글 · 이시온 · Studio Sion 파운더 · 13년차 프론트엔드 엔지니어

무엇을 공통으로 만들지 않을 것인가

여러 파트가 함께 쓰는 컴포넌트를 설계할 때, 정작 코드베이스를 망가뜨리는 건 중복이 아니라 합치지 말았어야 할 것을 합친 성실함이었습니다.

무엇을 공통으로 만들지 않을 것인가

여러 파트가 함께 쓰는 컴포넌트를 설계할 때 가장 어려운 결정은 “무엇을 공통으로 만들 것인가”가 아니었습니다. 오히려 더 어려운 질문은 “무엇을 공통으로 만들지 않을 것인가” 였습니다.

공통화는 언제나 매력적인 얼굴로 다가옵니다. 비슷하게 생긴 버튼이 다섯 개 있으면 하나로 합치고 싶고, 여기저기 쓰이는 카드 UI가 있으면 <Card /> 하나로 끝내고 싶습니다. 중복은 죄악이고 재사용은 미덕이라고 배워왔으니까요.

그런데 몇 년간 여러 조직에서 디자인 시스템과 공통 레이어를 만들어보면서 알게 된 건, 정작 코드베이스를 망가뜨리는 쪽은 “중복을 못 없앤 게으름”이 아니라 합치지 말았어야 할 것을 합친 성실함이었다는 사실입니다.

모든 걸 공통으로 만들고 싶은 유혹

DRY(Don’t Repeat Yourself)는 강력한 원칙입니다. 하지만 이 원칙은 자주 오해됩니다. 사람들은 DRY를 “비슷하게 생긴 코드가 있으면 합쳐라”로 받아들이곤 합니다.

하지만 DRY가 원래 말하는 핵심은 코드의 형태가 아니라 지식의 중복입니다. 같은 규칙, 같은 정책, 같은 개념이 여러 곳에 흩어져 서로 다르게 변하는 것을 막자는 뜻에 가깝습니다. 단순히 모양이 비슷하다는 이유만으로 코드를 합치라는 뜻은 아닙니다.

이 유혹은 팀이나 파트의 경계를 넘어갈수록 더 커집니다.

이건 우리 파트만 쓰는 게 아니라 다른 파트도 쓸 테니까 공통으로 빼자.

이 말은 언제나 옳게 들립니다. 하지만 여기에 함정이 있습니다. 어떤 컴포넌트가 여러 파트의 경계를 넘는 순간, 그건 더 이상 단순한 코드 조각이 아닙니다. 그때부터는 파트 간 계약이 됩니다.

다섯 개 파트가 쓰는 컴포넌트를 한 줄 고치려면, 그 변경은 다섯 개 파트와의 협상이 됩니다. 그래서 저는 원칙을 하나 뒤집었습니다.

널리 쓰이는 것일수록 공통화 기준을 낮추는 게 아니라 높여야 한다고요.

많은 곳에서 참조된다는 건 함부로 합쳐도 된다는 신호가 아닙니다. 잘못 합쳤을 때 대가가 그만큼 커진다는 경고입니다.

“닮았다”와 “같은 이유로 변한다”는 다르다

공통화를 결정하는 진짜 기준은 “지금 이 둘이 닮았는가”가 아닙니다.

더 중요한 질문은 이것입니다.

“이 둘은 같은 이유로 함께 변하는가?”

로그인 화면의 “제출” 버튼과 결제 화면의 “결제하기” 버튼은 지금 이 순간 픽셀 단위로 똑같이 생겼을 수 있습니다. 색상, 크기, radius, hover 상태까지 완전히 같을 수도 있습니다.

하지만 두 버튼은 서로 다른 이유로 변합니다.

결제 버튼은 PG 정책이 바뀌면 문구와 동작이 달라질 수 있습니다. 결제 실패 처리, 약관 동의, 포인트 사용, 쿠폰 적용 여부 같은 요구사항이 붙을 수 있습니다. 반면 로그인 버튼은 인증 플로우가 바뀌면 상태가 달라집니다. 소셜 로그인, 2단계 인증, 휴면 계정 처리, 비밀번호 재설정 같은 흐름에 영향을 받습니다.

겉모습이 같은 건 우연일 수 있습니다. 하지만 변하는 이유는 다릅니다.

이런 것을 우연한 중복(coincidental duplication) 이라고 부릅니다. 지금 똑같이 생겼다는 이유로 하나로 묶으면, 두 요구사항이 갈라지는 순간부터 문제가 시작됩니다.

결제 쪽 요구사항 하나를 넣기 위해 isCheckout 같은 prop이 생기고, 로그인 쪽 예외를 위해 또 다른 prop이 붙습니다. 그러다 보면 컴포넌트는 두 세계의 사정을 모두 알아야 하는 존재가 됩니다.

반대로 본질적 중복(essential duplication) 도 있습니다. 정말로 같은 개념이고, 같은 이유로 함께 변하는 중복입니다. 이런 경우에는 공통화할 강한 후보가 됩니다.

다만 여기서도 서두를 필요는 없습니다. 같은 이유로 변한다는 확신이 있어도, 소유권과 변경 방식, API 설계가 준비되지 않았다면 너무 빨리 공통화하지 않는 편이 나을 때도 있습니다.

그래서 저는 확신이 없으면 일단 복제해두고 기다립니다. 세 번째로 같은 패턴이 나타났을 때, 비로소 그 패턴이 우연이 아니라는 근거가 생기기 때문입니다. 흔히 말하는 rule of three에 가까운 판단입니다.

Sandi Metz의 유명한 말도 이 맥락과 맞닿아 있습니다.

잘못된 추상화보다 중복이 훨씬 싸다.

중복은 눈에 보이고 지우기 쉽습니다. 하지만 잘못된 추상화는 코드베이스 전체에 뿌리를 내린 뒤라 걷어내기가 훨씬 어렵습니다.

잘못된 공통화가 실제로 비싼 이유

억지로 합친 컴포넌트가 어떻게 망가지는지는 대개 props에서 먼저 드러납니다.

// 처음엔 순수했던 버튼이, 여러 파트의 요구를 받아내며 이렇게 됩니다
<Button
  variant="primary"
  size="lg"
  isLoading={loading}
  isCheckout            // 결제 파트만 씀
  showBadge             // 알림 파트만 씀
  badgeCount={3}
  requireConfirm        // 삭제 버튼만 씀
  confirmMessage="정말요?"
  trackingEvent="a/b-42" // 특정 실험만 씀
/>

isCheckout 같은 prop이 등장하는 순간이 결정적인 신호입니다.

특정 호출자 하나만 쓰는 prop을 공통 컴포넌트에 추가하고 있다면, 그건 공통이 아닌 것을 공통에 밀어 넣고 있다는 뜻입니다.

이런 prop이 쌓이면 세 가지 비용이 따라옵니다.

첫째, 조건 분기가 컴포넌트 내부로 빨려 들어갑니다. isCheckout ? A : B 같은 분기가 늘어나고, 각 호출자의 사정을 컴포넌트가 전부 떠안습니다. 관심사가 분리되기는커녕 한곳에 뭉칩니다.

둘째, 컴포넌트가 아무도 못 건드리는 존재가 됩니다. 다섯 파트가 쓰는 버튼을 수정하면 어디서 무엇이 깨질지 아무도 자신하지 못합니다. 그래서 사람들은 고치는 대신 우회합니다. prop을 하나 더 추가하거나, 옆에 비슷한 컴포넌트를 새로 만듭니다. 공통 레이어가 두려움의 대상이 되면 그 순간부터 코드는 굳어버립니다.

셋째, 무관한 기능들이 서로 결합됩니다. 알림 배지 로직을 손봤는데 결제 버튼의 스냅샷 테스트가 깨지는 식입니다. 아무 상관 없어야 할 두 기능이 하나의 컴포넌트를 통해 엮입니다.

잘못된 공통화의 무서운 점은 처음에는 깔끔해 보인다는 데 있습니다. 파일 수가 줄고, 중복이 사라지고, 재사용률이 올라간 것처럼 보입니다. 하지만 시간이 지나면 그 컴포넌트는 점점 더 많은 예외를 품게 됩니다. 그리고 어느 순간부터는 “공통”이라는 이름을 가진 가장 복잡한 도메인 컴포넌트가 되어버립니다.

공통에도 층위가 있다

“공통”을 하나의 평평한 개념으로 보면 결정이 계속 어려워집니다. 실제로는 층이 있습니다.

프리미티브

Button, Input, Box처럼 디자인 토큰에 가까운 최소 단위입니다. 이 층은 진짜 공통이어야 합니다.

대신 아주 작게 유지해야 합니다. 프리미티브의 미덕은 똑똑함이 아니라 작고 안정적임입니다. 온갖 도메인 지식을 흡수한 “스마트 버튼”은 가장 널리 쓰이면서도 가장 쉽게 깨지는 부품이 됩니다.

좋은 프리미티브는 많은 것을 알지 않습니다. 색상, 크기, 상태, 접근성 같은 최소한의 표현 규칙만 책임집니다.

패턴

SearchBar, DataTable, Pagination처럼 프리미티브를 조합한 컴포넌트입니다. 이 층은 여러 화면에서 반복되는 사용 패턴을 다룹니다.

다만 여전히 특정 비즈니스 도메인을 몰라야 합니다. DataTable은 정렬, 선택, 페이지네이션 같은 테이블의 일반적인 동작은 알 수 있습니다. 하지만 “주문 상태가 배송 중일 때 버튼을 숨긴다” 같은 정책을 알아서는 안 됩니다.

패턴은 조합으로 공통화하되, 도메인 규칙을 흡수하지 않는 것이 핵심입니다.

도메인

CheckoutButton, LoginForm, ProductCard처럼 특정 기능에 속하는 컴포넌트입니다.

이 층은 전역 공통 레이어로 올릴 대상이 아닙니다. 각 파트나 기능 안에 살아야 합니다. 물론 결제 도메인 안에서는 CheckoutButton이 재사용될 수 있습니다. 상품 도메인 안에서는 ProductCard가 공통일 수 있습니다.

문제는 이런 도메인 컴포넌트를 전역 common 레이어로 끌어올릴 때 발생합니다. 대부분의 사고는 여기서 시작됩니다.

정리하면 이렇습니다.

프리미티브는 작게 유지하고, 패턴은 도메인을 모르게 하고, 도메인은 각자의 파트에 남긴다.

겉모습이 비슷하다고 도메인 컴포넌트를 전역 공통으로 올리는 순간, 우연한 중복의 함정으로 들어가게 됩니다.

공통 컴포넌트에는 소유권이 필요하다

공통 컴포넌트는 만든 순간 끝나는 코드가 아닙니다. 오히려 만들어진 뒤부터 진짜 문제가 시작됩니다.

누가 변경을 승인할 것인지, 누가 회귀를 책임질 것인지, breaking change는 어떻게 알릴 것인지, 여러 파트의 요구가 충돌할 때 누가 기준을 정할 것인지가 필요합니다.

소유권 없는 공통 컴포넌트는 모두가 쓰지만 아무도 책임지지 않는 코드가 되기 쉽습니다.

이런 컴포넌트는 시간이 지날수록 더 위험해집니다. 사용하는 사람은 많지만, 정작 내부 구조를 자신 있게 바꿀 수 있는 사람은 없습니다. 그러면 공통 레이어는 점점 “수정하면 안 되는 곳”이 됩니다.

좋은 공통 레이어는 단순히 많이 쓰이는 코드 묶음이 아닙니다. 변경의 기준과 책임이 함께 정리된 레이어입니다.

뻣뻣한 컴포넌트 대신, 조립 가능한 조각

그렇다면 유연성은 어디로 가야 할까요?

답은 컴포넌트 내부가 아니라 호출부입니다.

prop을 15개 받는 뻣뻣한 컴포넌트 하나 대신, 작은 조각들을 조립하게 만들면 공통 레이어는 작게 유지되고 유연함은 쓰는 쪽에서 확보됩니다.

// prop 폭발 대신 — 합성(composition)으로
<Modal open={open} onClose={close}>
  <Modal.Header>주문 취소</Modal.Header>
  <Modal.Body>
    이 주문을 취소하시겠어요?
  </Modal.Body>
  <Modal.Footer>
    <Button variant="ghost" onClick={close}>돌아가기</Button>
    <Button variant="danger" onClick={cancel}>취소하기</Button>
  </Modal.Footer>
</Modal>

Modal은 열림/닫힘, 포커스 트랩, 오버레이 같은 정말로 공통인 뼈대만 책임집니다. 헤더에 무엇을 넣을지, 푸터에 버튼이 몇 개 들어갈지, 어떤 문구와 동작을 붙일지는 호출부가 결정합니다.

이렇게 하면 새로운 요구사항이 생겨도 ModalshowCancelButton, cancelButtonText, confirmButtonVariant 같은 prop을 계속 추가할 필요가 없습니다. 그냥 자식으로 다르게 넣으면 됩니다.

이런 방식은 합성(composition)을 기본으로 두고, 필요하다면 compound component 패턴으로 구조를 나누는 접근입니다. 공통 컴포넌트가 모든 경우의 수를 prop으로 받는 대신, 호출부가 필요한 조각을 직접 구성하게 만듭니다.

같은 사고를 더 밀면 헤드리스(headless) 접근이 됩니다. 동작, 상태 관리, 접근성 같은 보이지 않는 규칙만 제공하고 마크업과 스타일은 호출부에 넘기는 방식입니다.

Radix Primitives, Headless UI 같은 라이브러리가 이 철학을 잘 보여줍니다. 공통으로 만들 가치가 가장 큰 것은 “보이는 모양”이 아니라 “보이지 않는 동작”인 경우가 많습니다. 모양은 파트마다 달라질 수 있지만, 접근성과 상태 로직은 정말로 같은 이유로 변하는 경우가 많기 때문입니다.

공통화는 추출이 아니라 관찰에 가깝다

공통화는 처음부터 정답을 맞히는 일이 아닙니다. 오히려 변화의 방향을 관찰하는 일에 가깝습니다.

저는 보통 이런 순서로 판단합니다.

첫째, 먼저 복제합니다. 처음부터 합치지 않습니다. 지금 닮았다는 사실만으로는 부족합니다.

둘째, 변화 방향을 봅니다. 두 코드가 같은 요구사항 때문에 함께 바뀌는지, 아니면 각자 다른 이유로 갈라지는지 봅니다.

셋째, 중복된 모양이 아니라 중복된 규칙을 찾습니다. 색상이나 레이아웃이 같은지보다 더 중요한 건, 같은 정책과 같은 책임을 공유하는지입니다.

넷째, 최소 단위로 추출합니다. 공통화가 필요하다고 판단해도 크게 묶지 않습니다. 가장 작고 안정적인 단위부터 분리합니다.

다섯째, 호출자 하나만을 위한 prop이 생기면 다시 의심합니다. 공통 컴포넌트가 특정 도메인의 예외를 계속 받아내고 있다면, 추상화의 경계를 다시 봐야 합니다.

공통화는 “중복을 발견하는 즉시 제거하는 일”이 아닙니다. 변화를 충분히 본 뒤, 정말 함께 변하는 것만 조심스럽게 끌어올리는 일입니다.

그래서, 공통으로 만들기 전에 물어야 할 것들

결정 앞에서 저는 스스로에게 이렇게 묻습니다.

  1. 이 둘은 같은 이유로 함께 변하는가, 아니면 지금 우연히 닮았을 뿐인가?
  2. 이 추상화에 분명한 이름을 붙일 수 있는가?
  3. 이름에 A거나 B인 것, Checkout~, Login~ 같은 특정 기능의 냄새가 섞이지 않는가?
  4. 한 호출자만 쓰는 prop을 추가하고 있지는 않은가?
  5. 한 사용처를 위한 변경이 다른 사용처를 위험하게 만들지는 않는가?
  6. 이건 몇 번째로 본 패턴인가?
  7. 몇 개 파트의 경계를 넘는가?
  8. 이 컴포넌트의 소유권과 변경 책임은 분명한가?

특히 마지막 질문이 중요합니다.

경계를 많이 넘는 컴포넌트일수록 더 작고, 더 안정적이고, 더 보수적으로 설계해야 합니다. 여러 파트가 쓰는 컴포넌트는 더 많은 자유를 가져야 하는 게 아니라, 오히려 더 적은 책임만 가져야 합니다.

마치며

좋은 공통 레이어의 목표는 재사용의 극대화가 아닙니다.

진짜 목표는 경계의 정확함입니다.

경계는 컴포넌트의 겉모습이 아니라 변화의 모양을 따라 그어져야 합니다. 함께 변하는 것은 함께 두고, 따로 변하는 것은 따로 둡니다. 이 단순한 원칙이 대부분의 결정을 정리해줍니다.

공통 컴포넌트 설계의 진짜 실력은 무엇을 멋지게 추상화하느냐가 아닙니다. 무엇을 공통 레이어에 넣지 않고 견디느냐에 있습니다.

합치고 싶은 유혹을 참는 절제. 확신이 설 때까지 복제를 견디는 인내. 널리 쓰인다는 이유로 쉽게 끌어올리지 않는 보수성.

결국 “무엇을 공통으로 만들지 않을 것인가”라는 질문이 가장 어려웠던 이유도 여기에 있었습니다.

(다른 글)