선착순 이벤트, 한정판 상품 판매, 수강 신청과 같이 사용자의 기대감이 최고조에 달하며 트래픽이 폭발적으로 증가(Burst Traffic)하는 상황은 많은 백엔드 개발자에게 어려운 과제입니다. 저 역시 선착순 이벤트 서버에서 p95 응답 시간(상위 95% 요청에 대한 응답 시간)이 30초를 초과하는 심각한 성능 문제에 직면했습니다. 30초라는 대기 시간은 사용자에게 사실상 실패와 다름없으며, 이는 곧 비즈니스 기회의 손실로 이어졌습니다.
초기 분석은 코드 레벨의 비효율성에 집중되었지만, 문제의 본질은 더 깊은 곳에 있었습니다. 원인은 개별 코드의 비효율성이 아닌, 대규모 동시 요청을 처리하기 위해 선택했던 동기(Synchronous) 처리 방식과 Redisson 분산 락(Distributed Lock)의 조합이 만들어낸 구조적 한계였습니다. 분산 락은 분산 환경에서 데이터 정합성을 보장하는 효과적인 도구이지만, 저는 트래픽을 처리하는 시스템의 가장 앞단, 즉 '진입점'에서 이 도구를 잘못 사용하고 있었습니다. 좋은 기술을 잘못된 맥락에서 사용했을 때, 이는 강력한 기능이 아닌 치명적인 병목으로 작용할 수 있습니다.
이 글은 단순히 특정 기술을 도입해 문제를 해결했다는 성공 사례를 넘어, 문제의 근본 원인을 어떻게 진단하고 그에 맞는 아키"텍처를 선택했는지에 대한 회고입니다. 저는 동기 방식의 분산 락 구조를 Kafka를 이용한 비동기(Asynchronous) 처리 아키텍처로 전환했으며, 이 과정에서 Redis의 역할을 복잡한 '분산 락' 관리자에서 극도로 효율적인 '원자적 카운터(Atomic Counter)'로 재정의했습니다. 이 경험을 통해 얻은 기술적 교훈과 아키텍처 설계의 중요성을 공유하고자 합니다.
문제를 해결하기 위해선 먼저 기존 아키텍처가 어떻게 동작했고, 왜 실패했는지 정확히 이해해야 했습니다. 당시 시스템은 일반적인 웹 애플리케이션 구조를 따르고 있었습니다.
[Before 아키텍처]
사용자 요청 → 로드 밸런서 → 다수의 API 서버 인스턴스 → Redis (Redisson 분산 락) → 데이터베이스
사용자 요청이 API 서버에 도달하면, 재고 차감 로직을 수행하기 전에 다음과 같은 코드를 통해 분산 락을 획득했습니다.
@PostMapping("/events/{eventId}/apply")
public ResponseEntity<String> applyForEvent(@PathVariable String eventId) {
// 이벤트 ID를 기반으로 락 키 생성
RLock lock = redissonClient.getLock("lock:event:" + eventId);
boolean isLocked = false;
try {
// 락 획득을 위해 최대 30초 대기, 락 점유 시간은 10초로 설정
// 바로 이 지점이 시스템 전체를 마비시키는 병목 지점입니다.
isLocked = lock.tryLock(30, 10, TimeUnit.SECONDS);
if (!isLocked) {
// 락 획득 실패 시 사용자에게 재시도 요청
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Could not acquire lock, please try again.");
}
// --- 임계 영역(Critical Section) ---
// 1. 재고 조회
// 2. 재고 차감
// 3. 데이터베이스에 반영
stockService.decreaseStock(eventId);
// --- 임계 영역 종료 ---
return ResponseEntity.ok("Success!");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred.");
} finally {
// 획득한 락은 반드시 해제
if (isLocked) {
lock.unlock();
}
}
}
이 구조의 치명적인 문제는 바로 '락 경합(Lock Contention)' 현상입니다. 이벤트가 시작되는 순간, 수천 개의 스레드(여러 API 서버에 걸쳐)가 단 하나의 락(lock:event:{eventId}
)을 획득하기 위해 동시에 경쟁합니다. 오직 하나의 스레드만이 락을 획득하고 임계 영역의 로직을 수행할 수 있으며, 나머지 수천 개의 스레드는 lock.tryLock()
메서드에서 설정한 대기 시간 동안 아무것도 하지 못하고 대기 상태에 빠집니다.
Redisson은 내부적으로 Redis의 Pub/Sub 기능을 사용하여 락이 해제되었음을 대기 중인 스레드에 알립니다. 이는 무한 루프를 돌며 락 상태를 확인하는 스핀락(Spin Lock) 방식보다 Redis 자체의 부하를 줄여주는 효율적인 방식입니다. 하지만 문제의 본질은 Redis의 부하가 아니었습니다. 진짜 문제는 애플리케이션 스레드가 블로킹(Blocking)된다는 점입니다. 대기하는 스레드들은 웹 서버(Tomcat)의 제한된 스레드 풀 자원을 점유한 채로 멈춰있습니다. 결국, 사용 가능한 스레드가 모두 고갈되면 서버는 더 이상 새로운 요청을 처리할 수 없게 되고, 시스템 전체의 처리량(Throughput)은 마비 상태에 이릅니다.
이 아키텍처는 확장성에 대한 근본적인 오해에서 비롯되었습니다. 더 많은 트래픽을 처리하기 위해 API 서버 인스턴스를 늘렸지만, 이는 오히려 상황을 악화시켰습니다. 더 많은 서버는 더 많은 스레드를 의미하고, 이는 단 하나의 락을 향한 경쟁을 더욱 치열하게 만들 뿐이었습니다. 시스템의 전체 성능은 가장 느린 직렬 처리 구간, 즉 단 하나의 락을 획득하고 비즈니스 로직을 처리하는 시간에 수렴하게 됩니다. 분산 락 자체는 훌륭한 기술이지만, 대규모 동시 쓰기 요청을 처리하는 '진입점'에서 사용하는 것은 시스템의 확장성을 스스로 제한하는 치명적인 설계였습니다.
근본적인 원인이 동기 처리와 락 경합에 있다는 것을 파악한 후, 완전히 다른 패러다임을 도입하기로 결정했습니다. 바로 비동기 처리와 메시지 큐를 이용한 요청 직렬화(Request Serialization)입니다. 이 패러다임의 핵심에는 Kafka가 있습니다.
[After 아키텍처]
사용자 요청 → API 서버 → Kafka 토픽 (단일 파티션) → 이벤트 컨슈머 → Redis (원자적 카운터) → 데이터베이스
새로운 아키텍처의 흐름은 다음과 같습니다.
202 Accepted
와 같은 응답을 보내고 연결을 종료합니다.// API 서버 (Producer)
@PostMapping("/v2/events/{eventId}/apply")
public ResponseEntity<String> applyForEventAsync(@PathVariable String eventId, @RequestBody ApplyRequest request) {
// 요청 유효성 검사 후 메시지 생성
EventApplicationMessage message = new EventApplicationMessage(request.getUserId(), eventId);
// Kafka 토픽으로 메시지 발행. 이 작업은 매우 빠릅니다.
kafkaTemplate.send("event-applications-topic", message);
// 사용자에게 즉시 '처리 중' 응답을 반환
return ResponseEntity.accepted().body("Your application is being processed.");
}
이 구조에서 Kafka는 두 가지 핵심적인 역할을 수행합니다.
첫째, 버퍼(Buffer) 역할입니다. 이벤트 시작과 동시에 발생하는 Burst Traffic을 Kafka가 모두 흡수합니다. API 서버는 수만 건의 요청을 빠르게 받아 Kafka에 쌓아두기만 하면 되므로, 과부하로 다운될 위험이 사라집니다. Kafka는 이 요청들을 디스크 기반의 로그에 안전하게 보관하며, 다운스트림의 컨슈머가 처리할 수 있는 속도에 맞춰 메시지를 전달해 줍니다.
둘째, 그리고 더 중요한 것은 요청 직렬화(Serialize) 역할입니다. 이벤트 처리용 Kafka 토픽을 의도적으로 단 하나의 파티션(Single Partition)으로 구성했습니다. Kafka는 파티션 내에서의 메시지 순서를 엄격하게 보장합니다. 즉, 먼저 들어온 요청이 먼저 처리되는 것을 보장합니다. 수천 개의 스레드가 무질서하게 경쟁하던 상황이, Kafka를 통해 모든 요청이 한 줄로 정렬된 질서정연한 상태로 바뀐 것입니다. 이 '순서 보장'이라는 특성 덕분에, 더 이상 동시성 제어를 위해 비효율적인 분산 락에 의존할 필요가 없어졌습니다.
Kafka를 통해 모든 요청을 순서대로 처리할 수 있게 되자, 분산 락은 더 이상 필요하지 않았습니다. 컨슈머는 단일 스레드로 단일 파티션의 메시지를 순차적으로 처리하므로, 애초에 동시 접근 자체가 발생하지 않기 때문입니다.
이제 Redis의 역할을 재정의할 수 있었습니다. 복잡하고 무거운 '분산 락 관리자'에서, 빠르고 가벼운 '원자적 카운터(Atomic Counter)'로의 전환입니다.
재고 차감 로직의 핵심은 "현재 재고가 0보다 크면, 1을 감소시킨다"는 조건부 연산입니다. Redis는 Lua 스크립트를 실행하는 기능을 제공하며, 스크립트 전체의 실행에 대한 원자성(Atomicity)을 보장합니다. Redis는 싱글 스레드로 동작하기 때문에, 하나의 스크립트가 실행되는 동안에는 다른 어떤 클라이언트의 명령도 처리되지 않습니다. 이 특성을 활용하여 '조회 후 차감' 로직을 하나의 원자적 단위로 묶었습니다.
-- 재고 차감을 위한 Lua 스크립트
-- KEYS[1]: 재고 키 (예: "stock:event:123")
-- 반환값: 성공 시 1, 재고 부족으로 실패 시 0
-- 1. 현재 재고량을 가져온다.
local stock = redis.call('GET', KEYS[1])
-- 2. 재고가 존재하고 0보다 큰지 확인한다.
if (stock and tonumber(stock) > 0) then
-- 3. 재고가 충분하면 DECR 명령어로 1을 감소시킨다.
redis.call('DECR', KEYS[1])
return 1
else
-- 4. 재고가 없으면 0을 반환한다.
return 0
end
이 스크립트를 사용함으로써 컨슈머의 재고 차감 로직은 극도로 단순하고 강력해졌습니다.
redisTemplate.execute(script, ...)
단 한 번의 네트워크 호출로 원자적 연산 완료성능상의 이점은 명확합니다. 여러 번의 네트워크 왕복이 단 한 번으로 줄었고, 락과 관련된 부하와 복잡성이 완전히 사라졌습니다. 코드는 try-catch-finally 블록 없이 간결하게 유지되어 가독성과 유지보수성이 크게 향상되었습니다.
아키텍처 개선의 마지막 단계는 '선착순 마감'이라는 비즈니스 상황을 처리하는 방식을 개선하여 시스템의 안정성과 관찰 가능성(Observability)을 높이는 것이었습니다. 이전 컨슈머 코드는 Lua 스크립트가 0(재고 없음)을 반환하면 SoldOutException
과 같은 예외를 던지도록 구현되어 있었습니다. 이는 두 가지 심각한 부작용을 낳았습니다.
첫째, 모니터링 시스템을 오염시킵니다. APM(Application Performance Monitoring) 도구들은 예외 발생률을 시스템의 핵심 건강 지표로 삼습니다. 이벤트가 성공적으로 마감된 후 들어오는 모든 요청은 예외를 발생시키고, 그 결과 APM 대시보드는 에러 알람으로 뒤덮입니다. 운영자는 이것이 정상적인 비즈니스 마감 상황인지, 실제 시스템 장애인지 구분할 수 없게 됩니다.
둘째, 불필요한 리소스를 낭비합니다. Spring Kafka와 같은 메시징 프레임워크는 컨슈머에서 예외가 발생하면, 이를 일시적인 오류로 간주하고 동일한 메시지를 여러 번 재시도하는 정책을 가집니다. 하지만 '재고 없음'은 재시도를 통해 해결될 수 있는 문제가 아닙니다.
이 문제를 해결하기 위해 '선착순 마감'을 예외가 아닌, 정상적인 비즈니스 흐름으로 처리하도록 코드를 변경했습니다.
// 개선된 Kafka 컨슈머 로직
@KafkaListener(topics = "event-applications-topic", groupId = "event-processor-group")
public void processApplication(EventApplicationMessage message) {
// Lua 스크립트 실행 결과(1 또는 0)를 boolean으로 받는다.
boolean success = stockService.decreaseStockAtomically(message.getEventId());
if (success) {
// 성공 케이스: 정상 처리 로그를 남기고, 사용자에게 성공 알림을 보낸다.
log.info("Successfully processed application for user {} on event {}", ...);
notificationService.sendSuccess(message.getUserId());
} else {
// 실패(마감) 케이스: 예외를 던지는 대신, 정상 흐름으로 처리한다.
// 마감 로그를 남기고, 사용자에게 마감 알림을 보낸다.
log.info("Application rejected for user {} on event {}: SOLD OUT", ...);
notificationService.sendSoldOut(message.getUserId());
}
}
이 간단한 if/else 구조로의 변경은 시스템에 안정성을 가져왔습니다. APM의 에러율은 실제 장애가 발생할 때만 상승하게 되었고, 불필요한 재시도가 사라져 시스템 리소스가 효율적으로 사용되었습니다. 이는 코드가 비즈니스 로직의 의미를 정확하게 반영해야 함을 보여주는 중요한 교훈입니다. '선착순 마감'은 시스템의 실패가 아니라, 비즈니스의 성공적인 결과이기 때문입니다.
이번 아키텍처 개선 경험을 통해 얻은 교훈은, 단순히 최신 기술을 도입하는 것을 넘어 시스템이 마주한 문제의 본질을 정확히 파악하고 그에 맞는 구조를 선택하는 것이 얼마나 중요한지 깨달았다는 점입니다.
지표 / 측면 | Before (동기 방식 + 분산 락) | After (비동기 Kafka + 원자적 카운터) |
---|---|---|
p95 사용자 응답 시간 | > 30,000 ms | ~200 ms |
시스템 처리량 | 단일 락 홀더의 처리 시간에 의해 심각하게 제한됨 | 컨슈머 처리 속도에 의해 결정 (사용자와 분리됨) |
동시성 처리 방식 | 락 경합, 스레드 풀 고갈 | 요청 버퍼링 및 직렬화 |
Redis 역할 | 분산 락 관리자 (복잡한 조정자) | 원자적 카운터 (단순한 계산기) |
코드 복잡성 | 복잡한 try/finally 락 관리, 타임아웃 처리 | 단순한 kafkaTemplate.send() 및 redis.execute(script) |
시스템 관찰 가능성 | 노이즈 심함 (비즈니스 결과가 시스템 에러로 보고됨) | 신호 명확 (비즈니스 결과와 시스템 에러가 명확히 분리됨) |
장애 전파 방식 | 연쇄 장애, 전체 시스템 다운으로 이어짐 | 점진적 성능 저하, 프론트엔드는 가용성 유지 |
초기 아키텍처는 '대규모 동시 요청 상황에서 데이터 정합성을 어떻게 보장할 것인가?'라는 질문에 분산 락이라는 답을 내놓았습니다. 하지만 진짜 질문은 '대규모 동시 요청을 사용자를 기다리게 하지 않으면서, 어떻게 순서대로 공정하게 처리하고 데이터 정합성을 보장할 것인가?'였습니다. 이 질문에 대한 가장 정확한 답변이 바로 Kafka를 이용한 비동기 아키텍처였습니다.