Part 1. 문제 상황
https://chobo-backend.tistory.com/56
[바로] 일괄 주문 기능 개선 #1 (Ft. Lua Script, 비동기)
https://chobo-backend.tistory.com/54 [바로] 단일 주문 성능 개선 삽질기 (Ft. 목표 TPS 1666 vs 현실 187.4)상품을 주문하는 행위는 E-Commerce 도메인에서 가장 중요한 기능 중 하나이다. 먼저 재고 관리 측면에서
chobo-backend.tistory.com
바로 전 포스팅에서 일괄 주문을 개선하면서 Redis로 가져온 재고 데이터가 부하테스트 이후 불일치하다는 것을 체크했다
당황스러웠다.. DB에서 주문 수와 물품 수량은 데이터 불일치가 없었는데, Redis에서만 불일치하다는 것이 이해가 안갔다. 아주 많은 삽질을 하면서 결국 해결은 했지만, 본인의 로직에서 실수로 발생했던 것이었다.. 하지만 문제를 찾아가는 과정에서 알게된 것들을 남겨본다
Part 2. 해결 과정
1. AFTER_ROLLBACK에 대한 오해
먼저 기존에 구현했던 롤백 로직을 의심했다. 왜냐하면 12번의 테스트를 거치며 Redis의 데이터가 DB보다 더 많이 감소되는 상황은 전혀 발견되지 않았기 때문이다. 먼저 Rollback 로직을 보자.
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
fun restoreInventoryOnRollback(event: InventoryDeductionRequestedEvent) {
runCatching {
inventoryRedisRepository.restoreStocks(event.items)
}.onFailure { ex ->
log.error(ErrorMessage.INVENTORY_RESTORE_ERROR.format(event.orderId), ex)
}
}
코드를 보면 AFTER_ROLLBACK 단계에 진행하는 것을 알 수 있다. 이 과정이 진행되기전 메인로직 흐름은 다음과 같다.
1. User(유저) 존재 여부 체크
2. Product(상품) 조회
3. Order(주문), OrderItem(주문 상품) 생성
4. 주문 목록에 따른 Redis 재고 감소
5. Spring Event 발급
Redis는 Rollback이 불가능했고, 따라서 Rollback에 대한 영향을 가장 적게 받는 위치인 마지막 부분에 Redis 감소 로직을 두었다. 그리고 위의 Rollback 로직의 경우에는 Event 발급 이후에 문제가 생긴 경우 AFTER_ROLLBACK이 실행되는 줄 알았으나, Event 발급이 포함된 Transaction 전체에서 Rollback이 발생 할 경우 해당 로직 실행되는 것이었다. 따라서 Redis에 재고가 감소되지도 않았는데 원상복귀(+주문 수량)하는 상황이 발생할 수 있다는 것이었다.
2. Lua Script 도중 실패하는 것일까?
물품 1,2,3번의 개수가 다른 상황이 잘 이해가 안갔다. 분명 아래의 Redis 공식 문서에 의하면 Redis는 Lua Script가 Atomic하게 실행된다는 것을 보장한다고 하고 있다. 그렇다면 흔히 알려진 트랜잭션 ACID에 에서 A(Atomicity)의 의미는 하나의 트랜잭션이 전부 성공하거나 전부 실패하거나 원자적으로 동작한다는 뜻이다. 즉 이에 따르면, 현재 발생한 이 상황은 발생해서는 안되는 상황이었다.
https://redis.io/docs/latest/develop/programmability/eval-intro/#interacting-with-redis-from-a-script
Redis guarantees the script's atomic execution. While executing the script, all server activities are blocked during its entire runtime. These semantics mean that all of the script's effects either have yet to happen or had already happened.
여러 사례들을 찾아보던 중 본인과 비슷한 상황에 있는 글을 발견했다. 내용을 요약하면 Lua Script 도중 에러가 발생하면 기존까지 진행됐던 상황은 복구되지 않는다는 것이었다. 실제로 테스트를 통해 해당 현상이 발생한다는 것을 확인했다.
for i = 1, #KEYS do
local key = KEYS[i]
local deductAmount = tonumber(ARGV[i])
-- i가 2일 때 에러 발생
if i == 2 then
error("Error occurred at iteration i=2")
end
redis.call('DECRBY', key, deductAmount)
end
i = 2에서 에러가 발생했어도 i = 1에서 재고 차감은 그대로 적용된다..!
아래에 현재 Redis에 열려있는 이슈를 통해 더 자세한 내용이 확인가능하다.
https://github.com/redis/redis/issues/10576
Support rollback in Lua script · Issue #10576 · redis/redis
The problem/use-case that the feature addresses My company uses Redis as a main database. Lua script is heavily used to execute complicated business logic on Redis to provide atomicity. The script ...
github.com
그렇다면 현재 부하테스트 과정 속에서 예상치 못한 에러로 인해 실패하는 것일까? 하지만 Redis 로그를 살펴보았지만 아래 이외의 별다른 에러 로그는 확인할 수 없었다.
1:C 04 Sep 2025 02:05:17.604 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can also cause failures without low memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
번외로 위 로그를 살펴보면 vm.overcommit_memory = 1 로 두어서 항상 overcommit을 허용하라는 로그이다. Redis의 경우 AOF 파일 재작성하거나 replication 작업을 하는 상황에서 fork()를 사용하는데 이때 자식 프로세스가 부모의 전체 메모리를 복사할 수 있다고 가정하면 이론적으로 2배의 메모리가 필요하다. 따라서 메모리가 제한적인 환경이거나 AOF, Replication 같이 데이터 지속성이 중요한 경우 overcommit을 허용하는 것이 좋다
Part 3. 해결 방법
해결 방법은 허무하게도 Redis에 key가 없을 때 DB 최신화를 하는 과정이 문제였다. 아래의 기존 코드를 보면,
private fun executeScript(
script: String,
items: List<InventoryItem>,
autoInitializeFromDb: Boolean,
shouldThrowError: (Long) -> Boolean
): Boolean {
val keys = items.map { getStockKey(it.productId) }
val quantities = items.map { it.quantity.toString() }
var retryCount = 0
while (retryCount < MAX_RETRY_COUNT) {
val redisScript = redissonClient.getScript(StringCodec.INSTANCE)
val result = redisScript.eval<Long>(
RScript.Mode.READ_WRITE,
script,
RScript.ReturnType.INTEGER,
keys,
*quantities.toTypedArray()
)
when (result) {
MISSING_KEYS -> {
if (autoInitializeFromDb) {
val productIds = items.map { it.productId }
initializeStocksFromDb(productIds)
retryCount++
continue
}
return shouldThrowError(result)
}
INVALID_AMOUNT -> throw IllegalArgumentException(ErrorMessage.INVALID_STOCK_AMOUNT.message)
ERROR_WITH_ROLLBACK -> throw IllegalArgumentException("Error with rollback")
else -> {
return shouldThrowError(result)
}
}
}
throw IllegalStateException(ErrorMessage.INVENTORY_RETRY_EXCEEDED.format(MAX_RETRY_COUNT))
}
MISSING_KEYS를 처리하는 부분에서 다음과 같이 진행되면서 동시성 문제가 발생했던 것이다
1. 재고 관리 로직 중에서 Key를 찾지 못한 여러 작업들 발생
2. 이 작업들이 MISSING_KEYS 재처리 로직으로 몰림
3. 가장 마지막에 도착한 작업이 비동기로 인해 전부 반영되지 않은 DB 데이터로 최신화시킴
4. 초반에 키를 초기화하고 재고 감소 처리한 작업들이 증발
따라서 이 상황을 막기 위해 Lua Script를 통해 Key가 존재하는지 여부를 한번 더 체크하는 로직으로 아래와 같이 변경시키면서 데이터 정합성 문제는 해결되었다.
local setCount = 0
for i = 1, #KEYS do
local key = KEYS[i]
local value = ARGV[i]
if redis.call('EXISTS', key) == 0 then
redis.call('SET', key, value)
redis.call('EXPIRE', key, 43200) -- 12 hours
setCount = setCount + 1
end
end
return setCount
Part 4. Redis의 Lua Script가 Atomic 하다?
Redis는 왜 자동 Rollback을 지원하지 않을까?
결론적으로 Redis 공식문서에서 Atomic 하다는 것의 의미는 트랜잭션 ACID 특성 중에서 Isolation에 가깝다고 생각한다. 그럼 왜 Redis는 Transaction Rollback을 구현하지 않았을까? Redis 공식 블로그의 의하면 의견은 다음과 같다
https://redis.io/blog/you-dont-need-transaction-rollbacks-in-redis/
1. The snapshotting mechanism required to implement rollbacks would have a considerable computational cost. That extra complexity wouldn’t sit well with Redis’ philosophy and ecosystem.
2. Rollbacks can’t catch all errors. In the example above, we set “counter” to “banana” in order to show a blatant error, but in the real world the process that used the “counter” key in the wrong way might instead have deleted it, or put in a credit-card number, for example. Rollbacks would add a considerable amount of complexity and would still not fully solve the problem.
즉 요약하면, MySQL의 MVCC와 같은 스냅샷 매커니즘은 많은 비용을 발생시키고 이는 빠른 응답을 목표로하는 Redis의 철학과 다르다. 또한 Rollback을 지원한다고 해서 모든 에러를 잡아낼 수 없기에 동의하지 않는다고 한다.
결국 Redis의 Lua Script는 Atomicity하지 않다
공식문서의 말만 믿고 우리가 흔히 알고 있는 트랜잭션의 Atomicity(원자성)으로 착각하고 구현을 진행한다면 운영 도중 예상치 못한 이슈를 마주하게 될 것이다. 따라서 요구사항에 따라 데이터 정합성이 매우 중요한 경우라면 AOF를 통해 데이터의 일관성과 무결성을 지켜야한다. 다만 '바로' 서비스의 경우 물품의 재고 개수를 100% 일치시킬 필요는 없다고 생각한다. 일단 에러가 발생할 확률이 낮아보이며, 재고 데이터를 Redis에 TTL 없이 무제한 등록시킬 것도 아니기 때문에 주기적으로 최신화시켜주어 이를 해결할 수 있을 것이라 생각한다
'프로젝트' 카테고리의 다른 글
[바로] 일괄 주문 기능 개선 Vol.2 (Ft. Kafka, Transactional Outbox) (0) | 2025.09.15 |
---|---|
[바로] 인기상품조회 기능 개선 (Ft. DB Connection Pool) (0) | 2025.09.05 |
[바로] 일괄 주문 기능 개선 Vol.1 (Ft. Eventual Consistency, Lua Script) (0) | 2025.08.27 |
[바로] DeadLock 범인 찾기 (Ft. 위험한 FK?) (3) | 2025.08.25 |
[바로] 단일 주문 성능 개선 삽질기 (Ft. JPA save, FK) (0) | 2025.08.20 |