Skip to main content

레이어드 아키텍처와 DIP 적용


Intro

TL;DR

이커머스 핵심 도메인을 설계하며 레이어드 아키텍처와 DIP를 직접 적용했다. 인터페이스를 도메인에 두어 기술 의존성을 제거하고 비즈니스 규칙은 엔티티 내부로 응집시키는 과정을 정리했다.


1. Repository 인터페이스를 Domain에 두는 이유

처음에는 Repository 인터페이스가 왜 Domain Layer에 위치해야 하는지 의문이 있었다. Infrastructure 레이어에 있어도 무방하지 않을까 생각했지만, 직접 구현해 보며 그 이유를 확인했다.

도메인이 Repository 인터페이스만 알고 구현체를 모르면, 도메인은 DB 기술에 전혀 의존하지 않는 상태가 된다. Spring이 런타임에 구현체를 주입해 주기 때문에, 도메인 입장에서는 구체적인 저장소 기술이 무엇인지 몰라도 savefind 같은 행위를 수행할 수 있다.

이 설계의 장점이 실질적으로 체감된 순간은 Fake Repository를 만들 때였다. 테스트에서 ProductRepository 인터페이스를 HashMap으로 구현한 가짜 객체로 갈아끼웠더니, Spring Context나 DB 없이 순수 Java만으로 빠르게 테스트를 수행할 수 있었다. 인터페이스를 통한 추상화가 없었다면 불가능했을 일이다.


2. 비즈니스 규칙은 도메인 객체 안에

재고 부족을 방지하는 로직을 처음에는 Service에 두려 했으나, 이 방식은 Service를 비대하게 만들고 동일한 규칙이 여러 곳에 흩어질 위험이 있었다.

따라서 Product.decreaseStock() 메서드 내부에서 직접 검증하도록 구현했다.

public void decreaseStock(int quantity) {
if (this.stock < quantity) {
throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
}
this.stock -= quantity;
}

이렇게 하면 잘못된 상태의 Product 객체 자체가 존재할 수 없게 된다. 서비스 레이어에서 일일이 검증 로직을 태우지 않아도 객체 스스로 자신의 정당성을 보장한다.


3. Facade(Application Layer)의 역할

OrderFacade를 구현하며 Application Layer의 책임이 무엇인지 명확하게 이해했다.

주문 생성의 흐름:

  1. 상품 존재 확인ProductService
  2. 재고 확인 및 차감ProductService
  3. 브랜드 정보 조회BrandService
  4. 주문 스냅샷 생성 및 저장OrderService

이 흐름을 특정 도메인 서비스가 주도하게 되면 서비스 간 의존성이 복잡하게 꼬인다. 이때 Facade가 각 도메인 서비스를 조합하는 Orchestrator 역할을 수행한다. 도메인 서비스는 자기 도메인의 비즈니스 로직에만 집중하고, 전체적인 실행 흐름 제어는 Facade가 담당하는 구조를 잡았다.


4. ArchUnit을 활용한 아키텍처 검증

구현 과정에서 인상 깊었던 도구는 ArchUnit이다. 이전까지는 TDD를 엄격하게 지키지 못하는 경우가 많았지만, 이번에는 최대한 TDD 원칙을 지키며 진행했다.

특히 같은 팀 유탁님이 공유해 주신 인사이트 덕분에 ArchUnit을 도입했는데, **"Domain이 Infrastructure를 참조하면 안 된다"**는 아키텍처 원칙을 코드 리뷰가 아닌 테스트 코드로 강제할 수 있다는 점이 신선했다.

class ArchitectureTest {

// 1. 계층형 아키텍처 의존성 검증
@ArchTest
static final ArchRule layered_architecture_is_respected = layeredArchitecture()
.consideringOnlyDependenciesInAnyPackage("com.loopers..")
.layer("Interfaces").definedBy("..interfaces..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.layer("Infrastructure").definedBy("..infrastructure..")
.layer("Config").definedBy("..config..")
.layer("Support").definedBy("..support..")
.whereLayer("Interfaces").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure", "Interfaces", "Config")
.whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer()
.whereLayer("Config").mayNotBeAccessedByAnyLayer()
.whereLayer("Support").mayOnlyBeAccessedByLayers("Interfaces", "Application", "Domain", "Infrastructure", "Config");

// 2. Domain 계층 독립성 검증 (DIP)
@ArchTest
static final ArchRule domain_should_not_depend_on_infrastructure = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..");
}

실수로 Domain 클래스에서 JPA Repository를 직접 참조하는 코드를 작성하더라도, 이 테스트가 자동으로 감지해 준다. 원칙을 시스템적으로 강제하는 경험은 매우 유익했다.


마치며 - 클로드와 티키타카

이번 설계와 구현 과정에서도 역시 Claude Code를 적극적으로 활용했다.

초반에는 단순히 구현을 요청하기도 했지만, 그럴수록 코드는 완성되어도 정작 내가 설계의 핵심을 놓치고 있다는 느낌을 받았다. 이후로는 내가 먼저 고민하고 설계한 뒤, Claude에게 검증을 요청하는 방식으로 협업의 방향을 바꿨다.

설계에 정답은 없더라도 명백한 오답은 존재한다. 특정 도메인 상황을 충분히 설명했을 때, 클로드는 그에 맞는 최선의 설계를 제안해 주었다. 단순히 코드를 짜주는 도구를 넘어, 설계 의도에 맞춰 피드백을 주고받는 파트너로서의 활용 가치를 느꼈다.

AI가 많은 답을 줄 수는 있지만, 그것이 나의 지식이 되려면 결국 세심한 결정 단계에서 개발자 본인의 치열한 고민이 선행되어야 한다는 것을 다시금 확인했다.