스캐터랩 핑퐁팀이 코틀린·JPA(하이버네이트) 기반 루다 서버에서 "[name]" 같은 토큰을 실제 사용자 속성으로 치환하는 개인화 로직을 어노테이션과 코틀린 리플렉션으로 범용화한 과정을 다룬다. 그 과정에서 코틀린 어노테이션 위치와 하이버네이트 프록시 때문에 값이 조용히 비는 두 함정을 만나 해결했다.
핵심 포인트- 치환 대상 필드에 token을 담은 커스텀 어노테이션(@Attribute)을 붙이고, 리플렉션으로 어노테이션을 모아 토큰→실제값 맵을 만든다.
- 초기 구현은 함수에 붙은 어노테이션만 읽고 생성자 프로퍼티 값은 못 읽어, {[gender]=여자}만 나오는 버그가 났다.
- 원인은 코틀린 기본 생성자 프로퍼티의 어노테이션 use-site target이 param→property→field 순으로 결정돼 의도한 위치에 안 붙었기 때문이다.
- @Target에 PROPERTY·FUNCTION을 명시해 어노테이션 위치를 고정해 해결했다.
- 연관 엔티티가 LAZY면 하이버네이트 프록시가 자리를 차지해 부모 클래스 어노테이션을 못 찾아 값이 null이 되고, Hibernate.unproxy()로 원본을 꺼내 해결했다.
상세 정리- 배경: 루다는 "안녕 [name]! 점심 먹었어?"처럼 토큰이 든 발화를 서버에서 이름·성별·나이·직업으로 치환한다. 여러 클래스에서 재사용하려고 필드마다 @Attribute(token) 어노테이션을 선언했다.
- 리플렉션 유틸: arg::class.members를 순회하며 @Attribute를 찾아 token과 값의 맵을 구성한다. 코틀린 리플렉션의 declaredMembers·memberProperties·functions·members 차이를 활용한다.
- 첫 버그: age·name·job(프로퍼티)은 안 잡히고 getGender()(함수)만 잡혔다. 기대값 {[age]=22,[name]=루다,[job]=대학생,[gender]=여자} 대신 {[gender]=여자}만 나왔다.
- 원인 분석: 코틀린 기본 생성자 프로퍼티는 자바 바이트코드로 갈 때 param·property·field 중 어디에 어노테이션이 붙을지 모호하다. use-site target 미지정 시 param→property→field 순으로 적용돼 리플렉션이 보는 위치와 어긋났다.
- 해결 1: 어노테이션에 @Target(AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION)을 지정해 프로퍼티·함수에 명시적으로 붙게 했다.
- 두 번째 함정: Relation 엔티티가 User를 @OneToOne(fetch=LAZY)로 참조했는데 getRelatedDateString() 치환 결과가 null이었다.
- 원인: 하이버네이트는 엔티티를 상속한 자식 클래스로 프록시를 만들고, LAZY 연관관계엔 실제 엔티티 대신 프록시가 들어간다. 프록시는 자식이라 부모에만 붙은 어노테이션을 리플렉션이 못 찾는다.
- 해결 2: Hibernate.unproxy(arg)로 프록시에서 원본 객체를 추출한 뒤 어노테이션을 읽어 정상 치환했다.
- 교훈: 코틀린 어노테이션 use-site target과 ORM 프록시는 리플렉션 기반 로직에서 값이 조용히 비게 만드는 대표 함정이다. 원인을 파악해 팀에 공유하며 같은 실수를 막았다.
왜 읽나코틀린·JPA에서 어노테이션과 리플렉션으로 메타프로그래밍을 하는 백엔드 개발자에게 use-site target·하이버네이트 프록시 함정 레퍼런스.