실행 중에 이름만으로 클래스를 찾아 객체를 만들고 메서드를 부르는 기능.
Class<?> c = Class.forName("com.shop.OrderService");
Object o = c.getDeclaredConstructor().newInstance();
Method m = c.getDeclaredMethod("save", Order.class);
m.setAccessible(true); // private 도 뚫는다
m.invoke(o, order);
컴파일 시점에 그 클래스를 몰라도 된다. 클래스 정보가 메타스페이스에 그대로 남아 있기 때문에 가능한 일이다.
프레임워크가 우리 코드를 다루는 원리
- JPA 가 엔티티의
private필드에 값을 채운다 - Jackson 이 JSON 키를 보고 세터·필드를 찾는다
- JUnit 이
@Test붙은 메서드를 찾아 실행한다 - 스프링이
@Autowired필드에 빈을 꽂는다
전부 "이름으로 찾아 쓴다" 는 같은 동작이다. 우리가 프레임워크를 부르는 게 아니라 프레임워크가 우리를 부르는 구조가 이것으로 만들어진다.
대가
- 느리다 — 접근 검사와 인자 박싱이 붙는다. 다만 JIT 가 인라인 캐시를 만들면 반복 호출은 상당히 회복되므로, "리플렉션은 무조건 느리다" 는 과장이다
- 컴파일러 검사를 잃는다 — 메서드 이름 오타가 컴파일이 아니라 런타임에
NoSuchMethodException으로 터진다. 리팩터링 도구도 문자열은 못 따라간다 - 캡슐화를 뚫는다 —
setAccessible(true)로private에 접근한다
JPMS 가 이 통로를 통제한다
module com.shop.order {
exports com.shop.order.api; // 일반 접근 허용
opens com.shop.order.entity; // 리플렉션만 허용
}
exports는 컴파일·실행 모두에서 쓸 수 있게 연다opens는 리플렉션 전용이다. JPA·Jackson 이 필요로 하는 것이 이쪽이다
Java 17 부터 JDK 내부에 대한 강한 캡슐화가 기본이라, 낡은 라이브러리가 여기서 InaccessibleObjectException 으로 깨진다. --add-opens 로 임시로 여는 것이 마이그레이션에서 가장 자주 만나는 조치다.
줄어드는 흐름도 있다
네이티브 이미지(GraalVM)나 AOT 는 실행 전에 무엇이 쓰일지 알아야 하므로 런타임 탐색과 상성이 나쁘다. 그래서 최근 프레임워크는 리플렉션 대신 빌드 시점 코드 생성으로 옮겨 가고 있다. 같은 일을 하되 결정 시점을 앞당기는 것이다.
면접 함정
- ❌ "리플렉션은 항상 느려서 쓰면 안 된다" → 반복 호출은 JIT 가 상당히 회복시킨다. 문제는 속도보다 타입 안전성 상실이다.
- ❌ "public 이면 리플렉션으로 다 접근된다" → 모듈이
opens하지 않으면 막힌다.