관리 메뉴

나만을 위한 블로그

[Ktor] HTTP 요청 생성, 응답 처리 본문

개인 공부/Ktor

[Ktor] HTTP 요청 생성, 응답 처리

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

https://ktor.io/docs/server-requests-and-responses.html

 

Use Ktor and Kotlin to handle HTTP requests and generate responses | Ktor

 

ktor.io

 

이제 라우팅, 요청 처리, 매개변수에 대한 기본 내용들을 확인한다. 이 내용을 확인하면 아래의 것들을 할 수 있게 된다.

 

  • GET, POST 요청 처리
  • 요청에서 정보 추출
  • 데이터 변환 중 에러 처리
  • 단위 테스트를 통한 라우팅 검증

 

이 문서에선 작업 관리자 앱을 점진적으로 만든다.

 

  • 사용 가능한 모든 작업을 HTML 표로 볼 수 있다
  • 우선순위, 이름 순으로 표시
  • HTML 폼으로 작업 추가

 

정적 HTML 컨텐츠 표시

 

ktor 프로젝트 생성기를 써서 ktor-task-app이란 이름으로 새 프로젝트를 만든다. 이후 main > kotlin > com.example > Routing.kt를 열어서 configureRouting()의 구현을 수정한다.

 

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
    routing {
        get("/tasks") {
            call.respondText(
                contentType = ContentType.parse("text/html"),
                text = """
                <h3>TODO:</h3>
                <ol>
                    <li>A table of all the tasks</li>
                    <li>A form to submit new tasks</li>
                </ol>
                """.trimIndent()
            )
        }
    }
}

 

이렇게 하면 GET 타입 요청을 받는 /tasks라는 url이 만들어진다. GET 요청은 HTTP에서 가장 기본적인 요청 타입으로 유저가 브라우저 주소창에 입력하거나 일반 HTML 링크를 클릭하면 트리거된다. 지금은 정적 컨텐츠만 반환하며 클라이언트에게 HTML을 보낼 걸 알리기 위해 HTTP 컨텐츠 타입 헤더를 text/html로 설정한다.

 

프로젝트를 빌드한 후 127.0.0.1:8000/tasks를 주소창에 입력하면 왼쪽 상단에 아래 내용이 표시되는 화면이 표시된다.

 

 

Task 모델 구현

 

프로젝트 생성, 기본 라우팅을 설정했으니 앱을 확장할 수 있다.

 

  • task를 표현할 모델 타입 생성
  • 샘플 값이 포함된 작업 리스트 선언
  • 작업 리스트를 리턴하게 라우트, 요청 핸들러 수정
  • 브라우저에서 새 기능 작동 테스트

 

main > kotiln > com.example에 model 패키지를 만들고 Task라는 data class를 만든다.

 

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

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

 

HTML 안에서 클라이언트에 task를 보내기 때문에 확장 함수 2개도 추가한다.

 

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

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

fun Task.taskAsRow() = """
    <tr>
        <td>$name</td><td>$description</td><td>$priority</td>
    </tr>
    """.trimIndent()

fun List<Task>.tasksAsTable() = this.joinToString(
    prefix = "<table rules=\"all\">",
    postfix = "</table>",
    separator = "\n",
    transform = Task::taskAsRow
)

 

taskAsRow()를 사용하면 Task 객체를 table row로 렌더링할 수 있고 Task 리스트를 테이블로 렌더링하는 tasksAsTable()을 쓰면 Task 리스트를 테이블로 렌더링할 수 있다.

 

샘플 값 생성

 

model 패키지 안에 TaskRepository 파일을 만들고 아래 내용을 복붙한다.

 

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

 

새 라우트 추가

 

Routing.kt 파일의 configureRouting() 구현을 아래처럼 수정한다.

 

import com.example.model.tasks
import com.example.model.tasksAsTable
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
    routing {
        get("/tasks") {
            call.respondText(
                contentType = ContentType.parse("text/html"),
                text = tasks.tasksAsTable()
            )
        }
    }
}

 

클라이언트에 정적 컨텐츠를 리턴하는 대신 Task 리스트를 제공하게 된다. 네트워크를 통해 리스트를 직접 전송할 수 없어서 클라이언트가 이해 가능한 형식으로 변환해야 한다. 지금은 HTML 테이블로 변환된다.

실행 버튼을 눌러서 다시 빌드한 다음 브라우저를 새로고침하면 아래 화면이 표시된다.

 

 

모델 리팩토링

 

앱 기능을 확장하기 전에 레포지토리 안에서 Task 리스트를 캡슐화해서 디자인을 리팩토링해야 한다. 이렇게 하면 데이터 관리를 중앙집중화할 수 있어서 Ktor 코드에 집중할 수 있다.

TaskRepository를 아래처럼 수정한다.

 

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

 

리스트에 기반한 Task 관련 매우 간단한 데이터 레포지토리를 구현한다. 이 예시에선 작업 추가 순서는 유지되지만 예외를 던져서 중복은 허용되지 않는다. 이후엔 Exposed 라이브러리를 통해 관계형 DB에 연결하는 레포지토리 구현법을 확인하지만 지금은 경로 안의 레포지토리를 사용한다.

Routing.kt의 구현을 다시 수정한다.

 

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

fun Application.configureRouting() {
    routing {
        get("/tasks") {
            val tasks = TaskRepository.allTasks()
            call.respondText(
                contentType = ContentType.parse("text/html"),
                text = tasks.tasksAsTable()
            )
        }
    }
}

 

요청을 받으면 레포지토리는 현재 Task 리스트를 가져오는 데 쓰인다. 그 후 이런 작업이 포함된 HTTP 응답이 생성된다.

이제 재실행한 후 주소창에 127.0.0.1:8000/tasks을 입력하거나 입력된 상태라면 새로고침할 경우 이전에 본 테이블 형태의 표가 표시된다.

 

매개변수 작업

 

이제 유저가 우선순위에 따라 Task를 볼 수 있게 한다. 이렇게 구현하려면 앱에서 아래 URL에 대한 GET 요청을 허용해야 한다.

 

  • /tasks/byPriority/Low
  • /tasks/byPriority/Medium
  • / tasks/byPriority/High
  • / tasks/byPriority/Vital

 

추가할 라우트는 /tasks/byPriority/{priority}다. {priority}는 런타임에 추출해야 하는 쿼리 매개변수를 말한다.

요청 처리 프로세스는 아래처럼 요약할 수 있다.

 

  • 요청에서 우선순위라는 쿼리 매개변수를 추출한다
  • 해당 매개변수가 없으면 400 상태(잘못된 요청)를 리턴한다
  • 매개변수의 텍스트 값을 Priority enum으로 바꾼다
  • 실패 시 400 상태 코드가 포함된 응답을 리턴한다
  • 레포지토리를 써서 지정된 우선순위를 가진 모든 작업을 찾는다
  • 일치하는 작업이 없으면 404 상태(찾을 수 없음)를 리턴한다
  • 일치하는 작업을 HTML 테이블 형태로 리턴한다

 

새 라우트 추가

 

Routing.kt 파일의 구현을 수정한다.

 

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

fun Application.configureRouting() {
    routing {
        get("/tasks") {
            val tasks = TaskRepository.allTasks()
            call.respondText(
                contentType = ContentType.parse("text/html"),
                text = tasks.tasksAsTable()
            )
        }

        get("/tasks/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.respondText(
                    contentType = ContentType.parse("text/html"),
                    text = tasks.tasksAsTable()
                )
            } catch (e: IllegalArgumentException) {
                call.respond(HttpStatusCode.BadRequest)
            }
        }
    }
}

 

/tasks/byPriority/{priority} url 핸들러를 만들었다. priority는 유저가 추가한 쿼리 매개변수다.

서버에선 이 값이 enum 안의 4개 값 중 하나인지 보장할 방법이 없어 수동으로 확인해야 한다. 매개변수가 없으면 서버는 400 에러를 리턴한다. 그게 아니면 매개변수 값을 추출해 enum의 멤버로 변환을 시도한다.

변환에 실패하면 예외가 발생하고 서버가 이를 포착해 400 에러를 리턴한다. 변환에 성공하면 레포지토리를 써서 일치하는 작업을 찾고 작업이 없으면 404 에러를 반환한다. 작업이 있으면 HTML 테이블로 작업을 보낸다.

 

이제 url에 127.0.0.1:8000/tasks/byPriority/Medium을 치면 아래 화면이 표시된다.

 

 

로그캣에는 아래 내용들이 표시된다.

 

 

지금까지 브라우저만으로 확인했지만 에러가 발생했을 때 브라우저로 확인할 수 있는 건 제한적이다. 포스트맨을 쓰면 실패한 응답의 세부 정보를 확인할 수 있다.

포스트맨 설치 파일을 다운받으려면 아래 링크를 클릭한다.

 

https://www.postman.com/downloads/

 

Download Postman | Get Started for Free

Try Postman for free! Join 35 million developers who rely on Postman, the collaboration platform for API development. Create better APIs—faster.

www.postman.com

 

설치했다면 브라우저의 url을 복사해서 포스트맨 주소창에 그대로 복붙한 후 실행한다. 처음 탭을 열면 GET이 기본 선택돼 있으니 요청 메서드를 바꿀 필요는 없다.

실행하면 아래 화면이 표시될 것이다.

 

 

서버의 원시 출력, 요청, 응답의 모든 세부정보를 알 수 있다.

이제 404 에러가 나타나는지 확인하려면 Medium을 Vital로 바꿔서 실행한다. 그러면 응답 HTML이 표시되는 부분 우측 상단에 상태 코드가 표시된다.

 

 

잘못된 우선순위가 지정됐을 때 400 에러가 리턴되는지 확인하려면 정의된 속성 외의 다른 속성을 써서 GET 요청을 보낸다.

 

 

참고로 400 Bad Request 오른쪽의 Times, Size에 마우스를 대면 어느 과정에서 얼마만큼의 시간이 걸렸는지, 요청과 응답의 용량이 각각 얼마인지 확인할 수 있다.

 

단위 테스트 추가

 

지금까지 모든 작업을 검색하는 라우트, 우선순위에 따라 작업을 검색하는 라우트를 추가했다. 포스트맨 등의 도구를 쓰면 경로들을 완전히 테스트할 수 있지만 수동으로 검사해야 하고 외부에서 Ktor로 실행해야 한다. 프로토타이핑, 소규모 앱에선 허용되는 방법이지만 자주 실행해야 하는 수천 개의 테스트가 존재할 수 있는 대규모 앱에선 부적합하다.

나은 해결책은 테스트를 완전 자동화하는 것이다. Ktor는 라우트의 자동화된 유효성 검사를 지원하는 자체 테스트 프레임워크를 제공한다. 아래에선 기존 기능들에 몇 가지 테스트를 작성한다.

 

src 폴더 안에 test 폴더를 만들고 test 폴더 안에 kotlin 폴더를 만든다. 그리고 src/test/kotlin 안에 ApplicationTest 클래스를 만들고 아래처럼 수정한다.

 

import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import org.junit.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals

class ApplicationTest {
    @Test
    fun tasksCanBeFoundByPriority() = testApplication {
        application {
            module()
        }

        val response = client.get("/tasks/byPriority/Medium")
        val body = response.bodyAsText()

        assertEquals(HttpStatusCode.OK, response.status)
        assertContains(body, "Mow the lawn")
        assertContains(body, "Paint the fence")
    }

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

 

이 때 io.ktor.client.request와 statement에서 라이브러리를 찾을 수 없다는 에러가 나타날 수 있는데, libs.versions.toml의 libraries 부분에 아래 2개를 추가한다.

 

ktor-client-core = { module = "io.ktor:ktor-client-core-jvm", version.ref = "ktor-version" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio-jvm", version.ref = "ktor-version" }

 

그리고 프로젝트 gradle에서 사용한다.

 

testImplementation(libs.ktor.client.core)
testImplementation(libs.ktor.client.cio)

 

이후 인텔리제이를 완전 종료하고 재실행한 다음 테스트 파일을 실행해 보면 정상적으로 테스트가 돌아간다.

 

POST 요청 처리

 

새 Task를 만들 때 적절한 HTTP 요청 타입은 POST다. POST는 일반적으로 유저가 HTML form을 써서 제출 시 트리거된다. GET과 다르게 POST엔 form에 있는 모든 입력값의 이름, 값이 포함된 body가 있다. 이 정보는 서로 다른 입력에서 데이터를 분리하고 불법 문자를 피하기 위해 인코딩된다.

이 프로세스의 세부 사항은 브라우저, Ktor에서 처리하기 때문에 신경쓸 필요 없다.

 

정적 컨텐츠 생성

 

src/main/resources에 task-ui 폴더를 만들고 task-form.html을 만든다.

 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Adding a new task</title>
</head>
<body>
<h1>Adding a new task</h1>
<form method="post" action="/tasks">
    <div>
        <label for="name">Name: </label>
        <input type="text" id="name" name="name" size="10">
    </div>
    <div>
        <label for="description">Description: </label>
        <input type="text" id="description" name="description" size="20">
    </div>
    <div>
        <label for="priority">Priority: </label>
        <select id="priority" name="priority">
            <option name="Low">Low</option>
            <option name="Medium">Medium</option>
            <option name="High">High</option>
            <option name="Vital">Vital</option>
        </select>
    </div>
    <input type="submit">
</form>
</body>
</html>

 

그리고 Routing.kt의 routing {} 최상단에 정적 리소스를 사용하겠다는 함수를 작성하고 정적 리소스의 라우팅을 명시한다.

 

fun Application.configureRouting() {
    routing {
        staticResources("/task-ui", "task-ui")

        get("/tasks") {
            val tasks = TaskRepository.allTasks()
            call.respondText(
                contentType = ContentType.parse("text/html"),
                text = tasks.tasksAsTable()
            )
        }

        get("/tasks/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.respondText(
                    contentType = ContentType.parse("text/html"),
                    text = tasks.tasksAsTable()
                )
            } catch (e: IllegalArgumentException) {
                call.respond(HttpStatusCode.BadRequest)
            }
        }

    }
}

 

그리고 127.0.0.1:8080/task-ui/task-form.html을 브라우저에 실행하면 아래 화면이 나타난다.

 

 

input 태그의 영향으로 HTML form이 나타났으니 이걸 처리할 수 있게 수정한다. "/tasks" 경로로 POST 요청을 받을 경우의 코드를 추가한다.

 

import com.example.model.Priority
import com.example.model.Task
import com.example.model.TaskRepository
import com.example.model.tasksAsTable
import io.ktor.http.*
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("/task-ui", "task-ui")

        post("/tasks") {
            val formContent = call.receiveParameters()

            val params = Triple(
                formContent["name"] ?: "",
                formContent["description"] ?: "",
                formContent["priority"] ?: "",
            )

            if (params.toList().any { it.isEmpty() }) {
                call.respond(HttpStatusCode.BadRequest)
                return@post
            }

            try {
                val priority = Priority.valueOf(params.third)
                TaskRepository.addTask(
                    Task(
                        name = params.first,
                        description = params.second,
                        priority = priority
                    )
                )

                call.respond(HttpStatusCode.NoContent)
            } catch (e: IllegalArgumentException) {
                call.respond(HttpStatusCode.BadRequest)
            } catch (e: IllegalStateException) {
                call.respond(HttpStatusCode.BadRequest)
            }
        }

        get("/tasks") {
            val tasks = TaskRepository.allTasks()
            call.respondText(
                contentType = ContentType.parse("text/html"),
                text = tasks.tasksAsTable()
            )
        }

        get("/tasks/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.respondText(
                    contentType = ContentType.parse("text/html"),
                    text = tasks.tasksAsTable()
                )
            } catch (e: IllegalArgumentException) {
                call.respond(HttpStatusCode.BadRequest)
            }
        }

    }
}

 

아래 코드를 추가한다.

 

post("/tasks") {
    val formContent = call.receiveParameters()

    val params = Triple(
        formContent["name"] ?: "",
        formContent["description"] ?: "",
        formContent["priority"] ?: "",
    )

    if (params.toList().any { it.isEmpty() }) {
        call.respond(HttpStatusCode.BadRequest)
        return@post
    }

    try {
        val priority = Priority.valueOf(params.third)
        TaskRepository.addTask(
            Task(
                name = params.first,
                description = params.second,
                priority = priority
            )
        )

        call.respond(HttpStatusCode.NoContent)
    } catch (e: IllegalArgumentException) {
        call.respond(HttpStatusCode.BadRequest)
    } catch (e: IllegalStateException) {
        call.respond(HttpStatusCode.BadRequest)
    }
}

 

Triple은 코틀린에서 변수를 3개로 묶어서 쓰고 싶을 때 사용할 수 있는 클래스다. receiveParameters()를 통해 요청에 포함된 값들을 받은 뒤 params라는 Triple 객체를 만들고 name, description, priority를 넣는다.

이후 Triple을 리스트로 만들었을 때 하나의 요소라도 공백이라면 BadRequest를 리턴하고 종료하며, 모두 공백이 아닌 값이 존재한다면 try-catch문이 실행된다. 이후 TaskRepository.addTask()를 호출해서 task를 추가한다.

만약 아래처럼 작성하고 제출 버튼을 누르면

 

 

127.0.0.1:8080/tasks 경로로 이동했을 때 아래처럼 표시된다. 켜놓고 있었다면 새로고침하면 보일 것이다.

 

 

만약 name, description 중 하나라도 값을 입력하지 않고 제출 버튼을 누르면 아래 화면이 표시된다.

 

 

테스트도 작성한다. ApplicationTest에 아래 함수를 추가한다.

 

@Test
fun newTasksCanBeAdded() = testApplication {
    application {
        module()
    }

    val response1 = client.post("/tasks") {
        header(
            HttpHeaders.ContentType,
            ContentType.Application.FormUrlEncoded.toString()
        )

        setBody(
            listOf(
                "name" to "swimming",
                "description" to "Go to the beach",
                "priority" to "Low"
            ).formUrlEncode()
        )
    }

    assertEquals(HttpStatusCode.NoContent, response1.status)

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

    assertContains(body, "swimming")
    assertContains(body, "Go to the beach")
}

 

Routing 리팩토링

 

현재까지 만든 라우팅을 보면 모든 경로가 /tasks로 시작한다. 그래서 아래처럼 리팩토링할 수 있다.

 

fun Application.configureRouting() {
    routing {
        staticResources("/task-ui", "task-ui")

        route("/tasks") {
            post {
                val formContent = call.receiveParameters()

                val params = Triple(
                    formContent["name"] ?: "",
                    formContent["description"] ?: "",
                    formContent["priority"] ?: "",
                )

                if (params.toList().any { it.isEmpty() }) {
                    call.respond(HttpStatusCode.BadRequest)
                    return@post
                }

                try {
                    val priority = Priority.valueOf(params.third)
                    TaskRepository.addTask(
                        Task(
                            name = params.first,
                            description = params.second,
                            priority = priority
                        )
                    )

                    call.respond(HttpStatusCode.NoContent)
                } catch (e: IllegalArgumentException) {
                    call.respond(HttpStatusCode.BadRequest)
                } catch (e: IllegalStateException) {
                    call.respond(HttpStatusCode.BadRequest)
                }
            }

            get {
                val tasks = TaskRepository.allTasks()
                call.respondText(
                    contentType = ContentType.parse("text/html"),
                    text = tasks.tasksAsTable()
                )
            }

            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.respondText(
                        contentType = ContentType.parse("text/html"),
                        text = tasks.tasksAsTable()
                    )
                } catch (e: IllegalArgumentException) {
                    call.respond(HttpStatusCode.BadRequest)
                }
            }
        }

    }
}

 

반응형

'개인 공부 > Ktor' 카테고리의 다른 글

[Ktor] RESTful API 만들기  (0) 2025.01.29
[Ktor] Ktor 프로젝트 추가 작업  (0) 2024.12.12
[Ktor] Ktor 프로젝트 생성하고 실행하기  (0) 2024.10.06
Comments