Two-way Databinding error on AndroidX

안드로이드 스튜디오 3.2 환경에서 Kotlin + DataBinding를 사용하는 프로젝트에서 양방향 데이터바인딩을 사용할 때 DataBinding Error가 나타난다.

다음은 해당 문제가 나올 때의 로그이다.

[kapt] An exception occurred: android.databinding.tool.util.LoggedErrorException: Found data binding errors.
****/ data binding error ****msg:Unknown class: java.lang.String
file:C:\Users\windsekiurn\StudioProjects\---\app\src\main\res\layout\history_popup_activity.xml
loc:150:12 - 172:71
****\ data binding error ****

	at android.databinding.tool.processing.Scope.assertNoError(Scope.java:112)
	at android.databinding.annotationprocessor.ProcessDataBinding.doProcess(ProcessDataBinding.java:109)
	at android.databinding.annotationprocessor.ProcessDataBinding.process(ProcessDataBinding.java:73)
	at org.jetbrains.kotlin.kapt3.base.ProcessorWrapper.process(annotationProcessing.kt:99)
	at com.sun.tools.javac.processing.JavacProcessingEnvironment.callProcessor(JavacProcessingEnvironment.java:794)
	at com.sun.tools.javac.processing.JavacProcessingEnvironment.access$200(JavacProcessingEnvironment.java:91)
	at com.sun.tools.javac.processing.JavacProcessingEnvironment$DiscoveredProcessors$ProcessorStateIterator.runContributingProcs(JavacProcessingEnvironment.java:627)
	at com.sun.tools.javac.processing.JavacProcessingEnvironment$Round.run(JavacProcessingEnvironment.java:1033)
	at com.sun.tools.javac.processing.JavacProcessingEnvironment.doProcessing(JavacProcessingEnvironment.java:1198)
	at com.sun.tools.javac.main.JavaCompiler.processAnnotations(JavaCompiler.java:1170)
	at com.sun.tools.javac.main.JavaCompiler.processAnnotations(JavaCompiler.java:1068)

이 이슈는 어제의 이슈 와 비슷하게 Databinding를 사용하는 라이브러리와 충돌이 일어나서 발생한 이슈같은데, 살짝 원인이 다른 것 같다.

이 이슈의 원인은 양방향 데이터바인딩에서 뷰 -> 뷰모델쪽, 즉 InverseBindingAdapter 가 androidx 패키지로 생성되지 않아 발생하는 문제였던 것 같다.

해결 방법

안드로이드 스튜디오 버전을 3.3 Beta1로 올리고 gradle 플러그인 버전을 3.3.0-beta01, gradle-wrapper 버전을 4.10.1 로 올리면 해결된다.

참조 링크: https://issuetracker.google.com/issues/116361870

Cannot resolve DataBindingComponent after migrate to AndroidX

문제

Databinding + Kotlin을 사용하는 Android Studio 버전을 3.2로 올리고 ‘Migrate to AndroidX’ 를 실행하면 DataBindingComponent 객체를 찾을 수 없다면서 아래와 같은 로그가 뜬다.

error: cannot generate view binders java.lang.NullPointerException
  	at android.databinding.tool.store.SetterStore.getMatchingMultiAttributeSetters(SetterStore.java:615)
  	at android.databinding.tool.store.SetterStore.getMultiAttributeSetterCalls(SetterStore.java:502)
  	at android.databinding.tool.BindingTarget.resolveMultiSetters(BindingTarget.java:220)
  	at android.databinding.tool.LayoutBinder.<init>(LayoutBinder.java:257)
  	at android.databinding.tool.DataBinder.<init>(DataBinder.java:58)
  	at android.databinding.tool.CompilerChef.ensureDataBinder(CompilerChef.java:114)
  	at android.databinding.tool.CompilerChef.sealModels(CompilerChef.java:348)
  	at android.databinding.annotationprocessor.ProcessExpressions.writeResourceBundle(ProcessExpressions.java:233)
  	at android.databinding.annotationprocessor.ProcessExpressions.onHandleStep(ProcessExpressions.java:128)
  	at android.databinding.annotationprocessor.ProcessDataBinding$ProcessingStep.runStep(ProcessDataBinding.java:212)
  	at android.databinding.annotationprocessor.ProcessDataBinding$ProcessingStep.access$000(ProcessDataBinding.java:197)
  	at android.databinding.annotationprocessor.ProcessDataBinding.doProcess(ProcessDataBinding.java:98)
  	at android.databinding.annotationprocessor.ProcessDataBinding.process(ProcessDataBinding.java:73)
  	at org.jetbrains.kotlin.kapt3.base.ProcessorWrapper.process(annotationProcessing.kt:99)

해당 DataBindingComponent 객체는 프로젝트에 Databinding이 활성화 되있고, @BindingAdapter 어노테이션을 사용할 경우에 생성되는 클래스이다.
이 문제는 AndroidX 를 적용한 프로젝트 A와 A가 의존성을 가지는 라이브러리 프로젝트에서 AndroidX를 사용하지 않아 발생하는 문제로, 쉽게 말해 패키지 충돌이라 보면 된다.

실제로 AndroidX를 사용하지 않을 경우 DataBindingComponent 의 패키지는 android.databinding.DataBindingComponent 이고, AndroidX를 사용할 경우에는 androidx.databinding.DataBindingComponent가 된다.

해결 방법

Studio 에서 Project View를 ‘Project’에 맞춘 다음, ‘External Libraries’ 항목을 클릭하면 해당 프로젝트가 의존하고 있는 모든 라이브러리 프로젝트가 나온다. 이 라이브러리 중 Databinding를 활성한 라이브러리를 찾고, 그 중 AndroidX를 사용하지 않는 라이브러리를 찾으면 된다.

이 방법이 번거로운 경우에는, 해당 모듈의 build.gradle 에서 하나씩 지워보면서 확인해도 된다.

본인 같은 경우에는 두 개의 라이브러리가 이 대상이 되었는데, 하나는 프로젝트의 코어 프로젝트인 BaseApp 와 다른 하나는 WindSekirun/BindAdapters 에서 발생하고 있었다.
따라서 두 개의 라이브러리 코드에서 AndroidX로 마이그레이션하고, 마이그레이션한 버전을 다시 해당 프로젝트에 적용시키니 적용이 되었다.

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")
}

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

메모사항

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