분산락(Distributed Lock)과 데드락(Dead Lock)
Series: 데이터베이스
들어가며
앞선 글에서는 하나의 데이터베이스 내부에서 동시성을 제어하는 방법으로 낙관적 락과 비관적 락을 살펴보았다. 하지만 실제 서비스는 대부분 여러 서버로 구성된 분산 환경에서 동작한다. 이 경우 단순히 DB 락만으로는 동시성 문제를 해결하기 어려운 상황이 발생할 수 있다. 또한 락을 사용하다 보면 피할 수 없는 문제 중 하나가 바로 데드락(Deadlock) 이다. 이번 글에서는 분산 환경에서 사용하는 분산 락(Distributed Lock) 과, 락 사용 시 반드시 고려해야 할 데드락에 대해 정리해보겠다.
1. 개요
1.1. 분산 환경에서의 동시성 문제
단일 DB 환경에서는 SELECT ... FOR UPDATE 같은 방식으로 비교적 간단하게 동시성 제어가 가능하다.
하지만 다음과 같은 상황을 생각해보자.
- 서버 A, 서버 B가 동시에 동일한 API를 호출
- 각각 DB 트랜잭션을 시작
- 같은 자원을 동시에 수정 시도
이 경우 DB 레벨에서 어느 정도 제어는 가능하지만, DB 외부 자원(캐시, 외부 API, 파일 등) 까지 포함된 로직에서는 문제가 발생할 수 있다. 또한 여러 DB를 사용하는 경우나, MSA 구조에서는 하나의 DB 락으로 전체 흐름을 제어하기 어렵다. 이러한 문제를 해결하기 위해 사용하는 것이 분산 락이다.
2. 분산 락(Distributed Lock)
2.1. 개념
분산 락은 여러 서버(노드) 간에 특정 자원에 대한 접근을 하나로 제한하기 위한 메커니즘이다. 즉, 하나의 서버가 락을 획득하면 다른 서버는 해당 작업을 수행하지 못하도록 만든다. 일반적으로 Redis, Zookeeper, DB 등을 이용해 구현한다.
2.2. 동작 방식
대표적인 Redis 기반 분산 락은 비교적 단순한 방식으로 동작한다. 핵심은 '특정 key를 먼저 선점하는 서버만 작업을 수행할 수 있도록 만드는 것'이다. 전체 흐름을 조금 더 풀어서 보면 다음과 같다. 먼저, 어떤 자원에 대해 락을 걸 것인지 기준이 되는 key를 하나 정의한다. 예를 들어 특정 유저에 대한 작업이라면 lock:user:1과 같은 key를 사용할 수 있다.
이후 서버는 Redis에 다음과 같은 요청을 보낸다.
SET lock:user:1 unique_value NX PX 3000여기서 중요한 옵션은 다음과 같다.
- NX: 해당 key가 존재하지 않을 때만 설정 (이미 존재하면 실패)
- PX: TTL 설정 (밀리초 단위, 일정 시간이 지나면 자동으로 삭제)
이 요청이 성공하면, 해당 서버는 락을 획득한 것으로 간주하고 작업을 수행할 수 있다. 반대로 실패했다면 이미 다른 서버가 락을 잡고 있다는 의미이므로, 작업을 수행하지 않거나 재시도를 하게 된다. 락을 획득한 서버는 이후 결제 처리, 재고 감소 등의 비즈니스 로직을 수행하고, 작업이 끝나면 Redis에서 해당 key를 삭제하여 락을 해제한다.
즉, 정리하면 다음과 같은 흐름이다.
- Redis에 SET key NX 요청을 보내 락 획득 시도
- 성공하면 작업 수행, 실패하면 대기 또는 중단
- 작업 완료 후 key 삭제 (락 해제)
이 방식은 구현이 간단하면서도, 여러 서버 간에 하나의 작업만 수행되도록 제어할 수 있다는 점에서 널리 사용된다.
2.3. 주의할 점
분산 락은 겉보기에는 단순하지만, 실제로는 몇 가지 중요한 문제를 고려하지 않으면 쉽게 버그로 이어질 수 있다.
첫 번째는 락을 획득한 서버가 작업 도중 죽는 경우이다. 만약 TTL 없이 단순히 key를 설정하고, 작업이 끝난 뒤 삭제하는 구조라면, 서버가 중간에 죽었을 때 락이 영구적으로 남게 된다. 이 경우 다른 서버는 계속해서 락을 획득하지 못하고, 해당 기능이 사실상 멈춰버리는 문제가 발생한다. 이 문제를 방지하기 위해 반드시 TTL을 설정하여, 일정 시간이 지나면 자동으로 락이 해제되도록 해야 한다.
두 번째는 락 해제를 잘못하는 경우이다. 예를 들어 A 서버가 락을 획득한 뒤 작업을 수행하고 있는데, TTL이 만료되어 락이 자동으로 해제되고, 그 사이 B 서버가 동일한 락을 획득했다고 가정해보자. 이 상태에서 A 서버가 작업을 마치고 DEL key를 실행하면, B 서버가 획득한 락까지 같이 삭제해버리는 문제가 발생할 수 있다. 이 문제를 해결하기 위해 일반적으로는 다음과 같은 방식이 사용된다.
- 락을 획득할 때 unique_value를 함께 저장
- 락을 해제할 때 해당 value가 일치하는 경우에만 삭제
이를 보장하기 위해 Lua Script를 사용하는 경우도 많다.
세 번째는 네트워크 지연 및 분산 환경 특성으로 인한 문제이다. 분산 시스템에서는 네트워크 지연, 패킷 손실, 서버 시간 차이 등 다양한 변수가 존재한다. 이로 인해 락의 TTL이 아직 남아있다고 판단했지만 실제로는 만료되었거나, 반대로 이미 다른 서버가 락을 획득했는데도 인지하지 못하는 상황이 발생할 수 있다. 이러한 문제들을 단순한 구현만으로 완벽하게 해결하기는 어렵기 때문에, 실무에서는 Redis 단일 인스턴스 기반의 간단한 락 구현 대신, Redlock 알고리즘이나 검증된 라이브러리를 사용하는 경우가 많다.
2.4. 언제 사용하는가?
분산 락은 주로 여러 서버가 동시에 동일한 작업을 수행하면 안 되는 상황에서 사용된다. 이를테면, 배치 작업이나 스케줄러와 같이 하나의 작업이 한 번만 실행되어야 하는 경우이다. 예를 들어 여러 서버가 동일한 배치 작업을 동시에 실행한다면 데이터가 중복 처리될 수 있는데, 분산 락을 통해 하나의 서버만 작업을 수행하도록 제한할 수 있다. 또한 결제 처리나 재고 감소와 같이 중복 실행 시 치명적인 문제가 발생하는 경우에도 사용된다. 이런 경우에는 동일한 요청이 동시에 들어왔을 때 하나만 처리되도록 보장하는 것이 중요하다.
마지막으로, 여러 서버가 하나의 외부 자원(파일, 캐시, API 등)을 공유하는 경우에도 분산 락을 활용할 수 있다. 다만, 모든 동시성 문제를 분산 락으로 해결하려고 하면 오히려 시스템이 복잡해질 수 있다는 것이다. 예를 들어 단일 DB에서 해결 가능한 문제라면, SELECT ... FOR UPDATE 같은 DB 락이나 트랜잭션으로 처리하는 것이 더 단순하고 안정적인 경우가 많다. 즉, 분산 락은 강력한 도구이지만, 정말 필요한 경우에만 선택적으로 사용하는 것이 바람직하다.
3. 데드락 (Deadlock)
3.1. 개념
데드락은 두 개 이상의 트랜잭션이 서로가 가진 락을 기다리면서 영원히 대기 상태에 빠지는 상황(교착상태)을 의미한다. 조금 더 전문적인 용어로 설명하자면, 데드락이 발생하기 위해서는 아래 네 조건이 모두 충족되어야 한다.
- 상호 배제 (Mutual Exclusion) : 한 번에 한 개의 프로세스만이 공유 자원을 사용할 수 있어야 한다.
- 점유와 대기 (Hold & Wait) : 최소한 하나의 자원을 점유하고 있으면서 다른 프로세스에 할당되어 사용되고 있는 자원을 추가로 점유하기 위해 대기하는 프로세스가 있어야 한다.
- 비선점 (Non-Preemptive) : 다른 프로세스에 할당된 자원은 사용이 끝날 때까지 강제로 빼앗을 수 없어야 한다.
- 환형 대기 (Circular Wait) : 공유 자원과 자원을 사용하기 위해 대기하는 프로세스들이 원형으로 구성되어 있어 자신에게 할당된 자원을 점유하면서 앞이나 뒤에 있는 프로세스의 자원을 요구해야 한다.
데드락은 단순히 운이 나빠서 발생하는 문제가 아니라, 락을 획득하는 방식과 트랜잭션 구조에 의해 자연스럽게 발생할 수 있는 문제이다. 가장 대표적인 원인은 락 획득 순서가 서로 다른 경우이다. 예를 들어 다음과 같은 상황이 있다.
- 트랜잭션 A → row1 락 획득
- 트랜잭션 B → row2 락 획득
- 트랜잭션 A → row2 락 요청 (대기)
- 트랜잭션 B → row1 락 요청 (대기)
트랜잭션 A는 row1 → row2 순서로 락을 잡고, 트랜잭션 B는 row2 → row1 순서로 락을 잡는다면, 서로가 상대방의 락을 기다리면서 멈춰버리는 상황이 발생할 수 있다. 이 경우 두 트랜잭션은 서로를 기다리며 더 이상 진행할 수 없다. 또한 트랜잭션 범위가 불필요하게 긴 경우에도 데드락 가능성이 높아진다. 트랜잭션이 길어질수록 락을 오래 유지하게 되고, 그만큼 다른 트랜잭션과 충돌할 확률이 증가하기 때문이다. 뿐만 아니라, 여러 테이블이나 많은 row를 동시에 잠그는 경우에도 데드락 발생 가능성이 커진다. 특히 인덱스가 적절히 잡혀 있지 않다면, 의도하지 않은 범위까지 락이 확장되면서 문제가 발생할 수 있다.
3.2. DB에서의 처리
대부분의 DB(MySQL, PostgreSQL 등)는 데드락을 자동으로 감지하고, 한 트랜잭션을 롤백하여 상황을 해소한다. 예를 들어 MySQL에서는 다음과 같은 에러가 발생한다.
Deadlock found when trying to get lock; try restarting transaction즉, DB 레벨에서 데드락을 감지 후 상황을 해소하므로, 애플리케이션 레벨에서는 재시도 로직을 구현하는 것이 일반적이다.
3.3. 해결 및 예방 방법
데드락은 완전히 제거하기보다는, 발생 가능성을 줄이고 발생 시 대응할 수 있도록 설계하는 것이 중요하다.
3.3.1. 획득 순서 유지
가장 효과적인 방법 중 하나는 락 획득 순서를 일관되게 유지하는 것이다. 예를 들어 항상 user → order 순서로만 접근하도록 규칙을 정하면, 서로 다른 순서로 락을 잡는 상황을 예방할 수 있다.
3.3.2. 유지시간 감소
트랜잭션을 최대한 짧게 유지하는 것도 중요하다. DB 작업 외의 로직(외부 API 호출, 복잡한 계산 등)은 가능한 트랜잭션 밖으로 분리하여 락 유지 시간을 줄이는 것이 좋다.
3.3.3. 범위 최소화
필요한 데이터만 잠그도록 범위를 최소화해야 한다. 불필요하게 많은 row를 락으로 묶으면 충돌 가능성이 크게 증가한다.
3.3.4. 인덱스 활용
적절한 인덱스를 사용하는 것도 중요한 요소다. 인덱스가 없으면 DB는 더 넓은 범위를 스캔하면서 더 많은 row에 락을 걸 수 있고, 이는 데드락 가능성을 높인다.
3.5. 개인적 견해
데드락은 잘못 설계했다기보다는, 복잡한 동시성 환경에서는 자연스럽게 발생할 수 있는 문제라고 생각한다. 중요한 것은 데드락이 발생하지 않도록 완벽하게 막는 것이 아니라, 발생 가능성을 줄이고 데드락이 발생했을 때 안전하게 복구할 수 있도록 설계하는 것이라고 본다. 특히 실무에서는 데드락 자체보다도, 데드락 발생 후 재시도를 어떻게 처리할 것인가가 더 중요한 경우가 많다.
마치며
분산 락과 데드락은 모두 동시성 문제를 다루는 과정에서 반드시 마주하게 되는 개념이다.
- 분산 락 → 여러 서버 간 동시성 제어
- 데드락 → 락 사용 과정에서 발생하는 문제
락을 사용한다는 것은 결국 정합성과 성능 사이의 균형을 잡는 일이라고 생각한다. 단순히 락을 적용하는 것을 넘어, 어디까지 락으로 제어할 것인지, 어디서 풀어줄 것인지에 대한 고민이 좋은 시스템 설계로 이어진다고 생각한다.