콘텐츠로 이동

Ch.5 서버가 여러 대면 - 분산 환경의 동시성 제어

< Deadlock의 조건과 Semaphore | 유사 사례와 키워드 정리 >


여기까지 배운 Lock은 전부 "파이썬 프로세스 하나 안"의 이야기였다. threading.Lock()도, Semaphore도, 같은 메모리 공간을 공유하는 스레드끼리만 통한다. 그런데 현실의 서비스는 서버 한 대로 끝나지 않는다.

threading.Lock()이 안 통하는 순간

MSA(Microservice Architecture)나 Stateless 서버 구조에서는 같은 애플리케이션이 여러 인스턴스로 떠 있다. 오토스케일링이 걸려 있으면 트래픽에 따라 2대가 됐다가 10대가 됐다가 한다.

Stateless (무상태)

서버가 요청 사이의 상태를 자기 메모리에 들고 있지 않는 구조다. 어느 인스턴스로 요청이 가든 같은 결과가 나와야 한다. 그래야 인스턴스를 자유롭게 늘리고 줄일 수 있고(오토스케일), 한 대가 죽어도 다른 대가 이어받는다.

상태를 안 들고 있다는 건 곧 "공유해야 할 상태는 서버 바깥(DB, Redis 같은 공용 저장소)에 둔다"는 뜻이다.

문제는 여기서 시작한다. 서버가 3대인데 동시에 같은 재고를 차감하면?

graph TB
    LB["Load Balancer"] --> S1["서버 1<br/>threading.Lock A"]
    LB --> S2["서버 2<br/>threading.Lock B"]
    LB --> S3["서버 3<br/>threading.Lock C"]
    S1 --> DB[("공용 DB<br/>stock = 1")]
    S2 --> DB
    S3 --> DB
    style DB fill:#f96,stroke:#333

서버 1의 Lock A, 서버 2의 Lock B는 서로의 존재를 모른다. 각자 자기 프로세스 메모리 안에서만 "한 명"을 보장한다. 세 서버가 동시에 "재고 1개 있음"을 읽고 셋 다 차감하면, 사례 A에서 봤던 재고 마이너스가 이번엔 서버 단위로 재현된다. threading.Lock()으로는 절대 못 막는다.

그럼 어떻게 하는가. 원리는 하나다. 모든 서버가 함께 바라보는 단일 지점에서 조율한다. 그 지점이 DB냐 Redis냐, 잠그느냐 안 잠그느냐에 따라 전략이 갈린다.

다섯 가지 전략

전략 핵심 트래픽 적을 때 트래픽 많을 때 대표 용례
원자적 단일 쿼리 DB가 행 원자성을 보장 좋음 좋음 재고·카운터 (가장 흔함)
비관적 락 먼저 잠그고 진행 좋음 나쁨 (락 경합) 잔액·좌석, 충돌 잦음
낙관적 락 충돌 나면 재시도 무난 좋음 (읽기 多) 일반 CRUD, 충돌 드묾
분산 락 (Redis) DB 밖 자원·여러 스텝 조율 과함 무난 크론 중복 방지, 외부 API 직렬화
큐 직렬화 경쟁 자체를 제거 과함 좋음 (최종 일관성) 초고트래픽 주문

하나씩 본다.

1. 원자적 단일 쿼리 — 대부분 여기서 끝난다

Lock을 논하기 전에 짚어야 할 게 있다. 재고 차감이나 카운터 증가처럼 "읽고-체크하고-쓰는" 게 한 행(row)에서 끝나는 일은, 애초에 락을 잡을 필요가 없다. DB가 단일 쿼리를 원자적으로 처리하기 때문이다.

UPDATE products
SET stock = stock - 1
WHERE id = 1 AND stock >= 1;

stock >= 1 조건을 UPDATE 안에 넣었다. 재고가 남아 있으면 1 감소하고 영향 행 수(affected rows)가 1, 이미 0이면 조건에 안 걸려서 영향 행 수가 0이다. 애플리케이션은 이 숫자만 보고 성공/품절을 판정한다. 여러 서버가 동시에 쏴도 DB가 행 단위로 직렬화하니까 재고는 절대 음수가 안 된다.

이게 Ch.5 사례 A에서 봤던 CAS(Compare-And-Swap)의 DB 버전이다. "값이 조건과 맞으면 바꾸고, 아니면 안 바꾼다"를 한 문장에 담았다. 분산 환경 동시성의 1순위 선택지는 락이 아니라 이 원자적 쿼리다. 락은 이걸로 안 될 때 꺼내는 카드다.

2. 비관적 락 (Pessimistic Lock)

한 행에서 안 끝나고, 여러 행을 읽어서 복잡한 판단을 한 뒤 쓰는 로직이라면, 먼저 잠그고 진행한다.

BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- 이 행을 잠근다
-- 복잡한 비즈니스 로직 (다른 테이블 조회, 검증 등)
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;  -- 잠금 해제

"충돌이 날 것이다"라고 비관적으로 가정하고, 아예 남이 못 건드리게 막고 시작한다. 확실하지만, 트래픽이 몰리면 뒤따라온 트랜잭션들이 전부 이 락 앞에 줄을 선다. 사례 B의 Deadlock, 사례 후반의 Connection Pool 고갈이 여기서 그대로 재현된다. 트래픽이 적고 충돌이 잦은 곳에 맞는다. (Isolation Level과 함께 Ch.15에서 깊이 다룬다.)

3. 낙관적 락 (Optimistic Lock)

반대로 "충돌은 드물 것이다"라고 낙관하고, 잠그지 않는다. 대신 행에 버전(version) 컬럼을 두고, 내가 읽은 버전이 그대로일 때만 쓴다.

-- 읽을 때: version = 7 을 같이 읽어둔다
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 7;

그 사이 다른 서버가 먼저 갱신했으면 version이 8로 바뀌어 있어서 내 UPDATE는 0행에 걸린다. 그러면 실패로 보고 다시 읽어서 재시도한다. 잠그지 않으니 읽기가 많은 상황에서 빠르지만, 충돌이 잦으면 재시도가 폭증해 오히려 느려진다. 읽기가 많고 쓰기 충돌이 드문 곳에 맞는다. (역시 Ch.15에서 다룬다.)

4. 분산 락 (Distributed Lock, Redis)

지금까지는 조율 대상이 DB 안의 행이었다. 그런데 "DB 바깥의 일"을 한 번만 실행하고 싶을 때가 있다. 예를 들어 서버 3대에 같은 크론이 걸려 있는데 정산 배치는 한 대만 돌아야 한다거나, 외부 결제 API를 중복 호출하면 안 된다거나. 이럴 때 모든 서버가 공유하는 Redis에 락을 건다.

# 락 획득: 키가 없을 때만(NX) 세팅, 10초 뒤 자동 만료(PX)
ok = redis.set("lock:settlement", token, nx=True, px=10000)
if ok:
    try:
        run_settlement()   # 이 블록은 전 서버 통틀어 한 번만 실행된다
    finally:
        release(...)       # 아래 참고

nx=True가 "없을 때만 쓴다"는 원자적 SETNX고, px로 TTL을 걸어 락을 잡은 서버가 죽어도 언젠가 풀리게 한다. 여기까진 깔끔한데, 해제가 함정이다.

왜 Redis 락 해제에 Lua 스크립트를 쓰는가

순진하게 해제하면 이렇다: "내가 건 락이 맞는지 GET으로 확인하고, 맞으면 DEL로 지운다." 두 단계다.

그런데 GET과 DEL 사이에 내 락의 TTL이 만료되고, 그 틈에 다른 서버가 같은 키로 새 락을 잡으면? 나는 여전히 "내 거였지"라고 믿고 DEL을 쏴서 남의 락을 지워버린다. 사례 A의 Race Condition이 락 해제 로직에서 그대로 재발한 것이다.

Redis는 Lua 스크립트를 원자적으로 실행한다 — 스크립트 하나가 도는 동안 다른 명령이 끼어들지 못한다. 그래서 "확인 후 삭제"를 통째로 한 스크립트에 담아 보낸다.

-- KEYS[1] = 락 키, ARGV[1] = 내 토큰
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])   -- 내 락일 때만 지운다
else
    return 0                             -- 남의 락이면 손대지 않는다
end

GET과 DEL 사이에 아무도 끼어들 수 없으니, 확인한 순간의 상태 그대로 삭제가 보장된다. Redis에서 "여러 명령을 원자적으로 묶어야 할 때 Lua"라는 패턴은 락 말고도 두루 쓰인다.

분산 락은 이렇게 TTL 만료, 서버 장애, 네트워크 단절 같은 변수가 붙어서 DB 락보다 훨씬 까다롭다. Redis 자체가 여러 대일 때의 안전성(Redlock 논쟁), 락 만료 후 뒤늦게 돌아온 작업을 걸러내는 fencing token 같은 주제는 Redis를 본격적으로 다루는 Ch.17로 넘긴다. 여기서 기억할 건 하나다. DB 안에서 풀 수 있는 문제라면 DB 락이 정답이고, 분산 락은 DB 바깥을 조율할 때만 꺼낸다.

5. 큐로 직렬화 — 경쟁 자체를 없앤다

가장 트래픽이 큰 곳에서는 발상을 뒤집는다. 락으로 경쟁을 "관리"하는 대신, 경쟁이 생기지 않게 요청을 한 줄로 세운다. 주문을 메시지 큐에 넣고, 같은 상품(파티션 키)의 주문은 항상 같은 소비자가 순서대로 처리하게 하면, 한 상품에 대해선 언제나 한 번에 하나만 실행된다. 락이 아예 필요 없어진다.

대신 요청이 큐를 거치므로 "주문 접수"와 "실제 처리" 사이에 시차가 생긴다. 즉시 일관성을 최종 일관성(eventual consistency)과 맞바꾼 것이다. 이 구조와 트레이드오프는 이후 아키텍처 챕터에서 다시 만난다.

그래서 무엇을 언제 쓰나

트래픽을 축으로 정리하면 이렇다.

  • 트래픽이 적을 때: 비관적 락(SELECT ... FOR UPDATE)이 단순하고 확실하다. 락 경합 비용이 눈에 안 띄니까 굳이 복잡한 방식을 쓸 이유가 없다.
  • 트래픽이 많아질 때: 비관적 락이 병목이 된다. 한 행 안에서 끝나면 원자적 단일 쿼리로, 충돌이 드물면 낙관적 락으로, 감당이 안 되면 큐 직렬화로 옮겨 간다.
  • DB 바깥을 조율할 때만: Redis 분산 락. 그마저도 장애를 견디게 만들려면 품이 든다.

결정 순서로 요약하면:

graph TB
    Q1{"한 행 안에서<br/>끝나는가?"} -->|"예"| A["원자적 단일 쿼리<br/>UPDATE ... WHERE 조건"]
    Q1 -->|"아니오"| Q2{"충돌이<br/>잦은가?"}
    Q2 -->|"잦다"| B["비관적 락<br/>SELECT FOR UPDATE"]
    Q2 -->|"드물다"| C["낙관적 락<br/>version 컬럼"]
    Q1 -->|"DB 밖의 일"| D["분산 락<br/>Redis"]
    B -->|"트래픽 폭증"| E["큐 직렬화"]
    style A fill:#9c6,stroke:#333

실무에서 가장 흔한 건 단연 원자적 단일 쿼리다. 재고·좋아요·조회수·잔액 같은 카운터성 데이터는 이걸로 대부분 해결된다. 그다음이 낙관적 락, 진짜 필요할 때만 비관적 락과 분산 락을 얹는다. "일단 락부터"가 아니라 "락 없이 될 일인가"를 먼저 묻는 게 순서다.

이번 챕터에서 배운 Mutual Exclusion, Critical Section, Deadlock, Lock Ordering의 원리는 서버가 여러 대가 돼도 그대로 적용된다. 달라지는 건 "네트워크 지연"과 "부분 장애(partial failure)"라는 변수가 추가된다는 점뿐이다. 원리는 같고, 무대만 넓어졌다.


< Deadlock의 조건과 Semaphore | 유사 사례와 키워드 정리 >