FSD(Feature-Sliced Design) 알아가기

poyal·2024년 7월 9일
1
post-thumbnail

이글은 회사 동료가 FSD라는 것이 있다고 공유해 줘서 내 마음대로 적용해보고 적는 글입니다.

FSD 너는 뭐냐?

FSD 흐름도

FSD는 몇가지의 중요한 부분들이 있다고 느꼈습니다.

  • 의존성의 분리
  • 역활의 분리

이런 부분이 FSD의 중점이라고 느꼈습니다. 내가 느낀 FSD의 기본요건이 폴더 구분을 통한 의존성 분리 및 각 layer별로 명확한 역활이 분리되어야 한다는 것이다. 각각의 layer가 가지고있는 의미는 명확합니다.

Layers

우리는 이전에도 나름의 기준대로 layer를 구분하고 있었습니다.

├── src
│   ├── app
│   │   ├── {{layers}}
│   │   │   ├── {{slices}}
│   │   │   │   └── {{segments}}
│   │   │   └── shared // slice의 공통화 부분
│   │   └── shared // 전체 공통화 부분
│   ├── core // 비지니스 없는 코어 부분
...

이러한 방식으로 나름의 의존성을 구분하고 있었는데 프로젝트가 커져갈수록 의존성이 복잡해 지는 문제가 생겼습니다. FSD는 이러한 방식을 해결하려는 노력을 보여줍니다. 각각의 layer들로 역활을 명확하게 하고, 각각의 layer별로 참조하는 방식을 강력하게 제한하여 의존성을 분리하려는 노력을 한다는 것입니다. 이러한 노력이 어느정도 의존성을 해결한것 같아보입니다. FSD 공식 문서에서도, 여러 블로그에서도 여러가지 layer들에 대한 해석을 하였지만, 나만의 방식으로 각각의 layer들을 분류해 보았습니다.

shared

전체 entities, features, widgets, pages에서 공통적으로 사용되거나, 다중에서 사용되어 공통 코드가 많이 발생되는 부분에서 공통화를 시키는 부분을 포함합니다. app과 상당히 유사한 부분을 띄는데 이부분을 app과 분리지은 기준은 화면 비지니스에 표함되어 도움이 되고, 화면이 아닐 것 으로 기준 해석하여 해당부분을 분리했습니다. sharedslices 하지않는 대상입니다.

└── shared
    ├── core // 프로젝트 공통
    ├── directive
    ├── filter
    └── service

entities

entities는 데이터의 관리, 상태관리, 이벤트 관리에 중점을 둔 영역으로 정의 했습니다. 상위 영역들에서 공통적으로 불러오는 데이터들을 관리하고, 정의하는데 중점을 두었습니다.

└── entities
    └── {{slices}}
        ├── model
        ├── store
        ├── test // store 단위 테스트영역
        └── index.ts

example)

// model
import {Attribute, XssRequest, XssResponse}        from '@shared/core/decorator';
import {IsNotEmpty, IsNumber, IsString, MaxLength} from '@shared/core/decorator/validator';

export namespace AlbumModel {
  export namespace Request {
    export class Add {
      @Attribute('타이틀')
      @XssRequest()
      @IsString() @IsNotEmpty() @MaxLength(200)
      title!: string;

      @Attribute('유저 아이디')
      @IsNumber() @IsNotEmpty()
      userId!: number;
    }
  }

  export namespace Response {
    export class FindAll {
      @Attribute('아이디')
      id!: number;

      @Attribute('타이틀')
      @XssResponse()
      title!: string;

      @Attribute('유저 아이디')
      userId!: number;
    }

    export class FindOne {
      @Attribute('아이디')
      id!: number;

      @Attribute('타이틀')
      @XssResponse()
      title!: string;

      @Attribute('유저 아이디')
      userId!: number;
    }
  }
}

// store
import type {Ref, ref}      from 'vue';
import type {AxiosResponse} from 'axios';
import {defineStore}        from 'pinia';

import Container   from '@shared/core/container';
import SampleAxios from '@shared/core/axios/sample.axios';
import Mapper      from '@shared/core/service/mapper.service';

import {AlbumModel}  from '@entities/album';

export const useAlbumStore = defineStore('album-store', () => {
  const sample: SampleAxios = Container.resolve(SampleAxios);
  const mapper: Mapper = Container.resolve(Mapper);

  const one: Ref<AlbumModel.Response.FindOne> = ref(new AlbumModel.Response.FindOne());

  function setOne(id: number) {
    return sample.get(
      `/albums/${id}`,
    ).then((response: AxiosResponse<AlbumModel.Response.FindOne>) => {
        one.value = mapper.toObject(AlbumModel.Response.FindOne, response.data);
      }
    );
  }

  function getOne(): AlbumModel.Response.FindOne {
    return mapper.toObject(AlbumModel.Response.FindOne, one.value);
  }

  return {one, setOne, getOne};
});

features

features는 비지니스가 정의되지 않은 컴포넌트의 모음으로 정의하였습니다. 다른 부분들에서는 비지니스가 포함된 영역들이 해당 features에 포함되어야 한다고 하였지만, 그러면 또다시 shared가 비대해 질것을 우려하여 해당부분을 비지니스가 없는 컴포넌트의 모음으로 설정했습니다. 각각의 ui 별로 storybook을 작성하여 컴포넌트간의 테스트가 가능하도록 정의 하였습니다.
예) 전역 에디터, 전역 인풋, 검색 버튼

└── features
    └── {{slices}}
        ├── stories // ui stoybook
        ...
        └── ui

example)

<script lang="ts" setup>
export interface Props {
  modelValue: string | undefined;
}

const props = defineProps<Props>();
const emits = defineEmits(['update:modelValue', 'change']);

function input(event: Event) {
  emits('update:modelValue', setValue((event.target as HTMLInputElement).value));
}

function change(event: Event) {
  emits('change', setValue((event.target as HTMLInputElement).value));
}

function setValue(value: unknown): string | unknown {
  let returnValue: string | undefined = undefined;

  if (!!value) {
    returnValue = `${value}`;
  }

  return returnValue;
}
</script>

<template>
  <input :value="props.modelValue"
         type="text"
         @input="input($event)"
         @change="change($event)"/>
</template>

widgets

widgets 여기서 부터는 비지니스가 결합 되어야 합니다. 비지니스가 없는 데이터 관리, 이벤트관리등은 features, entities에서 정리하고 해당 부분에는 화면에 영향을 주는 비지니스가 정의 되어야 합니다. pages에서 공통으로 관리되거나 코드가 길어져서 분리해야 되는 부분들을 widgets에 정리하여 독립적으로 관리될수 있어야 합니다.
예) 앨범 검색바, 하트표시, 앨범설명 에디터

└── widgets
    └── {{slices}}
        ├── stories // ui stoybook
        ...
        └── ui

example)

<script lang="ts" setup>
import {AlbumModel} from '@entities/album';

import InputText    from '@features/input/ui/input-text.vue';
import ButtonManual from '@features/button/ui/button-manual.vue';

export interface Props {
  modelValue: AlbumModel.Request.Search;
}

const props = defineProps<Props>();
const emits = defineEmits(['search']);

function onSearch() {
  emits('search', props.modelValue);
}
</script>

<template>
  <input-text v-model="props.modelValue.title"/>
  <button-manual @click="onSearch">SEARCH</button-manual>
  {{ modelValue }}
</template>

pages

pages에서는 마지막에 화면에 표시될 영역이 해당 부분에 보여지게 됩니다. 이 영역에서는 하위의 영역 shared, entities, features, widgets를 사용하여 마지막으로 화면을 표현해야 하는 영역입니다.
예) 앨범 리스트, 앨범 조회, 앨범 등록등...

└── pages
    └── {{slices}}
        ├── router
        ├── stories // ui stoybook
        ...
        └── ui

example)

<script lang="ts" setup>
import type {Ref}    from 'vue';
import {ref, watch}  from 'vue';
import {storeToRefs} from 'pinia';
import {useRouter}   from 'vue-router';

import {ResultEnum, ResultModel}   from '@entities/result';
import {AlbumModel, useAlbumStore} from '@entities/album';

import InputText    from '@features/input/ui/input-text.vue';
import InputNumber  from '@features/input/ui/input-number.vue';
import ButtonManual from '@features/button/ui/button-manual.vue';

const router = useRouter();

const add: Ref<AlbumModel.Request.Add> = ref(new AlbumModel.Request.Add());

const store = useAlbumStore();
const {result} = storeToRefs(store);

function onAdd() {
  store.setAdd(add.value);
}

watch(result, () => {
  const result: ResultModel = store.getResult();
  if (result.action === ResultEnum.ADD) {
    window.alert('ADD COMPLETE');
    router.back();
  }
});
</script>

<template>
  <div>
    <label>title</label>
    <input-text v-model="add.title"/>
  </div>

  <div>
    <label>userId</label>
    <input-number v-model="add.userId"/>
  </div>

  <div>
    <button-manual class="button-manual-outline"
                   style="background-color: aqua"
                   @click="onAdd">
      ADD
    </button-manual>
  </div>
</template>

app

마지막으로 app은 프레임워크를 실행하기 위한 전역 application을 해당영역에 설정하고, 그외의 항목들은 하위 레이어에서 풀어야 한다.

alies

각각의 레이어들을 잘사용하고, 구분짖기 위해서 alies를 더욱 세분화해서 사용하였다. 지정된 layer들이 아니면 alies를 사용하지 못하도록 설정 하였다.

// AS-IS
"@/*": ["./src/*"],

// TO-BE
"@app/*": ["./src/app/*"],
"@shared/*": ["./src/shared/*"],
"@entities/*": ["./src/entities/*"],
"@features/*": ["./src/features/*"],
"@widgets/*": ["./src/widgets/*"],
"@pages/*": ["./src/pages/*"]

Slices

Slices에서는 각각의 기능별로 폴더구분을 정하는데, 각각의 폴더가 비슷하지 않아야 한다. User, Person 처럼 비슷한 역활을 하는것을 기피하여 작성해야 한다.

Segments

각각의 Segments들은 Slices 내부의 내용들로만 작성되어야 한다. 공통화나 다른부분에서 사용되어야 하면, 다른 방식으로 풀어야한다. 하나의
Slices에서 Segments들은 여러군데의 Slices에서 참조 되어서는 안되고 단일 Slices에서만 사용되어야 한다.

Storybook에서는?

FSD 스토리북

FSD에 스토리북을 적용하니 보이는것과 같이 이쁘게 정리된다.

그래서 나는?

장점

  • 견고하다.
  • 의존성이 상당수 덜어진다.
  • layer별로 테스트가 쉬워진다.

단점

  • 너무 견고하다.
  • 의존성이 덜어지지만 완벽하지는 않다.
  • 컴포넌트가 파편화 된다.
  • FSD를 공부해야 작업할수 있다. (개발자들의 코드 규약에 의존한다.)

내가 생각한 장단점은 이렇게 되었다. 많은 부분 공부하고 생각하게 되었고, 작업물도 나왔지만 결과적으로는 적용은 보류할것 같다. FSD는 사람이 적으면 적을수록 강력하고, 많으면 많을수록 힘들어지는 디자인 패턴이라고 생각이 든다.

참조

0개의 댓글