5. Lock
Lock이란?
- 동시 접근을 제어하기 위해 사용되는 동기화 메커니즘. 한 번에 하나의 주체만 접근할 수 있도록 제한한다.
- 락 획득 → 자원 접근 및 작업 수행 → 락 해제
Lock 종류
- 뮤텍스(Mutex, Mutual Exclusion)
- 한 번에 한 스레드만 락을 획득할 수 있습니다.
- 락을 획득한 스레드가 작업을 마치고 락을 해제해야 다른 스레드가 접근할 수 있습니다.
- 임계 구역에 접근 시 락을 획득하고, 임계 구역에서 나가면 락을 반환.
- 스핀락(Spin Lock)
- 락이 해제될 때까지 계속 반복해서 확인합니다. (대기하면서 CPU를 사용)
- 짧은 시간 내에 락이 해제될 때 유리하지만, 장시간 대기 시 CPU 리소스를 낭비할 수 있습니다.
- 분산 락(Distributed Lock)
- 여러 노드나 시스템에서 자원을 제어하기 위해 사용됩니다.
- 네트워크를 통해 락을 관리
낙관적 락
- 데이터를 읽을 때는 락을 걸지 않고, 업데이트할 때만 이전 데이터와 현재 데이터를 비교하여 충돌 여부를 판단한다.
- 충돌이 발생하지 않을 것이라고 가정하고, 충돌이 발생한 경우를 대비하는 방식!
- JPA에서 @Version을 이용하여 구현할 수 있다.
- 단점: 충돌이 빈번하게 발생하는 경우 성능이 저하될 수 있으며, 재시도 비용이 커질 수 있다.
비관적 락
- 데이터를 읽을 때부터 해당 데이터에 대한 락을 걸어 다른 트랜잭션이 해당 데이터를 변경할 수 없게 한다. 일관성 유지에 더욱 초점을 맞춘다!!
- 충돌이 발생할 확률이 높다고 가정하고, 데이터에 접근하기 전에 먼저 락을 걸어 충돌을 예방하는 방법이다.
- JPA에서 LockModeType을 사용하여 구현할 수 있다:
- PESSIMISTIC_READ: 읽기만 허용, 쓰기 불가.
- PESSIMISTIC_WRITE: 읽기와 쓰기 모두 불가.
- PESSIMISTIC_FORCE_INCREMENT: 락을 걸면서 버전을 증가시킴.
- 단점: 성능 저하 및 교착 상태에 빠질 수 있다. 이를 방지하기 위해 타임아웃 설정 등이 필요.
분산 락과 Redis를 사용한 분산 락
- 분산 락: 여러 노드에서 동일한 자원에 대한 접근을 제어하는 방법이다. 자원에 접근할 때가 아닌 로직의 시작점에서 락을 획득하는 것이 일반적인 분산 락의 사용 방식이다.
- 분산 락을 사용하면 동시에 같은 자원에 접근하는 것을 방지할 수 있고, 데이터 일관성 문제나 충돌을 방지할 수 있다.
- 멀티 인스턴스 환경에서도 공통된 락을 사용할 수 있다.
- “분산”의 의미: 여러 시스템(노드)에서 이루어지기 때문이다. 락이 분산되어 있다는 의미!
- Redis를 사용한 분산 락
- 다양한 방식으로 구현 가능:
- SETNX (SET if Not Exists): 락을 획득할 때 사용.
- Expire (TTL): 락 자동 해제 시간 설정.
- LuaScript: 락 소유 여부를 확인하며 락을 원자적으로 해제.
- Redisson 라이브러리를 사용하면 락 인터페이스를 통해 간편하게 구현 가능하며, Watchdog 기능으로 TTL 만료 문제를 해결할 수 있다.
- Pub/Sub 방식을 이용해 락 해제 이벤트를 다른 클라이언트에게 알림.
Lettuce는 분산락 구현 시 SETNX(SET if Not Exists)를 사용하여 락이 없을 경우에만 키를 생성하여 락 획득한다. 그리고 EXPIRE를 사용하여 TTL(Time-to-Live)을 설정해 락이 일정 시간이 지나면 자동으로 해제되도록 설정. 해제 시에는 락 소유 여부를 확인한 후에만 락을 해제해야 함.
그리고 이 과정에서 Lettuce는 스핀락(무작정 반복적으로 Lock이 반환될 때까지 확인하며 대기) 방식으로 동작합니다.
- Lettuce vs Redisson
- Lettuce: 비동기(non-blocking) 방식으로 Redis와 통신하는 클라이언트입니다. Netty 기반으로 구현되어 있으며, Reactive 프로그래밍을 지원합니다. Spring Data Redis에서 기본 클라이언트로 사용합니다.
- Netty 기반:
- 네트워크 I/O 처리를 위한 Netty 라이브러리를 사용하여 비동기 이벤트 기반 모델로 동작합니다.
- 비동기 및 반응형(reactive) API:
- 비동기 API(
CompletableFuture), 반응형 스트림(Reactive Streams), 동기 API를 모두 지원합니다.
- 비동기 방식 덕분에 I/O 작업을 기다리는 동안 스레드가 블록되지 않아 높은 성능을 제공합니다.
- 싱글 커넥션 기반:
- 하나의 연결에서 여러 작업을 비동기적으로 처리합니다.
- 커넥션 풀(Connection Pool)을 사용하지 않고, 대신 비동기 처리로 성능을 보완합니다.
- 트랜잭션과 Pub/Sub 지원:
- Redis의 트랜잭션, Pub/Sub 기능도 지원합니다.
- Redisson: 동기식(blocking)과 비동기식(non-blocking) 작업을 모두 지원하며, 다양한 분산 객체와 동시성 도구를 제공합니다.
- 커넥션 풀:
- Redisson은 내부적으로 커넥션 풀을 사용하여 여러 연결을 효율적으로 관리합니다.
- 분산 데이터 구조:
- Redis를 이용해 Java의 표준 데이터 구조(List, Map, Set 등)를 분산 환경에서 사용할 수 있습니다.
- 예를 들어,
RMap, RList, RLock 같은 Redisson의 객체를 통해 분산 환경에서도 동기화된 데이터 구조를 활용할 수 있습니다.
Redisson은 고수준 분산 기능을 제공하며 멀티 노드 환경에서 동기화 메커니즘이 필요할 때 유용합니다.
Lettuce는 가볍고 성능이 뛰어나며, 간단한 캐시 작업이나 저수준 데이터 접근에 적합합니다.
Redlock 알고리즘
- Redis가 권장하는 Lock을 제공하는 방법
- N대의 Redis 서버가 있다고 가정할 때,
과반 수 이상의 노드에서 Lock을 획득했다면 Lock을 획득한 것으로 간주한다.
- RedLock 알고리즘 절차
- Client는 Lock을 획득하기 위해 모든 Redis 서버에게 Lock을 요청. 과반 수 이상의 Redis 서버에게 Lock을 획득하면 Lock을 획득
- 실패했다면 모든 Redis 서버에게 Lock 해제를 요청하고 일정 시간 후에 Lock을 획득하기 위한 재 시도를 한다.
- 완벽한 알고리즘은 아니다.
- 네트워크 분할 문제 (Network Partition): 네트워크 분할(Partition)이 발생하면 일부 노드와의 통신이 단절될 수 있다.
- 예를 들어 전체 Redis 노드가 5개일 때, 3개 노드의 네트워크가 분리되면 두 개의 클러스터(2개 노드와 3개 노드)가 각각 존재하게 됩니다.
- 두 클러스터가 독립적으로 락을 획득하여 동시에 동일한 리소스에 접근할 가능성이 생깁니다.
- 이는 Redlock이 네트워크 분할 환경에서의 일관성 보장을 완벽히 해결하지 못함을 의미합니다.
- 락의 TTL과 클라이언트 동기화 문제: 락의 TTL(Time-to-Live)이 너무 짧거나, 클라이언트의 작업이 TTL을 초과할 경우, 락이 만료되어 다른 클라이언트가 락을 획득할 수 있습니다.
- 예를 들어, 클라이언트 A가 락을 획득하고 작업 중인데 TTL이 만료되면, 클라이언트 B가 락을 획득할 수 있습니다. 하지만 클라이언트 A는 여전히 락이 유효하다고 간주하고 작업을 진행 중일 수 있습니다.
- 이는 리소스 충돌을 초래할 수 있습니다.
- 락 연장(Renewal)을 도입하여 일부 문제를 완화할 수 있지만, 이 역시 분산 환경에서 완벽히 동기화되기 어렵습니다.
- 락 획득 실패 시 비용 문제: Redlock은 과반수 이상에서 락을 획득하지 못하면 모든 노드에서 락 해제 요청을 보내야 하며, 일정 시간 후 재시도합니다.
- 락 해제와 재시도 과정에서 불필요한 네트워크 비용 및 지연이 발생할 수 있습니다.
- 특히, 노드가 많아지면 이 문제는 더욱 심화됩니다.
💡가능하면, 락을 사용하지 않는 것이 성능면에서는 좋다.
락을 사용한다는 것은, 병목 지점을 만드는 것이다. 병목 지점이 생기면 교착상태에 빠질 수 있고, 스레드가 작업을 변환해야 하므로 컨텍스트 스위칭 비용이 증가할 수 있다. 이는 당연하게도 성능 저하로 이어진다.
따라서, 락을 사용하지 않고 동시성 문제를 해결할 수 있다면, 락 없이 문제를 해결하는 것이 좋다.