티스토리 뷰

 

 

들어가기 앞서

'앗차!' 프로젝트에서는 멀티 서버 환경에서 매 분마다 배치 작업을 실행해 사용자에게 푸시 알림을 전송합니다. 이때 Redis에 저장된 알림 후보를 확인하고, 조건에 맞으면 FCM 서버로 알림을 보냅니다.

 

문제는 두 서버가 동시에 같은 배치를 실행하면서, 동일한 알림에 대해 두 번 푸시가 전송되는 상황이 발생했다는 점입니다.

 

이번 글에서는 이 문제를 어떻게 해결했는지, 그리고 해결 과정에서 고민했던 기술적 선택들을 공유드리려 합니다.

 

 

처음 떠올린 해결책: 단일 서버에서만 배치 실행

가장 먼저 떠오른 해결책은 한 서버에서만 배치를 실행하는 방식입니다.

한 서버에서만 푸시 알림을 전송하면 중복 전송이 발생하지 않을 것이라는, 다소 단순한 접근이었습니다.

하지만 곧바로 이 방식이 적절하지 않다고 판단했습니다. 그 이유를 아래와 같이 정리해 보았습니다.

 

 

[안정성이 떨어집니다]

단일 서버에서만 배치를 실행할 경우, 해당 서버에 장애가 발생하면 푸시 알림을 전송하지 못하게 됩니다.

즉, SPOF(Single Point of Failure) 문제가 발생합니다.

 

 

[설정이 오히려 까다로울 것 같았습니다.]

두 서버 중 하나에서만 배치를 실행하도록 설정하거나, 배치 전용 서버를 별도로 두는 방식 모두 작업이 간단하지 않았습니다. 이는 디버깅이나 버그 트래킹 과정에서 어려움을 초래할 뿐만 아니라, 배포 시마다 서로 다른 버전을 각각 배포해야 하는 번거로움이 있습니다.

제3의 서버를 구성하는 방법도 고려했지만, 새로운 VM을 만들어야 하기 때문에 비용이 추가로 발생하고, 네트워크 구성도 복잡해지는 단점이 있었습니다. 예를 들어, 현재 운영 중인 두 개의 WAS 중 하나에서만 배치를 실행하려면, 두 서버의 배포 버전이 달라야 했습니다.

 

결과적으로 이 방식은 적절하지 않다고 생각하였고 다른 방법에 대해서 탐색해 보았습니다.

 

 

선택한 방식: Redis의 분산 락 활용 (SETNX)

그리고 생각해 낸 방식은 Redis의 SETNX를 이용한 분산 락 기반 중복 방지입니다.

Redis의 SETNX 명령을 직접 활용해 분산 락을 구현하면, 멀티 인스턴스 환경에서도 중복 푸시 알림을 방지할 수 있습니다.

val lockKey = // ..락 키 지정
val lockValue = UUID.randomUUID().toString()
val lockAcquire = lockValueOps.setIfAbsent(lockKey, lockValue, duration)
if (lockAcquire == true) {
    try {
        // 푸시 알림 요청 처리
    } finally {
        val currentValue = lockValueOps.get(lockKey)
        if (currentValue == lockValue) {
            lockRedisTemplate.delete(lockKey)
        }
    }
} else {
    // 락 획득 실패 처리
}

하지만 이 방식에는 두 가지 문제점이 있었습니다.

 

⚠️ 문제 1: 락 해제 과정의 원자성 부족

val currentValue = lockValueOps.get(lockKey)
if (currentValue == lockValue) {
    lockRedisTemplate.delete(lockKey)
}

위 코드는 get 이후 delete를 실행하는 구조입니다. 이 방식은 락 해제 과정이 원자적이지 않기 때문에 get 직후 락이 만료될 경우, 다른 프로세스의 락을 잘못 해제할 수 있는 위험이 존재합니다.

물론 if 조건문과 delete 사이의 시간은 매우 짧지만, "시간이 짧다"는 것이 곧 "절대 발생하지 않는다"는 의미는 아닙니다.

따라서 원자성을 부여할 방법이 필요했습니다.

 

 

✅ 해결책: Lua 스크립트 활용

그리고 이러한 원자성 문제를 Lua 스크립트를 활용하면 해결할 수 있었습니다.

val script = RedisScript.of<String>("""
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
""")
lockRedisTemplate.execute(script, listOf(lockKey), lockValue)

Lua 스크립트를 사용하면 Redis 내부에서 get과 delete가 동시에 수행되기 때문에 원자성이 보장됩니다.

 

📌 참고: execute 메서드에서 KEYS는 리스트로, ARGV는 가변 인자로 전달하는 이유는 무엇일까?

잠시 주제에서 벗어나는 얘기이지만, execute 메서드의 키 전달 방식과 가변 인자 전달 방식은 차이점이 있었습니다.
lockRedisTemplate.execute(script, listOf(lockKey), lockValue)​

execute 메서드를 살펴보면 키는 리스트로 받고 전달값(args)은 가변인자로 받는 것을 볼 수 있습니다.
왜 이러한 차이가 있을까요?? 둘 다 그냥 리스트로 보내는 방식으로 통일하는 게 더 편하지 않았을까요??

이에 대한 의문이 들어서 찾아보았습니다.
결론부터 말하자면 레디스가 KEYS와 ARGV를 명확히 분리해서 처리하기 때문입니다.
Lua 스크립트를 호출할 때 보통 단일 키만 다루지 않고 여러 키를 동시에 쓰기도 합니다.

redis.call("get", KEYS[1])
redis.call("del", KEYS[2])

따라서 키도 여러 개 보내질 수 있고 값도 여러 개 보내질 수 있기에 이를 구분하기 위해 키는 리스트에 담아서 보내주는 것입니다.

 

그렇다면 왜 값은 리스트에 담아서 보내주지 않은지 의문이 들 수 있을 것입니다.(제가 그랬습니다 ㅎㅎ)
이는 여러 가지 이유가 있는데 크게는 사용자가 쉽게 사용하기 위해서인 것 같습니다. 대부분의 Lua 스크립트는 args가 optional이다. 꼭 있어야 되는 것도 아니고, 개수가 정해진 것도 아닙니다.
반면에 키는 항상 사용합니다. 따라서 사용자의 편의성을 위해 값은 유연하게 여러 개를 쉽게 넘길 수 있는 가변 인자로 설계된 것입니다.



표로 정리하면 다음과 같습니다.

keys args
List<String> vararg Any
레디스가 명확한 key 영역 요구 유연한 인자 전달 목적
명시적, 제한적 가변적, 편리함

 

 

 

⚠️ 문제 2: 락 만료 시간 고정

다시 주제로 돌아와서 현재 레디스 분산락 방식의 두 번째 문제점에 대해서 살펴보겠습니다.

val lockAcquired = valueOps.setIfAbsent(lockKey, lockValue, Duration.ofMillis(100))

앞선 코드 예시를 살펴보면 setIfAbsent 호출 시 TTL(Duration)을 지정하게 되는데, 이 TTL 값을 짧게 설정하면 작업이 끝나기 전에 락이 만료될 수 있는 문제가 있습니다.

 

[만료 시간을 짧게 설정할 때]

설명을 위해, 다음과 같은 테스트를 진행해 보았습니다.

val threadCount = 1000
val successCount = AtomicInteger(0)
...
repeat(threadCount) {
    executor.submit {
        val lockValue = UUID.randomUUID().toString()
        val lockAcquired = valueOps.setIfAbsent(lockKey, lockValue, Duration.ofMillis(100))
        if (lockAcquired == true) {
            val count = successCount.incrementAndGet()
            println("🔐 Lock acquired by thread-$it (execution #$count)")
            Thread.sleep(1000)
        }
    }
}

1000개의 쓰레드가 동시에 락을 획득하려고 시도하는 테스트입니다. 락의 만료 시간은 짧게 100ms로 설정했습니다.

1000개의 쓰레드가 동시에 요청을 해도, 하나의 쓰레드만 작업을 락을 획득하는 것을 기대합니다. 

 

하지만 결과를 확인해 보면 1000개의 쓰레드 중 2개가 락을 획득했습니다.

이유는 락을 가지고 작업을 진행하는 동안에 락의 만료 시간이 지나면서 락이 자동으로 사라지게 되기 때문입니다.

쓰레드의 개수가 늘어날수록, 혹은 만료 시간이 짧을수록 락이 중복되어 획득하는 경우가 증가하였습니다.

 

TTL이 짧으면 중복 획득 가능성이 높아지고, TTL을 길게 설정하면 락이 유지되는 동안 다른 작업들이 불필요하게 대기하게 되어 처리량이 줄어들 수 있습니다.

 

 

[만료 시간을 길게 설정했을 때]

그렇다면 만료 시간을 충분히 길게 설정하면 문제가 없지 않을까요?

실제로 위 테스트에서 만료 시간을 100ms에서 500ms로 늘리게 되면 2000개의 쓰레드가 동시에 락 획득 요청을 해도 중복 획득되는 일이 잘 발생하지 않았습니다.

 

하지만 락 만료 시간을 길게 설정한다는 것은 만약 내부 작업에 문제가 발생하게 되면 락 만료 시간만큼 다른 작업을 수행할 수 없다는 것을 의미합니다. 예를 들어 락 TTL을 30초로 설정했는데 락 획득 이후 외부 API 호출 과정에서 예외가 발생한다면 어떨까요? 락은 그대로 30초 동안 유지되고 그동안 락이 필요한 다른 작업은 일절 수행할 수 없게 됩니다. 즉, 병목 현상이 일어나게 됩니다.

 

 

[짧아도 문제, 길어도 문제]

결국 중요한 것은 만료시간을 "적절히" 설정하는 것입니다. 그렇지만 문제는 “적절히”가 어느 정도인지 알 수 없다는 것이다. 적절한 만료 시간은 락 획득 이후 수행되는 내부 호출 로직에 따라 다를 것이고 만약 내부 로직이 외부 API와 같이 제어할 수 없는 작업이라면 더더욱 적절한 만료 시간을 설정하기가 어려울 것입니다. 단순히 500ms ~ 1000ms로 설정한다면 어느 정도의 최선의 해결할 수 있겠지만, 최적의 해결책이라고 보긴 어려울 것입니다.

 


 

✅ 해결책: 동적 TTL 설정

만약 상황에 따라 적절한 TTL이 다르다면 동적으로 TTL을 설정해 주면 해결할 수 있지 않을까요??

저 또한 동적 TTL을 설정하는 작업을 어떻게 구현할 수 있을지 고민해 보았고 3가지 해결 방식을 찾았습니다.

각각의 방식을 살펴보고 어떠한 특징이 있는지 살펴보겠습니다.

 

[방법 1] TTL 시간을 외부에서 할당

fun processWithLock(
    lockKey: String,
    estimatedDuration: Duration,
    action: () -> Boolean
): Boolean {
    val lockValue = UUID.randomUUID().toString()
    val ttl = estimatedDuration.plusMillis(500)
    val lockAcquired = valueOps.setIfAbsent(lockKey, lockValue, ttl)
    if (lockAcquired == true) {
        try {
            return action()
        } finally {
            // Lua script로 해제 (생략)
        }
    }
    return false
}

적절한 TTL 시간을 계산해서 락 작업을 호출할 때 같이 파라미터로 건네주는 방법입니다.

내부적으로 예비 시간까지 할당해 주면 상황에 맞게 락 만료 시간을 동적으로 설정해 줄 수 있습니다.

하지만 여전히 상황마다 적절한 시간이 어느 정도인지를 알 수 없기에 실제로 효과적일지는 미지수입니다.

 

 

[방법 2] 락 자동 연장 (Watchdog)

Watchdog 기반으로 작업이 진행 중인 경우 락을 자동 연장하는 방식입니다.

Watchdog이란? 일정 주기로 시스템 상태를 감시하고 자동으로 조치하는 메커니즘을 의미합니다.
val lockExtendThread = thread(start = true) {
    while (/* 작업이 끝나지 않음 */) {
        Thread.sleep(1000)
        val currentValue = valueOps.get(lockKey)
        if (currentValue == lockValue) {
            valueOps.set(lockKey, lockValue, ttl)
        }
    }
}

 

작업이 끝나지 않는 동안 Watchdog 쓰레드가 1초마다 락의 TTL을 갱신을 합니다.

하지만 이 방식은 매초마다 레디스 TTL 값을 갱신하는 요청을 보내기 때문에 자칫하면 레디스 서버에 부하를 줄 수 있다는 문제가 있습니다.

 

 

 

[방법 3] Redisson 활용

Redisson은 Java와 Kotlin 환경에서 레디스 기능을 보다 고수준으로 사용할 수 있게 도와주는 레디스 클라이언트 라이브러리로 비동기 논 블로킹 I/O를 제공합니다. 이러한 Redisson을 활용하면 락을 잡은 쓰레드가 작업 중이면 TTL을 자동으로 연장해서 락 중복 실행을 방지하는 기능을 활용할 수 있습니다

 

Redisson을 활용하면 아래와 같이 간단하게 코드를 작성하는 것으로 분산 락을 활용할 수 있습니다.

val lock = redissonClient.getLock("lock:resource")
val acquired = lock.tryLock(5, 10, TimeUnit.SECONDS)
if (acquired) {
    try {
        // 작업 실행
    } finally {
        lock.unlock()
    }
}

tryLock 메서드의 첫 번째 인자(waitTime)는 락 대기 시간을 의미하고 두 번째 인자(leaseTime)는 TTL을 의미합니다. leaseTime 이 끝나기 전에 작업이 끝나지 않았다면 Redisson 내부에서 TTL을 자동 연장시켜 줍니다.

 

내부적으로는 락을 작은 쓰레드 전용 watchdog thread가 주기적으로 TTL을 연장한다고 한다.

자세한 내용은 이 링크를 참고하면 좋을 것 같습니다.

 

 

하지만 이 방법은 외부 라이브러리를 활용한다는 점에서 깊게 고민해 볼 사항입니다. 단순히 편의성을 위해서 라이브러리를 활용하게 되면 오히려 복잡성만 야기될 수 있고 라이브러리 버그가 곧 서비스 장애로 연결될 수 있기 때문입니다.

 

따라서 실질적 도입 가치가 있다고 판단할 수 있을지 아래 표를 통해 명확히 살펴보는 것이 좋을 것 같다.

질문
YES → Redisson
NO → 직접 구현
action() 실행 시간이 예측 불가능한가?
락 충돌이 자주 일어나는가?
여러 노드에서 동시에 락을 잡을 가능성이 있는가?
라이브러리 관리/추상화에 익숙한가?
팀원들이 Redisson 구조를 잘 이해할 수 있는가?

 

 

 

결론

결론부터 말하면 저희는 SETNX를 활용한 레디스의 분산락을 통해 해결했습니다. 단일 서버에서만 배치를 돌리는 것은 디버깅에 어려움이 있고 가용성이 많이 떨어진다고 생각했기 때문에 고려하지 않았습니다.

위에서 살펴본 것처럼 레디스 분산락을 활용할 때에도 몇 가지 문제점이 있는데 해결책을 활용하면 어느 정도 문제를 해결할 수 있다고 생각했기에 레디스 분산 락을 최종 선택했습니다.

 

동적 TTL 설정은 Redisson을 활용하는 방식보다는 직접 구현하는 방식을 선택했습니다. Redisson을 활용하는 것이 코드적으로는 더 간단하지만, 디버깅 난이도는 추상화 수준이 높은 Redisson이 훨씬 복잡합니다. 또한 락에 대한 책임을 외부 라이브러리에 의존하기 때문에 테스트에도 어려움이 있을 것 같았습니다.

트래픽이 많아 락 충돌이 자주 발생하는 경우에는 Redisson을 활용하는 방식이 더 효과적일 수 있으나, 현재 저희 프로젝트에서는 사용자가 매우 적기 때문에 고수준 락을 지원하는 Redisson까지 사용할 필요성이 크게 느껴지지 않았습니다.

 

 

동적 TTL 설정

이전에 레디스의 락 만료 시간이 고정되어 있다는 문제점이 있어서 이를 동적 TTL 설정하는 방식으로 해결한다고 했었습니다.

실제로 어떻게 구현했는지에 대해서 살펴보겠습니다.

 

락을 획득한 경우, 작업이 진행되는 동안에는 락의 만료 시간이 계속해서 연장되어야 합니다. 이를 위해선 “작업이 끝났는지”를 확인하는 작업이 필요합니다. 그리고 이 확인하는 작업을 크게 2가지 방식으로 설정할 수 있었습니다.

 

1. 데몬 쓰레드 기반

2. 코루틴 기반

 

각각의 방식에 대해서 자세히 살펴보겠습니다.

데몬 쓰레드 기반 Watchdog

fun processWithLock(
    lockKey: String,
    action: () -> Boolean
): Boolean {

    (... 락 획득 시도)
    
    if (락 획득 실패) {
        return false
    }
    
		// ⭐️ Whatchdog 부분
    val watchdogThread = thread(start = true, isDaemon = true) {
        while (keepExtending) {
            Thread.sleep(500)
            val ttlMillis = ttl.toMillis().toString()

            val lockExtendResult = lockRedisTemplate.execute(
                lockExtendScript,
                listOf(lockKey),
                lockValue,
                ttlMillis
            )
            if (lockExtendResult == 1L) {
                log.info { "\uD83C\uDF00 $lockKey Lock extended by ${ttlMillis}ms." }
                continue
            }
            log.warn { "❌$lockKey Failed to extend lock" }
            keepExtending = false
        }
    }

    (... 락 반환 로직)
    
    return result
}

위 방식은 데몬 쓰레드를 만들어서 작업이 끝났는지 일정 기간마다 확인하는 코드입니다.

 

위 코드를 실제로 테스트를 돌려보면 아래와 같이 나옵니다.

thraed-230은 작업 쓰레드로 락을 획득하고 작업을 진행하고 작업이 완료되면 락을 반환합니다.

Thead-4 는 데몬 쓰레드로 작업이 끝났는지 감시하면서 아직 끝나지 않은 경우 락을 자동으로 연장합니다.

 

 

데몬 쓰레드

그렇다면 데몬 쓰레드가 무엇일까요??

데몬 쓰레드(Daemon Thread)는 JVM 종료 시 생존 여부에 영향을 주지 않는 백그라운드용 쓰레드이다.

 

일반 쓰레드
데몬 쓰레드
종료되지 않으면 JVM도 종료 안 됨
모든 일반 스레드가 끝나면 JVM은 데몬도 강제 종료함
예: main thread, worker thread
예: GC, 모니터링, 백그라운드 반복 작업 등

Watchdog thread는 백그라운드에서 작업이 끝났는지에 대해서 지속적으로 반복하는 작업이기에 데몬 쓰레드로 구현하는것이 적절합니다. 만약 일반 쓰레드로 만들게 되면, 쓰레드가 살아 있으면 JVM 프로세스가 종료되지 않게 되면서 문제가 발생할 수 있습니다. 작업이 끝났는지 확인하는 작업은 서비스의 핵심 흐름이 아니기 때문에 JVM 프로세스가 종료될 때 자동으로 같이 종료되게 데몬 쓰레드를 사용하는 것이 적절합니다.

 

 

단점

데몬 쓰레드로 Watchdog을 구현하게 되면 매번 요청마다 쓰레드를 생성하게 된다는 치명적인 단점이 있습니다. 요청이 많아질 경우, 쓰레드 수가 급격히 늘어나게 되면서 JVM 리소스를 과하게 점유할 위험이 있습니다.

 

그렇다면 쓰레드 풀을 사용하면 안되나 의문이 들 수 있습니다. 저도 그렇게 생각했었데 살펴보니 쓰레드 풀을 사용하게 되면 감시 작업의 특성과 쓰레드 풀의 동작 방식 간의 충돌이 있을 수 있다고 합니다. 쓰레드 풀은 단발성 작업 처리용입니다. 그렇기에 작업 큐에 있는 짧은 작업을 빠르게 처리하기 위해 주로 사용하게 됩니다. 반면 Watchdog 같은 감시 작업은 단발성이 아니고 계속 진행되어야 하는 작업으로 쓰레드를 지속해서 점유하게 된다. 결국 쓰데르 풀의 크기가 제한되어 있으면 작업이 밀리거나 대기하게 되며 설정에 따라 쓰레드를 새롭게 생성하는 등의 작업이 일어날 수 있게 됩니다. 다시 말해서 쓰레드 풀을 사용하는 장점을 활용할 수 없게 되는 것입니다. 그냥 직접 쓰레드를 만드는 것과 다를 바 없어지는 것이죠. 쓰레드 풀의 장점을 활용할 수 없다면 쓰레드 풀을 활용하는 것은 결국 복잡성만 증가만 야기하게 됩니다.

 

 

코루틴은 어떨까?

데몬 쓰레드를 사용하게 되면 매번 요청할 때 마다 쓰레드가 생성된다는 단점이 있었습니다.

그렇다면 이를 해결하기 위해 데몬 쓰레드 대신 코루틴을 활용하면 어떨까요?

 

(코루틴에 대한 자세한 설명은 제가 예전에 쓴 글을 참고하면 좋을 것 같습니다)

비동기 작업을 빠르게 도와주는 코루틴

 

간단하게 말하면 코루틴을 활용하게 되면 경량 쓰레드를 사용하여 매번 쓰레드를 생성하는 방식이 아닌 쓰레드 풀 위에서 경량 쓰레드처럼 동작하게 됩니다.

 

그렇다면 실제로 얼마만큼의 성능 차이가 있을까요?

궁금해서 저는 직접 테스트를 진행해 보았습니다.

 

아래 테스트는 약 100개의 작업을 동시 요청했을 때의 리소스 차이를 그래프로 그린 것입니다.

데몬과 코루틴의 힙 메모리 사용량 그래프

 

데몬과 코루틴의 GC 횟수 그래프

위 표를 살펴보면 주황색은 코루틴이고 파란색은 데몬 쓰레드 방식입니다.

둘을 비교해 보면 예상외로 실행 시 사용하는 힙 메모리와 GC 횟수 모두 코루틴이 더 높았습니다.

 

코루틴은 실행 시마다 컨텍스트 객체, Job 객체 등을 할당하여 힙에 보관하게 되는데 이러한 구조적 특성상 100개의 코루틴을 띄우면 관련 부가 데이터가 함께 생성됩니다. 따라서 힙 메모리 사용량이 증가하고 GC 입장에는 더 많은 객체를 관리해야 하므로 GC 횟수도 증가하는 것입니다.

 

그렇다면 데몬 쓰레드를 사용하는 방식이 코루틴을 활용하는 것보다 더 적은 리소스를 사용하는 것일까요?

결론부터 말하면 코루틴을 활용하는 것이 리소스가 더 적게 소모합니다.

 

아래는 100번의 요청을 동시에 보내 데몬 쓰레드와 코루틴의 쓰레드 사용량을 그래프로 그린 것입니다.

데몬 쓰레드의 쓰레드 사용량

 

코루틴의 쓰레드 사용량

테스트 결과, 데몬 쓰레드 방식은 매 작업마다 새로운 쓰레드를 생성하기 때문에 약 100개의 고유 쓰레드가 생성되었습니다.

반면, 코루틴 기반 방식은 동일한 100개의 작업을 처리하면서도 평균 3~8개의 스레드만으로 충분히 처리되었죠. 코루틴이 스레드를 점유하지 않고 비동기적으로 동작한다는 구조적 장점 덕분입니다.

 

“쓰레스 수”는 GC나 메모리 사용량보다 시스템 한계에 훨씬 더 직접적인 영향을 끼칩니다. 쓰레드는 생각보다 무거운 리소스 단위입니다. 쓰레드 하나는 컨텍스 전환이 필요한 작업 단위죠. 따라서 100개의 쓰레드를 동시에 띄우게 되면 그 만큼 스케줄링, 컨텍스트 전환 비용, 메모리, 커널 리소스가 전부 발생하게 됩니다.

 

따라서 GC 횟수나 힙 메모리 사용량이 일시적으로 더 높을 수 있지만, 쓰레드 사용 수가 적은 코루틴이 전체 시스템 리소스 효율성과 확장성 관점에서 더 적절한 선택이라고 봅니다.

 

코루틴 방식으로 리팩터링

그렇다면 기존의 데몬 쓰레드 방식의 Watchdog을 코루틴 방식으로 변경해 보겠습니다.


private const val SUCCESS = 1L
private const val BUFF_RATIO = 1.5
private const val WATCHDOG_SLEEP_RATIO = 2

fun processWithLock(
    lockKey: String,
    action: () -> Boolean
): Boolean {

    (... 락 획득 시도)
    
    if (락 획득 실패) {
        return false
    }
    
		// ⭐️ Whatchdog 부분
    var watchdogJob =
        CoroutineScope(Dispatchers.Default).launch {
            val sleepMills = expectedActionDurationMillis / WATCHDOG_SLEEP_RATIO
                while (true) {
                    Thread.sleep(sleepMills)
                    val lockRefreshResult =
                        lockRedisTemplate.execute(
                            lockRefreshScript,
                            listOf(lockKey),
                            lockValue,
                            (expectedActionDurationMillis * BUFF_RATIO).toLong().toString()
                        )

                    if (락 연장에 성공하면) {
                        log.info { "\uD83C\uDF00 $lockKey Lock extended by ${expectedActionDurationMillis}ms." }
                    } else {
                        log.warn { "❌$lockKey Failed to extend lock" }
                    }
                }
        }

    var result = false
    runBlocking {
        try {
            result = action()
        } finally {
            watchdogJob.cancel()
            (... 락 반환 로직)
        }
    }
    
    return result
}

변경된 코드는 위와 같습니다. 이제 Watchdog에서 쓰레드를 생성하는 것이 아닌 코루틴으로 동작하게 되는데 쓰레드 풀 위에서 경량 쓰레드가 Watchdog 작업을 수행하게 됩니다.

 

실제 위 로직을 실행하는 테스트를 보면 아래와 같이 로그가 나옵니다.

락을 획득하는 쓰레드와 락을 연장하는 쓰레드가 다른 것을 확인할 수 있죠. 여기서 락을 연장하는 쓰레드(-2 @courinte#1)는 실제 쓰레드가 아닌 경량 쓰레드로 OS 쓰레드가 아닌 비동기 작업 단위입니다. 따라서 훨씬 효율적으로 처리되죠.

 

참고) 락을 반환하는 쓰레드가 Main이 아닌 이유는 무엇일까요❓

runBlocking은 호출한 쓰레드에서 새로운 코루틴을 시작합니다. 그리고 해당 코루틴이 완료될 때 까지 그 쓰레드를 블로킹하도록 설계되어 있다. 그러나 runBlocking 내부에서 suspend 함수(delay, withContext 등)을 호출되거나 코루틴 스케줄러가 개입할 필요가 있다고 판단되는 경우, 다른 쓰레드에서 재개(resume)될 수 있다.

runBlocking의 공식 시그니처를 살펴보겠습니다.
public fun <T> runBlocking(
    context: CoroutineContext = EmptyCoroutineContext,
    block: suspend CoroutineScope.() -> T
): T​

 

context를 따로 지정하지 않은 경우 기본적으로 EmptyCoroutineContext를 사용하는 것을 볼 수 있습니다. 이 경우 runBlocking은 현재 쓰레드에서 코루틴을 시작하며, Dispatcher를 명시하지 않았기 때문에 특정 쓰레드 풀을 사용하지 않죠.

하지만 만약 내부 로직에 따라 코루틴 스케줄러가 최적화를 위해 worker thread를 할당하여 실행할 수 있습니다.
실제로 아래와 같이 테스트해 본 결과 runBlocking 내부에서 다른 쓰레드를 사용하는 것을 볼 수 있었습니다.
@Test
fun `runBlockingTest`() {
    log.info { "start" }  // main

    val result = runBlocking {
        log.info { "inside block" } // coroutine
    }

    log.info { "end" } // main
}​


runBlocking의 본질은 “어디에서 실행되느냐”가 아니라 “무엇을 동기적으로 실행하느냐”입니다. runBlocking 내부 블록이 어디에서 실행되는지는 관심거리가 아닙니다. 단지 내부 블록이 모두 완료가 될 때까지 대기하고 이후 로직을 실행하는 것이죠. 다시 말해 동기적으로 실행하는 것입니다.

 

 

 

 

추가) 락 할당 시 Ratio 시간 부여

락을 재할당 하는 Watchdog가 있더라도 락을 얼마나 설정할지는 미리 정해야 했었습니다.

그리고 락을 연장하는 Watchdog도 얼마큼의 주기로 작업이 완료되었는지에 대해서도 결정했어야 했었습니다.

작업 예상 소요 시간(ETC)이 오래 걸리는 작업에 대해 너무 짧은 락 만료 시간을 할당하면 Watchdog 주기가 많아지게 되고 레디스 서버에 불필요한 부하를 줄 것입니다. 따라서 ETC에 따라 적절한 락 설정 시간과 Watchdog 주기가 달라지는 게 적절하다고 생각했습니다.

 

결론부터 말하자면 각 시간을 다음과 같이 설정했습니다.

 

  • 락 만료 시간: 작업 예상 소요 시간(ETC) * 1.5
  • Watchdog 주기: 작업 예상 소요 시간(ETC) / 2

작업에 대한 락을 부여할 때 작업 예상 시간의 + 버퍼 시간을 부여하여 락을 걸도록 변경하였습니다. 이때 비율을 1.5배로 설정하였습니다. 예상 작업 소요 시간이 10초인 경우 15초의 시간으로 락을 거는 것입니다.

 

락을 재할당 하는 시간도 ETC에 맞춰서 ETC / 2로 설정하였습니다. 구글링을 하다 보면 ETC / 10으로 설정하는 게 좋다는 의견도 있는데 현재 프로젝트에서는 아직 사용자가 많지 않기 때문에 “락으로 인한 유저 대기 시간”보다 “짧은 주기로 인한 레디스 서버 부담”이 더 비용이 크다고 느껴졌습니다. 따라서 더 긴 주기로 레디스에 요청을 하는 ETC / 2로 설정하였습니다.

 

 

마무리

전체적으로 지금까지의 내용을 정리해 보겠습니다.

분산 서버 환경에서 알림 중복 전송 문제가 발생했었고, 이를 해결하기 위한 첫 번째로 단일 서버에 배치하는 방식을 고려했습니다. 하지만 이 방식은 서버 가용성이 떨어지고 서버 환경이 일치하지 않는 문제가 있어 적절하지 않았죠. 이에 따라 분산 환경에서도 활용할 수 있는 레디스 분산락을 활용하는 방향으로 전환하였다. 이 과정에서 원자성이 보장되지 않고 TTL이 고정되어 있다는 문제점이 있었지만 Lua 스크립트를 활용하고 동적 TTL을 코루틴 기반의 Watchdog을 구현하여 해결하였습니다.

 

단순히 중복 전송 문제를 해결하려고 했지만 가용성과 효율성을 생각하다 보니 분산락, 코루틴 등의 깊은 개념도 배울 수 있었습니다. 아직은 개념이 확실히 잡혔다고는 느껴지지 않는다. 아직 익숙하지 않아서 그런 것 같습니다. 계속 고민해 보면서 해당 개념들을 체득해봐야 할 것 같습니다.

 

긴 글 읽어주셔서 감사합니다!