생성형AI 시작하기/생성형 AI(ChatGPT) 글쓰기

Kotlin & Ktor 실전 입문서, 클린하고 확장 가능한 서버 개발(REST API 백엔드 구축부터 배포, 성능 최적화, SEO 전략까지 실무 완전 정복)

(주)올딩 2025. 7. 18.
반응형

Kotlin & Ktor 실전 입문서(클린하고 확장 가능한 서버 개발), 서항주 지


프롤로그: 왜 코틀린 그리고 Ktor인가?

소프트웨어 산업은 매년 빠르게 변화하고 있으며, 그 변화 속에서 개발자들이 사용하는 언어와 프레임워크 또한 새로운 흐름에 맞춰 진화하고 있다. 자바 생태계에서 오랜 기간 중심을 차지했던 Spring은 여전히 강력한 프레임워크이지만, 많은 개발자들이 보다 간결하고 현대적인 대안을 찾기 시작했다. 그중에서도 주목할 만한 조합이 바로 Kotlin과 Ktor이다.

Kotlin은 JetBrains에서 개발한 현대적인 프로그래밍 언어로, 간결함과 안정성을 모두 갖춘 문법 구조 덕분에 Android 개발에서 빠르게 확산되었다. 하지만 Kotlin은 모바일 개발에만 한정된 언어가 아니다. 정적 타입 기반의 안전성과 JVM 호환성을 바탕으로 서버 사이드 애플리케이션 개발에도 매우 적합하다. 실제로 JetBrains는 Kotlin을 서버 개발용 언어로도 확장하기 위해 Ktor라는 경량 서버 프레임워크를 직접 개발했다.

Ktor는 Spring Boot처럼 거대한 생태계를 갖춘 프레임워크는 아니다. 그러나 Ktor는 최소한의 코드로 HTTP 서버를 구축할 수 있도록 설계되었고, Kotlin의 언어적 장점을 최대한 활용할 수 있도록 설계되어 있어 경량 애플리케이션이나 API 서버 개발에 특히 유리하다. 이 책은 그런 Ktor의 실용성을 중심으로, 실전 환경에서 코틀린을 사용해 확장 가능하고 유지보수하기 쉬운 서버 애플리케이션을 만드는 방법을 안내한다.

많은 개발자들이 처음 Ktor를 접했을 때 느끼는 가장 큰 장점은 설정의 단순함이다. Spring에서 수많은 설정 파일과 어노테이션에 익숙했던 개발자라면, Ktor의 직관적이고 코틀린스러운 DSL 기반 라우팅 방식이 신선하게 다가올 것이다. 무엇보다도 프로젝트 구조가 명확하고, 구성 요소가 단순하며, 동시성 처리에 있어서 코루틴을 자연스럽게 활용할 수 있다는 점은 학습 곡선을 낮추는 데 큰 도움을 준다.

물론, Ktor는 만능은 아니다. 대규모 프로젝트에서 필요한 복잡한 기능들이 일부 부족하거나, 커뮤니티가 Spring에 비해 작다는 단점도 분명히 존재한다. 그러나 Ktor의 가장 큰 매력은 직접 구조를 설계하면서 백엔드 개발의 원리를 깊이 이해할 수 있게 된다는 점이다. 프레임워크에 개발자가 종속되는 것이 아니라, 개발자가 프레임워크를 자유롭게 조립하고 활용할 수 있다는 철학이 느껴지는 설계다.

이 책의 목적은 Ktor의 모든 기능을 설명하는 데 있지 않다. 오히려 이 책은 실전 개발에서 꼭 필요한 기능들만을 선별하여, 실습을 통해 자연스럽게 익히고, Ktor를 기반으로 한 API 서버를 직접 구현하면서 실무에서 바로 활용할 수 있는 역량을 기르는 데 초점을 맞추고 있다.

실습 중심이라는 이 책의 특징은 다음과 같은 방식으로 전개된다. 초반에는 Kotlin의 기초 문법을 실무 관점에서 다시 정리하고, 그 위에 Ktor의 구조를 하나씩 쌓아 나간다. 이후에는 RESTful API 서버를 구축하고, 인증과 권한 부여, 데이터베이스 연동, 테스트, 배포까지 포함한 실제 서비스 수준의 애플리케이션을 구현한다. 마지막에는 이 모든 과정을 통해 개발한 프로젝트를 활용하여 기술 블로그에 글을 작성하고, 포트폴리오로 활용하는 방법까지 다룬다.

프로그래밍 언어와 프레임워크는 단순한 도구에 불과하지만, 그 도구를 얼마나 효율적으로 사용할 수 있느냐에 따라 생산성과 성장 속도는 크게 달라진다. 이 책은 Kotlin과 Ktor라는 도구를 깊이 있게 다루며, 실제 서비스 개발에 적용하는 과정을 통해 독자 스스로 서버 개발 능력을 탄탄히 다질 수 있도록 돕는다.

마지막으로 이 책은 단순한 기술서가 아니다. 실전에서 부딪히는 문제를 해결하고, 기술적 선택의 이유를 스스로 판단할 수 있도록 돕는 "성장형 입문서" 를 지향한다. 개발자로서 한 단계 도약하고 싶은 독자에게 이 책이 좋은 징검다리가 되기를 바란다. 이제 Kotlin과 Ktor의 세계로 본격적으로 들어가보자.


 

1장. 코틀린으로 백엔드 개발하기에 앞서

Kotlin은 처음에는 Android 개발을 위해 도입되었지만, 현재는 JVM 위에서 실행되는 범용 언어로서 그 쓰임새가 점점 확대되고 있다. 이 장에서는 Kotlin이라는 언어가 왜 백엔드 개발에도 적합한지를 다루며, 기존 Java 기반 개발 경험이 있는 독자와 전혀 없는 독자를 모두 아우를 수 있도록 언어적 특징과 실무 적용 관점에서의 장점을 설명한다.

또한 이 장은 단순한 문법 소개보다는, 코틀린이 실제 백엔드 개발 시 어떤 효율성과 생산성을 제공하는지에 대한 근거 있는 설명을 제공하고, 이후 장들에서 실습을 진행하기 위한 기본 환경 구성까지 안내한다.

1.1 Kotlin의 시작과 진화

Kotlin은 2011년 JetBrains에서 처음 발표한 언어다. 이 언어의 가장 큰 목표는 "실용적인 현대 언어"이다. Kotlin은 Java와 100% 상호운용 가능하며, Java에서 발생하는 여러 불편함을 해소하는 데 초점을 맞췄다. 특히 Null 안정성, 간결한 문법, 함수형 프로그래밍 요소의 도입은 Kotlin의 주요 장점이다.

JetBrains는 Android Studio의 기반이 되는 IntelliJ IDEA를 만든 회사이기도 하며, 이들이 직접 사용하고 개선하기 위해 만든 언어인 만큼, Kotlin은 실무적인 요구사항을 잘 반영하고 있다. 특히 2017년 구글이 공식적으로 Android의 주 언어로 Kotlin을 채택하면서 빠르게 확산되기 시작했으며, 그 후 백엔드, 데스크탑, 웹, 멀티플랫폼까지 그 영역을 넓혀가고 있다.

백엔드 개발 영역에서 Kotlin은 Spring Framework의 공식 지원을 받으면서 점차 사용이 증가했다. 그러나 Kotlin 본연의 철학을 최대한 살릴 수 있는 서버 프레임워크로는 Ktor가 주목을 받고 있으며, 그 이유는 Kotlin의 코루틴, DSL(도메인 특화 언어), 함수형 스타일이 매우 자연스럽게 반영되어 있기 때문이다.

1.2 Java와 Kotlin의 차이점: 실무 관점 비교

기존에 Java로 서버 개발을 해본 개발자라면 Kotlin의 장점이 더욱 뚜렷하게 느껴질 것이다. 몇 가지 주요 비교 포인트를 실무 관점에서 살펴보자.

  1. Null 안정성
    Java에서는 NPE(NullPointerException)가 자주 발생하는데, Kotlin은 타입 시스템 자체에 null을 포함시켜 컴파일 단계에서 이를 방지한다.
  2. 데이터 클래스
    Java에서는 객체의 상태를 표현하기 위한 클래스에 getter, setter, toString, equals, hashCode 등을 일일이 구현해야 한다. Kotlin은 data class 키워드 하나로 이 모든 작업을 자동으로 처리해준다.
  3. 간결한 람다 표현
    Kotlin은 고차 함수와 람다를 매우 간결하게 표현할 수 있어, 비즈니스 로직이 복잡한 백엔드 서비스에서도 깔끔한 코드 유지가 가능하다.
  4. 기본 제공 컬렉션 처리 함수
    map, filter, reduce, fold 등 다양한 컬렉션 확장 함수를 기본 제공하며, 자바보다 훨씬 간결하고 읽기 쉬운 코드 작성을 가능하게 한다.
  5. Extension Function
    기존 클래스를 수정하지 않고 기능을 확장할 수 있어, 실제 서비스 코드의 재사용성과 가독성이 크게 향상된다.

이러한 특성 덕분에 Kotlin은 Java 대비 코드 라인이 줄어들고, 오류 발생 가능성이 줄며, 유지보수가 쉬운 서버 애플리케이션을 작성할 수 있게 해준다.

1.3 Kotlin 백엔드의 기술 스택

Kotlin 백엔드 개발 시 사용할 수 있는 다양한 기술 스택이 존재한다. 그중에서도 이 책에서는 아래와 같은 조합을 사용한다.

  • Ktor: Kotlin 공식 서버 프레임워크. REST API 개발에 최적화됨.
  • Exposed: JetBrains가 만든 Kotlin 기반 SQL ORM.
  • Kotlinx.serialization: Kotlin 기반 직렬화 라이브러리. Ktor와 자연스럽게 통합됨.
  • HikariCP: 빠르고 안정적인 커넥션 풀 관리.
  • Logback + Kotlin Logging: 효율적인 로그 처리.
  • PostgreSQL 또는 MySQL: 주요 관계형 데이터베이스.
  • JUnit5 + Kotest: 테스트 프레임워크 조합.

또한 클라우드 환경 배포를 위해 Docker, AWS EC2, Nginx 등도 함께 다룰 예정이며, CI/CD 구성에는 GitHub Actions를 사용할 계획이다.

1.4 Kotlin 개발 환경 설정

실습을 원활하게 진행하기 위해 먼저 개발 환경을 설정해야 한다. Kotlin과 Ktor는 JVM 기반이므로 기본적인 JDK 설치가 선행되어야 하며, JetBrains의 IntelliJ IDEA를 사용하는 것이 권장된다.

  1. JDK 설치: 최소 JDK 11 이상을 설치한다. OpenJDK 또는 Temurin 사용 가능.
  2. IntelliJ IDEA 설치: Community Edition도 충분하나, Ultimate Edition 사용 시 더 다양한 플러그인 활용이 가능하다.
  3. Kotlin 플러그인: 최신 버전으로 설치되어 있는지 확인.
  4. Gradle 설정: Kotlin DSL을 사용할 예정이며, Gradle 7 이상 권장.
  5. Ktor 프로젝트 템플릿: 공식 사이트에서 템플릿을 다운로드하거나, IntelliJ에서 직접 생성 가능.

1.5 Kotlin 코딩 스타일과 권장 패턴

Kotlin은 코드 스타일이 비교적 자유로운 편이지만, JetBrains와 커뮤니티에서 권장하는 몇 가지 스타일 가이드가 존재한다. 실무에서는 다음과 같은 원칙을 따르는 것이 좋다.

  • 한 줄에 너무 많은 로직을 담지 않는다.
    Kotlin이 간결하다고 해서 모든 코드를 한 줄에 작성하는 것은 가독성을 해칠 수 있다.
  • 명확한 함수 분리
    각 함수는 하나의 책임만 수행하게 하고, 이름은 함수의 목적을 명확하게 나타내도록 작성한다.
  • nullable 타입의 활용 최소화
    가능하다면 nullable이 아닌 타입을 기본으로 사용하고, null이 허용되는 경우만 예외적으로 처리한다.
  • 기본 제공 컬렉션 함수 활용
    직접 for문을 돌리기보다 map, filter, forEach 등을 적극적으로 사용한다.
  • immutable 객체 우선 사용
    가능한 불변 객체를 사용하여 상태 변화로 인한 사이드 이펙트를 줄인다.

1.6 Ktor와의 첫 연결 준비

Kotlin이 준비되었다면, 이제 Ktor를 사용하기 위한 기본적인 준비 단계에 진입하게 된다. Ktor는 매우 경량화된 프레임워크이기 때문에, 서버 설정이나 라우팅 구성이 직관적이고 간단하다.

다음 장에서는 Kotlin 문법을 간결하게 복습하면서, 실제 Ktor 프로젝트에 어떻게 활용할 수 있는지 예시 중심으로 설명한다. 특히 함수형 프로그래밍, 고차 함수, 확장 함수 등 코틀린의 주요 기능들이 백엔드 개발에서 어떻게 활용되는지에 집중한다.


 

2장. 코틀린 문법 복습 – 실무 관점으로

Kotlin은 간결한 문법과 강력한 언어 기능으로 많은 개발자들의 사랑을 받고 있다. 하지만 Kotlin의 문법은 배우기 쉽다는 인식과 달리, 실무에서 이를 적절히 활용하려면 분명한 이해가 필요하다. 특히 백엔드 개발에서는 함수형 프로그래밍 요소, Null 안정성, 확장 함수, 코루틴 등 Kotlin 고유의 특성이 중요한 역할을 한다.

이 장에서는 Kotlin 문법을 기초부터 실무 중심으로 정리하되, 백엔드 애플리케이션을 개발할 때 자주 마주치게 될 코드들을 중심으로 설명한다. 가능한 한 모든 설명은 실전에서 쓸 수 있는 패턴과 예제 코드로 구성하고, 개발자들이 실수하기 쉬운 포인트도 함께 짚는다.


2.1 변수와 상수, 타입 추론

Kotlin에서는 변수 선언 시 val과 var을 사용한다. val은 불변(immutable), var는 가변(mutable) 변수다. 실무에서는 가능한 한 val을 사용하여 사이드 이펙트를 줄이는 것이 권장된다.

val name = "Alice"      // 문자열 상수
var age = 30            // 정수 가변 변수

Kotlin은 타입 추론이 가능하여 명시적으로 타입을 적지 않아도 된다. 하지만 복잡한 함수나 API에서는 타입 명시가 가독성을 높이는 경우도 많으므로 상황에 따라 적절히 사용하는 것이 좋다.


2.2 조건문과 표현식

Kotlin의 조건문은 if 뿐 아니라 when 표현식을 사용할 수 있으며, 둘 다 값을 반환하는 표현식으로 사용 가능하다. 이는 Java와의 중요한 차이점이다.

val status = if (age > 18) "성인" else "미성년자"

또한 when은 복잡한 조건 분기를 깔끔하게 처리할 수 있다.

val grade = when (score) {
    in 90..100 -> "A"
    in 80..89 -> "B"
    else -> "F"
}

실무에서는 REST API 응답 상태 코드, 사용자 권한 분기, 로깅 레벨 처리 등에서 when 표현식을 자주 사용하게 된다.


2.3 함수 선언과 고차 함수

Kotlin은 함수 선언이 간결하고, 함수형 프로그래밍을 지향하는 구조를 가진다. 함수를 변수처럼 다룰 수 있고, 고차 함수(Higher-Order Function)를 자유롭게 사용할 수 있다.

fun greet(name: String): String {
    return "Hello, $name"
}

실무에서는 함수를 파라미터로 받는 경우가 많다. 예를 들어, API 요청 핸들러에서 미들웨어처럼 동작하는 구조를 만들 수 있다.

fun handleRequest(handler: () -> String) {
    println("Request 시작")
    val response = handler()
    println("Response: $response")
}

함수 타입을 명시할 수도 있다.

val multiplier: (Int, Int) -> Int = { a, b -> a * b }

2.4 클래스, 생성자, 데이터 클래스

Kotlin에서 클래스 선언은 매우 간결하며, 생성자와 프로퍼티를 동시에 정의할 수 있다.

class User(val name: String, var age: Int)

data class는 특히 백엔드 API에서 DTO나 응답 객체로 자주 사용된다.

data class Post(val id: Long, val title: String, val content: String)

equals, hashCode, toString, copy 등의 메서드가 자동 생성되므로 유지보수에 매우 유리하다.


2.5 Nullable 타입과 Null 안정성

Kotlin의 가장 큰 특징 중 하나는 Null 안정성을 타입 시스템에 내장했다는 점이다. String과 String?은 서로 완전히 다른 타입이다.

val nonNull: String = "Hello"
val nullable: String? = null

nullable 타입은 ?., ?:, !! 등의 연산자를 사용해 다룬다.

val length = nullable?.length ?: 0

실무에서 가장 많이 실수하는 부분이 바로 nullable 처리다. 특히 외부 API 응답이나 DB 결과를 다룰 때는 항상 nullable 여부를 체크해야 한다.


2.6 확장 함수와 유틸 함수 작성법

확장 함수(Extension Function)는 기존 클래스에 기능을 추가할 수 있는 Kotlin의 강력한 기능이다. 예를 들어, String에 커스텀 함수 추가:

fun String.truncate(limit: Int): String {
    return if (this.length > limit) this.substring(0, limit) + "..." else this
}

백엔드 프로젝트에서는 공통 유효성 검사, 날짜 포맷, 커스텀 로깅 등을 확장 함수로 처리하는 경우가 많다.


2.7 컬렉션 처리: map, filter, reduce

Kotlin은 컬렉션 처리에 있어 매우 풍부한 기능을 제공한다. 특히 map, filter, groupBy, fold, reduce 등은 데이터 가공 시 자주 사용된다.

val names = listOf("Alice", "Bob", "Charlie")
val upper = names.map { it.uppercase() }.filter { it.startsWith("A") }

비즈니스 로직에서 JSON 변환, DB 조회 결과 처리 등 컬렉션 로직이 자주 등장하므로 이 부분은 반드시 익숙해져야 한다.


2.8 객체 선언과 싱글턴

Kotlin에서는 object 키워드를 통해 간단하게 싱글턴을 선언할 수 있다.

object AppConfig {
    val version = "1.0.0"
}

Ktor 설정 정보, Logger 유틸, 상수 정의 등에 유용하게 활용된다.


2.9 Sealed Class와 상태 모델링

Kotlin의 sealed class는 상태를 명확히 구분지을 때 매우 효과적이다. 특히 응답 결과나 에러 상태를 다룰 때 자주 사용된다.

sealed class Result
data class Success(val data: String): Result()
data class Failure(val message: String): Result()

when을 통해 타입 별로 분기할 수 있다. 이 방식은 null 체크 없이 안전한 처리 흐름을 만들 수 있게 해준다.


2.10 코루틴 소개

Kotlin의 가장 핵심적인 기능 중 하나가 코루틴이다. 비동기 처리를 마치 동기식처럼 작성할 수 있으며, Ktor는 코루틴 기반으로 설계되어 있다.

suspend fun fetchData(): String {
    delay(1000)
    return "데이터"
}

launch, async, await, withContext 등은 다음 장에서 자세히 다룰 예정이다. 본 장에서는 suspend 함수의 개념을 이해하고, 일반 함수와의 차이점만 확인해두자.


2.11 예외 처리와 안전한 코드 작성

Kotlin에서는 Java와 동일하게 try-catch를 사용할 수 있다. 하지만 null 처리와 결합하면 더 강력한 오류 방지 구조를 만들 수 있다.

fun parseIntOrNull(input: String): Int? {
    return try {
        input.toInt()
    } catch (e: NumberFormatException) {
        null
    }
}

실무에서는 예외를 그대로 던지기보다, nullable로 안전하게 처리하는 방식이 선호된다.


2.12 실제 프로젝트에서 사용하는 패턴 예시

마지막으로, 간단한 사용자 생성 API 요청을 처리하는 DTO와 서비스 함수 예제를 보자.

data class CreateUserRequest(val name: String, val email: String)

fun validate(request: CreateUserRequest): Boolean {
    return request.name.isNotBlank() && request.email.contains("@")
}

fun createUser(request: CreateUserRequest): String {
    return if (validate(request)) {
        "사용자 생성 완료"
    } else {
        "입력값 오류"
    }
}

이와 같이 코틀린 문법은 매우 간단하고 명확하게 표현할 수 있으며, 실무에서도 안정성과 가독성 모두를 갖춘 구조를 쉽게 만들 수 있다.


 

3장. 객체지향 + 함수형 패러다임을 활용한 설계

Kotlin은 객체지향 언어이면서도 함수형 프로그래밍의 개념을 강하게 내포한 하이브리드 언어다. 이 장에서는 두 패러다임이 어떻게 Kotlin에서 자연스럽게 결합되는지를 설명하고, 이를 실무적인 백엔드 설계에 어떻게 적용할 수 있을지 구체적인 사례와 함께 다룬다.

단순히 이론적인 설명에 그치지 않고, 실제로 코드를 어떻게 구조화할 수 있는지, 어떤 방식이 테스트와 유지보수에 유리한지를 중심으로 실용적인 설계 철학을 제시한다.


3.1 객체지향 패러다임의 장점과 한계

객체지향 프로그래밍(OOP)은 소프트웨어 설계의 전통적인 방식이다. 클래스를 중심으로 한 책임 분리, 캡슐화, 상속, 다형성 등은 시스템을 유연하고 확장 가능하게 만드는 데 중요한 역할을 해왔다.

Kotlin에서도 이러한 객체지향 구조는 여전히 핵심 개념으로 작용한다. 예를 들어, 사용자 정보를 다루는 도메인 모델은 일반적으로 클래스 형태로 정의한다.

class User(val id: Long, val name: String, val email: String) {
    fun isEmailValid(): Boolean = email.contains("@")
}

하지만 실무에서 객체지향만으로는 다음과 같은 한계에 부딪히는 경우가 많다.

  • 유연성을 확보하려다 보면 클래스 계층이 깊어지고 복잡해진다.
  • 테스트를 위해 의존성을 분리하거나 인터페이스를 과도하게 도입하게 된다.
  • 상태를 가진 객체가 많아질수록 예측 가능한 흐름 유지가 어려워진다.

이러한 점에서 함수형 패러다임은 객체지향이 가지는 복잡성을 해소하는 도구가 될 수 있다.


3.2 함수형 프로그래밍이 주는 실용적 이점

함수형 프로그래밍(Functional Programming, FP)은 데이터와 로직을 함수 단위로 분리하고, 순수함수와 불변성을 강조하는 접근 방식이다. Kotlin은 이를 다양한 형태로 수용하고 있다.

특히 실무에서는 다음과 같은 장점이 있다.

  • 순수함수 기반 로직은 테스트가 쉬움
    같은 입력에 대해 항상 같은 출력을 내는 함수는 사이드 이펙트가 없기 때문에 테스트가 간단하고 예측 가능하다.
  • 불변성(Immutability)이 버그를 줄임
    상태 변경이 없기 때문에 동시성 이슈나 예기치 않은 부작용을 예방할 수 있다.
  • 고차 함수와 람다 사용으로 유연한 로직 처리
    공통된 로직을 캡슐화하고, 파라미터로 전달할 수 있다.

예를 들어, 사용자 입력 검증을 함수형 방식으로 처리해보면 다음과 같다.

fun isValidName(name: String): Boolean = name.length >= 2
fun isValidEmail(email: String): Boolean = email.contains("@")

fun validateUser(name: String, email: String): Boolean =
    isValidName(name) && isValidEmail(email)

이처럼 로직을 순수함수로 나누면 테스트 코드도 간결해진다.


3.3 객체지향 + 함수형의 조화: Kotlin에서의 통합 패턴

Kotlin의 장점은 객체지향과 함수형의 경계를 명확히 나누기보다 자연스럽게 결합할 수 있다는 데 있다. 핵심은 다음 두 가지 원칙을 조화시키는 것이다.

  1. 도메인(엔티티) 중심 구조는 클래스로 작성
  2. 비즈니스 로직이나 데이터 가공은 함수 중심으로 설계

예를 들어, 게시글(Post) 도메인은 클래스 형태로 설계하고, 게시글을 가공하는 로직은 함수로 분리한다.

data class Post(val id: Long, val title: String, val content: String)

fun summarize(post: Post, limit: Int): String =
    if (post.content.length > limit) post.content.substring(0, limit) + "..." else post.content

이 구조의 장점은 다음과 같다.

  • Post 자체는 단순한 데이터 컨테이너
  • 기능(요약, 필터링 등)은 외부에서 함수로 주입 가능
  • 테스트나 확장이 쉽고, 코드 의존성이 약함

3.4 도메인 설계와 계층 분리

Kotlin 백엔드에서 자주 사용하는 계층 분리 구조는 아래와 같다.

  • Controller: API 요청을 받고 응답을 반환
  • Service: 비즈니스 로직 처리
  • Repository: 데이터베이스 연동
  • Model/Entity: 도메인 객체

이러한 계층은 객체지향 패턴을 따르되, 각 계층 내부 로직은 함수형 스타일로 처리하는 것이 효과적이다.

class PostService(private val repository: PostRepository) {
    fun getRecentPosts(limit: Int): List<Post> {
        return repository.findAll().sortedByDescending { it.id }.take(limit)
    }
}

여기서 sortedByDescending, take 등은 함수형 컬렉션 처리 방식이다.


3.5 Kotlin에서 사용하는 주요 설계 패턴

Kotlin은 기존의 디자인 패턴을 단순화하거나, 불필요하게 만들기도 한다. 예를 들어 다음과 같은 패턴이 자주 사용된다.

  • Factory Pattern → 간단한 정적 함수 또는 companion object 사용
  • Builder Pattern → @DslMarker를 이용한 Kotlin DSL로 대체
  • Strategy Pattern → 함수형 인자 전달로 대체 가능
  • Singleton Pattern → object 키워드 사용

예시:

object TokenManager {
    fun generate(): String = UUID.randomUUID().toString()
}

이처럼 Kotlin에서는 클래스를 생성하지 않고도 유틸리티 성격의 객체를 바로 사용할 수 있다.


3.6 실무에서의 예외 처리 전략

함수형 프로그래밍의 특징 중 하나는 예외를 값으로 처리한다는 점이다. Kotlin에서는 이를 Result, sealed class, Either 타입 등을 통해 구현할 수 있다.

sealed class LoginResult
data class Success(val userId: Long): LoginResult()
data class Error(val reason: String): LoginResult()

fun login(email: String, password: String): LoginResult {
    return if (email == "test@test.com" && password == "1234") {
        Success(1)
    } else {
        Error("Invalid credentials")
    }
}

이렇게 설계하면 try-catch 없이도 안정적인 흐름 처리가 가능하다.


3.7 불변성 기반의 서비스 설계

Kotlin에서는 가능하면 모든 상태를 val로 정의하고, 불변 컬렉션을 사용한다. 이는 코드의 명확성을 높이고, 테스트와 디버깅을 쉽게 만든다.

예시:

val users: List<User> = listOf(User("Alice", 30), User("Bob", 25))

val adults = users.filter { it.age >= 18 }

users 리스트는 변하지 않으며, filter는 새로운 리스트를 반환한다. 이러한 방식은 side effect를 최소화한다.


3.8 Ktor 프로젝트에 적용할 아키텍처 구조

Ktor는 자유로운 구조 설계가 가능하므로, 개발자가 직접 계층과 책임을 나누는 것이 중요하다. 다음과 같은 구조가 추천된다.

  • domain/ → 도메인 모델과 핵심 로직
  • application/ → use case, 서비스 로직
  • infrastructure/ → 외부 시스템(DB, API) 연동
  • presentation/ → 라우팅, 요청/응답 처리

각 계층은 가능한 한 데이터 클래스와 순수함수 위주로 설계하되, 외부 상태에 의존하지 않도록 설계해야 유지보수가 쉬워진다.


3.9 마무리: 실무 설계의 핵심은 균형

Kotlin의 진정한 강점은 "객체지향과 함수형의 적절한 결합"에 있다. 어느 한쪽만 고수하면 코드가 지나치게 복잡하거나 비효율적으로 흘러가기 쉽다.

객체지향적인 구조 위에 함수형 스타일의 로직을 쌓는 방식은 테스트, 유지보수, 확장성 면에서 가장 안정적인 설계 방법이다. 이러한 철학은 다음 장부터 시작되는 Ktor 실습 프로젝트 전반에 걸쳐 계속 유지될 것이다.


 

4장. Ktor의 철학과 구조 이해

서버 애플리케이션을 개발하기 위한 프레임워크는 여러 가지가 존재한다. Java 생태계에서는 Spring Boot가 가장 널리 사용되며, Node.js 환경에서는 Express.js, Python에서는 Flask나 FastAPI 등이 대중적이다. 이 가운데서 Ktor는 Kotlin 언어의 장점을 가장 극대화할 수 있는 프레임워크로 주목받고 있다.

이 장에서는 Ktor가 추구하는 철학과 설계 구조를 설명하고, Ktor가 다른 프레임워크와 어떻게 다르며, 어떤 상황에 적합한지를 살펴본다. 이를 통해 이후 실습에서 등장할 다양한 구조와 개념을 이해할 수 있는 기반을 마련한다.


4.1 Ktor란 무엇인가

Ktor는 JetBrains에서 개발한 Kotlin 기반 비동기 웹 프레임워크다. 완전히 Kotlin으로 작성되었으며, 경량화된 구조와 플러그인 중심의 아키텍처로 유연한 서버 개발을 지원한다.

Ktor의 이름은 Kotlin + Server를 의미하며, 기존의 복잡한 프레임워크 구조에서 벗어나 "개발자가 필요한 기능만 선택해서 구성할 수 있도록 하는" 철학을 지향한다. 즉, 전체적인 프레임워크에 종속되기보다는 모듈형 구성과 Kotlin DSL을 통한 직관적인 설정이 특징이다.

Ktor는 크게 두 가지 방식으로 사용된다.

  • Ktor Server: REST API, 웹소켓, 인증, 라우팅 등 서버 애플리케이션 개발용
  • Ktor Client: Kotlin에서 HTTP 클라이언트를 구현할 수 있는 모듈 (멀티플랫폼 지원)

이 책에서는 서버 측에 집중하며, 실습을 통해 API 서버를 직접 구축해 나갈 예정이다.


4.2 Ktor의 핵심 철학

Ktor는 다음과 같은 철학을 기반으로 설계되었다.

  1. Kotlin스럽게 작동할 것
    다른 프레임워크들이 자바 문법에 맞춰 만들어졌다면, Ktor는 Kotlin 고유의 문법(DSL, 코루틴, 람다 등)을 최대한 활용한다.
  2. 모듈 단위로 구성할 것
    기본적으로 아무것도 포함되지 않은 상태에서 시작하며, 필요한 기능만 명시적으로 추가하는 방식이다.
  3. 비동기를 기본값으로 삼을 것
    모든 요청은 코루틴 기반으로 비동기 처리된다. 서버 응답을 기다리는 동안에도 다른 요청을 병렬로 처리할 수 있도록 설계되어 있다.
  4. 직관적인 구조를 유지할 것
    라우팅, 응답, 미들웨어 설정이 모두 코드 상에서 한눈에 보이며, XML이나 복잡한 설정 파일 없이 구성된다.

이러한 철학은 코드의 예측 가능성을 높이고, 불필요한 복잡도를 줄이는 데 큰 기여를 한다.


4.3 Ktor의 기본 구조

Ktor 프로젝트는 단순한 폴더 구조와 명확한 구성 요소로 이루어진다. 아래는 기본적인 구조 예시다.

src/
 └── main/
     └── kotlin/
         └── com/example/
             ├── Application.kt
             ├── routes/
             ├── plugins/
             └── services/
     └── resources/
         └── application.conf
  • Application.kt: Ktor 애플리케이션의 시작점이며, 플러그인 설치 및 라우팅 설정이 이뤄진다.
  • routes/: API 경로별 핸들러를 정의
  • plugins/: 인증, 로깅, CORS 등 플러그인 설정을 모아 관리
  • services/: 비즈니스 로직 계층
  • application.conf: 환경 설정 파일로, 포트 번호나 모듈 위치 등을 정의

4.4 Application 엔트리포인트 이해하기

Ktor 애플리케이션은 Application 클래스를 기반으로 동작하며, main 함수 없이 시작한다. 대신 embeddedServer 함수를 사용하거나, application.conf에서 설정된 엔트리포인트를 통해 서버가 구동된다.

기본 예시는 다음과 같다.

fun Application.module() {
    install(Routing) {
        get("/") {
            call.respondText("Hello, Ktor!")
        }
    }
}

여기서 Application.module 함수는 application.conf에 지정된 모듈 함수명과 일치해야 한다.

ktor {
    deployment {
        port = 8080
    }
    application {
        modules = [ com.example.ApplicationKt.module ]
    }
}

이러한 구조 덕분에 Ktor는 여러 개의 모듈을 설정하고 환경별로 다르게 구성할 수 있다.


4.5 플러그인 기반 구조의 이해

Ktor의 가장 큰 특징은 모든 기능이 플러그인(Plugin)으로 동작한다는 점이다. 기본적으로 아무것도 설치되지 않으며, 사용자가 직접 명시적으로 설치해야 한다.

예를 들어, 다음은 ContentNegotiation 플러그인을 설치하여 JSON 직렬화를 설정하는 코드다.

install(ContentNegotiation) {
    json()
}

마찬가지로 인증, 세션, 로깅, CORS, CallLogging, Compression 등도 모두 플러그인으로 제공된다.

이러한 플러그인 시스템은 모듈화와 책임 분리에 탁월하며, 테스트 시에도 필요한 기능만 설정할 수 있어 유연성이 높다.


4.6 Routing: 코틀린 DSL의 진면목

Ktor의 Routing은 완전한 Kotlin DSL(도메인 특화 언어)로 작성된다. 코드가 자연어처럼 읽히며, 중첩 구조를 통해 복잡한 경로를 쉽게 정의할 수 있다.

routing {
    get("/hello") {
        call.respondText("Hello!")
    }

    route("/user") {
        get {
            call.respondText("User List")
        }
        post {
            call.respondText("Create User")
        }
    }
}

이러한 DSL 구조는 직관적이며, 가독성이 매우 높다. 또한 중첩 구조로 인해 URI 경로 설계와 함수 처리를 한눈에 파악할 수 있다.


4.7 Ktor와 코루틴의 관계

Ktor는 Kotlin 코루틴을 기반으로 설계된 프레임워크다. 모든 요청 처리 함수는 비동기 처리를 위한 suspend 함수로 정의되며, 이를 통해 높은 동시성 성능을 발휘할 수 있다.

get("/delay") {
    delay(1000)
    call.respondText("응답 완료")
}

이처럼 delay를 사용하면 1초 동안 블로킹 없이 다른 요청이 동시에 처리된다. 이는 고성능 서버 애플리케이션 개발에 매우 중요한 요소이며, 코루틴을 제대로 이해하면 Ktor의 성능을 최대한 끌어낼 수 있다.


4.8 Ktor의 장점과 단점

Ktor의 장점은 다음과 같다.

  • 구성 요소가 가볍고 빠름
  • Kotlin 코드 기반으로 높은 생산성과 가독성
  • 직관적인 라우팅 및 플러그인 구조
  • 코루틴 기반의 고성능 비동기 처리
  • 테스트가 쉬운 구조 (단일 플러그인만 설치해서 테스트 가능)

하지만 단점도 분명 존재한다.

  • Spring에 비해 생태계가 작고 레퍼런스 부족
  • 처음 접하는 개발자에게는 설정 방식이 다소 낯설 수 있음
  • 기본적인 기능도 직접 구현해야 하는 경우가 있음

따라서 Ktor는 가볍고 유연한 API 서버, 내부 시스템, MVP 단계 서비스에 적합하며, 대규모 엔터프라이즈 시스템보다는 빠른 개발과 유지보수가 필요한 프로젝트에 이상적이다.


4.9 마무리: “의도된 단순함”을 이해하라

Ktor는 미리 만들어진 틀에 개발자를 맞추게 하지 않는다. 오히려 개발자가 원하는 구성으로 유연하게 설계할 수 있도록 구성되어 있다. 즉, 개발자가 프레임워크에 맞추는 것이 아니라, 프레임워크를 개발자의 설계 철학에 맞게 조정할 수 있다는 점에서 의미가 있다.

이러한 유연함은 분명 장점이지만, 동시에 책임이 따른다. 구조를 명확히 이해하지 않고 사용하는 경우, 오히려 유지보수가 어렵고 불안정한 코드가 될 수 있다. 따라서 이 장에서 다룬 Ktor의 구조와 철학을 바탕으로, 이후 실습에서는 단계적으로 기능을 쌓아가며 안정적인 서버 개발을 진행할 것이다.


5장. 프로젝트 생성부터 서버 실행까지

Ktor를 배우는 데 있어 가장 중요한 첫걸음은 직접 프로젝트를 생성하고 로컬에서 서버를 구동해보는 것이다. 이 장에서는 IntelliJ IDEA를 활용한 Ktor 프로젝트 생성부터 기본 라우팅 설정, JSON 응답 처리까지 가장 기초적인 Ktor 서버 실행 흐름을 다룬다.

Ktor는 매우 유연한 프레임워크이기 때문에 초기 설정을 정확히 이해하고 진행하는 것이 중요하다. 이 장에서 구성한 환경은 이후 실습에서도 계속 활용되므로, 구조와 설정을 확실히 이해하고 넘어가야 한다.


5.1 사전 준비 사항

프로젝트를 생성하기 전에 다음 도구들이 설치되어 있어야 한다.

  • JDK 17 이상 (JDK 11도 가능하지만, 안정성과 호환성을 위해 17 이상을 권장)
  • IntelliJ IDEA (Community 버전도 가능하지만, Ultimate 버전 사용 시 Web, DB 관련 기능 추가 지원)
  • Gradle (Kotlin DSL 기반, IntelliJ에서 자동으로 설정됨)
  • Git (선택 사항이지만 버전 관리를 위해 설치 권장)

5.2 IntelliJ에서 Ktor 프로젝트 생성하기

  1. IntelliJ IDEA 실행 후 [New Project] 클릭
  2. 왼쪽에서 Ktor 선택
  3. 프로젝트 설정:
    • Build System: Gradle
    • Kotlin DSL 선택
    • Engine: Netty
    • GroupId / ArtifactId: 예) com.example / ktor-example
  4. 플러그인 선택:
    • ContentNegotiation
    • Routing
    • CallLogging
    • Kotlinx Serialization
  5. 생성 버튼 클릭

IntelliJ는 자동으로 필요한 디렉토리 구조와 build.gradle.kts, application.conf 등의 설정 파일을 생성해준다.


5.3 디렉토리 구조 이해하기

Ktor 프로젝트 생성 후 기본 디렉토리는 다음과 같다.

src/
 └── main/
     ├── kotlin/
     │    └── com/example/Application.kt
     └── resources/
          ├── application.conf
          └── logback.xml
  • Application.kt: 서버의 진입점이자 모듈을 설정하는 파일
  • application.conf: 포트 설정, 모듈 위치, 환경별 설정이 포함된 구성 파일
  • logback.xml: 로그 출력 형식과 레벨을 설정

5.4 build.gradle.kts 설정 이해

Ktor는 Gradle Kotlin DSL 기반으로 의존성을 관리한다. 주요 항목은 다음과 같다.

val ktorVersion = "2.3.4"
val kotlinVersion = "1.9.10"

dependencies {
    implementation("io.ktor:ktor-server-core:$ktorVersion")
    implementation("io.ktor:ktor-server-netty:$ktorVersion")
    implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
    implementation("io.ktor:ktor-server-call-logging:$ktorVersion")
    testImplementation("io.ktor:ktor-server-tests:$ktorVersion")
    testImplementation(kotlin("test"))
}

추가적으로 kotlinx.serialization을 사용하기 위해 플러그인도 적용한다.

plugins {
    kotlin("jvm") version "1.9.10"
    kotlin("plugin.serialization") version "1.9.10"
    application
}

5.5 application.conf 설정하기

application.conf는 HOCON 포맷으로 작성되며, 기본 구성은 다음과 같다.

ktor {
    deployment {
        port = 8080
        watch = [ classes ]
    }
    application {
        modules = [ com.example.ApplicationKt.module ]
    }
}
  • port: 서버 실행 포트
  • watch: 코드 변경 시 자동 반영을 위한 설정 (IntelliJ에서 주로 무시됨)
  • modules: Ktor 애플리케이션의 시작 함수 (패키지명 포함)

5.6 Application.kt 작성하기

초기 기본 라우팅 코드는 매우 간단하다. Application.module 함수를 정의하고, 여기에 필요한 플러그인을 설치한다.

package com.example

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.plugins.contentnegotiation.*
import kotlinx.serialization.Serializable
import io.ktor.serialization.kotlinx.json.*

fun main() {
    embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}

fun Application.module() {
    install(ContentNegotiation) {
        json()
    }

    routing {
        get("/") {
            call.respondText("Hello, Ktor!")
        }

        get("/json") {
            call.respond(User("Kotlin", 35))
        }
    }
}

@Serializable
data class User(val name: String, val age: Int)

이 예시에서 / 경로는 텍스트를 응답하고, /json 경로는 JSON 형식으로 객체를 반환한다. Kotlinx Serialization을 통해 자동 직렬화가 이뤄진다.


5.7 서버 실행 및 테스트

  1. Application.kt에서 main() 함수 우클릭 후 Run 선택
  2. IntelliJ 하단의 Run 탭에서 로그 확인
  3. 브라우저 또는 Postman에서 http://localhost:8080/ 접속
    • "Hello, Ktor!" 텍스트 출력
  4. http://localhost:8080/json 접속
    • { "name": "Kotlin", "age": 35 } JSON 출력

5.8 서버 구조 확장 방향 소개

이제 기본적인 서버 실행이 완료되었으므로, 이후 단계에서는 아래와 같은 기능들을 하나씩 확장해 나간다.

  • 라우팅 기능 분리 및 모듈화
  • 요청 본문(JSON) 처리
  • URL 파라미터 및 쿼리 파라미터 처리
  • 예외 처리 및 공통 응답 구조 설정
  • JWT 인증 적용
  • 데이터베이스 연동

이러한 확장은 기존의 Application.module() 내부 코드를 점차 기능별로 나누면서 진행한다. 프로젝트의 유지보수성과 테스트 용이성을 위해 빠르게 모듈화를 시작하는 것이 중요하다.


5.9 마무리: 기초는 단순하게, 확장은 유연하게

이 장에서는 Ktor의 프로젝트 생성부터 실행까지 가장 기초적인 흐름을 구성해보았다. 서버의 구조는 단순하지만, 그 단순함 속에 확장성과 유연성이 존재한다.

Ktor는 사용자가 필요한 것만 설치하고 조합할 수 있도록 설계되어 있으며, 그만큼 책임도 개발자에게 있다. 플러그인 설치, 경로 분리, 예외 처리 등은 직접 구성해야 하지만, 그 과정이 바로 서버 개발의 본질을 이해하는 데 가장 중요한 학습이 된다.

다음 장에서는 실제로 URL 파라미터, 쿼리스트링, 요청 바디 등의 입력값을 처리하는 방법과 RESTful API를 설계하는 방식에 대해 알아본다.


6장. 기본 라우팅과 요청/응답 처리

Ktor에서 라우팅(Routing)은 웹 서버의 핵심 기능 중 하나다. 클라이언트가 보내는 요청을 어떤 핸들러로 연결할지 정의하고, 요청의 데이터(body, query, path)를 어떻게 받아 처리할지, 어떤 형식으로 응답할지를 설계하는 것이 라우팅의 역할이다.

이 장에서는 라우팅 DSL의 기초 문법을 살펴보고, 실습을 통해 다양한 형태의 요청과 응답 처리 방식을 다룬다. 또한 이후 장에서 기능 분리 및 구조화를 가능하게 하는 기반 구조로 확장 가능한 패턴도 소개한다.


6.1 라우팅 기본 구조

Ktor에서 라우팅은 routing {} 블록 내부에 정의된다. 각각의 HTTP 메서드(GET, POST, PUT, DELETE 등)와 경로를 바인딩하여 요청을 처리한다.

routing {
    get("/hello") {
        call.respondText("Hello, Ktor!")
    }

    post("/submit") {
        val content = call.receiveText()
        call.respondText("Received: $content")
    }
}
  • call.respondText(...): 텍스트 형태로 응답
  • call.receiveText(): 본문 내용 수신
  • call: 요청과 응답을 포함하는 컨텍스트 객체

6.2 요청 경로: 정적 vs 동적

Ktor는 라우트 경로에 동적 파라미터를 직접 정의할 수 있다. 다음은 정적 경로와 동적 경로 예시이다.

// 정적 경로
get("/users") {
    // 전체 사용자 목록
}

// 동적 경로
get("/users/{id}") {
    val id = call.parameters["id"]
    call.respondText("ID: $id")
}
  • 중괄호 {} 를 통해 URI 경로 파라미터를 정의할 수 있다.
  • call.parameters["id"] 는 해당 파라미터 값을 문자열로 반환한다.

실무에서는 이 값을 Long이나 Int로 형변환해야 하므로, 예외 처리를 동반하는 유틸 함수를 사용하는 것이 일반적이다.


6.3 쿼리 파라미터(Query String) 처리

경로가 아닌 쿼리스트링으로 데이터를 받을 수도 있다. 예를 들어 ?page=2&size=10과 같은 요청이다.

get("/posts") {
    val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
    val size = call.request.queryParameters["size"]?.toIntOrNull() ?: 10

    call.respondText("Page: $page, Size: $size")
}
  • call.request.queryParameters는 모든 쿼리 파라미터를 맵 형태로 제공한다.
  • 실무에서는 기본값을 설정하거나, 파라미터 유효성 검사를 반드시 진행해야 한다.

6.4 POST 요청에서 JSON 바디 처리하기

POST 요청 시 JSON 형식의 요청 본문(body)을 처리하는 방법은 call.receive<T>() 를 사용하는 것이다. 이때 T는 @Serializable로 선언된 데이터 클래스여야 한다.

@Serializable
data class CreateUserRequest(val name: String, val email: String)

post("/users") {
    val request = call.receive<CreateUserRequest>()
    call.respondText("User: ${request.name}, Email: ${request.email}")
}
  • ContentNegotiation 플러그인이 설치되어 있어야 JSON 역직렬화가 가능하다.
  • JSON 요청이 올바르지 않으면 400 에러가 발생하며, 이는 다음 장에서 커스터마이징할 수 있다.

6.5 JSON 응답 처리하기

응답도 마찬가지로 객체를 전달하면 자동으로 JSON으로 직렬화된다. 단, 응답 객체는 @Serializable 어노테이션이 적용된 데이터 클래스여야 한다.

@Serializable
data class ApiResponse<T>(val success: Boolean, val data: T?, val message: String?)

get("/ping") {
    call.respond(ApiResponse(success = true, data = "pong", message = null))
}
  • 응답의 일관성을 유지하기 위해 ApiResponse 형태로 감싸는 것이 일반적이다.
  • 클라이언트에서는 이 구조를 기준으로 에러 처리와 결과 파싱을 할 수 있다.

6.6 중첩 라우트와 구조화된 라우팅

Ktor에서는 라우팅을 중첩해서 정의할 수 있으며, 이를 통해 경로별 모듈화가 가능하다.

routing {
    route("/users") {
        get {
            call.respondText("사용자 목록")
        }

        get("/{id}") {
            val id = call.parameters["id"]
            call.respondText("사용자 ID: $id")
        }

        post {
            val user = call.receive<CreateUserRequest>()
            call.respondText("생성된 사용자: ${user.name}")
        }
    }
}

이 방식은 RESTful 설계에서 리소스 기반 경로 분리에 매우 유용하며, 이후 routes/UserRoutes.kt 등으로 파일을 분리하는 기반이 된다.


6.7 요청 헤더(Header)와 응답 헤더 설정

요청 헤더를 읽거나 응답 헤더를 설정하는 것도 가능하다.

get("/headers") {
    val userAgent = call.request.headers["User-Agent"]
    call.response.headers.append("X-App-Version", "1.0.0")
    call.respondText("Your agent: $userAgent")
}
  • 사용자 인증 토큰을 헤더에서 읽거나
  • 응답에 CORS, 인증 관련 헤더를 추가할 때 자주 사용된다.

6.8 라우팅을 파일로 분리하기

프로젝트가 커지면 Application.module() 함수 안에 모든 라우트를 정의하는 것은 비효율적이다. 따라서 라우팅은 파일별로 분리하여 구조화하는 것이 일반적이다.

UserRoutes.kt 예시

fun Route.userRoutes() {
    route("/users") {
        get {
            call.respondText("사용자 목록")
        }
        post {
            val request = call.receive<CreateUserRequest>()
            call.respondText("사용자 생성 완료: ${request.name}")
        }
    }
}

Application.kt에서 등록

fun Application.module() {
    install(ContentNegotiation) { json() }
    routing {
        userRoutes()
    }
}

이처럼 Route 확장 함수를 통해 파일을 나누면 가독성과 유지보수가 대폭 향상된다.


6.9 응답 코드 설정과 커스텀 응답

기본적으로 call.respond()는 200 OK 상태코드를 반환한다. 하지만 상황에 따라 응답 코드를 명시적으로 설정해야 할 때가 있다.

call.respond(HttpStatusCode.Created, "사용자 생성 완료")

또한 아래와 같은 방식으로 커스텀 메시지와 함께 특정 상태코드를 보낼 수 있다.

if (!isValidEmail(email)) {
    call.respond(HttpStatusCode.BadRequest, "유효하지 않은 이메일 형식입니다.")
}

6.10 마무리: 클린한 API 설계를 위한 첫걸음

이 장에서 다룬 라우팅, 요청 처리, 응답 처리 방식은 Ktor 서버 개발의 핵심이다. 클라이언트가 어떤 요청을 보냈는지 정확히 이해하고, 그에 맞는 응답을 정리된 구조로 반환하는 것은 API 품질의 기본이다.

Ktor는 코드 상에서 모든 라우팅과 응답 처리를 정의하므로, 개발자가 구조를 얼마나 깔끔하게 설계하는지가 프로젝트의 유지보수성과 확장성을 좌우한다.

다음 장에서는 요청 유효성 검사(Validation), 공통 응답 포맷 적용, 예외 처리 미들웨어 작성 등 실제 실무 수준의 API 서버에서 반드시 필요한 기능들을 구현해본다.


7장. 의존성 주입(DI)과 설정 관리

백엔드 애플리케이션이 복잡해질수록 각 컴포넌트 간의 결합도를 낮추고, 테스트 가능성과 유지보수성을 높이기 위해 의존성 주입(Dependency Injection, DI)이 필요하다. 또한 운영 환경, 테스트 환경, 개발 환경 등 각기 다른 설정을 일관성 있게 유지하려면 설정 관리(configuration management) 또한 체계적으로 수행해야 한다.

Ktor는 Spring처럼 프레임워크 수준에서 DI 컨테이너를 제공하지는 않지만, Kotlin 언어의 특성을 활용하여 직접 의존성 주입 구조를 구성할 수 있다. 이 장에서는 Ktor에서 DI를 구성하는 실용적인 방식과 환경별 설정값을 안전하게 관리하는 방법을 설명한다.


7.1 의존성 주입이 필요한 이유

초기 프로젝트에서는 각 컴포넌트를 직접 생성하고 내부에서 사용하는 방식이 흔하다.

class UserService {
    fun getUser(id: Long): User { ... }
}

val userService = UserService()

routing {
    get("/users/{id}") {
        val id = call.parameters["id"]?.toLongOrNull() ?: return@get
        call.respond(userService.getUser(id))
    }
}

이러한 방식은 빠르게 구현이 가능하지만, 다음과 같은 문제점이 발생한다.

  • 테스트가 어려움 (UserService를 교체할 수 없음)
  • 여러 곳에서 동일한 인스턴스를 생성하면 메모리 낭비
  • 의존성이 하드코딩되어 확장에 불리

이 문제를 해결하기 위해 의존성을 외부에서 주입하고, 서비스/레포지토리 간 관계를 명시적으로 구성하는 방식이 필요하다.


7.2 DI 구성 방식: 수동 vs 라이브러리 기반

Ktor는 기본적으로 DI 컨테이너를 제공하지 않는다. 따라서 다음 두 가지 방식 중 하나를 선택해야 한다.

1) 수동 주입 (Manual DI)

  • Kotlin 객체지향 문법을 활용해 Application.module()에서 필요한 의존성을 직접 생성하고 주입하는 방식
  • 의존성이 복잡하지 않은 소규모 프로젝트에 적합

2) 라이브러리 활용 (Koin, Dagger, Hilt 등)

  • DI 컨테이너를 사용해 의존성 생명주기 및 주입을 자동화
  • 중~대규모 프로젝트에 적합

이 책에서는 수동 주입을 기반으로 구조화된 방식을 먼저 소개하고, 이후 Koin을 연동하는 방식도 부록에서 다룬다.


7.3 수동 DI 구성하기

다음은 가장 기본적인 수동 의존성 주입 방식이다.

class UserRepository {
    fun findById(id: Long): User? { ... }
}

class UserService(private val userRepository: UserRepository) {
    fun getUser(id: Long): User? = userRepository.findById(id)
}

Application.module() 내에서 다음과 같이 생성 후 전달한다.

fun Application.module() {
    val userRepository = UserRepository()
    val userService = UserService(userRepository)

    routing {
        userRoutes(userService)
    }
}

userRoutes는 다음과 같이 구성한다.

fun Route.userRoutes(userService: UserService) {
    get("/users/{id}") {
        val id = call.parameters["id"]?.toLongOrNull() ?: return@get
        val user = userService.getUser(id)
        call.respond(user ?: HttpStatusCode.NotFound)
    }
}

이 방식은 다음과 같은 장점이 있다.

  • 명시적이고 추적 가능함
  • 테스트 시 Mock 객체로 교체 용이
  • 컨테이너를 사용하지 않아 설정이 단순

7.4 설정 값의 구조적 관리

실무에서는 데이터베이스 주소, 인증 키, 외부 API 주소 등 다양한 설정값을 관리해야 한다. 이를 위해 application.conf 또는 .env 파일을 활용하며, 코틀린에서는 이를 안전하게 불러오는 구조가 필요하다.

application.conf 예시

ktor {
  deployment {
    port = 8080
  }

  application {
    modules = [ com.example.ApplicationKt.module ]
  }

  config {
    database {
      url = "jdbc:postgresql://localhost:5432/mydb"
      user = "admin"
      password = "secret"
    }

    jwt {
      secret = "my-jwt-secret"
      issuer = "ktor-app"
      audience = "ktor-users"
    }
  }
}

설정 로딩 클래스 정의

data class DatabaseConfig(val url: String, val user: String, val password: String)
data class JwtConfig(val secret: String, val issuer: String, val audience: String)

data class AppConfig(
    val database: DatabaseConfig,
    val jwt: JwtConfig
)

fun Application.loadConfig(): AppConfig {
    val config = environment.config

    return AppConfig(
        database = DatabaseConfig(
            url = config.property("ktor.config.database.url").getString(),
            user = config.property("ktor.config.database.user").getString(),
            password = config.property("ktor.config.database.password").getString()
        ),
        jwt = JwtConfig(
            secret = config.property("ktor.config.jwt.secret").getString(),
            issuer = config.property("ktor.config.jwt.issuer").getString(),
            audience = config.property("ktor.config.jwt.audience").getString()
        )
    )
}

사용 예시

fun Application.module() {
    val config = loadConfig()

    val dbUrl = config.database.url
    val jwtSecret = config.jwt.secret

    // 필요한 서비스에 설정 전달
}

7.5 환경별 설정 분리 (dev/test/prod)

Ktor는 application.conf 외에 application-dev.conf, application-prod.conf 등으로 환경별 설정 파일을 구성할 수 있다.

기본 application.conf

include "application-${env}.conf"

실행 시 VM 옵션으로 환경 지정

-Denv=dev

이 방식을 사용하면 운영 환경과 개발 환경의 설정을 쉽게 분리할 수 있다.


7.6 설정값 보안 관리 (민감 정보)

비밀번호, API 키 등의 민감한 정보는 소스 코드나 Git에 포함되면 안 된다. 이를 위해 다음 방법을 사용할 수 있다.

  • .env 파일로 환경 변수 관리 (Dotenv 라이브러리 사용 가능)
  • CI/CD 시 환경 변수 주입
  • Docker 비밀 변수 사용 (secrets 디렉토리 활용)

실습 프로젝트에서는 application.conf는 예시 값을 포함하고, 실제 민감 값은 환경 변수로 처리하는 구조를 권장한다.


7.7 구조화된 DI와 설정 관리의 결합

의존성 주입과 설정 관리를 함께 구조화하면 아래와 같은 형태가 된다.

fun Application.module() {
    val config = loadConfig()
    val db = Database.connect(config.database.url, ...)

    val userRepository = UserRepository(db)
    val userService = UserService(userRepository)

    routing {
        userRoutes(userService)
    }
}

이 구조는 다음과 같은 이점이 있다.

  • 의존성이 명확하게 드러남
  • 테스트 및 모킹 가능
  • 환경 설정과 로직이 분리되어 유지보수 용이

7.8 마무리: 복잡한 시스템을 단순하게 유지하는 법

Ktor는 개발자에게 최대한의 유연성을 제공한다. 그러나 그 유연함 속에서 구조적 일관성을 유지하는 것이 장기적인 유지보수의 핵심이다.

이 장에서 소개한 수동 DI 방식은 중소규모 프로젝트에서는 매우 효과적이며, 나중에 Koin이나 Dagger 등 DI 라이브러리를 도입하는 것도 어렵지 않다.
또한 설정 관리를 명확하게 구조화하면, 운영 환경이 바뀌더라도 안정적으로 서비스를 유지할 수 있다.

다음 장에서는 본격적으로 JSON 직렬화와 공통 응답 구조, 예외 처리 미들웨어 등 REST API 서버로서 갖춰야 할 기본 기능을 확장한다.


 

8장. JSON 직렬화 (kotlinx.serialization / Jackson)

RESTful API 개발에서 JSON은 클라이언트와 서버 간 데이터 교환의 표준이다. 따라서 서버에서 JSON 데이터를 효과적으로 다루기 위해서는 직렬화(객체 → JSON)와 역직렬화(JSON → 객체) 기능이 필수적이다.

Ktor는 이러한 JSON 처리를 위해 ContentNegotiation 플러그인을 기반으로 작동하며, 직렬화 도구로 kotlinx.serializationJackson을 선택적으로 사용할 수 있도록 지원한다.
이 장에서는 두 도구의 차이와 선택 기준, 실제 사용법, 공통 응답 구조와 예외 처리를 어떻게 설계할 수 있는지를 단계적으로 설명한다.


8.1 ContentNegotiation 플러그인이란?

Ktor에서 JSON 직렬화 처리는 ContentNegotiation 플러그인을 통해 이루어진다. 이 플러그인은 요청의 Content-Type과 Accept 헤더를 분석하여 자동으로 JSON 처리 모드를 결정한다.

플러그인 설치는 Application.module() 함수 내부에서 다음과 같이 진행한다.

install(ContentNegotiation) {
    json()  // kotlinx.serialization 사용
}

또는 Jackson을 사용할 경우:

install(ContentNegotiation) {
    jackson()
}

이 설정을 통해 이후 모든 call.receive<T>(), call.respond(obj)와 같은 요청·응답에서 JSON 직렬화가 자동으로 적용된다.


8.2 kotlinx.serialization 사용법

kotlinx.serialization은 Kotlin 공식 직렬화 도구로, Kotlin 언어 특성을 반영하여 빠르고 안정적인 JSON 처리 기능을 제공한다.

주요 특징

  • Kotlin Native / Multiplatform 호환
  • 컴파일 타임에 직렬화 코드 생성
  • 직렬화 속도가 빠르고 구조가 단순

설정 방법

1. build.gradle.kts에 다음 의존성 추가:

implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")

 

2. plugins에 serialization 설정:

kotlin("plugin.serialization") version "1.9.10"

 

3. 응답 객체에 @Serializable 어노테이션 부여:

@Serializable
data class UserResponse(val id: Long, val name: String, val email: String)

 

4. 요청·응답 처리:

post("/user") {
    val request = call.receive<UserResponse>()
    call.respond(request.copy(name = request.name.uppercase()))
}

기본 동작

  • 모든 필드는 기본적으로 필수
  • nullable 필드는 ?로 명시
  • 기본값이 있는 경우 생략 가능
@Serializable
data class UserRequest(
    val name: String,
    val email: String,
    val age: Int = 0  // 기본값 지정
)

8.3 Jackson 사용법

Jackson은 Java 진영에서 가장 널리 쓰이는 JSON 직렬화 도구로, Ktor에서도 호환성이 좋고 유연한 기능으로 많은 실무 프로젝트에서 사용된다.

주요 특징

  • Java에서 검증된 오랜 사용 이력
  • 복잡한 JSON 구조 처리에 유리
  • 런타임 리플렉션 기반 처리

설정 방법

1. build.gradle.kts에 Jackson 모듈 추가:

implementation("io.ktor:ktor-serialization-jackson:$ktorVersion")

 

2. 플러그인 설치:

install(ContentNegotiation) {
    jackson {
        enable(SerializationFeature.INDENT_OUTPUT)
        configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
    }
}

 

3. 응답 객체에는 @JsonProperty 등을 사용할 수 있으나, Kotlin에서는 생략 가능:

data class Product(val id: Long, val name: String, val price: Double)

 

4. 요청·응답 처리 방식은 kotlinx.serialization과 동일:

val product = call.receive<Product>()
call.respond(product.copy(price = product.price * 1.1))

8.4 두 직렬화 도구 비교

항목 kotlinx.serialization Jackson
언어 설계 Kotlin 전용 Java 기반
성능 빠름 (컴파일 시 생성) 보통 (런타임 리플렉션)
설정 유연성 제한적 매우 유연
학습 난이도 낮음 중간~높음
Kotlin 멀티플랫폼 지원 미지원
실무 적용 중소규모 앱, 빠른 API 복잡한 데이터 모델, 다국어 처리

실무에서는 Jackson을 사용하는 Spring 환경에 익숙한 팀이라면 Jackson을,
Kotlin 프로젝트에 최적화된 경량 API 서버라면 kotlinx.serialization을 선택하는 것이 일반적이다.


8.5 공통 응답 포맷 설계하기

API 응답 구조를 일관되게 유지하면 클라이언트 개발이 훨씬 쉬워지고, 오류 처리가 명확해진다. 이를 위해 ApiResponse<T> 형태의 래핑 모델을 도입한다.

@Serializable
data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val message: String? = null
)

이 구조를 사용하여 모든 API에서 동일한 응답 구조를 유지할 수 있다.

get("/status") {
    call.respond(ApiResponse(success = true, data = "running"))
}

또한, 비즈니스 로직 실패 시에도 동일한 구조로 응답:

call.respond(
    HttpStatusCode.BadRequest,
    ApiResponse<Unit>(success = false, message = "필수 값이 누락되었습니다.")
)

8.6 예외 처리 미들웨어 설계

API 서버에서는 입력 오류, 역직렬화 실패, 서버 내부 오류 등을 처리할 수 있는 중앙 예외 처리 구조가 필요하다.

Ktor에서는 StatusPages 플러그인을 사용하여 예외를 통합적으로 처리할 수 있다.

install(StatusPages) {
    exception<Throwable> { call, cause ->
        call.respond(
            HttpStatusCode.InternalServerError,
            ApiResponse<Unit>(false, null, "서버 내부 오류가 발생했습니다.")
        )
    }

    exception<SerializationException> { call, cause ->
        call.respond(
            HttpStatusCode.BadRequest,
            ApiResponse<Unit>(false, null, "JSON 형식이 잘못되었습니다.")
        )
    }

    status(HttpStatusCode.NotFound) { call, _ ->
        call.respond(
            ApiResponse<Unit>(false, null, "요청한 리소스를 찾을 수 없습니다.")
        )
    }
}

이 구조는 응답 형식을 일관되게 유지하면서, 사용자에게는 명확한 에러 메시지를, 개발자에게는 예외 추적 가능성을 제공한다.


8.7 테스트 코드 작성 시 직렬화 사용

API 테스트에서도 직렬화 도구는 필수이다. 테스트 코드는 아래와 같이 직렬화를 통해 객체를 JSON으로 변환하여 요청 본문을 구성한다.

val request = CreateUserRequest("test", "test@example.com")
val json = Json.encodeToString(request)

handleRequest(HttpMethod.Post, "/users") {
    addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
    setBody(json)
}.apply {
    assertEquals(HttpStatusCode.OK, response.status())
}

Json.encodeToString()은 kotlinx.serialization에서 제공하며, Jackson을 사용하는 경우 ObjectMapper().writeValueAsString()을 사용한다.


8.8 마무리: JSON 응답의 품질이 API의 품질이다

Ktor는 직렬화 도구를 선택할 수 있도록 유연하게 설계되어 있다. 어떤 도구를 선택하든 중요한 것은 일관된 응답 구조와 안정적인 예외 처리를 구축하는 것이다.

직렬화 방식이 혼란스럽거나 응답 구조가 매번 다르다면, 클라이언트 개발자는 큰 불편을 겪게 된다. 이 장에서 다룬 공통 포맷, 오류 응답, 설정 방식은 이후 인증, DB 연동, 배포 등의 모든 과정에서 일관성 있는 API 품질을 유지하는 핵심이 된다.

다음 장에서는 로깅과 예외 처리 미들웨어를 더 확장하여 실무 수준의 공통 컴포넌트를 구성한다.


 

9장. 로깅과 예외 처리 미들웨어 작성하기

서버 개발에서 로깅(logging)과 예외 처리(exception handling)는 단순한 기능이 아니라 안정성과 신뢰성을 확보하기 위한 기반 시스템이다.
애플리케이션이 정상적으로 작동할 때는 물론, 오류가 발생했을 때에도 시스템은 예측 가능한 방식으로 동작하고, 그 원인을 추적할 수 있어야 한다.

이 장에서는 Ktor에서 로깅을 설정하는 방법과, 공통 예외 처리 미들웨어를 구축하여 일관된 API 응답 포맷과 실시간 디버깅이 가능한 구조를 만드는 방법을 설명한다.


9.1 왜 로깅과 예외 처리가 중요한가?

API 서버에서 오류는 피할 수 없다. 중요한 것은 오류가 발생했을 때 어디서, 왜, 어떤 상황에서 발생했는지를 빠르게 파악하고 대응할 수 있는지다.
이를 위해 반드시 필요한 두 가지 시스템이 있다.

  1. 로깅 시스템: 요청 흐름, 주요 이벤트, 오류 상황을 기록
  2. 예외 처리 미들웨어: 예외 발생 시 응답 형식을 제어하고, 사용자에게 명확한 메시지 전달

잘 설계된 로깅과 예외 처리는 운영 중인 서비스의 신뢰도와 안정성, 그리고 개발 및 유지보수 속도에 지대한 영향을 끼친다.


9.2 CallLogging 플러그인 설정

Ktor에서는 기본적으로 로깅 기능을 CallLogging 플러그인을 통해 제공한다.

install(CallLogging) {
    level = Level.INFO
    filter { call -> call.request.path().startsWith("/") }
}
  • level: 로그 출력 레벨 (TRACE, DEBUG, INFO, WARN, ERROR)
  • filter: 로그를 출력할 요청 조건 설정

설정 후에는 각 요청에 대해 다음과 같은 로그가 자동 출력된다.

INFO Application - HTTP call: GET - /users/1

9.3 로그백(Logback) 설정

Ktor는 기본적으로 SLF4J 기반이며, logback.xml을 통해 출력 형식과 대상 파일 등을 설정할 수 있다.

resources/logback.xml 예시:

<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
    </encoder>
  </appender>

  <logger name="io.ktor" level="INFO"/>
  <root level="INFO">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

이 설정은 콘솔에 로그를 출력하며, 실무에서는 파일, Elastic, Loki, CloudWatch 등과 연동하여 로그를 수집/분석하는 구조로 확장할 수 있다.


9.4 Kotlin Logging으로 간결한 로그 코드 작성

SLF4J에 직접 접근하는 대신 kotlin-logging 라이브러리를 사용하면, 훨씬 간결한 코드로 로그를 남길 수 있다.

build.gradle.kts에 추가:

implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")

사용 예시:

private val logger = KotlinLogging.logger {}

fun someFunction() {
    logger.info { "데이터 조회 시작" }
    logger.warn { "조건에 맞는 데이터가 없음" }
}

이 방식은 Ktor 미들웨어, 서비스 클래스, 예외 핸들러 등 어디서든 재사용 가능하다.


9.5 예외 처리 미들웨어: StatusPages 플러그인

Ktor에서 예외 처리를 위한 핵심 컴포넌트는 StatusPages 플러그인이다. 다양한 예외 상황을 하나의 중앙 미들웨어에서 처리할 수 있다.

설치 및 설정:

install(StatusPages) {
    exception<Throwable> { call, cause ->
        logger.error(cause) { "Unhandled exception" }
        call.respond(HttpStatusCode.InternalServerError, ApiResponse<Unit>(
            success = false,
            message = "서버 내부 오류가 발생했습니다."
        ))
    }

    exception<IllegalArgumentException> { call, cause ->
        call.respond(HttpStatusCode.BadRequest, ApiResponse<Unit>(
            success = false,
            message = cause.message ?: "잘못된 요청입니다."
        ))
    }

    status(HttpStatusCode.NotFound) { call, _ ->
        call.respond(ApiResponse<Unit>(
            success = false,
            message = "존재하지 않는 경로입니다."
        ))
    }
}

9.6 사용자 정의 예외 클래스 설계

복잡한 로직에서는 사용자 정의 예외 클래스를 만들어 사용하는 것이 더 명확하다.

class NotFoundException(message: String) : RuntimeException(message)
class UnauthorizedException : RuntimeException("인증이 필요합니다.")

예외 핸들러에 등록:

exception<NotFoundException> { call, cause ->
    call.respond(HttpStatusCode.NotFound, ApiResponse<Unit>(false, message = cause.message))
}

exception<UnauthorizedException> { call, _ ->
    call.respond(HttpStatusCode.Unauthorized, ApiResponse<Unit>(false, message = "인증 필요"))
}

이 방식은 비즈니스 로직에서 예외를 발생시키는 방식으로 흐름을 단순화할 수 있다.


9.7 예외 처리 기반 API 예시

서비스 함수:

fun getUserById(id: Long): User {
    return userRepository.findById(id) ?: throw NotFoundException("해당 사용자가 존재하지 않습니다.")
}

라우팅:

get("/users/{id}") {
    val id = call.parameters["id"]?.toLongOrNull() ?: throw IllegalArgumentException("ID 형식이 잘못됨")
    val user = userService.getUserById(id)
    call.respond(ApiResponse(true, data = user))
}

이 방식은 코드의 흐름을 if-else 대신 예외 처리 중심으로 구성하며, 공통 응답 구조 덕분에 클라이언트 측에서도 일관된 처리를 할 수 있다.


9.8 로깅 + 예외 처리 통합 전략

로깅과 예외 처리를 통합하면 다음과 같은 효과를 얻을 수 있다.

  • 사용자가 받은 에러 메시지와 내부 로그를 명확히 구분
  • 로그에는 내부 원인(cause), 스택트레이스 포함
  • 응답에는 사용자에게 필요한 메시지만 제공

예:

exception<Throwable> { call, cause ->
    logger.error(cause) { "예외 발생: ${call.request.uri}" }

    val message = if (cause is IllegalArgumentException) {
        cause.message ?: "잘못된 요청입니다."
    } else {
        "서버 오류가 발생했습니다."
    }

    call.respond(HttpStatusCode.InternalServerError, ApiResponse<Unit>(false, message = message))
}

9.9 테스트 환경에서 로그와 예외 처리

테스트 코드에서도 로그가 너무 많이 출력되면 분석이 어려워진다. 따라서 테스트 시에는 로그 레벨을 조절하거나, 테스트 전용 logback-test.xml 파일을 분리하는 것이 좋다.

<configuration>
  <root level="ERROR">
    <appender-ref ref="STDOUT"/>
  </root>
</configuration>

또한 테스트 환경에서는 예외 핸들러를 다르게 구성하거나, 미들웨어 설정을 스킵할 수도 있다.


9.10 마무리: API 품질의 최종 책임자, 미들웨어

Ktor에서 미들웨어 구조는 선택사항이 아니다. 실제 서비스를 운영하기 위해선 일관된 로그, 명확한 에러 응답, 그리고 예측 가능한 흐름이 필수다.

로깅과 예외 처리는 단순한 보조 도구가 아니라, API의 품질을 정의하는 핵심 설계 요소다.
이 장에서 다룬 구조를 기반으로 이후에는 인증, 데이터베이스 처리, 테스트 등 복잡한 상황에서도 일관된 흐름을 유지할 수 있게 된다.


 

10장. CORS, GZIP, 공통 설정 플러그인 구성

Ktor는 “필요한 기능을 개발자가 선택적으로 구성”하는 철학을 기반으로 설계되어 있다.
이는 자유로운 구성의 장점이 있는 동시에, 실무에서는 필요한 설정을 빠짐없이 챙겨야 한다는 의미이기도 하다.
이 장에서는 프론트엔드와의 통신을 위해 필수적인 CORS 설정, 응답 효율을 높이기 위한 GZIP 압축, 그리고 보안 및 요청 제한 관련 공통 설정을 플러그인 중심으로 구성하고 모듈화하는 방법을 설명한다.


10.1 플러그인의 역할과 장점

Ktor의 플러그인은 기존 프레임워크의 “미들웨어” 개념과 유사하다.
특정 기능(CORS 허용, 인증, Content-Type 자동 처리 등)을 서버 전역에 적용할 수 있는 구조이며, 다음과 같은 장점이 있다.

  • 전역 적용 가능 → 모든 라우터에 자동 반영
  • 조건부 적용 가능 → 경로, 메서드, 도메인 등 세분화 가능
  • 설정 분리 가능 → 유지보수성과 재사용성 향상

실무에서는 플러그인을 기능별로 분리하여 파일로 관리하고, Application.module()에서 설치한다.


10.2 CORS 정책 설정

프론트엔드 앱과 API 서버가 서로 다른 도메인에 있을 경우, 반드시 CORS(Cross-Origin Resource Sharing) 설정이 필요하다.
Ktor는 CORS 플러그인을 통해 이를 간단하게 구성할 수 있다.

설치 및 설정 예시:

install(CORS) {
    anyHost()  // 개발 단계에서만 허용 (운영에서는 도메인 제한)
    allowMethod(HttpMethod.Get)
    allowMethod(HttpMethod.Post)
    allowHeader(HttpHeaders.ContentType)
    allowHeader(HttpHeaders.Authorization)
    allowCredentials = true
}

운영 환경에서는 anyHost()를 사용하지 않고, 명시적으로 허용할 도메인을 지정해야 한다.

host("example.com", schemes = listOf("https"))
host("admin.example.com")

10.3 GZIP 압축 설정

JSON 응답은 크기가 클 수 있기 때문에, GZIP 압축을 통해 네트워크 사용량을 줄이고 응답 속도를 높일 수 있다.
Ktor는 Compression 플러그인을 통해 GZIP, Deflate 등 다양한 압축 방식을 지원한다.

GZIP 설정 예시:

install(Compression) {
    gzip {
        priority = 1.0
        matchContentType(ContentType.Application.Json)
    }
    deflate {
        priority = 10.0
        minimumSize(1024)  // 1KB 이상인 응답만 압축
    }
}

클라이언트에서 Accept-Encoding: gzip 헤더를 보냈을 때 자동으로 압축된 응답이 전송된다.


10.4 요청 크기 제한 설정

서버의 안정성을 위해 너무 큰 요청을 제한하는 설정도 필요하다.
Ktor는 기본적으로 request의 크기에 제한을 두지 않으므로, 플러그인이나 커스텀 미들웨어를 통해 직접 처리해야 한다.

예를 들어 파일 업로드나 대용량 JSON 요청이 예상될 경우:

install(DataConversion) {
    // 커스텀 바이트 수 제한 미들웨어로 별도 구현
}

혹은 라우팅 내부에서 크기 검사 로직을 직접 넣는 방식도 있다.

post("/upload") {
    val body = call.receiveChannel().toByteArray()
    if (body.size > 10 * 1024 * 1024) {
        call.respond(HttpStatusCode.PayloadTooLarge, "요청 본문이 너무 큽니다.")
        return@post
    }
}

10.5 보안 관련 기본 헤더 설정

웹 애플리케이션 보안을 위해 CSP, XSS 방지, Referrer 정책 등을 DefaultHeaders 플러그인으로 지정할 수 있다.

install(DefaultHeaders) {
    header("X-Engine", "Ktor")
    header("X-Content-Type-Options", "nosniff")
    header("X-Frame-Options", "DENY")
    header("Referrer-Policy", "no-referrer")
}

또한, TLS/SSL은 Nginx나 Cloudflare 등 프록시 레이어에서 처리하는 것이 일반적이지만, 로컬 테스트 용도로는 Ktor 자체에서 HTTPS 서버를 구성할 수도 있다.


10.6 플러그인 설정 파일로 분리하기

공통 설정을 plugins/ 디렉토리에 파일로 분리하여 유지보수를 용이하게 한다.

plugins/Cors.kt:

fun Application.configureCORS() {
    install(CORS) {
        anyHost()
        allowMethod(HttpMethod.Get)
        allowMethod(HttpMethod.Post)
        allowHeader(HttpHeaders.ContentType)
        allowHeader(HttpHeaders.Authorization)
    }
}

plugins/Compression.kt:

fun Application.configureCompression() {
    install(Compression) {
        gzip { priority = 1.0 }
    }
}

Application.module()에 등록:

fun Application.module() {
    configureCORS()
    configureCompression()
    configureRouting()
}

이 방식은 설정 변경 시 각 기능을 독립적으로 수정할 수 있고, 테스트 시 특정 플러그인만 비활성화하는 것도 쉬워진다.


10.7 환경에 따른 플러그인 동적 설정

환경이 dev, test, prod에 따라 설정을 다르게 가져가고 싶은 경우, 다음과 같이 분기 처리할 수 있다.

fun Application.configureCORS() {
    val env = environment.config.propertyOrNull("ktor.environment")?.getString() ?: "dev"

    install(CORS) {
        if (env == "dev") {
            anyHost()
        } else {
            host("example.com", schemes = listOf("https"))
        }
        allowHeader(HttpHeaders.Authorization)
    }
}

10.8 테스트 환경에서 CORS 및 GZIP 비활성화

통합 테스트 시, GZIP 압축이나 CORS 헤더로 인해 예상치 못한 문제가 발생할 수 있다.
이때는 isTestEnvironment 플래그를 조건으로 설정을 우회할 수 있다.

if (!environment.developmentMode) {
    install(Compression) { gzip() }
}

또는 테스트 전용 모듈을 따로 분리하여 테스트의 단순성과 예측 가능성을 높일 수 있다.


10.9 실무 적용 예시 요약

fun Application.module() {
    configureSecurityHeaders()
    configureCORS()
    configureCompression()
    configureMonitoring()
    configureExceptionHandling()
    configureRouting()
}
  • SecurityHeaders.kt → XSS, CSP, Referrer 설정
  • CORS.kt → CORS 정책
  • Compression.kt → GZIP
  • Monitoring.kt → CallLogging, Metrics
  • ExceptionHandling.kt → StatusPages
  • Routing.kt → API 라우트 등록

10.10 마무리: 사소해 보이는 설정이 실제 운영을 좌우한다

이 장에서 다룬 플러그인들은 단독으로는 단순해 보일 수 있다.
그러나 실제 운영 환경에서 API 서버의 성능, 보안, 클라이언트 대응 품질을 결정짓는 핵심 구성 요소다.

CORS 누락 → 프론트 통신 불가
압축 미설정 → 느린 응답, 모바일 데이터 과다 소비
헤더 설정 누락 → XSS 취약점 발생 가능성 증가

따라서 Ktor의 유연한 플러그인 구조를 적극 활용하여, 기능별로 명확히 분리된 설정 체계를 갖추는 것이 안정적이고 확장 가능한 서버 개발의 기본이다.


11장. RESTful API 만들기 – 실전 프로젝트 설계

Ktor의 기본 구조를 익혔다면 이제는 실제로 작동하는 API 시스템을 개발할 차례다.
실습을 통해 학습한 내용을 통합하고, 실전 프로젝트를 설계하며 RESTful API를 구성하는 전체적인 흐름을 경험해보게 될 것이다.

이 장에서는 개발할 프로젝트의 주제 선정부터 기능 목록 정의, DB 스키마 설계, 계층 구조 설계, URI 설계 원칙까지 실무 수준의 API 설계 과정을 다룬다.


11.1 실습 프로젝트 주제 선정

이번 실전 프로젝트의 주제는 게시판(Bulletin Board) API 서버이다.

프로젝트 요약:

  • 익명/비익명 사용자가 게시글을 작성할 수 있는 시스템
  • 게시글 목록, 상세조회, 작성, 수정, 삭제 기능
  • 댓글(Comment), 좋아요(Like) 등의 기본 인터랙션 포함
  • JWT 기반 인증 적용
  • 관리자용 API 일부 포함 (글 숨김 처리 등)

개발 목표:

  • 계층형 구조와 테스트 가능한 구조 설계
  • 공통 응답 구조 적용
  • RESTful API 원칙에 기반한 URI 및 응답 설계
  • 인증, 예외 처리, 설정 관리 등 실무 기능 반영

11.2 기본 기능 목록 정의

[사용자 기능]

  • 회원 가입 (이메일, 닉네임, 비밀번호)
  • 로그인 (JWT 토큰 발급)
  • 내 정보 조회 / 수정

[게시글 기능]

  • 게시글 작성
  • 게시글 목록 조회 (페이징, 검색 포함)
  • 게시글 상세 조회
  • 게시글 수정 / 삭제
  • 게시글 좋아요

[댓글 기능]

  • 댓글 작성
  • 댓글 목록 조회
  • 댓글 삭제

[관리자 기능]

  • 게시글 숨김 처리
  • 사용자 정지

11.3 RESTful API 설계 원칙 요약

이 프로젝트는 REST 아키텍처 스타일을 따르되, 다음의 실용적인 원칙을 따른다.

  • 자원(Resource) 은 명사형으로 표현: /posts, /users
  • 행위(Verb) 는 HTTP 메서드로 구분: GET, POST, PUT, DELETE
  • 복수형 사용: /users, /posts
  • 상태 코드는 의미에 맞게 사용:
    • 200 OK / 201 Created / 204 No Content
    • 400 Bad Request / 401 Unauthorized / 403 Forbidden / 404 Not Found
  • 응답 형식은 일관된 JSON 구조 사용

11.4 URI 구조 설계

사용자 관련:

  • POST /users → 회원가입
  • POST /auth/login → 로그인
  • GET /users/me → 내 정보 조회
  • PATCH /users/me → 내 정보 수정

게시글 관련:

  • GET /posts → 게시글 목록
  • GET /posts/{id} → 게시글 상세
  • POST /posts → 게시글 작성
  • PATCH /posts/{id} → 게시글 수정
  • DELETE /posts/{id} → 게시글 삭제
  • POST /posts/{id}/like → 게시글 좋아요

댓글 관련:

  • POST /posts/{id}/comments → 댓글 작성
  • GET /posts/{id}/comments → 댓글 목록
  • DELETE /comments/{id} → 댓글 삭제

관리자:

  • PATCH /admin/posts/{id}/hide → 게시글 숨김
  • PATCH /admin/users/{id}/ban → 사용자 정지

11.5 계층 구조 설계

서비스를 모듈화하여 다음과 같은 패키지 구조를 설계한다.

src/
 └── main/
     └── kotlin/
         └── com/example/
             ├── api/         ← Routing 정의
             ├── service/     ← 비즈니스 로직
             ├── repository/  ← DB 접근
             ├── domain/      ← 도메인 모델
             ├── dto/         ← 요청/응답 객체
             ├── config/      ← 설정 및 플러그인
             └── utils/       ← 공통 유틸

이러한 구조는 Ktor의 자유로운 구조 설계 철학을 따르면서도 실무에서 테스트와 확장을 고려한 계층 분리 방식이다.


11.6 데이터베이스 테이블 설계

ORM 도구로는 Exposed를 사용할 예정이며, 아래는 기본적인 테이블 설계이다.

users

  • id (PK)
  • email (UNIQUE)
  • nickname
  • password_hash
  • created_at
  • is_banned

posts

  • id (PK)
  • user_id (FK)
  • title
  • content
  • is_hidden
  • created_at

comments

  • id (PK)
  • post_id (FK)
  • user_id (FK)
  • content
  • created_at

likes

  • id (PK)
  • post_id (FK)
  • user_id (FK)

11.7 데이터 흐름 예시: 게시글 작성

  1. 클라이언트에서 POST /posts 요청 (JWT 포함)
  2. PostApi.kt에서 요청 라우팅
  3. 요청 본문 → CreatePostRequest
  4. 인증된 사용자 ID 획득
  5. PostService.createPost() 호출
  6. PostRepository.save()로 DB 저장
  7. 저장된 데이터로 응답 구성 → PostResponse

11.8 인증 구조 설계 (미리보기)

  • JWT 방식 사용
  • 로그인 성공 시 토큰 발급
  • 이후 요청 시 Authorization: Bearer <token> 헤더로 인증
  • 인증 실패 시 401 반환
  • 관리자 권한은 claim 또는 role 필드를 통해 판단

이 구조는 다음 장에서 직접 구현하게 될 예정이다.


11.9 유효성 검사 전략

요청값에 대한 유효성 검사는 다음 방식으로 처리한다.

  • 간단한 문자열 길이 검증 → DTO 내부에서 직접 처리
  • 복잡한 검증 → Validator 유틸 클래스 활용
  • 실패 시 IllegalArgumentException 또는 커스텀 예외 발생 → 예외 미들웨어에서 400 응답 반환

11.10 테스트 구조 미리보기

  • test/ 디렉토리에 라우팅 테스트, 서비스 로직 테스트 분리
  • Ktor의 testApplication 함수 사용
  • MockK 또는 TestContainers로 외부 의존성 분리

예시:

@Test
fun `POST /posts - 성공`() = testApplication {
    val response = client.post("/posts") {
        header(HttpHeaders.Authorization, "Bearer ...")
        setBody(Json.encodeToString(CreatePostRequest(...)))
    }
    assertEquals(HttpStatusCode.Created, response.status)
}

11.11 마무리: API 설계는 코딩보다 먼저 이루어져야 한다

API는 단순히 기능을 노출하는 창구가 아니다. 비즈니스 요구사항, 사용자 경험, 시스템 아키텍처를 연결하는 인터페이스다.
API 설계가 명확하고 일관되면, 백엔드와 프론트엔드, 기획자, QA, 운영자 모두가 예측 가능하고 효율적으로 협업할 수 있다.

이번 장에서 설계한 내용을 바탕으로, 다음 장부터는 1기능씩 직접 코드를 작성하며 실전 API를 완성해나가는 과정을 시작하게 된다.


 

12장. RESTful 엔드포인트 설계 및 구현 – 게시글 목록/상세

게시판 기능은 대부분의 서비스에서 핵심적인 역할을 한다. 사용자에게 정보를 제공하고, 소통할 수 있는 기반이 되기 때문이다.
이 장에서는 게시글 목록 조회와 상세 조회 기능을 구현하며, RESTful API 설계 원칙에 기반한 실제 서비스 구조를 갖춘 코드를 작성한다.


12.1 기능 요약 및 흐름 정리

게시글 목록 조회

  • URI: GET /posts
  • 쿼리 파라미터: page, size, search, sort
  • 결과: 게시글 리스트 + 전체 개수

게시글 상세 조회

  • URI: GET /posts/{id}
  • 경로 파라미터: 게시글 ID
  • 결과: 해당 게시글의 상세 정보

공통 사항

  • 숨김 처리된 게시글은 관리자만 조회 가능
  • 삭제된 게시글은 404 반환
  • 응답은 ApiResponse<List<PostResponse>> 형태로 반환

12.2 DTO 설계

@Serializable
data class PostResponse(
    val id: Long,
    val title: String,
    val content: String,
    val author: String,
    val createdAt: String
)

@Serializable
data class PostListResponse(
    val posts: List<PostResponse>,
    val totalCount: Int
)

Ktor의 kotlinx.serialization 기반으로 응답 DTO를 구성하며, 날짜는 ISO 8601 포맷 문자열로 통일한다.


12.3 API 라우팅 정의 – PostApi.kt

fun Route.postRoutes(postService: PostService) {
    route("/posts") {
        get {
            val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
            val size = call.request.queryParameters["size"]?.toIntOrNull() ?: 10
            val search = call.request.queryParameters["search"]
            val sort = call.request.queryParameters["sort"] ?: "desc"

            val result = postService.getPostList(page, size, search, sort)
            call.respond(ApiResponse(success = true, data = result))
        }

        get("{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
                ?: throw IllegalArgumentException("게시글 ID 형식이 잘못되었습니다.")
            val post = postService.getPostDetail(id)
            call.respond(ApiResponse(success = true, data = post))
        }
    }
}

12.4 서비스 계층 – PostService.kt

class PostService(private val postRepository: PostRepository) {

    suspend fun getPostList(
        page: Int,
        size: Int,
        search: String?,
        sort: String
    ): PostListResponse {
        val offset = (page - 1) * size
        val posts = postRepository.findAll(offset, size, search, sort)
        val total = postRepository.count(search)

        val responseList = posts.map {
            PostResponse(
                id = it.id,
                title = it.title,
                content = it.content.take(100),
                author = it.authorName,
                createdAt = it.createdAt.toString()
            )
        }

        return PostListResponse(posts = responseList, totalCount = total)
    }

    suspend fun getPostDetail(id: Long): PostResponse {
        val post = postRepository.findById(id)
            ?: throw NotFoundException("존재하지 않는 게시글입니다.")

        if (post.isHidden) {
            throw ForbiddenException("해당 게시글은 관리자에 의해 숨겨졌습니다.")
        }

        return PostResponse(
            id = post.id,
            title = post.title,
            content = post.content,
            author = post.authorName,
            createdAt = post.createdAt.toString()
        )
    }
}

12.5 Repository 구현 – PostRepository.kt

Exposed 기반 쿼리 작성 방식으로 처리한다.

class PostRepository {

    suspend fun findAll(offset: Int, size: Int, search: String?, sort: String): List<PostEntity> {
        return dbQuery {
            Posts.selectAll()
                .apply {
                    if (!search.isNullOrBlank()) {
                        andWhere { Posts.title like "%$search%" }
                    }
                }
                .orderBy(Posts.createdAt, if (sort == "asc") SortOrder.ASC else SortOrder.DESC)
                .limit(size, offset.toLong())
                .map { toEntity(it) }
        }
    }

    suspend fun count(search: String?): Int {
        return dbQuery {
            Posts.selectAll()
                .apply {
                    if (!search.isNullOrBlank()) {
                        andWhere { Posts.title like "%$search%" }
                    }
                }.count()
        }
    }

    suspend fun findById(id: Long): PostEntity? {
        return dbQuery {
            Posts.select { Posts.id eq id }
                .mapNotNull { toEntity(it) }
                .singleOrNull()
        }
    }
}

12.6 예외 처리 연동

서비스에서 NotFoundException, ForbiddenException을 던지면,
StatusPages 플러그인에서 이를 처리해준다.

exception<NotFoundException> { call, cause ->
    call.respond(HttpStatusCode.NotFound, ApiResponse<Unit>(false, message = cause.message))
}

exception<ForbiddenException> { call, _ ->
    call.respond(HttpStatusCode.Forbidden, ApiResponse<Unit>(false, message = "접근할 수 없는 게시글입니다."))
}

12.7 테스트 코드 작성 (Ktor testApplication 기반)

@Test
fun `GET /posts - 게시글 목록 조회`() = testApplication {
    val response = client.get("/posts?page=1&size=5")
    assertEquals(HttpStatusCode.OK, response.status)
    val body = response.bodyAsText()
    println(body)  // 구조 확인
}

@Test
fun `GET /posts/{id} - 게시글 상세 조회`() = testApplication {
    val response = client.get("/posts/1")
    assertEquals(HttpStatusCode.OK, response.status)
}

12.8 페이징 처리 및 정렬 전략

  • 기본 정렬: 최신순 (createdAt DESC)
  • 페이징: page, size 기반 offset 계산
  • 검색: %LIKE% 검색 (PostgreSQL 또는 MySQL 기준)
  • 앞으로 구현할 페이지네이션 응답에는 nextPage, hasMore 플래그도 포함 가능

12.9 리팩토링 포인트

  • 응답 DTO → ModelMapper 또는 확장 함수 분리
  • 예외 → 커스텀 에러 코드 + 로깅 연동
  • 캐시 처리 → Redis 적용 가능

12.10 마무리: 실무 API는 작동보다 구조가 먼저다

이 장에서 구현한 게시글 API는 단순한 CRUD 이상의 의미를 갖는다.
정확한 예외 처리, 요청 유효성 검증, 공통 응답 구조, 계층 분리 등은 모두 실무에서 반드시 요구되는 요소들이다.

RESTful 설계 원칙을 따르면서도, 테스트 가능하고 확장 가능한 구조를 만드는 것—이것이 실전 API 개발의 본질이다.


13장. 게시글 작성 API 구현 및 인증 연동 (JWT)

게시글 작성 기능은 모든 게시판 서비스의 핵심이다. 하지만 단순한 POST 요청이 아니라,
사용자가 인증되었는지, 작성한 글이 누구에게 귀속되는지, 입력값은 유효한지, 오류 발생 시 일관된 응답을 주는지 등이 함께 고려되어야 한다.

이 장에서는 POST /posts API를 구현하면서, JWT 기반 인증 구조를 구축하고 이를 라우트에 연동한다.


13.1 목표 기능 요약

  • URI: POST /posts
  • 요청 헤더: Authorization: Bearer <jwt-token>
  • 요청 본문: title, content
  • 결과: 생성된 게시글 ID, 작성자명, 작성 시간 등
  • 인증되지 않은 사용자는 401 Unauthorized 응답

13.2 JWT 인증 흐름 요약

  1. 로그인 성공 시 서버에서 JWT 토큰 발급
  2. 클라이언트는 이후 모든 요청 헤더에 토큰 포함
  3. 서버는 요청 수신 시 토큰을 파싱하고 유효성 검증
  4. 토큰에서 사용자 ID 등 클레임 추출 → call.principal<UserPrincipal>() 형태로 접근
  5. 인증 실패 시 자동으로 401 응답 반환

13.3 인증 플러그인 설치 및 구성

1. build.gradle.kts에 의존성 추가

implementation("io.ktor:ktor-server-auth:$ktorVersion")
implementation("io.ktor:ktor-server-auth-jwt:$ktorVersion")

2. JWT 설정용 Config 클래스

data class JwtConfig(
    val secret: String,
    val issuer: String,
    val audience: String,
    val realm: String
)

3. 플러그인 설치 – configureSecurity.kt

fun Application.configureSecurity(config: JwtConfig) {
    install(Authentication) {
        jwt("auth-jwt") {
            realm = config.realm
            verifier(
                JWT.require(Algorithm.HMAC256(config.secret))
                    .withAudience(config.audience)
                    .withIssuer(config.issuer)
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("userId").asLong() != null) {
                    UserPrincipal(credential.payload.getClaim("userId").asLong())
                } else null
            }
        }
    }
}

4. 인증 사용자 객체 – UserPrincipal.kt

data class UserPrincipal(val userId: Long) : Principal

13.4 게시글 작성 요청 DTO

@Serializable
data class CreatePostRequest(
    val title: String,
    val content: String
)
  • title: 공백 금지, 2~100자
  • content: 최소 10자

유효성 검사 함수 예시

fun validate(request: CreatePostRequest) {
    require(request.title.isNotBlank() && request.title.length <= 100) {
        "제목은 1~100자 이내여야 합니다."
    }
    require(request.content.length >= 10) {
        "본문은 10자 이상이어야 합니다."
    }
}

13.5 게시글 작성 라우팅

authenticate("auth-jwt") {
    post("/posts") {
        val principal = call.principal<UserPrincipal>() ?: throw UnauthorizedException()
        val request = call.receive<CreatePostRequest>()
        validate(request)

        val created = postService.createPost(
            userId = principal.userId,
            title = request.title,
            content = request.content
        )
        call.respond(
            HttpStatusCode.Created,
            ApiResponse(success = true, data = created)
        )
    }
}

13.6 서비스 계층 – PostService.kt

suspend fun createPost(userId: Long, title: String, content: String): PostResponse {
    val post = postRepository.save(userId, title, content)

    return PostResponse(
        id = post.id,
        title = post.title,
        content = post.content,
        author = post.authorName,
        createdAt = post.createdAt.toString()
    )
}

13.7 Repository 계층 – PostRepository.kt

suspend fun save(userId: Long, title: String, content: String): PostEntity {
    val id = dbQuery {
        Posts.insertAndGetId {
            it[Posts.userId] = userId
            it[Posts.title] = title
            it[Posts.content] = content
            it[Posts.createdAt] = Instant.now()
        }.value
    }

    return findById(id) ?: throw IllegalStateException("게시글 생성 후 조회 실패")
}

13.8 예외 처리 연동

  • 인증 실패 → 자동으로 401 반환
  • 유효성 실패 → IllegalArgumentException 발생 → 400 응답
  • 토큰 오류 → JWTVerificationException → StatusPages에서 401 응답

예외 처리 예시

exception<IllegalArgumentException> { call, cause ->
    call.respond(HttpStatusCode.BadRequest, ApiResponse<Unit>(false, message = cause.message))
}

exception<JWTVerificationException> { call, _ ->
    call.respond(HttpStatusCode.Unauthorized, ApiResponse<Unit>(false, message = "인증 토큰이 유효하지 않습니다."))
}

13.9 테스트 코드 예시

@Test
fun `POST /posts - 성공`() = testApplication {
    val token = generateJwtToken(userId = 1L)
    val request = CreatePostRequest("테스트 제목", "내용이 충분히 깁니다.")

    val response = client.post("/posts") {
        header(HttpHeaders.Authorization, "Bearer $token")
        contentType(ContentType.Application.Json)
        setBody(Json.encodeToString(request))
    }

    assertEquals(HttpStatusCode.Created, response.status)
}

13.10 마무리: 인증 연동된 API는 책임을 수반한다

이 장을 통해 우리는 다음과 같은 실무 개념을 직접 구현했다.

  • 보호된 API 설계 (authenticate 블록)
  • JWT를 통한 인증 흐름과 클레임 추출
  • 인증 사용자 정보 기반의 데이터 처리
  • 공통 예외 처리 구조와 통합 응답 포맷 유지

단순히 글을 저장하는 기능을 넘어, 실제 서비스에서 안전하게 인증된 사용자에게만 기능을 허용하고, 예측 가능한 결과를 제공하는 구조를 구축했다.


 

15장. 댓글 시스템 구현 – 관계형 데이터 처리와 트랜잭션 구조

게시글에 대한 사용자 반응을 수집하고 소통을 유도하는 기능 중 하나가 댓글이다.
하지만 댓글은 단순히 게시글에 붙는 문자열 이상이다.
사용자, 게시글, 댓글 간의 다대일 관계가 존재하며, 트랜잭션이 중요한 데이터 구조다.

이 장에서는 다음과 같은 기능을 구현한다:

  • 게시글에 댓글 작성
  • 특정 게시글의 댓글 목록 조회
  • 댓글 삭제 (작성자 본인만 가능)

15.1 댓글 테이블 설계

object Comments : LongIdTable() {
    val postId = reference("post_id", Posts)
    val userId = long("user_id")
    val content = varchar("content", 1000)
    val createdAt = datetime("created_at")
}

댓글은 Posts 테이블과 다대일 관계를 갖는다.
삭제 시에는 게시글 삭제와 별도로 처리(ON DELETE CASCADE 미사용).
userId는 FK로 연결하지 않고 수치만 저장(Exposed 간단화 목적).


15.2 API URI 구조

기능 URI 메서드
댓글 작성 POST /posts/{id}/comments POST
댓글 목록 GET /posts/{id}/comments GET
댓글 삭제 DELETE /comments/{id} DELETE
  • 댓글은 "게시글의 하위 자원"이므로 URI 상 중첩 구조 사용
  • 삭제는 댓글 ID만 필요하므로 /comments/{id}로 단순화

15.3 댓글 응답 DTO

@Serializable
data class CommentResponse(
    val id: Long,
    val author: String,
    val content: String,
    val createdAt: String
)

댓글 목록 응답 DTO

@Serializable
data class CommentListResponse(
    val comments: List<CommentResponse>,
    val totalCount: Int
)

15.4 댓글 작성 라우트

authenticate("auth-jwt") {
    post("/posts/{id}/comments") {
        val principal = call.principal<UserPrincipal>() ?: throw UnauthorizedException()
        val postId = call.parameters["id"]?.toLongOrNull() ?: throw IllegalArgumentException("잘못된 게시글 ID")

        val request = call.receive<CreateCommentRequest>()
        if (request.content.isBlank()) throw IllegalArgumentException("댓글 내용은 공백일 수 없습니다.")

        val comment = commentService.addComment(postId, principal.userId, request.content)
        call.respond(ApiResponse(success = true, data = comment))
    }
}

요청 DTO

@Serializable
data class CreateCommentRequest(
    val content: String
)

15.5 댓글 목록 조회 라우트

get("/posts/{id}/comments") {
    val postId = call.parameters["id"]?.toLongOrNull() ?: throw IllegalArgumentException("잘못된 게시글 ID")

    val list = commentService.getComments(postId)
    call.respond(ApiResponse(success = true, data = list))
}

15.6 댓글 삭제 라우트

authenticate("auth-jwt") {
    delete("/comments/{id}") {
        val principal = call.principal<UserPrincipal>() ?: throw UnauthorizedException()
        val commentId = call.parameters["id"]?.toLongOrNull() ?: throw IllegalArgumentException("잘못된 댓글 ID")

        commentService.deleteComment(commentId, principal.userId)
        call.respond(HttpStatusCode.NoContent)
    }
}

15.7 서비스 계층 – CommentService.kt

class CommentService(private val commentRepository: CommentRepository) {

    suspend fun addComment(postId: Long, userId: Long, content: String): CommentResponse {
        val comment = commentRepository.save(postId, userId, content)
        return comment.toResponse()
    }

    suspend fun getComments(postId: Long): CommentListResponse {
        val comments = commentRepository.findByPostId(postId)
        return CommentListResponse(
            comments = comments.map { it.toResponse() },
            totalCount = comments.size
        )
    }

    suspend fun deleteComment(commentId: Long, userId: Long) {
        val comment = commentRepository.findById(commentId)
            ?: throw NotFoundException("댓글이 존재하지 않습니다.")

        if (comment.userId != userId) {
            throw ForbiddenException("본인만 댓글을 삭제할 수 있습니다.")
        }

        commentRepository.delete(commentId)
    }
}

Entity 확장 함수

fun CommentEntity.toResponse() = CommentResponse(
    id = id,
    author = authorName,
    content = content,
    createdAt = createdAt.toString()
)

15.8 Repository 계층 – CommentRepository.kt

suspend fun save(postId: Long, userId: Long, content: String): CommentEntity {
    val id = dbQuery {
        Comments.insertAndGetId {
            it[Comments.postId] = postId
            it[Comments.userId] = userId
            it[Comments.content] = content
            it[createdAt] = LocalDateTime.now()
        }.value
    }

    return findById(id) ?: throw IllegalStateException("댓글 저장 실패")
}

suspend fun findByPostId(postId: Long): List<CommentEntity> = dbQuery {
    Comments.select { Comments.postId eq postId }
        .orderBy(Comments.createdAt to SortOrder.ASC)
        .map { toEntity(it) }
}

suspend fun findById(id: Long): CommentEntity? = dbQuery {
    Comments.select { Comments.id eq id }
        .mapNotNull { toEntity(it) }
        .singleOrNull()
}

suspend fun delete(id: Long) = dbQuery {
    Comments.deleteWhere { Comments.id eq id }
}

15.9 트랜잭션 고려 사항

  • 게시글 삭제 시 댓글 함께 삭제?
    → 본 예제에서는 독립적으로 유지
    → 실무에서는 외래키 + CASCADE 설정 고려
  • 댓글 저장 중 오류 시 롤백 보장
    → dbQuery 내부는 자동 트랜잭션 관리됨
  • 서비스 레벨에서 다중 작업 필요 시 transaction {} 사용

15.10 마무리: 실무 댓글 기능의 핵심은 관계와 책임

이 장에서 다룬 댓글 기능은 단순한 기능처럼 보일 수 있지만,
관계형 구조, 인증 연동, 삭제 시 권한 확인, 트랜잭션 관리, 중첩 URI 설계 등 실무적인 고려가 모두 반영되었다.

  • RESTful 설계에 맞는 URI 설계
  • 작성자만 삭제 가능 → 책임 있는 행동 설계
  • 응답 구조 통일 → 클라이언트 일관성 보장

 

16장. 사용자 인증 시스템 – 로그인, 회원가입, JWT 발급/갱신

Ktor는 인증 시스템을 매우 유연하게 구성할 수 있도록 설계되어 있다.
하지만 실무에서는 단순히 토큰을 발급하는 수준을 넘어서, 회원 데이터 저장, 비밀번호 해싱, 중복 검사, 예외 처리, 갱신 흐름까지 포함된 인증 구조가 필요하다.

이 장에서는 다음 기능을 구현한다:

  • 사용자 회원가입 (POST /users)
  • 로그인 및 JWT 발급 (POST /auth/login)
  • JWT 유효성 검사 및 인증 처리
  • 리프레시 토큰을 통한 액세스 토큰 갱신 (옵션)
  • 내 정보 조회 (GET /users/me)

16.1 유저 테이블 설계

object Users : LongIdTable() {
    val email = varchar("email", 100).uniqueIndex()
    val nickname = varchar("nickname", 50)
    val passwordHash = varchar("password_hash", 100)
    val createdAt = datetime("created_at")
    val isBanned = bool("is_banned").default(false)
}

16.2 비밀번호 해싱 전략

Ktor는 자체적으로 해싱 도구를 제공하지 않으므로,
Kotlin에서 널리 사용하는 BCrypt 라이브러리를 사용한다.

의존성 추가

implementation("at.favre.lib:bcrypt:0.9.0")

유틸 클래스: PasswordHasher.kt

object PasswordHasher {
    fun hash(password: String): String =
        BCrypt.withDefaults().hashToString(12, password.toCharArray())

    fun verify(password: String, hashed: String): Boolean =
        BCrypt.verifyer().verify(password.toCharArray(), hashed).verified
}

16.3 회원가입 API – POST /users

요청 DTO

@Serializable
data class SignUpRequest(
    val email: String,
    val nickname: String,
    val password: String
)

서비스 로직

suspend fun register(request: SignUpRequest): UserResponse {
    if (userRepository.findByEmail(request.email) != null) {
        throw ConflictException("이미 존재하는 이메일입니다.")
    }

    val hashed = PasswordHasher.hash(request.password)
    val user = userRepository.save(request.email, request.nickname, hashed)

    return user.toResponse()
}

라우트

post("/users") {
    val request = call.receive<SignUpRequest>()
    val user = authService.register(request)
    call.respond(HttpStatusCode.Created, ApiResponse(success = true, data = user))
}

16.4 로그인 API – POST /auth/login

요청 DTO

@Serializable
data class LoginRequest(
    val email: String,
    val password: String
)

@Serializable
data class LoginResponse(
    val accessToken: String
)

서비스 로직

suspend fun login(request: LoginRequest): LoginResponse {
    val user = userRepository.findByEmail(request.email)
        ?: throw UnauthorizedException("이메일 또는 비밀번호가 일치하지 않습니다.")

    if (!PasswordHasher.verify(request.password, user.passwordHash)) {
        throw UnauthorizedException("이메일 또는 비밀번호가 일치하지 않습니다.")
    }

    if (user.isBanned) {
        throw ForbiddenException("정지된 사용자입니다.")
    }

    val token = JwtProvider.createAccessToken(user.id)
    return LoginResponse(accessToken = token)
}

라우트

post("/auth/login") {
    val request = call.receive<LoginRequest>()
    val response = authService.login(request)
    call.respond(ApiResponse(success = true, data = response))
}

16.5 JWT 발급 도구 – JwtProvider.kt

object JwtProvider {
    private const val secret = "jwt-secret-key"
    private const val issuer = "ktor-server"
    private const val audience = "ktor-audience"
    private const val realm = "ktor-app"

    fun createAccessToken(userId: Long): String {
        return JWT.create()
            .withIssuer(issuer)
            .withAudience(audience)
            .withClaim("userId", userId)
            .withExpiresAt(Date(System.currentTimeMillis() + 1000 * 60 * 60)) // 1시간
            .sign(Algorithm.HMAC256(secret))
    }

    fun getVerifier(): JWTVerifier = JWT.require(Algorithm.HMAC256(secret))
        .withIssuer(issuer)
        .withAudience(audience)
        .build()
}

16.6 JWT 인증 플러그인 – configureSecurity.kt

install(Authentication) {
    jwt("auth-jwt") {
        realm = JwtProvider.realm
        verifier(JwtProvider.getVerifier())
        validate { credential ->
            val id = credential.payload.getClaim("userId").asLong()
            if (id != null) UserPrincipal(id) else null
        }
    }
}

16.7 내 정보 조회 API – GET /users/me

라우트

authenticate("auth-jwt") {
    get("/users/me") {
        val userId = call.principal<UserPrincipal>()!!.userId
        val user = userService.getById(userId)
        call.respond(ApiResponse(success = true, data = user))
    }
}

UserResponse DTO

@Serializable
data class UserResponse(
    val id: Long,
    val email: String,
    val nickname: String,
    val createdAt: String
)

16.8 예외 처리 연동

  • 비밀번호 틀림 / 계정 없음 → 401 Unauthorized
  • 정지된 사용자 → 403 Forbidden
  • 이미 가입된 이메일 → 409 Conflict

Exception 예시

class ConflictException(msg: String) : RuntimeException(msg)

StatusPages 처리

exception<ConflictException> { call, cause ->
    call.respond(HttpStatusCode.Conflict, ApiResponse<Unit>(false, message = cause.message))
}

16.9 리프레시 토큰 설계 (옵션)

  • POST /auth/refresh → 리프레시 토큰으로 액세스 토큰 재발급
  • 별도 테이블에 토큰 저장
  • 클라이언트에 두 개의 토큰 발급 (access, refresh)
  • 보안을 위해 단기 액세스 + 장기 리프레시 조합 사용

이 기능은 이후 고급 인증 장에서 확장 가능


16.10 마무리: 인증은 API의 관문이자 품질 기준

이 장에서는 API 서버에서 반드시 필요한 사용자 인증 시스템을 구축했다.

  • 회원가입 시 중복 검사와 비밀번호 해싱
  • 로그인 시 유저 확인 + JWT 발급
  • 토큰 인증 → 사용자 ID 식별
  • 보호된 API에서 권한 제어

인증 로직은 단순 보안이 아니라, 전체 서비스의 진입 관문이다.
따라서 구조적 설계, 유효성 검사, 예외 응답 등을 빈틈없이 구성해야 한다.


17장. 예외 처리, 응답 구조, 에러 코드 시스템 정리

실제 서비스에서는 수많은 오류 상황이 발생한다.
문제가 발생했을 때 중요한 것은 단지 에러 메시지를 보여주는 것이 아니라,
예측 가능한 구조와 의미 있는 코드로 클라이언트가 적절히 대처할 수 있도록 하는 것이다.

이 장에서는 Ktor 서버에 다음 기능을 통합한다:

  • 공통 응답 구조 적용
  • 전역 예외 핸들링 시스템 구축
  • 커스텀 에러 클래스 및 에러 코드 체계 정의
  • 상태 코드 정리 및 REST 표준화

17.1 공통 응답 구조 설계

ApiResponse

@Serializable
data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val message: String? = null,
    val errorCode: String? = null
)

모든 응답은 다음 규칙을 따른다:

항목 설명
success 성공 여부 (true/false)
data 실제 데이터 객체
message 사용자에게 보여줄 메시지
errorCode 클라이언트용 오류 식별 코드

예시: 성공 응답

{
  "success": true,
  "data": {
    "id": 1,
    "title": "게시글 제목"
  }
}

예시: 실패 응답

{
  "success": false,
  "message": "존재하지 않는 게시글입니다.",
  "errorCode": "POST_NOT_FOUND"
}

17.2 에러 코드 체계 설계

에러 코드 네이밍 전략

도메인 예시 코드 설명
USER USER_ALREADY_EXISTS 이메일 중복
AUTH AUTH_INVALID_CREDENTIAL 로그인 실패
POST POST_NOT_FOUND 존재하지 않는 게시글
COMMON COMMON_INVALID_PARAM 잘못된 파라미터

에러 코드 Enum

enum class ErrorCode(val code: String, val message: String, val status: HttpStatusCode) {
    USER_ALREADY_EXISTS("USER_ALREADY_EXISTS", "이미 존재하는 이메일입니다.", HttpStatusCode.Conflict),
    AUTH_INVALID_CREDENTIAL("AUTH_INVALID_CREDENTIAL", "이메일 또는 비밀번호가 틀렸습니다.", HttpStatusCode.Unauthorized),
    POST_NOT_FOUND("POST_NOT_FOUND", "게시글이 존재하지 않습니다.", HttpStatusCode.NotFound),
    COMMON_INVALID_PARAM("COMMON_INVALID_PARAM", "요청 파라미터가 잘못되었습니다.", HttpStatusCode.BadRequest)
}

17.3 커스텀 예외 클래스 정의

open class ApiException(
    val errorCode: ErrorCode
) : RuntimeException(errorCode.message)

class UserAlreadyExistsException : ApiException(ErrorCode.USER_ALREADY_EXISTS)
class InvalidCredentialException : ApiException(ErrorCode.AUTH_INVALID_CREDENTIAL)
class PostNotFoundException : ApiException(ErrorCode.POST_NOT_FOUND)

17.4 전역 예외 처리 구성 – configureException.kt

fun Application.configureExceptionHandling() {
    install(StatusPages) {
        exception<ApiException> { call, cause ->
            call.respond(
                cause.errorCode.status,
                ApiResponse<Unit>(
                    success = false,
                    message = cause.errorCode.message,
                    errorCode = cause.errorCode.code
                )
            )
        }

        exception<IllegalArgumentException> { call, cause ->
            call.respond(
                HttpStatusCode.BadRequest,
                ApiResponse<Unit>(
                    success = false,
                    message = cause.message,
                    errorCode = ErrorCode.COMMON_INVALID_PARAM.code
                )
            )
        }

        exception<Throwable> { call, cause ->
            call.application.environment.log.error("Unhandled Exception", cause)
            call.respond(
                HttpStatusCode.InternalServerError,
                ApiResponse<Unit>(
                    success = false,
                    message = "서버 내부 오류가 발생했습니다.",
                    errorCode = "INTERNAL_ERROR"
                )
            )
        }
    }
}

17.5 실제 적용 예시

이전의 코드에서

if (userRepository.findByEmail(request.email) != null) {
    throw IllegalArgumentException("이미 존재하는 이메일입니다.")
}

다음처럼 변경

if (userRepository.findByEmail(request.email) != null) {
    throw UserAlreadyExistsException()
}

17.6 API 문서 예시 정리   

상황 상태 코드 에러 코드 메시지
이메일 중복 409 USER_ALREADY_EXISTS 이미 존재하는 이메일입니다.
비밀번호 틀림 401 AUTH_INVALID_CREDENTIAL 이메일 또는 비밀번호가 틀렸습니다.
게시글 없음 404 POST_NOT_FOUND 게시글이 존재하지 않습니다.
요청 파라미터 오류 400 COMMON_INVALID_PARAM 요청 파라미터가 잘못되었습니다.

17.7 에러 처리의 실무적 장점

  • 프론트엔드에서 errorCode로 에러 분기 처리 가능
  • 사용자 메시지와 로그 메시지를 분리 가능
  • 통합 로깅 및 모니터링에 유리
  • 테스트 자동화 시 상태 코드 및 에러코드 기준으로 검증 용이

17.8 응답 구조 일관성 확보 전략

  • 성공 응답: 항상 ApiResponse(success = true, data = ...)
  • 실패 응답: 항상 ApiResponse(success = false, message = ..., errorCode = ...)
  • 예외는 항상 ApiException을 상속받도록 규칙화

17.9 테스트 예시

@Test
fun `존재하지 않는 게시글 조회 시 404와 POST_NOT_FOUND 반환`() = testApplication {
    val response = client.get("/posts/99999")
    assertEquals(HttpStatusCode.NotFound, response.status)

    val body = Json.decodeFromString<ApiResponse<Unit>>(response.bodyAsText())
    assertFalse(body.success)
    assertEquals("POST_NOT_FOUND", body.errorCode)
}

17.10 마무리: 에러 응답도 사용자 경험이다

많은 개발자가 API의 성공 응답에만 집중한다.
하지만 사용자가 오류를 만났을 때 어떻게 안내받는가는 서비스 신뢰도에 직결된다.

에러 응답도 UX의 일부다.
Ktor의 StatusPages 플러그인과 예외 클래스 구조를 활용하면,
신뢰할 수 있고 예측 가능한 API 환경을 구현할 수 있다.


 

18장. API 서버 구조 리팩토링 – 모듈화, 테스트, 유지보수 전략

코드를 잘 작동하게 만드는 것과 잘 유지되게 만드는 것은 다르다.
이 장에서는 그 차이를 명확히 인식하고, 지금까지 작성한 코드를 기준으로 서비스 품질을 높이는 구조적 개선을 진행한다.


18.1 현재까지의 기본 구조 정리

기능별 디렉토리는 다음과 같았다:

src/
 └── main/
     └── kotlin/
         └── com/example/
             ├── api/
             ├── service/
             ├── repository/
             ├── domain/
             ├── dto/
             ├── config/
             └── utils/

이 구조는 작은 규모의 프로젝트에는 유용하지만, 기능이 많아질수록 관리가 어려워진다.


18.2 기능 기반 모듈 구조로 재편

도메인 중심의 구조로 전환하여 관련된 기능을 하나의 feature 모듈로 묶는다.

Before:

service/
 ├── UserService.kt
 ├── PostService.kt
 └── CommentService.kt

After (기능별 디렉토리화):

features/
 ├── user/
 │   ├── UserService.kt
 │   ├── UserController.kt
 │   ├── UserRepository.kt
 │   ├── dto/
 │   └── domain/
 ├── post/
 │   ├── PostService.kt
 │   ├── PostController.kt
 │   ├── PostRepository.kt
 │   ├── dto/
 │   └── domain/
 └── comment/
     ├── CommentService.kt
     ├── CommentController.kt
     ├── CommentRepository.kt
     ├── dto/
     └── domain/

이 방식은 각 도메인의 코드가 함께 존재하기 때문에 이해와 변경이 용이하다.
팀 단위로 나눠 작업할 때도 충돌이 적고 독립성이 보장된다.


18.3 의존성 주입 개선 – Koin 사용

Ktor는 Koin, Dagger, Hilt 등의 의존성 주입(DI) 프레임워크를 사용할 수 있다.
이 프로젝트에서는 Koin을 사용해 DI를 간결하게 구성한다.

1. build.gradle.kts 추가

implementation("io.insert-koin:koin-ktor:3.4.0")
implementation("io.insert-koin:koin-logger-slf4j:3.4.0")

2. DI 모듈 정의

val appModule = module {
    single { UserRepository() }
    single { PostRepository() }
    single { CommentRepository() }

    single { UserService(get()) }
    single { PostService(get()) }
    single { CommentService(get()) }
}

3. Application.kt에 등록

fun Application.main() {
    install(Koin) {
        slf4jLogger()
        modules(appModule)
    }

    configureSecurity()
    configureRouting()
    configureExceptionHandling()
}

18.4 라우트 모듈화

fun Application.configureRouting() {
    routing {
        userRoutes()
        postRoutes()
        commentRoutes()
        authRoutes()
    }
}

각 라우트는 Route.userRoutes() 식으로 모듈화하여 관리한다.


18.5 테스트 전략 정리

단위 테스트 대상

  • Service 계층 (비즈니스 로직)
  • Repository → DB mocking (TestContainers or H2)

통합 테스트 대상

  • Routing, JWT 인증
  • 요청 → 응답 전체 흐름

단위 테스트 예시: PostServiceTest

class PostServiceTest {
    private val repository = mockk<PostRepository>()
    private val service = PostService(repository)

    @Test
    fun `게시글을 정상적으로 생성한다`() = runBlocking {
        val stubPost = PostEntity(...)
        every { repository.save(any(), any(), any()) } returns stubPost

        val result = service.createPost(1, "제목", "내용")
        assertEquals("제목", result.title)
    }
}

통합 테스트 예시: PostApiTest

@Test
fun `GET /posts - 게시글 목록 조회`() = testApplication {
    val response = client.get("/posts")
    assertEquals(HttpStatusCode.OK, response.status)
    val body = response.bodyAsText()
    println(body)
}

18.6 유지보수성과 확장성 확보를 위한 팁

  • Feature별 독립적 테스트 가능
  • 에러 코드 체계는 별도 관리 (ErrorCode.kt)
  • Request/Response 객체는 각 feature 내 dto로 한정
  • Route별 주석과 Swagger 문서화 가능 (Ktor + OpenAPI)
  • 테스트 케이스 누락 방지: coverage 리포트 확인

18.7 도메인 분리 전략

  • 사용자 인증/권한: auth/
  • 유저 관련: user/
  • 게시글 기능: post/
  • 댓글 기능: comment/
  • 어드민 기능: admin/

→ 각 도메인을 독립 모듈로 구성하는 것도 가능 (multi-module 프로젝트 확장 시)


18.8 정적 파일/환경 구성 관리

  • application.conf에서 환경 분기 처리
  • .env 파일 연동하여 비밀 키 보안 강화 (Dotenv 지원 가능)
  • Logback 설정 → 슬랙/파일 알림 연동 가능

18.9 CI/CD 연계 및 배포 전략 (요약)

  • 테스트 → 빌드 → Docker 이미지화 → 배포 자동화
  • GitHub Actions / GitLab CI 사용 가능
  • PostgreSQL, Redis 등 외부 의존성은 Docker Compose 활용

18.10 마무리: 코드 품질은 구조에서 시작된다

서비스는 기능만으로 완성되지 않는다.
유지보수가 가능한 코드 구조, 협업 가능한 폴더 구조,
의존성 주입을 통한 테스트 가능성 확보,
그리고 철저한 예외 처리와 응답 구조 일관성이 곧 프로덕션 품질의 기준이 된다.

이번 장에서는 단순히 “정리하는 것”을 넘어,
성장 가능한 API 서버 구조를 어떻게 구성할 수 있는지를 실전적으로 설명했다.


19장. 실무에 가까운 기능 확장 – 좋아요, 숨김 처리, 관리자 기능 설계

실제 서비스를 운영해 보면, 단순한 게시판 기능만으로는 부족하다는 사실을 금방 알 수 있다.
사용자의 반응을 유도하거나, 운영자가 콘텐츠 품질을 관리하기 위해선 다음과 같은 기능이 추가로 필요하다:

  • 게시글 좋아요/좋아요 취소
  • 게시글 숨김 처리 (관리자 전용)
  • 사용자 정지/해제 기능

이 장에서는 기능 구현뿐 아니라, 역할(Role) 기반 권한 설계, 상태 기반 필터링, 트랜잭션 구조 등을 함께 설계한다.


19.1 게시글 좋아요 기능

요구사항:

  • 유저는 각 게시글에 대해 한 번만 좋아요 가능
  • 한 번 더 누르면 좋아요 취소
  • 좋아요 수는 실시간으로 카운트됨

1) 테이블 설계

object PostLikes : Table() {
    val postId = reference("post_id", Posts)
    val userId = long("user_id")
    override val primaryKey = PrimaryKey(postId, userId)
}
  • Composite Key 사용 (postId + userId)
  • 중복 방지

2) 서비스 로직

suspend fun toggleLike(postId: Long, userId: Long): Boolean {
    val alreadyLiked = likeRepository.exists(postId, userId)
    return if (alreadyLiked) {
        likeRepository.remove(postId, userId)
        false
    } else {
        likeRepository.add(postId, userId)
        true
    }
}

3) 라우팅

authenticate("auth-jwt") {
    post("/posts/{id}/like") {
        val principal = call.principal<UserPrincipal>()!!
        val postId = call.parameters["id"]?.toLongOrNull() ?: throw IllegalArgumentException("잘못된 게시글 ID")

        val liked = likeService.toggleLike(postId, principal.userId)
        val message = if (liked) "좋아요 추가됨" else "좋아요 취소됨"
        call.respond(ApiResponse(success = true, message = message))
    }
}

19.2 게시글 숨김 처리 (관리자 기능)

요구사항:

  • 관리자는 특정 게시글을 “숨김 처리” 가능
  • 일반 사용자에게는 목록/상세에서 제외됨
  • 숨김 처리된 글은 DB에서 삭제되지 않음

1) 게시글 테이블 수정

val isHidden = bool("is_hidden").default(false)

2) 관리자 인증 처리

data class UserPrincipal(val userId: Long, val isAdmin: Boolean = false) : Principal

JWT 발급 시 isAdmin claim 포함 → 인증 시 파싱


3) 숨김 처리 API

authenticate("auth-jwt") {
    patch("/admin/posts/{id}/hide") {
        val principal = call.principal<UserPrincipal>()!!
        if (!principal.isAdmin) throw ForbiddenException("관리자만 접근 가능합니다.")

        val postId = call.parameters["id"]?.toLongOrNull() ?: throw IllegalArgumentException("ID 오류")
        postService.hidePost(postId)
        call.respond(ApiResponse(success = true, message = "숨김 처리 완료"))
    }
}

서비스 메서드 예시

suspend fun hidePost(postId: Long) {
    val post = postRepository.findById(postId) ?: throw NotFoundException("게시글 없음")
    postRepository.setHidden(postId, true)
}

19.3 사용자 정지 기능

요구사항:

  • 관리자는 특정 사용자를 정지하거나 해제 가능
  • 정지된 사용자는 로그인은 가능하나 모든 API 접근 제한
  • 사용자 테이블에 isBanned 필드 추가

1) 유저 테이블 필드 추가

val isBanned = bool("is_banned").default(false)

2) 인증 시 정지 여부 확인

validate { credential ->
    val userId = credential.payload.getClaim("userId").asLong()
    val isBanned = userRepository.findById(userId)?.isBanned ?: true
    if (!isBanned) UserPrincipal(userId) else null
}

정지된 사용자는 401 Unauthorized 또는 403 Forbidden 반환


3) 정지 API 예시

authenticate("auth-jwt") {
    patch("/admin/users/{id}/ban") {
        val principal = call.principal<UserPrincipal>()!!
        if (!principal.isAdmin) throw ForbiddenException("관리자 권한 필요")

        val userId = call.parameters["id"]?.toLongOrNull() ?: throw IllegalArgumentException("잘못된 ID")
        userService.setBanStatus(userId, true)
        call.respond(ApiResponse(success = true, message = "사용자 정지 처리 완료"))
    }
}

19.4 에러 코드 확장 예시

상황 에러 코드 메시지
이미 좋아요 누름 POST_ALREADY_LIKED 이미 좋아요한 게시글입니다.
관리자 권한 없음 ADMIN_ACCESS_DENIED 관리자만 접근할 수 있습니다.
정지된 사용자 USER_BANNED 해당 계정은 정지되었습니다.

19.5 상태 기반 필터링 전략

  • isHidden = true인 게시글은 일반 사용자에게 노출하지 않음
  • 관리자는 전체 게시글 조회 가능 → 쿼리 조건 분기 처리
fun findAll(onlyVisible: Boolean): List<PostEntity> {
    return if (onlyVisible) {
        // 일반 사용자용
        Posts.select { Posts.isHidden eq false }.toList()
    } else {
        // 관리자용
        Posts.selectAll().toList()
    }
}

19.6 마무리: 확장 기능도 설계가 전부다

좋아요, 숨김 처리, 정지 기능은 단순히 “옵션”이 아니다.
서비스의 완성도를 높이고 사용자 경험과 운영 품질을 끌어올리는 핵심 기능이다.

단순히 “작동하는 기능”을 만드는 것을 넘어서,
권한 구조, 상태 관리, 조건 분기, 실시간 반영까지 고려한 API 설계를 통해
우리는 실무에서 통용되는 수준의 서비스를 완성할 수 있다.


 

20장. 파일 업로드 및 이미지 처리 – 게시글 이미지 첨부 구현

사용자 경험(UX)을 높이고 콘텐츠 품질을 개선하는 핵심 기능 중 하나가 이미지 업로드 기능이다.
특히 게시글 작성 시 이미지를 첨부하거나, 썸네일을 등록하는 기능은 거의 필수에 가깝다.

이 장에서는 다음 기능을 구현한다:

  • 이미지 파일 업로드 처리 (multipart/form-data)
  • 서버 내 저장 및 URL 반환
  • 파일명 중복 방지 및 확장자 제한
  • 게시글과 이미지 매핑 저장
  • 이미지 접근 URL 제공

20.1 업로드 요청 형식 설계

HTTP 요청 예시

POST /upload
Content-Type: multipart/form-data

form-data:
- file: (binary file)

응답 예시

{
  "success": true,
  "data": {
    "url": "/static/uploads/2025/07/abc123.png"
  }
}

20.2 멀티파트 업로드 처리

Ktor는 멀티파트 요청을 수신하려면 ContentNegotiation 외에 Multipart 플러그인을 설치해야 한다.

Application.kt 설정

install(ContentNegotiation) {
    json()
}

install(CallLogging)
install(Compression)
install(PartialContent) // 대용량 파일 대응

20.3 업로드 라우트 구현

post("/upload") {
    val multipart = call.receiveMultipart()
    var fileUrl: String? = null

    multipart.forEachPart { part ->
        if (part is PartData.FileItem) {
            val ext = File(part.originalFileName ?: "").extension.lowercase()
            if (ext !in listOf("jpg", "jpeg", "png", "gif", "webp")) {
                throw IllegalArgumentException("허용되지 않은 이미지 형식입니다.")
            }

            val fileName = "${UUID.randomUUID()}.$ext"
            val folder = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"))
            val dir = File("uploads/$folder")
            if (!dir.exists()) dir.mkdirs()

            val file = File(dir, fileName)
            part.streamProvider().use { its -> file.outputStream().use { its.copyTo(it) } }

            fileUrl = "/static/uploads/$folder/$fileName"
        }
        part.dispose()
    }

    if (fileUrl == null) throw IllegalArgumentException("파일이 포함되어야 합니다.")

    call.respond(ApiResponse(success = true, data = mapOf("url" to fileUrl)))
}

20.4 정적 파일 서빙 설정

uploads 폴더는 서버에서 정적으로 접근할 수 있도록 라우팅 등록 필요

routing {
    static("/static") {
        staticRootFolder = File("uploads")
        files(".")
    }
}

이제 /static/uploads/2025/07/abc123.png 와 같은 경로로 이미지 접근 가능


20.5 업로드 이미지와 게시글 매핑

1) 테이블 설계

object PostImages : LongIdTable() {
    val postId = reference("post_id", Posts)
    val url = varchar("url", 300)
    val uploadedAt = datetime("uploaded_at")
}

2) 게시글 작성 시 이미지 URL 함께 저장

@Serializable
data class CreatePostRequest(
    val title: String,
    val content: String,
    val imageUrls: List<String> = emptyList()
)

서비스 로직에서 삽입 처리

request.imageUrls.forEach { url ->
    postImageRepository.save(post.id, url)
}

20.6 썸네일 처리 전략

  • 이미지 중 첫 번째를 썸네일로 설정
  • 썸네일 필드는 게시글 테이블에 추가할 수도 있고, 동적으로 처리 가능
val thumbnail = request.imageUrls.firstOrNull()

20.7 보안 고려사항

  • 허용된 확장자만 처리
  • 파일 크기 제한 (요청 크기 제한 가능)
  • 경로 조작 방지 (.. 체크 필요)
  • 사용자별 디렉토리 분리 저장 고려
  • 공개 URL은 클라우드로 이관 가능 (예: AWS S3)

20.8 고급 확장 아이디어

  • 이미지 리사이징 및 썸네일 자동 생성 (e.g., Thumbnailator)
  • AWS S3에 업로드 → presigned URL 반환
  • 파일별 만료 정책 (일정 기간 후 삭제)

20.9 테스트 예시

@Test
fun `이미지 업로드 성공 시 URL 반환`() = testApplication {
    val response = client.submitFormWithBinaryData(
        url = "/upload",
        formData = formData {
            append("file", File("test.jpg").readBytes(), Headers.build {
                append(HttpHeaders.ContentType, "image/jpeg")
                append(HttpHeaders.ContentDisposition, "filename=test.jpg")
            })
        }
    )
    assertEquals(HttpStatusCode.OK, response.status)
    val body = response.bodyAsText()
    assertTrue(body.contains("url"))
}

20.10 마무리: 실무적인 이미지 업로드의 기본을 구축하다

이번 장에서는 게시판에 파일 업로드 기능을 더하면서,
Ktor 기반 서버에서 멀티파트 요청 처리, 파일 저장, 경로 관리, 확장자 필터링, URL 응답까지
이미지 업로드의 전체 흐름을 실전처럼 구현해 보았다.

단순히 동작하는 업로드가 아닌,
보안, 확장성, 유지보수까지 고려한 설계 방식을 함께 익히는 것이 중요하다.


 

21장. 블로그/포트폴리오형 API 확장 전략 – 태그, 검색, 정렬

게시판이 일정 수준을 넘어가면, 사용자 맞춤형 탐색, 콘텐츠 분류, 검색 최적화 같은 고급 기능이 필요해진다.
특히 블로그나 포트폴리오 서비스로 확장하려면 다음 요소들이 필수로 따라온다:

  • 게시글 태그 기능
  • 제목/내용 검색
  • 최신순·좋아요순·조회순 정렬
  • 카테고리 필터링
  • URL Slug 설계

이 장에서는 이런 기능들을 기반으로, RESTful API를 실제 블로그/포트폴리오 플랫폼 수준으로 끌어올리는 확장 전략을 설명한다.


21.1 태그 시스템 설계

1) 테이블 구조

object Tags : LongIdTable() {
    val name = varchar("name", 30).uniqueIndex()
}

object PostTags : Table() {
    val postId = reference("post_id", Posts)
    val tagId = reference("tag_id", Tags)
    override val primaryKey = PrimaryKey(postId, tagId)
}
  • Tags: 태그 자체 정의
  • PostTags: 게시글과 태그의 N:M 매핑

2) 게시글 등록 시 태그 입력

@Serializable
data class CreatePostRequest(
    val title: String,
    val content: String,
    val tags: List<String> = emptyList()
)

서비스 내부 처리 흐름

val tagIds = request.tags.map { tagService.findOrCreateByName(it) }
postTagRepository.attachTags(postId, tagIds)

3) 태그 기반 필터링 API

get("/posts") {
    val tag = call.request.queryParameters["tag"]
    val posts = if (tag != null) {
        postService.findByTag(tag)
    } else {
        postService.findAll()
    }
    call.respond(ApiResponse(success = true, data = posts))
}

21.2 검색 기능 구현

1) 쿼리 파라미터 검색

GET /posts?query=코틀린

2) DB 검색 쿼리 예시 (Exposed 기준)

fun searchPosts(query: String): List<PostEntity> = dbQuery {
    Posts.select {
        (Posts.title like "%$query%") or
        (Posts.content like "%$query%")
    }.map { toEntity(it) }
}
  • 인덱스 최적화 위해 full-text search 고려 (PostgreSQL: tsvector, MySQL: MATCH AGAINST)

21.3 정렬 기능 설계

1) 요청 형식

GET /posts?sort=likes
GET /posts?sort=recent

2) 구현 예시

fun findAllSorted(sort: String): List<PostEntity> = dbQuery {
    val baseQuery = Posts.selectAll().adjustSort(sort)
    baseQuery.map { toEntity(it) }
}

private fun Query.adjustSort(sort: String): Query {
    return when (sort) {
        "likes" -> orderBy(Posts.likeCount to SortOrder.DESC)
        "recent" -> orderBy(Posts.createdAt to SortOrder.DESC)
        else -> this
    }
}

21.4 카테고리 분류 기능

1) 카테고리 테이블 추가

object Categories : LongIdTable() {
    val name = varchar("name", 50)
}

object Posts : LongIdTable() {
    val categoryId = reference("category_id", Categories).nullable()
    // ...
}

2) 요청 DTO에 categoryId 포함

@Serializable
data class CreatePostRequest(
    val title: String,
    val content: String,
    val categoryId: Long? = null
)

21.5 슬러그 기반 URL 설계

1) 슬러그란?

  • 사람이 읽기 쉬운 URL 식별자
  • 예: /posts/kotlin-ktor-guide
    → 대신 숫자 ID 기반 URL을 슬러그로 대체

2) 게시글 테이블에 슬러그 컬럼 추가

val slug = varchar("slug", 100).uniqueIndex()

3) 슬러그 생성 함수

fun generateSlug(title: String): String {
    return title.lowercase()
        .replace(Regex("[^a-z0-9\\s-]"), "")
        .replace(" ", "-")
        .take(100)
}

21.6 API 응답 예시

{
  "id": 12,
  "slug": "kotlin-api-design",
  "title": "Kotlin API 디자인 가이드",
  "category": "Ktor",
  "tags": ["코틀린", "백엔드"],
  "createdAt": "2025-07-18T12:34:56",
  "likeCount": 42
}

21.7 고급 확장 전략

  • 인기 태그 집계 → /tags/popular
  • 유저별 태그 기반 추천 → 머신러닝 연동 가능
  • 검색어 추천 기능 → Redis + 조회수 랭킹 기반 자동완성
  • 다국어 URL slug 처리 (i18n)

21.8 테스트 케이스 예시

@Test
fun `태그로 게시글 필터링`() = testApplication {
    val response = client.get("/posts?tag=Ktor")
    assertEquals(HttpStatusCode.OK, response.status)
    val body = Json.decodeFromString<ApiResponse<List<PostSummary>>>(response.bodyAsText())
    assertTrue(body.data!!.all { it.tags.contains("Ktor") })
}

21.9 관리용 API 설계 전략

  • /admin/categories → 카테고리 추가/수정/삭제
  • /admin/tags → 태그 삭제 처리 가능
  • /admin/posts → 전체 목록/정렬/검색 통합 조회

21.10 마무리: 콘텐츠 중심의 플랫폼으로 확장하자

단순한 CRUD 게시판은 훌륭한 출발점이다.
그러나 블로그나 포트폴리오처럼 콘텐츠 중심의 서비스로 발전하려면
검색성, 분류 체계, URL 설계, 정렬 조건 등이 함께 설계되어야 한다.

이번 장에서는 검색 최적화와 사용자 편의성 모두를 고려한 콘텐츠 API 설계 전략을 실전처럼 구현했다.
이를 기반으로 한 웹, 앱, 관리자 도구 개발이 가능하며, SEO 최적화와 수익화 연계도 자연스럽게 이어진다.


 

22장. 실전 배포 전략 – Docker, PostgreSQL, Nginx, CI/CD 구성

API를 만든 것만으로 서비스는 완성되지 않는다.
어디에서, 어떻게, 얼마나 안정적으로 운영할 것인가가 실전의 핵심이다.
이 장에서는 로컬에서 잘 작동하는 Ktor API 서버를 클라우드에 안전하게 배포하기 위한 실전 프로세스를 다룬다.


22.1 목표 아키텍처

[Client]
   │
   ▼
[Nginx] ← HTTPS 적용
   │
   ▼
[Ktor App] ← JWT 인증 / REST API
   │
   ▼
[PostgreSQL] ← 영속 데이터 저장
  • 서버는 Docker 기반 컨테이너 환경으로 구성
  • Nginx는 SSL 종단점 및 정적 리버스 프록시 역할
  • PostgreSQL은 외부 연결 차단 (내부 네트워크 전용)

22.2 Docker 기반 프로젝트 설정

1) Dockerfile

FROM openjdk:17-jdk-slim

WORKDIR /app

COPY build/libs/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

2) .dockerignore

build/
.gradle/
*.log
*.iml

22.3 Docker Compose로 환경 구성

docker-compose.yml

version: "3.8"
services:
  db:
    image: postgres:15
    container_name: ktor_db
    environment:
      POSTGRES_USER: ktor
      POSTGRES_PASSWORD: ktorpass
      POSTGRES_DB: ktor_app
    volumes:
      - ./dbdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  app:
    build: .
    container_name: ktor_app
    depends_on:
      - db
    environment:
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: ktor
      DB_PASSWORD: ktorpass
    ports:
      - "8080:8080"

  nginx:
    image: nginx:alpine
    container_name: ktor_nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./uploads:/usr/share/nginx/html/uploads
    depends_on:
      - app

22.4 Nginx 리버스 프록시 설정

nginx.conf

events {}

http {
    server {
        listen 80;

        location / {
            proxy_pass http://ktor_app:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }

        location /static/ {
            root /usr/share/nginx/html;
        }
    }
}

22.5 환경 변수 연동

application.conf

ktor {
  deployment {
    port = 8080
  }
  application {
    modules = [ com.example.ApplicationKt.main ]
  }
}

db {
  host = ${?DB_HOST}
  port = ${?DB_PORT}
  user = ${?DB_USER}
  password = ${?DB_PASSWORD}
  name = ktor_app
}

코드에서는 System.getenv("DB_HOST") 식으로 주입


22.6 SSL 인증서 적용 (옵션)

Let's Encrypt + Certbot을 통해 무료 SSL 인증서 적용 가능
→ nginx.conf 수정 + certbot-auto 설치
→ 실제 도메인 필요


22.7 GitHub Actions 기반 CI/CD 구성

.github/workflows/deploy.yml

name: Deploy to Server

on:
  push:
    branches: [ main ]

jobs:
  build-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          java-version: '17'

      - name: Build with Gradle
        run: ./gradlew clean build

      - name: Copy to Server
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_KEY }}
          source: "build/libs/*.jar"
          target: "/home/ktor/app"

      - name: Deploy via SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /home/ktor/app
            docker-compose down
            docker-compose up -d --build

22.8 실전 서버 배포 흐름 요약

  1. 프로젝트에서 ./gradlew build → JAR 생성
  2. GitHub에 main 브랜치 push
  3. GitHub Actions가 서버로 파일 복사
  4. Docker Compose로 애플리케이션 재기동
  5. Nginx가 외부 트래픽을 프록시하여 Ktor 앱에 전달

22.9 유지보수 전략

  • .env 파일로 모든 환경변수 관리
  • 이미지 태그 관리 (예: ktor:1.0.3)
  • DB 백업 자동화 → pg_dump, cron 설정
  • 로그 관리 → Logback + file rotation

22.10 마무리: API는 배포되어야 진짜다

개발만 잘해도 절반은 성공이다.
그러나 그 다음 절반은, 안정적인 운영과 배포다.

이번 장에서는 Docker를 기반으로 로컬 → 테스트 → 운영 서버까지 확장 가능한 구조를 만들었으며,
CI/CD를 통해 무중단 자동 배포까지 경험해 보았다.

이 흐름은 실무에서 바로 사용할 수 있는 수준이며,
클라우드 플랫폼 (AWS, GCP, Azure 등)에서도 유사하게 적용 가능하다.


 

23장. 클라우드 환경 최적화 – AWS EC2, RDS, S3 연동 전략

클라우드 인프라는 단순한 배포 공간이 아니다.
가용성, 확장성, 보안, 자동화를 모두 포함하는 실전 플랫폼이다.
이 장에서는 AWS를 기반으로 Ktor 백엔드 서버의 클라우드 최적화 전략을 구성해본다.


23.1 AWS EC2에 Ktor 서버 배포

1) EC2 인스턴스 생성

  • 인스턴스 유형: t3.micro (테스트 용도)
  • AMI: Ubuntu 22.04 LTS
  • 보안 그룹 설정:
    • 22 (SSH)
    • 80 (HTTP)
    • 443 (HTTPS)
    • 8080 (앱용 포트)

2) 초기 셋업

# 인스턴스 접속
ssh -i key.pem ubuntu@EC2_PUBLIC_IP

# 기본 패키지 설치
sudo apt update
sudo apt install docker.io docker-compose unzip git -y

3) 프로젝트 배포

  • GitHub에서 pull 받거나
  • SCP로 파일 전송 후 docker-compose up -d --build 실행

23.2 PostgreSQL → RDS 연동

1) RDS 인스턴스 생성

  • DB 엔진: PostgreSQL
  • 버전: 15.x
  • 퍼블릭 접근 가능 여부: ❌ (비공개 권장)
  • 보안 그룹: EC2 인스턴스와 같은 VPC 내부 통신 허용

2) 애플리케이션에서 환경 변수 설정

DB_HOST=rds-instance.abcdefg.ap-northeast-2.rds.amazonaws.com
DB_PORT=5432
DB_USER=ktor
DB_PASSWORD=securepassword

3) 연결 확인

psql -h $DB_HOST -U ktor -d ktor_app

23.3 S3 연동 – 이미지 저장소 분리

1) S3 버킷 생성

  • 예: ktor-image-storage
  • 공개 권한 ❌ (Presigned URL 방식 권장)

2) AWS SDK 설정

의존성 추가 (build.gradle.kts)

implementation("software.amazon.awssdk:s3:2.20.12")

3) S3Uploader 유틸 클래스

object S3Uploader {
    private val s3 = S3Client.builder()
        .region(Region.AP_NORTHEAST_2)
        .credentialsProvider(EnvironmentVariableCredentialsProvider.create())
        .build()

    fun upload(fileName: String, bytes: ByteArray): String {
        val key = "uploads/$fileName"
        val request = PutObjectRequest.builder()
            .bucket("ktor-image-storage")
            .key(key)
            .contentType("image/png")
            .build()

        s3.putObject(request, RequestBody.fromBytes(bytes))
        return "https://ktor-image-storage.s3.ap-northeast-2.amazonaws.com/$key"
    }
}

4) 업로드 API 수정

post("/upload") {
    val multipart = call.receiveMultipart()
    var url: String? = null

    multipart.forEachPart { part ->
        if (part is PartData.FileItem) {
            val bytes = part.streamProvider().readBytes()
            val fileName = "${UUID.randomUUID()}.png"
            url = S3Uploader.upload(fileName, bytes)
        }
        part.dispose()
    }

    call.respond(ApiResponse(success = true, data = mapOf("url" to url)))
}

23.4 IAM 역할 및 보안 관리

  • EC2 역할에 S3 쓰기 권한 부여 (IAM Role → AmazonS3FullAccess)
  • RDS는 퍼블릭 접근 ❌ → EC2 보안 그룹과만 연결
  • 보안 그룹 Ingress/Outbound 최소화

23.5 EC2 자동 재시작 및 모니터링

  • cloud-init 또는 systemd로 자동 시작 설정
sudo crontab -e
@reboot cd /home/ubuntu/app && docker-compose up -d
  • CloudWatch 로그 연동 → 애플리케이션 상태 모니터링

23.6 고급 최적화 팁

항목 전략
이미지 저장소 S3 + CloudFront
데이터베이스 RDS + 자동 백업
앱 서버 확장 EC2 Auto Scaling or Fargate
보안 WAF + Security Group 최소화
비용 최적화 Reserved Instance, Spot 사용

23.7 예시: 업로드 → S3 URL 반환 흐름

  1. 클라이언트에서 이미지 업로드 요청 (POST /upload)
  2. 서버는 S3에 파일 업로드
  3. 업로드된 S3 URL을 응답
  4. 게시글 작성 시 해당 URL과 함께 저장

→ 파일은 서버에 저장되지 않음 → 스토리지 분리


23.8 실무에서의 확장 전략

  • 이미지 URL 만료 → presigned URL 방식으로 다운로드 제한 가능
  • S3 버킷 정책으로 접근 제어
  • CloudFront CDN 연동으로 속도 향상 및 트래픽 절감

23.9 로컬 ↔ 클라우드 전환 전략

항목 로컬 개발 클라우드 운영
DB Docker-Postgres RDS(PostgreSQL)
파일 저장 uploads/ 디렉토리 AWS S3
도메인 localhost Route53, 도메인 연동
HTTPS 없음 Nginx + Let's Encrypt

23.10 마무리: 실전 배포는 곧 인프라 설계다

클라우드는 더 이상 인프라 팀의 영역만이 아니다.
개발자라면 API를 만들 뿐 아니라, 그것을 운영 가능한 구조로 올리는 능력도 갖추어야 한다.

이번 장에서는 AWS의 대표 서비스인 EC2, RDS, S3를 연동하여
Ktor API 서버를 실전 클라우드 환경에서 운영하는 구조를 직접 구성해 보았다.
이는 단순한 "서버 구축"이 아닌, 실제 서비스 제공자로서의 시작점이다.


 

24장. 성능 최적화 – 캐싱, 비동기 처리, 쿼리 튜닝, 모니터링 전략

기능을 잘 만드는 것도 중요하지만,
빠르고 안정적인 서비스를 제공하는 것이 진짜 실력이다.
이 장에서는 실전 Ktor 서버의 성능을 끌어올리는 다양한 전략을 다룬다:

  • 서버 내부/외부 캐싱
  • 비동기 코루틴 최적화
  • DB 쿼리 성능 분석 및 튜닝
  • 프로파일링과 모니터링 도구 활용

24.1 캐싱 전략

1) 응답 데이터 캐싱 (Redis 사용)

캐싱 대상 예시:

  • 인기 게시글 목록
  • 태그 목록
  • 카테고리 트리

의존성 추가 (Gradle)

implementation("redis.clients:jedis:4.4.3")

RedisClient.kt

object RedisClient {
    private val jedis = Jedis("localhost", 6379)

    fun get(key: String): String? = jedis.get(key)
    fun set(key: String, value: String, expire: Int = 60) {
        jedis.setex(key, expire, value)
    }
}

사용 예시:

fun getPopularPosts(): List<PostDto> {
    val cache = RedisClient.get("popular_posts")
    return if (cache != null) {
        Json.decodeFromString(cache)
    } else {
        val data = postRepository.findPopular()
        RedisClient.set("popular_posts", Json.encodeToString(data))
        data
    }
}

2) HTTP 레벨 캐싱

call.response.headers.append(HttpHeaders.CacheControl, "max-age=60")

24.2 비동기 처리 최적화 – 코루틴 실전 활용

Ktor는 기본적으로 코루틴 기반이지만,
서비스나 리포지토리 계층에서도 suspend 함수와 launch, async를 적절히 활용해야 성능 차이를 낼 수 있다.

예: 동시에 여러 외부 API 호출

suspend fun fetchUserDashboard(userId: Long): DashboardDto = coroutineScope {
    val postsDeferred = async { postService.getRecentPosts(userId) }
    val commentsDeferred = async { commentService.getLatestComments(userId) }

    DashboardDto(
        posts = postsDeferred.await(),
        comments = commentsDeferred.await()
    )
}
  • 병렬 처리 → I/O 대기 시간 단축

24.3 쿼리 성능 튜닝

1) 인덱스 활용

  • createdAt, likeCount, userId 등의 조건 필드에 인덱스 부여
  • N:M 테이블에는 composite index 필수
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);

2) N+1 문제 해결

  • Exposed 프레임워크에서 join 적극 활용
Posts.innerJoin(Users).selectAll()
  • Batch fetch 도입 → 다수 ID에 대한 한 번의 쿼리로 대체

24.4 요청 수 감소 전략

  • 클라이언트에 필요한 모든 정보를 하나의 API로 묶기
    → ex) /dashboard → 유저 정보 + 최근 게시물 + 알림
  • GraphQL 도입도 고려 가능 (선택적 필드 응답)

24.5 정적 파일 CDN 적용

  • 이미지 업로드 경로를 CloudFront로 구성
    → /images/abc.png → CloudFront 캐싱 적용
  • Nginx에서 Cache-Control 설정 강화
location /static/ {
    root /var/www/html;
    add_header Cache-Control "public, max-age=86400";
}

24.6 데이터베이스 커넥션 풀 설정

HikariCP 설정 예시

val config = HikariConfig().apply {
    jdbcUrl = "jdbc:postgresql://..."
    username = "ktor"
    password = "pass"
    maximumPoolSize = 20
    connectionTimeout = 3000
}
val dataSource = HikariDataSource(config)

24.7 로깅 및 모니터링 도구

1) Prometheus + Grafana

  • /metrics 엔드포인트 노출
  • JVM 메모리/CPU/GC 상태 실시간 시각화

2) Logback 설정

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>logs/app.log</file>
</appender>
  • 에러 로그 → 슬랙 연동, Sentry 연동 가능

24.8 프로파일링과 트레이싱

  • JProfiler, VisualVM → CPU 사용량/메소드별 호출 시간 분석
  • Jaeger, Zipkin → 분산 트레이싱 적용

24.9 성능 테스트 자동화

Gatling, K6, JMeter 사용 가능

k6 run test_script.js
  • API 응답 속도
  • TPS (Transactions Per Second)
  • 에러율 측정

24.10 마무리: 성능은 결과가 아니라 전략이다

Ktor와 Kotlin은 기본적으로 빠르고 안정적인 기술 스택이지만,
서비스의 응답 속도, 안정성, 확장성은 개발자의 설계와 튜닝에 달려 있다.

이번 장에서는 실무에서 꼭 필요한 캐싱 전략, 비동기 처리 방식, DB 최적화, 로깅과 모니터링 전략 등을 실전처럼 구현해 보았다.
이는 고성능 API 서비스를 위한 기초 체력 훈련과도 같다.


 

25장. SEO와 콘텐츠 마케팅을 고려한 API 설계 전략 – 검색 노출 최적화, OG 태그, 공유형 URL 설계

아무리 좋은 콘텐츠도 노출되지 않으면 무의미하다.
특히 블로그형 웹사이트나 포트폴리오 기반 서비스는 검색 노출과 소셜 미디어 공유가 트래픽의 핵심이다.
이 장에서는 검색엔진과 사람 모두를 만족시키는 콘텐츠 API 설계 전략을 소개한다.


25.1 검색 노출이 중요한 이유

  • 검색 결과에 잘 노출될수록 유입은 저절로 증가
  • 구조화된 콘텐츠 제공은 구글의 신뢰 지표 향상
  • SNS 공유 시 보기 좋은 미리보기 카드 제공 → 클릭률 상승

25.2 SSR 또는 프리렌더 방식 필요

Ktor는 서버 사이드 렌더링을 직접 제공하지 않으나,
정적 HTML 템플릿 기반 렌더링 또는 외부 프론트엔드와 연동하여 SSR 환경 구현이 가능하다.

예:

  • Ktor → /posts/{slug} 요청 시 HTML 렌더링
  • 또는 → Next.js 등의 프론트와 API 연동

25.3 Open Graph(OG) 태그와 메타 태그 설계

예: 미리보기 태그 포함된 HTML

<html>
  <head>
    <meta property="og:title" content="Kotlin과 Ktor로 만드는 백엔드" />
    <meta property="og:description" content="클린하고 확장 가능한 REST API 실전 가이드" />
    <meta property="og:image" content="https://yourcdn.com/images/post-thumbnail.png" />
    <meta property="og:url" content="https://yourapp.com/posts/kotlin-ktor-guide" />
    <title>Ktor 백엔드 실전 가이드</title>
  </head>
  <body> ... </body>
</html>

25.4 Ktor에서 HTML 응답 제공

get("/posts/{slug}") {
    val slug = call.parameters["slug"] ?: throw NotFoundException()
    val post = postService.findBySlug(slug) ?: throw NotFoundException()

    call.respondText(ContentType.Text.Html) {
        """
        <html>
        <head>
          <meta property="og:title" content="${post.title}" />
          <meta property="og:description" content="${post.summary}" />
          <meta property="og:image" content="${post.thumbnailUrl}" />
          <meta property="og:url" content="https://yourapp.com/posts/${post.slug}" />
          <title>${post.title}</title>
        </head>
        <body>
          <h1>${post.title}</h1>
          <p>${post.content}</p>
        </body>
        </html>
        """
    }
}

→ SNS, 카카오톡, 페이스북, 트위터 등에 링크 붙여도 미리보기 자동 생성


25.5 Slug 기반 SEO URL 설계

예:

  • /posts/12 → X (비직관적)
  • /posts/kotlin-ktor-guide → O (키워드 중심 구조)

Slug 생성 예시

fun generateSlug(title: String): String {
    return title.lowercase()
        .replace(Regex("[^a-z0-9\\s-]"), "")
        .replace("\\s+".toRegex(), "-")
        .take(100)
}

25.6 검색봇을 위한 sitemap.xml 자동 생성

예시:

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://yourapp.com/posts/kotlin-ktor-guide</loc>
    <lastmod>2025-07-18</lastmod>
  </url>
</urlset>

라우트 예시

get("/sitemap.xml") {
    val allPosts = postRepository.findAll()
    val body = buildString {
        append("http://www.sitemaps.org/schemas/sitemap/0.9\">")
        allPosts.forEach {
            append("https://yourapp.com/posts/${it.slug}")
            append("${it.updatedAt}")
        }
        append("")
    }

    call.respondText(body, ContentType.Application.Xml)
}

25.7 robots.txt 파일 제공

get("/robots.txt") {
    call.respondText("""
        User-agent: *
        Allow: /
        Sitemap: https://yourapp.com/sitemap.xml
    """.trimIndent(), ContentType.Text.Plain)
}

25.8 검색 최적화 API 응답 설계

  • 각 포스트 응답에 slug, title, summary, thumbnailUrl 포함
  • OG 태그에 그대로 활용 가능

API 응답 예

{
  "slug": "kotlin-ktor-guide",
  "title": "Kotlin과 Ktor로 만드는 백엔드",
  "summary": "REST API 서버를 클린하고 확장 가능하게 설계하는 방법",
  "thumbnailUrl": "https://yourcdn.com/images/kotlin.png"
}

25.9 공유형 링크에 UTM 파라미터 적용

마케팅 분석을 위한 UTM 파라미터 구성 예:

https://yourapp.com/posts/kotlin-ktor-guide?utm_source=facebook&utm_medium=post&utm_campaign=summer_launch

→ GA 또는 Matomo 등에서 유입 경로 분석 가능


25.10 마무리: 좋은 콘텐츠는 설계된 노출을 통해 완성된다

SEO는 기술과 마케팅이 교차하는 영역이다.
검색 엔진과 사용자, SNS 플랫폼까지 모두 고려한 콘텐츠 설계는
현대 API 서비스의 기본 역량이다.

이번 장에서는 Slug, OG 태그, sitemap, robots.txt, 공유 링크 설계 등
실무 마케팅에서도 효과적인 API 설계 방법을 실전 코드 중심으로 구현해 보았다.


 

에필로그: Kotlin & Ktor, 실무 백엔드 개발자의 다음 도약을 위하여

당신은 지금, 이 책의 마지막 장을 읽고 있다.
그 말은 곧, Kotlin과 Ktor라는 낯설고도 강력한 도구를 이용해
하나의 실제 REST API 서버를 구축하는 여정을 완주했다는 뜻이다.

이 여정은 단순히 기술 하나를 익히는 데서 끝나는 것이 아니다.
당신은 서버를 설계했고, API를 만들었으며,
JWT 인증, 데이터베이스 설계, 캐싱, 배포, 성능 최적화, SEO까지
서비스를 개발하고 운영하는 전 과정을 직접 체험했다.

이제 당신은 단순한 "코드를 짜는 사람"이 아니라,
서비스의 동작 원리를 이해하고 구조화할 수 있는 백엔드 개발자다.


이제 무엇을 할 것인가?

코틀린과 Ktor를 익힌 당신은, 이제 다양한 방향으로 성장할 수 있다.
다음의 제안은 그 출발점이 될 것이다.


1) 팀 프로젝트에 Ktor 도입해보기

지금까지는 혼자 개발했을지도 모른다.
이제는 작은 팀 프로젝트에 Ktor를 직접 제안해보자.

  • 프론트엔드와 REST API로 협업하기
  • JWT 토큰 발급 및 인증 흐름 적용
  • GitHub Action으로 자동 배포 구성하기

단순한 실습을 넘어서, 현실적인 개발 팀에서의 협업 훈련이 된다.


2) 실전 서비스 런칭해보기

당신이 만든 API 서버는,
지금도 충분히 하나의 작은 서비스로 외부에 공개될 수 있다.

  • 글쓰기 플랫폼, 포트폴리오 앱, 북마크 관리 서비스, 피드 기반 SNS…
  • UI는 React, Vue 등으로 구성하고
  • API는 Ktor가 담당

실제 도메인과 서버를 연결하고, Google에 색인시켜보라.
기술은 도구고, 가치는 실행에서 발생한다.


3) 기술 블로그에 작성하며 포트폴리오 만들기

지금 배운 내용을 당신의 언어로 정리해보자.
Ktor 관련 콘텐츠는 아직 국내에 많지 않다.
이런 글을 써볼 수 있다:

  • "Ktor로 API 서버 만들기 - 5일 완성 가이드"
  • "Spring 대신 Ktor를 선택한 이유"
  • "Kotlin 코루틴으로 API 성능 최적화하는 법"

잘 정리된 블로그는 당신의 가장 강력한 포트폴리오가 된다.


4) 오픈소스 기여

Ktor는 JetBrains에서 개발한 오픈소스 프레임워크다.
GitHub 이슈나 문서 번역, 샘플 프로젝트 공유 등
작은 기여도 성장의 발판이 된다.

오픈소스에 기여하는 사람은 ‘선택받는 개발자’가 된다.


5) Kotlin 멀티플랫폼(Multiplatform) 도전

Ktor는 Kotlin Multiplatform 지원을 기반으로
Android, iOS, Web, Desktop, Server 모두에서 동작할 수 있다.
서버 외에도 모바일 앱 클라이언트 코드까지 함께 작성하는 미래도 가능하다.

이런 도전은 당신을 풀스택을 넘어, 크로스 플랫폼 개발자로 만들어 줄 것이다.


이 책이 당신에게 무엇이었길 바라며

이 책은 단순한 기술 문서가 아니라,
하나의 실전 개발 로드맵으로 기획되었다.
그래서 코드만 나열하지 않았고,
실제 사용하는 이유와 방향성, 마케팅, 수익화, 인프라까지 포함했다.

그 이유는 하나다.
당신이 단순히 기술을 익히는 개발자에서,
실제 서비스를 만드는 프로덕트 메이커로 성장하길
바랐기 때문이다.


개발은 여전히 어렵지만, 그래서 더 멋지다

당신은 개발자로 살아가기로 결정했다.
수없이 바뀌는 기술, 끝없는 문제 해결,
그리고 늘 완벽하지 않은 현실 속에서
해답을 찾아가는 이 여정은 어렵고, 또 어렵다.

그러나 그 속에 가장 창의적이고, 자유롭고, 도전적인 세계가 있다.
그것이 바로 개발자라는 직업이 가진 본질이다.

그리고 당신은 지금,
그 세계를 만들 수 있는 능력을 갖췄다.


고맙습니다, 그리고 계속 나아가세요

이 책을 끝까지 읽어주셔서 진심으로 감사합니다.
여기까지 온 것만으로도 이미 큰 도약입니다.
이제는 당신 차례입니다.

다음은,
당신만의 프로젝트, 당신만의 글, 당신만의 서비스를 만들 시간입니다.

항상 응원합니다.
그리고 언젠가,
이 책보다 더 멋진 것을 당신이 만들기를 바랍니다.

— Kotlin & Ktor 실전 입문서 저자 드림


 

Kotlin & Ktor 실전 입문서, 클린하고 확장 가능한 서버 개발(REST API 백엔드 구축부터 배포, 성능 최적화, SEO 전략까지 실무 완전 정복), 서항주 지음

 

반응형
사업자 정보 표시
올딩 | 서항주 | 경기도 성남시 중원구 성남동 3313번지 B동 101호 | 사업자 등록번호 : 119-18-42648 | TEL : 02-6901-7078 | Mail : jason@allding.co.kr | 통신판매신고번호 : 제2012-경기성남-1364호 | 사이버몰의 이용약관 바로가기

댓글

💲 추천 글