루트에 @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 1 | 0 → 1 | 그대로 둬요 |
임베디드 값(addr.city) | 루트 UPDATE 1 | 0 → 1 | 그대로 둬요 |
| 요소 컬렉션에 태그 추가 | 루트 버전 UPDATE 1 + INSERT 1 | 0 → 1 | 그대로 둬요 |
단방향 @OneToMany에 줄 추가 | INSERT 1 + 루트 버전 UPDATE 1 + 외래키 UPDATE 1 | 0 → 1 | 그대로 둬요 |
단방향 @OneToMany에서 줄 삭제 | 루트 버전 UPDATE 1 + 외래키 UPDATE 1 + DELETE 1 | 0 → 1 | 그대로 둬요 |
자식 엔티티 필드(line.amount) | 자식 UPDATE 1 | 0 → 0 | 강제 증가 필요 |
mappedBy 컬렉션에 줄 추가 | 자식 INSERT 1 | 0 → 0 | 강제 증가 필요 |
mappedBy 컬렉션에서 줄 삭제 | 자식 DELETE 1 | 0 → 0 | 강제 증가 필요 |
제외 필드(viewCount++) | 루트 UPDATE 1(버전 검사만) | 0 → 0 | 의도대로예요 |
JPQL update Ord set … | 벌크 UPDATE 1 | 0 → 0 | update 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로 올린 뒤, 앨리스가 먼저 커밋하는 실험을 잠금 조합별로 돌렸어요.
결과는 네 칸 중 세 칸이 한도 초과였어요.
- 둘 다 기본 조회: 둘 다 커밋, 합계 110. 루트 버전은 0 그대로예요.
- 앨리스만 강제 증가: 앨리스가 버전을 1로 올렸지만 밥은 루트 버전을 검사하지 않아 커밋돼요. 합계 110이에요.
- 밥만 강제 증가: 앨리스가 버전을 올리지 않았으니 밥의
where version = 0이 그대로 맞아요. 합계 110이에요. - 둘 다 강제 증가: 밥의 루트 UPDATE가 0행을 갱신해
OptimisticLockException으로 롤백돼요. 합계 95예요. - 앨리스가
PESSIMISTIC_WRITE, 밥은 기본 조회: 앨리스가 루트 행을 잠가도 밥은 루트를 잠그지도, 버전을 보지도 않아서 합계 110이에요.
명세의 비관적 잠금 범위 설명도 엔티티가 외래키를 갖지 않는 관계(단방향 일대다 등)는 기본으로 잠그지 않는다고 적어요. 비관적 잠금도 모든 경로가 같은 루트 잠금을 잡을 때만 지켜져요.
그래서 점검 단위는 "엔티티"가 아니라 "불변식에 닿는 명령 목록"이에요. 줄 금액 변경, 줄 추가, 줄 삭제, 관리자 화면 일괄 수정, 배치처럼 그 규칙에 영향을 주는 진입점을 모두 적고, 각각이 루트 버전을 올리고 검사하는지 표시해 보세요. 표에 빈칸이 하나라도 있으면 그 경로로 불변식이 새요.
숫자로 보는 비용 — 추가 UPDATE와 버전 소모
강제 증가의 직접 비용은 루트 UPDATE 한 문장이에요. 실측한 쓰기 SQL 수를 명령 종류별로 놓으면 이래요.
눈여겨볼 것은 왼쪽과 세 번째 막대예요. 원래 루트 버전이 오르는 명령에 강제 증가를 덧붙이면 보호는 그대로인데 UPDATE만 하나 늘고 버전이 0 → 2로 두 칸 올라요. 꼭 필요한 곳은 가운데 두 종류(자식 필드, mappedBy 컬렉션)뿐이에요.
예시로 초당 쓰기 명령 500건 가운데 60%가 자식 필드만 바꾸고(300건), 30%가 루트 필드만 바꾸고(150건), 10%가 단방향 컬렉션에 줄을 추가한다고(50건) 가정해요.
- 기본: 300×1 + 150×1 + 50×3 = 초당 600문장
- 자식만 고치는 명령에만 강제 증가: 300×2 + 150×1 + 50×3 = 초당 900문장(1.5배)
- 모든 쓰기 명령에 강제 증가: 300×2 + 150×2 + 50×4 = 초당 1,100문장(약 1.83배)
루트 필드 변경과 단방향 줄 추가는 원래 루트 버전을 올리고 검사하니까요. 그래서 기준은 "모든 쓰기에 일괄"이 아니라 "루트 행을 건드리지 않는 쓰기에만"이에요.
버전 소모 속도도 같이 봐요. 루트 필드 변경에 강제 증가까지 걸면 명령마다 2씩 올라 int 버전의 수명이 절반이 돼요. 버전 타입을 long으로 두면 이 걱정은 사실상 사라져요. 버전 타입 고르는 기준과 경계 크기별 충돌률 계산은 DDD 애그리거트 경계 정하는 법에 따로 정리해 뒀어요.
벌크 수정·배치·네이티브 SQL 경로
엔티티를 거치지 않는 수정은 버전을 아예 모르고 지나가요. Jakarta Persistence 3.2 명세는 벌크 업데이트가 "낙관적 잠금 검사를 우회해 DB 업데이트로 바로 이어진다"며, 이식성 있는 애플리케이션은 버전 컬럼을 직접 올리거나 검증해야 한다고 적어요. 실측에서도 update Ord o set o.memo = 'z'는 버전을 0에 두었어요.
이게 문제가 되는 장면은 이래요. 사용자가 수정 화면을 연 뒤, 야간 배치가 같은 주문의 상태를 벌크로 바꿔요. 버전이 그대로이니 사용자의 저장은 배치 결과를 덮어쓰고도 성공해요.
해결은 둘 중 하나예요.
- HQL
update versioned: Hibernate 쿼리 언어 가이드는 update 문이 기본으로@Version컬럼을 건드리지 않고,versioned를 붙이면 버전을 올린다고 설명해요. 실측 SQL은set memo='z', version=(o1_0.version+1)이었어요. - 네이티브 SQL:
SET version = version + 1을 직접 넣어요. 자식 테이블만 바꾸는 배치라면 같은 트랜잭션에서 해당 루트들의 버전도 올려요.
벌크 수정은 영속성 컨텍스트와 동기화되지 않는다는 점도 명세에 함께 적혀 있어요. 벌크 수정과 엔티티 수정을 한 트랜잭션에 섞을 때의 플러시 순서 문제는 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을 두는 편이 충돌 범위가 좁아요. - 같은 루트에 쓰기가 얼마나 몰리는가: 강제 증가는 자식끼리의 동시 수정을 충돌로 바꿔요. 그동안 다른 줄을 동시에 고치던 사용자들이 이제 서로를 실패시켜요. 한 루트에 동시 편집자가 많다면 재시도 정책과 화면 안내(다시 불러오기)를 함께 준비해요.
- 매핑 방향을 바꿀 수 있는가: 단방향
@OneToMany+@JoinColumn이면 줄 추가·삭제에서 버전이 저절로 오르지만, 실측처럼 외래키 UPDATE가 따로 나가 문장 수가 늘어요. - 엔티티를 거치지 않는 수정이 있는가: 배치, 관리자 SQL, 다른 서비스의 직접 DB 접근이 있다면 그 경로도 버전 규칙을 지키게 바꿔야 해요. 바꿀 수 없다면 그 테이블은 낙관적 락만으로 보호된다고 보면 안 돼요.
- 커밋 뒤 후속 처리와 엮이는가: 강제 증가로 롤백되는 트랜잭션에서 이벤트를 먼저 보내면 안 되니, 후속 처리는 커밋 뒤 단계로 넘겨요. 리스너 단계 고르는 법은 스프링 도메인 이벤트 설정 고르기에 있어요.
자주 묻는 질문
자식 엔티티에 @Version을 붙이면 루트 강제 증가가 필요 없나요?
규칙이 그 자식 하나 안에서 끝날 때만 그래요. 서로 다른 두 줄을 고치는 두 트랜잭션은 각자 다른 행의 버전을 검사하니 둘 다 통과해요. "줄 합계 ≤ 한도"처럼 여러 줄을 함께 보는 규칙은 공통 지점인 루트 버전이 있어야 잡혀요.
mappedBy 컬렉션에 줄을 추가해도 부모 버전이 안 오르는 건 버그인가요?
아니에요. Jakarta Persistence 명세는 버전 관리 대상 상태에 그 엔티티가 소유한 관계만 넣고, 소유하지 않은 관계(양방향의 mappedBy 쪽)는 뺀다고 정해 두었어요. 그래서 부모 버전에 의존하는 규칙이라면 줄 추가 명령도 강제 증가로 루트를 읽어야 해요.
OPTIMISTIC_FORCE_INCREMENT와 PESSIMISTIC_FORCE_INCREMENT는 어떻게 고르나요?
실측에서 낙관적 쪽은 커밋 직전에 루트 버전을 올렸고, 비관적 쪽은 조회 직후 바로 루트 버전 UPDATE를 보내 행을 잠갔어요. 충돌이 드물면 낙관적 쪽이 대기가 없어 유리해요. 같은 루트에 쓰기가 몰려 실패와 재시도가 반복된다면 비관적 쪽으로 바꿔 순서대로 기다리게 할 수 있지만, 잠금 대기 시간과 교착 가능성을 함께 봐야 해요.
모든 조회 메서드에 강제 증가를 걸면 안전하지 않나요?
보호는 되지만 비용과 부작용이 커요. 읽기만 해도 루트 UPDATE가 나가 버전이 오르니 조회 화면을 연 것만으로 다른 사람의 저장이 실패할 수 있어요. 예시 계산에서 모든 쓰기에만 걸어도 자식 명령에만 걸 때보다 쓰기 SQL이 초당 200문장 많았는데, 읽기까지 걸면 그 차이가 읽기 수만큼 더 커져요.
강제 증가를 걸었는지 코드 리뷰에서 어떻게 확인하나요?
쓰기 명령 목록과 각 명령이 쓰는 조회 메서드를 표로 맞춰 보는 게 가장 빨라요. 쓰기용 조회 메서드 이름을 findForChildChange처럼 용도로 지어 두면, 자식만 바꾸는 서비스 메서드가 일반 findById를 부를 때 바로 눈에 띄어요. 위의 SQL 확인 테스트를 같이 두면 리뷰에서 놓친 것도 잡혀요.
정리
- 루트 버전은 루트 행이 갱신될 때만 올라요. 자식 엔티티 필드,
mappedBy컬렉션 추가·삭제, JPQL 벌크 수정은 실측에서 버전을 0에 두었어요. - 강제 증가는 루트 행을 건드리지 않는 쓰기 명령에만, 쓰기용 조회 메서드를 따로 만들어 걸어요. 루트 필드 변경에 덧붙이면 UPDATE가 하나 늘고 버전이 2씩 올라요.
- 불변식에 닿는 진입점은 하나도 빠짐없이 같은 규칙을 따라야 해요. 한쪽만 강제 증가나 비관적 락을 걸면 실측에서 한도 100이 110이 됐어요.
- 배치와 벌크 수정은
update versioned나version = version + 1로 버전 규칙에 넣고, 명령마다 루트 버전 UPDATE를 확인하는 테스트로 고정해요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 DDD 애그리거트와 트랜잭션 경계 원리 — 낙관적 락과 루트 버전에서 도식과 코드로 단계별로 풀었어요.