728x90
반응형
src/
├── shared/ # 공통 유틸리티, API 클라이언트, 도메인 기반 클래스
│ ├── api/ # API 클라이언트, Query Client
│ ├── domain/ # ValueObject 추상 클래스
│ ├── libs/ # 날짜, 에러 처리 등 유틸리티
│ └── ui/ # 공통 UI 컴포넌트
├── entities/ # 비즈니스 엔티티
│ ├── post/ # Post 도메인
│ ├── comment/ # Comment 도메인
│ └── user/ # User 도메인
├── features/ # 애플리케이션 비즈니스 로직
│ ├── post/ # 포스트 관련 기능
│ └── user/ # 사용자 관련 기능
├── widgets/ # 독립적인 UI 블록
├── pages/ # 페이지 컴포넌트
└── app/ # 앱 전역 설정
FSD Architecture TypeScript 예시
1. Shared Layer - 공통 유틸리티 및 기반 구조
shared/domain/valueObject.ts
export abstract class ValueObject<T> {
protected readonly _value: T
constructor(value: T) {
this.validate(value)
this._value = this.deepFreeze(value)
}
// 깊은 불변성 보장
private deepFreeze<U>(obj: U): U {
if (obj && typeof obj === 'object' && !Object.isFrozen(obj)) {
Object.freeze(obj)
Object.getOwnPropertyNames(obj).forEach((prop) => {
const value = obj[prop]
if (
value !== null &&
(typeof value === 'object' || typeof value === 'function') &&
!Object.isFrozen(value)
) {
this.deepFreeze(value)
}
})
}
return obj
}
protected abstract validate(value: T): void
public equals(other: ValueObject<T>): boolean {
if (other === null || other === undefined) return false
if (other.constructor !== this.constructor) return false
return this.equalsValue(other._value)
}
protected equalsValue(value: T): boolean {
if (typeof this._value === 'object' && this._value !== null) {
return JSON.stringify(this._value) === JSON.stringify(value)
}
return this._value === value
}
public get value(): T {
return this._value
}
}
// Entity 추상 클래스
export abstract class Entity<T> {
protected readonly id: string;
constructor(id: string) {
this.id = id;
}
public getId(): string {
return this.id;
}
public equals(other: Entity<T>): boolean {
return this.id === other.id;
}
}
shared/api/client.ts
import axios, { AxiosInstance } from 'axios';
export class ApiClient {
private static instance: AxiosInstance;
static getInstance(): AxiosInstance {
if (!this.instance) {
this.instance = axios.create({
baseURL: process.env.REACT_APP_API_URL,
timeout: 10000,
});
}
return this.instance;
}
}
export const apiClient = ApiClient.getInstance();
shared/api/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분
retry: 2,
},
},
});
shared/libs/result.ts
// 에러 처리를 위한 Result 패턴
export type Result<T, E = Error> = Success<T> | Failure<E>;
export class Success<T> {
constructor(public readonly value: T) {}
isSuccess(): this is Success<T> {
return true;
}
isFailure(): this is Failure<never> {
return false;
}
}
export class Failure<E> {
constructor(public readonly error: E) {}
isSuccess(): this is Success<never> {
return false;
}
isFailure(): this is Failure<E> {
return true;
}
}
shared/ui/Button.tsx
import React from 'react';
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
onClick,
variant = 'primary',
disabled = false
}) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant} ${disabled ? 'disabled' : ''}`}
>
{children}
</button>
);
};
2. Entities Layer - 비즈니스 엔티티
entities/post/model/post.ts
import { Entity, ValueObject } from 'shared/domain/valueObject';
// Value Objects
export class PostTitle extends ValueObject<string> {
protected validate(value: string): void {
if (!value || value.trim().length === 0) {
throw new Error('제목은 필수입니다');
}
if (value.length > 100) {
throw new Error('제목은 100자를 초과할 수 없습니다');
}
}
}
export class PostContent extends ValueObject<string> {
protected validate(value: string): void {
if (!value || value.trim().length === 0) {
throw new Error('내용은 필수입니다');
}
}
}
// Entity
export class Post extends Entity<Post> {
constructor(
id: string,
private title: PostTitle,
private content: PostContent,
private authorId: string,
private createdAt: Date,
private updatedAt: Date
) {
super(id);
}
public getTitle(): string {
return this.title.getValue();
}
public getContent(): string {
return this.content.getValue();
}
public getAuthorId(): string {
return this.authorId;
}
public getCreatedAt(): Date {
return this.createdAt;
}
public updateContent(newContent: string): void {
this.content = new PostContent(newContent);
this.updatedAt = new Date();
}
public static create(data: {
id: string;
title: string;
content: string;
authorId: string;
createdAt: string;
updatedAt: string;
}): Post {
return new Post(
data.id,
new PostTitle(data.title),
new PostContent(data.content),
data.authorId,
new Date(data.createdAt),
new Date(data.updatedAt)
);
}
}
entities/post/api/postRepository.ts
import { apiClient } from 'shared/api/client';
import { Result, Success, Failure } from 'shared/libs/result';
import { Post } from '../model/post';
export interface PostRepository {
findById(id: string): Promise<Result<Post>>;
findAll(): Promise<Result<Post[]>>;
save(post: Partial<Post>): Promise<Result<Post>>;
delete(id: string): Promise<Result<void>>;
}
export class HttpPostRepository implements PostRepository {
async findById(id: string): Promise<Result<Post>> {
try {
const response = await apiClient.get(`/posts/${id}`);
const post = Post.create(response.data);
return new Success(post);
} catch (error) {
return new Failure(error as Error);
}
}
async findAll(): Promise<Result<Post[]>> {
try {
const response = await apiClient.get('/posts');
const posts = response.data.map((data: any) => Post.create(data));
return new Success(posts);
} catch (error) {
return new Failure(error as Error);
}
}
async save(postData: Partial<Post>): Promise<Result<Post>> {
try {
const response = await apiClient.post('/posts', postData);
const post = Post.create(response.data);
return new Success(post);
} catch (error) {
return new Failure(error as Error);
}
}
async delete(id: string): Promise<Result<void>> {
try {
await apiClient.delete(`/posts/${id}`);
return new Success(undefined);
} catch (error) {
return new Failure(error as Error);
}
}
}
export const postRepository = new HttpPostRepository();
entities/post/api/postQueries.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { postRepository } from './postRepository';
import { Post } from '../model/post';
export const POST_QUERY_KEYS = {
all: ['posts'] as const,
detail: (id: string) => ['posts', id] as const,
};
export const usePost = (id: string) => {
return useQuery({
queryKey: POST_QUERY_KEYS.detail(id),
queryFn: async () => {
const result = await postRepository.findById(id);
if (result.isFailure()) {
throw result.error;
}
return result.value;
},
});
};
export const usePosts = () => {
return useQuery({
queryKey: POST_QUERY_KEYS.all,
queryFn: async () => {
const result = await postRepository.findAll();
if (result.isFailure()) {
throw result.error;
}
return result.value;
},
});
};
export const useCreatePost = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (postData: { title: string; content: string; authorId: string }) => {
const result = await postRepository.save(postData);
if (result.isFailure()) {
throw result.error;
}
return result.value;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: POST_QUERY_KEYS.all });
},
});
};
entities/post/index.ts
// Public API 캡슐화
export { Post, PostTitle, PostContent } from './model/post';
export { postRepository } from './api/postRepository';
export { usePost, usePosts, useCreatePost, POST_QUERY_KEYS } from './api/postQueries';
export type { PostRepository } from './api/postRepository';
3. Features Layer - 애플리케이션 비즈니스 로직
features/post/create-post/model/store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface CreatePostState {
title: string;
content: string;
isSubmitting: boolean;
setTitle: (title: string) => void;
setContent: (content: string) => void;
setSubmitting: (isSubmitting: boolean) => void;
reset: () => void;
}
export const useCreatePostStore = create<CreatePostState>()(
devtools(
(set) => ({
title: '',
content: '',
isSubmitting: false,
setTitle: (title) => set({ title }),
setContent: (content) => set({ content }),
setSubmitting: (isSubmitting) => set({ isSubmitting }),
reset: () => set({ title: '', content: '', isSubmitting: false }),
}),
{ name: 'create-post-store' }
)
);
features/post/create-post/lib/validation.ts
export interface CreatePostFormData {
title: string;
content: string;
}
export interface ValidationError {
field: keyof CreatePostFormData;
message: string;
}
export const validateCreatePostForm = (data: CreatePostFormData): ValidationError[] => {
const errors: ValidationError[] = [];
if (!data.title.trim()) {
errors.push({ field: 'title', message: '제목을 입력해주세요.' });
} else if (data.title.length > 100) {
errors.push({ field: 'title', message: '제목은 100자를 초과할 수 없습니다.' });
}
if (!data.content.trim()) {
errors.push({ field: 'content', message: '내용을 입력해주세요.' });
}
return errors;
};
features/post/create-post/ui/CreatePostForm.tsx
import React, { useState } from 'react';
import { Button } from 'shared/ui/Button';
import { useCreatePost } from 'entities/post';
import { useCreatePostStore } from '../model/store';
import { validateCreatePostForm, ValidationError } from '../lib/validation';
export const CreatePostForm: React.FC = () => {
const { title, content, setTitle, setContent, reset } = useCreatePostStore();
const [errors, setErrors] = useState<ValidationError[]>([]);
const createPostMutation = useCreatePost();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const formData = { title, content };
const validationErrors = validateCreatePostForm(formData);
if (validationErrors.length > 0) {
setErrors(validationErrors);
return;
}
setErrors([]);
try {
await createPostMutation.mutateAsync({
...formData,
authorId: 'current-user-id', // 실제로는 인증된 사용자 ID
});
reset();
alert('포스트가 생성되었습니다!');
} catch (error) {
alert('포스트 생성에 실패했습니다.');
}
};
const getFieldError = (field: string) => {
return errors.find(error => error.field === field)?.message;
};
return (
<form onSubmit={handleSubmit} className="create-post-form">
<div className="form-group">
<label htmlFor="title">제목</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className={getFieldError('title') ? 'error' : ''}
/>
{getFieldError('title') && (
<span className="error-message">{getFieldError('title')}</span>
)}
</div>
<div className="form-group">
<label htmlFor="content">내용</label>
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
className={getFieldError('content') ? 'error' : ''}
/>
{getFieldError('content') && (
<span className="error-message">{getFieldError('content')}</span>
)}
</div>
<Button
type="submit"
disabled={createPostMutation.isPending}
variant="primary"
>
{createPostMutation.isPending ? '생성 중...' : '포스트 생성'}
</Button>
</form>
);
};
features/post/create-post/index.ts
export { CreatePostForm } from './ui/CreatePostForm';
export { useCreatePostStore } from './model/store';
export { validateCreatePostForm } from './lib/validation';
4. Widgets Layer - 독립적인 UI 블록
widgets/post-list/ui/PostListWidget.tsx
import React from 'react';
import { usePosts } from 'entities/post';
export const PostListWidget: React.FC = () => {
const { data: posts, isLoading, error } = usePosts();
if (isLoading) {
return <div className="loading">포스트를 불러오는 중...</div>;
}
if (error) {
return <div className="error">포스트를 불러오는데 실패했습니다.</div>;
}
if (!posts || posts.length === 0) {
return <div className="empty">작성된 포스트가 없습니다.</div>;
}
return (
<div className="post-list-widget">
<h2>최신 포스트</h2>
<ul className="post-list">
{posts.map((post) => (
<li key={post.getId()} className="post-item">
<h3>{post.getTitle()}</h3>
<p className="post-preview">
{post.getContent().substring(0, 100)}...
</p>
<div className="post-meta">
<span>작성일: {post.getCreatedAt().toLocaleDateString()}</span>
</div>
</li>
))}
</ul>
</div>
);
};
widgets/post-list/index.ts
export { PostListWidget } from './ui/PostListWidget';
5. Pages Layer - 페이지 컴포넌트
pages/PostCreatePage/ui/PostCreatePage.tsx
import React from 'react';
import { CreatePostForm } from 'features/post/create-post';
export const PostCreatePage: React.FC = () => {
return (
<div className="page post-create-page">
<div className="container">
<h1>새 포스트 작성</h1>
<CreatePostForm />
</div>
</div>
);
};
pages/PostCreatePage/index.ts
export { PostCreatePage } from './ui/PostCreatePage';
pages/HomePage/ui/HomePage.tsx
import React from 'react';
import { PostListWidget } from 'widgets/post-list';
export const HomePage: React.FC = () => {
return (
<div className="page home-page">
<div className="container">
<h1>블로그 홈</h1>
<PostListWidget />
</div>
</div>
);
};
6. App Layer - 앱 전역 설정
app/providers/QueryProvider.tsx
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from 'shared/api/queryClient';
interface QueryProviderProps {
children: React.ReactNode;
}
export const QueryProvider: React.FC<QueryProviderProps> = ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
};
app/App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { QueryProvider } from './providers/QueryProvider';
import { HomePage } from 'pages/HomePage';
import { PostCreatePage } from 'pages/PostCreatePage';
export const App: React.FC = () => {
return (
<QueryProvider>
<Router>
<div className="app">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/posts/create" element={<PostCreatePage />} />
</Routes>
</div>
</Router>
</QueryProvider>
);
};
FSD 핵심 특징
1. 단방향 의존성 (Unidirectional Dependencies)
app → pages → widgets → features → entities → shared
- 상위 레이어는 하위 레이어에만 의존
- pages는 widgets, features, entities, shared를 import 가능
- entities는 shared만 import 가능
- shared는 어떤 레이어도 import하지 않음
2. 세그먼트 간 격리 (Segment Isolation)
// ❌ 잘못된 예 - 다른 feature의 내부 구현에 직접 의존
import { useCreatePostStore } from 'features/post/create-post/model/store';
// ✅ 올바른 예 - public API를 통한 접근
import { CreatePostForm } from 'features/post/create-post';
3. 캡슐화 (Encapsulation)
각 슬라이스는 index.ts를 통해 public API를 명시적으로 정의:
// entities/post/index.ts - Public API만 노출
export { Post, PostTitle, PostContent } from './model/post';
export { usePost, usePosts, useCreatePost } from './api/postQueries';
// 내부 구현은 숨김 (Repository 구현체 등)
4. 레이어별 책임
- Shared: 재사용 가능한 유틸리티, 기반 클래스
- Entities: 순수한 비즈니스 로직, 도메인 모델
- Features: 사용자 시나리오, 비즈니스 요구사항
- Widgets: 재사용 가능한 UI 블록
- Pages: 라우팅, 페이지 레이아웃
- App: 전역 설정, 프로바이더
이러한 구조를 통해 확장 가능하고 유지보수가 용이한 프론트엔드 애플리케이션을 구축할 수 있습니다.
728x90
반응형
'react' 카테고리의 다른 글
| [최적화] Nextjs 번들 최적화 (Bundle Optimization) (0) | 2025.12.12 |
|---|---|
| 2025년에 돌아보는 react-query (0) | 2025.12.10 |
| [설계] 객체지향의 유연한 구조 설계, TypeScript와 React로 이해하기 (0) | 2025.08.24 |
| [설계] 대규모 프로젝트에서 확장 가능한 의존성 설계 feat. Feature-Sliced Design (0) | 2025.08.24 |
| [설계] 리액트에서 API 통신 추상화하기 (0) | 2025.08.23 |