개요
백엔드 데브코스에서 마르코님이 진행하는 발표 스터디에서 프록시 패턴을 맡게 되었는데요! 해당 발표를 준비하면서 학습한 내용을 공유하고 싶어서 글을 쓰게 되었습니다.
프록시 패턴 소개
프록시는 직역하면 대리라는 의미를 가지는데요. 직역 그대로 프록시 패턴은 클라이언트가 원래 사용하려는 객체를 직접 쓰는 것이 아닌 대리인을 거쳐 쓰는 패턴이라고 보면 됩니다.
쉽게 말해, 회사 대표를 뵙기 전 비서를 먼저 봐야하는 것처럼, 요청이 왔을 때 항상 프록시가 먼저 받게 되는 것처럼 말이죠!
그림을 보면 프록시와 리얼 서브젝트가 공유하는 인터페이스가 있고 프록시는 리얼 서브젝트를 참조하고 있습니다. 그래서 클라이언트는 해당 인터페이스 타입으로 프록시를 사용합니다!
이처럼 클라이언트가 프록시를 거쳐 리얼 서브젝트를 사용하기 때문에, 프록시가 리얼 서브젝트에 대한 접근 제어 혹은 부가 기능 등을 사용하고 리얼 서브젝트는 자신이 해야하는 일만 할 수 있게 됩니다.
추가로 아래 그림을 보면 더 이해하기 쉬울겁니다!
그림을 보면 프록시가 로그를 남기거나 클라이언트가 요청한 데이터를 캐싱하는 등의 요청 흐름을 제어할 수 있고 접근 보호 혹은 초기화 지연 등의 클라이언트 요청 흐름을 제어하는 역할을 수행합니다! 이처럼 프록시는 대리인 역할을 하는 것이죠.
이렇게 기존 코드를 변경하지 않고 새로운 기능을 추가할 수 있게 되면서 OCP 개방-폐쇄 원칙을 지킬 수 있게 되었죠? 또 기존 코드가 해야하는 일만 유지할 수 있게 되어서 SRP 단일 책임 원칙도 지킬 수 있게 되었습니다!
코드로 보는 프록시 패턴
이번에는 코드로 프록시 패턴을 살펴봅시다!
코드로 보기 전에 위와 같이 상품을 검색하고 구매할 수 있는 상품 구매 서비스가 있다고 가정합시다.
이럴 경우, 위처럼 서비스의 검색과 구매 기능을 가진 인터페이스와 그에 따른 구현체가 생기겠죠?
하지만 이럴 경우엔 클라이언트가 수많은 중복된 검색 요청을 보냈다고 가정한다면, 검색된 데이터를 응답하기 위해, 중복된 요청이여도 매번 서버에 연결 후 데이터를 가져와서 클라이언트에게 제공합니다. 또한 로그를 찍는 등의 부가기능이 있을 시 각 기능은 하나의 책임만 가지는 것이 아니게 됩니다. 그러면 이 코드에 프록시를 적용한다면?
바로 위 코드처럼 구현할 수 있습니다! 코드를 살펴보면 기존 리얼 서비스를 각 내부에서 호출하고 있고 캐싱 등의 부가 기능과 초기화 지연이 수행되고 있습니다 .
이로 인해서, 여러 중복된 요청이 와도 매번 서버에 요청을 보내지 않아도 됩니다. 즉, 기존 코드를 수정하지 않고 확장을 할 수 있게 되었고 실제 객체도 자신만의 로직에 집중할 수 있게 되었습니다.
하지만 이 코드에는 문제점이 있는데요..!! 바로 중복 로직입니다. 또한, 필요한 부분에만 부가 기능을 넣고 싶은데, 인터페이스 상속으로 인해, 필요 없는 서비스까지 구현을 해야 되는 리소스가 발생합니다. 이 문제점을 어떻게 해결할까요?
JDK-Dynamic Proxy
바로 동적 프록시로 해결할 수 있습니다!
동적 프록시는 런타임에 특정 인터페이스를 구현하는 클래스 또는 인스턴스를 만드는 기술로 Reflection API의 프록시 클래스를 이용하여 구현할 수 있습니다.
다음 아래 그림은 동적 프록시의 흐름인데요.
타겟은 앞서 보여줬던 상품 서비스에서 프록시를 적용하고 싶은 대상이라고 생각하면 됩니다.
다음으로 Invocation Handler에는 invoke(...)가 있는데, 클라이언트가 호출한 모든 메소드들이 이 invoke(...)를 거쳐 진행됩니다. 한마디로 invoke(...)에서 어떻게 처리할 지에 대해 재정의하면 되겠죠?
이어서 바로 아래 코드가 Invocation Handler를 적용한 코드입니다!
해당 클래스에서 메소드 호출을 처리하는 Invocation Handler 상속받고 있습니다. 이 인터페이스 내부의 invoke(...)도 재정의하고 있죠.
이번에는 동적 프록시 부분에서 런타임에 특정 인터페이스를 구현하는 인스턴스를 만들 것인데, 이때 프록시의 newProxyInstance(...) 사용해서 만들 수 있습니다. 이 메서드 파라미터 정보는 클래스로더, 인터페이스 목록, InvocationHandler를 받습니다.
이어서 아래 코드가 프록시의 newProxyInstance(...)를 활용해, 동적 프록시를 사용하고 있습니다.
이처럼 동적 프록시를 이용해 구현하면 프록시 클래스를 직접 구현하지 않게 되어 복잡도 문제를 해결했습니다. 또한 InvocationHandler를 활용해 중복 코드 등의 문제점을 해결했습니다.
하지만 이 방식은 인터페이스가 있어야만 가능한 방법이고 Reflection을 사용하기 때문에 컴파일러 최적화를 전혀 받지 못해 성능상 좋아보이지 않습니다. 그리고 사실 여러 부가기능을 적용해야할 때, InvocationHandler가 계속해서 무거워지지 않을까요?
그렇다면 인터페이스를 만들지 않으면서 Reflection을 사용하지 않고 프록시를 활용할 수 있는 방법이 있을까요?
CGLIB
바로! CGLIB을 사용해서 해결할 수 있습니다!
이 방식은 동적 프록시와는 다르게 클래스 기반으로 동작을 하고 Invocation Handler를 사용하지 않고 서브 클래스를 만들 수 있는 라이브러리를 사용하여 프록시를 만들고 Method Interceptor를 활용합니다.
다음 그림은 CGLIB의 흐름입니다.
역시나 타겟은 앞과 동일합니다. 그리고 Method Interceptor에는 intercept(...)에서 어떻게 처리할 지에 대해 재정의하면 되겠죠?
이어서 바로 아래 코드가 Method Interceptor를 적용한 코드입니다!
코드를 보면 동적 프록시와는 다르게 Method Interceptor를 상속받고 있고 intercept(...)를 재정의하여 사용하는 것을 볼 수 있습니다. 그 외 코드 구현 사항은 거의 비슷합니다.
이어서 아래 코드가 외부 라이브러리를 활용해, CGLIB부분을 구현해보면 다음과 같습니다.
코드를 보면 CGLIB의 Enhancer를 사용하는 것을 볼 수 있습니다. 그리고 Enhancer의 create(...)를 사용 중인 것도 볼 수 있는데요. 이 라이브러리는 'org.springframework.cglib.proxy'와 'org.hibernate.bytecode.enhance.spi'에서 제공되고 있습니다.
결론적으로 CGLIB은 동적 프록시와는 다르게 외부 의존성을 사용하기 때문에, 의존성을 추가해야 합니다. 하지만 CGLIB은 메소드가 처음 호출 됐을 때, 동적으로 타겟 클래스의 바이트 코드를 조작하고 이후 호출 시에는 조작된 바이트 코드를 재사용합니다. 즉, 성능면에서 Reflection을 사용하는 동적 프록시보다 좋을 수밖에 없는데요.
공식문서에 따르면 아래 표와 같이 약 3배 가까이 빠른 것을 확인해볼 수 있습니다.
물론, private 생성자가 있는 경우 혹은 Final 클래스인 경우처럼 상속이 안되면 사용하지 못한다는 단점도 있습니다.
정리
Proxy Pattern | JDK - Dynamic Proxy | CGLIB |
대리인을 거쳐서 쓰는 패턴이다. | Reflection을 사용해서 느리다. | 바이트 코드를 조작해서 빠르다. |
부가적인 기능을 제공해야할 때 주로 사용한다. | 인터페이스가 있어야만 동작한다. | 클래스만 있어도 동작한다. |
OCP와 SRP 원칙을 지킬 수 있도록 해준다. | Invocation Handler를 활용한다. | Method Interceptor와 외부 라이브러리를 사용한다. |
Reference
'Activity > 데브코스 - 백엔드 엔지니어링' 카테고리의 다른 글
벡엔드 데브코스 TIL - 동시성 이슈 해결하기 (4) | 2023.10.11 |
---|---|
벡엔드 데브코스 TIL - 프로젝트로 단축 URL 알아가기 (0) | 2023.10.04 |
백엔드 데브코스 TIL - 동행 스크럼을 하다가 공부하게 된 Process와 Thread (0) | 2023.06.12 |
벡엔드 데브코스 TIL - 싱글톤 패턴 발표 (4) | 2023.06.08 |
백엔드 데브코스 TIL - 놓치기 쉬운 JAVA 이야기 (2) | 2023.06.02 |