지금까지 정한 표준들은 Repository의 자리를 정했다. 애그리거트 경계 표준은 “Repository는 애그리거트 루트당 하나”라고 했고, 패키지 구조 표준은 인터페이스는 domainpublic으로, 구현체는 infra에 package-private으로 두라고 했다. DTO 네이밍 표준은 반환 타입을 Domain으로, 페이징은 PagingResult<Domain>으로 고정했다.

// 지금까지 정한 것만으로 짠 Repository — 이거면 충분할 줄 알았다
public interface OrderRepository {
    OrderDomain save(OrderDomain order);
    Optional<OrderDomain> findById(Long id);
    PagingResult<OrderDomain> findAll(int page, int size);
}

그런데 이 인터페이스로 두 가지를 시키면 바로 막힌다.

하나, 관리자 검색 화면. 상태·기간·키워드 중 아무거나 골라서 조합해 검색하는 화면을 만들려면 findAll(page, size)로는 안 된다. 조건이 있을 수도 없을 수도 있는 경우를 어떻게 짤지 이 인터페이스는 말해주지 않는다.

둘, 만료 주문 5만 건 일괄 처리. 이걸 Domain을 하나씩 로드해서 저장하면 애그리거트 규칙은 지키지만 배치 성능이 나오지 않는다. 그렇다고 orderRepository에 벌크 쿼리 메서드를 추가하면 “Repository는 항상 Domain을 거친다”는 애그리거트 경계 표준의 전제가 깨진다.

이 글은 이 두 가지 — 조건이 조합되는 검색애그리거트 경계를 벗어나는 대량 연산 — 을 실제로 어떻게 처리할지 정한다.


먼저: 이미 정해진 것

이 글은 세 표준 위에 선다.

  • 드나드는 단위는 애그리거트 루트다. 내부 객체만 따로 저장하는 진입점을 만들지 않는다 — 애그리거트 경계 표준
  • 인터페이스는 domainpublic, 구현체는 infra에 package-private — 패키지 구조 표준
  • 반환 타입은 항상 Domain이다. 단건은 Optional<Domain>, 목록은 PagingResult<Domain>DTO 네이밍 표준

이 셋이 “어디 있고 무엇을 반환하는가”를 정했다면, 이 글은 그 안에서 실제로 어떻게 조회하고 쓰는가를 정한다.

다만 첫 번째는 이 글에서 한 번 좁힌다. 애그리거트 경계 표준은 “Repository는 애그리거트 루트당 하나”라고 썼는데, 원칙 1에서 인터페이스를 둘로 나눈다. 그 규칙이 막으려던 건 인터페이스의 개수가 아니라 애그리거트를 우회하는 진입점이었으므로 충돌은 아니지만, 문장 자체는 정확하지 않았다. 두 표준 모두 “애그리거트 루트 단위로만 드나든다”로 고쳤다.


원칙 1. 저장·단건 조회와 동적 검색은 인터페이스를 나눈다

조건이 하나뿐이고 항상 존재하면 JPA로 충분하다.

Page<OrderEntity> findByStatus(OrderStatus status, Pageable pageable);

Spring Data가 Pageable을 받아 Page<T>를 만들어준다. COUNT 쿼리도 자동으로 같이 나간다.

문제는 조건이 여러 개가 독립적으로 있을 수도 없을 수도 있을 때다 — 상태(선택), 기간(선택), 키워드(선택)를 조합하는 검색. JPQL로 이걸 짜면 :status is null or status = :status 같은 조건이 조건 개수만큼 겹겹이 쌓여 문자열이 지저분해진다. 이럴 땐 MyBatis의 <where><if>가 낫다.

<select id="search" resultType="OrderMapperResult">
    SELECT * FROM orders
    <where>
        <if test="status != null">AND status = #{status}</if>
        <if test="fromDate != null">AND created_at &gt;= #{fromDate}</if>
        <if test="keyword != null">AND order_number LIKE CONCAT('%', #{keyword}, '%')</if>
    </where>
</select>

그래서 저장·단건 조회와 동적 검색을 인터페이스 자체로 나눈다.

// order/domain/OrderRepository.java — 쓰기 + 단건 조회, JPA
public interface OrderRepository {
    OrderDomain save(OrderDomain order);
    Optional<OrderDomain> findById(Long id);
}

// order/domain/OrderSearchRepository.java — 동적 검색, MyBatis
public interface OrderSearchRepository {
    PagingResult<OrderDomain> search(OrderSearchCondition condition);
}
// order/application/OrderService.java
@Service
@RequiredArgsConstructor
class OrderService implements OrderQueryUseCase {

    private final OrderRepository orderRepository;             // JPA
    private final OrderSearchRepository orderSearchRepository; // MyBatis

    public PagingResult<OrderDomain> searchOrders(OrderSearchQuery query) {
        return orderSearchRepository.search(query.toCondition());
    }
}

조건 타입은 domain에 둔다

search()의 파라미터가 OrderSearchQuery가 아니라 OrderSearchCondition인 것에 이유가 있다. DTO 네이밍 표준은 읽기 입력을 XxxQuery로 정했으니 그걸 그대로 쓰는 게 자연스러워 보이는데, 막힌다.

XxxQueryapplication에 있고 이 인터페이스는 domain에 있다. 패키지 구조 표준domainapplication을 참조하지 않는다고 정했으므로, XxxQuery를 파라미터로 받으면 의존 방향이 뒤집힌다. 그래서 조회 조건 타입은 domain에 따로 두고 Service가 변환해 넘긴다.

// order/application/OrderSearchQuery.java — Controller가 만들어 Service에 넘긴다
public class OrderSearchQuery {
    private final OrderStatus statusOrNull;
    private final LocalDate fromDateOrNull;
    private final String keywordOrNull;

    public OrderSearchCondition toCondition() { ... }
}

// order/domain/OrderSearchCondition.java — Repository가 받는다
public class OrderSearchCondition {
    private final OrderStatus statusOrNull;
    private final LocalDate fromDateOrNull;
    private final String keywordOrNull;
    private final int page;
    private final int size;
}

RequestCommand와 정확히 같은 구조다. 경계를 넘을 때 타입이 바뀐다는 규칙이 여기서도 그대로 적용된 것이고, 이름이 Query가 아닌 것도 그래서 맞다.

대가. 필드가 거의 같은 타입이 하나 늘고 변환 코드가 한 번 더 붙는다. 조건이 추가될 때 두 곳을 고쳐야 한다.

반환 타입의 이름에서 Service를 뗐다

PagingResult는 원래 ServicePagingResult였다. domain의 인터페이스가 반환하는 타입에 Service가 붙어 있는 게 이상해서 고쳤다. ServiceResult·ServiceResults는 Service만 만들고 Service만 반환하는데, 페이징만 필연적으로 계층을 넘는다totalCount가 DB의 COUNT 쿼리에서 나오므로 Repository가 실어 올릴 수밖에 없다. 이제 접두사가 있으면 Service 전용, 없으면 공유로 읽는다.

기준을 표로 정리하면 이렇다.

조건 개수 조건의 성격 기술
0~1개 항상 존재 JPA — Pageable/Page 그대로 활용
2개 이상 각각 있을 수도 없을 수도 있음 MyBatis — OrderSearchRepository로 분리

QueryDSL을 고려하지 않은 건 아니다. 타입 세이프한 동적 쿼리를 만들 수 있다는 장점이 있지만, 원칙 2에서 메서드 이름을 도메인 의미로 감싸기로 한 순간 그 장점이 무의미해진다. 호출부는 search(condition)만 보지 내부에서 조건을 어떻게 조립하는지 보지 않는다. 그럴 거면 이미 쓰고 있는 MyBatis를 재사용하는 쪽이 새 도구(Q타입 생성, 빌드 설정 추가)를 들이는 것보다 싸게 먹힌다.

대가. DTO 네이밍 표준은 JPA와 MyBatis를 “같은 인터페이스의 대체 구현체”로 그렸다 — 기술을 통째로 갈아 끼울 수 있다는 뜻이었다. 이 원칙은 그 전제를 좁힌다. 쓰기/단건 조회는 여전히 기술을 통째로 바꿀 수 있지만, 동적 검색이 필요한 애그리거트는 그 부분만 별도 인터페이스로 떼어 MyBatis와 영구히 묶인다. Service는 이제 한 애그리거트에 대해 인터페이스 두 개를 필드로 받는다.


원칙 2. 메서드 이름은 기술이 아니라 도메인 언어로 짓는다

Spring Data가 파생 쿼리 메서드 이름을 그대로 시그니처로 써도 되게 해준다는 건 안다. 그래도 안 쓴다.

// ❌ 조건이 그대로 이름이 된다 — 조건이 늘면 이름이 감당 안 된다
Optional<OrderDomain> findByStatusAndCreatedAtBefore(OrderStatus status, LocalDateTime cutoff);

// ✅ 도메인이 원하는 것을 이름으로 표현한다
Optional<OrderDomain> findExpiredPendingOrders();

Service 계층 assert 표준에서 메서드 이름이 의도를 드러내야 한다고 정한 것과 같은 결이다. 조건이 PENDING 하나에서 PENDING 또는 ON_HOLD로 바뀌어도 호출부는 findExpiredPendingOrders()를 그대로 부른다. 구현만 바뀐다.

대가. 실제 조건이 뭔지 알려면 이름만 봐서는 안 되고 구현체까지 들어가야 한다. 이름과 실제 쿼리가 미묘하게 어긋나는 버그(이름은 “만료”인데 조건 하나가 빠진 경우)를 이름만 보고는 잡을 수 없다.


원칙 3. 애그리거트를 채우는 조인은 자식 컬렉션 개수로 전략을 가른다

애그리거트 경계 표준에서 “Domain은 지연 로딩이 없으니 Repository가 만들 때 이미 다 채워야 한다”고 정했다. 자식 컬렉션이 하나면 join fetch로 끝난다.

@Query("select o from OrderEntity o join fetch o.items where o.id = :id")
Optional<OrderEntity> findWithItemsById(@Param("id") Long id);

컬렉션이 두 개 이상이면 이 방식이 깨진다. items 10개, discounts 10개를 동시에 join fetch하면 SQL 레벨에서 둘이 조인되어 10 × 10 = 100행이 나온다. 컬렉션이 세 개(items, discounts, history, 각 10개)면 10 × 10 × 10 = 1000행이다.

실제로는 여기서 두 갈래로 갈린다. 컬렉션 타입이 List면 Hibernate가 실행 자체를 거부하고 MultipleBagFetchException을 던진다 — 빨리, 시끄럽게 실패한다. Set으로 바꾸면 예외는 안 나지만 DB가 저 100행/1000행을 그대로 실어 나른 뒤 애플리케이션 메모리에서 중복을 제거한다 — 결과는 맞는데 조용히 비효율적이다.

그래서 하나만 join fetch하고 나머지는 따로 조회해서 구현체 안에서 조립한다.

@Repository
class OrderJpaRepository implements OrderRepository {

    private final OrderJpaEntityRepository jpaEntityRepository;
    private final OrderDiscountJpaEntityRepository discountJpaEntityRepository;
    private final OrderStatusHistoryJpaEntityRepository historyJpaEntityRepository;
    private final OrderEntityMapper mapper;

    @Override
    public Optional<OrderDomain> findById(Long orderId) {
        // 1) 컬렉션 하나(items)만 join fetch로 한 번에
        Optional<OrderEntity> orderWithItems = jpaEntityRepository.findWithItemsById(orderId);
        if (orderWithItems.isEmpty()) {
            return Optional.empty();
        }

        // 2) 나머지는 각자 따로, 단순 조회
        List<OrderDiscountEntity> discounts = discountJpaEntityRepository.findByOrderId(orderId);
        List<OrderStatusHistoryEntity> history = historyJpaEntityRepository.findByOrderId(orderId);

        // 3) 구현체 안에서 조립
        return Optional.of(mapper.toDomain(orderWithItems.get(), discounts, history));
    }
}

쿼리는 컬렉션 개수만큼(여기서는 3번) 나가지만, 행 수는 곱셈이 아니라 덧셈(10+10+10=30)이 된다.

join fetchPageable을 같이 쓰지 않는 것도 같은 이유다. 함께 쓰면 Hibernate가 경고를 내며 전체 결과를 메모리에 올려 애플리케이션에서 페이징한다. 애그리거트 목록을 자식까지 채워서 페이징 조회해야 한다면, ID만 먼저 페이징 조회하고 그 ID 목록으로 자식을 배치 조회하는 방식을 쓴다 — 결국 이것도 “따로 조회해서 조립한다”와 같은 해법이다.


원칙 4. 내부용 Repository는 인터페이스 밖으로 나가지 않는다

원칙 3의 OrderDiscountJpaEntityRepository, OrderStatusHistoryJpaEntityRepository는 raw Spring Data JpaRepository<Entity, ID>다. DTO 네이밍 표준에서 정한 XxxJpaEntityRepository 네이밍을 이런 내부용 인터페이스에도 똑같이 적용한다.

// order/infra — 전부 package-private
interface OrderJpaEntityRepository extends JpaRepository<OrderEntity, Long> { }
interface OrderDiscountJpaEntityRepository extends JpaRepository<OrderDiscountEntity, Long> { }
interface OrderStatusHistoryJpaEntityRepository extends JpaRepository<OrderStatusHistoryEntity, Long> { }

이 인터페이스들은 전부 JpaRepository를 상속하므로 .save()도 갖고 있다. 애그리거트 경계 표준의 “OrderItemRepository 같은 내부 객체용 Repository는 만들지 않는다”는 규칙과 부딪히는 것처럼 보이지만, 부딪히지 않는다.

그 규칙이 막으려던 건 “메서드가 존재하는가”가 아니라 “Service가 그 메서드에 닿을 수 있는가”였다. OrderDiscountJpaEntityRepositorydomain이 아니라 infra에 있고 public이 아니다. OrderService는 이 타입을 import조차 할 수 없다. 이걸 실제로 호출하는 코드는 OrderJpaRepository.findById()/save() 내부뿐이고, 그 메서드는 애그리거트 전체를 한 트랜잭션에서 다룬다. discounts만 따로 저장하는 문이 열리는 게 아니라, 애그리거트를 조립하고 저장하는 과정에서 쓰는 부품 하나일 뿐이다.

가시성이 곧 규칙이다. domain에 노출된 적이 없는 Repository는 아무리 .save()를 갖고 있어도 우회로가 아니다.


삭제는 지울 수 있는 애그리거트에만 둔다. void delete(XxxDomain domain)이고 deleteById는 만들지 않는다. 다른 도메인이 ID로 참조하는 애그리거트는 애초에 delete를 두지 않아 컴파일러가 막는다. 삭제 표준에서 다룬다.

save 구현체는 신규와 수정이 다르다. 신규(id == null)만 toEntity()를 그대로 저장하고, 기존은 findById로 영속 Entity를 꺼내 applyTo로 덮는다. 분리 Entity를 save()에 넘기면 merge가 되어 Domain이 나르지 않는 컬럼이 null로 덮이기 때문이다. 이유와 자식 컬렉션 처리는 Domain 영속화 표준에서 다룬다.


원칙 5. 애그리거트 경계를 벗어나는 대량 연산은 Repository가 아니라 벌크의 영역이다

만료 주문 5만 건을 일괄 처리해야 한다면, Domain을 하나씩 로드해서 expire()를 부르고 저장하는 게 원칙적으로는 맞다. 하지만 대량 데이터에는 너무 느리다. 그렇다고 벌크 UPDATE를 orderRepository에 메서드로 추가하면 안 된다.

// ❌ Repository에 벌크 메서드를 추가하지 않는다
@Modifying
@Query("UPDATE OrderEntity e SET e.status = 'EXPIRED' WHERE e.status = 'PENDING' AND e.createdAt < :cutoff")
int expireOrders(LocalDateTime cutoff);

이유는 단순하다. OrderRepositorydomain에 있는 한, OrderService도 이 메서드를 얼마든지 호출할 수 있다. “이런 대량 연산은 벌크에서만 쓴다”는 규칙이 다시 사람이 지키는 약속으로 돌아간다 — 지금까지 이 표준들이 지켜온 “규칙을 컴파일러가 강제하게 한다”는 원칙과 어긋난다.

그래서 대량 연산은 Repository 계열에서 아예 뺀다. Spring Batch의 ItemWriter로 만들고, 도메인 안에 bulk 패키지를 따로 둔다.

5번째 패키지의 근거를 다시 세웠다

처음에 이 패키지의 이유를 “application에 두면 OrderService가 접근할 수 있으니까”라고 적었다. 그런데 application에 두자고 한 사람은 없었다. 진짜 대안은 infra였고, infra는 전부 package-private이라 OrderService가 이미 볼 수 없다. 격리만 놓고 보면 infra로도 충분했다는 뜻이다.

infra가 아니라 bulk여야 하는 이유는 다른 데 있었다. infra에 두면 같은 패키지의 OrderJpaRepositoryOrderBulkMapper를 주입받아 호출할 수 있다. Service는 못 부르지만 Repository 구현체는 부를 수 있고, 그러면 “이 인터페이스를 쓰면 애그리거트 규칙이 보장된다”는 약속을 구현체가 조용히 깨는 길이 열린다. 리뷰로만 잡히는 종류의 구멍이다. bulk를 별도 패키지로 두는 건 Service가 아니라 Repository 구현체로부터 격리하는 것이 목적이다.

그러면 파라미터 타입도 batch가 가져야 한다

패키지를 나누고 나서 코드가 컴파일되지 않는다는 걸 발견했다.

// order/bulk
class OrderBulkStatusWriter implements ItemWriter<OrderMapperParam> { }
//                                                ^^^^^^^^^^^^^^^^
// OrderMapperParam은 order.infra에 package-private이다. 볼 수 없다.

Java의 package-private은 하위 패키지에 상속되지 않는다. order.infra.bulkorder.infra와 다른 패키지이므로 “bulk를 infra 밑으로 넣어서 둘 다 만족시킨다”는 절충은 존재하지 않는다. 남는 선택은 MapperParampublic으로 열거나, batch가 자기 타입을 갖는 것뿐이었다.

public으로 여는 안은 버렸다. 그러면 applicationOrderMapperParam을 import할 수 있게 되어 Service가 MyBatis 타입을 알게 된다DTO 네이밍 표준이 막으려던 바로 그것이다. 타입 하나 아끼려고 계층 분리에 구멍을 내는 거래다.

그래서 bulk가 자기 파라미터·결과 타입을 갖는다.

// order/bulk — 전부 같은 패키지이므로 서로 참조된다
class OrderBulkMapperParam {          // package-private
    private final Long id;
    private final OrderStatus status;
}

@Component
class OrderBulkStatusWriter implements ItemWriter<OrderBulkMapperParam> {

    private final OrderBulkMapper bulkMapper;   // MyBatis, 벌크 insert/update 전용

    @Override
    public void write(Chunk<? extends OrderBulkMapperParam> chunk) {
        bulkMapper.bulkUpdateStatus(chunk.getItems());
    }
}
  • OrderBulkStatusWriter, OrderBulkMapper, OrderBulkMapperParam은 모두 order/bulk에 package-private으로 둔다.
  • ItemWriterStep 설정에서만 조립된다. 일반 요청 경로(Controller → Service)에는 이걸 부를 진입점이 없다.
  • Domain을 거치지 않는다. Domain 생성 비용 없이 대량 데이터를 그대로 다루는 것이 이 경로의 존재 이유다.
  • bulkdomaincommon만 참조한다. infra를 참조하지 않고 infrabulk를 참조하지 않는다 — 둘은 형제다.

대가. OrderMapperParamOrderBulkMapperParam이 비슷한 모양으로 둘 존재한다. 다만 벌크 UPDATE가 실제로 필요한 필드는 idstatus뿐이고 일반 CRUD가 다루는 필드 전체와는 다르다. 그래서 이건 중복이라기보다 다른 용도의 다른 타입이다. 두 타입의 필드가 정말로 같아지는 상황이 오면, 그건 벌크로 처리할 일이 아니라는 신호로 읽는다.

이 경로는 판단이 없는 연산만 맡는다. Domain을 거치지 않으므로 비즈니스 규칙을 검증할 자리가 구조적으로 없기 때문이다. 정책 판단이 필요한 대량 처리는 {도메인}/batch에서 Domain을 하나씩 거치는데, 그쪽은 패키지도 참조 범위도 예외 규칙도 다르다. 두 경로를 가르는 기준은 배치 실행 표준에서 정한다.

Repository라는 이름을 붙이지 않은 것도 의도적이다. Repository는 “이 메서드를 쓰면 애그리거트 규칙이 보장된다”는 약속인데, 벌크 연산은 애초에 그 약속을 깨는 물건이다. OrderBulkMapper/OrderBulkStatusWriter처럼 이름부터 “이건 다른 종류”라고 밝히는 게 정직하다.


판단 기준 정리

질문 결론
조회 조건이 몇 개, 선택적인가? 0~1개, 항상 존재 XxxRepository(JPA) — Pageable/Page 활용
  2개 이상, 선택적 조합 XxxSearchRepository(MyBatis)로 분리
자식 컬렉션이 몇 개인가? 1개 join fetch로 한 번에
  2개 이상 1개만 join fetch, 나머지는 따로 조회 후 조립
애그리거트를 벗어나는 대량 연산인가? 벌크 Repository 아닌 {도메인}/bulkItemWriter + XxxBulkMapper + XxxBulkMapperParam
  일반 요청 경로 금지 — 항상 Domain 경유
내부용 raw Repository가 필요한가? 애그리거트 내부에서만 쓰임 XxxJpaEntityRepository, infra에 package-private

이 표준을 정하기까지

시작은 “정한 게 없다”는 확인이었다. 애그리거트 경계, 패키지 구조, DTO 네이밍 표준 세 개가 Repository의 위치·가시성·반환 타입은 정했지만, 정작 그 안에서 조건이 여러 개 섞인 검색을 어떻게 짤지, 대량 데이터를 어떻게 다룰지는 아무 데도 없었다.

동적 조회 기술부터 막혔다. JPQL @Query, QueryDSL, MyBatis 셋을 놓고 트레이드오프를 짚었는데, 이미 “메서드 이름은 도메인 의미로 짓는다”고 정해둔 게 QueryDSL의 장점(호출부에서 조건을 유창하게 조립할 수 있다는 것)을 무의미하게 만든다는 걸 알아차렸다. 호출부는 어차피 이름만 보니, 내부 구현이 QueryDSL이든 MyBatis든 차이가 없어진다. 그래서 이미 쓰고 있는 MyBatis로 정리했다.

“페이징/리스트 조회면 MyBatis”라는 첫 기준은 너무 넓었다. findByStatus(status, pageable)처럼 조건이 하나뿐이고 항상 있는 경우는 JPA의 Pageable/Page를 그대로 쓰는 게 이득이었다 — MyBatis로 넘기면 COUNT 쿼리까지 직접 짜야 한다. 진짜 기준은 리스트냐 아니냐가 아니라 조건이 몇 개고, 그게 선택적으로 조합되는가였다.

벌크 연산 얘기에서 “우회”라는 말부터 정리해야 했다. 벌크 UPDATE/DELETE가 Domain을 아예 안 만들고 DB에 직접 SQL을 쏘는 것이고, 그러면 Domain의 비즈니스 규칙 검증과 JPA 영속성 컨텍스트 동기화를 둘 다 건너뛴다는 걸 구체적인 예시로 짚고 나서야 “배치에서만 허용”이라는 결론이 나왔다.

인터페이스를 나누기로 하니 구현체 이름 문제가 저절로 풀렸다. OrderRepository(JPA)와 OrderSearchRepository(MyBatis)로 나누면 각 구현체가 기술을 하나만 쓰게 되어 OrderJpaRepository, OrderMyBatisSearchRepository라는 이름이 다시 정확해진다. 그런데 이게 DTO 네이밍 표준의 “JPA와 MyBatis는 같은 인터페이스의 대체 구현체다”라는 전제와 부딪힌다는 걸 뒤늦게 알아차렸다. 그 글은 기술을 통째로 갈아 끼우는 상황을 그렸는데, 지금은 두 기술이 한 애그리거트 안에 영구히 공존한다. 범위를 좁히는 각주가 필요했다.

대량 쓰기를 어디 둘지가 가장 오래 걸렸다. 처음엔 검색과 같은 방식으로 OrderBulkRepository를 또 만들려고 했는데, 그러면 “벌크에서만 써야 한다”는 규칙이 다시 사람이 지키는 약속으로 돌아간다는 걸 깨달았다. domain에 인터페이스로 노출하는 순간 OrderService도 얼마든지 주입받을 수 있기 때문이다. 그래서 아예 Repository 계열에서 빼고 Spring Batch의 ItemWriter로 옮겼다 — Service가 구조적으로 닿을 수 없는 자리다.

애그리거트 즉시 로딩은 숫자로 확인하고 나서야 감이 잡혔다. 자식 컬렉션이 두 개면 카티션 곱으로 행이 곱해진다는 걸 10×10=100, 세 개면 10×10×10=1000이라는 구체적인 계산으로 짚었다. List면 Hibernate가 MultipleBagFetchException으로 아예 실행을 거부하고, Set이면 조용히 비효율적인 쿼리가 나간다는 차이도 여기서 나왔다. 해법은 하나만 join fetch하고 나머지는 따로 조회해서 구현체 안에서 조립하는 것 — 쿼리 수는 컬렉션 개수만큼 늘지만 행 수는 곱셈이 아니라 덧셈이 된다.

시그니처의 입력과 출력을 뒤늦게 둘 다 손봤다. 처음엔 search(OrderSearchCondition)이라고 써놓고 그 타입이 어디 있는 건지 정하지 않았다. 읽기 입력은 XxxQuery로 정해뒀으니 그걸 쓰면 되겠다 싶었는데, XxxQueryapplication에 있고 Repository 인터페이스는 domain에 있다는 게 걸렸다. domainapplication을 참조할 수 없으니 애초에 불가능한 선택이었다. 결국 조건 타입을 domain에 따로 두기로 했고, 그러고 보니 Condition이라는 이름이 우연히 맞는 이름이었다 — Query와 다른 계층의 다른 타입이니 다르게 불려야 했던 것이다. 출력 쪽도 같이 걸렸다. ServicePagingResultdomain이 반환하는 게 이상했는데, 따져보니 페이징만 계층을 넘는 게 필연이었다(totalCount가 COUNT 쿼리에서 나온다). 그래서 이름에서 Service를 떼고, 접두사 유무가 공유 범위를 나타내게 정리했다.

5번째 패키지의 근거가 허수아비였다는 걸 뒤늦게 알아차렸다. 처음엔 “application에 두면 Service가 접근할 수 있으니 bulk로 뺀다”고 적었는데, 다시 읽어보니 아무도 application에 두자고 하지 않았다. 진짜 대안은 infra였고 infra는 이미 Service에 닫혀 있다. 그러면 왜 bulk여야 하는가를 다시 따졌더니 답이 나왔다 — infra에 두면 같은 패키지의 Repository 구현체가 벌크 Mapper를 주입받을 수 있다. 막아야 할 상대가 Service가 아니라 Repository 구현체였던 것이다. 결론은 그대로였지만 이유가 바뀌었고, 이유가 바뀌니 아래 규칙이 따라 정해졌다.

그리고 예시 코드가 컴파일되지 않는다는 걸 발견했다. order/bulkItemWriter<OrderMapperParam>order.infra의 package-private 타입을 참조하고 있었다. 여기서 Java의 package-private이 하위 패키지에 상속되지 않는다는 사실을 짚고 나서야 “batch를 infra 밑으로” 같은 절충안이 애초에 없다는 게 분명해졌다. 남은 선택은 MapperParampublic으로 열거나 batch가 자기 타입을 갖는 것 둘뿐이었고, public으로 여는 쪽은 application까지 MyBatis 타입을 보게 만들어서 버렸다. 각 패키지가 자기 데이터 타입을 갖는다XxxProjectioninfra에 둔 것과 같은 규칙이 여기서도 성립했다. 그 부산물로 bulk의 의존 방향도 정해졌다. infra를 볼 이유가 없어져서 domaincommon만 참조하는 형제 패키지가 됐다.

마지막에 예시 코드 자체에서 규칙 위반을 하나 더 찾았다. 예시로 든 OrderDiscountJpaRepository, OrderStatusHistoryJpaRepositoryOrderJpaEntityRepository와 달리 이름에 Entity가 빠져 있었다. 셋 다 raw Spring Data 인터페이스로 같은 성격인데 이름만 갈렸던 것이다. 고치면서 “이것들도 .save()를 갖고 있는데 OrderItemRepository 금지 규칙과 안 부딪히나”라는 질문이 나왔고, 답은 가시성이었다 — domain에 노출된 적이 없어 OrderService가 애초에 그 타입을 볼 수 없으니, 규칙이 막으려던 우회로 자체가 생기지 않는다.


정리

  • 저장·단건 조회와 동적 검색은 인터페이스를 나눈다. 조건이 0~1개면 JPA, 2개 이상 선택적으로 조합되면 MyBatis
  • 메서드 이름은 도메인 언어로 짓는다. 구현 기술이 바뀌어도 호출부는 그대로다
  • 조회 조건 타입은 domainXxxSearchCondition으로 둔다. applicationXxxQuery를 쓰면 의존 방향이 뒤집힌다
  • 페이징 봉투는 PagingResult다. Service 접두사가 없는 건 Repository도 반환하기 때문이다
  • 자식 컬렉션이 둘 이상인 애그리거트는 하나만 join fetch하고 나머지는 따로 조회해 조립한다. 카티션 곱을 피하기 위해서다
  • 내부용 raw Repository는 XxxJpaEntityRepository로 통일하고 domain에 절대 노출하지 않는다. 가시성이 곧 안전장치다
  • 애그리거트 경계를 벗어나는 대량 연산은 Repository가 아니라 벌크의 영역이다. Service뿐 아니라 Repository 구현체도 닿을 수 없는 자리에 둔다
  • bulk는 자기 파라미터 타입을 자기 패키지에 갖는다. infraMapperParam을 열어서 빌려 쓰지 않는다
  • 도메인의 판단이 개입하는 연산은 벌크로 하지 않는다. 벌크에는 규칙을 검증할 자리가 없다 — 조건이 곧 규칙인 것만 맡긴다
  • 판단이 필요한 대량 처리는 {도메인}/batch가 맡는다. 두 경로를 가르는 기준은 배치 실행 표준에 있다

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

Claude Code — .claude/skills/repository-design-standard/SKILL.md

---
name: repository-design-standard
description: Repository 인터페이스와 구현체 설계 규칙. 조회 메서드, 동적 검색, 애그리거트 즉시 로딩, 대량 연산을 다루는 Repository를 만들거나 리뷰할 때 반드시 적용한다.
---

# Repository 설계 표준

## 인터페이스 분리
- 저장과 단건 조회(`save`, `findById`)는 JPA 기반 `XxxRepository`에 둔다.
- `save` 구현체는 신규(`id == null`)만 `toEntity`를 그대로 저장하고, 기존은 `findById`로 영속 Entity를 꺼내 `applyTo`로 덮는다. domain-persistence-standard를 따른다.
- 지울 수 있는 애그리거트의 Repository에만 `void delete(XxxDomain domain)`을 둔다. `deleteById(Long id)`를 만들지 않는다. aggregate-deletion-standard를 따른다.
- 조건이 2개 이상이고 각각 선택적으로 조합되는 동적 검색은 별도의 `XxxSearchRepository`에 두고 MyBatis로 구현한다.
- 조건이 0~1개이고 항상 존재하면 `XxxRepository`에 그대로 두고 `Pageable`/`Page`를 활용한다.
- Service는 두 인터페이스를 각각 필드로 주입받는다. 하나로 합치지 않는다.
- 동적 검색의 조건은 `XxxSearchCondition`으로 만들어 `domain`에 두고, Service가 `XxxQuery`를 변환해 넘긴다. `XxxQuery`를 Repository 파라미터로 쓰지 않는다 — `domain``application`을 참조하게 된다.
- 페이징 반환은 `PagingResult<XxxDomain>`이다. `ServicePagingResult`라는 이름을 쓰지 않는다.
- 조회의 답이 한 애그리거트 안에 있으면 `XxxDomain`을, 여러 도메인의 값이 필요하면 `XxxView`를 반환한다. read-model-standard를 따른다.

## 메서드 이름
- 메서드 이름은 도메인 의미로 짓는다. 예: `findExpiredPendingOrders`.
- Spring Data 파생 쿼리 메서드 이름(`findByStatusAndCreatedAtBefore` 등)을 그대로 시그니처에 노출하지 않는다.

## 애그리거트 즉시 로딩
- 자식 컬렉션이 하나면 JPQL `join fetch`로 한 번에 조회한다.
- 자식 컬렉션이 둘 이상이면 하나만 `join fetch`하고, 나머지는 각각 `where 부모ID = :id`로 따로 조회한 뒤 구현체 안에서 조립한다.
- 컬렉션을 두 개 이상 동시에 `join fetch`하지 않는다. `MultipleBagFetchException`이 발생하거나 카티션 곱으로 행이 폭증한다.
- `join fetch``Pageable`을 함께 쓰지 않는다. 목록에 페이징이 필요하면 ID만 먼저 페이징 조회하고 자식은 그 ID 목록으로 배치 조회한다.

## 내부용 Repository
- 애그리거트 내부에서만 쓰는 raw Spring Data 인터페이스(`OrderDiscountJpaEntityRepository` 등)는 이름에 `JpaEntityRepository`를 붙인다.
- 내부용 Repository는 `domain`에 노출하지 않고 `infra`에 package-private으로 둔다. 애그리거트 루트의 Repository 구현체에서만 사용한다.

## 대량 연산
- 애그리거트 경계를 벗어나는 벌크 UPDATE/DELETE, 대량 INSERT는 Repository 인터페이스에 메서드로 추가하지 않는다.
- 대량 연산은 Spring Batch `ItemReader`/`ItemWriter`와 전용 Bulk Mapper로 구현하고, `{도메인}/bulk` 패키지에 package-private으로 둔다.
- 이름은 `XxxBulkMapper`, `XxxBulk{의도}Writer` / `XxxBulk{의도}Reader`로 짓는다. `Repository` 접미사를 붙이지 않는다.
- 파라미터·결과 타입도 `{도메인}/bulk``XxxBulkMapperParam` / `XxxBulkMapperResult`로 따로 만든다. `infra``XxxMapperParam`을 재사용하지 않는다. package-private이라 다른 패키지에서 보이지 않고, `public`으로 열면 `application`까지 MyBatis 타입을 알게 된다.
- `bulk``infra`가 아니라 별도 패키지여야 하는 이유는 Service가 아니라 **Repository 구현체**로부터의 격리다. `infra`에 두면 같은 패키지의 Repository 구현체가 벌크 Mapper를 주입받을 수 있다.
- `bulk``domain``common`만 참조한다. `infra`를 참조하지 않으며 `infra``bulk`를 참조하지 않는다.
- 벌크 연산은 `Domain`을 거치지 않는다. 일반 요청 경로(Controller → Service)에서는 절대 호출하지 않는다.
- `Domain`을 거치지 않으므로 벌크에는 비즈니스 규칙을 검증할 자리가 없다. **도메인의 판단이 개입하는 연산은 벌크로 하지 않는다.** 조건이 곧 규칙인 것만 벌크로 처리한다.
- 정책 판단이 필요한 대량 처리는 `{도메인}/batch`에서 `Domain`을 하나씩 거친다. 경로를 가르는 기준과 배치 쪽 규칙은 batch-execution-standard를 따른다.
- 벌크는 예외를 번역하지 않고 원본 타입을 올린다. 실패 로그는 `StepExecutionListener` 한 곳에서 남긴다. exception-taxonomy-standard를 따른다.
- 벌크의 외부 입력은 `JobParameters` 하나이며 `JobParametersValidator`에서 검증한다. validation-standard를 따른다.

GitHub Copilot — .github/instructions/repository-design-standard.instructions.md

---
description: Repository 인터페이스와 구현체 설계 규칙
applyTo: "**/domain/**/*.java, **/infra/**/*.java"
---

- 저장과 단건 조회는 JPA 기반 `XxxRepository`에 두고, 조건 2개 이상이 선택적으로 조합되는 동적 검색은 별도의 `XxxSearchRepository`에 두고 MyBatis로 구현한다.
- `save` 구현체는 신규만 `toEntity`를 그대로 저장하고, 기존은 `findById`로 영속 Entity를 꺼내 `applyTo`로 덮는다.
- 지울 수 있는 애그리거트의 Repository에만 `void delete(XxxDomain domain)`을 두고, `deleteById(Long id)`는 만들지 않는다.
- 조건이 0~1개이고 항상 존재하면 `XxxRepository`에서 `Pageable`/`Page`를 그대로 활용한다.
- Service는 `XxxRepository``XxxSearchRepository`를 각각 필드로 주입받는다.
- 동적 검색의 조건은 `XxxSearchCondition`으로 만들어 `domain`에 두고, Service가 `XxxQuery`를 변환해 넘긴다.
- 페이징 반환 타입은 `PagingResult<XxxDomain>`으로 선언한다.
- 조회의 답이 한 애그리거트 안에 있으면 `XxxDomain`을, 여러 도메인의 값이 필요하면 `XxxView`를 반환한다.
- Repository 메서드 이름은 도메인 의미로 짓는다. Spring Data 파생 쿼리 이름을 시그니처에 그대로 노출하지 않는다.
- 애그리거트 자식 컬렉션이 하나면 JPQL `join fetch`로 조회한다.
- 자식 컬렉션이 둘 이상이면 하나만 `join fetch`하고 나머지는 따로 조회해 구현체에서 조립한다. 컬렉션 두 개 이상을 동시에 `join fetch`하지 않는다.
- `join fetch``Pageable`을 함께 쓰지 않는다.
- 내부용 raw Repository(`OrderDiscountJpaEntityRepository` 등)는 이름에 `JpaEntityRepository`를 붙이고 `infra`에 package-private으로 둔다.
- 애그리거트 경계를 벗어나는 대량 연산은 Repository에 메서드로 추가하지 않는다.
- 대량 연산은 Spring Batch `ItemReader`/`ItemWriter`와 전용 Bulk Mapper로 `{도메인}/bulk` 패키지에 package-private으로 구현하고, `Domain`을 거치지 않는다.
- 벌크 클래스 이름은 `XxxBulkMapper`, `XxxBulk{의도}Writer` / `XxxBulk{의도}Reader`로 짓고 `Repository` 접미사를 붙이지 않는다.
- 벌크가 다루는 파라미터·결과 타입은 `{도메인}/bulk``XxxBulkMapperParam` / `XxxBulkMapperResult`로 만든다. `infra``XxxMapperParam`을 재사용하거나 `public`으로 열지 않는다.
- `bulk``domain``common`만 참조한다. `bulk``infra`는 서로 참조하지 않는다.
- 도메인의 판단이 개입하는 연산은 벌크로 하지 않는다. 벌크는 `Domain`을 거치지 않아 규칙을 검증할 자리가 없다.
- 정책 판단이 필요한 대량 처리는 `{도메인}/batch`에서 `Domain`을 하나씩 거친다.
- 벌크는 예외를 번역하지 않고 원본 타입을 올리며, 실패 로그는 `StepExecutionListener` 한 곳에서 남긴다.
- 벌크의 외부 입력은 `JobParameters` 하나이며 `JobParametersValidator`에서 검증한다.

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

댓글남기기