← 모든 글

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

동시성 문제를 마주쳤을 때 lock-free 자료구조를 검토했지만 결국 선택하지 않은 이유와 대신 선택한 접근법을 공유한다.

동시성 문제가 발생한 맥락

여러 요청이 동시에 같은 잔액을 차감하는 시나리오가 있었다. 순간 트래픽이 몰리면 잔액이 음수가 되거나 이중 차감이 발생하는 버그가 간헐적으로 재현됐다.

이 문제를 처음 분석했을 때 팀 내에서 “lock-free 큐를 쓰면 어떨까”라는 의견이 나왔다. CAS(Compare-And-Swap) 기반으로 원자적 연산을 보장하면 락 없이도 정합성을 유지할 수 있다는 논리였다.

결론부터 말하면 우리는 lock-free를 선택하지 않았다.

선택하지 않은 이유

첫 번째 이유: 문제의 경계가 애플리케이션 레이어를 넘는다.

lock-free 자료구조는 단일 프로세스 내에서 스레드 간 경쟁을 다룰 때 강점이 있다. 그러나 우리가 직면한 문제는 복수의 서버 인스턴스와 DB가 얽혀 있었다. 애플리케이션 레이어에서 아무리 정교하게 CAS를 구현해도 DB 레코드가 복수 인스턴스에서 동시에 쓰이는 상황을 막을 수 없다.

두 번째 이유: 디버깅 비용이다.

lock-free 알고리즘은 올바르게 구현하기 어렵다. ABA 문제, 메모리 순서(ordering), 재시도 루프의 무한 반복 가능성 등 신경 써야 할 엣지 케이스가 많다. 팀 규모가 작고 서비스를 동시에 운영하는 상황에서 이 복잡성을 감당하는 것이 합리적이지 않다고 판단했다.

세 번째 이유: 이미 충분한 도구가 있었다.

문제를 해결하기 위해 실제로 선택한 것은 DB 수준의 낙관적 잠금(optimistic locking)과, 특정 임계 구역에 한해 Redis 분산 락이었다. 낙관적 잠금은 충돌이 드문 경우 성능 손실 없이 정합성을 보장한다. Redis 락은 구현이 단순하고 분산 환경에서 명확하게 작동한다.

실제로 적용한 구조

잔액 차감 흐름은 다음 순서로 동작한다.

DB에서 현재 잔액을 읽을 때 버전 번호를 함께 가져온다. 차감 쿼리는 WHERE id = ? AND version = ? 조건을 포함하며, 업데이트 건수가 0이면 충돌로 판단해 재시도한다. 재시도 횟수에 상한을 두고 초과 시 실패로 처리한다.

이 방식은 코드를 읽는 사람이 동작을 바로 이해할 수 있다는 장점이 있다. lock-free 알고리즘처럼 내부 메모리 모델을 알아야만 이해할 수 있는 코드가 아니다.

배운 것

성능 최적화나 동시성 해법을 선택할 때 “기술적으로 더 정교한 것”이 항상 옳은 선택은 아니다. 팀이 유지보수할 수 있고, 문제의 실제 경계에 맞게 동작하고, 디버깅 가능한 것을 선택하는 것이 장기적으로 낫다.

— by mings