개발 공부/프로젝트

동시성 제어와 성능 최적화, 왜 Redis 분산 락이었을까?

baby-t 2026. 3. 10. 11:16

1. 들어가며: 가상 자산 거래소와 동시성 문제의 만남

가상 자산 거래소 플랫폼을 개발하면서 가장 중요하게 생각한 것은 **'데이터의 정합성(Data Integrity)'**이었습니다. 특히 매수/매도 주문은 사용자의 '돈'과 직결되는 아주 민감한 비즈니스 로직입니다.

기능 구현을 마치고 JUnit을 통해 데이터 정합성 테스트를 진행하던 중, 아주 아찔한 상황을 마주했습니다.

2. 문제 상황: 1번만 성공해야 할 주문이 10번이나 성공하다?

사용자가 딱 1번만 매수할 수 있는 잔액을 가진 상태에서, 악의적으로(혹은 시스템 오류로) 동시에 100번의 매수 주문을 요청하는 부하 테스트 시나리오를 작성했습니다.

  • 기대 결과(Expected): 1번 성공, 99번 실패 (잔액 부족)
  • 실제 결과(Actual): 10번 성공 (잔액이 마이너스가 되는 현상 발생)

이것이 바로 전형적인 **경쟁 상태(Race Condition)**였습니다. 여러 스레드가 동시에 DB의 잔액 데이터를 읽고 수정하려고 하면서, 갱신 손실(Lost Update)이 발생한 것입니다.

3. 해결 방안 탐색: 왜 하필 Redis였을까?

이 동시성 문제를 해결하기 위해 여러 가지 방법을 고민했습니다.

  • Java synchronized 키워드: 가장 구현이 쉽지만, 단일 서버의 메모리 단에서만 동작합니다. 향후 Docker 컨테이너 기반으로 서버를 스케일 아웃(Scale-out) 할 확장성까지 고려했기 때문에 이 방법은 기각했습니다.
  • DB 락 (비관적 락 / 낙관적 락):
    • 비관적 락(Pessimistic Lock): DB의 Row 자체에 락을 걸어 확실하지만, 트래픽이 몰릴 경우 데드락(Deadlock)의 위험이 있고 전반적인 DB 성능 저하를 유발할 수 있습니다.
    • 낙관적 락(Optimistic Lock): 충돌이 잦은 '주문' 도메인의 특성상, 충돌 발생 시 개발자가 직접 재시도 로직을 구현해야 하며 이 과정에서 DB에 지속적인 부하를 줄 수 있습니다.

결론: Redis 분산 락 (Distributed Lock) 결국 In-Memory 기반으로 DB에 가해지는 부하를 줄이면서, 다중 서버 환경에서도 안전하게 동시성을 제어할 수 있는 Redis 분산 락을 선택했습니다. 그중에서도 Spin Lock 방식(지속적인 락 획득 시도로 부하 발생)의 Lettuce 대신, Pub/Sub 방식을 사용하여 Redis 부하를 최소화할 수 있는 Redisson 라이브러리를 채택했습니다.

4. 적용 및 문제 해결: Redisson Facade 패턴 도입

비즈니스 로직과 락 획득 로직이 섞이는 것을 방지하기 위해 Facade 패턴을 적용하여 코드를 분리했습니다.

tryLock을 통해 락 획득 대기 시간과 유지 시간을 설정하여, 무한 대기 상태(Deadlock)에 빠지는 것을 방지했습니다.

5. 결과 검증 및 보너스 (조회 성능 최적화)

코드를 리팩토링한 후 동일하게 100번의 동시 주문 테스트를 진행했습니다.

결과는 대성공이었습니다. 분산 락을 통해 잔액 부족 시의 주문 처리를 완벽하게 방어해 내며 **데이터 정합성 100%**를 확보할 수 있었습니다.

추가적으로, Redis를 인프라에 도입한 김에 Upbit API로 1초마다 받아오는 실시간 가상 자산 시세 정보에 Look-aside 캐싱 전략을 적용하여 DB 조회 부하까지 함께 줄이는 1석 2조의 효과를 얻었습니다.

6. 마무리하며

이번 트러블 슈팅을 통해 단순히 기능을 구현하는 것을 넘어, 다중 스레드 환경에서 데이터 무결성을 지키는 아키텍처적 고민을 할 수 있었습니다. 하지만 동기 방식의 한계로 인해 트래픽이 1,000명 단위로 몰렸을 때 락 획득을 대기하느라 응답 속도가 현저히 느려지는 새로운 병목 현상을 발견하게 되었습니다.

다음 포스팅에서는 이 성능 병목을 타개하기 위해 **Kafka 비동기 처리(Event-Driven)**를 도입한 과정을 다뤄보겠습니다.