본문 바로가기
Computer Sience/Java

[JAVA] 다이나믹 프록시

by 제우제우 2024. 10. 16.

스프링 데이터 JPA는 어떻게 동작할까?

@Entity
@Setter
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    @Column(name = "TITLE")
    private String title;
}

public interface BookRepository extends JpaRepository<Book, Long> {
}

스프링 데이터 JPA에서는 JpaRepository를 상속받기만 하면 별도의 구현체를 제공하지 않아도 자동으로 필요한 메서드들이 동작한다.
이는 스프링의 DI(의존성 주입)와 프록시 패턴을 사용하여 구현된 구조 덕분이다. 

 

프록시 생성

스프링 데이터 JPA는 @EnableJpaRepositories라는 어노테이션을 통해 특정 패키지 내의 Repository 인터페이스들을 찾아서 프록시 객체를 자동으로 생성한다. 이 프록시는 실제로 구현체 없이도 인터페이스를 구현한 것처럼 동작한다.


프록시가 구현체 역할
스프링은 BookRepository의 구현체를 런타임에 한다. 이 구현체는 JPA를 사용하여 데이터베이스와 상호작용하는 로직을 수행하는 프록시 클래스이다. 이 프록시는 EntityManager를 이용해 데이터베이스에 접근하며, 인터페이스에서 정의한 메서드를 적절히 해석하여 SQL 쿼리를 생성하고 실행한다.

 

정리 

스프링 데이터 JPA는 프록시 패턴을 사용해 런타임에 구현체를 생성하고, JPA의 EntityManager와 연동하여 필요한 작업을 자동으로 수행하게 해준다. 개발자는 인터페이스만 정의하고 필요한  메소드를 선언하면 스프링이 나머지 작업을 처리해주는 구조이다. 

프록시 패턴

프록시와 리얼 서브젝트가 공유하는 인터페이스가 존재하고 

클라이언트는 해당 인터페이스 타입으로 프록시를 사용한다. 

클라이언트는 프록시를 거쳐서 리얼 서브젝트를 사용하기 때문에 프록시는 리얼 서브젝트에 대한 

접근을 관리하거나 부가기능을 제공하거나 리턴값을 변경할 수도 있다. 

리얼 서브젝트는 자신이 해야 할 일만 하면서(SRP) 프록시를 사용해서 부가적인 기능(접근 제한, 로깅, 트랜잭션 등..)

을 제공할 때 이런 패턴을 자주 사용한다.  

 

public class BookService {
    public void rent(Book book){
        System.out.println("rent: " + book.getTitle());
    }
}

class BookRepositoryTest {
    BookService bookService;
    
    @BeforeEach
    public void beforeEach(){
        bookService = new BookService();    
    }
    
    @Test
    public void test(){
        Book book = new Book();
        book.setTitle("코믹 메이플스토리");
        bookService.rent(book);
    }
}

rent: 코믹 메이플스토리

단순하게 책 제목을 출력한다. 

 

요구사항이 주어졌다.

1. rent() 메소드 호출 이전과 이후에 시간을 출력해 주세요 

2. 비즈니스 로직인 rent()와 시간을 출력하는 로직을 분리해 주세요

→ 프록시 패턴을 활용하자

// BookService 인터페이스 

public interface BookService {
    public void rent(Book book);
}

// 리얼 서브젝트
public class DefaultBookService implements BookService{
    @Override
    public void rent(Book book){
        System.out.println("rent: " + book.getTitle());
    }
}

// 프록시 
public class BookServiceProxy implements BookService {
    private final BookService target;
    public BookServiceProxy(BookService target) {
        this.target = target;
    }
    @Override
    public void rent(Book book) {
        System.out.println(System.nanoTime());
        target.rent(book);
        System.out.println(System.nanoTime());
    }
}

// 테스트
class BookRepositoryTest {
    BookService bookService;
    @BeforeEach
    public void beforeEach(){
        bookService = new BookServiceProxy(new DefaultBookService());
    }
    @Test
    public void test(){
        Book book = new Book();
        book.setTitle("코믹 메이플스토리");
        bookService.rent(book);
    }
}

// 출력
3858750596400
rent: 코믹 메이플스토리
3858751655200

프록시 패턴을 적용하여 기존 로직인 rent()에 아무 추가를 하지 않고 깔끔하게 앞뒤로 시간을 출력하게 만들었다. 

 

현재 문제점

만약 프록시를 적용할 클래스가 1000개라면?

내가 하나하나 프록시 클래스를 다 만들어야함 

 

복잡성

여러가지 추가 로직이 있다면? 프록시 → 프록시 → 리얼 서브젝트? 

너무 복잡하다

 

이런 문제를 해결하기 위해서 런타임에 프록시 클래스를 생성하는 동적 프록시(Dynamic Proxy) 즉 다이나믹 프록시 개념이 등장하였다. 

다이나믹 프록시 

// BookService 인터페이스 
public interface BookService {
    public void rent(Book book);
}
// BookService 구현 클래스 
public class DefaultBookService implements BookService{
    @Override
    public void rent(Book book){
        System.out.println("rent: " + book.getTitle());
    }
}
// 테스트 
class BookServiceTest {
    BookService bookService;
    @BeforeEach
    public void beforeEach(){
        bookService = (BookService) Proxy.newProxyInstance(
            BookService.class.getClassLoader(),
            new Class[]{BookService.class},
            new InvocationHandler() {
                BookService bookService = new DefaultBookService();
                @Override
                public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                    System.out.println(System.nanoTime());
                    Object result = method.invoke(bookService, args);
                    System.out.println(System.nanoTime());
                    return result;
                }
            });
    }
    @Test
    public void test(){
        Book book = new Book();
        book.setTitle("코믹 메이플스토리");
        bookService.rent(book);
    }
}

// 출력 
7083851227200
rent: 코믹 메이플스토리
7083854869200

 

자바에서 제공하는 동적 프록시를 활용하여 Proxy를 생성했다. 

 

Proxy.newProxyInstance() 메소드 

자바의 동적 프록시를 생성하는 함수이다. 

이 메소드를 호출하면 BookService 인터페이스를 구현하는 프록시 객체가 반환된다. 

해당 프록시 객체는 실제 DefaultBookService의 메소드 호출을 가로채고 부가 로직을 추가할 수 있다. 

 

Proxy.newProxyInstance()  메소드 인수 세 가지 

1. BookService.class.getClassLoader()

프록시를 생성할 때 사용할 클래스 로더이다.

동적 프록시는 런타임에 생성되므로, 해당 프록시 객체가 어느 클래스 로더에서 로딩될지를 명시한다. 

 

2. new Class [] {BookService.class}

프록시가 구현할 인터페이스 목록이다. 여기서는 BookService 인터페이스 하나만 프록시가 구현해야 하므로 배열에

BookService.class를 넘겨준다. 자바 동적 프록시는 인터페이스 기반으로만 동작하므로 인터페이스를 명시해야 한다. 

 

3. new InvocationHandher() {...}

이 부분이 실제 프록시가 동작하는 핵심이다.

프록시는 메소드가 호출될 때 이 InvocationHandler를 통해 요청을 처리한다. 

InvocationHandelr는 invoke() 메소드를 구현하며, 이 메소드가 모든 호출을 가로채고, 그 안에서 실제 메소드를 호출하거나 부가적인 로직을 처리한다. 

 

문제점 

자바에서 기본으로 제공하는 동적 프록시의 문제점은 인터페이스 타입만 가능하다는 것이다. 

구현 클래스는 불가능하다.

또한 프록시가 단순하지 않고 프록시에 프록시를 호출하는 중첩 프록시는 복잡성 또한 많이 증가한다.

이를 수동으로 관리하는 것은 매우 어렵다. 

 

이런 문제점을 잘 보완해서 만든게 스프링 AOP이다. 

 

스프링 AOP(Aspect-Oriented Programming) 관점 지향 프로그래밍 

 

  • 클래스 기반 프록시 지원
    스프링 AOP는 CGLIB(Code Generation Library)를 사용하여 구현 클래스에 대한 프록시도 생성할 수 있다.
    이는 인터페이스가 없는 클래스에도 부가 기능을 적용할 수 있게 해준다. 
  • 자동 프록시 생성
    스프링은 @Aspect 어노테이션과 AOP 설정을 통해 자동으로 프록시를 생성하고 관리한다.
    개발자는 AOP 설정만 하면 스프링이 알아서 프록시와 부가 로직을 처리한다. 
  • 관심사 분리
    AOP를 통해 여러 부가 로직(로깅, 트랜잭션, 보안 등)을 관심사로 분리하여, 실제 비즈니스 로직(리얼 서브젝트)과 프록시 로직을 독립적으로 관리할 수 있다. 이는 프록시의 중첩을 쉽게 처리하며, 코드의 복잡성을 줄여준다.

클래스의 프록시가 필요하다면?

서브 클래스를 만들 수 있는 라이브러리를 사용하여 프록시를 만들 수 있다.

 

CGLIB

  • https://github.com/cglib/cglib/wiki
  • 스프링, 하이버네이트가 사용하는 라이브러리
  • 버전 호환성이 좋치 않아서 서로 다른 라이브러리 내부에 내장된 형태로 제공되기도 한다.
  • 스프링은 내부에 내장된 형태로 제공한다. 

스프링에 내장된 CGLIB 라이브러리를 통해서 BookService 클래스의 프록시를 생성하겠다.

public class BookService {
    public void rent(Book book){
        System.out.println("rent: " + book.getTitle());
    }
}

class BookServiceTest {
    BookService bookService;
    @BeforeEach
    public void beforeEach(){
        Callback callback = new MethodInterceptor() {
            BookService bookService = new BookService();
            @Override
            public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                System.out.println(System.nanoTime());
                Object result = method.invoke(bookService, args);
                System.out.println(System.nanoTime());
                return result;
            }
        };
        bookService = (BookService) Enhancer.create(BookService.class, callback);
    }
    @Test
    public void test(){
        Book book = new Book();
        book.setTitle("코믹 메이플스토리");
        bookService.rent(book);
    }
}

// 출력 
8590669191900
rent: 코믹 메이플스토리
8590675977499

 

이전 코드:  BookService 인터페이스 → 현재 코드: BookService 클래스 

 

Ehancer.create() 

프록시 객체 생성 메소드이다. 인자로 리얼 서브젝트 클래스 타입과 CallBack(인터페이스)를 넘겨준다. 

 

CallBack → MethodInterceptor extends CallBack

익명 클래스로 MethodInterceptor 객체를 만든다.

동작은 이전과 똑같이 정의하였다. 

 

ByteBuddy

  • https://bytebuddy.net/#/
  • 바이트 코드 조작 뿐 아니라 런타임(다이나믹) 프록시를 만들 때도 사용할 수 있다.
class BookServiceTest {
    BookService bookService;
    @DisplayName("ByteBuddy 사용")
    @BeforeEach()
    public void beforeEach() throws Exception{
        Class<? extends BookService> proxyClass = new ByteBuddy().subclass(BookService.class)
                .method(named("rent"))
                .intercept(InvocationHandlerAdapter.of(new InvocationHandler() {
                    BookService bookService = new BookService();
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        System.out.println(System.nanoTime());
                        Object result = method.invoke(bookService, args);
                        System.out.println(System.nanoTime());
                        return result;
                    }
                }))
                .make().load(BookService.class.getClassLoader()).getLoaded();
         bookService = proxyClass.getConstructor().newInstance();
    }
    @Test
    public void test(){
        Book book = new Book();
        book.setTitle("코믹 메이플스토리");
        bookService.rent(book);
    }
}

 

서브 클래스를 만드는 방법의 단점

  • 상속을 사용하지 못하는 경우 프록시를 만들 수 없다.
    • Private 생성자만 있는 경우
    • Final 클래스인 경우
  • 인터페이스가 있을 때는 인터페이스의 프록시를 만들어 사용할 것.

스프링 AOP에서는 인터페이스가 있으면 자바의 Dynamic Proxy를 사용하고, 인터페이스가 없을 때는 CGLIB를 사용해 프록시를 생성한다. 

다이나믹 프록시 정리 

다이나믹 프록시

  • 런타임에 인터페이스 또는 클래스의 프록시 인스턴스 또는 클래스를 만들어 사용하는 프로그래밍 기법

다이나믹 프록시 사용처

  • 스프링 데이터 JPA
  • 스프링 AOP
  • Mockito
  • 하이버네이트 lazy initialzation

참고

 

참고 자료 

인프런 백기선님 더 자바, 코드를 조작하는 다양한 방법

 

더 자바, 코드를 조작하는 다양한 방법 강의 | 백기선 - 인프런

백기선 | 여러분이 사용하고 있는 많은 자바 라이브러리와 프레임워크가 '어떻게' 이런 기능을 제공할 지 궁금한적 있으신가요? 이번 강좌를 통해 자바가 제공하는 다양한 코드 또는 객체를 조

www.inflearn.com