From ecc
라우팅 DSL, 플러그인, 인증, Koin DI, kotlinx.serialization, 웹소켓 및 testApplication 테스트를 포함한 Ktor 서버 패턴입니다.
npx claudepluginhub sam42-lab/everything-claude-code-krThis skill uses the workspace's default tool permissions.
Kotlin 코루틴을 사용하여 견고하고 유지보수 가능한 HTTP 서버를 구축하기 위한 포괄적인 Ktor 패턴입니다.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
Kotlin 코루틴을 사용하여 견고하고 유지보수 가능한 HTTP 서버를 구축하기 위한 포괄적인 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
// Application.kt
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
configureSerialization()
configureAuthentication()
configureStatusPages()
configureCORS()
configureDI()
configureRouting()
}
// plugins/Routing.kt
fun Application.configureRouting() {
routing {
userRoutes()
authRoutes()
healthRoutes()
}
}
// routes/UserRoutes.kt
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"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "ID 누락")
val deleted = userService.delete(id)
if (deleted) call.respond(HttpStatusCode.NoContent)
else call.respond(HttpStatusCode.NotFound)
}
}
}
fun Route.userRoutes() {
route("/users") {
// 공개 라우트
get { /* 사용자 목록 조회 */ }
get("/{id}") { /* 사용자 상세 조회 */ }
// 보호된 라우트
authenticate("jwt") {
post { /* 사용자 생성 - 인증 필요 */ }
put("/{id}") { /* 사용자 수정 - 인증 필요 */ }
delete("/{id}") { /* 사용자 삭제 - 인증 필요 */ }
}
}
}
// plugins/Serialization.kt
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<T>(
val data: List<T>,
val total: Long,
val page: Int,
val limit: Int,
)
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())
}
// plugins/Authentication.kt
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>("유효하지 않거나 만료된 토큰"))
}
}
}
}
// JWT에서 사용자 ID 추출
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))
}
}
}
}
// plugins/StatusPages.kt
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>("라우트를 찾을 수 없습니다"))
}
}
}
// plugins/CORS.kt
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
}
}
// di/AppModule.kt
val appModule = module {
// 데이터베이스
single<Database> { DatabaseFactory.create(get()) }
// 저장소 (Repositories)
single<UserRepository> { ExposedUserRepository(get()) }
single<OrderRepository> { ExposedOrderRepository(get()) }
// 서비스 (Services)
single { UserService(get()) }
single { OrderService(get(), get()) }
single { AuthService(get(), get()) }
}
// 애플리케이션 설정
fun Application.configureDI() {
install(Koin) {
modules(appModule)
}
}
fun Route.userRoutes() {
val userService by inject<UserService>()
route("/users") {
get {
val users = userService.getAll()
call.respond(ApiResponse.ok(users))
}
}
}
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
}
}
}
// 라우트에서 요청 데이터 검증
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(".+@.+\\..+"))) { "유효하지 않은 이메일 형식" }
}
fun Application.configureWebSockets() {
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 15.seconds
maxFrameSize = 64 * 1024 // 64 KiB — 프로토콜에 더 큰 프레임이 필요한 경우에만 늘리십시오.
masking = false // RFC 6455에 따라 서버-클라이언트 프레임은 마스킹되지 않음. 클라이언트-서버는 항상 Ktor에 의해 마스킹됨.
}
}
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)
// ConcurrentModificationException 방지를 위한 잠금 상태의 스냅샷
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.getAndIncrement()}"
companion object {
private val counter = AtomicInteger(0)
}
}
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
}
}
})
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()) }
})
}
}
| 패턴 | 설명 |
|---|---|
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으로 테스트하십시오.