항해 플러스의 두 번째 코스, 클린코드를 마치며 - FSD 편

2024년 11월 2일

#Fsd#Frontend-design-pattern#프론트엔드#항해99#항해플러스
항해 플러스의 두 번째 코스, 클린코드를 마치며 - FSD 편

TL;DR

  • 진심 1000% 담아서 써보는 글
  • 그 어떤 과제보다 세상 고민 많이했다. 과제 제출 전까지도 고민하며 작업했다.
  • 처음, 날 것의 소스코드를 보자마자 코치님의 순수 악의가 느껴졌다.
    • 한 파일에 정갈?하게있는 코드는 나의 멘탈을 흔들기에 충분헀다.
    • GET /posts 방식이 단순 목록 조회가 아닌 경우에 따라 세 가지로 나뉘는 코드를 보고 멘탈 살짝 금가기 시작했다.
    • 근데 이 경우별 조회 케이스 이외에 또 쿼리스트링에 따라 필터링 해줘야하는 항목들도 존재...
    • 남발되어 있는 useEffectsearchParams의 효과가 굉장했다.
  • 일단 각 레이어와 슬라이스 세그먼트 각 파트에 대한 이해가 필요했고 기본적은 구조 학습에 대한 러닝커브가 매우 높았다
  • 글을 읽다 보면 어느 순간 투정이 되어버리는 것을 느낄 수 있다.
큰 효과!

큰 효과!

FSD 2.0 적용하기

  • 지난 2주 동안 함수형 프로그래밍의 개념을 학습하고 실제 적용을 해보며 액션과 계산, 데이터에 대해 이해를 어느정 도 할 수 있었다.
  • 이어 이러한 개념들을 적용하기에 적합한 FSD를 학습해 보게 되었다.
  • 그럼 바로 시작하겠다.

들어가기 전에...

  • FSD 패턴은 아직까지 정형화된 패턴은 아니며 꾸준히 디벨롭되고 있다.
  • FSD의 기본 골조를 따라가되 자기만의 기준을 가지고 규칙을 정립하는 것이 좋다.
  • 기존 프로젝트에 적용을 하고 싶다면 한번에 갈아엎기 보다는 점진적으로 마이그레이션하는 것이 좋다.
  • 지금부터 보게될 내용들도 처음부터 FSD 패턴을 적용하는 것이아닌 기존의 하나의 파일에 몰려있던 코드들을 쪼개보며 적용하는 것을 알 수 있다.

그래서 FSD? 뭔데 그게?

  • FSD는 feature-sliced-design 의 약자로, 직역하면 역할군을 나눈 디자인 패턴을 의미한다.
  • 이는 과연 무슨 의미일까?
  • 과거 (그리 멀지 않지만) 디자인 패턴이라 불릴만한 것들이 몇 가지가 있었다. 아토믹 패턴이라던가 Container and presentation 패턴이라던가
  • 개인적으로 위에서 언급한 패턴들을 깊게 사용해 본 것은 아니지만 점점 커져가는 서비스에 적용하기에는 한계점이 느껴졌다.
  • 가장 큰 한계는 관리의 측면이었다.
  • 서비스가 커지고 다루는 도메인(데이터)가 커질 때 마다 관리해야할 포인트가 하나 이상 늘어났다.
  • UI 재사용성을 기준으로 관심사를 분리하다보니 도메인에 대한 고려가 부족했고 이를 나누는 기준도 모호했다고 생각한다.
  • 그렇게 시간이 흘러보면 하나의 수정이 필요할 때마다 하나의 도메인임에도 불구하고 흩어져있는 코드들을 따라 타고타고 들어가야 했다.
  • 물론 이는 분명 개인의 숙련도도 영향이 있다.
  • 허나 FSD는 이러한 고민과 문제에 대한 해답을 어느정도 만들었다.
  • 아래의 사진과 같이 각 역할에 따라 레이어를 나누고 해당 레이어에서 도메인(슬라이스)별로 나뉘게 된다. 이후 각 도메인에 맞는 여러 세부 폴더를 가진다.
FSD 구조

FSD 구조

  • 이는 확실히 DX적으로 유리한 패턴이다.
  • 각 레이어에 명시된 역할에 따라 예측가능한 설계가 1차적으로 가능하게 되고 하위 도메인(슬라이스)들은 해당 역할에 맞는 관심사를 가진다.
  • FSD는 아키텍쳐 설게에 대한 가이드와 규칙을 제공한다. 앞서 언급했듯이 FSD는 지금도 발전 중이며 강제성을 띄는 부분은 없다.
  • 그나마 가장 큰 강제성은 하위 레이어는 상위 레이어를 참조할 수 없다는 것이다. 같은 레이어 끼리의 참조도 안된다.
  • 개인적으로 같은 레이어 끼리의 참조 불가능 이라는 규칙을 지키는 것이 가장 어려웠다.
  • 자세한 내용은 FSD overview 에서 확인할 수 있다.
  • 필자는 과제에 대한 발제를 듣고 팀원들과 얘기를 나누고, 실제 FSD를 적용하면서 나름 자신만의 구조를 만들 수 있었다. 간략하게 나의 기준 은 아래와 같다.
    • 레이어
      • app과 shared는 slice(도메인)을 가지지 않는다.
      • app
        • 최상위 레이어에서 전 레이어에서 공통적으로 사용하기 위해 감싸는 것들
        • e.g.) Router, Provider, Global style
      • pages
        • 실제 router에 따라 나뉘는 페이지단 컴포넌트
        • e.g.) login-page. my-page, order-page
      • widgets
        • pages를 구성하는 하나의 완전한 기능을 하는 컴포넌트들
        • 최대한 도메인에 관여한 비즈니스 로직을 제외
        • e.g.) 상품 필터링,
      • features
        • 비즈니스 로직을 다루는 실제 구현 컴포넌트
      • entities
        • 도메인에 대한 엔티티를 가장 근접한 단계에서 다루는 레이어
        • e.g.) user 엔티티, product 엔티티
      • shared
        • 어느 레이어에서든 접근 가능한 엔티티들을 다루지 않는 레이어
        • e.g.) ui kit, toggle과 같은 공통적인 유틸 또는 hook
    • 슬라이스
      • 이는 각 비즈니스 도메인별로 나뉘어져 있으며 자유롭게 만들 수 있다.
      • 동일한 레이어의 다른 슬라이스를 참조할 수 없다. (높은 응집도, 낮은 결합도)
      • 이 부분에 있어서 필자는 완벽하게 지키지 못한 것 같다.
      • 이러한 부분을 집중적으로 연습한다면 나만의 규칙을 정할 수 있을 것 같다.
    • 세그먼트
      • app이나 shared에서는 슬라이스를 다루지 않기 떄문에 자유로운 세그먼트 구성이 가능하다.
      • 구분이 필요할 때는 케밥 케이스를 통해 파일 이름으로 구분
        • e.g.) 00-types.ts, product-config.ts
      • ui
        • UI와 관련된 모든 것: UI 컴포넌트, 날짜 포맷터, 스타일 등.
      • api
        • api 함수, tanstack-query와 같은 서버 사이드 상태 관리 라이브러리를 쓴다면 여기에
      • model
        • 스키마, 스토어 타입
      • lib
        • 슬라이스에 필요한 함수들
      • config
        • 상수, 설정 파일
  • 이어서는 새롭게 시작하는 프로젝트가 아닌 기존의 서비스를 단계별로 리팩토링하는 내용을 다뤄보겠다.

basic 단계

  • 과제를 구현하면서 일단은 가장 나누기 쉽다고 판단되는 shared 레이어에 한 파일에 몰려있던 ui kit을 분리하는 작업을 시작했다 (이때까지는 나름 순조로웠을 지도?...)
  • 그리고 app으로 분리할 수 있는 부분을 파악하고 나누었다.

entities

  • entities에 post / comment / tag / user 엔티티에 대한 슬라이스를 만들고 일단은 api와 model로 주로 활용했고 이후 ui에는 어떤게 들어가는가? 를 고민 하게됐다.
  • 아래는 entities의 post를 예로 들어 작성한 코드이다.
// @entities/post/api/post-api.ts
import { NewPost, Post, PostsResponse } from "@/entities/post/model/types";
import { PostSearchParamsKey } from "@/features/post/model/types";
import { createRequestOptions, fetchApi } from "@/shared/lib/api";
import { POST_API_PATHS } from "../config/post-api-paths";

//query
const getPosts = async (
  queries: PostSearchParamsKey
): Promise<PostsResponse> => {
  const { limit, skip, sortBy, sortOrder } = queries;
  const postsResponse = await fetchApi<PostsResponse>(POST_API_PATHS.base, {
    searchParams: { limit, skip, sortBy: sortBy, order: sortOrder },
  });
  return postsResponse;
};

// ....... 중략

// ======================================================

//mutate
const addPost = async (newPost: NewPost) => {
  const response = await fetchApi<Post>(
    POST_API_PATHS.add,
    createRequestOptions("POST", newPost)
  );
  return response;
};

// .......중략

// ======================================================

// total
export const postApi = Object.freeze({
  getPosts,
  addPost,
  updatePost,
  deletePost,
  searchPosts,
  getPostsByTag,
});
  • 코드에서도 확인할 수 있 듯 순수 post entity만을 다루는 api이다.
  • 이렇게 분리를 해놓으면 역할이 명확하기도 하고 api가 필요한 feature에서 자유롭게 가져다가 커스텀하거나 그대로 가져다 쓸 수 있다
  • feature도 아닌것이 entity에 대한 데이만 props로 받아서 비즈니스 로직없이 순수하게 View 용도로만 사용이 가능할까? 이게 widget이었군…
  • Tags로 한번 만들어 보긴했으나 뭔지 모를 찝찝함이 남아 있다.
// entities/tag/ui/Tags.tsx
import { HTMLAttributes } from "react";
import { Tag } from "../model/types";

type TagsProps = {
  tags: Tag[] | undefined;
  component?: React.ElementType;
} & HTMLAttributes<keyof React.JSX.IntrinsicElements>;

const Tags = ({
  tags,
  component: Component = "button",
  ...props
}: TagsProps) => {
  if (!tags) return <div>태그가 존재하지 않습니다.</div>;
  return tags.map((tag) => (
    <Component key={tag.url} value={tag.slug} {...props}>
      {tag.slug}
    </Component>
  ));
};

export default Tags;

widget

  • widget도 일단은 page에 정의 되어있던 것에서 큰 덩어리를 먼저 나누었다.
  • 나누고 리팩토링하는 과정은 생략했다.
  • 의도는 이러이러 했고 결론적으로는 이런 기능을 해야 한다. 라고만 이해해도 충분하다 생각한다.
// @/widgets/post/ui/TablePosts.tsx
import { useQueryPosts } from "@/features/post/api/use-get-post";
import {
  PostTableHeader,
  PostTablePagination,
  PostTableRow,
} from "@/features/post/ui/table";

import { useQueryParams } from "@/shared/model";
import { Loader, Table } from "@/shared/ui";
import { useMemo } from "react";

const TablePosts = () => {
  const { queries } = useQueryParams();
  const { data, isLoading, isError } = useQueryPosts(queries);

  const hasPosts = useMemo(
    () => data?.posts?.length && data.posts.length > 0,
    [data?.posts]
  );

  const tableBody = hasPosts ? (
    data?.posts.map((post) => <PostTableRow key={post.id} post={post} />)
  ) : (
    <Table.Row>
      <Table.Cell colSpan={5}>게시물이 없습니다.</Table.Cell>
    </Table.Row>
  );

  if (isError) {
    return <div>에러가 발생했습니다.</div>;
  }

  return (
    <>
      {isLoading ? (
        <Loader />
      ) : (
        <Table.Container>
          <PostTableHeader />
          <Table.Body>{tableBody}</Table.Body>
        </Table.Container>
      )}
      <PostTablePagination total={data?.total || 0} />
    </>
  );
};

export default TablePosts;
  • widget은 하나의 완전한 기능을 하는 컴포넌트로 원하는 pages에 import해서 그대로 사용하면 된다.

features

  • 그리고 각 widget에서 필요한 feature를 생각하고 작업을 시작하면서 대혼란이 오기 시작함
  • 슬라이스에 도메인과 기능 이 두 부분을 고려하다 보니 생각이 굉장히 많아졌다.
    • e.g.) filter 기능을 하는 tag sort order
      • 단순 ui뿐아니라 별로도 관리해야할 segement가 있을 것으로 판단했다.
      • but 나머지 tag / sort / order는 어떻게 해야하나 고민을 하게 되었다.
        • 1번 filter > ui segment에서 각 tag sort order
        • 2번 filter-tag > ui , filter-sort > ui , filter-order > ui
    • 그리고 comment와 post는 크게 생각해서 도매인 기준으로 slice를 나누고 ui 단에서 modal / table / …. 이렇게 나누게 되었다.
    • 그렇게 되니 사이즈가 꽤나 커져서 {domain}-{feature} 의 형태로 다시 슬라이스를 나눠야하나 고민하게 되었다.
    • 확실히 어느정도의 범위로 쪼개야하는지 감이 안잡히니 feature 레이어의 각 slice들이 ui를 서로 공유하게 됨, 여기서 관리 포인트가 하나 더 늘어났다고 생각했다.
  • 이렇게 feature에 대한 작업을 하다보니 widget도 무언가 feature와 비슷한 형태가 되는거 같았다
  • 그러나 하나의 widget은 완전한 기능을 해야하는 차이점이 있었고 이를 위해 여러 feature들이 모여 만들어진다.
  • e.g.) filter는 특정 entity에 엮여있다기 보다는 하나의 필터링이라는 기능을 담당하기에 slice를 filter로 두고 작업을 했다.
// @features/filter/ui/FilterOrder.tsx
import { useQueryParams } from "@/shared/model";
import { Select } from "@/shared/ui/Select";

const FilterOrder = () => {
  const {
    handleUpdateQuery,
    queries: { sortOrder },
  } = useQueryParams();
  return (
    <Select.Container
      value={sortOrder}
      onValueChange={(value: string) => handleUpdateQuery("sortOrder", value)}
    >
      <Select.Trigger className="w-[180px]">
        <Select.Value placeholder="정렬 순서" />
      </Select.Trigger>
      <Select.Content>
        <Select.Item value="asc">오름차순</Select.Item>
        <Select.Item value="desc">내림차순</Select.Item>
      </Select.Content>
    </Select.Container>
  );
};

export default FilterOrder;

others... 기타 고민하는 과정들

  • 이후 상태관리를 위해 처음에는 context api 로 진행했다.
    • 각 entity에 대한 use{entity}를 만들고 context api에 주입해서 전역에서 쓸 수 있도록 작업을 진행했다
    • 처음부터 상태관리 라이브러리를 쓰는게 낫지 않았을까 생각했다.
    • 작성해야할 보일러 플레이트 코드가 많다고 생각했고 꽤나 번거롭다.
  • 이후 zustand 도입을 진행했다.
    • 각 entity에 해당하는 store를 만들어서 관리했다.
    • useSearchParams도 만들어서 관리 중인데 단순 read를 위한 경우 바로 컴포넌트에서 넣고자했다.
    • list의 item 역할을 하는 ui는 엔티티 데이터를 props로 받아서 view의 역할을 담담하게 했다.
  • 모달에 대한 관리도 꽤나 힘들었다.
    • 처음에는 useToggle의 형태로 만들어서 관리하다 보니 close를 props로 넘겨줘야하는 일이 생겨서 외부에 의존하는 형태가 별로라 생각했다
    • 또한 중첩 이렇게 작업하게 되면 중첩 모달의 관리가 힘들었다
    • 그래서 이후 Map을 state로 두는 global modal을 만들어 진행했다.
    • 닫기가 필요한 컴포넌트에서 중첩 모달이 아닌경우 closeAll / 중첩 모달인 경우 close(${type값}) 형태로 관리를 했다.
import FormAddPost from "@/features/post/ui/forms/FormAddPost";

import { Button, Dialog } from "@/shared/ui";

import { Plus } from "lucide-react";
import { useModalAddPost } from "../../model/useModalAddPost";

const ModalAddPost = () => {
  //========================================
  //before
  const { isOpen, toggle } = useToggle();
  //after
  const { isOpen, toggle } = useModalAddPost();
  //========================================

  return (
    <Dialog.Container open={isOpen} onOpenChange={toggle}>
      <Dialog.Trigger asChild>
        <Button>
          <Plus className="w-4 h-4 mr-2" />
          게시물 추가
        </Button>
      </Dialog.Trigger>
      <Dialog.Content>
        <Dialog.Header>
          <Dialog.Title>새 게시물 추가</Dialog.Title>
        </Dialog.Header>
        //======================================== // before
        <FormAddPost close={close} />
        // after
        <FormAddPost />
        //========================================
      </Dialog.Content>
    </Dialog.Container>
  );
};

export default ModalAddPost;

// features/post/model/useModalAddPost.ts
import { getModalKey, useModalStore } from "@/shared/model/useModalStore";
import { useShallow } from "zustand/shallow";

export const useModalAddPost = () => {
  const modalStore = useModalStore(
    useShallow((state) => ({
      activeModals: state.activeModals,
      toggle: state.toggle,
      close: state.close,
    }))
  );

  return {
    isOpen: modalStore.activeModals.has(getModalKey({ type: "addPost" })),
    toggle: (isOpen: boolean) => modalStore.toggle({ type: "addPost" }, isOpen),
    close: () => modalStore.close({ type: "addPost" }),
  };
};
  • FSD에서도 점진적 적용에 대한 짤막한 가이드라인을 제공해서 한번 시도

    점진적 도입

    참고 문서

    FSD로 마이그레이션하고자 하는 기존 코드베이스가 있다면, 다음과 같은 전략을 제안합니다. 이는 우리의 마이그레이션 경험에서 유용했던 방법입니다.

    1. App과 Shared 레이어를 모듈별로 천천히 구성하여 기반을 만드는 것으로 시작하세요.
    2. 기존의 모든 UI를 Widgets와 Pages에 큰 틀에서 분배하세요. FSD 규칙을 위반하는 의존성이 있더라도 괜찮습니다.
    3. 점진적으로 임포트 위반을 해결하고, Entities와 가능하다면 Features도 추출하기 시작하세요.

    리팩토링 중이거나 프로젝트의 특정 부분만 리팩토링할 때는 새로운 대규모 엔티티를 추가하지 않는 것이 좋습니다.

advanced 단계

  • 이때 부터 조금씩 지쳐갔다.. ㅋㅋ
지침..

지침..

  • tanstack-query 도입 시작
    • https://feature-sliced.design/kr/docs/guides/tech/with-react-query / tanstack-query에 대한 가이드도 있긴해서 참고했다
    • query-key-factory 패턴으로 진행했다.
      • 이는 쿼리키에 대한 관리를 쉽게 가능하다.
    • 여기에서는 순수하게 queryOption을 통해 key를 관리 하지만 원래는 @lukemorales/query-key-factory 라이브러리를 써서 관리했다. 형태는 비슷하다.
    • FSD 도입 이전에 실무에서 tanstack-query의 아키텍처는 아래와 같이 구성했다.
기존 코드

기존 코드

  • FSD를 적용한 tanstack-query 아키텍쳐
FSD를 적용한 tanstack-query 아키텍쳐

FSD를 적용한 tanstack-query 아키텍쳐

리팩토링 이후 FSD 2.0 폴더 구조

FSD 2.0 폴더 구조

FSD 2.0 폴더 구조

결론

  • FSD 계속해서 언급했는 FSD는 강제성을 띈다기 보다는 가이드라고 나는 생각한다.
  • 역시 중요한 것은 본인 만의 기준이며 이를 팀 또는 조직에서 구성원들과 최종적인 컨벤션을 마련하는 것이다.
  • 개인적은 욕심은 FSD 3.0이라 스스로 칭할 수 있을만큼 가다듬어 가고자 한다.

PS. 포스트 순서가 뒤죽박죽일 수 있습니다.!!


이런 경험 나도 하고 싶다면?

  • 실무를 뛰면서
  • 도입하고 싶었지만 도입하지 못한,
  • 경험하고 싶었지만 경험하지 못한,
  • 이런 포인트들 다들 가지고 계실텐데요
  • 저는 이런 경험들을 하고 싶었기에 이번 항해플러스에 참여하게 되었습니다.
  • 현재 항해플러스 FE 4기도 모집 중이라 하니 참여하여서 원하는 부분들 채워가셨으면 좋겠습니다.
  • 아래의 코드를 입력하면 20만원 할인!
  • 추천 코드 : VX7gBb