🏗️ 아키텍처

Clean Architecture

Clean Architecture

Clean Architecture는 Robert C. Martin(Uncle Bob)이 제안한 소프트웨어 아키텍처 원칙으로, 의존성 방향을 항상 안쪽(비즈니스 로직)으로 향하게 하여 프레임워크나 데이터베이스로부터 독립적인 코드를 작성할 수 있게 합니다.

📖 상세 설명

Clean Architecture는 2012년 Robert C. Martin이 발표한 아키텍처 패턴으로, Hexagonal Architecture, Onion Architecture 등 기존 아키텍처의 핵심 원칙을 통합한 것입니다. 핵심 아이디어는 "의존성은 항상 안쪽으로"라는 의존성 규칙(Dependency Rule)입니다.

Frameworks & Drivers (UI, DB, Web)
↓
Interface Adapters (Controllers, Gateways)
↓
Use Cases (Application Business Rules)
↓
Entities (Enterprise Business Rules)

4개의 동심원 레이어

의존성 규칙 (Dependency Rule)

소스 코드 의존성은 안쪽으로만 향해야 합니다. 안쪽 원은 바깥쪽 원에 대해 아무것도 몰라야 합니다. 이를 통해:

💻 코드 예제

프로젝트 구조

src/
├── domain/                    # Entities (핵심 비즈니스 규칙)
│   ├── entities/
│   │   ├── User.ts
│   │   └── Order.ts
│   └── repositories/          # Repository 인터페이스
│       ├── IUserRepository.ts
│       └── IOrderRepository.ts
├── application/               # Use Cases (애플리케이션 규칙)
│   ├── usecases/
│   │   ├── CreateUserUseCase.ts
│   │   └── PlaceOrderUseCase.ts
│   └── dto/
│       ├── CreateUserDTO.ts
│       └── OrderDTO.ts
├── infrastructure/            # Frameworks & Drivers
│   ├── database/
│   │   ├── UserRepository.ts  # Repository 구현
│   │   └── OrderRepository.ts
│   └── web/
│       ├── express/
│       └── controllers/
└── interfaces/                # Interface Adapters
    ├── controllers/
    │   ├── UserController.ts
    │   └── OrderController.ts
    └── presenters/
        └── UserPresenter.ts

Entity 레이어

// domain/entities/User.ts
export class User {
  private constructor(
    public readonly id: string,
    public readonly email: string,
    private _name: string,
    private _isActive: boolean = true
  ) {
    this.validateEmail(email);
  }

  static create(props: { email: string; name: string }): User {
    return new User(
      crypto.randomUUID(),
      props.email,
      props.name
    );
  }

  static reconstitute(props: {
    id: string; email: string; name: string; isActive: boolean;
  }): User {
    return new User(props.id, props.email, props.name, props.isActive);
  }

  get name(): string { return this._name; }
  get isActive(): boolean { return this._isActive; }

  changeName(newName: string): void {
    if (!newName || newName.length < 2) {
      throw new Error('Name must be at least 2 characters');
    }
    this._name = newName;
  }

  deactivate(): void {
    this._isActive = false;
  }

  private validateEmail(email: string): void {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error('Invalid email format');
    }
  }
}

Repository 인터페이스 (Domain Layer)

// domain/repositories/IUserRepository.ts
import { User } from '../entities/User';

export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}

// 의존성 규칙: 도메인은 인터페이스만 정의
// 실제 구현은 infrastructure에서 담당

Use Case 레이어

// application/usecases/CreateUserUseCase.ts
import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository';

export interface CreateUserDTO {
  email: string;
  name: string;
}

export interface CreateUserOutputDTO {
  id: string;
  email: string;
  name: string;
}

export class CreateUserUseCase {
  // 의존성 주입: 인터페이스에 의존
  constructor(private userRepository: IUserRepository) {}

  async execute(input: CreateUserDTO): Promise<CreateUserOutputDTO> {
    // 비즈니스 규칙: 이메일 중복 검사
    const existingUser = await this.userRepository.findByEmail(input.email);
    if (existingUser) {
      throw new Error('Email already exists');
    }

    // 엔티티 생성 (도메인 규칙 적용)
    const user = User.create({
      email: input.email,
      name: input.name,
    });

    // 영속화
    await this.userRepository.save(user);

    // Output DTO 반환
    return {
      id: user.id,
      email: user.email,
      name: user.name,
    };
  }
}

Repository 구현 (Infrastructure Layer)

// infrastructure/database/UserRepository.ts
import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository';
import { PrismaClient } from '@prisma/client';

export class PrismaUserRepository implements IUserRepository {
  constructor(private prisma: PrismaClient) {}

  async findById(id: string): Promise<User | null> {
    const data = await this.prisma.user.findUnique({ where: { id } });
    if (!data) return null;

    return User.reconstitute({
      id: data.id,
      email: data.email,
      name: data.name,
      isActive: data.isActive,
    });
  }

  async findByEmail(email: string): Promise<User | null> {
    const data = await this.prisma.user.findUnique({ where: { email } });
    if (!data) return null;

    return User.reconstitute({
      id: data.id,
      email: data.email,
      name: data.name,
      isActive: data.isActive,
    });
  }

  async save(user: User): Promise<void> {
    await this.prisma.user.upsert({
      where: { id: user.id },
      update: { name: user.name, isActive: user.isActive },
      create: {
        id: user.id,
        email: user.email,
        name: user.name,
        isActive: user.isActive,
      },
    });
  }

  async delete(id: string): Promise<void> {
    await this.prisma.user.delete({ where: { id } });
  }
}

Controller (Interface Adapter)

// interfaces/controllers/UserController.ts
import { Request, Response } from 'express';
import { CreateUserUseCase } from '../../application/usecases/CreateUserUseCase';

export class UserController {
  constructor(private createUserUseCase: CreateUserUseCase) {}

  async createUser(req: Request, res: Response): Promise<void> {
    try {
      const { email, name } = req.body;

      const result = await this.createUserUseCase.execute({ email, name });

      res.status(201).json({
        success: true,
        data: result,
      });
    } catch (error) {
      if (error instanceof Error) {
        res.status(400).json({
          success: false,
          error: error.message,
        });
      }
    }
  }
}

의존성 주입 설정

// main.ts (Composition Root)
import { PrismaClient } from '@prisma/client';
import { PrismaUserRepository } from './infrastructure/database/UserRepository';
import { CreateUserUseCase } from './application/usecases/CreateUserUseCase';
import { UserController } from './interfaces/controllers/UserController';

// 의존성 조립 (바깥에서 안쪽으로 주입)
const prisma = new PrismaClient();
const userRepository = new PrismaUserRepository(prisma);
const createUserUseCase = new CreateUserUseCase(userRepository);
const userController = new UserController(createUserUseCase);

// Express 라우터 설정
app.post('/users', (req, res) => userController.createUser(req, res));

💬 현업 대화 예시

👨‍💼 PM

"우리 서비스 데이터베이스를 MySQL에서 PostgreSQL로 바꿔야 하는데, 얼마나 걸릴까요?"

👩‍💻 백엔드 개발자

"Clean Architecture로 설계해서 Repository 구현체만 바꾸면 됩니다. 비즈니스 로직은 건드릴 필요 없어요. 인터페이스가 동일하니까 새 PostgresUserRepository만 만들고 DI 설정만 변경하면 됩니다."

👨‍💻 주니어 개발자

"Use Case에서 직접 Prisma를 호출하면 안 되나요? 코드가 더 간단해질 것 같은데..."

👩‍💻 시니어 개발자

"의존성 규칙 위반이에요. Use Case가 Prisma에 직접 의존하면 DB 변경 시 비즈니스 로직까지 수정해야 해요. Repository 인터페이스를 통해 추상화하면 테스트 시 Mock으로 대체도 쉽고, ORM 변경도 자유로워져요."

👨‍💼 아키텍트

"이번 프로젝트는 도메인이 복잡하니까 Clean Architecture로 가져가죠. Entity와 Use Case를 분리해서 비즈니스 규칙을 명확하게 정의하고, 나중에 프레임워크 교체가 필요해도 핵심 로직은 보호할 수 있어요."

⚠️ 주의사항

과도한 추상화 경계: 작은 프로젝트에 Clean Architecture를 적용하면 오버엔지니어링이 됩니다. CRUD 위주의 간단한 서비스에는 오히려 생산성을 떨어뜨릴 수 있습니다.

레이어 간 데이터 변환 비용: Entity → DTO → Response 등 여러 번의 매핑이 필요합니다. 이 과정이 번거롭고 보일러플레이트 코드가 늘어날 수 있습니다.

의존성 규칙 위반 주의: 안쪽 레이어(Entity, Use Case)에서 바깥쪽(Infrastructure)을 import하면 의존성 규칙 위반입니다. 항상 인터페이스를 통해 추상화해야 합니다.

팁: 프로젝트 복잡도에 맞게 레이어를 조정하세요. 모든 프로젝트에 4개 레이어가 필요한 건 아닙니다. 핵심은 "의존성 방향"을 지키는 것입니다.

🔗 관련 용어

📚 더 배우기