FSD란?

FSD는 Feature Slice Design의 줄임말이다. FSD에의 명칭처럼 기능 중심 설계라고 생각하면 된다. FSD는 크게 Layer, Slice, Segment로 나뉜다.

Layer

Layer는 FSD에서 정의한 App, Pages, Widgets, Features, Entities, Shared로 나뉜다. 레이어는 반드시 정의 역할 만 만들어질 수 있다. 그 외의 역할의 자유도를 가지는 폴더 생성은 할 수 없다. 또한 상위에서 하위는 참조할 수 있지만 하위에서 상위 레이어를 참조할 수 없다.

Slice

Slice는 어플리케이션 혹은 도메인 명칭으로 이루어진 컴포넌트 집단이다. 이름과 갯수가 자유롭게 구성될 수 있다. Slice는 형제 Slice를 참조할 수 없다. 또한 그룹 내부에서도 서로 참조가 금지되어있다. 순환 참조, 응집도 해체를 막기 위한 규칙으로 보인다. 만약 참조를 해야한다면 상위에서 조립해서 사용하면 된다. 하지만 entities에서는 cross-import 예외를 허용하기도 한다. 나는 이번 프로젝트에서 entities에서 cross-import 폴더 규칙을 사용해 model을 서로 참조하도록 했다.

Segment

Segment는 표준 segment가 존재한다. 그러나 Layer처럼 이름이 강제되어있지는 않다. api, ui, lib, model, config가 있다. shared 내부에서 segment 이름을 type, queries 같은 이름으로 만드는 것은 안티 패턴으로 지정하고 있다.
예를들어 product type을 만들고 싶다면 type이라는 폴더를 만들고 그 안에 product.ts나 아니면 types.ts를 만들고자 할 것이다. 그러나 FSD 공식 문서는 반드시 목적을 설명하는 이름을 만들어 폴더를 열어 탐색하게 하지 말라고 한다. 예를 들면 model이라는 세그먼트 내부에 productType이라 폴더나 파일을 명명해서 파일 이름만 보고 상품 타입 관련된 집합이라는 것을 나타내야한다.

사용하며 생각했던 것

도서 문헌 분류와 비슷하다

FSD는 도서관에서 특정 도서 위치가 어디인지 빨리 찾을 수 있는 것과 비슷한 역할을 한다. 컴포넌트가 계층과 도메인에 따라 구분되어있기 때문이다. 앞서 살펴봤던 것처럼 폴더 구조에 특정 규칙을 부여하여 목적에 따른 분류를 명확하게 할수 있기 때문이다. 어플리케이션의 복잡도가 증가해 파일이 수백개가 되어도 코드 레벨에서 도메인 응집도를 어느 정도 보장해준다고 생각한다. 목적에 따른 분류가 이전에 사용하던 폴더 구조에 비해 명확하기 때문에 파일 내부에 코드에 무엇이 들어가야하는지도 모호함이 사라진다. 또 레이어에 따라 역할이 명확하기 때문에 새로 생성되는 파일을 분류하거나 해체하여 하나로 합칠때 유용하다. 만약 모바일 주문 시스템을 개발하는 개발자가 FSD를 대략적으로만 이해했다고 하더라도 features에 checkout 이라는 세그먼트 ui 슬라이스에는 결제를 위한 UI 컴포넌트가 모여있다는 것을 알 수 있다.

shared, entities, features를 어떻게 구분 지어야할까?

FSD를 사용할때 shared와 entities, feature를 어떻게 구분 해야하는지 햇갈렸다. 일단 shared는 도메인에 속하지 않으면서 비즈니스 로직을 포함하고 있지 않은 재사용 가능한 컴포넌트 집합이라고 기준을 잡았다. 버튼은 shared에 포함될 수 있다. 어느 계층에서나 button은 디자인 시스템이 정해지면 재사용 할 수 있고 재사용 되는 곳에서 비즈니스 로직을 언제든지 주입할 수 있기 때문이다.

1export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
2 variant: "primary" | "secondary";
3 size?: "small" | "large";
4 color?: "blue" | "red" | "green";
5}
6
7const buttonVariants = cva("button", {
8 variants: {
9 variant: {
10 primary,
11 secondary
12 },
13 size: {
14 small: "text-sm",
15 large: "text-lg"
16 },
17 color: {
18 blue: "bg-blue-500 text-white",
19 red: "bg-red-500 text-white",
20 green: "bg-green-500 text-white"
21 }
22 }
23});
24
25const Button = ({ variant, color, children, ...rest }: ButtonProps) => {
26 return (
27 <button
28 className={buttonVariants[variant]}
29 {...rest}
30 >
31 Button
32 </button>
33 );
34};

entities는 도메인에 속해있는 그러나 기능의 집합은 아닌 것을 모아두었다. 예를 들어 결제와 주문에서 사용되는 bottom sheet에서만 사용되는 버튼이 있는데 여기에는 외부 IP가 적용된 특수한 버튼이어야 하며 주문과 결제에서만 사용된다고 하면 entities > checkout > ui > AnimationCharactorButton.tsx경로에 파일을 만들 수 있다.

1interface AnimationCharactorButtonProps extends ButtonProps {
2 iconName: "Kiwi" | "Peach" | "Monkey";
3 orderType: "prepaid" | "postPaid";
4 price: number;
5}
6
7const AnimationCharactorButton = ({ price, orderType, iconName ...rest }: AnimationCharactorButton) => {
8 const buttonContent = orderType === "prepaid" ? "결제하기" : "주문하기";
9 return (
10 <Button
11 variant="primary"
12 size="large"
13 >
14 <SVG
15 className="absolute top-0 left-0"
16 name={iconName}
17 />
18 {price.toLocaleString()}{buttonContent}
19 </Button>
20 );
21};

feature는 도메인의 기능이라고 볼수 있다면 features로 파일을 묶었다. 위에서 만들어진 버튼은 기능 단위로 묶여 feature에서 재사용 되어진다. features > checkout > ui > CheckoutForm.tsxfeatures > order > ui > OrderForm.tsx에서 설계 목적에 맞게 재사용 할수 있다.

1const CheckoutForm = () => {
2 const { onSubmit } = useForm();
3 const handleOrderSubmit = onSubmit(() => {});
4 return (
5 <form onSubmit={handleOrderSubmit}>
6 <input type="text" />
7 <AnimationCharactorButton orderType="prepaid" />
8 </form>
9 );
10};
1const CheckoutForm = () => {
2 const { onSubmit } = useForm();
3 const handleOrderSubmit = onSubmit(() => {});
4 return (
5 <form onSubmit={handleOrderSubmit}>
6 <input type="text" />
7 <AnimationCharactorButton orderType="prepaid" />
8 </form>
9 );
10};

pages는 역할이 없는데?

기능 단위로 쪼개다보니 pages에는 feature, widget 컴포넌트 몇개가 조합되는 정도로만 사용되었다. pages는 역할이 거의 없다. feautre나 widget에서 조합해서 pages에서 가져와 import만 하고 route에 바인딩 되면 역할이 끝난다. 그런데 시간이 지나면서 코드 양이 많아지면서 레이어에서 복잡하게 횡단하며 개발하는게 상당히 피로해졌다. 그러다 보니 FSD의 효용성에 대해 의심하게 되었다. 하지만 v2.1에서는 pages 레이어의 역할을 강화했다.

  • Page 내부에 주요 UI와 비즈니스 로직을 두고 Shared layer는 순수 재사용 요소만 관리
  • 공통 로직이 실제로 여러 Page에서 쓰일 때만 하위 layer(Feature·Entity)로 분리

FSD Docs Migration 가이드 중

슬라이스에서 재사용되지 않는 컴포넌트는 feautres나 entities로 컴포넌트를 분리하지 않아도 된다. 그렇게 하면 checkout > ui > Checkout > Checkout.tsx라는 페이지 컴포넌트에 책임이 더 많이 부여될 수 있다. 그리고 무의미하게 features로 코드를 쪼개는 일도 하지 않아도 된다. 2.1에서 새로 생긴 규칙은 Public API를 더 주의 깊게 생각하게 한다.

무엇을 공개할 것인가?(Public API)

FSD는 베럴 export를 Public API 규칙으로 사용한다. 그래서 와일드 카드를 사용해 모든 export를 공개하는 것을 지양한다. 만약 와일드 카드로 폴더 전체가 export 되어있다면 처음 프로젝트에 온 개발자는 혼란을 빚게 된다.

1// pages > checkout > ui > index.ts
2export * from "./Checkout";

분명 Checkout 페이지에서 페이지 역할을 하는 컴포넌트가 export 될 것이라고 기대하지만 index.ts를 보면 모든 컴포넌트가 외부로 공개되어있기 때문에 프로젝트를 담당하게된 혹은 함께 개발하는 개발자는 결국 Page 역할을 하는 컴포넌트가 무엇인지 폴더 안에 들어가 일일이 열어보거나 route.ts 같은 라우팅 파일을 열어 직접 색인을 해야한다. 이렇게 사용하면 FSD 효과가 크게 반감된다.

또한 와일드 카드를 사용하면 순환 참조도 생기게 된다. 특히나 v2.1에서 재사용되지 않는 컴포넌트를 features로 쪼개지 않고 pages에 역할을 더 주었는데 그 내부에서 사용하는 UI 조각들이 외부에 공개되면서 시간이 지났을 때 누군가 무작위로 공개된 컴포넌트를 사용하게될 수도 있다.

API 응집도

API를 어느 계층에 두고 호출 해야하는지 고민이 생긴다. 현재까지 같은 API를 재사용하는 경우는 거의 없었다. 어쩌면 API의 설계가 좋지 않아 그럴수도 있지만 다른편으로 생각하면 결국 API도 도메인별로 나누어져있기 때문이다. 예를들어 GET /orders라는 API가 있다면 보통은 OrderHistory.tsx라는 주문 내역 페이지에서 사용하는 경우가 대부분이다. 그런데 만약 OrderListTable.tsx 내부 컴포넌트가 주문이나, 메뉴 화면에서 주문 내역을 확인 할 수 있는데 재사용이 가능하다면 나는 features > ui > OrderListTable > OrderListTable.tsx를 놓게 되고 GET /ordersfeatures > api > order.api.ts에 넣었다. 어쨌든 목적에 맞게 관련성 있는 것들을 응집 시키는편이 이 컴포넌트를 어떻게 유지보수 할것인지 의사 결정을 하는데 도움이 된다고 생각했기 때문이다.

1src/
2├── features/
3└── order/
4├── ui/
5│ └── OrderListTable/
6│ ├── OrderListTable.tsx
7│ └── index.ts
8└── api/
9├── order.api.ts
10└── index.ts
11└── ...

하지만 앞으로 개발되는 어플리케이션에서 API를 사용성을 더 낫게 하기 위해서 타입 추론이 보장되도록 시스템을 만들고 같은 도메인의 API끼리 뭉치기로 했다. 이렇게 하면 레이어의 역할이 더 선명해진다. shared에 api client instance를 두고 entities 계층에서 특정 도메인의 model과 api를 정의한뒤 feature, widget, page에서 가져와 사용할수 있다. 비록 이곳 저곳에 흩어져있는 것처럼 보이지만 오히려 API가 도메인별로 응집도가 높아지고 상위 레이어에서 재사용도 할 수 있어 현재와 미래 모두 가치가 있는 작업이라 생각한다. 물론 이전과 같이 features에 api에 query 함수를 놓아도 된다. 그러나 시작점은 하나가 된다. react-query의 query key를 factory를 만들지 않고 사용 했을 때, 정말 별거 아닌 건데 나중엔 별거가 되어 버린다는 것을 이미 경험했기 때문에 API를 응집시키는 것은 좋은 방향이라고 생각한다.

한계와 극복

FSD는 규칙이다. 린터나 steiger같은 규칙 검사 도구들이 의존성을 잘 지키도록 도움을 준다. 하지만 FSD는 코드 레벨에서 개발자에게 이래라 저래라 하지 않는다. 단지 가이드를 할 뿐이다. 만약 개발자가 pages 레이어에서 UserSetting.tsx라는 페이지를 만드는데 어떠한 것도 분리하지 않고 모든 코드를 한 컴포넌트에 다 쓴다고 해도 FSD는 침묵할 뿐이다. 빌드도 잘되고 런타임에서도 동작도 잘 된다.

FSD 규칙이 무너지고 코드가 뒤죽 박죽 되는 문제를 막기 위한 팀 단위 노력이 필요하다고 생각한다. 나는 FSD를 팀에 전파하기 위해서 공부하고 사용해보자고 이슈를 제시하고 발표를 하여 설득하는 과정을 지냈다. 또한 프로젝트에서 사용하기로 결정 되었을 때, 사용 후에 회고 기회가 주어진다면 팀에 재차 FSD 사용 후기를 발표했다. FSD 문서를 읽고 조금이라도 변경 사항이 있다면 팀 슬렉에 코멘트를 남겨놓는다.

또한 위와 같은 극단적인 사례를 막기 위해서는 코드 리뷰에서 걸러져야한다. 작성된 직후부터 냄새 나는 코드는 수정 해 줄것을 요청한다. 만약 FSD를 적용하는데 어려움을 겪는 팀원이 있다면 가이드 라인을 제시해야한다고 생각한다.

마지막으로 프로젝트를 함께 했던 백엔드 팀 동료가 FSD 설명을 듣더니 DDD를 소개해주었다. 도메인 주도 설계란 무엇인가를 읽고 나니 DDD나 FSD나 어쨌든 해결하고자 하는 문제는 추상화 된 레벨에서 도메인을 명확하게 분리하여 책임과 역할을 분명히 하고 시간이 지날수록 코드 레벨에서 도메인의 응집도가 낮아지는 것을 방지하고자 생겨난 방법론인 것 같다. 하지만 DDD는 조금 더 구체적으로 코드 레벨에서 어떻게 설계 해야 더 유연한 코드가 되는지 구체적으로 간섭을 한다.(물론 이것도 개발자가 안지키면 땡이다.)

함께 이번 프로젝트를 했던 동료와 탕비실에서 커피를 마시면서 빌려줬던 책에 대한 감상을 이야기 했는데, DDD를 당장 코드에 적용하기는 정말 어려울 것이라며 먼저 객체 지향을 공부해보라고 조언해주었다. 그간 SOLID나 객체 지향에 대해서 공부를 안한것은 아니지만 다시금 낮설음을 느끼는 것을 보니 아직은 내가 많이 미숙하다는 생각이 든다. 초등학교 때부터 영어를 했지만 아직도 외국인과 복잡한 의사소통이 불가능한 것과 똑같다. 그래서 의식적으로 공부하기로 했다. 프론트엔드 개발에 객체지향이나 DDD를 조금더 적용해볼수 있다면 FSD가 가지고 있는 한계를 조금더 보완할 수 있지 않을까.