📡 백엔드/🌱 Spring Boot

[Spring] Redisson 분산락 AOP로 동시성 문제 해결하기 (트랜잭션 전파속성 NEVER 사용)

gengminy 2023. 7. 6. 16:01

🌱 들어가며

동시성 문제는 백엔드 개발자들이라면 반드시 한 번쯤은 겪어보는 문제이다.

 

서버 단에서 동시성의 가장 흔한 예로는

  • 선착순, 이벤트 등의 상황에서 다수의 트래픽이 동시에 몰리는 상황
  • 확인 버튼을 실수로 두 번 클릭을 해(따닥) 서버에 동시에 동일한 요청이 두 번 전송된 상황
  • 어떤 사용자가 추천과 비추천, 취소를 순식간에 계속 눌렀는데
    서버에 마침 부하가 걸려 이 요청들이 동시에 들어온 상황

이런 예시들을 생각해볼 수 있다.

 

자바는 기본적으로 멀티 쓰레드 환경에서 실행되기 때문에

이러한 경쟁상태(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/

 

MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요. 비즈인프라개발팀 권순규입니다. 현재 광고시스템에서 사용하고 있는 MySQL을 이용한 분산락에 대해 설명드리고자 합니다. 분산락을 적용하게된 원인 현재 테이블은 아래

techblog.woowahan.com

 

 

📌 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/

 

풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.

helloworld.kurly.com

 

 

📌 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 를 사용하지 않은 경우 트랜잭션 플로우

만약 락이 트랜잭션의 내부에 있다면

락이 해제되고 커밋되는 그 사이에 다른 트랜잭션이 끼어들 여지가 생긴다.

이렇게 되면 동시성 문제가 똑같이 발생한다.

 

REQUIRES_NEW 를 사용한 경우 트랜잭션 플로우

트랜잭션을 락의 내부에서 새로 열어주면

다른 트랜잭션이 커밋 이전에 끼어들 여지가 없어져 데이터의 정합성이 보장된다.

 

이 방식은 올바른 방법이긴 하지만 단점도 있다.

 

💥 트랜잭션 전파속성 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 을 호출할 수 없기 때문에

예시처럼 예상치 못한 롤백 같은 상황이 생기지 않는다.

 

🌳 마치며

분산락을 구현하는 방식은 생각보다 다양하다.

각 방법을 상황에 맞게 사용한다면 효과적으로 동시성 이슈를 대응할 수 있을 것이라 생각한다.

 

읽어주셔서 감사합니다.