본문 바로가기
서버/스프링

[스프링 AOP] 1. 스프링 AOP와 Proxy 패턴

by 베어 그릴스 2024. 9. 28.
320x100

개요

최근 스프링 공식 문서를 시작하여 내부 코드부터 구현 원리까지 학습은 매우 많이 했는데 정리는 하지 못한 것 같아 이번 글부터 Aop를 시작으로 스프링에 대해 완벽 정리를 해보고자 한다. 

 

스프링 AOP란?

스프링 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)은 스프링 프레임워크에서 지원하는 프로그래밍 패러다임 중 하나로, 주된 기능은 애플리케이션의 핵심 비즈니스 로직과 공통적인 부가 기능(예: 로깅, 트랜잭션 관리, 보안 등)을 분리하는 것을 말한다.

 

예를 들어, 한 상품 판매자가 상품을 등록 하는 기능을 만들어야 한다고 가정해보자.

여기서 핵심 비즈니스 로직은 아래와 같을 것이다.

  • 상품의 이름은 10자를 넘어서면 안된다.
  • 상품의 이미지는 5개를 넘어서면 안된다.
  • 상품을 등록할 때는 타입(e.g. 책, 식품, 옷)을 구분하여야 한다.

공통적인 부가 기능은 다음과 같다.

  • 요청의 앞뒤에 요청에 걸린 시간을 측정해야한다.
  • 한 트랜잭션 안에 위 비즈니스 로직이 동작하여야 한다.
    • 개인적으로 트랜잭션 범위는 부가 기능이라기 보단 비즈니스 로직으로 생각할 수도 있다고 생각한다. (e.g. 상품과 상품 이미지를 순서대로 저장할 때 상품 이미지 저장에 실패해도 상품 자체는 저장되어야할 수도 있고 상품 이미지 저장에 실패하면 상품 저장도 롤백되어야 할 수도 있다.) 이런 경우 오히려 명시적으로 트랜잭션을 시작하고 닫아주는 것이 더 좋은 선택일 수도 있다고 생각한다.
  • 캐싱
  • ..

부가 기능과 핵심 기능이 섞이면 코드가 지저분해지고 코드의 반복이 발생하고 유지보수가 힘들어지는 등 많은 문제가 생기게 된다. 이러한 문제를 해결하기 위하여 이러한 부가기능을 모듈화하기 위한 수요가 생겼고 이에 AOP(관점 지향 프로그래밍)가 탄생하게 된다. 핵심 기능과 부가 기능을 분리하여 각각의 모듈로 만들고 분리된 모듈로 프로그래밍하는 방법을 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)라고 한다.

 

즉, AOP는 스프링에서만 제공하는 특별한 기술이 아니라 부가기능 문제를 해결하기 위해 나온 하나의 프로그래밍 방식이다. AOP는 객체지향 개발이나 절차지향 개발을 대체하는 하나의 새로운 기술이 아니라 위 개발을 더욱 효율적으로 하게끔 도와준다.

 

스프링 AOP에서는 AOP를 지원하기 위해 프록시 기반 기술을 사용하는데, 이를 더욱 잘 이해하기 위해 프록시 패턴부터 학습해보자.

 

참고로, AOP 지원 방법에는 프록시만 있는 것이 절대 아니다. 앞으로 볼 Proxy 패턴은 AOP를 지원하는 하나의 방법일 뿐이다.

 

Proxy 패턴

프록시(Proxy) 패턴은 디자인 패턴 중 하나로, 객체에 대한 접근을 제어하기 위해 사용되는 패턴이다. 프록시 패턴은 어떤 객체에 접근할 때 그 객체 대신 대리 역할을 하는 다른 객체(프록시)를 사용하여 실제 객체의 사용을 지연시키거나 접근을 통제하는 역할을 한다. 스프링 AOP에서는 이 Proxy가 실제 객체에 접근하기 전에 부가기능을 실행시켜 주는 역할을 한다.

 

여담으로, 프록시 패턴은 데코레이터 패턴과 똑같이 생겼는데 사용하는 목적에 따라 두가지 패턴을 나눈다고 한다.

 

프록시 패턴을 구현하는 방법에는 크게 두가지가 있다.

  • 상속 기반
  • 인터페이스 기반

상속 기반

우리는 개발자기 때문에 코드로 대화하는 것이 아마 더 빠를 것이라 생각된다. 함수의 실행 시간을 측정하여 로깅하라는 부가 기능을 구현해주는 Proxy를 만들어보자.

// target 클래스가 걸리는 시간을 측정하는 부가 기능을 구현하는 프록시
// 상속 기반
class Proxy(
    private val target: Target
) : Target() {
    override fun hello() {
        val before = System.currentTimeMillis()
        target.hello()
        val after = System.currentTimeMillis()
        println("time: ${after - before}")
    }
}

open class Target {
    open fun hello() {
        println("hello")
    }
}

fun main() {
    val target = Target()
    val proxy : Target = Proxy(target)


    // 사용하는 클라이언트에서는 Proxy인지 Target인지 모르고 사용
    proxy.hello()
}

이와 같은 관계를 가지게 된다.

인터페이스 기반

위와 똑같은 기능을 인터페이스 기반으로 만들어보자. 

interface ITarget {
    fun hello()
}

class TargetImpl : ITarget {
    override fun hello() {
        println("hello")
    }
}

class ProxyImpl(private val target: ITarget) : ITarget {
    override fun hello() {
        val before = System.currentTimeMillis()
        target.hello()
        val after = System.currentTimeMillis()
        println("time: ${after - before}")
    }
}

fun main() {
    val target = TargetImpl()
    val proxy : ITarget = ProxyImpl(target)

    // 사용하는 클라이언트에서는 Proxy인지 Target인지 모르고 사용
    proxy.hello()
}

이와 같은 관계를 가지게 된다.

 

결국 위 두가지 구현에서 중요한 것은 부가 기능을 추가한 Proxy 객체로 원래 핵심 기능을 가진 객체를 감쌌지만 사용하는 클라이언트 관점에서는 그것이 프록시인지 아닌지 신경쓸 필요가 없다는 것이다.

 

프록시 패턴을 직접 구현해서 사용할 때 문제

만약 우리가 위처럼 함수의 실행 시간을 측정해주는 부가 기능을 추가하는 프록시를 객체에 추가한다고 가정해보자.

똑같은 역할을 하는 중복 프록시 클래스가 매우 많이 생기게 된다.

 

TargetA와 TargetB … 에 메서드 실행 시간을 로깅하는 프록시를 만든다고 해보면 코드는 아래와 같을 것이다.

// 똑같은 역할을 하는 프록시 객체가 무수히 많이 생김
class AProxy(
    private val targetA: TargetA
) : TargetA() {
    override fun hello() {
        val before = System.currentTimeMillis()
        targetA.hello()
        val after = System.currentTimeMillis()
        println("time: ${after - before}")
    }
}

open class TargetA {
    open fun hello() {
        println("hello")
    }
}

class BProxy(
    private val targetB: TargetB
) : TargetB() {
    override fun hello() {
        val before = System.currentTimeMillis()
        targetB.hello()
        val after = System.currentTimeMillis()
        println("time: ${after - before}")
    }
}

open class TargetB {
    open fun hello() {
        println("hello")
    }
}
...

 

이러면 똑같은 부가기능을 추가 해야하는 클래스가 많아질 수록 코드의 중복이 생긴다.

실행 시간 로깅 스펙이 바뀐다면 모든 프록시 클래스를 하나하나 찾아가며 변경해야하는 재앙이 발생할 수 있다.

 

동적 프록시를 통한 해결

위와 같은 문제를 해결하기 위해 동적으로 Reflection을 이용해서 프록시를 만들어 해결할 수 있다.

 

동적 프록시란 우리가 직접 코드로 프록시 객체를 하난 하나 만드는 것이 아니라 Runtime에 Interface를 구현하는(혹은 클래스를 상속하는) 객체를 만들어내는 것을 말한다. 즉, 하나의 프록시 클래스로 여러 핵심 기능을 감쌀 수 있다는 특징을 가지고 있고 이를 통해 공통 기능을 프록시 클래스에 구현하여 코드 중복을 줄일 수 있다.

 

위 내용 역시 제대로 이해하지 못하였더라도 괜찮다. 우리는 개발자기 때문에 코드로 이해해보자. 참고로 실제로 위 라이브러리들을 직접 사용할 일은 거의 없기 때문에 사용법을 익히고 외울 필요는 없는 것 같다. 대강 '이렇게 되는구나' 정도만 이해하면 될 것 같다.

 

자바로 동적프록시를 구현하는 방법은 크게 두가지가 있다.

  • 인터페이스 기반 동적 프록시 : 주로 java.lang.reflect.Proxy 클래스와 java.lang.reflect.InvocationHandler 인터페이스를 사용하여 동적 프록시를 구현한다.
  • 클래스 기반 동적 프록시 : 주로 CGLib 라이브러리를 사용하여 동적 프록시를 구현한다

 

인터페이스 기반 동적 프록시 (jdk dynamic proxy)

자바에서 제공하는 jdk 동적 프록시를 사용하면 인터페이스 기반의 동적 프록시를 구현할 수 있다.

 

InvocationHandler 인터페이스

  • 대상 객체의 메서드 호출을 처리하는 인터페이스이다. 
  • invoke 메서드를 구현하여 메서드 호출 시 수행할 작업을 정의한다
  • 즉, 여기서 우리가 작성할 부가기능을 가지고 있다.
// 프록시의 동작을 여기에다가 담음
class TimeInvocationHandler(
    private val target: Any
) : InvocationHandler {
    override fun invoke(proxy: Any?, method: Method?, args: Array<out Any>?): Any? {
        println("Proxy 시작")
        val start = System.currentTimeMillis()

        // target의 메서드가 직접 실행됨
        val result = method?.invoke(target)
        
        val end = System.currentTimeMillis()
        println("Time: ${end - start}")
        println("Proxy 끝")
        return result
    }
}

// 아래처럼 사용
    @Test
    fun dynamicA() {
        val target = AImpl()
        val timeInvocationHandler = TimeInvocationHandler(target)

        val proxy = Proxy.newProxyInstance(
            AInterface::class.java.classLoader,
            arrayOf(AInterface::class.java),
            timeInvocationHandler
        ) as AInterface

        proxy.call()
    }

    @Test
    fun dynamicB() {
        val target = BImpl()
        val timeInvocationHandler = TimeInvocationHandler(target)

        val proxy = Proxy.newProxyInstance(
            BInterface::class.java.classLoader,
            arrayOf(BInterface::class.java),
            timeInvocationHandler
        ) as BInterface

        proxy.callB()
    }

 

위처럼 구현하면 결국 부가기능은 중복 없이 InvocationHandler에 모이게 된다.

위 코드에서 Proxy.newProxyInstance 의 결과를 인터페이스로 형변환 하는 것에 집중하길 바란다. 만약, 구체 클래스로 형변환하려 한다고 하면 다음과 같은 에러를 만날 수 있다.

class jdk.proxy3.$Proxy15 cannot be cast to class jungwoo.experiment.proxy.jdkdynamic.code.AImpl (jdk.proxy3.$Proxy15 is in module jdk.proxy3 of loader 'app'; jungwoo.experiment.proxy.jdkdynamic.code.AImpl is in unnamed module of loader 'app')
java.lang.ClassCastException: class jdk.proxy3.$Proxy15 cannot be cast to class jungwoo.experiment.proxy.jdkdynamic.code.AImpl (jdk.proxy3.$Proxy15 is in module jdk.proxy3 of loader 'app'; jungwoo.experiment.proxy.jdkdynamic.code.AImpl is in unnamed module of loader 'app')
	at jungwoo.experiment.proxy.jdkdynamic.JdkDynamicAProxyTest.dynamicA(JdkDynamicAProxyTest.kt:17)
	at java.base/java.lang.reflect.Method.invoke(Method.java:569)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

 

위와 같은 에러가 나는 이유는 무엇일까?

jdk 동적 프록시는 인터페이스 기반으로 프록시 객체를 만든다고 하였다.

즉, 만들어진 Proxy 객체는 AInterface의 구현체이다.

아래 그림같은 관계를 같기 때문에 형변환을 할 수가 없다.

클래스 기반 동적 프록시 (CGLIB)

CGLib는 코드 생성 라이브러리로, 자바 클래스의 바이트 코드를 조작하여 서브 클래스를 동적으로 생성하는 기능을 제공한다.

CGLib을 사용하면 인터페이스가 아닌 클래스를 상속받아 프록시를 생성할 수 있다.

CGLIB은 참고로 외부 라이브러리이기 때문에 의존성 추가가 필요하다.

 

MethodInterceptor 인터페이스

  • 대상 객체의 메서드 호출을 처리하는 인터페이스이다. 
  • intercept 메서드를 오버라이딩하여 메서드 호출 시 수행할 작업을 정의한다
  • 즉, 여기서 우리가 작성할 부가기능을 가지고 있다.
  @Test
    fun test() {
        val a = AService()

        val enhancer = Enhancer()
        // 부모 클래스를 설정해줌
        enhancer.setSuperclass(AService::class.java)
        enhancer.setCallback(TimeMethodInterceptor(a))
        // 부모 클래스 타입으로 형변환
        val aImpl = enhancer.create() as AService
        aImpl.call()
    }


// 실제 로직이 들어감
class TimeMethodInterceptor(
    private val target: Any
) : MethodInterceptor {
    override fun intercept(obj: Any?, method: java.lang.reflect.Method?, args: Array<out Any>?, proxy: org.springframework.cglib.proxy.MethodProxy?): Any? {
        val start = System.currentTimeMillis()
        val result = proxy?.invokeSuper(target, args)
        val end = System.currentTimeMillis()
        println("Time: ${end - start}")
        return result
    }

딱히 사용법은 중요하지 않으나,  enhancer.setSuperclass(AService::class.java) 여기서 인터페이스가 아닌 부모 클래스 타입을 지정해주는 것만 확인해보면 될 것 같다.

 

이제 위와 같은 동적 프록시를 사용하면 부가 기능이 한곳에 모이기 때문에 즉, 모듈화되기 때문에 코드 중복 등의 문제를 걱정할 필요가 없어졌다.

다음으로

이번 글에서는 Proxy 패턴과 직접 구현의 한계, 동적 프록시에 대해 보다 깊게 알아보았다. 다음 글에서는 스프링에서는 어떻게 이 프록시가 사용되고 있는지 더 깊게 알아보자.

728x90