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

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

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


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

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

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

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

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


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

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

order/
├── api/            Controller, XxxRequest, XxxResponse
├── application/    Service, XxxCommand, XxxQuery
├── domain/         XxxDomain, XxxRepository (인터페이스)
└── infra/          XxxEntity, XxxMapper, Repository 구현체
graph LR API["api
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가 정의하고 구현체는 갈아 끼운다”고 한 것의 물리적 형태다.

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


원칙 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;

같은 도메인 안에서는 제약이 없다. 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
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자리를 도메인별 대역으로 나눈다.

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

댓글남기기