여는 글
안정적인 트랜잭션 처리를 위해서는 락이 필요할 때가 있습니다. 그런데 이 락도 남용 되거나 고민이 충분히 되지 않은 상태에 쓰게 되면 장애 포인트나 성능의 병목이 될 수 있습니다. 그렇다면 어떻게 락을 쓰면 좋을까요?
Pessimistic lock(비관적 락)
데이터베이스 트랜잭션에서 비관적 락에는 크게 배타 락(X Lock, Exclusive Lock) 과 공유 락(S Lock, Shared Lock)이 있습니다. 배타 락은 달리 쓰기 잠금이라고 하는데, 특정 자원의 안정적인 변경을 위해 점유하고 다른 트랜잭션이 접근을 막습니다. 공유 락은 읽기 잠금 이라고도 불리는데, 특정 자원에 대해 읽기 잠금이 걸린 상태일 때 다른 트랜잭션도 읽기 잠금을 걸 수 있지만 이미 읽기 잠금이 걸린 자원에 대해 쓰기 잠금을 거는 것은 불가합니다.
Dead Lock(교착 상태)
데드 락은 락으로 발생할 수 있는 문제 중 하나입니다. 위키피디아에 따르면 두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상태를 가리킵니다.
데드 락의 조건에는 4가지가 있습니다.
- 상호배제(Mutual exclusion) : 프로세스들이 필요로 하는 자원에 대해 배타적인 통제권을 요구합니다.
- 점유대기(Hold and wait) : 프로세스가 할당된 자원을 가진 상태에서 다른 자원을 기다립니다.
- 비선점(No preemption) : 프로세스가 어떤 자원의 사용을 끝낼 때까지 그 자원을 뺏을 수 없습니다.
- 순환대기(Circular wait) : 각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있습니다.
예를 들어, 트랜잭션 ㄱ 이 자원 A가 점유한 상태에서 자원 B가 필요한데, 자원 B는 트랜잭션 ㄴ에 의해 점유된 상태이고 트랜잭션 ㄴ 은 트랜잭션 ㄱ이 점유 중인 자원 A가 필요한 상황을 예로 들 수 있습니다.
참고로, MySQL은 데드락 감지를 통해 변경 범위가 작은 트랜잭션을 우선으로 롤백을 하는 기능과, 락 대기 시간 설정을 통해 회피하기도 합니다.
데드 락을 방지하기 위한 다른 좋은 방법은 없을까요?
Optimistic Lock(낙관적 락)
낙관적 락은 데이터베이스의 기능을 사용하는 비관적 락과 달리 애플리케이션 레벨에서 로직을 통해 거는 락을 말합니다. 특정 엔티티가 버전을 관리 하도록 하고 이를 기반으로 변경이 유효한지 여부를 판별합니다.
데이터베이스에 동시에 여러 트랜잭션이 처리되고 있는 상황에서, 변경이 필요할 때 변경이 요청할 때의 상태와 SQL이 수행되는 시점에 상태가 동일한지 여부를 판별하기 위한 기준이 버전이 되는 샘 입니다.
아래와 같은 데이터를 가지는 book 테이블이 있다고 가정하겠습니다.
id | name | version | price |
---|---|---|---|
1 | Real MySQL | 1 | 30000 |
해당 1번 책 데이터의 가격을 3천원 올린다고 했을 때 아래와 같은 쿼리를 보내면 됩니다.
UPDATE book
SET price = price + 3000,
version = 2
WHERE id = 1, version = 1
만약 다른 트랜잭션에 의해 먼저 변경이 발생해 버전이 2 이상으로 올라 갔다면 1번 책 데이터는 변경되지 않을 것 입니다.
근데 만약 1번 책 데이터의 변경을 요청하는 클라이언트가 1만명, 2만명이라면 어떻게 될 까요? 아마 1명만 성공하고 나머지는 모두 실패해서 재시도를 하는 상황이 발생할 것입니다. 또한 재시도 과정에서 네트워크 대역폭이 가득차거나, 디비 커넥션이 부족하거나, DB CPU에도 영향을 줄 수 있습니다.
반대로, 수 많은 클라이언트가 하나의 자원에 대해 동시에 변경을 요청하는 상황이 발생하지 않는다면 낙관적 락은 좋은 선택지가 될 수 있습니다.
Pessimistic lock(비관적 락) 과 Optimistic Lock(낙관적 락)의 문제점
지금까지의 내용을 정리하자면 비관적 락은 Dead Lock 발생할 가능성이 있고, 낙관적 락은 특정 자원에 수 많은 클라이언트가 변경을 요청하는 상황이 발생할 수 있다면 성능 문제의 여지가 있습니다.
통상적으로 RDB는 수평적인 확장에 많은 어려워 CPU, Memory, Disk의 자원이 다른 시스템 구성요소(WAS, WEB SERVER)등에 비해 그 가치가 높은 편입니다. 그래서, 이를 해결하기 위해 레디스를 사용하는 것도 고려해볼 수 있습니다.
Redis를 사용한 Distributed Lock(분산 락)
레디스의 분산 락을 사용하면 비싼 RDB 자원을 쓰지 않고도 락을 걸 수 있습니다!
SET $key $name NX EX $seconds
# NX는 점유되지 않은 상태일 때만 생성합니다.
# EX는 무한정 점유되는 것을 방지하기 위해 만료시간(단위 초)을 설정합니다.
# 생성에 성공하면 "OK"가 반환됩니다.
# 특정 키가 이미 존재하면 (nil) 이 반홥니다.
도메인 특성을 고려하여 특정 키에 대해 만료시간을 설정해서 데이터를 생성합니다. 생성에 성공하면 락 점유에 성공한 것으로 간주합니다. 처리가 완료되면 DEL
명령어로 락으로 설정한 키를 삭제해 다른 트랜잭션이 점유할 수 있도록 합니다.
어플리케이션에서는 스핀 락 구조로 아래와 같은 수도 코드로 락을 점유하도록 구현할 수 있겠습니다.
while(redis.setIfAbsent(KEY, VALUE, 3000L, TimeUnit.seconds) != null) {
// businessLogic
}
다만, 위 코드의 문제는 점유가 될 때 까지 요청을 보내다보니 레디스는 끊임없이 부하를 받게됩니다.
이것에 대한 해결은 자바 레디스 클라이언트 중 하나인 Redisson의 tryLock을 통해 가능합니다. tryLock은 내부적으로 루아스크립트로 구현되어 있습니다.
대략 코드로 작성한 다면 아래와 같습니다.
final String lockName = "lock";
final RLock lock = redissonClient.getLock(lockName);
try {
if(!lock.tryLock(1, 3, TimeUnit.SECONDS)) {
return;
}
// businessLogic
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if(lock != null && lock.isLocked()) {
lock.unlock();
}
}
간단한 재고 관리 프로그램 개발을 통해 분산 락을 실제로 한 번 다뤄보도록 하겠습니다.