LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


Kotlin으로 서버사이드 개발과 Clova Skill Award 도전!

들어가며

LINE Engineering Blog를 찾아주신 여러분, 안녕하세요? 오늘은 두 명이 공동으로 작성한 포스팅입니다. 저희 소개 먼저 드리겠습니다. LINE에서 게임 플랫폼 개발을 맡고 있는 Kagaya와 동영상 생중계 서비스 LINE LIVE의 Android 앱 개발을 맡고 있는 Akira입니다.

여러분은 Kotlin을 쓰고 계신가요? Kotlin은 JVM에서 돌아가는 정적 타입 프로그래밍 언어입니다. 현재 IntelliJ나 Android Studio 같은 IDE로 유명한 JetBrains사의 주도로 개발되고 있습니다. 작년 Google I/O에서 Android용 공식 개발 언어로 채택되어 화제가 됐었죠. 작년에 Engineering Blog에서도 'LINE Creators Studio 개발에 사용하는 Kotlin 소개'라는 글에서 Android 앱 개발에 Kotlin을 사용하고 있다고 소개한 바 있습니다. LINE에서는 신규 앱은 물론 기존 앱도 적극적으로 Kotlin으로 전환, 리팩터링하는 작업이 한창 진행 중입니다. 메시징 서비스를 제공하는 LINE 앱을 비롯, LINE LIVE나 LINE Creators Studio, AI 어시스턴트의 Clova용 앱 등 많은 Android 앱에서 Kotlin을 도입하고 있습니다.

서버사이드 개발에서도 팀별로 조금씩 Kotlin을 도입하고 있는데요. 이번 포스팅에서는 아래 두 가지 이야기를 통해 서버사이드 Kotlin에 관한 노하우와 유스케이스를 공유할까 합니다. 군데군데 Android 개발과 겹치는 내용도 나옵니다.

  1. Kagaya가 담당하는 LINE GAME 플랫폼의 Kotlin 이용 현황 이야기(#kotlin-at-line-game)
  2. 'LINE 사내 Clova Skill Award'에 저희 둘이 팀으로 참가해서 Kotlin으로 영광의(?) 최우수상을 수상한 이야기(#kotlin-at-clova-skill-award)

Android 앱을 개발하시는 분들도 이번 기회에 Kotlin으로 서버사이드 개발에 도전해 보시는 건 어떨까요? 시작하기 어렵지 않으실 겁니다.

LINE에서 개최하는 개발 콘테스트 LINE BOOT AWARDS 2018에서도 우승 상금 1000만 엔을 걸고 많은 참여를 기다리고 있습니다. Kotlin으로 꼭 한번 도전해 보세요!

LINE GAME 플랫폼에서 서버사이드 개발에 사용된 Kotlin

LINE GAME의 많은 게임들은 외부 개발사나 계열사의 게임 스튜디오에서 제작되고 있습니다. 그들이 게임을 개발할 때 본질이라 할 수 있는 게임 로직 개발에만 더욱 전념할 수 있도록, LINE GAME 플랫폼에서 인증, 소셜 그래프 같은 기본적인 기능이나 프로모션 기능, 커뮤니케이션 관련 기능(사용자 타게팅, 공지 띄우기, 게임 내 게시판/채팅) 등을 제공하고 있습니다. LINE DEVELOPER DAY 2017에서 저희가 하는 일의 일부를 소개드렸었습니다. 플랫폼이라는 특성상 독립적인 구성 요소가 많습니다. 그래서 신규 개발을 스크래치부터 시작하는 경우가 많으며, 그 중 일부는 Kotlin을 사용하고 있습니다.

어떤 API에서 사용하나

어떤 구성 요소에서 Kotlin을 사용하고 있는지 대략 한번 살펴보겠습니다. LINE GAME 플랫폼에선 게임과 LINE을 연동한 사용자를 대상으로 아직 해당 게임을 해 본 적 없는 LINE 친구를 초대하는 기능을 제공하고 있습니다. 이에 더해 올해에는, 플랫폼 자체 알고리즘을 이용해 더욱 '게임을 할 것 같은' 사용자를 추천하는 기능을 릴리스했습니다. 아키텍처를 간단하게 정리하면 다음과 같습니다.

우선 추천에 필요한 설정을 불러오고(1), 사용자의 친구 정보(2) 및 친구들 각각의 프로필 정보를 획득한 뒤(3), 이를 Recommendation API 서버에서 순위를 매겨 사용자에게 반환합니다. (1)~(3)은 네트워크를 통한 접속입니다.

여기서 Recommendation API 서버를 Kotlin과 Spring Boot 1.5로 구현했습니다.

Kotlin의 장점

Java와의 호환성

저희 팀에선 어느 정도 monolithic하게 Gradle 멀티 프로젝트를 구성했으며, 그 중 일부 하위 프로젝트만 Kotlin으로 작성되어 있습니다. 그렇다 보니 Java로 작성한 라이브러리를 Kotlin으로 작성한 애플리케이션에서 호출하는 경우도 있습니다. Kotlin 공식 홈페이지를 보면 다음과 같은 구절이 있는데요.

100% interoperable with Java™

말 그대로 Java와의 호환성이 대단히 높아서 앞에서 언급한 상호 호출도 크게 문제는 없습니다(사실 완전히 없다고는 할 수 없는데, 뒤에서 따로 설명하겠습니다). 또 프로그래밍 언어로서 아직 역사가 짧아서, 순수하게 Kotlin으로 작성된 외부 라이브러리가 많지 않습니다(예를 들어 O/R 매퍼, HTTP 클라이언트 등). 하지만 Java로 작성된 라이브러리를 사용할 수 있기 때문에 Retrofit도, MyBatis도 Kotlin으로 쉽게 이용 가능합니다. 또한 언어 기능으로 Extension을 갖추고 있어서 이미 존재하는 라이브러리가 Kotlin을 지원하게 하는 것은 어렵지 않습니다. JSON 라이브러리인 Jackson의 Kotlin용 추가 모듈인 jackson-module-kotlin이 바로 그런 예라고 할 수 있습니다.

코루틴(coroutine)

Kotlin은 언어 기능으로 코루틴(coroutine)을 지원합니다. 코루틴이란 협력형 멀티태스킹을 실현하는 경량 태스크 및 그 구현체를 말합니다. 일반적인 스레드와 달리 네이티브 스레드에 매핑되지 않고 코루틴 사이를 이동할 때 콘텍스트 스위치(context switch)를 발생시키지 않아 빠른 계산이 가능합니다. 웹 애플리케이션을 구현할 때는 비동기로 처리해야 할 경우가 많은데요. 그럴 때 유용합니다.

본 API 서버에서는 사용자 친구의 프로필 정보를 다른 서비스에서 가져오는 'HTTP 호출(위 그림 속 (3)번)'을 병렬화하기 위해 코루틴을 사용하고 있습니다. 실제 코드를 단순화 해보면 대략 다음과 같습니다.

val ctx = newFixedThreadPoolContext(NUMBER_OF_TRHEADS, "user")

fun getFriends(userIds: List<String>): Map<String, UserProfile> = runBlocking {
    userIds.chunked(PROFILE_MAX_PER_REQUEST).map { subset ->
        async(ctx) {
            mapper.readValue<List<UserProfile>>(khttp.get(URL, params = mapOf("ids" to subset.joinToString(","))).text)
        }
    }.map {
        it.await()
    }.flatMap {
        it
    }.associate {
        it.userId to it
    }
}

캐시적중율도 높으므로 간단한 블로킹 HTTP 클라이언트인 khttp를 사용, CoroutineContext에 자체 스레드 풀 기반의 디스패처를 사용해서 병렬로 동시 실행 요청을 제한해가며 실행합니다. 프로필을 반환하는 API에서 여러 개의 프로필을 담당하는 엔드포인트를 사용하는데요. 여기서 한번에 획득할 수 있는 사용자 ID의 최대치(PROFILE_MAX_PER_REQUEST)에 따라 요청을 분할합니다.

그런데 위 예시가 비동기 처리를 심플하게 구현하고는 있지만, 괜찮은 코루틴 활용 사례라고 할 순 없습니다. 코루틴은 suspend function(중지 함수)이라 불리는 특수한 함수 내부에서 처리를 일단 '중지'하여 CPU를 효과적으로 활용할 수 있다는 점이 특징입니다. 그래서 논블로킹 I/O와 찰떡궁합입니다. 또 코루틴의 목표 가운데 하나는, 비동기 처리를 마치 동기 처리처럼 작성할 수 있다는 특징을 이용해 기존의 비동기 처리 방식인 콜백이나 Future/Promise 기반 구현을 개선하는 것입니다. 따라서 코루틴의 장점은 더욱 복잡한 원격 서비스 호출이 필요한 처리에서 좀 더 확실하게 느낄 수 있습니다. 예를 들어 위 예시에서 Netty 기반 HTTP 클라이언트인 AsyncHttpClient를 사용하면 다음과 같이 구현할 수 있습니다.

val client = asyncHttpClient()

fun getFriends2(userIds: List<String>): Map<String, UserProfile> {
    return userIds.chunked(PROFILE_MAX_PER_REQUEST).map { subset ->
        asyncApiCall(subset.joinToString(","))
    }.map {
        runBlocking {
            it.await()
        }
    }.flatMap {
        it
    }.associate {
        it.userId to it
    }
}

fun asyncApiCall(idsStr: String) = async {
    val response = client.prepareGet(URL).addQueryParam("ids", idsStr).execute().await()
    mapper.readValue<List<UserProfile>>(response.responseBody)
}

suspend fun <T> ListenableFuture<T>.await(): T = suspendCoroutine { continuation ->
    toCompletableFuture().whenComplete { result, exception ->
        if (exception == null) {
            continuation.resume(result)
        } else {
            continuation.resumeWithException(exception)
        }
    }
}

참고로 이 예시에는 동시 요청 수에 제한이 없는데요. AsyncHttpClient의 config에서 설정하거나 코루틴에서 pub-sub과 같은 기능을 제공하는 Channel을 이용하면 됩니다.

위에서 알 수 있듯이, 이미 존재하는 비동기 통신 라이브러리와의 연동은 아주 쉽게 구현할 수 있습니다. 또한 Rx, Reactor, nio, Guava의 ListenableFuture 등 몇몇 라이브러리는 이미 공식적으로 연동되어 있습니다.

코루틴의 기초를 알고 싶을 땐, 공식 가이드나 JetBrains사의 Roman Elizarov씨가 KotlinConf 2017에서 발표한 내용을 참고하시는 게 가장 좋습니다. 또 위와 같은 기존 future 형식, 콜백 형식 라이브러리와의 연동을 포함해서 코루틴이 어떻게 구현되어 있는지에 대해서도 역시 Elizarov씨의 발표 'Deep Dive into Coroutines on JVM'에 자세히 나와 있습니다. 특히 'Deep Dive into Coroutines on JVM'는 코루틴 구현을 이해하고 올바르게 사용하기 위해 꼭 한번 봐두는 편이 좋습니다. Kotlin에서 코루틴이 아직 실험 단계의 기능이기는 하지만, JetBrains사의 Andrey Breslav씨나 앞에 나온 Elizarov씨에 따르면 코루틴은 이미 production ready입니다. experimental로 되어 있는 이유는 향후 사용자의 피드백을 바탕으로 설계를 수정할 가능성이 있기 때문이라고 하네요(참고). Kotlin 1.3에서는 코루틴 기능이 stable이 될 예정입니다. kotlinx.coroutines.experimental 패키지는 최종 버전이 릴리스된 후에도 남는다고 하니, Kotlin이 업데이트된다 해도 한동안은 그대로 사용할 수 있을 것 같습니다.

기타

앞서 말씀드렸던 'LINE Creators Studio 개발에 사용하는 Kotlin 소개' 글을 비롯하여 많은 곳에서 소개하고 있는 Kotlin의 일반적인 장점들은 서버사이드나 클라이언트 개발, 어느 쪽이든 상관없이 거의 모두 누릴 수 있습니다.

  • POJO를 심플하게 작성할 수 있는 data class
    • Lombok 필요 없음
    • API 서버 개발 시 작성하는 DTO 작성 시 매우 유용
  • null-safety
  • val을 통해 재대입 불가능성을 심플하게 정의
  • enum의 확장 개념인 sealed class
  • operator overloading
  • etc...

Java에서 옮겨와 코딩을 해 보면 '오!'하고 놀랄 때가 많습니다. 전 개인적으로 표현식 형태로 되어 있는 try-catch문이나 if문을 사용해보고 너무 편리해서 '오!' 했었습니다. 더 자세한 내용은 공식 문서에서 확인하시기 바랍니다. IDE 개발 업체답게, JetBrains사의 공식 문서는 무척 쉽게 읽을 수 있었습니다.

Kotlin 서버 개발 시 놓치기 쉬운 점

Spring에선 Spring 5/Spring Boot 2.0대 버전부터 공식적으로 Kotlin 지원을 표방하고 있습니다. 그런데 현재 저희 팀에서는 주로 Spring Boot 1.5.x대 버전을 사용하다 보니 Spring MVC 4대 버전을 간접적으로 다루고 있는데요. 몇 가지 문제는 있지만, Spring 4에서도 Kotlin을 사용할 수는 있습니다. Spring과의 연동 등 몇 가지 주의 사항에 대해 알아보겠습니다.

Java에서 Kotlin 호출 시

요청 파라미터 바인딩에 data class(그대로는) 활용 불가

Spring에서는 @RequestMapping 어노테이션(및 그것을 확장한 @GetMapping 등)을 부여한 메서드가 각 엔드포인트에 대응하여, 그 인자에 POJO를 지정하면 파라미터를 POJO에 바인딩할 수 있습니다. 하지만 Spring의 바인딩 기능은 바인딩을 담당하는 ModelAttributeMethodProcesser 클래스의 구현에서도 알 수 있듯, 다음 사항이 충족되어야 합니다.

  1. POJO가 기본 생성자를 갖고 있을 것
  2. 각 필드가 적절한 세터를 갖고 있을 것

따라서 val 속성만 있는 data class 등을 지정하면 java.lang.NoSuchMethodException이 발생하고 맙니다. 1번 문제뿐이라면 공식적으로 제공되는 No-arg compiler plugin으로도 해결할 수 있지만, nullable 형식(String? 등)이 있는데도 이를 사용하지 못하게 되고, 세터가 필요하기 때문에 val도 사용할 수 없습니다. 저희는 현재 DTO에 data class 대신 일반적인 class를 사용하고 optional한 데이터에는 var, required한 것에는 lateinit var를 추가하며, 또 생성자가 아닌 클래스 내부에서 속성을 정의하는 식으로 처리하고 있습니다. 참고로 data class에서도 lateinit var는 사용할 수 있지만, lateinit var는 생성자에서는 쓸 수 없기 때문에 클래스 내부에서 정의해야 하며, 클래스 내부에 정의해 버리면 toString이나 hashCode 등 자동 생성되는 메서드의 제어 대상에서 제외되어 버리는 문제가 발생합니다.

이 문제는 Spring 5.0 대 버전에서는 해결된 상태입니다. 자세한 내용이 궁금하시면 4.0 대 버전과 5.0 대 버전에서 ModelAttributeMethodProcesser 클래스가 어떻게 구현되어 있는지 비교해 보면 이해하는 데 도움이 될 것입니다. 만약 어떤 사정때문에 Spring Boot 1.5.0 대 이전 버전을 사용하는 경우라면, 이 부분만 자체적으로 ArgumentResolver를 구현하는 방식도 생각해 볼 수 있을 것 같습니다.

@Component 같은 어노테이션(그대로는) 사용 불가

Spring은 @Component@Service 등의 어노테이션이 추가된 클래스를 자동으로 Bean으로 등록해 주는데, 이때 내부에서는 dynamic proxy를 이용해서 클래스를 확장합니다. 하지만 Kotlin의 클래스는 디폴트가 모두 final이기 때문에 open 식별자를 명시적으로 덧붙이지 않으면 Spring의 컴포넌트 스캔에 실패합니다. 이에 kotlin-spring이라는 Gradle 플러그인이 준비되어 있으며, 컴파일 시에 자동으로 Spring 확장이 필요한(가능성이 있는) 클래스를 open으로 만들어 줍니다. Spring Initializr를 사용한다면 처음부터 build.gradle에 기술되어 있지만, 풀 스크래치로 애플리케이션 개발에 착수할 때는 주의해야 합니다.

검사 예외(Checked Exceptions)가 없는 Kotlin 특성 상 Java에서 호출 시 try-catch 불가능

Kotlin에는 검사 예외(Checked Exceptions)가 없습니다. 따라서 Kotlin에서 예외를 throw 해도(설령 Java의 검사 예외를 Kotlin에서 throw한 경우라도) Kotlin에서 사용하는 경우라면 꼭 try-catch문일 필요는 없습니다. 반면, Java와 Kotlin을 함께 운용하고 있고 Java에서 Kotlin 코드를 호출하는 경우에는 그냥 두면 try-catch문이 컴파일 에러를 일으킵니다. 이를 막기 위해 추가하는 것이 @Throws 어노테이션입니다.

@Throws(IOException::class)
fun throwSomeException(): String

이는 Spring AOP 등에서 CGLIB를 이용해 클래스를 프록시할 때도 문제가 됩니다. CGLIB에서는 throws절에 포함되지 않는 검사 예외가 throw되면 UndeclaredThrowableException라는 비검사 예외(Unchecked exceptions)가 throw되어 Spring 쪽에서 핸들링이 잘 되지 않습니다. AOP를 이용하고 있다면 조심해야 하는 부분입니다.

Kotlin에서 Java 메서드 호출 시 인자/반환값

이번엔 Kotlin에서 Java 메서드 호출 시 놓치기 쉬운 점을 살펴보겠습니다. Kotlin에는 모든 형식에 nullable type이 존재하는데, Java에는 그런 것이 없기 때문에 Kotlin 쪽에서 보면 Java의 메서드 인자는 모두 platform type(T!)이라 불리는 특수한 형식이 되어 Kotlin의 최대 장점 중 하나인 Null Safety가 엄격히 적용되지 않습니다. 이 문제를 피해 가려면 Java 쪽에서 인자에 @NotNull이나 @Nullable과 같은 힌트를 줘야 합니다. 물론 T!는 T 또는 T?에 캐스팅할 수 있고 호출하는 쪽에서 핸들링하는 것도 가능하기는 하지만, 이는 엄밀한 의미에서 Null Safety라고 할 수 없습니다.

반환값 쪽에서 문제가 되는 것으로는 Mockito를 이용해서 메서드를 모킹할 때 사용하는 Mockito.any() 등의 도우미 함수를 들 수 있습니다. any()는 null을 반환하는 함수이므로 Kotlin에서 non-null로 설정되어 있는 인자에 사용하려고 하면 런타임 에러를 일으킵니다. 이 때문에 저희 팀에서는 작은 도우미 클래스를 준비했습니다.

@Suppress("UNCHECKED_CAST")
class KotlinMockitoHelper {
    companion object {
        fun <T> any(): T {
            return Mockito.any() ?: null as T
        }
    }
}

Kotlin에서는 의견이 분분하지만, generic한 반환값을 갖는 함수는 null check를 할 수 없기 때문에 다음과 같이 non-null한 형식에 대해 다시 한번 null check를 해야 합니다. 다음 코드에선 컴파일러가 이런 null check를 할 필요가 없도록 해두었지만, null check를 빼고 b()를 실행하면 NullPointerException이 발생합니다.

fun <T> a(): T {
    return null as T
}

fun b() {
    val c: String = a()
    if (c != null) { // Your compiler will display some warnings here!
        print(c.length)
    }
}

그런데 어쩌다보니 모킹 같은 경우에는 이게 workaround로 작용한 모양입니다. 참고로, 이 any()의 workaround를 포함해서 다양한 부분에서 Mockito를 Kotlin용으로 개선하기 위한 라이브러리로 mockito-kotlin도 있습니다. 아직까지는 저희 테스트에서 문제가 없어 자체 도우미 함수를 사용하고 있는데, 직접 관리하기가 버거워지면 이를mockito-kotlin을 사용해야 할 것 같습니다.

Kotlin으로 Clova Skill Award에 도전하다

위 영상은 일본어 영상입니다.

저희가 드리고 싶은 이야기는 아래 tech stack으로 Clova 스킬을 개발한 이야기입니다.

  • 언어 : Kotlin
  • Web 프레임워크 : Spring Boot 2.x, WebFlux
  • O/R 매퍼 : Exposed
  • DB : MySQL

사내 Clova Skill Award

드디어 LINE이 제공하는 AI 어시스턴트 Clova에서 사용자가 자신만의 스킬(Extension)을 개발, 공개할 수 있게 되었습니다. 이제 프로그래밍을 통해 Clova에게 원하는 언어로 말하게 하거나 사용자의 응답을 쉽게 취득할 수도 있습니다. 사용자의 응답을 추론하는 것은 Clova Extensions Kit(CEK) 플랫폼의 몫이니, 복잡해 보이는 AI 스피커용 앱 개발도 더 이상 어려운 일이 아닙니다.

그런데 이런 스킬 개발 플랫폼을 일반에게 공개하기 전에, 플랫폼 테스트를 겸해 'Clova 스킬로 재미있는 것을 만들어 보자'는 취지로 사내 콘테스트가 열렸습니다. 총 상금 50만 엔! 포스팅의 두 번째 꼭지를 쓰고 있는 저 Akira와 첫 번째 꼭지를 맡은 Kagaya, 저희 둘은 입사 동기로 평상시에는 전혀 다른 업무를 하고 있습니다. 그런데 역시 동기 중 한 명인 Ryokachii(일본어)씨의 한 마디에 팀을 결성해서 콘테스트에 도전하게 되었습니다. 이번에 개발한 것은 'Cook의 요리 서포트'라는 Clova 스킬입니다. 포스팅 맨 앞에 걸린 동영상을 보시면 이 스킬이 작동하는 모습을 확인하실 수 있습니다. 여러분도 스마트폰으로 레시피를 보면서 요리해 본 경험이 있으실 텐데요. 바로 그럴 때 도움이 되는 스킬입니다. Clova를 LINE bot과 연동시킨 뒤, 만들어 보고 싶은 음식의 레시피를 등록해 두면 Clova가 차근차근 레시피를 읽어 줍니다. 요리할 때 없어서는 안 될 타이머 기능이나 '다시 한 번 읽기' 기능도 당연히 지원됩니다.

이번 장에서는 Kotlin을 이용해서 이러한 스킬을 만드는 방법을 소개합니다. 바로 얼마 전에 Kotlin용 공식 Clova CEK SDK가 릴리스되었지만, 이번에는 SDK없이 API를 구현해 보았습니다. 다른 API나 웹 애플리케이션 개발에도 활용할 수 있는 내용이라 생각합니다. 또 앞에도 적었지만, 서버사이드를 해본 적이 없는 분이라도 꼭 한번 시도해 보셨으면 좋겠습니다. 저도 평상시에는 Android 엔지니어니까요.

구현 방법

  1. 템플릿 작성하기(#template)
  2. 모델 클래스 정의하기(#model-class)
  3. 각종 'Intent'가 담긴 요청 처리하기(#intent)
  4. Exposed 사용하기(#exposed)

1. 템플릿 작성하기

Spring Initalizr로 Kotlin, Gradle, Spring Boot 2.0.4 설정에서 Reactive Web과 MySQL을 의존 관계로 지정하여 다운로드합니다. 이번에는 IntelliJ에 Kotlin 플러그인을 설치해서 작업합니다.

2. 모델 클래스 정의하기

먼저 Clova Extensions Kit(CEK) 문서를 참고하여 CEK 서버에서 보내는 요청, 그리고 이쪽에서 반환할 응답의 각 JSON 항목 모델 클래스를 작성합니다. 이때 Kotlin의 data class와 null-safety가 제 역할을 톡톡히 합니다. data class를 사용하면 모델 클래스를 심플하게 구현할 수 있으며, 문서에서 Optional로 표기되어 있는 필드는 전송될 때와 전송되지 않을 때가 있으므로 val xxx: String?로 하면 nullable임을 안전하게 표현할 수 있습니다. 각 필드의 자세한 내용은 CEK 문서를 참고하시기 바랍니다. 참고로 Kotlin은 Java와 마찬가지로 어노테이션을 사용할 수 있어 Jackson 어노테이션을 활용하고 있습니다. 다음은 실제 코드 중 일부입니다.

// CEK에서 전송되는 요청
data class ClovaRequest(
    val version: String,
    val session: Session,
    val context: Context,
    @JsonTypeInfo(
        use = JsonTypeInfo.Id.NAME,
        include = JsonTypeInfo.As.PROPERTY,
        property = "type",
        visible = true
    )
    @JsonSubTypes(
        JsonSubTypes.Type(value = LaunchRequest::class, name = "LaunchRequest"),
        JsonSubTypes.Type(value = IntentRequest::class, name = "IntentRequest"),
        JsonSubTypes.Type(value = SessionEndedRequest::class, name = "SessionEndedRequest")
    )
    val request: RequestInterface
)

// 실행 요청, Intent(뒤에서 설명함) 요청, 세션 종료 요청 중 하나
interface RequestInterface {
    val type: String
}

// 실행 요청
data class LaunchRequest(override val type: String) : RequestInterface

// Intent 요청
data class IntentRequest(override val type: String, val intent: Intent) : RequestInterface {
    data class Intent(val name: String, val slots: Map<String, Slot>?)
    data class Slot(val name: String, val value: String)
}

// 세션 종료 요청
data class SessionEndedRequest(override val type: String) : RequestInterface

data class Session(val new: Boolean,
                   val sessionAttributes: Map<String, String>?,
                   val sessionId: String,
                   val user: User)

data class User(val userId: String, val accessToken: String?)

data class Context(@JsonProperty("System") val system: System)
data class System(val application: Application?, val user: User, val device: Device)
data class Application(val applicationId: String)
data class Device(val deviceId: String, val display: Display?)
data class Display(val size: String?, val orientation: String?, val dpi: Int, val contentLayer: ContentLayer?)
data class ContentLayer(val width: Int, val height: Int)

3. 각종 Intent가 담긴 요청 처리하기

CEK는 사용자의 발화 내용이 등록된 예문 중 어느 것과 유사한지 분석하고, Intent라는 형식으로 저희 서버에 전송합니다. 가령 이번 경우에는 아래와 같은 Intent를 정의했습니다.

  • 'n번 레시피 시작해 줘'를 처리할 RecipeSelectionIntent
  • 'm분 후에 알려줘'를 처리할 TimerIntent처리해야 하는 Intent를 Kotlin의 sealed class로 정의하면 쉽고 간단하게 코딩할 수 있습니다. 다음은 실제 코딩 내용입니다.
// Intent 정의
sealed class OryoriInternalIntent {
    data class RecipeSelectionIntent(val recipeId: String): OryoriInternalIntent() // 'n번 레시피 시작해 줘' '5번을 만들고 싶어'
    object PreparedIntent : OryoriInternalIntent() // '요리 준비 다 됐어'
    data class TimerIntent(val min: String) : OryoriInternalIntent() // '5분 있다가 알려줘' '타이머 5분'
    object RepeatIntent : OryoriInternalIntent() // '다시 한 번 말해 줘' '한 번 더 읽어 줘'
    object HowToIntent : OryoriInternalIntent() // '헬프' '만드는 법 가르쳐 줘'
    object UnknownIntent: OryoriInternalIntent() // 어느 Intent에도 해당하지 않는 경우
}

// Clova의 Intent에서 위 Intent로 변환```
@Component
class OryoriIntentMapper {
    fun fromIntent(intent: IntentRequest.Intent): OryoriInternalIntent {
        return when (intent.name) {
            "RecipeSelectionIntent" ->
                intent.toInternalIntentWithFirstSlot { OryoriInternalIntent.RecipeSelectionIntent(it) }
            "PreparedIntent" -> OryoriInternalIntent.PreparedIntent
            ...
        }
    }
}

그럼 이제 실제로 HTTP 요청을 받아 응답을 반환하는 부분을 작성해 보겠습니다. 먼저 라우터를 정의합니다. class에 @Configuration 어노테이션을 달아 선언한 뒤 router를 반환하는 메서드를 @Bean으로 선언하면, Spring의 DI 프레임워크가 자동으로 감지(Component Scan)되어, 이 클래스 및 메서드의 반환값을 사용합니다. 아래의 경우 "/"에 JSON의 POST가 도착하면, clovaHandlerhandle 메서드를 호출하고 있습니다. 또 clovaHandler는 생성자 인자를 통해 전달되는데, 뒤에 나오듯 @Component 어노테이션이 부여되어 있기 때문에 자동으로 주입됩니다.

@Configuration
class Router(private val clovaHandler: ClovaHandler) {

    @Bean
    fun apiRouter() = router {
        accept(MediaType.APPLICATION_JSON_UTF8).nest {
            POST("/", clovaHandler::handle)
        }
    }
}

이어서, 위 Router 클래스에 등장한, 실제 요청을 받는 클래스인 ClovaHandler를 정의합니다. Spring의 Webflux를 사용하면 '요청→의도(Intent) 분석→응답'의 흐름을 선언적으로 작성할 수 있습니다. Webflux는 Reactive Streams의 일종의 구현체인 Reactor를 기반으로 합니다. RxJava 등을 써본 적이 있는 분이라면 한번만 봐도 대강 감이 오실 겁니다.

@Component
class ClovaHandler (...) {
    fun handle(request: ServerRequest): Mono<ServerResponse> {
            return request.bodyToMono(ClovaRequest::class.java) // ClovaRequest 클래스에 역직렬화해서 수령하기
                .doOnNext {
                    logger.info("{}", it) // debug용 로깅
                }
                .map {
                    // it는 ClovaRequest
                    // when과 is를 조합할 수 있음
                    when (it.request) {
                        is LaunchRequest -> launchRequestHandler.handle(it.session.user.userId) // 스킬 실행 시의 요청
                        is SessionEndedRequest -> SpeechesSession(SpeechInfo("다음 번 요리할 때 또 불러주세요."))
                        is IntentRequest -> {
                            val oryoriIntent = oryoriIntentMapper.fromIntent(it.request.intent)
                            assistant.assist(oryoriIntent, it.session.user.userId)
                        }
                        else -> throw RuntimeException("Unknown request type is given")
                    }
                }
                .map {
                    // Intent를 바탕으로 Clova로하여금 어떤 말을 하게 할지 응답 반환하기
                    val outputSpeech = OutputSpeech(SpeechType.SPEECH_LIST, it.speeches)
                    ClovaResponse(shouldEndSession = it.endsSession, outputSpeech = outputSpeech)
                }
                .flatMap {
                    // ClovaResponse 객체를 JSON에 직렬화하기
                    ServerResponse.ok() 
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .body(Mono.just(it), ClovaResponse::class.java)
                }
    }
}

4. Exposed 사용하기

사용자 및 레시피 저장에는 MySQL을 사용하지만, 이번 기회에 O/R 매퍼는 100% Kotlin으로 작성된 Exposed를 써보았습니다. Exposed는 JetBrains사가 개발한 경량 SQL 라이브러리입니다. Gradle에 다음 의존성을 추가하면 사용할 수 있습니다.

compile 'org.jetbrains.exposed:exposed:0.10.2' // 개발 시의 버전
compile 'org.jetbrains.exposed:spring-transaction:0.10.2'

Exposed에서는 다음과 같이 Kotlin으로 테이블 스키마를 정의하고 작성할 수 있습니다.

object Users : Table() {
    val id = varchar("id", 255).primaryKey()
    val recipeId = (integer("recipe_id") references Recipes.id).nullable()
    val step = integer("step")
    val createdAt = datetime("created_at")
    val updatedAt = datetime("updated_at")
    val status = varchar("status", 512)
}

create(Users)

id로 사용자를 검색하는 코드는 다음과 같이 작성할 수 있습니다.

fun findUser(userId: String): User? = Users.select { Users.id eq userId }
    .limit(1)
    .map { it.toUser() } // SQL의 실행 결과는 ResultRow로 반환되므로 변환하기
    .firstOrNull()
}
private fun ResultRow.toUser() = Users.rowToUser(this)

// DAO 정의
data class User(val id: String, val recipeId: Int?, val step: Int, val status: String)

또 Exposed를 이용해서 Spring의 @Transactional 어노테이션을 통해 트랜잭션을 관리하기 위해 다음과 같이 Configuration을 정의합니다. @Transactional로 트랜잭션을 관리할 때 사용하는 PlatformTransactionManager에 Exposed가 제공하는 SpringTransactionManager를 지정하는 것인데요. 이렇게 하면 Exposed의 메서드를 사용하면서도 @Transactional을 부여한 메서드 단위로 트랜잭션의 시작, 커밋, (예외 발생 시) 롤백을 할 수 있게 됩니다.

import org.jetbrains.exposed.spring.SpringTransactionManager
import ...
@Configuration
@EnableTransactionManagement
class TransactionConfiguration(val dataSource: DataSource): TransactionManagementConfigurer {
    @Bean
    override fun annotationDrivenTransactionManager(): PlatformTransactionManager =
        SpringTransactionManager(dataSource)
}

Exposed를 사용해 보니 테이블의 정의/작성에서부터 CRUD, JOIN과 같은 SQL도 Kotlin스럽게 작성할 수 있고 형 추론(Type inference)도 가능하다는 점이 참 편리해서 좋았습니다. 다만 Exposed에서 사용하는 각 SQL의 작성 방법을 알고 있어야 하는데 여기에 익숙해지기까지가 힘이 들기는 할 것 같습니다.

LINE에서는 SQL을 그대로 작성할 수 있는 MyBatis를 사용한 프로젝트가 많은데요. MyBatis도 Kotlin으로 사용할 수 있습니다. 여기에 all-open과 no-arg compiler plugin까지 더하면 data class도 DAO에 사용 가능합니다.

data class User(val id: Int? = null, name: String)
val user = User(name = "foo")
insert(user)
user.id != null  // true, DB에서 넘버링된 id가 들어옴!

무척 편리하지만 본래 data class와는 다르게 작동하게 되니 주의해야 합니다.

정리

간단하게나마 Kotlin과 Spring Boot 2를 이용한 Clova 스킬 개발에 대해 전해드렸습니다. 여러분도 한번 만들어 보시면 어떨까요?

글을 맺으며

Kotlin? 재미있습니다. 여러분도 자유 연구 삼아 Kotlin으로 취미 앱을 만들어 본다든가 LINE BOOT AWARDS 2018에 참여해 보세요. 아니면 업무 앱이라도 작은 부분부터 Kotlin으로 바꿔보는 등 다양하게 시도해 보시면 좋겠습니다.