Logo
Min71 Tech Blog
Published on

Frontend Clean Architecture (with FSD & DDD)

Authors

1. Introduction

백엔드 개발을 경험하면서 DDD(Domain Driven Design)와 Clean Architecture가 가져다주는 구조적 명확성과 유지보수성을 체감했습니다. 복잡한 비즈니스 로직을 도메인 중심으로 구조화하고, 의존성을 역전시켜 테스트하기 쉬운 코드를 작성하는 과정에서 이런 아키텍처 패턴들의 가치를 확신하게 되었습니다.

이후 프론트엔드 개발로 돌아와 FSD(Feature-Sliced Design)를 적용하며 FSD의 entities 레이어가 도메인을 위한 레이어로 느껴졌습니다. 또한 단방향 의존성 규칙과 레이어별 책임 분리라는 FSD의 철학이 클린 아키텍처와 유사하다는 생각을 했습니다 그래서 이런 생각을 해보았습니다

FSD의 entities 레이어 안에서 DDD의 도메인 모델링과 Clean Architecture를 적용해보면 어떨까?

단순한 타입 정의와 데이터 페칭을 넘어서, 프론트엔드에서도 도메인 로직을 캡슐화하고 비즈니스 규칙을 명시적으로 표현할 수 있지 않을까 하는 생각에서 시작해보았습니다.

다만, 이런 접근이 모든 프로젝트에 적합하다고 생각하지는 않습니다. 작은 프론트엔드 프로젝트에서는 이런 수준의 구조화는 분명히 오버 엔지니어링이 될 수 있습니다. 개발 복잡성과 러닝 커브가 증가하고, 과도한 추상화가 오히려 생산성을 해칠 수 있는것은 인지해야합니다.

이 글은 "이런 접근을 프론트엔드에서 실제로 구현하면 코드가 어떤 모습일까?" 머릿속으로만 상상하던 것을 실제로 손으로 짜보면서 겪은 경험을 정리한 글입니다.

예시 코드는 여기에서 확인 가능합니다!


2. 세 가지 아키텍처 패턴의 핵심 개념

먼저 각 아키텍처 패턴에 대한 핵심 개념을 간단하게 알아보겠습니다.

2-1 FSD (Feature-Sliced Design)

FSD는 프론트엔드 애플리케이션의 복잡도 증가 문제를 체계적으로 해결하기 위한 설계 방법론입니다. 기존의 폴더 구조나 컴포넌트 분리 방식으로는 한계가 있는 대규모 프론트엔드 프로젝트에서, 비즈니스 로직과 UI의 명확한 분리, 기능 단위의 독립적 개발, 예측 가능한 의존성 흐름을 실현하기 위해 탄생했습니다.

src/
├── shared/       # 재사용 가능한 유틸리티, API 클라이언트
├── entities/     # 비즈니스 엔티티 (User, Post, Comment)
├── features/     # 비즈니스 기능 (CreatePost, GetPosts)
├── widgets/      # 독립적인 UI 블록 (PostListSection, MainHeader)
├── pages/        # 페이지 컴포넌트
└── app/          # 앱 전역 설정, 레이아웃

핵심 원칙 중 하나는 의존성의 방향이 항상 위에서 아래로만 흐른다는 점입니다. 즉, 상위 레이어가 하위 레이어를 참조할 수 있지만, 반대로 하위 레이어가 상위 레이어를 직접 참조해서는 안 됩니다. 또한 Public API를 통해 index.ts 기반의 명시적 인터페이스로만 레이어 간 통신하며, 슬라이스별 격리를 통해 동일 레이어 내 슬라이스 간 직접 의존성을 방지합니다.

//src/features/post/hooks/useGetPosts.ts
// 올바른 의존성 방향
import { PostService } from '../post/services' // 같은 슬라이스
import { Post } from '@/entities/post' // 하위 레이어

FSD가 제공하는 가치는 명확한 의존성 흐름입니다. 단방향 의존성 원칙으로 코드의 흐름을 예측하기 쉽게 만들고, 각 레이어의 책임이 명확해 유지보수성을 크게 개선합니다.

2-2 DDD (Domain-Driven Design)

DDD는 복잡한 비즈니스를 표현하는 방법론입니다. 여기서 말하는 도메인(Domain)이란 소프트웨어가 해결하고자 하는 문제 영역, 즉 비즈니스 영역을 의미합니다. 예를 들어 SNS같은 경우, 피드, 유저, 댓글 등이 도메인에 해당합니다. DDD의 핵심은 이런 도메인 지식을 코드로 정확히 표현하여 소프트웨어와 비즈니스 간의 간극을 줄이는 것입니다.

DDD는 크게 전략적 설계전술적 설계로 구분됩니다.

전략적 설계 (Strategic Design)

전략적 설계는 복잡한 도메인을 이해하고 시스템 전체의 경계를 나누는 거시적 관점의 설계 접근법입니다. 비즈니스 전략과 조직 구조까지 고려하여 소프트웨어 시스템의 전체적인 구조를 설계하는 것이 목표입니다. 주요 구성요소는 다음과 같습니다.

  • Bounded Context: 모델이 일관성을 유지하며 적용되는 명확한 경계를 정의합니다
  • Ubiquitous Language: 도메인 전문가와 개발자가 공통으로 사용하는 언어를 구축합니다
  • Context Map: 서로 다른 컨텍스트 간의 관계와 통합 방식을 정의합니다

전술적 설계 (Tactical Design)

전술적 설계는 실제 코드 레벨에서 도메인을 구현하는 구체적인 방법론입니다. 도메인 지식과 비즈니스 규칙을 코드로 명확히 표현하기 위한 다양한 패턴들을 제공하며, 비즈니스 로직을 명확하고 유지보수 가능한 형태로 구조화하는 것을 목표로 합니다. 주요 구성요소는 다음과 같습니다.

  • Entity: 고유 식별자를 가진 생명주기 동안 변화할 수 있는 도메인 객체를 의미합니다. 단순한 데이터가 아닌 행동을 가진 객체로, 비즈니스 규칙을 메서드로 표현합니다.
  • Value Object: 식별자가 없고 불변인 객체로, 값 자체로만 구별됩니다. 주로 유효성 검증 로직을 캡슐화하며, 도메인의 개념을 명시적으로 표현하는 역할을 가집니다.
  • Factory: 복잡한 객체 생성 로직을 캡슐화하는 패턴입니다. 객체의 불변조건을 보장하고, 생성 과정의 복잡성을 클라이언트로부터 숨깁니다.
  • Repository: 도메인 객체에 대한 컬렉션처럼 동작하는 인터페이스입니다. 데이터 저장소의 구체적인 구현을 추상화하여 도메인 로직이 Infrastructure에 의존하지 않도록 합니다.
  • Domain Service: 특정 엔티티나 값 객체에 속하지 않는 도메인 로직을 담당합니다. 여러 엔티티를 조율하거나 복잡한 비즈니스 규칙을 처리할 때 사용됩니다.
  • Aggregate: 관련된 객체들을 하나의 단위로 묶어 데이터 일관성을 보장하는 경계입니다. 애그리게이트 루트를 통해서만 외부에서 접근하도록 제한함으로써, 도메인 일관성을 유지합니다.

DDD의 본질은 문제 중심의 설계입니다. 기술적 관심사보다 비즈니스 문제 해결에 집중하며, 각 도메인의 고유한 특성을 코드에 반영하여 현실과 소프트웨어 사이의 간극을 최소화합니다.

2-3 Clean Architecture

Clean Architecture는 Robert C. Martin이 제안한 아키텍처 패턴으로, 비즈니스 로직을 외부 세계로부터 보호하는 것이 핵심 철학입니다. 여기서 말하는 외부 세계란 프레임워크, 데이터베이스, UI, 외부 API 등 변경 가능성이 높은 기술적 세부사항을 의미합니다. Clean Architecture의 목표는 이런 기술적 변화가 핵심 비즈니스 로직에 영향을 주지 않도록 의존성 역전을 통해 시스템을 설계하는 것입니다.

4개 레이어 구조

Clean Architecture는 동심원 형태의 4개 레이어로 구성되며, 각 레이어는 명확한 책임과 역할을 가집니다.

Entities (Enterprise Business Rules)

핵심 비즈니스 규칙을 담는 가장 안쪽 레이어입니다. 애플리케이션이나 시스템과 무관하게 존재하는 순수한 비즈니스 개념과 규칙을 표현하며, 외부 변화에 가장 적게 영향받습니다. 예를 들어 "사용자명은 3-20자의 영문, 숫자, 언더스코어만 허용한다"는 규칙은 어떤 기술을 사용하든 변하지 않는 핵심 규칙입니다.

// /src/entities/user/core/user.domain.ts
export class User implements UserEntity {
  constructor(
    private _id: string,
    private _username: string,
    private _email: string
  ) {}

  updateUsername(newUsername: string) {
    if (!this.isValidUsername(newUsername)) {
      throw BaseError.validation('Invalid username format')
    }
    this._username = newUsername
  }

  private isValidUsername(username: string): boolean {
    return /^[a-zA-Z0-9_.]{3,20}$/.test(username)
  }
}

Use Cases (Application Business Rules)

애플리케이션의 핵심 기능들을 구현하는 레이어입니다. 시스템이 사용자에게 제공하는 구체적인 서비스를 정의하며, 엔티티들을 활용해 특정 비즈니스 로직을 처리합니다. 시스템의 입출력을 정의하고 애플리케이션 흐름을 제어하되, 외부 시스템과는 인터페이스를 통해서만 소통하여 구체적인 구현에 의존하지 않습니다.

// /src/features/user/services/user.service.ts
import { UserProfileDto, UserRepository } from '@/entities/user'
import { BaseError } from '@/shared/libs/errors'
import { UserUseCase } from '../usecase/user.usecase'

export const UserService = (userRepository: UserRepository): UserUseCase => ({
  getUserProfile: async (): Promise<UserProfileDto> => {
    try {
      const result = await userRepository.getUserProfile()
      if (!result) {
        throw BaseError.notFound('User', 'current')
      }
      return result
    } catch (error) {
      console.error('Error fetching user profile:', error)
      if (error instanceof BaseError) {
        throw error
      }
      throw new BaseError('Failed to fetch user profile', 'FetchError')
    }
  },
})

Interface Adapters

외부 세계와 내부 비즈니스 로직 사이의 변환 계층입니다. 외부에서 들어오는 데이터를 유스케이스와 엔티티가 이해할 수 있는 형태로 변환하고, 반대로 내부 데이터를 외부 시스템이 원하는 형태로 내보냅니다. Repository 구현체, API 어댑터, 데이터 매퍼 등이 여기에 해당하며, 외부 데이터 소스가 바뀌어도 내부 로직에는 영향을 주지 않도록 격리시키는 것이 핵심입니다.

// /src/entities/user/infrastructure/repository/user.api.repository.ts
export class UserApiRepository implements UserRepository {
  constructor(private api: ReturnType<typeof UserAdapter>) {}

  async getUserProfile(): Promise<User> {
    const response = await this.api.getProfile()
    return UserMapper.toDomainFromProfile(response)
  }
}

Frameworks & Drivers

가장 바깥쪽 레이어로 웹 프레임워크, 데이터베이스, UI, 외부 API 등 구체적인 기술 구현체들이 위치합니다. 이 레이어의 변경은 내부 레이어에 영향을 주지 않아야 하며, 시스템의 세부 구현사항을 담당합니다.

// /src/shared/api/api.ts
export class ApiClient {
  private baseURL: string

  constructor(config: ApiConfig) {
    this.baseURL = config.baseURL
  }

  async get<T>(url: string, config: RequestInit = {}): Promise<ApiResponse<T>> {
    return this.request<T>(url, { ...config, method: 'GET' })
  }

  async post<T>(url: string, data?: unknown, config: RequestInit = {}): Promise<ApiResponse<T>> {
    return this.request<T>(url, {
      ...config,
      method: 'POST',
      body: JSON.stringify(data),
    })
  }
}

export const apiClient = new ApiClient({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3000/api',
})

핵심 원칙

  • 의존성 규칙(Dependency Rule): Clean Architecture의 가장 핵심적인 원칙입니다. 소스코드의 의존성은 반드시 안쪽인 중심부로만 향해야 하며, 내부 원은 외부 원에 대해 전혀 알지 못해야 합니다. 이는 외부의 변화가 내부로 전파되는 것을 차단하여 시스템의 안정성을 보장합니다.
  • 경계 분리(Boundary Separation): 각 레이어 간의 명확한 인터페이스를 정의하여 관심사를 분리하는 원칙입니다. 레이어 간 통신은 반드시 잘 정의된 인터페이스를 통해서만 이루어지며, 이를 통해 각 레이어의 독립성과 테스트 가능성을 보장합니다.
  • 제어의 역전(Inversion of Control): 고수준 모듈이 저수준 모듈에 의존하지 않고, 둘 다 추상화에 의존하도록 설계합니다. 이는 전통적인 레이어드 아키텍처와의 주요 차이점으로, 애플리케이션 로직은 Infrastructure에 의존하지 않게 되어 더 유연한 설계가 가능해집니다.

Clean Architecture가 추구하는 것은 외부 변화로부터의 독립성입니다. 외부 API나 UI 프레임워크가 바뀌어도 비즈니스 로직은 그대로 유지되며, 테스트 용이성 측면에서 의존성 주입을 통해 각 레이어를 독립적으로 검증할 수 있습니다.


3. 예시 코드 구현

3-1 FSD 구조 적용

레이어별 구조와 관심사 분리

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/          # 앱 전역 설정

각 레이어는 명확한 책임과 경계를 가지며, 의존성 방향이 일관됩니다. shared는 모든 곳에서 사용 가능하지만, entitiesfeatureswidgets에서만 import하고, 각 슬라이스는 서로 참조하지 않는 규칙을 지켰습니다. 이를 통해 순환 의존성 없는 안정적인 구조를 만들었습니다.

슬라이스별 내부 구조와 Public API

entities/post/
├── core/           # 도메인 로직
│   ├── post.domain.ts     # Entity
│   ├── post.factory.ts    # Factory
│   └── post.repository.ts # Repository Interface
├── infrastructure/ # 외부 구현체
│   ├── api/              # API 어댑터
│   ├── dto/              # 외부 데이터 구조
│   └── repository/       # Repository 구현체
├── mapper/         # 데이터 변환
├── types/          # 타입 정의
└── index.ts        # Public API
// /src/entities/post/index.ts - Public API 정의
export { Post } from './core/post.domain'
export { PostFactory } from './core/post.factory'
export { PostRepository } from './core/post.repository'
export { PostApiRepository } from './infrastructure/repository'
export { PostMapper } from './mapper'
export type { PostDto, PostEntity } from './types'

각 슬라이스는 Barrel Export 기반의 Public API 패턴을 활용하여 index.ts를 통해 외부에 노출할 API만 명시적으로 export하고 내부 구현 세부사항은 숨김으로써 강력한 캡슐화를 보장하며, 이를 통해 내부 리팩토링이나 구조 변경이 외부 의존성에 영향을 주지 않는 안정적인 모듈 경계를 구축했습니다.

3-2 DDD 전술적 패턴 적용

Domain

// /src/entities/user/core/user.domain.ts
import { UserEntity } from '@/entities/user/types/user.types'
import { BaseError } from '@/shared/libs/errors'

export class User implements UserEntity {
  private _id: string
  private _username: string
  private _profileImage: string
  private _age: number
  private _email: string
  // ...

  updateUsername(newUsername: string) {
    if (!this.isValidUsername(newUsername)) {
      throw BaseError.validation('Invalid username format')
    }
    this._username = newUsername
  }

  private isValidUsername(username: string): boolean {
    return /^[a-zA-Z0-9_.]{3,20}$/.test(username)
  }
}

Domain Entity는 도메인 모델에서 고유 식별자를 가지며 비즈니스 규칙과 행동을 캡슐화하는 핵심 구성 요소입니다. 단순한 데이터 컨테이너가 아닌 도메인 지식을 포함한 객체로 설계하여, 비즈니스 규칙이 코드에 명시적으로 표현되도록 합니다.

updateUsername() 메서드는 유저 이름 형식 검증이라는 도메인 로직을 내부에 캡슐화하여 잘못된 상태로의 변경을 원천적으로 차단합니다. 이러한 접근은 비즈니스 로직이 애플리케이션 전반에 흩어지는 것을 방지하고, 도메인 규칙 변경 시에도 해당 Entity만 수정하면 되는 중앙 집중식 관리를 가능하게 합니다. 결과적으로 도메인 지식의 명시성, 코드의 안전성, 그리고 비즈니스 로직의 일관성이 향상됩니다.

Value Object

// /src/shared/domain/value-object.ts - ValueObject 추상 클래스
export abstract class ValueObject<T> {
  protected readonly _value: T

  constructor(value: T) {
    this.validate(value)
    this._value = this.deepFreeze(value)
  }

  // 깊은 불변성 보장 (중첩 객체까지 freeze)
  private deepFreeze<U>(obj: U): U {
    if (obj && typeof obj === 'object' && !Object.isFrozen(obj)) {
      Object.freeze(obj)
      Object.getOwnPropertyNames(obj).forEach((prop) => {
        // @ts-expect-error - 인덱스 시그니처 문제 무시
        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
  }
}

// /src/entities/comment/value-objects/comment-body.vo.ts
export class CommentBody extends ValueObject<string> {
  private static readonly MAX_LENGTH = 100

  protected validate(value: string): void {
    if (!value || !value.trim()) {
      throw new Error('Comment body cannot be empty')
    }

    if (value.length > CommentBody.MAX_LENGTH) {
      throw new Error(`Comment body cannot exceed ${CommentBody.MAX_LENGTH} characters`)
    }
  }

  public get text(): string {
    return this.value
  }
}

Value Object는 도메인 모델에서 식별자 없이 값 자체로만 구별되는 불변 객체입니다. 이 구현에서는 deepFreeze()를 통해 내부에 객체나 배열이 포함된 경우에도 완전한 불변성을 보장합니다. 생성자에서 유효성 검증을 먼저 수행하고, 값이 유효할 때만 불변 객체로 만들어 타입 안정성과 도메인 규칙을 강제합니다.

equals() 메서드는 동등성 비교를 안전하게 처리하여, 값이 같으면 같은 객체로 간주할 수 있습니다. CommentBody와 같이 단순 문자열을 감쌀 때도, 생성 시점에 비즈니스 규칙을 강제할 수 있습니다. 이러한 설계는 원시 타입 사용으로 인한 실수를 방지하고, 도메인 개념이 코드에서 명확히 드러나도록 하며, 결과적으로 타입 안전성, 도메인 표현력, 런타임 오류 방지 효과를 모두 크게 향상시킵니다.

Factory

// /src/entities/post/core/post.factory.ts
export class PostFactory {
  // 새로운 포스트 생성
  static createNew(title: string, body: string, user: UserReference, image: string = ''): Post {
    return new Post(
      '', // 새 포스트는 서버에서 ID 할당
      user,
      title,
      body,
      image,
      0, // 좋아요 0으로 시작
      0, // 댓글 수 0으로 시작
      new Date().getTime(), // 생성 시간
      new Date().getTime() // 수정 시간 (생성 시점과 동일)
    )
  }

  // 외부 데이터로부터 도메인 객체 복원
  static createFromDto(dto: PostDto): Post {
    return new Post(
      dto.id,
      dto.user,
      dto.title,
      dto.body,
      dto.image || '', // 기본값 처리
      dto.likes || 0, // 기본값 처리
      dto.totalComments || 0, // 기본값 처리
      dto.createdAt || new Date().getTime(),
      dto.updatedAt || new Date().getTime()
    )
  }
}

Factory는 복잡한 도메인 객체 생성을 체계적으로 관리하는 패턴입니다. Post 객체 생성 시 필요한 다양한 규칙들인 기본값 설정, 유효성 검증, 불변조건 보장 등을 Factory 내부로 캡슐화하여, 클라이언트 코드가 구체적인 생성 로직을 알 필요 없이 명확한 의도를 표현할 수 있게 해줍니다.

createNew는 새로운 포스트 생성 시의 비즈니스 규칙을 담당합니다. 좋아요 수와 댓글 수를 0으로 초기화하고, 생성 시간과 수정 시간을 현재 시점으로 설정하는 등의 도메인 지식이 여기에 집중됩니다. createFromDto는 외부 시스템으로부터 받은 데이터를 도메인 객체로 안전하게 변환하는 역할을 하며, 누락된 값에 대한 기본값 처리나 데이터 정합성 검증을 담당합니다.

이러한 접근 방식은 객체 생성 로직이 애플리케이션 전반에 흩어지는 것을 방지하고, 생성 관련 비즈니스 규칙이 변경될 때 Factory만 수정하면 되는 중앙 집중식 관리를 가능하게 합니다. 결과적으로 코드의 일관성과 유지보수성이 향상됩니다.

Repository

// /src/entities/comment/core/comment.repository.ts

export interface CommentRepository {
  getByPostId(postId: string): Promise<Comment[]>
  getById(id: string): Promise<Comment>
  create(comment: Comment): Promise<Comment>
  update(comment: Comment): Promise<Comment>
  save(comment: Comment): Promise<Comment>
  delete(id: string): Promise<boolean>
  like(id: string, userId: string): Promise<boolean>
  unlike(id: string, userId: string): Promise<boolean>
}

Repository는 도메인 객체에 대한 컬렉션처럼 동작하는 인터페이스를 제공하여 데이터 저장소의 구체적인 구현을 추상화하는 패턴입니다. 도메인 관점에서 필요한 데이터 접근 방식을 정의하고, 구체적인 API 엔드포인트는 완전히 숨깁니다.

likeunlike를 별도 메서드로 분리한 것은 이들이 단순한 CRUD가 아닌 명확한 비즈니스 의미를 가진 작업이기 때문입니다. 이러한 접근은 도메인 로직이 인프라스트럭처 세부사항에 의존하지 않게 하고, 데이터 소스 변경 시에도 도메인 계층은 영향받지 않도록 보호합니다. 결과적으로 도메인 로직의 순수성, 테스트 용이성, 그리고 시스템의 유연성이 향상됩니다.

3-3 Clean Architecture 적용

Application Layer

// /src/features/post/services/post.service.ts
export const PostService = (
  postRepository: PostRepository, // 인터페이스에 의존
  commentRepository: CommentRepository,
  userRepository: UserRepository
): PostUseCase => ({
  updatePost: async (id: string, title: string, body: string): Promise<PostDto> => {
    try {
      const existingPost = await postRepository.getById(id)
      if (!existingPost) {
        throw BaseError.notFound('Post', id)
      }

      // 엔티티의 비즈니스 로직 활용
      existingPost.updateTitle(title)
      existingPost.updateBody(body)

      const updatedPost = await postRepository.update(existingPost)
      if (!updatedPost) {
        throw BaseError.updateFailed('Post', id)
      }

      return PostMapper.toDto(updatedPost)
    } catch (error) {
      if (error instanceof BaseError) throw error
      throw BaseError.updateFailed('Post', id)
    }
  },

  addPost: async (
    title: string,
    body: string,
    userId: string,
    image?: string
  ): Promise<PostDto> => {
    try {
      // Repository를 통한 데이터 접근
      const user = await userRepository.getUserProfile()
      if (!user) {
        throw BaseError.notFound('User', userId)
      }

      // Factory를 통한 도메인 객체 생성
      const newPost = PostFactory.createNew(
        title,
        body,
        {
          id: userId,
          username: user.username,
          profileImage: user.profileImage || '',
        },
        image
      )

      const createdPost = await postRepository.create(newPost)
      if (!createdPost) {
        throw BaseError.createFailed('Post')
      }

      return PostMapper.toDto(createdPost)
    } catch (error) {
      if (error instanceof BaseError) throw error
      throw BaseError.createFailed('Post')
    }
  },
})

UseCase는 애플리케이션이 사용자에게 제공하는 구체적인 기능을 구현하며, 여러 Repository를 조율하고 Entity의 비즈니스 로직을 활용합니다. 모든 의존성이 인터페이스를 통해 주입받기 때문에 구체적인 구현체에 의존하지 않고, 다양한 환경에서 유연성과 테스트 가능성을 동시에 확보합니다.

Infrastructure Layer

// src/entities/post/infrastructure/repository/post.api.repository.ts
export class PostApiRepository implements PostRepository {
  private api: ReturnType<typeof PostAdapter>

  constructor(apiClient: ApiClient) {
    this.api = PostAdapter(apiClient)
  }

  async getById(id: string): Promise<Post> {
    try {
      const postDto = await this.api.getById(id)
      if (!postDto) {
        throw new Error(`Post with ID ${id} not found`)
      }
      // 외부 API 응답을 도메인 모델로 변환
      return PostMapper.toDomain(postDto)
    } catch (error) {
      console.error(`Error fetching post with ID ${id}:`, error)
      throw error
    }
  }

  async create(post: Post): Promise<Post | null> {
    try {
      // 도메인 모델을 API 요청 형태로 변환
      const createDto = PostMapper.toCreateDto(post)
      const result = await this.api.create(createDto)
      if (!result) return null

      return PostMapper.toDomain(result)
    } catch (error) {
      console.error(`Error creating post:`, error)
      return null
    }
  }
}

// src/entities/post/mapper/post.mapper.ts
export class PostMapper {
  static toDomain(dto: PostDto): Post {
    return new Post({
      id: new PostId(dto.id),
      title: dto.title,
      body: dto.body,
      user: UserMapper.toDomain(dto.user),
      image: dto.image,
      likes: dto.likes,
      totalComments: dto.totalComments,
      createdAt: dto.createdAt,
      updatedAt: dto.updatedAt,
    })
  }

  static toCreateDto(post: Post): CreatePostDto {
    return {
      title: post.title,
      body: post.body,
      userId: post.user.id,
      image: post.image,
    }
  }

  static toUpdateDto(post: Post): UpdatePostDto {
    return {
      id: post.id,
      title: post.title,
      body: post.body,
      image: post.image,
    }
  }
}

Repository 구현체는 도메인이 정의한 Repository 인터페이스를 구체적으로 구현하여, 외부 데이터 소스인 외부 API 서버 와 도메인 계층을 연결합니다. Mapper는 외부 데이터 구조와 내부 도메인 모델 간의 변환 로직을 캡슐화하여, 외부 시스템의 변경이 도메인 로직에 직접적인 영향을 주지 않도록 격리 역할을 수행합니다.

API 스펙이 변경되거나 데이터 구조가 달라져도 변환 로직이 Mapper에 집중되어 있어 변경 영향도를 최소화할 수 있습니다.

레이어별 구조와 의존성 방향

위 다이어그램은 실제 예시 코드를 기반으로 FSD 구조와 Clean Architecture의 동심원 구조를 매핑한 것입니다. 각 레이어는 명확한 역할과 책임을 가지며, 의존성은 점선 화살표로 표시된 것처럼 항상 안쪽으로만 향합니다. 여기서 (I)는 Interface를, (Impl)은 Implementation을 의미합니다.

Enterprise Business Rules

  • Entities Domain Core: Post, Comment 등의 순수한 도메인 엔티티들이 위치합니다. 비즈니스 규칙과 도메인 로직을 캡슐화하며, 외부 세계에 대해 전혀 알지 못합니다.
  • Entities Repository(I): 도메인 객체 저장소의 추상화 인터페이스로, 데이터 액세스 방식을 도메인 관점에서 정의합니다.

Application Business Rules

  • Features Usecase(I): 게시글 조회, 댓글 작성 같은 애플리케이션 특정 비즈니스 유스케이스의 인터페이스를 정의합니다.
  • Features Service(Impl): 실제 비즈니스 로직을 구현하며, 여러 엔티티를 조합하여 복잡한 시나리오를 처리합니다.

Interface Adapters

  • Features Hooks: React의 custom hooks로, UI 컴포넌트와 비즈니스 로직 사이의 어댑터 역할을 합니다.
  • Entities Repository(Impl): 실제 API 통신을 담당하는 구현체로, 도메인 인터페이스를 외부 API에 맞게 구현합니다.

Frameworks & Drivers

  • App, Pages, Widgets: Next.js 기반의 UI 컴포넌트들과 페이지 라우팅을 담당합니다.
  • External API Server: 백엔드 서버이며, 실제 데이터 저장소와 비즈니스 로직을 제공합니다.

FSD, DDD, Clean Architecture를 조합하여 외부 의존성 변경이 핵심 로직에 영향을 주지 않는 아키텍처를 적용해보았습니다. 결과적으로 명확한 관심사 분리와 의존성 역전을 통해 변경에 유연한 코드베이스 구조를 만들 수 있었습니다.


4. 실제 구현하며 마주한 고민들

설계 단계에서는 고려하지 못한 것들이 실제 코드로 옮기면서 애매해지는 순간들이 있었습니다. 어떤 선택을 했는지, 왜 그렇게 판단했는지 기록해보았습니다.

모든 데이터를 도메인 모델로 변환하는 것이 항상 최선일까?

API로부터 받은 모든 데이터를 도메인 객체로 감싸는 과정이 실제 구현 단계에서 다소 이질적으로 느껴졌습니다.

특히 단순히 화면에 표시만 하는 데이터까지 도메인 객체로 변환하는 것이 꼭 필요한가, 오히려 불필요한 과정은 아닐까 하는 생각이 들었습니다.

// PostService에서 게시글 목록 처리
getAllPosts: async (limit?: number, skip?: number): Promise<PostListResult> => {
  const posts = await postRepository.getAll(limit, skip) // 도메인 객체로 변환됨
  return {
    data: PostMapper.toDtoList(posts), // 다시 DTO로 변환
    pagination: { limit: limit || 10, skip: skip || 0, total: posts.length },
  }
}

이처럼 게시글 목록처럼 단순히 화면에 보여주기만 하는 데이터까지 도메인 객체로 변환하는 것이 과연 효율적인지 의문이 들었습니다. 실제로는 이런 경우, 불필요한 오버헤드가 발생할 수 있다고 생각합니다.

그러므로, 실무에서는 데이터의 성격과 요구사항에 따라 DTO만 사용하는 것이 더 합리적일 수 있습니다

그러나 게시글 상세와 같이 updateTitle(), likePost() 등 비즈니스 메서드가 필요한 상황에서는 도메인 객체의 강점이 확실하게 드러납니다.

또한, 향후 비즈니스 로직이 추가될 때 변경 범위를 최소화할 수 있고, 테스트 코드 작성이나 타입 안정성 확보에도 큰 도움이 된다고 생각합니다.

이번 예시 코드에서는 아키텍처 실험과 패턴 정리를 목적으로, 모든 데이터에 도메인 객체를 일관되게 적용하는 방식을 선택했습니다.

Value Object 적용 기준, 어디까지가 적절할까?

Comment 엔티티에 CommentBody Value Object를 적용하면서 여러 가지 고민이 들었습니다.

// src/entities/comment/value-objects/comment-body.vo.ts
export class CommentBody extends ValueObject<string> {
  private static readonly MAX_LENGTH = 100

  protected validate(value: string): void {
    if (!value || !value.trim()) {
      throw new Error('Comment body cannot be empty')
    }
    if (value.length > CommentBody.MAX_LENGTH) {
      throw new Error(`Comment body cannot exceed ${CommentBody.MAX_LENGTH} characters`)
    }
  }
}

이번에는 아키텍처 패턴을 설명하고 비교하는 목적에서 예시 코드에 CommentBody를 Value Object로 구현하였습니다.

이렇게 구현하면 댓글에 대한 유효성 검증은 확실히 강화됩니다. 그렇지만, 이런 단순한 문자열 속성까지 굳이 Value Object로 감싸야 하는지에 대한 의문도 들었습니다.

예를 들어, Post의 title이나 body처럼 유사한 검증이 필요한 필드들, 그리고 CommentBody까지 모두 Value Object로 분리할 필요는 없다고 생각했습니다.

하지만 최근에는 주변 백엔드 개발자들과의 대화를 통해 TDD(Test-Driven Development)나 세밀한 단위 테스트 작성, 장기적인 확장성 관점에서 Value Object 도입의 장점에 대해 다시 생각하게 되었습니다.

Value Object를 활용하면 테스트 코드 작성이 쉬워지고, 도메인 규칙을 한 곳에 모아둘 수 있어 코드의 응집도와 유지보수성이 높아집니다. 또한, 추후 도메인 요구사항이 변경될 때도 수정 범위를 최소화할 수 있다는 점에 대해서 다시 생각해보는 계기가 되었습니다.

단순한 검증만 필요한 경우에는 여전히 엔티티 메서드에서 처리하는 것이 더 실용적일 수 있다고 생각합니다. 그러나 테스트 전략이나 도메인 확장 가능성을 고려한다면 Value Object 패턴을 적극적으로 도입하는 것도 충분히 고려해볼 만하다고 생각이 바뀌었습니다.

Hooks Factory 패턴, 과도한 추상화일까?

구현한 구조에서 hooks는 UI와 비즈니스 로직을 연결하는 Adapter 역할을 합니다. 그래서 Adapter 계층의 일관성과 확장성을 위해 hooks에도 Factory 패턴을 적용할지 고민했고, 예시코드에는 반영했습니다.

export const createPostHooks = (postUseCase: PostUseCase) => {
  return {
    useGetPosts: createUseGetPosts(postUseCase),
    useGetPostById: createUseGetPostById(postUseCase),
  }
}

하지만 실제 코드를 작성해보니 비즈니스 레이어 까지만 팩토리/싱글톤 패턴으로 관리하고, hooks는 각 서비스만 의존해서 직접 구현하는 방식이 복잡도를 최소화하면서도 Adapter로서 충분한 역할을 할 수 있다는 생각이 들었습니다.

결국, hooks까지 일관된 추상화를 강제하기보다는, 실용성과 유지보수성을 우선해 필요한 부분에만 적절히 적용하는 것이 더 효과적이라고 느꼈습니다.

Repository 의존성 주입 vs 직접 import

Service에서 Repository를 주입받는 방식과, 파일에서 직접 import하여 사용하는 방식 모두 각각의 장단점이 있습니다.

// 의존성 주입 방식
export const PostService = (
  postRepository: PostRepository,
  commentRepository: CommentRepository,
  userRepository: UserRepository
): PostUseCase => ({ ... });

// 직접 import 방식
import { postRepository } from '@/entities/post';
const PostService = (): PostUseCase => ({ ... });

프론트엔드 개발에서는 이러한 구조가 과도하게 복잡하게 느껴질 수 있습니다. 하지만 이 예시에서는 특정 라이브러리에 종속되지 않은 순수한 아키텍처 패턴을 구현하는 데 초점을 맞췄습니다. 의존성 주입을 사용하면 Repository 구현체를 쉽게 교체할 수 있으며, 테스트나 다양한 환경에서도 유연하게 대응할 수 있습니다.

기술적으로는 두 방식 모두 런타임 동작이나 성능에서 큰 차이는 없습니다. 다만, 의존성 주입 방식은 다양한 환경별 구현체 교체가 용이하다는 점에서 유지보수성과 확장성 측면에서 장점이 있습니다.

실무에서는 프로젝트의 복잡도, 팀의 개발 경험, 그리고 장기적인 유지보수 전략에 따라 적합한 방식을 선택하는 것이 중요하다고 생각합니다.

프론트엔드에서의 Aggregate 패턴

Post와 Comment의 관계를 설계할 때, DDD에서 권장하는 Aggregate 패턴 도입에 대해 고민했습니다.

// 개별 Entity로 관리
const post = await postRepository.getById(id)
const comments = await commentRepository.getByPostId(id)

// Aggregate 클래스를 별도로 두는 방식

class PostAggregate {
  constructor(
    private post: Post,
    private comments: Comment[]
  ) {}
  addComment(comment: Comment): void {
    this.comments.push(comment)
    this.post.incrementCommentCount()
  }
}

// 엔티티 내부에 배열을 직접 포함하는 방식
export class Post {
  private _id: string
  private _user: UserReference
  private _title: string
  private _body: string
  private _comments: Comment[]
  // ...

  constructor(
    id: string,
    user: UserReference,
    title: string,
    body: string,
    comments: Comment[] = []
    // ...
  ) {
    this._id = id
    this._user = user
    this._title = title
    this._body = body
    this._comments = comments
    // ...
  }

  get comments(): Comment[] {
    return this._comments
  }

  addComment(comment: Comment): void {
    this._comments.push(comment)
    // 필요하다면 totalComments 등 다른 필드도 함께 갱신
  }

  // ...
}

DDD 관점에서는 Post와 Comment를 하나의 Aggregate로 묶어 일관성을 직접 관리하는 방식이 권장될 수 있습니다.

그러나 실제로는 대부분의 일관성 처리가 서버에서 이루어지기 때문에, 프론트엔드에서 데이터 일관성을 직접 보장할 필요성은 크지 않다고 생각합니다.

따라서 프론트엔드에서는 여러 Repository를 조합하는 수준의 UseCase만으로도 충분하며, 복잡한 일관성 규칙이나 트랜잭션 관리는 서버에 위임하는 것이 현실적으로 더 적합한 선택이라고 생각합니다.

이러한 이유로 예시 코드에서는 Post와 Comment를 각각 독립적인 Entity로 관리하고, Aggregate 패턴은 서버에서 적용하는 것이 더 합리적이라는 생각이 들었습니다.

5. Conclusion

솔직히, 이 예시 코드는 전형적인 오버엔지니어링의 사례입니다.

단순한 사용자 정보 표시나 포스트 목록 조회 같은 기능에 Repository 패턴, Factory, Mapper, Value Object를 모두 적용한 것은 명백히 과도한 복잡성입니다.

그럼에도 불구하고 이런 계기를 통해 얻은 것은 사고방식의 확장이었습니다.

완벽한 구현이 아니더라도 DDD와 Clean Architecture의 관점에서 프론트엔드 코드를 바라보는 시각을 기를 수 있었고, 이런 극단적인 예시를 통해서 각 패턴의 역할과 가치를 명확히 이해할 수 있었습니다. 무엇보다 "언제 이런 복잡성이 정당화될 수 있는가?"에 대해 진지하게 고민해볼 기회를 얻었습니다.

결국 핵심은 "복잡성이 문제를 해결하는가?"라는 질문이라고 생각합니다. 단순한 CRUD나 데이터 표시 기능에는 기존의 간단한 방식이 더 적합할 수 있습니다. 하지만 복잡한 비즈니스 규칙이 있고, 변경이 빈번하며, 여러 팀원이 협업하는 기능에서는 이런 구조적 접근이 장기적으로 더 큰 가치를 제공할 수 있습니다.

아키텍처 패턴들의 진짜 가치는 "완벽한 구현"에 있는 것이 아니라, "더 나은 코드를 작성하기 위한 사고의 틀"을 제공하는 데 있다는 것을 이번 기회에 깨닫게 되었습니다.

호기심에서 시작한 오버엔지니어링이었지만, 이제는 "어디까지가 필요하고 어디부터가 과도한가?"에 대한 감각을 기를 수 있었습니다.

이런 경험과 고민들이 비슷한 길을 걷고 있는 개발자들에게 "언제 복잡성을 도입할지"에 대한 판단 기준을 제공하는 작은 도움이 되었으면 좋겠습니다!

Reference