애그리거트 경계 표준조회 쪽을 정면으로 다뤘다. “Domain은 POJO라 지연 로딩이 없다. 그래서 애그리거트 크기가 곧 매 조회의 고정 비용이다.”

저장 쪽은 같은 수준으로 따져본 적이 없다. 그런데 코드는 이미 있었다.

// aggregate-boundary-standard, layered-dto-naming-standard 두 글에 나오는 저장 코드
OrderEntity entity = mapper.toEntity(order);
return mapper.toDomain(jpaEntityRepository.save(entity));

이게 무슨 SQL을 만드는지, 무엇을 건드리는지 아무 데도 적혀 있지 않았다. 따져보니 조용히 데이터를 지우고 있었다.

주문을 확정했을 뿐인데 created_at이 NULL이 된다.
예외도 안 나고, 로그도 정상이고, 응답도 200이다.

먼저: Domain이 POJO라는 것의 저장 쪽 결과

세 가지가 맞물려 있다.

하나, toEntity()가 만드는 Entity는 항상 분리(detached) 상태다. JPA가 추적하는 것은 findById로 꺼낸 객체뿐인데, 우리는 Service가 Entity를 보지 못하게 막아뒀다. 그래서 저장할 때마다 new로 만들 수밖에 없고, 새로 만든 것은 JPA가 모른다.

둘, save()는 id가 있으면 merge를 부른다. Spring Data의 구현이 그렇다.

if (id == null) em.persist(entity);   // INSERT
else            em.merge(entity);     // ← 수정은 항상 이쪽

셋, merge는 분리 인스턴스의 모든 필드를 영속 객체로 복사한다. 여기가 핵심이다. “바뀐 것만”이 아니라 전부다.

그래서 벌어지는 일

OrderDomain에는 statusquantity만 있다. createdAtversion은 순수한 영속성 관심사라 넣지 않았다 — Domain은 영속성을 모른다”는 규칙을 지킨 결과다.

시점 무슨 일이 일어나나 DB의 created_at
조회 findById → 영속 Entity → OrderDomain으로 변환 2026-01-05
판단 order.confirm() → 새 OrderDomain 2026-01-05
변환 toEntity(order) → 새 Entity. createdAtnull 2026-01-05
저장 merge → 분리 객체의 모든 필드를 영속 객체로 복사
커밋 UPDATE orders SET status=?, created_at=NULL, ... NULL

규칙을 잘 지킨 것이 데이터를 지웠다. Domain을 순수하게 유지할수록 toEntity()가 채우지 못하는 컬럼이 늘고, 그만큼 더 많이 지워진다.

@Version도 같은 이유로 비어서 넘어간다. 예외의 종류에서 “OptimisticLockingFailureException을 Repository 구현체에서 번역한다”고 정해뒀는데, 그 예외가 의도대로 동작할 조건 자체가 없었다.

@LastModifiedDate@PreUpdate에서 다시 채워지므로 살아남는다. 셋 중 하나만 멀쩡한 것이 더 위험하다 — 감사 필드가 정상 동작하는 것처럼 보인다.


원칙 1. 수정 경로에서 merge를 쓰지 않는다

문제의 뿌리는 “분리 객체를 통째로 넘긴다”는 것이다. 그러면 넘기지 않으면 된다.

@Override
public OrderDomain save(OrderDomain order) {
    if (order.getId() == null) {                                    // 신규는 그대로
        return mapper.toDomain(jpaEntityRepository.save(mapper.toEntity(order)));
    }

    OrderEntity entity = jpaEntityRepository.findById(order.getId())   // 영속 상태로 꺼낸다
            .orElseThrow(() -> BusinessException.of(ErrorCode.ORDER_NOT_FOUND));

    mapper.applyTo(entity, order);      // Domain이 아는 필드만 덮는다

    return mapper.toDomain(entity);     // 커밋 시 더티체킹으로 UPDATE
}

영속 객체를 직접 손대니 JPA가 바뀐 것만 반영한다. createdAt은 건드리지 않았으므로 그대로 있고, version은 JPA가 알아서 올리고 검사한다.

@VersionDomain에 넣지 않아도 낙관적 락이 동작한다. 정해두고 동작하지 않던 규칙이 이제 실제로 선다.

applyTotoEntity와 다른 점

@Component
class OrderEntityMapper {

    OrderEntity toEntity(OrderDomain domain) { ... }        // 신규 생성용
    OrderDomain toDomain(OrderEntity entity) { ... }        // 조회용

    void applyTo(OrderEntity entity, OrderDomain domain) {  // 수정용
        entity.changeStatus(domain.getStatus());
        entity.changeQuantity(domain.getQuantity());
        // createdAt, version은 아예 언급하지 않는다
    }
}

applyTo가 언급하지 않은 필드는 그냥 안 건드려진다. 이게 이 방식의 진짜 이득이다.

대안은 DomainversioncreatedAt을 나르게 하는 것이었다. 그러면 코드 모양은 지금과 같지만, Entity에 컬럼이 늘 때마다 Domainrestore()까지 같이 고쳐야 하고 빠뜨리면 다시 null이 된다. 사람이 계속 기억해야 하는 규칙이다.

applyTo 쪽은 새 컬럼이 생겨도 아무것도 안 하면 안전하다. 패키지를 package-private으로 막은 것, 필드를 final로 만든 것과 같은 계열이다 — 규율이 아니라 구조가 강제한다.


원칙 2. 트랜잭션 경계는 Service의 UseCase 구현 메서드다

원칙 1에는 조건이 있다. 영속 상태와 더티체킹은 트랜잭션 안에서만 존재한다.

@Transactional이 없으면 findById가 자기 트랜잭션을 열고 바로 닫는다. 꺼낸 Entity는 그 즉시 분리 상태가 되고, applyTo로 값을 바꿔도 JPA는 모른다.

저장이 조용히 아무 일도 안 한다. 예외도 안 나고 UPDATE도 안 나간다.

@Service
@RequiredArgsConstructor
class OrderService implements OrderCommandUseCase, OrderQueryUseCase {   // 클래스는 package-private

    @Override
    @Transactional
    public ServiceResult<OrderDomain> confirmOrder(ConfirmOrderCommand command) { ... }

    @Override
    @Transactional(readOnly = true)
    public ServiceResult<OrderDomain> findOrder(FindOrderQuery query) { ... }

    void recalculateTotals(Long orderId) { ... }   // 내부 메서드 — 붙이지 않는다
}

클래스가 package-private인데도 프록시가 동작하는 이유가 있다. 인터페이스를 구현하는 메서드는 Java 규칙상 반드시 public이다. 그래서 UseCase 인터페이스 표준에서 구현체를 감춘 것과 트랜잭션은 충돌하지 않는다.

다만 UseCase에 없는 package-private 내부 메서드에는 붙이지 않는다. 프록시가 가로채지 못해 동작하지 않는데, 붙어 있으면 동작한다고 착각하게 된다.

조회에 readOnly = true를 붙이면 더티체킹을 끄므로 의도하지 않은 UPDATE가 나갈 여지가 사라진다. 그리고 readOnly 트랜잭션 안에서는 save를 부르지 않는다.

추가 쿼리 걱정은 하지 않아도 된다

save 안에서 findById를 한 번 더 부르는 게 낭비처럼 보이지만, 같은 트랜잭션이면 영속성 컨텍스트에서 찾으므로 쿼리가 나가지 않는다. Service가 이미 조회해온 그 객체다.

오히려 트랜잭션이 없을 때가 문제였다. 조회와 저장이 서로 다른 트랜잭션이면 그 사이에 남이 값을 바꿔도 알 수 없고, 애그리거트 표준이 세운 “함께 커밋되어야 하므로 한 애그리거트다”라는 전제가 깨진다.


원칙 3. 자식 컬렉션은 id로 대조한다

애그리거트 전체가 저장 단위이므로 자식 컬렉션도 applyTo가 맞춰야 한다.

DB 현재:      items = [A(id=1), B(id=2), C(id=3)]
Domain 최종:  items = [A(id=1), C(id=3), D(id=null)]
// OrderEntity
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItemEntity> items = new ArrayList<>();
void applyTo(OrderEntity entity, OrderDomain domain) {
    entity.changeStatus(domain.getStatus());

    Map<Long, OrderItemEntity> existing = entity.getItems().stream()
            .collect(toMap(OrderItemEntity::getId, it -> it));

    Set<Long> keep = domain.getItems().stream()
            .map(OrderItemDomain::getId)
            .filter(Objects::nonNull)
            .collect(toSet());

    entity.getItems().removeIf(it -> !keep.contains(it.getId()));   // 빠진 것만

    for (OrderItemDomain item : domain.getItems()) {
        if (item.getId() == null) {
            entity.getItems().add(toItemEntity(item, entity));      // 새 것만
        } else {
            applyToItem(existing.get(item.getId()), item);          // 남은 것은 필드만
        }
    }
}
DELETE FROM order_items WHERE id = 2;   -- B만
INSERT INTO order_items ... ;           -- D만
-- A와 C는 값이 안 바뀌었으면 UPDATE도 안 나간다

clear() 후 다시 채우지 않는 이유

훨씬 짧게 쓰는 방법이 있다. 컬렉션을 비우고 Domain의 항목으로 다시 채우면 orphanRemoval이 나머지를 알아서 지운다. 두 줄이면 끝난다.

그런데 자식 행의 PK가 매 저장마다 바뀐다. 안 바뀐 A와 C도 지웠다 다시 들어가기 때문이다.

이게 왜 문제인지는 항목 id가 애그리거트 밖으로 나가는가로 갈린다. 그리고 대개 나간다 — 클라이언트가 “3번 항목 수량 변경”처럼 특정 항목을 지목하려면 응답에 항목 id가 있어야 한다. 방금 받은 id로 수정 요청을 보냈는데 그 사이 다른 저장이 일어나 id가 바뀌어 있으면 “존재하지 않는 항목”이 된다. 예외는 나지만 원인을 찾기가 아주 어렵다.

그래서 20줄을 감수하고 id로 대조한다.

clear() 직후 같은 id로 다시 넣는 절충도 불가능하다. orphanRemoval이 이미 삭제를 예약해둔 상태라 같은 flush에서 충돌한다.

조회할 때 join fetch로 자식까지 채워오므로(Repository 설계 표준) applyTo 시점에 컬렉션은 이미 메모리에 있다. 대조 때문에 쿼리가 더 나가지는 않는다.


판단 기준 정리

질문 결론
신규인가 기존인가? id == null toEntity() 그대로 저장 (INSERT)
  id != null findById + applyTo (더티체킹)
수정에 save(toEntity(...))를 쓰나? 아니다 merge가 되어 Domain에 없는 컬럼이 null로 덮인다
@Version은 어디에? Entity에만 영속 Entity를 직접 고치므로 락이 저절로 동작한다
감사 필드는 어디에? Entity에만 applyTo가 건드리지 않으므로 안전하다
트랜잭션은 어디에? Service의 UseCase 구현 메서드 쓰기는 @Transactional, 조회는 readOnly = true
내부 package-private 메서드는? 붙이지 않는다 프록시가 가로채지 못한다
자식 컬렉션은? id로 대조 빠진 것만 삭제, 새 것만 추가, 남은 것은 필드만
clear() 후 재삽입은? 하지 않는다 자식 PK가 매번 바뀌어 밖에서 참조하던 것이 깨진다
배치에서는? 그대로 적용 트랜잭션은 chunk가 연다

이 표준을 정하기까지

시작은 HTTP 요청 한 사이클을 훑어본 것이었다. 요청부터 DB를 거쳐 응답까지 따라가며 표준에 빈칸이 있는지 봤는데, 저장 구간에서 걸렸다. 조회 쪽은 애그리거트 표준이 “지연 로딩이 없다”를 정면으로 다뤘는데 저장 쪽은 같은 질문을 한 적이 없었다.

save()merge라는 걸 확인하고 나서 문제가 보였다. Domain이 POJO라 Entity는 항상 분리 상태이고, 분리 상태를 save()하면 merge이고, merge는 모든 필드를 복사한다. 세 가지가 다 이미 알고 있던 것인데 이어 붙여본 적이 없었다. 이어 붙이니 created_atNULL이 되는 경로가 나왔다.

규칙을 잘 지킨 것이 문제를 만들었다는 게 뜻밖이었다. Domain을 순수하게 유지할수록 toEntity()가 못 채우는 컬럼이 늘고, 그만큼 더 많이 지워진다. 표준을 어겨서 생긴 문제가 아니라 표준을 지켜서 생긴 문제라 리뷰로는 잡히지 않는 종류였다.

낙관적 락이 여기 딸려 있다는 것도 이때 알았다. 예외의 종류 표준이 OptimisticLockingFailureException을 번역하라고 정해뒀는데, @Version을 어디 둘지는 아무 데도 없었다. 잡으라고 정한 예외가 발생할 조건 자체가 정의되지 않은 상태였던 셈이다.

두 안을 놓고 비교했다. Domainversion과 감사 필드를 나르게 하면 코드 모양이 안 바뀐다. 하지만 Entity에 컬럼이 늘 때마다 Domain까지 고쳐야 하고 빠뜨리면 다시 조용히 null이 된다. 사람이 기억해야 하는 규칙과 구조가 강제하는 규칙 중에서 후자를 골랐다 — 이 저장소가 계속 써온 기준이다.

고르고 나니 트랜잭션 경계가 따라 나왔다. 영속 Entity를 직접 고치는 방식은 트랜잭션이 없으면 아무 일도 안 하고 조용히 끝난다. 그래서 경계를 정하는 것이 선택이 아니라 필수가 됐다. 장부에 따로 열어뒀던 “트랜잭션 경계”와 “저장의 실체”가 사실 한 문제였다는 게 여기서 드러났다.

XxxService가 package-private인데 프록시가 되는지는 따로 확인했다. 인터페이스 구현 메서드는 Java 규칙상 public이라 문제가 없었다. 대신 UseCase에 없는 내부 메서드에는 붙여도 동작하지 않는다는 걸 규칙으로 못 박았다. 붙어 있으면 동작한다고 믿게 되는 종류의 함정이다.

마지막이 자식 컬렉션이었다. 통째로 비우고 다시 채우면 두 줄로 끝나지만 자식 PK가 매번 바뀐다. 그게 문제인지 아닌지는 “항목 id가 애그리거트 밖으로 나가는가” 하나로 갈렸고, 응답에 항목 id를 내려주는 순간 나가는 것이라 20줄짜리 대조를 택했다.


정리

  • 수정 경로에서 merge를 쓰지 않는다. findById로 영속 Entity를 꺼내 applyTo로 덮고 더티체킹에 맡긴다
  • applyTo가 언급하지 않은 필드는 안 건드려진다. 새 컬럼이 생겨도 아무것도 안 하면 안전하다
  • @Version과 감사 필드는 Entity에만 둔다. Domain이 몰라도 낙관적 락이 저절로 동작한다
  • 트랜잭션 경계는 Service의 UseCase 구현 메서드다. 없으면 저장이 조용히 아무 일도 안 한다
  • 내부 package-private 메서드에는 @Transactional을 붙이지 않는다. 동작하지 않는데 동작한다고 믿게 된다
  • 자식 컬렉션은 id로 대조한다. clear() 후 재삽입은 자식 PK를 매번 바꾼다

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

Claude Code — .claude/skills/domain-persistence-standard/SKILL.md

---
name: domain-persistence-standard
description: Domain을 Entity로 저장하는 규칙. Repository 구현체의 save, XxxEntityMapper, 트랜잭션 경계, 애그리거트 자식 컬렉션을 작성하거나 리뷰할 때 반드시 적용한다.
---

# Domain 영속화 표준

`Domain`은 POJO라 `toEntity()`가 만드는 Entity는 **항상 분리(detached) 상태**다. 그대로 `save()`하면 `merge`가 되고, `merge`는 분리 인스턴스의 **모든 필드를 복사**하므로 `Domain`이 나르지 않는 컬럼이 `null`로 덮인다.

## 저장 경로
- 신규(`id == null`)는 `toEntity()`로 만들어 그대로 저장한다. `persist`가 되어 INSERT다.
- 기존(`id != null`)은 `merge`를 쓰지 않는다. `findById`**영속 Entity**를 꺼내 `applyTo()`로 비즈니스 필드만 덮고 더티체킹에 맡긴다.
- 수정 경로에 `jpaEntityRepository.save(mapper.toEntity(domain))`를 쓰지 않는다. `@Version`과 감사 필드가 `null`로 덮인다.
- `findById`가 비면 `BusinessException`으로 `XXX_NOT_FOUND`를 던진다.
- 삭제도 같은 모양이다. `findById`로 자식까지 로딩한 뒤 영속 Entity를 `remove`한다. aggregate-deletion-standard를 따른다.
- 같은 트랜잭션 안에서 Service가 이미 조회했다면 구현체의 `findById`는 영속성 컨텍스트에서 찾으므로 추가 쿼리가 나가지 않는다.

## XxxEntityMapper
- `toEntity(XxxDomain)`, `applyTo(XxxEntity, XxxDomain)`, `toDomain(XxxEntity)` 셋을 갖는다.
- `applyTo``Domain`이 아는 필드만 덮는다. `@Version`, 감사 필드, 그 밖에 `Domain`에 없는 컬럼은 건드리지 않는다.
- Entity에 컬럼을 추가할 때 `Domain`에 넣을지 판단한다. 넣지 않으면 `applyTo`도 건드리지 않으므로 저장이 자동으로 안전하다.

## Domain이 갖지 않는 것
- `@Version`은 Entity에만 둔다. 영속 Entity를 직접 수정하므로 `Domain`이 version을 나르지 않아도 낙관적 락이 정상 동작한다.
- `@CreatedDate`, `@LastModifiedDate` 등 감사 필드도 Entity에만 둔다.
- `@CreatedBy`/`@LastModifiedBy`도 마찬가지다. `AuditorAware``SecurityContext`에서 직접 읽으며, `Command``userId`와는 다른 경로다. 둘을 섞어 `Domain`에 감사 필드를 넣지 않는다. authenticated-user-standard를 따른다.
- 이 필드들을 저장을 위해 `Domain`에 넣지 않는다. 응답에 필요하면 그때 별도로 정한다.

## 트랜잭션
- 경계는 `XxxService`의 UseCase 구현 메서드다. 쓰기는 `@Transactional`, 조회는 `@Transactional(readOnly = true)`.
- UseCase에 없는 package-private 내부 메서드에는 `@Transactional`을 붙이지 않는다. 프록시가 가로채지 못해 동작하지 않는데 붙어 있으면 동작한다고 착각한다.
- `XxxService` 클래스가 package-private이어도 인터페이스 구현 메서드는 `public`이므로 프록시는 정상 동작한다.
- `readOnly = true` 트랜잭션 안에서 `save`를 호출하지 않는다.
- 트랜잭션 없이 `applyTo` 경로를 타면 Entity가 분리 상태라 **UPDATE가 아예 나가지 않는다.** 예외도 나지 않으므로 경계를 반드시 건다.

## 애그리거트 자식 컬렉션
- Entity의 자식 컬렉션에 `cascade = CascadeType.ALL`, `orphanRemoval = true`를 건다.
- `applyTo`는 자식을 **id로 대조**한다. 빠진 것만 컬렉션에서 제거하고, id가 `null`인 것만 새로 추가하고, 남은 것은 필드만 덮는다.
- `clear()` 후 다시 채우지 않는다. 자식 행이 매 저장마다 새 PK를 받아, 응답으로 나간 항목 id나 그 id를 참조하는 테이블이 조용히 깨진다.
- `clear()` 직후 같은 id로 다시 넣지 않는다. `orphanRemoval`이 삭제를 예약한 상태라 같은 flush에서 충돌한다.
- 조회는 `join fetch`로 자식까지 채워오므로 `applyTo` 시점에 컬렉션은 이미 메모리에 있다. aggregate-boundary-standard를 따른다.

## 배치
- `{도메인}/batch``XxxUseCase`를 호출하므로 이 규칙이 그대로 적용된다. 트랜잭션은 chunk가 열고 Service는 거기에 참여한다. batch-execution-standard를 따른다.
- `{도메인}/bulk``Domain`도 Entity도 거치지 않으므로 이 규칙의 대상이 아니다.

GitHub Copilot — .github/instructions/domain-persistence-standard.instructions.md

---
description: Domain을 Entity로 저장하는 경로와 트랜잭션 경계 규칙
applyTo: "**/infra/**/*.java, **/application/**/*.java"
---

- 신규 저장(`id == null`)은 `mapper.toEntity(domain)`을 그대로 저장해 INSERT한다.
- 기존 저장(`id != null`)은 `merge`를 쓰지 않는다. `findById`로 영속 Entity를 꺼내 `mapper.applyTo(entity, domain)`로 덮고 더티체킹에 맡긴다.
- 수정 경로에 `jpaEntityRepository.save(mapper.toEntity(domain))`를 쓰지 않는다. `Domain`에 없는 컬럼이 `null`로 덮인다.
- `findById`가 비면 `BusinessException`으로 `XXX_NOT_FOUND`를 던진다.
- 삭제도 `findById`로 자식까지 로딩한 뒤 영속 Entity를 `remove`한다.
- `XxxEntityMapper``toEntity`, `applyTo`, `toDomain` 셋을 갖는다.
- `applyTo``Domain`이 아는 필드만 덮고 `@Version`·감사 필드는 건드리지 않는다.
- `@Version``@CreatedDate`/`@LastModifiedDate`는 Entity에만 두고 `Domain`에 넣지 않는다.
- `@CreatedBy`/`@LastModifiedBy``AuditorAware``SecurityContext`에서 읽는다. `Command``userId`와 섞어 `Domain`에 넣지 않는다.
- 트랜잭션 경계는 `XxxService`의 UseCase 구현 메서드다. 쓰기는 `@Transactional`, 조회는 `@Transactional(readOnly = true)`를 붙인다.
- UseCase에 없는 package-private 내부 메서드에는 `@Transactional`을 붙이지 않는다.
- `readOnly = true` 트랜잭션 안에서 `save`를 호출하지 않는다.
- 애그리거트 자식 컬렉션에는 `cascade = CascadeType.ALL`, `orphanRemoval = true`를 건다.
- `applyTo`는 자식을 id로 대조해 빠진 것만 제거하고, id가 `null`인 것만 추가하고, 남은 것은 필드만 덮는다.
- 자식 컬렉션을 `clear()` 후 다시 채우지 않는다.
- `{도메인}/batch`에도 이 규칙이 그대로 적용되고, `{도메인}/bulk`는 대상이 아니다.

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

댓글남기기