들어가며..
"이론은 알겠는데, 우리 프로젝트에 그대로 적용하는 게 정답일까?"
프로젝트에서 "내 모임 카드" 컴포넌트를 구현하게 되었다. 디자인 시안을 보니 동일한 카드가 8가지 이상의 변형으로 사용되고 있었다. 진행중, 진행 완료, 리뷰 작성, 모집중, 대기중... 상단은 비슷한데 하단이 상태마다 완전히 달랐다.
기존에는 메인페이지에서 사용되는 2가지의 카드 형태를 각자 구현한 뒤 나중에 리팩토링을 하려고 했지만, 바빠지면 진행하지 못할 것 같아서 미리 처음부터 좋은 구조로 잡고 싶어서 팀원과 회의를 깊게 나눴던 것 같다.
결국 재사용성을 고려해서 컴파운트 형태와 같이 가기로 결론을 지었다. 원래는 두 개의 이슈로 만들었어서 한 명이 대기하거나 이슈에서 빠져야 하는 상황이 되어서 이를 기회로 삼아서 한 번도 경험해보지 못했던 “페어프로그래밍”을 해보자고 팀원에게 제안했다. 다행히, 내 제안을 흔쾌히 수락해주셨고 우리는 페어프로그래밍을 할 수 있게 되었다.
결론적으로 교과서적인 Compound Pattern와는 조금 다른 방식으로 선택하게 된 과정을 기록한다.
본론
1. 카드 변형 분석
페어프로그래밍의 첫 단계는 같이 디자인 시안을 펼쳐놓고 분석하는 것이었다. 카드의 모든 변형을 나열해보니:
| 메인 > 내 모임 | 진행중 | 달성률 + 총 기간/현재 주차 |
| 마이페이지 > 나의 모임 | 진행완료 (리뷰 미작성) | 달성률 + 리뷰 쓰기 버튼 |
| 마이페이지 > 나의 모임 | 진행완료 (리뷰 작성) | 달성률 + 리뷰 작성 완료 |
| 마이페이지 > 만든 모임 | 모집중 | 모집 인원 / 신청 대기 |
| 마이페이지 > 대기중 | 대기중 | 참여 취소하기 |
| 메인 > 모임 리스트 | 모집중 | 하트 + 참여하기 |
| 마이페이지 > 찜한 모임 | 모집중 | 하트 + 참여하기 |
- 공통점: 카테고리 태그, 해시태그, 제목 (상단)
- 차이점: 모임기간, 모임상태, 맴버아바타, 인원수 하단 영역이 상태마다 완전히 다른 UI
팀원과 함께 "이걸 어떤 패턴으로 만들어야 유연하면서도 일관성을 유지할 수 있을까?"를 본격적으로 논의하기 시작했다.
2. 세 가지 후보 — 팀원과의 첫 번째 토론
후보 1: Props 기반
<MyGatheringCard
category="프로젝트"
subCategory="개발"
status="inProgress"
progressRate={80}
totalWeeks={9}
currentWeek={3}
reviewStatus="none"
recruitCount={4}
recruitMax={20}
onReviewClick={...}
onCancelClick={...}
// ... 끝이 안 보인다
/>
결론: 8가지 변형을 하나의 인터페이스로 커버하면 대부분이 optional이 되고, 컴포넌트 내부에 조건 분기가 폭발한다. 기각.
후보 2: Compound Component (교과서적)
tsx
<MyGatheringCard.Header category="프로젝트" subCategory="개발" status="진행중" />
<MyGatheringCard.Progressrate={80} />
<MyGatheringCard.WeekStatustotalWeeks={9} currentWeek={3} />
각 서브 컴포넌트가 자기만의 props를 받아 내부에서 렌더링을 캡슐화하는 방식. Radix UI, Headless UI 등에서 볼 수 있는 클래식한 패턴이다.
의문: "근데 우리 카드는 변형이 8개가 넘는데, 그걸 다 서브 컴포넌트로 만들면 오히려 서브 컴포넌트가 너무 많아지지 않을까?"
후보 3: 순수 Composition
tsx
<Card className="p-7">
<div className="mb-6 flex justify-between">...</div>
<div className="flex flex-col gap-3">...</div>
</Card>
이미 범용 Card 컴포넌트가 있으니, 그냥 children 으로 자유롭게 넣는 방식.
우려: "사용처마다 className을 일일이 외워야 하고, 누군가 mb-6을 mb-4로 잘못 쓰면 레이아웃이 미묘하게 어긋날 수 있다."
3. 우리의 선택 — "시맨틱 레이아웃 슬롯" 패턴
세 후보를 모두 검토한 끝에, 팀원과 합의한 방식은 교과서적 Compound의 형태를 빌리되, 서브 컴포넌트의 역할을 레이아웃에 한정하는 것이었다.
tsx
// MyGatheringCard/index.tsx
function MyGatheringCardRoot({children,className }:MyGatheringCardRootProps) {
return <Card className={cn('p-7',className)}>{children}</Card>;
}
export const MyGatheringCard = Object.assign(MyGatheringCardRoot, {
Header: MyGatheringCardHeader,// mb-6 flex justify-between
Body: MyGatheringCardBody,// flex flex-col gap-3
Footer: MyGatheringCardFooter,// flex gap-2
});
사용처에서는 이런식으로 쓴다:
tsx
<MyGatheringCard className="w-150">
<MyGatheringCard.Header>
<Tag variant="category" .../>
<Status Badgestatus="진행중"/>
</MyGatheringCard.Header>
<MyGatheringCard.Body>
<div>#디자인 #기획</div>
<h3>피그마 기초 스터디</h3>
</MyGatheringCard.Body>
<MyGatheringCard.Footer>
<ProgressBarrate={80}/>
</MyGatheringCard.Footer>
</MyGatheringCard>
교과서적 Compound와의 차이
교과서적 Compound 우리 방식
| 서브 컴포넌트의 역할 | props를 받아 내부 렌더링 캡슐화 | 레이아웃(간격, 정렬)만 담당 |
| 서브 컴포넌트 내부 | 자체적인 JSX 트리 보유 | children을 그대로 통과 |
| Context 사용 | 필요에 따라 사용 (예: Modal의 titleId) | 불필요 |
| 유연성 | 서브 컴포넌트 단위로 고정 | children 내부에서 자유롭게 조합 |
왜 교과서적 Compound를 채택하지 않았나?
- 변형이 8개 이상: 서브 컴포넌트에 props를 다 정의하면 결국 props 기반과 같은 복잡도
- 두 가지 카드 패밀리: 내 모임 카드 / 모임 리스트 카드의 상단 레이아웃 자체가 다름
- Context가 불필요: 서브 컴포넌트 간 공유할 상태가 없음
반면 우리 방식은:
- Object.assign으로 네임스페이스를 통한 가독성 확보
- 각 영역의 레이아웃 규칙을 한 곳에서 관리
- children으로 남겨둔 덕에 어떤 변형이든 자유롭게 대응 가능
4. 기존 Card와의 관계
페어프로그래밍 중 또 하나 중요했던 논의는 "기존 Card 컴포넌트와의 관계"였다.
Card (범용 디자인 래퍼: border, shadow, rounded)
└── MyGatheringCard (모임 카드 전용 레이아웃: 패딩, 영역 간 간격)
Card는 시각적 기반(border, shadow, rounded)만 담당하고, MyGatheringCard는 모임 카드만의 레이아웃 규칙(패딩, 영역 간 간격)을 관리한다. 이 구조라면 나중에 ReviewCard , NoticeCard 같은 새로운 카드가 필요해도 Card를 래핑해서 독립적으로 만들 수 있다.
팀원이 담당하는 "모임 리스트 카드"도 같은 Card를 래핑하되 자기만의 레이아웃 규칙을 가진 별도의 Compound로 구성하기로 했다. 서로의 컴포넌트에 영향을 주지 않으면서 병렬 작업이 가능해졌다.
5. 페어프로그래밍에서 얻은 것
우리는 드라이버-네비게이터 방식으로 25분 간격으로 역할을 교대하며 구현했다. 드라이버가 직접 코드를 작성하고, 네비게이터가 방향을 잡아주고 실시간으로 리뷰하는 방식이다.
솔직히 말하면, AI 도구를 쓰면 이 정도 컴포넌트는 금방 끝낼 수 있었을 것이다. 하지만 우리가 페어프로그래밍을 선택한 건 빨리 끝내기 위해서가 아니었다.
AI 시대에 직접 코드를 쓰는 이유
AI 시대에서 코드 생산량은 기하급수적으로 빨라졌다. 반면 AI가 생산해낸 코드를 읽을 수 없다면 그저 ‘승인’만 찍는 기계일 것이다. (Auto 모드로 한다면 이것마저도 필요 없을 것) 최근 AI 시대에 맞춰 미국 명문대학들이 커리큘럼을 재구성한다는 포스팅을 봤다. 결국 중요한 건 AI가 생산한 코드를 이해하고, 비판할 수 있는 시각을 길르는 것이다. AI가 생산한 코드를 이해하려면 해당 디자인 패턴에 대한 개념, 어떤방식으로 구현하는지 정도는 알아야 AI가 만든 코드를 리뷰할 수 있다.
(내가 읽은 글: https://flowkater.io/posts/2026-03-06-anthropic-codepath-curriculum/)
의사결정의 질이 달라진다
혼자 코딩했다면 "Compound 패턴이 좋다니까 그렇게 하자"로 끝났을 것이다. 하지만 팀원과 함께 모든 변형을 펼쳐놓고 분석하니, "이 프로젝트에서 교과서적 Compound가 과한 이유"를 구체적으로 고민할 수 있었다.
"왜?"라는 질문의 힘
"왜 서브 컴포넌트가 children만 받아?" → "지금은 레이아웃 역할이니까. 나중에 필요하면 props를 추가해서 진짜 Compound로 발전시킬 수 있어." → “근데 왜 지금같은 레이아웃 역할만 하는 게 낫다고 판단?”
페어프로그래밍에서 서로 "왜?"를 반복하면서 현재 구현의 한계와 확장 가능성을 동시에 인식할 수 있었다.
결론
우리가 택한 방식은 Compound Component의 Object.assign 문법은 사용하되, 서브 컴포넌트가 로직을 캡슐화하는 대신 레이아웃 규칙과 시맨틱한 의미를 부여하는 패턴이다.
이 선택의 핵심 근거는 세 가지다:
- 변형의 수: 8가지 이상의 카드 변형을 props나 교과서적 Compound로 커버하기엔 복잡도가 과하다
- 레이아웃 일관성: Card + div 직접 조합보다 MyGatheringCard.Header/Body/Footer가 가독성과 일관성 면에서 우월하다
- 확장성: Card를 기반 레이어로 두고 도메인별 카드를 독립적으로 만드는 구조가 팀의 병렬 작업에 유리하다
디자인 패턴은 "적용하는 것" 자체가 목적이 아니다. 우리 프로젝트의 요구사항에 맞게 변형하고, 그 변형의 이유를 팀 전체가 이해하는 것이 더 중요하다. 페어프로그래밍은 그 과정을 가속시켜주는 가장 효과적인 도구였다.
'개발 > 프로젝트' 카테고리의 다른 글
| 완성도(完成島): 회의 기능 구현 - 왜 LiveKit(SFU)을 선택했나 (0) | 2026.04.30 |
|---|---|
| AutoMeeting: 매일 20분 걸리던 회의 기록을 0으로 만든 AI 파이프라인 (0) | 2026.04.30 |
| AutoMeeting: 매일 쓰는 팀회의 STT, 왜 Cloud API 대신 로컬 Whisper를 골랐을까? (0) | 2026.04.30 |
| 완성도(完成島): Toss에서는 이렇게 쓰려나? Modal 설계 (0) | 2026.03.19 |