thumbnail

0. 서론

최근에 Kotlin 언어로 스프링 개발을 시작하였습니다. (이하 코프링 )
코틀린을 사용하던 중, 코틀린을 이용한 Spring AOP를 비슷하게 사용한 코드를 공유합니다.

1. Spring AOP

우선 AOP 에대해서 간단한 설명이 필요할 것 같습니다.
AOP는 관점지향프로그래밍(Aspect Oriented Programming) 를 의미합니다

관점지향 프로그래밍이란 핵심적인 관점과, 부가적인 관점을 분리하여
코드의 응집도와 재생산성을 높이는 프로그래밍을 말합니다

예를 들어, 메소드를 항상 호출하기전
부가적으로 beforeCall() 이라는 문장을 console로 출력하는
요상한 요구사항이 있다고 가정해봅니다.

그리고 계산기 서비스를 구현합니다.

@Service
public class CalcualtorService{
    public Int plus(int x, int y){
        System.out.printlnt("beforeCall()");
        return x + y;
    }
    
    public Int minus(int x, int y){
        System.out.printlnt("beforeCall()");
        return x - y;
    }

우선 불편한점을 찾아 볼 수 있습니다.
비즈니스코드를 부가적인 관점이 System.out.println 이라는 저수준의 코드가 더럽히고 있습니다.
이런 코드는 출력의 변경이 핵심 비즈니스로직의 재컴파일, 수정에 의한 내 비즈니스코드가 안전할까라는 의심을 낳게 합니다.

또한 반복된 중복 코드를 우리에서 우리는 불편함을 확인 할 수 있습니다.

보통의 우리는 이런상황에서 AOP를 생각 할 수 있습니다.
물론 함수가 몇개 안되는 상황에서, AOP를 구현하는 배보다 배꼽이 더 큰 상황에서는 아니겠지만요!

Spring AOP는 다음과 같이 구현 할 수 있습니다.

가시적인 어노테이션 기반에 AOP를 선호합니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface BeforeStdoutAop {}

//----

@Component
@Aspect
public class BeforeStdoutAspect {
    @Around("@annotation(BeforeStdoutAop)")
    public Object aspect(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("beforeCall()");
        return joinPoint.proceed();
    }
}

//--- 

@Service
public class CalculatorService {
    @BeforeStdoutAop
    public int plus(int x, int y) {
        return x + y;
    }

    @BeforeStdoutAop
    public int minus(int x, int y) {
        return x - y;
    }
}

<br/>

CalulatorService#plus() 를 호출하면, AOP가 적용되어 console에 출력됨을 확인 할 수 있습니다.

    @PostConstruct
    public void init(){
        System.out.println("plus 호출!!");
        calculatorService.plus(1,2);
    }

<img src="https://static.podo-dev.com/blogs/images/2021/06/30/origin/d7357a51-8d61-4d97-b7d1-d73dd3fb32de.png" alt="base64.png" style="width:230px;">

이로써 AOP를 이용한, 응집도가 높은 재사용이 좋은 코드를 생성할 수 있습니다.

<br/>

2. Spring AOP 단점

하지만, Spring AOP를 사용하면서 몇가지 단점들을 느끼곤 합니다.

<br/>

우선, 클래스 내부에서 내부함수호출로는 AOP가 동작하지가 않습니다.

예를 들어, 다음과 같은 코드를 보면,
예상과는 다르게 beforeCall() 한번만 출력될 것입니다.

@Service
public class CalculatorService {
    @BeforeStdoutAop
    public int plus(int x, int y) {
        minus(x,y);
        return x + y;
    }

    @BeforeStdoutAop
    public int minus(int x, int y) {
        return x - y;
    }  
}

<img src="https://static.podo-dev.com/blogs/images/2021/06/30/origin/66c41b27-fae3-43cf-a9db-742b35f3be36.png" alt="base64.png" style="width:434px;">

이유는 Spring AOP는 proxy로 동작하기 때문입니다.

이전에 작성한 Spring @Trasactional 처리과정에 proxy에 대한 내용을 설명하였습니다

하지만 우리는 때로는, 분명히 내부함수 호출에도 AOP가 적용되기를 희망 할 때가 있습니다.

<br/>

두번째 단점으로, 일단 간단한 케이스에 경우..

굳이 어노테이션을 정의하고, Aspect를 정의하면서.. 까지 AOP를 사용할까..라는 불편함이있습니다.

합리적인 판단에서 몇 가지 케이스에 대해서만 관점지향이 필요하다면,
AOP를 사용하지 않고, 응집도와 재사용성을 양보하는 코드도 나쁘지 않다고 생각할 것입니다.

<br/>

세번째 단점으로는 @Cacheable 같은 케이스 입니다.

@Cacheable은 Spring에서 지원하는 cache 관련 AOP 입니다.
@Cacheable 어노테이션 인자값 중 key는 SPEL문법을 받고 있습니다.

SPEL문법을 String으로 입력해서, 메소드 인자값을 추출하여 캐시의 키로 정의하는 것입니다.

<img src="https://static.podo-dev.com/blogs/images/2021/06/30/origin/eecfd536-df60-49bf-a941-ab3a5ffd1972.png" alt="base64.png" style="width:400px;">

메소드 인자 x, y 의 값을 꺼내어, 캐시의 키로 사용합니다.
key에 입력되는 값이 단순히 String임을 주목해야합니다.

문제는 컴파일단계에서 버그가 확인이 안된다는 것입니다.

메소드 인자의 변수명이라도 바뀐다면,
SPEL문법의 정의한 변수명과 일치하지 않게되고,
런타임에 버그가 발생하게 될 것입니다.

결론적으로, 매우 위험한 코드임을 보여줍니다.

<img src="https://static.podo-dev.com/blogs/images/2021/06/30/origin/94eee1fd-a8c8-4308-b914-02411e68d7c5.png" alt="base64.png" style="width:400px;">

버그발생!!! 하지만 런타임에 알 수 있습니다, 휴먼!

<br/>

3. 코틀린으로 극복해보자!!!!

긴 서론을 지나,
위에 몇가지 단점을 코틀린을 통해 보완하는 방법을 공유드립니다.

우선 간단한, 코틀린 문법을 공유하면,,

코틀린은 패키지 레벨로 함수가 정의가 가능합니다.

자바와 다르게 코틀린은 패키지 레벨에서 함수 정의가 가능합니다.
클래스 밖에서도 함수를 정의 할수 있음을 의미합니다.
코드로 보면 다음과 같습니다.

fun hello(){
    println("Hello World")
}

class HelloWorld(){
    fun world(){
        hello()
    }
}

또한, 코틀린에는 쿼리함수 가 존재합니다.

코틀린에서는 문법적으로 다음과 같은 사용이 가능합니다.

함수의 인자로 함수를 받는다면,

    fun hello(function : () -> Void){
        function.invoke()
    }

다음과 같은 식으로 표현이 가능하게 되며, 이를 쿼리함수라 정의합니다.

    fun world(){
        hello { println("hello-world") }
    }

world() 함수를 호출한다면, console에 hello-world가 출력됨을 확인 할 수 있습니다.

fun main(args: Array<String>) {
    val demo = Demo()
    demo.world()
}

<img src="https://static.podo-dev.com/blogs/images/2021/06/30/origin/27ee4d7b-c977-4c0c-a18f-0c3c917ab2f1.png" alt="base64.png" style="width:320px;">

<br/>

4. 코틀린을 이용한 AOP!

정확한 네이밍은 아니지만 이하 소개되는 내용을 코틀린AOP로 명칭하도록 하겠습니다!

위의 두 코틀린 문법을 조합하면,
AOP를 정말 쉽게 구현 할 수있습니다.

다음과 같은 모습이 될 것입니다.

fun aspect(function : () -> Int) : Int{
    println("beforeCall()")
    return function.invoke()
}

@Service
class CalculatorService {
    fun plus(x: Int, y: Int) = aspect {
        x + y
    }

    fun minus(x: Int, y: Int)= aspect {
        x - y
    }
}

그리고 동일하게 함수를 호출한다면 beforeCall() 출력되는것을 확인 할 수 있습니다.

    @PostConstruct
    fun init() {
        calculatorService.plus(1, 2)
    }

<img src="https://static.podo-dev.com/blogs/images/2021/06/30/origin/780ae1f6-039e-4b4d-8c28-0f0cfe4eea8e.png" alt="base64.png" style="width:266px;">

<br/>

약간 변태같은.. 문법이 처음 보기에는 이해가 잘 가지 않을 수도 있습니다.
plus() 함수의 변형과정을 좀 더 뜯어보면, 다음과 같은 모습이 변형된 것입니다.

    // 기존
    fun plus(x: Int, y:Int) : Int {
        return aspect(function = {x + y})   
    }
    
    // 변태!
    fun plus(x: Int, y: Int) = aspect {
        x + y
    }

<br/>

지금까지 모습을 보면, 한가지 단점을 극복하였습니다.

우선 AOP를 사용하기가 굉장히 쉽다는 것입니다!!
패키지 레벨 + 쿼리 함수 형태의 함수만 정의한다면,

어느 클래스에서든 해당 함수를 이용하여 AOP를 적용 할 수 있습니다.

<br/>

또한, 한가지 더 극복이 가능합니다.

두번째 극복은
클래스 내에서 내부 함수 호출을 하더라도 AOP 함수가 두 번 호출되게 됩니다.

Spring AOP와 달리, proxy 기반이 아니기 때문입니다.

fun aspect(function : () -> Int) : Int{
    println("beforeCall()")
    return function.invoke()
}

@Service
class CalculatorService {
    fun plus(x: Int, y: Int) = aspect {
        minius(x,y)
        x + y
    }

    fun minus(x: Int, y: Int)= aspect {
        x - y
    }

동일하게 함수를 호출한다면, AOP가 두번 호출 되는것을 확인 할 수 있습니다.

    @PostConstruct
    fun init() {
        calculatorService.plus(1, 2)
    }

<img src="https://static.podo-dev.com/blogs/images/2021/06/30/origin/4e6b3334-f791-46b5-9028-1520a85c951a.png" alt="base64.png" style="width:564px;">

<br/>

음.. 하지만 apect() 인자로 넘긴 함수가, 반환값이 Int라 범용성에 한계점이..

aspect() 함수의 인자가 반환값이 Int로 되있기 때문에,
한계점이 있지 않을까 고민하게됩니다.

정답은 제네릭에 있습니다.

aspect() 함수를 제네릭으로 선언합니다.
그리고 호출 시, 자바와 달리 타입 선언을 해줄 필요 또한 없습니다.

fun <T> aspect(function : () -> T) : T{
    println("beforeCall()")
    return function.invoke()
}

@Service
class CalculatorService {
    fun plus(x: Int, y: Int) = aspect {
        (x + y).toLong()
    }

    fun minus(x: Int, y: Int)= aspect {
        (x - y).toString()
    }
}

<br/>

마지막으로 3번째 단점을 극복합니다.

@Caceable에 문제점은 SPEL문법을 String으로 입력받아,
컴파일시가 아닌 런타임시에 버그가 발생 할 수 있다는 것입니다.

하지만 코틀린 AOP를 사용한다면, 이 문제를 해결 할 수 있습니다.
Cacheable() 의 인자에 key 값을 넘기는 것입니다.

fun <T> Cacheable(key: String, function : () -> T) : T{
    println("beforeCall()")
    return function.invoke()
}

@Service
class CalculatorService {
    fun plus(x: Int, y: Int) = Cacheable(key = x.toString()){
        (x + y).toLong()
    }

    fun minus(x: Int, y: Int)= Cacheable(key = x.toString()) {
        (x - y).toString()
    }
}

만약 plus() 함수의 인자 변수명이 바뀐다면, 컴파일 단계에 확인이 되며 빌드가 되지 않을 것입니다.

<br/>

하지만 코틀린 AOP에서는 Bean객체를 못쓰는것 아닐까..?

Spring AOP를 사용하면서 3가지 단점을 코틀린AOP로 극복하였습니다.

하지만 Spring Bean 객체를 쓰고싶다면 어떨까요?

위의 예제에서 aspect() 함수는
단순히 console 출력만 하는 로직을 담고 있습니다.

하지만 필요에 의해서 Spring에 등록된 Bean 객체를 사용해야 할 수도 있습니다.

하지만 이 역식 극뽁! 가능합니다.

다음과 같이 KotlinAspect.kt 파일을 정의합니다.

fun <T> Cacheable(key: String, function : () -> T) : T{
    val redisClient = KotlinAspect.redisClient
    println(redisClient != null) // NOT NULL!!!!!
    return function.invoke()
}

@Service
private class KotlinAspect(
    redisClient: RedisClient // Autowired
){
    init{
        KotlinAspect.redisClient = redisClient
    }

    companion object{
        lateinit var redisClient : RedisClient
    }
}
@Component
class RedisClient{}

KotlinAspect에 생성 시에, static 변수에 bean을 주입하였습니다.

주목할 점은 KotlinAspect 클래스가 private 접근지정자인 것 입니다.

따라서 동일한 파일 내인, Cacheable() 함수에서만 사용가능합니다.
외부에서는 KotlinAspect 클래스는 접근이 불가능합니다.

CalculatorService 클래스에서는 동일하게 코틀린AOP를 사용합니다

@Service
class CalculatorService {
    fun plus(x: Int, y: Int) = Cacheable(key = x.toString()){
        (x + y).toLong()
    }

    fun minus(x: Int, y: Int)= Cacheable(key = x.toString()) {
        (x - y).toString()
    }
}

코틀린AOP를 사용하면서 bean 객체에도 무리없이 접근이 가능합니다.

<br/>

5. 아쉽게도 한계점은 물론 있습니다.

한계점은 물론 있습니다.

코틀린 AOP를 사용하면,
AOP가 사용되어지는 함수에 인자에 접근 할 수 없다는 것입니다.

자세히 보면, Cacheable() 함수는 인자 x, y에는 접근 할 수 있는 방법이 없습니다. (반대로 Spring AOP에서는 지저분하지만.. 가능합니다)

fun <T> Cacheable(key: String, function : () -> T) : T{
    val redisClient = KotlinAspect.redisClient
    return function.invoke()
}

방법이 있다면 Cacheable() 함수에 인자를 추가해서 받는 것입니다.
하지만 이방법은 범용적으로 사용하고자하는 AOP라면 적절한 방법은 아닐것 입니다.

fun <T> Cacheable(key: String, x: Int, y: Int, function : () -> T) : T{
    val redisClient = KotlinAspect.redisClient
    return function.invoke()
}

<br/>

하지만 반대로 이게 됩니다..!

하지만 반대로 생각하면, 이렇게는 될것입니다.
AOP에서 호출되는 함수내부에 값을 넘기는 것입니다.

fun <T> aspect(function : (Int) -> T) : T{
    val baseValue = 10 
    return function.invoke(baseValue)
}
@Service
class CalculatorService {
    fun plus(x: Int, y: Int) = aspect {  baseValue ->
        (baseValue + x + y).toLong()
    }

    fun minus(x: Int, y: Int)=  aspect {  baseValue ->
        (baseValue - x - y).toLong()
    }
}

Spring AOP에서는 이런 동작은 불가능합니다.

<br/>

6. Cacheable AOP

끝으로 코틀린 AOP를 이용한 Cacheable AOP
간단히 구현한 코드를 공유로 마무리합니다..!

fun <T> Cacheable(key: String, function : () -> T): T {
    val redisClient = CacheableAspect.redisClient

    if(redisClient.contain(key)){
        return redisClient.get(key)
    }

    val result = function.invoke()

    redisClient.put(key, result)

    return result
}

@Service
private class CacheableAspect(
    redisClient: RedisClient
){
    init{
        KotlinAspect.redisClient = redisClient
    }

    companion object{
        lateinit var redisClient : RedisClient
    }

}
@Service
class CalculatorService {

    fun plus(x: Int, y: Int) = Cacheable(key = "${x}:${y}"){
        (x + y).toLong()
    }

    fun minus(x: Int, y: Int)= Cacheable(key = "${x}:${y}") {
        (x - y).toString()
    }
}

코틀린 재밌네용.. :)

CommentCount 0
이전 댓글 보기
등록
TOP