Delegation in Kotlin

Image from https://project-management.com/12-rules-of-delegation/

위임(Delegation) 이란 하나의 인스턴스에서 다른 인스턴스로 권한을 부여하는 것이다. 즉, 클래스 간의 상속을 이용해 고정적 관계를 형성하는 것이 아닌 가변적으로 형성하여 상속 기능을 구현할 수 있는 대체제라고 표현할 수 있다. 흔히 이런 점이 위임을 강력한 기제로 작용하는 이유가 된다.

위임은 발신 객체를 수신 객체로 명시적으로 전달하는 것으로 이루어 질 수 있는데, 이를 명시적 위임(Explicitly Delegation) 이라 부르며 객체지향의 개념을 가지고 있는 언어를 사용하고 있을 경우에 쉽게 구현이 가능하다. 또는, 명시적으로 전달하지 않고 암시적으로 전달할 수 있는데 이 것을 암시적 위임(Implicitly Delegation) 으로 부른다.

글에서 사용할 언어인 Kotlin 은 일반적인 명시적 위임 말고도 언어적 기능으로 클래스 위임(Implementation by Delegation) 과 위임 프로퍼티(Delegated Properties) 을 지원하는데, 이 글에서는 명시적 위임과 클래스 위임, 위임 프로퍼티에 대해 각각 알아보고 어떤 목적엔 어떤 위임을 사용해야 하는지 알아보려 한다.

Explicitly Delegation

상기했듯이 명시적 위임은 발신 객체를 수신 객체로 명시적으로 전달하는 것을 의미한다. 이는 객체지향의 개념을 가지고 있는 언어라면 구현이 가능한 위임 패턴이다. 즉, 발신 객체를 수신 객체로 전달하는 과정에서 모든 과정이 코드로서 나타낸 경우를 의미한다. 다음은 명시적 위임의 예제이다.

interface Coffee {
    fun build()
}

open class CoffeeImpl : Coffee {
    override fun build() {
        println("Delivered!")
    }
}

class CoffeeMaker(private val coffee: Coffee) : Coffee {
    override fun build() {
        coffee.build()
    }
}

fun main(args: Array<String>) {
    println("Welcome to Pyxis Cafe!")
    println()

    val cappuccino = CoffeeImpl()
    val cappuccinoMaker = CoffeeMaker(cappuccino)
    cappuccino.build()
}

CoffeeCoffeeMaker란 클래스가 있다고 가정해본다.  Coffee 는 특정한 메서드를 가진 인터페이스로 여기에서는 만들어진 커피를 고객에게 전달하는 build() 메서드가 있다.

CoffeeMaker 클래스는 Coffee 클래스 자체를 파라미터로 가지고 있다. 즉, 여기서 CoffeeCoffeeMaker 사이에 위임 통로(Delegation link) 가 발생한다. build() 메서드에서는 받은 Coffee 파라미터의 build() 메서드를 실행해 고객에게 전달되게 한다.

마지막으로, 실제로 코드가 실행될  main 메서드에선 Coffee 의 새 인스턴스인 카푸치노(cappuccino)를 생성하고, CoffeeMaker 의 인스턴스를 만들어 build() 를 실행한다. 생성한 카푸치노 인스턴스를 CoffeeMaker 클래스의 생성자에 삽입하는데, 이 때 두 객체 간의 위임이 실행된다. 즉, Coffee 의 기능을 CoffeeMaker 에 위임하는 것이다.

실제로 실행해보면 다음과 같은 결과가 나온다.

Coffee 는 상속 가능한 클래스이므로 Coffee 를 상속하는 하위 클래스를 만들어서  CoffeeMaker 에 위임시킬 수 있다.

...

class Latte : CoffeeImpl() {
    override fun build() {
        println("Latte Delivered!")
    }
}

fun main(args: Array<String>) {
    ...

    val latte = Latte()
    val latteMaker = CoffeeMaker(latte)
    latteMaker.build()
}

상기했듯이 위임 패턴이 강력한 기제로 작용되는 이유는 Coffee 의 인스턴스나 하위 클래스를 수 없이 만들어도 CoffeeMaker 자체는 한 가지 형태만 필요하다는 것이다. 즉, 어떤 형태가 와도 유연하고 강력하게 대응할 수 있다.

특히, 상기 예제에서는 언어직 기능을 사용하지 않았기 때문에 객체지향 언어라면 모두 사용이 가능하다. 다만, 명시적 위임에 문제가 있다면 CoffeeMakerCoffee 의 모든 public 메서드를 구현하여 원래의 Coffee 메서드를 수동으로 실행해야 하므로 작성해야 되는 코드가 늘어난다는 것이다. (이를 객체지향에서는 Forwarding 라 부른다.)

이를 해결하기 위해 언어적으로 발신 객체를 수신 객체로 전달하는 과정을 작성할 수 있는 기능이 필요한데, 여기에서 나오는 것이 Kotlin 에서 지원하는 클래스 위임이다.

Implementation by Delegation

클래스 위임은 명시적 위임 방식으로 구현함에 있어 상용적인 코드를 제거할 수 있는 기능이다. 즉, 명시적 위임에서 CoffeeMakerCoffee 의 모든 public 메서드를 구현하여 원래의 Coffee 메서드를 수동으로 실행해주었다면, 클래스 위임에서는 이러한 작업을 컴파일러가 대신하여 작업하는 것이다.

클래스 위임은 기반이 되는 객체가 interface일 때, 인터페이스 이름 by 변수 이름으로 사용할 수 있다. 즉, 여기에서는 class CoffeeMaker(coffee: Coffee) : Coffee by coffeeCoffeeMaker 클래스를 구성할 수 있다. 실제로 컴파일 되었을 때에는 명시적 위임과 같이 CoffeeMaker 클래스가 Coffee 클래스를 구현하고 coffee 의 메서드를 실행시키는 코드를 확인할 수 있는데, 컴파일된 코드는 다음과 같다.

여기까지 내용을 정리하자면, 명시적 위임은 클래스를 기반으로 발신 객체를 수신 객체로 명시적으로 전달하는 것이다. 그리고 클래스 위임은 언어적 기능을 사용하여 전달하는 기능을 컴파일러에 위임한 것이다.

Delegated Properties

위임은 클래스에 기반해서 이루어지는 것 뿐만 아니라, 특정 변수를 대상으로 하여 기능을 위임하는 역할도 있다.  이러한 역할을 가진 역할을 위임 프로퍼티 라고 부른다.

예를 들어, 해당 속성이 변경되었을 때에 대한 알림을 주는 Delegates.observable() 나 변수에 처음 접근할 때에 주어진 함수로 변수를 초기화 하고, 다음부터 접근할 때에는 설정된 값을 반환하는 lazy() 가 있다.

위임 프로퍼티를 사용하기 위해서는 특정 조건을 만족해야 하는데, 그 조건은 다음과 같다.

  • Val를 대상으로 할 경우
    • getValue 메서드 구현 필요
    • operator fun getValue(thisRef: Any?, property: KProperty<*>): T
    • thisRef: 해당 속성의 소유자와 동일하거나 상위 타입이어야 함
    • property: kotlin.reflect 패키지에 있는 클래스이며, 해당 속성에 대한 정보를 가짐 (이름, 반환 타입 등)
  • Var를 대상으로 할 경우
    • getValue, setValue 메서드 구현 필요
    • operator fun setValue(thisRef: R, property: KProperty<*>, value: T)
    • value: 변수에 설정될 값

위 조건을 만족하는 클래스를 만들었을 경우 val(var) 이름 : 타입 by 위임 클래스 이름 으로 사용이 가능하며, 만일 Delegation 이라는 클래스를 만들었다면 val name: String by Delegation() 으로 사용할 수 있다.

예제로, JVM 환경에서 GC가 발생되기 전 까지는 참조를 유지하고, 발생하면 회수되는 WeakReference 에 대해 만들었는데, 다음은 그 코드이다.

class WeakReferenceDelegation<T>(private var value: WeakReference<T?>) {

    operator fun getValue(thisRef: Any?, property: KProperty<*>): T? {
        return value.get()
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) {
        this.value = WeakReference(value)
    }
}

fun <T> weak(value: T) = WeakReferenceDelegation(WeakReference(value))

그리고 사용법은 다음과 같다.

var coffee: Coffee? by weak(null)

fun main(args: Array<String>) {
    coffee = CoffeeImpl()
    coffee?.build()
}

기존에 WeakReference 를 사용하려면 해당 변수의 타입을 WeakReference<Coffee> 로 선언하고, 값을 설정할 때 coffee = new WeakReference<>(coffee) 로 설정했던 것에 대비해 위임 프로퍼티를 사용하면 변수 타입을 그대로 유지하고, 값을 설정하는 것 또한 일반적인 방법으로 할 수 있다.

단, 글을 작성하는 일자(2018-10-13) 기준으로 coffee 객체가 mutable 하여 non-null 로 cast 되지 않는 버그가 있는데, 이는 방법을 더 찾아봐야 될 것 같다. 예상하기로는 Kotlin 1.3 에 추가될 contract 로 해결될 것으로 보인다.

정리

이번 글에서는 Kotlin 에서 사용이 가능한 세 가지 위임에 대해 살펴보았다. 다소 예제가 부족한 느낌은 있지만 정리하면서 그동안 혼동이 있었던 사실에 대해 이해한 것 같았다.

Project. NEW GAME! 경과 정리 (2018. 07. 30)

도입

Project. NEW GAME! 는 게임물관리위원회 의 게임 심의 결과 정보를 이용해 매일마다 새로운 게임 정보를 Cloud Messaging 로 알려주는 앱을 만드는 프로젝트로, 7월 28일 낙성대역의 어느 카페에서 직장 동료와 코딩을 하다가 떠오른 토이 프로젝트이다.

이 프로젝트에서 구현하는 기능은 다음과 같다.

  • 게임 심의 목록 리스트 / 검색 / 상세보기
  • 매일 웹사이트를 크롤링하여 새로운 정보가 있을 경우 Firebase Cloud Messaging 로 알림
  • 네이티브 안드로이드 앱
  • 백엔드 서버

이 프로젝트를 통해 추구하고자 하는 목적은 다음과 같다.

  • Backend stack: Ktor + Exposed + HikariCP + PostgreSQL 에 대한 탐구 및 정리
  • Frontend (Mobile) stack: Kotlin + MVVM + RxJava + Databinding 에 대한 탐구 및 정리
  • Java 코드를 전혀 쓰지 않는 100% Kotlin Backend + Frontend 프로젝트

그래서 오늘의 작업은?

  • HikariCP 를 이용해 로컬 PostgreSQL 10 연동
  • Ktor DSL를 이용해 API 설계 반영
  • Kotlin exposed 를 이용해 batchInsert 작업 실행
  • Kotlin coroutine를 이용해 결과를 기다린 후 응답

HikariCP를 이용해 로컬 PostgreSQL 10 연동

HikariCP는 JDBC (클라이언트가 어떻게 DB에 연결할 것인지 정의한 자바의 API) 컨넥션 풀을 관리하는 라이브러리이다. JDBC 나 ODBC 는 흔하게 많이 사용하는 Tomcat, Netty 등에서 데이터베이스를 연결하기 위해 사용하는 기능으로, ‘드라이버’ 라는 개념으로 이어진다.

기존에는 자주 C3P0나 bone 를 이용했던 모양이지만, 최근 이 HikariCP 를 이용하는 것이 추세로 보여 기왕 사용하는 것이면 차후에 발생할 컨넥션 풀 관리를 위해 사용하게 되었다.

사용되는 형태는 사용자가 일정 기능을 요청하면, 그 기능을 Exposed 를 이용하여 SQL Query 를 만든 다음, Exposed 로 실행하는 SQL Query 를 HikariCP 에서 가지고 있는 컨넥션 풀을 통해 PostgreSQL 로 전송하고, 쿼리의 결과를 HikariCP 로 통해 Exposed, 즉 웹 프레임워크 단에 전달해 최종적으로 유저에게 반환되는 형태이다.

HikariCP 를 이용하기 위해서는 HikariDataSource 란 객체의 인스턴스를 사용할 필요가 있는데, 이 HikariDataSource 의 인스턴스 초기화에는 HikariConfig 라는 클래스의 인스턴스가 필요하다. 이름이 뜻하는 그대로 HikariCP 에 사용되는 설정 인스턴스가 필요한데, 프로그래밍 적으로 이를 초기화하는 방법도 있고 파일을 통해 초기화하는 방법이 있다. 여기에서는 파일을 통해 초기화하는 것으로 한다.

Intellij CE 기준으로 src/main/resources 에 hikari.properties 파일을 만들고, 아래 설정을 적는다.

dataSourceClassName=org.postgresql.ds.PGSimpleDataSource
dataSource.user=
dataSource.password=
dataSource.databaseName=
dataSource.portNumber=5432
dataSource.serverName=localhost

여기서는 PostgreSQL 와 연동할 것이기 때문에 PGSimpleDataSource 라는 클래스 이름을 적어주었으나, 사용하는 DB에 따라 다르게 적어주면 된다. 관련 내용은 HikariCP 의 README 내 항목을 참고하면 된다.

적당히 user, password, databaseName 를 적고, 만든 파일을 사용해 HikariConfig 클래스와 HikariDataSource의 인스턴스를 만든다.

val config = HikariConfig("/hikari.properties")
val ds = HikariDataSource(config)

그리고 만들어진 HikariDataSource 의 인스턴스를 Exposed 의 클래스인 DataBase.connect 에 넘기면 연결이 수립되고, 인증에 실패하지 않는 이상 연결이 된다.

Database.connect(ds)

연결이 되면 2018-07-30 22:44:33.479 [HikariPool-1 housekeeper] DEBUG com.zaxxer.hikari.pool.HikariPool – HikariPool-1 – Pool stats (total=10, active=0, idle=10, waiting=0) 라는 로그가 나오게 되는데, 이는 현재 HikariPool 이 가지고 있는 컨넥션 풀의 상태를 주기적으로 보여주는 것이다. 현재는 아무런 작업을 하지 않으니 대기 상태의 컨넥션 풀 10개만 있다.

Ktor DSL를 이용해 API 설계 반영

Ktor 는 DSL 을 이용해 API 를 작성하는데, 주로 파이프라인에 기능을 ‘설치’한다는 의미를 가지고 있다. 이는 압축, CORS(Cross-Origin Resource sharing), 라우팅 등을 하나의 기능으로 보고 파이프라인에 설치하는 것 이라고도 말할 수 있다. 만들어진 파이프라인은 embeddedServer 라는 메서드를 통해 실행이 가능하며, 여기에서 사용할 Http client, 포트, 호스트 등을 정할 수 있다.

ktor 에서 사용이 가능한 http client 와 기타 설정 방법은 이 글에서 다루지 않으므로 Ktor 도큐먼트를 참고하는 편이 더 자세하다.

API는 라우팅 이란 기능에 정의하는데, 주로 route(“/”) 메서드를 활용하게 된다. 첫 번째 파라미터는 path 로, API 의 경로를 의미한다. /를 적어주면 http://0.0.0.0:port/ 를 나타내고, /api/users 를 적어주면 http://0.0.0.0:port/api/users 를 나타내게 된다.

route 밑에는 get, post, put, delete, update 등의 RESTful 를 구성하기 위해 필요한 메서드를 부를 수 있으며, 각각의 block 이라는 고차함수 파라미터에 행동을 정의함으로서 해당 path 로 된 API 가 불렸을 때 해당 고차함수가 실행되는 방식이다.

API 의 파라미터나 요청에 대한 결과를 보낼 때에는 call 라는 필드를 사용하며, 파라미터를 얻을 때에는 call.parameters 를, 요청에 대한 결과를 보낼 때 에는 call.respond 메서드를 사용한다. call.respond 에는 별도로 상태 코드를 정의하지 않는 이상 어떤 형태(Any) 라도 보낼 수 있는데, 파이프라인 내부에 설치된 ContentNegotiation 를 이용하여 respond 를 인코딩 하여 보낸다. 이 프로젝트에서는 서버 요청/결과를 json 형식으로 받을 것이기 때문에, Gson 을 이용할 것이다.

지난 글에서 구현한 UserController 를 이용해 유저에 대한 API 설계를 작성한 것이다.

fun Application.main() {
    install(Compression)
    install(CORS) {
        anyHost()
    }
    install(DefaultHeaders)
    install(CallLogging)
    install(ContentNegotiation) {
        gson {
            setDateFormat(DateFormat.LONG)
            setPrettyPrinting()
        }
    }

    initDB()

    install(Routing) {

        val logger = KotlinLogging.logger { }

        route("/api") {
            route("/users") {
                val userController = UserController

                get("/{id}") {
                    val id = call.parameters["id"]!!.toInt()
                    call.respond(userController.detail(id))
                }

                post("/") {
                    val user = call.receive()
                    logger.debug { user }
                    call.respond(userController.create(user))
                }

                put("/{id}") {
                    val id = call.parameters["id"]!!.toInt()
                    val user = call.receive()
                    call.respond(userController.update(id, user))
                }

                delete("/{id}") {
                    val id = call.parameters["id"]!!.toInt()
                    call.respond(userController.delete(id))
                }
            }
        }
    }
}

install(Routing) 은 공통 코드로, 여러 기능을 파이프라인에 설치하고 initDB 라는 메서드를 부르는데, 이 메서드는 위 챕터에서 말했던 HikariDataSource 의 인스턴스를 만들고 DataBase.connect 에 넘긴 다음, DB 에 필요한 스키마가 없을 경우 생성하는 기능이 담겨있다.

fun initDB() {
    val config = HikariConfig("/hikari.properties")
    val ds = HikariDataSource(config)
    Database.connect(ds)

    transaction {
        SchemaUtils.create(Games, Users)
    }
}

중간에 call.receive 라는 문구가 보이는데, 이는 받은 파라미터를 유형 매개변수로 선언한 클래스로 디코딩하여 얻는 메서드이다. 즉, 아래와 같이 request 를 보내면 된다.

이 request 를 보내면 UserController 를 통해 PostgreSQL 에 기록되고, 기록된 데이터가 response 로 내려오게 된다.

마지막으로 만들어진 파이프라인을 embeddedServer 메서드를 이용해 실행하면 된다.

fun main(args: Array<String>) {
    val server = embeddedServer(Netty, 8080, module = Application::main)

    server.start()
}

이 main 메서드를 Intellij 에서 실행시킬 경우 API 서버가 실행되며, 원하는 API 클라이언트로 정의한 API를 실행하면 관련된 로그가 터미널에 나오면서 작동할 것이다.

Kotlin exposed 를 이용해 batchInsert 작업 실행

28일에 만들었던 Controller 객체에서는 insert 를 하기 위해 Games.insert 란 메서드를 사용했으나, 만일 리스트를 insert 해야 할 경우 각각에 대해 insert를 하는 것은 컨넥션 관리에 매우 좋지 않다. 따라서 insert 대신 batchInsert 를 사용해 insert 를 한번에 호출하는 코드를 작성하려 한다.
다만, insert 와 크게 바뀐 부분이 거의 없다고 보면 된다.

fun insert(games: List<Game>): List<Game> {
     transaction {
         Games.batchInsert(games) { game ->
             this[Games.id] = game.id
             this[Games.name] = game.name
             this[Games.applicant] = game.applicant
             this[Games.regNo] = game.regNo
             this[Games.rate] = game.rate
             this[Games.regDate] = game.regDate
             this[Games.reason] = game.reason
             this[Games.platform] = game.platform
             this[Games.genre] = game.genre
         }
     }

     return games
 }

파라미터와 반환형에 Games 대신 List 를 사용하고, it[Games.id] 대신 this[Games.id] 를 사용한다. it 는 insert 될 Game의 객체를 의미하기 때문에 스키마에 접근하기 위해서는 it 가 아닌 BatchInsertStatement, 즉 this 를 사용한다. 또한, it.id 라고 표현할 수도 있으나 가독성을 위해 game 라고 별도로 정의한 것도 차이점이라고 할 수 있다.

Kotlin coroutine를 이용해 결과를 기다린 후 응답

위에서 사용한 Ktor DSL 를 이용해 작성하는 것은 크게 어렵지는 않았으나, 때로는 API 요청에 대한 결과를 줄 때에 시간이 오래 걸려서 blocking 한 다음에 나온 결과를 클라이언트에 돌려줄 필요가 있다.

이 때, 주로 Callback 를 사용하는 것이 일반적이나 지난 글에서도 언급했듯이 Ktor 및 Exposed 는 Kotlin corountine 를 기반으로 돌아가기 때문에 Callback 안에서는 해당 메서드가 호출되지 않는다.

따라서 작업에 대한 결과를 얻을 수 있는 코루틴의 async { } 를 이용하여 작업을 진행하고, 작업이 완료되기 까지 대기하고, 끝나면 작업에 대한 리스트를 클라이언트에 돌려주는 코드 작성이 필요하다.

여기서 조금 해멘 점이 많은데, 차근히 정리해보면 다음과 같다.

먼저 첫번째로, 작업을 생성하기 위해서는 launch { } 나 async { } 등의 메서드를 사용해야 되는데, launch 는 결과값을 기대하지 않는 작업이고, async 는 결과값을 기대하는 작업으로 그 역할이 다르다.

정확히는 launch 메서드는 Job 라는 클래스를 반환하며, async 는 Deferred 를 반환하는데, launch 의 설명에는 Launches new coroutine without blocking current thread and returns a reference to the coroutine as a [Job]. (스레드를 블로킹하지 않는 새로운 코루틴을 실행시키고, 코루틴의 참조를 Job 로서 반환한다.) 라고 되어있으며 async 의 설명에는 Creates new coroutine and returns its future result as an implementation of [Deferred]. (새로운 코루틴을 생성하고 미래에 다가올 결과를 Deferred 의 구현체로서 반환한다.) 라고 되어있다.

결론적으로는 Deferred 또한 Job 를 상속하기 때문에 두 개의 방식은 비슷하나 여기에서는 사이트 크롤링을 하고, DB 에 기록한 리스트 결과물을 받을 필요가 있으므로 asycnc 를 사용한다.

두 번째로, join() 메서드의 사용이나 이 join() 은 Job 클래스에 속해 있는 메서드 중 하나로 명시적으로 작업이 모두 끝날 때 까지 코루틴을 중단하는 역할을 한다. 참고로, 메인 코루틴에서 이 메서드를 사용할 경우에는 작업이 완료되기 까지 멈춰있게 된다.

세 번째로, async 의 결과물은 job.getCompleted() 로 가져올 수 있는데, 완료되지 않았을 경우에는 Exception 을 발생시킨다.

위 사항을 적절히 검토해서 낸 결과물은 다음과 같다.

val job = async {
    val parseResult = parser.start(index, count)

    val list = gameController.selectAll().map { it.id }
    val newGames = parseResult.filter { !list.contains(it.id) }

    gameController.insert(newGames)
}

job.join()
if (job.getCompleted().isNotEmpty()) {
    call.respond(HttpStatusCode.Created, job.getCompleted())
} else {
    call.respond(HttpStatusCode.InternalServerError, "Failed to Parse")
}

지금도 올바른 처리 방법인지는 파악이 안 가지만, 의도대로 모든 크롤링이 끝날 때 까지 클라이언트는 무기한 대기하고 있으며 끝나면 생성된 리스트가 결과물로 내려온다.

메모사항

이제 백엔드의 경우 푸시 전송 및 검색 기능만 대응하면 어느정도 완료가 될 것 같다.

Project. NEW GAME! 경과 정리 (2018. 07. 28)

도입

Project. NEW GAME! 는 게임물관리위원회 의 게임 심의 결과 정보를 이용해 매일마다 새로운 게임 정보를 Cloud Messaging 로 알려주는 앱을 만드는 프로젝트로, 7월 28일 낙성대역의 어느 카페에서 직장 동료와 코딩을 하다가 떠오른 토이 프로젝트이다.

이 프로젝트에서 구현하는 기능은 다음과 같다.

  • 게임 심의 목록 리스트 / 검색 / 상세보기
  • 매일 웹사이트를 크롤링하여 새로운 정보가 있을 경우 Firebase Cloud Messaging 로 알림
  • 네이티브 안드로이드 앱
  • 백엔드 서버

이 프로젝트를 통해 추구하고자 하는 목적은 다음과 같다.

  • Backend stack: Ktor + Exposed + HikariCP + PostgreSQL 에 대한 탐구 및 정리
  • Frontend (Mobile) stack: Kotlin + MVVM + RxJava + Databindign 에 대한 탐구 및 정리
  • Java 코드를 전혀 쓰지 않는 100% Kotlin Backend + Frontend 프로젝트

그래서 오늘의 작업은?

  • Jsoup를 이용해 게임물관리위원회 사이트 크롤링 코드 작성
  • 데이터베이스 테이블 설계
  • API 설계
  • 데이터베이스 테이블 설계를 바탕으로 DAO / Controller 객체 작성
  • Database Connection Pool 관리 모듈인 HikariCP 연동 준비 (하는중)
  • API 설계를 바탕으로 Ktor DSL 를 이용하여 Routing 코드 작성 (하는중)

이 중, (하는중) 이란 태그가 붙은 것은 금일에 100% 처리하지 못한 것으로, 100% 완료되었을 때 항목을 설명할 것이다.

게임물관리위원회 사이트 크롤링 코드 작성

대상 사이트는 2개인데, 하나는 게임물 심의 리스트인 https://www.grac.or.kr/Statistics/GameStatistics.aspx 와 각 항목에 대한 결정내용 팝업인 https://www.grac.or.kr/Statistics/Popup/Pop_ReasonInfo.aspx?74300c05da019ba330ba2344ddc871f6a6c813a6a053e8e5ec12581d53453bb0 이다. 주로 아래 작업을 반복하면 된다.

  1. 첫 번째 사이트에서 목록은 최신순으로 10개씩 나오니, 이 10개를 가져와 각각의 분류번호와 고유 ID 를 가져온다.
  2. 고유 ID 를 가지고 결정내용 팝업 뒤 주소에 붙여 전체 주소를 만든다음, 결정내용 팝업에서 필요한 정보를 모두 가져온다.

여기서 주의해야 되었던 점은 분류번호와 고유 ID는 서로 다르고, 고유 ID 의 경우 게임명에 걸리는 링크에서 regex로 추출해야 된다는 점이다.

나머지 작업의 경우 평이하게 진행되었기 때문에 특이점 외에는 작성할 필요가 없는 것 같다.

데이터베이스 테이블 설계

이 프로젝트의 백엔드의 경우 아래와 같은 구조를 가진다고 생각하면 된다.

PostgreSQL(DB) <-> HikariCP (Connection Pool) <-> Exposed (SQL Layer) <-> Ktor(Web Framework)

이 구조가 정석인지는 판단하기 어렵지만, 실제 백엔드 코드에서는 ORM을 이용해 데이터 모델을 가져오고 보낼 것이므로 데이터 클래스를 만들어 정의하는 것으로 설계를 했다.

필요한 데이터 클래스는 2종으로, 개별 사용자에 대한 푸시 키와 푸시 수신 여부를 가지고 추후에 로그인 기능이 붙었을 때를 위한 Member 클래스 와 게임물 심의 정보에 대한 정보를 가지고 있을 Game 클래스가 필요하다.

먼저 Member 클래스에 대한 데이터 클래스는 다음과 같다.

package com.github.windsekirun.newgame.data

data class User(var no: Int = 0,
                var deviceId: String = "",
                var registrationKey: String = "",
                var pushFlag: Int = 1)

그 다음 Game 에 대한 데이터 클래스이다.

package com.github.windsekirun.newgame.data

data class Game(var id: String = "",
            var name: String = "",
            var applicant: String = "",
            var regNo: String = "",
            var rate: String = "",
            var regDate: String = "",
            var reason: String = "",
            var platform: String = "",
            var genre: String = "")

API 설계

API 는 크게 두 가지 카테고리로 분리된다. 하나는 유저에 대한 /api/user 이고, 나머지 하나는 게임물에 대한 /api/game 이다. 모든 API 는 RESTful 하게 설계되었으며, 각각의 목적에 따라 get, post, put, delete 가 구현된다.

초기 설계에서 필요하다고 여겨진 유저 관련 API는 다음과 같다.

  • POST /api/user

    • 파라미터는 deviceId, deviceType, registrationKey

    • 결과: SUCCESS / FAILED

  • PUT /api/user

    • flag 업데이트 (true, false)

    • 결과: SUCCESS / FAILED

다음은 게임물 관련 API 이다.

  • GET /api/game

    • 파라미터: platform, name, rate, startDay, endDay, manufacturer

    • 결과: List<Game>

  • GET /api/game/id

    • 결과: Game

이 것 말고도 각각에 대한 delete 나 외부에서 크롤링 요청을 보낼 수 있게 하는 request 도 생각하였으나, 크롤링 코드나 DAO / Controller 가 어떻게 나올지는 모르기에 일단 이렇게 작성했다.

DAO / Controller 객체 작성

앞서 작성한 데이터 클래스는 Ktor <- Exposed 만 생각하였기에, 반대로 Ktor -> Exposed 의 역할도 필요한데, 그것이 바로 DAO 객체이다. DAO 객체 에서 primaryKey 나 autoincrement 등을 설정하기 때문에, 스키마의 역할을 한다고 보면 된다.

기본적으로 데이터 클래스와 선언법은 크게 다르지 않으나, data class 가 아닌 object 로 선언하고 Table 라는 객체를 상속하는 것, 그리고 각 변수에 text() 나 integer(), varchar() 의 메서드를 사용하여 대입해야 된다는 점이 다르다.

먼저 데이터 클래스 User에 대한 DAO 객체인 Users 객체이다.

package com.github.windsekirun.newgame.dao

import org.jetbrains.exposed.sql.Table

object Users : Table() {
    val no = integer("no").primaryKey().autoIncrement()
    val deviceId = text("deviceId")
    val registrationKey = text("registrationKey")
    val pushFlag = integer("pushFlag")
}

그 다음, 데이터 클래스 Game 에 대한 DAO 객체인 Games 객체이다.

package com.github.windsekirun.newgame.dao

import org.jetbrains.exposed.sql.Table

object Games : Table() {
    var id = text("id")
    var name = text("name")
    var applicant = text("applicant")
    var regNo = text("regNo")
    var rate = text("rate")
    var regDate = text("regDate")
    var reason = text("reason")
    var platform = text("platform")
    var genre = text("genre")
}

Controller 객체는 실제로 DB에 create 하고 update, delete 같은 행동을 하는 클래스를 말한다. 일반적으로 DB에 접근한다는 것은 asynchronous 한 작업이기 때문에, Exposed 와 Ktor 둘 다 코루틴에 기반하고 있다.

여기서는 기본적인 crud 작업인 list, detail, create, update, delete 만 구현하는 Controller 객체를 만드려 한다.

먼저 User 객체에 대한 UserController 객체이다.

package com.github.windsekirun.newgame.controller

import com.github.windsekirun.newgame.dao.Users
import com.github.windsekirun.newgame.data.User
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction

object UserController {

    fun list(): ArrayList<User> {
        val values: ArrayList<User> = arrayListOf()
        transaction {
            Users.selectAll().map {
                values.add(it.buildMemberObject())
            }
        }
        return values
    }

    fun detail(no: Int): User {
        return transaction {
            Users.select { Users.no eq no }
                    .map { it.buildMemberObject() }
                    .first()
        }
    }

    fun create(user: User): User {
        val memberNo = transaction {
            Users.insert {
                it[Users.deviceId] = user.deviceId
                it[Users.registrationKey] = user.registrationKey
                it[Users.pushFlag] = user.pushFlag
            }.generatedKey
        }

        return user.copy(no = memberNo!!.toInt())
    }

    fun update(no: Int, user: User): User {
        transaction {
            Users.update({ Users.no eq no }) {
                it[Users.deviceId] = user.deviceId
                it[Users.registrationKey] = user.registrationKey
                it[Users.pushFlag] = user.pushFlag
            }
        }
        return User(no, user.deviceId, user.registrationKey, user.pushFlag)
    }

    fun delete(no: Int) {
        transaction {
            Users.deleteWhere { Users.no eq no }
        }
    }

    private fun ResultRow.buildMemberObject() = User(no = this[Users.no],
            deviceId = this[Users.deviceId],
            registrationKey = this[Users.registrationKey],
            pushFlag = this[Users.pushFlag])

}

여기서 몇 가지 포인트를 볼 수 있는데, Users.no 와 실제 User 객체를 와리가리(?) 하는 것이 보이고, Users.no eq no 라는 좀 신기한 형태가 보인다.

첫 번째로, Users 객체는 User 객체의 DAO 객체로, 위에서도 언급했다싶이 User 테이블의 스키마 역할을 한다. 따라서 명시적으로 해당 스키마에 대한 실제 객체의 property 를 매칭할 필요가 있어, autoincrement 로 선언한 id를 제외한 나머지 파라미터들에 대해서 전부 매칭을 해주게 된다. 또한, delete 를 제외한 나머지들에 대해서 User 객체를 반환하고 있는데, 이는 User 객체를 바로 response 로 내려보내기 위해서다.

두 번째로 Users.no eq no 는 코틀린의 infix 문법을 이용한 것으로서, 기본적인 형태는 (Users.no).eq(no) 이다. eq 는 equal 로 줄임말로 즉 동위 관계를 체크하는 것이다. eq 이외에도 이런 연산자? 가 있는데, 그 목록은 다음과 같다.

  • eq -> =
  • neq => <>
  • less => <
  • lessEq => <=
  • greater => >
  • greaterEq => >=
  • like => LIKE
  • notlike => NOT LIKE
  • regexp => REGEXP
  • notRegexp => NOT REGEXP
  • plus => +
  • minus => –
  • times => *
  • div => /

그 다음 Game 객체에 대한 GameController이다.

package com.github.windsekirun.newgame.controller

import com.github.windsekirun.newgame.dao.Games
import com.github.windsekirun.newgame.data.Game
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction

object GameController {

    fun list(): ArrayList<Game> {
        val values: ArrayList<Game> = arrayListOf()
        transaction {
            Games.selectAll().map {
                values.add(it.buildGameObject())
            }
        }
        return values
    }

    fun detail(id: String): Game {
        return transaction {
            Games.select { Games.id eq id }
                    .map { it.buildGameObject() }
                    .first()
        }
    }

    fun create(game: Game): Game {
        transaction {
            Games.insert {
                it[Games.id] = game.id
                it[Games.name] = game.name
                it[Games.applicant] = game.applicant
                it[Games.regNo] = game.regNo
                it[Games.rate] = game.rate
                it[Games.regDate] = game.regDate
                it[Games.reason] = game.reason
                it[Games.platform] = game.platform
                it[Games.genre] = game.genre
            }
        }

        return game
    }

    fun create(games: List<Game>): List<Game> {
        return games.map { create(it) }
    }

    fun update(id: String, game: Game): Game {
        transaction {
            Games.update({ Games.id eq id }) {
                it[Games.id] = game.id
                it[Games.name] = game.name
                it[Games.applicant] = game.applicant
                it[Games.regNo] = game.regNo
                it[Games.rate] = game.rate
                it[Games.regDate] = game.regDate
                it[Games.reason] = game.reason
                it[Games.platform] = game.platform
                it[Games.genre] = game.genre
            }
        }
        return Game(id, game.name, game.applicant, game.regNo, game.rate,
                game.regDate, game.reason, game.platform, game.genre)
    }

    fun delete(id: String) {
        transaction {
            Games.deleteWhere { Games.id eq id }
        }
    }

    private fun ResultRow.buildGameObject() = Game(id = this[Games.id],
            name = this[Games.name],
            applicant = this[Games.applicant],
            regNo = this[Games.regNo],
            rate = this[Games.rate],
            regDate = this[Games.regDate],
            reason = this[Games.reason],
            platform = this[Games.platform],
            genre = this[Games.genre])

}

다만 아직까지 where 나 page 같은 대응은 없는데, 좀 더 공부하고 나서 구현할 생각이다.