1편에서 언급했던 KOIN을 드디어 도입해보자.
Spring의 DI 컨테이너에 익숙한 개발자라면 Koin이 얼마나 가볍고 직관적인지 금방 느낄 수 있을 것이다.
먼저 build.gradle.kts에 Koin 관련 의존성을 추가했다.
koin-ktor는 Ktor와의 통합을 위한 라이브러리이고, koin-logger-slf4j는 Koin의 내부 동작을 로깅하기 위해 추가했다.
개발 초기에는 DI 컨테이너가 제대로 동작하는지 확인하기 위해 로깅을 켜두는 것이 디버깅에 도움이 된다.
build.gradle.kts에서 implementaion
val kotlin_version: String by project
val logback_version: String by project
val koinVersion: String by project
plugins {
kotlin("jvm") version "2.1.21"
id("io.ktor.plugin") version "3.2.2"
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.21"
}
group = "com.example"
version = "0.0.1"
application {
mainClass = "io.ktor.server.netty.EngineMain"
}
repositories {
mavenCentral()
}
dependencies {
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-server-content-negotiation")
implementation("io.ktor:ktor-server-call-logging")
implementation("io.ktor:ktor-serialization-kotlinx-json")
implementation("io.ktor:ktor-server-netty")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-server-default-headers:3.2.2")
testImplementation("io.ktor:ktor-server-test-host")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
implementation("io.insert-koin:koin-ktor:${koinVersion}")
implementation("io.insert-koin:koin-logger-slf4j:${koinVersion}")
}
gradle.properties에서 koinVersion=4.0.3으로 설정했는데, 이는 2024년 말 기준으로 안정적인 버전이다.
Kotlin과 Ktor 버전도 함께 업데이트했는데, 특히 Kotlin 2.1.21은 성능 개선사항이 많아서 권장한다.
configureKoin() 함수에서 흥미로운 부분이 있었는데 프로젝트 생성시 Sample Code를 선택하게 되면 기본적으로 제공되는 내장된 함수가 아니라 Application의 확장함수인 것을 알 수 있다.
Ktor 프레임워크에서는 모듈화를 권장하는데 아무래도 관심사 분리, 코드 재사용을 염두에 두고 있어서 그런 것 같다.
원래 Ktor에서는 install(Koin)을 사용하는 것이 일반적이지만, 여기서는 startKoin을 직접 사용했다.
Koin.kt
import com.example.di.koinModule
import io.ktor.server.application.*
import org.koin.core.context.GlobalContext.startKoin
import org.koin.logger.slf4jLogger
fun Application.configureKoin() {
startKoin {
slf4jLogger()
modules(
koinModule
)
}
}
처음에 install(Koin) 방식으로 선언할 경우 오류가 발생했는데, 람다의 receiver 타입이 명확하지 않아서 컴파일러가 타입 추론에 실패했던 것이 원인이었다.
이는 Kotlin의 타입 시스템과 Ktor의 플러그인 시스템 간의 충돌 때문인데, startKoin을 사용하면 이런 문제를 피할 수 있다.
실제로 안드로이드 개발을 해본 사람이라면 startKoin이 더 익숙할텐데 안드로이드에서도 Application 클래스에서 동일한 방식으로 Koin을 초기화하기 때문이다.
Application.kt
import com.example.plugins.configureDefaultHeader
import com.example.plugins.configureKoin
import com.example.plugins.configureMonitoring
import com.example.plugins.configureRouting
import com.example.plugins.configureSerialization
import io.ktor.server.application.*
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
@Suppress("unused")
fun Application.module() {
configureKoin()
configureSerialization()
configureMonitoring()
configureRouting()
configureDefaultHeader()
}
메인 함수에서는 EmbeddedServer 방식을 사용하지 않고 EngineMain.main(args)를 직접 호출하도록 변경했다.
이는 1편에서 언급했던 auto-reload 기능을 제대로 활용하기 위함이다.
처음에 configureKoin()을 가장 나중에 호출하도록 했는데 오류가 발생하기도 했다.
왜냐하면 모듈 순서가 중요한 것이 다른 플러그인들에서 Koin으로 주입받은 의존성을 사용할 가능성이 있기 때문이다.
이제 본격적인 API 개발을 위한 기반을 다져보자.
먼저 클라이언트와의 통신에서 중요한 HTTP 헤더부터 설정했다.
DefaultHeaders.kt
import io.ktor.http.HttpHeaders
import io.ktor.server.application.*
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
import java.time.Duration
fun Application.configureDefaultHeader() {
install(DefaultHeaders) {
val oneYearInSeconds = Duration.ofDays(365).seconds
header(
name = HttpHeaders.CacheControl,
value = "public, max-age=$oneYearInSeconds, immutable"
)
}
}
Cache-Control 헤더를 임의의 1년으로 설정해놓았는데 정적 리소스(이미지, CSS, JS 등)에 대한 캐싱 전략이다.
immutable 키워드는 해당 리소스가 절대 변경되지 않는다는 것을 브라우저에게 알려주어 불필요한 재검증 요청을 방지한다.
물론 실제 API 응답에는 이런 긴 캐시 시간이 적절하지 않을 수 있다.
나중에 라우팅별로 다른 캐시 전략을 적용하는 방법도 고려해야 할 것 같다.
Hero API를 만들기 위해서 안드로이드 진영에서 흔하게 사용되는 Repository 패턴을 도입했다.
데이터를 담는 data class를 선언할 때도 느꼈지만 kotlin에 익숙한 안드로이드 개발자라면 매우 익숙해서 편했다.
@Serializable 어노테이션도 달았다.
Hero.kt
@Serializable
data class Hero(
val id: Int,
val name: String,
val image: String,
val about: String,
val rating: Double,
val power: Int,
val month: String,
val day: String,
val family: List<String>,
val abilities: List<String>,
val natureTypes: List<String>
)
ApiResponse.kt
@Serializable
data class ApiResponse(
val success: Boolean,
val message: String? = null,
val prevPage: Int? = null,
val nextPage: Int? = null,
val heroes: List<Hero> = emptyList()
)
ApiResponse에서 페이지네이션 필드(prevPage, nextPage)를 포함해서 모바일 앱에서 무한스크롤이나 페이징 처리할 때 필수적인 메타 데이터를 넣었다.
HeroRepository.kt
import com.example.models.ApiResponse
import com.example.models.Hero
interface HeroRepository {
val heroes: Map<Int, List<Hero>>
val page1: List<Hero>
val page2: List<Hero>
val page3: List<Hero>
val page4: List<Hero>
val page5: List<Hero>
suspend fun getAllHeroes(page: Int = 1): ApiResponse
suspend fun searchHeroes(name: String): ApiResponse
}
page1, 2... 이런 식으로 호출 테스트를 위해서 임시적으로 하드코딩해두었다.
KoinModule.kt
val koinModule = module {
single<HeroRepository> {
HeroRepositoryImpl()
}
}
Repository는 보통 상태를 가지지 않는 순수한 데이터 액세스 레이어이므로 애플리케이션 전체에서 하나의 인스턴스만 있으면 되기 때문에 싱글톤으로 처리했다.
이제 실제 API 엔드포인트를 구현해보자.
Ktor의 라우팅 DSL(Domain Specific Language)을 활용해서 깔끔하게 구조화했다.
AllRoutes.kt
import com.example.models.ApiResponse
import io.ktor.http.HttpStatusCode
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.response.respond
fun Route.getAllHeroes() {
get("/boruto/heroes") {
// 클라이언트가 엔드포인트에서 오류를 발생하면 엘비스 연산자로 기본 1페이지 제공
try {
val page = call.request.queryParameters["page"]?.toInt() ?: 1
require(page in 1..5)
call.respond(message = page)
} catch (e: NumberFormatException) {
call.respond(
message = ApiResponse(success = false, message = "Only Numbers Allowed"),
status = HttpStatusCode.BadRequest
)
} catch (e: IllegalArgumentException) {
call.respond(
message = ApiResponse(success = false, message = "Heroes not Found"),
status = HttpStatusCode.BadRequest
)
}
}
}
Route의 확장 함수로 엔드포인트를 정의해서 라우팅 로직을 기능별로 분리하기로 했다.
Spring Boot의 @Controller와 비슷한 역할을 하지만 더 함수형 프로그래밍 스타일에 가깝다.
AllRoutes.kt 일부 발췌
val page = call.request.queryParameters["page"]?.toInt() ?: 1
require(page in 1..5)
쿼리 파라미터 처리에서 엘비스 연산자(?:)를 사용해서 null-safe하도록 했고 역시 Java와는 다른 Kotlin의 우수성을 다시 한 번...
클라이언트가 page 파라미터를 전달하지 않으면 자동으로 1페이지를 반환한다.
require()는 조건이 false일 때 IllegalArgumentException을 던지므로, 아래 catch 블록에서 적절히 처리할 수 있다.
AllRoutes.kt 일부 발췌
catch (e: NumberFormatException) {
call.respond(
message = ApiResponse(success = false, message = "Only Numbers Allowed"),
status = HttpStatusCode.BadRequest
)
} catch (e: IllegalArgumentException) {
call.respond(
message = ApiResponse(success = false, message = "Heroes not Found"),
status = HttpStatusCode.BadRequest
)
}
NumberFormatException은 클라이언트가 숫자가 아닌 값을 전달했을 때, IllegalArgumentException은 페이지 범위를 벗어났을 때 발생한다.
다만 두 경우 모두 HttpStatusCode.BadRequest를 사용했는데, 개인적으로는 페이지 범위를 벗어난 경우는 존재하지 않는 리소스에 접근하고 있다는 것을 클라이언트쪽에 인지시키기 위해서 HttpStatusCode.NotFound가 더 적절할 것 같다.
AllRoutes.kt 일부 발췌
call.respond(message = page)
현재는 페이지 번호만 반환하고 있는데, 아마 Repository 구현이 완료되면 실제 Hero 데이터를 반환하도록 변경될 것 같다.
Root.kt
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
fun Route.root() {
get("/") {
call.respond(
message = "Welcome to Boruto API!",
status = HttpStatusCode.OK
)
}
}
API의 헬스체크나 기본 정보 제공용 엔드포인트다.
실제 운영 환경에서는 여기에 API 버전 정보, 사용 가능한 엔드포인트 목록 등을 포함하는 것이 좋다.
일단 에러 처리를 try catch 구문으로 분기 처리했지만 공통 에러 핸들러나 예외 처리 미들웨어라는 것을 활용해볼 예정이다.
다음 편에서는 Koin으로 주입받은 HeroRepository를 통해서 실제 데이터를 return받을 수 있도록 해보겠다.