react

FSD Architecture TypeScript 예시

긍정왕_JERRY 2025. 8. 29. 22:19
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
반응형