광고
~ / posts / developer

JPA 애그리거트 루트 버전 안 오를 때, 강제 증가 판단표와 비용

자식 엔티티 필드와 mappedBy 컬렉션 변경은 루트 버전을 올리지 않아요. Hibernate 7.4로 명령별 버전 변화와 SQL 수를 실측해 강제 증가를 걸 경로, 거는 방법, 추가 UPDATE 비용, 벌크 수정 함정, 점검 순서를 정리했어요.

루트에 @Version을 붙였는데 두 사람이 같은 주문의 서로 다른 줄을 고치자 한도가 조용히 깨졌다면, 원인은 거의 항상 그 명령이 루트 행을 건드리지 않아서 루트 버전이 그대로였기 때문이에요. 결론부터 말하면 자식 엔티티 필드나 mappedBy 컬렉션만 바꾸는 명령은 루트를 OPTIMISTIC_FORCE_INCREMENT로 읽어야 하고, 이건 불변식에 닿는 모든 쓰기 경로에 빠짐없이 걸어야 효과가 있어요. 한 경로만 빠져도 실측에서 한도 100짜리 주문이 110이 됐어요.

이 글은 원리 설명보다 "우리 코드의 어느 명령에 무엇을 걸까"를 정하는 데 집중해요. Hibernate ORM 7.4.13.Final과 H2 2.4.240으로 명령 종류별 버전 변화와 SQL 문장 수를 직접 세었고, 그 숫자로 판단표·비용 계산·점검 순서를 만들었어요.

광고

명령별로 루트 버전이 오르는지 — Hibernate 7.4 실측표

애그리거트 루트 Ord(버전 long)에 단방향 @OneToMany 줄, @ElementCollection 태그, @Embedded 주소, @OptimisticLock(excluded = true) 조회수를 두고, 비교용으로 양방향(mappedBy) 컬렉션을 가진 루트도 따로 만들었어요. 명령 하나를 트랜잭션 하나로 커밋하고, StatementInspector로 나간 SQL을 모두 모아 셌어요.

루트 버전이 저절로 오르는 변경, 안 오르는 변경, 버전을 우회하는 변경을 세 칸으로 나눈 그림
가운데와 오른쪽 칸이 불변식에 닿으면 그 경로만 따로 처리해요
명령이 바꾸는 것나간 쓰기 SQL루트 버전판단
루트 필드(memo)루트 UPDATE 10 → 1그대로 둬요
임베디드 값(addr.city)루트 UPDATE 10 → 1그대로 둬요
요소 컬렉션에 태그 추가루트 버전 UPDATE 1 + INSERT 10 → 1그대로 둬요
단방향 @OneToMany에 줄 추가INSERT 1 + 루트 버전 UPDATE 1 + 외래키 UPDATE 10 → 1그대로 둬요
단방향 @OneToMany에서 줄 삭제루트 버전 UPDATE 1 + 외래키 UPDATE 1 + DELETE 10 → 1그대로 둬요
자식 엔티티 필드(line.amount)자식 UPDATE 10 → 0강제 증가 필요
mappedBy 컬렉션에 줄 추가자식 INSERT 10 → 0강제 증가 필요
mappedBy 컬렉션에서 줄 삭제자식 DELETE 10 → 0강제 증가 필요
제외 필드(viewCount++)루트 UPDATE 1(버전 검사만)0 → 0의도대로예요
JPQL update Ord set …벌크 UPDATE 10 → 0update versioned로

mappedBy 결과는 버그가 아니라 명세대로예요. Jakarta Persistence 3.2 명세 3.5.1절은 버전 관리에서 엔티티의 상태를 "관계가 아닌 영속 필드와, 그 엔티티가 소유한 관계"로 정의하고, 소유하지 않은 관계는 상태에 넣지 않는다고 적어요. 양방향에서 외래키를 가진 쪽은 자식이니, 부모 컬렉션에 줄을 넣고 빼도 부모 상태는 바뀌지 않은 것이에요. 반대로 @JoinColumn을 단 단방향 @OneToMany는 부모가 관계를 소유하므로 버전이 올라요.

강제 증가를 거는 방법 고르기

루트 버전을 올리는 방법은 네 가지가 있고, 버전이 오르는 시점과 잠금 방식이 달라요. 아래 시점도 같은 실험에서 SQL 순서로 확인했어요.

방법루트 버전이 오르는 때잠금맞는 경우
em.find(Ord.class, id, OPTIMISTIC_FORCE_INCREMENT)플러시 뒤, 커밋 직전없음자식만 고치는 명령의 기본 선택
조회 뒤 em.lock(root, OPTIMISTIC_FORCE_INCREMENT)플러시 뒤, 커밋 직전없음루트를 다른 쿼리로 이미 읽었을 때, 쓰기가 확정된 뒤에만 걸고 싶을 때
PESSIMISTIC_FORCE_INCREMENT조회 직후 즉시루트 행 쓰기 잠금충돌이 잦아 실패·재시도보다 대기가 나은 루트
루트에 lastChangedAt 같은 필드를 두고 매번 갱신플러시 때없음잠금 모드를 못 쓰는 경로(직렬화된 엔티티 병합 등)

Hibernate 사용자 가이드는 OPTIMISTIC_FORCE_INCREMENT를 "엔티티가 바뀌지 않았어도 버전을 자동으로 올린다"고 설명하고, 같은 문서에서 애플리케이션이 Hibernate가 정한 버전 값을 직접 바꾸는 것은 금지라고 적어요. root.version++ 같은 코드는 쓰지 말고 위 방법 중 하나를 써요. 마지막 줄의 "필드 하나 갱신"은 값이 실제로 바뀌어야 UPDATE가 나가니, 같은 밀리초 안의 두 명령처럼 값이 같아지는 경우를 조심해요.

스프링 데이터 JPA에서는 JPA Locking 문서대로 조회 메서드에 @Lock을 붙여요. 기본 findById를 다시 선언해 잠그면 읽기 전용 화면까지 버전이 오르니, 쓰기용 조회 메서드를 따로 두는 편이 안전해요.

interface OrdRepository extends Repository<Ord, Long> {
    Optional<Ord> findById(Long id);                       // 조회 화면용, 잠금 없음

    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    @Query("select o from Ord o where o.id = :id")
    Optional<Ord> findForChildChange(@Param("id") Long id); // 자식만 고치는 명령용
}

한 경로라도 빠지면 깨져요 — 섞인 경로 실험

강제 증가는 양쪽이 모두 걸어야 의미가 있어요. 한도 100, 줄 40+40인 주문을 두 트랜잭션이 동시에 읽고 각자 다른 줄을 55로 올린 뒤, 앨리스가 먼저 커밋하는 실험을 잠금 조합별로 돌렸어요.

앨리스와 밥의 잠금 모드 조합 네 가지 중 둘 다 강제 증가를 건 칸만 밥이 롤백되고 합계 95를 지키며, 나머지는 모두 합계 110이 되는 표
초록 칸 하나만 안전해요

결과는 네 칸 중 세 칸이 한도 초과였어요.

명세의 비관적 잠금 범위 설명도 엔티티가 외래키를 갖지 않는 관계(단방향 일대다 등)는 기본으로 잠그지 않는다고 적어요. 비관적 잠금도 모든 경로가 같은 루트 잠금을 잡을 때만 지켜져요.

그래서 점검 단위는 "엔티티"가 아니라 "불변식에 닿는 명령 목록"이에요. 줄 금액 변경, 줄 추가, 줄 삭제, 관리자 화면 일괄 수정, 배치처럼 그 규칙에 영향을 주는 진입점을 모두 적고, 각각이 루트 버전을 올리고 검사하는지 표시해 보세요. 표에 빈칸이 하나라도 있으면 그 경로로 불변식이 새요.

숫자로 보는 비용 — 추가 UPDATE와 버전 소모

강제 증가의 직접 비용은 루트 UPDATE 한 문장이에요. 실측한 쓰기 SQL 수를 명령 종류별로 놓으면 이래요.

명령 하나가 DB에 보내는 쓰기 SQL 수 (실측)
기본OPTIMISTIC_FORCE_INCREMENT
01234루트 필드 변경 · 기본 1문장루트 필드 변경 · OPTIMISTIC_FORCE_INCREMENT 2문장루트 필드 변경자식 필드 변경 · 기본 1문장자식 필드 변경 · OPTIMISTIC_FORCE_INCREMENT 2문장자식 필드 변경단방향 줄 추가 · 기본 3문장단방향 줄 추가 · OPTIMISTIC_FORCE_INCREMENT 4문장단방향 줄 추가mappedBy 줄 추가 · 기본 1문장mappedBy 줄 추가 · OPTIMISTIC_FORCE_INCREMENT 2문장mappedBy 줄 추가

루트 필드나 단방향 줄을 바꾸는 명령에 강제 증가를 걸면 버전 UPDATE가 한 번 더 나가 버전이 2씩 올라요.

단위: 문장 · 자료: Hibernate ORM 7.4.13.Final · H2 2.4.240, StatementInspector로 직접 집계
표로 보기
구분기본OPTIMISTIC_FORCE_INCREMENT
루트 필드 변경12
자식 필드 변경12
단방향 줄 추가34
mappedBy 줄 추가12

눈여겨볼 것은 왼쪽과 세 번째 막대예요. 원래 루트 버전이 오르는 명령에 강제 증가를 덧붙이면 보호는 그대로인데 UPDATE만 하나 늘고 버전이 0 → 2로 두 칸 올라요. 꼭 필요한 곳은 가운데 두 종류(자식 필드, mappedBy 컬렉션)뿐이에요.

예시로 초당 쓰기 명령 500건 가운데 60%가 자식 필드만 바꾸고(300건), 30%가 루트 필드만 바꾸고(150건), 10%가 단방향 컬렉션에 줄을 추가한다고(50건) 가정해요.

초당 쓰기 명령 500건일 때 DB 쓰기 SQL 수 (예시 계산)
기본(보호 안 됨)기본(보호 안 됨) · 600문장/초600문장/초자식만 고치는 명령에 강제 증가자식만 고치는 명령에 강제 증가 · 900문장/초900문장/초모든 쓰기에 강제 증가모든 쓰기에 강제 증가 · 1,100문장/초1,100문장/초

두 번째와 세 번째의 보호 수준은 같아요. 차이 200문장은 순수한 낭비예요.

단위: 문장/초 · 자료: 위 실측 문장 수 × 가정한 명령 비율(자식 60% · 루트 30% · 줄 추가 10%)로 직접 계산
표로 보기
구분쓰기 SQL
기본(보호 안 됨)600
자식만 고치는 명령에 강제 증가900
모든 쓰기에 강제 증가1,100

루트 필드 변경과 단방향 줄 추가는 원래 루트 버전을 올리고 검사하니까요. 그래서 기준은 "모든 쓰기에 일괄"이 아니라 "루트 행을 건드리지 않는 쓰기에만"이에요.

버전 소모 속도도 같이 봐요. 루트 필드 변경에 강제 증가까지 걸면 명령마다 2씩 올라 int 버전의 수명이 절반이 돼요. 버전 타입을 long으로 두면 이 걱정은 사실상 사라져요. 버전 타입 고르는 기준과 경계 크기별 충돌률 계산은 DDD 애그리거트 경계 정하는 법에 따로 정리해 뒀어요.

벌크 수정·배치·네이티브 SQL 경로

엔티티를 거치지 않는 수정은 버전을 아예 모르고 지나가요. Jakarta Persistence 3.2 명세는 벌크 업데이트가 "낙관적 잠금 검사를 우회해 DB 업데이트로 바로 이어진다"며, 이식성 있는 애플리케이션은 버전 컬럼을 직접 올리거나 검증해야 한다고 적어요. 실측에서도 update Ord o set o.memo = 'z'는 버전을 0에 두었어요.

이게 문제가 되는 장면은 이래요. 사용자가 수정 화면을 연 뒤, 야간 배치가 같은 주문의 상태를 벌크로 바꿔요. 버전이 그대로이니 사용자의 저장은 배치 결과를 덮어쓰고도 성공해요.

해결은 둘 중 하나예요.

벌크 수정은 영속성 컨텍스트와 동기화되지 않는다는 점도 명세에 함께 적혀 있어요. 벌크 수정과 엔티티 수정을 한 트랜잭션에 섞을 때의 플러시 순서 문제는 JPA 더티 체킹 비용과 플러시 모드 고르는 법에서 다뤘어요.

광고
광고

운영에서 자주 나는 문제와 확인 순서

1. 합계·개수 규칙이 가끔 깨져 있어요. SQL 로그(또는 StatementInspector)에서 그 명령이 루트 테이블에 version을 갱신하는 UPDATE를 보내는지 먼저 봐요. 없으면 위 실측표에서 해당 줄을 찾아요. 자식 필드나 mappedBy 컬렉션이면 쓰기용 조회를 강제 증가로 바꾸고, 같은 규칙에 닿는 다른 진입점도 모두 바꿔요.

2. 조회만 했는데 다른 사람 저장이 실패해요. 읽기 경로가 강제 증가 조회를 쓰고 있을 가능성이 커요. 실측에서 아무것도 바꾸지 않고 OPTIMISTIC_FORCE_INCREMENT로 읽기만 해도 루트 UPDATE가 한 번 나가고 버전이 올랐어요. @OptimisticLock(excluded = true)로 뺀 조회수 필드도, 강제 증가로 읽은 뒤 바꾸면 버전이 올라요. 조회 메서드와 쓰기 메서드를 나눠요.

3. 버전이 명령마다 2씩 올라요. 루트 필드를 바꾸는 명령에도 강제 증가를 걸었을 때예요. 보호는 같고 UPDATE만 늘었으니, 그 명령은 기본 조회로 되돌려요.

4. 비관적 락을 걸었는데도 규칙이 깨져요. 잠금 없이 같은 루트를 읽어 자식을 고치는 다른 경로가 있는지 찾아요. 섞인 경로 실험처럼 한쪽만 PESSIMISTIC_WRITE면 다른 쪽은 기다리지 않아요.

5. 배치가 돈 뒤 화면 저장이 배치 결과를 덮어써요. 배치의 수정 쿼리에 versioned나 version = version + 1이 있는지 봐요.

6. 충돌 예외가 갑자기 늘었어요. 강제 증가를 새로 건 직후라면 정상일 수 있어요. 그동안 조용히 덮어쓰던 동시 수정이 이제 드러난 것이에요. 예외가 난 루트 ID를 기록해 같은 ID에 몰리는지 보고, 몰린다면 경계를 다시 나눌 신호예요. 실패한 요청을 다시 시도할 때는 새 트랜잭션에서 다시 읽어야 해요.

재발을 막으려면 테스트로 고정해 두는 게 가장 확실해요. 명령을 실행한 뒤 루트 버전 UPDATE가 나갔는지 확인하는 간단한 방법이에요.

// 테스트용 StatementInspector가 모은 SQL에서 루트 버전 갱신을 찾는다
long rootBumps = SqlCapture.statements().stream()
        .filter(sql -> sql.startsWith("update ord set") && sql.contains("version=?"))
        .count();
assertThat(rootBumps).as("줄 금액 변경은 루트 버전을 올려야 함").isEqualTo(1);

같은 테스트를 불변식에 닿는 명령마다 하나씩 두면, 누군가 조회 메서드를 findById로 바꿨을 때 바로 실패해요. 동시 실행 실패 여부는 두 EntityManager로 섞인 경로 실험처럼 따로 확인해요.

도입 전에 따질 것

광고
광고

자주 묻는 질문

자식 엔티티에 @Version을 붙이면 루트 강제 증가가 필요 없나요?

규칙이 그 자식 하나 안에서 끝날 때만 그래요. 서로 다른 두 줄을 고치는 두 트랜잭션은 각자 다른 행의 버전을 검사하니 둘 다 통과해요. "줄 합계 ≤ 한도"처럼 여러 줄을 함께 보는 규칙은 공통 지점인 루트 버전이 있어야 잡혀요.

mappedBy 컬렉션에 줄을 추가해도 부모 버전이 안 오르는 건 버그인가요?

아니에요. Jakarta Persistence 명세는 버전 관리 대상 상태에 그 엔티티가 소유한 관계만 넣고, 소유하지 않은 관계(양방향의 mappedBy 쪽)는 뺀다고 정해 두었어요. 그래서 부모 버전에 의존하는 규칙이라면 줄 추가 명령도 강제 증가로 루트를 읽어야 해요.

OPTIMISTIC_FORCE_INCREMENT와 PESSIMISTIC_FORCE_INCREMENT는 어떻게 고르나요?

실측에서 낙관적 쪽은 커밋 직전에 루트 버전을 올렸고, 비관적 쪽은 조회 직후 바로 루트 버전 UPDATE를 보내 행을 잠갔어요. 충돌이 드물면 낙관적 쪽이 대기가 없어 유리해요. 같은 루트에 쓰기가 몰려 실패와 재시도가 반복된다면 비관적 쪽으로 바꿔 순서대로 기다리게 할 수 있지만, 잠금 대기 시간과 교착 가능성을 함께 봐야 해요.

모든 조회 메서드에 강제 증가를 걸면 안전하지 않나요?

보호는 되지만 비용과 부작용이 커요. 읽기만 해도 루트 UPDATE가 나가 버전이 오르니 조회 화면을 연 것만으로 다른 사람의 저장이 실패할 수 있어요. 예시 계산에서 모든 쓰기에만 걸어도 자식 명령에만 걸 때보다 쓰기 SQL이 초당 200문장 많았는데, 읽기까지 걸면 그 차이가 읽기 수만큼 더 커져요.

강제 증가를 걸었는지 코드 리뷰에서 어떻게 확인하나요?

쓰기 명령 목록과 각 명령이 쓰는 조회 메서드를 표로 맞춰 보는 게 가장 빨라요. 쓰기용 조회 메서드 이름을 findForChildChange처럼 용도로 지어 두면, 자식만 바꾸는 서비스 메서드가 일반 findById를 부를 때 바로 눈에 띄어요. 위의 SQL 확인 테스트를 같이 두면 리뷰에서 놓친 것도 잡혀요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 DDD 애그리거트와 트랜잭션 경계 원리 — 낙관적 락과 루트 버전에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#OPTIMISTIC_FORCE_INCREMENT#JPA 루트 버전#낙관적 락 누락#애그리거트 불변식#mappedBy 버전 증가#update versioned#Spring Data JPA Lock#Hibernate 버전 관리
← 이전 글Mockito doReturn vs when 고르는 법, spy·매처·strict 스텁 점검표다음 글 →MCP 무상태 스펙 전환, 접속 모드·ttlMs·MRTR 설정 고르는 법
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →