이 글은 Tao of React 를 번역한 내용입니다.
원글: https://alexkondov.com/tao-of-react/
저는 2016년부터 리액트를 사용해서 일하고 있습니다. 하지만 어플리케이션 구조와 디자인에 대한 단 하나의 best practice가 아직도 존재하지 않습니다.
마이크로 레벨에서의 best practice는 찾아볼 수 있지만, 대부분의 팀들은 본인들만의 설계를 가지고 있습니다.
물론 모든 비즈니스와 어플리케이션에 적용 가능한 best practice는 존재하지 않습니다. 하지만 생산적인 코드 베이스를 작성하기 위해 공통적으로 적용할 수 있는 룰은 몇 가지 있습니다.
소프트웨어의 구조와 설계의 목적은 생산성과 유연함을 유지하기 위함입니다. 개발자들은 핵심 부분을 다시 작성하지 않고도 효율적으로 수정 작업을 할 수 있어야 합니다.
이 글은 제가 일해온 팀들에서 효율적이라고 증명된 원칙과 룰들을 모아놓은 글입니다. 컴포넌트, 어플리케이션 구조, 테스트, 스타일, 상태 관리, 그리고 데이터 fetching에 대한 좋은 사례들을 서술했습니다. 몇몇 예시들의 경우 구현이 아니라 원칙에만 집중할 수 있도록 많이 단순화 시켰다는 점을 참고 바랍니다.
여기 있는 모든 내용들은 하나의 의견이며, 절대적인 것이 아닙니다. 소프트웨어를 만드는 방법은 하나 이상입니다.
클래스형 컴포넌트(Class component)보다 함수형 컴포넌트 사용을 우선시하세요. 더 단순한 syntax로 코드를 작성할 수 있습니다. 라이프사이클 method, 생성자(constructor) 혹은 보일러플레이트(boilerplate)를 작성하지 않아도 됩니다. 같은 로직을 가독성을 잃지 않으면서 더 적은 코드로 작성할 수 있습니다.
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>
)
}
컴포넌트 작성 시 같은 스타일을 유지하세요. 헬퍼 함수를 같은 위치에 작성하고, 같은 방법으로 export하고, 동일한 네이밍 패턴을 사용하세요.
특정한 방법이 다른 방법보다 특별히 이점을 가지지는 않습니다.
파일의 마지막에서 export하는지, 컴포넌트 정의 시 바로 export 하는지는 상관 없습니다. 한 가지 방법을 정해서 그것만 일관되게 사용하세요.
항상 컴포넌트에 이름을 붙이세요. 에러 stack trace를 읽거나, React Dev Tools를 사용할 때 도움이 됩니다.
또한 파일 내에 컴포넌트명이 있으면 개발 중에 해당 컴포넌트가 어디에 있는지 찾기 쉬워집니다.
// 👎 이런 방식은 피하세요
export default () => <form>...</form>
// 👍 함수(컴포넌트)에 이름을 붙이세요
export default function Form() {
return <form>...</form>
}
컴포넌트의 클로저를 가지고 있을 필요가 없는 헬퍼 함수들은 컴포넌트 밖으로 옮겨야 합니다. 파일을 위에서부터 아래로 읽을 수 있도록 컴포넌트 정의 이전에 위치하도록 하는 것이 이상적입니다.
그렇게 하면 컴포넌트 내에 필요 없는 내용들을 줄이고, 꼭 거기에 있어야 하는 내용들만 남길 수 있습니다.
// 👎 클로저를 가질 필요 없는 중첩 함수를 피하세요
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>
}
컴포넌트 정의 내에는 최소한의 헬퍼 함수들만 유지하세요. 최대한 바깥으로 옮겨두고, state는 argument로 전달하세요.
로직을 input 인자에만 의존하는 순수 함수(pure function)로 구성하면 버그를 찾거나 함수를 확장하기에 더 좋습니다.
// 👎 헬퍼 함수는 컴포넌트의 state를 바로 읽어서는 안됩니다
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>
</>
)
}
네비게이션, 필터, 리스트에 마크업을 하드코딩 하지 마세요. 값을 설정하기 위한 object를 생성해서 그 object의 값을 순회하는 방법을 사용하세요.
이렇게 하면 한 곳에서 전체 마크업과 아이템을 수정할 수 있습니다.
// 👎 하드코딩 된 마크업은 관리하기에 더 어렵습니다
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>
</>
)
}
리액트 컴포넌트는 단지 props를 받아서 마크업을 반환하는 하나의 함수이기 때문에, 소프트웨어 디자인 원칙들이 동일하게 적용됩니다.
하나의 함수가 너무 많은 일을 하고 있다면 일부 로직을 분리해서 또 다른 함수를 호출하도록 하는데요, 이것은 컴포넌트에도 동일하게 적용됩니다. 컴포넌트가 너무 많은 기능을 가지고 있다면 더 작은 컴포넌트들로 쪼개서 그것들을 호출하도록 하세요.
만약 마크업의 일부가 루프나 조건문을 가지고 있는 등 복잡하게 구성되어 있다면, 그 부분을 컴포넌트로 분리하세요.
컴포넌트 간 통신과 데이터 전달은 props와 callback을 사용하세요. 코드의 줄 수는 객관적인 측정 방법이 아닙니다. 코드 줄 수를 줄이는 것 보다는 컴포넌트의 책임과 추상화에 집중하세요.
더 명확한 설명이 필요한 부분이 있다면, 코드 블럭을 열어서 추가적인 정보를 제공하세요. 마크업도 로직의 한 부분입니다. 더 명확한 정보를 작성할 필요가 있다는 생각이 든다면, 그렇게 하세요.
function Component(props) {
return (
<>
{/* 구독한 사용자라면 광고를 보여주지 않음 */}
{user.subscribed ? null : <SubscriptionPlans />}
</>
)
}
하나의 컴포넌트에서 발생한 에러가 전체 UI를 망가뜨려서는 안됩니다. 전체 페이지를 노출하지 않거나 다른 페이지로 리다이렉트 시켜야 하는 치명적인 에러 케이스는 많지 않습니다. 대부분의 경우에는 에러가 발생한 컴포넌트만 화면에서 숨기는 걸로 충분합니다.
데이터를 다루는 함수 안에는 여러 개의 try/catch문이 사용될 수도 있습니다. Error boundary를 제일 바깥쪽 top level에만 사용하지 마세요. 화면 내에서 분리되어 존재하는 각 컴포넌트를 error boundary로 감싸서 연쇄적인 오류를 방지하세요.
function Component() {
return (
<Layout>
<ErrorBoundary>
<CardWidget />
</ErrorBoundary>
<ErrorBoundary>
<FiltersWidget />
</ErrorBoundary>
<div>
<ErrorBoundary>
<ProductList />
</ErrorBoundary>
</div>
</Layout>
)
}
대부분의 리액트 컴포넌트는 그저 props를 받아서 마크업을 반환하는 함수입니다. 일반적인 함수에서는 전달받은 인자를 그대로 사용하는데, 여기서도 그 원칙을 적용하는 것이 합당합니다. props
를 모든 곳에서 반복할 필요는 없습니다.
Props를 비구조화 하지 않는 중 하나는 내부와 외부 state를 구분하기 위함입니다. 하지만 일반적인 함수는 인자(arguments)와 내부 변수(variables)를 구분하지 않습니다. 불필요한 패턴을 생성하지 마세요.
// 👎 컴포넌트의 모든 곳에서 props를 반복하지 마세요
function Input(props) {
return <input value={props.value} onChange={props.onChange} />
}
// 👍 비구조화해서 값을 바로 사용하세요
function Component({ value, onChange }) {
const [state, setState] = useState('')
return <div>...</div>
}
하나의 컴포넌트가 몇 개의 props를 받아야 하는지는 주관적인 부분입니다. 컴포넌트가 가지는 props의 개수는 그 컴포넌트가 얼마나 많은 기능을 가지고 있는지와 연관됩니다. 더 많은 props를 전달할수록, 그 컴포넌트는 더 많은 책임을 가지게 되는 것입니다.
너무 많은 props를 가진다면, 그건 그 컴포넌트가 너무 많은 일을 하고 있다는 신호입니다.
저는 props가 5개 이상이라면 그 컴포넌트가 쪼개져야 하는지를 고려합니다. 단지 컴포넌트가 많은 데이터를 필요로 하는 경우도 있습니다. 예를 들어, input 필드의 경우 많은 props를 가질 수도 있습니다. 하지만 다른 경우에는 무언가가 분리되어야 한다는 신호일 수 있습니다.
Note: 컴포넌트가 많은 props를 가질 수록, rerender가 발생할 이유가 많아지는 것입니다.
Props의 개수를 조절하는 방법 중 하나는 원시 타입을 바로 넘기는 대신 object를 사용하는 것입니다. 사용자명, 이메일, 설정 데이터를 하나씩 넘기는 대신 object로 묶어서 전달하세요. 이렇게 하면 데이터에 필드가 추가되는 경우에도 수정해야하는 부분을 줄일 수 있습니다.
TypeScript를 사용하면 더욱 쉬워집니다.
// 👎 연관된 값은 하나씩 전달하지 마세요
<UserProfile
bio={user.bio}
name={user.name}
email={user.email}
subscription={user.subscription}
/>
// 👍 모든 데이터를 가지고 있는 object를 전달하세요
<UserProfile user={user} />
일부 상황에서는 short-circuit 연산자를 사용하면 의도치 않은 0
이 UI에 노출되는 역효과를 낳을 수도 있습니다. 이를 피하기 위해서 기본적으로 삼항 연산자를 사용하세요. 삼항 연산자가 short-circuit 연산자에 비해 갖는 유일한 단점은 단지 조금 더 장황하다는 것밖에 없습니다.
Short-circuit 연산자는 코드의 양을 줄여주고, 이는 언제나 훌륭한 일입니다. 삼항 연산자는 더 장황하지만, 잘못된 일이 발생할 가능성이 없습니다. 게다가 다른 조건을 추가하는 경우 코드 변경량이 더 적습니다.
// 👎 Short-circuit 연산자 사용을 피하세요
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>
}
삼항 연산자는 첫 번째 레벨 이후로는 읽기 어려워집니다. 당장은 공간을 줄이는 것처럼 보일 수 있어도, 결국엔 의도를 명확하고 확실하게 나타내는 것이 더 낫습니다.
// 👎 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}
/>
)
}
아이템 리스트를 순회하는 것은 흔히 사용되는 로직이며, 보통 map
함수를 사용합니다. 하지만 마크업 내용이 많은 컴포넌트의 경우, map
의 문법에 사용되는 추가적인 indentation은 가독성에 좋지 않습니다.
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의 기본값을 지정하는 방법 중 하나는 컴포넌트에 defaultProps
프로퍼티를 붙이는 것입니다. 하지만 이 경우에 컴포넌트 함수와 인자의 값이 서로 다른 곳에 위치하게 됩니다.
Props를 비구조화 할당할 때 기본값을 바로 지정하는 방법을 사용하세요. 코드를 여기저기 볼 필요 없이 위에서부터 아래로 읽기 쉬워지고, 정의와 값을 한 곳에 같이 둘 수 있습니다.
// 👎 함수 밖에서 defaultProps를 정의하지 마세요
function Component({ title, tags, subscribed }) {
return <div>...</div>
}
Component.defaultProps = {
title: '',
tags: [],
subscribed: false,
}
// 👍 인자 목록에 바로 기본값을 지정하세요
function Component({ title = '', tags = [], subscribed = false }) {
return <div>...</div>
}
컴포넌트나 로직에서 마크업을 분리할 때에는, 같은 컴포넌트 내에 존재하는 함수에 두지 마세요. 컴포넌트는 단지 함수일 뿐입니다. 이렇게하면 부모 내에 중첩된 함수를 정의하게 되는 것입니다.
이는 중첩된 함수가 부모 컴포넌트의 모든 state와 데이터에 접근할 수 있다는 의미입니다. 이것은 코드의 가독성을 안좋게 만듭니다. 그 함수가 컴포넌트 전체에서 어떤 일을 하고 있는 것인지 알기 어려워집니다.
분리된 컴포넌트를 생성해서 이름을 붙이고, 클로저 대신 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>
)
}
와 감사합니다:)