리액트 문법을 어느 정도 익히고 나면 다음 벽이 찾아온다. 돌아가긴 하는데 코드가 손대기 무서워지는 단계다. 컴포넌트 하나가 오백 줄까지 부풀고, 어디를 고치면 어디가 터질지 감이 안 잡히기 시작한다. 나도 처음 만든 실무 화면이 딱 그랬다. 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>
);
}


Layoutuser를 몰라도 된다. Profile이 이미 user를 쥔 채로 children 자리에 들어오기 때문이다. 값을 중간에서 이어 나를 필요가 사라진다. 물론 앱 전역에서 필요한 값이라면 이보다 Context가 나을 때도 있지만, 그 얘기는 여기서 다룰 자리가 아니다. 우선 합성으로 풀 수 있는지부터 보는 습관이 좋다.


props 설계. 불리언을 늘어놓지 말고 의도를 드러내라. 버튼 하나를 여러 모양으로 쓰고 싶어서 이런 props를 다는 사람이 많다.


<Button isPrimary isLarge isDanger />


처음엔 편해 보이는데, 이게 늘어나면 조합이 지옥이 된다. isPrimaryisDanger를 동시에 true로 주면 무슨 색이 나와야 하나. 서로 배타적인 선택지를 불리언 여러 개로 표현하면 모순된 조합이 열려버린다. 이럴 땐 하나의 값으로 묶는 게 낫다.


<Button variant="primary" size="large" />


variant"primary""danger" 중 하나만 가질 수 있으니 모순이 원천봉쇄된다. props를 설계할 땐 이렇게 자문하면 된다. 이 두 값이 동시에 참일 수 있나. 아니라면 하나의 선택 값으로 합친다. 이름도 컴포넌트 내부 구현이 아니라 쓰는 사람 입장에서 짓는다. onUserClickhandleClickInternalRow보다 낫다.


props에는 기본값을 정해두면 쓰는 쪽이 편하다. 컴포넌트를 여러 곳에서 쓰다 보면, 매번 모든 props를 다 넘기는 게 성가실 때가 있다. 안 넘기면 undefined가 들어와 화면이 깨지곤 한다. 구조 분해에서 등호로 기본값을 박아두면 이 문제가 사라진다.


function Button({ variant = "default", size = "medium", children }) {
return <button className={variant + " " + size}>{children}</button>;
}


이제 <Button>저장</Button>처럼 아무것도 안 넘겨도 variant"default", size"medium"으로 채워진다. 흔히 쓰는 값은 기본값으로, 특별한 경우만 넘기게 설계하면 쓰는 쪽 코드가 훨씬 짧아진다. 어떤 값이 가장 흔한지를 기본값으로 정하는 것도 설계의 일부다.


상태는 필요한 만큼만 끌어올린다. 두 컴포넌트가 같은 값을 공유해야 하면, 그 값을 둘의 공통 부모로 올려 한 군데서 관리하는 게 정석이다. 이걸 상태 끌어올리기라 한다. 다만 초보가 자주 하는 과잉이 있다. 겁이 나서 모든 상태를 최상위로 몰아버리는 것이다. 그러면 최상단 컴포넌트가 온갖 상태로 뚱뚱해지고, 그 상태 하나만 바뀌어도 앱 전체가 리렌더된다. 원칙은 딱 필요한 높이까지만이다. 검색어를 검색창 혼자 쓰면 검색창 안에 두고, 검색창과 결과 목록이 같이 쓰면 그 둘의 부모까지만 올린다. 상태는 그것을 쓰는 컴포넌트들의 가장 가까운 공통 조상에 두는 게 맞다.


제어와 비제어. 값을 누가 쥐는지 먼저 정한다. 입력 컴포넌트를 만들 때 값을 부모가 쥐게 할지, 컴포넌트 자신이 쥐게 할지를 정해야 한다. 값과 변경 함수를 props로 받아 부모가 통제하면 제어 컴포넌트다. 부모가 언제든 값을 읽고 바꿀 수 있어 유연하지만, 쓸 때마다 valueonChange를 붙여줘야 한다. 반대로 컴포넌트가 자기 state로 값을 들고 있으면 비제어에 가깝다. 쓰긴 편한데 바깥에서 값을 건드리기 어렵다. 정답은 없고, 이 컴포넌트를 쓸 사람이 값을 바깥에서 통제할 일이 있느냐로 정하면 된다. 재사용을 노리는 공용 입력 컴포넌트라면 제어 방식이 대개 무난하다. 설계라는 게 결국 이런 선택들의 누적이다. 한 컴포넌트에 한 가지 일만 맡기고, 조립으로 재사용하고, props는 의도가 드러나게 짓고, 상태는 필요한 높이에만 둔다. 이 네 가지만 지켜도 오백 줄짜리 괴물은 잘 안 태어난다.