Redisson 분산락 도입기 — 그리고 Self-invocation을 피한 방법

정영범·2026년 4월 26일

토이프로젝트

목록 보기
8/11

들어가기 전에

지금까지 재고 동시성은 Redis Lua 스크립트로 제어했다. reserve → commit → release 3단계 구조로 오버셀링을 막았고, 낙관적 락으로 스케줄러와 결제 핸들러의 충돌을 처리했다.

그런데 주문 취소는 다른 문제다.

재고 예약은 단일 원자적 연산이라 Lua 스크립트로 처리할 수 있었다. 주문 취소는 다르다. Redis에서 재고를 해제하고, DB에서 주문 상태를 변경하는 두 가지 작업이 순서대로 일어나야 한다. 동시에 같은 주문에 대해 취소 요청이 두 번 들어오면 재고가 두 번 해제될 수 있다.

이걸 막으려면 분산락이 필요하다.


왜 Redisson인가

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 클라이언트 라이브러리다. 분산락을 포함한 다양한 분산 자료구조를 제공한다. 직접 구현의 문제들을 내부적으로 처리해준다.

  • Watch Dog: 락을 보유하는 동안 자동으로 leaseTime을 갱신한다. 작업이 길어져도 락이 만료되지 않는다.
  • 안전한 해제: 자신이 획득한 락만 해제할 수 있다. 다른 스레드의 락을 실수로 해제하는 문제가 없다.
  • Redis Cluster 지원: 클러스터 환경에서도 그대로 동작한다.

이 프로젝트는 이미 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에 적용하려면 별도 설정이 필요했다.


RedissonConfig — 클러스터 설정

@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)
    }
}

@ConfigurationPropertiesapplication.ymlspring.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초 대기 후 실패 반환한다.


Self-invocation — 왜 두 클래스로 분리했나

처음엔 하나의 클래스에 다 넣으려고 했다.

// ❌ 처음에 시도한 구조
@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
    }
}

OrderCancelServiceOrderCancelExecutor를 Spring 빈으로 주입받아서 호출한다. 다른 빈을 호출하면 Spring 프록시를 통해 호출되기 때문에 @Transactional이 정상 작동한다.


Watch Dog — leaseTime을 넘기는 작업을 위해

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가 필요한 케이스는 장시간 배치 작업 같은 경우다.


InventoryBootstrap — 재고 자동 초기화

작은 변경이지만 개발 편의성에 도움이 됐다. 기존엔 앱을 띄울 때마다 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 문제 회피
InventoryBootstrapdev 환경 재고 자동 초기화

Self-invocation 문제는 Spring을 쓰다 보면 한 번은 반드시 만나게 되는 함정이다. @Transactional이 왜 안 먹히는지 처음엔 당황스럽지만, AOP 프록시 구조를 이해하고 나면 해결책은 명확하다. 책임을 분리하면 자연스럽게 해결된다.

profile
벨로그 좋은것만 드려요

0개의 댓글