리액트 문법을 어느 정도 익히고 나면 다음 벽이 찾아온다. 돌아가긴 하는데 코드가 손대기 무서워지는 단계다. 컴포넌트 하나가 오백 줄까지 부풀고, 어디를 고치면 어디가 터질지 감이 안 잡히기 시작한다. 나도 처음 만든 실무 화면이 딱 그랬다. Dashboard 컴포넌트 하나가 데이터도 불러오고, 필터도 그리고, 표도 그리고, 모달도 띄웠다. 기능을 하나 추가하려고 열 때마다 스크롤을 한참 내려야 했고, 무엇보다 어디까지가 무슨 일을 하는 코드인지 나조차 헷갈렸다. 설계 원칙이란 거창한 게 아니라, 이렇게 되는 걸 미리 막는 몇 가지 습관이다.
단일 책임. 컴포넌트 하나는 한 가지 일만 한다. 잘 짜인 컴포넌트는 "이건 뭐 하는 애냐"고 물으면 한 문장으로 답이 나온다. UserCard는 사용자 한 명을 카드로 보여준다. SearchBar는 검색어를 입력받는다. 반대로 답이 "사용자를 불러와서 필터링하고 정렬해서 표로 보여주고 클릭하면 상세를 여는 애"라면, 그건 한 컴포넌트가 아니라 네다섯 개가 뭉쳐 있는 것이다. 앞의 Dashboard를 나는 결국 이렇게 쪼갰다.
function Dashboard() {
const users = useUsers();
return (
<div>
<SearchBar />
<UserFilter />
<UserTable users={users} />
</div>
);
}
이렇게 나누면 Dashboard는 조각들을 배치하는 일만 한다. 표의 모양을 고치고 싶으면 UserTable만 열면 되고, 다른 화면에서 검색창이 필요하면 SearchBar를 그대로 가져다 쓴다. 쪼개는 기준은 줄 수가 아니라 책임이다. 서른 줄짜리도 두 가지 일을 하면 쪼개는 게 맞고, 백 줄이라도 한 가지 일만 하면 그대로 둬도 된다.
합성. 상속이 아니라 조립으로 재사용한다. 다른 언어를 하다 온 사람은 공통 기능을 부모 클래스로 빼는 상속을 떠올린다. 리액트는 그 길을 거의 안 쓴다. 대신 컴포넌트 안에 다른 컴포넌트를 끼워 넣는 합성으로 조립한다. 그 핵심 도구가 children이다. 여는 태그와 닫는 태그 사이에 넣은 내용이 그대로 props.children으로 들어온다.
function Card({ children }) {
return <div className="card">{children}</div>;
}
<Card>
<h2>제목</h2>
<p>아무 내용이나 넣는다</p>
</Card>
Card는 자기 안에 뭐가 들어올지 미리 알 필요가 없다. 테두리와 그림자 같은 껍데기만 책임지고, 속을 채우는 건 쓰는 쪽에 맡긴다. 그래서 제목이 든 카드, 이미지가 든 카드, 폼이 든 카드를 Card 하나로 다 만든다. 카드 종류마다 새 컴포넌트를 찍어내지 않아도 되는 것이다.
props를 뚫고 내려보내는 짓을 children으로 피한다. 데이터를 필요한 자식에게 주려고 중간 컴포넌트들에 props를 줄줄이 이어 넘기는 걸 prop drilling(값을 여러 층 아래로 관통시켜 내려보내는 것)이라 한다. 세 층 네 층 내려가다 보면, 자기는 쓰지도 않는 값을 그저 아래로 전달만 하는 컴포넌트가 잔뜩 생긴다. 이럴 때 합성이 답이 되는 경우가 많다. 데이터를 아는 쪽에서 컴포넌트를 미리 만들어 children으로 꽂아버리면, 중간 층은 그게 뭔지 몰라도 그냥 자리만 내주면 된다.
function Layout({ children }) {
return <main>{children}</main>;
}
function Page() {
const user = useUser();
return (
<Layout>
<Profile user={user} />
</Layout>
);
}
Layout은 user를 몰라도 된다. Profile이 이미 user를 쥔 채로 children 자리에 들어오기 때문이다. 값을 중간에서 이어 나를 필요가 사라진다. 물론 앱 전역에서 필요한 값이라면 이보다 Context가 나을 때도 있지만, 그 얘기는 여기서 다룰 자리가 아니다. 우선 합성으로 풀 수 있는지부터 보는 습관이 좋다.
props 설계. 불리언을 늘어놓지 말고 의도를 드러내라. 버튼 하나를 여러 모양으로 쓰고 싶어서 이런 props를 다는 사람이 많다.
<Button isPrimary isLarge isDanger />
처음엔 편해 보이는데, 이게 늘어나면 조합이 지옥이 된다. isPrimary와 isDanger를 동시에 true로 주면 무슨 색이 나와야 하나. 서로 배타적인 선택지를 불리언 여러 개로 표현하면 모순된 조합이 열려버린다. 이럴 땐 하나의 값으로 묶는 게 낫다.
<Button variant="primary" size="large" />
variant는 "primary"나 "danger" 중 하나만 가질 수 있으니 모순이 원천봉쇄된다. props를 설계할 땐 이렇게 자문하면 된다. 이 두 값이 동시에 참일 수 있나. 아니라면 하나의 선택 값으로 합친다. 이름도 컴포넌트 내부 구현이 아니라 쓰는 사람 입장에서 짓는다. onUserClick이 handleClickInternalRow보다 낫다.
props에는 기본값을 정해두면 쓰는 쪽이 편하다. 컴포넌트를 여러 곳에서 쓰다 보면, 매번 모든 props를 다 넘기는 게 성가실 때가 있다. 안 넘기면 undefined가 들어와 화면이 깨지곤 한다. 구조 분해에서 등호로 기본값을 박아두면 이 문제가 사라진다.
function Button({ variant = "default", size = "medium", children }) {
return <button className={variant + " " + size}>{children}</button>;
}
이제 <Button>저장</Button>처럼 아무것도 안 넘겨도 variant는 "default", size는 "medium"으로 채워진다. 흔히 쓰는 값은 기본값으로, 특별한 경우만 넘기게 설계하면 쓰는 쪽 코드가 훨씬 짧아진다. 어떤 값이 가장 흔한지를 기본값으로 정하는 것도 설계의 일부다.
상태는 필요한 만큼만 끌어올린다. 두 컴포넌트가 같은 값을 공유해야 하면, 그 값을 둘의 공통 부모로 올려 한 군데서 관리하는 게 정석이다. 이걸 상태 끌어올리기라 한다. 다만 초보가 자주 하는 과잉이 있다. 겁이 나서 모든 상태를 최상위로 몰아버리는 것이다. 그러면 최상단 컴포넌트가 온갖 상태로 뚱뚱해지고, 그 상태 하나만 바뀌어도 앱 전체가 리렌더된다. 원칙은 딱 필요한 높이까지만이다. 검색어를 검색창 혼자 쓰면 검색창 안에 두고, 검색창과 결과 목록이 같이 쓰면 그 둘의 부모까지만 올린다. 상태는 그것을 쓰는 컴포넌트들의 가장 가까운 공통 조상에 두는 게 맞다.
제어와 비제어. 값을 누가 쥐는지 먼저 정한다. 입력 컴포넌트를 만들 때 값을 부모가 쥐게 할지, 컴포넌트 자신이 쥐게 할지를 정해야 한다. 값과 변경 함수를 props로 받아 부모가 통제하면 제어 컴포넌트다. 부모가 언제든 값을 읽고 바꿀 수 있어 유연하지만, 쓸 때마다 value와 onChange를 붙여줘야 한다. 반대로 컴포넌트가 자기 state로 값을 들고 있으면 비제어에 가깝다. 쓰긴 편한데 바깥에서 값을 건드리기 어렵다. 정답은 없고, 이 컴포넌트를 쓸 사람이 값을 바깥에서 통제할 일이 있느냐로 정하면 된다. 재사용을 노리는 공용 입력 컴포넌트라면 제어 방식이 대개 무난하다. 설계라는 게 결국 이런 선택들의 누적이다. 한 컴포넌트에 한 가지 일만 맡기고, 조립으로 재사용하고, props는 의도가 드러나게 짓고, 상태는 필요한 높이에만 둔다. 이 네 가지만 지켜도 오백 줄짜리 괴물은 잘 안 태어난다.