Archive.sakamoto
Component Design

React 컴포넌트 3단 구조 Best Practice

Controller · List · Item 3단 레이어로 React 컴포넌트의 역할을 분리하는 설계 원칙을 알아보자!

Sakamoto·

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 페이지에서 숙소 목록을 날짜와 함께 렌더링하는 전체 흐름:

  1. usePlanStore()startDate = "2025-05-16", plannedAccommodations = [Place, null] 반환
  2. PlannedAccommodationController → 원본 데이터를 PlannedAccommodationList에 props로 전달
  3. PlannedAccommodationListindex = 0 기준으로 "05.16 (목) - 05.17 (금)" 계산
  4. PlannedAccommodationListindex = 1 기준으로 "05.17 (금) - 05.18 (토)" 계산
  5. PlannedAccommodationItemdateLabelaccommodation.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단 분리가 유지보수·재사용·테스트 모두를 가능하게 만드는 설계 원칙이다.