FSD 아키텍처 알아보기

dante Yoon·2024년 3월 17일
73

FSD

목록 보기
1/1
post-thumbnail

안녕하세요, 단테입니다.

영상으로 보기 https://www.youtube.com/watch?v=8yAdCSZrxBI

FSD와 FDA

FSD는 Feature Sliced Design의 줄임말입니다.
FSD의 모체는 FDA이며 Feature Driven Architecture의 줄임말입니다.
먼저 FDA의 멘탈모델에 대해 설명하고 FSD를 다루기로 하겠습니다.

FDA는 당시 webflow 회사에 재직중이었던 Oleg Isonen (올레그 이소넨)에 의해 2018 React Day
Berlin 콘퍼런스에서 소개되었습니다.

좋은 코드의 조건

그의 발표에서는 좋은 소프트웨어의 코드가 가져야 할 공통 목표에는 다음의 것들이 있다고 이야기 합니다.

Discoverability

프로젝트 규모가 커질 수록 어떤 기능과 연관된 코드를 탐색하는 과정이 느려지고 어려워집니다. 정확한 의존관계에 놓여있는 코드를 탐색하기 위해 복잡한 구조의 폴더와 코드를 순차적으로 탐색해나가야 하기 때문입니다.
discoverability

Work parallelisation

큰 규모의 프로젝트에서는 컨트리뷰터 개개인이 기능과 연관된 코드를 탐색하는 것도 어려워지지만 여러 명이 서로 다른 기능에 동시에 작업하는 것은 더욱 어려워집니다.

이유는 간단한데 내가 수정하는 코드가 다른 사람이 미리 만들어 놓은 기능에 버그를 불러일으키는지 확신할 수 없기 때문입니다.

Controlling shared absractions

공통적으로 사용해야만 하는 코드들도 있는데 이런 코드들은 찾기 쉬운 위치에 직관적인 이름으로 작성되어 있어 접근성이 높습니다. 이를테면 토큰 값을 가져오는 로직입니다.

shared/utils/token.ts

export default function getToken(key) {
  return localStorage.getItem(key)
}

이런 코드들은 적당한 규모로 작성되어야 합니다. 너무 많은 내용이 담기면 많은 역할을 하기 때문에 자주 변경될 확률이 높아지기 때문입니다.

FDA에서 위의 목표를 달성하기 위해 주요하게 생각하는 다음의 사항들에 대해 이야기해보겠습니다.

Decentralization

모놀리스(Monolith)

모놀리스의 프론트엔드 폴더 구조란 각 코드를 저장하는 폴더의 배치를 앱에서 맡은 의미적인 기능 즉, 도메인이 아닌 components, utils 이렇게 기술적인 기능으로만 일차원적으로 배치해놓는 걸 의미합니다.

다음과 같이 생겼습니다. 많은 분들이 사용하고 계신 구조입니다.

├── components/
|    ├── DeliveryCard
|    ├── DeliveryChoice
|    ├── RegionSelect
|    ├── UserAvatar
├── actions/
|    ├── delivery.js
|    ├── region.js
|    ├── user.js
├── epics/
|    ├── delivery.js
|    ├── region.js
|    ├── user.js
├── constants/
|    ├── delivery.js
|    ├── region.js
|    ├── user.js
├── helpers/
|    ├── delivery.js
|    ├── region.js
|    ├── user.js
├── entities/
|    ├── delivery/
|    |      ├── getters.js
|    |      ├── selectors.js
|    ├── region/
|    ├── user/

각 코드가 하는 역할별로 폴더에 저장을 해놓았는데 문제는 한 폴더 내부에서 어떤 파일이 앱의 어떤 도메인과 연관이 있는지 파악하기가 매우 어렵습니다.

예를들어 UserAvatar가 내부적으로 어떤 훅을 참조하고 있는데 새로운 페이지에서 추가로 사용해야 한다면 내부에 훅 하나를 더 추가하는 식으로 역할을 확장하게 될 것입니다.

그게 아니라면 UserAvatar에 특정 훅을 사용하지 않게 해당 부분을 도려내야 하는데 이미 커다란 컴포넌트가 되어버렸다면 공통 컴포넌트로 정리하기 힘들 것입니다. 다이어트는 항상 힘든 법이니까요.
monolith

https://feature-sliced.design/

좌측 상단 첫번째 그래프에서 모놀리틱 구조의 단점이 잘 드러납니다. 의존성 그래프가 복잡해서 추적하기가 어렵고 순환참조가 일어나기가 쉽습니다.

피쳐 단위로 코드를 나누기

그래서 이를 feature 단위로 잘게 나눕니다. 피쳐는 앱의 피쳐입니다. 사진첩 앱이면 업로드, 액자, 댓글등이 될 수 있습니다.

├── features/
    ├── comments
    ├── share
    ├── upload
    ├── follow

그리고 피쳐외에도 다음과 같은 타입들이 있습니다. 각 폴더를 타입이라고 부릅니다.

├── pages
├── features
├── components
├── utilities

피쳐 타입의 코드들은 각 피쳐 내부에서만 사용되므로 코드를 수정할 때 영향을 미칠 범위가 분명하게 구분되어있습니다.

이에 더해 캡슐화를 구현할 수 있습니다.

캡슐화

├── features/
|   ├── comments
|   |   ├── privateCommentA.js
|   |   ├── privateCommentB.js
|   |   ├── privateCommentC.js
|   |   ├── index.js
|   ├── share
|   ├── upload
|   ├── follow
├── shared/

privateCommentA,B,C는 index.js를 통해서만 다른 모듈에서 참조될 수 있습니다. 여기서 모듈이란 privateCommentA, privateCommentB와 같이 동일한 comments 폴더 계층에 있는 다른 코드들이나 pages와 같은 다른 폴더를 의미합니다.
index.js에 필요한 모듈들만 외부로 노출하므로써 이 코드를 사용하는 다른 사람에게 private 모듈과 public 모듈을 분리하여 전달할 수 있습니다.

Explicit Sharing

공통 코드가 변경될 때 버그가 발생하는 것은 테스트코드를 통해 100% 해결할 수 없습니다. 왜냐하면 공통모듈 작성자가 모든 유즈케이스를 100% 예측할 수 없기 때문입니다.

const f = (value) => {
  if(
    typeof value === "object" &&
    value.say === "hi"
  ) {
    return {...value, say: "Hello"}
  }
  return value;
}

f({say: "Hi"})
f(null) // Uncaught TypeError

https://www.youtube.com/watch?v=BWAeYuWFHhs

f 함수는 작성당시 null을 사용할 것이라고 예상하지 못했습니다. 작성당시 object literal 타입을 호출할 것이라고 예상했기 때문입니다.

이를 방지하기 위해서는 타입스크립트와 같이 정적 타이핑을 사용해야 합니다.

또다른 방법으로는 shared 코드에 들어갈 수 있는 기준을 높이는 것입니다. shared 코드는 꼭

  • 재사용 중인 모듈이 있어야 하며
  • 특정 도메인에 결합된 로직이 아니어야 하며
  • 변경 가능성이 적어야 합니다.

또한 다음을 고려해야 합니다.

  • 더 많은 테스트 코드 작성
  • 함수를 순수함수로 작성
  • 코드 리뷰를 강화

co-location

한 피쳐 내부에서 쓰이는 도메인 코드가 여러 곳으로 분류되어있으면 코드를 탐색하기가 어렵기 때문에 최대한 image, 상태관리, 테스트트 코드등을 한 폴더 내부에 위치시킵니다.

co location

https://www.youtube.com/watch?v=BWAeYuWFHhs

de-coupling isolation

  • 피쳐 코드 내부에서 사용하는 코드들은 다른 피쳐 모듈을 의존하면 안됩니다.
  • page코드는 다른 page를 의존하면 안됩니다.
  • shared 코드가 다른 타입을 의존하면 안됩니다.

위의 룰이 굉장히 중요하고 FSD에서도 그대로 계승합니다.
이 규칙이 깨지면 FSD, FDA를 하는 의미가 없어집니다. 의존성 그래프의 복잡성이 올라가기 때문입니다.

FDA 정리

지금까지 FSD의 멘탈 모델이 된 FDA를 알아보았습니다. FDA는 어떤 내용을 다루는 아키텍쳐인지 한 문장으로 정리하면 다음과 같습니다.

A set of principles that helps you to define the boundaries
코드의 역할과 책임을 나누는 원리

다음 포스팅에서는 FSD 구조와 그 예시를 살펴보겠습니다.

감사합니다.

FSD

FSD는 앞서 FDA에서 영감을 얻어 러시아의 개발자들이 만든 프론트엔드 아키텍쳐로 다음의 버전들을 거쳐 지금의 v2.0 버전이 만들어지게 되었습니다.

  • Feature Driven
  • Feature Slices v0.1
  • Feature Slices v1.0

언제 적용해야 할까

FDA와 마찬가지로 운영기간이 짧거나 작은 규모의 프로젝트라면 적용할 필요 없이 모놀리스 형식으로 프로젝트를 구성해도 충분하며 만약 점진적으로 소프트웨어 규모가 커질 예정이라면 FSD를 적용할 수 있습니다.

FSD의 구성요소

fsd

Layers

추상화 레벨과 비즈니스 레벨

abstraction
상위 레벨에 있는 레이어는 하위 레벨을 의존성으로 가질 수 있지만 그 반대는 성립될 수 없습니다. 하위 레이어로 갈 수록 추상화과 심화되며 상위 레이어로 갈 수록 비즈니스 로직이 심화됩니다.

앱에서 각 레이어가 맡고 있는 역할을 분명하게 나눔으로 인해 의존성 그래프가 영향을 미치는 범위를 일부분으로 한정시킵니다.

cohesion

FSD는 최대 7가지 레이어를 가질 수 있으며 레이어는 어플리케이션 전반에 영향을 미칠 수 있는 구성요소를 각자의 역할에 따라 분류한 것입니다. 각 레이어가 맡은 역할을 알아보며 좀 더 자세히 이해해보도록 하겠습니다.

slices

slices

슬라이스는 FSD의 두번째 계층으로 슬라이스의 이름이 가져야 할 특별 한 규칙은 없습니다. 비즈니스 도메인으로 슬라이스 이름이 정해집니다.

사진 갤러리 앱을 만든다면 photo, create-album, gallery-page와 같은 슬라이스 이름을,

SNS앱을 만든다면 post, add-user-to-friends, news-feed와 같은 이름을 지을 수 있습니다.

슬라이스 내부에서는 비즈니스와 연관된 로직을 작성할 수 있으며 app과 shared 레이어에서는 각각 앱의 전체적인 부분과 연관된 코드를, shared 레이어에서는 깊은 추상화를 가지고 있고 비즈니스 로직을 작성하지 않기 때문에 slice를 두지 않습니다.

슬라이스 내부에서는 여러 그룹의 세그먼트를 가질 수 있으나 특정 코드를 세그먼트를 만들지 않고 배치하지 않습니다.

not allowed slice

https://feature-sliced.design/docs/reference/slices-segments#slices

슬라이스 내부에서는 public api 정의가 있어야 하며 public api를 명시하지 않으면 앱 전체에서 존재할 이유가 없습니다.

└── features/               # 
       ├── auth-form /      # Internal structure of the feature
       |     ├── ui/        #
       |     ├── model/     #
       |     ├── {...}/     #
       ├── index.ts         # Entrypoint features with its public API
export { Form as AuthForm } from "./ui"
export * as authFormModel from "./model"

segments

segments

세그먼트는 FSD의 마지막 계층에 있는 요소로 한 도메인 안에서 기술적인 결과를 달성하기 위해 작성합니다.

세그먼트의 이름은 ui, model, lib, api등이 될 수 있으며 예시는 아래와 같습니다.

  • ui: UI 컴포넌트
  • model: 비즈니스 로직, data aggregation 함수들
  • lib: infra structural code
  • api: backend api를 호출하기 위한 코드들

Layers 구성요소

app

app은 앱의 설정을 맡는 레이어로 여러 모듈에서 재사용하지 않으나 실행시 앱에 고루 영향을 미치는 모듈들을 저장하는 곳입니다.

예로 들 수 있는 유즈케이스에는 아래와 같은 모듈들이 있을 수 있는데 github-client라는 저장소의 예제코드를 가져와 함께 살펴보겠습니다.

  • css-in-js의 theme provider
  • hoc (router, auth 등)
  • 레이아웃

layout

hoc와 header가 있는데 hoc는 router나 error handler, api adapter등이 있고 header는 앱에 전체적으로 적용할 수 있는 헤더 레이아웃이 정의되어 있습니다.

hocs/with-router.tsx

import { Spin } from "antd";
import React, { Suspense } from "react";
import { BrowserRouter, Route } from "react-router-dom";
import { QueryParamProvider } from "use-query-params";

const withRouter = (component: Component) => () => (
    <BrowserRouter>
        <Suspense fallback={<Spin delay={300} className="overlay" size="large" />}>
            <QueryParamProvider ReactRouterRoute={Route}>{component()}</QueryParamProvider>
        </Suspense>
    </BrowserRouter>
);

export default withRouter;

header 폴더 내부에서는 이 내부에서만 사용할 hooks도 작성되어 있습니다.

header/index.tsx

import React from "react";
import { Layout, Input } from "antd";
import { Link } from "react-router-dom";
import { GITHUB_MAIN, GITHUB_FEEDBACK } from "shared/get-env";
import { Auth } from "features";
import { ReactComponent as IcLogo } from "./logo.svg";
import { useSearchInput } from "./hooks";
import "./index.scss";

const Header = () => {
    const { isAuth } = Auth.useAuth();
    const { handleKeyDown, searchValue } = useSearchInput();

    return (
        <Layout.Header className="header">
            <div className="nav flex flex-grow items-center">
                <Link className="header__logo flex items-center" to="/">
                    <IcLogo />
                    {!isAuth && <span className="gc-app__title text-white m-4">GITHUB-CLIENT</span>}
                </Link>
                {isAuth && (
                    <Input
                        className="header__search"
                        placeholder="Search..."
                        defaultValue={searchValue}
                        onKeyDown={handleKeyDown}
                    />
                )}
                <a
                    className="m-4 text-gray-600"
                    href={GITHUB_MAIN}
                    target="_blank"
                    rel="noopener noreferrer"
                >
                    GitHub
                </a>
                <a
                    className="m-4 text-gray-600"
                    href={GITHUB_FEEDBACK}
                    target="_blank"
                    rel="noopener noreferrer"
                >
                    Feedback
                </a>
            </div>
            <Auth.User />
        </Layout.Header>
    );
};

export default Header;

예제 코드 내부에서는 antd의 컴포넌트를 사용했으나

이렇게 만들어진 레이아웃들은 app/index.tsx에서 조립됩니다.

import React from "react";
import { Layout } from "antd";
import Routing from "pages";
import Header from "./header";
import { withHocs, ErrorHandlingProvider } from "./hocs";
import "./index.scss";

const App = () => {
    return (
        <div className="gc-app" data-testid="gc-app">
            <Layout>
                <Header />
                <Layout.Content className="gc-app-content">
                    <ErrorHandlingProvider>
                        <Routing />
                    </ErrorHandlingProvider>
                </Layout.Content>
            </Layout>
        </div>
    );
};

export default withHocs(App);

processes

이 레이어는 deprecated되어 이제 사용하지 않습니다.

pages

하위 레이어들을 조합하여 완전한 기능을 제공하는 레이어로 앱에서 각 라우터에 해당하는 페이지들을 작성하는 곳입니다. 각 페이지에서 사용하는 스타일 파일도 동일한 폴더에 위치시킵니다.

그리고 index.tsx에서 app에서 사용할 페이지들을 노출시킵니다.

import React, { lazy, useEffect } from "react";
import { Redirect, Route, Switch, useHistory } from "react-router-dom";
import { dom } from "shared/helpers";
import { Auth, Origin } from "features";

const HomePage = lazy(() => import("./home"));
const RepositoryPage = lazy(() => import("./repository"));
const UserPage = lazy(() => import("./user"));
const SearchPage = lazy(() => import("./search"));
const AuthPage = lazy(() => import("./auth"));

const useResetScrollAtEveryPage = () => {
    const history = useHistory();

    useEffect(() => {
        const unlisten = history.listen(() => {
            dom.scrollToTop();
        });
        return () => {
            unlisten();
        };
    }, [history]);
};

const Routing = () => {
    const { isAuth, viewer } = Auth.useAuth();
    useResetScrollAtEveryPage();

    if (!isAuth) {
        return (
            <Switch>
                <Route exact path={Auth.routes.main} component={HomePage} />
                <Route exact path={Auth.routes.login} component={AuthPage} />
                <Redirect to={Auth.routes.login} />
            </Switch>
        );
    }
    return (
        <>
            <Origin />
            <Switch>
                <Route exact path="/search" component={SearchPage} />
                <Route exact path="/:username" component={UserPage} />
                <Route
                    path="/:username/:repository/:branch(tree/[\w\d-_./]+)?"
                    component={RepositoryPage}
                />
                <Redirect to={`/${viewer?.username}`} />
            </Switch>
        </>
    );
};

export default Routing;

widgets

위젯은 하위의 레이어들을 이용해 특정 피쳐에서 사용할 수 있는 UI 블록들을 만드는 곳입니다. widgets 레이어는 선택적 레이어로 불필요하다면 생략할 수 있습니다.

이 컴포넌트들은 특정 의존성과 강결합되어있기 때문에 여러 페이지에서 재사용하지 못할 수 있으며 IssuesList, UserProfile등의 앱 내부에서 맡고있는 도메인이 정확하게 구분되어 있습니다.

github-client 예제코드에는 widgets 레이어가 없으므로 또 다른 예제 프로젝트인 falkchat의 코드를 살펴보겠습니다.

widgets

chat 위젯 내부에서 사용하는 훅을 lib에다가 넣어두었네요

useChatScroll

import { useEffect, useState } from 'react';

type ChatScrollProps = {
  chatRef: React.RefObject<HTMLDivElement>;
  bottomRef: React.RefObject<HTMLDivElement>;
  shouldLoadMore: boolean;
  loadMore: () => void;
  count: number;
};

export const useChatScroll = ({
  chatRef,
  bottomRef,
  shouldLoadMore,
  loadMore,
  count,
}: ChatScrollProps) => {
  const [hasInitialized, setHasInitialized] = useState(false);

  useEffect(() => {
    const topDiv = chatRef?.current;

    const handleScroll = () => {
      const scrollTop = topDiv?.scrollTop;

      if (scrollTop === 0 && shouldLoadMore) {
        loadMore();
      }
    };

    topDiv?.addEventListener('scroll', handleScroll);

    return () => {
      topDiv?.removeEventListener('scroll', handleScroll);
    };
  }, [shouldLoadMore, loadMore, chatRef]);

  useEffect(() => {
    const bottomDiv = bottomRef?.current;
    const topDiv = chatRef.current;
    const shouldAutoScroll = () => {
      if (!hasInitialized && bottomDiv) {
        setHasInitialized(true);
        return true;
      }

      if (!topDiv) {
        return false;
      }

      const distanceFromBottom =
        topDiv.scrollHeight - topDiv.scrollTop - topDiv.clientHeight;
      return distanceFromBottom <= 100;
    };

    if (shouldAutoScroll()) {
      setTimeout(() => {
        bottomRef.current?.scrollIntoView({
          behavior: 'smooth',
        });
      }, 100);
    }
  }, [bottomRef, hasInitialized, count, chatRef]);
};

그리고 상위 레이어인 pages에서 사용 가능한 위젯은 chat-messages 컴포넌트로 한정하여 index.tsx에서 노출시킵니다.

index.tsx

export { ChatMessages } from './ui/chat-messages';

features

버튼 클릭과 같은 유저의 인터렉션과 관련된 로직, 특정 비즈니스 로직이 담겨있는 레이어로 SendComment, AddToCart, UsersSearch등이 있습니다.

예제코드에서는 버튼 클릭에 사용할 모달을 feature 내부에 위치하고 app/provider에서 모든 모달을 모아두었습니다.

use-origin훅을 같은 slice에서 사용했습니다.

서로 다른 slice끼리는 참조할 수 없으나 slice 내부의 segment는 다른 세그먼트에서 참조할 수 있습니다.

entities

entities

entities 내부에서는 특정 도메인과 연관된 api의 호출 함수가 정의되어 있습니다. 특정 도메인의 모델을 정의하고 그와 연관된 api를 호출하는 api 어뎁터를 작성합니다.

chat-query.ts

import { useSocket } from '@/shared/api/socket';
import { useInfiniteQuery } from '@tanstack/react-query';
import qs from 'query-string';

interface props {
  queryKey: string;
  apiUrl: string;
  paramKey: 'channelId' | 'conversationId';
  paramValue: string;
}

export const useChatQuery = ({
  queryKey,
  apiUrl,
  paramKey,
  paramValue,
}: props) => {
  const { isConnected } = useSocket();

  const fetchMessages = async ({ pageParam = undefined }) => {
    const url = qs.stringifyUrl(
      {
        url: apiUrl,
        query: {
          cursor: pageParam,
          [paramKey]: paramValue,
        },
      },
      { skipNull: true },
    );

    const res = await fetch(url);
    return res.json();
  };

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
    useInfiniteQuery({
      queryKey: [queryKey],
      queryFn: fetchMessages,
      getNextPageParam: (lastPage) => lastPage?.nextCursor,
      refetchInterval: isConnected ? false : 1000,
      initialPageParam: undefined,
    });

  return { data, fetchNextPage, hasNextPage, isFetchingNextPage, status };
};

예제 코드를 보면 shared 내부의 api/socket.ts 코드를 참조하고 있는 걸 볼 수 있습니다.

이 예제 프로젝트에서는 prisma client 코드를 shared 내부에 선언했는데

이어서 shared 코드를 보겠습니다.

shared

shared/api/db.ts

import { PrismaClient } from '@prisma/client';

declare global {
  var prisma: PrismaClient | undefined;
}

export const db = globalThis.prisma || new PrismaClient();

if (process.env.NODE !== 'production') {
  globalThis.prisma = db;
}

shared/api/socket.ts

'use client';

import { createContext, useContext } from 'react';

type contextType = {
  socket: any | null;
  isConnected: boolean;
};

export const socketContext = createContext<contextType>({
  socket: null,
  isConnected: false,
});

export const useSocket = () => {
  return useContext(socketContext);
};

이 예제코드처럼 꼭 작성해야 하는 것은 아닙니다만, 리액트 안에서 어떻게 FSD에 맞게 context를 작성하고 다른 레이어에서 참조할 수 있는지 보여주는 좋은 예제인 것 같습니다.

shared 내부의 socket에서 소켓 연결이 되었는지를 참조할 수 있는 코드를 context로 만들고 entities/chat-query.ts 내부에서 해당 shared/api/socket.ts의 소켓정보를 참조할 수 있게 합니다.

entities/chat/api/chat-query.ts


import { useSocket } from '@/shared/api/socket';
import { useInfiniteQuery } from '@tanstack/react-query';
import qs from 'query-string';

interface props {
  queryKey: string;
  apiUrl: string;
  paramKey: 'channelId' | 'conversationId';
  paramValue: string;
}

export const useChatQuery = ({
  queryKey,
  apiUrl,
  paramKey,
  paramValue,
}: props) => {
  const { isConnected } = useSocket();

오늘은 FSD에 대해 알아보았습니다.
잘 알고 사용해야 혼란이 적을 것 같습니다.
프론트엔드 아키텍쳐 구성할 때 유지보수 관점에서 장점이 많은 아키텍쳐라고 생각됩니다. 감사합니다.

profile
성장을 향한 작은 몸부림의 흔적들

2개의 댓글

comment-user-thumbnail
2024년 8월 24일

정리를 너무 잘해주셔서 감사합니다 단테님. 덕분에 좀 더 쉽게 지식을 얻어 간 것 같습니다. 🙇‍♂️

1개의 답글