들어가면서

여러 화면에서 비슷한 모양으로 보이는 “혜택 안내 영역”이 있었다. 같은 결제 수단을 강조하는 작은 카드/배너 같은 것들이다. 화면마다 비슷한 정책으로 그려지길래, 하나의 공유 UI 패키지로 빼면 좋겠다고 생각했다. 이름은 가명으로 promo-nudging-ui 라고 두자.

처음 목표는 이랬다.

  • 같은 정책의 UI는 한 곳에서 정의한다.
  • 디자인이 바뀌면 패키지만 고치고, 앱은 거의 안 건드린다.
  • 호출하는 화면 코드는 짧고 깔끔해진다.

만들면서도 마음 한 켠에 의심은 있었지만, 그래도 “같은 정책이면 한 곳에서 관리하는 게 맞지” 라는 흔한 생각으로 시작했다.

첫 번째 어긋남 — 디자인이 다 달라졌다

막상 화면별로 가져다 쓰려고 보니, 같은 정책을 노출하는 같은 영역인데도 화면마다 미묘하게 다 달랐다.

  • 어떤 화면에서는 한 줄짜리 컴팩트 카드,
  • 어떤 화면에서는 두 줄에 “적용가” 까지 우측 정렬,
  • 어떤 화면에서는 모션이 있는 요약 버전,
  • 어떤 화면에서는 행동 버튼이 더 붙은 버전.

처음에는 “그래 이런 건 variant 으로 처리하면 되지” 라고 생각했다. 그런데 두 가지를 넘어가면서부터, 한 컴포넌트의 props 가 분기 천국이 되어 갔다. 결국엔 다 갈라졌다.

// 한 패키지 안에서 결국 이렇게 갈라졌다
export const PromoCompactNudge = (...) => { ... };
export const PromoAppliedPriceNudge = (...) => { ... };
export const PromoFirstPaymentNudge = (...) => { ... };
export const PromoBenefitSummaryWithActionNudge = (...) => { ... };
export const PromoAnimatedSummary = (...) => { ... };

하나로 합치려고 props 분기를 늘리다 보면, 그 컴포넌트 자체가 또 다른 거대한 분기 트리가 되어 버린다. “같은 정책, 다른 디자인” 앞에서, 공유 UI 는 거의 재사용되지 않았다. 컴포넌트는 늘었고, 그 컴포넌트들은 각각 한 군데에서만 쓰였다.

그럼 도대체 무엇이 공유되었는가

UI 는 흩어졌지만, 정작 “공유” 가 의미 있게 남은 건 두 가지였다.

  1. 동작 — 클릭하면 어디로 이동할지에 대한 라우팅 정책
  2. 이벤트 로깅 — 노출/클릭 시 어떤 키와 어떤 페이로드로 보낼지

이 둘은 정책에 가깝다. 디자인이 한 줄짜리든 두 줄짜리든 모션이든, “이걸 누르면 어디로 가야 하는지”, “어떤 스키마로 로그를 보내는지” 는 모두에서 같다.

돌이켜 보면, 공통화하기 좋은 건 “보이는 것” 이 아니라 “행동과 약속” 이었다.

모노레포에서 외부 패키지로 분리한다는 것

외부 패키지로 분리하면 보통 이런 이득을 기대한다.

“패키지만 올리면 앱 코드는 안 건드려도 된다.”

운영 중인 서비스에서는 이 점이 정말 크다. 변경 반경이 좁아지고, 영향도가 명확해지니까.

그런데 모노레포에서는 그 분리가 생각만큼 와닿지 않았다.

  • 패키지와 앱이 같은 저장소에 있다.
  • 빌드/배포 파이프라인도 같이 묶여 있다.
  • “패키지만 PR 로 올리고 앱은 그대로 둔다” 는 흐름이 사실상 잘 안 만들어진다. 어차피 앱 PR 도 함께 올라가게 된다.

물론 변경 반경 자체는 좁아진다. 하지만 외부 npm 으로 진짜 떨어져 있는 라이브러리만큼의 격리 효과는 아니었다. “공통 코드를 패키지로 분리해 두었다” 는 심리적 안정감은 있어도, 운영상 이득은 기대만큼 크지 않았다.

오히려 디자인이 자주 바뀌는 영역을 패키지로 두면, 패키지 변경 → 앱 재빌드/재배포 흐름을 똑같이 다 타야 한다. 결국 이 영역을 “별도 패키지에 둬야 할 이유” 가 점점 희미해졌다.

버그를 찾기 — 쉽기도 하고 어렵기도 하다

이건 양면이다.

  • 쉬운 면. 한 곳에 모여 있으니 그 영역에서만 보면 된다.
  • 어려운 면. “이 컴포넌트가 어떤 화면에서 어떻게 쓰이는지” 가 흐려진다.

화면마다 컴포넌트가 따로 있을 때는, 그 화면을 열면 모두 그 화면 안에 있다. 그런데 패키지로 빼는 순간, 호출 지점은 화면이고 정의는 패키지가 된다. 화면 → 패키지 → 다시 호출 지점, 으로 왔다 갔다 해야 전체 그림이 잡힌다.

결합도가 낮아진 게 아니라, 결합 지점이 보이지 않게 흩어진 것에 가까웠다.

그럼 어떻게 했어야 했나 — UI 가 아니라 동작을 공유한다

생각해 보면 답은 가까이에 있었다. 공유하기에 적합한 결은 “동작과 로깅” 이었으니까, UI 는 각 화면에 두고, 동작과 로깅만 hook 한두 개로 분리하면 됐다.

const { handleClick, logImpression, logClick } = usePromoNudge({
  placement: 'CHECKOUT_SUMMARY',
  payload: { /* 라우팅·로깅 컨텍스트 */ },
});

return (
  <PromoSlot onView={logImpression}>
    <button onClick={handleClick}>
      {/* 화면별로 자유롭게 그려진 UI */}
    </button>
  </PromoSlot>
);

이 hook 안에서만 라우팅 정책과 로그 스키마를 책임지면, “뷰는 자유롭게, 행동은 일관되게” 가 자연스럽게 만들어진다. 컴포넌트를 재사용하려고 디자인 다양성을 죽일 일도 없고, hook 인터페이스만 잘 잡으면 호출 쪽도 짧다.

useMyDiscount 글에서 했던 말과 결국 비슷한 자리로 돌아온다 — 부모(화면)는 표시와 레이아웃에 가깝게 두고, 정책(라우팅·로깅)은 한 곳에서 일관되게.

그래서 — UI 공유 패키지에 대한 내 입장

조심스럽지만 약간 부정적인 쪽으로 정리됐다.

  • 디자인 정책이 매우 엄격해서 한 가지 모양만 허용될 때, UI 공유는 보상으로 돌아왔다.
  • 디자인이 화면 컨텍스트에 따라 자주 변형되는 영역에서는, 공유 UI 패키지는 만든 비용을 잘 회수하지 못했다.
  • 모노레포에서는 “외부 패키지만 올려서 끝낸다” 의 운영적 이득도 작았다.
  • 진짜 공유할 가치가 있던 건 동작과 로깅이었고, 그건 컴포넌트가 아니라 hook 한두 개로 충분했다.

다음에 비슷한 영역을 마주치면 이렇게 잡고 싶다.

  1. 정말로 같은 한 모양만 허용되는 UI 인지 디자이너와 먼저 합의한다.
  2. 그렇지 않다면 UI 는 각 화면에 두고, 행동과 로깅만 패키지/hook 으로 뺀다.
  3. 컴포넌트 공유가 진짜로 필요해질 때 그때 가서 끌어 올린다. 먼저 추상화하지 않는다.

마치며

“공통화하면 좋다” 는 말은 절반만 맞다. 공통화하기에 적합한 결이 있고, 그 결을 잘못 잡으면 추상화는 빚이 된다.

이번에 만든 UI 패키지가 그렇게까지 큰 빚이 되진 않았지만, 큰 힘이 될 줄 알았던 것에 비하면 작은 힘이 되었다. 다음번에는 패키지를 만들기 전에, “이게 정말 다섯 번 같은 모양으로 쓰일 영역인가?” 를 먼저 묻고 싶다.

Don't repeat yourself 보다 먼저 와야 하는 질문은, What is actually repeated? 인 것 같다.