← 블로그로 돌아가기

Optimistic vs Pessimistic 프로그래밍

교차로 비유

신호등이 있는 교차로를 생각해보자.

신호등 있는 교차로 (Pessimistic):

  🚗 A → [빨간불: 정지] ─── 기다림 ───→ [초록불] → 통과
                                          │
  🚙 B → [초록불] → 통과 ─────────────────┘

  항상 한쪽만 통과. 충돌 확률 = 0%.
  대신 아무도 안 오는데도 빨간불이면 멈춰서 기다려야 한다.
신호등 없는 교차로 (Optimistic):

  🚗 A → 그냥 진입 → 충돌 없음 → 통과 ✅
  🚙 B → 그냥 진입 → 충돌 없음 → 통과 ✅

  대부분의 경우 아무 문제 없이 빠르게 통과한다.

  하지만 가끔:
  🚗 A → 진입 ─┐
                ├─→ 💥 충돌! → 한 대가 후진해서 다시 시도
  🚙 B → 진입 ─┘

이게 optimistic과 pessimistic의 본질이다.

  • Pessimistic(비관적): “충돌이 일어날 거다"라고 가정하고, 아예 한쪽을 먼저 막는다.
  • Optimistic(낙관적): “충돌은 안 일어날 거다"라고 가정하고, 일단 진행한다. 충돌이 생기면 그때 처리한다.

컴퓨터에서의 구현

Pessimistic Locking

-- PostgreSQL: SELECT FOR UPDATE
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;  -- 이 row에 락을 건다
-- 다른 트랜잭션이 이 row를 읽으려 하면 대기(block)

UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;  -- 여기서 락 해제
트랜잭션 A:  [락 획득] ──── [작업] ──── [커밋 + 락 해제]
트랜잭션 B:  [락 시도] ──── [대기...] ──── [대기...] ──── [락 획득] ──── [작업]
                           ↑ A가 끝날 때까지 block

핵심: 내가 작업하는 동안 아무도 이 데이터를 건드리지 못하게 한다.

Optimistic Locking

-- 1. 데이터를 읽으면서 version을 기억한다
SELECT balance, version FROM accounts WHERE id = 1;
-- balance = 1000, version = 5

-- 2. 작업한다 (애플리케이션 레벨에서)
new_balance = 1000 - 100  -- = 900

-- 3. 쓸 때 version이 바뀌었는지 확인한다
UPDATE accounts
SET balance = 900, version = 6
WHERE id = 1 AND version = 5;  -- version이 5일 때만 업데이트!

-- 영향받은 row가 0이면? → 누군가 먼저 수정함 → 1번부터 다시
트랜잭션 A:  [읽기 v=5] ──── [작업] ──── [쓰기 v=5→6] ✅ 성공
트랜잭션 B:  [읽기 v=5] ──── [작업] ──── [쓰기 v=5→6] ❌ 실패 (이미 v=6)
                                                         → 다시 시도

핵심: 락을 안 건다. 대신 “내가 읽은 이후로 아무도 안 건드렸는지"를 쓸 때 확인한다.

언제 뭐가 유리한가?

직관적으로 생각해보면 답이 나온다.

교차로에 차가 1시간에 1대 오는 한적한 시골길이라면? 신호등이 필요 없다. 신호등을 달면 아무도 안 오는데 혼자 빨간불에 서서 기다리는 바보가 된다. → Optimistic이 유리

교차로가 강남 교차로처럼 1초에 차 10대가 밀려들면? 신호등 없이는 난장판이 된다. → Pessimistic이 유리

                충돌이 드문 상황              충돌이 잦은 상황
               (read 위주, 동시             (write 위주, 같은 데이터에
                접근하는 키가 다름)            동시에 write)

Optimistic     ✅ 최적                      ❌ 최악
               - 락 오버헤드 없음             - 계속 실패 → 재시도 폭풍
               - 처리량 최대                  - 재시도할수록 충돌 확률↑
                                             - livelock 위험

Pessimistic    ❌ 오버킬                     ✅ 최적
               - 불필요한 대기 발생            - 충돌 자체가 안 일어남
               - 처리량 낭비                  - 재시도 없음, 예측 가능

이걸 확률적으로 생각하면 더 명확하다:

충돌 확률이 p라고 하면,

Optimistic의 평균 시도 횟수 = 1/(1-p)

p = 0.01 (1%)   → 평균 1.01회 시도. 사실상 한 번에 성공.
p = 0.10 (10%)  → 평균 1.11회. 아직 괜찮다.
p = 0.50 (50%)  → 평균 2회. 절반이 실패하고 재시도.
p = 0.90 (90%)  → 평균 10회. 재시도가 재시도를 낳는 지옥.

그래서 설계할 때 “이 데이터에 동시에 write하는 빈도가 얼마나 되는가?“를 먼저 따져봐야 한다.

Redis에서의 Optimistic Locking: WATCH

Redis는 싱글 스레드다. 모든 명령어가 하나씩 순서대로 실행된다. 그러면 동시성 문제가 아예 없는 거 아닌가?

개별 명령어는 그렇다. INCR counter는 atomic하다. 하지만 “읽고 → 판단하고 → 쓰기” 같은 여러 명령어 조합은 atomic하지 않다.

클라이언트 A: GET counter  → 10
클라이언트 B: GET counter  → 10
클라이언트 A: SET counter 11
클라이언트 B: SET counter 11  ← 10 + 1 = 11이어야 하는데, 12가 되어야 맞음!

Redis가 싱글 스레드라서 각 명령어가 atomic인 건 맞지만, 클라이언트 A의 GET과 SET 사이에 클라이언트 B의 명령어가 끼어들 수 있다. Redis 서버 입장에서는 그냥 순서대로 GET(A) → GET(B) → SET(A) → SET(B) 처리한 것뿐이다.

이 문제를 해결하는 게 WATCH/MULTI/EXEC 패턴이다.

WATCH/MULTI/EXEC의 동작 원리

WATCH mykey          ← "이 키를 감시해줘"
val = GET mykey      ← 현재 값을 읽음 (10)
val = val + 1        ← 클라이언트 측에서 계산 (11)
MULTI                ← 트랜잭션 시작 (이후 명령어는 큐에 쌓임)
SET mykey 11         ← 큐에 쌓임 (아직 실행 안 됨)
EXEC                 ← 실행! 하지만...

EXEC 시점에 Redis가 확인하는 것:

WATCH 이후에 mykey가 다른 클라이언트에 의해 변경되었는가?

YES → EXEC 실패. nil 반환. 큐에 쌓인 명령어 전부 버림.
      클라이언트: "아, 충돌났구나" → 처음부터 다시 시도

NO  → EXEC 성공. 큐에 쌓인 명령어 전부 atomic하게 실행.

이게 정확히 optimistic locking이다. 교차로 비유로 돌아가면:

WATCH = 교차로에 진입하면서 "다른 차 있나?" 확인 시작
GET   = 길 상태 파악
MULTI = "나 지금 지나갈게" 선언
SET   = 지나가기
EXEC  = 최종 확인: "내가 확인한 이후로 다른 차 안 왔지?"
        → 안 왔으면 통과
        → 왔으면 후진해서 다시 시도

실제 코드로 보면

Python(redis-py)에서의 구현:

import redis

r = redis.Redis()

def safe_increment(key, max_retries=10):
    for attempt in range(max_retries):
        try:
            # pipeline을 transaction 모드로 생성
            pipe = r.pipeline()

            # WATCH: 이 키를 감시 시작
            pipe.watch(key)

            # 현재 값 읽기 (이건 즉시 실행됨, 아직 MULTI 전이니까)
            current = int(pipe.get(key) or 0)

            # MULTI: 여기서부터 큐잉 시작
            pipe.multi()

            # SET: 큐에 쌓임
            pipe.set(key, current + 1)

            # EXEC: 실행 시도
            pipe.execute()

            # 성공!
            return current + 1

        except redis.WatchError:
            # WATCH 이후에 다른 클라이언트가 key를 수정함
            # → 처음부터 다시
            continue

    raise Exception("Max retries exceeded")

WatchError가 발생하는 시나리오:

시간 →

클라이언트 A:  WATCH key → GET key (=10) → ... 계산 중 ...
클라이언트 B:                               SET key 15 ← 여기서 변경!
클라이언트 A:                                            MULTI → SET key 11 → EXEC
                                                         → WatchError! (key가 바뀌었으니까)
클라이언트 A:  WATCH key → GET key (=15) → MULTI → SET key 16 → EXEC → 성공 ✅

WATCH의 내부 구현은 어떻게 되어 있을까?

Redis 서버 내부에서 WATCH는 이렇게 동작한다:

Redis 서버 내부:

watched_keys: {
    "mykey": [클라이언트A의 연결, 클라이언트C의 연결],
    "otherkey": [클라이언트B의 연결]
}

누군가 "mykey"를 수정하는 명령어를 실행하면:
→ watched_keys["mykey"]에 등록된 모든 클라이언트에
  REDIS_DIRTY_CAS 플래그를 설정

클라이언트 A가 EXEC를 호출하면:
→ REDIS_DIRTY_CAS 플래그가 있는가?
   YES → 트랜잭션 abort, nil 반환
   NO  → 큐에 쌓인 명령어 실행

Redis가 싱글 스레드이기 때문에 이 flag 확인과 명령어 실행이 자연스럽게 atomic하다. 별도의 락이 필요 없다!

그러면 Redis에서 Pessimistic Locking은?

Redis 자체에는 SELECT FOR UPDATE 같은 pessimistic lock 명령어가 없다. 싱글 스레드 모델에서는 의미가 없기 때문이다.

하지만 Redis를 이용해서 분산 환경의 pessimistic lock을 구현할 수는 있다.

방법 1: SETNX 기반 분산 락

SET lock:resource1 "client-uuid-abc" NX PX 30000
│                                     │    │
│                                     │    └─ 30초 후 자동 만료 (데드락 방지)
│                                     └─ NX: 키가 없을 때만 SET (이미 있으면 실패)
└─ 락 키
클라이언트 A: SET lock:order42 "uuid-a" NX PX 30000 → OK (락 획득!)
클라이언트 B: SET lock:order42 "uuid-b" NX PX 30000 → nil (실패, 대기)
클라이언트 A: (작업 수행)
클라이언트 A: (락 해제 - Lua 스크립트로)
클라이언트 B: SET lock:order42 "uuid-b" NX PX 30000 → OK (이제 획득!)

이건 완전히 pessimistic이다. “내가 끝날 때까지 아무도 못 들어와.”

방법 2: Lua 스크립트

Lua 스크립트는 Redis에서 atomic하게 실행된다. 스크립트가 실행되는 동안 다른 어떤 명령어도 끼어들 수 없다.

-- 재고 차감 Lua 스크립트
-- 읽기 + 판단 + 쓰기가 한 덩어리로 atomic하게 실행
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1  -- 성공
else
    return 0  -- 재고 부족
end

이건 optimistic도 pessimistic도 아닌, 그냥 atomic이다. 신호등도 없고 교차로도 아니다. 아예 1차선 터널이라서 한 대씩만 들어가는 것이다.

Lua 스크립트:

[명령어1 → 명령어2 → 명령어3]  ← 이 사이에 아무것도 끼어들 수 없음
                                   Redis가 싱글 스레드니까!

WATCH vs Lua vs SETNX: 언제 뭘 쓰나?

상황                              추천 방식
──────────────────────────────────────────────────────
"읽고 → 계산 → 쓰기"              WATCH/MULTI/EXEC
간단한 CAS                        (또는 Redis 8.4의 SET IFEQ)

복잡한 조건부 로직                  Lua 스크립트
(if-else 분기가 여러 개)

긴 시간 동안 리소스 독점 필요        SETNX 기반 분산 락
(외부 API 호출 등 포함)

여러 Redis 인스턴스에서              Redlock 알고리즘
고가용성 락 필요

WATCH는 충돌이 드문 상황에서 가장 빛난다. Redis 공식 문서에서도 이걸 명시한다: “대부분의 경우 여러 클라이언트가 서로 다른 키에 접근하므로 충돌이 드물다 - 보통 재시도할 필요가 없다.”

Lua 스크립트는 만능처럼 보이지만 주의할 점이 있다. 스크립트가 실행되는 동안 Redis 전체가 block된다. 오래 걸리는 Lua 스크립트는 Redis의 이벤트 루프를 멈추는 것이나 다름없다. 그래서 Lua 스크립트는 짧고 단순하게 유지해야 한다.

더 넓은 관점: Optimistic/Pessimistic은 어디에나 있다

이 패턴은 DB나 Redis에만 국한되지 않는다.

Git:
- git merge → optimistic (각자 작업하다가 충돌 나면 그때 resolve)
- 파일 잠금 시스템 (SVN 등) → pessimistic (내가 편집 중이면 다른 사람 편집 불가)

HTTP:
- ETag / If-Match → optimistic (리소스가 변경되었으면 412 Precondition Failed)
- 분산 락 서비스 → pessimistic

Kubernetes:
- resourceVersion → optimistic (업데이트 시 version 불일치하면 409 Conflict)

Java:
- CAS (Compare-And-Swap) → optimistic (AtomicInteger 등)
- synchronized / ReentrantLock → pessimistic

ETag 예시를 보면 Redis WATCH와 구조가 같다:

1. GET /resource → 200 OK, ETag: "abc123"
2. 클라이언트가 수정
3. PUT /resource
   If-Match: "abc123"    ← "내가 읽었을 때 버전이 abc123이었는데, 아직도 그런가?"
4. 서버 확인:
   - 아직 abc123 → 200 OK (업데이트 성공)
   - 바뀌었음 → 412 Precondition Failed (다시 읽고 재시도)

정리

Optimistic과 pessimistic은 기술적 구현 세부사항이 아니라, 문제에 대한 태도다.

Optimistic은 “대부분은 괜찮을 거야"라고 가정한다. 충돌이 드문 환경에서 불필요한 대기를 없애서 처리량을 극대화한다. 하지만 충돌이 잦으면 재시도가 재시도를 낳는 악순환에 빠진다.

Pessimistic은 “누군가 건드릴 거야"라고 가정한다. 항상 락을 걸어서 충돌 자체를 방지한다. 예측 가능하고 안전하지만, 불필요한 대기가 처리량을 깎아먹는다.

Redis에서는 WATCH/MULTI/EXEC가 optimistic을, SETNX 기반 분산 락이 pessimistic을, Lua 스크립트가 “그냥 atomic하게 만들기"를 담당한다. 특히 WATCH는 Redis의 싱글 스레드 모델 덕분에 별도의 락 메커니즘 없이도 DIRTY_CAS 플래그 하나로 깔끔하게 동작한다.

어떤 방식을 쓸지는 충돌 확률에 달려 있다. 신호등이 필요한 강남 교차로인지, 필요 없는 시골길인지를 먼저 판단하자. 시골길에 신호등을 세우는 건 낭비고, 강남 교차로에서 신호등을 떼는 건 재앙이다.