Day 45 — 클래스 로딩과 리플렉션
목차 19
지난 시간엔 정비공이 되어 메모리 누수를 진단했어요. VisualVM 으로 Heap 을 들여다보고, 덤프를 떠서 안 죽고 쌓인 범인 객체를 찾아냈죠. 그런데 끝에 묘한 의문 하나를 남겼어요. VisualVM 은 우리 프로그램 속을 들여다보면서 NamedSubscriber 라는 클래스 이름을 어떻게 다 알아냈을까요? 그리고 클래스 정보가 산다던 Metaspace, 그 정보는 대체 누가 읽어서 거기 올려놓는 걸까요?
오늘은 그 의문 둘을 정면으로 풉니다. 첫 번째 답은 클래스 로더(class loader) 예요. 우리가 짠 코드가 실행되려면 먼저 누군가가 클래스를 메모리로 실어 날라야 하는데, 그 일을 하는 일꾼이죠. 두 번째 답은 리플렉션(reflection) 이에요. 실행 중인 프로그램이 자기 자신의 속(필드·메서드·생성자)을 들여다보고 다루는 기능이에요. VisualVM 이 클래스 이름을 알아낸 비결이 바로 이거예요.
그리고 이 둘이 만나면 재미있는 일이 벌어져요. @ 로 시작하는 어노테이션(annotation) — Day 10 부터 봐온 @Override 같은 그 표시 말이에요 — 을 우리가 직접 만들고, 실행 중에 리플렉션으로 읽어서 프로그램을 움직일 수 있게 돼요. 오늘 마지막엔 "표시만 붙여두면 알아서 값이 채워지는" 작은 장치를 직접 만들어 볼 거예요.
흐름은 이래요. 먼저 클래스가 어떻게 메모리에 올라가는지 큰 그림을 그리고, 그 일을 하는 클래스 로더 삼형제를 만나요. 그다음 리플렉션의 입구인 Class 객체를 손에 넣고, 그걸로 필드·메서드·생성자를 차례차례 들여다봐요. 끝으로 나만의 어노테이션을 만들어서, 리플렉션과 묶어 자동화 장치를 완성하면 끝이에요.
🎯 학습 목표
- 우리가 짠
.java가.class로 번역되어 클래스 로더를 거쳐 메모리에 올라가는 과정을 이해해요. - 클래스 로더의 세 계층(Bootstrap → Platform → Application)과 부모에게 먼저 묻는 위임 구조를 설명할 수 있어요.
- 리플렉션으로 Class 객체를 얻고, 필드·메서드·생성자를 실행 중에 들여다보고 다룰 수 있어요.
setAccessible(true)로 private 멤버를 열고,getDeclaredConstructor().newInstance()로 객체를 만들 수 있어요.- 커스텀 어노테이션(
@interface·@Retention·@Target)을 만들고, 리플렉션으로 읽어 동작을 자동화할 수 있어요.
Step 1: 클래스도 메모리에 올라간 "정보" 다
지금까지 우리는 객체를 new 로 만들어 쓰는 데만 집중했어요. 그런데 new Member() 가 동작하려면, 그전에 Member 라는 클래스 자체가 어딘가에 준비되어 있어야 해요. 붕어빵 틀(클래스)이 있어야 붕어빵(객체)을 찍죠. 그 "틀" 은 어디서 와서 어디에 놓이는 걸까요?
우리가 Member.java 를 작성하면, 그건 사람이 읽기 좋은 글일 뿐이에요. 자바는 이걸 javac 라는 번역기로 Member.class 라는 파일로 바꿔요. 이 .class 안에는 컴퓨터가 읽는 바이트코드(bytecode)가 담겨 있어요. 그리고 프로그램이 실행되어 Member 가 처음 필요해지는 순간, 누군가 이 .class 파일을 읽어서 메모리에 올려놓아요. 지난 시간 배운 그 Metaspace 에 바로 그 클래스 정보가 놓여요.
1) Member.java (사람이 읽는 코드)
│ javac 가 번역해요
▼
2) Member.class (기계가 읽는 바이트코드 파일)
│ 실행 중 처음 필요해질 때, 클래스 로더가 메모리로 실어 날라요
▼
3) Metaspace 안의 Member 클래스 정보 (이제 new Member() 가능)
여기서 3번 화살표, ".class 파일을 읽어서 메모리에 올리는" 그 일을 하는 일꾼이 바로 클래스 로더(class loader) 예요. 지난 시간 "Metaspace 의 클래스 정보를 누가 읽어 올리나?" 하고 남겼던 의문, 그 답이 클래스 로더였던 거예요. 우리가 모르는 사이에 클래스 로더는 프로그램이 켜질 때부터 부지런히 클래스들을 실어 나르고 있었어요.
💡 오늘 기억할 한 줄:
.java→ (javac) →.class→ (클래스 로더) → 메모리. 클래스도 실행 중에 메모리로 "불려 올라가는" 정보예요. 그 일을 하는 일꾼이 클래스 로더고요.
Step 2: 클래스 로더 삼형제와 "부모에게 먼저 묻기"
클래스 로더는 사실 한 명이 아니라 세 명이 협력해요. 회사로 치면 직급이 다른 세 사람이 각자 맡은 클래스를 불러오는 거예요. 누가 무엇을 맡는지 보면 이래요.
- Bootstrap(부트스트랩) 로더 — 자바의 가장 핵심 클래스(
String·Integer같은java.base식구)를 불러와요. 너무 근본이라 자바 코드로 표현되지도 않아서, "널 누가 불러왔어?" 하고 물으면null이라는 답이 와요. - Platform(플랫폼) 로더 — 핵심에선 한 발 떨어진 표준 모듈(예: 데이터베이스용
java.sql)을 불러와요. - Application(애플리케이션) 로더 — 우리가 직접 만든 클래스(
Member·ClassLoaderDemo같은)를 불러와요. 우리 코드는 거의 다 이 막내가 담당해요.
말로만 들으면 막연하니, 직접 물어보는 작은 실험을 준비했어요. 어떤 클래스든 .getClassLoader() 라고 물으면 "널 불러온 로더가 누구인지" 를 알려줘요.
// com/instagram/javabasic/reflection/ClassLoaderDemo.java
public class ClassLoaderDemo {
// String 같은 핵심 클래스를 불러온 로더예요. Bootstrap 은 null 로 나타나요.
public ClassLoader bootstrapLoader() {
return String.class.getClassLoader();
}
// java.sql.Driver 는 java.base 밖의 다른 모듈(java.sql)에 있어요.
// 그래서 Bootstrap 이 아니라 Platform 로더가 불러와요.
public ClassLoader platformLoader() {
return java.sql.Driver.class.getClassLoader();
}
// 우리가 만든 이 클래스는 Application(애플리케이션) 로더가 불러와요.
public ClassLoader applicationLoader() {
return ClassLoaderDemo.class.getClassLoader();
}
}
세 메서드를 실제로 돌려서 무엇이 나오는지 표로 정리해 봤어요.
| 무엇을 물었나 | 돌아온 로더 | 계층 |
|---|---|---|
String.class.getClassLoader() |
null |
Bootstrap |
java.sql.Driver.class.getClassLoader() |
PlatformClassLoader | Platform |
ClassLoaderDemo.class.getClassLoader() |
AppClassLoader | Application |
String 은 핵심 중의 핵심이라 null(Bootstrap), 우리가 만든 ClassLoaderDemo 는 막내인 Application 로더가 불러왔죠. 이름만 다른 세 일꾼이 실제로 따로 존재한다는 게 눈에 보여요.
위임 — 일단 부모에게 먼저 물어봐요
이 셋은 부모-자식 사슬로 이어져 있어요. Application 의 부모가 Platform, Platform 의 부모가 Bootstrap 이죠. 그리고 중요한 규칙이 하나 있어요. 어떤 로더가 "이 클래스 좀 불러와" 라는 요청을 받으면, 자기가 바로 불러오지 않고 먼저 부모에게 물어봐요. 이걸 위임(delegation, 맡기기)이라고 해요.
[이 클래스 불러와 줘!] 요청이 오면, 곧장 불러오지 않고 위(부모)에게 먼저 물어요.
Application 로더 ── 나한테 온 요청이지만, 일단 부모에게 먼저
│ "부모님, 이거 아세요?"
▼
Platform 로더 ── 역시 부모에게 먼저
│ "부모님, 이거 아세요?"
▼
Bootstrap 로더 ── 가장 윗대. 핵심 클래스(String 등)는 여기서 끝
▲
└─ 위에서 아무도 못 찾으면, 그제야 요청받은 로더가 직접 불러와요
왜 굳이 부모에게 먼저 물을까요? 핵심 클래스를 보호하기 위해서예요. 만약 누군가 나쁜 마음으로 String 이라는 이름의 가짜 클래스를 만들어 슬쩍 끼워 넣어도, 요청이 위로 올라가 Bootstrap 이 "진짜 String 은 내가 이미 갖고 있어" 하고 먼저 답해버려요. 그래서 가짜가 진짜를 밀어내지 못해요. 부모에게 먼저 묻는 이 습관이 자바의 안전선인 셈이에요.
💡 오늘 기억할 한 줄: 클래스 로더는 Bootstrap(핵심) → Platform(표준 모듈) → Application(내 코드) 세 계층. 요청이 오면 부모에게 먼저 묻고, 위에서 못 찾을 때만 자기가 불러와요.
null이 나오면 Bootstrap 이 불러온 핵심 클래스란 뜻이고요.
Step 3: 리플렉션의 입구 — Class 객체 손에 넣기
이제 본론인 리플렉션으로 들어가요. 리플렉션은 거울에 자기를 비춰 보듯, 실행 중인 프로그램이 자기 자신을 들여다보는 기능이에요. "이 객체엔 어떤 필드가 있지? 어떤 메서드가 있지?" 를 실행 도중에 알아내는 거죠.
그 출발점이 Class 객체 예요. 모든 클래스에는 그에 딸린 "설명서" 같은 Class 객체가 메모리에 딱 하나 존재해요. 클래스 로더가 Step 1 에서 클래스를 올려놓을 때, 이 설명서도 같이 만들어 둬요. 이 설명서를 손에 넣으면, 거기 적힌 필드 목록·메서드 목록을 펼쳐볼 수 있어요. 설명서를 얻는 방법은 세 가지예요.
// com/instagram/javabasic/reflection/ClassObjectDemo.java
public class ClassObjectDemo {
// 1번 방법: 타입.class — 클래스 이름을 직접 적어 설명서를 가져와요.
public Class<?> fromClassLiteral() {
return com.instagram.javabasic.domain.member.Member.class;
}
// 2번 방법: 객체.getClass() — 이미 만든 객체에게 "넌 어떤 클래스니?" 라고 물어요.
public Class<?> fromInstance(Object obj) {
return obj.getClass();
}
// 3번 방법: Class.forName("FQCN") — 패키지까지 포함한 전체 이름(글)으로 설명서를 찾아요.
public Class<?> fromName(String fullClassName) throws ClassNotFoundException {
return Class.forName(fullClassName);
}
}
세 방법은 쓰는 상황이 조금씩 달라요. 타입.class 는 클래스 이름을 코드에 직접 적을 때, 객체.getClass() 는 이미 만든 객체가 손에 있을 때, Class.forName("이름") 은 클래스 이름을 글(문자열)로만 알 때 써요. 특히 세 번째가 흥미로운데, 문자열로 받은 이름을 보고 Step 2 의 클래스 로더를 시켜 그 클래스를 메모리로 불러오기까지 해요. 이름표만 건네면 알아서 데려오는 거죠.
Member.class ─┐
member.getClass() ├──▶ Member 의 Class 객체 (메모리에 단 하나뿐)
Class.forName(...) ─┘
세 방법 모두 똑같은 하나를 가리켜요 → == 로 비교하면 true
설명서가 메모리에 하나뿐이라, 세 방법으로 같은 클래스를 가리키면 결과는 완전히 같은 객체예요. 그래서 == 로 비교해도 true 가 나와요. "설명서는 클래스당 하나" — 이걸 기억하면 리플렉션이 한결 편해져요.
💡 오늘 기억할 한 줄: 리플렉션은 Class 객체(클래스의 설명서)에서 출발해요. 얻는 법은
타입.class·객체.getClass()·Class.forName("이름")세 가지. 같은 클래스면 셋 다 똑같은 하나를 가리켜요.
Step 4: 필드 들여다보기 + 잠긴 문 열기
설명서를 손에 넣었으니, 이제 그 안을 펼쳐볼 차례예요. 먼저 필드(field, 객체가 품은 데이터 칸) 부터 봐요. Day 8 에서 만든 Member 를 떠올려보세요. username·followers 같은 필드를 private 으로 숨겨놨었죠. 평소엔 클래스 밖에서 이 필드들을 직접 못 봐요. 그런데 리플렉션을 쓰면 "이 객체에 어떤 필드들이 있는지" 목록을 통째로 받아올 수 있어요.
// com/instagram/javabasic/reflection/FieldInspector.java
public class FieldInspector {
// 객체가 가진 모든 필드의 "이름" 목록을 모아서 돌려줘요.
// getDeclaredFields() 는 private 까지 포함해 그 클래스에 직접 선언된 필드를 전부 가져와요.
public List<String> fieldNames(Object target) {
Field[] fields = target.getClass().getDeclaredFields();
List<String> names = new ArrayList<>();
for (Field field : fields) {
names.add(field.getName());
}
return names;
}
// 이름으로 필드 하나를 찾아 그 값을 꺼내 돌려줘요.
// private 필드라도 setAccessible(true) 로 잠깐 열어 두면 값을 읽을 수 있어요.
public Object readField(Object target, String fieldName) throws ReflectiveOperationException {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(target);
}
}
getDeclaredFields() 가 핵심이에요. 어떤 객체를 줘도 그 클래스에 선언된 필드를 private 까지 전부 배열로 돌려줘요. Member 객체를 넘기면 username·followers·posts… 같은 이름들이 줄줄이 나와요. VisualVM 이 우리 프로그램의 클래스 이름과 필드를 알아냈던 비결이 바로 이런 기능이었어요. 도구가 우리 클래스의 설명서를 펼쳐 읽은 거죠.
두 번째 메서드에 새 표현이 하나 있어요. field.setAccessible(true) 예요. private 필드는 원래 바깥에서 못 읽지만, "이 필드 잠깐만 열어 줘" 하고 부탁하는 게 setAccessible(true) 예요. 잠긴 문에 마스터키를 대는 셈이죠. 이렇게 열고 나면 field.get(target) 으로 그 객체의 필드 값까지 꺼내 볼 수 있어요.
⚠️ 마스터키가 항상 통하는 건 아니에요. 우리가 직접 만든 클래스는
setAccessible(true)로 잘 열리지만, 자바 내부의 깊숙한 클래스나 단단히 잠가둔 외부 도구의 클래스는 열리지 않고 막힐 수 있어요. 지금은 "내 클래스는 열린다" 만 알아두면 충분해요.
🙋 학생 질문 — "튜터님, private 으로 숨겼는데 이렇게 열어버리면 캡슐화가 의미 없는 거 아니에요?"
날카로운 질문이에요. 결론부터 말하면, 캡슐화는 여전히 의미가 있어요. private 은 "평소엔 여기 손대지 마세요" 라는 일상의 약속이에요. 우리가 매일 코드를 짤 땐 이 약속을 지켜서, 정해진 getter/setter 로만 드나들죠. 그래야 객체가 자기 데이터를 스스로 지킬 수 있으니까요.
리플렉션의 setAccessible(true) 는 그 약속을 깨는 특수 도구예요. 평범한 코드에서 꺼내 쓰라고 있는 게 아니라, VisualVM 같은 진단 도구나 프레임워크처럼 "남의 클래스 속을 들여다봐야만 하는" 특별한 프로그램을 위한 비상용 마스터키예요. 우리가 매일 쓰는 현관 열쇠가 따로 있고, 소방관의 도끼가 따로 있는 것과 같아요. 도끼가 있다고 해서 현관 열쇠가 의미 없어지는 건 아니죠.
그러니 "리플렉션으로 뚫을 수 있으니 private 은 소용없다" 가 아니라, "평소엔 약속을 지키되, 꼭 필요한 특수 상황에만 마스터키를 쓴다" 가 맞아요. 이 이야기는 오늘 마지막 생각해볼 주제에서 한 번 더 곱씹어 볼게요.
💡 오늘 기억할 한 줄:
getClass().getDeclaredFields()로 필드 목록(private 포함)을 받고,setAccessible(true)로 잠근 필드를 연 뒤field.get(객체)로 값을 읽어요. 진단 도구가 클래스 속을 아는 비결이 이거예요.
Step 5: 메서드 들여다보고 이름만으로 호출하기
필드를 봤으니 이번엔 메서드 차례예요. 보통 우리는 member.getUsername() 처럼 메서드 이름을 코드에 직접 적어서 불러요. 그런데 리플렉션을 쓰면, "getUsername" 이라는 글자 하나만 가지고도 그 메서드를 찾아 실행할 수 있어요. 메서드 이름이 변수에 담긴 문자열이어도 부를 수 있다는 뜻이죠.
// com/instagram/javabasic/reflection/MethodInspector.java
public class MethodInspector {
// 객체가 직접 선언한 모든 메서드의 "이름" 목록을 모아 돌려줘요.
// getDeclaredMethods() 는 그 클래스에 직접 적힌 메서드를 가져와요(상속받은 건 제외).
public List<String> methodNames(Object target) {
Method[] methods = target.getClass().getDeclaredMethods();
List<String> names = new ArrayList<>();
for (Method method : methods) {
names.add(method.getName());
}
return names;
}
// 메서드 이름(글)으로 메서드를 찾아 직접 실행하고, 그 반환값을 돌려줘요.
public Object invokeNoArg(Object target, String methodName) throws ReflectiveOperationException {
Method method = target.getClass().getDeclaredMethod(methodName);
method.setAccessible(true);
return method.invoke(target);
}
}
필드와 짝을 이루는 모양이라 익숙하죠? getDeclaredMethods() 로 메서드 이름 목록을 받고, getDeclaredMethod("이름") 으로 메서드 하나를 콕 집어요. 진짜 새로운 건 마지막 줄 method.invoke(target) 이에요. invoke 는 "불러내다, 실행하다" 라는 뜻인데, "이 객체(target)에 대고 이 메서드를 실행해 줘" 라는 말이에요. 그러면 member.getUsername() 을 직접 적은 것과 똑같이 실행되고, 그 반환값이 돌아와요.
이게 왜 대단할까요? 평소 코드는 부를 메서드를 미리 정해두지만, 리플렉션은 실행 중에 정할 수 있어요. 예를 들어 사용자가 화면에서 고른 항목 이름을 그대로 메서드 이름으로 써서 호출하는 식이죠. 곧 배울 테스트 도구나 여러 자동화 장치가, 바로 이 "이름으로 메서드 실행" 위에서 돌아가요.
💡 오늘 기억할 한 줄:
getDeclaredMethods()로 메서드 목록을 보고,getDeclaredMethod("이름")으로 찾은 메서드를invoke(객체)로 실행해요. 메서드 이름을 문자열로만 알아도 부를 수 있다는 게 핵심이에요.
Step 6: 리플렉션으로 객체 찍어내기
필드도 보고 메서드도 불렀어요. 마지막 한 조각, 객체를 새로 만드는 것 도 리플렉션으로 할 수 있어요. 평소엔 new Member() 라고 직접 적지만, 리플렉션에선 Class 객체(설명서)에게 "네 생성자를 찾아서 객체 하나 만들어 줘" 라고 부탁해요.
// com/instagram/javabasic/reflection/ReflectiveCreator.java
public class ReflectiveCreator {
// 매개변수가 없는 기본 생성자로 빈 객체를 만들어요.
// getDeclaredConstructor() 의 괄호를 비우면 "인자 없는 생성자" 를 찾아요.
public Object createWithNoArg(Class<?> type) throws ReflectiveOperationException {
Constructor<?> constructor = type.getDeclaredConstructor();
return constructor.newInstance();
}
// 두 개의 문자열을 받는 생성자로 객체를 만들어요(예: Member(String username, String email)).
public Object createWithTwoStrings(Class<?> type, String first, String second)
throws ReflectiveOperationException {
Constructor<?> constructor = type.getDeclaredConstructor(String.class, String.class);
return constructor.newInstance(first, second);
}
}
순서가 두 단계예요. 먼저 getDeclaredConstructor(...) 로 원하는 생성자를 찾아요. 괄호를 비우면 인자 없는 기본 생성자, getDeclaredConstructor(String.class, String.class) 처럼 타입을 적으면 "문자열 두 개를 받는 생성자" 를 골라줘요. Member 에는 Member() 와 Member(String username, String email) 가 둘 다 있어서, 원하는 쪽을 정확히 집을 수 있어요. 그다음 newInstance(...) 에 값을 넣으면 실제 객체가 만들어져요.
여기서 함정 하나를 짚을게요. 예전엔 클래스.newInstance() 라는 더 짧은 방법도 있었어요. 그런데 이 방법은 생성자에서 나는 예외를 어물쩍 가려버리는 문제가 있어서, 지금은 쓰지 않기로 정리됐어요(deprecated, 사용 권장 안 함). 그래서 우리는 항상 "생성자를 먼저 찾고(getDeclaredConstructor) → newInstance" 하는 두 단계 방식을 써요. IDE 에서 옛 방식에 취소선이 그어져 있는 걸 보면, "아, 이제 안 쓰는 거구나" 하고 새 방식으로 가면 돼요.
💡 오늘 기억할 한 줄: 객체도 리플렉션으로 만들어요.
getDeclaredConstructor(...)로 생성자를 찾고newInstance(...)로 찍어내요. 짧은 옛 방식클래스.newInstance()는 안 쓰고, 이 두 단계를 써요.
Step 7: 나만의 어노테이션 만들기
이제 분위기를 바꿔서 어노테이션 으로 가요. 어노테이션은 @ 로 시작하는, 코드에 붙이는 "메모지" 예요. Day 10 에서 본 @Override, Day 15 에서 본 @Deprecated 가 다 어노테이션이었죠. 그 자체로는 프로그램을 실행하지 않고, 그냥 "이 코드는 이런 의미예요" 하고 표시만 해둬요.
그런데 이런 표시를 우리가 직접 만들 수도 있어요. 클래스를 만들 때 class 라고 쓰듯, 어노테이션은 @interface 라고 써요. 새 회원에게 보여줄 환영 카드의 일부 칸을 "여긴 나중에 자동으로 채워주세요" 라고 표시하는 @AutoFill 을 만들어 볼게요.
// com/instagram/javabasic/reflection/annotation/AutoFill.java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface AutoFill {
// 이 필드에 채워 넣을 값을 찾을 때 쓰는 "키" 예요.
// 예: @AutoFill("appName") 이라고 적으면 "appName" 키의 값을 가져와 넣어요.
String value();
}
어노테이션을 만들 때 위에 붙은 두 표시가 중요해요. 이 둘은 "어노테이션을 위한 어노테이션" 이에요.
@Target(ElementType.FIELD) 는 이 표시를 어디에 붙일 수 있는지 정해요. FIELD 라고 했으니 필드 위에만 붙일 수 있어요. 메서드나 클래스엔 못 붙이게 막는 거죠.
@Retention(RetentionPolicy.RUNTIME) 은 이 표시를 언제까지 살려둘지 정해요. 이게 오늘의 핵심이에요. 어노테이션의 수명은 세 단계 중 하나로 정할 수 있어요.
어노테이션을 언제까지 살려 둘까? — @Retention 으로 정해요.
SOURCE : 컴파일하면 사라짐 (예: @Override 는 컴파일러만 보고 버려요)
CLASS : .class 엔 있지만 실행 땐 안 읽힘 (따로 안 정하면 이게 기본)
RUNTIME : 실행 중에도 살아 있음 ◀── 리플렉션으로 읽으려면 반드시 이것!
리플렉션은 실행 중에 어노테이션을 읽어요. 그러니 표시가 실행 시점까지 살아 있지 않으면 읽을 수가 없겠죠. @Retention(RetentionPolicy.RUNTIME) 을 꼭 붙여야 하는 이유가 이거예요. 만약 이걸 빼먹으면 기본값(CLASS)이 적용돼서, 다음 Step 의 자동 채우기 장치가 표시를 못 찾고 아무 일도 안 일어나요. 커스텀 어노테이션에서 가장 흔히 하는 실수라, 꼭 기억해두면 좋아요.
💡 오늘 기억할 한 줄: 나만의 어노테이션은
@interface로 만들어요.@Target으로 붙일 위치를,@Retention으로 수명을 정하는데, 리플렉션으로 읽으려면 반드시RUNTIME이어야 해요.
Step 8: 어노테이션 + 리플렉션 = 자동 채우기 장치
오늘 배운 걸 전부 한데 모으는 마지막 Step 이에요. 만든 어노테이션(@AutoFill)을 실제 필드에 붙이고, 리플렉션으로 그 표시를 찾아 값을 자동으로 채워 넣는 장치를 완성해요.
먼저 표시를 붙인 환영 카드예요. 채워질 칸엔 @AutoFill("키") 를 붙이고, 그냥 둘 칸엔 안 붙였어요.
// com/instagram/javabasic/reflection/WelcomeCard.java
public class WelcomeCard {
@AutoFill("appName")
private String appName;
@AutoFill("greeting")
private String greeting;
// 표시가 없는 평범한 필드 — 자동 채우기 대상이 아니라 처음 값 그대로 남아요.
private String buttonLabel = "시작하기";
// ... getAppName(), getGreeting(), getButtonLabel() getter 생략 ...
}
이제 표시를 읽어 값을 채우는 장치예요. Step 4(필드 훑기·열기)와 Step 7(어노테이션)이 여기서 만나요.
// com/instagram/javabasic/reflection/MiniInjector.java
public class MiniInjector {
// target 의 @AutoFill 필드들을 values 묶음의 값으로 채운 뒤, 채워진 그 객체를 돌려줘요.
public Object fill(Object target, Map<String, Object> values) throws ReflectiveOperationException {
Field[] fields = target.getClass().getDeclaredFields();
for (Field field : fields) {
// @AutoFill 표시가 붙어 있지 않은 필드는 건너뛰어요.
if (!field.isAnnotationPresent(AutoFill.class)) {
continue;
}
// 표시에 적힌 키(예: "appName")를 읽어요.
AutoFill autoFill = field.getAnnotation(AutoFill.class);
String key = autoFill.value();
// 값 묶음에 그 키가 있으면 꺼내 필드에 채워 넣어요.
if (values.containsKey(key)) {
field.setAccessible(true);
field.set(target, values.get(key));
}
}
return target;
}
}
동작을 한 줄씩 따라가 보면 이래요. 모든 필드를 훑다가(getDeclaredFields), @AutoFill 표시가 붙은 필드만 골라요(isAnnotationPresent). 그 표시에 적힌 키(value)를 읽고, 값 묶음(values)에 그 키가 있으면 setAccessible 로 필드를 열어 채워 넣어요(set). 표시가 없는 buttonLabel 은 건드리지 않으니 처음 값 그대로 남고요.
이렇게 써보면 결과가 한눈에 들어와요.
WelcomeCard card = new WelcomeCard();
Map<String, Object> values = Map.of("appName", "Instagram", "greeting", "환영해요!");
new MiniInjector().fill(card, values);
// card.getAppName() → "Instagram" (@AutoFill 이라 자동으로 채워짐)
// card.getGreeting() → "환영해요!" (@AutoFill 이라 자동으로 채워짐)
// card.getButtonLabel() → "시작하기" (표시가 없어 처음 값 그대로)
우리가 setter 를 하나도 부르지 않았는데, 표시가 붙은 칸만 알아서 채워졌어요. "표시(어노테이션)를 붙여두면, 그걸 읽는 장치(리플렉션)가 알아서 값을 넣어준다" — 이 작은 마법이 오늘의 결론이에요. 사실 이 방식은 다음 과목에서 배울 백엔드 도구가 객체들을 자동으로 이어 붙일 때 쓰는 원리와 똑같아요. 그땐 표시 이름만 다를 뿐, "표시를 보고 리플렉션이 알아서 해준다" 는 뼈대는 오늘 만든 이것 그대로예요.
💡 오늘 기억할 한 줄: 어노테이션은 혼자선 아무것도 안 하는 "표시" 일 뿐이에요. 그 표시를 리플렉션으로 읽어 행동하는 코드가 있어야 비로소 살아 움직여요. 표시(
@AutoFill)와 해석(MiniInjector)은 한 쌍이에요.
마무리
오늘은 프로그램이 자기 자신을 들여다보는 거울, 리플렉션을 손에 쥐어본 하루였어요. 지난 시간 남긴 두 의문에 답을 모두 찾았죠.
- 클래스 로딩 —
.java→.class→ 메모리. 그 일을 하는 클래스 로더는 Bootstrap → Platform → Application 세 계층이고, 요청이 오면 부모에게 먼저 물어요. (Metaspace 정보를 올리는 주인공이 바로 이 클래스 로더였어요.) - Class 객체 — 리플렉션의 입구.
타입.class·객체.getClass()·Class.forName()으로 얻고, 같은 클래스면 셋 다 똑같은 하나예요. - 필드·메서드·생성자 —
getDeclaredFields·getDeclaredMethods·getDeclaredConstructor로 들여다보고,setAccessible로 private 을 열고,invoke·newInstance로 실행했어요. (VisualVM 이 클래스 속을 아는 비결이 이거였고요.) - 커스텀 어노테이션 —
@interface로 만들고@Retention(RUNTIME)으로 실행까지 살려둬요. 리플렉션과 묶어 "표시만 보고 자동으로 값을 채우는" 장치를 직접 완성했어요.
오늘 만든 MiniInjector 가 한 일을 한 문장으로 줄이면 이거예요. "표시를 읽어서, 알맞은 메서드나 필드를 실행 중에 골라 다룬다." 그런데 이건 우리가 곧 만날 또 다른 도구의 정체이기도 해요. 다음 시간엔 @Test 라고 적힌 메서드만 골라 자동으로 실행해 주는 JUnit 이라는 테스트 도구를 직접 만나봐요. 그동안 우리 코드가 정말 제대로 동작하는지 확인해 온 그 도구가, 사실은 오늘 배운 어노테이션과 리플렉션으로 돌아가고 있었어요. 오늘 만든 자동 채우기 장치를 떠올리면, JUnit 이 한결 친근하게 보일 거예요. 수고 많으셨어요!
과제
오늘 배운 리플렉션 도구들을 직접 다뤄 보는 과제예요. 한 단계씩 쌓아 올리도록 준비했어요.
과제 1: 객체 속을 통째로 출력하기 [기초]
어떤 객체를 받아도 그 안의 모든 필드를 이름 = 값 형태로 한 줄씩 뽑아내는 도구 ObjectDumper 를 만들어 보세요. Step 4 에서 배운 getDeclaredFields 와 setAccessible 을 묶으면 돼요. 간단한 게시물 카드(예: 제목·좋아요 수·공개 여부 필드를 가진 객체)를 만들어 넘겨보고, 세 줄이 제대로 출력되는지 확인해 보세요. (힌트: 클래스에 하나뿐인 static 필드는 한 객체의 정보가 아니니 건너뛰는 게 깔끔해요.)
과제 2: 어노테이션으로 필수 입력 검증기 만들기 [응용]
"이 필드는 꼭 채워져 있어야 한다" 를 표시하는 커스텀 어노테이션 @Required 를 만들고(@Retention 과 @Target 을 잊지 마세요), @Required 가 붙은 필드 중 값이 null 인 필드의 이름을 모아 돌려주는 RequiredValidator 를 만들어 보세요. 회원가입 폼처럼 일부 필드엔 @Required 를 붙이고 일부엔 안 붙인 객체로 시험해, 비어 있는 필수 항목만 정확히 잡아내는지 확인해 보세요. (Step 7·8 의 isAnnotationPresent 와 표시 읽기를 응용하면 돼요.)
과제 3: 리플렉션으로 객체 복제기 만들기 [심화]
원본 객체를 받아 같은 타입의 새 객체를 만들고, 모든 필드 값을 그대로 옮겨 담아 복제본을 돌려주는 ReflectiveCopier 를 만들어 보세요. Step 6 의 getDeclaredConstructor().newInstance() 로 빈 객체를 만들고, Step 4 의 필드 읽기·쓰기로 값을 옮기면 돼요. 복제본이 원본과 필드 값은 같지만 서로 다른 객체 인지 확인해 보세요. (힌트: 여기서도 static 필드는 건너뛰어야 해요.)
생각해볼 주제
1. 리플렉션은 이렇게 강력한데, 왜 평소 코드엔 잘 안 쓸까
리플렉션이면 private 도 뚫고, 이름만으로 메서드도 부르고, 객체도 찍어내요. 못 하는 게 없어 보이죠. 그런데 막상 평범한 업무 코드에선 리플렉션을 거의 안 써요. 컴파일러가 미리 잡아주던 오타나 타입 오류를 리플렉션은 실행하기 전까지 못 잡는다는 점, 그리고 매번 설명서를 뒤져 찾는 동작이라 직접 부르는 것보다 느리다는 점을 떠올리면서, 리플렉션을 "꺼내 쓸 때" 와 "아껴야 할 때" 의 경계를 생각해 보세요.
2. setAccessible 로 private 을 열 수 있다면, private 은 의미가 없는 걸까
setAccessible(true) 한 줄이면 꽁꽁 숨긴 private 필드도 열려요. 그렇다면 우리가 그동안 공들인 캡슐화는 헛수고였을까요? 일상에서 지키는 약속(평범한 코드)과, 특별한 권한을 가진 도구(진단기·프레임워크)가 쓰는 비상용 마스터키를 구분해서 생각해 보세요. private 이 막아주는 것이 "절대 못 여는 벽" 인지, 아니면 "함부로 열지 말라는 약속" 인지가 이 질문의 갈림길이에요.
3. 아무 동작도 안 하는 "표시" 일 뿐인 어노테이션이, 어떻게 프로그램을 움직일까
@AutoFill 이나 @Override 는 그 자체로는 아무 일도 하지 않아요. 그냥 코드에 붙은 메모지일 뿐이죠. 그런데 오늘 우리는 그 메모지 덕분에 값이 자동으로 채워지는 걸 봤어요. 표시 자체와, 그 표시를 읽어 행동하는 코드(리플렉션)를 나눠서 생각해 보세요. 만약 표시만 있고 그걸 읽는 코드가 아무도 없다면 어떻게 될지도 함께 떠올리면, 어노테이션의 정체가 또렷해질 거예요.
✅ 예시 답안정답 보기
아래는 과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니니, 내가 짠 코드·내가 떠올린 답과 비교하며 "왜 이렇게 했는지" 를 짚어보세요.
과제 예시답안
과제 1 예시답안 — 객체 속을 통째로 출력하기
핵심 접근
이 과제의 핵심은 둘이에요. getDeclaredFields() 로 필드를 전부 훑을 수 있는가, 그리고 setAccessible(true) 로 private 필드의 값까지 꺼낼 수 있는가. Step 4 에서 따로따로 본 두 가지를 한 메서드 안에 묶는 연습이에요. 여기에 "static 필드는 한 객체의 정보가 아니니 건너뛴다" 는 판단을 더하면 깔끔해져요.
예시 구현
// com/instagram/javabasic/reflection/solution/day45/ObjectDumper.java
public class ObjectDumper {
// target 의 인스턴스 필드를 모두 훑어 "이름 = 값" 문자열 목록으로 돌려줘요.
public List<String> dump(Object target) throws ReflectiveOperationException {
Field[] fields = target.getClass().getDeclaredFields();
List<String> lines = new ArrayList<>();
for (Field field : fields) {
// static 필드는 한 객체의 값이 아니라서 덤프 대상에서 빼요.
if (Modifier.isStatic(field.getModifiers())) {
continue;
}
field.setAccessible(true);
lines.add(field.getName() + " = " + field.get(target));
}
return lines;
}
}
출력 대상으로 쓸 간단한 게시물 카드는 이렇게 두면 돼요. 필드는 전부 private 이지만, 리플렉션으로는 다 들여다보여요.
// com/instagram/javabasic/reflection/solution/day45/PhotoPost.java
public class PhotoPost {
private String caption;
private int likeCount;
private boolean isPublic;
public PhotoPost(String caption, int likeCount, boolean isPublic) {
this.caption = caption;
this.likeCount = likeCount;
this.isPublic = isPublic;
}
}
미션별 답
-
세 줄이 제대로 나오는가:
new PhotoPost("첫 게시물", 12, true)를dump에 넘기면caption = 첫 게시물,likeCount = 12,isPublic = true세 줄이 나와요. private 인데도setAccessible(true)덕분에 값까지 읽혔죠. -
static 필드 건너뛰기:
Modifier.isStatic(field.getModifiers())로 static 여부를 확인해 건너뛰면, 클래스에 하나뿐인 공유 값(예: 전체 개수 카운터)이 덤프에 섞이지 않아요. PhotoPost 엔 static 필드가 없어 차이가 안 보이지만,Member처럼 static 필드를 가진 객체를 덤프하면 이 한 줄이 결과를 깔끔하게 만들어줘요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 필드 전체 훑기 | getDeclaredFields() 로 private 포함 모든 필드를 돌았는가 |
상 |
| private 값 읽기 | setAccessible(true) 후 field.get(target) 으로 값을 꺼냈는가 |
상 |
| 이름 = 값 형태 | field.getName() 과 값을 합쳐 한 줄로 만들었는가 |
중 |
| static 제외 | static 필드를 건너뛰는 처리를 넣었는가 | 중 |
흔한 실수
getFields()를 써서 private 필드가 안 나오는 경우 →getFields()는 public 필드만 가져와요. 선언된 모든 필드는getDeclaredFields()예요. 이름이 비슷해 헷갈리기 쉬워요.setAccessible(true)를 빼먹어IllegalAccessException이 나는 경우 → private 필드 값을 읽으려면 먼저 문을 열어야 해요. 목록(getName)만 볼 땐 안 열어도 되지만, 값(get)을 꺼낼 땐 열어야 해요.
실무 개선 포인트 (심화)
실무의 객체 출력 도구(흔히 toString 을 자동으로 만들어주는 도구들)도 속을 들여다보면 이 리플렉션 덤프와 같은 원리로 돌아가요. 다만 한발 더 나아가면, 필드 안에 또 객체가 들어 있을 때 그 속까지 파고들지(중첩), 아니면 주소만 찍을지를 정해야 하고, 서로가 서로를 가리키는 순환 구조에서 무한히 도는 걸 막는 장치도 필요해요. 지금은 한 겹 필드를 평평하게 출력하는 것만으로 충분하지만, "객체를 글로 풀어내는 도구는 결국 리플렉션 위에 서 있다" 는 감만 챙겨두면 좋아요.
과제 2 예시답안 — 어노테이션으로 필수 입력 검증기 만들기
핵심 접근
이 과제의 마침표는 "표시(@Required)와 그 표시를 읽는 코드(RequiredValidator)는 한 쌍" 이라는 걸 직접 만들어 보는 거예요. 어노테이션을 만들 때 @Retention(RUNTIME) 을 빠뜨리지 않는 것, 그리고 검증기에서 isAnnotationPresent 로 표시 붙은 필드만 골라 null 인지 확인하는 것 — 이 둘이 맞물려야 동작해요.
예시 구현
먼저 "꼭 채워야 한다" 를 뜻하는 표시예요. 실행 중에 리플렉션으로 읽어야 하니 RUNTIME 이 생명이에요.
// com/instagram/javabasic/reflection/solution/day45/Required.java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Required {
}
그 표시를 읽어 비어 있는 필수 필드를 찾아내는 검증기예요.
// com/instagram/javabasic/reflection/solution/day45/RequiredValidator.java
public class RequiredValidator {
// target 의 @Required 필드 중 값이 null 인 필드 이름 목록을 돌려줘요. 다 채워졌으면 빈 목록.
public List<String> findMissing(Object target) throws ReflectiveOperationException {
Field[] fields = target.getClass().getDeclaredFields();
List<String> missing = new ArrayList<>();
for (Field field : fields) {
// @Required 표시가 없는 필드는 검사하지 않아요.
if (!field.isAnnotationPresent(Required.class)) {
continue;
}
field.setAccessible(true);
if (field.get(target) == null) {
missing.add(field.getName());
}
}
return missing;
}
}
시험에 쓸 회원가입 폼은 username·email 에만 @Required 를 붙이고, nickname 은 선택이라 안 붙였어요.
// com/instagram/javabasic/reflection/solution/day45/SignupForm.java
public class SignupForm {
@Required
private String username;
@Required
private String email;
private String nickname;
public SignupForm(String username, String email, String nickname) {
this.username = username;
this.email = email;
this.nickname = nickname;
}
}
미션별 답
-
다 채웠을 때:
new SignupForm("jaehoon", "jaehoon@example.com", null)처럼 필수 둘을 채우면,nickname이 비어 있어도findMissing은 빈 목록을 돌려줘요.nickname엔@Required가 없으니 검사 대상이 아니거든요. -
필수가 비었을 때:
new SignupForm(null, null, "별명만")을 넘기면["username", "email"]이 잡혀요. 표시가 붙은 두 필드가 모두null이라서죠. -
표시 없는 필드는 무시:
nickname은null이어도 결과에 안 들어와요. 이게 어노테이션 검증의 핵심이에요 — "검사할 대상" 을 표시로 정한다는 것. 이 동작은 코드베이스에서 세 경우(다 채움 / 필수 빔 / 선택만 빔) 모두 확인해 두었어요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| RUNTIME 유지 | 어노테이션에 @Retention(RUNTIME) 을 붙였는가 |
상 |
| 표시 필터링 | isAnnotationPresent(Required.class) 로 붙은 필드만 골랐는가 |
상 |
| null 판정 | 값을 꺼내 null 인 필드 이름만 모았는가 |
상 |
| 선택 필드 제외 | @Required 없는 필드는 비어도 안 잡히는가 |
중 |
흔한 실수
@Retention을 안 붙여 검증기가 표시를 못 찾는 경우 → 안 붙이면 기본값(CLASS)이라 실행 중엔 안 읽혀요.isAnnotationPresent가 항상false라 비어 있어도 아무것도 안 잡혀요. 가장 자주 나오는 함정이에요.- 값을 안 열고 읽으려다 막히는 경우 → 필수 필드도 보통 private 이라,
get전에setAccessible(true)가 필요해요. - 빈 문자열
""도 비었다고 봐야 하나 헷갈리는 경우 → 이번 과제는null만 "안 채움" 으로 봤어요. 빈 문자열까지 막고 싶다면 조건을 넓히면 되는데, 그건 검증 규칙을 어디까지 잡을지의 선택이에요.
실무 개선 포인트 (심화)
실무의 입력 검증은 보통 @Required 한 종류로 끝나지 않아요. "이메일 형식인가", "길이가 몇 자 이상인가", "숫자가 범위 안인가" 처럼 여러 표시를 두고, 각 표시마다 검사 방법을 짝지어 두죠. 그러면 폼 클래스엔 표시만 붙이고, 검증기가 표시를 읽어 알맞은 검사를 돌려요 — 우리가 만든 구조를 그대로 넓힌 모습이에요. 실제로 백엔드 과목에서 만날 검증 도구가 정확히 이 방식으로 동작해요. 지금은 @Required 하나로 뼈대를 익히는 것으로 충분하고, "표시를 늘리면 검증도 늘어난다" 는 확장 방향만 그려두면 좋아요.
과제 3 예시답안 — 리플렉션으로 객체 복제기 만들기
핵심 접근
이 과제는 오늘 배운 두 도구를 합치는 종합 과제예요. Step 6 의 getDeclaredConstructor().newInstance() 로 빈 객체를 만들고, Step 4 의 필드 읽기·쓰기로 값을 옮겨요. 마침표는 결과 검증에 있어요 — 복제본이 원본과 필드 값은 같지만 서로 다른 객체 여야 진짜 복제예요.
예시 구현
// com/instagram/javabasic/reflection/solution/day45/ReflectiveCopier.java
public class ReflectiveCopier {
// original 과 같은 타입의 새 객체를 만들어, 모든 인스턴스 필드 값을 옮겨 담아 돌려줘요.
public Object copy(Object original) throws ReflectiveOperationException {
Class<?> type = original.getClass();
Object clone = type.getDeclaredConstructor().newInstance();
for (Field field : type.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers())) {
continue;
}
field.setAccessible(true);
field.set(clone, field.get(original));
}
return clone;
}
}
미션별 답
-
빈 객체 만들기:
original.getClass()로 원본의 설명서를 얻고, 그 기본 생성자(getDeclaredConstructor())로 빈 객체를 하나 찍어내요. 원본이Member면 복제본도Member, 원본이 다른 타입이면 그 타입으로 만들어지죠. 타입을 직접 적지 않고 원본에게 물어보는 게 포인트예요. -
값 옮겨 담기: 원본의 필드를 하나씩 읽어(
field.get(original)) 복제본의 같은 필드에 써넣어요(field.set(clone, ...)). 둘 다setAccessible(true)로 열어야 private 까지 옮길 수 있어요. static 필드는 한 객체의 값이 아니라 건너뛰고요(안 그러면static final같은 필드에서 쓰기 오류가 날 수 있어요). -
결과 검증:
Member original = new Member("jaehoon", 1240, 42, 5, 100)을 복제하면, 복제본은getUsername()·getFollowers()·getPosts()가 원본과 같은 값을 돌려줘요. 그런데원본 != 복제본이라,==로 비교하면false예요. 값은 같지만 메모리엔 두 개의 다른 객체가 있는 거죠. 이 "값은 같고 객체는 다르다" 가 복제가 제대로 됐다는 증거예요. (코드베이스에서 이 동작을 확인해 두었어요.)
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 같은 타입 생성 | original.getClass() 로 빈 객체를 만들었는가 (타입 하드코딩 안 함) |
상 |
| 필드 값 복사 | get 으로 읽어 set 으로 옮기는 흐름을 짰는가 |
상 |
| 다른 객체 검증 | 복제본이 값은 같고 == 는 false 임을 확인했는가 |
상 |
| static 제외 | static 필드를 건너뛰어 쓰기 오류를 피했는가 | 중 |
흔한 실수
- 복제본이 원본과
==로 같다고 나오는 경우 → 새 객체를 안 만들고 원본을 그대로 돌려준 거예요. 반드시newInstance()로 새 객체를 찍어내야 해요. - static 필드를 안 건너뛰어 오류가 나는 경우 →
static final필드에 값을 쓰려다 막힐 수 있어요.Modifier.isStatic으로 걸러주면 안전해요. - 필드 안에 든 객체까지 진짜로 복제됐다고 오해하는 경우 → 이 복제기는 필드의 "참조" 를 그대로 옮겨요(얕은 복제). 원본과 복제본이 같은 하위 객체를 함께 가리키게 되죠. 속까지 새로 만드는 깊은 복제는 한 단계 더 들어가야 해요.
실무 개선 포인트 (심화)
방금 짚은 "얕은 복제 vs 깊은 복제" 가 실무에서 늘 따라다니는 주제예요. 얕은 복제는 필드 안 객체를 원본과 공유하니, 복제본에서 그 하위 객체를 바꾸면 원본도 같이 바뀌어 버려요. 그게 곤란하면 하위 객체까지 새로 만드는 깊은 복제가 필요하죠. 또 리플렉션 복제는 어떤 타입이 와도 동작하는 대신 느리고, 컴파일러가 타입을 못 챙겨줘요. 그래서 실무에선 복제 규칙이 분명한 곳엔 직접 만든 복제 메서드를, "타입을 가리지 않는 범용 도구" 가 필요할 때만 리플렉션 복제를 골라요. 지금은 얕은 복제로 원리를 잡는 것으로 충분해요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — 리플렉션은 강력한데, 왜 평소 코드엔 잘 안 쓸까
[문제 상황 요약]
리플렉션은 private 도 뚫고, 이름만으로 메서드도 부르고, 객체도 찍어내는 만능처럼 보여요. 그런데 막상 평범한 업무 코드에선 거의 안 써요. 이 강력한 도구를 왜 아끼는지, 어디서 꺼내고 어디서 참아야 하는지에 대한 질문이에요.
[튜터의 가이드 및 해설]
리플렉션을 아끼는 이유는 크게 셋이에요.
첫째, 컴파일러의 보호를 잃어요. 평소 member.getUsername() 이라고 쓰면, 오타가 있거나 그런 메서드가 없을 때 컴파일러가 빨간 줄로 바로 알려줘요. 그런데 invoke("getUsername") 처럼 문자열로 부르면, 이름이 틀려도 컴파일러는 아무 말 안 해요. 실행하다가 그제야 터지죠. 미리 잡을 수 있던 실수를 실행 시점까지 미루는 셈이에요.
둘째, 느려요. 직접 메서드를 부르는 것과 달리, 리플렉션은 매번 설명서(Class 객체)를 뒤져 필드나 메서드를 찾아내요. 한 번은 티가 안 나지만, 수십만 번 반복되는 곳에선 이 차이가 쌓여요.
셋째, 읽기 어렵고 깨지기 쉬워요. 필드 이름이 문자열로 코드 곳곳에 흩어지면, 나중에 필드 이름 하나만 바꿔도 어디가 같이 깨질지 추적하기 힘들어요. 캡슐화로 숨긴 속을 함부로 들여다보니, 클래스의 안쪽이 바뀌면 리플렉션 코드가 조용히 무너지고요.
그래서 리플렉션은 "대상을 미리 알 수 없는 범용 장치" 에서만 진가를 발휘해요. 어떤 클래스가 올지 모르는 채로 동작해야 하는 진단 도구, 테스트 실행기, 객체↔데이터 변환기, 프레임워크 같은 것들요. 반대로 "이 객체의 이 메서드를 부른다" 가 분명한 평범한 업무 로직에선, 직접 부르는 게 더 빠르고 안전하고 읽기 쉬워요. 정리하면 — 리플렉션은 평상복이 아니라 특수 장비예요. 꼭 필요한 현장에서만 꺼내고, 평소엔 정직하게 직접 부르는 게 맞아요.
🎯 면접관을 홀리는 핵심 멘트
"리플렉션은 강력하지만 세 가지 비용을 치릅니다. 컴파일러의 타입·오타 검사를 우회해 오류가 런타임으로 미뤄지고, 매번 메타데이터를 탐색해 느리고, 캡슐화를 깨서 유지보수가 취약해집니다. 그래서 대상을 컴파일 시점에 알 수 없는 범용 인프라 — 프레임워크, 테스트 러너, 직렬화, 진단 도구 — 에서만 씁니다. 호출 대상이 분명한 일반 비즈니스 로직은 직접 호출이 더 빠르고 안전하고 읽기 좋습니다. 리플렉션은 평상복이 아니라 특수 장비예요."
생각해볼 주제 2 예시답안 — setAccessible 로 private 을 열 수 있다면, private 은 의미가 없을까
[문제 상황 요약]
setAccessible(true) 한 줄이면 꽁꽁 숨긴 private 필드도 열려요. 그렇다면 우리가 공들인 캡슐화는 헛수고였을까요? private 이 막아주는 게 "절대 못 여는 벽" 인지, "함부로 열지 말라는 약속" 인지에 대한 질문이에요.
[튜터의 가이드 및 해설]
먼저 private 의 정체를 정확히 봐야 해요. private 은 물리적인 강철 벽이 아니라, 컴파일러와 맺는 약속 이에요. "이 필드는 클래스 안에서만 쓰기로 해요" 라는 합의죠. 평범한 코드가 이 약속을 어기면 컴파일러가 막아서요. 그 덕분에 객체는 자기 데이터를 정해진 통로(getter/setter)로만 드나들게 통제할 수 있고, 우리는 "이 필드는 바깥에서 안 건드린다" 고 안심하고 클래스 속을 자유롭게 바꿀 수 있어요. 이게 캡슐화가 주는 진짜 값이에요.
setAccessible(true) 는 그 약속을 깨는 특수 권한이에요. 하지만 이건 아무 데서나 쓰라고 있는 게 아니라, 남의 클래스 속을 들여다봐야만 하는 도구(진단기·테스트 실행기·프레임워크)를 위한 비상용 마스터키예요. 게다가 늘 통하는 것도 아니에요. 자바 내부의 깊은 클래스나, 단단히 잠가둔 모듈의 클래스엔 이 마스터키도 막혀요. 즉 "원하면 무조건 다 열린다" 가 아니라, "특별한 권한이 있고 상대가 허락한 범위에서만 열린다" 예요.
그러니 결론은 이래요. private 이 의미 없어지는 게 아니라, 두 세계가 따로 있는 거예요. 일상의 코드는 약속을 지켜 정해진 문으로만 드나들고 — 그래서 캡슐화의 이점을 그대로 누리고 — 특수한 도구만이 꼭 필요할 때 마스터키를 꺼내요. 현관 열쇠가 있다고 해서 소방관의 도끼가 그걸 무의미하게 만들지 않듯이요. private 은 "잠긴 문" 이 아니라 "노크하고 들어오라는 약속" 이고, 그 약속이 코드를 안전하게 지켜줘요.
🎯 면접관을 홀리는 핵심 멘트
"private 은 물리적 벽이 아니라 컴파일러와의 약속이에요. 그 약속 덕에 객체가 내부를 통제하고, 우리는 안심하고 구현을 바꿀 수 있죠 — 캡슐화의 값은 거기 있습니다. setAccessible 은 그 약속을 깨는 특수 권한이지만, 진단기·프레임워크 같은 도구 전용이고 모듈이 잠그면 그마저 막힙니다. 그래서 private 이 무의미해지는 게 아니라, 약속을 지키는 일상 코드와 마스터키를 든 특수 도구가 분리돼 있는 겁니다. 현관 열쇠와 소방 도끼가 공존하듯이요."
생각해볼 주제 3 예시답안 — 아무 동작도 안 하는 "표시" 가 어떻게 프로그램을 움직일까
[문제 상황 요약]
@AutoFill 이나 @Override 는 그 자체로는 아무 일도 하지 않는 메모지일 뿐이에요. 그런데 오늘 우리는 그 메모지 덕분에 값이 자동으로 채워지는 걸 봤어요. 표시 하나가 어떻게 프로그램을 움직였는지에 대한 질문이에요.
[튜터의 가이드 및 해설]
핵심은 표시와 해석은 따로 라는 거예요. 어노테이션은 정말로 아무 동작도 안 해요. 그냥 코드에 붙은 메모지, 데이터일 뿐이에요. 프로그램을 움직인 건 어노테이션이 아니라, 그 메모지를 읽고 행동한 코드 예요. 오늘로 치면 @AutoFill 표시가 아니라, 그 표시를 리플렉션으로 찾아 값을 채운 MiniInjector 가 일을 한 거죠.
비유하자면 이래요. 책 곳곳에 붙인 형광색 포스트잇은 그 자체론 아무 일도 안 해요. 누군가 "포스트잇 붙은 곳만 골라 다시 읽어라" 라는 규칙을 들고 책을 넘길 때, 비로소 포스트잇이 의미를 가져요. 어노테이션이 포스트잇이라면, 리플렉션 코드가 그 규칙을 들고 넘기는 사람이에요. 둘이 한 쌍으로 만나야 동작이 생겨요.
그래서 중요한 깨달음이 하나 나와요. 표시만 붙이고 그걸 읽는 코드가 아무도 없으면, 그 표시는 아무 일도 안 해요. @AutoFill 을 백 개 붙여도 MiniInjector 가 없으면 필드는 텅 빈 채 그대로예요. 거꾸로, 표시를 읽는 코드가 있어야 비로소 표시가 살아 움직이죠. (@Override 가 조금 특별한 건, 그걸 읽는 쪽이 우리가 짠 코드가 아니라 컴파일러라는 점이에요. 컴파일러가 그 표시를 보고 "정말 부모의 메서드를 덮어쓰는 게 맞나" 검사해 주거든요.)
이 "표시(데이터)와 해석(행동)의 분리" 가 어노테이션을 강력하게 만들어요. 클래스엔 의미를 담은 표시만 깔끔하게 붙여두고, 그 표시를 어떻게 처리할지는 별도의 코드가 책임지니까요. 덕분에 같은 표시를 여러 도구가 각자 다르게 해석할 수도 있어요. 오늘 만든 작은 장치가, 사실은 수많은 자바 도구가 쓰는 그 구조의 축소판이었던 거예요.
🎯 면접관을 홀리는 핵심 멘트
"어노테이션 자체는 아무 동작도 없는 메타데이터, 즉 표시일 뿐입니다. 프로그램을 움직이는 건 그 표시를 리플렉션으로 읽어 행동하는 처리 코드예요. 표시와 해석이 분리돼 있다는 게 핵심입니다. 그래서 표시만 있고 읽는 쪽이 없으면 아무 일도 안 일어나고, 처리 코드가 있어야 비로소 살아납니다. 클래스엔 의미를 선언만 하고 행동은 별도 코드가 책임지니, 같은 표시를 여러 도구가 다르게 해석할 수도 있죠. 선언과 처리의 분리 — 이게 어노테이션의 힘입니다."