이 글에서 얻는 것

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

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

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

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

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

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

  • Layered(기술 기준): controller/service/repository 같은 기술 레이어 중심

    • 장점: 단순, 초기에 빠름
    • 단점: 기능이 커지면 “서비스 레이어가 비대해지고” 도메인 규칙이 흩어지기 쉬움
  • Modular(도메인/기능 기준): 주문/결제/배송처럼 기능 단위로 모듈 경계

    • 장점: 변경 반경이 작아지고, 팀/기능 확장이 쉬움
    • 단점: 경계 설계를 잘못하면 모듈 간 결합이 더 심해질 수도 있음

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

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

기본 원칙:

  • UI/Interface(Controller, API) → Application(UseCase) → Domain(규칙) → Infrastructure(구현)

여기서 중요한 건 “Domain이 바깥(인프라)을 모르게” 하는 겁니다.

  • Domain에는 인터페이스(Port)를 두고,
  • Infrastructure에서 그 인터페이스를 구현(Adapter)합니다.

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

3) 패키지 설계 실무 팁

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

예:

  • order/*
  • payment/*
  • shipping/*

그리고 각 기능 안에서 레이어를 나눕니다(필요한 만큼만).

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

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

  • 진짜 공통인지(모든 컨텍스트에서 의미가 동일한지) 먼저 의심하고,
  • 가능하면 기능 모듈 내부로 두거나, 의존성 방향이 명확한 작은 라이브러리로 분리합니다.

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

모듈은 내부를 숨기고(캡슐화), 외부에는 최소한의 계약만 노출하는 편이 안전합니다.

  • 외부에서 바로 엔티티/리포지토리를 호출하게 만들지 말고,
  • UseCase/Facade/DTO 같은 “경계”를 둡니다.

4) 멀티모듈(Gradle/Maven)로 가는 기준

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

  • 빌드 시간이 너무 길어져서 생산성이 떨어진다
  • 모듈 경계를 강제하고 싶다(순환 의존/레이어 규칙을 물리적으로 차단)
  • 팀이 기능 단위로 나뉘었고, 변경 충돌이 잦다

흔한 분리 형태(예시):

  • :domain (순수 도메인)
  • :application (유스케이스)
  • :infrastructure (DB/외부 연동)
  • :api (웹/컨트롤러)

단, 처음부터 크게 쪼개기보다 “경계가 가장 명확한 것부터” 분리하는 게 성공 확률이 높습니다.

5) 흔한 실패 패턴

  • 모듈을 쪼갰는데 API/DTO/엔티티가 서로 엉켜서 결합도가 더 커진다
  • 순환 의존을 “편의상” 열어버린다(결국 다시 스파게티)
  • infrastructure가 domain으로 역침투(도메인이 JPA/HTTP 타입을 알게 됨)

연습(추천)

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