지금까지 세운 표준들을 다시 읽어보면 같은 문장이 반복해서 나온다.
- “
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도 쓸 수 있게 된다.
도메인별로 모으면 이 문제가 사라진다. 그리고 나중에 도메인 하나를 별도 모듈이나 서비스로 떼어낼 때도 폴더 하나만 옮기면 된다.
원칙 2. 도메인 안은 네 겹으로 나눈다
지금까지 세운 의존 규칙과 1:1로 맞춘다.
order/
├── api/ Controller, XxxRequest, XxxResponse
├── application/ Service, XxxCommand, XxxQuery
├── domain/ XxxDomain, XxxRepository (인터페이스)
└── infra/ XxxEntity, XxxMapper, Repository 구현체
Controller, Request, Response"] --> APP["application
Service, Command, Query"] 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
화살표가 전부 domain을 향한다. domain은 아무 쪽도 가리키지 않는다. 이게 DTO 네이밍 표준에서 “Repository 인터페이스는 Service가 정의하고 구현체는 갈아 끼운다”고 한 것의 물리적 형태다.
infra가 domain을 향한다는 점이 핵심이다. 구현이 추상을 의존하지, 그 반대가 아니다.
원칙 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
public class OrderService {
private final OrderRepository orderRepository; // 내 도메인
private final ProductQueryService productQueryService; // 상품 도메인의 공개 창구
}
// ❌ 다른 도메인의 내부를 직접 본다
import com.example.shop.product.domain.ProductDomain;
import com.example.shop.product.infra.ProductEntity; // 애초에 컴파일도 안 된다
infra는 package-private이라 자동으로 막히지만, domain은 같은 도메인 안에서 public이어야 하므로 다른 도메인에서도 보인다. 여기는 규율로 지켜야 하는 영역이다.
도메인 간 순환 참조는 금지한다. 주문이 상품을 부르는데 상품도 주문을 부른다면, 둘 중 하나가 잘못 나뉜 것이거나 제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;
같은 도메인 안에서는 제약이 없다. OrderEntity와 OrderItemEntity는 둘 다 order/infra에 있으므로, package-private이어도 서로 참조되고 @OneToMany가 정상 동작한다.
// order/infra — 같은 패키지라 문제없다
@Entity
class OrderEntity {
@OneToMany(mappedBy = "order")
private List<OrderItemEntity> items;
}
즉 패키지가 애그리거트 경계 역할을 한다. 한 패키지 안은 객체 참조, 밖은 ID 참조다. 이 규칙은 문서가 아니라 컴파일러가 지켜준다.
그 애그리거트를 어디서 끊을지는 애그리거트 경계 표준에서 다룬다.
여러 도메인을 조인하는 조회는 Projection으로 뺀다
연관관계를 못 쓰니 조인이 필요한 조회를 따로 처리해야 한다. 조회를 주도하는 도메인의 infra에 전용 경로를 둔다.
// order/infra/OrderSummaryProjection.java
class OrderSummaryProjection { // package-private
private final Long orderId;
private final String productName; // product 테이블에서 조인해 온 값
private final String userName; // user 테이블에서 조인해 온 값
// ...
}
네이티브 쿼리나 MyBatis로 조인해서 채우고, Repository 구현체가 Domain이나 전용 결과 타입으로 변환해 내보낸다. DTO 네이밍 표준에서 “집계·조인 조회는 XxxProjection으로 이름을 분리한다”고 한 것이 바로 이 자리다.
연관관계를 우회하려고 억지로 쓰는 게 아니다. 목록이나 통계처럼 여러 도메인의 값을 한 화면에 보여주는 조회는 애초에 애그리거트 단위 조회와 성격이 다르다.
단건이나 소량이면 Service에서 각 도메인을 호출해 조합해도 된다. 기준은 쿼리가 반복되는가다. 목록을 돌면서 도메인별 조회를 반복하고 있다면 Projection으로 옮길 때다.
원칙 5. common에는 도메인이 없는 것만 둔다
common/
├── response/
│ ├── HttpApiResponse.java
│ ├── ServiceResult.java
│ ├── ServiceResults.java
│ └── ServicePagingResult.java
├── error/
│ ├── ErrorCode.java
│ ├── BusinessException.java
│ └── ApiExceptionHandler.java
└── security/
├── SecurityResponseWriter.java
├── ApiAuthenticationEntryPoint.java
└── ApiAccessDeniedHandler.java
기준은 “이 클래스가 특정 도메인이 없어져도 남아야 하는가”다. 주문 도메인을 통째로 지워도 HttpApiResponse는 남아야 하므로 common이다.
ErrorCode는 인정하고 가는 타협이다
원칙대로라면 common은 어떤 도메인도 몰라야 한다. 그런데 ErrorCode에는 STOCK_SHORTAGE, ORDER_NOT_FOUND 같은 도메인별 값이 들어간다.
도메인별로 ErrorCode를 쪼개고 common에는 인터페이스만 두는 방법도 있다. 하지만 그러면 코드 번호 중복을 사람이 관리해야 한다. 주문과 결제가 각자 409001을 쓰는 사고를 막을 방법이 없어진다.
한 곳에 모아두면 중복이 한눈에 보인다. 그래서 이 결합은 받아들이기로 했다. 타입 의존이 아니라 상수 나열이라는 점에서 실제 결합도 얕다.
대신 뒤 3자리를 도메인별 대역으로 나눠 관리한다.
// 409 — 현재 상태로 불가능
// 001~099 주문
STOCK_SHORTAGE(409001, "재고가 부족합니다."),
ORDER_ALREADY_CONFIRMED(409002, "이미 확정된 주문입니다."),
// 100~199 회원
DUPLICATE_EMAIL(409100, "이미 가입된 이메일입니다."),
전체 구조
com.example.shop
├── common/
│ ├── response/ HttpApiResponse, ServiceResult ...
│ ├── error/ ErrorCode, BusinessException, ApiExceptionHandler
│ └── security/ EntryPoint, AccessDeniedHandler
│
├── order/
│ ├── api/ OrderController, CreateOrderRequest, OrderResponse
│ ├── application/ OrderService, CreateOrderCommand, FindOrderQuery
│ ├── domain/ OrderDomain, OrderRepository(interface)
│ └── infra/ OrderEntity*, OrderEntityMapper*, OrderJpaRepository*
│
├── product/
│ └── (동일 구조)
│
└── user/
└── (동일 구조)
* package-private
판단 기준 정리
| 클래스 | 위치 | 접근 제어 |
|---|---|---|
XxxController |
{도메인}/api |
public |
XxxRequest, XxxResponse |
{도메인}/api |
public |
XxxService |
{도메인}/application |
public |
XxxCommand, XxxQuery |
{도메인}/application |
public |
XxxDomain |
{도메인}/domain |
public |
XxxRepository (인터페이스) |
{도메인}/domain |
public |
XxxEntity |
{도메인}/infra |
package-private |
XxxMapperParam, XxxMapperResult |
{도메인}/infra |
package-private |
XxxEntityMapper |
{도메인}/infra |
package-private |
XxxProjection (조인·집계 조회) |
{도메인}/infra |
package-private |
| Repository 구현체 | {도메인}/infra |
package-private |
HttpApiResponse, ServiceResult |
common/response |
public |
ErrorCode, BusinessException |
common/error |
public |
ApiExceptionHandler |
common/error |
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자리를 도메인별 대역으로 나누기로 했다.
마지막으로 도메인 간 참조 규칙을 정했다. infra는 package-private이라 자동으로 막히지만 domain은 같은 도메인 안에서 public이어야 해서 다른 도메인에도 보인다. 여기는 컴파일러가 막아주지 못하므로 application 계층에서만, 상대가 공개한 것만 쓰기로 규율을 남겼다.
정리
- 최상위는 도메인으로 나눈다. 계층별로 나누면 Entity를
public으로 열 수밖에 없다 - 도메인 안은
api/application/domain/infra네 겹이다. 의존은 전부domain을 향한다 infra는 전부 package-private으로 막는다. Entity 유출이 약속이 아니라 컴파일 에러가 된다- Repository 구현체도 감출 수 있다. 주입이 인터페이스로 이루어지기 때문이다
- 도메인끼리는
application에서만, 상대가 공개한 것만 쓴다. 순환 참조는 금지한다 - Entity 연관관계는 같은 도메인 안에서만 쓴다. 도메인을 넘는 참조는 ID로 하고, 이건 컴파일러가 강제한다
- 여러 도메인을 조인하는 조회는
XxxProjection으로 뺀다. 쿼리가 반복되기 시작하면 옮길 때다 common에는 도메인이 없어져도 남을 것만 둔다ErrorCode의 도메인 결합은 인정한다. 코드 중복을 한눈에 보기 위한 타협이고, 대역을 나눠 관리한다
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/package-structure-standard/SKILL.md
---
name: package-structure-standard
description: 패키지 구조와 접근 제어 규칙. 새 클래스를 만들거나 배치할 때, import를 추가할 때, 계층 위반을 검토할 때 반드시 적용한다.
---
# 패키지 구조 표준
패키지는 정리 수단이 아니라 **계층 위반을 컴파일 에러로 만드는 강제 수단**이다.
## 최상위 구조
- 최상위는 계층이 아니라 도메인으로 나눈다. `order/`, `product/`, `user/`, 그리고 `common/`.
- 계층별(`controller/`, `service/`)로 나누지 않는다. Entity를 `public`으로 열어야만 동작하게 되어 가시성으로 막을 수 없다.
## 도메인 내부
- 각 도메인은 `api`, `application`, `domain`, `infra` 네 패키지로 나눈다.
- `api`: Controller, `XxxRequest`, `XxxResponse`
- `application`: Service, `XxxCommand`, `XxxQuery`
- `domain`: `XxxDomain`, `XxxRepository` 인터페이스
- `infra`: `XxxEntity`, `XxxMapperParam`, `XxxMapperResult`, Mapper, Repository 구현체
- 의존 방향은 `api → application → domain`이고 `infra → domain`이다. `domain`은 다른 패키지를 참조하지 않는다.
## 접근 제어
- `infra` 패키지의 모든 클래스는 `public`을 붙이지 않고 package-private으로 만든다.
- `XxxEntity`, `XxxMapperResult`, `XxxMapperParam`, `XxxEntityMapper`, Repository 구현체가 모두 해당된다.
- Repository 구현체를 package-private으로 둬도 되는 이유는 주입이 `domain`의 인터페이스 타입으로 이루어지기 때문이다.
- `api`, `application`, `domain`의 클래스는 `public`으로 만든다.
## 도메인 간 참조
- 다른 도메인은 `application` 계층에서만 호출한다.
- 다른 도메인의 `domain`이나 `infra` 패키지를 직접 import하지 않는다. 상대 도메인의 `application`이 공개한 것만 쓴다.
- 도메인 간 순환 참조를 만들지 않는다.
- Entity 연관관계(`@ManyToOne`, `@OneToMany`)는 **같은 도메인 안에서만** 만든다. 같은 `infra` 패키지에 있으므로 package-private이어도 서로 참조된다.
- 다른 도메인의 데이터는 `@ManyToOne`이 아니라 **ID 컬럼으로 참조**한다. 예: `private Long productId`.
- 여러 도메인을 조인해야 하는 조회는 조회를 주도하는 도메인의 `infra`에 `XxxProjection`을 두고 네이티브 쿼리나 MyBatis로 채운다. 단건이나 소량은 `application`에서 각 도메인을 호출해 조합해도 되며, 쿼리가 반복되기 시작하면 Projection으로 옮긴다.
## common
- `common`에는 특정 도메인이 사라져도 남아야 하는 것만 둔다.
- `common/response`: `HttpApiResponse`, `ServiceResult`, `ServiceResults`, `ServicePagingResult`
- `common/error`: `ErrorCode`, `BusinessException`, `ApiExceptionHandler`
- `common/security`: `AuthenticationEntryPoint`, `AccessDeniedHandler` 구현체
- `ErrorCode`는 도메인별 상수를 포함하지만 한 곳에 모은다. 코드 번호 중복을 한눈에 확인하기 위해서다. 뒤 3자리를 도메인별 대역으로 나눠 관리한다.
GitHub Copilot — .github/instructions/package-structure-standard.instructions.md
---
description: 패키지 구조와 접근 제어 규칙
applyTo: "**/*.java"
---
- 최상위 패키지는 계층이 아니라 도메인으로 나눈다.
- 각 도메인은 `api`, `application`, `domain`, `infra` 네 패키지로 구성한다.
- Controller와 Request/Response는 `api`에 둔다.
- Service와 Command/Query는 `application`에 둔다.
- Domain 클래스와 Repository 인터페이스는 `domain`에 둔다.
- Entity, MapperParam, MapperResult, Mapper, Repository 구현체는 `infra`에 둔다.
- `infra` 패키지의 클래스에는 `public`을 붙이지 않고 package-private으로 선언한다.
- `api`, `application`, `domain`의 클래스는 `public`으로 선언한다.
- `domain` 패키지는 다른 패키지를 참조하지 않는다.
- 다른 도메인은 `application` 계층에서만 호출하고, 상대의 `domain`이나 `infra`를 import하지 않는다.
- 도메인 간 순환 참조를 만들지 않는다.
- Entity 연관관계는 같은 도메인 안에서만 만들고, 다른 도메인은 `private Long productId`처럼 ID로 참조한다.
- 여러 도메인을 조인하는 조회는 `XxxProjection`을 `infra`에 두고 네이티브 쿼리나 MyBatis로 채운다.
- `HttpApiResponse`와 `ServiceResult` 계열은 `common/response`에 둔다.
- `ErrorCode`, `BusinessException`, `ApiExceptionHandler`는 `common/error`에 둔다.
- `ErrorCode`는 하나로 유지하고 코드 뒤 3자리를 도메인별 대역으로 나눈다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기