본문 바로가기

Project/Hello-World (Language Exchange)

스프링은 프록시 객체를 어떻게 만들까?

Intro

안녕하세요. 개발자가 되기를 꿈꾸며 학습에 임하고 있는 개발 꿈나무입니다. 지금까지는 공부했던 내용을 따로 정리해서 그냥 모아두기만 했는데요. 이제는 프로젝트를 진행하면서 궁금했던 점이나 문제 해결과정에서 공부한 부분을 글로 풀어서 블로그에 공유하는 일도 함께 해보고자 합니다.

첫 포스팅의 주제는 ‘스프링은 프록시 객체를 어떻게 생성할까?’ 입니다.

 


 

왜 프록시에 대해 자세히 공부하기로 했는가?

프록시는 ‘대리인’ 혹은 ‘대리자’라는 뜻을 가진 단어입니다.

무언가를 대신한다는 (주로 요청을 대신 처리) 프록시의 특성상 스프링에만 국한되지 않고 CS관련해서 많은 분야에서 쓰이는 용어라는 것을 알게 되었습니다. (ex. 프록시 서버, reverse 프록시 등)

다른 여러 요인들도 있겠지만, 스프링에서 프록시가 정말 중요한 이유는 스프링을 구성하는 3대 기술 중 하나인 AOP(Aspect Oriented Programming)를 구현하는 핵심이기 때문인 것 같습니다. AOP는 핵심 로직을 부가 로직으로부터 코드의 중복 없이 완전히 분리해내는데 큰 공헌을 하는 만큼 꼭 제대로 익혀야 할 부분입니다. 그런데 AOP 자체가 난이도가 있는 기술인 만큼 개발 초보에서 벗어나 이를 제대로 활용하기 위해서는 그 밑바탕이 되는 ‘스프링의 프록시 생성 원리’에 대한 이해가 우선시되어야 되겠다는 생각을 했습니다.

 


 

기본적인 프록시의 활용

- AOP를 구현하는데 있어 스프링의 프록시 활용패턴은 다음과 같습니다.

 

스프링의 프록시 객체

 

1. IoC 컨테이너는 미리 정해진 설정을 통해 타겟(Target)에 대한 프록시 빈(Proxy Bean)을 생성해줍니다.

2. 생성된 프록시 객체는 클라이언트 (위의 사진에서는 Caller)가 타겟에게 보내는 요청들을 먼저 가로채 대신 받습니다.

3. 프록시 객체는 타겟에게 요청을 넘겨주기 전후로 부가기능 적용여부 등을 판단해서 이를 수행합니다.

4. 핵심 로직의 처리는 자신이 갖고 있는 타겟 객체로의 reference를 활용해 타겟 객체를 호출함으로써 이루어지도록 합니다.

5. 결과를 반환합니다.

 

위의 과정은 런타임 시점에서 이루어지기 때문에 Runtime Weaving이라고도 불립니다.

추가로, AOP 적용은

 

1. Compile Weaving

2. LoadTime Weaving

 

의 두 가지 방법으로도 이루어질 수 있습니다.

 

Compile Weaving컴파일 시점에 바이트 코드를 조작하여 AOP를 적용하는 것이고, LoadTime Weaving클래스가 JVM의 메모리로 로딩되는 시점에 바이트 코드의 조작을 통해 AOP를 적용하는 것은 의미합니다.

이를 위해서는 AspectJ라는 추가 모듈이 필요한데 많은 학습이 필요한 부분이므로 차후에 AOP에 좀 더 익숙해지고 AspectJ를 본격적으로 익힌 후에 관련 글을 작성해보고자 합니다.

 


 

그렇다면 스프링은 어떻게 프록시 객체를 생성할까?

 

이제부터 본격적으로 스프링이 프록시 객체를 어떻게 생성하는지 알아보겠습니다. 스프링은 다음의 두 가지 방법을 사용해서 프록시를 dynamic한 방법(Dynamic Proxy)으로 생성합니다.

 

1. JDK Dynamic Proxy

2. CGLIB

 

* 참고) Dynamic Proxy란?

: 프록시를 생성하기 위해 수동으로 일일이 인터페이스를 구현하는 과정 없이도 자동으로 프록시 객체를 만들어주는 것을 말합니다.

 

프록시 수동 구현 예시

 

프록시 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Slf4j
public class HelloProxy implements Hello {
 
    private Hello helloTarget = new HelloTarget();
 
    @Override
    public void sayHello(String name) {
        long start = System.currentTimeMillis();
        helloTarget.sayHello(name);
        log.info("타겟의 메소드가 수행되기까지 {} 초 걸렸습니다.", (System.currentTimeMillis() - start));
 
    }
 
    @Override
    public void sayHi(String name) {
        long start = System.currentTimeMillis();
        helloTarget.sayHi(name);
        log.info("타겟의 메소드가 수행되기까지 {} 초 걸렸습니다.", (System.currentTimeMillis() - start));
    }
 
    @Override
    public void greetings(String name) {
        long start = System.currentTimeMillis();
        helloTarget.greetings(name);
        log.info("타겟의 메소드가 수행되기까지 {} 초 걸렸습니다.", (System.currentTimeMillis() - start));
    }
}
cs

 

타겟 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
public class HelloTarget implements Hello {
    @Override
    public void sayHello(String name) {
        log.info("Hello {}", name);
    }
 
    @Override
    public void sayHi(String name) {
        log.info("Hi {}", name);
    }
 
    @Override
    public void greetings(String name) {
        log.info("Greetings {}", name);
    }
}
cs

 

보시는 것처럼 메소드가 3개만 있는 인터페이스를 사용해 프록시를 만들었는데도 꽤나 많은 수고가 들었습니다. 만약 메소드의 개수가 훨씬 많고, 여러 개의 프록시를 구현해야 했다면 어마어마한 코드의 양과 중복으로 문제가 되었을 것입니다. 다행히도 스프링은 Dynamic한 방법을 사용해 자동으로 프록시를 객체를 생성해주기 때문에 위처럼 수동 구현은 하지 않아도 됩니다.

 


 

1) JDK Dynamic Proxy

- 스프링이 JDK Dynamic Proxy를 활용해 dynamic한 방식으로 프록시를 생성하도록 만들기 위해서는 다음의 세가지가 필요합니다.

 

1. Class Loader

2. Interface

3. 타겟의 정보를 담고 있는 Handler 클래스(Invocation Handler 인터페이스 구현필요)

 

예시)

1
2
3
4
5
6
7
8
public void dynamicProxy() {
 
    Object proxy = Proxy.newProxyInstance(
            ClassLoader, // 클래스 로더
            Class<?> { Interface }, // 타겟의 인터페이스
            InvocationHandler // 타겟의 정보가 포함된 Handler
    )
}
cs

 

주의사항)

- JDK Dynamic Proxy를 사용할 때는 다음의 내용을 알아 두어야 합니다.

 

1. JDK는 프록시 객체의 생성을 위해 인터페이스를 필요로 한다.

 

JDK Dynamic Proxy의 프록시 객체 생성

 

* 먼저 명심할 부분은 JDK Dynamic Proxy는 인터페이스가 꼭 필요하다는 점입니다.

- 인터페이스의 구현 유무가 스프링이 JDK Dynamic Proxy 또는 CGLIB 중 무엇을 활용할지 결정합니다.

(인터페이스를 구현한 경우는 JDK Dynamic Proxy 활용)

* 스프링은 JDK Dynamic Proxy를 활용할 때 Class Loader와 전달받은 타겟이 구현한 인터페이스 정보타겟에 대한 직접적인 정보를 담고 있는 Handler를 파라미터로 받아서 프록시 객체를 만든 뒤 반환해줍니다.

 

2. Java Reflection 이 활용된다.

* Reflection은 클래스에 대한 구체적인 타입 없이도 클래스의 정보(메소드, 타입, 변수 등)를 분석해내도록 도와주는 자바 API입니다.

- 자바의 클래스는 메모리의 Static 영역에 위치하고 있기 때문에 한 클래스 안에서 Dynamic Loading을 이용해 다른 클래스의 정보에 접근할 수 있습니다.

* Reflection API중에서 ‘Method’라는 클래스가 있습니다.

- 이 Method 클래스를 활용하면 한 클래스 내에서 특정 이름을 가진 메소드에 대한 정보를 가져올 수 있습니다.

 

예시)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Client {
 
// 생략
 
public int getStringLength(String target) {
    return target.length();
}
 
public int methodPractice(Client obj) throws Exception {
 
    // 리플렉션을 통해 위의 메소드 가져오기
    Method methodByReflection = Client.class.getMethod("getStringLength"String.class);
 
    // invoke 를 이용해 메소드 실행하기 (해당 클래스로 만든 객체와 메소드에 필요한 파라미터를 전달)
    return (int) methodByReflection.invoke(obj, "Soo");
}
 
// 실행결과: 3 (= “Soo”.length)
cs

 

3. Invocation Handler도 잊어버리면 안된다.

* 프록시를 생성할 때 전달해주는 파라미터 중 하나인 Handler입니다.

* Handler를 만들기 위해서는 먼저 Invocation Handler 인터페이스를 구현해야 합니다.

  1. 해당 인터페이스는 단 하나의 메소드인 invoke만을 담고 있습니다.

  2. 그리고 메소드는 타겟이 프록시에 호출되어 실행할 메소드의 정보를 reflection을 통해 얻은 Method

  3. 마지막으로는 이를 실행하기 위해 필요한 인자로 args를 파라미터로 갖고 있습니다.

* 이처럼 Handler는 타겟의 핵심 로직을 실행할 메소드와, 이에 필요한 파라미터에 대한 정보를 가 담고 있기 때문에, 타겟에 대한 참조변수만 추가해주면 프록시는 invoke 메소드를 통해 타겟에게 핵심 로직에 대한 처리를 위임해줄 수 있게 됩니다.

 

예시)

1
2
3
4
5
6
7
8
9
public class Handler implements InvocationHandler {
    Object target;
 
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String res = (String) method.invoke(target, args);
        return res
    }
}
cs

 

*  타겟 메소드 호출 전후로 부가 로직 혹은 검증 로직이 필요하다면 간단하게 덧붙여 줄 수 있습니다.

 


 

2) CGLIB (Code Generation Library)

- 다음으로는 CGLIB가 스프링 프록시 객체를 생성하는 방법을 살펴보겠습니다.

- 생성과정을 살펴보기에 앞서 CGLIB의 작동 원리에 대한 이해를 조금 더 높이기 위해 여기서 사용되는 구성 요소를 먼저 살펴보겠습니다.

구성요소)

 

CGLIB의 프록시 객체 생성

 

1. Enhancer

* 프록시 객체를 생성하는 역할을 합니다.

- Enhancer는 기존의 클래스를 상속한 Sub 클래스를 만들고 해당 Sub 클래스의 인스턴스를 생성하는데 이 인스턴스가 바로 프록시 객체가 됩니다.

- 영어단어 ‘enhance - 향상시키다’가 가지는 뜻처럼 enhancer는 상속받은 부모 클래스에 있는 메소드를 overriding하는 동시에 이를 확장해서 부가기능을 추가하는 등의 역할을 합니다.

 

2. Callback

* template/callback 패턴의 형태로 핵심 로직을 수행하는 타겟 객체의 정보와 메소드를 enhancer에게 전달해줍니다.

- Enhancer가 생성한 프록시 객체는 부가 로직을 수행하다가 핵심 로직을 실행할 차례가 오면 전달받은 callback에 있는 타겟의 정보를 활용해 타겟이 핵심 로직을 실행할 수 있도록 합니다.

* 전달할 수 있는 callback 중 주로 쓰이는 두 가지는 다음과 같습니다.

 

1. NoOp: 아무 작업도 수행하지 않는 콜백입니다. (콜백이 없이 프록시가 실행되면 NullPointerException이 호출되므로 아무 작업이 필요 없는 경우에도 꼭 NoOp callback 객체를 설정해주어야 합니다.

 

2. Method Interceptor: 주요 콜백으로 프록시와 원본 객체 사이에 위치해서 메소드 호출을 조작할 수 있도록 도와주는 역할을 합니다.

 

(더 자세한 내용은 link를 참고해주세요.)

http://mydailyjava.blogspot.com/2013/11/cglib-missing-manual.html

 

3. Callback Filter

* 전달 받은 callback에 적용되는 Filter 클래스 입니다. 

- enhancer에 전달되는 callback은 하나가 아니라 여러 개가 될 수도 있습니다. 이때 Filter를 활용해 상황마다 다른 callback을 적용할 수 있습니다. (= 메소드 마다 다른 advice 적용하기)

- callback이 여러 개가 있는 경우 배열의 형태로 전달이 되는데 callback filter는 인덱스를 활용해 필요한 callback을 선정합니다.

 

예시)

1
2
3
4
5
6
7
8
9
10
11
class MemberServiceCallbackFilter implements CallbackFilter {
 
    @Override
    public int accept(Method method) {
        if (method.getName().equals("regist")) {
            return 0// 일치하면 인덱스가 0인 메소드 호출
        } else {
            return 1// 아닌 경우는 인덱스가 1인 메소드 호출
        }
    }
}
cs

 

CGLIB을 이용해 프록시 객체 생성하기

1. 프록시 생성을 위해 enhancer 객체를 생성합니다.

2. set ~ 메소드를 통해 enhancer에 프록시를 만들 타겟 클래스, callback과 callback Filter를 설정해줍니다. (필터는 필요한 경우만)

3. enhancer.create( )를 호출해 프록시 객체를 생성합니다.
  - create 호출 시 타겟 클래스의 객체가 생성됩니다.
  - 메소드 별 callback 지정 (filter가 있는 경우 filter가 적용됩니다.
  - 프록시 클래스와 객체 생성이 이루어집니다.

 

예시)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) {
    // 1. 프록시 객체를 만들기 위해 enhancer 생성
    Enhancer enhancer = new Enhancer();
 
    // 2. 프록시 객체의 대상이 되는 타겟클래스 설정
    enhancer.setSuperclass(Cat.class);
 
    // 3. 프록시가 호출할 callback 지정
    enhancer.setCallback(new MethodInterceptor() {
        @Override
        public Object intercept(Object target, Method method, Object[] args, MethodProxy proxy) throws Throwable {
            Long start = System.currentTimeMillis();
            Object res = proxy.invokeSuper(target, args);
            Long end = System.currentTimeMillis();
            log.info("타겟의 메소드가 수행되기까지 {} 초 걸렸습니다.", (end - start));
            return res;
        }
    });
 
    // 4. enhancer 의 create() 메소드를 호출해 프록시를 생성한다. return 값은 Object 이므로 형변환은 필수
    Cat proxy = (Cat) enhancer.create();
    log.info(proxy.sayName());
    log.info(proxy.sayName("Rosie"));
}
cs

 

CGLIB 프록시 객체의 작동원리

1. 클라이언트가 요청을 보내면 프록시는 생성과정에서 매칭된 callback을 통해 요청을 가로챕니다.

2. 이때 proxy/target 객체 그리고 메소드, 파라미터 등의 정보를 함께 전달받습니다.

3. 부가기능이 있으면 수행하고 핵심 로직은 callback을 통해 수행합니다.

 

주의사항)

- 마지막으로 CGLIB을 활용할 때 알아 두어야 할 점입니다.

 

1. CGLIB는 ‘인터페이스’ 구현이 전제조건이 아니다.

- 인터페이스가 없는 경우엔 CGLIB은 타겟(부모) 클래스를 가지고 상속을 활용해 프록시 클래스를 만들어 프록시 객체를 생성합니다.

 

2. 상속을 활용하기 때문에 타겟 클래스에 final 키워드가 붙어있으면 상속을 할 수 없어 예외가 발생합니다.

참고) 메소드의 경우 final이 붙어 있으면 CGLIB 프록시 객체 자체는 생성은 됩니다. 하지만 스프링에서 AOP를 적용할 때 이렇게 해버리면 컴파일 에러가 나고 프록시 생성 및 적용이 되지 않습니다.

 

3. (주의) Method Interceptor 사용시

- callback내에서 프록시 객체를 통해 타겟의 객체를 통해 핵심 로직을 수행할 때 `invoke`가 아닌 **`invokeSuper`**을 호출해야 합니다.

- invoke 메소드를 호출해버리면 해당 프록시 객체로 계속 요청이 전달이 되기 때문에 다음으로 넘어가지 못한 채 자기 자신을 계속 호출하게 되므로 결국 StackOverFlowError가 발생합니다.

invokeSuper 대신 invoke를 호출한 경우

 

4. reflection이 아닌 바이트 코드를 조작하는 프레임워크인 ASM을 사용하기 때문에 JDK Dynamic Proxy에 비해서 속도가 더 빠르다고 합니다.

참고) 이는 다양한 프레임워크가 CGLIB을 적용한 이유 중 하나이며 Spring boot의 경우는 기존에는 JDK Dynamic Proxy를 기본 Proxy 생성법으로 활용했으나 현재는 예외가 덜 발생한다는 이유로 CGLIB을 기본으로 해서 Proxy를 생성하고 있다고 합니다.

 


 

Outro

지금까지 스프링이 프록시 객체를 어떻게 생성하는지에 대해 알아보았습니다. 이제 스프링의 프록시 생성의 기본 원리를 알았으니 이를 참고로 코드를 작성해가면서 프록시 적용에 익숙해진 뒤 진행중인 프로젝트에까지 적용해보고자 합니다.

처음이라 단순히 원리만 파악하는 글임에도 작성이 꽤 오래 걸렸네요. 다음에는 좀 더 발전된 내용으로 찾아오겠습니다. 감사합니다.

 


 

References)

토비의 스프링 Vol.1 6장 AOP

https://pasudo123.tistory.com/441

https://velog.io/@hanblueblue/Spring-Proxy-1-Java-Dynamic-Proxy-vs.-CGLIB

http://mydailyjava.blogspot.com/2013/11/cglib-missing-manual.html


 

Project Link)

https://github.com/f-lab-edu/Hello-World

f-lab-edu/Hello-World
언어교환 상대찾기 서비스. Contribute to f-lab-edu/Hello-World development by creating an account on GitHub.
github.com