조급하면 모래성이 될뿐

Kotlin AOP 따라해보기 본문

TroubleShooting/Yapp

Kotlin AOP 따라해보기

Pawer0223 2023. 9. 21. 05:12

https://tech.kakaopay.com/post/overcome-spring-aop-with-kotlin/#overcome-with-kotlin--spring-context

 

Kotlin으로 Spring AOP 극복하기! | 카카오페이 기술 블로그

Kotlin의 문법적 기능을 사용해서 Spring AOP 아쉬운 점을 극복한 경험을 공유합니다.

tech.kakaopay.com

 

위 글을 읽고 프로젝트에 적용한 AOP 코드를 개선해보고 싶다는 생각이 들었다. 개선하고 싶은 코드는 동시성 처리를 위한 Redisson Lock 처리 코드이다.

 

정리

- 동시성 처리를 위해 적용한 AOP 코드를 KotlinAOP로 변경해 보았다.

- 두 방식 모두 장, 단점이 있는 것 같다.

- Spring AOP는 익숙하기 때문에 가독성이 더 좋았다. 하지만 나의 경우 동적으로 키를 만들기 위해 적용한 코드 때문에 런타임에 발생할 수 있는 문제가 존재했다. 또한 Spring에 익숙하지 않다면 의도한 대로 동작하지 않을 수 있다. (여러 AOP가 동작할 때 순서와 같은)

- Kotlin AOP는 익숙하지 않기 때문에 가독성은 비교적 떨어진다. 하지만 기존 동적키 생성 방식으로 인한 문제점과 AOP순서에 대한 예상치 못한 문제를 해결(예방?)할 수 있었다. 그러나 의존성을 받지 않고 사용하기 위해 처리하는 작업들이 반복적으로 느껴졌다. (정적필드로 감싸는 패턴..)

 

뭐 쓸까..

- 단순한 로직들, 예를 들면 계정 정보가 있는지 계속 확인하는 코드들.. 은 KotlinAOP를 활용하면 좋을 것 같다.

- 명시적인 게 좋다면 KotlinAOP를 써도 좋을 것 같다. 본문 링크에서의 @Cacheable이나, 내가 기존에 사용하던 @DistributedLock을 사용하면서 발생할 수 있는 런타임 문제를 해결할 수 있기 때문이다.

- 복잡한 로직을 풀어내는 데 있어서는 SpringAOP를 활용할 것 같다. 중요한 로직은 이해하기 쉬운 편이 더 좋다고 생각하기 때문이다. 또 KotlinAOP는 의존성을 받지 않고 사용하기 위해 정적필드를 한번 감싸는 수고가 반복해서 든다고 느꼈기 때문이다.

- 물론 정답은 없다. 환경에 적합한 방식을 선택하고, 문제는 고쳐나가면 되지 않을까..

 

AS-IS

- 동시성 처리를 위해 Redisson Lock을 획득하고, 해제하는 로직이 반복된다.

- 이를 해결하기 위해 Spring AOP를 사용했다.

- @DistributedLockAop 어노테이션을 정의하고, 해당 어노테이션을 포인트컷으로 등록했다.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DistributedLock(
    val identifier: String,
    val lockName: String,
    val waitTime: Long = 30L,
    val leaseTime: Long = 10L,
    val timeUnit: TimeUnit = TimeUnit.SECONDS
)

@Aspect
@Component
class DistributedLockAop(
    private val redissonService: RedissonService
) {
    @Around("@annotation(distributedLock)")
    fun lock(joinPoint: ProceedingJoinPoint, distributedLock: DistributedLock): Any? {
       ... 
    }
    
    ...

}

 

기존 방식의 문제점 1

동적으로 Redisson Key를 만들기 위해 리플렉션을 활용했다.

이 방식의 주요 단점은 어노테이션의 identifier값과 함수에서 호출되는 변수명을 일치시켜야 하기 때문에 컴파일 시점에 문제를 발견할 수 없다는 점이다.

 

- 예를 들어 1번 게시글과, 2번 게시글은 구분되어 락이 걸려야 한다. 때문에 board:{id}와 같이 key를 동적으로 만들어야 했다.

- 이를 위해 호출 함수와 어노테이션의 메타정보를 리플렉션을 통해 얻어와 key값을 만들었다.

    private fun createDynamicRedissonKey(
        joinPoint: ProceedingJoinPoint,
        distributedLock: DistributedLock
    ): String {
        val signature = joinPoint.signature as MethodSignature
        val parameterOrder = signature.parameterNames.indexOf(distributedLock.identifier)
        if (parameterOrder == -1) {
            throw RuntimeException("${distributedLock.identifier} 의 파라미터 정보를 찾을 수 없습니다.")
        }
        return "${distributedLock.lockName}:${joinPoint.args[parameterOrder]}"
    }

 

- 서비스에서는 아래와 같이 호출한다.

- updateBoard는 board:1, board:2와 같은 key가, updateMoney는 money:1, moeny:2와 같은 키값이 생성된다.

- updateBoard 함수에 적용된 어노테이션의 identifier 값 boardId는 해당 함수의 변수 명을 의미한다.

- updateMoney 함수에 적용된 어노테이션의 identifier 값 moneyId는 해당 함수의 변수 명을 의미한다.

@Service
class BoardService(
    @Transactional
    @DistributedLock(lockName = "board", identifier = "boardId")
    fun updateBoard(boardId: Long) {
       ... update ...
    }
    
    @Transactional
    @DistributedLock(lockName = "money", identifier = "moneyId")
    fun updateMoney(moneyId: Long) {
       ... update ...
    }
}

 

기존 방식의 문제점 2

트랜잭션 관리를 신경 써주어야 한다.

공유자원을 잠금(Lock), 해제(UnLock)하는 행위는 트랜잭션 전, 후로 수행되어야 한다. 

스프링의 @Transactional 역시 AOP 방식으로 동작하기 때문에 추가한 @DistributedLock이 @Transactional 보다 뒤에 실행됨을 보장해야 한다.

 

또한 부모트랜잭션이 존재하는 경우에 Lock을 획득하게 되면 의도한 대로 동작하지 않을 수 있다.

(부모트랜잭션 실행) -> 락 -> (updateBoard 트랜잭션 실행) -> updateBoard  -> (updateBoard 트랜잭션 종료) -> 언락 -> (부모트랜잭션 종료)

와 같이 동작하게 될 수도 있기 때문이다. 위와 같이 동작하게 되면 공유 자원에 대한 해제(UnLock)가 완료되었지만, 변경사항이 반영되지 않았기 때문에 여전히 동시성문제가 발생할 수 있다. (전파레벨이 Propagation.Required인 경우)

 

이를 해결하기 위해 3개의 조치가 필요했다.

 

1. 분산락 처리를 위한 Aspect가 @Transactional보다 먼저 처리되도록 순서를 제어한다.

-> @Order 어노테이션 활용

 

2. Redisson Lock을 획득, 해제하는 별도의 컴포넌트를 정의 후, 해당 메서드의 트랜잭션 전파 레벨을 Never로 설정한다.

-> 때문에, 부모 트랜잭션이 있는 경우에 Lock 획득을 시도하게 되면 예외가 발생한다.

 

3. @DistributedLock 어노테이션에 @Transactional을 추가한다.

-> Aspect안에서 Lock 획득, 해제하는 함수(2번)로 호출 함수를 전달한다. 때문에 부모트랜잭션이 없는 상황에서 새로운 트랜잭션이 실행됨을 보장할 수 있다.

 

기존 방식의 문제가 될 수 있는 부분은 AOP 처리 순서에 익숙하지 않다면, 의도치 않게 동작할 수 있다는 점이다. 때문에 1번과 같이 순서를 보장할 수 있도록 제어하는게 안전하다.

 

하고 싶은 말은.. 공통로직을 AOP로 풀어내면서 트랜잭션에 대한 제어가 필요하기 때문에 신경 써야 할 부분이 많다는 점이었다.

 

(트랜잭션 관리 문제는 Spring AOP를 적용하면서 발생할 수 있는 문제만은 아니다. 두 방식 모두 신경써야 한다.)

 

Kotlin AOP?

본문 시작의 링크에서 처음 알게 된 개념이다. 공식적인 용어는 아니다. 핵심은 공통로직을 Kotlin 문법 Trailing Lambdas를 활용해서 분리해 내는 것이다.

 

TO-BE

- 기존의 @DistributedLock 어노테이션의 기능을 이제는 RedissonLockAdvice라는 컴포넌트에서 처리한다.

- Adivce에 대한 Component를 의존성 주입 없이 전역적으로 사용하기 위해서 RedissonLockAspect로 한번 더 감싼다.

- RedissonLockAdvice에서는 락을 획득 -> 트랜잭션을 열고 -> 로직을 실행 -> 트랜잭션을 닫고 -> 락을 해제한다.

- 여기서 트랜잭션을 열고, 닫는 작업을 위해서 참조 링크에 TxAdvice를 그대로 활용했다.

- 또한 부모트랜잭션이 존재하지 않도록 전파레벨을 Never로 설정했다.

@Component
class RedissonLockAdvice(
    private val redissonClient: RedissonClient
) {
    private val log = KLogging().logger

    @Transactional(propagation = Propagation.NEVER)
    fun <T> runWithLock(
        lockName: String,
        waitTime: Long = 30L,
        leaseTime: Long = 10L,
        timeUnit: TimeUnit = TimeUnit.SECONDS,
        function: () -> T
    ): T {
        val rLock = redissonClient.getLock(lockName)
        try {
            if (!rLock.tryLock(waitTime, leaseTime, timeUnit)) {
                throw Exception("래디슨 락 획득에 실패했습니다.")
            }
            return TxAspect.run(function)
        } catch (e: Exception) {
            log.error(e) { "" }
            throw e
        } finally {
            rLock.unlock()
        }
    }
}

@Component
class RedissonLockAspect(
    _redissonLockAdvice: RedissonLockAdvice
) {
    init {
        redissonLockAdvice = _redissonLockAdvice
    }

    companion object {
        lateinit var redissonLockAdvice: RedissonLockAdvice
            private set
        private const val TOKEN = ":"

        fun <T> runWithLock( 
            baseKey: String,
            keys: Array<out Any>,
            waitTime: Long = 30L,
            leaseTime: Long = 10L,
            timeUnit: TimeUnit = TimeUnit.SECONDS,
            function: () -> T
        ): T {
            return redissonLockAdvice.runWithLock(
                lockName = generateDynamicKey(baseKey, keys),
                waitTime = waitTime,
                leaseTime = leaseTime,
                timeUnit = timeUnit,
                function
            )
        }

        private fun generateDynamicKey(baseKey: String, keys: Array<out Any>): String {
            return "$baseKey$TOKEN${keys.joinToString(TOKEN)}"
        }
    }
}

 

결과적으로 서비스 로직에서 RedissonLock을 통한 동시성을 고려하기 위해서는 아래와 같이 호출할 수 있다.

    fun updateMoney(id: Long, amount: Int) = RedissonLockAspect.runWithLock(baseKey = "money", keys = arrayOf(id)) {
        val findEntity = testRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("없음!")
        if (findEntity.amount >= amount) {
            findEntity.amount -= amount
        }
    }

 

차이점 비교

공통 로직을 분리하기 위한 노력은 두 방식 모두 비슷한 것 같다.. 큰 차이점은 아래와 같이 느꼈다.

 

1. KotlinAOP에서는 트랜잭션 순서에 대한 처리가 덜 헷갈린다.

- 기존에는 2개의 AOP (Transactional, DistributedLock)에 대한 순서를 제어해야 했다. 이를 위해 @Order를 활용했다.

- KotlinAOP 에서는 RedissonLock에 대한 제어가 Spring에 의해 관리되지 않기 때문에 이런 순서를 고려하지 않아도 괜찮다.

- 즉, 기존 SpringAOP와 비교하여 예상하지 못한 문제가 발생할 가능성이 더 적다고 느꼈다.

 

2. 런타임 시점에 발견할 수 있는 문제를 해결할 수 있었다.

- 기존 방식은 파라미터 이름과, 어노테이션의 identifier를 맞춰줘야 했다. 때문에 오타가 있다면 런타임 시점에 알 수 있었다.

- KotlinAOP에서는 리플렉션 없이 파라미터로 전달하면 동적으로 Key값을 만든다. 때문에 기존의 문제점을 해결할 수 있었다.

 

3. 가독성

- 공통 로직을 이해하는 데는 SpringAOP 방식이 좋은 것 같다. 익숙하기 때문이다. 어노테이션만 봐도 어느 정도 유추가 가능하다. @DistributedLock을 보면 @Transactional이 걸려있는지도 한눈에 보기 쉽다. 어떤 기능이 수행되는지도 @Aspect를 따라가며 찾아갈 수 있다.

- Kotlin AOP는 일반적이지 않기 때문에 비즈니스 로직에 트랜잭션이 걸려있는지 한눈에 알아보기 어렵다.

- TxAspect.run(function)을 따라가야 @Transactional이 걸린 함수에서 호출하고 있음을 알 수 있다.

// AS-IS
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@Transactional
annotation class DistributedLock(
    val identifier: String,
    val lockName: String,
    val waitTime: Long = 30L,
    val leaseTime: Long = 10L,
    val timeUnit: TimeUnit = TimeUnit.SECONDS
)

// TO-BE
@Transactional(propagation = Propagation.NEVER)
fun <T> runWithLock(
    lockName: String,
    waitTime: Long = 30L,
    leaseTime: Long = 10L,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    function: () -> T
): T {
    val rLock = redissonClient.getLock(lockName)
    try {
        if (!rLock.tryLock(waitTime, leaseTime, timeUnit)) {
            throw Exception("래디슨 락 획득에 실패했습니다.")
        }
        return TxAspect.run(function)
    } catch (e: Exception) {
        log.error(e) { "" }
        throw e
    } finally {
        rLock.unlock()
    }
}

 

결과 확인

Kotlin AOP를 적용한 테스트 대상 코드는 아래와 같다.  id에 돈이 amount원 이상 남아있으는 경우에만 차감한다.

    fun updateMoney(id: Long, amount: Int) = RedissonLockAspect.runWithLock(baseKey = "money", keys = arrayOf(1)) {
        val findEntity = testRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("없음!")
        if (findEntity.amount >= amount) {
            findEntity.amount -= amount
        }
    }

 

1. 먼저 트랜잭션이 제대로 동작하는지 확인해 보자

@Transactional 어노테이션이 정상적으로 동작한다면 DirtyChecking에 의해 1000원이 차감되어야 한다.

    test("트랜잭션이 정상동작하여 Dirty Checking 이 수행된다.") {
        // given
        val testEntity = testRepository.save(TestEntity(id = null, name = "테스트", amount = 3000))
        // when
        boardService.updateMoney(testEntity.id!!, 1000)
        // then
        val actual = testRepository.findByIdOrNull(testEntity.id)!!
        actual.amount shouldBe 2000
    }

Update가 반영됐다.

만약 RedissonLockAdvice에서 트랜잭션이 동작하지 않는다면? 즉, 아래와 같이 되어있다면 위 테스트는 실패한다.

(주석 부분, TxAspect.run 내부에 @Transactional이 걸려있다. 따라서 바로 function을 호출하면 트랜잭션없이 동작한다. 본문 참조 링크에 있는 Tx와 동일한 기능을 수행한다.)

 

    @Transactional(propagation = Propagation.NEVER)
    fun <T> runWithLock(
        lockName: String,
        waitTime: Long = 30L,
        leaseTime: Long = 10L,
        timeUnit: TimeUnit = TimeUnit.SECONDS,
        function: () -> T
    ): T {
        val rLock = redissonClient.getLock(lockName)
        try {
            if (!rLock.tryLock(waitTime, leaseTime, timeUnit)) {
                throw Exception("래디슨 락 획득에 실패했습니다.")
            }
            return function.invoke()
            // return TxAspect.run(function)
        } catch (e: Exception) {
            log.error(e) { "" }
            throw e
        } finally {
            rLock.unlock()
        }
    }

Update가 적용되지 않았다.

 

2. 부모 트랜잭션이 존재한다면?

부모 트랜잭션이 있다면 예외가 발생해야 한다. 아쉽지만 런타임시점에 확인이 가능하다. Kotest에서 메서드 단위로 트랜잭션 적용이 잘 안 되어서 별도 클래스에 케이스를 작성했다.

@SpringBootTest
@Transactional
class ParentTransactionalTest @Autowired constructor(
    private val boardService: BoardService,
    private val testRepository: TestRepository
): FunSpec({
    extension(SpringExtension)

    afterTest {
        testRepository.deleteAll()
    }

    test("부모 트랜잭션이 존재하면 예외가 발생한다") {
        // given
        val testEntity = testRepository.save(TestEntity(id = null, name = "테스트", amount = 3000))
        // when & then
        shouldThrow<IllegalTransactionStateException> {
            boardService.updateMoney(testEntity.id!!, 1000)
        }
    }
})

당연하게도 클래스 레벨에 선언한 @Transactional을 제거하면 테스트는 실패한다.

@Transactional을 제거했다.

 

 

3. 잔액이 1000원인데 3개의 요청이 몰리게 된다면??

예를 들어 조회시점에는 1000원인데, 차감금액이 100원, 200, 300원이라고 해보자. 공유자원에 대한 처리가 되었다면 100원, 200원, 300원이 다른 스레드에서 사용되고 있다면 기다렸다가 처리되기 때문에 총 400원이 남아야 한다.

(이 테스트는 깨질 수도 있다. 진짜 빠르다면 각각의 스레드가 종료된 후, 요청이 처리될 수도 있기 때문이다. 더 정확한 테스트를 해보려면 조회 이후에 Sleep을 1초 정도 줘보면 된다.)

    test("동시에 여러 요청이 오더라도 데이터 정합성이 유지된다.") {
        // given
        val testEntity = testRepository.save(TestEntity(id = null, name = "테스트", amount = 1000))
        val threadPoolSize = 3
        val executorService = Executors.newFixedThreadPool(threadPoolSize)
        val latch = CountDownLatch(threadPoolSize)
        // when
        executorService.submit {
            boardService.updateMoney(testEntity.id!!, 100)
            latch.countDown()
        }
        executorService.submit {
            boardService.updateMoney(testEntity.id!!, 200)
            latch.countDown()
        }
        executorService.submit {
            boardService.updateMoney(testEntity.id!!, 300)
            latch.countDown()
        }
        latch.await()
        // then
        val actual = testRepository.findByIdOrNull(testEntity.id!!)
        actual!!.amount shouldBe 400
    }

 

만약 동일한 테스트를 동시성 처리를 고려하지 않았다면 어떻게 될까?? 

    @Transactional
    fun updateMoney(id: Long, amount: Int) {
        val findEntity = testRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("없음!")
        if (findEntity.amount >= amount) {
            findEntity.amount -= amount
        }
    }

당연하지만 실패한다. 하지만 이 역시도 진짜 찰나의 순간이라면 성공할 수도 있을 것이다..

 

 

반응형

'TroubleShooting > Yapp' 카테고리의 다른 글

HttpOnly 쿠키로 토큰 관리 & 재발급 로직 구현  (0) 2023.08.07
JWT 로그인 구현  (0) 2023.07.24
카테고리 목록 캐싱 처리하기  (0) 2023.07.12