Clean Architecture
Clean Architecture
Clean Architecture는 Robert C. Martin(Uncle Bob)이 제안한 소프트웨어 아키텍처 원칙으로, 의존성 방향을 항상 안쪽(비즈니스 로직)으로 향하게 하여 프레임워크나 데이터베이스로부터 독립적인 코드를 작성할 수 있게 합니다.
Clean Architecture
Clean Architecture는 Robert C. Martin(Uncle Bob)이 제안한 소프트웨어 아키텍처 원칙으로, 의존성 방향을 항상 안쪽(비즈니스 로직)으로 향하게 하여 프레임워크나 데이터베이스로부터 독립적인 코드를 작성할 수 있게 합니다.
Clean Architecture는 2012년 Robert C. Martin이 발표한 아키텍처 패턴으로, Hexagonal Architecture, Onion Architecture 등 기존 아키텍처의 핵심 원칙을 통합한 것입니다. 핵심 아이디어는 "의존성은 항상 안쪽으로"라는 의존성 규칙(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
// 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');
}
}
}
// 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에서 담당
// 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,
};
}
}
// 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 } });
}
}
// 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));
"우리 서비스 데이터베이스를 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개 레이어가 필요한 건 아닙니다. 핵심은 "의존성 방향"을 지키는 것입니다.