먼저 실제 Hero 데이터를 담을 Repository 구현체를 만들어보자. 실제 프로젝트라면 데이터베이스에서 가져오겠지만, 지금은 테스트를 위해 하드코딩된 데이터를 사용하기로 했다.
API에서 Hero의 썸네일 이미지를 제공하려면 먼저 이미지 파일을 준비해야 한다. resources 패키지에 images 폴더를 생성한 후 각 캐릭터의 이미지를 넣어두었다.
resources/
├── images/
│ ├── sasuke.jpg
│ ├── naruto.jpg
│ ├── sakura.jpg
│ └── ...
나중에 Ktor의 static 리소스 설정을 통해 이 이미지들을 /images/ 경로로 접근할 수 있도록 만들 예정이다.
이제 본격적으로 Repository 구현체를 만들어보자.
HeroRepositoryImpl.kt
class HeroRepositoryImpl: HeroRepository {
override val heroes: Map<Int, List<Hero>> by lazy {
mapOf(
1 to page1,
2 to page2,
3 to page3,
4 to page4,
5 to page5
)
}
override val page1 = listOf(
Hero(
id = 1,
name = "Sasuke",
image = "/images/sasuke.jpg",
about = "Sasuke Uchiha (うちはサスケ, Uchiha Sasuke) is one of the last surviving members of Konohagakure's Uchiha clan. After his older brother, Itachi, slaughtered their clan, Sasuke made it his mission in life to avenge them by killing Itachi. He is added to Team 7 upon becoming a ninja and, through competition with his rival and best friend, Naruto Uzumaki.",
rating = 5.0,
power = 98,
month = "July",
day = "23rd",
family = listOf(
"Fugaku",
"Mikoto",
"Itachi",
"Sarada",
"Sakura"
),
abilities = listOf(
"Sharingan",
"Rinnegan",
"Sussano",
"Amateratsu",
"Intelligence"
),
natureTypes = listOf(
"Lightning",
"Fire",
"Wind",
"Earth",
"Water"
)
),
Hero(
id = 2,
name = "Naruto",
image = "/images/naruto.jpg",
about = "Naruto Uzumaki (うずまきナルト, Uzumaki Naruto) is a shinobi of Konohagakure's Uzumaki clan. He became the jinchūriki of the Nine-Tails on the day of his birth — a fate that caused him to be shunned by most of Konoha throughout his childhood. After joining Team Kakashi, Naruto worked hard to gain the village's acknowledgement all the while chasing his dream to become Hokage.",
rating = 5.0,
power = 98,
month = "Oct",
day = "10th",
family = listOf(
"Minato",
"Kushina",
"Boruto",
"Himawari",
"Hinata"
),
abilities = listOf(
"Rasengan",
"Rasen-Shuriken",
"Shadow Clone",
"Senin Mode"
),
natureTypes = listOf(
"Wind",
"Earth",
"Lava",
"Fire"
)
),
Hero(
id = 3,
name = "Sakura",
image = "/images/sakura.jpg",
about = "Sakura Uchiha (うちはサクラ, Uchiha Sakura, née Haruno (春野)) is a kunoichi of Konohagakure. When assigned to Team 7, Sakura quickly finds herself ill-prepared for the duties of a shinobi. However, after training under the Sannin Tsunade, she overcomes this, and becomes recognised as one of the greatest medical-nin in the world.",
rating = 4.5,
power = 92,
month = "Mar",
day = "28th",
family = listOf(
"Kizashi",
"Mebuki",
"Sarada",
"Sasuke"
),
abilities = listOf(
"Chakra Control",
"Medical Ninjutsu",
"Strength",
"Intelligence"
),
natureTypes = listOf(
"Earth",
"Water",
"Fire"
)
)
)
// page2, page3, page4, page5는 비슷한 형식으로 구현...
override suspend fun getAllHeroes(page: Int): ApiResponse {
return ApiResponse(
success = true,
message = "ok",
prevPage = calculatePage(page = page)[PREVIOUS_PAGE_KEY],
nextPage = calculatePage(page = page)[NEXT_PAGE_KEY],
heroes = heroes[page]!!
)
}
private fun calculatePage(page: Int): Map<String, Int?> {
var prevPage: Int? = page
var nextPage: Int? = page
if (page in 1..4) {
nextPage = nextPage?.plus(1)
}
if (page in 2..5) {
prevPage = prevPage?.minus(1)
}
if (page == 1) {
prevPage = null
}
if (page == 5) {
nextPage = null
}
return mapOf(
PREVIOUS_PAGE_KEY to prevPage,
NEXT_PAGE_KEY to nextPage
)
}
override suspend fun searchHeroes(name: String?): ApiResponse {
return ApiResponse(
success = true,
message = "ok",
heroes = findHeroes(query = name)
)
}
private fun findHeroes(query: String?): List<Hero> {
val founded = mutableListOf<Hero>()
return if (!query.isNullOrEmpty()) {
heroes.forEach { (_, heroes) ->
heroes.forEach { hero ->
if (hero.name.lowercase().contains(query.lowercase())) {
founded.add(hero)
}
}
}
founded
} else {
emptyList()
}
}
companion object {
const val PREVIOUS_PAGE_KEY = "prevPage"
const val NEXT_PAGE_KEY = "nextPage"
}
}
여기서 몇 가지 재미있는 부분을 짚어보자.
by lazy를 사용해서 heroes Map을 초기화했는데, 이는 실제로 heroes가 처음 액세스될 때까지 초기화를 지연시킨다. 메모리 효율성 측면에서 좋은 패턴이다.
calculatePage() 메서드는 페이지네이션 로직을 처리한다. 첫 페이지에서는 prevPage가 null이고, 마지막 페이지에서는 nextPage가 null이 되도록 처리했다. 이런 방식은 모바일 앱에서 무한 스크롤을 구현할 때 매우 유용하다.
드디어 Koin의 진가를 발휘할 때가 왔다! 1편에서 설정해둔 Koin 모듈이 어떻게 활용되는지 보자.
AllHeroes.kt
import com.example.models.ApiResponse
import com.example.repository.HeroRepository
import io.ktor.http.HttpStatusCode
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.response.respond
import org.koin.ktor.ext.inject
fun Route.getAllHeroes() {
val heroRepository: HeroRepository by inject()
get("/boruto/heroes") {
try {
val page = call.request.queryParameters["page"]?.toInt() ?: 1
require(page in 1..5)
val apiResponse = heroRepository.getAllHeroes(page = page)
call.respond(
message = apiResponse,
status = HttpStatusCode.OK
)
} 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.NotFound
)
}
}
}
여기서 핵심은 val heroRepository: HeroRepository by inject() 부분이다.
org.koin.ktor.ext.inject를 사용해서 Repository를 주입받았다. 안드로이드에서 by viewModel()이나 by inject()를 사용해본 경험이 있다면 매우 친숙할 것이다.
여기서 중요한 것은 by inject()는 지연 초기화(lazy initialization)된다는 점이다. 즉, heroRepository가 실제로 사용될 때까지는 인스턴스가 생성되지 않는다. 이는 성능상 이점이 있고, 순환 의존성 문제도 방지할 수 있다.
이제 하드코딩된 페이지 번호 대신 Repository에서 실제 데이터를 가져와서 반환한다.
val apiResponse = heroRepository.getAllHeroes(page = page)
call.respond(
message = apiResponse,
status = HttpStatusCode.OK
)
ApiResponse 객체를 그대로 응답으로 보내는 것도 깔끔하다.
kotlinx.serialization이 자동으로 JSON으로 변환해준다.
실제로 API를 호출하면 이런 응답을 받을 수 있다:
{
"success": true,
"message": "ok",
"prevPage": null,
"nextPage": 2,
"heroes": [
{
"id": 1,
"name": "Sasuke",
"image": "/images/sasuke.jpg",
"about": "Sasuke Uchiha...",
"rating": 5.0,
"power": 98,
"month": "July",
"day": "23rd",
"family": ["Fugaku", "Mikoto", "Itachi", "Sarada", "Sakura"],
"abilities": ["Sharingan", "Rinnegan", "Sussano", "Amateratsu", "Intelligence"],
"natureTypes": ["Lightning", "Fire", "Wind", "Earth", "Water"]
}
// ... more heroes
]
}
catch (e: IllegalArgumentException) {
call.respond(
message = ApiResponse(success = false, message = "Heroes not Found"),
status = HttpStatusCode.NotFound // BadRequest에서 NotFound로 변경
)
}
IllegalArgumentException 발생 시 HttpStatusCode.NotFound를 사용하도록 변경했다.
존재하지 않는 페이지를 요청했을 때 404를 반환하는 것이 RESTful API 설계 원칙에 더 부합한다.
실제로 Spring Boot에서도 @RestControllerAdvice로 전역 에러 핸들러를 만들 때 비슷한 방식으로 처리한다.
페이지네이션뿐만 아니라 검색 기능도 추가해보자.
SearchHeroes.kt
fun Route.searchHeroes() {
val heroRepository: HeroRepository by inject()
get("/boruto/heroes/search") {
val name = call.request.queryParameters["name"]
val apiResponse = heroRepository.searchHeroes(name = name ?: "")
call.respond(
message = apiResponse,
status = HttpStatusCode.OK
)
}
}
override suspend fun searchHeroes(name: String?): ApiResponse {
return ApiResponse(
success = true,
message = "ok",
heroes = findHeroes(query = name)
)
}
private fun findHeroes(query: String?): List<Hero> {
val founded = mutableListOf<Hero>()
return if (!query.isNullOrEmpty()) {
heroes.forEach { (_, heroes) ->
heroes.forEach { hero ->
if (hero.name.lowercase().contains(query.lowercase())) {
founded.add(hero)
}
}
}
founded
} else {
emptyList()
}
}
검색 로직은 단순하게 이름에 쿼리 문자열이 포함되어 있는지 확인하는 방식으로 구현했다.
실제 프로덕션에서는 Elasticsearch나 데이터베이스의 Full-text search를 사용하겠지만, 지금은 이 정도로도 충분하다.
이제 Controller → Repository → Data Layer의 기본적인 아키텍처가 완성되었다.
Koin을 통한 의존성 주입으로 각 레이어 간의 결합도를 낮추고, 테스트하기 쉬운 구조가 되었다.
특히 나중에 Repository의 구현체를 바꿔야 할 때(예: 인메모리 데이터에서 데이터베이스로 변경) Koin 모듈만 수정하면 되므로 유지보수성이 크게 향상된다.
Spring Boot에서 @Autowired나 생성자 주입을 사용하는 것과 비슷하지만, Koin은 런타임에 동작하므로 컴파일 타임이 더 빠르다는 장점이 있다. 물론 컴파일 타임에 의존성 문제를 잡을 수 없다는 단점도 있지만, 작은 프로젝트에서는 이런 트레이드오프가 충분히 가치 있다고 생각한다.