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단 분리가 유지보수·재사용·테스트 모두를 가능하게 만드는 설계 원칙이다.