Devin.KR
로그인

자바 리플렉션과 동적 프록시 - 프레임워크 동작 원리와 비용 (자바 고급 20단원)

개발자 조회 1

이 단원에서 배우는 것

14단원에서 애노테이션을 직접 정의하고 리플렉션으로 읽어 봤다. 그때는 문법 소개에 가까웠고, 그것이 실제로 무엇을 만들어 내는지는 다루지 않았다. 한편 17~19단원에서 만든 InventoryService 에는 비즈니스 로직만 들어 있었다. 그런데 실무 코드에는 로직 말고도 트랜잭션 시작/커밋, 실행 시간 로깅, 권한 확인, 캐시 조회 같은 것들이 늘 따라붙는다. 이것들을 메서드마다 손으로 넣으면 본질이 안 보인다. 스프링의 @Transactional 한 줄이 그 일을 대신해 주는데, 그 뒤에서 실제로 무슨 일이 일어나는지 모르면 "왜 이 메서드에서만 트랜잭션이 안 걸리지" 같은 문제 앞에서 손을 못 쓴다. 이번 단원은 그 밑바닥인 리플렉션과 동적 프록시를 직접 만들어 보고, 비용과 위험까지 측정한다.

  • 애노테이션 + 리플렉션으로 DB 행을 객체로 채우는 미니 매퍼를 직접 만든다
  • java.lang.reflect.Proxy 로 공통 관심사를 주입하고, 스프링 AOP의 한계가 왜 생기는지 설명한다
  • 리플렉션의 실제 비용을 측정하고, Java 17의 강한 캡슐화가 무엇을 막는지 확인한다

왜 필요한가

DB에서 주문 한 건을 읽어 객체로 만든다고 하자. 컬럼이 30개면 rs.getString("sku") 같은 줄이 30줄 나온다. 컬럼이 하나 추가될 때마다 매퍼도 고쳐야 하고, 테이블이 50개면 이 코드가 50벌 생긴다. MyBatis도 JPA도 이 일을 대신해 주는데, 그들이 하는 일은 결국 "컴파일 시점에는 알 수 없던 클래스의 필드 목록을 실행 시점에 읽어서 값을 채우는 것" 이다. 자바에서 그게 가능한 이유는 클래스 파일이 필드 이름·타입·애노테이션을 그대로 담고 있고, JVM이 그 정보를 Class 객체로 노출하기 때문이다.

이 능력이 없으면 프레임워크라는 것 자체가 성립하지 않는다. 반대로 이 능력은 컴파일러의 검증을 통째로 우회한다. 필드 이름을 문자열로 쓰므로 오타는 컴파일 에러가 아니라 런타임 예외가 되고, private 도 무력해진다. 프레임워크를 쓰는 사람으로서 이 트레이드오프를 아는 것이 이 단원의 목적이다.

문법과 예제

1. 미니 ORM 매퍼 — 애노테이션 + 리플렉션

DB 행(Map)을 받아 객체 필드를 채우는 매퍼다. 실제 MyBatis가 하는 일의 뼈대와 같다.

import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;

@Retention(RetentionPolicy.RUNTIME)     // 이게 없으면 실행 시점에 안 보인다
@Target(ElementType.FIELD)
@interface Column {
    String name();
}

class OrderRow {
    @Column(name = "order_id")  private Long id;
    @Column(name = "sku")       private String sku;
    @Column(name = "qty")       private int qty;
    private String memo;                     // 애노테이션이 없으면 무시

    @Override public String toString() {
        return "OrderRow{id=" + id + ", sku=" + sku + ", qty=" + qty + ", memo=" + memo + "}";
    }
}

public class MiniMapper {

    static <T> T map(Map<String, Object> row, Class<T> type) throws Exception {
        Constructor<T> ctor = type.getDeclaredConstructor();   // 기본 생성자
        ctor.setAccessible(true);
        T target = ctor.newInstance();

        for (Field f : type.getDeclaredFields()) {
            Column col = f.getAnnotation(Column.class);
            if (col == null) continue;
            Object value = row.get(col.name());
            if (value == null) continue;
            f.setAccessible(true);          // private 을 뚫는다
            f.set(target, value);
        }
        return target;
    }

    public static void main(String[] args) throws Exception {
        Map<String, Object> row = Map.of("order_id", 1001L, "sku", "SKU-1", "qty", 3);
        System.out.println(map(row, OrderRow.class));
    }
}
OrderRow{id=1001, sku=SKU-1, qty=3, memo=null}

여기서 짚어야 할 것이 셋이다.

  • @Retention(RUNTIME) 이 없으면 애노테이션은 클래스 파일에조차 안 남거나(SOURCE) 실행 시점에 조회되지 않아(CLASS) getAnnotationnull 을 돌려준다. 커스텀 애노테이션이 "왜 안 먹히는지" 모르겠을 때 첫 번째로 볼 곳이다.
  • getDeclaredFields()자기 클래스의 필드만 준다. 상속받은 필드까지 훑으려면 getSuperclass() 를 타고 올라가야 한다. 반대로 getFields() 는 상속 포함이지만 public 만 준다. 이 둘을 헷갈려 상위 클래스 필드가 안 채워지는 사고가 흔하다.
  • JPA 엔티티에 기본 생성자가 필수인 이유가 여기 있다. 프레임워크는 어떤 인자를 넘겨야 할지 모르므로 인자 없이 만든 다음 필드를 채운다.

2. 동적 프록시 — @Transactional 이 동작하는 원리

이제 비즈니스 코드를 한 줄도 고치지 않고 모든 메서드에 실행 시간 측정을 끼워 넣어 본다.

import java.lang.reflect.*;

interface InventoryService {
    boolean reserve(String sku, int qty);
    int stockOf(String sku);
}

class SimpleInventoryService implements InventoryService {
    private int stock = 100;
    @Override public boolean reserve(String sku, int qty) {
        if (stock < qty) return false;
        stock -= qty;
        return true;
    }
    @Override public int stockOf(String sku) { return stock; }
}

/** 공통 관심사. 비즈니스 코드는 손대지 않는다 */
class TimingHandler implements InvocationHandler {
    private final Object target;
    TimingHandler(Object target) { this.target = target; }

    @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        long start = System.nanoTime();
        try {
            return method.invoke(target, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();                     // 원래 예외를 복원한다
        } finally {
            long us = (System.nanoTime() - start) / 1_000;
            System.out.println("[TIMER] " + method.getName() + " " + us + "us");
        }
    }
}

public class ProxyDemo {
    @SuppressWarnings("unchecked")
    static <T> T wrap(T target, Class<T> iface) {
        return (T) Proxy.newProxyInstance(
                iface.getClassLoader(),
                new Class<?>[]{iface},
                new TimingHandler(target));
    }

    public static void main(String[] args) {
        InventoryService inv = wrap(new SimpleInventoryService(), InventoryService.class);
        System.out.println("예약 결과 = " + inv.reserve("SKU-1", 30));
        System.out.println("남은 재고 = " + inv.stockOf("SKU-1"));
        System.out.println("프록시 클래스 = " + inv.getClass().getName());
    }
}
[TIMER] reserve 67us
예약 결과 = true
[TIMER] stockOf 15us
남은 재고 = 70
프록시 클래스 = $Proxy0

$Proxy0 은 JVM이 실행 중에 만들어 낸 클래스다. 소스도 클래스 파일도 없다. @Transactional 은 이 invoke 앞뒤에 트랜잭션 시작과 커밋/롤백을 넣은 것에 지나지 않는다. 이 구조를 알면 아래 두 가지가 자연스럽게 이해된다.

  • JDK 동적 프록시는 인터페이스가 있어야 한다. 인터페이스 없는 클래스에 프록시를 씌우려면 바이트코드를 생성해 서브클래스를 만드는 CGLIB/ByteBuddy가 필요하고, 그래서 final 클래스나 final 메서드에는 프록시를 못 씌운다. 스프링 부트는 기본이 CGLIB 방식이다.
  • 내부 호출에는 프록시가 끼지 못한다. 프록시를 거쳐 들어온 뒤 this.otherMethod() 로 호출하면 그 호출은 프록시가 아니라 실제 객체에서 일어나므로, otherMethod 에 붙은 @Transactional 이나 @Cacheable 은 그냥 무시된다. 실무에서 가장 자주 밟는 지뢰다.

InvocationTargetException 처리도 중요하다. method.invoke 가 던지는 예외는 대상 메서드의 예외가 아니라 그것을 감싼 래퍼다. getCause() 로 벗기지 않으면, 서비스가 던진 IllegalArgumentException 이 전부 InvocationTargetException 으로 바뀌어 예외 처리 로직이 통째로 무력해진다.

3. 비용 — 얼마나 느린가

"리플렉션은 느리다"는 말은 절반만 맞다. 직접 재 보자(정밀 측정은 22단원의 JMH로 하고, 여기서는 자릿수만 본다).

import java.lang.reflect.*;

public class CostDemo {
    static class Order { private int qty = 3; int qty() { return qty; } }

    public static void main(String[] args) throws Exception {
        Order o = new Order();
        Method m = Order.class.getDeclaredMethod("qty");
        m.setAccessible(true);
        int n = 20_000_000, sink = 0;

        for (int i = 0; i < n; i++) sink += o.qty();                 // 워밍업
        for (int i = 0; i < n; i++) sink += (int) m.invoke(o);

        long t1 = System.nanoTime();
        for (int i = 0; i < n; i++) sink += o.qty();
        long direct = System.nanoTime() - t1;

        long t2 = System.nanoTime();
        for (int i = 0; i < n; i++) sink += (int) m.invoke(o);
        long refl = System.nanoTime() - t2;

        System.out.println("direct  = " + direct / 1_000_000 + "ms");
        System.out.println("reflect = " + refl / 1_000_000 + "ms");
        System.out.println("lookup  = " + measureLookup(n / 20) + "ms");  // 100만 회
    }

    static long measureLookup(int n) throws Exception {
        long t = System.nanoTime();
        for (int i = 0; i < n; i++) Order.class.getDeclaredMethod("qty");
        return (System.nanoTime() - t) / 1_000_000;
    }
}
direct  = 4ms      (2천만 회)
reflect = 30ms     (2천만 회)
lookup  = 52ms     (100만 회)

숫자를 환산하면 이렇다. 직접 호출은 호출당 0.2ns, Method.invoke 는 1.5ns 정도다. 7배 차이지만 절대값이 나노초다. DB 한 번 다녀오는 데 밀리초가 걸리는 웹 요청에서 이 차이는 보이지 않는다. 진짜 비용은 마지막 줄에 있다. getDeclaredMethod 한 번이 약 52ns로, 호출 자체보다 30배 이상 비싸다. 게다가 이 메서드는 매번 Method 객체를 새로 만들어 반환하므로 GC 부담까지 준다.

리플렉션 최적화의 핵심은 "쓰지 않는 것"이 아니라 탐색 결과를 캐시하는 것이다. Field·Method 객체는 클래스당 한 번만 찾아 Map 에 담아 두고 재사용한다. 프레임워크들이 전부 그렇게 한다.

더 빠르게 해야 한다면 MethodHandle(java.lang.invoke)이 있다. static final 필드에 담아 두면 JIT가 직접 호출 수준으로 인라인할 수 있다. 다만 API가 까다롭고, 대부분의 애플리케이션 코드에는 과한 선택이다.

4. Java 9 이후의 벽 — 강한 캡슐화

예전에는 리플렉션으로 JDK 내부까지 마음대로 건드릴 수 있었다. Java 16부터 이것이 기본 차단됐다.

Field f = ArrayList.class.getDeclaredField("elementData");
f.setAccessible(true);      // Java 17에서 예외
java.lang.reflect.InaccessibleObjectException
Unable to make field transient java.lang.Object[] java.util.ArrayList.elementData accessible:
module java.base does not "opens java.util" to unnamed module @bba30dd

구버전 라이브러리를 쓰는 프로젝트를 Java 17로 올릴 때 이 예외를 만난다. 임시 조치는 실행 옵션으로 모듈을 여는 것이다.

java --add-opens java.base/java.util=ALL-UNNAMED -jar order-service.jar

다만 이건 기한이 있는 우회로 봐야 한다. --add-opens 자체는 JDK 25까지도 정식 지원되고 경고도 나오지 않지만, 무결성을 깨는 다른 통로들은 차례로 좁아지고 있다. Java 17에서 포괄적 완화 옵션 --illegal-access 가 폐기됐고(JEP 403), JDK 24에서는 sun.misc.Unsafe 의 메모리 접근(JEP 498)과 승인 없는 네이티브 접근(JEP 472)에 런타임 경고가 붙었다. 방향은 분명하므로 라이브러리를 올리는 것이 정답이고, --add-opens 는 그때까지의 임시방편으로만 쓴다. 어떤 모듈을 열어야 하는지는 예외 메시지가 그대로 알려 준다.

실무에서 자주 틀리는 것

1. 같은 클래스 안의 메서드를 호출하면서 @Transactional 이 걸릴 거라 믿는다

앞에서 본 프록시 구조의 직접적인 귀결이다.

@Service
public class OrderService {
    public void placeAll(List<OrderRow> rows) {
        for (OrderRow r : rows) save(r);   // this.save(...) 다. 프록시를 안 거친다
    }

    @Transactional
    public void save(OrderRow r) { ... }   // 트랜잭션이 안 걸린다
}

예외도 경고도 없이 그냥 트랜잭션이 없는 상태로 돈다. 데이터가 부분 저장되고 나서야 발견된다. 해법은 (a) 트랜잭션 경계를 별도 빈으로 분리해 주입받기, (b) TransactionTemplate 을 직접 쓰기다. 자기 자신을 주입받는 편법은 순환 참조를 만들어 권하지 않는다. @Async, @Cacheable, @PreAuthorize 도 전부 같은 제약을 받는다.

2. 리플렉션 탐색을 루프 안에서 한다

앞의 측정대로 getDeclaredMethod/getDeclaredField 는 호출보다 훨씬 비싸다. 10만 건 배치의 각 행마다 필드를 다시 찾으면, 그 자체가 병목이 된다. 클래스별 Map<Class<?>, List<Field>> 캐시를 두는 것만으로 대개 수 배가 빨라진다. 다만 클래스 객체를 키로 하는 static 캐시는 클래스로더 누수를 만든다는 점도 함께 알아야 한다. 19단원의 메타스페이스 OOM이 바로 이 형태다. 애플리케이션 수명과 캐시 수명이 같으면 문제없지만, 동적으로 클래스를 로드/언로드하는 환경이라면 WeakHashMap 계열을 쓴다.

3. setAccessible(true) 를 습관적으로 쓴다

테스트에서 private 필드를 뒤져 검증하는 코드는 편해 보이지만, 필드 이름을 바꾸는 순간 테스트가 런타임에 깨진다. 컴파일러가 잡아 주지 못하는 결합이 생기는 것이다. 23단원에서 다루겠지만 private 필드를 리플렉션으로 검증해야 한다면 대개 설계가 잘못된 것이고, 관찰 가능한 동작으로 검증하도록 바꾸는 편이 낫다. 운영 코드에서는 보안 문제도 된다. 외부 입력으로 받은 클래스명을 Class.forName 에 넣고 newInstance 하는 패턴은 원격 코드 실행 취약점의 고전적인 형태다.

4. 파라미터 이름이 실행 시점에 남아 있다고 가정한다

생성자 파라미터 이름으로 값을 바인딩하는 라이브러리(잭슨, 스프링의 @ConfigurationProperties 등)를 쓰다 보면 "로컬에서는 되는데 빌드 서버에서만 arg0 으로 들어오는" 일이 생긴다. 자바는 기본적으로 파라미터 이름을 클래스 파일에 넣지 않기 때문이다. -parameters 컴파일 옵션이 필요하고, 스프링 부트 스타터 부모 POM은 이걸 켜 준다. 직접 빌드를 구성했다면(24단원) 이 옵션이 빠졌는지 확인한다. 참고로 record 는 컴포넌트 이름이 항상 보존되므로(21단원) 이 문제에서 자유롭다.

스스로 확인하기

  1. 커스텀 애노테이션 @Audit 을 만들어 서비스 메서드에 붙였는데, 리플렉션으로 getAnnotation(Audit.class) 를 하면 항상 null 이다. 가장 먼저 확인할 것은?
  2. 인터페이스 없이 public final class PaymentClient 로 선언된 스프링 빈에 @Transactional 을 붙였다. 어떤 일이 생기는가?
  3. 10만 건을 처리하는 배치에서 행마다 type.getDeclaredFields() 를 호출한다. 필드가 30개일 때 이 부분에서만 대략 얼마의 시간을 쓰게 되며, 어떻게 줄이는가?

정답

  1. @Retention(RetentionPolicy.RUNTIME) 이 붙어 있는지 확인한다. 지정하지 않으면 기본값이 CLASS 라서 클래스 파일에는 기록되지만 실행 시점에 조회할 수 없다. 그다음으로 볼 것은 @Target 이다. METHOD 가 없으면 애초에 컴파일이 안 되므로 대개는 첫 번째가 원인이다. 프록시를 거친 객체에서 애노테이션을 찾는 경우라면 프록시 클래스에는 애노테이션이 복사되지 않는다는 점도 함께 봐야 한다.
  2. 스프링 부트 기본 설정(CGLIB 프록시)에서는 final 클래스의 서브클래스를 만들 수 없으므로 기동 시점에 예외가 난다. 컨텍스트 로딩 실패라 배포 직후 바로 드러난다는 점은 그나마 다행이다. 더 위험한 변형은 클래스는 final 이 아닌데 메서드만 final 인 경우다. 이때는 프록시 생성이 성공하고 그 메서드만 오버라이드되지 않아, 예외 없이 트랜잭션만 조용히 빠진다. 해결은 final 을 떼거나 인터페이스를 도입하는 것이다.
  3. getDeclaredFields() 는 호출마다 Field 배열을 새로 복사해 돌려주므로 필드 30개 기준 대략 1~2µs 수준이다. 10만 건이면 0.1~0.2초, 여기에 매번 만들어지는 Field 객체 300만 개가 GC 부담으로 더해진다. 배치 전체가 몇 초짜리라면 무시 못 할 비중이다. 해결은 클래스당 한 번만 조회해 List<Field> 로 캐시하고(setAccessible(true) 도 그때 한 번만), 루프에서는 캐시된 목록만 순회하는 것이다.