에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/데이터 & DB

[JPA] 낙관적 락 vs 비관적 락: @Version, SELECT FOR UPDATE, 동시성 충돌 해결

반응형
[JPA] 낙관적 락 vs 비관적 락: @Version, SELECT FOR UPDATE, 동시성 충돌 해결

[JPA] 낙관적 락 vs 비관적 락: @Version, SELECT FOR UPDATE, 동시성 충돌 해결

두 요청이 같은 데이터를 동시에 수정하면, 트랜잭션을 썼는데도 한쪽의 변경이 사라질 수 있어요. 갱신 분실(lost update)이에요. 포인트 적립 두 건이 동시에 들어왔는데 한 건만 반영되는 식이죠. JPA는 이걸 막는 두 가지 락을 줘요 — 낙관적 락과 비관적 락이에요.

이 글은 갱신 분실이 왜 생기는지부터, @Version 낙관적 락의 동작과 재시도, 비관적 락(SELECT FOR UPDATE), 둘의 선택 기준, 그리고 Redis 분산 락과의 관계까지 정리해요.

01. 트랜잭션만으로는 못 막는 갱신 분실

갱신 분실 lost update 타임라인. 두 트랜잭션이 같은 포인트 1000을 읽고 각자 더해 저장하면 나중에 커밋한 쪽이 먼저 것을 덮어써 한쪽의 적립이 증발한다

A와 B가 모두 포인트 1000을 읽어요. A가 +500을 저장하고 B가 +300을 저장하면 — 최종 값은 1300이에요. A의 +500은 증발했어요. 둘 다 "읽고-계산하고-쓰는" 동안 서로를 못 봤거든요.

격리수준이 보통 수준(READ COMMITTED 등)이면 이건 트랜잭션만으로 안 막혀요. 그래서 명시적인 동시성 제어가 필요해요. 막는 철학이 두 가지예요. "일단 진행하고 충돌이면 실패시키자"(낙관적)와 "아예 줄을 세우자"(비관적)예요.

02. 낙관적 락, @Version 한 줄

엔티티에 버전 필드를 추가하면 낙관적 락이 켜져요.

@Entity
public class Point {
    @Id private Long id;
    private long amount;

    @Version                 // 이 한 줄이 낙관적 락
    private long version;
}
JPA 낙관적 락 @Version 동작. 둘 다 version 1을 읽고 A가 먼저 커밋하면 version이 2가 되고, B의 UPDATE는 WHERE version=1 조건에 걸려 0건이 되어 ObjectOptimisticLockingFailureException으로 충돌이 감지된다

동작 원리가 깔끔해요. UPDATE가 나갈 때 WHERE id = ? AND version = ?읽었던 시점의 버전을 조건에 넣고 성공하면 버전을 1 올려요. 누가 먼저 수정했으면 내 UPDATE는 0건이 되고 Hibernate가 이걸 감지해 OptimisticLockException을 던져요. 스프링이 이걸 ObjectOptimisticLockingFailureException으로 감싸 주니, 우리가 코드에서 잡는 건 보통 후자예요. 갱신 분실이 조용히 일어나는 대신, 시끄럽게 실패하는 거예요.

낙관적 락의 본질은 "충돌을 막는 게 아니라 들키게 하는 것"이에요. 그래서 예외가 났을 때 뭘 할지는 우리 몫이에요. 재시도할지(다시 읽어서 다시 계산), 사용자에게 "다른 곳에서 수정됐어요"를 보여줄지 정해야 해요. 재시도를 안 짜놓으면 동시 요청이 그냥 500 에러로 떨어져요 — 락을 걸었는데 서비스는 더 불안정해 보이는 역설이 생기죠.

재시도는 트랜잭션 메서드 단위로 짜요 — 이 예외는 보통 커밋(flush) 시점에 던져져서 같은 트랜잭션 안에서 잡아봐야 의미가 없거든요. 호출하는 쪽에서 실패를 받아 새 트랜잭션으로 메서드 전체를 다시 호출하는 구조(Spring Retry의 @Retryable 등)로 2~3회 재시도해요.

03. 비관적 락, SELECT FOR UPDATE

충돌이 잦으면 매번 실패-재시도하는 것보다 아예 줄을 세우는 게 나아요. 비관적 락은 조회 시점에 DB 행 락을 잡아요.

public interface PointRepository extends JpaRepository<Point, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)   // SELECT ... FOR UPDATE
    @Query("select p from Point p where p.id = :id")
    Optional<Point> findByIdForUpdate(@Param("id") Long id);
}
JPA 비관적 락 동작과 선택 기준. SELECT FOR UPDATE로 행 락을 잡으면 다른 트랜잭션은 끝날 때까지 대기해 충돌 자체가 없다. 충돌이 드물면 낙관적, 잦으면 비관적이 기준이다

A가 FOR UPDATE로 행을 잡으면, B도 같은 잠금 조회(FOR UPDATE)로 들어올 때 A가 끝날 때까지 대기해요. 일반 SELECT는 스냅샷 읽기라 락에 안 걸리고 그냥 지나가요. 충돌이 아예 안 생기고 B는 A의 결과를 본 다음 이어서 작업해요. 갱신 분실이 구조적으로 사라져요.

대가는 대기예요. 락을 잡은 트랜잭션이 길면 뒤가 줄줄이 밀리고, 서로 다른 순서로 락을 잡으면 데드락도 생겨요(이건 MySQL 락 편에서 깊게 다뤄요). 그래서 비관적 락 트랜잭션은 짧게 유지하는 게 중요해요.

04. 무엇을 고르나

  • 충돌이 드물다 → 낙관적 락. 평소엔 락 비용이 전혀 없고 가끔의 충돌만 재시도로 흡수해요. 같은 데이터를 동시에 고칠 일이 적은 일반적인 CRUD에 잘 맞아요.
  • 충돌이 잦다 → 비관적 락. 인기 상품 재고, 선착순 쿠폰처럼 한 행에 요청이 몰리면, 낙관적 락은 재시도 폭주가 돼요. 100개 요청 중 1개만 성공하고 99개가 재시도하는 식이죠. 이럴 땐 줄 세우는 게 차라리 싸요.

판단 기준은 "같은 행을 동시에 수정할 확률"이에요. 확률이 낮으면 낙관적으로 가볍게, 높으면 비관적으로 확실하게예요.

05. Redis 분산 락과는 뭐가 다른가요

동시성 제어 수단으로 Redis 분산 락도 있었죠. 역할이 달라요.

JPA/DB 락은 "한 DB 행"의 정합성을 지켜요. 보호 대상이 DB 데이터면 DB 락이 가장 가깝고 확실해요. 트랜잭션과 같은 생명주기라 커밋·롤백과 정확히 맞물리고요. 반면 분산 락은 "DB 밖까지 걸친 임계 구역"(외부 API 호출 + DB 쓰기를 한 묶음으로) 같은, 행 단위 락으로 표현 안 되는 구간에 써요.

"재고 차감 같은 단일 행 갱신에 분산 락부터 꺼내는" 경우가 많은데, 그건 DB 락(또는 원자적 UPDATE 쿼리)으로 더 단순하게 풀려요. 보호 대상이 어디 있느냐로 도구를 고르면 돼요.

06. 자주 만나는 문제

동시 수정에서 한쪽 변경이 사라져요

갱신 분실이에요. @Version을 추가하면 사라지는 대신 예외로 드러나요. 그 예외의 재시도·안내 처리까지 설계해요.

ObjectOptimisticLockingFailureException이 자주 터져요

충돌이 잦은 데이터에 낙관적 락을 쓴 거예요. 그 행만 비관적 락으로 바꾸거나, UPDATE point SET amount = amount + ? WHERE id = ? 같은 원자적 갱신 쿼리로 read-modify-write 자체를 없애는 것도 방법이에요.

비관적 락을 걸었더니 응답이 줄줄이 밀려요

락 잡은 트랜잭션이 긴 거예요. 임계 구역을 최소화하고 외부 API 호출 같은 느린 작업을 락 밖으로 빼요.

배치와 실시간 요청이 데드락 나요

서로 다른 순서로 행 락을 잡는 거예요. 락을 잡는 순서를 통일해요(예: ID 오름차순). 자세한 데드락 진단은 MySQL 편에서 다뤄요.

정리

동시 수정의 갱신 분실은 트랜잭션만으로는 안 막혀요. 충돌이 드물면 @Version 낙관적 락(가볍지만 재시도 설계가 필수)이고 잦으면 비관적 락(확실하지만 대기·데드락을 관리해야)이에요. 그리고 단일 행 정합성은 DB 락으로, DB 밖까지 걸친 임계 구역은 분산 락으로 — 보호 대상이 어디 있느냐로 도구를 골라요.

그래서 락을 고르기 전에 스스로 물어볼 한 줄은 이거예요. "같은 행을 동시에 고칠 확률이 얼마이고 내가 지켜야 할 것은 DB 안에 있나 밖에 있나?" DB 레벨의 락(레코드락·갭락·데드락)은 이 글이 선 영속성 컨텍스트 아래층, 곧 MySQL 클러스터에서 더 깊이 들어가요. 중복 요청을 흡수하는 또 다른 길은 멱등성 글과 이어져요.

바로 다음 편은 결이 다른 함정이에요. 트랜잭션이 끝났는데 영속성 컨텍스트와 커넥션은 언제까지 살아 있어야 하는가, OSIV를 켤지 끌지부터 따져요.

출처: Hibernate User Guide — Locking · Spring Data JPA — Locking

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."