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

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

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

한 줄씩 보면, mapper.toEntity(order)는 Domain 객체를 DB 테이블과 짝지어진 Entity로 바꾸는 호출이고, jpaEntityRepository.save(entity)는 JPA가 기본으로 제공하는 저장 메서드로 그 Entity를 DB에 반영한다(새 데이터면 INSERT, 이미 있는 데이터면 UPDATE). mapper.toDomain(...)은 그 반대로 저장된 결과를 다시 Domain으로 바꿔 돌려준다.

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

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

여기서 created_at은 orders 테이블의 컬럼 하나다 — 그 행을 언제 만들었는지 저장해 두는 자리다. 예외도 로그도 없이 이 컬럼만 조용히 비워진다는 게 문제다.


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

세 가지가 맞물려 있다.

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

둘, save()는 id가 있으면 merge를 부른다. Spring Data의 구현이 그렇다 — save()는 우리가 짠 코드가 아니라 Spring Data가 이미 만들어 둔 메서드이고, 그 안에서 id 유무로 persist와 merge를 가른다.

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

한 줄씩 보면, id가 없으면(아직 DB에 없는 새 데이터면) persist를 불러 INSERT 문이 나가게 하고, id가 있으면(이미 있는 데이터면) merge를 부른다. em은 EntityManager의 줄임으로, JPA가 영속성 컨텍스트를 직접 다루는 저수준 창구다.

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

그래서 벌어지는 일

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

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

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

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

당시 @LastModifiedDate는 @PreUpdate에서 다시 채워져 살아남았다. 셋 중 하나만 멀쩡한 것이 더 위험했다 — 감사 필드가 정상 동작하는 것처럼 보였다. 이후 DB 시간 저장 표준에서 createdAt·updatedAt은 DB가 만들도록 바꿨다가, PostgreSQL로 옮기면서(2026-10-20) createdAt은 DB 기본값이, updatedAt은 @PreUpdate가 채우도록 다시 바꿨다.


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

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

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

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

        mapper.applyTo(entity, order);      // Domain이 아는 필드만 덮는다
        jpaEntityRepository.flush();        // 바뀐 것만 UPDATE — 예외를 이 catch에서 받기 위해 커밋 전에 보낸다

        return mapper.toDomain(entity);

    } catch (OptimisticLockingFailureException e) {
        throw ErrorCodeException.of(ErrorCode.ORDER_CONCURRENT_UPDATE, e);
    }
}

한 줄씩 따라가기. findById(order.getId())는 결과가 있을 수도 없을 수도 있어 Optional에 담아 돌려준다. .orElseThrow(() -> ErrorCodeException.of(ErrorCode.ORDER_NOT_FOUND))에서 () -> ErrorCodeException.of(...)는 람다다 — “값이 있으면 그 값을 쓰고, 없으면 이 괄호 안의 코드를 실행해서 그 결과(예외)를 던져라”는 뜻이다. 즉 주문을 못 찾으면 ORDER_NOT_FOUND 예외를 즉시 던지고, 찾았으면 그 영속 OrderEntity를 그대로 꺼내 쓴다.

영속 객체를 직접 손대니 JPA가 바뀐 것만 반영한다. flush()는 그 UPDATE를 커밋까지 미루지 않고 이 자리에서 보낸다 — 커밋 때 터진 예외는 메서드가 이미 끝난 뒤라 아래 catch에 닿지 않기 때문이다(예외의 종류 표준 원칙 2-2). 그 catch는 남이 먼저 고쳐 낙관적 락이 걸렸을 때 나는 OptimisticLockingFailureException을 받아, 우리 에러 코드 ORDER_CONCURRENT_UPDATE를 실은 ErrorCodeException으로 바꿔 던진다(원칙 5에서 다시 본다). createdAt은 건드리지 않았으므로 그대로 있고, version은 JPA가 알아서 올리고 검사한다.

@Version을 Domain에 넣지 않아도 낙관적 락이 동작한다. 정해두고 동작하지 않던 규칙이 이제 실제로 선다. 다만 이 락이 막는 것은 두 저장이 같은 순간에 겹칠 때뿐이다. 수정 화면을 열어 둔 사이에 남이 저장한 경우는 따로 막아야 했다(원칙 5-1).

applyTo가 toEntity와 다른 점

@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은 아예 언급하지 않는다
    }
}

한 줄씩 보면, @Component는 이 클래스를 스프링이 관리하는 빈으로 등록하라는 표시다 — @Service가 “업무 절차를 처리하는 빈”이라는 더 구체적인 의미를 더한 것이라면, @Component는 그런 역할 구분 없이 “그냥 빈으로 등록해 달라”는 가장 기본적인 표시다. OrderEntityMapper는 업무 절차가 아니라 Domain과 Entity를 서로 바꿔주는 변환 작업만 하므로 @Service가 아니라 @Component를 붙인다.

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

대안은 Domain이 version과 createdAt을 나르게 하는 것이었다. 그러면 코드 모양은 지금과 같지만, Entity에 컬럼이 늘 때마다 Domain과 restore()까지 같이 고쳐야 하고 빠뜨리면 다시 null이 된다. restore()는 DB에서 꺼낸 값으로 기존 Domain을 다시 만들 때 쓰는 정적 팩토리 메서드다 — 새로 만들 때 쓰는 create()와 짝을 이룬다. 둘 다 Entity에 필드가 느는 순간 같이 고쳐야 하는 자리라, 사람이 계속 기억해야 하는 규칙이다.

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


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

UseCase는 “이 프로그램으로 할 수 있는 일 하나”를 가리키는 인터페이스다 — OrderCommandUseCase처럼 보통 인터페이스로 만들고, Service가 그 인터페이스를 구현한 메서드가 실제 처리 절차이자 이 원칙이 말하는 트랜잭션 경계다.

원칙 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) { ... }   // 내부 메서드 — 붙이지 않는다
}

한 줄씩 보면, @Service는 이 클래스를 스프링이 관리하는 Service 빈으로 등록하는 어노테이션이고, @RequiredArgsConstructor는 final 필드만 받는 생성자를 자동으로 만들어 주는 롬복 어노테이션이다. @Transactional은 그 메서드가 시작될 때 트랜잭션을 열고 끝나면 커밋하는 표시이고, readOnly = true를 주면 조회 전용이라는 걸 알려줘서 JPA가 더티체킹을 꺼 불필요한 비용을 줄인다. 두 메서드가 돌려주는 ServiceResult는 Domain을 그대로 주지 않고 한 겹 감싸는 그릇이다. 인자로 받는 ConfirmOrderCommand는 요청이 서비스로 들고 오는 값을 담은 Command다(이름 규칙은 계층별 DTO 네이밍 표준).

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

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

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

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

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


원칙 3. UseCase에 없는 내부 메서드에는 @Transactional을 붙이지 않는다

반증: 같은 패키지의 다른 빈이 부를 때 @Transactional 이 가시성별로 걸리는가(메서드 안 isActualTransactionActive) — Boot 3.5.16 프록시 cglib · public true · protected true · package-private true · Boot 4.1.1 프록시 cglib · public true · protected true · package-private true — 증명

여기서 isActualTransactionActive는 스프링이 제공하는 메서드로, 지금 실행 중인 코드가 실제로 트랜잭션 안에 있는지를 참/거짓으로 알려준다. 이 실험은 메서드 호출 직전(트랜잭션 밖)과 메서드 안(트랜잭션 안이어야 할 자리)에서 이 값을 각각 재서, @Transactional이 가시성(public·protected·package-private)에 따라 실제로 걸리는지를 확인했다.

원칙 2가 경계를 UseCase 구현 메서드에 두었으니, 그 밖의 package-private 내부 메서드에는 @Transactional을 붙이지 않는다. 경계를 UseCase 구현 메서드 하나로 유지하기 위해서다.

「프록시가 못 가로채니 어차피 동작하지 않는다」고 적어뒀는데 그건 틀렸다. 실측에서 같은 패키지의 다른 빈이 부르자 public·protected·package-private 셋 다 트랜잭션 안이었다(두 Boot 버전, CGLIB 프록시). 호출 직전에 잰 값은 전부 false라 앞 트랜잭션이 샌 착시도 아니다. Spring 문서도 같은 말을 한다 — “As of 6.0, protected or package-visible methods can also be made transactional for class-based proxies by default.”(Spring Framework 문서, Using @Transactional) 5.3까지는 public만 인식했으니, 옛 문장은 옛 동작을 기준으로 쓰인 것이다.

규칙은 그대로 두고 이유를 바꾼다. 내부 메서드에 붙인 @Transactional은 누가 부르느냐에 따라 걸리기도 하고 안 걸리기도 한다.

  • 같은 Service 안에서 부르면 걸리지 않는다. 가시성 때문이 아니라 프록시를 거치지 않아서다 — “only external method calls coming in through the proxy are intercepted.”(같은 문서) 이건 실험으로 재지 않았고 문서로만 받친다
  • 같은 패키지의 다른 빈이 부르면 걸린다. 실측이 이쪽이다. 그러면 경계가 UseCase가 아닌 자리에 새로 생긴다. 부르는 쪽이 이미 트랜잭션 안이면 기본 설정대로 그 트랜잭션에 합류해 티가 안 나지만, 부르는 쪽이 트랜잭션 밖이면 그 메서드가 자기 트랜잭션을 새로 열고 끝나는 순간 먼저 커밋한다(spring-tx의 support/AbstractPlatformTransactionManager.java:382–405, :787–794). 부르는 쪽이 그 뒤에 실패해도 앞부분만 DB에 남고, readOnly인지도 UseCase가 아니라 그 메서드가 정하게 된다

어느 쪽이든 붙어 있는 어노테이션만 보고는 경계가 어디인지 알 수 없다. 그래서 이 규칙은 가시성이 막아 주는 것이 아니다. 한동안은 사람이 리뷰에서 지키는 약속으로만 두었다.

내부 메서드 금지는 ArchUnit 두 줄이 지킨다

리뷰로 지키는 규칙은 패키지 구조 표준 원칙 6이 이미 버린 모양이다 — 파일이 수백 개인 저장소에서 어노테이션 한 줄을 눈으로 거르는 것은 되는 척만 하는 규칙이다. 그래서 같은 도구, ArchUnit으로 지키기로 했다(2026-10-21). 안건(#168)에는 후보가 셋 올라 있었다.

  • 컴파일할 때 검사하는 도구(Error Prone 커스텀 검사 같은 것) — 얻는 것은 테스트를 돌리기 전, 컴파일 단계에서 걸린다는 점이다. 버린 이유는 검사기를 직접 짜서 컴파일러에 꽂아야 하고, 이 표준은 이미 ArchUnit으로 구조 규칙을 지키고 있어서 도구가 하나 더 늘기 때문이다
  • Spring 설정을 바꿔 public 메서드의 @Transactional만 읽게 되돌리기(AnnotationTransactionAttributeSource(true)) — 얻는 것은 package-private 메서드에 붙여도 트랜잭션이 안 걸려, 경계가 UseCase 밖에 생기지 않는다는 점이다. 버린 이유는 그 어노테이션이 에러 없이 조용히 무시된다는 것이다. public이 아니면 Spring이 트랜잭션 설정을 「없음」으로 돌려주고(spring-tx의 interceptor/AbstractFallbackTransactionAttributeSource.java:163–164), 설정이 없는 메서드는 프록시가 가로채지 않는다(interceptor/TransactionAttributeSourcePointcut.java:54–55). 경고 로그도 없다. 붙어 있는데 아무 일도 안 하는 어노테이션이 남아 6.0 이전의 함정이 되살아난다. 게다가 Spring의 기본 동작(annotation/AbstractTransactionManagementConfiguration.java:84의 false)을 바꾸는 일이다
  • ArchUnit 규칙 — 붙이는 순간 테스트가 빨개져 알려 준다

고른 것은 ArchUnit 규칙 두 줄이다. 두 번째 안과 달리 잘못 붙인 어노테이션이 조용히 남지 않고, 첫 번째 안과 달리 이미 쓰는 도구에 두 줄을 더할 뿐이다.

private static final String SPRING_TX = "org.springframework.transaction.annotation.Transactional";
private static final String JAKARTA_TX = "jakarta.transaction.Transactional";

@ArchTest
static final ArchRule Transactional_메서드는_public이다 =
        methods().that().areAnnotatedWith(SPRING_TX)
                .or().areAnnotatedWith(JAKARTA_TX)
                .should().bePublic();

@ArchTest
static final ArchRule Transactional_클래스에는_package_private이나_protected_메서드가_없다 =
        noMethods().that().areDeclaredInClassesThat().areAnnotatedWith(SPRING_TX)
                .or().areDeclaredInClassesThat().areAnnotatedWith(JAKARTA_TX)
                .should().bePackagePrivate().orShould().beProtected();

한 줄씩 따라가기. @ArchTest가 붙은 ArchRule 필드 하나가 테스트 하나다. 첫 규칙의 methods().that().areAnnotatedWith(SPRING_TX)는 「Spring의 @Transactional이 붙은 메서드 전부」이고, .or().areAnnotatedWith(JAKARTA_TX)가 「또는 jakarta의 @Transactional이 붙은 메서드」를 더한다. .should().bePublic()은 그 메서드가 전부 public이어야 한다는 뜻이라, package-private이나 protected 메서드에 붙이면 테스트가 실패한다. 둘째 규칙의 noMethods().that().areDeclaredInClassesThat().areAnnotatedWith(...)는 「클래스 머리에 @Transactional이 붙은 클래스 안의 메서드는 하나도」이고, .should().bePackagePrivate().orShould().beProtected()와 합쳐 「package-private이거나 protected이면 안 된다」가 된다. 첫 규칙과 같은 기준이다. 클래스에 붙인 @Transactional은 프록시가 가로채는 메서드 전부에 걸린다. Spring은 메서드에 @Transactional이 없으면 그 메서드가 속한 클래스에서 찾고(spring-tx의 interceptor/AbstractFallbackTransactionAttributeSource.java:175–184), 기본 설정에서는 public 검사를 하지 않기 때문이다(같은 파일 :163, annotation/AbstractTransactionManagementConfiguration.java:84). 그래서 메서드에 직접 붙이지 않은 내부 메서드도 이 줄이 잡는다.

jakarta 쪽도 겨누는 이유는 Spring이 그것도 트랜잭션으로 읽기 때문이다. Spring은 클래스패스에 jakarta.transaction.Transactional이 있으면 그 어노테이션을 읽는 해석기를 하나 더 등록한다(spring-tx의 transaction/annotation/AnnotationTransactionAttributeSource.java:69, :90). Spring 것만 막으면 jakarta 것을 붙인 내부 메서드는 그대로 지나간다. 어노테이션을 클래스가 아니라 이름 문자열로 겨눈 것은, jakarta.transaction이 없는 프로젝트에서도 이 테스트가 컴파일되게 하려는 것이다.

두 줄을 가짜 클래스에 돌려 봤다. ArchUnit 1.4.1(이 표준을 실제로 적용해 쓰는 프로젝트 하나가 쓰는 버전)로, 두 어노테이션을 같은 이름으로 흉내 낸 클래스를 만들어 걸었다. 메서드에 붙인 package-private·protected 두 곳과, 클래스에 붙인 경우(두 어노테이션 각각)의 package-private·protected 메서드 네 곳이 걸렸다. @Transactional이 없는 클래스의 package-private·protected 메서드, private 메서드, 람다가 컴파일되며 생기는 private 메서드는 걸리지 않았다. 그 프로젝트의 코드에 돌려 본 것은 아니다.

대가는 이 두 줄이 「UseCase에 없는」까지는 보지 않는다는 것이다. UseCase 인터페이스에 없는 메서드라도 public이면 첫 줄을 통과한다. 두 줄이 막는 것은 원칙의 문장 그대로, package-private(그리고 protected) 내부 메서드에 붙이는 것이다.


원칙 4. 자식 컬렉션은 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<>();

이 설정은 OrderEntity가 OrderItemEntity 목록을 @OneToMany · cascade · orphanRemoval 관계로 들고 있다는 뜻이다. items 컬렉션에서 항목을 빼면 그 자식 행이 DB에서 같이 지워지고, 새 항목을 더하면 같이 저장된다 — 자식의 생명주기를 부모가 대신 관리해 준다.

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);          // 남은 것은 필드만
        }
    }
}

한 줄씩 보면 이렇다. existing은 지금 Entity가 들고 있는 자식들을 id를 키로 하는 맵으로 만든 것이다 — .stream()으로 리스트를 스트림으로 바꿔 훑으면서, toMap의 첫 번째 인자 OrderItemEntity::getId는 메서드 참조다 — it -> it.getId()라는 람다를 짧게 줄여 “각 항목에서 getId()를 불러 그 값을 키로 써라”는 뜻이고, 두 번째 인자 it -> it라는 람다는 “그 항목 자체를 값으로 쓰겠다”는 뜻이다. keep은 Domain이 최종적으로 가지고 있어야 할 자식들의 id만 모은 집합이다 — 새로 추가되는 항목은 아직 id가 없으므로 filter(Objects::nonNull)로 걸러낸다. removeIf는 keep에 없는, 즉 Domain에서 빠진 자식만 컬렉션에서 지운다 — orphanRemoval 덕분에 이 한 줄이 DELETE로 이어진다. 마지막 for문은 Domain의 자식을 순서대로 보면서, id가 없으면 새 Entity를 만들어 더하고(INSERT), id가 있으면 기존 Entity를 찾아 필드만 덮는다(UPDATE 또는 변경 없음).

DELETE FROM order_items WHERE id = 2;   -- B만
INSERT INTO order_items ... ;           -- D만
-- A와 C는 값이 안 바뀌었으면 UPDATE도 안 나간다

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

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

그런데 자식 행의 PK(지금까지 써온 그 id가 바로 이것이다)가 매 저장마다 바뀐다. 안 바뀐 A와 C도 지웠다 다시 들어가기 때문이다.

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

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

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

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


원칙 5. 수정 경로가 있는 Entity에는 @Version을 둔다

원칙 1은 「@Version을 Entity에만 두면 낙관적 락이 동작한다」고 했다. 그런데 @Version을 둬야 하는지는 정하지 않았다. 두는 곳만 정하고, 두라는 말은 없었다.

표준 DB인 PostgreSQL의 기본 격리 수준 READ COMMITTED에서는 이 빈칸이 그대로 구멍이다. 예외의 종류 표준 원칙 2-3은 낙관락 catch가 돌도록 격리 수준을 READ COMMITTED에 두기로 했는데, 그 상태에서 version 칸이 없는 표를 두 트랜잭션이 읽고 고치자 먼저 커밋한 쪽의 수정이 예외도 롤백도 없이 사라졌다(증명의 (마)). DB가 대신 막아 주는 장치가 처음부터 없다. 잃어버린 수정이다. 낙관적 락은 「읽었을 때 몇 번이었나」를 비교하는 장치라서, 비교할 번호 칸이 없으면 아무것도 막지 못한다. 그 비교를 하는 것은 JPA이고, JPA는 Entity에 @Version이 붙은 필드가 있을 때만 비교한다. 실측 (마)는 version 칸이 아예 없는 표였다. 표에 칸은 있는데 Entity에 @Version 필드가 없는 경우는 재지 않았다.

정해야 했던 건 이거였다 — 어떤 Entity에 @Version을 둬야 하는가.

안 A — 정하지 않는다. 지금처럼 필요한 사람이 붙인다. 얻는 것은 쓰지 않을 칸을 만들지 않는다는 점이다. 버린 이유는 READ COMMITTED에서는 붙이지 않은 표마다 동시 수정이 조용히 사라지기 때문이다. 예외도 로그도 없어서, 누가 빠뜨렸는지 한참 뒤에야 데이터가 어긋난 것으로 알게 된다.

안 B — 모든 Entity에 둔다. 규칙이 가장 단순하다. 얻는 것은 「이 표는 고치는가」를 따질 필요가 없다는 점이다. 버린 이유는 넣기만 하고 고치지 않는 표(로그, 이력)에도 한 번도 올라가지 않을 version 칸을 만들게 한다는 것이다. 덮어쓸 일이 없는 표에는 지킬 것이 없다.

골랐다 — 안 C. 수정 경로가 있는 Entity에는 @Version을 반드시 둔다. 「수정 경로가 있다」는 것은 한 번 저장된 행을 원칙 1처럼 findById로 읽어서 고치는 코드가 있다는 뜻이다. 읽지 않고 조건으로만 고치는 벌크 경로는 여기에 들지 않는다(이 섹션 끝). 안 A가 남기는 구멍을 막고, 안 B처럼 고치지 않는 표에까지 칸을 강제하지 않는다.

@Entity
class OrderEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private Long version;   // 수정 경로가 있으므로 둔다 — Domain은 모른다(원칙 1)
    ...
}

한 줄씩 보면, @Version은 이 필드를 낙관적 락의 번호 칸으로 쓰라는 JPA 표시다. JPA가 고친 내용을 DB에 저장할 때마다 이 값을 1 올리고, 저장 명령에 「번호가 읽었을 때 그대로인 행만 고쳐라」라는 조건을 싣는다. 그사이 남이 고쳐 번호가 바뀌었으면 조건에 맞는 행이 0개라, JPA가 OptimisticLockingFailureException을 던지고 Repository 구현체가 그것을 409로 번역한다 — HTTP 상태 코드 409 Conflict, 「지금 서버 상태와 부딪혀 처리하지 않았다」는 응답이다(예외의 종류 표준 원칙 2). 이 번역에는 조건이 둘 붙는다 — 구현체가 try 안에서 flush()를 불러야 하고(원칙 2-2), 격리 수준이 READ COMMITTED여야 한다(원칙 2-3). 원칙 1대로 applyTo는 이 필드를 건드리지 않고, OrderDomain에도 이 필드는 없다.

대가는 Entity를 만들 때 「이 표를 나중에 고치는가」를 한 번 묻게 된다는 것이다. 처음에 넣기만 하는 표였다가 나중에 읽어서 고치는 코드가 생기면, 그때 @Version과 마이그레이션을 함께 넣어야 한다. 그 순간을 놓치면 다시 구멍이 열린다. 무엇으로 그 순간을 잡을지는 아직 정하지 않았다.

읽지 않고 고치는 경로는 이 원칙의 대상이 아니다. 벌크로만 고치는 Entity라면 @Version을 두지 않아도 된다. 벌크 경로는 행을 꺼내 보지 않고 조건으로 한꺼번에 고친다. 벌크로만 고치는 Entity는 읽어서 고치는 경로가 없으니 비교할 번호가 필요 없다.

수정 경로와 벌크가 둘 다 있는 Entity는 다르다. 이 Entity에는 위 규칙대로 @Version이 있고, 벌크 SQL이 version을 올리지 않으면 이 원칙이 막으려던 잃어버린 수정이 벌크 쪽에서 다시 생긴다. 관리자가 번호 3으로 읽은 주문을 벌크가 고치고 번호를 3에 그대로 두면, 관리자의 저장이 「번호가 3인가」를 통과해 벌크의 변경을 지운다. 관리자가 다른 칸만 고쳤어도 그렇다 — Hibernate는 따로 설정하지 않으면(@DynamicUpdate가 없으면) 바뀐 칸만이 아니라 id를 뺀 모든 칸을 UPDATE에 싣기 때문에, 관리자가 읽어 둔 낡은 status까지 다시 써 넣는다(Hibernate 사용자 가이드 「Persistence Context」 장: “By default, when you modify an entity, all columns but the identifier are being set during update.”). 그래서 이런 표를 벌크로 고칠 때는 SQL에 version = version + 1을 함께 적는다. 장면과 버린 안은 배치 실행 표준 원칙 2-1에 있다.


원칙 5-1. 수정 화면이 있는 Entity는 조회 때 받은 version을 수정 요청이 돌려보낸다

원칙 5의 @Version이 있으면 동시 수정이 막힌다고 적었다. 그런데 막히는 것은 두 저장이 같은 순간에 겹칠 때뿐이다. 사람이 화면을 보고 고치는 시간까지는 막지 못한다.

1. A가 주문 수정 화면을 연다        화면에 보이는 값: quantity 3 (version 3)
2. B가 같은 주문의 quantity를 4로 고쳐 저장한다   DB: quantity 4, version 4
3. 5분 뒤 A가 화면에서 quantity를 5로 고쳐 저장한다
   서버가 findById로 다시 읽는다    → quantity 4, version 4인 최신 행
   applyTo로 A의 값 5를 덮는다
   UPDATE ... WHERE id = 7 AND version = 4   → 1행 고쳐짐, 예외 없음

한 줄씩 보면, 1에서 A가 본 것은 version 3일 때의 주문, 수량 3이다. 2에서 B가 수량을 4로 고쳐 번호가 4가 됐다. 3에서 A의 저장 요청이 오면 서버는 원칙 1대로 findById로 행을 그때 읽는다. 읽은 번호가 4이니 JPA가 싣는 조건도 「version이 4인 행」이고, 읽은 뒤에 끼어든 저장이 없으니 조건이 맞아 그대로 저장된다. A는 수량이 4로 바뀐 것을 본 적이 없는데 A의 5가 B의 4를 덮는다. 잃어버린 수정이다. @Version은 「서버가 읽은 순간」과 「서버가 저장하는 순간」 사이만 지키고, 「사람이 화면에서 본 순간」은 모른다.

그 프로젝트를 세어 보니 @Version이 있는 Entity는 2개, 화면에서 돌려받은 번호를 비교하는 코드는 0건이었다. 이 장면은 지금 아무 데서도 막히지 않는다.

정해야 했던 건 이거였다 — 화면을 연 뒤 남이 저장한 것을 무엇으로 알아채는가.

안 A — 조회의 ETag를 If-Match로 돌려받아 412로 거절한다. HTTP 성공 응답 표준이 조회 응답에 이미 ETag를 붙인다. 클라이언트가 수정 요청에 그 값을 If-Match 헤더로 실어 보내고, 서버는 지금의 ETag와 다르면 412(조건 실패)로 거절한다. 얻는 것은 HTTP 표준 장치를 그대로 쓴다는 점과, 응답 본문에 새 칸이 생기지 않는다는 점이다. 버린 이유는 둘이다. 그 ETag는 완성된 응답 본문의 해시라, 비교하려면 저장 요청 안에서 조회 응답을 통째로 다시 만들어 해시해야 한다. 저장마다 조회 한 번이 고스란히 붙는다. 그리고 본문에는 그 Entity 말고도 딸려 나가는 데이터가 있어서, 고치려는 Entity는 그대로인데 딸린 데이터만 바뀌어도 충돌로 잡힌다.

안 B — 막지 않는다. 나중에 저장한 쪽이 이긴다. 얻는 것은 아무것도 더하지 않는다는 점이다. 버린 이유는 위 장면 그대로, B의 수정이 소리 없이 덮인다는 것이다. 예외도 로그도 없어서 B가 「분명히 4로 고쳤는데 5가 돼 있다」고 말할 때에야 드러난다.

골랐다 — 안 C. 수정 화면이 있는 Entity만 조회 응답에 version을 싣고, 수정 요청이 그 값을 돌려보내고, Repository 구현체가 비교한다.

화면은 수정 화면을 열 때 받은 조회 응답(JSON)에서 version을 받아 들고 있다가, 사용자가 값을 고쳐 저장을 누르면 수정 요청 본문에 그 값을 손대지 않고 그대로 넣어 보낸다.

// 조회 응답 — 수정 화면이 있는 Entity만 version을 싣는다
{ "id": 7, "quantity": 3, "version": 3 }

// 수정 요청 본문 — 받은 version을 그대로 돌려보낸다
{ "quantity": 5, "version": 3 }
// Repository 구현체
@Override
public OrderDomain update(OrderDomain order, long expectedVersion) {
    try {
        OrderEntity entity = jpaEntityRepository.findById(order.getId())
                .orElseThrow(() -> ErrorCodeException.of(ErrorCode.ORDER_NOT_FOUND));

        if (entity.getVersion() != expectedVersion) {        // 화면을 연 뒤 남이 저장했다
            throw ErrorCodeException.of(ErrorCode.ORDER_CONCURRENT_UPDATE);
        }

        mapper.applyTo(entity, order);
        jpaEntityRepository.flush();                          // 같은 순간에 겹친 저장은 @Version이 막는다
        return mapper.toDomain(entity);

    } catch (OptimisticLockingFailureException e) {
        throw ErrorCodeException.of(ErrorCode.ORDER_CONCURRENT_UPDATE, e);
    }
}

한 줄씩 따라가기. 메서드는 고칠 OrderDomain과 별도로 expectedVersion을 받는다 — 화면이 처음 받았던 번호다. findById로 영속 Entity를 꺼내는 것까지는 원칙 1과 같다. 그다음 if가 지금 DB의 번호와 화면이 본 번호를 비교한다. 위 장면이라면 4와 3이라 다르고, 그러면 applyTo로 값을 덮기 전에 ORDER_CONCURRENT_UPDATE(409)를 던진다. 같으면 원칙 1 그대로 덮고 flush()한다. 비교와 flush() 사이에 다른 저장이 끼어드는 경우는 원래의 @Version이 맡아 OptimisticLockingFailureException이 나고, 아래 catch가 그것도 같은 409로 바꾼다(예외의 종류 표준 원칙 2). 두 장치가 서로 다른 틈을 맡는다 — 비교는 사람이 화면을 보던 시간을, @Version은 서버 안의 짧은 순간을.

version은 이 길에서 Domain을 한 번도 거치지 않는다. 수정 요청 본문의 값을 XxxCommand(요청이 서비스로 들고 오는 값)가 들고 오고, Service가 repository.update(domain, command.getExpectedVersion())처럼 Repository에 따로 넘긴다. 원칙 1이 Domain에서 version을 뺀 이유 — 넣으면 Entity에 칸이 늘 때마다 Domain까지 고쳐야 하고 빠뜨리면 다시 null이 된다 — 가 여기서도 그대로 서 있어서다. 조회 쪽에서는 XxxView가 version을 싣는다(2026-10-21). XxxView는 조회 결과를 담는 타입이고, XxxResponse는 밖으로 나가는 응답이다(이름 규칙은 계층별 DTO 네이밍 표준). 조회 경로는 Repository가 XxxView를 바로 돌려주고 Domain을 거치지 않으므로, 조회 SQL이 version 컬럼을 같이 읽어 XxxView에 담고 XxxResponse가 그대로 내보낸다. 그래서 왕복 전체에서 Domain은 version을 모른다 — 나갈 때는 XxxView, 돌아올 때는 XxxCommand가 든다. HTTP 성공 응답 표준의 「ETag 재료를 얻으려고 version을 XxxView로 올리지 않는다」는 ETag가 목적일 때의 말이라 이것과 부딪히지 않는다.

안 A와 견주면, 안 C는 비교할 값이 그 Entity의 번호 하나라서 조회 응답을 다시 만들 일이 없고, 딸린 데이터가 바뀌어도 걸리지 않는다. 안 B와 견주면 B의 수정을 지키고, 실패를 409로 알려 A가 최신 화면을 다시 열게 한다. 실패를 412가 아니라 409로 내는 것은 낙관적 락 충돌과 같은 실패이기 때문이다 — 예외의 종류 표준이 이미 그것을 XXX_CONCURRENT_UPDATE(409)로 정해 두었다.

수정 화면이 있는 Entity로 좁힌 것은, 이 틈이 사람이 화면을 열어 두는 시간에서 생기기 때문이다. 화면 없이 요청 하나 안에서 읽고 고치는 경로에는 그 시간이 없고, 원칙 5의 @Version으로 충분하다.

대가는 셋이다. 수정 화면이 있는 Entity마다 응답과 수정 요청에 version 칸이 하나씩 생기고, 클라이언트는 받은 값을 빠뜨리지 않고 돌려보내야 한다. Repository 인터페이스에는 save와 따로 expectedVersion을 받는 update가 생긴다. 그리고 「이 Entity에 수정 화면이 있는가」를 Entity마다 판단해야 한다 — 나중에 수정 화면이 생기면 그때 version을 응답과 요청에 함께 넣어야 하고, 그 순간을 놓치면 위 장면이 다시 열린다.


판단 기준 정리

질문 답 결론
신규인가 기존인가? id == null toEntity() 그대로 저장 (INSERT)
  id != null findById + applyTo + flush() (바뀐 것만 UPDATE)
수정에 save(toEntity(...))를 쓰나? 아니다 merge가 되어 Domain에 없는 컬럼이 null로 덮인다
@Version은 어디에? Entity에만 영속 Entity를 직접 고치므로 락이 저절로 동작한다
@Version은 어느 Entity에? 수정 경로가 있는 Entity 전부 없으면 동시 수정이 예외 없이 사라진다. 넣기만 하는 표는 두지 않는다
그 Entity를 벌크도 고치면? 벌크 SQL이 version = version + 1을 함께 적는다 안 올리면 읽고 고치는 경로가 벌크의 수정을 덮는다
화면을 연 뒤 남이 저장한 것은? 수정 화면이 있는 Entity만 응답에 version을 싣고, 수정 요청이 돌려보내고, 구현체가 비교한다 다르면 409. version은 Domain을 거치지 않는다. If-Match·412는 쓰지 않는다
시간 감사 필드는 어디에? Entity와 DB 컬럼 applyTo가 건드리지 않는다. createdAt은 DB 기본값이, updatedAt은 @PreUpdate가 채운다
트랜잭션은 어디에? Service의 UseCase 구현 메서드 쓰기는 @Transactional, 조회는 readOnly = true
내부 package-private 메서드는? 붙이지 않는다. ArchUnit 두 줄이 지킨다 같은 Service 안 호출이면 안 걸리고, 다른 빈이 부르면 걸려서 경계가 흔들린다
자식 컬렉션은? id로 대조 빠진 것만 삭제, 새 것만 추가, 남은 것은 필드만
clear() 후 재삽입은? 하지 않는다 자식 PK가 매번 바뀌어 밖에서 참조하던 것이 깨진다
배치에서는? 그대로 적용 트랜잭션은 chunk가 연다

이 표준을 정하기까지

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

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

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

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

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

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

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

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

내부 메서드 규칙의 이유는 나중에 실측으로 뒤집혔다(2026-09-16). 증명 후보를 훑다가 「Spring 6.0부터 CGLIB 프록시는 package-private 메서드도 가로챈다」는 의심이 올라왔고, 실험이 그대로 확인했다. 실험이 틀렸는지부터 봤는데 Spring 문서가 같은 동작을 적고 있었다. 깨진 것은 규칙이 아니라 이유였다. 「붙여도 동작하지 않는다」가 「누가 부르느냐에 따라 동작이 갈린다」로 바뀌었고, 붙이지 않는다는 행동은 오히려 근거가 하나 늘었다.

@Version을 둬야 하는지는 다른 표준의 결정이 드러냈다(2026-10-20). 예외의 종류 표준은 처음에 표준 DB를 MariaDB로 두고, 낙관락 catch를 살리려고 MariaDB의 스냅숏 격리를 끄기로 했다. 그 대가를 따지는데, 끈 상태에서는 version 칸이 없는 표의 동시 수정이 조용히 사라졌다(MariaDB 실험의 (마)). 켠 상태에서는 MariaDB가 이것을 오류 1020으로 막아 주고 있었다. 이 글은 처음부터 「@Version을 어디 둘지」만 정했고 「둬야 하는지」는 정한 적이 없었다. 그동안은 DB 설정이 뒤에서 막아 줘서 아무도 몰랐다. 모든 Entity에 두는 안도 봤지만 고치지 않는 표에까지 칸을 강제해서, 수정 경로가 있는 Entity로 좁혔다(원칙 5).

같은 날 표준 DB를 PostgreSQL로 바꾸면서 원칙 5의 이유가 바뀌었다(2026-10-20). PostgreSQL에는 끌 설정이 없고, 기본값 READ COMMITTED에서 낙관락 catch가 그대로 돌았다. 대신 version 없는 표는 처음부터 무방비였다(PostgreSQL 실험의 (마)). 「끄는 대가로 생긴 구멍」이 「처음부터 있던 구멍」이 된 것이라, 규칙은 그대로 두고 이유만 고쳤다.

원칙 5의 끝에 벌크가 걸린 반대 방향을 이었다(2026-10-21). 원칙 5는 「벌크로만 고치는 Entity는 @Version이 필요 없다」에서 멈춰 있었다. 그런데 수정 경로와 벌크가 둘 다 있는 Entity라면, 벌크가 번호를 그대로 두는 사이 그 전에 읽어 둔 수정 경로의 저장이 낙관적 락을 통과해 벌크의 변경을 지운다. 벌크에는 판단이 없으니 규칙이 필요 없다는 안도 놓았지만 그것은 벌크가 남을 덮지 않는다는 데까지만 맞았다. 벌크가 version을 올리게 하는 규칙은 벌크를 다루는 배치 실행 표준에 두고, 여기서는 그리로 잇기만 했다. 「벌크로만 고치는 Entity」 문장은 그대로 맞아서 두었다.

원칙 5가 막지 못하는 장면이 남아 있었다(2026-10-21). 수정 충돌을 HTTP의 If-Match로 막을지 묻는 안건(#123)을 따지다가, 지금의 @Version이 두 저장이 같은 순간에 겹칠 때만 막는다는 것을 알았다. 「A가 화면을 열고 5분 뒤 저장, 그 사이 B가 저장」은 통과한다. 저장 요청이 올 때 서버가 그때의 최신 행을 읽어 고치기 때문이다. 처음 안건이 묻던 대로 조회에 붙여 둔 ETag를 If-Match로 돌려받아 412로 막는 안을 먼저 봤지만, 그 ETag는 응답 본문 전체의 해시라 저장 요청 안에서 조회 응답을 통째로 다시 만들어야 했고, 본문에 딸린 다른 데이터만 바뀌어도 충돌이 됐다. 막지 않는 안은 소리 없이 덮는다. 그래서 수정 화면이 있는 Entity만 version을 응답에 싣고 돌려받아 Repository 구현체가 비교하기로 했다(원칙 5-1). 비교에 쓰는 값은 여전히 Domain을 거치지 않는다 — 원칙 1이 Domain에서 version을 뺀 이유가 그대로 서 있어서, Command에서 Repository로 따로 넘기는 인자로만 흐르게 했다.

같은 날 원칙 3의 내부 메서드 금지를 ArchUnit으로 강제하기로 했다(2026-10-21, #168). 원칙 3의 이유가 실측으로 바뀐 뒤로 이 규칙은 「사람이 지키는 약속」으로 남아 있었다. 패키지 구조 표준이 경계를 ArchUnit에 맡긴 것과 같은 길을 골라, 「@Transactional이 붙은 메서드는 public이다」와 「@Transactional이 붙은 클래스에는 package-private이나 protected 메서드가 없다」 두 줄을 더했다. Spring이 jakarta.transaction.Transactional도 읽는다는 것을 소스로 확인하고 두 어노테이션을 함께 겨눴다.


정리

  • 수정 경로에서 merge를 쓰지 않는다. findById로 영속 Entity를 꺼내 applyTo로 덮고 구현체 안에서 flush()한다
  • applyTo가 언급하지 않은 필드는 안 건드려진다. 새 컬럼이 생겨도 아무것도 안 하면 안전하다
  • @Version과 감사 필드는 Domain에 두지 않는다. createdAt은 DB 기본값이, updatedAt은 @PreUpdate가 채운다(DB 시간 저장 표준)
  • 트랜잭션 경계는 Service의 UseCase 구현 메서드다. 없으면 저장이 조용히 아무 일도 안 한다
  • 내부 package-private 메서드에는 @Transactional을 붙이지 않는다. 누가 부르느냐에 따라 걸리기도 하고 안 걸리기도 해서 경계가 흔들린다. ArchUnit이 「붙은 메서드는 public」과 「붙은 클래스에 package-private이나 protected 메서드 없음」 두 줄로 지킨다
  • 자식 컬렉션은 id로 대조한다. clear() 후 재삽입은 자식 PK를 매번 바꾼다
  • 수정 경로가 있는 Entity에는 @Version을 반드시 둔다. 없으면 PostgreSQL 기본값 READ COMMITTED에서 동시 수정이 예외 없이 사라진다
  • 수정 화면이 있는 Entity는 조회 때 받은 version을 수정 요청이 돌려보내고 Repository 구현체가 비교한다. @Version은 두 저장이 같은 순간에 겹칠 때만 막는다. 다르면 409이고, version은 Domain을 거치지 않는다

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

댓글남기기