참고 자료: 이 글은 아래 강의를 들으며 정리하고, 제 나름의 해석을 덧붙인 내용입니다.
https://www.inflearn.com/course/designing-a-server-s
3편에서 Redis 원자 연산으로 거절을 1밀리초 미만까지 줄였다. 그런데 쿠폰을 받은 사람의 대기 시간은 그대로였다 — 성공 요청 평균 4.56초, p95 6.44초. 이유는 하나였다. Redis 게이트를 통과해도 카운터 UPDATE와 INSERT, DB 쓰기 두 번이 끝나야 200 OK가 나갔다.
이번 편에서는 그 DB 쓰기를 응답 경로에서 떼어낸다. 같은 구조를 인메모리 큐와 스프링 이벤트, 두 가지 방식으로 구현해 보면서, 본질은 똑같다는 것과 그럼에도 겉으로 안 보이는 보증 하나가 조용히 사라진다는 것을 코드로 확인한다. 내구성이라는 더 큰 대가는 다음 편, 카프카에서 다룬다.
스택: Kotlin · Spring Boot · Spring Data JPA · MySQL 8 · Redis 8 · k6 · Docker Compose
이 글에서 얻어갈 것들
- 판정과 기록을 시간으로 분리하는 법 . 사용자가 진짜 알아야 하는 건 당첨 여부뿐이고, 그건 Redis 명령 하나로 이미 결정 나 있다. DB에 남기는 건 이미 벌어진 사실의 기록이고, 사용자는 그 기록이 끝나길 기다릴 이유가 없다. - 이 경계를 코드의 정확히 어느 줄에 그어야 하는지
- 큐를 손으로 한 번 만들어 봐야 하는 이유. LinkedBlockingQueue와 데몬 스레드로 프로듀셔,컨슈머,백프레셔를 직접 구현하고 나면, @Async와 @EventListner가 정확히 무엇을 대신 해주는 건지 코드 없이도 설명할 수 있게 된다.
- 추상화는 편의를 주고 보증을 가져간다. 손으로 만든 큐는 가득 차면 503으로 명시적으로 거절했다. 같은 구조를 스프링 이벤트로 옮기자, 아무도 지우기로 결정하지 않았는데 그 백프레셔 신호가 코드에서 사라졌다.
- 멱등서은 보내는 쪽이 아니라 받는 쪽에 있어야 하는 이유. 유니크 제약 위반을 컨슈머가 조용히 흡수하도록 하는 이유, 그리고 이게 지금 당장은 한 번도 발동 안 하는데고 넣어둬야 하는 보험인 이유
- 이번에 산 응답 속도의 대가는 재로 수량이 아니라 시간 그 자체에 있다. 큐에 실린 메시지는 애플리케이션의 생명과 함께한다. - 그리고 그 유실 창이 정확히 코드의 어느 지점에서 열리는지, 왜 그게 3편의 재고 누수보다 더 다루기 까다로운지 짚는다.
0. 3편까지의 상태 - 남은 4.56초
3편에서 재고 판정을 MySQL에서 Redis로 옮겼다. Lua 스크립트 하나로 재고 확인과 차감을 원자적으로 묶었고, 그 결과 거절 비용이 1밀리초 밑으로 떨어졌다. 표로 다시 보면 이렇다.
| 정확성 | 성공 요청 평균 | 성공 요청 p95 | 거절 비용 | |
| 기본 구현 | ❌ 6.3배 과발급 | 4.96s | 5.88s | 거절 자체가 없었음 |
| DB 비관적 락 | ✅ | 4.72s | 7.44s | 락 큐 대기 |
| Redis 원자 연산 | ✅ (상한) | 4.56s | 6.44s | 1ms 미만 |
3편 끝에서 이미 스스로 짚었던 문제가 있다. 거절은 1,000 배 빨라졌는데 당첨자는 13% 밖에 안 빨라졌다. 원인도 이미 알고 있었다.
couponIssuer.tryIssue(couponId) // ← Redis, 여기까진 빠르다
couponRepository.incrementIssuedQuantity(couponId) // ← DB 왕복 1
return issuanceRepository.save(Issuance(...)) // ← DB 왕복 2, 여기까지 사용자가 기다림
Redis를 통과한 5,000 명은 여전히 같은 쿠폰 행 위에서 update-> insert-> commit 을 한 줄로 통과해야 했다.
재고 판정은 앞단으로 옮겼지만, 그 뒤에 남은 기록 작업은 여전히 사용자를 붙잡고 있었다.
거기에 하나 더, 이번 편에서 처음 짚는 부분이 있다. 3편 코드를 다시 보면 재고 판정 앞에 이 검사가 남아 있다.
if (issuanceRepository.existsByUserIdAndCouponId(userId, couponId)) {
throw AlreadyIssuedException()
}
아건 락 경쟁이 걸리는 자리는 아니다. 인덱스 하나 타는 가벼운 SELECT다. 그런데 재고와 무관하게 도착하는 모든 요청, 심지어 나중에는 매진으로 거절될 요청까지 전부 이 DB왕복을 한 번씩 지불한다. 3편이 없앤 건 줄을 서는 것 이었지, 이 호출 자체는 그대로 남아있다.
1. 경계를 다시 긋는다 - 판정과 기록을 시간으로 분리
핵심 발상은 단순하다. 사용자가 실제로 필요로 하는 정보는 내가 5,000 명 안에 들었는가 뿐이고, 그건 Redis의 DECR이 실행되는 순간 이미 확정된다. 그 뒤에 벌어지는 coupon 카운터 갱신과 issuance 행 삽입은, 이미 일어난 사실을 나중에 찾아볼 수 있도록 기록해 두는 작업일 뿐이다. 사실 기록이 끝나야 응답할 이유가 없다.
[ 3편까지 ]
발급 API ──► Redis (재고 판정, 원자적) ──► MySQL (카운터 UPDATE + INSERT) ──► 200 OK
▲ 사용자는 여기까지 기다린다 (평균 4.56s)
[ 4편부터 ]
발급 API ──► Redis (재고 판정, 원자적) ──► 200 OK ← 사용자는 여기서 끝
└──► 큐에 적재 ──► 워커가 뒤에서 MySQL에 기록
1.1 마지막 남은 동기 DB호출을 치운다 - 중복 검사도 Redis로
0절에서 짚은 existsByUserIdAndCouponId 부터 정리한다. 3편의 Lua 스크립트는 재고만 봤다.
-- 3편
local remaining = tonumber(redis.call('GET', KEYS[1]) or '0')
if remaining <= 0 then
return 0
end
redis.call('DECR', KEYS[1])
return 1
여기에 사용자별 발급 이력을 담을 Redis Set을 하나 더 두고, 재고 확인과 같은 스크립트 안에서 판정한다.
-- 4편 — 발급 원자 처리. 재고 차감 + 사용자 추가를 한 덩어리에 묶는다.
--
-- KEYS[1] = coupon:{id}:stock (재고 카운터)
-- KEYS[2] = coupon:{id}:users (발급된 사용자 set)
-- ARGV[1] = user_id
-- 반환: 1 발급 성공 / 0 매진 / -1 이미 발급된 사용자
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -1
end
local remaining = tonumber(redis.call('GET', KEYS[1]) or '0')
if remaining <= 0 then
return 0
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return 1
중복 검사를 재고 검사보다 먼저 두는 순서가 중요하다. 순서를 반대로 하면, 이미 발급 받은 사용자가 매진 이후에 다시 요청 했을 때 ALREADY_ISSUED가 아니라 SOLD_OUT을 받는다. 사용자 입장에서 내가 이미 받았나? 와 품절됐나?는 전혀 다른 질문이고, 원인을 반대로 알려주면 안된다. 중복 여부는 재고 상태와 무관하게 항상 먼저 확정되어야 하는 사실이다.
애플리케이션 쪽은 이렇게 바뀐다.
@Component
class CouponIssuer(
private val redisTemplate: StringRedisTemplate,
) {
private val script: RedisScript<Long> = RedisScript.of(
ClassPathResource("lua/issue.lua"),
Long::class.java,
)
fun tryIssue(couponId: Long, userId: Long) {
val raw = redisTemplate.execute(
script,
listOf(stockKey(couponId), usersKey(couponId)),
userId.toString(),
) ?: error("Lua 스크립트 결과가 null")
when (raw) {
1L -> Unit
0L -> throw SoldOutException()
-1L -> throw AlreadyIssuedException()
else -> error("예상치 못한 Lua 결과: $raw")
}
}
fun initStock(couponId: Long, totalQuantity: Int) {
redisTemplate.opsForValue().set(stockKey(couponId), totalQuantity.toString())
}
private fun stockKey(couponId: Long) = "coupon:$couponId:stock"
private fun usersKey(couponId: Long) = "coupon:$couponId:users"
}
issuanceRepository.existsByUserIdAndCouponId 호출은 서비스에서 완전히 사라진다. 이제 재고 판정에 도달하는 모든 요청 — 성공이든 거절이든 — 은 DB를 단 한 번도 거치지 않는다. 3편이 "줄을 서는 인원"을 줄였다면, 이건 "줄을 서기 전에 반드시 거쳐야 했던 관문 하나"를 아예 치운 것이다.
1.2 issue() - 이제 쓰기는 한 줄도 없다.
@Transactional
fun issue(couponId: Long, userId: Long): Issuance {
val coupon = couponRepository.findById(couponId)
.orElseThrow { CouponNotFoundException() }
val now = LocalDateTime.now()
if (!coupon.isBookingOpen(now)) {
throw NotStartedException()
}
if (coupon.isSoldOut()) {
throw SoldOutException()
}
val expiresAt = now.plusDays(coupon.validityDays.toLong())
couponIssuer.tryIssue(couponId, userId)
issuanceQueue.enqueue(
IssuanceRequested(
couponId = couponId,
userId = userId,
issuedAt = now,
expiresAt = expiresAt,
)
)
return Issuance(
userId = userId,
couponId = couponId,
issuedAt = now,
expiresAt = expiresAt,
)
findById 는 발급 정책(시작 시각, 유효기간)을 읽기 위한 PK 조회 하나뿐이고, 그 외에 DB에 쓰는 코드는 한 줄도 없다.
눈여겨볼 것은 마지막 줄이다. issuanceRespository.save(...)가 사라지고, 아직 DB에 존재하지 않는 Issuance 객체를 그대로 응답으로 돌려준다. id가 없는 채로 나가는게 이상해 보일 수 있지만, 이 시점엔 실제로 아직 발급 기록이 없는게 맞다. 그래서 IssuanceResponse도 id를 nullable로 받도록 바꿔야 한다 — 응답 DTO의 타입이 아키텍처의 사실을 정직하게 반영하는 셈이다.
2. 첫 번째 구현 - 인메모리 큐를 손으로 만든다.
Spring 이 제공하는 걸 바로 쓸 수도 있지만, 먼저 손으로 만들어 볼 이유가 있다. 프로듀서,큐, 컨슈머라는 구조를 한 번 몸에 익혀 두면, 다음 절의 @Async가 정확히 뭘 대신해 주는 건지 껍데기가 아니라 실체로 이해할 수 있다.
class IssuanceRequested(
val couponId: Long,
val userId: Long,
val issuedAt: LocalDateTime,
val expiresAt: LocalDateTime,
)
큐에 실릴 메시지는 딱 이만큼만 담는다. DB에 쓰는 데 필요한 정보만 있으면 되고, 그 이상을 실으면 큐가 또 하나의 도메인 모델이 되어 버린다.
@Component
class InMemoryIssuanceQueue {
private val queue = LinkedBlockingQueue<IssuanceRequested>(CAPACITY)
fun enqueue(event: IssuanceRequested) {
if (!queue.offer(event)) {
throw QueueFullException()
}
}
fun poll(): IssuanceRequested? = queue.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS)
fun size(): Int = queue.size
companion object {
private const val CAPACITY = 10_000
private const val POLL_TIMEOUT_MS = 100L
}
}
두 가지가 눈여겨볼 지점이다.
용량을 왜 유한하게 뒀는가. 큐를 무한정 받아주면 애플리케이션 메모리가 밀린 만큼 늘어난다. 처리 속도보다 유입 속도가 빠르면 결국 OOM으로 끝난다. CAPACITY = 10_000은 "이 이상은 우리가 감당 못 한다"는 걸 미리 정한 상한선이고, offer()가 false를 반환하면 QueueFullException — 503 — 으로 번역해 즉시 거절한다.
3편에서 Redis 게이트를 "재고가 없으면 거절"로 만들었다면, 이건 그 뒤에 놓은 두 번째 게이트다. "처리할 여력이 없으면 거절." 이유가 다르니 코드도 다르다 — 하나는 409 SOLD_OUT, 하나는 503 QUEUE_FULL이다. 사용자에게는 둘 다 "실패"지만, 운영자에게는 완전히 다른 신호다. 전자는 정상적인 인기고, 후자는 시스템이 뒤처지고 있다는 경보다.
그리고 이 두 번째 게이트에는 3편에서 이미 본 위험이 그대로 옮겨 붙어 있다. issue()의 순서를 다시 보자.
couponIssuer.tryIssue(couponId, userId) // ← 여기서 Redis 재고가 이미 깎였다
issuanceQueue.enqueue(IssuanceRequested(...)) // ← 여기서 QueueFullException이 나면?
tryIssue가 성공한 시점에 Redis 재고 카운터는 이미 줄었고, 그 사용자는 coupon:{id}:users Set에도 이미 들어갔다.
그 다움 줄인 enqueue()가 큐 포화로 예외를 던지면, 이 사실을 기록할 이벤트는 애초에 만들어지지도 못한 채 사라진다.
사용자는 503을 받고, 그 재고 한장은 아무에게도 가지 않은 채 없어진다. 게다가 이 사용자는 재시도해도 소용없다.-Redis 입장에선 이미 발급받은 사람이라 ALREADY_ISSUED로 막힌다.
3편의 재고 누수는 "DB 쓰기 실패"가 방아쇠였다. 이번 편은 방아쇠가 하나 늘었다 — 큐 포화도 똑같은 결과를 낳는다. 그리고 이번 방아쇠가 오히려 더 현실적이다. 트래픽이 몰려서 큐가 찰 가능성이 높아지는 바로 그 순간에, 재고도 가장 빠르게 줄어들고 있기 때문이다. 두 일이 같은 원인(폭주)으로 동시에 일어난다.
왜 poll()에 100ms 타임아웃을 줬는가. 정확히 말하면 이건 인터럽트를 "받기 위해서"가 아니다. LinkedBlockingQueue의 블로킹 대기는 take()든 타임아웃을 준 poll()이든 전부 인터럽트 가능한 대기(interruptible wait)라서, 대기 중에 interrupt()가 오면 타임아웃과 무관하게 그 즉시 깨어나 InterruptedException을 던진다. 즉 여기서 타임아웃을 없애고 take()를 썼어도 stop()의 종료 반응 속도는 다르지 않았을 것이다.
진짜 이유는 습관적인 방어다. 자바의 모든 블로킹 호출이 인터럽트에 반응하는 건 아니다 — 일반 소켓 I/O나 JDBC 드라이버 내부의 대기 같은 건 인터럽트를 그냥 무시하고 계속 블록한다. "블로킹 대기는 항상 타임아웃을 주고 주기적으로 깨어나 조건을 다시 확인한다"는 건 그런 호출까지 감안한 방어적 관행이고, 이 코드도 그 관행을 그대로 따른 것에 가깝다. 지금 이 큐 하나만 놓고 보면 딱히 없어도 되는 안전장치지만, 나중에 이 자리에 인터럽트를 무시하는 다른 블로킹 호출이 들어오더라도 루프가 무한정 멈추지 않게 보험을 들어 둔 것이다.
private val log = KotlinLogging.logger {}
@Component
class InMemoryIssuanceWorker(
private val inMemoryIssuanceQueue: InMemoryIssuanceQueue,
private val issuanceWriter: IssuanceWriter,
) {
// HTTP 요청 스레드와는 완전히 별개인, 이 빈이 살아있는 동안 계속 떠 있을 스레드.
// 아직 생성 전이라 lateinit — start()에서 실제로 만들어진다.
private lateinit var workerThread: Thread
// 이 빈이 생성되자마자(@PostConstruct) 백그라운드 스레드를 하나 띄운다.
// 애플리케이션이 시작하는 순간부터 큐를 감시하기 시작한다는 뜻이다.
@PostConstruct
fun start() {
// thread { ... } 는 새 스레드를 "만들고 바로 실행까지" 시킨다.
// isDaemon = true → 메인 스레드(애플리케이션)가 끝나면 이 스레드는 따로 안 죽여도 같이 정리된다.
workerThread = thread(name = "issuance-worker", isDaemon = true) {
// 이 스레드의 평생 할 일: 인터럽트 신호가 오기 전까지 무한 반복.
while (!Thread.currentThread().isInterrupted) {
val event = try {
// 큐에서 이벤트를 하나 꺼낸다. 최대 100ms 기다려보고 없으면 null.
// null이면 처리할 게 없다는 뜻이니 continue로 while 맨 위로 돌아가
// "인터럽트 됐나?"를 다시 확인하고 또 기다린다.
inMemoryIssuanceQueue.poll() ?: continue
} catch (e: InterruptedException) {
// 기다리는(poll) 도중에 interrupt()가 걸리면 여기로 온다.
// 인터럽트 상태를 다시 세팅해두고(관례) while 문 밖으로 나가 스레드 실행을 끝낸다.
Thread.currentThread().interrupt()
break
}
try {
// 진짜 하고 싶었던 일 — 이벤트를 실제 DB 쓰기로 옮긴다.
// 이 한 줄 때문에 위의 큐/스레드/interrupt 코드가 전부 존재하는 것이다.
issuanceWriter.write(event)
} catch (e: Exception) {
// write() 안에서 뭐가 터지든, 이 스레드는 하나뿐인 소비자다.
// 여기서 예외를 밖으로 흘리면 while 루프가 죽고 그 뒤로는
// 큐에 아무리 쌓여도 아무도 처리 안 하는 상태가 된다. 그래서 로깅만 하고 계속 돈다.
log.error(e) { "Worker write 실패: couponId=${event.couponId}, userId=${event.userId}" }
}
}
// while 문을 빠져나왔다 = 이 람다(스레드가 실행하던 코드)가 끝났다 = 스레드 종료.
log.info { "issuance-worker 종료" }
}
}
// 스프링 컨테이너가 이 빈을 없애기 직전(@PreDestroy)에 호출된다.
// 애플리케이션 종료 시퀀스에서 자동으로 불린다 — 우리가 직접 호출하는 게 아니다.
@PreDestroy
fun stop() {
if (::workerThread.isInitialized) {
// "죽여라"가 아니라 "인터럽트 플래그를 켜고, 대기 중이면 깨워달라"는 요청.
// 실제로 스레드가 끝나는 건 위 while 루프가 그 신호를 보고 스스로 빠져나갈 때다.
workerThread.interrupt()
}
}
}
왜 데몬 스레드인가. 메인 스레드와 별개로 항상 떠 있어야 하지만, 애플리케이션이 종료될 때 이 스레드 하나 때문에 종료가 막혀서는 안된다. 데몬 스레드는 애플리케이션 생사와 함께 자동으로 정리된다. @PreDestory에서 interrupt()를 명시적으로 걸어 "지금 처리 중인걸 끝내고 나가라"는 신호를 준다. - 이 부분이 다음 절에서 스프링이 대신 해주는 것 중 하나다.
write() 실패는 왜 다시 던지지 않는가? 여기서 예외를 밖으로 흘리면 while 루프가 그 자리에서 죽는다. 워커 스레드가 하나뿐인 구조에서 이건 곧 모든 후속 발급 기록이 영원히 멈춘다는 뜻이다. 그래서 실패는 로깅만 하고 다음 메시지로 넘어간다. 하나의 실패가 전체 파이프라인을 멈추면 안된다는 원칙이다.
컨슈머 쪽 실제 쓰기를 두 클래스로 나뉜다.
@Component
class IssuanceTransactionalWriter(
private val issuanceRepository: IssuanceRepository,
private val couponRepository: CouponRepository,
) {
@Transactional
fun insertAndIncrement(event: IssuanceRequested) {
issuanceRepository.save(
Issuance(
userId = event.userId,
couponId = event.couponId,
issuedAt = event.issuedAt,
expiresAt = event.expiresAt,
)
)
couponRepository.incrementIssuedQuantity(event.couponId)
}
}
@Component
class IssuanceWriter(
private val issuanceTransactionalWriter: IssuanceTransactionalWriter,
) {
fun write(event: IssuanceRequested) {
try {
issuanceTransactionalWriter.insertAndIncrement(event)
} catch (e: DataIntegrityViolationException) {
log.debug { "UNIQUE 위반은 멱등 처리: couponId=${event.couponId}, userId=${event.userId}" }
}
}
}
굳이 두 클래스로 나눈 이유는 스프링 트랜잭션의 익숙한 제약 때문이다. @Transactional은 프록시 기반으로 동작해서, 같은 클래스 안의 메서드를 자기 자신이 호출하면(self-invocation) AOP가 끼어들 틈이 없어 아예 무시된다. 그래서 트랜잭션이 걸릴 메서드는 별도 빈으로 분리해야 한다.
incrementIssuedQuantity 는 2편에서 만든 원자적 update 쿼리 그대로다, 이 워커가 지금은 한 개뿐이지만, 나중에 여러 개로 늘어나도 이 카운터 증가는 여전히 안전하다. coupon.issuedQuantity++ 같은 read-modify-write 가 아니라 SET x = x + 1 where id = ... 이기 때문이다. 2편의 설계가 이번 편의 확장성까지 미리 사 둔 셈이다.
그리고 DataIntegrityViolationException을 조용히 흡수하는 부분. (user_id, coupon_id) 유니크 제약을 뚫고 같은 이벤트가 두 번 들어오면 두 번째 INSERT는 실패한다. 이걸 실패로 취급해서 던지면, 있지도 않은 재시도 로직이 계속 같은 메시지를 재처리하려 들 수 있다. 디버그 레벨로 조용히 넘기는 것 자체가 "이건 중복이니 처리할 필요가 없다"는 멱등성 선언이다.
지금 당장은 이 코드가 한 번도 발동하지 않는다. 지금 구조에서 같은 이벤트가 큐에 두 번 들어올 방법이 없기 때문이다. 그런데도 넣어 두는 이유는 명확하다. 멱등성 처리는 발행자가 아니라 소비자에게 있어야 한다. 다음 편에서 카프카로 넘어가면, 브로커는 최소 한 번(at-least-once) 전달만 보장하고 정확히 한 번을 보장하지 않는다. 그때는 이 방어선이 실제로 발동한다. 지금 짜 두는 건 그날을 위한 보험이다.
3. 두 번째 구현 - 스프링 이벤트로 같은 구조를 표현한다
이번엔 위에서 손으로 만든 것과 정확인 같은 일을 스프리으이 어노테이션으로 다시 짠다.
@Service
class CouponService(
private val couponRepository: CouponRepository,
private val couponIssuer: CouponIssuer,
private val eventPublisher: ApplicationEventPublisher,
) {
// ...
fun issue(couponId: Long, userId: Long): Issuance {
// ...
eventPublisher.publishEvent(
IssuanceRequested(couponId = couponId, userId = userId, issuedAt = now, expiresAt = expiresAt)
)
// ...
}
}
issuanceQueue.enqueue(...) 한 줄이 eventPublisher.publishEvent(...) 한줄로 바뀐 것 말고는 다르지 않다.
const val ISSUANCE_TASK_EXECUTOR = "issuanceTaskExecutor"
@Configuration
@EnableAsync
class AsyncIssuanceConfig {
@Bean(name = [ISSUANCE_TASK_EXECUTOR])
fun issuanceTaskExecutor(): Executor {
val executor = ThreadPoolTaskExecutor()
executor.corePoolSize = 1
executor.maxPoolSize = 1
executor.queueCapacity = 10_000
executor.setThreadNamePrefix("issuance-async-")
executor.setWaitForTasksToCompleteOnShutdown(true)
executor.setAwaitTerminationSeconds(30)
executor.initialize()
return executor
}
}
@Component
class IssuanceEventHandler(
private val issuanceWriter: IssuanceWriter,
) {
@Async(ISSUANCE_TASK_EXECUTOR)
@EventListener
fun handle(event: IssuanceRequested) {
issuanceWriter.write(event)
}
}
InMemoryIssuanceQueue와 InMemoryIssuanceWorker 두 클래스, 그리고 그 안의 while 루프와 인터럽트 처리 코드가 통째로 사라지고, 설정 클래스 하나와 메서드 하나로 줄었다. 두 구조를 나란히 놓으면 대응관계가 그대로 보인다.
| 우리가 손으로 만든 것 | 스프링이 대신하는 것 |
| LinkedBlockingQueue(10_000) | ThreadPoolTaskExecutor의 내부 큐 (queueCapacity) |
| 데몬 워커 스레드 1개 | corePoolSize = 1, maxPoolSize = 1 |
| issuanceQueue.enqueue(event) | eventPublisher.publishEvent(event) |
| while 루프 + poll() | @Async + @EventListener 디스패치 |
| @PostConstruct / @PreDestroy + interrupt() | @EnableAsync + waitForTasksToCompleteOnShutdown(true) |
이 표가 이번 편의 핵심이다. 오른쪽 칸이 왼쪽 칸보다 짧아 보이는 건 코드가 없어져서가 아니라, 스프링 내부의 ThreadPoolTaskExecutor 구현체 안에 똑같은 코드가 이미 들어 있어서다. @Async를 걷어내고 스프링 소스를 열어 보면 우리가 방금 짠 것과 본질적으로 같은 큐잉·폴링·예외 처리가 나온다. 달라진 건 누가 그 코드를 유지보수하느냐일 뿐, 그 코드가 하는 일 자체는 똑같다.
3.1 겉보기엔 같지만 조용히 사라진 것
무엇이 사라졌는지 짚기 전에, 먼저 안 사라진 것부터 확인하고 가는 게 공평하다. IssuanceWriter.write() 안에서 DataIntegrityViolationException이 아닌 다른 예외 — 예를 들어 DB 커넥션이 순간적으로 끊기는 경우 — 가 터지면 어떻게 되는가.
인메모리 버전은 워커의 catch (e: Exception) { log.error(...) }가 그걸 잡아 로깅만 하고 다음 메시지로 넘어갔다. 스프링 이벤트 버전인 IssuanceEventHandler.handle()에는 그런 catch 블록이 안 보이는데, 그런데도 동작은 똑같다. @Async가 붙은 void 메서드 안에서 예외가 나면, 스프링이 자체 AsyncUncaughtExceptionHandler로 그 예외를 잡아 로깅하고, 워커 스레드는 죽지 않은 채 다음 작업을 계속 받는다. 이 부분은 스프링이 정확히 우리가 손으로 짠 것과 같은 안전망을 대신 쳐 준다. "메시지 하나의 실패가 전체 파이프라인을 멈추면 안 된다"는 원칙은 어노테이션 뒤에서도 그대로 지켜지고 있다.
그런데 클래스를 지우면서 같이 지워진 게 있다. 이건 다르다.
-class QueueFullException(message: String = "발급 큐가 일시적으로 가득 찼습니다") :
- DomainException("QUEUE_FULL", HttpStatus.SERVICE_UNAVAILABLE, message)
인메모리 큐 버전에서는 큐가 가득 차면 offer()가 false를 반환하고, 그걸 우리가 직접 QueueFullException → 503으로 번역했다. 스프링 이벤트 버전으로 옮기면서 이 예외 클래스 자체가 지워졌다. 그럼 queueCapacity = 10_000이 다 차면 무슨 일이 일어나는가?
ThreadPoolTaskExecutor가 큐까지 가득 찬 상태에서 새 작업을 받으면 기본 정책(AbortPolicy)에 따라 거부한다 — 정확히는 내부의 java.util.concurrent.RejectedExecutionException을 스프링이 TaskRejectedException으로 감싸 다시 던진다. 앞서 확인한 "예외 안전망"은 작업이 스레드 풀 안에서 실행되는 도중의 이야기고, 이건 그 이전, 작업을 스레드 풀에 접수하는 그 순간의 이야기라는 게 다르다. @Async 메서드 호출은 프록시가 executor.execute(...)를 그 자리에서 즉시 실행하는 형태로 동작하므로, 이 예외는 비동기로 숨어 있지 않고 eventPublisher.publishEvent(...)를 호출한 그 스레드로 그대로 튄다. GlobalExceptionHandler는 DomainException만 잡도록 되어 있으니, 이 예외는 어디에도 걸리지 않고 컨트롤러 밖으로 빠져나가 처리되지 않은 500 Internal Server Error가 된다.
즉 같은 상황(큐 포화)에 대한 응답이 이렇게 달라진다.
| 인메모리 큐 | 스프링 이벤트 | |
| 큐가 가득 찼을 때 | 503 QUEUE_FULL (의도된 응답) | 500 (스택트레이스 노출, 원인 불명) |
| 사용자에게 주는 정보 | "지금은 안 되니 나중에" | "뭔가 터졌다" |
| 운영자가 보는 것 | 명확한 도메인 코드 | 원인 불명의 예외 로그 |
아무도 이 백프레셔를 지우자고 결정한 적 없다. 클래스 두 개를 통째로 지우면서, 그 안에 들어 있던 지식 - "가득하면 503으로 정중하게 거절한다" - 이 같이 딸려 나간 것이다. 코드가 짧아졌다고 동작까지 똑같이 유지된다는 보장이 없다.
원칙: 코드를 지울 때는 그 코드가 "무엇을 했는가"가 아니라 "무엇을 막고 있었는가"를 먼저 물어야 한다.
그리고 더 불편한 사실이 있다. 이번 편의 부하 테스트는 성공 요청이 최대 5,000건이라, 큐에 5,000개 미만의 메시지만 쌓인다. queueCapacity = 10_000은 절대 안 찬다. 그래서 이 회귀는 이번 글의 어떤 측정에서도 드러나지 않는다. 3편에서 이미 한 번 했던 이야기를 여기서 또 하게 된다 — 초록색 결과가 확인하는 건 우리가 확인하기로 정한 것뿐이다.
4. 측정 방법 — 같은 잣대, 그런데 워밍업이 두 번 필요해진 이유
k6 시나리오는 2·3편과 같은 constant-arrival-rate를 그대로 쓴다. 초당 5,000건, 30초, 재고 5,000장. 다만 이번 스크립트는 사용자 풀을 20,000명으로 넉넉하게 잡았다.
const USER_POOL = 20000; // 재고 5000 << 사용자 20000 → 대부분 매진 응답
// ...
export const options = {
scenarios: {
burst: {
executor: 'constant-arrival-rate',
rate: 5000, timeUnit: '1s', duration: '30s',
preAllocatedVUs: 2000, maxVUs: 5000,
},
},
thresholds: {
'issue_latency': ['p(99)<500'], // 큐 디커플링 브랜치에서는 통과, 동기 브랜치에서는 깨지는 게 정상
},
};
풀이 재고보다 훨씬 크다는 게 핵심이다. 여기서 보고 싶은 건 발급 성공 수가 아니라 응답 시간의 분포 자체다. 대부분의 요청은 매진 또는 중복으로 거절되고, 성공과 거절이 응답 시간 관점에서 얼마나 벌어지는지(혹은 벌어지지 않는지)를 본다.
run.sh는 회차마다 프로세스를 새로 띄우지 않고, DB와 Redis만 비운 채로 워밍업 1회 + 본 측정 1회를 순서대로 돌린다.
# JVM JIT, HikariCP 풀, Lettuce/Kafka 컨슈머 상태가 유지돼야 2회차가 steady-state.
run_once "워밍업 1/2 (JIT, 커넥션풀, 컨슈머 그룹 조인 비용 흡수)"
run_once "본 측정 2/2 (steady-state)"
이번 편부터 워밍업이 필수가 된 이유가 있다. 애플리케이션을 막 띄운 직후에는 JIT 컴파일러가 아직 인터프리터 모드로 실행 중이고, DB·Redis 커넥션 풀도 아직 웜업이 안 된 상태다. 특히 주석에 미리 적어 둔 "컨슈머 그룹 조인 비용"은 다음 편 카프카를 염두에 둔 표현이다 — 이 스크립트는 이미 세 가지 구현(인메모리·이벤트·카프카) 모두에서 동일하게 재사용되도록 짜여 있다. 워밍업 없이 첫 실행만으로 판단하면, 시스템 자체의 특성이 아니라 JVM의 콜드 스타트를 측정하게 된다.
reset.sh에는 이번 편에서 한 줄이 추가됐다.
# Redis 의 coupon stock 카운터, 발급자 set 도 비워야 회차 사이 잔존 상태가
# 다음 측정에 섞이지 않는다.
docker compose exec -T redis redis-cli FLUSHDB
3편까지는 Redis에 재고 카운터만 있어서 안 비워도 문제가 없었다. 이제는 사용자별 발급 이력을 담은 Set까지 Redis에 있으므로, 이걸 안 비우면 이전 회차에 발급받은 사용자가 다음 회차에서도 "이미 발급됨"으로 판정된다. 상태를 하나 더 Redis로 옮겼으면, 리셋 절차도 그만큼 넓어져야 한다.
마지막으로 application.yaml.
jpa:
show-sql: false # 부하 테스트 시 stdout 동기 IO 가 응답 latency 에 박히므로 끔.
logging:
level:
org.hibernate.SQL: WARN
이건 코드 변화가 아니라 측정 환경 정리다. 표준 출력으로 나가는 로깅은 동기 I/O라서, 부하가 몰리는 상황에서 로그를 많이 찍을수록 그 자체가 응답 지연에 섞여 들어간다. 성능을 측정하려는 도구가 성능에 영향을 주면, 그 측정값은 시스템이 아니라 로깅 정책을 재는 셈이다.
5. 결과 - 이번엔 성공 요청도 빨라졌다.
같은 조건(재고 5,000 장, 초당 5,000건, 30초) 에서 세 가지 구현을 나란히 돌렸다.
|
|
동기 구현 (3편 기준선) |
인메모리 큐
|
스프링 이벤트
|
|
워밍업 p99
|
6.01s
|
1.49s
|
1.17s
|
|
본측정 p99
|
3.13s
|
109.99ms
|
26.29ms
|
|
본측정 평균
|
200.44ms
|
5.35ms
|
1.97ms
|
|
본측정 중앙값
|
1.31ms
|
1.16ms
|
1.05ms
|
|
임계값(p99 < 500ms)
|
✗ FAIL
|
✓ PASS
|
✓ PASS
|
|
발급 성공 / 정합성
|
4,995 / OK
|
5,000 / OK
|
5,000 / OK
|
동기 구현은 이번 편이 시작되기 직전, 즉 4절의 중복 검사를 Redis로 옮기기 전 상태(3편의 코드 그대로, DB 쓰기도 여전히 동기)를 같은 스크립트로 다시 측정한 값이다. 대조군을 새로 잡은 이유는, 이번 편에서 바뀐 게 큐 하나가 아니라 "중복 검사를 Redis로 옮긴 것"까지 두 가지이기 때문이다. 이 기준선이 두 변화를 합친 효과를 보여준다.
중앙값 줄을 눈여겨볼 만하다. 동기 구현조차 중앙값은 1.31ms 로 나머지 둘과 큰 차이가 없다.
existsByUserIdAndCouponId 가 모든 요청에 걸려 있어도 그 자체는 가벼운 인덱스 조회라, 대부분의 요청(거절되는 쪽)도 원래도 빨랐다는 뜻이다. p99를 3.13초까지 끌어올린 건 소수의 당첨자들이 같은 쿠폰 행 위에서 UPDATE -> INSERT 를 한 줄로 통과하며 만든 꼬리였다 - 0절에서 짚은 바로 그 지점이다.
p99 3.13 초 -> 109.99ms -> 26.29ms. 이번엔 이 숫자를 그대로 믿어도 된다. 그 이유가 이번 편의 진짜 성과다.
2·3편에서는 매번 { expected_response: true } 같은 성공 전용 지표를 따로 봐야 했다. 성공 요청과 거절 요청의 응답 시간이 수백 배씩 벌어져, 섞인 평균이나 p99는 둘중 어느 쪽 이야기인지 알 수 없는 숫자 였기 때문이다.
이번 편의 issue_latency 는 성공과 거절을 나주 않은 값인데, 그래도 된다. 왜냐하면 이제 두 경로가 똑같이 빠르기 때문이다. 성공한 요청은 Redis 원자 연산 한 번 + 큐 적재 한번, 거절된 요청은 Redis 원자 연산 한번 - 둘 다 DB 를 거치지 않는다.2.3편에서 집계 지표를 믿을 수 없었던 이유가 "성공과 실패의 처리 비용이 다르기 때문" 이었다면, 이번 편은 그 비용을 같게 만들어서 그 함정 자체를 없앤 것이다.
하지만 이건 코드를 읽고 세운 추론이지, 이 스크립트가 직접 증명해 준 값이 아니다. 2·3편 내내 강조했던 원칙 — 성공과 실패를 섞은 지표는 거짓말을 한다 — 을 이번 글에서도 똑같이 지키려면, 사실 이번에도 상태 코드별로 나눈 지표가 있어야 했다. 지금 표의 숫자가 말해주는 건 "성공과 거절이 합쳐져도 전체가 빠르다"이지, "성공만 따로 떼어도 26ms대다"가 아니다. 후자일 가능성이 높다고 코드가 말해주는 것과, 후자라는 걸 측정으로 확인하는 것은 다른 일이다. 이 격차를 인정하고 다음 측정에서 메꾸는 편이, 여기서 눈감고 넘어가는 것보다 이 시리즈의 원칙에 맞는다.
한 가지는 미리 못 박아 둔다. 인메모리 큐(109.99ms)와 스프링 이벤트(26.29ms)의 차이를 "스프링이 4배 더 빠르다"로 읽으면 안 된다. 3절의 표에서 봤듯 둘은 구조적으로 동일하고, 이 정도 편차는 실행마다 흔들리는 잡음의 영역이다 — 2편에서 이미 "3.4%는 개선이라고 부르면 안 되는 숫자"라고 정리했던 것과 같은 얘기다. 알아야 할 건 하나뿐이다. 둘 다 500ms 임계값을 여유 있게 통과했고, 둘 다 동기 구현 대비 수십~백 배 빠르다. 어느 쪽이 몇 % 더 빠른지는 이 시리즈의 주제가 아니다.
발급 성공 수가 매번 정확히 5,000이 아니라 4,995 정도로 나오는 실행도 있다. 3편에서 이미 짚었듯 이건 버그가 아니라 Redis 원자 연산이 보장하는 게 "정확히 5,000장"이 아니라 "최대 5,000장"이기 때문이다. 이번 편이 손댄 건 응답 속도지, 그 트레이드오프 자체는 그대로 남아 있다.