Card가 미션을 알면 안 되는 이유 — 도메인 분리 설계기

rondido·2026년 8월 6일

projects

목록 보기
12/13

들어가며

카드 컴포넌트 하나를 만들면서 도메인 결합, 상태 소유권, 스타일 조합이라는 세 가지 문제를 동시에 겪었습니다. Compound Component 패턴을 처음 적용해보면서 설계 결정이 코드에 완전히 반영되지 않으면 버그가 된다는 것을 직접 겪었고, 그 과정을 기록합니다.


1. 요구사항

카드 컴포넌트는 두 가지 맥락에서 사용됩니다.

오늘의 미션 카드 (TodayMissionCard)

  • 처음에는 앞면이 보이고, 탭하면 3D flip 애니메이션으로 뒤집혀 미션 내용이 보임
  • 앞면은 일반 미션(초록색 그라데이션)과 히든 미션(노랑색 그라데이션) 두 가지
  • 한 번 뒤집히면 다시 앞면으로 돌아올 수 없음

나의 미션 카드 (MyMissionCard)

  • 항상 뒷면(미션 내용)이 보이는 상태로 시작
  • 완료하기 버튼 클릭 시 미션 완료 처리

두 카드는 시각적으로 동일한 카드 껍데기를 공유하지만, 내부 콘텐츠와 동작 방식이 다릅니다. 이 둘을 어떻게 하나의 컴포넌트로 관리할지가 핵심 과제였습니다.


2. 초기 구조와 문제점

const Card = ({ mission, type, id, title, categoryName, completedCount, isSpecial }) => {
  const [flipped, setFlipped] = useState(false);
  const [selected, setSelected] = useState(false);
  const [isCompleted, setIsCompleted] = useState(false);

  return (
    <div onClick={() => setFlipped(prev => !prev)}>
      {!flipped ? (
        <div className={isSpecial ? 'bg-special ...' : 'bg-basic ...'}>
          ...
        </div>
      ) : (
        <div className={
          isCompleted
            ? isSpecial ? 'bg-special-complate ...' : 'bg-complate ...'
            : selected ? isSpecial ? 'border-yellow ...' : 'border-green ...' : ''
        }>
          ...
        </div>
      )}
    </div>
  );
};

① Card가 도메인을 너무 많이 알고 있었습니다

카드는 원래 "뒤집히는 UI 껍데기"일 뿐인데, 미션 제목, 카테고리, 완료 횟수까지 알아야 했습니다. 미션이 아닌 다른 곳에서는 재사용할 수 없는 구조였습니다.

② 상태 소유권이 불분명했습니다

flipped는 UI 동작, isSpecial은 도메인 데이터, isCompleted는 도메인 로직, selected는 TodayMissionCard에서만 의미 있는 상태입니다. 네 가지 성격이 모두 다른데 하나의 컴포넌트에 섞여 있었습니다.

③ 중첩 삼항 연산자로 스타일이 엉켜 있었습니다

className={
  isCompleted
    ? isSpecial
      ? 'bg-special-complate-card-pattern'
      : 'bg-complate-card-pattern'
    : isSpecial
      ? 'bg-special-mission-card-pattern'
      : 'bg-basic-mission-card-pattern'
}

상태 조합이 늘어날수록 depth가 깊어지고, 어떤 조합에 어떤 스타일이 적용되는지 한눈에 파악이 어려웠습니다.

④ 양방향 flip UX 문제

setFlipped(prev => !prev) 구조여서 뒤집힌 카드를 다시 탭하면 앞면으로 돌아오는 의도치 않은 동작이 있었습니다.


3. Compound Component 패턴 도입

이 패턴을 선택한 핵심 이유는 "카드 UI 구조는 고정이지만, 내용물은 사용처마다 달라야 한다" 는 요구사항 때문입니다. Card는 flip 가능한 컨테이너 역할만 하고, 내용물의 책임은 사용처에 위임하는 구조입니다.

src/shared/ui/card/
├── card.tsx              ← CardRoot: flip 상태 관리 + Context 제공
├── card.context.tsx      ← CardContext + useCard hook
├── card.front.tsx        ← 앞면 레이아웃 껍데기
├── card.back.tsx         ← 뒷면 레이아웃 껍데기
└── index.tsx
// card.context.tsx
export const CardContext = createContext<CardContextProps | null>(null);
export function useCard() {
  const context = useContext(CardContext);
  if (!context) throw new Error('Card.* 는 <Card> 안에서 사용해야 합니다.');
  return context;
}

// card.tsx
const CardRoot = ({ children, isSpecial = false, defaultFlipped = false }: CardRootProps) => {
  const [flipped, setFlipped] = useState(defaultFlipped);
  const handleFlip = () => setFlipped(true);
  const handleUnflip = () => setFlipped(false);

  return (
    <CardContext.Provider value={{ flipped, isSpecial, handleUnflip }}>
      <div onClick={handleFlip}>
        <div data-flipped={flipped}>{children}</div>
      </div>
    </CardContext.Provider>
  );
};

export const Card = Object.assign(CardRoot, {
  Front: CardFront,
  Back: CardBack,
});
// TodayMissionCard — 앞면에서 시작
<Card isSpecial={mission.isSpecial}>
  <Card.Front><MissionFrontContent /></Card.Front>
  <Card.Back selected={selected}><TodayMissionBackContent /></Card.Back>
</Card>

// MyMissionCard — 항상 뒷면에서 시작
<Card isSpecial={mission.isSpecial} isCompleted={isCompleted} defaultFlipped>
  <Card.Front />
  <Card.Back><MyMissionBackContent /></Card.Back>
</Card>

4. 상태 설계 — 어디에 무엇을 두었는가

flipped — CardRoot

카드가 뒤집혔는지는 카드 UI 자체의 상태입니다. 도메인과 무관하게 어떤 카드든 플립 동작을 가지므로 CardRoot에서 관리합니다.

isSpecial — prop으로 전달, Context로 공유

isSpecial은 미션 도메인 데이터입니다. Card 자체는 isSpecial이 무엇인지 모르고, true/false로 스타일만 다르게 적용합니다. 부모에서 prop으로 주입하고 Context를 통해 하위 컴포넌트에 전달합니다.

isCompleted — widgets에서 관리, prop으로 전달

완료 상태는 미션 도메인 로직입니다. Card Context에 넣으면 Card가 미션 도메인에 종속되기 때문에 widgets에서 상태를 관리하고 CardRoot에 prop으로 내려줍니다. CardFront/CardBack은 이 값으로 스타일만 바꿀 뿐, 완료가 무슨 의미인지는 모릅니다.

selected — widgets에서 별도 훅으로 분리

처음에는 Card Context에 포함하는 방향을 시도했습니다. 그런데 막상 구현해보니 MyMissionCard에는 selected라는 개념 자체가 없었고, 범용 UI 컴포넌트인 Card가 특정 화면에서만 의미 있는 상태를 갖는 것이 어색했습니다. 그래서 useMissionCardState 훅으로 분리했습니다.

export const useMissionCardState = () => {
  const [selected, setSelected] = useState(false);
  const select = () => setSelected(true);
  const cancel = () => setSelected(false);
  return { selected, select, cancel };
};

이 시행착오에서 중요한 점은 설계 문서상의 결정이 코드에 완전히 반영되지 않을 수 있다는 것이었습니다. 분리했다고 생각했는데 실제 코드에는 selected={isCompleted} 형태로 잘못 전달된 부분이 남아 있었고, 완료 버튼을 누르면 선택 상태의 border 스타일이 의도치 않게 나타나는 버그로 이어졌습니다.


5. Variant Map 패턴으로 스타일 조합 정리

중첩 삼항 연산자 문제를 Variant Map 패턴으로 해결했습니다.

export type CardVariant = 'basic' | 'special' | 'completed' | 'specialCompleted';

export const getCardVariant = (isSpecial: boolean, isCompleted: boolean): CardVariant => {
  if (isCompleted && isSpecial) return 'specialCompleted';
  if (isCompleted) return 'completed';
  if (isSpecial) return 'special';
  return 'basic';
};

export const cardBgStyles: Record<CardVariant, string> = {
  basic:            'bg-basic-mission-card-pattern',
  special:          'bg-special-mission-card-pattern',
  completed:        'bg-complate-card-pattern',
  specialCompleted: 'bg-special-complate-card-pattern',
};

컴포넌트에서는 두 줄로 끝납니다.

const variant = getCardVariant(isSpecial, isCompleted);
className={cn('...', cardBgStyles[variant])}

TypeScript가 Record<CardVariant, string>의 모든 키가 채워졌는지 검사해주기 때문에 누락된 케이스를 컴파일 단계에서 잡아낼 수 있다는 장점도 있었습니다.


6. 트러블슈팅

완료 후 배경색 변화가 화면에 보이지 않음

isCompleted를 true로 바꿔도 화면에 아무런 변화가 없었습니다.

원인: MyMissionCard는 처음부터 뒷면이 보이는데, 완료 시 배경색 변경 로직이 CardFront에 적용되어 있었습니다.

해결: CardBack 안에 절대 위치의 오버레이 div를 깔고 clip-path 애니메이션으로 채우도록 했습니다.

{isCompleted && (
  <div className={cn('animate-card-fill-up absolute inset-0', cardBgStyles[variant])} />
)}
@keyframes card-fill-up {
  from { clip-path: inset(100% 0 0 0); }
  to   { clip-path: inset(0% 0 0 0); }
}

transform: scaleY()는 요소가 중앙 기준으로 크기가 변하는 문제가 있어 clip-path를 선택했습니다.


뒷면 버튼 클릭이 카드 flip을 다시 트리거함

원인: 카드 전체에 onClick={handleFlip}이 걸려 있고, 버튼 클릭 이벤트가 버블링되어 flip 핸들러까지 도달했습니다.

해결: 뒷면의 모든 버튼 핸들러에 e.stopPropagation()을 추가했습니다.

const handleSelect = (e: React.MouseEvent<HTMLButtonElement>) => {
  e.stopPropagation();
  onSelect();
};

flip 후에도 앞면이 클릭 가능한 문제

원인: backface-hidden은 시각적으로만 숨기므로 포인터 이벤트는 여전히 전달됩니다.

해결: flipped 상태일 때 앞면에 pointer-events-none을 적용했습니다.

<div className={cn('backface-hidden', flipped && 'pointer-events-none')}>

마치며

이번 리팩토링에서 가장 크게 배운 것은 두 가지입니다.

구조를 나누는 것만으로는 부족합니다. selected를 Context에서 분리하기로 결정했지만 코드에 완전히 반영되지 않아 실제 버그로 이어졌습니다. 설계 결정이 코드 차원에서도 강제되어야 한다는 것을 직접 경험했습니다.

설계의 장점이 동시에 제약이 될 수 있습니다. 내부 상태 캡슐화는 좋은 설계지만, Storybook에서 flip 상태를 재현하기 어렵게 만드는 제약이 됐습니다. 하나의 결정이 다른 곳에 어떤 영향을 미치는지를 미리 고려하는 습관이 중요하다는 것을 느꼈습니다.

profile
풋살을 좋아하는 프론트엔드 개발자

0개의 댓글