슈뢰딩거의 재고: RedisTimeoutException과 불확실성 해결

이준우·2025년 12월 30일

문제를 인식하게 된 과정

이전 테스트를 통해 대용량 트래픽 환경에서 안정적으로 재고를 관리하려면 Redis의 원자적 연산이 필요하다는 결론을 내렸습니다. 애플리케이션 라이브러리로 Redisson을 선택한 후, 학습을 위해 테스트 코드를 작성하던 중 우연히 Docker와의 네트워크 예외가 발생했습니다.

Redisson 학습 테스트 도중 RedisTimeoutException 발견

이 과정에서 Redisson이 발생시킬 수 있는 Redis 네트워크 예외의 종류를 확인했습니다.

RedisConnectionException

  • Redis와의 연결이 완전히 끊긴 상태입니다.
  • 서비스 측면에서는 크리티컬한 문제지만, 정합성 관점에서는 명확합니다. 데이터가 Redis에 도달하지 않았음이 확실하기 때문입니다.

RedisTimeoutException

  • Redis와 연결 수립은 완료되었으나 응답을 받지 못한 경우입니다.
  • 가장 큰 문제는 Redis에 값이 도달했는지 도달하지 않았는지 알 수 없다는 점입니다.
  • 슈뢰딩거의 고양이처럼 연산이 적용되었는지 알 수 없는 미지의 상태입니다.

특히 Redisson의 재시도 설정을 기본값(4회)으로 두었을 경우, 최초 요청 1회 + 재시도 4회로 총 5번의 연산이 적용될 수 있습니다. 이를 확인하기 위해 다음과 같은 테스트 코드를 작성했습니다.

@Test
fun `batch() 중 네트워크 Timeout 장애가 발생했을 경우`() {
    val key = "product:1"

    val batch = redissonClient.createBatch(
        BatchOptions.defaults().executionMode(BatchOptions.ExecutionMode.IN_MEMORY)
            .responseTimeout(1, TimeUnit.SECONDS)
    )
    batch.getAtomicLong(key).addAndGetAsync(1L)

    // DOWNSTREAM -> 응답 자체가 안옴
    val toxi = redisProxy.toxics().timeout("timeout", ToxicDirection.DOWNSTREAM, 0)

    val error = assertThrows<RedisResponseTimeoutException> { batch.execute() }
    logger.info { "error message: ${error.message}" }

    toxi.remove()

    val result = redissonClient.getAtomicLong(key).get()
    // 1회 + 재시도 기본값 4회 = 5회
    assertEquals(5L, result)
}

테스트 결과, RedisResponseTimeoutException이 발생했음에도 불구하고 실제 Redis에는 5번의 연산이 모두 적용되었습니다. 클라이언트는 예외를 받았지만, 서버는 모든 요청을 처리한 것입니다.

[테스트 컨테이너를 pause()로 일시 정지하면 TCP 버퍼에 요청이 쌓여서 한 번에 처리되는 현상 (7초)]

재시도 정책은 데이터가 도달하지 못한 것이 확실하거나, 멱등성을 보장하는 경우에만 사용해야 합니다.

재고 관리 API에서의 영향 분석

제 서비스의 재고 관리 API는 4가지입니다.

  1. 재고 선점: 구매 확정 전 Redis 재고를 먼저 차감
  2. 재고 차감: 구매 확정 시 MySQL 재고 차감
  3. 재고 선점 해제: 구매 미확정 시 Redis 재고 복구
  4. 재고 증가: 관리자의 재고 추가 시 MySQL과 Redis 모두 증가

이 4가지 기능은 결국 Redis의 product_id 값을 증가 또는 감소시키는 연산입니다.

재고를 감소시키는 로직에서 RedisTimeoutException 발생 시:

  • 연산이 적용되었다면: 실제보다 적은 재고 → 판매 기회 손실 (undersell)
  • 연산이 적용되지 않았다면: 예외 처리로 해결

재고를 증가시키는 로직에서 RedisTimeoutException 발생 시:

  • 연산이 적용되었다면: 실제보다 많은 재고 → 없는 재고 판매로 고객 보상 필요 (oversell)
  • 연산이 적용되지 않았다면: 재시도 필요

결론적으로 보정 작업 없이는 판매자가 어떤 경우든 손실을 입을 수밖에 없는 구조입니다.

다만 제 서비스에서는 oversell을 절대 허용하지 않는다는 기조를 가지고 있습니다. 일시적인 정합성 문제로 인한 undersell은 배치를 통해 점진적으로 해결할 수 있지만, oversell은 고객 신뢰와 직결되는 치명적인 문제이기 때문입니다.

정합성을 맞추려면 해당 연산이 실제로 Redis에 도달하여 실행되었는지 여부를 확인해야 하며, 이를 request_key로 해결하기로 했습니다.

문제를 해결하기 위한 과정

예외 발생 시 DB 로그 저장 및 Batch 보정 아이디어

사용자는 여러 상품을 구매할 수 있기 때문에 재고 API들은 Redis 파이프라인을 활용하여 N개 상품의 재고를 동시에 수정합니다. 여기에 request_key를 추가로 전송합니다.

  • Redis 파이프라인: N개의 명령을 묶어 한 번에 전송하여 네트워크 비용 절감 (원자성 보장 아님)

RedisTimeoutException 발생 시, 데이터베이스에 해당 request_key와 전송했던 데이터의 로그를 저장합니다.

5분 간격의 배치 작업을 통해 데이터베이스에 저장된 로그를 읽어와 Redis와의 정합성을 맞춥니다.

Redis 파이프라인으로 한 번에 데이터를 전송했기 때문에 일부 데이터만 유실되는 경우는 고려하지 않아도 됩니다.

시스템 흐름도

전체 시스템의 상호작용을 시퀀스 다이어그램으로 표현하면 다음과 같습니다.

예외 상황별 처리 전략

Redis에 명령어가 도달하지 않은 경우

배치 과정에서 request_key를 확인합니다:

  • Redis에 존재하는 경우: 연산이 이미 수행되었다고 판단
  • Redis에 존재하지 않는 경우: 패킷 손실로 판단하여 재시도

RedisTimeoutException

  • 데이터가 도달했는지 알 수 없으므로 Batch로 정합성 확인 필요

RedisConnectionException

  • 데이터가 도달하지 못했음이 확실
  • 반드시 적용되어야 하는 API(재고 증가)만 Batch로 보정
  • 재고 차감은 사용자에게 예외를 반환하면 실패 처리되므로 무시

oversell 방지 원칙

  • 재고 감소 연산에서 예외 발생 시, 보수적으로 접근하여 연산이 적용되지 않았다고 가정합니다.
  • undersell(판매 기회 손실)은 배치를 통해 점진적으로 복구할 수 있지만, oversell(없는 재고 판매)은 절대 발생시키지 않습니다.

정합성 보정 Batch 로직

  1. 5분마다 MySQL에 저장된 로그 확인 (1분 전 데이터를 조회하여 Redis 처리 완료 시간 확보)
  2. 로그의 request_key가 Redis에 존재하는지 확인
    • 존재하는 경우: 이미 연산 완료되었으므로 패스
    • 존재하지 않는 경우: Redis에 도달하지 않았으므로 연산 재수행

1분 전 데이터를 조회하는 이유:

  • API 서버가 Redis에 request_key를 전송했지만, 네트워크 지연이나 Redis 부하로 인해 아직 처리/저장이 완료되지 않을 수 있습니다.
  • 만약 방금 생성된 로그를 바로 조회하면, Redis 처리가 진행 중인 상태에서 request_key를 "없음"으로 잘못 판단하여 중복 연산이 발생할 수 있습니다.
  • 1분의 여유 시간을 두면 Redis 연산이 완료될 확률이 높아지므로, request_key 존재 여부를 정확하게 판단할 수 있습니다.

Batch 과정에서도 네트워크 예외가 발생할 수 있습니다.

  • 재고 증가(롤백) 연산의 경우는 RedisTimeoutException이든 RedisConnectionException이든 반드시 연산을 수행해야 합니다.
  • oversell 방지 원칙에 따라, 증가 연산은 적극적으로 재시도하여 정합성을 맞춥니다.

구현

앞서 설계한 해결 방안을 실제로 구현한 내용입니다. 핵심은 requestKey를 통한 중복 연산 방지배치를 통한 장애 복구입니다.

핵심 구현 포인트

requestKey 생성 및 Redis 연산

override fun reserve(vararg changes: StockChange) {
    val requestKey = UUID.randomUUID().toString()
    redisStockRepository.decreaseStock(requestKey, *changes)
}
// requestKey를 Redis에 저장 (TTL 1일)
this.getBucket<String>(generateRequestKey(requestKey))
    .setAsync("1", 1, TimeUnit.DAYS)

원자적 배치 연산

val batch = generateBatch(requestKey)

stockChanges.sortedBy { it.productId }.forEach {
    batch.getAtomicLong(generateStockKey(it.productId))
         .addAndGetAsync(-it.quantity)
}

batch.execute().responses.drop(1).map { it as Long }.apply {
    if (this.any { it < 0 }) throw ProductOutOfStockException()
}
  • productId 정렬로 데드락 방지
  • AtomicLong으로 개별 연산의 동시성 안전 보장
  • 파이프라인으로 네트워크 비용 절감
  • 음수 검증으로 재고 부족 즉시 감지

재시도 차단

BatchOptions.defaults()
    .executionMode(BatchOptions.ExecutionMode.IN_MEMORY)
    .responseTimeout(1, TimeUnit.SECONDS)
    .retryAttempts(0)  // 중복 실행 방지

테스트에서 확인했듯이, 재시도 설정을 기본값으로 두면 5번의 중복 연산이 발생할 수 있으므로 재시도를 차단합니다.

장애 복구

에러 로그 저장

catch (e: RedisTimeoutException) {
    applicationScope.launch {
        saveRedisStockChangeException(requestKey, *stockChanges)
    }
    throw InfraException(e)
}

RedisTimeoutException 발생 시:

  • 재고 감소: 사용자에게 예외 반환 (oversell 방지)
  • 재고 증가: MySQL에 로그 저장 후 배치에서 복구

배치 복구 로직

@DisallowConcurrentExecution
class StockConsistencyBatchJob : QuartzJobBean() {
    override fun executeInternal(context: JobExecutionContext) {
        // 1분 이상 된 미처리 에러 로그 조회
        errorLogRepository.findAllErrorLog(
            reason = ErrorLogType.STOCK_CHANGE,
            typeRef = object : TypeReference<List<StockChange>>() {}
        ).forEach { errorLog ->
            val requestKey = errorLog.requestKey

            try {
                // requestKey가 Redis에 존재하면 이미 처리됨
                if (redisStockRepository.hasRequestKey(requestKey)) {
                    errorLogRepository.setExecuted(requestKey)
                    return@forEach
                }

                // 재고 증가로 복구
                redisStockRepository.increaseStock(
                    requestKey = requestKey,
                    stockChanges = errorLog.content.toTypedArray()
                )

                errorLogRepository.setExecuted(requestKey)
            } catch (e: Exception) {
                when (e) {
                    is RedisTimeoutException, is RedisConnectionException -> {
                        logger.warn { "네트워크 장애로 재시도 대기: $requestKey" }
                    }
                    else -> errorLogRepository.setExecuted(requestKey)
                }
            }
        }
    }
}
// 1분 이상 경과한 로그만 조회 (Redis 처리 완료 시간 확보)
fun <T> findAllErrorLog(reason: ErrorLogType, typeRef: TypeReference<T>): List<ErrorLog<T>> {
    val sql = """
        SELECT request_key, request_content as content
        FROM exception_logs
        WHERE reason = ?
          AND is_executed = false
          AND created_at <= DATE_SUB(NOW(), INTERVAL 1 MINUTE)
        ORDER BY created_at
    """

    return jdbcTemplate.query(sql, rowMapper, reason.name)
}

oversell 방지 전략

재고 감소 연산 실패 시 사용자에게 즉시 예외를 반환하여 oversell을 원천 차단합니다. 일시적인 undersell은 배치를 통해 점진적으로 복구되지만, oversell은 고객 신뢰와 직결되므로 절대 허용하지 않습니다.

재고 증가 연산은 MySQL에 이미 반영되었으므로 Redis에도 반드시 적용되어야 하며, 배치가 이를 보장합니다.

결론

정리

Redis의 RedisTimeoutException은 연산 적용 여부를 알 수 없는 불확실한 상태를 만듭니다. 이 문제를 해결하기 위해 다음 전략을 사용했습니다.

  • requestKey 기반 요청 추적: 모든 요청에 고유 키를 부여하여 처리 상태를 조회할 수 있습니다.
  • 재시도 차단: retryAttempts(0)으로 설정하여 중복 연산을 원천 차단합니다.
  • 배치 복구: 예외 발생 시 MySQL에 로그를 저장하고, 5분 간격 배치가 정합성을 복구합니다.
  • oversell 절대 방지: 재고 감소 실패는 즉시 예외 반환, 재고 증가 실패는 배치가 반드시 복구합니다.

이 접근 방식은 가용성보다 정합성을 우선하며, 최종적 일관성(Eventual Consistency)을 통해 실시간 정합성과 시스템 복잡도 사이의 균형을 맞춥니다.

profile
잘 살고 싶은 사람

0개의 댓글