Springboot 서버 비동기 처리 - Redis 를 이용한 분산락 설명 및 구현
- Springboot 는 기본적으로 멀티 스레드로 요청을 처리합니다.
즉, 이로인해 비동기 처리에 대한 문제가 발생할 수 있으며,
이에 대한 적절한 조치가 필요할 것입니다.
- 먼저, Springboot 가 멀티 스레드로 동작하지 않는다면 어떤 문제가 발생할까요?
만약 Springboot 가 단일 스레드로 API 요청을 처리한다면, 동시 사용자가 조금만 늘어나더라도 각 요청을 순차적으로 처리하느라 사용성이 떨어지게 될 것입니다.
많은 연산량을 필요로 하는 기능이 아닐지라도, 네트워크 요청과 같은 단순히 요청 시간이 긴 작업을 수행할 동안에도 다른 요청을 처리하지 않고 있으므로 단일 스레드 서버는 비동기 처리를 하지 않아도 되는 단순성과 그로인한 강제적인 안정성을 제외하고는 이득이 없습니다.
- 위와 같은 이유로 서버 프로그램은 멀티 스레드로 구성되어 동작되어야 하는데,
Springboot 가 멀티 스레드로 구성되었을 때 발생하는 대표적인 문제를 소개드리겠습니다.
예를들어 은행 프로그램을 만들었다고 합시다.
유저가 자신의 계좌에 돈을 입력할 때에는,
추가할 금액 입력 -> 데이터베이스에서 기존 은행 계좌 잔액 확인 -> 계좌 잔액과 금액을 더하기 -> 더한 금액을 데이터베이스에 저장
위와 같은 알고리즘으로 처리할 것입니다.
그런데 위와 같은 일련의 알고리즘이 비동기적으로 실행된다고 합시다.
거의 완전히 동시에 금액 입력 요청이 들어왔을 때, 거의 완전히 동일한 속도로 작업을 처리한다고 가정하면,
스레드 1에서도
추가할 금액 입력 -> 데이터베이스에서 기존 은행 계좌 잔액 확인 -> 계좌 잔액과 금액을 더하기
스레드 2에서도
추가할 금액 입력 -> 데이터베이스에서 기존 은행 계좌 잔액 확인 -> 계좌 잔액과 금액을 더하기
이런 작업을 수행할 것입니다.
문제는 여기에 있습니다.
둘은 동시에 같은 작업을 수행하니,
기존 은행 계좌 잔액 확인 시점에 확인한 금액은 동일한 금액일 것입니다.
이를 10000 원이라고 하겠습니다.
이때, 이 계좌 잔액에 추가할 금액을 더한다고 해봅시다.
각각 500 원과 1000 원이라고 할 때,
각 스레드의 메모리 상으로는
10500 원과 11000 원으로 계산되어 존재하겠네요.
이 상태로 데이터베이스에 값을 저장해버린다면,
털끝만큼의 차이로 스레드 2 가 늦어졌다고 했을 때,
스레드 1의 10500 원이 먼저 저장되고, 이후 바로 11000 원이 입력되어,
데이터베이스상으로는 11000 원이 저장된 것으로 나타날 것입니다.
자, 500 원과 1000 원을 입력했는데, 기존 10000 원과 합쳐서 11000 원이 되다니...
고객은 11500 원을 기대할 것인데 서버에서의 값이 달라져 버리는 것이므로 이는 큰 문제가 아닐 수 없습니다.
요약하자면, 기존 데이터를 기반으로 하는 데이터 수정 작업시 비동기 처리가 되어있지 않다면 큰 문제가 발생한다는 것입니다.
- 위와 같은 문제를 해결하는 방법은,
1. 함수에 락을 사용
2. 데이터베이스 락을 사용
3. 분산락을 사용
위와 같은 방법이 있습니다.
- 함수에 락을 사용하는 방법은,
private final Semaphore viewCountUpSemaphore = new Semaphore(1);
try {
// 접근 락
viewCountUpSemaphore.acquire();
// 데이터 조회 및 수정 로직
...
} catch (Exception e) {
classLogger.error(e.toString());
} finally {
viewCountUpSemaphore.release(); // 락 해제
}
위와 같습니다.
semaphore 와 같은 스레드 동시 접근 제어 객체를 사용하여 한 함수를 사용하는 것 자체에 락을 걸어,
해당 함수를 사용하는 주체를 하나의 스레드로 막아버리는 방법입니다.
간편한 방식이지만 문제가 있습니다.
semaphore 객체는 해당 프로세스 내에서만 사용되므로, 만약 서버 복제등을 통해 성능을 높이려 할 때에 위와 같은 방식을 사용한다면 한 서버 내에서 1개의 스레드를 보장하지만 다른 서버에서는 스레드 선점을 모르기에 멀티 스레드 문제를 막을 수 없습니다.
- 그렇다면 락을 프로세스 내에서 처리하는게 아니라 데이터베이스에서 적용하면 어떨까요?
데이터베이스 락은 데이터베이스 특정 테이블, 특정 행에 대한 접근을 막을 수 있습니다.
이렇게 되면 비동기 처리 문제도 해결되고, 다른 서버에서도 데이터베이스 접근이 차단되므로 문제가 해결됩니다.
다만, 데이터베이스 락을 사용한 경우에는 데이터베이스 성능 저하 문제가 생길 수 있습니다.
- 위에서 살펴본 비동기 환경에서의 데이터 무결성 확보, 서로 다른 서버에서의 락 공유, 데이터베이스 성능 저하를 해결하는 방법으로는 분산락이라는 방법이 존재합니다.
분산락이란,
크게 tryLock 과 unLock 이라는 인터페이스로 구성된 객체라고 생각하면 됩니다.
tryLock 을 했을 때, 기존에 Lock 상태가 아니라면 새로운 lock 고유번호를 반환해주고, 기존에 lock 고유번호를 반환한 Lock 상태라면 null 을 반환해주는 단순한 동작을 수행합니다.
여기서 발급받은 lock 고유번호를 가지고 unlock 을 하여 락을 해제하면, 또 다른 곳에서 lock 을 발급 받을 수 있는 구조죠.
(만약 설명이 잘 와닿지 않는다면 아래의 Redis 를 이용한 분산락 구현 코드부터 보셔도 됩니다.)
이때 중요한 것은, tryLock 과 unlock 은 싱글 스레드로 실행이 되어야 합니다.
요청은 멀티 스레드로 동시에 받더라도, 반드시 싱글 스레드로 각 작업이 처리되므로 tryLock 에서 lock 고유번호를 발급하는 로직이 동시 발행을 하지 않는다는 법칙을 반드시 지켜야 합니다.
이렇게 구성된 분산락으로 락을 적용할 코드 뭉치의 앞에서는 tryLock 을 하여 lock 고유번호를 받을 때 까지 대기하도록 작성하고, 작업이 완료되면 unLock 을 시켜주어 다른 위치에서 작업을 수행할 수 있도록 처리하면 위에서 설명했던 문제가 해결 되는 것입니다.
- 이번 글에서는 위와 같은 분산락을 Redis 를 사용하여 구현할 것입니다.
Redis 는, 이전글의 설명과 세팅 방법을 참고하시고,
Redis 를 이용하여 분산락을 구현하는 이유는,
Redis 의 키-값 구조가 위와 같은 분산락을 구현하는데에 적합한 구조이며, 락 구현시 실수로 인해 락을 풀지 않았을 경우 발생할 수 있는 데드락 문제를, Redis 만료시간 설정으로 해소가 가능하며, 서버 어느 위치에서든 접근이 가능한 구조이면서, 클러스터링으로 Scale Out 이 가능하므로 성능 확장 및 운영에도 합리적인 기능을 제공해주기 때문입니다.
- 자, Springboot 에서 Redis 를 사용한 분산락 구현을 시작하겠습니다.
기본 준비는, 이전글을 참고하시고,
BasicRedisLock.kt 파일을 추가로 생성할 것입니다.
새로운 프로젝트 구조는 위와 같습니다.
BasicRedisLock 의 코드는,
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.core.script.RedisScript
import java.util.*
// [RedisLock 의 Abstract 클래스]
abstract class BasicRedisLock(
private val redisTemplateObj: RedisTemplate<String, String>,
private val mapName: String
) {
// <공개 메소드 공간>
// (락 획득 메소드 - Lua 스크립트 적용)
fun tryLock(expireTimeMs: Long): String? {
val uuid = UUID.randomUUID().toString()
val scriptResult = if (expireTimeMs < 0) {
// 만료시간 무한
redisTemplateObj.execute(
RedisScript.of(
"""
if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
return 1
else
return 0
end
""".trimIndent(),
Long::class.java
),
listOf(mapName),
uuid
)
} else {
// 만료시간 유한
redisTemplateObj.execute(
RedisScript.of(
"""
if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
redis.call('pexpire', KEYS[1], ARGV[2])
return 1
else
return 0
end
""".trimIndent(),
Long::class.java
),
listOf(mapName),
uuid,
expireTimeMs.toString()
)
}
return if (scriptResult == 1L) {
// 락을 성공적으로 획득한 경우
uuid
} else {
// 락을 획득하지 못한 경우
null
}
}
// (락 해제 메소드 - Lua 스크립트 적용)
fun unlock(uuid: String) {
redisTemplateObj.execute(
RedisScript.of(
"""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""".trimIndent(),
Long::class.java
),
listOf(mapName),
uuid
)
}
// (락 강제 삭제)
fun deleteLock() {
redisTemplateObj.delete(mapName)
}
}
위와 같이 작성하였습니다.
설명하자면, tryLock 으로 일정시간 동안 유지되는 Lock 을 생성할 것인데, 기존에 락이 되어있지 않다면 UUID 를 함수에서 반환해주고, 이미 락이 되어있는 상태라면 null 을 반환합니다.
unlock 함수는, 앞서 발행받은 uuid 로 락을 해제하는 함수로,
uuid 가 일치하지 않는다면 동작하지 않도록 하였습니다.
위와 같이 선언하고,
Redis1_Lock_Test 라는, 테스트 락을 생성해서,
import com.raillylinker.module_idp_redis.abstract_classes.BasicRedisLock
import com.raillylinker.module_idp_redis.configurations.redis_configs.Redis1MainConfig
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Component
// [RedisMap 컴포넌트]
@Component
class Redis1_Lock_Test(
// !!!RedisConfig 종류 변경!!!
@Qualifier(Redis1MainConfig.REDIS_TEMPLATE_NAME) val redisTemplate: RedisTemplate<String, String>
) : BasicRedisLock(redisTemplate, MAP_NAME) {
// <멤버 변수 공간>
companion object {
// !!!중복되지 않도록, 본 클래스명을 MAP_NAME 으로 설정하기!!!
const val MAP_NAME = "Redis1_Lock_Test"
}
}
위와 같이 락 이름만 설정해주면 됩니다.
- 이제 위와 같이 생성한 Redis1_Lock_Test 라는 이름의 락을 실행시켜보겠습니다.
private val redis1LockTest: Redis1_Lock_Test
위와 같이 컴포넌트 빈을 받고,
////
override fun api6TryRedisLockSample(httpServletResponse: HttpServletResponse): C8Service1TkV1RedisTestController.Api6TryRedisLockSampleOutputVo? {
val lockKey = redis1LockTest.tryLock(100000)
if (lockKey == null) {
httpServletResponse.status = HttpStatus.NO_CONTENT.value()
httpServletResponse.setHeader("api-result-code", "1")
return null
} else {
httpServletResponse.status = HttpStatus.OK.value()
return C8Service1TkV1RedisTestController.Api6TryRedisLockSampleOutputVo(lockKey)
}
}
////
override fun api7UnLockRedisLockSample(httpServletResponse: HttpServletResponse, lockKey: String) {
redis1LockTest.unlock(lockKey)
httpServletResponse.status = HttpStatus.OK.value()
}
이렇게 테스트 코드를 작성하였습니다.
redis 락을 받는 함수에서는, 기존에 락이 걸려있지 않다면 uuid 를 반환하게 하였고, 락이 걸려있다면 null 을 반환하여 결과를 확인하게 하였으며,
uuid 로 락을 해제하는 테스트 코드도 작성하였습니다.
위와 같은 방식으로 락이 잘 적용되는지를 확인하면 됩니다.
- 이상입니다.