pile·
백엔드·스캐터랩스캐터랩 (이루다)·

루다 서버에서 루다의 개인화 메시지를 처리하는 방법

스캐터랩 핑퐁팀이 코틀린·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·하이버네이트 프록시 함정 레퍼런스.
스캐터랩
스캐터랩 (이루다) 블로그
원문은 여기서 이어서 읽을 수 있어요
원문 읽기
읽음 (0)

이 글과 비슷한

  1. 백엔드·github-engGitHub Engineering·

    조기 종료를 없애야 벡터화된다 — 메모리 속도 소스 코드 케이스 폴딩

    GitHub의 코드 검색 엔진 Blackbird는 480TB 이상의 소스 코드를 인덱싱하기 전 모든 바이트에 case folding을 적용한다. 이 글은 Rust로 구현한 case folding을 메모리 대역폭 한계(45+ GiB/s)까지 끌어올린 두 가지 반직관적 최적화를 상세히 다룬다. 핵심은 루프 조기 종료(break) 제거로 LLVM 벡터화를 유도하고, UTF-8을 디코딩하지 않고 바이트 공간 산술만으로 fold를 수행하는 것이다.

    #rust#unicode#simd+2
  2. 백엔드·여기어때 (GC컴퍼니)여기어때 (GC컴퍼니)·

    트랜잭션 스크립트에서 숙소 메타 + 가격 계산 모듈로 — 전시 아키텍처 개선기 (2/3)

    여기어때 전시개발팀이 숙소 상세(PDP) API를 해부한 결과, 코드상으로는 DB 호출 3번처럼 보이던 요청이 실제로는 MongoDB $lookup 체인으로 컬렉션을 19회 접근하는 구조였다. 이 트랜잭션 스크립트 방식의 핵심 문제는 "aggregation이 I/O를 가린다"는 점으로, 독립적인 쿼리 10개가 단일 파이프라인에 직렬화되어 병렬화 기회를 잃고, 가격 때문에 거의 안 바뀌는 이미지까지 매 요청마다 읽어야 하는 읽기 증폭이 발생했다. V3에서는 "조회 시점 조립"을 "쓰기 시점 사전 조립"으로 전환하고, 화면별로 복제되던 가격 계산 로직을 goodsprice 단일 모듈로 수렴했다. 4개 API(PLP/PDP/RDP/ILP)의 반복 마이그레이션은 Claude Code skill로 절차를 고정하고 쉐도잉 + 동일성 검증으로 안전망을 마련하는 방식으로 진행됐다.

    #architecture#migration#caching+2