Navigation

Component Boundaries

2025년 5월 모바일 웹 서비스 구축을 준비하면서 컴포넌트를 어디까지 나눌지 글로 정리했습니다. 같은 카드의 클래스와 DOM 구조를 기억해서 조립하는 방식이 반복됐고 반대로 단순한 제목까지 컴포넌트로 감싸는 경우도 고민했습니다. 그보다 앞서 운영 백오피스의 목록 화면을 만들며 적어 둔 메모와 이후 다른 프로젝트에서 레이어 규칙을 다듬은 기록까지 함께 다시 읽었습니다.

원문은 분리 기준을 제안한 기록입니다. 문서의 모든 기준이 팀 표준이 됐다고 확인한 것은 아닙니다. 이 글에서는 스타일을 재사용하는 문제, 구조와 상태를 감추는 문제, 라이브러리를 감싸는 래퍼의 계약 문제를 나눠 설명합니다. 코드는 모두 경계를 설명하려고 새로 쓴 예시이며 실제 서비스 코드를 옮긴 것이 아닙니다.

같은 모양이어도 같은 책임은 아닙니다

정리를 시작한 계기는 몇 가지 반복되는 실패였습니다. 같은 상품 목록 레이아웃을 여러 화면에 복사하다 보면 클래스 하나를 빠뜨리거나 순서를 바꿔 화면이 깨지는 일이 생겼습니다. btn-primary, btn-lg 같은 클래스는 있었지만 화면마다 hover 동작이 달랐습니다. 같은 카드인데 개발자마다 DOM 구조가 조금씩 달랐고 석 달쯤 지나면 어떤 클래스를 어떤 순서로 붙여야 하는지 잊어버린다는 문제도 있었습니다. 거꾸로 단순한 섹션 제목까지 컴포넌트로 만들어 파일만 늘어난 경우도 있었습니다.

여백이나 글자 크기가 같은 두 영역이 서로 다른 이유로 바뀔 수 있습니다. 제목 스타일을 공유하려고 전체 영역을 하나의 컴포넌트에 넣으면 한쪽에만 필요한 버튼과 상태가 props로 계속 늘어납니다.

DOM 순서, 버튼 의미와 로딩 상태까지 같은 카드라면 사정이 다릅니다. 이런 카드는 클래스 조합을 호출부마다 반복할수록 실수가 생기기 쉽습니다. 이때는 컴포넌트가 올바른 구조와 상태 조합을 책임지게 할 수 있습니다.

확인할 질문스타일만 공유하는 경우컴포넌트를 검토할 경우
무엇이 반복되는가?색, 간격, 글꼴의미가 있는 DOM 구조와 동작
호출부가 기억할 규칙은?클래스 한두 개요소 순서, 접근성, 상태 조합
변경 이유가 같은가?화면마다 다름같은 UI 계약을 따라 함께 변경
상태를 누가 처리하는가?상태가 없거나 부모가 모두 처리로딩, 선택, 펼침 같은 경계가 있음
디자인이 바뀌면?클래스를 고치면 모든 사용처가 바뀜컴포넌트 안만 고침

중첩이 많거나 사용 횟수가 많다는 것만으로 결정하지는 않습니다. 한 번 쓰는 UI도 독립된 책임이 크면 나눌 수 있고 여러 번 쓰는 단순 스타일은 CSS로 충분할 수 있습니다.

판단 순서와 폴더의 역할을 정했습니다

표는 기준을 나열할 뿐 어떤 질문부터 던질지는 알려 주지 않습니다. 원문에 질문 순서를 흐름도로 그린 판을 함께 남긴 이유입니다. 먼저 프로젝트 밖에서도 쓸 수 있는지를 묻고 그다음 기능적으로 의미가 있는 단위인지를 묻습니다. 의미가 있다면 구조의 깊이와 상태 유무를 보고 의미가 없다면 스타일 조합으로 끝냅니다.

flowchart TD
    A{"프로젝트 밖에서도 쓸 수 있는가?"} -- 예 --> Core["core 컴포넌트"]
    A -- 아니요 --> B{"기능적으로 의미가 있는 단위인가?"}
    B -- 예 --> C{"DOM이 깊거나 반복되는가?"}
    C -- 예 --> UI["ui 컴포넌트"]
    C -- 아니요 --> D{"hover, loading, skeleton 같은 상태가 있는가?"}
    D -- 예 --> UI
    D -- 아니요 --> E{"스타일 조합만 필요한가?"}
    E -- 예 --> Util["CSS 유틸로 조립"]
    E -- 아니요 --> UI
    B -- 아니요 --> F{"정적인 스타일 조합인가?"}
    F -- 예 --> Tpl["CSS 템플릿이나 유틸"]
    F -- 아니요 --> Inline["레이아웃 안에 직접 작성"]

흐름의 결과는 폴더에 그대로 드러나게 했습니다. core에는 다른 서비스로 가져가도 의미가 같은 기능 단위를 두고 ui에는 이 서비스의 구조와 스타일을 함께 감싼 단위를 둡니다. 전역 스타일 폴더에는 토큰, 믹스인, 리셋처럼 여러 컴포넌트가 함께 쓰는 것만 남기고 한 컴포넌트에만 속한 스타일은 그 컴포넌트 옆에 둡니다.

역할별 폴더 구성text
src/
  components/
    core/        # 서비스와 무관한 기능 단위: Button, Input, Icon, Modal
    ui/          # 이 서비스의 구조와 스타일: ProductCard, SearchBox, FilterGroup
      ProductCard/
        ProductCard.tsx
        ProductCard.module.css   # 이 컴포넌트에만 속한 스타일
  styles/
    tokens.css   # 색, 간격, 글꼴 변수
    mixins.css
    globals.css  # 리셋과 전역 규칙만

core와 ui를 나눈 이유는 재사용 범위와 설계 수준이 다르기 때문입니다. 구축 프로젝트가 운영으로 넘어가거나 디자인 시스템으로 커질 때 core만 떼어 라이브러리로 옮길 수 있어야 한다는 전제도 두었습니다. 원문은 이 방향을 “기억하지 않아도 되는 설계”라고 불렀습니다.

호출부에서 없앨 지식을 찾았습니다

상품 카드가 제목, 이미지와 상태 배지를 보여 준다면 호출부가 배지의 DOM 순서까지 알 필요는 없습니다. 하지만 구매 API 호출과 로그인 화면 이동까지 카드 안에 넣으면 카드가 특정 화면의 업무 정책을 알아야 합니다.

업무 정책을 props 계약 밖에 두기tsx
type ProductCardProps = {
  title: string
  imageUrl: string
  statusLabel: string
  onOpen: () => void
}

const ProductCard = ({ title, imageUrl, statusLabel, onOpen }: ProductCardProps) => (
  <article>
    <img src={imageUrl} alt="" />
    <h3><button type="button" onClick={onOpen}>{title}</button></h3>
    <span>{statusLabel}</span>
  </article>
)

카드는 표현 구조와 버튼의 의미를 맡고 호출부가 항목 id와 이동 정책을 연결합니다. 여러 화면에서 상태 라벨 정책까지 같다면 그 규칙을 ViewModel 변환으로 모을 수 있습니다. 이 구분은 응답과 화면 로직을 나누는 흐름과 이어집니다.

flowchart LR
    Screen["화면: 인증, 요청과 이동"] --> VM["ViewModel: 표시 값"]
    VM --> Card["컴포넌트: 구조와 상호작용 계약"]
    Style["CSS: 색, 간격과 배치"] --> Card
    Card -->|onOpen| Screen

로딩 화면도 같은 질문으로 봤습니다. 홈 배너에는 화면 안에서만 쓰는 skeleton 컴포넌트를 따로 둔 구현이 있었습니다. 로딩 중의 DOM과 표시 구조를 한곳에서 읽을 수 있으니 분리 기준과도 맞았습니다. 그렇다고 skeleton 하나가 있다는 것만으로 전체 설계 원칙이 모두 적용됐다고 볼 수는 없습니다. 로딩과 정상 상태를 나눌 때는 바깥 크기가 지나치게 달라지는지, 보조 기술에 같은 콘텐츠가 중복 노출되는지, 클릭할 수 없는 영역이 버튼으로 남는지도 확인해야 합니다. 파일을 나누는 것만으로 UI 계약이 완성되지는 않습니다.

필터 영역은 컴포넌트 대신 공통 스타일로 묶었습니다

운영 백오피스의 목록 화면은 화면마다 필터 조합이 달랐습니다. 공개 여부, 적용 대상, 등록일, 공개일, 상태, 검색 옵션처럼 필터 종류만 14개였습니다. 메모에는 이 영역을 만드는 두 가지 모양이 남아 있습니다.

첫째는 필터 바 하나에 모든 필터를 넣고 화면마다 showStatus, showCreatedAt 같은 플래그로 켜고 끄는 방식입니다. 이렇게 하면 props가 열 개를 넘고 필터가 하나 늘 때마다 필터 바와 모든 호출부를 함께 고쳐야 합니다. 둘째는 필터 바를 껍데기로만 두고 각 필터를 컴포넌트로 만들어 자식으로 넘기는 방식입니다. React 문서가 설명하는 children 조합이라 호출부는 필요한 필터만 고르면 됩니다. 대신 메모의 걱정처럼 라벨과 입력 하나뿐인, 이름만 가진 컴포넌트가 잔뜩 생깁니다.

검토한 두 모양: 플래그와 자식 조합tsx
// 첫째: 화면마다 플래그를 켜고 끈다
<FilterBar showStatus showCategory showCreatedAt showKeyword onSearch={search} />

// 둘째: 껍데기는 버튼만 맡고 필터는 자식으로 받는다
<FilterBar onSearch={search} onReset={reset}>
  <StatusFilter value={draft.status} onChange={setStatus} />
  <DateRangeFilter value={draft.createdAt} onChange={setCreatedAt} />
</FilterBar>

실제 코드는 둘 중 어느 쪽도 아닌 셋째 모양으로 끝났습니다. 필터 영역에는 자기 로직이 없었습니다. 입력 중인 조건 초안과 실제 조회 조건, 페이지 정보는 모두 화면 맨 위가 갖고 있었습니다. 그래서 필터 영역은 다음 절에서 다룰 “스타일만 있으면 공통 스타일로”라는 규칙에 해당했습니다. 각 목록 화면이 날짜 범위, 선택 상자, 입력 같은 기본 컴포넌트를 직접 배치하고 배치와 간격만 공통 스타일 모듈 하나로 맞췄습니다. 이 스타일 모듈을 목록 화면 9개가 함께 썼습니다.

셋째: 화면이 필드를 직접 배치하고 배치 스타일만 공유한다tsx
import filter from '@/styles/filter.module.css'

const search = () => setQuery({ ...draft, page: 1 })
const reset = () => {
  setDraft(initialFilters)
  setQuery({ ...initialFilters, page: 1 })
}

<div className={filter.bar}>
  <div className={filter.field}>
    <label htmlFor="status">상태</label>
    <Select id="status" value={draft.status} onChange={setStatus} options={statusOptions} />
  </div>
  <div className={filter.field}>
    <label htmlFor="createdAt">등록일</label>
    <DateRangePicker id="createdAt" value={draft.createdAt} onChange={setCreatedAt} />
  </div>
  <div className={filter.actions}>
    <Button onClick={reset}>초기화</Button>
    <Button variant="primary" onClick={search}>검색</Button>
  </div>
</div>

필터 바가 상태를 모르니 필터 값을 props로 몇 단계씩 내려보낼 일도 없었습니다. 같은 메모에 초기화 시점도 적었습니다. 검색 버튼을 누르면 페이지는 1, 페이지 크기는 기본값으로 다시 조회합니다. 3페이지를 보다가 초기화를 눌러도 1페이지로 돌아갑니다. 조건이 바뀌었는데 이전 페이지 번호가 남으면 빈 목록이 보일 수 있기 때문입니다.

대신 화면마다 필드 배치 코드가 반복됩니다. 필드의 순서와 라벨이 화면마다 다르고 필터 영역이 상태를 갖지 않는다면 이 반복은 플래그 조합이나 이름만 가진 컴포넌트보다 고칠 곳이 분명합니다. 필터 영역이 자기 상태를 갖게 되면 둘째 모양으로 옮길 이유가 생깁니다.

스타일만 있는 컴포넌트는 만들지 않았습니다

백오피스 작업 때는 컴포넌트 규칙을 짧게 적어 두었습니다. 기능이 없는 컴포넌트는 만들지 않고 스타일만 있는 영역은 공통 스타일로 묶습니다. 크기를 바꾸는 props는 임의의 숫자 대신 sm, md 같은 프리셋으로만 받습니다. 컴포넌트로 만들 만한 것은 재사용성이 높거나 다른 프로젝트에서도 쓰거나 안에 로직이 있거나 핵심 동작을 맡는 경우라고 봤습니다.

핸들러 규칙도 함께 정했습니다. 핸들러 타입은 명시하고 쓰지 않는 이벤트 인자는 밑줄로 시작하는 이름으로 남깁니다. 목록의 행처럼 인자가 더 필요하면 커링으로 받습니다. 이후 다른 프로젝트에서 규칙을 하나 더 붙였습니다. 마지막에 DOM이나 라이브러리 컴포넌트에 붙는 핸들러는 그 컴포넌트의 props 계약을 indexed access 타입으로 다시 씁니다. 화면은 onRemove(id)처럼 의미 단위 콜백을 갖고 마지막 연결 지점만 원래 계약을 따릅니다.

핸들러: 원래 계약을 재사용하고 추가 인자는 커링으로tsx
import { Button, type ButtonProps } from './Button'

type RowActionsProps = {
  ids: string[]
  onRemove: (id: string) => void // 화면이 아는 의미 단위 콜백
}

export const RowActions = ({ ids, onRemove }: RowActionsProps) => {
  // 로컬 시그니처 (id: string) => void를 새로 만들지 않고 Button의 onClick 계약을 쓴다
  const handleRemoveClick =
    (id: string): ButtonProps['onClick'] =>
    (_event) =>
      onRemove(id)

  return ids.map((id) => (
    <Button key={id} size="sm" onClick={handleRemoveClick(id)}>
      삭제
    </Button>
  ))
}

이렇게 하면 Button의 이벤트 타입이 바뀔 때 핸들러도 같이 타입 검사에 걸립니다. 로컬 시그니처를 손으로 다시 적으면 원래 계약과 조용히 어긋날 수 있습니다.

래퍼는 라이브러리의 계약을 다시 만들지 않습니다

UI 라이브러리를 쓰는 프로젝트에서는 경계가 하나 더 생깁니다. 라이브러리 컴포넌트를 어디서 직접 가져다 쓰느냐입니다. 화면마다 라이브러리의 원시 컴포넌트를 바로 쓰면 테마와 기본값을 바꿀 때 사용처를 모두 찾아야 합니다. 라이브러리를 직접 가져오는 곳은 ui 레이어 하나로 제한하고 나머지 레이어는 ui의 래퍼만 쓰게 했습니다.

레이어두는 것두지 않는 것
ui라이브러리 원시 컴포넌트의 얇은 래퍼, 의미가 뚜렷한 compound 부품ui를 조립한 화면 구조
widgetui를 조립해 여러 화면에서 다시 쓰는 구성한 화면에서만 쓰는 조합
화면 로컬한 화면의 조합과 지원 데이터, 상태나 비동기 경계를 가진 하위 트리상태 없이 나열만 하는 단락

래퍼 규칙을 다듬으며 바로잡은 항목 중 하나는 라이브러리에 이미 있는 계약을 새 이름으로 다시 만드는 것이었습니다. variant, color, size가 있는데 tone, emphasis 같은 prop을 따로 만들면 문서도 두 벌이 되고 라이브러리 업데이트를 따라가기도 어렵습니다. 래퍼는 라이브러리의 공개 props를 같은 이름으로 노출하고 새 의미가 정말 필요할 때만 확장합니다. 기본값은 테마나 라이브러리가 공식으로 제공하는 확장 경로에서 처리합니다.

얇은 래퍼: 라이브러리 props를 같은 이름으로 넘기기tsx
import MuiButton, { type ButtonProps as MuiButtonProps } from '@mui/material/Button'

// 피한 모양: tone, emphasis처럼 이미 있는 variant, color를 새 이름으로 감싼다
export type UiButtonProps = MuiButtonProps

export const UiButton = (props: UiButtonProps) => {
  const { children, ...restProps } = props

  return (
    <MuiButton disableElevation {...restProps}>
      {children}
    </MuiButton>
  )
}

children과 나머지 props를 본문에서 나눠 두면 이 래퍼가 자식을 감싸는 경계라는 사실이 코드에서 바로 보입니다. 대화상자나 표처럼 Root, Title, Actions, Row 같은 부품이 뚜렷한 묶음은 UiDialog.Title처럼 compound 형태로 공개해 같은 경계에 속한다는 사실을 이름에 드러냈습니다.

너무 잘게 나누는 것도 경계했습니다. 화면 맨 위 소개 영역이나 요약 영역처럼 상태, Provider, 비동기 요청, 라이브러리 경계를 갖지 않는 단락까지 화면 로컬 컴포넌트로 빼면 화면 파일에서 흐름이 오히려 보이지 않습니다. 표, 트리, 차트처럼 라이브러리 경계가 빽빽하거나 하위 트리가 자기 상태를 가질 때만 따로 뺐습니다.

추상화를 하나 만들 때마다 유지할 계약도 하나 생깁니다. compact, special, alternate 같은 옵션이 서로 충돌하기 시작하면 같은 책임을 묶었는지 다시 봅니다. 분리 여부를 마지막으로 판단할 때는 호출부가 얼마나 짧아졌는지보다 호출부가 어떤 규칙을 몰라도 되게 됐는지를 봤습니다. 감춘 규칙이 분명하면 그 컴포넌트를 유지할 이유도 설명하기 쉬웠습니다.

참고 자료