- Published on
클린 아키텍처 완전 가이드: SOLID부터 헥사고날, DDD, 레이어드까지 — 시니어로 가는 설계 원칙
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 1. 왜 아키텍처가 중요한가
- 2. SOLID 원칙 심화
- 3. 레이어드 아키텍처
- 4. 클린 아키텍처 (Uncle Bob)
- 5. 헥사고날 아키텍처 (포트 & 어댑터)
- 6. DDD (Domain-Driven Design)
- 7. 아키텍처 비교 총정리
- 8. 실전 프로젝트: 이커머스 주문 시스템
- 9. 테스트 전략
- 10. 안티패턴과 주의점
- 퀴즈
- 참고 자료
들어가며
소프트웨어 개발에서 가장 비용이 큰 것은 무엇일까요? 초기 개발이 아닙니다. 유지보수입니다. 연구에 따르면 소프트웨어 생명주기 비용의 60-80%가 유지보수에 소요됩니다. 그리고 유지보수 비용을 결정하는 가장 큰 요인이 바로 아키텍처입니다.
Robert C. Martin(Uncle Bob)은 이렇게 말했습니다. "좋은 아키텍처는 시스템을 이해하고, 개발하고, 유지보수하고, 배포하기 쉽게 만든다." 이것은 단순한 격언이 아니라, 수십 년간 소프트웨어 프로젝트의 성공과 실패를 관통하는 핵심 원칙입니다.
시니어 개발자 면접에서 "클린 아키텍처와 헥사고날 아키텍처의 차이점을 설명해 주세요", "DDD의 Bounded Context란 무엇인가요?"와 같은 질문이 빠지지 않는 이유가 여기에 있습니다. 아키텍처를 이해하는 것은 코드를 작성하는 기술을 넘어, 시스템을 설계하는 역량을 갖추는 것입니다.
이 글에서는 SOLID 원칙부터 레이어드, 클린, 헥사고날, DDD까지 — 현대 소프트웨어 설계의 핵심 패턴들을 실전 코드와 함께 심층 분석합니다. 각 패턴의 철학, 구현 방법, 장단점, 그리고 언제 무엇을 선택해야 하는지까지 다룹니다.
1. 왜 아키텍처가 중요한가
1.1 기술 부채의 실체
기술 부채(Technical Debt)는 시간이 지날수록 복리로 불어납니다. 초기에 아키텍처 없이 빠르게 개발한 프로젝트는 다음과 같은 비용 곡선을 그립니다.
비용
^
| / 아키텍처 없음
| /
| /
| /
| / ___--- 좋은 아키텍처
| / ---
| / --
| /--
+-------------------> 시간
Stripe의 2018년 연구에 따르면 개발자들은 기술 부채 처리에 업무 시간의 약 33%를 소비합니다. 이는 연간 수천억 원 규모의 생산성 손실에 해당합니다.
1.2 좋은 아키텍처의 4가지 특성
Uncle Bob이 정의한 좋은 아키텍처의 특성은 다음과 같습니다.
- 이해하기 쉬움 (Easy to understand) — 새로운 팀원이 빠르게 시스템을 파악
- 개발하기 쉬움 (Easy to develop) — 기능 추가 시 영향 범위가 제한적
- 유지보수하기 쉬움 (Easy to maintain) — 버그 수정과 리팩토링이 안전
- 배포하기 쉬움 (Easy to deploy) — 독립적 배포와 롤백 가능
1.3 시니어 면접에서의 아키텍처
시니어 개발자 면접에서 아키텍처 질문은 필수입니다. 자주 나오는 주제들입니다.
- SOLID 원칙 각각을 실제 프로젝트에 어떻게 적용했는지
- 레이어드 vs 클린 vs 헥사고날의 차이와 선택 기준
- DDD를 적용한 경험과 Bounded Context 설계
- 아키텍처 결정 기록(ADR) 작성 경험
- 기술 부채를 어떻게 관리하고 해소했는지
이러한 질문들에 대해 단순한 이론이 아닌 실전 경험 기반의 깊이 있는 답변을 할 수 있어야 합니다.
2. SOLID 원칙 심화
SOLID는 Robert C. Martin이 정리한 객체지향 설계의 5가지 핵심 원칙입니다. 이 원칙들은 모든 아키텍처 패턴의 기초가 됩니다.
2.1 S — Single Responsibility Principle (단일 책임 원칙)
클래스는 변경의 이유가 하나여야 한다.
핵심은 "하나의 일만 한다"가 아니라 **"변경의 이유가 하나"**라는 것입니다.
BAD: 여러 변경 이유를 가진 클래스
// BAD: User 클래스가 비즈니스 로직, 영속성, 알림을 모두 담당
class UserService {
createUser(name: string, email: string) {
// 검증 로직
if (!email.includes('@')) throw new Error('Invalid email')
// DB 저장 (영속성 관심사)
const query = `INSERT INTO users (name, email) VALUES ('${name}', '${email}')`
database.execute(query)
// 이메일 발송 (알림 관심사)
const mailClient = new SmtpClient()
mailClient.send(email, 'Welcome!', 'Thanks for joining')
// 로깅 (인프라 관심사)
console.log(`User created: ${name}`)
}
}
GOOD: 책임을 분리한 구조
// GOOD: 각 클래스가 하나의 변경 이유만 가짐
class UserValidator {
validate(name: string, email: string): void {
if (!email.includes('@')) throw new Error('Invalid email')
if (name.length < 2) throw new Error('Name too short')
}
}
class UserRepository {
save(user: User): Promise<User> {
return this.db.users.create({ data: user })
}
}
class WelcomeEmailSender {
send(email: string): Promise<void> {
return this.mailService.send({
to: email,
subject: 'Welcome!',
body: 'Thanks for joining',
})
}
}
class CreateUserUseCase {
constructor(
private validator: UserValidator,
private repository: UserRepository,
private emailSender: WelcomeEmailSender
) {}
async execute(name: string, email: string): Promise<User> {
this.validator.validate(name, email)
const user = await this.repository.save({ name, email })
await this.emailSender.send(email)
return user
}
}
2.2 O — Open/Closed Principle (개방-폐쇄 원칙)
확장에는 열려 있고, 수정에는 닫혀 있어야 한다.
BAD: 새 결제 수단 추가 시 기존 코드 수정 필요
// BAD: 새 결제 수단마다 if문 추가 필요
class PaymentProcessor {
process(payment: Payment) {
if (payment.type === 'credit_card') {
// 카드 결제 처리
this.processCreditCard(payment)
} else if (payment.type === 'paypal') {
// 페이팔 결제 처리
this.processPayPal(payment)
} else if (payment.type === 'bitcoin') {
// 새 결제 수단 추가할 때마다 기존 코드 수정!
this.processBitcoin(payment)
}
}
}
GOOD: 전략 패턴으로 확장에 열림
// GOOD: 새 결제 수단 추가 시 기존 코드 수정 불필요
interface PaymentStrategy {
process(payment: Payment): Promise<PaymentResult>
}
class CreditCardStrategy implements PaymentStrategy {
async process(payment: Payment): Promise<PaymentResult> {
// 카드 결제 처리
return { success: true, transactionId: 'cc-123' }
}
}
class PayPalStrategy implements PaymentStrategy {
async process(payment: Payment): Promise<PaymentResult> {
// 페이팔 결제 처리
return { success: true, transactionId: 'pp-456' }
}
}
// 새 결제 수단은 새 클래스만 추가하면 됨
class KakaoPayStrategy implements PaymentStrategy {
async process(payment: Payment): Promise<PaymentResult> {
return { success: true, transactionId: 'kp-789' }
}
}
class PaymentProcessor {
private strategies: Map<string, PaymentStrategy>
constructor(strategies: Map<string, PaymentStrategy>) {
this.strategies = strategies
}
async process(payment: Payment): Promise<PaymentResult> {
const strategy = this.strategies.get(payment.type)
if (!strategy) throw new Error(`Unknown payment type: ${payment.type}`)
return strategy.process(payment)
}
}
2.3 L — Liskov Substitution Principle (리스코프 치환 원칙)
하위 타입은 상위 타입을 대체할 수 있어야 한다.
BAD: 정사각형-직사각형 문제
// BAD: Square가 Rectangle의 불변 조건을 위반
class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int getArea() { return width * height; }
}
class Square extends Rectangle {
// LSP 위반: setWidth가 height도 변경
@Override
public void setWidth(int w) {
this.width = w;
this.height = w; // 부모와 다른 동작!
}
@Override
public void setHeight(int h) {
this.width = h;
this.height = h;
}
}
// 클라이언트 코드가 깨짐
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
assert r.getArea() == 50; // Square이면 실패! (100)
}
GOOD: 공통 인터페이스로 해결
// GOOD: 공통 인터페이스로 설계
interface Shape {
int getArea();
}
class Rectangle implements Shape {
private final int width;
private final int height;
Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int getArea() { return width * height; }
}
class Square implements Shape {
private final int side;
Square(int side) { this.side = side; }
public int getArea() { return side * side; }
}
2.4 I — Interface Segregation Principle (인터페이스 분리 원칙)
클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다.
BAD: 뚱뚱한 인터페이스
// BAD: 모든 기기가 모든 기능을 구현해야 함
interface SmartDevice {
print(): void
scan(): void
fax(): void
copyDocument(): void
}
// 프린터는 scan, fax를 구현할 수 없음
class SimplePrinter implements SmartDevice {
print() {
/* OK */
}
scan() {
throw new Error('Not supported')
} // 강제 구현
fax() {
throw new Error('Not supported')
} // 강제 구현
copyDocument() {
throw new Error('Not supported')
}
}
GOOD: 분리된 인터페이스
// GOOD: 역할별 분리된 인터페이스
interface Printer {
print(): void
}
interface Scanner {
scan(): void
}
interface FaxMachine {
fax(): void
}
// 필요한 인터페이스만 구현
class SimplePrinter implements Printer {
print() {
/* 인쇄 처리 */
}
}
class AllInOnePrinter implements Printer, Scanner, FaxMachine {
print() {
/* 인쇄 */
}
scan() {
/* 스캔 */
}
fax() {
/* 팩스 */
}
}
2.5 D — Dependency Inversion Principle (의존성 역전 원칙)
고수준 모듈은 저수준 모듈에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다.
이것이 모든 아키텍처 패턴의 핵심입니다.
BAD: 고수준이 저수준에 직접 의존
// BAD: 비즈니스 로직이 구체적 구현에 의존
class OrderService {
private mysqlDb = new MySQLDatabase() // 구체 클래스에 의존
private smtpMail = new SmtpEmailClient() // 구체 클래스에 의존
createOrder(order: Order) {
this.mysqlDb.insert('orders', order)
this.smtpMail.send(order.userEmail, 'Order confirmed')
}
}
// MySQL을 PostgreSQL로 바꾸려면 OrderService 수정 필요!
GOOD: 추상화에 의존
// GOOD: 인터페이스(추상화)에 의존
interface OrderRepository {
save(order: Order): Promise<Order>
}
interface NotificationService {
notify(userId: string, message: string): Promise<void>
}
class OrderService {
constructor(
private repository: OrderRepository, // 추상화에 의존
private notification: NotificationService // 추상화에 의존
) {}
async createOrder(order: Order): Promise<Order> {
const saved = await this.repository.save(order)
await this.notification.notify(order.userId, 'Order confirmed')
return saved
}
}
// 인프라 계층에서 구현
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<Order> {
return this.prisma.order.create({ data: order })
}
}
class SlackNotificationService implements NotificationService {
async notify(userId: string, message: string): Promise<void> {
await this.slackClient.postMessage({ channel: userId, text: message })
}
}
3. 레이어드 아키텍처
3.1 기본 구조
레이어드 아키텍처는 가장 전통적이고 널리 사용되는 패턴입니다. 계층 간 명확한 책임 분리가 핵심입니다.
┌─────────────────────────────┐
│ Presentation Layer │ ← Controller, View
├─────────────────────────────┤
│ Application Layer │ ← Service, DTO
├─────────────────────────────┤
│ Domain Layer │ ← Entity, Business Logic
├─────────────────────────────┤
│ Infrastructure Layer │ ← Repository, External API
└─────────────────────────────┘
의존성 방향: 위 → 아래 (Presentation → Infrastructure)
3.2 Spring Boot 프로젝트 구조 예시
src/main/java/com/example/shop/
controller/
OrderController.java
service/
OrderService.java
domain/
Order.java
OrderItem.java
repository/
OrderRepository.java
JpaOrderRepository.java
dto/
CreateOrderRequest.java
OrderResponse.java
3.3 구현 예시
// Presentation Layer
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@RequestBody CreateOrderRequest request) {
OrderResponse response = orderService.createOrder(request);
return ResponseEntity.status(201).body(response);
}
}
// Application Layer
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
public OrderResponse createOrder(CreateOrderRequest request) {
Product product = productRepository.findById(request.getProductId())
.orElseThrow(() -> new NotFoundException("Product not found"));
Order order = Order.create(
request.getUserId(),
product,
request.getQuantity()
);
Order saved = orderRepository.save(order);
return OrderResponse.from(saved);
}
}
// Domain Layer
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String userId;
private BigDecimal totalAmount;
private OrderStatus status;
public static Order create(String userId, Product product, int quantity) {
Order order = new Order();
order.userId = userId;
order.totalAmount = product.getPrice().multiply(BigDecimal.valueOf(quantity));
order.status = OrderStatus.CREATED;
return order;
}
public void cancel() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Only CREATED orders can be cancelled");
}
this.status = OrderStatus.CANCELLED;
}
}
3.4 장단점
장점:
- 이해하기 쉬움 — 대부분의 개발자에게 익숙
- 빠른 개발 — 간단한 CRUD 앱에 적합
- 관심사 분리 — 기본적인 계층 분리 제공
단점:
- 도메인 로직이 서비스 계층에 흩어지기 쉬움 (Anemic Domain Model)
- 의존성이 위에서 아래로 흐르므로 DB 변경 시 영향 범위가 큼
- 테스트 시 인프라 의존성을 모킹해야 함
- 복잡한 비즈니스 로직을 표현하기 어려움
3.5 언제 사용하면 좋은가
- 간단한 CRUD 애플리케이션
- 팀 규모가 작고 빠른 프로토타이핑이 필요할 때
- 비즈니스 로직이 복잡하지 않을 때
- 교체 가능성이 낮은 기술 스택을 사용할 때
4. 클린 아키텍처 (Uncle Bob)
4.1 핵심 개념: 동심원 구조
Robert C. Martin이 2012년에 발표한 클린 아키텍처는 **의존성 규칙(Dependency Rule)**을 핵심으로 합니다. 모든 의존성은 안쪽을 향해야 합니다.
┌─────────────────────────────────────────────────┐
│ Frameworks & Drivers (Web, DB, External) │
│ ┌─────────────────────────────────────────┐ │
│ │ Interface Adapters (Controllers, GW) │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Application (Use Cases) │ │ │
│ │ │ ┌─────────────────────────┐ │ │ │
│ │ │ │ Entities │ │ │ │
│ │ │ │ (Domain Models) │ │ │ │
│ │ │ └─────────────────────────┘ │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
의존성 방향: 바깥 → 안쪽 (Frameworks → Entities)
4.2 각 계층의 역할
Entities (엔티티): 비즈니스 규칙을 캡슐화. 가장 변하지 않는 핵심 로직.
// Entities - 어떤 프레임워크에도 의존하지 않음
class Order {
private items: OrderItem[] = []
private status: OrderStatus = 'CREATED'
addItem(product: Product, quantity: number): void {
if (quantity <= 0) throw new Error('Quantity must be positive')
if (this.status !== 'CREATED') throw new Error('Cannot modify confirmed order')
this.items.push(new OrderItem(product, quantity))
}
get totalAmount(): Money {
return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero('KRW'))
}
confirm(): void {
if (this.items.length === 0) throw new Error('Cannot confirm empty order')
this.status = 'CONFIRMED'
}
}
Use Cases (유스케이스): 애플리케이션 고유의 비즈니스 규칙. 엔티티를 조합하여 시나리오 구현.
// Use Cases
interface OrderRepository {
save(order: Order): Promise<Order>
findById(id: string): Promise<Order | null>
}
interface PaymentGateway {
charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>
}
class CreateOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private paymentGateway: PaymentGateway,
private eventPublisher: DomainEventPublisher
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
const order = new Order(input.userId)
for (const item of input.items) {
order.addItem(item.product, item.quantity)
}
order.confirm()
const paymentResult = await this.paymentGateway.charge(order.totalAmount, input.paymentMethod)
if (!paymentResult.success) {
throw new PaymentFailedError(paymentResult.reason)
}
const saved = await this.orderRepo.save(order)
await this.eventPublisher.publish(new OrderCreatedEvent(saved.id))
return { orderId: saved.id, totalAmount: order.totalAmount.toString() }
}
}
Interface Adapters (인터페이스 어댑터): Use Case와 외부 세계를 연결. Controller, Presenter, Gateway.
// Interface Adapter - Controller
class OrderController {
constructor(private createOrderUseCase: CreateOrderUseCase) {}
async handleCreateOrder(req: HttpRequest): Promise<HttpResponse> {
try {
const input = this.mapToInput(req.body)
const output = await this.createOrderUseCase.execute(input)
return { status: 201, body: output }
} catch (error) {
if (error instanceof PaymentFailedError) {
return { status: 402, body: { error: error.message } }
}
return { status: 500, body: { error: 'Internal server error' } }
}
}
private mapToInput(body: any): CreateOrderInput {
return {
userId: body.userId,
items: body.items,
paymentMethod: body.paymentMethod,
}
}
}
4.3 Spring Boot 프로젝트 구조
src/main/java/com/example/shop/
domain/ # Entities
model/
Order.java
OrderItem.java
Money.java
event/
OrderCreatedEvent.java
application/ # Use Cases
port/
in/
CreateOrderUseCase.java
out/
OrderRepository.java
PaymentGateway.java
service/
OrderApplicationService.java
adapter/ # Interface Adapters + Frameworks
in/
web/
OrderController.java
CreateOrderRequest.java
out/
persistence/
JpaOrderRepository.java
OrderJpaEntity.java
payment/
StripePaymentGateway.java
config/
BeanConfiguration.java
4.4 NestJS 프로젝트 구조
src/
domain/
entities/
order.entity.ts
order-item.entity.ts
money.value-object.ts
events/
order-created.event.ts
application/
ports/
order.repository.port.ts
payment.gateway.port.ts
use-cases/
create-order.use-case.ts
cancel-order.use-case.ts
infrastructure/
adapters/
in/
http/
order.controller.ts
dto/
create-order.dto.ts
out/
persistence/
prisma-order.repository.ts
payment/
stripe-payment.gateway.ts
config/
module.config.ts
4.5 의존성 규칙의 위력
클린 아키텍처의 핵심은 의존성 규칙입니다. 안쪽 원은 바깥 원에 대해 아무것도 모릅니다.
// domain 계층 - 외부 의존성 ZERO
// Order.ts에 import 문을 보면:
// - express 없음
// - prisma 없음
// - typeorm 없음
// 순수 TypeScript/Java 코드만 존재
class Order {
// 순수 비즈니스 로직만 포함
// DB가 MySQL이든 MongoDB든 상관없음
// 웹 프레임워크가 Express든 Fastify든 상관없음
}
이 규칙 덕분에 프레임워크나 DB를 교체해도 핵심 비즈니스 로직은 영향을 받지 않습니다.
5. 헥사고날 아키텍처 (포트 & 어댑터)
5.1 Alistair Cockburn의 원래 개념
헥사고날 아키텍처는 2005년 Alistair Cockburn이 제안한 패턴으로, 공식 이름은 "Ports and Adapters"입니다. 육각형 모양은 포트의 수가 유동적임을 표현한 것이지 정확히 6개라는 의미가 아닙니다.
┌──────────┐
HTTP ──▶ │ │ ──▶ PostgreSQL
│ Port │
CLI ──▶ │ & │ ──▶ Redis
│ Domain │
gRPC ──▶ │ Core │ ──▶ Kafka
│ │
Test ──▶ │ │ ──▶ Mock DB
└──────────┘
[Driving Adapters] [Core] [Driven Adapters]
(입력 측) (비즈니스) (출력 측)
5.2 포트와 어댑터 개념
포트(Port): 비즈니스 로직이 외부와 소통하기 위한 인터페이스. 포트는 두 종류로 나뉩니다.
- Driving Port (인바운드 포트): 외부에서 애플리케이션을 호출하는 인터페이스
- Driven Port (아웃바운드 포트): 애플리케이션이 외부 시스템을 호출하기 위한 인터페이스
어댑터(Adapter): 포트의 구체적 구현.
- Driving Adapter (인바운드 어댑터): REST Controller, CLI, gRPC Handler
- Driven Adapter (아웃바운드 어댑터): JPA Repository, HTTP Client, Message Publisher
5.3 전체 구현 예시
// ===== PORTS (인터페이스 정의) =====
// Driving Port (인바운드) - 외부가 애플리케이션을 호출
interface CreateOrderPort {
createOrder(command: CreateOrderCommand): Promise<OrderId>
}
interface CancelOrderPort {
cancelOrder(orderId: string): Promise<void>
}
// Driven Port (아웃바운드) - 애플리케이션이 외부를 호출
interface LoadOrderPort {
findById(id: string): Promise<Order | null>
}
interface SaveOrderPort {
save(order: Order): Promise<Order>
}
interface SendNotificationPort {
sendOrderConfirmation(order: Order): Promise<void>
}
// ===== DOMAIN CORE (핵심 비즈니스 로직) =====
class Order {
private readonly id: string
private items: OrderItem[] = []
private status: OrderStatus = 'PENDING'
constructor(
id: string,
private readonly customerId: string
) {
this.id = id
}
addItem(productId: string, price: Money, quantity: number): void {
this.ensureModifiable()
this.items.push(new OrderItem(productId, price, quantity))
}
confirm(): void {
if (this.items.length === 0) {
throw new DomainError('Cannot confirm empty order')
}
this.status = 'CONFIRMED'
}
cancel(): void {
if (this.status === 'SHIPPED') {
throw new DomainError('Cannot cancel shipped order')
}
this.status = 'CANCELLED'
}
private ensureModifiable(): void {
if (this.status !== 'PENDING') {
throw new DomainError('Order is not modifiable')
}
}
get totalAmount(): Money {
return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero('KRW'))
}
}
// ===== APPLICATION SERVICE (유스케이스 구현) =====
class OrderService implements CreateOrderPort, CancelOrderPort {
constructor(
private readonly loadOrder: LoadOrderPort,
private readonly saveOrder: SaveOrderPort,
private readonly notification: SendNotificationPort
) {}
async createOrder(command: CreateOrderCommand): Promise<OrderId> {
const order = new Order(generateId(), command.customerId)
for (const item of command.items) {
order.addItem(item.productId, new Money(item.price, 'KRW'), item.quantity)
}
order.confirm()
const saved = await this.saveOrder.save(order)
await this.notification.sendOrderConfirmation(saved)
return saved.id
}
async cancelOrder(orderId: string): Promise<void> {
const order = await this.loadOrder.findById(orderId)
if (!order) throw new NotFoundError('Order not found')
order.cancel()
await this.saveOrder.save(order)
}
}
// ===== ADAPTERS (구체적 구현) =====
// Driving Adapter - REST Controller
class OrderRestController {
constructor(private createOrderPort: CreateOrderPort) {}
async handlePost(req: Request, res: Response): Promise<void> {
const command: CreateOrderCommand = {
customerId: req.body.customerId,
items: req.body.items,
}
const orderId = await this.createOrderPort.createOrder(command)
res.status(201).json({ orderId })
}
}
// Driven Adapter - Database
class PostgresOrderAdapter implements LoadOrderPort, SaveOrderPort {
constructor(private prisma: PrismaClient) {}
async findById(id: string): Promise<Order | null> {
const data = await this.prisma.order.findUnique({
where: { id },
include: { items: true },
})
return data ? this.toDomain(data) : null
}
async save(order: Order): Promise<Order> {
await this.prisma.order.upsert({
where: { id: order.id },
create: this.toPersistence(order),
update: this.toPersistence(order),
})
return order
}
private toDomain(data: PrismaOrder): Order {
/* 매핑 */
}
private toPersistence(order: Order): PrismaOrderData {
/* 매핑 */
}
}
// Driven Adapter - Notification
class EmailNotificationAdapter implements SendNotificationPort {
constructor(private mailer: MailClient) {}
async sendOrderConfirmation(order: Order): Promise<void> {
await this.mailer.send({
to: order.customerEmail,
subject: 'Order Confirmed',
body: `Your order #${order.id} has been confirmed.`,
})
}
}
5.4 클린 아키텍처와의 비교
| 관점 | 클린 아키텍처 | 헥사고날 아키텍처 |
|---|---|---|
| 제안자 | Robert C. Martin (2012) | Alistair Cockburn (2005) |
| 시각화 | 동심원 (Concentric Circles) | 육각형 (Hexagon) |
| 핵심 아이디어 | 의존성이 안쪽을 향함 | 포트와 어댑터로 분리 |
| 계층 수 | 4개 (Entities, Use Cases, Adapters, Frameworks) | 3개 (Adapters, Ports, Domain) |
| 강조점 | 의존성 규칙 | 입출력 대칭성 |
| 테스트 | Use Case 단위 테스트 용이 | 어댑터 교체로 테스트 용이 |
| 본질 | 사실상 같은 원칙의 다른 표현 | 사실상 같은 원칙의 다른 표현 |
두 아키텍처 모두 의존성 역전이라는 동일한 원칙에 기반합니다. 실무에서는 혼용해서 사용하는 경우가 많습니다.
6. DDD (Domain-Driven Design)
6.1 DDD란
DDD(Domain-Driven Design)는 Eric Evans가 2003년 저서에서 소개한 방법론으로, 복잡한 비즈니스 도메인을 소프트웨어 모델의 중심에 놓는 접근법입니다.
DDD는 크게 Strategic DDD(전략적 설계)와 Tactical DDD(전술적 설계)로 나뉩니다.
6.2 Strategic DDD — 큰 그림 설계
Ubiquitous Language (유비쿼터스 언어)
개발자와 비즈니스 전문가가 동일한 용어를 사용하는 것. 코드의 클래스명, 메서드명이 비즈니스 용어와 일치해야 합니다.
비즈니스: "고객이 주문을 생성하고, 결제가 승인되면 주문이 확정된다"
코드: customer.createOrder() → payment.approve() → order.confirm()
비즈니스: "배송이 시작되면 주문을 취소할 수 없다"
코드: order.cancel() → if (status === 'SHIPPED') throw Error
Bounded Context (바운디드 컨텍스트)
하나의 시스템 안에서도 같은 용어가 다른 의미를 가질 수 있습니다. Bounded Context는 특정 모델이 유효한 경계를 명시합니다.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Order Context │ │ Payment Context │ │ Shipping Context │
│ │ │ │ │ │
│ Order │ │ Payment │ │ Shipment │
│ OrderItem │ │ Transaction │ │ Package │
│ Customer (id, │ │ Customer (id, │ │ Customer (id, │
│ name, email) │ │ paymentMethod) │ │ address) │
│ │ │ │ │ │
│ "Customer"는 │ │ "Customer"는 │ │ "Customer"는 │
│ 주문자 정보 │ │ 결제자 정보 │ │ 수령인 정보 │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───── Context Map (컨텍스트 매핑) ───────┘
Context Mapping 패턴
Bounded Context 간의 관계를 정의하는 패턴들입니다.
- Shared Kernel: 두 컨텍스트가 공통 모델을 공유
- Customer-Supplier: 한쪽이 공급자, 다른 쪽이 소비자
- Anti-Corruption Layer (ACL): 외부 모델이 내부를 오염시키지 않도록 변환 계층 설치
- Published Language: 잘 문서화된 공유 언어로 통합
- Separate Ways: 통합 없이 각자 독립적으로 운영
6.3 Tactical DDD — 코드 레벨 설계
Entity (엔티티)
고유 식별자를 가지며, 생명주기 동안 식별자가 유지되는 객체.
public class Order {
private final OrderId id; // 고유 식별자
private OrderStatus status;
private List<OrderItem> items;
private Money totalAmount;
// 비즈니스 로직이 엔티티 안에 존재 (Rich Domain Model)
public void addItem(Product product, int quantity) {
validateModifiable();
OrderItem item = new OrderItem(product, quantity);
this.items.add(item);
recalculateTotal();
}
public void confirm() {
if (items.isEmpty()) {
throw new OrderEmptyException("Cannot confirm empty order");
}
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmedEvent(this.id));
}
// equals/hashCode는 id 기반
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order)) return false;
return id.equals(((Order) o).id);
}
}
Value Object (값 객체)
식별자가 없고, 속성 값으로 동등성을 판단. 불변(Immutable).
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
Objects.requireNonNull(currency);
}
public Money add(Money other) {
validateSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
public static Money zero(Currency currency) {
return new Money(BigDecimal.ZERO, currency);
}
private void validateSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(
"Cannot operate on different currencies"
);
}
}
}
// 사용 예시
Money price = new Money(BigDecimal.valueOf(15000), Currency.KRW);
Money total = price.multiply(3); // 45000 KRW
Aggregate (애그리거트)
관련 엔티티와 값 객체의 클러스터. Aggregate Root를 통해서만 접근 가능.
// Order가 Aggregate Root
// OrderItem은 Order를 통해서만 접근/수정 가능
public class Order { // Aggregate Root
private final OrderId id;
private final CustomerId customerId;
private List<OrderItem> items = new ArrayList<>(); // 내부 엔티티
private ShippingAddress address; // 값 객체
private Money totalAmount; // 값 객체
// 외부에서 OrderItem을 직접 생성/수정 불가
// 반드시 Order를 통해서만 조작
public void addItem(ProductId productId, Money price, int qty) {
OrderItem item = new OrderItem(productId, price, qty);
this.items.add(item);
recalculateTotal();
}
public void removeItem(ProductId productId) {
this.items.removeIf(item -> item.getProductId().equals(productId));
recalculateTotal();
}
}
Aggregate 설계 규칙:
- Aggregate 바깥에서는 Root Entity만 참조
- Aggregate 간 참조는 ID로만 (직접 객체 참조 금지)
- 하나의 트랜잭션에서 하나의 Aggregate만 수정
- Aggregate는 작게 유지 (1 Root + 소수의 내부 객체)
Domain Event (도메인 이벤트)
도메인에서 발생한 중요한 사건을 나타내는 객체.
public record OrderConfirmedEvent(
OrderId orderId,
CustomerId customerId,
Money totalAmount,
Instant occurredAt
) implements DomainEvent {
public OrderConfirmedEvent(OrderId orderId, CustomerId customerId, Money totalAmount) {
this(orderId, customerId, totalAmount, Instant.now());
}
}
// Aggregate Root에서 이벤트 등록
public class Order extends AbstractAggregateRoot<Order> {
public void confirm() {
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount));
}
}
// 이벤트 핸들러
@Component
public class OrderConfirmedEventHandler {
@EventListener
public void handleOrderConfirmed(OrderConfirmedEvent event) {
// 결제 처리 트리거
// 재고 차감 트리거
// 확인 이메일 발송
}
}
Repository (리포지토리)
Aggregate의 영속성을 담당하는 인터페이스. Domain 계층에 인터페이스를, Infrastructure 계층에 구현을 배치합니다.
// Domain 계층에 인터페이스 정의
public interface OrderRepository {
Order findById(OrderId id);
OrderId save(Order order);
void delete(Order order);
List<Order> findByCustomerId(CustomerId customerId);
}
// Infrastructure 계층에서 구현
@Repository
public class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderRepository springDataRepo;
private final OrderMapper mapper;
@Override
public Order findById(OrderId id) {
return springDataRepo.findById(id.getValue())
.map(mapper::toDomain)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@Override
public OrderId save(Order order) {
OrderJpaEntity entity = mapper.toJpaEntity(order);
OrderJpaEntity saved = springDataRepo.save(entity);
return new OrderId(saved.getId());
}
}
6.4 Event Storming
Event Storming은 DDD에서 도메인을 탐색하고 Bounded Context를 발견하기 위한 워크숍 기법입니다.
1. Domain Events (주황색 포스트잇) - 과거형으로 작성
"주문이 생성되었다" "결제가 승인되었다" "상품이 배송되었다"
2. Commands (파란색 포스트잇) - 이벤트를 발생시키는 행위
"주문 생성" "결제 승인" "배송 시작"
3. Aggregates (노란색 포스트잇) - 커맨드를 처리하는 주체
"Order" "Payment" "Shipment"
4. Bounded Contexts 발견 - 관련 있는 것들을 그룹핑
Order Context | Payment Context | Shipping Context
6.5 DDD + Hexagonal = 강력한 조합
DDD의 전술적 패턴을 헥사고날 아키텍처에 배치하면 가장 강력한 조합이 됩니다.
src/
order/ # Bounded Context
domain/ # 헥사고날의 Core
model/
Order.java # Aggregate Root (Entity)
OrderItem.java # Entity
Money.java # Value Object
OrderStatus.java # Value Object (Enum)
event/
OrderConfirmedEvent.java # Domain Event
service/
OrderDomainService.java # Domain Service
application/ # 헥사고날의 Port 정의 + Use Case
port/
in/
CreateOrderUseCase.java # Driving Port
CancelOrderUseCase.java
out/
LoadOrderPort.java # Driven Port
SaveOrderPort.java
PaymentPort.java
service/
OrderApplicationService.java # Use Case 구현
infrastructure/ # 헥사고날의 Adapter
adapter/
in/
web/
OrderController.java # Driving Adapter
messaging/
OrderEventListener.java
out/
persistence/
JpaOrderRepository.java # Driven Adapter
OrderJpaEntity.java
payment/
StripePaymentAdapter.java
6.6 DDD를 사용하지 말아야 할 때
DDD는 강력하지만 모든 프로젝트에 적합하지는 않습니다.
사용하지 말아야 할 경우:
- 단순한 CRUD 애플리케이션
- 비즈니스 로직이 거의 없는 데이터 파이프라인
- 프로토타입이나 MVP
- 팀이 DDD에 대한 이해가 부족할 때
- 도메인 전문가와 협업이 불가능할 때
DDD 복잡도 판단 기준:
비즈니스 로직 복잡도
^
│ DDD + Hexagonal
│ ┌──────────────┐
│ │ │
│ │ Sweet Spot │
│ │ │
│ └──────────────┘
│ Clean Architecture
│ ┌──────────────┐
│ │ │
│ └──────────────┘
│ Layered
│ ┌──────────────┐
│ └──────────────┘
└──────────────────────> 프로젝트 규모/팀 크기
7. 아키텍처 비교 총정리
7.1 비교표
| 항목 | 레이어드 | 클린 | 헥사고날 | DDD |
|---|---|---|---|---|
| 복잡도 | 낮음 | 중간 | 중간-높음 | 높음 |
| 학습 곡선 | 낮음 | 중간 | 중간 | 높음 |
| 적합한 프로젝트 | 간단한 CRUD | 중간 복잡도 | 복잡한 도메인, 많은 통합 | 복잡한 비즈니스 로직 |
| 핵심 장점 | 이해하기 쉬움 | 프레임워크 독립 | 어댑터 교체 가능 | 비즈니스 정렬 |
| 테스트 용이성 | 보통 | 높음 | 높음 | 매우 높음 |
| 팀 규모 | 소규모 | 중규모 | 중-대규모 | 대규모 |
| DB 교체 용이성 | 어려움 | 쉬움 | 매우 쉬움 | 매우 쉬움 |
7.2 선택 가이드 플로우차트
프로젝트 시작
│
├─ 비즈니스 로직이 단순한가? ──YES──▶ 레이어드 아키텍처
│
NO
│
├─ 외부 시스템 통합이 많은가? ──YES──▶ 헥사고날 아키텍처
│
NO
│
├─ 도메인 전문가와 협업 가능? ──YES──┐
│ │
NO ▼
│ 비즈니스 로직이 매우 복잡?
│ │ │
│ YES NO
│ │ │
│ ▼ ▼
│ DDD + Hexagonal 클린 아키텍처
│
└──▶ 클린 아키텍처 (안전한 기본 선택)
7.3 실무 적용 팁
- 점진적 도입: 처음부터 DDD를 전체 적용하지 말고, 가장 복잡한 모듈부터 시작
- 혼합 사용: 한 시스템에서 모듈별로 다른 아키텍처 적용 가능 (핵심은 DDD, 나머지는 레이어드)
- 팀 역량 고려: 팀의 경험과 이해도에 맞는 아키텍처 선택
- YAGNI 원칙: 지금 필요하지 않은 추상화를 미리 만들지 말 것
8. 실전 프로젝트: 이커머스 주문 시스템
8.1 도메인 모델 설계
Clean/Hexagonal + DDD를 적용한 이커머스 주문 시스템의 전체 구현입니다.
Money (Value Object)
public record Money(BigDecimal amount, String currency) {
public Money {
Objects.requireNonNull(amount, "Amount must not be null");
Objects.requireNonNull(currency, "Currency must not be null");
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
}
public static Money of(long amount, String currency) {
return new Money(BigDecimal.valueOf(amount), currency);
}
public static Money zero(String currency) {
return new Money(BigDecimal.ZERO, currency);
}
public Money add(Money other) {
assertSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
public boolean isGreaterThan(Money other) {
assertSameCurrency(other);
return this.amount.compareTo(other.amount) > 0;
}
private void assertSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Currency mismatch: " + this.currency + " vs " + other.currency
);
}
}
}
OrderItem (Entity)
public class OrderItem {
private final String productId;
private final String productName;
private final Money unitPrice;
private int quantity;
public OrderItem(String productId, String productName, Money unitPrice, int quantity) {
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
this.productId = productId;
this.productName = productName;
this.unitPrice = unitPrice;
this.quantity = quantity;
}
public Money getSubtotal() {
return unitPrice.multiply(quantity);
}
public void changeQuantity(int newQuantity) {
if (newQuantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
this.quantity = newQuantity;
}
}
Order (Aggregate Root)
public class Order {
private final String id;
private final String customerId;
private final List<OrderItem> items = new ArrayList<>();
private OrderStatus status;
private Money totalAmount;
private final Instant createdAt;
private final List<DomainEvent> domainEvents = new ArrayList<>();
public Order(String id, String customerId) {
this.id = id;
this.customerId = customerId;
this.status = OrderStatus.PENDING;
this.totalAmount = Money.zero("KRW");
this.createdAt = Instant.now();
}
public void addItem(String productId, String productName, Money price, int quantity) {
ensureModifiable();
this.items.add(new OrderItem(productId, productName, price, quantity));
recalculateTotal();
}
public void confirm() {
if (items.isEmpty()) {
throw new OrderDomainException("Cannot confirm empty order");
}
if (status != OrderStatus.PENDING) {
throw new OrderDomainException("Only pending orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
domainEvents.add(new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount));
}
public void cancel() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new OrderDomainException("Cannot cancel order in status: " + status);
}
this.status = OrderStatus.CANCELLED;
domainEvents.add(new OrderCancelledEvent(this.id, this.customerId));
}
public void markAsShipped(String trackingNumber) {
if (status != OrderStatus.CONFIRMED) {
throw new OrderDomainException("Only confirmed orders can be shipped");
}
this.status = OrderStatus.SHIPPED;
domainEvents.add(new OrderShippedEvent(this.id, trackingNumber));
}
private void ensureModifiable() {
if (status != OrderStatus.PENDING) {
throw new OrderDomainException("Order is not modifiable");
}
}
private void recalculateTotal() {
this.totalAmount = items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.zero("KRW"), Money::add);
}
public List<DomainEvent> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
8.2 Application Layer (Use Cases)
// Driving Port
public interface CreateOrderUseCase {
CreateOrderResult execute(CreateOrderCommand command);
}
public record CreateOrderCommand(
String customerId,
List<OrderItemCommand> items
) {}
public record OrderItemCommand(
String productId,
String productName,
long price,
int quantity
) {}
// Driven Ports
public interface OrderRepository {
Optional<Order> findById(String id);
Order save(Order order);
}
public interface EventPublisher {
void publish(List<DomainEvent> events);
}
// Use Case 구현
@Service
@Transactional
public class CreateOrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository;
private final EventPublisher eventPublisher;
private final IdGenerator idGenerator;
public CreateOrderService(
OrderRepository orderRepository,
EventPublisher eventPublisher,
IdGenerator idGenerator) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
this.idGenerator = idGenerator;
}
@Override
public CreateOrderResult execute(CreateOrderCommand command) {
Order order = new Order(idGenerator.generate(), command.customerId());
for (OrderItemCommand item : command.items()) {
order.addItem(
item.productId(),
item.productName(),
Money.of(item.price(), "KRW"),
item.quantity()
);
}
order.confirm();
Order saved = orderRepository.save(order);
eventPublisher.publish(saved.getDomainEvents());
saved.clearDomainEvents();
return new CreateOrderResult(saved.getId(), saved.getTotalAmount().toString());
}
}
// CancelOrderUseCase
public interface CancelOrderUseCase {
void execute(String orderId);
}
@Service
@Transactional
public class CancelOrderService implements CancelOrderUseCase {
private final OrderRepository orderRepository;
private final EventPublisher eventPublisher;
@Override
public void execute(String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.cancel();
orderRepository.save(order);
eventPublisher.publish(order.getDomainEvents());
order.clearDomainEvents();
}
}
8.3 Infrastructure Layer (Adapters)
// Driven Adapter - JPA Repository
@Repository
public class JpaOrderRepositoryAdapter implements OrderRepository {
private final SpringDataOrderRepository jpaRepo;
private final OrderPersistenceMapper mapper;
@Override
public Optional<Order> findById(String id) {
return jpaRepo.findById(id).map(mapper::toDomain);
}
@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toJpaEntity(order);
OrderJpaEntity saved = jpaRepo.save(entity);
return mapper.toDomain(saved);
}
}
// Driven Adapter - Kafka Event Publisher
@Component
public class KafkaEventPublisher implements EventPublisher {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
@Override
public void publish(List<DomainEvent> events) {
for (DomainEvent event : events) {
String topic = resolveTopic(event);
String payload = serialize(event);
kafkaTemplate.send(topic, event.getAggregateId(), payload);
}
}
private String resolveTopic(DomainEvent event) {
if (event instanceof OrderConfirmedEvent) return "order.confirmed";
if (event instanceof OrderCancelledEvent) return "order.cancelled";
if (event instanceof OrderShippedEvent) return "order.shipped";
return "order.unknown";
}
private String serialize(DomainEvent event) {
try {
return objectMapper.writeValueAsString(event);
} catch (Exception e) {
throw new EventPublishException("Failed to serialize event", e);
}
}
}
// Driving Adapter - REST Controller
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
private final CancelOrderUseCase cancelOrderUseCase;
@PostMapping
public ResponseEntity<CreateOrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
CreateOrderCommand command = request.toCommand();
CreateOrderResult result = createOrderUseCase.execute(command);
return ResponseEntity.status(201).body(CreateOrderResponse.from(result));
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> cancelOrder(@PathVariable String orderId) {
cancelOrderUseCase.execute(orderId);
return ResponseEntity.noContent().build();
}
}
9. 테스트 전략
9.1 도메인 로직 단위 테스트
도메인 로직은 외부 의존성 없이 순수하게 테스트할 수 있습니다. 이것이 클린/헥사고날 아키텍처의 가장 큰 장점입니다.
class OrderTest {
@Test
void shouldCalculateTotalAmount() {
Order order = new Order("order-1", "customer-1");
order.addItem("prod-1", "Keyboard", Money.of(50000, "KRW"), 2);
order.addItem("prod-2", "Mouse", Money.of(30000, "KRW"), 1);
assertEquals(Money.of(130000, "KRW"), order.getTotalAmount());
}
@Test
void shouldNotConfirmEmptyOrder() {
Order order = new Order("order-1", "customer-1");
assertThrows(OrderDomainException.class, () -> order.confirm());
}
@Test
void shouldNotCancelShippedOrder() {
Order order = createConfirmedOrder();
order.markAsShipped("TRACK-123");
assertThrows(OrderDomainException.class, () -> order.cancel());
}
@Test
void shouldRegisterDomainEventOnConfirm() {
Order order = new Order("order-1", "customer-1");
order.addItem("prod-1", "Keyboard", Money.of(50000, "KRW"), 1);
order.confirm();
List<DomainEvent> events = order.getDomainEvents();
assertEquals(1, events.size());
assertInstanceOf(OrderConfirmedEvent.class, events.get(0));
}
}
class MoneyTest {
@Test
void shouldAddSameCurrency() {
Money a = Money.of(1000, "KRW");
Money b = Money.of(2000, "KRW");
assertEquals(Money.of(3000, "KRW"), a.add(b));
}
@Test
void shouldRejectDifferentCurrency() {
Money krw = Money.of(1000, "KRW");
Money usd = Money.of(1, "USD");
assertThrows(IllegalArgumentException.class, () -> krw.add(usd));
}
@Test
void shouldRejectNegativeAmount() {
assertThrows(IllegalArgumentException.class,
() -> new Money(BigDecimal.valueOf(-1), "KRW"));
}
}
9.2 Use Case 통합 테스트
class CreateOrderServiceTest {
private CreateOrderService sut;
private OrderRepository fakeRepository;
private EventPublisher fakePublisher;
@BeforeEach
void setUp() {
fakeRepository = new InMemoryOrderRepository();
fakePublisher = new FakeEventPublisher();
sut = new CreateOrderService(fakeRepository, fakePublisher, new UuidIdGenerator());
}
@Test
void shouldCreateAndConfirmOrder() {
CreateOrderCommand command = new CreateOrderCommand(
"customer-1",
List.of(new OrderItemCommand("prod-1", "Keyboard", 50000, 2))
);
CreateOrderResult result = sut.execute(command);
assertNotNull(result.orderId());
Order saved = fakeRepository.findById(result.orderId()).orElseThrow();
assertEquals(OrderStatus.CONFIRMED, saved.getStatus());
}
}
// 테스트용 In-Memory Repository
class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> store = new HashMap<>();
@Override
public Optional<Order> findById(String id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Order save(Order order) {
store.put(order.getId(), order);
return order;
}
}
9.3 ArchUnit으로 아키텍처 테스트
ArchUnit을 사용하면 아키텍처 규칙을 자동으로 검증할 수 있습니다.
class ArchitectureTest {
private final JavaClasses classes = new ClassFileImporter()
.importPackages("com.example.shop");
@Test
void domainShouldNotDependOnInfrastructure() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..")
.check(classes);
}
@Test
void domainShouldNotDependOnApplication() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..application..")
.check(classes);
}
@Test
void domainShouldNotUseSpringAnnotations() {
noClasses()
.that().resideInAPackage("..domain..")
.should().beAnnotatedWith("org.springframework.stereotype.Service")
.orShould().beAnnotatedWith("org.springframework.stereotype.Component")
.check(classes);
}
@Test
void useCasesShouldOnlyBeAccessedByAdapters() {
classes()
.that().resideInAPackage("..application.service..")
.should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage("..adapter..", "..config..", "..application..")
.check(classes);
}
}
9.4 테스트 피라미드
/ E2E Tests \ ← 적게, 느림, 비쌈
/ (Cypress, etc) \
/─────────────────\
/ Integration Tests \ ← 중간
/ (Adapter Tests) \
/───────────────────────\
/ Unit Tests \ ← 많이, 빠름, 저렴
/ (Domain + Use Case) \
/────────────────────────────\
클린/헥사고날 아키텍처에서는 도메인 로직 단위 테스트가 전체 테스트의 대부분을 차지합니다. 이 테스트들은 외부 의존성이 없어 매우 빠르고 안정적입니다.
10. 안티패턴과 주의점
10.1 Over-Engineering (과도한 엔지니어링)
가장 흔한 실수입니다. 간단한 CRUD 앱에 DDD + Hexagonal을 적용하면 생산성이 오히려 떨어집니다.
// BAD: User의 이름을 바꾸는데 7개 파일이 필요?
// Command -> CommandHandler -> UseCase -> DomainService
// -> Repository -> Adapter -> Mapper
// 단순 업데이트에는 과도함!
// GOOD: 복잡도에 맞는 아키텍처 선택
// 간단한 CRUD = 레이어드 + 서비스 패턴으로 충분
10.2 Anemic Domain Model (빈혈 도메인 모델)
엔티티에 getter/setter만 있고 비즈니스 로직이 서비스 계층에 흩어진 패턴입니다. Martin Fowler가 명명한 안티패턴입니다.
// BAD: Anemic Domain Model
class Order {
private String status;
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
class OrderService {
void cancelOrder(Order order) {
// 비즈니스 로직이 서비스에 존재
if (order.getStatus().equals("SHIPPED")) {
throw new Exception("Cannot cancel");
}
order.setStatus("CANCELLED");
}
}
// GOOD: Rich Domain Model
class Order {
private OrderStatus status;
public void cancel() {
// 비즈니스 로직이 엔티티 안에 존재
if (this.status == OrderStatus.SHIPPED) {
throw new OrderDomainException("Cannot cancel shipped order");
}
this.status = OrderStatus.CANCELLED;
}
}
10.3 God Class (신 클래스)
하나의 클래스가 너무 많은 책임을 가진 패턴. SRP 위반의 극단적 형태입니다.
// BAD: 하나의 서비스가 모든 것을 처리
class OrderGodService {
void createOrder() { /* ... */ }
void cancelOrder() { /* ... */ }
void processPayment() { /* ... */ }
void sendNotification() { /* ... */ }
void generateInvoice() { /* ... */ }
void updateInventory() { /* ... */ }
void calculateShipping() { /* ... */ }
// ... 수천 줄의 메서드
}
// GOOD: 책임별 분리
class CreateOrderUseCase { /* 주문 생성만 */ }
class CancelOrderUseCase { /* 주문 취소만 */ }
class PaymentService { /* 결제만 */ }
class NotificationService { /* 알림만 */ }
10.4 Leaky Abstraction (추상화 누수)
인프라 세부사항이 도메인 계층으로 누출되는 패턴.
// BAD: JPA 어노테이션이 도메인 엔티티에 존재
@Entity
@Table(name = "orders")
class Order {
@Id @GeneratedValue
private Long id;
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderItem> items;
// JPA라는 인프라 세부사항이 도메인에 누출
}
// GOOD: 도메인 엔티티와 JPA 엔티티 분리
// domain/Order.java - 순수 도메인 객체
class Order {
private final String id;
private List<OrderItem> items;
// 프레임워크 의존성 없음
}
// infrastructure/OrderJpaEntity.java - JPA 전용
@Entity @Table(name = "orders")
class OrderJpaEntity {
@Id private String id;
@OneToMany(cascade = CascadeType.ALL)
private List<OrderItemJpaEntity> items;
}
10.5 Resume-Driven Architecture (이력서 주도 아키텍처)
프로젝트 요구사항이 아닌, 이력서를 화려하게 꾸미기 위해 과도한 기술을 도입하는 패턴.
"우리 프로젝트에는 마이크로서비스 + CQRS + Event Sourcing +
Saga Pattern + GraphQL Federation이 필요해!"
실제 요구사항: 사용자 5명의 사내 도구
결과: 6개월 후에도 배포 못 함
원칙: 문제에 맞는 해결책을 선택하라.
퀴즈
아래 퀴즈로 이해도를 점검해 보세요.
Q1: SOLID에서 의존성 역전 원칙(DIP)의 핵심은 무엇인가요?
A: 고수준 모듈(비즈니스 로직)이 저수준 모듈(DB, 외부 서비스)에 직접 의존하지 않고, 둘 다 **추상화(인터페이스)**에 의존해야 한다는 원칙입니다. 이것이 클린 아키텍처와 헥사고날 아키텍처의 핵심 기반입니다.
Q2: 클린 아키텍처의 의존성 규칙(Dependency Rule)을 설명해 주세요.
A: 모든 소스코드 의존성은 **안쪽(고수준)**을 향해야 합니다. Frameworks(바깥) -> Adapters -> Use Cases -> Entities(안쪽). 안쪽 원은 바깥 원에 대해 아무것도 알지 못합니다. Entity는 Use Case를 모르고, Use Case는 Controller를 모릅니다.
Q3: 헥사고날 아키텍처에서 Driving Adapter와 Driven Adapter의 차이는 무엇인가요?
A: **Driving Adapter(인바운드)**는 외부에서 애플리케이션을 호출하는 어댑터입니다 (REST Controller, CLI, gRPC Handler). **Driven Adapter(아웃바운드)**는 애플리케이션이 외부 시스템을 호출하기 위한 어댑터입니다 (DB Repository, HTTP Client, Message Publisher). 입력과 출력의 대칭성이 핵심입니다.
Q4: DDD에서 Aggregate의 설계 규칙 4가지를 설명해 주세요.
A:
- Aggregate 바깥에서는 오직 Root Entity만 참조합니다
- Aggregate 간 참조는 ID로만 합니다 (직접 객체 참조 금지)
- 하나의 트랜잭션에서 하나의 Aggregate만 수정합니다
- Aggregate는 작게 유지합니다 (1 Root + 소수의 내부 객체)
이 규칙들은 데이터 일관성을 보장하고 확장성을 확보하기 위한 것입니다.
Q5: Anemic Domain Model이 안티패턴인 이유는 무엇인가요?
A: Anemic Domain Model은 엔티티에 getter/setter만 있고 비즈니스 로직이 서비스 계층에 흩어진 패턴입니다. 이것이 안티패턴인 이유는: (1) 비즈니스 규칙이 여러 서비스에 중복/분산되어 일관성이 깨지기 쉽고, (2) 도메인 객체가 단순 데이터 컨테이너로 전락하여 OOP의 장점(캡슐화)을 잃으며, (3) 비즈니스 규칙 변경 시 영향 범위 파악이 어려워집니다. Rich Domain Model에서는 비즈니스 로직이 엔티티 안에 존재하여 응집도가 높고 변경에 안전합니다.
참고 자료
도서
- Robert C. Martin, Clean Architecture: A Craftsman's Guide to Software Structure and Design (2017)
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)
- Vaughn Vernon, Implementing Domain-Driven Design (2013)
- Vaughn Vernon, Domain-Driven Design Distilled (2016)
- Martin Fowler, Patterns of Enterprise Application Architecture (2002)
- Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship (2008)
아티클 및 블로그
- Alistair Cockburn, Hexagonal Architecture — https://alistair.cockburn.us/hexagonal-architecture/
- Robert C. Martin, The Clean Architecture (2012) — https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- Martin Fowler, AnemicDomainModel — https://martinfowler.com/bliki/AnemicDomainModel.html
- Martin Fowler, BoundedContext — https://martinfowler.com/bliki/BoundedContext.html
도구 및 프레임워크
- ArchUnit — https://www.archunit.org/
- Spring Modulith — https://spring.io/projects/spring-modulith
- NestJS CQRS Module — https://docs.nestjs.com/recipes/cqrs
강의 및 영상
- Uncle Bob, Clean Architecture and Design (NDC Conference)
- Vaughn Vernon, Strategic Domain-Driven Design (InfoQ)
- Alberto Brandolini, Event Storming — https://www.eventstorming.com/
- Netflix Engineering Blog, Domain-Driven Design at Netflix