
“이 컴포넌트, 어디까지 분리해야 하지?”
React로 개발하다 보면 자주 마주치는 고민입니다. 너무 잘게 쪼개면 파일만 늘어나고, 너무 뭉쳐두면 재사용이 어려워집니다. 결국 추상화의 기준이 없으면 매번 감에 의존하게 됩니다.
이 글에서는 컴포넌트 추상화를 판단할 수 있는 다섯 가지 기준을 소개합니다. 각 기준마다 나쁜 예시와 좋은 예시를 비교하며, 실제 설계에 적용할 수 있는 핵심 질문들을 정리했습니다.
1. UI와 코드의 1:1 대응
좋은 컴포넌트 설계의 첫 번째 기준은 코드를 보고 화면을 떠올릴 수 있는가입니다.
⚠️ 나쁜 예시
<FAQAccordion items={faqItems} />
이 코드만 보고는 화면에 무엇이 보이는지 알기 어렵습니다.
어떤 항목들이 있는지, 각 항목의 내용이 무엇인지 faqItems 데이터와 FAQAccordion 내부 구현을 봐야 알 수 있습니다.
✅ 좋은 예시
<Accordion defaultValue="shipping">
<Accordion.Item value="shipping">
<Accordion.Trigger>배송은 얼마나 걸리나요?</Accordion.Trigger>
<Accordion.Content>평균 2-3일 내에 배송됩니다.</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="return">
<Accordion.Trigger>반품은 어떻게 하나요?</Accordion.Trigger>
<Accordion.Content>7일 이내에 고객센터로 연락주세요.</Accordion.Content>
</Accordion.Item>
</Accordion>
코드 구조가 실제 UI 구조와 일치합니다. 아코디언 항목이 두 개 있고, 각각의 질문과 답변이 무엇인지 바로 알 수 있습니다.
핵심 질문
- 코드를 보고 화면을 떠올릴 수 있는가?
- 화면의 특정 부분을 코드에서 쉽게 찾을 수 있는가?
2. Props는 인터페이스다, 데이터 통로가 아니다
Props는 컴포넌트의 공개 API입니다. 단순히 데이터를 전달하는 통로가 아니라, 컴포넌트가 무엇을 하는지 설명하는 계약이어야 합니다.
<??? checked={isChecked} onCheckedChange={handleCheckedChange} />
props만 보고도 “체크 가능한 무언가”라는 것을 알 수 있습니다. Checkbox, Toggle, SelectableItem 등의 컴포넌트 이름을 예측할 수 있습니다.
⚠️ 나쁜 예시
<CartItem
productId={item.product.id}
productName={item.product.name}
productImage={item.product.imageUrl}
originalPrice={item.product.price}
discountRate={item.product.discountRate}
quantity={item.quantity}
isChecked={item.isChecked}
/>
컴포넌트 이름은 CartItem인데, props는 productId, originalPrice, discountRate, isChecked입니다. props 이름들을 보고 “이건 장바구니 아이템이겠구나”라고 예측하기 어렵습니다. props가 그저 내부로 데이터를 전달하는 통로로만 쓰이고 있습니다.
✅ 좋은 예시
<CartItem
item={item}
onQuantityChange={handleQuantityChange}
onRemove={handleRemove}
/>
props가 컴포넌트의 역할을 설명합니다.
item: 장바구니 아이템 데이터onQuantityChange: 수량 변경 가능onRemove: 삭제 가능
props만 보고도 “장바구니 아이템을 보여주고, 수량 변경과 삭제가 가능한 컴포넌트”라는 것을 알 수 있습니다.
핵심 질문
- 컴포넌트 이름으로부터 props 이름을 예측할 수 있는가?
- props 이름으로부터 컴포넌트 이름을 예측할 수 있는가?
3. 보폭의 일관성
보폭이란 이름과 props가 같은 추상화 레벨에서 말하고 있는가를 의미합니다.
좋은 레퍼런스: HTML
HTML에서 제공하는 컴포넌트를 보며 한 번 생각을 해볼까요?
<dialog open={isOpen} onClose={handleClose} />
// └─ 열렸는지 └─ 닫을 때
<input value={text} onChange={handleChange} />
// └─ 값 └─ 변할 때
이름이 행위/역할을 말하고, props도 그 행위에 필요한 것만 있습니다.
컴포넌트 이름과 props가 같은 언어로 말합니다. 이것이 보폭이 좁은(일관된) 상태입니다.
⚠️ 보폭이 넓은 예시 (나쁜 예시)
<ImageUploader
s3Bucket={bucket}
presignedUrl={url}
maxFileSizeMB={10}
allowedMimeTypes={["image/png", "image/jpeg"]}
/>
// "이미지를 올린다"는 개념인데, props는 S3, presigned URL 등 구현 세부사항
이름은 “이미지 업로더”라는 행위를 말하는데, props는 구현 방식(S3, presigned URL)을 말하고 있습니다. 이것이 보폭이 넓은(불일관한) 상태입니다.
✅ 보폭이 좁은 예시 (좋은 예시)
<ImageUploader value={images} onChange={setImages} maxCount={5} />
// "이미지를 올린다" → value, onChange, maxCount (업로드에 필요한 것)
이름과 props가 같은 레벨에서 “이미지 업로드”에 대해 말하고 있습니다.
컴포넌트 간에도 적용됩니다
한 컴포넌트 내에서 다른 컴포넌트들을 조합할 때도 보폭이 일관되어야 합니다.
// 나쁜 예시: 보폭이 섞임
function BookStorePage() {
return (
<>
{/* 큰 보폭: "헤더"라는 개념 */}
<Header title="서점" />
{/* 작은 보폭: div, className, map... 구현 세부사항 */}
<div className="grid grid-cols-3 gap-4">
{books.map((book) => (
<div key={book.id} className="border p-4 rounded shadow">
<img src={book.coverUrl} alt={book.title} className="w-full" />
<h3 className="font-bold mt-2">{book.title}</h3>
<p className="text-gray-600">{book.author}</p>
<p className="text-blue-600 font-bold">{book.price}원</p>
</div>
))}
</div>
{/* 큰 보폭: "푸터"라는 개념 */}
<Footer />
</>
);
}
// 좋은 예시: 보폭이 일관됨
function BookStorePage() {
return (
<>
<Header title="서점" />
<BookList books={books} />
<Footer />
</>
);
}
// 모두 "개념 단위"로 일관되게 표현됩니다.
핵심 질문
- 컴포넌트 이름과 props가 같은 추상화 레벨에서 말하고 있는가?
- 한 컴포넌트 내의 모든 요소가 같은 보폭인가?
- HTML 요소를 레퍼런스로 삼았을 때, 이 컴포넌트의 props는 자연스러운가?
4. 안에서 밖으로: 구현에서 추상화로
추상화를 설계할 때는 먼저 내부 구현을 작성하고, 그 다음 본질을 찾아 추상화하는 것이 효과적입니다.
과정
- 펼치기: 먼저 모든 구현을 한 곳에 작성합니다
- 본질 찾기: 이 코드가 하는 일의 본질이 무엇인지 파악합니다
- 추상화: 본질만 드러나도록 인터페이스를 설계합니다
예시: 가격 입력 필드
1단계 - 펼치기
<input
type="text"
value={price ? price.toLocaleString() : ""}
onChange={(e) => {
const numericValue = e.target.value.replace(/[^0-9]/g, "");
setPrice(numericValue ? Number(numericValue) : 0);
}}
placeholder="가격을 입력하세요"
/>
2단계 - 본질 찾기
이 코드의 본질은 “숫자(가격)를 입력받는 것”입니다. 콤마 포맷팅, 문자열 변환, 정규식 처리는 구현 세부사항입니다.
3단계 - 추상화
<PriceInput value={price} onChange={setPrice} placeholder="가격을 입력하세요" />
외부에서는 숫자만 다루고, 포맷팅과 변환 로직은 내부에 숨깁니다.
핵심 질문
- 구현을 먼저 작성하고 본질을 찾았는가?
- 외부에 드러난 인터페이스가 본질만 표현하고 있는가?
5. 과한 추상화 경계하기
추상화는 숨기는 것이 아니라 드러내야 할 것을 선택하는 것입니다.
⚠️ 나쁜 예시: 모든 것을 숨김
<DynamicForm schema={formSchema} onSubmit={handleSubmit} />
폼에 어떤 필드가 있는지, 어떤 검증이 있는지 전혀 알 수 없습니다.
✅ 좋은 예시: 필요한 것만 드러냄
<Form onSubmit={handleSubmit}>
<Form.Field name="email" label="이메일" type="email" required />
<Form.Field name="password" label="비밀번호" type="password" required />
<Form.Submit>로그인</Form.Submit>
</Form>
폼의 구조가 드러나면서도, 각 필드의 내부 구현은 숨겨져 있습니다.
핵심 질문
- 이 추상화가 정말 필요한가?
- 숨긴 것이 이해를 돕는가, 방해하는가?
- 드러내야 할 것을 숨기고 있지 않은가?
정리: 좋은 추상화의 체크리스트
-
UI와 코드가 1:1로 대응되는가?
-
Props가 인터페이스로서 의미가 있는가?
-
추상화 수준이 일관되는가?
-
본질에 집중하고 있는가?
-
과하게 숨기고 있지 않은가?
추상화는 무엇을 드러내고 무엇을 숨길 것인가에 대한 의사결정입니다.
그리고 그 기준은 코드를 읽는 사람이 의도를 쉽게 파악할 수 있는가입니다.