카테고리 없음

선착순 쿠폰 서비스 (3) — 응답은 74% 빨라졌는데 사용자는 그대로였다(비관적 락, Redis 도입)

경딩 2026. 9. 3. 09:09

2편에서 정확성은 끝났다. UPDATE ... WHERE issued_quantity < total_quantity 한 줄로 과발급도 갱신 손실도 사라졌다. 그런데 5,000장을 나눠주려고 92,730번 DB를 두드리는 구조는 그대로다.

이번 편에서는 재고 판정을 Redis로 꺼낸다. 전체 평균은 74% 빨라진다. 그리고 쿠폰을 받은 사람의 대기 시간은 1초도 줄지 않는다. 왜 그런지가 이 글의 주제다.

스택: Kotlin · Spring Boot · Spring Data JPA · MySQL 8 · Redis 8 · k6 · Docker Compose

 
참고 자료: 이 글은 아래 강의를 들으며 정리하고, 제 나름의 해석을 덧붙인 내용입니다.
https://www.inflearn.com/course/designing-a-server-s
 
이 글에서 얻어갈 것들

  • 락을 없앤 게 아니라 옮긴 것이다. Redis 원산 연산의 본질은 락 제거가 아니라 임계 구역을 가장 짧고 싼 곳으로 이동하는 것이다. 비관적 락도, 조건부 udpate 도, redis도 전부 직렬화한다 — 직렬 구간의 단가가 1,000배 다를 뿐이다.
  • 좋아 보이는 숫자가 나왔을 때가 가장 위험하다. 전체 평균 74% 개선, 중앙값 1,200배 개선. 그런데 성공 요청은 하나도 안 빨라졌다. 개선은 전부 거절 쪽에서 일어났다.
  • 과발급을 막은 대가는 과소발급이다. Redis 게이트가 보장하는 건 "정확히 5,000장"이 아니라 "최대 5,000장" 이다. 2편의 조건부 UPDATE에는 없던 문제이고, 이건 버그가 아니라 선택이다.
  • @Transactional은 Redis를 롤백하지 않는다. 트랜잭션 경계 안에 외부 시스템 호출이 있으면, 그건 트랜잭션이 아니라 "트랜잭션처럼 생긴 것"이다.
  • Redis에 무언가를 올릴 때 물어야 할 질문. "이 키가 지금 사라지면 무슨 일이 일어나는가?" 답이 "서비스가 멈춘다"면 그건 캐시가 아니라 데이터베이스다.

0. 2편까지의 상태

측정 조건, k6로 초당 5,000건씩 30초, 재고 5,000장짜리 쿠폰 하나에 집중 시킨다. 매 실행 전 테이블을 비워 같은 초기 상태에서 시작한다. 검증은 HTTP 응답이 아니라 DB에 남은 상태로 한다.— 장부(coupon.issued_quantity)와 실물(issuance 행 수)이 둘 다 정확히 5,000이어야 한다.


 

  정확성 성공 요청 p95 거절 비용
기본 구현 X 6.3배 과발급 5.88s 거절 자체가 없었다
DB 비관적 락 V 7.44s 락 큐 대기
조건부 UPDATE V (미측정) DB 왕복 1회

2편에서 얻은 것 두 가지만 기억하면 된다.

  • 정확성은 UPDATE ... SET x = x + 1 WHERE x < limit 한 줄로 이미 끝났다. Redis가 필요한 이유는 정확성이 아니다.
  • 집계 지표는 거짓말을 한다. 비관적 락은 전체 평균을 68% 개선했지만, 성공 요청 p95는 27% 악화시켰다. 실패가 성공보다 빠른 시스템에서는 실패율이 오르면 평균이 자동으로 좋아진다. 그래서 이 글에서도 계속 { expected_response:true } 줄을 본다.

남은 문제는 하나다. 매진 이후의 8만 건도 전부 MySQL까지 온다. 커넥션을 빌리고, 쿼리를 파싱하고, 락을 기다렸다가,"0건 영향" 하나 받고 돌아간다. DB가 자기 앞을 지키는 문지기 노릇까지 하고 있다.

redis는 정확성을 사러가는게 아니라, db 커넥션 풀을 지키러 가는 것이다.

1. Redis로 간다 - 분산 락이 아니라 원자 연산으로

먼저, Redis 분산 락은 답이 아니다.
Redis를 떠올리면 보통 분산 락이 먼저 나온다. coupon:{couponId} 키로 락을 잡고, 조회 → 증가 → 저장을 하고, 락을 푼다. DB행이 오래 잠기지 않으니 DB락 부담은 준다.
 
그런데 응답 시간은 거의 그대로다. 락을 어디에 두든 임계 구역안에 DB 왕복 3~4회가 그대로 들어있기 때문이다. 요청이 한 줄로 서는 것도 똑같다.

락의 위치를 옮기는 것과 임계 구역을 줄이는 것은 다른 일이다. 그리고 응답 시간을 결정하는 건 후자다.

그래서 이 프로젝트는 분산 락을 건너뛰었다. 대신 발상을 바꾼다.
모든 요청을 줄 세우는 대신, 줄을 설 자격이 있는 요청 수를 먼저 제한한다. 재고를 지키는 문 — 2편에서 게이트라고 부른 그 isSoldOut() 검사 — 을 DB에서 떼어 Redis로 올리는 것이다. 그러면 정확히 5,000건만 게이트를 통과하고, 통과한 요청끼리는 재고를 두고 경쟁할 일이 없어진다.
 
Lua 스크립트 - 왜 GET + DECR을 따로 쓰면 안 되는가?

Redis는 명령 하나하나는 원자적인지만, GET 하고 나서 DRCT하면 그 사이에 다른 클라이언트가 끼어든다. 2편에서 본 read-modify-write 문제가 Redis에서 재현될 뿐이다.
 
Lua 스크립트는 스크립트 전체가 하나의 명령처럼 실행된다.

local remaining = tonumber(redis.call('GET', KEYS[1]) or '0')
if remaining <= 0 then
  return 0
end
redis.call('DECR', KEYS[1])
return 1

 
여섯 줄이지만 이게 이 편의 핵심 정치다. 읽고, 판단하고 감소시키는 세 동작이 쪼개질 수 없는 하나가 된다.
거절당한 87,730건은 이 Lua 스크립트에서 0을 받고 즉시 응답한다. DB까지 올 기회가 없다.
 
발급 서비스 구현

@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) {
        val raw = redisTemplate.execute(script, listOf(stockKey(couponId)))
            ?: error("Lua 스크립트 결과가 null")

        when (raw) {
            1L -> Unit                        // 재고 확보 성공
            0L -> throw SoldOutException()    // 매진
            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"
}

stockKey()를 메서드로 뽑은 이유는, 키 문자열이 두 곳에 하드코딩되면 한쪽만 고치는 날 초기화와 차감이 서로 다른 키를 보게 되고 그게 에러가 아니라 "전부 매진"으로 조용히 나타나기 때문이다. 0L을 예외로, 그 외 값을 error()로 나눈 것도 의도적이다 — 알 수 없는 응답을 성공으로 처리하지 않는다.
 
카운터는 2편의 방식을 그대로 쓴다.
Redis 게이트만 세우고 나머지를 그대로 두면 효과가 사라진다. 이게 가장 놓치기 쉬운 부분이다.

val coupon = couponRepository.findById(couponId).orElseThrow { ... }   // 4,999를 읽음
// ...
couponIssuer.tryIssue(couponId)      // Redis에서 정확히 5,000건만 통과
coupon.issuedQuantity++              // ← 여기서 다시 갱신 손실

 
Redis가 통과 요청 수를 5,000으로 제한해도, 그 5,000건이 앞에서 조회해 둔 낡은 엔티티에 ++를 하고 있으면 2편의 갱신 손실이 그대로 재현된다. 게이트를 세워 놓고 그 뒤에서 같은 실수를 반복하는 셈이다.
 
그래서 카운터는 2편의 원자적 UPDATE를 쓴다. 다만 재고 판정은 이미 Redis가 하므로 WHERE 조건은 뺀다.

@Modifying
@Query("UPDATE Coupon c SET c.issuedQuantity = c.issuedQuantity + 1 WHERE c.id = :id")
fun incrementIssuedQuantity(@Param("id") id: Long): Int
더보기
더보기


주의: @Modifying 벌크 연산은 영속성 컨텍스트를 우회한다. 같은 트랜잭션에서 앞서 조회한 coupon 엔티티는 여전히 옛날 값을 들고 있다. 지금은 그 뒤에 수량을 읽지 않아 문제가 없지만, 나중에 누군가 if (coupon.isSoldOut())을 아래쪽에 추가하면 조용히 틀린 판단을 하게 된다. 그런 경우엔 @Modifying(clearAutomatically = true)가 필요하다. 
완성된 발급 로직

@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()          // 빠른 실패용 1차 필터
    if (issuanceRepository.existsByUserIdAndCouponId(userId, couponId)) {
        throw AlreadyIssuedException()
    }

    couponIssuer.tryIssue(couponId)                            // ← 진짜 게이트
    couponRepository.incrementIssuedQuantity(couponId)         // ← 원자적 증가

    return issuanceRepository.save(
        Issuance(
            userId = userId,
            couponId = couponId,
            issuedAt = now,
            expiresAt = now.plusDays(coupon.validityDays.toLong()),
        )
    )
}

재고 판정의 책임은 이제 전부  tryIssue 에 있다. 위쪽 검사들은 쿠폰 존재, 발급 시작 시각, 중복 발급만 본다.
 
왜 이게 빠른가 - Redis는 명령 실행이 단일 스레드다.
 
Redis가 명령을 한 번에 하나씩 실행하기 때문에 락이라는 개념 자체가 필요없다. 순서는 자동으로 보장된다.
그리고 그 한 건이 메모리 안에서 끝나므로 마이크로초 단위다.

 

여기서 왕복 몇회는 총 왕복이 아니라 락을 쥔 채로 하는 왕복이다. 뒤에 줄 선 요청이 기다리는 시간은 앞 요청의 총 처리 시간이 아니라 락 보유 시간이기 때문이다.
 

                직렬 구간          단가      그 구간에 줄 서는 요청
비관적 락        DB 왕복 4회       수 ms     92,730건 전부
조건부 UPDATE    DB 왕복 2회       수 ms     92,730건 전부
Redis 원자 연산  Redis 명령 1회    수 μs     92,730건 전부
               + DB 왕복 2회       수 ms     통과한 5,000건만

마지막 줄이 중요하다. Redis를 붙여도 DB의 직렬 구간은 사라지지 않는다. 통과한 요청도 결국 같은 쿠폰 행을 UPDATE하기 때문이다. 달라진 건 거기 줄 서는 인원이 92,730명에서 5,000명으로 줄었다는 것이다 

락을 없앤 게 아니라, 그 앞에 줄 서는 요청 수를 줄인 것이다.
이 관점에서 중요한 이유는 다음 개선 방향을 알려주기 때문이다. 락을 더 없애자가 아니라 아직 직렬로 남아 있는게 뭐지? 를 묻게 된다.

 

 


2. 측정값을 믿기 전에 볼 두 줄

위 실행의 k6 요약은 이랬다.

    http_req_duration.............: avg=415.39ms min=370µs    med=967µs p(90)=820.55ms p(95)=3.73s
      { expected_response:true }..: avg=4.56s    min=272.56ms med=4.86s p(90)=6.34s    p(95)=6.44s
    http_req_failed...............: 95.72% 112010 out of 117010
    http_reqs.....................: 117010 3899.77/s
    dropped_iterations............: 32991

첫 줄만 보면 압도적이다. 전체 평균 1.6초 → 415ms. 74% 개선. 중앙값은 1.18초에서 967마이크로초, 1,200배다.
 
여기서 축하하고 글을 끝내면 2편에서 스스로 경고한 함정에 그대로 빠진다. { expected_response:true } 줄을 봐야 한다.
 

  기본 구현 비관적 락 Redis 원자 연산
issued / rows 3,162 / 31,608 5,000 / 5,000 5,000 / 5,000
정확성 X FAIL ✅ PASS ✅ PASS
전체 평균 4.96s 1.6s 415ms
전체 중앙값 5.03s 1.18s 967µs
성공 요청 평균 4.96s 4.72s 4.56s
성공 요청 p95 5.88s 7.44s 6.44s
TPS 910 2,972 3,900
dropped_iterations 118,393 57,272 32,991

 
 
성공 요청 평균은 4.72초 → 4.56초. 3.4% 차이다. 그리고 3.4%는 개선이라고 부르면 안 되는 숫자다 — 부하 테스트는 코드를 한 줄도 안 바꾸고 다시 실행해도 이 정도는 흔들린다. 정직하게 쓰면 "변하지 않았다" 이다. p95만 7.44 → 6.44초로 13% 줄어서, 이쪽은 실제 개선일 가능성이 있다.

 

함정 1 - 목표의 79%를 보내지 못했다.

dropped_iterations: 32,991.

 

초당 5,000건 × 30초 = 15만 건을 보내려 했는데 실제로는 117,010건만 나갔다. 22%가 발사되지 못했다.

 

2편보다는 나아졌지만, 여전히 한계가 있다는 뜻이다. 이 수치가 있어야 "시스템이 초당 5,000건을 견뎌냈다"고 말할 수 없다.

원칙: dropped_iterations를 보지 않은 부하 테스트 결과는 전부 무효다

 

함정 2 — 거절은 1,000배 빨라졌다 (진짜)

 

중앙값 5초 → 1ms. 이게 진짜 변화다. 거절당한 요청들이 빨라진 것이다.

비관적 락에서는 매진 이후의 8만 건도 SELECT ... FOR UPDATE를 걸어야 했다. Redis 버전에서는 그 요청들이 락을 만나지 않는다. Lua 스크립트가 0을 반환하면 그대로 끝이다.

 

그 결과가 처리량으로 나타난다.

  • TPS 2,972 → 3,900 (31% 증가)
  • dropped_iterations 57,272 → 32,991 (42% 감소)

시스템이 더 많은 부하를 실제로 받아냈다. 왜? 거절이 싸기 때문이다.

 

함정 3 — 성공 경로는 왜 그대로인가

 

당첨자의 줄은 없어지지 않았기 때문이다.

Redis를 통과한 요청도 결국 같은 쿠폰 행 하나를 UPDATE한다. InnoDB는 여기서 배타 락을 잡고 커밋까지 쥔다. 즉 당첨된 5,000명은 UPDATE → INSERT → COMMIT을 그 행 위에서 여전히 한 줄로 통과해야 한다.

비관적 락        92,730건이 [ DB 왕복 4회 ] 를 한 줄로 통과
Redis 원자 연산   5,000건이 [ DB 왕복 2회 ] 를 한 줄로 통과

줄이 짧아지고 인원도 18분의 1로 줄었지만, 당첨자 입장에서는 여전히 줄이다. 성공 p95가 7.44 → 6.44초로 13%밖에 안 줄어든 이유가 여기 있다.

Redis가 없앤 것은 87,730건이 그 줄에 서는 일이지, 줄 자체가 아니다.
그리고 사용자가 기다리는 4.5초는 그 줄에서 나온다.

3. 우리가 얻은 것과 남은 것

  임계 구역 정확성 거절 비용 성공 요청 p95 부작용
기본 구현 없음 ❌ 6배 과발급 거절 자체가 없음 4.96s
DB 비관적 락 조회~커밋 전체 (왕복 4회) 락 큐 대기 7.44s 핫 로우 직렬화, 커넥션 풀 점유
Redis 분산 락 조회~커밋 전체 (위치만 이동) 락 큐 대기 TTL 운영 이슈 (미구현)
조건부 UPDATE UPDATE~커밋 (왕복 2회) 누수 없음 DB 왕복 1회 거절 요청도 전부 DB까지 옴
Redis 원자 연산 Redis 명령 1회 + DB 왕복 2회
(단, 통과분 5,000건만)
✅ (상한) 1ms 미만 6.44s 재고 누수 가능 (3절)

정확성의 현주소

 

장부 5,000, 실물 5,000, 초과 0. verify.sh는 PASS를 찍는다.

다만, Redis는 가볍고 빠른 대신 DB와는 독립적인 시스템이라는 점이 중요하다. 만약 Redis가 재시작되고 키가 없어진다면? 그 순간 모든 요청이 SOLD_OUT을 받는다. DB에는 3,000장이 남아 있어도.

 

이건 fail-closed 전략이라 의도적이다. 의심스러울 때 무엇을 하는가가 안전한 시스템을 가른다. 문제는 복구 경로가 필요하다는 것인데, 그건 4편의 주제다.

 

거절은 정말 빨라졌는가

네. 1,000배다. 중앙값 5초 → 1ms. 이것이 Redis 도입의 진짜 효과다.

 

성공 요청은 변하지 않았다. 정확히는 13% 개선이지만, 이건 실측 편차 범위 내다.

왜? 당첨된 5,000명이 같은 행 위에서 INSERT를 기다리고 있기 때문이다. Redis 는 그 줄을 없애지 않았다. 

줄을 설 자격이 있는 사람만 제한했다.


4. 다음 편으로 가기 — 판정과 기록을 분리한다

1절의 그림이 다음을 정해 준다.

비관적 락        92,730건이 [ DB 왕복 4회 ] 를 한 줄로
Redis 원자 연산   5,000건이 [ DB 왕복 2회 ] 를 한 줄로

 

거절은 1,000배 빨라졌다. 그런데 성공 요청은 여전히 4.5초다. 이 서비스에서 가장 중요한 5,000 명이 가장 오래 기다리고 있다.

 

Redis게이트를 통과한 요청은 여전히 DB쓰기 2회를 동기적으로 기다리기 때문이다. 사용자는 발급 성공을 알기 위해 INSERT 가 끝날 때까지 잡혀 있다.

 

그런데 사용자가 정말 알아야 하는 건 무엇인가?

 

내가 5,000명 안에 들었는가? - 그건 이미 Redist에서 결정됐다.

 

나머지는 기록뿐이다. DB에 저장하는 것은 사용자를 기다리게 할 이유가 없다.

 

4편에서는 이 둘을 분리한다. 응답과 기록을 시간으로 나눈다. 당첨이 확정되면 즉시 응답하고, DB 기록은 큐를 통해 미룬다.

응답 시간은 Redis왕복 수준으로 떨어진다.

 

그 대신 새로운 세계가 열린다. 최종적 일관성(eventaul consistency), 이중 쓰기 문제, 보상 트랜잭션- 분산 시스템의 진짜 어려움들이 시작된다.

 

그리고 가장 중요한 통찰: 빠른 응답과 정확한 일관성을 동시에 가져야 할 때, 무엇을 택하는가


정리 

3편을 닫으며, 2편부터 여기까지 반복된 패턴을 정리한다.

 

무언가를 얻으면 그 대가가 다른 곳에서 나타난다.

  • 비관적 락- 정확성을 얻고 동시에 팔았다. p95가 27% 악화 
  • 조건부 업데이트 - 정확성을 지키고 동시성을 되찾았다. 대신 거절도 DB까지 온다.
  • Redis원자 연산 - 거절을 1ms 미만으로 만들었따. 성공 요청은 그대로

그리고 가장 위험한 순간은 버그를 만났을 때가 아니라 좋아 보이는 숫자를 만났을 때였다. 전체 평균 74% 개선이라는 숫자에 반해 정작 사용자가 기다리는 시간은 끝내 줄지 못했다는 사실을 놓칠 뻔 했다.

측정하지 않는 것은 개선되지 않는다. 그리고 검증 항목을 정하는 순간, 그 목록에 없는 실패는 일어나도 초록색으로 나온다.

2편에서 정리한 그 문장을 다시 한 번.

무엇을 포기했는지 알고 있는가.

4편에서 그 포기의 정체를 더 명확히 한다.