1. 핵심 개념
요리사가 혼자 주문받고, 재료 손질하고, 요리까지 다 하면 금방 무너진다.
그래서 실제 주방은 홀(주문), 조리보조(재료), 요리사(완성) 로 역할을 나눈다.
React 컴포넌트도 같다. 하나의 컴포넌트가 데이터를 가져오고, 가공하고, 화면까지 그리면 규모가 커질수록 유지보수가 불가능해진다.
그래서 실무에서는 3단 레이어로 역할을 명확히 나눈다.
- Controller / Container — Store·API 등 외부 데이터를 가져오는 데이터 출처
- List / Layout / Section — 받은 데이터를 UI에 맞게 가공하는 중간 레이어
- Item / Row / Card — 가공된 데이터를 그대로 렌더링하는 단순 View
2. 왜 이렇게 설계하는가
이 구조가 없으면 어떤 문제가 생기는가?
하나의 컴포넌트 안에서 API 호출, 날짜 포맷 계산, 렌더링을 모두 처리한다고 생각해보자.
처음엔 괜찮아 보이지만, 기획이 바뀌어 날짜 표시 형식만 수정해야 하는 상황이 오면 그 코드가 어디 있는지 찾는 것부터 고통이다.
테스트도 어렵고, 같은 UI를 다른 페이지에 재사용하려 해도 데이터 로직이 뒤섞여 있어 분리가 불가능하다.
이 설계가 선택된 이유:
- 유지보수 — 날짜 계산 로직이 바뀌면 List 레이어만 수정하면 된다. Controller와 Item은 건드릴 필요가 없다.
- 재사용성 — Item 컴포넌트는 어떤 데이터가 와도 받아서 렌더링만 한다. Controller만 교체하면 같은 UI를 다른 맥락에서도 쓸 수 있다.
- 테스트 — 각 레이어가 명확히 분리되어 있어 단위 테스트 작성이 쉬워진다. Item은 props만 넣으면 테스트 완료다.
💡 면접 tip
"컴포넌트 설계 시 어떤 기준으로 분리하나요?" 질문이 나오면, 이 3단 레이어를 기준으로 역할(데이터 출처 / 가공 / 렌더링)을 나눈다고 설명하면 된다.
실제 프로젝트에서 어떤 레이어가 어떤 책임을 가졌는지 예시까지 들 수 있으면 훨씬 좋다.
3. 사용 원리 (코드 실행 흐름)
아래 예시는 여행 플랜에서 숙소 목록을 날짜와 함께 보여주는 UI다. 각 레이어가 어떤 일을 하는지 코드로 확인해보자.
① Controller / Container — 데이터를 가져와서 넘겨주는 역할
// PlannedAccommodationController.tsx
// 이 컴포넌트가 하는 일: store에서 데이터를 꺼내 List에 전달하는 것뿐
const PlannedAccommodationController = () => {
// [입력] store에서 외부 데이터 읽기
const { startDate, plannedAccommodations } = usePlanStore();
// [처리] 하위가 필요한 데이터만 추려서 props로 내려줌
// UI 가공, 날짜 계산 같은 건 여기서 하지 않는다
// [출력] List에게 원본 데이터 전달
return (
<PlannedAccommodationList
plannedAccommodations={plannedAccommodations}
startDate={startDate}
/>
);
};② List — 데이터를 UI에 맞게 가공하는 역할
// PlannedAccommodationList.tsx
// 이 컴포넌트가 하는 일: 원본 데이터를 받아 UI 친화적인 형태로 가공 후 Item에 전달
const PlannedAccommodationList = ({ plannedAccommodations, startDate }) => {
return (
<>
{plannedAccommodations.map((acc, index) => {
// [입력] Controller로부터 받은 raw 데이터 + index
// [처리] 날짜 계산, 포맷팅 등 UI용 데이터 생성
// store 접근은 하지 않는다 — 데이터는 위에서 내려온 것만 사용
const dateLabel = startDate ? createDateLabel(startDate, index) : null;
// [출력] 이미 가공된 데이터를 Item에 전달
return (
<PlannedAccommodationItem
key={index}
accommodation={acc}
dateLabel={dateLabel}
index={index}
/>
);
})}
</>
);
};③ Item — 받은 데이터를 렌더링만 하는 역할
// PlannedAccommodationItem.tsx
// 이 컴포넌트가 하는 일: 가공된 데이터를 화면에 그리는 것뿐
const PlannedAccommodationItem = ({ accommodation, dateLabel }) => {
// [입력] List로부터 이미 가공된 데이터 수신
// [처리] Null 체크 수준의 최소한의 조건 분기만 허용
// 날짜 계산, API 호출, store 접근은 절대 없음
// [출력] UI 렌더링
return (
<div className="accommodation-card">
<span className="date-label">{dateLabel}</span>
<p className="name">{accommodation?.name ?? "숙소 없음"}</p>
</div>
);
};4. 데이터 흐름 (입력 → 처리 → 출력)
/plan 페이지에서 숙소 목록을 날짜와 함께 렌더링하는 전체 흐름:
usePlanStore()→startDate = "2025-05-16",plannedAccommodations = [Place, null]반환PlannedAccommodationController→ 원본 데이터를PlannedAccommodationList에 props로 전달PlannedAccommodationList→index = 0기준으로"05.16 (목) - 05.17 (금)"계산PlannedAccommodationList→index = 1기준으로"05.17 (금) - 05.18 (토)"계산PlannedAccommodationItem→dateLabel과accommodation.name을 카드 UI에 표시
페이지 이동 / 데이터 변경 시: store의 plannedAccommodations가 바뀌면 Controller → List → Item 순으로 새 데이터가 흘러내려가고 UI가 갱신된다.
5. 주요 레이어 역할 정리
| 레이어 | 역할 | 해야 하는 것 | 하면 안 되는 것 |
|---|---|---|---|
| Controller | 데이터 출처 | store, API, params 읽기 | UI 가공, 날짜 포맷, 렌더링 로직 |
| List | 중간 가공 | map, 날짜 계산, 정렬, 필터 | store 직접 접근, heavy UI 렌더링 |
| Item | 단순 뷰 | 렌더링만 담당 | 데이터 가공, 비즈니스 로직, API 호출 |
6. 실무 사용 패턴
패턴 1 — 날짜 기반 숙소 목록 UI
어떤 상황: 여행 플랜에서 숙박 일정을 순서대로 보여줄 때, 각 숙소에 "몇 박째" 날짜를 붙여야 함
// Controller: store에서 여행 시작일 + 숙소 목록 가져오기
const { startDate, plannedAccommodations } = usePlanStore();
// List: index 기반으로 "05.16 (목) - 05.17 (금)" 형태 날짜 계산
const dateLabel = startDate ? createDateLabel(startDate, index) : null;
// Item: dateLabel + 숙소 이름만 렌더링
<p>{accommodation.name}</p>
<span>{dateLabel}</span>🔧 실무 point
createDateLabel처럼 날짜 계산 함수는 List 레이어 하단에function선언식으로 작성한다. Item 안에 날짜 계산 로직이 들어가는 순간 그 컴포넌트는 재사용이 불가능해진다.
패턴 2 — 필터링된 장소 목록
어떤 상황: 카테고리 필터에 따라 장소 목록이 달라지는 UI
// Controller: QueryString과 서버 응답 읽기
const { data: places } = useQuery({ queryKey: ['places'], queryFn: fetchPlaces });
const { category } = useSearchParams();
// List: category 기반 필터링 후 PlaceCard에 전달
const filtered = places.filter(p => p.category === category);
return filtered.map(place => <PlaceCard key={place.id} place={place} />);
// Item(PlaceCard): 받은 place 데이터 렌더링만
<h3>{place.name}</h3>
<p>{place.address}</p>🔧 실무 point
필터 조건이 바뀌어도PlaceCard(Item)는 수정할 일이 없다. List만 바꾸면 된다. 이게 레이어 분리의 핵심 가치다.
패턴 3 — 장바구니
어떤 상황: 상품 목록 + 각 항목 수량 + 총합 계산이 필요한 UI
// Controller: 서버에서 장바구니 데이터 가져오기
const { data: cartItems } = useQuery({ queryKey: ['cart'], queryFn: fetchCart });
// List: 가격 포맷팅, 총합 계산 후 각 Item에 전달
const totalPrice = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
return cartItems.map(item => (
<CartItem
key={item.id}
item={item}
formattedPrice={item.price.toLocaleString('ko-KR')}
/>
));
// Item(CartItem): 가공된 가격 데이터 그대로 표시
<p>{item.name}</p>
<span>{formattedPrice}원</span>7. 자주 하는 실수 / 주의사항
❌ Item 안에서 store 직접 접근
// 잘못된 예
const CartItem = ({ itemId }) => {
const item = useCartStore(s => s.items.find(i => i.id === itemId)); // ❌
return <p>{item.name}</p>;
};✅ 올바른 방법: Controller에서 꺼내 props로 내려주기
const CartItem = ({ item }) => { // ✅ 가공된 데이터를 props로 받음
return <p>{item.name}</p>;
};🔎 이유: Item이 store를 직접 참조하면 데이터 출처가 두 군데가 된다. Controller를 바꿔도 Item이 다른 데서 데이터를 가져와 예측이 불가능해지고, 테스트할 때 store를 mock해야 하는 번거로움도 생긴다.
❌ List에서 비즈니스 로직 처리
// 잘못된 예
const AccommodationList = ({ accommodations }) => {
const { user } = useAuthStore(); // ❌ List가 인증 상태에 접근
const filtered = accommodations.filter(a => a.ownerId === user.id);
// ...
};✅ 올바른 방법: 필터링 기준 데이터도 Controller에서 내려주기
// Controller에서
const { user } = useAuthStore();
const myAccommodations = accommodations.filter(a => a.ownerId === user.id);
return <AccommodationList accommodations={myAccommodations} />; // ✅🔎 이유: List가 store에 접근하기 시작하면 Controller와 List의 경계가 무너진다. 어디서 데이터가 결정되는지 추적이 어려워진다.
❌ 레이어 건너뛰기 (Controller → Item 직접 연결)
// 잘못된 예: List 없이 Controller가 Item을 직접 렌더링
const Controller = () => {
const { items } = useStore();
return items.map((item, i) => {
const label = createLabel(item.date, i); // ❌ Controller에서 가공까지
return <Item key={i} item={item} label={label} />;
});
};✅ 올바른 방법: 가공 로직은 List로 분리
🔎 이유: 목록이 길어지거나 가공 로직이 복잡해지면 Controller가 비대해진다. 나중에 분리하려면 리팩터링 비용이 크다. 처음부터 List를 두는 것이 낫다.
8. 한 줄 요약
Controller는 데이터를 가져오고, List는 UI에 맞게 가공하고, Item은 렌더링만 한다.
이 3단 분리가 유지보수·재사용·테스트 모두를 가능하게 만드는 설계 원칙이다.