서비스가 잘 굴러갈 때는 평화롭습니다. 문제는 트래픽이 몰리는 순간이죠. 똑같은 데이터에 여러 요청이 동시에 달려들기 시작하면, 분명히 처리했다고 생각한 업데이트가 흔적도 없이 사라지는 무서운 일이 벌어집니다. "분명 저장했는데요?" 하는 CS가 들어오기 시작하면 그때부터 진짜 디버깅 지옥이 열립니다.
이 글에서는 JPA에서 이런 동시성 문제를 막아주는 두 가지 무기, 낙관적 잠금(Optimistic Lock)과 비관적 잠금(Pessimistic Lock)을 정리해 봅니다. 개념부터 LockModeType 종류, 예외 처리, 그리고 "그래서 실무에서 뭘 써야 하냐"까지 한 번에 훑어보겠습니다.
도대체 왜 잠금이 필요할까: 갱신 손실(Lost Update) 문제
먼저 적이 누군지부터 알아야겠죠. 가장 흔한 동시성 사고는 "갱신 손실(Lost Update)"입니다. 시나리오는 이렇습니다.
상품 재고가 1개 남았습니다. 그런데 거의 동시에 두 명의 손님 A, B가 주문 버튼을 눌렀습니다.
| 순서 | 트랜잭션 A | 트랜잭션 B | 실제 재고 |
| 1 | 재고 조회 (1개 확인) | 1 | |
| 2 | 재고 조회 (1개 확인) | 1 | |
| 3 | 재고 = 1 - 1 = 0 으로 저장 | 0 | |
| 4 | 재고 = 1 - 1 = 0 으로 저장 | 0 |
분명히 두 건이 팔렸는데 재고는 -1이 아니라 0입니다. 즉 한 건의 차감이 통째로 증발한 거죠. 둘 다 "재고 1개"라는 옛날 정보를 보고 계산했기 때문입니다. 이게 바로 잠금이 필요한 이유입니다. 동시 읽기와 쓰기 사이에서 데이터의 일관성을 지켜줘야 합니다.
JPA는 이 문제를 해결하기 위해 두 가지 접근법을 제공합니다. 하나는 "설마 충돌나겠어?"라고 믿는 낙관주의자, 다른 하나는 "무조건 충돌난다"고 의심하는 비관주의자입니다.
낙관적 잠금(Optimistic Lock)
낙관적 잠금은 이름 그대로 "충돌은 거의 안 일어날 거야"라고 낙관하는 전략입니다. 그래서 처음부터 DB에 락을 거는 게 아니라, 막판에 "혹시 내가 읽은 사이에 누가 건드렸나?"만 확인합니다. 그래서 엄밀히 말하면 락(Lock)이라기보다는 충돌 감지(Conflict Detection)에 가깝습니다.
동작 원리: @Version 한 줄이면 끝
핵심은 엔티티에 버전(version) 컬럼을 두는 것입니다. JPA는 엔티티를 조회할 때 버전 값을 기억해 뒀다가, 업데이트하기 직전에 DB의 버전과 다시 비교합니다.
- 버전이 그대로다 → 아무도 안 건드렸음 → 정상 업데이트하면서 버전 +1
- 버전이 바뀌었다 → 그사이 누가 먼저 수정함 → OptimisticLockException 발생 후 롤백
구현은 허무할 정도로 간단합니다. 엔티티에 @Version 어노테이션을 붙인 필드 하나만 선언하면 됩니다.
@Entity
public class Student {
@Id
private Long id;
private String name;
private String lastName;
@Version
private Integer version;
}
이 상태에서 동시에 같은 행을 수정하려 하면, 늦게 도착한 트랜잭션은 버전 불일치로 예외를 맞고 튕겨 나갑니다. 앞서 본 재고 차감 사고가 자연스럽게 방어됩니다.
@Version 사용 시 주의사항
@Version을 붙일 때는 몇 가지 규칙이 있습니다.
| 규칙 | 설명 |
| 엔티티당 하나만 | 버전 속성은 엔티티 클래스마다 단 하나만 존재해야 합니다. |
| 기본 테이블에 위치 | 여러 테이블에 매핑된 엔티티라면 버전 필드는 기본(primary) 테이블에 두어야 합니다. |
| 허용 타입 | int, Integer, short, Short, long, Long, java.sql.Timestamp 중 하나여야 합니다. |
만약 지원하지 않는 타입(예: String)을 버전으로 쓰면 하이버네이트가 다음과 비슷한 캐스팅 오류를 뱉습니다.
class org.hibernate.type.StringType cannot be cast to class org.hibernate.type.VersionType
정말 특수한 타입으로 버저닝을 하고 싶다면 org.hibernate.type.VersionType을 상속받아 초기값(seed), 증가 로직(next), 비교 함수(getComparator)를 직접 구현하면 커스텀 버저닝도 가능합니다. 다만 99%의 경우 Long이면 충분하니 굳이 멋부릴 필요는 없습니다.
낙관적 잠금의 LockModeType
낙관적 잠금에도 모드가 여러 개 있습니다.
- NONE: 별도 옵션을 주지 않아도 @Version 필드만 있으면 수정 시점에 자동으로 낙관적 잠금이 적용됩니다. (뒤에서 다룰 암시적 잠금입니다.)
- OPTIMISTIC: 원래는 수정할 때만 버전을 검사하는데, 이 옵션을 주면 단순 읽기 시점에도 버전을 검사합니다. 트랜잭션이 끝날 때까지 그 행을 다른 트랜잭션이 바꾸지 않았음을 보장하므로 dirty read와 non-repeatable read를 방지할 수 있습니다.
- OPTIMISTIC_FORCE_INCREMENT: 엔티티의 실제 데이터 변경이 없어도 버전을 강제로 증가시킵니다. 연관된 데이터의 변경을 "이 엔티티가 바뀐 것으로 친다"라고 표시하고 싶을 때 유용합니다.
- READ / WRITE: 각각 OPTIMISTIC, OPTIMISTIC_FORCE_INCREMENT와 동일합니다. JPA 1.0 시절 이름이 남아 있는 레거시 호환용 옵션이라고 보면 됩니다.
코드로 보는 사용법
EntityManager.find()에 락 모드를 넘기는 방법:
entityManager.find(Student.class, studentId, LockModeType.OPTIMISTIC);
Query 객체의 setLockMode()를 쓰는 방법:
Query query = entityManager.createQuery("select s from Student s where s.id = :id");
query.setParameter("id", studentId);
query.setLockMode(LockModeType.OPTIMISTIC_FORCE_INCREMENT);
query.getResultList();
@NamedQuery에 미리 박아두는 방법:
@NamedQuery(
name = "optimisticLock",
query = "select s from Student s where s.id = :id",
lockMode = LockModeType.WRITE
)
이미 조회한 엔티티에 나중에 락을 거는 방법:
Student student = entityManager.find(Student.class, id);
entityManager.lock(student, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
OptimisticLockException, 어떻게 처리할까
낙관적 잠금에서 충돌이 감지되면 영속성 프로바이더(하이버네이트 등)가 OptimisticLockException을 던지고 해당 트랜잭션은 롤백됩니다.
여기서 중요한 건 "예외가 났다 = 장애다"가 아니라는 점입니다. 낙관적 잠금에서 충돌은 "정상적으로 발생할 수 있는 일"입니다. 그래서 권장되는 처리 방식은 엔티티를 다시 조회(refresh)해서 최신 상태로 만든 뒤 업데이트를 재시도(retry)하는 것입니다. 다행히 이 예외 객체는 충돌이 일어난 엔티티 정보를 함께 담아주기 때문에 재시도 로직을 짜기 어렵지 않습니다.
int maxRetry = 3;
for (int attempt = 0; attempt < maxRetry; attempt++) {
try {
// 조회 -> 비즈니스 로직 -> 저장
return updateStudentScore(studentId, newScore);
} catch (OptimisticLockException e) {
if (attempt == maxRetry - 1) {
throw e; // 마지막 시도까지 실패하면 그대로 던진다
}
// 잠깐 쉬고 다시 시도
}
}
비관적 잠금(Pessimistic Lock)
비관적 잠금은 정반대 성격입니다. "어차피 충돌 날 거 뻔하니까, 일단 락부터 걸고 시작하자"는 의심 많은 전략입니다. 충돌 감지에 의존하는 낙관적 잠금과 달리, 비관적 잠금은 데이터베이스 수준에서 실제로 행에 락을 겁니다. 내부적으로는 SELECT ... FOR UPDATE 같은 쿼리가 나간다고 생각하면 이해가 빠릅니다.
전제 조건이 하나 있습니다. 비관적 잠금은 반드시 트랜잭션 안에서 비즈니스 로직이 진행되어야 합니다. 락을 잡고 작업하다가 커밋 또는 롤백 시점에 락이 풀리기 때문입니다. 락 획득에 실패하면 PersistenceException 계열 예외가 발생합니다.
비관적 잠금의 LockModeType
JPA 명세는 세 가지 비관적 잠금 모드를 정의하고, 모두 트랜잭션이 커밋되거나 롤백될 때까지 유지됩니다.
- PESSIMISTIC_READ: 공유 잠금(Shared Lock)을 획득합니다. 다른 트랜잭션이 해당 데이터를 UPDATE하거나 DELETE하는 것을 막아줍니다. 참고로 공유 잠금을 지원하지 않는 DB에서는 자동으로 PESSIMISTIC_WRITE로 대체됩니다.
- PESSIMISTIC_WRITE: 배타적 잠금(Exclusive Lock)을 획득합니다. 다른 트랜잭션의 READ, UPDATE, DELETE를 모두 막는 가장 강력한 잠금입니다. 재고 차감처럼 "내가 작업하는 동안 아무도 못 건드리게" 해야 하는 상황의 단골입니다.
- PESSIMISTIC_FORCE_INCREMENT: PESSIMISTIC_WRITE처럼 동작하면서, @Version이 붙은 엔티티와 협력해 잠금 획득 시 버전까지 올려줍니다.
비관적 잠금 관련 예외
| 예외 | 발생 시점 |
| PessimisticLockException | 공유 잠금 또는 배타적 잠금 획득에 실패했을 때 |
| LockTimeoutException | 락을 기다리다 설정한 대기 시간(wait time)을 초과했을 때 |
| PersistenceException | 위 예외들을 포함한 영속성 계층의 포괄적 예외. 단 NoResultException, NonUniqueResultException, LockTimeoutException, QueryTimeoutException은 트랜잭션을 롤백으로 마킹하지 않습니다. |
Lock Scope: 어디까지 잠글 것인가
비관적 잠금은 락의 적용 범위(scope)도 지정할 수 있습니다.
- PessimisticLockScope.NORMAL: 기본값으로, 해당 엔티티만 잠급니다. 다만 @Inheritance(strategy = InheritanceType.JOINED) 같은 조인 상속을 쓰면 부모 테이블 행도 함께 잠깁니다.
- PessimisticLockScope.EXTENDED: @ElementCollection, @OneToOne, @OneToMany 등으로 연관된 엔티티들까지 함께 잠급니다. 범위가 넓어지는 만큼 데드락 위험도 커지니 신중하게 써야 합니다.
코드로 보는 사용법
find()에서 바로 거는 방법:
entityManager.find(Student.class, studentId, LockModeType.PESSIMISTIC_READ);
Query에서 거는 방법:
Query query = entityManager.createQuery("select s from Student s where s.id = :studentId");
query.setParameter("studentId", studentId);
query.setLockMode(LockModeType.PESSIMISTIC_WRITE);
query.getResultList();
이미 조회한 엔티티에 명시적으로 거는 방법:
Student resultStudent = entityManager.find(Student.class, studentId);
entityManager.lock(resultStudent, LockModeType.PESSIMISTIC_WRITE);
refresh()로 최신 데이터를 다시 읽으며 거는 방법:
Student resultStudent = entityManager.find(Student.class, studentId);
entityManager.refresh(resultStudent, LockModeType.PESSIMISTIC_FORCE_INCREMENT);
Spring Data JPA를 쓴다면 @Lock 한 줄
순수 JPA의 EntityManager를 직접 만지는 일은 사실 실무에서 드뭅니다. Spring Data JPA를 쓴다면 리포지토리 메서드에 @Lock 어노테이션만 붙이면 훨씬 깔끔합니다.
public interface StudentRepository extends JpaRepository<Student, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Student s where s.id = :id")
Optional<Student> findByIdForUpdate(@Param("id") Long id);
}
이렇게 해두면 findByIdForUpdate()로 조회하는 순간 해당 행에 배타적 잠금이 걸립니다. 재고 차감 로직에서 자주 보게 되는 패턴입니다.
암시적 잠금 vs 명시적 잠금
마지막으로 자주 헷갈리는 두 용어를 정리하고 갑니다.
암시적 잠금(Implicit Lock)
내가 따로 락을 걸어달라고 하지 않아도 JPA가 알아서 거는 잠금입니다. @Version 필드가 있거나 @OptimisticLocking 어노테이션이 설정되어 있으면, 별도 설정 없이도 수정 시점에 충돌 감지가 자동으로 동작합니다. 또한 삭제 쿼리가 나갈 때는 해당 행에 대해 암시적으로 행 배타 잠금(Row Exclusive Lock)이 걸립니다.
명시적 잠금(Explicit Lock)
개발자가 의도적으로 코드로 거는 잠금입니다. EntityManager로 엔티티를 조회할 때 LockMode를 지정하거나, SELECT ... FOR UPDATE 쿼리를 직접 날리는 식입니다.
Student student = entityManager.find(Student.class, id);
entityManager.lock(student, LockModeType.OPTIMISTIC);
Student resultStudent = entityManager.find(Student.class, studentId);
entityManager.lock(resultStudent, LockModeType.PESSIMISTIC_WRITE);
그래서 뭘 써야 할까: 낙관적 vs 비관적 비교
| 구분 | 낙관적 잠금 | 비관적 잠금 |
| 기본 가정 | 충돌은 드물 것이다 | 충돌은 자주 일어날 것이다 |
| 잠금 방식 | DB 락 없이 버전으로 충돌 감지 | DB 수준에서 실제 락 획득 |
| 성능 | 평소엔 빠름 (락 대기 없음) | 락 대기로 처리량 저하 가능 |
| 충돌 시 | 예외 발생 후 재시도 필요 | 락 대기로 충돌 자체를 차단 |
| 적합한 상황 | 읽기가 많고 충돌이 드문 경우 | 충돌이 잦고 정합성이 매우 중요한 경우 |
| 위험 요소 | 재시도 로직 필요, 충돌 잦으면 비효율 | 데드락, 락 타임아웃 |
정리하면 이렇게 기억하면 편합니다. 충돌이 드물고 읽기 비중이 높은 도메인(게시글 수정, 프로필 변경 등)은 낙관적 잠금이 가볍고 좋습니다. 반대로 한정 수량 쿠폰 발급, 재고 차감, 잔액 처리처럼 "조금이라도 틀어지면 큰일 나는" 영역은 비관적 잠금으로 확실하게 잠그는 편이 마음 편합니다.
물론 정답은 없습니다. 트래픽 패턴과 충돌 빈도를 보고 골라야 하고, 분산 환경(MSA)에서는 DB 락만으로 부족해 분산 락(예: Redis 기반)까지 고려해야 하는 경우도 많습니다. 하지만 그 모든 선택의 출발점은 "낙관과 비관, 이 둘의 차이를 정확히 아는 것"입니다. 오늘 정리한 내용이 그 출발점이 되었길 바랍니다.
마무리
동시성은 평소엔 조용하다가 가장 중요한 순간에 사고를 칩니다. @Version 한 줄, @Lock 한 줄이 별것 아닌 것 같아도, 트래픽이 몰리는 새벽에 여러분의 데이터를 지켜주는 든든한 안전벨트가 되어 줍니다. 다음에 동시성 이슈를 만나면 "이건 낙관이냐 비관이냐"부터 떠올려 보세요. 절반은 풀린 셈입니다.
참고 자료
- Baeldung, JPA Optimistic Locking: https://www.baeldung.com/jpa-optimistic-locking
- Baeldung, Pessimistic Locking in JPA: https://www.baeldung.com/jpa-pessimistic-locking
- reiphiel, Understanding JPA Lock: https://reiphiel.tistory.com/entry/understanding-jpa-lock
'IT' 카테고리의 다른 글
| JPA 더티 체킹의 함정: 동시성과 정합성 문제 해결기 (3) | 2026.06.26 |
|---|---|
| 2026년 6월 25일, 오늘 가장 핫한 IT 뉴스 5가지 총정리: 마이크론 어닝 서프라이즈부터 바이트댄스 AI 공습까지 (4) | 2026.06.25 |
| 재고는 1개인데 주문이 2건? 동시성 이슈를 해결하는 모든 방법 총정리 (4) | 2026.06.24 |
| 2026년 6월 24일 IT 뉴스 톱5: 코스피 '검은 화요일'부터 AI 모델 대전, 램마겟돈 시즌2까지 (7) | 2026.06.24 |
| 2026년 6월 22일 가장 핫한 IT 뉴스 5선: 스페이스X의 60조원 베팅부터 클로드 셧다운까지 (5) | 2026.06.22 |