소스: java-src/advanced/08_reflection_proxy (어노테이션 5개, FakeDb, MapperFactory, TxProxy, MiniContainer, Main).
cd java-src/advanced/08_reflection_proxy
javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 MainClass<?> c = MemberServiceImpl.class;
for (Field f : c.getDeclaredFields())
System.out.printf("필드 %s %s @Inject=%b%n",
f.getType().getSimpleName(), f.getName(), f.isAnnotationPresent(Inject.class));
for (Method m : c.getDeclaredMethods()) {
Transactional t = m.getAnnotation(Transactional.class);
System.out.printf("메서드 %s @Transactional=%s%n",
m.getName(), t == null ? "없음" : "readOnly=" + t.readOnly());
}필드 MemberMapper mapper @Inject=true
메서드 find(인자 1개) @Transactional=없음
메서드 transfer(인자 3개) @Transactional=readOnly=false
메서드 transferAll(인자 2개) @Transactional=없음
record 구성요소: [long id, java.lang.String name, int point]getDeclaredMethods() 의 순서는 보장되지 않습니다. 예제는 이름으로 정렬해 출력했습니다. record 는 getRecordComponents() 로 구성요소를 순서대로 얻을 수 있어 매핑에 쓰기 좋습니다.
Class<?> c = Class.forName("MemberServiceImpl");
Object svc = c.getDeclaredConstructor().newInstance();
Field f = c.getDeclaredField("mapper");
f.get(svc); // IllegalAccessException: private
f.setAccessible(true);
f.get(svc); // null (아직 주입 전)
Method find = c.getMethod("find", long.class);
find.invoke(svc, 1L); // 안에서 NPE → InvocationTargetExceptionprivate 접근 실패: class Main cannot access a member of class MemberServiceImpl ...
setAccessible 후 mapper = null
find(Long) 없음 → 시그니처는 long.class 로 찾아야 합니다
invoke 예외: InvocationTargetException ← 원본 NullPointerExceptionList<String> logged = (List<String>) Proxy.newProxyInstance(
Main.class.getClassLoader(), new Class<?>[]{List.class},
(proxy, m, args) -> {
System.out.println(" 호출: " + m.getName() + Arrays.toString(args));
return m.invoke(target, args);
});
logged.add("a");
logged.size(); 호출: add[a]
호출: add[b]
호출: size[]
size() = 2
프록시 클래스: jdk.proxy2.$Proxy4, isProxyClass=true, instanceof List=true
클래스는 불가: java.util.ArrayList is not an interfaceinstanceof List 가 참이므로 호출자는 프록시인지 모릅니다. 인자가 없는 메서드는 args 가 null 로 들어오므로 배열로 다룰 때 주의합니다.
MemberMapper 에는 구현 클래스가 없습니다. MapperFactory.create 가 프록시를 만들고, 핸들러가 SQL 을 조립합니다.
public interface MemberMapper {
@Select("SELECT * FROM member WHERE id = #{id}")
Member findById(@Param("id") long id);
@Update("UPDATE member SET point = point + #{delta} WHERE id = #{id}")
int addPoint(@Param("id") long id, @Param("delta") int delta);
}핸들러의 바인딩 부분입니다. #{이름} 을 찾을 때마다 ? 로 바꾸고, 그 이름의 값을 순서대로 모읍니다.
Matcher mt = PARAM.matcher(sql); // #\{(\w+)}
while (mt.find()) {
ordered.add(named.get(mt.group(1)));
mt.appendReplacement(prepared, "?");
}
mt.appendTail(prepared);mapper = MapperProxy[MemberMapper] (jdk.proxy2.$Proxy5)
==> Preparing: SELECT * FROM member WHERE id = ?
==> Parameters: [1]
findById(1) = Member[id=1, name=김철수, point=100]
==> Preparing: UPDATE member SET point = point + ? WHERE id = ?
==> Parameters: [25, 3]
addPoint(3, 25) = 1건 → Member[id=3, name=박민수, point=75]
findById(99) = nullMyBatis 로그의 ==> Preparing / ==> Parameters 와 같은 모양입니다. 인자 순서가 SQL 에 나온 순서(delta, id) 로 바뀐 것을 보면 이름 바인딩이 왜 필요한지 알 수 있습니다.
행을 객체로 바꾸는 부분은 record 정식 생성자를 리플렉션으로 호출합니다.
RecordComponent[] comps = type.getRecordComponents();
for (int i = 0; i < comps.length; i++) {
types[i] = comps[i].getType();
vals[i] = row.get(comps[i].getName()); // 컬럼명 = 구성요소 이름
}
return type.getDeclaredConstructor(types).newInstance(vals);MemberService svc = TxProxy.wrap(new MemberServiceImpl(mapper), MemberService.class, tx);
svc.transfer(1, 2, 30); // BEGIN → COMMIT
svc.transfer(1, 99, 10); // BEGIN → 예외 → ROLLBACK, 예외는 그대로 전달
svc.transferAll(1, 99); // 안에서 this.transfer → 프록시 없음(a) 정상 이체 1→2, 30점
[TX] BEGIN
[TX] COMMIT
결과: Member[id=1, name=김철수, point=70], Member[id=2, name=이영희, point=230]
(b) 없는 회원에게 이체 1→99, 10점
[TX] BEGIN
[TX] ROLLBACK ← IllegalArgumentException
예외 그대로 전달: 받는 회원 없음: 99
결과: Member[id=1, name=김철수, point=70] (롤백으로 70점 유지)
(c) transferAll(1, 99): 안에서 this.transfer 호출
예외: 받는 회원 없음: 99
결과: Member[id=1, name=김철수, point=0] ← BEGIN 없이 실행되어 롤백되지 않음(b) 는 먼저 70점을 빼고 나서 예외가 났지만 롤백으로 원상복구됐습니다. (c) 는 같은 예외인데 BEGIN 로그가 없고, 빠진 70점이 그대로 사라졌습니다. 이것이 실무에서 "트랜잭션이 안 걸려요" 의 원인 1위입니다.
프록시 핸들러의 핵심은 세 줄입니다. 어노테이션을 구현 클래스 쪽에서 다시 찾는 점에 주의합니다. 인터페이스 Method 객체에는 구현체의 어노테이션이 없습니다.
Method impl = cls.getMethod(m.getName(), m.getParameterTypes());
Transactional t = impl.getAnnotation(Transactional.class);
if (t == null) return invoke(m, target, args);MiniContainer container = new MiniContainer(tx)
.register(new MapperFactory(db).create(MemberMapper.class), new MemberServiceImpl());
container.start();
MemberService svc = container.get(MemberService.class); 등록: 프록시[interface MemberMapper]
등록: MemberServiceImpl → 프록시로 감쌈
주입: MemberServiceImpl.mapper ← 프록시[interface MemberMapper]
꺼낸 빈: 프록시[interface MemberService]
[TX] BEGIN
[TX] COMMIT컨테이너는 두 단계로 일합니다. 먼저 @Transactional 이 있는 객체를 프록시로 감싸 공개용 목록에 넣고, 그다음 각 객체의 @Inject 필드에 공개용 빈을 넣습니다. get() 은 항상 공개용을 돌려주므로 호출자는 반드시 프록시를 거칩니다. Spring 의 @Autowired 가 프록시를 주입하는 이유가 이 순서에 있습니다.