이글은 회사 동료가 FSD라는 것이 있다고 공유해 줘서 내 마음대로 적용해보고 적는 글입니다.
FSD는 몇가지의 중요한 부분들이 있다고 느꼈습니다.
이런 부분이 FSD의 중점이라고 느꼈습니다. 내가 느낀 FSD의 기본요건이 폴더 구분을 통한 의존성 분리 및 각 layer
별로 명확한 역활이 분리되어야 한다는 것이다. 각각의 layer
가 가지고있는 의미는 명확합니다.
우리는 이전에도 나름의 기준대로 layer
를 구분하고 있었습니다.
├── src
│ ├── app
│ │ ├── {{layers}}
│ │ │ ├── {{slices}}
│ │ │ │ └── {{segments}}
│ │ │ └── shared // slice의 공통화 부분
│ │ └── shared // 전체 공통화 부분
│ ├── core // 비지니스 없는 코어 부분
...
이러한 방식으로 나름의 의존성을 구분하고 있었는데 프로젝트가 커져갈수록 의존성이 복잡해 지는 문제가 생겼습니다. FSD는 이러한 방식을 해결하려는 노력을 보여줍니다. 각각의 layer
들로 역활을 명확하게 하고, 각각의 layer
별로 참조하는 방식을 강력하게 제한하여 의존성을 분리하려는 노력을 한다는 것입니다. 이러한 노력이 어느정도 의존성을 해결한것 같아보입니다. FSD 공식 문서에서도, 여러 블로그에서도 여러가지 layer
들에 대한 해석을 하였지만, 나만의 방식으로 각각의 layer
들을 분류해 보았습니다.
전체 entities
, features
, widgets
, pages
에서 공통적으로 사용되거나, 다중에서 사용되어 공통 코드가 많이 발생되는 부분에서 공통화를 시키는 부분을 포함합니다. app
과 상당히 유사한 부분을 띄는데 이부분을 app
과 분리지은 기준은 화면 비지니스에 표함되어 도움이 되고, 화면이 아닐 것 으로 기준 해석하여 해당부분을 분리했습니다. shared
는 slices
하지않는 대상입니다.
└── shared
├── core // 프로젝트 공통
├── directive
├── filter
└── service
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
에 포함되어야 한다고 하였지만, 그러면 또다시 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
여기서 부터는 비지니스가 결합 되어야 합니다. 비지니스가 없는 데이터 관리, 이벤트관리등은 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
에서는 마지막에 화면에 표시될 영역이 해당 부분에 보여지게 됩니다. 이 영역에서는 하위의 영역 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
은 프레임워크를 실행하기 위한 전역 application을 해당영역에 설정하고, 그외의 항목들은 하위 레이어에서 풀어야 한다.
각각의 레이어들을 잘사용하고, 구분짖기 위해서 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
에서는 각각의 기능별로 폴더구분을 정하는데, 각각의 폴더가 비슷하지 않아야 한다. User
, Person
처럼 비슷한 역활을 하는것을 기피하여 작성해야 한다.
각각의 Segments
들은 Slices
내부의 내용들로만 작성되어야 한다. 공통화나 다른부분에서 사용되어야 하면, 다른 방식으로 풀어야한다. 하나의
Slices
에서 Segments
들은 여러군데의 Slices
에서 참조 되어서는 안되고 단일 Slices
에서만 사용되어야 한다.
FSD에 스토리북을 적용하니 보이는것과 같이 이쁘게 정리된다.
내가 생각한 장단점은 이렇게 되었다. 많은 부분 공부하고 생각하게 되었고, 작업물도 나왔지만 결과적으로는 적용은 보류할것 같다. FSD는 사람이 적으면 적을수록 강력하고, 많으면 많을수록 힘들어지는 디자인 패턴이라고 생각이 든다.