ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring AOP를 Kotlin으로 개선해보기! (feat. 카카오페이 테크블로그)
    📖 개발 공부/kotlin 2024. 3. 17. 12:56

    이번 글에서는 Spring AOP의 아쉬운 점과 함께 Kotlin의 Trailing Lambda 문법을 통해 개선하는 방법을 알아보려고 한다!

    카카오페이 테크블로그의 "Kotlin으로 Spring AOP 극복하기!" 글을 보고 해결한 내용이다.

     

    AOP 간단 설명

    AOP는 관점 지향 프로그래밍(Aspected Oriented Programming)으로 로깅, 트랜잭션과 같은 공통으로 실행되어야하는 부분을 비즈니스 로직에서 떼어내어 모듈화하여 재사용하는 것을 의미한다.

    AOP를 사용하여 비즈니스 로직을 작성하는 데에 집중할 수 있게 해준다.

     

    https://prasadct.medium.com/understanding-spring-aop-bdfe4cd84377

     

     

    이때 트랜잭션 로직과 같이 삽입되는 로직을 Advice, 그리고 로직이 삽입될 함수를 JoinPoint라고 정의한다.

     

    https://prasadct.medium.com/understanding-spring-aop-bdfe4cd84377

     

    Spring AOP 아쉬운 점 살펴보기

    나는 Spring AOP를 적용할 때마다, 배보다 배꼽이 더 큰 느낌이랄까? 생산성 향상을 위한 것은 인정하지만,, 신경 써야할 부분들이 꽤 많다고 느꼈다.

    카카오 테크블로그에서 언급한 아쉬운 점들을 보며 나만 느끼는 게 아니구나 싶었다.ㅎㅎ

    1. 구현의 번거로움

    Spring AOP를 적용하기 위해서는

     

    (1) 어노테이션 정의

    @Target(AnnotationTarget.FUNCTION)
    @Retention(AnnotationRetention.RUNTIME)
    annotation class Logging
    

     

    (2) Advice 클래스 정의

    @Aspect
    @Component
    class LoggingAdvice {
    
       companion object {
           val logger: Logger = LoggerFactory.getLogger(this::class.java)
       }
    
       @Around("@annotation(com.example.springaop.aspect.Logging)")
       fun atTarget(joinPoint: ProceedingJoinPoint, name: String): Any? {
           val startAt = LocalDateTime.now()
           logger.info("Start At : $startAt")
    
           val proceed = joinPoint.proceed()
    
           val endAt = LocalDateTime.now()
    
           logger.info("End At : $endAt")
           logger.info("Logic Duration : ${Duration.between(startAt, endAt).toMillis()}ms")
    
           return proceed
       }
    }
    

     

    (3) Pointcut 표현식 사용

     

    이렇게 3가지를 해야한다. 또한 나는 제대로 적용이 되었는지 확인하는 과정도 거쳤다.

    2. 내부함수 호출 적용 불가

    Spring AOP는 Proxy 기반으로 동작하는데, 이 때문에 내부함수에서는 AOP가 적용되지 않는다.

    @Service
    class TestService(
       val testRepository: TestRepository
    ){
    
       @Logging
       fun test1(input: Input) {
           this.test2(user)
       }
    
       @Logging
       private fun test2(input: Input) {
           testRepository.save(input.test)
       }
    }
    

     

    test2 메서드에 AOP 적용을 했지만, 코드 실행을 하면 삽입되어야할 실행시간 로깅이 발생하지 않았다.

    이는 프록시의 한계점으로 발생한 것이다.

     

    설명을 덧붙여보자면, TestService는 런타임에 프록시로 감싸져있다.

    test1을 호출하는 쪽에서는 해당 프록시의 test1을 호출하고, 프록시는 Logging 로직 수행 중에 test1을 호출한다. 이때, 내부 함수 호출은 this 를 참조하고 있어서, 프록시를 거치지 않는다.

     

     

     

     

    내부 함수 호출에 AOP 적용을 하고 싶은 요구사항이 있을까?라는 의문이 들수도 있다.

    @Transactional 어노테이션을 사용할 때 이 필요성을 크게 느꼈는데, 이는 밑에서 더 살펴보겠다!

     

    3. 런타임 예외 발생 가능성

    • Pointcut 표현식 오작성 시에도, 컴파일 에러가 나지 않아 런타임 에러가 발생할 수 있다.
      또한, 패키지명이나 클래스명을 변경할 때 Pointcut 표현식도 함께 변경해줘야한다. 이를 놓친다면, AOP가 적용되지 않는 버그가 발생할 수 있다.
    • Advice에서 JoinPoint의 인자를 가져올 때도 있는데, 이는 단순히 Array<Any> 타입으로 가져온다. 이를 위해 JoinPoint의 인자 타입과 순서를 의도적으로 맞추어 작성해야한다. 이또한 런타임 예외를 일으킬만한 위험 요소이다.

     

    AOP 구현에 용이한 Kotlin의 유용한 문법! Trailing Lambda

    Trailing Lambda(후행 람다) 란 Kolin 함수형 프로그래밍 문법 중 하나이다.

    이 문법은 마지막에 오는 함수 형태의 인자를 람다식으로 변환시켜 넘겨준다.

      val result = test({ 1 + 2 })
      val result = test() { 1 + 2 }
    

     

    위에서 Spring AOP로 구현한 Logging 를 Trailing Lambda를 이용해서 요렇게 변환할 수 있다.

    fun <T> logging(function: () -> T): T {
       val startAt = LocalDateTime.now()
       logger.info("Start At : $startAt")
    
       val result = function.invoke()
    
       val endAt = LocalDateTime.now()
    
       logger.info("End At : $endAt")
       logger.info("Logic Duration : ${Duration.between(startAt, endAt).toMillis()}ms")
    
       return result
    }
    

     

    이렇게 정의한 함수를 TestService에 다음과 같이 적용할 수 있다.

    @Service
    class TestService(
       val testRepository: TestRepository
    ){
    
       fun test1(input: Input) {
    	   logging {
    		   this.test2(user)
    	   }     
       }
    
       private fun test2(input: Input) { 
    	   logging {
           testRepository.save(input.test)
         }
       }
    }
    

    어노테이션, Advice, Pointcut 표현식없이 이렇게 간단하게 동일한 동작을 할 수 있다.!!

     

    @Transactional 을 Kotlin AOP로 개선해보기!

     

    나는 Trailing Lambda를 활용하여, @Transactional 어노테이션을 필요한 곳에 보다 유연하게 적용하는 방식으로 개선하였다.

     

    일단 Spring AOP를 사용할 때 겪었던 문제를 설명해보겠다.

     

    특정 DB에 write하는 로직과 해당 write 이벤트를 카프카에 발행하는 로직이 @Transactional 어노테이션을 통해 하나의 트랜잭션으로 묶여있었다. 그렇기 때문에 이벤트를 발행하더라도, 트랜잭션 커밋이 되지 않은 상태가 길어진다면 write 이전 데이터를 읽어갈 가능성이 있다.

    그렇기에 컨슘하는 쪽의 서버 데이터와 우리 데이터 상태가 일치하지 않은 문제가 발생했다.

     

    하지만 Spring AOP는 내부 함수 호출할 때 적용이 되지 않기 때문에(위의 두번째 문제), 또 다른 클래스를 만들어서 해결해야만 했다.

    나는 AOP 적용을 위해 불필요한 클래스를 만드는 이 해결법이 너무나도 일차원적인 해결법이라고 생각을 했다.

     

    그래서 위에서 제시한 Trailing Lambda 문법을 통해 해결했다.

    import org.springframework.stereotype.Component
    import org.springframework.transaction.annotation.Transactional
    
    interface Tx {
        fun <T> execute(function: () -> T): T
    
        @Component
        @Transactional(transactionManager = "postgresTransactionManager")
        class PostgresTx : Tx {
            override fun <T> execute(function: () -> T): T {
                return function()
            }
        }
    }
    

     

    이렇게 Tx 인터페이스를 선언한 후, 구현체에 @Transactional 어노테이션을 달았다.

    override fun update(id: Long, request: POIStatuUpdateRequest) {
        val data = tx.execute {
            // 로직 수행
        }
        poiEventPort.publishUpdateEvent(Event(data))
    

     

    이렇게 Trailing Lambda를 활용하여 트랜잭션이 필요한 곳에만 실행하게 하고, 이후 이벤트를 발행하도록 할 수 있었다!

    이 글을 통해 Spring AOP의 몇 가지 아쉬운 점을 살펴보고, Kotlin의 Trailing Lambda를 활용해 이러한 아쉬움을 어떻게 개선했는지 알아보았다! Trailing Lambda 문법을 이렇게나 유용하게 쓰일 수 있었다니ㅎㅎ

     

    특히, @Transactional 어노테이션을 더 유연하게 적용하는 방법도 다뤘다.

    앞으로도 테크블로그에서 얻은 인사이트를 실제 프로젝트에 적용해보며 지속적으로 학습하고 성장하고 싶다!

     

     

     

     

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

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

    tech.kakaopay.com

     

     

    반응형

    댓글

Designed by Tistory.