🏗️ 아키텍처

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개 레이어가 필요한 건 아닙니다. 핵심은 "의존성 방향"을 지키는 것입니다.

🔗 관련 용어

📚 더 배우기