[Spring] Redisson 분산락 AOP로 동시성 문제 해결하기 (트랜잭션 전파속성 NEVER 사용)
🌱 들어가며
동시성 문제는 백엔드 개발자들이라면 반드시 한 번쯤은 겪어보는 문제이다.
서버 단에서 동시성의 가장 흔한 예로는
- 선착순, 이벤트 등의 상황에서 다수의 트래픽이 동시에 몰리는 상황
- 확인 버튼을 실수로 두 번 클릭을 해(따닥) 서버에 동시에 동일한 요청이 두 번 전송된 상황
- 어떤 사용자가 추천과 비추천, 취소를 순식간에 계속 눌렀는데
서버에 마침 부하가 걸려 이 요청들이 동시에 들어온 상황
이런 예시들을 생각해볼 수 있다.
자바는 기본적으로 멀티 쓰레드 환경에서 실행되기 때문에
이러한 경쟁상태(Race Condition)에 대해 처리를 해줄 필요가 있다.
결론부터 말하자면 Redis 의 구현체 중 하나인 Redisson Client 를 활용하여 이 문제를 해결했다.
이번 포스팅에서는 그 과정에 대해 서술해보려고 한다.
❓ 그거 프론트에서 더블 클릭 막으면 되는 거 아닌가?
프론트에서 동시에 여러 번 클릭하는 상황을 막는 기법들이 존재한다.
디바운스나 쓰로틀링 같은 기법으로
클라이언트로부터 일정 주기안에 들어오는 동시 요청 중 단 하나만 수행하도록 할 수 있다.
그런데 이 방식은 허점이 존재한다.
만약 클라이언트가 여러 개의 브라우저로 동시 요청을 보내면?
하나의 자원에 대해 여러 클라이언트가 동시에 처리 요청을 보내면?
프론트에서의 처리는 보조적인 수단일 뿐, 근본적인 동시성 문제를 해결할 수 없다.
📌 Java Synchronized
자바 진영에서는 기본적으로 synchronized 키워드를 통해 상호 배제(Mutual Exclusion) 기능을 제공한다.
단 하나의 인스턴스, 즉 싱글 프로세스로 동작하는 서버는
Synchronized 를 통한 멀티 쓰레드의 동시성 제어가 가능할 것으로 예상된다.
@Service
class ReviewService {
@Transactional
@Synchronized
fun update(productId: Long) {
// do something
}
}
그러나 Synchronized 키워드는 몇 가지 문제가 있다.
1. Synchronized 키워드는 @Transactional 에서 처리되지 않는다.
스프링 AOP는 @Transactional 이 붙은 메소드를
트랜잭션의 시작, 커밋, 종료를 담당하는 프록시 객체로 감싸 처리해준다.
그러나 이 부분은 Synchronized 메소드에 포함되어 있지 않다.
즉, 트랜잭션의 커밋을 처리하고 DB에 반영되기 직전,
다른 스레드가 이 구역에 진입해 반영되지 않은 값을 읽어도 오류가 발생하지 않는다.
2. 멀티 프로세스 환경에서는 동작하지 않는다.
Synchronized 는 싱글 프로세스 내부의 멀티 쓰레드를 제어할 수 있다.
하지만 멀티 프로세스 환경에서는 다른 프로세스 내부의 쓰레드를 제어할 수 없기 때문에
서버가 확장된다면 동시성 제어가 불가능하다.
📌 MySQL을 이용한 분산락
현재 이 분산락을 적용하려는 서버도 MySQL 을 사용하고 있기 때문에
MySQL 에 내장되어 있는 Lock 을 고려할 수도 있었다.
MySQL 의 Lock은 Lock Table 을 생성하여 별도 관리하는데
락에 대한 상태와 소유자 정보를 쿼리를 통해 관리한다.
추가적인 인프라에 대한 구현 및 유지보수 비용은 줄일 수 있으나
별도의 커넥션 풀을 관리해야 하고, DB에 락과 관련된 부하를 일으켜 속도가 저하될 수 있으며 dead lock 의 가능성도 있다.
이미 우리 서버는 Redis 를 사용하고 있는 상황이기 때문에
MySQL을 이용한 Lock은 사용하지 않았다.
우형에서는 MySQL을 이용한 분산락을 구현한 글을 적어두었다.
https://techblog.woowahan.com/2631/
📌 Redis를 이용한 분산락
Zookeeper 를 통한 예제도 있지만 오버 테크놀로지라 생각했고,
또한 현재 서버에서 토큰 및 캐싱 관리에 Redis 를 사용중이기 때문에 이를 채택했다.
Redis는 싱글 쓰레드 특성을 지녀 한 번에 하나의 명령을 처리하기 때문에 분산락 구현에 많이 사용된다.
Redis Client 중 기본 설정인 Lettuce 와 다른 클라이언트인 Redisson 중 하나를 선택해야 했다.
1. Lettuce
@Component
class RedisLockRepository(private val redisTemplate: RedisTemplate<String, String>) {
fun lock(key: Long): Boolean {
return redisTemplate.opsForValue()
.setIfAbsent(key.toString(), "lock", Duration.ofMillis(3000))
}
fun unlock(key: Long): Boolean {
return redisTemplate.delete(key.toString())
}
}
Lettuce 는 setnx, setex 같은 명령어를 통해 분산락을 구현해야 한다.
다만, 이 명령어들은 Spin Lock 방식으로 동작하기 때문에 Redis 에 엄청난 부하를 준다.
말 그대로 단순하게 무한 while 루프를 통해 대기하는 방식이다.
또한 락의 타임아웃이 없어 불행하게도 특정 프로세스에서 락을 획득한 후 종료되면?
영원히 락을 획득하지 못하고 무한대기를 하게 될 것이다.
2. Redisson
@Service
class ReviewService(private val redissonClient: RedissonClient) {
@Transactional
fun update(productId: Long) {
val lock = redissonClient.getLock("key")
try {
val available = lock.tryLock(2, 3, TimeUnit.SECONDS)
if (!available) {
throw RuntimeExcetpion("Lock Not Avaliable")
}
// do something
} finally {
lock.unlock()
}
}
}
Redisson 을 이용한 lock 예시
현재 우리 서버에서 사용 중인 클라이언트이다.
Pub/Sub 방식으로 동작하며 락이 해제되면 해당 락을 Subscribe 중인 클라이언트들이 신호를 받고 락에 진입한다.
Redisson 은 Lock에 타임아웃을 명시해야 하며, 덕분에 무한 루프에 빠지지 않을 수 있다.
특히 마켓컬리 팀에서 작성한 분산락 방식이 최상단에 뜨는데
검색하면 나오는 대부분의 결과도 Redisson 으로 비슷하게 구현되어 있다.
https://helloworld.kurly.com/blog/distributed-redisson-lock/
📌 Redisson Lock AOP 구현
이렇게 Redisson Lock 을 매번 필요한 비즈니스 로직마다 사용하면 비즈니스 로직이 오염된다.
그래서 스프링의 AOP를 이용하여 분리시키고자 한다.
검색하면 나오는 대부분의 예제는 AOP에 너무 많은 처리 로직이 몰려있어
이를 LockManager를 통해 분리시켰다.
📝 DistributedLock
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class DistributedLock(
val type: DistributedLockType,
val keys: Array<String>,
)
DistributedLock 을 정의하는 어노테이션
lock name 을 enum 으로 관리하고 key 값을 배열로 받아와 복합키에 대한 락을 대응했다.
우리 프로젝트에서는 DDD 를 사용하고 있기 때문에 Object Key 에 대해서는 고려하지 않았다.
이 key 값에는 분산락의 키로 사용하는 각 엔티티 id가 메소드의 인자로 들어온다.
📝 DistributedLockAop
@Aspect
@Component
class DistributedLockAop(
private val lockManager: LockManager,
) {
@Around("@annotation(distributedLock)")
fun lock(joinPoint: ProceedingJoinPoint, distributedLock: DistributedLock): Any? {
val dynamicKey = createDynamicKey(joinPoint, distributedLock.keys)
val lockName = "${distributedLock.type.lockName}:$dynamicKey"
return lockManager.lock(lockName) { joinPoint.proceed() }
}
private fun createDynamicKey(joinPoint: ProceedingJoinPoint, keys: Array<String>): String {
val methodSignature = joinPoint.signature as MethodSignature
val methodParameterNames = methodSignature.parameterNames
val methodArgs = joinPoint.args
val dynamicKey = keys.joinToString(separator = ":") { key ->
val indexOfKey = methodParameterNames.indexOf(key)
methodArgs.getOrNull(indexOfKey)?.toString() ?: throw InvalidLockException("Invalid Lock Key")
}
return dynamicKey
}
}
Redis Client 가 바뀌거나 Lock 을 처리하는 방법 자체가 바뀔 상황을 대비하여
LockManager 를 별도 인터페이스로 정의하여 빼주었다.
또한 AOP 에서 Lock 을 처리하지 않고 LockManager 에 위임했다.
그래서 각 key 를 메소드 파라미터에서 찾아와 콜론으로 분리하여 키를 설정했다.
추후 확장한다면 Object 에 대해서도 리플렉션으로 가져와 key 값으로 적절하게 처리할 수 있게 하면 된다.
@DistributedLock 과 관련된 오류는 InvalidLockException 이라는 커스텀 예외를 만들어 던졌다.
📝 LockManager
@Component
interface LockManager {
fun lock(lockName: String, operation: () -> Any?): Any?
}
락을 담당하는 인터페이스이다.
동작하려는 함수 하나와 락 이름을 지정한다.
📝 RedissonLockManager
@Component
class RedissonLockManager(
private val redissonClient: RedissonClient,
private val aopForNewTransaction: AopForNewTransaction
): LockManager {
override fun lock(operation: () -> Any?, lockName: String): Any? {
val rLock = redissonClient.getLock(lockName)
try {
val available = rLock.tryLock(waitTime, leaseTime, timeUnit)
if (!available) {
throw InvalidLockException("Redisson Lock Not Available")
}
return aopForNewTransaction.callNewTransaction { operation() }
} finally {
rLock.unlock()
}
}
companion object {
private const val waitTime = 5L
private const val leaseTime = 3L
private val timeUnit = TimeUnit.SECONDS
}
}
Redisson 을 이용한 분산락을 처리하는 LockManager의 구현체이다.
락으로 자원을 잠그고 로직을 처리한다.
waitTime 과 leaseTime 등의 각 속성은
각 LockManager 구현체마다 동일하고 바꿀 일이 거의 없을 것이라 예상하고
상수를 통해 전체적으로 관리한다.
📝 AopForNewTransaction
@Component
class AopForNewTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun callNewTransaction(operation: () -> Any?): Any? {
return operation()
}
}
주목할 점은 이 부분이다.
락으로 자원을 잠그고, 해제하는 그 사이에
트랜잭션 전파속성을 통해 완전히 새로운 트랜잭션을 열고 로직을 처리하는 것이다.
즉 트랜잭션이 락 내부에 있기 때문에 트랜잭션이 완전히 커밋된 후에야 락이 해제된다.
왜 이렇게 구현해야 할까?
만약 락이 트랜잭션의 내부에 있다면
락이 해제되고 커밋되는 그 사이에 다른 트랜잭션이 끼어들 여지가 생긴다.
이렇게 되면 동시성 문제가 똑같이 발생한다.
트랜잭션을 락의 내부에서 새로 열어주면
다른 트랜잭션이 커밋 이전에 끼어들 여지가 없어져 데이터의 정합성이 보장된다.
이 방식은 올바른 방법이긴 하지만 단점도 있다.
💥 트랜잭션 전파속성 REQUIRES_NEW 의 단점
// (1)
@Transactional
@DistributedLock(DistributedLockType.LIKE, ["productId", "memberId"])
fun like(productId: Long): ProductLikeVO {
// do something
}
// (2)
@DistributedLock(DistributedLockType.LIKE, ["productId", "memberId"])
fun like(productId: Long): ProductLikeVO {
// do something
}
만약 어떠한 메소드에 @Transactional 과 @DistributedLock 을 모두 사용했다고 하자.
@Transactional 의 우선순위가 기본적으로 높기 때문에
하나의 트랜잭션 내부에서 다시 @DistributedLock 의 새로운 트랜잭션 호출 로직(REQUIRES_NEW)이 동작한다.
그러면 총 두 개의 트랜잭션이 하나의 메소드에서 호출된다.
여기서 @DistributedLock 관련 트랜잭션인 Transaction 2는 커밋이 되었는데
이 메소드를 호출한 Transaction 1 은 오류가 발생했다고 하자.
그러면 Transaction 1에서 @Transactional 을 사용했기 때문에
두 트랜잭션이 모두 롤백 되기를 기대했지만
하나의 트랜잭션은 커밋되고 하나의 트랜잭션은 커밋이 된 상황이다.
두 트랜잭션은 독립적이기 때문이다.
이를 방지하려면 (2)번처럼 @DistributedLock 하나의 어노테이션만 사용해야 하는데
그러면 이 메소드가 트랜잭션을 포함하는지 안하는지, 코드의 동작 결과를 한눈에 예상하기 어려워질 것이다.
그래서 이를 해결하기 위해 전파속성을 바꿔서 처리하고자 했다.
📌 Propagation 속성 변경을 통한 aspect 개선
앞서 구현한 분산락 코드를 개선하고자 하는 이유는
어떠한 메소드에 @Transactional 과 @DistributedLock 을 모두 사용하여
코드의 동작 결과를 예상하기 쉽게 하여 가독성을 높이기 위함이다.
문제 상황은 @DistributedLock 이 다른 트랜잭션에서 호출될 때 발생했다.
그러면 다른 트랜잭션에서 절대 호출할 수 없도록 하면 해결되지 않을까?
💥 트랜잭션 전파속성 NEVER
트랜잭션 전파속성 중 NEVER 은
트랜잭션을 사용하지 않음과 동시에 선행 트랜잭션이 존재하면 오류를 발생시키는 옵션이다.
즉 선행 트랜잭션이 존재하는지 검사하기 위해 사용하는 옵션이다.
이를 이용하면 아까와 같은 상황에서 오류를 발생시켜
다른 메소드에서 분산락 메소드를 호출하지 않도록 할 수 있다.
📝 RedissonLockManager
@Component
class RedissonLockManager(
private val redissonClient: RedissonClient,
): LockManager {
// 트랜잭션이 모두 Lock 내부에서 실행됨을 보장한다
@Transactional(propagation = Propagation.NEVER)
override fun lock(lockName: String, operation: () -> Any?): Any? {
val rLock = redissonClient.getLock(lockName)
try {
val available = rLock.tryLock(waitTime, leaseTime, timeUnit)
if (!available) {
throw InvalidLockException("Redisson Lock Not Available")
}
return operation()
} finally {
rLock.unlock()
}
}
companion object {
private const val waitTime = 5L
private const val leaseTime = 3L
private val timeUnit = TimeUnit.SECONDS
}
}
AopForNewTransaction 을 없애고 Lock 에 대한 연산만 남겨두었다.
다만 이렇게 될 경우 @Transaction 의 기본 우선순위가 높기 때문에
@Transaction 과 @DistributedLock 가 메소드에 동시에 붙게 되면
@Transaction 내부에서 @DistributedLock 이 호출되고,
Propagation=NEVER 옵션에서 제약에 걸려 오류가 발생한다.
그렇기 때문에 @DistributedLock 의 우선순위를 높혀
@Transactional 보다 먼저 처리될 수 있도록 해야 한다.
📝 DistributedLockAop
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
class DistributedLockAop(
private val lockManager: LockManager,
) {
...
}
스프링에서는 @Order 라는 어노테이션을 통해 어노테이션의 적용 우선순위를 설정할 수 있다.
우선순위는 INT_MIN 부터 INT_MAX 까지 설정할 수 있다.
Ordered.HIGHEST_PRECEDENCE 라는 상수는 INT_MIN 으로 설정되어 있는데
이것을 사용해 @DistributedLock 의 적용 우선순위를 @Transactional 보다 높혔다.
이제 @Transactional 은 락이 설정된 이후에 호출되기 때문에
메소드 선언에서 같이 사용할 수 있다.
또한 외부 메소드에서 @DistributedLock 을 호출할 수 없기 때문에
예시처럼 예상치 못한 롤백 같은 상황이 생기지 않는다.
🌳 마치며
분산락을 구현하는 방식은 생각보다 다양하다.
각 방법을 상황에 맞게 사용한다면 효과적으로 동시성 이슈를 대응할 수 있을 것이라 생각한다.
읽어주셔서 감사합니다.