| name | kotlin-ktor-patterns |
| description | 라우팅 DSL, 플러그인, 인증, Koin DI, kotlinx.serialization, 웹소켓 및 testApplication 테스트를 포함한 Ktor 서버 패턴입니다. |
| origin | ECC |
Ktor 서버 패턴
Kotlin 코루틴을 사용하여 견고하고 유지보수 가능한 HTTP 서버를 구축하기 위한 포괄적인 Ktor 패턴입니다.
활성화 시점
- Ktor HTTP 서버를 구축할 때
- Ktor 플러그인(Auth, CORS, ContentNegotiation, StatusPages)을 구성할 때
- Ktor로 REST API를 구현할 때
- Koin으로 의존성 주입을 설정할 때
- testApplication을 사용하여 Ktor 통합 테스트를 작성할 때
- Ktor에서 웹소켓(WebSockets)을 처리할 때
애플리케이션 구조
표준 Ktor 프로젝트 레이아웃
src/main/kotlin/
├── com/example/
│ ├── Application.kt # 엔트리 포인트, 모듈 구성
│ ├── plugins/
│ │ ├── Routing.kt # 라우트 정의
│ │ ├── Serialization.kt # 콘텐츠 협상 설정
│ │ ├── Authentication.kt # 인증 구성
│ │ ├── StatusPages.kt # 오류 처리
│ │ └── CORS.kt # CORS 구성
│ ├── routes/
│ │ ├── UserRoutes.kt # /users 엔드포인트
│ │ ├── AuthRoutes.kt # /auth 엔드포인트
│ │ └── HealthRoutes.kt # /health 엔드포인트
│ ├── models/
│ │ ├── User.kt # 도메인 모델
│ │ └── ApiResponse.kt # 응답 봉투(Envelopes)
│ ├── services/
│ │ ├── UserService.kt # 비즈니스 로직
│ │ └── AuthService.kt # 인증 로직
│ ├── repositories/
│ │ ├── UserRepository.kt # 데이터 액세스 인터페이스
│ │ └── ExposedUserRepository.kt
│ └── di/
│ └── AppModule.kt # Koin 모듈
src/test/kotlin/
├── com/example/
│ ├── routes/
│ │ └── UserRoutesTest.kt
│ └── services/
│ └── UserServiceTest.kt
애플리케이션 엔트리 포인트
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
configureSerialization()
configureAuthentication()
configureStatusPages()
configureCORS()
configureDI()
configureRouting()
}
라우팅 DSL
기본 라우트
fun Application.configureRouting() {
routing {
userRoutes()
authRoutes()
healthRoutes()
}
}
fun Route.userRoutes() {
val userService by inject<UserService>()
route("/users") {
get {
val users = userService.getAll()
call.respond(users)
}
get("/{id}") {
val id = call.parameters["id"]
?: return@get call.respond(HttpStatusCode.BadRequest, "ID 누락")
val user = userService.getById(id)
?: return@get call.respond(HttpStatusCode.NotFound)
call.respond(user)
}
post {
val request = call.receive<CreateUserRequest>()
val user = userService.create(request)
call.respond(HttpStatusCode.Created, user)
}
put("/{id}") {
val id = call.parameters["id"]
?: return@put call.respond(HttpStatusCode.BadRequest, "ID 누락")
val request = call.receive<UpdateUserRequest>()
val user = userService.update(id, request)
?: return@put call.respond(HttpStatusCode.NotFound)
call.respond(user)
}
delete("/{id}") {
val id = call.parameters["id"]
?: call.respond(HttpStatusCode.BadRequest, )
deleted = userService.delete(id)
(deleted) call.respond(HttpStatusCode.NoContent)
call.respond(HttpStatusCode.NotFound)
}
}
}
인증된 라우트를 포함한 라우트 구성
fun Route.userRoutes() {
route("/users") {
get { }
get("/{id}") { }
authenticate("jwt") {
post { }
put("/{id}") { }
delete("/{id}") { }
}
}
}
콘텐츠 협상 및 직렬화 (Serialization)
kotlinx.serialization 설정
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = false
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
})
}
}
직렬화 가능한 모델
@Serializable
data class UserResponse(
val id: String,
val name: String,
val email: String,
val role: Role,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
)
@Serializable
data class CreateUserRequest(
val name: String,
val email: String,
val role: Role = Role.USER,
)
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: String? = null,
) {
companion object {
fun <T> ok(data: T): ApiResponse<T> = ApiResponse(success = true, data = data)
fun <T> error(message: String): ApiResponse<T> = ApiResponse(success = false, error = message)
}
}
@Serializable
data class PaginatedResponse<>(
: List<T>,
total: ,
page: ,
limit: ,
)
맞춤형 직렬화 도구 (Custom Serializers)
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant =
Instant.parse(decoder.decodeString())
}
인증 (Authentication)
JWT 인증
fun Application.configureAuthentication() {
val jwtSecret = environment.config.property("jwt.secret").getString()
val jwtIssuer = environment.config.property("jwt.issuer").getString()
val jwtAudience = environment.config.property("jwt.audience").getString()
val jwtRealm = environment.config.property("jwt.realm").getString()
install(Authentication) {
jwt("jwt") {
realm = jwtRealm
verifier(
JWT.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtIssuer)
.build()
)
validate { credential ->
if (credential.payload.audience.contains(jwtAudience)) {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, ApiResponse.error<Unit>("유효하지 않거나 만료된 토큰"))
}
}
}
}
fun ApplicationCall.userId(): String =
principal<JWTPrincipal>()
?.payload
?.getClaim("userId")
?.asString()
?: throw AuthenticationException("토큰에 userId가 없습니다")
인증 라우트
fun Route.authRoutes() {
val authService by inject<AuthService>()
route("/auth") {
post("/login") {
val request = call.receive<LoginRequest>()
val token = authService.login(request.email, request.password)
?: return@post call.respond(
HttpStatusCode.Unauthorized,
ApiResponse.error<Unit>("유효하지 않은 자격 증명"),
)
call.respond(ApiResponse.ok(TokenResponse(token)))
}
post("/register") {
val request = call.receive<RegisterRequest>()
val user = authService.register(request)
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
}
authenticate("jwt") {
get("/me") {
val userId = call.userId()
val user = authService.getProfile(userId)
call.respond(ApiResponse.ok(user))
}
}
}
}
상태 페이지 (Status Pages - 오류 처리)
fun Application.configureStatusPages() {
install(StatusPages) {
exception<ContentTransformationException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("잘못된 요청 본문: ${cause.message}"),
)
}
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>(cause.message ?: "잘못된 요청"),
)
}
exception<AuthenticationException> { call, _ ->
call.respond(
HttpStatusCode.Unauthorized,
ApiResponse.error<Unit>("인증이 필요합니다"),
)
}
exception<AuthorizationException> { call, _ ->
call.respond(
HttpStatusCode.Forbidden,
ApiResponse.error<Unit>("접근이 거부되었습니다"),
)
}
exception<NotFoundException> { call, cause ->
call.respond(
HttpStatusCode.NotFound,
ApiResponse.error<Unit>(cause.message ?: "리소스를 찾을 수 없습니다"),
)
}
exception<Throwable> { call, cause ->
call.application.log.error("처리되지 않은 예외 발생", cause)
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse.error<Unit>("내부 서버 오류"),
)
}
status(HttpStatusCode.NotFound) { call, status ->
call.respond(status, ApiResponse.error<Unit>("라우트를 찾을 수 없습니다"))
}
}
}
CORS 구성
fun Application.configureCORS() {
install(CORS) {
allowHost("localhost:3000")
allowHost("example.com", schemes = listOf("https"))
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowCredentials = true
maxAgeInSeconds = 3600
}
}
Koin 의존성 주입
모듈 정의
val appModule = module {
single<Database> { DatabaseFactory.create(get()) }
single<UserRepository> { ExposedUserRepository(get()) }
single<OrderRepository> { ExposedOrderRepository(get()) }
single { UserService(get()) }
single { OrderService(get(), get()) }
single { AuthService(get(), get()) }
}
fun Application.configureDI() {
install(Koin) {
modules(appModule)
}
}
라우트에서 Koin 사용하기
fun Route.userRoutes() {
val userService by inject<UserService>()
route("/users") {
get {
val users = userService.getAll()
call.respond(ApiResponse.ok(users))
}
}
}
테스트를 위한 Koin
class UserServiceTest : FunSpec(), KoinTest {
override fun extensions() = listOf(KoinExtension(testModule))
private val testModule = module {
single<UserRepository> { mockk() }
single { UserService(get()) }
}
private val repository by inject<UserRepository>()
private val service by inject<UserService>()
init {
test("getUser가 사용자를 반환함") {
coEvery { repository.findById("1") } returns testUser
service.getById("1") shouldBe testUser
}
}
}
요청 검증 (Request Validation)
fun Route.userRoutes() {
val userService by inject<UserService>()
post("/users") {
val request = call.receive<CreateUserRequest>()
require(request.name.isNotBlank()) { "이름은 필수입니다" }
require(request.name.length <= 100) { "이름은 100자 이내여야 합니다" }
require(request.email.matches(Regex(".+@.+\\..+"))) { "유효하지 않은 이메일 형식" }
val user = userService.create(request)
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
}
}
fun CreateUserRequest.validate() {
require(name.isNotBlank()) { "이름은 필수입니다" }
require(name.length <= 100) { "이름은 100자 이내여야 합니다" }
require(email.matches(Regex(".+@.+\\..+"))) { "유효하지 않은 이메일 형식" }
}
웹소켓 (WebSockets)
fun Application.configureWebSockets() {
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 15.seconds
maxFrameSize = 64 * 1024
masking = false
}
}
fun Route.chatRoutes() {
val connections = Collections.synchronizedSet<Connection>(LinkedHashSet())
webSocket("/chat") {
val thisConnection = Connection(this)
connections += thisConnection
try {
send("연결됨! 온라인 사용자: ${connections.size}")
for (frame in incoming) {
frame as? Frame.Text ?: continue
val text = frame.readText()
val message = ChatMessage(thisConnection.name, text)
val snapshot = synchronized(connections) { connections.toList() }
snapshot.forEach { conn ->
conn.session.send(Json.encodeToString(message))
}
}
} catch (e: Exception) {
logger.error("웹소켓 오류", e)
} finally {
connections -= thisConnection
}
}
}
data class Connection(val session: DefaultWebSocketSession) {
val name: String = "User-"
{
counter = AtomicInteger()
}
}
testApplication 테스트
기본 라우트 테스트
class UserRoutesTest : FunSpec({
test("GET /users가 사용자 목록을 반환함") {
testApplication {
application {
install(Koin) { modules(testModule) }
configureSerialization()
configureRouting()
}
val response = client.get("/users")
response.status shouldBe HttpStatusCode.OK
val body = response.body<ApiResponse<List<UserResponse>>>()
body.success shouldBe true
body.data.shouldNotBeNull().shouldNotBeEmpty()
}
}
test("POST /users가 사용자를 생성함") {
testApplication {
application {
install(Koin) { modules(testModule) }
configureSerialization()
configureStatusPages()
configureRouting()
}
val client = createClient {
install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) {
json()
}
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("Alice", "alice@example.com"))
}
response.status shouldBe HttpStatusCode.Created
}
}
test("GET /users/{id}가 알 수 없는 ID에 대해 404를 반환함") {
testApplication {
application {
install(Koin) { modules(testModule) }
configureSerialization()
configureStatusPages()
configureRouting()
}
val response = client.get("/users/unknown-id")
response.status shouldBe HttpStatusCode.NotFound
}
}
})
인증된 라우트 테스트
class AuthenticatedRoutesTest : FunSpec({
test("보호된 라우트에는 JWT가 필요함") {
testApplication {
application {
install(Koin) { modules(testModule) }
configureSerialization()
configureAuthentication()
configureRouting()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("Alice", "alice@example.com"))
}
response.status shouldBe HttpStatusCode.Unauthorized
}
}
test("유효한 JWT를 사용하면 보호된 라우트 호출이 성공함") {
testApplication {
application {
install(Koin) { modules(testModule) }
configureSerialization()
configureAuthentication()
configureRouting()
}
val token = generateTestJWT(userId = "test-user")
val client = createClient {
install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() }
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
bearerAuth(token)
setBody(CreateUserRequest("Alice", "alice@example.com"))
}
response.status shouldBe HttpStatusCode.Created
}
}
})
구성 (Configuration)
application.yaml
ktor:
application:
modules:
- com.example.ApplicationKt.module
deployment:
port: 8080
jwt:
secret: ${JWT_SECRET}
issuer: "https://example.com"
audience: "https://example.com/api"
realm: "example"
database:
url: ${DATABASE_URL}
driver: "org.postgresql.Driver"
maxPoolSize: 10
구성 읽기
fun Application.configureDI() {
val dbUrl = environment.config.property("database.url").getString()
val dbDriver = environment.config.property("database.driver").getString()
val maxPoolSize = environment.config.property("database.maxPoolSize").getString().toInt()
install(Koin) {
modules(module {
single { DatabaseConfig(dbUrl, dbDriver, maxPoolSize) }
single { DatabaseFactory.create(get()) }
})
}
}
빠른 참조: Ktor 패턴
| 패턴 | 설명 |
|---|
route("/path") { get { } } | DSL을 사용한 라우트 그룹화 |
call.receive<T>() | 요청 본문 역직렬화 |
call.respond(status, body) | 상태 코드와 함께 응답 전송 |
call.parameters["id"] | 경로 파라미터 읽기 |
call.request.queryParameters["q"] | 쿼리 파라미터 읽기 |
install(Plugin) { } | 플러그인 설치 및 구성 |
authenticate("name") { } | 인증으로 라우트 보호 |
by inject<T>() | Koin 의존성 주입 |
testApplication { } | 통합 테스트 수행 |
기억하십시오: Ktor는 Kotlin 코루틴과 DSL을 중심으로 설계되었습니다. 라우트는 얇게 유지하고, 로직은 서비스로 밀어넣으며, 의존성 주입에는 Koin을 사용하십시오. 전체 통합 커버리지를 위해 testApplication으로 테스트하십시오.