관리 메뉴

나만을 위한 블로그

[Ktor] RESTful API 만들기 본문

개인 공부/Ktor

[Ktor] RESTful API 만들기

참깨빵위에참깨빵_ 2025. 1. 29. 17:42
728x90
반응형

https://ktor.io/docs/server-create-restful-apis.html

 

How to create RESTful APIs in Kotlin with Ktor | Ktor

 

ktor.io

 

여기선 JSON 파일을 만드는 RESTful API 예제를 중심으로 코틀린, Ktor를 써서 백엔드 서비스를 빌드하는 방법을 확인하고 작업 관리를 위한 RESTful 서비스를 만든다.

여기선 아래 내용을 확인한다.

 

  • JSON 직렬화를 쓰는 RESTful 서비스 만들기
  • Content Negotiation 프로세스 이해
  • Ktor 안에서 REST API의 경로 정의

 

com.example.ktor-rest-task-app 이름의 프로젝트를 만들고 Routing, Content Negotiation, Kotlinx.serialization, Static Content 라이브러리를 추가한 뒤 다운받아서 인텔리제이에서 실행한다.

그리고 main>kotlin 패키지 안에 model 패키지를 만들고 Task 파일을 만든다.

 

import kotlinx.serialization.Serializable

enum class Priority {
    Low,
    Medium,
    High,
    Vital
}

@Serializable
data class Task(
    val name: String,
    val description: String,
    val priority: Priority
)

 

Routing.kt에서 만든 것들을 사용한다.

 

import com.example.model.Priority
import com.example.model.Task
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
    routing {
        staticResources("/static", "static")

        get("/tasks") {
            call.respond(
                listOf(
                    Task("cleaning", "Clean the house", Priority.Low),
                    Task("gardening", "Mow the lawn", Priority.Medium),
                    Task("shopping", "Buy the groceries", Priority.High),
                    Task("painting", "Paint the fence", Priority.Medium)
                )
            )
        }
    }
}

 

그리고 브라우저에 127.0.0.1:8080/tasks를 실행하면 JSON 배열이 나타난다. 난 크롬에 JSON 플러그인을 설치해서 아래처럼 보이지만 보통이라면 검은 화면에 흰색으로 JSON 데이터들이 가로로 표시될 것이다.

 

 

Content Negotiation 이해하기

 

앞서 프로젝트를 만들 때 Content Negotiation 플러그인을 추가했었다. 이것은 클라이언트가 렌더링할 수 있는 컨텐트 타입을 확인하고 현재 서비스가 제공 가능한 컨텐트 타입과 일치시킨다. 그래서 컨텐트 협상이라고 한다.

HTTP에서 클라이언트는 Accept 헤더로 어떤 컨텐트 타입을 렌더링할 수 있는지 알려준다. 이 헤더값은 하나 이상의 컨텐트 타입이다. 브라우저에 내장된 개발자 도구로 이 헤더값을 검사할 수 있다. 아래는 예시다.

 

text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7

 

이것은 html, xml 또는 이미지를 허용하지만 다른 컨텐트 타입도 허용한다는 뜻이다. Content Negotiation 플러그인은 브라우저로 데이터를 재전송할 형식을 찾아야 한다. 프로젝트에 생성된 코드 중 main>kotlin>Serialization.kt에 아래 코드가 있다.

 

install(ContentNegotiation) {
    json()
}

 

이것은 ContentNegotiation 플러그인을 설치하고 kotlinx.serialization 플러그인을 구성하는 코드다. 그래서 클라이언트가 요청을 보내면 서버는 JSON으로 직렬화된 객체를 재전송할 수 있다.

브라우저에서 요청이 들어오면 Content Negotiation 플러그인은 JSON만 리턴할 수 있음을 알고 브라우저는 전송된 모든 걸 표시하려고 시도하기 때문에 요청은 성공한다.

 

자바스크립트를 통한 Content Negotiation

 

프로덕션에선 브라우저에 JSON을 표시하고 싶지 않을 것이다. 대신 브라우저에서 자바스크립트 코드가 실행되서 요청을 수행하고 단일 페이지 앱의 일부로 리턴된 데이터를 표시한다. 일반적으로 이런 앱은 리액트, 앵귤러, Vue 같은 프레임워크를 써서 작성된다.

main > resources > static 안의 index.html 페이지를 아래처럼 수정한다.

 

<html>
<head>
    <title>A Simple SPA For Tasks</title>
    <script type="application/javascript">
        function fetchAndDisplayTasks() {
            fetchTasks()
                .then(tasks => displayTasks(tasks))
        }

        function fetchTasks() {
            return fetch(
                "/tasks",
                {
                    headers: { 'Accept': 'application/json' }
                }
            ).then(resp => resp.json());
        }

        function displayTasks(tasks) {
            const tasksTableBody = document.getElementById("tasksTableBody")
            tasks.forEach(task => {
                const newRow = taskRow(task);
                tasksTableBody.appendChild(newRow);
            });
        }

        function taskRow(task) {
            return tr([
                td(task.name),
                td(task.description),
                td(task.priority)
            ]);
        }

        function tr(children) {
            const node = document.createElement("tr");
            children.forEach(child => node.appendChild(child));
            return node;
        }

        function td(text) {
            const node = document.createElement("td");
            node.appendChild(document.createTextNode(text));
            return node;
        }
    </script>
</head>
<body>
<h1>Viewing Tasks Via JS</h1>
<form action="javascript:fetchAndDisplayTasks()">
    <input type="submit" value="View The Tasks">
</form>
<table>
    <thead>
    <tr><th>Name</th><th>Description</th><th>Priority</th></tr>
    </thead>
    <tbody id="tasksTableBody">
    </tbody>
</table>
</body>
</html>

 

HTML form, 빈 테이블이 포함돼 있다. form을 submit하면 자바스크립트 이벤트 핸들러가 Accept 헤더를 application/json으로 설정해서 /tasks 엔드포인트로 요청을 보낸다. 그럼 리턴된 데이터는 직렬화 해제되어 HTML 테이블에 추가된다.

프로젝트를 실행해서 127.0.0.1:8000/static/index.html을 주소창에 치면 아래 화면이 표시된다.

 

 

버튼을 누르면 아래 화면으로 변경된다.

 

 

GET 라우트 추가

 

이전 글에서 사용한 TaskRepository를 재사용한다. model 패키지에 TaskRepository object class를 추가한다.

 

object TaskRepository {
    private val tasks = mutableListOf(
        Task("cleaning", "Clean the house", Priority.Low),
        Task("gardening", "Mow the lawn", Priority.Medium),
        Task("shopping", "Buy the groceries", Priority.High),
        Task("painting", "Paint the fence", Priority.Medium)
    )

    fun allTasks(): List<Task> = tasks

    fun tasksByPriority(priority: Priority) = tasks.filter {
        it.priority == priority
    }

    fun taskByName(name: String) = tasks.find {
        it.name.equals(name, ignoreCase = true)
    }

    fun addTask(task: Task) {
        if (taskByName(task.name) != null) {
            throw IllegalStateException("Cannot duplicate task names!")
        }
        tasks.add(task)
    }
}

 

이제 GET 라우트를 구현할 수 있다. task를 HTML로 바꾸는 걸 신경쓸 필요가 없어서 이전 코드를 간소화할 수 있다.

 

import com.example.model.Priority
import com.example.model.TaskRepository
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
    routing {
        staticResources("static", "static")

        route("/tasks") {
            get {
                val tasks = TaskRepository.allTasks()
                call.respond(tasks)
            }

            get("/byName/{taskName}") {
                val name = call.parameters["taskName"]
                if (name == null) {
                    call.respond(HttpStatusCode.BadRequest)
                    return@get
                }

                val task = TaskRepository.taskByName(name)
                if (task == null) {
                    call.respond(HttpStatusCode.NotFound)
                    return@get
                }
                call.respond(task)
            }

            get("/byPriority/{priority}") {
                val priorityAsText = call.parameters["priority"]
                if (priorityAsText == null) {
                    call.respond(HttpStatusCode.BadRequest)
                    return@get
                }
                try {
                    val priority = Priority.valueOf(priorityAsText)
                    val tasks = TaskRepository.tasksByPriority(priority)

                    if (tasks.isEmpty()) {
                        call.respond(HttpStatusCode.NotFound)
                        return@get
                    }
                    call.respond(tasks)
                } catch (ex: IllegalArgumentException) {
                    call.respond(HttpStatusCode.BadRequest)
                }
            }
        }
    }
}

 

프로젝트를 재실행하고 127.0.0.1/tasks/byPriority/Medium을 주소창에 치면 priority가 Medium인 모든 task들이 JSON 형태로 표시된다.

 

 

포스트맨에서 해당 URL을 실행하면 아래처럼 표시된다.

 

 

POST 요청 라우트 추가

 

이전 튜토리얼에선 HTML form으로 task를 만들었지만 RESTful 서비스를 만들고 있어서 이제 이렇게 할 필요가 없다. 대신 무거운 작업들을 대부분 처리하는 kotlinx.serialization 프레임워크를 쓰게 된다.

Routing.kt를 아래처럼 수정한다. 맨 밑의 POST 요청이 추가됐다.

 

import com.example.model.Priority
import com.example.model.Task
import com.example.model.TaskRepository
import io.ktor.http.*
import io.ktor.serialization.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
    routing {
        staticResources("static", "static")

        route("/tasks") {
            get {
                val tasks = TaskRepository.allTasks()
                call.respond(tasks)
            }

            get("/byName/{taskName}") {
                val name = call.parameters["taskName"]
                if (name == null) {
                    call.respond(HttpStatusCode.BadRequest)
                    return@get
                }

                val task = TaskRepository.taskByName(name)
                if (task == null) {
                    call.respond(HttpStatusCode.NotFound)
                    return@get
                }
                call.respond(task)
            }

            get("/byPriority/{priority}") {
                val priorityAsText = call.parameters["priority"]
                if (priorityAsText == null) {
                    call.respond(HttpStatusCode.BadRequest)
                    return@get
                }
                try {
                    val priority = Priority.valueOf(priorityAsText)
                    val tasks = TaskRepository.tasksByPriority(priority)

                    if (tasks.isEmpty()) {
                        call.respond(HttpStatusCode.NotFound)
                        return@get
                    }
                    call.respond(tasks)
                } catch (ex: IllegalArgumentException) {
                    call.respond(HttpStatusCode.BadRequest)
                }
            }

            post {
                try {
                    val task = call.receive<Task>()
                    TaskRepository.addTask(task)

                    call.respond(HttpStatusCode.Created)
                } catch (e: IllegalStateException) {
                    call.respond(HttpStatusCode.BadRequest)
                } catch (e: JsonConvertException) {
                    call.respond(HttpStatusCode.BadRequest)
                }
            }

        }
    }
}

 

POST 요청을 /tasks로 보내면 kotlinx.serialization 프레임워크가 요청 body를 Task 객체로 바꾸는 데 쓰인다. 이게 성공하면 task가 레포지토리에 추가된다. 역직렬화 프로세스가 실패하면 서버는 JsonConvertException을 처리하고, task가 중복됐다면 IllegalStateException을 처리한다.

프로젝트를 재실행하고 포스트맨에 127.0.0.1:8080/tasks를 입력한 다음 포스트맨 주소창 밑의 Body를 클릭하고 raw 라디오버튼을 눌러 직접 JSON 형태의 데이터를 입력한다.

 

 

실행 후 성공하면 ktor 프로젝트에서 정의했던 대로 201 응답을 받게 되는 걸 볼 수 있다. 어떤 값으로 응답할지는 작성한 게 없기 때문에 응답란에는 당연히 아무것도 표시되지 않는다.

 

삭제 기능 추가

 

CRUD 중 delete를 추가한다. TaskRepository에 함수를 새로 정의한다.

 

fun removeTask(name: String): Boolean {
    return tasks.removeIf { it.name == name }
}

 

그리고 Routing.kt를 열어서 새 delete 함수를 추가한다.

 

import com.example.model.Priority
import com.example.model.Task
import com.example.model.TaskRepository
import io.ktor.http.*
import io.ktor.serialization.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
    routing {
        staticResources("static", "static")

        route("/tasks") {
            get {
                val tasks = TaskRepository.allTasks()
                call.respond(tasks)
            }

            get("/byName/{taskName}") {
                val name = call.parameters["taskName"]
                if (name == null) {
                    call.respond(HttpStatusCode.BadRequest)
                    return@get
                }

                val task = TaskRepository.taskByName(name)
                if (task == null) {
                    call.respond(HttpStatusCode.NotFound)
                    return@get
                }
                call.respond(task)
            }

            get("/byPriority/{priority}") {
                val priorityAsText = call.parameters["priority"]
                if (priorityAsText == null) {
                    call.respond(HttpStatusCode.BadRequest)
                    return@get
                }
                try {
                    val priority = Priority.valueOf(priorityAsText)
                    val tasks = TaskRepository.tasksByPriority(priority)

                    if (tasks.isEmpty()) {
                        call.respond(HttpStatusCode.NotFound)
                        return@get
                    }
                    call.respond(tasks)
                } catch (ex: IllegalArgumentException) {
                    call.respond(HttpStatusCode.BadRequest)
                }
            }

            post {
                try {
                    val task = call.receive<Task>()
                    TaskRepository.addTask(task)

                    call.respond(HttpStatusCode.Created)
                } catch (e: IllegalStateException) {
                    call.respond(HttpStatusCode.BadRequest)
                } catch (e: JsonConvertException) {
                    call.respond(HttpStatusCode.BadRequest)
                }
            }

            delete("/{taskName}") {
                val name = call.parameters["taskName"]
                if (name == null) {
                    call.respond(HttpStatusCode.BadRequest)
                    return@delete
                }

                if (TaskRepository.removeTask(name)) {
                    call.respond(HttpStatusCode.NoContent)
                } else {
                    call.respond(HttpStatusCode.NotFound)
                }
            }

        }
    }
}

 

프로젝트를 재실행하고 포스트맨 주소창에 127.0.0.1:8080/tasks/gardening을 입력하면 아래 화면이 표시된다. 주소창 왼쪽의 HTTP 메서드를 delete로 반드시 설정해야 한다.

 

 

성공하면 NoContent를 리턴하도록 작성했는데 응답이 204 No Content로 온 것을 봐서 성공했다는 걸 알 수 있다.

이후 다시 Send 버튼을 누르면 404 Not Found가 표시된다. removeTask()로 gardening을 삭제할 수 없기 때문에 else가 실행된 결과다.

 

 

Ktor 클라이언트를 사용한 단위 테스트 작성

 

지금까지 앱을 수동 테스트했지만 알다시피 이 방식은 시간이 많이 걸리고 확장되지 않는다.

대신 기본 제공 클라이언트 객체를 써서 JSON을 가져오고 역직렬화하는 JUnit 테스트를 만들 수 있다.

 

먼저 libs.versions.toml의 libraries에 아래 코드를 추가한다.

 

ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-version" }

 

그리고 build.gradle의 dependencies에 추가했던 라이브러리를 사용하게 명시한다.

 

testImplementation(libs.ktor.client.content.negotiation)

 

이후 프로젝트 동기화를 진행해서 추가한 라이브러리를 프로젝트에 적용하고, src > test의 ApplicationTest.kt를 열어서 아래처럼 수정한다. 반드시 프로젝트 동기화를 먼저 진행하고 코드를 작성한다.

 

import com.example.model.Priority
import com.example.model.Task
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import kotlin.test.*

class ApplicationTest {
    @Test
    fun tasksCanBeFoundByPriority() = testApplication {
        application {
            module()
        }
        val client = createClient {
            install(ContentNegotiation) {
                json()
            }
        }

        val response = client.get("/tasks/byPriority/Medium")
        val results = response.body<List<Task>>()

        assertEquals(HttpStatusCode.OK, response.status)

        val expectedTaskNames = listOf("gardening", "painting")
        val actualTaskNames = results.map(Task::name)
        assertContentEquals(expectedTaskNames, actualTaskNames)
    }

    @Test
    fun invalidPriorityProduces400() = testApplication {
        application {
            module()
        }
        val response = client.get("/tasks/byPriority/Invalid")
        assertEquals(HttpStatusCode.BadRequest, response.status)
    }


    @Test
    fun unusedPriorityProduces404() = testApplication {
        application {
            module()
        }
        val response = client.get("/tasks/byPriority/Vital")
        assertEquals(HttpStatusCode.NotFound, response.status)
    }

    @Test
    fun newTasksCanBeAdded() = testApplication {
        application {
            module()
        }
        val client = createClient {
            install(ContentNegotiation) {
                json()
            }
        }

        val task = Task("swimming", "Go to the beach", Priority.Low)
        val response1 = client.post("/tasks") {
            header(
                HttpHeaders.ContentType,
                ContentType.Application.Json
            )

            setBody(task)
        }
        assertEquals(HttpStatusCode.Created, response1.status)

        val response2 = client.get("/tasks")
        assertEquals(HttpStatusCode.OK, response2.status)

        val taskNames = response2
            .body<List<Task>>()
            .map { it.name }

        assertContains(taskNames, "swimming")
    }
}

 

이후 실행하면 별 에러 없이 모든 테스트들이 성공할 것이다.

 

JsonPath로 단위 테스트 작성

 

Ktor 클라이언트 또는 유사 라이브러리를 써서 서비스를 테스트하기는 편하지만 QA 관점에선 단점이 있다. JSON을 직접 처리하지 않는 서비스는 JSON 구조에 대한 가정을 확신할 수 없다. 예를 들어 아래와 같은 가정이 있다.

 

  • 실제로 객체가 쓰일 때 값은 배열에 저장된다
  • 프로퍼티가 실제론 문자열이지만 숫자로 저장된다
  • 회원은 신고 순서대로 직렬화되지만 그렇지 않은 경우도 있다

 

여러 클라이언트에서 서비스를 사용하려는 경우 JSON 구조에 대한 확신을 갖는 게 중요하다. 이를 위해 ktor 클라이언트를 써서 서버에서 텍스트를 검색한 다음 JSONPath 라이브러리를 써서 이 컨텐츠를 분석하라.

먼저 build.gradle에 아래 라이브러리를 추가한다.

 

testImplementation("com.jayway.jsonpath:json-path:2.9.0")

 

그리고 src > test 패키지에 ApplicationJsonPathTest.kt 클래스를 만들고 아래 코드를 추가한다.

 

import com.jayway.jsonpath.DocumentContext
import com.jayway.jsonpath.JsonPath
import io.ktor.client.*
import com.example.model.Priority
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*

class ApplicationJsonPathTest {
    @Test
    fun tasksCanBeFound() = testApplication {
        application {
            module()
        }
        val jsonDoc = client.getAsJsonPath("/tasks")

        val result: List<String> = jsonDoc.read("$[*].name")
        assertEquals("cleaning", result[0])
        assertEquals("gardening", result[1])
        assertEquals("shopping", result[2])
    }

    @Test
    fun tasksCanBeFoundByPriority() = testApplication {
        application {
            module()
        }
        val priority = Priority.Medium
        val jsonDoc = client.getAsJsonPath("/tasks/byPriority/$priority")

        val result: List<String> =
            jsonDoc.read("$[?(@.priority == '$priority')].name")
        assertEquals(2, result.size)

        assertEquals("gardening", result[0])
        assertEquals("painting", result[1])
    }

    suspend fun HttpClient.getAsJsonPath(url: String): DocumentContext {
        val response = this.get(url) {
            accept(ContentType.Application.Json)
        }
        return JsonPath.parse(response.bodyAsText())
    }
}

 

JsonPath 쿼리는 아래처럼 작동한다.

 

  • $[*].name은 문서를 배열로 취급하고 각 아이템의 name 프로퍼티 값을 리턴한다는 의미다
  • $[?(@.priority == '$priority')].name은 제공된 값과 같은 우선순위인 배열의 모든 아이템의 name 프로퍼티 값을 리턴한다는 의미다
반응형
Comments