지금까지 세운 표준들을 다시 읽어보면 같은 문장이 반복해서 나온다.

그런데 그 패키지가 어떻게 생겼는지는 어디에도 없다. 정의되지 않은 것을 네 개 표준이 근거로 삼고 있는 셈이다. 게다가 이 규칙들은 전부 사람이 지켜야 하는 약속이라, 어겨도 그냥 컴파일된다.

DTO 생성 표준에서 필드를 final로 만든 이유는 “규율이 아니라 컴파일러가 강제한다”였다. 패키지도 같은 방식으로 쓸 수 있다. 이 글은 계층 위반을 컴파일 에러로 만드는 것을 목표로 패키지 구조를 정한다.


원칙 1. 도메인별로 나눈다

최상위를 계층이 아니라 도메인으로 나눈다.

❌ 계층별                        ✅ 도메인별
com.example.shop                com.example.shop
├── controller/                 ├── order/
├── service/                    ├── product/
├── repository/                 ├── user/
└── domain/                     └── common/

계층별로 나누면 주문 기능 하나를 고칠 때 네 개 패키지를 오가야 하고, 각 패키지에는 서로 상관없는 도메인이 뒤섞인다. 무엇보다 중요한 문제는 따로 있다 — OrderEntityrepository 패키지에 있고 OrderServiceservice 패키지에 있으면, Entity를 public으로 열어야만 Repository가 쓸 수 있다. 열린 순간 Service도 Controller도 쓸 수 있게 된다.

도메인별로 모으면 이 문제가 사라진다. 그리고 나중에 도메인 하나를 별도 모듈이나 서비스로 떼어낼 때도 폴더 하나만 옮기면 된다.


원칙 1-1. 도메인을 쪼개는 기준은 크기가 아니라 자기 유스케이스다

원칙 1이 「도메인별로 나눈다」로 최상위를 정했다. 그러면 한 도메인이 커졌을 때 그것을 다시 쪼개는가가 남는다.

실제로 그런 폴더가 생겼다. 자바 파일 669개 중 socialprovider116개로 저장소의 17%다. 두 번째인 content(77)의 1.5배다.

크기를 기준으로 삼는 안을 먼저 재봤다. 안에 든 것을 성격별로 갈라 보니 이렇다.

수집     정부 사이트에서 기관 정보를 긁어온다     17개
경쟁사   기관을 회사에 묶는다                     14개
남는 것                                          85개

둘을 떼어내도 85개로 여전히 저장소 최대다. 그리고 그 85개의 정체가 답을 정했다 — 41개가 「속성 8개 × 5겹」이다.

Satisfaction  →  Response · Domain · View · Entity · MapperResult

부가서비스·실적·만족도·서비스군·가격·인력 셋. 여덟 개가 전부 정확히 5겹이다. 표준이 시킨 대로 한 결과지 잘못 쌓인 것이 아니다. 그리고 속성은 기관 없이 존재하지 않는다 — 「만족도」만 따로 조회하는 화면이 없고 기관 상세의 한 칸이다. 쪼갤 수 있는 것이 아니다.

그래서 크기를 기준에서 뺀다. 파일이 몇 개인지는 그 도메인이 속성을 몇 개 가졌는지를 말할 뿐, 쪼개야 한다는 뜻이 아니다.

남는 기준은 하나다 — 자기 유스케이스가 있는가. 자기 application 유스케이스가 없는 개념은 도메인이 아니라 그 도메인의 테이블이다. socialprovider에서 나갈 수 있는 것이 수집과 경쟁사뿐인 이유가 그것이다.

떼어낸 것은 계층 넷을 다 갖는다. 컨트롤러도 자기 것을 만든다. 지금 컨트롤러 하나에 엔드포인트 여덟이 붙어 있는데 유스케이스대로 4·2·2로 이미 갈려 있다.

URL은 바뀌지 않는다. POST /api/v1/admin/social-providers/collect는 코드에 글자로 적혀 있고 파일이 어느 패키지에 있는지와 무관하다. 부르는 쪽은 아무것도 안 바뀐다.

api만 원래 자리에 두는 안은 버렸다. 그러면 승격한 폴더에 컨트롤러만 없고, 「이 기능의 API가 어디 있나」의 답이 도메인마다 달라진다. 찾을 때마다 「이건 승격된 것이었나」를 먼저 떠올려야 한다.

대가는 컨트롤러 파일이 는다는 것이다. 그리고 승격은 되돌리기 비싸다 — 패키지를 옮기면 import가 전부 바뀐다. 그래서 유스케이스가 생기기 전에 미리 쪼개지 않는다.


원칙 2. 도메인 안은 네 겹으로 나눈다

지금까지 세운 의존 규칙과 1:1로 맞춘다.

order/
├── api/            Controller, XxxRequest, XxxResponse
├── application/    XxxUseCase (인터페이스), XxxService, XxxCommand, XxxQuery
├── domain/         XxxDomain, XxxRepository (인터페이스)
└── infra/          XxxEntity, XxxMapper, Repository 구현체
graph LR API["api
Controller, Request, Response"] --> APP["application
UseCase 인터페이스, Service"] APP --> DOM["domain
Domain, Repository 인터페이스"] INF["infra
Entity, Mapper, 구현체"] --> DOM style API fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style APP fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style DOM fill:#1a202c,stroke:#ed8936,stroke-width:3px,color:#e2e8f0 style INF fill:#2d3748,stroke:#f56565,stroke-width:2px,color:#e2e8f0

api가 담는 것은 JSON을 내보내는 Controller까지다. 템플릿 엔진으로 HTML을 렌더하는 Controller는 {도메인}/web에 따로 둔다. apiwebapplication을 향하는 방향이 같은 형제이므로 계층이 늘어나는 것은 아니고, bulk·batch와 마찬가지로 네 겹이 기본형이고 SSR을 쓰는 도메인만 web을 더 갖는다. 가른 이유와 다른 안들은 컨트롤러 패키지 분리 표준에서 다룬다.

apiapplication은 그 안을 접미사로 한 겹 더 나눈다. apicontroller·request·response, applicationusecase·command·query·service다. domaininfra는 나누지 않는다 — 나누면 Entitypublic으로 열어야 하고, 애초에 알파벳 정렬이 이미 개념별로 묶어주고 있어서 얻을 것도 없다. 한 겹 더 파는 순간 접근 제어가 끊기므로 이 표준의 「apipublic」도 거기서 다시 판정된다. 계층별로 답이 갈린 실측과 판정 기준은 접미사 패키지 분할 표준에 있다.

화살표가 전부 domain을 향한다. domain은 아무 쪽도 가리키지 않는다. 이게 DTO 네이밍 표준에서 “Repository 인터페이스는 Service가 정의하고 구현체는 갈아 끼운다”고 한 것의 물리적 형태다.

여기서 “domain이 아무 쪽도 가리키지 않는다”는 같은 도메인 안의 api·application·infra를 말한다. common은 예외다. domain의 Repository 인터페이스는 common/responsePagingResult를 반환하고, Domaincommon/errorErrorCodeException을 던진다. 이게 허용되는 이유는 common에 두는 것들이 어떤 계층도 아니기 때문이다 — 예외 처리 표준에서 ErrorCode의 HTTP 상태를 HttpStatus가 아니라 int로 담기로 한 게 정확히 이 지점을 위한 결정이었다. common이 프레임워크나 특정 계층에 의존하지 않는 한, 모든 패키지가 common을 봐도 의존 방향은 깨지지 않는다.

infradomain을 향한다는 점이 핵심이다. 구현이 추상을 의존하지, 그 반대가 아니다.

네 겹은 기본형이고, 대량 처리가 필요한 도메인만 두 패키지를 더 갖는다. 애그리거트 경계를 벗어나는 벌크 연산은 {도메인}/bulk에, Domain을 하나씩 거치는 잡은 {도메인}/batch에 둔다. 이어지는 설명은 bulk 이야기이고, batch의 참조 범위와 규칙은 배치 실행 표준에서 다룬다. bulkinfra에 둘 수도 있었지만 그러면 같은 패키지의 Repository 구현체가 벌크 Mapper를 주입받을 수 있다 — Service는 못 부르는데 구현체는 부를 수 있는 구멍이 생긴다. 그래서 격리 대상은 Service가 아니라 Repository 구현체다. bulkdomaincommon만 참조하고 infra와는 서로 보지 않는 형제 패키지이며, 자기 데이터 타입(XxxBulkMapperParam 등)을 자기 안에 갖는다. 자세한 내용과 이유는 Repository 설계 표준에서 다룬다.


원칙 3. infra는 전부 package-private으로 막는다

여기서 규칙이 문서가 아니라 코드가 된다. public을 붙이지 않으면 같은 패키지에서만 보인다.

// order/infra/OrderEntity.java
@Entity
@Table(name = "orders")
class OrderEntity {                     // public이 아니다
    // ...
}

// order/infra/OrderJpaEntityRepository.java
interface OrderJpaEntityRepository extends JpaRepository<OrderEntity, Long> { }

// order/infra/OrderEntityMapper.java
@Component
class OrderEntityMapper { ... }

// order/infra/OrderJpaRepository.java
@Repository
@RequiredArgsConstructor
class OrderJpaRepository implements OrderRepository {   // 이것도 public이 아니다

    private final OrderJpaEntityRepository jpaEntityRepository;
    private final OrderEntityMapper mapper;
    // ...
}

구현체까지 package-private으로 둘 수 있는 이유는 주입이 타입이 아니라 인터페이스로 이루어지기 때문이다. Service는 OrderRepository만 알면 되고, 그 인터페이스는 domain 패키지에서 public이다.

이제 Service에서 Entity를 쓰려고 하면 이렇게 된다.

// order/application/OrderService.java
import com.example.shop.order.infra.OrderEntity;   // ❌ 컴파일 에러

“Entity는 패키지 밖으로 나가지 않는다”가 약속이 아니라 사실이 된다. MyBatis의 MapperResult도 같은 자리에 두면 동일하게 막힌다.

예외 번역도 여기서 강제된다

예외의 종류에서 “영속성 예외를 Repository 구현체에서 번역한다”고 정했는데, 이 구조에서는 번역하지 않으면 애초에 코드가 안 된다. DataIntegrityViolationException을 Service에서 잡으려 해도, 그걸 던지는 원인인 Entity 조작 자체가 infra 안에서만 가능하기 때문이다.


원칙 4. 도메인끼리는 application에서만, 인터페이스로 만난다

주문이 상품 정보를 필요로 하는 경우다. 규칙은 둘이다.

  • application 계층에서만 다른 도메인을 호출한다
  • 상대의 application이 공개한 것만 쓴다. domain이나 infra를 직접 건드리지 않는다
// order/application/OrderService.java
@Service
@RequiredArgsConstructor
class OrderService implements OrderCommandUseCase, OrderQueryUseCase {

    private final OrderRepository orderRepository;   // 내 도메인
    private final ProductProvider productProvider;   // 상품 도메인의 공개 창구
}
// ❌ 다른 도메인의 내부를 직접 본다
import com.example.shop.product.domain.ProductDomain;
import com.example.shop.product.infra.ProductEntity;   // 애초에 컴파일도 안 된다

infra는 package-private이라 자동으로 막히지만, domain은 같은 도메인 안에서 public이어야 하므로 다른 도메인에서도 보인다.import order.domain.OrderDomain은 컴파일된다 — 가시성으로 막을 수 없는 유일한 구멍이다.

이 구멍은 타입으로 막는다. 도메인 간 창구(XxxProvider)의 반환 타입을 애초에 XxxDomain이 아니라 XxxSnapshot으로 두면, 상대 도메인이 Domain을 받을 경로 자체가 없어진다. 위 예시의 ProductProvider가 그것이고, Snapshot을 만드는 팩토리는 제공 도메인 안에서만 호출할 수 있게 package-private으로 둔다. 기준은 UseCase 인터페이스 표준에 있다.

도메인 간 순환 참조는 금지한다. 주문이 상품을 부르는데 상품도 주문을 부른다면, 둘 중 하나가 잘못 나뉜 것이거나 제3의 도메인이 필요하다는 신호다.

Entity 연관관계도 도메인 경계에서 끊긴다

infra를 package-private으로 막으면 부수 효과가 하나 생긴다. 다른 도메인의 Entity를 참조할 수 없다.

// order/infra/OrderEntity.java
@ManyToOne
private ProductEntity product;    // ❌ product/infra 에 있고 package-private → 컴파일 에러

이건 막혀서 곤란한 게 아니라 막아야 하는 것이 막힌 것이다. 도메인 간 Entity 연관관계를 만들면 주문과 상품이 DB 레벨에서 묶여 나중에 떼어낼 수 없고, 지연 로딩 프록시가 도메인 경계를 넘나들며 예상하지 못한 쿼리를 만든다.

도메인을 넘는 참조는 객체가 아니라 ID로 한다.

// ✅ order/infra/OrderEntity.java
@Column(name = "product_id")
private Long productId;

같은 도메인 안에서는 제약이 없다. OrderEntityOrderItemEntity는 둘 다 order/infra에 있으므로, package-private이어도 서로 참조되고 @OneToMany가 정상 동작한다.

// order/infra — 같은 패키지라 문제없다
@Entity
class OrderEntity {

    @OneToMany(mappedBy = "order")
    private List<OrderItemEntity> items;
}

패키지가 애그리거트 경계 역할을 한다. 한 패키지 안은 객체 참조, 밖은 ID 참조다. 이 규칙은 문서가 아니라 컴파일러가 지켜준다.

그 애그리거트를 어디서 끊을지는 애그리거트 경계 표준에서 다룬다.

여러 도메인을 조인하는 조회는 Projection으로 뺀다

연관관계를 못 쓰니 조인이 필요한 조회를 따로 처리해야 한다. 조회를 주도하는 도메인의 infra에 전용 경로를 둔다.

// order/infra/OrderSummaryProjection.java
interface OrderSummaryProjection {      // package-private 인터페이스

    Long getOrderId();
    String getProductName();            // product 테이블에서 조인해 온 값
    String getUserName();               // user 테이블에서 조인해 온 값
}
// order/infra/OrderJpaEntityRepository.java
@Query(value = """
        SELECT o.id AS orderId, p.name AS productName, u.name AS userName
        FROM orders o JOIN products p ON p.id = o.product_id
                      JOIN users u ON u.id = o.user_id
        """, nativeQuery = true)
List<OrderSummaryProjection> findSummaries();

클래스가 아니라 인터페이스인 것에 이유가 있다. 클래스로 만들면 이걸 무엇으로 채우느냐가 문제가 된다 — MyBatis 기본 매핑은 기본 생성자와 setter를 요구하고, <constructor>@ConstructorResult로 생성자를 쓰면 열 순서에 위치로 묶인다. 위 예시에서 productNameuserName은 둘 다 String이라, 순서가 뒤바뀌어도 컴파일되고 실행되며 값만 잘못 들어간다. assert 표준이 경계한 “조용히 잘못되는 코드”다.

인터페이스로 두면 Spring Data가 별칭을 getter 이름에 이름으로 매칭하므로 순서 문제가 없고, 생성자도 setter도 없으니 DTO 생성 표준의 예외를 늘리지 않아도 된다.

대신 이 방식은 JPA 전용이다. 조인 조회를 MyBatis로 한다면 결과 타입은 XxxProjection이 아니라 XxxMapperResult다 — MyBatis 결과 타입에는 이미 setter 예외가 있으므로 그쪽 규칙을 그대로 쓴다. XxxProjection은 JPA 이름이고, XxxMapperResult는 MyBatis 이름이다.

네이티브 쿼리로 조인해서 채우고, Repository 구현체가 Domain이나 전용 결과 타입으로 변환해 내보낸다. DTO 네이밍 표준에서 “집계·조인 조회는 XxxProjection으로 이름을 분리한다”고 한 것이 바로 이 자리다.

연관관계를 우회하려고 억지로 쓰는 게 아니다. 목록이나 통계처럼 여러 도메인의 값을 한 화면에 보여주는 조회는 애초에 애그리거트 단위 조회와 성격이 다르다.

단건이나 소량이면 Service에서 각 도메인을 호출해 조합해도 된다. 기준은 쿼리가 반복되는가다. 목록을 돌면서 도메인별 조회를 반복하고 있다면 Projection으로 옮길 때다.

그런데 Projectioninfra에 package-private으로 있다. 여기까지 정하고 나서도 그 결과가 무슨 타입으로 밖에 나가는지는 비어 있었다. 위 문장의 “전용 결과 타입”이 그 자리인데 이름도 위치도 정한 적이 없었다. 나중에 조회 전용 타입 표준에서 domainXxxView로 확정했다. 구현체가 Projection·MapperResultView로 바꿔 내보내고, 조합으로 채울 때도 같은 View를 쓴다 — 그래서 위 문장의 “옮긴다”가 채우는 주체만 바꾸는 일이 된다.


원칙 5. common에는 도메인이 없는 것만 둔다

common/
├── response/
│   ├── HttpApiResponse.java
│   ├── ServiceResult.java
│   ├── ServiceResults.java
│   └── PagingResult.java        ← domain~application 공유
├── error/
│   ├── ErrorCode.java
│   ├── ErrorCodeException.java
│   └── ApiExceptionHandler.java
└── security/
    ├── SecurityResponseWriter.java
    ├── ApiAuthenticationEntryPoint.java
    └── ApiAccessDeniedHandler.java

기준은 “이 클래스가 특정 도메인이 없어져도 남아야 하는가”다. 주문 도메인을 통째로 지워도 HttpApiResponse는 남아야 하므로 common이다.

ErrorCode는 인정하고 가는 타협이다

원칙대로라면 common은 어떤 도메인도 몰라야 한다. 그런데 ErrorCode에는 STOCK_SHORTAGE, ORDER_NOT_FOUND 같은 도메인별 값이 들어간다.

도메인별로 ErrorCode를 쪼개고 common에는 인터페이스만 두는 방법도 있다. 하지만 그러면 코드 번호 중복을 사람이 관리해야 한다. 주문과 결제가 각자 409001001을 쓰는 사고를 막을 방법이 없어진다.

한 곳에 모아두면 중복이 한눈에 보인다. 그래서 이 결합은 받아들이기로 했다. 타입 의존이 아니라 상수 나열이라는 점에서 실제 결합도 얕다.

대신 코드에 도메인 칸을 따로 두고, 그 번호는 DomainCode enum 한 곳에서 관리한다.

// common/error/DomainCode.java
public enum DomainCode {

    COMMON(0),      // 특정 도메인에 속하지 않는 실패
    ORDER(1),
    PRODUCT(2),
    USER(3);
}
// common/error/ErrorCode.java — 409, 현재 상태로 불가능
STOCK_SHORTAGE(409, DomainCode.ORDER, 1, "재고가 부족합니다."),          // → 409001001
ORDER_ALREADY_CONFIRMED(409, DomainCode.ORDER, 2, "이미 확정된 주문입니다."),
DUPLICATE_EMAIL(409, DomainCode.USER, 1, "이미 가입된 이메일입니다."),    // → 409003001

도메인이 늘어날 때 손대는 곳은 DomainCode 한 파일이다. ErrorCode에는 DomainCode.ORDER라고 적을 뿐 숫자를 쓰지 않으므로, 번호를 잘못 세는 실수가 ErrorCode 쪽으로 번지지 않는다. 코드 구성과 자릿수 상한은 예외 처리 표준에서 다룬다.


원칙 6. 경계는 ArchUnit 이 지킨다

원칙 1~5가 경계를 그었다. 그런데 그것을 지키는 것이 컴파일러뿐이다. public 하나를 붙이면 그냥 통과한다.

실제로 어느 규칙이 새는지 재봤다.

컴파일러가 잡는 것
  infra 는 전부 package-private       174개 중 위반 0
  XxxService 도 package-private        35개 중 위반 0

컴파일러가 못 잡는 것
  계층 의존 방향(api→infra 등)          위반 0
  도메인끼리는 application 에서만       18곳 중 위반 1~2

갈리는 자리가 정확히 드러났다. 컴파일러가 붙들고 있는 209개는 하나도 안 샜고, 컴파일러가 모르는 자리에서만 샜다.

샌 것은 이것이다.

// auth/api/response/AdminRoleResponse.java
import com.taecobug.pcs.adminrole.application.AdminRoleSnapshot;

Snapshotapplication에서 받아 변환해 넘겨야 하는데 api가 직접 잡았다. 원칙 4를 어긴 자리이고, 사람 눈으로는 오늘 처음 봤다.

안 A — 사람이 리뷰에서 본다. 도구가 안 늘고 판단이 유연하다. 버린 이유는 못 봤기 때문이다. 18곳 중 하나인데 그동안 아무도 못 잡았다. 파일이 669개인 저장소에서 import 한 줄을 눈으로 거르는 것은 되는 척만 하는 규칙이다.

안 B — 규칙을 한 벌씩 넣는다. 검사를 켜면 기존 위반이 쏟아져 예외 처리만 하다 끝나는 것이 흔한 실패라, 보통은 이게 맞다. 여기서는 버린다 — 지금 위반이 거의 0이라 다 넣어도 안 터진다. 그리고 지금이 제일 싼 시점이다. 코드가 더 늘면 그때는 「다 고치고 켜기」와 「예외 목록을 안고 켜기」 중에 골라야 한다.

골랐다 — ArchUnit 을 넣고 세 벌을 한꺼번에 켠다. 계층 의존 방향, package-private, 도메인 간 참조다. 안 A가 못 지켜주는 것은 매번 걸리는 것이고, 안 B가 못 지켜주는 것은 0건일 때의 싼 값이다.

빼는 것은 목록으로 적는다. 서블릿 필터처럼 계층 구조 밖에서 도는 것이 있다 — ApiAuditLogFilter가 컨트롤러 전에 돌면서 로그인 사용자를 읽는데, 거기엔 application을 거칠 자리가 없다. 그런 예외를 조용히 두지 않는다. 규칙에서 빼되 왜 뺐는지를 그 자리에 적는다. 안 적으면 다음 사람이 「여기는 원래 되는구나」로 읽는다.

대가는 셋이다. 의존성이 하나 늘고, 테스트가 느려지고(전 클래스를 훑는다), 규칙을 코드로 적어야 한다 — 글로 적는 것보다 품이 든다. 다만 셋 다 한 번 내는 값이고, 안 내면 규칙이 지켜지는지 영영 모른다.


원칙 7. 테스트도 프로덕션과 같은 도메인 구조를 따른다

원칙 1~6은 전부 src/main 이야기였다. src/test 를 어떻게 나누는지는 정한 적이 없다. 표준 전체를 훑어도 그 자리가 비어 있다.

규칙은 프로덕션과 같다. src/test/java 아래도 최상위를 도메인으로 나누고, 테스트 파일은 대상이 사는 도메인 패키지에 둔다. 종류별 폴더(unit/, integration/, contract/)로 모으지 않는다. 어느 도메인에도 속하지 않는 테스트 지원 타입만 common에 둔다.

src/test/java/com.example.shop
├── order/     OrderServiceTest, OrderContract, OrderControllerTest
└── product/   ProductServiceTest, ProductContract

  contract/  unit/  integration/   ← 만들지 않는다

그러면 도메인 폴더가 그대로 「이 도메인에 테스트가 있는가」의 답이 된다. 폴더가 지표라서 어긋나면 집계에서 티가 난다.

원칙 3의 package-private이 이 규칙의 절반을 붙든다. infra를 만지는 테스트는 같은 패키지가 아니면 애초에 컴파일이 안 된다. 종류별 폴더로 모으려면 infrapublic으로 열어야 하고, 그 순간 원칙 3이 깨진다. 테스트를 잘못 두는 것과 접근 제어가 무너지는 것이 같은 사건이 된다.

나머지 절반은 안 붙든다. 컨트롤러 테스트도 계약 enum도 public 타입만 보므로 어디에 둬도 컴파일된다. 거기는 약속이다.

대가는 이 표준의 범위가 src/main 에서 src/test 로 넓어지는 것이다. 지금까지 프로덕션 패키지만 다뤘고, 문서 하나가 그만큼 커진다. API 테스트 계약표 표준에는 「계약 enum의 자리는 여기를 따른다」 한 줄만 걸어 규칙을 두 벌로 만들지 않는다.


원칙 7-1. 계약표만 제자리로 옮기는 안은 원인을 안 고쳐서 버렸다

원칙 7의 빈자리는 숫자가 틀리고 나서야 드러났다. 테스트 냄새 어휘 표준이 기준값을 재면서 무테스트 도메인을 셌는데, 도메인 폴더로 세니 셋이 0으로 나왔다. 보정하니 하나였다. 두 도메인은 API 테스트 계약표로 테스트가 돌고 있었는데, 그 계약 enum이 도메인 폴더가 아니라 contract/ 에 따로 살아서 폴더가 비어 보였다.

처음 물은 건 좁은 질문이었다 — 계약표를 도메인 아래로 내릴 것인가, 셀 때만 이어 붙일 것인가.

안 A — 자리는 그대로 두고 셀 때 이어 붙인다.

competitor/                 (비어 있음)      → 무테스트로 세어진다
contract/
  CompetitorContract.java   competitor 것이지만 여기 산다

세는 규칙에 한 줄: contract/ 의 XxxContract 는 Xxx 도메인의 테스트로 친다

얻는 것은 파일을 안 옮기는 것과, 계약이 한곳에 모여 있어 「지금 계약이 몇 개인가」를 폴더 하나로 보는 것이다.

버린 이유는 세는 사람이 그 예외를 기억해야만 값이 맞는다는 것이다. 실제로 처음 셀 때 그걸 몰라서 3이 나왔고, 어휘 표준이 덮은 것은 「어떻게 셌는지를 같이 적는다」까지다. 다음에 세는 사람이 그 문장을 안 읽으면 또 3이 나온다. 같은 모양을 계약 커버리지 게이트 표준이 이미 한 번 버렸다 — 「면제 목록을 계약과 떨어뜨려 두지 않는다. 떨어져 살면 한쪽만 고쳤을 때 어긋난다.」

안 B — 계약표만 도메인 아래로 내린다.

얻는 것은 지금 틀린 숫자가 그 자리에서 맞아지는 것이고, 한 번 옮기면 끝난다. 계약 enum은 원래 도메인마다 하나씩이라(@Cellstate가 도메인 enum에 묶인다) 옮길 데가 이미 정해져 있다.

버린 이유는 원인이 계약표가 아니기 때문이다. 표준을 훑어보니 src/test 의 구조를 정한 곳이 아예 없었다. 계약표만 제자리로 보내면 그 자리는 메워지지만 원인은 남는다. 다음에 생기는 테스트 산출물이 또 도메인 밖으로 새고, 그때 또 하나씩 보정하게 된다.

골랐다 — 안 C, 곧 원칙 7이다. 안 A가 못 지켜주는 것은 사람이 안 기억해도 맞는 것이고, 안 B가 못 지켜주는 것은 다음 산출물이다. 좁은 질문에 좁게 답하지 않고 테스트 전체로 넓힌 이유가 그것이다.


전체 구조

com.example.shop
├── common/
│   ├── response/        HttpApiResponse, ServiceResult ...
│   ├── error/           ErrorCode, DomainCode, ErrorCodeException, ApiExceptionHandler
│   └── security/        EntryPoint, AccessDeniedHandler
│
├── order/
│   ├── api/             OrderController, CreateOrderRequest, OrderResponse
│   ├── application/     OrderService, CreateOrderCommand, FindOrderQuery
│   ├── domain/          OrderDomain, OrderRepository(interface), OrderSummaryView
│   ├── infra/           OrderEntity*, OrderEntityMapper*, OrderJpaRepository*
│   ├── bulk/            OrderBulkMapper*, OrderBulkStatusWriter*        (대량 처리 시에만)
│   └── batch/           OrderDiscountProcessor*, OrderDiscountJobConfig* (대량 처리 시에만)
│
├── product/
│   └── (동일 구조)
│
└── user/
    └── (동일 구조)

* package-private

테스트는 이 구조를 그대로 비춘다. 종류가 아니라 도메인이 최상위다.

src/test/java/com.example.shop
├── common/              테스트 지원 타입 (도메인 이름이 안 붙는 것만)
│
├── order/               OrderServiceTest, OrderContract, OrderControllerTest
├── product/             ProductServiceTest, ProductContract
└── user/                UserServiceTest, UserContract

  contract/  unit/  integration/   ← 만들지 않는다

판단 기준 정리

클래스 위치 접근 제어
XxxController {도메인}/api public
XxxRequest, XxxResponse {도메인}/api public
XxxUseCase, XxxProvider (인터페이스) {도메인}/application public
XxxService (구현체) {도메인}/application package-private
XxxCommand, XxxQuery {도메인}/application public
XxxSnapshot (도메인 간 전달) {도메인}/application public
XxxDomain {도메인}/domain public
XxxRepository (인터페이스) {도메인}/domain public
XxxSearchCondition (조회 조건) {도메인}/domain public
XxxView (조회 결과) {도메인}/domain public
XxxEntity {도메인}/infra package-private
XxxMapperParam, XxxMapperResult {도메인}/infra package-private
XxxEntityMapper {도메인}/infra package-private
XxxProjection (JPA 조인 조회, 인터페이스) {도메인}/infra package-private
Repository 구현체 {도메인}/infra package-private
XxxBulkMapper, XxxBulk{의도}Writer {도메인}/bulk package-private
XxxBulkMapperParam, XxxBulkMapperResult {도메인}/bulk package-private
Domain을 거치는 잡의 Reader/Processor/Writer {도메인}/batch package-private
Job/Step 설정 조립 대상과 같은 패키지 package-private
HttpApiResponse, ServiceResult, PagingResult common/response public
ErrorCode, DomainCode, ErrorCodeException common/error public
ApiExceptionHandler common/error public
도메인이 커졌다 크기는 기준이 아니다 파일 수는 속성 개수를 말할 뿐이다
언제 쪼개나 자기 유스케이스가 있을 때 유스케이스가 없으면 도메인이 아니라 테이블이다
쪼갠 것은 계층 몇 겹인가 넷 다 갖는다 컨트롤러만 남의 폴더에 두지 않는다. URL은 안 바뀐다
경계를 무엇이 지키나 ArchUnit 컴파일러는 절반만 붙든다
규칙을 몇 개씩 켜나 한꺼번에 지금 위반이 0이라 안 터진다. 나중엔 비싸진다
규칙에서 뺀 것은 목록으로 적는다 조용히 빼면 「원래 되는구나」로 읽힌다
테스트는 어디에 두나 대상이 사는 도메인 패키지 도메인 폴더가 곧 「이 도메인에 테스트가 있는가」의 답이다
종류별 폴더로 모으나 아니다 도메인 폴더가 비어 보여 무테스트로 집계된다
계약 enum은 어디에 그 도메인의 테스트 패키지 계약과 떨어지면 세는 사람이 예외를 기억해야만 맞는다

위 표의 api 두 줄은 접미사 분할 후에 바뀐다. {도메인}/apicontroller·request·response로 나누고 나면 XxxController는 밖에서 부르는 코드가 없어 package-private이 되고, XxxRequest·XxxResponsecontroller가 다른 패키지에서 이름을 부르므로 대부분 public으로 남는다. 판정 기준은 접미사 패키지 분할 표준이 든다.


이 표준을 정하기까지

출발점은 빚을 확인한 것이었다. “Entity는 패키지 밖으로 나가지 않는다”는 문장이 네 개 표준에 걸쳐 근거로 쓰이는데, 정작 그 패키지를 정의한 적이 없었다. MyBatis에 setter를 허용한 논거도 여기 통째로 기대고 있었다.

그런데 정의만으로는 부족하다는 걸 곧 깨달았다. 패키지 구조를 문서로 적어도 결국 사람이 지키는 약속이다. DTO 생성 표준에서 final 필드를 택한 이유가 “컴파일러가 강제한다”였는데, 패키지에도 같은 걸 적용할 수 있는지 따져봤다.

답은 package-private이었다. public을 안 붙이면 같은 패키지에서만 보이므로, Entity와 그것을 쓰는 코드를 한 패키지에 모으면 밖에서는 import 자체가 불가능해진다. 그러려면 패키지를 계층이 아니라 도메인으로 나눠야 한다 — 계층별로 나누면 Entity를 public으로 열어야만 Repository가 쓸 수 있고, 여는 순간 모두에게 열린다.

구현체까지 감출 수 있다는 건 확인하면서 알았다. 주입이 인터페이스 타입으로 이루어지므로 Repository 구현체 클래스 자체는 public일 필요가 없다. 결과적으로 infra 패키지 전체를 닫을 수 있고, 밖에서 보이는 것은 domain의 인터페이스뿐이다.

부수 효과가 하나 더 있었다. 예외 번역 규칙도 자동으로 지켜진다. Service에서 DataIntegrityViolationException을 잡고 싶어도, 그 예외를 유발하는 Entity 조작이 infra 안에서만 가능하므로 애초에 그럴 코드를 쓸 수 없다.

막힌 건 ErrorCode였다. common은 도메인을 몰라야 하는데 ORDER_NOT_FOUND 같은 값이 들어간다. 도메인별로 쪼개는 방법을 검토했지만, 그러면 코드 번호 중복을 사람이 관리해야 한다. 주문과 결제가 각자 409001을 쓰는 걸 막을 방법이 없어진다. 한 곳에 모아야 중복이 눈에 보인다는 실용적 이유로 결합을 받아들이고, 대신 뒤 3자리를 도메인별 대역으로 나누기로 했다.

대역 방식은 나중에 폐기했다. 폭을 100으로 잡으면 도메인이 열 개까지밖에 안 들어간다는 걸 세어보고 알았다. 지금은 코드에 도메인 칸을 따로 두고 그 번호를 DomainCode enum으로 뺐다. ErrorCode가 도메인을 안다는 결합은 그대로지만, 어느 도메인인지가 숫자가 아니라 타입으로 적힌다는 점이 달라졌다. 자릿수와 조립 규칙은 예외 처리 표준에 있다.

마지막으로 도메인 간 참조 규칙을 정했다. infra는 package-private이라 자동으로 막히지만 domain은 같은 도메인 안에서 public이어야 해서 다른 도메인에도 보인다. 여기는 컴파일러가 막아주지 못하므로 application 계층에서만, 상대가 공개한 것만 쓰기로 규율을 남겼다.

테스트 쪽은 한참 뒤에, 숫자가 틀리고 나서야 열렸다. 어휘 표준이 무테스트 도메인을 세는데 도메인 폴더로 세니 셋이 0으로 나왔고 보정하니 하나였다. 원인을 따라가니 계약 enum이 contract/ 에 따로 살고 있었는데, 더 파보니 계약표가 잘못 놓인 게 아니라 src/test 에 규칙이 아예 없었다. 그래서 계약표만 옮기는 안을 버리고 테스트 전체를 프로덕션 구조에 맞췄다.

원칙 3이 거기서도 절반을 붙든다는 건 정하고 나서 알았다. infra를 만지는 테스트는 같은 패키지가 아니면 컴파일이 안 된다. 종류별 폴더로 모으려면 infrapublic으로 열어야 하므로, 테스트를 잘못 두는 것과 접근 제어가 무너지는 것이 같은 사건이 된다. 원칙 하나를 세우려다 이미 있던 원칙이 그 절반을 이미 지키고 있었던 셈이다.


정리

  • 경계는 ArchUnit 이 지킨다. 컴파일러가 붙드는 209개는 하나도 안 샜고, 컴파일러가 모르는 자리에서만 샜다.
  • 도메인을 쪼개는 기준은 크기가 아니라 자기 유스케이스다. 유스케이스가 없는 개념은 도메인이 아니라 그 도메인의 테이블이고, 쪼갠 것은 계층 넷을 다 갖는다.
  • 최상위는 도메인으로 나눈다. 계층별로 나누면 Entity를 public으로 열 수밖에 없다
  • 도메인 안은 api / application / domain / infra 네 겹이다. 의존은 전부 domain을 향한다
  • infra는 전부 package-private으로 막는다. Entity 유출이 약속이 아니라 컴파일 에러가 된다
  • Repository 구현체도 감출 수 있다. 주입이 인터페이스로 이루어지기 때문이다
  • 도메인끼리는 application에서만, 상대가 공개한 것만 쓴다. 순환 참조는 금지한다
  • Entity 연관관계는 같은 도메인 안에서만 쓴다. 도메인을 넘는 참조는 ID로 하고, 이건 컴파일러가 강제한다
  • 여러 도메인을 조인하는 조회는 XxxProjection으로 뺀다. 쿼리가 반복되기 시작하면 옮길 때다
  • common에는 도메인이 없어져도 남을 것만 둔다
  • ErrorCode의 도메인 결합은 인정한다. 코드 중복을 한눈에 보기 위한 타협이고, 도메인 번호는 DomainCode enum에 모은다
  • 테스트도 같은 도메인 구조를 따른다. 종류별 폴더로 모으면 도메인 폴더가 비어 보여 무테스트로 집계되고, infra를 테스트하려고 public을 열게 된다

AI 코드 어시스턴트에 바로 적용하기

Claude Code · Codex — .claude/skills/package-structure-standard/SKILL.md · .agents/skills/package-structure-standard/SKILL.md

두 파일은 바이트까지 같아야 한다. .agents 쪽은 tools/agents-skills.rb 가 뽑는 생성물이다.

---
name: package-structure-standard
description: 패키지 구조와 접근 제어 규칙. 새 클래스를 만들거나 배치할 때, import를 추가할 때, 계층 위반을 검토할 때 반드시 적용한다.
---

# 패키지 구조 표준

패키지는 정리 수단이 아니라 **계층 위반을 컴파일 에러로 만드는 강제 수단**이다.

## 최상위 구조
- 최상위는 계층이 아니라 도메인으로 나눈다. `order/`, `product/`, `user/`, 그리고 `common/`.
- 계층별(`controller/`, `service/`)로 나누지 않는다. Entity를 `public`으로 열어야만 동작하게 되어 가시성으로 막을 수 없다.

## 도메인 내부
- 각 도메인은 `api`, `application`, `domain`, `infra` 네 패키지로 나눈다. 이 네 겹이 기본형이다.
- `api``application`은 그 안을 접미사로 한 겹 더 나눈다. `domain``infra`는 나누지 않는다. suffix-package-split-standard를 따른다.
- `api`: Controller, `XxxRequest`, `XxxResponse`
- `application`: `XxxUseCase`/`XxxProvider` 인터페이스, `XxxService` 구현체, `XxxCommand`, `XxxQuery`, `XxxSnapshot`
- `domain`: `XxxDomain`, `XxxRepository` 인터페이스, `XxxSearchCondition`, `XxxView`
- `infra`: `XxxEntity`, `XxxMapperParam`, `XxxMapperResult`, Mapper, Repository 구현체
- 대량 처리가 필요한 도메인만 예외로 `bulk`, `batch` 두 패키지를 더 갖는다. 없으면 만들지 않는다. 세부 규칙은 batch-execution-standard를 따른다.
- `bulk`: `XxxBulkMapper`, `XxxBulk{의도}Writer`, `XxxBulkMapperParam`, `XxxBulkMapperResult`. `Domain`을 거치지 않는 벌크 연산 전용이다.
- `batch`: `Domain`을 하나씩 거치는 Spring Batch 잡의 Reader/Processor/Writer와 `Job`/`Step` 설정.
- 의존 방향은 `api → application → domain`이고 `infra → domain`, `bulk → domain`, `batch → application → domain`이다. `domain`은 같은 도메인의 `api`, `application`, `infra`, `bulk`, `batch`를 참조하지 않는다.
- `infra``bulk`는 서로 참조하지 않는다. 형제 패키지이며 각각 자기 데이터 타입을 갖는다.
- `batch``bulk``infra`를 참조하지 않는다. `application`, `domain`, `common`만 본다.
- `common`의 공용 타입(`ServiceResult` 계열, `PagingResult`, `ErrorCode`, `ErrorCodeException`)은 예외로 어느 패키지에서든 참조할 수 있다. `domain`도 마찬가지다.
- 조회 조건 타입은 `domain`에 둔다. `application``XxxQuery`를 Repository 파라미터로 쓰면 `domain → application`이 되어 의존 방향이 뒤집힌다.

## 도메인을 쪼갤 때

**기준은 크기가 아니라 자기 유스케이스다.**

- **파일이 몇 개인지는 기준이 아니다.** 파일 수는 그 도메인이 속성을 몇 개 가졌는지를 말할 뿐이다 — 속성 하나가 `Response` · `Domain` · `View` · `Entity` · `MapperResult` 다섯 겹을 만든다.
- **자기 `application` 유스케이스가 없는 개념은 도메인이 아니라 그 도메인의 테이블이다.** 「만족도」만 따로 조회하는 화면이 없으면 그것은 기관 상세의 한 칸이지 도메인이 아니다.
- **떼어낸 것은 계층 넷을 다 갖는다.** 컨트롤러도 자기 것을 만든다. `api` 만 원래 자리에 두면 「이 기능의 API 가 어디 있나」의 답이 도메인마다 달라진다.
- **URL 은 바뀌지 않는다.** 주소는 코드에 글자로 적혀 있고 파일이 어느 패키지에 있는지와 무관하다. 부르는 쪽은 아무것도 안 바뀐다.
- **유스케이스가 생기기 전에 미리 쪼개지 않는다.** 패키지를 옮기면 `import` 가 전부 바뀌어 되돌리기 비싸다.

## 테스트 패키지
- 테스트는 프로덕션과 **같은 도메인 구조**를 따른다. `src/test/java` 아래도 최상위를 도메인으로 나눈다.
- 테스트 파일은 **대상이 사는 도메인 패키지**에 둔다. 종류별 폴더(`unit/`, `integration/`, `contract/`)로 모으지 않는다.
- API 테스트 계약표의 계약 enum(`{도메인}Contract`)도 그 도메인 패키지에 둔다. 공용 `contract/` 에 모으지 않는다. api-test-contract-table-standard를 따른다.
- 어느 도메인에도 속하지 않는 테스트 지원 타입만 `common`에 둔다. 이름에 도메인이 붙는 것은 `common`에 두지 않는다.
- 도메인 폴더가 그대로 **「이 도메인에 테스트가 있는가」의 답**이 된다. 종류별로 모으면 도메인 폴더가 비어 보여 무테스트로 집계된다.
- 같은 패키지에 두면 테스트가 **package-private 타입을 그대로 본다.** 종류별로 모으면 `infra`를 테스트하려고 `public`을 열게 되어 접근 제어가 무너진다.

## 접근 제어
- `infra` 패키지의 모든 클래스는 `public`을 붙이지 않고 package-private으로 만든다.
- `XxxEntity`, `XxxMapperResult`, `XxxMapperParam`, `XxxEntityMapper`, Repository 구현체가 모두 해당된다.
- Repository 구현체를 package-private으로 둬도 되는 이유는 주입이 `domain`의 인터페이스 타입으로 이루어지기 때문이다.
- `{도메인}/bulk``{도메인}/batch`의 클래스도 package-private으로 만든다. `Step` 설정에서만 조립되므로 밖으로 열 필요가 없다.
- 그래서 `Job`/`Step` 설정 클래스도 조립 대상과 같은 패키지에 둔다. 밖에 두면 package-private 컴포넌트를 조립할 수 없다.
- package-private은 하위 패키지에 상속되지 않는다. `infra`의 타입이 다른 패키지에서 필요해 보이면 `public`으로 열지 말고, 그 패키지가 자기 타입을 갖게 한다.
- `domain`의 클래스는 `public`으로 만든다.
- `api`는 접미사 분할 후 패키지 밖에서 참조되는 것만 `public`이다. `XxxController`는 package-private이고 `XxxRequest`·`XxxResponse`는 대부분 `public`이다. suffix-package-split-standard를 따른다.
- `application`은 인터페이스(`XxxUseCase`, `XxxProvider`)와 `XxxCommand`/`XxxQuery`/`XxxSnapshot``public`이고, `XxxService` 구현체는 package-private으로 만든다. usecase-interface-standard를 따른다.

## 경계는 ArchUnit 이 지킨다

**컴파일러는 절반만 붙든다.** package-private 은 컴파일러가 막지만, **도메인 간 참조와 계층 의존 방향은 안 막는다** — 실제로 새는 자리가 거기였다.

**세 벌을 한꺼번에 켠다.**

```
계층 의존 방향     api → infra, domain → api/application/infra 를 막는다
package-private    infra 와 XxxService 가 패키지 밖으로 안 나가는지 본다
도메인 간 참조     다른 도메인은 application 에서만, 인터페이스로 만난다
```

- **한 벌씩 넣지 않는다.** 위반이 쌓인 뒤에 켜면 「다 고치고 켜기」와 「예외 목록을 안고 켜기」 중에 골라야 한다. **위반이 적을 때가 제일 싸다.**
- **사람 리뷰에 맡기지 않는다.** 파일이 수백 개인 저장소에서 `import` 한 줄을 눈으로 거르는 것은 되는 척만 하는 규칙이다.
- **규칙에서 뺀 것은 목록으로 적는다.** 서블릿 필터처럼 계층 밖에서 도는 것은 뺄 수 있지만, **왜 뺐는지를 그 자리에 적는다.** 안 적으면 다음 사람이 「여기는 원래 되는구나」로 읽는다.
- 대가는 의존성 하나 · 느려지는 테스트 · 코드로 적는 품이다. **셋 다 한 번 내는 값이고, 안 내면 규칙이 지켜지는지 영영 모른다.**

## 도메인 간 참조
- 다른 도메인은 `application` 계층에서만 호출한다.
- 다른 도메인의 `domain`이나 `infra` 패키지를 직접 import하지 않는다. 상대 도메인의 `application`이 공개한 것만 쓴다.
- 구체적으로는 상대 도메인의 `XxxProvider` 인터페이스를 주입받고 `XxxSnapshot`을 받는다. `XxxDomain`을 주고받지 않는다. usecase-interface-standard를 따른다.
- 도메인 간 순환 참조를 만들지 않는다.
- Entity 연관관계(`@ManyToOne`, `@OneToMany`)는 **같은 도메인 안에서만** 만든다. 같은 `infra` 패키지에 있으므로 package-private이어도 서로 참조된다.
- 다른 도메인의 데이터는 `@ManyToOne`이 아니라 **ID 컬럼으로 참조**한다. 예: `private Long productId`.
- 그 ID 컬럼에는 DB FK 제약을 건다. JPA 연관관계는 만들지 않되 참조 무결성은 DB가 지키게 한다. 참조하는 쪽 테이블에 건다. aggregate-deletion-standard를 따른다.
- 여러 도메인을 조인해야 하는 조회는 조회를 주도하는 도메인의 `infra`에 전용 결과 타입을 두고 채운다. JPA 네이티브 쿼리면 `XxxProjection` 인터페이스, MyBatis면 `XxxMapperResult`다.
- 그 결과는 `infra` 밖으로 나가지 않는다. 구현체가 `domain``XxxView`로 변환해 내보낸다. read-model-standard를 따른다.
- 단건이나 소량은 `application`에서 각 도메인의 `XxxProvider`를 호출해 조합해도 되며, 쿼리가 반복되기 시작하면 조인으로 옮긴다. 두 경로가 같은 `XxxView`를 채우므로 옮길 때 `application` 위쪽은 바뀌지 않는다.

## common
- `common`에는 특정 도메인이 사라져도 남아야 하는 것만 둔다.
- `common/response`: `HttpApiResponse`, `ServiceResult`, `ServiceResults`, `PagingResult`
- `common/error`: `ErrorCode`, `DomainCode`, `ErrorCodeException`, `ApiExceptionHandler`
- `common/security`: `AuthenticationEntryPoint`, `AccessDeniedHandler` 구현체, `LoginUser`, `LoginUserArgumentResolver`, 그 등록 `WebMvcConfigurer`
- Spring Security 타입을 아는 곳은 `common/security`뿐이다. `api`·`application`·`domain``LoginUser`만 알거나 그마저도 모른다. authenticated-user-standard를 따른다.
- `ErrorCode`는 도메인별 상수를 포함하지만 한 곳에 모은다. 코드 번호 중복을 한눈에 확인하기 위해서다.
- 코드는 9자리이고 가운데 3자리가 도메인 칸이다. 도메인 번호는 `common/error``DomainCode` enum에 모으고, 특정 도메인에 속하지 않는 실패는 `COMMON(0)`을 쓴다. exception-handling-standard를 따른다.
- 도메인을 새로 만들면 `DomainCode`에 상수를 추가한다. `ErrorCode` 상수에 도메인 번호를 숫자로 직접 적지 않는다.

GitHub Copilot — .github/instructions/package-structure-standard.instructions.md

---
description: 패키지 구조와 접근 제어 규칙
applyTo: "**/*.java"
---

- 최상위 패키지는 계층이 아니라 도메인으로 나눈다.
- 각 도메인은 `api`, `application`, `domain`, `infra` 네 패키지로 구성한다.
- `api``application`은 그 안을 접미사로 한 겹 더 나눈다. `domain``infra`는 나누지 않는다.
- 대량 처리가 필요한 도메인만 예외로 `{도메인}/bulk``{도메인}/batch`를 더 갖고, 두 패키지의 클래스는 package-private으로 선언한다.
- `bulk``Domain`을 거치지 않는 벌크 연산 전용이며 `domain``common`만 참조한다. `bulk``infra`는 서로 참조하지 않고 각각 자기 데이터 타입을 갖는다.
- `batch``Domain`을 하나씩 거치는 잡 전용이며 `application`, `domain`, `common`을 참조한다. `api`, `infra`, `bulk`는 참조하지 않는다.
- `Job`/`Step` 설정 클래스는 조립 대상과 같은 패키지에 둔다.
- package-private은 하위 패키지에 상속되지 않는다. 다른 패키지에서 `infra` 타입이 필요해 보이면 `public`으로 열지 말고 그 패키지에 전용 타입을 만든다.
- Controller와 Request/Response는 `api`에 둔다.
- `XxxUseCase`/`XxxProvider` 인터페이스, `XxxService`, Command/Query, `XxxSnapshot``application`에 둔다.
- Domain 클래스, Repository 인터페이스, `XxxSearchCondition`, `XxxView``domain`에 둔다.
- `application``XxxQuery`를 Repository 파라미터로 쓰지 않는다. `domain → application`이 되어 의존 방향이 뒤집힌다.
- Entity, MapperParam, MapperResult, Mapper, Repository 구현체는 `infra`에 둔다.
- `infra` 패키지의 클래스에는 `public`을 붙이지 않고 package-private으로 선언한다.
- `domain`의 클래스는 `public`으로 선언한다.
- `api`는 접미사 분할 후 패키지 밖에서 참조되는 것만 `public`이다. `XxxController`는 package-private으로 선언한다.
- `application`은 인터페이스와 Command/Query/Snapshot만 `public`이고 `XxxService` 구현체는 package-private으로 선언한다.
- `domain` 패키지는 같은 도메인의 `api`, `application`, `infra`, `bulk`, `batch`를 참조하지 않는다.
- `common`의 공용 타입(`ServiceResult` 계열, `ErrorCode`, `ErrorCodeException`)은 예외로 `domain`을 포함한 어느 패키지에서든 참조할 수 있다.
- 다른 도메인은 `application` 계층에서만 호출하고, 상대의 `domain`이나 `infra`를 import하지 않는다.
- 다른 도메인은 상대의 `XxxProvider`를 주입받아 `XxxSnapshot`을 받는다. `XxxDomain`을 주고받지 않는다.
- 도메인 간 순환 참조를 만들지 않는다.
- Entity 연관관계는 같은 도메인 안에서만 만들고, 다른 도메인은 `private Long productId`처럼 ID로 참조한다.
- 그 ID 컬럼에는 참조하는 쪽 테이블에 DB FK 제약을 건다.
- 여러 도메인을 조인하는 조회는 `infra`에 전용 결과 타입을 둔다. JPA 네이티브 쿼리면 `XxxProjection` 인터페이스, MyBatis면 `XxxMapperResult`다.
- 그 타입은 `infra` 밖으로 내보내지 않고 구현체가 `domain``XxxView`로 변환해 반환한다.
- `HttpApiResponse`, `ServiceResult` 계열, `PagingResult``common/response`에 둔다.
- `ErrorCode`, `DomainCode`, `ErrorCodeException`, `ApiExceptionHandler``common/error`에 둔다.
- `AuthenticationEntryPoint`, `AccessDeniedHandler`, `LoginUser`, `LoginUserArgumentResolver``common/security`에 둔다. Spring Security 타입을 아는 곳은 여기뿐이다.
- `ErrorCode`는 하나로 유지한다. 9자리 코드의 가운데 3자리가 도메인 칸이고, 도메인 번호는 `common/error``DomainCode` enum에 모은다. 특정 도메인에 속하지 않는 실패는 `COMMON(0)`을 쓴다.
- 도메인을 새로 만들면 `DomainCode`에 상수를 추가하고, `ErrorCode`에 도메인 번호를 숫자로 직접 적지 않는다.
- **도메인을 쪼개는 기준은 크기가 아니라 자기 유스케이스다.** 파일 수는 그 도메인이 속성을 몇 개 가졌는지를 말할 뿐이다 — 속성 하나가 `Response`·`Domain`·`View`·`Entity`·`MapperResult` 다섯 겹을 만든다. **자기 `application` 유스케이스가 없는 개념은 도메인이 아니라 그 도메인의 테이블이다.**
- **떼어낸 것은 계층 넷을 다 갖는다.** 컨트롤러도 자기 것을 만든다 — `api` 만 원래 자리에 두면 「이 기능의 API 가 어디 있나」의 답이 도메인마다 달라진다. **URL 은 바뀌지 않는다**(주소는 코드에 글자로 적혀 있다). **유스케이스가 생기기 전에 미리 쪼개지 않는다** — 패키지를 옮기면 `import` 가 전부 바뀐다.
- **경계는 ArchUnit 이 지킨다.** 컴파일러는 package-private 만 붙들고 **도메인 간 참조와 계층 의존 방향은 안 막는다** — 실제로 새는 자리가 거기다. **세 벌(계층 의존 방향 · package-private · 도메인 간 참조)을 한꺼번에 켠다** — 위반이 쌓인 뒤에 켜면 「다 고치고 켜기」와 「예외 목록을 안고 켜기」 중에 골라야 하므로 **위반이 적을 때가 제일 싸다.**
- **사람 리뷰에 맡기지 않는다**(파일 수백 개에서 `import` 한 줄을 눈으로 거르는 것은 되는 척만 하는 규칙이다). **규칙에서 뺀 것은 목록으로 적는다** — 서블릿 필터처럼 계층 밖에서 도는 것은 뺄 수 있지만 **왜 뺐는지를 그 자리에 적는다.**
- **테스트는 프로덕션과 같은 도메인 구조를 따른다.** `src/test/java` 아래도 최상위를 도메인으로 나누고, 테스트 파일은 대상이 사는 도메인 패키지에 둔다. 종류별 폴더(`unit/`, `integration/`, `contract/`)로 모으지 않는다.
- **계약 enum(`{도메인}Contract`)도 그 도메인의 테스트 패키지에 둔다.** 공용 `contract/` 에 모으지 않는다. 어느 도메인에도 속하지 않는 테스트 지원 타입만 `common`에 둔다.
- 도메인 폴더가 그대로 **「이 도메인에 테스트가 있는가」의 답**이 된다. 종류별로 모으면 도메인 폴더가 비어 보여 무테스트로 집계되고, `infra`를 테스트하려고 `public`을 열게 된다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기