어노테이션 선언은 @interface 로 합니다. 메서드처럼 생긴 것은 속성이고, default 로 기본값을 둡니다.
@Retention(RetentionPolicy.RUNTIME) // 실행 중에도 남긴다
@Target(ElementType.METHOD) // 메서드에만 붙일 수 있다
public @interface Transactional {
boolean readOnly() default false;
}@Retention 이 핵심입니다. 기본값은 CLASS 로, 컴파일 결과에는 남지만 실행 중에 읽을 수 없습니다. 프레임워크가 읽어야 하는 어노테이션은 반드시 RUNTIME 이어야 합니다.
| 유지 정책 | 남는 곳 | 예 |
|---|---|---|
| SOURCE | 소스에만 | @Override, @SuppressWarnings |
| CLASS | .class 까지 (기본값) | 바이트코드 분석 도구용 |
| RUNTIME | 실행 중 리플렉션 가능 | @Transactional, @Select |
모든 클래스는 실행 중에 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 입니다.
method.invoke(target, args) 로 호출한 메서드가 예외를 던지면, 호출자는 원본이 아니라 InvocationTargetException 을 받습니다. 원본은 getCause() 안에 있습니다. 프록시를 만들 때 이것을 풀어 주지 않으면 서비스의 IllegalStateException 이 엉뚱한 예외로 바뀝니다.
Proxy.newProxyInstance 는 주어진 인터페이스를 구현한 클래스를 실행 중에 만들고, 그 인스턴스를 돌려줍니다. 어떤 메서드를 호출하든 전부 InvocationHandler 하나로 들어옵니다.
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 가 보이면 "지금 프록시를 거치고 있다" 는 뜻입니다.
JDK 동적 프록시는 인터페이스만 됩니다. 클래스를 넘기면 is not an interface 예외입니다. 인터페이스가 없는 클래스에 @Transactional 을 붙이면 Spring 은 CGLIB 로 그 클래스의 하위 클래스를 만들어 같은 일을 합니다. Spring Boot 는 기본이 CGLIB 방식입니다.
| 방식 | 대상 | 제약 |
|---|---|---|
| JDK Proxy | 인터페이스 | 인터페이스 메서드만 가로챔 |
| CGLIB | 클래스 상속 | final 클래스·메서드 불가 |
어느 쪽이든 원리는 같습니다. 호출자와 실제 객체 사이에 한 겹이 끼고, 그 겹이 어노테이션을 읽고 행동합니다.
호출자 ──▶ 프록시 ──▶ 실제 서비스
│ @Transactional 있음?
│ ├─ 없음: 그냥 위임
│ └─ 있음: BEGIN → 위임 → COMMIT
│ 예외면 ROLLBACK 후 예외 재전달여기서 내부 호출 함정이 나옵니다. 서비스 안에서 this.transfer() 를 부르면 화살표가 프록시를 거치지 않고 실제 객체 안에서 바로 이동합니다. 어노테이션을 읽을 주체가 없으니 트랜잭션도 없습니다.
MyBatis 의 MemberMapper 는 인터페이스만 있습니다. @MapperScan 이 인터페이스를 찾아 프록시를 만들고, 호출이 오면 네 단계를 밟습니다.
@Select/@Update 또는 XML 에서 SQL 을 꺼냅니다.#{id} 를 ? 로 바꾸고, @Param 이름으로 인자를 순서대로 모읍니다.PreparedStatement 로 실행합니다.@Param 이 필요한 이유도 리플렉션에 있습니다. 컴파일 결과에는 기본적으로 인자 이름이 없어 arg0 으로 보입니다. -parameters 옵션으로 컴파일하면 이름이 남고, Spring Boot 는 이 옵션을 켭니다.