CodeGym /행동 /JAVA 25 SELF /리플렉션의 보안, 제약, 그리고 대안

리플렉션의 보안, 제약, 그리고 대안

JAVA 25 SELF
레벨 62 , 레슨 2
사용 가능

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 최적화와 얽혀 예상치 못하고 잡기 어려운 버그로 이어질 수 있습니다.

1
과제
JAVA 25 SELF, 레벨 62, 레슨 2
잠금
잠긴 일기 들여다보기 시도 🔒
잠긴 일기 들여다보기 시도 🔒
1
과제
JAVA 25 SELF, 레벨 62, 레슨 2
잠금
숨겨진 숫자 변경의 마법 🎩
숨겨진 숫자 변경의 마법 🎩
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION