리플렉션 (프로그래밍)
더 많은 작업
- Reflection; Reflective programming; 리플렉션; 반영
- 실행 중인 프로그램이 자기 자신의 구조와 동작을 들여다보고 바꾸는 능력
리플렉션(reflection, reflective programming)은 프로그램이 실행 중에 자기 자신의 구조(클래스, 메서드, 필드, 타입 정보)를 살펴보고, 그 정보를 바탕으로 객체를 만들거나 메서드를 호출하거나 동작을 바꾸는 능력이다. 컴파일할 때 이름을 몰라도 "문자열로 받은 클래스 이름으로 객체를 만들고, 이름으로 찾은 메서드를 부르는" 일이 가능해진다. 한국어로는 "반영"이라고 옮기기도 하지만 보통 리플렉션이라고 쓴다.
리플렉션 덕분에 프레임워크는 사용자가 만든 클래스를 미리 알지 못해도 다룰 수 있다. 스프링 프레임워크의 의존성 주입, Hibernate 같은 ORM 프레임워크, JSON 직렬화 라이브러리, JUnit 같은 단위 테스트 프레임워크가 모두 리플렉션으로 사용자 코드의 클래스·필드·어노테이션을 읽어 동작한다. 반면 실행 속도가 느리고, 접근 제한을 우회해 캡슐화와 보안을 무너뜨릴 수 있으며, 네이티브 AOT 컴파일과 잘 맞지 않는다는 문제가 있다.
리플렉션은 흔히 두 단계로 나눈다.
- 내성(introspection, 자기 관찰): 실행 중에 객체의 타입과 속성을 알아내는 것. 어떤 클래스인지, 어떤 메서드와 필드가 있는지, 어떤 어노테이션이 붙었는지 읽기만 한다. 이 능력만 있는 것을 타입 내성(type introspection)이라고 하며, C++의 RTTI(typeid, dynamic_cast), C#의 is, Java의 instanceof 수준이 여기에 해당한다.
- 개입(intercession): 한 걸음 더 나아가 프로그램의 동작을 실행 중에 바꾸는 것. 메서드 호출을 가로채거나, 클래스에 메서드를 추가하거나, 새 클래스를 만들어 올리는 일이다.
또 무엇을 다루느냐에 따라 구조적 리플렉션(structural reflection, 클래스·메서드 같은 프로그램 구조를 다룸)과 행위적 리플렉션(behavioral reflection, 메서드 호출·메시지 전달 같은 실행 의미를 다룸)으로 나누기도 한다.
리플렉션을 지원하는 언어는 대개 다음 기능을 제공한다.
- 코드 블록, 클래스, 메서드, 프로토콜 같은 소스 코드 구성 요소를 실행 중에 일급 객체로 찾고 바꾸기. 이런 메타데이터를 담은 객체를 미러(mirror)라고도 한다.
- 클래스나 함수의 이름 문자열을 실제 클래스·함수 참조로 바꾸거나 호출하기.
- 문자열을 소스 코드처럼 실행 중에 평가하기(eval).
- 언어의 바이트코드에 대한 새 인터프리터를 만들어 구성 요소에 새 의미를 부여하기.
리플렉션은 메타프로그래밍(metaprogramming)(프로그램을 다루는 프로그램)의 핵심 수단이다. 매크로나 템플릿처럼 컴파일 시점에 코드를 만드는 메타프로그래밍과 달리, 전통적 리플렉션은 실행 시점에 일어난다.
- 초기 컴퓨터는 어셈블리어로 프로그래밍했고, 명령을 데이터처럼 다뤄 자기 수정 코드(self-modifying code)를 쓸 수 있었으므로 본질적으로 리플렉션이 가능했다. ALGOL, COBOL, Fortran, Pascal, C 같은 컴파일 언어가 주류가 되면서 이 능력은 한동안 사라졌다.
- 1982년: 브라이언 캔트웰 스미스(Brian Cantwell Smith)가 MIT 박사 논문 "Procedural Reflection in Programming Languages"에서 절차적 언어의 계산 리플렉션 개념을 정립하고, 그 구현으로 메타순환 인터프리터를 쌓은 3-Lisp를 제시했다.[1]
- 1980년대: Smalltalk-80은 클래스와 메서드, 실행 스택까지 객체로 다루는 강한 리플렉션 환경을 제공했고, Common Lisp 객체 시스템(CLOS)의 메타객체 프로토콜(MOP)은 클래스와 메서드 호출 규칙 자체를 사용자가 재정의할 수 있게 했다.
- 1997년: Java JDK 1.1이 이너 클래스, JavaBeans, RMI와 함께 리플렉션 API(java.lang.reflect)를 도입했다.[2]
- 2000년대 초: .NET Framework가 처음부터 어셈블리 메타데이터를 읽는 System.Reflection과 실행 중 코드 생성을 위한 System.Reflection.Emit을 갖추고 나왔다.
- 2022년: JDK 18의 JEP 416이 Method, Constructor, Field의 내부 구현을 메서드 핸들 기반으로 다시 만들었다(공개 API는 그대로).[3]
- 2025년: C++ 표준 위원회가 컴파일 시점 리플렉션(P2996, ^^ 연산자와 std::meta::info)을 C++26 초안에 넣기로 의결했다.[4]
| 용도 | 리플렉션으로 하는 일 | 예 |
|---|---|---|
| 의존성 주입 | 생성자·필드의 타입과 어노테이션을 읽어 알맞은 객체를 찾아 넣음 | Spring, Guice, ASP.NET Core |
| ORM | 클래스 필드를 테이블 열에 대응시키고 값을 읽고 씀 | Hibernate/JPA, Entity Framework, Django ORM |
| 직렬화 | 객체의 필드를 훑어 JSON·XML로 바꾸고, 반대로 필드를 채움 | Jackson, Gson, System.Text.Json, encoding/json(Go) |
| 테스트 프레임워크 | @Test가 붙은 메서드를 찾아 실행, 비공개 멤버 확인, 목(mock) 객체 생성 | JUnit, NUnit, pytest, Mockito |
| 어노테이션·속성 처리 | 실행 중에 메타데이터를 읽어 동작을 정함 | 검증(@NotNull), 라우팅(@GetMapping) |
| 플러그인·동적 로딩 | 설정 파일에 적힌 클래스 이름으로 구현을 골라 올림 | JDBC 드라이버, 서비스 로더 |
| 동적 프록시·AOP | 인터페이스를 구현하는 대리 객체를 실행 중에 만들어 호출을 가로챔 | java.lang.reflect.Proxy, Spring AOP |
| 개발 도구 | 객체의 구조를 보여 주고 자동 완성·디버깅 | IDE, REPL, 디버거 |
리플렉션은 C 언어처럼 리플렉션이 없는 언어에서 별도 도구가 하던 일을 대신하기도 한다. C에서는 ASN.1 같은 직렬화 형식을 쓰려면 보조 컴파일러로 코드를 생성해야 하지만, 리플렉션이 있는 Java는 라이브러리만으로 처리한다. 또 C#과 Java에서는 리플렉션으로 멤버 접근 제한을 우회해 비공개 메서드를 호출하거나 비공개 필드에 직접 쓸 수 있는데, 테스트나 프레임워크가 이를 이용하기도 한다.
아래 예는 모두 Foo 클래스의 인스턴스를 만들고 hello 메서드를 부르는 일을, 보통 방식과 리플렉션 방식으로 비교한다.
java.lang.Class가 출발점이고, java.lang.reflect 패키지의 Method, Field, Constructor로 멤버를 다룬다.
import java.lang.reflect.Method;
class Foo {
public void hello() { System.out.println("Hello"); }
private int secret = 42;
}
public class Main {
public static void main(String[] args) throws Exception {
new Foo().hello(); // 보통 호출
Class<?> cls = Class.forName("Foo"); // 이름으로 클래스 찾기
Object foo = cls.getDeclaredConstructor().newInstance();
Method m = cls.getMethod("hello");
m.invoke(foo); // 리플렉션 호출
var f = cls.getDeclaredField("secret");
f.setAccessible(true); // private 접근 제한 해제
System.out.println(f.getInt(foo)); // 42
}
}
Java 9의 모듈 시스템 이후 setAccessible로 다른 모듈의 비공개 멤버를 여는 것이 제한되었고, JDK 17의 JEP 403부터는 JDK 내부 요소에 대한 이런 접근이 기본적으로 InaccessibleObjectException으로 실패한다. 다만 sun.misc.Unsafe가 든 jdk.unsupported 모듈은 예외로 열려 있다.[5] Unsafe는 원래 호출 클래스가 부트스트랩 클래스 로더가 아니면 예외를 내도록 막혀 있지만, 리플렉션으로 내부 필드를 꺼내 쉽게 우회할 수 있는 것으로 유명하다. Java 7부터는 java.lang.invoke의 메서드 핸들(MethodHandle)이 리플렉션보다 빠르고 JIT 최적화를 받기 쉬운 대안으로 쓰인다.
.NET은 어셈블리의 메타데이터를 System.Reflection으로 읽는다. Type, MethodInfo, PropertyInfo, 사용자 정의 특성(attribute) 조회를 제공하고, System.Reflection.Emit으로 실행 중에 IL 코드를 만들 수도 있다.
using System;
using System.Reflection;
class Foo { public void Hello() => Console.WriteLine("Hello"); }
var type = Type.GetType("Foo");
object foo = Activator.CreateInstance(type);
MethodInfo m = type.GetMethod("Hello");
m.Invoke(foo, null);
.NET의 소스 생성기(source generator)는 리플렉션이 하던 일을 컴파일 시점으로 옮기는 흐름이다.
파이썬은 모든 것이 객체라 리플렉션이 언어에 녹아 있다. type(), dir(), getattr(), setattr(), hasattr(), isinstance(), __dict__, globals()가 기본 도구이고, inspect 모듈은 모듈·클래스·함수·트레이스백·프레임 객체 같은 살아 있는 객체의 정보를 얻는 함수를 제공한다.[6]
import inspect
class Foo:
def hello(self, name="world"):
return f"Hello, {name}"
obj = globals()["Foo"]() # 이름으로 클래스 찾기
print(getattr(obj, "hello")("IT위키")) # 이름으로 메서드 호출
print(inspect.signature(Foo.hello)) # (self, name='world')
setattr(Foo, "bye", lambda self: "Bye") # 실행 중에 메서드 추가(개입)
print(obj.bye()) # Bye
실행 중에 클래스나 모듈의 속성을 바꿔 치우는 이른바 몽키 패치(monkey patching)도 리플렉션의 한 형태다.
Go의 reflect 패키지는 인터페이스 값에서 reflect.Type과 reflect.Value를 꺼낸다. 롭 파이크(Rob Pike)는 2011년 글 "The Laws of Reflection"에서 세 가지 규칙을 정리했다. 리플렉션은 인터페이스 값에서 리플렉션 객체로 가고, 리플렉션 객체에서 인터페이스 값으로 돌아오며, 리플렉션 객체를 바꾸려면 그 값이 설정 가능(settable)해야 한다.[7]
type Foo struct{ Name string `json:"name"` }
func (f Foo) Hello() { fmt.Println("Hello", f.Name) }
v := reflect.ValueOf(Foo{Name: "Go"})
v.MethodByName("Hello").Call(nil) // Hello Go
t := v.Type()
fmt.Println(t.Field(0).Tag.Get("json")) // name (구조체 태그 읽기)
encoding/json이 구조체 태그를 읽어 필드 이름을 정하는 것이 바로 이 방식이다.
- 자바스크립트: 동적 언어라 객체 속성을 문자열로 읽고 쓸 수 있고(obj[name]), ES2015는 Reflect 객체와 Proxy로 속성 조회·대입·호출을 가로채는 개입 기능을 표준화했다.
- Kotlin: Java 리플렉션을 쓰거나, kotlin-reflect의 KClass(Foo::class)로 Kotlin 고유 정보까지 다룬다.
- C++: C++26 이전에는 RTTI(typeid, dynamic_cast) 수준의 타입 내성만 있었다. C++26 리플렉션은 ^^ 연산자로 코드 요소를 std::meta::info 값으로 바꾸고 컴파일 시점(consteval)에 멤버를 나열·조작한다. 실행 시간 비용은 없지만 실행 중 리플렉션보다는 능력이 제한적이다.
- Rust: 실행 중 리플렉션은 거의 없고(std::any::Any로 타입 확인 정도), 절차적 매크로와 derive(serde의 Serialize 등)로 컴파일 시점에 코드를 만든다.
- Objective-C·Swift: Objective-C 런타임은 NSClassFromString, performSelector 같은 동적 호출을 제공한다. Swift의 Mirror는 읽기 전용이라 동적 호출은 Objective-C 런타임(@objc)을 거쳐야 한다.
- PHP: ReflectionClass, ReflectionMethod 같은 Reflection API를 제공한다.
- Ruby: Object.const_get("Foo").new.send(:hello)처럼 이름으로 클래스와 메서드를 다루며, 메타프로그래밍이 매우 자유롭다.
- Common Lisp, Smalltalk: 클래스와 메서드, 실행 스택이 모두 객체라 언어 차원에서 강한 리플렉션을 제공한다.
- C: 리플렉션이 없다. 이름과 함수 포인터를 짝지은 표를 직접 만들어 일부를 흉내 낼 뿐이다.
컴파일 언어는 소스 코드 정보를 제공하려고 런타임 시스템에 기댄다. 예를 들어 컴파일된 Objective-C 실행 파일은 모든 메서드 이름을 표로 기록해 두고, 실행 중에 함수를 만들어 낼 수 있는 Common Lisp 같은 언어는 런타임에 컴파일러나 인터프리터를 포함한다.
리플렉션 호출은 실행 중에 이름으로 멤버를 찾고, 인자를 Object 배열로 감싸고(박싱), 접근 검사를 하고, 예외를 감싸야 한다. 또 호출 대상이 정적으로 드러나지 않으므로 컴파일러와 JIT가 인라이닝, 정적 바인딩 같은 최적화를 하기 어렵다. 그래서 같은 일을 직접 호출할 때보다 대체로 느리다. JEP 416의 측정에서는 Method 객체를 static final 상수 필드에 담아 두면 새 구현이 43~57% 빨랐지만, 상수가 아닌 필드에 담은 경우는 51~77% 느려졌다.[3] 흔한 대책은 Method·Field 객체를 한 번 찾아 캐시하는 것, 메서드 핸들이나 람다 메타팩토리를 쓰는 것, 소스 생성기·어노테이션 처리기로 코드를 컴파일 시점에 만들어 리플렉션을 없애는 것이다.
리플렉션은 개발자가 의도하지 않은 제어 흐름을 만들어 보안 장치를 우회하는 데 쓰일 수 있다. 과거 Java 애플릿의 여러 취약점이 안전하지 않은 리플렉션을 이용해 원격에서 받은 코드가 샌드박스를 벗어나게 했다. 외부 입력으로 로드할 클래스나 호출할 메서드를 정하는 것은 CWE-470(Unsafe Reflection)으로 분류된 약점이다. 공격자가 예상하지 못한 클래스나 메서드를 고르게 해 접근 통제를 우회하거나 임의 코드를 실행할 수 있으므로, 리플렉션을 피하거나 사용자 입력으로 클래스를 고르지 않거나 허용 목록으로 엄격히 제한해야 한다.[8] 역직렬화 취약점도 리플렉션으로 임의 클래스를 만들어 채우는 과정에서 생기는 경우가 많다.
setAccessible 같은 기능으로 private 멤버를 건드리면 캡슐화가 깨진다. 라이브러리 내부 구현에 기대는 코드는 버전이 바뀌면 컴파일 오류 없이 실행 중에 깨진다. 메서드 이름을 문자열로 적으므로 이름을 바꾸는 리팩터링 도구가 이를 놓치고, 타입 검사도 실행 시점으로 밀린다. 코드를 읽는 사람도 어떤 메서드가 어디서 불리는지 추적하기 어렵다.
리플렉션은 "실행 중에 무엇을 부를지 정한다"는 성질 때문에, 빌드 시점에 쓰는 코드만 남기는 정적 분석이나 AOT(ahead-of-time) 컴파일과 충돌한다.
- GraalVM Native Image는 모든 클래스를 빌드 시점에 알아야 한다는 닫힌 세계 가정(closed-world assumption)을 쓴다. 리플렉션으로 접근하는 클래스·메서드·필드는 정적 분석으로 찾을 수 없으므로 reachability-metadata.json 같은 도달 가능성 메타데이터로 알려 주어야 하며, 애플리케이션을 실제로 돌려 이를 수집하는 추적 에이전트(Native Image Agent)가 있다.[9]
- .NET Native AOT는 실행 중 코드 생성(System.Reflection.Emit)과 동적 어셈블리 로딩(Assembly.LoadFile)을 지원하지 않고 트리밍을 강제한다. 트리밍으로 지워질 수 있는 리플렉션 사용에는 빌드 경고가 나온다.[10]
- Android의 R8·ProGuard 같은 코드 축소 도구도 리플렉션으로만 쓰는 클래스를 지우거나 이름을 바꿀 수 있어, keep 규칙으로 보존해야 한다.
이 때문에 최근 프레임워크(Micronaut, Quarkus, Spring AOT, .NET 소스 생성기 등)는 실행 시점 리플렉션을 줄이고 컴파일 시점 코드 생성으로 옮겨 가는 추세다.
- ↑ Brian Cantwell Smith, "Procedural Reflection in Programming Languages", MIT PhD thesis, 1982
- ↑ java.lang.reflect.Method (Since: 1.1), Java SE API 문서
- ↑ 3.0 3.1 JEP 416: Reimplement Core Reflection with Method Handles
- ↑ P2996: Reflection for C++26
- ↑ JEP 403: Strongly Encapsulate JDK Internals
- ↑ inspect: Inspect live objects, Python 문서
- ↑ Rob Pike, "The Laws of Reflection", The Go Blog, 2011
- ↑ CWE-470: Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')
- ↑ Reachability Metadata, GraalVM Native Image 문서
- ↑ Native AOT deployment overview, Microsoft Learn