1. 보안: 리플렉션은 왜 위험한가?
리플렉션은 프로그램의 ‘자물쇠 따개’와 같습니다: 일반 코드가 접근하지 못해야 하는 곳에도 들어갈 수 있게 해 줍니다. 예를 들어, 리플렉션을 사용하면 private 필드를 읽고 수정하고, private 메서드를 호출하며, 심지어 final 필드의 값까지 바꿀 수 있습니다(그래요, 이런 요령이 가능하긴 하지만 항상 부작용 없이 되지는 않습니다).
예: 캡슐화 우회
import java.lang.reflect.Field;
public class Secret {
private String secret = "여기 비밀이 있어!";
public String getSecret() {
return secret;
}
}
public class ReflectionDemo {
public static void main(String[] args) throws Exception {
Secret s = new Secret();
Field field = Secret.class.getDeclaredField("secret");
field.setAccessible(true); // "문"을 연다
field.set(s, "뚫렸다!");
System.out.println(s.getSecret()); // 뚫렸다!
}
}
보통은 private 필드가 보호되지만, setAccessible(true)를 사용한 리플렉션은 이 보호를 깨뜨립니다. 이는 초능력이자 동시에 막대한 책임을 요구합니다.
SecurityManager와 제약
과거에는 Java에 SecurityManager라는 메커니즘이 있어(예: 애플릿이나 서버 애플리케이션에서) 리플렉션 사용을 제한할 수 있었습니다. 하지만 Java 17에서 SecurityManager는 deprecated for removal로 표시되었고, Java 21에서는 플랫폼에서 완전히 제거되었습니다.
현대 JVM에서는 보안이 다르게 구현됩니다: 모듈 시스템(Java 9+)과 내부 클래스에 대한 엄격한 접근 제한을 통해서입니다.
취약점 예: final 필드 변경
import java.lang.reflect.Field;
public class FinalDemo {
private final int number = 42;
public static void main(String[] args) throws Exception {
FinalDemo obj = new FinalDemo();
Field f = FinalDemo.class.getDeclaredField("number");
f.setAccessible(true);
f.set(obj, 99);
System.out.println(obj.number); // 42 (!)
System.out.println(f.get(obj)); // 99
}
}
필드 number의 값은 실제로 항상 ‘의도한 대로’ 바뀌지 않습니다 — 컴파일러와 JVM은 final 필드를 최적화할 수 있어 결과가... 예상 밖일 수 있습니다! 이는 리플렉션이 마법 지팡이가 아니라, 때로는 통하고 때로는 통하지 않는 ‘쇠지렛대’에 가깝다는 것을 다시 한 번 보여 줍니다.
2. 리플렉션의 제약
성능 저하
메서드 호출과 필드 접근을 리플렉션으로 수행하면 일반 호출보다 느립니다. JVM은 이런 호출을 일반 메서드 호출이나 필드 접근만큼 잘 최적화할 수 없습니다. 만약 큰 루프나 핫 패스에서 리플렉션으로 메서드를 호출한다면 — 성능 저하를覚悟해야 합니다.
public class PerfDemo {
public void sayHello() {}
public static void main(String[] args) throws Exception {
PerfDemo obj = new PerfDemo();
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
obj.sayHello();
}
long direct = System.nanoTime() - start;
var method = PerfDemo.class.getMethod("sayHello");
start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
method.invoke(obj);
}
long reflect = System.nanoTime() - start;
System.out.printf("일반 호출: %d 마이크로초\n", direct / 1000);
System.out.printf("리플렉션 호출: %d 마이크로초\n", reflect / 1000);
}
}
결과: 리플렉션은 보통 10–100배 느립니다!
타입 안전성 손실
리플렉션은 Object 타입으로 객체를 다루며 수동 캐스팅을 요구합니다. 오류(예: 잘못된 인수 타입)는 컴파일 단계가 아니라 실행 중에야 드러납니다. 그만큼 ‘예상치 못한 상황’과 찾기 어려운 버그의 위험이 커집니다.
예외와 checked 오류
리플렉션은 NoSuchFieldException, IllegalAccessException, InvocationTargetException 등 예외를 잘 던집니다. 이를 처리하지 않으면 프로그램이 그대로 종료됩니다.
모듈 시스템의 제약
Java에 모듈 시스템이 도입되면서 내부 클래스와 private 멤버에 대한 접근이 제한되었습니다. 다른 모듈의 클래스에서 private 필드에 접근하려 하면 InaccessibleObjectException이 발생합니다.
예
// 모듈식 애플리케이션에서:
Field f = SomeClass.class.getDeclaredField("secret");
f.setAccessible(true); // java.lang.reflect.InaccessibleObjectException!
이런 접근을 허용하려면 패키지를 명시적으로 열어야 합니다(예: JVM 매개변수 --add-opens). 하지만 이는 항상 가능하거나 안전하지는 않습니다.
3. 현대적 대안
리플렉션은 정말 어쩔 수 없을 때만 사용하는 도구입니다. 다행히 Java 언어와 생태계는 발전하고 있어, 대부분의 경우 리플렉션 없이도 해결할 수 있는 새로운 기능들이 등장하고 있습니다.
Pattern Matching (Java 16+)
Pattern Matching을 사용하면 객체의 내부를 리플렉션으로 ‘후비지’ 않고도 우아하게 검사하고 값을 꺼낼 수 있습니다.
// instanceof에 대한 패턴 매칭 예 (Java 16+)
if (obj instanceof String s) {
System.out.println("이 문자열의 길이: " + s.length());
}
Sealed classes (Java 17+)
Sealed 클래스는 상속 계층을 명시적으로 제한하므로 코드 분석이 쉬워지고, 구조를 리플렉션으로 ‘추측’할 필요가 줄어듭니다.
public sealed class Shape permits Circle, Rectangle {}
public final class Circle extends Shape {}
public final class Rectangle extends Shape {}
Record 클래스 (Java 16+)
record 클래스는 생성자, getter, equals, hashCode, toString을 자동으로 생성합니다. 덕분에 직렬화와 비교가 더 단순하고 안전해져, 종종 리플렉션이 필요하지 않습니다.
public record Point(int x, int y) {}
Annotation Processing (APT)
실행 중 리플렉션으로 애노테이션을 분석하는 대신, 컴파일 단계에서 애노테이션 프로세서(@SupportedAnnotationTypes 등)를 사용해 필요한 코드를 생성할 수 있습니다. 이 방법이 더 빠르고 안전합니다.
인터페이스, 팩터리, DI 사용
예전에 클래스 이름으로 객체를 만들기 위해 리플렉션을 사용했다면, 이제는 인터페이스, 팩터리, dependency injection 컨테이너(예: Spring)를 사용하는 것이 훨씬 낫습니다. 이렇게 하면 클래스를 ‘억지로 열어’ 보지 않고도 유연하고 확장 가능한 시스템을 만들 수 있습니다.
4. 모범 사례: 리플렉션을 안전하게 사용하는 법
- 정말 불가피한 경우에만 리플렉션을 사용하세요. 예를 들어 라이브러리, 프레임워크, 플러그인, 테스트 도구를 작성할 때.
- 적용 범위를 최소화하세요. ‘혹시 몰라서’ 모든 필드와 메서드를 setAccessible(true)로 열지 마세요.
- 리플렉션 사용을 문서화하세요. 코드를 유지보수할 사람이 어디서, 왜 이 도구를 쓰는지 알아야 합니다.
- 모든 checked 예외를 처리하세요. 무시하면 가장 안 좋은 타이밍에 버그가 터질 수 있습니다.
- final 필드, private 및 내부 클래스는 특히 조심하세요. 리플렉션으로 변경하면 애플리케이션이 불안정해질 수 있습니다.
- 모듈 시스템의 제약을 고려하세요. 애플리케이션이 모듈 환경(Java 9+)에서 동작한다면 내부 멤버 접근 시나리오를 미리 설계하세요.
- 일상적인 작업에는 리플렉션을 사용하지 마세요. 대부분은 언어의 일반적 수단(인터페이스, 팩터리, 디자인 패턴)으로 해결할 수 있습니다.
5. 실습: 모듈식 애플리케이션에서 private 필드에 접근하기
모듈식 애플리케이션에서 리플렉션으로 다른 클래스의 private 필드에 접근해 보고, 무엇이 일어나는지 확인해 봅시다.
코드 예제
// module-info.java
module my.app {}
// SomeClass.java
package my.app;
public class SomeClass {
private String secret = "모듈 비밀";
}
// Main.java
package my.app;
import java.lang.reflect.Field;
public class Main {
public static void main(String[] args) throws Exception {
SomeClass obj = new SomeClass();
Field field = SomeClass.class.getDeclaredField("secret");
field.setAccessible(true); // java.lang.reflect.InaccessibleObjectException!
System.out.println(field.get(obj));
}
}
무슨 일이 일어날까요?
Java 17+ (이상)에서는 다음과 같은 예외가 발생합니다:
Exception in thread "main" java.lang.reflect.InaccessibleObjectException:
Unable to make field private java.lang.String my.app.SomeClass.secret accessible:
module my.app does not "opens my.app" to unnamed module
어떻게 해결할 수 있을까요?
패키지를 리플렉션을 위해 명시적으로 개방합니다(예: JVM 매개변수):
--add-opens my.app/my.app=ALL-UNNAMED
혹은(더 좋은 방법!) 가능한 곳에서는 리플렉션을 사용하지 마세요.
6. 리플렉션 사용 시 흔한 실수와 위험
오류 1: 근거 없는 setAccessible(true) 사용.
private 필드 접근을 여는 것은, 냉장고에서 열쇠를 꺼내려고 자기 집을 부수는 것과 같습니다. 정말 필요하고 결과를 충분히 이해할 때만 하세요.
오류 2: checked 예외를 무시함.
리플렉션은 예외를 자주 던집니다. 이를 처리하지 않으면 애플리케이션이 갑자기 종료될 수 있습니다. “내 환경에선 잘 되는데”라고 해도 모든 사용자에게 그렇다는 보장은 없습니다.
오류 3: 리플렉션이 항상 동일하게 동작한다고 가정함.
모듈 시스템, JVM 제약, 다양한 Java 버전과 실행 옵션이 여러분의 리플렉션 코드를 갑자기 ‘망가뜨릴’ 수 있습니다.
오류 4: 일상적인 작업에 리플렉션 사용.
인터페이스, 팩터리, DI로 해결할 수 있다면 리플렉션을 쓰지 마세요. 복잡도와 성능 저하만 늘어납니다.
오류 5: 리플렉션으로 final 필드를 변경.
컴파일러와 JVM 최적화와 얽혀 예상치 못하고 잡기 어려운 버그로 이어질 수 있습니다.
GO TO FULL VERSION