공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능0/10 완료
  • 01제네릭과 와일드카드
  • 02멀티스레드와 동기화
  • 03람다식과 함수형 인터페이스
  • 04Stream API와 병렬 처리
  • 05Optional로 NPE 방지
  • 06메서드 활용 패턴 (고급)
  • 07Java 21 모던 문법
  • 08어노테이션·리플렉션·동적 프록시
  • 09CompletableFuture 심화와 가상 스레드 실전
  • 10JVM 메모리·GC·OOM 진단
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 고급 › 08 / 10

어노테이션·리플렉션·동적 프록시

Spring 과 MyBatis 가 움직이는 원리
섹션 7진행 0 / 10
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 어노테이션은 "표식" 일 뿐입니다

어노테이션 선언은 @interface 로 합니다. 메서드처럼 생긴 것은 속성이고, default 로 기본값을 둡니다.

java
@Retention(RetentionPolicy.RUNTIME)   // 실행 중에도 남긴다
@Target(ElementType.METHOD)           // 메서드에만 붙일 수 있다
public @interface Transactional {
    boolean readOnly() default false;
}

@Retention 이 핵심입니다. 기본값은 CLASS 로, 컴파일 결과에는 남지만 실행 중에 읽을 수 없습니다. 프레임워크가 읽어야 하는 어노테이션은 반드시 RUNTIME 이어야 합니다.

유지 정책 남는 곳 예
SOURCE 소스에만 @Override, @SuppressWarnings
CLASS .class 까지 (기본값) 바이트코드 분석 도구용
RUNTIME 실행 중 리플렉션 가능 @Transactional, @Select

2.2 리플렉션: 실행 중에 클래스를 읽고 조작합니다

모든 클래스는 실행 중에 Class 객체 하나를 갖습니다. 이 객체에서 필드·메서드·생성자·어노테이션을 꺼낼 수 있고, 꺼낸 것으로 값을 읽거나 호출할 수 있습니다.

얻는 방법 코드 쓰는 곳
리터럴 Member.class 타입을 아는 경우
인스턴스 obj.getClass() 실제 타입 확인
문자열 Class.forName("Foo") XML 설정, 컴포넌트 스캔

Class.forName 이 프레임워크의 출발점입니다. MyBatis XML 의 type="com.x.Member", Spring 의 컴포넌트 스캔 모두 문자열로 클래스를 찾습니다.

자주 쓰는 조회 메서드는 getDeclaredXxx 와 getXxx 두 벌입니다. 차이를 알아야 원하는 것을 찾습니다.

메서드 범위 접근 제한자
getDeclaredFields() 이 클래스에 선언된 것만 private 포함
getFields() 부모까지 포함 public 만
getDeclaredMethods() 이 클래스에 선언된 것만 private 포함
getMethods() 부모·인터페이스 포함 public 만

private 멤버는 찾을 수는 있지만 읽으려면 setAccessible(true) 가 필요합니다. Spring 이 private 필드에 @Autowired 주입을 할 수 있는 이유가 바로 이 호출입니다.

메서드를 찾을 때는 이름과 인자 타입을 정확히 맞춰야 합니다. find(long) 은 getMethod("find", long.class) 이고, Long.class 로 찾으면 NoSuchMethodException 입니다.

2.3 Method.invoke 는 예외를 한 번 감쌉니다

method.invoke(target, args) 로 호출한 메서드가 예외를 던지면, 호출자는 원본이 아니라 InvocationTargetException 을 받습니다. 원본은 getCause() 안에 있습니다. 프록시를 만들 때 이것을 풀어 주지 않으면 서비스의 IllegalStateException 이 엉뚱한 예외로 바뀝니다.

2.4 동적 프록시: 인터페이스 호출을 가로챕니다

Proxy.newProxyInstance 는 주어진 인터페이스를 구현한 클래스를 실행 중에 만들고, 그 인스턴스를 돌려줍니다. 어떤 메서드를 호출하든 전부 InvocationHandler 하나로 들어옵니다.

java
List<String> logged = (List<String>) Proxy.newProxyInstance(
        loader, new Class<?>[]{List.class},
        (proxy, method, args) -> {
            System.out.println("호출: " + method.getName());
            return method.invoke(target, args);   // 진짜 객체에 위임
        });

핸들러 안에서 "앞뒤에 무엇을 할지" 를 정하는 것이 전부입니다. 로그·시간 측정·트랜잭션·캐시·권한 검사가 모두 이 자리에 들어갑니다. Spring AOP 의 어드바이스가 곧 이 핸들러입니다.

프록시 클래스 이름은 jdk.proxy2.$Proxy4 처럼 생성됩니다. 디버거에서 $Proxy 가 보이면 "지금 프록시를 거치고 있다" 는 뜻입니다.

2.5 JDK 프록시의 한계와 CGLIB

JDK 동적 프록시는 인터페이스만 됩니다. 클래스를 넘기면 is not an interface 예외입니다. 인터페이스가 없는 클래스에 @Transactional 을 붙이면 Spring 은 CGLIB 로 그 클래스의 하위 클래스를 만들어 같은 일을 합니다. Spring Boot 는 기본이 CGLIB 방식입니다.

방식 대상 제약
JDK Proxy 인터페이스 인터페이스 메서드만 가로챔
CGLIB 클래스 상속 final 클래스·메서드 불가

어느 쪽이든 원리는 같습니다. 호출자와 실제 객체 사이에 한 겹이 끼고, 그 겹이 어노테이션을 읽고 행동합니다.

2.6 트랜잭션 프록시가 하는 일

text
호출자 ──▶ 프록시 ──▶ 실제 서비스
            │  @Transactional 있음?
            │  ├─ 없음: 그냥 위임
            │  └─ 있음: BEGIN → 위임 → COMMIT
            │            예외면 ROLLBACK 후 예외 재전달

여기서 내부 호출 함정이 나옵니다. 서비스 안에서 this.transfer() 를 부르면 화살표가 프록시를 거치지 않고 실제 객체 안에서 바로 이동합니다. 어노테이션을 읽을 주체가 없으니 트랜잭션도 없습니다.

2.7 매퍼 프록시가 하는 일

MyBatis 의 MemberMapper 는 인터페이스만 있습니다. @MapperScan 이 인터페이스를 찾아 프록시를 만들고, 호출이 오면 네 단계를 밟습니다.

  1. 메서드의 @Select/@Update 또는 XML 에서 SQL 을 꺼냅니다.
  2. #{id} 를 ? 로 바꾸고, @Param 이름으로 인자를 순서대로 모읍니다.
  3. PreparedStatement 로 실행합니다.
  4. 반환 타입을 보고 단건·List·int 로 변환합니다. 행을 객체로 바꾸는 것이 resultMap 입니다.

@Param 이 필요한 이유도 리플렉션에 있습니다. 컴파일 결과에는 기본적으로 인자 이름이 없어 arg0 으로 보입니다. -parameters 옵션으로 컴파일하면 이름이 남고, Spring Boot 는 이 옵션을 켭니다.

핵심 원리
  • 2.1 어노테이션은 "표식" 일 뿐입니다
  • 2.2 리플렉션: 실행 중에 클래스를 읽고 조작합니다
  • 2.3 Method.invoke 는 예외를 한 번 감쌉니다
  • 2.4 동적 프록시: 인터페이스 호출을 가로챕니다
  • 2.5 JDK 프록시의 한계와 CGLIB
  • 2.6 트랜잭션 프록시가 하는 일
  • 2.7 매퍼 프록시가 하는 일
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제