← 모든 글

lock-free 를 우리가 선택하지 않은 케이스

동시성 버그를 만났을 때 lock-free를 선택하지 않은 이유. 문제 레이어, 디버깅 비용, 충돌률 1.2%의 수치로 낙관적 잠금과 Redis 락 조합을 선택한 HEDVION의 실전 판단 과정.

결제·정산 현장에서 동시성 버그는 다른 무게를 갖는다

일반적인 웹 애플리케이션에서 동시성 버그는 "가끔 응답이 느려진다" 수준에서 끝날 때가 많다. 하지만 결제와 정산을 직접 운영하는 팀에게 동시성 버그는 돈의 문제다. 잔액 음수, 이중 차감, 정산 집계 오차—이것들은 로그에 기록되고 나서야 발견되는 조용한 버그지만, 한 번 터지면 다음 정산 배치 전체에 오염을 퍼뜨린다. HEDVION에서는 자동화 정산이 일 단위로 돌아가기 때문에 오전 배치 실행 전까지 오차를 발견하지 못하면 당일 전체 정산을 롤백하거나 수동 조정에 들어가야 한다. 서비스 지연이 아니라 회계 마감 실패다.

우리가 직면했던 구체적인 시나리오는 이랬다. 피크 타임에 API 요청이 초당 수십 건 이상 몰리는 구간에서 동일한 가맹점 잔액 레코드에 복수의 서버 인스턴스가 동시에 차감 쓰기를 시도했다. 버전 관리가 없는 단순한 UPDATE wallet SET balance = balance - ? WHERE id = ? 쿼리는 레이스 컨디션 앞에서 속수무책이었다. 모니터링 알림이 올라온 건 해당 배포 후 3일째였고, 그 사이 잔액 오차가 발생한 트랜잭션은 12건이었다. 작은 숫자처럼 보일 수 있다. 하지만 결제·정산 회계에서 12건의 불일치는 감사 대응 실패와 직결되고, 정산 파트너와의 신뢰 문제로 번진다.

lock-free가 매력적으로 보였던 이유

문제를 처음 논의하는 자리에서 팀 안에 "lock-free 큐를 써보자"는 제안이 나왔다. 논리는 충분히 타당했다. CAS(Compare-And-Swap) 기반의 원자적 연산은 전통적인 mutex 락 없이도 스레드 간 정합성을 보장하며, 락 경합(lock contention)으로 인한 스로틀링이 없으니 고처리량 환경에서 이론적으로 더 나은 처리량을 낸다. Java의 AtomicLong, ConcurrentLinkedQueue, C++의 std::atomic이 내부적으로 이 방식을 활용하는 이유도 여기에 있다.

lock-free가 실제로 빛을 발하는 시나리오는 분명히 존재한다. 단일 프로세스 내에서 수백 개의 스레드가 공유 카운터를 동시에 갱신하는 경우가 대표적이다. mutex로 직렬화하면 컨텍스트 스위칭 오버헤드가 선형으로 증가하는 반면, CAS는 하드웨어 레벨에서 원자성을 보장하기 때문에 경합이 과도하지 않은 상황에서 유의미한 성능 이점이 있다. 고성능 메시지 브로커, 멀티코어 환경의 링 버퍼 구현에 lock-free가 많이 쓰이는 이유다. 제안이 나온 맥락 자체는 충분히 이해할 수 있었고, 그래서 더 신중하게 검토해야 했다.

우리가 lock-free를 선택하지 않은 세 가지 이유

첫째, 문제가 존재하는 레이어가 달랐다. lock-free 자료구조가 다루는 경쟁은 단일 프로세스 메모리 안의 스레드 간 경쟁이다. 우리 문제는 그 바깥, 즉 네트워크 너머에 있었다. 결제 API 서버는 오토스케일링 구성으로 N개 인스턴스가 동시에 떠 있고, 공유 상태는 MySQL DB 레코드다. 애플리케이션 레이어에서 아무리 정교하게 CAS를 구현해도, 네트워크 건너편의 DB 레코드가 여러 인스턴스에서 동시에 수정되는 상황은 막을 수 없다. 이건 알고리즘 선택의 문제가 아니라 아키텍처 레이어 선택의 문제였다. 엉뚱한 레이어에 정교한 해법을 투입하는 건, 방에 새 이중창을 달면서 지붕 구멍은 그대로 두는 것과 같다.

둘째, 디버깅 비용이 팀의 현실적 역량을 초과한다. lock-free 알고리즘을 올바르게 구현하는 것은 생각보다 훨씬 어렵다. ABA 문제(포인터가 A→B→A로 변경됐을 때 CAS가 변경을 감지하지 못하는 현상), 메모리 오더링(acquire/release/seq_cst 중 어느 것을 쓰느냐에 따라 다른 CPU 아키텍처에서 결과가 달라짐), 재시도 루프의 라이브락(livelock) 가능성까지 엣지 케이스가 연쇄된다. 이 가운데 하나라도 잘못 짚으면 결제 정합성이 깨진다. 소수 팀이 서비스를 동시에 운영하면서 이 수준의 복잡성을 올바르게 관리하려면 전담 리뷰어와 충분한 격리 테스트 시간이 필요하다. 현실적으로 두 가지 다 확보하기 어려웠다.

셋째, 이미 충분한 도구가 있었다. 문제를 정확히 정의하고 나니 선택지는 좁혀졌다. DB 수준의 낙관적 잠금(Optimistic Locking)과, 충돌 빈도가 높은 핫 계정에만 선별적으로 적용하는 Redis 분산 락이었다. 이 조합은 구현이 단순하고, 코드를 처음 읽는 사람도 동작을 즉시 이해할 수 있다. 새벽 3시에 장애 알림을 받고 코드를 열었을 때 바로 파악할 수 있어야 한다는 기준을 통과했다. lock-free는 그 기준을 통과하지 못했다.

실제 구현: 낙관적 잠금과 Redis 분산 락의 역할 분담

낙관적 잠금의 구현은 단순하다. 잔액 레코드에 version 컬럼을 추가하고, 차감 쿼리에 WHERE id = ? AND version = ? 조건을 포함한다. 반환된 업데이트 행 수가 0이면 다른 트랜잭션이 먼저 수정한 것으로 판단하고 재시도한다. 재시도 횟수는 최대 3회로 고정하고, 초과 시 명시적인 실패 응답을 반환한다.

UPDATE wallet
SET balance = balance - ?, version = version + 1
WHERE id = ? AND version = ? AND balance >= ?

이 쿼리 한 줄에 세 가지 안전장치가 모두 담겨 있다. 버전 충돌 방지, 잔액 음수 방지(balance >= ?), 그리고 DB 엔진 레벨의 원자적 감소. 재시도 로직은 애플리케이션 레이어에서 명시적으로 처리하며, 재시도 횟수와 실패 이유를 로그에 남긴다. Redis 분산 락은 낙관적 잠금만으로 충돌이 과도하게 반복되는 핫 계정 케이스에만 적용했다. SET NX PX 명령으로 TTL을 두고, 잠금 획득 실패 시 즉시 429 응답을 반환하거나 내부 처리 큐에 넣어 순차 처리한다. Redis 락의 적용 대상은 코드에 명시적으로 주석과 함께 문서화했다—범위가 암묵적으로 퍼지는 것을 막기 위해서다.

수치로 본 트레이드오프: 낙관적 잠금이 언제 깨지는가

우리 서비스 기준으로, 피크 타임에도 동일 레코드에서 발생하는 낙관적 잠금 충돌 비율은 전체 트랜잭션의 약 1.2%였다. 이 충돌률에서 재시도 1회 이내로 처리가 완료되는 비율은 98.5%이며, 평균 응답 지연 증가는 약 12ms 수준이었다. 비관적 잠금(pessimistic locking)으로 모든 요청을 직렬화했을 때 예상되는 추가 지연인 40~80ms와 비교하면 현실적으로 합리적인 선택이었다.

단, 낙관적 잠금에는 명확한 한계가 있다. 충돌률이 15~20% 이상으로 올라가는 시나리오에서는 재시도 비용이 급격히 증가하고, 재시도가 재시도를 부르는 양성 피드백 루프가 형성될 수 있다. 특히 이벤트 프로모션처럼 단기간에 동일 가맹점에 트래픽이 폭증하는 케이스가 여기에 해당한다. 이 임계점을 실시간으로 추적하기 위해 계정별 충돌률 메트릭을 쌓고 있으며, 단일 계정의 충돌률이 5%를 초과하면 자동으로 Redis 락 대상으로 전환하는 로직을 추가했다. "저충돌 환경에선 낙관적 잠금, 고충돌 환경에선 직렬화"가 우리의 기본 원칙이 됐다.

지금 바로 써먹을 시사점

결제·정산·자동화 시스템에서 동시성 문제를 만났을 때 아래 순서로 접근하자.

① 문제 레이어를 먼저 확정하라. 단일 프로세스 내 스레드 경쟁인가, 복수 인스턴스와 공유 DB 사이의 경쟁인가? 전자라면 lock-free나 채널 기반 설계를 검토할 만하다. 후자라면 DB 수준 잠금이나 분산 락이 더 적합하다. 레이어를 틀리면 아무리 정교한 구현도 문제를 해결하지 못한다.

② 낙관적 잠금을 디폴트로 시작하라. version 컬럼 추가와 WHERE version = ? 조건 하나로 대부분의 저충돌 시나리오를 커버할 수 있다. 구현 시간 30분, 코드 리뷰 부담은 거의 없다. 복잡한 알고리즘을 검토하기 전에 이 한 줄을 먼저 시도해야 한다.

③ 충돌률을 반드시 계측하라. 재시도가 얼마나 자주 발생하는지, 어느 계정에서 집중되는지를 메트릭으로 쌓아야 현재 전략이 여전히 적합한지 판단할 수 있다. 충돌률 임계값(우리는 5%로 설정)을 미리 정하고, 넘으면 자동 알림과 대응 전략을 준비해두어라.

④ Redis 락의 적용 범위를 명시적으로 제한하라. 편의성 때문에 무분별하게 확산시키면 Redis 장애가 결제 흐름 전체를 멈추는 단일 장애점이 된다. 어느 계정, 어느 엔드포인트에 적용했는지 코드와 문서에 명시적으로 관리하라. TTL 설계와 Redis 장애 시 폴백도 반드시 함께 정의해야 한다.

⑤ "기술적으로 정교한 것"과 "우리 팀이 유지보수할 수 있는 것"을 분리해서 판단하라. lock-free가 잘못된 기술은 아니다. 올바른 레이어에, 올바른 팀 규모와 함께 쓰인다면 강력한 도구다. 하지만 문제의 실제 경계, 팀의 유지보수 역량, 장애 대응 속도를 종합적으로 고려했을 때 더 단순한 도구가 더 나은 선택인 경우가 많다. 결제 시스템에서 "이해하기 어려운 코드"는 곧 "고칠 수 없는 버그"다.

— by mings

* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

📚 추천 강의
한 입 크기로 잘라먹는 바이브코딩 (with Claude Code)
Claude Code로 바이브코딩, 개발자라면 꼭 들어야 할 필수 강의
강의 보러가기 →

* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.