프로그래밍 리플렉션, 런타임에 타입을 다루는 기능과 성능 대가
프로그래밍 리플렉션은 실행 중에 타입·메서드·필드를 이름으로 다루는 기능이다. 자바·C#·파이썬·Go의 진입점과 직렬화·의존성 주입 같은 실무 쓰임, 성능 대가와 대안까지 개발자 눈높이로 짚었다.
프로그래밍 리플렉션(reflection)은 프로그램이 실행 중에 자기 자신의 구조, 즉 타입과 필드와 메서드를 들여다보고 필요하면 그 이름을 문자열로 지정해 호출하거나 값을 바꾸는 기능이다. 무엇을 부를지 컴파일 시점에 못 박아 두지 않고 런타임에 정한다는 점이 핵심이다. Oracle 자바 공식 튜토리얼, Go 공식 reflect 패키지 문서, 마이크로소프트 .NET 공식 문서는 모두 리플렉션을 로드된 클래스나 타입의 정보를 실행 중에 얻고 다루는 수단으로 설명한다.
이 글은 자바, C#, 파이썬, Go 네 언어의 리플렉션을 개발자 눈높이에서 다룬다. 각 언어 API의 모든 메서드를 나열하기보다 리플렉션이 무엇이고 어디에 쓰이며 어떤 대가를 치르는지에 초점을 둔다. 특정 라이브러리의 최신 버전 사용법이나 언어 문법 전반은 범위 밖이다.
리플렉션의 정의와 인트로스펙션과의 경계
흔히 인트로스펙션(introspection)과 리플렉션을 나눠서 본다. 인트로스펙션은 이 객체에 어떤 필드가 있는지, 이 메서드의 매개변수 타입이 무엇인지를 들여다보기만 하는 수동적 조회다. 리플렉션은 여기서 한 걸음 더 나가, 실행 중에 값을 바꾸고 메서드를 호출하는 능동적 조작까지 포함한다. 파이썬 공식 문서를 예로 들면 hasattr()로 속성이 있는지 확인하는 쪽은 인트로스펙션에 가깝고, setattr()로 속성을 새로 넣는 쪽은 리플렉션이다.
정적 타입 언어에서 이 구분이 특히 크게 다가온다. 자바나 C#은 보통 컴파일 시점에 어떤 메서드를 부를지 확정하는데, 리플렉션은 그 결정을 문자열 이름을 근거로 런타임까지 미룬다. 덕분에 컴파일할 때는 존재조차 몰랐던 클래스를 나중에 이름만으로 불러 쓸 수 있다.

리플렉션은 실무에서 어디에 쓰이나
직접 리플렉션 코드를 짜는 일은 드물어도, 우리가 매일 쓰는 프레임워크 상당수가 안에서 리플렉션에 기대어 돌아간다. 대표적인 자리가 직렬화, 의존성 주입, 테스트 실행이다.
Oracle 자바 공식 튜토리얼은 디버거와 인터프리터, 객체 검사기, 클래스 브라우저, 그리고 객체 직렬화와 자바빈즈 같은 서비스가 java.lang.reflect 패키지를 활용한다고 설명한다. 실제로 잭슨(Jackson) 같은 JSON 라이브러리는 객체의 필드를 리플렉션으로 읽어 JSON 키에 대응시키고, 스프링(Spring) 같은 의존성 주입 프레임워크는 애너테이션이 붙은 클래스를 스캔해 인스턴스를 만들고 서로 연결한다. JUnit은 @Test가 달린 메서드를 리플렉션으로 찾아 실행한다.
여기에 공통점이 하나 보인다. 라이브러리를 만드는 쪽은 사용자가 어떤 클래스를 넘길지 미리 알 수 없다. 그 모르는 타입을 런타임에 열어 보는 창구가 리플렉션이다. 반대로 애플리케이션 코드를 짜는 입장에서는 이 창구를 직접 열기보다 이미 잘 다듬어진 프레임워크에 맡기는 편이 대체로 낫다.
언어별 리플렉션 진입점 비교
언어마다 리플렉션을 시작하는 출발점이 다르다. 자바는 객체의 getClass()로 얻는 Class 객체, C#은 GetType()으로 얻는 Type 객체에서 출발한다. 파이썬은 객체와 모듈에 곧바로 getattr()을 쓰고, Go는 interface{}에 담긴 값을 reflect.TypeOf와 reflect.ValueOf로 푼다.
| 언어 | 진입점 | 대표 API |
|---|---|---|
| 자바 | Class 객체 | java.lang.reflect (Method.invoke, Field.get) |
| C# | Type 객체 | System.Reflection (GetType, GetMethod) |
| 파이썬 | 객체·모듈 | getattr, setattr, inspect |
| Go | interface{} | reflect.TypeOf, reflect.ValueOf |
마이크로소프트 공식 문서에 따르면 C#에서는 GetType()으로 변수의 타입을 얻고, Type.GetType("System.IO.BinaryReader")처럼 이름 문자열로도 타입 객체를 얻는다. Go 공식 reflect 패키지 문서는 reflect.TypeOf가 값의 동적 타입을, reflect.ValueOf가 런타임 데이터를 담은 Value를 반환한다고 설명한다. 자바에서 메서드 이름을 문자열로 지정해 호출하는 코드는 이런 모양이다.
Method m = obj.getClass().getMethod("greet", String.class);
Object result = m.invoke(obj, "world");파이썬이라면 같은 종류의 일을 훨씬 짧게 쓴다.
value = getattr(obj, "name", None) # 없으면 None
setattr(obj, "name", "새 값") # 런타임에 속성 설정두 예제 모두 greet나 name이 문자열이라는 점을 눈여겨보자. 이 문자열은 오타가 나도 컴파일러가 잡아 주지 못하고 실행 중에야 터진다. 리플렉션이 편한 만큼 조심스러운 이유가 여기에 있다.
리플렉션의 성능 대가는 얼마나 클까
정확한 배수는 언어와 버전, 측정 상황에 따라 다르지만 방향은 뚜렷하다. 리플렉션 연산은 같은 일을 하는 일반 코드보다 느리다. Oracle 자바 공식 문서는 리플렉션이 동적으로 해석되는 타입을 다루기 때문에 일부 JVM 최적화가 적용되지 못하고, 그래서 리플렉션 연산이 비리플렉션 코드보다 느리므로 성능이 민감한 애플리케이션에서 자주 호출되는 구간에서는 피하는 편이 좋다고 안내한다. 같은 문서는 Field 접근과 Method.invoke(), Constructor.newInstance() 같은 연산이 과거 릴리스보다 빨라지도록 다시 작성됐다는 점도 함께 밝힌다.
실무 감각으로 옮기면 이렇다. 앱을 켤 때 설정을 한 번 읽거나 프레임워크가 초기화하며 쓰는 정도의 리플렉션은 거의 문제되지 않는다. 반대로 초당 수만 번 도는 루프 안쪽에서 매번 메서드를 리플렉션으로 찾아 호출하면 병목이 될 수 있다. 이럴 때는 한 번 찾아낸 Method 객체를 캐시해 두고 재사용하는 것만으로도 반복 조회 비용을 크게 줄인다.
리플렉션을 쓸 때 지켜야 할 선
편리함에는 청구서가 따라온다. 리플렉션의 대가는 성능만이 아니다.
첫째, 타입 안전성이 사라진다. 앞서 본 것처럼 메서드나 필드를 문자열로 지정하면 컴파일러의 검사망을 그대로 빠져나간다. 이름을 리팩터링할 때 IDE가 문자열 안의 참조까지 따라 바꿔 주지 못하는 경우가 많아, 조용히 깨진 코드가 런타임에 가서야 드러난다.
둘째, 캡슐화를 뚫는다. 자바의 setAccessible(true)처럼 리플렉션은 private 멤버에도 손을 뻗을 수 있다. 외부 라이브러리가 감춰 둔 내부를 억지로 건드리면, 그쪽이 버전을 올리며 구현을 바꾸는 순간 무너진다. 자바는 이 문제를 의식해 자바 9의 모듈 시스템 이후로 다른 모듈 내부를 향한 깊은 리플렉션을 기본적으로 제한하고, 정말 필요하면 --add-opens 같은 옵션을 명시하도록 했다.
셋째, 대안을 먼저 따져 본다. 런타임 리플렉션 대신 컴파일 타임에 코드를 생성하는 길이 있다. 자바의 애너테이션 처리(annotation processing)나 C#의 소스 제너레이터(source generator)는 빌드 시점에 필요한 코드를 미리 찍어 내므로, 런타임 비용과 타입 안전성 문제를 함께 던다. 프로그램이 실행 중에 자기 내부를 들여다보는 리플렉션과 달리, 완성된 바이너리를 밖에서 정적으로 뜯어보는 작업은 결이 또 다른데, 그 반대편 감각은 x86 어셈블리를 리버싱으로 읽어 내려가는 과정에서 확인할 수 있다.
정리하면 리플렉션은 이름을 런타임까지 미룰 수 있는 강력한 도구지만, 성능과 타입 안전성과 캡슐화를 담보로 잡는다. 프레임워크를 만들거나 진짜로 동적인 요구가 있을 때 꺼내 쓰고, 컴파일 시점에 확정할 수 있는 일이라면 그쪽을 먼저 택하는 편이 오래 가는 코드에 가깝다.





