공통 컴포넌트의 추상화 경계선 정립하기

민호·2025년 4월 25일
18

deepdive

목록 보기
1/9
post-thumbnail




이번 디프만 16기를 진행하며 '협업하기 좋은 코드란 무엇일까?' 에 대해 가장 크게 신경을 썼었다.

물론 해당 물음은 ‘좋은 코드'에 대한 모든 범주를 아우르는 내용은 아니다.

  • 말 그대로 ‘좋은 코드’ 라는 범주의 일부로써 ’협업하기 좋은 코드’가 있는 것이고

  • ‘협업하기 좋은 코드’를 달성하기 위한 다양한 방법 중에서 이전에 항상 기술적으로 벽을 느꼈었던 공통 컴포넌트 에 대해 도전하며 겪었던 경험을 정리해 보고자 한다.

해당 글의 ‘컴포넌트’ 라는 단어는 React의 함수형 컴포넌트를 의미합니다.






공통 컴포넌트란?

공통 컴포넌트는 재사용성을 위해 여러 곳에서 동일하게 사용할 수 있도록 범용적이고 추상화된 컴포넌트를 말한다.

일반적으로 기능이나 UI 패턴이 반복될 때 이를 하나의 컴포넌트로 추상화해 두고, 다양한 곳에서 props로 커스터마이징하여 사용하는 형태이다.

‘공통된 부분을 재사용 한다‘ 라는 사전적 의미는 컴포넌트 기반의 React를 사용하면서 매우 매력적인 선택지처럼 들린다.

이는 실제로도 맞는 말이고 단순히 코드의 양이 줄어드는걸 넘어서 개발 생산성과도 직결된다.





공통 컴포넌트를 ‘잘’ 만들면, 만능 컴포넌트로 사용할 수 있을까?



결론부터 말하지면, 공통 컴포넌트는 만능 컴포넌트 까지는 될 수 없다고 생각한다. 오히려 지향해서는 안 된다고 생각한다.


왜냐하면 앞서 언급한 공통 컴포넌트의 사전적 의미를 기반으로, 실제 코드레벨에서 구현 하게 될 경우
“공통된 부분의 경계선을 도대체 어디까지 정립해야 할까?” 라는 그 애매함에 정말 많은 고민이 오가게 된다.

이때 모든 곳에서 자유롭게 재사용 하는 게 좋다는 의도 하나만으로 ‘만능 컴포넌트’를 지향하게 된다면 된다면 반드시 이에 따르는 트레이드 오프가 발생하게 된다.

공통 되는 부분과 재사용 범주를 점점 늘리게 될수록 외부에서 주입해줘야 하는 props의 양과 이를 내부에서 조건부 처리하게 되는 그 복잡성이 동시에 비례하는 것이다


즉, 공통 컴포넌트를 구성하는데 가장 중요한 건 "합리적인 기본값"과 "필요한 유연성"의 균형이다.


그러므로 “공통 컴포넌트는 만능 컴포넌트로 가려는 방향을 지양해야 한다.” 라는 나만의 가치관을 가지고 디프만 16기에서 나는 아래와 같이 공통 컴포넌트를 구축했다.










📝 버튼 공통 컴포넌트

공통 컴포넌트로 가장 많이 사용되곤 하는 버튼 컴포넌트를 작성한다고 해보자.

🙂  버튼 같은 공통 컴포넌트의 경우, 생각보다 나만의 ‘공통된 부분의 경계선을 정립’ 하는 기준이 크게 어렵지 않았다.

아래 사진은 실제 우리 팀에서 사용됐던 버튼의 디자인 시스템 중 일부이다.




버튼 공통 컴포넌트의 경우 디자인 시스템을 기반으로 아래의 props들만 외부에서 주입해주면 된다.

  • variant
    우리 팀의 테마 컬러 값

  • usage
    텍스트 버튼, 아이콘+텍스트 버튼, 아이콘 버튼

  • 사이즈
    width, height, padding 등

  • 이벤트 핸들러




❗ 이 방식이 ‘협업하기 좋은 코드’가 되는 이유는 바로 ‘추상화’이다.

팀원들이 디자인 버튼 UI의 색상으로 #C8EFFF 를 써야 하는지 #96D4ED를 써야 하는지,

버튼의 사이즈가 어떤 상황에서 어떤 값으로 사용돼야 하는지 사용하는 팀원은 알 필요가 없다.

단지 사전에 정의된 props의 타입 시스템을 기반으로 선택하기만 하면 된다.



🧐  “공통된 스타일 값을 상수 형태로 두고 사용하는 것과 뭐가 다른건가?”


색상, 폰트, 폰트 사이즈, 패딩, 사이즈등의 스타일 값을 모두 상수 형태로 정의해두고, 이를 컴포넌트에 직접 주입하도록 구성하면

각 상황에 맞는 디자인 시스템의 해석과 스타일 조합 및 계산의 책임이 우리 팀원에게 전가된다.

그냥 매번 각 스타일 값을 주입하는데, 그 값을 하드코딩하는 수고만 조금 줄일 수 있을 뿐 별 차이가 없다.

이와 더불어 휴먼 에러의 가능성 역시 존재한다.



그에 비해, 위와 같이 공통 컴포넌트 내부에 모든 스타일 및 기능 등을 처리하는 로직을 캡슐화 해두면

우리 팀원은 내부 복잡한 스타일 계산 로직은 알 필요 없이 사용할 때마다 최소한의 테마 형태의 값만 넣어주면 된다. 그리고 컴포넌트 내부에서 모든 값을 알아서 계산하며 UI를 렌더링 하는 것이다.

이는 곳 완벽한 추상화가 될 수 있는 것이다.



여기에 더해서, 나는 보다 강한 휴먼 에러를 방지하기 위해 never타입을 활용하여

사용처 (usage)를 기반으로 props 자체에 대한 경우의 수를 제한하기도 했다.

만약 usage‘text’라면, 이는 텍스트 전용 버튼이 되므로 icon과 관련된 props는 절대 못 받게끔 강제하는 것이다.





🧐 “그렇지만 이 방식은 폐쇄성이 짙어보이는데? 변칙적인 디자인에는 어떻게 대응해야 하지?”

프로젝트를 진행하다보면 디자인 시스템에는 없던 UI가 추가되기도 하고, 애초에 디자인 시스템과 독립적인 1회성의 버튼 디자인도 사용되기도 한다.

우리 팀의 경우, 로그인 버튼이 그러했다.

구글 로그인 버튼 <GoogleAuthButton /> 은 1회성으로만 사용되며 별도로 디자인 시스템을 따르는 형태도 아니었다.

원래라면 이런 경우를 대비해서 Button 컴포넌트에 외부 상황에도 대응해야 할 수 있는 로직을 끼워넣어야 한다.


그러나 나는 애초에 Button 컴포넌트는 디자인 시스템만 따르는 방향성이 옳다고 생각했다.

하나의 Button 컴포넌트에서 디자인 시스템을 계산하는 로직외에, 별도의 상황까지 고려해서 계산해야 하는 로직이 끼어들게 되는건 좋지 않다고 판단한 것이다.

  • 디자인 시스템만을 주입받는 props의 타입 시스템을 견고하게 지키고 싶기도 했고
  • 관심사 분리 측면에서도 디버깅을 효율적으로 하기 위해 따로 분리하는게 맞다고 생각했다.


그러므로 나는 추상화의 계층을 추가로 분리했다.

복잡한건 아니고, 우리 팀에서 사용되는 ‘아주 기본적인 버튼의 스타일’이 적용된 <BaseButton /> 을 별도로 생성하는 것이다.

그리고 <BaseButton /> 을 기반으로

  • 디자인 시스템을 따르는 <Button /> 컴포넌트와

  • 1회성 디자인 버튼인 <GoogleAuthButton /> 가 사용되는 것이다.


분명 내 설명에 논리적 비약이 존재할 것이므로 아래 시각화 사진으로 대체한다.

여기서 언급한 <BaseButton />의 ‘아주 기본적인 버튼의 스타일’은 아래와 같다. 정말 서비스에 사용될 기초적인 버튼 스타일이다.

  display: inline-flex;
  border: none;
  color: inherit;
  font-size: inherit;
  white-space: nowrap;
  background-color: transparent;
  cursor: pointer;
  ...
  &:disabled {
    pointer-events: none;
  }

그리고 <BaseButton /> 컴포넌트는 Radix의 slot을 활용해서 구현했다.

버튼의 경우, 내부 children으로 렌더링하는 컨텐츠가 단일 컨텐츠가 아닌 복합적인 UI가 사용될 수도 있다.

이 부분에 대해 간편하게 Slot을 제공해주는 Radix의 간편함을 사용하고자 도입했다.






📝 사이드바 공통 컴포넌트

이번에는 도전의 범위를 늘려서, 단순 HTMLElement가 아니라 범위가 큰 단위의 UI에 대해 공통 컴포넌트를 만들어 보았다.

😭 사이드바 공통 컴포넌트의 경우, 버튼과 다르게 어느 부분까지 공통된 부분을 정의해야 할지 정말 머리가 아팠다.

사실 내가 선택한 방법도 정답이 아니다. 단지 내 상황에 그나마 맞는 선택을 했을 뿐이다.



아래 사진은 실제 우리 팀에 사용되고 있는 사이드바 UI이다.

이런 디자인의 사이드바가 여러 페이지에 사용되고 있다면 이를 공통 컴포넌트로 묶어보려는 시도를 하게 될텐데,

이 과정에서 나는 다음과 같은 과정의 시행착오를 겪었다.



1. 공통되는 부분을 미리 고정해두고 사용해보자.

현재 사이드바의 경우 아래의 공통된 부분을 가지고 있다.

  • 왼쪽에서 등장
  • 사이드바 헤더
  • 사이드바 컨텐츠

위 부분들에 대한 기본 레이아웃과 스타일들을 고정해놓고 외부에서 변경이 필요할 때마다 추가적인 요소들을 주입할 수 있게끔 한 것이다.

(사이드바를 구현하기 위해 사용한 Dialog는 @radix-ui/react-dialog 입니다. 링크1 링크2)

❌ 그러나 당연하게도, 이 방법은 재사용성에 매우 제한적인 환경이 된다.

미리 정해진 스타일과 레이아웃을 고정해놓았기 때문이다. 만약 다른 페이지에서 동일한 사이드바 기능이지만 약간 다른 ReactNode나 디자인이 추가된다면 이는 애초에 재사용을 못하는 구조인 것이다.

외부에서 주입을 받는다고 해도 어차피 재사용 불가능한 정해진 틀 안에 들어갈 컨텐츠만 갈아 끼우는 꼴인 것이다.



🔥 개인적으로 여기서 가장 큰 깨달음을 얻게 되었는데, 공통 컴포넌트를 작성하는 기준에 UI로직 (ex CSS)는 배제하는게 맞다고 생각한다.

왜냐하면, 공통 컴포넌트 묶을 때 그 기준에 UI 요소가 포함되는 순간 재사용성이 낮아지게 되는것을 체감했기 때문이다.

지금과같이 사이드바의 특정 스타일을 공통 컴포넌트에 미리 지정해두면


"이 스타일과 비슷한 요소가 있다면 재사용하세요. 아 혹시 모르니 스타일 오버라이딩은 조금 열어둘게요."


이런 의도가 되어버리는데 사실 ‘해당 스타일과 비슷한 요소’는 공통 컴포넌트라는 명분에 걸맞지 않게 너무 폐쇄적인 환경이 된다.

각 페이지마다 사이드바가 사용된다고 해도 100% 동일한 스타일이 존재하지 않을 것이며,
만약을 대비해서 오버라이딩을 열어두더라도 사이드바같이 여러 UI 박스모델이 얽혀있는 상황에서는 괜한 오버라이딩이 예측못한 복잡성을 더 초래하게 된다.

이럴거면 공통 컴포넌트를 쓸 이유가 없다.





2. 그렇다면 공통된 부분을 줄이고, 외부에서 주입하는 양을 늘리자

이렇게 되면 사이드바가 사용되는 모든 곳에서 재사용할 수 있고 얼마든지 상황에 따라 자유롭게 주입을 해주며 커스터마이징 역시 가능해진다.

쉽게 말해서, 아주아주 기본적인 '사이드바' 의 틀만 공통 컴포넌트로 만들어 두고 나머지는 다 외부에서 주입해주는 것이다.

'사이드바' 기본 기능외에 나머지는 자유롭게 사용할 수 있다는 것이 굉장히 매력적인 방법처럼 보였다. 그러나 실제로 이 방법 역시 사용할 수 없었다.


❌  애초에 이렇게 쓸거면 공통 컴포넌트를 쓸 이유가 없다.

아주 기본적인 틀만 정의하고 나머진 죄다 주입을 해주는 꼴이라면 이걸 굳이 공통 컴포넌트로 묶는 이유가 없는 것이다.

즉, 여기서 말한 기본적인 틀이라는 범위가 너무 작기에 "공통된 범위가 이렇게 작을거면 공통 컴포넌트로 묶을 이유가 없다"라는 것이다.



❌ 공통 컴포넌트 본래의 목적을 퇴색하게 된다

  • 하나의 컴포넌트가 많은 역할을 수행하게 될 수록 사용하는 쪽에서 props를 구체화 해야 하기 때문에 오히려 복잡성 증가로 공통 컴포넌트라는 본래의 목적을 퇴색하게 된다.
  • 그러므로 공통 컴포넌트의 책임과 역할을 명확히 하고 그 경계를 벗어나지 않는 선에서 확장성을 확보하기 위해 이 방법은 지양해야 한다.




3. 컴포넌트의 인터페이스를 기준으로 경계선을 정립하자

이와 같은 문제를 해결하기 위해 여러 아티클을 탐색하다 토스 SLASH 22 영상 을 보게 되었다.

공통된 부분을 정의할 때, UI보단 컴포넌트의 인터페이스를 먼저 생각해야 한다는 것이 주된 내용이었다.

지금 나의 경우 사이드바의 인터페이스는 아래와 같이 도출할 수 있다.

  • 트리거 버튼을 통해 사이드바가 왼쪽에서 생성된다.
  • 사이드바에 내부 요소(헤더, 컨텐츠 등)가 존재한다.
  • 내부 요소를 클릭하게 되면 별도의 이벤트 핸들러가 동작한다.

즉, 위와 같은 인터페이스를 공통적으로 추상화 할 수 있다면 이는 충분히 개선된 사이드바 공통 컴포넌트가 될 것이라 생각했다.


나는 앞서 Radix의 Dialog를 사용해서 사이드바를 구현하고 있었다.

해당 목적을 달성하기 위해, 이전처럼 Dialog를 통째로 사용하면서 사이드바를 구현하는게 아니라, compound components 구조로 사용했다.

전체 구조를 시각화하면 아래와 같다

이렇게 세부적인 기능 및 UI별로 컴포넌트를 작은 단위로 쪼개 놓게 되면,

  • 사이드바의 기본 인터페이스를 구현하면서
  • 상황마다 필요한 부분만 호출해서 조립해가는 방식으로 컴포넌트를 구성해갈 수 있는 것이다.
  • 추가적으로, 이를 사용하는 사람들은 굳이 내부 동작(Radix의 Dialog 설정)에 대해 알 필요 없이 직관적으로 LeftSidebar의 Triger, Container 등의 사용처를 바로 알고 쓸 수 있다.

결국 추상화를 달성함과 동시에 확장성이나 재사용성 측면에서 이점을 가져갈 수 있게 된다.



🧐  “그렇다면 CSS는 어떻게 주입해줘야 하지?”

이렇게 컴포넌트의 인터페이스를 기반으로 공통 부분을 묶게 되면 문제는 css요소다.

결국 화면에 보여야 하는 것은 css가 입혀진 UI여야 하는데, 단순히 인터페이스를 기반으로만 분리를 하다보면 css요소는 아예 고려를 하지 않게 된다.

“그렇다면 각 사이드바의 디자인마다 모든 박스모델의 css를 전부 props로 넘겨줘야 하는 것인가?”

“그러면 또 다시 2번의 문제로 되돌아가게 되는데?”


이 부분에 있어서 결국 완벽한 해답은 찾지 못했다.

여기서 내가 차선택으로 선택한 방향은 ‘디자인 시스템’ 을 활용하는 것이었다.

디자인 시스템에 정의된(=사전에 확립된 디자인)을 기반으로 css 요소들은 테마값으로 받는 것이다.

마치 버튼 컴포넌트가 현재 프로젝트에서 자주 사용되는 스타일을 미리 정의해 두고 사용했듯이,

사이드바 역시 피그마의 디자인 시스템에 정립돼있는 우리팀만의 사이드바에 대한 여러 디자인 값들을 테마 형태로 미리 지정해두면 정해진 디자인에 한해서, css가 주입되는 양을 압도적으로 줄일 수 있다고 생각했기 때문이다.

실제 사용 코드는 아래와 같다.

compound components 형태로 쪼개놓은 각 요소들을 필요에 따라 조립해가되,

UI 요소들은 <LeftSidebar /> 내부에 미리 디자인 시스템을 따르는 테마 형태의 스타일로 정의해둔 값을 사용하는 것이다.



🧐  “그럼 <LeftSidebar />가 재사용할 수 있는 범위는 어디까지인거지?


정답은 없다.

내가 지금 구현한 <LeftSidebar /> 의 경우, 만약 내부에서 테마 값을 계산하는 부분이나 기본 인터페이스 동작을 수정하는 범위가 점점 커진다면

오히려 다른 공통 컴포넌트를 생성하는게 낫다고 생각한다.

<LeftSidebar /> 가 아닌, <RightSidebar /> , <LeftModalSidebar /> 등으로 말이다.










본 글의 서두에서 언급했듯이, 공통 컴포넌트를 구성하는데 가장 중요한 건 "합리적인 기본값"과 "필요한 유연성"의 균형이다.

어떻게 보면 이번에 공통 컴포넌트를 구현하면서 느꼈던 가장 큰 것은 섣부른 추상화는 오히려 독이 될 수도 있다는 것이다.



버튼 컴포넌트는 사용 빈도가 높고 디자인 명세가 명확하게 정의되어 있는 경우가 많기 때문에, 공통 컴포넌트로 분리하는 것이 효율적이라 생각한다.

반면, 사이드바와 같은 구조는 조금 더 신중한 접근이 필요하다고 느꼈다.
단순히 ‘공통화해야 한다’는 전제만으로 추상화를 진행하면, 실제 사용처가 제한적인 경우 오히려 불필요한 코드의 복잡도를 높이고 유지보수 비용만 증가시킬 수 있다.

또한 어떤 기준으로 공통된 부분의 경계를 정의할 것인지, 어느 정도의 유연성을 허용할 것인지에 대한 깊은 고민이 동반되어야 한다.

profile
Magnificent Tree.

4개의 댓글

comment-user-thumbnail
2025년 5월 2일

잘 읽었습니다!! 너무 유용하네요 ㅎㅎ!

1개의 답글
comment-user-thumbnail
2025년 5월 9일

컴포넌트를 재사용 가능한 단위로 나눌때, 저도 추상화를 많이 고민합니다 :)
버튼같은경우에도 outline 스타일을 줘야할때도 있고 해서 너무 컴포넌트내에 코드가 복잡도가 증가하면 추상화를 하지않고 그냥 별도의 컴포넌트로 만들기도 했었던게 기억에남네요.

최근에는 renderProps 패턴을 통해서, 실제 렌더링 되는 버튼 컴포넌트를 주입해주고 props 만 잘 추상화해서 내려주면 되지않나 생각을 하는데 radix도 좋은 솔루션이 될 수 있을것같군요!

글 잘 보고 갑니다.

답글 달기
comment-user-thumbnail
2025년 5월 30일

좋은 글 감사합니다!

답글 달기