지금까지 세운 표준들을 다시 읽어보면 같은 문장이 반복해서 나온다.
- ”
Entity는 패키지 밖으로 나가지 않는다” — DTO 네이밍 표준 - “
MapperResult는 패키지 밖으로 안 나간다” — DTO 생성 표준 - “영속성 예외가 Service로 새면 안 된다” — 예외의 종류
- “
Domain은 영속성도 웹도 모른다” — DTO 네이밍 표준
그런데 그 패키지가 어떻게 생겼는지는 어디에도 없다. 정의되지 않은 것을 네 개 표준이 근거로 삼고 있는 셈이다. 게다가 이 규칙들은 전부 사람이 지켜야 하는 약속이라, 어겨도 그냥 컴파일된다.
DTO 생성 표준에서 필드를 final로 만든 이유는 “규율이 아니라 컴파일러가 강제한다”였다. 패키지도 같은 방식으로 쓸 수 있다. 이 글은 계층 위반을 컴파일 에러로 만드는 것을 목표로 패키지 구조를 정한다.
원칙 1. 도메인별로 나눈다
최상위를 계층이 아니라 도메인으로 나눈다.
❌ 계층별 ✅ 도메인별
com.example.shop com.example.shop
├── controller/ ├── order/
├── service/ ├── product/
├── repository/ ├── user/
└── domain/ └── common/
계층별로 나누면 주문 기능 하나를 고칠 때 네 개 패키지를 오가야 하고, 각 패키지에는 서로 상관없는 도메인이 뒤섞인다. 무엇보다 중요한 문제는 따로 있다 — OrderEntity가 repository 패키지에 있고 OrderService가 service 패키지에 있으면, Entity를 public으로 열어야만 Repository가 쓸 수 있다. 열린 순간 Service도 Controller도 쓸 수 있게 된다.
도메인별로 모으면 이 문제가 사라진다. 그리고 나중에 도메인 하나를 별도 모듈이나 서비스로 떼어낼 때도 폴더 하나만 옮기면 된다.
원칙 1-1. 도메인을 쪼개는 기준은 크기가 아니라 자기 유스케이스다
원칙 1이 「도메인별로 나눈다」로 최상위를 정했다. 그러면 한 도메인이 커졌을 때 그것을 다시 쪼개는가가 남는다.
실제로 그런 폴더가 생겼다. 자바 파일 669개 중 socialprovider가 116개로 저장소의 17%다. 두 번째인 content(77)의 1.5배다. (빌드 도구가 자바 소스 파일을 두기로 정한 표준 경로가 src/main/java다. 그 아래 .java 파일을 도메인 폴더별로 세어 비교한 수치다.)
크기를 기준으로 삼는 안을 먼저 재봤다. 안에 든 것을 성격별로 갈라 보니 이렇다.
수집 정부 사이트에서 기관 정보를 긁어온다 17개
경쟁사 기관을 회사에 묶는다 14개
남는 것 85개
둘을 떼어내도 85개로 여전히 저장소 최대다. 그리고 그 85개의 정체가 답을 정했다 — 41개가 「속성 8개 × 5겹」이다.
Satisfaction → Response · Domain · View · Entity · MapperResult
부가서비스·실적·만족도·서비스군·가격·인력 셋. 여덟 개가 전부 정확히 5겹이다. 표준이 시킨 대로 한 결과지 잘못 쌓인 것이 아니다. 그리고 속성은 기관 없이 존재하지 않는다 — 「만족도」만 따로 조회하는 화면이 없고 기관 상세의 한 칸이다. 쪼갤 수 있는 것이 아니다.
(View가 무엇인지는 원칙 4 뒤쪽에서 정의한다 — 지금은 다섯 번째 결과 타입이 하나 더 있다는 것만 알아두면 된다.)
그래서 크기를 기준에서 뺀다. 파일이 몇 개인지는 그 도메인이 속성을 몇 개 가졌는지를 말할 뿐, 쪼개야 한다는 뜻이 아니다.
남는 기준은 하나다 — 자기 유스케이스가 있는가. 자기 application 유스케이스가 없는 개념은 도메인이 아니라 그 도메인의 테이블이다. socialprovider에서 나갈 수 있는 것이 수집과 경쟁사뿐인 이유가 그것이다.
떼어낸 것은 계층 넷을 다 갖는다. 컨트롤러도 자기 것을 만든다. 지금 컨트롤러 하나에 엔드포인트 여덟이 붙어 있는데 유스케이스대로 4·2·2로 이미 갈려 있다.
URL은 바뀌지 않는다. POST /api/v1/admin/social-providers/collect처럼 HTTP 방식과 주소를 합친 것을 엔드포인트라고 부르는데, 이건 코드에 글자로 적혀 있고 파일이 어느 패키지에 있는지와 무관하다. 부르는 쪽은 아무것도 안 바뀐다.
api만 원래 자리에 두는 안은 버렸다. 그러면 승격한 폴더에 컨트롤러만 없고, 「이 기능의 API가 어디 있나」의 답이 도메인마다 달라진다. 찾을 때마다 「이건 승격된 것이었나」를 먼저 떠올려야 한다.
대가는 컨트롤러 파일이 는다는 것이다. 그리고 승격은 되돌리기 비싸다 — 패키지를 옮기면 import가 전부 바뀐다. 그래서 유스케이스가 생기기 전에 미리 쪼개지 않는다.
원칙 2. 도메인 안은 네 겹으로 나눈다
실측(supported): common 의 5개 파일을 javac 로 컴파일 — spring-web·spring-security-web 있을 때 에러 0(전부 컴파일) · 뺐을 때 에러 10, 실패 파일 ApiAuthenticationEntryPoint.java,ApiExceptionHandler.java — 증명
이 실측은 common 폴더의 파일 5개만 따로 떼어 컴파일이 되는지 본 것이다. 스프링 라이브러리(spring-web, spring-security-web)를 끼워 넣으면 5개가 전부 컴파일됐고, 빼고 컴파일하면 두 파일이 에러를 냈다. 즉 이 두 파일은 스프링 없이는 혼자 못 돌아가는 코드라는 뜻이다.
지금까지 세운 의존 규칙과 1:1로 맞춘다.
order/
├── api/ Controller, XxxRequest, XxxResponse
├── application/ XxxUseCase (인터페이스), XxxService, XxxCommand, XxxQuery
├── domain/ XxxDomain, XxxRepository (인터페이스)
└── infra/ XxxEntity, XxxMapper, Repository 구현체
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에 따로 둔다.api와web은application을 향하는 방향이 같은 형제이므로 계층이 늘어나는 것은 아니고,bulk·batch와 마찬가지로 네 겹이 기본형이고 SSR을 쓰는 도메인만web을 더 갖는다. (bulk는 애그리거트 경계를 벗어나는 대량 연산,batch는Domain을 하나씩 거치는 대량 처리 잡을 담는 패키지다 — 둘 다 바로 다음 원칙 2의 “네 겹은 기본형이고, 대량 처리가 필요한 도메인만” 단락에서 자세히 다룬다.) 가른 이유와 다른 안들은 컨트롤러 패키지 분리 표준에서 다룬다.
api와application은 그 안을 접미사로 한 겹 더 나눈다.api는controller·request·response,application은usecase·command·query·service다.domain과infra는 나누지 않는다 — 나누면Entity를public으로 열어야 하고, 애초에 알파벳 정렬이 이미 개념별로 묶어주고 있어서 얻을 것도 없다. 한 겹 더 파는 순간 접근 제어가 끊기므로 이 표준의 「api는public」도 거기서 다시 판정된다. 계층별로 답이 갈린 실측과 판정 기준은 접미사 패키지 분할 표준에 있다.
화살표가 전부 domain을 향한다. domain은 아무 쪽도 가리키지 않는다. 이게 DTO 네이밍 표준에서 “Repository 인터페이스는 Service가 정의하고 구현체는 갈아 끼운다”고 한 것의 물리적 형태다.
이 글에서 「방향」은 import 방향, 곧 컴파일 의존이다. A 클래스가 B 클래스를 import하면 화살표는 A에서 B로 간다. 실행 중에 누가 누구를 먼저 부르는지와는 다른 이야기다 — 요청은 api → application → domain → infra 순으로 흘러 infra의 Repository 구현체가 맨 뒤에 불리지만, 그 구현체가 domain의 인터페이스를 import해서 구현하므로 화살표는 infra → domain이다.
여기서 “domain이 아무 쪽도 가리키지 않는다”는 같은 도메인 안의 api·application·infra를 말한다. common은 예외다. domain의 Repository 인터페이스는 common/response의 PagingResult(목록 조회 결과를 페이지 단위로 담는 그릇)를 반환하고, Domain은 common/error의 ErrorCodeException(정해둔 오류 코드를 담아 던지는 예외)을 던진다. 이게 허용되는 이유는 common에 두는 것들이 어떤 계층도 아니기 때문이다 — 예외 처리 표준에서 ErrorCode의 HTTP 상태를 HttpStatus라는 자바 타입이 아니라 int로 담기로 한 게 정확히 이 지점을 위한 결정이었다. HttpStatus는 스프링 타입(org.springframework.http.HttpStatus)이라, ErrorCode가 그것을 들면 ErrorCode를 쓰는 domain도 스프링 없이는 컴파일되지 않는다.
다만 이 예외는 common 패키지 전체가 아니라 domain이 쓰는 타입에만 성립한다. 이 글에서 domain이 닿으면 안 되는 것은 스프링(org.springframework), jakarta(JPA·서블릿 같은 자바 표준 API), Swagger(io.swagger, API 문서를 만드는 도구) 세 패키지이고, 이 셋을 줄여 「프레임워크」라고 부른다. Swagger는 프레임워크라기보다 도구지만 같이 막는 이유는 원칙 5에 있다. common 안에는 스프링에 기대는 클래스(원칙 5의 ApiExceptionHandler 등)도 같이 있다 — 바로 위 실측이 보인 것이 그것이다. 그래서 조건은 domain을 주어로 건다 — domain은 프레임워크에 직접도, common을 거쳐서도 닿지 않는다. 직접 닿는다는 것은 domain 클래스가 이 세 패키지의 타입을 import하는 것이고, 거쳐서 닿는다는 것은 domain이 import한 common 타입이 다시 그런 타입을 import하는 것이다(몇 단계를 건너도 같다). 이 조건이 지켜지는 한 domain은 common을 봐도 여전히 이 셋 없이 컴파일된다.
이 조건은 domain에만 건다. api·application·infra는 원래 스프링을 쓰는 계층이라(Controller의 @RestController, Service의 @Service, Repository 구현체의 @Repository), common의 스프링 타입을 봐도 새로 생기는 의존이 없다. 그래서 이 세 계층은 common의 어느 타입을 봐도 된다. common을 쪼개지 않고 이 조건을 고른 이유는 원칙 5에, 이 조건을 지키는 장치는 원칙 6의 네 번째 규칙에 있다.
infra가 domain을 향한다는 점이 핵심이다. 구현이 추상을 의존하지, 그 반대가 아니다.
네 겹은 기본형이고, 대량 처리가 필요한 도메인만 두 패키지를 더 갖는다. 애그리거트 경계를 벗어나는 벌크 연산은
{도메인}/bulk에,Domain을 하나씩 거치는 잡은{도메인}/batch에 둔다. 이어지는 설명은bulk이야기이고,batch의 참조 범위와 규칙은 배치 실행 표준에서 다룬다.bulk를infra에 둘 수도 있었지만 그러면 같은 패키지의 Repository 구현체가 벌크 Mapper를 주입받을 수 있다 — Service는 못 부르는데 구현체는 부를 수 있는 구멍이 생긴다. 그래서 격리 대상은 Service가 아니라 Repository 구현체다.bulk는domain과common만 참조하고infra와는 서로 보지 않는 형제 패키지이며, 자기 데이터 타입(XxxBulkMapperParam등)을 자기 안에 갖는다. 자세한 내용과 이유는 Repository 설계 표준에서 다룬다.
원칙 3. infra는 전부 package-private으로 막는다
반증: 중복 키 저장을 Service 의 catch(DataIntegrityViolationException) 가 잡는가 — Boot 3.5.16 (가)saveAndFlush Service catch 가 잡음/밖으로=UnexpectedRollbackException (나)save+커밋 Service catch 못 잡음/밖으로=DataIntegrityViolationException<-PSQLException · Boot 4.1.1 (가)saveAndFlush Service catch 가 잡음/밖으로=UnexpectedRollbackException (나)save+커밋 Service catch 못 잡음/밖으로=DataIntegrityViolationException<-PSQLException — 증명
이 반증 줄의 표기부터 풀면, (가)와 (나)는 비교한 두 상황에 붙인 이름표다 — (가)는 saveAndFlush를 쓴 경우, (나)는 save만 부르고 트랜잭션이 끝나는 커밋 시점에야 DB로 반영되는 경우다. 밖으로=는 Service가 try-catch로 잡으려 해도 결국 호출한 쪽까지 빠져나가는 예외가 무엇인지를 적은 것이다. 이제 나오는 말을 풀면, saveAndFlush는 변경 내용을 그 자리에서 바로 DB로 내보내는 메서드다(flush 참고). PSQLException은 PostgreSQL 드라이버가 던지는 에러이고, DataIntegrityViolationException은 스프링이 그걸 감싸 돌려주는 예외다. UnexpectedRollbackException은 이미 되돌리기로 정해진 트랜잭션을 또 커밋하려 할 때 나는 예외다. 이 넷이 실제로 어떻게 엮이는지는 아래 “예외 번역도 여기서 강제된다”에서 설명한다.
여기서 규칙이 문서가 아니라 코드가 된다. 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;
// ...
}
한 줄씩 따라가면 이렇다.
@Entity와@Table(name = "orders")는 이 클래스가orders테이블과 짝이라고 JPA에 알려주는 표시다(Entity 참고).class OrderEntity에public이 없으므로 같은 패키지(order/infra) 밖에서는 이 클래스를 import 할 수 없다.interface OrderJpaEntityRepository extends JpaRepository<OrderEntity, Long>은 JPA가 이미 만들어 둔 저장·조회 메서드를 그대로 물려받겠다는 선언이다. 메서드를 직접 구현하지 않아도 Spring Data가 구현체를 자동으로 만들어 준다.@Component는OrderEntityMapper를 스프링이 관리하는 객체(빈)로 등록해, 다른 클래스가 주입받아 쓸 수 있게 한다(@Component참고).@Repository도@Component와 같은 종류의 표시이고,@RequiredArgsConstructor가final필드(jpaEntityRepository,mapper)를 받는 생성자를 자동으로 만들어 준다(@RequiredArgsConstructor참고).OrderJpaRepository는 이 생성자로 받은 둘을 써서OrderRepository인터페이스를 실제로 구현한 클래스다.
의존성 주입이 실제로 일어나는 과정은 이렇다 — OrderJpaRepository의 생성자가 OrderJpaEntityRepository와 OrderEntityMapper 타입을 받는다는 것을 Spring이 클래스 정보에서 읽어서, 자신이 미리 만들어 둔 그 타입의 객체(빈)를 찾아 생성자 호출 자리에 그대로 채워 넣는다. 내가 직접 new로 만들지 않아도 프로그램이 시작될 때 이 과정이 자동으로 일어난다.
구현체까지 package-private으로 둘 수 있는 이유는 주입이 타입이 아니라 인터페이스로 이루어지기 때문이다. Service는 OrderRepository만 알면 되고, 그 인터페이스는 domain 패키지에서 public이다.
이제 Service에서 Entity를 쓰려고 하면 이렇게 된다.
// order/application/OrderService.java
import com.example.shop.order.infra.OrderEntity; // ❌ 컴파일 에러
“Entity는 패키지 밖으로 나가지 않는다”가 약속이 아니라 사실이 된다. MyBatis의 MapperResult(SQL 결과를 그대로 받는 타입)도 같은 자리에 두면 동일하게 막힌다.
예외 번역도 여기서 강제된다
예외의 종류에서 “영속성 예외를 Repository 구현체에서 번역한다”고 정했다. 여기 오래 「번역하지 않으면 애초에 코드가 안 된다」고 적어뒀는데, 실측에서 뒤집혔다.
DataIntegrityViolationException은 spring-tx의 public 타입이라 Service가 Repository 호출을 감싸 얼마든지 잡을 수 있다. Spring Data JPA를 쓰면 saveAndFlush 자리에서 실제로 잡혔다(두 Boot 버전 동일). 막아주는 것은 패키지 구조가 아니었다.
그런데 잡아봐야 소용이 없다. 잡아서 삼켜도 트랜잭션에는 이미 롤백 표시가 박혀 있어 커밋 단계에서 UnexpectedRollbackException이 나온다. flush를 당기지 않으면 예외가 커밋 시점에 터져 try 블록 밖이라 잡을 자리조차 없다.
그래서 규칙은 그대로 두되 이유가 바뀐다 — 「못 잡는다」가 아니라 「잡아도 트랜잭션을 못 살린다」다. 번역은 예외가 실제로 터지는 자리, 즉 구현체에서 해야 의미가 있다.
원칙 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 → 컴파일 에러
한 줄씩 보면, @ManyToOne은 “이 필드 하나가 상대 Entity 하나를 가리킨다”는 애너테이션이다. 여기서는 OrderEntity 하나가 ProductEntity 하나를 가리킨다는 뜻인데, 그 상대가 다른 도메인의 package-private Entity라서 애초에 import조차 되지 않는다.
이건 막혀서 곤란한 게 아니라 막아야 하는 것이 막힌 것이다. 도메인 간 Entity 연관관계를 만들면 주문과 상품이 DB 레벨에서 묶여 나중에 떼어낼 수 없고, 지연 로딩 프록시가 도메인 경계를 넘나들며 예상하지 못한 쿼리를 만든다.
도메인을 넘는 참조는 객체가 아니라 ID로 한다.
// ✅ order/infra/OrderEntity.java
@Column(name = "product_id")
private Long productId;
한 줄씩 보면, @Column(name = "product_id")는 자바 필드 이름(productId)과 DB 컬럼 이름(product_id)이 다를 때 그 둘을 연결해 주는 표시다. 바로 위 @ManyToOne 예시처럼 상대 Entity 객체를 통째로 들고 있는 대신, 숫자 하나(Long productId)만 저장해서 다른 도메인의 Entity를 아예 참조하지 않는다.
같은 도메인 안에서는 제약이 없다. OrderEntity와 OrderItemEntity는 둘 다 order/infra에 있으므로, package-private이어도 서로 참조되고 @OneToMany가 정상 동작한다.
// order/infra — 같은 패키지라 문제없다
@Entity
class OrderEntity {
@OneToMany(mappedBy = "order")
private List<OrderItemEntity> items;
}
한 줄씩 보면, @OneToMany(mappedBy = "order")는 “이 컬렉션이 OrderItemEntity 여러 개를 담는데, 이 관계의 주인은 내가 아니라 상대방의 order라는 필드”라는 뜻이다. mappedBy에 적은 문자열은 상대 Entity 안에서 이 관계를 가리키는 필드 이름이고, 같은 패키지에 있으므로 서로 참조해도 컴파일이 막히지 않는다.
즉 패키지가 애그리거트 경계 역할을 한다. 한 패키지 안은 객체 참조, 밖은 ID 참조다. 이 규칙은 문서가 아니라 컴파일러가 지켜준다.
그 애그리거트를 어디서 끊을지는 애그리거트 경계 표준에서 다룬다.
여러 도메인을 조인하는 조회는 Projection으로 뺀다
연관관계를 못 쓰니 조인이 필요한 조회를 따로 처리해야 한다. 조회를 주도하는 도메인의 infra에 전용 경로를 둔다.
// order/infra/OrderSummaryProjection.java
interface OrderSummaryProjection { // package-private 인터페이스
Long getOrderId();
String getProductName(); // product 테이블에서 조인해 온 값
String getUserName(); // user 테이블에서 조인해 온 값
}
이 인터페이스에는 구현하는 클래스가 하나도 없다. 메서드 본문도 없다. 대신 Spring Data가 바로 아래 나오는 쿼리의 별칭(AS productName 같은 것)을 읽어서, 그 이름과 같은 getXxx() 메서드를 가진 구현체를 실행 중에 스스로 만들어 채워 넣는다 — 그래서 직접 구현할 필요가 없다.
// 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();
이 SQL을 한 줄씩 보면, SELECT o.id AS orderId, ...는 “orders 테이블의 id 컬럼을 꺼내 orderId라는 이름을 붙여 달라”는 뜻이고, FROM orders o JOIN products p ON p.id = o.product_id는 “orders 테이블과 products 테이블을, products.id가 orders.product_id와 같은 행끼리 이어 붙여 달라”는 뜻이다. 이렇게 여러 테이블을 조건으로 이어 붙이는 것을 조인(JOIN)이라고 부른다. @Query(..., nativeQuery = true)는 이 SQL 문장을 JPA가 가공하지 않고 그대로 DB에 보내라는 표시다.
클래스가 아니라 인터페이스인 것에 이유가 있다. 클래스로 만들면 이걸 무엇으로 채우느냐가 문제가 된다 — MyBatis 기본 매핑은 기본 생성자와 setter를 요구하고, 생성자로 값을 채우려면 MyBatis XML에서는 <constructor> 태그를, JPA 네이티브 쿼리에서는 @ConstructorResult를 써야 한다. 둘 다 “몇 번째 열을 몇 번째 생성자 인자에 넣을지”를 순서로 정하는 방식이라, 이렇게 생성자를 쓰면 열 순서에 위치로 묶인다. 위 예시에서 productName과 userName은 둘 다 String이라, 순서가 뒤바뀌어도 컴파일되고 실행되며 값만 잘못 들어간다. assert 표준이 경계한 “조용히 잘못되는 코드”다.
인터페이스로 두면 Spring Data가 별칭을 getter 이름에 이름으로 매칭하므로 순서 문제가 없고, 생성자도 setter도 없으니 DTO 생성 표준의 예외를 늘리지 않아도 된다.
대신 이 방식은 JPA 전용이다. 조인 조회를 MyBatis로 한다면 결과 타입은 XxxProjection이 아니라 XxxMapperResult다 — MyBatis 결과 타입에는 이미 예외(private 기본 생성자, setter 없음)가 있으므로 그쪽 규칙을 그대로 쓴다. XxxProjection은 JPA 이름이고, XxxMapperResult는 MyBatis 이름이다.
네이티브 쿼리로 조인해서 채우고, Repository 구현체가 Domain이나 전용 결과 타입으로 변환해 내보낸다. DTO 네이밍 표준에서 “집계·조인 조회는 XxxProjection으로 이름을 분리한다”고 한 것이 바로 이 자리다.
연관관계를 우회하려고 억지로 쓰는 게 아니다. 목록이나 통계처럼 여러 도메인의 값을 한 화면에 보여주는 조회는 애초에 애그리거트 단위 조회와 성격이 다르다.
단건이나 소량이면 Service에서 각 도메인을 호출해 조합해도 된다. 기준은 쿼리가 반복되는가다. 목록을 돌면서 도메인별 조회를 반복하고 있다면 Projection으로 옮길 때다.
그런데
Projection은infra에 package-private으로 있다. 여기까지 정하고 나서도 그 결과가 무슨 타입으로 밖에 나가는지는 비어 있었다. 위 문장의 “전용 결과 타입”이 그 자리인데 이름도 위치도 정한 적이 없었다. 나중에 조회 전용 타입 표준에서domain의XxxView로 확정했다. 구현체가Projection·MapperResult를View로 바꿔 내보내고, 조합으로 채울 때도 같은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이다.
이 기준은 프레임워크를 묻지 않는다. 그래서 common 안은 섞여 있고, 지키는 단위는 패키지가 아니라 domain이 쓰는 타입이다. 다음 절이 그 이야기다.
common은 쪼개지 않고, domain이 쓰는 타입만 프레임워크에서 떼어 둔다
위 트리를 다시 보면 섞여 있다. 이 글 트리의 예시 클래스로는 ErrorCode·ErrorCodeException·PagingResult가 프레임워크 타입을 import하지 않는 순수 자바 클래스지만(실제 프로젝트의 클래스는 다를 수 있다 — 아래에서 센 실제 프로젝트의 PagingResult에는 Swagger 애노테이션이 붙어 있다), ApiExceptionHandler는 @RestControllerAdvice가 붙은 스프링 클래스이고 security/ 아래 둘(ApiAuthenticationEntryPoint·ApiAccessDeniedHandler)은 스프링 시큐리티 인터페이스를 구현한다. 원칙 2 첫 줄의 실측이 이것을 기계로 확인했다. 다만 실측은 당시 common 구성 5개 파일(HttpApiResponse·ErrorCode·ErrorCodeException·ApiExceptionHandler·ApiAuthenticationEntryPoint)로 쟀고, 스프링 라이브러리를 빼면 그중 ApiExceptionHandler·ApiAuthenticationEntryPoint 두 파일이 컴파일되지 않았다. 지금 트리 10개 파일 중 나머지 5개(ServiceResult·ServiceResults·PagingResult·SecurityResponseWriter·ApiAccessDeniedHandler)는 실측에 없었다. 실험의 파일 목록은 증명 글에 있다. 내가 확인한 실제 프로젝트 두 곳도 common 안에 프레임워크에 기대는 파일과 안 기대는 파일이 섞여 있었다.
그래서 원칙 2는 처음에 「common이 프레임워크나 특정 계층에 의존하지 않는 한, 모든 패키지가 common을 봐도 의존 방향은 깨지지 않는다」라고 적었다(2026-10-20에 지금 문장으로 고쳤다). 이 문장의 전제인 「common이 프레임워크에 의존하지 않는다」가 패키지 단위로는 거짓이었다 — 위 실측에서 보듯 common의 일부는 스프링 없이 컴파일되지 않는다. 정해야 했던 건 이거였다 — domain이 common을 봐도 스프링에 닿지 않는다는 것을 무엇으로 보장하나.
안 A — common은 그대로 두고, 지킬 단위를 패키지에서 「domain이 쓰는 타입」으로 옮긴다.
트리는 위 그대로다. 원칙 2의 조건을 「domain은 프레임워크에 직접도, common을 거쳐서도 닿지 않는다」로 고치고, 이것을 원칙 6의 네 번째 ArchUnit 규칙으로 켠다. 이 규칙은 domain에서 출발해 import를 따라 닿는 모든 타입을 보고, 그중 하나라도 스프링·jakarta·Swagger면 테스트를 실패시킨다.
얻는 것은 패키지를 하나도 옮기지 않는다는 것과, domain이 쓰는 타입이 무엇이든 — 누가 목록에 적어 두지 않은 타입이라도 — 같은 규칙에 걸린다는 것이다.
안 B — common을 프레임워크 무의존 패키지와 의존 패키지 둘로 쪼갠다.
common/
├── core/ ErrorCode, ErrorCodeException, PagingResult ... ← domain 이 봐도 된다
└── web/ ApiExceptionHandler, security/* ← domain 이 보면 안 된다
(core·web은 설명을 위해 붙인 이름이다.) 얻는 것은 원칙 2의 처음 문장이 패키지 단위로 그대로 참이 된다는 것이다. 「domain은 common/core만 본다」 한 줄로 규칙이 끝나고, 폴더만 봐도 무엇을 써도 되는지 보인다.
버린 이유는 실제 코드에서 error·response·security 하위 패키지가 전부 안에서 갈라진다는 것이다. error 하나만 봐도 ErrorCode(무의존)와 ApiExceptionHandler(의존)가 같이 있다. 쪼개면 위 트리와 인증 사용자 표준·예외 처리 표준의 경로 그림까지 다시 그려야 한다. 그런데 쪼갠 뒤에도 누가 domain에서 common/web의 타입을 import하면, 그것을 잡는 것은 폴더가 아니라 「domain은 common/web을 보지 않는다」를 검사하는 ArchUnit 규칙이어야 한다. 폴더는 어디를 보면 안 되는지 알려 줄 뿐 막지는 않는다. 결국 지키는 것은 안 A와 같은 규칙 하나이고, 안 B는 그 위에 폴더 이동을 더 낸다.
안 C — 조건 문장을 지우고, domain이 써도 되는 타입 목록만 남긴다.
얻는 것은 거짓 문장이 사라지고 고칠 곳이 한 줄이라는 것이다.
버린 이유는 둘이다. domain이 common을 봐도 되는 근거가 함께 사라진다. 그리고 목록 밖의 타입은 아무것도 막지 않는다 — 누가 domain에서 ApiExceptionHandler를 import해도, 사람이 목록을 기억해서 리뷰에서 잡아야만 지켜진다. 원칙 6이 「사람이 리뷰에서 본다」를 버린 것과 같은 이유다.
골랐다 — 안 A. 안 C가 못 하는 것, 곧 목록 밖 타입까지 막는 것을 안 A의 규칙은 한다 — import를 따라 닿는 타입을 전부 보기 때문이다. 그리고 안 B도 결국 같은 규칙 하나에 기대야 하는데, 안 A는 그 보장을 패키지를 하나도 옮기지 않고 얻는다.
켜기 전에 이 표준을 적용하는 실제 프로젝트 하나에서 세어 봤다. 그 프로젝트는 PostgreSQL을 쓰는 곳으로, 위에서 말한 두 곳 중 하나다. domain이 import하는 common 타입은 ErrorCode·ErrorCodeException·PagingResult·CursorPagingResult와 알림 타입 셋(AlertChannel 등)이고, 어느 것도 org.springframework나 jakarta 패키지를 직접 import하지 않는다. 센 것은 여기까지다. 그 타입들이 쓰는 타입을 끝까지 따라가는 네 번째 규칙을 그 프로젝트에 실제로 돌려 위반이 0건인지는 아직 확인하지 않았다. PagingResult·CursorPagingResult에는 API 문서용 Swagger 애노테이션(@Schema)이 붙어 있다. 이것은 스프링·jakarta 밖이라 처음 규칙에는 걸리지 않았다. 이 구멍은 바로 아래에서 막았다.
대가는 폴더를 봐서는 domain이 무엇을 써도 되는지 알 수 없다는 것이다. 패키지 이름이 답해 주지 않으니, 스프링에 닿는 타입을 domain에서 쓰면 ArchUnit 테스트가 실패하고 나서야 안다.
domain은 API 문서 도구 Swagger에도 닿지 않는다
처음 규칙은 스프링과 jakarta만 막았다. 그런데 domain이 쓰는 common 타입 PagingResult·CursorPagingResult에 @Schema(io.swagger)가 붙어 있다. 그 타입들이 @Schema를 import하니, domain을 컴파일하려면 Swagger 라이브러리가 컴파일 경로에 있어야 한다(빼고 컴파일해 보지는 않았다). 원칙 2에서 「domain은 프레임워크에 직접도, common을 거쳐서도 닿지 않는다」를 정해 놓고 바로 옆에 이 구멍이 남아 있었다. 정해야 했던 건 이거였다 — domain이 Swagger에 닿는 것도 막나.
안 A — Swagger는 허용한다. @Schema는 문서를 만드는 쪽(springdoc)이 읽는 표시일 뿐, domain 코드의 동작에는 영향이 없다. 그러니 그 프로젝트의 코드도 지금 그대로 둘 수 있다. 버린 이유는 그래도 컴파일 의존이 생긴다는 것이다. import가 있는 한 Swagger 라이브러리가 컴파일 경로에 있어야 한다. 그리고 그 의존이 묶는 것은 API 문서, 곧 api 계층의 관심사다.
안 B — 정하지 않고 이슈로 남긴다. 지금 고칠 것이 없다. 버린 이유는 원칙에 알려진 구멍을 둔 채 둔다는 것이다. 원칙 2에 「domain은 프레임워크에 직접도, common을 거쳐서도 닿지 않는다」를 적어 놓고 예외 하나를 말없이 안고 가게 된다.
골랐다 — 막는다. domain이 닿으면 안 되는 것에 io.swagger를 더하고, 원칙 6의 네 번째 규칙에도 넣는다. domain이 쓰는 common 타입에는 @Schema를 붙이지 않는다. API 문서 설명은 api 계층의 응답 타입에 붙인다. Swagger는 API 문서를 만드는 도구라 api 계층의 관심사다. domain이 그 도구에 묶이면 원칙 2의 「domain은 프레임워크에 직접도, common을 거쳐서도 닿지 않는다」에 구멍이 생긴다. 안 A는 그 구멍을 domain의 동작에는 영향이 없다는 이유로 받아들이고, 안 B는 구멍을 안 막은 채 미룬다.
대가는 그 프로젝트의 PagingResult·CursorPagingResult에서 @Schema를 떼야 한다는 것이다(그 프로젝트 쪽 일이다). springdoc은 애노테이션이 없어도 필드를 읽어 문서를 만들지만, 붙여 둔 설명 문구는 문서에서 빠진다. 이 부분은 일반적으로 알려진 동작이고 그 프로젝트에서 재지 않았다.
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;
Snapshot은 application에서 받아 변환해 넘겨야 하는데 api가 직접 잡았다. 원칙 4를 어긴 자리이고, 사람 눈으로는 오늘 처음 봤다.
안 A — 사람이 리뷰에서 본다. 도구가 안 늘고 판단이 유연하다. 버린 이유는 못 봤기 때문이다. 18곳 중 하나인데 그동안 아무도 못 잡았다. 파일이 669개인 저장소에서 import 한 줄을 눈으로 거르는 것은 되는 척만 하는 규칙이다.
안 B — 규칙을 한 벌씩 넣는다. 검사를 켜면 기존 위반이 쏟아져 예외 처리만 하다 끝나는 것이 흔한 실패라, 보통은 이게 맞다. 여기서는 버린다 — 위 표에서 잰 규칙들은 지금 위반이 거의 0이라 다 넣어도 안 터진다. 그리고 지금이 제일 싼 시점이다. 코드가 더 늘면 그때는 「다 고치고 켜기」와 「예외 목록을 안고 켜기」 중에 골라야 한다.
골랐다 — ArchUnit 을 넣고 그때 있던 세 벌을 한꺼번에 켠다. 안 A가 못 지켜주는 것은 매번 걸리는 것이고, 안 B가 못 지켜주는 것은 0건일 때의 싼 값이다.
계층 의존 방향 api → infra, domain → api/application/infra 를 막는다
package-private infra 와 XxxService 가 패키지 밖으로 안 나가는지 본다
도메인 간 참조 다른 도메인은 application 에서만, 인터페이스로 만난다
domain 프레임워크 무의존 domain 은 org.springframework.., jakarta.., io.swagger.. 타입에 직접이든 common 을 거쳐서든 닿지 않는다
처음 켠 것은 위 세 줄이었다. 네 번째는 2026-10-20에 더했다 — 원칙 5에서 common을 쪼개지 않기로 하면서, 원칙 2의 조건을 사람 대신 지킬 장치가 필요해졌다. 위 실측 표에 이 규칙이 없는 것은 그래서다. 그래서 「다 넣어도 안 터진다」는 앞의 세 벌에만 확인된 말이다 — 세 벌은 위반이 거의 0(셋째 규칙은 1~2)으로 확인됐고, 네 번째는 재지 않았다. 네 번째 규칙은 그 프로젝트에 실제로 돌려 보지 않았고, 원칙 5에서 센 것은 domain이 import하는 common 타입이 스프링·jakarta를 직접 import하는지까지다.
@ArchTest
static final ArchRule domain_은_프레임워크에_닿지_않는다 =
noClasses().that().resideInAPackage("..domain..")
.should().transitivelyDependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta..", "io.swagger..");
noClasses().that().resideInAPackage("..domain..")는 「이름에 domain 패키지가 들어간 클래스는 하나도」라는 뜻이다. transitivelyDependOnClassesThat()이 이 규칙의 핵심이다 — 바로 import한 타입만 보지 않고, 그 타입이 import한 타입, 또 그 타입이 import한 타입까지 끝까지 따라간다. 그래서 domain이 common의 어떤 타입을 거쳐 스프링에 닿아도 걸린다. 마지막 줄의 ..은 「이 패키지와 그 아래 전부」다. 이 메서드는 그 프로젝트가 쓰는 ArchUnit 1.4.1에 있다. "io.swagger.."는 같은 날 뒤이어 더했다 — Swagger도 domain이 닿으면 안 되는 것으로 정했기 때문이다(원칙 5). 그래서 그 프로젝트에서는 PagingResult·CursorPagingResult의 @Schema가 이 줄과 어긋난다. 규칙을 실제로 돌려 그 자리에서 걸리는지는 확인하지 않았다.
빼는 것은 목록으로 적는다. 서블릿 필터처럼 계층 구조 밖에서 도는 것이 있다 — 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/ ← 만들지 않는다
OrderServiceTest·OrderControllerTest는 그 이름이 가리키는 클래스를 검사하는 테스트이고, OrderContract는 주문 API가 어떤 요청에 어떤 결과를 내야 하는지 미리 정해 둔 계약 enum이다. API 테스트 계약표 표준에서 자세히 다룬다.
그러면 도메인 폴더가 그대로 「이 도메인에 테스트가 있는가」의 답이 된다. 폴더가 지표라서 어긋나면 집계에서 티가 난다.
원칙 3의 package-private이 이 규칙의 절반을 붙든다. infra를 만지는 테스트는 같은 패키지가 아니면 애초에 컴파일이 안 된다. 종류별 폴더로 모으려면 infra를 public으로 열어야 하고, 그 순간 원칙 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은 원래 도메인마다 하나씩이라(계약 enum 생성자의 state 매개변수가 그 도메인의 상황 enum 타입이다 — Cell) 옮길 데가 이미 정해져 있다.
버린 이유는 원인이 계약표가 아니기 때문이다. 표준을 훑어보니 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 | 컴파일러는 절반만 붙든다 |
domain이 common을 봐도 되나 |
domain이 쓰는 타입이 프레임워크에 직접·간접으로 안 닿을 때만 |
common은 섞여 있다. 쪼개지 않고 ArchUnit 네 번째 규칙이 타입을 따라가며 본다 |
domain이 쓰는 common 타입에 @Schema를 붙이나 |
붙이지 않는다 | Swagger(io.swagger)도 domain이 닿으면 안 된다. API 문서 설명은 api의 응답 타입에 붙인다 |
| 규칙을 몇 개씩 켜나 | 한꺼번에 | 지금 위반이 0이라 안 터진다. 나중엔 비싸진다 |
| 규칙에서 뺀 것은 | 목록으로 적는다 | 조용히 빼면 「원래 되는구나」로 읽힌다 |
| 테스트는 어디에 두나 | 대상이 사는 도메인 패키지 | 도메인 폴더가 곧 「이 도메인에 테스트가 있는가」의 답이다 |
| 종류별 폴더로 모으나 | 아니다 | 도메인 폴더가 비어 보여 무테스트로 집계된다 |
| 계약 enum은 어디에 | 그 도메인의 테스트 패키지 | 계약과 떨어지면 세는 사람이 예외를 기억해야만 맞는다 |
위 표의
api두 줄은 접미사 분할 후에 바뀐다.{도메인}/api를controller·request·response로 나누고 나면XxxController는 밖에서 부르는 코드가 없어 package-private이 되고,XxxRequest·XxxResponse는controller가 다른 패키지에서 이름을 부르므로 대부분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를 만지는 테스트는 같은 패키지가 아니면 컴파일이 안 된다. 종류별 폴더로 모으려면 infra를 public으로 열어야 하므로, 테스트를 잘못 두는 것과 접근 제어가 무너지는 것이 같은 사건이 된다. 원칙 하나를 세우려다 이미 있던 원칙이 그 절반을 이미 지키고 있었던 셈이다.
2026-10-20에 원칙 2의 조건 문장이 원칙 5의 트리와 어긋난다는 것을 고쳤다. 원칙 2는 「common이 프레임워크에 의존하지 않는 한」 모든 패키지가 common을 봐도 된다고 적었는데, 원칙 5의 common에는 ApiExceptionHandler처럼 스프링 클래스가 있었다. 스프링 라이브러리를 빼고 컴파일해 보니 실제로 두 파일이 죽었다(원칙 2 첫 줄의 실측). 처음에는 common을 무의존/의존 두 패키지로 쪼개는 쪽으로 기울었다. 그런데 실제 코드를 보니 error·response·security가 전부 안에서 갈라졌고, 쪼개면 다른 표준 두 편의 경로 그림까지 다시 그려야 했다. 방향이 꺾인 것은 「domain이 실제로 쓰는 타입」을 세어 봤을 때다. 그 프로젝트의 domain이 쓰는 common 타입은 하나도 스프링이나 jakarta를 import하지 않았다. 지켜야 하는 것은 패키지 전체가 아니라 그 타입들이었고, 그건 패키지를 옮기지 않아도 ArchUnit이 import를 끝까지 따라가며 지킬 수 있었다. 그래서 조건 문장을 타입 단위로 고치고 규칙을 네 번째로 켰다. 조건을 지우고 타입 목록만 남기는 안도 있었지만, 목록 밖 타입을 아무것도 막지 않아 버렸다. 같은 날 원칙 2에 「방향은 import 방향」이라는 문장도 더했다 — DTO 네이밍 표준이 「방향」을 실행 중 호출 순서로 쓰고 있던 것을 고치면서, 링크해 둘 정의가 필요했다. 마지막으로 PagingResult의 @Schema 때문에 「Swagger도 막나」가 열린 채 남아 있던 것을 닫았다. Swagger는 API 문서, 곧 api의 관심사라 막기로 하고 io.swagger를 네 번째 규칙에 더했다. domain 동작에는 영향이 없으니 허용하자는 안은 컴파일 의존이 그대로 남아서 버렸다.
정리
- 경계는 ArchUnit 이 지킨다. 컴파일러가 붙드는 209개는 하나도 안 샜고, 컴파일러가 모르는 자리에서만 샜다.
- 도메인을 쪼개는 기준은 크기가 아니라 자기 유스케이스다. 유스케이스가 없는 개념은 도메인이 아니라 그 도메인의 테이블이고, 쪼갠 것은 계층 넷을 다 갖는다.
- 최상위는 도메인으로 나눈다. 계층별로 나누면 Entity를
public으로 열 수밖에 없다 - 도메인 안은
api/application/domain/infra네 겹이다. 의존은 전부domain을 향한다 infra는 전부 package-private으로 막는다. Entity 유출이 약속이 아니라 컴파일 에러가 된다- Repository 구현체도 감출 수 있다. 주입이 인터페이스로 이루어지기 때문이다
- 도메인끼리는
application에서만, 상대가 공개한 것만 쓴다. 순환 참조는 금지한다 - Entity 연관관계는 같은 도메인 안에서만 쓴다. 도메인을 넘는 참조는 ID로 하고, 이건 컴파일러가 강제한다
- 여러 도메인을 조인하는 조회는
XxxProjection으로 뺀다. 쿼리가 반복되기 시작하면 옮길 때다 common에는 도메인이 없어져도 남을 것만 둔다domain이 쓰는common타입은 프레임워크(스프링 ·jakarta· Swagger)에 직접·간접으로 닿지 않는다.common은 쪼개지 않는다. 지키는 단위는 패키지가 아니라 타입이고, ArchUnit 네 번째 규칙이 지킨다. API 문서 설명(@Schema)은api의 응답 타입에 붙인다ErrorCode의 도메인 결합은 인정한다. 코드 중복을 한눈에 보기 위한 타협이고, 도메인 번호는DomainCodeenum에 모은다- 테스트도 같은 도메인 구조를 따른다. 종류별 폴더로 모으면 도메인 폴더가 비어 보여 무테스트로 집계되고,
infra를 테스트하려고public을 열게 된다
자신만의 철학을 만들어가는 중입니다.
댓글남기기