여는글

안녕하세요. 개발을 하다보면, ‘중복된 코드를 분리’하는 작업을 종종하게 되는데요. 코드의 효율성을 따지다보면 ‘이것까지 묶을 수 있다고?’하는 발견을 하게 될때도 있습니다.

그러다 발견하게된 것이 바로 Java Reflection입니다.

Java Reflection

Reflection은 런타임에 프로그램의 구조(클래스, 메소드, 필드, 인터페이스 등)을 검사하고 수정할 수 있는 자바의 기능입니다. 따라서, 이 Reflection을 이용해 런타임 시점에 객체의 정보를 얻고 이를 동적으로 제어할 수 있는 것이죠. 컴파일 시점에 결정되는 정보들을 런타임에서 조작할 수 있다는 것이 주목할 점입니다.

Java Reflection API는 자바의 java.lang.reflect패키지에 포함되어 있습니다.

Class<?> clazz = Class.forName("com.example.MyClass");
Method method = clazz.getDeclaredMethod("myMethod");
method.invoke(clazz.newInstance());

// 클래스 정보 조회
Class<?> clazz = MyClass.class;
System.out.println("클래스 이름: " + clazz.getName());

// 동적 메소드 호출
Method method = clazz.getDeclaredMethod("myMethod");
method.setAccessible(true); // private 메소드 접근
method.invoke(clazz.newInstance());

자바는 일반적으로 정적 타입 언어로 알려져 있지만, 위 코드처럼 런타임 시점에 클래스의 정보를 조회하거나 동적 메소드를 호출, 객체 생성 및 접근 제어 우회가 가능합니다.

왜 Reflection이 필요한가?

Reflection이 자주 사용되는 이유는 유연성과 동적 프로그래밍을 지원하기 때문입니다. 따라서, 다음과 같은 상황들에서 유용한데요.

동적 클래스 로딩

프로그램을 작성할 때 모든 클래스와 메소드를 미리 알 수 없는 경우가 있습니다. 예를 들어, 플러그인을 만든다고 했을때, 특정 시점에 클래스의 이름만으로 동적 로딩이 필요할 수 있는데요. 여러 프레임워크에서 동적으로 클래스를 로드해서 기능을 확장하는 방식으로 설계를 많이 합니다. Spring Framework에서는 Bean을 동적으로 관리할 때 Reflection을 활용하는 것처럼 말이죠.

테스트 자동화 및 프레임워크 개발

Reflection을 이용하면 테스트 중에 접근이 어려운 비공개 메소드나 필드에 접근할 수 있습니다. Junit과 같은 테스트 프레임워크는 내부적으로 이 Reflection을 사용하여 테스트를 수행합니다.

객체 직렬화 및 역직렬화

JSON이나 XML 같은 형식의 데이터를 Java 객체로 변환하거나 그 반대의 작업을 할 때 Reflection을 사용해 필드와 메소드에 접근할 수 있습니다. 이는 많은 ORM(Object Relational Mapping)프레임워크와 직렬화 라이브러리에서 사용되는데요. 많이 사용되는 Jackson, Gson도 내부적으로 Reflection을 통해 런타임에 객체의 필드와 메소드에 접근해서 직렬화 및 역직렬화를 수행합니다.

의존성 주입

많은 의존성 주입 프레임워크에서 Reflection을 사용하여 객체 간의 관계를 설정하고 주입합니다. Spring FrameworkBeanFactoryApplicationContext내부에서 Reflection을 사용해 Bean을 동적으로 생성하고, 설정 파일이나 어노테이션 기반의 설정을 처리합니다.

Reflection 활용 예시

Hibernate

ORM(Object Relational Mapping)프레임워크인 Hibernate는 데이터베이스 테이블과 자바 객체를 매핑하는데에 Reflection을 사용합니다. 이를 통해 런타임에도 객체의 필드 정보를 동적으로 조회하고, 해당 객체를 데이터베이스 테이블의 열과 매핑하는데요. 코드를 보시면 아시는 내용일겁니다.

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "username")
    private String username;

    @Column(name = "email")
    private String email;
}

JPA를 써보셨다면, 위와 같은 어노테이션을 사용해보신적이 있을겁니다. @Column어노테이션은 경우에 따라 사용할 필요가 없긴 하지만, Hibernate는 이런식으로 Reflection을 사용해 매핑을 하는것이죠.

의존성 주입 (Dependency Injection)

앞서 잠깐 언급했듯이, Spring Framework는 런타임에 의존성 주입을 처리하기 위해 Reflection을 사용합니다.

테스트 자동화 프레임워크

테스트 프레임워크에서는 테스트 메소드를 동적으로 호출할 수 있어야 하는데요. JUnit과 같은 테스트 프레임워크는 Reflection을 통해 테스트 메소드를 실행하고, 테스트 결과를 수집하며 리포트를 생성합니다.

public class MyTest {
    @Test
    public void testMethod() {
        // 테스트 코드
    }
}

JUnit에서는 JUnit의 내부 코드에서 Reflection을 사용해 모든 @Test 어노테이션이 붙은 메소드를 동적으로 찾아내고 실행합니다.

Reflection의 단점

Reflection은 성능과 보안 측면에서 여러가지 단점이 있습니다. 특히, 대규모 애플리케이션에서는 이러한 문제들이 더 두드러질 수 있는데요. 성능 저하의 원인은 다음과 같습니다.

성능 저하의 원인

자바의 일반적인 컴파일 타임 최적화가 Reflection을 사용할 때는 적용되지 않습니다. 메소드 호출, 필드 접근, 객체 생성 등이 동적으로 이루어지기 때문에 추가적인 연산 비용이 발생하게 되는 것이죠.

런타임 메타데이터 조회

Reflection을 사용할 때 자바 런타임은 클래스, 필드, 메소드 등의 메타데이터를 동적으로 조회해야 합니다. 이는 일반적인 메소드 호출보다 더 많은 시간이 소요될 수 밖에 없습니다.

JIT(Just-In-Time) 컴파일러 최적화 미적용

자바는 JIT 컴파일러를 통해 런타임에 코드를 최적화하지만, Reflection을 사용하는 코드는 이러한 최적화 대상에서 제외되기 때문에 성능이 저하될 수 있습니다.

// 일반적인 메소드 호출
myObject.myMethod();

// Reflection을 통한 메소드 호출
Method method = myObject.getClass().getMethod("myMethod");
method.invoke(myObject);  // 성능 저하 발생

Reflection을 통한 메소드 호출은 직접적인 호출에 비해 최대 10배 이상 느릴 수 있다는 연구결과도 있습니다. 특히 대량의 메소드 호출이 필요한 상황에서는 성능 저하가 두드러질 수 있습니다.

보안 이슈

Reflection은 보안상 위험을 수반 할 수 있습니다 특히 setAccessible(true) 메소드를 사용해서 private필드나 메소드에 접근할 수 있기 때문에, 악의적인 코드가 이를 악용할 경우 시스템의 중요한 정보를 유출하거나 조작할 수 있기 때문입니다.

Reflection의 단점 보완

사실 단점을 보완하기 보다는 Reflection의 사용을 줄이는 것이 가장 좋은 방법입니다. 캐싱 로직을 통해 성능 향상을 도모해볼 수 있겠지만, 대규모 트래픽 상황에서 유의미 할지는 모르겠습니다.

캐싱(Caching)

// 캐싱을 이용한 성능 최적화
Method method = methodCache.get("myMethod");
if (method == null) {
    method = myObject.getClass().getMethod("myMethod");
    methodCache.put("myMethod", method);
}
method.invoke(myObject);

Reflection 대체

다른 API를 사용하는 방법도 있습니다. MethodHandles를 활용하는 방법인데요. MethodHandles API를 사용하여 Reflection을 대체할 수 있습니다. MethodHandles는 더 빠른 메소드 호출을 제공하며, JIT 컴파일러가 이를 최적화할 수 있도록 지원합니다.

MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle methodHandle = lookup.findVirtual(MyClass.class, "myMethod", MethodType.methodType(void.class));
methodHandle.invoke(myObject);

보안이슈 보완

그래도 Reflection을 사용하시겠다면, 특정 클래스나 패키지에 대해 사용을 제한할 수 있습니다. 자바의 Security Manager를 사용하면 되는데요. 다음과 같이 사용할 수 있습니다.

SecurityManager securityManager = new SecurityManager();
System.setSecurityManager(securityManager);

결론

Java Reflection은 자바에서 매우 유용한 도구로, 런타임에 클래스의 구조를 동적으로 조작할 수 있게 합니다. 플러그인 시스템, 테스트 자동화, 의존성 주입, ORM 프레임워크 등에서 Reflection의 유연성은 필수로 사용되는데요. 런타임에 객체나 메소드에 동적으로 접근하고 조작할 수 있기 때문에 코드의 확장성과 동적 처리가 가능해지지만, 그만큼 성능 저하와 보안 이슈가 따를 수 있습니다.

Reflection의 한계와 신중한 사용

Reflection은 강력한 도구지만, 과도하게 사용하면 성능 문제와 보안 위험이 생길 수 있습니다. 자주 호출되는 코드나 성능이 중요한 애플리케이션에서는 정적 메소드 호출을 사용하는 것이 좋습니다. 최신 자바 버전에서는 MethodHandlesLambdaMetafactory 같은 대체 기술을 사용해 성능을 최적화할 수 있습니다.

Reflection의 적절한 사용 상황

Reflection을 모든 상황에서 사용하는 것은 바람직하지 않으며, 필요할 때만 사용하는 것이 좋습니다. 특히 아래와 같은 경우에 적합합니다.

  • 플러그인 시스템에서 런타임에 동적으로 객체를 로딩할 때.
  • 테스트 자동화 프레임워크에서 테스트 메소드를 동적으로 호출할 때.
  • ORM 프레임워크에서 동적으로 데이터 매핑이 필요할 때.

결론적으로

Reflection을 사용할 때는 성능과 보안 문제를 항상 염두에 두고, 캐싱이나 보안 관리자 설정과 같은 최적화 전략을 도입해야 합니다. 이를 통해 Reflection의 장점을 최대한 활용하면서도 문제를 최소화할 수 있습니다. 실무에서는 Reflection을 꼭 필요할 때만 신중하게 사용하고, 성능과 보안을 위해 적절한 대체 기술을 사용하는 것이 중요합니다.


참고자료