🏗️ Clean Architecture 완벽 가이드: 실무에 바로 적용하는 클린 아키텍처
10년 후에도 유지보수가 쉬운 코드를 작성하고 싶으신가요? 비즈니스 로직이 프레임워크에 종속되지 않는 독립적인 시스템을 만들고 싶으신가요? Robert C. Martin(Uncle Bob)이 제안한 Clean Architecture는 이러한 고민에 대한 해답을 제시합니다. 오늘은 Clean Architecture의 원칙부터 실제 구현까지, 바로 프로젝트에 적용할 수 있는 완벽한 가이드를 제공해드리겠습니다.
📐 Clean Architecture란 무엇인가?
의존성 규칙을 통한 관심사의 분리
Clean Architecture는 소프트웨어를 계층으로 나누고, 각 계층이 안쪽 계층에만 의존하도록 하는 아키텍처 패턴입니다. 가장 중요한 비즈니스 로직을 중심에 두고, 데이터베이스, 웹 프레임워크, UI 같은 세부사항을 바깥쪽에 배치합니다. 이를 통해 비즈니스 규칙이 외부 요소의 변경에 영향받지 않는 견고한 시스템을 만들 수 있습니다.
Clean Architecture의 핵심은 의존성 역전 원칙(DIP)입니다. 소스 코드의 의존성은 반드시 안쪽으로, 즉 고수준 정책을 향해야 합니다. 바깥쪽 원의 어떤 것도 안쪽 원에 영향을 주어서는 안 됩니다. 이것이 Clean Architecture의 가장 중요한 규칙입니다.
🎯 4개의 동심원 - 레이어 구조
(Web, DB, UI)
(Controllers, Presenters)
(Application Business)
(Enterprise Business)
1️⃣ Entities (엔티티)
가장 핵심적인 비즈니스 규칙을 캡슐화합니다. 기업의 핵심 비즈니스 규칙과 데이터를 포함하며, 외부 변경사항에 가장 영향을 받지 않는 계층입니다.
2️⃣ Use Cases (유스케이스)
애플리케이션의 비즈니스 규칙을 포함합니다. 시스템의 모든 유스케이스를 구현하며, 엔티티로의 데이터 흐름을 조정합니다.
3️⃣ Interface Adapters (인터페이스 어댑터)
유스케이스와 엔티티에 가장 편리한 형식에서 데이터베이스나 웹 같은 외부 에이전시에 가장 편리한 형식으로 데이터를 변환합니다.
4️⃣ Frameworks & Drivers (프레임워크와 드라이버)
가장 바깥쪽 계층으로 데이터베이스, 웹 프레임워크 등 세부사항을 포함합니다. 이 계층은 안쪽으로 향하는 인터페이스를 구현합니다.
💻 실제 구현 예제 - 사용자 관리 시스템
이제 실제로 Clean Architecture를 적용한 사용자 관리 시스템을 구현해보겠습니다. TypeScript로 작성하여 프론트엔드와 백엔드 모두에서 활용할 수 있도록 했습니다.
1. Domain Layer - 엔티티 정의
// src/domain/entities/User.ts
export class User {
private constructor(
private readonly id: string,
private name: string,
private email: string,
private password: string,
private createdAt: Date,
private updatedAt: Date
) {}
// 팩토리 메서드 - 비즈니스 규칙 적용
static create(props: {
name: string;
email: string;
password: string;
}): User {
// 비즈니스 규칙 검증
if (!props.name || props.name.length < 2) {
throw new Error('Name must be at least 2 characters');
}
if (!this.isValidEmail(props.email)) {
throw new Error('Invalid email format');
}
if (props.password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
const id = this.generateId();
const now = new Date();
return new User(
id,
props.name,
props.email,
this.hashPassword(props.password),
now,
now
);
}
// 비즈니스 메서드
changeEmail(newEmail: string): void {
if (!User.isValidEmail(newEmail)) {
throw new Error('Invalid email format');
}
this.email = newEmail;
this.updatedAt = new Date();
}
verifyPassword(password: string): boolean {
return User.hashPassword(password) === this.password;
}
// Helper methods
private static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
private static hashPassword(password: string): string {
// 실제로는 bcrypt 등을 사용
return `hashed_${password}`;
}
private static generateId(): string {
return Date.now().toString(36) + Math.random().toString(36);
}
// Getters
getId(): string { return this.id; }
getName(): string { return this.name; }
getEmail(): string { return this.email; }
}
2. Domain Layer - Repository Interface
// src/domain/repositories/UserRepository.ts
import { User } from '../entities/User';
export interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
update(user: User): Promise<void>;
delete(id: string): Promise<void>;
findAll(): Promise<User[]>;
}
3. Application Layer - Use Cases
// src/application/use-cases/CreateUser.ts
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/repositories/UserRepository';
export interface CreateUserDTO {
name: string;
email: string;
password: string;
}
export class CreateUserUseCase {
constructor(
private readonly userRepository: UserRepository
) {}
async execute(dto: CreateUserDTO): Promise<{ id: string }> {
// 비즈니스 규칙: 이메일 중복 체크
const existingUser = await this.userRepository.findByEmail(dto.email);
if (existingUser) {
throw new Error('Email already exists');
}
// 엔티티 생성 (도메인 규칙 적용)
const user = User.create({
name: dto.name,
email: dto.email,
password: dto.password
});
// 저장
await this.userRepository.save(user);
return {
id: user.getId()
};
}
}
// src/application/use-cases/GetUser.ts
export class GetUserUseCase {
constructor(
private readonly userRepository: UserRepository
) {}
async execute(userId: string): Promise<UserDTO | null> {
const user = await this.userRepository.findById(userId);
if (!user) {
return null;
}
// DTO로 변환 (민감한 정보 제외)
return {
id: user.getId(),
name: user.getName(),
email: user.getEmail()
};
}
}
interface UserDTO {
id: string;
name: string;
email: string;
}
4. Infrastructure Layer - Repository 구현
// src/infrastructure/repositories/InMemoryUserRepository.ts
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/repositories/UserRepository';
export class InMemoryUserRepository implements UserRepository {
private users: Map<string, User> = new Map();
async save(user: User): Promise<void> {
this.users.set(user.getId(), user);
}
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async findByEmail(email: string): Promise<User | null> {
for (const user of this.users.values()) {
if (user.getEmail() === email) {
return user;
}
}
return null;
}
async update(user: User): Promise<void> {
this.users.set(user.getId(), user);
}
async delete(id: string): Promise<void> {
this.users.delete(id);
}
async findAll(): Promise<User[]> {
return Array.from(this.users.values());
}
}
// src/infrastructure/repositories/PostgresUserRepository.ts
import { Pool } from 'pg';
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/repositories/UserRepository';
export class PostgresUserRepository implements UserRepository {
constructor(
private readonly pool: Pool
) {}
async save(user: User): Promise<void> {
const query = `
INSERT INTO users (id, name, email, password, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
`;
await this.pool.query(query, [
user.getId(),
user.getName(),
user.getEmail(),
user.getPassword(),
user.getCreatedAt(),
user.getUpdatedAt()
]);
}
async findById(id: string): Promise<User | null> {
const query = 'SELECT * FROM users WHERE id = $1';
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return this.mapToEntity(result.rows[0]);
}
// 추가 메서드들...
private mapToEntity(row: any): User {
// DB 데이터를 도메인 엔티티로 매핑
return User.restore({
id: row.id,
name: row.name,
email: row.email,
password: row.password,
createdAt: row.created_at,
updatedAt: row.updated_at
});
}
}
5. Presentation Layer - Controller
// src/infrastructure/web/UserController.ts
import { Request, Response } from 'express';
import { CreateUserUseCase } from '../../application/use-cases/CreateUser';
import { GetUserUseCase } from '../../application/use-cases/GetUser';
export class UserController {
constructor(
private readonly createUserUseCase: CreateUserUseCase,
private readonly getUserUseCase: GetUserUseCase
) {}
async createUser(req: Request, res: Response): Promise<void> {
try {
const { name, email, password } = req.body;
const result = await this.createUserUseCase.execute({
name,
email,
password
});
res.status(201).json({
success: true,
data: result
});
} catch (error) {
res.status(400).json({
success: false,
error: error.message
});
}
}
async getUser(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const user = await this.getUserUseCase.execute(id);
if (!user) {
res.status(404).json({
success: false,
error: 'User not found'
});
return;
}
res.json({
success: true,
data: user
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
}
}
6. Dependency Injection Container
// src/infrastructure/container.ts
import { InMemoryUserRepository } from './repositories/InMemoryUserRepository';
import { CreateUserUseCase } from '../application/use-cases/CreateUser';
import { GetUserUseCase } from '../application/use-cases/GetUser';
import { UserController } from './web/UserController';
export class Container {
private static instance: Container;
private userRepository: InMemoryUserRepository;
private createUserUseCase: CreateUserUseCase;
private getUserUseCase: GetUserUseCase;
private userController: UserController;
private constructor() {
// Repository
this.userRepository = new InMemoryUserRepository();
// Use Cases
this.createUserUseCase = new CreateUserUseCase(this.userRepository);
this.getUserUseCase = new GetUserUseCase(this.userRepository);
// Controllers
this.userController = new UserController(
this.createUserUseCase,
this.getUserUseCase
);
}
static getInstance(): Container {
if (!Container.instance) {
Container.instance = new Container();
}
return Container.instance;
}
getUserController(): UserController {
return this.userController;
}
}
7. Express 서버 설정
// src/main.ts
import express from 'express';
import { Container } from './infrastructure/container';
const app = express();
app.use(express.json());
// 의존성 주입 컨테이너
const container = Container.getInstance();
const userController = container.getUserController();
// Routes
app.post('/users', (req, res) => userController.createUser(req, res));
app.get('/users/:id', (req, res) => userController.getUser(req, res));
app.listen(3000, () => {
console.log('Server running on port 3000');
});
🧪 테스트 전략
// src/application/use-cases/__tests__/CreateUser.test.ts
import { CreateUserUseCase } from '../CreateUser';
import { InMemoryUserRepository } from '../../../infrastructure/repositories/InMemoryUserRepository';
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let repository: InMemoryUserRepository;
beforeEach(() => {
repository = new InMemoryUserRepository();
useCase = new CreateUserUseCase(repository);
});
it('should create a new user successfully', async () => {
const dto = {
name: 'John Doe',
email: 'john@example.com',
password: 'securePassword123'
};
const result = await useCase.execute(dto);
expect(result.id).toBeDefined();
const savedUser = await repository.findById(result.id);
expect(savedUser?.getName()).toBe('John Doe');
});
it('should throw error when email already exists', async () => {
const dto = {
name: 'John Doe',
email: 'john@example.com',
password: 'securePassword123'
};
await useCase.execute(dto);
await expect(useCase.execute(dto))
.rejects
.toThrow('Email already exists');
});
});
✅ Clean Architecture의 장점
🔄 독립성
비즈니스 로직이 프레임워크, 데이터베이스, UI에 독립적입니다. Express를 Fastify로, PostgreSQL을 MongoDB로 쉽게 교체할 수 있습니다.
🧪 테스트 용이성
비즈니스 규칙을 UI, 데이터베이스, 외부 요소 없이도 테스트할 수 있습니다. 유닛 테스트가 매우 간단해집니다.
🔧 유지보수성
각 계층이 명확히 분리되어 있어 변경사항이 다른 계층에 영향을 미치지 않습니다. 새로운 기능 추가가 용이합니다.
📚 이해하기 쉬운 구조
코드가 비즈니스를 그대로 반영합니다. 새로운 팀원도 빠르게 프로젝트를 이해하고 기여할 수 있습니다.
⚖️ Clean Architecture vs 다른 아키텍처
| 특징 | Clean Architecture | MVC | Layered Architecture |
|---|---|---|---|
| 복잡도 | 높음 | 낮음 | 중간 |
| 초기 개발 속도 | 느림 | 빠름 | 중간 |
| 장기 유지보수 | 매우 용이 | 어려움 | 보통 |
| 테스트 용이성 | 매우 높음 | 낮음 | 중간 |
| 확장성 | 매우 높음 | 낮음 | 중간 |
| 적합한 프로젝트 | 대규모/장기 | 소규모/단기 | 중규모 |
Clean Architecture 도입 시 고려사항:
- ✓ 작은 프로젝트에는 오버엔지니어링일 수 있습니다
- ✓ 팀원 모두가 아키텍처를 이해해야 합니다
- ✓ 초기 개발 시간이 더 걸립니다
- ✓ 하지만 6개월 이상의 프로젝트라면 투자 가치가 충분합니다
🎯 Clean Architecture, 언제 어떻게 적용할까?
Clean Architecture는 복잡한 비즈니스 로직을 가진 애플리케이션을 장기간 유지보수해야 할 때 빛을 발합니다. 초기 투자 비용은 높지만, 시간이 지날수록 그 가치가 증명됩니다. 특히 요구사항이 자주 변경되거나, 여러 플랫폼을 지원해야 하거나, 팀이 커질 것으로 예상되는 프로젝트에 적합합니다.
오늘 제공한 예제 코드를 바탕으로 작은 프로젝트부터 시작해보세요. 처음에는 복잡해 보일 수 있지만, 한 번 익숙해지면 더 이상 다른 방식으로는 코드를 작성하기 어려워질 것입니다. Clean Architecture는 단순한 아키텍처 패턴이 아니라, 좋은 소프트웨어를 만드는 사고방식입니다. 여러분의 다음 프로젝트에 Clean Architecture를 적용해보시고, 그 차이를 직접 경험해보시기 바랍니다! 🚀