이 글에서 얻는 것

  • “패키지/레이어 구조"를 취향이 아니라 의존성 관리 도구로 이해하고, 변경이 퍼지지 않게 구조를 잡을 수 있습니다.
  • Domain을 인프라(ORM/HTTP/메시지)로부터 보호하는 의존성 방향(UI → App → Domain)을 적용할 수 있습니다.
  • 멀티모듈 분리를 “처음부터 거창하게"가 아니라, 필요해질 때 단계적으로 도입하는 기준이 생깁니다.
  • 실제 코드(패키지 구조, Gradle 멀티모듈, ArchUnit 의존성 검증)를 통해 즉시 적용할 수 있습니다.

0) 구조 설계의 목적은 ‘변경을 국소화’하는 것이다

좋은 구조는 한 문장으로 설명됩니다.

어떤 기능을 바꾸면, 바뀌는 파일의 반경이 작다.

반대로 나쁜 구조는 “어디를 고치면 어디가 깨질지” 예측이 어렵습니다. 그래서 패키지/모듈 설계는 결국 의존성 그래프를 다루는 일입니다.

구조 품질의 측정 기준

지표좋은 상태나쁜 상태
변경 반경기능 수정 시 1~3개 패키지 터치5개 이상 패키지 수정
순환 의존0개모듈 A↔B 순환 존재
빌드 영향변경 모듈만 재빌드전체 재빌드
팀 충돌PR이 겹치는 경우 드묾같은 파일에서 잦은 충돌

1) 레이어드 vs 모듈러: 어디를 기준으로 나누는가

1-1) Layered (기술 기준)

com.example.myapp
├── controller/
│   ├── OrderController.java
│   ├── PaymentController.java
│   └── ShippingController.java
├── service/
│   ├── OrderService.java
│   ├── PaymentService.java
│   └── ShippingService.java
├── repository/
│   ├── OrderRepository.java
│   └── PaymentRepository.java
└── domain/
    ├── Order.java
    └── Payment.java
  • 장점: 단순, 초기에 빠름, 팀 규모가 작을 때 효율적
  • 단점: 기능이 커지면 “서비스 레이어가 비대해지고” 도메인 규칙이 흩어지기 쉬움. OrderServicePaymentRepository를 직접 호출하기 시작하면 사실상 경계가 사라짐

1-2) Modular (도메인/기능 기준)

com.example.myapp
├── order/
│   ├── api/
│   │   └── OrderController.java
│   ├── application/
│   │   ├── OrderService.java
│   │   └── OrderFacade.java        ← 외부 모듈용 공개 API
│   ├── domain/
│   │   ├── Order.java
│   │   ├── OrderItem.java
│   │   └── OrderRepository.java    ← 인터페이스(Port)
│   └── infrastructure/
│       ├── JpaOrderRepository.java  ← 구현(Adapter)
│       └── OrderEventPublisher.java
├── payment/
│   ├── api/
│   ├── application/
│   ├── domain/
│   └── infrastructure/
└── shipping/
    ├── api/
    ├── application/
    ├── domain/
    └── infrastructure/
  • 장점: 변경 반경이 작아지고, 팀/기능 확장이 쉬움. 모듈 단위로 소유권 부여 가능
  • 단점: 경계 설계를 잘못하면 모듈 간 결합이 더 심해질 수도 있음

1-3) 실무 선택 기준

상황추천
팀 1~3명, 도메인 단순Layered로 시작
팀 3~5명, 도메인 복잡도 중간기능별 패키지 + Layered 내부
팀 5명+, 도메인 복잡Modular (Package-by-Feature)
독립 배포 필요멀티모듈 또는 MSA

실무에서는 “레이어 + 모듈(기능별)“을 섞되, 핵심은 의존성 규칙을 강제하는 것입니다.


2) 의존성 방향을 고정하라: Domain을 보호한다

2-1) 의존성 규칙

                    허용 방향 →
┌─────────────────────────────────────────┐
│  API (Controller)                       │
│    └──→ Application (UseCase/Service)   │
│           └──→ Domain (Entity/규칙)     │
│                  ↑                      │
│         Infrastructure (구현)           │
│           (JPA, HTTP Client, MQ)        │
└─────────────────────────────────────────┘

금지: Domain → Infrastructure (역전)
금지: Application → API (역전)
금지: 모듈 A ↔ 모듈 B (순환)
  • Domain에는 인터페이스(Port)를 두고,
  • Infrastructure에서 그 인터페이스를 구현(Adapter)합니다.

이렇게 하면 DB/메시지/외부 API가 바뀌어도 도메인 규칙은 흔들리지 않습니다.

2-2) Port/Adapter 실제 구현

// ── domain 패키지 ──
// Port (인터페이스): Domain이 정의
public interface OrderRepository {
    Order findById(OrderId id);
    void save(Order order);
    List<Order> findPendingOrders(LocalDate since);
}

public interface PaymentGateway {
    PaymentResult charge(PaymentRequest request);
    void refund(RefundRequest request);
}

// Domain Event (도메인이 발행)
public record OrderCompletedEvent(
    OrderId orderId, 
    UserId userId, 
    Money totalAmount,
    Instant completedAt
) {}
// ── infrastructure 패키지 ──
// Adapter (구현): Infrastructure가 제공
@Repository
@RequiredArgsConstructor
public class JpaOrderRepository implements OrderRepository {

    private final JpaOrderEntityRepository jpaRepo;
    private final OrderMapper mapper;

    @Override
    public Order findById(OrderId id) {
        return jpaRepo.findById(id.value())
                .map(mapper::toDomain)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @Override
    public void save(Order order) {
        jpaRepo.save(mapper.toEntity(order));
    }

    @Override
    public List<Order> findPendingOrders(LocalDate since) {
        return jpaRepo.findByStatusAndCreatedAtAfter(
            OrderStatus.PENDING, since.atStartOfDay()
        ).stream().map(mapper::toDomain).toList();
    }
}
// 외부 API Adapter
@Component
@RequiredArgsConstructor
public class TossPaymentGateway implements PaymentGateway {

    private final WebClient tossClient;
    private final MeterRegistry meterRegistry;

    @Override
    @CircuitBreaker(name = "tossPayment")
    @Retry(name = "tossPayment")
    public PaymentResult charge(PaymentRequest request) {
        return tossClient.post()
                .uri("/v1/payments/confirm")
                .bodyValue(toTossRequest(request))
                .retrieve()
                .bodyToMono(TossPaymentResponse.class)
                .map(this::toPaymentResult)
                .doOnSuccess(r -> meterRegistry.counter("payment.charge.success").increment())
                .doOnError(e -> meterRegistry.counter("payment.charge.failure").increment())
                .block(Duration.ofSeconds(5));
    }
}

핵심: Domain 패키지에는 JPA 어노테이션, Spring 어노테이션, 외부 SDK 타입이 하나도 없어야 합니다.


3) 패키지 설계 실무 팁

3-1) 기능(도메인) 기준으로 패키지를 먼저 나눈다

✅ 좋은 예: 기능 기준
com.example.shop.order.*
com.example.shop.payment.*
com.example.shop.shipping.*

❌ 나쁜 예: 기술 기준
com.example.shop.controller.*
com.example.shop.service.*
com.example.shop.entity.*

3-2) “공통(common)“은 마지막 수단이다

공통 모듈은 시간이 지나면 “모든 게 들어가는 쓰레기통"이 됩니다.

❌ 피해야 할 패턴
common/
├── utils/StringUtils.java
├── dto/ApiResponse.java
├── config/RedisConfig.java
├── domain/Money.java         ← 이건 진짜 공통?
└── exception/BusinessException.java

✅ 분리 기준
shared-kernel/                 ← 진짜 공통 (Money, ErrorCode)
├── Money.java
├── ErrorCode.java
└── ApiResponse.java

order/domain/                  ← 도메인별 로직은 각자
payment/domain/

판별 기준: “이 클래스를 모든 모듈이 같은 의미로 쓰는가?” → Yes면 공통, No면 각 모듈로.

3-3) 모듈의 ‘공개 API’를 최소화한다

// ── order 모듈의 공개 API (Facade) ──
// 외부 모듈은 이것만 호출
@Service
@RequiredArgsConstructor
public class OrderFacade {

    private final OrderService orderService;

    /**
     * 외부 모듈에서 주문 정보가 필요할 때 사용.
     * 도메인 엔티티가 아닌 DTO를 반환하여 결합도를 줄임.
     */
    public OrderSummaryDto getOrderSummary(Long orderId) {
        return orderService.getSummary(orderId);
    }

    /**
     * 결제 완료 이벤트 수신 시 주문 상태 변경.
     */
    @EventListener
    public void onPaymentCompleted(PaymentCompletedEvent event) {
        orderService.markPaid(event.orderId());
    }
}

규칙: 외부 모듈에서 바로 엔티티/리포지토리를 호출하지 말 것. UseCase/Facade/DTO 같은 “경계"를 둡니다.

3-4) 모듈 간 통신 패턴

패턴결합도사용 시점
Facade 직접 호출중간동기적, 트랜잭션 내
도메인 이벤트 (ApplicationEventPublisher)낮음비동기적, 최종 일관성 가능
공유 인터페이스 (shared-kernel)낮음계약 기반, API DTO 수준
메시지 큐 (Kafka/SQS)최저MSA, 물리적 분리
// 이벤트 기반 느슨한 결합 예시
// order 모듈에서 발행
@Service
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void completeOrder(OrderId orderId) {
        Order order = orderRepository.findById(orderId);
        order.complete();
        orderRepository.save(order);

        // 이벤트 발행 → payment, shipping 등 관심 모듈이 수신
        eventPublisher.publishEvent(new OrderCompletedEvent(
            order.getId(), order.getUserId(), order.getTotalAmount(), Instant.now()
        ));
    }
}

// shipping 모듈에서 수신
@Component
public class ShippingOrderEventListener {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCompleted(OrderCompletedEvent event) {
        shippingService.prepareShipment(event.orderId());
    }
}

4) Gradle 멀티모듈 실전 구성

4-1) 프로젝트 구조

my-shop/
├── build.gradle.kts
├── settings.gradle.kts
├── core/
│   ├── core-domain/         ← 순수 도메인 (의존성 최소)
│   │   └── build.gradle.kts
│   └── core-shared/         ← 공통 DTO, 에러코드
│       └── build.gradle.kts
├── module/
│   ├── module-order/
│   │   └── build.gradle.kts
│   ├── module-payment/
│   │   └── build.gradle.kts
│   └── module-shipping/
│       └── build.gradle.kts
├── infra/
│   ├── infra-jpa/           ← JPA 구현
│   │   └── build.gradle.kts
│   ├── infra-redis/         ← Redis 구현
│   │   └── build.gradle.kts
│   └── infra-external-api/  ← 외부 API 클라이언트
│       └── build.gradle.kts
└── app/
    └── app-api/             ← 부트 실행 + Controller
        └── build.gradle.kts

4-2) settings.gradle.kts

rootProject.name = "my-shop"

include(
    ":core:core-domain",
    ":core:core-shared",
    ":module:module-order",
    ":module:module-payment",
    ":module:module-shipping",
    ":infra:infra-jpa",
    ":infra:infra-redis",
    ":infra:infra-external-api",
    ":app:app-api"
)

4-3) 각 모듈 의존성

// core-domain/build.gradle.kts
// 외부 프레임워크 의존성 최소화
dependencies {
    // JDK만 사용, Spring/JPA 없음
}

// module-order/build.gradle.kts
dependencies {
    implementation(project(":core:core-domain"))
    implementation(project(":core:core-shared"))
    // Spring은 application 레이어에서만
    implementation("org.springframework:spring-context")
    implementation("org.springframework:spring-tx")
}

// infra-jpa/build.gradle.kts
dependencies {
    implementation(project(":core:core-domain"))
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    runtimeOnly("org.postgresql:postgresql")
}

// app-api/build.gradle.kts
dependencies {
    implementation(project(":core:core-domain"))
    implementation(project(":core:core-shared"))
    implementation(project(":module:module-order"))
    implementation(project(":module:module-payment"))
    implementation(project(":module:module-shipping"))
    implementation(project(":infra:infra-jpa"))
    implementation(project(":infra:infra-redis"))
    implementation(project(":infra:infra-external-api"))
    implementation("org.springframework.boot:spring-boot-starter-web")
}

4-4) 의존성 방향 강제 (Gradle 빌드 실패)

// root build.gradle.kts
subprojects {
    afterEvaluate {
        // core-domain은 다른 모듈에 의존할 수 없음
        if (project.path == ":core:core-domain") {
            configurations.all {
                dependencies.forEach { dep ->
                    if (dep is ProjectDependency && dep.dependencyProject.path.startsWith(":module")) {
                        throw GradleException(
                            "core-domain은 module에 의존할 수 없습니다: ${dep.dependencyProject.path}"
                        )
                    }
                }
            }
        }
    }
}

5) ArchUnit으로 의존성 규칙 자동 검증

5-1) 의존성

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.3.0</version>
    <scope>test</scope>
</dependency>

5-2) 핵심 아키텍처 테스트

@AnalyzeClasses(packages = "com.example.shop")
public class ArchitectureTest {

    // 1. 도메인은 인프라를 몰라야 한다
    @ArchTest
    static final ArchRule domain_should_not_depend_on_infrastructure =
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..infrastructure..", "..api..");

    // 2. 도메인은 Spring/JPA 프레임워크에 의존하지 않는다
    @ArchTest
    static final ArchRule domain_should_not_use_spring =
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage(
                "org.springframework..",
                "jakarta.persistence..",
                "javax.persistence.."
            );

    // 3. 순환 의존 금지
    @ArchTest
    static final ArchRule no_cycles =
        slices().matching("com.example.shop.(*)..")
            .should().beFreeOfCycles();

    // 4. Controller는 Service/UseCase만 호출
    @ArchTest
    static final ArchRule controllers_should_only_call_services =
        classes()
            .that().resideInAPackage("..api..")
            .should().onlyDependOnClassesThat()
            .resideInAnyPackage(
                "..api..", "..application..", "..domain..",
                "org.springframework..", "java..", "jakarta.."
            );

    // 5. Repository 구현은 infrastructure에만 존재
    @ArchTest
    static final ArchRule repository_impl_in_infrastructure =
        classes()
            .that().haveNameMatching(".*RepositoryImpl")
            .or().haveNameMatching("Jpa.*Repository")
            .should().resideInAPackage("..infrastructure..");

    // 6. 레이어드 아키텍처 전체 검증
    @ArchTest
    static final ArchRule layered_architecture =
        layeredArchitecture()
            .consideringAllDependencies()
            .layer("API").definedBy("..api..")
            .layer("Application").definedBy("..application..")
            .layer("Domain").definedBy("..domain..")
            .layer("Infrastructure").definedBy("..infrastructure..")
            .whereLayer("API").mayNotBeAccessedByAnyLayer()
            .whereLayer("Application").mayOnlyBeAccessedByLayers("API")
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure")
            .whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer();
}

5-3) 모듈 경계 검증

@AnalyzeClasses(packages = "com.example.shop")
public class ModuleBoundaryTest {

    // 모듈 간 직접 접근 금지 (Facade를 통해서만)
    @ArchTest
    static final ArchRule order_module_encapsulation =
        classes()
            .that().resideInAPackage("com.example.shop.order.domain..")
            .or().resideInAPackage("com.example.shop.order.infrastructure..")
            .should().onlyBeAccessed()
            .byClassesThat().resideInAPackage("com.example.shop.order..");

    // payment 모듈이 order 내부에 직접 접근하면 안 됨
    @ArchTest
    static final ArchRule payment_should_not_access_order_internals =
        noClasses()
            .that().resideInAPackage("com.example.shop.payment..")
            .should().dependOnClassesThat()
            .resideInAnyPackage(
                "com.example.shop.order.domain..",
                "com.example.shop.order.infrastructure.."
            );
}

6) 멀티모듈 도입 기준과 단계적 분리

6-1) 언제 도입하는가

멀티모듈은 장점이 있지만 “복잡도"를 추가합니다. 다음이 명확할 때 도입하는 편이 좋습니다.

신호설명임계점
빌드 시간전체 빌드가 너무 느림5분+
코드 충돌같은 패키지에서 PR 충돌 빈번주 3회+
의존성 침투Domain에 JPA 어노테이션 침투1건이라도
팀 확장기능별 팀 분리 필요3팀+

6-2) 단계적 분리 전략

Phase 1: 패키지 분리 (0원, 0리스크)
├── 기능별 패키지로 재배치
├── 순환 의존 제거
└── ArchUnit 테스트 추가

Phase 2: Domain 모듈 추출 (낮은 리스크)
├── :core-domain 분리 (순수 Java)
├── 빌드에서 Spring/JPA 의존성 차단
└── Port 인터페이스 정의

Phase 3: Infrastructure 분리 (중간 리스크)
├── :infra-jpa, :infra-redis 분리
├── Adapter 구현 이동
└── 테스트에서 in-memory 구현 활용

Phase 4: 기능 모듈 분리 (주의 필요)
├── :module-order, :module-payment 분리
├── 모듈 간 통신을 이벤트/Facade로 전환
└── 독립 빌드/배포 가능 상태

7) 흔한 실패 패턴과 해결

안티패턴증상해결
God Module하나의 모듈이 모든 의존성 보유기능별 분리 + 의존성 역전
순환 의존A→B→C→A인터페이스 추출, 이벤트 전환
공통 쓰레기통common/에 아무거나 추가shared-kernel 최소화
인프라 역침투Domain에 @Entity, @TransactionalArchUnit으로 빌드 시 차단
과도한 분리모듈 20개, 클래스 3개씩합칠 것은 합치기
DTO 복사 지옥모듈마다 같은 DTO 중복shared 모듈에 DTO 정의

순환 의존 끊기 실전

// ❌ 순환: Order → Payment, Payment → Order
class OrderService {
    @Autowired PaymentService paymentService;  // Order → Payment
}
class PaymentService {
    @Autowired OrderService orderService;      // Payment → Order ← 순환!
}

// ✅ 이벤트로 끊기
class OrderService {
    @Autowired ApplicationEventPublisher publisher;

    public void completeOrder(OrderId id) {
        // ... 주문 완료 처리
        publisher.publishEvent(new OrderCompletedEvent(id));
    }
}

class PaymentEventListener {
    @EventListener
    public void onOrderCompleted(OrderCompletedEvent event) {
        paymentService.processPayment(event.orderId());
    }
}

8) 운영 체크리스트

초기 도입 시

  • 현재 순환 의존 파악 (jdepend, degraph, 또는 IntelliJ 분석)
  • ArchUnit 기본 테스트 3개 추가 (레이어/순환/프레임워크 격리)
  • 기능별 패키지 재배치 (Layered → Package-by-Feature)
  • 공개 API(Facade) 정의 + 외부 모듈 직접 접근 차단

멀티모듈 전환 시

  • Gradle/Maven 모듈 분리 + 의존성 방향 검증
  • core-domain에서 Spring/JPA 의존성 완전 제거
  • 각 모듈 단위 테스트 독립 실행 확인
  • CI에서 ArchUnit + 의존성 규칙 자동 검증

성숙 단계

  • 모듈별 코드 소유권(CODEOWNERS) 설정
  • 변경 반경 지표 정기 측정 (PR당 변경 파일 수)
  • 불필요한 모듈 간 의존성 정기 정리 (분기 1회)

연습(추천)

  • 현재 프로젝트에서 “가장 자주 바뀌는 기능” 1개를 골라, 기능 기준 패키지로 재배치해보기(변경 반경이 줄어드는지 관찰)
  • 도메인 레이어에 Port 인터페이스를 두고, 인프라 구현을 어댑터로 분리해보기(DB/외부 API 중 하나)
  • ArchUnit 테스트를 3개 추가하고, CI에서 자동으로 의존성 규칙 위반을 잡아보기
  • 순환 의존을 찾아(IDE/빌드 도구), 끊는 방법(인터페이스 분리/이벤트/DTO) 2가지를 시도해보기

관련 심화 학습