낙관적 락이 통한 건 충돌이 드물어서가 아니다
출처: 뱅크샐러드 기술블로그 — 뱅크샐러드가 게임을 만들 때 데이터 정합성을 유지하는 법 (feat. 낙관적 락)
방치형 앱테크 게임 ‘일해라 김뱅샐’에서 현금성 재화의 갱신 손실을 막기 위해 낙관적 락을 고른 기록이다. 원문을 읽고 정리한 내용과 내 생각을 함께 적는다.
문제
‘일해라 김뱅샐’은 캐릭터를 키우며 게임 내 재화인 샐(SAL)을 모으는 서비스다. 이 SAL이 실제 원화로 전환된다. 그래서 캐릭터 상태는 게임 데이터인 동시에 잔액이다.
핵심 테이블인 character_state를 건드리는 경로가 하나가 아니었다. 유저가 스킬을 쓰거나 쓰레기를 치우는 API 호출이 있고, 운영자가 CS 처리나 이벤트 보상으로 백오피스에서 잔액을 직접 조정하는 경로가 있고, 앞으로 붙을 배치와 비동기 이벤트가 있다. 원문은 마지막 것을 “예방적 설계”라고 부르며 미리 후보에 넣어뒀다.
여기서 갱신 손실이 난다. 원문이 든 예시는 이렇다. 유저가 스킬로 200 SAL을 얻으려고 현재 잔액 1,000을 읽는다. 같은 찰나에 운영자가 CS 보상 500을 주려고 역시 1,000을 읽는다. 유저 쪽이 1,200으로 쓰고, 운영자 쪽이 1,500으로 쓴다. 유저가 얻은 200은 사라지고, 1,700이어야 할 잔액이 1,500이 된다.
읽고-계산하고-쓰는 사이에 남이 끼어들면 나중에 쓴 쪽이 이긴다. 흔한 문제고, 흔한 만큼 답도 정해져 있다고 생각하기 쉽다.
분석
원문은 세 가지 후보를 놓고 비교한다.
| 방식 | 동작 | 비용 |
|---|---|---|
| 비관적 락 | SELECT ... FOR UPDATE로 행을 선점 | 락 대기, 대규모 트래픽에서 DB 커넥션 병목 |
| 분산 락 | Redis나 ZooKeeper에 잠금 권한을 기록 | 통신 오버헤드, 별도 인프라 운영 비용 |
| 낙관적 락 | 업데이트 시점에 WHERE 조건으로 버전 일치 검사 | 충돌 시 애플리케이션이 처리해야 함 |
낙관적 락을 고른 이유로 원문은 넷을 든다. 유저별로 데이터가 격리돼 있어 충돌 확률이 낮다. update_version 컬럼 하나면 되니 인프라가 안 늘어난다. 락 대기가 없어 게임 반응성을 지킬 수 있다. 그리고 서비스 구조상 재시도 로직이 필요 없다.
순서대로 읽으면 첫 번째가 결정적으로 보인다. 충돌이 드무니까 낙관적으로 가도 된다 — 교과서에도 그렇게 적혀 있다. 그런데 절반짜리 논거다.
낙관적 락의 실질 비용은 충돌 자체가 아니다. 충돌한 뒤에 무엇을 할지 정하는 코드다. 재시도할 것인가, 몇 번까지 할 것인가, 실패로 확정한다면 사용자에게 뭐라고 말할 것인가, 그 사이 중복 처리는 어떻게 막을 것인가. 충돌률이 0.1%여도 이 분기는 100% 작성해야 한다. 드물다는 건 이 코드가 자주 실행되지 않는다는 뜻이지, 없어도 된다는 뜻이 아니다. 오히려 드물수록 검증이 어려워서 더 나쁠 때도 있다.
비관적 락이 무거운 대신 주는 게 바로 이 분기의 부재다. 대기하다가 순서대로 통과하니 애플리케이션은 충돌이라는 사건을 볼 일이 없다. 낙관적 락은 그 처리를 DB에서 애플리케이션으로 옮긴다. 성능을 얻는 대신 분기 처리를 떠안는 거래다.
그러니 낙관적 락 도입에 치른 값이 적었다면, 봐야 할 건 충돌 확률이 아니라 저 네 번째 이유다.
해결
구현 자체는 평범하다. 원문은 업데이트가 잦은 컬럼을 character_state로 떼어냈다. 테이블을 나누면 인덱스 리프 노드에서 겹치는 범위가 줄어든다는 이유다.
CREATE TABLE `character_state` (
`character_state_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '캐릭터 상태 ID',
`character_id` BIGINT NOT NULL,
`current_sal` BIGINT NOT NULL COMMENT '경험치이자 자산인 SAL',
`current_energy` BIGINT NOT NULL COMMENT '캐릭터가 돈을 벌어들일 수 있는 에너지(초 단위)',
`update_version` BIGINT NOT NULL COMMENT '동시성 제어를 위한 버전 필드',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`character_state_id`),
UNIQUE KEY `idx_character_u1` (`character_id`)
) ENGINE = InnoDB;
갱신은 읽어둔 버전을 WHERE에 넣는 CAS다.
numUpdated, err := mysql.CharacterStates(
mysql.CharacterStateWhere.CharacterID.EQ(characterID),
mysql.CharacterStateWhere.UpdateVersion.EQ(updateVersion), // 버전 체크!
).UpdateAll(ctx, exec, m)
if numUpdated == 0 {
return 0, ErrConcurrentStateUpdate // 충돌 감지
}
여기까지는 어느 프로젝트에 붙여도 같은 코드가 나온다. 진짜 차이는 ErrConcurrentStateUpdate를 받은 쪽에 있다. 뱅크샐러드는 여기서 아무것도 하지 않는다.
아무것도 하지 않아도 되는 이유는 보상 계산식에 있다.
Reward = (T_current - T_last_update) × Rate_per_second
수익을 클릭 이벤트마다 적립하는 게 아니라, 마지막 갱신 시각부터 지금까지 흐른 시간에 초당 비율을 곱해서 그 자리에서 다시 계산한다. 갱신이 실패하면 updated_at도 갱신되지 않는다. 그래서 다음 액션이 성공할 때 실패한 기간까지 한꺼번에 정산된다. 원문의 표현으로는 실패가 유실이 아니라 “보상 확정 시점의 일시적인 이월”이다.
이게 락 이야기가 아니라는 점이 중요하다. 이월이 가능한 건 상태를 증분의 합이 아니라 시각 기준의 재계산 결과로 정의했기 때문이다. 같은 서비스를 “이번 액션으로 200을 더한다”는 증분 모델로 짰다고 해보자. CAS가 실패하는 순간 그 200은 어디에도 남지 않는다. 유저는 스킬을 썼는데 보상이 없다. 그러면 재시도는 선택이 아니라 의무가 되고, 재시도를 넣는 순간 중복 지급을 막을 멱등키가 따라오고, 몰린 재시도가 서로를 밀어내지 않게 백오프가 따라온다.
즉 낙관적 락이 싸게 먹힌 이유는 충돌이 드물어서가 아니라 충돌 뒤에 할 일이 없어서다. 그리고 할 일이 없게 만든 건 락 선택이 아니라 도메인 모델링이다. 원문이 이걸 마지막 이유로 배치했지만, 무게로 보면 앞의 셋을 합친 것보다 크다.
일반화하면 이렇다. 상태를 “지금까지의 누적 결과”로 다시 계산할 수 있는 도메인이면 실패가 이월로 흡수되고, 낙관적 락은 버전 컬럼 하나 값만 치르면 된다. 상태가 “매 요청의 증분 합”으로만 정의되는 도메인이라면 — 결제 승인, 주문 생성, 포인트 차감 같은 것들 — 같은 선택이 재시도와 멱등키와 백오프를 전부 데려온다. 락 기술은 같은데 청구서가 다르다.
버전 필드는 왜 정수여야 하나
updated_at이 이미 있는데 굳이 컬럼을 하나 더 두는 게 아깝게 보일 수 있다. 원문은 두 가지를 든다. DB 타임스탬프가 밀리초 단위면 같은 밀리초 안에서 일어난 두 갱신을 구분하지 못한다. 그리고 분산 환경에서는 서버 간 시계 차이가 논리적 선후 관계를 흐린다.
시각은 “언제인가”를 말하고 버전은 “몇 번째 상태인가”를 말한다. 앞의 값은 같아질 수도 있고 뒤로 갈 수도 있지만, 뒤의 값은 갱신될 때마다 반드시 하나씩 는다. 비교가 항상 성립하는 쪽을 골라야 한다.
리플리카를 읽으면 전제가 깨진다
낙관적 락은 “내가 읽은 버전이 그 시점의 최신”이라는 전제 위에서 돈다. Primary와 Replica 사이에 복제 지연이 있는데 Replica를 읽으면 이 전제가 무너진다. 이미 낡은 버전을 들고 CAS를 시도하니 영향 행 수는 0이 나온다. 충돌이 아니라 그냥 매번 실패다. 원문이 정합성이 중요한 경로에서 Primary 조회를 강제하라고 적은 이유다. 락은 잘못되지 않았는데 읽기 경로 하나 때문에 낙관적 락이 비관적 락보다 느려지는 상황이 나올 수 있다.
결과
원문이 밝힌 성과는 런칭 이후 데이터 정합성 이슈 0건, 유저가 1원(SAL)의 오차도 없이 보상을 받고 있다는 것이다. 트래픽 규모나 실제 충돌 빈도는 공개되지 않아 이 결과를 다른 조건에 그대로 옮기기는 어렵다.
더 눈에 남는 건 글 후반이다. 앞에서 재시도가 필요 없다고 한 글이 뒤에서는 재시도 4원칙을 정리한다. Primary에서 최신 버전을 다시 읽을 것, 지수 백오프에 지터를 섞을 것, request_id로 멱등성을 보장할 것, 최대 재시도 횟수를 제한할 것.
모순이 아니다. 증거에 가깝다. 재시도가 필요 없었던 건 낙관적 락이 그래서가 아니라 보상 계산 방식이 실패를 이월로 바꿔줬기 때문이고, 그 방식이 통하지 않는 자리에서는 비용이 그대로 청구된다. 이벤트 핸들러는 메시지에 실린 버전과 DB 버전을 대조하고 전파를 기다려야 하며, 클라이언트가 응답을 못 받고 다시 보내는 요청은 시간 차분으로 흡수되지 않으니 멱등성 테이블이 필요하다. 4원칙은 낙관적 락 일반에 대한 조언이면서, 동시에 이 팀이 어디까지는 공짜였고 어디부터는 아니었는지를 보여주는 목록이다.
인상 깊었던 점
동시성 문제를 만나면 나는 대체로 후보를 락 목록 안에서 고른다. 비관적이냐 낙관적이냐, DB냐 Redis냐. 이 글이 뒤집은 건 그 선택지의 폭을 결정하는 게 락 기술이 아니라는 점이다. 상태를 어떻게 정의했느냐가 먼저 정해지고, 그에 따라 어떤 락이 감당 가능한지가 갈린다. 시간 차분 정산을 고른 순간 낙관적 락은 이미 부담 없는 선택이 돼 있었다.
그래서 순서를 바꿔 물어야 한다. 어떤 락을 쓸까 이전에, 이 상태를 재계산 가능한 형태로 정의할 수 있는가를 먼저 본다. 재계산이 가능하면 실패가 치르는 대가가 내려가고, 대가가 내려가면 가벼운 도구로도 통한다. 불가능한 도메인이라면 그건 그것대로 중요한 정보다. 낙관적 락을 고르되 재시도와 멱등키까지 예산에 넣어야 한다는 뜻이니까.
이 블로그에 락으로 막지 말고, 경합이 생기지 않게 나눠라를 쓴 적이 있다. 카카오페이는 Kafka Record Key에 userId를 넣어 같은 사용자의 건을 한 컨슈머로 모았다. 경합 자체가 생기지 않게 배치를 바꾼 것이다. 뱅크샐러드는 반대로 경합을 그냥 허용한다. 대신 충돌해서 진 요청이 손해로 남지 않게 상태 정의를 바꿨다. 한쪽은 부딪히지 않게 줄을 세웠고 다른 쪽은 부딪혀도 잃을 게 없게 만들었으니 방향은 정반대인데, 둘 다 락을 더 정교하게 만드는 대신 락 바깥을 손봤다는 점은 같다. 락은 충돌한 뒤에 조정하는 장치일 뿐이고, 조정 비용을 줄이는 일은 대체로 락 밖에서 일어난다.