JAVA

스프링 데이터 JPA는 인터페이스만 정의했는데 어떻게 동작할까?

경딩 2025. 10. 7. 00:24

인터페이스만 정의했는데 어떻게 동작하지?

 

다이나믹 프록시의 핵심 클래스는 java.lang.reflect.Proxy이다.

import org.springframework.data.jpa.repository.JpaRepository;

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

 

인터페이스만 정의했을 뿐인데, save(), findById() 같은 메서드가 마법처럼 동작한다.

 

여기서 일어나는 일:

  • BookRepository 의 객체가 만들어짐
  • 그 객체가 스프링 빈으로 등록됨
  • 빈 등록 작업도 spring-data-jpa가 자동으로 처리

도대체 이 인터페이스를 어떻게 인스턴스로 만들어준 걸까?

 

실제로 동작하는지 확인해보기

 

테스트 코드

package hello.diexample.springdi;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class BookRepositoryTest {

    @Autowired
    private BookRepository bookRepository;

    @Test
    public void di() {
        System.out.println("bookRepository = " + bookRepository);
        assertNotNull(bookRepository);
    }
}

 

실행결과

bookRepository = org.springframework.data.jpa.repository.support.SimpleJpaRepository@c0cb9c2

 

우리가 정의한 BookRespository  가 아니라 SimpleJpaRepository 가  출력된다.

 

구현 하지 않은 메서드가 실제로 동작한다.

save(), findById() 처럼 우리가 직접 구현하지 않았지만, 상속받은 인터페이스가 제공하는 

모든 기능들을 실제로 동작한다.

 @Test
    public void di() {
        System.out.println("bookRepository = " + bookRepository);
        assertNotNull(bookRepository);

        Book book = new Book();
        book.setTitle("spring");
        bookRepository.save(book); // 구현하지 않았는데 동작함

        book = bookRepository.findById(book.getId()).get();
        System.out.println("book = " + book);
    }

 

실행 결과

book = Book{id=1, title='spring'}

 

핵심 질문

내가 구현하지 않은 BookRespository  는 누가 어떻게 구현되어서 만들었을까?

이 질문에 대한 답은 바로 리플렉션을 활용한 Proxy클래스이다.

 

Spring 이 내부적으로 하는 일

Spring AOP 핵심 코드

스프링 AOP 가장 밑단에 아래와 같은 코드가 있다.

InvocationHandler handler = new MyInvocationHandler(...);
Foo f = (Foo) Proxy.newProxyInstance(Foo.class.getClassLoader(),
                                     new Class<?>[] { Foo.class },
                                     handler);

 

동작 흐름:

  1. Spring Data JPA 는 Spring AOP 를 사용
  2. Spring AOP는 위와 같은 프록시 생성 코드를 사용
  3. Spring AOP는 프록시를 추상화한 ProxyFactory를 제공

스프링 jpa 가 사용하는 RepositoryFactprySupport 에서 ProxyFactory 를 사용하는 부분을 확인할 수 있다.

	// Create proxy
		StartupStep repositoryProxyStep = onEvent(applicationStartup, "spring.data.repository.proxy", repositoryInterface);
		ProxyFactory result = new ProxyFactory();
		result.setTarget(target);
		result.setInterfaces(repositoryInterface, Repository.class, TransactionalProxy.class);

 

정리

Spring Data JPA의 마법 정체:

  • 우리가 정의한 인터페이스를 Spring이 감지
  • 런타임에 다이나믹 프록시를 생성
  • 실제 구현체 (SimpleJpaRepository) 를 프록시로 감싸서 제공
  • 메서드 호출 시 프록시가 가로채서 실제 구현체로 위임

 

이 모든 것이 다이나믹 프록시 패턴과 리플렉션을 통해 이루어진다.

다음 섹션에서는 프록시 패턴의 기본 개념 부터 차근 차근 살펴보자.

 


프록시 패턴

다이나믹 프록시 살펴보기 전에  프록시 패턴을 우선 이해해야한다.

 

프록시란?

프록시는 직역하면 "대리인"이라는 뜻이있다.

 

프로그래밍에서의 프록시 :

  • 프록시와 리얼 서브젝트가 공유하는 인터페이스가 있고 (= 프록시와 리얼 서브젝트 모두 같은 인터페이스를 구현하고 있음)
  • 클라이언트는 해당 인터페이스 타입으로 프록시를 사용
  • 클라이언트는 프록시(=비서 , 접근 제어 역할도 가능)를 거쳐서 리얼 서브젝트를 사용하기 때문에
  • 프록시는 리얼서브젝트에 대한 접근을 관리하거나 부가기능을 제공

핵심

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

 

 

 


실습 : 책 대여 서비스에 로깅 추가하기 

책을 빌린다고 가정해보자.

여기에 추가적인 로깅을 하려면 어떻게 해야할까?

@Service
public class DefaultBookService implements BookService {

    @Autowired
    BookRepository bookRepository;

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

 

@SpringBootTest
class BookServiceTest {

    @Autowired
    private BookService bookService;

    @Test
    public void di() {
        Book book = new Book();
        book.setTitle("spring");
        bookService.rentBook(book);
    }
}

 

실행 결과

rent: spring

 

실행결과 위와 같은 결과가 출력된다.

여기에 부가적인 로깅을 더하고 싶다면

 

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

 

방법 1 : 직접 수정 (비추천)
기존 로직에 앞뒤로 넣어주어도 되지만 DefaultBookService 를 손대지 않고, 앞뒤로 메시지를 넣어주는 방식이 프록시 패턴으로 가능하다.

 

방법 2: 프록시 패턴 (추천)

DefaultBookService 를 손대지 않고, 프록시를 사용해 앞뒤로 메시지를 넣어 준다.

 

구조:

  • Subject :  BookService (인터페이스)
  • Real Subject:  DefaultBookService 가 리얼 서브젝트 (실제 비즈니스 로직)
  • Proxy : BookServiceProxy (부가 기능 담당)
class BookServiceTest {


     BookService defaultBookService = new DefaultBookService();
     BookService bookService = new BookServiceProxy(defaultBookService);

    @Test
    public void di() {
        Book book = new Book();
        book.setTitle("spring");
        bookService.rentBook(book);
    }
}

 

이제부터 클라이언트인 BookServiceTest  가 BookService Type(서브젝트)으로  서브젝트 타입으로 프록시를 쓴다.

프록시는 내부에서 리얼서브젝트를 호출한다.

 

실행 결과

aaaaaa
rent: spring
bbbbbb

 

리얼 서브젝트 코드 변경없이 앞뒤로 로깅 과정이 추가되었다.

 

 

프록시 패턴의 단점

  • 부가적인 기능을 추가할때 마다 프록시 클래스를 만들어야 한다. 
  • 타켓으로 위임하는 코드가 반복적일 수 있다.

이런 단점을 극복하기 위해 매번 프록시 클래스를 만드는 것이 아닌 동적으로 런타임에 생성해내는 방법,

그것을 다이나믹 프록시라 부르며 자바 리플렉션 패키지에서 제공하는 기능이 있다.

 


 

다이나믹 프록시

런타임 (애플리케이션 실행되는 도중) 에 특정 인터페이스들을 구현하는 클래스 또는 인스턴스를 만드는 기술

즉 클래스로 만들 수 있지만 우리가 지금 해야하는건 인스턴스를 만드는 것이다.

 

참고문서:

https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/proxy.html

 

Dynamic Proxy Classes

Dynamic Proxy Classes Contents Introduction Dynamic Proxy API Serialization Examples A dynamic proxy class is a class that implements a list of interfaces specified at runtime such that a method invocation through one of the interfaces on an instance of th

docs.oracle.com

다이나믹 프록시를 사용해서 어떻게 프록시 인스턴스를 만드는가를 알아보자.

  • Object Proxy.newProxyInstance(ClassLoader, Interfaces, InvocationHandler)

 

기존에 만든 BookServiceProxy 는 컴파일 타임에 이미 존재하는 프록시이다.

이제 이 프록시를 런타임에 동적으로 만들어보자

 

기본 구조

 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 {
                     return method.invoke(bookService, args);
                 }
             });

Proxy.newProxyInstance 를 이용하여 프록시를 만들어보자.

 

세 가지 필수 인자

1.ClassLoader 

왜 클래스로더를 적어주어야 한까?

 

중요한 사실:

클래스 로더가 다르면 같은 클래스도 다른 타입이다.

 

자바에서 클래스의 정체성을 "클래스 이름" 만으로 결정되지 않고, "클래스 로더 + 클래스 이름" 의 조합으로 결정된다.

 

같은 FQCN(Fully Qualified Class Name)을 가진 클래스라도 다른 ClassLoader로 로딩하면 JVM 입장에서 완전히 다른 타입으로 봅니다.

 

BookServie.class  를 읽어들인 클래스로더를 쓰면 해당 타입의 프록시를 만들 수 있다.

 

2. Interfaces (클래스 배열)

이 프록시 인스턴스가 어떤 인터페이스 타입의 구현체인지 알려줘야 한다.

인터페이스 목록을 클래스 배열로 전달한다.

 

3. InvocationHandler

프록시의 메서드가 호출될 때 그 메서드를 어떻게 처리할 것인지에 대한 로직이다..

 

  • 리턴 타입:  Object ->  타입 캐스팅 필요  
  • 두번째 인자로 BookService 타입의 프록시를 만든다고 명시를 했기 때문에 해당 타입으로 캐스팅 가능

 

실제 구현

     BookService 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 {
                     return method.invoke(bookService, args);
                 }
             });

 

invoke 에 부가적인 기능을 넣어서 구현하면 된다.

리얼 서브젝트를 new InvocationHandler 에서 들고 있어야 한다.

 

파라미터 설명:

  • method : 프록시의 호출된 메서드 레퍼런스
  • proxy : 생성한 프록시 객체
  • args: 메서드 인자

method.invoke(bookService, args)를 실행하면 리얼 서브젝트에게 작업을 위임한다.

 

 

실행결과

rent: spring

 

부가기능 추가하기

앞뒤로 부가기능을 남겨보자.

  BookService 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("before");
                     Object invoke =  method.invoke(bookService, args);
                     System.out.println("after");
                     return invoke;

                 }
             });

 

 

실행결과

before
rent: spring
after

 

 

모든 메서드에 로깅이 적용된다.

    @Test
    public void di() {
        Book book = new Book();
        book.setTitle("spring");
        bookService.rentBook(book);

        bookService.returnBook(book);

 

실행결과

before
rent: spring
after
before
returnBook = spring
after

 

메서드별 분기 처리

     BookService 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 {
                     if (method.getName().equals("rentBook")) {
                         System.out.println("before");
                         Object invoke =  method.invoke(bookService, args);
                         System.out.println("after");
                         return invoke;

                     }
                     return method.invoke(bookService, args);
                 }
             });

 


다이나믹 프록시의 장단점

장점:

  • 프록시 클래스를 매번 만드는 수고로움이 사라짐
  • 런타임에 동적으로 생성가능

단점:

  • invaocation핸들러가 유연하지 않다.
  • 여러가지 부가기능을 제공해야 한다면 코드가 계속 커질 수 있다.

그래서 Spring AOP가 이 구조를 스프링이 정의하는 인터페이스로 만들었다.

이것이 바로 프록시 기반의 Spring AOP이다

 

JDK Dynamic Proxy의 제약사항

 

치명적인 한계: 인터페이스가 필수

  • 자바의 다이나믹 프록시는 클래스 기반의 프록시를 만들지 못한다.
  • 두번째 인자는 반드시 인터페이스여야 한다.
hello.diexample.springdi.DefaultBookService is not an interface
java.lang.IllegalArgumentException: hello.diexample.springdi.DefaultBookService is not an interface

 

클래스만 있을 경우 다이나믹 프록시를 생성하지 못한다.


클래스 프록시가 필요하다면? (인터페이스가 없는 경우)

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

 

CGlib 

https://github.com/cglib/cglib/wiki

스프링, 하이버네이트가 사용하는 라이브러리

버전 호환성이 좋치 않아서 서로 다른 라이브러리 내부에 내장된 형태로 제공되기도

한다.

 

CGlib  사용 예시


class BookServiceTest {

    // CGLIB 프록시가 호출될 때 실행될 핸들러(메서드 가로채기 로직)
    MethodInterceptor handler = new MethodInterceptor() {
        // 실제 호출할 원본 객체 (Target)
        BookService bookService = new DefaultBookService();

        @Override
        public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
           // rentBook() 메서드만 proxy 로직 추가 (전/후 처리)
            if (method.getName().equals("rentBook")) {
               System.out.println("aaaa");
               Object invoke =  method.invoke(bookService, args);
               System.out.println("bbbb");
               return invoke;
           }
            // rentBook이 아닌 메서드는 그냥 호출
           return method.invoke(bookService, args);
        }
    };

    // CGLIB 을 사용해  BookService 타입의 프록시 객체 생성
    // -> 인터페이스가 아닌 "클래스 자체"를 상속해서 프록시 생성 가능  (JDK Proxy와 다른 점)
    BookService bookService = (BookService) Enhancer.create(BookService.class, handler);


    @Test
    public void di() {
        Book book = new Book();
        book.setTitle("spring");
        //  rentBook 은 프록시 로직을 타고 실행됨
        bookService.rentBook(book);

        // returnBook 은 전/후 로직 없이 바로 원본 호출
        bookService.returnBook(book);
    }
}

 

동작 방식

 

  • 메서드는 리얼 서브젝트로 호출하며 메서드에서 넘어오는 args 인자값을 넘겨줌
  • 각각의 프록시 객체의 메서드를 호출할 때마다 어떤 일을 해야 하는지 알려주는 핸들러를 넘겨줘야 함
  • BookService를 상속받아서 만들기 때문에 BookService로 타입 캐스팅 가능

 

    BookService bookService = (BookService) Enhancer.create(BookService.class, handler);

 

ByteBuddy

https://bytebuddy.net/#/

바이트 코드 조작 아니라 런타임(다이나믹) 프록시를 만들 때도 사용할 수 있다.

  • 서브 클래스를 만드는 방법의 단점
    • 상속을 사용하지 못하는 경우 프록시를 만들 수 없다.
      • Private 생성자만 있는 경우
        • 상속을 할 경우 하위 클래스에서 부모의 생성자를 호출하기 때문이다.
      • Final 클래스인 경우
        • 상속 자체가 불가능

권장 사항:인터페이스가 있을 때는 인터페이스의 프록시를 만들어 사용할 것

ByteBuddy 사용 예시

package hello.diexample.springdi;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.implementation.InvocationHandlerAdapter;
import org.junit.jupiter.api.Test;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

import static net.bytebuddy.matcher.ElementMatchers.named;


class BookServiceTest {



    @Test
    public void di() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {

        Class<? extends DefaultBookService> proxyClass = new ByteBuddy().subclass(DefaultBookService.class)
                .method(named("returnBook")).intercept(InvocationHandlerAdapter.of(new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        System.out.println("aaaa");
                        Object invoke = method.invoke(new DefaultBookService(), args);
                        System.out.println("ccc");
                        return invoke;
                    }
                }))
                .make().load(DefaultBookService.class.getClassLoader()).getLoaded();

        DefaultBookService bookService = proxyClass.getConstructor(null).newInstance();

        Book book = new Book();
        book.setTitle("spring");
        //  rentBook 은 프록시 로직을 타고 실행됨
        bookService.rentBook(book);

        // returnBook 은 전/후 로직 없이 바로 원본 호출
        bookService.returnBook(book);
    }
}

 

ByteBuddy는 인스턴스를 바로 만들지 않고 클래스를 만들어야한다.

Java가 제공하는 다이나믹 프록시의 프록시 클래스를 만드는 방법과 비슷하다.


 다이나믹 프록시 정리

 

정의

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

 

기술별 차이

JDKDynamic Proxy: 인터페이스 기반, 인스턴스를 직접 생성

CGLIB: 클래스 기반, 인스턴스를 직접 생성(내부적으로 클래스 생성)

ByteBuddy: 클래스를 먼저 생성한 후 인스턴스 화

 

다이나믹 프록시 사용처

  • Spring Data JPA
    인터페이스만으로 Repository 구현
  • Spring AOP
    @Transactional, @Cacheable 등의 애노테이션 기반 AOP
  • Mockito
    테스트 더블(Mock, Spy) 생성
  • Hibernate Lazy Initialization
    지연 로딩 구현

핵심 통찰

1. 프록시 선택 기준

상황 사용기술
인터페이스 있음 JDK Dynamic Proxy (권장)
클래스만 있음 CGLIB 또는 ByteBuddy
최신 기술 필요 ByteBuddy

2. Spring의 자동 선택

Spring의 ProxyFactory는 상황에 따라 자동으로 선택한다.

  • 인터페이스가 있으면 ->JDK Dynamic Proxy
  • 클래스만 있으면 -> CGLibProxy

 

마치며

Spring Data JPA 가 인터페이스만으로 동작하는 이유:

  • 런타임에 다이나믹 프록시 생성
  • 실제 구현체를 프록시로 감싸서 제공
  • 메서드 호출 시 프록시가 가로채서 실제 구현체로 위임

이 모든 것이 다이나믹 프록시 패턴과 리플렉션을 통해 이뤄진다.

다이나믹 프록시는 단순히 내부 기술이 아니라, 객체 지향 설계 원칙을 실현하는 핵심 메커니즘 이다.

 

  • 횡단 관심사 분리
  • 단일 책임 원칙(SRP)
  • 개방-폐쇄 원칙(OCP)

 

'JAVA' 카테고리의 다른 글

람다 활용  (0) 2025.10.20
외부 연동 방식 변경과 어댑터 패턴 적용  (3) 2025.08.03
5-1 단일 책임 원칙  (1) 2025.05.29
동시성 문제와 해결 방안  (0) 2025.04.09
Java 예외 처리, 제대로 알고 쓰자  (1) 2025.03.26