-
최근 간단한 예약 서비스 과제를 진행하면서, JPA의 Lock을 직접 다뤄야 하는 상황이 생겼다.
@Transactional만으로는 동시성 문제가 해결되지 않았고 비관적 락(Pessimistic Lock) 을 적용해 해결했다.이후 해당 내용에 대해 좀 더 자세히 알아보고자 글을 작성하게 되었다.
대표적으로 사용하는 DB중 하나인 MySQL 기준으로 작성할 예정이며 아래와 같은 순서로 작성하겠다.
1. DB Lock의 기본 개념
2. Read Lock vs Write Lock
3. 비관적 락 vs 낙관적 락
4. 교착 상태
5. ORM과 Lock (JPA)
1. DB Lock
Lock은 왜 사용해야 할까? 예를 들어 캐치테이블 같은 예약 앱을 생각해보자.
여러 명의 유저가 동시에 예약을 시도했을 때, 누가 예약을 성공해야 할까? 모두 성공하면 중복 예약이 되고, 일부만 성공해도 일관성이 깨질 수 있다.
이처럼 동시성 문제를 방지하기 위해 Lock이 필요하다.
DB Lock은 트랜잭션 간 경쟁 조건(Race Condition)이나 Dirty Read를 방지하고, 데이터의 무결성과 일관성을 보장하기 위해 사용하는 기법이다.
- 경쟁 조건(Race Condition)?
둘 이상의 트랜잭션 또는 스레드가 동시에 동일한 자원(데이터)에 접근하여 결과가 실행 순서에 따라 달라지는 현상
- Dirty Read?
커밋되지 않은 트랜잭션의 데이터를 다른 트랜잭션이 읽는 현상 -> 잘못된 데이터 노출 위험
- 데이터 무결성(Integrity)
데이터의 형식, 제약 조건, 참조 관계 등이 정해진 규칙을 어기지 않고 유지되는 상태
- 데이터 일관성
트랜잭션 전후에 데이터가 논리적으로 정합된 상태를 유지하는 것
2. Read Lock vs Write Lock
DB에서의 락은 읽기와 쓰기 작업을 구분하여 관리된다.
- Read Lock (공유 락): 여러 트랜잭션이 동시에 데이터를 읽을 수 있음. 서로 충돌 없음.
- Write Lock (배타 락): 데이터를 수정하는 트랜잭션이 단독으로 접근해야 하며, 다른 트랜잭션은 대기한다.
1) 트랜잭션 격리 수준(Transaction Isolation Level)
트랜잭션의 일관성과 동시성을 보장하기 위해 격리 수준이 필요하다. 각 격리 수준은 다음과 같은 특성과 잠금 전략을 가진다.
- READ UNCOMMITTED: 다른 트랜잭션의 커밋되지 않은 데이터를 읽을 수 있음 (Dirty Read 허용)
- READ COMMITTED: 커밋된 데이터만 읽음 (Non-repeatable Read 허용)
- REPEATABLE READ: 동일 데이터를 반복 조회 시 값이 변하지 않음 (Phantom Read 허용)
- SERIALIZABLE: 가장 엄격한 격리 수준, 트랜잭션을 직렬화하여 처리 (모든 읽기에 락 적용)
2) 읽기 현상(Read Phenomena)
트랜잭션 동시성에서 발생할 수 있는 대표적인 문제 상황들은 아래와 같다.
- Dirty Read : 커밋되지 않은 데이터를 읽음
- Non-repeatable Read : 같은 데이터를 두 번 읽었을 때 값이 다름
- Phantom Read : 같은 조건의 SELECT에서 처음엔 없던 데이터가 나중엔 존재
3) 락의 세부 범위(Lock Granularity)
락은 적용되는 범위에 따라 성능과 충돌 가능성에 영향을 준다.
- row-level Lock: 가장 정밀한 단위. 충돌은 적지만 비용 높음.
- page-level Lock: 다수의 레코드를 포함하는 페이지 단위로 락
- table-level Lock: 전체 테이블을 락으로 묶음. 성능은 좋지만 병렬성 낮음
MySQL InnoDB는 기본적으로 row-level lock을 사용.
3) 2단계 락 규약(2PL - two-phase locking protocol)
2단계 락 규약은 트랜잭션에서 다음 두 단계를 반드시 따를 때 일관성과 직렬성을 보장한다.
1. 확장 단계(Growing Phase): 락을 획득할 수는 있지만 해제할 수는 없음
2. 축소 단계(Shrinking Phase): 락을 해제할 수는 있지만 새로운 락을 획득할 수는 없음
이 규칙을 지키면 트랜잭션 간 충돌 시 데이터 불일치나 교착 상태를 피할 수 있다.
3. 비관적 락 vs 낙관적 락
1) 비관적 락(Pessimistic Lock)
- 다른 트랜잭션이 동시에 데이터를 변경할 수 있다고 비관적으로 가정
- 데이터에 접근할 때 즉시 락을 획득
- 다른 트랜잭션이 접근 못 하도록 선제적으로 제어
장점: 확실한 충돌 방지
단점: 락을 지속적으로 유지하기 때문에 락 보유 시간이 길어질수록 성능 저하 발생2) 낙관적 락 (Optimistic Locking)
- 대부분 충돌이 없을 것이라 낙관적으로 가정
- 트랜잭션이 끝나는 시점에서 버전 정보를 비교해 충돌 여부 판단
장점: 충돌이 없으면 성능 우수, 락이 없기 때문에 다른 트랜잭션과 병렬 실행 가능
단점: 충돌이 발생할 경우 예외를 처리해야 함
4. 교착 상태(DeadLock)
교착 상태란 두 개 이상의 트랜잭션이 서로 상대방의 락을 기다리면서 영원히 대기 상태에 빠지는 현상이다.
교착 상태를 방지하려면 다음과 같은 전략을 고려할 수 있다.
- 트랜잭션 순서화: 락을 획득하는 순서를 일정하게 만들어 교착 상태를 방지
- 락 타임아웃 설정: 일정 시간 동안 락을 얻지 못하면 트랜잭션을 롤백하도록 설정
- 최소화된 락 범위 사용: 필요한 최소 범위만 락을 걸어 다른 트랜잭션의 대기 시간을 줄임
MySQL InnoDB는 Deadlock 발생 시 하나의 트랜잭션을 강제로 롤백하여 해결한다고 한다.
5. JPA의 Lock
0) JPA의 Lock의 종류
JPA에서 사용하는 LockType의 종류와 설명은 아래와 같다.
public enum LockModeType { READ, // 낙관적 락 (Optimistic Lock) WRITE, // 낙관적 락 (OPTIMISTIC_FORCE_INCREMENT) OPTIMISTIC, // 낙관적 락 OPTIMISTIC_FORCE_INCREMENT, // 낙관적 락 + 버전 증가 PESSIMISTIC_READ, // 비관적 읽기 락 PESSIMISTIC_WRITE, // 비관적 쓰기 락 PESSIMISTIC_FORCE_INCREMENT, // 비관적 락 + 버전 증가 NONE // 락 적용 안함 }
1) JPA 비관적 락(pessimistic lock)
@Lock(LockModeType. PESSIMISTIC_WRITE)을 사용하면 비관적 락을 적용할 수 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE) public Entity findEntityBuId(Long id);
2) JPA 낙관적 락(optimistic lock)
@Lock(LockModeType. OPTIMISTIC)과 엔티티에 @Version 어노테이션을 사용하여 버전 관리를 통해 충돌 여부를 확인한다.
@Entity public class Entity { @Id private Long id; @Version private Long version; }
3) JPA의 락 예외 처리
락의 종류에 따라 다음과 같은 예외가 발생할 수 있다.
예외 클래스 발생 상황 PessimisticLockException 비관적 락 획득 실패 시 (다른 트랜잭션이 선점 중) OptimisticLockException 낙관적 락 사용 시 버전 충돌이 발생했을 때 CannotAcquireLockException DB에서 Deadlock 또는 락 대기시간 초과 (timeout) 발생 시 LockTimeoutException (JPA 2.2+) 특정 시간 안에 락을 획득하지 못한 경우 비관적 락 예외 처리
비관적 락은 락을 선점하려다 실패하거나, DB 락 타임아웃 설정에 걸리면 아래와 같은 예외가 발생할 수 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE) public Item findWithPessimisticLock(Long id) { try { return entityManager.find(Item.class, id, LockModeType.PESSIMISTIC_WRITE); } catch (PessimisticLockException | LockTimeoutException e) { // 다른 트랜잭션이 선점 중이거나 타임아웃이 발생한 경우 throw new BusinessException("잠시 후 다시 시도해주세요. 현재 다른 사용자가 작업 중입니다."); } }
낙관적 락 예외 처리
낙관적 락은 병행 트랜잭션 간 충돌 시점에 발생하며, 주로 merge() 또는 flush() 시점에 예외가 발생한다.
@Transactional public void updateProduct(ProductRequest dto) { Product product = productRepository.findById(dto.getId()) .orElseThrow(() -> new NotFoundException("존재하지 않는 상품입니다.")); product.updateName(dto.getName()); // 변경 try { // flush 또는 트랜잭션 종료 시점에서 충돌 감지 } catch (OptimisticLockException e) { throw new ConflictException("다른 사용자가 먼저 수정했습니다. 다시 시도해주세요."); } }
+ 재시도 전략 (Retryable)
낙관적 락 충돌 시 @Retryable을 사용하는 전략도 고려할 수 있다.
@Retryable( value = { OptimisticLockException.class }, maxAttempts = 3, backoff = @Backoff(delay = 200) ) @Transactional public void updateProductWithRetry() { // 업데이트 로직 }
Lock에 대해 작성해봤고 ORM에서 Lock을 사용하는 방법과 예외 처리 방법에 대해 작성해봤다.
실무에서 성능과 동시성 요구사항에 따라 어떤 락 전략을 선택해야 할 지 고민하는데 도움이 되리라 생각한다.
출처
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-transaction-model.html
https://www.postgresql.org/docs/current/explicit-locking.html
반응형'개발 > 기타' 카테고리의 다른 글
DNS, DNS 레코드, 서브도메인 (0) 2025.05.03 브라우저에 google.com을 입력하면 어떤 일이 일어날까? - 2 (0) 2025.04.21 브라우저에 google.com을 입력하면 어떤 일이 일어날까? - 1 (0) 2025.04.14 스택이 매우 커질 수 있다면 힙은 불필요할까? (2) 2025.04.06 쿠버네티스란? (0) 2025.02.23