-
Notifications
You must be signed in to change notification settings - Fork 3
FE 프로젝트 구조 개선 과정
/api
: 백엔드와의 통신을 담당하는 코드
/components
: 컴포넌트 폴더
/lib
: 프로젝트 혹은 라이브러리에 의존하지 않는 함수
/hooks
: 리액트에 의존하고, 리액트 상태 관리를 이용하는 함수 + 비즈니스 로직
/store
: 상태 관련 파일들
현재 /components
의 역할이 불분명한 상태이다.
기능에 관련된 컴포넌트, 공용 컴포넌트, 컨테이너 컴포넌트들이 모두 /components
폴더에 있고 정해진 기준이 없는 상태이다.
또한 기능에 관련된 컴포넌트들은 맡고있는 역할이 너무 많다.
canvas
editor
sidebar
다른 컴포넌트에서 불러와서 사용하는 파일들이 담겨있다.
ex) Dialog
, PopOver
, Button
, …
기능에 관련된 컴포넌트와 공용 컴포넌트들을 조합해서 사용하는 컴포넌트이다.
현재 EditorView.tsx
가 layout
에 있는 레이아웃 컴포넌트, commons
에 있는 공용 컴포넌트, Editor
에 있는 에디터 기능 관련 컴포넌트를 모두 불러와서 사용한다.
파일 간의 의존성이 높아서, 컴포넌트가 어떤 구조로 연결되어 있는지 파악하기가 어려웠다.
FSD(Feature-Sliced Design)는 프론트엔드 애플리케이션을 구조화하는 새로운 방법론이다.
기존의 단순한 역할별 분류(components
, utils
등)에서 벗어나, 애플리케이션을 더 체계적으로 구성할 수 있는 방법을 제시한다.
코드베이스가 커지게 되면 /components
, /hooks
, /utils
폴더 등에 수많은 파일이 쌓이고, 이를 다시 분류하려고 하면 프로젝트마다 제각각의 구조를 가지게 된다.
이러한 현상이 벌어지는 이유는 ‘역할’이라는 단일 관점의 관심사만으로 코드를 구성했기 때문이다.실제로 프로젝트에는 역할 뿐만이 아니라 도메인, 기능, 데이터의 흐름 등 다양한 관심사가 존재하며, 이를 세밀하게 분리할 필요가 있다.
FSD는 이런 문제를 해결하기 위해 세 가지 축으로 코드를 구조화한다.
Layers
: 기능적 역할에 따른 수직적 관심사 분리
Slices
: 도메인 별 관심사 분리
Segments
: 기술적 관심사 분리
- 다양한 관점의 관심사를 세밀하게 분리하여 각각의 관심사를 특정하여 분리할 수 있다.
- 코드의 일관성과 탐색의 용이성을 높이는 명확한 컨벤션을 제공한다.
우리 프로젝트는 페이지 단위가 아닌 기능 위주의 애플리케이션이다.
하지만, 기능에 관련된 컴포넌트의 코드와 역할이 너무 많았고, 응집도가 낮았다.
애플리케이션을 더 체계적으로 구성하기 위해 FSD 아키텍처를 선택했고 적용했다.
전에는 하나의 계층에서 모든 것을 관리했다면, FSD에서는 기능적 역할 (Layer
), 도메인 (Slice
), 기술적 관심사 (Segment
)로 분리를 해서 프로젝트 구조가 더 체계적으로 변경됐다.
이전에는 /components
에 있는 컴포넌트 파일들이 다른 컴포넌트 파일들을 아무렇게나 사용했다.
FSD 아키텍처에서는 상위 Layer
가 하위 Layer
를 조합해서 사용하고, 하위 Layer
는 상위 Layer
를 알 필요가 없으므로, 의존성이 한쪽 방향으로만 흐른다.
- api: 외부 서비스와의 통신을 담당하는 API 엔드포인트
- lib: 순수 함수와 도메인 특화 헬퍼 함수 모음.
- model: 데이터 구조, 상태 관리, 비즈니스 로직.
- ui: 순수 표현 컴포넌트. 데이터와 이벤트 핸들러를 받아 화면을 렌더링한다.
필요한 부분만 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 하지 않는다.
// features/canvas/ui/Canvas
import { EditorView } from "@/widgets/EditorView" // ❌
같은 Layer
+ 같은 Slice
+ 다른 Segment
는 상대 경로를 이용하고, 다른 Layer
의 Slice
는 가져올 때는 절대 경로를 이용한다.
// 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에서는 같은 Layer
에서 다른 Slice
를 사용하면 안된다.
→ 우리 프로젝트의 useCanvas
에서는 현재 열고 있는 페이지를 알기 위해, 전역 상태인 pageStore
를 사용하고 있었다. 여기서 같은 Layer
에서 다른 Slice
를 사용한 케이스가 발생했다!
→ 뭔가 구조가 잘못 되어 있다는 경고!!
// features/canvas/model/useCanvas.ts
import { usePageStore } from "@/features/pageSidebar"; ❌
FSD를 적용하지 않았을 때는 문제가 되지 않았다.
하지만 지금와서 다시 보니, page
에 대한 상태와 editor
에 대한 상태를 같이 관리하는 것이 SRP
를 위반한 것 처럽 보였다.
→ pageStore
과 editorStore
를 분리해야겠다!
// 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 }),
}));
여기서에도 마찬가지로 같은 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]);
⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
✏️ 에디터
Novel이란?
Novel 스타일링 문제
에디터 저장 및 고려 사항들
📠 실시간 협업, 통신
Yorkie와 Novel editor 연동
YJS, Websocket, React-Flow
YJS, Socket.io
WebSocket과 Socket.io에 대해 간단히 알아보기
YJS 가이드 근데 이제 Socket.io를 곁들인
🏗️ 인프라와 CI/CD
NCloud CI CD 구축
BE 개발 스택과 기술적 고민
private key로 원격 서버 접근
nCloud 서버, VPC 만들고 설정
monorepo로 변경
⌛ 캐시, 최적화
rabbit mq 사용법
🔑 인증, 인가, 보안
passport로 oAuth 로그인 회원가입 구현
FE 로그인 기능 구현
JWT로 인증 인가 구현
JWT 쿠키로 사용하기
refresh token 보완하기
🧸 팀원 소개
⛺️ 그라운드 룰
🍞 커밋 컨벤션
🧈 이슈, PR 컨벤션
🥞 브랜치 전략
🌤️ 데일리 스크럼
📑 회의록
1️⃣ 1주차
킥오프(10/25)
2일차(10/29)
3일차(10/30)
4일차(10/31)
2️⃣ 2주차
8일차(11/04)
9일차(11/05)
11일차(11/07)
13일차(11/09)
3️⃣ 3주차
3주차 주간계획(11/11)
16일차(11/12)
18일차(11/14)
4️⃣ 4주차
4주차 주간계획(11/18)
23일차(11/19)
24일차(11/20)
25일차(11/21)
5️⃣ 5주차
5주차 주간계획(11/25)
29일차(11/25)
32일차(11/28)
34일차(11/30)
6️⃣ 6주차
6주차 주간계획(12/2)
37일차(12/3)