이번 프로젝트에서는 실시간 입찰 기능을 구현했습니다.
입찰 기능은 단순히 가격만 변경하는 작업이 아니었습니다. 사용자가 입찰에 성공하면 신규 입찰자의 페이를 차감해야 하고, 기존 최고 입찰자에게는 페이를 환불해야 합니다. 또한 해당 거래글에 참여 중인 사용자들에게 새로운 입찰이 발생했다는 알림도 전달해야 했습니다.
즉, 하나의 입찰 요청 안에는 다음과 같은 작업들이 포함되어 있었습니다.
서비스 특성상 금전 데이터가 포함되어 있었기 때문에 정합성이 매우 중요했고, 초기에는 가장 안정적인 방법인 DB 비관적 락(Pessimistic Lock) 기반으로 구현했습니다.
초기 구조에서는 거래글(Row)에 비관적 락을 획득한 뒤 입찰 전체 과정을 하나의 트랜잭션 안에서 처리했습니다.
입찰 요청이 들어오면 먼저 현재 거래글을 락으로 보호하고, 최고 입찰 정보를 조회한 뒤 환불, 차감, 입찰 저장까지 순차적으로 수행하는 구조였습니다.
이 방식의 장점은 명확했습니다.
반면 입찰 처리 과정 전체가 하나의 락 구간 안에 존재한다는 특징도 가지고 있었습니다.
즉, 최고가 비교뿐 아니라 환불, 차감, 입찰 저장까지 모두 동일한 락 범위 안에서 수행되고 있었습니다.
기능 구현 이후 동시 입찰 상황을 가정한 부하 테스트를 진행했습니다.

| KPI | A) DB 비관적 락 |
|---|---|
| 성공률 | ⚠️ 50.00% |
| 처리량 (TPS) | 🐌 14.35 req/sec |
| 95% 응답 시간 | 🐢 60,037 ms (60초) |
| 평균 응답 시간 | 🐢 31,863 ms (32초) |
동시 스파이크로 1,000명의 사용자가 입찰을 진행했을 시, 평균 응답 시간은 32초였고, 성공률은 50%밖에 되지 않았습니다. DB에서 절반의 요청에서 세션 타임아웃이 발생한 것입니다.
테스트 과정에서 확인한 문제는 생각보다 단순했습니다.
입찰 과정에서 실제로 경쟁이 발생하는 구간은 매우 짧았습니다.
실질적으로 동시성 충돌이 발생하는 부분은 다음 두 가지뿐입니다.
하지만 기존 구조에서는 이 두 작업뿐 아니라,
까지 모두 동일한 락 구간 안에서 처리되고 있었습니다.
결과적으로 요청 수가 증가할수록 락 대기 시간이 길어졌고, 동시성이 높은 상황에서는 입찰 처리 성능이 급격히 저하되는 현상이 발생했습니다.
문제의 본질은 데이터베이스 자체가 아니라,
경쟁이 발생하는 핵심 구간과 그렇지 않은 구간이 분리되어 있지 않다는 점
이었습니다.
병목을 분석한 결과, 모든 로직을 Redis로 옮길 필요는 없다고 판단했습니다.
실제로 동시성 경쟁이 집중되는 구간은 다음 두 가지뿐이었습니다.
반면 아래 작업들은 성능보다 정합성이 중요한 영역입니다.
따라서 구조를 다음과 같이 분리했습니다.
즉,
경쟁이 집중되는 Hot Path는 Redis,
최종 데이터 확정은 DB
라는 구조로 역할을 나누었습니다.
입찰 경쟁 구간은 여러 사용자가 동시에 접근하는 영역이므로 원자성이 반드시 보장되어야 합니다.
이를 위해 Redis에서는 Lua Script를 사용했습니다.
Lua Script는 여러 Redis 명령을 하나의 연산처럼 실행할 수 있기 때문에,
를 하나의 원자적 연산으로 처리할 수 있습니다.
이를 통해 다수의 사용자가 동시에 입찰하더라도 최고가 경쟁 과정에서 발생할 수 있는 Race Condition을 방지할 수 있었습니다.
Redis를 사용한다고 해서 단순히 값을 갱신하는 것으로 끝나지는 않습니다.
입찰은 여러 사용자가 동시에 경쟁하는 환경이기 때문에 상태 변경 시점에 대한 관리가 필요했습니다.
이를 위해 현재 구조에서는 입찰 상태마다 별도의 버전을 관리합니다.
최고가가 갱신될 때마다 버전이 증가하고, 이후 후속 처리 과정에서는 자신이 처리하려는 상태가 현재 버전과 일치하는지 확인합니다.
이 방식은 일반적으로 CAS(Compare-And-Set) 패턴으로 알려져 있습니다.
CAS를 도입함으로써 다음과 같은 특성을 확보할 수 있었습니다.
즉, 여러 입찰이 동시에 발생하더라도 이전 작업이 최신 상태를 덮어쓰는 문제를 방지할 수 있게 되었습니다.
구조를 개선하면서 가장 크게 바뀐 부분은 "입찰 성공"의 기준이었습니다.
초기에는 Redis에서 최고가 갱신이 성공하면 사실상 입찰이 성공한 것으로 볼 수 있었습니다.
하지만 현재 구조에서는 그렇게 판단하지 않습니다.
Redis는 어디까지나 경쟁을 빠르게 조정하기 위한 계층일 뿐이며, 실제 입찰 확정은 DB 기준으로 이루어집니다.
현재 구조에서는 Redis에서 최고가 경쟁을 통과한 이후에도 별도의 확정 단계를 수행합니다.
이 과정에서
등을 다시 검증한 뒤 최종 입찰을 확정합니다.
즉,
Redis는 경쟁을 조정하고,
DB는 최종 상태를 확정한다.
라는 원칙을 적용했습니다.
입찰 처리 과정에는 금전 처리 외에도 다양한 부가 작업이 존재합니다.
대표적으로
등이 있습니다.
이러한 작업들은 중요하지만 입찰 자체의 정합성을 결정하는 요소는 아닙니다.
따라서 현재 구조에서는 입찰 확정 이후 이벤트를 발행하고, 후속 작업은 별도의 이벤트 핸들러에서 처리하도록 분리했습니다.
또한 이벤트 소비 시점도 트랜잭션 커밋 이후로 제한하여, 입찰이 정상적으로 확정된 경우에만 후속 처리가 실행되도록 구성했습니다.
이를 통해
를 달성할 수 있었습니다.

| KPI | 개선 후 | 개선 효과 |
|---|---|---|
| 성공률 | 🏆 100% | 에러율 0% |
| 처리량 (TPS) | 🚀 91.58 req/sec | 6.4배 (538%) 증가 |
| 95% 응답 시간 | ⏱️ 128 ms (0.13초) | 469배 (99.79%) 단축 |
| 평균 응답 시간 | ⏱️ 86 ms (0.09초) | 370배 (99.73%) 단축 |
동일한 환경에서 부하 테스트를 재진행한 결과 TPS가 약 6.4배 증가하였고, 에러율도 0%로 감소한 것을 확인하였습니다.
이번 개선 작업의 핵심은 Redis를 도입한 것 자체가 아니었습니다.
더 중요한 것은
어떤 작업이 실제 경쟁 구간인지,
어떤 작업이 최종 정합성을 책임지는 영역인지
를 구분하는 것이었습니다.
현재 구조는 다음과 같은 원칙 위에서 동작합니다.
결과적으로 이번 작업을 통해 단순한 캐시 활용을 넘어,
대규모 트래픽 환경에서 성능과 정합성을 각각 어떤 계층이 책임져야 하는지 설계하는 방법을 경험할 수 있었습니다.