Loki's Blog

반복을 줄이는 것보다 역할을 드러내는 코드

  • 설계
  • 코드리뷰
  • 리팩토링
2026. 05. 27.

들어가며


예외 하나쯤은 옵셔널 prop으로 넘어가도 될 것 같았다.

특정 페이지를 클론 코딩하던 중, 카드 4개를 나란히 배치하는 섹션을 만났다. 카드 데이터를 배열로 만들고 map으로 순회하며 하나의 Card 컴포넌트를 반복 렌더링하는 방식으로 구현했다. 카드 네 개가 앞으로도 계속 같은 모양일 것이라는 전제 위에 선 구조였고, 그 전제가 깨질 일은 없어 보였다.

그런데 코드 리뷰에서 “카드 하나에만 배지를 달아야 한다면 어떻게 할 건가요?”라는 질문을 받으면서 그 전제가 흔들렸다. 질문에 답하려면 Card에 옵셔널 prop 하나를 추가하면 그만이었지만, prop이 늘 때마다 Card가 어떤 조건에서 어떤 모습으로 그려지는지 머릿속으로 따라가야 하는 경우의 수도 함께 늘었다.

배지 색상처럼 요구사항이 하나 더 얹히자 그 경우의 수는 더 늘어났고, 어느 순간부터는 Card라는 이름만으로 이 컴포넌트가 정확히 무엇을 렌더링하는지 설명할 수 없게 됐다.

초기 구현: 객체 배열 하나로 카드 4개를 표현


초기에는 어떻게 구현했는가?
Notion image

카드에 필요한 데이터를 객체로 선언하고, 여러 객체를 배열로 묶은 뒤 map으로 순회하면서 카드 4개를 반복 렌더링했다. 홈 대시보드, 분석 리포트, 설정, 알림처럼 성격은 달라도 생김새는 똑같은 카드들이라, 데이터만 다르게 채워 넣으면 되는 전형적인 목록 UI였다.

const CARDS = [
  { id: 1, icon: '🏠', title: '홈 대시보드', ... },
  { id: 2, icon: '📊', title: '분석 리포트', ... },
  { id: 3, icon: '⚙️', title: '설정', ... },
  { id: 4, icon: '🔔', title: '알림', ... },
]

// Card 선언부
function Card({ ... }) {
  return (
    <div>
      <div>{icon}</div>
      <h3>{title}</h3>
      <p>{description}</p>
    </div>
  )
}

// Card 호출부
{CARDS.map((card) => (
  <Card key={card.id} {...card} />
))}

데이터와 렌더링이 분리되어 있었고, map 한 줄로 카드 4개를 모두 그릴 수 있었기 때문에 처음에는 꽤 깔끔한 구조처럼 보였다. 카드가 하나 추가되더라도 CARDS 배열에 객체 하나만 추가하면 됐고, Card 컴포넌트는 수정할 필요가 없었다.

이 구조가 문제없어 보인 이유는 단순했다.

당시 카드는 아이콘, 제목, 설명 세 가지 값만 받아 화면에 그려주는 단순한 구조였다. 아이콘을 그리고 제목을 쓰고 설명을 붙이는 세 가지 동작이 Card가 하는 일의 전부였고, 값이 세 개뿐이니 Card가 어떤 역할을 하는지도 명확했다.

어떤 데이터를 넣어도 모든 카드가 같은 형태로 렌더링됐고, 그 명확함은 카드 네 개가 앞으로도 계속 같은 모양일 거라는, 도입부에서 세운 전제가 아직 한 번도 흔들리지 않았기 때문에 가능한 것이었다.

요구사항 1: 특정 카드에 배지 추가


카드 하나에만 배지를 달아야 한다면 어떻게 할까?
Notion image

코드 리뷰에서 분석 리포트 카드에만 NEW 배지를 추가해야 한다는 요구사항을 가정해보라는 질문을 받았다. 아직 한 번도 깨지지 않았던, 카드 네 개가 항상 같은 모양일 거라는 전제를 처음으로 건드리는 질문이었다.

가장 빠르게 떠오른 방법은 특정 카드 객체에 배지와 관련된 필드를 추가하는 것이었다. 이미 있는 배열에 값 하나만 얹으면 되니 별도 컴포넌트를 새로 만들 필요도 없어 보였다. 예를 들어 badge 필드를 넣고, Card 내부에서 조건부로 배지를 렌더링할 수 있다.

const CARDS = [
  { id: 1, icon: '🏠', title: '홈 대시보드', ... },
  { id: 2, icon: '📊', title: '분석 리포트', badge: 'NEW', ... },  // ← 예외 필드 추가
  { id: 3, icon: '⚙️', title: '설정', ... },
  { id: 4, icon: '🔔', title: '알림', ... },
]

// Card 선언부
function Card({ ... }) {
  return (
    <div>
      {badge && (<Badge>{badge}</Badge>)}

      <div>{icon}</div>
      <h3>{title}</h3>
      <p>{description}</p>
    </div>
  )
}

// Card 호출부
{CARDS.map((card) => (
  <Card key={card.id} {...card} />
))}

여전히 map으로 카드 4개를 렌더링할 수 있고, 호출부의 구조도 바뀌지 않는다. 실제로 변경된 부분은 배열 안에 있는 카드 객체 하나에 필드 두 개를 추가한 것뿐이다. 실제 diff도 배열의 필드 한 줄과 Card 내부의 조건문 한 줄, 딱 두 줄이었다. 그래서 겉으로 보기에는 변경 범위가 작고, 기존 구조도 잘 유지한 것처럼 보였다.

이 방식이 적절하다고 생각한 이유는 무엇인가?

이 방법을 선택하면서 고려한 부분은 컴포넌트 구조보다 데이터 구조였다. 카드에 필요한 값을 모두 하나의 객체에서 관리하면, 각 카드에 어떤 데이터가 들어가는지 해당 객체만 보고 바로 파악할 수 있다고 생각했다. 분석 리포트 카드에 배지가 있는지 궁금하면 CARDS 배열의 두 번째 객체만 열어보면 그만이었다.

또한 기존의 렌더링 방식도 그대로 유지할 수 있었다. 카드 하나에 예외적인 속성이 추가되더라도 배열 안에 필드를 추가하면 되기 때문에, 컴포넌트 호출부를 별도로 수정할 필요가 없었다.

그래서 이 정도로는 전제가 깨지지 않는다고 판단했다.

실제 코드 변경도 필드 두 개를 추가하는 정도였으므로, 이 문제는 컴포넌트 구조를 바꾸기보다는 데이터 구조 안에서 해결할 수 있는 문제라고 판단했다. 질문은 전제를 처음 건드렸지만, 내 선택까지 그 전제를 건드리지는 않는다고 봤다.

요구사항 2: 각 배지마다 색상을 다르게 적용


배지의 색상을 모두 다르게 해야 한다면?
Notion image

리뷰어가 여기서 한 단계 더 나아가서 “각 배지의 색상도 다르게 적용해야 한다면 어떻게 할 건가요?” 라는 질문을 던졌다. 분석 리포트의 NEW 배지는 파란색이었지만, 다른 카드에 배지가 생기면 초록색이나 빨간색처럼 다른 색이 필요할 수도 있었다.

이번에도 방법은 같아 보였다. badgeColor라는 prop 하나만 더 추가하면 됐다.

const CARDS = [
  {
    id: 1,
    icon: '🏠',
    title: '홈 대시보드',
    ...
  },
  {
    id: 2,
    icon: '📊',
    title: '분석 리포트',
    badge: 'NEW',
    badgeColor: 'blue',
    ...
  },
  ...
]

이렇게 하면 요구사항을 계속 확장해나갈 수는 있다. 배지 문구가 필요하면 badge를 추가하고, 배지 색상이 필요하면 badgeColor를 추가하면 된다. 이후에도 배지 위치, 크기, 스타일, 클릭 이벤트처럼 새로운 요구사항이 생길 때마다 관련 prop을 하나씩 더 추가할 수 있다. 다만 prop이 하나 늘 때마다 데이터 배열에 필드를 채우는 곳, Card 내부에서 그 필드를 조건부로 그리는 곳을 함께 수정해야했다.

손댈 곳만 늘어난 게 아니었다.

문제는 badge, badgeColor와 같은 필드가 계속 추가될수록 Card 내부의 조건부 렌더링도 함께 늘어난다는 점이다. 새 필드 하나가 추가될 때마다 데이터 배열과 컴포넌트 내부의 분기를 모두 수정해야 한다. 둘 중 하나라도 빠뜨리면 의도한 UI가 제대로 나타나지 않을 수 있다.

또 다른 문제로는, Card라는 이름만 봐서는 이 컴포넌트가 배지를 포함할 수도 있다는 사실을 알기 어렵다는 점이다. 호출부에서는 모든 카드가 동일한 컴포넌트로 렌더링되고 있지만, 실제로는 카드마다 서로 다른 조건에 따라 다른 형태로 그려질 수 있다. 카드 4개가 같은 모양이라는, 처음의 전제가 깨지는 지점이었다.

겉보기엔 여전히 “카드 4개, map 한 줄”이지만, 실제로는 카드마다 다른 모습이 될 수 있는 컴포넌트 하나가 그 뒤에 숨어 있는 셈이다.

코드 안에 이정표가 남는다


“홈 대시보드”라는 화면상의 글자만 보고 관련 코드를 찾을 수 있을까?

리뷰어는 이 상황을 “이정표”라는 표현으로 설명했다. 예를 들어 화면의 “홈 대시보드” 카드에서 아이콘이 잘못 나온다는 버그 리포트를 받았다고 해보자. CARDS 배열 안에는 다음과 같이 title: '홈 대시보드'가 적혀 있다.

const CARDS = [
  { id: 1, icon: '🏠', title: '홈 대시보드', ... },
  ...
]

이 문자열이 실제 화면에 표시된다는 사실은 알 수 있지만, 이 코드만 보고 해당 카드가 화면의 어느 위치에 배치되어 있는지 바로 파악하기는 어렵다. CARDS 배열이 사용되는 곳을 찾고, 배열이 어떤 컴포넌트로 전달되는지 확인한 뒤, Card 컴포넌트 내부까지 따라가야 실제 렌더링 구조를 알 수 있다. 파일을 세 번 옮겨 다니고 나서야 아이콘을 그리는 코드에 도착하는 셈이다.

반면, JSX 안에 제목이 직접 적혀 있다면 상황이 달라진다.
<Card
  icon="🏠"
  title="홈 대시보드"
  description="..."
/>

이 경우에는 코드에서 화면에 표시되는 문구인 “홈 대시보드”를 검색했을 때, 해당 문구가 포함된 컴포넌트 호출부를 바로 찾을 수 있다. 그리고 그 코드 주변에는 해당 카드가 배치된 다른 JSX 요소들도 함께 작성되어 있을 가능성이 높다. 아까 그 버그 리포트를 받았다면, 파일을 세 번 옮겨 다닐 필요 없이 “홈 대시보드” 하나로 검색해 곧장 아이콘을 그리는 코드에 도착했을 것이다.

즉, 화면에서 “홈 대시보드”가 표시된 위치와 그 카드를 렌더링하는 코드의 위치가 가까워진다. 별도의 기억이나 추적 과정 없이도, 화면에서 본 문구를 코드에서 검색해 해당 UI의 구조와 주변 맥락까지 빠르게 확인할 수 있는 것이다.

// 데이터 안에 있으면:
// "홈 대시보드"를 찾더라도 실제 화면에서 어디에 배치되는지는
// CARDS가 사용되는 곳과 Card 내부를 추가로 확인해야 한다.
const CARDS = [
  { id: 1, icon: '🏠', title: '홈 대시보드', ... },
  ...
]

// JSX 안에 있으면:
// 화면에서 본 문구를 검색했을 때
// 해당 카드를 렌더링하는 코드와 주변 구조를 바로 확인할 수 있다.
<Card
  icon="🏠"
  title="홈 대시보드"
  description="..."
/>

코드 안에 화면의 위치와 구조를 찾아갈 수 있는 이정표가 남는 셈이다. 화면에 보이는 값과 그 값을 검색할 키워드가 같은 자리에 같은 문자열로 적혀 있기 때문에 가능한 일이다. 화면에서 특정 문구나 요소를 발견했을 때, 그 값을 그대로 코드에서 검색하면 관련된 컴포넌트와 주변 JSX에 빠르게 도달할 수 있다. 데이터 배열 방식이었다면 새로운 버그가 생길 때마다 매번 겪었을 탐색 비용을, 이후로는 신경 쓸 필요가 없어졌다.

데이터로 추상화하는 게 항상 정답은 아니다

데이터 배열로 추상화하면 반복되는 구조를 줄일 수 있다는 장점이 있다. 하지만 모든 UI를 데이터로만 표현해야 하는 것은 아니다. 특히 카드마다 조금씩 다른 역할이나 시각적 의미를 갖는다면, 화면에 대응하는 컴포넌트를 JSX에 직접 배치하는 편이 코드를 읽고 원하는 UI를 찾는 데 더 도움이 될 수 있다. 카드가 전부 다 같은 모습일 때는 데이터 배열이 최선이었다. 그 전제가 깨지는 순간, 조건부 렌더링뿐 아니라 이정표를 찾는 비용까지 함께 따라왔다.

코드 중복을 감수하고 컴포넌트를 쪼개다


코드가 중복되더라도 역할이 한눈에 들어오는 쪽을 택했다.

코드 리뷰 이후에는 배지가 있는 카드를 Card 내부의 조건문으로 처리하는 대신, CardWithBadge라는 별도의 컴포넌트로 분리했다. 배지가 필요 없는 카드는 그대로 Card를 쓰고, 배지가 필요한 분석 리포트 카드에만 CardWithBadge를 쓰도록 호출부를 나눴다.

<Card
  icon="🏠"
  title="홈 대시보드"
  description="..."
/>
<CardWithBadge
  badge="NEW"
  icon="📊"
  title="분석 리포트"
  description="..."
/>
<Card
  icon="⚙️"
  title="설정"
  description="..."
/>
<Card
  icon="🔔"
  title="알림"
  description="..."
/>

map으로 카드 목록을 한 번에 렌더링하던 방식은 사라졌고, 호출부의 코드도 네 줄로 늘어났다. CardCardWithBadge 모두 아이콘 / 제목 / 설명을 그리는 JSX 구조가 거의 그대로 반복됐고, 그만큼 코드 중복도 생겼다.

그럼에도 이 구조를 선택한 이유는 각 컴포넌트의 역할이 훨씬 명확해졌기 때문이다. Card를 보면 배지와 관련된 코드가 전혀 없다. CardWithBadge를 보면 이름만으로도 배지가 포함된 카드라는 사실을 알 수 있다. 조건부 렌더링도, 이정표도 이제 컴포넌트 이름 하나로 대체됐다.

이제 새로운 요구사항이 생겼을 때도 판단 기준이 분명해진다. 기존 카드 전체에 적용되는 공통 기능이라면 Card를 수정하면 되고, 배지 색상이나 문구처럼 배지가 있는 카드에만 필요한 변화라면 CardWithBadge를 수정하면 된다. 기존 카드 두 개는 변경 대상에서 자연스럽게 제외되고, 기본 카드에는 배지와 관련된 prop이나 조건문도 남지 않는다.

코드 줄 수만으로 판단하지 않기


판단 기준은 코드 줄 수가 아니다

공통 컴포넌트를 분리할지 결정할 때, 단순히 코드 줄 수가 적은지를 기준으로 판단하기는 어렵다. 하나의 컴포넌트로 묶으면 중복 코드를 줄일 수 있지만, 그만큼 컴포넌트가 담당하는 역할과 렌더링 조건이 복잡해질 수 있다. 앞서 Card 하나에 badge, badgeColor 필드를 계속 얹었을 때 실제로 겪은 복잡성이 그랬다.

반대로 컴포넌트를 나누면 코드가 중복될 수 있고 호출부도 길어질 수 있다. 하지만 컴포넌트의 이름과 시그니처만으로 어떤 UI를 표현하는지 알 수 있다면, 코드를 읽고 수정하는 비용은 오히려 줄어들 수 있다. CardWithBadge로 나눈 뒤에는 실제로 이름만 보고도 배지가 있는 카드인지 바로 구분할 수 있었다.

컴포넌트를 이름과 시그니처만으로 설명할 수 있는지 확인하자.

prop이 하나 늘어날 때마다, 그 컴포넌트의 이름과 시그니처만으로 동작을 설명할 수 있는지를 확인하는 것이 중요하다. 이름만으로 설명이 안 된다면, 코드를 읽는 사람은 결국 컴포넌트 내부의 조건문을 하나하나 열어봐야 그 컴포넌트가 무엇을 그리는지 알 수 있다. Card라는 이름만으로는 배지가 있는 카드까지 표현한다는 사실을 설명하기 어렵지만, CardWithBadge라는 이름은 그 역할을 바로 드러낸다.

돌이켜보면 판단이 흔들린 지점은, 카드 4개가 같은 모양이라는 처음의 전제가 깨진 순간이었다. 코드가 조금 중복되더라도 각 컴포넌트의 책임과 화면 구조가 한눈에 들어온다면, 그것은 단순한 중복이 아니라 코드 안에 남겨둔 이정표가 될 수 있다.

관련 글