코드 지옥에서 탈출하기: Feature- Sliced Design

minseok baek·2025년 4월 9일

프로젝트

목록 보기
20/20

"무슨 컴포넌트가 여기에?" 이코드는 또 어디에 분류하지... ? "

미로

개발을 하다보면 누구나 한 번쯤 코드의 미로 속에서 길을 잃어본 경험이 있을 거에요. 특히 혼자 개발할 떄는 더 심해요. "어차피 나만 볼건데" 하는 마음으로 시작했다가 2주 후의 내가 내코드를 이해하지 못하는 상황...😅 큰맘 먹고 리팩토링 하여 정답이라고 생각했지만, 시간이 지나고보면 또 오늘의 저를 이해할 수 없는 무한의 굴례...

오늘은 제가 단독 프론트엔드로 개발하는 프로젝트에서 코드 미로를 탈출하기 위해 겪은 여정을 통해 Feature-Sliced Design(FSD)를 도입한 여정을 나눠볼려고해요.

스파게티가 된 코드x 프로젝트

처음 프로젝트를 시작할 때는 누구나 그렇듯 저도 낙관적이었어요. 컴포넌트, 페이지, 유틸리티 등을 역할별로 구분하는 전통적인 리액트 프로젝트 구조로 시작했죠. 그런데 한 달이 지나고, 또 한 달이 지나고.. 코드베이스는 자라났고, 저 또한 프로젝트를 진행하면서 조금씩 성장하다보니 문제가 시작됐어요.

처음에는 크게 문제를 느끼지 못했지만, 점점 기능이 추가되고 기존 코드들의 관심사를 분리하다보니 자연스럽게 코드 파일도 증가했어요. 특히 새로운 기능을 추가하는 것은 크게 문제가 아니었지만, 기존 코드를 건드리는 일이 점점 부담스럽게 느껴지기 시작했어요.

무엇이 어렵게 만들었을까 ?

Cohesion,Coupling그래프

위 그래프는 소프트웨어 구조를 응집도(Cohesion)와 결합도(Coupling)을 기준으로 평가한것인데요. 이상적인 코드 구조는 응집도는 높고(High Cohesion) 결합도는 낮은(Low Coupling) 4사분면에 위치합니다.

  • 응집도 (Chohesion) : 모듈 내부 요소들이 얼마나 관련성 있게 모여 있는지를 나타냅니다.
  • 결합도 (Coupling) : 모듈 간의 상호 의존성 정도를 나타냅니다.

제가 처음 시도한 전통적인 구조는 1사분면인 'Poorly Chosen Boundaries'에 해당했어요. 사실 저는 여기에 더해서 경험이 절대적으로 부족했기에 1사분면과 2사분면(God Object) 사이를 오가는 구조였던 것 같아요. 'God Object'는 너무 많은 기능이 하나의 거대한 객체나 컴포넌트에 모여 있는 상태인데, 이런 구조에서는 코드 이해와 유지보수가 극도로 어려워지죠..

당연히 이런 구조에서는 아래와 같은 문제가 발생할 수 밖에 없었죠.

  • 의존성 지옥 : 이 컴포넌트를 수정하면 저쪽에서 왜 에러가 나지 ?? 모듈 간 의존성이 스파게티처럼 얽혀버렸어요.
  • 로직 카오스 : UI코드와 비즈니스 로직이 한 파일에 섞여 있으니 무엇이 무엇인지 알 수 없게 되었죠.
  • 유지보수 악몽 : 간단한 기능 수정도 여기저기 파일을 열어봐야 했고, 2주 전에 작성한 코드도 이해하는 데 시간이 걸렸어요.

문제 해결을 위해 도메인별로 코드를 구분해보고, 커스텀 훅으로 비즈니스 로직을 분리해보기도 했어요. 잠시 숨통이 트이는 것 같았지만... 또 수개월이 흐르면서 코드가 증가하니 또 다른 문제가 찾아왔죠

  • 폴더와 파일이 급격히 늘어났고
  • 폴더 구조는 점점더 깊어져서 import 경로가 미로처럼 복잡해졌어요.

처음으로 받아본 사용자 피드백

혼란 스러워 하는 이미지

사실 코드가 복잡해져도 "그래도 동작하니까.."라는 자기합리화로 미뤄두고 있었어요. 스파게티 구조를 바꾸는 건 단순한 문제 해결이 아니라 자신을 갈아 넣어야 하는 일이니까요.

그런데 첫 배포 후 받은 사용자 피드백이 저를 충격에 빠뜨렸어요.

"UI가 너무 복잡해서 어떤 기능인지 알기 어려워요"
"이 화면에서 내가 뭘 해야 하는지 직관적으로 이해가 안 돼요"

피드백을 처음 받은 날, 스타일 코드를 어떻게 수정해야 할지 고민을 거듭하며 코드들을 훑어보았는데 스스로 많은 반성을 했던 것 같아요. 정말 코드의 규칙들이 제각각이라 흩어진 퍼즐들을 찾아 헤매고 다녔죠. 그동안 코드를 항상 중점적으로 생각하며 나름 새로운 것들을 배울 때마다 적용시켰는데, 전체 그림을 보니 한숨이 나왔어요.

이걸 지금 안 건드리면 앞으로 프로젝트의 발전 가능성은 없을 것 같고, 건드리자니 막막하고... "차라리 처음부터 다시 만들까?"라는 생각도 했지만 그건 결코 해결책이 아니었기에 다시 한번 저를 갈아 넣기로 마음을 먹고 무엇을 해야 할지 생각해봤어요.

우선 사용자 경험을 해치는 UI는 둘째 치고, 저라는 개발자 경험을 해치는 문제들이 무엇인지 정리해보기로 했어요.

불편을 느꼈던 구조적 문제점들

위험한 건물

의존성 관리의 어려움

  • 모듈 간 순환 참조로 인한 복잡성 증가 - A가 B를 참조하고 B가 다시 A를 참조하는 악순환 🔄
  • 컴포넌트 간 의존 관계 파악이 어려움 - "엥 얘를 수정했는데 왜 니가터짐? ..🎲"

일관성 없는 구조

  • 파일 구조를 파악할 때마다 생기는 새로운 기준 - 아...이건 또 다른 패턴이네? 싶은 순간들
  • 리팩토링 시 구조 파악에 느끼는 피로감 - 이 파일은 왜 여기에...? 아, 3주전의 내가 생각했던 새로운 기준.. 🤦‍♂️

컴포넌트 배치의 모호성

  • RootLayout와 같은 구조적 컴포넌트의 위치 선정 - "RootLayout은 우주의 근원과도 같은 존재인데... common? component? app? 어디에 두는 게 맞을까?" 🤔
  • Provider와 같은 전역 상태 관리 컴포넌트의 적절한 위치 ? - 넌 또 뭐니... 컴포넌트? 아니면 context 폴더를 만들까?
  • NavbarHeader의 계층 구조 관계 설정의 모호함? - NavbarHeader의 동생인데 어딜 겸상을..

분명 과거의 기능 구현 위주 개발에서 벗어나 프로젝트의 지속 가능성을 고민하며 수없이 리팩토링을 반복했는데... 자꾸만 똑같은 현상이 반복되는 원인에 대해 생각해보니 바로 체계적인 아키텍처의 부재였어요.

컴포넌트 디자인 패턴 같은 기법들에는 관심이 많았지만, 정작 전체 구조에 대해서는 너무 안일했고, 초기 설계 계획조차 "일단 해보자~" 하면서 별 생각 없이 그냥 많이 쓰니까 따라한 구조였죠.

마치 골조는 제대로 세우지 않고 외관만 그럴싸하게 꾸미려고 한 격이었어요. 아무리 유지보수를 거듭해도 건물의 골격이 튼튼하지 않으면 그 결과는 뻔한데도, 저는 계속 흔들리는 건물을 급하게 지탱하려고만 했어요. 벽에 금이 가면 테이프로 붙이고, 바닥이 기울면 받침대를 대고... 🏗️

결국 이 모든 문제의 핵심은 제대로 된 설계 원칙 없이 그때그때 상황에 맞춰 임기응변식으로 코드를 작성했다는 것이었어요. 이대로는 안 되겠다는 생각에 좀 더 체계적인 아키텍처에 대해 알아보기 시작했어요

Atomic Design? 아니면 다른 무언가!

아토믹

사실 처음부터 제 마음을 사로잡았던 건 Atomic Design이었어요. 메가테라를 통해 처음 이 개념을 접했을 때는 🧪 원자(Atoms)에서 시작해 분자(Molecules), 유기체(Organisms)로 성장해가는 이론이 왠지 모르게 낭만적으로 다가왔어요. "나도 컴포넌트의 연금술사가 될 수 있겠다!" 하는 자신감이 넘쳤죠.

하지만... 현실은 단순하지 않았어요. 😅

  • 디자인 시스템이 없었어요. : 디자이너님을 구하지 못해 피그마 레이아웃 스케치와 제 빈약한 상상력에 의존해서 개발하고 있었거든요. "이게 원자인가, 분자인가?" 고민하다 하루가 지나갈 지경이었죠.
  • 변화가 너무 잦았어요 : 초기 프로젝트라 사용자 피드백에 따라 UI가 자주 바뀌었어요. 원자 단위로 쪼개놓은 컴포넌트들이 오히려 발목을 잡을 것 같았죠. "어제 만든 원자가 오늘은 분자가 되어야 한다고요? 🤦‍♂️"
  • 규모 :Atomic Design을 도입하여 재사용성을 높일 만큼의 규모는 아니었어요. 솔직히 "대포로 모기 잡기" 같은 느낌이었습니다.

그러다 우연히 유튜브 알고리즘의 축복으로 단테님의 영상을 발견했고, Feature-Sliced Design(FSD)이라는 새로운 세계를 만났어요. 층층이 쌓인 티라미수 케이크 같은 구조가 단번에 제 마음을 사로잡았죠! 😍

단테님의 영상을 마치 시험 공부하듯 수차례 돌려보고, FSD 공식 문서와 여러 자료를 밤새 읽어보니 "이거다!" 싶었어요. 마치 미로에서 탈출구를 발견한 느낌이랄까요?
섬광

  • 직관적인 구조 : "이 기능 찾아야 해? features 폴더 접근" 단순명쾌!
  • 유연함 : 엄격한 규칙보단 원칙을 제시하고 팀(또는 나)의 상황에 맞게 조정 가능
  • 의존성 방향 제한 : 상위 레이어가 하위 레이어를 참조할 수 있지만 그 반대는 안 됨 -> 기본적인 의존성 지옥 탈출!

FSD 적용기: 이론과 현실의 간극

파피루스![](https://velog.velcdn.com/images/minseok96/post/9b75f821-1e5d-4b3e-9ba8-a8465253c158/image.png)

FSD를 도입하기로 결정했지만, 막상 적용하려니 새로운 고민들이 쏟아졌어요. 이론적으로는 멋진 청사진이었지만, 현실의 코드에 적용하려니 수 많은 고민들이 쏟아졌어요.

자료 부족과의 싸움

불과 몇 개월 전만 해도 FSD 자료는 정말 제한적이었어요. 지금은 국내에도 자료도 꽤 많고, 공식 문서도 한국 번역판을 지원하고 있지만, 초기에는 공식 문서를 위주로 러시아 유튜버 영상의 스크립트를 따서 번역기 돌려보며 읽기도 했어요. 마치 고대 문명의 비밀 문서를 해독하는 고고학자가 된 기분이었달까요?

혹시 몰라 아래에 당시에 정리한 기술 중점 자료를 공유할게요. 지금은 공식 문서에서 사라진 내용도 포함되어 있어요.

FSD 적용기의 시작

FSD 아키텍처

우선 결론적으로 제가 프로젝트에 적용한 FSD 레이어 원칙은 이렇게 정의했어요.

  • app : 프로젝트 전역 설정들 (모달, 토스트 , 프로바이더)등을 관리하는 최상위 레이어
  • pages : 실제 사용자가 보는 화면의 조합 최종본
  • widgets : 여러 기능을 조합해 새로운 기능을 만들거나, 화면상 공유 및 재사용성을 위한 독립적 컴포넌트
  • features : 사용자와 상호작용하는 각각의 기능들 관련된 직접적인 내용
  • entities : 프로젝트와 도메인의 연결점, 비즈니스 엔티티 관련 로직(API 호출, 데이터 관리)
  • shared : 프로젝트 전체에서 공유되는 공통 코드들 (가급적 특정 프로젝트을 타겟하지 않음)

여러 차례의 시행착오와 고민 끝에 위와 같은 구조를 정립하게 되었어요. FSD에 이미 익숙하신 분들이라면 "음, 여기 몇 가지 표준과 다른데?" 하고 느끼실 수도 있을 것 같아요. 맞아요! 저는 FSD의 핵심 철학은 지키되, 제 프로젝트의 특성과 개발 경험에 맞게 일부 요소들을 유연하게 재해석했거든요.

결국 아키텍처는 엄격한 규칙보다 실용성이 더 중요하니까요. 마치 요리 레시피를 내 입맛에 맞게 살짝 변형하는 것처럼요!

공유 코드의 딜레마, Shared 레이어

shared 레이어는 프로젝트나 비즈니스의 구체적인 내용과는 분리된 독립적인 모듈, 컴포넌트 및 추상화들로 구성됩니다. 다른 레이어와 달리 슬라이스(slice)로 구성되지 않고, 직접적으로 세그먼트(segments)로 구성됩니다.

가장 먼저 겪은 고민은 최하위층인 Shared 레이어였어요. 전체 레이어에서 공유되는 코드라는 정의는 명확해 보이지만, 막상 기존의 코드들을 옮기면서 적용하려니 "뭐가 진짜 공유 코드야?" 하는 모호한 부분이 많았죠.

예를 들어, Button이나 Input 같은 UI 컴포넌트는 쉽게 결정할 수 있었어요. 어떤 화면에서도 동일한 모습과 기능을 가지기도 하고, 누가 봐도 전체가 공유할 코드가 맞잖아요! 😊

"생각보다 간단할지도...?" 라는 순진한 생각은 얼마가지 못하고 산산조각 났어요. 저는 곧 고민의 늪에 빠지게 되었죠!
특히 API 관련 코드는 정말 머리를 쥐어짜게 만들었어요. 기존 구조에서는 모든 API 호출 코드를 apis 폴더에 깔끔하게? 넣어뒀었거든요.

api
- AUTH
- ESSAY
- FEEDBACK
- INTERVIEWQUESTION
- QnABOARD
- TEMPLATE
client.tsx
config.tsx

근데 뭐가 문제야?.. 할 수 있지만, 제가 고민에 빠진 이유는 아키텍처라는 것이 저에게는 마치 절대적인 원칙으로 다가왔기 때문이에요. "프로젝트나 비즈니스의 구체적인 내용과는 분리된 독립된 모듈"이라는 말이 제 머릿속에서 너무 엄격한 제약으로 변해버렸죠..

현재 구조대로 shared/api를 가져가도 프로젝트의 상위 레이어에서 모두 접근하는 데 전혀 문제가 없었어요. 하지만 저의 "절대 원칙" 관점에서 보면 도메인으로 분류하는 것만으로도 이미 원칙이 깨지는 것처럼 느껴졌어요... 마치 "이건 죄악이야!" 하는 느낌? 😱

제가 내린 결론을 스스로 틀린 답이 아니라는 것을 입증할려고 FSD의 공식문서에서 채택된 모든 프로젝트를 수 없이 들여다봤어요. 하지만 문제를 해결해주기보다는 오히려 제 혼란을 더 키웠어요.

정답이 없었어요. 정말로 정답이 없더라고요! 어떤 프로젝트는 shared/api에 모든 API를 두고, 또 다른 프로젝트는 entities에 도메인별로 분산하고... 공식 채택된 예제들인데도 접근 방식이 완전히 달랐어요.

처음엔 "뭐가 맞는 거지...? 내가 뭔가 놓치고 있나?" 하며 혼란스러웠지만, 결국 깨달았어요. FSD는 팀의 상황과 프로젝트 특성에 맞게 유연하게 적용하는 것이 핵심이었던 거예요! 너무 교조적으로 접근하다 보니 스스로 괴로웠던 거죠. 😅

절대 원칙

결국 저희 프로젝트에서는 Shared/api에는 토큰 관리나 기본 HTTP 클라이언트 같은 진짜 공통 기능만 두고, 도메인별 API는 entities 레이어에 두기로 결정했어요. 이렇게 하니 "이 API는 어디 있지?" 하고 고민할 필요 없이 코드 찾기가 훨씬 직관적이게 되었어요!

또 다른 예제로는 기능적 측면에서 영상녹화 기능을 제공하는 useMediaRecord나 텍스트-음성 변환을 담당하는 useWebSpeech 같은 훅들은 어디에 정의해야 할지가 고민이었어요. 이건 마치 '이 컴포넌트는 어느 폴더에 정리해야 가장 적절할까?' 하는 퍼즐 같았죠!

이걸 features에 두게 되면 다른 슬라이스가 접근을 못하는데... "너는 이 영역에서만 사용할 수 있어!" 같은 느낌이잖아요. 그럼 이건 어쨌든 인터뷰라는 도메인 관점을 가지고 있으니 entities일까? 하지만 entities는 기능을 두면 안된다는 FSD의 불문율... 또 시작되는 절대원칙의 강박감 🤔

사실 재사용 가능성은 크지 않았지만 그래도 미래는 모르는 거니깐! "언젠가 다른 곳에서도 필요할지 몰라..." 하는 마음으로 훅 내부에서 store를 통하는 상태관리라는 복잡한 부분을 덜어내고, 마치 외부 라이브러리를 끌어오되 특별한 의존성은 설치하지 않는다는 관점에서 React 기본 훅과 표준 브라우저 API만을 활용하여 shared로 분류했어요.

이 훅들은 마치 "난 어느 컴포넌트에서든 부를 수 있는 유틸리티야!"처럼 자유롭게 어디든 사용될 수 있게 된 거죠. 이러한 접근 방식은 코드의 재사용성을 높이고 어느 부분에서든 쉽게 통합할 수 있는 유연성을 제공했어요. 결국 "이게 정답이다"보다는 "이게 저희 프로젝트에 맞는 접근법이다"를 찾아가는 과정이었던 것 같아요.

Entities, 본격적인 FSD에 대한 고민 시작?

shared 레이어는 전통적인 리액트 구조에서도 흔히 볼 수 있어 비교적 친숙했지만, 진짜 FSD의 고민은 여기서부터 시작됐어요. 간단히 정리하면 Entities에서는 slice라는 새로운 개념이 등장하고, 이 slice는 다시 segment라는 작은 단위로 구성될 수 있어요. sharedslice가 존재하지 않는 segment의 구성으로 이루어져있어요. 처음 접했을 때는 "이게 대체 무슨 말이지?" 싶었어요! 😅

Entities를 본격적으로 이야기하기 전에, 먼저 FSD의 가장 기본 단위인 segmentslice에 대해 함께 알아볼게요.

슬라이스와 세그먼트

슬라이스는 독립적이고 자체적으로 완결된 기능 단위예요. 사진 갤러리 앱을 만든다면 photo, create-album, gallery-page와 같이 기능 중심으로, 게시판이라면 post, comment, user처럼 도메인 중심으로 명확하게 구분할 수 있죠.

슬라이스를 정의하는 건 생각보다 수월했어요. API 스펙을 그대로 반영해도 되고, 프로젝트 특성에 맞게 유연하게 조정할 수 있으니까요. 마치 마트에서 음료수 코너를 탄산음료, 과일주스, 생수 섹션으로 깔끔하게 나누는 것처럼 명확했어요.

하지만 진짜 고민은 그 다음이었어요. 바로 슬라이스를 구성하는 세그먼트였죠. 세그먼트는 슬라이스의 내부 구조를 결정하는 중요한 요소인데, 각 세그먼트에 어떤 관심사를 담느냐에 따라 전체 레이어의 성격이 완전히 달라질 수 있어요. 이건 마치 마트 음료수 코너에서 "제로 칼로리"라고 써있는 섹션을 봤는데, 알고보니 전혀 제로가 아닌 음료들이 진열된 상황 같은 거예요!

🤦‍♂️ "이게 왜 여기에...?

그럼 segment에는 어떤 내용을 포함해야할까?

  • ui: UI 컴포넌트 - 사용자에게 보여지는 시각적 요소들
  • model: 데이터와 비즈니스 로직, 스키마, 상태관리 로직, 도메인 로직
  • lib: 비즈니스를 포함하지 않은 로직, 유틸리티 함수들
  • api: 백엔드와의 상호작용, 데이터 타입 정의, 데이터 변환 로직

Segment는 일반적으로 ui, api, model, lib와 같은 명칭으로 구분되는데, FSD에서는 팀 혹은 개인의 결정에 따라 유연하게 변경할 수 있다고 강조해요.

"그럼 너무 생소한데 그냥 components, hooks, utils, types 처럼 익숙하게 구분할래!" 하는 생각이 들 수 있어요. 물론 이게 틀린 것은 아니지만, FSD에서 추구하는 방향과는 거리가 멀어지죠, 참고로 제가 했던 실수에요 ㅎㅎ..

FSD에서는 세그먼트 이름이 '무엇인지'가 아니라 '무엇을 하는지'를 표현하는 것이 중요해요.기술 중심의 분류(components, hooks)보다 목적 중심의 분류(ui, model)가 코드의 의도를 더 명확하게 드러낸다는 철학이죠 .

과학적 원리

프로젝트의 방향을 결정하는 Entities

Entities는 프로젝트의 본질적인 개념을 나타내는 레이어에요. 쉽게 말해 우리 프로젝트에서 다루는 현실 세계의 객체들이죠. 사용자, 게시물, 피드백 같은 도메인 데이터와 관련 로직을 관리하는 핵심 계층이에요

다시 본론으로 넘어와 Entities 레이어는 FSD 아키텍처에서 방향을 결정하는 중요한 역할을 해요. 상위 레이어가 하위 레이어를 참조할 수 있지만, 하위레이어는 상위를 참조할 수 없는 규칙 때문에, Entities에서 방향을 잘못 설정하면 전체 구조를 수정해야 하는 대참사가 일어날 수 있죠. 😱

이전에도 언급했던 기초가 튼튼하지 않으면 결국 흔들리게 되니까요. 그래서 저는 특히나 더 entities 레이어에 신경 썼던 것 같아요. 그래서 Entities의 세그먼트 구성을 명확히 정의해봤어요.

UI: 보여주기만 할게요
UI는 데이터를 시각적으로 표현하는 데 집중하며,Feature와의 경계를 명확하기 위해 다음과 같은 기준을 설정했어요

  • 데이터를 시각적으로 표현하는 목적에만 집중 합니다
  • props를 통해 상호작요에 관여하기도 하지만 비즈니스 로직에는 관여하지 않습니다. 순수한 표현의 목적이에요
  • 해당 도메인과 관련된 자주 사용하는 스타일을 관리해요

API: 서버와의 대화창구
특정 도메인의 서버와 통신을 담당합니다.

  • 프로젝트에서 서버와 직접적으로 연결되는 유일한 통로예요
  • 저희 프로젝트에서는 react-query를 사용하기 때문에 querykey, mutations, queries등으로 관리해요
  • 일부 프로젝트에서는 react-query를 사용하는 경우 lib으로 분류하여 처리하지만, 저는 api가 더 직관적이라고 생각했어요. "여기가 데이터를 가져오는 곳!"이라고 명확히 알려주니까요.

Model: 데이터의 심장부
특정 도메인의 저장소나 해당 데이터의 조작 함수를 관리하고 데이터와 직간접적인 로직을 처리해요.

  • 해당 도메인의 zustand store를 관리해요
  • zod를 사용해 데이터 스키마를 정의해요.
  • 데이터 타입을 관리해요.

lib: 만능 도구상자
저장과 관련이 없는 entities 인스턴스를 조작하는 함수들이 모여 있어요. 솔직히 이 부분은 가장 정의하기 애매했어요.

  • 정확한 목적은 잘 모르겠어요.
  • 프로젝트마다 제각각이라 queries, mutations 훅을 관리하는 경우도 있고,
  • translate와 관련된 다중 언어 지원,
  • storage를 관리하기 위한 유틸리티 등 다양한 용도로 활용되고 있었어요.

공개 API: 보이는 것과 숨겨진 것의 균형

또 다르게 entities에서 고민했던 부분은 Public API를 어떻게 설정해야 상위 레이어에서 원하는 모듈을 바로 끌어올 수 있을까였어요. FSD에서는 슬라이스나 세그먼트로 같은 관련 주제를 모아 응집시키는 것도 중요하지만, 내부에서만 필요한 모듈과 외부에 노출할 모듈을 구분하는 캡슐화도 핵심이에요.

문제는 "어떻게 하면 캡슐화된 모듈을 외부에서 쉽고 명확하게 사용할 수 있을까?"에 대한 고민이었어요. 마치 라이브러리를 사용할 때처럼, 복잡한 내부 구현은 숨기고 필요한 기능만 깔끔하게 제공하고 싶었거든요.

캡슐화

FSD에서 추구하는 모듈 임포트 방식은 다음과 같아요:

`import { authSchema, authType } from '@/src/entities/auth';"` 

이처럼 entities/auth의 내부가 어떻게 구성되어있는지는 feature의 관심이 아니에요. feature관점에서 그냥 entities/auth 도메인이 제공하는 모듈만 사용하는 거예요. entities/auth에서 가져오는 코드 내부가 어떻게 구성되어있는지보단 무엇을 하는지에만 집중하는 거죠.

그럼 내부에서는 어떻게 모듈을 내보내야 원하는 것에 쉽게 접근할 수 있을까요? 이게 제 고민의 핵심이었어요.

export * as authMutations from './api/mutations';
export * as authType from './model/type';
export * as authSchema from './model/schema';
export { default as useAuthStore } from './model/useAuthStore';

관련 모듈을 재내보내기(re-export)하는 방식에는 크게 두 가지가 있었어요

1. 직접 모듈 내보내기 (export * from) :

이 방식은 모든 모듈을 그대로 외부에 노출해요. 사용할 땐 이렇게 깔끔하게 가져올 수 있죠

import {
  useAddInterViewQ, 
  useDeleteInterViewQ, 
  useFetchRandomQuestion
} from '@/src/entities/interview_question';

// 사용할 때도 간결해요
const { mutate: dispatchAddQuestions } = useAddInterViewQ();

장점은 사용법이 직관적이고 익숙하다는 거예요. 하지만 모듈이 많아지면 import 문이 길어지고, 무엇보다 사용자가 내부 구조를 어느 정도 알고 있어야 한다는 단점이 있어요.

2. 네임스페이스 사용 (export * as):

제가 선택한 방식은 관련 기능을 네임스페이스로 묶어서 내보내는 거였어요

import {
  interviewMutation,
  interviewType
} from '@/src/entities/interview_question';

// 사용할 때는 네임스페이스를 통해 접근해요
const { mutate: getRandomQustions } = interviewMutation.useFetchRandomQuestion();

이 방식의 장점은 관련 기능이 명확하게 그룹화된다는 거예요. interviewMutation.만 입력해도 자동완성으로 사용 가능한 모든 뮤테이션을 볼 수 있죠. 특히 처음 코드를 접하는 사람도 어떤 기능이 있는지 쉽게 탐색할 수 있어요.
물론 단점도 있어요. interviewMutation.이라는 접두어가 항상 붙기 때문에 코드가 조금 더 길어지고, 처음에는 익숙하지 않아 이질감을 느꼈어요. 마치 jQuery를 사용할 때 $를 붙이는 것처럼 말이죠.

모든 레이어에 공개 API 패턴을 적용하는 것이 원칙이에요. 저는 모든 모듈을 네임스페이스 방식으로 통일하진 않았고, entities의 경우에만 이렇게 처리했어요. 이 방식에서 생긴 문제점은 이어서 설명해 볼게요.

Three Shaking 급격하게 증가하는 번들사이즈

이런 원칙들을 적용해서 기존 코드들을 수정하고보니 예상치 못한 문제로 고통 받았어요. shared 레이어에서 반복되는 코드를 모듈화하여 30% 이상 줄였는데, 오히려 페이지의 First Load JS가 2배 이상 증가한 거예요! 하나만 그러면 모를까 전체적으로 문제가 생기니 원인 파악이 정말 힘들었죠. 😭😭😭😭😭😭😭

다행히 리팩토링 초기 단계에서 발견했기에, 문제가 있는 페이지들의 공통점을 하나하나 비교하면서 원인을 찾을 수 있었어요. 빈 페이지를 만들어 모듈을 하나씩 추가해보니 shared의 공용 UI 키트가 주범이었죠. 나중에 발견했다면... 생각만 해도 아찔해요!

문제 해결 과정에서 @next/bundle-analyzer라는 보물 같은 도구도 알게 됐어요. 이 도구로 각 모듈의 크기와 비중을 시각적으로 확인할 수 있더라고요. 진작 알았다면 좋았을 텐데요! 🤦‍♂️

나무 흔들기

문제의 핵심은 Tree Shaking이었어요. Next.js가 기본적으로 지원하는 기능이지만 어떤 이유에서 인지 제대로 동작을 하지 않았어요.

다행히도 package.jsonsideEffects: false를 설정하니 문제가 해결됐는데, 이 설정이 필요했던 이유가 궁금해 더 파고들었어요. 알고 보니 FSD 구조에서 생긴 여러 단계의 re-export가 원인이었죠.

// shared/ui/markdown
export { default as MarkdownEditor } from './MarkdownEditor';
export { default as MarkdownPreview } from './MarkdownPreview';
// shared/ui
export * from './markdown';

이런 계층적 구조에서는 번들러가 모듈 간 의존성을 정확히 추적하기 어려워요. 특히 export * from처럼 모든 것을 외부로 노출시키면, 번들러는 "이게 어디서 어떻게 쓰이는지" 파악하기 힘들어 사이드 이펙트로 간주하게 되죠.

재미있는 건 export 방식에 따른 차이예요. export * from은 side effect 설정으로 어느 정도 해결되지만, 네임스페이스 방식(export * as)은 번들러가 전체를 사용하는 것으로 간주해요.

이런 차이점을 고려해 레이어별로 다른 전략을 채택했어요. UI 컴포넌트가 많고 무거운 shared 레이어에서는 export * from 방식을 사용했고, 상대적으로 가벼운 API와 데이터 모델 중심의 entities에서는 개발 편의성이 높은 네임스페이스 방식을 유지했어요.

이거 Feature야 ?, Widget이야???

Features 코드를 옮기는 과정에서 저의 머릿속은 꼬리에 꼬리를 무는 물음표로 가득했어요. 🤔 그 중 최고의 난제는 바로 이것! "Feature와 Widget, 대체 뭐가 다른 거지?!"

사실 FSD에서 Widgets 레이어는 "있어도 그만, 없어도 그만"인 선택적 레이어로 소개되기도 해요. 그런데 이상하게도 저에겐 이 '선택적' 레이어가 가장 필수적이었어요. 왜냐고요? 바로 제 오랜 고민을 해결해 주는 열쇠였거든요!

Header의 정체성 찾기

FSD를 도입하기 전, 저를 혼란스럽게 했던 문제가 있었어요.

components/
  ├── Header  👈 확인! ?
  └── Navbar  👈 넌 왜 같이 있어?

대부분의 React 프로젝트에서 Header와 Navbar는 늘 같은 'components' 폴더에 나란히 있었어요. 직관적으로 뭔가 이상했죠. Header는 단순한 컴포넌트가 아니라 여러 기능의 집합체인데, 왜 단순 메뉴바인 Navbar와 동급 취급을 받는 걸까요?

저 역시 이것이 너무 익숙해서, 뭔가 고칠 때마다 틀린 답을 쓰는 기분이었어요. "이래도 되나?" 하는 의문이 항상 따라다녔죠. 그런데 FSD 문서를 읽다 보니 Header는 Widget의 대표 사례로 당당히 등장했어요!

"와, 이거였어!" 마치 문제의 정답을 발견한 것처럼 속이 다 시원했어요. 공식 문서에서 딱 "Header는 Widget이야"라고 정해주니 뭔가 제가 오랫동안 찾던 확신을 얻은 기분이었죠.

하지만... 이 선택 때문에 머리속에서 ???들이 쏟아질 줄 몰랐어요.

위젯 지옥에 오신 것을 환영합니다

이번에도 역시나 이론적으로 정말 단순하고 명확했어요.
"Widgets는 독립적이고 재사용 가능한 UI블록" ..그래, 헤더 같은 거구나! 얼마나 쉽고 명확한가요😄
하지만 기존의 코드의 아키텍처를 옮기 때문에서 인지 모르겠는데, 막상 적용할려면 매번 혼란의 연속이였죠.

아무 생각이 없다

- 유저 플레이리스트 (Feature 👌)
- 추천 질문 선택기 (Feature 👌)
- 둘을 합친 인터뷰 준비 화면 (????? 🤯)

이 "합쳐진" UI는 대체 어디에 두는 게 맞을까요?
FSD 공식 문서는 이렇게 말해요: "재사용되지 않는다면 Widget으로 분류하지 말고 Page에 직접 배치하세요." 음... 그런데 이건 왜 페이지가 아닌것 같지..

어김없이 찾아온 강박감

저는 이미 머릿속에 "Widget = 재사용 가능한 독립적 UI 블록"이라는 공식이 박혀있었어요. 그래서 이런식으로 코드를 분류 해봤어요.

1. 유저 플레이리스트 ✓ 독립적? ✓ 기능
2. 질문 선택기 ✓ 독립적? ✓ 기능 
3. 둘을 합친 화면 ✓ 독립적?! ✓ 재사용 가능? 절대 아님!!!! 
4. 그럼 page ??? 음 근데 어떻게 보면 기능이잖아?... 페이지에 왠 기능??

마지막이 진짜 문제였어요. 명백히 독립적인 UI 블록인데, 재사용될 일은 없는 이 녀석을 어떻게 분류할까요?....
반드시 두개의 독립적인 기능이 합쳐져야만 완전한 사용자의 경험을 만들 수 있는데.. 그럼 이건 하나의 "기능"일까요?
"합쳐진 걸 기능 슬라이스로 보고 더 쪼갤까?" 고민도 해봤지만, 그 생각은 금방 접었어요. 이미 제 기준에서 플레이리스트와 선택기 자체가 충분히 큰 슬라이스인데, 여기서 더 쪼개면 나중엔 코드가 끝없이 파고드는 블랙홀처럼 될까 봐 무서웠거든요.

Widget의 경로 찾기: 재사용성 ?????

고민하던 중, 문득 Header 코드를 다시 살펴보게 되었어요.

export default function Header() {
  const role = useAuthStore((state) => state.role);
  return (
    <HeaderContainer>
      <Wrapper>
        <BrandLogo title={BRAND.title} path="/" />
        <NavMenuList navList={navList} />
        {role === null ? <LoginMenuItem /> : <UserDropdown />}
      </Wrapper>
    </HeaderContainer>
  );
  

Header는 대부분 한 번만 사용되는 컴포넌트인데도 FSD는 Widget의 대표 예시로 Header를 소개하고 있었어요!

이 순간 제 머릿속의 "재사용성"이라는 강박관념이 깨지기 시작했어요. 재사용성만이 Widget을 정의하는 절대적인 기준은 아닐 수도 있겠다는 생각이 들었죠.

더 깊이 생각해보니 Widget의 핵심은 "여러 기능을 조합한 독립적인 UI 블록"이라는 점이었어요. Header도 BrandLogo, NavMenu, UserDropdown 등 여러 기능적 요소를 조합한 UI 블록이잖아요!

그런데 여기서 중요한 깨달음을 얻었어요. 제가 재사용성을 너무 좁게 해석하고 있었던 거예요. 코드 차원에서의 재사용만 생각했는데, 실제로는 화면상에서의 재사용이 더 중요한 개념이었어요.

Header는 코드를 여러 번 호출하지는 않지만, 레이아웃의 일부로서 모든 페이지에서 지속적으로 나타나잖아요. 이것 자체가 일종의 재사용인 거였죠. 마치 건물의 지붕처럼, 한 번 만들어지지만 건물 전체에 걸쳐 기능하는 것처럼요.

다시 한번 정리하기

이런 생각을 하다 보니, 두 레이어를 바라보는 새로운 관점이 생겼어요.

  • feature - 사용자가 "무엇을 할 수 있는지"에 초점
  • widget - 기능들을 "어떻게 돋보이게 할지"에 초점
import styled from 'styled-components';

import { ResizableSplitPanel } from '@/src/widgets/resize-panel';

import {
  ExpectedQuestionSelector,
  UserQuestionPlayList,
} from '@/src/features/interview/';

import { ExplanationContent } from '@/src/shared/ui';
import { styleMixin, V } from '@/src/shared/styles';

const explanationList: string[] = [
  '면접 질문 설정',
  '- 플레이리스트에 등록된 질문들이 순서대로 재생됩니다',
  '- 드래그로 순번 변경이 가능하며, 영역 밖으로 끌어내면 삭제됩니다',
  '- 예측할 수 없는 질문을 원한다면 랜덤 모드를 활용하세요',
  '- 선택 모드와 랜덤 모드를 함께 사용할 수 있습니다',
  '- 랜덤 질문만 원한다면 이 단계를 건너뛰어도 됩니다',
];

/**
 * 인터뷰 질문 설정 레이아웃
 *
 * - ExplanationContent : 사용자 가이드를 표시합니다.
 * - ExpectedQuestionSelecotr : 관리자가 제공하는 질문을 선택
 * - UserQuestionPlayList : 유저가 선택한 질문 + 커스텀 질문
 */
function ExpectedQuestionLayout() {
  return (
    <InterviewListViewStyle>
      <ExplanationContent explanationList={explanationList} />
      <ResizableSplitPanel>
        {(expandedPanel) => (
          <SwitchContainer>
            <ResizableSplitPanel.LeftPanel>
              <ExpectedQuestionSelector />
            </ResizableSplitPanel.LeftPanel>
            {expandedPanel === null && <GapDivider />}
            <ResizableSplitPanel.RightPanel>
              <UserQuestionPlayList />
            </ResizableSplitPanel.RightPanel>
          </SwitchContainer>
        )}
      </ResizableSplitPanel>
    </InterviewListViewStyle>
  );
}

결국 문제의 인터뷰 준비 화면은 FSD 팀이 권장하는 대로 Page에 분류하기로 했어요. ㅎㅎ

왜 그렇게 복잡하게 많은 생각을 했었는지... 저는 의도치 않게 Page를 단순히 "라우팅의 끝점"으로만 바라보고 있었던 거예요ㅎㅎ.. 하지만 Page의 진짜 목적은 여러 개의 컴포넌트를 조합해서 완성된 사용자 경험을 만드는 것이 잖아요? 마치 요리사가 준비된 재료들을 조합해 하나의 요리를 완성하는 것처럼요.
다만 한 가지 주의할 점이 있다면, 페이지에서 직접적인 비즈니스 로직에 관여하기 시작하면 책임이 모호해질 수 있기 때문에 조심해야 한다는 것이에요. 그래서 저는 페이지 내에서는 비즈니스 로직을 다룬다면 사용자와 상호작용했을 때 페이지가 변화하는가에 중점을 두고 다뤘어요. 혹은 페이지 전체를 컨트롤 하는가? 키보드 상호작용 같은 로직말이죠

코드 구성요소들의 배치이유

혹시 코드를 보고 저의 관점이 궁금하실 분들을 위해 각 컴포넌트의 분류 기준을 설명드릴게요.

shared 레이어의 요소들

  • styleMixin : 프로젝트 전반에서 재사용되는 스타일 조합
  • V : 폰트 사이즈, 패딩 , 쉐도우 등의 디자인 토큰
  • ExplanationContent : 전체적으로 활용되는 안내문 UI 컴포넌트

feature 레이어의 요소들

  • ExpectedQuestionSelector : 관리자가 제공하는 면접 질문 선택 기능
  • UserQuestionPlayList : 사용자 맞춤 면접 질문 목록 관리 기능

ResizableSplitPanel은 왜 Widget?

특히 ResizableSplitPanel을 Widget으로 분류한 이유가 궁금하실 텐데, 이건 좀 애매한 케이스에요 ㅎㅎ
ResizableSplitPanel은 두 개의 섹션 사이즈를 조절해서 하나의 섹션만 보여주거나 둘을 분할해서 보여주는 역할의 컴포넌트예요. 사실 이 친구는 어디에 둬도 크게 개념에서 벗어나지 않는 애매한 포지션이에요.

Shared에 둬도 ✅ (재사용 가능한 UI)
Feature에 둬도 ✅ (특정 기능과 연관)
Widget에 둬도 ✅ (여러 기능을 조합)

그런데 왜 하필 Widget으로 배치했냐면 제어를 하고 싶었어요.
만약 Shared에 있다면 Feature에서도 직접 사용할 수 있고, Feature에 있다면 Widget에서도 가져다 쓸 수 있거든요. 하지만 저의 설계 관점에서는 이 컴포넌트를 "페이지 내에서 두 개의 Feature를 화면상에서 조합할 때만 사용되는 조합 도구"로 두고 싶었어요.
그래서 일종의 관점 이탈을 사전에 방지하기 위한 장치로 Widget에 배치한 거예요.

유연함과 일관성 사이의 균형

오랜 시간 Feature-Sliced Design을 적용하면서 깨달은 것은 아키텍처는 엄격한 법칙이 아니라 팀의 이해와 협업을 돕는 도구라는 점이였어요. 때로는 원칙을 유연하게 해석하되, 그 이유가 확실하다면 약간의 변형은 괜찮을지도... 물론 너무 자의적으로 해석하면 아키텍처의 의미가 퇴색되겠지만, 적당한 합의점을 찾아가며 판단하는 것이 오히려 프로젝트를 더 건강하게 만든다고 생각해요.

https://emewjin.github.io/feature-sliced-design/
https://velog.io/@jay/fsd

profile
성장은 점진적 과부하, 매주 회고를 목표로 시작했지만 그때 그때 컨셉이 달라요. 시행착오를 통해 저만의 방식을 찾아가는중입니다.

0개의 댓글