articles icon indicating copy to clipboard operation
articles copied to clipboard

[번역] React의 궁극적 원리 - 소프트웨어 디자인, 아키텍쳐 & 좋은 예시 1

Open sbyeol3 opened this issue 3 years ago • 3 comments

원문 : 2021.01.18에 작성된 Tao of React - Software Design, Architecture & Best Practices

저는 React를 2016년부터 사용했지만 여전히 어플리케이션 구조와 디자인에 대한 모범적인 예시는 한 건도 없습니다. 마이크로 수준에 대한 모범 사례는 있지만 대부분의 팀들은 각자 자신만의 아키텍쳐를 구축합니다. 물론, 모든 비즈니스와 어플리케이션에 적용될 수 있는 최고의 예시는 없습니다. 그러나 생산적인 코드베이스를 만들기 위해 따라야 하는 일반적인 규칙들은 존재합니다.

소프트웨어 아키텍쳐와 디자인의 목적은 소프트웨어를 생산적이고 유연하게 유지하는 것에 있습니다. 개발자들은 소프트웨어의 핵심부분을 재작성하지 않고 효과적으로 작업하고 수정해야만 합니다. 이 글은 저와 제가 함께 일했던 팀에서 꽤 효과적이라고 증명되었던 원리와 규칙들의 모음집입니다.

컴포넌트, 어플리케이션 구조, 테스팅, 스타일링, 상태 관리와 데이터 fetching 등에 대한 사례들을 개략적으로 설명합니다. 예제의 일부는 구현보다는 원칙에 집중하고자 지나치게 단순화되어 있으니 참고하세요.

이 글의 모든 것을 절대적인 것이 아닌 하나의 의견으로서 받아들이길 바랍니다. 소프트웨어를 만드는 데에는 여러 방법이 존재합니다.

컴포넌트 (Components)

함수형 컴포넌트를 지향하라 (Favor Functional Components)

함수형 컴포넌트를 지향하라 - 간단한 표현입니다. 라이프사이클 메소드, 생성자, 보일러플레이트도 없습니다. 클래스 컴포넌트를 사용할 때보다 가독성을 잃지 않으면서 적은 표현으로 같은 로직을 표현할 수 있습니다. error boundary가 필요한 것이 아니라면 이 방식을 따라야만 합니다. 여러분의 머릿 속에 간직해야 할 정신적인 모델이 훨씬 작아질 것입니다.

// 👎 클래스 컴포넌트는 장황합니다
class Counter extends React.Component {
  state = {
    counter: 0,
  }

  constructor(props) {
    super(props)
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick() {
    this.setState({ counter: this.state.counter + 1 })
  }

  render() {
    return (
      <div>
        <p>counter: {this.state.counter}</p>
        <button onClick={this.handleClick}>Increment</button>
      </div>
    )
  }
}

// 👍 함수형 컴포넌트가 읽기도 쉽고 유지하기도 좋죠
function Counter() {
  const [counter, setCounter] = useState(0)

  handleClick = () => setCounter(counter + 1)

  return (
    <div>
      <p>counter: {counter}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  )
}

일관된 컴포넌트를 작성하라 (Write Consistent Components)

여러분의 컴포넌트의 스타일을 일관되게 유지하세요. 같은 위치에 헬퍼 함수를 두고, 같은 방식으로 export하고 동일한 네이밍 패턴을 따르세요. 어떤 접근 방식이 다른 방식보다 나은 점은 없습니다. 파일 아래에서 export하든지, 컴포넌트 정의에서 바로 하든지 상관없습니다. 하나를 골라 그 방식을 고수하세요.

컴포넌트의 이름을 지어라 (Name Components)

여러분의 컴포넌트에 항상 이름을 지어주세요. 이는 나중에 여러분이 에러 스택을 추적하거나 React Dev tools를 사용할 때 도움이 됩니다. 또한 파일 내에 컴포넌트 이름이 있다면 여러분이 개발할 때 컴포넌트를 찾기가 더 쉽습니다.

헬퍼 함수를 구성하라 (Organize Helper Functions)

컴포넌트에 대해 클로져를 가질 필요 없는 헬퍼 함수는 바깥으로 옮겨야 합니다. 이상적인 위치는 컴포넌트가 정의된 곳보다 먼저 나오는 것이 좋습니다. 그래야 위에서부터 아래로 파일을 읽기가 좋기 때문입니다. 이 룰은 컴포넌트를 깨끗하게 해주며 컴포넌트 내에서 필요한 것만 남겨둡니다.

// 👎 클로져를 유지할 필요 없는 복잡한 함수를 피하세요.
function Component({ date }) {
  function parseDate(rawDate) {
    ...
  }

  return <div>Date is {parseDate(date)}</div>
}

// 👍 헬퍼함수는 컴포넌트 위에 두세요
function parseDate(date) {
  ...
}

function Component({ date }) {
  return <div>Date is {parseDate(date)}</div>
}

여러분은 정의한 함수 내에 적은 양의 헬퍼함수를 유지하고 싶을 수 있습니다. 가능한 한 헬퍼 함수는 밖으로 옮겨두고 인자로 상태값을 전달하세요. 로직을 입력값에 의존하는 순수함수로 구성하는 것이 버그를 찾기가 용이하며 확장성이 좋습니다.

// 👎 헬퍼 함수가 컴포넌트의 상태에서 읽히면 안 됩니다.
export default function Component() {
  const [value, setValue] = useState('')

  function isValid() {
    // ...
  }

  return (
    <>
      <input
        value={value}
        onChange={e => setValue(e.target.value)}
        onBlur={validateInput}
      />
      <button
        onClick={() => {
          if (isValid) {
            // ...
          }
        }}
      >
        Submit
      </button>
    </>
  )
}

// 👍 헬퍼함수는 밖으로 빼서 필요한 값만 전달하세요.
function isValid(value) {
  // ...
}

export default function Component() {
  const [value, setValue] = useState('')

  return (
    <>
      <input
        value={value}
        onChange={e => setValue(e.target.value)}
        onBlur={validateInput}
      />
      <button
        onClick={() => {
          if (isValid(value)) {
            // ...
          }
        }}
      >
        Submit
      </button>
    </>
  )
}

마크업을 하드코딩하지 마라 (Don't Hardcode Markup)

네비게이션, 필터, 목록에 대한 마크업을 하드코딩하지 마세요. 대신 객체 배열을 사용하여 아이템을 순회하세요. 즉, 한 곳에서만 마크업과 아이템을 변경하면 됩니다.

// 👎 하드코딩된 마크업은 관리하기가 어렵습니다.
function Filters({ onFilterClick }) {
  return (
    <>
      <p>Book Genres</p>
      <ul>
        <li>
          <div onClick={() => onFilterClick('fiction')}>Fiction</div>
        </li>
        <li>
          <div onClick={() => onFilterClick('classics')}>
            Classics
          </div>
        </li>
        <li>
          <div onClick={() => onFilterClick('fantasy')}>Fantasy</div>
        </li>
        <li>
          <div onClick={() => onFilterClick('romance')}>Romance</div>
        </li>
      </ul>
    </>
  )
}

// 👍 loop와 객체 배열을 사용하세요
const GENRES = [
  {
    identifier: 'fiction',
    name: Fiction,
  },
  {
    identifier: 'classics',
    name: Classics,
  },
  {
    identifier: 'fantasy',
    name: Fantasy,
  },
  {
    identifier: 'romance',
    name: Romance,
  },
]

function Filters({ onFilterClick }) {
  return (
    <>
      <p>Book Genres</p>
      <ul>
        {GENRES.map(genre => (
          <li>
            <div onClick={() => onFilterClick(genre.identifier)}>
              {genre.name}
            </div>
          </li>
        ))}
      </ul>
    </>
  )
}

컴포넌트 길이 (Component Length)

리액트 컴포넌트는 props를 받고 마크업을 리턴하는 함수일 뿐입니다. 컴포넌트도 동일한 소프트웨어 설계 원칙을 준수합니다. 함수가 너무 많은 일을 한다면, 로직의 일부를 꺼내서 다른 함수를 호출하도록 하는 것처럼 컴포넌트도 마찬가지입니다. 너무 많은 기능을 하는 컴포넌트라면 작은 컴포넌트들로 쪼개서 호출하도록 하세요.

마크업의 어느 부분이 반복문을 사용하거나 조건문을 사용하여 복잡하다면 밖으로 빼두세요. 커뮤니케이션과 데이터를 위한 props와 callback에 의존하세요. 코드의 라인은 객관적인 척도가 아닙니다. 대신에 책임감과 추상성에 대해 생각해보세요.

JSX에서 주석을 사용하라 (Write Comments in JSX)

더 명확하게 해야 할 때 코드 블록을 열고 추가적인 정보를 제공하세요. 마크업은 로직의 일부이므로 명확하게 할 때 주석을 작성하세요.

function Component(props) {
  return (
    <>
      {/* If the user is subscribed we don't want to show them any ads */}
      {user.subscribed ? null : <SubscriptionPlans />}
    </>
  )
}

Error Boundary를 사용하라 (Use Error Boundaries)

한 컴포넌트에서 에러가 발생한다고 해서 전체 UI가 깨지면 안됩니다. 심각한 오류가 발생하여 페이지를 통째로 내리거나 리다이렉트하게 되는 경우는 드문 일입니다. 대부분의 경우 스크린에서 에러가 발생한 부분만 숨기는 것이 좋습니다.

데이터를 다루는 함수에서 여러 try/catch 문을 사용할 수도 있습니다. 최상위 레벨이 아니더라도 사용하고자 하는 곳에 Error boundary를 두세요. 별개로 존재할 수 있는 스크린의 element를 감싸면 이어지는 장애를 방지할 수 있습니다.

function Component() {
  return (
    <Layout>
      <ErrorBoundary>
        <CardWidget />
      </ErrorBoundary>

      <ErrorBoundary>
        <FiltersWidget />
      </ErrorBoundary>

      <div>
        <ErrorBoundary>
          <ProductList />
        </ErrorBoundary>
      </div>
    </Layout>
  )
}

props를 비구조화하라 (Destructure Props)

대부분의 리액트 컴포넌트는 함수일 뿐입니다. props를 받아 마크업을 리턴하죠. 일반 함수에서 인수를 사용하면 인수가 직접 전달되므로 리액트 컴포넌트에도 동일한 원리를 적용하면 됩니다. 어디서나 props를 반복할 필요가 없습니다.

props를 비구조화하지 않는 이유는 아마 외부에서 온 것과 내부 상태를 구분짓기 위함일 것입니다. 그러나 일반 함수에서 인자와 변수의 구분이 따로 있지 않습니다. 불필요한 패턴을 만들지 마세요.

// 👎 컴포넌트 어디에서든지 props를 반복하지 마세요
function Input(props) {
  return <input value={props.value} onChange={props.onChange} />
}

// 👍 props를 비구조화하여 바로 사용하세요
function Component({ value, onChange }) {
  const [state, setState] = useState('')

  return <div>...</div>
}

props의 수 (Number of Props)

컴포넌트가 받는 props의 개수가 몇개여야 하는지에 대한 질문은 주관적입니다. 컴포넌트가 가지는 props의 수는 컴포넌트가 하는 것과 상관 있습니다. 더 많이 props를 전달할 수록 컴포넌트의 책임도 함께 커집니다. props가 너무 많다면 이는 컴포넌트가 너무 많은 일을 한다는 신호입니다.

저는 5개 이상의 props를 받는다면 컴포넌트를 나누어야 할지를 생각해 봅니다. 일부의 경우에서 많은 데이터가 필요할 수도 있습니다. 예를 들어 input 필드는 많은 props를 받아야 합니다. 다른 경우에서는 나누어야 한다는 신호가 되기도 합니다.

주의 : 컴포넌트가 가지는 props가 많을 수록 리렌더링되어야 할 이유도 많아집니다!

원시값 대신 객체를 전달하라 (Pass Objects Instead of Primitives)

props의 양을 줄이는 방법 중 하나는 원시값 대신에 객체를 전달하는 것입니다. 이름, 이메일, 설정 등을 바로 전달하는 것 대신에 묶어서 객체로 전달할 수 있죠. 이는 사용자가 추가적인 값을 전달할 때 변경해야 하는 부분을 줄여줍니다.

타입스크립트를 사용하면 이를 더 쉽게 해줍니다.

// 👎 관련된 값이라면 하나씩 전달하지 마세요
<UserProfile
  bio={user.bio}
  name={user.name}
  email={user.email}
  subscription={user.subscription}
/>

// 👍 대신에 객체로 묶어 보내세요
<UserProfile user={user} />

조건부 렌더링 (Conditional Rendering)

일부 상황에서 조건부 렌더링을 위해 단락 연산자(&&)를 사용하는 것은 역효과를 낳을 수 있으며 여러분의 UI에 원치않는 0이 보일 수 있습니다. 이런 상황을 피하기 위해 삼항 연산자를 사용하세요. 다만 좀 더 장황해질 수 있습니다. 단락 연산자는 코드의 양을 줄여주고 이는 아주 좋습니다. 삼항 연산자는 더 복잡하지만 잘못될 여지는 없습니다. 게다가 다른 조건을 추가하게 되더라도 변경사항이 적습니다.

// 👎 단락 연산자를 사용하는 것을 지양하세요
function Component() {
  const count = 0

  return <div>{count && <h1>Messages: {count}</h1>}</div>
}

// 👍 대신 삼항 연산자를 사용하세요
function Component() {
  const count = 0

  return <div>{count ? <h1>Messages: {count}</h1> : null}</div>
}

중첩된 삼항 연산자를 지양하라 (Avoid Nested Ternary Operators)

삼항 연산자는 한 단계를 지나가게 되면 읽기 어려워집니다. 공간을 절약하는 것처럼 보이더라도 여러분의 의도를 분명히 하는 것이 좋습니다.

// 👎 JSX에서 중첩된 삼항 연산자는 읽기 어렵습니다.
isSubscribed ? (
  <ArticleRecommendations />
) : isRegistered ? (
  <SubscribeCallToAction />
) : (
  <RegisterCallToAction />
)

// 👍 컴포넌트 내에 두세요
function CallToActionWidget({ subscribed, registered }) {
  if (subscribed) {
    return <ArticleRecommendations />
  }

  if (registered) {
    return <SubscribeCallToAction />
  }

  return <RegisterCallToAction />
}

function Component() {
  return (
    <CallToActionWidget
      subscribed={subscribed}
      registered={registered}
    />
  )
}

배열은 별개의 컴포넌트로 옮겨라 (Move Lists in a Separate Component)

map 함수를 사용하려 아이템의 배열을 순회하는 것은 흔히 사용하는 방법입니다. 그러나 여러 마크업을 가진 컴포넌트 내에서 추가적인 여백이나 map 문법은 가독성을 해칩니다. map으로 순회해야 한다면 마크업이 많지 않더라도 그 부분을 따로 빼서 컴포넌트로 분리하세요. 부모 컴포넌트는 자세한 부분을 알 필요없이 배열을 나타내기만 하면 됩니다.

컴포넌트의 주 역할이 보여주기라면 마크업에 루프를 두기만 하세요. 컴포넌트마다 하나의 매핑을 두도록 하세요. 마크업이 너무 길어지거나 복잡해진다면 따로 빼두는 것이 좋습니다.

// 👎 반복문과 다른 마크업을 같이 두지 마세요.
function Component({ topic, page, articles, onNextPage }) {
  return (
    <div>
      <h1>{topic}</h1>
      {articles.map(article => (
        <div>
          <h3>{article.title}</h3>
          <p>{article.teaser}</p>
          <img src={article.image} />
        </div>
      ))}
      <div>You are on page {page}</div>
      <button onClick={onNextPage}>Next</button>
    </div>
  )
}

// 👍 배열에 대한 컴포넌트를 따로 빼세요
function Component({ topic, page, articles, onNextPage }) {
  return (
    <div>
      <h1>{topic}</h1>
      <ArticlesList articles={articles} />
      <div>You are on page {page}</div>
      <button onClick={onNextPage}>Next</button>
    </div>
  )
}

비구조화를 할 때 기본 props를 할당하라 (Assign Default Props When Destructuring)

기본 prop 값을 설정하는 방법은 컴포넌트에 defaultProps를 두는 것입니다. 이는 컴포넌트 함수와 인자 값이 함께 있지 않다는 것을 의미하기도 합니다. props를 비구조화할 때 기본값을 바로 할당하는 것을 지향하세요. 이는 코드를 위에서부터 읽기 편하게 하며 정의와 값을 함께 둡니다.

// 👎 default props를 함수 밖에 정의하지 마세요
function Component({ title, tags, subscribed }) {
  return <div>...</div>
}

Component.defaultProps = {
  title: '',
  tags: [],
  subscribed: false,
}

// 👍 인자 목록에 함께 두세요
function Component({ title = '', tags = [], subscribed = false }) {
  return <div>...</div>
}

중첩된 렌더 함수를 지양하라 (Avoid Nested Render Functions)

컴포넌트나 로직으로부터 마크업을 분리하고 싶을 때 같은 컴포넌트 내에 마크업 함수를 두지 마세요. 컴포넌트는 함수일 뿐입니다. 이렇게 함수를 정의하게 되면 부모 함수 내에서 둥지를 트는 셈입니다. 이는 부모의 상태나 데이터를 모두 접근할 수 있다는 것을 의미하는데요. 컴포넌트 내에서 함수가 하는 일이 없음에도 내부에 있다면 코드의 가독성을 해치게 됩니다.

마크업 컴포넌트로 옮기고 이름을 짓고 클로져 대신에 props로 전달하세요.

// 👎 중첩되도록 렌더함수를 만들지 마세요
function Component() {
  function renderHeader() {
    return <header>...</header>
  }
  return <div>{renderHeader()}</div>
}

// 👍 각 컴포넌트로 분리하세요
import Header from '@modules/common/components/Header'

function Component() {
  return (
    <div>
      <Header />
    </div>
  )
}

sbyeol3 avatar Dec 23 '21 07:12 sbyeol3

상태 관리

리듀서를 사용하라 (Use Reducers)

때때로 상태의 변경사항을 표현하거나 관리할 수 있는 좋은 방법이 필요할 수 있습니다. 외부 라이브러리를 사용하기 전에 useReducer로 시작해보세요. 이는 복잡한 상태 관리를 하는 좋은 메커니즘이며 서드파티 의존성이 필요하지 않습니다. React Context와 타입스크립트를 조합하면 useReducer는 정말 강력해질 수 있습니다. 안타깝게도 널리 사용되지는 않습니다. 사람들은 여전히 서드파티 라이브러리를 사용합니다.

상태의 여러 조각들이 필요하다면 리듀서에 옮겨보세요.

// 👎 너무 많은 분리된 상태 조각들을 사용하지 마세요
const TYPES = {
  SMALL: 'small',
  MEDIUM: 'medium',
  LARGE: 'large'
}

function Component() {
  const [isOpen, setIsOpen] = useState(false)
  const [type, setType] = useState(TYPES.LARGE)
  const [phone, setPhone] = useState('')
  const [email, setEmail] = useState('')
  const [error, setError] = useSatte(null)

  return (
    ...
  )
}

// 👍 하나로 합쳐서 리듀서에서 사용하세요
const TYPES = {
  SMALL: 'small',
  MEDIUM: 'medium',
  LARGE: 'large'
}

const initialState = {
  isOpen: false,
  type: TYPES.LARGE,
  phone: '',
  email: '',
  error: null
}

const reducer = (state, action) => {
  switch (action.type) {
    ...
    default:
      return state
  }
}

function Component() {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    ...
  )
}

고차 컴포넌트나 렌더 props보다는 Hook을 선호하라 (Prefer Hooks to HOCs and Render Props)

어떤 경우에서는 컴포넌트를 강화하거나 외부 상태에 접근하도록 해야 합니다. 일반적으로 HOCs, render props, hook 이렇게 세 가지 방식이 있습니다. Hook은 그런 구성을 만들기에 가장 효과적인 방법으로 증명되었습니다. 철학적인 관점에서 보면 컴포넌트는 다른 함수를 사용하는 함수입니다. 서로 충돌하지 않고 여러 외부 기능들을 활용할 수 있게 합니다. hook의 개수와 관계없이 각 값들이 어디서 오는지 알 수 있죠.

HOC를 사용하면 props로 값을 얻습니다. 이는 부모 컴포넌트에서 오는지 감싸진 어딘가에서 값이 오는지 명확하지 않습니다. 또한 여러 props를 연쇄적으로 사용하면 에러를 발생할 수도 있습니다.

Render props는 많은 공백과 나쁜 가독성을 야기시킵니다. 같은 트리에서 render props로 여러 컴포넌트를 중첩되게 사용하면 더 나쁩니다. 또한 마크업 자체에서 값이 노출되므로 로직을 그 곳에서 작성하거나 전달해야만 합니다.

간단한 값들로 작업할 때 Hook을 사용하면 추적하기가 쉽고 JSX에서 개입할 필요가 없습니다.

// 👎 render props를 지양하라
function Component() {
  return (
    <>
      <Header />
      <Form>
        {({ values, setValue }) => (
          <input
            value={values.name}
            onChange={e => setValue('name', e.target.value)}
          />
          <input
            value={values.password}
            onChange={e => setValue('password', e.target.value)}
          />
        )}
      </Form>
      <Footer />
    </>
  )
}

// 👍 단순함과 가독성을 위해 hook을 지향하세요
function Component() {
  const [values, setValue] = useForm()

  return (
    <>
      <Header />
      <input
        value={values.name}
        onChange={e => setValue('name', e.target.value)}
      />
      <input
        value={values.password}
        onChange={e => setValue('password', e.target.value)}
      />
    )}
      <Footer />
    </>
  )
}

date fetching 라이브러리를 사용하라 (Use Data Fetching Libraries)

종종 상태에서 관리해야 하는 데이터는 API로부터 받습니다. 메모리에 데이터를 유지하고 싶다면 데이터를 업데이트하고 여러 곳에서 접근해야 합니다. React Query와 같은 모던 데이터 fetching 라이브러리는 외부 데이터를 관리하는 충분한 메커니즘을 제공합니다. 이를 캐싱하고 무효화하며 다시 가져올 수도 있습니다. 또한 데이터를 보낼 때도 사용되며 다른 데이터를 새로 가져오게 합니다. 클라이언트 상태 개념이 내장되어 있으므로 Apollo와 같은 GraphQL 클라이언트를 사용한다면 더욱 쉽습니다.

상태 관리 라이브러리 (State Management Libraries)

대부분의 경우 상태관리 라이브러리는 필요 없습니다. 복잡한 상태를 관리해야하는 큰 어플리케이션에서는 필요합니다. 이 주제에 대해 많은 가이드들이 존재하므로 저는 이런 상황에서 사용했던 Recoil과 Redux 두 라이브러리만 언급하도록 하겠습니다.

sbyeol3 avatar Dec 23 '21 09:12 sbyeol3

컴포넌트 심상 모델

Container & Presentational

주된 사고방식은 컴포넌트를 컨테이너 컴포넌트 두 가지로 나누는 것입니다. 똑똑한 것과 멍청한 것으로 생각할 수도 있죠. 이 사고는 어떤 컴포넌트는 단지 props와 함께 부모 컴포넌트로부터 호출되며 기능이나 상태가 없다는 것에서 비롯되는데요. 컨테이너 컴포넌트는 비즈니스 로직을 포함하고 데이터를 가져오며 상태를 관리한다는 것입니다.

이 심상 모델은 백엔드 어플리케이션을 위한 MVC 구조입니다. 어디서나 작동하기에 일반적이며 잘못해서는 안 됩니다. 그러나, 모던 UI 어플리케이션에서는 이 패턴에 미치지 못합니다. 모든 로직을 일부 컴포넌트에만 넣게 되면 이 컴포넌트들은 흉물스러워집니다. 너무 많은 역할을 갖게 되고 관리하기 어려워집니다. 어플리케이션이 커지면서 자라나는 복잡성을 일부 집중된 곳에만 둔다면 유지보수하기 어렵습니다.

Stateless & Stateful

컴포넌트를 상태가 있는 것과 없는 것으로 생각해보세요. 이 모델은 적은 컴포넌트들이 많은 복잡성을 맡게 된다는 것을 암시합니다. 대신에 앱 전반에 퍼져야 합니다.

데이터는 사용되는 곳 근처에 있어야 합니다. 만약 여러분이 GraphQL 클라이언트를 사용한다면 데이터를 보여주는 컴포넌트에서 데이터를 가져와야 합니다. 탑 레벨이 아니더라도 말이죠. 컨테이너에 대해 생각하지 말고 책임에 대해 생각하세요. 상태를 유지하기 위해 가장 논리적인 컴포넌트가 무엇일지 생각해보세요.

예를 들어 <Form /> 컴포넌트가 폼에 대한 데이터를 갖고 있다고 합시다. <Input />은 데이터를 받고 변경이 일어날 때 콜백을 호출해야만 합니다. <Button />은 눌렸을 때 폼에게 알려주고 처리할 수 있도록 합니다.

폼에서 유효셩 검증은 누가 할까요? 인풋 필드가 해야 할까요? 이 컴포넌트가 여러분의 어플리케이션의 비즈니스 로직에 대해 알고 있다는 것을 의미합니다. 에러가 발생했을 때 폼에게 어떻게 알려주어야 할까요? 또한 에러 상태를 언제 리프레쉬하며 폼이 그걸 알 수 있을까요? 오류가 있는데 폼을 제출하려고 하면 무슨 일이 발생할까요?

이런 질문들에 직면했다면 여러분들은 너무 많은 책임들이 뒤섞여 있다는 것을 알아야 합니다. 이 경우에서 인풋은 stateless하게 두어야 하고 폼으로부터 에러메시지를 받도록 해야 합니다.

sbyeol3 avatar Dec 23 '21 10:12 sbyeol3

어플리케이션 구조

Route/Module로 그룹화하라 (Group by Route/Module)

컨테이너와 컴포넌트로 그루핑하는 것은 어플리케이션 탐색을 어렵게 만듭니다. 어떤 컴포넌트가 여러분이 친숙하다고 느끼는 곳에 속함을 이해해야 합니다. 모든 컴포넌트는 같지 않습니다. 어떤 컴포넌트는 전역으로 쓰이고 어떤 컴포넌트는 특정 부분을 위해 생성됩니다. 이 구조는 작은 프로젝트 단위에 적용하기 좋습니다. 그러나 어느 수준을 넘어가면 관리하기가 어렵습니다.

// 👎 기술적으로 세세하게 그룹화하지 마세요
├── containers
|   ├── Dashboard.jsx
|   ├── Details.jsx
├── components
|   ├── Table.jsx
|   ├── Form.jsx
|   ├── Button.jsx
|   ├── Input.jsx
|   ├── Sidebar.jsx
|   ├── ItemCard.jsx

// 👍 모듈과 도메인으로 그룹화하세요
├── modules
|   ├── common
|   |   ├── components
|   |   |   ├── Button.jsx
|   |   |   ├── Input.jsx
|   ├── dashboard
|   |   ├── components
|   |   |   ├── Table.jsx
|   |   |   ├── Sidebar.jsx
|   ├── details
|   |   ├── components
|   |   |   ├── Form.jsx
|   |   |   ├── ItemCard.jsx

시작부터 route/module로 그룹화하세요. 이는 어플리케이션이 변경되거나 커지는 것을 잘 지원합니다. 요점은 어플리케이션이 아키텍처를 빠르게 확장하지 않도록 하는 것입니다. 컴포넌트와 컨테이너 기반으로 한다면 확장이 빨리 발생합니다. 모듈 기반 구조는 확장하기가 쉽습니다. 복잡성을 증가시키지 않고 상위에 모듈을 추가하기만 하면 됩니다.

컨테이너/컴포넌트 구조는 잘못된 구조는 아니지만 너무 일반적입니다. 읽는 사람에게 React를 사용한다는 것 말고는 아무것도 알려주지 않습니다.

Common Module을 만들어라

버튼, 인풋, 카드와 같은 컴포넌트들은 여러 곳에서 사용됩니다. 모듈 기반 구조를 사용하지 않더라도 빼두는 것이 좋습니다. 스토리북을 사용하고 있지 않더라도 일반적으로 사용되는 컴포넌트들이 무엇인지 볼 수 있습니다. 이는 중복을 피하게끔 해줍니다. 여러분은 팀원들이 각자만의 버튼을 만드는 것을 원치 않으시겠죠. 불행히도 이 문제는 잘못 구조화된 프로젝트에서 굉장히 자주 발생합니다.

절대경로를 사용하라 (Use Absolute Paths)

쉽게 변경할 수 있는 것이 프로젝트 구조의 기본입니다. 절대경로는 여러분이 컴포넌트를 이동하더라도 변경할 필요가 없습니다. 또한 어디서 오는 모듈인지 바로 알 수 있게 해주죠.

// 👎 상대경로를 사용하지 마세요
import Input from '../../../modules/common/components/Input'

// 👍 절대경로는 변하지 않습니다
import Input from '@modules/common/components/Input'

내부 모듈이라는 의미로 저는 @ 접두사를 사용하는데 ~로 사용하는 경우도 봤습니다.

외부 컴포넌트를 감싸라 (Wrap External Components)

너무 많은 서드파티 컴포넌트를 직접적으로 임포트하지 않도록 하세요. 그 컴포넌트들을 감싸는 어댑터를 만듦으로써 필요할 때 API를 수정할 수 있습니다. 또한 하나의 장소에서 라이브러리를 수정할 수 있습니다.

이는 시맨틱 UI나 유틸리티 컴포넌트에도 해당하는 말입니다. 여러분이 할 수 있는 가장 간단한 방법은 common 모듈에서 reexport함으로써 동일한 장소에서 내보내는 것입니다. 컴포넌트는 우리가 사용하는 date picker가 어떤 라이브러리를 사용하는지 알 필요 없이 존재하면 됩니다.

// 👎 직접 임포트 하지 마세요
import { Button } from 'semantic-ui-react'
import DatePicker from 'react-datepicker'

// 👍 컴포넌트를 export하고 내부 모듈에서 참조하세요
import { Button, DatePicker } from '@modules/common/components'

컴포넌트를 폴더로 이동하라 (Move Components in Folders)

저의 리액트 어플리케이션에서 각 모듈에 대해 컴포넌트 폴더를 생성했습니다. 컴포넌트를 생성해야 할 때마다 폴더를 먼저 생성합니다. 스타일이나 테스트에 관해 추가적인 파일이 필요할 때는 해당 폴더에 넣어주도록 합니다.

일반적인 상황에서 index.js 파일을 만들어 리액트 컴포넌트를 내보내는 것이 좋습니다. import 경로를 변경할 필요도 없고 import Form from 'components/UserForm/UserForm'처럼 중복되게 작성할 필요도 없습니다. 그러나 여러 개를 열었을 때 헷갈리지 않도록 컴포넌트 파일을 폴더 이름 그대로 두는 것이 좋습니다.

sbyeol3 avatar Dec 24 '21 09:12 sbyeol3