지금까지 재고 동시성은 Redis Lua 스크립트로 제어했다. reserve → commit → release 3단계 구조로 오버셀링을 막았고, 낙관적 락으로 스케줄러와 결제 핸들러의 충돌을 처리했다.
그런데 주문 취소는 다른 문제다.
재고 예약은 단일 원자적 연산이라 Lua 스크립트로 처리할 수 있었다. 주문 취소는 다르다. Redis에서 재고를 해제하고, DB에서 주문 상태를 변경하는 두 가지 작업이 순서대로 일어나야 한다. 동시에 같은 주문에 대해 취소 요청이 두 번 들어오면 재고가 두 번 해제될 수 있다.
이걸 막으려면 분산락이 필요하다.
Redis로 분산락을 구현하는 방법은 여러 가지가 있다.
방법 1: SET NX EX (직접 구현)
SET order:lock:{orderId} 1 NX EX 30
가장 단순하다. 키가 없을 때만 저장(NX)하고, 30초 후 자동 만료(EX)된다. 직접 구현하면 이 정도 코드가 필요하다.
val acquired = redisTemplate.opsForValue()
.setIfAbsent("order:lock:$orderId", "1", 30, TimeUnit.SECONDS) ?: false
if (acquired) {
try {
// 비즈니스 로직
} finally {
redisTemplate.delete("order:lock:$orderId")
}
}
단순하지만 문제가 있다. leaseTime(30초)을 넘기는 작업이 생기면 락이 만료되고 다른 스레드가 진입할 수 있다. 락이 만료된 상태에서 finally의 delete가 실행되면 다른 스레드가 획득한 락을 해제해버린다.
방법 2: Redisson
Redisson은 Redis 기반의 Java 클라이언트 라이브러리다. 분산락을 포함한 다양한 분산 자료구조를 제공한다. 직접 구현의 문제들을 내부적으로 처리해준다.
이 프로젝트는 이미 Redis Cluster를 쓰고 있고, Watch Dog 기능이 필요했다. Redisson을 선택했다.
// order-service/build.gradle.kts
implementation("org.redisson:redisson-spring-boot-starter:3.27.2")
redisson-spring-boot-starter를 쓰면 Spring Boot 자동 설정을 활용할 수 있다. 하지만 Redis Cluster 설정을 application.yml에서 읽어서 Redisson에 적용하려면 별도 설정이 필요했다.
@ConfigurationProperties(prefix = "spring.data.redis.cluster")
data class RedisClusterProperties(
var nodes: List<String> = emptyList()
)
@Configuration
@EnableConfigurationProperties(RedisClusterProperties::class)
class RedissonConfig(
private val redisClusterProperties: RedisClusterProperties
) {
@Bean
fun redissonClient(): RedissonClient {
val config = Config()
val clusterServersConfig = config.useClusterServers()
// application.yml의 노드 목록을 그대로 사용
redisClusterProperties.nodes.forEach { node ->
clusterServersConfig.addNodeAddress("redis://$node")
}
clusterServersConfig
.setScanInterval(2000) // 클러스터 상태 스캔 간격
.setConnectTimeout(10000) // 연결 타임아웃
.setTimeout(3000) // 응답 타임아웃
.setRetryAttempts(3) // 재시도 횟수
.setRetryInterval(1500) // 재시도 간격
return Redisson.create(config)
}
}
@ConfigurationProperties로 application.yml의 spring.data.redis.cluster.nodes 목록을 읽어온다. 기존 Spring Redis 설정과 동일한 노드 목록을 Redisson에도 그대로 적용한다. 설정 파일을 이중으로 관리할 필요가 없다.
주문 취소 플로우는 이렇다.
1. Redis에서 재고 해제 (release.lua)
2. DB에서 주문 상태 ORDER_CANCELED로 변경
동시에 같은 주문 취소 요청이 두 번 들어오면?
Thread A: Redis 재고 해제 → DB 상태 변경 시작...
Thread B: Redis 재고 해제 → (이미 해제됐지만 hold 키가 없으니 아무것도 안 함)
→ DB 상태 변경 시작...
release.lua는 hold 키 EXISTS 체크로 중복 해제를 막는다. 하지만 두 스레드 모두 DB 상태 변경을 시도할 수 있다. 낙관적 락으로 막을 수 있지만, 취소는 충돌 빈도가 더 높을 수 있다. 분산락으로 아예 동시 진입 자체를 막기로 했다.
@Service
class OrderCancelService(
private val orderCancelExecutor: OrderCancelExecutor,
private val redissonClient: RedissonClient
) {
fun cancel(orderId: UUID, reason: String): Boolean {
val lockKey = "order:lock:$orderId"
val lock = redissonClient.getLock(lockKey)
return try {
// 최대 10초 대기, 30초 후 자동 해제
val acquired = lock.tryLock(10, 30, TimeUnit.SECONDS)
if (!acquired) {
logger.warn { "주문 취소 락 획득 실패: orderId=$orderId" }
return false
}
try {
orderCancelExecutor.execute(orderId, reason) // 실제 취소 로직
} finally {
lock.unlock()
}
} catch (e: Exception) {
logger.error(e) { "주문 취소 처리 중 에러: orderId=$orderId" }
false
}
}
}
락 키를 order:lock:{orderId}로 설정했다. 같은 주문에 대한 취소 요청은 락을 먼저 획득한 쪽만 처리하고, 나머지는 10초 대기 후 실패 반환한다.
처음엔 하나의 클래스에 다 넣으려고 했다.
// ❌ 처음에 시도한 구조
@Service
class OrderCancelService(
private val redissonClient: RedissonClient,
private val ordersRepository: OrdersRepository,
private val inventoryReservationService: InventoryReservationService
) {
fun cancel(orderId: UUID, reason: String): Boolean {
val lock = redissonClient.getLock("order:lock:$orderId")
val acquired = lock.tryLock(10, 30, TimeUnit.SECONDS)
if (acquired) {
try {
execute(orderId, reason) // 같은 클래스 내부 호출
} finally {
lock.unlock()
}
}
return acquired
}
@Transactional // ← 이게 작동하지 않는다!
fun execute(orderId: UUID, reason: String): Boolean {
// 취소 로직
}
}
@Transactional이 붙은 execute()를 같은 클래스 안에서 호출하면 트랜잭션이 적용되지 않는다.
Spring의 @Transactional은 AOP 프록시로 동작한다. 외부에서 orderCancelService.execute()를 호출할 때는 Spring이 만든 프록시 객체를 통해 호출되고, 프록시가 트랜잭션을 시작한다.
외부 호출: 프록시 → @Transactional 시작 → execute() 실행
그런데 같은 클래스 안에서 execute()를 호출하면 this.execute()가 된다. 프록시를 거치지 않고 실제 객체를 직접 호출한다. 트랜잭션이 없다.
내부 호출: this.execute() → 프록시 우회 → @Transactional 무시
이게 Self-invocation 문제다.
해결책은 두 가지다.
방법 1: ApplicationContext에서 자기 자신을 꺼내서 호출
@Autowired
private lateinit var self: OrderCancelService
self.execute(orderId, reason) // 프록시를 통해 호출
동작하긴 하지만 어색하다. 자기 자신을 주입받는다는 게 코드로 보면 이상하다.
방법 2: 클래스를 분리
이 프로젝트에서 선택한 방법이다. 분산락 담당과 트랜잭션 담당을 별도 클래스로 분리했다.
// 분산락 전담
@Service
class OrderCancelService(
private val orderCancelExecutor: OrderCancelExecutor, // 다른 빈 주입
private val redissonClient: RedissonClient
) {
fun cancel(orderId: UUID, reason: String): Boolean {
val lock = redissonClient.getLock("order:lock:$orderId")
// ...
orderCancelExecutor.execute(orderId, reason) // 다른 빈 호출 → 프록시 작동!
}
}
// 트랜잭션 전담
@Service
class OrderCancelExecutor(
private val ordersRepository: OrdersRepository,
private val inventoryReservationService: InventoryReservationService
) {
@Transactional // 정상 작동
fun execute(orderId: UUID, reason: String): Boolean {
val order = ordersRepository.findById(orderId).orElse(null) ?: return false
if (order.status != OrdersStatus.ORDER_RESERVED) return false
order.reservationId?.let {
inventoryReservationService.release(order.productId, it)
}
order.status = OrdersStatus.ORDER_CANCELED
ordersRepository.save(order)
return true
}
}
OrderCancelService가 OrderCancelExecutor를 Spring 빈으로 주입받아서 호출한다. 다른 빈을 호출하면 Spring 프록시를 통해 호출되기 때문에 @Transactional이 정상 작동한다.
Redisson의 Watch Dog은 leaseTime을 -1로 설정하면 활성화된다.
// leaseTime = -1 → Watch Dog 활성화
val acquired = lock.tryLock(10, -1, TimeUnit.SECONDS)
Watch Dog는 락을 보유하는 동안 백그라운드에서 주기적으로 leaseTime을 갱신한다. 기본 설정은 30초마다 갱신한다. 작업이 30초를 넘겨도 락이 유지된다.
leaseTime을 명시적으로 설정하면(예: 30, TimeUnit.SECONDS) Watch Dog가 비활성화된다. 30초가 지나면 락이 자동 만료된다.
이 프로젝트에서 주문 취소는 빠르게 끝나는 작업이라 leaseTime: 30초로 설정했다. Watch Dog가 필요한 케이스는 장시간 배치 작업 같은 경우다.
작은 변경이지만 개발 편의성에 도움이 됐다. 기존엔 앱을 띄울 때마다 Redis에 수동으로 재고를 세팅해야 했다.
@Configuration
class InventoryBootstrap {
@Bean
@Profile("dev")
fun initStock(template: StringRedisTemplate) = ApplicationRunner {
val key = "{inventory}:stock:default"
if (template.hasKey(key) != true) {
template.opsForValue().set(key, "100")
}
}
}
dev 프로파일에서만 동작하고, 키가 이미 있으면 덮어쓰지 않는다. 앱을 재시작해도 기존 재고 상태가 유지된다.
이번에 추가된 것들을 정리하면 이렇다.
| 항목 | 내용 |
|---|---|
| Redisson 도입 | Redis Cluster 기반 분산락 |
| OrderCancelService | 분산락 획득/해제 전담 |
| OrderCancelExecutor | @Transactional 취소 로직 전담 |
| 클래스 분리 이유 | Self-invocation 문제 회피 |
| InventoryBootstrap | dev 환경 재고 자동 초기화 |
Self-invocation 문제는 Spring을 쓰다 보면 한 번은 반드시 만나게 되는 함정이다. @Transactional이 왜 안 먹히는지 처음엔 당황스럽지만, AOP 프록시 구조를 이해하고 나면 해결책은 명확하다. 책임을 분리하면 자연스럽게 해결된다.