회사에서 프론트엔드는 FSD 아키텍처로 만들어요. 저에게는 2번째로 진행하는 FSD 아키텍처 프로젝트에요. FSD로 직접 구현해보기 전에는 레이어를 나누어 역할을 분리하고, 의존성이 단방향으로 흐르고, 팀원 누가 봐도 파일이 어디에 있는지 금방 찾고, 유지보수도 더 쉬울거라고 다들 생각해요.
하지만 늘 그렇듯, 실무에서 레이어를 "어디에 놓을지" 결정하는 것 자체가 매우 어려워요. 이 글은 판단의 변화를 솔직하게 정리한 회고에요.
[결제 요청 정보 표시] + [요청 취소 액션] 역할을 수행하는 Card 컴포넌트를 만들었어요. 이 때 결제 요청 정보 표시는 Entity 레이어에서 구현하고, 요청 취소 액션은 Feature 레이어에서 구현하면 알맞은 분리 같아요. 하지만 저는 Card 컴포넌트 자체를 Entity 레이어에 포함하는게 낫다고 생각해서 entities에서 취소까지 구현했었어요.
entities/ payment-request/ ui/ PaymentRequestCard.tsx ← 취소 버튼이 여기 있음 model/ api/
위 구조의 문제는 FSD 원칙과는 벗어났어요. (혹시 이에 대해 반발하시고 아키텍처가 정해진대로 사용하는 것은 아니라는 것에 100% 동의해요!! 저 또한 그렇게 사용하고 그렇게 사용해야만 한다고 생각해요. 하지만 원칙적으로만 이야기 하는거에요.) features 레이어에서 사용자 행동(action)을 처리하고 entities에서 비즈니스 모델 단위를 표현하는데 집중하면 더 좋겠죠.
그래서 원칙에 맞게 바꿔봤어요.
features/ cancel-payment-request/ ui/ CancelPaymentRequestButton.tsx model/ useCancelPaymentRequest.tsentities/ payment-request/ ui/ PaymentRequestCard.tsx ← 버튼을 props로 주입받는 구조// widgets에서 조합<PaymentRequestCard data={data} action={<CancelPaymentRequestButton id={data.id} />} // 외부 주입/>이렇게 바꿔보니 PaymentRequestCard 컴포넌트에서 취소 버튼 위치를 레이아웃으로 잡는데, props로 받아서 {action} 슬롯에 렌더링하는 구조가 되었어요. entities에서는 사전에 무슨 액션이 올것이고 해당 액션이 들어갈 자리를 미리 알고 구현한다는 점에서 이미 의존성을 가지기 때문에 FSD 아키텍처 계층에서 모순으로 느껴지기도 했어요.
그래서 Card 컴포넌트를 widget으로 올리는게 더 자연스럽다고 생각해서 Widget레이어로 올렸어요. 왜냐하면 action + entity를 모두 나타내자나요.
const PaymentRequestCard = ({ data }) => { const { cancel } = usePaymentRequest(data.id); return ( <Card> <CardContent>{data.title}</CardContent> <Button onClick={cancel}>요청 취소</Button> </Card> );};최종 결과는
변경 전: entities/payment-request/ui/PaymentRequestCard.tsx
변경 후: widgets/payment-request-card/ui/PaymentRequestCard.tsx
이렇게 바꼇어요. "데이터 표현 + 액션"이 함께 있을 때는 widget 레이어로 올리는 게 맞다고 생각해요.
아키텍처를 사용하면서 코드 위치가 이상하다고 생각되면, 아키텍처의 의도에 맞게 생각을 못하고 있을 확률이 높은 것 같아요. 당연한 말이지만 기능 분할 아키텍처이면 기능과 역할을 보고 분할을 해야겠죠.
FSD 문서(공식인지는 모르겠지만?? 왜냐하면 개인적으로 아키텍처에 너무 몰두하는 것 같아 동의하지 못하는 부분이 많아서.)는 layer별로 가장 밀접한 기능에 api를 위치시키도록 가이드라인을 제공해주고 있어요. 그런데
저는 FSD 문서의 설명과 다르게 api 위치 Eintities로 몰아넣었어요. 왜냐하면 CRUD api들이 모두 Entity와 밀접한 관련이 있는 것인데 더 세부적으로 밀접한 관련이 있는 곳에 api를 위치하면 찾기가 더 힘들꺼라고 생각했어요.
entities/ credit/ api/ index.ts fetchCreditXXX.ts postCreditXXX.ts keys/ getCreditOptions.ts ← queryOptions chargeCreditOptions.ts ← mutationOptions useCreditOptions.ts ← mutationOptions그리고 api layer에서는 모든 DTO를 앱 내부에서 사용하는 Entity 타입으로 변환하는 책임을 함께 가지고 있어요. DTO => Entity로 변환되는 부분이 Entity 레이어 1곳으로 압축할 수 있어서 훨씬 관리가 편하다고 생각했어요.
하지만 단순히 기능과 연관없이 Entity 레이어에 api를 몰아두는 것은 FSD 아키텍처 사상과는 좀 맞지 않은 것 같아서 더 좋은 방법이 있지 않을까 고민했어요.
그래서 DTO => Entity를 변환하는 책임을 queryOptions와 queryMutaions로 옮기고, 순수 api 호출 함수와 엔드포인트는 모두 /shared/api로 옮기는 방법을 생각해봤어요. 이렇게 하면 여전히 의존성 방향은 그대로 유지하고, fetch 함수의 단일 책임 원칙과 재사용성을 더 확보할 수 있을거라고 생각했어요.
shared/ api/ credits/ fetchCredit.ts ← 순수 HTTP 함수만 postChargeCredit.ts postUseCredit.tsentities/ credit/ api/ getCreditOptions.ts ← queryOptionsfeatures/ charge-credit/ api/ chargeCreditOptions.ts ← mutationOptions use-credit/ api/ useCreditOptions.ts ← mutationOptions// entities/credit/api/getCreditOptions.tsexport const getCreditOptions = queryOptions({ queryFn: async () => { const dto = await fetchCredit(); return toCreditEntity(dto); // ← 변환은 여기서 }});이렇게 구현하면 분면 FSD의 원칙은 잘 지켜져요. 하지만 Adaptor 역할을 하는 코드가 곳곳에 뿌려지고 그 로직은 queryOptions에 숨어들어가요. 그리고 참고해야할 의존성 방향을 추적하기 복잡해져요.
그래서 지금은 이러지도 저러지도 못하는 상태에요. 제 생각에는 아직 FSD로 나눌만큼 코드베이스가 크지 않기 때문에 불편함을 느끼고 있다고 생각해요. 현재 코드베이스에 맞게 더 많은 고민을 해봐야할 것 같아요.
정리하자면 저의 생각 흐름은 아래와 같아요.
처음엔 entities/credit/api에 전부 몰아넣었다 → 도메인 응집도는 높지만 FSD 아키텍처 사상과 맞지않음
shared/api로 순수 http 통신 함수를 빼고, options를 레이어별로 분리하고 Entity 책임까지 주면 어떨까 → 원칙에는 가깝지만 DTO 변환 위치가 흩뿌려짐
매번 한 문제를 해결하면 다른 문제가 딸려와서 트레이드오프 관계에 놓여요. 아무래도 지금 상태에서는 entities에 몰아 넣어도 문제를 못느끼기 때문에 1번이 정답이고 나중에 코드 베이스가 커지면 2번이 정답이 되지 않을까요?
FSD 아키텍처에서도 하나의 page에서만 사용할 코드는 page 레이어에 두라고 튜토리얼에 명시해주고 있어요. 하지만 짜증나는 것은 도메인 모델은 entities에, 액션은 features에 배치하기를 원하는 것 또한 FSD 아키텍처 튜토리얼에서 원하는 거에요. 그리고 개념적으로 깔끔하게 느끼는 것은 entities와 features로 분리하는 거지만, 실제 코드적으로 깔끔하게 확인 가능한 것은 page 레이어에 잘 분리해서 코드를 배치시키는 거에요.
예를 들면 Credit 페이지에서만 "잔액 배너"가 쓰인다고 해봐요. 그런데 Credit Entity와 가장 밀접한 관련이 있어요. 그래서 아래와 같이 배치했어요.
entities/ credit/ ui/ CreditBalanceBanner.tsx ← 잔액 배너 CreditHistoryHeader.tsx ← 내역 헤더그런데 잔액 배너는 사실 1개의 페이지에서만 사용되고 단 한번도 재사용된 적이 없어요. 그리고 다른 페이지에서 재사용될 가능성도 없어보여요. 그래서 저는 실제로 entities에서 잔액 배너 ui를 page로 올려버렸어요. 이게 훨씬 맞다고 생각어요. 재사용하지 않을 코드를 굳이 "자동 완성"으로 api를 공개할 필요는 없자나요.
pages/ credit/ ui/ CreditPage.tsx CreditBalanceBanner.tsx ← 여기서만 쓰니까 여기에 CreditHistoryHeader.tsx그래서 저는 이런 방식의 코드가 더 좋은 것 같아요. 여차하면 api나 훅까지 함께 올려버려도 좋다고 생각했어요. 하지만 저는 api나 훅이 사방팔방 흩어져 있으면 코드 파악이 더 힘들 것 같아서 자제했어요.
위 고민들을 하면서 중요한 것은 너무 아키텍처에 갇히거나, 분리해야하는 강박에 갇히거나, 단일 책임 강박에 갇히는 것 또한 저의 사고의 자유를 많이 막는다는 것을 느꼈어요. 중요한건 큰 흐름인거자나요. 그리고 FSD가 무엇을 위해 만들어 졌고, 그 무엇을 해결하기 위해 잘 활용해야하는 거고요. 방법에 몰두하지 않고 의도에 더 신경써서 고민해야할 것 같아요. 늘 수단은 목적이 아닌데 수단을 고민하고 있자나요. 실제로는 수단을 활용하는 것이 목적이 아니더라도요.
상황 | 처음 판단 | 결과 |
|---|---|---|
취소 버튼이 포함된 카드 | entities에 카드 배치 | widgets으로 이동. 데이터 표현 + 액션이 함께 있으면 Widget 이상의 레이어로. |
Feature 버튼 주입 | props injection으로 분리 | → 오히려 복잡해지고 가독성을 해침 |
도메인 API 위치 | entities/credit/api에 CRUD 전부 → shared/api 분리 구상 | → 해결하면 DTO 변환 위치 문제가 딸려옴. 코드베이스규모에 따라 정답이 달라질 것으로 보임. |
특정 페이지 전용 UI | entities/credit/ui에 배치 | → 재사용도 안 되는 컴포넌트가 Entity에 쌓임. Page에 두는 게 나을 수도 |
저는 사실 FSD 아키텍처를 싫어해요. 뭔가 튜토리얼이 FSD 아키텍처 구조를 강제하는 느낌이에요. 그래서인지 몇년간 몇몇 레이어는 아에 공식에서 빼버리기도 했어요. 그리고 사람들은 FSD 아키텍처 아이디어만 가져오고 각 회사의 코드나 사상에 맞게 레이어 이름과 개수 목적등을 분리하는 경우가 많아요.
FSD 아키텍처는 확실히 초보자가 쓰면 오히려 초보자를 망칠 수 있는 아키텍처 같아요. 본인이 유연하게 아키텍처의 일부만 빌려 장점만 취하거나 코드에 맞게 사용할 줄 알아야하는데 그럴 능력이 없잖아요. 그래서 고수들을 위한 아키텍처 같아요. 고수라면 항상 이 아키텍처 규칙 그대로 사용하지는 않고, 필요할때는 사상을 차용해서 본인이나 조직의 입맛대로 바꿔서 더 유연하게 사용할 것 같아요.