Skip to content

FE 프로젝트 구조 개선 과정

진예원 edited this page Dec 3, 2024 · 1 revision

현재 프로젝트 구조

/api : 백엔드와의 통신을 담당하는 코드

/components : 컴포넌트 폴더

/lib : 프로젝트 혹은 라이브러리에 의존하지 않는 함수

/hooks : 리액트에 의존하고, 리액트 상태 관리를 이용하는 함수 + 비즈니스 로직

/store : 상태 관련 파일들


문제점 1 : 역할이 불분명하다.

현재 /components 의 역할이 불분명한 상태이다.

기능에 관련된 컴포넌트, 공용 컴포넌트, 컨테이너 컴포넌트들이 모두 /components 폴더에 있고 정해진 기준이 없는 상태이다.

또한 기능에 관련된 컴포넌트들은 맡고있는 역할이 너무 많다.


기능에 관련된 컴포넌트 : canvas, editor, sidebar

canvas

editor

sidebar

sidebar


공용 컴포넌트 : commons

다른 컴포넌트에서 불러와서 사용하는 파일들이 담겨있다.

ex) Dialog , PopOver , Button , …


뷰 컴포넌트 : EditorView.tsx, CursorView.tsx

기능에 관련된 컴포넌트와 공용 컴포넌트들을 조합해서 사용하는 컴포넌트이다.


문제점 2 : 의존성 문제

현재 EditorView.tsxlayout 에 있는 레이아웃 컴포넌트, commons 에 있는 공용 컴포넌트, Editor 에 있는 에디터 기능 관련 컴포넌트를 모두 불러와서 사용한다.

파일 간의 의존성이 높아서, 컴포넌트가 어떤 구조로 연결되어 있는지 파악하기가 어려웠다.



체계적인 프로젝트 구조 : FSD 아키텍처

FSD(Feature-Sliced Design)는 프론트엔드 애플리케이션을 구조화하는 새로운 방법론이다.

기존의 단순한 역할별 분류(components , utils 등)에서 벗어나, 애플리케이션을 더 체계적으로 구성할 수 있는 방법을 제시한다.

코드베이스가 커지게 되면 /components/hooks/utils 폴더 등에 수많은 파일이 쌓이고, 이를 다시 분류하려고 하면 프로젝트마다 제각각의 구조를 가지게 된다.

이러한 현상이 벌어지는 이유는 ‘역할’이라는 단일 관점의 관심사만으로 코드를 구성했기 때문이다.실제로 프로젝트에는 역할 뿐만이 아니라 도메인, 기능, 데이터의 흐름 등 다양한 관심사가 존재하며, 이를 세밀하게 분리할 필요가 있다.

FSD는 이런 문제를 해결하기 위해 세 가지 축으로 코드를 구조화한다.

Layers : 기능적 역할에 따른 수직적 관심사 분리

Slices: 도메인 별 관심사 분리

Segments : 기술적 관심사 분리


FSD의 장점

  • 다양한 관점의 관심사를 세밀하게 분리하여 각각의 관심사를 특정하여 분리할 수 있다.
  • 코드의 일관성과 탐색의 용이성을 높이는 명확한 컨벤션을 제공한다.



FSD 아키텍처 적용

우리 프로젝트는 페이지 단위가 아닌 기능 위주의 애플리케이션이다.

하지만, 기능에 관련된 컴포넌트의 코드와 역할이 너무 많았고, 응집도가 낮았다.

애플리케이션을 더 체계적으로 구성하기 위해 FSD 아키텍처를 선택했고 적용했다.


기존 문제 해결

역할 불분명

전에는 하나의 계층에서 모든 것을 관리했다면, FSD에서는 기능적 역할 (Layer ), 도메인 (Slice ), 기술적 관심사 (Segment)로 분리를 해서 프로젝트 구조가 더 체계적으로 변경됐다.


의존성 문제

이전에는 /components 에 있는 컴포넌트 파일들이 다른 컴포넌트 파일들을 아무렇게나 사용했다.

FSD 아키텍처에서는 상위 Layer 가 하위 Layer 를 조합해서 사용하고, 하위 Layer 는 상위 Layer 를 알 필요가 없으므로, 의존성이 한쪽 방향으로만 흐른다.


적용 및 규칙

Segments

  • api: 외부 서비스와의 통신을 담당하는 API 엔드포인트
  • lib: 순수 함수와 도메인 특화 헬퍼 함수 모음.
  • model: 데이터 구조, 상태 관리, 비즈니스 로직.
  • ui: 순수 표현 컴포넌트. 데이터와 이벤트 핸들러를 받아 화면을 렌더링한다.

barrel export 규칙

필요한 부분만 export한다.

// index.ts
export {
  getPage,
  getPages,
  createPage,
  deletePage,
  updatePage,
} from "./api/pageApi"; // ✅
export { type Page, type CreatePageRequest } from "./model/pageTypes"; // ✅

모든 것을 export하지 않는다.

export * from "./api/pageApi"; // ❌
export * from "./model/pageTypes"; // ❌

import 규칙

하위 레이어는 상위 레이어의 파일을 import 하지 않는다.

// features/canvas/ui/Canvas

import { EditorView } from "@/widgets/EditorView" // ❌

같은 Layer + 같은 Slice + 다른 Segment는 상대 경로를 이용하고, 다른 LayerSlice 는 가져올 때는 절대 경로를 이용한다.

// features/canvas/ui/Canvas
import { CollaborativeCursors } from "../CollaborativeCursors";
import { useCanvas } from "../../model/useCanvas";
import { MemoizedGroupNode, NoteNode } from "@/entities/node";
import { cn } from "@/shared/lib";

같은 Layer + 다른 slice은 import 하지 않는다.

// features/canvas/model/useCanvas.ts

import { usePageStore } from "@/features/pageSidebar"; 

헷갈리는 개념들

entities : 사용자가 이용을 할 수 없는 데이터 그 자체

features : 사용자가 이용을 할 수 있는 기능

ex )

Editor는 사용자가 이용을 한다. → features

Canvas는 사용자가 이용을 한다. → features

Page는 사용자가 이용을 할 수 없는 엔티티 그 자체이다. → entities

model : 서비스 도메인에 의존하는 비즈니스 로직 + 커스텀 훅 (상태와 생명주기에 의존)

lib : 유틸함수 + 헬퍼 함수


FSD를 이용한 문제 해결

잘못된 상태 관리 문제점 발견

FSD에서는 같은 Layer에서 다른 Slice를 사용하면 안된다.

→ 우리 프로젝트의 useCanvas에서는 현재 열고 있는 페이지를 알기 위해, 전역 상태인 pageStore 를 사용하고 있었다. 여기서 같은 Layer에서 다른 Slice를 사용한 케이스가 발생했다!

→ 뭔가 구조가 잘못 되어 있다는 경고!!

// features/canvas/model/useCanvas.ts
import { usePageStore } from "@/features/pageSidebar"; 

FSD를 적용하지 않았을 때는 문제가 되지 않았다.

하지만 지금와서 다시 보니, page에 대한 상태와 editor에 대한 상태를 같이 관리하는 것이 SRP를 위반한 것 처럽 보였다.

pageStoreeditorStore를 분리해야겠다!

// pageStore.ts
import { create } from "zustand";

interface PageStore {
  currentPage: number | null;
  isPanelOpen: boolean;
  isMaximized: boolean;
  setCurrentPage: (currentPage: number | null) => void;
  togglePanel: () => void;
  toggleMaximized: () => void;
  setIsPanelOpen: (isOpen: boolean) => void;
}

export const usePageStore = create<PageStore>((set) => ({
  currentPage: null,
  isPanelOpen: true,
  isMaximized: false,
  setCurrentPage: (currentPage: number | null) =>
    set((state) => ({
      currentPage,
      isPanelOpen:
        currentPage === state.currentPage
          ? !state.isPanelOpen
          : currentPage !== null,
    })),
  togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
  toggleMaximized: () => set((state) => ({ isMaximized: !state.isMaximized })),
  setIsPanelOpen: (isPanelOpen: boolean) => set({ isPanelOpen }),
}));

분리한 결과

// entities/page/model/pageStore.ts
import { create } from "zustand";

interface PageStore {
  currentPage: number | null;
  setCurrentPage: (currentPage: number | null) => void;
}

export const usePageStore = create<PageStore>((set) => ({
  currentPage: null,
  setCurrentPage: (currentPage: number | null) => set({ currentPage }),
}));

// features/editor/model/editorStore.ts
import { create } from "zustand";

interface EditorStore {
  isPanelOpen: boolean;
  isMaximized: boolean;
  togglePanel: () => void;
  toggleMaximized: () => void;
  setIsPanelOpen: (isOpen: boolean) => void;
}

export const useEditorStore = create<EditorStore>((set) => ({
  isPanelOpen: true,
  isMaximized: false,
  togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
  toggleMaximized: () => set((state) => ({ isMaximized: !state.isMaximized })),
  setIsPanelOpen: (isPanelOpen: boolean) => set({ isPanelOpen }),
}));

props 문제 해결

여기서에도 마찬가지로 같은 Layer의 다른 Slice를 참조하고 있었다.

// entities/node
import { usePageStore } from "@/entities/page";
import { useUserStore } from "@/entities/user";

export function NoteNode({ data }: NodeProps<NoteNodeType>) {
  const { currentPage, setCurrentPage } = usePageStore();
  ...
}

UI 컴포넌트가 직접 전역 상태를 가져와서 처리하는 것보다, 상위 컴포넌트로 props로 전달 받은 값을 이용하는 게 좋다고 생각했다.

export function NoteNode({
  data,
  currentPage,
  users,
  handleNodeClick,
}: NoteNodeProps) {
	...
}

const nodeTypes = useMemo(() => {
  return {
    note: (props: NodeProps<NoteNodeType>) => {
      return NoteNode({
        ...props,
        currentPage,
        handleNodeClick,
        users,
      });
    },
    group: MemoizedGroupNode,
  };
}, [users]);

참고 자료

https://velog.io/@teo/separation-of-concerns-of-frontend

https://velog.io/@teo/fsd

개발 문서

⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
🚧 트러블슈팅

팀 문화

🧸 팀원 소개
⛺️ 그라운드 룰
🍞 커밋 컨벤션
🧈 이슈, PR 컨벤션
🥞 브랜치 전략

그룹 기록

📢 발표 자료
🌤️ 데일리 스크럼
📑 회의록
🏖️ 그룹 회고
🚸 멘토링 일지
Clone this wiki locally