Race Condition 해결을 위한 로컬 락 관리 구현체 분석

1. 상황: 동시에 수강 신청하기

수강 신청 서비스를 만들어야 할 때 서버 엔지니어 입장에서 깊이 고려해야되는 "동시성(Concurrency)" 이슈가 발생하는 상황을 생각해보자.

예를 들어, 인기 있는 운영제체 전공 수업의 남은 자리가 딱 1자리라고 가정하자. 이때, 수강 신청 기간 시작과 동시에 3명의 학생(Thread A, B, C)이 0.0001초의 차이도 없이 거의 동시에 "신청하기" 버튼을 눌렀다.

동시성 처리가 제대로 되어있지 않다면, 3명 모두 "수강 신청 성공"이라는 응답을 받고, 실제 시스템상의 수강 인원은 정원을 초과해버리는(Overbooking) 시나리오가 발생한다. 상식적으로 당연히 1명만 성공하고 나머지 2명은 실패가 뜨는게 정상아니겠는가? 왜 동시성 처리를 안해놓으면 이런 현상이 발생할 수 있는지 알아보자.

2. 원인: Read-Modify-Write 패턴

이유는 컴퓨터가 데이터를 처리하는 기계어 수준의 과정에서의 Read-Modify-Write 패턴과 컨텍스트 스위칭 사이에 틈이 존재하기 때문이다.

수강 신청 로직은 기계어 수준에서 최소한 다음의 3단계로 진행된다.

  • Read: DB나 메모리에서 남은 자리 수를 읽어옴. (현재 값: 1)
  • Modify: 자리가 있는지 확인하고, 있다면 남은 자리 수를 1 줄임. (1 - 1 = 0)
  • Write: 계산된 값(0)을 다시 저장.

이 단계는 따로 발생할 수 있는데 이게 핵심이다. 즉, 원자성이 보장 안된다!

만약 위 3단계 도중에 컨텍스트 스위칭이 발생한다면 어떻게 될까. Read와 Write 사이에 스위칭이 발생하면 다음과 같은 일이 발생할 것이다.

  • [Thread A] Read: 남은 자리가 1임을 확인.
    • 이때 OS가 Thread A를 멈추고 Thread B로 스위칭!
  • [Thread B] Read: A가 아직 자리를 줄이지(Write) 않았기에, B도 남은 자리를 1로 읽음.
    • 이때 OS가 Thread B를 멈추고 Thread C로 스위칭!
  • [Thread C] Read: C 역시 남은 자리를 1로 읽음.
  • [Thread A] Modify & Write: 아까 읽은 1에서 1을 빼서 0을 저장하고 "성공" 처리.
  • [Thread B] Modify & Write: B도 아까 1을 읽었으므로, 1을 빼서 0을 저장하고 "성공" 처리.
    • 이미 0인데 또 0으로 덮어씌움.
  • [Thread C] Modify & Write: C마저도 0을 저장하고 "성공" 처리.

결과적으로 남은 자리는 0이 되었지만, 실제로는 3명이 수강 신청에 성공하는 데이터 무결성 오류가 발생한다.

이를 경쟁 상태(Race Condition) 라 함.

3. 해결법: 원자성 보장하기

위의 원인을 살펴보면 해결책은 명확하다. 바로 분리되어 있던 Read-Modify-Write 과정을 쪼개질 수 없는 원자적인 작업으로 만드는 것.

이를 위해 해당 데이터 수정 로직을 임계 영역(Critical Section)으로 두고, 한 스레드가 작업하는 동안 다른 스레드가 개입할 수 없도록 상호 배제(Mutual Exclusion)를 보장한다면 논리적인 원자성을 확보할 수 있을것이다.

이를 구현하는 방법은 시스템의 규모와 요구사항에 따라 다양하다.

  • DB 수준 제어: 낙관적 락, 비관적 락
  • 애플리케이션 수준 제어: 로컬 락(synchronized, ReentrantLock 등)
  • 인프라 수준 제어: 분산 락(Redis 등), 메시지 큐(Kafka, RabbitMQ 등)

이번 글에서는 애플리케이션 코드 레벨에서 직관적으로 제어할 수 있는 로컬 락 의 구현을 살펴보고, 서비스가 Scale-out 될 때 발생하는 한계를 통해 분산 락 이 필요한 이유까지 단계적으로 고찰해보겠다.

4. 로컬 락 구현

@Component  
public class LocalLockManager implements LockManager {  
	// Key: 강의 ID, Value: 해당 강의를 위한 Lock 객체
    private final Map<Long, Lock> locks = new ConcurrentHashMap<>();  
  
    @Override  
    public Lock getLock(Long courseId) {  
	    // 해당 ID에 대한 Lock이 없으면 생성해서 넣고, 있으면 가져온다 (원자적 연산)
        return locks.computeIfAbsent(courseId, id -> new ReentrantLock(true));  
    }  
  
    @Override  
    public void clear() {  
        locks.clear();  
    }  
}

위 코드는 최근 무신사 코딩테스트에 제출한 LocalLockManager 클래스다. Claude 가 구현해준것인데 이를 기반으로 이번 글을 진행해보겠다.

위 코드에서 다음 3가지를 주목해보겠다.

  1. 자료구조: ConcurrentHashMap
  2. 원자적 연산 메서드: ConcurrentHashMap#computeIfAbsent()
  3. 락 구현체: ReentrantLock(true)

4.1. Thread Safe한 ConcurrentHashMap

멀티스레드 환경에서 스레드 세이프하지 않은 자료구조를 쓴다면 경쟁 상태로 인한 이슈가 발생할 수 있다. 그래서 자바의 concurrent package에 있는 스레드 세이프한 자료구조인 ConcurrentHashMap을 쓰게된 것.

스레드 안전 수법: CAS를 통한 Lock-Free 동시성 제어

ConcurrentHashMap은 데이터를 삽입, 갱신할 때 무조건 락을 걸지 않는다. 대신 CAS(Compare-And-Swap) 라는 기술을 이용한다.

일반적 락 방식은 비관적 방식이다. 그냥 문을 잠가버리고 아무도 못들어오게하는. 하지만 CAS는 "일단 해보고, 그 사이에 누가 건드렸으면 다시 하지 뭐"하는 낙관적 방식의 메커니즘을 가진다. 락을 걸고 대기하는(Blocking) 비용 없이 낙관적인 방식으로 처리해 처리량이 상대적으로 높다.

Java의 CAS는 커널의 스케줄러나 시스템 콜을 거치지 않고, JVM이 하드웨어(CPU)의 기계어 명령어를 직접 이용하여 처리하는 방식

락 방식은 커널 모드 진입, 스레드 중단 및 재개 등의 무거운 작업이 있지만, CAS는 CPU의 단일 하드웨어 명령어로 처리되 가볍긴하다. 하지만 항상 좋은건 아니고 경합 상황에 따라 다르다. 고경합 상황에서 수백 개의 스레드가 하나의 변수를 수정하려 한다면, CAS는 끊임없는 재시도 루프에 빠지게된다. 이는 CPU 자원을 100% 점유하면서 정작 실제 업데이트는 하나씩만 성공하는 '라이브 락'을 유발할 수 있다는 점 유의.

처리량 유리: Table 락이아닌 Bucket Lock을 통한 처리량 보장

Hashtable이나 Collections.synchronizedMap을 쓴다면 메서드 전체에 synchronized를 걸어버린다. 즉, A가 1번 방에 데이터를 쓰는 동안 B는 100번 방에도 접근할 수 없고, 이는 병목 현상의 원인이 된다.

반면 ConcurrentHashMap은 해시 버킷 단위로 락을 쪼개서 관리한다. 1번 강의에 락을 거는 동안에도 2번 강의에 대한 접근은 자유롭기 때문에 동시 처리량이 높아진다.

4.2. 원자적 연산 지원: ConcurrentHashMap#computeIfAbsent()

처음에 내가 의문을 품은 부분이었다. 스레드 세이프하다면 get()으로 확인하고 없으면 put()을 해도 되지 않을까?

// Thread A와 Thread B가 동시에 진입
Lock lock = locks.get(id); 
if (lock == null) {
    lock = new ReentrantLock(true);
    locks.put(id, lock); // A와 B가 서로 다른 락 객체를 맵에 넣어버릴 수 있음!
}
return lock;

위 코드는 경쟁 상태에 취약한데 그 이유는 get()put() 사이에 틈이 존재하기 때문이다. 이 틈으로 다른 스레드가 끼어들면, 같은 강의 ID에 대해 서로 다른 락 객체가 2개 생성될 수 있는 것.

이렇게 되면 A는 1번 락을 잡고, B는 2번 락을 잡게 되어 상호 배제가 안된다.

이를 해결해주는 computeIfAbsent()메서드는 값의 확인과 저장 행동을 하나의 원자적 연산으로 처리해준다.

  • return locks.computeIfAbsent(courseId, id -> new ReentrantLock(true));

내부적으로 락을 통해 이 과정 원자성이 보장되므로, 수백 개의 스레드가 동시에 요청해도 해당 ID에 대해서는단 하나의 Lock 객체만 생성됨을 보장한다.

그냥 위 메서드(락을 얻는 메서드) 전체에 synchronized를 걸면 안 될까?

가능은 하지만 성능상 비효율적.synchronized를 사용하여 직접 동기화하면, 서로 다른 강의(ID)를 조회하는 스레드끼리도 불필요한 대기가 발생. 반면, ConcurrentHashMap#computeIfAbsent는 내부적으로 버킷 단위의 락을 사용하기 때문에, 서로 다른 키에 대한 요청은 병렬로 처리될 수 있어 성능이 나음.

// 이렇게 락을 얻는 메서드내부에 synchronized를 쓰는 경우 처리량이 떨어질 수 있다.
private final Map<Long, Lock> locks = new ConcurrentHashMap<>();
private final Object monitor = new Object(); // 락을 위한 객체
 
public Lock getLock(Long courseId) {
    // 이 블록은 한 번에 하나의 스레드만 들어올 수 있음 (전역 락)
    synchronized (monitor) { 
        Lock lock = locks.get(courseId);
        if (lock == null) {
            lock = new ReentrantLock(true);
            locks.put(courseId, lock);
        }
        return lock;
    }
}

4.3. ReentrantLock과 fair 옵션

구현체 코드를 보면 ReentrantLock(true)을 사용했다. 여기서 생성자 파라미터 true공정성(Fairness) 을 의미한다. 좀 더 디테일하게 설명하자면

  • 비공정 모드 (false, 기본값): 락을 기다리는 스레드가 있어도, 방금 도착한 스레드가 락을 얻을 수 있습니다. 처리량은 좋지만, 특정 스레드가 계속 락을 못 잡는 기아 현상이 발생할 수 있다.
  • 공정 모드 (true): FIFO를 준수하는 락을 획득. 수강 신청처럼 선착순 보장이 필요한 비즈니스 로직에서는 성능을 조금 희생하더라도 공정 모드를 사용할 수 있다.

공정 모드가 비공정 모드보다 처리량이 떨어지는 것은 비공정 모드의 경우 락 획득을 대기하는 스레드 들 중에 당장 컨텍스트 스위칭 없이 바로 동작이 가능한게 있으면 그 스레드에 락을 부여한다 반면, 공정 모드는 대기 큐에 있는 스레드를 순서대로 깨워야 하므로 Context Switch 비용이 더 자주 발생하기 때문에 처리량이 좀 떨어 질 수 있는것이다.

5. 구현된 LocalLockManager 취약점과 한계

구현된 LocalLockManager는 단일 서버 환경에서는 문제없이 동작하지만, 실제 운영 환경을 고려하면 몇 가지 치명적인 단점이 존재한다.

5.1 메모리 누수와 락 관리 어려움

현재 구현은 computeIfAbsent로 생성된 락은 ConcurrentHashMap에 계속 저장된다. 만약 수강 가능한 강의가 10만 개라면? 맵의 사이즈도 10만 개가 된다. 강의가 끝나서 더 이상 락이 필요 없어져도 GC의 대상이 되지 않아, 메모리를 불필요하게 점유할 수 있다.

그렇다면 이 락을 지우면 될텐데 문제는 언제 지울지를 어떤 기준으로 할 지가 어렵다. 단순히 locks.remove(id)를 호출하거나 주기적으로 locks.clear()를 한다면, 어떤 스레드가 락을 획득하고 로직을 수행하는 도중에 맵에서 락 객체가 제거된다면, 다음에 들어오는 스레드는 새로운 락 객체를 생성하게 되고 동시성 문제가 발생할 것이다.

단순히 맵을 clear한다면 락을 쥐고 있는 도중 락 객체가 사라져 동시성이 깨질 위험이 있다.

당장은 TTL 정도가 떠오른다. 조금 찾아보니 Resource Cleanup 전략으로 실무에서는 Guava의 Striped Lock을 사용하거나 Caffeine Cache 등을 이용해 사용하지 않는 락을 안전히 Eviction 시키는 전략이 있다하는데 추후 공부해볼 예정.

5.2 Scale-Out 시 동시성 보장 불가

서버 트래픽이 늘어나 서버를 2대(Server A, Server B)로 증설했다 가정해보자.

  • 유저 A는 Server A에 요청을 보내 101번 강의의 락을 획득.
  • 유저 B는 Server B에 요청을 보내 101번 강의의 락을 획득.

서로 다른 서버의 메모리 공간은 공유되지 않기 때문에, 같은 강의에 대해 두 명이 동시에 수강 신청에 성공하게 된다. 즉, 로컬 락은단일 서버구조에서만 유효한 전략인 것.

6. 분산 락으로 보완하자

로컬 락의 한계를 극복하기 위해서는, 모든 서버가 공통으로 바라볼 수 있는 외부의 제3자가 락을 관리해줘야 할 필요성이 있다. 모든 서버가 임계 영역에 접근하기 전에 Redis라는 공용 메모리에 접근하여 락을 획득 후 해당 로직을 실행하는 방식.

다음 글에서는 다중 서버 환경에서도 안전하게 수강 신청을 처리하는 분산 락의 구현과 어떻게 고가용성과 장애를 대응할 것인가에 관하여 공부해고 글을 써보려한다.