🥳 200만 유저의 친구 ‘이루다’ 기술로 AI 캐릭터를 자유롭게 만들어보세요 ‘핑퐁 스튜디오’ 보러가기

Tech

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

루다 서버에서 루다의 개인화 메시지를 처리하는 방법과, 그 과정에서 주의가 필요했던 점들에 대한 글입니다.

정우영 조한용 | 2023년 10월 19일 | #Engineering

안녕하세요. 이번 글에서는 코틀린과 JPA 하이버네이트 구현체를 사용하는 루다 서버에서 어노테이션을 이용해 개인화 메시지를 처리하는 방법을 먼저 소개합니다. 또한 이 방법에서 메시지를 처리할 때 주의가 필요했던 부분에 대해서 다루고자 합니다.

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

루다는 여러 친구들에게 개인화된 메시지를 통해 더 좋은 관계 경험을 제공합니다. 개인화된 메시지를 처리하기 위해서 루다는 발화에서 루다와 대화 중인 사용자의 속성(이름, 성별, 나이, 직업 등등)을 사용합니다. 그래서 루다는 사전에 정의된 토큰을 발화하고, 이를 루다 서버에서 실제 사용자의 속성값으로 치환합니다. 예를 들어 루다가 ‘민수‘라는 이름을 가진 사용자와 대화 중 “안녕 민수! 점심은 먹었어?“라는 발화를 하고자 하는 상황을 가정해 보겠습니다. 루다는 실제로 ‘민수’ 대신 이름에 해당하는 토큰인 [name] 이라는 토큰을 이용해서 “안녕 [name]! 점심은 먹었어?”라고 발화하고, 이 토큰을 서버에서 사용자의 이름인 ‘민수’로 치환합니다.

루다 발화, 더 나아가 임의의 문자열에서 동적으로 사용자 속성 토큰을 치환하는 로직은 루다 백엔드 서버에서 꽤 빈번하게 쓰입니다. User 라는 하나의 케이스 뿐만 아니라 다양한 클래스에서도 동일 로직을 활용할 수 있도록 치환 가능한 필드에 치환 대상 토큰을 어노테이션과 함께 명시했습니다.

@Retention(AnnotationRetention.RUNTIME)
annotation class Attribute(val token: String)

@Entity
@Table(name = "user")
class User(
    @Id
    val id: Int = 0,
    @Column @Attribute(token="[age]")
    val age: Int,
    @Column @Attribute(token="[name]")
    val name: String,
    @Column @Attribute(token="[job]")
    val job: String,
) {
    @Column @Attribute(token="[gender]")
    fun getGender(): String {
        return "여자"
    }
}

Kotlin Reflection을 활용한 유틸 함수 만들기

이제 이렇게 명시된 클래스를 활용해서 치환 가능한 토큰과 그 값을 가져올 수 있도록 유틸 함수를 만들어 봅시다. 우선 각 필드에 명시된 어노테이션을 활용하기위해 코틀린의 Reflection을 활용할 수 있습니다. 코틀린은 클래스에 명시된 속성 값들을 가져오기 위해 여러 함수를 지원합니다.

주어진 예시 클래스에서 어노테이션이 프로퍼티와 함수에 모두 붙어 있으므로, 두 값들을 모두 가져올 수 있어야 합니다. 또한 상속을 고려해서 부모 클래스에 정의된 내용들도 모두 가져올 수 있어야 합니다. members를 활용하면 앞에서 언급한 조건에 맞게 속성들을 가져올 수 있습니다. 각 속성에서 findAnnotation 메서드를 이용하여 원하는 어노테이션만을 가져올 수 있습니다. 이 내용들을 적용해서 아래와 같은 유틸 함수를 구현하였습니다.

fun getAttributes(arg: Any): Map<String, String?> {
    return arg::class.members
        .mapNotNull {
            val prop = it.findAnnotation<Attribute>() ?: return@mapNotNull null
            prop.token to it.call(arg)?.toString()
        }.toMap()
}

문자열을 치환하기 위한 용도이니 모두 문자열로 치환해서 통일하였고, 맵 구조체를 활용하여 치환 가능한 토큰과 실제 값을 연결하였습니다. 지금까지의 내용대로면 저희가 의도 했던 대로 동작을 해야할 것 같지만 실제 결과는 아래와 같습니다.

getAttributes(User(age = 22, name = "루다", job = "대학생"))
// expected: {[age]=22, [name]=이루다, [job]=대학생, [gender]=여자}
// actual: {[gender]=여자}

함수에 붙은 결과는 잘 가져왔지만, 프로퍼티에 붙은 값들은 가져오지 못했는데 왜 이런 상황이 발생한 걸까요?

Kotlin annotation use-site target

이 현상은 코틀린 어노테이션 위치의 모호함에서부터 출발합니다. 코틀린에서는 기본 생성자에서 멤버 변수를 선언하는 문법을 통해 더욱 간결하게 클래스와 생성자를 선언할 수 있습니다. 하지만 멤버 변수의 선언과 생성자의 선언이 결합된 문법 때문에 어노테이션의 위치가 불명확한 문제가 발생합니다. 그렇다면, 위의 예에서 명시된 @Attribute 어노테이션은 어디에 적용되는 걸까요?

코틀린 Annotation use-site targets 문서를 참고하면 이 질문에 대답을 찾을 수 있습니다. use-site target 문법을 활용하면 자바 바이트 코드를 생성할 때 어노테이션의 위치를 명시적으로 지정할 수 있습니다.

위와 같이 다양한 케이스를 지원하고 있습니다. 어노테이션에 use-site target을 명시하지 않으면, @Target 어노테이션에 명시한 타겟에 따라 적용이 되며, 여러 타겟이 적용 가능할 경우 다음과 같은 순서로 적용가능한 케이스가 적용됩니다.

  1. param (생성자 파라미터)
  2. property (코틀린에서 지원하는 프로퍼티)
  3. field (자바 멤버 변수)

따라서 위에서 제시한 예시 코드에서는 1번 케이스를 따라 생성자 파라미터에 @Attribute 어노테이션이 적용 되었습니다. members 함수는 해당 클래스에 선언된 모든 함수와 프로퍼티들을 불러오고, 생성자 파라미터에 적용된 어노테이션을 가져올 수 없어 개선이 필요합니다.

@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Attribute

getAttributes(User(age = 22, name = "이루다", job = "대학생"))
// expected: {[age]=22, [name]=이루다, [job]=대학생, [gender]=여자}
// actual: {[age]=22, [name]=이루다, [job]=대학생, [gender]=여자}

위와 같이 @Target 어노테이션을 활용하여 타겟을 프로퍼티와 함수로 정의하면 원하는대로 동작하는 결과를 얻을 수 있습니다.

Hibernate proxy

이번에는 JPA 하이버네이트 구현체를 사용하는 스프링 애플리케이션에서 개발할 때 주의가 필요한 부분입니다. 하이버네이트 프록시에서 또한 코틀린에서의 주의점과 유사하게 어노테이션의 존재 여부를 달리할 수 있는 하이버네이트 프록시 객체의 특성을 위주로 살펴봅니다.

먼저 아래에 사용자와 루다, 다온이, 세중이 등 친구와의 관계 매핑을 위한 Relation 엔티티를 정의했습니다. 내부에 User 엔티티 타입의 필드를 @OneToOne 연관관계 매핑을 통해 포함하고 있는 것이 주요한 특징입니다. 그리고 새로 정의한 Relation 엔티티와 함께 아래와 같은 간단한 예제 코드를 작성했습니다. 아래처럼 코드를 실행하면 “민수, 기억 나? 2023년 9월 14일이 우리 처음 만난 날이야!”를 출력하는 것으로 예상해 볼 수 있겠습니다.

@Entity
@Table(name = "relation")
class Relation(
    @Id
    val id: Int = 0,
    @OneToOne(fetch = FetchType.LAZY)
    val user: User,
    @Column
    val relatedDate: LocalDate,
) {
    @Attribute(token="[relatedDate]")
    fun getRelatedDateString(): String {
        val formatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일")
        return relatedDate.format(formatter)
    }
}

fun getFirstMeetRemindMessage(relation: Relation): String? {
    val attributes = getAttributes(relation) + getAttributes(relation.user)
    val name = attributes["name"]
    val relatedDate = attributes["relatedDate"]

    if (name == null || relatedDate == null) {
        return null
    }

    return "${name}, 기억 나? ${relatedDate}이 우리 처음 만난 날이야!"
}

interface RelationRepository : Repository<Relation, Int> {
    fun findById(id: Int): Relation?
}

/**
 * Relation(
 *     id = 0,
 *     user = User(age = 20, name = "민수", job = "대학생"),
 *     relatedDate = LocalDate.of(2023, 9, 14)
 * )
 **/

val relation = relationRepository.findById(0)!!
print(getFirstMeetRemindMessage(relation))
// expect: 민수, 기억 나? 2023년 9월 14일이 우리 처음 만난 날이야!
// actual: null

코드는 예상처럼 동작할까요? 직관적으로는 하이버네이트 프록시 객체도 일반 객체와 동일한 타입을 가졌으니 당연히 같은 동작을 하리라 짐작할 수 있습니다. 하지만 실제로 코드를 실행해보면 예상과는 조금 다르게 null을 출력하는 것을 확인할 수 있습니다. 위 코드에서 namerelatedDate 변수의 값을 각각 확인해보면 relatedDate 필드에는 예상했던 값이 있고 name 필드에는 값이 없음을 알 수 있는데요. 이런 현상의 원인은 하이버네이트 프록시에서 찾을 수 있습니다.

User 엔티티와, Relation 엔티티를 가지고 또 다른 로직을 전개한다고 가정해 봅시다. 요구사항에 따라 위 로직과는 다르게 Relation#user에 대한 참조 없이도 이후 로직을 전개할 수 있는 경우도 있을 것 입니다. 이런 경우에도 데이터베이스에서 Relation#user 까지 함께 조회하면 리소스 낭비가 발생합니다. 리소스 낭비를 해결하기 위해 JPA에서는 연관관계 매핑 시에 fetchType을 함께 지정할 수 있도록 합니다. fetchType을 LAZY 로 지정한 경우에 엔티티를 조회하면 즉시 Relation#user 를 함께 조회하지 않고, 이후 로직에서 Relation#user 를 참조할 때 조회해 리소스 낭비를 없앨 수 있습니다. 여기서 한 가지 의문이 듭니다. 위 상황에서 Relation 엔티티 조회와 Relation#user 조회 사이에 간극이 존재하는데, 그럼 그동안 Relation#user 필드에는 어떤 값이 들어있을까요?

바로 여기서 하이버네이트 프록시가 그 간극을 채우는 역할을 합니다. 연관관계 매핑 시에 fetchType이 LAZY로 지정된 경우 실제 엔티티 객체 대신 하이버네이트 프록시 객체가 필드값을 차지합니다. 그리고 앞서 언급한 것처럼 실제 엔티티의 값이 필요할 때 쿼리를 실행해 프록시 내부에서 엔티티의 값을 초기화하는 방식으로 동작합니다.

위에서 언급했던 대로, 하이버네이트가 프록시 객체를 구현하는 방법에 대해 이해하면 이 현상의 원인을 알 수 있습니다. 하이버네이트는 엔티티 클래스의 상속받은 클래스를 생성하는 방식으로 프록시 객체를 구현합니다. 그리고 내부에 원본 객체를 필드로 가지고 필요에 따라 내부 객체의 값을 반환하도록 동작합니다. 따라서 하이버네이트 프록시 객체는 엔티티 클래스의 직접적인 객체가 아닌, 엔티티 클래스를 상속받은 자식 클래스의 객체입니다. 마찬가지로 저희가 어노테이션을 붙인 필드나 메서드 역시 하이버네이트 프록시 클래스가 아닌, 부모 클래스에 위치하게 됩니다.

프록시 객체 내부의 원본 객체에서 어노테이션이 붙은 필드와 메서드를 탐색하면 원래 의도대로 코드가 동작합니다. Hibernate#unproxy 메서드를 이용해 손쉽게 프록시 객체에서 원본 객체를 가져올 수 있습니다.

fun getProxyProperties(arg: Any): Map<String, String> {
    return getProperties(Hibernate.unproxy(arg))
}

마치며

이번 포스트에서는 코틀린과 JPA를 이용하는 환경에서 커스텀 어노테이션을 사용할 때, 주의해야하는 사항들에 대해 소개했습니다. 핑퐁팀에서는 더 높은 생산성을 가질 수 있도록 코드를 개선하기 위해 노력하고 있습니다. 이번 이슈도 Kotlin 환경으로의 전환과 더불어 개인화 메시지 치환 로직을 개선하는 과정에서 발생하고 파악한 내용입니다.

핑퐁팀은 더 좋은 개발 환경을 만들기 위해 노력하고 있고, 문제 상황이 발생하면 원인을 잘 파악하고 팀 내부에 공유해 같은 실수를 하지 않도록 돕습니다. 저희와 함께 이런 개발 문화를 만들어 나가고 싶은 분들은 채용 공고를 참조해주세요!

스캐터랩이 직접 전해주는
AI에 관한 소식을 받아보세요

능력있는 현업 개발자, 기획자, 디자이너가
지금 스캐터랩에서 하고 있는 일, 세상에 벌어지고 있는 흥미로운 일들을 알려드립니다.