왜 Clean Architecture인가?

Service 클래스 하나에 3000줄. 이게 우리 프로젝트의 현실이었다. '일단 돌아가니까'라는 말로 2년을 버텼는데, 신규 기능 추가할 때마다 사이드 이펙트가 터졌다. 결국 Clean Architecture를 도입했고, 처음엔 '이게 왜 이렇게 파일이 많아?'라는 불만이 쏟아졌다. 6개월 후 팀원들의 반응은 180도 달라졌다.

Clean Architecture는 의존성 방향을 안쪽(도메인)으로 통일시켜, 비즈니스 로직을 프레임워크나 인프라에 의존하지 않도록 분리합니다. 이로써 테스트 용이성, 유지보수성, 그리고 기술 스택 교체의 유연성을 얻을 수 있습니다.

패키지 구조 설계

Spring Boot에 헥사고날 아키텍처를 적용할 때 가장 먼저 결정해야 할 것이 패키지 구조입니다.

com.example.order/
├── domain/                    # 핵심 비즈니스 로직
│   ├── model/
│   │   ├── Order.java
│   │   ├── OrderItem.java
│   │   └── OrderStatus.java
│   ├── port/
│   │   ├── in/               # Inbound Ports (Use Cases)
│   │   │   ├── CreateOrderUseCase.java
│   │   │   └── GetOrderUseCase.java
│   │   └── out/              # Outbound Ports
│   │       ├── OrderRepository.java
│   │       ├── PaymentGateway.java
│   │       └── NotificationSender.java
│   └── service/
│       └── OrderDomainService.java
├── application/               # Use Case 구현
│   ├── CreateOrderService.java
│   └── GetOrderService.java
└── adapter/                   # 외부 세계와의 연결
    ├── in/
    │   └── web/
    │       ├── OrderController.java
    │       └── dto/
    │           ├── CreateOrderRequest.java
    │           └── OrderResponse.java
    └── out/
        ├── persistence/
        │   ├── OrderJpaRepository.java
        │   ├── OrderJpaEntity.java
        │   └── OrderPersistenceAdapter.java
        ├── payment/
        │   └── TossPaymentAdapter.java
        └── notification/
            └── SlackNotificationAdapter.java

도메인 모델 설계

도메인 모델은 어떤 프레임워크에도 의존하지 않는 순수 Java 객체입니다. JPA 어노테이션이나 Spring 의존성 없이 비즈니스 규칙만 담습니다.

public class Order {
    private final OrderId id;
    private final UserId userId;
    private final List<OrderItem> items;
    private OrderStatus status;
    private Money totalAmount;

    public void addItem(Product product, int quantity) {
        validateOrderModifiable();
        if (quantity <= 0) {
            throw new InvalidOrderException(
                "수량은 1 이상이어야 합니다");
        }
        items.add(OrderItem.of(product, quantity));
        recalculateTotal();
    }

    public void confirm() {
        if (status != OrderStatus.PENDING) {
            throw new InvalidOrderStateException(
                "대기 상태의 주문만 확정할 수 있습니다");
        }
        if (items.isEmpty()) {
            throw new InvalidOrderException(
                "주문 항목이 비어있습니다");
        }
        this.status = OrderStatus.CONFIRMED;
    }

    private void validateOrderModifiable() {
        if (status != OrderStatus.PENDING) {
            throw new InvalidOrderStateException(
                "수정 가능한 상태가 아닙니다: " + status);
        }
    }

    private void recalculateTotal() {
        this.totalAmount = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);
    }
}

포트 정의

Inbound Port (Use Case 인터페이스)

public interface CreateOrderUseCase {
    OrderId execute(CreateOrderCommand command);
}

public record CreateOrderCommand(
    Long userId,
    List<OrderItemCommand> items,
    String couponCode
) {
    public CreateOrderCommand {
        Objects.requireNonNull(userId, "userId는 필수입니다");
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException(
                "주문 항목은 최소 1개 이상이어야 합니다");
        }
    }
}

Outbound Port (인프라 인터페이스)

public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(OrderId id);
    List<Order> findByUserId(UserId userId);
}

public interface PaymentGateway {
    PaymentResult processPayment(Order order, PaymentInfo info);
    void cancelPayment(String paymentKey);
}

Application Service (Use Case 구현)

@Service
@Transactional
@RequiredArgsConstructor
public class CreateOrderService implements CreateOrderUseCase {

    private final OrderRepository orderRepository;
    private final PaymentGateway paymentGateway;
    private final NotificationSender notificationSender;

    @Override
    public OrderId execute(CreateOrderCommand command) {
        // 1. 도메인 객체 생성
        Order order = Order.create(
            UserId.of(command.userId()),
            command.items().stream()
                .map(this::toOrderItem)
                .collect(Collectors.toList())
        );

        // 2. 비즈니스 규칙 실행
        order.confirm();

        // 3. 저장
        Order savedOrder = orderRepository.save(order);

        // 4. 알림 전송
        notificationSender.sendOrderConfirmation(savedOrder);

        return savedOrder.getId();
    }
}

어댑터 구현

Web Adapter (Inbound)

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final CreateOrderUseCase createOrderUseCase;
    private final GetOrderUseCase getOrderUseCase;

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        CreateOrderCommand command = request.toCommand();
        OrderId orderId = createOrderUseCase.execute(command);
        return ResponseEntity
            .created(URI.create("/api/orders/" + orderId.value()))
            .body(OrderResponse.from(orderId));
    }
}

Persistence Adapter (Outbound)

@Component
@RequiredArgsConstructor
public class OrderPersistenceAdapter implements OrderRepository {

    private final OrderJpaRepository jpaRepository;
    private final OrderMapper mapper;

    @Override
    public Order save(Order order) {
        OrderJpaEntity entity = mapper.toEntity(order);
        OrderJpaEntity saved = jpaRepository.save(entity);
        return mapper.toDomain(saved);
    }

    @Override
    public Optional<Order> findById(OrderId id) {
        return jpaRepository.findById(id.value())
                .map(mapper::toDomain);
    }
}

도입 시 고려사항

Clean Architecture는 모든 프로젝트에 적합하지는 않습니다. 단순 CRUD 위주의 소규모 프로젝트에서는 오히려 보일러플레이트 코드가 늘어나 생산성이 떨어질 수 있습니다. 복잡한 비즈니스 로직이 있고, 장기적으로 유지보수해야 하는 프로젝트에서 그 진가가 발휘됩니다.

또한 팀원들이 아키텍처의 의도를 이해하고 동의해야 합니다. 아키텍처 결정 기록(ADR)을 작성하고, 코드 리뷰에서 의존성 방향을 지속적으로 검증하는 것이 중요합니다.

마무리

Clean Architecture의 핵심은 의존성 역전입니다. 비즈니스 로직이 프레임워크에 의존하는 것이 아니라, 프레임워크가 비즈니스 로직에 맞춰 구성되는 구조를 만드는 것입니다. 점진적으로 도입하되, 도메인 모델의 순수성을 지키는 것에 집중하면 성공적으로 적용할 수 있습니다.

← 목록으로 다음 글 →
Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글