이전 글에서는 동시성이 무엇인지, 자바에서는 어떻게 제어하고 있는지 알아보았다.
이번 글에서는 JPA를 통한 애플리케이션 레벨(DB Lock을 사용하지 않고 Version 관리를 통해 처리)에서의 동시성 제어에 대해 알아보고자 한다.
낙관적 락 (Optimistic Lock)
낙관적 락은 여러 트랜잭션이 같은 데이터를 수정할 가능성이 낮다고 가정하고 충돌이 발생했을 때만 문제를 해결하는 접근법이다. 트랜잭션이 데이터에 접근할 때 락을 걸지 않고, 작업을 커밋하는 시점에서 충돌이 발생했는지 확인한다.
낙관적 락 동작 방식

- 데이터 읽기: 트랜잭션이 DB에서 데이터를 읽어오고, 데이터와 함께 버전 번호나 타임스탬프 같은 정보를 함께 가져온다.
- 작업 수행: 트랜잭션이 작업을 수행한다. 작업을 수행할 때는 DB에 락이 걸리지 않고, 다른 트랜잭션도 해당 데이터를 읽거나 수정할 수 있다.
- 변경 사항 검증: 작업 후 트랜잭션이 커밋되기 전에, 처음 데이터를 읽었을 때의 버전 번호나 타임스탬프가 현재 값과 일치하는지 확인한다.
- 충돌 처리: 두 값이 일치하지 않으면(다른 트랜잭션에 의해 수정되었으면), 충돌이 발생했다고 판단하고 트랜잭션을 롤백하거나 재시도한다. 충돌이 없다면 변경 사항을 커밋한다.
JPA에서 제공하는 Lock 적용 가능 범위
- EntityManager.lock() , EntityManager.find(), EntityManager.refresh()
- Query.setLockMode()
- @NamedQuery
참고로 Querydsl에서는 `setLockMode(LockModeType lockMode)` 메서드를 지원한다.
JPA의 @Version
JPA에서 낙관적 락을 사용하기 위해서는 @Entity 객체에 @Version 필드가 필요하다. `@Version` 필드는 엔티티를 수정할 때마다 +1씩 자동으로 증가한다. (버전은 JPA가 직접 관리하므로 개발자가 수정하면 안된다.)
버전이 충돌할 경우 `OptimisticLockException`이 발생한다.
@Version을 적용할 수 있는 데이터 타입
- Long, long
- Integer, int
- Short, short
- Timestamp
JPA에서 낙관적 락 적용하기
JPA를 사용할 때 추천하는 전략은 READ COMMITTED 격리 수준 + 낙관적 버전 관리 이다.
(참고로 SQL Server는 READ_COMMITTED가 기본 격리 수준이다. )
`LockMode.OPTIMISTIC` 옵션을 통해 낙관적 락을 사용할 수 있다.
// entity 클래스
@Entity
class Student {
...
@Version
private long version;
}
// 조회시 LockModeType을 OPTIMISTIC 으로 설정
entityManager.find(Student.class, studentId, LockModeType.OPTIMISTIC);
낙관적 락의 한계
낙관적 락으로 동시성 이슈를 모두 해결할 수 있는가? 그렇지 않다.
- 낙관적 락은 충돌이 발생하면 예외 처리를 직접 구현해야 한다.
- 만약 충돌시 재시도를 한다면 재시도 횟수에 따라 데이터베이스 I/O가 발생하기 때문에 부하가 증가하고, 애플리케이션 가용성이 저하된다.
- 재시도 실패에 대한 처리도 구현해야 하므로 코드 복잡성이 증가한다.
이러한 문제의 보완 방법으로 비관적 락을 사용할 수 있다.
비관적 락 (Pessimistic Lock)
비관적 락은 트랜잭션이 데이터에 접근하는 동안 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 락을 거는 방식이다. 데이터를 읽거나 수정하는 동안 락이 걸리며, 락이 해제되기 전까지 다른 트랜잭션은 BLOCKING되어 커밋될 때까지 대기하게 된다.
낙관적 락과 달리 DB의 Lock 기능을 이용한다. 주로 `SELECT FOR UPDATE` 구문을 사용하고, 버전 정보를 사용하지 않는다.
비관적 락을 설정할 때는 명시적으로 timeout을 설정해야 한다. 설정하지 않으면 DB에 따라 설정된 Lock Timeout 시간 동안 대기하게 된다.
MS SQL에서 Lock Timeout 설정 및 조회
-- Lock TimeOut 설정 값 조회 / default: -1 (무제한)
SELECT @@LOCK_TIMEOUT
-- Lock TimeOut 설정 (밀리세컨드)
SET LOCK_TIMEOUT 1800 -- 3분이 지나면 세션 끊김
비관적 락 동작 방식
- 잠금 획득: 트랜잭션이 데이터에 접근하려고 할 때, 해당 데이터에 대해 락을 건다. 트랜잭션이 완료될 때까지 해당 데이터에 대한 다른 트랜잭션의 접근을 막는다.
- 작업 수행: 트랜잭션이 작업을 수행한다.
- 잠금 해제: 트랜잭션이 완료되면 잠금이 해제되고, 다른 트랜잭션이 해당 데이터에 접근할 수 있게 된다.
비관적 락의 종류
공유 락 (Shared Lock)
S-Lock은 동일한 자원에 대해 다른 트랜잭션에 READ는 허용하지만 WRITE는 제한한다.
즉, 어떤 트랜잭션이 데이터 A에 대해 S락을 획득했다면, 다른 트랜잭션들은 데이터 A에 대한 X락을 획득할 수 없고(S락은 획등 가능) 이에 따라 다른 트랜잭션에서의 update, delete 등의 작업을 할 수 없다.
배타 락 (Exclusive Lock)
X-Lock은 자원을 선점한 트랜잭션이 COMMIT 할 때까지 다른 트랜잭션은 BLOCK 된다.
어떤 트랜잭션이 데이터 A에 대해서 X락을 획득했다면, 다른 트랜잭션들은 해당 데이터에 대해 S락과 X락 모두 획득할 수 없다. 즉 다른 트랜잭션에서 update, delete 등의 작업을 막는 것이다.
JPA에서 비관적 락 적용하기
JPA에서 비관적 락은 `@Lock` 어노테이션을 통해 적용할 수 있으며 공유 락은 `PESSIMISTIC_READ` 옵션으로, 배타 락은 `PESSIMISTIC_WRITE` 옵션으로 사용 가능하다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="5000")})
Stock findById(String id)
Querydsl에서는 setLockMode() 메서드를 통해 락 옵션을 적용할 수 있다.
@Override
public Optional<Board> findBoardDetailById(Long boardId) {
... = queryFactory
.selectFrom(...)
.where(...)
.setLockMode(LockModeType.PESSIMISTIC_WRITE) // Lock 적용
.fetchFirst();
}
비관적 락의 한계
비관적 락은 트랜잭션 간의 충돌을 원천적으로 차단한다. 덕분에 데이터 무결성을 확실히 보장한다.
그렇지만 비관적 락으로 동시성 이슈를 모두 해결 가능하냐고 물으면 그렇지 않다.
성능 저하
비관적 락은 모든 트랜잭션에 대해 락을 사용한다(락이 필요하지 않아도). 때문에 트래픽이 많은 경우 대기 시간이 길어져 전체 시스템 성능 저하로 이어질 수 있다.
데드락
다음 상황을 가정해보자.
- 트랜잭션 A가 테이블1의 1번 데이터에 lock을 획득
- 트랜잭션 B가 테이블2의 1번 데이터에 lock을 획득
- 트랜잭션 A가 테이블2의 1번 데이터에 lock 획득 시도(실패 - 대기)
- 트랜잭션 B가 테이블1의 1번 데이터에 lock 획득 시도(실패 - 대기)
서로 다른 트랜잭션이 각자 자원을 점유한 채, 상대방이 점유한 자원을 얻기 위해 무한히 대기하는 상황이 발생할 수 있다.
마치며
낙관적 락과 비관적 락으로 발생할 수 있는 문제들을 보완할 수 있는 방법으로 분산 락(Distributed lock)
이 있다. 낙관적 락과 비관적 락으로는 스케일 아웃된 DB 환경(샤딩, 레플리케이션 등)에서는 동시성 제어를 할 수 없기 때문에 분산 락을 통한 동시성 제어가 필요하다.
다음 글에서는 분산 락에 대해서 다뤄보고자 한다.
참고 자료
동시성 문제 해결하기 V1 - 낙관적 락(Optimisic Lock) feat.데드락 첫 만남
동시성 문제 해결하기 V2 - 비관적 락(Pessimistic Lock)
🔐 동시성 문제 해결을 위해 어떤 Lock을 사용해야 할 까? - (2) DB Lock의 Shared Lock, Exclusive Lock Deep Dive 🔓