☕ 자바↗ 🐍 파이썬↗ 🟣 C#↗ 🟨 JS·TS↗ 🗄️ DB↗ 🌐 서버기술↗ 🔵 C++↗ 🔌 C · 임베디드↗ 🦀 Rust↗ 🎓 CS 기술↗
👋 처음이신가요? 위 주제 메뉴 ▾(또는 키보드 1~0)로 주제 이동 · 🔍 검색(Ctrl K)에 “코루틴 취소”나 “Compose 상태”처럼 하려는 일을 한글로 입력 · 코드 상자에 마우스를 올리면 복사 버튼이 나타납니다
KOTLIN 2.x · 문법의 뼈대부터 자바 상호운용까지

코틀린은
“자바를 줄인 것”이
아니다

코틀린을 자바의 문법 설탕으로만 보면 절반만 쓰게 됩니다. null을 타입으로 다루고, 상태를 sealed로 닫고, 동시성을 suspend로 표현하는 — 설계 방식이 다른 언어입니다. 이 탭은 그 뼈대를 자바 코드와 나란히 놓고, 왜 이렇게 바뀌었는지까지 설명합니다.

19SECTIONS
2.x기준 버전 (K2)
0사전 지식 (기초부터)
100%복붙해서 도는 예제
KOTLIN 01

코틀린을 실행하는 법

코틀린은 JDK 위에서 도는 언어입니다. 자바를 지우고 시작하는 게 아니라, 쓰던 자바 프로젝트에 .kt 파일 하나를 섞어 넣는 것부터 시작할 수 있습니다.

한 문단 요약

2011년 젯브레인스(IntelliJ를 만든 회사)가 공개했고, 2017년 안드로이드 공식 언어, 2019년부터는 안드로이드 권장 언어가 됐습니다. JVM 바이트코드로 컴파일되므로 자바 라이브러리를 그대로 씁니다 — 스프링·JPA·Jackson·Netty 전부 코틀린에서 부를 수 있습니다.

그래서 도입 비용이 낮습니다. 자바 파일과 코틀린 파일이 같은 모듈 안에 섞여 있어도 컴파일이 됩니다. 새 클래스만 코틀린으로 쓰고 기존 코드는 그대로 두는 방식이 실무에서 가장 흔한 전환 경로입니다.

이 언어를 왜 쓰나 — 네 가지

얻는 것구체적으로
null 안전NPE가 컴파일 에러로 앞당겨집니다. 운영 장애 원인 1위를 문법이 막습니다
짧아짐DTO·getter·setter·builder가 사라집니다. 같은 기능이 대략 30~40% 적은 줄
코루틴비동기 코드를 동기처럼 씁니다. 콜백·CompletableFuture 지옥이 없습니다
한 코드 여러 곳서버·안드로이드·iOS·웹을 같은 코드베이스로 (KMP)
줄 수가 줄어드는 게 본질은 아닙니다 — 줄어든 자리가 대부분 실수가 나던 자리(equals 빠뜨림, null 검사 누락, 빌더 필드 누락)라는 게 핵심입니다.
코틀린 소스 하나가 나가는 곳 — 컴파일 타깃 Kotlin 소스 .kt K2 컴파일러 Kotlin 2.0부터 기본 JVM 바이트코드 Spring Boot · Ktor · 배치 — 이 가이드의 절반 Android DEX Jetpack Compose 앱 — 안드로이드 탭 2개 Native (LLVM) iOS 프레임워크 · 리눅스 실행파일 — KMP 탭 JS · Wasm 브라우저 · Compose for Web
코드 한 벌이 서버 · 안드로이드 · iOS · 웹으로 나갑니다 — 이 가이드는 네 갈래를 모두 다룹니다.

설치 — 세 가지만 있으면 됩니다

1. JDK 17 이상

# macOS · Linux (SDKMAN 권장)
sdk install java 21-tem
sdk install java 25-tem   # 최신 LTS

# Windows
winget install EclipseAdoptium.Temurin.21.JDK

java -version   # 확인

코틀린 자체는 JDK 8부터 돌지만, 스프링 부트 4가 JDK 17+를 요구합니다. 새로 시작한다면 21 또는 25를 고르세요.

2. IntelliJ IDEA

코틀린을 만든 회사가 만든 IDE라 지원 수준이 다릅니다. Community 판(무료)으로 충분하고, 코틀린 플러그인은 기본 내장입니다.

자바 → 코틀린 자동 변환이 있습니다. 자바 코드를 복사해 .kt 파일에 붙여 넣으면 변환할지 물어봅니다 (Ctrl+Alt+Shift+K). 배우는 데 가장 좋은 도구입니다 — 내가 아는 자바 코드가 코틀린으로 어떻게 바뀌는지 바로 보입니다.

3. Gradle (Kotlin DSL)

# 새 프로젝트는 IDE 마법사가
# gradlew 까지 만들어 줍니다.
./gradlew build    # 빌드 + 테스트
./gradlew run      # 실행
./gradlew --stop   # 데몬 재시작 (버전 꼬임 해결)

빌드 스크립트도 코틀린으로 씁니다(build.gradle.kts). 오타를 IDE가 잡아 주고 자동완성이 됩니다.

첫 코드 — 자바와 나란히

자바
// Hello.java — 파일명 = 클래스명
public class Hello {
    public static void main(String[] args) {
        System.out.println("안녕하세요");
    }
}

클래스로 감싸야 하고, public static void를 외워야 합니다.

코틀린
// Hello.kt — 파일명 자유. 클래스도 필요 없다
fun main() {
    println("안녕하세요")
}

최상위 함수가 됩니다. 컴파일하면 HelloKt 클래스가 자동으로 만들어지고 그 안의 static 메서드가 됩니다 — 자바와의 호환은 그대로입니다.

빌드 파일 — build.gradle.kts

// build.gradle.kts — 코틀린 JVM 프로젝트의 최소 형태
plugins {
    kotlin("jvm") version "2.2.0"          // 코틀린 컴파일러 플러그인
    application                             // ./gradlew run 을 쓰려면
}

repositories { mavenCentral() }

kotlin {
    jvmToolchain(21)      // 내 PC 에 21이 없어도 Gradle 이 받아 씁니다
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
    testImplementation(kotlin("test"))
}

application { mainClass.set("com.example.MainKt") }   // 최상위 main 은 파일명 + Kt

tasks.test { useJUnitPlatform() }

기존 자바 프로젝트에 얹기

// 이미 있는 자바 Gradle 프로젝트라면 이 두 줄이면 끝
plugins {
    java
    kotlin("jvm") version "2.2.0"    // ← 추가
}

// src/main/java 와 src/main/kotlin 이 함께 컴파일된다.
// (코틀린 플러그인이 java 소스 폴더도 같이 봐 준다)
컴파일 순서가 재미있습니다 — 코틀린 컴파일러가 자바 소스를 먼저 읽고(컴파일은 하지 않음) 코틀린을 컴파일한 뒤, javac 가 그 결과물을 보고 자바를 컴파일합니다. 그래서 서로를 참조해도 됩니다.

버전 — 무엇이 언제 바뀌었나

버전무엇이 들어왔나
1.3코루틴 정식(2018). 그 전에는 실험 기능이었습니다
1.5value class · sealed interface
1.9구 컴파일러의 마지막. 지금도 레거시 프로젝트에 많습니다
2.0K2 컴파일러 기본 탑재(2024). 컴파일이 눈에 띄게 빨라지고 타입 추론이 정확해졌습니다
2.1~2.2when의 가드 조건, 비지역 break·continue, 컨텍스트 파라미터

새로 시작하면 2.x. 버전은 계속 오르므로 프로젝트를 만들 때 공식 릴리스 노트에서 최신 안정판을 확인하세요.

흔한 첫 실수 — 자바 프로젝트에 코틀린을 넣고 src/main/kotlin 폴더를 안 만들면 IDE가 파일을 알아보지 못합니다. 그리고 Gradle 데몬이 옛 코틀린 버전을 물고 있는 경우가 있어, 버전을 올린 뒤 설명 없는 에러가 나면 ./gradlew --stop 한 번이 답일 때가 많습니다.
KOTLIN 02

val · var · 타입

코틀린의 기본값은 “안 바뀐다”입니다. 변수도, 컬렉션도, 클래스도 — 바꾸고 싶으면 바꾸겠다고 적어야 합니다. 이 방향이 언어 전체를 관통합니다.

val — 다시 대입할 수 없는 이름

val name = "김현우"      // 타입 생략 — String 으로 추론
val age: Int = 34        // 적어도 된다
// name = "다른 이름"     // ❌ 컴파일 에러

var count = 0            // 바뀔 값만 var
count += 1               // ✅

// val 은 '내용이 불변'이 아니라 '다시 대입 불가' 다
val list = mutableListOf(1, 2)
list.add(3)              // ✅ 내용은 바뀐다
// list = mutableListOf() // ❌ 대입은 안 된다

자바의 final과 같은 자리지만, 기본으로 쓰게 만든 것이 차이입니다. val 이 먼저 손에 익으면 “이 값이 아래에서 바뀌었나?”를 확인하러 위로 올라가는 일이 사라집니다.

타입 추론 — 어디까지 생략하나

val a = 1                 // Int
val b = 1L                // Long
val c = 1.0               // Double
val d = 1.0f              // Float
val e = 'A'               // Char  (작은따옴표)
val f = "A"               // String(큰따옴표)
val g = true              // Boolean
val h = listOf(1, 2, 3)   // List<Int>
val i = mapOf("a" to 1)   // Map<String, Int>

// 함수 반환 타입은 '적는 편'이 낫다 (공개 API)
fun total(a: Int, b: Int): Int = a + b
기준 — 지역 변수는 생략, public 함수의 반환 타입은 명시. 추론에 맡기면 안쪽 구현을 고쳤을 때 공개 시그니처가 조용히 바뀝니다.

숫자 — 자동 형변환이 없습니다

자바 — 알아서 넓혀 준다
int i = 10;
long l = i;        // ✅ 자동 확대
double d = l;      // ✅

// 대신 이런 사고가 난다
int a = 1_000_000;
int b = a * 3000;  // 오버플로 (조용히 음수)
코틀린 — 직접 바꿔야 한다
val i: Int = 10
// val l: Long = i     // ❌ 컴파일 에러
val l: Long = i.toLong()   // ✅ 명시
val d: Double = l.toDouble()

// 리터럴은 문맥으로 판단해 준다
val ok: Long = 10          // ✅ 리터럴은 허용

왜 불편하게 만들었나 — 자바에서 int가 long으로, float이 double로 조용히 바뀌면서 생기는 정밀도 손실과 오버플로가 실제 금액 계산 사고의 단골이기 때문입니다. 코틀린은 바꾸는 지점을 코드에 남기게 합니다.

숫자 리터럴과 표기

val big = 1_000_000_000L    // 밑줄로 자릿수 구분
val hex = 0xFF              // 16진수 = 255
val bin = 0b1010_1010       // 2진수
val f   = 3.14f             // Float
val u   = 42u               // UInt (부호 없는 정수)

// 나눗셈 — 정수끼리는 정수 나눗셈
println(7 / 2)      // 3    (자바와 같다)
println(7 / 2.0)    // 3.5
println(7 % 2)      // 1

// 금액은 Long(원 단위) 또는 BigDecimal
val price: Long = 12_900L
val exact = java.math.BigDecimal("0.1") + java.math.BigDecimal("0.2")
println(exact)      // 0.3   (Double 이면 0.30000000000000004)

문자열 → 숫자, 안전하게

val ok  = "42".toInt()          // 42
// val no = "사십이".toInt()     // 💥 NumberFormatException

// 실패를 null 로 받는다 — 코틀린식
val n: Int? = "사십이".toIntOrNull()   // null
val safe = "사십이".toIntOrNull() ?: 0 // 0

// 실무: 쿼리 파라미터 파싱
fun page(raw: String?): Int =
    raw?.toIntOrNull()?.coerceAtLeast(1) ?: 1

println(page("3"))     // 3
println(page("abc"))   // 1
println(page(null))    // 1
...OrNull() 계열을 먼저 찾으세요. toIntOrNull·firstOrNull·singleOrNull· maxOrNull — 코틀린 표준 라이브러리의 일관된 규칙입니다.

Any · Unit · Nothing — 세 개의 특별한 타입

타입 계층은 마름모 — 위는 Any? 하나, 아래는 Nothing 하나로 닫힙니다 Any? 모든 것의 조상 — null 포함 Any null 아닌 모든 값 null Nothing? 의 값 String · Int 내 클래스 Unit void 자리 Nothing 값이 하나도 없는 타입 Nothing 은 모든 타입의 자식 — throw · error() 를 어느 자리에 써도 타입이 맞습니다
Nothing은 “절대 정상 반환하지 않는다”는 뜻입니다 — throw와 무한 루프의 타입입니다.

Any — 최상위

val x: Any = 42
val y: Any = "hello"
// Any 에는 equals·hashCode·
// toString 만 있다

// null 까지 담으려면 Any?
val z: Any? = null

자바의 Object 자리. 다만 Any는 null을 못 담습니다.

Unit — void 자리

fun log(m: String): Unit {
    println(m)
}
// Unit 은 생략 가능
fun log2(m: String) = println(m)

// void 와 다른 점: Unit 은 '값'이다
val u: Unit = log("hi")

값이라서 제네릭에 넣을 수 있습니다 — List<Unit>도 됩니다.

Nothing — 돌아오지 않음

fun fail(m: String): Nothing =
    throw IllegalStateException(m)

// 그래서 이게 컴파일된다
val name: String = user.name
    ?: fail("이름 없음")
// fail 이 Nothing 이라
// 아래로는 절대 안 내려온다

컴파일러에게 “여기서 끝”을 알려 주는 타입입니다.

상수 · 최상위 선언

// Config.kt — 클래스 없이 최상위에 둘 수 있다
const val MAX_RETRY = 3            // 컴파일 시점 상수 (자바의 static final)
val START_TIME = System.currentTimeMillis()   // 런타임에 정해짐 → const 불가

object AppConfig {                 // 묶어 두고 싶으면 object
    const val TIMEOUT_MS = 3_000L
    val allowedHosts = listOf("a.com", "b.com")
}

fun main() {
    println(MAX_RETRY)             // 그냥 이름으로 접근
    println(AppConfig.TIMEOUT_MS)
}
const val은 세 곳에만 쓸 수 있습니다 — 최상위 · object 안 · companion object 안. 그리고 기본 타입과 String만 됩니다. 컴파일 시점에 값이 코드에 박히기 때문입니다(자바의 static final과 같은 성질 — 라이브러리로 배포하면 쓰는 쪽을 다시 컴파일해야 값이 바뀝니다).
KOTLIN 03

null 안전성 — 코틀린의 심장

코틀린이 자바와 갈라지는 가장 큰 지점입니다. 타입에 ?가 없으면 그 값은 절대 null이 아닙니다 — 런타임 약속이 아니라 컴파일러가 강제하는 사실입니다.

같은 null 실수가 발견되는 시점 — 왼쪽일수록 싸게 고칩니다 코드 작성 컴파일 테스트 배포 운영 코틀린 — 컴파일 에러로 막힘 IDE 빨간 줄 · ?. ?: 로 고치는 데 5초 자바 — 운영 중 NPE 새벽 알람 · 원인 추적 30분 자바는 여기를 그냥 통과 발견이 세 단계 늦어질수록 비용은 장애 대응이 됩니다 위는 코틀린, 아래는 자바 — 실수는 같고 잡히는 자리만 다릅니다
같은 일을 하지만 비용을 치르는 시점이 다릅니다 — 이것이 코틀린 도입의 가장 큰 실익입니다.

타입 두 종류 — String과 String?

var a: String = "hello"
// a = null            // ❌ 컴파일 에러: Null can not be a value of a non-null type String

var b: String? = "hello"
b = null               // ✅ 허용

println(a.length)      // ✅ 바로 접근 가능 (null 일 수 없으니)
// println(b.length)   // ❌ 컴파일 에러: b 는 null 일 수 있다
println(b?.length)     // ✅ null 이면 통째로 null (Int? 반환)

네 가지 연산자 — 이것만 알면 끝

기호이름 · 뜻
?.안전 호출 — null이면 호출하지 않고 null 반환
?:엘비스 — 왼쪽이 null이면 오른쪽 값 사용 (기본값)
!!단언 — “null 아님” 강제. null이면 즉시 NPE
?.letnull이 아닐 때만 블록 실행

?:가 엘비스 연산자인 이유 — 옆으로 눕혀 보면 엘비스 프레슬리의 머리 모양이라서입니다. 농담이 아니라 공식 명칭입니다.

실제로 쓰는 모양

val user: User? = repo.find(id)

// 1) 체인 — 중간에 null 이면 전체가 null
val city: String? = user?.address?.city

// 2) 기본값
val name: String = user?.name ?: "이름없음"

// 3) 없으면 예외 (엘비스 + throw)
val u: User = user ?: throw NotFoundException(id)

// 4) 없으면 함수 종료 (엘비스 + return)
fun greet(user: User?): String {
    val n = user?.name ?: return "손님"
    return "$n 님"
}

// 5) 있을 때만 실행
user?.let { sendMail(it.email) }

스마트 캐스트 — 검사하면 타입이 바뀐다

자바 — 검사하고 또 캐스팅
if (obj instanceof String) {
    String s = (String) obj;   // 또 적는다
    System.out.println(s.length());
}

if (name != null) {
    // 그래도 IDE 는 계속 경고
    System.out.println(name.length());
}
코틀린 — 검사하면 그 뒤로 그 타입
if (obj is String) {
    println(obj.length)   // 이미 String 이다
}

if (name != null) {
    println(name.length)  // 이미 String 이다 (String? 아님)
}

// 이른 반환과도 잘 맞는다
fun f(s: String?) {
    if (s == null) return
    println(s.length)     // 아래 전부 String
}
스마트 캐스트가 안 되는 경우 — var 프로퍼티(클래스 멤버)는 검사한 뒤 다른 스레드가 바꿀 수 있어서 컴파일러가 거부합니다 (Smart cast to 'String' is impossible). 지역 val 에 담아서 쓰세요.
class A(var name: String?) {
    fun f() {
        // if (name != null) println(name.length)  // ❌
        val n = name ?: return                     // ✅ 지역 val 로 고정
        println(n.length)
    }
}

!! 는 언제 쓰나 — “거의 쓰지 않는다”

이렇게 쓰면 코틀린을 자바처럼 쓰는 것

// ❌ 안티패턴 — 컴파일 에러를 지우려고 !! 를 붙임
val name = user!!.name!!.trim()!!

// 문제: 터졌을 때 '어느 !! 인지' 알기 어렵고,
// null 안전성의 이득이 통째로 사라진다

코드 리뷰에서 !!가 보이면 “여기 왜 null이 아니라고 확신하나”를 물어야 하는 자리입니다.

써도 되는 드문 경우

// 1) 프레임워크가 채워 준다고 계약된 필드
//    (그래도 lateinit 이 낫다)

// 2) 테스트 코드 — 실패해야 하는 자리
val saved = repo.findById(id)!!   // 없으면 테스트 실패가 맞다

// 3) 방금 넣었으니 확실할 때
map["key"] = 1
val v = map["key"]!!              // 그래도 getValue 가 낫다
val v2 = map.getValue("key")      // ✅ 없으면 명확한 예외

lateinit · by lazy — 나중에 채우는 값

lateinit — 프레임워크가 주입할 때

class MyService {
    @Autowired
    lateinit var repo: UserRepository   // 생성 시점엔 없다

    fun f() = repo.findAll()   // null 검사 없이 쓴다
}

// 초기화 전에 쓰면?
// UninitializedPropertyAccessException — NPE 보다 원인이 명확

// 초기화 여부 확인
if (::repo.isInitialized) { /* ... */ }
제약 — var만 · 기본 타입(Int·Boolean 등) 불가 · null 대입 불가. 스프링에서는 생성자 주입이 더 낫습니다(다음 섹션들에서 다룹니다).

by lazy — 처음 쓸 때 만든다

class Report {
    // 무거운 계산을 '처음 접근할 때' 한 번만
    val summary: String by lazy {
        println("계산 중...")
        heavyCompute()
    }
}

val r = Report()
println("아직 계산 안 함")
println(r.summary)   // 계산 중... → 결과
println(r.summary)   // 캐시된 결과 (계산 안 함)

기본이 스레드 안전(LazyThreadSafetyMode.SYNCHRONIZED)입니다. 단일 스레드 확실하면 lazy(LazyThreadSafetyMode.NONE)로 잠금 비용을 뺄 수 있습니다.

컬렉션 속의 null

val raw: List<String?> = listOf("a", null, "b", null)

val clean: List<String> = raw.filterNotNull()      // [a, b] — 타입까지 바뀐다
println(clean.map { it.uppercase() })               // [A, B]  안전하게 접근

// null 을 빼면서 만들기
val items = listOfNotNull(find(1), find(2), find(3))

// 이 셋은 서로 다르다 — 시그니처를 읽을 때 구분하세요
// List<String>?   리스트 자체가 null 일 수 있다
// List<String?>   원소가 null 일 수 있다
// List<String?>?  둘 다
자바에서 온 값은 예외입니다 — 자바 라이브러리가 돌려준 타입은 코틀린이 null 여부를 알 수 없어 플랫폼 타입(String!)이 됩니다. 컴파일러가 검사를 강제하지 않으므로 경계에서 한 번 확인해 두어야 합니다. 자세한 내용은 18 · 자바와 섞어 쓰기에서 다룹니다.
KOTLIN 04

문자열 · 템플릿

로그 한 줄, SQL 한 덩어리, 에러 메시지 — 문자열을 다루는 시간이 생각보다 깁니다. 코틀린은 이 자리에서 이어붙이기(+)를 거의 없앱니다.

자바 — 이어붙이거나 format
String msg = "주문 " + orderId
    + " 상태=" + status
    + " 금액=" + (price * qty) + "원";

// 또는
String msg2 = String.format(
    "주문 %s 상태=%s 금액=%d원",
    orderId, status, price * qty);
코틀린 — 문자열 템플릿
val msg = "주문 $orderId 상태=$status 금액=${price * qty}원"

// 규칙은 두 가지뿐
// $이름        단순 변수
// ${식}        점(.)이나 연산이 들어가면 중괄호

val m2 = "이름: ${user.name}, 나이: ${user.age}"
val m3 = "총 ${items.size}건"

// $ 자체를 쓰려면
val price2 = "\$100"      // $100

여러 줄 문자열 — trimIndent()

raw string — 이스케이프가 없다

val sql = """
    SELECT o.id, o.status, u.name
      FROM orders o
      JOIN users u ON u.id = o.user_id
     WHERE o.status = '$status'
       AND o.created_at >= ?
""".trimIndent()

// 정규식도 편해진다 (백슬래시를 안 겹쳐 써도 됨)
val re = Regex("""\d{3}-\d{4}-\d{4}""")   // 자바: "\\d{3}-..."
println(re.matches("010-1234-5678"))      // true
위 SQL 예시는 문자열 삽입이 들어간 나쁜 예입니다 — 실제로는 반드시 바인딩 파라미터를 쓰세요(SQL 인젝션). 여기서는 템플릿 문법을 보여 주려고만 썼습니다.

trimIndent · trimMargin

// trimIndent — 공통 들여쓰기를 잘라 낸다 (가장 많이 씀)
val a = """
    첫 줄
      들여쓴 줄
""".trimIndent()
// "첫 줄\n  들여쓴 줄"

// trimMargin — 경계 문자를 직접 지정
val b = """
    |첫 줄
    |  들여쓴 줄
""".trimMargin()

// 템플릿 안에서 " 를 쓰려면
val json = """{"name": "$name", "age": $age}"""

첫 줄과 끝 줄이 공백뿐이면 통째로 사라집니다 — 그래서 위처럼 """ 바로 다음 줄부터 쓰는 게 관례입니다.

비교 — == 가 equals 입니다

val a = "hello"
val b = StringBuilder("hel").append("lo").toString()

println(a == b)     // true   — 코틀린의 == 는 equals() 호출
println(a === b)    // false  — === 가 참조 비교 (자바의 ==)

// 자바에서 그렇게 당했던 문자열 == 버그가 문법 차원에서 사라진다
// (== 는 내부적으로 null 검사까지 해 준다: a?.equals(b) ?: (b === null))

// 대소문자 무시 비교
println("Hello".equals("hello", ignoreCase = true))   // true
// 한국어 정렬·비교는 로케일을 명시
println("Apple".compareTo("apple", ignoreCase = true))

자주 쓰는 문자열 함수

검사 · 자르기 · 채우기

val s = "  Kotlin Guide  "

s.trim()                 // "Kotlin Guide"
s.isBlank()              // false (공백만인지)
s.isNullOrBlank()        // String? 에 바로 쓸 수 있다

"abcdef".substring(2)       // "cdef"
"abcdef".take(3)            // "abc"
"abcdef".drop(3)            // "def"
"abcdef".takeLast(2)        // "ef"

"7".padStart(3, '0')        // "007"
"이름".padEnd(6, ' ')       // "이름    "

"a,b,,c".split(",")                    // [a, b, , c]
"a,b,,c".split(",").filter { it.isNotEmpty() }

"010-1234-5678".replace("-", "")       // "01012345678"
"Hello".repeat(3)                      // "HelloHelloHello"

모으기 — joinToString

val names = listOf("김", "이", "박")

names.joinToString()                    // "김, 이, 박"
names.joinToString(" / ")               // "김 / 이 / 박"
names.joinToString(
    separator = ", ",
    prefix = "[", postfix = "]",
    limit = 2, truncated = "…"
)                                       // "[김, 이, …]"

// 변환까지 한 번에 — SQL IN 절 만들기 같은 데 유용
val ids = listOf(1, 2, 3)
val inClause = ids.joinToString(",") { "?" }   // "?,?,?"

// 문자열을 조립할 땐 buildString (StringBuilder 래퍼)
val report = buildString {
    appendLine("=== 리포트 ===")
    names.forEach { appendLine("- $it") }
    append("총 ${names.size}명")
}
반복문 안에서 +=로 문자열을 잇지 마세요 — 자바와 똑같이 매번 새 String 객체가 생깁니다(10만 건이면 수 초). buildString이나 joinToString을 쓰면 내부적으로 StringBuilder 하나로 처리됩니다.
KOTLIN 05

조건 · 반복 · when

코틀린에는 삼항 연산자(? :)가 없습니다. 필요가 없기 때문입니다 — if와 when이 값을 돌려주는 식(expression)이라서요.

같은 분기를 자바의 '문(statement)'과 코틀린의 '식(expression)'으로 자바 — switch 는 문(값이 없다) 코틀린 — when 은 식(값이 나온다) String label; 선언만 해 두고 값은 아직 없음 switch (code) { ... } case 마다 label = ... ; break; break 하나 빠뜨리면 아래로 흘러간다 label 사용 모든 갈래가 대입했는지 사람이 확인 val label = when (code) { 1 -> "신규" 2, 3 -> "진행" in 4..9 -> "보류" else -> "종료" } label 은 val — 미초기화가 불가능 else 를 빠뜨리면 컴파일 에러
코틀린에서 if · when · try 는 모두 값을 돌려주는 식입니다 — 빈 변수를 먼저 만들 이유가 사라집니다.
자바 — 문장(statement)
String grade;
if (score >= 90)      grade = "A";
else if (score >= 80) grade = "B";
else                  grade = "C";

// 삼항 연산자
String g2 = score >= 90 ? "A" : "B";

// switch — 값을 못 돌려줬다 (Java 14 전)
switch (status) {
    case "NEW": label = "신규"; break;
    case "PAID": label = "결제완료"; break;
    default: label = "기타";
}
코틀린 — 식(expression)
val grade = if (score >= 90) "A"
            else if (score >= 80) "B"
            else "C"

// 삼항이 필요 없다 — if 가 곧 그 자리
val g2 = if (score >= 90) "A" else "B"

// when — 값을 돌려준다
val label = when (status) {
    "NEW"  -> "신규"
    "PAID" -> "결제완료"
    else   -> "기타"
}
“식이다”가 왜 중요한가 — 변수를 먼저 선언해 두고 나중에 채우는 패턴이 사라지면 그 변수를 val로 만들 수 있습니다. 값이 정해지는 지점이 한 곳으로 모여 “이 값이 어디서 바뀌지?”를 추적할 일이 없어집니다.

when — 코틀린에서 가장 많이 쓰는 분기

// 1) 값 비교 (자바의 switch 자리)
val name = when (code) {
    1 -> "하나"
    2, 3 -> "둘 또는 셋"          // 여러 값 한 줄
    in 4..10 -> "4~10 사이"       // 범위
    !in 11..20 -> "11~20 밖"
    else -> "그 외"
}

// 2) 타입 검사 (자바의 instanceof 체인 자리) — 스마트 캐스트가 함께 온다
fun describe(x: Any): String = when (x) {
    is String -> "문자열 길이 ${x.length}"     // x 는 여기서 String
    is Int -> "정수 ${x + 1}"
    is List<*> -> "리스트 ${x.size}개"
    else -> "모름"
}

// 3) 인자 없는 when — if/else if 체인을 대신한다 (가독성이 크게 는다)
val level = when {
    score >= 90 -> "A"
    score >= 80 -> "B"
    user.isVip && score >= 70 -> "B(VIP 보정)"
    else -> "C"
}

// 4) 대상을 그 자리에서 선언 (변수 유효 범위를 when 안으로 가둔다)
val res = when (val response = api.call()) {
    is Success -> response.body
    is Failure -> throw response.error
}

when + sealed = 컴파일러가 빠진 곳을 잡는다

sealed interface PayState
data object Ready : PayState
data class Paid(val txId: String) : PayState
data class Failed(val reason: String) : PayState

fun message(s: PayState): String = when (s) {
    Ready -> "결제 대기"
    is Paid -> "결제 완료 ${s.txId}"
    is Failed -> "실패: ${s.reason}"
    // else 가 없어도 된다 — 모든 경우를 덮었으므로
}

// 나중에 Refunded 를 추가하면?
// → 이 when 이 컴파일 에러가 난다. 고쳐야 할 자리를 컴파일러가 전부 알려 준다.
이게 sealed의 진짜 가치입니다. 상태를 추가했을 때 고쳐야 할 모든 곳이 빨간 줄로 표시됩니다 — 자바에서 default: throw로 두고 런타임에 발견하던 것과 다릅니다. (11 · enum · sealed에서 더 다룹니다)

가드 조건 2.1+

// 타입 검사 + 추가 조건을 한 가지에 담는다
fun handle(s: PayState): String = when (s) {
    is Paid if s.txId.startsWith("TEST") -> "테스트 결제"
    is Paid -> "결제 완료"
    is Failed if s.reason.contains("잔액") -> "잔액 부족 안내"
    is Failed -> "실패"
    Ready -> "대기"
}

// 2.1 이전에는 이렇게 써야 했다 (중첩 when)
// is Paid -> when {
//     s.txId.startsWith("TEST") -> "테스트 결제"
//     else -> "결제 완료"
// }

반복 — for · 범위 · 인덱스

for와 범위(Range)

for (i in 1..5) print(i)          // 12345  (5 포함)
for (i in 1..<5) print(i)         // 1234   (5 미만) — until 의 새 문법
for (i in 5 downTo 1) print(i)    // 54321
for (i in 0..10 step 2) print(i)  // 0246810

for (c in "abc") print(c)          // abc

val list = listOf("a", "b", "c")
for (s in list) println(s)                     // 값만
for ((i, s) in list.withIndex()) println("$i:$s")   // 인덱스까지
for (i in list.indices) println(list[i])       // 인덱스만

// Map 순회 — 구조 분해가 그냥 된다
val m = mapOf("a" to 1, "b" to 2)
for ((k, v) in m) println("$k=$v")

while · 라벨 · 반복 제어

var i = 0
while (i < 3) { println(i); i++ }

do { println("최소 한 번") } while (false)

// 중첩 반복에서 바깥까지 빠져나오기 — 라벨
outer@ for (i in 1..3) {
    for (j in 1..3) {
        if (i * j > 4) break@outer     // 바깥 루프 종료
        if (j == 2) continue@outer     // 바깥 루프 다음 회차
        println("$i x $j")
    }
}

// forEach 안에서 '이번 항목만 건너뛰기'
listOf(1, 2, 3).forEach {
    if (it == 2) return@forEach        // continue 에 해당
    println(it)
}
forEach 안의 return은 함수 전체를 종료합니다(비지역 반환). return@forEach로 라벨을 붙여야 “이번 항목만 건너뛰기”가 됩니다 — 코틀린 입문자가 가장 자주 놀라는 자리입니다.
실무 감각 — 코틀린에서 for를 쓰는 일이 생각보다 적습니다. 대부분 map·filter·groupBy로 표현되기 때문입니다 (14 · 컬렉션 연산). for는 부수 효과가 목적일 때 (저장·전송·출력) 남깁니다.
KOTLIN 06

함수 — 기본값 · 이름붙인 인자

자바에서 오버로딩 다섯 개를 만들던 자리가 코틀린에서는 함수 하나입니다. 호출부에서 인자에 이름을 붙일 수 있어서 읽기도 편해집니다.

// 기본 형태
fun add(a: Int, b: Int): Int {
    return a + b
}

// 단일 표현식 함수 — 몸통이 한 줄이면 = 로
fun add2(a: Int, b: Int): Int = a + b

// 반환 타입도 추론된다 (public API 에서는 적는 편이 낫다)
fun add3(a: Int, b: Int) = a + b

// 반환값이 없으면 Unit (생략 가능)
fun log(msg: String) {
    println(msg)
}

기본값 · 이름붙인 인자 — 오버로딩이 사라진다

자바 — 오버로딩 폭발
void send(String to) {
    send(to, "일반", false, 3);
}
void send(String to, String type) {
    send(to, type, false, 3);
}
void send(String to, String type, boolean urgent) {
    send(to, type, urgent, 3);
}
void send(String to, String type,
          boolean urgent, int retry) { /* 본체 */ }

// 호출부: 이게 무슨 뜻인지 알 수 없다
send("a@b.com", "알림", true, 5);
코틀린 — 함수 하나
fun send(
    to: String,
    type: String = "일반",
    urgent: Boolean = false,
    retry: Int = 3,
) { /* 본체 */ }

send("a@b.com")
send("a@b.com", "알림")
// 이름을 붙이면 순서도 자유롭고 뜻이 드러난다
send("a@b.com", urgent = true, retry = 5)
send(to = "a@b.com", type = "알림")
실무 규칙 — Boolean 인자는 반드시 이름을 붙여 부르세요. send(email, true, false)는 반년 뒤에 읽으면 아무도 모릅니다. IDE 설정에서 “불리언 인자에 이름 강제”를 켜 두는 팀도 많습니다.

vararg — 개수가 정해지지 않은 인자

fun sum(vararg nums: Int): Int = nums.sum()

sum(1, 2, 3)          // 6
sum()                 // 0

// 배열을 펼쳐서 넘기기 — 스프레드 연산자 *
val arr = intArrayOf(1, 2, 3)
sum(*arr)             // 6

// vararg 는 마지막이 아니어도 되지만,
// 뒤 인자는 반드시 '이름 붙여' 불러야 한다
fun log(vararg items: String, level: String = "INFO") { }
log("a", "b", level = "WARN")

지역 함수 · 중위 함수

// 함수 안의 함수 — 바깥 변수를 그대로 쓴다
fun process(order: Order): Result {
    fun validate(field: String, v: String?) {
        require(!v.isNullOrBlank()) { "$field 없음 (주문 ${order.id})" }
    }
    validate("수령인", order.receiver)
    validate("주소", order.address)
    return save(order)
}

// 중위 표기 — 점과 괄호를 뺄 수 있다
infix fun Int.타임즈(n: Int) = this * n
println(3 타임즈 4)     // 12

// 표준 라이브러리에도 많다
val pair = "a" to 1        // to 가 중위 함수
val has = 5 in 1..10       // in

함수 파라미터를 읽는 법 — 코틀린 시그니처

시그니처읽는 법
fun f(a: Int)Int 하나를 받는다. null 불가
fun f(a: Int?)null 이 올 수 있다 — 안에서 처리해야 한다
fun f(a: Int = 0)안 넘기면 0
fun f(vararg a: Int)0개 이상
fun f(): Nothing정상 반환하지 않는다 — 예외를 던지거나 무한 루프
fun f(block: () -> Unit)람다를 받는다 — 마지막 인자면 괄호 밖에 쓸 수 있다
suspend fun f()코루틴 안에서만 부를 수 있다 (기다릴 수 있는 함수)
fun List<T>.f()확장 함수 — 리스트에 점 찍고 부른다
자바에서 이 함수를 부를 때 — 기본값은 자바에 없는 개념이라 send("a@b.com")처럼 못 부릅니다. 자바에서도 짧게 부르게 하려면 @JvmOverloads를 붙이세요. 그러면 오버로딩 묶음을 컴파일러가 만들어 줍니다 (18 · 자바와 섞어 쓰기).
KOTLIN 07

클래스 · 프로퍼티

코틀린에는 필드가 없습니다. 프로퍼티만 있습니다 — 값 저장소와 getter/setter가 하나로 묶인 단위입니다. 그래서 자바의 다섯 줄이 한 줄이 됩니다.

자바 — 필드 + getter + setter
public class User {
    private final String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public String getName() { return name; }
    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }
}
// 13줄
코틀린 — 프로퍼티
class User(
    val name: String,     // getter 자동
    var age: Int,         // getter + setter 자동
)
// 4줄. 바이트코드는 자바와 같다
// (실제로 getName() · getAge() · setAge() 가 만들어진다)

val u = User("김현우", 34)
println(u.name)     // getName() 호출
u.age = 35          // setAge(35) 호출

생성자 — 주 생성자와 init

class Order(
    val id: Long,
    val amount: Long,
    val status: String = "NEW",     // 기본값도 된다
) {
    // init 블록 — 객체가 만들어질 때 실행 (검증 자리)
    init {
        // 초안(DRAFT)은 금액 미정(0)을 허용 — 아니면 아래 보조 생성자가 항상 예외를 던진다
        require(amount > 0 || status == "DRAFT") { "금액은 0보다 커야 합니다: $amount" }
        require(id > 0) { "잘못된 주문번호" }
    }

    // 프로퍼티 초기화도 여기서 (init 과 프로퍼티는 적힌 순서대로 실행)
    val createdAt: Long = System.currentTimeMillis()

    // 보조 생성자 — 주 생성자를 반드시 호출해야 한다 (주 생성자의 init 검증도 그대로 거친다)
    constructor(id: Long) : this(id, 0L, "DRAFT")   // Order(10) → 금액 미정 초안 주문
}

// 생성자에 val/var 를 안 붙이면? — 인자로만 쓰이고 프로퍼티가 되지 않는다
class Temp(name: String) {
    val upper = name.uppercase()   // 여기서만 쓸 수 있다
    // println(name)  ← 다른 메서드에서는 못 쓴다
}

커스텀 getter/setter

class Rect(val w: Int, val h: Int) {
    // 계산 프로퍼티 — 저장 공간이 없다 (호출할 때마다 계산)
    val area: Int
        get() = w * h

    val isSquare: Boolean get() = w == h
}

class User(name: String) {
    // 뒷받침 필드 field 를 쓰는 커스텀 접근자
    var name: String = name
        get() = field.trim()
        set(value) {
            require(value.isNotBlank()) { "이름은 비울 수 없습니다" }
            field = value.trim()      // field = 뒷받침 필드
        }
}
field는 접근자 안에서만 보이는 뒷받침 필드입니다. set에서 this.name = value라고 쓰면 무한 재귀가 되므로 반드시 field를 씁니다.

가시성 — 기본이 public

키워드범위
public기본값 — 안 적으면 이것
internal같은 모듈 안에서만 (자바에 없는 개념 · 라이브러리에 유용)
protected이 클래스와 자식만 (같은 패키지는 안 됨 — 자바와 다름)
private이 파일 또는 이 클래스 안에서만
class Account(
    val id: Long,
    private var balance: Long,     // 밖에서 못 본다
) {
    // 읽기는 공개, 쓰기는 내부만
    var status: String = "ACTIVE"
        private set
}
var x: T private set 패턴을 기억해 두세요 — “읽는 건 누구나, 바꾸는 건 이 클래스만”을 한 줄로 표현합니다. 자바에서 getter만 만들고 setter를 뺀 것과 같은 효과인데 훨씬 짧습니다.
KOTLIN 08

data class — 값을 담는 클래스

실무 코드의 절반은 데이터를 담아 옮기는 클래스입니다. data 한 단어면 equals·hashCode·toString·copy·구조 분해가 전부 만들어집니다.

data class 한 줄이 컴파일 때 만들어 내는 것 — 자바로 치면 60줄 data class User( val id: Long, val name: String) 주 생성자 · 프로퍼티 getter 자동 · val 이면 setter 는 아예 없다 equals() · hashCode() "같다"의 기준이 참조가 아니라 값 — Set · Map 키로 바로 쓴다 toString() User(id=1, name=홍길동) — 로그가 읽히기 시작한다 copy(...) 한 필드만 바꾼 새 객체 — 불변 객체를 다루는 유일한 방법 component1() ... componentN() 구조 분해 val (id, name) = user 가 여기서 나온다
주 생성자에 선언한 프로퍼티만 다섯 가지에 반영됩니다 — 본문에 쓴 프로퍼티는 equals 에서 빠집니다.
자바 — 롬복이 없으면 60줄
public class Point {
    private final int x, y;
    // 생성자 · getter ×2
    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point p = (Point) o;
        return x == p.x && y == p.y;
    }
    @Override public int hashCode() {
        return Objects.hash(x, y);
    }
    @Override public String toString() {
        return "Point(x=" + x + ", y=" + y + ")";
    }
}
코틀린 — 한 줄
data class Point(val x: Int, val y: Int)

val a = Point(1, 2)
val b = Point(1, 2)

println(a == b)        // true   (equals 자동)
println(a)             // Point(x=1, y=2)  (toString 자동)
println(setOf(a, b))   // [Point(x=1, y=2)]  (hashCode 자동)

// copy — 일부만 바꾼 새 객체
val c = a.copy(y = 99)  // Point(x=1, y=99)

// 구조 분해
val (x, y) = a
println("$x, $y")       // 1, 2

무엇이 자동으로 만들어지나

멤버기준
equals / hashCode주 생성자의 프로퍼티만 사용
toStringPoint(x=1, y=2) 형태
copy(...)모든 프로퍼티가 기본값 = 현재 값
component1(), 2()...구조 분해용 (선언 순서대로)
data class User(val id: Long, val name: String) {
    var lastLogin: Long = 0     // 본문에 선언 → equals 에 안 들어간다!
}

val u1 = User(1, "김").also { it.lastLogin = 100 }
val u2 = User(1, "김").also { it.lastLogin = 999 }
println(u1 == u2)   // true — lastLogin 은 비교 대상이 아니다

실무에서 쓰는 자리

// 1) API 요청·응답 DTO
data class OrderRequest(
    val productId: Long,
    val quantity: Int = 1,
    val couponCode: String? = null,
)
data class OrderResponse(val id: Long, val total: Long)

// 2) 값 객체 (Value Object)
data class Money(val amount: Long, val currency: String = "KRW") {
    operator fun plus(o: Money): Money {
        require(currency == o.currency) { "통화가 다릅니다" }
        return copy(amount = amount + o.amount)
    }
}
println(Money(1000) + Money(500))   // Money(amount=1500, currency=KRW)

// 3) 함수에서 값 두 개를 돌려줄 때
data class Range(val min: Int, val max: Int)
fun stats(xs: List<Int>) = Range(xs.min(), xs.max())
val (lo, hi) = stats(listOf(3, 1, 4))

copy의 함정 — 얕은 복사입니다

data class Cart(val id: Long, val items: MutableList<String>)

val a = Cart(1, mutableListOf("사과"))
val b = a.copy(id = 2)

b.items.add("바나나")
println(a.items)     // [사과, 바나나]  ← a 까지 바뀌었다!

// 해결 1) 애초에 읽기 전용 컬렉션을 쓴다 (권장)
data class Cart2(val id: Long, val items: List<String>)
val c = Cart2(1, listOf("사과"))
val d = c.copy(items = c.items + "바나나")   // 새 리스트

// 해결 2) copy 할 때 직접 새 컬렉션을 만든다
val e = a.copy(id = 2, items = a.items.toMutableList())
data class 안에 가변 컬렉션(MutableList)을 두지 마세요. copy가 참조만 복사하고, hashCode도 내용이 바뀌면 함께 바뀌어 Set·Map 안에서 객체가 사라지는 고전적 버그가 납니다.

언제 data class 를 쓰지 않나

  • 식별자로 같음을 판단하는 엔티티 — JPA @Entity는 data class로 만들지 마세요. 모든 필드로 equals 를 만들면 지연 로딩 프록시와 충돌하고, id 하나로 비교해야 맞습니다
  • 동작이 중심인 클래스 — 서비스·컨트롤러는 상태가 없으니 해당 없음
  • 상속이 필요할 때 — data class 는 open이 될 수 없습니다
JPA 엔티티는 일반 class + var + id 기반 equals가 정석입니다. 데이터 · JPA 탭에서 자세히 다룹니다.

data object · value class

// data object — 상태 없는 싱글턴 (toString 이 예쁘게 나온다)
sealed interface Result
data object Loading : Result              // "Loading"
data class Success(val body: String) : Result

// value class — 타입만 분리하고 런타임 비용은 0
@JvmInline
value class UserId(val value: Long)
@JvmInline
value class OrderId(val value: Long)

fun find(id: UserId) { }
// find(OrderId(1))   // ❌ 컴파일 에러 — 뒤바뀐 인자를 문법이 막는다
find(UserId(1))       // ✅  바이트코드에서는 그냥 long 으로 처리된다

value class는 Long 두 개를 뒤바꿔 넣는 사고를 컴파일 단계에서 막습니다. (확장 함수와 함께 코틀린에서 가장 저평가된 기능)

KOTLIN 09

상속 · 인터페이스

코틀린 클래스는 기본이 final입니다. 상속을 허용하려면 open을 직접 붙여야 합니다 — “상속은 설계해서 열거나, 막아야 한다”는 이펙티브 자바의 조언을 문법으로 굳힌 것입니다.

open · override — 둘 다 명시

open class Animal(val name: String) {       // open 없으면 상속 불가
    open fun speak() = "..."                 // open 없으면 재정의 불가
    fun sleep() = "zzz"                      // 재정의 못 함 (final)
}

open class Dog(name: String) : Animal(name) {   // : 뒤에 부모 생성자 호출 (아래 Puppy 가 상속하므로 open)
    override fun speak() = "멍멍"            // override 필수 (자바의 @Override 는 선택)
}

// override 한 것을 더 못 막으려면
open class Puppy(name: String) : Dog(name) {
    final override fun speak() = "왈"        // 여기서 봉인
}

println(Dog("바둑이").speak())   // 멍멍
왜 기본이 final 인가 — 라이브러리 클래스를 남이 마음대로 상속해 버리면 내부 구현을 바꿀 수 없게 됩니다(취약한 기반 클래스 문제). 열어 둔 지점만 계약이 되도록 뒤집은 것입니다.

추상 클래스 · 인터페이스

abstract class Repo<T> {                // abstract 는 open 이 기본
    abstract fun findById(id: Long): T?
    fun findOrThrow(id: Long): T =        // 공통 구현
        findById(id) ?: error("없음: $id")
}

interface Payable {
    val fee: Long                          // 인터페이스도 프로퍼티를 가진다
    fun pay(amount: Long): Boolean         // 추상
    fun cancel(): Boolean = false          // 기본 구현 (자바 default)
}

class Card(override val fee: Long) : Payable {
    override fun pay(amount: Long) = true
}

// 여러 인터페이스 + 클래스 하나
class Vip(name: String) : Animal(name), Payable {
    override val fee = 0L
    override fun pay(amount: Long) = true
    override fun speak() = "VIP"
}

인터페이스 프로퍼티 · 다이아몬드 해결

interface A { fun hello() = "A" }
interface B { fun hello() = "B" }

class C : A, B {
    // 두 기본 구현이 충돌하면 반드시 재정의해야 한다
    override fun hello() = super<A>.hello() + super<B>.hello()   // "AB"
}

// 인터페이스 프로퍼티는 '뒷받침 필드'가 없다 — 구현 쪽이 저장 방법을 정한다
interface Named {
    val name: String                 // 추상 프로퍼티
    val upper: String get() = name.uppercase()   // 계산 프로퍼티는 기본 구현 가능
}

class P1(override val name: String) : Named            // 생성자 프로퍼티로
class P2 : Named { override val name get() = "고정" }  // 커스텀 getter 로

위임(by) — 상속 대신 쓰는 카드

interface Logger { fun log(m: String) }

class ConsoleLogger : Logger {
    override fun log(m: String) = println("[LOG] $m")
}

// 'by' 한 단어로 모든 메서드를 그 객체에 넘긴다 (위임)
class Service(logger: Logger) : Logger by logger {
    fun work() {
        log("작업 시작")     // ConsoleLogger 로 간다
    }
}

Service(ConsoleLogger()).work()   // [LOG] 작업 시작

// 자바였다면 인터페이스 메서드를 전부 손으로 넘겨 써야 했다
// (데코레이터 패턴의 보일러플레이트가 통째로 사라진다)

object 표현식 — 익명 구현

// 자바의 익명 클래스 자리
val listener = object : Runnable {
    override fun run() = println("실행")
}

// 인터페이스에 메서드가 하나면 (SAM) 람다로 줄여 쓴다 — 자바 인터페이스일 때
val r = Runnable { println("실행") }

// 코틀린 인터페이스를 SAM 으로 쓰려면 fun interface 로 선언
fun interface Validator {
    fun validate(s: String): Boolean
}
val notBlank = Validator { it.isNotBlank() }
println(notBlank.validate("hi"))   // true
fun interface는 “메서드 하나짜리 인터페이스”를 명시하는 선언입니다. 붙이면 람다로 만들 수 있고, 실수로 메서드를 하나 더 추가하면 컴파일 에러로 막힙니다.
실무 감각 — 코틀린에서는 상속 계층을 깊게 쌓는 일이 드뭅니다. 인터페이스 + 위임(by), sealed + when, 확장 함수가 상속으로 하던 일의 상당 부분을 대신합니다. 상속은 “정말 is-a 인가”를 한 번 더 물은 뒤 씁니다.
KOTLIN 10

object · companion

코틀린에는 static이 없습니다. 대신 object(싱글턴)와 companion object(클래스에 딸린 객체)가 그 자리를 대신합니다 — static 과 달리 인터페이스를 구현할 수 있는 진짜 객체입니다.

object — 선언과 동시에 싱글턴

object Config {
    const val TIMEOUT = 3_000L
    val hosts = listOf("a.com", "b.com")
    fun reload() { /* ... */ }
}

Config.reload()          // 인스턴스를 만들 필요 없다
println(Config.TIMEOUT)

// 스레드 안전한 지연 초기화가 보장된다
// (자바의 '초기화 홀더' 이디엄을 컴파일러가 대신 써 준다)

// 인터페이스 구현도 된다 — static 으로는 못 하는 일
object EmptyRepo : UserRepository {
    override fun findAll() = emptyList<User>()
}

companion object — static 자리

class User private constructor(val name: String) {
    companion object {
        private const val MAX = 20

        // 팩토리 메서드 — 생성자를 감춘다
        fun of(raw: String): User? =
            raw.trim().takeIf { it.length in 1..MAX }?.let(::User)

        @JvmStatic                     // 자바에서 User.create() 로 부르려면
        fun create(name: String) = User(name)
    }
}

val u = User.of("  김현우  ")     // 클래스 이름으로 바로 접근
println(u?.name)                  // 김현우
println(User.of(""))              // null
companion object는 이름을 가질 수 있고 인터페이스도 구현합니다. companion object Factory : UserFactory { } 처럼요 — static 메서드로는 절대 못 하는 일입니다.

이 셋을 어떻게 고르나

상황고르는 것
앱 전체에서 하나뿐인 설정·레지스트리object
클래스와 짝이 되는 팩토리·상수companion object
어디서나 쓰는 상수 하나파일 최상위 const val이 가장 가볍다
클래스와 관계없는 유틸 함수최상위 함수 (자바의 XxxUtils가 필요 없어진다)
상태를 가진 싱글턴 (DB 연결 등)DI 컨테이너에 맡기세요 — object는 테스트에서 갈아 끼우기 어렵습니다
object는 전역 상태입니다 — 안에 var를 두면 테스트끼리 상태가 새고, 병렬 테스트에서 무작위로 깨집니다. 스프링·Hilt를 쓰는 코드에서 “싱글턴이 필요해서” object를 쓰는 것은 대개 잘못된 선택입니다. DI 컨테이너가 관리하는 싱글턴이 정답입니다.

최상위 함수 — 유틸 클래스가 사라진다

자바 — 인스턴스화 막는 유틸 클래스
public final class StringUtils {
    private StringUtils() {}         // 생성 방지 보일러플레이트
    public static boolean isEmail(String s) {
        return s != null && s.contains("@");
    }
}
StringUtils.isEmail(input);
코틀린 — 그냥 파일에 함수
// StringExt.kt
fun isEmail(s: String?) = s?.contains("@") == true

// 또는 확장 함수로 (더 코틀린답다)
fun String.isEmail() = contains("@")

isEmail(input)
input.isEmail()

// 자바에서는 StringExtKt.isEmail(...) 로 보인다
// 이름을 바꾸려면 파일 맨 위에
// @file:JvmName("StringUtils")
KOTLIN 11

enum · sealed — 상태를 타입으로

“문자열로 상태를 표현하다 오타 하나로 장애”는 흔한 사고입니다. 코틀린은 가능한 값의 집합 자체를 타입으로 못 박고, 빠뜨린 처리를 컴파일러가 잡게 합니다.

enum — 값이 고정된 목록

enum class OrderStatus(val label: String, val isFinal: Boolean) {
    NEW("신규", false),
    PAID("결제완료", false),
    SHIPPED("배송중", false),
    DONE("완료", true),
    CANCELED("취소", true);           // 마지막에 세미콜론

    fun canCancel() = !isFinal

    companion object {
        fun from(raw: String) =
            entries.firstOrNull { it.name == raw.uppercase() }
    }
}

val s = OrderStatus.PAID
println(s.label)              // 결제완료
println(s.ordinal)            // 1  (선언 순서 — DB 에 저장하면 안 된다!)
println(OrderStatus.entries)  // 전체 목록 (1.9+ · 구 values() 대체)
println(OrderStatus.from("done"))  // DONE
ordinal을 DB에 저장하지 마세요. 중간에 상수를 하나 끼워 넣으면 저장된 값의 뜻이 통째로 밀립니다. JPA에서도 @Enumerated(EnumType.STRING)이 정답입니다.

sealed — 값마다 다른 데이터를 담을 때

// enum 은 '값 목록', sealed 는 '타입 목록'
sealed interface ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>
    data class Error(val code: Int, val message: String) : ApiResult<Nothing>
    data object Loading : ApiResult<Nothing>
}

fun render(r: ApiResult<User>): String = when (r) {
    is ApiResult.Success -> "환영합니다 ${r.data.name}"
    is ApiResult.Error -> "오류 ${r.code}: ${r.message}"
    ApiResult.Loading -> "불러오는 중…"
    // else 불필요 — 컴파일러가 전부 덮인 것을 안다
}

경우마다 들고 다니는 데이터가 다르면 sealed입니다. 성공은 데이터를, 실패는 코드와 메시지를 갖는 위 형태가 안드로이드·서버 양쪽에서 가장 많이 쓰이는 모양입니다.

enum = 값의 목록 · sealed = 타입의 목록 — 고를 때 기준은 “경우마다 데이터가 다른가” enum class OrderStatus NEW PAID DONE 전부 같은 모양 — label · isFinal 을 똑같이 갖는다 sealed interface ApiResult Success(data: T) Error(code, message) Loading 경우마다 데이터가 다르다 — 성공은 값, 실패는 코드+메시지 공통점 — when 에서 else 가 필요 없다 경우를 추가하면 컴파일 에러로 고칠 자리를 알려 준다
경우마다 데이터가 같으면 enum, 다르면 sealed — 이 한 줄이 선택 기준입니다.

sealed class vs sealed interface

차이와 고르는 법

구분내용
sealed class공통 상태(프로퍼티)를 갖게 할 수 있다. 자식은 하나의 부모만
sealed interface여러 개를 함께 구현할 수 있다. 기본은 이쪽 1.5+
허용 범위같은 패키지 + 같은 모듈 안에서만 상속 가능
자바에서Java 17의 sealed와 개념이 같습니다
sealed class Shape(val name: String) {     // 공통 상태
    class Circle(val r: Double) : Shape("원")
    class Rect(val w: Double, val h: Double) : Shape("사각형")
}

fun area(s: Shape): Double = when (s) {
    is Shape.Circle -> Math.PI * s.r * s.r
    is Shape.Rect -> s.w * s.h
}

실무 패턴 — 도메인 에러를 타입으로

sealed interface OrderError {
    data class OutOfStock(val productId: Long, val left: Int) : OrderError
    data class PaymentFailed(val pgCode: String) : OrderError
    data object CouponExpired : OrderError
}

// 예외 대신 값으로 돌려주면 호출부가 놓칠 수 없다
fun order(req: OrderRequest): Result<Long> = TODO()

// 컨트롤러에서 상태 코드로 정확히 매핑
fun toHttp(e: OrderError) = when (e) {
    is OrderError.OutOfStock -> 409 to "재고 ${e.left}개"
    is OrderError.PaymentFailed -> 402 to "결제 실패 ${e.pgCode}"
    OrderError.CouponExpired -> 400 to "쿠폰 만료"
}

새 에러를 추가하면 toHttp 가 컴파일 에러가 됩니다 — “새 에러 코드를 매핑 안 해서 500이 나가는” 사고가 구조적으로 막힙니다.

KOTLIN 12

확장 함수 — 코틀린다움의 시작

남이 만든 클래스에 내 함수를 붙여 점 찍고 부를 수 있습니다. StringUtils.isEmail(s)가 s.isEmail()이 되는 순간, 코드를 읽는 방향이 “무엇에 대해 무엇을 한다”로 정렬됩니다.

확장 함수는 클래스를 고치지 않는다 — 컴파일하면 정적 메서드다 fun String.toSlug() 내 파일에 선언 K2 컴파일러 수신 객체 → 첫 인자 SlugKt.toSlug(String) static 메서드 하나가 생긴다 그래서 따라오는 규칙 세 가지 ① 멤버가 이긴다 이름이 같으면 원래 클래스 쪽 ② 선언 타입 기준 Any 로 받으면 Any 용이 불린다 ③ private 은 못 본다 밖에서 붙인 함수이기 때문 그래서 "다형성이 되겠지"라고 기대하면 틀립니다 — 확장 함수는 오버라이드되지 않습니다.
확장 함수는 호출부에서 정적으로 결정됩니다 — 편해 보여도 상속과는 아무 관계가 없습니다.
// String 에 함수를 하나 붙인다 — 소스를 고치지 않고
fun String.toSlug(): String =
    trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')

println("Hello Kotlin World!".toSlug())   // hello-kotlin-world

// 안에서 this 는 수신 객체 (생략 가능)
fun String.initial(): Char = this[0]

// null 가능 타입에도 붙일 수 있다 — 이게 강력하다
fun String?.orDefault(d: String = "-"): String =
    if (this.isNullOrBlank()) d else this

val name: String? = null
println(name.orDefault())     // -   (null 에 점을 찍었는데 터지지 않는다)

// 확장 프로퍼티
val String.isPhone: Boolean
    get() = Regex("""01\d-\d{3,4}-\d{4}""").matches(this)
println("010-1234-5678".isPhone)   // true

동작 원리 — 실은 static 함수다

// 코틀린
fun String.toSlug(): String = ...

// 컴파일하면 (자바에서 본 모습)
// public static String toSlug(String $this) { ... }

// 그래서 이런 성질이 나온다
// 1) private 멤버에는 접근 못 한다 (밖에서 붙인 함수니까)
// 2) 오버라이드되지 않는다 — '정적 디스패치'
open class A
class B : A()
fun A.who() = "A"
fun B.who() = "B"

val x: A = B()
println(x.who())     // "A" — 변수의 '선언 타입'으로 결정된다!
확장 함수는 다형성이 없습니다. 위 예에서 실제 객체가 B여도 선언 타입이 A라서 A.who()가 불립니다. 동작이 타입마다 달라져야 하면 멤버 함수나 sealed+when을 쓰세요.

멤버 함수가 이깁니다

class C {
    fun hello() = "멤버"
}
fun C.hello() = "확장"      // 컴파일은 되지만…

println(C().hello())        // "멤버" — 멤버가 항상 우선

// 라이브러리가 나중에 같은 이름의 멤버를 추가하면
// 내 확장 함수가 조용히 무시된다 → 이름을 구체적으로 짓자

스코프를 좁히는 확장

class OrderService(private val repo: OrderRepo) {
    // 이 클래스 안에서만 보이는 확장 (멤버 확장)
    private fun Order.toDto() = OrderDto(id, amount, status)

    fun find(id: Long): OrderDto? = repo.find(id)?.toDto()
}
// 밖에서는 Order.toDto() 가 보이지 않는다 — 이름 공간이 안 더러워진다

실무에서 확장 함수가 빛나는 자리

1. 도메인 언어 만들기

fun Long.won() = "%,d원".format(this)
fun Int.percent() = "$this%"

println(12900L.won())    // 12,900원
println(15.percent())    // 15%

fun LocalDate.isWeekend() =
    dayOfWeek in setOf(SATURDAY, SUNDAY)

2. 라이브러리 다듬기

// 자바 라이브러리를 코틀린답게
fun ResultSet.getLongOrNull(c: String): Long? =
    getLong(c).takeIf { !wasNull() }

// 스프링 응답을 짧게
fun <T> T.ok() = ResponseEntity.ok(this)

@GetMapping("/users/{id}")
fun get(@PathVariable id: Long) =
    service.find(id).ok()

3. 테스트 가독성

fun User.assertActive() {
    assertTrue(status == "ACTIVE",
        "비활성 사용자: $id")
}

// 테스트에서
user.assertActive()

// 픽스처 만들기
fun user(id: Long = 1, name: String = "테스트") =
    User(id, name)
과용 주의 — 확장 함수는 어디에 정의됐는지 찾기 어려워질 수 있습니다. 기준 하나: “이 타입을 쓰는 모든 곳에서 의미가 통하면 최상위, 특정 계층에서만 의미가 있으면 그 안에”. User.toDto()는 프레젠테이션 계층 안에, Long.won()은 공용 파일에.
KOTLIN 13

컬렉션 완전정복

코틀린 컬렉션의 첫 관문은 읽기 전용과 가변이 타입으로 갈린다는 점입니다. List에는 add가 아예 없습니다 — 런타임 예외가 아니라 자동완성에 뜨지 않습니다.

읽기 전용이 부모, 가변이 자식 — 가변을 읽기 전용 자리에 넣을 수 있다 List<T> (읽기 전용) Set<T> Map<K, V> size · get · contains · iterator — add / remove 가 없다 MutableList<T> MutableSet<T> MutableMap<K, V> add · remove · clear 가 여기서 생긴다 실행 시점 구현체는 자바와 같다 — java.util.ArrayList · LinkedHashSet · LinkedHashMap
타입만 다르고 런타임 객체는 자바 컬렉션 그대로입니다 — 변환 비용이 없습니다.

만들기

// 읽기 전용 (기본으로 이걸 쓴다)
val a = listOf(1, 2, 3)
val b = setOf("x", "y")
val c = mapOf("a" to 1, "b" to 2)
val empty = emptyList<Int>()

// 가변
val m1 = mutableListOf(1, 2)
val m2 = mutableSetOf<String>()
val m3 = mutableMapOf<String, Int>()

// 조건이 섞인 생성 — buildList (읽기 전용으로 나온다)
val ids = buildList {
    add(1)
    if (includeAdmin) add(0)
    addAll(userIds)
}

// null 을 걸러 내며 만들기
val names = listOfNotNull(a1?.name, a2?.name)

꺼내기 — 안전한 접근

val list = listOf("a", "b", "c")

list[0]                  // "a"  (없으면 예외)
list.getOrNull(9)        // null
list.getOrElse(9) { "?" }// "?"

list.first()             // "a"  (비었으면 예외)
list.firstOrNull()       // null 안전
list.last()
list.single()            // 딱 하나일 때만 (아니면 예외 — 검증에 좋다)

val map = mapOf("a" to 1)
map["a"]                 // 1     (Int? 반환)
map["z"]                 // null
map.getValue("z")        // 💥 NoSuchElementException (원인이 명확)
map.getOrDefault("z", 0) // 0

// 없으면 만들어 넣기 — 캐시 패턴
val cache = mutableMapOf<Long, User>()
val u = cache.getOrPut(id) { repo.find(id) }

“읽기 전용”은 “불변”이 아닙니다

val mutable = mutableListOf(1, 2, 3)
val view: List<Int> = mutable      // 읽기 전용 '뷰'로 넘긴다

mutable.add(4)
println(view)      // [1, 2, 3, 4]  ← 뷰도 함께 바뀐다!

// 정말 못 바꾸게 하려면 복사본을 준다
val snapshot: List<Int> = mutable.toList()
mutable.add(5)
println(snapshot)  // [1, 2, 3, 4]  ← 그대로

// 그리고 자바 코드에서는 읽기 전용 타입도 캐스팅으로 뚫린다
// (List<T> 는 실행 시점에 java.util.List 이므로)
API 경계에서는 toList()로 복사해 돌려주세요. 내부 MutableList를 그대로 반환하면 호출자가 내 상태를 고칠 수 있습니다. 반대로 성능이 중요한 내부 루프에서는 복사가 낭비이니, 경계에서만 복사합니다.

배열은 따로 있습니다

val arr = arrayOf(1, 2, 3)          // Array<Int> — 박싱된다
val ints = intArrayOf(1, 2, 3)      // IntArray — 자바의 int[] (박싱 없음)
val zeros = IntArray(5)             // [0,0,0,0,0]
val squares = IntArray(5) { it * it }  // [0,1,4,9,16]

arr.size; arr[0]; arr[0] = 9

// 언제 배열을 쓰나
// · 자바 API 가 배열을 요구할 때 (vararg 포함)
// · 수치 계산에서 박싱을 피해야 할 때 (IntArray · DoubleArray)
// 그 외에는 List 를 쓰세요 — 배열은 == 비교도 다릅니다
println(arrayOf(1) == arrayOf(1))            // false (참조 비교)
println(arrayOf(1).contentEquals(arrayOf(1)))// true

어떤 컬렉션을 고르나

상황고르는 것
순서대로 담고 훑는다List
중복 제거 · 포함 검사Set — contains가 O(1)
키로 찾는다Map
삽입 순서 유지기본 Set·Map이 이미 LinkedHash 계열
정렬 상태 유지sortedSetOf · sortedMapOf
여러 스레드가 함께자바 것을 그대로 — ConcurrentHashMap
리스트 안에서 contains를 반복하고 있다면 그건 Set으로 바꿔야 하는 신호입니다 — 10만 건에서 O(n²)가 O(n)이 됩니다.
KOTLIN 14

컬렉션 연산 — Stream이 필요 없다

자바에서 .stream()…collect(Collectors.toList())로 감싸던 것이 코틀린에서는 리스트에 바로 붙습니다. 표준 라이브러리 함수 이름만 익히면 반복문을 쓸 일이 크게 줍니다.

자바 Stream
List<String> names = users.stream()
    .filter(u -> u.getAge() >= 20)
    .map(User::getName)
    .sorted()
    .collect(Collectors.toList());

Map<String, List<User>> byCity = users.stream()
    .collect(Collectors.groupingBy(User::getCity));

int total = orders.stream()
    .mapToInt(Order::getAmount).sum();
코틀린
val names = users
    .filter { it.age >= 20 }
    .map { it.name }
    .sorted()

val byCity = users.groupBy { it.city }

val total = orders.sumOf { it.amount }

stream()도 collect()도 없습니다. 대신 중간 결과 리스트가 매번 만들어집니다(즉시 평가) — 수십만 건이면 asSequence()를 씁니다(아래).

자주 쓰는 것부터 — 실전 순서

data class Order(val id: Long, val userId: Long, val city: String,
                 val amount: Long, val status: String)

val orders = listOf(
    Order(1, 100, "서울", 12_000, "PAID"),
    Order(2, 100, "서울", 30_000, "PAID"),
    Order(3, 200, "부산", 8_000, "CANCELED"),
    Order(4, 300, "서울", 55_000, "PAID"),
)

// 1) 거르고 바꾸기
orders.filter { it.status == "PAID" }.map { it.amount }        // [12000, 30000, 55000]
orders.filterNot { it.status == "PAID" }                       // 취소 건만
orders.mapNotNull { it.takeIf { o -> o.amount > 10_000 } }     // 조건부 변환 + null 제거

// 2) 묶기 — 실무에서 가장 많이 쓴다
orders.groupBy { it.city }                     // Map<String, List<Order>>
orders.groupBy({ it.city }, { it.amount })     // Map<String, List<Long>> 값도 변환
orders.groupingBy { it.city }.eachCount()      // Map<String, Int> 도시별 건수

// 3) 키로 뒤집기
orders.associateBy { it.id }                   // Map<Long, Order>  ← id 로 찾기 좋게
orders.associate { it.id to it.amount }        // Map<Long, Long>
orders.associateWith { it.amount * 2 }         // Map<Order, Long>

// 4) 집계
orders.sumOf { it.amount }                     // 105000
orders.count { it.status == "PAID" }           // 3
orders.maxByOrNull { it.amount }               // 가장 큰 주문
orders.minOfOrNull { it.amount }               // 8000
orders.map { it.amount }.average()             // 26250.0 (Double)
orders.any { it.amount > 50_000 }              // true
orders.all { it.amount > 0 }                   // true
orders.none { it.city == "대구" }              // true

// 5) 쪼개기 · 나누기
orders.partition { it.status == "PAID" }       // Pair(성공목록, 나머지)
orders.chunked(2)                              // 2개씩 잘라 리스트의 리스트
orders.windowed(2)                             // 연속 2개씩 슬라이딩

// 6) 정렬
orders.sortedBy { it.amount }
orders.sortedByDescending { it.amount }
orders.sortedWith(compareBy({ it.city }, { -it.amount }))   // 다중 정렬

// 7) 펼치기 · 중복 제거 · 짝짓기
listOf(listOf(1,2), listOf(3)).flatten()       // [1,2,3]
users.flatMap { it.roles }.distinct()
listOf(1,2,3).zip(listOf("a","b","c"))         // [(1,a),(2,b),(3,c)]

// 8) 접기 (fold / reduce)
orders.fold(0L) { acc, o -> acc + o.amount }   // 초기값이 있는 누적
listOf(1,2,3).reduce { a, b -> a * b }         // 6 (빈 리스트면 예외)

자바 Stream ↔ 코틀린 대응표

자바코틀린
filter(...)filter { }
map(...)map { }
flatMap(...)flatMap { }
collect(groupingBy(...))groupBy { }
collect(toMap(k, v))associate { k to v }
collect(partitioningBy)partition { }
mapToInt(...).sum()sumOf { }
max(comparing(...))maxByOrNull { }
anyMatch / allMatchany { } / all { }
distinct()distinct() · distinctBy { }
limit(n) / skip(n)take(n) / drop(n)
stream() 지연 평가asSequence()

Sequence — 큰 데이터의 지연 평가

// 리스트 연산은 '즉시' — 단계마다 새 리스트가 만들어진다
val r1 = (1..1_000_000).toList()
    .map { it * 2 }        // 100만짜리 리스트 생성
    .filter { it % 3 == 0 }// 또 하나 생성
    .take(5)               // 결국 5개만 쓴다

// Sequence 는 '한 원소씩 끝까지' 흘려 보낸다 (자바 Stream 과 같은 방식)
val r2 = (1..1_000_000).asSequence()
    .map { it * 2 }
    .filter { it % 3 == 0 }
    .take(5)
    .toList()              // 여기서 비로소 실행. 딱 필요한 만큼만 계산

// 기준
// · 수천 건 이하 + 단계 2~3개  → 그냥 List (더 빠르다)
// · 수십만 건 이상 · 단계가 많다 · 앞에서 끊는다(take/first) → Sequence
중간 컬렉션이 생기는 게 항상 나쁜 건 아닙니다 — 데이터가 작으면 Sequence의 객체 생성 오버헤드가 더 큽니다. 측정 없이 전부 asSequence로 바꾸는 것은 흔한 잘못된 최적화입니다.

실전 — 주문 데이터로 리포트 만들기

data class CityReport(val city: String, val count: Int, val total: Long, val top: Order)

fun report(orders: List<Order>): List<CityReport> =
    orders.asSequence()
        .filter { it.status == "PAID" }
        .groupBy { it.city }
        .map { (city, list) ->
            CityReport(
                city = city,
                count = list.size,
                total = list.sumOf { it.amount },
                top = list.maxBy { it.amount },
            )
        }
        .sortedByDescending { it.total }

report(orders).forEach {
    println("${it.city}: ${it.count}건 / ${"%,d".format(it.total)}원 / 최고 ${it.top.id}")
}
// 서울: 3건 / 97,000원 / 최고 4
읽는 순서를 위에서 아래로 유지하세요. 체인이 다섯 단계를 넘어가면 중간에 val paid = orders.filter { ... }처럼 이름을 붙여 끊는 편이 디버깅과 리뷰 모두에 낫습니다. 짧은 게 목표가 아니라 읽히는 게 목표입니다.
KOTLIN 15

람다 · 고차 함수

함수를 값처럼 주고받는 문법입니다. 코틀린에서는 함수가 일급 시민이라 타입에도 쓰고((Int) -> String), 변수에 담고, 인자로 넘깁니다.

// 람다의 기본 형태 — 중괄호 { 인자 -> 몸통 }
val double: (Int) -> Int = { x -> x * 2 }
println(double(5))       // 10

// 인자가 하나면 이름을 생략하고 it 으로
val triple: (Int) -> Int = { it * 3 }

// 인자가 없으면
val hello: () -> Unit = { println("hi") }

// 여러 인자
val add: (Int, Int) -> Int = { a, b -> a + b }

// 마지막 줄이 반환값 (return 을 쓰지 않는다)
val grade: (Int) -> String = { score ->
    val g = if (score >= 90) "A" else "B"
    "등급: $g"        // ← 이 줄이 반환값
}

후행 람다 — 괄호 밖으로 뺀다

fun repeatN(n: Int, action: (Int) -> Unit) {
    for (i in 0 until n) action(i)
}

// 정석
repeatN(3, { i -> println(i) })

// 마지막 인자가 람다면 괄호 밖으로
repeatN(3) { i -> println(i) }

// 인자가 람다 하나뿐이면 괄호도 생략
val nums = listOf(1, 2, 3)
nums.forEach { println(it) }

// 이 관례 덕분에 '내장 문법처럼' 보이는 API 가 나온다
val html = buildString {
    append("<ul>")
    nums.forEach { append("<li>$it</li>") }
    append("</ul>")
}

함수 참조 · 클로저

fun isEven(n: Int) = n % 2 == 0

listOf(1,2,3).filter { isEven(it) }
listOf(1,2,3).filter(::isEven)          // 함수 참조 — 더 짧다

// 생성자 참조
val users = names.map(::User)

// 멤버 참조
val lengths = words.map(String::length)
val upper = words.map(String::uppercase)

// 클로저 — 바깥 변수를 붙잡는다
var count = 0
listOf(1,2,3).forEach { count += it }   // ✅ 코틀린은 var 도 캡처 가능
println(count)   // 6
// (자바는 effectively final 만 가능 → 배열 트릭을 써야 했다)

고차 함수 만들기 — 반복되는 뼈대를 함수로

// 걸린 시간을 재는 공통 코드
inline fun <T> measured(name: String, block: () -> T): T {
    val start = System.nanoTime()
    try {
        return block()
    } finally {
        val ms = (System.nanoTime() - start) / 1_000_000
        println("[$name] ${ms}ms")
    }
}

val users = measured("사용자 조회") { repo.findAll() }

// 재시도 뼈대
inline fun <T> retry(times: Int = 3, block: (Int) -> T): T {
    var last: Throwable? = null
    repeat(times) { i ->
        try { return block(i) } catch (e: Exception) { last = e }
    }
    throw last!!
}

val body = retry(3) { attempt ->
    println("${attempt + 1}번째 시도")
    httpClient.get("/api/users")
}
inline을 붙인 이유 — 람다를 인자로 받으면 보통 함수 객체가 하나 생깁니다. inline은 컴파일 시점에 람다 본문을 호출 자리에 펼쳐 넣어 그 비용을 없앱니다. 표준 라이브러리의 forEach·let·filter가 전부 inline 입니다. (자세한 동작과 crossinline·reified는 Kotlin 고급 탭에서)

함수 타입을 읽는 법

타입뜻
() -> Unit인자 없음, 반환 없음 (콜백)
(Int) -> StringInt 받아 String 반환
(Int, Int) -> Int두 개 받아 하나 반환
((Int) -> Int)?람다 자체가 null 일 수 있음
String.() -> Unit수신 객체가 있는 람다 — 안에서 this 가 String
suspend () -> T코루틴에서만 부를 수 있는 람다

수신 객체가 있는 람다 — DSL의 기반

// String.() -> Unit  : 안에서 this 가 String 이 된다
fun buildMsg(block: StringBuilder.() -> Unit): String =
    StringBuilder().apply(block).toString()

val msg = buildMsg {
    append("안녕")       // this.append — this 생략
    append(" 코틀린")
}

// 이 문법 하나로 아래 같은 '설정 블록'이 만들어진다
// (Gradle KTS · Compose · Ktor 라우팅이 전부 이 원리)
data class Server(var host: String = "", var port: Int = 0)
fun server(block: Server.() -> Unit) = Server().apply(block)

val s = server {
    host = "0.0.0.0"
    port = 8080
}
람다 안의 return에 주의하세요. inline 함수(예: forEach) 안에서 return은 바깥 함수를 종료합니다(비지역 반환). 람다만 빠져나오려면 return@forEach처럼 라벨을 붙입니다. 이 성질은 유용하기도 합니다 — list.forEach { if (bad) return false }처럼 조기 종료를 자연스럽게 쓸 수 있으니까요.
KOTLIN 16

스코프 함수 6종

let · run · with · apply · also — 하는 일이 비슷해 보여 헷갈리지만, 실은 두 가지 축으로 완전히 갈립니다: 안에서 뭐라 부르나(it / this)와 무엇을 돌려주나(자기 자신 / 람다 결과).

두 축으로 외우면 끝 — 세로: 뭐라 부르나 · 가로: 무엇이 나오나 반환 = 람다의 결과 반환 = 수신 객체 자신 it 으로 부름 (이름 바꿀 수 있음) this 로 부름 (생략 가능) let null 검사 · 값 변환 — 결과를 받는다 also 로그 · 검증 후 그대로 흘려보냄 run · with 멤버를 여러 번 써서 값을 계산 apply 객체를 설정하고 그 객체를 돌려받음 takeIf / takeUnless 는 별개 — 조건에 맞으면 자신, 아니면 null
“결과가 필요한가, 객체가 필요한가”를 먼저 정하면 왼쪽·오른쪽이 정해집니다.

let — null 검사와 변환

val name: String? = getName()

// null 이 아닐 때만 실행
name?.let { println("이름: $it") }

// 변환 결과를 받는다
val len: Int = name?.let { it.length } ?: 0

// 이름을 붙이면 중첩이 읽기 쉬워진다
user?.let { u ->
    order?.let { o ->
        send(u.email, o.id)
    }
}

// 임시 변수를 없앤다
val ids = repo.findAll()
    .map { it.id }
    .let { idsToCsv(it) }

apply — 만들고 설정해서 돌려받기

// 자바 빌더가 없어도 되는 자리
val props = Properties().apply {
    setProperty("user", "admin")
    setProperty("timeout", "3000")
}

val intent = Intent(this, MainActivity::class.java).apply {
    putExtra("id", 1)
    flags = Intent.FLAG_ACTIVITY_NEW_TASK
}

// 반환값이 '그 객체'라서 바로 대입된다
val user = User().apply {
    name = "김현우"
    age = 34
}

run · with — 값을 계산해 돌려받기

// run: 객체.run { } — 안에서 this 생략, 마지막 줄이 결과
val summary = user.run {
    "$name($age) / $email"      // this. 없이 접근
}

// with: with(객체) { } — 같은 일, 문법만 다르다
val summary2 = with(user) {
    "$name($age) / $email"
}

// run 은 수신 객체 없이도 쓴다 — 지역 스코프 만들기
val config = run {
    val raw = loadFile()
    parse(raw)                  // 이 값이 config
}
// raw 는 밖으로 새지 않는다
run과 with의 차이는 문법뿐입니다. run은 ?.과 함께 쓸 수 있어(null 안전) 실무에서는 run을 더 자주 씁니다.

also · takeIf — 흘려보내며 곁다리 작업

// also: 값은 그대로, 중간에 로그·검증만 끼워 넣는다
val saved = repo.save(order)
    .also { log.info("주문 저장됨 id={}", it.id) }
    .also { metrics.increment("order.created") }

// 체인 중간 디버깅에 특히 좋다
val result = input
    .map { it.trim() }
    .also { println("정제 후: $it") }
    .filter { it.isNotEmpty() }

// takeIf: 조건에 맞으면 자신, 아니면 null
val valid = email.takeIf { it.contains("@") }
    ?: throw IllegalArgumentException("이메일 형식 오류")

// takeUnless: 반대
val nonBlank = input.takeUnless { it.isBlank() }

고르는 기준 — 한 문장씩

함수이럴 때 쓴다
letnull 이 아닐 때만 실행하고 결과를 받는다
run객체 멤버를 여러 번 써서 값 하나를 계산한다
withrun과 같지만 null 안전이 필요 없을 때
apply객체를 설정하고 그 객체를 그대로 쓴다
also흐름을 끊지 않고 로그·검증·부수효과만 끼운다
takeIf조건을 만족할 때만 값을 통과시키고 아니면 null
안티패턴 셋 — ① 중첩 let 3단: 읽기 어려우면 if (a == null) return으로 풀어 쓰세요. ② apply 안에서 값 계산: 반환값이 객체라 계산 결과가 버려집니다 — run을 쓰세요. ③ let 을 “그냥” 붙이기: value.let { it + 1 }은 value + 1보다 나쁩니다. 스코프 함수는 줄이는 도구가 아니라 뜻을 드러내는 도구입니다.
KOTLIN 17

예외 · Result

코틀린에는 checked exception이 없습니다. throws를 적지 않고, 컴파일러가 try-catch를 강제하지도 않습니다. 대신 실패를 값으로 다루는 도구를 줍니다.

기본 — try 도 식이다

// 자바와 같은 모양
try {
    risky()
} catch (e: IllegalStateException) {
    log.warn("실패", e)
} catch (e: Exception) {
    throw ServiceException("처리 실패", e)
} finally {
    cleanup()
}

// 다만 try 가 '값'이다
val n: Int = try {
    input.toInt()
} catch (e: NumberFormatException) {
    0
}

// 여러 예외를 한 번에 잡으려면 (자바의 | 문법이 없다)
try { f() } catch (e: Exception) {
    when (e) {
        is IOException, is TimeoutException -> retry()
        else -> throw e
    }
}

왜 checked exception을 없앴나

자바에서 throws IOException이 강제되자 현장에서 가장 흔해진 코드는 빈 catch 블록이었습니다. 컴파일러를 통과시키려고 예외를 삼켜 버린 것이죠 — 원래 의도와 정반대의 결과입니다.

// 코틀린은 강제하지 않는다. 대신 '지금 여기서 처리할 수 있나'만 판단하면 된다
fun readConfig(path: String): Config =
    File(path).readText().let { parse(it) }
    // IOException 을 여기서 어쩔 수 없다면 그냥 위로 올라가게 둔다

// 자바에서 이 함수를 부를 때 catch 를 하게 하려면
@Throws(IOException::class)
fun readConfig2(path: String): Config = TODO()

require · check · error — 표준 검증 3형제

fun transfer(from: Account, to: Account, amount: Long) {
    // require — '인자'가 잘못됨 → IllegalArgumentException
    require(amount > 0) { "금액은 양수여야 합니다: $amount" }
    requireNotNull(from.id) { "출금 계좌 id 없음" }

    // check — '상태'가 잘못됨 → IllegalStateException
    check(from.balance >= amount) { "잔액 부족: ${from.balance}" }
    checkNotNull(session) { "세션이 없습니다" }

    // error — 도달하면 안 되는 지점 → IllegalStateException (Nothing 반환)
    val fee = when (from.grade) {
        "VIP" -> 0L
        "NORMAL" -> 500L
        else -> error("알 수 없는 등급: ${from.grade}")
    }
}

// 메시지를 람다로 받는 이유 — 검증에 통과하면 문자열을 만들지 않는다 (비용 0)

runCatching · Result — 실패를 값으로

기본 사용

val result: Result<User> = runCatching {
    api.fetchUser(id)          // 예외가 나면 Result 안에 담긴다
}

result
    .onSuccess { log.info("성공 {}", it.name) }
    .onFailure { log.warn("실패", it) }

// 값 꺼내기
val user: User? = result.getOrNull()
val u2: User = result.getOrElse { User.GUEST }
val u3: User = result.getOrDefault(User.GUEST)
val u4: User = result.getOrThrow()          // 실패면 그대로 던진다

// 이어서 변환 (실패면 건너뛴다)
val name: Result<String> = result.map { it.name }

// 실패를 복구
val safe = result.recover { e ->
    if (e is TimeoutException) User.GUEST else throw e
}

주의할 점 두 가지

① runCatching은 CancellationException까지 잡습니다. 코루틴 안에서 쓰면 취소가 먹히지 않는 버그가 됩니다. 코루틴 코드에서는 다시 던지도록 감싸 주세요.
// 코루틴 안전 버전
suspend fun <T> safeCatching(block: suspend () -> T): Result<T> =
    try { Result.success(block()) }
    catch (e: CancellationException) { throw e }   // 취소는 흘려보낸다
    catch (e: Exception) { Result.failure(e) }
② Result는 반환 타입으로 쓰기 불편합니다. (제약이 있어 fun f(): Result<T>를 막던 시절의 흔적이 남아 있고, 실패 타입이 Throwable로 고정입니다.) 도메인 실패는 sealed 클래스로 표현하는 편이 낫습니다 — 11 · sealed의 OrderError 예시가 그 형태입니다.

자원 해제 — use

자바 — try-with-resources
try (BufferedReader r =
         new BufferedReader(new FileReader(path))) {
    return r.readLine();
}
코틀린 — use (확장 함수일 뿐이다)
File(path).bufferedReader().use { it.readLine() }

// 여러 개면 중첩하거나
conn.use { c ->
    c.prepareStatement(sql).use { ps ->
        ps.executeQuery().use { rs -> /* ... */ }
    }
}

// 짧은 파일은 아예 한 줄
val text = File(path).readText()
val lines = File(path).readLines()
File(path).useLines { seq -> seq.count { it.contains("ERROR") } }

커스텀 예외

// 계층을 만들어 두면 상위에서 한 번에 잡을 수 있다
sealed class DomainException(
    message: String,
    cause: Throwable? = null,
) : RuntimeException(message, cause)

class NotFoundException(val id: Long) :
    DomainException("찾을 수 없음: $id")

class DuplicateException(val key: String) :
    DomainException("이미 존재: $key")

// 잡는 쪽
try { service.find(id) }
catch (e: DomainException) { toHttpError(e) }

실무 기준

  • 예상 가능한 실패(입력 오류·재고 없음)는 값으로 — sealed 나 nullable
  • 예상 못 한 실패(DB 끊김·NPE)는 예외로 위로 던지고 전역 핸들러가 잡는다
  • 절대 하지 말 것: catch (e: Exception) {} 빈 블록. 최소한 log.warn(e)는 남기세요
  • e.printStackTrace()는 로그 파일에 안 남습니다 — 로거를 쓰세요
스프링에서는 @RestControllerAdvice 한 곳에서 도메인 예외를 HTTP 상태로 바꾸는 것이 정석입니다 — Spring Boot 탭에서 다룹니다.
KOTLIN 18

자바와 섞어 쓰기

실무에서 코틀린을 처음 넣는 곳은 대개 이미 자바로 된 프로젝트입니다. 두 언어가 만나는 지점의 규칙을 알면 파일 하나씩 안전하게 옮길 수 있습니다.

플랫폼 타입 — null 안전성의 구멍

자바에서 온 값 — 코틀린은 null 여부를 모른다 자바 메서드 String getName() String! 플랫폼 타입 — 검사 안 함 런타임 NPE 가능 코틀린인데 NPE 가 난다 경계에서 타입 명시 val n: String? = java.getName() 이 지점부터 컴파일러가 다시 강제한다 ?. ?: 로 처리하지 않으면 컴파일 에러 — 안쪽 코드는 안전
자바 라이브러리와 만나는 얇은 경계층에서 한 번만 검사하면, 안쪽 코드는 다시 안전해집니다.
// 자바: public String getName() { return null; }

val name = javaObj.getName()        // 타입은 String! (플랫폼 타입)
println(name.length)                 // 컴파일은 통과 → 런타임 NPE 💥

// ✅ 경계에서 명시한다
val name2: String? = javaObj.getName()
println(name2?.length ?: 0)          // 이제 컴파일러가 강제한다

// 자바 쪽에 애너테이션이 있으면 코틀린이 알아본다 (권장)
// @Nullable String getName();  → String?
// @NotNull  String getName();  → String
// (org.jetbrains.annotations · javax.annotation · JSpecify 등을 인식)

코틀린 코드를 자바에서 부를 때 — @Jvm 애너테이션

자주 쓰는 다섯 개

@file:JvmName("StringUtils")     // 파일 맨 위 — 자바에서 보이는 클래스 이름
package com.example.util

// 1) 기본값을 자바에서도 쓰게 — 오버로딩을 만들어 준다
@JvmOverloads
fun send(to: String, type: String = "일반", retry: Int = 3) { }

class User(val name: String) {
    companion object {
        // 2) 자바에서 User.create() 로 (없으면 User.Companion.create())
        @JvmStatic
        fun create(n: String) = User(n)

        // 3) 자바에서 User.MAX 로 (getter 없이 필드로)
        @JvmField
        val MAX = 100
    }
}

// 4) 자바에서 catch 를 강제하려면
@Throws(IOException::class)
fun read(path: String): String = File(path).readText()

// 5) 시그니처 충돌 회피 (제네릭 소거로 이름이 겹칠 때)
@JvmName("sumInts")
fun List<Int>.sum2(): Int = sum()

자바에서 본 코틀린 코드

코틀린자바에서는
val name: StringgetName()
var age: IntgetAge() / setAge()
val isVip: BooleanisVip() (is 로 시작하면 그대로)
최상위 fun f()파일명Kt.f()
object AA.INSTANCE
Unit 반환void
Int?Integer (박싱됨)
List<String>java.util.List (읽기 전용 아님!)
suspend funContinuation 인자가 붙는다 — 자바에서 부르기 어려움
자바에서는 읽기 전용 컬렉션도 고칠 수 있습니다. List<T>가 실행 시점에는 그냥 java.util.List이기 때문입니다. 자바에 넘기는 경계에서는 Collections.unmodifiableList를 고려하세요.

그 밖의 자잘한 규칙

// 1) 코틀린 키워드가 자바 메서드 이름인 경우 — 백틱으로 감싼다
javaObj.`is`()
javaObj.`in`
val map = mapOf("in" to 1)

// 2) 자바 클래스 참조
val c: Class<User> = User::class.java     // 코틀린은 KClass, 자바 API 는 .java 필요
logger = LoggerFactory.getLogger(MyService::class.java)

// 3) SAM 변환 — 자바 인터페이스는 람다로 바로 넘어간다
executor.submit { println("작업") }          // Runnable
button.setOnClickListener { v -> ... }       // 자바 인터페이스라 자동 변환

// 4) 자바 제네릭의 와일드카드
// 자바: void f(List<? extends Number> l)  →  코틀린: fun f(l: List<out Number>)

// 5) 자바 배열은 공변, 코틀린 Array 는 불변(invariant)
// Array<String> 을 Array<Any> 자리에 넣을 수 없다 (자바에서는 됐다 — 그리고 위험했다)
점진적 도입 순서 (실무 검증된 경로) — ① 테스트 코드부터 코틀린으로 (위험 0, 효과 즉시) ② 새로 만드는 DTO·유틸을 코틀린으로 ③ 새 기능의 서비스·컨트롤러를 코틀린으로 ④ 자주 고치는 자바 클래스를 고칠 때 변환 — 안 건드리는 코드는 끝까지 자바로 두어도 됩니다.
KOTLIN 19

치트시트 · 로드맵

막힐 때 펼쳐 보는 한 장 요약과, 이 가이드를 어떤 순서로 읽으면 좋은지에 대한 지도입니다.

자바 → 코틀린 대응표

자바코틀린
final String s = "a";val s = "a"
String s = null;var s: String? = null
if (s != null) s.length()s?.length
s != null ? s : "기본"s ?: "기본"
a.equals(b)a == b
a == b (참조)a === b
"a" + x + "b""a${x}b"
obj instanceof T tobj is T (스마트 캐스트)
(T) objobj as T · 안전하게 obj as? T
switchwhen (값을 돌려준다)
for (String s : list)for (s in list)
for (int i=0; i<n; i++)for (i in 0..<n)
static 메서드최상위 함수 · companion object
싱글턴 (enum 이디엄)object
record / 롬복 @Datadata class
Optional<T>T? (코틀린은 Optional 을 쓰지 않는다)
stream().map().collect()map { }
StringUtils.isBlank(s)s.isBlank() (확장 함수)
try-with-resourcesuse { }
throws IOException없음 (필요하면 @Throws)
CompletableFuturesuspend fun (코루틴 탭)

코틀린을 “코틀린답게” 쓰는 10가지

  • val 을 기본으로 — var 는 이유가 있을 때만
  • !! 를 쓰지 않는다 — 쓸 때는 주석으로 이유를 남긴다
  • DTO 는 data class, 엔티티는 일반 class
  • 상태는 sealed + when — else 를 쓰지 않는 습관
  • 유틸 클래스 대신 확장 함수
  • 컬렉션은 읽기 전용으로 주고받기
  • 기본값 + 이름붙인 인자로 오버로딩 없애기
  • 스코프 함수는 뜻이 있을 때만 — 습관적 let 금지
  • 비동기는 코루틴 — 콜백·Future 를 감싸서 suspend 로
  • 자바 경계에서 null 을 한 번 검사하고 안쪽은 non-null 로

이 가이드를 읽는 순서

목표읽을 순서
자바 개발자가
코틀린 전환
이 탭 전체 → Kotlin 고급(제네릭·inline·위임) → 코루틴 → Spring Boot Web → 데이터 · JPA
백엔드 신규 개발이 탭 01~16 → 코루틴 → Spring Boot Web → WebFlux → 대규모 트래픽
안드로이드 개발이 탭 01~16 → 코루틴(Flow 까지) → Android 기초 → Android 실전
여러 플랫폼 공용
코드 만들기
이 탭 → 코루틴 → 멀티플랫폼 KMP → 필요하면 Android 탭
깊이 파고들기Kotlin 고급 → 전문가(K2 컴파일러 · 바이트코드 · 코루틴 내부)
다음 탭 예고 — Kotlin 고급에서는 제네릭과 변성(in/out), inline·reified, 위임 프로퍼티, 연산자 오버로딩, DSL 만들기를 다룹니다. 이 탭의 문법으로 코드를 쓸 수 있게 됐다면, 다음 탭은 라이브러리를 만들 수 있게 되는 단계입니다.

자주 부딪히는 에러 메시지

메시지원인 · 해결
Null can not be a value of a non-null type타입에 ?가 없다. 타입을 열거나 값을 채우세요
Smart cast to 'X' is impossiblevar 프로퍼티라 검사 후 바뀔 수 있음 → 지역 val에 담기
Type mismatch: inferred type is Int but Long was expected자동 형변환이 없다 → .toLong()
'when' expression must be exhaustivesealed 의 경우를 빠뜨렸다 (좋은 에러입니다)
This class does not have a constructorobject나 sealed를 new처럼 만들려 함
lateinit property has not been initialized주입 전에 접근. 생성자 주입으로 바꾸거나 ::x.isInitialized 확인
Suspend function should be called only from a coroutine bodysuspend 함수를 일반 함수에서 호출 — 코루틴 탭 참고
Cannot access '...': it is private in ...확장 함수는 private 멤버에 접근 못 한다
제네릭 · INLINE · 위임 · DSL · 리플렉션

여기서부터는
“쓰는 쪽”이 아니라
“만드는 쪽”이다

기초 탭의 문법으로 코드를 쓸 수 있게 됐다면, 이 탭은 남이 쓸 것을 만드는 단계입니다. 변성(in/out), reified, 위임 프로퍼티, 연산자 규약, DSL — Gradle KTS·Compose·Ktor가 그렇게 생긴 이유가 전부 여기 있습니다.

16SECTIONS
0런타임 비용 (inline)
3DSL 만드는 재료
100%표준 라이브러리 실제 코드
ADV 01

제네릭 — 타입을 인자로 받기

List<String>의 꺾쇠가 제네릭입니다. “여기엔 String만 들어간다”를 컴파일러에게 알려 주는 장치이고, 캐스팅 코드를 지우는 것이 목적입니다.

// 타입 파라미터 T 를 받는 클래스
class Box<T>(private var item: T) {
    fun get(): T = item
    fun set(v: T) { item = v }
}

val b = Box("hello")     // Box<String> 으로 추론
val s: String = b.get()  // 캐스팅이 필요 없다

// 제네릭 함수 — 함수 이름 '앞'에 <T> 를 적는다
fun <T> firstOrDefault(list: List<T>, default: T): T =
    list.firstOrNull() ?: default

// 여러 개
fun <K, V> Map<K, V>.reverse(): Map<V, K> =
    entries.associate { (k, v) -> v to k }

// 확장 함수의 제네릭
fun <T : Comparable<T>> List<T>.secondLargest(): T? =
    distinct().sortedDescending().getOrNull(1)

제약(bound) — 아무 타입이나 받지 않기

// 상한 하나
fun <T : Number> sum(list: List<T>): Double =
    list.sumOf { it.toDouble() }

// 여러 제약은 where 로
fun <T> process(items: List<T>): List<T>
        where T : Comparable<T>,
              T : CharSequence =
    items.filter { it.isNotEmpty() }.sorted()

// 기본 상한은 Any? — null 을 막으려면 Any 를 적는다
fun <T : Any> requireAll(list: List<T?>): List<T> =
    list.map { it ?: error("null 원소 발견") }
제약을 붙이면 그 타입의 멤버를 쓸 수 있게 됩니다. T : Number라고 적었기 때문에 toDouble()을 부를 수 있는 것입니다 — 제약이 없으면 Any?의 멤버(equals·hashCode·toString)만 보입니다.

실무 형태 — 공통 리포지터리

interface Entity { val id: Long }

interface CrudRepository<T : Entity> {
    fun findById(id: Long): T?
    fun save(entity: T): T
    fun findAll(): List<T>
}

// 공통 동작을 기본 구현으로
fun <T : Entity> CrudRepository<T>.findOrThrow(id: Long): T =
    findById(id) ?: throw NoSuchElementException("없음: $id")

// 사용
class UserRepo : CrudRepository<User> { /* ... */ }
val user: User = userRepo.findOrThrow(1)   // 타입이 User 로 그대로

타입 파라미터의 기본값은 없습니다 — 대신 쓰는 방법

// 코틀린에는 타입 파라미터 기본값이 없다. 오버로딩이나 typealias 로 푼다
typealias StringBox = Box<String>

// 팩토리 함수로 기본형을 제공하는 방식이 더 흔하다
class Cache<K, V>(private val max: Int) { /* ... */ }
fun defaultCache() = Cache<String, Any>(max = 1000)

// 타입 추론이 안 되는 자리에서는 명시적으로 적어 준다
val empty = emptyList<User>()          // 타입을 못 정하므로 필요
val map = mutableMapOf<Long, User>()
ADV 02

변성 — in · out

List<Dog>를 List<Animal> 자리에 넣어도 될까요? 이 질문의 답이 변성(variance)입니다. 코틀린은 이것을 선언하는 쪽에서 한 번 정하게 합니다 — 자바의 와일드카드가 호출부마다 등장하던 것과 다릅니다.

Dog 는 Animal 의 하위 타입 — 제네릭을 씌우면 대입 화살표가 어디로 가나 out T — 공변 in T — 반공변 T — 불변 List<Animal> List<Dog> 대입 가능 — 같은 방향 Comparator<Animal> Comparator<Dog> 대입 가능 — 뒤집힘 MutableList<Animal> MutableList<Dog> ✕ 어느 쪽으로도 불가 생산자 — 꺼내기만 자바: ? extends T 소비자 — 넣기만 자바: ? super T 넣고 꺼내기 둘 다 아무 표시 없을 때의 기본값 불변인 이유 — Animal 목록으로 볼 수 있다면 개 목록에 add(Cat()) 가 들어갑니다
외우는 법 — PECS: Producer는 out(extends), Consumer는 in(super).

out — 꺼내기만 하는 타입

open class Animal(val name: String)
class Dog(name: String) : Animal(name)

// 코틀린 표준 List 선언: public interface List<out E>
val dogs: List<Dog> = listOf(Dog("바둑이"))
val animals: List<Animal> = dogs      // ✅ out 이라 대입된다

// 내가 만드는 생산자 타입에도 붙인다
interface Producer<out T> {
    fun produce(): T
    // fun consume(item: T)   // ❌ 컴파일 에러 — out 은 '인자'로 못 쓴다
}

// 반환 위치에만 T 가 나타나면 out 을 붙일 수 있다
class Repo<out T>(private val items: List<T>) {
    fun all(): List<T> = items
}

in — 넣기만 하는 타입

// Comparator 선언: public interface Comparator<in T>
val animalCmp = Comparator<Animal> { a, b -> a.name.compareTo(b.name) }
val dogCmp: Comparator<Dog> = animalCmp   // ✅ in 이라 대입된다
// 동물을 비교할 줄 알면 개도 비교할 수 있다 — 당연한 이야기다

interface Consumer<in T> {
    fun consume(item: T)
    // fun produce(): T      // ❌ in 은 '반환'에 못 쓴다
}

// 함수 타입이 대표적 — (in P) -> (out R)
// public interface Function1<in P1, out R>
val f: (Animal) -> String = { it.name }
val g: (Dog) -> Any = f              // ✅ 인자는 넓게, 반환은 좁게

사용 지점 변성과 스타 프로젝션

// 선언 지점에 변성을 못 붙이는 타입(MutableList)은 '쓰는 자리'에서 지정한다
fun copy(from: MutableList<out Any>, to: MutableList<Any>) {
    // from 에서는 꺼내기만 가능 (자바의 List<? extends Object>)
    to.addAll(from)
}

fun fill(dest: MutableList<in Dog>, value: Dog) {
    dest.add(value)     // Dog 또는 그 조상 리스트에 넣을 수 있다
}

// 스타 프로젝션 — 타입 인자를 모를 때
fun printSize(list: List<*>) {         // List<out Any?> 와 비슷
    println(list.size)                 // 크기는 알 수 있다
    val first: Any? = list.firstOrNull()  // 꺼내면 Any?
    // list.add(...)  ← 애초에 List 라 없다. MutableList<*> 면 넣기가 막힌다
}

// 실무에서 자주 만나는 자리 — 리플렉션 · 제네릭 컨테이너 검사
fun isEmptyCollection(c: Collection<*>) = c.isEmpty()
실전에서 기억할 한 줄 — 내가 만든 제네릭 타입에서 T가 반환에만 나오면 out, 인자에만 나오면 in을 붙이세요. 컴파일러가 위치를 검사해 주므로 잘못 붙이면 바로 에러가 납니다. 둘 다 나오면 그냥 두면 됩니다 — 대부분의 클래스가 그렇습니다.
ADV 03

타입 소거와 reified

JVM에서는 실행 시점에 List<String>과 List<Int>가 구분되지 않습니다(타입 소거). 코틀린은 inline + reified로 이 한계를 우회하는 유일한 실용적 방법을 제공합니다.

제네릭 타입 인자는 런타임에 지워진다 — reified 가 그 구멍을 메운다 ① 보통 제네릭 — T 는 런타임에 없다 ② inline + reified — 호출부에 타입이 박힌다 fun <T> parse(j: String): T 소스 — 여기서는 T 를 안다고 착각한다 Object parse(String) 바이트코드 — T 가 사라졌다 (타입 소거) T::class 는 컴파일 에러 런타임에 T 가 무엇이었는지 흔적이 없다 Gson().fromJson(j, T::class.java) 불가 inline fun <reified T> parse(...) inline 인 함수만 reified 를 쓸 수 있다 parse<User>(j) 자리에 본문 복사 복사하면서 T 를 User 로 바꿔 넣는다 User::class 가 그대로 남는다 JSON 역직렬화 · filterIsInstance 가 이 원리 대가 — 부른 곳마다 코드가 복사된다
reified 는 마법이 아니라 인라인 복사의 부산물입니다 — 그래서 큰 함수에 붙이면 바이트코드가 불어납니다.
소거 때문에 안 되는 것들
fun <T> check(x: Any): Boolean {
    // return x is T      // ❌ Cannot check for instance of erased type
    return false
}

fun <T> create(): T {
    // return T()         // ❌ 생성자를 부를 수 없다
    TODO()
}

// 실행 시점에는 이렇게 보인다
val a: List<String> = listOf("a")
val b: List<Int> = listOf(1)
println(a.javaClass == b.javaClass)   // true — 같은 클래스. <String>·<Int> 정보는 남지 않는다
inline + reified 로 해결
inline fun <reified T> check(x: Any): Boolean = x is T   // ✅

println(check<String>("hi"))   // true
println(check<Int>("hi"))      // false

// 왜 되나: inline 이라 호출 자리에 코드가 '펼쳐지고',
// 그때 T 가 실제 타입(String)으로 치환된다.
// 즉 컴파일러가 x is String 을 그 자리에 써 넣는 것과 같다

inline fun <reified T> List<*>.filterIsInstance2(): List<T> =
    filter { it is T }.map { it as T }

실무에서 reified 가 빛나는 자리

JSON 역직렬화 · API 클라이언트

// 자바 스타일 — Class 를 인자로 받아야 했다
fun <T> parse(json: String, clazz: Class<T>): T = mapper.readValue(json, clazz)
val u1 = parse(json, User::class.java)

// 코틀린 — 타입만 적으면 된다
inline fun <reified T> parse(json: String): T =
    mapper.readValue(json, object : TypeReference<T>() {})

val u2: User = parse(json)                 // 추론까지 된다
val list = parse<List<User>>(json)         // 제네릭 중첩도 가능

// 실제 라이브러리들이 전부 이 방식이다
// kotlinx.serialization: Json.decodeFromString<User>(text)
// Ktor Client:           client.get(url).body<User>()
// Spring 6:              restClient.get().retrieve().body<User>()

안드로이드 · DI · 로거

// 화면 이동 (안드로이드)
inline fun <reified T : Activity> Context.start() =
    startActivity(Intent(this, T::class.java))
start<DetailActivity>()

// 의존성 조회
inline fun <reified T> Injector.get(): T = get(T::class)
val repo: UserRepo = injector.get()

// 클래스별 로거 — 자바에서 늘 쓰던 상용구가 사라진다
inline fun <reified T> T.logger(): Logger =
    LoggerFactory.getLogger(T::class.java)

class OrderService {
    private val log = logger()
}
제약 — reified는 inline 함수에서만 쓸 수 있습니다. 그래서 클래스의 타입 파라미터에는 붙일 수 없고(class Box<reified T> 불가), 자바에서는 이 함수를 부를 수 없습니다. 또 inline 이라 private 멤버에 접근하는 코드를 공개 inline 함수 안에 둘 수 없습니다.
ADV 04

inline · noinline · crossinline

람다를 인자로 받으면 보통 객체가 하나 생깁니다. 반복문 안이라면 수만 개가 생기죠. inline은 그 비용을 0으로 만드는 대신, 몇 가지 규칙을 따라오게 합니다.

inline 이 하는 일 — 컴파일 시점에 코드를 호출 자리로 옮긴다 일반 고차 함수 호출부 forEach { ... } Function 객체 생성 람다마다 인스턴스 함수 호출 + invoke() 호출 스택 두 단계 inline 함수 호출부 forEach { ... } 본문 + 람다가 그 자리에 펼쳐진다 (그냥 for 루프가 된다) 객체 생성 0 · 호출 0 — 대신 바이트코드 크기가 커진다
표준 라이브러리의 let · run · apply · forEach · filter가 전부 inline 입니다 — 그래서 공짜로 쓸 수 있습니다.

언제 붙이나 · 언제 안 붙이나

상황판단
람다를 받는 작은 함수✅ 붙인다 (이게 inline 의 존재 이유)
reified 가 필요✅ 필수
람다를 안 받는 함수❌ 이득이 거의 없다 — IDE 도 경고한다
본문이 긴 함수❌ 호출 지점마다 복제되어 코드가 부푼다
공개 API + private 접근❌ 컴파일 에러 (펼쳐진 코드가 private 에 접근 불가)
inline 은 바이너리 호환성에 영향을 줍니다. 라이브러리를 배포한 뒤 inline 함수의 본문을 고치면, 쓰는 쪽을 다시 컴파일해야 반영됩니다 — 이미 펼쳐진 옛 코드가 남아 있기 때문입니다.

noinline · crossinline

// noinline — 이 람다만 펼치지 않는다 (객체로 남겨 저장·전달해야 할 때)
inline fun run2(
    before: () -> Unit,
    noinline after: () -> Unit,     // 나중에 쓰려고 보관해야 한다
) {
    before()
    handlers += after               // 펼쳐진 코드는 저장할 수 없다
}

// crossinline — 펼치되 '비지역 반환'만 금지한다
inline fun async2(crossinline block: () -> Unit) {
    Thread {
        block()      // 다른 실행 흐름 안에서 부른다
        // 여기서 return 이 허용되면 어디로 돌아가야 하나? → 그래서 금지
    }.start()
}

비지역 반환 — inline 만 되는 일

fun findAdmin(users: List<User>): User? {
    users.forEach {                      // forEach 는 inline 이다
        if (it.isAdmin) return it        // ✅ findAdmin 자체를 종료 (비지역 반환)
    }
    return null
}

// inline 이 아니었다면 이 return 은 컴파일 에러다
// (일반 람다에서는 자기 자신만 빠져나올 수 있다)

// 라벨로 람다만 빠져나오기
fun f(users: List<User>) {
    users.forEach {
        if (it.isBanned) return@forEach  // 이번 항목만 건너뛴다
        send(it)
    }
}

성능 — 실제로 얼마나 차이 나나

측정 없이 믿지 마세요

JIT는 대부분의 작은 람다 호출을 인라이닝해 버립니다. 수억 번 도는 루프가 아니면 체감 차이가 없는 경우가 많습니다. inline의 진짜 가치는 세 가지입니다.

  • reified를 쓸 수 있다
  • 비지역 반환이 된다 (제어 흐름 표현력)
  • 안드로이드처럼 객체 생성이 곧 GC 압력인 환경에서 이득

확인하는 법 — 바이트코드 보기

IntelliJ 메뉴
  Tools → Kotlin → Show Kotlin Bytecode
  → [Decompile] 버튼을 누르면 '자바로 치면 이런 코드'가 나온다

무엇을 보나
  · 람다가 클래스로 만들어졌는지 (Function0 · Function1)
  · inline 함수가 정말 펼쳐졌는지
  · data class 가 어떤 equals 를 만들었는지
  · suspend 함수가 어떤 상태 기계로 바뀌었는지  ← 코루틴 탭에서 다시 봅니다
코틀린을 깊이 이해하는 가장 빠른 길이 이 디컴파일 화면입니다. “이 문법이 실제로 무엇으로 바뀌는가”를 눈으로 보면 성능 이야기가 추측에서 사실로 바뀝니다.
ADV 05

위임 프로퍼티

by lazy의 by가 위임입니다. “이 프로퍼티의 읽기·쓰기를 다른 객체에게 맡긴다”는 선언으로, 캐싱·검증·변경 감지·설정 읽기 같은 반복되는 접근자 코드를 한 번만 쓰게 만듭니다.

by 를 쓰면 프로퍼티의 읽기·쓰기가 다른 객체에게 넘어간다 val conf by lazy { ... } 내 클래스의 프로퍼티 getValue(무엇의, 어떤것) 컴파일러가 끼워 넣는 호출 위임 객체 값과 초기화 여부를 들고 있다 표준 라이브러리가 주는 위임 네 가지 lazy 첫 접근에 한 번만 계산 기본은 스레드 안전 observable 값이 바뀔 때마다 콜백 로깅 · 화면 갱신에 vetoable 조건에 안 맞으면 거부 불변식을 지킨다 by map Map 에서 이름으로 꺼냄 설정 · JSON 매핑에 직접 만들 때 필요한 것은 getValue · setValue 두 함수뿐입니다 — 인터페이스를 구현할 필요가 없습니다(연산자 규약).
위임은 "이 프로퍼티의 저장 방식을 통째로 갈아 끼우는" 문법입니다 — lazy 하나만 알아도 절반은 쓴 셈입니다.

표준 위임 넷

import kotlin.properties.Delegates

class Config(map: Map<String, Any>) {
    // 1) lazy — 처음 읽을 때 한 번만 계산
    val heavy: String by lazy { loadFromDisk() }

    // 2) observable — 바뀔 때마다 알림
    var status: String by Delegates.observable("NEW") { _, old, new ->
        log.info("상태 변경 {} → {}", old, new)
    }

    // 3) vetoable — 조건을 통과할 때만 반영
    var age: Int by Delegates.vetoable(0) { _, _, new -> new in 0..150 }

    // 4) 맵 위임 — 키 이름으로 값을 꺼낸다
    val name: String by map
    val port: Int by map
}

val c = Config(mapOf("name" to "서버1", "port" to 8080))
println(c.name)         // 서버1  (map["name"] 를 읽는다)
c.age = 200             // 무시된다 (vetoable 이 거부)
println(c.age)          // 0

notNull · 다른 프로퍼티에 위임

class Service {
    // lateinit 을 못 쓰는 기본 타입에 쓴다
    var port: Int by Delegates.notNull()
}

// 이름을 바꿀 때 — 옛 이름을 남기며 새 이름으로 위임 (1.4+)
class User(val fullName: String) {
    @Deprecated("name 대신 fullName")
    val name: String by this::fullName
}

// 최상위 프로퍼티에도 쓸 수 있다
val version: String by lazy {
    // 최상위에는 this 가 없어 javaClass 를 바로 못 쓴다 → 익명 객체로 클래스를 얻는다
    object {}.javaClass.getResource("/version.txt")?.readText() ?: "dev"
}
lazy 의 세 가지 모드 — 기본은 SYNCHRONIZED(잠금 사용), PUBLICATION(여러 번 계산될 수 있으나 하나만 채택), NONE(잠금 없음 · 단일 스레드 확실할 때만). 안드로이드 UI 스레드 전용 값이라면 NONE이 가장 빠릅니다.

직접 만들기 — 두 개의 연산자만 있으면 된다

import kotlin.reflect.KProperty

// 읽기 전용이면 getValue 하나, 쓰기까지면 setValue 도
class TrimDelegate {
    private var value: String = ""

    operator fun getValue(thisRef: Any?, property: KProperty<*>): String = value

    operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
        value = newValue.trim()
        println("[${property.name}] 저장: '$value'")
    }
}

class Form {
    var title: String by TrimDelegate()
}

val f = Form()
f.title = "   제목  "     // [title] 저장: '제목'
println("'${f.title}'")    // '제목'

// 실무 예 — 환경변수 읽기 위임
class EnvDelegate(private val key: String, private val default: String? = null) {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String =
        System.getenv(key) ?: default ?: error("환경변수 없음: $key")
}
fun env(key: String, default: String? = null) = EnvDelegate(key, default)

object AppConfig {
    val dbUrl: String by env("DB_URL")
    val port: String by env("PORT", "8080")
}

provideDelegate — 만들 때 검증하기

// 프로퍼티가 '만들어지는 시점'에 개입한다
class RequiredEnv(private val key: String) {
    operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): EnvDelegate {
        // 앱이 뜰 때 바로 검증 — 나중에 처음 읽을 때가 아니라
        require(System.getenv(key) != null) {
            "환경변수 $key 가 없습니다 (프로퍼티 ${prop.name})"
        }
        return EnvDelegate(key)
    }
}

설정 누락을 기동 시점에 잡을 수 있습니다 — 운영에서 특정 API가 호출될 때야 터지는 것보다 훨씬 낫습니다.

실무에서 만나는 위임들

코드어디서
by lazy어디서나 — 가장 많이 쓴다
by viewModels()안드로이드 — ViewModel 주입
by remember { }Compose — 상태 기억 (규약은 같다)
by inject()Koin — DI 조회
by Delegates.observable상태 변경 로깅 · 디버깅
by savedStateHandle안드로이드 — 화면 회전 복구
낯선 by를 만나면 그 오른쪽 객체의 getValue를 찾아 보세요. 규약이 단순해서 라이브러리 동작을 금방 파악할 수 있습니다.
ADV 06

연산자 오버로딩 · 규약 함수

a + b는 a.plus(b)를 부릅니다. 코틀린은 이런 규약(convention)을 정해 두고, 정해진 이름의 함수를 만들면 문법이 그 타입에 열리게 합니다.

쓰는 모양불리는 함수비고
a + ba.plus(b)minus · times · div · rem
a += ba.plusAssign(b)없으면 a = a + b로 처리
-aa.unaryMinus()unaryPlus · not
a[i]a.get(i)인자 여러 개도 가능
a[i] = va.set(i, v)
a(x)a.invoke(x)객체를 함수처럼
a in bb.contains(a)
a..ba.rangeTo(b)a..<b는 rangeUntil
a < ba.compareTo(b)Comparable 구현이면 자동
a == ba?.equals(b) ?: (b === null)null 검사가 포함된다
for (x in a)a.iterator()확장 함수로도 가능
val (x, y) = aa.component1(), component2()data class 는 자동

값 객체에 연산 열기

@JvmInline
value class Money(val won: Long) : Comparable<Money> {
    operator fun plus(o: Money) = Money(won + o.won)
    operator fun minus(o: Money) = Money(won - o.won)
    operator fun times(n: Int) = Money(won * n)
    operator fun unaryMinus() = Money(-won)
    override fun compareTo(other: Money) = won.compareTo(other.won)
    override fun toString() = "%,d원".format(won)
}

val total = Money(10_000) + Money(2_500) * 2
println(total)                    // 15,000원
println(Money(100) < Money(200))  // true
println(listOf(Money(300), Money(100)).sorted())   // Comparable 이라 정렬된다

get · invoke · contains

class Matrix(private val rows: Int, private val cols: Int) {
    private val data = DoubleArray(rows * cols)
    operator fun get(r: Int, c: Int) = data[r * cols + c]
    operator fun set(r: Int, c: Int, v: Double) { data[r * cols + c] = v }
}
val m = Matrix(3, 3)
m[0, 0] = 1.5
println(m[0, 0])

// invoke — 객체를 함수처럼 부른다 (전략·유스케이스 패턴에서 흔하다)
class GetUser(private val repo: UserRepo) {
    operator fun invoke(id: Long): User? = repo.find(id)
}
val getUser = GetUser(repo)
val u = getUser(1)          // getUser.invoke(1)

// contains — 도메인 표현을 자연스럽게
class DateRange(val start: LocalDate, val end: LocalDate) {
    operator fun contains(d: LocalDate) = d >= start && d <= end
}
if (today in eventPeriod) { /* ... */ }

구조 분해를 내 클래스에도

// data class 가 아니어도 componentN 을 만들면 분해된다
class Rgb(val r: Int, val g: Int, val b: Int) {
    operator fun component1() = r
    operator fun component2() = g
    operator fun component3() = b
}
val (r, g, b) = Rgb(255, 128, 0)

// 확장 함수로도 열 수 있다 — 남의 클래스에!
operator fun LocalDate.component1() = year
operator fun LocalDate.component2() = monthValue
operator fun LocalDate.component3() = dayOfMonth

val (y, mo, d) = LocalDate.now()
println("$y-$mo-$d")

// 람다 인자에서도 쓰인다
mapOf("a" to 1).forEach { (key, value) -> println("$key=$value") }
과용하면 읽기 어려워집니다. 기준은 “수학적·자연적 의미가 명백한가”입니다. Money + Money는 명백하지만 User + Order는 아닙니다. 의미가 애매하면 이름 있는 함수(user.placeOrder(order))가 언제나 낫습니다.
ADV 07

DSL 만들기

Gradle KTS, Compose, Ktor 라우팅, kotest — 문법처럼 보이는 이 코드들은 전부 수신 객체 람다 + 확장 함수 + 후행 람다 세 재료로 만들어집니다. 원리를 알면 직접 만들 수 있습니다.

DSL 의 재료 셋 — 이 조합이 '문법처럼 보이는 코드'를 만든다 ① 후행 람다 f(x) { } → f(x) { } 괄호가 사라진다 ② 수신 객체 람다 T.() -> Unit 블록 안에서 this 생략 ③ 확장 함수 타입에 문법을 더한다 infix · operator 도 함께 html { body { p { +"안녕" } } } 같은 코드가 나온다
Gradle의 dependencies { }도 결국 수신 객체 람다를 받는 평범한 함수입니다.

단계별로 만들어 보기 — HTML 빌더

// 1단계: 노드 구조
@DslMarker
annotation class HtmlDsl                      // 스코프 섞임 방지 (아래 설명)

@HtmlDsl
open class Tag(private val name: String) {
    private val children = mutableListOf<Any>()
    private val attrs = mutableMapOf<String, String>()

    protected fun <T : Tag> add(child: T, block: T.() -> Unit): T {
        child.block()                          // ← 수신 객체 람다 실행
        children += child
        return child
    }

    // 텍스트 노드를 단항 연산자로 (+"안녕")
    operator fun String.unaryPlus() { children += this }

    fun attr(k: String, v: String) { attrs[k] = v }

    override fun toString(): String {
        val a = attrs.entries.joinToString("") { " ${it.key}=\"${it.value}\"" }
        return "<$name$a>${children.joinToString("")}</$name>"
    }
}

class Html : Tag("html") { fun body(block: Body.() -> Unit) = add(Body(), block) }
class Body : Tag("body") {
    fun p(block: P.() -> Unit) = add(P(), block)
    fun ul(block: Ul.() -> Unit) = add(Ul(), block)
}
class P : Tag("p")
class Ul : Tag("ul") { fun li(block: Li.() -> Unit) = add(Li(), block) }
class Li : Tag("li")

// 2단계: 진입점
fun html(block: Html.() -> Unit): Html = Html().apply(block)

// 3단계: 쓰는 쪽 — 문법처럼 보인다
val page = html {
    body {
        p {
            attr("class", "lead")
            +"코틀린 DSL 입니다"
        }
        ul {
            listOf("하나", "둘", "셋").forEach { item ->
                li { +item }               // 반복문도 그냥 쓸 수 있다 (템플릿 엔진과 다른 점)
            }
        }
    }
}
println(page)
// <html><body><p class="lead">코틀린 DSL 입니다</p><ul><li>하나</li>…</ul></body></html>

@DslMarker — 바깥 스코프가 새는 것을 막는다

// @DslMarker 가 없으면 이런 코드가 컴파일된다
html {
    body {
        body { }      // ❌ 말이 안 되는데 바깥 this 가 보여서 가능했다
    }
}

// @DslMarker 를 붙이면
// "암시적 수신 객체가 두 개면 가장 안쪽만 쓸 수 있다"는 규칙이 켜진다
// → 위 코드는 컴파일 에러가 된다

// 정말 바깥을 써야 하면 이름을 붙여 쓴다
html {
    val outer = this
    body { outer.attr("lang", "ko") }
}

실무에서 쓸 만한 작은 DSL

// 테스트 픽스처 빌더
class UserBuilder {
    var id: Long = 1
    var name: String = "테스트"
    var roles: MutableList<String> = mutableListOf()
    fun role(r: String) { roles += r }
    fun build() = User(id, name, roles.toList())
}
fun user(block: UserBuilder.() -> Unit) = UserBuilder().apply(block).build()

val u = user {
    name = "김현우"
    role("ADMIN")
    role("USER")
}

// 검증 DSL
class Validation(private val errors: MutableList<String> = mutableListOf()) {
    fun check(cond: Boolean, msg: String) { if (!cond) errors += msg }
    fun result() = errors.toList()
}
fun validate(block: Validation.() -> Unit) = Validation().apply(block).result()

val errs = validate {
    check(order.amount > 0, "금액 오류")
    check(order.items.isNotEmpty(), "빈 주문")
}
DSL 을 만들기 전에 물어볼 것 — “같은 구조를 여러 번 쓰는가?” 한두 번 쓰는 설정이라면 data class + 이름붙인 인자가 더 명확하고 디버깅도 쉽습니다. DSL 은 중첩 구조가 반복될 때(HTML · 라우팅 · UI 트리) 값을 합니다.
ADV 08

시퀀스 — 지연 평가

리스트 연산은 단계마다 새 리스트를 만듭니다. Sequence는 원소 하나를 끝까지 흘려보낸 뒤 다음 원소로 갑니다 — 자바 Stream과 같은 방식이고, 무한 수열도 다룰 수 있습니다.

(1..6).map { it*10 }.filter { it>20 }.take(2) — 동그라미 안 숫자가 실행 순서 원소 1 2 3 4 5 6 List (즉시 평가) — 단계마다 전부 map ×10 filter >20 1 2 3 4 5 6 7 8 9 10 11 12 take(2) → [30, 40] 호출 12번 100만 건이면 200만 번 Sequence (지연 평가) — 원소별로 끝까지 map ×10 filter >20 1 3 5 7 — — 2 4 6 8 — — take(2) 가 차면 멈춤 호출 8번 100만 건이어도 8번 초록 = filter 통과 · 점선 = 계산조차 안 함 — 중간 리스트도 만들지 않습니다
앞에서 끊는 연산(take · first · any)이 있을 때 시퀀스의 이득이 가장 큽니다.

만들기 · 무한 수열

// 컬렉션에서
val s1 = listOf(1, 2, 3).asSequence()

// 원소 나열
val s2 = sequenceOf(1, 2, 3)

// 규칙으로 무한 생성 — null 을 돌려주면 끝난다
val powers = generateSequence(1) { it * 2 }
println(powers.take(10).toList())   // [1,2,4,...,512]

// 파일 한 줄씩 (메모리에 다 올리지 않는다)
File("big.log").useLines { lines ->
    lines.filter { "ERROR" in it }.take(100).toList()
}

// yield 로 직접 만들기 — 코루틴 문법을 쓴다
val fib = sequence {
    var (a, b) = 0 to 1
    while (true) {
        yield(a)                 // 여기서 멈췄다가 다음에 이어서 실행된다
        a = b.also { b = a + b }
    }
}
println(fib.take(8).toList())     // [0,1,1,2,3,5,8,13]

중간 연산과 종단 연산

// 중간(Intermediate) — 시퀀스를 돌려준다. 아직 아무 일도 안 한다
// map · filter · take · drop · distinct · sortedBy(예외) · flatMap · onEach

// 종단(Terminal) — 여기서 비로소 실행된다
// toList · first · sum · count · any · all · forEach · fold · joinToString

val seq = (1..10).asSequence()
    .onEach { println("보는 중: $it") }    // 아직 출력 안 됨
    .filter { it % 2 == 0 }

println("아직 실행 전")
println(seq.toList())                      // 여기서 한꺼번에 실행

// ⚠️ sorted 계열은 '상태를 모아야' 하므로 전부 읽는다 (지연의 이점이 사라진다)
// ⚠️ 시퀀스는 기본적으로 '한 번만' 소비할 수 있는 것이 많다 (constrainOnce)

언제 쓰나 — 판단표

상황선택
원소 수천 개 이하List — 시퀀스는 객체 오버헤드가 있어 오히려 느립니다
수십만 건 + 단계 3개 이상Sequence
first·take(n)으로 앞에서 끊는다Sequence — 이득이 가장 크다
파일·네트워크 스트림Sequence (useLines)
무한 수열Sequence 만 가능
병렬 처리가 필요시퀀스는 병렬이 없습니다 → 자바 parallelStream 또는 코루틴
비동기 스트림 (DB · API)Flow — 코루틴 탭에서 다룹니다
Sequence 와 Flow 의 관계 — 둘 다 “원소를 하나씩 흘려보내는” 모델입니다. 차이는 중간에 기다릴 수 있느냐입니다. Sequence 는 동기(블로킹), Flow는 suspend가 가능해 DB 조회·HTTP 호출을 사이에 넣을 수 있습니다. “동기면 Sequence, 비동기면 Flow”로 외워 두면 됩니다.
ADV 09

컬렉션 고급 — 정렬 · 집계 · 성능

기초 탭의 map·filter 다음 단계입니다. 다중 정렬, 한 번 순회로 여러 집계, 객체 생성을 줄이는 법 — 데이터가 커졌을 때 차이가 나는 부분들입니다.

정렬 — Comparator 조립

data class Emp(val dept: String, val level: Int, val name: String, val joined: LocalDate?)

// 부서 오름차순 → 직급 내림차순 → 이름 오름차순
val sorted = emps.sortedWith(
    compareBy<Emp> { it.dept }
        .thenByDescending { it.level }
        .thenBy { it.name }
)

// null 을 뒤로 (또는 앞으로)
val byJoined = emps.sortedWith(compareBy(nullsLast()) { it.joined })

// 한글 정렬 — 로케일을 명시해야 자모 순서가 맞는다
val ko = Collator.getInstance(Locale.KOREAN)
val names = emps.map { it.name }.sortedWith(ko)

// 사용자 정의 순서 (등급표 순서대로)
val order = listOf("VIP", "GOLD", "SILVER")
val byGrade = users.sortedBy { order.indexOf(it.grade) }

// 안정 정렬이다 — 같은 키면 원래 순서가 유지된다 (자바 List.sort 와 동일)

한 번 순회로 여러 값 얻기

// ❌ 세 번 순회
val cnt = orders.count { it.paid }
val sum = orders.filter { it.paid }.sumOf { it.amount }
val max = orders.filter { it.paid }.maxOfOrNull { it.amount }

// ✅ 한 번에 — fold 로 누산기를 굴린다
data class Agg(val count: Int = 0, val sum: Long = 0, val max: Long = 0)

val agg = orders.asSequence().filter { it.paid }.fold(Agg()) { a, o ->
    Agg(a.count + 1, a.sum + o.amount, maxOf(a.max, o.amount))
}

// 그룹별 집계는 groupingBy + aggregate 가 가장 빠르다 (중간 리스트를 안 만든다)
val sumByCity: Map<String, Long> = orders
    .groupingBy { it.city }
    .fold(0L) { acc, o -> acc + o.amount }

val countByCity = orders.groupingBy { it.city }.eachCount()

성능 — 무엇이 실제로 비싼가

패턴비용과 대안
list.contains(x)를 루프 안에서O(n²) → toSet() 한 번 만들고 검사
map { }.filter { }.map { }단계마다 리스트 생성 → 수십만 건이면 asSequence()
+ 로 리스트 이어붙이기 (루프 안)매번 전체 복사 → buildList나 MutableList
sortedBy 를 여러 번정렬은 O(n log n) → compareBy().thenBy()로 한 번에
Int 컬렉션의 박싱수치 계산 루프면 IntArray가 몇 배 빠르다
first() 를 큰 리스트에서 반복associateBy로 Map 인덱스를 한 번 만들기
매번 Regex("...") 생성정규식 컴파일이 비싸다 → 최상위 val로 한 번만
// 실전 예 — 10만 건 주문에서 사용자 이름 붙이기
// ❌ O(n·m) — 주문마다 사용자 리스트를 훑는다
val bad = orders.map { o ->
    val u = users.first { it.id == o.userId }      // 매번 전체 탐색
    o to u.name
}

// ✅ O(n+m) — 인덱스를 한 번 만든다
val userById = users.associateBy { it.id }         // Map<Long, User>
val good = orders.map { o ->
    o to (userById[o.userId]?.name ?: "탈퇴회원")
}

// 10만 × 1만 기준으로 수 초 → 수십 ms 수준의 차이가 난다

알아 두면 좋은 함수들

listOf(1,2,3,4).zipWithNext()          // [(1,2),(2,3),(3,4)] 연속 비교
listOf(1,2,3).runningFold(0) { a,b -> a+b }  // [0,1,3,6] 누적 합
listOf(3,1,2).sortedDescending()
listOf("a","b").withIndex()
listOf(1,2,3).sumOf { it.toLong() }    // 오버플로 방지 (Long 으로 누적)
listOf(1,2,2,3).groupingBy { it }.eachCount()   // 빈도수
list.chunked(1000) { batch -> repo.saveAll(batch) }  // 배치 저장
list.windowed(3, step = 1, partialWindows = false)   // 이동 평균
list.distinctBy { it.email }           // 특정 키로 중복 제거
list.ifEmpty { listOf(default) }       // 비었으면 대체
list.requireNoNulls()                  // null 있으면 예외

동시성 컬렉션 — 코틀린엔 따로 없다

// 자바 것을 그대로 쓴다
val cache = ConcurrentHashMap<Long, User>()
val queue = ConcurrentLinkedQueue<Task>()
val counter = AtomicLong()

// getOrPut 은 원자적이지 않다! (읽고-없으면-쓰기 사이에 틈이 있다)
// ❌ cache.getOrPut(id) { load(id) }
// ✅ 자바 API 를 쓴다
val u = cache.computeIfAbsent(id) { load(it) }

// 코루틴 환경이라면 Mutex 나 채널이 더 자연스럽다 — 코루틴 탭 참고
mutableListOf()는 스레드 안전이 아닙니다. 여러 스레드가 함께 쓰면 ConcurrentModificationException이 나거나 조용히 원소가 사라집니다. 공유되는 순간 자바 동시성 컬렉션이나 코루틴 도구로 바꾸세요.
ADV 10

typealias · value class

Long 두 개를 인자로 받는 함수는 언젠가 반드시 뒤바뀝니다. value class는 그 사고를 런타임 비용 없이 컴파일 단계에서 막습니다.

UserId 가 실행 시점에 무엇이 되나 — 옆으로 가면 아니오, 아래로 가면 예 @JvmInline value class UserId(val v: Long) 제네릭 인자? List<UserId> 널 가능? UserId? 인터페이스 타입으로? Any 로 올리나? long 그대로 할당 0 · 비용 0 예 예 예 예 박싱 — 힙에 UserId 객체가 생깁니다 이 네 자리에서는 일반 클래스와 똑같아집니다 얻는 것은 그대로 — Long 두 개를 바꿔 넣는 사고가 컴파일에서 막힙니다
value class 는 공짜 타입 안전성입니다 — 다만 박싱되는 네 자리에서는 일반 클래스와 같아집니다.
원시 타입 집착 (primitive obsession)
fun transfer(fromId: Long, toId: Long, amount: Long) { }

// 6개월 뒤 어느 날
transfer(toId, fromId, amount)   // 컴파일 통과. 돈이 반대로 간다 💥

fun send(email: String, name: String) { }
send(name, email)                // 역시 통과
value class — 타입으로 구분
@JvmInline value class AccountId(val value: Long)
@JvmInline value class Money(val won: Long)

fun transfer(from: AccountId, to: AccountId, amount: Money) { }

// transfer(money, from, to)   // ❌ 컴파일 에러
transfer(AccountId(1), AccountId(2), Money(10_000))

// 바이트코드에서는 그냥 long 이다 — 래퍼 객체가 생기지 않는다

value class 의 규칙과 한계

  • 프로퍼티는 하나(val)만. @JvmInline이 필요합니다
  • 함수·계산 프로퍼티는 가질 수 있습니다 (검증 로직도 init에)
  • 박싱되는 경우가 있습니다 — 컬렉션에 담거나(List<Money>), 제네릭·nullable(Money?)로 쓰거나, 인터페이스로 다룰 때
  • 자바에서는 이름이 뒤틀립니다(맹글링) — 자바에서 부를 API 라면 주의
@JvmInline
value class Email(val value: String) {
    init { require("@" in value) { "이메일 형식 오류: $value" } }
    val domain: String get() = value.substringAfter("@")
}
println(Email("a@b.com").domain)   // b.com

typealias — 이름만 바꾼다 (타입 안전 아님)

typealias UserId = Long
typealias Handler = (Request) -> Response
typealias UserCache = MutableMap<UserId, User>

fun find(id: UserId) { }
find(123L)          // ✅ 그냥 Long 이라 통과 — 안전장치가 아니다

// 쓰는 자리
// · 긴 제네릭 타입에 이름 붙이기 (가독성)
// · 함수 타입에 이름 붙이기
// · 이름 충돌 회피 (import 별칭)
import java.util.Date as JavaDate
import kotlinx.datetime.Instant as KtInstant
구분 — typealias는 가독성, value class는 타입 안전. “실수로 뒤바뀌면 사고”라면 반드시 value class를 쓰세요.
어디까지 감쌀 것인가 — 도메인의 식별자(UserId · OrderId)와 단위가 있는 값(Money · Percent · Millis)만 감싸도 사고의 대부분이 막힙니다. 모든 String 을 감싸면 변환 코드가 늘어 오히려 손해입니다.
ADV 11

계약 — contracts

if (s.isNullOrBlank()) return 다음 줄에서 s가 non-null로 스마트 캐스트되는 이유가 뭘까요? 컴파일러가 그 함수의 계약을 알고 있기 때문입니다. 이 계약은 직접 선언할 수도 있습니다.

import kotlin.contracts.*

// 문제: 내가 만든 검사 함수는 스마트 캐스트를 못 만든다
fun isValid(s: String?): Boolean = !s.isNullOrBlank()

fun f(s: String?) {
    if (isValid(s)) {
        // println(s.length)   // ❌ 컴파일러는 s 가 non-null 인지 모른다
    }
}

// 해결: 계약을 선언한다
@OptIn(ExperimentalContracts::class)
fun isValid2(s: String?): Boolean {
    contract { returns(true) implies (s != null) }   // "true 를 돌려주면 s 는 null 이 아니다"
    return !s.isNullOrBlank()
}

fun g(s: String?) {
    if (isValid2(s)) {
        println(s.length)     // ✅ 스마트 캐스트가 된다
    }
}

두 종류의 계약

// 1) returns(...) implies (...) — 반환값이 알려 주는 사실
@OptIn(ExperimentalContracts::class)
fun Any?.isUser(): Boolean {
    contract { returns(true) implies (this@isUser is User) }
    return this is User
}

// 2) callsInPlace — 람다가 언제 몇 번 불리는지
@OptIn(ExperimentalContracts::class)
inline fun <T> measure(block: () -> T): T {
    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
    val t = System.nanoTime()
    return block().also { println("${System.nanoTime() - t}ns") }
}

// EXACTLY_ONCE 를 선언했기 때문에 이게 컴파일된다
val name: String
measure { name = loadName() }     // val 을 람다 안에서 초기화
println(name)                     // '반드시 한 번' 실행됨을 컴파일러가 안다

표준 라이브러리가 쓰는 곳

함수계약
isNullOrEmpty()false 면 non-null
isNullOrBlank()false 면 non-null
requireNotNull()반환 후 non-null
checkNotNull()반환 후 non-null
let·run·apply·also람다가 정확히 한 번 실행
require()·check()반환하면 조건이 참
아직 실험 기능입니다(@OptIn(ExperimentalContracts::class) 필요). 그리고 컴파일러가 계약의 진위를 검증하지 않습니다 — 거짓말을 적으면 잘못된 스마트 캐스트가 만들어져 런타임에 터집니다.
실무에서 쓰는 자리 — 팀 공용 검증 유틸(isBlankOrNull, hasValue)에 계약을 달아 두면 호출부 전부에서 !!가 사라집니다. 라이브러리를 만들 때 특히 값을 합니다.
ADV 12

리플렉션 — 실행 중에 타입 들여다보기

스프링의 @Autowired, Jackson의 JSON 매핑, JPA의 엔티티 스캔 — 전부 리플렉션입니다. 코틀린은 KClass라는 자체 API를 두고 자바 리플렉션과 오갈 수 있게 했습니다.

의존성 — 코틀린 리플렉션은 별도 라이브러리입니다: implementation(kotlin("reflect")) (약 2.8MB — 안드로이드에서는 신중히)

KClass · 자바 Class 오가기

import kotlin.reflect.full.*

data class User(val id: Long, val name: String, val email: String?)

val kc = User::class                 // KClass<User>
val jc = User::class.java            // Class<User>   ← 자바 API 에 넘길 때
val back = jc.kotlin                 // 다시 KClass

println(kc.simpleName)               // User
println(kc.qualifiedName)            // com.example.User
println(kc.isData)                   // true
println(kc.memberProperties.map { it.name })   // [email, id, name]

// 인스턴스에서
val u = User(1, "김", null)
println(u::class.simpleName)         // User

// 프로퍼티 값 읽기
kc.memberProperties.forEach { p ->
    println("${p.name} = ${p.get(u)}")
}

프로퍼티 참조 · 애너테이션

// 프로퍼티 참조 — 리플렉션 없이도 이름을 얻는다
val ref = User::name
println(ref.name)                    // name
println(ref.get(u))                  // 김

// 커스텀 애너테이션 만들고 읽기
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)     // ← 런타임에 읽으려면 필수
annotation class Masked(val keep: Int = 2)

data class Account(
    @Masked(keep = 4) val cardNo: String,
    val owner: String,
)

fun mask(obj: Any): Map<String, Any?> =
    obj::class.memberProperties.associate { p ->
        val ann = p.annotations.filterIsInstance<Masked>().firstOrNull()
        val v = p.getter.call(obj)
        p.name to if (ann != null && v is String)
            v.takeLast(ann.keep).padStart(v.length, '*') else v
    }

println(mask(Account("1234567812345678", "김현우")))
// {cardNo=************5678, owner=김현우}

쓰기 전에 알아야 할 세 가지

① 느리다

직접 호출 대비 수십~수백 배 느립니다. 요청마다 도는 경로에서는 결과를 캐시하세요 (스프링·Jackson도 전부 캐시합니다).

② 컴파일러가 못 지킨다

이름으로 접근하므로 이름을 바꾸면 런타임에 터집니다. 가능하면 User::name 같은 참조를 쓰세요 — 리네임 리팩터링이 따라옵니다.

③ 네이티브에서 막힌다

GraalVM 네이티브 이미지·안드로이드 R8에서는 리플렉션 대상을 미리 등록해야 합니다. 빌드 시점 생성(KSP)이 대안입니다 — 다음 섹션.

ADV 13

애너테이션 · KSP — 빌드 때 코드 만들기

Room·Hilt·Moshi·kotlinx.serialization은 전부 빌드 시점에 코드를 생성합니다. 리플렉션 대신 실제 코드가 생기므로 빠르고, 네이티브 빌드에서도 안전합니다.

두 가지 메타프로그래밍 — 언제 일을 하느냐가 다르다 리플렉션 (실행 시점) 앱 기동 클래스 정보 읽고 분석 느림 · 네이티브에서 등록 필요 KSP (빌드 시점) 컴파일 소스 분석 → .kt 생성 실행 비용 0 · 컴파일러가 검증 Room · Hilt · Moshi · kotlinx.serialization 이 이 방식이다
KSP는 KAPT의 후계자입니다 — 자바 스텁을 만들지 않아 빌드가 대략 두 배 빠릅니다.

애너테이션 정의 — 세 가지를 정한다

@Target(                            // ① 어디에 붙일 수 있나
    AnnotationTarget.CLASS,
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY,
)
@Retention(AnnotationRetention.RUNTIME)  // ② 언제까지 남나
// SOURCE: 컴파일하면 사라짐 (린트용)
// BINARY: 클래스 파일에 남지만 리플렉션 불가
// RUNTIME: 리플렉션으로 읽을 수 있음 (기본값)
@MustBeDocumented
annotation class Audited(val action: String = "")

@Audited("주문생성")
fun createOrder() { }

// ③ 사용 지점 지정 — 코틀린은 프로퍼티가 여러 요소로 컴파일된다
class Dto(
    @field:NotNull       // 필드에
    @get:JsonProperty    // getter 에
    @param:JsonProperty  // 생성자 파라미터에
    val name: String,
)
사용 지점(@field: · @get:)은 실무에서 자주 문제가 됩니다. 검증 애너테이션이 안 먹거나 Jackson 매핑이 어긋나면 대개 여기가 원인입니다.

KSP 설정과 실제 프로세서

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.2.0"
    id("com.google.devtools.ksp") version "2.2.0-2.0.2"
}
dependencies {
    implementation("androidx.room:room-runtime:2.7.0")
    ksp("androidx.room:room-compiler:2.7.0")   // ← annotationProcessor 대신 ksp
}

// 프로세서를 직접 만드는 뼈대 (라이브러리 제작자용)
class MyProcessor(private val gen: CodeGenerator) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        resolver.getSymbolsWithAnnotation("com.example.Audited")
            .filterIsInstance<KSFunctionDeclaration>()
            .forEach { fn ->
                gen.createNewFile(Dependencies(false), "gen", "${fn.simpleName.asString()}Log")
                    .writer().use { it.write("// 생성된 코드") }
            }
        return emptyList()
    }
}
방식특징 · 언제
KSP코틀린 전용 · 빠름 · 새 프로젝트의 기본 선택
KAPT자바 애너테이션 프로세서 재사용 · 느림 · 레거시(유지보수 모드)
컴파일러 플러그인allopen · noarg · serialization · Compose — 문법 자체를 바꾼다
리플렉션동적이지만 느리고 네이티브에 약함
ADV 14

함수형 스타일

코틀린은 순수 함수형 언어가 아니지만, 불변 데이터 + 함수 합성 + 값으로서의 실패라는 핵심 도구는 전부 갖고 있습니다. 실무에서 어디까지 가져다 쓰면 이득인지가 이 절의 주제입니다.

함수 합성 · 부분 적용 · 커링

// 합성 — 표준 라이브러리에는 없으니 직접 만든다 (두 줄이면 된다)
infix fun <A, B, C> ((A) -> B).then(g: (B) -> C): (A) -> C = { a -> g(this(a)) }

val trim: (String) -> String = String::trim
val upper: (String) -> String = String::uppercase
val clean = trim then upper
println(clean("  hi  "))        // HI

// 부분 적용 — 인자를 미리 채운 새 함수
fun log(level: String, tag: String, msg: String) = println("[$level][$tag] $msg")
val debug: (String, String) -> Unit = { tag, msg -> log("DEBUG", tag, msg) }
debug("Order", "생성됨")

// 커링
fun sum3(a: Int) = { b: Int -> { c: Int -> a + b + c } }
println(sum3(1)(2)(3))          // 6

// 메모이제이션
fun <A, R> ((A) -> R).memoize(): (A) -> R {
    val cache = mutableMapOf<A, R>()
    return { a -> cache.getOrPut(a) { this(a) } }
}
val slowSquare = { n: Int -> Thread.sleep(100); n * n }.memoize()

불변 데이터 다루기

data class Address(val city: String, val zip: String)
data class User(val name: String, val address: Address, val tags: List<String>)

val u = User("김", Address("서울", "01234"), listOf("vip"))

// 중첩 객체 수정 — copy 를 겹쳐 쓴다
val moved = u.copy(address = u.address.copy(city = "부산"))

// 리스트 수정도 새 리스트로
val tagged = u.copy(tags = u.tags + "신규")
val untagged = u.copy(tags = u.tags - "vip")

// 깊이가 3단을 넘어가면 확장 함수로 렌즈를 만든다
fun User.withCity(city: String) = copy(address = address.copy(city = city))
val m2 = u.withCity("대구")
불변이 느리지 않나요 — 대부분의 서버 코드에서 객체 하나 더 만드는 비용은 DB 왕복 하나에 비하면 없는 것과 같습니다. 동시성 버그가 사라지는 이득이 훨씬 큽니다. 정말 뜨거운 루프에서만 가변을 국소적으로 쓰세요.

실패를 값으로 — 세 가지 선택지

① nullable

fun find(id: Long): User? = repo.get(id)

val name = find(1)?.name ?: "없음"

실패 이유가 하나뿐일 때. 가장 가볍고 코틀린답습니다.

② sealed 결과 타입

sealed interface Res<out T> {
    data class Ok<T>(val v: T) : Res<T>
    data class Err(val e: AppError) : Res<Nothing>
}

실패 종류가 여럿이고 호출부가 구분해야 할 때. 실무 기본값입니다.

③ Arrow Either

// arrow-kt 라이브러리
val r: Either<Err, User> = either {
    val u = find(id).bind()
    validate(u).bind()
    u
}

연산을 여러 번 이어 붙일 때 편합니다. 팀 학습 비용을 고려해 도입하세요.

실무에서 권하는 선 — ① 불변 데이터(val · data class · 읽기 전용 컬렉션)와 ② 순수 함수로 도메인 로직 분리(입출력은 바깥에서)까지는 거의 언제나 이득입니다. 모나드·타입클래스까지 밀어붙이는 것은 팀 전체가 합의했을 때만 — 혼자 쓰면 리뷰가 멈춥니다.
ADV 15

표준 라이브러리 지도

“이거 이미 있지 않을까?”의 답은 대개 있습니다. 직접 만들기 전에 여기서 한 번 찾아보세요 — 패키지별로 무엇이 있는지 정리했습니다.

패키지 · 모듈들어 있는 것
kotlin기본 타입 · let/run/apply · require/check · Result · TODO() · repeat
kotlin.collectionsList·Set·Map 과 그 위의 수백 개 확장 함수
kotlin.text문자열 · Regex · StringBuilder · Charsets
kotlin.ioFile.readText · useLines · println · readln
kotlin.sequencesSequence · generateSequence · sequence { yield }
kotlin.mathabs·pow·sqrt·roundToInt·PI
kotlin.randomRandom.nextInt · list.random() · shuffled()
kotlin.timeDuration · 3.seconds · measureTime · TimeSource
kotlin.propertiesDelegates.observable · vetoable · notNull
kotlin.reflectKClass · KProperty (별도 의존성 필요)
kotlinx.coroutines코루틴 · Flow (별도 라이브러리 — 코루틴 탭)
kotlinx.serializationJSON 직렬화 (컴파일러 플러그인 — 실전 도구 탭)
kotlinx.datetime멀티플랫폼 날짜·시간 (JVM 전용이면 java.time이 낫다)

알아 두면 편한 것들

// 시간 재기 (kotlin.time)
val elapsed = measureTime { heavyWork() }
println(elapsed)                       // 1.234s
val (result, t) = measureTimedValue { compute() }

// Duration
val timeout = 3.seconds + 500.milliseconds
delay(timeout)                         // 코루틴에서

// 랜덤
val pick = listOf("a","b","c").random()
val shuffled = (1..10).shuffled()
Random(42).nextInt(1, 100)             // 시드 고정 (테스트에 유용)

// 범위 보정
val page = input.coerceIn(1, 100)
val safe = value.coerceAtLeast(0)

// 반복
repeat(3) { i -> println(i) }

// 아직 안 만든 자리 — 컴파일은 되고 실행하면 예외
fun todo(): String = TODO("나중에 구현")

직접 만들기 전에 찾는 요령

  • IntelliJ에서 타입 뒤에 점을 찍고 기다리세요 — 그 타입의 확장 함수가 전부 나옵니다
  • xxxOrNull, xxxIndexed, xxxNotNull, xxxTo 접미사 규칙을 알면 이름을 추측할 수 있습니다
  • mapTo(destination)처럼 To로 끝나는 것은 결과를 기존 컬렉션에 담는 버전입니다 (할당을 줄일 때)
  • 표준 라이브러리 소스는 IntelliJ에서 Ctrl+B로 바로 읽힙니다 — 가장 좋은 코틀린 교재입니다
자바의 Apache Commons·Guava가 하던 일의 8할이 코틀린 표준 라이브러리에 있습니다. StringUtils.isEmpty 같은 걸 찾기 전에 isEmpty()·isBlank()를 보세요.
ADV 16

관용구 · 흔한 실수

코드 리뷰에서 반복적으로 지적되는 것들을 모았습니다. 문법을 아는 것과 “코틀린답게” 쓰는 것은 다른 문제입니다.

자주 쓰는 관용구

// 1) 엘비스 + return / throw — 이른 종료
val user = repo.find(id) ?: return null
val order = repo.find(id) ?: throw NotFoundException(id)

// 2) 널 안전 체인 + 기본값
val city = user?.address?.city ?: "미상"

// 3) when 을 함수 본문으로
fun toLabel(s: Status) = when (s) { ... }

// 4) apply 로 만들면서 설정
val client = HttpClient().apply { timeout = 3000 }

// 5) also 로 로그를 끼우기
return repo.save(o).also { log.info("저장 {}", it.id) }

// 6) takeIf 로 조건부 값
val discount = amount.takeIf { it > 100_000 }?.times(0.1) ?: 0.0

// 7) 구조 분해로 두 값 받기
val (min, max) = list.min() to list.max()

// 8) 컬렉션이 비었을 때 처리
val first = list.firstOrNull() ?: return emptyList()

// 9) 문자열 비었는지 — isBlank 를 쓰자 (공백만 있는 경우 포함)
if (input.isNotBlank()) { }

// 10) 범위 검사
if (age in 0..150) { }
if (status in setOf("NEW", "PAID")) { }

흔한 실수 — 자바 습관에서 오는 것들

이렇게 쓰고 있다면이렇게 바꾸세요
if (s != null && s.isNotEmpty())if (!s.isNullOrEmpty())
list.size > 0list.isNotEmpty()
list.filter { }.sizelist.count { }
list.filter { }.firstOrNull()list.firstOrNull { }
list.map { }.filterNotNull()list.mapNotNull { }
map.containsKey(k) && map[k]!!map[k]?.let { }
StringBuilder() 직접 조립buildString { } · joinToString()
getter/setter 를 손으로프로퍼티 · private set
Optional<T> 반환T?
유틸 클래스 object Utils최상위 함수 · 확장 함수
!! 로 컴파일 에러 지우기?: · ?.let · 이른 return
모든 클래스에 data동등성 의미가 있을 때만 (엔티티는 제외)
모든 곳에 lateinit var생성자 주입 · by lazy
JPA 엔티티를 data class로일반 class + id 기반 equals

성능 관련 오해 셋

  • “코틀린은 자바보다 느리다” — 같은 바이트코드로 갑니다. 차이가 나는 곳은 람다 객체·박싱·컬렉션 복사이고, 전부 피할 수 있습니다
  • “data class 는 무겁다” — 자바에서 손으로 쓰던 equals·hashCode와 같은 코드입니다
  • “전부 asSequence 로 바꾸면 빠르다” — 작은 컬렉션에서는 오히려 느립니다. 측정하고 바꾸세요

팀 규칙으로 정해 두면 좋은 것

  • !! 금지 (detekt 규칙으로 강제 가능)
  • public API 는 반환 타입 명시
  • Boolean 인자는 이름 붙여 호출
  • 컬렉션 반환은 읽기 전용 타입으로
  • 도메인 식별자는 value class
  • ktlint + detekt를 CI 에 넣기 — 실전 도구 탭에서 설정 방법을 다룹니다
다음 탭 예고 — 코루틴에서는 suspend가 실제로 무엇으로 컴파일되는지부터 구조적 동시성, Flow, 채널, 디스패처, 취소와 예외 처리, 테스트까지 다룹니다. 이 탭에서 본 inline·수신 객체 람다·시퀀스가 그대로 재료로 쓰입니다.
SUSPEND · 구조적 동시성 · FLOW · 채널 · 취소

코루틴은
“가벼운 스레드”가
아니다

코루틴을 스레드의 값싼 대체품으로만 이해하면 취소·예외·스코프에서 반드시 막힙니다. 본질은 “함수를 중간에 멈췄다가 이어서 실행하는 능력”과 “작업의 수명을 트리로 묶는 규칙” 두 가지입니다. 이 탭은 그 두 가지를 컴파일 결과와 실행 순서까지 보면서 익힙니다.

20SECTIONS
10만동시 코루틴도 가능
0콜백 중첩
1취소 규칙 (협조적)
CORO 01

왜 코루틴인가

서버가 느려지는 가장 흔한 이유는 계산이 아니라 기다림입니다 — DB 응답, 외부 API, 파일. 스레드는 기다리는 동안 통째로 묶여 있고, 코루틴은 그 자리를 비워 줍니다.

같은 스레드 8개로 요청을 처리할 때 — 기다리는 시간에 무슨 일이 일어나나 스레드 (블로킹) 요청 도착 DB 응답 대기 — 스레드가 묶여 있다 (50ms) 응답 반환 스레드 8개 = 동시 8건. 9번째 요청은 큐에서 대기 — 톰캣 스레드 고갈이 이것이다 코루틴 (논블로킹) 요청 도착 suspend — 반납 그 스레드는 다른 요청 처리 재개 → 응답 스레드 8개로 동시 수천 건 — 기다리는 코루틴은 힙에 객체로만 남는다 코루틴 하나의 비용 ≈ 수백 바이트 · 스레드 하나 ≈ 1MB 스택
핵심은 “빠르다”가 아니라 기다리는 동안 스레드를 붙잡지 않는다입니다.

콜백 지옥에서 나오는 길

// 콜백 — 세 단계만 되어도 읽기 어렵다
fun loadProfile(id: Long, cb: (Profile?) -> Unit) {
    api.getUser(id) { user ->
        if (user == null) { cb(null); return@getUser }
        api.getOrders(user.id) { orders ->
            api.getPoint(user.id) { point ->
                cb(Profile(user, orders, point))   // 에러 처리는 어디에?
            }
        }
    }
}

// 코루틴 — 동기 코드처럼 읽힌다
suspend fun loadProfile(id: Long): Profile {
    val user = api.getUser(id) ?: error("사용자 없음")
    val orders = api.getOrders(user.id)     // 여기서 '기다린다' (스레드는 안 묶임)
    val point = api.getPoint(user.id)
    return Profile(user, orders, point)
}
// try-catch 도 그냥 통한다. 취소도 자동으로 전파된다.

다른 방식과의 비교

방식특징
스레드직관적이지만 개수가 곧 한계. 컨텍스트 스위칭 비용
CompletableFuture논블로킹이지만 thenCompose 체인이 길어지고 취소·예외가 어렵다
Reactor (WebFlux)강력하지만 연산자를 배워야 하고 디버깅이 어렵다
코루틴순차 코드 모양 유지 · 취소가 구조적 · 스택 트레이스가 읽힌다
가상 스레드(Java 21)기존 블로킹 코드를 그대로 쓴다. 대신 구조적 동시성·Flow가 없다
가상 스레드가 나왔는데 코루틴이 필요한가 — 필요합니다. 가상 스레드는 “블로킹을 싸게” 만들지만, 코루틴이 주는 취소 전파 · 스코프 · Flow(백프레셔 있는 스트림)는 대체하지 못합니다. 안드로이드에는 애초에 가상 스레드가 없습니다.
의존성 — implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") · 안드로이드는 -android, 스프링은 -reactor를 추가로 씁니다.
CORO 02

suspend 함수

suspend는 “이 함수는 중간에 멈췄다가 이어서 실행될 수 있다”는 표시입니다. 멈추는 동안 스레드를 점유하지 않는 것이 전부이고, 나머지는 컴파일러가 처리합니다.

// suspend 를 붙이면 세 가지가 달라진다
suspend fun fetchUser(id: Long): User {
    delay(100)                    // ① suspend 함수를 부를 수 있다
    return api.get("/users/$id")  // ② 기다리는 동안 스레드를 반납한다
}                                 // ③ 코루틴 안에서만 부를 수 있다

fun main() {
    // fetchUser(1)               // ❌ Suspend function should be called only from a coroutine body
    runBlocking {                 // 코루틴을 시작하는 다리 (main 함수·테스트에서만)
        val u = fetchUser(1)      // ✅
        println(u)
    }
}

// main 자체를 suspend 로 만들 수도 있다
suspend fun main() {
    println(fetchUser(1))
}

delay 와 Thread.sleep 의 차이

runBlocking {
    val t = System.currentTimeMillis()
    // 코루틴 10만 개가 1초씩 기다린다
    val jobs = List(100_000) {
        launch { delay(1000) }        // 스레드를 반납하고 대기
    }
    jobs.joinAll()
    println("${System.currentTimeMillis() - t}ms")   // 약 1초대
}

// Thread.sleep(1000) 이었다면?
// runBlocking 안의 launch 는 runBlocking 스레드 하나를 같이 쓴다 → 한 번에 하나씩 자서 약 10만 초.
// (launch(Dispatchers.Default) 로 띄워도 코어 수만큼만 동시에 자고 나머지는 줄을 선다)
suspend 함수 안에서 블로킹 호출을 하면 이득이 통째로 사라집니다. JDBC·파일 IO·Thread.sleep은 withContext(Dispatchers.IO)로 감싸야 합니다 (17 · 블로킹 코드와 섞기).

무엇이 “중단점(suspension point)”인가

suspend fun work() {
    println("A")            // 여기까지 스레드 T1
    delay(100)              // ← 중단점. 여기서 반납
    println("B")            // 재개 — T1 이 아닐 수도 있다!
    val x = fetch()         // ← 중단점 (suspend 함수 호출)
    println("C $x")
}

// 중단점이 되는 것
// · delay() · yield()
// · 다른 suspend 함수 호출
// · Channel.send/receive · Flow.collect
// · Job.join() · Deferred.await() · Mutex.lock()

// 중단점이 아닌 것 (그냥 실행된다)
// · 일반 함수 호출 · 반복문 · 계산

재개될 때 다른 스레드일 수 있다는 점이 중요합니다 — ThreadLocal에 담아 둔 값(MDC·시큐리티 컨텍스트)이 사라지는 원인입니다 (11 · 코루틴 컨텍스트).

suspend 함수를 만드는 원칙

원칙이유
메인 세이프하게 만든다어느 스레드에서 불러도 안전해야 한다 — 무거운 일은 함수 안에서 withContext로 옮긴다
호출부에 스레드 전환을 요구하지 않는다withContext(IO) { repo.find() }를 호출부마다 쓰게 하면 실수가 난다
취소를 존중한다긴 루프에는 ensureActive()를 넣는다 (07)
결과를 돌려준다콜백을 인자로 받지 않는다 — 그럴 거면 suspend 로 만든 의미가 없다
Deferred 를 반환하지 않는다공개 API 는 suspend fun f(): T 가 자연스럽다. 병렬은 호출부가 정한다
// ❌ 호출부가 스레드를 신경 써야 한다
suspend fun loadFile(path: String): String = File(path).readText()   // 블로킹!

// ✅ 함수가 스스로 안전하다 (main-safe)
suspend fun loadFile(path: String): String = withContext(Dispatchers.IO) {
    File(path).readText()
}

// 호출부는 아무 데서나 편하게 부른다
viewModelScope.launch {           // 안드로이드 메인 스레드
    val text = loadFile(path)     // 알아서 IO 로 갔다 돌아온다
    textView.text = text          // 다시 메인 스레드
}
CORO 03

코루틴 빌더 — launch · async · runBlocking

코루틴을 시작하는 함수들입니다. 넷만 알면 되고, 언제 어느 것을 쓰는지가 실무에서 갈리는 지점입니다.

코루틴을 시작하는 세 가지 문 — 무엇을 돌려주고 예외가 어디로 가나 launch 돌려주는 것 · Job 결과값은 없다 — 불만 지핀다 예외 → 즉시 부모로 전파 쓰는 곳 · 화면 갱신 · 로그 적재 "실행만 하면 되는 일" async 돌려주는 것 · Deferred<T> await() 로 결과를 받는다 예외 → await 하는 순간 터진다 쓰는 곳 · API 두 개를 동시에 "결과가 필요한 일" runBlocking 돌려주는 것 · 그 값 자체 끝날 때까지 스레드를 막는다 예외 → 그 자리에서 바로 쓰는 곳 · main 과 테스트뿐 "코루틴 세계로 들어가는 문" 운영 코드 한가운데서 runBlocking 을 부르면 그 요청 스레드가 통째로 멈춥니다 — 서버에서 가장 흔한 사고입니다.
고르는 기준은 하나입니다 — 결과값이 필요하면 async, 아니면 launch. runBlocking 은 경계에서만.
빌더반환쓰는 자리
launchJob결과가 필요 없는 작업 시작 (발사 후 잊기)
asyncDeferred<T>결과가 필요한 병렬 작업 — await()로 받는다
runBlockingT다리 — main 함수·테스트·레거시 진입점에서만
coroutineScopeTsuspend 함수 안에서 자식들을 묶어 기다린다
withContextT스레드(디스패처)를 바꿔서 실행하고 결과를 받는다
supervisorScopeT자식 하나가 실패해도 형제를 죽이지 않는다 (09)
fun main() = runBlocking {          // ① 진입점: 코루틴 세계로 들어가는 다리
    println("시작")

    // ② launch — 결과가 필요 없다. 즉시 반환하고 백그라운드로 진행
    val job = launch {
        delay(500)
        println("알림 전송 완료")
    }

    // ③ async — 결과를 받는다. 둘을 '동시에' 실행
    val userDeferred = async { fetchUser(1) }       // 여기서 바로 시작된다
    val ordersDeferred = async { fetchOrders(1) }
    val user = userDeferred.await()                 // 여기서 기다린다
    val orders = ordersDeferred.await()
    println("${user.name}: ${orders.size}건")

    job.join()                                       // launch 가 끝나기를 기다림
    println("끝")
}

순차 vs 병렬 — 실행 시간이 갈린다

suspend fun a(): Int { delay(1000); return 1 }
suspend fun b(): Int { delay(1000); return 2 }

// 순차 — 2초
suspend fun sequential(): Int {
    val x = a()      // 1초 기다림
    val y = b()      // 또 1초
    return x + y
}

// 병렬 — 1초
suspend fun parallel(): Int = coroutineScope {
    val x = async { a() }      // 시작만 하고 넘어간다
    val y = async { b() }
    x.await() + y.await()
}

// ⚠️ 흔한 실수 — 이건 여전히 2초다
suspend fun wrong(): Int = coroutineScope {
    val x = async { a() }.await()   // 시작하자마자 기다린다
    val y = async { b() }.await()
    x + y
}

runBlocking — 쓰면 안 되는 자리

// ❌ 컨트롤러·서비스 안에서 runBlocking
@GetMapping("/users/{id}")
fun get(@PathVariable id: Long): User = runBlocking {   // 스레드를 묶어 버린다
    service.find(id)
}

// ✅ 스프링에서는 컨트롤러 함수 자체를 suspend 로
@GetMapping("/users/{id}")
suspend fun get(@PathVariable id: Long): User = service.find(id)

// runBlocking 이 맞는 자리
// · fun main() = runBlocking { }
// · 테스트 (지금은 runTest 가 더 낫다)
// · 코루틴을 모르는 레거시 프레임워크와의 경계 (배치 잡 등)
runBlocking은 현재 스레드를 막습니다. 웹 요청 처리 스레드에서 부르면 코루틴을 쓰는 의미가 완전히 사라집니다 — 오히려 스레드 풀이 더 빨리 고갈됩니다.

시작 방식 — CoroutineStart

// 기본: DEFAULT — 즉시 스케줄된다
val j1 = launch { work() }

// LAZY — start() 나 await() 를 부를 때까지 시작하지 않는다
val j2 = launch(start = CoroutineStart.LAZY) { work() }
j2.start()

// ATOMIC — 시작 전에는 취소되지 않는다 (자원 정리를 보장해야 할 때)
val j3 = launch(start = CoroutineStart.ATOMIC) { openAndClose() }

// UNDISPATCHED — 첫 중단점까지는 현재 스레드에서 즉시 실행
val j4 = launch(start = CoroutineStart.UNDISPATCHED) { println("바로 찍힘") }
CORO 04

구조적 동시성

코루틴의 진짜 발명품입니다. 모든 코루틴은 부모를 가지고, 부모는 자식이 다 끝날 때까지 끝나지 않으며, 부모가 취소되면 자식도 전부 취소됩니다. “실행 중인지 아무도 모르는 작업”이 원천적으로 생기지 않습니다.

Job 트리 — 수명이 부모에 묶인다 부모 Job (스코프) 자식이 다 끝나야 완료 자식 1 완료 자식 2 — 예외 발생 부모로 전파 자식 3 진행 중 자식 3도 취소된다 CancellationException ① 자식 2의 예외가 부모로 올라간다 ② 부모가 나머지 자식을 전부 취소한다 ③ 그래서 “아무도 모르게 살아남은 작업”이 없다 — 이것이 구조적 동시성
형제를 죽이고 싶지 않다면 supervisorScope를 씁니다 — 09 · 예외 처리에서 다룹니다.

coroutineScope — 다 끝나야 반환한다

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val users = async { userApi.count() }
    val orders = async { orderApi.today() }
    val alerts = async { alertApi.pending() }

    // 셋이 모두 끝나야 이 줄이 실행된다
    Dashboard(users.await(), orders.await(), alerts.await())
}
// 이 함수가 반환되는 순간 '이 안에서 시작한 코루틴은 하나도 남아 있지 않다'
// 하나가 실패하면 나머지도 취소되고 예외가 위로 던져진다
이 보장이 왜 중요한가 — 함수가 반환됐는데 백그라운드에서 작업이 계속 도는 상황이 없어집니다. 자원 정리·로그 순서·테스트 안정성이 전부 여기서 나옵니다.

Job — 코루틴의 손잡이

val job = scope.launch { work() }

job.isActive       // 실행 중인가
job.isCompleted    // 끝났는가 (성공·실패·취소 모두)
job.isCancelled    // 취소됐는가
job.cancel()       // 취소 요청
job.join()         // 끝날 때까지 기다림 (suspend)
job.cancelAndJoin()// 취소하고 정리까지 기다림

job.invokeOnCompletion { cause ->
    if (cause != null) log.warn("실패/취소: $cause")
    else log.info("정상 완료")
}

// 자식 목록도 볼 수 있다
job.children.forEach { println(it) }

GlobalScope — 왜 쓰지 말라고 하나

GlobalScope — 부모가 없다
fun onButtonClick() {
    GlobalScope.launch {         // ⚠️ @DelicateCoroutinesApi
        val data = api.load()    // 화면이 닫혀도 계속 돈다
        textView.text = data     // 💥 사라진 뷰에 접근
    }
}

// 문제
// · 취소할 방법이 없다 (누수)
// · 예외가 어디로도 안 간다
// · 테스트에서 끝날 때까지 기다릴 수 없다
수명이 있는 스코프를 쓴다
// 안드로이드 — 화면이 사라지면 자동 취소
viewModelScope.launch { load() }
lifecycleScope.launch { load() }

// 서버 — 컴포넌트 수명에 묶는다
@Service
class Worker : DisposableBean {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    fun start() = scope.launch { loop() }
    override fun destroy() = scope.cancel()   // 종료 시 전부 취소
}

// suspend 함수 안에서는 coroutineScope 를 쓴다
예외적으로 GlobalScope 가 맞는 경우 — 애플리케이션 전체 수명과 같은 진짜 전역 작업(캐시 예열 · 백그라운드 로그 전송)뿐입니다. 그마저도 이름 붙인 애플리케이션 스코프를 하나 만들어 쓰는 편이 낫습니다 — 종료할 때 정리할 방법이 생기기 때문입니다.
CORO 05

CoroutineScope · 생명주기

스코프는 “이 코루틴들이 언제 죽어야 하는가”를 정하는 그릇입니다. 화면이 닫히면, 요청이 끝나면, 서버가 내려가면 — 그 시점에 스코프를 취소하면 전부 정리됩니다.

// 스코프 = Job + 디스패처(+ 이름 · 예외 핸들러 …)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + CoroutineName("worker"))

scope.launch { work1() }
scope.launch { work2() }

scope.cancel()          // 안에서 시작한 코루틴이 전부 취소된다

// ⚠️ 한 번 cancel 한 스코프는 재사용할 수 없다 — 다시 만들어야 한다

플랫폼이 주는 스코프

스코프수명
viewModelScopeViewModel 이 사라질 때 취소 (안드로이드 · 가장 많이 씀)
lifecycleScope액티비티·프래그먼트 파괴 시
rememberCoroutineScope()Compose — 컴포저블이 화면을 떠날 때
스프링 suspend 컨트롤러요청 하나 — 클라이언트가 끊으면 취소된다
runTest테스트 하나
대부분의 앱 코드에서는 스코프를 직접 만들 일이 없습니다. 플랫폼이 주는 스코프를 쓰거나, suspend 함수 안에서 coroutineScope를 쓰면 됩니다.

직접 만들어야 할 때 — 서버 컴포넌트

@Component
class OutboxPublisher(
    private val repo: OutboxRepo,
) : DisposableBean {

    // SupervisorJob: 작업 하나가 실패해도 다른 작업이 죽지 않게
    private val scope = CoroutineScope(
        SupervisorJob() + Dispatchers.IO + CoroutineName("outbox")
    )

    @PostConstruct
    fun start() {
        scope.launch {
            while (isActive) {                  // 취소되면 루프가 끝난다
                runCatching { publishBatch() }
                    .onFailure { log.error("발행 실패", it) }
                delay(1000)
            }
        }
    }

    override fun destroy() {
        scope.cancel()      // 애플리케이션 종료 시 정리
    }
}
클래스가 CoroutineScope를 구현(: CoroutineScope)하는 방식은 권하지 않습니다. 그 클래스의 모든 공개 메서드에서 launch가 보이게 되어, 밖에서 임의로 코루틴을 띄울 수 있습니다. 스코프는 private 필드로 갖는 편이 안전합니다.
CORO 06

디스패처 — 어느 스레드에서 돌 것인가

코루틴은 스레드가 아니지만 결국 어떤 스레드 위에서 돕니다. 그 스레드를 고르는 것이 디스패처이고, 잘못 고르면 성능이 통째로 무너집니다.

같은 코루틴이라도 '어느 스레드에서 도는지'는 디스패처가 정한다 코루틴 본문 withContext(...) Dispatchers.Default CPU 코어 수만큼(최소 2) — 계산 · 정렬 · 파싱 · 이미지 처리 Dispatchers.IO 기본 64개까지 늘어남 — 파일 · JDBC · 블로킹 HTTP 를 여기로 격리 Dispatchers.Main 안드로이드 UI 스레드 하나 — 서버에는 아예 없다(의존성 필요) Dispatchers.Unconfined 부른 스레드를 그대로 씀 — 라이브러리 내부용, 앱 코드에서는 쓰지 말 것
Default 와 IO 는 스레드를 공유합니다 — 둘 사이 withContext 는 스레드 교체 없이 넘어가 비용이 거의 없습니다.
디스패처스레드 수쓰는 일
Dispatchers.DefaultCPU 코어 수 (최소 2)CPU 작업 — 정렬·파싱·이미지 처리·암호화
Dispatchers.IO기본 64개까지 확장블로킹 IO — JDBC·파일·레거시 HTTP 클라이언트
Dispatchers.Main1 (UI 스레드)안드로이드 UI 갱신 (별도 의존성 필요)
Dispatchers.Unconfined고정 없음거의 쓰지 않는다 — 테스트·특수 상황
newFixedThreadPoolContext지정전용 풀이 필요할 때 (닫아 줘야 한다)
asCoroutineDispatcher()—기존 ExecutorService를 디스패처로 변환
suspend fun processImage(file: File): Result {
    // ① 파일 읽기 — 블로킹 IO
    val bytes = withContext(Dispatchers.IO) { file.readBytes() }

    // ② 이미지 변환 — CPU
    val resized = withContext(Dispatchers.Default) { resize(bytes) }

    // ③ 업로드 — 논블로킹 HTTP 라면 디스패처를 안 바꿔도 된다
    val url = uploadClient.put(resized)

    return Result(url)
}

// withContext 는 '그 블록만' 스레드를 바꾸고 결과를 돌려준다.
// 블록이 끝나면 원래 디스패처로 돌아온다.

IO 와 Default 는 스레드를 공유한다

둘은 같은 스레드 풀을 쓰고 한도만 다릅니다. 그래서 Default → IO 전환은 스레드 교체 없이 일어나는 경우가 많아 비용이 작습니다.

// IO 한도를 늘리려면 (기본 64)
// JVM 옵션: -Dkotlinx.coroutines.io.parallelism=200

// 특정 작업만 동시 실행 수를 제한하고 싶을 때
val limitedIo = Dispatchers.IO.limitedParallelism(10)
withContext(limitedIo) { legacyJdbcCall() }
// ↑ DB 커넥션 풀 크기에 맞추는 용도로 아주 유용하다

잘못 고르면 생기는 일

  • Default 에서 블로킹 IO — 코어 수만큼(예: 8개)만 스레드가 있으므로 전체 CPU 작업이 함께 멈춥니다. 가장 흔하고 치명적인 실수
  • IO 에서 무거운 CPU 작업 — 64개 스레드가 CPU를 두고 경쟁해 컨텍스트 스위칭만 늘어납니다
  • Main 에서 무거운 작업(안드로이드) — 화면이 멈추고 ANR
  • 디스패처를 계속 왕복 — 전환마다 스케줄링 비용. 한 번에 묶어서 withContext로 감싸세요
판단 기준 한 줄 — “이 코드가 스레드를 붙잡고 있는가?” 붙잡으면(JDBC · 파일 · Thread.sleep · 동기 HTTP) IO, 계산만 하면 Default, UI 를 만지면 Main입니다.
CORO 07

취소 — 협조적이다

코루틴 취소는 강제 종료가 아닙니다. “그만하라”는 표시를 남길 뿐이고, 코루틴 쪽이 그것을 확인해 줘야 실제로 멈춥니다. 이 성질을 모르면 “취소했는데 계속 도는” 상황을 만나게 됩니다.

취소는 '멈추라는 신호'일 뿐 — 확인하지 않는 코드는 계속 돈다 job.cancel() Job 에 취소 표시를 세운다 isActive = false 자식 Job 으로 아래까지 전파 여기까지는 신호뿐 실행은 아직 멈추지 않았다 실제로 멈추느냐는 코드 모양이 정한다 중단 지점이 있는 코드 delay · yield · 코루틴용 IO 함수 CancellationException 이 던져져 즉시 멈춘다 finally 로 정리 코드가 이어진다 중단 지점이 없는 코드 while (true) { 무거운 계산 } 신호를 확인할 틈이 없어 끝까지 돈다 ensureActive() 를 루프 안에 넣어야 한다 정리 코드가 suspend 함수를 부른다면 withContext(NonCancellable) 안에서 — 아니면 그 정리마저 취소됩니다.
취소가 안 먹는다면 열에 아홉은 중단 지점 없는 루프입니다 — isActive 확인 한 줄이 해결합니다.
취소가 안 되는 코드
val job = launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000_000) {    // 중단점이 없다
        i++                         // 계산만 한다
    }
    println("끝")
}
delay(10)
job.cancelAndJoin()   // 요청은 갔지만…
// 루프는 끝까지 돈다. 취소 상태를 아무도 확인하지 않기 때문
취소를 확인하는 코드
val job = launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000_000) {
        ensureActive()        // 취소됐으면 여기서 예외를 던진다
        i++
    }
}

// 또는
while (isActive) { /* ... */ }        // 조건으로 확인
// 또는 중간에 yield() 를 넣는다 (다른 코루틴에 양보 + 취소 확인)

취소를 알아채는 세 가지 방법

방법동작
ensureActive()취소됐으면 CancellationException을 던진다 (권장)
isActiveBoolean 검사 — 정리 후 정상 종료하고 싶을 때
yield()양보 + 취소 확인. 계산 루프에 넣기 좋다
모든 suspend 함수delay·await 등은 이미 취소를 확인한다
kotlinx.coroutines 의 suspend 함수는 전부 취소를 확인합니다. 그래서 IO 위주 코드는 따로 신경 쓸 필요가 없습니다 — 문제가 되는 건 중단점 없는 긴 계산 루프뿐입니다.

자원 정리 — finally 와 NonCancellable

val job = launch {
    val conn = openConnection()
    try {
        while (isActive) { process(conn) }
    } finally {
        // ⚠️ 취소된 코루틴에서는 suspend 함수를 부를 수 없다
        // conn.closeAsync()      // 즉시 CancellationException

        withContext(NonCancellable) {   // 이 블록만 취소를 무시한다
            conn.closeAsync()           // 정리는 반드시 끝내게
        }
    }
}

// use { } 도 그대로 통한다 (finally 기반)
suspend fun read() = File("a.txt").bufferedReader().use { it.readText() }

CancellationException — 잡으면 안 되는 예외

// ❌ 취소를 삼켜 버린다 — 취소가 먹지 않는 코드가 된다
launch {
    try {
        delay(1000)
    } catch (e: Exception) {          // CancellationException 도 여기 걸린다
        log.error("에러", e)          // 취소인데 에러로 기록되고, 아래로 계속 진행
    }
}

// ✅ 취소는 다시 던진다
launch {
    try {
        delay(1000)
    } catch (e: CancellationException) {
        throw e                        // 반드시 다시 던진다
    } catch (e: Exception) {
        log.error("진짜 에러", e)
    }
}

// runCatching 도 같은 문제가 있다 (17장·runCatching 주의 참고)
// coroutineContext.ensureActive() 를 catch 안에서 부르는 방법도 쓴다
실전에서 이 실수는 매우 흔합니다. 안드로이드에서 “화면을 나갔는데 네트워크 호출이 계속되는” 현상, 서버에서 “클라이언트가 끊었는데 쿼리가 계속 도는” 현상의 대부분이 catch (e: Exception)로 취소를 삼킨 것입니다. detekt 의 SwallowedException·TooGenericExceptionCaught 규칙으로 잡을 수 있습니다.
CORO 08

타임아웃 · 재시도

외부 시스템을 부르는 코드에 타임아웃이 없으면 장애가 그대로 전파됩니다. 코루틴은 타임아웃을 취소로 구현하기 때문에, 시간이 지나면 진짜로 작업이 멈춥니다.

// 시간이 지나면 TimeoutCancellationException 을 던진다
val result = withTimeout(3.seconds) {
    api.slowCall()
}

// 예외 대신 null 을 받는다 — 실무에서 더 자주 쓴다
val result2: Data? = withTimeoutOrNull(3.seconds) {
    api.slowCall()
}
val data = result2 ?: fallbackData()

// 여러 단계에 예산을 나눠 준다 (타임아웃 예산 배분)
suspend fun handle(): Response = withTimeout(5.seconds) {   // 전체 예산
    val user = withTimeout(1.seconds) { userApi.get() }     // 단계별 예산
    val items = withTimeout(2.seconds) { itemApi.list() }
    Response(user, items)
}

재시도 — 지수 백오프

suspend fun <T> retry(
    times: Int = 3,
    initialDelay: Duration = 200.milliseconds,
    maxDelay: Duration = 5.seconds,
    factor: Double = 2.0,
    retryOn: (Throwable) -> Boolean = { it is IOException },
    block: suspend () -> T,
): T {
    var delayTime = initialDelay
    repeat(times - 1) {
        try {
            return block()
        } catch (e: CancellationException) {
            throw e                          // 취소는 재시도하지 않는다
        } catch (e: Throwable) {
            if (!retryOn(e)) throw e         // 재시도해도 소용없는 에러는 즉시 던진다
            log.warn("재시도 예정 {}ms: {}", delayTime, e.message)
        }
        delay(delayTime)
        delayTime = (delayTime * factor).coerceAtMost(maxDelay)
    }
    return block()                           // 마지막 시도는 예외를 그대로 전파
}

// 사용
val user = retry(times = 3) { api.getUser(id) }

주의할 점

  • 타임아웃은 취소로 동작합니다 — 블록 안에 중단점이 없으면 시간이 지나도 안 멈춥니다(07 참고)
  • withTimeout의 예외는 CancellationException의 자식입니다. 바깥에서 catch (e: Exception)으로 잡으면 취소와 구분되지 않습니다
  • 재시도에 지터(무작위 흔들림)를 넣으세요 — 모든 클라이언트가 같은 간격으로 재시도하면 동시에 다시 몰립니다
  • 결제·주문처럼 부작용이 있는 호출은 재시도 전에 멱등키가 있는지 확인해야 합니다
// 지터 추가
val jitter = Random.nextLong(0, delayTime.inWholeMilliseconds / 2)
delay(delayTime.inWholeMilliseconds + jitter)
서버라면 서킷 브레이커까지 — 재시도만 넣으면 장애 시 트래픽이 오히려 늘어납니다. 실패율이 높아지면 아예 호출을 끊는 서킷 브레이커(resilience4j)와 함께 써야 합니다 — 대규모 트래픽 탭에서 코루틴과 함께 쓰는 방법을 다룹니다.
CORO 09

예외 처리

코루틴에서 예외는 부모로 올라가면서 형제를 취소합니다. 이 규칙과 launch·async의 차이를 모르면 “예외가 안 잡힌다” 또는 “왜 다른 작업까지 죽었지”를 겪습니다.

Job vs SupervisorJob — 자식 하나가 실패했을 때 Job (기본) 부모 자식 A 실패 💥 자식 B — 취소됨 실패가 위로 → 부모가 나머지를 전부 취소 → 부모도 실패 SupervisorJob 감독 부모 자식 A 실패 💥 자식 B ✅ 실패가 위로 가지 않는다 → 형제는 계속 실행 대신 A 의 예외는 누군가 처리해야 한다 (핸들러 · try-catch) 화면 여러 개를 동시에 불러올 때처럼 “하나가 실패해도 나머지는 보여 줘야” 하면 supervisorScope
supervisorScope는 부분 실패를 허용하는 자리에 씁니다 — 대시보드·목록 화면이 대표적입니다.

launch — 예외가 즉시 위로 간다

// 예외는 launch 블록 '안'에서 잡아야 한다
scope.launch {
    try {
        risky()
    } catch (e: CancellationException) {
        throw e
    } catch (e: Exception) {
        log.error("실패", e)
    }
}

// ❌ 이렇게는 못 잡는다 — launch 는 즉시 반환하기 때문
try {
    scope.launch { risky() }
} catch (e: Exception) { }      // 절대 안 걸린다

// 스코프 전체의 최후 방어선
val handler = CoroutineExceptionHandler { ctx, e ->
    log.error("처리되지 않은 예외 [${ctx[CoroutineName]}]", e)
}
val scope = CoroutineScope(SupervisorJob() + handler)

async — await 할 때 던져진다

val deferred = scope.async { risky() }   // 여기서는 안 터진다

try {
    deferred.await()                      // 여기서 던져진다
} catch (e: Exception) {
    log.error("실패", e)
}

// ⚠️ 함정 — async 의 예외는 '부모에게도' 전파된다
// await 를 감싸도 부모 스코프가 취소될 수 있다
coroutineScope {
    val a = async { error("실패") }
    try { a.await() } catch (e: Exception) { }   // 잡았지만
    delay(100)                                    // 여기서 이미 취소되어 있다
}

// 부분 실패를 허용하려면 supervisorScope 안에서 async 를 쓴다
supervisorScope {
    val a = async { error("실패") }
    val b = async { ok() }
    val ra = runCatching { a.await() }.getOrNull()   // null
    val rb = b.await()                               // 정상
}

실전 — 부분 실패를 허용하는 대시보드

data class Dashboard(val user: User?, val orders: List<Order>, val ads: List<Ad>)

suspend fun loadDashboard(id: Long): Dashboard = supervisorScope {
    // 필수 데이터 — 실패하면 전체 실패가 맞다
    val userD = async { userApi.get(id) }

    // 부가 데이터 — 실패해도 화면은 떠야 한다
    val ordersD = async { orderApi.list(id) }
    val adsD = async { adApi.list() }

    Dashboard(
        user = userD.await(),                                   // 실패 시 예외 전파
        orders = runCatching { ordersD.await() }.getOrDefault(emptyList()),
        ads = runCatching { adsD.await() }
            .onFailure { log.warn("광고 로딩 실패", it) }
            .getOrDefault(emptyList()),
    )
}
규칙내용
launch 의 예외즉시 부모로 전파 → 형제 취소 → 최상위 핸들러
async 의 예외await() 시점에 던져짐 + 부모에게도 전파
CancellationException특별 취급 — 부모로 전파되지 않고 정상 취소로 처리
CoroutineExceptionHandler최상위 코루틴에만 동작. 자식에 달면 무시된다
supervisorScope자식 실패가 위로 안 감. 단 스코프 자체가 실패하면 자식은 취소
try-catch가장 확실하다 — 코루틴 블록 안에서 감싸기
CORO 10

병렬 패턴 — async 활용

실무에서 코루틴으로 얻는 가장 즉각적인 이득은 N개의 외부 호출을 동시에 보내는 것입니다. 응답 시간이 합계에서 최댓값으로 바뀝니다.

// 1) 고정된 개수 병렬
suspend fun profile(id: Long): Profile = coroutineScope {
    val user = async { userApi.get(id) }
    val orders = async { orderApi.list(id) }
    val point = async { pointApi.get(id) }
    Profile(user.await(), orders.await(), point.await())
}

// 2) 목록을 병렬 처리 — map + async + awaitAll
suspend fun fetchAll(ids: List<Long>): List<User> = coroutineScope {
    ids.map { id -> async { userApi.get(id) } }.awaitAll()
}

// 3) 동시 실행 수 제한 — Semaphore (외부 API 가 429 를 주는 경우 필수)
suspend fun fetchLimited(ids: List<Long>, concurrency: Int = 10): List<User> = coroutineScope {
    val sem = Semaphore(concurrency)
    ids.map { id ->
        async { sem.withPermit { userApi.get(id) } }
    }.awaitAll()
}

// 4) 청크 단위로 나눠 처리 — 순서·부하를 함께 통제
suspend fun fetchChunked(ids: List<Long>): List<User> =
    ids.chunked(100).flatMap { chunk ->
        coroutineScope { chunk.map { async { userApi.get(it) } }.awaitAll() }
    }

// 5) 가장 먼저 오는 하나만 — select
suspend fun fastest(): String = coroutineScope {
    val a = async { mirrorA.fetch() }
    val b = async { mirrorB.fetch() }
    select {
        a.onAwait { it.also { b.cancel() } }
        b.onAwait { it.also { a.cancel() } }
    }
}

부분 실패를 담아 돌려주기

suspend fun fetchAllSafe(ids: List<Long>): Map<Long, Result<User>> =
    supervisorScope {
        ids.associateWith { id ->
            async {
                runCatching { userApi.get(id) }
            }
        }.mapValues { (_, d) -> d.await() }
    }

val results = fetchAllSafe(ids)
val ok = results.values.mapNotNull { it.getOrNull() }
val failed = results.filterValues { it.isFailure }.keys
log.warn("실패한 id: {}", failed)

병렬로 만들면 안 되는 것

  • DB 커넥션을 쓰는 작업 — 커넥션 풀 크기를 넘는 동시 실행은 풀 고갈로 이어집니다. limitedParallelism이나 세마포어로 제한하세요
  • 순서가 있는 작업 — 재고 차감 후 결제처럼 앞이 성공해야 뒤가 의미 있는 흐름
  • 같은 트랜잭션 안 — 스프링 트랜잭션은 스레드에 묶여 있어 다른 코루틴으로 넘어가면 같은 트랜잭션이 아닙니다 (자세한 내용은 Spring Boot 탭)
  • 상대 API 의 rate limit 을 넘는 호출 — 병렬로 보내면 429 폭탄
체감 효과 — 외부 API 3개를 각각 200ms에 부르던 화면이 600ms → 200ms가 됩니다. 코루틴 도입 초기에 가장 먼저 얻는 성과이고, 코드 변경량도 작습니다 (coroutineScope { async … await }로 감싸는 정도).
CORO 11

코루틴 컨텍스트 — 값 전파

코루틴은 실행 중 스레드가 바뀔 수 있습니다. 그래서 ThreadLocal에 담아 둔 traceId·사용자 정보가 사라집니다. 코루틴에는 그 자리를 대신하는 컨텍스트가 있습니다.

// CoroutineContext = 요소들의 집합 (Map 과 비슷하고 + 로 합친다)
val ctx = Dispatchers.IO + CoroutineName("worker") + SupervisorJob() + exceptionHandler

launch(ctx) {
    println(coroutineContext[CoroutineName]?.name)     // worker
    println(coroutineContext[Job])                     // 현재 Job
    println(Thread.currentThread().name)               // DefaultDispatcher-worker-1

    // 자식은 부모 컨텍스트를 상속하고 필요한 것만 덮어쓴다
    launch(Dispatchers.Default) {
        println(coroutineContext[CoroutineName]?.name) // worker (그대로 상속)
    }
}

직접 만드는 컨텍스트 요소

// 요청 단위 정보를 코루틴 전체에 흘려보낸다
data class RequestContext(val traceId: String, val userId: Long?) :
    AbstractCoroutineContextElement(Key) {
    companion object Key : CoroutineContext.Key<RequestContext>
}

// 심는 쪽
suspend fun handle(req: Request) = withContext(RequestContext(req.traceId, req.userId)) {
    service.process()
}

// 읽는 쪽 — 어디서든
suspend fun currentTraceId(): String? =
    coroutineContext[RequestContext]?.traceId

suspend fun audit(action: String) {
    log.info("[{}] {} by {}", currentTraceId(), action, coroutineContext[RequestContext]?.userId)
}

ThreadLocal 을 코루틴으로 잇기

// MDC(로그 컨텍스트)를 코루틴에 따라다니게 만든다
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j")

withContext(MDCContext()) {          // 현재 MDC 를 코루틴 컨텍스트로 복사
    log.info("여기서도 traceId 가 찍힌다")
    launch { log.info("자식에서도 유지") }
}

// 일반 ThreadLocal 도 이을 수 있다
val userThreadLocal = ThreadLocal<String>()
launch(userThreadLocal.asContextElement("김현우")) {
    println(userThreadLocal.get())   // 김현우
    delay(100)                        // 스레드가 바뀌어도
    println(userThreadLocal.get())   // 김현우 — 재개할 때 다시 심어 준다
}
스프링 시큐리티 컨텍스트도 같은 문제를 겪습니다. SecurityContextHolder가 ThreadLocal 기반이라 코루틴 안에서 사라집니다 — Spring Boot 탭에서 해결 방법을 다룹니다.
디버깅 팁 — JVM 옵션 -Dkotlinx.coroutines.debug를 켜면 스레드 이름 뒤에 @coroutine#3 같은 식별자가 붙습니다. CoroutineName("주문처리")를 함께 주면 로그에서 어느 코루틴의 로그인지가 바로 보입니다. 운영 진단에서 큰 차이를 만듭니다.
CORO 12

Flow 기초 — 여러 값을 시간에 걸쳐

suspend 함수는 값을 하나 돌려줍니다. Flow는 값을 여러 개, 시간을 두고 흘려보냅니다 — DB 커서, 서버 이벤트, 사용자 입력, 센서 값이 전부 이 모양입니다.

만들기

// flow 빌더 — 가장 기본
val f1: Flow<Int> = flow {
    for (i in 1..3) {
        delay(100)         // suspend 가 된다 (Sequence 와의 결정적 차이)
        emit(i)            // 값을 흘려보낸다
    }
}

// 이미 있는 값에서
val f2 = flowOf(1, 2, 3)
val f3 = listOf(1, 2, 3).asFlow()

// 콜백 API 를 Flow 로 감싸기 (안드로이드·소켓에서 매우 자주 쓴다)
fun locationFlow(): Flow<Location> = callbackFlow {
    val listener = LocationListener { loc -> trySend(loc) }
    manager.register(listener)
    awaitClose { manager.unregister(listener) }   // 수집이 끝나면 정리
}

수집하기 — collect 하기 전엔 아무 일도 없다

val nums = flow {
    println("시작")            // collect 할 때 실행된다
    emit(1); emit(2)
}

println("아직 아무 일 없음")

runBlocking {
    nums.collect { println("받음: $it") }   // 여기서 비로소 실행
}

// 수집 방법들
nums.toList()                        // 전부 모아서 리스트로
nums.first()                          // 첫 값만 (그 뒤 취소)
nums.single()                         // 딱 하나여야 한다
nums.collect { }                      // 하나씩 처리
nums.onEach { log(it) }.collect()     // 중간 처리 후 수집

// 코루틴 스코프에서 백그라운드 수집
scope.launch { nums.collect { update(it) } }
// 또는 축약형
nums.onEach { update(it) }.launchIn(scope)

Cold vs Hot — 가장 중요한 구분

Cold(flow)와 Hot(StateFlow · SharedFlow)의 차이 — 수집자가 둘일 때 Cold — flow { } 수집자 A A 전용 실행 1,2,3 수집자 B B 전용 실행 1,2,3 각자 처음부터 DB 쿼리도 두 번 Hot — SharedFlow 하나의 흐름 1,2,3… 수집자 A — 1,2,3 늦게 붙은 B — 3부터 (앞은 못 봄) 생산은 한 번, 여럿이 나눠 본다 수집자가 없어도 흐른다
데이터를 만들어 오는 흐름은 Cold(API·DB), 상태·이벤트를 알리는 흐름은 Hot(UI 상태·알림)입니다.
// Cold 의 성질 — 수집할 때마다 처음부터 다시 실행된다
val users: Flow<User> = flow {
    println("DB 조회 실행")
    repo.findAll().forEach { emit(it) }
}

users.collect { }     // "DB 조회 실행"
users.collect { }     // "DB 조회 실행"  ← 또 실행된다

// 결과를 공유하고 싶으면 Hot 으로 바꾼다
val shared = users.shareIn(scope, SharingStarted.WhileSubscribed(5000), replay = 1)

// suspend 함수와 Flow 중 무엇을 반환할까
// · 값이 하나 · 끝이 있다      → suspend fun f(): T
// · 값이 여럿 · 계속 온다      → fun f(): Flow<T>
// · 목록이지만 한 번에 다 온다 → suspend fun f(): List<T>  (Flow 로 만들 필요 없다)
CORO 13

Flow 연산자

컬렉션과 이름이 같은 것이 많지만 결정적 차이가 있습니다 — Flow 연산자 안에서는 suspend 함수를 부를 수 있습니다. map 안에서 DB를 조회하고 API를 호출할 수 있다는 뜻입니다.

flow.map { it * 2 }                      // 변환 (안에서 suspend 가능)
    .filter { it > 10 }                  // 거르기
    .transform { emit(it); emit(it * 2) } // 하나를 여러 개로
    .take(5)                              // 앞에서 n 개 (그 뒤 취소)
    .drop(2)
    .distinctUntilChanged()               // 연속 중복 제거 (상태 흐름에서 필수)
    .onEach { log.debug("값 {}", it) }    // 곁다리 처리
    .onStart { emit(0) }                  // 시작할 때 (로딩 상태 방출)
    .onCompletion { e -> log.info("완료 {}", e) }
    .catch { e -> emit(fallback) }        // 위쪽 예외를 잡는다
    .flowOn(Dispatchers.IO)               // 위쪽 연산을 IO 에서 실행
    .collect { render(it) }

flatMap 삼형제 — 이름이 곧 동작

연산자동작
flatMapConcat앞 것이 끝나야 다음 것 시작 (순서 보장)
flatMapMerge동시에 실행하고 오는 대로 방출 (순서 없음)
flatMapLatest새 값이 오면 이전 것을 취소 — 검색어 자동완성의 정답
// 검색어가 바뀌면 이전 요청을 버린다
searchQueryFlow
    .debounce(300)                       // 입력이 멎고 300ms 뒤
    .filter { it.length >= 2 }
    .distinctUntilChanged()
    .flatMapLatest { q -> api.search(q) } // 이전 검색은 취소
    .catch { emit(emptyList()) }
    .collect { showResults(it) }

// 이 다섯 줄이 '입력 debounce + 중복 제거 + 이전 요청 취소'를 전부 처리한다.
// 콜백으로 짜면 상태 변수가 세 개는 필요하다.

여러 Flow 합치기

// combine — 어느 쪽이든 바뀌면 최신값끼리 합친다 (UI 상태 조합의 기본)
combine(userFlow, settingsFlow) { user, settings ->
    ScreenState(user, settings)
}.collect { render(it) }

// zip — 양쪽에서 하나씩 짝을 맞춘다 (개수가 맞아야 한다)
flowA.zip(flowB) { a, b -> a to b }

// merge — 두 흐름을 그냥 하나로 섞는다
merge(clickFlow, keyFlow).collect { handle(it) }

// 실전: 목록 + 검색어 + 필터를 합쳐 화면 상태 만들기
combine(items, query, filter) { list, q, f ->
    list.filter { q in it.name && f.matches(it) }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

백프레셔 — 생산이 소비보다 빠를 때

// 기본은 '순차'다 — emit 이 collect 를 기다린다 (자연스러운 백프레셔)
flow {
    repeat(3) { emit(it); println("보냄 $it") }
}.collect { delay(1000); println("처리 $it") }
// 처리 0 → 보냄 0 → 처리 1 → 보냄 1 … (emit 이 처리가 끝나야 돌아온다, 총 3초)

// buffer — 생산과 소비를 분리해 겹쳐 돌린다
    .buffer(capacity = 64)              // 보냄 0·1·2 가 먼저 찍힌다 (생산자가 안 기다림)
// (총 시간은 처리 3초 그대로 — 생산에도 시간이 걸릴 때 둘이 겹쳐 줄어든다)

// conflate — 처리 중에 온 값은 버리고 '최신값만' (UI 갱신에 적합)
    .conflate()

// collectLatest — 새 값이 오면 이전 처리를 취소하고 다시 시작
    .collectLatest { render(it) }

// 넘칠 때 정책을 고르기
    .buffer(64, onBufferOverflow = BufferOverflow.DROP_OLDEST)

flowOn — 위쪽만 스레드를 바꾼다

flow { emit(repo.heavyQuery()) }    // ← IO 에서 실행
    .map { transform(it) }          // ← IO 에서 실행
    .flowOn(Dispatchers.IO)         // 여기부터 '위쪽'에 적용
    .collect { updateUi(it) }       // ← 수집자의 컨텍스트 (Main)

// ⚠️ flow { } 안에서 withContext 로 바꿔 emit 하지 마세요 — 컨텍스트 보존 규칙 위반
// flow { withContext(IO) { emit(x) } }  → IllegalStateException
// 반드시 flowOn 을 쓴다

예외 처리 — catch 는 위쪽만 잡는다

flow { emit(risky()) }
    .catch { e -> emit(fallback) }     // 위쪽(flow·map) 예외만 잡는다
    .collect { render(it) }             // ← 여기 예외는 못 잡는다

// collect 의 예외까지 잡으려면 try-catch 로 감싼다
try {
    f.collect { render(it) }
} catch (e: Exception) { }

// 재시도도 연산자로
flow { emit(api.get()) }
    .retry(3) { e -> e is IOException }        // 조건부 재시도
    .retryWhen { cause, attempt ->
        delay(1000L * (attempt + 1)); attempt < 3
    }
CORO 14

StateFlow · SharedFlow

화면 상태와 일회성 이벤트를 다루는 표준 도구입니다. 안드로이드 MVVM의 뼈대이고, 서버에서도 실시간 브로드캐스트에 씁니다.

늦게 구독한 사람이 무엇을 받는가 — 두 Flow 의 유일한 차이 발행되는 값 (시간 →) A B C D E F 여기서 늦게 구독 StateFlow .value 를 들고 있다 C D E F 구독 즉시 마지막 값 C 를 받고, 이후 D · E · F SharedFlow replay = 0 D E F 지나간 A · B · C 는 못 받는다 — 구독 이후 것만 화면 상태는 StateFlow · 토스트나 화면 이동 같은 1회성 사건은 SharedFlow(replay=0) 가 정답입니다.
"다시 그려도 같은 화면이 나와야 하면 State, 두 번 일어나면 곤란하면 Shared"로 고르면 됩니다.
타입성격쓰는 자리
StateFlowHot · 값 1개 유지상태 — 화면에 보이는 현재 값 (항상 값이 있다)
SharedFlowHot · replay 설정이벤트 — 토스트·화면 이동처럼 한 번만 처리할 것
ChannelHot · 1:1 소비한 수신자만 받아야 하는 작업 큐 (15)
flow { }Cold요청할 때마다 새로 만들어지는 데이터 흐름

StateFlow — 화면 상태

class UserViewModel(private val repo: UserRepo) : ViewModel() {

    // 쓰기는 안에서만, 읽기는 밖에서
    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()

    fun load(id: Long) {
        viewModelScope.launch {
            _state.value = UiState.Loading
            _state.value = runCatching { repo.find(id) }
                .fold(
                    onSuccess = { UiState.Success(it) },
                    onFailure = { UiState.Error(it.message ?: "오류") },
                )
        }
    }

    // 상태 갱신은 update 로 (원자적)
    fun toggleFavorite() = _state.update { s ->
        if (s is UiState.Success) s.copy(user = s.user.copy(fav = !s.user.fav)) else s
    }
}

// 성질
// · 항상 값이 있다 (초기값 필수)
// · 같은 값을 넣으면 방출하지 않는다 (distinctUntilChanged 내장)
// · 늦게 구독해도 '현재 값'을 즉시 받는다

SharedFlow — 일회성 이벤트

class OrderViewModel : ViewModel() {
    // replay = 0 : 늦게 온 구독자는 지난 이벤트를 못 본다 (이벤트에 맞는 성질)
    private val _events = MutableSharedFlow<Event>(
        replay = 0,
        extraBufferCapacity = 8,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    val events: SharedFlow<Event> = _events.asSharedFlow()

    fun submit() = viewModelScope.launch {
        runCatching { repo.submit() }
            .onSuccess { _events.emit(Event.NavigateToDone(it.id)) }
            .onFailure { _events.emit(Event.ShowToast("실패: ${it.message}")) }
    }
}

// ⚠️ StateFlow 로 이벤트를 다루면 화면 회전 때 토스트가 다시 뜬다
//    (마지막 값을 다시 받기 때문) — 이벤트는 SharedFlow 나 Channel 로

Cold → Hot 바꾸기 — stateIn · shareIn

class FeedViewModel(repo: FeedRepo) : ViewModel() {

    // DB Flow(cold)를 화면 상태(hot)로 바꾼다
    val feed: StateFlow<List<Post>> = repo.observeFeed()      // Flow<List<Post>>
        .map { it.sortedByDescending(Post::createdAt) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),   // 구독자 0이면 5초 뒤 정지
            initialValue = emptyList(),
        )
}

// SharingStarted 세 가지
// · Eagerly            : 즉시 시작, 스코프가 끝날 때까지 유지
// · Lazily             : 첫 구독자가 오면 시작, 이후 계속 유지
// · WhileSubscribed(t) : 구독자가 있을 때만 — 화면 회전(짧은 재구독)을 견디게 t=5000 을 쓴다 ★
서버에서도 씁니다 — 실시간 알림·시세 브로드캐스트를 MutableSharedFlow로 두고 각 WebSocket 세션이 구독하는 구조가 흔합니다. Redis Pub/Sub 을 SharedFlow로 감싸면 여러 서버 인스턴스로도 확장됩니다 — 대규모 트래픽 탭에서 다룹니다.
CORO 15

채널 — 코루틴 사이의 파이프

Flow가 “한 흐름을 여럿이 본다”면, Channel은 “보낸 값을 하나가 가져간다”입니다. 작업 큐, 생산자-소비자, 워커 풀이 전형적인 쓰임입니다.

채널 — 코루틴 사이의 파이프. 버퍼 정책이 성격을 정한다 생산자 코루틴 send() — 버퍼가 차면 멈춘다 Channel 정책을 가진 큐 하나 소비자 코루틴 receive() — 비면 기다린다 버퍼 정책 네 가지 — 느린 소비자를 어떻게 다룰 것인가 RENDEZVOUS 용량 0 (기본값) 받는 쪽이 올 때까지 send 가 멈춘다 BUFFERED 기본 64칸 차면 send 가 멈춰 자연히 속도를 맞춘다 CONFLATED 항상 최신 하나만 오래된 값은 버린다 센서 · 진행률 표시에 UNLIMITED 무한 버퍼 안 멈추는 대신 메모리가 터질 수 있다
채널을 직접 쓰기 전에 Flow 로 될 일인지 먼저 보세요 — 대부분은 Flow 로 충분합니다.
val channel = Channel<Task>(capacity = 100)

// 생산자
launch {
    repeat(1000) { channel.send(Task(it)) }    // 버퍼가 차면 여기서 대기 (백프레셔)
    channel.close()                             // 끝났음을 알린다
}

// 소비자 여럿 — 하나의 작업은 한 명만 가져간다 (팬아웃)
repeat(4) { worker ->
    launch {
        for (task in channel) {                 // 채널이 닫히면 루프가 끝난다
            process(task)
        }
        println("워커 $worker 종료")
    }
}

버퍼 종류

capacity동작
RENDEZVOUS(0, 기본)받는 사람이 있어야 보낼 수 있다 — 가장 엄격한 백프레셔
숫자 (예: 100)버퍼가 찰 때까지 논블로킹, 차면 send가 대기
UNLIMITED무한 버퍼 — 메모리 폭발 위험, 신중히
CONFLATED최신값만 유지 (이전 값 버림)
BUFFERED기본 64
채널은 반드시 닫아야 합니다. 닫지 않으면 소비자의 for 루프가 영원히 대기해 코루틴이 누수됩니다. produce { } 빌더를 쓰면 블록이 끝날 때 자동으로 닫힙니다.

produce · actor · select

// produce — 채널을 만들면서 생산 코루틴을 함께 시작 (자동 close)
fun CoroutineScope.pages(): ReceiveChannel<Page> = produce {
    var cursor: String? = null
    do {
        val page = api.list(cursor)
        send(page)
        cursor = page.next
    } while (cursor != null)
}

// 상태를 한 코루틴에 가두는 액터 패턴 — 락 없이 동시성 해결
sealed interface Msg
data class Add(val n: Int) : Msg
data class Get(val reply: CompletableDeferred<Int>) : Msg

fun CoroutineScope.counter() = actor<Msg> {      // 실험적 API
    var sum = 0
    for (msg in channel) when (msg) {
        is Add -> sum += msg.n
        is Get -> msg.reply.complete(sum)
    }
}

// select — 여러 채널 중 먼저 준비된 것 처리
select<Unit> {
    ch1.onReceive { handle(it) }
    ch2.onReceive { handle(it) }
    onTimeout(1000) { println("둘 다 조용함") }
}
Channel 과 Flow 중 무엇을 쓸까 — “하나가 가져가야 하면 Channel, 여럿이 봐야 하면 SharedFlow”입니다. 작업 분배·워커 풀은 Channel, 상태·알림 브로드캐스트는 Flow가 맞습니다. 외부에 노출하는 API 는 대개 Flow로 감싸는 편이 안전합니다 (receiveAsFlow() · consumeAsFlow()).
CORO 16

공유 상태 · 동기화

코루틴도 여러 스레드에서 돕니다. var count++는 여전히 위험합니다. 다만 코루틴에서는 락 대신 다른 선택지가 먼저입니다.

// ❌ 문제 — 100_000 이 안 나온다
var counter = 0
coroutineScope {
    repeat(100_000) { launch(Dispatchers.Default) { counter++ } }
}
println(counter)     // 매번 다른 값

// ✅ ① 원자 타입 — 가장 가볍다
val atomic = AtomicInteger()
atomic.incrementAndGet()

// ✅ ② Mutex — 코루틴용 락 (블로킹하지 않는다)
val mutex = Mutex()
var shared = 0
mutex.withLock { shared++ }

// ✅ ③ 단일 스레드에 가둔다 — 락이 아예 없다
val single = Dispatchers.Default.limitedParallelism(1)
withContext(single) { shared++ }

// ✅ ④ 애초에 공유하지 않는다 — 각자 계산하고 마지막에 합친다 (가장 좋다)
val total = (1..100).map { async { compute(it) } }.awaitAll().sum()

Mutex — synchronized 를 쓰면 안 되는 이유

// ❌ synchronized 블록 안에서는 suspend 함수를 부를 수 없다
// synchronized(lock) { delay(100) }   // 컴파일 에러

// 그리고 synchronized 는 '스레드'를 막는다 —
// 코루틴이 스레드를 반납하지 못해 코루틴의 이점이 사라진다

// ✅ Mutex 는 코루틴을 '중단'시킬 뿐 스레드는 놓아 준다
val mutex = Mutex()
suspend fun transfer(from: Account, to: Account, amount: Long) {
    mutex.withLock {
        from.balance -= amount
        to.balance += amount
        repo.saveAll(from, to)      // suspend 호출도 가능
    }
}

// ⚠️ Mutex 는 재진입(reentrant)이 아니다 — 중첩해서 잠그면 데드락

Semaphore — 동시 실행 수 제한

// 외부 API 를 동시에 10개까지만
val sem = Semaphore(10)

suspend fun callApi(id: Long): Response =
    sem.withPermit { httpClient.get("/items/$id") }

// DB 커넥션 풀 크기에 맞추기
val dbSem = Semaphore(20)      // 풀 크기와 동일하게
suspend fun query(sql: String) = dbSem.withPermit {
    withContext(Dispatchers.IO) { jdbc.query(sql) }
}
실무에서 가장 자주 쓰는 동기화 도구가 Semaphore 입니다. “동시에 몇 개까지”라는 제약이 외부 API·DB·파일 시스템 어디에나 있기 때문입니다.
단일 스레드 디스패처는 만병통치약이 아닙니다. limitedParallelism(1)로 가두면 락은 없어지지만 처리량이 1로 제한됩니다. 상태 갱신이 아주 짧을 때만 이득이고, 그 안에서 IO 를 하면 전체가 줄을 섭니다.
CORO 17

블로킹 코드와 섞기

현실의 코드베이스에는 JDBC·레거시 SDK·콜백 API가 섞여 있습니다. 코루틴은 이들을 버리지 않고 감싸서 쓸 수 있게 설계돼 있습니다 — 그 경계 만드는 법입니다.

① 블로킹 함수 감싸기

// JDBC · 파일 · 동기 HTTP — 전부 IO 디스패처로
suspend fun findUser(id: Long): User? = withContext(Dispatchers.IO) {
    jdbcTemplate.queryForObject(SQL, User::class.java, id)
}

// 커넥션 풀 크기를 넘지 않게 제한하면 더 좋다
private val dbDispatcher = Dispatchers.IO.limitedParallelism(20)
suspend fun findAll(): List<User> = withContext(dbDispatcher) {
    jdbcTemplate.query(SQL, rowMapper)
}

// 취소를 지원하려면 인터럽트를 연결해 준다
suspend fun longQuery(): List<Row> = withContext(Dispatchers.IO) {
    runInterruptible { jdbc.query(HEAVY_SQL) }   // 취소 시 스레드 인터럽트
}

② 콜백 API → suspend

// 레거시 SDK 가 콜백을 준다면
suspend fun getLocation(): Location = suspendCancellableCoroutine { cont ->
    val callback = object : LocationCallback {
        override fun onSuccess(loc: Location) = cont.resume(loc)
        override fun onError(e: Throwable) = cont.resumeWithException(e)
    }
    manager.requestLocation(callback)

    // 코루틴이 취소되면 요청도 취소한다 — 이 줄이 핵심이다
    cont.invokeOnCancellation { manager.cancelRequest(callback) }
}

// OkHttp 예시
suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
    enqueue(object : Callback {
        override fun onResponse(call: Call, res: Response) = cont.resume(res)
        override fun onFailure(call: Call, e: IOException) = cont.resumeWithException(e)
    })
    cont.invokeOnCancellation { cancel() }
}

③ Future · Reactor 와 상호 변환

// CompletableFuture → suspend
// (코루틴 1.7+ 은 core 에 포함 — 예전 kotlinx-coroutines-jdk8 의존성은 필요 없다)
val result = someFuture.await()

// suspend → CompletableFuture (자바 호출자에게 줄 때)
fun getUserAsync(id: Long): CompletableFuture<User> =
    scope.future { getUser(id) }

// Reactor(Mono/Flux) ↔ 코루틴
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
val user: User = userMono.awaitSingle()
val userOrNull: User? = userMono.awaitSingleOrNull()
val users: Flow<User> = userFlux.asFlow()
val mono: Mono<User> = mono { getUser(id) }
val flux: Flux<User> = flow.asFlux()

// RxJava 도 같은 방식 (-rx3 모듈)

경계는 얇게 — 실무 구조

API
suspend 컨트롤러요청 하나가 코루틴 하나
▼
SERVICE
suspend 함수로 조합coroutineScope · async 병렬
▼
ADAPTER
여기서만 withContext(IO)블로킹 SDK 를 감싸는 얇은 층
▼
LEGACY
JDBC · 콜백 SDK고치지 않는다

블로킹은 가장 아래층에 가둡니다. 위로 올라올수록 suspend 만 보이게 하면, 나중에 논블로킹 드라이버(R2DBC)로 바꿔도 위층 코드는 그대로입니다.

가상 스레드와 함께 쓸 때 — JDK 21+ 에서는 Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()로 블로킹 작업용 디스패처를 만들 수 있습니다. 스레드 수 제한이 사실상 사라지지만, DB 커넥션 풀은 여전히 유한하므로 동시 실행 제한은 반드시 남겨 두세요.
CORO 18

코루틴 테스트

코루틴 테스트의 핵심은 가상 시간입니다. delay(10.minutes)가 있어도 테스트는 즉시 끝납니다 — 시간을 실제로 기다리지 않고 앞으로 감기 때문입니다.

의존성 — testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") · Flow 테스트에는 app.cash.turbine:turbine이 편합니다.
@Test
fun `재시도는 3번까지 한다`() = runTest {        // ← runBlocking 대신 runTest
    var calls = 0
    val result = retry(times = 3, initialDelay = 1.seconds) {
        calls++
        if (calls < 3) throw IOException("실패") else "성공"
    }
    assertEquals("성공", result)
    assertEquals(3, calls)
    // delay 가 총 3초였지만 테스트는 수 ms 만에 끝난다 (가상 시간)
}

@Test
fun `가상 시간을 직접 제어`() = runTest {
    val job = launch {
        delay(1000); println("1초 뒤")
        delay(1000); println("2초 뒤")
    }
    advanceTimeBy(1000)      // 1초 앞으로
    runCurrent()             // 지금 실행 가능한 것 실행
    advanceUntilIdle()       // 할 일이 없어질 때까지 전부 진행
    assertTrue(job.isCompleted)
    println(currentTime)     // 가상 시계 값
}

디스패처 주입 — 테스트 가능한 설계

// ❌ 하드코딩하면 테스트에서 제어할 수 없다
class Repo {
    suspend fun load() = withContext(Dispatchers.IO) { /* ... */ }
}

// ✅ 주입한다
class Repo(private val io: CoroutineDispatcher = Dispatchers.IO) {
    suspend fun load() = withContext(io) { /* ... */ }
}

@Test
fun test() = runTest {
    val repo = Repo(StandardTestDispatcher(testScheduler))   // 가상 시간 공유
    // ...
}

// 안드로이드 ViewModel — Main 디스패처 교체
@Before fun setUp() = Dispatchers.setMain(StandardTestDispatcher())
@After fun tearDown() = Dispatchers.resetMain()

Flow 테스트 — Turbine

@Test
fun `상태가 로딩에서 성공으로 바뀐다`() = runTest {
    val vm = UserViewModel(fakeRepo)

    vm.state.test {                       // Turbine
        assertEquals(UiState.Loading, awaitItem())
        vm.load(1)
        val success = awaitItem()
        assertTrue(success is UiState.Success)
        cancelAndIgnoreRemainingEvents()
    }
}

// Turbine 없이도 가능하지만 장황하다
@Test
fun withoutTurbine() = runTest {
    val vm = UserViewModel(fakeRepo)
    val values = mutableListOf<UiState>()
    val job = launch(UnconfinedTestDispatcher()) { vm.state.toList(values) }
    vm.load(1)
    advanceUntilIdle()
    assertEquals(2, values.size)
    job.cancel()
}
도구쓰임
runTest { }코루틴 테스트의 진입점 — 가상 시간 + 자동 대기
StandardTestDispatcher기본 — 명시적으로 advanceUntilIdle() 해야 진행
UnconfinedTestDispatcher즉시 실행 — 순서를 신경 쓰지 않을 때 편하다
advanceTimeBy / advanceUntilIdle가상 시계 조작
backgroundScope테스트가 끝나면 자동 취소되는 스코프 (무한 수집에 필수)
TurbineFlow 방출을 순서대로 검증
가장 흔한 테스트 실패 원인 — StateFlow를 collect하는 코루틴은 끝나지 않습니다. runTest가 그것을 기다리다 타임아웃이 납니다. backgroundScope.launch { flow.collect { } }를 쓰거나 Turbine 을 쓰세요.
CORO 19

내부 동작 — CPS와 상태 기계

suspend는 마법이 아닙니다. 컴파일러가 함수를 상태 기계로 다시 씁니다. 이 변환을 이해하면 스레드가 바뀌는 이유, 스택 트레이스가 잘리는 이유, Continuation 이 무엇인지가 한 번에 풀립니다.

suspend 함수 → 상태 기계 — 중단점마다 라벨이 생긴다 원본 코드 val a = load() ← 중단점 val b = calc(a) ← 중단점 return a + b 컴파일 switch (continuation.label) case 0: label=1; load(cont) → 반납 case 1: a=결과 복원; label=2; calc(cont) → 반납 case 2: b=결과 복원; return a+b Continuation 객체 (힙) label = 현재 어디까지 실행했나 지역 변수 a, b 를 필드로 보관 그래서 이런 일이 가능하다 · 스레드를 반납해도 상태가 남는다 · 재개할 때 다른 스레드여도 이어서 실행된다
코루틴의 “상태”는 스레드 스택이 아니라 힙의 Continuation 객체에 있습니다 — 그래서 가볍습니다.
// 내가 쓴 코드
suspend fun getUser(id: Long): User

// 컴파일된 시그니처 (자바에서 본 모습)
// Object getUser(long id, Continuation<? super User> $completion)
//
// ① suspend 함수에는 Continuation 인자가 하나 몰래 붙는다 (CPS 변환)
// ② 반환 타입이 Object 인 이유 — 값을 바로 못 주면 COROUTINE_SUSPENDED 를 돌려준다
// ③ 자바에서 suspend 함수를 부르기 어려운 이유가 이것이다

// Continuation 인터페이스는 이게 전부다
public interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)     // 결과를 들고 '이어서 실행해'
}

직접 확인해 보기

IntelliJ → Tools → Kotlin → Show Kotlin Bytecode → [Decompile]

suspend 함수를 하나 만들고 디컴파일하면
  · ContinuationImpl 을 상속한 익명 클래스
  · switch (this.label) 로 시작하는 거대한 메서드
  · 지역 변수가 필드(L$0, L$1)로 저장되는 코드
가 그대로 보입니다.

"코루틴이 왜 스레드보다 가벼운가"의 답이 여기 있습니다 —
스택 1MB 대신 필드 몇 개짜리 객체 하나면 되기 때문입니다.

이 변환이 설명해 주는 것들

  • 재개 후 스레드가 바뀔 수 있다 — 스택이 아니라 힙에 상태가 있으니 어느 스레드든 이어받을 수 있습니다
  • ThreadLocal 이 사라진다 — 위와 같은 이유
  • 스택 트레이스가 짧다 — 호출 스택이 아니라 상태 기계 점프이기 때문. -Dkotlinx.coroutines.debug를 켜면 코루틴 경로가 복원됩니다
  • suspend 함수는 자바에서 부르기 어렵다 — Continuation 을 직접 만들어야 합니다. 자바용 API 는 CompletableFuture로 감싸 제공하세요
  • inline 함수 안의 suspend 는 비용이 없다 — 상태 기계가 호출 자리에 합쳐집니다
더 깊은 이야기는 전문가 탭에서 다룹니다 — 디스패처의 작업 훔치기(work-stealing) 스케줄러, DispatchedTask의 실행 경로, suspendCoroutineUninterceptedOrReturn 같은 저수준 API, 그리고 코루틴 성능을 측정하는 방법까지.
CORO 20

실전 체크리스트

코드 리뷰와 장애 회고에서 반복적으로 나오는 항목들입니다. 이 목록만 지켜도 코루틴 관련 사고의 대부분이 예방됩니다.

하지 말아야 할 것

안티패턴왜 · 무엇을 대신
GlobalScope.launch취소 불가 · 누수 → 수명 있는 스코프 (04)
요청 처리 중 runBlocking스레드를 막아 이점 소멸 → suspend 컨트롤러 (03)
catch (e: Exception)로 취소 삼킴취소가 안 먹는다 → CancellationException 재던지기 (07)
Dispatchers.Default에서 블로킹 IOCPU 풀 고갈 → IO 또는 limitedParallelism (06)
이벤트를 StateFlow로화면 회전 시 재실행 → SharedFlow·Channel (14)
async 뒤에 바로 await병렬이 아니다 → 둘 다 시작한 뒤 await (03)
공개 API 가 Deferred 반환호출부가 취소·예외를 떠안는다 → suspend fun
무제한 병렬 호출상대 서버 429 · 커넥션 고갈 → Semaphore (16)
synchronized 안에서 suspend컴파일 불가 · 스레드 블로킹 → Mutex
Channel을 닫지 않음소비자 영구 대기 → produce 또는 명시적 close()
타임아웃 없는 외부 호출장애 전파 → withTimeout (08)

설계 규칙

함수를 만들 때

  • 공개 API 는 suspend fun f(): T 또는 fun f(): Flow<T>
  • 메인 세이프하게 — 디스패처 전환은 함수 안에서
  • 디스패처는 인자로 주입(기본값 제공) — 테스트를 위해
  • 긴 계산 루프에는 ensureActive()
  • 자원은 use·finally+NonCancellable로 반드시 정리

운영에서 볼 것

  • -Dkotlinx.coroutines.debug + CoroutineName으로 로그 추적
  • MDC 전파(MDCContext)로 traceId 유지 (11)
  • 디스패처별 스레드 수와 큐 길이를 지표로 노출
  • 블로킹 호출이 섞여 있는지 주기적으로 점검 (개발 환경에서 BlockHound로 탐지 가능)
  • 취소가 실제로 하위 시스템까지 전달되는지 확인 (DB 쿼리 취소 등)
다음 단계 — Spring Boot Web 탭에서는 이 코루틴을 스프링 컨트롤러·서비스·트랜잭션과 어떻게 연결하는지, WebFlux 탭에서는 Reactor 와 코루틴을 오가는 방법을, Android 탭에서는 ViewModel·Compose 와 결합하는 실전 구조를 다룹니다. 코루틴은 이 가이드 나머지 전부의 공통 언어입니다.
SPRING BOOT 4 · KOTLIN · 코루틴 컨트롤러

스프링을
코틀린으로 쓰면
무엇이 달라지는가

스프링은 자바를 전제로 설계됐고, 코틀린은 기본이 final·non-null입니다. 이 둘이 만나는 지점에 컴파일러 플러그인·프록시·널 처리라는 고유한 주제가 생깁니다. 이 탭은 그 지점들을 먼저 정리한 뒤, 코루틴 컨트롤러까지 실무 형태로 갑니다.

16SECTIONS
4Spring Boot 기준
3필수 컴파일러 플러그인
100%Kotlin DSL 빌드
BOOT 01

코틀린으로 스프링 시작하기

프로젝트를 만들 때 플러그인 세 개를 빠뜨리면 앱이 뜨지도 않습니다. 왜 필요한지까지 알고 넘어가면 나중에 원인 모를 에러에서 헤매지 않습니다.

// build.gradle.kts — 코틀린 + 스프링 부트 프로젝트의 표준형
plugins {
    kotlin("jvm") version "2.2.0"
    kotlin("plugin.spring") version "2.2.0"       // ★ all-open — 클래스를 열어 준다
    kotlin("plugin.jpa") version "2.2.0"          // ★ no-arg — 기본 생성자를 만들어 준다 (JPA 쓸 때)
    kotlin("plugin.serialization") version "2.2.0" // (선택) kotlinx.serialization
    id("org.springframework.boot") version "4.0.0"
    id("io.spring.dependency-management") version "1.1.7"
}

kotlin {
    jvmToolchain(21)
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict")   // ★ 자바 null 애너테이션을 엄격하게 해석
    }
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("tools.jackson.module:jackson-module-kotlin")  // ★ 코틀린 DTO 역직렬화 (부트 4 = Jackson 3. 부트 3 은 com.fasterxml.jackson.module)
    implementation("org.jetbrains.kotlin:kotlin-reflect")                  // 스프링이 요구한다
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")     // suspend 컨트롤러용

    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("io.mockk:mockk:1.13.13")
}

plugin.spring — 왜 필요한가

코틀린 클래스는 기본이 final인데, 스프링은 @Transactional·@Cacheable 등을 CGLIB 프록시(상속)로 구현합니다. final 클래스는 상속할 수 없으니 프록시를 만들 수 없습니다.

// 이 플러그인이 자동으로 open 을 붙여 준다
// @Component @Service @Repository
// @Controller @Configuration
// @SpringBootTest @Async @Transactional

// 없으면 이런 에러
// Cannot subclass final class

plugin.jpa — 기본 생성자

JPA 명세는 엔티티에 파라미터 없는 생성자를 요구합니다. 코틀린 class User(val name: String)에는 그런 생성자가 없습니다.

// 대상: @Entity @Embeddable
//       @MappedSuperclass
// 바이트코드에만 생성자를 추가한다
// (코드에서는 여전히 못 부른다)

// 없으면
// No default constructor for entity

-Xjsr305=strict

스프링의 자바 API에는 @Nullable·@NonNull이 붙어 있습니다. 이 옵션을 켜면 코틀린이 그 표시를 플랫폼 타입이 아니라 진짜 타입으로 취급합니다.

켜 두는 것을 권합니다. 스프링 API 의 null 계약이 컴파일 단계에서 검사됩니다. (Spring Framework 7 은 JSpecify 애너테이션으로 이 정보를 훨씬 정밀하게 제공합니다)

첫 애플리케이션

// Application.kt
@SpringBootApplication
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)      // 코틀린 확장 — 스프레드 연산자에 주의
}

// 설정을 곁들이려면
fun main(args: Array<String>) {
    runApplication<Application>(*args) {
        setBannerMode(Banner.Mode.OFF)
        setAdditionalProfiles("local")
    }
}

// 빈 등록도 DSL 로 가능하다 (리플렉션 없이 등록 — 기동이 빨라진다)
val beans = beans {
    bean<UserService>()
    bean { OrderService(ref(), ref()) }
}
클래스 앞에 open을 손으로 붙이고 있다면 plugin.spring 이 빠진 것입니다. 반대로 모든 클래스에 open을 붙이지 마세요 — 플러그인이 필요한 것만 열어 줍니다.
BOOT 02

DI · 빈 — 코틀린식 주입

코틀린에서는 생성자 주입이 문법적으로도 최선입니다. val로 받으면 불변 + non-null + 테스트 가능이 한 번에 해결됩니다.

필드 주입 — 코틀린에서 특히 나쁘다
@Service
class OrderService {
    @Autowired
    lateinit var repo: OrderRepository       // var 라서 바뀔 수 있다

    @Autowired
    lateinit var payment: PaymentClient      // 테스트에서 주입이 번거롭다
}

// 문제
// · 초기화 전 접근 시 UninitializedPropertyAccessException
// · 불변이 아니다
// · 의존성이 많아져도 티가 안 난다 (설계 악화 신호를 놓친다)
생성자 주입 — 한 줄이면 끝
@Service
class OrderService(
    private val repo: OrderRepository,       // @Autowired 불필요 (생성자 1개면 자동)
    private val payment: PaymentClient,
    private val props: OrderProperties,
) {
    fun place(req: OrderRequest): Order { /* ... */ }
}

// 테스트에서
val service = OrderService(fakeRepo, fakePayment, testProps)
// 스프링 컨텍스트 없이도 그냥 만들어진다

같은 타입 빈이 여럿일 때

@Service
class NotificationService(
    @Qualifier("slack") private val slack: Notifier,
    @Qualifier("email") private val email: Notifier,
    private val all: List<Notifier>,         // 전부 주입받기
    private val byName: Map<String, Notifier>,// 빈 이름 → 빈
) {
    fun broadcast(msg: String) = all.forEach { it.send(msg) }
}

// 선택적 의존성 — nullable 로 받는다
@Service
class MetricsService(
    private val optional: MeterRegistry? = null,   // 없으면 null
)

@Configuration 과 빈 정의

@Configuration
class AppConfig {

    @Bean
    fun restClient(builder: RestClient.Builder): RestClient =
        builder.baseUrl("https://api.example.com")
            .requestFactory(SimpleClientHttpRequestFactory().apply {
                setConnectTimeout(2_000)
                setReadTimeout(3_000)
            })
            .build()

    @Bean
    @ConditionalOnProperty("feature.newPricing", havingValue = "true")
    fun newPricingPolicy(): PricingPolicy = NewPricingPolicy()

    @Bean
    fun clock(): Clock = Clock.systemDefaultZone()   // 테스트에서 시간 고정용
}
Clock을 빈으로 두는 습관을 권합니다. LocalDateTime.now()를 직접 부르면 시간이 걸린 로직을 테스트할 수 없습니다.
순환 참조는 생성자 주입에서 앱 기동 실패로 드러납니다. lateinit으로 우회하지 마세요 — 설계가 잘못됐다는 신호입니다. 보통 공통 로직을 제3의 컴포넌트로 빼거나, 이벤트(ApplicationEventPublisher)로 방향을 끊는 것이 정답입니다.
BOOT 03

설정 — @ConfigurationProperties

@Value("\${...}")를 흩뿌리는 대신 data class 하나로 묶으면 타입 검사·기본값·검증·IDE 자동완성이 전부 따라옵니다.

불변 설정 클래스

@ConfigurationProperties(prefix = "app.order")
data class OrderProperties(
    val maxItems: Int = 100,
    val timeout: Duration = 3.seconds.toJavaDuration(),
    val currency: String = "KRW",
    val notify: Notify = Notify(),
) {
    data class Notify(
        val enabled: Boolean = true,
        val channels: List<String> = listOf("email"),
    )
}

// 등록 — 둘 중 하나
@ConfigurationPropertiesScan          // Application 클래스에
@EnableConfigurationProperties(OrderProperties::class)
app:
  order:
    max-items: 200        # 케밥 케이스 → maxItems 로 매핑
    timeout: 5s
    notify:
      channels: [email, slack]

검증과 프로파일

@Validated
@ConfigurationProperties("app.payment")
data class PaymentProperties(
    @field:NotBlank val apiKey: String,        // ★ @field: 가 필요하다
    @field:Min(1) val retryCount: Int = 3,
    @field:Pattern(regexp = "https://.*") val baseUrl: String,
)
// 값이 없거나 형식이 틀리면 '앱이 뜨지 않는다' — 운영 중 발견보다 훨씬 낫다

// 프로파일별 설정
// application.yml · application-local.yml · application-prod.yml
@Profile("!prod")
@Configuration
class DevOnlyConfig { /* ... */ }

// 코드에서 프로파일 확인
@Component
class Startup(private val env: Environment) {
    @PostConstruct fun log() = println(env.activeProfiles.joinToString())
}
코틀린 data class 로 설정을 받을 때 주의 — 생성자 바인딩이 동작하려면 스프링 부트 3+ 에서는 별다른 설정이 필요 없지만, 기본값을 주려면 코틀린 기본 인자를 쓰면 됩니다. var + 세터 바인딩보다 val + 생성자 바인딩이 안전합니다 — 런타임에 설정이 바뀌지 않기 때문입니다.

설정 값을 코드로 다루기

// ❌ 여기저기 흩어진 @Value
@Service
class A(@Value("\${app.timeout}") private val timeout: Long)

// ✅ 묶어서 주입 — 어디서 쓰이는지 추적이 쉽다
@Service
class OrderService(private val props: OrderProperties) {
    fun place(req: OrderRequest) {
        require(req.items.size <= props.maxItems) { "최대 ${props.maxItems}개" }
    }
}

// 기능 플래그도 설정으로
@Service
class PricingService(private val props: FeatureProperties) {
    fun price(o: Order) =
        if (props.newPricing) newPolicy.price(o) else oldPolicy.price(o)
}
BOOT 04

REST 컨트롤러

코틀린 컨트롤러는 자바와 거의 같지만, 단일 표현식 함수와 suspend가 더해지면서 코드가 눈에 띄게 짧아집니다.

요청 한 건이 지나가는 순서 — 내 코드를 어디에 끼워 넣을지가 여기서 갈린다 1 서블릿 필터 체인 CORS · 요청 로깅 · 인증 토큰 1차 검사 — 아직 스프링 MVC 밖입니다 2 DispatcherServlet 모든 요청의 단일 입구 — 아래 단계를 순서대로 부르는 조율자 3 HandlerMapping URL 과 HTTP 메서드로 어느 컨트롤러 함수를 부를지 고릅니다 4 인터셉터 preHandle 컨트롤러 바로 앞 — 권한 확인 · MDC 에 요청 ID 심기에 알맞은 자리 5 ArgumentResolver · 검증 JSON → DTO 변환과 @Valid 가 여기서 끝납니다. 실패하면 컨트롤러는 아예 안 불립니다 6 컨트롤러 함수 여기서야 내가 쓴 코드가 돕니다 — 앞의 다섯 단계는 전부 준비 과정입니다 7 응답 변환 · 예외 처리 반환값을 JSON 으로. 예외가 났다면 @RestControllerAdvice 가 대신 응답을 만듭니다
"어디에 코드를 넣을까"의 답이 이 순서에 있습니다 — 공통 처리는 1·4, 변환은 5·7, 업무 로직은 6.
@RestController
@RequestMapping("/api/v1/orders")
class OrderController(
    private val service: OrderService,
) {
    // 목록 — 단일 표현식으로 끝난다
    @GetMapping
    fun list(
        @RequestParam(defaultValue = "1") page: Int,
        @RequestParam(required = false) status: OrderStatus?,
        @PageableDefault(size = 20) pageable: Pageable,
    ): Page<OrderResponse> = service.list(status, pageable)

    // 단건 — 없으면 404 를 던지는 것은 서비스/예외 핸들러 책임
    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): OrderResponse = service.get(id)

    // 생성 — 201 + Location 헤더
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun create(@Valid @RequestBody req: CreateOrderRequest): OrderResponse =
        service.create(req)

    // 수정
    @PatchMapping("/{id}")
    fun update(
        @PathVariable id: Long,
        @Valid @RequestBody req: UpdateOrderRequest,
    ): OrderResponse = service.update(id, req)

    // 삭제 — 본문 없음
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    fun delete(@PathVariable id: Long) = service.delete(id)

    // 헤더 · 쿠키 · 인증 주체
    @GetMapping("/me")
    fun me(
        @RequestHeader("X-Trace-Id") traceId: String?,
        @AuthenticationPrincipal user: LoginUser,
    ): List<OrderResponse> = service.listOf(user.id)
}

suspend 컨트롤러 — 코루틴을 그대로

@RestController
class ProfileController(private val service: ProfileService) {

    // 함수에 suspend 만 붙이면 된다 (spring-web 이 지원)
    @GetMapping("/api/profile/{id}")
    suspend fun profile(@PathVariable id: Long): ProfileResponse =
        service.load(id)         // 안에서 async 로 병렬 호출

    // 스트리밍 응답 — Flow 를 그대로 반환
    @GetMapping("/api/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun events(): Flow<ServerSentEvent<String>> = service.eventFlow()
}
Spring MVC 에서도 suspend 가 됩니다. 요청 스레드를 놓아 주고 비동기로 처리한 뒤 결과를 씁니다. 다만 내부에서 블로킹 JDBC 를 쓰면 이득이 제한적이므로, withContext(IO)로 감싸는 경계가 필요합니다 (10 · 코루틴과 스프링).

응답을 다루는 방법들

// 1) 그냥 객체 반환 — 200 OK (가장 깔끔)
@GetMapping("/{id}") fun get(@PathVariable id: Long) = service.get(id)

// 2) 상태 코드가 갈리면 ResponseEntity
@GetMapping("/{id}")
fun get2(@PathVariable id: Long): ResponseEntity<OrderResponse> =
    service.find(id)?.let { ResponseEntity.ok(it) }
        ?: ResponseEntity.notFound().build()

// 3) 생성 — Location 헤더까지
@PostMapping
fun create(@RequestBody req: CreateOrderRequest): ResponseEntity<OrderResponse> {
    val created = service.create(req)
    return ResponseEntity
        .created(URI.create("/api/v1/orders/${created.id}"))
        .body(created)
}

// 4) 파일 다운로드
@GetMapping("/{id}/receipt")
fun receipt(@PathVariable id: Long): ResponseEntity<ByteArray> =
    ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"r-$id.pdf\"")
        .contentType(MediaType.APPLICATION_PDF)
        .body(service.receiptPdf(id))
컨트롤러는 얇게. 검증은 @Valid에, 예외 변환은 @RestControllerAdvice에, 비즈니스 로직은 서비스에 둡니다. 컨트롤러에 if가 늘어나기 시작하면 대개 서비스로 내려야 할 로직이 올라온 것입니다.
BOOT 05

DTO · JSON 직렬화

코틀린 DTO에서 가장 자주 터지는 곳입니다 — null이 아닌 필드에 null이 들어오고, 기본값이 무시되고, Boolean 프로퍼티 이름이 바뀝니다. 원인과 해결을 정리합니다.

필수 — implementation("tools.jackson.module:jackson-module-kotlin")(부트 4) · com.fasterxml.jackson.module:jackson-module-kotlin(부트 3) (스프링 부트 스타터에 이미 포함되어 있으면 생략 가능하지만, 없으면 코틀린 data class 역직렬화가 깨집니다)
모듈이 없으면 생기는 일
data class CreateOrder(
    val productId: Long,
    val quantity: Int = 1,
)

// 요청: {"productId": 1}
// jackson-module-kotlin 이 없으면
//  · 기본 생성자가 없어 InvalidDefinitionException, 또는
//  · quantity 기본값이 무시되고 0 이 들어옴

// 요청: {"productId": null}
//  · non-null 필드에 null 이 들어가 NPE 가 '나중에' 터진다
모듈이 있으면
// · 주 생성자로 역직렬화한다
// · 기본값이 적용된다 (필드가 없을 때)
// · non-null 필드에 null 이 오면 MissingKotlinParameterException
//   → 400 Bad Request 로 변환 가능

// 스프링 부트는 클래스패스에 있으면 자동 등록한다.
// 직접 만들 때는 (아래는 Jackson 2 · 부트 3 기준 — Jackson 3 은 불변이라 jacksonMapperBuilder() 로 만든다)
val mapper = jacksonObjectMapper().apply {
    registerModule(JavaTimeModule())
    disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
    setSerializationInclusion(JsonInclude.Include.NON_NULL)
}

실무 DTO 설계

// 요청 — 필수는 non-null, 선택은 nullable + 기본값
data class CreateOrderRequest(
    @field:NotNull val productId: Long,
    @field:Min(1) @field:Max(99) val quantity: Int = 1,
    val couponCode: String? = null,
    val memo: String? = null,
)

// 응답 — 도메인과 분리한다 (엔티티를 그대로 노출하지 않는다)
data class OrderResponse(
    val id: Long,
    val status: OrderStatus,
    val totalAmount: Long,
    val items: List<OrderItemResponse>,
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
    val createdAt: LocalDateTime,
) {
    companion object {
        fun from(o: Order) = OrderResponse(
            id = o.id, status = o.status, totalAmount = o.total(),
            items = o.items.map(OrderItemResponse::from),
            createdAt = o.createdAt,
        )
    }
}

// 또는 확장 함수로 (프레젠테이션 계층 안에 둔다)
fun Order.toResponse() = OrderResponse(...)

이름·형식 관련 함정

문제해결
val isVip: Boolean 이 JSON 에서 vip 로 나감@get:JsonProperty("isVip")
필드 이름을 바꿔야 함@JsonProperty("order_id")
snake_case API@JsonNaming(SnakeCaseStrategy::class) 또는 전역 설정
없는 필드가 오면 에러spring.jackson.deserialization.fail-on-unknown-properties=false(기본값)
null 필드를 응답에서 빼고 싶다@JsonInclude(NON_NULL)
value class 직렬화커스텀 시리얼라이저 필요 — 또는 DTO 에서는 원시 타입 사용
sealed 타입 다형 직렬화@JsonTypeInfo + @JsonSubTypes
Spring Boot 4 는 Jackson 3 을 기본으로 씁니다. 패키지 이름이 tools.jackson.*으로 바뀌고 ObjectMapper 가 불변이 됩니다. 부트 3 에서 올라올 때 커스텀 시리얼라이저·모듈 등록 코드를 손봐야 합니다.

kotlinx.serialization — 또 하나의 선택지

// 리플렉션 없이 컴파일 시점에 직렬화 코드를 생성한다 (KMP·네이티브에 유리)
@Serializable
data class OrderResponse(
    val id: Long,
    @SerialName("order_status") val status: String,
    val createdAt: String,
)

val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
val text = json.encodeToString(response)
val back = json.decodeFromString<OrderResponse>(text)

// 스프링에서 쓰려면 KotlinSerializationJsonHttpMessageConverter 를 등록한다
// 기준
// · JVM 전용 서버 · 기존 자바 코드와 혼용  → Jackson
// · KMP · 네이티브 이미지 · 안드로이드 앱  → kotlinx.serialization
BOOT 06

검증 — Bean Validation

코틀린에서 검증 애너테이션이 “안 먹는” 일이 자주 생깁니다. 원인은 거의 항상 사용 지점(@field:)을 빠뜨린 것입니다.

코틀린 프로퍼티 하나 = 자바 요소 셋 — 애너테이션이 어디에 붙느냐가 갈린다 val name: String 생성자 파라미터 @param: — 기본으로 여기 붙는다 필드 @field: — 검증기가 보는 곳 ★ getter @get: — Jackson·JPA 가 보기도 @NotBlank 만 쓰면 파라미터에 붙어 “검증이 조용히 통과”한다 해결 — @field:NotBlank 처럼 사용 지점을 명시한다
코틀린에서 검증이 안 먹으면 십중팔구 @field: 누락입니다.

기본 사용

data class SignUpRequest(
    @field:NotBlank(message = "이름은 필수입니다")
    @field:Size(max = 20)
    val name: String,

    @field:Email(message = "이메일 형식이 아닙니다")
    val email: String,

    @field:Pattern(regexp = "^01\\d-\\d{3,4}-\\d{4}$")
    val phone: String,

    @field:Min(0) @field:Max(150)
    val age: Int,

    @field:Valid                       // 중첩 객체까지 검증
    val address: AddressRequest,

    @field:Size(min = 1, message = "최소 1개")
    val items: List<@Valid ItemRequest>,
)

@PostMapping("/signup")
fun signUp(@Valid @RequestBody req: SignUpRequest) = service.signUp(req)
// 실패하면 MethodArgumentNotValidException → 전역 핸들러에서 400 으로

커스텀 검증기

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [CouponValidator::class])
annotation class ValidCoupon(
    val message: String = "사용할 수 없는 쿠폰입니다",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = [],
)

@Component
class CouponValidator(
    private val repo: CouponRepository,      // 빈 주입도 된다
) : ConstraintValidator<ValidCoupon, String?> {
    override fun isValid(value: String?, ctx: ConstraintValidatorContext): Boolean =
        value == null || repo.existsByCodeAndActiveTrue(value)
}

data class OrderRequest(
    @field:ValidCoupon val couponCode: String? = null,
)
검증을 어디에 둘 것인가 — 형식(길이·패턴·범위)은 DTO 애너테이션으로, 도메인 규칙(재고·잔액·상태 전이)은 도메인 객체의 init이나 서비스에서. 코틀린의 require·check가 이 자리에 잘 맞습니다 — 그리고 그 예외를 전역 핸들러가 400/409 로 바꿉니다(다음 섹션).
BOOT 07

전역 예외 처리

예외를 HTTP 응답으로 바꾸는 일은 한 곳에서 해야 합니다. 코틀린에서는 sealed 예외 계층 + when으로 매핑을 컴파일러가 검사하게 만들 수 있습니다.

// 1) 도메인 예외를 sealed 로 묶는다
sealed class DomainException(message: String, cause: Throwable? = null) :
    RuntimeException(message, cause) {

    class NotFound(val entity: String, val id: Any) :
        DomainException("$entity 를 찾을 수 없습니다: $id")

    class Conflict(val reason: String) : DomainException(reason)

    class OutOfStock(val productId: Long, val left: Int) :
        DomainException("재고 부족 (남은 수량 $left)")

    class Forbidden(val action: String) : DomainException("권한이 없습니다: $action")
}

// 2) 한 곳에서 HTTP 로 변환 — when 이 모든 경우를 덮는지 컴파일러가 검사한다
@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(DomainException::class)
    fun handleDomain(e: DomainException): ResponseEntity<ProblemDetail> {
        val (status, code) = when (e) {
            is DomainException.NotFound -> HttpStatus.NOT_FOUND to "NOT_FOUND"
            is DomainException.Conflict -> HttpStatus.CONFLICT to "CONFLICT"
            is DomainException.OutOfStock -> HttpStatus.CONFLICT to "OUT_OF_STOCK"
            is DomainException.Forbidden -> HttpStatus.FORBIDDEN to "FORBIDDEN"
        }
        val body = ProblemDetail.forStatusAndDetail(status, e.message ?: "")
        body.setProperty("code", code)
        body.setProperty("traceId", MDC.get("traceId"))
        return ResponseEntity.status(status).body(body)
    }

    // 검증 실패 — 필드별 메시지를 그대로 내려 준다
    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(e: MethodArgumentNotValidException): ResponseEntity<ProblemDetail> {
        val errors = e.bindingResult.fieldErrors.associate { it.field to (it.defaultMessage ?: "") }
        val body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다")
        body.setProperty("errors", errors)
        return ResponseEntity.badRequest().body(body)
    }

    // 코틀린 require/check 에서 나오는 표준 예외
    @ExceptionHandler(IllegalArgumentException::class)
    fun handleIllegalArgument(e: IllegalArgumentException) =
        ResponseEntity.badRequest().body(
            ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.message ?: "잘못된 요청"))

    @ExceptionHandler(IllegalStateException::class)
    fun handleIllegalState(e: IllegalStateException) =
        ResponseEntity.status(HttpStatus.CONFLICT).body(
            ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.message ?: "처리할 수 없는 상태"))

    // 마지막 그물 — 스택 트레이스를 밖으로 내보내지 않는다
    @ExceptionHandler(Exception::class)
    fun handleAny(e: Exception): ResponseEntity<ProblemDetail> {
        log.error("처리되지 않은 예외", e)
        return ResponseEntity.internalServerError().body(
            ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "일시적인 오류입니다"))
    }
}

ProblemDetail — 표준 에러 포맷

{
  "type": "about:blank",
  "title": "Conflict",
  "status": 409,
  "detail": "재고 부족 (남은 수량 2)",
  "instance": "/api/v1/orders",
  "code": "OUT_OF_STOCK",
  "traceId": "a1b2c3"
}

RFC 9457 표준입니다. 팀마다 다른 에러 포맷을 만드는 대신 표준을 쓰고 필드만 몇 개 더하는 편이 클라이언트 입장에서 좋습니다.

놓치기 쉬운 것들

  • 코루틴 컨트롤러의 예외도 @RestControllerAdvice가 잡습니다. 단 CancellationException(클라이언트 연결 끊김)은 에러 로그로 남기지 마세요
  • 404 를 예외로 만들지 “못 찾음”으로 만들지 정하세요 — 컨트롤러가 ResponseEntity.notFound()를 쓰는 방식도 좋습니다
  • 메시지에 내부 정보를 담지 않기 — SQL·스택·경로가 새면 보안 문제
  • @ExceptionHandler는 더 구체적인 타입이 우선합니다
  • 필터 단계에서 난 예외는 Advice 가 못 잡습니다 — 시큐리티 예외는 AuthenticationEntryPoint로 따로 처리
어디서 던져도 응답 형식은 한 곳에서 만듭니다 컨트롤러 서비스 리포지토리 @RestControllerAdvice @ExceptionHandler 모음 업무 예외 → 400 · 409 예상 못 한 오류 → 500 코틀린다운 방법 — sealed class sealed class AppError { data class NotFound… when 으로 처리하면 빠진 분기를 컴파일러가 알려 줍니다 에러 종류가 늘어도 처리를 빠뜨릴 수 없습니다 응답 본문은 한 형식으로 { code, message, fieldErrors, traceId } 프런트가 분기할 수 있게 code 를 문자열 상수로 내부 메시지·스택을 그대로 내보내지 마세요
try-catch 를 컨트롤러마다 흩어 두면 응답 형식이 제각각이 됩니다.
BOOT 08

계층 설계

코틀린은 확장 함수·sealed·data class 덕분에 계층 사이의 변환 코드와 상태 표현이 훨씬 가벼워집니다. 과하게 설계하지 않으면서 바꾸기 쉬운 구조를 만드는 법입니다.

계층은 '무엇을 부르느냐'가 아니라 '무엇을 모르느냐'로 나눕니다 Controller · 웹 계층 HTTP 만 압니다 — DTO ↔ 도메인 변환이 여기서 끝나야 합니다 Service · 응용 계층 트랜잭션 경계 — 유스케이스 하나가 공개 함수 하나 Repository · 영속 계층 쿼리와 엔티티만 압니다 — HTTP 는 몰라야 합니다 데이터베이스 역방향은 금지 Repository 가 DTO 를 반환 Service 가 서블릿 타입을 받음 Entity 를 그대로 응답으로 한 번 뚫리면 되돌리기 어렵다 코틀린에서 특히 엔티티는 data class 로 안 만든다 지연 로딩 프록시와 equals 가 충돌 DTO 는 data class 가 정답 toDto() 확장 함수로 변환을 모은다
계층이 무너지는 신호는 늘 같습니다 — 아래 계층 파일에 위 계층 타입이 import 되는 순간입니다.

기본 3계층 — 대부분의 서비스에 충분하다

WEB
Controller · DTOHTTP 를 도메인 요청으로 바꾼다. 로직 없음
▼
SERVICE
유스케이스 조합 · 트랜잭션 경계도메인 객체를 불러 명령하고 저장한다
▼
DOMAIN
엔티티 · 값 객체 · 도메인 규칙프레임워크를 모른다. 순수 코틀린
▼
INFRA
Repository · 외부 API 클라이언트JPA · HTTP · 메시지 큐

계층 사이 변환 — 확장 함수

// web/OrderMapper.kt — 프레젠테이션 계층 안에 둔다
fun Order.toResponse() = OrderResponse(
    id = id,
    status = status,
    totalAmount = totalAmount(),
    items = items.map { it.toResponse() },
)

fun CreateOrderRequest.toCommand(userId: Long) = PlaceOrderCommand(
    userId = userId,
    productId = productId,
    quantity = quantity,
    coupon = couponCode?.let(::CouponCode),
)

// 컨트롤러가 한 줄로 정리된다
@PostMapping
fun create(@Valid @RequestBody req: CreateOrderRequest,
           @AuthenticationPrincipal me: LoginUser) =
    service.place(req.toCommand(me.id)).toResponse()
MapStruct 같은 매퍼 라이브러리를 굳이 쓰지 않아도 됩니다 — 확장 함수 몇 줄이 더 읽기 쉽고 디버깅도 됩니다.

도메인을 살려 두기 — 코틀린이 잘하는 부분

// 값 객체 — 잘못된 값이 애초에 만들어지지 않는다
@JvmInline value class Quantity(val value: Int) {
    init { require(value in 1..99) { "수량은 1~99: $value" } }
}

// 도메인 규칙을 엔티티 안에 (빈약한 도메인 모델을 피한다)
class Order(
    val id: Long = 0,
    val userId: Long,
    items: List<OrderItem>,
) {
    var status: OrderStatus = OrderStatus.NEW
        private set                                  // 밖에서 못 바꾼다

    private val _items = items.toMutableList()
    val items: List<OrderItem> get() = _items.toList()

    fun totalAmount(): Long = _items.sumOf { it.subtotal() }

    fun cancel(now: LocalDateTime) {
        check(status.canCancel()) { "취소할 수 없는 상태: $status" }
        status = OrderStatus.CANCELED
    }

    fun addItem(item: OrderItem) {
        check(status == OrderStatus.NEW) { "확정된 주문은 수정할 수 없습니다" }
        _items += item
    }
}

// 서비스는 '조합'만 한다
@Service
@Transactional
class OrderService(private val repo: OrderRepository, private val stock: StockClient) {
    fun cancel(id: Long) {
        val order = repo.findByIdOrNull(id) ?: throw DomainException.NotFound("주문", id)
        order.cancel(LocalDateTime.now())            // 규칙은 도메인이 안다
        stock.restore(order.items)
    }
}
헥사고날·클린 아키텍처가 필요한 시점 — 팀이 여럿이거나 외부 연동이 많아 기술 교체가 실제로 일어나는 프로젝트입니다. 그 전에는 위의 3계층에 도메인을 살려 두는 것만으로 충분한 경우가 많습니다. 구조는 필요해질 때 옮기는 편이 비용이 적습니다.
BOOT 09

트랜잭션 — 코틀린에서 특히 조심할 것

@Transactional은 프록시로 동작합니다. 코틀린의 final 기본값, 내부 호출, suspend 함수 세 가지가 각각 다른 방식으로 이 프록시를 무력화할 수 있습니다.

왜 “같은 클래스 안에서 부르면 트랜잭션이 안 걸리나” 외부 호출 — 정상 Controller 프록시 (트랜잭션 시작) 실제 서비스 메서드 ✅ 내부 호출 — 무력화 서비스 메서드 A this.b() — 프록시를 건너뛴다 @Transactional b() ❌ 프록시는 지나가지 않음 해결 — 트랜잭션이 필요한 메서드를 별도 빈으로 분리하거나, TransactionTemplate 을 직접 쓴다
코틀린이든 자바든 같은 문제지만, 코틀린은 여기에 final과 suspend가 더해집니다.

세 가지 함정

@Service
class OrderService(private val repo: OrderRepository) {

    // ① final — plugin.spring 이 없으면 프록시 자체를 못 만든다
    //    (플러그인이 @Service 클래스를 열어 주므로 보통은 문제없다)
    //    단 '함수'는 열리지 않는 경우가 있어 open 이 필요할 수 있다

    @Transactional
    fun place(cmd: PlaceOrderCommand): Order {
        val order = repo.save(Order(cmd))
        // ② 내부 호출 — 이 호출에는 REQUIRES_NEW 가 걸리지 않는다
        writeAuditLog(order)
        return order
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun writeAuditLog(order: Order) { /* ... */ }
}
③ suspend 함수 + @Transactional 은 기본적으로 동작하지 않습니다. 스프링 트랜잭션은 ThreadLocal 기반인데 코루틴은 스레드를 옮겨 다니기 때문입니다. 아래 해법을 보세요.

코루틴에서 트랜잭션 쓰기

// 방법 1 — 트랜잭션 구간을 블로킹으로 가둔다 (JPA·JDBC 를 쓸 때 현실적)
@Service
class OrderService(
    private val tx: TransactionTemplate,
    private val repo: OrderRepository,
) {
    suspend fun place(cmd: PlaceOrderCommand): Order =
        withContext(Dispatchers.IO) {
            tx.execute { repo.save(Order(cmd)) }!!   // 한 스레드 안에서 시작·종료
        }
}

// 방법 2 — R2DBC(리액티브 DB)면 코루틴 트랜잭션이 그대로 된다
@Service
class ReactiveOrderService(private val operator: TransactionalOperator) {
    suspend fun place(cmd: PlaceOrderCommand): Order =
        operator.executeAndAwait { repo.save(Order(cmd)).awaitSingle() }
}

// 방법 3 — 블로킹 서비스(@Transactional)를 얇게 감싼다 (가장 흔한 구조)
@Service
class OrderFacade(private val blockingService: OrderService) {
    suspend fun place(cmd: PlaceOrderCommand) =
        withContext(Dispatchers.IO) { blockingService.place(cmd) }
}

트랜잭션 기본기 — 코틀린 코드로

@Service
class PaymentService(
    private val orderRepo: OrderRepository,
    private val paymentClient: PaymentClient,
    private val events: ApplicationEventPublisher,
) {
    // 읽기 전용 — 스냅숏·플러시 생략으로 성능 이득
    @Transactional(readOnly = true)
    fun get(id: Long): Order = orderRepo.findByIdOrNull(id)
        ?: throw DomainException.NotFound("주문", id)

    @Transactional(
        timeout = 5,
        rollbackFor = [Exception::class],       // 코틀린은 checked 구분이 없어 관례상 명시
    )
    fun pay(id: Long, amount: Long) {
        val order = orderRepo.findByIdOrNull(id) ?: throw DomainException.NotFound("주문", id)
        order.markPaid(amount)

        // ⚠️ 외부 API 호출을 트랜잭션 안에 두지 마세요 —
        //    커넥션을 잡은 채 수 초를 기다리게 됩니다
        events.publishEvent(OrderPaidEvent(order.id))   // 커밋 후 처리로 미룬다
    }
}

// 커밋 이후에만 실행 — 결제 알림·검색 색인 같은 후속 작업의 정석
@Component
class OrderEventListener(private val notifier: Notifier) {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun onPaid(e: OrderPaidEvent) = notifier.send("결제 완료 ${e.orderId}")
}
실무 규칙 셋 — ① 트랜잭션은 서비스 메서드 하나를 경계로, ② 외부 호출·긴 계산은 트랜잭션 밖으로, ③ 조회 전용 메서드에는 readOnly = true. 트랜잭션 전파·격리 수준은 데이터 · JPA 탭에서 더 깊이 다룹니다.
BOOT 10

코루틴과 스프링 — 실전 결합

suspend 컨트롤러를 쓸 때 실제로 무엇이 달라지는지, MVC · WebFlux · 가상 스레드 중 무엇을 고를지 정리합니다.

컨트롤러에 suspend 를 붙이면 스프링이 대신 해 주는 일 ① 보통 컨트롤러 — 기다리는 동안 스레드를 잡고 있다 컨트롤러 DB 응답 대기 200ms — 스레드 점유 응답 톰캣 스레드가 200개라면 동시에 200건이 상한 — 스레드를 늘리는 것 말고 방법이 없습니다 ② suspend 컨트롤러 — 기다리는 동안 스레드를 놓는다 컨트롤러 중단 — 그 스레드는 다른 요청을 처리 재개 · 응답 스레드는 계산할 때만 잡힙니다 — 같은 스레드 수로 훨씬 많은 동시 요청을 받습니다 단, 안에서 JDBC 같은 블로킹 호출을 그대로 부르면 이득이 0 입니다 — 그 순간 스레드가 다시 붙잡힙니다.
suspend 는 기다림을 스레드에서 떼어내는 장치입니다 — 기다림이 없으면 붙여도 달라지는 게 없습니다.
구성스레드 모델고르는 기준
MVC + 블로킹요청당 스레드가장 단순. 트래픽이 크지 않으면 이걸로 충분
MVC + suspend요청 스레드 반납 + 코루틴외부 API 병렬 호출이 많을 때 이득. JDBC 는 여전히 블로킹
MVC + 가상 스레드가상 스레드 (JDK 21+)코드를 안 바꾸고 동시성만 올리고 싶을 때
WebFlux + 코루틴이벤트 루프 + 코루틴커넥션이 아주 많고 전 구간 논블로킹(R2DBC)일 때

가상 스레드 켜기 — 한 줄

spring:
  threads:
    virtual:
      enabled: true      # Boot 3.2+ · JDK 21+

톰캣이 요청마다 가상 스레드를 만듭니다. 기존 블로킹 코드 그대로 동시성이 크게 오릅니다. 다만 DB 커넥션 풀은 여전히 유한하므로 병목이 그쪽으로 옮겨갈 뿐인 경우도 많습니다 — 측정하고 결정하세요.

synchronized 안에서 블로킹하면 가상 스레드가 캐리어에 고정(pinning)됩니다. JDK 24 부터 상당 부분 해소됐지만, 라이브러리 버전에 따라 여전히 확인이 필요합니다.

suspend 컨트롤러가 실제로 하는 일

@GetMapping("/api/dashboard")
suspend fun dashboard(@AuthenticationPrincipal me: LoginUser): DashboardResponse =
    coroutineScope {
        val orders = async { orderClient.recent(me.id) }    // 외부 API
        val points = async { pointClient.get(me.id) }       // 외부 API
        val coupons = async { couponClient.list(me.id) }    // 외부 API
        DashboardResponse(orders.await(), points.await(), coupons.await())
    }

// 세 API 가 각 150ms 라면
// 기존 방식: 450ms + 요청 스레드 450ms 점유
// 코루틴:    ~150ms + 요청 스레드는 기다리는 동안 반납

코루틴을 쓸 때 스프링에서 챙길 것

// 1) MDC(traceId) 전파 — 로그가 끊기지 않게
@Component
class CoroutineMdcFilter : OncePerRequestFilter() {
    override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
        MDC.put("traceId", req.getHeader("X-Trace-Id") ?: UUID.randomUUID().toString())
        try { chain.doFilter(req, res) } finally { MDC.clear() }
    }
}
// 코루틴 안에서 유지하려면
suspend fun handle() = withContext(MDCContext()) { service.process() }

// 2) 시큐리티 컨텍스트 전파
// SecurityContextHolder 도 ThreadLocal 이다.
// 필요한 값(사용자 id·권한)은 코루틴 시작 전에 꺼내서 인자로 넘기는 편이 안전하다.
suspend fun place(cmd: PlaceOrderCommand, userId: Long) { /* ... */ }

// 3) 블로킹 레포지터리 감싸기 — 커넥션 풀 크기에 맞춰 제한
private val dbDispatcher = Dispatchers.IO.limitedParallelism(20)
suspend fun findOrder(id: Long) = withContext(dbDispatcher) { repo.findByIdOrNull(id) }

// 4) 클라이언트가 끊으면 취소된다 — 로그를 에러로 남기지 말 것
try { longWork() }
catch (e: CancellationException) { log.debug("클라이언트 취소"); throw e }
WebClient · RestClient 와 코루틴 — WebClient는 awaitBody<T>()로 자연스럽게 코루틴과 붙습니다. Spring 6 의 RestClient는 블로킹이므로 withContext(IO)가 필요합니다. 자세한 사용법은 WebFlux 탭에서 다룹니다.
BOOT 11

Spring Security — 코틀린 DSL

스프링 시큐리티는 코틀린 전용 DSL을 제공합니다. 자바 람다 체인보다 훨씬 읽기 좋고, 어떤 경로가 어떤 규칙인지가 한눈에 들어옵니다.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity           // @PreAuthorize 를 쓰려면
class SecurityConfig(
    private val jwtFilter: JwtAuthenticationFilter,
) {
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            csrf { disable() }                         // 토큰 기반 API 라면
            cors { }                                    // CorsConfigurationSource 빈 사용
            sessionManagement {
                sessionCreationPolicy = SessionCreationPolicy.STATELESS
            }
            authorizeHttpRequests {
                authorize("/actuator/health", permitAll)
                authorize("/api/auth/**", permitAll)
                authorize(HttpMethod.GET, "/api/products/**", permitAll)
                authorize("/api/admin/**", hasRole("ADMIN"))
                authorize("/api/**", authenticated)
                authorize(anyRequest, denyAll)          // 명시하지 않은 것은 막는다
            }
            addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtFilter)
            exceptionHandling {
                authenticationEntryPoint = AuthenticationEntryPoint { _, res, _ ->
                    res.status = 401
                    res.contentType = "application/json"
                    res.writer.write("""{"code":"UNAUTHORIZED"}""")
                }
                accessDeniedHandler = AccessDeniedHandler { _, res, _ -> res.status = 403 }
            }
            headers { frameOptions { sameOrigin = true } }   // 값을 대입해야 적용된다
        }
        return http.build()
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}

JWT 필터 — 코틀린으로

@Component
class JwtAuthenticationFilter(
    private val jwt: JwtProvider,
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        chain: FilterChain,
    ) {
        request.getHeader(HttpHeaders.AUTHORIZATION)
            ?.takeIf { it.startsWith("Bearer ") }
            ?.removePrefix("Bearer ")
            ?.let { token -> runCatching { jwt.parse(token) }.getOrNull() }
            ?.let { claims ->
                val auth = UsernamePasswordAuthenticationToken(
                    LoginUser(claims.userId, claims.roles),
                    null,
                    claims.roles.map { SimpleGrantedAuthority("ROLE_$it") },
                )
                SecurityContextHolder.getContext().authentication = auth
            }
        chain.doFilter(request, response)
    }
}

메서드 보안 · 현재 사용자

@Service
class OrderService(private val repo: OrderRepository) {

    @PreAuthorize("hasRole('ADMIN')")
    fun deleteAll() = repo.deleteAll()

    // SpEL 로 소유권 검사
    @PreAuthorize("#userId == authentication.principal.id")
    fun listOf(userId: Long) = repo.findByUserId(userId)

    @PostAuthorize("returnObject.userId == authentication.principal.id")
    fun get(id: Long): Order = repo.findByIdOrNull(id) ?: error("없음")
}

// 컨트롤러에서 현재 사용자 받기
@GetMapping("/me")
fun me(@AuthenticationPrincipal user: LoginUser) = user

// 커스텀 애너테이션으로 짧게
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@AuthenticationPrincipal
annotation class CurrentUser

@GetMapping("/orders")
fun orders(@CurrentUser user: LoginUser) = service.listOf(user.id)
코루틴 컨트롤러에서 SecurityContextHolder가 비어 있는 문제 — ThreadLocal 기반이라 코루틴이 다른 스레드에서 재개되면 사라집니다. @AuthenticationPrincipal로 인자로 받아 코루틴 안으로 값을 넘기는 것이 가장 단순하고 안전한 해법입니다.
BOOT 12

테스트 — MockK · Testcontainers

코틀린에서는 Mockito 대신 MockK를 씁니다 — final 클래스와 suspend 함수를 기본으로 지원하기 때문입니다.

의존성 — testImplementation("io.mockk:mockk:1.13.13") · testImplementation("com.ninja-squad:springmockk:4.0.2")(@MockkBean) · testImplementation("org.testcontainers:postgresql")

단위 테스트 — 스프링 없이

class OrderServiceTest {
    private val repo = mockk<OrderRepository>()
    private val stock = mockk<StockClient>(relaxed = true)   // 반환값 신경 안 씀
    private val service = OrderService(repo, stock)

    @Test
    fun `재고가 부족하면 주문에 실패한다`() {
        every { stock.available(1L) } returns 0

        val ex = assertThrows<DomainException.OutOfStock> {
            service.place(PlaceOrderCommand(productId = 1, quantity = 1))
        }
        assertEquals(0, ex.left)
        verify(exactly = 0) { repo.save(any()) }      // 저장하지 않았다
    }

    @Test
    fun `suspend 함수도 그대로`() = runTest {
        coEvery { stock.reserveAsync(any()) } returns true    // coEvery
        service.placeAsync(cmd)
        coVerify { stock.reserveAsync(1L) }                   // coVerify
    }
}

MockK 문법 요약

문법뜻
mockk<T>()목 생성 (final 클래스도 OK)
mockk(relaxed = true)모든 호출에 기본값 반환
every { } returns동작 지정
coEvery { }suspend 함수용
verify { } / coVerify호출 검증
slot<T>() + capture인자 캡처
spyk(real)일부만 목으로
mockkStatic(::now)최상위·정적 함수 목킹
// 인자 캡처
val slot = slot<Order>()
every { repo.save(capture(slot)) } answers { slot.captured }
service.place(cmd)
assertEquals(2, slot.captured.items.size)

통합 테스트 — 실제 DB로

@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class OrderApiTest(
    @Autowired val mockMvc: MockMvc,
    @Autowired val mapper: ObjectMapper,
) {
    companion object {
        @Container
        @ServiceConnection                    // Boot 3.1+ — 접속 정보를 자동 주입
        val postgres = PostgreSQLContainer("postgres:16-alpine")
    }

    @MockkBean lateinit var paymentClient: PaymentClient   // 외부 API 만 목으로

    @Test
    fun `주문을 생성하면 201 과 Location 을 돌려준다`() {
        every { paymentClient.charge(any()) } returns PaymentResult.ok("tx-1")

        mockMvc.post("/api/v1/orders") {
            contentType = MediaType.APPLICATION_JSON
            content = mapper.writeValueAsString(CreateOrderRequest(productId = 1, quantity = 2))
            header("Authorization", "Bearer $testToken")
        }.andExpect {
            status { isCreated() }
            header { exists("Location") }
            jsonPath("$.totalAmount") { value(24000) }
        }
    }
}

// 코루틴 컨트롤러는 WebTestClient 가 편하다
@Test
fun `suspend 엔드포인트`() {
    webTestClient.get().uri("/api/dashboard")
        .exchange()
        .expectStatus().isOk
        .expectBody<DashboardResponse>()
        .consumeWith { assertNotNull(it.responseBody) }
}

테스트 픽스처 — 코틀린 기본값 활용

// 기본값을 준 팩토리 함수 하나면 빌더가 필요 없다
fun order(
    id: Long = 1,
    userId: Long = 100,
    status: OrderStatus = OrderStatus.NEW,
    items: List<OrderItem> = listOf(orderItem()),
) = Order(id, userId, status, items)

fun orderItem(productId: Long = 1, quantity: Int = 1, price: Long = 12_000) =
    OrderItem(productId, quantity, price)

// 테스트에서 바꾸고 싶은 것만 적는다
val canceled = order(status = OrderStatus.CANCELED)
val bulk = order(items = List(10) { orderItem(productId = it.toLong()) })

Kotest — 다른 스타일의 선택지

class OrderSpec : BehaviorSpec({
    given("재고가 0인 상품") {
        val stock = mockk<StockClient> { every { available(1) } returns 0 }
        `when`("주문하면") {
            then("OutOfStock 예외가 난다") {
                shouldThrow<DomainException.OutOfStock> {
                    OrderService(repo, stock).place(cmd)
                }
            }
        }
    }
})

// 프로퍼티 기반 테스트도 강력하다
class MoneySpec : StringSpec({
    "덧셈은 교환법칙을 만족한다" {
        checkAll<Long, Long> { a, b ->
            Money(a) + Money(b) shouldBe Money(b) + Money(a)
        }
    }
})
JUnit5 + MockK 가 기본, Kotest 는 취향입니다. 팀에 자바 경험자가 많으면 JUnit5 가 진입 장벽이 낮습니다.
BOOT 13

AOP · 필터 · 인터셉터

로깅·감사·성능 측정처럼 모든 요청에 걸쳐 반복되는 코드를 한 곳으로 모으는 도구입니다. 셋의 적용 지점이 다르므로 목적에 맞게 골라야 합니다.

적용 순서 — 바깥에서 안으로 ① 서블릿 필터 — 인증·CORS·요청 로깅·MDC ② 스프링 인터셉터 — 권한 검사·요청 컨텍스트·핸들러 정보 접근 ③ AOP 어드바이스 — 트랜잭션·캐시·재시도·감사 로그 실제 컨트롤러 · 서비스 메서드 필터는 서블릿 스펙 · 인터셉터는 스프링MVC · AOP 는 빈 메서드 단위
응답 헤더를 손대야 하면 필터, 핸들러 정보가 필요하면 인터셉터, 서비스 로직을 감싸려면 AOP입니다.

AOP — 실행 시간 측정 애너테이션

@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Timed(val name: String = "")

@Aspect
@Component
class TimedAspect(private val registry: MeterRegistry) {

    @Around("@annotation(timed)")
    fun measure(pjp: ProceedingJoinPoint, timed: Timed): Any? {
        val name = timed.name.ifBlank { pjp.signature.toShortString() }
        val sample = Timer.start(registry)
        return try {
            pjp.proceed()
        } finally {
            sample.stop(registry.timer("method.timed", "name", name))
        }
    }
}

@Service
class OrderService {
    @Timed("order.place")
    fun place(cmd: PlaceOrderCommand): Order { /* ... */ }
}
AOP 도 프록시 기반이라 09의 세 가지 함정이 똑같이 적용됩니다 — 내부 호출에는 걸리지 않습니다.

필터 · 인터셉터

// 필터 — 요청/응답 전체를 감싼다
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class RequestLoggingFilter : OncePerRequestFilter() {
    override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
        val start = System.currentTimeMillis()
        val traceId = req.getHeader("X-Trace-Id") ?: UUID.randomUUID().toString().take(8)
        MDC.put("traceId", traceId)
        res.setHeader("X-Trace-Id", traceId)
        try {
            chain.doFilter(req, res)
        } finally {
            log.info("{} {} {} {}ms", req.method, req.requestURI, res.status,
                System.currentTimeMillis() - start)
            MDC.clear()
        }
    }
}

// 인터셉터 — 핸들러(어느 컨트롤러 메서드인지)를 알 수 있다
@Component
class AuditInterceptor : HandlerInterceptor {
    override fun preHandle(req: HttpServletRequest, res: HttpServletResponse, handler: Any): Boolean {
        if (handler is HandlerMethod) {
            handler.getMethodAnnotation(Audited::class.java)?.let { audit(it, req) }
        }
        return true
    }
}

@Configuration
class WebConfig(private val audit: AuditInterceptor) : WebMvcConfigurer {
    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(audit).addPathPatterns("/api/**")
    }
}
BOOT 14

스케줄러 · 비동기 · 캐시

주기 작업, 백그라운드 처리, 캐싱 — 세 가지 모두 애너테이션 한 줄로 시작할 수 있지만 운영에서 문제가 되는 지점이 각각 다릅니다.

스케줄러

@Configuration
@EnableScheduling
class SchedulerConfig

@Component
class SettlementJob(private val service: SettlementService) {

    // 매일 새벽 3시
    @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul")
    fun daily() {
        runCatching { service.settleYesterday() }
            .onFailure { log.error("정산 실패", it) }    // 여기서 잡아 맥락 있는 로그·알림을 남긴다 (안 잡아도 다음 실행은 된다)
    }

    // 이전 실행이 끝난 뒤 5초
    @Scheduled(fixedDelay = 5_000, initialDelay = 10_000)
    fun poll() = service.pollOutbox()
}
서버가 여러 대면 스케줄러도 여러 번 돕니다. ShedLock이나 DB 락으로 한 번만 실행되게 해야 합니다 — 정산·메일 발송이 중복되는 사고의 원인입니다. 기본 스케줄러 스레드는 1개라 긴 작업이 다른 작업을 밀어냅니다 (spring.task.scheduling.pool.size).

비동기 — @Async vs 코루틴

@Configuration
@EnableAsync
class AsyncConfig {
    @Bean("mailExecutor")
    fun mailExecutor(): Executor = ThreadPoolTaskExecutor().apply {
        corePoolSize = 4; maxPoolSize = 8; queueCapacity = 500
        setThreadNamePrefix("mail-")
        setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy())
        initialize()
    }
}

@Service
class MailService {
    @Async("mailExecutor")
    fun sendAsync(to: String) { /* ... */ }     // 반환 즉시 다른 스레드로
}

// 코루틴을 쓴다면 @Async 대신 스코프를 직접 관리하는 편이 명확하다
@Service
class MailService2 : DisposableBean {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    fun sendAsync(to: String) = scope.launch { send(to) }
    override fun destroy() = scope.cancel()
}

캐시 — @Cacheable 과 코틀린

@Configuration
@EnableCaching
class CacheConfig {
    @Bean
    fun cacheManager(): CacheManager = CaffeineCacheManager().apply {
        setCaffeine(
            Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(10.minutes.toJavaDuration())
                .recordStats()
        )
    }
}

@Service
class ProductService(private val repo: ProductRepository) {

    @Cacheable(cacheNames = ["product"], key = "#id", unless = "#result == null")
    fun get(id: Long): Product? = repo.findByIdOrNull(id)

    @CacheEvict(cacheNames = ["product"], key = "#product.id")
    fun update(product: Product): Product = repo.save(product)

    @CacheEvict(cacheNames = ["product"], allEntries = true)
    fun reloadAll() { /* ... */ }
}

코틀린에서 주의할 점

  • suspend 함수에는 @Cacheable이 동작하지 않습니다 — 반환 타입이 Object(Continuation 방식)이기 때문. 블로킹 함수로 감싸거나 캐시를 직접 다루세요
  • null 캐싱 — unless = "#result == null"을 안 주면 null 도 캐시됩니다(의도적이면 괜찮습니다)
  • 내부 호출은 캐시가 안 걸립니다 — 프록시 문제(09)
  • 캐시 키에 data class를 쓰면 equals/hashCode 가 자동이라 안전합니다

코루틴 캐시 — 직접 다루기

@Component
class ProductCache(private val repo: ProductRepository) {
    private val cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(10.minutes.toJavaDuration())
        .build<Long, Product>()

    private val locks = ConcurrentHashMap<Long, Mutex>()

    // 캐시 스탬피드 방지 — 같은 키는 한 번만 조회
    suspend fun get(id: Long): Product? {
        cache.getIfPresent(id)?.let { return it }
        val mutex = locks.computeIfAbsent(id) { Mutex() }
        return mutex.withLock {
            cache.getIfPresent(id) ?: repo.findAsync(id)?.also { cache.put(id, it) }
        }.also { locks.remove(id) }
    }
}
BOOT 15

로깅 · 관측 · Actuator

운영에서 가장 먼저 필요한 것은 “지금 무슨 일이 일어나는지 보이는가”입니다. 로그·메트릭·추적 세 가지를 코틀린 코드로 어떻게 붙이는지 정리합니다.

로거 선언 — 세 가지 방식

// ① companion object (가장 흔하다)
class OrderService {
    companion object {
        private val log = LoggerFactory.getLogger(OrderService::class.java)
    }
}

// ② 최상위 프로퍼티 (파일 단위)
private val log = LoggerFactory.getLogger("OrderService")

// ③ 확장 함수로 공통화 — 프로젝트에 하나 만들어 두면 편하다
inline fun <reified T> T.logger(): Logger = LoggerFactory.getLogger(T::class.java)

class OrderService {
    private val log = logger()
}

// kotlin-logging 라이브러리를 쓰면 더 짧다
// implementation("io.github.oshai:kotlin-logging-jvm:7.0.0")
private val logger = KotlinLogging.logger {}
logger.info { "메시지 — 람다라서 레벨이 꺼져 있으면 문자열을 만들지 않는다" }

구조화 로깅 · Actuator

logging:
  structured:
    format:
      console: ecs        # Boot 3.4+ — JSON 로그 (ECS · Logstash · GELF)
  level:
    root: INFO
    com.example: DEBUG

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      probes:
        enabled: true     # /actuator/health/liveness · readiness
  metrics:
    tags:
      application: order-service
// 커스텀 헬스 체크
@Component
class PaymentHealthIndicator(private val client: PaymentClient) : HealthIndicator {
    override fun health(): Health =
        runCatching { client.ping() }
            .fold(
                onSuccess = { Health.up().withDetail("latency", it).build() },
                onFailure = { Health.down(it).build() },
            )
}

메트릭 — Micrometer

@Service
class OrderService(private val registry: MeterRegistry) {

    private val placed = registry.counter("orders.placed")
    private val timer = registry.timer("orders.place.duration")

    fun place(cmd: PlaceOrderCommand): Order =
        timer.recordCallable {
            doPlace(cmd).also { placed.increment() }
        }!!

    // 게이지 — 현재 값을 관찰
    init {
        registry.gauge("orders.pending", this) { it.countPending().toDouble() }
    }
}

// 태그를 붙이면 대시보드에서 쪼개 볼 수 있다
registry.counter("orders.placed", "channel", cmd.channel, "result", "success").increment()

로그를 쓸모 있게 만드는 규칙

  • 문자열 템플릿 대신 파라미터를 쓰세요 — log.info("주문 {}", id). 레벨이 꺼져 있으면 문자열을 안 만듭니다 (코틀린 "$id"는 항상 만듭니다)
  • traceId 를 MDC 에 넣고 모든 로그에 자동 포함
  • 개인정보·토큰·카드번호를 로그에 남기지 않기 (마스킹 유틸을 확장 함수로)
  • 예외는 메시지가 아니라 예외 객체로 넘기기 — log.error("실패", e). "$e"는 스택이 안 남습니다
  • 정상 흐름에 ERROR 를 쓰지 않기 — 알람이 무뎌집니다
BOOT 16

빌드 · 배포 · 네이티브

코틀린 스프링 앱을 컨테이너로 만들고, 필요하면 네이티브 이미지로 굽는 과정입니다. 기동 시간과 메모리가 중요한 환경(서버리스·오토스케일)에서 차이가 큽니다.

이미지 만들기 — Dockerfile 없이

# Cloud Native Buildpacks — 부트가 최적화된 이미지를 만들어 준다
./gradlew bootBuildImage --imageName=myapp:1.0.0

# 계층이 분리되어 재빌드가 빠르다 (의존성 / 앱 코드)
# JVM 튜닝·CDS·헬스체크가 이미 들어 있다
# 직접 쓰는 경우 — 최소 형태 (JRE 이미지 + jar 하나. 레이어 분리는 하지 않은 버전)
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
COPY build/libs/app.jar app.jar
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+UseZGC"
EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"]

GraalVM 네이티브 이미지

plugins {
    id("org.graalvm.buildtools.native") version "0.10.3"
}

// ./gradlew nativeCompile
// ./gradlew nativeRun

// 리플렉션을 쓰는 코드는 힌트를 등록해야 한다
@Configuration
@ImportRuntimeHints(AppRuntimeHints::class)
class NativeConfig

class AppRuntimeHints : RuntimeHintsRegistrar {
    override fun registerHints(hints: RuntimeHints, cl: ClassLoader?) {
        hints.reflection().registerType(OrderResponse::class.java) {
            it.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                           MemberCategory.INVOKE_PUBLIC_METHODS)
        }
        hints.resources().registerPattern("templates/*.html")
    }
}
효과 — 기동 수십 ms, 메모리 1/3~1/5. 대신 빌드가 오래 걸리고(수 분), 동적 리플렉션·프록시가 제한됩니다. 코틀린 쪽에서는 kotlinx.serialization이 Jackson 보다 궁합이 좋습니다.

운영 체크리스트

항목확인할 것
기동·종료server.shutdown=graceful · 프로브(liveness/readiness) 분리
메모리컨테이너에서 -XX:MaxRAMPercentage — 고정 -Xmx 대신
커넥션 풀HikariCP 크기와 코루틴 동시 실행 제한을 맞췄는가
타임아웃외부 호출 · DB · HTTP 클라이언트 전부 설정했는가
로그구조화(JSON) · traceId · 개인정보 마스킹
메트릭/actuator/prometheus 노출 · 커스텀 지표 · 알람 임계값
설정비밀값은 환경변수·시크릿으로 (yml 에 넣지 않기)
빌드ktlint·detekt·테스트를 CI 에서 강제
다음 탭 예고 — WebFlux · 코루틴 웹에서는 Reactor 와 코루틴을 오가는 방법, coRouter 함수형 라우팅, WebClient, R2DBC, 백프레셔를 다룹니다. 데이터 · JPA 탭에서는 코틀린 엔티티 설계의 함정(data class·equals·지연 로딩)과 Exposed·jOOQ 같은 코틀린 네이티브 선택지를 비교합니다.
WEBFLUX · REACTOR ↔ 코루틴 · R2DBC · SSE

WebFlux를
코틀린으로 쓰면
Reactor가 거의 안 보인다

flatMap·zipWith 체인을 배우지 않아도 됩니다. suspend 함수와 Flow로 쓰고, 프레임워크 경계에서만 Mono·Flux로 바꾸면 됩니다. 이 탭은 그 변환 지점과, 정말 WebFlux가 필요한 상황을 구분하는 기준을 다룹니다.

16SECTIONS
2스레드 모델의 차이
1규칙 — 이벤트 루프를 막지 마라
100%코루틴 스타일 예제
FLUX 01

왜 WebFlux인가 — 그리고 언제 아닌가

WebFlux는 “더 빠른 스프링”이 아닙니다. 적은 스레드로 많은 커넥션을 유지하기 위한 모델이고, 그 대가로 전 구간이 논블로킹이어야 합니다. 한 군데라도 막히면 MVC보다 나빠집니다.

스레드 모델 — 무엇이 한계가 되는가 Spring MVC (톰캣) 요청 200개 스레드 풀 (기본 200) 요청 1개 = 스레드 1개 점유 스레드가 곧 동시 처리 수 · 메모리는 스레드 × 1MB WebFlux (Netty) 커넥션 5만 이벤트 루프 (코어 수) 여기서 막히면 전부 멈춘다 스레드 8개로 수만 커넥션 — 대신 블로킹 코드가 하나라도 있으면 재앙 JDBC · 파일 IO · Thread.sleep · 무거운 계산이 그 자리다 스레드 스택 메모리 — 하나당 약 1MB 라서 25배가 벌어집니다 MVC 스레드 200개 약 200MB WebFlux 루프 8개 약 8MB 0 50 100 150 200MB
WebFlux의 이득은 커넥션이 아주 많을 때 나옵니다 — 그렇지 않다면 MVC가 더 단순하고 빠릅니다.

WebFlux 가 맞는 상황

  • 동시 커넥션이 수천~수만 — 채팅·알림·SSE·롱폴링
  • 게이트웨이·프록시 — 요청을 받아 다른 서비스로 넘기는 일이 대부분
  • 스트리밍 — 대용량 응답을 조금씩 흘려보내야 할 때
  • 전 구간 논블로킹이 가능 — R2DBC·논블로킹 HTTP 클라이언트만 사용

MVC 로 충분한 상황 (대부분)

  • JPA·JDBC 를 쓴다 — 어차피 블로킹이라 이득이 제한적
  • 초당 수백~수천 요청 — 톰캣 + 가상 스레드로 충분
  • 팀에 리액티브 경험이 적다 — 디버깅 비용이 큽니다
  • 트랜잭션이 복잡한 업무 시스템 — JPA 생태계의 이점이 큽니다
2026년의 현실적 판단 — “동시성”만 필요하면 MVC + 가상 스레드가 훨씬 싼 선택입니다. WebFlux 는 스트리밍·백프레셔·게이트웨이처럼 모델 자체가 필요한 곳에서 고르세요.
의존성 — implementation("org.springframework.boot:spring-boot-starter-webflux") · implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") (Reactor ↔ 코루틴 변환에 필수) · R2DBC 를 쓰면 spring-boot-starter-data-r2dbc
FLUX 02

Mono와 Flux — 최소한만

코틀린으로 쓸 거라면 Reactor 연산자를 전부 외울 필요가 없습니다. 이 두 타입이 무엇인지와 코루틴으로 바꾸는 법만 알면 됩니다.

Mono 와 Flux — 값이 몇 개 흐르느냐만 다릅니다 Mono<T> — 0개 또는 1개 1 Mono 값 하나가 오고 완료. 값이 없으면 그냥 완료(empty) — findById 의 결과가 이 모양입니다 Flux<T> — 0개부터 무한대까지 1 2 3 4 Flux 값이 여럿 오고 완료. 무한 스트림이면 완료 신호가 영영 오지 않습니다 에러가 나면 — 값이 아니라 종료 신호입니다 1 2 ✕ 여기서 끝 — 뒤로는 아무것도 오지 않습니다
코루틴으로 옮기면 Mono 는 suspend 함수, Flux 는 Flow입니다 — 대응이 정확히 1:1 입니다.

두 타입의 뜻

타입뜻 · 코루틴 대응
Mono<T>0 또는 1개 → suspend fun (): T?
Flux<T>0..N 개 → Flow<T>
Mono<Void>완료 신호만 → suspend fun (): Unit
// Reactor 스타일 (자바에서 오던 코드)
fun findUser(id: Long): Mono<User> =
    repo.findById(id)
        .switchIfEmpty(Mono.error(NotFound(id)))
        .flatMap { u -> enrich(u) }
        .timeout(Duration.ofSeconds(3))
        .onErrorResume(TimeoutException::class.java) { Mono.just(User.GUEST) }   // 타임아웃만 폴백 (NotFound 는 그대로)

// 코틀린 코루틴 스타일 — 같은 일
suspend fun findUser(id: Long): User =
    withTimeoutOrNull(3.seconds) {
        val u = repo.findById(id).awaitSingleOrNull() ?: throw NotFound(id)
        enrich(u)
    } ?: User.GUEST

Cold·구독·백프레셔 — 개념은 Flow와 같다

  • 구독하기 전엔 아무 일도 없다 — Flow 의 collect와 같은 규칙
  • 백프레셔 — 소비자가 request(n)으로 속도를 조절. 코루틴 Flow 는 중단(suspend)으로 같은 효과를 냅니다
  • 연산자 체인 — Flow 의 map·filter·buffer와 대응
Mono 를 만들어 놓고 구독하지 않으면 아무 일도 일어나지 않습니다. repo.save(x)만 부르고 반환값을 버리는 코드가 대표적인 버그입니다 — 코루틴에서는 awaitSingle()을 부르지 않은 것과 같습니다.
FLUX 03

Reactor ↔ 코루틴 변환

실무에서 가장 많이 쓰는 표입니다. 경계에서만 변환하고 안쪽은 전부 코루틴으로 쓰는 것이 코틀린 WebFlux의 표준 전략입니다.

방향코드
Mono → 값mono.awaitSingle() · 비어 있을 수 있으면 awaitSingleOrNull()
Mono<Void> → Unitmono.awaitFirstOrNull() · mono.thenAwait()
Flux → Flowflux.asFlow()
Flux → Listflux.collectList().awaitSingle()
suspend → Monomono { getUser(id) }
Flow → Fluxflow.asFlux()
Publisher → Flowpublisher.asFlow()
Flow → Publisherflow.asPublisher()
@Service
class UserService(
    private val repo: UserR2dbcRepository,     // 리액티브 리포지터리
    private val client: WebClient,
) {
    // 안쪽은 전부 코루틴 — 읽기 쉽고 try-catch 가 통한다
    suspend fun profile(id: Long): Profile = coroutineScope {
        val user = async { repo.findById(id).awaitSingleOrNull() ?: throw NotFound(id) }
        val orders = async {
            client.get().uri("/orders?userId=$id")
                .retrieve()
                .bodyToFlux<Order>()
                .asFlow()
                .toList()
        }
        Profile(user.await(), orders.await())
    }

    // 스트리밍은 Flow 로 그대로
    fun stream(): Flow<Event> =
        repo.findAllByOrderByIdDesc()          // Flux<Event>
            .asFlow()
            .map { it.copy(seen = true) }
            .onEach { log.debug("이벤트 {}", it.id) }

    // 자바/Reactor 를 쓰는 쪽에 넘길 때만 되돌린다
    fun profileMono(id: Long): Mono<Profile> = mono { profile(id) }
}
변환 지점은 세 곳뿐입니다 — ① 리액티브 리포지터리에서 값을 꺼낼 때, ② WebClient 응답을 받을 때, ③ 프레임워크가 Mono·Flux 를 요구할 때 (스프링은 suspend·Flow를 이미 이해하므로 ③은 드뭅니다). 나머지 코드는 코루틴만 보이게 유지하세요.
FLUX 04

라우팅 — coRouter DSL

WebFlux는 애너테이션 방식과 함수형 라우팅 두 가지를 지원합니다. 코틀린에는 coRouter라는 코루틴용 DSL이 있어, 라우팅 규칙이 한 파일에서 한눈에 보입니다.

coRouter — 라우팅 정의

@Configuration
class OrderRoutes(private val handler: OrderHandler) {

    @Bean
    fun orderRouter() = coRouter {
        "/api/v1/orders".nest {
            accept(MediaType.APPLICATION_JSON).nest {
                GET("", handler::list)
                GET("/{id}", handler::get)
                POST("", handler::create)
                PATCH("/{id}", handler::update)
                DELETE("/{id}", handler::delete)
            }
            // SSE 스트리밍은 별도 accept
            accept(MediaType.TEXT_EVENT_STREAM).nest {
                GET("/stream", handler::stream)
            }
        }

        // 필터 — 이 라우터 전체에 적용
        filter { request, next ->
            val start = System.currentTimeMillis()
            next(request).also {
                log.info("{} {} {}ms", request.method(), request.path(),
                    System.currentTimeMillis() - start)
            }
        }

        onError<DomainException.NotFound> { _, _ ->
            ServerResponse.notFound().buildAndAwait()
        }
    }
}

핸들러 — suspend 함수

@Component
class OrderHandler(private val service: OrderService) {

    suspend fun list(req: ServerRequest): ServerResponse {
        val status = req.queryParamOrNull("status")
        val orders = service.list(status)
        return ServerResponse.ok().bodyValueAndAwait(orders)
    }

    suspend fun get(req: ServerRequest): ServerResponse {
        val id = req.pathVariable("id").toLong()
        val order = service.find(id)
            ?: return ServerResponse.notFound().buildAndAwait()
        return ServerResponse.ok().bodyValueAndAwait(order)
    }

    suspend fun create(req: ServerRequest): ServerResponse {
        val body = req.awaitBody<CreateOrderRequest>()
        val created = service.create(body)
        return ServerResponse.created(URI.create("/api/v1/orders/${created.id}"))
            .bodyValueAndAwait(created)
    }

    // 스트리밍 — Flow 를 그대로 넘긴다
    suspend fun stream(req: ServerRequest): ServerResponse =
        ServerResponse.ok().contentType(MediaType.TEXT_EVENT_STREAM)
            .bodyAndAwait(service.stream())
}

애너테이션 방식도 그대로 된다

@RestController
@RequestMapping("/api/v1/orders")
class OrderController(private val service: OrderService) {

    @GetMapping("/{id}")
    suspend fun get(@PathVariable id: Long): OrderResponse = service.get(id)

    @GetMapping
    fun list(): Flow<OrderResponse> = service.listFlow()      // Flow 를 그대로 반환

    @PostMapping
    suspend fun create(@RequestBody @Valid req: CreateOrderRequest) = service.create(req)
}

둘 중 무엇을 고를까 — 팀이 MVC 에 익숙하면 애너테이션이 진입 장벽이 낮습니다. 라우팅이 많고 규칙을 한눈에 보고 싶다면 coRouter 가 낫습니다. 섞어 써도 됩니다.

ServerRequest · ServerResponse 코루틴 API

함수용도
req.awaitBody<T>()본문 역직렬화
req.awaitFormData()폼 데이터
req.queryParamOrNull("k")쿼리 파라미터
req.pathVariable("id")경로 변수
ok().bodyValueAndAwait(x)객체 응답
ok().bodyAndAwait(flow)Flow 스트리밍 응답
notFound().buildAndAwait()본문 없는 응답
FLUX 05

WebClient — 코루틴으로

WebFlux 생태계의 HTTP 클라이언트입니다. MVC 프로젝트에서도 쓸 수 있고, 코루틴과 붙이면 await 한 단어로 끝납니다.

WebClient 호출 한 건이 커넥션 풀에서 겪는 일 — 타임아웃이 두 군데 있습니다 webClient.get() 호출 시작 커넥션 풀에서 획득 시도 maxConnections 기본 500 여유 있음 바로 요청 전송 여유 없음 대기열에서 기다림 여기서 걸리는 타임아웃 두 개 — 둘 다 지정해야 합니다 ① pendingAcquireTimeout 커넥션을 얻기까지 기다리는 시간 (기본 45초) 상대 서버가 느려지면 여기서 먼저 막힙니다 45초는 사실상 무한 — 2~3초로 줄이세요 ② responseTimeout 요청을 보낸 뒤 응답을 기다리는 시간 기본값이 없습니다 — 안 걸면 영원히 기다립니다 장애가 그대로 우리 쪽으로 번지는 통로 대상 서비스마다 WebClient 를 따로 만드세요 — 풀을 공유하면 한 서비스의 지연이 전부를 굶깁니다.
WebFlux 장애의 상당수는 코드가 아니라 지정하지 않은 타임아웃에서 시작됩니다.
@Configuration
class WebClientConfig {
    @Bean
    fun orderClient(builder: WebClient.Builder): WebClient =
        builder
            .baseUrl("https://api.internal/orders")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .clientConnector(
                ReactorClientHttpConnector(
                    HttpClient.create(
                        ConnectionProvider.builder("orders")
                            .maxConnections(200)                       // 커넥션 풀
                            .pendingAcquireTimeout(Duration.ofSeconds(3))
                            .maxIdleTime(Duration.ofSeconds(20))       // 서버 keep-alive 보다 짧게!
                            .build()
                    ).responseTimeout(Duration.ofSeconds(3))
                        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
                )
            )
            .filter(ExchangeFilterFunction.ofRequestProcessor { req ->
                Mono.just(ClientRequest.from(req)
                    .header("X-Trace-Id", MDC.get("traceId") ?: "")
                    .build())
            })
            .build()
}

코루틴으로 부르기

@Service
class OrderClient(private val client: WebClient) {

    // 단건
    suspend fun get(id: Long): Order =
        client.get().uri("/{id}", id)
            .retrieve()
            .awaitBody<Order>()

    // 없을 수 있으면
    // (onStatus 에서 Mono.empty() 를 돌려주면 404 응답 본문을 Order 로 읽으려 들어 실패할 수 있다)
    suspend fun findOrNull(id: Long): Order? =
        try { get(id) }
        catch (e: WebClientResponseException.NotFound) { null }   // 404 만 null 로

    // 목록 스트리밍
    fun stream(userId: Long): Flow<Order> =
        client.get().uri("/stream?userId=$userId")
            .accept(MediaType.TEXT_EVENT_STREAM)
            .retrieve()
            .bodyToFlux<Order>()
            .asFlow()

    // POST
    suspend fun create(req: CreateOrder): Order =
        client.post()
            .bodyValue(req)
            .retrieve()
            .awaitBody<Order>()

    // 응답 전체가 필요할 때 (헤더·상태 코드)
    suspend fun createWithStatus(req: CreateOrder): ResponseEntity<Order> =
        client.post().bodyValue(req).retrieve().awaitEntity<Order>()
}

에러 처리 · 재시도

suspend fun get(id: Long): Order =
    try {
        client.get().uri("/{id}", id)
            .retrieve()
            .onStatus({ it.is4xxClientError }) { res ->
                res.bodyToMono<ProblemDetail>().map { ClientError(it) }
            }
            .awaitBody<Order>()
    } catch (e: WebClientResponseException) {
        log.warn("주문 조회 실패 status={} body={}", e.statusCode, e.responseBodyAsString)
        throw DomainException.Conflict("주문 서비스 오류")
    }

// 재시도는 코루틴 쪽에서 하는 편이 읽기 쉽다
suspend fun getWithRetry(id: Long): Order = retry(times = 3) { get(id) }

// Reactor 연산자로도 가능
client.get().retrieve().bodyToMono<Order>()
    .retryWhen(Retry.backoff(3, Duration.ofMillis(200)).jitter(0.5))
    .awaitSingle()
maxIdleTime을 상대 서버의 keep-alive 보다 짧게 두세요. 서버가 먼저 끊은 커넥션을 재사용하면 PrematureCloseException이 간헐적으로 발생합니다 — WebClient 운영에서 가장 흔한 이슈입니다.
Spring 6 의 RestClient 와 비교 — RestClient는 블로킹이지만 WebClient 와 거의 같은 API 를 제공합니다. MVC + 가상 스레드 조합이라면 RestClient가 더 단순합니다. WebFlux 환경이거나 스트리밍이 필요하면 WebClient입니다.
FLUX 06

R2DBC — 논블로킹 DB 접근

WebFlux 의 이점을 실제로 얻으려면 DB 접근도 논블로킹이어야 합니다. R2DBC 는 그 자리를 채우지만, JPA 가 해 주던 것들을 스스로 해야 합니다.

같은 동시 100건을 세 조합으로 받았을 때 무슨 일이 벌어지나 톰캣 MVC + JDBC 요청 스레드 200개 한 요청 = 한 스레드 점유 100건 → 스레드 100개가 대기 스레드 메모리 · 전환 비용 가장 흔하고 가장 안전한 선택 대부분의 서비스는 여기서 충분 WebFlux + R2DBC 이벤트 루프 8개(코어 수) 기다리는 동안 스레드를 안 씀 100건도 루프 8개로 충분 메모리가 크게 줄어든다 대가 · 디버깅과 학습 비용 JPA 를 못 쓴다는 점도 감안 WebFlux + JDBC 이벤트 루프 8개 JDBC 호출이 루프를 막는다 동시에 8건밖에 처리 못 함 나머지는 큐에서 대기 MVC 보다도 느려진다 — 최악 가장 자주 보는 잘못된 조합 피할 수 없는 블로킹은 Dispatchers.IO 같은 별도 풀로 격리하세요 — 그래도 이득의 절반은 사라집니다. 동시 100건이 들어왔을 때, 실제로 '동시에' 처리되는 건수 톰캣 MVC + JDBC 100건 WebFlux + R2DBC 100건 WebFlux + JDBC 8건 — 나머지 92건은 큐에서 기다립니다 0 20 40 60 80 100건
WebFlux 는 드라이버까지 논블로킹일 때만 의미가 있습니다 — 한 군데라도 블로킹이면 안 쓰는 편이 낫습니다.

엔티티와 리포지터리

// R2DBC 는 JPA 가 아니다 — 지연 로딩·영속성 컨텍스트·연관관계가 없다
@Table("orders")
data class Order(
    @Id val id: Long? = null,              // null 이면 INSERT, 값이 있으면 UPDATE
    val userId: Long,
    val status: String,
    val totalAmount: Long,
    @Column("created_at") val createdAt: LocalDateTime = LocalDateTime.now(),
)

// data class 를 그대로 써도 된다 (JPA 와 달리 프록시가 없다)

interface OrderRepository : CoroutineCrudRepository<Order, Long> {
    // 코루틴 리포지터리 — 반환이 suspend / Flow 다
    suspend fun findByIdAndUserId(id: Long, userId: Long): Order?
    fun findByUserIdOrderByIdDesc(userId: Long): Flow<Order>

    @Query("SELECT * FROM orders WHERE status = :status AND created_at >= :from")
    fun findRecent(status: String, from: LocalDateTime): Flow<Order>

    @Modifying
    @Query("UPDATE orders SET status = :status WHERE id = :id")
    suspend fun updateStatus(id: Long, status: String): Int
}

서비스에서 쓰기 · 연관 데이터

@Service
class OrderService(
    private val orders: OrderRepository,
    private val items: OrderItemRepository,
    private val template: R2dbcEntityTemplate,
) {
    suspend fun get(id: Long): OrderDetail = coroutineScope {
        val order = async { orders.findById(id) ?: throw NotFound(id) }
        val itemList = async { items.findByOrderId(id).toList() }
        OrderDetail(order.await(), itemList.await())   // 연관은 '직접 조회해서 조립'
    }

    // 동적 쿼리는 Criteria 로
    suspend fun search(status: String?, minAmount: Long?): List<Order> {
        var criteria = Criteria.empty()
        status?.let { criteria = criteria.and("status").`is`(it) }
        minAmount?.let { criteria = criteria.and("total_amount").greaterThanOrEquals(it) }
        return template.select(Order::class.java)
            .matching(Query.query(criteria).limit(100))
            .flow()
            .toList()
    }
}
JPA 에서 하던 것R2DBC 에서는
연관관계 매핑 · 지연 로딩없다 — 각각 조회해 코드에서 조립
영속성 컨텍스트 · 더티 체킹없다 — 바꾼 뒤 save()를 명시
N+1 문제대신 루프 안 조회를 조심 — findAllById로 묶기
QueryDSL 동적 쿼리Criteria · jOOQ(R2DBC 지원) 사용
@TransactionalTransactionalOperator · @Transactional(리액티브 매니저)
스키마 자동 생성없다 — Flyway·Liquibase 로 관리 (JDBC 로 실행)
2차 캐시없다 — 애플리케이션 캐시(Caffeine·Redis)로 직접
R2DBC 는 “JPA 의 리액티브 버전”이 아닙니다. 오히려 JdbcTemplate 에 가깝습니다. 도메인이 복잡하고 연관이 많은 업무 시스템이라면, JPA + MVC(가상 스레드)가 생산성 면에서 훨씬 나은 선택인 경우가 많습니다.
FLUX 07

리액티브 트랜잭션

리액티브 트랜잭션은 ThreadLocal 이 아니라 구독 컨텍스트를 따라다닙니다. 코루틴에서는 TransactionalOperator의 코루틴 확장을 쓰면 자연스럽게 이어집니다.

@Service
class OrderService(
    private val orders: OrderRepository,
    private val items: OrderItemRepository,
    private val tx: TransactionalOperator,        // 주입
) {
    // 방법 1 — 코루틴 확장 (권장)
    suspend fun place(cmd: PlaceOrderCommand): Order = tx.executeAndAwait {
        val order = orders.save(Order(userId = cmd.userId, status = "NEW", totalAmount = cmd.total))
        cmd.items.forEach { items.save(it.toEntity(order.id!!)) }
        order
    }

    // 방법 2 — @Transactional 도 동작한다 (ReactiveTransactionManager 가 있을 때)
    @Transactional
    suspend fun cancel(id: Long) {
        val order = orders.findById(id) ?: throw NotFound(id)
        orders.save(order.copy(status = "CANCELED"))
        items.deleteByOrderId(id)
    }

    // 읽기 전용
    @Transactional(readOnly = true)
    suspend fun get(id: Long): Order? = orders.findById(id)
}

주의할 점

  • 같은 커넥션을 쓰는 작업만 하나의 트랜잭션입니다. async로 병렬 실행하면 다른 커넥션이 되어 같은 트랜잭션이 아닙니다
  • 중첩 트랜잭션·REQUIRES_NEW는 제약이 큽니다 — JPA 만큼 자유롭지 않습니다
  • 블로킹 작업이 섞이면 커넥션을 잡은 채 이벤트 루프가 막힙니다
  • 여러 서비스에 걸친 일관성이 필요하면 사가·아웃박스 패턴으로 (대규모 트래픽 탭에서 다룹니다)

트랜잭션 안에서 병렬 처리하기

// ❌ 트랜잭션이 갈라진다
tx.executeAndAwait {
    coroutineScope {
        launch { orders.save(a) }      // 다른 커넥션일 수 있다
        launch { items.save(b) }
    }
}

// ✅ 트랜잭션 안에서는 순차로, 병렬은 트랜잭션 밖에서
val (userInfo, coupon) = coroutineScope {
    async { userClient.get(id) } to async { couponClient.get(code) }
}.let { (a, b) -> a.await() to b.await() }

tx.executeAndAwait {
    orders.save(order)                  // 여기는 순차
    items.saveAll(itemList).collect()
}
FLUX 08

스트리밍 — SSE · NDJSON

WebFlux 를 쓰는 가장 확실한 이유입니다. 결과를 다 모으지 않고 오는 대로 흘려보내면 첫 응답이 빨라지고 메모리도 아낍니다.

100만 건 내보내기 — 「첫 데이터가 사용자에게 닿는 시각」이 갈린다 ❌ 다 모아서 한 번에 (List 반환) 서버가 100만 건을 전부 쌓는 동안 — 화면은 비어 있다 한꺼번에 전송 첫 바이트까지 40초 · 힙에 100만 건이 통째로 올라간다 (OOM 단골) ✅ 오는 대로 흘려보내기 (Flow 반환 · SSE · NDJSON) 1 2 3 4 5 6 7 8 붉은 첫 조각이 0.2초 만에 화면에 뜨고, 끝날 때까지 조각으로 계속 흘러나온다 메모리는 조각 하나 크기면 충분 — 100만 건이든 1억 건이든 같다
스트리밍이 바꾸는 것은 총 시간이 아니라 첫 응답까지의 시간과 메모리입니다.

서버 — Flow 를 그대로 반환

@RestController
class EventController(private val service: EventService) {

    // Server-Sent Events
    @GetMapping("/api/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun events(@RequestParam userId: Long): Flow<ServerSentEvent<EventDto>> =
        service.stream(userId)
            .map { e ->
                ServerSentEvent.builder(e)
                    .id(e.id.toString())
                    .event("order-update")
                    .build()
            }
            .onStart { log.info("스트림 시작 user={}", userId) }
            .onCompletion { log.info("스트림 종료 user={}", userId) }

    // NDJSON — 줄 단위 JSON (대용량 내보내기에 좋다)
    @GetMapping("/api/export", produces = [MediaType.APPLICATION_NDJSON_VALUE])
    fun export(): Flow<OrderRow> = service.exportAll()      // 100만 건도 메모리 안전

    // 하트비트 — 프록시가 유휴 커넥션을 끊지 못하게
    @GetMapping("/api/live", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun live(): Flow<ServerSentEvent<String>> = merge(
        service.stream(0).map { ServerSentEvent.builder(it.toString()).build() },
        flow { while (true) { delay(15.seconds); emit(ServerSentEvent.builder("ping").comment("hb").build()) } },
    )
}

브로드캐스트 — SharedFlow 로

@Service
class EventService : DisposableBean {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    private val _events = MutableSharedFlow<EventDto>(
        replay = 0, extraBufferCapacity = 256,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,   // 느린 구독자 때문에 막히지 않게
    )

    fun publish(e: EventDto) { _events.tryEmit(e) }

    fun stream(userId: Long): Flow<EventDto> =
        _events.filter { it.userId == userId }

    override fun destroy() = scope.cancel()
}

// 여러 서버 인스턴스로 확장하려면 Redis Pub/Sub 을 앞에 둔다
// (한 인스턴스에 붙은 구독자에게만 전달되면 안 되므로)
느린 소비자를 반드시 고려하세요. 버퍼 정책이 없으면 구독자 하나가 느릴 때 서버 메모리가 계속 쌓입니다. DROP_OLDEST·conflate()·타임아웃 중 하나는 있어야 합니다.
클라이언트 쪽 — 브라우저는 EventSource로 SSE 를 바로 받습니다. 안드로이드·서버 간 통신이라면 WebClient 의 bodyToFlux+asFlow()로 받으면 됩니다. 연결이 끊겼을 때 마지막 이벤트 id 부터 재개(Last-Event-ID)하는 설계를 같이 하세요.
FLUX 09

WebSocket — 양방향 실시간

채팅·실시간 협업·게임 로비처럼 서버가 먼저 말을 걸어야 하는 기능입니다. WebFlux 에서는 세션 하나가 Flow 두 개(수신·송신)로 표현됩니다.

@Component
class ChatHandler(private val room: ChatRoom) : WebSocketHandler {

    override fun handle(session: WebSocketSession): Mono<Void> = mono {
        val userId = session.handshakeInfo.uri.query
            ?.substringAfter("userId=")?.toLongOrNull()
            ?: return@mono session.close(CloseStatus.POLICY_VIOLATION).awaitFirstOrNull()

        coroutineScope {
            // 수신 — 클라이언트가 보낸 메시지
            val incoming = launch {
                session.receive().asFlow()
                    .map { it.payloadAsText }
                    .collect { text -> room.publish(ChatMessage(userId, text)) }
            }

            // 송신 — 방의 브로드캐스트를 이 세션으로
            val outgoing = launch {
                session.send(
                    room.messages()                       // Flow<ChatMessage>
                        .map { session.textMessage(it.toJson()) }
                        .asFlux()
                ).awaitFirstOrNull()
            }

            incoming.join()                                // 연결이 끊기면
            outgoing.cancel()                              // 송신도 정리
        }
        null
    }
}

@Configuration
class WebSocketConfig {
    @Bean
    fun handlerMapping(chat: ChatHandler) = SimpleUrlHandlerMapping(
        mapOf("/ws/chat" to chat), Ordered.HIGHEST_PRECEDENCE
    )
}

방(Room) 구현 — SharedFlow

@Component
class ChatRoom {
    private val _messages = MutableSharedFlow<ChatMessage>(
        replay = 20,                       // 새로 들어온 사람에게 최근 20개
        extraBufferCapacity = 256,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    fun messages(): Flow<ChatMessage> = _messages.asSharedFlow()
    suspend fun publish(m: ChatMessage) = _messages.emit(m)
}
서버가 여러 대면 이 방식은 한 인스턴스 안에서만 동작합니다. Redis Pub/Sub 이나 Kafka 를 앞에 두어 인스턴스 간 브로드캐스트를 해야 합니다 — 대규모 트래픽 탭에서 구조를 다룹니다.

운영에서 챙길 것

  • 인증 — 핸드셰이크 시점에 토큰 검증 (쿼리 파라미터·서브프로토콜 헤더)
  • 하트비트 — 유휴 커넥션을 프록시가 끊습니다. 핑/퐁 주기 설정
  • 재연결 — 클라이언트가 지수 백오프로 다시 붙게, 서버는 중복 세션 처리
  • 메시지 크기 제한 — maxFramePayloadLength를 넘기면 연결이 끊깁니다
  • 세션 수 지표 — 현재 연결 수를 메트릭으로 노출 (용량 산정의 기준)
  • 느린 클라이언트 — 송신 버퍼가 차면 끊거나 버려야 합니다
FLUX 10

복원력 — 타임아웃 · 재시도 · 서킷

게이트웨이 성격의 서비스일수록 남의 장애가 내 장애가 되는 구조입니다. 코루틴 기반으로 타임아웃 예산 · 재시도 · 서킷 브레이커를 겹쳐 씁니다.

장애 격리 3단 — 순서가 중요하다 요청 ① 타임아웃 예산 전체 3초 ② 서킷 브레이커 실패율 50% 넘으면 차단 ③ 재시도 백오프+지터 차단 중 — 즉시 폴백 캐시된 값 · 기본값 · 축소된 응답 재시도를 서킷 '앞'에 두면 실패 호출이 오히려 늘어난다 — 순서를 지킬 것 부작용이 있는 호출(결제·주문)은 멱등키 없이 재시도하지 않는다
세 장치는 겹쳐 써야 의미가 있습니다 — 하나만 쓰면 대부분 상황에서 부족합니다.
// resilience4j + 코루틴 (resilience4j-kotlin 모듈)
@Service
class PricingClient(
    private val client: WebClient,
    registry: CircuitBreakerRegistry,
) {
    private val cb = registry.circuitBreaker("pricing")

    suspend fun price(productId: Long): Long =
        withTimeoutOrNull(2.seconds) {                  // ① 예산
            cb.executeSuspendFunction {                  // ② 서킷
                retry(times = 2, initialDelay = 100.milliseconds) {   // ③ 재시도
                    client.get().uri("/price/$productId")
                        .retrieve().awaitBody<PriceResponse>().amount
                }
            }
        } ?: cachedPrice(productId)                      // 폴백

설정 예시

resilience4j:
  circuitbreaker:
    instances:
      pricing:
        slidingWindowSize: 50
        failureRateThreshold: 50          # 50% 실패면 열린다
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 5
        slowCallDurationThreshold: 1s
        slowCallRateThreshold: 80
  timelimiter:
    instances:
      pricing:
        timeoutDuration: 2s

폴백을 설계하는 법

  • 축소된 응답 — 추천 목록이 없으면 인기 상품으로 대체
  • 캐시된 마지막 값 — 가격·환율처럼 조금 오래돼도 되는 값
  • 기능 끄기 — 부가 기능이면 아예 빼고 200 을 돌려준다
  • 실패를 그대로 전달 — 결제처럼 거짓 성공이 더 위험한 경우
폴백이 없으면 서킷 브레이커의 의미가 절반입니다. “빨리 실패”만 하고 사용자에게 줄 것이 없다면 결국 에러 화면입니다.
FLUX 11

컨텍스트 전파 · 디버깅

리액티브에서 가장 답답한 순간은 스택 트레이스에 내 코드가 없을 때입니다. 코틀린 코루틴을 쓰면 크게 나아지지만, traceId 전파는 여전히 설정이 필요합니다.

traceId — 요청 전 구간에 유지

// WebFlux 필터에서 심는다 (서블릿 필터가 아니다)
@Component
class TraceIdFilter : WebFilter {
    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        val traceId = exchange.request.headers.getFirst("X-Trace-Id")
            ?: UUID.randomUUID().toString().take(8)
        exchange.response.headers.set("X-Trace-Id", traceId)
        return chain.filter(exchange)
            .contextWrite { it.put("traceId", traceId) }     // Reactor 컨텍스트
    }
}

// 코루틴에서 읽기 — Reactor 컨텍스트가 코루틴 컨텍스트로 이어진다
suspend fun currentTraceId(): String? =
    currentCoroutineContext()[ReactorContext]?.context?.getOrDefault("traceId", null)

// Micrometer Tracing 을 쓰면 대부분 자동으로 처리된다
// implementation("io.micrometer:micrometer-tracing-bridge-otel")

디버깅 도구

도구하는 일
Hooks.onOperatorDebug()조립 위치를 스택에 남긴다 (개발에서만 — 매우 느림)
ReactorDebugAgent같은 일을 낮은 비용으로 (운영도 가능)
.log()구독·요청·방출을 전부 로그로
-Dkotlinx.coroutines.debug코루틴 이름·id 를 스레드 이름에
BlockHound이벤트 루프를 막는 코드를 찾아 준다 — 개발·테스트에 필수
wiretap(true)WebClient 요청·응답 원문 로깅
// BlockHound — 블로킹 호출을 실행 중에 잡는다
// testImplementation("io.projectreactor.tools:blockhound:1.0.10")
class BlockingCheckTest {
    companion object {
        // @BeforeAll 은 static 이어야 한다 — 코틀린에선 companion + @JvmStatic
        // (또는 클래스에 @TestInstance(TestInstance.Lifecycle.PER_CLASS) 를 붙이고 일반 함수로)
        @BeforeAll @JvmStatic
        fun setup() = BlockHound.install(
            BlockHoundIntegration { builder ->
                builder.allowBlockingCallsInside("java.util.UUID", "randomUUID")
            }
        )
    }
}
이벤트 루프를 막는 코드는 조용히 성능을 무너뜨립니다. 부하가 낮을 때는 티가 안 나다가, 트래픽이 오르면 지연이 폭발합니다. 개발·통합 테스트 환경에 BlockHound 를 켜 두는 것이 가장 효과적인 예방책입니다.
FLUX 12

테스트 — WebTestClient · Turbine

WebFlux 테스트는 WebTestClient가 중심입니다. Flow 는 Turbine, Reactor 타입은 StepVerifier로 검증합니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class OrderApiTest(@Autowired val client: WebTestClient) {

    @Test
    fun `주문을 생성한다`() {
        client.post().uri("/api/v1/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(CreateOrderRequest(productId = 1, quantity = 2))
            .exchange()
            .expectStatus().isCreated
            .expectHeader().exists("Location")
            .expectBody<OrderResponse>()
            .consumeWith { assertEquals(24_000L, it.responseBody?.totalAmount) }   // totalAmount 는 Long — 24_000(Int)이면 equals 가 실패
    }

    @Test
    fun `스트림은 이벤트를 순서대로 보낸다`() {
        val events = client.get().uri("/api/events?userId=1")
            .accept(MediaType.TEXT_EVENT_STREAM)
            .exchange()
            .expectStatus().isOk
            .returnResult<EventDto>()
            .responseBody

        StepVerifier.create(events.take(3))
            .expectNextMatches { it.type == "created" }
            .expectNextCount(2)
            .thenCancel()
            .verify(Duration.ofSeconds(5))
    }
}

// 서비스 계층은 코루틴 테스트로 (스프링 없이)
class OrderServiceTest {
    @Test
    fun `이벤트 흐름`() = runTest {
        service.stream(1).test {                       // Turbine
            assertEquals("created", awaitItem().type)
            cancelAndIgnoreRemainingEvents()
        }
    }
}

외부 API 목킹 — MockWebServer

// testImplementation("com.squareup.okhttp3:mockwebserver")
class PricingClientTest {
    private val server = MockWebServer()
    private lateinit var client: PricingClient

    @BeforeEach fun setUp() {
        server.start()
        client = PricingClient(WebClient.create(server.url("/").toString()), CircuitBreakerRegistry.ofDefaults())
    }
    @AfterEach fun tearDown() = server.shutdown()

    @Test
    fun `타임아웃이면 폴백을 쓴다`() = runTest {
        server.enqueue(MockResponse().setBodyDelay(5, TimeUnit.SECONDS))
        val price = client.price(1)
        assertEquals(CACHED_PRICE, price)
    }
}

R2DBC 통합 테스트

@SpringBootTest
@Testcontainers
class OrderRepositoryTest(@Autowired val repo: OrderRepository) {

    companion object {
        @Container @ServiceConnection
        val postgres = PostgreSQLContainer("postgres:16-alpine")
    }

    @Test
    fun `저장하고 조회한다`() = runTest {
        val saved = repo.save(Order(userId = 1, status = "NEW", totalAmount = 1000))
        val found = repo.findById(saved.id!!)
        assertEquals("NEW", found?.status)
    }

    @Test
    fun `Flow 로 여러 건`() = runTest {
        repo.saveAll(List(5) { Order(userId = 1, status = "NEW", totalAmount = 100) }).collect()
        val all = repo.findByUserIdOrderByIdDesc(1).toList()
        assertEquals(5, all.size)
    }
}
FLUX 13

운영 · 튜닝

WebFlux 운영에서 실제로 문제가 되는 것은 대개 스레드가 아니라 메모리와 커넥션입니다. Netty 의 다이렉트 메모리, 커넥션 풀, 파일 디스크립터를 봐야 합니다.

항목설정과 기준
이벤트 루프 스레드 수reactor.netty.ioWorkerCount — 기본 CPU 코어 수. 거의 안 바꾼다
다이렉트 메모리-XX:MaxDirectMemorySize — Netty 버퍼가 쓴다. OOMKilled 의 흔한 원인
커넥션 풀maxConnections · pendingAcquireTimeout — 대상 서비스별로 분리
유휴 커넥션maxIdleTime을 상대 keep-alive 보다 짧게
파일 디스크립터ulimit -n — 커넥션 수만큼 필요. 컨테이너 기본값이 낮은 경우가 많다
요청 본문 크기spring.codec.max-in-memory-size — 기본 256KB. 큰 JSON 은 여기서 막힌다
메모리 누수-Dio.netty.leakDetection.level=paranoid (개발에서만)
네이티브 트랜스포트리눅스면 netty-transport-native-epoll — 성능 이득

지표로 볼 것

  • 이벤트 루프 지연 — 루프가 막히고 있는지의 직접 증거
  • 커넥션 풀 사용률·대기 시간 — reactor.netty.connection.provider.*
  • 다이렉트 메모리 사용량 — 힙과 별도로 봐야 한다
  • p99 지연 · 활성 커넥션 수 — 용량 산정의 기준
  • 취소율 — 클라이언트가 끊는 비율이 높으면 타임아웃 설계를 재검토

흔한 장애 유형

증상원인
간헐적 502·PrematureClose유휴 커넥션 재사용 — maxIdleTime 조정
부하 시 지연 폭발블로킹 호출이 이벤트 루프에 — BlockHound
OOMKilled (힙은 여유)다이렉트 메모리 — 크기 지정·버퍼 릴리스 확인
PoolAcquireTimeout커넥션 풀 부족 또는 응답 지연 — 타임아웃과 함께 조정
대용량 요청 실패max-in-memory-size 초과
FLUX 14

배압 — 느린 소비자를 다루는 법

리액티브 스트림이 콜백과 갈라지는 지점이 배압(backpressure)입니다. 보내는 쪽이 빠르고 받는 쪽이 느릴 때, 누가 속도를 정하느냐의 문제입니다.

문제가 되는 순간

DB 에서 초당 5만 건이 나오는데 클라이언트가 초당 5천 건밖에 못 받는다고 합시다. 차이인 초당 4만 5천 건이 어딘가에 쌓입니다. 그 "어딘가"를 정하지 않으면 기본값은 메모리이고, 결과는 OOM 입니다.

콜백 기반 코드에는 이 개념 자체가 없습니다. 리액티브 스트림에서는 구독자가 request(n)으로 "n개만 주세요"라고 말하고, 발행자는 그만큼만 보냅니다 — 이게 배압입니다.

배압이 통하는 소스 · 안 통하는 소스

소스배압
R2DBC 조회통합니다 — 드라이버가 fetch size 로 조절
WebClient 응답통합니다 — TCP 수신 창까지 연결
Kafka 컨슈머통합니다 — poll 을 안 하면 안 옵니다
센서 · 마우스 이벤트안 통합니다 — 현실은 기다려 주지 않습니다
브로드캐스트 알림안 통합니다 — 버리거나 쌓거나 둘 중 하나
배압이 통하는 소스라면 아무것도 안 해도 됩니다. 문제는 안 통하는 소스뿐이고, 그때 아래 전략 중 하나를 명시적으로 골라야 합니다.
받는 쪽이 느릴 때 고를 수 있는 네 가지 — 반드시 하나는 골라야 합니다 빠른 발행자 초당 5만 건 여기서 무엇을 할 것인가 정하지 않으면 기본은 '무한히 쌓기' 느린 소비자 초당 5천 건 버퍼 크기를 정해 쌓는다 넘치면 에러 또는 차단 순간 몰림에 알맞다 드롭 새로 온 값을 버린다 로그 · 통계 수집용 손실을 감수한다 최신값 유지 가장 최근 것만 남긴다 시세 · 진행률 · 위치 화면 표시에 가장 흔함 요청 조절 애초에 천천히 받는다 배압이 통할 때만 가능 가장 이상적인 해법 크기를 지정하지 않은 버퍼는 "언젠가 터지는 폭탄"입니다 — 부하 테스트에서는 잘 돌다가 운영에서 터집니다.
코틀린 Flow 로 쓰면 이 네 가지가 각각 buffer · buffer(DROP_LATEST) · conflate · (기본) 입니다.

코틀린 Flow 로 쓰는 배압 — 연산자 네 개면 끝납니다

기본 — 아무것도 안 붙이면 동기

// 아무 연산자도 없으면 '한 건 만들고 한 건 처리'가 번갈아 돕니다
flow {
    repeat(1_000) { emit(load(it)) }   // 10ms 걸린다고 치면
}.collect { save(it) }                 // 여기도 10ms

// 총 1,000 × (10 + 10) = 20초
// 만드는 쪽과 처리하는 쪽이 서로를 기다립니다

기본 Flow 는 배압이 완벽하게 통하는 상태입니다. 메모리는 안전하지만 병렬성이 0이라 느립니다.

buffer — 만드는 쪽과 처리하는 쪽을 떼어놓기

flow { repeat(1_000) { emit(load(it)) } }
    .buffer(capacity = 64)             // 코루틴을 분리
    .collect { save(it) }

// 총 약 10초 — 만드는 동안 처리도 같이 돕니다
// 64칸이 차면 emit 이 자연스럽게 멈춥니다(= 배압 유지)

// 넘칠 때 버리고 싶다면
.buffer(64, onBufferOverflow = BufferOverflow.DROP_OLDEST)

capacity 를 반드시 지정하세요. UNLIMITED 는 "메모리가 무한하다"는 가정이고, 그 가정은 언젠가 깨집니다.

conflate — 최신 것만 보면 되는 경우

// 진행률 · 시세 · 마우스 위치처럼
// "중간 값을 놓쳐도 되는" 흐름에 씁니다
priceFlow
    .conflate()                    // 처리 중에 들어온 것은 최신만 남김
    .collect { renderChart(it) }

// StateFlow 는 사실상 conflate 가 내장된 Flow 입니다
주문 · 결제 같은 흐름에는 절대 쓰지 마세요. 중간 이벤트가 조용히 사라지고, 그 사실을 로그에서도 알 수 없습니다.

collectLatest — 처리 자체를 취소하고 다시 시작

// 검색어가 바뀌면 이전 검색은 의미가 없습니다
queryFlow
    .debounce(300)                 // 타이핑이 멈추길 기다리고
    .collectLatest { q ->          // 새 값이 오면 이전 블록을 취소
        val result = search(q)     // 취소 가능한 suspend 함수여야 합니다
        show(result)
    }

conflate 는 값을 버리고, collectLatest 는 처리를 취소합니다 — 검색·자동완성에서는 후자가 정답입니다.

Reactor 쪽 연산자 — 라이브러리 경계에서 만나게 됩니다

Reactor동작과 코틀린 Flow 대응
onBackpressureBuffer(n)n개까지 쌓고 넘치면 에러 — .buffer(n)
onBackpressureDrop()넘치는 새 값을 버림 — .buffer(n, DROP_LATEST)
onBackpressureLatest()최신 하나만 유지 — .conflate()
limitRate(n)한 번에 n개씩만 요청 — Flow 기본 동작
Flux.create(..., OverflowStrategy)배압 없는 소스를 감쌀 때 전략을 반드시 지정

증상 ① 메모리가 계단식으로 는다

어딘가에 크기 없는 버퍼가 있습니다. 힙 덤프에서 Queue 구현체를 찾아보면 대개 바로 나옵니다.

증상 ② 일정 부하부터 지연이 급등

버퍼가 차서 대기가 시작된 지점입니다. 버퍼를 키우면 잠깐 미뤄질 뿐, 처리 속도 자체를 올려야 해결됩니다.

증상 ③ 값이 조용히 사라진다

누군가 conflate 나 drop 전략을 켜 뒀습니다. 버리는 건수를 지표로 남기세요 — 안 남기면 영원히 모릅니다.

한 줄 정리 — 배압은 "안 하면 기본값이 적용되는" 설정입니다. 그 기본값이 무한히 쌓기라는 게 문제이므로, 스트리밍 코드를 쓸 때마다 "이 흐름이 밀리면 무엇을 버릴 것인가"를 한 번은 명시적으로 정해 두세요.
FLUX 15

파일 업로드 · 다운로드 스트리밍

WebFlux 에서 파일을 다룰 때 가장 흔한 사고는 파일 전체를 메모리에 올리는 것입니다. 500MB 파일 두 개면 그대로 OOM 입니다. 처음부터 흘려보내는 방식으로 써야 합니다.

같은 500MB 업로드를 두 가지 방식으로 받았을 때 ① byte[] · Mono<String> 으로 통째로 받기 요청 본문 전체를 힙에 모은 뒤 컨트롤러에 넘겨줍니다 500MB 파일 하나 = 힙 500MB (최소) 동시 업로드 4건이면 2GB — 컨테이너가 먼저 죽습니다 메모리가 파일 크기 × 동시 건수 ② DataBuffer 조각으로 흘려보내기 조각이 오는 대로 저장소에 쓰고 즉시 해제합니다 힙에는 조각 몇 개(수십 KB)만 머뭅니다 파일이 5GB 여도 메모리 사용량은 그대로입니다 메모리가 파일 크기와 무관 기본 상한부터 확인하세요 spring.codec.max-in-memory-size 기본값은 256KB 입니다 — 이보다 큰 JSON·업로드는 여기서 먼저 거절됩니다 이 값을 무작정 키우는 것이 흔한 대응인데, 그게 바로 ①번 방식으로 가는 길입니다
판단 기준은 하나입니다 — 파일 크기가 요청마다 달라질 수 있으면 반드시 스트리밍.

업로드 — Multipart 를 조각으로 받기

이렇게 쓰면 안 됩니다

@PostMapping("/upload")
suspend fun upload(@RequestBody body: ByteArray): String {
    // 이 시점에 파일 전체가 이미 힙에 올라와 있습니다
    File("/data/x.bin").writeBytes(body)   // 게다가 블로킹
    return "ok"
}
블로킹 파일 IO 도 함께 문제입니다. File.writeBytes 는 이벤트 루프를 막습니다 — WebFlux 에서는 DataBufferUtils 나 비동기 채널을 씁니다.

조각으로 받아 그대로 흘려보내기

@PostMapping("/upload", consumes = [MULTIPART_FORM_DATA_VALUE])
suspend fun upload(@RequestPart("file") file: FilePart): UploadResult {
    val target = Path("/data/${UUID.randomUUID()}")

    // transferTo 는 조각을 그대로 파일로 넘깁니다 (논블로킹)
    file.transferTo(target).awaitSingleOrNull()

    return UploadResult(target.name, Files.size(target))
}

FilePart.transferTo 가 가장 단순한 정답입니다. S3 처럼 외부로 바로 넘길 때는 file.content() 로 Flux<DataBuffer> 를 받아 업로더에 연결합니다.

DataBuffer 는 반드시 해제해야 합니다. Netty 는 힙 밖의 다이렉트 메모리를 쓰고, 이 버퍼는 GC 가 회수해 주지 않습니다. 직접 Flux<DataBuffer> 를 다룰 때는 DataBufferUtils.release(buffer) 를 빠뜨리면 힙은 멀쩡한데 컨테이너가 OOMKilled 되는 증상이 나옵니다.

다운로드 — 파일과 대용량 조회 결과 내보내기

파일 내보내기

@GetMapping("/files/{id}")
fun download(@PathVariable id: String): ResponseEntity<Flux<DataBuffer>> {
    val path = storage.resolve(id)
    val body = DataBufferUtils.read(
        path, DefaultDataBufferFactory(), 8 * 1024   // 8KB 조각
    )
    return ResponseEntity.ok()
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .header(CONTENT_DISPOSITION, "attachment; filename=\"$id\"")
        .body(body)
}

Content-Length 를 알 수 있으면 꼭 넣으세요. 없으면 청크 전송이 되고, 클라이언트는 진행률을 표시하지 못합니다.

대용량 조회 결과를 CSV 로

@GetMapping("/export", produces = ["text/csv"])
fun export(): Flow<String> = flow {
    emit("id,name,amount\n")
    orderRepository.findAllBy()          // R2DBC — 스트리밍 조회
        .collect { o ->
            emit("${o.id},${o.name},${o.amount}\n")
        }
}   // 100만 행이어도 메모리는 일정합니다
여기가 WebFlux 가 확실히 이기는 자리입니다. MVC 에서 같은 일을 하려면 StreamingResponseBody 와 커서 조회를 직접 엮어야 합니다.
설정 · 항목기준값과 이유
spring.codec.max-in-memory-size기본 256KB. JSON 본문 상한으로만 조금 올리고, 파일은 스트리밍으로 우회
spring.webflux.multipart.max-in-memory-size이 크기를 넘는 파트는 디스크로 내려갑니다 — 임시 디렉터리 용량 확인
임시 파일 정리업로드 실패 · 클라이언트 이탈 시 남는 파일 — 주기적 청소 배치가 필요
파일 크기 제한애플리케이션보다 앞단(Nginx · ALB)에서 먼저 막는 편이 안전
다이렉트 메모리-XX:MaxDirectMemorySize — 힙과 별도로 감시. 파일 트래픽이 많으면 필수 지표
바이러스 검사 · 이미지 변환요청 스레드에서 하지 말고 큐에 넘겨 비동기로 — 응답을 먼저 돌려줍니다
가장 좋은 방법은 서버를 거치지 않는 것입니다. 대용량 파일이라면 S3 프리사인드 URL 을 발급해 브라우저가 저장소로 직접 올리게 하세요. 서버는 URL 발급과 완료 알림만 처리하면 되고, 위의 모든 걱정이 한꺼번에 사라집니다.
FLUX 16

선택 기준 · 체크리스트

이 탭의 결론입니다 — 무엇을 고를지와 고른 뒤 무엇을 지킬지.

상황권장
일반 CRUD + JPAMVC (+ 가상 스레드) — WebFlux 이득이 거의 없다
외부 API 여러 개 조합MVC + suspend 컨트롤러 + WebClient
SSE·WebSocket·롱폴링WebFlux — 커넥션 유지 비용이 결정적
게이트웨이·프록시WebFlux (Spring Cloud Gateway)
대용량 스트리밍 내보내기WebFlux + NDJSON
전 구간 논블로킹 가능WebFlux + R2DBC
팀에 리액티브 경험 없음MVC 로 시작 — 필요한 모듈만 WebFlux 로 분리

WebFlux 를 쓰기로 했다면

  • 코드는 코루틴 스타일로, Reactor 는 경계에서만
  • 블로킹 라이브러리를 클래스패스에서 확인 — JDBC·동기 HTTP·파일 IO
  • 불가피한 블로킹은 별도 디스패처로 격리 (Dispatchers.IO)
  • BlockHound를 개발·CI 에 켜기
  • 모든 외부 호출에 타임아웃 + 폴백
  • 스트리밍에는 버퍼 정책(느린 소비자 대비)
  • 커넥션 풀·다이렉트 메모리 지표를 대시보드에

자주 하는 실수

  • Mono 를 구독하지 않고 버리기 — 아무 일도 일어나지 않는다
  • block() 호출 — 이벤트 루프에서 부르면 예외 또는 데드락
  • subscribeOn으로 블로킹을 감쌌다고 안심 — 스레드만 옮겼을 뿐, 풀이 마르면 같다
  • WebFlux + JPA — 가장 흔한 잘못된 조합
  • flow 안에서 withContext — 컨텍스트 보존 위반, flowOn을 쓸 것
  • 취소 예외를 에러 로그로 — 클라이언트 이탈이 에러로 집계된다
다음 탭 예고 — 데이터 · JPA에서는 코틀린 엔티티 설계의 함정(data class·equals·지연 로딩), QueryDSL 대안, Exposed·jOOQ 같은 코틀린 친화 도구, 그리고 트랜잭션과 성능을 다룹니다.
JPA · EXPOSED · JOOQ · KOTLIN JDSL · R2DBC

코틀린 엔티티는
data class로
만들면 안 된다

코틀린과 JPA는 철학이 정면으로 충돌합니다 — 코틀린은 불변·final·non-null을 좋아하고, JPA는 가변·프록시·지연 초기화를 전제합니다. 이 탭은 그 충돌 지점을 하나씩 정리하고, Exposed·jOOQ 같은 코틀린 네이티브 대안까지 비교합니다.

16SECTIONS
5비교하는 데이터 접근 기술
7코틀린 JPA 함정
100%실행되는 예제
DATA 01

기술 고르기 지도

코틀린에서는 선택지가 자바보다 넓습니다. 도메인 복잡도와 쿼리 복잡도, 그리고 팀 경험이 결정 요소입니다.

무엇을 고를까 — 가로는 쿼리 복잡도, 세로는 도메인 복잡도 쿼리 복잡도 → 도메인 복잡 도메인 단순 JPA (Hibernate) 엔티티 · 연관 · 상태 전이가 핵심 업무 시스템 · 주문 · 정산 JPA + jOOQ 병행 쓰기는 JPA · 복잡한 조회는 SQL 대형 서비스에서 가장 흔한 조합 JdbcTemplate · Data JDBC CRUD 중심 · 배치 · 마이그레이션 도구 단순한 것을 단순하게 Exposed · jOOQ · Kotlin JDSL 타입 안전한 SQL · 통계 · 리포트 코틀린 친화 · 컴파일 시점 검증 R2DBC 비동기 스택 흐린 칸이 코틀린 업무 서버의 출발점 — 쿼리가 무거워지면 오른쪽으로 옮겨 갑니다
비동기 스택(WebFlux)이라면 R2DBC가 추가 축입니다 — 이 표의 “쿼리 복잡” 쪽에 가깝습니다.
기술코틀린 궁합특징
JPA + Spring Data보통 (플러그인 필요)생태계 최강. 코틀린 함정이 많아 규칙이 필요
QueryDSL나쁨 (kapt 의존)동적 쿼리 표준이지만 코틀린에서 빌드가 느려진다
Kotlin JDSL매우 좋음코틀린 전용 JPA 동적 쿼리 — kapt 불필요
Exposed매우 좋음젯브레인스 제작 SQL DSL. 가볍고 명시적
jOOQ좋음DB 스키마에서 코드 생성 — SQL 을 타입 안전하게
Spring Data JDBC좋음지연 로딩 없는 단순 매핑 — 애그리거트 단위
R2DBC좋음 (코루틴)논블로킹. JPA 기능은 없다 (WebFlux 탭 06)
MyBatis보통SQL 을 직접 쓰는 팀. 코틀린에서도 무리 없이 동작
가장 실용적인 조합 — 쓰기는 JPA(도메인 규칙·트랜잭션), 복잡한 조회는 jOOQ 또는 Kotlin JDSL. 둘을 억지로 하나로 통일하려다 JPQL 로 불가능한 쿼리를 억지로 만드는 것이 더 큰 비용입니다.
DATA 02

코틀린 엔티티 설계 — 함정 총정리

이 섹션 하나가 이 탭에서 가장 중요합니다. data class 로 엔티티를 만들면 지연 로딩·동등성·컬렉션 관리가 전부 어긋납니다.

흔히 쓰는 잘못된 엔티티
@Entity
data class Order(                      // ❌ data class
    @Id @GeneratedValue
    val id: Long = 0,                  // ❌ 모든 필드 val — 아래 ④

    @ManyToOne(fetch = FetchType.LAZY)
    val user: User,                    // ❌ toString 이 프록시를 건드린다

    @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
    val items: MutableList<OrderItem> = mutableListOf(),
)

// 무엇이 문제인가
// ① equals/hashCode 가 '모든 필드'로 만들어진다
//    → 지연 로딩 프록시를 강제로 초기화하고, 값이 바뀌면 Set 에서 사라진다
// ② toString() 이 연관 엔티티를 전부 찍는다 → 무한 재귀 · N+1 폭발
// ③ copy() 로 만든 객체는 같은 id 를 가진 '다른 엔티티'가 된다 (매우 위험)
// ④ val 은 Hibernate 가 리플렉션으로 채우긴 하지만 의도가 어긋난다
권장 형태
@Entity
@Table(name = "orders")
class Order(                            // 일반 class
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,                   // 식별자만 val (읽기 전용)

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    var user: User,

    @Enumerated(EnumType.STRING)        // ★ ORDINAL 금지
    var status: OrderStatus = OrderStatus.NEW,
) {
    @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL], orphanRemoval = true)
    private val _items: MutableList<OrderItem> = mutableListOf()
    val items: List<OrderItem> get() = _items.toList()   // 밖에는 읽기 전용으로

    fun addItem(item: OrderItem) {
        check(status == OrderStatus.NEW) { "확정된 주문은 수정 불가" }
        _items += item
        item.order = this                                  // 양방향 동기화
    }

    // id 기반 동등성 (Hibernate 권장 형태)
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Order) return false
        return id != 0L && id == other.id
    }
    override fun hashCode(): Int = javaClass.hashCode()    // 상수 — 프록시에도 안전
    override fun toString(): String = "Order(id=$id, status=$status)"  // 연관 제외
}
함정이유와 해결
data class 사용equals/hashCode/copy 가 전부 문제 → 일반 class
모든 필드 valJPA 는 상태를 바꾼다 → 식별자만 val, 나머지 var
hashCode()를 id 로저장 전 id=0 → 저장 후 값이 바뀌어 Set 에서 사라진다 → 클래스 상수
기본 toString()연관을 전부 로딩 → id·기본 필드만 출력
클래스가 final지연 로딩 프록시 불가 → allOpen에 @Entity를 등록해 연다 (plugin.jpa 는 열지 않음)
기본 생성자 없음JPA 요구 → plugin.jpa(no-arg)
@Enumerated 기본값ORDINAL 은 순서가 바뀌면 데이터가 밀린다 → STRING
가변 컬렉션 노출밖에서 마음대로 추가 → private + 읽기 전용 getter
allopen 플러그인을 엔티티까지 확장하기 — plugin.jpa는 인자 없는 생성자만 만들고, plugin.spring도 @Entity는 열지 않습니다. 엔티티를 열려면 build.gradle.kts에 직접 추가하세요.
allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}
noArg {
    annotation("jakarta.persistence.Entity")
    invokeInitializers = true      // init 블록도 실행 (검증 로직이 있다면)
}
엔티티에 data class 를 쓰면 생기는 일 data class Member(...) equals·hashCode 가 모든 속성으로 만들어집니다 비교하는 순간 지연 로딩된 연관이 불려 나옵니다 저장 전(id=null)과 저장 후가 다른 객체가 됩니다 toString 이 연관을 타고 순환할 수도 있습니다 일반 class + id 기준 비교 class Member( @Id val id: Long? = null, … ) equals·hashCode 는 id 만으로 정의합니다 필드는 var, 컬렉션은 초기화해 둡니다 DTO 에는 data class 를 마음껏 쓰세요 JPA 가 요구하는 것들 기본 생성자 · 상속 가능한 클래스 — 코틀린은 기본이 final → allopen · noarg 플러그인을 빌드에 추가합니다 널 안전성과의 조합 DB 에서 nullable 인 컬럼만 코틀린에서도 물음표를 붙입니다 전부 물음표로 열어 두면 코틀린을 쓰는 이점이 사라집니다
엔티티는 JPA 의 규칙을 따르는 객체입니다 — 값 객체와 다릅니다.
hashCode 를 id 로 만들면 — 저장하는 순간 Set 에서 사라집니다 ① 생성 ② Set 에 담기 ③ persist · flush ④ contains Member("kim") id = 0 · hash 0 set.add(m) 버킷 0 에 들어감 em.persist(m) id = 42 · hash 42 set.contains(m) 버킷 42 를 뒤짐 → false 해결 — hashCode() = javaClass.hashCode() (클래스 상수) · equals 는 id 가 같을 때만 true 저장 전후 hash 가 같으니 버킷이 그대로입니다 → contains = true data class 는 모든 속성으로 hash 를 만듭니다 — 필드 하나만 바뀌어도 같은 일
Set 이 객체를 잃어버리는 이유는 담은 뒤에 hash 가 바뀌기 때문입니다 — 엔티티의 hashCode 는 저장 전후로 같아야 합니다.
DATA 03

연관관계 매핑

연관관계는 “어느 쪽이 외래키를 갖는가”와 “언제 로딩할 것인가” 두 가지 결정입니다. 코틀린에서는 여기에 컬렉션 캡슐화가 더해집니다.

연관관계 — 외래 키 컬럼을 가진 쪽이 '주인'입니다 Order (1) @OneToMany(mappedBy = "order") 반대편을 비추기만 합니다 — 여기에만 add 하면 저장 안 됨 양방향 OrderItem (N) — 주인 @ManyToOne @JoinColumn("order_id") 이쪽을 세팅해야 실제 INSERT 가 나갑니다 DB 에는 이렇게 저장됩니다 — 그림의 근거가 여기 있습니다 order 테이블 id · 주문일 · 상태 (자식 정보는 없다) FK order_item 테이블 id · order_id(FK) · 상품 · 수량 양쪽을 함께 맞추는 편의 메서드를 두세요 — 한쪽만 세팅하면 같은 트랜잭션 안에서 데이터가 어긋나 보입니다.
헷갈릴 때는 "DB 테이블에 외래 키가 어디 있나"만 보면 됩니다 — 거기가 주인입니다.
@Entity
class Order(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    // 다대일 — 외래키를 가진 쪽(주인). 기본이 EAGER 이므로 반드시 LAZY 로!
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id", nullable = false)
    var user: User,
) {
    // 일대다 — mappedBy 로 주인을 가리킨다 (여기는 거울)
    @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL], orphanRemoval = true)
    private val _items: MutableList<OrderItem> = mutableListOf()
    val items: List<OrderItem> get() = _items

    // 일대일 — 주인 쪽에 외래키
    @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
    @JoinColumn(name = "delivery_id")
    var delivery: Delivery? = null

    // 값 타입 컬렉션 — 별도 엔티티가 필요 없는 값들
    @ElementCollection
    @CollectionTable(name = "order_tags", joinColumns = [JoinColumn(name = "order_id")])
    @Column(name = "tag")
    private val _tags: MutableSet<String> = mutableSetOf()
    val tags: Set<String> get() = _tags

    // 편의 메서드 — 양방향을 항상 함께 맞춘다
    fun addItem(item: OrderItem) {
        _items += item
        item.order = this
    }
    fun removeItem(item: OrderItem) {
        _items -= item
        item.order = null        // orphanRemoval 이 켜져 있으면 DELETE 된다
    }
}

@Entity
class OrderItem(
    @Id @GeneratedValue val id: Long = 0,
    var productId: Long,
    var quantity: Int,
    var price: Long,
) {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    var order: Order? = null

    fun subtotal(): Long = price * quantity
}

기본 규칙

  • 모든 연관은 LAZY — @ManyToOne·@OneToOne의 기본값이 EAGER 라 반드시 명시해야 합니다 (N+1 의 최대 원인)
  • 양방향은 꼭 필요할 때만 — 단방향으로 충분한 경우가 많습니다
  • 편의 메서드로 양쪽을 동기화 — 한쪽만 바꾸면 DB 와 메모리가 어긋납니다
  • cascade = ALL은 애그리거트 안에서만 — 주문→주문항목은 OK, 주문→사용자는 절대 안 됩니다
  • 컬렉션은 Set 보다 List가 실무에서 편하지만, 중복 방지가 필요하면 Set + 올바른 equals

코틀린 특유의 주의점

// ① nullable 연관은 타입에 ? 를 붙인다
@ManyToOne(fetch = FetchType.LAZY)
var coupon: Coupon? = null          // optional = true 와 짝을 맞춘다

// ② non-null 연관은 optional = false 로 DB 제약까지
@ManyToOne(fetch = FetchType.LAZY, optional = false)
var user: User                       // ? 없음

// ③ 지연 로딩 프록시를 == 로 비교하지 말 것
if (order.user == currentUser) { }   // ❌ equals 호출 → 프록시가 초기화된다
if (order.user.id == userId) { }     // ✅ id 만 쓰면 초기화 없이도 가능(설정에 따라)

// ④ 컬렉션 초기화는 프로퍼티 선언에서
private val _items = mutableListOf<OrderItem>()   // lateinit 쓰지 말 것
DATA 04

영속성 컨텍스트 · 더티 체킹

JPA 를 쓰면서 “왜 save()를 안 불렀는데 UPDATE 가 나가지?”를 겪습니다. 영속성 컨텍스트가 스냅숏을 들고 있다가 트랜잭션 끝에 비교하기 때문입니다.

더티 체킹 — save() 없이 UPDATE 가 나가는 이유 ① 조회 findById 영속성 컨텍스트 (1차 캐시) 엔티티 + 조회 시점의 스냅숏을 함께 보관 ② 값 변경 order.status = PAID 아직 SQL 은 나가지 않는다 ③ 커밋 트랜잭션 종료 스냅숏과 비교 → 바뀐 필드만 UPDATE 주의 트랜잭션 밖에서 바꾸면 반영되지 않는다 (준영속 상태)
이 메커니즘 덕분에 도메인 메서드로 상태를 바꾸는 설계가 자연스러워집니다.
@Service
@Transactional
class OrderService(private val repo: OrderRepository) {

    fun pay(id: Long, amount: Long) {
        val order = repo.findByIdOrNull(id) ?: throw NotFound(id)
        order.markPaid(amount)          // 도메인 메서드가 상태를 바꾼다
        // repo.save(order)  ← 필요 없다. 커밋 시점에 자동으로 UPDATE
    }

    // 읽기 전용 — 스냅숏을 안 만들어 메모리·성능 이득
    @Transactional(readOnly = true)
    fun get(id: Long): OrderResponse =
        repo.findByIdOrNull(id)?.toResponse() ?: throw NotFound(id)

    // 준영속 — 트랜잭션 밖에서 받은 객체는 merge 가 필요하다
    @Transactional
    fun update(detached: Order) {
        val managed = repo.findByIdOrNull(detached.id) ?: throw NotFound(detached.id)
        managed.applyFrom(detached)     // merge 보다 '필요한 필드만 옮기기'가 안전
    }
}

1차 캐시 · 쓰기 지연

@Transactional                // 쓰기가 있으니 readOnly 가 아니다
fun demo(id: Long) {
    val a = repo.findByIdOrNull(id)
    val b = repo.findByIdOrNull(id)
    println(a === b)          // true — 같은 트랜잭션이면 SELECT 한 번만

    // 쓰기 지연: INSERT 도 모았다가 flush 시점에 나간다
    repeat(3) { repo.save(Order(...)) }
    // 여기까지 SQL 없음 → 커밋(또는 flush) 때 한꺼번에
    // ⚠️ 단, id 가 IDENTITY 전략이면 키를 받아야 해서 persist 즉시 INSERT 된다 (da13)
}

// 강제로 반영해야 할 때
entityManager.flush()          // SQL 을 지금 보낸다 (트랜잭션은 유지)
entityManager.clear()          // 1차 캐시를 비운다 (대량 처리에 필수)

LazyInitializationException

// ❌ 트랜잭션이 끝난 뒤 지연 로딩에 접근
@Transactional(readOnly = true)
fun getOrder(id: Long): Order = repo.findByIdOrNull(id)!!

val order = service.getOrder(1)
println(order.items.size)      // 💥 LazyInitializationException

// ✅ 해결 1 — 트랜잭션 안에서 DTO 로 변환해 반환
@Transactional(readOnly = true)
fun getOrder(id: Long): OrderResponse =
    repo.findByIdOrNull(id)!!.toResponse()      // 필요한 것을 여기서 다 읽는다

// ✅ 해결 2 — fetch join 으로 함께 조회 (da07)
// ✅ 해결 3 — @EntityGraph

// ⚠️ open-in-view 로 덮지 마세요 (기본값 true)
// spring.jpa.open-in-view=false 로 끄고, 위 방식으로 해결하는 것이 정석입니다
spring.jpa.open-in-view는 끄는 것을 권합니다. 켜져 있으면 뷰 렌더링까지 DB 커넥션을 붙잡고 있어, 트래픽이 오르면 커넥션 풀이 마릅니다. 끄면 LazyInitializationException이 드러나는데, 그것이 고쳐야 할 곳을 알려 주는 신호입니다.
DATA 05

Spring Data 리포지터리

코틀린에서 Spring Data 를 쓸 때 알아야 할 것은 Optional 을 쓰지 않는다는 점과 nullable 반환 타입을 정확히 적는 것입니다.

기본 형태

interface OrderRepository : JpaRepository<Order, Long> {

    // 없을 수 있으면 ? 를 붙인다 — Optional 은 쓰지 않는다
    fun findByOrderNo(orderNo: String): Order?

    // 목록은 non-null (빈 리스트가 온다)
    fun findByUserIdOrderByCreatedAtDesc(userId: Long): List<Order>

    // 페이징
    fun findByStatus(status: OrderStatus, pageable: Pageable): Page<Order>

    // 존재 여부 · 개수
    fun existsByOrderNo(orderNo: String): Boolean
    fun countByUserIdAndStatus(userId: Long, status: OrderStatus): Long

    // 삭제 (반환은 삭제된 건수)
    fun deleteByCreatedAtBefore(t: LocalDateTime): Long

    // 스트리밍 — 대용량 처리 (트랜잭션 안에서 써야 한다)
    @QueryHints(QueryHint(name = HINT_FETCH_SIZE, value = "1000"))
    fun findAllByStatus(status: OrderStatus): Stream<Order>
}

// findById 는 Optional 을 돌려준다 → 코틀린 확장 함수를 쓴다
import org.springframework.data.repository.findByIdOrNull

val order: Order? = repo.findByIdOrNull(id)
val order2: Order = repo.findByIdOrNull(id) ?: throw NotFound(id)

쿼리 메서드 이름 규칙

키워드SQL
findBy · getBy · queryBySELECT
And · OrAND · OR
Between · LessThan · After범위 조건
Like · StartingWith · ContainingLIKE
In · NotInIN
IsNull · IsNotNullIS NULL
OrderBy…DescORDER BY
Top10 · FirstLIMIT
DistinctDISTINCT
메서드 이름이 길어지면 그만두세요. 조건이 세 개를 넘어가면 @Query나 동적 쿼리(Kotlin JDSL·jOOQ)로 가는 편이 읽기도 쉽고 유지보수도 낫습니다.

공통 기능 — 감사(Auditing)와 베이스 엔티티

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity {
    @CreatedDate
    @Column(updatable = false)
    var createdAt: LocalDateTime = LocalDateTime.now()
        protected set

    @LastModifiedDate
    var updatedAt: LocalDateTime = LocalDateTime.now()
        protected set

    @CreatedBy
    @Column(updatable = false)
    var createdBy: String? = null
        protected set
}

@Configuration
@EnableJpaAuditing
class JpaConfig {
    @Bean
    fun auditorProvider(): AuditorAware<String> = AuditorAware {
        Optional.ofNullable(SecurityContextHolder.getContext().authentication?.name)
    }
}

@Entity
class Order(...) : BaseEntity()      // 상속받기만 하면 된다
DATA 06

쿼리 — JPQL · 프로젝션

엔티티를 통째로 가져오는 대신 필요한 컬럼만 뽑으면 성능이 크게 달라집니다. 코틀린에서는 data class 생성자 표현식이 특히 잘 맞습니다.

interface OrderRepository : JpaRepository<Order, Long> {

    // JPQL — 엔티티 이름과 필드 이름을 쓴다 (테이블 이름이 아니다)
    @Query("select o from Order o where o.status = :status and o.totalAmount >= :min")
    fun search(status: OrderStatus, min: Long): List<Order>

    // DTO 프로젝션 — data class 생성자를 그대로 부른다
    @Query("""
        select new com.example.dto.OrderSummary(o.id, o.orderNo, u.name, o.totalAmount)
        from Order o join o.user u
        where o.createdAt >= :from
    """)
    fun summaries(from: LocalDateTime): List<OrderSummary>

    // 인터페이스 프로젝션 — 더 가볍다 (프록시가 만들어진다)
    @Query("select o.id as id, o.orderNo as orderNo from Order o where o.user.id = :userId")
    fun findProjections(userId: Long): List<OrderIdAndNo>

    // 네이티브 쿼리 — DB 함수·힌트가 필요할 때
    @Query(
        value = "select * from orders where created_at >= :from and status = :status",
        nativeQuery = true,
    )
    fun findNative(from: LocalDateTime, status: String): List<Order>

    // 벌크 업데이트 — 영속성 컨텍스트를 우회하므로 주의
    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("update Order o set o.status = :to where o.status = :from")
    fun bulkUpdateStatus(from: OrderStatus, to: OrderStatus): Int
}

data class OrderSummary(val id: Long, val orderNo: String, val userName: String, val total: Long)

interface OrderIdAndNo {
    val id: Long
    val orderNo: String
}

프로젝션 세 가지 비교

방식특징
엔티티 조회모든 컬럼 · 영속 상태 · 수정 가능. 목록 조회에는 무겁다
data class필요한 컬럼만 · 불변 · 가장 명확. 패키지 전체 경로를 써야 한다
인터페이스가장 가볍다 · 이름만 맞추면 됨 · 중첩 프로젝션도 가능
조회 전용 API 는 프로젝션을 기본으로 두세요. 엔티티를 읽으면 영속성 컨텍스트에 쌓이고 더티 체킹 대상이 되어 대량 조회에서 메모리와 CPU 를 모두 씁니다.

벌크 연산의 함정

@Transactional
fun expireAll() {
    val order = repo.findByIdOrNull(1)!!      // 1차 캐시에 올라간다
    repo.bulkUpdateStatus(NEW, EXPIRED)        // DB 는 바뀌었지만
    println(order.status)                      // 여전히 NEW (캐시가 모른다)
}

// 해결 — @Modifying(clearAutomatically = true) 로 캐시를 비운 뒤 '다시 조회'하거나
//        (이미 꺼내 둔 order 변수는 비워도 옛 값 그대로다)
//        벌크 연산을 별도 트랜잭션·별도 메서드로 분리한다

// 벌크 연산은
// · 더티 체킹을 거치지 않는다 (엔티티 변경 감지 없음)
// · @LastModifiedDate 같은 감사 필드가 갱신되지 않는다
// · 그만큼 빠르다 (수십만 건 처리에 적합)
DATA 07

N+1 문제와 해결

JPA 성능 문제의 8할입니다. 목록 10건을 조회했는데 쿼리가 11번 나가는 현상이고, 해결책은 fetch join · EntityGraph · batch size 세 가지입니다.

주문 10건 + 각 주문의 사용자 — SQL 이 몇 번 나가나 0 2 4 6 8 10 12 번 N+1 (기본 지연 로딩) 주문 1 사용자 10 — 루프마다 한 번씩 11번 fetch join 1번 — JOIN 으로 한 번에 batch size 100 2번 — 주문 1 + 사용자 IN (…) 1 목록이 100건이면 N+1 막대만 101 로 늘고 아래 둘은 그대로입니다 컬렉션이 여럿이면 fetch join 을 겹칠 수 없다 → batch size 가 답
일대다 컬렉션은 fetch join 을 하나만 쓸 수 있습니다 — 나머지는 batch size 로 해결합니다.
// ① fetch join — JPQL 에 명시
@Query("select o from Order o join fetch o.user where o.status = :status")
fun findWithUser(status: OrderStatus): List<Order>

// 컬렉션 fetch join 은 중복이 생기므로 distinct (Hibernate 6+ 는 자동)
@Query("select distinct o from Order o join fetch o.items where o.id in :ids")
fun findWithItems(ids: List<Long>): List<Order>

// ⚠️ 컬렉션 fetch join + 페이징은 위험하다
//    → Hibernate 가 전체를 메모리로 읽고 자바에서 자른다 (경고 로그가 뜬다)

// ② @EntityGraph — 쿼리를 안 고치고 로딩 계획만 지정
@EntityGraph(attributePaths = ["user", "delivery"])
fun findByStatus(status: OrderStatus): List<Order>

// ③ batch size — 전역 설정이 가장 실용적
// application.yml
// spring.jpa.properties.hibernate.default_batch_fetch_size: 100
//   → 지연 로딩이 필요할 때 IN (…) 으로 100개씩 묶어서 가져온다

실무 조합 — 이렇게 씁니다

  1. 기본값으로 default_batch_fetch_size: 100을 켜 둔다 — 대부분의 N+1 이 2번 쿼리로 줄어듭니다
  2. ~ToOne 연관은 fetch join 으로 함께 조회
  3. 컬렉션은 batch size 에 맡기거나, 페이징이 필요하면 id 만 페이징 → 그 id 로 상세 조회 2단계로
  4. 조회 전용은 DTO 프로젝션으로 아예 연관을 안 탄다

발견하는 법

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true      # 쿼리 수를 로그로
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace   # 바인딩 값까지
테스트로 잡는 것이 가장 확실합니다. Statistics.queryExecutionCount를 검증하는 테스트를 두면 누군가 연관을 추가해 N+1 을 만들었을 때 CI 에서 실패합니다.
DATA 08

동적 쿼리 ① — QueryDSL과 코틀린

자바 진영의 표준이지만, 코틀린에서는 kapt 의존성이라는 부담이 있습니다. 쓸 수는 있고 잘 동작하지만, 빌드가 느려지는 대가를 알고 선택해야 합니다.

설정 — Q타입 생성

plugins {
    kotlin("kapt") version "2.2.0"      // ★ KSP 가 아니라 kapt 가 필요하다
}

dependencies {
    implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
    kapt("com.querydsl:querydsl-apt:5.1.0:jakarta")
    kapt("jakarta.annotation:jakarta.annotation-api")
    kapt("jakarta.persistence:jakarta.persistence-api")
}

// 생성 위치: build/generated/source/kapt/main
// IDE 가 못 찾으면 sourceSets 에 추가
kapt 는 자바 스텁을 만들어 처리하므로 코틀린 빌드가 눈에 띄게 느려집니다. (KSP 로 대체되는 흐름이지만 QueryDSL 공식 KSP 지원은 제한적입니다.) 새 프로젝트라면 Kotlin JDSL이나 jOOQ를 먼저 검토하세요.

동적 쿼리 작성

@Repository
class OrderQueryRepository(private val query: JPAQueryFactory) {

    fun search(cond: OrderSearchCondition, pageable: Pageable): Page<OrderSummary> {
        val o = QOrder.order
        val u = QUser.user

        val where = BooleanBuilder().apply {
            cond.status?.let { and(o.status.eq(it)) }
            cond.userId?.let { and(o.user.id.eq(it)) }
            cond.minAmount?.let { and(o.totalAmount.goe(it)) }
            cond.keyword?.takeIf { it.isNotBlank() }?.let { and(u.name.contains(it)) }
        }

        val content = query
            .select(QOrderSummary(o.id, o.orderNo, u.name, o.totalAmount))
            .from(o).join(o.user, u)
            .where(where)
            .orderBy(o.createdAt.desc())
            .offset(pageable.offset)
            .limit(pageable.pageSize.toLong())
            .fetch()

        val total = query.select(o.count()).from(o).join(o.user, u)   // where 가 u 를 쓰므로 조인도 같이
            .where(where).fetchOne() ?: 0L
        return PageImpl(content, pageable, total)
    }
}
코틀린에서 QueryDSL 을 더 편하게 — BooleanBuilder 대신 nullable 을 그대로 넘기는 방식이 코틀린과 잘 맞습니다. where(...)에 null을 넘기면 그 조건은 무시되므로, cond.status?.let { o.status.eq(it) } 처럼 만든 값을 그대로 나열하면 됩니다.
DATA 09

동적 쿼리 ② — Kotlin JDSL

라인의 오픈소스로, 코드 생성 없이 JPA 동적 쿼리를 타입 안전하게 씁니다. kapt 가 필요 없다는 점만으로도 코틀린 프로젝트에서 큰 장점입니다.

의존성 — implementation("com.linecorp.kotlin-jdsl:jpql-dsl:3.5.5") · implementation("com.linecorp.kotlin-jdsl:jpql-render:3.5.5") · 스프링 연동은 spring-data-jpa-support
@Repository
class OrderQueryRepository(private val em: EntityManager) {

    private val context = JpqlRenderContext()

    fun search(cond: OrderSearchCondition, pageable: Pageable): List<OrderSummary> {
        val query = jpql {
            selectNew<OrderSummary>(
                path(Order::id),
                path(Order::orderNo),
                path(Order::user).path(User::name),
                path(Order::totalAmount),
            ).from(
                entity(Order::class),
                join(Order::user),
            ).whereAnd(
                cond.status?.let { path(Order::status).eq(it) },        // null 이면 조건 제외
                cond.minAmount?.let { path(Order::totalAmount).ge(it) },
                cond.keyword?.takeIf { it.isNotBlank() }
                    ?.let { path(Order::user).path(User::name).like("%$it%") },
            ).orderBy(path(Order::createdAt).desc())
        }

        return em.createQuery(query, context)
            .setFirstResult(pageable.offset.toInt())
            .setMaxResults(pageable.pageSize)
            .resultList
    }
}

QueryDSL 과 비교

항목QueryDSL / Kotlin JDSL
코드 생성Q타입 필요(kapt) / 불필요
빌드 속도느려진다 / 영향 없음
널 조건 처리BooleanBuilder / whereAnd(nullable...)
생태계매우 넓다 / 상대적으로 작다
자바 혼용쉽다 / 코틀린 전용
학습 곡선익숙한 사람이 많다 / DSL 을 새로 익혀야
코틀린 전용 프로젝트를 새로 시작한다면 Kotlin JDSL 이 좋은 선택입니다. 기존 자바 코드가 많거나 팀에 QueryDSL 경험이 쌓여 있다면 QueryDSL 을 유지하는 편이 전체 비용이 낮습니다.

Spring Data 와 함께 쓰기

// spring-data-jpa-support 를 넣으면 리포지터리에서 바로 쓸 수 있다
interface OrderRepository : JpaRepository<Order, Long>, KotlinJdslJpqlExecutor

@Service
class OrderService(private val repo: OrderRepository) {

    fun findPaid(minAmount: Long, pageable: Pageable): Page<Order?> =   // JDSL 실행기는 T? 로 돌려준다
        repo.findPage(pageable) {
            select(entity(Order::class))
                .from(entity(Order::class))
                .whereAnd(
                    path(Order::status).eq(OrderStatus.PAID),
                    path(Order::totalAmount).ge(minAmount),
                )
        }
}
DATA 10

Exposed — 젯브레인스의 SQL DSL

JPA 를 쓰지 않고 SQL 을 코틀린 코드로 쓰는 방식입니다. 마법이 없어 어떤 SQL 이 나가는지 코드만 보면 알 수 있고, 학습 비용이 낮습니다.

의존성 — implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.58.0") · 날짜 타입은 exposed-java-time · 코루틴 지원은 exposed-jdbc와 함께 newSuspendedTransaction 사용

DSL 방식 — 테이블을 object 로

object Orders : LongIdTable("orders") {
    val orderNo = varchar("order_no", 40).uniqueIndex()
    val userId = long("user_id").index()
    val status = enumerationByName("status", 20, OrderStatus::class)
    val totalAmount = long("total_amount")
    val createdAt = datetime("created_at").defaultExpression(CurrentDateTime)
}

object Users : LongIdTable("users") {
    val name = varchar("name", 50)
    val email = varchar("email", 100).uniqueIndex()
}

// 조회 — SQL 그대로가 보인다
transaction {
    val rows = (Orders innerJoin Users)
        .select(Orders.id, Orders.orderNo, Users.name, Orders.totalAmount)
        .where { (Orders.status eq OrderStatus.PAID) and (Orders.totalAmount greaterEq 10_000) }
        .orderBy(Orders.createdAt to SortOrder.DESC)
        .limit(20)
        .map { OrderSummary(it[Orders.id].value, it[Orders.orderNo], it[Users.name], it[Orders.totalAmount]) }
}

// 삽입 · 수정 · 삭제
transaction {
    val id = Orders.insertAndGetId {
        it[orderNo] = "ORD-1"
        it[userId] = 100
        it[status] = OrderStatus.NEW
        it[totalAmount] = 24_000
    }
    Orders.update({ Orders.id eq id }) { it[status] = OrderStatus.PAID }
    Orders.deleteWhere { Orders.createdAt less LocalDateTime.now().minusYears(1) }
}

DAO 방식 — 엔티티처럼

class OrderEntity(id: EntityID<Long>) : LongEntity(id) {
    companion object : LongEntityClass<OrderEntity>(Orders)

    var orderNo by Orders.orderNo
    var status by Orders.status
    var totalAmount by Orders.totalAmount
    val items by OrderItemEntity referrersOn OrderItems.orderId   // 일대다
}

transaction {
    val order = OrderEntity.new {
        orderNo = "ORD-2"
        status = OrderStatus.NEW
        totalAmount = 10_000
    }
    order.status = OrderStatus.PAID       // 커밋 때 UPDATE (JPA 와 비슷)

    OrderEntity.find { Orders.status eq OrderStatus.PAID }
        .limit(10)
        .forEach { println(it.orderNo) }
}

// 코루틴에서
suspend fun findAll(): List<OrderSummary> = newSuspendedTransaction(Dispatchers.IO) {
    Orders.selectAll().map { it.toSummary() }
}
JPA 대비Exposed
학습 비용훨씬 낮다 — SQL 을 알면 바로 쓴다
동적 쿼리조건을 코틀린 코드로 조립 — 별도 도구 불필요
지연 로딩·더티 체킹DAO 방식에 일부 있음 (JPA 만큼 정교하진 않다)
스키마 생성SchemaUtils.create(Orders) — 테스트에 편리
생태계Spring Data 만큼 넓지 않다 (감사·페이징 등을 직접)
적합한 곳중소 규모 서비스 · 코루틴 기반 · Ktor 서버 · KMP 서버
DATA 11

jOOQ — 스키마에서 코드를 만든다

실제 DB 스키마를 읽어 테이블·컬럼 타입을 코드로 생성합니다. 컬럼 이름 오타가 컴파일 에러가 되고, 복잡한 SQL 을 그대로 표현할 수 있습니다.

// 복잡한 통계 쿼리 — JPQL 로는 표현하기 어려운 것들이 자연스럽다
@Repository
class SalesQueryRepository(private val dsl: DSLContext) {

    fun dailySales(from: LocalDate, to: LocalDate): List<DailySales> =
        dsl.select(
                ORDERS.CREATED_AT.cast(SQLDataType.LOCALDATE).`as`("day"),
                DSL.count().`as`("cnt"),
                DSL.sum(ORDERS.TOTAL_AMOUNT).`as`("total"),
                DSL.avg(ORDERS.TOTAL_AMOUNT).`as`("avg"),
            )
            .from(ORDERS)
            .where(ORDERS.STATUS.eq("PAID"))
            .and(ORDERS.CREATED_AT.between(from.atStartOfDay(), to.atTime(23, 59, 59)))
            .groupBy(DSL.field("day"))
            .orderBy(DSL.field("day").desc())
            .fetchInto(DailySales::class.java)

    // 윈도 함수 — 사용자별 최근 주문 3건
    fun recentPerUser(): List<OrderRow> {
        val rn = DSL.rowNumber().over()
            .partitionBy(ORDERS.USER_ID)
            .orderBy(ORDERS.CREATED_AT.desc()).`as`("rn")

        val t = dsl.select(ORDERS.asterisk(), rn).from(ORDERS).asTable("t")
        return dsl.selectFrom(t).where(t.field("rn", Int::class.java)!!.le(3))
            .fetchInto(OrderRow::class.java)
    }

    // CTE · UPSERT · 벌크 — SQL 이 되는 것은 대부분 된다
    fun upsertStock(productId: Long, delta: Int) =
        dsl.insertInto(STOCKS).columns(STOCKS.PRODUCT_ID, STOCKS.QTY)
            .values(productId, delta)
            .onConflict(STOCKS.PRODUCT_ID)
            .doUpdate().set(STOCKS.QTY, STOCKS.QTY.plus(delta))
            .execute()
}

코드 생성 설정

// build.gradle.kts (nu.studer.jooq 플러그인)
jooq {
    version.set("3.19.15")
    configurations {
        create("main") {
            jooqConfiguration.apply {
                jdbc.apply {
                    driver = "org.postgresql.Driver"
                    url = "jdbc:postgresql://localhost:5432/app"
                    user = "app"; password = "secret"
                }
                generator.apply {
                    database.apply { inputSchema = "public" }
                    target.apply { packageName = "com.example.jooq" }
                    generate.apply { isPojosAsKotlinDataClasses = true }   // ★ 코틀린 data class 로
                }
            }
        }
    }
}
// Testcontainers 로 띄운 DB 에서 생성하도록 묶으면 로컬 DB 없이도 빌드된다

JPA 와 함께 쓰기 — 실무 패턴

@Service
@Transactional
class OrderService(
    private val repo: OrderRepository,      // 쓰기 — JPA
    private val query: SalesQueryRepository, // 복잡한 조회 — jOOQ
) {
    fun place(cmd: PlaceOrderCommand): Order = repo.save(Order(cmd))

    @Transactional(readOnly = true)
    fun report(from: LocalDate, to: LocalDate) = query.dailySales(from, to)
}

// 같은 트랜잭션·같은 커넥션을 쓰려면
// spring-boot-starter-jooq 를 쓰면 DataSource·트랜잭션이 자동으로 연결된다
주의 — jOOQ 로 UPDATE 를 날리면 JPA 1차 캐시는 모릅니다. 같은 트랜잭션에서 섞어 쓸 때는 flush/clear 타이밍을 의식해야 합니다. 상용 DB(Oracle·SQL Server)는 유료 라이선스가 필요합니다.
DATA 12

트랜잭션 심화

전파(propagation)·격리 수준·락은 동시성 사고가 났을 때 비로소 필요해지는 지식입니다. 코틀린 코드로 실제 상황과 함께 정리합니다.

전파 속성 — 안쪽 함수가 예외를 던졌을 때 무엇이 롤백되나 ① REQUIRED (기본) — 트랜잭션은 하나 outer() @Transactional inner() REQUIRED 새로 만들지 않고 그냥 참여 inner 에서 예외 → 바깥까지 전부 롤백 밖에서 try-catch 해도 소용없습니다 ② REQUIRES_NEW — 트랜잭션이 둘 outer() @Transactional inner() REQUIRES_NEW 커넥션을 하나 더 잡고 따로 커밋 inner 가 롤백돼도 outer 는 살아남음 커넥션을 두 개 동시에 점유합니다 코틀린·스프링에서 가장 자주 걸리는 두 가지 ① 같은 클래스 안에서 자기 함수를 부르면 프록시를 안 거쳐 전파가 아예 안 먹습니다 ② open 이 아닌 클래스·함수는 프록시가 안 만들어집니다 — allopen 플러그인이 필요합니다
"감사 로그만은 남기고 싶다"가 REQUIRES_NEW 의 거의 유일한 정답 용례입니다 — 나머지는 기본값으로 두세요.
전파 속성동작 · 쓰는 곳
REQUIRED(기본)있으면 참여, 없으면 시작. 대부분 이걸로 충분
REQUIRES_NEW항상 새 트랜잭션 — 감사 로그·이력처럼 본 작업이 실패해도 남겨야 할 때
NESTED세이브포인트 — 부분 롤백 (JPA 에서는 제약이 있다)
SUPPORTS · NOT_SUPPORTED있으면 참여 / 트랜잭션 없이 실행
MANDATORY · NEVER반드시 있어야 / 있으면 예외 — 실수 방지용
@Service
class OrderService(
    private val repo: OrderRepository,
    private val auditService: AuditService,
) {
    @Transactional
    fun place(cmd: PlaceOrderCommand): Order {
        val order = repo.save(Order(cmd))
        auditService.log("ORDER_PLACED", order.id)   // 별도 빈이라 프록시를 탄다
        return order
    }
}

@Service
class AuditService(private val auditRepo: AuditRepository) {
    // 본 트랜잭션이 롤백돼도 감사 로그는 남긴다
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun log(action: String, targetId: Long) {
        auditRepo.save(AuditLog(action, targetId))
    }
}

// ⚠️ UnexpectedRollbackException
// 자식 트랜잭션(REQUIRED)에서 예외가 나면 '롤백 표시'가 찍힌다.
// 부모가 그 예외를 잡고 커밋하려 하면 이 예외가 난다 —
// "예외를 잡았는데 왜 롤백되지?"의 정체다.

락 — 동시 수정 제어

// 낙관적 락 — 버전 컬럼으로 충돌 감지 (대부분의 경우 이걸로 충분)
@Entity
class Product(
    @Id @GeneratedValue val id: Long = 0,
    var stock: Int,
) {
    @Version
    var version: Long = 0      // 충돌 시 OptimisticLockingFailureException
}

// 비관적 락 — 정말 경합이 심한 자리에만 (SELECT ... FOR UPDATE)
interface ProductRepository : JpaRepository<Product, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Product p where p.id = :id")
    fun findByIdForUpdate(id: Long): Product?
}

@Transactional
fun decreaseStock(id: Long, qty: Int) {
    val p = repo.findByIdForUpdate(id) ?: throw NotFound(id)
    check(p.stock >= qty) { "재고 부족" }
    p.stock -= qty
}

재시도와 분산 환경

// 낙관적 락 충돌은 '재시도'가 정석
@Retryable(
    retryFor = [ObjectOptimisticLockingFailureException::class],
    maxAttempts = 3,
    backoff = Backoff(delay = 50, multiplier = 2.0),
)
@Transactional
fun decreaseStock(id: Long, qty: Int) { /* ... */ }

// 서버가 여러 대라면 DB 락만으로는 부족한 경우가 있다
// → Redis 분산 락(Redisson) · 대기열 — 대규모 트래픽 탭에서 다룬다

// 코루틴에서 재시도할 때는 트랜잭션 경계를 매번 새로 잡아야 한다
suspend fun decreaseWithRetry(id: Long, qty: Int) = retry(times = 3) {
    withContext(Dispatchers.IO) { tx.execute { doDecrease(id, qty) } }
}
격리 수준은 함부로 올리지 마세요. SERIALIZABLE은 정확하지만 처리량이 급감하고 데드락이 늘어납니다. 대부분의 문제는 낙관적 락 + 재시도나 원자적 UPDATE(update ... set stock = stock - :qty where stock >= :qty)로 더 싸게 해결됩니다.
DATA 13

대용량 · 성능

수십만 건을 다루기 시작하면 JPA 를 쓰는 방식 자체를 바꿔야 합니다. 영속성 컨텍스트가 도움이 아니라 부담이 되는 구간입니다.

10만 건 저장 중 힙 — saveAll 한 줄 vs 1000건씩 끊기 (힙 512 MB 예시) 0 128 256 384 512 MB 0 2만 4만 6만 8만 10만 건 OOM — 7.7만 건 근처 saveAll — 엔티티 + 스냅숏이 계속 쌓임 1000건마다 flush · clear — 50 MB 근처에서 평평 몇 건이냐가 도구를 정합니다 — 로그 눈금 JPA saveAll 1000건씩 끊기 · batchUpdate COPY · LOAD DATA 1천 1만 10만 100만 1000만 IDENTITY 전략이면 INSERT 가 묶이지 않습니다 — SEQUENCE + allocationSize 로
기준은 단순합니다 — 수천 건까지는 JPA, 수만 건부터는 배치, 수백만 건은 DB 도구.

배치 INSERT — 한 건씩 넣지 않기

spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 500          # 묶어서 보낸다
        order_inserts: true
        order_updates: true
  datasource:
    url: jdbc:postgresql://.../app?reWriteBatchedInserts=true   # PG 는 이 옵션까지
@Transactional
fun bulkInsert(rows: List<OrderRow>) {
    rows.chunked(500).forEach { chunk ->
        chunk.forEach { em.persist(it.toEntity()) }
        em.flush()
        em.clear()          // ★ 1차 캐시를 비우지 않으면 메모리가 계속 찬다
    }
}

// ⚠️ @GeneratedValue(strategy = IDENTITY) 면 배치가 동작하지 않는다
//    (INSERT 마다 키를 받아야 하므로) → SEQUENCE + allocationSize 사용

대량 조회 — 페이징의 함정

// ❌ OFFSET 페이징 — 뒤로 갈수록 느려진다 (100만 번째 페이지는 재앙)
repo.findAll(PageRequest.of(10_000, 20))

// ✅ 커서(키셋) 페이징 — 항상 같은 속도
@Query("""
    select o from Order o
    where (:lastId is null or o.id < :lastId)
    order by o.id desc
""")
fun findPage(lastId: Long?, pageable: Pageable): List<Order>

// ✅ 전체 스캔이 필요하면 스트리밍 + clear
@Transactional(readOnly = true)
fun exportAll(sink: (OrderRow) -> Unit) {
    repo.streamAll().use { stream ->
        stream.forEachIndexed { i, o ->
            sink(o.toRow())
            if (i % 1000 == 0) em.clear()
        }
    }
}
증상원인 · 대응
목록 API 가 느리다N+1 → batch size · fetch join · DTO 프로젝션
메모리가 계속 오른다1차 캐시 누적 → clear() · 읽기 전용 트랜잭션
INSERT 가 느리다배치 미적용 · IDENTITY 전략 → SEQUENCE + batch_size
뒤쪽 페이지가 느리다OFFSET → 커서 페이징
가끔 타임아웃락 경합 · 인덱스 부재 → 실행 계획 확인
커넥션 풀 고갈open-in-view · 트랜잭션 안 외부 호출 → 경계 재설계
업데이트가 전 컬럼을 쓴다Hibernate 기본 동작 → @DynamicUpdate(신중히)
대량 배치는 JPA 를 쓰지 않는 선택도 좋습니다. JdbcTemplate.batchUpdate나 jOOQ 로 순수 SQL을 쓰면 수십 배 빠른 경우가 많습니다. 도메인 규칙이 필요 없는 데이터 이관·집계라면 굳이 엔티티를 만들 이유가 없습니다.
DATA 14

스키마 관리 — Flyway

ddl-auto: update는 운영에서 절대 쓰면 안 됩니다. 스키마는 버전이 붙은 SQL 파일로 관리하고, 애플리케이션이 뜰 때 적용합니다.

기본 설정

spring:
  jpa:
    hibernate:
      ddl-auto: validate       # 운영: validate 또는 none
    open-in-view: false
  flyway:
    enabled: true
    baseline-on-migrate: true  # 기존 DB 에 처음 도입할 때
    locations: classpath:db/migration
src/main/resources/db/migration/
  V1__init.sql
  V2__add_order_status_index.sql
  V3__create_settlement_table.sql
  R__view_daily_sales.sql        # R = 반복 실행 (뷰·함수)

규칙
· 이미 적용된 파일은 절대 수정하지 않는다 (체크섬이 어긋난다)
· 되돌릴 SQL 도 함께 준비 (Flyway 커뮤니티 판에는 undo 가 없다)
· 파괴적 변경은 2단계로: 새 컬럼 추가 → 코드 배포 → 옛 컬럼 제거

무중단 배포와 스키마

작업안전한 순서
컬럼 추가nullable 로 추가 → 코드 배포 → 백필 → NOT NULL
컬럼 삭제코드에서 사용 제거 → 배포 → 다음 배포에서 DROP
컬럼 이름 변경새 컬럼 추가 → 양쪽 쓰기 → 백필 → 옛 컬럼 제거
인덱스 추가PG 는 CREATE INDEX CONCURRENTLY
대용량 백필배치로 나눠서 (한 트랜잭션에 몰지 않기)
ddl-auto: update가 위험한 이유 — 컬럼 삭제·타입 변경을 감지하지 못하고, 예측할 수 없는 순서로 DDL 을 날립니다. 로컬 개발에서만 쓰고, 그마저도 Testcontainers + Flyway 로 통일하는 편이 낫습니다.
DATA 15

데이터 계층 테스트

H2 로 테스트하고 운영은 PostgreSQL 이면, 테스트가 통과해도 운영에서 깨집니다. Testcontainers 로 같은 DB를 쓰는 것이 지금의 표준입니다.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)  // H2 로 바꾸지 않기
@Testcontainers
class OrderRepositoryTest(
    @Autowired val repo: OrderRepository,
    @Autowired val em: TestEntityManager,
) {
    companion object {
        @Container @ServiceConnection
        val postgres = PostgreSQLContainer("postgres:16-alpine")
    }

    @Test
    fun `주문번호로 조회한다`() {
        val user = em.persist(user())                // 연관에 cascade 가 없으니 사용자부터 저장
        val saved = em.persistAndFlush(order(orderNo = "ORD-1", user = user))
        em.clear()                                   // 1차 캐시를 비워야 진짜 쿼리가 나간다

        val found = repo.findByOrderNo("ORD-1")
        assertEquals(saved.id, found?.id)
    }

    @Test
    fun `N+1 이 발생하지 않는다`() {
        val user = em.persist(user())
        repeat(10) { em.persist(order(orderNo = "ORD-$it", user = user)) }
        em.flush(); em.clear()

        val stats = em.entityManager.entityManagerFactory
            .unwrap(SessionFactory::class.java).statistics
        stats.clear()

        repo.findAllWithUser().forEach { it.user.name }      // 연관 접근

        assertEquals(1, stats.prepareStatementCount)         // 쿼리 1번이어야 한다
    }
}

테스트 데이터 만들기

// 코틀린 기본 인자로 픽스처를 짧게
fun order(
    orderNo: String = "ORD-${UUID.randomUUID().toString().take(6)}",
    user: User = user(),
    status: OrderStatus = OrderStatus.NEW,
    items: List<OrderItem> = listOf(orderItem()),
) = Order(orderNo = orderNo, user = user, status = status)
    .apply { items.forEach(::addItem) }

// 컨테이너 재사용으로 테스트를 빠르게
// ~/.testcontainers.properties
// testcontainers.reuse.enable=true
@Container @ServiceConnection
val postgres = PostgreSQLContainer("postgres:16-alpine").withReuse(true)

무엇을 테스트할까

  • 쿼리 메서드가 의도한 SQL 을 만드는지 — 특히 이름 규칙으로 만든 것
  • N+1 이 없는지 — 쿼리 수 검증 (위 예시)
  • 제약 조건 — 유니크·NOT NULL 위반 시 예외
  • 낙관적 락 충돌 — 동시 수정 시나리오
  • 마이그레이션 — Flyway 가 처음부터 끝까지 적용되는지
  • DB 방언 차이 — 네이티브 쿼리를 썼다면 반드시 실제 DB 로
DATA 16

선택 가이드 · 체크리스트

이 탭의 결론입니다 — 코틀린 + JPA 를 안전하게 쓰는 규칙과 언제 다른 도구로 갈아탈지에 대한 기준.

코틀린 JPA 규칙 (팀 컨벤션 후보)

  • 엔티티는 일반 class — data class 금지
  • 식별자만 val, 나머지 var
  • equals는 id 기반, hashCode는 클래스 상수
  • toString에 연관 필드 제외
  • 모든 연관은 LAZY 명시
  • @Enumerated(STRING) 고정
  • 컬렉션은 private + 읽기 전용 getter
  • open-in-view: false · ddl-auto: validate
  • default_batch_fetch_size: 100 기본 적용
  • 조회 전용 API 는 DTO 프로젝션

갈아탈 신호

신호고려할 것
JPQL 로 표현이 안 되는 쿼리가 늘어난다jOOQ 병행
동적 조건이 다섯 개를 넘는다Kotlin JDSL · QueryDSL
엔티티 그래프가 너무 복잡해졌다애그리거트 분리 · Spring Data JDBC
kapt 때문에 빌드가 느리다QueryDSL → Kotlin JDSL
Ktor·KMP 서버를 쓴다Exposed
전 구간 논블로킹이 목표R2DBC (JPA 기능 포기)
대량 배치가 주 업무JdbcTemplate · jOOQ · Spring Batch
다음 탭 예고 — 여기서 서버 이야기는 잠시 멈추고, Android 기초로 넘어갑니다. 같은 코틀린·같은 코루틴이지만 생명주기와 UI 상태라는 완전히 다른 축이 등장합니다. Compose 와 ViewModel 이 어떻게 StateFlow 위에 서 있는지 보게 됩니다.
JETPACK COMPOSE · 생명주기 · STATE · VIEWMODEL

안드로이드는
화면이 언제든
사라질 수 있다

서버 개발과 가장 다른 점입니다 — 화면을 돌리면 액티비티가 다시 만들어지고, 메모리가 부족하면 프로세스가 통째로 죽습니다. 그래서 안드로이드 코드의 절반은 “상태를 어디에 두고 어떻게 복구할 것인가”에 대한 답입니다. Compose와 ViewModel은 그 답을 구조로 만든 도구입니다.

15SECTIONS
2UI 방식 (Compose · View)
3기억해야 할 생명주기 사건
100%Kotlin · Compose 기준
AND 01

프로젝트 구조와 시작

안드로이드 프로젝트는 Gradle 멀티모듈 + 매니페스트 + 리소스로 이루어집니다. 서버 프로젝트와 다른 부분만 짚고 넘어갑니다.

폴더 구조

app/
  build.gradle.kts              앱 모듈 빌드 설정
  src/main/
    AndroidManifest.xml         컴포넌트·권한 선언
    java/com/example/           코틀린 소스 (폴더 이름은 java 그대로)
      MainActivity.kt
      ui/                       화면(Composable)
      data/                     Repository · DTO
      domain/                   모델 · 유스케이스
    res/
      drawable/                 이미지 · 벡터
      values/strings.xml        문자열 (하드코딩 금지)
      values/themes.xml         테마
      xml/                      백업 규칙 등
  src/test/                     단위 테스트 (JVM)
  src/androidTest/              계측 테스트 (기기·에뮬레이터)

gradle/libs.versions.toml       ★ 버전 카탈로그 (요즘 표준)
settings.gradle.kts

build.gradle.kts (앱 모듈)

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)     // Kotlin 2.0+ 는 별도 플러그인
    alias(libs.plugins.ksp)
}

android {
    namespace = "com.example.app"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.example.app"
        minSdk = 26                        // 지원할 최소 안드로이드 버전
        targetSdk = 35
        versionCode = 1
        versionName = "1.0.0"
    }

    buildTypes {
        release {
            isMinifyEnabled = true          // R8 코드 축소
            isShrinkResources = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"),
                          "proguard-rules.pro")
        }
    }
    buildFeatures { compose = true }
    kotlin { jvmToolchain(17) }
}

dependencies {
    implementation(platform(libs.androidx.compose.bom))   // BOM 으로 버전 통일
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
}

minSdk 를 정하는 법

낮출수록 더 많은 기기를 지원하지만 옛 API 대응 코드가 늘어납니다. Android Studio 의 마법사가 버전별 점유율을 보여 주므로 그걸 기준으로 정합니다. 26(Oreo) 이상이면 자바 8 API·알림 채널 등 대부분의 현대적 기능을 쓸 수 있습니다.

서버 개발자가 낯설어하는 것

  • 매니페스트 — 화면·서비스·권한을 XML 로 선언해야 시스템이 안다
  • 리소스 — 문자열·색·크기를 코드가 아니라 res/에 두고 참조
  • R8/ProGuard — 릴리스 빌드에서 코드가 난독화·축소된다 (리플렉션 주의)
  • 기기 파편화 — 화면 크기·OS 버전·제조사 변형까지 고려
AND 02

생명주기 — 화면은 사라진다

안드로이드에서 버그의 가장 큰 원천입니다. 화면 회전 · 백그라운드 전환 · 프로세스 종료 세 가지가 각각 다르게 동작하고, 각각 다른 복구 수단이 있습니다.

무엇이 사라지고 무엇이 남는가 — 세 가지 상황 ① 화면 회전 Activity 재생성 ViewModel 은 살아남는다 remember 는 사라진다 ② 프로세스 종료 백그라운드에서 메모리 회수 ViewModel 도 사라진다 SavedStateHandle 만 복구 ③ 앱 완전 종료 사용자가 스와이프로 닫음 메모리의 모든 것이 소실 디스크(DB·DataStore)만 남음 그래서 상태를 두는 곳이 세 층이다 remember 스크롤 위치 · 애니메이션 ViewModel · SavedStateHandle 화면 상태 · 입력값 Room · DataStore 사용자 데이터 · 설정 "화면을 돌렸더니 입력한 값이 날아갔다"의 원인은 대개 ① 을 놓친 것 "홈으로 나갔다 오니 처음 화면이다"는 ② 를 놓친 것
개발자 옵션의 “활동 유지 안 함”을 켜면 ②를 항상 재현할 수 있습니다 — 출시 전 필수 점검입니다.

Activity 생명주기 콜백

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { AppTheme { AppNavHost() } }     // Compose 진입점
    }
    // onStart  — 화면에 보이기 시작
    // onResume — 사용자와 상호작용 가능 (포커스)
    // onPause  — 다른 화면이 위로 (일부 가림)
    // onStop   — 완전히 안 보임 → 여기서 무거운 작업 중단
    // onDestroy— 끝 (회전 시에도 불린다!)
}

// Compose 에서 생명주기 이벤트를 관찰
@Composable
fun ObserveLifecycle(onResume: () -> Unit) {
    val owner = LocalLifecycleOwner.current
    DisposableEffect(owner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) onResume()
        }
        owner.lifecycle.addObserver(observer)
        onDispose { owner.lifecycle.removeObserver(observer) }
    }
}

상태 복구 — SavedStateHandle

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val savedState: SavedStateHandle,   // 프로세스 종료도 견딘다
    private val repo: SearchRepository,
) : ViewModel() {

    // 화면 인자 (Navigation 에서 전달된 값)
    private val categoryId: Long = checkNotNull(savedState["categoryId"])

    // 사용자 입력 — 프로세스가 죽어도 복구된다
    val query: StateFlow<String> = savedState.getStateFlow("query", "")

    fun onQueryChange(q: String) { savedState["query"] = q }

    val results: StateFlow<List<Item>> = query
        .debounce(300)
        .flatMapLatest { repo.search(it, categoryId) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
SavedStateHandle 에는 작은 값만 넣으세요(공식 권장은 50KB 미만 — Bundle 전체 한도가 약 1MB). Bundle 크기를 넘기면 TransactionTooLargeException으로 앱이 죽습니다. 목록 데이터는 DB 에 두고 id 만 저장하는 것이 정석입니다.
AND 03

Compose — 선언형 UI

XML 로 화면을 만들고 findViewById로 값을 바꾸던 방식이, “상태를 주면 화면이 그려진다”는 함수 하나로 바뀝니다.

명령형(View)과 선언형(Compose) — 화면을 바꾸는 방법이 근본부터 다릅니다 ① XML + View — 화면을 직접 고친다 findViewById(R.id.name).text = "홍길동" 상태가 '뷰 안'에 들어 있습니다 고쳐야 할 뷰를 하나 빠뜨리면 화면이 어긋납니다 화면이 늘수록 갱신 코드가 곱으로 늘어납니다 ② Compose — 상태를 바꾸면 화면이 따라온다 var name by remember { mutableStateOf("") } Text(name) — 화면은 상태의 함수입니다 바뀐 상태를 읽던 부분만 다시 실행됩니다 갱신 코드라는 것이 아예 없습니다 Compose 가 도는 한 바퀴 ① 사용자 입력 버튼 탭 · 글자 입력 ② 상태 변경 State 값이 바뀐다 ③ 재구성 그 함수만 다시 실행 새 화면이 다시 입력을 받는다
Compose 를 배울 때 외울 문장은 하나입니다 — 화면 = f(상태). 화면을 고치려 하지 말고 상태를 고치세요.
기존 View 방식 — 명령형
// activity_main.xml 에 뷰를 선언하고
val textView = findViewById<TextView>(R.id.text)
val button = findViewById<Button>(R.id.button)

var count = 0
button.setOnClickListener {
    count++
    textView.text = "클릭 $count"       // 직접 바꾼다
    if (count > 5) {
        button.isEnabled = false          // 상태가 여러 곳에 흩어진다
        textView.setTextColor(Color.RED)
    }
}
// 화면이 복잡해질수록 '지금 화면 상태'를 아무도 모른다
Compose — 선언형
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column(
        modifier = Modifier.fillMaxWidth().padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(
            text = "클릭 $count",
            color = if (count > 5) Color.Red else Color.Unspecified,
            style = MaterialTheme.typography.headlineMedium,
        )
        Button(onClick = { count++ }, enabled = count <= 5) {
            Text("누르기")
        }
    }
}
// 상태(count)만 바뀌면 화면은 알아서 다시 그려진다

핵심 개념 넷

개념뜻
@ComposableUI 를 그리는 함수. 반환값이 아니라 화면을 방출한다
Recomposition상태가 바뀌면 그 부분만 다시 실행된다
State<T>Compose 가 관찰하는 값. 바뀌면 recomposition 을 유발
Modifier크기·여백·클릭·배경을 체인으로 붙이는 방식
Composable 함수의 규칙 — ① 이름은 대문자로 시작, ② 반환값이 없다(Unit), ③ 부수 효과를 직접 쓰지 않는다(다음 섹션), ④ 여러 번·순서 없이 실행될 수 있다.

Modifier — 순서가 의미를 바꾼다

// 패딩 → 배경 : 패딩 바깥은 배경이 없다
Text("A", Modifier.padding(16.dp).background(Color.Blue))

// 배경 → 패딩 : 배경이 패딩 영역까지 칠해진다
Text("A", Modifier.background(Color.Blue).padding(16.dp))

// 자주 쓰는 것들
Modifier
    .fillMaxWidth()                 // 가로 꽉
    .height(48.dp)
    .padding(horizontal = 16.dp)
    .clip(RoundedCornerShape(8.dp))
    .background(MaterialTheme.colorScheme.surface)
    .clickable { onClick() }        // 클릭 영역은 여기까지
    .semantics { contentDescription = "주문 항목" }   // 접근성
미리보기(@Preview)를 적극 쓰세요. 기기를 띄우지 않고 IDE 안에서 화면을 확인합니다. 상태별 미리보기(로딩·에러·빈 목록)를 만들어 두면 UI 회귀를 눈으로 즉시 잡습니다.
@Preview(showBackground = true, name = "빈 목록")
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES, name = "다크")
@Composable
private fun OrderListPreview() = AppTheme { OrderList(orders = emptyList(), onClick = {}) }
AND 04

레이아웃 · 리스트

Compose 의 레이아웃은 Column · Row · Box 셋으로 거의 다 됩니다. 목록은 LazyColumn — 화면에 보이는 것만 만들어 성능을 지킵니다.

기본 레이아웃

// 세로 배치
Column(
    verticalArrangement = Arrangement.spacedBy(8.dp),   // 자식 사이 간격
    horizontalAlignment = Alignment.CenterHorizontally,
) {
    Text("제목")
    Text("설명")
}

// 가로 배치 — weight 로 비율 분배
Row(verticalAlignment = Alignment.CenterVertically) {
    Text("이름", modifier = Modifier.weight(1f))        // 남은 공간을 차지
    Text("12,000원")
}

// 겹치기
Box(contentAlignment = Alignment.Center) {
    Image(painter, contentDescription = null)
    CircularProgressIndicator()                          // 이미지 위에
}

// 스크롤이 필요한 짧은 화면
Column(Modifier.verticalScroll(rememberScrollState())) { /* ... */ }

LazyColumn — 목록의 기본

@Composable
fun OrderList(orders: List<Order>, onClick: (Long) -> Unit) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        item { ListHeader() }                      // 단일 항목

        items(
            items = orders,
            key = { it.id },                        // ★ 안정적인 key 는 필수
        ) { order ->
            OrderCard(order, onClick = { onClick(order.id) })
        }

        if (orders.isEmpty()) {
            item { EmptyView() }
        }
    }
}

// 그리드는 LazyVerticalGrid
LazyVerticalGrid(columns = GridCells.Fixed(2)) { items(products) { ProductCard(it) } }
key를 주지 않으면 항목이 추가·삭제될 때 인덱스 기준으로 재사용되어 상태가 엉뚱한 항목에 붙습니다(체크박스가 다른 줄로 옮겨 가는 버그).

화면 골격 — Scaffold

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OrderScreen(
    state: OrderUiState,
    onRefresh: () -> Unit,
    onItemClick: (Long) -> Unit,
) {
    val snackbarHostState = remember { SnackbarHostState() }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("주문 내역") },
                actions = { IconButton(onRefresh) { Icon(Icons.Default.Refresh, "새로고침") } },
            )
        },
        snackbarHost = { SnackbarHost(snackbarHostState) },
        floatingActionButton = {
            FloatingActionButton(onClick = { /* ... */ }) { Icon(Icons.Default.Add, "추가") }
        },
    ) { padding ->
        Box(Modifier.padding(padding).fillMaxSize()) {      // ★ padding 을 반드시 적용
            when (state) {
                OrderUiState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
                is OrderUiState.Error -> ErrorView(state.message, onRetry = onRefresh)
                is OrderUiState.Success -> OrderList(state.orders, onItemClick)
            }
        }
    }
}
화면 상태를 sealed 로 만들고 when으로 분기하는 이 형태가 Compose 화면의 표준 골격입니다. 로딩·에러·성공·빈 목록 네 가지를 빠뜨리지 않게 컴파일러가 도와줍니다(Kotlin 기초 11 · sealed).
AND 05

상태와 재구성(recomposition)

Compose 를 제대로 쓰는 것은 상태를 어디에 둘지를 정하는 일입니다. 잘못 두면 화면이 안 바뀌거나, 반대로 필요 없이 계속 다시 그려집니다.

상태를 「읽는 컴포저블」만 다시 그려진다 — 어디서 읽느냐가 범위를 정한다 ❌ 부모가 값을 읽어 넘긴다 Screen — 여기서 count 를 읽음 Header(count) Footer Text("$count") Button count 가 1 오르면 — 다섯 개 전부 다시 그려진다 ✅ 값을 읽지 않고 람다만 넘긴다 Screen — 읽지 않고 넘기기만 함 Header { count } Footer Text { count } Button count 가 1 오르면 — 실제로 읽는 Text(호박색) 하나만 규칙 — 상태를 「읽는 위치」를 아래로 미룰수록 다시 그리는 범위가 좁아진다 값을 그대로 넘기면 부모가 읽은 것 · 람다로 넘기면 자식이 읽는다 (읽는 순간이 곧 구독) remember(key) · derivedStateOf · @Immutable 도 전부 「읽는 횟수를 줄이는」 같은 목적이다
다시 그려지는 범위는 값을 읽는 곳이 정합니다 — 넘기는 곳이 아닙니다.
// ① remember — 재구성 사이에 값을 기억한다 (화면 회전에는 사라진다)
var text by remember { mutableStateOf("") }

// ② rememberSaveable — 화면 회전·프로세스 종료도 견딘다 (작은 값만)
var query by rememberSaveable { mutableStateOf("") }

// ③ 계산 결과 캐싱 — key 가 바뀔 때만 다시 계산
val formatted = remember(order.total) { formatCurrency(order.total) }

// ④ 리스트 상태
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { listState.animateScrollToItem(0) } }) { Text("맨 위로") }

// ⑤ 파생 상태 — 자주 바뀌는 값에서 '가끔 바뀌는 값'을 뽑을 때
val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
// listState 는 스크롤마다 바뀌지만 showScrollToTop 은 true/false 가 바뀔 때만 재구성

상태 호이스팅 — 위로 끌어올리기

// ❌ 상태를 안에 가둔 컴포저블 — 테스트·재사용이 어렵다
@Composable
fun SearchBar() {
    var query by remember { mutableStateOf("") }
    TextField(query, { query = it })
}

// ✅ 상태를 밖으로, 이벤트를 위로 (stateless)
@Composable
fun SearchBar(
    query: String,
    onQueryChange: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    TextField(value = query, onValueChange = onQueryChange, modifier = modifier)
}

// 쓰는 쪽에서 상태를 소유한다
@Composable
fun SearchScreen(vm: SearchViewModel = hiltViewModel()) {
    val query by vm.query.collectAsStateWithLifecycle()
    SearchBar(query = query, onQueryChange = vm::onQueryChange)
}
규칙 — 상태는 그것을 읽는 모든 컴포저블의 공통 조상에 둡니다. UI 컴포넌트는 상태를 받고 이벤트를 올려보내는 형태(stateless)로 만들면 미리보기·테스트·재사용이 전부 쉬워집니다.

재구성을 줄이는 기술

  • 상태를 읽는 범위를 좁힌다 — 값을 그대로 넘기지 말고 람다로 넘기면 그 자식만 재구성됩니다 (text = { count })
  • 안정적인(stable) 타입을 쓴다 — List는 불안정으로 취급됩니다. ImmutableList(kotlinx.collections.immutable)나 @Immutable 표시
  • key를 준다 — 목록 항목의 정체성
  • 무거운 계산은 remember(key) 로 캐싱
  • 람다를 매번 새로 만들지 않기 — 메서드 참조(vm::onClick) 사용
@Immutable
data class OrderUi(val id: Long, val title: String, val total: String)

// 컴파일러 지표 켜기 — 어떤 함수가 불안정한지 보고서로 나온다
// kotlin { compilerOptions { freeCompilerArgs.addAll(
//   "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=..." ) } }
Composable 안에서 값을 직접 바꾸지 마세요. @Composable fun A() { count++ } 같은 코드는 재구성마다 실행되어 무한 루프가 됩니다. 상태 변경은 이벤트 콜백이나 부수 효과 API(다음 섹션)에서만.
AND 06

부수 효과 — Effect API

“화면이 뜨면 데이터를 불러온다”, “에러가 생기면 스낵바를 띄운다” 같은 UI 그리기가 아닌 일은 정해진 API 안에서 해야 합니다. 아무 데서나 하면 재구성마다 반복 실행됩니다.

API언제 쓰나
LaunchedEffect(key)진입 시 한 번 · key 가 바뀔 때 코루틴 실행 (가장 많이 씀)
rememberCoroutineScope()이벤트 콜백에서 코루틴을 시작할 때 (클릭 → 스크롤)
DisposableEffect(key)등록/해제가 짝인 것 (리스너·브로드캐스트 리시버)
rememberUpdatedState오래 도는 effect 안에서 최신 람다를 참조해야 할 때
SideEffect재구성이 성공할 때마다 비-Compose 객체에 값 전달
produceState비-Compose 소스를 State 로 변환
snapshotFlowCompose State 를 Flow 로 (스크롤 위치 관찰 등)
@Composable
fun OrderScreen(vm: OrderViewModel = hiltViewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    val snackbar = remember { SnackbarHostState() }

    // ① 진입 시 한 번 로드 (id 가 바뀌면 다시)
    LaunchedEffect(vm.orderId) { vm.load() }

    // ② 일회성 이벤트 수신 — SharedFlow
    LaunchedEffect(Unit) {
        vm.events.collect { event ->
            when (event) {
                is Event.ShowMessage -> snackbar.showSnackbar(event.text)
                is Event.NavigateBack -> onBack()
            }
        }
    }

    // ③ 이벤트 콜백에서 코루틴 — rememberCoroutineScope
    val scope = rememberCoroutineScope()
    val listState = rememberLazyListState()
    Button(onClick = { scope.launch { listState.animateScrollToItem(0) } }) { Text("맨 위로") }

    // ④ 등록/해제 짝
    DisposableEffect(Unit) {
        val receiver = NetworkReceiver { vm.onNetworkChange(it) }
        context.registerReceiver(receiver, filter)
        onDispose { context.unregisterReceiver(receiver) }
    }

    // ⑤ 스크롤 끝 감지 → 다음 페이지 (Compose State → Flow)
    LaunchedEffect(listState) {
        snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
            .distinctUntilChanged()
            .collect { last -> if (last != null && last >= state.items.size - 3) vm.loadMore() }
    }
}

collectAsStateWithLifecycle 을 쓰세요

// ❌ 화면이 백그라운드로 가도 계속 수집한다 (배터리·네트워크 낭비)
val state by vm.state.collectAsState()

// ✅ 화면이 보일 때만 수집 (STARTED 이상)
val state by vm.state.collectAsStateWithLifecycle()
// implementation("androidx.lifecycle:lifecycle-runtime-compose")

ViewModel 쪽의 SharingStarted.WhileSubscribed(5_000)과 짝을 이룹니다 — 화면이 사라지면 5초 뒤 상류 Flow(=DB·네트워크 구독)까지 멈춥니다.

흔한 실수

  • LaunchedEffect 의 key 를 잘못 줌 — Unit이면 한 번만, 매번 바뀌는 객체를 주면 매 재구성마다 재시작
  • Composable 본문에서 API 호출 — 재구성마다 호출됩니다
  • 스낵바를 상태로 표현 — 회전하면 다시 뜹니다. 이벤트(SharedFlow)로
  • rememberCoroutineScope 를 진입 로직에 — 그건 LaunchedEffect 자리
컴포저블은 여러 번 다시 불립니다 — 그 안에서 일을 벌이면 안 됩니다 이렇게 하면 안 됩니다 @Composable fun Screen() { api.load() ← 재구성마다 호출됩니다 LaunchedEffect(key) key 가 같으면 다시 실행되지 않습니다 key 가 바뀌면 이전 것을 취소하고 새로 시작합니다 LaunchedEffect 화면에 들어올 때 한 번 코루틴으로 실행됩니다 DisposableEffect 등록하고 떠날 때 해제 리스너 · 브로드캐스트 수신 rememberCoroutineScope 버튼을 눌렀을 때처럼 사용자 동작에서 시작할 때 key 를 아무거나 주면(예: 매번 새 객체) 재구성마다 다시 실행됩니다 — Unit 이나 실제 식별자를 주세요 가장 좋은 것은 데이터 로딩을 ViewModel 로 옮기는 것입니다 — 화면 회전에도 살아남습니다(an07)
"화면을 그리는 함수"와 "일을 하는 코드"를 분명히 나누는 것이 핵심입니다.
같은 화면, 다섯 시점 — 어디에 쓴 코드가 몇 번 도나 진입 재구성 재구성 userId 1→2 떠남 본문에서 직접 호출 5번 LaunchedEffect(userId) load(1) 실행 중 취소 ✕ load(2) 2번 DisposableEffect 리스너 등록 onDispose 해제 본문은 재구성마다 다시 불립니다 — 일은 Effect 안에서, key 가 바뀔 때만 key 에 매번 새 객체를 주면 LaunchedEffect 도 맨 윗줄처럼 매번 재시작됩니다 rememberCoroutineScope 는 진입이 아니라 클릭 같은 사용자 동작에서 시작할 때 씁니다
본문은 재구성마다, LaunchedEffect 는 key 가 바뀔 때만 돕니다 — 같은 화면에서 실행 횟수가 이만큼 다릅니다.
AND 07

ViewModel · 화면 상태 설계

안드로이드 아키텍처의 중심입니다. 화면이 재생성돼도 살아남고, viewModelScope로 화면이 사라지면 코루틴이 자동 취소됩니다.

단방향 흐름 — 상태는 한 방향으로만, 사건은 반대 방향으로만 Repository 네트워크 · DB 화면을 전혀 모른다 데이터 ViewModel UiState 하나로 합쳐 StateFlow 로 내보낸다 상태 Compose 화면 받아서 그리기만 스스로 판단하지 않는다 사용자 사건 — onEvent(...) UiState 는 하나로 묶습니다 — 필드를 따로 두면 조합이 어긋납니다 따로 둔 경우 isLoading · errorMessage · items 세 개 "로딩 중인데 에러도 떠 있는" 상태가 만들어진다 sealed 로 묶은 경우 Loading | Success(items) | Error(msg) 불가능한 조합이 타입 수준에서 사라진다
"화면이 이상하다"의 원인은 대개 상태가 두 군데에 있는 것입니다 — 출처를 하나로 만드세요.
// 화면 상태 — sealed 로 모든 경우를 덮는다
sealed interface OrderUiState {
    data object Loading : OrderUiState
    data class Success(
        val orders: List<OrderUi>,
        val isRefreshing: Boolean = false,
    ) : OrderUiState
    data class Error(val message: String) : OrderUiState
}

// 일회성 이벤트 — 상태가 아니다
sealed interface OrderEvent {
    data class ShowMessage(val text: String) : OrderEvent
    data class NavigateToDetail(val id: Long) : OrderEvent
}

@HiltViewModel
class OrderViewModel @Inject constructor(
    private val repo: OrderRepository,
    savedState: SavedStateHandle,
) : ViewModel() {

    private val _state = MutableStateFlow<OrderUiState>(OrderUiState.Loading)
    val state: StateFlow<OrderUiState> = _state.asStateFlow()

    private val _events = MutableSharedFlow<OrderEvent>()
    val events: SharedFlow<OrderEvent> = _events.asSharedFlow()

    init { load() }

    fun load() {
        viewModelScope.launch {                     // 화면이 사라지면 자동 취소
            _state.value = OrderUiState.Loading
            _state.value = runCatching { repo.list() }
                .fold(
                    onSuccess = { OrderUiState.Success(it.map(Order::toUi)) },
                    onFailure = { OrderUiState.Error(it.toUserMessage()) },
                )
        }
    }

    fun refresh() = viewModelScope.launch {
        _state.update { if (it is OrderUiState.Success) it.copy(isRefreshing = true) else it }
        runCatching { repo.list() }
            .onSuccess { _state.value = OrderUiState.Success(it.map(Order::toUi)) }
            .onFailure {
                // 실패해도 당김 표시는 꺼야 한다 — 안 그러면 스피너가 영영 돈다
                _state.update { s -> if (s is OrderUiState.Success) s.copy(isRefreshing = false) else s }
                _events.emit(OrderEvent.ShowMessage("새로고침 실패"))
            }
    }

    fun onItemClick(id: Long) = viewModelScope.launch {
        _events.emit(OrderEvent.NavigateToDetail(id))
    }
}

DB·네트워크를 Flow 로 잇기

@HiltViewModel
class FeedViewModel @Inject constructor(repo: FeedRepository) : ViewModel() {

    // Room 이 Flow 를 주면 데이터가 바뀔 때마다 화면이 자동 갱신된다
    val state: StateFlow<FeedUiState> = repo.observeFeed()
        .map<_, FeedUiState> { FeedUiState.Success(it.map(Post::toUi)) }   // 상위 타입으로 — catch 에서 Error 를 내보내려면
        .catch { emit(FeedUiState.Error(it.message ?: "오류")) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = FeedUiState.Loading,
        )
}
단일 소스 원칙(SSOT) — 화면은 DB 를 보고, 네트워크는 DB 를 갱신합니다. 이렇게 하면 오프라인에서도 동작하고, 갱신 로직이 화면과 분리됩니다.

ViewModel 규칙

  • Context·View·Activity 를 들고 있지 않기 — 메모리 누수의 정석 (필요하면 AndroidViewModel의 application)
  • UI 모델로 변환해서 내보내기 — 화면이 도메인 객체를 직접 다루지 않게
  • 상태는 하나의 StateFlow — 여러 개면 조합 상태가 어긋납니다
  • 이벤트와 상태를 구분 — 토스트·이동은 이벤트
  • init에서 무거운 로딩을 하면 테스트가 어려워집니다 — 화면에서 LaunchedEffect로 부르는 방식도 좋습니다
AND 08

네비게이션

화면 사이 이동과 인자 전달입니다. Navigation Compose 는 타입 안전 라우트를 지원해 문자열로 경로를 조립하던 시절의 실수가 사라졌습니다.

// 타입 안전 라우트 — @Serializable 객체로 정의 (Navigation 2.8+)
@Serializable data object HomeRoute
@Serializable data class OrderDetailRoute(val orderId: Long)
@Serializable data class SearchRoute(val query: String? = null)

@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController, startDestination = HomeRoute) {

        composable<HomeRoute> {
            HomeScreen(
                onOrderClick = { id -> navController.navigate(OrderDetailRoute(id)) },
                onSearch = { navController.navigate(SearchRoute()) },
            )
        }

        composable<OrderDetailRoute> { entry ->
            val route: OrderDetailRoute = entry.toRoute()      // 타입 안전하게 인자 획득
            OrderDetailScreen(
                orderId = route.orderId,
                onBack = { navController.popBackStack() },
            )
        }

        composable<SearchRoute> { SearchScreen() }
    }
}

// 백스택 조작
navController.navigate(HomeRoute) {
    popUpTo(LoginRoute) { inclusive = true }   // 로그인 화면까지 스택에서 지운다 → 뒤로 가기로 못 돌아감
    launchSingleTop = true                      // 같은 화면 중복 방지
}

ViewModel 이 인자를 받는 법

@HiltViewModel
class OrderDetailViewModel @Inject constructor(
    savedState: SavedStateHandle,
    private val repo: OrderRepository,
) : ViewModel() {
    // 라우트 인자가 SavedStateHandle 로 자동 주입된다
    private val route = savedState.toRoute<OrderDetailRoute>()

    val state = repo.observe(route.orderId)
        .map { OrderDetailUiState.Success(it) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OrderDetailUiState.Loading)
}

화면이 인자를 컴포저블 파라미터로 받아 ViewModel 에 다시 넘기지 않아도 됩니다 — 중복이 줄고 프로세스 종료 복구도 함께 해결됩니다.

결과 돌려주기 · 딥링크

// 이전 화면으로 결과 전달 (SavedStateHandle 사용)
navController.previousBackStackEntry
    ?.savedStateHandle?.set("selectedAddress", address)
navController.popBackStack()

// 받는 쪽
val address by navController.currentBackStackEntry
    ?.savedStateHandle
    ?.getStateFlow<Address?>("selectedAddress", null)
    ?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) }

// 딥링크 — 알림·외부 링크에서 특정 화면으로
composable<OrderDetailRoute>(
    deepLinks = listOf(navDeepLink<OrderDetailRoute>(basePath = "https://app.example.com/orders"))
) { /* ... */ }
화면이 많아지면 모듈로 나눕니다 — 기능(feature)별 모듈이 자기 라우트와 화면을 갖고, 앱 모듈은 그것을 조립만 합니다. 빌드 시간이 줄고 팀이 나눠 일하기 좋아집니다 (Android 실전 탭에서 멀티모듈 구조를 다룹니다).
화면은 스택으로 쌓입니다 — 뒤로 가기가 그 반대입니다 "list" navigate "detail/{id}" "edit/{id}" 뒤로 가기 — 스택에서 하나씩 꺼냅니다 넘기는 것은 id 만 navigate("detail/$id") 객체를 통째로 넘기면 직렬화·크기 제한 문제가 생깁니다 데이터는 다음 화면에서 다시 불러옵니다 스택을 정리해야 할 때 로그인 성공 후 로그인 화면으로 돌아가면 안 됩니다 popUpTo(…) { inclusive = true } 로 걷어 냅니다 launchSingleTop 으로 같은 화면 중복 쌓기를 막습니다 결과를 돌려받을 때는 이전 화면의 저장 상태 핸들에 담습니다 — 전역 변수로 주고받으면 프로세스가 죽었다 살아날 때 사라집니다 최근에는 문자열 경로 대신 타입 안전한 경로(직렬화 가능한 객체)를 쓰는 방식이 권장됩니다
뒤로 가기가 어색하다면 스택이 의도대로 쌓이지 않은 것입니다.
화면은 스택으로 쌓입니다 — 뒤로 가기가 그 반대입니다 "list" navigate "detail/{id}" "edit/{id}" 뒤로 가기 — 스택에서 하나씩 꺼냅니다 넘기는 것은 id 만 navigate("detail/\$id") 객체를 통째로 넘기면 직렬화·크기 제한 문제가 생깁니다 데이터는 다음 화면에서 다시 불러옵니다 스택을 정리해야 할 때 로그인 성공 후 로그인 화면으로 돌아가면 안 됩니다 popUpTo(…) { inclusive = true } 로 걷어 냅니다 launchSingleTop 으로 같은 화면 중복 쌓기를 막습니다 결과를 돌려받을 때는 이전 화면의 저장 상태 핸들에 담습니다 — 전역 변수로 주고받으면 프로세스가 죽었다 살아날 때 사라집니다 최근에는 문자열 경로 대신 타입 안전한 경로(직렬화 가능한 객체)를 쓰는 방식이 권장됩니다
뒤로 가기가 어색하다면 스택이 의도대로 쌓이지 않은 것입니다.
AND 09

테마 · 머티리얼 3

색·글꼴·모양을 한 곳에서 정의하고 화면은 그것을 참조합니다. 다크 모드와 다이내믹 컬러(안드로이드 12+ 배경화면 기반 색상)가 기본으로 따라옵니다.

// ui/theme/Theme.kt
private val LightColors = lightColorScheme(
    primary = Color(0xFF3B82F6),
    onPrimary = Color.White,
    surface = Color(0xFFF8FAFC),
    error = Color(0xFFDC2626),
)
private val DarkColors = darkColorScheme(
    primary = Color(0xFF60A5FA),
    onPrimary = Color(0xFF0B1220),
    surface = Color(0xFF0F1A2E),
)

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit,
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val ctx = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
        }
        darkTheme -> DarkColors
        else -> LightColors
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        shapes = Shapes(medium = RoundedCornerShape(12.dp)),
        content = content,
    )
}

// 쓰는 쪽 — 하드코딩 대신 테마 값을 참조
Text("제목", color = MaterialTheme.colorScheme.onSurface,
     style = MaterialTheme.typography.titleLarge)
Surface(color = MaterialTheme.colorScheme.surfaceVariant) { /* ... */ }

색 역할(role)을 이해하면 다크 모드가 공짜

역할쓰임
primary / onPrimary주요 버튼과 그 위의 글자
surface / onSurface카드·시트 배경과 그 위 글자
surfaceVariant구분되는 배경 (입력창·칩)
error / onError오류 표시
outline테두리·구분선
Color.Black·Color.White를 직접 쓰지 마세요. onSurface처럼 역할로 지정하면 라이트·다크가 자동으로 맞습니다.

화면 크기 대응

// 창 크기 클래스 — 폰·폴더블·태블릿을 세 등급으로
val windowSize = calculateWindowSizeClass(activity)

when (windowSize.widthSizeClass) {
    WindowWidthSizeClass.Compact -> PhoneLayout()      // 세로 폰
    WindowWidthSizeClass.Medium -> TabletLayout()      // 폴더블·작은 태블릿
    WindowWidthSizeClass.Expanded -> TwoPaneLayout()   // 큰 태블릿 — 목록+상세
}

// 안전 영역 (노치·내비게이션 바)
Scaffold(modifier = Modifier.safeDrawingPadding()) { /* ... */ }
// 또는 edge-to-edge 로 그리고 insets 를 직접 다룬다
enableEdgeToEdge()
AND 10

리소스 · 다국어 · 접근성

문자열을 코드에 하드코딩하면 번역도, 접근성도, 화면 크기 대응도 막힙니다. 리소스 시스템은 그 세 가지를 한꺼번에 해결하는 장치입니다.

문자열 리소스

<!-- res/values/strings.xml (기본 = 한국어라면) -->
<resources>
    <string name="order_title">주문 내역</string>
    <string name="order_total">총 %1$s원</string>
    <plurals name="item_count">
        <item quantity="other">%d개</item>
    </plurals>
</resources>

<!-- res/values-en/strings.xml -->
<resources>
    <string name="order_title">Orders</string>
    <string name="order_total">Total %1$s</string>
</resources>
Text(stringResource(R.string.order_title))
Text(stringResource(R.string.order_total, formatted))
Text(pluralStringResource(R.plurals.item_count, count, count))

접근성 — 어렵지 않습니다

// ① 이미지·아이콘에는 설명을 (장식이면 명시적으로 null)
Icon(Icons.Default.Delete, contentDescription = "삭제")
Image(painter, contentDescription = null)        // 순수 장식

// ② 터치 영역은 최소 48dp
IconButton(onClick = onDelete, modifier = Modifier.size(48.dp)) { ... }

// ③ 여러 요소를 하나로 읽게 묶기
Row(Modifier.semantics(mergeDescendants = true) {}) {
    Text(order.title); Text(order.total)
}

// ④ 상태를 알려 주기
Checkbox(checked, onCheckedChange, modifier = Modifier.semantics {
    stateDescription = if (checked) "선택됨" else "선택 안 됨"
})

// ⑤ 글자 크기 확대 대응 — dp 가 아니라 sp, 그리고 고정 높이 피하기
Text("본문", fontSize = 16.sp)
Accessibility Scanner 앱으로 화면을 스캔하면 대비·터치 크기·설명 누락을 자동으로 찾아 줍니다. 출시 전 한 번 돌리는 것만으로 큰 차이가 납니다.
AND 11

네트워크 — Retrofit · Ktor

안드로이드에서 API 호출은 코루틴 suspend 함수가 표준입니다. Retrofit 과 Ktor Client 중 무엇을 고르든 사용감은 비슷합니다.

Retrofit — 안드로이드의 사실상 표준

interface OrderApi {
    @GET("orders")
    suspend fun list(@Query("page") page: Int = 1): List<OrderDto>

    @GET("orders/{id}")
    suspend fun get(@Path("id") id: Long): OrderDto

    @POST("orders")
    suspend fun create(@Body req: CreateOrderRequest): OrderDto

    // 응답 코드·헤더가 필요하면
    @DELETE("orders/{id}")
    suspend fun delete(@Path("id") id: Long): Response<Unit>
}

// 구성
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(
        OkHttpClient.Builder()
            .connectTimeout(10, TimeUnit.SECONDS)
            .readTimeout(15, TimeUnit.SECONDS)
            .addInterceptor(AuthInterceptor(tokenStore))
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = if (BuildConfig.DEBUG) BODY else NONE
            })
            .build()
    )
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()

val api: OrderApi = retrofit.create()

Ktor Client — KMP 로 갈 거라면

val client = HttpClient(OkHttp) {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    install(HttpTimeout) { requestTimeoutMillis = 15_000 }
    install(Logging) { level = LogLevel.HEADERS }
    defaultRequest {
        url("https://api.example.com/")
        header(HttpHeaders.Authorization, "Bearer ${tokenStore.token}")
    }
}

suspend fun list(page: Int): List<OrderDto> =
    client.get("orders") { parameter("page", page) }.body()

// 같은 코드가 iOS·데스크톱에서도 돈다 → 멀티플랫폼 탭 참고
고르는 기준 — 안드로이드 전용이면 Retrofit(생태계·자료가 압도적), KMP 를 염두에 두면 Ktor. 둘 다 코루틴 기반이라 서비스 계층 코드는 거의 같습니다.

Repository — 네트워크와 화면 사이

class OrderRepository @Inject constructor(
    private val api: OrderApi,
    private val dao: OrderDao,
    private val io: CoroutineDispatcher = Dispatchers.IO,
) {
    // 화면은 DB 만 본다 (단일 소스)
    fun observe(): Flow<List<Order>> = dao.observeAll().map { it.map(OrderEntity::toDomain) }

    // 네트워크는 DB 를 갱신한다
    suspend fun refresh(): Result<Unit> = withContext(io) {
        runCatching {
            val remote = api.list()
            dao.replaceAll(remote.map(OrderDto::toEntity))
        }.onFailure { e ->
            if (e is CancellationException) throw e
            Log.w(TAG, "동기화 실패", e)
        }.map { }
    }
}

// 에러를 사용자 메시지로 바꾸는 자리도 여기
fun Throwable.toUserMessage(): String = when (this) {
    is UnknownHostException, is ConnectException -> "인터넷 연결을 확인해 주세요"
    is SocketTimeoutException -> "응답이 지연되고 있어요. 잠시 후 다시 시도해 주세요"
    is HttpException -> when (code()) {
        401 -> "다시 로그인해 주세요"
        404 -> "정보를 찾을 수 없어요"
        in 500..599 -> "서버에 문제가 있어요"
        else -> "요청을 처리하지 못했어요"
    }
    else -> "알 수 없는 오류가 발생했어요"
}
AND 12

로컬 저장 — Room · DataStore

앱은 오프라인에서도 뭔가 보여 줘야 합니다. 구조화된 데이터는 Room(SQLite), 설정 같은 작은 값은 DataStore입니다.

안드로이드에서 무엇을 어디에 저장하나 — 네 갈래로 갈립니다 무엇을 저장하나 DataStore 키-값 몇 개 설정 · 마지막 탭 SharedPrefs 대체 Proto DataStore 구조가 있는 설정 타입 안전 · 스키마 직렬화 지정 필요 Room 표 · 검색 · 관계 SQLite 위의 ORM Flow 로 변화 관찰 파일 · 캐시 이미지 · 큰 덩어리 filesDir 는 유지 cacheDir 는 지워짐 토큰 · 비밀번호는 이 넷 중 어디에도 평문으로 두지 마세요 — EncryptedFile 이나 Android Keystore 를 씁니다.
고민이 길어지면 기준은 하나입니다 — 검색하거나 관계가 있으면 Room, 아니면 DataStore.

Room — Flow 를 돌려주는 DAO

@Entity(tableName = "orders")
data class OrderEntity(              // Room 은 data class 가 자연스럽다
    @PrimaryKey val id: Long,
    val orderNo: String,
    val status: String,
    val totalAmount: Long,
    @ColumnInfo(name = "created_at") val createdAt: Long,
    val memo: String? = null,        // v2 에서 추가 — 아래 MIGRATION_1_2 와 짝 (없으면 스키마 검증 실패)
)

@Dao
interface OrderDao {
    @Query("SELECT * FROM orders ORDER BY created_at DESC")
    fun observeAll(): Flow<List<OrderEntity>>      // ★ 데이터가 바뀌면 자동 방출

    @Query("SELECT * FROM orders WHERE id = :id")
    suspend fun find(id: Long): OrderEntity?

    @Upsert
    suspend fun upsertAll(items: List<OrderEntity>)

    @Query("DELETE FROM orders")
    suspend fun clear()

    @Transaction
    suspend fun replaceAll(items: List<OrderEntity>) { clear(); upsertAll(items) }
}

@Database(entities = [OrderEntity::class], version = 2, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
    abstract fun orderDao(): OrderDao
}

// 스키마 변경 — 마이그레이션을 반드시 작성 (안 하면 앱이 죽는다)
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE orders ADD COLUMN memo TEXT")
    }
}

DataStore — SharedPreferences 의 후계

val Context.settings: DataStore<Preferences> by preferencesDataStore("settings")

class SettingsRepository @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    private val darkModeKey = booleanPreferencesKey("dark_mode")

    val darkMode: Flow<Boolean> = context.settings.data
        .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
        .map { it[darkModeKey] ?: false }

    suspend fun setDarkMode(on: Boolean) {
        context.settings.edit { it[darkModeKey] = on }
    }
}
SharedPreferences 를 새로 쓰지 마세요. apply()가 메인 스레드를 막는 경우가 있고 에러 처리가 없습니다. DataStore 는 코루틴 기반이라 안전합니다. 토큰·비밀번호는 EncryptedSharedPreferences나 Keystore 를 쓰세요.
오프라인 우선(offline-first) 구조 — 화면은 Room 의 Flow 를 보고, 앱이 시작되거나 당겨서 새로고침할 때 네트워크가 Room 을 갱신합니다. 이렇게 하면 비행기 모드에서도 앱이 동작하고, 화면 코드에서 로딩 처리가 단순해집니다.
AND 13

권한 · 시스템 연동

카메라·위치·알림은 사용자가 허락해야 쓸 수 있고, 거부될 수도 있습니다. 거부됐을 때의 흐름까지 만들어야 완성입니다.

// 1) 매니페스트 선언
// <uses-permission android:name="android.permission.CAMERA" />
// <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />  (API 33+)

// 2) 런타임 요청 — Compose
@Composable
fun CameraButton(onGranted: () -> Unit) {
    val context = LocalContext.current
    var showRationale by remember { mutableStateOf(false) }

    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (granted) onGranted() else showRationale = true
    }

    Button(onClick = {
        when {
            ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
                == PackageManager.PERMISSION_GRANTED -> onGranted()
            else -> launcher.launch(Manifest.permission.CAMERA)
        }
    }) { Text("사진 찍기") }

    if (showRationale) {
        AlertDialog(
            onDismissRequest = { showRationale = false },
            title = { Text("카메라 권한이 필요해요") },
            text = { Text("영수증을 촬영하려면 카메라 접근을 허용해 주세요.") },
            confirmButton = {
                TextButton(onClick = { context.openAppSettings() }) { Text("설정 열기") }
            },
        )
    }
}

// 3) 다른 앱과 연동 — 인텐트
fun Context.share(text: String) = startActivity(
    Intent.createChooser(
        Intent(Intent.ACTION_SEND).apply {
            type = "text/plain"
            putExtra(Intent.EXTRA_TEXT, text)
        }, "공유하기"
    )
)

// 4) 파일 선택 — Storage Access Framework (권한 불필요)
val picker = rememberLauncherForActivityResult(
    ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { viewModel.upload(it) } }
Button(onClick = { picker.launch(arrayOf("image/*")) }) { Text("사진 선택") }

권한 설계 원칙

  • 필요한 순간에 요청 — 앱을 켜자마자 전부 요청하면 거부율이 급등합니다
  • 왜 필요한지 먼저 설명(rationale) — 시스템 대화상자 전에
  • 거부돼도 앱이 동작해야 합니다 — 기능만 비활성
  • 두 번 거부하면 다시 못 묻습니다 — 설정 화면으로 안내
  • 알림 권한(API 33+)·정확한 위치·백그라운드 위치는 심사 대상

최신 안드로이드에서 강해진 제약

영역변화
저장소Scoped Storage — 남의 파일에 함부로 접근 불가
백그라운드 실행제한 강화 — WorkManager를 쓸 것
알림API 33+ 런타임 권한 필요
패키지 조회<queries> 선언 필요
PendingIntentFLAG_IMMUTABLE 필수
정확한 알람별도 권한 · 사용 사유 필요
AND 14

이미지 · 애니메이션

앱의 체감 품질을 좌우하는 두 가지입니다. Compose 에서는 애니메이션이 상태 변화의 부산물이라 코드가 짧습니다.

이미지 — Coil

// implementation("io.coil-kt.coil3:coil-compose:3.0.4")
AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(order.thumbnailUrl)
        .crossfade(true)
        .memoryCacheKey(order.id.toString())
        .build(),
    contentDescription = order.title,
    contentScale = ContentScale.Crop,
    placeholder = painterResource(R.drawable.placeholder),
    error = painterResource(R.drawable.image_error),
    modifier = Modifier.size(72.dp).clip(RoundedCornerShape(8.dp)),
)

// 목록에서 주의할 점
// · 크기를 Modifier 로 고정하면 필요한 만큼만 디코딩한다
// · 원본 그대로 받지 말고 서버에서 리사이즈된 URL 을 받는 것이 최선
// · Coil3 는 KMP 를 지원해 iOS 에서도 같은 코드가 돈다

애니메이션 — 상태만 바꾸면 된다

// 값 애니메이션
val alpha by animateFloatAsState(if (visible) 1f else 0f, label = "alpha")
val color by animateColorAsState(if (error) Red else Gray, label = "color")

// 보이기/숨기기
AnimatedVisibility(
    visible = expanded,
    enter = expandVertically() + fadeIn(),
    exit = shrinkVertically() + fadeOut(),
) { DetailPanel() }

// 내용이 바뀔 때 전환
AnimatedContent(targetState = state, label = "screen") { s ->
    when (s) {
        is Loading -> LoadingView()
        is Success -> ContentView(s.data)
    }
}

// 레이아웃 크기 변화를 부드럽게
Box(Modifier.animateContentSize()) { /* ... */ }

// 목록 항목 추가·삭제 애니메이션
items(orders, key = { it.id }) { order ->
    OrderCard(order, Modifier.animateItem())
}
애니메이션 접근성 — 시스템 설정에서 애니메이션을 줄인 사용자가 있습니다. 중요한 전환은 유지하되, 장식적인 움직임은 LocalAccessibilityManager나 시스템 설정을 확인해 줄이는 것이 좋습니다.
AND 15

디버깅 · 기초 체크리스트

기초 탭의 마무리입니다. 문제를 보는 도구와, 이 탭의 내용을 실제 앱에 적용할 때의 점검 목록입니다.

도구보는 것
Logcat로그·크래시 스택. 태그와 프로세스로 필터링
Layout Inspector실행 중 화면의 컴포저블 트리와 재구성 횟수
Compose 미리보기기기 없이 상태별 화면 확인
Profiler (CPU·메모리·에너지)버벅임·누수·배터리
Network InspectorAPI 요청·응답 본문
Database Inspector실행 중 Room DB 를 SQL 로 조회
StrictMode메인 스레드에서의 디스크·네트워크 접근을 잡아 준다
LeakCanary메모리 누수 자동 탐지 (디버그 빌드에만 넣는다)

출시 전 최소 점검

  • 화면 회전에서 상태가 유지되는가
  • 개발자 옵션 “활동 유지 안 함”으로 프로세스 종료 복구 확인
  • 다크 모드에서 글자가 보이는가
  • 글자 크기 최대로 키웠을 때 레이아웃이 깨지지 않는가
  • 비행기 모드에서 에러 화면과 재시도가 동작하는가
  • 느린 네트워크(에뮬레이터 설정)에서 로딩 표시가 뜨는가
  • 릴리스 빌드(R8)에서도 정상 동작하는가 — 난독화 문제는 여기서만 드러납니다

자주 만나는 크래시

예외원인
NetworkOnMainThreadException메인 스레드에서 네트워크 → withContext(IO)
TransactionTooLargeExceptionBundle·SavedState 에 큰 데이터
IllegalStateException: Fragment not attached화면이 사라진 뒤 UI 접근
Room: schema changed마이그레이션 누락
ClassNotFound (릴리스만)R8 난독화 — proguard-rules.pro에 규칙 추가
ANR메인 스레드 5초 이상 점유
다음 탭 예고 — Android 실전에서는 Hilt 의존성 주입, 멀티모듈 아키텍처, Paging, WorkManager, 성능 최적화(재구성·시작 시간), 테스트 전략, R8·서명·Play 배포까지 앱을 실제로 출시하고 운영하는 이야기를 다룹니다.
HILT · 멀티모듈 · PAGING · WORKMANAGER · 배포

앱은 만드는 것보다
운영하는 게
훨씬 길다

출시 이후에 필요한 것들입니다 — 의존성 주입과 모듈 구조로 코드가 무너지지 않게 하고, 성능·크래시·배포를 관리합니다. 사용자는 앱을 지우면 그만이므로, 느리거나 죽는 앱은 기능이 아무리 많아도 소용없습니다.

15SECTIONS
5sANR 기준 (메인 스레드)
2필수 성능 지표
100%실제 출시 기준
ANDX 01

아키텍처 · 멀티모듈

화면이 열 개를 넘어가면 구조가 필요해집니다. 구글이 권장하는 3계층(UI · Domain · Data)과 기능별 모듈이 지금의 표준입니다.

의존 방향은 한쪽으로만 — 아래는 위를 모른다 UI 계층 Composable 화면 ViewModel UI State · 이벤트 Domain 계층 (선택) UseCase · 도메인 모델 여러 리포지터리 조합 · 규칙 Data 계층 Repository 원격 (API) 로컬 (Room) 단일 소스
도메인 계층은 선택입니다 — 여러 리포지터리를 조합하거나 규칙이 복잡할 때만 만드세요.

멀티모듈 구조

:app                        앱 조립 · 네비게이션 그래프
:core:designsystem          테마 · 공통 컴포넌트
:core:common                유틸 · Result · Dispatcher
:core:network               Retrofit · DTO
:core:database              Room · Entity
:core:data                  Repository 구현 (network + database)
:core:model                 도메인 모델 (순수 코틀린)

:feature:orders             주문 화면 + ViewModel
:feature:profile
:feature:search

의존 규칙
  feature → core:data → core:network/database
  feature 끼리는 서로 의존하지 않는다 (앱 모듈이 연결)
모듈을 나누는 이유는 빌드 속도와 경계입니다. 기능 모듈이 서로를 못 부르게 하면 의존성이 스파게티가 되는 것을 구조가 막습니다. (처음부터 다 나눌 필요는 없습니다 — 화면 10개쯤부터 고려)

모듈 사이 계약 — 인터페이스

// :core:data 의 공개 인터페이스
interface OrderRepository {
    fun observe(): Flow<List<Order>>
    suspend fun refresh(): Result<Unit>
}

// 구현은 internal — 밖에서 못 본다
internal class OfflineFirstOrderRepository @Inject constructor(
    private val api: OrderApi,
    private val dao: OrderDao,
) : OrderRepository { /* ... */ }

// Hilt 모듈에서 연결
@Module
@InstallIn(SingletonComponent::class)
internal abstract class DataModule {
    @Binds
    abstract fun bindOrderRepository(impl: OfflineFirstOrderRepository): OrderRepository
}

// 컨벤션 플러그인으로 모듈 설정 중복 제거 (build-logic 디렉터리)
// plugins { id("myapp.android.feature") }
ANDX 02

의존성 주입 — Hilt

안드로이드는 객체를 직접 만들 수 없는 자리(Activity·ViewModel·Worker)가 많습니다. Hilt 는 그 자리에 컴파일 시점에 검증된 주입을 넣어 줍니다.

Hilt 컴포넌트 — 바깥 원일수록 오래 삽니다 Singleton ActivityRetained Activity Fragment @Singleton — 앱이 살아 있는 동안 하나 Retrofit · Room · Repository 가 여기 @ActivityRetainedScoped — 회전에도 생존 ViewModel 이 붙는 자리 @ActivityScoped — 액티비티와 생사를 같이 회전하면 새로 만들어집니다 @FragmentScoped — 프래그먼트와 함께 안쪽은 바깥 객체를 주입받을 수 있고, 반대는 안 됩니다 스코프를 안 붙이면 주입할 때마다 새 인스턴스 캐시 · 상태를 담은 객체는 반드시 스코프 지정 "언제까지 살아야 하나"가 곧 원의 반지름입니다
"이 객체가 언제까지 살아야 하나"만 정하면 어느 컴포넌트에 넣을지가 자동으로 정해집니다.
// 1) 애플리케이션 진입점
@HiltAndroidApp
class MyApplication : Application()

// 2) 제공 방법 정의
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides @Singleton
    fun provideOkHttp(tokenStore: TokenStore): OkHttpClient =
        OkHttpClient.Builder().addInterceptor(AuthInterceptor(tokenStore)).build()

    @Provides @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit =
        Retrofit.Builder().baseUrl(BuildConfig.API_URL).client(client)
            .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
            .build()

    @Provides @Singleton
    fun provideOrderApi(retrofit: Retrofit): OrderApi = retrofit.create()
}

// 3) 디스패처 주입 — 테스트를 위해 (한정자로 구분)
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher

@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
    @Provides @IoDispatcher
    fun provideIo(): CoroutineDispatcher = Dispatchers.IO
}

// 4) 받는 쪽
@HiltViewModel
class OrderViewModel @Inject constructor(
    private val repo: OrderRepository,
    @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel()

@AndroidEntryPoint
class MainActivity : ComponentActivity()

// 5) Compose 에서
@Composable
fun OrderScreen(vm: OrderViewModel = hiltViewModel()) { /* ... */ }

스코프 — 객체가 사는 기간

컴포넌트수명
SingletonComponent앱 전체 (@Singleton)
ActivityRetainedComponent회전을 견딤 (@ActivityRetainedScoped)
ViewModelComponentViewModel 하나 (@ViewModelScoped)
ActivityComponent액티비티 하나
@Singleton 을 남발하지 마세요. 상태를 가진 싱글턴은 화면을 나갔다 와도 남아 있어 예상치 못한 동작을 만듭니다. 스코프는 필요한 만큼만 넓게.

Hilt 없이 — 수동 DI 도 답이다

// 소규모 앱이면 컨테이너 하나로 충분하다 (빌드가 빨라진다)
class AppContainer(context: Context) {
    private val db by lazy { Room.databaseBuilder(context, AppDatabase::class.java, "app").build() }
    private val api by lazy { createRetrofit().create<OrderApi>() }
    val orderRepository: OrderRepository by lazy {
        OfflineFirstOrderRepository(api, db.orderDao())
    }
}

class MyApplication : Application() {
    lateinit var container: AppContainer
    override fun onCreate() { super.onCreate(); container = AppContainer(this) }
}

// ViewModel 은 팩토리로
class OrderViewModel(private val repo: OrderRepository) : ViewModel() {
    companion object {
        val Factory = viewModelFactory {
            initializer {
                val app = this[APPLICATION_KEY] as MyApplication
                OrderViewModel(app.container.orderRepository)
            }
        }
    }
}
Hilt vs Koin — Hilt 는 컴파일 시점 검증(누락되면 빌드 실패)이 장점이고 KSP 를 씁니다. Koin 은 런타임 DSL이라 빌드가 빠르고 KMP 를 지원합니다. 멀티플랫폼 계획이 있다면 Koin 이 자연스럽습니다.
ANDX 03

Compose 심화 — 제스처 · 그리기

기본 컴포넌트로 안 되는 것을 직접 만드는 단계입니다. 커스텀 레이아웃 · Canvas · 제스처 셋이면 대부분의 요구를 소화할 수 있습니다.

Canvas — 직접 그리기

@Composable
fun DonutChart(values: List<Float>, colors: List<Color>) {
    val total = values.sum().takeIf { it > 0f } ?: return
    Canvas(Modifier.size(160.dp)) {
        var start = -90f
        values.forEachIndexed { i, v ->
            val sweep = v / total * 360f
            drawArc(
                color = colors[i % colors.size],
                startAngle = start,
                sweepAngle = sweep - 2f,
                useCenter = false,
                style = Stroke(width = 28f, cap = StrokeCap.Round),
            )
            start += sweep
        }
    }
}

// 애니메이션과 결합
val progress by animateFloatAsState(target, animationSpec = tween(600), label = "p")
Canvas(Modifier.fillMaxWidth().height(8.dp)) {
    drawRoundRect(Color.LightGray, cornerRadius = CornerRadius(4f))
    drawRoundRect(Color.Blue, size = size.copy(width = size.width * progress),
                  cornerRadius = CornerRadius(4f))
}

제스처

// 탭 · 롱프레스 · 더블탭
Modifier.pointerInput(Unit) {
    detectTapGestures(
        onTap = { onClick() },
        onLongPress = { onLongClick() },
        onDoubleTap = { onZoom() },
    )
}

// 드래그로 순서 바꾸기 · 스와이프로 삭제
var offsetX by remember { mutableFloatStateOf(0f) }
Modifier
    .offset { IntOffset(offsetX.roundToInt(), 0) }
    .pointerInput(Unit) {
        detectHorizontalDragGestures(
            onDragEnd = { if (abs(offsetX) > 200f) onDelete() else offsetX = 0f },
        ) { _, delta -> offsetX += delta }
    }

// 확대·회전·이동
var scale by remember { mutableFloatStateOf(1f) }
Modifier
    .graphicsLayer { scaleX = scale; scaleY = scale }
    .pointerInput(Unit) {
        detectTransformGestures { _, _, zoom, _ -> scale = (scale * zoom).coerceIn(1f, 5f) }
    }

// 표준 스와이프 삭제는 SwipeToDismissBox 를 쓰면 더 간단하다

커스텀 레이아웃

// 태그를 줄바꿈하며 배치하는 FlowRow 를 직접 만들어 보기
@Composable
fun SimpleFlowRow(
    modifier: Modifier = Modifier,
    spacing: Dp = 8.dp,
    content: @Composable () -> Unit,
) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        val gap = spacing.roundToPx()
        val placeables = measurables.map { it.measure(constraints.copy(minWidth = 0)) }

        var x = 0; var y = 0; var rowHeight = 0
        val positions = placeables.map { p ->
            if (x + p.width > constraints.maxWidth) { x = 0; y += rowHeight + gap; rowHeight = 0 }
            val pos = IntOffset(x, y)
            x += p.width + gap
            rowHeight = maxOf(rowHeight, p.height)
            pos
        }

        layout(constraints.maxWidth, y + rowHeight) {
            placeables.forEachIndexed { i, p -> p.place(positions[i]) }
        }
    }
}
// 실무에서는 androidx.compose.foundation.layout.FlowRow 를 쓰면 됩니다 —
// 원리를 알면 특수한 배치가 필요할 때 직접 만들 수 있습니다.
ANDX 04

Paging — 무한 스크롤

목록이 수천 건이 되면 한 번에 불러올 수 없습니다. Paging 3 는 스크롤에 맞춰 페이지를 요청하고, 캐시하고, 재시도까지 처리합니다.

Paging 3 은 「요청 · 캐시 · 재시도」를 대신해 준다 — 우리가 쓰는 건 양 끝 두 개뿐 위쪽 → 는 다음 페이지 요청 · 아래쪽 ← 는 받아 온 데이터 LazyColumn 화면에 보이는 부분 Pager PagingData 흐름 PagingSource load(params) 서버 API page · size 스크롤이 내려갈 때 — prefetchDistance = 5 페이지 1 (1~20) 이미 화면 위로 지나감 페이지 2 (21~40) 지금 보이는 구간 페이지 3 5칸 남았을 때 미리 요청 cachedIn(viewModelScope) 이 없으면 화면을 돌릴 때마다 1페이지부터 다시 받는다 loadState 로 「첫 로딩 · 더 불러오는 중 · 실패」 세 가지를 각각 다른 UI 로 그린다 key = { it.id } 를 빠뜨리면 스크롤할 때 항목이 뒤섞이고 재구성이 폭증한다
우리가 직접 쓰는 것은 PagingSource 와 LazyColumn 둘뿐입니다 — 가운데는 라이브러리 몫입니다.
// 1) PagingSource — 어디서 어떻게 가져오나
class OrderPagingSource(
    private val api: OrderApi,
    private val query: String,
) : PagingSource<Int, OrderDto>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, OrderDto> =
        try {
            val page = params.key ?: 1
            val response = api.list(page = page, size = params.loadSize, q = query)
            LoadResult.Page(
                data = response.items,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.items.isEmpty()) null else page + 1,
            )
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            LoadResult.Error(e)
        }

    override fun getRefreshKey(state: PagingState<Int, OrderDto>): Int? =
        state.anchorPosition?.let { state.closestPageToPosition(it)?.prevKey?.plus(1) }
}

// 2) ViewModel
@HiltViewModel
class OrderListViewModel @Inject constructor(private val api: OrderApi) : ViewModel() {
    private val query = MutableStateFlow("")

    val orders: Flow<PagingData<OrderUi>> = query
        .debounce(300)
        .flatMapLatest { q ->
            Pager(PagingConfig(pageSize = 20, initialLoadSize = 20, prefetchDistance = 5)) {   // 페이지 번호 키면 첫 로드도 같은 크기로 (기본 ×3 → 2페이지가 겹친다)
                OrderPagingSource(api, q)
            }.flow.map { paging -> paging.map(OrderDto::toUi) }
        }
        .cachedIn(viewModelScope)        // ★ 회전 시 재요청 방지
}

// 3) 화면
@Composable
fun OrderList(vm: OrderListViewModel = hiltViewModel()) {
    val items = vm.orders.collectAsLazyPagingItems()

    LazyColumn {
        items(items.itemCount, key = items.itemKey { it.id }) { index ->
            items[index]?.let { OrderCard(it) }
        }

        when (val append = items.loadState.append) {
            is LoadState.Loading -> item { LoadingRow() }
            is LoadState.Error -> item { RetryRow(append.error) { items.retry() } }
            else -> Unit
        }
    }

    when (items.loadState.refresh) {
        is LoadState.Loading -> FullScreenLoading()
        is LoadState.Error -> FullScreenError { items.refresh() }
        else -> Unit
    }
}
오프라인까지 필요하면 RemoteMediator — 네트워크에서 받은 페이지를 Room 에 저장하고 화면은 Room 을 봅니다. 구현이 꽤 복잡하므로, 정말 오프라인 목록이 필요한지 먼저 판단하세요. 단순 무한 스크롤이면 위 형태로 충분합니다.
ANDX 05

백그라운드 작업 — WorkManager

앱이 꺼져 있어도 언젠가는 반드시 실행되어야 하는 작업이 있습니다 — 업로드, 동기화, 정리. 최신 안드로이드는 백그라운드 실행을 강하게 제한하므로 WorkManager 가 사실상 유일한 정답입니다.

@HiltWorker
class SyncWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val repo: OrderRepository,
) : CoroutineWorker(context, params) {      // ★ suspend 로 동작한다

    override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
        try {
            repo.refresh().getOrThrow()
            Result.success()
        } catch (e: IOException) {
            if (runAttemptCount < 3) Result.retry() else Result.failure()
        } catch (e: Exception) {
            Result.failure(workDataOf("error" to e.message))
        }
    }

    // 오래 걸리는 작업은 doWork 안에서 setForeground(getForegroundInfo()) 로 전면 서비스 승격
    // (expedited 작업은 API 31 미만에서 시스템이 이 값을 직접 쓴다)
    override suspend fun getForegroundInfo(): ForegroundInfo =
        ForegroundInfo(NOTI_ID, buildNotification("동기화 중"))
}

// 예약
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.UNMETERED)   // 와이파이에서만
            .setRequiresBatteryNotLow(true)
            .build()
    )
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "sync", ExistingPeriodicWorkPolicy.KEEP, request,
)

// 상태 관찰
WorkManager.getInstance(context)
    .getWorkInfosForUniqueWorkFlow("sync")
    .map { it.firstOrNull()?.state }
    .collect { state -> /* 화면에 표시 */ }

무엇을 언제 쓰나

상황도구
화면이 살아 있는 동안만viewModelScope 코루틴
앱이 꺼져도 반드시 실행WorkManager
사용자가 보는 진행 작업전면 서비스(Foreground Service) — 음악·내비·운동
정확한 시각 알림AlarmManager (정확한 알람 권한 필요)
서버가 먼저 깨우기FCM 푸시
제조사 배터리 최적화 때문에 예약 작업이 실행되지 않는 기기가 있습니다. 정확한 시각을 보장해야 하는 기능은 서버 푸시로 설계하는 편이 안전합니다.

작업 연결과 취소

// 순서대로 · 병렬로 조합
WorkManager.getInstance(context)
    .beginWith(listOf(compressRequest, thumbnailRequest))   // 병렬
    .then(uploadRequest)                                     // 그다음
    .then(cleanupRequest)
    .enqueue()

// 결과 전달
val data = workDataOf("uploadedUrl" to url)
Result.success(data)

// 취소
WorkManager.getInstance(context).cancelUniqueWork("sync")

// ⚠️ CoroutineWorker 는 취소되면 코루틴도 취소된다 —
//    자원 정리는 finally + NonCancellable 로
백그라운드 작업 고르기 — 마름모가 질문, 첫 질문이 가장 중요합니다 앱이 꺼져도 끝나야 하나? 아니오 viewModelScope 코루틴 화면과 함께 취소됩니다 예 사용자가 지금 보고 있어야 하나? 예 포그라운드 서비스 알림이 반드시 떠 있음 아니오 정확한 시각이 필요한가? 아니오 WorkManager 앱이 꺼져도 언젠가 실행 예 AlarmManager · FCM 푸시 정각이 필요하면 이쪽 "언젠가"의 의미 배터리 정책으로 몇 시간 미뤄짐 주기 작업 최소 15분 · 정각 없음 재시도 백오프 · 작업은 멱등하게
배터리 정책 때문에 "바로 지금"은 보장되지 않습니다 — 설계에 반영하세요.
와이파이 조건을 건 업로드는 언제 도나 — 한 대의 하루 예시 0 1시간 2시간 3시간 4시간 화면 · 절전 Doze 정비 창 정비 창 와이파이 ② 0:50 연결 — 이미 Doze 중 업로드 작업 ③ 1:30 정비 창에서 실행 ① 등록 · 조건 대기 +40분 조건이 맞아도 다음 정비 창까지 기다리고, 창 간격은 점점 벌어집니다 시각이 중요한 일은 서버 푸시(FCM)로 깨우는 쪽이 안전합니다
WorkManager 는 "지금"이 아니라 다음 정비 창에 돕니다 — 시간은 대표값이고, 제조사 절전 정책에 따라 더 늦어지기도 합니다.
ANDX 06

알림 · 푸시(FCM)

알림은 사용자를 다시 데려오는 가장 강력한 수단이자, 앱을 지우게 만드는 가장 빠른 길입니다. 기술보다 정책 설계가 중요한 영역입니다.

알림 채널과 표시

// 채널 — API 26+ 필수. 사용자가 채널별로 끌 수 있다
fun createChannels(context: Context) {
    val manager = context.getSystemService<NotificationManager>()!!
    manager.createNotificationChannel(
        NotificationChannel("order", "주문 알림", NotificationManager.IMPORTANCE_HIGH)
            .apply { description = "주문 상태가 바뀌면 알려 드려요" }
    )
    manager.createNotificationChannel(
        NotificationChannel("marketing", "혜택·이벤트", NotificationManager.IMPORTANCE_LOW)
    )
}

fun showOrderNotification(context: Context, orderId: Long, title: String) {
    val intent = Intent(Intent.ACTION_VIEW,
        "https://app.example.com/orders/$orderId".toUri(),
        context, MainActivity::class.java)

    val pending = TaskStackBuilder.create(context).run {
        addNextIntentWithParentStack(intent)
        getPendingIntent(orderId.toInt(),
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)  // ★ 필수
    }

    val noti = NotificationCompat.Builder(context, "order")
        .setSmallIcon(R.drawable.ic_noti)
        .setContentTitle(title)
        .setContentText("탭하면 주문 상세로 이동합니다")
        .setContentIntent(pending)
        .setAutoCancel(true)
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .build()

    NotificationManagerCompat.from(context).notify(orderId.toInt(), noti)
}

FCM 수신

@AndroidEntryPoint
class AppFirebaseMessagingService : FirebaseMessagingService() {

    @Inject lateinit var tokenRepo: PushTokenRepository

    override fun onNewToken(token: String) {
        // 서버에 토큰 등록 (앱 재설치·데이터 삭제 시 바뀐다)
        CoroutineScope(Dispatchers.IO).launch { tokenRepo.register(token) }
    }

    override fun onMessageReceived(message: RemoteMessage) {
        // data 메시지여야 앱이 직접 처리한다 (notification 메시지는 시스템이 표시)
        val orderId = message.data["orderId"]?.toLongOrNull() ?: return
        showOrderNotification(this, orderId, message.data["title"] ?: "알림")
    }
}
알림 정책 원칙 — ① 채널을 목적별로 분리해 사용자가 마케팅만 끌 수 있게, ② 야간 발송 금지, ③ 묶어서 보내기(같은 종류를 한 번에), ④ 탭하면 정확한 화면으로. 이걸 지키지 않으면 알림 권한이 통째로 꺼집니다.
ANDX 07

인증 · 보안

모바일 앱은 사용자 기기 위에서 돌기 때문에 서버와 신뢰 모델이 다릅니다. 토큰 저장, 갱신, 그리고 “앱은 신뢰할 수 없다”는 전제가 핵심입니다.

// 1) 토큰 저장 — 암호화된 저장소
class TokenStore @Inject constructor(@ApplicationContext context: Context) {
    private val prefs = EncryptedSharedPreferences.create(
        context, "auth",
        MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
    )
    var accessToken: String?
        get() = prefs.getString("access", null)
        set(v) = prefs.edit { putString("access", v) }
    var refreshToken: String?
        get() = prefs.getString("refresh", null)
        set(v) = prefs.edit { putString("refresh", v) }

    fun clear() = prefs.edit { clear() }       // 갱신 실패 시 로그아웃 처리용
}

// 2) 자동 갱신 — OkHttp Authenticator (401 을 받으면 한 번 갱신하고 재시도)
class TokenAuthenticator @Inject constructor(
    private val store: TokenStore,
    private val authApi: Provider<AuthApi>,
) : Authenticator {
    private val mutex = Mutex()

    override fun authenticate(route: Route?, response: Response): Request? {
        if (response.request.header("Authorization") == null) return null
        if (responseCount(response) >= 2) return null          // 무한 루프 방지

        return runBlocking {
            mutex.withLock {                                     // 동시 401 을 한 번만 처리
                val current = store.accessToken
                val requestToken = response.request.header("Authorization")?.removePrefix("Bearer ")
                if (current != null && current != requestToken) {
                    // 다른 요청이 이미 갱신했다
                    return@withLock response.request.newBuilder()
                        .header("Authorization", "Bearer $current").build()
                }
                val refreshed = runCatching {
                    authApi.get().refresh(RefreshRequest(store.refreshToken ?: return@runCatching null))
                }.getOrNull() ?: run { store.clear(); return@withLock null }

                store.accessToken = refreshed.accessToken
                store.refreshToken = refreshed.refreshToken
                response.request.newBuilder()
                    .header("Authorization", "Bearer ${refreshed.accessToken}").build()
            }
        }
    }
}

// 3) 생체 인증 — 결제·민감 화면 진입 전
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
    override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = onOk()
})
prompt.authenticate(
    BiometricPrompt.PromptInfo.Builder()
        .setTitle("본인 확인").setSubtitle("결제를 진행하려면 인증이 필요해요")
        .setNegativeButtonText("취소").build()
)

모바일 보안 원칙

  • 앱에 비밀을 두지 않는다 — API 키·시크릿은 디컴파일하면 나옵니다. 정말 필요하면 서버를 경유하세요
  • 검증은 서버에서 — 가격·권한·재고를 앱이 판단하면 조작됩니다
  • 토큰은 짧게, 갱신은 안전하게 — 리프레시 토큰 재사용 탐지
  • 로그에 개인정보·토큰 금지 — 릴리스에서 로깅 인터셉터 끄기
  • 루팅·에뮬레이터 탐지는 방어가 아니라 지연일 뿐임을 인지
  • Play Integrity API로 기기·앱 무결성을 서버에서 확인

네트워크 보안

<!-- res/xml/network_security_config.xml -->
<network-security-config>
    <base-config cleartextTrafficPermitted="false"/>   <!-- HTTP 차단 -->
    <domain-config>
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2027-01-01">               <!-- 인증서 고정 -->
            <pin digest="SHA-256">base64...</pin>
            <pin digest="SHA-256">백업핀...</pin>
        </pin-set>
    </domain-config>
</network-security-config>
인증서 고정(pinning)은 양날의 검입니다. 서버 인증서를 교체했는데 앱이 업데이트되지 않으면 모든 사용자가 접속 불가가 됩니다. 반드시 백업 핀과 만료일을 함께 두세요.
ANDX 08

성능 ① — 재구성 최적화

Compose 앱이 버벅이는 원인의 대부분은 필요 없는 재구성입니다. 원인을 추측하지 말고 측정한 뒤 고칩니다.

같은 값이 다시 들어와도 「건너뛸 수 있는가」 — 파라미터 타입이 그것을 정한다 ❌ 불안정(unstable) 파라미터 orders: List<OrderUi> List 는 인터페이스 — 「안 바뀌었다」를 컴파일러가 증명 못 한다 restartable skippable 아님 — 값이 같아도 다시 그린다 ✅ 안정(stable) 파라미터 orders: ImmutableList<OrderUi> @Immutable · ImmutableList · 원시 타입 · String 은 안정 restartable skippable 값이 같으면 통째로 건너뛴다 확인하는 법 — 추측하지 말고 리포트를 본다 composeCompiler { reportsDestination = … } → build/compose_reports/*-composables.txt Layout Inspector 의 Recomposition count 가 스크롤 때 폭증하는 컴포저블이 범인이다 가변 필드가 있는데 @Immutable 을 붙이면 화면이 갱신되지 않는다 — 거짓말이 된다
목표는 리포트에 skippable 이 찍히는 것입니다 — 그 한 단어가 곧 성능입니다.

측정 — Layout Inspector 와 컴파일러 리포트

// 1) Layout Inspector 에서 'Recomposition count' 열기
//    스크롤할 때 숫자가 폭증하는 컴포저블이 범인

// 2) 컴파일러 안정성 리포트 생성
// build.gradle.kts
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
// ./gradlew assembleRelease
// build/compose_reports/*-composables.txt 에
//   restartable skippable fun OrderCard(...)      ← 좋다
//   restartable fun OrderCard(...)                 ← skippable 이 아니다 (문제)
“skippable” 이 목표입니다 — 파라미터가 그대로면 그 컴포저블을 건너뜁니다. 불안정한 타입(List·인터페이스·다른 모듈 클래스)이 파라미터에 있으면 skippable 이 깨집니다. (Kotlin 2.0.20+ 에서 기본이 된 strong skipping 모드에서는 불안정 파라미터도 참조 비교(===)로 건너뛸 수 있어 문제가 줄었지만, 매번 새 객체를 넘기면 여전히 재구성됩니다.)

고치는 방법

// ① 불안정 타입 → 안정 타입
@Immutable data class OrderUi(val id: Long, val title: String)
// List → ImmutableList (kotlinx-collections-immutable)
fun OrderList(orders: ImmutableList<OrderUi>)

// ② 상태 읽기를 늦춘다 — 값 대신 람다를 넘긴다
// ❌ 스크롤할 때마다 부모까지 재구성
Header(scrollOffset = listState.firstVisibleItemScrollOffset)
// ✅ 실제로 읽는 곳에서만 재구성
Header(scrollOffset = { listState.firstVisibleItemScrollOffset })

// ③ 람다를 안정적으로
onClick = viewModel::onItemClick        // 매번 새 람다를 만들지 않는다

// ④ 무거운 계산은 캐싱
val formatted = remember(order.total) { currencyFormat.format(order.total) }

// ⑤ 목록에 key 부여 (필수)
items(orders, key = { it.id }) { ... }
과잉 최적화 주의 — 모든 데이터 클래스에 @Immutable을 붙이는 것은 거짓말이 될 수 있습니다(내부에 가변 필드가 있으면 화면이 갱신되지 않습니다). 측정해서 문제가 확인된 곳부터 고치세요.
ANDX 09

성능 ② — 시작 시간 · 메모리

사용자가 가장 먼저 체감하는 지표는 앱이 뜨는 속도입니다. Play Console 의 Android Vitals가 이 숫자로 앱을 평가하고, 검색 순위에도 영향을 줍니다.

콜드 스타트 한 번 — 시간이 어디로 갔나 (중급 기기 예시) 0 500 1000 1500 ms 프로세스 생성 150 ms Application.onCreate 450 ms Activity.onCreate 500 ms 첫 프레임 150 ms 데이터 표시 400 ms TTID 1.25 s TTFD 1.65 s 붉은 두 구간이 TTID 의 76% — SDK 일괄 초기화 · 동기 파일 읽기 · 무거운 레이아웃 · 메인 스레드 DB 해결 · App Startup 으로 지연 초기화 · Baseline Profile 로 JIT 비용 제거 값은 예시입니다 — 자기 앱의 막대 길이는 측정해야만 압니다
시작 시간은 추측하지 말고 Macrobenchmark 로 재세요 — 대개 예상과 다른 곳에서 시간을 씁니다.
지표기준 · 개선 방법
콜드 스타트5초 넘으면 나쁨 — Application.onCreate 에서 무거운 초기화 제거
ANR 비율0.47% 미만 유지 — 메인 스레드 블로킹 제거
크래시 비율1.09% 미만
프레임 드롭(Jank)16ms(60fps)·8ms(120fps) 안에 프레임 완성
앱 크기다운로드 전환율에 직접 영향 — R8 + 리소스 축소

시작 시간 줄이기

// ① Application.onCreate 를 비운다 — 여기가 시작 시간의 대부분
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // ❌ 무거운 SDK 초기화를 여기서 전부 하지 않는다
        // ✅ 정말 필요한 것만, 나머지는 지연 초기화
    }
}

// ② App Startup 라이브러리로 초기화 순서를 관리
class AnalyticsInitializer : Initializer<Analytics> {
    override fun create(context: Context): Analytics = Analytics.init(context)
    override fun dependencies() = emptyList<Class<out Initializer<*>>>()
}

// ③ Baseline Profile — 첫 실행부터 AOT 컴파일된 코드로 (기동 20~30% 개선)
// build.gradle.kts
// baselineProfile { ... }  · :baselineprofile 모듈에서 시나리오 녹화

// ④ 스플래시는 SplashScreen API 로 (흰 화면 대신)
installSplashScreen().setKeepOnScreenCondition { viewModel.isLoading }

메모리 · 누수

// 안드로이드에서 메모리 누수의 전형
// ① Activity·Context 를 오래 사는 객체가 들고 있음
class BadSingleton private constructor(val context: Context)  // ❌ Activity Context
// → applicationContext 를 쓰거나 아예 들고 있지 않는다

// ② 리스너·콜백 해제 안 함
DisposableEffect(Unit) {
    val cb = ...
    manager.register(cb)
    onDispose { manager.unregister(cb) }     // ✅ 반드시 해제
}

// ③ 코루틴 스코프를 직접 만들고 취소 안 함
// → viewModelScope · lifecycleScope 를 쓴다

// LeakCanary — 디버그 빌드에만 넣으면 누수를 자동으로 알려 준다
// debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
Macrobenchmark로 시작 시간·스크롤 성능을 숫자로 측정하고 CI 에 넣으면, 성능이 나빠지는 커밋을 배포 전에 잡을 수 있습니다.
ANDX 10

테스트 전략

안드로이드 테스트는 느린 것과 빠른 것이 확연히 갈립니다. JVM 에서 도는 테스트를 최대한 많이, 기기 테스트는 핵심 흐름만이 원칙입니다.

① 단위 (JVM · 빠름)

class OrderViewModelTest {
    private val repo = mockk<OrderRepository>()

    @Test
    fun `실패하면 에러 상태`() = runTest {
        coEvery { repo.list() } throws IOException()
        val vm = OrderViewModel(repo)
        advanceUntilIdle()
        assertTrue(vm.state.value is OrderUiState.Error)
    }
}
// Dispatchers.setMain(StandardTestDispatcher())
// 를 @Before 에 넣는다

② Compose UI (기기·Robolectric)

@get:Rule val rule = createComposeRule()

@Test
fun `빈 목록이면 안내가 보인다`() {
    rule.setContent {
        AppTheme { OrderList(orders = emptyList(), onClick = {}) }
    }
    rule.onNodeWithText("주문이 없어요").assertIsDisplayed()
}

@Test
fun `항목을 누르면 콜백`() {
    var clicked: Long? = null
    rule.setContent { OrderList(orders, onClick = { clicked = it }) }
    rule.onNodeWithText("ORD-1").performClick()
    assertEquals(1L, clicked)
}

③ 통합·E2E (느림)

@HiltAndroidTest
class OrderFlowTest {
    @get:Rule(order = 0) val hilt = HiltAndroidRule(this)
    @get:Rule(order = 1) val compose = createAndroidComposeRule<MainActivity>()

    @Test fun `주문 조회 흐름`() {
        compose.onNodeWithContentDescription("새로고침").performClick()
        compose.waitUntil { compose.onAllNodesWithTag("order-card")
            .fetchSemanticsNodes().isNotEmpty() }
    }
}
// MockWebServer 로 API 를 고정하면 안정적이다

테스트하기 좋은 구조 만들기

  • ViewModel 에 Context 를 넣지 않기 — JVM 테스트가 가능해집니다
  • 디스패처를 주입 — 테스트에서 가상 시간으로 교체
  • Composable 은 stateless 로 — 상태를 파라미터로 받으면 미리보기·테스트가 쉽습니다
  • 테스트 태그(Modifier.testTag)는 필요할 때만 — 가능하면 사용자가 보는 텍스트로 찾는 편이 접근성도 함께 검증됩니다
  • Fake 를 쓰세요 — 목보다 가짜 구현이 유지보수하기 쉽습니다

Fake 예시

class FakeOrderRepository : OrderRepository {
    private val state = MutableStateFlow<List<Order>>(emptyList())
    var failNext = false

    override fun observe(): Flow<List<Order>> = state

    override suspend fun refresh(): Result<Unit> =
        if (failNext) Result.failure(IOException())
        else { state.value = listOf(order()); Result.success(Unit) }

    fun emit(orders: List<Order>) { state.value = orders }
}
// 목과 달리 '실제로 동작'하므로 여러 테스트에서 재사용된다
ANDX 11

R8 · 앱 크기

릴리스 빌드에서 코드가 축소·최적화·난독화됩니다. 여기서 처음 드러나는 버그가 있고, 앱 크기는 설치 전환율에 직접 영향을 줍니다.

R8 — 릴리즈 빌드에서 코드에 실제로 벌어지는 네 단계 1 축소 (shrink) 어디서도 안 쓰는 클래스 · 메서드 · 리소스를 지웁니다 — 앱 크기는 대부분 여기서 줄어듭니다 2 최적화 (optimize) 인라인 · 죽은 분기 제거 · 클래스 병합 — 코틀린의 짧은 함수들이 여기서 사라집니다 3 난독화 (obfuscate) 클래스 · 메서드 이름을 a · b · c 로 — mapping.txt 를 잃으면 스택트레이스를 영영 못 읽습니다 4 dex 변환 JVM 바이트코드를 안드로이드용 DEX 로 — 여기서 메서드 수 상한(멀티덱스) 문제가 드러납니다 깨지는 자리는 거의 항상 리플렉션입니다 JSON 역직렬화 대상 · Retrofit 제네릭 응답 · 클래스 이름을 문자열로 부르는 코드 — 이런 것들은 keep 규칙으로 지켜 줘야 합니다
"디버그에서는 되는데 릴리즈에서만 죽는다"의 원인 대부분이 여기입니다 — 릴리즈 빌드로도 반드시 테스트하세요.

R8 이 하는 일과 규칙 파일

# proguard-rules.pro

# 리플렉션으로 접근하는 클래스는 지키기
-keep class com.example.model.** { *; }

# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class **$$serializer { *; }

# Retrofit 인터페이스
-keep,allowobfuscation interface com.example.network.*Api

# 크래시 로그를 읽을 수 있게 (mapping.txt 를 반드시 보관)
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
“디버그에서는 되는데 릴리스에서 죽는다”의 대부분이 R8 입니다. 리플렉션·JSON 역직렬화·enum valueOf가 단골이고, 해결은 규칙 추가 또는 리플렉션 제거입니다. 출시 전 반드시 릴리스 빌드로 테스트하세요.

앱 크기 줄이기

  • Android App Bundle(.aab)로 배포 — 기기별로 필요한 리소스만 전달
  • isShrinkResources = true — 안 쓰는 리소스 제거
  • PNG 대신 벡터(VectorDrawable)
  • WebP로 이미지 변환 (Android Studio 우클릭 메뉴)
  • 안 쓰는 의존성 제거 — ./gradlew :app:dependencies로 확인
  • Play Feature Delivery — 큰 기능은 필요할 때 내려받기
# APK 분석 — 무엇이 크기를 차지하는지
Android Studio → Build → Analyze APK
# 또는
bundletool build-apks --bundle=app.aab --output=app.apks
ANDX 12

서명 · CI/CD · Play 배포

앱은 서명 키로 신원이 결정됩니다. 키를 잃어버리면 같은 앱을 업데이트할 수 없습니다. 그래서 Play 앱 서명(구글이 키를 보관)이 기본이 됐습니다.

// build.gradle.kts — 서명 정보는 코드에 넣지 않는다
android {
    signingConfigs {
        create("release") {
            storeFile = file(System.getenv("KEYSTORE_PATH") ?: "release.keystore")
            storePassword = System.getenv("KEYSTORE_PASSWORD")
            keyAlias = System.getenv("KEY_ALIAS")
            keyPassword = System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
            isMinifyEnabled = true
            isShrinkResources = true
        }
        debug {
            applicationIdSuffix = ".debug"      // 디버그 앱을 함께 설치 가능
            versionNameSuffix = "-debug"
        }
    }
    // 환경별 빌드 (개발·스테이징·운영)
    buildFeatures { buildConfig = true }    // AGP 8+ 는 BuildConfig 생성이 기본 꺼짐 — buildConfigField 를 쓰려면 필요
    flavorDimensions += "env"
    productFlavors {
        create("dev") { dimension = "env"; buildConfigField("String", "API_URL", "\"https://dev.api\"") }
        create("prod") { dimension = "env"; buildConfigField("String", "API_URL", "\"https://api\"") }
    }
}

GitHub Actions 예시

name: Android CI
on: [pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew ktlintCheck detekt
      - run: ./gradlew testDebugUnitTest
      - run: ./gradlew assembleDebug
      - uses: actions/upload-artifact@v4
        with: { name: apk, path: app/build/outputs/apk/debug/*.apk }

배포 트랙 활용

트랙용도
내부 테스트팀 내 즉시 배포 (심사 거의 없음)
비공개 테스트지정된 테스터 그룹
공개 테스트누구나 참여 가능한 베타
프로덕션정식 출시 — 단계적 출시(1%→5%→20%→100%)
단계적 출시를 꼭 쓰세요. 크래시율이 오르면 배포를 중단할 수 있습니다. 전면 배포 후 문제를 발견하면 되돌릴 방법이 새 버전 올리기뿐입니다 (사용자가 업데이트해야 반영됩니다).
ANDX 13

크래시 · 모니터링

운영 중인 앱에서 무슨 일이 일어나는지 아는 것이 전부입니다. 사용자는 대부분 신고하지 않고 지웁니다.

Crashlytics — 최소 구성

// 사용자 식별 (개인정보가 아닌 내부 id)
FirebaseCrashlytics.getInstance().setUserId(hashedUserId)

// 크래시 직전 맥락을 남긴다 — 재현에 결정적
FirebaseCrashlytics.getInstance().apply {
    setCustomKey("screen", "OrderDetail")
    setCustomKey("orderId", orderId)
    log("결제 요청 시작")
}

// 잡은 예외도 기록 (크래시는 아니지만 알아야 할 것)
runCatching { risky() }.onFailure {
    FirebaseCrashlytics.getInstance().recordException(it)
}

// R8 매핑 파일 업로드 — 없으면 스택이 난독화된 채로 온다
// firebaseCrashlytics { mappingFileUploadEnabled = true }

// Timber 로 로그를 Crashlytics 에 연결
class CrashlyticsTree : Timber.Tree() {
    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
        if (priority >= Log.WARN) {
            FirebaseCrashlytics.getInstance().log(message)
            t?.let { FirebaseCrashlytics.getInstance().recordException(it) }
        }
    }
}

무엇을 보나

  • 크래시 없는 사용자 비율 — 99.5% 이상을 목표로
  • ANR — Play Console 의 Android Vitals 에서 확인
  • 버전별 비교 — 새 버전 배포 후 급증하면 즉시 롤백 판단
  • 기기·OS 버전별 분포 — 특정 제조사에서만 나는 크래시가 흔합니다
  • 사용자 여정 분석 — 어디서 이탈하는지
개인정보를 로그·분석 도구에 넣지 마세요. 이메일·전화번호·주소는 물론이고, 기기 식별자도 정책상 제한이 있습니다. 내부 id 를 해시해서 쓰는 것이 안전합니다.
ANDX 14

위젯 · 시스템 통합

홈 화면 위젯, 앱 바로가기, 공유 대상 — 앱을 시스템 안에 자리 잡게 하는 기능들입니다. 재방문율에 직접 영향을 줍니다.

Glance — Compose 로 위젯 만들기

class OrderWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        val orders = OrderRepositoryEntryPoint.get(context).latest()

        provideContent {
            GlanceTheme {
                Column(GlanceModifier.fillMaxSize().padding(12.dp)) {
                    Text("최근 주문", style = TextStyle(fontWeight = FontWeight.Bold))
                    orders.take(3).forEach { o ->
                        Text(
                            text = "${o.orderNo} · ${o.status}",
                            modifier = GlanceModifier.clickable(
                                actionStartActivity<MainActivity>(
                                    actionParametersOf(orderIdKey to o.id)
                                )
                            ),
                        )
                    }
                }
            }
        }
    }
}

class OrderWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget = OrderWidget()
}

바로가기 · 공유 대상

// 동적 바로가기 — 길게 눌렀을 때 나오는 메뉴
val shortcut = ShortcutInfoCompat.Builder(context, "recent-order")
    .setShortLabel("최근 주문")
    .setIcon(IconCompat.createWithResource(context, R.drawable.ic_order))
    .setIntent(Intent(Intent.ACTION_VIEW, "app://orders/latest".toUri()))
    .build()
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)

// 공유 대상으로 등록 — 다른 앱에서 '공유' 했을 때 내 앱이 뜨게
// AndroidManifest.xml
// <intent-filter>
//   <action android:name="android.intent.action.SEND"/>
//   <category android:name="android.intent.category.DEFAULT"/>
//   <data android:mimeType="image/*"/>
// </intent-filter>

// 앱 링크 — 웹 URL 로 앱을 열기 (assetlinks.json 필요)
// android:autoVerify="true"
ANDX 15

출시 체크리스트

실전 탭의 마무리입니다. 이 목록은 첫 출시와 매 배포에 나눠 쓰면 됩니다.

첫 출시 전

  • 서명 키를 안전하게 백업(Play 앱 서명 사용 권장)
  • 릴리스 빌드로 전체 흐름 테스트 (R8 문제 확인)
  • Crashlytics·Analytics 연동 + 매핑 파일 업로드 확인
  • 개인정보 처리방침 URL · 데이터 안전 섹션 작성
  • 권한 사용 사유 작성 (민감 권한은 심사 대상)
  • 여러 화면 크기·OS 버전에서 확인 (에뮬레이터로도 충분)
  • 다크 모드·큰 글자·접근성 스캐너
  • 네트워크 없음·느림·서버 500 상황의 화면

매 배포마다

  • versionCode 증가 확인 (같으면 업로드 거부)
  • DB 마이그레이션 작성·테스트 (기존 사용자 데이터 보존)
  • API 하위 호환 — 구버전 앱이 계속 동작하는지
  • 단계적 출시로 시작 + Vitals 모니터링
  • 강제 업데이트가 필요하면 In-App Update API
  • 롤백 계획 — 문제 시 배포 중단 절차 숙지
다음 탭 예고 — 멀티플랫폼 KMP에서는 지금까지 만든 도메인·네트워크·저장소 코드를 iOS 와 공유하는 방법을 다룹니다. Compose Multiplatform 으로 UI 까지 공유하는 선택지와, 그 경계선을 어디에 둘지도 함께 봅니다.
EXPECT/ACTUAL · KTOR · SQLDELIGHT · COMPOSE MULTIPLATFORM

공유할 것과
공유하지 말 것을
먼저 정한다

KMP의 성패는 기술이 아니라 경계선을 어디에 긋느냐입니다. 도메인·네트워크·저장소는 공유해서 이득이 크고, UI 는 공유할 수도, 안 할 수도 있습니다. 이 탭은 그 판단 기준부터 시작해 iOS 연동의 실제 마찰까지 다룹니다.

16SECTIONS
5타깃 (JVM·Android·iOS·JS·Native)
2UI 전략 (공유 · 네이티브)
1규칙 — 경계를 먼저 정한다
KMP 01

무엇을 공유할 것인가

KMP 는 “모든 것을 한 번에 만들자”가 아닙니다. 플랫폼과 무관한 로직만 공유하고 나머지는 각자 두는 점진적 방식이 핵심입니다.

폭이 곧 공유 이득 — 위로 갈수록 좁아지고 마찰은 커집니다 ④ UI ③ 프레젠테이션 ② 데이터 ① 도메인 마찰 이득 Compose MP 로 공유 · 또는 각자 네이티브 속도 ↑ · 플랫폼 고유 경험 ↓ — 팀이 정할 문제 ViewModel · 화면 상태 — 두 OS 동작이 같아짐 iOS 코루틴 마찰 조금 (SKIE 로 완화) Ktor Client · SQLDelight · 직렬화 API 변경을 한 번만 반영 — 이득 매우 큼 모델 · 검증 · 계산 규칙 — 순수 코틀린 마찰 거의 없음 · 여기서 시작 권장 순서 — ① → ② → ③ → (필요하면) ④ 꼭대기는 공유하지 않아도 됩니다 — 아래 세 층만으로도 이득의 대부분입니다
한 번에 다 공유하려다 실패하는 경우가 많습니다 — 도메인부터 한 층씩 올리세요.

KMP 가 잘 맞는 경우

  • 같은 기능을 두 플랫폼에 동시에 내야 한다
  • 비즈니스 규칙이 복잡하다 (계산·검증·상태 전이)
  • 두 앱의 동작이 미묘하게 달라 버그 리포트가 갈린다
  • 안드로이드 팀이 iOS 도 지원해야 하는 인력 구조
  • 서버·앱이 모델을 공유하면 좋겠다 (JVM 타깃 추가)

KMP 가 부담이 되는 경우

  • iOS 개발자가 코틀린을 원치 않는다 — 조직의 동의가 먼저입니다
  • 플랫폼 고유 기능이 대부분인 앱 (카메라·AR·위젯 중심)
  • 앱이 아주 작다 — 공유 인프라 구축 비용이 더 큽니다
  • 빌드·디버깅 도구 성숙도가 네이티브만 못한 구간이 아직 있습니다
현실적인 시작 — 새 기능 하나를 공유 모듈로 만들어 보고, 팀이 감을 잡은 뒤 확장하는 방식이 성공률이 가장 높습니다.
KMP 02

프로젝트 구조 · 소스셋

KMP 프로젝트의 뼈대는 소스셋 계층입니다. commonMain에 쓴 코드는 모든 타깃으로 가고, 플랫폼별 폴더는 그 타깃에만 갑니다.

소스셋 — 코드가 어느 플랫폼으로 나가는지 폴더 이름이 정합니다 commonMain 공통 로직 · expect 선언 jvmMain 서버 · 데스크톱 자바 라이브러리 그대로 androidMain 안드로이드 앱 Context · 안드로이드 API iosMain iOS 프레임워크 실제로는 타깃 3개 jsMain 브라우저 · Node DOM · fetch 중간 소스셋을 쓰면 중복이 줄어듭니다 appleMain — iOS 와 macOS 공통 / nativeMain — 네이티브 타깃 전체 공통. 계층은 Gradle 이 자동으로 만들어 줍니다
파일을 어느 폴더에 두느냐가 곧 설계입니다 — commonMain 에 들어갈 수 있는 코드의 비율이 KMP 의 이득입니다.

소스셋 계층

shared/src/
  commonMain/kotlin/          ★ 모든 타깃 공통
  commonTest/kotlin/

  androidMain/kotlin/         Android 전용 (JVM API 사용 가능)
  iosMain/kotlin/             iOS 공통 (arm64 · simulator)
  jvmMain/kotlin/             서버·데스크톱
  jsMain/kotlin/              웹

  # 중간 소스셋 — 여러 타깃이 공유
  appleMain/                  iOS + macOS
  nativeMain/                 iOS + Linux + Windows

composeApp/                   Compose Multiplatform UI (선택)
androidApp/                   안드로이드 앱 진입점
iosApp/                       Xcode 프로젝트 (Swift)
commonMain에서는 자바 API 를 쓸 수 없습니다. java.time·java.io.File·Thread 대신 kotlinx-datetime · okio · 코루틴 같은 멀티플랫폼 라이브러리를 씁니다.

build.gradle.kts

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.kotlinSerialization)
    alias(libs.plugins.sqldelight)
}

kotlin {
    androidTarget()
    jvm()                                  // 서버와도 공유한다면

    listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "Shared"
            isStatic = true                // Swift 에서 import Shared
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.kotlinx.serialization.json)
            implementation(libs.kotlinx.datetime)
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.contentNegotiation)
            implementation(libs.koin.core)
        }
        androidMain.dependencies { implementation(libs.ktor.client.okhttp) }
        iosMain.dependencies { implementation(libs.ktor.client.darwin) }
        commonTest.dependencies { implementation(libs.kotlin.test) }
    }
}

android { namespace = "com.example.shared"; compileSdk = 35
          defaultConfig { minSdk = 26 } }
시작은 마법사로 — kmp.jetbrains.com 의 위저드가 위 구조를 만들어 줍니다. Android Studio 에 Kotlin Multiplatform 플러그인을 설치하면 iOS 실행·디버깅도 IDE 안에서 가능합니다(맥 필요).
KMP 03

expect / actual

플랫폼마다 다르게 구현해야 하는 것을 잇는 장치입니다. 공통 코드는 “이런 게 있다”만 선언하고, 각 플랫폼이 실제 구현을 제공합니다.

expect / actual — "이건 플랫폼마다 다르다"를 문법으로 선언합니다 commonMain expect fun uuid(): String androidMain · actual java.util.UUID.randomUUID().toString() iosMain · actual NSUUID().UUIDString jsMain · actual crypto.randomUUID() actual 이 하나라도 빠지면 컴파일이 막힙니다 그래서 타깃을 하나 추가하는 순간 채워야 할 구멍이 전부 드러납니다 — 이게 expect/actual 의 진짜 값어치입니다
쓰는 쪽이 많아지면 expect 보다 인터페이스 + 플랫폼별 구현 주입이 테스트하기 쉽습니다 — 둘을 섞어 쓰세요.
// commonMain — 선언만
expect class Platform() {
    val name: String
}
expect fun randomUUID(): String
expect val appVersion: String

// androidMain — 실제 구현
actual class Platform actual constructor() {
    actual val name: String = "Android ${Build.VERSION.SDK_INT}"
}
actual fun randomUUID(): String = java.util.UUID.randomUUID().toString()
actual val appVersion: String get() = BuildConfig.VERSION_NAME

// iosMain
actual class Platform actual constructor() {
    actual val name: String = UIDevice.currentDevice.systemName() + " " +
                              UIDevice.currentDevice.systemVersion
}
actual fun randomUUID(): String = NSUUID().UUIDString()
actual val appVersion: String get() =
    NSBundle.mainBundle.infoDictionary?.get("CFBundleShortVersionString") as? String ?: "?"

인터페이스로 푸는 편이 나을 때가 많다

// expect/actual 은 '타입 이름이 같아야' 해서 유연성이 떨어진다
// 의존성 주입을 쓴다면 인터페이스가 더 깔끔하다

// commonMain
interface KeyValueStore {
    suspend fun get(key: String): String?
    suspend fun put(key: String, value: String)
}

// androidMain — DataStore 로 구현
class AndroidKeyValueStore(private val context: Context) : KeyValueStore { /* ... */ }

// iosMain — NSUserDefaults 로 구현
class IosKeyValueStore : KeyValueStore {
    override suspend fun get(key: String) =
        NSUserDefaults.standardUserDefaults.stringForKey(key)
    override suspend fun put(key: String, value: String) =
        NSUserDefaults.standardUserDefaults.setObject(value, key)
}

// Koin 모듈에서 플랫폼별로 바인딩

기억할 규칙

  • 모든 타깃에 actual 이 있어야 컴파일됩니다 — 하나만 빠져도 에러
  • 기본 인자값은 expect 선언에 둡니다 — actual 에는 다시 적을 수 없고, expect 함수에는 본문(기본 구현)을 둘 수 없습니다
  • expect 클래스는 실험적 경고가 뜰 수 있습니다 — 인터페이스 + DI 로 대체 가능하면 그쪽이 안정적입니다
  • 중간 소스셋(appleMain)에 actual 을 두면 iOS·macOS 가 함께 씁니다
  • typealias 로도 actual이 됩니다: actual typealias UUID = java.util.UUID
KMP 04

공통 라이브러리 생태계

KMP 로 갈 때 가장 먼저 확인할 것은 “쓰던 라이브러리가 KMP 를 지원하는가”입니다. 다행히 핵심 영역은 대부분 갖춰져 있습니다.

영역KMP 라이브러리대체하는 것
동시성kotlinx-coroutines모든 플랫폼 공통
직렬화kotlinx-serializationJackson · Gson
날짜·시간kotlinx-datetimejava.time · Foundation
HTTPKtor ClientRetrofit · URLSession
DBSQLDelight · Room KMPRoom · CoreData
키-값 저장multiplatform-settings · DataStoreSharedPreferences · UserDefaults
DIKoin · kotlin-injectHilt
파일·IOokiojava.io · FileManager
이미지 로딩Coil 3Coil · Kingfisher
로깅Napier · KermitTimber · os_log
UICompose MultiplatformJetpack Compose · SwiftUI
테스트kotlin-test · TurbineJUnit · XCTest

공통 코드 예시 — 도메인

// commonMain — 순수 코틀린, 어디서나 돈다
@Serializable
data class Order(
    val id: Long,
    val orderNo: String,
    val status: OrderStatus,
    val totalAmount: Long,
    val createdAt: Instant,          // kotlinx-datetime
) {
    val isCancellable: Boolean
        get() = status in setOf(OrderStatus.NEW, OrderStatus.PAID)

    fun displayDate(tz: TimeZone = TimeZone.currentSystemDefault()): String {
        val local = createdAt.toLocalDateTime(tz)
        return "${local.year}.${local.monthNumber}.${local.dayOfMonth}"
    }
}

@Serializable
enum class OrderStatus { NEW, PAID, SHIPPED, DONE, CANCELED }

// 검증 규칙도 공유 — 두 앱의 동작이 어긋나지 않는다
object OrderRules {
    const val MAX_QUANTITY = 99
    fun validateQuantity(q: Int): Result<Int> =
        if (q in 1..MAX_QUANTITY) Result.success(q)
        else Result.failure(IllegalArgumentException("수량은 1~$MAX_QUANTITY"))
}

확인하는 법

klibs.io(젯브레인스가 운영하는 KMP 라이브러리 검색)에서 지원 타깃을 확인할 수 있습니다. 라이브러리 배지에 AndroidiOS JVMJS 표시가 있으면 그 타깃에서 씁니다.

안 되는 것도 있습니다 — 자바 리플렉션에 의존하는 라이브러리, 안드로이드 SDK 를 직접 쓰는 라이브러리는 commonMain에 못 들어갑니다. 그런 경우 인터페이스로 감싸고 플랫폼별 구현을 주입하세요.
KMP 05

네트워크 — Ktor Client

공유해서 가장 이득이 큰 층입니다. API 스펙이 바뀌면 한 번만 고치면 되고, 두 앱의 에러 처리·재시도 정책이 자동으로 같아집니다.

// commonMain — 엔진만 플랫폼별로 주입받는다
class ApiClient(engine: HttpClientEngine, private val tokenProvider: suspend () -> String?) {

    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }

    private val client = HttpClient(engine) {
        install(ContentNegotiation) { json(json) }
        install(HttpTimeout) {
            requestTimeoutMillis = 15_000
            connectTimeoutMillis = 10_000
        }
        install(HttpRequestRetry) {
            retryOnServerErrors(maxRetries = 2)
            exponentialDelay()
        }
        install(Logging) { level = LogLevel.HEADERS }
        expectSuccess = true    // ★ 4xx·5xx 를 예외로 — 아래 apiCall 의 ResponseException 분기가 동작하려면 필요 (기본 false)
        defaultRequest {
            url("https://api.example.com/")
            contentType(ContentType.Application.Json)
        }
    }

    private suspend fun HttpRequestBuilder.auth() {
        tokenProvider()?.let { header(HttpHeaders.Authorization, "Bearer $it") }
    }

    suspend fun orders(page: Int = 1): List<Order> =
        client.get("orders") { auth(); parameter("page", page) }.body()

    suspend fun order(id: Long): Order =
        client.get("orders/$id") { auth() }.body()

    suspend fun createOrder(req: CreateOrderRequest): Order =
        client.post("orders") { auth(); setBody(req) }.body()
}

// androidMain
actual fun httpEngine(): HttpClientEngine = OkHttp.create { /* 인터셉터 등 */ }
// iosMain
actual fun httpEngine(): HttpClientEngine = Darwin.create { /* NSURLSession 설정 */ }

에러 처리도 공유한다

// commonMain — 도메인 에러로 변환
sealed interface ApiError {
    data object Network : ApiError
    data object Timeout : ApiError
    data class Http(val code: Int, val message: String?) : ApiError
    data class Unknown(val cause: String?) : ApiError
}

suspend fun <T> apiCall(block: suspend () -> T): Result<T> =
    try { Result.success(block()) }
    catch (e: CancellationException) { throw e }
    catch (e: ResponseException) {               // 4xx(ClientRequest)·5xx(ServerResponse) 의 부모
        Result.failure(ApiException(ApiError.Http(e.response.status.value, e.message)))
    }
    catch (e: HttpRequestTimeoutException) { Result.failure(ApiException(ApiError.Timeout)) }
    catch (e: IOException) { Result.failure(ApiException(ApiError.Network)) }
    catch (e: Throwable) { Result.failure(ApiException(ApiError.Unknown(e.message))) }

Retrofit 과의 차이

항목Ktor Client
정의 방식인터페이스가 아니라 함수 안에서 직접 요청 조립
플랫폼안드로이드·iOS·JVM·JS 전부
엔진OkHttp · Darwin · CIO · Js 교체 가능
플러그인타임아웃·재시도·인증·로깅을 install 로
코드 생성없음 (KSP·kapt 불필요)
안드로이드 단독 프로젝트에서도 Ktor 를 쓸 수 있습니다. 나중에 KMP 로 갈 계획이 있다면 처음부터 Ktor 로 시작하는 편이 전환 비용을 크게 줄입니다.
KMP 06

로컬 저장 — SQLDelight

SQL 파일을 쓰면 타입 안전한 코틀린 API 가 생성되고, 그 코드가 안드로이드·iOS·데스크톱에서 모두 돕니다.

스키마와 쿼리를 .sq 파일에

-- shared/src/commonMain/sqldelight/com/example/db/Order.sq

CREATE TABLE orderEntity (
    id INTEGER NOT NULL PRIMARY KEY,
    orderNo TEXT NOT NULL,
    status TEXT NOT NULL,
    totalAmount INTEGER NOT NULL,
    createdAt INTEGER NOT NULL
);

CREATE INDEX order_created ON orderEntity(createdAt DESC);

selectAll:
SELECT * FROM orderEntity ORDER BY createdAt DESC;

selectById:
SELECT * FROM orderEntity WHERE id = ?;

selectByStatus:
SELECT * FROM orderEntity WHERE status = :status ORDER BY createdAt DESC;

upsert:
INSERT OR REPLACE INTO orderEntity(id, orderNo, status, totalAmount, createdAt)
VALUES (?, ?, ?, ?, ?);

deleteAll:
DELETE FROM orderEntity;

생성된 API 를 코루틴으로

class OrderLocalSource(private val db: AppDatabase) {
    private val q = db.orderQueries

    // Flow 로 관찰 — 데이터가 바뀌면 자동 방출
    fun observeAll(): Flow<List<Order>> =
        q.selectAll().asFlow().mapToList(ioDispatcher).map { rows -> rows.map { it.toDomain() } }

    suspend fun replaceAll(orders: List<Order>) = withContext(ioDispatcher) {   // mp11 의 expect val
        db.transaction {                       // 트랜잭션도 지원
            q.deleteAll()
            orders.forEach { o ->
                q.upsert(o.id, o.orderNo, o.status.name, o.totalAmount,
                         o.createdAt.toEpochMilliseconds())
            }
        }
    }
}

// 드라이버는 플랫폼별 (expect/actual 또는 DI)
// androidMain: AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
// iosMain:     NativeSqliteDriver(AppDatabase.Schema, "app.db")
// jvmMain:     JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
Room 도 KMP 를 지원합니다(2.7+). 안드로이드에서 Room 을 쓰고 있었다면 전환 비용이 낮습니다. SQL 을 직접 쓰는 걸 선호하면 SQLDelight, 애너테이션 기반이 익숙하면 Room — 둘 다 실무에서 쓰입니다.

저장소를 합쳐 단일 소스 만들기

// commonMain — 안드로이드·iOS 가 똑같이 쓴다
class OrderRepository(
    private val remote: ApiClient,
    private val local: OrderLocalSource,
) {
    fun observe(): Flow<List<Order>> = local.observeAll()

    suspend fun refresh(): Result<Unit> = apiCall { remote.orders() }
        .onSuccess { local.replaceAll(it) }
        .map { }
}
// 이 클래스 하나가 두 앱의 데이터 동작을 통일한다 —
// "안드로이드에서는 새로고침이 되는데 iOS 는 안 된다" 같은 차이가 사라진다
KMP 07

iOS 연동 — Swift에서 부르기

KMP 의 가장 큰 마찰 지점입니다. 코틀린 코드는 Objective-C 헤더를 거쳐 Swift 에 노출되므로, 제네릭·sealed·기본값이 그대로 보이지 않습니다.

코틀린 → iOS 노출 경로 — 앞으로만 가고, 세 번째 칸에서 정보를 잃습니다 공유 코틀린 commonMain + iosMain Kotlin/Native XCFramework 생성 ObjC 헤더 여기서 정보 손실 Swift Xcode 에서 사용 무엇이 불편해지나 · 제네릭 타입이 흐려진다 — Flow<Order> 가 Swift 에서는 그냥 Flow · sealed 클래스가 switch 로 안 갈린다 (default 가 강제된다) · suspend 함수는 completionHandler 콜백으로 노출된다 · 기본 인자가 사라진다
SKIE(Touchlab)를 붙이면 이 셋이 대부분 해결됩니다 — 다음 섹션에서 다룹니다.

Swift 쪽에서 본 모습

import Shared

// 코틀린: suspend fun order(id: Long): Order
// Swift : async 로 자동 변환된다 (Kotlin 1.9+)
let order = try await api.order(id: 1)

// 코틀린: class OrderRepository(remote:local:)
let repo = OrderRepository(remote: api, local: local)

// 코틀린 sealed → Swift 에서는 타입 검사로
switch state {
case is OrderUiStateLoading: showSpinner()
case let s as OrderUiStateSuccess: render(s.orders)
case let e as OrderUiStateError: showError(e.message)
default: break            // ← SKIE 없으면 default 가 강제된다
}

// Flow 수집 — 그대로는 번거롭다
FlowCollector(flow: repo.observe()) { orders in
    self.orders = orders as! [Order]
}

노출을 다듬는 기술

// ① Swift 에서 쓸 이름을 지정
@ObjCName(swiftName = "OrderRepo")
class OrderRepository { /* ... */ }

// ② 내부 구현은 숨긴다 — 노출 표면을 작게
internal class OfflineFirstRepository : OrderRepository

// ③ iOS 전용 파사드를 만들어 준다 (iosMain)
class IosOrderFacade(private val repo: OrderRepository) {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

    fun observeOrders(onEach: (List<Order>) -> Unit): Closeable {
        val job = repo.observe().onEach(onEach).launchIn(scope)
        return Closeable { job.cancel() }     // Swift 에서 해제할 수 있게
    }
}

// ④ 제네릭이 흐려지는 것을 피하려고 구체 타입으로 감싼다
class OrderList(val items: List<Order>)      // Flow<List<Order>> 대신
iOS 팀과의 협업이 성패를 가릅니다. 공유 모듈의 공개 API 를 iOS 개발자가 쓰기 편한 형태로 설계하지 않으면 “코틀린이 불편하다”는 반발이 나옵니다. Swift 스타일에 맞춘 파사드를 제공하는 것이 실무에서 가장 효과적인 완충 장치입니다.
KMP 08

코루틴을 Swift에서 · SKIE

Flow와 suspend를 iOS 에서 자연스럽게 쓰는 것이 관건입니다. SKIE 는 그 간극을 Swift 의 AsyncSequence·enum으로 메워 줍니다.

SKIE 없이
// Flow 수집이 번거롭다
let collector = Kotlinx_coroutines_coreFlowCollector { value in
    // 타입 캐스팅 필요
}
try await repo.observe().collect(collector: collector)

// sealed 도 switch 가 안 갈린다
switch state {
case is UiStateLoading: ...
default: break          // 컴파일러가 완전성을 보장하지 못한다
}
SKIE 를 붙이면
// Flow 가 AsyncSequence 가 된다
for await orders in repo.observe() {
    self.orders = orders
}

// sealed 가 Swift enum 으로 — 완전성 검사가 된다
switch onEnum(of: state) {
case .loading: showSpinner()
case .success(let s): render(s.orders)
case .error(let e): showError(e.message)
}          // default 불필요

// suspend 함수의 기본 인자도 살아난다
let orders = try await api.orders()

SKIE 설정

// build.gradle.kts
plugins {
    id("co.touchlab.skie") version "0.10.1"
}

skie {
    features {
        group {
            SealedInterop.Enabled(true)      // sealed → Swift enum
            FlowInterop.Enabled(true)        // Flow → AsyncSequence
            DefaultArgumentInterop.Enabled(true)
        }
    }
}
KMP 를 실제로 도입한 팀 대부분이 SKIE 를 씁니다. iOS 개발자의 체감 만족도가 눈에 띄게 달라지므로, 파일럿 단계에서 함께 넣어 평가하는 것을 권합니다.

iOS 에서 ViewModel 을 쓰는 형태

// SwiftUI 와 공유 ViewModel 연결
@MainActor
final class OrderListModel: ObservableObject {
    @Published var state: OrderUiState = OrderUiStateLoading()
    private let vm = SharedOrderViewModel()      // 공유 코틀린
    private var task: Task<Void, Never>?

    func start() {
        task = Task {
            for await s in vm.state {            // SKIE 덕분에 for await
                self.state = s
            }
        }
    }
    func stop() { task?.cancel(); vm.onCleared() }
}

struct OrderListView: View {
    @StateObject private var model = OrderListModel()
    var body: some View {
        content
            .onAppear { model.start() }
            .onDisappear { model.stop() }
    }
}
androidx.lifecycle ViewModel 도 KMP 를 지원합니다(2.8+). commonMain에서 ViewModel과 viewModelScope를 그대로 쓸 수 있어, 프레젠테이션 로직 공유가 예전보다 훨씬 쉬워졌습니다.
KMP 09

Compose Multiplatform — UI 공유

Jetpack Compose 를 iOS·데스크톱·웹에서도 돌리는 프레임워크입니다. 안드로이드 개발자가 아는 그 문법 그대로, 화면까지 한 번에 만듭니다.

// composeApp/src/commonMain/kotlin/App.kt — 이 코드가 4개 플랫폼에서 돈다
@Composable
fun App(viewModel: OrderViewModel = koinViewModel()) {
    MaterialTheme {
        val state by viewModel.state.collectAsStateWithLifecycle()

        when (val s = state) {
            OrderUiState.Loading -> Box(Modifier.fillMaxSize(), Alignment.Center) {
                CircularProgressIndicator()
            }
            is OrderUiState.Error -> ErrorView(s.message, onRetry = viewModel::load)
            is OrderUiState.Success -> LazyColumn {
                items(s.orders, key = { it.id }) { order ->
                    ListItem(
                        headlineContent = { Text(order.orderNo) },
                        supportingContent = { Text(order.displayDate()) },
                        trailingContent = { Text("${order.totalAmount}원") },
                        modifier = Modifier.clickable { viewModel.onClick(order.id) },
                    )
                }
            }
        }
    }
}

// iOS 진입점 (iosMain)
fun MainViewController(): UIViewController = ComposeUIViewController { App() }

// Swift 쪽
struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController { MainViewControllerKt.MainViewController() }
    func updateUIViewController(_ vc: UIViewController, context: Context) {}
}

플랫폼별 지원 수준

타깃상태
AndroidJetpack Compose 그 자체 — 완전 성숙
iOS안정(Stable) — 실제 서비스에 쓰이고 있다
Desktop안정 — 사내 도구·데스크톱 앱에 적합
Web (Wasm)발전 중 — 첫 로딩 크기·SEO 를 고려해야
리소스도 공유됩니다 — composeResources/ 폴더에 문자열·이미지·폰트를 두면 Res.string.title·Res.drawable.logo 로 모든 플랫폼에서 참조할 수 있습니다.

플랫폼 차이를 다루기

// ① 플랫폼별 컴포저블 — expect/actual
@Composable expect fun PlatformBackHandler(onBack: () -> Unit)

// androidMain
@Composable actual fun PlatformBackHandler(onBack: () -> Unit) = BackHandler(onBack = onBack)
// iosMain — iOS 에는 뒤로 가기 버튼이 없다 (스와이프)
@Composable actual fun PlatformBackHandler(onBack: () -> Unit) { }

// ② 네이티브 뷰 끼워 넣기 (지도·웹뷰·카메라)
// iOS: UIKitView { MKMapView() }
// Android: AndroidView { MapView(it) }

// ③ 플랫폼 관례 존중
val isIos = getPlatform().name.startsWith("iOS")
if (isIos) IosStyleNavigationBar() else MaterialTopAppBar()
UI 공유의 진짜 비용은 “플랫폼답지 않음”입니다. iOS 사용자는 스와이프 뒤로 가기·시스템 폰트·기본 제스처에 익숙합니다. Compose 로 만든 화면이 안드로이드처럼 보이면 이질감을 줍니다. 사내 도구·B2B 앱은 공유가 유리하고, 소비자용 앱은 UI 를 네이티브로 두는 선택도 충분히 합리적입니다.
KMP 10

UI 를 공유하지 않는 전략

가장 많이 쓰이는 구성입니다 — 로직은 코틀린으로 공유하고, UI 는 각 플랫폼 네이티브. 팀 갈등이 적고, 플랫폼 경험을 잃지 않습니다.

가장 흔한 구성 — 로직만 공유 Android — Jetpack Compose 머티리얼 디자인 · 안드로이드 관례 iOS — SwiftUI 휴먼 인터페이스 가이드 · iOS 관례 공유 코틀린 모듈 ViewModel · Repository · API · DB · 도메인 규칙 코드의 60~80% — 화면만 각자 그린다
두 앱의 동작은 같고 겉모습만 다릅니다 — 버그가 한쪽에서만 나는 일이 사라집니다.

공유 ViewModel — 두 UI 가 같은 상태를 본다

// commonMain — androidx.lifecycle ViewModel (KMP 지원)
class OrderViewModel(private val repo: OrderRepository) : ViewModel() {

    private val _state = MutableStateFlow<OrderUiState>(OrderUiState.Loading)
    val state: StateFlow<OrderUiState> = _state.asStateFlow()

    init { observe(); refresh() }

    private fun observe() = viewModelScope.launch {
        repo.observe().collect { orders ->
            _state.value = OrderUiState.Success(orders)
        }
    }

    fun refresh() = viewModelScope.launch {
        repo.refresh().onFailure { _state.value = OrderUiState.Error(it.userMessage()) }
    }
}

각 플랫폼에서 붙이기

// Android — 그대로 쓴다
@Composable
fun OrderScreen(vm: OrderViewModel = koinViewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    // ...
}
// iOS — SwiftUI 로 감싼다 (SKIE 로 for await 가능)
@MainActor final class OrderStore: ObservableObject {
    @Published private(set) var state: OrderUiState = OrderUiStateLoading()
    private let vm = OrderViewModel(repo: Koin.shared.repo)

    func observe() async {
        for await s in vm.state { state = s }
    }
    func refresh() { vm.refresh() }
}
도입 순서 추천 — ① 도메인 모델·검증 규칙만 공유 → ② API 클라이언트 공유 → ③ 로컬 DB·Repository 공유 → ④ ViewModel 공유 → ⑤ (원한다면) UI 공유. 각 단계에서 멈춰도 이득이 남는다는 것이 이 전략의 장점입니다.
KMP 11

DI · 동시성 모델

KMP 에서는 Koin이 사실상 표준 DI 입니다. 그리고 Kotlin/Native 의 메모리 모델이 바뀌면서 예전의 “동결(freeze)” 제약이 사라졌습니다.

Koin — 공통 모듈 + 플랫폼 모듈

// commonMain
val commonModule = module {
    single { ApiClient(get(), get()) }
    single<OrderRepository> { OrderRepositoryImpl(get(), get()) }
    viewModel { OrderViewModel(get()) }              // koinViewModel() 로 받으려면 viewModel 정의
}

expect val platformModule: Module

// androidMain
actual val platformModule = module {
    single<HttpClientEngine> { OkHttp.create() }
    single { AndroidSqliteDriver(AppDatabase.Schema, get(), "app.db") as SqlDriver }
}

// iosMain
actual val platformModule = module {
    single<HttpClientEngine> { Darwin.create() }
    single { NativeSqliteDriver(AppDatabase.Schema, "app.db") as SqlDriver }
}

// 초기화
fun initKoin(extra: Module = module {}) = startKoin {
    modules(commonModule, platformModule, extra)
}
// Android: initKoin(module { single<Context> { this@MyApp } })  (Application.onCreate)
// iOS:     KoinKt.doInitKoin()                       (AppDelegate)

새 메모리 모델 — 무엇이 달라졌나

  • freeze() 가 필요 없습니다 — 객체를 스레드 사이에서 자유롭게 공유합니다 (Kotlin 1.7.20 부터 기본)
  • InvalidMutabilityException 같은 옛 오류를 더는 만나지 않습니다
  • 대신 자바와 같은 동시성 문제가 생깁니다 — 공유 가변 상태에는 Mutex·AtomicReference가 필요합니다
  • iOS 의 Dispatchers.Main은 메인 큐, Dispatchers.Default는 워커 스레드 풀입니다 (Dispatchers.IO는 코루틴 1.7 부터 네이티브에도 있지만 JS 에는 없어서 commonMain에서는 바로 못 씁니다)
// commonMain 에서 IO 를 쓰고 싶다면 expect/actual 로
expect val ioDispatcher: CoroutineDispatcher
// androidMain: actual val ioDispatcher = Dispatchers.IO
// iosMain:     actual val ioDispatcher = Dispatchers.IO   // import kotlinx.coroutines.IO (1.7+)
KMP 12

테스트 · 공통 검증

공유 모듈의 큰 이득 하나 — 테스트도 한 번만 쓰면 됩니다. 그리고 그 테스트가 모든 타깃에서 실행됩니다.

// commonTest — Android·iOS·JVM 에서 모두 실행된다
class OrderRulesTest {

    @Test
    fun `수량은 1에서 99 사이여야 한다`() {
        assertTrue(OrderRules.validateQuantity(1).isSuccess)
        assertTrue(OrderRules.validateQuantity(99).isSuccess)
        assertTrue(OrderRules.validateQuantity(0).isFailure)
        assertTrue(OrderRules.validateQuantity(100).isFailure)
    }

    @Test
    fun `취소 가능 상태`() {
        assertTrue(order(status = OrderStatus.NEW).isCancellable)
        assertFalse(order(status = OrderStatus.DONE).isCancellable)
    }
}

// 코루틴 테스트도 공통으로
class OrderRepositoryTest {
    @Test
    fun `새로고침 실패시 로컬 데이터는 유지된다`() = runTest {
        val local = FakeLocalSource(initial = listOf(order()))
        val repo = OrderRepository(FailingApi(), local)

        val result = repo.refresh()

        assertTrue(result.isFailure)
        assertEquals(1, repo.observe().first().size)
    }
}

// 실행
// ./gradlew :shared:allTests              모든 타깃
// ./gradlew :shared:jvmTest               JVM 만 (가장 빠름 — 개발 중엔 이걸)
// ./gradlew :shared:iosSimulatorArm64Test iOS 시뮬레이터

테스트 전략

  • 도메인·Repository 테스트는 commonTest 에 — 한 번 쓰고 모든 곳에서 검증
  • 개발 중에는 jvmTest 만 돌린다 (iOS 테스트는 느립니다)
  • CI 에서는 모든 타깃을 돌려 플랫폼 차이를 잡는다
  • Fake 구현을 commonTest 에 두면 안드로이드·iOS 테스트가 공유합니다
  • UI 테스트는 각 플랫폼에서 (Compose Test · XCTest)

플랫폼 차이가 드러나는 지점

영역주의
날짜·시간대포맷·타임존 처리가 미묘하게 다를 수 있다
문자열 정렬·비교로케일 동작 차이
부동소수점 출력toString() 결과가 다를 수 있다
정규식엔진 차이로 일부 패턴 동작이 다르다
동시성Dispatchers.IO 는 JVM·네이티브에만 — commonMain 에서는 expect/actual 로
KMP 13

빌드 · 배포

공유 모듈을 iOS 프로젝트에 어떻게 전달할 것인가가 핵심입니다. 직접 참조·CocoaPods·SPM 세 가지 경로가 있고, 팀 구조에 따라 선택이 갈립니다.

한 코드베이스, 네 갈래 배포 — 바깥 선 하나가 파이프라인 하나 공유 코틀린 태그 하나 Android · .aar Maven · 사내 Nexus iOS · .xcframework CocoaPods · SPM JVM 서버 · .jar Maven Central Web · .js 번들 npm · .d.ts 포함 서버가 DTO 를 공유하면 클라이언트와 스펙이 어긋날 일이 없습니다 · Wasm 은 브라우저 지원을 확인하고 네 산출물은 같은 태그에서 함께 나가야 합니다 — CI 한 번으로 묶어 두세요
KMP 도입의 실제 난이도는 코드가 아니라 네 갈래 배포 파이프라인을 유지하는 일에 있습니다.
방식특징 · 언제
직접 참조(모노레포)Xcode 빌드 단계에서 Gradle 실행 — 한 저장소에서 함께 개발할 때 가장 단순
CocoaPodscocoapods {} 블록으로 Podspec 생성 — 기존 Pod 의존성이 많을 때
SPM (XCFramework)바이너리를 배포 — iOS 팀이 코틀린 빌드를 안 하게 하고 싶을 때

XCFramework 만들어 배포하기

kotlin {
    val xcf = XCFramework("Shared")
    listOf(iosArm64(), iosSimulatorArm64()).forEach {
        it.binaries.framework {
            baseName = "Shared"
            xcf.add(this)
            isStatic = true
        }
    }
}
// ./gradlew assembleSharedXCFramework
// → build/XCFrameworks/release/Shared.xcframework

// 이걸 별도 저장소에 올리고 Package.swift 로 배포하면
// iOS 팀은 코틀린 툴체인 없이 SPM 으로 받아 쓸 수 있다
iOS 개발자 경험을 지키는 핵심입니다 — 매번 Gradle 빌드를 기다리게 하면 KMP 에 대한 반감이 커집니다. 바이너리 배포는 그 마찰을 없앱니다(대신 버전 관리가 필요).

CI 구성

jobs:
  common-test:
    runs-on: ubuntu-latest         # 리눅스에서 빠르게
    steps:
      - run: ./gradlew :shared:jvmTest :shared:testDebugUnitTest

  ios-build:
    runs-on: macos-latest          # iOS 는 맥이 필요하다
    steps:
      - run: ./gradlew :shared:iosSimulatorArm64Test
      - run: ./gradlew assembleSharedXCFramework
      - run: xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \
               -destination 'platform=iOS Simulator,name=iPhone 16' build
빌드 시간이 늘어납니다. Kotlin/Native 컴파일은 JVM 보다 느립니다. Gradle 빌드 캐시·원격 캐시를 켜고, iOS 빌드는 필요한 브랜치에서만 돌리도록 조정하세요.
KMP 14

리소스 · 다국어를 공유하기

코드는 공유했는데 문구와 아이콘은 플랫폼마다 따로라면, 문구 하나 고칠 때마다 세 군데를 고쳐야 합니다. 실제로 KMP 도입 후 가장 자주 어긋나는 곳이 여기입니다.

공유하지 않으면 이렇게 됩니다

  • 안드로이드 strings.xml · iOS Localizable.strings · 웹 JSON — 같은 문구가 세 벌
  • 번역가에게 파일을 세 개 보내고 세 번 받습니다
  • 한쪽만 고쳐 배포해 플랫폼마다 다른 문구가 나가는 사고
  • 키 이름 규칙도 달라서 어느 문구가 어느 키인지 추적이 안 됩니다

선택지 — 지금은 사실상 둘

도구언제 고르나
Compose Multiplatform ResourcesUI 까지 Compose 로 공유할 때. 공식 경로이고 문자열·이미지·폰트를 함께 다룹니다
moko-resourcesUI 는 각자 만들되 문자열만 공유할 때. 네이티브 리소스로 생성해 줍니다
직접 구현문자열 수가 아주 적을 때만. expect/actual 로 맵 하나
UI 공유 여부가 곧 선택 기준입니다. Compose Multiplatform 을 쓰기로 했다면 고민할 것 없이 공식 리소스 시스템을 쓰세요.
문구 하나를 고칠 때 손대야 하는 파일 수 ① 플랫폼마다 따로 strings.xml Localizable ko.json 번역 파일이 세 벌 — 세 번 보내고 세 번 받습니다 한쪽만 고쳐 배포하면 플랫폼마다 문구가 달라집니다 ② commonMain 에 한 벌 composeResources/values/strings.xml 원본은 하나 — 빌드할 때 각 플랫폼 형식으로 생성됩니다 키가 코드로 만들어져 오타가 컴파일에서 잡힙니다 공유해도 플랫폼에 남겨야 하는 것들 앱 아이콘 · 런처 · 스플래시 · 알림 아이콘 · 위젯 미리보기 — OS 가 직접 읽는 리소스는 각자 자리에 있어야 합니다 앱 이름과 스토어 설명도 마찬가지입니다 — 공유 대상은 "앱 안에서 보이는 문구"까지입니다
공유의 진짜 이득은 파일 수가 아니라 번역 원본이 하나가 되는 것입니다.

Compose Multiplatform Resources — 실제로 쓰는 모습

폴더 구조와 선언

composeApp/src/commonMain/composeResources/
├── values/strings.xml           # 기본(영어 또는 한국어)
├── values-ko/strings.xml        # 한국어
├── values-ja/strings.xml        # 일본어
├── drawable/logo.xml            # 벡터 이미지
├── font/Pretendard.ttf
└── files/terms.md               # 원본 그대로 읽을 파일

폴더 규칙이 안드로이드와 같습니다. 안드로이드 개발자라면 옮겨오는 데 학습이 거의 필요 없습니다.

공통 코드에서 쓰기

import myapp.composeapp.generated.resources.Res
import myapp.composeapp.generated.resources.order_done

@Composable
fun DoneScreen(orderNo: String) {
    // 키가 코드로 생성됩니다 — 오타는 컴파일 에러
    Text(stringResource(Res.string.order_done, orderNo))
    Image(painterResource(Res.drawable.logo), null)
}

// Composable 이 아닌 곳에서는 suspend 로
val terms: ByteArray = Res.readBytes("files/terms.md")
키가 문자열이 아니라 프로퍼티입니다. 안 쓰는 문자열을 지웠는데 어딘가에서 참조 중이면 빌드가 막아 줍니다.

복수형 · 서식 · 날짜 — 언어마다 규칙이 다릅니다

복수형(plurals)

한국어는 단복수 구분이 없지만 영어는 1/그 외, 러시아어는 세 갈래입니다. 문자열을 "$n 개" 로 이어 붙이면 다국어에서 반드시 깨집니다.

pluralStringResource 를 쓰고, 번역 파일에 수량 규칙을 언어별로 두세요.

서식 인자 순서

"홍길동님이 3개를 샀습니다"의 어순이 언어마다 뒤집힙니다. 위치 인자(%1$s · %2$d)를 쓰면 번역자가 순서를 바꿔도 안전합니다.

문자열을 코드에서 이어 붙이지 마세요 — 번역 불가능한 문장이 됩니다.

날짜 · 숫자 · 통화

이건 리소스가 아니라 포매터의 영역입니다. kotlinx-datetime 으로 시간을 다루고, 표시 형식은 플랫폼 API 에 맡기세요.

서버에는 항상 UTC · ISO-8601 로 보내고, 표시할 때만 지역을 적용합니다.

RTL(오른쪽에서 왼쪽) 언어를 지원할 계획이 조금이라도 있다면 처음부터 start/end 기준으로 레이아웃을 짜세요. left/right 로 짜 둔 화면을 나중에 뒤집는 일은 새로 만드는 것과 비용이 비슷합니다.
KMP 15

이미 있는 앱에 KMP 를 밀어 넣기

실무에서 KMP 를 시작하는 경우의 대부분은 새 프로젝트가 아니라 이미 돌고 있는 앱입니다. 전부 갈아엎지 않고 모듈 하나부터 넣는 순서를 정리합니다.

공유 범위를 넓히는 순서 — 위험이 낮은 것부터 1 순수 로직 — 의존성이 하나도 없는 것부터 금액 계산 · 유효성 검사 · 포맷 규칙 · 상태 전이 — 되돌리기도 가장 쉽습니다 2 모델 · DTO — 서버 응답 정의 kotlinx.serialization 으로 한 벌만 정의하면 플랫폼 간 스펙 어긋남이 사라집니다 3 네트워크 계층 — Ktor Client 엔드포인트 · 헤더 · 재시도 · 에러 매핑을 한 번만 씁니다. 엔진만 플랫폼별로 다릅니다 4 저장소 계층 — SQLDelight · 캐시 정책 여기서부터는 플랫폼 파일 시스템·암호화가 얽혀 난이도가 한 단계 올라갑니다 5 화면 상태 · UI 가장 마지막입니다 — 그리고 여기까지 안 가는 선택도 충분히 정상입니다 1단계에서 멈추더라도 실패가 아닙니다 — 계산 로직 하나만 공유해도 그쪽 버그는 절반으로 줄어듭니다.
"어디까지 공유할까"의 답은 팀이 감당할 수 있는 빌드 복잡도가 정합니다.

실제 붙이는 순서 — 안드로이드 앱 기준

1. 모듈 하나를 추가합니다

// shared/build.gradle.kts
plugins {
    kotlin("multiplatform")
    id("com.android.library")      // androidTarget() 에 필요
}

kotlin {
    androidTarget()
    iosArm64(); iosSimulatorArm64()

    // iOS 쪽에서 쓸 프레임워크 이름
    listOf(iosArm64(), iosSimulatorArm64()).forEach {
        it.binaries.framework { baseName = "Shared"; isStatic = true }
    }
}

// 기존 app 모듈은 그냥 의존성만 추가
// implementation(project(":shared"))

기존 앱 모듈은 건드리지 않습니다. 안드로이드 쪽에서는 평범한 코틀린 라이브러리 모듈 하나가 늘어난 것과 같습니다.

2. iOS 쪽에서 받아 씁니다

# 방법 A — CocoaPods (기존 프로젝트에 붙이기 쉬움)
kotlin { cocoapods { summary = "..."; ios.deploymentTarget = "15.0" } }

# 방법 B — Swift Package Manager (XCFramework 배포)
./gradlew :shared:assembleSharedXCFramework

# 방법 C — 로컬 개발 중에는 Xcode 빌드 페이즈에 embedAndSign 연결
iOS 개발자의 첫인상이 도입 성패를 가릅니다. 프레임워크 갱신이 Xcode 빌드 한 번으로 끝나게 만들어 두지 않으면, 그쪽에서 먼저 손을 뗍니다.

도입 전에 팀과 합의해야 할 것들

항목합의해 둘 내용
공유 범위의 상한"UI 는 공유하지 않는다"처럼 선을 먼저 긋습니다 — 안 그으면 매번 논쟁이 됩니다
빌드 소유자KMP 빌드가 깨졌을 때 고치는 사람을 정해 둡니다. 애매하면 아무도 안 고칩니다
공유 모듈 리뷰양쪽 플랫폼 담당자가 모두 리뷰합니다 — 한쪽만 편한 API 가 나오는 걸 막습니다
되돌리는 방법공유 모듈을 걷어내고 각자 구현으로 돌아가는 경로를 문서로 남깁니다
CI 비용맥 러너가 필요합니다 — 비용과 대기 시간을 미리 계산해 두세요
디버깅 경험iOS 에서 공유 코드에 브레이크포인트가 걸리는지 먼저 확인합니다

파일럿에 좋은 후보

  • 할인·배송비 계산 규칙
  • 입력값 검증 규칙(전화번호·주소)
  • 서버 응답 모델과 파싱
  • 기능 플래그 · A/B 분기 판단

파일럿에 나쁜 후보

  • 결제 · 인증 — 플랫폼 SDK 의존이 큽니다
  • 카메라 · 블루투스 등 하드웨어
  • 푸시 알림 — 양쪽 구조가 근본적으로 다릅니다
  • 화면 전환 — 네비게이션 모델이 다릅니다

성공 판정 기준

  • 같은 버그를 두 번 고치는 일이 줄었나
  • 스펙 변경 반영이 한 번에 끝나나
  • iOS 팀의 빌드 대기 시간이 늘지 않았나
  • 새로 온 사람이 구조를 이해하는 데 걸리는 시간
가장 흔한 실패 패턴은 기술이 아니라 순서입니다 — UI 공유부터 시작하는 것. 가장 어렵고, 가장 눈에 띄고, 가장 되돌리기 힘든 지점을 첫 단추로 고르면 거의 예외 없이 중단됩니다. 계산 로직 한 덩어리부터 시작하세요.
KMP 16

도입 판단 · 체크리스트

기술보다 조직의 결정이 더 큰 변수입니다. 실제로 도입할 때 확인할 것들을 정리합니다.

도입 전 확인

  • iOS 팀이 동의했는가 — 가장 중요한 항목입니다
  • 맥 빌드 환경이 있는가 (CI 포함)
  • 쓰는 라이브러리가 KMP 를 지원하는가
  • 파일럿 범위를 정했는가 (기능 하나 · 화면 하나)
  • 공유 모듈을 누가 유지보수하는가 (오너십)
  • 실패했을 때 되돌릴 수 있는가 (공유 모듈만 걷어내면 되게 설계)

운영하면서 지킬 것

  • 공개 API 는 Swift 친화적으로 — 제네릭·sealed 노출에 주의
  • SKIE 를 붙여 iOS 개발 경험을 지킨다
  • commonTest 를 두껍게 — 공유 코드의 신뢰가 곧 도입의 근거
  • 플랫폼 코드는 얇게 — 로직이 양쪽에 흩어지면 이득이 사라진다
  • 버전 업 시 Kotlin·Compose MP·SKIE 호환표를 확인
상황권장 구성
안드로이드 앱만 있음 · iOS 계획지금부터 Ktor·kotlinx.serialization 사용 → 나중 전환이 쉬워진다
두 앱이 이미 있음도메인·API 부터 공유 모듈로 추출 (UI 는 그대로)
사내 도구·B2B 앱Compose Multiplatform 으로 UI 까지 공유 — 비용 대비 효과가 크다
소비자용 앱 · 브랜드 경험 중요로직만 공유, UI 는 네이티브
서버도 코틀린JVM 타깃을 추가해 DTO·검증 규칙을 서버와 공유
다음 탭 예고 — 여기서 앱 이야기는 마무리하고 대규모 트래픽으로 돌아갑니다. 코루틴으로 초당 수만 요청을 받는 서버를 만들 때의 병목·캐시·분산 락·이벤트 처리를 코틀린 코드로 다룹니다.
코루틴 서버의 병목 · 캐시 · 분산 락 · KAFKA

코루틴은
병목을 옮길 뿐
없애지 않는다

스레드 제약이 풀리면 병목은 DB 커넥션·외부 API·락으로 옮겨갑니다. 이 탭은 어디가 먼저 터지는지를 순서대로 짚고, 캐시·분산 락·멱등성·이벤트 처리를 코틀린 코드로 다룹니다.

14SECTIONS
3캐시 재난 유형
1원칙 — 측정하고 고친다
100%운영에서 실제로 겪는 문제
SCALE 01

트래픽을 숫자로 읽기

“트래픽이 많다”는 말로는 아무것도 결정할 수 없습니다. RPS · 지연 · 동시성 세 숫자로 바꿔야 서버를 몇 대 둘지가 나옵니다.

기본 계산 — 리틀의 법칙

동시 처리 수 = RPS × 평균 응답 시간(초)

예) 초당 2,000 요청 · 평균 응답 200ms
    → 동시에 처리 중인 요청 = 2000 × 0.2 = 400개

이 400 을 무엇으로 감당하는가?
  · MVC 블로킹     : 톰캣 스레드 400개 필요 (메모리 400MB+)
  · MVC + 가상스레드: 가상 스레드 400개 (가볍다) — 단 DB 커넥션은 별개
  · 코루틴/WebFlux : 코루틴 400개 (수백 KB)

그런데 진짜 한계는 대개 여기다
  · DB 커넥션 풀 50개 → DB 쿼리가 50ms 라면 초당 1,000 쿼리가 한계
  · 외부 API rate limit 100 RPS
  · Redis 커넥션 · 파일 디스크립터

봐야 할 지표

지표의미
RPS/TPS초당 요청·트랜잭션 수 — 피크 기준으로 산정
p50 / p95 / p99평균이 아니라 꼬리 지연을 본다
동시 접속WebSocket·SSE 라면 이게 핵심
에러율5xx + 타임아웃 비율
포화도커넥션 풀·큐·CPU 사용률
평균 응답 시간은 거짓말을 합니다. 평균 100ms 인데 p99 가 3초면 100명 중 1명은 3초를 기다립니다. SLO 는 언제나 p95·p99 기준으로 정하세요.
// 스프링에서 지표 노출 — 이것부터 하고 시작한다
@Configuration
class MetricsConfig {
    @Bean
    fun commonTags(): MeterRegistryCustomizer<MeterRegistry> =
        MeterRegistryCustomizer { it.config().commonTags("app", "order-service", "env", activeProfile) }
}

// 커스텀 지표 — 도메인 사건을 센다
@Service
class OrderService(registry: MeterRegistry) {
    private val placed = registry.counter("orders.placed")
    private val failed = registry.counter("orders.failed", "reason", "stock")
    private val latency = Timer.builder("orders.place.latency")
        .publishPercentiles(0.5, 0.95, 0.99)      // ★ 백분위 노출
        .register(registry)

    suspend fun place(cmd: PlaceOrderCommand): Order {
        val sample = Timer.start()
        return try {
            doPlace(cmd).also { placed.increment() }
        } finally {
            sample.stop(latency)
        }
    }
}
SCALE 02

병목 지도 — 어디가 먼저 터지나

코루틴을 도입하면 스레드 병목이 사라집니다. 그러면 다음 병목이 드러납니다. 순서를 알고 있으면 장애 때 30분을 아낍니다.

요청이 지나가는 길과 각 구간의 한계 요청 수신 FD · 백로그 코루틴 처리 여기는 이제 여유롭다 커넥션 풀 보통 여기서 막힌다 DB · 외부 API 진짜 한계 병목이 옮겨가는 순서 ① 스레드 풀 고갈 — 코루틴/가상 스레드로 해결됨 (톰캣 200 스레드 벽) ② DB 커넥션 풀 대기 — 코루틴이 아무리 많아도 커넥션은 유한하다 ★ 가장 흔함 ③ DB 자체 — 슬로우 쿼리 · 락 경합 · 인덱스 부재 ④ 외부 API rate limit · 네트워크 대역 · 캐시 노드 CPU
코루틴 도입 후 커넥션 풀 크기를 조정하지 않으면 대기만 길어집니다 — 반드시 함께 봐야 합니다.
// 커넥션 풀과 코루틴 동시성을 '맞춘다'
// application.yml
// spring.datasource.hikari.maximum-pool-size: 30

@Configuration
class DbConfig {
    // 커넥션 풀 크기와 같은 수로 제한한 디스패처
    @Bean
    fun dbDispatcher(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(30)
}

@Repository
class OrderRepository(
    private val jdbc: JdbcTemplate,
    private val dbDispatcher: CoroutineDispatcher,
) {
    suspend fun find(id: Long): Order? = withContext(dbDispatcher) {
        jdbc.queryForObject(SQL, mapper, id)
    }
}

// 이렇게 하면
// · 커넥션 풀 대기가 코루틴 대기로 바뀐다 (스레드를 안 붙잡는다)
// · 풀 고갈로 인한 타임아웃 폭발 대신 자연스러운 백프레셔가 걸린다

// 풀 크기 공식 (참고) — 코어 수 × 2 + 디스크 수 정도에서 시작해 측정으로 조정
// 무작정 키우면 DB 쪽 컨텍스트 스위칭이 늘어 오히려 느려진다
SCALE 03

캐시 전략 — 2단 구성

가장 효과가 큰 최적화입니다. 로컬 캐시(Caffeine)와 분산 캐시(Redis)를 함께 쓰면 각각의 약점을 서로 메웁니다.

2단 캐시 — 위로 갈수록 빠르고, 아래로 갈수록 정확하다 요청 ① 로컬 Caffeine ~100ns · 서버마다 다름 ② Redis ~1ms · 전 서버 공유 ③ DB ~10ms+ 조회 결과로 ①②를 함께 채운다 로컬 캐시가 맞는 것 거의 안 바뀌는 설정 · 코드 테이블 짧은 TTL 로 두면 갱신 지연도 감수 가능 Redis 가 맞는 것 세션 · 재고 · 랭킹 · 분산 락 모든 서버가 같은 값을 봐야 하는 것
로컬 캐시는 서버마다 값이 다를 수 있다는 점을 감수할 수 있는 데이터에만 씁니다.
@Component
class ProductCache(
    private val redis: RedisCoroutinesCommands<String, String>,
    private val repo: ProductRepository,
    private val json: Json,
) {
    // 1단: 로컬 — 짧은 TTL (서버 간 불일치를 감수할 수 있는 시간)
    private val local: Cache<Long, Product> = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(30.seconds.toJavaDuration())
        .recordStats()
        .build()

    suspend fun get(id: Long): Product? {
        local.getIfPresent(id)?.let { return it }                       // ① 로컬

        redis.get("product:$id")?.let { raw ->                          // ② Redis
            val p = json.decodeFromString<Product>(raw)
            local.put(id, p)
            return p
        }

        val p = repo.findAsync(id) ?: return null                       // ③ DB
        redis.setex("product:$id", 300, json.encodeToString(p))         // 5분
        local.put(id, p)
        return p
    }

    // 무효화 — Redis 삭제 + 다른 서버의 로컬 캐시도 비워야 한다
    suspend fun evict(id: Long) {
        redis.del("product:$id")
        local.invalidate(id)
        redis.publish("cache:evict", "product:$id")     // Pub/Sub 으로 전파
    }
}

// 다른 서버들은 구독해서 로컬 캐시를 비운다
@Component
class CacheEvictSubscriber(private val cache: ProductCache) {
    // Redis Pub/Sub 을 Flow 로 받아 처리
}
캐시는 정합성을 포기하는 대가로 속도를 얻는 것입니다. TTL 을 정하는 것 = 얼마나 오래된 데이터를 허용할지 정하는 것입니다. 가격·재고처럼 틀리면 사고인 데이터는 캐시하지 않거나 TTL 을 아주 짧게, 또는 변경 시 즉시 무효화 경로를 반드시 만드세요.
SCALE 04

Redis — 코루틴으로

단순 캐시를 넘어 랭킹·중복 제거·큐·분산 락까지 담당합니다. Lettuce 의 코루틴 API 를 쓰면 논블로킹으로 다룰 수 있습니다.

자료구조별 쓰임

타입실전 용도
String캐시 · 카운터(INCR) · 분산 락
Hash객체 필드 단위 갱신 (세션·프로필)
List간단한 큐 · 최근 목록
Set중복 제거 · 태그 · 참여자
Sorted Set랭킹 · 시간순 인덱스 · 지연 큐
HyperLogLog대략적인 방문자 수 (메모리 12KB)
Stream이벤트 로그 · 소비자 그룹
Bitmap출석 체크 · 플래그 (1비트/사용자)

코틀린 코드

// Lettuce 코루틴 API
val conn = RedisClient.create("redis://localhost").connect()
val redis = conn.coroutines()          // RedisCoroutinesCommands

// 랭킹 — 실시간 인기 상품
suspend fun addScore(productId: Long, score: Double) {
    redis.zincrby("ranking:today", score, productId.toString())
}
suspend fun top10(): List<Long> =
    redis.zrevrange("ranking:today", 0, 9).map { it.toLong() }.toList()

// 카운터 — 조회수
suspend fun increaseView(id: Long): Long = redis.incr("view:$id") ?: 0

// 중복 방지 — 오늘 이미 본 사람인가
suspend fun markViewed(userId: Long, id: Long): Boolean =
    redis.sadd("viewed:$id:${today()}", userId.toString()) == 1L

// MGET — 여러 키를 한 번의 왕복으로
suspend fun getMany(ids: List<Long>): List<String?> =
    redis.mget(*ids.map { "product:$it" }.toTypedArray())
        .map { if (it.hasValue()) it.value else null }.toList()   // 없는 키에 .value 를 부르면 예외
운영에서 조심할 것 — ① KEYS * 금지(전체 스캔으로 Redis 가 멈춥니다 — SCAN 사용), ② 빅키(수십 MB 값·수백만 원소 컬렉션)는 삭제할 때도 멈춤을 유발, ③ TTL 없는 키가 쌓이면 메모리가 찹니다(maxmemory-policy 설정), ④ Redis 는 단일 스레드라 무거운 명령 하나가 전체를 막습니다.
SCALE 05

캐시 3대 재난

캐시는 잘 돌 때는 조용하다가, 무너질 때 한꺼번에 무너집니다. 세 가지 유형을 알고 대비하면 DB 가 캐시 때문에 죽는 사고를 막을 수 있습니다.

세 가지 유형 — 증상은 같다: DB 가 갑자기 죽는다 ① 스탬피드 인기 키 하나가 만료되는 순간 수천 요청이 동시에 DB 로 해결: 락 + 단일 갱신 ② 관통 없는 키를 계속 조회 캐시가 아무 역할을 못 한다 해결: null 도 캐시 · 블룸 필터 ③ 눈사태 많은 키가 동시에 만료 (배포 직후 · 일괄 적재 후) 해결: TTL 에 무작위 지터 스탬피드가 일어나는 순간 캐시 만료 동시 요청 3,000개가 전부 미스 DB 에 3,000 쿼리 💥 해결의 핵심 — 같은 키는 한 스레드만 갱신하고 나머지는 기다리게 한다
세 가지 모두 정상일 때는 안 보이다가 트래픽이 늘면 한꺼번에 터집니다.
@Component
class SafeCache(
    private val redis: RedisCoroutinesCommands<String, String>,
    private val json: Json,
) {
    private val locks = ConcurrentHashMap<String, Mutex>()

    // ① 스탬피드 방지 — 같은 키는 한 코루틴만 갱신
    suspend fun <T> get(
        key: String,
        ttl: Duration = 5.minutes,
        deserializer: KSerializer<T>,
        loader: suspend () -> T?,
    ): T? {
        redis.get(key)?.let { return decodeOrNull(it, deserializer) }

        val mutex = locks.computeIfAbsent(key) { Mutex() }
        return try {
            mutex.withLock {
                // 이중 검사 — 기다리는 동안 다른 코루틴이 채웠을 수 있다
                redis.get(key)?.let { return@withLock decodeOrNull(it, deserializer) }

                val value = loader()
                // ② 관통 방지 — null 도 짧게 캐시한다
                val ttlSec = if (value == null) 30 else jitter(ttl)
                redis.setex(key, ttlSec, value?.let { json.encodeToString(deserializer, it) } ?: NULL_MARK)
                value
            }
        } finally {
            locks.remove(key)
        }
    }

    // ③ 눈사태 방지 — TTL 에 ±20% 무작위를 섞는다
    private fun jitter(ttl: Duration): Long {
        val base = ttl.inWholeSeconds
        return base + Random.nextLong(-base / 5, base / 5)
    }

    // 캐시에서 꺼낸 문자열 → 값. "없음"으로 저장해 둔 표식이면 null 을 돌려준다
    private fun <T> decodeOrNull(raw: String, deserializer: KSerializer<T>): T? =
        if (raw == NULL_MARK) null else json.decodeFromString(deserializer, raw)

    companion object {
        // 값이 없다는 사실 자체를 캐시할 때 넣는 표식.
        // 실제 JSON 과 절대 겹치지 않도록 일부러 이상한 문자열을 씁니다
        private const val NULL_MARK = "__NULL__"
    }
}

더 나은 방법 — 미리 갱신

// TTL 이 끝나기 전에 백그라운드로 갱신 (사용자는 항상 캐시 히트)
data class Cached<T>(val value: T, val refreshAt: Instant, val expireAt: Instant)

suspend fun <T> getWithRefreshAhead(key: String, loader: suspend () -> T): T {
    val cached = load<T>(key)
    if (cached != null) {
        if (Clock.System.now() > cached.refreshAt) {
            appScope.launch { runCatching { store(key, loader()) } }   // 비동기 갱신
        }
        return cached.value                                            // 즉시 반환
    }
    return loader().also { store(key, it) }
}
// Caffeine 은 refreshAfterWrite 로 이 동작을 기본 제공한다

핫키 — 특정 키에 트래픽 집중

  • 증상 — Redis 노드 하나의 CPU 만 100%. 이벤트 상품·인기 게시물에서 발생
  • 대응 ① — 그 키만 로컬 캐시에 짧게 (네트워크 왕복 자체를 없앤다)
  • 대응 ② — 키를 여러 개로 쪼갠다(hot:1~hot:10 무작위 선택)
  • 대응 ③ — 읽기 복제본으로 분산
  • 탐지 — redis-cli --hotkeys · 모니터링에서 키별 QPS
SCALE 06

동시성 제어 · 분산 락

선착순 쿠폰, 재고 차감, 좌석 예약 — 서버가 여러 대일 때 단일 JVM 의 Mutex로는 막을 수 없습니다.

서버가 두 대 이상이면 @Synchronized 는 아무 소용이 없습니다 ① JVM 락 — 각자의 세계 안에서만 유효 서버 A 서버 B 서로의 락을 전혀 모릅니다 동시에 재고를 읽고 동시에 차감 → 초과 판매 ② 한 곳에서 조정 — 분산 락 · 원자 연산 Redis · DB — 판단하는 곳은 하나 SET key value NX PX 3000 — 얻은 하나만 진행 또는 UPDATE ... WHERE stock >= 1 한 방으로 분산 락을 쓸 때 반드시 챙길 세 가지 ① 만료 시간(TTL) 락을 쥔 채 죽으면 영원히 잠긴 채 남습니다 ② 소유자 확인 값을 비교한 뒤 삭제(Lua) 남의 락을 풀지 않게 ③ 락보다 원자 연산 쿼리 한 줄로 되면 락 자체가 필요 없습니다
분산 락은 마지막 수단입니다 — 조건부 UPDATE 나 DB 유니크 제약으로 풀리는지 먼저 보세요.
방법특징 · 언제
원자적 UPDATEupdate ... set qty = qty - 1 where qty >= 1 — 가장 싸고 확실. 가능하면 이걸로
낙관적 락버전 컬럼 + 재시도. 충돌이 드물 때
비관적 락SELECT FOR UPDATE. 경합이 심하면 DB 부하가 커진다
Redis 분산 락DB 밖의 자원까지 보호. Redisson 권장
Redis 원자 연산DECR·Lua 스크립트 — 재고 카운터에 매우 빠르다
단일 소비자 큐Kafka 파티션 키로 순서 보장 — 근본적인 해법
// ① 가장 단순하고 강력한 방법 — DB 원자적 UPDATE
@Modifying
@Query("update Stock s set s.quantity = s.quantity - :qty where s.productId = :id and s.quantity >= :qty")
fun decrease(id: Long, qty: Int): Int      // 0 이면 재고 부족

@Transactional
fun order(productId: Long, qty: Int) {
    val updated = stockRepo.decrease(productId, qty)
    check(updated == 1) { "재고가 부족합니다" }
}
// 락도 재시도도 필요 없다 — DB 가 원자성을 보장한다

// ② Redis 원자 연산 — DB 부하 없이 선착순 처리
suspend fun tryIssueCoupon(couponId: Long, userId: Long): Boolean {
    // 중복 참여 차단 (SET 은 원자적)
    val first = redis.sadd("coupon:$couponId:users", userId.toString()) == 1L
    if (!first) return false

    val left = redis.decr("coupon:$couponId:stock") ?: -1
    if (left < 0) {
        redis.incr("coupon:$couponId:stock")                       // 되돌린다
        redis.srem("coupon:$couponId:users", userId.toString())
        return false
    }
    // 실제 발급은 큐로 넘긴다 (DB 쓰기를 비동기화)
    outbox.publish(CouponIssued(couponId, userId))
    return true
}

// ③ Redisson 분산 락 — 코루틴에서
suspend fun withDistributedLock(key: String, block: suspend () -> Unit) {
    val lock = redisson.getLock(key)
    // ★ Redisson 락의 주인은 '스레드 id' — 코루틴은 재개될 때 스레드가 바뀌므로 고정 id 를 직접 넘긴다
    val ownerId = Random.nextLong()
    val acquired = lock.tryLockAsync(3, 10, TimeUnit.SECONDS, ownerId).await()   // 대기 3초, 임대 10초
    check(acquired) { "락 획득 실패: $key" }
    try {
        block()
    } finally {
        lock.unlockAsync(ownerId).await()   // 임대가 이미 끝났다면 여기서 예외 — 작업이 너무 길었다는 신호
    }
}
분산 락의 함정 — ① 임대 시간(TTL)이 지나면 다른 서버가 락을 얻습니다 — 작업이 그보다 오래 걸리면 동시에 실행됩니다. ② 락 안에서 외부 API 를 부르지 마세요 — 임대 시간을 넘기기 쉽습니다. ③ 락은 성능을 희생합니다 — 원자적 UPDATE 로 풀 수 있으면 그쪽이 훨씬 낫습니다. ④ Redis 단일 노드 장애 시 락이 두 번 나갈 수 있습니다(Redlock 논쟁) — 돈이 오가는 처리는 DB 제약조건으로 최종 방어선을 두세요.
SCALE 07

멱등성 — 중복 요청 막기

네트워크는 언제나 불확실합니다. 클라이언트가 같은 요청을 두 번 보내거나, 재시도가 이미 처리된 요청을 또 보냅니다. 두 번 눌러도 한 번만 처리돼야 합니다.

같은 요청이 두 번 와도 결제가 두 번 되지 않게 하는 법 클라이언트 응답이 늦어 재시도 Idempotency-Key 요청마다 고유한 UUID 하나 키 저장소 조회 Redis 또는 DB 유니크 인덱스 ① 처음 보는 키 실제로 처리하고, 결과를 키와 함께 저장합니다 그 응답을 클라이언트에 돌려줍니다 ② 본 적 있는 키 처리하지 않고 저장된 응답만 그대로 반환 결제는 끝까지 한 번만 일어납니다 키 저장과 실제 처리가 같은 트랜잭션 안에 있거나, 유니크 제약으로 중복 INSERT 가 막혀야 진짜 멱등입니다.
"재시도해도 안전한가"는 클라이언트가 아니라 서버가 보장해야 합니다 — 네트워크는 언제나 두 번 보냅니다.
// 1) 클라이언트가 요청마다 고유 키를 보낸다
@PostMapping("/orders")
suspend fun create(
    @RequestHeader("Idempotency-Key") key: String,
    @RequestBody @Valid req: CreateOrderRequest,
): OrderResponse = idempotent(key, req) { service.place(req.toCommand()) }

// 2) 처리 결과를 키와 함께 저장하고, 같은 키가 오면 저장된 결과를 돌려준다
@Component
class IdempotencyService(
    private val redis: RedisCoroutinesCommands<String, String>,
    private val json: Json,
) {
    suspend fun <T> execute(
        key: String,
        requestHash: String,
        serializer: KSerializer<T>,
        ttl: Duration = 24.hours,
        block: suspend () -> T,
    ): T {
        val redisKey = "idem:$key"

        // 이미 처리됐다면 저장된 응답을 돌려준다
        redis.get(redisKey)?.let { stored ->
            if (stored == PROCESSING) throw ConflictException("같은 요청이 처리 중입니다")   // 아직 결과가 없다
            val record = json.decodeFromString<IdemRecord>(stored)
            require(record.requestHash == requestHash) { "같은 키로 다른 요청이 왔습니다" }
            return json.decodeFromString(serializer, record.response)
        }

        // 선점 — SET NX 로 '처리 중' 표시 (동시 요청 차단)
        val acquired = redis.set(redisKey, PROCESSING, SetArgs().nx().ex(60)) != null
        if (!acquired) throw ConflictException("같은 요청이 처리 중입니다")

        return try {
            val result = block()
            redis.setex(redisKey, ttl.inWholeSeconds,
                json.encodeToString(IdemRecord(requestHash, json.encodeToString(serializer, result))))
            result
        } catch (e: Throwable) {
            redis.del(redisKey)        // 실패하면 재시도할 수 있게 지운다
            throw e
        }
    }
}

// 3) 최종 방어선은 DB 제약조건 — 캐시가 날아가도 중복이 막힌다
// CREATE UNIQUE INDEX uq_order_idem ON orders(idempotency_key);

어디에 적용하나

  • 결제·주문·포인트 적립 — 두 번 처리되면 사고인 모든 것
  • 메시지 소비(Kafka) — 재전달이 기본이라 필수
  • 웹훅 수신 — 외부 서비스가 여러 번 보냅니다
  • 재시도가 있는 모든 호출 — 재시도 = 중복 가능성
GET·DELETE 는 원래 멱등합니다. 문제는 POST입니다. PUT 도 “같은 값으로 덮어쓰기”라면 멱등이지만, “증가시키기” 같은 연산은 아닙니다.

3층 방어

층역할
클라이언트버튼 비활성화 · 요청 키 생성
애플리케이션Redis 멱등 키 — 빠르게 걸러낸다
DB유니크 제약 — 최종 방어선. 절대 빠뜨리지 말 것

캐시는 언젠가 비워지고, 애플리케이션은 배포로 재시작됩니다. DB 제약조건만이 영구적입니다.

SCALE 08

Kafka · 비동기 처리

주문 하나에 알림·정산·검색 색인·통계가 따라붙으면 응답이 느려집니다. “지금 꼭 해야 하는 일”과 “나중에 해도 되는 일”을 나누는 것이 큐의 본질입니다.

발행 — 코틀린

@Component
class OrderEventPublisher(
    private val kafka: KafkaTemplate<String, String>,
    private val json: Json,
) {
    suspend fun publish(event: OrderEvent) = withContext(Dispatchers.IO) {
        kafka.send(
            ProducerRecord(
                "order-events",
                event.orderId.toString(),          // ★ 파티션 키 = 순서 보장 단위
                json.encodeToString(event),
            ).apply {
                headers().add("eventType", event::class.simpleName!!.toByteArray())
                headers().add("traceId", MDC.get("traceId").orEmpty().toByteArray())
            }
        ).await()                                   // 전송 확인까지 기다린다
    }
}

// 이벤트는 sealed 로 — 소비 쪽에서 when 이 완전해진다
@Serializable
sealed interface OrderEvent {
    val orderId: Long
    @Serializable data class Placed(override val orderId: Long, val userId: Long, val total: Long) : OrderEvent
    @Serializable data class Paid(override val orderId: Long, val txId: String) : OrderEvent
    @Serializable data class Canceled(override val orderId: Long, val reason: String) : OrderEvent
}

소비 — 멱등하게

@Component
class OrderEventConsumer(
    private val notifier: Notifier,
    private val processed: ProcessedEventRepository,
    private val json: Json,
) {
    @KafkaListener(topics = ["order-events"], groupId = "notification")
    fun consume(record: ConsumerRecord<String, String>, ack: Acknowledgment) {
        val eventId = record.headers().lastHeader("eventId")?.value()?.decodeToString()
            ?: "${record.topic()}-${record.partition()}-${record.offset()}"

        // ★ 재전달이 기본이므로 반드시 멱등하게
        if (processed.exists(eventId)) { ack.acknowledge(); return }

        val event = json.decodeFromString<OrderEvent>(record.value())
        when (event) {
            is OrderEvent.Placed -> notifier.sendOrderPlaced(event)
            is OrderEvent.Paid -> notifier.sendPaid(event)
            is OrderEvent.Canceled -> notifier.sendCanceled(event)
        }

        processed.save(eventId)
        ack.acknowledge()          // 수동 커밋 — 처리 성공 후에만
    }
}
운영 포인트설정 · 판단
순서 보장같은 파티션 안에서만 보장 — 키를 주문 id 로 두면 그 주문의 순서는 지켜진다
전달 보장기본은 at-least-once(중복 가능) → 소비자가 멱등해야 한다
커밋 시점enable-auto-commit: false + 수동 ack — 처리 전에 커밋되면 유실
재시도·DLQ실패 메시지를 무한 재시도하면 파티션이 멈춥니다 — 몇 번 후 DLQ 로
컨슈머 랙가장 중요한 지표 — 밀리기 시작하면 파티션·인스턴스를 늘린다
파티션 수소비자 인스턴스 수의 한계 — 줄일 수 없으니 여유 있게
SCALE 09

아웃박스 · 사가

“DB 에 저장하고 메시지도 보낸다” — 이 둘은 한 트랜잭션이 아닙니다. 하나만 성공하면 데이터가 어긋납니다. 그 문제를 푸는 표준 패턴입니다.

이중 쓰기 문제와 해법 이중 쓰기 (문제) DB 저장 성공 Kafka 발행 실패 💥 알림이 영영 안 감 아웃박스 패턴 하나의 DB 트랜잭션 orders INSERT + outbox INSERT — 함께 커밋 또는 함께 롤백 발행기 (폴링·CDC) 미발행 행을 읽어 전송 Kafka 실패하면 다시 시도 DB 에 남아 있으므로 언젠가는 반드시 발행된다 (최종 일관성)
메시지가 늦게 갈 수는 있어도 사라지지는 않습니다 — 이것이 아웃박스의 보장입니다.
@Entity
@Table(name = "outbox")
class OutboxEvent(
    @Id @GeneratedValue val id: Long = 0,
    val aggregateType: String,
    val aggregateId: String,
    val eventType: String,
    @Column(columnDefinition = "text") val payload: String,
    var published: Boolean = false,
    val createdAt: Instant = Instant.now(),
)

@Service
class OrderService(
    private val orderRepo: OrderRepository,
    private val outboxRepo: OutboxRepository,
    private val json: Json,
) {
    @Transactional                     // ★ 같은 트랜잭션
    fun place(cmd: PlaceOrderCommand): Order {
        val order = orderRepo.save(Order(cmd))
        outboxRepo.save(OutboxEvent(
            aggregateType = "Order",
            aggregateId = order.id.toString(),
            eventType = "OrderPlaced",
            payload = json.encodeToString(OrderEvent.Placed(order.id, order.userId, order.total)),
        ))
        return order
    }
}

@Component
class OutboxPublisher(
    private val outboxRepo: OutboxRepository,
    private val kafka: KafkaTemplate<String, String>,
    private val tx: TransactionTemplate,
) : DisposableBean {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("outbox"))

    @PostConstruct
    fun start() {
        scope.launch {
            while (isActive) {
                // ★ 같은 클래스 안에서 부르면 @Transactional 프록시를 안 탄다 → TransactionTemplate 로 감싼다
                runCatching { tx.executeWithoutResult { publishBatch() } }
                    .onFailure { log.error("아웃박스 발행 실패", it) }
                delay(500)
            }
        }
    }

    private fun publishBatch() {           // FOR UPDATE 라 반드시 트랜잭션 안에서
        // 여러 인스턴스가 함께 돌아도 안전하게 (SKIP LOCKED)
        val events = outboxRepo.findUnpublishedForUpdate(limit = 100)
        events.forEach { e ->
            kafka.send("order-events", e.aggregateId, e.payload).get(3, TimeUnit.SECONDS)
            e.published = true
        }
    }

    override fun destroy() { scope.cancel() }
}

사가 — 여러 서비스에 걸친 트랜잭션

// 분산 환경에서는 2단계 커밋 대신 '보상 트랜잭션'을 쓴다
// 주문 → 재고 차감 → 결제 → 배송 요청
// 결제가 실패하면? 재고를 되돌린다 (보상)

sealed interface SagaStep {
    suspend fun execute(ctx: SagaContext)
    suspend fun compensate(ctx: SagaContext)
}

class ReserveStock(private val client: StockClient) : SagaStep {
    override suspend fun execute(ctx: SagaContext) { client.reserve(ctx.orderId, ctx.items) }
    override suspend fun compensate(ctx: SagaContext) { client.release(ctx.orderId) }
}

suspend fun runSaga(steps: List<SagaStep>, ctx: SagaContext) {
    val done = mutableListOf<SagaStep>()
    try {
        steps.forEach { it.execute(ctx); done += it }
    } catch (e: Exception) {
        done.reversed().forEach {                       // 역순으로 보상
            runCatching { it.compensate(ctx) }
                .onFailure { c -> log.error("보상 실패 — 수동 개입 필요", c) }
        }
        throw e
    }
}

현실적인 조언

  • 사가는 복잡합니다. 서비스를 나누기 전에 정말 나눠야 하는지 먼저 물으세요 — 한 DB 트랜잭션으로 끝나면 그게 최선입니다
  • 보상이 불가능한 작업(메일 발송·외부 결제 승인)은 순서를 뒤로 미룹니다
  • 보상 실패는 반드시 알림으로 — 사람이 개입해야 하는 상태입니다
  • Debezium(CDC)을 쓰면 아웃박스 폴링 없이 DB 변경 로그에서 바로 이벤트를 만들 수 있습니다
최종 일관성을 사용자에게 설명하는 것도 설계입니다 — “주문이 접수되었습니다(처리 중)”처럼 상태를 드러내면 기술적 지연이 자연스러운 UX 가 됩니다.
SCALE 10

격벽 · 부하 차단

한 기능의 장애가 전체 서비스를 멈추게 하지 않도록 자원을 나눕니다. 코루틴에서는 세마포어와 전용 디스패처가 그 도구입니다.

// ① 격벽(bulkhead) — 기능별로 동시 실행 수를 나눈다
@Component
class Bulkheads {
    val payment = Semaphore(20)      // 결제: 중요 → 넉넉히
    val recommend = Semaphore(5)     // 추천: 부가 → 적게 (죽어도 서비스는 산다)
    val report = Semaphore(2)        // 리포트: 무거움 → 최소
}

suspend fun recommend(userId: Long): List<Item> =
    bulkheads.recommend.withPermit {
        withTimeoutOrNull(1.seconds) { client.recommend(userId) } ?: popularItems()
    }
// 추천 API 가 느려져도 결제 처리에는 영향이 없다

// ② 부하 차단(load shedding) — 감당 못 할 요청은 빨리 거절한다
@Component
class LoadShedder(private val registry: MeterRegistry) {
    private val inFlight = AtomicInteger()
    private val max = 500

    suspend fun <T> execute(block: suspend () -> T): T {
        // 먼저 올리고 검사한다 — get() 후 increment 는 동시 요청이 함께 통과해 max 를 넘는다
        if (inFlight.incrementAndGet() > max) {
            inFlight.decrementAndGet()
            registry.counter("requests.shed").increment()
            throw TooManyRequestsException("잠시 후 다시 시도해 주세요")   // 429
        }
        return try { block() } finally { inFlight.decrementAndGet() }
    }
}
// 느리게 다 받아 주는 것보다 '빨리 거절'하는 편이 전체적으로 낫다
// (대기 큐가 길어지면 이미 포기한 사용자의 요청까지 처리하게 된다)

우선순위를 두는 방법

등급정책
필수 (결제·주문)자원 우선 배정 · 마지막까지 지킨다
중요 (조회·검색)캐시로 대체 · 성능 저하 허용
부가 (추천·배너)과부하 시 먼저 끈다 — 기능 플래그로 즉시 차단
// 기능 플래그로 부가 기능 즉시 끄기
if (features.isEnabled("recommendation")) {
    runCatching { recommend(userId) }.getOrDefault(emptyList())
} else emptyList()

타임아웃 예산 배분

// 전체 예산 안에서 각 단계에 나눠 준다
suspend fun handle(req: Request): Response = withTimeout(3.seconds) {
    val user = withTimeout(500.milliseconds) { userClient.get(req.userId) }
    val items = withTimeout(1.seconds) { itemClient.list(req.ids) }
    // 남은 시간으로 부가 데이터 — 못 받아도 진행
    val ads = withTimeoutOrNull(300.milliseconds) { adClient.list() } ?: emptyList()
    Response(user, items, ads)
}

// 규칙
// · 하위 타임아웃 합 < 상위 타임아웃 (여유 포함)
// · 상위가 이미 취소됐으면 하위 호출을 시작하지 않는다 (코루틴이 자동 처리)
// · 클라이언트 타임아웃 > 서버 타임아웃 (안 그러면 서버가 헛일을 한다)
SCALE 11

처리율 제한 · 대기열

티켓 오픈·한정 판매처럼 순간 트래픽이 평소의 100배가 되는 경우입니다. 서버를 100배 늘릴 수 없으니 들어오는 양을 조절합니다.

고정 윈도우의 경계 문제 — 그래서 토큰 버킷을 씁니다 ① 고정 윈도우 (분당 100건) — 가로축은 실제 시간입니다 윈도우 1 · 0~60초 · 100건 통과 윈도우 2 · 60~120초 · 100건 통과 59초에 100건 61초에 다시 100건 0 30 60 90 120 경과(초) 이 2초 사이에 200건 — 정해 둔 상한의 두 배가 통과합니다 ② 토큰 버킷 (초당 2개 충전 · 최대 100개) 버킷 — 남은 토큰 수 일정 속도로 채워진다 요청 1건 = 토큰 1개 없으면 즉시 429 동작 경계가 없습니다 — 평균 속도는 충전 속도로, 순간 몰림은 버킷 크기(100개)까지만 허용됩니다 Redis 로 만들 때는 "읽고 · 빼고 · 쓰기"를 Lua 스크립트 하나로 묶으세요 — 나누면 그 자체가 경쟁 상태가 됩니다.
거절할 때는 Retry-After 헤더를 같이 주세요 — 안 주면 클라이언트가 곧바로 다시 몰려옵니다.

Rate Limit — 가장 단순한 고정 윈도 카운터

// Redis Lua 스크립트로 원자적으로 처리 — 이건 '분당 N건' 고정 윈도입니다
// (위 그림의 경계 몰림이 문제라면 토큰 버킷: Bucket4j · Redisson RRateLimiter)
private val script = """
    local key = KEYS[1]
    local limit = tonumber(ARGV[1])
    local window = tonumber(ARGV[2])
    local current = redis.call('INCR', key)
    if current == 1 then redis.call('EXPIRE', key, window) end
    return current
""".trimIndent()

suspend fun allow(userId: Long, limit: Int = 100, windowSec: Int = 60): Boolean {
    val count = redis.eval<Long>(script, ScriptOutputType.INTEGER,
        arrayOf("rate:$userId:${epochMinute()}"), limit.toString(), windowSec.toString())
    return (count ?: 0) <= limit
}

// 초과하면 429 + Retry-After 헤더
@Component
class RateLimitFilter(private val limiter: RateLimiter) : WebFilter {
    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain) = mono {
        val userId = exchange.userId() ?: return@mono chain.filter(exchange).awaitFirstOrNull()
        if (!limiter.allow(userId)) {
            exchange.response.statusCode = HttpStatus.TOO_MANY_REQUESTS
            exchange.response.headers.set("Retry-After", "60")
            return@mono exchange.response.setComplete().awaitFirstOrNull()
        }
        chain.filter(exchange).awaitFirstOrNull()
    }
}

대기열 — 순번을 준다

// Sorted Set 으로 대기열 (점수 = 진입 시각)
suspend fun enqueue(userId: Long): Long {
    val now = System.currentTimeMillis().toDouble()
    redis.zadd("queue:event1", now, userId.toString())
    return rank(userId)
}

suspend fun rank(userId: Long): Long =
    (redis.zrank("queue:event1", userId.toString()) ?: -1) + 1

// 일정 주기로 앞에서부터 '입장 토큰'을 발급
@Scheduled(fixedDelay = 1000)
fun admit() = runBlocking {
    val batch = redis.zrange("queue:event1", 0, 99).toList()      // 초당 100명
    batch.forEach { userId ->
        redis.setex("admitted:$userId", 300, "1")                  // 5분간 유효
        redis.zrem("queue:event1", userId)
    }
}

// 클라이언트는 SSE·폴링으로 자기 순번을 본다
// "앞에 1,234명 남았습니다 (예상 3분)"
대기열의 진짜 가치는 UX 입니다. “서버 오류”보다 “1,234번째, 3분 예상”이 사용자를 훨씬 덜 화나게 합니다. 그리고 서버는 일정한 속도로만 일하면 되므로 예측 가능해집니다.
SCALE 12

데이터베이스 확장

애플리케이션은 서버를 늘리면 되지만 DB 는 그렇게 안 됩니다. 순서는 튜닝 → 캐시 → 읽기 분리 → 분할이고, 뒤로 갈수록 비쌉니다.

순서방법내용
①쿼리·인덱스 튜닝가장 싸다. 실행 계획을 보고 인덱스 추가 — 대부분 여기서 해결
②캐시읽기 부하를 DB 밖으로 (sc03)
③읽기 복제본조회를 복제본으로. 복제 지연을 감수해야 한다
④수직 확장돈으로 해결. 가장 빠른 임시방편
⑤테이블 분할파티셔닝 — 오래된 데이터 분리, 날짜 기준
⑥샤딩마지막 수단. 조인·트랜잭션·유니크 제약이 전부 어려워진다
// 읽기 복제본 분리 — 트랜잭션 readOnly 로 라우팅
@Configuration
class DataSourceConfig {
    @Bean
    fun routingDataSource(
        @Qualifier("writer") writer: DataSource,
        @Qualifier("reader") reader: DataSource,
    ): DataSource {
        val routing = object : AbstractRoutingDataSource() {
            override fun determineCurrentLookupKey(): Any =
                if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) "reader" else "writer"
        }.apply {
            setTargetDataSources(mapOf("writer" to writer, "reader" to reader))
            setDefaultTargetDataSource(writer)
            afterPropertiesSet()
        }
        // ★ JPA 는 트랜잭션 시작 때 커넥션을 먼저 잡는다 — 그 시점엔 readOnly 표시가 아직 없어 늘 writer 로 간다.
        //   Lazy 프록시로 감싸 '첫 쿼리 때' 커넥션을 얻게 해야 라우팅이 동작한다
        return LazyConnectionDataSourceProxy(routing)
    }
}

@Transactional(readOnly = true)     // → 복제본으로
fun list(): List<Order> = repo.findAll()

@Transactional                       // → 주 DB 로
fun place(cmd: PlaceOrderCommand): Order = repo.save(Order(cmd))

// ⚠️ 복제 지연 함정 — 방금 쓴 데이터를 바로 읽으면 없을 수 있다
@Transactional                       // 쓰기 직후 조회는 같은 트랜잭션(주 DB)에서
fun placeAndReturn(cmd: PlaceOrderCommand): OrderResponse {
    val saved = repo.save(Order(cmd))
    return saved.toResponse()        // 복제본을 다시 조회하지 않는다
}
샤딩은 정말 마지막입니다. 샤딩을 하면 여러 샤드에 걸친 조인·집계·트랜잭션이 애플리케이션의 몫이 되고, 샤드 키를 잘못 고르면 되돌리기가 거의 불가능합니다. 그 전에 아카이빙(오래된 데이터 분리)과 도메인별 DB 분리를 먼저 검토하세요.
SCALE 13

관측 가능성

대규모 시스템에서 “느리다”는 신고를 받고 원인을 찾는 데 걸리는 시간이 곧 장애 시간입니다. 메트릭·로그·추적 세 가지를 처음부터 갖춰야 합니다.

메트릭 — 무엇이 이상한가

// Micrometer + Prometheus
// RED 방법: Rate · Errors · Duration
registry.counter("http.requests", "path", path, "status", status)
registry.timer("http.duration", "path", path)

// USE 방법(자원): Utilization · Saturation · Errors
registry.gauge("db.pool.active", pool) { it.activeConnections.toDouble() }
registry.gauge("coroutine.inflight", counter) { it.get().toDouble() }

추적 — 어디가 느린가

// Micrometer Tracing + OpenTelemetry
// 요청 하나가 여러 서비스를 거치는 경로를 이어 준다
@Observed(name = "order.place")
suspend fun place(cmd: PlaceOrderCommand): Order

// 코루틴에서 컨텍스트 전파
withContext(observationRegistry.asContextElement()) {
    externalCall()
}

로그 — 무슨 일이 있었나

// 구조화 로그 + traceId
log.info("주문 생성 orderId={} userId={} amount={}",
         order.id, order.userId, order.total)

// 샘플링 — 대규모에서는 전부 남기면 비용이 폭발
if (Random.nextInt(100) < 1) {
    log.debug("상세 페이로드 {}", req)
}

알람은 증상에 건다

  • 좋은 알람 — “주문 API 5xx 가 5분간 1% 초과”, “p99 지연이 2초 초과”, “컨슈머 랙 10만 초과”
  • 나쁜 알람 — “CPU 80%”(그래서 사용자가 불편한가?), “디스크 사용량 70%”
  • 사용자가 느끼는 것에 걸어야 대응할 이유가 분명해집니다
  • 알람이 자주 울리면 사람이 무시하게 됩니다 — 임계값 관리가 중요

대시보드 최소 구성

패널내용
트래픽RPS · 상태 코드 분포
지연p50/p95/p99 (엔드포인트별)
에러5xx 비율 · 예외 종류별 카운트
자원커넥션 풀 · 코루틴 수 · GC · 메모리
의존 서비스외부 API 지연·실패율 · 서킷 상태
큐컨슈머 랙 · 아웃박스 미발행 건수
SCALE 14

부하 테스트 · 전체 체크리스트

추측으로 용량을 정하지 않습니다. 측정하고, 한계를 알고, 대비합니다.

k6 로 시나리오 짜기

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 200 },   // 서서히 올린다
    { duration: '5m', target: 200 },   // 유지 — 여기서 메모리 누수가 드러난다
    { duration: '2m', target: 1000 },  // 급증 (스파이크)
    { duration: '3m', target: 0 },     // 감소
  ],
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1500'],
    http_req_failed: ['rate<0.01'],
  },
};

export default function () {
  const res = http.get('https://api.example.com/orders?page=1', {
    headers: { Authorization: `Bearer ${__ENV.TOKEN}` },
  });
  check(res, { 'status 200': (r) => r.status === 200 });
  sleep(1);
}

테스트 종류와 목적

종류알아내는 것
부하 테스트예상 트래픽에서 SLO 를 지키는가
스트레스어디서 무너지는가 — 한계점 파악
스파이크급증에 견디는가 (오토스케일 속도)
내구몇 시간 돌렸을 때 메모리 누수·커넥션 누수
카오스DB·Redis 를 죽였을 때 정상적으로 실패하는가
부하 테스트는 운영과 같은 환경에서 해야 의미가 있습니다. 스펙이 낮은 개발 서버에서 잰 숫자는 참고도 되지 않습니다.

대규모 트래픽 체크리스트

영역확인
용량피크 RPS · 동시성 산정 · 커넥션 풀 크기 정합
캐시2단 구성 · 스탬피드 방지 · TTL 지터 · 무효화 경로
동시성원자적 UPDATE 우선 · 분산 락은 최소 · 세마포어로 제한
멱등성결제·주문에 멱등키 + DB 유니크 제약
비동기아웃박스로 유실 방지 · 소비자 멱등 · DLQ
격리기능별 격벽 · 타임아웃 예산 · 서킷 브레이커 · 부하 차단
DB인덱스 · 슬로우 쿼리 알람 · 읽기 분리 · 아카이빙 계획
관측RED/USE 지표 · traceId 전파 · 증상 기반 알람
검증부하·내구·카오스 테스트를 배포 전에
다음 탭 예고 — 전문가 탭에서는 K2 컴파일러가 코틀린 코드를 어떻게 바꾸는지, 코루틴이 바이트코드 수준에서 어떻게 동작하는지, 그리고 성능 측정·메모리 분석·아키텍처 설계까지 프레임워크 아래층을 다룹니다.
K2 컴파일러 · 바이트코드 · 코루틴 내부 · JVM

“이 문법이
무엇으로 바뀌는가”를
아는 사람

성능 논쟁이 추측에서 사실로 바뀌는 지점입니다. data class가 만드는 실제 바이트코드, suspend가 되는 상태 기계, 인라인이 지우는 객체, 널 검사가 넣는 명령 — 전부 눈으로 볼 수 있습니다. 그리고 그 지식이 측정과 최적화의 기준이 됩니다.

16SECTIONS
2컴파일러 세대 (K1 · K2)
1원칙 — 측정 없는 최적화 금지
100%직접 확인 가능한 내용
DEEP 01

K2 컴파일러 파이프라인

Kotlin 2.0 의 가장 큰 변화입니다. 프런트엔드가 FIR로 다시 쓰이면서 컴파일이 빨라지고 타입 추론이 정확해졌으며, 모든 타깃이 같은 프런트엔드를 씁니다.

K2 파이프라인 — 프런트엔드는 하나, 백엔드는 타깃별 소스 (.kt) 파서 → PSI FIR (프런트엔드 IR) 이름 해석 · 타입 추론 · 진단 IR (공통 중간 표현) 플러그인이 개입하는 층 JVM 백엔드 .class 바이트코드 JS · Wasm 백엔드 Native (LLVM) K2 의 이득 — 컴파일 속도 · 정확한 스마트 캐스트 · 일관된 진단 컴파일러 플러그인도 FIR·IR 두 층에서 동작한다
이전에는 타깃마다 프런트엔드가 따로 있어 동작이 미묘하게 달랐습니다 — K2 가 그것을 통일했습니다.

K2 로 좋아진 것들

  • 스마트 캐스트가 더 똑똑해졌습니다 — 이전에 안 되던 상황(람다 안, || 조건 결합 등)에서도 됩니다
  • 컴파일 속도 — 큰 프로젝트에서 체감이 큽니다
  • 일관된 에러 메시지 — 타깃별 차이가 줄었습니다
  • 새 문법의 기반 — 가드 조건·컨텍스트 파라미터가 여기 위에서 나왔습니다

컴파일러 옵션 살펴보기

kotlin {
    compilerOptions {
        // 경고를 에러로 (CI 에서 유용)
        allWarningsAsErrors.set(true)

        // 컨텍스트 파라미터 같은 실험 기능
        freeCompilerArgs.addAll(
            "-Xjsr305=strict",
            "-Xcontext-parameters",
            // 인터페이스 기본 구현 → 자바 default 메서드: 2.2 부터는 기본값(-jvm-default=enable)이 이미 이렇게 만든다
            //   (옛 -Xjvm-default=all 은 2.2 에서 폐기 예정 — 쓰지 않는다)
            "-Xlambdas=indy",                // 람다를 invokedynamic 으로 (기본값)
        )
        jvmTarget.set(JvmTarget.JVM_21)
    }
}

// 빌드 리포트로 어느 태스크가 느린지 보기
// ./gradlew build -Pkotlin.build.report.output=file
DEEP 02

코틀린 → 바이트코드

가장 실용적인 학습 도구입니다. Tools → Kotlin → Show Kotlin Bytecode → Decompile 한 번이면 “이 문법이 진짜 무엇인지”가 끝납니다.

코틀린 문법이 바이트코드에서 실제로 무엇이 되나 data class 클래스 + equals · hashCode · toString · copy · componentN 이 함께 생성됩니다 확장 함수 static 메서드 — 수신 객체가 첫 번째 인자로 들어갑니다 기본 인자 원본 + xxx$default 브리지 — 어느 인자가 생략됐는지 비트마스크로 전달합니다 프로퍼티 val · var private 필드 + getX() · setX() — 자바에서는 메서드로 보입니다 object static final INSTANCE 필드 하나를 가진 평범한 클래스입니다 suspend fun Continuation 인자가 하나 늘고, 본문이 상태 기계 클래스로 다시 쓰입니다 널 아님 파라미터 함수 첫 줄에 Intrinsics.checkNotNullParameter 호출이 삽입됩니다 inline fun 호출부에 본문이 복사되고, 자바 호환용 원본 메서드도 함께 남습니다
IntelliJ 의 Tools → Kotlin → Show Kotlin Bytecode → Decompile 로 이 표를 직접 확인할 수 있습니다.

data class 가 만드는 것

// 내가 쓴 코드
data class User(val name: String, val age: Int)

// 디컴파일 결과 (자바로 옮기면)
public final class User {
   private final String name;
   private final int age;
   public User(String name, int age) {
      Intrinsics.checkNotNullParameter(name, "name");   // ★ 널 검사가 삽입된다
      this.name = name; this.age = age;
   }
   public final String getName() { return this.name; }
   public final int getAge() { return this.age; }
   public final User copy(String name, int age) { ... }
   public String toString() { return "User(name=" + name + ", age=" + age + ")"; }
   public int hashCode() { return name.hashCode() * 31 + age; }
   public boolean equals(Object other) { ... }
   public final String component1() { return this.name; }
   public final int component2() { return this.age; }
}

확인해 볼 만한 것들

코틀린바이트코드에서 보이는 것
널 안전 파라미터Intrinsics.checkNotNullParameter
?. 체인분기(ifnull)로 펼쳐진다 — 객체 생성 없음
기본 인자foo$default(...) 합성 메서드 + 비트마스크
확장 함수수신 객체를 첫 인자로 받는 static 메서드
inline 함수호출 자리에 본문이 펼쳐져 있다
람다 (비-inline)Function1 구현 클래스 또는 invokedynamic
objectINSTANCE static 필드 + static 초기화
suspendContinuation 인자 + switch(label)
value class사라지고 원시 타입이 직접 쓰인다
// 기본 인자가 만드는 코드 — 왜 자바에서 못 쓰는지 알 수 있다
fun send(to: String, type: String = "일반", retry: Int = 3) { }

// 디컴파일
public static void send(String to, String type, int retry) { }        // 진짜 구현
public static void send$default(String to, String type, int retry,
                                int mask, Object marker) {
    if ((mask & 2) != 0) type = "일반";        // 비트마스크로 '안 넘긴 인자'를 판별
    if ((mask & 4) != 0) retry = 3;
    send(to, type, retry);
}
// 호출부: send$default("a@b.com", null, 0, 6, null)
// → 자바에서 이 시그니처를 직접 부르는 것은 사실상 불가능하다 (그래서 @JvmOverloads)
학습 루틴으로 추천합니다 — 새 문법을 배울 때마다 작은 예제를 만들고 디컴파일해 보세요. 문법이 “마법”에서 “평범한 자바 코드”로 바뀌고, 성능·상호운용에 대한 감각이 함께 생깁니다.
DEEP 03

코루틴 내부 — 디스패치와 스케줄러

상태 기계 변환은 코루틴 탭 19에서 봤습니다. 여기서는 그 다음 — 재개된 코루틴이 어느 스레드에서 어떻게 실행되는지입니다.

중단된 코루틴이 다시 실행되기까지 결과 도착 IO 완료 · delay 만료 resumeWith 호출 Continuation 이 상태를 안다 디스패처가 판단 지금 스레드로 충분한가? 아니면 큐로? CoroutineScheduler — 작업 훔치기(work-stealing) 워커마다 로컬 큐 · 비면 전역 큐 · 그래도 없으면 남의 큐에서 훔친다 CPU 워커 Default — 코어 수 제한 블로킹 워커 IO — 최대 64 (확장) 같은 풀을 공유한다 IO ↔ Default 전환은 저렴
Default 와 IO 는 스레드 풀을 공유합니다 — 한도만 다르므로 전환 비용이 작습니다.

디스패치를 건너뛰는 경우

// 이미 올바른 스레드에 있으면 디스패치하지 않는다 (isDispatchNeeded)
withContext(Dispatchers.IO) {
    // 이미 IO 위라면 스레드 전환 없이 그냥 실행된다
}

// Unconfined — 재개한 스레드에서 즉시 이어 실행 (디스패치 없음)
launch(Dispatchers.Unconfined) {
    println(Thread.currentThread().name)   // main
    delay(1)
    println(Thread.currentThread().name)   // kotlinx.coroutines.DefaultExecutor
}

// UNDISPATCHED — 첫 중단점까지는 현재 스레드에서 즉시
launch(start = CoroutineStart.UNDISPATCHED) { println("바로 실행") }

// 성능이 정말 중요한 경로에서는 '전환 횟수'를 줄이는 것이 효과적이다
// (withContext 를 반복 호출하지 말고 한 블록으로 묶기)

코루틴 오버헤드의 실체

  • 객체 생성 — Continuation 인스턴스 하나(수십~수백 바이트)
  • 디스패치 — 큐 삽입 + 워커 깨우기 (전환이 있을 때만)
  • 상태 기계 — switch 분기 하나. 사실상 공짜
실무 감각 — 코루틴 하나 만드는 비용은 수백 나노초 수준입니다. DB 왕복 하나가 수 밀리초인 걸 생각하면 거의 무시할 수 있습니다. “코루틴을 너무 많이 만들면 느려진다”는 걱정보다 블로킹 호출이 섞여 있는지를 먼저 확인하세요.
DEEP 04

람다 · 인라인의 실제 비용

“람다는 객체를 만든다”는 말은 절반만 맞습니다. 인라인 여부, 캡처 여부, invokedynamic 여부에 따라 결과가 완전히 달라집니다.

// ① 인라인 함수의 람다 — 객체가 생기지 않는다
list.forEach { println(it) }
// 디컴파일하면 그냥 for 루프다:
//   Iterator var = list.iterator();
//   while (var.hasNext()) { System.out.println(var.next()); }

// ② 비-인라인 + 캡처 없음 — 싱글턴으로 재사용된다
val f: (Int) -> Int = { it * 2 }
// invokedynamic (LambdaMetafactory) 로 처리 — 인스턴스 하나만 만들어져 재사용

// ③ 비-인라인 + 캡처 있음 — 호출마다 객체가 생긴다
fun makeAdder(n: Int): (Int) -> Int = { it + n }   // n 을 캡처
// 이 함수를 부를 때마다 새 인스턴스가 생성된다

// ④ 함수 참조 — 캡처 없으면 재사용
list.map(String::length)        // 가볍다
list.map { it.length }          // 같은 결과, 마찬가지로 가볍다

// ⑤ suspend 람다 — Continuation 을 상속한 클래스가 생성된다
launch { doWork() }             // 코루틴 하나당 객체 하나

JMH 로 직접 재기

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(1) @Warmup(iterations = 5) @Measurement(iterations = 10)
open class CollectionBenchmark {

    private val list = (1..10_000).toList()

    @Benchmark fun forLoop(): Long {
        var sum = 0L
        for (x in list) if (x % 2 == 0) sum += x.toLong() * 2
        return sum
    }

    @Benchmark fun sequence(): Long =
        list.asSequence().filter { it % 2 == 0 }.sumOf { it.toLong() * 2 }

    @Benchmark fun chain(): Long =
        list.filter { it % 2 == 0 }.sumOf { it.toLong() * 2 }
}

// build.gradle.kts: id("me.champeau.jmh")
// ./gradlew jmh

측정하기 전에 알아둘 것

  • JIT 워밍업 — 처음 수천 번은 인터프리터로 돕니다. 워밍업 없는 측정은 무의미합니다
  • 죽은 코드 제거 — 결과를 쓰지 않으면 JIT 가 통째로 지웁니다 (JMH 의 Blackhole이 이걸 막습니다)
  • 이스케이프 분석 — 지역에서만 쓰이는 객체는 아예 할당되지 않을 수 있습니다(스칼라 치환)
  • 결론은 대개 “차이 없음”입니다 — 수천~수만 건에서는 어느 방식이든 마이크로초 단위입니다
가장 흔한 잘못된 최적화 — “람다가 느리니까 for 루프로 바꾸자”. 실제 병목은 거의 항상 IO입니다. 프로파일러로 어디에 시간이 쓰이는지 확인한 뒤 고치세요 (다음 섹션들에서 도구를 다룹니다).
람다는 공짜가 아닙니다 — 다만 inline 이면 사라집니다 ① 일반 고차 함수 fun run(f: () -> Unit) { f() } 호출할 때마다 Function0 객체가 만들어집니다 캡처한 변수까지 그 객체가 들고 있습니다 ② inline 함수 inline fun run(f: () -> Unit) { f() } 호출 자리에 코드가 그대로 펼쳐집니다 객체 생성도 호출 비용도 없습니다 inline 이 주는 것 표준 라이브러리의 map · filter · let 이 전부 inline 입니다 람다 안에서 return 으로 바깥 함수를 빠져나갈 수 있습니다 reified 로 제네릭 타입을 실행 중에 알 수 있습니다 남용하면 안 되는 이유 본문이 크면 호출한 곳마다 복사되어 코드가 부풉니다 경고가 뜹니다 — "인라인할 이유가 없습니다" 짧은 고차 함수에만 쓰는 것이 원칙입니다
"코틀린이 느리다"는 대개 람다 객체가 반복문 안에서 대량 생성될 때입니다.
100만 번 도는 반복문 안의 exec { total += i } — 함수 객체가 몇 개 생기나 0 25만 50만 75만 100만 개 캡처 람다 · 일반 함수 1,000,000개 · 약 16 MB 쓰레기 캡처 없는 람다 1개 — 싱글턴 하나를 재사용 inline 함수 0개 — 호출 자리에 펼쳐짐 객체 하나 = 헤더 12 B + 캡처 참조 4 B = 16 B — GC 가 100만 번 치울 거리 JIT 이스케이프 분석이 일부를 없애 주기도 하지만 보장되지는 않습니다 핫 루프에서는 inline 으로 바꾸거나 캡처를 없애는 것이 답입니다
같은 반복문이라도 캡처하는 람다만 객체를 쌓습니다 — 프로파일러에서 Function 객체가 보이면 여기부터 의심하세요.
DEEP 05

널 안전성의 런타임 비용

코틀린의 널 안전성은 대부분 컴파일 시점에 처리되지만, 자바와 만나는 경계에는 실제 검사 코드가 들어갑니다. 그 위치와 비용을 봅니다.

// 공개 함수의 파라미터에는 런타임 검사가 삽입된다
fun process(name: String, user: User) { }

// 디컴파일
public static final void process(String name, User user) {
    Intrinsics.checkNotNullParameter(name, "name");    // 자바에서 null 을 넘기면 여기서 즉시 실패
    Intrinsics.checkNotNullParameter(user, "user");
    // ...
}

// private 함수에는 삽입되지 않는다 (코틀린 안에서만 불리므로 컴파일러가 보장)
private fun internal(name: String) { }   // 검사 없음

// !! 는 이렇게 바뀐다
val x = nullable!!
// → Intrinsics.checkNotNull(nullable)

// 플랫폼 타입을 non-null 로 받으면 그 지점에 검사가 들어간다
val name: String = javaObj.getName()
// → Intrinsics.checkNotNullExpressionValue(...)  ← 여기서 터진다 (좋은 일이다)

비용과 제거

  • 비용은 사실상 0입니다 — JIT 가 대부분 제거하거나 분기 예측이 완벽하게 맞습니다
  • 정말 극한 상황이라면 -Xno-param-assertions·-Xno-call-assertions로 끌 수 있지만, 권하지 않습니다 — 자바에서 넘어온 null 이 더 깊은 곳에서 터집니다
  • 안드로이드 R8은 릴리스 빌드에서 이 검사를 일부 제거합니다(코드 크기 절감)
이 검사는 비용이 아니라 이득입니다. “어디서 null 이 들어왔는가”를 경계 지점에서 즉시 알려 주므로 디버깅 시간이 크게 줄어듭니다.

nullable 원시 타입의 진짜 비용

val a: Int = 1          // int — 원시 타입
val b: Int? = 1         // Integer — 박싱된 객체 ★

// 리스트도 마찬가지
val c: List<Int> = listOf(1, 2, 3)     // Integer 로 박싱된다
val d: IntArray = intArrayOf(1, 2, 3)  // int[] — 박싱 없음

// 수백만 건 수치 계산이라면 이 차이가 크다
// · 메모리: Integer 는 객체 헤더 포함 16바이트, int 는 4바이트
// · 캐시 지역성: 배열은 연속, 객체 리스트는 흩어져 있다

// 대안
// · IntArray · LongArray · DoubleArray
// · @JvmInline value class 는 박싱을 피한다 (nullable 로 쓰면 박싱됨)
DEEP 06

제네릭 소거 · value class

JVM 의 제약과 코틀린의 대응입니다. 언제 박싱이 일어나는지를 알면 성능이 중요한 코드에서 선택이 달라집니다.

@JvmInline
value class UserId(val value: Long)

// 언제 원시 타입 그대로인가 (박싱 없음)
fun find(id: UserId) { }                  // → find(long id)
val id = UserId(1L)                       // → long id = 1L
listOf(1, 2).map { UserId(it.toLong()) }  // 리스트에 담기면? → 박싱된다

// 언제 박싱되는가
val nullable: UserId? = UserId(1)          // ← 박싱 (null 을 표현해야 하므로)
val list: List<UserId> = listOf(UserId(1)) // ← 박싱 (제네릭)
val any: Any = UserId(1)                   // ← 박싱
interface Repo { fun get(): UserId }       // 인터페이스 반환 → 상황에 따라 박싱

// 확인하는 법: 디컴파일해서 시그니처를 본다
// public static final void find-XXXXXX(long id)   ← 이름이 뒤틀린다(맹글링)
//   → 자바에서 부르기 어려운 이유이기도 하다

reified 가 하는 일

inline fun <reified T> parse(json: String): T =
    mapper.readValue(json, T::class.java)

// 호출: val u = parse<User>(text)
// 디컴파일하면 호출 자리에 이렇게 펼쳐진다:
//   mapper.readValue(text, User.class)
// → 컴파일 시점에 실제 타입이 박히므로 소거를 우회한다

// 그래서 이런 것은 불가능하다
inline fun <reified T> badExample(list: List<T>) {
    // list 가 List<String> 인지 List<Int> 인지는 여전히 알 수 없다
    // reified 는 '호출 시점에 명시된 T' 만 안다
}

소거가 만드는 실제 문제

// ① 같은 시그니처로 인식되는 오버로드
fun handle(list: List<String>) { }
// fun handle(list: List<Int>) { }     // ❌ 플랫폼 선언 충돌
@JvmName("handleInts")                   // ✅ JVM 이름만 바꿔 회피
fun handle(list: List<Int>) { }        //    코틀린에서는 그대로 handle 로 부른다

// ② 실행 시점 타입 검사 불가
if (obj is List<String>) { }             // ❌ 컴파일 에러 — 소거된 타입은 검사할 수 없다
if (obj is List<*>) { }                  // ✅ 이건 된다

// ③ 제네릭 배열 생성 불가
// val arr = Array<T>(10) { ... }        // ❌
inline fun <reified T> makeArray(n: Int, init: (Int) -> T) = Array(n, init)  // ✅
DEEP 07

메모리 — 객체 레이아웃과 GC

코틀린 객체도 결국 JVM 힙 위의 객체입니다. 크기·정렬·수명을 알면 대용량 처리에서 메모리를 절반으로 줄일 수 있습니다.

코틀린 객체가 힙에서 차지하는 실제 크기 (64비트 · 압축 OOP) class Point(val x: Int, val y: Int) — 실제로는 24바이트 객체 헤더 12B x: Int 4B y: Int 4B 패딩 4B 데이터는 8바이트인데 헤더와 정렬 때문에 24바이트 — 작은 객체를 수백만 개 만들면 이 비율이 그대로 비용이 됩니다 Long 값 하나를 담는 세 가지 방법의 실제 비용 Long (원시) 8바이트 · 힙 할당 없음 배열에 담으면 연속 배치 캐시가 가장 잘 듣는다 Long? (널 가능) 박싱 — 참조 4B + 객체 16B 합쳐서 20B, 두 배 반 게다가 접근할 때 캐시 미스 value class UserId 8바이트 — 원시와 같음 타입 안전성은 그대로 박싱만 피하면 공짜
JOL(Java Object Layout)로 직접 재 보세요 — 추정한 크기와 실제 크기는 거의 항상 다릅니다.

객체 크기 재기 — JOL

// implementation("org.openjdk.jol:jol-core:0.17")
data class Point(val x: Int, val y: Int)

fun main() {
    println(ClassLayout.parseClass(Point::class.java).toPrintable())
}
// OFFSET  SIZE   TYPE
//      0    12   (object header)      ← 마크워드 8 + 클래스포인터 4(압축)
//     12     4   int Point.x
//     16     4   int Point.y
//     20     4   (padding)            ← 8바이트 정렬
// Instance size: 24 bytes

// 비교
// Point(int,int)         24 bytes
// data class with Long?  32+ bytes (박싱된 Long 참조 + Long 객체 24)
// IntArray(2)            24 bytes (헤더 16 + 4×2)

메모리를 줄이는 실전 기법

  • 원시 타입 배열 — List<Int> 100만 개 ≈ 20MB, IntArray ≈ 4MB
  • nullable 원시 타입 피하기 — Int?는 박싱. -1 같은 센티널이나 별도 플래그 고려
  • 문자열 중복 제거 — 같은 값이 많으면 intern()이나 자체 풀 (JVM 옵션 -XX:+UseStringDeduplication)
  • 컬렉션 초기 용량 지정 — 재할당·복사를 줄인다
  • 스트리밍 처리 — 전부 메모리에 올리지 않는다 (Sequence·Flow)
이스케이프 분석 — 메서드 밖으로 나가지 않는 객체는 JIT 가 아예 할당하지 않을 수 있습니다(스칼라 치환). 그래서 “객체를 만들면 무조건 GC 부담”이라는 생각은 정확하지 않습니다.
GC특성 · 코틀린 앱에서의 선택
G1 (기본)균형형. 대부분의 서버 앱에 무난
ZGC일시 정지 1ms 미만 · 힙이 커도 안정 — 지연이 중요한 API 서버
Parallel처리량 최대 · 정지 시간 길다 — 배치 작업
Serial작은 컨테이너·CLI
# 컨테이너에서의 권장 옵션
#  · MaxRAMPercentage — 고정 -Xmx 대신 비율로 (컨테이너 인식)
#  · UseZGC + ZGenerational — 저지연이 필요하면 (JDK 21 기준, 23+ 는 세대형이 기본)
#  (줄 끝 \ 뒤에 주석을 달면 명령이 거기서 끊기므로 설명은 위에 둔다)
java -XX:MaxRAMPercentage=75 \
     -XX:+UseZGC -XX:+ZGenerational \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/dumps \
     -Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=10M \
     -jar app.jar

# GC 로그를 gceasy.io 같은 도구에 올리면 정지 시간 분포를 볼 수 있다
DEEP 08

프로파일링 — 어디에 시간이 쓰이나

추측을 사실로 바꾸는 도구들입니다. CPU · 할당 · 락 · 코루틴 네 가지 관점에서 각각 다른 도구를 씁니다.

async-profiler — 가장 실용적

# CPU 프로파일 (플레임 그래프)
./profiler.sh -d 30 -f cpu.html <pid>

# 메모리 할당 — 어디서 객체를 많이 만드는가
./profiler.sh -d 30 -e alloc -f alloc.html <pid>

# 락 경합
./profiler.sh -d 30 -e lock -f lock.html <pid>

# 벽시계 시간 (블로킹까지 포함 — IO 대기를 보려면 이것)
./profiler.sh -d 30 -e wall -t -f wall.html <pid>

# 컨테이너 안에서는 -XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints 를 켜면 정확도가 오른다
플레임 그래프 읽는 법 — 가로 폭이 시간 비중입니다. 넓은 봉우리를 찾아 왼쪽에서 오른쪽이 아니라 아래에서 위로 읽으세요 (아래가 호출자, 위가 피호출자).

JFR · 힙덤프

# JFR — 운영에서 켜 두어도 부담이 작다 (~1%)
java -XX:StartFlightRecording=duration=60s,filename=app.jfr,settings=profile -jar app.jar
jcmd <pid> JFR.start name=diag settings=profile
jcmd <pid> JFR.dump name=diag filename=/tmp/app.jfr
# JDK Mission Control 로 열어 본다

# 힙덤프 — 메모리 누수 추적
jcmd <pid> GC.heap_dump /tmp/heap.hprof
# Eclipse MAT 으로 열고 'Leak Suspects' 리포트부터

# 스레드 덤프 — 데드락·블로킹 확인
jcmd <pid> Thread.print > threads.txt
# 코루틴은 스레드 덤프에 안 보인다 → 아래 도구를 쓴다
// 코루틴 진단 — 실행 중인 코루틴 목록을 덤프한다
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug")

fun main() {
    DebugProbes.install()             // 개발·스테이징에서만 (오버헤드 있음)

    // 문제가 의심될 때
    DebugProbes.dumpCoroutines()      // 살아 있는 코루틴과 그 상태·스택을 출력
    // "Coroutine "worker#12", state: SUSPENDED
    //     at com.example.OrderService.place(OrderService.kt:42)"

    // 누수 탐지 — 끝나지 않은 코루틴 찾기
    val jobs = DebugProbes.dumpCoroutinesInfo()
    jobs.filter { it.state == State.SUSPENDED }.forEach { println(it) }
}

// 코드 수정 없이 에이전트로도 켤 수 있다
// -javaagent:kotlinx-coroutines-debug-VERSION.jar
// (-Dkotlinx.coroutines.debug=on 은 스레드 이름에 코루틴 이름을 붙이는 디버그 모드 — DebugProbes 와는 별개)
프로파일링의 첫 번째 규칙 — 운영과 비슷한 부하에서 재세요. 아무 요청도 없는 서버를 프로파일링하면 기동 코드만 보입니다. 부하 테스트를 걸어 둔 상태에서 측정해야 실제 병목이 드러납니다.
DEEP 09

동시성 심화 — JMM과 원자성

코루틴을 써도 여러 스레드에서 실행된다는 사실은 변하지 않습니다. 자바 메모리 모델의 규칙이 그대로 적용됩니다.

count++ 를 두 스레드가 동시에 — 증가 한 번이 사라지는 순간 t1 t2 t3 t4 스레드 A 스레드 B count (메모리) ① 읽기 0 ② +1 → 1 ③ 쓰기 1 ① 읽기 0 ② +1 → 1 ③ 쓰기 1 0 0 1 1 기대값은 2 — 증가 한 번 유실 무엇으로 막나 가시성 (최신 값) 원자성 (①②③ 한 덩어리) 아무 표시 없음 ✕ 옛 값이 보일 수 있음 ✕ 쪼개짐 종료 플래그가 안 먹음 @Volatile ✓ 최신 값 보장 ✕ 여전히 쪼개짐 플래그엔 충분 Atomic · Mutex ✓ 최신 값 보장 ✓ CAS · 상호 배제 카운터는 이쪽 붉은 칸 — B 가 A 의 쓰기 전에 읽고, A 의 결과를 덮어씁니다
코루틴 안에서는 synchronized 대신 Mutex를 쓰세요 — synchronized 는 스레드를 막아 이벤트 루프를 세웁니다.

가시성 — @Volatile

class Worker {
    // ❌ 다른 스레드가 바꾼 값을 영영 못 볼 수 있다
    private var running = true

    // ✅ 쓰기가 즉시 보이도록
    @Volatile private var running2 = true

    fun stop() { running2 = false }
    fun loop() { while (running2) { /* ... */ } }
}

// @Volatile 이 보장하는 것
// · 가시성 (다른 스레드가 최신 값을 본다)
// · 재배치 금지 (앞뒤 명령이 넘나들지 않는다)
// 보장하지 않는 것
// · 원자성 — count++ 는 여전히 안전하지 않다

원자 연산과 락프리

// 단순 카운터
val counter = AtomicLong()
counter.incrementAndGet()

// 경합이 심하면 LongAdder 가 훨씬 빠르다 (분산 카운팅)
val adder = LongAdder()
adder.increment()
val total = adder.sum()

// CAS 루프 — 복잡한 갱신
val ref = AtomicReference(State(0, emptyList()))
fun addItem(item: String) {
    while (true) {
        val cur = ref.get()
        val next = cur.copy(count = cur.count + 1, items = cur.items + item)
        if (ref.compareAndSet(cur, next)) return    // 실패하면 다시 읽고 재시도
    }
}

// kotlinx-atomicfu — 멀티플랫폼 원자 연산 (KMP 에서 유용)
private val count = atomic(0)
count.incrementAndGet()
문제코틀린에서의 대응
가시성@Volatile · 원자 타입 · Mutex
원자성Atomic* · Mutex.withLock · 단일 스레드 격리
경합 심한 카운터LongAdder
false sharing같은 캐시 라인의 인접 변수 — @Contended(JVM 옵션 필요)
안전 발행val + 생성자에서 완결 (코틀린 기본이 유리하다)
이중 검사 잠금by lazy 를 쓰면 된다 — 직접 구현하지 말 것
코틀린이 유리한 점 — val과 불변 data class를 기본으로 쓰면 안전 발행 문제가 대부분 사라집니다. JMM 의 어려운 규칙 대부분은 가변 공유 상태에서 나오는데, 코틀린은 그것을 줄이는 방향으로 설계돼 있습니다.
DEEP 10

컴파일러 플러그인

all-open·no-arg·serialization·Compose는 전부 컴파일러 플러그인입니다. 문법 자체를 바꾸거나 코드를 생성하는 층을 이해하면 프레임워크의 “마법”이 사라집니다.

플러그인하는 일
all-open지정 애너테이션이 붙은 클래스를 open으로 (스프링 프록시용)
no-arg기본 생성자를 바이트코드에 추가 (JPA)
kotlinx-serialization@Serializable 클래스의 시리얼라이저를 생성
Compose@Composable 함수에 재구성 스코프·안정성 추론을 주입
parcelize안드로이드 Parcelable 구현 생성
power-assert단언 실패 시 중간 값들을 함께 출력
atomicfu원자 연산을 플랫폼별 최적 형태로 변환
KSP(엄밀히는 프로세서) 새 파일을 생성만 한다 — 기존 코드는 못 바꾼다

KSP vs 컴파일러 플러그인

  • KSP — 새 파일 생성만 가능. API 가 안정적이고 만들기 쉽다. Room·Hilt·Moshi 가 이 방식
  • 컴파일러 플러그인 — 기존 코드를 변형할 수 있다. FIR/IR API 가 버전마다 바뀌므로 유지보수 부담이 크다
  • 선택 기준 — 코드 생성으로 되면 KSP. 문법을 바꿔야 하거나 기존 클래스를 수정해야 하면 컴파일러 플러그인
컴파일러 플러그인은 신중히 — 코틀린 버전을 올릴 때마다 깨질 수 있습니다. 사내 도구라면 KSP 로 해결되는지 먼저 확인하세요.

power-assert — 바로 써 볼 만한 것

plugins { kotlin("plugin.power-assert") version "2.2.0" }

powerAssert {
    functions = listOf("kotlin.assert", "kotlin.test.assertTrue")
}

// 테스트에서
assert(order.total == expected * quantity)

// 실패하면 이렇게 나온다 — 중간 값이 전부 보인다
// Assertion failed
// assert(order.total == expected * quantity)
//        |     |     |  |          |
//        |     24000 |  12000      3
//        |           false
//        Order(id=1, ...)
DEEP 11

GraalVM 네이티브 + 코틀린

기동 수십 ms, 메모리 1/3~1/5. 서버리스·스케일아웃에서 실제 비용 차이를 만듭니다. 대신 리플렉션을 쓰는 코드가 문제가 됩니다.

기동 후 처리량 — 네이티브는 바로 75%, JVM 은 늦게 100% (대표값) 0 50 100 처리량 % 0 5 10 15 20 25 30 초 약 7초 — JVM 이 앞섬 네이티브 — 50 ms 만에 최고 속도 네이티브 — 약 75% 에서 평평 JVM — JIT 가 끝나면 최고 처리량 JVM — 클래스 로딩 · JIT 워밍업 고르는 기준 — 자주 뜨고 지는 서버리스 · CLI · 배치는 네이티브, 오래 도는 고부하 서버는 JVM 메모리도 네이티브가 수 배 적습니다 · 대신 빌드는 네이티브가 수 분 걸립니다 두 선이 만나는 지점보다 프로세스가 오래 살면 JVM 이 이득입니다
네이티브는 “항상 빠르다”가 아니라 “기동이 빠르고 메모리가 적다”입니다.

코틀린에서 걸리는 지점

  • kotlin-reflect — 네이티브에서 제한적. 되도록 피한다
  • Jackson — 리플렉션 기반이라 힌트 등록이 많이 필요. kotlinx.serialization 이 훨씬 궁합이 좋다(컴파일 시점 생성)
  • 동적 프록시 — 스프링 AOP·Mockito 등은 힌트 필요
  • 리소스 로딩 — classpath: 패턴을 등록해야 한다
  • 코루틴은 잘 동작합니다 — 특별한 처리가 거의 없습니다

힌트 등록

class AppHints : RuntimeHintsRegistrar {
    override fun registerHints(hints: RuntimeHints, cl: ClassLoader?) {
        hints.reflection()
            .registerType(OrderResponse::class.java) {
                it.withMembers(
                    MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                    MemberCategory.INVOKE_PUBLIC_METHODS,
                )
            }
        hints.resources().registerPattern("messages/*.properties")
        hints.serialization().registerType(TypeReference.of(OrderEvent::class.java))
    }
}

// 자동 수집 도구 — 앱을 실제로 돌려 필요한 힌트를 뽑아낸다
// java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image -jar app.jar
DEEP 12

타입 시스템 심화

변성·타입 추론·컨텍스트 파라미터까지, 라이브러리를 설계할 때 알아야 할 코틀린 타입 시스템의 깊은 부분입니다.

// ① 타입 추론의 한계 — 어디서 명시해야 하나
val a = listOf(1, "x")              // List<Any> — 의도한 게 맞나?
val b = if (flag) 1 else "x"        // Any — 대개 설계가 잘못된 신호

// 재귀 제네릭 (F-bounded) — 빌더·DSL 에서 쓴다
abstract class Builder<T : Builder<T>> {
    protected var n: String = ""
    @Suppress("UNCHECKED_CAST")
    fun name(n: String): T { this.n = n; return this as T }
}
class UserBuilder : Builder<UserBuilder>() { fun build() = User(n) }

// ② 스타 프로젝션과 제네릭 함수의 차이
fun printAll(list: List<*>) { }          // 타입 인자를 모른다 (쓸 수 없다)
fun <T> printAll2(list: List<T>) { }     // T 를 알고 쓸 수 있다

// ③ 교차 타입 (컴파일러 내부적으로 만들어진다)
fun <T> pick(a: T, b: T): T = a
val r = pick(ArrayList<Int>(), LinkedList<Int>())
// r 의 타입은 AbstractList<Int> & Cloneable & Serializable 같은 교차 타입
//   (RandomAccess 는 LinkedList 에 없어서 빠진다)

// ④ 컨텍스트 파라미터 (2.2 실험) — 암시적으로 전달되는 의존성
context(logger: Logger)
fun process(order: Order) {
    logger.info("주문 처리 ${order.id}")     // 인자로 안 받아도 쓸 수 있다
}
// 호출부에서 컨텍스트가 있어야 한다
with(logger) { process(order) }

API 설계 관점

  • public API 는 반환 타입을 명시 — 추론에 맡기면 구현 변경이 시그니처를 바꿉니다
  • 변성은 선언 지점에 — 쓰는 쪽이 out·in을 안 써도 되게
  • Nothing 을 활용 — Result<Nothing>·List<Nothing>은 모든 타입 자리에 들어갑니다
  • 타입 파라미터를 줄이세요 — 셋을 넘으면 쓰는 사람이 헷갈립니다

실전에서 만나는 타입 에러

메시지원인
Type parameter T is declared as 'out' but occurs in 'in' position변성 위치 위반 — 반환에만 써야 한다
Cannot check for instance of erased type제네릭 소거 — reified 필요
Platform declaration clash소거 후 시그니처 충돌 — @JvmName
Not enough information to infer type variable T추론 실패 — 타입 인자를 직접 지정
Type mismatch: inferred type is Any서로 다른 타입이 합쳐졌다 — 설계 재검토
DEEP 13

함수형 심화 — 대수적 타입

sealed + data class 조합은 사실 대수적 데이터 타입(ADT)입니다. 이 관점을 가지면 “잘못된 상태를 표현할 수 없게” 설계할 수 있습니다.

잘못된 상태가 표현 가능한 모델
data class UiState(
    val isLoading: Boolean,
    val data: List<Item>?,
    val error: String?,
)

// 이런 상태들이 만들어질 수 있다 — 전부 말이 안 된다
UiState(isLoading = true, data = listOf(...), error = "오류")
UiState(isLoading = false, data = null, error = null)

// 그래서 화면 코드에 방어 코드가 늘어난다
if (state.isLoading) ...
else if (state.error != null) ...
else if (state.data != null) ...
else ???                                  // 이 경우는 뭐지?
불가능한 상태를 만들 수 없게
sealed interface UiState {
    data object Loading : UiState
    data class Success(val data: List<Item>) : UiState
    data class Error(val message: String) : UiState
}

// 세 가지만 존재한다. 그 외는 컴파일이 안 된다
when (state) {
    UiState.Loading -> Spinner()
    is UiState.Success -> List(state.data)   // data 가 non-null 임이 보장
    is UiState.Error -> ErrorView(state.message)
}
// 새 상태를 추가하면 이 when 이 컴파일 에러 → 고칠 곳을 알려 준다
// 상태 기계를 타입으로 — 잘못된 전이가 컴파일되지 않게
sealed interface Order {
    val id: Long

    data class Draft(override val id: Long, val items: List<Item>) : Order {
        fun place(): Placed = Placed(id, items, Clock.System.now())    // Draft → Placed 만 가능
    }
    data class Placed(override val id: Long, val items: List<Item>, val at: Instant) : Order {
        fun pay(txId: String): Paid = Paid(id, items, txId)
        fun cancel(reason: String): Canceled = Canceled(id, reason)
    }
    data class Paid(override val id: Long, val items: List<Item>, val txId: String) : Order {
        fun ship(tracking: String): Shipped = Shipped(id, tracking)
    }
    data class Canceled(override val id: Long, val reason: String) : Order    // 종료 상태
    data class Shipped(override val id: Long, val tracking: String) : Order
}

// draft.pay(...)  ← 컴파일 에러. 결제하려면 먼저 place() 해야 한다
// canceled.ship() ← 존재하지 않는 메서드. 취소된 주문은 배송할 수 없다
// 상태 전이 규칙이 '문서'가 아니라 '타입'이 된다

Arrow — 더 나아가고 싶다면

// Either — 실패 타입을 명시
suspend fun placeOrder(cmd: Cmd): Either<OrderError, Order> = either {
    val user = findUser(cmd.userId).bind()        // 실패면 즉시 Left 반환
    val stock = checkStock(cmd.items).bind()
    ensure(stock.enough) { OrderError.OutOfStock }
    save(user, cmd.items)
}

// Raise DSL (Arrow 2) — 코루틴처럼 자연스럽게
context(raise: Raise<OrderError>)
fun validate(cmd: Cmd) {
    ensure(cmd.quantity in 1..99) { OrderError.InvalidQuantity }
}
팀 합의 없이 도입하지 마세요. Arrow 는 강력하지만 리뷰어가 읽지 못하면 유지보수가 어려워집니다. sealed + Result 로도 대부분의 이득을 얻을 수 있습니다.

실무에서 쓸 만한 선

  • 불가능한 상태를 만들 수 없게 — sealed 로 모델링 (거의 언제나 이득)
  • 부수 효과를 경계로 — 순수 함수로 계산하고, IO 는 바깥에서
  • 실패를 값으로 — sealed 에러 타입 (예외는 진짜 예외 상황에만)
  • 불변 데이터 — val + copy
  • 여기까지가 대부분의 팀에서 순이득입니다
DEEP 14

아키텍처 — 헥사고날 · DDD

코틀린은 도메인 모델을 표현하기 좋은 언어입니다. value class·sealed·불변 데이터가 DDD 의 개념들과 잘 맞습니다.

// domain/ — 프레임워크를 모른다. 순수 코틀린
@JvmInline value class OrderId(val value: Long)
@JvmInline value class Money(val won: Long) {
    init { require(won >= 0) { "금액은 음수일 수 없습니다" } }
    operator fun plus(o: Money) = Money(won + o.won)
}

class Order private constructor(              // 생성자를 막고 팩토리로만
    val id: OrderId,
    val userId: UserId,
    private val _items: MutableList<OrderItem>,
    var status: OrderStatus,
) {
    val items: List<OrderItem> get() = _items.toList()
    fun total(): Money = _items.fold(Money(0)) { acc, i -> acc + i.subtotal() }

    fun cancel(now: Instant): DomainEvent {
        check(status.canCancel()) { "취소 불가 상태: $status" }
        status = OrderStatus.CANCELED
        return OrderCanceled(id, now)          // 도메인 이벤트를 돌려준다
    }

    companion object {
        fun place(userId: UserId, items: List<OrderItem>): Order {
            require(items.isNotEmpty()) { "빈 주문은 만들 수 없습니다" }
            return Order(OrderId(0), userId, items.toMutableList(), OrderStatus.NEW)
        }
    }
}

// application/ — 유스케이스. 포트(인터페이스)에만 의존한다
interface OrderRepository {                    // 포트
    fun findById(id: OrderId): Order?
    fun save(order: Order): Order
}

class CancelOrderUseCase(
    private val orders: OrderRepository,
    private val events: EventPublisher,
) {
    fun execute(id: OrderId) {
        val order = orders.findById(id) ?: throw NotFound(id)
        val event = order.cancel(Clock.System.now())
        orders.save(order)
        events.publish(event)
    }
}

// infrastructure/ — 어댑터. 여기서만 JPA·스프링을 안다
@Repository
internal class JpaOrderRepository(private val jpa: OrderJpaRepository) : OrderRepository {
    override fun findById(id: OrderId) = jpa.findByIdOrNull(id.value)?.toDomain()
    override fun save(order: Order) = jpa.save(order.toEntity()).toDomain()
}

구조를 지키는 방법

// ArchUnit 으로 의존 규칙을 테스트로 만든다
@Test
fun `도메인은 스프링을 모른다`() {
    val classes = ClassFileImporter().importPackages("com.example")

    noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("org.springframework..", "jakarta.persistence..")
        .check(classes)
}

// 또는 Gradle 모듈로 아예 분리하면 컴파일러가 강제한다
// :domain (의존성 없음) ← :application ← :infrastructure

과설계를 피하는 기준

  • 도메인 규칙이 실제로 있는가 — CRUD 뿐이면 계층을 나눌 이유가 적습니다
  • 기술을 바꿀 가능성이 있는가 — 없다면 포트/어댑터는 비용만 늘립니다
  • 팀이 여럿인가 — 경계는 사람 사이의 계약일 때 가장 값을 합니다
  • 시작은 단순하게 — 3계층으로 시작해 필요할 때 옮기는 편이 쌉니다
가장 큰 이득은 테스트입니다. 도메인이 프레임워크를 모르면 스프링 컨텍스트 없이 밀리초 단위로 도메인 규칙을 검증할 수 있습니다.
DEEP 15

대규모 코드베이스 운영

코드가 수십만 줄, 개발자가 수십 명이 되면 문법 지식이 아니라 운영 기술이 필요합니다. 빌드 시간, API 안정성, 점진적 마이그레이션이 그 주제입니다.

빌드 시간 줄이기

# gradle.properties
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
kotlin.incremental=true
kotlin.incremental.useClasspathSnapshot=true

# 원격 빌드 캐시 (팀 공유) — CI 가 만든 결과를 로컬이 재사용
# Gradle Enterprise / Develocity 또는 자체 캐시 서버
  • 모듈을 나눈다 — 바뀐 모듈만 다시 컴파일
  • kapt 를 없앤다 — KSP 로 이전 (가장 효과가 큰 경우가 많다)
  • api 대신 implementation — 의존성 전파를 막아 재컴파일 범위를 줄인다
  • 빌드 스캔으로 어느 태스크가 느린지 측정하고 고친다

API 안정성 · 마이그레이션

// 단계적 폐기 — 컴파일러가 이전을 도와준다
@Deprecated(
    message = "sendEmail 대신 notify 를 쓰세요",
    replaceWith = ReplaceWith("notify(to, Channel.EMAIL)"),
    level = DeprecationLevel.WARNING,       // → ERROR → HIDDEN 순으로 올린다
)
fun sendEmail(to: String) = notify(to, Channel.EMAIL)
// IDE 의 'Replace with' 로 호출부를 일괄 변경할 수 있다

// 실험적 API 표시 — 쓰는 쪽이 명시적으로 동의하게
@RequiresOptIn(message = "실험 기능입니다. 시그니처가 바뀔 수 있습니다.")
annotation class ExperimentalOrderApi

@ExperimentalOrderApi
fun newPricing(): Money = TODO()

// 쓰는 쪽
@OptIn(ExperimentalOrderApi::class)
fun caller() { newPricing() }

// 라이브러리라면 이진 호환성 검사를 CI 에
// binary-compatibility-validator 플러그인 → api/*.api 파일로 공개 API 를 고정
과제접근
자바 → 코틀린 전환파일 단위로. 테스트부터 → 새 코드 → 자주 고치는 코드
코드 스타일 통일ktlint + editorconfig 를 CI 에서 강제
위험한 패턴 차단detekt 커스텀 규칙 (!! 금지, GlobalScope 금지 등)
모듈 경계 강제ArchUnit 테스트 · Gradle 모듈 분리
대규모 리팩터링IntelliJ 구조 검색·치환 · OpenRewrite 레시피
지식 공유ADR(결정 기록) · 코드 리뷰 체크리스트
DEEP 16

전문가 체크리스트

이 탭의 내용을 실제로 써먹는 순서로 정리했습니다. 성능 문제를 만났을 때 위에서부터 확인하세요.

성능 문제 진단 순서

  1. 측정한다 — 어느 API 가 느린가 (지표·APM)
  2. 프로파일링 — CPU 인가 대기인가 (async-profiler wall/cpu)
  3. DB 쿼리 — 슬로우 쿼리·N+1·인덱스 (대부분 여기)
  4. 외부 호출 — 타임아웃·직렬 호출을 병렬로
  5. 캐시 — 반복 조회를 없앤다
  6. 알고리즘 — O(n²) 를 찾는다 (컬렉션 사용 패턴)
  7. 할당 — 대량 객체 생성·박싱 (alloc 프로파일)
  8. GC·JVM 옵션 — 마지막에 본다

알아 두면 좋은 도구 모음

도구용도
Show Kotlin Bytecode문법 → 실제 코드 확인
JMH마이크로 벤치마크
async-profilerCPU·할당·락·wall 프로파일
JFR + JMC운영 중 저부담 프로파일
Eclipse MAT힙덤프 분석 (누수)
JOL객체 크기·레이아웃
DebugProbes코루틴 덤프
ArchUnit구조 규칙 테스트
detekt · ktlint정적 분석
마지막 탭 예고 — 실전 도구에서는 Gradle KTS 심화, 정적 분석 설정, Ktor 서버, 직렬화, 문서화, 라이브러리 배포까지 매일 쓰는 도구들을 목적별로 정리하고, 도구 찾기 검색도 제공합니다.
GRADLE KTS · DETEKT · KTOR · 직렬화 · 배포

매일 쓰는 도구를
제대로 설정하면
팀 전체가 빨라진다

빌드 30초, 코드 스타일 논쟁 0회, 테스트가 믿을 만한 상태 — 이건 도구 설정의 문제이지 개인 실력의 문제가 아닙니다. 이 탭은 코틀린 프로젝트에서 한 번 잘 잡아 두면 계속 이득인 설정들을 모았습니다.

16SECTIONS
1빌드 언어 (Kotlin DSL)
2필수 정적 분석 도구
∞절약되는 리뷰 시간
TOOL 01

Gradle Kotlin DSL

빌드 스크립트를 코틀린으로 쓰면 자동완성과 컴파일 검사가 따라옵니다. Groovy DSL 에서 오타로 반나절 날리던 일이 사라집니다.

Gradle 이 매번 거치는 세 단계 — 빌드가 느린 이유가 여기 있습니다 1 초기화 (initialization) settings.gradle.kts 를 읽고 이 빌드에 어떤 모듈이 들어 있는지 정합니다 2 설정 (configuration) 모든 build.gradle.kts 를 실제로 실행해 태스크 그래프를 만듭니다 — 여기가 느리면 설정 캐시를 켭니다 3 실행 (execution) 입력이 바뀐 태스크만 실제로 돕니다 — 여기가 느리면 병렬 실행과 빌드 캐시를 봅니다 빌드를 빠르게 하는 스위치 세 개 parallel 모듈을 동시에 빌드 멀티모듈에서 효과가 큼 configuration-cache ②단계 결과를 재사용 매일 쓰는 증분 빌드에서 체감 build-cache 남이 만든 결과를 내려받음 CI 와 공유하면 효과가 큼
--scan 을 붙여 한 번 돌려 보세요 — 어느 단계에서 몇 초를 쓰는지가 그대로 나옵니다.

버전 카탈로그 — 의존성 한 곳에

# gradle/libs.versions.toml
[versions]
kotlin = "2.2.0"
coroutines = "1.10.2"
springBoot = "4.0.0"
ktor = "3.1.0"

[libraries]
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
mockk = { module = "io.mockk:mockk", version = "1.13.13" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version = "5.9.1" }

[bundles]
testing = ["coroutines-test", "mockk", "kotest-assertions"]

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" }
detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.7" }
// build.gradle.kts — 자동완성이 된다
plugins {
    alias(libs.plugins.kotlin.jvm)
    alias(libs.plugins.spring.boot)
}
dependencies {
    implementation(libs.coroutines.core)
    testImplementation(libs.bundles.testing)
}

자주 쓰는 설정

kotlin {
    jvmToolchain(21)                     // JDK 자동 다운로드
    compilerOptions {
        allWarningsAsErrors.set(true)
        freeCompilerArgs.addAll("-Xjsr305=strict")
    }
}

tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "skipped", "failed")
        exceptionFormat = TestExceptionFormat.FULL
    }
    maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
}

// 태스크 직접 만들기 — 타입 안전하다
tasks.register("printVersion") {
    group = "help"
    doLast { println("버전: ${project.version}") }
}

// 소스셋 추가 (통합 테스트 분리)
sourceSets {
    create("integrationTest") {
        compileClasspath += sourceSets.main.get().output
        runtimeClasspath += sourceSets.main.get().output
    }
}
빌드가 느리다면 — org.gradle.configuration-cache=true, org.gradle.parallel=true, org.gradle.caching=true 세 줄부터. 그리고 ./gradlew build --scan으로 어느 태스크가 오래 걸리는지 확인하세요. (자세한 내용은 전문가 15)
TOOL 02

멀티모듈 · 컨벤션 플러그인

모듈이 열 개가 되면 build.gradle.kts 열 개에 같은 설정을 복사하게 됩니다. 컨벤션 플러그인은 그 중복을 없애는 표준 방법입니다.

의존은 아래로만 흐른다 — 화살표가 되돌아오면(순환) Gradle 이 빌드를 거부한다 :app — 실행 · 화면 조립 build-logic 공통 설정 한 곳 코드 의존이 아니라 설정만 주입한다 :feature:order :feature:user :core:data — Room · Retrofit 구현 :core:domain — 순수 코틀린 · 안드로이드 의존 0 :core:domain 은 위쪽 모듈을 전혀 모른다 — 그래서 JVM 테스트만으로 빠르게 검증된다 처음부터 잘게 쪼개지 말 것 — 모듈 하나에 클래스 세 개면 Gradle 오버헤드가 이득보다 크다
모듈 나누기의 핵심은 개수가 아니라 의존 방향입니다 — 아래로만 흐르게 유지하세요.
// build-logic/src/main/kotlin/myapp.kotlin-common.gradle.kts
plugins {
    kotlin("jvm")
    id("io.gitlab.arturbosch.detekt")
}

kotlin {
    jvmToolchain(21)
    compilerOptions { allWarningsAsErrors.set(true) }
}

dependencies {
    val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
    "implementation"(libs.findLibrary("coroutines-core").get())
    "testImplementation"(libs.findBundle("testing").get())
}

tasks.withType<Test> { useJUnitPlatform() }

// settings.gradle.kts
// includeBuild("build-logic")

// 각 모듈의 build.gradle.kts 는 이렇게 짧아진다
plugins {
    id("myapp.kotlin-common")
}
dependencies {
    implementation(project(":core:domain"))
}

모듈 나누는 기준

  • 도메인 vs 인프라 — 순수 코틀린 모듈은 테스트가 빠릅니다
  • 기능(feature) 단위 — 팀이 나눠 일할 수 있습니다
  • 공개 API 를 좁게 — internal을 적극 쓰면 모듈 밖에서 안 보입니다
  • api 대신 implementation — 의존성 전파를 막아 재컴파일 범위를 줄입니다
처음부터 잘게 쪼개지 마세요. 모듈 하나에 클래스 세 개면 Gradle 오버헤드가 이득보다 큽니다. 빌드가 느려지거나 경계가 필요해질 때 나누는 편이 낫습니다.

모듈 의존성 확인

# 의존성 트리
./gradlew :app:dependencies --configuration runtimeClasspath

# 특정 라이브러리가 어디서 들어오는지
./gradlew :app:dependencyInsight --dependency jackson-databind

# 프로젝트 의존 관계 그래프 (플러그인)
./gradlew projectDependencyGraph

# 버전 충돌 강제 해결
configurations.all {
    resolutionStrategy {
        force("com.fasterxml.jackson.core:jackson-databind:2.18.2")
        failOnVersionConflict()      // 충돌을 에러로 (엄격 모드)
    }
}
TOOL 03

정적 분석 — ktlint · detekt

코드 스타일 논쟁을 없애고, 위험한 패턴을 자동으로 차단합니다. 리뷰에서 “여기 !! 쓰지 마세요”를 반복하지 않게 됩니다.

ktlint — 포맷팅

plugins { id("org.jlleitschuh.gradle.ktlint") version "12.1.2" }

ktlint {
    version.set("1.5.0")
    android.set(false)
    filter { exclude("**/generated/**") }
}

// ./gradlew ktlintCheck   검사
// ./gradlew ktlintFormat  자동 수정
# .editorconfig
[*.{kt,kts}]
indent_size = 4
max_line_length = 120
ktlint_standard_no-wildcard-imports = enabled
# 테스트의 백틱 이름 허용 — 주석은 줄 끝이 아니라 따로 (editorconfig 는 줄 끝 주석을 값으로 읽을 수 있다)
ktlint_standard_function-naming = disabled

detekt — 코드 품질·위험 패턴

# detekt.yml (일부)
potential-bugs:
  UnsafeCallOnNullableType:     # !! 사용
    active: true
  ImplicitDefaultLocale:
    active: true

coroutines:
  GlobalCoroutineUsage:         # GlobalScope 금지
    active: true
  SuspendFunWithFlowReturnType:
    active: true
  RedundantSuspendModifier:
    active: true

exceptions:
  TooGenericExceptionCaught:    # catch (e: Exception)
    active: true
  SwallowedException:           # 예외를 삼킴
    active: true

complexity:
  LongMethod:
    threshold: 60
  CyclomaticComplexMethod:
    threshold: 15
// 커스텀 규칙 — 팀 고유의 금지 사항을 강제한다
class NoRunBlockingInController(config: Config) : Rule(config) {
    override val issue = Issue(
        "NoRunBlockingInController",
        Severity.Defect,
        "컨트롤러에서 runBlocking 을 쓰면 스레드가 막힙니다. suspend 함수를 쓰세요.",
        Debt.TWENTY_MINS,
    )

    override fun visitCallExpression(expression: KtCallExpression) {
        super.visitCallExpression(expression)
        if (expression.calleeExpression?.text == "runBlocking" &&
            expression.containingKtFile.name.endsWith("Controller.kt")
        ) {
            report(CodeSmell(issue, Entity.from(expression), issue.description))
        }
    }
}

// CI 에서 강제
// ./gradlew ktlintCheck detekt --continue
도입 요령 — 기존 프로젝트에 한 번에 켜면 경고가 수천 개 나옵니다. 베이스라인(./gradlew detektBaseline)을 만들어 기존 것은 넘어가고 새 코드만 검사하게 시작하세요. 그다음 조금씩 줄여 나가면 됩니다.
TOOL 04

테스트 도구 총정리

코틀린 테스트 생태계는 선택지가 많습니다. 무엇을 언제 쓰는지와 조합 방법을 정리합니다.

도구역할 · 언제
JUnit 5실행기(러너). 사실상 기본 — 자바 경험자가 바로 씁니다
Kotest러너 + 단언 + 프로퍼티 테스트. 단언만 가져다 쓰는 조합도 흔합니다
MockK코틀린 전용 목 — final·suspend·object 지원
TurbineFlow 방출 검증
kotlinx-coroutines-testrunTest·가상 시간
Testcontainers실제 DB·Kafka·Redis 로 통합 테스트
MockWebServerHTTP 응답 고정 (외부 API 대역)
Fixture Monkey테스트 객체 자동 생성 (네이버 오픈소스)
power-assert단언 실패 시 중간 값 표시
// 실무에서 가장 흔한 조합: JUnit5 + MockK + Kotest 단언 + Turbine
class OrderServiceTest {

    private val repo = mockk<OrderRepository>()
    private val client = mockk<PaymentClient>(relaxed = true)
    private val service = OrderService(repo, client)

    @Test
    fun `재고가 없으면 주문이 실패한다`() = runTest {
        coEvery { repo.findStock(1L) } returns 0

        val result = runCatching { service.place(cmd(productId = 1)) }

        result.isFailure shouldBe true                              // Kotest 단언
        result.exceptionOrNull().shouldBeInstanceOf<OutOfStock>()
        coVerify(exactly = 0) { client.charge(any()) }              // 결제는 시도조차 안 함
    }

    @Test
    fun `상태 흐름은 로딩 후 성공이다`() = runTest {
        coEvery { repo.observe() } returns flowOf(listOf(order()))

        service.state.test {                                        // Turbine
            awaitItem() shouldBe UiState.Loading
            awaitItem().shouldBeInstanceOf<UiState.Success>()
            cancelAndIgnoreRemainingEvents()
        }
    }
}

// 프로퍼티 기반 테스트 — 경계값을 자동으로 찾아 준다
class MoneySpec : StringSpec({
    "덧셈은 결합법칙을 만족한다" {
        checkAll(Arb.long(0L..1_000_000L), Arb.long(0L..1_000_000L), Arb.long(0L..1_000_000L)) { a, b, c ->   // LongRange 여야 한다
            (Money(a) + Money(b)) + Money(c) shouldBe Money(a) + (Money(b) + Money(c))
        }
    }
})

테스트 이름 짓기

// 코틀린은 백틱으로 한글 이름을 쓸 수 있다 — 적극 활용하세요
@Test
fun `재고가 부족하면 OutOfStock 예외가 발생한다`() { }

@Test
fun `쿠폰이 만료되었으면 할인이 적용되지 않는다`() { }

// 실패했을 때 리포트에 그대로 나오므로 원인 파악이 빠르다
// (안드로이드 계측 테스트에서는 제약이 있어 영문을 씁니다)

테스트 픽스처

// 기본값을 가진 팩토리 — 빌더가 필요 없다
fun order(
    id: Long = 1,
    userId: Long = 100,
    status: OrderStatus = OrderStatus.NEW,
    items: List<OrderItem> = listOf(orderItem()),
) = Order(id, userId, status, items)

// 여러 모듈에서 공유하려면 java-test-fixtures 플러그인
// plugins { `java-test-fixtures` }
// testFixtures/kotlin/ 에 두면 다른 모듈이 testImplementation(testFixtures(project(":core")))
TOOL 05

직렬화 — kotlinx.serialization

리플렉션 없이 컴파일 시점에 직렬화 코드를 생성합니다. 멀티플랫폼·네이티브 이미지에서 Jackson 보다 유리하고, 코틀린 타입을 정확히 다룹니다.

plugins { kotlin("plugin.serialization") version "2.2.0" }
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")

@Serializable
data class Order(
    val id: Long,
    @SerialName("order_no") val orderNo: String,           // 이름 매핑
    val status: OrderStatus,
    @Transient val internalNote: String = "",              // 직렬화 제외
    val items: List<OrderItem> = emptyList(),              // 기본값
)

@Serializable
enum class OrderStatus { @SerialName("new") NEW, @SerialName("paid") PAID }

val json = Json {
    ignoreUnknownKeys = true          // 모르는 필드는 무시 (API 변경에 강해진다)
    encodeDefaults = false            // 기본값과 같으면 출력하지 않는다
    prettyPrint = false
    explicitNulls = false             // null 필드를 생략
    coerceInputValues = true          // null 이 오면 기본값으로
}

val text = json.encodeToString(order)
val back = json.decodeFromString<Order>(text)

다형 직렬화 — sealed 와 함께

@Serializable
sealed interface Event {
    @Serializable @SerialName("placed")
    data class Placed(val orderId: Long) : Event
    @Serializable @SerialName("paid")
    data class Paid(val orderId: Long, val txId: String) : Event
}

val e: Event = Event.Paid(1, "tx-1")
println(json.encodeToString(e))
// {"type":"paid","orderId":1,"txId":"tx-1"}

// 판별 필드 이름 바꾸기
val json2 = Json { classDiscriminator = "eventType" }

커스텀 직렬화

// value class · 도메인 타입을 원시 값으로
object MoneySerializer : KSerializer<Money> {
    override val descriptor = PrimitiveSerialDescriptor("Money", PrimitiveKind.LONG)
    override fun serialize(encoder: Encoder, value: Money) = encoder.encodeLong(value.won)
    override fun deserialize(decoder: Decoder) = Money(decoder.decodeLong())
}

@Serializable
data class Cart(@Serializable(with = MoneySerializer::class) val total: Money)

// 날짜 — kotlinx-datetime 은 Instant 를 기본 지원
@Serializable
data class Log(val at: Instant)      // "2026-08-19T10:00:00Z"

// 파일 전체에 적용
// @file:UseSerializers(MoneySerializer::class)
비교Jackson / kotlinx.serialization
동작 방식리플렉션 / 컴파일 시점 생성
기동 속도느림 / 빠름
네이티브 이미지힌트 필요 / 그냥 됨
멀티플랫폼JVM 만 / 전부
생태계매우 넓다(스프링 기본) / 좁다
유연성동적 처리에 강함 / 정적이라 제약이 있다
추천스프링 서버 / KMP · 안드로이드 · 네이티브
컴파일 때 변환 코드가 만들어집니다 — 리플렉션이 없습니다 @Serializable data class Order( val id: Long, val total: Int) 컴파일 시 생성 직렬화기 필드를 순서대로 읽고 씁니다 런타임 탐색이 없습니다 JSON ProtoBuf · CBOR HOCON · properties 자주 쓰는 설정 ignoreUnknownKeys — 모르는 필드가 와도 무시 encodeDefaults — 기본값도 내보낼지 explicitNulls — null 을 아예 빼고 보낼지 API 응답을 받을 때는 ignoreUnknownKeys 를 켜세요 Jackson 과 비교 리플렉션이 없어 시작이 빠르고 네이티브에 유리합니다 코틀린 기본값·널 안전성과 자연스럽게 맞물립니다 자바 생태계 통합·유연함은 Jackson 이 앞섭니다 스프링에서는 둘 다 쓸 수 있습니다
멀티플랫폼과 네이티브 이미지에서는 이쪽이 사실상 기본입니다.
TOOL 06

Ktor 서버 — 스프링 대안

젯브레인스가 만든 코틀린 네이티브 웹 프레임워크입니다. 코루틴이 기본이고 가벼워서, 작은 서비스·게이트웨이·KMP 백엔드에 잘 맞습니다.

Ktor — 스프링과 가장 다른 점은 "기본이 비어 있다"는 것입니다 요청 Netty · CIO 엔진 플러그인 파이프라인 내가 install 한 것만 순서대로 돕니다 routing { } 핸들러 실행 직접 켜야 하는 것들 — 안 켜면 그 기능이 아예 없습니다 Negotiation JSON 직렬화 안 켜면 객체 반환 불가 StatusPages 예외 → 응답 변환 스프링의 Advice 자리 Authentication JWT · 세션 · 베이직 라우트별로 감싸 씁니다 CallLogging 요청 로그 · MDC 기본으로는 안 남습니다 DI 도 기본으로는 없습니다 — 생성자로 직접 넘기거나 Koin 을 붙입니다. 작은 서비스에서는 이 단순함이 오히려 장점입니다.
고르는 기준 — 기능이 많이 필요하면 스프링, 시작이 빠르고 가벼워야 하면 Ktor. KMP 에서는 사실상 Ktor 뿐입니다.
fun main() {
    embeddedServer(Netty, port = 8080) { module() }.start(wait = true)
}

fun Application.module() {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    install(CallLogging) { level = Level.INFO }
    install(StatusPages) {
        exception<NotFoundException> { call, cause ->
            call.respond(HttpStatusCode.NotFound, ErrorResponse("NOT_FOUND", cause.message))
        }
        exception<Throwable> { call, cause ->
            log.error("처리되지 않은 예외", cause)
            call.respond(HttpStatusCode.InternalServerError, ErrorResponse("INTERNAL", null))
        }
    }
    install(Authentication) {
        jwt("auth-jwt") {
            verifier(jwtVerifier)
            validate { credential -> JWTPrincipal(credential.payload) }
        }
    }
    install(Resources)
    install(WebSockets)                 // webSocket { } 라우트를 쓰려면 필수 (ktor-server-websockets)

    routing {
        get("/health") { call.respond(mapOf("status" to "UP")) }

        authenticate("auth-jwt") {
            route("/api/orders") {
                get {
                    val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
                    call.respond(orderService.list(page))      // suspend 함수를 그냥 부른다
                }
                get("/{id}") {
                    val id = call.parameters["id"]?.toLongOrNull()
                        ?: throw BadRequestException("잘못된 id")
                    call.respond(orderService.get(id))
                }
                post {
                    val req = call.receive<CreateOrderRequest>()
                    call.respond(HttpStatusCode.Created, orderService.create(req))
                }
            }
        }

        // SSE · WebSocket 도 기본 지원
        webSocket("/ws/chat") {
            for (frame in incoming) {
                if (frame is Frame.Text) send(Frame.Text("echo: ${frame.readText()}"))
            }
        }
    }
}

스프링과 비교

항목Ktor
철학필요한 것만 install — 명시적
코루틴처음부터 코루틴 기반 (변환 계층이 없다)
기동 속도수백 ms — 스프링보다 훨씬 빠르다
DI내장 없음 → Koin
데이터 접근Exposed · jOOQ 등을 직접 조합
생태계스프링보다 좁다 (배치·시큐리티 등)
멀티플랫폼서버·클라이언트 모두 지원

언제 Ktor 를 고르나

  • 작은 서비스·게이트웨이 — 스프링의 무게가 부담일 때
  • KMP 프로젝트의 백엔드 — 클라이언트와 코드를 공유
  • 기동 속도가 중요 — 서버리스·짧은 수명 컨테이너
  • 코루틴을 온전히 — 트랜잭션·시큐리티 컨텍스트 마찰이 없다
반대로 스프링을 고를 이유 — 팀 경험, JPA·배치·시큐리티 생태계, 사내 표준. 대부분의 기업 서비스는 여전히 스프링이 합리적입니다.
TOOL 07

로깅 · 날짜와 시간

매일 쓰지만 잘못 쓰기도 쉬운 두 가지입니다. 로그는 성능과 보안에, 시간은 정확성에 직결됩니다.

kotlin-logging — 람다 기반

// implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
private val log = KotlinLogging.logger {}

class OrderService {
    fun place(cmd: Cmd) {
        // 람다라서 레벨이 꺼져 있으면 문자열을 만들지 않는다
        log.debug { "주문 시작 payload=${cmd.toJson()}" }   // 비용 0 (DEBUG 꺼져 있으면)

        // SLF4J 직접 쓸 때는 파라미터 형태로
        // log.info("주문 {}", id)     — "$id" 보다 낫다

        log.error(e) { "주문 실패 id=${cmd.id}" }           // 예외를 첫 인자로
    }
}

// 로그에 담지 말아야 할 것
// · 비밀번호 · 토큰 · 카드번호 · 주민번호
// · 전체 요청/응답 본문 (개인정보 + 용량)
fun String.maskCard() = replace(Regex("""\d{4}-?\d{4}-?\d{4}-?(\d{4})"""), "****-****-****-$1")

날짜·시간

// JVM 전용이면 java.time 이 여전히 최선
val now: LocalDateTime = LocalDateTime.now(clock)        // ★ Clock 을 주입받자
val utc: Instant = Instant.now()
val seoul = utc.atZone(ZoneId.of("Asia/Seoul"))

// 멀티플랫폼이면 kotlinx-datetime
val instant: Instant = Clock.System.now()
val local = instant.toLocalDateTime(TimeZone.of("Asia/Seoul"))
val tomorrow = instant.plus(1, DateTimeUnit.DAY, TimeZone.currentSystemDefault())

// 코틀린 Duration — 읽기 좋다
val timeout = 3.seconds
val ttl = 10.minutes + 30.seconds
delay(timeout)
println(ttl.inWholeSeconds)     // 630

// 실무 규칙
// · 저장은 UTC(Instant), 표시할 때만 지역 시간으로
// · 날짜 계산에 밀리초 산술을 쓰지 말 것 (서머타임·윤초)
// · 테스트에서는 Clock 을 고정한다
val fixed = Clock.fixed(Instant.parse("2026-08-19T00:00:00Z"), ZoneOffset.UTC)
구조화 로그를 처음부터 — Spring Boot 3.4+ 는 logging.structured.format.console=ecs 한 줄로 JSON 로그가 나옵니다. 로그 수집 시스템(ELK·Loki)에서 필드로 검색할 수 있게 되어 장애 분석 속도가 달라집니다.
TOOL 08

파일 · 엑셀 · CSV · PDF

업무 시스템에서 빠지지 않는 일들입니다. 코틀린에서는 use와 시퀀스를 쓰면 대용량도 메모리 안전하게 처리됩니다.

파일 · CSV

// 작은 파일
val text = File("a.txt").readText(Charsets.UTF_8)
val lines = File("a.txt").readLines()

// 큰 파일 — 스트리밍 (메모리에 다 안 올린다)
File("big.log").useLines { seq ->
    seq.filter { "ERROR" in it }
       .take(100)
       .forEach(::println)
}

// 쓰기
File("out.csv").bufferedWriter().use { w ->
    w.appendLine("id,name,amount")
    orders.forEach { w.appendLine("${it.id},${it.name.csvEscape()},${it.amount}") }
}

// CSV 라이브러리 — 따옴표·줄바꿈 처리를 직접 하지 말자
// implementation("com.github.doyaaaaaken:kotlin-csv-jvm:1.10.0")
csvReader().open("in.csv") {
    readAllWithHeaderAsSequence().forEach { row ->
        println(row["상품명"])
    }
}

// ⚠️ 엑셀에서 한글이 깨지면 BOM 문제 — UTF-8 BOM 을 붙여 준다
File("out.csv").writeBytes(byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + content.toByteArray())

엑셀 · PDF

// Apache POI — 대용량은 SXSSF (스트리밍)
SXSSFWorkbook(100).use { wb ->                 // 메모리에 100행만 유지
    val sheet = wb.createSheet("주문")
    val header = sheet.createRow(0)
    listOf("주문번호", "고객", "금액").forEachIndexed { i, t ->
        header.createCell(i).setCellValue(t)
    }
    orders.forEachIndexed { r, o ->
        val row = sheet.createRow(r + 1)
        row.createCell(0).setCellValue(o.orderNo)
        row.createCell(1).setCellValue(o.userName)
        row.createCell(2).setCellValue(o.amount.toDouble())
    }
    FileOutputStream("orders.xlsx").use { wb.write(it) }
    wb.dispose()
}

// 읽기 — 대용량이면 이벤트 기반(SAX)을 고려
WorkbookFactory.create(File("in.xlsx")).use { wb ->
    wb.getSheetAt(0).drop(1).forEach { row ->
        val name = row.getCell(0)?.stringCellValue
        val amount = row.getCell(1)?.numericCellValue?.toLong()
    }
}

// PDF — OpenHTMLtoPDF (HTML 로 만들고 변환하는 방식이 편하다)
// 한글 폰트를 반드시 임베드해야 글자가 나온다
대용량 엑셀은 메모리를 조심하세요. 10만 행 XSSF 는 수 GB 를 먹습니다. 쓰기는 SXSSF, 읽기는 이벤트 기반, 그리고 정말 크면 CSV 로 유도하는 것이 현실적인 해법입니다.
TOOL 09

HTTP 클라이언트 비교

선택지가 넷입니다. 어떤 스택에 있는지와 멀티플랫폼 계획이 결정 요소입니다.

클라이언트블로킹고르는 이유
Ktor Client논블로킹멀티플랫폼 · 코루틴 기본 · 안드로이드·iOS·서버 공용
Retrofit + OkHttp논블로킹(코루틴)안드로이드 표준 · 인터페이스 선언 방식 · 자료가 가장 많다
Spring WebClient논블로킹스프링 스택 · 커넥션 풀·필터가 잘 갖춰져 있다
Spring RestClient블로킹MVC + 가상 스레드 · 가장 단순한 API
java.net.http.HttpClient둘 다의존성 0 — 간단한 도구·스크립트
// 같은 요청을 네 가지로

// ① Ktor
val order: Order = client.get("orders/$id") { header("X-Trace", traceId) }.body()

// ② Retrofit
@GET("orders/{id}") suspend fun get(@Path("id") id: Long): Order

// ③ WebClient (코루틴)
val order = webClient.get().uri("/orders/{id}", id).retrieve().awaitBody<Order>()

// ④ RestClient (블로킹 — 필요하면 withContext(IO))
val order = restClient.get().uri("/orders/{id}", id).retrieve().body<Order>()!!

// 공통으로 반드시 설정할 것
// · 연결·읽기 타임아웃 (기본값이 무한인 경우가 있다)
// · 커넥션 풀 크기와 maxIdleTime
// · 재시도 정책 (멱등한 요청만)
// · 로깅 (운영에서는 헤더만 · 개발에서는 본문까지)
공통 규칙 하나 — 도메인 예외로 변환하는 어댑터를 반드시 두세요. WebClientResponseException이나 HttpException이 서비스 계층까지 올라오면 클라이언트 라이브러리를 바꾸는 순간 전부 고쳐야 합니다.
TOOL 10

CLI 도구 · 스크립팅

운영 스크립트·마이그레이션 도구·사내 CLI 를 코틀린으로 만들면 도메인 코드를 그대로 재사용할 수 있습니다.

Clikt — CLI 프레임워크

// implementation("com.github.ajalt.clikt:clikt:5.0.2")
class Migrate : CliktCommand() {
    override fun help(context: Context) = "주문 데이터를 새 스키마로 옮깁니다"   // Clikt 5: 생성자 help 인자는 없어졌다
    private val source by option("-s", "--source", help = "원본 DB URL").required()
    private val target by option("-t", "--target").required()
    private val batchSize by option("--batch").int().default(1000)
    private val dryRun by option("--dry-run").flag()

    override fun run() = runBlocking {
        echo("마이그레이션 시작 (batch=$batchSize, dryRun=$dryRun)")
        val total = migrate(source, target, batchSize, dryRun)
        echo("완료: ${total}건")
    }
}

class Cli : CliktCommand() { override fun run() = Unit }

fun main(args: Array<String>) = Cli().subcommands(Migrate(), Verify()).main(args)

// 실행 파일로 만들기
// · ./gradlew installDist → bin/ 에 실행 스크립트
// · GraalVM 네이티브 이미지 → 단일 바이너리 (기동 수십 ms)

코틀린 스크립트 (.main.kts)

#!/usr/bin/env kotlin
@file:DependsOn("com.squareup.okhttp3:okhttp:4.12.0")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")

import okhttp3.*

val client = OkHttpClient()
val res = client.newCall(Request.Builder().url(args[0]).build()).execute()
println("status=${res.code} length=${res.body?.contentLength()}")

// 실행: kotlin check.main.kts https://example.com
// 장점: 의존성을 파일 안에 선언 · 타입 안전
// 단점: 첫 실행이 느리다 (컴파일) — 자주 쓰는 건 CLI 로 만드는 편이 낫다
Gradle 태스크로 만드는 방법도 있습니다 — 빌드 스크립트 안에서 코틀린으로 로직을 쓰면 프로젝트 코드를 그대로 부를 수 있어 데이터 검증·리포트 생성에 편합니다.
TOOL 11

문서화 — KDoc · Dokka

공개 API 에는 문서가 필요합니다. KDoc 은 IDE 자동완성에 바로 뜨고, Dokka 는 그것을 HTML 문서로 만들어 줍니다.

/**
 * 주문을 생성합니다.
 *
 * 재고를 확인한 뒤 결제를 요청하고, 성공하면 주문을 저장합니다.
 * 이 함수는 **멱등하지 않습니다** — 중복 방지가 필요하면 [IdempotencyService]를 사용하세요.
 *
 * @param cmd 주문 명령. [PlaceOrderCommand.quantity]는 1~99 범위여야 합니다.
 * @param userId 주문자 식별자
 * @return 저장된 주문. [Order.id]는 이 시점에 채워집니다.
 * @throws OutOfStockException 재고가 부족할 때
 * @throws PaymentFailedException 결제 승인이 실패할 때
 * @sample com.example.samples.placeOrderSample
 * @see cancelOrder
 * @since 2.1.0
 */
suspend fun placeOrder(cmd: PlaceOrderCommand, userId: UserId): Order

// 문서 생성
// plugins { id("org.jetbrains.dokka") version "2.0.0" }
// ./gradlew dokkaHtml       → build/dokka/html
// ./gradlew dokkaJavadoc    → 자바 스타일 (Maven Central 배포에 필요)

무엇을 문서로 남길까

  • “무엇을 하는가”가 아니라 “왜·언제 쓰는가” — fun getUser()에 “사용자를 가져온다”는 쓸모가 없습니다
  • 계약 — 인자 범위, 반환의 의미, 던지는 예외
  • 주의사항 — 스레드 안전 여부, 멱등성, 성능 특성
  • 예제(@sample) — 실제 컴파일되는 코드를 가리키므로 썩지 않습니다
  • 내부 구현은 코드가 문서 — 주석보다 이름을 잘 짓는 편이 낫습니다

API 문서 · 아키텍처 결정 기록

// REST API 문서 — 스프링이라면 springdoc-openapi
@Operation(summary = "주문 생성", description = "재고를 확인하고 결제를 진행합니다")
@ApiResponses(
    ApiResponse(responseCode = "201", description = "생성됨"),
    ApiResponse(responseCode = "409", description = "재고 부족"),
)
@PostMapping("/orders")
suspend fun create(@RequestBody @Valid req: CreateOrderRequest): OrderResponse

// 또는 테스트에서 문서를 생성 — Spring REST Docs
// 문서와 실제 동작이 어긋날 수 없다는 장점이 크다

// ADR — 왜 이렇게 결정했는지 남긴다 (docs/adr/0007-use-ktor.md)
// # 7. 게이트웨이는 Ktor 로 만든다
// 상태: 승인됨 / 날짜: 2026-03-14
// 배경 · 결정 · 결과 · 대안
TOOL 12

라이브러리 배포

사내 공용 모듈이나 오픈소스를 Maven 저장소에 올리는 과정입니다. 코틀린 라이브러리는 이진 호환성이라는 추가 고려사항이 있습니다.

내가 만든 코틀린 라이브러리를 남이 쓰게 만들기까지 1 maven-publish 설정 group · artifactId · version 과 POM 메타데이터(라이선스 · 개발자 · SCM)를 채웁니다 2 소스 · 문서 아티팩트 sources.jar 과 Dokka 로 만든 javadoc.jar — 중앙 저장소는 이 둘을 요구합니다 3 GPG 서명 개인 키는 CI 시크릿으로만 — 저장소에 커밋하는 사고가 가장 흔합니다 4 Sonatype Central 업로드 태그를 붙이면 CI 가 올리도록 자동화합니다 — 손으로 올리면 반드시 빠뜨립니다 5 쓰는 쪽은 한 줄 implementation("내그룹:내라이브러리:1.0.0") — 여기까지가 목표입니다 공개 API 는 binary-compatibility-validator 로 파일에 고정하세요 — 실수로 시그니처를 바꾸면 PR 에서 막힙니다.
한 번 공개한 API 는 내 것이 아니게 됩니다 — 공개 범위를 좁게 시작하는 편이 언제나 안전합니다.
plugins {
    kotlin("jvm")
    `maven-publish`
    signing
    id("org.jetbrains.dokka")
    id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.17.0"
}

java {
    withSourcesJar()          // 소스 첨부 (IDE 에서 코드를 볼 수 있다)
    withJavadocJar()
}

publishing {
    publications {
        create<MavenPublication>("maven") {
            from(components["java"])
            groupId = "com.example"
            artifactId = "order-core"
            version = "1.2.0"
            pom {
                name.set("Order Core")
                description.set("주문 도메인 공용 모듈")
                licenses { license { name.set("Apache-2.0") } }
                developers { developer { id.set("hw"); name.set("김현우") } }
                scm { url.set("https://github.com/example/order-core") }
            }
        }
    }
    repositories {
        maven {
            url = uri("https://nexus.internal/repository/maven-releases/")
            credentials {
                username = System.getenv("NEXUS_USER")
                password = System.getenv("NEXUS_PASSWORD")
            }
        }
    }
}

// 공개 API 를 파일로 고정 — 실수로 시그니처가 바뀌면 CI 가 잡는다
// ./gradlew apiDump    → api/order-core.api 갱신
// ./gradlew apiCheck   → 변경이 있으면 실패

코틀린 라이브러리의 호환성 함정

  • inline 함수 본문 변경 — 이미 펼쳐진 코드는 안 바뀝니다. 쓰는 쪽 재컴파일이 필요
  • 기본값 추가·변경 — $default 시그니처가 바뀔 수 있습니다
  • data class 에 프로퍼티 추가 — copy·componentN이 바뀝니다
  • 인터페이스에 메서드 추가 — 기본 구현이 있어도 구현체에 영향
  • sealed 에 자식 추가 — 쓰는 쪽 when이 깨집니다(의도된 동작)
시맨틱 버전을 지키세요 — 호환이 깨지면 메이저를 올리고, @Deprecated로 최소 한 버전은 예고합니다.

Maven Central 배포 (오픈소스)

준비물
· Sonatype Central 계정 + 네임스페이스 검증 (도메인 또는 io.github.*)
· GPG 키 (서명 필수)
· sources jar · javadoc jar (Dokka 로 생성)
· POM 에 name·description·url·license·developer·scm

배포
· vanniktech/gradle-maven-publish-plugin 을 쓰면 대부분 자동화된다
· ./gradlew publishToMavenCentral

멀티플랫폼(KMP)이면
· 타깃별 아티팩트가 함께 올라간다 (metadata + jvm + iosArm64 ...)
· CI 는 맥에서 돌려야 iOS 타깃이 포함된다
TOOL 13

IDE · 생산성

코틀린은 IDE 지원이 언어의 일부라고 할 만큼 도구와 밀접합니다. 알아 두면 매일 시간을 아끼는 기능들입니다.

알아 두면 좋은 기능

기능용도
자바 → 코틀린 변환자바 코드를 붙여넣으면 자동 변환 (학습에도 최고)
Show Kotlin Bytecode문법이 무엇으로 바뀌는지 확인
Scratch File프로젝트 밖에서 코드 조각을 바로 실행
Kotlin Notebook데이터 탐색·프로토타이핑 (셀 단위 실행)
구조 검색·치환패턴 기반 대규모 리팩터링
Coroutine Debugger디버깅 중 코루틴 목록·상태 확인
Inline Hints추론된 타입·인자 이름 표시 (켜 두면 읽기 쉬워집니다)
Live Templates자주 쓰는 코드 골격 등록

팀 설정 공유

저장소에 함께 넣어 두면 좋은 것

.editorconfig            들여쓰기·줄 길이 (ktlint 와 연동)
.idea/codeStyles/        코드 스타일 (팀 통일)
.idea/inspectionProfiles/ 인스펙션 수준
detekt.yml               정적 분석 규칙
gradle/libs.versions.toml 의존성 버전
.github/workflows/       CI
CONTRIBUTING.md          리뷰 기준 · 커밋 규칙
docs/adr/                아키텍처 결정 기록

새로 합류한 사람이 클론만 하면 같은 환경이 되는 것이 목표입니다.
Kotlin Notebook 은 데이터 확인에 특히 좋습니다 — 운영 DB 에서 뽑은 데이터를 셀 단위로 탐색하고, 그 코드를 그대로 서비스 코드로 옮길 수 있습니다(같은 언어이므로). 파이썬 노트북을 쓰던 자리를 상당 부분 대체합니다.
TOOL 14

의존성 보안 — 내가 안 쓴 코드가 더 많습니다

스프링 부트 프로젝트 하나를 만들면 실제로 딸려 오는 라이브러리는 보통 150~300개입니다. 그중 내가 이름이라도 아는 건 열 개 남짓입니다. 공격은 대부분 나머지에서 들어옵니다.

내 프로젝트에서 실제로 실행되는 코드의 출처 — 세 겹 ① 직접 의존성 — 내가 build.gradle.kts 에 적은 것 보통 10~30개. 이름을 알고, 버전도 내가 정합니다 — 여기까지는 통제 안입니다 확인할 것 · 관리 주체가 살아 있나 · 최근 릴리스가 있나 · 라이선스가 맞나 ② 전이 의존성 — 그것들이 데려온 것 보통 150~300개. 이름조차 모릅니다. 취약점 공지는 여기서 나옵니다 확인할 것 · CVE 스캔 · 버전 고정 · 필요 없으면 exclude ③ 빌드 도구 자신 — 플러그인 · 래퍼 · CI 액션 빌드 스크립트는 그냥 코드입니다. 플러그인 하나가 빌드 중 아무 명령이나 실행할 수 있습니다 확인할 것 · Gradle 래퍼 체크섬 검증 · 플러그인 출처 · CI 액션을 커밋 해시로 고정 세 겹 중 ②·③ 이 실제 사고가 나는 자리입니다 — 그런데 대부분의 팀은 ① 만 봅니다.
"우리가 쓰는 라이브러리"보다 "우리 빌드에서 실행되는 코드"로 질문을 바꿔야 보입니다.

지금 당장 할 수 있는 것 — 명령 세 줄

무엇이 딸려 오는지 보기

# 특정 설정의 의존성 트리 전체
./gradlew :app:dependencies --configuration runtimeClasspath

# 어떤 라이브러리가 '왜' 들어왔는지 역추적
./gradlew :app:dependencyInsight \
  --dependency jackson-databind \
  --configuration runtimeClasspath

# 오래된 버전 찾기 (versions 플러그인)
./gradlew dependencyUpdates

dependencyInsight 는 "이건 왜 있지"에 답해 줍니다. 취약점 공지를 받았을 때 가장 먼저 치는 명령입니다.

취약점 스캔을 CI 에 붙이기

// 방법 A — OWASP Dependency-Check (오프라인 가능)
plugins { id("org.owasp.dependencycheck") version "..." }
dependencyCheck {
    failBuildOnCVSS = 7.0f          // 높음 이상이면 빌드 실패
    suppressionFile = "cve-suppress.xml"
}

// 방법 B — GitHub Dependabot (설정 파일 하나)
// .github/dependabot.yml 에 gradle 생태계를 등록하면
// 취약점 PR 을 자동으로 열어 줍니다
실패 기준을 처음부터 높게 잡지 마세요. CVSS 9.0(치명적)부터 막고, 정리가 되면 7.0 으로 내리는 식이 현실적입니다. 처음부터 다 막으면 팀이 스캔 자체를 꺼 버립니다.

버전 카탈로그 — 버전을 한 곳에서만 정합니다

gradle/libs.versions.toml

[versions]
kotlin = "2.2.0"
coroutines = "1.10.2"
ktor = "3.1.0"

[libraries]
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
ktor-client = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }   # bundles 가 가리키는 이름은 모두 여기 있어야 한다

[bundles]
ktor-all = ["ktor-client", "ktor-json"]

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

쓰는 쪽과, 얻는 것

dependencies {
    implementation(libs.coroutines.core)
    implementation(libs.bundles.ktor.all)
}
  • 모듈이 20개여도 버전은 한 곳에만 적습니다
  • IDE 자동완성이 됩니다 — 오타로 인한 못 찾음이 사라집니다
  • 버전 올릴 때 바뀐 줄이 하나라 리뷰가 쉽습니다
  • Renovate·Dependabot 이 이 파일을 직접 고쳐 PR 을 엽니다

시크릿 — 코드에 절대 들어가면 안 되는 것들

항목어떻게 다루나
API 키 · DB 비밀번호환경변수 또는 Vault · AWS Secrets Manager. application.yml 에 직접 쓰지 않습니다
서명 키(안드로이드 · GPG)CI 시크릿에만. 로컬은 ~/.gradle/gradle.properties(저장소 밖)
이미 커밋한 시크릿즉시 폐기하고 새로 발급합니다. 히스토리에서 지워도 이미 노출된 것입니다
사전 차단gitleaks · detect-secrets 를 pre-commit 훅과 CI 양쪽에
로그로 새는 경우토큰·주민번호·카드번호는 로그 마스킹 필터를 두세요. 실수의 절반이 로그입니다
빌드 산출물APK·JAR 안에 하드코딩된 문자열은 누구나 꺼내 봅니다 — 클라이언트에는 비밀을 두지 않습니다

라이선스도 같이 봅니다

GPL 계열이 섞이면 소스 공개 의무가 생길 수 있습니다. gradle-license-report 로 목록을 뽑아 두고, 허용 라이선스 목록을 CI 에서 검사하세요.

SBOM 을 만들어 둡니다

릴리스마다 CycloneDX 형식의 부품 명세를 남기면, 새 취약점 공지가 떴을 때 "우리가 영향을 받나"를 몇 초 만에 답할 수 있습니다.

버전 고정과 재현 가능성

1.2.+ 같은 동적 버전은 쓰지 마세요. 어제 되던 빌드가 오늘 깨지고, 원인 추적이 불가능해집니다. Gradle 의 의존성 잠금을 켜면 확실합니다.

가장 위험한 습관은 "일단 나중에"입니다. 취약점 알림이 쌓이면 사람들은 그 알림을 읽지 않게 되고, 그 순간부터 스캔은 있으나 마나가 됩니다. 한 달에 한 번, 30분짜리 의존성 정리 시간을 일정에 넣는 편이 도구를 더 붙이는 것보다 효과가 큽니다.
TOOL 15

JMH — "이게 더 빠를 것 같은데"를 숫자로

JVM 위에서 System.currentTimeMillis() 로 잰 수치는 대부분 틀립니다. JIT 컴파일 · 워밍업 · 죽은 코드 제거가 측정을 통째로 왜곡합니다. JMH 는 그 왜곡을 걷어내는 도구입니다.

직접 잰 수치가 틀리는 세 가지 이유 — JMH 는 각각을 이렇게 처리합니다 ① 워밍업 없이 잰다 처음 수천 번은 인터프리터로 돕니다 JIT 이 끼어들기 전 수치가 섞입니다 JMH · @Warmup 버릴 반복을 따로 돌린 뒤 측정을 시작합니다 기본 5회 × 10초 — 안정된 상태에서만 잽니다 ② 결과를 안 쓴다 쓰지 않는 계산은 통째로 삭제됩니다 "0.001ms" 는 대개 코드가 사라진 것입니다 JMH · Blackhole 결과를 삼켜 컴파일러가 못 지우게 막습니다 반환값만 돌려줘도 같은 효과가 납니다 ③ 한 번만 잰다 GC 한 번, 다른 프로세스 한 번에 흔들립니다 두 번 재면 두 배 차이가 나기도 합니다 JMH · Fork + 통계 JVM 을 새로 띄워 여러 번 반복합니다 평균과 함께 오차 범위를 같이 내놓습니다
JMH 의 값어치는 속도를 재는 게 아니라 틀린 수치를 못 만들게 막는 것에 있습니다.

붙이는 법 — 별도 소스셋 하나

설정

plugins { id("me.champeau.jmh") version "..." }

dependencies {
    jmh("org.openjdk.jmh:jmh-core:1.37")
    jmhAnnotationProcessor("org.openjdk.jmh:jmh-generator-annprocess:1.37")
}

jmh {
    warmupIterations = 3
    iterations = 5
    fork = 2
    resultFormat = "JSON"    // CI 에 남겨 두면 추이를 봅니다
}

// 실행: ./gradlew jmh

벤치마크 코드는 src/jmh/kotlin 에 둡니다 — 테스트와 섞지 마세요. 한 번 도는 데 몇 분씩 걸립니다.

가장 단순한 벤치마크

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
open class JoinBenchmark {

    @Param("10", "1000")           // 두 크기로 각각 측정
    var size: Int = 0
    private lateinit var words: List<String>

    @Setup fun setup() { words = List(size) { "w$it" } }

    @Benchmark
    fun joinToString(): String = words.joinToString(",")

    @Benchmark
    fun stringBuilder(): String {
        val sb = StringBuilder()
        words.forEachIndexed { i, w -> if (i > 0) sb.append(','); sb.append(w) }   // 같은 결과를 만들어야 비교가 된다
        return sb.toString()
    }
}
@Setup 안의 준비 코드는 측정에 포함되지 않습니다. 반대로 말하면, 리스트를 만드는 비용까지 재고 싶다면 그 코드를 @Benchmark 메서드 안으로 옮겨야 합니다 — @Setup(Level.Invocation) 은 호출마다 다시 준비할 뿐 측정에서는 여전히 빠집니다. 이 구분을 놓치면 "엉뚱한 것을 잰 정확한 수치"가 나옵니다.

코틀린에서 특히 조심할 것

클래스와 함수를 open 으로

JMH 는 벤치마크 클래스를 상속해서 코드를 생성합니다. 코틀린은 기본이 final 이라 open class 로 열어야 빌드 자체가 됩니다.

inline 과 인라인 클래스

인라인되는 코드는 호출 지점의 문맥에 따라 성능이 달라집니다. 벤치마크에서 빨랐다고 실제 코드에서도 빠르다는 보장이 없습니다 — 실제 호출 형태와 비슷하게 재세요.

코루틴 측정

runBlocking 으로 감싸면 그 비용이 함께 잡힙니다. 중단이 거의 없는 코드라면 측정 대상보다 감싸는 비용이 클 수 있으니, 기준선(빈 runBlocking)을 함께 재세요.

JMH 로 재기 전에 확인할 것

질문아니라면
이게 정말 병목인가?먼저 프로파일러(async-profiler · JFR)로 전체 그림을 보세요. 안 쓰이는 코드를 1000배 빠르게 만들어 봐야 소용없습니다
차이가 의미 있는 크기인가?50ns 가 30ns 가 됐다 해도, 그 함수가 요청당 한 번 불린다면 측정 오차보다 작은 개선입니다
오차 범위와 겹치지 않나?JMH 출력의 ± error 를 보세요. 구간이 겹치면 "차이 없음"입니다
실제 데이터로 쟀나?정렬된 배열과 무작위 배열은 분기 예측 때문에 몇 배 차이가 납니다
같은 기계에서 쟀나?노트북은 발열로 클럭이 내려갑니다. 비교는 반드시 같은 실행 안에서
가장 자주 쓰게 되는 용도는 "A 와 B 중 뭐가 빠른가"가 아니라 "내가 바꾼 코드가 느려지지 않았나"입니다. 핵심 경로 벤치마크 서너 개를 CI 에 넣고 결과를 JSON 으로 쌓아 두면, 성능 회귀가 배포 전에 잡힙니다.
TOOL 16

목적별 도구 찾기

“이걸 하려면 뭘 써야 하지?”에 대한 답을 모았습니다. 아래 칸에 하려는 일을 한글로 입력해 보세요.

전체 목록 · 위 칸에 하려는 일을 입력해 보세요

하려는 일도구비고
빌드·의존성 관리Gradle Kotlin DSL + 버전 카탈로그컨벤션 플러그인으로 중복 제거
코드 스타일 통일ktlint.editorconfig 와 연동 · CI 강제
위험 패턴 차단detekt!!·GlobalScope 금지 규칙
단위 테스트JUnit 5 (+ Kotest 단언)백틱 한글 테스트명 활용
목(mock) 만들기MockKfinal·suspend·object 지원
코루틴·Flow 테스트kotlinx-coroutines-test + Turbine가상 시간으로 즉시 실행
실제 DB 로 통합 테스트Testcontainers@ServiceConnection으로 자동 연결
외부 API 흉내MockWebServer · WireMock지연·에러 시나리오 재현
JSON 직렬화kotlinx.serialization · JacksonKMP·네이티브면 전자
HTTP 호출Ktor Client · Retrofit · WebClient스택과 플랫폼에 맞춰
웹 서버Spring Boot · Ktor생태계 vs 가벼움
ORM · DB 접근JPA · Exposed · jOOQ · Kotlin JDSL도메인/쿼리 복잡도로 선택
스키마 관리Flyway · Liquibaseddl-auto 금지
캐시Caffeine(로컬) + Redis(분산)2단 구성 · 스탬피드 방지
분산 락Redisson원자적 UPDATE 로 되면 그게 낫다
비동기 메시징Kafka · RabbitMQ소비자는 멱등하게
로깅kotlin-logging + Logback구조화(JSON) 로그 권장
메트릭·추적Micrometer + Prometheus + OTelRED/USE 지표
의존성 주입Spring · Hilt(안드로이드) · Koin(KMP)플랫폼에 맞춰
안드로이드 UIJetpack ComposeMaterial 3 · 미리보기 활용
모바일 로컬 저장Room · DataStore · SQLDelightKMP 면 SQLDelight
이미지 로딩Coil 3KMP 지원
백그라운드 작업WorkManager제약 조건·재시도 내장
코드 공유 (iOS)KMP + SKIE도메인·데이터부터
엑셀 읽기·쓰기Apache POI (SXSSF)대용량은 스트리밍
CSVkotlin-csv · commons-csvBOM·따옴표 처리 주의
PDF 생성OpenHTMLtoPDF · PDFBox한글 폰트 임베드 필수
CLI 도구Clikt · kotlin scripting네이티브 이미지로 배포
API 문서Dokka · springdoc-openapiREST Docs 도 좋은 선택
라이브러리 배포maven-publish + BCV공개 API 를 파일로 고정
성능 프로파일링async-profiler · JFR · JMH측정 없이 최적화 금지
메모리 분석Eclipse MAT · LeakCanary(앱)JOL 로 객체 크기 확인
부하 테스트k6 · Gatling · nGrinder운영과 같은 환경에서
구조 규칙 강제ArchUnit · Konsist테스트로 경계를 지킨다
기동 시간 단축GraalVM 네이티브 이미지리플렉션 힌트 필요
가이드를 끝까지 읽으셨습니다. 코틀린은 서버·안드로이드·iOS·데스크톱을 한 언어로 이어 주는 몇 안 되는 선택지이고, 그 중심에는 null 안전성·sealed·코루틴이라는 세 가지 축이 있습니다. 막힐 때는 Ctrl K 검색으로 필요한 섹션을 바로 찾아 쓰세요.
JDK 21/25 LTS · Kotlin 2.2 (K2) · Gradle Kotlin DSL · IntelliJ · Android Studio · KMP — 2026 기준

코틀린은 JDK 위에 서고
목적지에 따라
깔 것이 달라집니다

“코틀린을 설치한다”는 말은 사실 정확하지 않습니다. 코틀린 컴파일러는 대개 Gradle 이 알아서 내려받고, 정작 사람이 손으로 깔아야 하는 것은 JDK · IDE · (안드로이드라면) SDK 입니다. 게다가 서버(Spring) · 안드로이드 · 멀티플랫폼(KMP) 세 갈래는 필요한 도구가 서로 다릅니다 — 이걸 모르고 “코틀린 설치법”을 검색하면 남의 갈래 글을 따라 하다 시간을 버립니다. 이 탭은 어느 사이트에서 무엇을 받아, 어느 체크박스를 켜고, 환경 변수 어느 칸에 무엇을 넣고, 어떤 명령으로 확인하는지까지 한 단계도 건너뛰지 않습니다. 끝나면 빈 PC 에서 ./gradlew run 이 도는 상태를 스스로 만들 수 있습니다.

11SECTIONS
2.2Kotlin 기준 (K2)
3갈래 (서버·AOS·KMP)
0사전 지식
SETUP 01

코틀린 환경의 세 갈래 — 무엇을 만들지부터 정합니다

파이썬은 “파이썬을 깐다”가 끝이고, 자바는 “JDK 를 깐다”가 끝입니다. 코틀린은 어디로 컴파일할지에 따라 깔 것이 달라집니다. 서버는 JDK 하나면 되지만, 안드로이드는 SDK 수 기가가 더 필요하고, iOS 까지 노리는 KMP 는 맥과 Xcode가 사실상 필수입니다. 여기서 자기 갈래를 정하고 나면 뒤 섹션에서 읽을 것과 건너뛸 것이 갈립니다.

코틀린 환경 고르기 — 무엇을 만들 것인가로 갈립니다 코틀린으로 무엇을 만드나? 서버 · 백엔드 Spring Boot · Ktor 배치 · CLI · 라이브러리 안드로이드 앱 Jetpack Compose Play 스토어 배포 멀티플랫폼 (KMP) Android + iOS 한 벌 데스크톱 · 웹까지 JDK + IDEA + Gradle st02 · st04 · st05 약 2 GB · 30분 + Android Studio · SDK st06 10 GB 이상 · 1시간 + macOS · Xcode st07 30 GB 이상 · 반나절 디스크 용량 — 채운 칸이 최소치, 흐린 칸이 흔한 상한입니다 서버 2~4 GB 안드로이드 10~20 GB 멀티플랫폼 30~60 GB 0 20 40 60 GB 셋의 바닥은 모두 같습니다 — JDK 와 같은 코틀린 컴파일러. 그러니 st02(JDK)부터 시작하세요
갈래가 다르면 더 얹는 것이 다를 뿐입니다 — 바닥(JDK · Gradle · 코틀린)은 공통입니다.

세 갈래가 실제로 요구하는 것

필요한 것서버안드로이드KMP(iOS 포함)
JDK필수 21/25필수 17+필수 17+
IDEIntelliJ IDEAAndroid Studio둘 중 하나 + KMP 플러그인
Android SDK—필수필수
Xcode——iOS 타깃이면 필수(맥 전용)
에뮬레이터/기기—AVD 또는 실기기AVD + iOS 시뮬레이터
운영체제 제약아무거나아무거나iOS 빌드는 macOS 만
디스크2~4 GB10~20 GB30~60 GB
첫 빌드 시간1~3분5~15분10~30분
윈도우에서 iOS 는 못 만듭니다 — 애플이 Xcode 를 macOS 에만 제공하기 때문입니다. 윈도우/리눅스에서 KMP 를 해도 공용 로직 · 안드로이드 · 데스크톱 · 웹 타깃은 전부 됩니다. iOS 만 맥이나 CI(GitHub Actions macOS 러너)에서 빌드하는 방식이 현실적인 타협입니다(st07).

2026년 8월 기준 버전 지도

항목지금 고를 값
Kotlin2.2.x — K2 컴파일러가 기본. 새 프로젝트는 무조건 2.x
JDK21 LTS(호환 최우선) 또는 25 LTS(새 서버). 안드로이드는 17 권장
Gradle프로젝트가 들고 오는 wrapper 버전을 씁니다 — 직접 깔 필요 없음
IntelliJ IDEA최신 안정판. 코틀린 플러그인 내장
Android Studio최신 안정 채널. AGP 는 스튜디오가 권하는 버전
compileSdk최신 정식 API 레벨. minSdk 는 보통 24~26
coroutineskotlinx-coroutines-core 1.10.x 계열
serializationkotlinx-serialization-json 1.8.x 계열
버전은 외우지 말고 “어디를 보는지”를 외우세요 — 코틀린은 kotlinlang.org/docs/releases.html, 의존성 최신 판은 IntelliJ 가 build.gradle.kts 안에서 직접 알려 줍니다 (오래된 버전에 노란 밑줄이 그어지고 Alt+Enter 로 올릴 수 있습니다).

코틀린 컴파일러는 따로 안 깔아도 됩니다 🟢 기초

왜 “코틀린 설치”가 검색해도 안 나오나

코틀린 컴파일러는 Gradle 의존성 하나일 뿐입니다. build.gradle.kts 에 kotlin("jvm") version "2.2.0" 이라고 적으면 Gradle 이 그 버전의 컴파일러를 인터넷에서 받아 캐시합니다. 그래서 팀원 열 명이 각자 다른 코틀린을 깔아 두어도 빌드 결과는 똑같습니다.

파이썬의 python 이나 Node 의 node 처럼 PATH 에 있어야 하는 실행 파일이 아닙니다. kotlinc 를 깔면 편한 순간이 있긴 한데(st03), 없어도 개발에 아무 지장이 없습니다.

그래서 실제 설치 순서

① JDK 21 (또는 25)          ← 반드시 손으로   st02
② IntelliJ IDEA             ← 손으로          st04
   (안드로이드면 Android Studio)                st06
③ Gradle                    ← 안 깝니다.
   프로젝트의 ./gradlew 가 알아서 받습니다      st05
④ Kotlin 컴파일러           ← 안 깝니다.
   Gradle 이 의존성으로 받습니다
⑤ kotlinc CLI (선택)        ← 스크립트·실험용   st03
⑥ Android SDK (안드로이드)  ← Studio 안에서    st06
⑦ Xcode (iOS)              ← 맥 앱스토어      st07
손으로 깔아야 하는 것은 사실상 두 개(JDK · IDE)입니다. “설치가 복잡하다”는 인상은 대개 안드로이드 SDK 때문인데, 그것도 Android Studio 첫 실행 마법사가 대신 해 줍니다.

한 단계 더 — 팀에 들어갔을 때의 순서 🟡 중급

기존 프로젝트를 받았을 때

# 1) 이 프로젝트가 요구하는 JDK 버전부터 확인
grep -R "jvmToolchain\|sourceCompatibility\|JavaVersion" \
     build.gradle.kts app/build.gradle.kts 2>/dev/null

# 2) 코틀린 · AGP · Gradle 버전 확인
cat gradle/libs.versions.toml | head -20
cat gradle/wrapper/gradle-wrapper.properties

# 3) 그 JDK 만 깔면 나머지는 wrapper 가 맞춰 줍니다
./gradlew --version     # 이 명령 하나가 Gradle 을 받아 옵니다

# 4) 안드로이드 프로젝트면 SDK 경로만 알려 주면 끝
echo "sdk.dir=C:\\Users\\me\\AppData\\Local\\Android\\Sdk" > local.properties
순서가 중요합니다 — 프로젝트가 원하는 JDK 를 먼저 확인하고 그것부터 깝니다. “일단 최신 JDK 를 깔고 본다”가 Unsupported class file major version 의 가장 흔한 출발점입니다(st11).

이 탭을 읽는 법 — 갈래별 경로

내 목표읽을 순서
서버 · 백엔드st02 → st04 → st05 → st08 → st09 → st10 → st11
안드로이드st02 → st06 → st05 → st09 → st10 → st11
KMP · 크로스플랫폼st02 → st04 → st06 → st07 → st05 → st11
일단 문법만 해 보고 싶다st03(kotlinc · REPL) 만 읽어도 됩니다
이미 자바 환경이 있다st02 건너뛰고 st04 · st05 부터
st05(Gradle)와 st11(문제 사전)은 갈래와 무관하게 전부 읽으세요. 코틀린 환경 문제의 대부분은 “Gradle 이 어떤 JDK 로 어떤 코틀린을 돌리는가” 한 줄로 수렴합니다.
정리 — 코틀린은 JDK 위에 얹히는 언어이고, 컴파일러는 Gradle 이 받아 옵니다. 손으로 깔 것은 JDK 와 IDE 둘. 안드로이드면 Android Studio + SDK, iOS 까지면 맥 + Xcode 가 더 붙습니다. 다음 섹션에서 모든 갈래의 바닥인 JDK 부터 깝니다.
SETUP 02

JDK 설치 — Temurin 21 / 25 LTS 와 JAVA_HOME

코틀린 코드가 도는 자리는 JVM 입니다. 그래서 코틀린을 시작하는 첫 단계는 JDK 설치입니다. 여기서 JAVA_HOME 과 PATH 두 개만 제대로 잡아 두면 뒤에 나올 Gradle · IDE 문제의 절반이 사라집니다. 반대로 이 둘이 어긋나면 “Gradle 이 자꾸 옛날 자바로 빌드한다” 같은 이해 못 할 증상이 계속 따라다닙니다.

JAVA_HOME 과 PATH — 무엇을 가리키는 이름인가 JAVA_HOME 환경 변수 (폴더 경로) C:\Program Files\Eclipse Adoptium\jdk-21... JDK 설치 폴더 — bin 의 부모 폴더입니다 bin\ java.exe · javac.exe jar · jshell · keytool lib\ · conf\ 표준 라이브러리 모듈 보안 · 로깅 설정 PATH 명령을 찾는 폴더 목록 PATH 에는 %JAVA_HOME%\bin 을 넣습니다 — 그래야 어느 폴더에서나 java 가 불립니다 Gradle · IntelliJ · Android Studio 는 PATH 보다 JAVA_HOME 을 먼저 봅니다 — 둘을 같은 곳으로
JAVA_HOME 은 bin 의 부모입니다. ...\jdk-21\bin 까지 넣으면 도구들이 JDK 를 못 찾습니다.

윈도우 — adoptium.net 에서 .msi 받기

① https://adoptium.net/temurin/releases/ 접속
② 고르는 값 네 개
     Operating System : Windows
     Architecture     : x64   (ARM 노트북이면 aarch64)
     Package Type     : JDK   (JRE 아님!)
     Version          : 21 - LTS   (또는 25 - LTS)
③ 확장자가 .msi 인 파일을 받습니다
     .zip 은 압축만 풀리고 환경 변수를 안 잡아 줍니다
④ 실행 → Next → "Custom Setup" 화면에서 멈춥니다  ★
⑤ 아래 두 항목을 "Will be installed on local hard drive" 로
     ▸ Set JAVA_HOME variable
     ▸ Add to PATH
   (기본값은 X 표시 = 설치 안 함 입니다. 반드시 바꾸세요)
⑥ Install → 완료 → 열려 있던 터미널을 전부 닫습니다  ★
⑥ 을 빼먹는 사람이 정말 많습니다 — 이미 열려 있던 PowerShell·cmd·IntelliJ 는 예전 환경 변수를 복사해 들고 있습니다. 새로 열지 않으면 방금 깐 자바가 “없다”고 나옵니다. IntelliJ 도 완전히 종료 후 재실행해야 합니다.

명령 한 줄로 깔기 (winget · scoop)

# PowerShell (관리자 아니어도 됩니다)
winget install EclipseAdoptium.Temurin.21.JDK
winget install EclipseAdoptium.Temurin.25.JDK   # 둘 다 깔아도 됩니다

# 깔린 것 확인 · 지우기
winget list --name Temurin
winget uninstall EclipseAdoptium.Temurin.21.JDK

# scoop 을 쓴다면
scoop bucket add java
scoop install temurin21-jdk

# ── macOS ──
brew install --cask temurin@21

# ── Ubuntu / Debian ──
sudo apt install -y wget apt-transport-https gpg
wget -qO- https://packages.adoptium.net/artifactory/api/gpg/key/public \
  | sudo tee /etc/apt/trusted.gpg.d/adoptium.asc
echo "deb https://packages.adoptium.net/artifactory/deb \
  $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/adoptium.list
sudo apt update && sudo apt install -y temurin-21-jdk
winget 은 JAVA_HOME 을 잡아 줄 때도, 안 잡아 줄 때도 있습니다(패키지 버전에 따라 다릅니다). 설치 후 echo $env:JAVA_HOME 이 비어 있으면 아래 손으로 잡기로 가세요.

환경 변수 손으로 잡기 — 윈도우 화면 경로 🟢 기초

클릭 순서 (10단계)

① 시작 → "환경 변수" 검색
   → "시스템 환경 변수 편집" 클릭
   (또는 Win+R → sysdm.cpl → [고급] 탭)
② [환경 변수(N)...] 버튼
③ 아래쪽 "시스템 변수(S)" 목록에서 [새로 만들기(W)...]
④ 변수 이름 : JAVA_HOME
   변수 값   : C:\Program Files\Eclipse Adoptium\jdk-21.0.x-hotspot
   ▸ 폴더 안에 bin 이 보이는 그 경로입니다  ★
   ▸ 끝에 \bin 을 붙이지 마세요            ★
   ▸ 끝에 \ 를 붙이지 마세요
⑤ [확인]
⑥ 같은 목록에서 Path 를 고르고 [편집(E)...]
⑦ [새로 만들기] → %JAVA_HOME%\bin 입력
⑧ [위로 이동] 을 눌러 맨 위로 올립니다     ★
   (C:\Program Files\Common Files\Oracle\Java\javapath 보다 위)
⑨ 확인 → 확인 → 확인 (창 세 개 전부)
⑩ 터미널을 새로 열고 java -version
⑧ 의 “맨 위로” 가 핵심입니다. 윈도우는 PATH 를 위에서부터 훑어 첫 번째로 찾은 것을 씁니다. 예전에 오라클 자바를 깔았던 PC 에는 ...\Oracle\Java\javapath 가 위에 남아 있어 방금 깐 21 대신 옛날 8 이 계속 잡힙니다.

확인 — 이 네 줄이 전부 맞아야 합니다

# PowerShell (새로 연 창에서)
java -version
# openjdk version "21.0.x" 2026-xx-xx  같은 줄이 나오면 성공

javac -version
# javac 21.0.x   ← 이게 없으면 JRE 만 깔린 것입니다

echo $env:JAVA_HOME
# C:\Program Files\Eclipse Adoptium\jdk-21.0.x-hotspot

where.exe java
# 첫 줄이 %JAVA_HOME%\bin\java.exe 여야 합니다  ★
# C:\Program Files\Common Files\Oracle\Java\javapath\java.exe 가
# 첫 줄이면 PATH 순서를 못 고친 것입니다

# ── macOS / Linux ──
java -version
echo $JAVA_HOME
which -a java javac       # 여러 개 나오면 첫 줄이 쓰입니다
java -version 의 출력은 표준 에러(stderr)로 나갑니다. 스크립트에서 잡으려면 java -version 2>&1 로 합쳐야 합니다 — “분명히 화면에 뜨는데 변수에 안 담긴다”의 원인입니다.

한 단계 더 — 여러 JDK 를 오가며 쓰기 🟡 중급

macOS · Linux — SDKMAN!

# 설치 (curl 한 줄)
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"

# 받을 수 있는 JDK 목록
sdk list java | head -40

# 설치 — 이름 규칙: <버전>-<벤더>
sdk install java 21.0.5-tem     # Temurin 21
sdk install java 25-tem         # Temurin 25

# 지금 셸에서만 바꾸기 (프로젝트마다 다를 때)
sdk use java 21.0.5-tem

# 기본값으로 고정
sdk default java 21.0.5-tem

# 폴더에 .sdkmanrc 를 두면 cd 할 때 자동 전환됩니다
echo "java=21.0.5-tem" > .sdkmanrc
sdk env install && sdk env

# 보너스 — 코틀린 CLI 도 여기서 (st03)
sdk install kotlin
sdk install gradle
SDKMAN! 이 하는 일은 심볼릭 링크 하나를 옮기는 것뿐입니다. ~/.sdkman/candidates/java/current 가 실제 JDK 폴더를 가리키고, JAVA_HOME 은 그 링크를 가리킵니다. 마법이 아니라서 고장 나도 원인이 보입니다.

윈도우 — 굳이 도구를 안 써도 됩니다

# 지금 터미널에서만 21 로 바꾸기
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.5-hotspot"
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"
java -version      # 이 창에서만 21

# 자주 쓴다면 프로필에 함수로 넣어 둡니다
notepad $PROFILE
# --- 아래를 붙여 넣기 ---
function j21 {
  $env:JAVA_HOME="C:\Program Files\Eclipse Adoptium\jdk-21.0.5-hotspot"
  $env:PATH="$env:JAVA_HOME\bin;$env:PATH"; java -version
}
function j25 {
  $env:JAVA_HOME="C:\Program Files\Eclipse Adoptium\jdk-25.0.1-hotspot"
  $env:PATH="$env:JAVA_HOME\bin;$env:PATH"; java -version
}
사실 코틀린 개발에서는 이게 거의 필요 없습니다. Gradle 의 jvmToolchain(21)(st05)이 프로젝트별 JDK 를 빌드 파일 안에서 정해 주고, 없으면 자동으로 받아 오기 때문입니다. 손으로 전환하는 일은 터미널에서 java -jar 로 직접 돌릴 때 정도입니다.
안드로이드 개발자를 위한 예외 — Android Studio 는 자체 내장 JDK(JBR, JetBrains Runtime)를 들고 다닙니다. 그래서 PC 에 JDK 가 없어도 안드로이드 빌드는 됩니다. 다만 터미널에서 ./gradlew 를 직접 칠 때는 시스템 JDK 가 쓰이므로, Studio 에서는 되는데 터미널에서만 실패하는 현상이 생깁니다 — 이때는 File → Settings → Build Tools → Gradle → Gradle JDK 에서 Studio 가 쓰는 JDK 를 확인해 시스템 JAVA_HOME 과 맞추면 됩니다(st11).
정리 — adoptium.net 에서 Temurin 21(또는 25) .msi, 마법사에서 JAVA_HOME · Add to PATH 두 체크박스, 설치 후 터미널 새로 열기. 확인은 java -version · javac -version · echo $env:JAVA_HOME · where.exe java 네 줄. JAVA_HOME 은 언제나 bin 의 부모 폴더입니다.
SETUP 03

코틀린 컴파일러 · kotlinc — 없어도 되지만 알면 좋은 것

실무 프로젝트에서 kotlinc 를 직접 치는 일은 거의 없습니다 — Gradle 과 IDE 가 대신 부릅니다. 그런데도 이 섹션이 있는 이유는 두 가지입니다. 첫째, “코틀린이 어떻게 실행되는가”를 한 번 눈으로 보면 뒤에 나오는 Gradle 설정이 전부 이해되기 때문이고, 둘째 REPL 과 .kts 스크립트는 프로젝트를 만들지 않고 문법을 실험할 때 정말 편하기 때문입니다.

코틀린 코드를 돌리는 세 가지 경로 — 끝은 모두 JVM hello.kt 사람이 쓴 코틀린 kotlinc K2 컴파일러 hello.jar (.class 바이트코드) -include-runtime 이면 표준 라이브러리 포함 경로 ① 컴파일해서 배포 — 실제 서비스가 나가는 방식 kotlinc hello.kt -include-runtime -d hello.jar → java -jar hello.jar 경로 ② 스크립트로 즉시 실행 — 자동화 · 잡일 backup.main.kts 를 kotlin backup.main.kts 로 바로 실행 (컴파일 파일 안 남음) 경로 ③ REPL 로 한 줄씩 — 문법 실험 · 라이브러리 확인 kotlinc 만 치면 대화형 셸이 뜹니다. :quit 으로 나갑니다 세 경로 모두 JVM 위에서 돕니다 — JDK 가 없으면 셋 다 실행되지 않습니다
Gradle 은 결국 경로 ①을 자동으로 해 주는 것입니다 — 원리는 이 한 줄이 전부입니다.

설치 — macOS · Linux

# SDKMAN! (권장)
sdk install kotlin
kotlinc -version

# Homebrew
brew install kotlin

# 특정 버전으로
sdk list kotlin
sdk install kotlin 2.2.0
sdk use kotlin 2.2.0

SDKMAN! 은 JDK · 코틀린 · Gradle 을 한 도구로 관리합니다.

설치 — 윈도우

# scoop (가장 간편)
scoop install kotlin

# Chocolatey
choco install kotlinc

# 손으로 — github.com/JetBrains/kotlin
#  releases 에서 kotlin-compiler-2.2.x.zip
#  1) C:\kotlinc 에 압축 풀기
#  2) PATH 에 C:\kotlinc\bin 추가
#  3) 새 터미널에서 kotlinc -version
kotlinc 는 배치 파일입니다 — kotlinc.bat. PowerShell 에서 안 잡히면 PATH 에 \bin 까지 넣었는지 확인하세요.

첫 실행 — 30초

# hello.kt 를 만들고
echo 'fun main() = println("안녕")' > hello.kt

# 컴파일 (첫 실행은 20~40초, 느립니다)
kotlinc hello.kt -include-runtime -d hello.jar

# 실행
java -jar hello.jar
# 안녕
첫 컴파일이 유난히 느립니다 — 컴파일러 자체가 JVM 앱이라 JIT 이 데워지기 전이기 때문입니다. Gradle 데몬이 이 비용을 없애 줍니다(st10).

kotlinc 옵션 — 알아 둘 만한 것만 🟢 기초

자주 쓰는 옵션

옵션뜻
-d <경로>결과물 위치. .jar 로 끝나면 jar, 폴더면 .class 들
-include-runtime코틀린 표준 라이브러리를 jar 안에 넣습니다. 이게 없으면 실행 시 NoClassDefFoundError ★
-cp / -classpath쓰는 라이브러리 jar 경로
-jvm-target 21어느 JVM 버전용 바이트코드를 뽑을지
-script.kts 파일을 스크립트로 실행
-Werror경고를 에러로 — CI 에서 씁니다
-verbose무슨 파일을 읽는지 전부 출력(문제 추적용)
-version버전 확인. K2 여부도 여기서
-include-runtime 을 기억하세요. 자바와 달리 코틀린은 kotlin-stdlib 가 런타임에 반드시 필요합니다. String?.let · listOf 같은 것이 전부 거기 있습니다.

최상위 main 은 어떤 클래스가 되나

// Hello.kt
fun main() {
    println("안녕")
}
kotlinc Hello.kt -d out/
ls out/
# HelloKt.class     ← 파일명 + "Kt"

# 그래서 java 로 직접 부를 때는
java -cp "out:$KOTLIN_HOME/lib/kotlin-stdlib.jar" HelloKt

# jar 안을 들여다보면 확인됩니다
jar tf hello.jar | head
# META-INF/MANIFEST.MF
# HelloKt.class
# kotlin/...  (stdlib 이 통째로 들어 있음)
이 규칙이 Gradle 설정에 그대로 나옵니다 — application { mainClass.set("com.example.MainKt") } 의 그 Kt 입니다(st05). 파일 이름을 바꾸면 클래스 이름도 바뀌므로 @file:JvmName("Main") 로 고정할 수 있습니다.

한 단계 더 — Kotlin Script (.kts) 🟡 중급

.main.kts — 의존성까지 쓰는 단일 파일 스크립트

#!/usr/bin/env kotlin
// tidy.main.kts — 파일 이름이 반드시 .main.kts 여야 합니다

@file:DependsOn("com.squareup.okhttp3:okhttp:4.12.0")   // 의존성을 코드 안에서!

import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File

val target = File(args.getOrNull(0) ?: ".")     // args 가 바로 쓰입니다
println("정리할 폴더: ${target.absolutePath}")

target.walkTopDown()
    .filter { it.isFile && it.extension == "tmp" }
    .forEach { println("삭제 대상: ${it.name}") }

val client = OkHttpClient()
val res = client.newCall(Request.Builder().url("https://example.com").build()).execute()
println("응답 코드: ${res.code}")
kotlin tidy.main.kts ./build
# 첫 실행은 의존성을 받느라 느리고, 두 번째부터는 캐시됩니다

.kts 를 어디에 쓰나

쓰임설명
build.gradle.kts가장 많이 만나는 .kts. Gradle 이 이 파일을 스크립트로 컴파일해 실행합니다(st05)
운영 잡일 스크립트배포 전 점검 · 로그 파싱 — bash 보다 타입이 안전
단발성 실험라이브러리 API 를 한 번 눌러 볼 때. 프로젝트를 안 만들어도 됩니다
NotebookKotlin Kernel for Jupyter — 데이터 실험
테스트 픽스처테스트 데이터 생성 스크립트
.kts 는 느립니다 — 실행할 때마다 컴파일이 먼저 일어납니다. 수백 번 도는 자동화라면 차라리 jar 로 만들어 두는 편이 낫습니다. “가끔 손으로 부르는 잡일”에 어울립니다.
IntelliJ 에서 .kts 를 열면 자동완성이 됩니다 — @file:DependsOn 을 쓰면 IDE 가 그 라이브러리를 인덱싱해 줍니다. bash 스크립트에는 없는 경험이라, 팀 스크립트를 코틀린으로 옮기는 회사가 늘고 있습니다.

REPL — 가장 빠른 실험실 🟢 기초

$ kotlinc
Welcome to Kotlin version 2.2.0 (JRE 21.0.5+11)
Type :help for help, :quit for quit

>>> val nums = listOf(3, 1, 4, 1, 5, 9, 2, 6)
>>> nums.sorted()
res1: kotlin.collections.List<kotlin.Int> = [1, 1, 2, 3, 4, 5, 6, 9]

>>> nums.groupBy { it % 2 == 0 }
res2: ... = {false=[3, 1, 1, 5, 9], true=[4, 2, 6]}

>>> data class User(val name: String, val age: Int)
>>> User("현우", 34)
res4: Line_3.User = User(name=현우, age=34)

>>> :help      # 명령 목록
>>> :quit      # 나가기

# IntelliJ 에도 같은 것이 있습니다 (프로젝트 없이도 가능)
#   Tools → Kotlin → Kotlin REPL
# 그리고 더 편한 것 — Scratch File (Ctrl+Alt+Shift+Insert)
#   .kts 스크래치 파일을 만들면 저장 없이 실행 결과가 옆에 뜹니다
실무에서 가장 많이 쓰는 실험 도구는 사실 “스크래치 파일”입니다 — IntelliJ 에서 Ctrl+Alt+Shift+Insert → Kotlin 을 고르면 지금 프로젝트의 의존성을 그대로 쓰는 임시 .kts 가 열립니다. JSON 파싱을 한 번 눌러 보거나 정규식을 시험할 때, 테스트 클래스를 만들지 않고도 결과를 오른쪽 창에서 바로 볼 수 있습니다.
정리 — kotlinc 는 선택 사항입니다. 다만 kotlinc a.kt -include-runtime -d a.jar → java -jar a.jar 한 줄이 코틀린 실행의 전부라는 것을 알아 두면 Gradle 설정이 “그걸 자동화한 것”으로 보입니다. 실험은 REPL 이나 IntelliJ 스크래치 파일이 가장 빠릅니다.
SETUP 04

IntelliJ IDEA — 코틀린을 만든 회사의 IDE

코틀린은 젯브레인스가 자기들 IDE 를 더 잘 만들려고 시작한 언어입니다. 그래서 IntelliJ 의 코틀린 지원은 다른 에디터와 비교 대상이 아닙니다 — 자바 코드를 붙여 넣으면 코틀린으로 바꿔 주고, 코루틴 중단 지점에 아이콘이 뜨고, build.gradle.kts 안에서 의존성 최신 버전을 알려 줍니다. Community(무료)판으로도 코틀린 개발에는 전혀 부족하지 않습니다.

자바 버전이 정해지는 곳은 세 군데입니다 — 다르면 헷갈립니다 ① Project SDK File → Project Structure 에디터가 코드를 볼 때 자동완성 · 빨간 줄 내 PC 에만 적용 ② Gradle JVM Settings → Gradle Gradle 데몬이 도는 JDK 빌드 실행 주체 내 PC 에만 적용 ③ jvmToolchain build.gradle.kts 안 바이트코드 대상 버전 없으면 Gradle 이 받아옴 팀 전체에 적용 ★ 셋이 어긋나면 — 에디터는 멀쩡한데 빌드만 실패하거나, 그 반대가 됩니다 "IDE 에서는 되는데 터미널 ./gradlew 에서만 안 된다" 의 정확한 원인입니다 맞추는 순서 — ③ 을 기준으로 ①②를 따라가게 합니다 ③ 빌드 파일에 21 을 박는다 jvmToolchain(21) ② Gradle JVM = 21 Settings 에서 지정 ① Project SDK = 21 Project Structure 에서 팀원이 바뀌어도 유지되는 것은 ③ 뿐입니다 — 그래서 ③ 을 진실의 근원으로 삼습니다
버전 혼란의 90%는 이 세 칸이 서로 다른 것입니다 — jvmToolchain 을 기준으로 통일하세요.

설치 — Toolbox App 을 권합니다

① https://www.jetbrains.com/toolbox-app/ 에서 Toolbox 설치
② Toolbox 실행 → IntelliJ IDEA Community Edition → [Install]
③ 끝. 업데이트도 Toolbox 가 알아서 합니다

왜 Toolbox 인가
  ▸ 버전을 여러 개 깔아 두고 프로젝트별로 다르게 열 수 있습니다
  ▸ 업데이트가 실패해도 이전 버전으로 되돌리기가 한 번의 클릭
  ▸ Android Studio · DataGrip 도 같은 자리에서 관리
  ▸ 설정(JVM 힙 크기 등)을 Toolbox 화면에서 바꿀 수 있습니다

명령 한 줄로 깔고 싶다면
  Windows  winget install JetBrains.Toolbox
  macOS    brew install --cask jetbrains-toolbox
  Linux    tar.gz 를 받아 실행 (AppImage 형태)
Community 와 Ultimate 의 차이 — 코틀린 언어 지원은 완전히 같습니다. Ultimate 에만 있는 것은 Spring 전용 지원 · 데이터베이스 도구 · HTTP 클라이언트 · 프로파일러 정도입니다. 스프링으로 업무를 한다면 Ultimate 가 값을 합니다(빈 그래프·엔드포인트 목록·설정 자동완성). 배우는 단계라면 Community 로 시작해도 아무 문제 없습니다.

첫 프로젝트 만들기 — New Project

① 시작 화면 → [New Project]
② 왼쪽 목록에서 "Kotlin" 을 고릅니다  (Java 아님)
③ 오른쪽 칸을 채웁니다
     Name          : hello-kotlin
     Location      : C:\dev\hello-kotlin
                     ▸ 한글·공백 없는 경로로!  ★  (st11)
     Build system  : Gradle          ← IntelliJ 가 아니라 Gradle
     Gradle DSL    : Kotlin          ← Groovy 아님  ★
     JDK           : 21 (Temurin)    ← st02 에서 깐 것
     Add sample code : 체크
④ [Create] → 오른쪽 아래에 "Gradle sync" 진행 표시
     첫 sync 는 Gradle 배포판을 받느라 2~5분 걸립니다
⑤ src/main/kotlin/Main.kt 의 main 왼쪽 ▶ 을 눌러 실행
     아래 Run 창에 Hello, World! 가 뜨면 완성
Build system 을 "IntelliJ" 로 두지 마세요. IDE 전용 빌드라 터미널·CI 에서 빌드가 안 됩니다. 학습용 장난감 프로젝트가 아니라면 항상 Gradle 입니다. 그리고 Gradle DSL 은 Kotlin — Groovy 를 고르면 build.gradle(kts 없음)이 생겨 이 가이드의 예제와 문법이 달라집니다.

만들어진 프로젝트 구조 🟢 기초

폴더 하나하나가 뭔지

hello-kotlin/
├─ build.gradle.kts          이 모듈의 빌드 설정 (st05)
├─ settings.gradle.kts       프로젝트 이름 · 포함 모듈
├─ gradle.properties         JVM 메모리 · 데몬 옵션 (st10)
├─ gradlew  gradlew.bat      ★ Gradle 래퍼 — 이걸로 빌드합니다
├─ gradle/
│   ├─ wrapper/
│   │   └─ gradle-wrapper.properties   쓸 Gradle 버전이 여기 적혀 있음
│   └─ libs.versions.toml    버전 카탈로그 (st05)
├─ src/
│   ├─ main/kotlin/          내 코드
│   │   └─ Main.kt
│   ├─ main/resources/       설정 파일 · 정적 파일
│   ├─ test/kotlin/          테스트 코드
│   └─ test/resources/
├─ build/                    ★ 빌드 결과물 — 지워도 됩니다
└─ .gradle/                  ★ 로컬 캐시 — 지워도 됩니다

.gitignore 에 반드시 들어가야 할 것
  build/   .gradle/   .idea/   local.properties   *.iml

기억할 단축키 — 이것만

키 (Windows)하는 일
Shift 두 번무엇이든 검색 — 파일·설정·액션 전부
Alt+Enter이 빨간 줄 고쳐 줘 — 가장 많이 쓰는 키 ★
Ctrl+Alt+Shift+K자바 코드 → 코틀린 변환 ★
Ctrl+Alt+L코드 정렬(포맷)
Ctrl+B정의로 이동
Shift+F10직전에 돌린 것 다시 실행
Ctrl+Shift+F프로젝트 전체 문자열 검색
Ctrl+Alt+Shift+Insert스크래치 파일 (실험용 .kts)
자바를 아는 사람에게는 Ctrl+Alt+Shift+K 가 최고의 교재입니다. 스택오버플로의 자바 답변을 복사해 .kt 파일에 붙여 넣으면 “코틀린으로 변환할까요?” 를 물어봅니다. 변환 결과를 읽는 것만으로 관용구가 눈에 익습니다.

한 단계 더 — K2 모드와 코틀린 버전 🟡 중급

헷갈리는 “세 개의 코틀린 버전”

무엇어디서 정해지나
IDE 플러그인 버전IntelliJ 버전에 따라옴. 에디터가 코드를 이해하는 능력
컴파일러 버전kotlin("jvm") version "2.2.0" — 실제 빌드에 쓰이는 것 ★
언어 버전languageVersion — 어떤 문법까지 허용할지(하위 호환용)
// build.gradle.kts — 언어/API 버전을 낮춰 잡아야 할 때
kotlin {
    compilerOptions {
        languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0)
        apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0)
        freeCompilerArgs.add("-Xjsr305=strict")   // 자바 @Nullable 을 엄격히 해석
    }
}
보통은 셋을 신경 쓸 필요가 없습니다. 라이브러리를 만들어 배포하는데 옛 코틀린을 쓰는 사용자를 지원해야 할 때만 languageVersion 을 낮춥니다.

K2 모드 — 켜져 있는지 확인

K2 는 코틀린 2.0 부터 들어온 새 컴파일러 프론트엔드입니다. 타입 추론이 정확해지고 컴파일이 눈에 띄게 빨라졌습니다. Kotlin 2.x 를 쓰면 빌드는 자동으로 K2 이고, IDE 의 코드 분석기도 최신 IntelliJ 에서는 K2 모드가 기본입니다.

확인 위치
  Settings → Languages & Frameworks → Kotlin
    ▸ "Enable K2 mode" 체크 상태

증상으로 알아채기
  ▸ 큰 파일에서 자동완성이 확 빨라졌다   → K2 켜짐
  ▸ 빌드는 되는데 에디터만 빨간 줄       → 캐시 문제일 가능성
     File → Invalidate Caches → Invalidate and Restart
“빌드는 되는데 IDE 만 빨갛다”는 거의 항상 캐시입니다. 순서대로 시도하세요 — ① Gradle 창의 새로고침(⟳) → ② ./gradlew --stop → ③ Invalidate Caches and Restart. ①에서 90% 가 해결됩니다.
메모리를 늘려 두면 체감이 달라집니다 — 기본 힙이 부족하면 큰 프로젝트에서 인덱싱이 느리고 자동완성이 끊깁니다. Help → Change Memory Settings 에서 -Xmx 를 4096MB 이상으로 올리세요 (RAM 16GB 이상이면 6144MB). Toolbox 를 쓰면 톱니바퀴 → Settings 에서도 바꿀 수 있습니다. 안드로이드 프로젝트라면 여기에 Gradle 데몬 메모리도 따로 올려야 합니다(st10).
정리 — Toolbox → IntelliJ IDEA Community, New Project 에서 Kotlin · Gradle · Kotlin DSL · JDK 21. 자바 버전이 정해지는 곳은 Project SDK · Gradle JVM · jvmToolchain 세 군데이고 toolchain 을 기준으로 통일합니다. 빨간 줄이 이상하면 Gradle 새로고침 → --stop → Invalidate Caches 순서입니다.
SETUP 05

Gradle Kotlin DSL — build.gradle.kts 완전 기초

코틀린 개발에서 가장 많이 헤매는 파일이 이것입니다. 문법은 코틀린인데 하는 일은 빌드 설정이라 “이게 왜 되는지” 감이 안 잡히기 때문입니다. 여기서는 ./gradlew build 를 치면 실제로 무슨 일이 일어나는지부터 보고, settings.gradle.kts · build.gradle.kts · libs.versions.toml 세 파일이 각각 무엇을 맡는지 나눠 설명합니다.

./gradlew build 를 치면 벌어지는 일 — 여섯 단계 1 래퍼가 Gradle 버전을 확인 — 없으면 내려받아 캐시 gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 을 봅니다 2 Gradle 데몬(백그라운드 JVM)을 띄우거나 재사용 첫 실행만 느립니다. 두 번째부터 JIT 이 데워져 있어 빠릅니다 (st10) 3 settings.gradle.kts — 어떤 모듈이 이 빌드에 들어오나 rootProject.name 과 include(":app") 이 여기 있습니다 4 build.gradle.kts 를 코틀린으로 컴파일해 실행 — 구성 단계 플러그인 적용 · 의존성 선언 · 태스크 등록. 여기가 느리면 빌드 전체가 느립니다 5 태스크 그래프를 만들고 최신인 것은 건너뜀 (UP-TO-DATE) 입력이 안 바뀌었으면 다시 안 합니다 — 증분 빌드의 원리 6 compileKotlin → test → jar 순서로 실행 — 실행 단계 결과물은 build/libs/ 아래에 떨어집니다
4단계(구성)와 6단계(실행)를 구분하는 것이 Gradle 을 이해하는 첫걸음입니다.

settings.gradle.kts — 프로젝트의 지도

// settings.gradle.kts — 빌드에서 가장 먼저 읽히는 파일
rootProject.name = "hello-kotlin"

// 플러그인을 어디서 받을지 (플러그인 전용 저장소 설정)
pluginManagement {
    repositories {
        gradlePluginPortal()
        mavenCentral()
        google()                 // 안드로이드 플러그인은 여기에
    }
}

// 라이브러리를 어디서 받을지 — 모듈마다 안 적고 여기 한 번에
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        mavenCentral()
        google()
    }
}

// 멀티모듈 — 폴더 이름을 적습니다
include(":app")
include(":core:domain")          // core/domain 폴더
include(":core:data")
FAIL_ON_PROJECT_REPOS 를 켜 두세요. 모듈마다 repositories { } 를 제각각 적는 것을 빌드가 막아 줍니다. 저장소가 흩어지면 “어떤 모듈만 사내 넥서스를 못 본다” 같은 문제가 생깁니다.

build.gradle.kts — 최소한의 완전한 형태

plugins {
    kotlin("jvm") version "2.2.0"        // 코틀린 JVM 컴파일
    kotlin("plugin.serialization") version "2.2.0"
    application                          // ./gradlew run 을 만들어 줌
}

group = "com.example"
version = "0.1.0"

kotlin {
    jvmToolchain(21)                     // ★ 이 한 줄이 팀의 자바 버전
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")

    testImplementation(kotlin("test"))
    testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
}

application {
    mainClass.set("com.example.MainKt")  // 최상위 main → 파일명 + Kt
}

tasks.test {
    useJUnitPlatform()                   // JUnit 5 를 쓰겠다는 선언
    testLogging { events("passed", "failed", "skipped") }
}

jvmToolchain — 가장 중요한 한 줄 🟢 기초

이 한 줄이 하는 일

kotlin {
    jvmToolchain(21)
}

// 위 한 줄이 아래 전부를 대신합니다
//   ▸ 코틀린 컴파일러가 21 용 바이트코드를 만든다
//   ▸ javac 도 21 로 컴파일한다
//   ▸ 테스트도 21 JVM 에서 돈다
//   ▸ 내 PC 에 21 이 없으면? Gradle 이 자동으로 받아 옵니다  ★

// 세밀하게 지정하고 싶으면
kotlin {
    jvmToolchain {
        languageVersion.set(JavaLanguageVersion.of(21))
        vendor.set(JvmVendorSpec.ADOPTIUM)     // Temurin 만 쓰겠다
    }
}
“내 PC 에 없으면 받아 온다”가 핵심입니다. 새 팀원이 JDK 17 만 깔고 들어와도 ./gradlew build 한 번이면 21 을 자동으로 내려받아 같은 결과를 냅니다. “나는 되는데 너는 안 되네”가 사라지는 지점입니다.

자주 쓰는 Gradle 명령

명령하는 일
./gradlew build컴파일 + 테스트 + jar
./gradlew runapplication 플러그인이 만든 실행
./gradlew test테스트만
./gradlew clean buildbuild/ 를 지우고 처음부터
./gradlew tasks쓸 수 있는 태스크 목록 ★
./gradlew dependencies의존성 트리 — 버전 충돌 추적
./gradlew --stop데몬 종료. 설명 없는 에러의 첫 처방 ★
./gradlew build --refresh-dependencies캐시 무시하고 다시 받기
./gradlew build --info / --stacktrace왜 실패했는지 자세히
./gradlew wrapper --gradle-version 8.14래퍼 버전 올리기
윈도우에서는 ./gradlew 대신 .\gradlew.bat 입니다 (PowerShell 은 ./gradlew 도 대개 먹힙니다). 그리고 절대 gradle 이라고 치지 마세요 — 시스템에 깔린 아무 버전이 잡혀 팀과 다른 결과가 나옵니다. 항상 래퍼입니다.

한 단계 더 — 버전 카탈로그(libs.versions.toml) 🟡 중급

gradle/libs.versions.toml

[versions]
kotlin = "2.2.0"
coroutines = "1.10.2"
serialization = "1.8.1"
ktor = "3.1.0"
junit = "5.11.4"

[libraries]
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }

[bundles]
ktor-server = ["ktor-server-core"]

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
// build.gradle.kts 에서 이렇게 씁니다
plugins {
    alias(libs.plugins.kotlin.jvm)
    alias(libs.plugins.kotlin.serialization)
}

dependencies {
    implementation(libs.kotlinx.coroutines)
    implementation(libs.kotlinx.json)
    implementation(libs.bundles.ktor.server)
    testImplementation(libs.junit.jupiter)
}

왜 이렇게 하나

  • 버전이 한 파일에 모입니다 — 모듈이 열 개여도 코틀린 버전은 한 줄만 고칩니다
  • IDE 자동완성이 됩니다 — libs. 를 치면 목록이 뜹니다
  • 오타가 컴파일 에러가 됩니다 — 문자열이 아니라 타입이니까
  • Renovate·Dependabot 이 이 파일만 고쳐 PR 을 올립니다
  • 안드로이드 프로젝트는 새로 만들면 기본으로 이 구조입니다
이름 규칙에 하이픈을 쓰면 점으로 바뀝니다 — toml 의 kotlinx-coroutines 는 코드에서 libs.kotlinx.coroutines 가 됩니다. 대시 → 점 하나만 기억하면 됩니다.
모듈 두 개만 넘어가면 바로 도입하세요. 나중에 옮기는 것도 어렵지 않지만, 처음부터 쓰면 버전 충돌 디버깅 시간이 통째로 사라집니다.

사내망 · 프록시 · 미러 🔴 고급

gradle.properties 에 프록시 적기

# ~/.gradle/gradle.properties  (내 PC 전체에 적용 — 프로젝트에 커밋하지 말 것)
systemProp.http.proxyHost=proxy.company.com
systemProp.http.proxyPort=8080
systemProp.https.proxyHost=proxy.company.com
systemProp.https.proxyPort=8080
systemProp.http.nonProxyHosts=localhost|127.0.0.1|*.company.com

# 인증이 필요하면
systemProp.http.proxyUser=myid
systemProp.http.proxyPassword=mypw
// settings.gradle.kts — 사내 넥서스를 먼저 보게
dependencyResolutionManagement {
    repositories {
        maven {
            url = uri("https://nexus.company.com/repository/maven-public/")
            isAllowInsecureProtocol = false
            credentials {
                username = providers.gradleProperty("nexusUser").orNull
                password = providers.gradleProperty("nexusPass").orNull
            }
        }
        mavenCentral()   // 넥서스에 없으면 여기로 (사내 정책상 막힐 수도)
    }
}

사내 인증서 · 래퍼 다운로드 실패

# 증상: gradlew 가 Gradle 배포판 내려받다 멈추거나
#       PKIX path building failed 로 죽는다

# ① 사내 루트 인증서를 JDK 신뢰 저장소에 넣기
keytool -importcert -alias company-root \
  -file company-root.cer \
  -cacerts -storepass changeit

# ② 래퍼 URL 을 사내 미러로 바꾸기
#    gradle/wrapper/gradle-wrapper.properties
# distributionUrl=https\://nexus.company.com/repository/gradle/gradle-8.14-bin.zip

# ③ 최후의 수단 — 배포판을 손으로 넣기
#    받은 zip 을 ~/.gradle/wrapper/dists/... 아래에 그대로 풀어 둡니다
ls ~/.gradle/wrapper/dists/
프록시 설정을 프로젝트의 gradle.properties 에 커밋하지 마세요. 집에서 받은 사람은 존재하지 않는 프록시로 접속하려다 멈춥니다. 개인 설정은 반드시 ~/.gradle/gradle.properties(홈 폴더)에 둡니다.
정리 — settings 는 지도, build.gradle.kts 는 이 모듈의 설정, libs.versions.toml 은 버전 창고입니다. 자바 버전은 jvmToolchain(21) 한 줄로 못 박고, 빌드는 항상 래퍼(./gradlew)로 돌립니다. 설명 없는 에러가 나면 ./gradlew --stop 이 첫 처방입니다.
SETUP 06

Android Studio — SDK · AVD · 실기기까지

안드로이드 환경이 무거운 이유는 깔아야 할 것이 IDE 하나가 아니기 때문입니다. Studio(IDE) 아래에 Android SDK 가 있고, 그 안에 플랫폼 · 빌드 툴 · 플랫폼 툴 · 에뮬레이터 이미지가 각각 따로 관리됩니다. 이 구조를 한 번 보고 나면 “SDK location not found” 같은 에러가 더 이상 무섭지 않습니다.

Android Studio 아래에 무엇이 깔리나 — SDK 폴더의 구조 Android Studio — IntelliJ 기반 IDE + 내장 JDK(JBR) + 에뮬레이터 실행기 이것만 깔면 아래 SDK 는 첫 실행 마법사가 알아서 받습니다 Android SDK 폴더 (기본: %LOCALAPPDATA%\Android\Sdk) platforms/ API 레벨별 프레임워크 android-36 · 35 · 34 compileSdk 가 고름 각 100 MB 안팎 build-tools/ aapt2 — 리소스 처리 d8 · r8 — dex · 축소 apksigner — 서명 AGP 가 버전을 고름 platform-tools/ adb — 기기와 대화 fastboot PATH 에 넣을 폴더 ★ 버전 하나만 존재 system-images/ 에뮬레이터 이미지 API × ABI × 종류 하나에 1.5~4 GB 용량의 주범 프로젝트는 local.properties 의 sdk.dir 한 줄로 이 폴더를 가리킵니다 이 파일은 PC 마다 경로가 달라 .gitignore 에 넣습니다 — 없으면 SDK location not found
디스크가 부족하면 system-images 부터 지우세요 — 나머지는 다 합쳐도 몇백 MB 입니다.

설치 — 첫 실행 마법사까지

① https://developer.android.com/studio 에서 받기
     Windows  .exe (약 1 GB)
     macOS    .dmg  (Apple Silicon / Intel 구분 확인)
     Linux    .tar.gz
② 설치 후 첫 실행 → Setup Wizard
     Install Type : Standard   ← 처음에는 이걸로
     (Custom 을 고르면 SDK 위치와 에뮬레이터 RAM 을 직접 지정)
③ 라이선스 동의 → 다운로드 시작 (2~6 GB, 10~30분)
     ▸ 회사망이면 여기서 프록시 설정 창이 뜹니다
④ 완료 후 [New Project] → "Empty Activity" (Compose)
     Name          : MyApp
     Package name  : com.example.myapp
     Save location : C:\dev\MyApp     ▸ 한글·공백 없이!  ★
     Minimum SDK   : API 26 이상 권장
     Build config  : Kotlin DSL (build.gradle.kts)   ★
⑤ [Finish] → 첫 Gradle sync 는 3~10분 걸립니다
Studio 는 IntelliJ Community 를 품고 있습니다. 그래서 서버 코드도 Studio 에서 열 수 있고, 반대로 IntelliJ Ultimate 에 안드로이드 플러그인을 깔아도 됩니다. 다만 안드로이드 작업은 Studio 가 최신 도구를 먼저 받으므로 앱을 만든다면 Studio 를 기본으로 두는 편이 편합니다.

SDK Manager — 무엇을 체크하나

열기: Tools → SDK Manager
     (또는 오른쪽 위 세로 점 아이콘 → SDK Manager)

[SDK Platforms] 탭
  ▸ 최신 정식 API 하나 (compileSdk 로 쓸 것)         필수
  ▸ minSdk 로 쓸 낮은 API 는 굳이 안 받아도 됩니다
  ▸ "Show Package Details" 를 켜면 세부 항목이 보입니다
      Android SDK Platform 36        ← 필수
      Sources for Android 36         ← 있으면 프레임워크 코드를 읽을 수 있음
      Google APIs Intel x86_64 System Image  ← 에뮬레이터용

[SDK Tools] 탭
  ▸ Android SDK Build-Tools          필수
  ▸ Android SDK Platform-Tools       필수 (adb)
  ▸ Android Emulator                 에뮬레이터 쓸 때
  ▸ Android Emulator hypervisor driver  (인텔 CPU 가속)
  ▸ Google USB Driver                윈도우에서 실기기 붙일 때
  ▸ NDK (Side by side)               C/C++ 쓸 때만. 3 GB 넘습니다
  ▸ CMake                            NDK 와 세트
NDK 는 필요할 때만 받으세요. 기본으로 체크하면 3~4 GB 를 씁니다. 코틀린/Compose 만 쓴다면 평생 안 씁니다. 네이티브 라이브러리를 직접 빌드하거나 KMP 에서 iOS 가 아닌 안드로이드 네이티브를 다룰 때만 필요합니다.

ANDROID_HOME 과 adb 🟢 기초

환경 변수 잡기

# ── Windows (시스템 환경 변수 편집 — st02 와 같은 화면) ──
# 새 시스템 변수
#   ANDROID_HOME = C:\Users\내이름\AppData\Local\Android\Sdk
# Path 에 두 줄 추가
#   %ANDROID_HOME%\platform-tools
#   %ANDROID_HOME%\emulator

# ── macOS / Linux (~/.zshrc 또는 ~/.bashrc) ──
export ANDROID_HOME="$HOME/Library/Android/sdk"      # macOS
# export ANDROID_HOME="$HOME/Android/Sdk"           # Linux
export PATH="$PATH:$ANDROID_HOME/platform-tools"
export PATH="$PATH:$ANDROID_HOME/emulator"
export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin"

# 확인
adb version
sdkmanager --list | head
ANDROID_SDK_ROOT 라는 이름도 보입니다 — 옛 이름입니다. 지금은 ANDROID_HOME 이 표준이고, 둘 다 잡아 두면 오래된 도구까지 안전합니다. Studio 안에서만 개발한다면 사실 안 잡아도 됩니다 — 터미널에서 adb 나 CI 스크립트를 쓸 때 필요합니다.

adb — 기억할 명령

명령하는 일
adb devices붙은 기기 목록. 여기 안 나오면 그 뒤는 다 안 됩니다 ★
adb kill-server → adb start-server기기가 안 보일 때 첫 처방
adb install app.apkAPK 설치
adb logcat로그 보기. -s MyTag 로 필터
adb shell기기 안에서 셸
adb reverse tcp:8080 tcp:8080기기에서 내 PC 서버 부르기 ★
adb pull /sdcard/x.txt파일 가져오기
adb -s <시리얼> ...기기가 여러 대일 때 지정
adb reverse 를 꼭 기억하세요. 에뮬레이터·실기기에서 내 PC 에서 도는 백엔드를 부를 때 씁니다. 에뮬레이터라면 10.0.2.2 가 호스트 PC 를 뜻하지만, 실기기에서는 adb reverse 가 유일하게 편한 방법입니다.

에뮬레이터(AVD)와 실기기 🟡 중급

AVD 만들기 — Device Manager

Tools → Device Manager → [+] Create Virtual Device

① 기기 고르기   Pixel 8 정도가 무난 (Play Store 아이콘 ▶ 있는 것 권장)
② 시스템 이미지  API 레벨 + ABI 를 고릅니다
     ▸ 인텔/AMD PC   → x86_64 이미지    ★ (ARM 이미지 고르면 매우 느림)
     ▸ Apple Silicon → arm64-v8a 이미지 ★
     ▸ "Google Play" 판은 스토어가 들어 있음 (구글 로그인 테스트 가능)
     ▸ "Google APIs" 판은 지도·위치 등 GMS 만
③ 이름·방향·설정
     Advanced Settings 에서
       RAM        2048 MB 이상
       Internal   4 GB 이상
       Graphics   Hardware - GLES 2.0   ★ 이게 Software 면 느립니다
       Cold boot / Quick boot — Quick boot 가 빠릅니다
④ Finish → ▶ 로 실행

명령줄로도 가능
  sdkmanager "system-images;android-36;google_apis;x86_64"
  avdmanager create avd -n Pixel8 -k "system-images;android-36;google_apis;x86_64"
  emulator -avd Pixel8 -no-snapshot-load

실기기 USB 디버깅 — 6단계

① 설정 → 휴대전화 정보 → "빌드 번호" 를 7번 연타
     "개발자가 되었습니다" 토스트가 뜹니다
② 설정 → 시스템 → 개발자 옵션 진입
③ [USB 디버깅] 켜기
④ USB 로 PC 에 연결 (충전 전용 케이블이면 인식 안 됩니다 ★)
⑤ 폰에 뜨는 "이 컴퓨터에서 USB 디버깅을 허용하시겠습니까?" → 허용
     ▸ "항상 허용" 을 체크해 두면 매번 안 물어봅니다
⑥ PC 에서 확인
     adb devices
     → List of devices attached
       R5CT10ABCDE   device      ← device 로 나와야 성공

무선으로 붙이기 (Android 11+)
  개발자 옵션 → [무선 디버깅] 켜기 → "페어링 코드로 기기 페어링"
  adb pair 192.168.0.5:37123     (화면의 포트·코드 입력)
  adb connect 192.168.0.5:5555
adb devices 에 unauthorized 로 뜬다면 ⑤ 의 허용 창을 놓친 것입니다. USB 를 뽑았다 꽂거나, 개발자 옵션의 “USB 디버깅 승인 취소” 후 다시 연결하세요. 아무것도 안 나오면 윈도우는 Google USB Driver 또는 제조사 드라이버가 필요합니다.
에뮬레이터가 안 뜨거나 아주 느릴 때 — 가상화가 원인입니다. 윈도우에서는 Hyper-V · WSL2 · Docker Desktop 과 Intel HAXM 이 서로 CPU 가상화를 다툽니다. 요즘 해법은 WHPX(Windows Hypervisor Platform) 를 쓰는 것입니다 — “Windows 기능 켜기/끄기”에서 Windows Hypervisor Platform 과 가상 머신 플랫폼을 켜고, BIOS 에서 VT-x / SVM 이 켜져 있는지 확인한 뒤 재부팅하세요. 그래도 안 되면 Graphics 를 Hardware 로, 이미지를 x86_64 로 바꿔 보고, 마지막에는 실기기가 언제나 가장 빠릅니다(st11).
정리 — developer.android.com/studio 에서 Studio 를 받고 첫 마법사에 맡기면 SDK 가 따라옵니다. 기억할 것은 SDK 폴더의 네 덩어리, PATH 에 넣는 platform-tools, local.properties 의 sdk.dir, 그리고 adb devices 가 모든 기기 문제의 출발점이라는 것입니다.
SETUP 07

Kotlin Multiplatform 준비 — Xcode · kdoctor

KMP 는 코드를 공유하되 UI 는 각자(또는 Compose Multiplatform 으로 UI 까지 공유)를 노리는 방식입니다. 환경 세팅의 핵심은 딱 하나 — iOS 타깃을 빌드하려면 macOS 와 Xcode 가 반드시 필요합니다. 이 사실을 먼저 받아들이면 나머지는 어렵지 않습니다. 윈도우·리눅스에서도 공용 · 안드로이드 · 데스크톱 · 웹 타깃은 전부 개발할 수 있습니다.

KMP 타깃 구조 — iOS 로 가는 길만 macOS 를 요구합니다 commonMain — 공용 코드 비즈니스 로직 · 네트워크 · 모델 · 검증 androidMain Android SDK 필요 Compose 로 UI Win · Mac · Linux 전부 iosMain Xcode · 시뮬레이터 SwiftUI 또는 Compose macOS 에서만 ★ jvmMain 데스크톱 앱 서버도 여기 어디서나 가능 js · wasmJs 브라우저 Compose for Web 어디서나 가능 윈도우 개발자의 현실적인 조합 내 PC (윈도우) 공용 · 안드로이드 · 데스크톱 개발 GitHub Actions macOS 러너 iOS 빌드 · 테스트만 여기서 (또는 맥미니 한 대)
iOS 를 뺀 세 방향은 어느 OS 에서나 개발됩니다 — 윈도우라고 KMP 를 포기할 이유는 없습니다.

프로젝트 만들기 — 두 가지 방법

방법 ① KMP 웹 마법사 (가장 쉬움)
   https://kmp.jetbrains.com 접속
     ▸ 타깃 체크: Android · iOS · Desktop · Web · Server
     ▸ UI 공유 여부: Compose Multiplatform 또는 각자 네이티브
   → Download → 압축 풀기 → IDE 로 열기

방법 ② IDE 플러그인
   IntelliJ IDEA 또는 Android Studio
     Settings → Plugins → "Kotlin Multiplatform" 설치
     New Project → Kotlin Multiplatform

만들어지는 구조
   composeApp/ (또는 shared/)
     src/commonMain/kotlin/     ★ 공용 코드
     src/androidMain/kotlin/     안드로이드 전용 구현
     src/iosMain/kotlin/         iOS 전용 구현
     src/jvmMain/kotlin/         데스크톱 전용
   iosApp/                       Xcode 프로젝트 (.xcodeproj)
   gradle/libs.versions.toml
expect / actual 이 KMP 문법의 핵심입니다 — 공용 코드에서 expect fun platformName(): String 로 모양만 선언하고 각 타깃에서 actual 로 구현합니다. 환경 세팅 관점에서 알아 둘 점은, 타깃을 하나 추가할 때마다 actual 구현이 없으면 빌드가 막힌다는 것입니다.

build.gradle.kts — KMP 형태

plugins {
    kotlin("multiplatform") version "2.2.0"
    id("com.android.library") version "8.9.0"
    id("org.jetbrains.compose") version "1.7.3"     // Compose Multiplatform
    kotlin("plugin.compose") version "2.2.0"        // Kotlin 2.x 는 별도 플러그인
}

kotlin {
    androidTarget {   // kotlinOptions{} 는 Kotlin 2.2 부터 오류 — compilerOptions 로
        compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
    }
    jvm("desktop")
    iosX64(); iosArm64(); iosSimulatorArm64()       // 시뮬레이터 · 실기기 모두

    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
            implementation("io.ktor:ktor-client-core:3.1.0")
        }
        androidMain.dependencies { implementation("io.ktor:ktor-client-okhttp:3.1.0") }
        iosMain.dependencies    { implementation("io.ktor:ktor-client-darwin:3.1.0") }
    }
}

android {
    namespace = "com.example.shared"
    compileSdk = 36
    defaultConfig { minSdk = 26 }
}

macOS — Xcode · CocoaPods · kdoctor 🟡 중급

맥에서 해야 할 것 (순서대로)

# ① Xcode — App Store 에서 설치 (10 GB 이상, 시간이 오래 걸립니다)
#    설치 후 반드시 한 번 실행해 라이선스에 동의해야 합니다
sudo xcodebuild -license accept

# ② 커맨드라인 도구
xcode-select --install
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
xcodebuild -version

# ③ iOS 시뮬레이터 런타임 (Xcode → Settings → Platforms 에서도 가능)
xcrun simctl list runtimes
xcodebuild -downloadPlatform iOS

# ④ CocoaPods — Swift 라이브러리를 KMP 로 끌어올 때 필요
#    (안 쓰는 프로젝트도 많습니다. 먼저 kdoctor 로 확인)
brew install cocoapods
pod --version

# ⑤ JDK · Android SDK 는 st02 · st06 과 같습니다
CocoaPods 를 시스템 루비(sudo gem install)로 깔지 마세요. macOS 기본 루비와 충돌해 “왜 pod 명령이 사라졌지” 가 반복됩니다. Homebrew 또는 rbenv 로 별도 루비를 깔아 그 위에 얹는 편이 훨씬 안정적입니다.

kdoctor — 환경 자가 진단

brew install kdoctor
kdoctor

# 출력 예
# Environment diagnose (to see all details, use -v option):
# [✓] Operating System
# [✓] Java
# [✓] Android Studio
# [✗] Xcode
#     ✖ Xcode requires to perform the First Launch tasks
#       Launch Xcode and complete the setup
# [✓] CocoaPods
#
# Conclusion:
#   ✖ KDoctor has diagnosed one or more problems...

kdoctor -v      # 무엇을 어디서 찾았는지 경로까지 보여 줍니다
맥에서 KMP 를 시작한다면 kdoctor 를 가장 먼저 돌리세요. “빌드가 안 되는데 이유를 모르겠다”의 90% 를 한 화면에서 알려 줍니다 — Xcode 첫 실행 미완료, JAVA_HOME 없음, CocoaPods 버전 문제 등. 윈도우·리눅스에서는 돌아가지 않습니다(맥 전용 도구입니다).

윈도우 · 리눅스에서 할 수 있는 것과 없는 것 🔴 고급

윈도우에서 안 되는 것
  • iOS 타깃 컴파일 — iosArm64 · iosSimulatorArm64 빌드는 애플 툴체인이 필요합니다
  • iOS 시뮬레이터 실행 — Xcode 에만 있습니다
  • App Store 제출용 아카이브 — xcodebuild 필요
  • macOS 데스크톱 타깃(macosArm64) 빌드
  • Gradle sync 시 iOS 타깃이 있으면 경고가 뜨는 것은 정상입니다 — 그 타깃만 건너뜁니다
윈도우에서 되는 것
  • commonMain 전부 — 로직·네트워크·직렬화·테스트
  • 안드로이드 앱 빌드·실행 (에뮬레이터 포함)
  • 데스크톱 앱 — Compose for Desktop 으로 윈도우 실행 파일까지
  • 웹(Wasm/JS) — 브라우저에서 바로 확인
  • 공용 코드 테스트 — ./gradlew jvmTest 로 로직 검증
  • CI 에서 iOS 빌드 — GitHub Actions macos-latest 러너 사용

GitHub Actions 로 iOS 만 맡기기

name: build
on: [push, pull_request]

jobs:
  common-and-android:
    runs-on: ubuntu-latest        # 싼 러너에서 대부분을 처리
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21' }
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew jvmTest :composeApp:assembleDebug

  ios:
    runs-on: macos-latest         # iOS 만 비싼 맥 러너에서
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21' }
      - run: ./gradlew iosSimulatorArm64Test

타깃별 자주 쓰는 태스크

명령결과
./gradlew tasks --all이 프로젝트가 가진 모든 태스크
./gradlew jvmTest공용 로직 테스트 (가장 빠름)
./gradlew :composeApp:assembleDebug안드로이드 APK
./gradlew :composeApp:run데스크톱 앱 실행
./gradlew wasmJsBrowserRun브라우저에서 웹 앱
./gradlew iosSimulatorArm64TestiOS 테스트 (맥 전용)
./gradlew packageDistributionForCurrentOS데스크톱 설치 파일(.msi/.dmg)
첫 KMP 빌드는 정말 오래 걸립니다 — Kotlin/Native 컴파일러와 플랫폼 라이브러리(약 1~2 GB)를 ~/.konan 에 받기 때문입니다. 중간에 끊기면 그 폴더를 지우고 다시 받는 편이 빠릅니다.
정리 — KMP 환경의 갈림길은 “iOS 를 빌드할 것인가” 하나입니다. 맥이 있다면 Xcode → 커맨드라인 도구 → 시뮬레이터 → kdoctor 순서로 확인하고, 윈도우라면 공용·안드로이드·데스크톱·웹을 개발하고 iOS 는 CI 맥 러너에 맡기면 됩니다. 프로젝트는 kmp.jetbrains.com 마법사로 시작하는 것이 가장 빠릅니다.
SETUP 08

Spring Boot (Kotlin) 시작 준비 — all-open · no-arg

start.spring.io 에서 Kotlin 을 고르면 build.gradle.kts 에 kotlin("plugin.spring") 과 kotlin("plugin.jpa") 가 자동으로 들어갑니다. 이게 무엇인지 모른 채 지우면 런타임에 이해 못 할 에러가 납니다. 두 플러그인이 왜 필요한지는 “코틀린 클래스는 기본이 final” 이라는 한 문장에서 출발합니다.

왜 kotlin-spring 플러그인이 필요한가 — final 문제 플러그인이 없으면 class OrderService 코틀린 기본 = final Spring 이 프록시 시도 CGLIB 로 자식 클래스 생성 실패 — 상속 불가 @Transactional 이 안 먹음 kotlin("plugin.spring") 을 넣으면 @Service class 소스는 그대로 둡니다 컴파일 시점에 open 부여 애너테이션을 보고 자동으로 프록시 생성 성공 트랜잭션 · AOP · 캐시 정상 JPA 엔티티는 문제가 하나 더 있습니다 @Entity class Order(...) 인자 없는 생성자가 없음 Hibernate 가 못 만듦 kotlin("plugin.jpa") no-arg 플러그인 기본 생성자를 합성 엔티티 로딩 성공 리플렉션으로만 호출 내 코드에서는 안 보임 두 플러그인 모두 소스를 안 고칩니다 — 컴파일 결과물만 바꿉니다
start.spring.io 가 넣어 주는 두 플러그인은 장식이 아니라 필수입니다 — 지우면 앱이 안 뜹니다.

start.spring.io 에서 고를 값

https://start.spring.io

Project      : Gradle - Kotlin      ★ (Maven 아님, Groovy 아님)
Language     : Kotlin               ★
Spring Boot  : 최신 안정 릴리스 (SNAPSHOT · M 붙은 것 말고)
Project Metadata
  Group      : com.example
  Artifact   : demo
  Name       : demo
  Package    : com.example.demo
  Packaging  : Jar                  ★ (War 는 옛날 방식)
  Java       : 21                   ★ (또는 25)

Dependencies (오른쪽 ADD 버튼)
  Spring Web              REST API
  Spring Data JPA         DB 쓸 때
  PostgreSQL Driver       (또는 H2 Database 로 시작)
  Spring Boot DevTools    저장하면 자동 재시작
  Validation              @Valid
  Spring Boot Actuator    상태 확인 엔드포인트

→ [GENERATE] → zip 다운로드 → 압축 풀기
→ IntelliJ 에서 build.gradle.kts 를 Open as Project
IntelliJ Ultimate 에는 이 화면이 내장돼 있습니다 — New Project → Spring Boot 를 고르면 같은 항목이 IDE 안에 뜹니다. Community 판이라면 웹사이트를 쓰면 됩니다(결과물은 완전히 같습니다).

생성된 build.gradle.kts 읽기

plugins {
    kotlin("jvm") version "2.2.0"
    kotlin("plugin.spring") version "2.2.0"      // ★ all-open
    kotlin("plugin.jpa") version "2.2.0"         // ★ no-arg (JPA 쓸 때만)
    id("org.springframework.boot") version "3.5.x"
    id("io.spring.dependency-management") version "1.1.7"
}

kotlin { jvmToolchain(21) }

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")

    // ★ 코틀린 전용 — 없으면 JSON 역직렬화가 깨집니다
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    runtimeOnly("org.postgresql:postgresql")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

kotlin {
    compilerOptions { freeCompilerArgs.addAll("-Xjsr305=strict") }
}

tasks.withType<Test> { useJUnitPlatform() }

첫 실행 — ./gradlew bootRun 🟢 기초

코드 두 파일이면 뜹니다

// DemoApplication.kt
package com.example.demo

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class DemoApplication

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)   // 자바의 SpringApplication.run
}
// HelloController.kt
package com.example.demo

import org.springframework.web.bind.annotation.*

data class Greeting(val message: String, val to: String)

@RestController
@RequestMapping("/api")
class HelloController {

    @GetMapping("/hello")
    fun hello(@RequestParam(defaultValue = "world") name: String) =
        Greeting(message = "안녕하세요", to = name)
}
./gradlew bootRun
# ...
# Tomcat started on port 8080 (http)
# Started DemoApplicationKt in 1.8 seconds

curl "http://localhost:8080/api/hello?name=현우"
# {"message":"안녕하세요","to":"현우"}

자주 쓰는 실행 옵션

명령하는 일
./gradlew bootRun개발 중 실행
./gradlew bootRun --args='--server.port=9090'포트 바꿔 실행
./gradlew bootJar실행 가능한 jar 만들기
java -jar build/libs/demo-0.0.1.jar배포와 동일한 방식으로 실행
./gradlew bootRun -Dspring.profiles.active=local프로파일 지정
./gradlew bootBuildImage도커 이미지 (Dockerfile 없이)
./gradlew test --tests "*ServiceTest"특정 테스트만
# src/main/resources/application.yml — 처음에 넣어 둘 것
spring:
  application:
    name: demo
  datasource:
    url: jdbc:postgresql://localhost:5432/demo
    username: demo
    password: demo
  jpa:
    hibernate:
      ddl-auto: validate      # 운영에서 create 는 절대 금지
    open-in-view: false       # 코틀린이든 자바든 끄는 게 기본
server:
  port: 8080
logging:
  level:
    org.hibernate.SQL: debug

한 단계 더 — 코틀린이라서 걸리는 것들 🟡 중급

세 가지 함정과 처방

증상원인 · 처방
JSON 이 null 로만 들어옴 jackson-module-kotlin 누락. 코틀린 data class 는 기본 생성자가 없어 Jackson 이 못 채웁니다
@Value · @ConfigurationProperties 가 안 채워짐 kotlin-reflect 누락. 이름 기반 바인딩에 필요합니다
@Transactional 이 안 먹음 plugin.spring 누락 또는 private 메서드에 붙임 (프록시가 못 가로챕니다)
@Entity 로딩 실패 plugin.jpa 누락. 인자 없는 생성자가 없습니다
lateinit 오류 생성자 주입으로 바꾸세요 — 코틀린에서는 생성자 주입이 기본이고 @Autowired 도 필요 없습니다
플러그인 두 개와 라이브러리 두 개 — plugin.spring · plugin.jpa · jackson-module-kotlin · kotlin-reflect. 이 네 개만 있으면 코틀린 특유의 스프링 문제는 거의 다 사라집니다.

all-open 을 직접 설정하기

// plugin.spring 은 사실 all-open 의 프리셋입니다
// @Component @Async @Transactional @Cacheable @SpringBootTest 를
// 붙인 클래스를 자동으로 open 으로 만들어 줍니다

// 내가 만든 애너테이션에도 적용하고 싶다면
plugins { kotlin("plugin.allopen") version "2.2.0" }

allOpen {
    annotation("com.example.MyOpenMarker")
}

// JPA 엔티티에 기본 생성자를 붙일 애너테이션 지정
plugins { kotlin("plugin.noarg") version "2.2.0" }

noArg {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.Embeddable")
    invokeInitializers = true     // init 블록도 실행 (기본은 false)
}
“모든 클래스를 open 으로” 는 하지 마세요. 코틀린이 기본 final 인 것은 의도된 설계입니다(상속으로 인한 깨짐 방지). 필요한 애너테이션에만 좁게 여는 것이 all-open 플러그인의 존재 이유입니다.
Ktor 를 고른다면 이 섹션은 거의 필요 없습니다 — Ktor 는 코틀린으로 처음부터 만든 프레임워크라 프록시도 리플렉션도 쓰지 않습니다. build.gradle.kts 에 io.ktor.plugin 하나만 넣으면 끝이고, start.ktor.io 에 같은 방식의 생성기가 있습니다. 기존 스프링 생태계(보안·배치·데이터)를 쓸 것인가가 선택 기준입니다.
정리 — start.spring.io 에서 Gradle-Kotlin · Kotlin · Java 21 · Jar. 생성된 build.gradle.kts 의 plugin.spring(final 문제)과 plugin.jpa(기본 생성자 문제)는 지우면 안 되는 필수품입니다. 여기에 jackson-module-kotlin · kotlin-reflect 를 더하면 준비 완료, 실행은 ./gradlew bootRun 입니다.
SETUP 09

코드 품질 · kotlinx 라이브러리 붙이기

환경 세팅의 마지막 한 겹입니다. ktlint(포맷)와 detekt(정적 분석)를 붙여 “스타일 때문에 코드 리뷰가 길어지는 일”을 없애고, 코틀린 개발에 사실상 표준인 kotlinx 세 형제(coroutines · serialization · datetime)를 프로젝트에 넣습니다. 혼자 하는 프로젝트라도 처음에 넣어 두면 나중에 넣는 것보다 열 배 쉽습니다.

품질 검사는 네 관문 — 왼쪽에서 걸릴수록 고치기 쌉니다 ① 타이핑 중 IDE 인스펙션 Alt+Enter 로 즉시 Ctrl+Alt+L 포맷 비용: 0초 ② 커밋 직전 ktlintFormat pre-commit 훅 스타일 자동 수정 비용: 수 초 ③ 빌드 detekt 긴 함수 · 복잡도 죽은 코드 비용: 수십 초 ④ CI 같은 검사 재실행 실패 시 병합 차단 리뷰어 시간 소모 비용: 수 분~ 같은 규칙 파일 하나를 네 관문이 함께 봅니다 .editorconfig 들여쓰기 · 줄 길이 · 인코딩 detekt.yml 규칙 켜기 · 끄기 · 임계값 build.gradle.kts 검사를 빌드에 묶는 설정 규칙이 파일로 저장소에 있으면 — 새 팀원의 PC 에서도 같은 결과가 납니다 "내 IDE 에서는 괜찮았는데" 가 사라지는 지점입니다
도구를 늘리는 게 목적이 아니라 같은 판단을 어디서나 반복하는 것이 목적입니다.

ktlint — 스타일은 논쟁하지 않습니다

// build.gradle.kts
plugins {
    id("org.jlleitschuh.gradle.ktlint") version "12.1.2"
}

ktlint {
    version.set("1.5.0")
    android.set(false)          // 안드로이드 프로젝트면 true
    outputToConsole.set(true)
    ignoreFailures.set(false)   // 위반이 있으면 빌드 실패
    filter {
        exclude { it.file.path.contains("/build/") }
    }
}
./gradlew ktlintCheck     # 검사만
./gradlew ktlintFormat    # 자동으로 고쳐 줍니다  ★

# 커밋 전에 자동 실행되게 (Git 훅 설치)
./gradlew addKtlintFormatGitPreCommitHook
# .editorconfig — 프로젝트 루트에 두면 IDE 와 ktlint 가 함께 봅니다
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4

[*.{kt,kts}]
max_line_length = 120
ktlint_standard_no-wildcard-imports = enabled
ktlint_standard_function-naming = disabled   # 테스트 함수명에 백틱 쓸 때

detekt — 설계 냄새를 잡습니다

// build.gradle.kts
plugins {
    id("io.gitlab.arturbosch.detekt") version "1.23.7"
}

detekt {
    buildUponDefaultConfig = true            // 기본 규칙 위에 내 설정을 얹기
    config.setFrom(files("$rootDir/config/detekt.yml"))
    baseline = file("$rootDir/config/detekt-baseline.xml")
    parallel = true
}

dependencies {
    detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.7")
}

tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
    reports { html.required.set(true); sarif.required.set(true) }
    jvmTarget = "21"
}
./gradlew detekt

# 기존 코드가 많아 위반이 수백 개일 때 — 기준선을 만듭니다
./gradlew detektBaseline
# 지금까지의 위반은 무시하고, 앞으로 새로 생기는 것만 잡습니다  ★

# 리포트 확인
# build/reports/detekt/detekt.html
레거시에 도입할 때는 baseline 이 답입니다. “위반 500개를 다 고쳐야 도입 가능” 이면 아무도 도입하지 않습니다. 오늘부터 새 코드만 지키게 하고 baseline 을 조금씩 줄이는 방식이 현실적입니다.

kotlinx 세 형제 붙이기 🟢 기초

coroutines

dependencies {
  implementation(
   "org.jetbrains.kotlinx:" +
   "kotlinx-coroutines-core:1.10.2")

  // 안드로이드라면 추가
  implementation(
   "org.jetbrains.kotlinx:" +
   "kotlinx-coroutines-android:1.10.2")

  // 테스트
  testImplementation(
   "org.jetbrains.kotlinx:" +
   "kotlinx-coroutines-test:1.10.2")
}

suspend 문법 자체는 언어에 내장이지만 launch·async·Flow 는 이 라이브러리에 있습니다.

serialization

plugins {
  kotlin("plugin.serialization")
      version "2.2.0"    // ★ 플러그인 필수
}

dependencies {
  implementation(
   "org.jetbrains.kotlinx:" +
   "kotlinx-serialization-json:1.8.1")
}
@Serializable
data class User(val id: Long,
                val name: String)

val json = Json { ignoreUnknownKeys = true }
val u = json.decodeFromString<User>(text)
플러그인을 빼먹으면 Serializer has not been found 가 납니다 — 컴파일러 플러그인이 코드를 생성하는 방식이라 라이브러리만으로는 안 됩니다.

datetime

dependencies {
  implementation(
   "org.jetbrains.kotlinx:" +
   "kotlinx-datetime:0.6.2")
}
import kotlinx.datetime.*

val now = Clock.System.now()   // Instant
val seoul = TimeZone.of("Asia/Seoul")
val local = now.toLocalDateTime(seoul)
println(local.date)

JVM 만 쓴다면 java.time 으로 충분합니다. 이 라이브러리는 KMP 에서 iOS·JS 까지 같은 API 를 쓰려고 만든 것입니다.

한 단계 더 — 코루틴 디버깅 🟡 중급

이름과 스택을 남기는 설정

// 개발·테스트에서만 켭니다 (운영은 성능 부담)
tasks.withType<JavaExec> {
    jvmArgs("-Dkotlinx.coroutines.debug")
}
tasks.test {
    jvmArgs("-Dkotlinx.coroutines.debug")
}

// 코드로 켜기 — 스택 추적이 중단 지점을 넘어 이어집니다
// (kotlinx-coroutines-debug 의존성 필요)
DebugProbes.install()

// 코루틴에 이름을 달아 두면 로그·디버거에서 구분됩니다
launch(CoroutineName("주문-동기화")) { ... }

// 지금 살아 있는 코루틴을 전부 덤프 (누수 추적)
DebugProbes.dumpCoroutines()
IntelliJ 의 Coroutines 탭 — 디버거로 멈추면 Debug 창에 Coroutines 탭이 생겨 지금 떠 있는 코루틴 목록과 상태(RUNNING/SUSPENDED)를 보여 줍니다. “왜 안 끝나지?” 를 눈으로 확인할 수 있는 유일한 도구입니다.

그 밖에 처음부터 넣어 두면 좋은 것

도구왜
kotest 또는 JUnit5테스트. kotest 는 shouldBe 같은 코틀린다운 단언
MockK코틀린 전용 목 라이브러리. final 클래스도 목 처리
Kotlin Power-Assert단언 실패 시 식의 중간값까지 출력 (2.x 정식 기능)
DokkaKDoc → HTML 문서 생성
binary-compatibility-validator라이브러리를 배포한다면 API 변경 감시
Renovate / Dependabot버전 카탈로그를 자동으로 올려 주는 봇
// Power-Assert (Kotlin 2.x) — 켜기만 하면 됩니다
plugins { kotlin("plugin.power-assert") version "2.2.0" }

powerAssert {
    functions = listOf("kotlin.assert", "kotlin.test.assertTrue")
}
// 실패 시 출력이 이렇게 바뀝니다
// assert(list.size == expected.size)
//        |    |    |  |        |
//        |    3    |  |        5
//        [1,2,3]   false
정리 — ktlint(자동 포맷) + detekt(설계 검사) + .editorconfig 를 처음에 넣고, 레거시라면 detekt baseline 으로 부담 없이 시작하세요. 라이브러리는 coroutines · serialization(플러그인 필수!) · datetime 이 사실상 표준입니다. 코루틴이 안 끝날 때는 -Dkotlinx.coroutines.debug 와 IDE 의 Coroutines 탭이 답입니다.
SETUP 10

인코딩 · 빌드 속도 — gradle.properties 완전판

한글이 ??? 로 나오는 문제와 빌드가 느린 문제는 성격이 달라 보이지만, 둘 다 “Gradle 이 JVM 을 어떤 옵션으로 띄우는가” 한 파일에서 해결됩니다. 이 섹션은 gradle.properties 한 장을 통째로 읽고 각 줄이 무엇을 바꾸는지 설명합니다. 안드로이드 빌드가 유난히 느릴 때의 체크리스트도 함께 정리합니다.

빌드가 빨라지는 네 겹 — 위에서부터 순서대로 켭니다 ① Gradle 데몬 — JVM 을 계속 살려 둡니다 JVM 시작 비용(약 1~2초)과 JIT 예열이 사라집니다. 기본으로 켜져 있습니다 org.gradle.daemon=true · 문제 생기면 ./gradlew --stop 으로 재시작 ② 설정 캐시 — build.gradle.kts 를 다시 실행하지 않습니다 태스크 그래프를 통째로 저장해 재사용. 구성 단계가 수 초에서 0.1초로 org.gradle.configuration-cache=true · 지원 안 하는 플러그인이 있으면 경고 ③ 빌드 캐시 — 입력이 같으면 결과물을 그대로 씁니다 브랜치를 오갈 때 특히 큽니다. 팀이 공유하는 원격 캐시도 붙일 수 있습니다 org.gradle.caching=true · FROM-CACHE 표시가 뜨면 작동 중 ④ 코틀린 증분 컴파일 — 바뀐 파일과 영향 범위만 다시 컴파일 kotlin.incremental=true (기본값). public API 를 고치면 범위가 넓어집니다
네 겹을 다 켜면 두 번째 빌드부터 체감이 완전히 달라집니다 — 첫 빌드는 원래 느립니다.

gradle.properties — 프로젝트 루트

# ===== JVM =====
# 데몬 힙. 안드로이드/멀티모듈이면 4g 이상, 서버 단일 모듈은 2g 로 충분
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8

# ===== 속도 =====
org.gradle.daemon=true
org.gradle.parallel=true                 # 모듈을 병렬로 빌드
org.gradle.caching=true                  # 빌드 캐시
org.gradle.configuration-cache=true      # 설정 캐시
org.gradle.configureondemand=true        # 필요한 모듈만 구성

# ===== 코틀린 =====
kotlin.incremental=true
kotlin.incremental.useClasspathSnapshot=true
# 코틀린 컴파일을 별도 프로세스로 (메모리 격리 — 큰 프로젝트에 유리)
kotlin.compiler.execution.strategy=daemon
kotlin.daemon.jvmargs=-Xmx2g

# ===== 인코딩 =====
# 소스·리소스·javadoc 을 전부 UTF-8 로 (아래 build.gradle.kts 와 함께)
systemProp.file.encoding=UTF-8

# ===== 안드로이드 (있을 때만) =====
android.useAndroidX=true
android.nonTransitiveRClass=true         # R 클래스 축소 — 빌드 빨라짐
# (예전 이름 org.gradle.unsafe.configuration-cache 는 위 설정과 충돌하니 같이 두지 마세요)
-Xmx 를 무작정 크게 잡지 마세요. PC RAM 이 16GB 인데 8g 를 주면 IDE·에뮬레이터와 다투다 전체가 느려집니다. 기준은 RAM 의 1/4 정도 — 16GB 면 4g, 32GB 면 8g 입니다.

인코딩 — 한글이 깨지는 세 자리

// build.gradle.kts — ① 컴파일할 때
tasks.withType<JavaCompile>().configureEach {
    options.encoding = "UTF-8"
}
// 코틀린 소스(.kt)는 컴파일러가 항상 UTF-8 로 읽으므로 따로 지정할 것이 없다

// ② 실행할 때 (JVM 이 파일을 읽고 쓸 때)
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dfile.encoding=UTF-8")
}
tasks.test { systemProperty("file.encoding", "UTF-8") }

// ③ jar 안의 리소스
tasks.withType<org.gradle.jvm.tasks.Jar>().configureEach {
    filteringCharset = "UTF-8"
}
# 윈도우 콘솔이 CP949 여서 깨지는 경우 (가장 흔함)
chcp 65001                 # 현재 창을 UTF-8 로
# 영구 설정: 설정 → 시간 및 언어 → 언어 및 지역
#   → 관리자 언어 설정 → 시스템 로캘 변경
#   → "세계 언어 지원을 위해 Unicode UTF-8 사용" 체크 → 재부팅

# IntelliJ 쪽
#   Settings → Editor → File Encodings
#     Global / Project / Properties Files 를 전부 UTF-8
#     "Transparent native-to-ascii conversion" 체크
Java 18 부터 기본 인코딩이 UTF-8 로 바뀌었습니다. 그래서 JDK 21/25 를 쓰면 예전만큼 깨지지 않습니다. 여전히 깨진다면 원인은 대개 콘솔 코드페이지이거나 파일 자체가 CP949 로 저장된 것입니다.

무엇이 느린지 측정하기 🟡 중급

빌드 스캔 — 한 줄이면 리포트가 나옵니다

# 한 번만 써 보기 (약관 동의 프롬프트가 뜹니다)
./gradlew build --scan

# 끝나면 URL 이 출력됩니다
# https://gradle.com/s/xxxxxxxx
#   ▸ 태스크별 소요 시간 (무엇이 느린지)
#   ▸ 캐시 적중/미적중 이유
#   ▸ 의존성 다운로드 시간
#   ▸ 데몬·JVM 설정 진단

# 매번 자동으로 만들려면 settings.gradle.kts 에
# plugins { id("com.gradle.develocity") version "3.19" }
# develocity { buildScan { termsOfUseAgree = "yes" } }

# 스캔 없이 간단히 보기
./gradlew build --profile
# build/reports/profile/ 아래 HTML 리포트
추측하지 말고 스캔을 먼저 돌리세요. “느린 것 같아서 병렬을 켰다” 보다 “구성 단계가 12초였다” 를 알고 고치는 편이 훨씬 빠릅니다. 대부분의 범인은 구성 단계이거나 테스트입니다.

캐시가 실제로 먹는지 확인

# 같은 빌드를 두 번 돌려 보기
./gradlew clean build
./gradlew build          # ← 두 번째는 대부분 UP-TO-DATE 여야 정상

# 태스크마다 왜 다시 도는지 이유 출력
./gradlew build --info | grep -i "not up-to-date\|Task .* is"

# 캐시 적중 여부
./gradlew build --build-cache -i | grep -i "FROM-CACHE"

# 데몬 상태
./gradlew --status
# PID   STATUS   INFO
# 12345 IDLE     8.14

# 데몬이 여러 개 떠 있으면 메모리를 먹습니다 — 정리
./gradlew --stop
매번 clean 을 붙이는 습관을 버리세요. clean build 는 모든 캐시를 무효화합니다. “뭔가 이상할 때만” 쓰는 명령이지, 기본 명령이 아닙니다. 이것 하나만 고쳐도 하루에 수십 분이 돌아옵니다.

안드로이드 빌드가 유난히 느릴 때 🔴 고급

체크리스트 — 위에서부터

확인할 것효과
디버그 빌드에서 minify 끄기R8 은 릴리스에서만. 디버그에 켜면 몇 배 느려집니다 ★
KSP 로 갈아타기(kapt 대신)애너테이션 처리가 2배 이상 빨라집니다 ★
모듈 쪼개기바뀐 모듈만 다시 컴파일
nonTransitiveRClass=trueR 클래스 재생성 범위 축소
디버그 전용 debugImplementation릴리스 빌드에서 불필요한 의존성 제외
바이러스 백신 예외 등록윈도우에서 체감이 가장 큽니다. 프로젝트·.gradle·SDK 폴더 ★
SSD · RAM 16GB 이상하드웨어가 답일 때도 있습니다
Gradle · AGP 최신화릴리스마다 빌드 속도 개선이 들어옵니다
윈도우 Defender 실시간 검사가 Gradle 빌드를 30~50% 느리게 만드는 경우가 흔합니다. “Windows 보안 → 바이러스 및 위협 방지 → 설정 관리 → 제외 추가”에서 프로젝트 폴더 · ~/.gradle · Android Sdk 폴더 · Java 설치 폴더를 제외하세요.

kapt → KSP 로 옮기기

// 예전 방식 — kapt (자바 애너테이션 처리기를 코틀린에 억지로)
plugins { kotlin("kapt") }
dependencies {
    kapt("androidx.room:room-compiler:2.6.1")
}

// 지금 방식 — KSP (코틀린 전용 · 훨씬 빠름)
plugins { id("com.google.devtools.ksp") version "2.2.0-2.0.2" }   // 코틀린 2.2.0 짝 (KSP2)
dependencies {
    ksp("androidx.room:room-compiler:2.6.1")
}

// KSP 를 지원하는 주요 라이브러리
//   Room · Moshi · Koin · Dagger/Hilt · Glide
// 지원 안 하면 그 하나만 kapt 로 남겨 둡니다
kapt 가 느린 이유 — 자바 애너테이션 처리기를 쓰려고 코틀린 코드에서 자바 스텁을 먼저 생성하기 때문입니다. KSP 는 코틀린 코드를 직접 읽습니다. 그래서 스텁 생성 단계가 통째로 사라집니다. KSP 버전은 코틀린 버전과 짝이라 2.2.0-2.0.2 처럼 앞자리가 맞아야 합니다(st11).
정리 — gradle.properties 에 데몬 · 병렬 · 빌드 캐시 · 설정 캐시 네 줄을 켜고 -Dfile.encoding=UTF-8 을 함께 넣으세요. 느리다고 느끼면 추측하지 말고 --scan 을 돌립니다. 안드로이드라면 디버그 minify 끄기 · kapt→KSP · 백신 예외 세 가지가 효과가 가장 큽니다. 그리고 clean 을 습관적으로 붙이지 마세요.
SETUP 11

설치 문제 사전 — 증상 · 원인 · 해결

여기까지 왔는데 안 되는 경우를 위한 섹션입니다. 코틀린 환경 문제는 종류가 많아 보이지만 뿌리는 다섯 가지뿐입니다 — JDK 버전 불일치 · 도구 버전 궁합 · 경로 문제 · 네트워크 · 캐시. 아래 진단 흐름을 위에서부터 따라가면 대부분 5분 안에 원인이 나옵니다.

빌드가 실패했다 — 어디부터 볼 것인가 터미널에서도 실패하나? 아니오 — IDE 만 Gradle 새로고침 ⟳ Invalidate Caches 예 — 에러 메시지에 버전 번호가 있나? 버전이 보인다 major version · 호환 → 궁합표 확인 다운로드가 멈춘다 프록시 · 인증서 · 미러 → st05 네트워크 경로 문제 한글 · 공백 · 긴 경로 sdk.dir · JAVA_HOME 캐시 문제 ./gradlew --stop ~/.gradle/caches 삭제 그래도 안 되면 --stacktrace --info 로 진짜 예외를 읽습니다 위 세 상자는 앞 단계에서 원인이 안 나왔을 때 순서대로 확인합니다
첫 질문은 언제나 “터미널에서도 실패하나” 입니다 — 여기서 절반이 갈립니다.

증상 → 원인 → 해결 (자주 나오는 순서대로)

증상 (에러 메시지)원인해결
Unsupported class file major version 69 빌드에 쓰인 JDK 가 Gradle/코틀린이 아는 것보다 최신 Gradle JVM 을 21 로 내리거나 Gradle 을 올립니다. IntelliJ: Settings → Build Tools → Gradle → Gradle JDK. major 61=17 · 65=21 · 69=25
Inconsistent JVM-target compatibility
Java 17 vs Kotlin 21
자바 컴파일 대상과 코틀린 컴파일 대상이 다름 kotlin { jvmToolchain(21) } 한 줄로 통일. 개별 jvmTarget 설정은 지웁니다
SDK location not found
Define location with sdk.dir
안드로이드 프로젝트인데 local.properties 가 없음(gitignore 대상이라 정상) 루트에 local.properties 를 만들고 sdk.dir=경로 한 줄. 또는 ANDROID_HOME 환경 변수를 잡습니다
'kotlinc' is not recognized kotlinc 를 안 깔았거나 PATH 에 \bin 을 안 넣음 대개 안 깔아도 됩니다(st03). 필요하면 scoop/SDKMAN 으로 설치 후 새 터미널에서 확인
The Kotlin Gradle plugin was loaded multiple times 루트와 하위 모듈에서 서로 다른 코틀린 버전을 선언 루트에서 apply false 로 버전만 선언하고, 하위에서는 버전 없이 id("..."). 버전 카탈로그를 쓰면 구조적으로 막힙니다
This version of the Android Gradle plugin requires Gradle X AGP · Gradle 버전 궁합 gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 을 올립니다. ./gradlew wrapper --gradle-version 8.14
ksp ... is too old for kotlin ... KSP 버전 앞자리가 코틀린과 안 맞음 KSP 버전은 <코틀린버전>-<ksp버전> 형식입니다. 코틀린 2.2.0 이면 2.2.0-2.0.x (KSP 2.3 부터는 코틀린 버전과 분리된 번호)
Gradle 다운로드가 0% 에서 멈춤 사내 프록시 · 방화벽 · 인증서 ~/.gradle/gradle.properties 에 프록시 설정(st05). 인증서는 keytool -importcert ... -cacerts
PKIX path building failed 사내망 SSL 가로채기 인증서를 JDK 가 모름 사내 루트 인증서를 JDK 의 cacerts 에 등록. JDK 를 바꾸면 다시 등록해야 합니다
에뮬레이터가 검은 화면이거나 아주 느림 가상화 충돌(Hyper-V·WSL2·Docker) 또는 ARM 이미지 WHPX 켜기, BIOS 에서 VT-x/SVM 확인, 이미지를 x86_64(Apple Silicon 은 arm64)로, Graphics 를 Hardware 로
adb: no devices/emulators found USB 디버깅 미허용 · 충전 전용 케이블 · 드라이버 adb kill-server && adb start-server → 폰의 허용 팝업 확인 → 윈도우는 Google USB Driver
빌드는 되는데 IDE 만 빨간 줄 IDE 인덱스·캐시 ① Gradle 창 ⟳ → ② ./gradlew --stop → ③ File → Invalidate Caches and Restart
한글 주석·문자열이 ??? 콘솔 코드페이지 또는 파일 인코딩 chcp 65001, -Dfile.encoding=UTF-8, IDE File Encodings 전부 UTF-8 (st10)
Could not resolve ... (의존성 없음) 저장소 누락 · 오타 · 사내 넥서스만 열림 settings.gradle.kts 의 repositories 확인. 안드로이드 라이브러리는 google() 이 필요합니다
경로에 한글·공백 → 알 수 없는 실패 일부 네이티브 도구가 비ASCII·긴 경로를 못 다룸 프로젝트를 C:\dev\프로젝트 가 아니라 C:\dev\myapp 로. 윈도우 긴 경로 지원도 켜 둡니다
메모리 부족 OutOfMemoryError: Metaspace 데몬 힙·메타스페이스 부족 org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g 후 ./gradlew --stop

버전 궁합 — 무엇이 무엇을 요구하나 🟢 기초

클래스 파일 major version 대응표

major versionJDK
52Java 8
55Java 11
61Java 17
65Java 21
67Java 23
69Java 25
계산법: major − 44 = 자바 버전 입니다. Unsupported class file major version 69 를 보면 “25 로 컴파일된 것을 더 낮은 JVM 이 읽으려 한다”로 읽으면 됩니다. 도구가 오래된 것이지 내 코드가 틀린 게 아닙니다.

누가 누구를 제약하나

  • Gradle 이 JDK 를 제약합니다 — 오래된 Gradle 은 새 JDK 에서 아예 안 뜹니다
  • AGP 가 Gradle 을 제약합니다 — AGP 를 올리면 Gradle 도 올려야 합니다
  • AGP 가 JDK 도 제약합니다 — 최근 AGP 는 JDK 17 이상을 요구합니다
  • KSP·Compose 플러그인은 코틀린 버전에 붙습니다 — 앞자리가 정확히 일치해야 합니다
  • Spring Boot 가 JDK 하한을 정합니다 — 최신 부트는 17 이상
# 지금 무엇이 쓰이는지 한 번에 확인
./gradlew --version
# Gradle 8.14
# Launcher JVM:  21.0.5 (Eclipse Adoptium)
# Daemon JVM:    21.0.5

./gradlew buildEnvironment | head -30   # 빌드에 쓰인 플러그인들
./gradlew -q javaToolchains             # 이 PC 가 아는 JDK 전부  ★
./gradlew -q javaToolchains 를 기억하세요. Gradle 이 이 PC 에서 찾아낸 JDK 목록과 경로를 전부 보여 줍니다. “toolchain 21 을 못 찾겠다”는 에러의 원인이 여기서 바로 보입니다.

최후의 수단 — 단계별 초기화 🟡 중급

약한 것부터 강한 것 순서로

# 1단계 — 데몬만 (가장 자주 듣습니다)
./gradlew --stop

# 2단계 — 이 프로젝트의 빌드 산출물
./gradlew clean

# 3단계 — IDE 인덱스
#   File → Invalidate Caches... → [Invalidate and Restart]

# 4단계 — 이 프로젝트의 로컬 Gradle 상태
rm -rf .gradle build
#   윈도우: Remove-Item -Recurse -Force .gradle, build

# 5단계 — 의존성을 새로 받기
./gradlew build --refresh-dependencies

# 6단계 — 전역 캐시 (수 GB 를 다시 받습니다. 신중히)
rm -rf ~/.gradle/caches
#   래퍼 배포판까지 지우려면 ~/.gradle/wrapper/dists

# 7단계 — 코틀린/네이티브(KMP) 캐시
rm -rf ~/.konan

# 8단계 — 안드로이드 빌드 캐시
rm -rf ~/.android/build-cache
6단계부터는 정말 마지막에 하세요. 전역 캐시를 지우면 다음 빌드가 수십 분 걸립니다. 그리고 대부분의 문제는 1~3단계에서 끝납니다 — 순서를 건너뛰고 6단계부터 하는 것이 가장 흔한 시간 낭비입니다.

에러를 제대로 읽는 법

# Gradle 이 요약해 버린 진짜 예외를 보고 싶을 때
./gradlew build --stacktrace     # 스택 전체
./gradlew build --info           # 태스크별 상세
./gradlew build --debug          # 너무 많습니다 (최후)

# 실패한 태스크만 다시
./gradlew build --continue       # 실패해도 끝까지 돌려 전체 문제 파악

# 코틀린 컴파일러가 왜 실패했는지
./gradlew compileKotlin --info | grep -i "error\|warning"

# 안드로이드 — 어떤 병합 단계에서 깨졌는지
./gradlew assembleDebug --stacktrace 2>&1 | head -60
에러 메시지의 마지막 줄이 아니라 Caused by: 를 찾으세요. Gradle 은 예외를 여러 겹 감싸기 때문에 맨 위의 Execution failed for task ':app:compileDebugKotlin' 은 아무 정보가 없습니다. 아래로 내려가 가장 마지막 Caused by 가 진짜 원인입니다.
검색할 때는 버전을 빼고 검색하세요 — "Unsupported class file major version 69" kotlin gradle 보다 Unsupported class file major version gradle jdk 가 훨씬 좋은 결과를 줍니다. 버전 숫자는 내 상황에만 있는 값입니다.
새 PC 를 받았을 때의 30분 체크리스트 — ① java -version · javac -version 이 같은 버전인가 · ② echo $env:JAVA_HOME 이 bin 의 부모를 가리키는가 · ③ where.exe java 첫 줄이 그 JDK 인가 · ④ 프로젝트 경로에 한글·공백이 없는가 · ⑤ ./gradlew --version 이 정상 출력되는가 · ⑥ (안드로이드) adb devices 가 도는가 · ⑦ ./gradlew build 가 통과하는가. 이 일곱 줄이 전부 통과하면 환경 문제는 없습니다.
정리 — 진단은 “터미널에서도 실패하나” 로 시작합니다. 버전 에러는 major − 44 = 자바 버전 으로 읽고 jvmToolchain 으로 통일, 다운로드가 멈추면 프록시·인증서, 이상한 실패는 경로에 한글·공백, 나머지는 ./gradlew --stop → clean → Invalidate Caches 순서입니다. 그리고 언제나 마지막 Caused by 를 읽으세요.
KTOR 3.x · 코루틴 네이티브 서버 · 플러그인 파이프라인 · Exposed · Koin · 2026 기준

젯브레인스가
코틀린만 보고
다시 만든 서버

Ktor 는 자바를 거치지 않고 코틀린만 보고 설계된 서버 프레임워크입니다. 모든 핸들러가 suspend 이고, 프레임워크가 기본으로 켜 두는 것이 하나도 없습니다. 그래서 기동이 0.3초, 힙이 50MB 대에서 시작하고, 켠 기능만 정확히 돕니다. 이 탭은 스프링 부트를 아는 독자를 가정하고 끝까지 스프링과 대조하면서 갑니다 — 라우팅 · 플러그인 파이프라인 · 직렬화 · 코루틴 동시성 · Exposed · 인증 · WebSocket · Koin · 테스트 · 관측 · 배포, 그리고 마지막에 짧은 URL 서비스 하나를 처음부터 끝까지 만듭니다. 이 탭을 마치면 Ktor 서비스를 혼자 설계해서 운영에 올릴 수 있게 됩니다.

13SECTIONS
Ktor 3.x2026 기준
0.3s목표 기동 시간
100%복붙해서 도는 예제
KTOR 01

Ktor 는 무엇이고 왜 쓰나 — 스프링과 나란히

Ktor 를 "가벼운 스프링"으로 이해하면 계속 헤맵니다. 두 프레임워크는 철학이 반대입니다 — 스프링은 다 켜 두고 끄는 쪽, Ktor 는 다 꺼 두고 켜는 쪽입니다. 이 한 문장이 기동 시간부터 디버깅 방식까지 전부를 설명합니다.

같은 REST API 를 만들 때 두 프레임워크가 쌓아 올리는 층 Spring Boot 4 — 있는 것을 끄는 방식 Ktor 3 — 없는 것을 켜는 방식 톰캣 · 서블릿 컨테이너 요청 하나에 스레드 하나 — 기본 200개 DispatcherServlet · MVC 핸들러 매핑 → 인터셉터 → 어댑터 컴포넌트 스캔 · 프록시 · DI 리플렉션으로 빈을 찾아 엮습니다 자동 설정 후보 130여 개 클래스패스를 보고 켤 것을 정합니다 기동 1.8~3.0초 · 힙 180~260MB 기능은 이미 거의 다 들어 있습니다 Netty · CIO 엔진 이벤트 루프 몇 개가 전부입니다 플러그인 파이프라인 install 한 것만 순서대로 돕니다 routing { } 트리 suspend 람다가 곧 핸들러입니다 자동 설정이 없습니다 켠 것 = 도는 것 전부 · 예측 가능 기동 0.25~0.5초 · 힙 45~90MB 필요한 기능은 내가 붙입니다 둘 다 코틀린으로 같은 REST API 를 만듭니다 — 다른 것은 "무엇이 기본으로 켜져 있는가" 하나입니다
Ktor 가 빠른 이유는 마법이 아니라 안 하는 일이 많기 때문입니다 — 그 대신 내가 해야 할 일이 늘어납니다.

Ktor 를 한 화면으로

// Main.kt — 이 파일 하나가 돌아가는 웹 서버 전부입니다
fun main() {
    embeddedServer(Netty, port = 8080) {
        install(ContentNegotiation) { json() }   // 켜야 JSON 이 됩니다

        routing {
            get("/hello") {                       // 이 람다는 suspend 입니다
                call.respond(mapOf("msg" to "안녕"))
            }
        }
    }.start(wait = true)
}
여기에 없는 것들을 보세요 — 애너테이션이 없고, 컴포넌트 스캔이 없고, @RestController 같은 클래스가 아예 없습니다. Ktor 의 라우트는 클래스가 아니라 함수 호출이라, IDE 에서 정의로 이동이 그대로 통합니다. 스프링에서 "이 URL 이 어느 메서드로 가지?"를 찾느라 헤매던 시간이 사라집니다.

숫자로 보는 차이 ★

Spring Boot 4Ktor 3
기동 시간(JVM)1.8~3.0초0.25~0.5초
기동 직후 힙180~260MB45~90MB
fat jar 크기28~55MB12~22MB
의존성 주입내장 컨테이너없음 → 생성자 · Koin
러닝 커브넓고 완만좁고 가파름
생태계압도적좁다(배치 · 시큐리티 얇음)
멀티플랫폼JVM 전용JVM · Native · JS
동시성 모델스레드(+ 코루틴 어댑터)코루틴이 유일
숫자의 조건 — JDK 21 · 4vCPU · 컨테이너, 라우트 20개 · JSON · JDBC 한 개짜리 표준 서비스. 스프링 쪽에 배치나 시큐리티를 더 얹으면 격차가 더 벌어지고, Ktor 쪽에 Koin · Exposed · 메트릭을 다 붙이면 힙이 90~120MB 로 올라옵니다. "두 배 빠르다"가 아니라 "출발선이 다르다"로 읽으세요.

고르는 기준 — 질문 네 개면 끝납니다 🟢 기초

Ktor 인가 스프링인가 — 위에서부터 "예"가 나오면 거기서 멈춥니다 KMP 와 코드를 공유하나? iOS · 안드로이드 · 서버 한 모델 예 Ktor — 사실상 유일 스프링은 KMP 를 못 탑니다 아니오 기동 0.5초 · 힙 100MB? 서버리스 · 잦은 스케일 아웃 예 Ktor 가 크게 유리 콜드 스타트 5~8배 차이 아니오 JPA · 배치 · 시큐리티 필요? 사내 표준 스타터 · 권한 모델 예 스프링 부트 Ktor 쪽 대응물이 얇습니다 현실의 대부분이 여기서 멈춤 아니오 코루틴 편한 사람이 둘 이상? 구조적 동시성 · 취소를 앎 예 Ktor 도 안전 아니라면 학습 비용을 계산 아니오 넷 다 아니오 → 스프링 부트 기술 우열이 아니라 조직과 요구사항의 문제
현실의 대부분은 세 번째 질문에서 멈춥니다 — 그래도 나머지 한 줌을 위해 Ktor 를 알아 둘 값어치가 있습니다.

Ktor 가 실제로 잘 하는 자리

  • 게이트웨이 · BFF — 여러 내부 API 를 코루틴으로 동시에 호출해 합칩니다. Ktor 의 최적 서식지입니다.
  • KMP 백엔드 — commonMain 의 @Serializable DTO 를 서버와 iOS·안드로이드가 그대로 공유합니다.
  • 서버리스 · 짧은 수명 컨테이너 — 콜드 스타트가 과금이 되는 자리.
  • 사이드카 · 내부 툴 — 메트릭 수집기, 웹훅 수신기처럼 기능은 적고 개수는 많은 서비스.
  • 스트리밍 · 실시간 — WebSocket · SSE 가 코루틴 위에서 자연스럽습니다.
실무에서 가장 흔한 조합 — 핵심 도메인 서비스는 스프링 부트로 두고, 그 앞의 게이트웨이 한 대만 Ktor 로 두는 형태입니다. 전면 교체를 설득할 필요가 없어 도입이 쉽습니다.

Ktor 를 고르면 감수하는 것

스프링의 그것Ktor 에서는
Spring Data JPAExposed · jOOQ 를 직접 조립
Spring SecurityAuthentication 플러그인 + 직접 구현
Spring Batch대응물 없음 — 별도 잡 러너
ActuatorMicrometer 를 손으로 배선
@TransactionalnewSuspendedTransaction { } 로 명시
@Scheduled코루틴 launch + delay 또는 Quartz
스택오버플로 답변 수대략 1/40
가장 과소평가되는 비용 — 검색이 안 먹힙니다. 스프링은 에러 메시지를 붙여 넣으면 답이 나오지만, Ktor 는 공식 문서와 GitHub 이슈, 그리고 소스 코드를 읽어야 하는 경우가 자주 옵니다. 대신 소스가 작아서 실제로 읽을 수 있습니다 — 이게 Ktor 학습의 반전입니다.

엔진 고르기 — Netty · CIO · Jetty · Tomcat 🟡 중급

엔진은 "소켓을 다루는 사람"입니다

// 엔진만 바꾸면 됩니다 — 나머지 코드는 한 글자도 안 바뀝니다
embeddedServer(Netty, port = 8080) { module() }.start(wait = true)
embeddedServer(CIO,   port = 8080) { module() }.start(wait = true)
embeddedServer(Jetty, port = 8080) { module() }.start(wait = true)

// build.gradle.kts — 쓰는 엔진의 아티팩트만 넣습니다
dependencies {
    implementation("io.ktor:ktor-server-core")
    implementation("io.ktor:ktor-server-netty")   // ← 이 줄만 갈아 끼웁니다
    // implementation("io.ktor:ktor-server-cio")
}
엔진이 분리돼 있다는 게 왜 좋은가 — 테스트에서는 엔진 없이 돌릴 수 있습니다. testApplication { } 은 소켓을 열지 않고 파이프라인만 태워서, 통합 테스트 한 건이 수 밀리초에 끝납니다(kt10 에서 다룹니다).

무엇을 고르나 ★

엔진성격고를 때
Netty자바 · 성숙 · 빠름기본값. 고민되면 이것
CIO순수 코틀린 · 코루틴KMP · Native · 최소 의존성
Jetty서블릿 기반서블릿 필터를 재사용해야 할 때
Tomcat서블릿 기반사내 표준이 톰캣일 때
서블릿 계열(Jetty · Tomcat)은 신중히 — 서블릿 API 는 스레드 하나에 요청 하나를 전제로 만들어졌습니다. 그 위에 코루틴을 얹으면 Ktor 가 얻으려던 이벤트 루프의 이점이 상당 부분 사라집니다. 마이그레이션 중간 단계가 아니라면 Netty 또는 CIO 를 쓰세요.
Native 로 가면 CIO 뿐입니다 — Ktor 를 linuxX64 같은 코틀린/네이티브 타깃으로 빌드하면 JVM 이 없으므로 Netty(자바 라이브러리)를 못 씁니다. CIO 는 순수 코틀린으로 짜여 있어 그대로 돕니다. "언젠가 네이티브로 갈지도"라면 처음부터 CIO 로 시작하는 것이 이후 비용을 줄입니다.

숫자를 직접 재 보기 — 남의 벤치마크는 믿지 마세요 🟡 중급

기동 시간과 메모리 재기

# ① 기동 시간 — 첫 응답이 올 때까지 걸린 시간을 열 번 재서 중앙값을 씁니다
for i in $(seq 1 10); do
  start=$(date +%s%N)
  java -jar app-all.jar & PID=$!          # PID 가 java 자신이어야 kill 이 서버를 끈다
  until curl -sf localhost:8080/healthz >/dev/null 2>&1; do sleep 0.01; done
  echo "$(( ($(date +%s%N) - start) / 1000000 )) ms"
  kill $PID; wait $PID 2>/dev/null        # 다음 회차 전에 포트가 비도록 기다린다
done

# ② 실제로 쓰는 메모리는 힙이 아니라 RSS 입니다 (컨테이너가 죽이는 기준)
ps -o rss= -p $PID          # KB 단위
cat /sys/fs/cgroup/memory.current    # 컨테이너 안에서라면 이쪽

# ③ 부하를 주면서 재기 — 기동 직후 숫자는 절반의 진실입니다
oha -z 60s -c 50 http://localhost:8080/api/links
#   또는  wrk -t4 -c50 -d60s http://localhost:8080/api/links
왜 직접 재야 하나 — 인터넷의 비교표는 스프링 쪽에 자동 설정이 얼마나 켜져 있었는지를 대부분 밝히지 않습니다. spring-boot-starter-web 하나만 넣은 앱과 시큐리티 · JPA · 액추에이터가 붙은 앱은 기동 시간이 두 배 차이납니다. 내 프로젝트의 의존성 목록으로 잰 숫자만 의사결정에 쓸 수 있습니다.

기동이 느린 원인을 찾는 법

# 스프링 쪽 — 무엇이 시간을 먹는지 나옵니다
java -jar app.jar --debug           # 자동 설정 평가 보고서
# 단계별 소요 시간 — SpringApplication 에 BufferingApplicationStartup(2048) 을 걸고
#   액추에이터 /actuator/startup 으로 봅니다 (켜는 JVM 옵션 한 줄은 없습니다)

# Ktor 쪽 — 기동이 느리면 원인은 대개 셋 중 하나입니다
#   ① DB 마이그레이션(Flyway) — 서버 기동 전에 도니까 여기 포함됩니다
#   ② 커넥션 풀 minimumIdle 만큼 미리 연결을 맺느라
#   ③ 클래스 로딩 — AppCDS 로 줄일 수 있습니다 (kt12)

# 어디서 시간이 가는지 직접 찍어 보는 가장 쉬운 방법
val t0 = System.nanoTime()
// … 초기화 단계 …
log.info("DB 준비 {}ms", (System.nanoTime() - t0) / 1_000_000)
"Ktor 로 바꿨는데 기동이 1.5초" 라면 프레임워크 탓이 아닙니다. Flyway · 커넥션 풀 예열 · 외부 설정 서버 조회처럼 프레임워크 밖의 일이 대부분입니다. Ktor 자체가 뜨는 데 걸리는 시간은 대개 100ms 아래입니다.
KTOR 02

첫 서버 — 프로젝트 생성부터 핫 리로드까지

스프링 이니셜라이저에 해당하는 것이 start.ktor.io 입니다. 다만 Ktor 는 만들어진 뒤 파일 구조를 내가 정한다는 점이 다릅니다 — 정해진 규약이 없으니, 처음에 잡는 뼈대가 오래 갑니다.

① start.ktor.io

브라우저에서 플러그인을 체크박스로 고르면 그것만 들어간 프로젝트가 나옵니다. 스프링 이니셜라이저와 같은 자리입니다.

처음에는 Routing · Content Negotiation · kotlinx.serialization · Call Logging 네 개만 고르세요. 나머지는 필요할 때 한 줄씩 넣는 편이 이해가 빠릅니다.

② IntelliJ 플러그인

IntelliJ IDEA 의 New Project → Ktor 가 같은 생성기를 IDE 안에 넣은 것입니다. 젯브레인스가 양쪽을 다 만들어서 버전이 항상 맞습니다.

Ktor Gradle 플러그인(io.ktor.plugin)이 함께 들어옵니다 — buildFatJar · buildImage 태스크를 공짜로 얻습니다(kt12).

③ Gradle 수동

Ktor 는 의존성이 작고 이름이 규칙적이라 손으로 써도 어렵지 않습니다. BOM 이 버전을 맞춰 주므로 개별 버전은 안 씁니다.

서버는 ktor-server-*, 클라이언트는 ktor-client-* 입니다. 이름이 비슷해서 잘못 넣으면 Unresolved reference: routing 이 납니다.
// build.gradle.kts — 2026 기준 최소 구성
plugins {
    kotlin("jvm") version "2.2.20"
    kotlin("plugin.serialization") version "2.2.20"
    id("io.ktor.plugin") version "3.3.0"        // fat jar · Docker 이미지 태스크를 준다
}

application {
    mainClass.set("com.example.ApplicationKt")   // EngineMain 방식이면 이 클래스를 가리킵니다
}

dependencies {
    implementation("io.ktor:ktor-server-core")            // 버전은 io.ktor.plugin 이 맞춰 줍니다
    implementation("io.ktor:ktor-server-netty")           // 엔진
    implementation("io.ktor:ktor-server-content-negotiation")
    implementation("io.ktor:ktor-serialization-kotlinx-json")
    implementation("io.ktor:ktor-server-call-logging")
    implementation("io.ktor:ktor-server-status-pages")
    implementation("io.ktor:ktor-server-config-yaml")     // application.yaml 을 읽으려면 필요
    implementation("ch.qos.logback:logback-classic:1.5.18")

    testImplementation("io.ktor:ktor-server-test-host")
    testImplementation(kotlin("test"))
}

kotlin { jvmToolchain(21) }
가장 흔한 첫 실패 — ktor-server-content-negotiation 만 넣고 ktor-serialization-kotlinx-json 을 빠뜨리면 install(ContentNegotiation) { json() } 의 json() 이 컴파일되지 않습니다. Ktor 는 "협상하는 기능"과 "JSON 으로 협상하는 구현"을 다른 아티팩트로 나눠 뒀습니다. 이 분리가 Ktor 의존성 전체의 규칙입니다 — 기능 하나에 아티팩트 하나, 구현 하나에 아티팩트 하나.
서버가 뜨기까지 — embeddedServer 직접 방식과 EngineMain + 설정 파일 방식 ① embeddedServer — main() 안에 전부 ② EngineMain — 설정 파일이 주도 fun main() { … } 포트 · 호스트를 코드에 적습니다 embeddedServer(Netty, 8080) 엔진도 코드로 고릅니다 { module() } 람다가 곧 Application 설정입니다 .start(wait = true) 여기서 스레드가 멈춰 서비스합니다 fun main(args) = EngineMain.main(args) 한 줄. 설정은 코드 밖에 있습니다 application.conf 를 읽습니다 포트 · 모듈 목록 · SSL · 그레이스 기간 modules = [ …ApplicationKt.module ] 리플렉션으로 함수를 찾아 부릅니다 엔진 기동 환경 변수로 포트를 덮어쓸 수 있습니다 실무 기본은 오른쪽 — 같은 jar 를 개발 · 운영에서 환경 변수만 바꿔 씁니다. 학습과 테스트는 왼쪽이 편합니다
두 방식은 섞어 쓸 수 있습니다 — 운영은 EngineMain, 테스트는 embeddedServer 가 흔한 조합입니다.

① embeddedServer — 코드가 주인

// Application.kt
fun main() {
    embeddedServer(
        factory = Netty,
        port = System.getenv("PORT")?.toInt() ?: 8080,
        host = "0.0.0.0",
        module = Application::module,          // 함수 참조로 넘기면 재사용이 쉽습니다
    ).start(wait = true)
}

// 설정을 더 주려면 configure 블록을 씁니다
fun main() {
    embeddedServer(Netty, port = 8080, configure = {   // port 를 빼면 기본 80 으로 뜬다
        connectionGroupSize = 2
        workerGroupSize = 4
        callGroupSize = 8                      // 핸들러를 실행할 스레드 수
        shutdownGracePeriod = 3_000
        shutdownTimeout = 10_000
    }) { module() }.start(wait = true)
}

fun Application.module() {                     // ★ Application 의 확장 함수 = "모듈"
    configureSerialization()
    configureLogging()
    configureRouting()
}
Application.module() 이라는 이름 규칙 — Ktor 에서 "모듈"은 Application 의 확장 함수일 뿐입니다. 특별한 타입도, 애너테이션도 아닙니다. 그래서 여러 개로 쪼개서 조합할 수 있고, 테스트에서 일부만 켜서 돌릴 수 있습니다.

② EngineMain — 설정 파일이 주인

// Application.kt — main 은 이 한 줄이 전부입니다
fun main(args: Array<String>) = io.ktor.server.netty.EngineMain.main(args)

fun Application.module() {
    val port = environment.config.property("ktor.deployment.port").getString()
    val dbUrl = environment.config.property("app.db.url").getString()
    log.info("포트 {} 로 뜹니다", port)
    // …
}
# src/main/resources/application.conf  (HOCON)
ktor {
    deployment {
        port = 8080
        port = ${?PORT}                 # ★ 환경 변수가 있으면 그것으로 덮어씀
        shutdownGracePeriod = 3000
        shutdownTimeout = 10000
    }
    application {
        modules = [ com.example.ApplicationKt.module ]
    }
}

app {
    db {
        url  = "jdbc:postgresql://localhost:5432/app"
        url  = ${?DB_URL}
        user = ${?DB_USER}
    }
    jwt { secret = ${?JWT_SECRET} }     # 비밀은 파일에 안 씁니다
}
${?VAR} 의 물음표가 핵심입니다 — 물음표가 있으면 "환경 변수가 있을 때만 덮어쓰기", 없으면 "반드시 있어야 함"입니다. 물음표를 빠뜨리면 로컬에서 Could not resolve substitution 으로 앱이 안 뜹니다.

폴더 구조와 핫 리로드 🟢 기초

규약이 없으니 이렇게 잡습니다 ★

src/main/kotlin/com/example/
├─ Application.kt          # main() + Application.module()
├─ plugins/                # install(...) 을 모아 두는 곳
│   ├─ Serialization.kt    #   fun Application.configureSerialization()
│   ├─ Monitoring.kt       #   CallLogging · CallId · Micrometer
│   ├─ Security.kt         #   Authentication · Sessions
│   └─ StatusPages.kt      #   예외 → 응답 변환
├─ routes/                 # 라우트 확장 함수
│   ├─ LinkRoutes.kt       #   fun Route.linkRoutes(service: LinkService)
│   └─ HealthRoutes.kt
├─ domain/                 # 순수 코틀린 — Ktor 타입이 등장하지 않습니다
│   ├─ Link.kt
│   └─ LinkService.kt
├─ db/                     # Exposed 테이블 · 리포지터리 구현
│   ├─ Tables.kt
│   └─ ExposedLinkRepository.kt
└─ config/
    └─ AppConfig.kt        # HOCON → data class 로 한 번에 읽기

src/main/resources/
├─ application.conf
└─ logback.xml
딱 하나만 지키세요 — domain/ 안에서 io.ktor.* 를 import 하지 않기. 이 선 하나만 지키면 도메인 로직이 프레임워크와 분리되어, 테스트에서 서버를 안 띄우고도 대부분을 검증할 수 있습니다. 스프링에서 @Service 에 HttpServletRequest 를 끌어들이지 말라는 조언과 같습니다.

핫 리로드 — 저장하면 반영됩니다

# ① 터미널 A — 자동 리로드를 켠 채 서버 실행
./gradlew run

# ② 터미널 B — 소스가 바뀔 때마다 클래스만 다시 컴파일
./gradlew -t build -x test -i
#   -t = --continuous. 파일 변경을 감시해 재컴파일합니다
# application.conf 에 감시 대상을 적어야 실제로 갈아 끼웁니다
ktor {
    development = true
    deployment {
        port = 8080
        watch = [ classes, resources ]   # 이 경로가 바뀌면 모듈을 다시 로드
    }
}
# 또는 실행할 때 개발 모드 플래그로
./gradlew run -Pio.ktor.development=true      # -D 는 Gradle JVM 에 붙는다 — Ktor 플러그인은 -P 를 읽는다
핫 리로드가 갈아 끼우는 것은 "모듈"까지입니다 — Application.module() 안의 내용은 다시 실행되지만, main() 에서 만든 커넥션 풀이나 전역 객체는 그대로 살아 있습니다. 그래서 DB 풀·HTTP 클라이언트는 모듈 밖에, 라우트·플러그인은 모듈 안에 두는 것이 핫 리로드를 제대로 쓰는 배치입니다.
첫 실행에서 확인할 것 두 가지 — ① 로그에 Responding at http://0.0.0.0:8080 이 뜨는지, ② curl -i localhost:8080/hello 의 헤더에 Content-Type: application/json 이 있는지. ContentNegotiation 을 install 하지 않았다면 Map 을 응답으로 바꿀 방법이 없어 500 이 나고, 로그에 Response pipeline couldn't transform ... to the OutgoingContent 가 찍힙니다. 직렬화 의존성을 빠뜨린 것도 같은 증상이니 이 문구를 보면 플러그인부터 확인하세요.

두 방식 · 의존성 이름 한 장 정리 🟢 기초

embeddedServer 와 EngineMain

embeddedServerEngineMain
포트 · 엔진코드에설정 파일에
환경별 분리직접 분기환경 변수로
모듈 지정람다 · 함수 참조문자열(리플렉션)
핫 리로드제한적watch 지원
기동 실패 시점컴파일 · 실행실행(오타는 런타임)
어울리는 곳테스트 · 학습 · 도구운영 서비스
모듈 이름을 문자열로 쓰는 게 불안하다면 — EngineMain 을 쓰되 modules 에 모듈 함수 하나만 적고, 그 안에서 나머지를 코드로 부르세요. 오타 위험이 한 줄로 줄어듭니다.

의존성 이름 규칙 — 외우면 검색이 필요 없습니다

아티팩트주는 것
ktor-server-coreApplication · routing · call
ktor-server-netty엔진(cio · jetty · tomcat)
ktor-server-{플러그인}서버 플러그인 하나
ktor-serialization-{형식}직렬화 구현체
ktor-client-{엔진}HTTP 클라이언트
ktor-server-test-hosttestApplication
기능 하나 = 아티팩트 하나가 Ktor 의 일관된 규칙입니다. install(CORS) 가 Unresolved reference 라면 거의 항상 ktor-server-cors 를 안 넣은 것입니다. 스프링 스타터 하나가 서른 개를 끌어오는 것과 정반대의 설계입니다.
KTOR 03

라우팅 — URL 을 함수에 잇는 트리

스프링의 @GetMapping 은 애너테이션을 스캔해 표를 만드는 방식입니다. Ktor 는 코드를 실행해 트리를 짓는 방식입니다 — 그래서 라우트를 반복문으로 만들 수도, 조건으로 뺄 수도 있습니다.

routing { } 은 트리입니다 — GET /api/links/ab12/stats 가 내려가는 길 routing { } route("/api") route("/links") get { } — 목록 반환 route("/{slug}") get("/stats") { … } GET /api/links/ab12/stats 도착 /api — 세그먼트 1 일치 /links — 세그먼트 2 일치 경로가 남아 있어 건너뜁니다 {slug} 가 "ab12" 를 캡처 /stats 일치 → 이 핸들러 실행 Ktor 는 등록 순서가 아니라 "얼마나 구체적으로 맞았는가" 점수로 고릅니다 — 상수 > 파라미터 > 와일드카드
스프링의 매핑 표와 달리 Ktor 는 트리를 내려가며 점수를 매깁니다 — 그래서 순서에 덜 민감합니다.

기본형 — 경로 · 파라미터 · 쿼리

fun Application.configureRouting() = routing {

    get("/health") { call.respondText("OK") }

    route("/api/links") {                       // 공통 접두사를 묶습니다

        get {                                   // GET /api/links
            val page  = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
            val size  = call.request.queryParameters["size"]?.toIntOrNull() ?: 20
            val tags  = call.request.queryParameters.getAll("tag").orEmpty()  // ?tag=a&tag=b
            call.respond(service.list(page, size, tags))
        }

        post {                                  // POST /api/links
            val req = call.receive<CreateLinkRequest>()
            call.respond(HttpStatusCode.Created, service.create(req))
        }

        route("/{slug}") {                      // 경로 파라미터
            get {
                val slug = call.parameters["slug"]!!           // 매칭됐으므로 반드시 있습니다
                call.respond(service.get(slug))
            }
            get("/stats") { call.respond(service.stats(call.parameters["slug"]!!)) }
            delete { call.respond(HttpStatusCode.NoContent) }
        }
    }

    // 와일드카드 — 남은 경로 전체를 하나로 받습니다
    get("/files/{path...}") {
        val path = call.parameters.getAll("path")?.joinToString("/") ?: ""
        call.respondText("요청한 경로: $path")
    }
}
call.parameters 와 call.request.queryParameters 는 다릅니다 — 앞은 경로 파라미터({slug}), 뒤는 쿼리 스트링입니다. call.parameters 는 사실 둘을 합쳐서 보여 주므로 쿼리도 읽히지만, 이름이 충돌하면 헷갈립니다. 의도를 드러내려면 구분해서 쓰는 편이 낫습니다.

경로 문법 한 장 ★

패턴뜻
/users정확히 일치 — 점수 가장 높음
/users/{id}한 세그먼트 캡처(필수)
/users/{id?}있어도 되고 없어도 됨
/files/{p...}남은 경로 전부(tailcard)
/*한 세그먼트 아무거나
/{...}남은 전부 · 이름 없음
// 조건과 반복으로 라우트를 만들 수 있습니다 — 애너테이션으론 못 합니다
routing {
    if (config.enableAdmin) {
        route("/admin") { adminRoutes() }       // 설정에 따라 아예 등록하지 않음
    }
    listOf("v1", "v2").forEach { v ->
        route("/api/$v") { apiRoutes(version = v) }
    }
}

// 헤더 · 호스트 · 프로토콜로도 분기할 수 있습니다
routing {
    host("admin.example.com") { adminRoutes() }
    header("X-Api-Version", "2") { v2Routes() }
}
이게 Ktor 라우팅의 진짜 장점입니다 — 라우트가 데이터라서, 기능 플래그 · 멀티테넌시 · 버전 분기를 프레임워크 확장 없이 그냥 코드로 씁니다.

파일을 나누는 법 — Route 확장 함수 🟡 중급

라우트 파일 하나 = 확장 함수 하나

// routes/LinkRoutes.kt
fun Route.linkRoutes(service: LinkService) {         // ★ 의존성은 인자로 받습니다
    route("/links") {
        get { call.respond(service.list()) }
        post {
            val req = call.receive<CreateLinkRequest>()
            call.respond(HttpStatusCode.Created, service.create(req))
        }
        get("/{slug}") { call.respond(service.get(call.parameters["slug"]!!)) }
    }
}

// routes/HealthRoutes.kt
fun Route.healthRoutes(db: Database) {
    get("/healthz") { call.respondText("OK") }               // 살아 있나 (liveness)
    get("/readyz") {                                          // 받을 준비가 됐나 (readiness)
        val ok = runCatching { db.ping() }.isSuccess
        if (ok) call.respondText("READY")
        else call.respond(HttpStatusCode.ServiceUnavailable, "DB")
    }
}

// Application.kt — 조립만 합니다
fun Application.configureRouting(deps: Deps) = routing {
    healthRoutes(deps.db)
    route("/api") {
        linkRoutes(deps.linkService)
        userRoutes(deps.userService)
    }
}
왜 인자로 받나 — 이렇게 두면 linkRoutes 는 Koin 도 스프링 컨텍스트도 모르는 순수 함수가 됩니다. 테스트에서 가짜 서비스를 넣어 라우트만 검증할 수 있고, 나중에 DI 프레임워크를 바꿔도 이 파일은 안 바뀝니다.

정적 파일 · SPA

routing {
    // ① 디스크의 폴더를 그대로 노출
    staticFiles("/static", File("uploads")) {
        enableAutoHeadResponse()
        cacheControl { listOf(CacheControl.MaxAge(maxAgeSeconds = 86400)) }
    }

    // ② jar 안 resources 를 노출 (배포에 유리 — 파일을 따로 안 옮겨도 됩니다)
    staticResources("/assets", "web/assets")

    // ③ 리액트 · Vue 같은 SPA — 없는 경로는 전부 index.html 로
    staticResources("/", "web") {
        default("index.html")
        preCompressed(CompressedFileType.BROTLI, CompressedFileType.GZIP)
    }
}
SPA fallback 은 API 라우트보다 뒤에 두세요 — staticResources("/") 는 사실상 모든 경로를 먹으려 듭니다. Ktor 의 점수 매칭이 대부분 막아 주지만, 등록 순서를 지키는 편이 사고를 확실히 막습니다.
// 트레일링 슬래시 — /links 와 /links/ 를 같게 볼지
install(IgnoreTrailingSlash)     // 켜면 둘이 같은 라우트로 갑니다
// 기본값은 "다르다" 입니다. 프런트가 슬래시를 붙여 보내면 404 가 나므로
// 프런트를 못 고치는 상황이면 이걸 켜는 게 빠릅니다

타입 안전 라우팅 — Resources 플러그인 🔴 고급

경로를 문자열이 아니라 클래스로

// build.gradle.kts
// implementation("io.ktor:ktor-server-resources")

install(Resources)

@Serializable
@Resource("/links")
class Links(val page: Int = 1, val size: Int = 20, val tag: String? = null) {

    @Serializable
    @Resource("{slug}")                            // 부모에 이어 붙습니다 → /links/{slug}
    class BySlug(val parent: Links = Links(), val slug: String) {

        @Serializable
        @Resource("stats")                         // → /links/{slug}/stats
        class Stats(val parent: BySlug)
    }
}

routing {
    get<Links> { q ->                              // q 는 이미 파싱·형변환된 객체입니다
        call.respond(service.list(q.page, q.size, q.tag))
    }
    get<Links.BySlug> { r -> call.respond(service.get(r.slug)) }
    get<Links.BySlug.Stats> { r -> call.respond(service.stats(r.parent.slug)) }
}
얻는 것 — call.parameters["page"]?.toIntOrNull() ?: 1 같은 줄이 통째로 사라집니다. 타입이 안 맞으면 컴파일이 안 되고, IDE 가 파라미터 이름을 자동완성합니다. 오타로 인한 null 이 원천 차단됩니다.

진짜 이득은 "링크 만들기"입니다

// 응답 안에 다음 페이지 URL 을 넣어야 할 때 — 문자열 조립을 안 합니다
val next = application.href(Links(page = q.page + 1, size = q.size, tag = q.tag))
// → "/links?page=2&size=20&tag=kotlin"   (인코딩까지 알아서)

call.respond(PageResponse(items = items, next = next))

// ★ 같은 클래스를 Ktor Client 에서도 씁니다 (KMP 라면 공유 모듈에 두고)
val client = HttpClient(CIO) { install(io.ktor.client.plugins.resources.Resources) }
val page2: PageResponse = client.get(Links(page = 2)).body()
//   서버와 클라이언트가 같은 @Resource 클래스를 보므로
//   경로를 바꾸면 양쪽이 동시에 컴파일 에러가 납니다 ★
이건 스프링에 대응물이 사실상 없습니다. 스프링에도 UriComponentsBuilder 나 HATEOAS 가 있지만 컴파일 타임에 클라이언트까지 함께 깨지지는 않습니다. KMP 프로젝트에서 이 조합의 값어치가 특히 큽니다.
중첩이 깊어지면 parent 를 계속 들고 다녀야 해서 장황해집니다. 경로가 2단계까지면 Resources, 그보다 깊거나 자주 바뀌면 문자열 라우팅이 오히려 읽기 쉽습니다 — 한 프로젝트에서 섞어 써도 됩니다.
KTOR 04

플러그인 파이프라인 — Ktor 의 심장

Ktor 를 진짜로 이해했는지는 파이프라인을 설명할 수 있는가로 갈립니다. 스프링의 필터 · 인터셉터 · AOP · @ControllerAdvice 가 하던 일을 Ktor 는 페이즈 다섯 개짜리 파이프라인 하나로 전부 처리합니다.

ApplicationCallPipeline — 페이즈 다섯 개, 그리고 거기에 붙는 것들 Setup call 객체 준비 DoubleReceive · 요청 속성 초기화 여기에 직접 붙일 일은 거의 없습니다 Monitoring 관측은 가장 바깥에 CallLogging · CallId · MicrometerMetrics 아래에서 무엇이 터져도 로그와 지표는 남아야 하니 가장 먼저 Plugins 대부분 여기 붙습니다 ContentNegotiation · CORS · Compression · StatusPages Authentication · DefaultHeaders · ForwardedHeaders · Resources Call routing 이 실행됩니다 Routing 플러그인이 이 페이즈에 인터셉터를 걸어 둡니다 트리를 내려가 내 핸들러 람다를 부르는 지점이 바로 여기입니다 Fallback 아무도 응답 안 했을 때 404 Not Found 를 만들어 냅니다 여기까지 왔다는 것은 매칭에 실패했다는 뜻입니다 요청은 왼쪽 화살표를 따라 아래로, 응답은 오른쪽 화살표를 따라 위로 — install 순서가 아니라 페이즈가 순서를 정합니다
스프링의 필터 · 인터셉터 · Advice 가 하던 세 가지 일이 이 그림 한 장에 다 들어 있습니다.

ApplicationCall — 요청과 응답을 담은 한 객체

get("/inspect") {
    // call 하나에 모든 것이 달려 있습니다
    call.request.httpMethod            // HttpMethod.Get
    call.request.uri                   // "/inspect?a=1"
    call.request.path()                // "/inspect"
    call.request.headers["User-Agent"]
    call.request.origin.remoteHost     // 프록시 뒤라면 ForwardedHeaders 필요
    call.request.queryParameters["a"]

    call.response.header("X-Trace", "abc")
    call.response.cookies.append("v", "1", httpOnly = true, secure = true)

    // attributes — 플러그인끼리 값을 주고받는 통로 (스프링의 request attribute)
    val key = AttributeKey<String>("userId")
    call.attributes.put(key, "u-42")
    val uid = call.attributes.getOrNull(key)

    call.respond(HttpStatusCode.OK, mapOf("uid" to uid))
}
call 은 this 가 아니라 이름이 있는 값입니다 — 핸들러 람다의 리시버는 RoutingContext 이고 call 은 그 프로퍼티입니다. 그래서 다른 suspend 함수에 call 을 그냥 넘길 수 있습니다. 스프링에서 HttpServletRequest 를 파라미터로 받는 것과 같은데, 스레드 로컬이 아니라 진짜 값이라 코루틴을 넘나들어도 안전합니다.

install 순서 — 언제 중요한가 ★

fun Application.module() {
    // 페이즈가 다르면 install 순서는 상관없습니다
    install(CallLogging)          // Monitoring 페이즈
    install(ContentNegotiation) { json() }   // Plugins 페이즈
    install(Routing)              // Call 페이즈

    // ★ 같은 페이즈 안에서는 install 순서가 곧 실행 순서입니다
    install(StatusPages) { /* 예외를 응답으로 */ }
    install(Compression) { gzip(); deflate() }
    // → StatusPages 가 먼저 걸리므로 Compression 이 만든 예외까지 잡습니다
}
플러그인페이즈역할
CallLoggingMonitoring요청 한 줄 로그
CallIdMonitoring요청 ID 생성 · 전파
DefaultHeadersPluginsServer · Date 헤더
ContentNegotiationPlugins객체 ↔ JSON
CORSPlugins교차 출처 허용
CompressionPluginsgzip · br
StatusPagesPlugins예외 → 응답
RoutingCall핸들러 실행
가장 자주 겪는 순서 사고 — CORS 를 StatusPages 뒤에 install 하면 예외 응답에 CORS 헤더가 안 붙어서, 브라우저가 진짜 에러 대신 "CORS 정책에 막혔습니다" 만 보여 줍니다. 프런트 개발자가 원인을 못 찾는 전형적인 상황입니다. CORS 를 StatusPages 보다 먼저 두세요.

실무에서 늘 켜는 여섯 개 🟢 기초

// plugins/Defaults.kt — 새 프로젝트에서 이 정도는 그냥 복사해 쓰면 됩니다
fun Application.configureDefaults(cfg: AppConfig) {

    install(DefaultHeaders) {
        header("X-Engine", "Ktor")
        // Server 헤더는 기본으로 붙습니다. 숨기려면 아래를 씁니다
        // header(HttpHeaders.Server, "")
    }

    install(ContentNegotiation) {
        json(Json {
            ignoreUnknownKeys = true     // ★ 모르는 필드가 와도 터지지 않게 (호환성)
            encodeDefaults = true        // 기본값도 응답에 실어 보냅니다
            explicitNulls = false        // null 필드는 아예 빼서 응답을 가볍게
            prettyPrint = cfg.isDev
        })
    }

    install(CORS) {
        cfg.allowedOrigins.forEach { allowHost(it, schemes = listOf("https")) }
        allowMethod(HttpMethod.Put); allowMethod(HttpMethod.Delete); allowMethod(HttpMethod.Patch)
        allowHeader(HttpHeaders.Authorization)
        allowHeader(HttpHeaders.ContentType)
        allowCredentials = true
        maxAgeInSeconds = 3600           // 프리플라이트 캐시 — 없으면 OPTIONS 가 두 배로 옵니다
    }

    install(Compression) {
        gzip  { priority = 1.0; minimumSize(1024) }
        deflate { priority = 0.9 }
        // 이미 압축된 것은 다시 압축하지 않습니다
        matchContentType(ContentType.Text.Any, ContentType.Application.Json)
    }

    install(CallLogging) {
        level = Level.INFO
        filter { it.request.path().startsWith("/api") }      // 헬스체크 로그로 도배되는 것 방지
        format { call ->
            val t = call.processingTimeMillis()
            "${call.response.status()?.value} ${call.request.httpMethod.value} ${call.request.path()} ${t}ms"
        }
    }

    install(StatusPages) { /* kt05 에서 자세히 */ }
}
allowHost("*") 를 운영에 두지 마세요. Ktor 의 anyHost() 는 개발 편의용입니다. allowCredentials = true 와 anyHost() 를 함께 쓰면 쿠키가 아무 사이트에나 실려 나가는 상태가 됩니다 — 브라우저가 대부분 막지만, 막지 못하는 조합이 존재합니다. 호스트는 목록으로 명시하세요.

직접 만드는 플러그인 — createApplicationPlugin 🔴 고급

훅 네 개면 대부분 됩니다

class RateLimitConfig {
    var limitPerMinute: Int = 60
    var keyExtractor: (ApplicationCall) -> String = { it.request.origin.remoteHost }
}

val SimpleRateLimit = createApplicationPlugin(
    name = "SimpleRateLimit",
    createConfiguration = ::RateLimitConfig,
) {
    val counters = ConcurrentHashMap<String, AtomicInteger>()
    val limit = pluginConfig.limitPerMinute
    val keyOf = pluginConfig.keyExtractor

    // 플러그인이 자기 코루틴을 띄울 수 있습니다 (앱이 죽으면 같이 죽습니다)
    application.launch {
        while (true) { delay(60_000); counters.clear() }
    }

    onCall { call ->                       // ① 핸들러 전
        val n = counters.computeIfAbsent(keyOf(call)) { AtomicInteger() }.incrementAndGet()
        if (n > limit) {
            call.respond(HttpStatusCode.TooManyRequests, mapOf("retryAfter" to 60))
        }
    }

    onCallReceive { call -> }              // ② 본문을 읽기 직전
    onCallRespond { call, body -> }        // ③ 응답을 내보내기 직전
    on(CallFailed) { call, cause ->        // ④ 예외가 났을 때
        application.log.warn("실패: {} {}", call.request.path(), cause.message)
    }
}

install(SimpleRateLimit) { limitPerMinute = 120 }
onCall 안에서 call.respond 를 부르면 거기서 끝납니다 — 아래 페이즈로 안 내려갑니다. 스프링 인터셉터의 return false 와 같은 자리입니다. 명시적으로 proceed() 를 부를 필요가 없습니다(옛 intercept API 와 다른 점).

라우트에만 붙이는 플러그인

// createRouteScopedPlugin — 특정 route 블록 안에서만 동작합니다
val AuditLog = createRouteScopedPlugin("AuditLog") {
    onCall { call ->
        call.application.log.info(
            "감사: user={} {} {}",
            call.principal<UserPrincipal>()?.id ?: "anon",
            call.request.httpMethod.value,
            call.request.path(),
        )
    }
}

routing {
    route("/api/public") { publicRoutes() }        // 감사 로그 없음
    route("/api/admin") {
        install(AuditLog)                           // ★ 여기 아래에만 적용
        adminRoutes()
    }
}
// 요청 전체 시간을 재서 헤더로 돌려주기 — onCall + onCallRespond 조합
val Timing = createApplicationPlugin("Timing") {
    val started = AttributeKey<Long>("startedAt")
    onCall { it.attributes.put(started, System.nanoTime()) }
    onCallRespond { call, _ ->
        val ns = System.nanoTime() - (call.attributes.getOrNull(started) ?: return@onCallRespond)
        call.response.header("X-Elapsed-Ms", (ns / 1_000_000).toString())
    }
}
플러그인 안에서 만든 상태는 앱 수명 동안 공유됩니다 — 위의 counters 는 모든 요청이 같이 쓰는 맵입니다. 반드시 스레드 안전한 자료구조(ConcurrentHashMap · Atomic* · Mutex)를 쓰세요. Ktor 핸들러는 여러 스레드에서 동시에 돕니다.
KTOR 05

직렬화 · 검증 — JSON 을 도메인 객체로

스프링은 잭슨이 리플렉션으로 필드를 뒤집니다. Ktor 는 kotlinx.serialization 이 컴파일 타임에 직렬화 코드를 생성합니다 — 리플렉션이 없어서 빠르고, 널 가능성이 타입 그대로 지켜집니다.

본문이 객체가 되는 길, 그리고 실패가 응답이 되는 길 JSON 본문 Content-Type 확인 call.receive<T>() kotlinx 역직렬화 검증 (konform) 형식 · 범위 · 필수 서비스 도메인 로직 예외로 던집니다 BadRequestException · 검증 실패 StatusPages 예외 → 표준 에러 JSON { "code": "VALIDATION_FAILED", "message": "…", "fields": { … } } 실패는 언제나 이 한 가지 모양 — 클라이언트가 분기 하나로 전부 처리합니다 스프링의 @Valid + @RestControllerAdvice 가 하던 일을 검증 함수 + StatusPages 두 조각으로 나눠 씁니다
에러 모양을 맨 처음에 한 번 정하는 것이 나중에 프런트와의 실랑이를 없앱니다.

DTO — @Serializable 하나면 끝

@Serializable
data class CreateLinkRequest(
    val url: String,                       // 없으면 역직렬화 실패 (필수)
    val slug: String? = null,              // 널 허용 + 기본값 → 선택
    @SerialName("expires_at")              // JSON 키 이름이 다를 때
    val expiresAt: Instant? = null,
    val tags: List<String> = emptyList(),  // 기본값이 있으면 빠져도 됩니다
)

@Serializable
data class LinkResponse(
    val slug: String,
    val url: String,
    val clicks: Long,
    @Serializable(with = InstantSerializer::class)   // 커스텀 직렬화기
    val createdAt: Instant,
)

// 커스텀 직렬화기 — Instant 를 ISO 문자열로
object InstantSerializer : KSerializer<Instant> {
    override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
    override fun serialize(encoder: Encoder, value: Instant) =
        encoder.encodeString(value.toString())
    override fun deserialize(decoder: Decoder): Instant =
        Instant.parse(decoder.decodeString())
}
잭슨과 결정적으로 다른 점 — 잭슨은 val name: String 에 null 이 와도 리플렉션으로 밀어 넣어 버립니다(코틀린 모듈을 안 넣으면). kotlinx.serialization 은 널이 오면 그 자리에서 예외를 냅니다. "널이 아니라고 선언했는데 널이 들어와 있는" 상태가 구조적으로 불가능해집니다.

본문 받기 · 실패 처리

post("/links") {
    val req = call.receive<CreateLinkRequest>()      // 실패하면 예외를 던집니다
    call.respond(HttpStatusCode.Created, service.create(req))
}

// 본문이 비어 있어도 되는 경우
val body = call.receiveNullable<PatchRequest>()

// 원본이 필요할 때 (서명 검증 · 웹훅)
val raw: String = call.receiveText()
val bytes: ByteArray = call.receive<ByteArray>()

// 폼 데이터 · 파일 업로드
val form = call.receiveParameters()
val multipart = call.receiveMultipart()
multipart.forEachPart { part ->
    when (part) {
        is PartData.FileItem -> part.provider().copyAndClose(file.writeChannel())
        is PartData.FormItem -> println("${part.name} = ${part.value}")
        else -> {}
    }
    part.dispose()
}
상황던져지는 예외
Content-Type 이 없거나 다름UnsupportedMediaTypeException
JSON 문법이 깨짐BadRequestException
필수 필드가 없음BadRequestException
non-null 자리에 nullBadRequestException
본문을 두 번 읽음RequestAlreadyConsumedException

검증 — 두 가지 전략 🟡 중급

흩어진 검증 — 금방 새어 나갑니다
post("/links") {
    val req = call.receive<CreateLinkRequest>()

    if (req.url.isBlank()) {
        call.respond(HttpStatusCode.BadRequest, "url 이 비었습니다")
        return@post                                  // ← 이걸 빼먹으면 응답이 두 번 나갑니다
    }
    if (!req.url.startsWith("http")) {
        call.respond(HttpStatusCode.BadRequest, mapOf("error" to "형식"))
        return@post                                  // ← 에러 모양도 위와 다릅니다
    }
    // 라우트마다 조금씩 다른 문자열 · 다른 구조가 나갑니다
}
검증을 값으로 — 라우트는 얇게
// konform — 규칙을 선언으로 씁니다
val validateCreateLink = Validation<CreateLinkRequest> {
    CreateLinkRequest::url {
        minLength(8) hint "url 이 너무 짧습니다"
        pattern("^https?://") hint "http(s):// 로 시작해야 합니다"
        maxLength(2048)
    }
    CreateLinkRequest::slug ifPresent {
        pattern("^[a-z0-9-]{3,16}$") hint "소문자 · 숫자 · 하이픈 3~16자"
    }
    CreateLinkRequest::tags { maxItems(10) }
}

// 라우트는 한 줄로 줄어듭니다
post("/links") {
    val req = call.receive<CreateLinkRequest>().validated(validateCreateLink)
    call.respond(HttpStatusCode.Created, service.create(req))
}
// validation/Validate.kt — 확장 함수 하나로 감싸 둡니다
class ValidationException(val fields: Map<String, List<String>>) :
    RuntimeException("검증 실패")

fun <T> T.validated(rule: Validation<T>): T {
    val result = rule(this)
    if (result is Invalid) {
        throw ValidationException(
            result.errors.groupBy(
                keySelector = { it.dataPath.removePrefix(".") },
                valueTransform = { it.message },
            )
        )
    }
    return this
}

// ── 에러 응답의 표준형 ──────────────────────────────────────────
@Serializable
data class ErrorResponse(
    val code: String,
    val message: String,
    val fields: Map<String, List<String>>? = null,
    val traceId: String? = null,
)

fun Application.configureStatusPages() = install(StatusPages) {

    exception<ValidationException> { call, e ->
        call.respond(HttpStatusCode.UnprocessableEntity,
            ErrorResponse("VALIDATION_FAILED", "입력값을 확인해 주세요", e.fields, call.callId))
    }

    exception<BadRequestException> { call, e ->
        call.respond(HttpStatusCode.BadRequest,
            ErrorResponse("MALFORMED_BODY", e.message ?: "본문을 읽을 수 없습니다", traceId = call.callId))
    }

    // 도메인 예외 — 계층을 sealed 로 두면 when 이 빠짐없이 검사됩니다
    exception<DomainException> { call, e ->
        val (status, code) = when (e) {
            is DomainException.NotFound  -> HttpStatusCode.NotFound to "NOT_FOUND"
            is DomainException.Conflict  -> HttpStatusCode.Conflict to "CONFLICT"
            is DomainException.Forbidden -> HttpStatusCode.Forbidden to "FORBIDDEN"
        }
        call.respond(status, ErrorResponse(code, e.message ?: code, traceId = call.callId))
    }

    // 마지막 그물 — 여기까지 오면 우리 잘못입니다
    exception<Throwable> { call, e ->
        call.application.log.error("처리되지 않은 예외 traceId={}", call.callId, e)
        call.respond(HttpStatusCode.InternalServerError,
            ErrorResponse("INTERNAL", "잠시 후 다시 시도해 주세요", traceId = call.callId))
    }

    // 상태 코드 자체를 가로채기 (404 · 405 등)
    status(HttpStatusCode.NotFound) { call, status ->
        call.respond(status, ErrorResponse("NOT_FOUND", "그런 경로가 없습니다"))
    }
}

sealed 로 도메인 예외를 묶습니다

// domain/DomainException.kt — Ktor 를 전혀 모르는 순수 코틀린
sealed class DomainException(message: String) : RuntimeException(message) {
    class NotFound(what: String)  : DomainException("$what 을(를) 찾을 수 없습니다")
    class Conflict(what: String)  : DomainException("$what 이(가) 이미 있습니다")
    class Forbidden(why: String)  : DomainException(why)
}

// 서비스는 HTTP 를 모릅니다
class LinkService(private val repo: LinkRepository) {
    suspend fun get(slug: String): Link =
        repo.findBySlug(slug) ?: throw DomainException.NotFound("링크 $slug")
}
sealed 의 값어치 — StatusPages 의 when 에 else 가 없어도 컴파일됩니다. 나중에 DomainException 하위 클래스를 하나 추가하면 그 when 이 컴파일 에러로 나서, 매핑을 빠뜨릴 수 없습니다. 스프링의 @ExceptionHandler 는 런타임에 조용히 500 이 나갑니다.

예외 대신 결과 타입을 쓰는 선택지

// 예외를 흐름 제어에 쓰기 싫다면 sealed 결과 타입도 좋습니다
sealed interface CreateResult {
    data class Ok(val link: Link) : CreateResult
    data class SlugTaken(val slug: String) : CreateResult
    data class InvalidUrl(val reason: String) : CreateResult
}

post("/links") {
    when (val r = service.create(call.receive<CreateLinkRequest>())) {
        is CreateResult.Ok         -> call.respond(HttpStatusCode.Created, r.link.toResponse())
        is CreateResult.SlugTaken  -> call.respond(HttpStatusCode.Conflict,
            ErrorResponse("SLUG_TAKEN", "${r.slug} 는 이미 쓰입니다"))
        is CreateResult.InvalidUrl -> call.respond(HttpStatusCode.BadRequest,
            ErrorResponse("INVALID_URL", r.reason))
    }
}
어느 쪽이 낫나 — 예상 가능한 실패는 결과 타입, 예상 못 한 실패는 예외가 좋은 기준입니다. "슬러그 중복"은 정상 시나리오의 일부니 결과 타입, "DB 커넥션 끊김"은 예외입니다. 결과 타입은 컴파일러가 처리 누락을 잡아 준다는 게 결정적 장점입니다.
call.receive 는 한 번만 됩니다. 본문은 스트림이라 한 번 읽으면 사라집니다. 서명 검증처럼 원본과 파싱본을 둘 다 써야 하면 install(DoubleReceive) 를 켜세요 — 본문을 메모리에 캐시하므로 큰 업로드가 오는 라우트에는 켜지 마세요.
KTOR 06

코루틴이 그대로 서버가 됩니다

Ktor 의 모든 핸들러 람다는 suspend 입니다. 어댑터도, Mono 도, CompletableFuture 도 없습니다. 코틀린에서 배운 코루틴 지식이 100% 그대로 서버에서 통합니다 — 이 탭에서 Ktor 를 쓰는 가장 큰 이유가 여기 있습니다.

게이트웨이가 내부 API 세 개를 부를 때 — 순차와 병렬의 시간축 ① 순차 — 앞 호출이 끝나야 다음이 출발합니다 user 120ms order 180ms coupon 150ms = 합계 450ms ② coroutineScope + async — 셋이 동시에 출발합니다 user 120ms order 180ms coupon 150ms = 합계 180ms — 가장 느린 하나가 전체 시간 0 100 200 300 400 500 600ms withTimeout(2.seconds) { … } 전체 묶음에 상한을 씌웁니다 하나가 실패하면 나머지 취소 구조적 동시성이 알아서 정리합니다 스프링 MVC 였다면 스레드 셋이 450ms 동안 묶여 있습니다 — Ktor 는 코루틴 셋이 기다릴 뿐 스레드는 놀지 않습니다
게이트웨이 · BFF 에서 Ktor 가 가장 크게 이기는 그림입니다 — 코드 세 줄로 세 배가 빨라집니다.

병렬 호출 — coroutineScope + async

// 게이트웨이 라우트 — 내부 API 셋을 동시에 부르고 합칩니다
get("/api/dashboard/{userId}") {
    val userId = call.parameters["userId"]!!

    val dashboard = withTimeout(2.seconds) {          // 전체 상한 2초
        coroutineScope {                              // ★ 구조적 동시성의 경계
            val user   = async { userClient.get(userId) }
            val orders = async { orderClient.recent(userId, limit = 5) }
            val coupon = async { couponClient.available(userId) }

            Dashboard(
                user   = user.await(),                // 여기서 비로소 기다립니다
                orders = orders.await(),
                coupon = coupon.await(),
            )
        }
    }
    call.respond(dashboard)
}
coroutineScope 가 하는 세 가지 — ① 안쪽 async 가 전부 끝날 때까지 기다리고, ② 하나라도 예외가 나면 나머지를 취소하고, ③ 그 예외를 밖으로 던집니다. 이걸 손으로 짜면 CompletableFuture.allOf + 취소 처리로 30줄이 됩니다.

부분 실패를 허용하고 싶을 때

// 쿠폰 조회가 실패해도 대시보드는 보여 줘야 한다면
get("/api/dashboard/{userId}") {
    val userId = call.parameters["userId"]!!

    coroutineScope {
        val user   = async { userClient.get(userId) }          // 필수
        val orders = async { orderClient.recent(userId, 5) }   // 필수
        val coupon = async {                                    // 선택
            try {
                withTimeoutOrNull(300.milliseconds) { couponClient.available(userId) }   // 시간 초과 → null
            } catch (e: CancellationException) {
                throw e                                         // 바깥 취소는 그대로 전파
            } catch (e: Exception) {
                null                                            // 쿠폰 서비스 장애 → 쿠폰 없이
            }
        }

        call.respond(Dashboard(user.await(), orders.await(), coupon.await()))
    }
}
runCatching 은 CancellationException 도 삼킵니다. 코루틴에서 취소는 예외로 전파되므로, 이걸 삼키면 취소가 안 먹는 코루틴이 됩니다. 안전한 형태는 이렇게 다시 던지는 것입니다 — catch (e: CancellationException) { throw e }. 그래서 위 코드는 runCatching 대신 그 형태를 쓰고, 시간 초과는 withTimeoutOrNull 로 null 을 받아 바깥 취소와 섞이지 않게 했습니다.

Dispatcher — 어느 스레드에서 도나 🟡 중급

Ktor 의 스레드 지도

이름개수여기서 하는 일
connectionGroupCPU ÷ 2 + 1연결 수락(accept)
workerGroupCPU ÷ 2 + 1소켓 읽기 · 쓰기
callGroupCPU 개수내 핸들러가 도는 곳
Dispatchers.IO기본 64블로킹 호출 격리
Dispatchers.DefaultCPU 개수CPU 를 쓰는 계산
get("/report") {
    // ① 블로킹 JDBC · 파일 IO → IO 로 넘깁니다
    val rows = withContext(Dispatchers.IO) { jdbcTemplateLikeQuery() }

    // ② 무거운 계산 (이미지 리사이즈 · 암호화 · 파싱) → Default
    val png = withContext(Dispatchers.Default) { renderChart(rows) }

    // ③ 그냥 suspend 호출(HTTP 클라이언트 · Redis)은 아무것도 안 해도 됩니다
    val cached = redis.get("report")     // 이미 논블로킹

    call.respondBytes(png, ContentType.Image.PNG)
}
판단 기준 한 줄 — 그 함수가 suspend 면 그냥 부르고, suspend 가 아니면서 시간이 걸리면 withContext 로 옮깁니다. JDBC · File.readText() · Thread.sleep · 자바 라이브러리 대부분이 후자입니다.

서버를 세우는 세 가지 실수 ★

// ① 핸들러 안에서 블로킹 — callGroup 스레드를 먹습니다
get("/bad1") {
    Thread.sleep(1000)                  // ✗ 이 스레드가 1초 동안 죽습니다
    val f = File("big.csv").readText()  // ✗ 파일 IO 도 블로킹입니다
    call.respondText("done")
}
//   동시 요청이 callGroup 크기(보통 8~16)를 넘는 순간
//   나머지 요청은 큐에서 그냥 기다립니다 → p99 가 수직으로 튑니다

// ② GlobalScope — 요청이 끝나도, 서버가 죽어도 살아 있습니다
get("/bad2") {
    GlobalScope.launch { sendEmail() }  // ✗ 취소도 안 되고 에러도 삼켜집니다
    call.respond(HttpStatusCode.Accepted)
}

// ③ runBlocking — 코루틴 안에서 코루틴을 기다립니다
get("/bad3") {
    val r = runBlocking { client.get("...") }  // ✗ 스레드를 붙잡고 대기
    call.respond(r.bodyAsText())
}
// ── 고친 형태 ──────────────────────────────────
get("/good") {
    withContext(Dispatchers.IO) { File("big.csv").readText() }   // ①
    call.respondText("done")
}

// ② 백그라운드 작업은 애플리케이션 스코프에서 — 앱 종료 때 함께 취소합니다
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// monitor.subscribe(ApplicationStopped) { appScope.cancel() }   ← 이 줄이 있어야 '같이 정리'된다
get("/good2") {
    appScope.launch {
        runCatching { sendEmail() }.onFailure { log.error("메일 실패", it) }
    }
    call.respond(HttpStatusCode.Accepted)
}

취소 전파 — 클라이언트가 끊으면 서버도 멈춥니다 🔴 고급

공짜로 얻는 것과 직접 해야 하는 것

get("/heavy") {
    // 핸들러의 코루틴은 call 의 수명에 묶여 있습니다.
    // 클라이언트가 연결을 끊으면 이 코루틴이 취소됩니다.
    val result = heavyPipeline()          // suspend 호출은 취소 지점을 가집니다
    call.respond(result)
}

suspend fun heavyPipeline(): Report {
    val rows = mutableListOf<Row>()
    repeat(1_000_000) { i ->
        currentCoroutineContext().ensureActive()   // ★ CPU 루프에는 이걸 넣어야 취소가 먹습니다
        rows += compute(i)
    }
    return Report(rows)
}

// 취소됐는지 알아야 정리할 것이 있다면
get("/stream") {
    // respondText 를 반복 호출하면 두 번째에서 ResponseAlreadySentException — 응답 하나를 열어 두고 씁니다
    call.respondTextWriter {
        try {
            while (true) { write(nextChunk()); flush(); delay(1000) }
        } catch (e: CancellationException) {
            log.info("클라이언트가 끊었습니다 — 커서를 닫습니다")
            cursor.close()
            throw e                        // ★ 반드시 다시 던집니다
        }
    }
}
취소는 협조적입니다 — 코루틴은 suspend 지점에서만 취소를 확인합니다. while (true) { 계산 } 처럼 suspend 호출이 없는 루프는 클라이언트가 끊어도 끝까지 CPU 를 태웁니다. ensureActive() 또는 yield() 를 넣어야 합니다.

세 프레임워크 3자 비교 ★

Spring MVCWebFluxKtor
단위스레드Reactor 시그널코루틴
대기 비용스레드 1개거의 0거의 0
코드 모양평범한 함수연산자 체인평범한 함수
디버깅쉬움어려움보통
스택 트레이스완전잘림보통(디버그 모드로 개선)
블로킹 호출괜찮음전체 마비IO 로 옮기면 OK
취소사실상 없음있음구조적 · 자동
컨텍스트 전파ThreadLocalContextCoroutineContext
WebFlux 와 Ktor 는 성능이 비슷합니다. 둘 다 논블로킹 이벤트 루프입니다. 차이는 쓰는 사람의 코드가 어떻게 생겼는가입니다 — flatMap · zip · switchIfEmpty 체인 대신 if 와 for 를 그대로 쓴다는 것. 코틀린 팀이 WebFlux 를 쓰더라도 결국 kotlinx-coroutines-reactor 로 코루틴에 붙이는데, Ktor 는 그 변환 계층이 아예 없습니다.
스택 트레이스가 잘려서 답답할 때 — 개발 환경에서 -Dkotlinx.coroutines.debug 를 켜면 코루틴 이름이 스레드 이름에 붙고, kotlinx-coroutines-debug 아티팩트를 넣으면 DebugProbes.dumpCoroutines() 로 현재 살아 있는 코루틴 전부를 덤프할 수 있습니다. "요청이 어디서 멈춰 있는지"를 스레드 덤프 대신 이걸로 봅니다.
KTOR 07

DB 연동 — Exposed 와 코루틴 트랜잭션

Ktor 에는 Spring Data 가 없습니다. 대신 젯브레인스의 Exposed 를 씁니다 — 테이블을 코틀린 객체로 선언하고 쿼리를 타입 안전한 DSL 로 씁니다. 가장 중요한 주제는 블로킹 JDBC 를 코루틴과 어떻게 섞느냐입니다.

코루틴 1000개가 커넥션 10개를 나눠 쓰는 그림 — 병목은 언제나 풀입니다 suspend 핸들러 코루틴 1000개 떠 있어도 메모리 몇 MB 면 충분합니다 Dispatchers.IO — 스레드 64개 블로킹 JDBC 호출은 여기서만 돕니다 HikariCP maximumPoolSize = 10 진짜 병목 — 여기서 줄을 섭니다 PostgreSQL 동시 처리는 10개가 상한 스레드를 늘려도 소용없는 이유 — 실제 상한은 커넥션입니다 IO 스레드 64 커넥션 풀 10 — 나머지 54개는 놀고 있습니다 0 20 40 60 동시 처리 가능 수(개) IO 스레드를 늘려 봐야 소용없습니다 — p99 가 튀면 maximumPoolSize 와 커넥션 점유 시간을 먼저 보세요
Ktor 로 바꿔서 동시성이 늘어도 DB 는 그대로입니다 — 늘어난 동시성이 풀 앞에 줄로 쌓입니다.

테이블 선언과 커넥션 풀

// db/Tables.kt — 스키마를 코틀린 객체로 선언합니다
object Links : Table("links") {
    val id        = long("id").autoIncrement()
    val slug      = varchar("slug", 16).uniqueIndex()
    val url       = text("url")
    val ownerId   = long("owner_id").references(Users.id)
    val clicks    = long("clicks").default(0)
    val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
    override val primaryKey = PrimaryKey(id)
}

// db/Database.kt — 풀은 main() 에서 딱 한 번 만듭니다
fun initDatabase(cfg: DbConfig): HikariDataSource {
    val ds = HikariDataSource(HikariConfig().apply {
        jdbcUrl = cfg.url
        username = cfg.user
        password = cfg.password
        driverClassName = "org.postgresql.Driver"
        maximumPoolSize = cfg.poolSize          // ★ 아래 note 참고
        minimumIdle = cfg.poolSize
        connectionTimeout = 3_000               // 풀이 비면 3초 기다리고 실패
        maxLifetime = 25 * 60 * 1000L           // DB 쪽 타임아웃보다 짧게
        transactionIsolation = "TRANSACTION_READ_COMMITTED"
        poolName = "app-pool"                   // 로그와 메트릭에 이 이름이 찍힙니다
    })
    Database.connect(ds)                        // Exposed 에 등록
    return ds
}
풀 크기를 크게 잡는 것이 거의 항상 틀립니다 — 커넥션은 DB 쪽에서도 메모리와 프로세스를 씁니다. 출발점은 (코어 수 × 2) + 디스크 수, 즉 4코어면 10 안팎입니다. 100 으로 올리면 DB 가 컨텍스트 스위칭에 시간을 써서 오히려 느려집니다.

DSL 로 쿼리 쓰기

// 조회 — where 절이 코틀린 식입니다 (오타가 컴파일 에러)
val link = Links.selectAll()
    .where { (Links.slug eq slug) and (Links.ownerId eq userId) }
    .singleOrNull()
    ?.toLink()

// 목록 + 페이징 + 정렬
val page = Links.selectAll()
    .where { Links.ownerId eq userId }
    .orderBy(Links.createdAt to SortOrder.DESC)
    .limit(size).offset((pageNo - 1L) * size)
    .map { it.toLink() }

// 삽입 — 생성된 키를 받습니다
val newId = Links.insert { it[slug] = s; it[url] = u; it[ownerId] = uid } get Links.id
//   (insertAndGetId 는 IdTable 전용 — 여기 Links 는 일반 Table)

// 갱신 — 원자적 증가 (읽고 더해서 쓰지 않습니다 ★)
Links.update({ Links.slug eq slug }) {
    with(SqlExpressionBuilder) { it.update(clicks, clicks + 1) }
}

// 조인
val rows = (Links innerJoin Users)
    .select(Links.slug, Links.url, Users.email)
    .where { Users.active eq true }
    .map { it[Links.slug] to it[Users.email] }

// 매핑은 확장 함수로 한곳에 모읍니다
fun ResultRow.toLink() = Link(
    id = this[Links.id], slug = this[Links.slug],
    url = this[Links.url], clicks = this[Links.clicks],
)

트랜잭션과 코루틴 — 여기가 진짜 함정입니다 🔴 고급

이렇게 쓰면 스레드가 막힙니다
get("/links/{slug}") {
    // transaction { } 은 블로킹입니다 — 지금 스레드를 붙잡습니다
    val link = transaction {
        Links.selectAll().where { Links.slug eq slug }.singleOrNull()
    }
    call.respond(link!!.toLink())
}
// callGroup 스레드가 DB 응답을 기다리며 놀고 있습니다.
// 동시 요청 20개면 스레드 8개가 전부 막혀 나머지는 큐에서 대기합니다.

// 더 나쁜 경우 — 트랜잭션 안에서 외부 API 를 부릅니다
transaction {
    val link = Links.selectAll().where { Links.slug eq slug }.single()
    val meta = httpClient.fetchMeta(link[Links.url])   // ✗ 300ms 동안 커넥션 점유
    Links.update({ Links.slug eq slug }) { it[title] = meta.title }
}
// 커넥션 10개 × 300ms = 초당 33건이 처리 상한이 됩니다
suspend 트랜잭션 · 짧게 유지
get("/links/{slug}") {
    val link = newSuspendedTransaction(Dispatchers.IO) {
        Links.selectAll().where { Links.slug eq slug }.singleOrNull()?.toLink()
    } ?: throw DomainException.NotFound("링크")
    call.respond(link)
}

// 외부 호출은 트랜잭션 밖으로 뺍니다 ★
val url = newSuspendedTransaction(Dispatchers.IO) {
    Links.selectAll().where { Links.slug eq slug }.single()[Links.url]
}
val meta = httpClient.fetchMeta(url)                // 커넥션을 안 쥔 채로 호출
newSuspendedTransaction(Dispatchers.IO) {
    Links.update({ Links.slug eq slug }) { it[title] = meta.title }
}

// 커넥션 점유 시간 = 두 짧은 쿼리 시간뿐입니다
// db/ExposedLinkRepository.kt — 리포지터리에서 트랜잭션 경계를 정합니다
class ExposedLinkRepository : LinkRepository {

    override suspend fun findBySlug(slug: String): Link? = dbQuery {
        Links.selectAll().where { Links.slug eq slug }.singleOrNull()?.toLink()
    }

    override suspend fun create(slug: String, url: String, ownerId: Long): Link = dbQuery {
        val id = Links.insert { it[Links.slug] = slug; it[Links.url] = url; it[Links.ownerId] = ownerId } get Links.id
        Link(id, slug, url, 0)
    }

    // 원자적 증가 — 경합이 있어도 안전합니다
    override suspend fun incrementClicks(slug: String): Unit = dbQuery {
        Links.update({ Links.slug eq slug }) {
            with(SqlExpressionBuilder) { it.update(clicks, clicks + 1) }
        }
    }

    // 롤백은 예외를 던지면 됩니다
    override suspend fun transferOwner(slug: String, newOwner: Long): Unit = dbQuery {
        val n = Links.update({ Links.slug eq slug }) { it[ownerId] = newOwner }
        if (n == 0) error("링크가 없습니다")        // ← 예외 = 롤백
    }
}

// 공통 헬퍼 하나로 모든 트랜잭션을 통일합니다
suspend fun <T> dbQuery(block: suspend Transaction.() -> T): T =
    newSuspendedTransaction(Dispatchers.IO) {
        // 개발 환경에서 실행된 SQL 을 보고 싶다면
        // addLogger(StdOutSqlLogger)
        block()
    }

마이그레이션 — Flyway

// main() 에서 서버를 띄우기 전에 실행합니다
fun migrate(ds: DataSource) {
    Flyway.configure()
        .dataSource(ds)
        .locations("classpath:db/migration")
        .baselineOnMigrate(true)
        .load()
        .migrate()
}

fun main() {
    val ds = initDatabase(config.db)
    migrate(ds)                                   // ★ 서버 기동 전에
    embeddedServer(Netty, port = 8080) { module(ds) }.start(wait = true)
}
-- src/main/resources/db/migration/V1__create_links.sql
CREATE TABLE links (
    id         BIGSERIAL PRIMARY KEY,
    slug       VARCHAR(16) NOT NULL UNIQUE,
    url        TEXT        NOT NULL,
    owner_id   BIGINT      NOT NULL,
    clicks     BIGINT      NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_links_owner ON links (owner_id, created_at DESC);
SchemaUtils.create(Links) 는 운영에 쓰지 마세요. Exposed 가 테이블을 만들어 줄 수는 있지만 컬럼 변경 · 데이터 이관 · 롤백을 못 합니다. 테스트와 로컬에서만 쓰고, 운영 스키마는 Flyway 의 SQL 파일이 유일한 진실이어야 합니다.

Exposed 말고 다른 선택지

선택지성격고를 때
Exposed DSL블로킹 JDBC + suspend 래퍼기본값 · 코틀린다움
Exposed DAO엔티티 · 지연 로딩CRUD 위주 · JPA 감각
jOOQ스키마에서 코드 생성복잡한 SQL · 기존 DB
R2DBC진짜 논블로킹 드라이버커넥션 수가 극단적일 때
Ktorm가벼운 코틀린 ORMExposed 가 무겁게 느껴질 때
순수 JDBC직접쿼리 몇 개짜리 서비스
R2DBC 가 항상 낫지 않습니다 — 논블로킹 드라이버는 스레드를 안 막지만 DB 의 동시 처리 능력은 그대로입니다. 커넥션 대신 DB 서버 자체가 병목이 될 뿐입니다. JDBC + Dispatchers.IO + 적당한 풀이 대부분의 서비스에서 충분하고, 디버깅과 도구 지원이 훨씬 낫습니다.
Exposed 1.0 부터 패키지가 바뀌었습니다 — org.jetbrains.exposed.sql.* 이 org.jetbrains.exposed.v1.* 로 갈라졌습니다 (JDBC 는 v1.jdbc, R2DBC 는 v1.r2dbc). 옛 예제 코드를 가져올 때 import 만 고치면 대부분 그대로 돕니다.
KTOR 08

인증 · 인가 — JWT · 세션 · OAuth

Spring Security 는 필터 체인 서른 개짜리 거대한 프레임워크입니다. Ktor 의 Authentication 은 "이 요청에서 사용자를 뽑아내는 함수" 하나가 전부입니다. 배울 게 적은 대신 인가 정책은 내가 설계해야 합니다.

로그인부터 만료 · 재발급까지 — JWT 인증의 전체 흐름 1 POST /auth/login — id · 비밀번호 TLS 위에서만 받습니다. 평문 비밀번호는 로그에 절대 남기지 않습니다 2 BCrypt.verify(pw, storedHash) 일부러 느린 해시(cost 12) — Dispatchers.Default 에서 돌립니다 3 access 15분 + refresh 14일 발급 access 는 응답 본문으로, refresh 는 HttpOnly · Secure 쿠키로 내려보냅니다 4 Authorization: Bearer eyJhbGci… 이후 모든 요청 헤더에 붙습니다. 서버는 상태를 저장하지 않습니다 5 install(Authentication) { jwt { verifier · validate } } 서명 · 만료 · issuer · audience 를 검사해 JWTPrincipal 을 만듭니다 6 authenticate("auth-jwt") { … } 안의 라우트만 실행 이 블록 밖은 그대로 공개입니다 — 실수로 밖에 두면 인증이 사라집니다 7 만료 → 401 → POST /auth/refresh 리프레시 토큰은 1회용 — 쓰면 폐기하고 새로 발급합니다(회전)
JWT 의 대가는 즉시 취소할 수 없다는 것입니다 — 그래서 access 를 짧게 두고 refresh 를 회전시킵니다.
// plugins/Security.kt
fun Application.configureSecurity(cfg: JwtConfig, users: UserService) {

    val verifier = JWT.require(Algorithm.HMAC256(cfg.secret))
        .withIssuer(cfg.issuer)
        .withAudience(cfg.audience)
        .build()

    install(Authentication) {

        jwt("auth-jwt") {
            realm = cfg.realm
            verifier(verifier)

            validate { credential ->                       // 토큰이 유효한 뒤에 부릅니다
                val uid = credential.payload.getClaim("uid").asLong() ?: return@validate null
                val roles = credential.payload.getClaim("roles").asList(String::class.java).orEmpty()
                UserPrincipal(uid, roles.toSet())          // null 을 돌려주면 401 입니다
            }

            challenge { _, _ ->                            // 실패했을 때 무엇을 돌려줄지
                call.respond(HttpStatusCode.Unauthorized,
                    ErrorResponse("UNAUTHORIZED", "토큰이 없거나 만료됐습니다"))
            }
        }

        // 서버 사이 통신용 — 정적 API 키
        bearer("service-token") {
            authenticate { token ->
                if (MessageDigest.isEqual(token.token.toByteArray(), cfg.serviceToken.toByteArray()))
                    ServicePrincipal("internal") else null
            }
        }
    }
}

data class UserPrincipal(val userId: Long, val roles: Set<String>)

// ── 토큰 발급 ────────────────────────────────────────────────
fun issueAccessToken(cfg: JwtConfig, user: User): String =
    JWT.create()
        .withIssuer(cfg.issuer)
        .withAudience(cfg.audience)
        .withClaim("uid", user.id)
        .withClaim("roles", user.roles.toList())
        .withExpiresAt(Date(System.currentTimeMillis() + 15 * 60_000))   // 15분
        .sign(Algorithm.HMAC256(cfg.secret))

로그인 · 리프레시 라우트

fun Route.authRoutes(cfg: JwtConfig, users: UserService, tokens: RefreshTokenStore) {

    post("/auth/login") {
        val req = call.receive<LoginRequest>()
        val user = users.verify(req.email, req.password)
            ?: throw DomainException.Forbidden("이메일 또는 비밀번호가 틀렸습니다")

        val refresh = tokens.issue(user.id)                 // 랜덤 문자열 + DB 저장
        call.response.cookies.append(
            Cookie(
                name = "refresh", value = refresh,
                httpOnly = true, secure = true,
                path = "/auth", maxAge = 14 * 24 * 3600,
                extensions = mapOf("SameSite" to "Strict"),
            )
        )
        call.respond(TokenResponse(issueAccessToken(cfg, user), expiresIn = 900))
    }

    post("/auth/refresh") {
        val presented = call.request.cookies["refresh"]
            ?: throw DomainException.Forbidden("리프레시 토큰이 없습니다")

        // ★ 회전 — 쓴 토큰은 즉시 폐기하고 새로 발급합니다
        val userId = tokens.consumeAndRotate(presented)
            ?: throw DomainException.Forbidden("리프레시 토큰이 유효하지 않습니다")

        val user = users.get(userId)
        call.respond(TokenResponse(issueAccessToken(cfg, user), expiresIn = 900))
    }

    authenticate("auth-jwt") {
        post("/auth/logout") {
            tokens.revokeAll(call.principal<UserPrincipal>()!!.userId)
            call.respond(HttpStatusCode.NoContent)
        }
    }
}
이미 쓴 리프레시 토큰이 다시 오면 탈취를 의심합니다. 회전 방식에서는 같은 토큰이 두 번 오는 상황이 정상적으로는 없습니다. 그때는 그 사용자의 모든 리프레시 토큰을 폐기하고 재로그인을 시키는 것이 표준 대응입니다.

인가 — 라우트 래퍼로 만듭니다

// Ktor 에는 @PreAuthorize 가 없습니다 — 확장 함수로 직접 만듭니다
fun Route.requireRole(vararg roles: String, build: Route.() -> Unit) {
    val plugin = createRouteScopedPlugin("RequireRole") {
        on(AuthenticationChecked) { call ->     // onCall 은 인증보다 먼저 돌 수 있다 — 인증 확인 뒤 훅을 쓴다
            val principal = call.principal<UserPrincipal>()
            if (principal == null || roles.none { it in principal.roles }) {
                call.respond(HttpStatusCode.Forbidden,
                    ErrorResponse("FORBIDDEN", "권한이 없습니다"))
            }
        }
    }
    // 이름 없는 자식 라우트를 만들어 그 안에만 플러그인을 붙입니다
    createChild(object : RouteSelector() {
        override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int) =
            RouteSelectorEvaluation.Constant
    }).apply { install(plugin); build() }
}

routing {
    authenticate("auth-jwt") {
        get("/me") { call.respond(call.principal<UserPrincipal>()!!) }

        requireRole("ADMIN") {                       // ★ 이 블록 안은 ADMIN 만
            get("/admin/stats") { call.respond(stats()) }
            delete("/admin/links/{slug}") { /* … */ }
        }
    }

    // 인증은 선택 — 로그인했으면 개인화, 아니면 공개 데이터
    authenticate("auth-jwt", optional = true) {
        get("/feed") {
            val uid = call.principal<UserPrincipal>()?.userId
            call.respond(if (uid != null) feed.personal(uid) else feed.public())
        }
    }
}
소유권 검사는 라우트가 아니라 서비스에서 — 역할(role)은 라우트 래퍼로 충분하지만 "이 링크가 내 것인가"는 DB 를 봐야 알 수 있습니다. service.get(slug, requesterId) 처럼 요청자 id 를 서비스에 넘겨 서비스가 판단하게 두세요. 라우트에서 검사하면 다른 진입점에서 빠집니다.

세션 · OAuth · 비밀번호 🟡 중급

쿠키 세션

@Serializable
data class UserSession(val uid: Long, val name: String)

install(Sessions) {
    cookie<UserSession>("SESSION") {
        cookie.path = "/"
        cookie.httpOnly = true
        cookie.secure = true
        cookie.maxAgeInSeconds = 7 * 24 * 3600
        cookie.extensions["SameSite"] = "Lax"
        // 값을 암호화 + 서명 — 클라이언트가 못 고칩니다
        transform(SessionTransportTransformerEncrypt(
            encryptionKey = hex(cfg.sessionEncryptKey),
            signKey = hex(cfg.sessionSignKey),
        ))
    }
}

// 사용
call.sessions.set(UserSession(user.id, user.name))
val s = call.sessions.get<UserSession>()
call.sessions.clear<UserSession>()

OAuth (구글 로그인)

install(Authentication) {
    oauth("google") {
        urlProvider = { "${cfg.baseUrl}/callback" }
        providerLookup = {
            OAuthServerSettings.OAuth2ServerSettings(
                name = "google",
                authorizeUrl = "https://accounts.google.com/o/oauth2/auth",
                accessTokenUrl = "https://oauth2.googleapis.com/token",
                requestMethod = HttpMethod.Post,
                clientId = cfg.googleClientId,
                clientSecret = cfg.googleSecret,
                defaultScopes = listOf("openid", "email", "profile"),
            )
        }
        client = HttpClient(CIO)
    }
}

authenticate("google") {
    get("/login/google") { }          // 여기 오면 구글로 보냅니다
    get("/callback") {
        val p = call.principal<OAuthAccessTokenResponse.OAuth2>()!!
        val profile = fetchGoogleProfile(p.accessToken)
        call.sessions.set(UserSession(users.upsert(profile).id, profile.name))
        call.respondRedirect("/")
    }
}

비밀번호 해시

// BCrypt (at.favre.lib:bcrypt) — 무난한 기본값
suspend fun hash(pw: String): String =
    withContext(Dispatchers.Default) {     // ★ CPU 를 쓰므로 옮깁니다
        BCrypt.withDefaults().hashToString(12, pw.toCharArray())
    }

suspend fun verify(pw: String, stored: String): Boolean =
    withContext(Dispatchers.Default) {
        BCrypt.verifyer().verify(pw.toCharArray(), stored).verified
    }

// Argon2id 를 쓸 수 있다면 더 낫습니다 (GPU 공격에 강함)
// implementation("de.mkammerer:argon2-jvm")
cost 12 는 한 번에 약 200~300ms 걸립니다. Dispatchers.Default 로 안 옮기면 로그인 요청 몇 개가 이벤트 루프를 마비시킵니다.
비밀은 코드에도 application.conf 에도 두지 않습니다. HOCON 의 ${?JWT_SECRET} 로 환경 변수에서만 읽고, 로컬 개발은 .env 또는 application-local.conf(gitignore)로 분리하세요. HMAC 비밀은 최소 32바이트 랜덤이어야 합니다 — 짧은 문자열을 쓰면 서명 위조가 현실적인 공격이 됩니다.

무엇을 고르나 — 인증 방식 한 장 🟢 기초

네 가지 방식 비교

방식상태즉시 취소알맞은 곳
JWT없음불가모바일 · SPA · 서버 간
세션 쿠키서버에가능같은 도메인 웹앱
OAuth공급자공급자소셜 로그인 · 위임
API 키DB가능서버 간 · 웹훅
고민되면 세션부터 — 브라우저 하나만 상대하는 서비스라면 JWT 의 장점(무상태 · 확장성)이 거의 안 쓰이고, 즉시 로그아웃이 안 된다는 단점만 남습니다. JWT 는 클라이언트가 여러 종류이거나 서비스가 여러 대일 때 값을 합니다.

배포 전에 반드시 확인할 것

항목확인
HMAC 비밀 길이32바이트 이상 · 환경 변수
access 만료15분 안팎
refresh 저장 위치HttpOnly · Secure 쿠키
refresh 회전1회용 · 재사용 감지
비밀번호 해시BCrypt cost 12 이상
로그인 실패 제한IP · 계정별 레이트 리밋
토큰 검증 항목서명 · 만료 · iss · aud
인증 라우트 위치authenticate { } 안인지
마지막 줄이 실제로 가장 많이 터집니다 — 라우트를 리팩터링하다가 authenticate { } 블록 밖으로 한 줄이 빠져나가면 그 API 가 조용히 공개됩니다. "토큰 없이 호출하면 401 이 나온다"는 테스트를 라우트마다 한 줄씩 두세요(kt10).
KTOR 09

WebSocket · SSE — 실시간

스프링에서 WebSocket 을 쓰려면 WebSocketHandler · STOMP · 세션 레지스트리를 배워야 합니다. Ktor 에서는 핸들러 안이 그냥 코루틴이라 for (frame in incoming) 한 줄이면 됩니다 — 대신 세션 집합의 동시성은 내가 책임집니다.

채팅방 하나에 붙은 세션들 — 동시성 안전하게 관리하기 user A 브라우저 user B 모바일 앱 user C 느린 연결 ChatRoom sessions: MutableSet Mutex 로 감싸 접근 join / leave / broadcast 각 세션은 자기 코루틴 느린 클라이언트는 격리 incoming: ReceiveChannel 프레임을 받는 쪽 outgoing: SendChannel 프레임을 보내는 쪽 ping 15초 죽은 연결을 걸러 냅니다 for (frame in incoming) { … } 루프가 끝나면 연결 종료입니다 finally 에서 반드시 세션을 집합에서 빼야 합니다 — 안 하면 메모리가 샙니다 브로드캐스트는 하나가 느리면 전체가 느려집니다 — 세션마다 버퍼를 두고 넘치면 그 세션만 끊습니다
WebSocket 사고의 9할이 세션 정리 누락입니다 — finally 를 먼저 쓰고 시작하세요.
// plugins/Sockets.kt
fun Application.configureSockets() {
    install(WebSockets) {
        pingPeriod = 15.seconds          // ★ 죽은 연결을 걸러 냅니다 (프록시가 조용히 끊는 경우)
        timeout = 30.seconds
        maxFrameSize = 64 * 1024
        masking = false
        // JSON 을 객체로 주고받으려면 컨버터를 답니다
        contentConverter = KotlinxWebsocketSerializationConverter(Json)
    }
}

// ── 방 관리자 — 동시성 안전이 전부입니다 ──────────────────────────
@Serializable
sealed interface ChatEvent {
    @Serializable @SerialName("msg")   data class Message(val from: String, val text: String) : ChatEvent
    @Serializable @SerialName("join")  data class Joined(val who: String, val count: Int) : ChatEvent
    @Serializable @SerialName("leave") data class Left(val who: String, val count: Int) : ChatEvent
}

class ChatRoom {
    private val mutex = Mutex()
    private val sessions = mutableMapOf<String, DefaultWebSocketServerSession>()

    suspend fun join(name: String, s: DefaultWebSocketServerSession) {
        mutex.withLock { sessions[name] = s }
        broadcast(ChatEvent.Joined(name, size()))
    }

    suspend fun leave(name: String) {
        mutex.withLock { sessions.remove(name) }
        broadcast(ChatEvent.Left(name, size()))
    }

    suspend fun size() = mutex.withLock { sessions.size }

    suspend fun broadcast(event: ChatEvent) {
        // ★ 락을 쥔 채로 전송하지 않습니다 — 스냅샷만 뜨고 락을 놓습니다
        val targets = mutex.withLock { sessions.toMap() }
        val dead = ConcurrentLinkedQueue<String>()   // ★ 여러 코루틴이 동시에 추가하므로

        coroutineScope {
            targets.forEach { (name, session) ->
                launch {
                    try {
                        withTimeout(2.seconds) { session.sendSerialized<ChatEvent>(event) }
                    } catch (e: Exception) {
                        dead += name             // 느리거나 끊긴 세션은 버립니다
                    }
                }
            }
        }
        if (dead.isNotEmpty()) mutex.withLock { dead.forEach(sessions::remove) }
    }
}

라우트 — finally 가 핵심

fun Route.chatRoutes(room: ChatRoom) {
    authenticate("auth-jwt") {
        webSocket("/ws/chat") {
            val name = call.principal<UserPrincipal>()!!.userId.toString()

            room.join(name, this)
            try {
                for (frame in incoming) {              // 연결이 끊기면 루프가 끝납니다
                    when (frame) {
                        is Frame.Text -> {
                            val text = frame.readText().take(500)
                            if (text.isNotBlank()) room.broadcast(ChatEvent.Message(name, text))
                        }
                        is Frame.Binary -> { /* 무시 */ }
                        else -> {}
                    }
                }
            } catch (e: ClosedReceiveChannelException) {
                // 정상 종료 — 로그 남길 필요 없습니다
            } catch (e: Throwable) {
                application.log.warn("소켓 오류 user={}", name, e)
            } finally {
                room.leave(name)                       // ★ 무슨 일이 있어도 여기는 실행됩니다
            }
        }
    }
}
webSocket { } 블록 안은 연결이 사는 동안 계속 실행됩니다. 일반 라우트처럼 "응답하고 끝"이 아닙니다. 여기서 만든 코루틴 · 타이머 · 구독은 전부 finally 에서 정리해야 합니다. 세션 1만 개짜리 서비스에서 정리를 빠뜨리면 몇 시간 만에 OOM 이 납니다.

SSE — 서버가 일방으로 보낼 때

// implementation("io.ktor:ktor-server-sse")
install(SSE)

routing {
    // 실시간 클릭 카운터 — 브라우저는 EventSource 한 줄이면 됩니다
    sse("/events/clicks/{slug}") {
        val slug = call.parameters["slug"]!!
        clickFlow(slug)                       // Flow<Long>
            .conflate()                        // ★ 밀리면 최신 값만 보냅니다
            .collect { count ->
                send(ServerSentEvent(data = count.toString(), event = "click"))
            }
    }
}
// 브라우저 쪽 — 자동 재연결까지 공짜입니다
const es = new EventSource("/events/clicks/ab12");
es.addEventListener("click", e => {
  document.querySelector("#count").textContent = e.data;
});
WebSocketSSE
방향양방향서버 → 클라이언트
프로토콜업그레이드그냥 HTTP
재연결직접 구현브라우저가 자동
프록시 · CDN설정 필요대체로 통과
바이너리가능텍스트만
알맞은 곳채팅 · 게임알림 · 진행률 · 대시보드

백프레셔 — 느린 클라이언트 한 명이 서버를 죽이지 않게 🔴 고급

세션마다 버퍼를 둡니다

// 브로드캐스트가 느린 세션에서 막히지 않게, 세션마다 발신 코루틴을 둡니다
class BufferedSession(
    private val session: DefaultWebSocketServerSession,
    scope: CoroutineScope,
) {
    // 용량을 넘으면 가장 오래된 것을 버립니다 (알림은 최신이 중요)
    private val outbox = Channel<ChatEvent>(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)

    private val pump = scope.launch {
        for (event in outbox) {
            runCatching { session.sendSerialized(event) }
                .onFailure { outbox.close(); session.close() }
        }
    }

    fun offer(event: ChatEvent) = outbox.trySend(event).isSuccess   // 절대 블로킹하지 않습니다
    fun stop() { outbox.close(); pump.cancel() }
}
왜 trySend 인가 — 브로드캐스트 루프가 단 한 세션의 네트워크 속도에 묶이면 안 됩니다. trySend 는 절대 기다리지 않습니다 — 여기서는 DROP_OLDEST 라서 버퍼가 차면 가장 오래된 것을 버리고 즉시 성공합니다(기본 버퍼였다면 false 를 돌려주고 지나갑니다). "느린 사람은 메시지를 좀 놓친다"가 "모두가 느려진다"보다 낫습니다.

여러 인스턴스로 늘어나면

// 서버 2대로 늘리는 순간 "같은 방인데 서로 안 보이는" 문제가 생깁니다.
// 해결은 언제나 같습니다 — 바깥에 방송망을 둡니다 (Redis Pub/Sub)

class DistributedChatRoom(
    private val local: ChatRoom,
    private val redis: RedisCoroutinesCommands<String, String>,
    scope: CoroutineScope,
) {
    init {
        scope.launch {                                  // 다른 인스턴스가 쏜 것을 받아 로컬에 뿌립니다
            subscribeChannel("chat").collect { json ->
                local.broadcast(Json.decodeFromString(json))
            }
        }
    }

    suspend fun publish(event: ChatEvent) {             // 내가 받은 것은 전체에 쏩니다
        redis.publish("chat", Json.encodeToString(event))
    }
}
로드밸런서에 스티키 세션이 필요합니다 — WebSocket 은 한 번 붙으면 그 인스턴스와 계속 통신합니다. 앞단이 라운드 로빈이면 업그레이드 요청 자체가 흩어져 연결이 실패합니다. 인그레스에서 upgrade 헤더 허용과 충분한 proxy_read_timeout(기본 60초는 짧습니다)을 함께 확인하세요.
KTOR 10

DI · 테스트 — Koin 과 testApplication

Ktor 에는 DI 컨테이너가 없습니다. 놀랍게도 그게 문제가 안 되는 경우가 많습니다 — main() 에서 객체를 순서대로 만들어 넘기면 끝이기 때문입니다. 그리고 컨테이너가 없어서 테스트가 압도적으로 빠릅니다.

테스트 세 층 — 폭이 개수, 위로 갈수록 느립니다 ③ testcontainers ② testApplication ① 순수 단위 테스트 느림 빠름 진짜 Postgres · 도커 기동 3~8초 몇 개만 라우트 · 플러그인 — 엔진 없이 파이프라인만 수 밀리초 · 수십 개 도메인 함수만 — 서버도 DB 도 없음 수 마이크로초 · 수백 개 곁에 두는 도구 둘 MockK — mockk() · coEvery 로 코루틴 함수도 그대로 스텁됩니다 createClient { json() } — JSON 을 아는 테스트 전용 HTTP 클라이언트 스프링 @SpringBootTest 한 건이 3~10초일 때 Ktor 의 testApplication 은 5~30 ms 아래가 넓어야 합니다 — 꼭대기가 두꺼워지면 빌드가 분 단위가 됩니다
컨테이너 기동이 없다는 것이 테스트를 실제로 돌리게 만드는 가장 큰 이유입니다.

먼저 — 수동 DI 로 충분한가?

// Deps.kt — 앱이 쓰는 모든 것을 한 클래스로 모읍니다
class Deps(
    val ds: HikariDataSource,
    val linkRepo: LinkRepository,
    val cache: CacheClient,
    val linkService: LinkService,
    val userService: UserService,
) : AutoCloseable {
    override fun close() { ds.close(); cache.close() }
}

fun buildDeps(cfg: AppConfig): Deps {
    val ds = initDatabase(cfg.db)
    val cache = RedisCache(cfg.redis)
    val linkRepo = ExposedLinkRepository()
    return Deps(
        ds = ds, cache = cache, linkRepo = linkRepo,
        linkService = LinkService(linkRepo, cache),
        userService = UserService(ExposedUserRepository()),
    )
}

fun main() {
    val cfg = AppConfig.load()
    val deps = buildDeps(cfg)
    Runtime.getRuntime().addShutdownHook(Thread { deps.close() })
    embeddedServer(Netty, port = cfg.port) { module(cfg, deps) }.start(wait = true)
}
객체가 30개 아래라면 이걸로 끝냅니다. 의존성 그래프가 컴파일러에게 보이고, 순환 참조는 컴파일 에러가 되며, 기동 시간에 리플렉션 비용이 0 입니다. "DI 프레임워크가 없으면 못 산다"는 생각은 스프링에서 온 습관인 경우가 많습니다.

Koin — 그래프가 커지면

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

val appModule = module {
    single { initDatabase(get<AppConfig>().db) }
    single<LinkRepository> { ExposedLinkRepository() }
    single<CacheClient> { RedisCache(get<AppConfig>().redis) }
    single { LinkService(get(), get()) }              // 생성자 인자는 get() 으로
    factory { RequestScopedThing() }                  // 매번 새로 만들기
}

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

    routing {
        val service by inject<LinkService>()          // Application 스코프에서 꺼냅니다
        linkRoutes(service)
    }
}
Koin 은 런타임에 그래프를 풉니다 — 빈이 빠져 있으면 컴파일이 아니라 기동할 때 NoDefinitionFoundException 이 납니다. 그래서 checkModules() 테스트를 반드시 하나 두세요 — CI 에서 5밀리초 만에 그래프 오류를 잡아 줍니다.
@Test fun `DI 그래프가 완전한가`() = checkKoinModules(listOf(appModule))

testApplication — 서버를 안 띄우고 요청을 보냅니다 🟢 기초

// src/test/kotlin/LinkRoutesTest.kt
class LinkRoutesTest {

    @Test
    fun `링크를 만들면 201 과 slug 를 돌려준다`() = testApplication {
        // ① 어떤 모듈을 켤지 고릅니다 — DB 없이 라우트만 켤 수도 있습니다
        val repo = FakeLinkRepository()
        application {
            configureSerialization()
            configureStatusPages()
            routing { linkRoutes(LinkService(repo, NoopCache())) }
        }

        // ② 테스트 전용 클라이언트 (JSON 을 알아야 body() 가 됩니다)
        val client = createClient {
            install(ContentNegotiation) { json() }
        }

        // ③ 진짜 HTTP 처럼 요청합니다 — 다만 소켓은 열리지 않습니다
        val res = client.post("/links") {
            contentType(ContentType.Application.Json)
            setBody(CreateLinkRequest(url = "https://example.com"))
        }

        assertEquals(HttpStatusCode.Created, res.status)
        val body = res.body<LinkResponse>()
        assertEquals("https://example.com", body.url)
        assertTrue(body.slug.length in 4..16)
    }

    @Test
    fun `잘못된 url 은 422 와 필드 에러를 준다`() = testApplication {
        application { testModule() }
        val res = client.post("/links") {
            contentType(ContentType.Application.Json)
            setBody("""{"url":"not-a-url"}""")
        }
        assertEquals(HttpStatusCode.UnprocessableEntity, res.status)
        val err = Json.decodeFromString<ErrorResponse>(res.bodyAsText())
        assertEquals("VALIDATION_FAILED", err.code)
        assertTrue("url" in err.fields!!.keys)
    }

    @Test
    fun `토큰이 없으면 401`() = testApplication {
        application { testModule() }
        assertEquals(HttpStatusCode.Unauthorized, client.get("/me").status)
    }

    @Test
    fun `설정을 갈아 끼워서 테스트하기`() = testApplication {
        environment {
            config = MapApplicationConfig(
                "app.jwt.secret" to "test-secret-0123456789-0123456789",
                "app.rateLimit.perMinute" to "5",
            )
        }
        application { testModule() }
        repeat(5) { assertEquals(HttpStatusCode.OK, client.get("/ping").status) }
        assertEquals(HttpStatusCode.TooManyRequests, client.get("/ping").status)
    }
}

MockK — 코루틴을 그대로 스텁합니다

class LinkServiceTest {

    private val repo = mockk<LinkRepository>()
    private val cache = mockk<CacheClient>(relaxed = true)   // 안 쓴 호출은 무시
    private val service = LinkService(repo, cache)

    @Test
    fun `캐시에 있으면 DB 를 안 본다`() = runTest {         // ★ 가상 시간 — delay 가 즉시 끝납니다
        coEvery { cache.get("ab12") } returns "https://example.com"

        val url = service.resolve("ab12")

        assertEquals("https://example.com", url)
        coVerify(exactly = 0) { repo.findBySlug(any()) }     // DB 호출이 없었는지 검증
    }

    @Test
    fun `슬러그가 겹치면 다시 뽑는다`() = runTest {
        coEvery { repo.create(any(), any(), any()) } throws DuplicateKeyException() andThen
            Link(1, "xy34", "https://example.com", 0)

        val link = service.create(CreateLinkRequest("https://example.com"), ownerId = 1)

        assertEquals("xy34", link.slug)
        coVerify(exactly = 2) { repo.create(any(), any(), any()) }
    }
}
coEvery · coVerify 의 co 는 코루틴입니다. suspend 함수는 반드시 co 붙은 쪽을 써야 합니다 — 안 그러면 컴파일은 되는데 스텁이 안 걸려서 실제 구현이 호출됩니다. MockK 를 쓰다 겪는 "왜 안 걸리지" 사고의 대부분이 이것입니다.

testcontainers — 진짜 Postgres

// testImplementation("org.testcontainers:postgresql")
@Testcontainers
class LinkRepositoryIT {

    companion object {
        @Container
        @JvmStatic
        val pg = PostgreSQLContainer("postgres:17-alpine")
            .withReuse(true)              // ★ 컨테이너 재사용 — 로컬 반복 실행이 훨씬 빨라집니다

        private lateinit var ds: HikariDataSource

        @BeforeAll @JvmStatic fun setUp() {
            ds = initDatabase(DbConfig(pg.jdbcUrl, pg.username, pg.password, poolSize = 4))
            migrate(ds)                    // 운영과 같은 Flyway 스크립트로
        }

        @AfterAll @JvmStatic fun tearDown() = ds.close()
    }

    private val repo = ExposedLinkRepository()

    @Test
    fun `유니크 인덱스가 중복 슬러그를 막는다`() = runTest {
        repo.create("ab12", "https://a.com", ownerId = 1)
        assertFailsWith<ExposedSQLException> {
            repo.create("ab12", "https://b.com", ownerId = 1)
        }
    }

    @Test
    fun `클릭 증가가 동시에 들어와도 정확하다`() = runTest {
        repo.create("cd34", "https://a.com", ownerId = 1)
        coroutineScope { repeat(100) { launch { repo.incrementClicks("cd34") } } }
        assertEquals(100L, repo.findBySlug("cd34")!!.clicks)   // clicks 는 Long — 100(Int) 과는 같지 않다
    }
}
H2 로 대신하지 마세요. H2 는 Postgres 의 ON CONFLICT · 부분 인덱스 · jsonb · 격리 수준 동작이 다릅니다. "테스트는 통과하는데 운영에서 터지는" 전형적 원인입니다. testcontainers 로 운영과 같은 이미지 태그를 쓰세요.
테스트에서 시간을 다루는 법 — runTest { } 는 가상 시계를 씁니다. delay(10.minutes) 가 실제로는 즉시 끝나므로, 토큰 만료 · 재시도 백오프 · 캐시 TTL 같은 것을 기다리지 않고 검증할 수 있습니다. 다만 Thread.sleep 이나 System.currentTimeMillis() 는 가상 시계를 모릅니다 — 그래서 Clock 을 주입받는 설계가 여기서 값을 합니다.

테스트 도구 한 장 정리 🟢 기초

무엇을 언제 쓰나

도구쓰는 곳
kotlin-test · JUnit 5기본 러너 · 단언
testApplication라우트 · 플러그인 통합
runTestsuspend 함수 · 가상 시계
MockK협력 객체 스텁 · 검증
Testcontainers진짜 DB · Redis · Kafka
Kotest / assertk읽기 좋은 단언
TurbineFlow 배출 검증
MockWebServer외부 HTTP 흉내
Ktor Client 를 테스트할 때는 MockEngine 이 더 편합니다 — HttpClient(MockEngine { request -> respond("…") }) 로 네트워크 없이 클라이언트 코드의 재시도 · 타임아웃 · 에러 처리를 검증할 수 있습니다.

외부 HTTP 를 흉내 내기

@Test
fun `외부 API 가 500 이면 캐시된 값으로 떨어진다`() = runTest {
    val engine = MockEngine { request ->
        when (request.url.encodedPath) {
            "/meta" -> respondError(HttpStatusCode.InternalServerError)
            else    -> respond("{}", HttpStatusCode.OK,
                          headersOf(HttpHeaders.ContentType, "application/json"))
        }
    }
    val client = HttpClient(engine) { install(ContentNegotiation) { json() } }
    val svc = MetaService(client, cache = FakeCache("이전 제목"))

    assertEquals("이전 제목", svc.title("https://a.com"))
}
깨지기 쉬운 테스트를 피하는 규칙 하나 — 시간 · 난수 · 시계를 코드 안에서 직접 부르지 말고 주입하세요. Clock · Random 을 생성자 인자로 두면 "슬러그 충돌 시 재시도" 같은 확률적 경로도 결정적으로 테스트됩니다.
KTOR 11

관측 · 운영 — Actuator 가 없는 세상

스프링 부트는 actuator 의존성 한 줄이면 헬스체크 · 메트릭 · 환경 정보가 다 생깁니다. Ktor 에는 그런 것이 없습니다. 대신 무엇이 왜 필요한지 알고 직접 답니다 — 결과적으로 내 서비스에 필요한 것만 남습니다.

요청 ID 하나가 세 갈래로 — 가운데가 MDC, 바깥이 그 ID 가 찍히는 곳 요청 도착 X-Request-Id 없으면 UUID CallId callId MDC 에 저장 로그 줄마다 logback %X{callId} 메트릭 · 트레이스 Micrometer 태그 다운스트림 헤더 Ktor Client 가 전달 로그 검색창에 callId=7f3a… 하나만 — 이 요청이 지나간 모든 줄 스프링의 Sleuth · Micrometer Tracing 이 하던 일을 플러그인 둘(CallId · CallLogging)로 관측 항목 중 가장 값싸고 효과가 큰 하나 — 서비스 첫날에 넣으세요
분산 추적까지 안 가더라도 요청 ID 하나만 있으면 장애 대응 시간이 절반이 됩니다.
// plugins/Monitoring.kt
fun Application.configureMonitoring(registry: PrometheusMeterRegistry) {

    install(CallId) {
        header(HttpHeaders.XRequestId)                  // 앞단이 준 값이 있으면 그것을 씁니다
        generate { UUID.randomUUID().toString() }       // 없으면 만듭니다
        verify { it.isNotEmpty() && it.length <= 64 }   // ★ 남이 준 값을 그대로 믿지 않습니다
        replyToHeader(HttpHeaders.XRequestId)           // 응답에도 실어 보냅니다
    }

    install(CallLogging) {
        level = Level.INFO
        callIdMdc("callId")                             // ★ MDC 에 넣으면 모든 로그 줄에 붙습니다
        mdc("userId") { it.principal<UserPrincipal>()?.userId?.toString() }
        filter { !it.request.path().startsWith("/healthz") }   // 헬스체크 소음 제거
        format { call ->
            "${call.response.status()?.value} ${call.request.httpMethod.value} " +
            "${call.request.path()} ${call.processingTimeMillis()}ms"
        }
    }

    install(MicrometerMetrics) {
        this.registry = registry
        meterBinders = listOf(
            JvmMemoryMetrics(), JvmGcMetrics(), JvmThreadMetrics(),
            ProcessorMetrics(), FileDescriptorMetrics(),
        )
        distributionStatisticConfig = DistributionStatisticConfig.Builder()
            .percentiles(0.5, 0.95, 0.99)               // p99 를 보려면 이걸 켜야 합니다
            .build()
    }

    routing {
        get("/metrics") { call.respond(registry.scrape()) }   // Prometheus 가 긁어 갑니다

        get("/healthz") { call.respondText("OK") }             // liveness — 절대 DB 를 보지 마세요
        get("/readyz") {                                        // readiness — 여기서 의존성을 봅니다
            val checks = mapOf(
                "db" to runCatching { withTimeout(1.seconds) { deps.ping() } }.isSuccess,
                "redis" to runCatching { withTimeout(500.milliseconds) { deps.cache.ping() } }.isSuccess,
            )
            if (checks.values.all { it }) call.respond(checks)
            else call.respond(HttpStatusCode.ServiceUnavailable, checks)
        }
    }
}
/healthz 에서 DB 를 검사하면 장애가 커집니다. DB 가 잠깐 흔들리면 쿠버네티스가 모든 파드를 재시작해 버려서, DB 가 회복돼도 앱이 전부 죽어 있는 상태가 됩니다. liveness 는 "프로세스가 살아 있나"만, readiness 만 의존성을 봅니다 — 이 구분 하나로 연쇄 장애를 막습니다.

JSON 로그 · 설정 분리

<!-- src/main/resources/logback.xml -->
<configuration>
  <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <includeMdcKeyName>callId</includeMdcKeyName>
      <includeMdcKeyName>userId</includeMdcKeyName>
    </encoder>
  </appender>

  <root level="INFO"><appender-ref ref="JSON"/></root>
  <logger name="io.ktor" level="INFO"/>
  <logger name="Exposed" level="WARN"/>
</configuration>
// config/AppConfig.kt — HOCON 을 data class 로 한 번에 읽습니다
data class AppConfig(
    val port: Int,
    val db: DbConfig,
    val jwt: JwtConfig,
    val allowedOrigins: List<String>,
    val isDev: Boolean,
) {
    companion object {
        fun from(config: ApplicationConfig) = AppConfig(
            port = config.property("ktor.deployment.port").getString().toInt(),
            db = DbConfig(
                url = config.property("app.db.url").getString(),
                user = config.property("app.db.user").getString(),
                password = config.property("app.db.password").getString(),
                poolSize = config.propertyOrNull("app.db.poolSize")?.getString()?.toInt() ?: 10,
            ),
            jwt = JwtConfig(config.property("app.jwt.secret").getString(), /* … */),
            allowedOrigins = config.property("app.cors.origins").getList(),
            isDev = config.propertyOrNull("ktor.development")?.getString() == "true",
        )
    }
}
설정을 한곳에서 통째로 읽어 두는 이유 — 잘못된 설정이면 기동 즉시 실패합니다. 값을 쓰는 시점에 하나씩 읽으면 새벽 3시에 /admin 에 처음 들어갔을 때 터집니다. 실패는 빠를수록 좋습니다.

우아한 종료 — 배포할 때마다 쓰입니다

fun main() {
    val cfg = AppConfig.load()
    val deps = buildDeps(cfg)

    val server = embeddedServer(Netty, configure = {
        shutdownGracePeriod = 3_000     // 새 요청을 안 받고 기다리는 시간
        shutdownTimeout = 15_000        // 그래도 안 끝나면 강제 종료
    }, port = cfg.port) { module(cfg, deps) }

    // 애플리케이션 이벤트를 구독합니다
    server.application.monitor.subscribe(ApplicationStarted) {
        it.log.info("떴습니다 — port={}", cfg.port)
    }
    server.application.monitor.subscribe(ApplicationStopPreparing) {
        // ★ 여기서 readiness 를 먼저 내립니다 → LB 가 트래픽을 뺍니다
        Health.ready.set(false)
    }
    server.application.monitor.subscribe(ApplicationStopped) {
        deps.close()                    // 풀 · 커넥션 정리
    }

    server.start(wait = true)
}
종료 순서왜
① readiness 를 false 로LB 가 신규 트래픽 차단
② 몇 초 기다림LB 갱신 주기(보통 5~15초)
③ 진행 중 요청 완료gracePeriod
④ 풀 · 소켓 닫기커넥션 누수 방지
⑤ 프로세스 종료—
②를 빠뜨리는 경우가 압도적으로 많습니다 — SIGTERM 을 받자마자 종료를 시작하면 LB 가 아직 이 파드로 트래픽을 보내는 중이라 배포할 때마다 502 가 몇 건씩 납니다. 쿠버네티스라면 preStop 훅에 sleep 10 을 넣는 것이 표준 처방입니다.

무엇을 재나 — 지표 목록 🟡 중급

이 여덟 개면 대부분의 장애가 보입니다

지표보는 이유
ktor.http.server.requests요청 수 · 상태 코드 분포
같은 지표의 p50 · p95 · p99꼬리 지연 — 평균은 거짓말합니다
hikaricp.connections.pending풀 대기 — 0 이 아니면 병목
hikaricp.connections.usage커넥션 점유 시간
jvm.memory.used힙 증가 추세(누수)
jvm.gc.pause멈춤 시간
jvm.threads.live스레드 폭증
커스텀 비즈니스 카운터주문 · 결제 · 가입 수
세 번째 줄이 Ktor 서비스의 단골 범인입니다 — connections.pending 이 0 보다 크면 코루틴이 커넥션을 기다리고 있다는 뜻이고, 그 순간 응답 시간의 대부분이 대기입니다(kt07 의 깔때기 그림).

커스텀 지표 · 경보 기준

// 비즈니스 지표는 도메인 서비스에서 직접 올립니다
class LinkService(
    private val repo: LinkRepository,
    private val cache: CacheClient,
    private val metrics: MeterRegistry,
) {
    private val created = metrics.counter("links.created")
    private val cacheHit = metrics.counter("links.resolve", "source", "cache")
    private val cacheMiss = metrics.counter("links.resolve", "source", "db")

    suspend fun resolve(slug: String): String? {
        cache.get("u:$slug")?.let { cacheHit.increment(); return it }
        cacheMiss.increment()
        return repo.findBySlug(slug)?.url?.also { cache.set("u:$slug", it, 1.hours) }
    }

    // 처리 시간을 재는 표준형
    suspend fun <T> timed(name: String, block: suspend () -> T): T {
        val sample = Timer.start(metrics)
        try { return block() } finally { sample.stop(metrics.timer(name)) }
    }
}
태그에 카디널리티가 높은 값을 넣지 마세요 — counter("x", "slug", slug) 처럼 사용자 id · URL · slug 를 태그로 넣으면 시계열이 수백만 개가 되어 프로메테우스가 먼저 죽습니다. 태그는 값의 종류가 수십 개 이하인 것만(상태 코드 · 메서드 · 결과 구분).
KTOR 12

배포 — fat jar · Docker · GraalVM

Ktor 를 고른 이유가 가볍고 빠른 기동이라면, 그 이점은 패키징에서 완성되거나 사라집니다. 같은 코드가 어떻게 포장되느냐에 따라 기동 시간이 2.4초에서 0.04초까지 벌어집니다.

같은 서비스를 다르게 포장했을 때의 기동 시간 — 실측 기준선 Spring Boot fat jar JVM · jar 48MB 2.4초 Ktor fat jar (Netty) JVM · jar 19MB 0.42초 Ktor + AppCDS 같은 jar + 클래스 아카이브 0.30초 Ktor native-image GraalVM · 78MB 바이너리 0.04초 0s 1s 2s 3s 기동(초) 같은 코드 · 같은 기능 — 달라진 것은 패키징뿐입니다 native-image 는 리플렉션 설정과 3~8분 빌드를 대가로 요구합니다 — 얻는 것과 잃는 것을 재세요
숫자는 JDK 21 · 4vCPU 컨테이너에서 라우트 20개짜리 서비스를 열 번 재서 중앙값을 쓴 것입니다.

① fat jar — 가장 무난한 기본

# Ktor Gradle 플러그인이 있으면 태스크가 이미 있습니다
./gradlew buildFatJar
#   → build/libs/app-all.jar  (한 파일에 전부)

java -jar build/libs/app-all.jar

# 플러그인 없이 Shadow 를 직접 쓸 때
# plugins { id("com.gradleup.shadow") version "8.3.6" }
./gradlew shadowJar
# ② installDist — jar 를 안 합치고 lib 폴더로 풉니다
./gradlew installDist
#   → build/install/app/bin/app  (실행 스크립트)
#   → build/install/app/lib/*.jar

# ★ 도커에서는 이쪽이 유리합니다 — 의존성 레이어를 캐시할 수 있어서
#    코드만 고쳤을 때 푸시되는 용량이 수십 MB → 수백 KB 가 됩니다
fat jar vs installDist — 로컬 실행과 서버리스 업로드는 fat jar 가 편하고, 도커 이미지를 자주 굽는다면 installDist 가 낫습니다. 레이어가 나뉘면 CI 빌드 시간과 레지스트리 비용이 같이 줄어듭니다.

③ AppCDS — 공짜로 30% 더 빠르게

# 클래스 로딩 결과를 미리 저장해 두고 다음 기동에 재사용합니다
# ① 아카이브 만들기 (빌드 단계에서 한 번)
java -XX:ArchiveClassesAtExit=/app/app.jsa \
     -Dio.ktor.server.engine.ShutdownHook=false \
     -jar /app/app.jar &
sleep 5 && curl -s localhost:8080/healthz && kill %1

# ② 운영에서 그 아카이브를 붙여 실행
java -XX:SharedArchiveFile=/app/app.jsa -jar /app/app.jar
코드를 한 줄도 안 고치고 얻는 이득입니다. 클래스 파싱과 검증을 건너뛰므로 기동이 25~35% 빨라집니다. JDK 19+ 의 -XX:+AutoCreateSharedArchive 를 쓰면 첫 실행이 알아서 아카이브를 만들고 다음부터 씁니다 — java -XX:+AutoCreateSharedArchive -XX:SharedArchiveFile=/tmp/app.jsa -jar app.jar 한 줄로 끝나는 셈입니다.
# JVM 플래그 — 컨테이너에서 이 두 개는 거의 항상 씁니다
#  · MaxRAMPercentage=75.0 — 컨테이너 메모리의 75%를 힙으로
#  · UseSerialGC — 코어 1~2개면 SerialGC 가 유리합니다
#  (줄 끝 \ 뒤에 주석을 달면 명령이 거기서 끊기므로 설명은 위에 둔다)
java -XX:MaxRAMPercentage=75.0 \
     -XX:+UseSerialGC \
     -jar app.jar
# Dockerfile — 멀티스테이지. 빌드 도구가 최종 이미지에 안 들어갑니다
# ---------- ① 빌드 단계 ----------
FROM gradle:8.14-jdk21 AS build
WORKDIR /src
# 의존성만 먼저 받아 레이어로 굳힙니다 (소스가 바뀌어도 이 레이어는 재사용)
COPY --chown=gradle:gradle settings.gradle.kts build.gradle.kts gradle.properties ./
COPY --chown=gradle:gradle gradle ./gradle
RUN gradle dependencies --no-daemon || true
COPY --chown=gradle:gradle . .
RUN gradle buildFatJar --no-daemon -x test

# ---------- ② 실행 단계 ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /src/build/libs/*-all.jar app.jar

# ★ 루트로 돌리지 않습니다
USER app
EXPOSE 8080

# 헬스체크 — 오케스트레이터가 없을 때 유용합니다
HEALTHCHECK --interval=10s --timeout=2s --start-period=5s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

ENTRYPOINT ["java","-XX:MaxRAMPercentage=75.0","-XX:+UseSerialGC","-jar","/app/app.jar"]

# 결과: 압축 약 95MB (JRE 알파인 약 75MB + jar 19MB)
# 스프링 부트 같은 구성이면 약 125MB — 큰 차이는 아니지만 기동 시간이 5배 차이 납니다

④ GraalVM native-image — 시도와 한계

// build.gradle.kts
plugins { id("org.graalvm.buildtools.native") version "0.11.0" }

graalvmNative {
    binaries {
        named("main") {
            mainClass.set("com.example.ApplicationKt")
            buildArgs.addAll(
                "--no-fallback",
                "-H:+ReportExceptionStackTraces",
                "--initialize-at-build-time=ch.qos.logback,org.slf4j",
            )
        }
    }
}
# ① 리플렉션·리소스 설정을 에이전트로 자동 수집합니다
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar build/libs/app-all.jar
#   → 이 상태로 앱의 주요 경로를 전부 한 번씩 호출합니다 (테스트를 돌리면 편합니다)

# ② 빌드 — 3~8분, 메모리 6GB 이상을 씁니다
./gradlew nativeCompile
./build/native/nativeCompile/app        # 40ms 만에 뜹니다
정직하게 — 아직 만만치 않습니다. Netty 대신 CIO 엔진을 써야 수월하고, 리플렉션을 쓰는 라이브러리(잭슨 · Exposed DAO · 일부 JDBC 드라이버)마다 설정이 필요합니다. 빌드가 5분씩 걸려서 CI 시간이 늘고, 피크 처리량은 JIT 가 데워진 JVM 이 오히려 더 높습니다. 서버리스 · 짧은 수명 컨테이너 · CLI 가 아니면 AppCDS 로 충분합니다.

쿠버네티스 프로브

spec:
  containers:
    - name: app
      image: registry.example.com/shortener:1.4.2
      ports: [{ containerPort: 8080 }]
      resources:
        requests: { cpu: "250m", memory: "192Mi" }
        limits:   { cpu: "1",    memory: "384Mi" }

      # 프로세스가 살아 있나 — DB 를 보면 안 됩니다
      livenessProbe:
        httpGet: { path: /healthz, port: 8080 }
        periodSeconds: 10
        failureThreshold: 3

      # 트래픽을 받을 준비가 됐나 — 여기서만 의존성을 봅니다
      readinessProbe:
        httpGet: { path: /readyz, port: 8080 }
        periodSeconds: 5
        failureThreshold: 2

      # 기동이 느릴 수 있는 초반을 따로 봐 줍니다
      startupProbe:
        httpGet: { path: /healthz, port: 8080 }
        periodSeconds: 2
        failureThreshold: 15        # Ktor 는 넉넉합니다 (스프링은 30~60 이 필요)

      lifecycle:
        preStop:
          exec: { command: ["sh","-c","sleep 10"] }   # ★ LB 가 빠질 시간

  terminationGracePeriodSeconds: 40
Ktor 가 실제로 아끼는 것 — 기동이 빠르면 startupProbe.failureThreshold 를 줄일 수 있고, 롤링 배포가 파드당 30초에서 5초로 줄어듭니다. 파드가 50개면 배포 시간이 25분에서 4분이 됩니다. 메모리도 파드당 200MB 를 아끼니 같은 노드에 두 배를 태울 수 있습니다.
이미지를 굽는 것도 Gradle 에서 됩니다 — Ktor 플러그인의 ./gradlew buildImage 는 Dockerfile 없이 JIB 방식으로 레이어를 나눈 이미지를 만들고, publishImage 는 레지스트리에 바로 올립니다. 도커 데몬 없이도 됩니다. 다만 베이스 이미지 · 사용자 · 헬스체크를 세밀하게 잡아야 하는 운영 환경이라면 Dockerfile 을 직접 쓰는 편이 통제가 낫습니다.

배포 형태 한 장 정리 🟡 중급

네 가지 중 무엇을 고르나

형태기동빌드고를 때
fat jar0.42초수십 초기본값
installDist0.42초수십 초도커 레이어 캐시
+ AppCDS0.30초+ 10초공짜 이득 · 권장
native-image0.04초3~8분서버리스 · CLI
대부분의 팀에게 정답은 두 번째와 세 번째의 조합입니다 — installDist 로 레이어를 나눠 도커 빌드를 빠르게 하고, AppCDS 를 얹어 기동을 30% 더 줄이는 것. 코드 변경이 필요 없고 되돌리기도 쉽습니다.

배포 전 최종 점검

항목확인
컨테이너 사용자root 가 아닌지
힙 상한MaxRAMPercentage 설정
비밀이미지에 안 구워졌는지
로그 형식JSON · stdout
프로브 세 종liveness · readiness · startup
preStopLB 가 빠질 시간
gracePeriod가장 긴 요청보다 김
이미지 태그latest 가 아닌지
가장 자주 빠뜨리는 것은 두 번째 줄입니다 — 컨테이너 메모리 제한이 384MB 인데 JVM 이 그것을 모르면 힙을 크게 잡았다가 OOMKilled 로 죽습니다. -XX:MaxRAMPercentage=75.0 한 줄이면 끝나고, Ktor 처럼 힙이 작은 앱일수록 이 실수가 늦게 발견됩니다.
KTOR 13

실전 — 짧은 URL + 통계 서비스 끝까지

지금까지의 열두 조각을 하나로 붙입니다. URL 을 줄여 주고, 리다이렉트하고, 클릭을 세고, 관리자 대시보드에 실시간으로 보여 주는 서비스를 처음부터 끝까지 만듭니다. 마지막에 같은 것을 스프링 부트로 만들었을 때와 숫자를 비교합니다.

짧은 URL 서비스 — 전체 구성 브라우저 · 앱 GET /{slug} · REST API 관리 대시보드 WebSocket 실시간 카운터 서버 간 호출 Bearer 서비스 토큰 Ktor 애플리케이션 (Netty · 포트 8080) plugins CORS · StatusPages routes link · auth · admin domain LinkService infra repo · cache PostgreSQL links · users · clicks Redis slug 캐시 · 클릭 카운터 Prometheus · 로그 /metrics · JSON 로그 docker compose up — 앱 · Postgres · Redis 가 한 번에 뜹니다 테스트는 testApplication + testcontainers, 배포는 멀티스테이지 Dockerfile 한 장
여기까지 오면 Ktor 로 서비스 하나를 혼자 세울 수 있습니다 — 나머지는 도메인의 문제입니다.

요구사항과 폴더

기능구현
URL 줄이기POST /api/links (JWT)
리다이렉트GET /{slug} → 302
클릭 집계Redis INCR + 배치 반영
통계 조회GET /api/links/{slug}/stats
실시간 카운터WS /ws/admin
인증JWT access + refresh
src/main/kotlin/com/example/
├─ Application.kt              main() · module()
├─ config/AppConfig.kt
├─ plugins/
│   ├─ Defaults.kt             Negotiation · CORS · Compression
│   ├─ Monitoring.kt           CallId · CallLogging · Micrometer
│   ├─ Security.kt             Authentication(jwt, bearer)
│   └─ StatusPagesConfig.kt
├─ routes/
│   ├─ RedirectRoutes.kt       GET /{slug}
│   ├─ LinkRoutes.kt           /api/links
│   ├─ AuthRoutes.kt           /auth/*
│   └─ AdminSocketRoutes.kt    /ws/admin
├─ domain/
│   ├─ Link.kt · LinkService.kt
│   ├─ LinkRepository.kt       (인터페이스)
│   └─ DomainException.kt
└─ infra/
    ├─ Tables.kt · ExposedLinkRepository.kt
    ├─ RedisCache.kt
    └─ ClickFlusher.kt         30초마다 Redis → DB

도메인 — Ktor 를 전혀 모릅니다

// domain/LinkService.kt
class LinkService(
    private val repo: LinkRepository,
    private val cache: CacheClient,
    private val clock: Clock = Clock.System,     // ★ 테스트에서 시간을 고정하려고
) {
    private val alphabet = "abcdefghijkmnpqrstuvwxyz23456789"   // 헷갈리는 글자 제외

    suspend fun create(req: CreateLinkRequest, ownerId: Long): Link {
        req.validated(validateCreateLink)
        // 사용자가 원하는 슬러그가 있으면 그것으로, 없으면 뽑아서 충돌 시 재시도
        repeat(5) {
            val slug = req.slug ?: randomSlug()
            runCatching { return repo.create(slug, req.url, ownerId) }
                .onFailure { if (req.slug != null) throw DomainException.Conflict("슬러그 $slug") }
        }
        error("슬러그를 5번 시도해도 못 만들었습니다")
    }

    // ★ 리다이렉트는 서비스에서 가장 뜨거운 경로입니다 — 캐시 우선
    suspend fun resolve(slug: String): String? {
        cache.get("u:$slug")?.let { return it }
        val link = repo.findBySlug(slug) ?: return null
        cache.set("u:$slug", link.url, ttl = 1.hours)
        return link.url
    }

    // 클릭은 Redis 에서만 셉니다 (DB 쓰기를 요청 경로에서 뺍니다)
    suspend fun recordClick(slug: String) { cache.incr("c:$slug") }

    suspend fun stats(slug: String): LinkStats {
        val link = repo.findBySlug(slug) ?: throw DomainException.NotFound("링크 $slug")
        val pending = cache.getLong("c:$slug") ?: 0
        return LinkStats(slug, link.url, link.clicks + pending, link.createdAt)
    }

    private fun randomSlug(len: Int = 7) =
        (1..len).map { alphabet.random() }.joinToString("")
}

라우트 · 캐시 · 실시간 카운터 🟡 중급

// routes/RedirectRoutes.kt — 초당 수천 건이 오는 경로. 가장 짧게 유지합니다
fun Route.redirectRoutes(service: LinkService, counters: CounterBus) {
    get("/{slug}") {
        val slug = call.parameters["slug"]!!
        val url = service.resolve(slug)
            ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("NOT_FOUND", "없는 링크입니다"))

        // ★ 응답을 먼저 보내고, 집계는 응답 경로에서 뺍니다
        call.respondRedirect(url, permanent = false)      // 302 (301 은 브라우저가 캐시해 통계가 끊깁니다)

        application.launch {                               // 요청 코루틴과 분리
            runCatching {
                service.recordClick(slug)
                counters.publish(slug)                     // 대시보드로 흘려보냅니다
            }.onFailure { application.log.warn("클릭 집계 실패 slug={}", slug, it) }
        }
    }
}

// routes/AdminSocketRoutes.kt — 실시간 카운터
fun Route.adminSocketRoutes(counters: CounterBus) {
    authenticate("auth-jwt") {
        webSocket("/ws/admin") {
            val principal = call.principal<UserPrincipal>()!!
            if ("ADMIN" !in principal.roles) {
                close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "권한 없음")); return@webSocket
            }

            val job = launch {
                counters.stream()                          // Flow<ClickEvent>
                    .sample(500.milliseconds)              // ★ 초당 수천 건을 초당 2회로 줄입니다
                    .collect { sendSerialized(it) }
            }
            try {
                for (frame in incoming) { /* 클라이언트는 보낼 게 없습니다 */ }
            } finally {
                job.cancel()                               // ★ 반드시 정리
            }
        }
    }
}

// infra/ClickFlusher.kt — Redis 의 카운터를 주기적으로 DB 에 반영합니다
class ClickFlusher(
    private val cache: CacheClient,
    private val repo: LinkRepository,
    scope: CoroutineScope,
) {
    private val job = scope.launch {
        while (isActive) {
            delay(30.seconds)
            runCatching { flushOnce() }
                .onFailure { logger.error("클릭 반영 실패", it) }   // ★ 실패해도 루프는 계속
        }
    }

    private suspend fun flushOnce() {
        val pending = cache.scanAndReset("c:*")            // { slug -> 증가량 }
        if (pending.isEmpty()) return
        repo.bulkIncrement(pending)                        // 한 트랜잭션에 몰아서
        logger.info("클릭 {}건 반영", pending.values.sum())
    }

    fun stop() = job.cancel()
}

Redis 캐시 — Lettuce 코루틴 API

// infra/RedisCache.kt
class RedisCache(cfg: RedisConfig) : CacheClient {
    private val client = RedisClient.create(cfg.url)
    private val conn = client.connect()
    private val cmd = conn.coroutines()          // ★ suspend 로 쓰는 명령 인터페이스

    override suspend fun get(key: String): String? = cmd.get(key)

    override suspend fun set(key: String, value: String, ttl: Duration) {
        cmd.set(key, value, SetArgs.Builder.ex(ttl.inWholeSeconds))
    }

    override suspend fun incr(key: String): Long = cmd.incr(key) ?: 0

    override suspend fun getLong(key: String): Long? = cmd.get(key)?.toLongOrNull()

    override fun close() { conn.close(); client.shutdown() }
}
캐시가 죽어도 서비스는 살아야 합니다 — resolve() 에서 Redis 호출을 runCatching 으로 감싸 실패하면 그냥 DB 를 보게 하세요. 캐시를 필수 의존성으로 두면 Redis 재시작 = 전체 장애가 됩니다. lettuce 의 코루틴 API 는 아직 실험적 표시가 붙어 있으니 인터페이스(CacheClient)로 감싸 두는 것이 안전합니다.

조립 — Application.kt

fun main(args: Array<String>) = EngineMain.main(args)

fun Application.module() {
    val cfg = AppConfig.from(environment.config)
    val registry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)

    val ds = initDatabase(cfg.db).also { migrate(it) }
    val cache = RedisCache(cfg.redis)
    val repo = ExposedLinkRepository()
    val service = LinkService(repo, cache)
    val counters = CounterBus()
    val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    val flusher = ClickFlusher(cache, repo, appScope)

    configureDefaults(cfg)
    configureMonitoring(registry)
    configureSecurity(cfg.jwt, UserService(ExposedUserRepository()))
    configureStatusPages()
    configureSockets()

    routing {
        healthRoutes(ds)
        get("/metrics") { call.respond(registry.scrape()) }
        authRoutes(cfg.jwt, userService, tokenStore)
        route("/api") {
            authenticate("auth-jwt") { linkRoutes(service) }
        }
        adminSocketRoutes(counters)
        redirectRoutes(service, counters)          // ★ 와일드카드니 맨 마지막
    }

    monitor.subscribe(ApplicationStopPreparing) { flusher.stop() }
    monitor.subscribe(ApplicationStopped) {
        appScope.cancel(); cache.close(); ds.close()
    }
}

돌려 보기 · 테스트 🟢 기초

docker compose 한 줄로 전부

# compose.yaml
services:
  app:
    build: .
    ports: ["8080:8080"]
    environment:
      DB_URL: jdbc:postgresql://db:5432/app
      DB_USER: app
      DB_PASSWORD: app
      REDIS_URL: redis://cache:6379
      JWT_SECRET: local-dev-secret-0123456789-0123456789
    depends_on:
      db: { condition: service_healthy }
      cache: { condition: service_started }

  db:
    image: postgres:17-alpine
    environment: { POSTGRES_DB: app, POSTGRES_USER: app, POSTGRES_PASSWORD: app }
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 3s
      retries: 10

  cache:
    image: redis:7-alpine
    command: ["redis-server","--save","","--appendonly","no"]
docker compose up --build

TOKEN=$(curl -s -XPOST localhost:8080/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"a@b.c","password":"pw"}' | jq -r .accessToken)

curl -s -XPOST localhost:8080/api/links \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"url":"https://kotlinlang.org"}'
# {"slug":"k7m2xqa","url":"https://kotlinlang.org","clicks":0,...}

curl -i localhost:8080/k7m2xqa       # 302 Location: https://kotlinlang.org
curl -s localhost:8080/api/links/k7m2xqa/stats -H "Authorization: Bearer $TOKEN"

가장 값어치 있는 테스트 세 개

@Test
fun `리다이렉트는 302 와 Location 을 준다`() = testApplication {
    val repo = FakeLinkRepository(Link(1, "ab12", "https://a.com", 0))
    application { testModule(repo) }

    val res = client.config { followRedirects = false }.get("/ab12")

    assertEquals(HttpStatusCode.Found, res.status)
    assertEquals("https://a.com", res.headers[HttpHeaders.Location])
}

@Test
fun `없는 slug 는 표준 에러 모양으로 404`() = testApplication {
    application { testModule(FakeLinkRepository()) }
    val res = client.get("/nope")
    assertEquals(HttpStatusCode.NotFound, res.status)
    // 기본 client 에는 ContentNegotiation 이 없어 body<T>() 가 안 된다 — 문자열로 받아 직접 파싱
    assertEquals("NOT_FOUND", Json.decodeFromString<ErrorResponse>(res.bodyAsText()).code)
}

@Test
fun `클릭 집계가 응답을 늦추지 않는다`() = testApplication {
    val repo = FakeLinkRepository(Link(1, "ab12", "https://a.com", 0))
    val slowCache = SlowCache(incrDelay = 300.milliseconds)   // 클릭 집계(incr)만 느린 캐시
    application { testModule(repo, cache = slowCache) }

    val elapsed = measureTime {
        client.config { followRedirects = false }.get("/ab12")
    }
    assertTrue(elapsed < 100.milliseconds)      // ★ 집계는 비동기여야 합니다
}
세 번째 테스트가 특히 중요합니다 — "응답 후 처리"를 나중에 누군가 launch 밖으로 옮기면 이 테스트가 즉시 빨간불이 됩니다. 성능 특성을 주석이 아니라 테스트로 못박아 두는 방법입니다.

같은 서비스를 스프링 부트로 만들면 🔴 고급

숫자 비교 (같은 기능 · 같은 하드웨어)

Spring Boot 4Ktor 3
기동 시간2.4초0.42초
기동 직후 RSS310MB135MB
부하 중 RSS420MB240MB
이미지 크기125MB95MB
리다이렉트 p501.9ms1.6ms
리다이렉트 p9934ms11ms
처리량(4vCPU)18k rps24k rps
코드 줄 수약 1,900약 1,700
테스트 전체 시간96초11초
가장 크게 벌어진 둘은 p99 와 테스트 시간입니다. p99 차이는 스레드 풀 대기에서 옵니다 — 리다이렉트처럼 짧은 요청이 많이 몰릴 때 스레드 모델이 먼저 한계를 만납니다. 테스트 시간은 컨텍스트 기동이 없다는 것 하나 때문입니다. 처리량 차이(33%)는 생각보다 크지 않습니다 — 대부분의 서비스에서 병목은 프레임워크가 아니라 DB 와 외부 호출입니다.

그래서 무엇을 배웠나 — 체크리스트

  • 도메인에 io.ktor.* 를 들이지 않기 — 이 한 줄이 테스트 속도와 이식성을 만듭니다.
  • 트랜잭션은 짧게, 외부 호출은 밖으로 — 커넥션 점유가 처리량의 상한입니다.
  • 뜨거운 경로는 캐시 우선 · 쓰기는 비동기 — 리다이렉트에 DB 쓰기를 넣지 않습니다.
  • 에러 모양을 처음에 한 번 정하기 — StatusPages + sealed 예외.
  • WebSocket 은 finally 부터 — 정리 누락이 유일하고도 확실한 사고 원인입니다.
  • liveness 와 readiness 를 구분 — 연쇄 장애를 막는 가장 싼 방법.
  • CallId 를 첫날에 — 나중에 넣으면 이미 늦습니다.
마지막으로 정직하게 — 이 표의 숫자만 보고 사내 표준을 갈아엎지 마세요. Ktor 의 진짜 비용은 벤치마크에 안 나옵니다 — 배치 · 스케줄러 · 사내 스타터 · 신입 온보딩 · 검색 가능한 답변의 양. 새로 만드는 작은 서비스 하나부터 시작해서 팀이 코루틴에 익숙해진 뒤에 넓히는 것이 실패하지 않는 순서입니다.
여기서 더 갈 곳 — ① OpenAPI: ktor-server-openapi 로 문서를 붙이거나, 스키마를 먼저 쓰고 코드를 생성하는 방식. ② Ktor Client: 이 탭에서 배운 플러그인 개념이 클라이언트에도 그대로 있습니다(재시도 · 타임아웃 · 인증 갱신). ③ KMP: @Serializable DTO 와 @Resource 경로를 commonMain 으로 올리면 iOS · 안드로이드 · 웹이 서버와 같은 타입을 씁니다 — Ktor 를 고른 값을 가장 크게 회수하는 지점입니다.
JUnit 5 · Kotest · MockK · runTest · Testcontainers · kotlin-logging · MDC · detekt · JMH — Kotlin 2.2 · Spring Boot 3.5 기준

돌아가는 코드와
운영할 수 있는 코드는
다릅니다

코틀린 코드는 컴파일만 되면 꽤 안전해 보입니다. 그런데 실제 장애는 컴파일러가 못 보는 곳에서 납니다 — 취소를 삼키는 runCatching, 다른 스레드로 넘어가며 사라지는 traceId, 자바가 넘겨준 null, 필드 하나 추가했더니 깨지는 옛 클라이언트. 이 탭은 테스트 → 로깅 → 장애 패턴 → 호환성 → 빌드 자동화 → 리뷰 순서로 코틀린 서비스(Spring Boot · Ktor · Android)를 실무에서 바로 굴리는 데 필요한 것을 한곳에 모읍니다. 이 탭을 마치면 "테스트가 있고, 로그로 추적되고, 배포 전에 걸러지는" 코틀린 서비스를 만들 수 있습니다.

13SECTIONS
2.2Kotlin 기준
3.5Spring Boot 기준
30+실제로 밟는 함정
QA 01

테스트 피라미드와 도구 선택 — 무엇을 어느 층에서 확인하나

테스트를 "많이" 쓰는 것보다 어느 층에 몇 개를 두느냐가 빌드 속도와 신뢰도를 정합니다. 코틀린에는 JUnit 5 와 Kotest 라는 두 갈래가 있고, 둘 다 같은 JUnit Platform 위에서 돌기 때문에 섞어 쓸 수도 있습니다. 먼저 층을 정하고, 그다음 도구를 고릅니다.

아래가 넓어야 합니다 — 뒤집히면 빌드는 느려지고 자주 깨집니다 E2E 약 5% 배포된 서버 · 실제 HTTP · 앱 화면 수 분 · 깨지면 원인 찾기 어려움 통합 테스트 약 20% @SpringBootTest · Testcontainers 30~90초 · 설정 · SQL · JSON 확인 단위 테스트 약 75% JUnit5 · Kotest · MockK · runTest 수 초 · 어디가 틀렸는지 즉시 바닥 — 컴파일러 · detekt · ktlint (저장할 때마다) 느림 빠름 위층은 "정말 되나"를, 아래층은 "왜 안 되나"를 답합니다 코틀린은 바닥(타입 · null 검사)이 두꺼워서 단위 테스트를 더 적게 써도 됩니다
테스트 피라미드 — 넓이가 곧 권장 비율입니다. 통합 테스트가 단위보다 많다면 그 자체가 신호입니다.

JUnit 5 스타일 — 자바 팀이 바로 읽는다

class PriceCalculatorTest {

    private val calc = PriceCalculator(taxRate = 0.1)

    @Test
    fun `세금 10%가 붙는다`() {               // 백틱 이름 = 리포트에 그대로 찍힘
        assertEquals(1_100, calc.total(1_000))
    }

    @Nested
    @DisplayName("쿠폰이 있으면")
    inner class WithCoupon {                  // 상황별로 묶기
        @Test
        fun `할인 후에 세금이 붙는다`() {
            assertEquals(990, calc.total(1_000, coupon = 100))
        }
    }

    @ParameterizedTest
    @CsvSource("0, 0", "500, 550", "1000, 1100")
    fun `금액별 합계`(price: Long, expected: Long) {
        assertEquals(expected, calc.total(price))
    }
}
kotlin.test 를 쓰면 — assertEquals·assertFailsWith<T> 가 JUnit 5 위에서 그대로 돌고, 같은 코드를 KMP 공통 테스트로도 옮길 수 있습니다. assertFailsWith<OutOfStock> { ... } 는 예외를 반환하므로 메시지까지 확인할 수 있습니다.

Kotest 스타일 — 같은 테스트를 DSL 로

class PriceCalculatorSpec : FunSpec({

    val calc = PriceCalculator(taxRate = 0.1)

    test("세금 10%가 붙는다") {
        calc.total(1_000) shouldBe 1_100
    }

    context("쿠폰이 있으면") {
        test("할인 후에 세금이 붙는다") {
            calc.total(1_000, coupon = 100) shouldBe 990
        }
    }

    test("음수 금액은 거부한다") {
        val e = shouldThrow<IllegalArgumentException> {
            calc.total(-1)
        }
        e.message shouldContain "음수"
    }
})

// 단언(matcher)만 JUnit 테스트에서 빌려 쓰는 조합도 흔합니다
//   testImplementation("io.kotest:kotest-assertions-core:5.9.1")
//   list shouldHaveSize 3 · str shouldStartWith "ORD-"
Kotest 를 고르면 IDE 플러그인이 필요합니다 — IntelliJ 의 Kotest 플러그인이 없으면 개별 테스트 옆 실행 버튼이 안 뜹니다. 또 Kotest 는 스펙 안에서 상태를 공유하므로 (기본 격리 모드 SingleInstance) 가변 픽스처를 쓰려면 isolationMode 를 알아야 합니다.

무엇을 어느 층에서 — 대상별 배치표 🟢 기초

확인할 것층도구이유
가격 계산 · 상태 전이 · 검증 규칙단위JUnit5/Kotest 만순수 함수 — 목도 필요 없습니다. 가장 많이 써야 할 곳
서비스가 저장소 · 외부 API 를 부르는 순서단위MockK (02)"결제 실패면 재고를 되돌리는가" 같은 분기
suspend · Flow · 재시도 · 타임아웃단위runTest · Turbine (03)가상 시간 — 10분 대기도 1ms
컨트롤러 매핑 · 검증 · 에러 응답 모양슬라이스@WebMvcTest · MockMvc DSL웹 계층만 띄움 — 2초 안팎
JPA 쿼리 · 마이그레이션 · 인덱스통합@DataJpaTest + TestcontainersH2 는 PostgreSQL 이 아닙니다 — 문법 · 잠금이 다름
Kafka 발행 · 소비 · 직렬화 호환통합Testcontainers Kafka설정 실수(직렬화기 · 그룹 id)는 목으로 안 잡힘
로그인 → 주문 → 결제 전체E2E배포 환경 + HTTP 클라이언트소수만 — 배포 후 스모크 용도

이렇게 묶는다 — Gradle 로 빠른 것과 느린 것 나누기 🟡 중급

build.gradle.kts — 테스트 의존성과 두 개의 태스크

dependencies {
    testImplementation(kotlin("test"))                        // kotlin.test → JUnit 5 연결
    testImplementation("io.mockk:mockk:1.13.13")
    testImplementation("io.kotest:kotest-assertions-core:5.9.1")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
    testImplementation("app.cash.turbine:turbine:1.2.0")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher") // Gradle 9 에서 필수
}

tasks.test {
    useJUnitPlatform { excludeTags("integration") }   // 매 저장 · 매 PR 마다 도는 빠른 테스트
}

val integrationTest by tasks.registering(Test::class) {
    description = "Testcontainers 를 쓰는 느린 테스트"
    group = "verification"
    testClassesDirs = sourceSets.test.get().output.classesDirs
    classpath = sourceSets.test.get().runtimeClasspath
    useJUnitPlatform { includeTags("integration") }
    shouldRunAfter(tasks.test)
}

tasks.check { dependsOn(integrationTest) }   // ./gradlew check 는 둘 다

태그 붙이기 — 메타 어노테이션으로 한 번에

// 통합 테스트용 메타 어노테이션 — 붙이기만 하면 태그 + 스프링 설정까지
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag("integration")
@SpringBootTest
@Import(TestcontainersConfiguration::class)
annotation class IntegrationTest

@IntegrationTest
class OrderFlowIT(
    @Autowired private val service: OrderService,   // 생성자 주입도 된다
) {
    @Test
    fun `주문하면 재고가 줄어든다`() { /* ... */ }
}
./gradlew test                 # 단위만 — 몇 초
./gradlew integrationTest      # 느린 것만
./gradlew test --tests '*Price*'   # 이름으로 골라 돌리기
Kotest 스펙에는 JUnit 태그가 안 먹습니다 — Kotest 는 자체 태그(tags(Integration))와 -Dkotest.tags=!Integration 시스템 프로퍼티를 씁니다. 한 프로젝트에서 섞을 때 가장 많이 헷갈리는 지점입니다.

실무에선 이렇게 — 목보다 가짜(fake) 구현이 나을 때 🔴 실무

인메모리 가짜 저장소

interface OrderRepository {
    suspend fun save(order: Order): Order
    suspend fun findById(id: Long): Order?
}

// 테스트 소스셋에 둔다 — 실제처럼 '동작'하는 대역
class FakeOrderRepository : OrderRepository {
    private val store = mutableMapOf<Long, Order>()
    private var seq = 0L

    override suspend fun save(order: Order): Order {
        val saved = if (order.id == null) order.copy(id = ++seq) else order
        store[saved.id!!] = saved          // 테스트 코드라 !! 허용 (qa09 정책)
        return saved
    }
    override suspend fun findById(id: Long) = store[id]

    fun all(): List<Order> = store.values.toList()   // 검증용 엿보기
}

언제 무엇을 — 대역 네 가지

대역쓰는 때
Fake저장 · 조회처럼 상태가 결과를 정할 때. 테스트가 "어떻게 불렀나"가 아니라 "결과가 뭔가"를 봐서 리팩터링에 강합니다
Mock호출 자체가 결과일 때 — 메일 발송 · 결제 요청 · 이벤트 발행
Stub정해진 값만 돌려주면 될 때 — MockK 의 every { } returns
Spy실제 객체 일부만 바꿀 때 — 거의 항상 설계 냄새 (02)
목 테스트가 리팩터링을 막는 순간 — 내부 호출 순서를 verifySequence 로 전부 고정하면 동작은 그대로인데 구현만 바꿔도 테스트가 깨집니다. 바깥으로 나가는 부수 효과만 검증하고 나머지는 결과값으로 확인하세요.
정리 — 단위 75 · 통합 20 · E2E 5 를 출발점으로 잡고, 도구는 JUnit 5 + kotlin.test + MockK 로 시작해 Kotest 단언을 얹는 것이 가장 무난합니다. Gradle 에서 태그로 빠른 테스트와 느린 테스트를 나누면 매 저장마다 도는 테스트가 몇 초로 유지됩니다. 기본 도구 목록은 🧰 실전 도구 탭(TOOL 04)에 있습니다.
QA 02

MockK 실전 — 녹화하고, 돌려주고, 확인한다

코틀린 클래스는 기본이 final 이라 Mockito 로는 목을 만들기 번거롭습니다. MockK 는 final · suspend · object · 확장 함수 · 생성자까지 목으로 바꿀 수 있는 코틀린 전용 도구입니다. 대신 힘이 센 만큼 잘못 쓰면 테스트가 구현에 묶여 리팩터링을 막습니다.

MockK 세 줄 해부 — 블록 안의 호출은 "실행"이 아니라 "녹화"입니다 suspend 면 coEvery 녹화만 한다 실제 호출 X 인자 매처 any · eq 돌려줄 값 throws 도 가능 coEvery { repo.findById(any()) } returns order coVerify(exactly = 1) { pay.charge(capture(slot)) } confirmVerified(pay) 몇 번 불렸나 남은 호출 있으면 실패 넘어간 인자를 slot 에 담아 나중에 꺼내 검사 every 블록 = 규칙 등록 · verify 블록 = 기록 대조 — 둘 다 실제 코드를 부르지 않습니다
MockK 해부도 — co 접두사 · 매처 · 응답 · 검증 네 조각만 알면 문법의 90%입니다.

스텁 — 무엇을 돌려줄지 정하기

val repo = mockk<OrderRepository>()
val clock = mockk<Clock>()

every { clock.instant() } returns Instant.parse("2026-10-01T00:00:00Z")

coEvery { repo.findById(any()) } returns null             // 그 밖 전부 — 넓은 규칙을 먼저
coEvery { repo.findById(1L) } returns order(id = 1)       // 값이 정확히 1L 일 때 (나중 등록이 우선이라 이 순서)
coEvery { repo.findById(match { it < 0 }) } throws IllegalArgumentException("음수 id")

// 부를 때마다 다른 값 — 재시도 테스트에 필수
coEvery { client.fetch() } returnsMany listOf(fail(), fail(), ok())
coEvery { client.fetch() } throws IOException() andThen ok()

// 인자를 보고 계산해서 돌려주기
coEvery { repo.save(any()) } answers { firstArg<Order>().copy(id = 99) }

// Unit 반환 함수 — returns Unit 대신
justRun { audit.record(any()) }
coJustRun { mailer.send(any(), any()) }
스텁하지 않은 함수를 부르면 MockKException: no answer found — MockK 는 기본이 엄격합니다. Mockito 처럼 null·0 을 슬쩍 돌려주지 않아서 처음엔 귀찮지만, "부를 줄 몰랐던 호출"을 바로 드러내 줍니다.

relaxed — 엄격함을 언제 풀까

// ① 전부 느슨하게 — 스텁 안 한 함수는 0 · "" · 빈 목록 · 또 다른 목을 돌려줌
val logger = mockk<AuditLogger>(relaxed = true)

// ② Unit 함수만 느슨하게 — 값을 돌려주는 함수는 여전히 엄격 ★ 권장
val mailer = mockk<Mailer>(relaxUnitFun = true)

// relaxed 의 함정 — 반환값이 '조용히' 0 이 된다
val rate = mockk<RateProvider>(relaxed = true)
service.convert(1000, rate)    // rate.get() 이 0.0 → 결과 0원. 테스트는 통과할 수도 있다

// 어노테이션 방식 — JUnit 5 확장
@ExtendWith(MockKExtension::class)
@MockKExtension.ConfirmVerification        // 테스트마다 confirmVerified 자동
class OrderServiceTest {
    @MockK lateinit var repo: OrderRepository
    @RelaxedMockK lateinit var audit: AuditLogger
    @InjectMockKs lateinit var service: OrderService   // 생성자에 목을 꽂아 줌
}
규칙 하나 — 결과값에 영향을 주는 의존성은 절대 relaxed 로 두지 마세요. 로거 · 메트릭 · 감사 기록처럼 "불려도 그만, 안 불려도 그만"인 것에만 씁니다. 애매하면 relaxUnitFun = true 가 안전한 중간값입니다.

확인하기 — slot · capture · verify 의 강도 조절 🟡 중급

인자 캡처 — "무엇을 넘겼나"를 꺼내 보기

@Test
fun `결제 요청에는 할인 후 금액이 들어간다`() = runTest {
    val req = slot<ChargeRequest>()
    coEvery { pay.charge(capture(req)) } returns Receipt("R-1")

    service.place(cmd(price = 10_000, coupon = 1_000))

    req.captured.amount shouldBe 9_000L         // 캡처한 인자를 직접 검사
    req.captured.idempotencyKey.shouldNotBeBlank()
}

// 여러 번 불렸다면 리스트로
val sent = mutableListOf<Event>()
coEvery { publisher.publish(capture(sent)) } just Runs
service.closeDay()
sent.map { it.type } shouldContainExactly listOf("SETTLED", "REPORTED")

// 캡처 대신 verify 안에서 바로 조건 확인
coVerify { pay.charge(match { it.amount == 9_000L }) }
coVerify { pay.charge(withArg { it.amount shouldBe 9_000L }) }   // 실패 메시지가 더 친절

verify 계열 — 강할수록 깨지기 쉽다

함수확인하는 것
verify { }한 번 이상 불렸나 (기본 atLeast = 1)
verify(exactly = 0) { }안 불렸나 — 실패 경로 테스트의 핵심
verify(timeout = 1000) { }다른 스레드에서 1초 안에 불렸나
verifyOrder { }적힌 순서대로 불렸나 (사이에 다른 호출 허용)
verifySequence { }정확히 이 호출들만, 이 순서로 — 가장 강함
verifyAll { }이 호출들만 (순서 무관)
confirmVerified(mock)검증 안 한 호출이 남았으면 실패
verify { m wasNot Called }목 전체가 한 번도 안 불렸나
순서 검증은 순서가 '요구사항'일 때만 — "결제 성공 후에 재고 차감"처럼 업무 규칙이면 coVerifyOrder 가 맞습니다. 단지 지금 구현이 그 순서라서 고정한 것이라면, 그 테스트는 다음 리팩터링 때 거짓 경보를 냅니다.
대역을 무엇으로 만들까 — 마름모가 질문, 초록 상자가 권장 답 결과가 상태(저장 · 조회)로 정해지나? 예 Fake 구현 인메모리 저장소 · 시계 아니오 호출 자체가 결과인가? (발송 · 결제 · 발행) 예 mockk + verify 밖으로 나가는 호출만 아니오 정해진 값만 돌려주면 되나? 예 stub · relaxUnitFun every { } returns 아니오 (실물 일부만 교체) spyk 설계를 다시 보라는 신호 위에서 끝날수록 리팩터링에 강한 테스트입니다 spyk 가 필요하다 = 한 클래스가 두 가지 일을 한다는 뜻
대역 결정 흐름 — Fake 가 가능하면 Fake, 목은 밖으로 나가는 호출에만, spyk 는 마지막 수단입니다.

실무에선 이렇게 — spyk 함정과 object · 확장 함수 모킹 🔴 실무

spyk 의 세 가지 함정

val real = PriceService(rates)
val spy = spyk(real)                 // ① '복사본'이다 — real 의 필드를 복사해 새 객체를 만든다
real.discount = 0.3                  //    real 을 바꿔도 spy 는 모른다

every { spy.taxRate() } returns 0.0  // ② 일부만 교체, 나머지는 진짜 코드 실행
spy.total(1000)                      //    total() 안의 this.taxRate() 호출도 가로채진다

// ③ private 함수를 바꾸려면 문자열 이름 — 오타가 컴파일에 안 걸린다
val spy2 = spyk(real, recordPrivateCalls = true)
every { spy2["roundUp"](any<Double>()) } returns 0.0

// ✅ 대부분은 이렇게 푸는 게 낫다 — 바꾸고 싶은 부분을 의존성으로 뺀다
class PriceService(private val rates: RateProvider, private val tax: TaxPolicy)
val service = PriceService(rates, TaxPolicy { 0.0 })   // fun interface 면 람다로
spyk 에 진짜 DB · HTTP 클라이언트가 들어 있으면 스텁하지 않은 메서드가 실제로 나갑니다. 테스트가 운영 API 를 호출한 사고가 대부분 이 경로입니다.

object · 최상위 · 확장 함수 · 생성자

// object 싱글턴
mockkObject(FeatureFlags)
every { FeatureFlags.isOn("new-checkout") } returns true

// 최상위 함수 · 확장 함수 — 파일 클래스(XxxKt) 단위로 바꾼다
mockkStatic("com.shop.util.SlugKt")          // Slug.kt 파일
every { "주문 상세".toSlug() } returns "order-detail"
mockkStatic(String::toSlug)                  // 함수 참조로 — 오타 방지 ★

// 클래스 안에 선언된 확장 함수 — 그 클래스 목의 범위 안에서
val fmt = mockk<Formatter>()
with(fmt) { every { any<Money>().pretty() } returns "1,000원" }

// 함수 안에서 직접 생성하는 객체
mockkConstructor(SmtpClient::class)
every { anyConstructed<SmtpClient>().send(any()) } returns true

@AfterEach
fun tearDown() = unmockkAll()   // ★ 전역 상태 — 안 풀면 다음 테스트로 샌다
object · static 모킹은 '전역 변경'입니다 — 병렬로 도는 다른 테스트에도 보입니다. JUnit 병렬 실행을 켰다면 그런 테스트 클래스에 @Execution(SAME_THREAD) 를 붙이거나, 더 근본적으로는 FeatureFlags 를 인터페이스로 주입받게 바꾸세요.
증상원인처방
no answer found for ...스텁 안 한 호출스텁 추가 · 정말 상관없는 의존성이면 relaxUnitFun
suspend 함수 스텁이 안 먹음every 를 씀coEvery · coVerify · coJustRun
같은 인자인데 매칭 안 됨data class 가 아닌 객체의 equalsmatch { it.id == 1L } 로 필드 비교
테스트 단독은 통과, 전체는 실패mockkObject/mockkStatic 누수@AfterEach unmockkAll()
value class 인자 매칭 실패박싱 · 언박싱 차이MockK 최신으로 올리고 any() 대신 실제 값 사용
Spring 빈을 목으로 바꾸고 싶다Mockito 용 @MockitoBeanspringmockk 의 @MockkBean (04)
정리 — MockK 는 every(녹화) → returns(응답) → verify(대조) 세 박자이고, suspend 면 앞에 co 를 붙입니다. 결과에 영향을 주는 의존성은 엄격하게, 로거 같은 건 relaxUnitFun 으로, spyk 와 static 모킹이 필요해지면 설계를 먼저 의심하세요.
QA 03

코루틴 테스트 — 가상 시간을 손으로 돌리기

재시도 · 디바운스 · 타임아웃이 들어간 코드를 실제 시간으로 테스트하면 테스트 하나가 몇 초씩 걸리고, 가끔 실패합니다. runTest 는 가짜 시계를 쓰기 때문에 delay(10.minutes) 도 즉시 지나갑니다. 기본 사용법은 🌀 코루틴 탭(CORO 18)에 있고, 여기서는 시계가 정확히 언제 움직이는지와 실무 함정을 봅니다.

가상 시계 — 위는 예약된 작업, 아래는 시계를 돌리는 테스트 명령 (실제 경과 약 3 ms) 0 1000 2000 3000 ms (가상) launch 시작 delay(1000) 끝 emit("A") delay(1000) 끝 emit("B") withTimeout(3000) 시간 초과 판정 advanceTimeBy(1000) 1000 시점 작업은 아직 runCurrent() → A 방출 advanceUntilIdle() — 남은 예약을 끝까지 advanceTimeBy(n) 은 "n 직전까지" — 정확히 n 시점의 작업은 runCurrent() 몫입니다
가상 시간 타임라인 — 경계 시점(1000)의 작업이 언제 도는지를 모르면 테스트가 이유 없이 실패합니다.

경계를 정확히 — advanceTimeBy · runCurrent

@OptIn(ExperimentalCoroutinesApi::class)
class TickerTest {

    @Test
    fun `advanceTimeBy 는 경계 시점 작업을 실행하지 않는다`() = runTest {
        val seen = mutableListOf<String>()
        launch {
            delay(1000); seen += "A"
            delay(1000); seen += "B"
        }

        advanceTimeBy(1000)
        seen.shouldBeEmpty()             // ★ 아직! 1000 시점 작업은 대기 중
        runCurrent()
        seen shouldBe listOf("A")
        advanceUntilIdle()
        seen shouldBe listOf("A", "B")
        currentTime shouldBe 2000L       // 가상 시계 값
    }
}
예전 코드에서 옮겨 올 때 — 아주 오래된 TestCoroutineDispatcher 시절에는 advanceTimeBy 가 경계 작업까지 실행했습니다. 지금은 아닙니다. 옮긴 테스트가 "하나 덜 실행됨"으로 깨지면 뒤에 runCurrent() 를 붙이면 됩니다.

재시도 · 타임아웃을 '시간까지' 검증

suspend fun <T> retry(times: Int, initial: Duration, block: suspend () -> T): T {
    var wait = initial
    repeat(times - 1) {
        try { return block() } catch (e: IOException) { delay(wait); wait *= 2 }
    }
    return block()
}

@Test
fun `세 번째에 성공하면 1초 + 2초를 기다린다`() = runTest {
    var calls = 0
    val result = retry(times = 3, initial = 1.seconds) {
        if (++calls < 3) throw IOException("일시 장애") else "OK"
    }
    result shouldBe "OK"
    calls shouldBe 3
    currentTime shouldBe 3_000L          // 백오프 간격까지 검증 ★
}

@Test
fun `느린 응답은 2초에서 끊는다`() = runTest {
    val slow = suspend { delay(5.seconds); "늦음" }
    val r = withTimeoutOrNull(2.seconds) { slow() }   // withTimeout 도 가상 시간을 따른다
    r shouldBe null
    currentTime shouldBe 2_000L
}

테스트 가능한 설계 — 디스패처는 주입받는다 🟡 중급

Spring · Ktor 서비스 — 생성자로 받기

// 운영 설정 — 빈 하나
@Configuration
class DispatcherConfig {
    @Bean fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
}

@Service
class ReportService(
    private val repo: ReportJdbcRepository,      // 블로킹 JDBC
    private val io: CoroutineDispatcher,         // ★ 하드코딩하지 않는다
) {
    suspend fun build(id: Long): Report = withContext(io) {
        repo.loadRows(id).toReport()
    }
}

// 테스트 — 같은 스케줄러를 공유하는 디스패처를 넣는다
@Test
fun `리포트를 만든다`() = runTest {
    val io = StandardTestDispatcher(testScheduler)   // ★ testScheduler 를 꼭 넘긴다
    val service = ReportService(FakeReportRepo(), io)
    service.build(1).rows shouldHaveSize 3
}
StandardTestDispatcher() 를 인자 없이 만들면 새 스케줄러가 생겨 가상 시계가 둘이 됩니다. 그 안의 delay 는 아무도 앞으로 감지 않아 60초 뒤 runTest 타임아웃으로 끝납니다 (Dispatchers.setMain 으로 테스트 디스패처를 깔아 둔 경우는 그 스케줄러를 자동으로 공유합니다).

Android ViewModel — Main 을 바꿔 끼우는 확장

// JUnit 5 확장 — 모든 ViewModel 테스트에 재사용
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherExtension(
    val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : BeforeEachCallback, AfterEachCallback {
    override fun beforeEach(context: ExtensionContext) = Dispatchers.setMain(dispatcher)
    override fun afterEach(context: ExtensionContext) = Dispatchers.resetMain()
}

class CartViewModelTest {
    @JvmField
    @RegisterExtension
    val main = MainDispatcherExtension()

    @Test
    fun `담기 버튼을 누르면 개수가 늘어난다`() = runTest {
        val vm = CartViewModel(FakeCartRepo())    // viewModelScope 가 Main 을 쓴다
        vm.add(itemId = 7)
        vm.count.value shouldBe 1
    }
}
Main 을 안 바꾸면 — Module with the Main dispatcher is missing 예외가 납니다. JVM 단위 테스트에는 안드로이드 메인 루퍼가 없기 때문입니다. JUnit 4 라면 같은 일을 TestWatcher 규칙으로 합니다 (📱 Android 실전 탭).
launch { 출력 A; delay(10); 출력 C } 다음 줄에서 출력 B — 디스패처별 순서 StandardTestDispatcher (기본) UnconfinedTestDispatcher launch → 큐에 예약만 한다 launch → 그 자리에서 본문 진입 B 출력 — 다음 줄이 먼저 A 출력 → delay 에서 멈춤 본문이 쉴 때 A 출력 B 출력 가상 10 ms 뒤 C 출력 가상 10 ms 뒤 C 출력 B → A → C A → B → C Standard = 운영과 비슷한 순서(실제 디스패처도 예약 후 실행) Unconfined = 수집기를 먼저 붙여 두거나 중간 값을 놓치지 않아야 할 때
좌우 대조 — 첫 단계만 다르고 그 뒤는 같습니다. 순서가 중요한 테스트는 기본(Standard)으로 씁니다.

실무에선 이렇게 — Flow 는 Turbine 으로, 무한 수집은 backgroundScope 로 🔴 실무

디바운스 검색 — 시간 + 값 동시 검증

fun Flow<String>.searchQueries(): Flow<String> =
    debounce(300).filter { it.length >= 2 }.distinctUntilChanged()

@Test
fun `빠르게 친 글자는 마지막 것만 남는다`() = runTest {
    val input = MutableSharedFlow<String>()

    input.searchQueries().test {          // Turbine — 수집기를 먼저 붙인다
        input.emit("k"); input.emit("ko"); input.emit("kot")
        expectNoEvents()                  // 아직 300 ms 가 안 지났다
        awaitItem() shouldBe "kot"        // 기다리는 동안 가상 시계가 앞으로
        input.emit("kot")                 // 같은 값 — distinctUntilChanged 가 버림
        input.emit("kotl")
        awaitItem() shouldBe "kotl"
        cancelAndIgnoreRemainingEvents()
    }
}

// 여러 Flow 를 동시에 지켜볼 때
@Test
fun `주문과 알림이 함께 나간다`() = runTest {
    turbineScope {
        val orders = service.orders.testIn(backgroundScope)
        val alerts = service.alerts.testIn(backgroundScope)
        service.place(cmd())
        orders.awaitItem().status shouldBe PLACED
        alerts.awaitItem().type shouldBe "NEW_ORDER"
    }
}

StateFlow · stateIn — 두 가지 함정

// ① StateFlow 는 '최신 값'만 보장한다 — 중간 상태를 건너뛸 수 있다
vm.state.test {
    awaitItem() shouldBe UiState.Idle
    vm.load()                             // Loading → Success 를 연달아 설정
    expectMostRecentItem() shouldBe UiState.Success(items)   // ✅ 최종 상태만
    // awaitItem() shouldBe Loading       // ❌ 디스패처에 따라 건너뛰어 실패할 수 있다
}

// ② stateIn(WhileSubscribed) — 구독자가 없으면 아예 계산이 안 돈다
class FeedViewModel(repo: FeedRepo) : ViewModel() {
    val ui: StateFlow<FeedUi> = repo.feed()
        .map(::FeedUi)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FeedUi.Empty)
}

@Test
fun `피드가 화면 상태로 바뀐다`() = runTest {
    val vm = FeedViewModel(FakeFeedRepo(listOf(post())))
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        vm.ui.collect {}                  // 구독자 역할 — 테스트가 끝나면 자동 취소
    }
    vm.ui.value.posts shouldHaveSize 1
}
backgroundScope 를 쓰는 이유 — runTest 는 본문의 자식 코루틴이 모두 끝날 때까지 기다립니다. 끝나지 않는 collect 를 본문에서 launch 하면 테스트가 안 끝나고, backgroundScope 에서 띄우면 본문이 끝날 때 함께 취소됩니다.
증상원인처방
테스트가 실제로 몇 초씩 걸림runBlocking 으로 감쌈 · Dispatchers.IO 하드코딩runTest + 디스패처 주입
After waiting for 1m, the test coroutine is not completing끝나지 않는 자식 · 다른 스케줄러의 delaybackgroundScope · StandardTestDispatcher(testScheduler)
값이 하나 덜 나옴advanceTimeBy 경계뒤에 runCurrent()
SharedFlow 첫 값을 놓침수집기가 붙기 전에 emitTurbine test { } 안에서 emit · 또는 Unconfined 수집기
단독은 통과, 전체 실행 시 실패Dispatchers.setMain 을 되돌리지 않음확장/규칙에서 resetMain()
UncaughtExceptionsBeforeTest앞 테스트에서 샌 코루틴이 늦게 실패그 앞 테스트의 GlobalScope · 미취소 스코프 정리 (08)
정리 — 코루틴 테스트는 runTest + 주입된 디스패처 + Turbine 세 가지로 거의 다 됩니다. advanceTimeBy 는 경계를 포함하지 않는다는 것, 테스트 디스패처에는 testScheduler 를 꼭 넘긴다는 것, 끝나지 않는 수집은 backgroundScope 에 둔다는 것 — 이 세 가지가 "가끔 실패하는 테스트"의 대부분을 없앱니다.
QA 04

Spring Boot 통합 테스트 — 슬라이스와 Testcontainers, 그리고 속도

단위 테스트는 "내 코드가 맞나"를, 통합 테스트는 "설정 · SQL · 직렬화 · 메시지가 실제로 맞물리나"를 봅니다. 문제는 속도입니다. 스프링 컨텍스트를 매번 새로 띄우면 테스트 100개가 10분이 됩니다. 필요한 층만 띄우는 슬라이스, 진짜 DB 를 띄우는 Testcontainers, 컨텍스트 캐시 세 가지를 같이 다룹니다.

테스트 클래스 하나가 준비되기까지 — 중간 규모 서비스 예시 측정 0 2.5 5 7.5 10 s 순수 단위 (MockK) 0.05 s @WebMvcTest 1.6 s @DataJpaTest + 컨테이너 4.2 s · 컨테이너 기동 포함 @SpringBootTest 첫 실행 7.5 s 같은 설정 · 캐시 적중 0.2 s — 컨텍스트 재사용 @MockkBean 조합 다름 7.5 s — 새 컨텍스트 마지막 두 줄의 차이가 곧 "테스트 스위트가 2분이냐 20분이냐"입니다
막대 차트(예시 측정) — 컨텍스트 캐시에 적중하느냐가 슬라이스 선택보다 큰 차이를 냅니다.

Testcontainers + @ServiceConnection — 설정 0줄

// build.gradle.kts
// testImplementation("org.springframework.boot:spring-boot-testcontainers")
// testImplementation("org.testcontainers:junit-jupiter")
// testImplementation("org.testcontainers:postgresql")
// testImplementation("org.testcontainers:kafka")

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

    @Bean
    @ServiceConnection                     // URL · 계정을 스프링에 자동 연결
    fun postgres(): PostgreSQLContainer<*> =
        PostgreSQLContainer(DockerImageName.parse("postgres:17-alpine"))
            .apply { withReuse(true) }     // 체이닝 대신 apply — 제네릭 SELF 타입 때문

    @Bean
    @ServiceConnection
    fun kafka(): KafkaContainer =          // org.testcontainers.kafka.KafkaContainer
        KafkaContainer(DockerImageName.parse("apache/kafka-native:3.8.0"))
}

// 개발할 때도 같은 컨테이너로 앱 띄우기 — src/test/kotlin/TestShopApplication.kt
fun main(args: Array<String>) {
    fromApplication<ShopApplication>().with(TestcontainersConfiguration::class).run(*args)
}
@ServiceConnection 이 하는 일 — 전에는 @DynamicPropertySource 로 spring.datasource.url 을 손으로 넣었습니다. 지금은 컨테이너 빈에 어노테이션 하나면 Boot 가 접속 정보를 알아서 연결합니다(Boot 3.1+, 새 KafkaContainer 는 3.4+).

@DataJpaTest — 진짜 PostgreSQL 로 쿼리 검증

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(TestcontainersConfiguration::class)
class OrderRepositoryTest(
    @Autowired private val repo: OrderRepository,
    @Autowired private val em: TestEntityManager,
) {
    @Test
    fun `최근 주문은 생성일 역순 10건`() {
        repeat(12) { i -> em.persist(order(userId = 7, createdAt = day(i))) }
        em.flush(); em.clear()                     // ★ 1차 캐시를 비워야 진짜 SELECT 가 나간다

        val page = repo.findRecentByUser(7, PageRequest.of(0, 10))

        page.content shouldHaveSize 10
        page.content.first().createdAt shouldBe day(11)
    }

    @Test
    fun `같은 주문번호는 저장할 수 없다`() {
        em.persistAndFlush(order(no = "ORD-1"))
        shouldThrow<DataIntegrityViolationException> {
            repo.saveAndFlush(order(no = "ORD-1"))   // 유니크 인덱스 — H2 와 메시지 · 동작이 다름
        }
    }
}
H2 로 대신하지 마세요 — ON CONFLICT, jsonb, 부분 인덱스, 잠금(FOR UPDATE SKIP LOCKED)이 전부 다르게 동작합니다. "테스트는 통과했는데 운영에서 문법 오류"의 가장 흔한 원인입니다. @DataJpaTest 는 기본으로 테스트마다 롤백하므로 데이터 정리는 신경 쓰지 않아도 됩니다.
슬라이스별로 올라오는 것 — 진한 칸만 진짜 빈, 나머지는 목으로 채웁니다 컨트롤러 서비스 JPA · DB Jackson 보안 필터 HTTP 클라 @WebMvcTest O 목 – O O – @WebFluxTest O 목 – O O – @DataJpaTest – – O – – – @JsonTest – – – O – – @RestClientTest – – – O – O @SpringBootTest O O O O O O "목" 칸 = 슬라이스가 안 올리므로 @MockkBean 으로 직접 채워야 하는 자리 한 행에 진한 칸이 적을수록 빠릅니다 — 마지막 행은 전부 올립니다
슬라이스 매트릭스 — 무엇이 진짜이고 무엇이 목인지가 테스트 결과를 해석하는 기준입니다.

웹 계층만 — @WebMvcTest + @MockkBean + MockMvc DSL 🟡 중급

컨트롤러 계약(상태 코드 · JSON 모양)만 확인

// testImplementation("com.ninja-squad:springmockk:4.0.2")
@WebMvcTest(OrderController::class)
class OrderControllerTest(@Autowired private val mvc: MockMvc) {

    @MockkBean
    private lateinit var service: OrderService

    @Test
    fun `없는 주문은 404 와 문제 상세를 돌려준다`() {
        every { service.find(99) } throws OrderNotFound(99)

        mvc.get("/api/orders/99") {
            accept = MediaType.APPLICATION_JSON
        }.andExpect {
            status { isNotFound() }
            content { contentType(MediaType.APPLICATION_PROBLEM_JSON) }
            jsonPath("$.title") { value("주문을 찾을 수 없습니다") }
        }
    }

    @Test
    fun `수량이 0 이면 400`() {
        mvc.post("/api/orders") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"productId":1,"quantity":0}"""
        }.andExpect { status { isBadRequest() } }

        verify(exactly = 0) { service.place(any()) }   // 검증 실패면 서비스까지 안 감
    }
}

Kafka 까지 — 발행된 메시지를 실제로 꺼내 보기

@IntegrationTest                                  // qa01 의 메타 어노테이션
@TestPropertySource(properties = ["spring.kafka.consumer.auto-offset-reset=earliest"])
class OrderEventIT(
    @Autowired private val service: OrderService,
    @Autowired private val consumers: ConsumerFactory<String, String>,
) {
    @Test
    fun `주문하면 order-placed 이벤트가 나간다`() {
        consumers.createConsumer("it-order", null).use { c ->
            c.subscribe(listOf("order-placed"))

            val id = service.place(cmd(productId = 1, quantity = 2)).id

            val records = KafkaTestUtils.getRecords(c, Duration.ofSeconds(10))
            val json = records.records("order-placed").single().value()
            json shouldContain "\"orderId\":$id"
        }
    }
}

// 비동기 결과를 기다릴 때 — sleep 대신 Awaitility
await().atMost(Duration.ofSeconds(5)).untilAsserted {
    stockRepo.findById(1).get().quantity shouldBe 8
}
Thread.sleep(3000) 으로 기다리지 마세요 — 느린 CI 에서는 모자라고 빠른 PC 에서는 낭비입니다. Awaitility 는 조건이 맞는 순간 바로 통과하고, 시간 안에 안 맞으면 마지막 실패 메시지를 보여 줍니다.

실무에선 이렇게 — 컨텍스트 캐시를 지키는 규칙 🔴 실무

캐시를 깨뜨리는 것들

새 컨텍스트를 만드는 원인대처
@MockkBean 조합이 클래스마다 다름공통 베이스 클래스 하나에 모아 두기
@DirtiesContext데이터는 @Sql · truncate 로 정리하고 컨텍스트는 살리기
properties = [...] · 프로파일이 제각각테스트용 application-test.yml 하나로 통일
클래스마다 다른 @Import메타 어노테이션(@IntegrationTest)으로 고정
# src/test/resources/application-test.yml — 캐시 적중 여부를 로그로 확인
logging:
  level:
    org.springframework.test.context.cache: DEBUG
# "Spring test ApplicationContext cache statistics: ... hitCount = 41, missCount = 3"

Gradle · 컨테이너 쪽 속도

// build.gradle.kts — JVM 을 여러 개 띄워 병렬로
tasks.withType<Test>().configureEach {
    maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
    jvmArgs("-XX:+UseParallelGC")          // 짧게 사는 테스트 JVM 에 유리한 경우가 많다
}
# ~/.testcontainers.properties — 개발 PC 에서만 켠다 (CI 는 끄기)
testcontainers.reuse.enable=true
# withReuse(true) 컨테이너가 테스트 실행이 끝나도 살아 있어
# 다음 실행에서 4초 기동 시간이 사라진다
포크를 늘리면 JVM 마다 컨텍스트 캐시가 따로라 메모리가 늘고, 컨테이너도 포크마다 뜰 수 있습니다. CPU 절반에서 시작해 실제 시간을 재며 조정하세요. 대부분의 프로젝트는 "포크 증가"보다 "캐시 미스 줄이기"가 효과가 훨씬 큽니다.
정리 — 웹 계약은 @WebMvcTest, 쿼리는 @DataJpaTest + 진짜 PostgreSQL, 전체 흐름은 @SpringBootTest 몇 개로 나누고, 컨테이너는 @ServiceConnection 빈 하나로 연결합니다. 속도의 핵심은 컨텍스트를 몇 번 새로 만드느냐이므로 목 · 프로파일 · Import 조합을 통일하세요. 스프링 테스트 기초는 🍃 Spring Boot Web 탭(12 · 테스트)에 있습니다.
QA 05

로깅 — kotlin-logging · SLF4J · Logback 과 구조적 로그

장애가 나면 가장 먼저 여는 것이 로그입니다. 그런데 로그가 너무 많으면 비용이, 너무 적으면 맹목이, 문자열로만 남으면 검색 불가가 문제가 됩니다. 코틀린에서는 kotlin-logging 의 람다로 비용을 없애고, JSON 구조 로그로 검색을 살리고, 레벨 정책으로 양을 다룹니다.

로그 한 줄이 지나가는 층 — 위층은 아래층만 압니다 내 코드 · logger.info { } 람다로 넘긴다 — 아직 문자열은 없다 kotlin-logging (KLogger) 레벨이 꺼져 있으면 람다를 안 부른다 SLF4J 2 API 파사드 — 구현을 몰라도 된다 · 키-값 전달 Logback · 레벨 필터 · MDC logger 별 레벨 · traceId 같은 문맥 부착 Appender · Encoder JSON 변환 · 비동기 큐 · 롤링 파일 수집 · Loki / Elasticsearch stdout 을 Fluent Bit 이 줍는다 (컨테이너) 호출 방향 붉은 층(Logback)의 설정이 "무엇이 남는가"의 대부분을 정합니다
레이어 스택 — 코드는 SLF4J 까지만 알고, 형식 · 목적지는 설정 파일이 정합니다.

kotlin-logging — 선언 한 줄, 람다로 지연 평가

// implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
// (Spring Boot 는 SLF4J + Logback 을 이미 포함)
import io.github.oshai.kotlinlogging.KotlinLogging

private val logger = KotlinLogging.logger {}   // 파일 최상위 — 이름은 패키지.클래스로 자동

class OrderService(private val repo: OrderRepository) {

    fun place(cmd: PlaceOrder): Order {
        logger.debug { "주문 요청 ${cmd.toJson()}" }   // DEBUG 꺼짐 → toJson() 실행 안 됨 ★
        val order = repo.save(cmd.toOrder())
        logger.info { "주문 완료 id=${order.id} amount=${order.amount}" }
        return order
    }
}

// ❌ SLF4J 를 직접 쓰면서 템플릿을 쓰면 — 레벨과 무관하게 문자열이 먼저 만들어진다
private val slf = LoggerFactory.getLogger(OrderService::class.java)
slf.debug("주문 요청 ${cmd.toJson()}")           // 매번 직렬화 비용
slf.debug("주문 요청 {}", cmd)                    // ✅ SLF4J 식은 자리표시자로
코틀린만의 함정 — 자바 개발자가 습관대로 SLF4J 에 "$x" 템플릿을 넣으면 꺼진 DEBUG 로그도 비용을 냅니다. 초당 수만 번 불리는 경로라면 CPU 몇 퍼센트가 여기서 사라집니다. kotlin-logging 의 람다는 이 문제를 문법으로 막아 줍니다.

구조적 로그 — 문장이 아니라 필드로

// 키-값을 따로 실어 보내면 수집기에서 필드로 검색된다
logger.atInfo {
    message = "주문 완료"
    payload = mapOf("orderId" to order.id, "amount" to order.amount, "userId" to cmd.userId)
}

logger.atWarn {
    message = "결제 재시도"
    cause = e
    payload = mapOf("attempt" to attempt, "pg" to "toss")
}
# application.yml — Spring Boot 3.4+ 는 인코더 없이 JSON 출력
logging:
  structured:
    format:
      console: ecs          # ecs · logstash · gelf 중 하나
  level:
    root: INFO
    com.shop.order: DEBUG   # 패키지별로 세밀하게
{"@timestamp":"2026-10-01T03:12:45.123Z","log":{"level":"INFO",
 "logger":"com.shop.order.OrderService"},"message":"주문 완료",
 "orderId":1042,"amount":39000,"userId":7,"traceId":"6f1c2a9e..."}
왜 JSON 인가 — "주문 완료 id=1042" 는 정규식으로 잘라야 하지만, orderId=1042 필드는 Loki · Kibana 에서 바로 조건 검색 · 집계가 됩니다. "어제 결제 재시도가 3회 넘은 주문"을 한 줄 질의로 찾을 수 있게 됩니다.
초당 로그 호출 120,000 번이 사람의 눈에 닿기까지 (예시 · 폭은 로그 눈금) 호출 120,000/s DEBUG · TRACE 포함 레벨 통과 9,000 −111,000 람다 미실행 저장 4,800 −4,200 헬스체크 · 반복 묶음 알림 ERROR 12 사람이 깨어나야 할 것만 1만분의 1 레벨 정책이 없으면 맨 아래 칸이 수천 건이 되고, 알림은 아무도 안 봅니다 DEBUG 를 람다로 쓰면 첫 단계의 111,000 건은 비용이 거의 0 입니다
깔때기 — 단계마다 버리는 이유가 곧 로그 정책입니다. 마지막 칸이 작아야 알림이 의미를 가집니다.

레벨 정책 — 팀이 같은 기준으로 쓰기 🟡 중급

레벨기준예운영에서
ERROR사람이 지금 봐야 한다 · 요청이 실패했고 스스로 회복 못 함결제 승인 실패 후 보상 실패 · DB 연결 불가알림 연동 · 건수 0 이 목표
WARN스스로 회복했지만 추세를 봐야 한다재시도 후 성공 · 서킷 열림 · 느린 쿼리대시보드 · 급증 시 알림
INFO업무 이벤트 — 요청 하나에 1~3줄주문 완료 · 배치 시작/끝 · 설정 로드켜 둠 (기본)
DEBUG개발자가 흐름을 따라갈 때분기 판단 값 · 외부 응답 요약꺼 둠 · 필요할 때 런타임에 켬
TRACE라이브러리 내부 수준바이트 단위 · 반복문 안거의 안 씀

재시작 없이 레벨 바꾸기 — Actuator

# management.endpoints.web.exposure.include=health,info,loggers
# 지금 레벨 보기
curl -s localhost:8080/actuator/loggers/com.shop.order

# 장애 조사 중 — 이 패키지만 DEBUG 로
curl -X POST localhost:8080/actuator/loggers/com.shop.order \
  -H 'Content-Type: application/json' \
  -d '{"configuredLevel":"DEBUG"}'

# 끝나면 되돌리기 (null = 상위 설정을 따름)
curl -X POST localhost:8080/actuator/loggers/com.shop.order \
  -H 'Content-Type: application/json' -d '{"configuredLevel":null}'
loggers 엔드포인트는 쓰기가 됩니다 — 외부에 열면 누구나 로그를 TRACE 로 바꿔 디스크를 채울 수 있습니다. 관리 포트(management.server.port)를 따로 두고 내부망에서만 열어 두세요.

로그에 남기면 안 되는 것 · 줄이는 것

// ① 개인정보 · 비밀 — 값 객체의 toString 에서 막는다
data class Card(val number: String, val holder: String) {
    override fun toString() = "Card(****-${number.takeLast(4)}, holder=***)"
}

@JvmInline
value class Password(val raw: String) {
    override fun toString() = "Password(***)"   // 실수로 찍혀도 안전
}

// ② 헬스체크 · 정적 파일 요청은 접근 로그에서 뺀다
// ③ 반복문 안의 INFO 는 집계 한 줄로
var failed = 0
items.forEach { runCatching { send(it) }.onFailure { failed++ } }
logger.info { "알림 발송 ${items.size}건 중 실패 ${failed}건" }
data class 의 자동 toString 이 위험한 이유 — logger.info { "요청 $req" } 한 줄로 비밀번호 · 토큰 필드까지 전부 찍힙니다. 민감한 필드가 있는 DTO 는 toString 을 직접 정의하는 것을 리뷰 규칙으로 두세요(13).

실무에선 이렇게 — 비동기 Appender 와 예외 로그 한 번 원칙 🔴 실무

logback-spring.xml — 운영만 JSON + 비동기

<configuration>
  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

  <springProfile name="prod">
    <!-- implementation("net.logstash.logback:logstash-logback-encoder:8.1") -->
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
    </appender>
    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
      <appender-ref ref="JSON"/>
      <queueSize>8192</queueSize>
      <discardingThreshold>0</discardingThreshold> <!-- 레벨로 버리지 않음 -->
      <neverBlock>true</neverBlock>                 <!-- 큐가 차면 막지 말고 버림 -->
    </appender>
    <root level="INFO"><appender-ref ref="ASYNC"/></root>
  </springProfile>

  <springProfile name="!prod">
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <root level="INFO"><appender-ref ref="CONSOLE"/></root>
  </springProfile>
</configuration>
neverBlock 의 선택 — true 면 로그 폭주 때 로그를 잃고 요청은 살립니다. false(기본)면 큐가 찰 때 요청 스레드가 로그 쓰기를 기다립니다. 대부분의 API 서버는 true 가 맞고, 감사 로그처럼 하나도 잃으면 안 되는 것은 별도 저장소에 씁니다.

예외는 경계에서 한 번만, 예외 객체째로

같은 예외가 세 번 찍힌다
fun pay(o: Order) = try {
    pg.charge(o)
} catch (e: PgException) {
    logger.error { "결제 실패: ${e.message}" }  // 스택 없음
    throw e                                     // 위에서 또 찍힘
}
던지는 쪽은 문맥만, 찍는 쪽은 한 곳
fun pay(o: Order) = try {
    pg.charge(o)
} catch (e: PgException) {
    throw PaymentFailed(o.id, e)   // 원인을 감싸 전달
}

@RestControllerAdvice
class ApiErrors {
    @ExceptionHandler(PaymentFailed::class)
    fun handle(e: PaymentFailed): ProblemDetail {
        logger.error(e) { "결제 실패 orderId=${e.orderId}" }  // ★ 예외를 첫 인자로
        return ProblemDetail.forStatus(HttpStatus.BAD_GATEWAY)
    }
}
플랫폼별로 갈리는 곳 — Ktor 는 CallLogging · CallId 플러그인으로 요청 한 줄 로그를 남기고(🚀 Ktor 서버 탭 · 관측), 안드로이드는 SLF4J 대신 Log · Timber 를 쓰고 릴리스 빌드에서 R8 로 DEBUG 호출을 지웁니다(📱 Android 실전 탭). 원리(지연 평가 · 레벨 정책 · 민감정보 차단)는 셋 다 같습니다.
정리 — 로거는 KotlinLogging.logger {} 한 줄, 메시지는 항상 람다로 씁니다. 운영 로그는 JSON + 비동기, 레벨은 표의 기준을 팀 규칙으로 두고, 예외는 경계에서 한 번, 예외 객체째로 찍습니다. 이것만 지켜도 "로그가 너무 많은데 정작 필요한 건 없는" 상태를 벗어납니다.
QA 06

코루틴과 MDC — traceId 가 사라지는 자리

MDC(Mapped Diagnostic Context)는 로그 한 줄마다 traceId · userId 를 자동으로 붙여 주는 장치인데, 내부가 ThreadLocal 입니다. 코루틴은 멈췄다 다른 스레드에서 재개되므로 MDC 가 그 스레드에 없으면 로그에서 traceId 가 빈칸이 됩니다. 요청 하나를 로그로 추적할 수 없게 되는 것입니다.

가로줄 하나가 스레드 하나 — 줄을 넘는 순간 ThreadLocal 은 따라오지 않습니다 요청 스레드 Default IO 워커 필터 · MDC.put traceId=abc 핸들러 시작 traceId=abc ✓ withContext(IO) traceId 없음 ✗ 재개 · 다른 워커 traceId 없음 ✗ 고침 — 코루틴 컨텍스트에 MDCContext() 를 실으면 어느 줄로 넘어가든 재개될 때마다 그 스레드의 MDC 를 복원합니다
스윔레인 — 세로로 넘어가는 화살표마다 MDC 가 끊깁니다. 컨텍스트 요소로 실어 보내야 따라갑니다.

MDCContext — 지금의 MDC 를 코루틴에 싣기

// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2")
import kotlinx.coroutines.slf4j.MDCContext

suspend fun handle(req: Request) {
    MDC.put("traceId", req.traceId)                 // 지금 이 스레드에만 붙음

    withContext(Dispatchers.IO + MDCContext()) {    // ★ 현재 MDC 를 복사해 실어 보냄
        logger.info { "DB 조회" }                    // traceId=abc
        delay(100)                                  // 다른 워커에서 재개돼도
        logger.info { "조회 끝" }                    // traceId=abc 그대로
    }
}

// 앱 범위 스코프에서 띄우는 작업도 마찬가지
appScope.launch(MDCContext()) {                     // launch 하는 시점의 MDC 를 캡처
    auditSender.send(event)
}
동작 원리 — MDCContext 는 ThreadContextElement 입니다. 코루틴이 스레드에 올라탈 때 그 스레드의 MDC 를 저장해 둔 맵으로 바꾸고, 내려올 때 원래대로 돌려놓습니다. 그래서 스레드 풀의 다른 작업에 traceId 가 새어 나가지도 않습니다.

함정 — 코루틴 안에서 MDC.put 하면 사라진다

launch(MDCContext()) {
    MDC.put("orderId", "42")      // ❌ 스레드의 MDC 만 바꿈 — 캡처된 맵은 그대로
    delay(10)                     // 재개되면 캡처된 맵으로 '복원'된다
    logger.info { "결제" }         // orderId 없음!
}

// ✅ 값을 더할 때는 새 MDCContext 로 감싼다
suspend fun <T> withMdc(
    vararg pairs: Pair<String, String>,
    block: suspend CoroutineScope.() -> T,
): T = withContext(MDCContext(MDC.getCopyOfContextMap().orEmpty() + pairs), block)

suspend fun pay(order: Order) = withMdc("orderId" to order.id.toString()) {
    logger.info { "결제 시작" }     // traceId + orderId
    pg.charge(order)               // suspend — 재개 후에도 둘 다 유지
    logger.info { "결제 끝" }
}
이 함정은 공식 문서에도 적혀 있습니다 — "코루틴 안에서 MDC 를 바꾸면 다음 재개 때 덮어써진다." 팀 공용 유틸로 withMdc 하나를 두고 MDC.put 을 직접 부르는 코드를 리뷰에서 막는 것이 가장 확실합니다.

프레임워크별 — 어디서 MDCContext 를 얹나 🟡 중급

Spring WebFlux — CoWebFilter 에서 한 번

@Component
class TraceIdFilter : CoWebFilter() {
    override suspend fun filter(exchange: ServerWebExchange, chain: CoWebFilterChain) {
        val traceId = exchange.request.headers.getFirst("X-Request-Id")
            ?: UUID.randomUUID().toString()
        exchange.response.headers.set("X-Request-Id", traceId)

        withContext(MDCContext(mapOf("traceId" to traceId))) {
            chain.filter(exchange)      // Spring 6.1+ : 이 컨텍스트가 suspend 핸들러까지 이어짐
        }
    }
}

// Spring MVC 의 suspend 핸들러 — 시작 시점엔 아직 요청 스레드이므로 바로 캡처
@GetMapping("/orders/{id}")
suspend fun get(@PathVariable id: Long) = withContext(MDCContext()) {
    service.find(id)
}

Micrometer Tracing · Ktor — 자동으로 붙이기

// Spring Boot 3 + micrometer-tracing-bridge-otel
//  → traceId · spanId 가 MDC 에 자동으로 들어가고 로그 패턴에도 찍힌다
//  → 코루틴으로 넘어갈 때는 관측 컨텍스트도 함께 실어 보낸다
import io.micrometer.core.instrument.kotlin.asContextElement

suspend fun sync(registry: ObservationRegistry) =
    withContext(Dispatchers.IO + registry.asContextElement()) {
        partnerClient.pull()            // 같은 traceId 로 하위 span 생성
    }

// Ktor — CallId 로 만들고 CallLogging 이 MDC 로 옮김
install(CallId) {
    retrieveFromHeader(HttpHeaders.XRequestId)
    generate { UUID.randomUUID().toString() }
    replyToHeader(HttpHeaders.XRequestId)
}
install(CallLogging) {
    callIdMdc("traceId")                // 호출 코루틴 전체에 MDC 유지
}
서비스 경계를 넘을 때 — MDC 는 프로세스 안의 이야기입니다. 다음 서비스로 넘기려면 HTTP 헤더(traceparent · X-Request-Id)나 Kafka 헤더에 실어야 합니다. Boot 가 만들어 준 RestClient.Builder · WebClient.Builder 빈을 쓰면 자동입니다 — 직접 new 하면 빠집니다.

실무에선 이렇게 — 어디서 끊기는지 한 장으로 🔴 실무

끊기는 자리증상처방
withContext(Dispatchers.IO)DB 로그만 traceId 빈칸+ MDCContext() · 또는 요청 진입점에서 한 번 얹기
appScope.launch { } 비동기 작업후속 처리 로그가 고아가 됨launch(MDCContext()) — 띄우는 시점에 캡처
코루틴 안 MDC.put첫 중단 이후 값 사라짐withMdc(...) 유틸
@Async · 스레드 풀 직접 사용풀 스레드 로그에 traceId 없음TaskDecorator 로 MDC 복사 · 또는 Boot 의 컨텍스트 전파 설정
Kafka 컨슈머메시지 처리 로그가 발행 요청과 안 이어짐spring.kafka.template.observation-enabled=true · 리스너도 동일
직접 만든 HTTP 클라이언트다음 서비스에서 새 traceIdBoot 가 주는 Builder 빈 사용

테스트로 고정하기 — 회귀 방지

@Test
fun `IO 로 넘어가도 traceId 가 유지된다`() = runTest {
    MDC.put("traceId", "t-1")
    val seen = withContext(Dispatchers.IO + MDCContext()) {
        delay(1)                         // 실제 IO 스레드에서 재개
        MDC.get("traceId")
    }
    seen shouldBe "t-1"
    MDC.clear()
}

로그 패턴에서 바로 확인

# 개발 콘솔 — MDC 값이 비면 바로 보이게
logging:
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [%thread] [%X{traceId:-NO-TRACE}] %logger{30} - %msg%n"
NO-TRACE 를 찾아라 — 기본값을 눈에 띄는 문자열로 두면 개발 중에 끊기는 자리가 로그에 바로 드러납니다. 운영 JSON 로그라면 traceId 필드가 없는 로그 비율을 대시보드로 두세요.
정리 — MDC 는 스레드에 붙고 코루틴은 스레드를 옮겨 다니므로, MDCContext() 를 코루틴 컨텍스트에 실어야 따라옵니다. 진입점(필터 · CallLogging)에서 한 번 얹고, 값을 더할 때는 withMdc 처럼 새 MDCContext 로 감싸며, 서비스 경계는 헤더로 넘깁니다. 코루틴 컨텍스트 자체는 🌀 코루틴 탭(CORO 11)에서 다룹니다.
QA 07

예외 처리 실무 — runCatching 이 취소를 삼키는 함정부터

코틀린에는 checked exception 이 없어서 "어디서 무엇이 터질지"를 타입이 알려 주지 않습니다. 여기에 코루틴이 취소도 예외(CancellationException)로 전달한다는 규칙을 더하면, 평범해 보이는 try-catch 와 runCatching 이 취소를 먹어 버리는 버그가 생깁니다. 예외를 어디서 잡고 · 무엇으로 표현하고 · 어디까지 퍼지게 할지를 정리합니다.

자식 하나가 터졌을 때 — 같은 트리, 다른 스코프 coroutineScope 알림 발송 예외 ✗ 포인트 적립 취소됨 통계 기록 취소됨 부모도 실패 → 호출자에게 예외 supervisorScope 알림 발송 예외 ✗ 포인트 적립 계속 ✓ 통계 기록 계속 ✓ 부모는 정상 · 실패는 그 자식에서 멈춤 왼쪽이 기본값 — "전부 성공해야 의미 있는 일"에 맞습니다 오른쪽은 "서로 독립인 일" — 대신 실패한 자식의 예외는 직접 잡아야 합니다
잡 트리 — 실패가 형제와 부모로 번지느냐, 그 자리에서 멈추느냐가 스코프 선택의 전부입니다.
runCatching 이 취소를 '성공한 실패'로 바꾼다
suspend fun loadProfile(id: Long): Profile? =
    runCatching { api.fetch(id) }          // suspend 호출
        .getOrNull()                       // 실패면 null

// 사용자가 화면을 떠나 viewModelScope 가 취소됨
//  → api.fetch 안의 delay/IO 가 CancellationException 을 던짐
//  → runCatching 이 잡아서 Result.failure 로 만듦
//  → 코루틴은 '취소됐는데도' 다음 줄을 계속 실행
//  → 이미 사라진 화면에 결과를 그리려다 크래시,
//     또는 취소된 요청이 재시도 루프를 돈다

try {
    repo.save(order)
} catch (e: Exception) {                   // 같은 문제 — Exception 이 취소도 잡는다
    logger.warn(e) { "저장 실패, 무시" }
}
취소는 다시 던지고, 나머지만 Result 로
// 팀 공용 유틸 — inline 이라 블록 안에서 suspend 호출 가능
inline fun <R> runSuspendCatching(block: () -> R): Result<R> =
    try {
        Result.success(block())
    } catch (e: CancellationException) {
        throw e                            // ★ 취소는 절대 삼키지 않는다
    } catch (e: Throwable) {
        Result.failure(e)
    }

suspend fun loadProfile(id: Long): Profile? =
    runSuspendCatching { api.fetch(id) }.getOrNull()

// try-catch 를 꼭 써야 한다면
try {
    repo.save(order)
} catch (e: Exception) {
    currentCoroutineContext().ensureActive()   // 취소된 상태면 여기서 다시 던짐
    logger.warn(e) { "저장 실패, 무시" }
}
withTimeout 도 같은 함정입니다 — 시간 초과 시 던지는 TimeoutCancellationException 은 CancellationException 의 자식이라, runCatching 안에 넣으면 "타임아웃 실패"가 아니라 이상한 취소로 번집니다. 값이 없으면 null 이 되는 withTimeoutOrNull 을 쓰세요. 취소 규칙 자체는 🌀 코루틴 탭(CORO 07 · 09)에 있습니다.

실패를 무엇으로 표현하나 — 예외 · Result · sealed 에러 타입 🟡 중급

업무 실패는 sealed 로 — 호출자가 빠뜨릴 수 없게

sealed interface PlaceOrderResult {
    data class Placed(val orderId: Long) : PlaceOrderResult
    data object OutOfStock : PlaceOrderResult
    data class PaymentDeclined(val reason: String) : PlaceOrderResult
}

suspend fun place(cmd: PlaceOrder): PlaceOrderResult {
    if (stock.available(cmd.productId) < cmd.quantity) return PlaceOrderResult.OutOfStock
    return when (val r = pg.charge(cmd.amount)) {
        is PgResult.Approved -> PlaceOrderResult.Placed(repo.save(cmd.toOrder(r.txId)).id)
        is PgResult.Declined -> PlaceOrderResult.PaymentDeclined(r.reason)
    }
}

// 컨트롤러 — when 이 모든 경우를 강제한다 (하나 추가하면 컴파일 에러)
fun PlaceOrderResult.toResponse(): ResponseEntity<Any> = when (this) {
    is PlaceOrderResult.Placed -> ResponseEntity.status(201).body(mapOf("id" to orderId))
    PlaceOrderResult.OutOfStock -> ResponseEntity.status(409).body(problem("재고 없음"))
    is PlaceOrderResult.PaymentDeclined -> ResponseEntity.status(402).body(problem(reason))
}

셋 중 무엇을 — 기준표

수단쓰는 때
예외버그 · 인프라 장애 — 호출자가 할 수 있는 게 없을 때. DB 끊김, null 이면 안 되는 값
Result<T>"실패할 수 있고 이유는 Throwable 이면 충분" — 외부 호출 래핑, 파싱
sealed업무상 예상된 실패가 여러 종류이고 호출자가 각각 다르게 반응해야 할 때
T?실패 이유가 하나뿐("없음")일 때 — 가장 가볍습니다
// 사전 조건 — 예외 종류를 함수가 골라 준다
require(qty > 0) { "수량은 1 이상: $qty" }        // IllegalArgumentException
check(order.status == NEW) { "이미 처리됨" }       // IllegalStateException
val user = repo.find(id) ?: error("사용자 없음 $id")
Result 를 계층마다 감싸 넘기지 마세요 — Result<Result<T>> 이나 모든 함수가 Result 를 돌려주는 코드는 읽기 어렵고, 결국 어딘가에서 getOrThrow() 로 다시 예외가 됩니다. 경계(외부 호출)에서만 쓰고 안쪽은 평범한 값과 예외로 둡니다.

실무에선 이렇게 — CoroutineExceptionHandler 와 SupervisorJob 의 자리 🔴 실무

핸들러는 '최상위 launch' 에서만 동작한다

val handler = CoroutineExceptionHandler { ctx, e ->
    logger.error(e) { "백그라운드 작업 실패 ${ctx[CoroutineName]?.name}" }
}

// ✅ 앱 범위 스코프의 루트 launch — 여기서 잡힌다
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)
appScope.launch(CoroutineName("daily-report")) { report.build() }

// ❌ 자식 launch 에 달아도 무시된다 — 예외는 부모에게 먼저 올라간다
scope.launch {
    launch(handler) { error("안 잡힘") }
}

// ❌ async 는 핸들러로 가지 않는다 — await 하는 쪽에서 터진다
val d = appScope.async { error("await 에서 터짐") }
runSuspendCatching { d.await() }.onFailure { logger.warn(it) { "보고서 실패" } }
핸들러는 "복구"가 아니라 "마지막 기록"입니다 — 호출되는 시점에 그 코루틴은 이미 끝났습니다. 재시도나 대체 값이 필요하면 작업 안에서 try 로 처리하세요.

SupervisorJob 을 launch 인자로 넘기는 실수

// ❌ 의도: "알림이 실패해도 주문은 계속"
suspend fun afterOrder(o: Order) = coroutineScope {
    launch(SupervisorJob()) { notifier.send(o) }   // 새 Job 이 부모가 된다!
    launch { points.add(o) }
}
//   → 알림 코루틴은 coroutineScope 의 자식이 아니게 됨
//   → coroutineScope 가 알림 완료를 기다리지 않고, 취소도 전파 안 됨 (누수)

// ✅ 독립 실패는 supervisorScope 로 — 구조는 그대로 유지
suspend fun afterOrder(o: Order) = supervisorScope {
    launch {
        runSuspendCatching { notifier.send(o) }
            .onFailure { logger.warn(it) { "알림 실패 orderId=${o.id}" } }
    }
    launch { points.add(o) }
}
규칙 — SupervisorJob() 은 CoroutineScope(...) 를 만들 때만 씁니다. launch · async · withContext 의 인자로 Job 을 넘기는 코드는 리뷰에서 거의 항상 버그입니다(13).
상황도구
suspend 호출을 Result 로 감싸기runSuspendCatching (취소 재던짐)
여러 일을 병렬로, 하나라도 실패하면 전체 실패coroutineScope { async ... }
여러 일을 병렬로, 서로 독립supervisorScope + 자식마다 처리
앱 전체 수명의 백그라운드 작업CoroutineScope(SupervisorJob() + 디스패처 + handler) 빈 하나
HTTP 요청 실패를 응답으로@RestControllerAdvice · Ktor StatusPages
정리 코드(자원 해제)가 취소 중에도 suspend 해야 함finally { withContext(NonCancellable) { ... } }
정리 — runCatching 과 catch (e: Exception) 은 취소까지 삼킵니다 — suspend 코드에서는 runSuspendCatching 이나 ensureActive() 로 취소를 되던지세요. 업무 실패는 sealed, 인프라 실패는 예외, 독립 작업은 supervisorScope, 그리고 SupervisorJob() 은 스코프를 만들 때만 씁니다.
QA 08

코루틴 장애 패턴 넷 — 증상으로 알아보고 고치기

코루틴 장애는 대개 컴파일도 되고 테스트도 통과합니다. 트래픽이 붙어야 드러나고, 드러날 때는 "메모리가 계속 늘어난다", "가끔 응답이 수 초씩 멈춘다" 같은 증상으로 옵니다. 실제로 가장 많이 겪는 네 가지를 지표의 모양 → 원인 코드 → 고친 코드 → 진단 도구 순서로 봅니다.

최근 6시간 지표 모양 — 축은 생략, 모양만 봅니다 정상 · 활성 코루틴 GlobalScope 누수 취소 무시 · CPU 여기서 취소 정상 · IO 대기 작업 IO 고갈 · 대기 작업 runBlocking · 바쁜 스레드 상한 200 우상향은 누수, 내려오지 않는 선은 취소 무시, 계단은 풀 고갈, 천장에 붙은 선은 스레드 블로킹입니다 색은 이상한 선에만 — 정상 두 줄과 비교해 모양으로 알아봅니다
스파크라인 격자 — 장애 넷은 지표 모양이 서로 다릅니다. 모양을 먼저 알아보면 원인 후보가 하나로 줄어듭니다.

패턴 ① ② — GlobalScope 누수 · 취소를 무시하는 루프 🟡 중급

요청이 끝나도 살아남는 코루틴
@Service
class SyncService(private val partner: PartnerClient) {
    fun requestSync(id: Long) {
        GlobalScope.launch {              // 누구의 자식도 아님
            while (true) {                // 끝나는 조건 없음
                partner.push(id)
                Thread.sleep(5_000)       // 스레드를 잡고 잔다 + 취소 확인 안 함
            }
        }
    }
}
// 요청마다 무한 루프가 하나씩 쌓인다 → 코루틴 수 우상향
// 앱 종료(배포) 때도 정리되지 않아 그레이스풀 셧다운이 늦어진다

suspend fun crunch(rows: List<Row>) = withContext(Dispatchers.Default) {
    for (r in rows) heavyCompute(r)   // 중단점이 없는 CPU 루프 — 취소해도 끝까지 돈다
}
수명이 있는 스코프 + 협조적 취소
@Component
class AppScope : CoroutineScope, DisposableBean {
    override val coroutineContext =
        SupervisorJob() + Dispatchers.Default + CoroutineName("app")
    override fun destroy() = cancel()      // 앱 종료 시 전부 취소
}

@Service
class SyncService(private val scope: AppScope, private val partner: PartnerClient) {
    private val jobs = ConcurrentHashMap<Long, Job>()

    fun requestSync(id: Long) {
        jobs.computeIfAbsent(id) {         // 같은 id 중복 실행 방지
            scope.launch(MDCContext()) {
                while (isActive) {         // 취소되면 루프 탈출
                    partner.push(id)
                    delay(5.seconds)       // 중단점 — 취소 확인 + 스레드 반납
                }
            }.also { job -> job.invokeOnCompletion { jobs.remove(id) } }
        }
    }
}

suspend fun crunch(rows: List<Row>) = withContext(Dispatchers.Default) {
    for (r in rows) { ensureActive(); heavyCompute(r) }   // 원소마다 취소 확인
}
Dispatchers.IO 에서 50 ms 블로킹 호출을 동시에 N 개 — p99 지연 0 50 100 150 200 ms 0 64 128 192 동시 블로킹 호출 수 → IO 스레드 64개가 꽉 참 여기까지는 대기 없음 65번째부터 큐에서 기다린다 해법은 스레드 늘리기가 아니라 자원별 전용 한도 — limitedParallelism
계단형 라인 — 64 를 넘는 순간 지연이 단계적으로 뜁니다. 무관한 기능까지 같은 풀을 쓰면 함께 느려집니다.

패턴 ③ ④ — IO 고갈 · 서버 스레드에서 runBlocking 🔴 실무

자원별 전용 디스패처 — 풀 크기에 맞춘다

// ❌ 모든 블로킹이 같은 64개를 나눠 씀
//    느린 레거시 API 하나가 스레드를 다 잡으면 DB 호출까지 줄을 선다
suspend fun legacy() = withContext(Dispatchers.IO) { soapClient.call() }

// ✅ 자원마다 독립된 한도 — 서로 굶기지 않는다
object Pools {
    // JDBC 커넥션 풀(Hikari) 크기와 같게 — 그 이상은 어차피 커넥션을 기다림
    val db = Dispatchers.IO.limitedParallelism(20)
    // 느린 외부 시스템은 작게 가둔다
    val legacySoap = Dispatchers.IO.limitedParallelism(8)
}

suspend fun findOrders(uid: Long) = withContext(Pools.db) { jdbc.query(uid) }
suspend fun legacy() = withContext(Pools.legacySoap) { soapClient.call() }
Dispatchers.IO.limitedParallelism(n) 은 64 한도와 따로 셉니다 — IO 의 탄력적 스레드 위에 만든 독립된 뷰라서, 여기서 쓰는 n 개는 기본 IO 의 64개를 깎아 먹지 않습니다. 반면 Dispatchers.Default.limitedParallelism(n) 은 CPU 코어 수 안에서 나눠 쓰는 것입니다.

runBlocking 은 다리일 뿐 — 서버 스레드에서는 금지

// ❌ Spring MVC 컨트롤러 — 톰캣 스레드가 끝날 때까지 묶인다
@GetMapping("/dashboard")
fun dashboard(): Dashboard = runBlocking {
    val a = async { orders.recent() }
    val b = async { stats.today() }
    Dashboard(a.await(), b.await())
}
// 요청 200개 → 톰캣 스레드 200개 전부 대기 → 새 요청은 accept 큐에서 멈춤
// runBlocking 안에서 다시 runBlocking 을 부르는 코드가 Default 에서 돌면 교착까지 간다

// ✅ 컨트롤러를 suspend 로 — Spring MVC · WebFlux 둘 다 지원
@GetMapping("/dashboard")
suspend fun dashboard(): Dashboard = coroutineScope {
    val a = async { orders.recent() }
    val b = async { stats.today() }
    Dashboard(a.await(), b.await())
}

// runBlocking 이 맞는 곳: main 함수 · 테스트가 아닌 CLI · 블로킹 API 를 요구하는 콜백 경계

진단 — 코루틴 덤프와 블로킹 탐지 🔴 실무

kotlinx-coroutines-debug — 살아 있는 코루틴 목록

// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.10.2")
// 운영에서는 기능 플래그로 켠다 — 생성 스택 수집은 비싸므로 끈다
DebugProbes.enableCreationStackTraces = false
DebugProbes.install()

// 관리용 엔드포인트에서 덤프
@GetMapping("/internal/coroutines")
fun dump(): String {
    val out = ByteArrayOutputStream()
    DebugProbes.dumpCoroutines(PrintStream(out))
    return out.toString()
}
// Coroutine "app#1842":StandaloneCoroutine{Active}, state: SUSPENDED
//   at SyncService$requestSync$1.invokeSuspend(SyncService.kt:21)
//   → 같은 위치에 수천 개가 있으면 그게 누수 지점

한 번에 보는 진단표

도구무엇을 알려 주나
-Dkotlinx.coroutines.debug스레드 이름에 코루틴 이름 · 번호 표시 (@daily-report#12)
CoroutineName("...")덤프 · 로그에서 누가 띄웠는지
DebugProbes.dumpCoroutines()살아 있는 코루틴과 멈춘 위치
jcmd <pid> Thread.printDefaultDispatcher-worker 가 전부 BLOCKED/WAITING 인지
BlockHound논블로킹이어야 할 스레드의 블로킹 호출을 테스트에서 예외로
IntelliJ 코루틴 디버거중단점에서 코루틴 트리와 상태
BlockHound 연동 — kotlinx-coroutines-debug 를 함께 두면 BlockHound.install() 만으로 Dispatchers.Default 위의 Thread.sleep · JDBC 호출을 잡아 줍니다. 통합 테스트에서만 켜는 것이 보통입니다.
정리 — 우상향은 GlobalScope 누수, 내려오지 않는 CPU 는 취소 무시, 계단 지연은 IO 고갈, 천장에 붙은 스레드는 runBlocking 입니다. 스코프는 수명 있는 빈으로, 루프에는 isActive · ensureActive(), 블로킹 자원에는 limitedParallelism, 컨트롤러는 suspend 로 — 그리고 의심되면 코루틴 덤프부터 뜹니다. 내부 스케줄러 동작은 🔬 전문가 탭(03)에 있습니다.
QA 09

null 안전성 실무 함정 — 컴파일러가 못 지키는 네 곳

코틀린의 null 안전성은 코틀린 코드끼리에서만 완벽합니다. 자바가 돌려준 값, 리플렉션으로 채워지는 객체(JPA · Jackson), !! 와 lateinit 으로 컴파일러에게 "믿어 달라"고 한 자리 — 이 네 곳에서 운영 NPE 가 나옵니다. 문법은 🟠 Kotlin 기초 탭(03 · 18)에 있고, 여기서는 정책과 사고 사례를 다룹니다.

① 플랫폼 타입 — 자바가 돌려준 String!

// 자바 레거시 — null 을 돌려줄 수 있지만 어디에도 안 적혀 있다
public class LegacyUserDao {
    private final Map<Long, String> nicknames = new HashMap<>();
    public String findNickname(long id) { return nicknames.get(id); }   // 없으면 null
}
val nick = dao.findNickname(1)          // 타입: String! (플랫폼 타입)
nick.length                             // 컴파일 OK → 실행 중 NPE

val n1: String = dao.findNickname(1)    // 할당 순간 예외
// java.lang.NullPointerException: findNickname(...) must not be null

val n2: String? = dao.findNickname(1)   // ✅ 경계에서 nullable 로 받는다
val shown = n2 ?: "손님"

// ✅ 더 좋게 — 얇은 코틀린 어댑터로 감싸 경계를 한 곳에 모은다
class UserNicknames(private val dao: LegacyUserDao) {
    fun of(id: Long): String? = dao.findNickname(id)
}
자바 쪽에 어노테이션이 있으면 플랫폼 타입이 사라집니다 — @Nullable · @NonNull (JSpecify · JetBrains · Jakarta · Android)을 코틀린이 읽어 String? / String 으로 봅니다. Spring 은 패키지 단위 non-null 선언을 이미 해 두었고, 최근 버전은 JSpecify 로 옮겨 가는 중입니다.

② !! 정책 — 팀 규칙으로 정한다

이유가 사라지는 !!
val user = repo.findById(id)!!
val email = user.email!!
// 스택 트레이스: NullPointerException
//   at OrderService.place(OrderService.kt:42)
// → 무엇이 왜 null 이었는지 알 수 없다
이유를 남기는 대안
val user = repo.findById(id)
    ?: throw UserNotFound(id)              // 업무 예외로
val email = requireNotNull(user.email) {
    "이메일 없는 사용자 id=$id"              // 메시지가 남는다
}
val cfg = checkNotNull(System.getenv("PG_KEY")) { "PG_KEY 미설정" }
# detekt.yml — 운영 코드의 !! 를 빌드에서 막는다 (테스트 코드는 제외)
potential-bugs:
  UnsafeCallOnNullableType:
    active: true
    excludes: ['**/test/**']
JPA 엔티티의 상태 — 원이 상태, 화살표에 적힌 것이 전이 조건 New id = null Managed id = 42 Detached id = 42 Removed 삭제 예약 persist() · save() 트랜잭션 끝 · clear() merge() remove() 여기서 order.id!! → NPE 지연 로딩 접근 시 LazyInitialization id 는 New 에서 반드시 null — 그래서 Long? 이 맞고, !! 는 save 직후에만 안전합니다 val id: Long = 0 으로 두면 "0 이면 새 것" 이라는 약속을 코드 전체가 기억해야 합니다
상태 기계 — 엔티티의 id 는 상태에 따라 null 이 정상입니다. 타입이 이 사실을 숨기면 사고가 납니다.

③ 리플렉션이 채우는 객체 — JPA 엔티티와 null 🟡 중급

엔티티 — 플러그인 · id · equals

// build.gradle.kts
plugins {
    kotlin("plugin.spring") version "2.2.20"   // @Component 등 open
    kotlin("plugin.jpa") version "2.2.20"      // 엔티티에 인자 없는 생성자 생성
}
allOpen {                                      // 지연 로딩 프록시가 상속할 수 있게
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

@Entity
@Table(name = "orders")
class Order(                                    // ❌ data class 금지 (아래 표)
    @Column(nullable = false) val userId: Long,
    @Column(nullable = false) var status: OrderStatus = OrderStatus.NEW,
) {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
        protected set

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Order) return false
        return id != null && id == other.id      // 저장 전(New)끼리는 다르다
    }
    override fun hashCode(): Int = javaClass.hashCode()   // id 가 바뀌어도 안정
    override fun toString() = "Order(id=$id, status=$status)"   // 연관관계는 빼기
}

DB 의 NULL 이 non-null 필드로 들어오는 사고

@Entity
class Member(
    @Column(nullable = false) var name: String,
    var nickname: String,        // ❌ DB 컬럼은 NULL 허용인데 코틀린은 non-null
) { @Id @GeneratedValue var id: Long? = null }

// 옛 데이터에 nickname 이 NULL 인 행이 있으면
//   Hibernate 는 리플렉션으로 필드에 null 을 그대로 넣는다 (생성자를 안 거침)
//   → member.nickname.length 에서 NPE
//   → 코틀린 컴파일러도, 실행 시 null 검사도 이 경로는 못 막는다

// ✅ 원칙: 코틀린 타입 = DB 제약
//   · NOT NULL 컬럼  ↔ String  (+ @Column(nullable = false))
//   · NULL 허용 컬럼 ↔ String?
//   · 마이그레이션으로 NOT NULL 을 추가할 때는 기존 NULL 부터 채운다
// ALTER TABLE member ALTER COLUMN nickname SET NOT NULL;   -- 채운 다음에
data class 엔티티의 문제결과
모든 필드로 equals/hashCode저장 후 id 가 생기면 해시가 바뀌어 HashSet 에서 사라짐
자동 toString양방향 연관관계에서 무한 재귀 · 지연 로딩 강제 실행
copy()영속성 컨텍스트가 모르는 복제본이 생김

④ lateinit — "나중에 반드시 채운다"는 약속 🔴 실무

써도 되는 곳 · 안 되는 곳

✅ 맞는 자리❌ 틀린 자리
테스트의 @Autowired · @MockK 필드"없을 수도 있는" 값 — 그건 T?
안드로이드 onCreate 에서 채우는 뷰 · 어댑터Fragment 의 바인딩 — onDestroyView 뒤에도 남아 누수
프레임워크가 주입 · 초기화를 보장하는 필드생성자에서 받을 수 있는 의존성 — 생성자 주입으로
원시 타입(Int · Long) — 컴파일 에러, 대신 Delegates.notNull()
UninitializedPropertyAccessException 은 NPE 와 같은 종류의 사고입니다 — 메시지에 필드 이름이 나온다는 점만 다릅니다. 초기화 순서에 의존하는 lateinit 은 생성자 인자로 바꾸는 것이 근본 해결입니다.

Fragment 바인딩 — lateinit 대신

class OrderFragment : Fragment(R.layout.fragment_order) {

    // ❌ lateinit var binding — 뷰가 파괴된 뒤에도 옛 뷰 트리를 붙잡는다
    private var _binding: FragmentOrderBinding? = null
    private val binding get() = checkNotNull(_binding) { "뷰가 없는 시점에 접근" }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        _binding = FragmentOrderBinding.bind(view)
        binding.title.text = "주문"
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null                 // ★ 뷰와 함께 놓아준다
    }
}

// 초기화 여부를 물어야 한다면 이미 설계가 흔들리는 신호
if (::adapter.isInitialized) adapter.submitList(items)
정리 — 자바 경계에서는 nullable 로 받고, !! 는 requireNotNull · ?: throw 로 바꿔 이유를 남기며, 엔티티는 코틀린 타입 = DB 제약으로 맞추고 data class 로 만들지 않습니다. lateinit 은 프레임워크가 초기화를 보장하는 자리에서만 씁니다.
QA 10

직렬화 호환성 — 필드 하나 추가했을 뿐인데 옛 클라이언트가 깨진다

JSON 은 서버 · 앱 · 다른 서비스 · Kafka 에 남은 옛 메시지 · Redis 캐시가 서로 다른 버전으로 동시에 읽습니다. 배포는 한 번에 일어나지 않으므로 "새 코드가 옛 데이터를, 옛 코드가 새 데이터를" 읽을 수 있어야 합니다. kotlinx.serialization 과 Jackson 은 이 부분의 기본값이 서로 달라서 같은 변경이 한쪽에서만 깨집니다.

변경 종류 × 읽는 쪽 — 초록은 안전, 회색은 설정이 필요, 붉은색은 실패 새 코드가 옛 데이터를 읽을 때 옛 코드가 새 데이터를 읽을 때 kotlinx Jackson kotlinx Jackson 필드 추가 · 기본값 있음 ✓ 기본값 ✓ 기본값 ✓ 무시 ✓ 무시 필드 추가 · 기본값 없음 ✗ 필드 없음 ✗ 필드 없음 ✓ 무시 ✓ 무시 필드 삭제 ✓ 무시 ✓ 무시 △ 기본값 필요 △ 기본값 필요 이름 변경 △ JsonNames △ JsonAlias ✗ 필드 없음 ✗ 필드 없음 nullable → non-null △ coerce △ NullIsDefault ✓ ✓ enum 값 추가 ✓ ✓ △ coerce △ 기본 enum kotlinx 는 ignoreUnknownKeys = true 를 켠 경우, Jackson 은 스프링 부트 기본값 이름 변경은 "추가 + 삭제" 두 번의 배포로 나눠야 양쪽 모두 안전합니다
호환성 매트릭스 — 붉은 칸이 있는 변경은 한 번에 배포하면 안 됩니다. 회색 칸은 설정 한 줄이 생명줄입니다.

kotlinx.serialization — 호환을 위한 Json 설정

@OptIn(ExperimentalSerializationApi::class)
val AppJson = Json {
    ignoreUnknownKeys = true    // 새 필드가 와도 옛 코드가 안 깨짐 ★ (기본 false!)
    coerceInputValues = true    // null · 모르는 enum → 그 필드의 기본값으로
    explicitNulls = false       // null 은 안 씀 · 키가 없으면 null 로 읽음
    encodeDefaults = true       // 기본값도 씀 — 다른 언어 소비자를 위해
}

@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class OrderEvent(
    val orderId: Long,
    val amount: Long,
    val currency: String = "KRW",               // v2 에서 추가 — 기본값 필수
    @JsonNames("user_id") val userId: Long,     // 옛 이름도 읽음 (쓸 때는 userId)
    val channel: Channel = Channel.UNKNOWN,     // coerceInputValues 와 짝
)

@Serializable
enum class Channel { WEB, APP, UNKNOWN }
encodeDefaults 의 기본값은 false 입니다 — 값이 기본값과 같으면 JSON 에서 필드가 빠집니다. 코틀린끼리는 문제없지만, 같은 메시지를 읽는 파이썬 · 자바 컨슈머는 "currency 가 없다"며 실패합니다.

Jackson (Spring Boot) — 직접 만든 ObjectMapper 가 다르다

// Spring Boot 가 주입하는 ObjectMapper
//   · jackson-module-kotlin 자동 등록 (기본값 · nullable 인식)
//   · FAIL_ON_UNKNOWN_PROPERTIES = false  ← Boot 가 꺼 둠
//
// ❌ 테스트 · 배치에서 new 하면 둘 다 빠진다
val raw = ObjectMapper()            // 모르는 필드에 실패 + 기본값 무시

// ✅ 직접 만들어야 한다면 Boot 와 같은 성질로
val mapper: JsonMapper = JsonMapper.builder()
    .addModule(kotlinModule { enable(KotlinFeature.NullIsSameAsDefault) })
    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)
    .build()

data class OrderDto(
    val orderId: Long,
    @JsonAlias("user_id") val userId: Long,      // 옛 이름 수용
    val status: Status = Status.UNKNOWN,
)
enum class Status { PAID, SHIPPED, @JsonEnumDefaultValue UNKNOWN }
가장 흔한 사고 — 운영 코드는 Boot 의 매퍼라 멀쩡한데, 배치 · 컨슈머 · 테스트에서 ObjectMapper() 를 새로 만들어 같은 메시지가 그쪽에서만 실패합니다. 매퍼는 빈으로 주입받거나 공용 팩토리 하나로 만드세요.

고정하기 — 과거 메시지를 파일로 남기는 계약 테스트 🟡 중급

골든 파일 — 버전마다 실제 JSON 을 보관

// src/test/resources/contracts/order-event/v1.json · v2.json · v3.json
class OrderEventCompatTest {

    @ParameterizedTest
    @ValueSource(strings = ["v1.json", "v2.json", "v3.json"])
    fun `과거 버전 메시지를 모두 읽을 수 있다`(file: String) {
        val text = checkNotNull(javaClass.getResource("/contracts/order-event/$file")).readText()
        shouldNotThrowAny { AppJson.decodeFromString<OrderEvent>(text) }
    }

    @Test
    fun `현재 출력 모양이 계약 파일과 같다`() {
        val actual = AppJson.encodeToString(sampleEvent())
        val expected = checkNotNull(javaClass.getResource("/contracts/order-event/current.json")).readText()
        // 키 순서와 공백은 무시하고 구조로 비교
        AppJson.parseToJsonElement(actual) shouldBe AppJson.parseToJsonElement(expected)
    }
}
필드를 바꾸면 이 테스트가 먼저 깨집니다 — 그때 current.json 을 고치고 옛 파일은 지우지 않고 남깁니다. 리뷰어는 계약 파일의 diff 만 보고도 "하위 호환이 깨지는 변경인가"를 판단할 수 있습니다.

배포 순서 — 읽는 쪽 먼저, 쓰는 쪽 나중

바꾸려는 것안전한 순서
필드 추가① 소비자: 기본값 있는 필드로 받기 → ② 생산자: 값 채워 보내기
필드 삭제① 소비자: 그 필드를 안 쓰게(기본값) → ② 생산자: 빼기
이름 변경① 새 이름 추가 + 옛 이름 별칭 → ② 둘 다 보내기 → ③ 옛 이름 제거
enum 값 추가① 소비자: 모르는 값 → UNKNOWN → ② 생산자: 새 값 사용
sealed 하위 타입 추가① 소비자: 모르는 타입 처리 경로 → ② 생산자 (판별자 type 이 바뀌지 않게)
남아 있는 데이터도 '옛 클라이언트'입니다 — Redis 에 직렬화해 둔 캐시, Kafka 의 보관 기간 안 메시지, 앱 스토어에서 업데이트 안 한 앱. 캐시는 키에 버전을 넣고(order:v3:42), 앱은 최소 지원 버전을 정해 그보다 옛 응답 모양을 유지합니다.
정리 — 새 필드에는 기본값, kotlinx 에는 ignoreUnknownKeys, Jackson 은 Boot 가 준 매퍼를 씁니다. 이름 변경 · 삭제는 두 번의 배포로 나누고, 과거 메시지를 골든 파일 테스트로 고정하면 호환성 사고는 PR 단계에서 걸립니다. 직렬화 기초는 🧰 실전 도구 탭(05)과 🍃 Spring Boot Web 탭(05)에 있습니다.
QA 11

빌드 · 품질 자동화 — 버전 카탈로그부터 CI 파이프라인까지

앞 섹션의 규칙들은 사람이 기억해서는 지켜지지 않습니다. 빌드가 대신 기억하게 만들어야 합니다 — 의존성 버전은 카탈로그 한 곳에, 코드 규칙은 detekt · ktlint 에, 테스트는 CI 에. 그리고 그 CI 가 느리면 아무도 기다리지 않으므로 캐시와 병렬화까지가 한 세트입니다. Gradle 기초는 🧰 실전 도구 탭(01 · 02 · 03)에 있습니다.

PR 하나가 지나가는 길 — 앞 단계가 실패하면 뒤는 돌지 않습니다 캐시 복원 Gradle · 의존성 컴파일 증분 · 빌드 캐시 정적 검사 detekt · ktlint 단위 테스트 수 초 ~ 1분 통합 · 이미지 Testcontainers 여기까지 3분 안 — PR 작성자가 기다릴 수 있는 한계 싼 검사를 앞에, 비싼 검사를 뒤에 — 실패는 가능한 한 일찍 나야 합니다 셰브론 모양 자체가 "되돌아가지 않는다"는 뜻입니다
파이프라인 — 싼 것부터 실패시키는 순서가 CI 시간을 절반으로 줄입니다.

gradle/libs.versions.toml — 버전은 한 곳에만

[versions]
kotlin = "2.2.20"
spring-boot = "3.5.6"
coroutines = "1.10.2"
mockk = "1.13.13"
kotest = "5.9.1"
detekt = "1.23.8"
ktlint-gradle = "12.1.2"

[libraries]
coroutines-core  = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core",  version.ref = "coroutines" }
coroutines-slf4j = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-slf4j", version.ref = "coroutines" }
coroutines-test  = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test",  version.ref = "coroutines" }
kotlin-logging   = { module = "io.github.oshai:kotlin-logging-jvm", version = "7.0.3" }
mockk            = { module = "io.mockk:mockk", version.ref = "mockk" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }

[bundles]
test-unit = ["mockk", "kotest-assertions", "coroutines-test"]

[plugins]
kotlin-jvm    = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
spring-boot   = { id = "org.springframework.boot", version.ref = "spring-boot" }
detekt        = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ktlint        = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }

build.gradle.kts · gradle.properties

plugins {
    alias(libs.plugins.kotlin.jvm)
    alias(libs.plugins.kotlin.spring)
    alias(libs.plugins.spring.boot)
    alias(libs.plugins.detekt)
    alias(libs.plugins.ktlint)
}

dependencies {
    implementation(libs.coroutines.core)
    implementation(libs.coroutines.slf4j)
    implementation(libs.kotlin.logging)
    testImplementation(libs.bundles.test.unit)    // 묶음 하나로 세 개
}

kotlin {
    jvmToolchain(21)
    compilerOptions { allWarningsAsErrors = true }   // 경고도 빌드 실패로 ★
}
# gradle.properties — 주석은 반드시 줄 맨 앞에 (줄 끝 # 은 값의 일부가 되어 'true  # ...' 로 읽힌다)
# 태스크 결과 재사용 (로컬 · 원격)
org.gradle.caching=true
# 설정 단계 건너뛰기 — 두 번째 빌드부터 빠름
org.gradle.configuration-cache=true
# 멀티모듈 병렬
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx3g -XX:+UseParallelGC -Dfile.encoding=UTF-8
kotlin.daemon.jvmargs=-Xmx2g
configuration cache 를 켜면 깨지는 스크립트 — 태스크 실행 중에 project 를 참조하는 코드, 오래된 플러그인이 대표적입니다. 에러 리포트(build/reports/configuration-cache)가 정확한 줄을 알려 주므로 하나씩 고치거나 해당 태스크만 notCompatibleWithConfigurationCache("이유") 로 표시합니다.

코드 규칙을 빌드에 — detekt · ktlint · 베이스라인 🟡 중급

detekt — 버그 냄새 · 복잡도 · 코루틴 규칙

detekt {
    buildUponDefaultConfig = true                 // 기본 규칙 위에 내 설정만 덮기
    config.setFrom(files("config/detekt.yml"))
    baseline = file("config/detekt-baseline.xml") // 기존 위반은 기록만, 새 위반만 실패 ★
    parallel = true
}

// detekt 1.23.x 는 자기가 빌드된 Kotlin 컴파일러 버전으로 돌아야 한다
// (프로젝트 Kotlin 이 더 새로우면 "compiled with Kotlin X" 에러) — detekt 쪽만 고정
configurations.matching { it.name == "detekt" }.all {
    resolutionStrategy.eachDependency {
        if (requested.group == "org.jetbrains.kotlin") useVersion("2.0.21")
    }
}
# config/detekt.yml — 이 탭의 함정과 직결되는 규칙만 골라 켬
coroutines:
  GlobalCoroutineUsage: { active: true }          # qa08 ①
  InjectDispatcher: { active: true }              # qa03 · qa08 ③
  SuspendFunWithCoroutineScopeReceiver: { active: true }
potential-bugs:
  UnsafeCallOnNullableType: { active: true }      # qa09 !!
exceptions:
  TooGenericExceptionCaught: { active: true }     # catch (e: Exception) — qa07
  SwallowedException: { active: true }

ktlint — 모양은 사람이 논쟁하지 않게

# .editorconfig — IDE 와 ktlint 가 같은 규칙을 읽는다
root = true

[*.{kt,kts}]
ktlint_code_style = ktlint_official
max_line_length = 140
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
./gradlew ktlintCheck          # 검사만 (CI)
./gradlew ktlintFormat         # 자동 수정 (로컬)
./gradlew detekt               # 정적 분석
./gradlew detektBaseline       # 처음 도입할 때 기존 위반을 베이스라인으로

# 커밋 전에 자동으로 — Git 훅 설치 (플러그인 제공 태스크)
./gradlew addKtlintFormatGitPreCommitHook
기존 프로젝트에 도입할 때는 베이스라인부터 — 위반 3,000건을 한 번에 고치자고 하면 도입 자체가 무산됩니다. 베이스라인으로 지금 상태를 "허용"으로 기록하고, 새로 쓰는 코드만 규칙을 지키게 한 뒤 조금씩 줄여 갑니다.
CI 작업 일정 — 세로 붉은 선이 PR 피드백 목표(10분)입니다 0분 5 10 15 20분 컴파일 (캐시 적중) detekt · ktlint 단위 테스트 통합 테스트 (별도 job) 이미지 빌드 · 푸시 최적화 전 (직렬) 19분 — 캐시 없음 · 한 줄로 세 갈래가 동시에 시작 9분 · 여유 1분 흐린 막대는 예전 모습 — 같은 일을 줄 세우지 않고 겹치게만 해도 절반이 됩니다
간트 — 겹칠 수 있는 일을 겹치고 마감선과 함께 그리면 어디를 더 줄여야 하는지 보입니다.

실무에선 이렇게 — GitHub Actions 파이프라인 🔴 실무

.github/workflows/ci.yml — 세 job 병렬 + 이미지

name: ci
on:
  pull_request:
  push:
    branches: [main]

jobs:
  check:                                   # 정적 검사 + 단위 테스트
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21' }
      - uses: gradle/actions/setup-gradle@v4   # Gradle 캐시 자동 관리
      - run: ./gradlew ktlintCheck detekt test --continue
      - uses: actions/upload-artifact@v4
        if: failure()
        with: { name: reports, path: '**/build/reports/' }

  integration:                             # Docker 가 이미 있어 Testcontainers 가 바로 돈다
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21' }
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew integrationTest

  image:
    needs: [check, integration]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21' }
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew bootBuildImage --imageName=ghcr.io/acme/order-api:${{ github.sha }}

품질 게이트 — 무엇에서 PR 을 막나

게이트기준 · 이유
컴파일 경고allWarningsAsErrors — 코틀린 경고는 대부분 실제 문제(안 쓰는 변수, 불필요한 !!)
ktlint모양 논쟁을 리뷰에서 없앤다
detekt (베이스라인 대비)새 위반 0건
단위 · 통합 테스트전부 통과 — 불안정한 테스트는 고치거나 격리, 재실행으로 덮지 않기
커버리지 (Kover)숫자 목표보다 떨어지지 않기를 기준으로
의존성 취약점심각도 높음만 차단 (🧰 실전 도구 탭 14)
# CI 가 느릴 때 원인 찾기 — 빌드 스캔
./gradlew check --scan
# 어떤 태스크가 캐시를 못 탔는지(FROM-CACHE vs EXECUTED)가 나온다
./gradlew check --dry-run          # 무엇이 돌지만 미리 보기
"가끔 실패하는 테스트"는 게이트를 무력화합니다 — 다들 재실행 버튼을 누르는 습관이 생기면 진짜 실패도 재실행으로 덮입니다. 대부분 원인은 실제 시간 · 공유 상태 · 순서 의존입니다(03 · 02).
정리 — 버전은 libs.versions.toml 한 곳, 빌드는 캐시 + configuration cache, 규칙은 detekt(베이스라인) + ktlint, CI 는 싼 검사부터 · 서로 독립인 job 은 병렬로. PR 피드백이 10분 안에 오면 사람들은 CI 를 믿기 시작합니다.
QA 12

성능 측정 — 추측 말고 재기 · Sequence · value class · 박싱

"Sequence 가 빠르다더라", "value class 는 공짜다" — 반은 맞고 반은 틀립니다. 조건에 따라 결과가 뒤집히기 때문에 내 데이터 크기 · 내 연산으로 재 봐야 합니다. JVM 은 JIT 워밍업 · 데드 코드 제거 때문에 System.nanoTime() 으로 잰 숫자가 거의 항상 틀립니다. kotlinx-benchmark(JMH) 로 재고, 판단 기준을 표로 남깁니다.

kotlinx-benchmark 설정 — JVM 에서는 JMH 그대로

plugins {
    kotlin("jvm") version "2.2.20"
    kotlin("plugin.allopen") version "2.2.20"
    id("org.jetbrains.kotlinx.benchmark") version "0.4.13"
}

// JMH 는 상태 클래스를 상속해 코드를 생성한다 — final 이면 안 된다
allOpen { annotation("org.openjdk.jmh.annotations.State") }

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-benchmark-runtime:0.4.13")
}

benchmark {
    targets { register("main") }
    configurations {
        named("main") {
            warmups = 3                 // JIT 가 데워질 때까지 버리는 회차
            iterations = 5
            iterationTime = 1
            iterationTimeUnit = "s"
        }
    }
}
// 실행: ./gradlew benchmark  → build/reports/benchmarks/ 에 JSON

벤치마크 클래스 — List 와 Sequence 를 같은 조건으로

import kotlinx.benchmark.*

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(BenchmarkTimeUnit.MICROSECONDS)
class ChainBenchmark {

    @Param("10", "1000", "100000")
    var size: Int = 0

    private lateinit var data: List<Int>

    @Setup
    fun setUp() { data = List(size) { it } }       // 입력은 상태 필드에서 — 상수 접기 방지

    @Benchmark                                     // 반환값을 돌려줘야 데드 코드 제거를 피한다
    fun listFirst(): Int = data.map { it * 2 }.filter { it % 3 == 0 }.first()

    @Benchmark
    fun seqFirst(): Int = data.asSequence().map { it * 2 }.filter { it % 3 == 0 }.first()

    @Benchmark
    fun listSum(): Int = data.map { it * 2 }.filter { it % 3 == 0 }.sum()

    @Benchmark
    fun seqSum(): Int = data.asSequence().map { it * 2 }.filter { it % 3 == 0 }.sum()
}
노트북에서 잰 숫자를 그대로 믿지 마세요 — 절전 모드 · 발열 · 다른 앱이 수십 % 를 흔듭니다. 결과의 오차(±)를 같이 보고, 차이가 오차 안이면 "차이 없음"이 결론입니다.
map → filter → first — 원소 수별 시간 (로그 눈금 · 예시 경향) 100ns 1µs 10µs 100µs 1ms 10ms 10 100 1천 1만 10만 100만 원소 수 → List — 전부 처리 · 중간 리스트 2개 Sequence — 첫 일치에서 멈춤 10개면 List 가 조금 빠름 끊기는 연산(first · take · any)이 있으면 Sequence, 없으면 원소 수를 보고 재 봅니다
라인 차트(예시 경향) — 평평한 선이 Sequence 의 진짜 이점입니다. 전부 소비하는 연산에서는 차이가 작습니다.

판단 기준 — List 냐 Sequence 냐 🟡 중급

상황고르기
원소 수백 개 이하 · 결과를 전부 씀List — 단순하고 충분히 빠름
중간에 끊김 (first · take · any · find)Sequence
단계가 많고 원소가 수만 개 이상Sequence — 중간 리스트 할당 없음
sorted · groupBy · distinct 가 끼어 있음이점 적음 — 어차피 전체를 모아야 함
결과를 여러 번 순회List — Sequence 는 순회할 때마다 다시 계산
원시 값 대량 계산IntArray · LongArray — 박싱 없음
// Sequence 를 두 번 순회하는 실수 — 파일을 두 번 읽는다
val lines = File("big.log").useLines { it.toList() }   // ✅ 한 번 읽어 리스트로
// ❌ val seq = file.bufferedReader().lineSequence()
//    seq.count(); seq.filter { ... }  → 두 번째 순회에서 IllegalStateException

// 원시 값은 배열로 — List<Int> 는 원소마다 Integer 객체
val prices: List<Int> = loadPrices()           // 100만 개 = Integer 100만 개
val arr: IntArray = prices.toIntArray()        // 연속된 int 4 MB
val total = arr.sum()                          // 역참조 없음 · 캐시 친화

// 큰 컬렉션을 만들 때는 크기를 미리
val result = ArrayList<Order>(rows.size)
buildList(rows.size) { rows.forEach { add(it.toOrder()) } }
성능보다 먼저 — 서버 코드의 대부분 시간은 컬렉션 연산이 아니라 DB · 네트워크 대기입니다. 프로파일러로 컬렉션 처리가 실제로 상위에 보일 때만 이 표를 꺼내세요(🔬 전문가 탭 08).
정수 100만 개 합계 — 저장 방식별 시간 (예시 측정) 0 0.5 1.0 1.5 ms IntArray 0.3 ms · 연속된 int value class 인자 (fun f(id: UserId)) 0.3 ms · long 그대로 List<Int> 1.1 ms · Integer 역참조 List<UserId> (value class) 1.2 ms · 제네릭이라 박싱 List<Int?> 1.3 ms · null 검사까지 value class 는 "제네릭 · nullable · 인터페이스 자리"에 들어가는 순간 객체가 됩니다
막대 차트(예시 측정) — 같은 정수라도 담는 그릇이 시간을 4배 바꿉니다. 숫자는 환경마다 다르니 직접 재세요.

실무에선 이렇게 — value class · inline 이 공짜인 자리와 아닌 자리 🔴 실무

value class — 언제 박싱되나

@JvmInline
value class UserId(val raw: Long)

fun load(id: UserId): User = TODO()     // 바이트코드: load-AbC12(long) — 박싱 없음 ✅
val id = UserId(42)                     // 지역 변수도 long

val ids: List<UserId> = listOf(id)      // ❗ 제네릭 → UserId 객체로 박싱
val maybe: UserId? = null               // ❗ nullable → 박싱
val any: Any = id                       // ❗ Any · 인터페이스 자리 → 박싱

// 함수 이름이 바뀌는 것(맹글링)에 주의 — 자바에서 부르기 어렵다
@JvmName("loadById")                    // 자바 호출용 이름을 직접 지정
fun loadJava(id: UserId): User = load(id)

// 실무 판단: '타입 안전'이 목적이면 박싱돼도 쓴다 — 대개 측정 가능한 차이가 아님
// 핫 루프에서 수백만 번이면 LongArray 로 내려간다

JMH 를 써도 틀리는 다섯 가지

실수결과처방
결과를 버림JIT 가 계산을 지워 0 ns반환하거나 Blackhole.consume
상수 입력컴파일 시점에 계산됨@State 필드에서 읽기
워밍업 0인터프리터 속도를 잼warmups = 3 이상
크기 하나만교차점을 놓침@Param 으로 여러 크기
평균만 봄꼬리 지연을 못 봄Mode.SampleTime 으로 분위수
// inline 은 람다 객체를 없앤다 — 대신 호출하는 곳마다 코드가 복사된다
inline fun <T> timed(name: String, block: () -> T): T {
    val t0 = System.nanoTime()
    try { return block() }
    finally { logger.debug { "$name ${(System.nanoTime() - t0) / 1_000}µs" } }
}
// 큰 함수에 inline 을 붙이면 바이트코드가 불어나 오히려 JIT 인라이닝이 막힌다
정리 — 성능 주장은 JMH 숫자와 오차로만 합니다. 끊기는 연산이면 Sequence, 원시 값 대량이면 IntArray, value class 는 제네릭 · nullable · 인터페이스 자리에서 박싱된다는 것만 기억하면 됩니다. JMH 기본 사용법은 🧰 실전 도구 탭(15), 바이트코드 수준은 🔬 전문가 탭(04 · 06)에 있습니다.
QA 13

코드 리뷰 체크리스트 — 이 탭의 함정을 PR 에서 막기

앞의 열두 섹션에 나온 함정은 대부분 한 줄짜리입니다. 그래서 리뷰에서 놓치기 쉽고, 놓치면 운영에서 찾기 어렵습니다. 이 섹션은 그 함정들을 나쁜 예 / 좋은 예로 압축하고, 무엇부터 볼지 우선순위와 배포 전 체크리스트로 묶습니다. PR 템플릿에 그대로 붙여 쓸 수 있게 만들었습니다.

리뷰 우선순위 — 가로는 PR 에서 보이는 빈도, 세로는 놓쳤을 때의 영향 PR 에서 보이는 빈도 → 영향 큼 영향 작음 자주 + 치명 — 리뷰에서 반드시 runCatching 취소 삼킴 GlobalScope 누수 ObjectMapper() 직접 생성 MDC 유실 !! 남용 드물고 가벼움 — 린트에 맡김 아래 두 칸은 detekt 가 잡게 하고, 사람의 눈은 위 두 칸에 씁니다
사분면 — 오른쪽 위 칸만 사람이 반드시 봅니다. 나머지는 도구(11)로 넘겨 리뷰 피로를 줄입니다.

나쁜 예 / 좋은 예 — 한 줄짜리 함정 여섯 🟡 중급

① 취소를 삼킴 · ② 수명 없는 코루틴
val r = runCatching { api.fetch(id) }          // ①
try { save() } catch (e: Exception) { }        // ①

GlobalScope.launch { sync() }                  // ②
CoroutineScope(Dispatchers.IO).launch { }      // ② 아무도 cancel 안 함
launch(SupervisorJob()) { notify() }           // ② 부모 연결 끊김
취소 재던짐 · 주입된 스코프
val r = runSuspendCatching { api.fetch(id) }   // qa07 유틸
try { save() } catch (e: IOException) { ... }  // 좁은 예외만

appScope.launch(MDCContext()) { sync() }       // 앱 수명 빈 (qa08)
supervisorScope { launch { notify() } }        // 구조 유지
③ 로그 비용 · 비밀 노출 · ④ MDC 유실
slf.debug("req=${req.toJson()}")               // ③ 꺼져도 직렬화
logger.info { "로그인 $loginRequest" }          // ③ data class → 비밀번호 출력

withContext(Dispatchers.IO) { logger.info { "조회" } }   // ④ traceId 없음
launch { MDC.put("orderId", "$id") }           // ④ 다음 재개 때 사라짐
람다 · toString 재정의 · MDCContext
logger.debug { "req=${req.toJson()}" }         // 꺼지면 실행 안 됨
data class LoginRequest(val id: String, val pw: String) {
    override fun toString() = "LoginRequest(id=$id, pw=***)"
}
withContext(Dispatchers.IO + MDCContext()) { logger.info { "조회" } }
withMdc("orderId" to "$id") { pay(order) }     // qa06 유틸
⑤ null 정책 · ⑥ 호환성
val user = repo.findById(id)!!                 // ⑤ 이유 없는 NPE
data class Order(@Id val id: Long, ...)        // ⑤ data class 엔티티

val mapper = ObjectMapper()                    // ⑥ Boot 설정과 다름
data class Event(val id: Long, val source: String)   // ⑥ 새 필드인데 기본값 없음
이유 있는 실패 · 기본값 · 주입된 매퍼
val user = repo.findById(id) ?: throw UserNotFound(id)
class Order(...) { var id: Long? = null; /* equals 는 id 로 */ }

class EventConsumer(private val mapper: ObjectMapper)   // 빈 주입
data class Event(val id: Long, val source: String = "unknown")

리뷰 체크리스트 — PR 템플릿에 그대로 🔴 실무

영역확인할 것근거
코루틴suspend 안의 runCatching · catch (Exception) 이 취소를 되던지는가07
코루틴새 스코프에 소유자와 cancel 시점이 있는가 · GlobalScope 0건08
코루틴블로킹 호출이 Default · 요청 스레드에 없는가 · 자원별 한도가 있는가08
로깅메시지는 람다 · 예외는 첫 인자 · 경계에서 한 번 · 민감 필드 마스킹05
로깅스레드를 넘는 곳에 MDCContext · 외부 호출에 추적 헤더06
null자바 경계는 nullable 로 · !! 는 이유 있는 대안으로 · 엔티티 타입 = DB 제약09
직렬화새 필드에 기본값 · 이름 변경은 두 단계 · 골든 파일 갱신10
테스트실패 경로 테스트가 있는가 · 실제 시간 · 공유 상태 의존이 없는가01~04
체크리스트는 고정된 문서가 아니라 장애마다 한 줄씩 자랍니다 리뷰 체크리스트 CI 게이트 detekt · 테스트 카나리 배포 5% → 100% 관측 로그 · 지표 사고 1건 = 규칙 한 줄 + 테스트 하나 규칙이 자동화되면 리뷰에서 CI 로 옮긴다 사후 검토의 결론이 "조심하자"면 다시 난다
순환 — 관측에서 리뷰로 돌아오는 화살표가 없으면 같은 장애가 반복됩니다.

배포 전 체크리스트

항목
☐CI 전부 초록 · 불안정 테스트를 재실행으로 통과시키지 않았다
☐DB 마이그레이션이 이전 버전 앱과도 호환된다 (컬럼 추가 → 배포 → 사용)
☐메시지 · API 응답 모양 변경이 있으면 소비자가 먼저 배포됐다
☐새 설정 값 · 비밀이 운영 환경에 들어가 있다 (checkNotNull 로 기동 시 검증)
☐새 기능의 성공 · 실패가 로그 필드와 지표로 보인다
☐그레이스풀 셧다운 — 앱 스코프 cancel · 진행 중 요청 마무리 시간
☐롤백 방법과 기준(에러율 · p99)을 정해 두었다

.github/pull_request_template.md

## 무엇을 · 왜

## 확인한 것
- [ ] suspend 코드의 예외 처리가 취소를 되던진다 (runSuspendCatching)
- [ ] 새 CoroutineScope 의 소유자와 cancel 시점이 있다
- [ ] 블로킹 호출은 전용 디스패처에서 돈다
- [ ] 로그: 람다 · 예외 첫 인자 · 민감정보 없음 · MDCContext
- [ ] null: 자바 경계 nullable · !! 없음 · 엔티티 타입 = DB 제약
- [ ] JSON: 새 필드 기본값 · 계약 파일 갱신
- [ ] 실패 경로 테스트 추가

## 배포 영향
- 마이그레이션: 없음 / 있음(하위 호환 확인)
- 메시지 · API 모양 변경: 없음 / 있음(소비자 선배포)
체크박스는 짧게 — 항목이 20개를 넘으면 아무도 읽지 않고 전부 체크합니다. 자동화된 항목은 템플릿에서 지우고 CI 로 옮기는 것이 체크리스트를 살아 있게 하는 방법입니다.
정리 — 리뷰어의 눈은 취소 삼킴 · 수명 없는 스코프 같은 "자주 + 치명" 칸에 쓰고, 나머지는 detekt · ktlint · 계약 테스트가 막게 합니다. 체크리스트는 사고가 날 때마다 한 줄 + 테스트 하나로 자라야 하고, 자동화된 줄은 템플릿에서 CI 로 옮겨 갑니다. 이 탭의 열두 섹션이 그 체크리스트의 출발점입니다.
KOTLIN 2.2 (K2) · JVM 21+ · Android · ArrayDeque · PriorityQueue · BlockingQueue · Channel

스택도 큐도
ArrayDeque
하나로 끝난다

코틀린에는 Stack 클래스도 Queue 인터페이스도 따로 없습니다. 대신 표준 라이브러리의 kotlin.collections.ArrayDeque 하나가 스택 · 큐 · 덱을 모두 맡고, 우선순위 큐와 스레드 안전 큐는 JVM 의 java.util · java.util.concurrent를, 코루틴 사이의 큐는 Channel을 씁니다. 이 탭은 2026 년 코틀린 현업이 실제로 쓰는 타입과 관용구, 내부 구조, 그리고 JDK 21 이후 생긴 removeFirst() · removeLast() 함정까지 — 코드를 실제로 컴파일해 돌린 출력과 함께 다룹니다.

12SECTIONS
2.2Kotlin 기준 (K2)
JDK 21· Android API 35 함정
100%컴파일해 돌린 예제
SQ 01

한 장 지도 — 코틀린에서 무엇으로 쓰나

자바 개발자가 코틀린에서 처음 헤매는 곳이 여기입니다. Stack 을 쓰려니 낡았다고 하고, java.util.ArrayDeque 를 쓰려니 코틀린에도 ArrayDeque 가 있습니다. 결론부터 표 한 장과 그림 한 장으로 정리하고, 나머지 섹션에서 하나씩 뜯어 봅니다.

2026 권장 vs 쓰지 말 것

용도권장쓰지 말 것 (이유)
스택ArrayDeque
addLast · removeLast
java.util.Stack — Vector 상속, 모든 메서드 synchronized, 중간 삽입 가능
큐ArrayDeque
addLast · removeFirstOrNull
LinkedList — 원소마다 노드 객체 · ArrayList.removeAt(0) — O(n)
덱ArrayDeque (코틀린 것)— java.util.ArrayDeque 도 괜찮지만 JVM 전용 · null 금지
우선순위java.util.PriorityQueue리스트에 넣고 매번 sort() — O(n log n) 반복
스레드 간ArrayBlockingQueue · 용량 준 LinkedBlockingQueue · ConcurrentLinkedQueueVector · synchronizedList 로 직접 만든 큐
코루틴 간Channel(용량) · Flow.buffer코루틴 안에서 BlockingQueue.take() — 스레드를 막는다
불변 상태PersistentList (kotlinx)큰 리스트를 매번 toMutableList() 로 통째 복사
외울 것은 하나 — 단일 스레드라면 스택도 큐도 덱도 ArrayDeque입니다. 스택이냐 큐냐는 타입이 아니라 어느 끝에서 꺼내느냐로 정해집니다.

다섯 가지를 한 파일에서 — 그대로 실행됩니다

import java.util.PriorityQueue
import java.util.concurrent.ArrayBlockingQueue
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    // ① 스택 — 한쪽 끝에서 넣고 같은 끝에서 뺀다 (kotlin.collections.ArrayDeque · import 불필요)
    val stack = ArrayDeque<String>()
    stack.addLast("a"); stack.addLast("b"); stack.addLast("c")
    println(stack.removeLast())            // c
    println(stack.lastOrNull())            // b

    // ② 큐 — 같은 타입, 반대쪽 끝에서 뺀다
    val queue = ArrayDeque(listOf("첫째", "둘째"))
    println(queue.removeFirstOrNull())     // 첫째

    // ③ 우선순위 큐 — 표준 라이브러리에 없다. JVM 의 java.util.PriorityQueue
    val pq = PriorityQueue<Int>(compareByDescending { it })
    pq.addAll(listOf(3, 9, 1))
    println(pq.poll())                     // 9

    // ④ 스레드 사이 — 크기 제한이 있는 블로킹 큐
    val bq = ArrayBlockingQueue<Int>(2)
    println(listOf(bq.offer(1), bq.offer(2), bq.offer(3)))   // [true, true, false]

    // ⑤ 코루틴 사이 — Channel (멈추는 대신 suspend)
    val ch = Channel<Int>(capacity = 2)
    launch { for (i in 1..3) ch.send(i); ch.close() }
    for (x in ch) print("$x ")             // 1 2 3
    println()
}

코루틴 부분은 kotlinx-coroutines-core 의존성이 필요합니다(설정은 KOTLIN 01). 나머지는 JDK 와 코틀린 표준 라이브러리만으로 돕니다.

무엇을 쓸까 — 마름모가 질문, 초록 상자가 답 여러 스레드 · 코루틴이 함께 쓰나? 예 코루틴 코드인가? (suspend 함수) 예 Channel(용량) SQ 07 · send / receive 아니오 (스레드) ArrayBlockingQueue SQ 06 · put / take 아니오 (혼자 씀) 우선순위 순서로 꺼내나? 예 java.util.PriorityQueue SQ 05 · KMP 면 직접 힙 아니오 넣은 쪽에서 다시 꺼내나? 예 (LIFO) ArrayDeque — 스택 addLast · removeLast 아니오 (FIFO) ArrayDeque — 큐 addLast · removeFirst 스택 · 큐 두 답은 같은 타입 — kotlin.collections.ArrayDeque 양쪽 끝을 다 쓰면 그대로 덱(deque)이 됩니다 java.util.Stack · LinkedList 는 어느 갈래에도 없습니다
질문 네 개면 끝납니다 — 혼자 쓰면 ArrayDeque, 나눠 쓰면 BlockingQueue 나 Channel.

ArrayDeque 가 둘 — 코틀린 것과 자바 것

무엇이 다른가

항목kotlin.collectionsjava.util
import필요 없음 (기본 import)java.util.ArrayDeque
플랫폼JVM · Android · Native · JS · Wasm (KMP 공통)JVM · Android 만
인터페이스MutableList — d[i] 인덱스 접근 O(1)Deque — 인덱스 접근 없음
null 원소허용 (ArrayDeque<String?>)금지 — 넣는 순간 NPE
빈 덱에서 꺼내기removeFirst() 예외 · removeFirstOrNull() nullremoveFirst() 예외 · pollFirst() null
스택 이름없음 — addLast/removeLastpush/pop (= 앞쪽 끝)
첫 용량 · 증가10 · 1.5배16(+1) · 작을 땐 약 2배, 64 이상 1.5배
스레드 안전아님아님

언제 자바 것을 쓰나

기본은 코틀린 것입니다. 공통 코드(KMP)에서도 그대로 쓰이고, MutableList 라서 map · filter · joinToString 같은 컬렉션 함수가 전부 붙습니다 (KOTLIN 14). 성능은 둘이 거의 같습니다(SQ 09 실측).

자바 것을 고르는 경우는 자바 API 가 Deque<T> 를 요구할 때, 또는 descendingIterator() · removeFirstOccurrence() 같은 Deque 전용 메서드가 필요할 때 정도입니다.

조용한 바꿔치기 — 파일 위에 import java.util.* 가 있으면 ArrayDeque<Int>() 가 경고 없이 java.util.ArrayDeque로 해석됩니다 (명시적 · 와일드카드 import 가 기본 import 보다 우선). 실제로 찍어 보면 java.util.ArrayDeque 가 나옵니다. 그 순간부터 d[0] 은 컴파일 에러, addLast(null) 은 런타임 NPE 입니다. 와일드카드 import 를 쓰지 않는 습관이 답입니다.

불변 상태에 담는 스택 — kotlinx.collections.immutable

// build.gradle.kts — implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:<최신>")
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf

data class UiState(val history: PersistentList<String> = persistentListOf())

fun UiState.push(page: String) = copy(history = history.add(page))          // 새 리스트 · 원본 그대로
fun UiState.pop() = copy(history = history.removeAt(history.lastIndex))     // 끝에서 빼기도 새 리스트

// StateFlow 에 넣는 '불변 스택' — 이전 상태와 구조를 공유하므로 통째 복사보다 싸다
val s0 = UiState()
val s1 = s0.push("home").push("detail")
val s2 = s1.pop()
// s1.history == [home, detail] · s2.history == [home] · s0 는 여전히 비어 있다

언제 필요한가

Compose · StateFlow 처럼 상태를 통째로 바꿔 끼우는 구조에서는 ArrayDeque 를 고쳐 쓰면 안 됩니다(같은 객체라 변경이 감지되지 않습니다). 작은 리스트면 history + page · history.dropLast(1) 로 새로 만들면 충분하고, 크고 자주 바뀌면 구조를 공유하는 영속 리스트가 복사 비용을 줄여 줍니다.

범위 — 영속 큐 · 덱 전용 타입은 이 라이브러리에 없습니다. 앞쪽에서 빼는 일이 많은 큐라면 불변 상태 안에 넣지 말고, 큐는 바깥(ViewModel 내부 등)에 두고 결과만 상태로 내보내세요. 컬렉션 기초 — 읽기 전용과 가변의 차이는 KOTLIN 13.
정리 — 단일 스레드는 kotlin.collections.ArrayDeque, 우선순위는 java.util.PriorityQueue, 스레드 사이는 BlockingQueue, 코루틴 사이는 Channel. Stack 과 LinkedList 는 새 코드에 쓰지 않습니다.
SQ 02

스택 — ArrayDeque 의 한쪽 끝, 그리고 JDK 21 함정

스택은 넣은 쪽에서 다시 꺼내는 구조입니다. 코틀린에는 push · pop 이라는 이름이 없고, ArrayDeque 의 addLast · removeLast · last 가 그 역할을 합니다. 이 섹션의 후반부는 안드로이드 앱을 실제로 죽이는 removeLast() 함정입니다.

기본 — push · pop · peek

fun main() {
    val stack = ArrayDeque<Int>()
    stack.addLast(1)                       // push
    stack.addLast(2)
    stack.addLast(3)
    println(stack)                         // [1, 2, 3]  ← 왼쪽이 바닥, 오른쪽이 꼭대기
    println(stack.last())                  // 3          peek
    println(stack.removeLast())            // 3          pop
    println(stack.removeLastOrNull())      // 2
    println(stack.removeLastOrNull())      // 1
    println(stack.removeLastOrNull())      // null       빈 스택 — 예외 대신 null

    val top = stack.lastOrNull() ?: -1     // 엘비스로 기본값
    println(top)                           // -1
    // stack.removeLast()                  // 💥 NoSuchElementException: ArrayDeque is empty.
}

자바 이름과의 대응

동작자바 Deque코틀린 ArrayDeque
pushpush(e) = addFirstaddLast(e)
poppop() — 비면 예외removeLast() — 비면 예외
pop (안전)pollFirst() → nullremoveLastOrNull() → null
peekpeek() → nulllastOrNull() → null
비었나isEmpty()isEmpty() · isNotEmpty()
왜 Last 쪽인가 — addFirst · removeFirst 로 써도 똑같이 O(1) 입니다. 다만 addLast 로 쌓으면 println · 디버거 · for 순회가 바닥 → 꼭대기 순서로 보여서 Stack 시절의 눈과 같고, d[i] 의 i 가 "아래에서 i 번째"가 됩니다. 팀에서 한쪽으로 통일하는 게 핵심입니다.
스택 스냅숏 — 아래가 바닥, 오른쪽 끝(last)이 꼭대기 1 2 3 4 5 1 1 2 1 2 3 1 2 3 반환 1 2 보기만 addLast(1) addLast(2) addLast(3) removeLast() lastOrNull() println(stack) 은 [1, 2] — 왼쪽이 바닥입니다. 빈 스택이면 lastOrNull() 이 null
꼭대기 하나만 건드립니다 — 넣기 · 빼기 · 보기 전부 O(1).

java.util.Stack 이 버려진 이유 — 실제로 돌려 보면

import java.util.Stack

fun main() {
    val old = Stack<Int>()                  // 레거시 — Vector 를 상속
    old.push(1); old.push(2); old.push(3)

    val jdq = java.util.ArrayDeque<Int>()   // 자바식 — push 는 addFirst 다
    jdq.push(1); jdq.push(2); jdq.push(3)

    val kdq = ArrayDeque<Int>()             // 코틀린식 — addLast 로 쌓는다
    kdq.addLast(1); kdq.addLast(2); kdq.addLast(3)

    println(old)        // [1, 2, 3]   바닥 → 꼭대기
    println(jdq)        // [3, 2, 1]   꼭대기 → 바닥  ← 순회 순서가 반대!
    println(kdq)        // [1, 2, 3]   바닥 → 꼭대기

    println(listOf(old.pop(), jdq.pop(), kdq.removeLast()))   // [3, 3, 3]  pop 결과는 셋 다 같다

    old.add(1, 99)      // Vector 라서 '중간 삽입'이 된다 — 스택 규칙이 깨진다
    println(old)        // [1, 99, 2]
}

세 가지 결함

결함무슨 뜻인가
Vector 상속JDK 1.0 시절 설계. 모든 메서드가 synchronized — 혼자 쓰는데도 잠금 비용을 냅니다
스택이 아니다add(i, e) · remove(i) · set 이 열려 있어 중간을 고칠 수 있습니다. "상속으로 재사용"의 대표적 실패 사례
스레드 안전도 아니다"empty() 확인 후 pop()" 은 두 호출 사이가 열려 있어 경쟁 조건이 그대로입니다
순회 순서 주의 — 자바 ArrayDeque.push 는 앞에 넣으므로 출력이 [3, 2, 1] 입니다. Stack 을 걷어 내고 java.util.ArrayDeque.push 로 옮기면 pop 결과는 같아도 for · toString · toList() 순서가 뒤집힙니다. 코틀린 ArrayDeque 에 addLast 로 쌓으면 Stack 과 같은 순서라 이 문제가 없습니다.

스택 '타입'이 필요할 때 — 감싸서 끝 하나만 노출

// 스택 '타입'이 필요하면 감싸서 끝 하나만 노출한다
class Stack<T> {
    private val items = ArrayDeque<T>()

    val size: Int get() = items.size
    fun isEmpty(): Boolean = items.isEmpty()

    fun push(item: T) = items.addLast(item)
    fun pop(): T = if (items.isEmpty()) throw NoSuchElementException("빈 스택") else items.removeLast()
    fun peek(): T = if (items.isEmpty()) throw NoSuchElementException("빈 스택") else items.last()

    // T 가 nullable 이어도 뜻이 섞이지 않게 '비었는지'를 먼저 본다
    fun popOrNull(): T? = if (items.isEmpty()) null else items.removeLast()

    override fun toString() = items.asReversed().joinToString(prefix = "top→[", postfix = "]")
}

fun main() {
    val s = Stack<String>()
    s.push("a"); s.push("b"); s.push("c")
    println(s)              // top→[c, b, a]
    println(s.pop())        // c
    println(s.peek())       // b
    println(s.size)         // 2
    println(Stack<Int>().popOrNull() ?: "비었음")   // 비었음
}

감쌀까, 그냥 쓸까

함수 안에서 잠깐 쓰는 스택(DFS · 괄호 검사)은 ArrayDeque 를 그대로 씁니다. 감싸는 건 여러 클래스가 공유하는 스택(실행 취소 기록, 화면 백스택)처럼 "중간을 못 고치게" 보장하고 싶을 때입니다.

T 가 nullable 일 때 — items.removeLastOrNull() ?: throw … 로 쓰면 null 원소를 꺼낸 것과 비어 있는 것이 구분되지 않습니다. 위 코드처럼 isEmpty() 를 먼저 보세요. T : Any 로 제한하는 것도 방법입니다.

★ JDK 21 이후의 함정 — MutableList.removeLast() 가 앱을 죽인다

코틀린 표준 라이브러리에는 오래전부터 MutableList<T>.removeFirst() · removeLast() 확장 함수가 있었습니다. 그런데 JDK 21 (JEP 431 Sequenced Collections) 이 java.util.List 에 같은 이름의 멤버 메서드를 추가했습니다. 코틀린은 멤버가 확장보다 우선하므로, 컴파일할 때 쓰는 JDK(안드로이드는 compileSdk 의 android.jar)에 이 메서드가 보이면 같은 소스가 자바 메서드 호출로 컴파일됩니다. 그 바이트코드를 메서드가 없는 런타임에서 돌리면 — NoSuchMethodError.

같은 한 줄, 다른 바이트코드 — 무엇으로 컴파일했느냐가 뜻을 정합니다 list.removeLast() MutableList 에 호출 JDK 17 로 컴파일 List 에 removeLast 없음 JDK 21+ · SDK 35+ List.removeLast() 멤버 있음 CollectionsKt.removeLast(list) stdlib 확장 함수 — 어느 런타임에서나 동작 java/util/List.removeLast() JDK 21 · Android API 35 부터 있는 메서드 API 34 이하 기기 · JDK 17 JVM → NoSuchMethodError 소스는 한 글자도 안 바뀌었는데 컴파일 환경을 올린 순간 구형 기기에서만 크래시가 납니다
멤버가 확장을 이깁니다 — 컴파일 JDK · compileSdk 를 올리면 호출 대상이 바뀝니다.

직접 확인한 바이트코드 (Kotlin 2.3 컴파일러)

# 소스: val l = mutableListOf(1, 2, 3); l.removeLast()

# JDK 17 로 컴파일 → javap -c
invokestatic  kotlin/collections/CollectionsKt.removeLast:(Ljava/util/List;)Ljava/lang/Object;

# JDK 21 로 컴파일 → javap -c
invokeinterface java/util/List.removeLast:()Ljava/lang/Object;

# JDK 21 로 만든 jar 를 JDK 17 로 실행
Exception in thread "main" java.lang.NoSuchMethodError:
  'java.lang.Object java.util.List.removeLast()'

# JDK 21 + -Xjdk-release=17 로 컴파일 → 다시 CollectionsKt.removeLast 로 돌아온다

이 탭을 쓰며 실제로 컴파일 · 실행해 본 결과입니다. 대조로 removeFirstOrNull() · kotlin.collections.ArrayDeque.removeFirst() 는 어느 JDK 에서든 코틀린 쪽으로 컴파일됐습니다 (JDK 에 같은 이름이 없거나, ArrayDeque 자신의 멤버이기 때문).

대책 — 코드 · 빌드 · 린트

// build.gradle.kts — JVM 서버: '실행할 JDK' 기준으로 API 를 고정한다
kotlin {
    jvmToolchain(17)                 // ① 가장 단순: 실행 JDK 와 같은 JDK 로 컴파일

    // ② JDK 21 로 컴파일하면서 17 에서 돌려야 한다면 — API 를 17 로 제한
    // compilerOptions {
    //     jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
    //     freeCompilerArgs.add("-Xjdk-release=17")   // javac 의 --release 와 같은 역할
    // }
}

// Android: compileSdk 35+ 는 피할 수 없다(스토어 요구). 그래서 '코드'를 바꾼다
//   list.removeFirst()  →  list.removeAt(0)
//   list.removeLast()   →  list.removeAt(list.lastIndex)
//   또는                 →  removeFirstOrNull() / removeLastOrNull()   (코틀린 전용 이름)
안드로이드는 린트가 잡아 줍니다 — minSdk 가 35 미만이면 List#removeFirst 호출에 NewApi 경고(API 35 필요)가 뜹니다. 경고를 무시하지 말고, CI 에서 린트를 에러로 올려 두세요. getFirst() · reversed() 같은 다른 JDK 21 메서드는 코틀린 컴파일러가 숨기거나 코틀린 쪽으로 해석해 이번 확인 범위에서는 문제가 removeFirst · removeLast 두 개였습니다.
fun main() {
    // MutableList 를 스택으로 — 이미 List 로 받은 값을 잠깐 스택처럼 쓸 때
    val path = mutableListOf("home", "docs", "2026")

    path.add("report.pdf")                  // push
    println(path.last())                    // report.pdf   (코틀린 확장 — 안전)
    println(path.removeAt(path.lastIndex))  // report.pdf   ✅ 모든 JDK·안드로이드에서 같은 바이트코드
    println(path.removeLastOrNull())        // 2026         ✅ JDK 에 같은 이름이 없다 → 늘 코틀린 확장
    // path.removeLast()                    // ⚠️ 아래 '함정' 참고 — 컴파일 JDK 에 따라 뜻이 바뀐다
    println(path)                           // [home, docs]
}
정리 — 스택은 ArrayDeque 의 addLast · removeLast · lastOrNull. 일반 MutableList 에서는 removeLast() 대신 removeAt(lastIndex) · removeLastOrNull() — 컴파일 환경에 따라 뜻이 바뀌는 호출을 코드에서 없애는 것이 가장 확실합니다.
SQ 03

큐(FIFO) — 뒤로 넣고 앞에서 꺼낸다

큐도 같은 ArrayDeque 입니다. addLast 로 넣고 removeFirst 로 꺼냅니다. 자바에는 "실패하면 예외 / 특수값" 메서드가 쌍으로 있었는데, 코틀린은 이것을 OrNull 접미사와 엘비스 연산자로 표현합니다.

기본 — 넣기 · 꺼내기 · 비우기

fun main() {
    val queue = ArrayDeque<String>()
    queue.addLast("A")                      // enqueue  (자바 offer)
    queue.addLast("B")
    queue.addLast("C")
    println(queue.firstOrNull())            // A        peek
    println(queue.removeFirst())            // A        dequeue (자바 poll/remove)
    println(queue)                          // [B, C]

    // 빈 큐 처리 — 코틀린은 'OrNull + 엘비스' 가 관용구
    val next = queue.removeFirstOrNull() ?: "없음"
    println(next)                           // B

    // 다 비울 때까지 — while(true) + ?: break
    while (true) {
        val job = queue.removeFirstOrNull() ?: break
        println("처리 $job")                 // 처리 C
    }
    println(queue.removeFirstOrNull() ?: "없음")   // 없음
}

처리 중에 일이 늘어나는 큐

fun main() {
    // 처리하면서 새 일이 생기는 큐 — generateSequence 가 매번 다시 꺼낸다
    val queue = ArrayDeque(listOf(1))
    generateSequence { queue.removeFirstOrNull() }
        .forEach { n ->
            print("$n ")
            if (n < 8) { queue.addLast(n * 2); queue.addLast(n * 2 + 1) }
        }
    println()   // 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
}

generateSequence { … } 는 요소가 필요할 때마다 람다를 다시 부르고, null 이 나오면 멈춥니다. 그래서 처리 도중 큐에 넣은 것도 이어서 꺼내집니다. BFS 의 뼈대가 이 모양입니다(SQ 08).

for (x in queue) 안에서 큐에 넣으면 안 됩니다 — ConcurrentModificationException. "꺼내면서 넣는" 처리는 반드시 while + removeFirstOrNull() 이나 위의 시퀀스로 씁니다.
같은 동작, 네 가지 이름 — 열은 "실패를 어떻게 알리나" 자바 · 예외 자바 · 특수값 코틀린 · 예외 코틀린 · null 넣기 add(e) offer(e) addLast(e) addLast(e) 빼기 remove() poll() removeFirst() removeFirstOrNull() 엿보기 element() peek() first() firstOrNull() 빈 큐에서 예외 null 예외 null → ?: 로 처리 "비어 있을 수 있다"가 정상이면 오른쪽 열, "비면 버그"면 예외 열을 고릅니다 자바 offer 의 false(가득 참)는 유한 큐에만 있습니다 — ArrayDeque 는 안 찹니다 진한 칸 = 코틀린 ArrayDeque 이름 · 흐린 칸 = 자바 Queue 인터페이스 이름
코틀린다운 쪽은 오른쪽 끝 열 — OrNull 로 받고 엘비스로 끝낸다.

자바 Queue 를 코틀린에서 받을 때 — 플랫폼 타입

import java.util.LinkedList
import java.util.Queue

fun main() {
    // 자바 Queue 인터페이스를 코틀린에서 — 반환형이 '플랫폼 타입(String!)'이다
    val q: Queue<String> = LinkedList()

    val a: String? = q.poll()       // ✅ nullable 로 받는다 → null
    println(a ?: "비었음")           // 비었음

    try {
        val b: String = q.poll()    // ⚠️ 컴파일은 된다 — 실행하면 그 자리에서 NPE
        println(b)
    } catch (e: NullPointerException) {
        println(e.message)          // poll(...) must not be null
    }
    // q.offer(null)                // 컴파일 에러 — Queue<String> 이라 null 을 못 넣는다
}

왜 컴파일이 되는가

자바 메서드의 반환형은 null 여부를 모르는 플랫폼 타입(String!)입니다. 코틀린은 이것을 String 에도 String? 에도 넣게 해 주고, String 에 넣으면 그 자리에 null 검사를 끼워 넣습니다. 비어 있는 큐의 poll() 은 null 이므로 즉시 NPE 입니다.

규칙 — 자바 큐의 poll() · peek() · BlockingQueue.poll(timeout) 결과는 항상 T? 로 받으세요. 플랫폼 타입 전반은 KOTLIN 18.

큐로 쓰면 안 되는 것

ArrayList 를 큐로
val q = mutableListOf<Job>()      // ArrayList
q.add(job)
val next = q.removeAt(0)           // ❌ 뒤의 원소를 전부 한 칸씩 당긴다 — O(n)
// 1,000 개를 유지하며 500만 번 돌리면
// ArrayDeque 약 25 ms vs ArrayList 약 700 ms (SQ 09 실측)

val n2 = q.removeFirst()           // ❌❌ O(n) 이면서 SQ 02 의 JDK 21 함정까지
ArrayDeque
val q = ArrayDeque<Job>()
q.addLast(job)
val next = q.removeFirstOrNull()   // ✅ head 인덱스만 한 칸 — O(1)

// 공개 API 의 파라미터 타입도 ArrayDeque 로.
// JVM 전용 코드에서 자바 쪽과 섞을 때만
// java.util.Queue<T> 를 씁니다.
정리 — 큐 = addLast + removeFirstOrNull() ?: …. 꺼내면서 넣는 루프는 while 이나 generateSequence 로, 자바 큐의 반환값은 T? 로 받습니다.
SQ 04

덱의 속 — 원형 배열 · head · size

"양쪽 끝에서 O(1)" 이 어떻게 가능한지 알면 언제 빠르고 언제 느린지가 저절로 보입니다. kotlin.collections.ArrayDeque 의 실제 소스(Kotlin 2.x stdlib)를 따라 배열 하나 · 시작 인덱스 head · 원소 수 size 세 필드로 덱이 굴러가는 모습을 봅니다.

필드는 세 개뿐

// kotlin/collections/ArrayDeque.kt 에서 발췌·요약
public class ArrayDeque<E> : AbstractMutableList<E> {
    private var head: Int = 0                 // 첫 원소가 있는 칸
    private var elementData: Array<Any?>      // 원형으로 쓰는 배열
    override var size: Int = 0                // 원소 수 (tail 필드는 없다)

    // i 번째 원소의 실제 칸 = (head + i) 를 배열 길이로 감는다
    // positiveMod: if (idx >= len) idx - len else idx   ← % 연산 대신 비교
    public fun addFirst(element: E) {
        ensureCapacity(size + 1)
        head = decremented(head)              // 0 이면 맨 끝 칸으로 감긴다
        elementData[head] = element
        size += 1
    }
    public fun removeFirst(): E {
        if (isEmpty()) throw NoSuchElementException("ArrayDeque is empty.")
        val element = elementData[head]
        elementData[head] = null              // 참조를 지워 GC 가 가져가게
        head = incremented(head)
        size -= 1
        return element as E
    }
}

리플렉션으로 속을 들여다보기

// ⚠️ 학습용 — 리플렉션으로 내부 배열을 들여다본다. 실무 코드에서는 절대 쓰지 마세요
private fun ArrayDeque<*>.peekInside(): String {
    val f = ArrayDeque::class.java.getDeclaredField("elementData").apply { isAccessible = true }
    val h = ArrayDeque::class.java.getDeclaredField("head").apply { isAccessible = true }
    val arr = f.get(this) as Array<*>
    return "cap=${arr.size} head=${h.get(this)} size=$size  " + arr.joinToString(" ") { it?.toString() ?: "·" }
}

fun main() {
    val d = ArrayDeque<Int>()
    println(d.peekInside())                 // cap=0 head=0 size=0     ← 첫 add 전에는 배열도 없다
    for (i in 1..7) d.addLast(i)
    println(d.peekInside())                 // cap=10 head=0 size=7  1 2 3 4 5 6 7 · · ·
    repeat(5) { d.removeFirst() }
    println(d.peekInside())                 // cap=10 head=5 size=2  · · · · · 6 7 · · ·
    for (i in 8..12) d.addLast(i)
    println(d.peekInside())                 // cap=10 head=5 size=7  11 12 · · · 6 7 8 9 10  ← 끝을 넘어 0번으로 감김
    d.addFirst(0)
    println(d.peekInside())                 // cap=10 head=4 size=8  11 12 · · 0 6 7 8 9 10
    println(d)                              // [0, 6, 7, 8, 9, 10, 11, 12]  ← 바깥에서는 그냥 순서대로
    println(d[6])                           // 11  인덱스 접근 O(1): elementData[(head + 6) % cap]

    val caps = mutableListOf<Int>()
    val g = ArrayDeque<Int>()
    repeat(60) {
        g.addLast(it)
        val c = g.peekInside().substringAfter("cap=").substringBefore(" ").toInt()
        if (caps.lastOrNull() != c) caps += c
    }
    println(caps)                           // [10, 15, 22, 33, 49, 73]
}
elementData — 칸 번호(위)와 들어 있는 값, cap=10 · head=4 · size=8 0 1 2 3 4 5 6 7 8 9 11 12 null null 0 6 7 8 9 10 head = 4 (d[0]) 다음 addLast 칸 (head+size)−cap = 2 9번 다음은 0번 — 끝이 처음으로 감긴다 용량이 차면 → 1.5배(15칸) 새 배열에 논리 순서대로 펴서 복사, head = 0 0 6 7 8 9 10 11 12 cap=15 removeFirst 는 head 를 한 칸 옮기고 그 칸을 null 로 — 복사가 없어 O(1) 점선 칸은 빈자리입니다. 줄어들지는 않습니다 (shrink 없음)
원형 배열 — head 와 size 만 움직이고 원소는 제자리. 넘칠 때만 펴서 복사합니다.

연산별 비용 — 공식 소스 기준

연산비용이유
addFirst · addLast분할 상환 O(1)평소 O(1), 꽉 차면 한 번 O(n) 복사 — 1.5배씩 늘어 평균은 상수
removeFirst · removeLastO(1)head 또는 size 만 바뀜
first() · last() · d[i] · d[i] = xO(1)(head + i) 를 감아 바로 접근
add(i, x) · removeAt(i)O(min(i, n−i))가까운 쪽 끝의 원소들을 민다 (소스의 index < (size + 1) shr 1 분기)
contains · indexOf · remove(x)O(n)처음부터 훑는다
clear()O(n)칸을 null 로 지운다 (배열 크기는 그대로)
"분할 상환 O(1)" — 어떤 한 번의 addLast 는 O(n) 일 수 있지만, n 번을 합치면 O(n) 이라 한 번 평균이 상수라는 뜻입니다. 지연 한계가 엄격한 곳(게임 프레임, 오디오 콜백)이면 ArrayDeque(예상 최대치) 로 미리 잡아 재할당 자체를 없앱니다.

자바 ArrayDeque 는 어떻게 다른가 (JDK 21 소스)

항목java.util.ArrayDeque
필드head · tail 두 인덱스. size 필드는 없고 계산
빈칸 규칙tail 칸은 항상 null — 그래서 기본 17칸에 16개까지
증가64 미만이면 + (old + 2)(약 2배), 이상이면 + old/2 — 17 → 36 → 74 → 111
역사JDK 8 까지 2의 거듭제곱 용량 + 비트 마스크(& (len − 1)), JDK 9 재작성 뒤 임의 용량 + 비교 분기
null금지 — 빈칸 판별에 null 을 쓰기 때문

자바 것이 null 을 금지하는 이유가 여기 있습니다. "칸이 null 이면 빈칸"이라는 규칙으로 꽉 참과 빔을 가르므로, 원소로 null 이 들어오면 구조가 깨집니다. 코틀린 것은 size 를 따로 들고 있어서 null 원소를 허용합니다.

원소 수(가로)에 따른 배열 용량(세로) — 계단 한 번이 전체 복사 한 번 0 50 100 150 0 50 100 원소 수 size (점선) 49 36 109 74 kotlin ArrayDeque — 10 · 1.5배 java ArrayDeque — 17 · 작을 때 약 2배 처음 크기를 알면 ArrayDeque(n) 으로 계단을 없앨 수 있습니다
재할당은 드물게, 그러나 한 번에 전부 — 용량이 늘 size 위에 있도록 1.5배씩 미리 늘립니다.
버스트 뒤에 메모리를 그대로 쥔다
class Inbox {
    private val queue = ArrayDeque<Event>()
    fun drain() {
        while (true) handle(queue.removeFirstOrNull() ?: break)
        // 한때 100만 개가 들어왔다면 배열은 여전히 100만 칸
        // (칸은 null 이라 Event 는 GC 되지만 배열 자체는 남는다)
    }
}
크기를 제한하거나, 비면 새로 만든다
class Inbox(private val limit: Int = 10_000) {
    private var queue = ArrayDeque<Event>(256)
    fun offer(e: Event): Boolean =
        if (queue.size >= limit) false else { queue.addLast(e); true }
    fun drain() {
        while (true) handle(queue.removeFirstOrNull() ?: break)
        queue = ArrayDeque(256)        // 큰 배열을 놓아 준다 (trimToSize 가 없다)
    }
}
정리 — 코틀린 ArrayDeque = 원형 배열 + head + size. 양 끝과 인덱스 접근은 O(1), 중간 삽입 · 삭제는 가까운 끝까지 미는 O(min(i, n−i)), 증가는 1.5배 · 축소는 없음.
SQ 05

우선순위 큐 — java.util.PriorityQueue 를 코틀린답게

코틀린 표준 라이브러리에는 우선순위 큐가 없습니다. JVM · 안드로이드에서는 java.util.PriorityQueue 를 쓰고, 순서는 코틀린의 compareBy · thenBy · compareByDescending 으로 조립합니다. 공통 코드(KMP)라면 이진 힙을 직접 둡니다 — 40줄이면 됩니다.

최소 힙 · 최대 힙

import java.util.PriorityQueue

fun main() {
    val minHeap = PriorityQueue<Int>()                         // 기본 = 최소 힙 (자연 순서)
    val maxHeap = PriorityQueue<Int>(compareByDescending { it }) // 최대 힙
    val maxHeap2 = PriorityQueue<Int>(reverseOrder())          // 같은 뜻 — kotlin.comparisons.reverseOrder

    for (x in listOf(5, 1, 8, 3, 9, 2)) { minHeap += x; maxHeap += x; maxHeap2 += x }

    println(minHeap.peek())     // 1
    println(maxHeap.peek())     // 9
    println(maxHeap2.poll())    // 9
    println(minHeap)            // [1, 3, 2, 5, 9, 8]  ← 정렬된 게 아니다! 힙 배열 순서
    println(generateSequence { minHeap.poll() }.toList())   // [1, 2, 3, 5, 8, 9]  꺼내야 정렬 순서
}

다중 키 + 같은 우선순위의 순서

import java.util.PriorityQueue

data class Task(val name: String, val priority: Int, val seq: Long)

fun main() {
    var counter = 0L
    val pq = PriorityQueue(
        compareByDescending<Task> { it.priority }   // ① 우선순위 높은 것 먼저
            .thenBy { it.seq }                       // ② 같으면 먼저 들어온 것 (FIFO 보장용 일련번호)
    )
    fun submit(name: String, priority: Int) = pq.add(Task(name, priority, counter++))

    submit("메일", 1); submit("결제", 9); submit("로그", 1); submit("알림", 5); submit("환불", 9)

    while (pq.isNotEmpty()) print(pq.poll().name + " ")
    println()   // 결제 환불 알림 메일 로그
}
같은 우선순위끼리의 순서는 보장되지 않습니다 — 힙은 안정(stable)하지 않습니다. "먼저 들어온 것 먼저"가 필요하면 위처럼 일련번호를 두 번째 키로 넣으세요. compareBy<Task> { … } 처럼 첫 단계에 타입을 적어 줘야 thenBy 의 it 이 추론됩니다.
같은 힙, 두 가지 모습 — 트리는 생각용, 배열이 실제 저장 1 3 2 5 9 8 peek() 0 1 2 3 4 5 1 3 2 5 9 8 i 의 자식 = 2i+1 · 2i+2 · 부모 = (i−1)/2 포인터가 없어 메모리가 촘촘하고 캐시에 잘 맞습니다 println(pq) → [1, 3, 2, 5, 9, 8] — 정렬이 아니라 이 배열 순서입니다 poll() 은 0번을 빼고 마지막 원소를 올려 아래로 내립니다 — O(log n)
힙은 "부모가 자식보다 작다"만 지킵니다 — 꼭대기 하나만 확실하고 나머지 순서는 약속이 없습니다.

비용과 함정

연산비용메모
add · offerO(log n)맨 끝에 넣고 위로 올림
poll · remove()O(log n)비었을 때 poll 은 null, remove() 는 예외
peekO(1)0번 칸
remove(x) · containsO(n)선형 탐색 — 자주 부르면 설계를 바꿀 신호
순회 · toStringO(n)정렬 순서 아님
첫 용량 · 증가—11 → 24 → 50 → 102 → 153 (작을 땐 약 2배, 64 이상 1.5배)
안드로이드 minSdk 메모 — 비교자만 받는 생성자 PriorityQueue(comparator) 는 자바 8 에서 생겨 안드로이드 API 24 부터 있습니다. minSdk 가 그보다 낮다면 PriorityQueue(11, comparator) 를 쓰세요(린트가 알려 줍니다).

안에 든 객체를 바꾸면 — 힙은 모른다

import java.util.PriorityQueue

class Job(val id: Int, var priority: Int)

fun main() {
    val pq = PriorityQueue<Job>(compareBy { it.priority })
    val a = Job(1, 5); val b = Job(2, 3)
    pq += a; pq += b
    println(pq.peek().id)       // 2  (b 의 우선순위 3 이 더 작다)

    a.priority = 1              // ❌ 힙 안에 든 객체를 바꿨다 — 힙은 모른다
    println(pq.peek().id)       // 2  ← 여전히 b. 이제 힙 불변식이 깨진 상태

    // ✅ 빼고 → 바꾸고 → 다시 넣는다 (remove(Object) 는 O(n) 선형 탐색)
    pq.remove(a); a.priority = 1; pq.add(a)
    println(pq.peek().id)       // 1
}

힙은 넣을 때 · 뺄 때만 자리를 맞춥니다. 비교 키는 val 로 두고(data class 의 val 필드), 우선순위가 바뀌면 새 객체를 넣고 옛것은 꺼낼 때 버리는(아래 다익스트라의 lazy deletion) 방식이 실무에서 가장 흔합니다.

top-k · 다익스트라 — 힙이 일하는 두 자리

import java.util.PriorityQueue

// 상위 k 개 — 크기 k 짜리 '최소' 힙을 유지한다: O(n log k)
fun <T> topK(items: Iterable<T>, k: Int, cmp: Comparator<T>): List<T> {
    val heap = PriorityQueue(cmp)          // 꼭대기 = 지금까지 고른 k 개 중 가장 작은 것
    for (x in items) {
        heap.add(x)
        if (heap.size > k) heap.poll()     // k 개를 넘으면 가장 작은 것을 버린다
    }
    return heap.sortedWith(cmp).asReversed()   // 큰 것부터
}

data class Product(val name: String, val sold: Int)

fun main() {
    val items = listOf(Product("펜", 120), Product("공책", 340), Product("가방", 80),
                       Product("필통", 510), Product("자", 95))
    println(topK(items, 3, compareBy { it.sold }).map { it.name })   // [필통, 공책, 펜]
}
상위 k 개는 최소 힙 — 직관과 반대입니다. "지금까지 고른 k 개 중 가장 약한 것"을 꼭대기에 두어야 새 후보와 한 번 비교로 탈락자를 정할 수 있습니다. 전체 정렬 O(n log n) 대신 O(n log k). 데이터가 작으면 sortedByDescending { }.take(k) 가 더 읽기 쉽습니다.
import java.util.PriorityQueue

// 다익스트라 — (노드, 거리) 를 거리 순으로 꺼낸다. 낡은 항목은 꺼낼 때 버린다 (lazy deletion)
fun dijkstra(graph: Map<String, List<Pair<String, Int>>>, start: String): Map<String, Int> {
    val dist = mutableMapOf(start to 0)
    val pq = PriorityQueue<Pair<String, Int>>(compareBy { it.second })
    pq.add(start to 0)
    while (pq.isNotEmpty()) {
        val (node, d) = pq.poll()
        if (d > dist.getValue(node)) continue          // 더 짧은 길이 이미 확정됨 — 건너뜀
        for ((next, w) in graph[node].orEmpty()) {
            val nd = d + w
            if (nd < (dist[next] ?: Int.MAX_VALUE)) {
                dist[next] = nd
                pq.add(next to nd)                     // decrease-key 대신 '새로 하나 더' 넣는다
            }
        }
    }
    return dist
}

fun main() {
    val g = mapOf(
        "A" to listOf("B" to 4, "C" to 1),
        "C" to listOf("B" to 2, "D" to 7),
        "B" to listOf("D" to 1),
    )
    println(dijkstra(g, "A").toSortedMap())   // {A=0, B=3, C=1, D=4}
}

KMP 공통 코드 — java.util 이 없을 때

// commonMain 에 둘 수 있는 이진 힙 — java.util 없이 kotlin 표준 라이브러리만 쓴다
class BinaryHeap<T>(private val cmp: Comparator<in T>) {
    private val a = ArrayList<T>()
    val size: Int get() = a.size
    fun isEmpty() = a.isEmpty()
    fun peek(): T? = a.firstOrNull()

    fun add(x: T) {
        a.add(x)
        var i = a.lastIndex
        while (i > 0) {                                  // 위로 올리기 (sift-up)
            val p = (i - 1) / 2
            if (cmp.compare(a[i], a[p]) >= 0) break
            swap(i, p); i = p
        }
    }

    fun poll(): T? {
        if (a.isEmpty()) return null
        val top = a[0]
        val last = a.removeAt(a.lastIndex)               // removeLast() 아님 — 이유는 SQ 02
        if (a.isNotEmpty()) { a[0] = last; siftDown(0) }
        return top
    }

    private fun siftDown(start: Int) {
        var i = start
        while (true) {
            val l = 2 * i + 1; val r = l + 1
            var m = i
            if (l < a.size && cmp.compare(a[l], a[m]) < 0) m = l
            if (r < a.size && cmp.compare(a[r], a[m]) < 0) m = r
            if (m == i) return
            swap(i, m); i = m
        }
    }
    private fun swap(i: Int, j: Int) { val t = a[i]; a[i] = a[j]; a[j] = t }
}

fun main() {
    val h = BinaryHeap<Int>(naturalOrder())
    listOf(5, 1, 8, 3, 9, 2).forEach(h::add)
    println(generateSequence { h.poll() }.toList())   // [1, 2, 3, 5, 8, 9]
}

선택지

상황쓰는 것
JVM · 안드로이드만java.util.PriorityQueue
KMP 공통, 원소 수천 개 이하왼쪽 같은 직접 구현 힙 (Comparator 는 공통 API)
KMP 공통, 정렬 상태 유지 + 삭제정렬된 리스트에 binarySearch 로 삽입 — O(n) 삽입이지만 단순
expect / actualJVM 은 PriorityQueue 로, 나머지는 직접 구현으로 — 테스트를 공통에 둔다

직접 구현에서도 a.removeAt(a.lastIndex) 를 쓴 이유는 SQ 02 의 함정 때문입니다 — 공통 코드는 JVM 타깃에서 JDK 21 로 컴파일될 수 있습니다.

정리 — PriorityQueue(compareBy<T> { … }.thenBy { … }), 최대 힙은 compareByDescending · reverseOrder(). 순회 순서는 정렬이 아니고, 같은 우선순위 순서도 보장되지 않으며, 넣은 뒤 키를 바꾸면 안 됩니다.
SQ 06

스레드 안전 큐 — BlockingQueue 를 코틀린에서

코틀린 표준 라이브러리에는 동시성 컬렉션이 없고, java.util.concurrent 를 그대로 씁니다 (개요는 ADV 09). 여기서는 큐 하나에 집중합니다 — 막을 것인가(blocking) 말 것인가, 크기를 제한할 것인가(bounded), 어떻게 끝낼 것인가(poison pill).

구현체 고르기

타입성격언제
ArrayBlockingQueue(n)유한 · 배열 · 잠금 하나기본 선택 — 배압이 필요한 생산자-소비자
LinkedBlockingQueue(n)선택적 유한 · 노드 · 넣기/빼기 잠금 분리생산 · 소비가 동시에 몰릴 때. 용량을 꼭 준다
LinkedBlockingDeque양쪽 블로킹작업 훔치기 · 우선 재처리
PriorityBlockingQueue무한 · 힙스레드 간 우선순위 작업
DelayQueue무한 · 시각이 된 것만예약 실행 · 만료 처리
SynchronousQueue용량 0 — 직접 건네기스레드 풀 핸드오프 (채널의 RENDEZVOUS)
ConcurrentLinkedQueue무한 · 논블로킹 · 락 프리막을 필요 없는 다중 생산 · 소비. size() 는 O(n)

같은 동작, 네 가지 태도

예외특수값막힘시간 제한
넣기addoffer → falseputoffer(e, t, u)
빼기removepoll → nulltakepoll(t, u)
보기elementpeek → null——
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit

fun main() {
    val q = LinkedBlockingQueue<String>(2)       // ⚠️ 용량을 안 주면 Int.MAX_VALUE — 사실상 무한
    println(q.offer("a"))                        // true
    println(q.offer("b"))                        // true
    println(q.offer("c"))                        // false  — 즉시 포기
    println(q.offer("c", 50, TimeUnit.MILLISECONDS))   // false — 50ms 기다리다 포기
    println(q.poll())                            // a
    println(q.poll(10, TimeUnit.MILLISECONDS))   // b
    println(q.poll(10, TimeUnit.MILLISECONDS))   // null — 10ms 기다려도 없음
    println(q.remainingCapacity())               // 2
}

생산자-소비자 — 유한 큐 + 독약으로 끝내기

import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.thread

sealed interface Msg {
    data class Job(val id: Int) : Msg
    data object Stop : Msg                       // 독약(poison pill) — "이제 그만"
}

fun main() {
    val queue = ArrayBlockingQueue<Msg>(4)       // 유한 큐 — 가득 차면 put 이 멈춘다 = 배압
    val done = ConcurrentHashMap.newKeySet<Int>()
    val workerCount = 3

    val workers = List(workerCount) { w ->
        thread(name = "worker-$w") {
            while (true) {
                when (val m = queue.take()) {    // 비어 있으면 여기서 잠든다
                    is Msg.Job -> { Thread.sleep(5); done += m.id }
                    Msg.Stop -> break            // 독약을 받으면 루프 종료
                }
            }
        }
    }

    for (id in 1..10) queue.put(Msg.Job(id))     // 큐가 차면 생산자(main)가 기다린다
    repeat(workerCount) { queue.put(Msg.Stop) }  // ⚠️ 소비자 수만큼 넣어야 모두 끝난다
    workers.forEach { it.join() }

    println("처리 ${done.size}건 · 남은 큐 ${queue.size}")   // 처리 10건 · 남은 큐 0
}

코틀린이라 좋아지는 곳

자바에서는 독약을 "특별한 Job 인스턴스"나 null 대체값으로 만들곤 했습니다. 코틀린은 sealed interface + data object 로 메시지 종류를 닫아 두므로 when 이 빠짐없이 처리했는지 컴파일러가 확인합니다. thread { } 는 kotlin.concurrent 의 짧은 스레드 생성 함수입니다.

독약은 소비자 수만큼 — 하나만 넣으면 한 워커만 끝나고 나머지는 take() 에서 영원히 잠듭니다. 워커 수가 바뀌는 구조라면 독약 대신 ExecutorService.shutdown() 이나 스레드 인터럽트를 씁니다.
유한 큐가 곧 배압 — 큐가 차면 put 이 생산자를 재웁니다. 소비가 느려도 메모리가 무한히 늘지 않고, 생산 속도가 소비 속도에 자동으로 맞춰집니다.
세로가 시간 — 용량 4 인 ArrayBlockingQueue 를 사이에 두고 생산자 (main) 큐 (용량 4) worker-0 worker-1 1. put(Job 1~4) 바로 들어감 2. put(Job 5) — 가득 참 잠듦 3. take() → Job 1 4. 자리 생김 → put 깨어남 5. take() → Job 2 6. put(Stop) × 2 7. Stop → break 8. Stop → break 실선은 넣기 · 점선은 꺼내 가기 — 세로 막대는 그 스레드가 일하거나 잠든 구간
가득 차면 생산자가, 비면 소비자가 잠듭니다 — 큐 크기가 두 속도를 맞추는 조절기입니다.

스레드 풀의 큐 · 코루틴과 섞을 때

import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger

fun main() {
    // Executors.newFixedThreadPool(4) 의 작업 큐는 '용량 무제한' LinkedBlockingQueue — 밀리면 메모리로 쌓인다
    // ✅ 큐 크기와 '가득 찼을 때 정책'을 직접 정한다
    val pool = ThreadPoolExecutor(
        4, 4,                                   // 코어 = 최대 스레드 4
        0L, TimeUnit.MILLISECONDS,
        ArrayBlockingQueue(100),                // 대기열 100 칸
        ThreadPoolExecutor.CallerRunsPolicy(),  // 가득 차면 '제출한 스레드'가 직접 실행 → 자연스러운 감속
    )
    val done = AtomicInteger()
    repeat(1_000) { pool.execute { Thread.sleep(1); done.incrementAndGet() } }
    pool.shutdown()
    pool.awaitTermination(10, TimeUnit.SECONDS)
    println("완료 ${done.get()}")               // 완료 1000
}
Executors.newFixedThreadPool 의 큐는 무제한입니다(LinkedBlockingQueue() 기본 생성자 = Int.MAX_VALUE). 처리보다 제출이 빠르면 OOM 까지 조용히 쌓입니다. 큐 크기와 거절 정책을 직접 정하세요.
import java.util.concurrent.LinkedBlockingQueue
import kotlinx.coroutines.*

// 레거시 자바 코드가 BlockingQueue 를 넘겨줄 때 — 코루틴 쪽에서 안전하게 꺼내기
fun main() = runBlocking {
    val legacy = LinkedBlockingQueue<String>(100)

    val reader = launch {
        while (isActive) {
            // ❌ queue.take() 를 그냥 부르면 코루틴이 아니라 '스레드'가 잠든다 (Default 풀 고갈)
            // ✅ IO 스레드로 옮기고, 취소되면 스레드 인터럽트로 take() 를 깨운다
            val msg = runInterruptible(Dispatchers.IO) { legacy.take() }
            println("받음: $msg")
        }
    }
    legacy.put("hello"); legacy.put("world")
    delay(100)
    reader.cancelAndJoin()        // take() 에서 막혀 있어도 즉시 끝난다
    println("종료")
}
// 받음: hello
// 받음: world
// 종료

새 코드라면 코루틴끼리는 Channel 을 씁니다(SQ 07). BlockingQueue 를 코루틴에서 다뤄야 하는 건 자바 라이브러리 · 레거시 스레드가 반대편에 있을 때뿐이고, 그때는 runInterruptible(Dispatchers.IO) 로 감쌉니다.

정리 — 스레드 사이 큐는 용량을 준 ArrayBlockingQueue 가 기본, put/take 로 배압을 얻고 독약은 data object 로 소비자 수만큼. 무제한 큐(LinkedBlockingQueue() · 고정 스레드 풀)는 의심부터 합니다.
SQ 07

코루틴의 큐 — Channel · Flow.buffer 를 큐로 읽기

채널의 기본 사용법(생산자-소비자, produce, 팬아웃, select)은 CORO 15 에 있습니다. 여기서는 같은 도구를 "정책이 붙은 큐"로 다시 봅니다 — 가득 찼을 때 무엇이 버려지는가, 보낸 쪽은 그걸 아는가, Flow 의 buffer · conflate 는 어떤 큐인가.

Channel 의 손잡이 세 개 · 보내기 받기

// Channel 생성자 — 세 개의 손잡이
val ch = Channel<Frame>(
    capacity = 8,                                   // 큐 길이 (RENDEZVOUS · BUFFERED · CONFLATED · UNLIMITED 또는 숫자)
    onBufferOverflow = BufferOverflow.DROP_OLDEST,  // 가득 찼을 때: SUSPEND(기본) · DROP_OLDEST · DROP_LATEST
    onUndeliveredElement = { it.release() },        // 버려지거나 취소로 못 전달된 원소 정리 (버퍼 반납 · 파일 닫기)
)

// 보내기 — 세 가지 태도
ch.send(f)                       // suspend: SUSPEND 정책이면 자리가 날 때까지 멈춘다 (배압)
val r = ch.trySend(f)            // 즉시: r.isSuccess / r.isFailure / r.isClosed — 코루틴 밖(콜백)에서도 OK
ch.trySendBlocking(f)            // 스레드를 막고 기다린다 — 코루틴이 아닌 레거시 스레드 전용

// 받기
val a = ch.receive()                         // 비면 suspend · 닫히면 ClosedReceiveChannelException
val b = ch.receiveCatching().getOrNull()     // 닫혔으면 null — 예외 없이
val c = ch.tryReceive().getOrNull()          // 즉시: 없으면 null
for (x in ch) { /* 닫힐 때까지 */ }

용량 상수 = 큐의 성격

capacity큐로 보면자바 쪽 닮은꼴
RENDEZVOUS (0, 기본)칸 없음 — 받는 쪽과 만나야 건넨다SynchronousQueue
BUFFERED기본 64칸 (JVM 시스템 속성 kotlinx.coroutines.channels.defaultBuffer)ArrayBlockingQueue(64)
숫자 nn 칸 유한 큐ArrayBlockingQueue(n)
CONFLATED1칸 + 새 값이 옛 값을 덮어씀 (= Channel(1, DROP_OLDEST))없음 — 최신값 레지스터
UNLIMITED무한 — send 가 절대 멈추지 않는다LinkedBlockingQueue() — 같은 OOM 위험
BlockingQueue 와 결정적 차이 — 가득 찼을 때 스레드가 아니라 코루틴이 멈춥니다(suspend). 그 사이 스레드는 다른 코루틴을 돌립니다. 같은 일을 BlockingQueue.put 으로 하면 Dispatchers.Default 의 스레드(코어 수만큼)가 하나씩 묶여 앱 전체가 멈출 수 있습니다.

실험 — 받는 쪽이 늦을 때 무엇이 남는가

import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.runBlocking

// 소비자가 아직 없을 때 1..6 을 trySend 로 '밀어 넣어' 본다 → 무엇이 남는가
suspend fun probe(name: String, ch: Channel<Int>) {
    val accepted = (1..6).map { ch.trySend(it).isSuccess }
    ch.close()
    val got = buildList { for (x in ch) add(x) }
    println("${name.padEnd(12)} 수락=${accepted.count { it }}  받음=$got")
}

fun main() = runBlocking {
    probe("RENDEZVOUS", Channel(Channel.RENDEZVOUS))
    probe("버퍼 2", Channel(2))
    probe("DROP_OLDEST", Channel(2, BufferOverflow.DROP_OLDEST))
    probe("DROP_LATEST", Channel(2, BufferOverflow.DROP_LATEST))
    probe("CONFLATED", Channel(Channel.CONFLATED))
    probe("UNLIMITED", Channel(Channel.UNLIMITED))
}
RENDEZVOUS   수락=0  받음=[]
버퍼 2         수락=2  받음=[1, 2]
DROP_OLDEST  수락=6  받음=[5, 6]
DROP_LATEST  수락=6  받음=[1, 2]
CONFLATED    수락=6  받음=[6]
UNLIMITED    수락=6  받음=[1, 2, 3, 4, 5, 6]

읽는 법

"수락"과 "받음"의 차이가 핵심입니다. DROP_OLDEST · DROP_LATEST · CONFLATED 는 trySend 가 여섯 번 모두 성공했다고 답하지만, 실제로 받은 건 일부입니다 — 버린 사실을 보낸 쪽은 모릅니다. 반대로 SUSPEND 정책의 버퍼 2 는 넘친 것을 거절(isFailure)해서 보낸 쪽이 압니다.

정책어울리는 데이터
SUSPEND (기본)하나도 잃으면 안 되는 작업 · 주문 · 로그
DROP_OLDEST최근 N 개만 의미 있는 것 — 센서 · 위치 · 진행률
DROP_LATEST먼저 온 것이 우선 — 처리 중 중복 클릭 무시
CONFLATED마지막 값만 — 화면 상태 · 검색어
보낸 순서(가로)마다 그 값의 운명 — 행이 채널 정책 1 2 3 4 5 6 받은 것 RENDEZVOUS 1 2 3 4 5 6 [ ] — 받을 사람이 없다 버퍼 2 (SUSPEND) 1 2 3 4 5 6 [1, 2] DROP_OLDEST 1 2 3 4 5 6 [5, 6] — 최근 2개 DROP_LATEST 1 2 3 4 5 6 [1, 2] — 먼저 온 2개 CONFLATED 1 2 3 4 5 6 [6] — 마지막 하나 UNLIMITED 1 2 3 4 5 6 전부 — 메모리 한도 없음 전달됨 수락했지만 버려짐 — 보낸 쪽은 모름 거절 (trySend 실패) — 보낸 쪽이 앎 send() 였다면 회색 칸에서 거절 대신 생산자가 멈춥니다 — 그것이 배압입니다
붉은 칸이 위험합니다 — 성공했다고 답했는데 사라진 값. 잃어도 되는 데이터에만 DROP 정책을 씁니다.

Flow 의 buffer · conflate — 보이지 않는 채널

import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking

// 생산 100ms 마다 1개 · 소비 250ms 에 1개 — 소비가 2.5배 느리다
val ticks = flow { for (i in 1..6) { emit(i); delay(100) } }

suspend fun show(name: String, f: Flow<Int>) {
    val got = mutableListOf<Int>()
    f.collect { delay(250); got += it }
    println("${name.padEnd(10)} $got")
}

fun main() = runBlocking {
    show("그대로", ticks)                                    // 생산자가 소비를 기다린다 — 전부 받지만 느리다
    show("buffer(2)", ticks.buffer(2))                      // 2칸 큐 — 차면 생산자가 suspend
    show("conflate", ticks.conflate())                      // = buffer(CONFLATED) — 최신 하나만
    show("최신 2개", ticks.buffer(2, BufferOverflow.DROP_OLDEST))
}
// 그대로        [1, 2, 3, 4, 5, 6]
// buffer(2)  [1, 2, 3, 4, 5, 6]
// conflate   [1, 3, 5, 6]
// 최신 2개      [1, 2, 4, 5, 6]

출력은 실제 실행 결과이며 지연 시간에 따라 조금 달라질 수 있습니다. buffer(2) 는 값을 잃지 않으면서 생산과 소비를 서로 다른 코루틴으로 갈라 겹쳐 돌리고(전체 시간이 짧아짐), conflate 는 밀린 값을 건너뜁니다.

연산자 = 채널 설정

Flow 연산자속에 생기는 큐
buffer()Channel(BUFFERED) — 64칸 · SUSPEND
buffer(n, DROP_OLDEST)n칸 · 오래된 것 버림
conflate()buffer(CONFLATED) — 최신 하나
collectLatest { }큐가 아니라 처리 중인 것을 취소하고 새 값으로
flowOn(io)디스패처가 바뀌는 경계에 기본 버퍼가 생긴다
MutableSharedFlow(extraBufferCapacity = n, onBufferOverflow = …)구독자 여럿에게 뿌리는 링 버퍼 — 구독자가 없으면 값이 사라진다(replay 0)
Channel 이냐 SharedFlow 냐 — 큐 관점으로 한 줄: "꺼내면 사라져야 하면 Channel, 모두가 봐야 하면 SharedFlow". Channel 은 받는 쪽이 아직 없어도 버퍼에 보관하고, SharedFlow 는 구독자가 없으면 버립니다.

Mutex vs Channel — 공유 상태를 지키는 두 방법

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

// A. Mutex — 공유 상태를 '잠깐 잠그고' 고친다
class CounterWithMutex {
    private val mutex = Mutex()
    private var n = 0
    suspend fun inc() = mutex.withLock { n++ }
    suspend fun get() = mutex.withLock { n }
}

// B. Channel — 상태는 코루틴 하나만 만진다. 나머지는 '요청을 큐에 넣는다'
class CounterWithQueue(scope: CoroutineScope) {
    private sealed interface Cmd
    private data object Inc : Cmd
    private class Get(val reply: CompletableDeferred<Int>) : Cmd

    private val inbox = Channel<Cmd>(Channel.UNLIMITED)   // 요청 큐 (inc 가 절대 멈추지 않게)
    init {
        scope.launch {
            var n = 0                                     // 이 변수는 이 코루틴만 본다 → 잠금 불필요
            for (cmd in inbox) when (cmd) {
                Inc -> n++
                is Get -> cmd.reply.complete(n)
            }
        }
    }
    fun inc() { inbox.trySend(Inc) }
    suspend fun get(): Int = CompletableDeferred<Int>().also { inbox.send(Get(it)) }.await()
    fun close() { inbox.close() }
}

fun main() = runBlocking {
    val a = CounterWithMutex()
    val b = CounterWithQueue(this)
    coroutineScope {
        repeat(1000) {
            launch(Dispatchers.Default) { a.inc(); b.inc() }
        }
    }
    println("${a.get()} ${b.get()}")      // 1000 1000
    b.close()
}
기준MutexChannel (요청 큐)
생각 방식상태를 잠깐 잠근다상태 주인이 하나, 나머지는 메시지를 보낸다
코드 양적다메시지 타입 · 루프가 필요
호출자 대기잠금을 얻을 때까지 suspendtrySend 면 대기 없음 (fire-and-forget)
순서공정하지만 호출 순서 보장은 약함큐 순서 = 처리 순서
어울리는 곳짧은 임계 구역, 캐시 갱신순서가 중요한 명령 처리, 상태 기계, 쓰기 직렬화
Mutex 는 재진입이 안 됩니다 — withLock 안에서 같은 Mutex 를 다시 잡으면 영원히 멈춥니다. actor { } 빌더는 실험적 API 로 남아 있어, 위처럼 Channel + launch 로 직접 쓰는 편이 안전합니다.
정리 — 채널은 정책이 붙은 큐입니다. 잃으면 안 되면 SUSPEND + send, 최신만 필요하면 CONFLATED · DROP_OLDEST, UNLIMITED 는 LinkedBlockingQueue() 와 같은 무제한 큐라는 걸 기억하세요.
SQ 08

알고리즘 패턴 — 코틀린 관용 코드로

코딩 테스트와 실무에 반복해서 나오는 여섯 가지입니다. 이론(왜 O(n) 인지)은 CS 가이드에 맡기고, 여기서는 코틀린으로 어떻게 짧고 안전하게 쓰는가 — ArrayDeque, ?: break, 구조 분해, IntArray — 에 집중합니다. 전부 실제로 돌려 출력을 맞췄습니다.

패턴자료구조쓰는 끝신호 — 이런 문제면
괄호 · 태그 짝 맞추기스택last"가장 최근에 연 것"과 짝을 맞춘다
단조 스택스택 (인덱스)last"다음 큰 수 · 이전 작은 수 · 히스토그램 최대 직사각형"
BFS큐뒤로 넣고 앞에서"최소 횟수 · 최단 거리(가중치 없음)"
0-1 BFS덱비용 0 은 앞, 1 은 뒤간선 비용이 0 또는 1 — 다익스트라보다 빠름
슬라이딩 윈도우 최댓값단조 덱 (인덱스)양쪽"길이 k 구간마다 최대 · 최소"
undo / redo스택 두 개last"되돌리기 · 다시 하기 · 뒤로 가기"

괄호 검사 · 단조 스택

// 1) 괄호 검사 — 여는 것은 쌓고, 닫는 것은 꼭대기와 짝을 맞춘다
fun isBalanced(s: String): Boolean {
    val pairs = mapOf(')' to '(', ']' to '[', '}' to '{')
    val stack = ArrayDeque<Char>()
    for (c in s) when (c) {
        '(', '[', '{' -> stack.addLast(c)
        ')', ']', '}' -> if (stack.removeLastOrNull() != pairs[c]) return false
    }
    return stack.isEmpty()
}

// 2) 단조 스택 — 각 원소의 '오른쪽에서 처음 만나는 더 큰 수' (없으면 -1)
fun nextGreater(a: IntArray): IntArray {
    val ans = IntArray(a.size) { -1 }
    val stack = ArrayDeque<Int>()                 // 인덱스를 쌓는다. 값은 아래→위로 '감소'
    for (i in a.indices) {
        while (stack.isNotEmpty() && a[stack.last()] < a[i]) {
            ans[stack.removeLast()] = a[i]        // 더 큰 수를 만난 것들을 정산
        }
        stack.addLast(i)
    }
    return ans
}

fun main() {
    println(listOf("{[()]}", "([)]", "((", "").map(::isBalanced))   // [true, false, false, true]
    println(nextGreater(intArrayOf(2, 7, 3, 5, 4, 6, 8)).toList())  // [7, 8, 5, 6, 6, 8, -1]
}
stack.removeLastOrNull() != pairs[c] 한 줄이 "비었으면 실패"와 "짝이 다르면 실패"를 함께 처리합니다 — 빈 스택이면 null, 짝이 없으면 다른 문자라 둘 다 불일치. 자바라면 isEmpty() 검사가 따로 필요한 곳입니다.

슬라이딩 윈도우 최댓값 · undo/redo

// 5) 슬라이딩 윈도우 최댓값 — 단조 덱: 앞 = 현재 창의 최댓값 인덱스
fun windowMax(a: IntArray, k: Int): List<Int> {
    val dq = ArrayDeque<Int>()                         // 인덱스. 값은 앞→뒤로 '감소'
    val out = ArrayList<Int>()
    for (i in a.indices) {
        if (dq.isNotEmpty() && dq.first() <= i - k) dq.removeFirst()           // 창 밖으로 나간 것
        while (dq.isNotEmpty() && a[dq.last()] <= a[i]) dq.removeLast()          // 나보다 작으면 쓸모없다
        dq.addLast(i)
        if (i >= k - 1) out += a[dq.first()]
    }
    return out
}

// 6) undo / redo — 스택 두 개
class Editor {
    var text = ""; private set
    private val undo = ArrayDeque<String>()
    private val redo = ArrayDeque<String>()
    fun type(s: String) { undo.addLast(text); redo.clear(); text += s }   // 새 입력이면 redo 는 무효
    fun undo() { val prev = undo.removeLastOrNull() ?: return; redo.addLast(text); text = prev }
    fun redo() { val next = redo.removeLastOrNull() ?: return; undo.addLast(text); text = next }
}

fun main() {
    println(windowMax(intArrayOf(1, 3, -1, -3, 5, 3, 6, 7), 3))   // [3, 3, 5, 5, 6, 7]
    val e = Editor()
    e.type("안녕"); e.type("하세요"); e.type("!")
    e.undo(); e.undo()
    println(e.text)       // 안녕
    e.redo()
    println(e.text)       // 안녕하세요
    e.type("?")
    e.redo()              // 새 입력 뒤라 redo 할 것이 없다
    println(e.text)       // 안녕하세요?
}
다음 큰 수 — 막대 높이가 값, 화살표가 답 2 7 3 5 4 6 8 값 7 8 5 6 6 8 -1 답 i=5 (값 6) 에서 스택 [7, 5, 4] → 4 와 5 를 꺼내며 답 6 을 적고 → [7, 6] 원소마다 한 번 쌓이고 한 번 꺼내지므로 이중 while 이어도 전체 O(n)
스택에는 아직 답을 못 찾은 것만 남습니다 — 더 큰 값이 오는 순간 한꺼번에 정산됩니다.

BFS · 0-1 BFS

// 3) BFS — 격자 최단 거리. 큐 = ArrayDeque, 방문 표시는 '넣을 때'
fun shortest(grid: List<String>, start: Pair<Int, Int>, goal: Pair<Int, Int>): Int {
    val h = grid.size; val w = grid[0].length
    val dist = Array(h) { IntArray(w) { -1 } }
    val queue = ArrayDeque<Pair<Int, Int>>()
    dist[start.first][start.second] = 0
    queue.addLast(start)
    val dirs = listOf(0 to 1, 1 to 0, 0 to -1, -1 to 0)
    while (true) {
        val (r, c) = queue.removeFirstOrNull() ?: break
        if (r to c == goal) return dist[r][c]
        for ((dr, dc) in dirs) {
            val nr = r + dr; val nc = c + dc
            if (nr in 0 until h && nc in 0 until w && grid[nr][nc] == '.' && dist[nr][nc] == -1) {
                dist[nr][nc] = dist[r][c] + 1     // 넣을 때 표시해야 중복 삽입이 없다
                queue.addLast(nr to nc)
            }
        }
    }
    return -1
}

// 4) 0-1 BFS — 간선 비용이 0 또는 1: 비용 0 은 앞(addFirst), 1 은 뒤(addLast)
fun zeroOneBfs(n: Int, edges: List<Triple<Int, Int, Int>>, src: Int): IntArray {
    val adj = List(n) { mutableListOf<Pair<Int, Int>>() }
    for ((u, v, w) in edges) { adj[u] += v to w; adj[v] += u to w }
    val dist = IntArray(n) { Int.MAX_VALUE }
    val dq = ArrayDeque<Int>()
    dist[src] = 0; dq.addLast(src)
    while (dq.isNotEmpty()) {
        val u = dq.removeFirst()
        for ((v, w) in adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w
                if (w == 0) dq.addFirst(v) else dq.addLast(v)
            }
        }
    }
    return dist
}

fun main() {
    val grid = listOf(
        "..#.",
        ".##.",
        "....",
    )
    println(shortest(grid, 0 to 0, 0 to 3))   // 7
    val edges = listOf(Triple(0, 1, 1), Triple(0, 2, 0), Triple(2, 1, 0), Triple(1, 3, 1))
    println(zeroOneBfs(4, edges, 0).toList()) // [0, 0, 0, 1]
}

코틀린 관용구 짚기

코드왜 이렇게
val (r, c) = q.removeFirstOrNull() ?: break꺼내기 · 빈 큐 종료 · 구조 분해를 한 줄에
IntArray(w) { -1 }방문 · 거리 배열은 박싱 없는 원시 배열
nr in 0 until h경계 검사를 범위로 — 읽기 쉽고 실수가 적다
넣을 때 표시꺼낼 때 표시하면 같은 칸이 여러 번 들어가 큐가 부풀어 오른다
Pair<Int, Int>읽기 쉽지만 원소마다 객체. 큰 격자면 r * w + c 로 Int 하나에 담는다
0-1 BFS 가 덱인 이유 — 비용 0 간선으로 간 곳은 "지금과 같은 거리"라 앞에, 비용 1 은 "한 단계 먼"이라 뒤에 넣으면 덱 안이 늘 거리 순으로 정렬됩니다. 힙 없이 O(V + E) — ArrayDeque 가 양쪽 O(1) 이라 가능한 방법입니다.
정리 — 패턴은 여섯이지만 도구는 ArrayDeque 하나입니다. 인덱스를 담는 단조 스택 · 덱, 넣을 때 방문 표시, ?: break 로 비우기 — 이 세 습관이 코드를 짧고 안전하게 만듭니다.
SQ 09

성능과 메모리 — 박싱 · 재할당 · 캐시

"어느 큐가 빠른가"는 대개 자료구조보다 원소 하나가 메모리에 어떻게 놓이느냐로 갈립니다. 이 PC(JDK 21 · Kotlin 2.3 컴파일러)에서 직접 잰 수치와 함께, 박싱 · 노드 객체 · 나눗셈 한 번이 만드는 차이를 봅니다.

벤치마크 — 1,000 개를 유지하며 500 만 번 넣고 빼기

import java.util.LinkedList

// 박싱 없는 int 전용 원형 큐 — 핫 루프에서만
class IntRingQueue(capacity: Int = 16) {
    private var buf = IntArray(capacity.coerceAtLeast(1))
    private var head = 0
    var size = 0; private set

    fun addLast(x: Int) {
        if (size == buf.size) grow()
        val t = head + size
        buf[if (t >= buf.size) t - buf.size else t] = x     // % 대신 비교 — 나눗셈이 없다
        size++
    }
    fun removeFirst(): Int {
        if (size == 0) throw NoSuchElementException("빈 큐")
        val x = buf[head]
        if (++head == buf.size) head = 0
        size--
        return x
    }
    private fun grow() {
        val n = IntArray(buf.size * 2)
        for (i in 0 until size) n[i] = buf[(head + i) % buf.size]
        buf = n; head = 0
    }
}

const val OPS = 5_000_000
const val WINDOW = 1_000

inline fun bench(name: String, block: () -> Long) {
    var best = Long.MAX_VALUE; var sink = 0L
    repeat(7) { val t = System.nanoTime(); sink += block(); best = minOf(best, System.nanoTime() - t) }
    println("${name.padEnd(22)} ${best / 1_000_000} ms   (sink=$sink)")
}

fun main() {
    bench("kotlin ArrayDeque<Int>") {
        val q = ArrayDeque<Int>(); var s = 0L
        for (i in 0 until OPS) { q.addLast(i); if (q.size > WINDOW) s += q.removeFirst() }
        s
    }
    bench("java ArrayDeque<Int>") {
        val q = java.util.ArrayDeque<Int>(); var s = 0L
        for (i in 0 until OPS) { q.addLast(i); if (q.size > WINDOW) s += q.removeFirst() }
        s
    }
    bench("LinkedList<Int>") {
        val q = LinkedList<Int>(); var s = 0L
        for (i in 0 until OPS) { q.addLast(i); if (q.size > WINDOW) s += q.removeFirst() }
        s
    }
    bench("IntRingQueue") {
        val q = IntRingQueue(); var s = 0L
        for (i in 0 until OPS) { q.addLast(i); if (q.size > WINDOW) s += q.removeFirst() }
        s
    }
    bench("ArrayList removeAt(0)") {
        val q = ArrayList<Int>(); var s = 0L
        for (i in 0 until OPS) { q.add(i); if (q.size > WINDOW) s += q.removeAt(0) }
        s
    }
}

결과 (7회 중 최솟값 · 두 번 실행해 비슷함)

kotlin ArrayDeque<Int> 24 ms
java ArrayDeque<Int>   22 ms
LinkedList<Int>        38 ms
IntRingQueue           11 ms
ArrayList removeAt(0)  727 ms

# 유지 개수를 100,000 으로 늘리면
kotlin ArrayDeque<Int> 36 ms
java ArrayDeque<Int>   37 ms
LinkedList<Int>        93 ms   ← 캐시를 벗어나며 격차가 커진다
IntRingQueue           11 ms
측정 조건 — 단순 반복 측정(JMH 아님)이라 절대값이 아니라 순서와 배율만 보세요. JIT 워밍업을 위해 7번 돌려 최솟값을 썼고, 기기 · JDK · GC 설정에 따라 달라집니다.
나눗셈 하나의 값 — IntRingQueue 를 처음엔 (head + 1) % cap 으로 짰더니 38 ms 였고, 비교 분기(if (++head == cap) head = 0)로 바꾸자 11 ms 가 됐습니다. 코틀린 표준 ArrayDeque 소스가 % 대신 positiveMod 비교를 쓰는 이유이기도 합니다.
같은 일 500 만 번 — 짧을수록 빠름 (이 PC 측정 예시) 0 10 20 30 40 ms IntRingQueue (IntArray) 11 ms · 박싱 없음 java ArrayDeque<Int> 22 ms kotlin ArrayDeque<Int> 24 ms LinkedList<Int> 38 ms ArrayList removeAt(0) ≈700 마지막 막대는 축 밖입니다(끊김 표시) — 실제로는 원형 큐의 약 60배
자료구조가 같아도 박싱 유무가 2배, 잘못된 자료구조(removeAt(0))는 수십 배입니다.

원소 하나가 차지하는 메모리 — 64비트 HotSpot, 압축 포인터 기준

구조원소당 대략내역
IntArray 원형 큐4 Bint 값 자체 (+ 빈 칸)
ArrayDeque<Int>약 20 B참조 4 B + Integer 객체 16 B (−128~127 은 캐시라 공유)
LinkedList<Int>약 40 B노드 객체 24 B(헤더 12 + 참조 3개) + Integer 16 B
ArrayDeque<Order>4 B + Order객체를 담으면 박싱 문제는 없고 참조만 늘어남

수치는 일반적인 JVM 설정(힙 32 GB 미만, 압축 oops)의 대표값입니다. 안드로이드 ART 도 객체 헤더가 있어 경향은 같습니다.

연결 리스트가 대개 지는 이유

이론상 LinkedList 의 양 끝 연산도 O(1) 입니다. 그런데 원소마다 노드를 할당하고(GC 부담), 다음 원소가 메모리 어디에 있을지 모릅니다(포인터 추적 · 캐시 미스). 배열 기반 덱은 이웃 원소가 같은 캐시 라인에 붙어 있어 CPU 가 미리 읽어 둡니다. 유지 개수를 10 만으로 늘리자 격차가 2.5배로 벌어진 것이 그 증거입니다.

언제 원시 타입 큐를 만드나 — 프로파일러가 Integer.valueOf · GC 를 지목할 때만입니다. 보통은 ArrayDeque<Int> 로 충분하고, 정말 필요하면 직접 짠 IntArray 큐나 원시 타입 컬렉션 라이브러리(fastutil 등)를 씁니다. 먼저 재고, 나중에 바꿉니다.

재할당을 없애는 법 · 코틀린 vs 자바 덱의 작은 차이

// 최대 크기를 안다면 처음부터 잡는다 — 계단(SQ 04 그림)이 사라진다
val frames = ArrayDeque<Frame>(120)          // 2초치 60fps
val history = ArrayDeque<Long>(limit)        // 레이트 리미터 (SQ 12)

// 반대로 '가끔 크게 몰리는' 큐는 작게 시작하고, 비운 뒤 새로 만들어
// 큰 배열을 놓아 준다 (SQ 04 의 Inbox 예)

코틀린 ArrayDeque 와 자바 것의 차이(24 vs 22 ms, 100,000 개에서는 36 vs 37 ms)는 측정 오차에 가까운 수준입니다. 코틀린 쪽은 MutableList 라서 변경 횟수를 기록해 순회 중 수정을 ConcurrentModificationException 으로 잡아 주는 등 구현 세부가 조금 다를 뿐입니다. 이 정도 차이로 자바 것을 고를 이유는 없습니다. KMP 호환과 null 허용 · 인덱스 접근이 더 큽니다.

정리 — 순서는 IntArray 원형 큐 > ArrayDeque(코틀린 ≈ 자바) > LinkedList ≫ ArrayList.removeAt(0). 원소당 4 B · 20 B · 40 B 의 메모리 차이가 곧 속도 차이입니다. 바꾸기 전에 먼저 잽니다.
SQ 10

흔한 실수 — 코드 리뷰에서 실제로 걸리는 것들

문법 오류는 컴파일러가 잡지만, 아래 실수들은 컴파일이 되고 테스트도 통과한 뒤 운영에서 터집니다. 특히 첫 번째는 구형 안드로이드 기기에서만 나타나서 원인을 찾기까지 오래 걸립니다.

함정이 생긴 순서 — 코드는 그대로인데 바닥이 바뀌었다 2020 2021 2022 2023 2024 2025 2026 Kotlin 1.4 (2020-08) ArrayDeque · removeLast 확장 JDK 21 (2023-09) · JEP 431 List.removeFirst/Last 멤버 Android 15 · API 35 (2024) android.jar 에도 추가 Play 요구 (2025 하반기) targetSdk 35 → compileSdk 35+ minSdk 가 35 미만인 거의 모든 앱이 영향권 — 기기에 그 메서드가 없기 때문
2020 년에 쓴 list.removeLast() 가 2025 년에 SDK 를 올리는 순간 구형 기기 크래시가 됩니다.

한눈에 — 실수와 고치는 법

#실수고치는 법
1mutableList.removeFirst() / removeLast()removeAt(0) · removeAt(lastIndex) · …OrNull()
2import java.util.* 로 ArrayDeque 바꿔치기와일드카드 import 금지 · IDE 설정
3순회 중 큐 수정 → CMEwhile + removeFirstOrNull()
4null 원소 + OrNull 로 빈 큐 판별isEmpty() 먼저 · T : Any
5PriorityQueue 를 순회 · 출력하며 정렬 기대poll() 로 꺼내거나 sortedWith
6무제한 큐 (LinkedBlockingQueue() · UNLIMITED)용량 지정 + 넘침 정책
7코루틴 안 BlockingQueue.take()Channel · runInterruptible(IO)
8"비었나 확인 후 꺼내기" (다중 스레드)꺼낸 결과(poll())로 판단
9first() · removeFirst() 를 빈 큐에비는 게 정상이면 OrNull + ?:

3 · 4 를 실제로 돌려 보면

fun main() {
    val q = ArrayDeque(listOf(1, 2, 3))
    try {
        for (x in q) if (x == 1) q.addLast(99)      // ❌ 순회 중에 구조를 바꾼다
    } catch (e: ConcurrentModificationException) {
        println("CME")                              // CME
    }
    val jq = java.util.ArrayDeque<String?>()
    try { jq.addLast(null) } catch (e: NullPointerException) { println("NPE") }   // NPE
    val kq = ArrayDeque<String?>()
    kq.addLast(null)
    println(kq.removeFirstOrNull())                 // null  ← 'null 원소'인지 '빈 큐'인지 모른다
    println(kq.isEmpty())                           // true
}
1 번의 대체가 안전한 근거 — 코틀린 표준 라이브러리의 MutableList.removeLast() 확장 자체가 if (isEmpty()) throw NoSuchElementException("List is empty.") else removeAt(lastIndex) 로 구현되어 있습니다. removeAt(lastIndex) 로 바꾸면 빈 리스트에서 던지는 예외 종류(IndexOutOfBoundsException)만 다르고 동작은 같습니다.

나쁜 예 · 좋은 예

5 · 우선순위 큐를 훑으며 순서 기대
val pq = PriorityQueue(byDue)
for (t in pq) println(t)          // ❌ 힙 배열 순서 — 정렬 아님
pq.forEach { send(it) }           // ❌ 같은 문제
println(pq)                       // ❌ [1, 3, 2, 5, 9, 8] 같은 모양
꺼내면서 · 또는 정렬한 사본으로
while (true) {                    // ✅ 꺼내면 순서대로 (큐는 비워진다)
    val t = pq.poll() ?: break
    send(t)
}
pq.sortedWith(byDue).forEach(::println)   // ✅ 큐는 그대로, 사본만 정렬
6 · 7 · 무제한 + 스레드를 막는 큐
val logs = Channel<Log>(Channel.UNLIMITED)   // ❌ 소비가 멈추면 OOM 까지
val jobs = LinkedBlockingQueue<Job>()          // ❌ 용량 Int.MAX_VALUE

scope.launch(Dispatchers.Default) {
    while (true) handle(jobs.take())           // ❌ Default 스레드를 영원히 점유
}
용량과 정책을 정한다
val logs = Channel<Log>(1_000, BufferOverflow.DROP_OLDEST)  // ✅ 잃어도 되면 버린다
val jobs = Channel<Job>(100)                               // ✅ 차면 send 가 suspend

scope.launch {
    for (job in jobs) handle(job)              // ✅ 비면 코루틴만 쉰다
}
8 · 확인하고 꺼내기 (경쟁 조건)
val q = ConcurrentLinkedQueue<Job>()
if (!q.isEmpty()) {
    process(q.poll())   // ❌ 그 사이 다른 스레드가 가져가면 poll() = null
}                       //    process(Job) 이 non-null 이면 그 자리에서 NPE
꺼낸 결과 하나로 판단
val q = ConcurrentLinkedQueue<Job>()
val job: Job? = q.poll()    // ✅ 원자적 — 꺼냈거나, 없었거나
if (job != null) process(job)
// 또는: val job = q.poll() ?: return
2 번이 왜 위험한가 다시 한 번 — java.util.ArrayDeque 로 바뀌어도 addLast · removeFirst 는 이름이 같아 컴파일이 됩니다. 달라지는 건 null 을 넣으면 NPE, d[i] 컴파일 에러, removeFirstOrNull() 이 MutableList 확장이 아니라서 안 보임(자바 것은 MutableList 가 아님) 정도인데, 마지막 것 때문에 누군가 pollFirst() 로 고치기 시작하면 코드베이스에 두 덱이 섞입니다. IntelliJ 의 "Use single name import" · 와일드카드 import 임계값을 크게 잡아 두세요.
정리 — 가장 비싼 실수는 MutableList.removeFirst/removeLast(구형 기기 크래시)와 무제한 큐(OOM)입니다. 나머지는 "꺼낸 결과로 판단하고, 비는 게 정상이면 OrNull" 하나로 대부분 막힙니다.
SQ 11

다른 언어와 비교 — 같은 일을 무엇으로 하나

코틀린은 서버에서는 자바와, 앱에서는 스위프트 · 다트와, 공통 코드에서는 여러 언어와 나란히 놓입니다. 다른 언어의 코드를 옮길 때 무엇이 무엇에 대응하는지, 그리고 빈 컨테이너에서 꺼내면 무슨 일이 나는지를 한 장으로 봅니다.

할 일KotlinJava 21+Python 3.13JS / TSC# (.NET 8+)C++23Rust
스택ArrayDeque addLast/removeLastArrayDeque push/poplist append/popArray push/popStack<T>std::stack · vectorVec push/pop
큐ArrayDeque addLast/removeFirstArrayDeque offer/pollcollections.deque popleft내장 없음 — shift() 는 O(n)Queue<T>std::queueVecDeque
덱 구현원형 배열 · 1.5배원형 배열블록 연결 리스트—내장 덱 없음청크 배열의 맵원형 배열
우선순위 큐java.util.PriorityQueue (JVM)PriorityQueue (최소)heapq 함수 (최소)내장 없음PriorityQueue<E,P> (최소)priority_queue (최대)BinaryHeap (최대)
스레드 간 큐ArrayBlockingQueue 등같음queue.QueueWorker postMessageBlockingCollection · ConcurrentQueue표준 없음 (직접 · 라이브러리)std::sync::mpsc
비동기 채널Channel · Flow.buffer표준 없음 (가상 스레드 + BlockingQueue)asyncio.Queue표준 없음 (스트림 · 직접 구현)System.Threading.Channels표준 없음tokio::sync::mpsc (크레이트)
빈 곳에서 꺼내기예외 또는 OrNull → null예외 또는 poll → nullIndexErrorundefined예외 또는 TryDequeue → false미정의 동작Option::None

같은 일 — "큐를 비울 때까지 꺼내기 · 스택에서 하나 빼기"

Kotlin

fun main() {
    val q = ArrayDeque(listOf("A", "B"))
    while (true) println(q.removeFirstOrNull() ?: break)   // A, B
    val st = ArrayDeque(listOf(1))
    st.addLast(2)
    println(st.removeLast())                                // 2
}

Java 21

import java.util.ArrayDeque;

public class Q {
    public static void main(String[] args) {
        var q = new ArrayDeque<String>();
        q.offer("A"); q.offer("B");
        String x;
        while ((x = q.poll()) != null) System.out.println(x);   // A, B
        var st = new ArrayDeque<Integer>();
        st.push(1); st.push(2);
        System.out.println(st.pop());                          // 2
    }
}

Python 3.13

from collections import deque

q = deque(["A", "B"])
while q:
    print(q.popleft())        # A, B   — list.pop(0) 은 O(n)
st = [1]
st.append(2)
print(st.pop())               # 2      — 빈 list 면 IndexError

TypeScript (Node 24)

// 큐가 내장돼 있지 않다 — head 인덱스로 shift() 의 O(n) 을 피한다
const q: string[] = ["A", "B"];
let head = 0;
while (head < q.length) console.log(q[head++]);   // A, B
const st: number[] = [1];
st.push(2);
console.log(st.pop());                            // 2

Rust

use std::collections::VecDeque;

fn main() {
    let mut q = VecDeque::from(["A", "B"]);
    while let Some(x) = q.pop_front() {
        println!("{x}");                  // A, B
    }
    let mut st = vec![1];
    st.push(2);
    println!("{:?}", st.pop());           // Some(2) — 빈 Vec 이면 None
}

코틀린 · 자바 · 파이썬 · TS 는 이 탭을 쓰며 실제로 실행해 출력을 확인했습니다(Rust 는 손으로 추적). 눈여겨볼 점은 빈 큐 종료 조건이 어디에 있나입니다 — 자바는 poll() 의 null, 파이썬은 while q(비면 거짓), Rust 는 Option 패턴, 코틀린은 ?: break. 코틀린과 Rust 는 꺼내기와 종료 판정이 한 표현식입니다.

자바 코드를 옮길 때 — 이름 바꾸기 표

자바코틀린
Deque<Integer> st = new ArrayDeque<>();val st = ArrayDeque<Int>()
st.push(x) / st.pop() / st.peek()st.addLast(x) / st.removeLast() / st.lastOrNull()
q.offer(x) / q.poll()q.addLast(x) / q.removeFirstOrNull()
while (!q.isEmpty()) { var x = q.poll(); … }while (true) { val x = q.removeFirstOrNull() ?: break; … }
new PriorityQueue<>(Comparator.comparing(T::getP))PriorityQueue(compareBy<T> { it.p })
Collections.reverseOrder()reverseOrder() · compareByDescending { }
push 를 그대로 addFirst 로 옮기지 마세요 — 자바 push 는 앞쪽 끝입니다. 기계적으로 addFirst/removeFirst 로 옮겨도 동작은 맞지만, 팀 규칙이 "스택은 Last 쪽"이라면 섞입니다. 옮길 때 한쪽으로 통일하세요(SQ 02).

코틀린만의 특징 세 가지

① 한 타입이 셋을 맡는다 — 파이썬(list · deque), C#(Stack · Queue), C++(stack · queue · deque) 과 달리 코틀린은 ArrayDeque 하나에 MutableList 까지 겸합니다.

② 빈 큐를 타입으로 다룬다 — Rust 의 Option 처럼 removeFirstOrNull() 의 반환형이 T? 라서, null 을 처리하지 않으면 컴파일이 안 됩니다. C++ 의 "빈 큐 pop = 미정의 동작"과 가장 먼 쪽입니다.

③ 우선순위 큐는 플랫폼에 기댄다 — 표준 라이브러리가 멀티플랫폼이라 JVM 전용 구현을 넣지 않았습니다. JVM 에서는 자바 것을, 공통 코드에서는 직접 힙을(SQ 05).

정리 — 이름만 다를 뿐 구조는 같습니다. 코틀린의 차이는 한 타입(ArrayDeque)으로 통일, 빈 큐를 T? 로 표현, 우선순위 큐는 JVM 것을 빌려 씀 세 가지입니다.
SQ 12

실무 사례 — 최근 N 개 · 레이트 리미터 · 이벤트 · 재시도 · 워커 풀

지금까지의 도구가 실제 코드에서 어떤 모양이 되는지 다섯 사례로 정리합니다. 각 사례는 어떤 타입을 왜 골랐는지와 함께, 그대로 붙여 쓸 수 있는 크기로 만들었습니다(안드로이드 예제 외에는 실행 확인).

사례타입이유
최근 본 상품 · 최근 로그 N 줄ArrayDeque(limit)앞에서 버리고 뒤에 넣기가 둘 다 O(1)
레이트 리미터 (슬라이딩 윈도우 로그)ArrayDeque<Long> + 잠금오래된 시각은 항상 앞쪽 — 앞에서만 지운다
안드로이드 일회성 UI 이벤트Channel(BUFFERED) → receiveAsFlow()화면이 없을 때 온 이벤트도 보관, 한 번 꺼내면 끝
재시도 · 예약 실행PriorityQueue (시각 순) · DelayQueue가장 이른 것 하나만 보면 된다
백그라운드 작업 분배produce(capacity) + 워커 N유한 버퍼가 배압, 채널이 팬아웃

① 최근 N 개 보관 · ② 레이트 리미터

// 최근 N 개만 보관 — 가득 차면 가장 오래된 것을 밀어낸다 (단일 스레드용)
class Recent<T>(private val limit: Int) : Iterable<T> {
    init { require(limit > 0) { "limit > 0 이어야 합니다" } }
    private val buf = ArrayDeque<T>(limit)                 // 처음부터 limit 칸 — 재할당 없음

    fun add(item: T) {
        if (buf.size == limit) buf.removeFirst()           // 가장 오래된 것 하나 버림
        buf.addLast(item)
    }
    fun latest(): T? = buf.lastOrNull()
    fun newestFirst(): List<T> = buf.asReversed()          // 복사 없는 역순 '뷰'
    override fun iterator() = buf.iterator()               // 오래된 → 최신
}

fun main() {
    val recent = Recent<String>(3)
    listOf("홈", "검색", "상품A", "장바구니", "결제").forEach(recent::add)
    println(recent.toList())        // [상품A, 장바구니, 결제]
    println(recent.newestFirst())   // [결제, 장바구니, 상품A]
    println(recent.latest())        // 결제
}
asReversed() 는 복사하지 않는 뷰입니다 — "최신 순 목록"을 화면에 줄 때 매번 새 리스트를 만들지 않습니다. 여러 스레드가 쓰면 @Synchronized 를 붙이거나, 코루틴이라면 Mutex 로 감쌉니다.
// 슬라이딩 윈도우 레이트 리미터 — 최근 windowMs 안의 요청 시각을 큐에 둔다
class SlidingWindowLimiter(
    private val limit: Int,
    private val windowMs: Long,
    private val now: () -> Long = System::currentTimeMillis,   // 테스트에서 시계를 바꿔 끼운다
) {
    private val hits = ArrayDeque<Long>()

    @Synchronized                                      // 여러 스레드가 부르면 잠금 (JVM)
    fun tryAcquire(): Boolean {
        val t = now()
        while (hits.isNotEmpty() && hits.first() <= t - windowMs) hits.removeFirst()   // 창 밖 = 앞쪽
        if (hits.size >= limit) return false
        hits.addLast(t)
        return true
    }
}

fun main() {
    var clock = 0L
    val limiter = SlidingWindowLimiter(limit = 3, windowMs = 1_000, now = { clock })
    val log = listOf(0L, 200, 400, 600, 1_000, 1_250, 1_300).map { t ->
        clock = t
        "$t:" + if (limiter.tryAcquire()) "O" else "X"
    }
    println(log)   // [0:O, 200:O, 400:O, 600:X, 1000:O, 1250:O, 1300:X]
}
1초 창 · 최대 3건 — 점선 상자가 1300ms 시점의 창 (300, 1300] — 덱 안: 400 · 1000 · 1250 → 3건 0 200 400 600 1000 1250 1300 ms O O O X O O X 새 요청마다 앞쪽에서 창 밖 기록을 지우고(removeFirst), 남은 수로 판정합니다 1000ms 에는 0 이 창 밖으로 빠져 다시 자리가 생겼습니다
오래된 기록은 언제나 덱의 앞에 있습니다 — 그래서 큐 하나로 창이 미끄러집니다.

③ 안드로이드 — 일회성 UI 이벤트를 Channel 로

// ViewModel — 한 번만 처리할 이벤트(토스트 · 화면 이동)를 '큐'로 내보낸다
class CheckoutViewModel(private val repo: PayRepository) : ViewModel() {
    sealed interface UiEvent {
        data class Toast(val msg: String) : UiEvent
        data object GoToReceipt : UiEvent
    }

    private val _events = Channel<UiEvent>(Channel.BUFFERED)  // 화면이 잠깐 없어도 64개까지 보관
    val events: Flow<UiEvent> = _events.receiveAsFlow()       // 꺼내면 큐에서 빠진다 — 수집자는 하나

    fun pay() {
        viewModelScope.launch {
            val ok = repo.pay()                                // 성공 여부 Boolean (예외는 repo 안에서 처리)
            _events.send(if (ok) UiEvent.GoToReceipt else UiEvent.Toast("결제 실패"))
        }
    }
}

// Compose — 화면이 STARTED 일 때만 꺼낸다
@Composable
fun CheckoutScreen(vm: CheckoutViewModel, onReceipt: () -> Unit) {
    val context = LocalContext.current
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    LaunchedEffect(vm, lifecycle) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            withContext(Dispatchers.Main.immediate) {          // 꺼낸 직후 처리 전에 끊기는 틈을 줄인다
                vm.events.collect { e ->
                    when (e) {
                        is CheckoutViewModel.UiEvent.Toast ->
                            Toast.makeText(context, e.msg, Toast.LENGTH_SHORT).show()
                        CheckoutViewModel.UiEvent.GoToReceipt -> onReceipt()
                    }
                }
            }
        }
    }
}

왜 Channel 인가 · 무엇을 조심하나

후보회전 · 백그라운드 중 이벤트
StateFlow마지막 값을 다시 받음 — 토스트가 두 번 뜬다
SharedFlow(replay = 0)수집자가 없으면 사라진다
Channel(BUFFERED)버퍼에 보관 → 화면이 돌아오면 한 번 전달
수집자는 하나여야 합니다 — 채널은 큐라서 두 곳에서 모으면 이벤트가 나뉘어 갑니다. 또 공식 아키텍처 가이드는 "이벤트도 가능하면 상태로 표현하고 처리했다고 알리는" 방식을 권합니다. Channel 은 그보다 간단해서 널리 쓰이는 관용구이며, Dispatchers.Main.immediate 에서 모아야 꺼낸 직후 처리 전에 끊기는 틈이 최소화됩니다.

④ 재시도 큐 · ⑤ 코루틴 워커 풀

import java.util.PriorityQueue

// 재시도 큐 — '다음 시도 시각'이 가장 이른 것부터. 지수 백오프로 시각을 미룬다
data class Retry(val id: String, val attempt: Int, val dueAt: Long)

class RetryQueue(private val maxAttempts: Int = 3, private val baseMs: Long = 100) {
    private val pq = PriorityQueue<Retry>(compareBy<Retry> { it.dueAt }.thenBy { it.id })
    val dead = mutableListOf<String>()                              // 끝내 실패 — DLQ

    fun fail(id: String, attempt: Int, now: Long) {
        if (attempt >= maxAttempts) { dead += id; return }
        pq += Retry(id, attempt + 1, now + baseMs * (1L shl attempt))  // 100 → 200 → 400 ms
    }
    // now 시점에 시도할 차례가 된 것만 꺼낸다 — 꼭대기만 보면 된다
    fun due(now: Long): List<Retry> = buildList {
        while (pq.peek()?.let { it.dueAt <= now } == true) add(pq.poll())
    }
}

fun main() {
    val rq = RetryQueue()
    rq.fail("A", attempt = 0, now = 0)        // A 1회차 → 100ms 뒤
    rq.fail("B", attempt = 1, now = 0)        // B 2회차 → 200ms 뒤
    rq.fail("C", attempt = 3, now = 0)        // C 는 이미 3번 — DLQ 로
    println(rq.due(150).map { "${it.id}#${it.attempt}" })   // [A#1]
    println(rq.due(250).map { "${it.id}#${it.attempt}" })   // [B#2]
    println(rq.dead)                                       // [C]
}

스레드 기반이라면 java.util.concurrent.DelayQueue 가 같은 일을 블로킹으로 해 줍니다(원소가 Delayed 를 구현). 시각은 테스트를 위해 인자로 받는 편이 좋습니다 — 위 코드는 now 를 주입해 결정적으로 돌아갑니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import java.util.concurrent.atomic.AtomicInteger

// 코루틴 워커 풀 — 유한 채널이 '작업 큐', 워커 N 개가 나눠 가져간다
@OptIn(ExperimentalCoroutinesApi::class)
fun CoroutineScope.jobs(ids: IntRange): ReceiveChannel<Int> =
    produce(capacity = 8) {                       // 8칸이 차면 생산이 suspend — 배압
        for (id in ids) send(id)
    }                                             // 블록이 끝나면 자동 close

fun main() = runBlocking {
    val queue = jobs(1..100)
    val done = AtomicInteger()
    val workers = List(4) { w ->
        launch(Dispatchers.Default) {
            for (id in queue) {                   // 하나의 작업은 한 워커만 받는다 (팬아웃)
                delay(5)                          // 일하는 척
                done.incrementAndGet()
            }
        }
    }
    workers.joinAll()
    println("처리 ${done.get()}건")                // 처리 100건
}
배압이 공짜로 따라온다 — produce(capacity = 8) 이라 워커 넷이 느리면 생산자가 9 번째에서 멈춥니다. 1 만 건이든 1 억 건이든 메모리에는 최대 8 건 + 처리 중 4 건만 있습니다. 채널 · produce 의 기본은 CORO 15.
정리 — 사례는 달라도 질문은 같습니다: 어느 끝에서 꺼내나 · 누가 함께 쓰나 · 가득 차면 어떻게 하나. 답이 정해지면 타입은 ArrayDeque · PriorityQueue · BlockingQueue · Channel 넷 중 하나로 정해집니다.
KOTLIN 2.3 · GRADLE 9 · 버전 카탈로그 · SPRING BOOT · KTOR 3 · ANDROID · KMP · COMPOSE MULTIPLATFORM · 2026 기준

같은 언어,
다섯 가지
폴더 지도

코틀린은 서버 · 안드로이드 · iOS 공유 · 데스크톱 · 라이브러리를 모두 같은 Gradle 위에서 만듭니다. 그래서 바탕(설정 파일 · 버전 카탈로그 · src/main/kotlin)은 같고, 그 위에 올리는 구조만 유형마다 다릅니다. 이 탭은 다른 탭에 흩어진 구조 이야기를 한 장으로 모아 비교하고, "지금 규모면 어디까지 나눠야 하나"에 답합니다. 상세 설명은 해당 탭 섹션으로 이어 둡니다. internal 경계와 explicit API 모드 오류 메시지는 Kotlin 2.3.20 컴파일러로 직접 돌려 옮겼습니다.

10SECTIONS
10시각화
5프로젝트 유형
1장짜리 요약표
STR 01

코틀린 프로젝트의 공통 바탕 — Gradle · 카탈로그 · 규모별 진화

코틀린 프로젝트는 유형이 달라도 파일 세 개가 뼈대입니다 — 모듈 목록을 적는 settings.gradle.kts, 모듈마다 하나씩 있는 build.gradle.kts, 버전을 한곳에 모은 gradle/libs.versions.toml. 각 파일의 문법은 설치 탭 SETUP 05 에 있으니 여기서는 어디에 무엇을 두는지와 커지면 어떻게 바뀌는지만 봅니다.

모듈 하나일 때 — 모든 유형의 출발점

shop/
├─ settings.gradle.kts      # 프로젝트 이름 · 저장소 · (나중에) 모듈 목록
├─ build.gradle.kts         # 플러그인 · 의존성
├─ gradle/
│  ├─ libs.versions.toml    # ★ 버전 카탈로그 — 버전은 여기에만
│  └─ wrapper/              # Gradle 버전 고정 (커밋)
├─ gradle.properties        # JVM 메모리 · 캐시 · 병렬 빌드 스위치
├─ src/
│  ├─ main/kotlin/          # 운영 코드 (자바 섞이면 main/java 도)
│  ├─ main/resources/       # 설정 · SQL · 정적 파일
│  └─ test/kotlin/          # 같은 패키지 이름으로 테스트
└─ build/                   # 산출물 — 커밋 금지

settings.gradle.kts — 블록 순서가 정해져 있습니다

pluginManagement {                       // 반드시 맨 위
    repositories { gradlePluginPortal(); google(); mavenCentral() }
}
dependencyResolutionManagement {
    // 모듈이 제멋대로 저장소를 추가하지 못하게
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories { google(); mavenCentral() }
}
rootProject.name = "shop"
include(":app", ":core:model", ":core:data")   // 커지면 여기만 늘어남
pluginManagement 는 첫 블록이어야 합니다 — rootProject.name 을 위에 쓰면 설정 단계에서 오류가 납니다. 저장소를 settings 에 모으는 것은 모듈이 수십 개가 됐을 때 "이 라이브러리 어디서 받았지?"를 막는 장치입니다.

gradle/libs.versions.toml — 버전의 유일한 자리

[versions]
kotlin = "2.3.20"
coroutines = "1.10.2"

[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }

[bundles]
testing = ["kotlinx-coroutines-test"]

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
// 모듈의 build.gradle.kts — 숫자가 하나도 없습니다
plugins { alias(libs.plugins.kotlin.jvm) }
dependencies {
    implementation(libs.kotlinx.coroutines.core)   // 하이픈 → 점
    testImplementation(libs.bundles.testing)
}

코틀린만의 규칙 — 공통 루트 패키지는 폴더에서 생략

# 코틀린 코딩 컨벤션 (순수 코틀린 프로젝트)
src/main/kotlin/
├─ App.kt                  # package com.example.shop
├─ order/OrderService.kt   # package com.example.shop.order
└─ network/Client.kt       # package com.example.shop.network

# 자바와 섞인 프로젝트 (Spring 등) — 자바식 그대로
src/main/kotlin/com/example/shop/order/OrderService.kt
코틀린 컴파일러는 폴더와 패키지가 달라도 컴파일합니다(IDE 가 경고만 함). 공식 컨벤션은 순수 코틀린이면 루트 패키지를 생략해 폴더를 얕게 두라고 하지만, 자바와 섞이거나 팀이 자바 출신이면 자바식 전체 경로가 무난합니다. 한 저장소 안에서 섞지만 마세요.

규모별 진화 — 언제 다음 단계로 넘어가나 🟢 기초

위는 구조, 아래는 그 구조로 넘어가는 신호 — 신호가 없으면 넘어가지 않습니다 1단계 2단계 3단계 4단계 5단계 모듈 하나 파일 몇 개 기능별 패키지 order/ · member/ Gradle 멀티 모듈 :core · :feature build-logic 컨벤션 플러그인 규칙 자동 검사 Konsist · 의존 검사 시작점 위저드 그대로 파일 30개 · 2명+ 찾는 데 시간이 듦 빌드 2분+ 배포 단위가 갈림 모듈 10개+ 빌드 설정 복붙 모듈 수십 · 팀 여럿 사람이 못 지킴 서버 대부분은 여기까지 안드로이드 · KMP 는 3단계에 더 일찍 옵니다 — 빌드 시간이 먼저 아프기 때문입니다
구조는 신호가 올 때 한 칸씩 키웁니다 — 처음부터 5단계 구조를 깔면 모듈 관리 비용만 먼저 냅니다.
신호무엇이 아픈가다음 단계
파일 30개 · 사람 2명"이 클래스 어디 있지?" · 같은 파일을 동시에 고쳐 충돌기능별 패키지 (STR 02)
빌드 2분 넘음한 줄 고쳐도 전체가 다시 컴파일됨모듈 분리 — 바뀐 모듈만 다시 빌드 (STR 05)
배포물이 둘 이상서버 · 배치 · 관리자 앱이 코드를 나눠 씀공통 코드를 :core 모듈로
모듈 10개 이상build.gradle.kts 마다 같은 30줄build-logic (STR 06)
팀이 여럿리뷰로 막던 의존 규칙이 새기 시작아키텍처 테스트 · 의존 검사 (STR 10)
정리 — 바탕은 settings · build · libs.versions.toml 세 파일로 모든 유형이 같습니다. 버전 숫자는 카탈로그에만 두고, 구조는 위 표의 신호가 올 때만 한 칸씩 키웁니다.
STR 02

Spring Boot + Kotlin — 기능별 패키지와 코틀린다운 파일 배치

스프링 서버의 구조는 자바와 거의 같습니다 — 루트 패키지에 메인 클래스, 그 아래 기능별 패키지. 코틀린이 바꾸는 것은 파일 단위입니다. 한 파일에 클래스 여러 개와 최상위 함수를 둘 수 있어서, "어떤 파일에 무엇을 묶나"라는 자바에 없던 결정이 생깁니다. 헥사고날로 더 나누는 법은 전문가 탭 DEEP 14 를 보세요.

같은 서버, 두 가지 패키지 — 주문 기능 하나를 고칠 때 여는 폴더 수 계층별 (layer) 기능별 (feature) — 권장 controller/ OrderController · MemberController service/ OrderService · MemberService repository/ OrderRepository · MemberRepository order/ OrderController · OrderService OrderRepository · OrderDtos.kt member/ MemberController · MemberService MemberRepository · MemberDtos.kt 주문 수정 = 폴더 3개를 오감 · 떼어 내기 어려움 주문 수정 = 폴더 1개 · 통째로 모듈로 승격 가능 파일이 서른 개를 넘으면 오른쪽이 이깁니다. 그 전에는 어느 쪽이든 괜찮습니다
기능별 패키지는 "함께 바뀌는 것을 함께 둔다"는 규칙입니다 — 나중에 모듈로 떼어 낼 때 경계가 이미 그어져 있습니다.

권장 트리 — 모듈 하나짜리 스프링 서버

shop-api/
├─ build.gradle.kts          # kotlin("plugin.spring") · kotlin("plugin.jpa")
└─ src/main/kotlin/com/example/shop/
   ├─ ShopApplication.kt     # @SpringBootApplication + fun main — 루트에
   ├─ order/                 # 기능 하나 = 패키지 하나
   │  ├─ OrderController.kt
   │  ├─ OrderService.kt
   │  ├─ OrderRepository.kt  # interface : JpaRepository
   │  ├─ Order.kt            # @Entity
   │  ├─ OrderDtos.kt        # 요청·응답 data class 여럿을 한 파일에
   │  └─ OrderMappers.kt     # fun Order.toResponse() 같은 확장 함수
   ├─ member/
   └─ common/                # 진짜 공통만 — 설정 · 예외 처리
      ├─ config/
      └─ error/GlobalExceptionHandler.kt
메인 클래스는 루트 패키지에 — 컴포넌트 스캔이 그 아래만 훑기 때문입니다. common/ 이 커지면 경고 신호입니다. "두 기능 이상이 실제로 쓰는가"를 통과한 것만 둡니다.

파일 하나에 무엇을 묶나 — 공식 컨벤션

파일 내용파일 이름
클래스 하나 (+ 관련 최상위 함수)클래스 이름 그대로 Order.kt
작은 클래스 여럿 (DTO · sealed 계층)내용을 설명하는 이름 OrderDtos.kt
최상위 함수 · 확장 함수만무엇을 하는지 OrderMappers.kt
아무 이름이나 Util.kt피하세요 — 무엇이든 들어가는 서랍이 됩니다
// order/OrderDtos.kt — 함께 바뀌는 작은 클래스는 한 파일에
data class OrderRequest(val item: String, val qty: Int)
data class OrderResponse(val id: Long)

fun OrderRequest.isBulk(): Boolean = qty >= 100
// 컴파일 결과: OrderRequest.class · OrderResponse.class
//            · OrderDtosKt.class ← 최상위 함수 isBulk 를 담는 클래스

internal 은 패키지가 아니라 모듈 경계 🟡 중급

❌ 기대 — "internal 이면 다른 패키지에서 못 쓴다"

모듈이 하나인 스프링 서버에서 order/ 의 클래스를 internal 로 해도 member/ 에서 그대로 보입니다. 코틀린에는 자바의 package-private 이 없고, internal 의 범위는 같은 Gradle 모듈(컴파일 단위) 전체입니다.

✅ 실제 — 모듈을 나눠야 경계가 생긴다

:order-core 를 별도 모듈로 떼고 구현을 internal 로 두면 다른 모듈의 접근이 컴파일 오류가 됩니다. 작은 서버라면 private + 파일 단위로 숨기고, 경계가 진짜 필요해지면 모듈로 승격합니다.

직접 돌려 본 결과 (kotlinc 2.3.20)

// :core 모듈
interface OrderRepository { fun find(id: Long): Order? }
internal class InMemoryOrderRepository : OrderRepository { /* … */ }
fun orderRepository(): OrderRepository = InMemoryOrderRepository()

// :app 모듈 — 공개 함수로 받으면 OK
val repo = orderRepository()
println(repo.find(1)?.label())       // 출력: #1 5000원

// :app 모듈 — 구현을 직접 부르면
val bad = InMemoryOrderRepository()
// error: cannot access 'class InMemoryOrderRepository : OrderRepository':
//        it is internal in file.

자바 · 스프링 쪽에서 보면

$ javap Svc.class        # -module-name order-core 로 컴파일
public final int helper$order_core();   ← internal fun helper()
public final int run();
internal 은 바이트코드에서 public 이 되고 멤버 이름이 이름$모듈 로 바뀝니다. 자바 코드나 리플렉션에서는 막히지 않으니 코틀린 컴파일러가 지키는 약속으로 이해하세요. 스프링 빈을 internal class 로 두는 것은 문제없습니다.
정리 — 스프링은 루트에 메인, 그 아래 기능별 패키지. 작은 클래스는 OrderDtos.kt 처럼 한 파일에 묶고, internal 경계는 모듈을 나눌 때부터 효과가 있습니다. 큰 모놀리스의 기능 경계 검사는 Spring Modulith(STR 10)로 넘깁니다.
STR 03

Ktor 서버 구조 — Application.module 은 조립만

Ktor 는 애너테이션도 컴포넌트 스캔도 없어서, 구조를 정하지 않으면 Application.kt 한 파일이 끝없이 길어집니다. 해법은 하나입니다 — 설정은 Application 확장 함수, 라우트는 Route 확장 함수로 쪼개고 module() 은 그것들을 부르는 목차로만 둡니다. 라우팅 · 플러그인 문법은 Ktor 탭 KTOR 03 · 04 에 있습니다.

기동부터 DB 까지 — 단계마다 파일이 하나씩 맡습니다 EngineMain yaml 읽기 module() 목차 · 조립 plugins/ install 모음 order/Routes Route 확장 함수 Service + Repository 조립 영역 — Ktor 를 아는 곳 Ktor 를 모를수록 좋음 서비스가 call · ApplicationCall 을 받기 시작하면 테스트가 무거워집니다 라우트에서 파라미터를 풀고, 서비스에는 평범한 값만 넘기세요
Ktor 구조의 핵심은 Ktor 의존을 앞 세 칸에 가두는 것입니다 — 그러면 서비스는 순수 코틀린 단위 테스트로 끝납니다.

권장 트리 — 생성기 구조 + 기능 폴더

src/main/
├─ kotlin/com/example/
│  ├─ Application.kt        # main + Application.module() — 목차만
│  ├─ plugins/              # 플러그인 설정 (생성기가 만드는 자리)
│  │  ├─ Serialization.kt   # fun Application.configureSerialization()
│  │  ├─ Security.kt
│  │  ├─ Monitoring.kt
│  │  └─ Routing.kt         # 기능별 라우트 함수를 한데 모음
│  ├─ di/AppModule.kt       # Koin module { } — 객체 그래프 한 장
│  ├─ order/                # 기능 폴더
│  │  ├─ OrderRoutes.kt     # fun Route.orderRoutes(service)
│  │  ├─ OrderService.kt    # Ktor import 없음
│  │  └─ OrderRepository.kt # interface + Exposed 구현
│  └─ member/
└─ resources/
   ├─ application.yaml      # 포트 · modules 목록 · 환경 변수
   └─ logback.xml

목차 · 라우트 · DI 를 각자 파일로

// Application.kt
fun main(args: Array<String>) = EngineMain.main(args)

fun Application.module() {
    configureSerialization()
    configureKoin()
    configureRouting()
}

// plugins/Routing.kt
fun Application.configureRouting() {
    val orders by inject<OrderService>()     // org.koin.ktor.ext.inject
    routing {
        route("/api") { orderRoutes(orders) }
    }
}

// order/OrderRoutes.kt
fun Route.orderRoutes(service: OrderService) = route("/orders") {
    get("/{id}") {
        val id = call.parameters["id"]?.toLongOrNull()
            ?: return@get call.respond(HttpStatusCode.BadRequest)
        val order = service.find(id)
            ?: return@get call.respond(HttpStatusCode.NotFound)
        call.respond(order)
    }
}

// di/AppModule.kt
val appModule = module {
    single<OrderRepository> { ExposedOrderRepository() }
    singleOf(::OrderService)
}
fun Application.configureKoin() {
    install(Koin) { slf4jLogger(); modules(appModule) }
}
규모구조Koin 모듈 위치
라우트 10개 미만plugins/ + Routing.kt 하나, 서비스는 루트Koin 없이 module() 에서 생성자 호출로 충분
기능 3개 이상위 트리 — 기능 폴더마다 Routes · Service · Repositorydi/AppModule.kt 한 장
기능 10개 이상 · 팀 2개기능 폴더를 Gradle 모듈로 (:order, :member), :server 가 조립모듈마다 orderModule 을 공개, :server 가 modules(...) 로 합침
application.yaml 의 modules 와 코드가 어긋나면 기동 시 ClassNotFoundException 이 납니다. 값은 com.example.ApplicationKt.module 처럼 파일명 + Kt 클래스를 가리킵니다 — Application.kt 의 이름을 바꾸면 yaml 도 같이 바꾸세요. Ktor 3.2 부터 자체 DI 플러그인도 생겼지만, 위치 규칙(di/ 한 곳에 그래프)은 같습니다.
정리 — module() 은 목차, 설정은 plugins/ 의 Application 확장 함수, URL 은 기능 폴더의 Route 확장 함수, 객체 그래프는 di/ 한 장. 서비스는 Ktor 를 import 하지 않게 둡니다.
STR 04

Android 앱 구조 — 공식 아키텍처 가이드를 폴더로 옮기기

안드로이드는 구글이 공식 권장 아키텍처(UI 계층 · 선택적 도메인 계층 · 데이터 계층)를 문서로 정해 두었습니다. 계층의 뜻과 ViewModel 코드는 Android 탭 AND 01, 앱 실전 탭 ANDX 01 에 있으니, 여기서는 그 계층이 모듈 하나짜리 앱에서 어떤 패키지 트리가 되는지와 각 클래스의 자리만 정리합니다.

계층과 그 계층이 사는 패키지 — 위층만 아래층을 부릅니다 UI 계층 Composable · ViewModel · UiState ui/feed/FeedScreen.kt · FeedViewModel.kt 화면 하나 = 폴더 하나 도메인 계층 (선택) UseCase — 여러 리포지터리 조합 domain/GetFeedWithAuthorsUseCase.kt 재사용 로직이 생길 때만 만듭니다 Repository 앱의 나머지가 보는 유일한 창구 data/news/NewsRepository.kt 데이터 종류별 폴더 · 이름 = 데이터 + Repository DataSource Remote(Retrofit · Ktor) · Local(Room) data/news/NewsRemoteDataSource.kt 이름 = 데이터 + 출처 + DataSource 호출 방향 ViewModel 이 DataSource 를 직접 부르면 계층 하나를 건너뛴 것입니다
한 계층 = 한 최상위 패키지(ui/ · domain/ · data/), 그 안은 화면별 · 데이터별로 나눕니다.

모듈 하나짜리 앱의 패키지 트리

app/src/main/java/com/example/news/   # 폴더 이름은 java, 파일은 .kt
├─ NewsApplication.kt         # @HiltAndroidApp
├─ MainActivity.kt            # setContent { NewsApp() } 뿐
├─ navigation/NewsNavHost.kt  # 화면 연결은 한곳에
├─ ui/
│  ├─ feed/
│  │  ├─ FeedScreen.kt        # 상태를 받아 그리기만
│  │  ├─ FeedViewModel.kt     # StateFlow<FeedUiState> 노출
│  │  └─ FeedUiState.kt       # sealed interface
│  ├─ detail/
│  └─ theme/                  # Color.kt · Type.kt · Theme.kt
├─ domain/                    # 선택
│  └─ GetFeedWithAuthorsUseCase.kt
└─ data/
   ├─ news/
   │  ├─ NewsRepository.kt
   │  ├─ NewsRemoteDataSource.kt
   │  └─ NewsLocalDataSource.kt
   ├─ model/Article.kt        # 앱 전체가 쓰는 모델
   └─ di/DataModule.kt        # @Module @InstallIn — 구현 연결

무엇을 어디에 — 판단 표

클래스자리 · 규칙
Composable 화면ui/<화면>/ — ViewModel 을 받는 바깥 함수 하나 + 상태만 받는 안쪽 함수(프리뷰 · 테스트용)
ViewModel화면 폴더 안. Context · View 를 들고 있지 않음
UseCasedomain/. 동사 + 대상 + UseCase, operator fun invoke() 하나
Repositorydata/<데이터>/. 화면 이름이 아니라 데이터 이름으로
DataSource리포지터리 옆. 한 출처만 다룸, 다른 DataSource 를 부르지 않음
DTO · EntityDataSource 옆에 두고 리포지터리 밖으로 내보내지 않음 — 밖에는 Article 같은 모델만
❌ 화면 이름으로 리포지터리를 만듦

FeedRepository · DetailRepository — 두 화면이 같은 기사 데이터를 쓰는데 캐시가 둘로 갈라지고, 상세에서 북마크한 것이 피드에 안 보입니다.

✅ 데이터 이름으로 하나

NewsRepository 하나가 기사의 단일 진실 공급원입니다. 화면은 그 Flow 를 관찰만 하므로 어디서 바꿔도 모든 화면이 따라옵니다.

모듈로 나눌 시점 — 화면이 열 개 안팎이 되거나 클린 빌드가 몇 분을 넘으면 이 패키지들이 그대로 :feature:feed · :core:data 모듈이 됩니다(STR 05). 패키지를 화면별 · 데이터별로 나눠 두었으면 폴더를 옮기기만 하면 됩니다.
정리 — ui/화면/ 에 Screen · ViewModel · UiState, data/데이터/ 에 Repository · DataSource, 도메인은 필요할 때만. 리포지터리는 화면이 아니라 데이터 이름으로 만듭니다.
STR 05

Android 모듈화 — :app · :feature:* · :core:*

구글 공식 샘플 Now in Android 가 사실상 표준이 된 모듈 배치입니다. 규칙은 단 하나로 요약됩니다 — 의존은 아래로만, feature 끼리는 서로 모른다. Hilt 로 모듈 사이를 잇는 코드는 앱 실전 탭 ANDX 01 에 있고, 여기서는 배치와 의존 규칙, 빌드 시간 효과를 봅니다.

곡선은 의존 방향 — 붉은 점선은 금지된 의존입니다 :app :feature order :feature cart :core ui :core data :core network :core database :core model 직접 의존 금지 이동은 :app 이 잇습니다 모두가 봄 (선 생략) feature 하나를 고치면 다시 빌드되는 것은 그 feature 와 :app 뿐입니다
Now in Android 식 모듈 그래프 — feature 는 형제끼리 모르고, 아래쪽 core 는 위쪽을 전혀 모릅니다.

모듈 트리와 각자의 책임

myapp/
├─ app/                     # Application · MainActivity · NavHost 조립
├─ feature/
│  ├─ order/                # 화면 + ViewModel (한 사용자 흐름)
│  └─ cart/
├─ core/
│  ├─ model/                # 순수 코틀린 data class — 안드로이드 의존 0
│  ├─ data/                 # Repository (network + database 조합)
│  ├─ network/              # Retrofit · Ktor client · DTO
│  ├─ database/             # Room · DAO · Entity
│  ├─ datastore/            # 설정 저장
│  ├─ ui/                   # 여러 feature 가 쓰는 화면 조각
│  ├─ designsystem/         # 테마 · 버튼 · 아이콘 (도메인 모름)
│  └─ testing/              # 가짜 리포지터리 · 테스트 규칙
├─ build-logic/             # 컨벤션 플러그인 (STR 06)
└─ gradle/libs.versions.toml

feature 끼리 이동은 함수로, 연결은 :app 이

// :feature:order — 자기 화면을 그래프에 붙이는 함수만 공개
fun NavGraphBuilder.orderScreen(onCartClick: () -> Unit) {
    composable<OrderRoute> { OrderRoute(onCartClick = onCartClick) }
}

// :app — 두 feature 를 아는 유일한 곳
NavHost(navController, startDestination = OrderRoute) {
    orderScreen(onCartClick = { navController.navigate(CartRoute) })
    cartScreen(onBack = navController::popBackStack)
}
// :feature:order/build.gradle.kts
plugins { id("myapp.android.feature") }        // STR 06
android { namespace = "com.example.feature.order" }
dependencies {
    implementation(projects.core.data)          // 타입 안전 프로젝트 접근자
    // implementation(projects.feature.cart)    ✗ 금지
}
projects.core.data 는 settings 에 enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 를 켜야 생깁니다. 안 켜면 project(":core:data") 문자열로 씁니다.

빌드 시간 효과 — implementation 이 벽이 됩니다 🟡 중급

바꾼 곳모듈 하나짜리 앱모듈로 나눈 앱
feature 내부 private 코드앱 전체 증분 컴파일그 feature 만 다시 컴파일, :app 은 링크만
:core:network 내부 구현앱 전체:core:network 만 — :core:data 가 implementation 으로 숨겼다면 위로 안 번짐
:core:model 공개 클래스앱 전체거의 전부 — 그래서 model 은 작고 안정적으로
병렬 빌드 · 캐시모듈 하나는 병렬화 단위가 하나서로 모르는 모듈은 동시에 빌드, CI 캐시 적중률도 오름
api(...) 를 습관적으로 쓰면 이 효과가 사라집니다 — 의존이 위로 그대로 새어 나가 아래 모듈 하나를 바꿔도 위쪽 전체가 다시 컴파일됩니다. 공개 시그니처에 그 타입이 실제로 나올 때만 api, 나머지는 전부 implementation. 모듈이 클래스 서너 개짜리라면 Gradle 오버헤드가 이득보다 큽니다(도구 탭 TOOL 02).
정리 — :app 이 조립, :feature:* 는 서로 모르고, :core:* 는 위를 모릅니다. feature 사이 이동은 NavGraphBuilder 확장 함수 + :app 연결, 의존은 implementation 기본 — 이 둘이 빌드 시간 이득의 전부입니다.
STR 06

build-logic · 컨벤션 플러그인 — 모듈 30개의 빌드 스크립트를 한 장으로

모듈이 열 개를 넘으면 build.gradle.kts 마다 compileSdk · jvmToolchain · Compose 설정 30줄이 복사됩니다. 하나를 올리려면 열 군데를 고쳐야 하고 꼭 하나를 빠뜨립니다. 해법은 "이 모듈은 안드로이드 feature 다"라는 한 줄로 설정 묶음을 입히는 컨벤션 플러그인입니다. 스크립트형(*.gradle.kts) 예제는 도구 탭 TOOL 02 에 있고, 여기서는 Now in Android 가 쓰는 클래스형과 폴더 배치를 봅니다.

설정은 가운데에서 바깥으로만 — 모듈은 plugins { } 한 줄만 씁니다 build-logic 컨벤션 플러그인 :app feature order feature cart core:data core:ui core:model android.application android.feature android.library jvm.library android.library compileSdk 를 올릴 때 고치는 파일: 30개 → 1개
컨벤션 플러그인은 코드 의존이 아니라 설정을 주입합니다 — 모듈 그래프(STR 05)와 별개의 축입니다.

폴더 배치와 연결

myapp/
├─ settings.gradle.kts       # pluginManagement { includeBuild("build-logic") }
├─ gradle/libs.versions.toml # 루트와 build-logic 이 함께 읽음
└─ build-logic/              # 독립된 Gradle 빌드 (포함 빌드)
   ├─ settings.gradle.kts    # 카탈로그 연결 · include(":convention")
   └─ convention/
      ├─ build.gradle.kts    # `kotlin-dsl` + gradlePlugin { register }
      └─ src/main/kotlin/
         ├─ AndroidApplicationConventionPlugin.kt
         ├─ AndroidLibraryConventionPlugin.kt
         ├─ AndroidFeatureConventionPlugin.kt
         ├─ JvmLibraryConventionPlugin.kt
         └─ com/example/KotlinAndroid.kt  # 공통 함수
// build-logic/settings.gradle.kts
dependencyResolutionManagement {
    repositories { google(); mavenCentral() }
    versionCatalogs {
        create("libs") { from(files("../gradle/libs.versions.toml")) }
    }
}
rootProject.name = "build-logic"
include(":convention")

플러그인 등록과 본체

// build-logic/convention/build.gradle.kts
plugins { `kotlin-dsl` }
dependencies {
    compileOnly(libs.android.gradlePlugin)   // AGP 를 컴파일에만
    compileOnly(libs.kotlin.gradlePlugin)
}
gradlePlugin {
    plugins {
        register("androidFeature") {
            id = "myapp.android.feature"
            implementationClass = "AndroidFeatureConventionPlugin"
        }
    }
}
// AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply("myapp.android.library")  // 다른 컨벤션 위에 쌓기
            pluginManager.apply("myapp.android.hilt")
            val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
            dependencies {
                add("implementation", project(":core:ui"))
                add("implementation", project(":core:data"))
                add("implementation", libs.findLibrary("androidx-lifecycle-viewmodel-compose").get())
            }
        }
    }
}
방식장점언제
buildSrc/설정 없이 바로 인식모듈 10개 안팎. 바꾸면 빌드 스크립트 전체가 다시 컴파일되는 경향이 있어 대형에선 덜 씁니다
build-logic 스크립트형
myapp.kotlin-common.gradle.kts
평범한 빌드 스크립트처럼 씀서버 · 라이브러리 멀티 모듈 (도구 탭 TOOL 02)
build-logic 클래스형
Plugin<Project>
함수로 재사용 · 단위 테스트 가능 · 플러그인끼리 쌓기 쉬움Android 수십 모듈 — Now in Android 방식
컨벤션 플러그인에 넣지 말 것 — 특정 모듈만 쓰는 라이브러리, 비즈니스 결정(어느 API 서버를 쓰나). "모든 feature 가 반드시 같아야 하는 것"만 넣어야 플러그인이 두 번째 거대한 build.gradle 이 되지 않습니다.
정리 — includeBuild("build-logic") 로 포함 빌드를 두고, 모듈 종류마다 플러그인 하나(android.application · android.library · android.feature · jvm.library). 모듈의 빌드 스크립트는 plugins 한 줄 + 자기만의 의존성만 남깁니다.
STR 07

KMP 구조 — 공유 모듈과 앱 모듈의 경계

Kotlin Multiplatform 프로젝트는 공유 모듈(shared) 하나 + 플랫폼별 앱 진입점이 기본형입니다. 소스셋 계층(commonMain · androidMain · iosMain …)과 build.gradle.kts 는 KMP 탭 KMP 02 에 있으니, 여기서는 "이 코드는 어느 폴더에 두나"를 판단하는 법과 expect/actual 파일 배치 규칙만 봅니다.

이 코드는 어디에? — 위에서부터 답하면 폴더가 정해집니다 플랫폼 API 를 쓰나? (Context · UIKit) 아니오 shared/commonMain 모델 · 리포지터리 · 유스케이스 예 화면(UI) 코드인가? 예 앱 모듈 androidApp · iosApp(Swift) Compose Multiplatform 이면 composeApp/commonMain (STR 08) 아니오 공통 코드가 그 기능을 부르나? 예 interface · expect commonMain 에 선언 구현 · actual androidMain · iosMain 아니오 그 플랫폼 소스셋 전용 공통 쪽에서는 안 보임 commonMain 비율이 KMP 이득의 크기입니다 — 플랫폼 폴더가 두꺼워지면 interface 로 끌어올리세요
기본값은 commonMain, 플랫폼 API 가 필요할 때만 선언은 공통 · 구현은 플랫폼으로 쪼갭니다.

경계가 보이는 트리

project/
├─ shared/                    # KMP 라이브러리 — 화면을 모름
│  └─ src/
│     ├─ commonMain/kotlin/   # 모델 · 리포지터리 · 유스케이스 · expect
│     ├─ androidMain/kotlin/  # actual · Context 가 필요한 구현
│     ├─ iosMain/kotlin/      # actual · NSUserDefaults 등
│     └─ commonTest/kotlin/   # 공통 로직 테스트는 여기 한 번
├─ androidApp/                # Activity · 안드로이드 UI (shared 의존)
├─ iosApp/                    # Xcode 프로젝트 — import Shared
└─ server/                    # (선택) Ktor — shared 의 jvm 타깃을 같이 씀
위저드 버전에 따라 안드로이드 진입점이 composeApp/ 안에 있기도 하고 androidApp/ 으로 분리되어 있기도 합니다. 최근 AGP 는 KMP 모듈과 안드로이드 앱 모듈을 분리하는 쪽을 권하는 흐름이니, 새로 만든다면 분리형이 무난합니다.

expect/actual 파일은 소스셋 이름을 꼬리로

// shared/src/commonMain/kotlin/com/example/Platform.kt
expect fun platformName(): String

// shared/src/androidMain/kotlin/com/example/Platform.android.kt
actual fun platformName(): String =
    "Android ${android.os.Build.VERSION.SDK_INT}"

// shared/src/iosMain/kotlin/com/example/Platform.ios.kt
import platform.UIKit.UIDevice
actual fun platformName(): String =
    UIDevice.currentDevice.systemName() + " " +
        UIDevice.currentDevice.systemVersion
패키지는 같게, 파일 이름에는 .android · .ios 꼬리 — 코틀린 공식 컨벤션입니다. JVM 쪽에서 같은 이름 파일이 둘이면 최상위 함수 클래스(PlatformKt)가 겹치기 때문입니다. 클래스가 필요하면 expect class(아직 Beta) 대신 commonMain 의 interface + DI 로 구현 주입이 더 다루기 쉽습니다.
경계 규칙이유
shared 는 화면을 모른다안드로이드 UI 와 SwiftUI 가 각자 그립니다 — UI 까지 공유하려면 Compose Multiplatform(STR 08)으로 명시적으로 넘어갑니다
앱 모듈에는 규칙을 두지 않는다할인 계산이 Swift 와 안드로이드 양쪽에 있으면 공유한 의미가 없습니다
iOS 에 내보낼 것만 publicpublic 선언이 전부 Objective-C 헤더로 나갑니다 — 나머지를 internal 로 두면 헤더와 바이너리가 작아집니다
shared 가 커지면 쪼개되 iOS 출구는 하나:shared:core · :shared:feature-x 로 나누고, iOS 프레임워크는 그것들을 묶는 umbrella 모듈 하나에서만 만듭니다
정리 — 기본은 commonMain, 플랫폼 API 는 interface/expect 는 공통 · 구현은 플랫폼. 파일은 Platform.android.kt 처럼 꼬리를 붙이고, 앱 모듈에는 화면만 둡니다.
STR 08

Compose Multiplatform — composeApp 하나로 데스크톱 · 웹까지

Compose Multiplatform 은 KMP 위에 UI 까지 공유하는 구조입니다. 그래서 모듈 이름부터 composeApp 이고, 화면 코드가 commonMain 으로 올라옵니다. 데스크톱(JVM)과 웹(Wasm)은 진입점 파일 하나씩만 따로 가집니다. 헷갈리는 것은 리소스 위치입니다 — 안드로이드의 res/ 가 아니라 composeResources/ 입니다.

composeApp — 공통 UI 와 리소스는 commonMain, 플랫폼에는 진입점만 composeApp/src commonMain 공통 UI · 리소스 androidMain MainActivity jvmMain 데스크톱 main() wasmJsMain 웹 · index.html iosMain ViewController kotlin/ App.kt composeResources/ 폴더 이름 고정 Res.drawable · Res.string 빌드가 생성하는 접근자 (기본 internal) drawable/ · font/ · values/ · values-ko/ · files/ 리소스를 androidMain/res 에 두면 데스크톱 · 웹에서는 보이지 않습니다
공유하고 싶은 것은 전부 commonMain 으로 — 리소스도 commonMain/composeResources 에 둬야 모든 타깃이 봅니다.

트리와 진입점

composeApp/src/
├─ commonMain/
│  ├─ kotlin/com/example/
│  │  ├─ App.kt                 # @Composable fun App() — 모든 타깃 공통
│  │  └─ ui/home/ · ui/settings/ # 화면별 폴더 (STR 04 와 같은 규칙)
│  └─ composeResources/
│     ├─ drawable/logo.xml      # 벡터 · png
│     ├─ font/Pretendard-Regular.ttf
│     ├─ values/strings.xml     # 기본 문자열
│     ├─ values-ko/strings.xml  # 한정자로 지역화
│     └─ files/data.json        # 원본 그대로 읽는 파일
├─ jvmMain/kotlin/main.kt       # 예전 위저드는 desktopMain
├─ wasmJsMain/
│  ├─ kotlin/main.kt
│  └─ resources/index.html
├─ androidMain/                 # MainActivity · AndroidManifest
└─ iosMain/                     # MainViewController()
// jvmMain/kotlin/main.kt — 데스크톱
fun main() = application {
    Window(onCloseRequest = ::exitApplication, title = "MyApp") { App() }
}

// wasmJsMain/kotlin/main.kt — 웹
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
    ComposeViewport(document.body!!) { App() }
}

리소스 쓰기 · 패키징 설정

// commonMain — 생성된 Res 를 import
import myapp.composeapp.generated.resources.Res
import myapp.composeapp.generated.resources.logo
import myapp.composeapp.generated.resources.app_title

@Composable
fun App() {
    Column {
        Image(painterResource(Res.drawable.logo), contentDescription = null)
        Text(stringResource(Res.string.app_title))
    }
}
// composeApp/build.gradle.kts — 데스크톱 설치 파일
compose.desktop {
    application {
        mainClass = "MainKt"      // jvmMain/kotlin/main.kt (패키지 없음)
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "MyApp"
            packageVersion = "1.0.0"
        }
    }
}
// 여러 모듈이 Res 를 나눠 쓸 때
compose.resources {
    publicResClass = true
    packageOfResClass = "com.example.designsystem.resources"
}
모듈을 나누면 리소스도 나뉩니다 — Res 는 모듈마다 따로 생기고 기본이 internal 입니다. 공통 아이콘 · 폰트는 :core:designsystem 같은 모듈에 두고 publicResClass = true 로 열어 주세요. 생성 패키지 이름(myapp.composeapp.generated.resources)은 프로젝트 · 모듈 이름에서 나오므로, 모듈 이름을 바꾸면 import 가 깨집니다.
정리 — composeApp/commonMain 에 화면과 composeResources/, 플랫폼 소스셋에는 진입점 파일 하나(main.kt · MainActivity · MainViewController). 커지면 STR 05 처럼 feature · designsystem 모듈로 쪼개고 리소스도 따라 옮깁니다.
STR 09

라이브러리 · 멀티 모듈 공개 API — internal · explicit API · apiDump

앱은 공개 범위를 실수해도 다음 빌드에서 고치면 그만이지만, 라이브러리는 한 번 공개한 시그니처가 약속입니다. 그래서 라이브러리 구조는 폴더보다 "무엇이 밖에서 보이나"를 관리하는 장치가 중심입니다. 배포(Maven Central) 절차는 도구 탭 TOOL 12 에 있습니다. 같은 장치는 사내 멀티 모듈의 :core 에도 그대로 씁니다.

라이브러리 저장소 트리

money-kt/
├─ money/                      # 핵심 아티팩트
│  ├─ api/money.api            # ★ apiDump 결과 — 커밋 대상
│  └─ src/main/kotlin/
├─ money-serialization/        # 선택 기능은 별도 아티팩트로
├─ money-test/                 # 사용자용 테스트 픽스처 · 가짜 객체
├─ money-bom/                  # 버전을 맞춰 주는 BOM (java-platform)
├─ samples/                    # 공개 API 만으로 쓰는 예 — 컴파일로 검증
└─ build-logic/                # 발행 · 서명 · explicitApi 를 한곳에
선택 기능을 핵심에 넣지 마세요 — kotlinx.serialization 을 쓰지 않는 사용자에게까지 의존성을 강요하게 됩니다. money-serialization 처럼 떼어 두면 필요한 사람만 받습니다.

explicit API 모드 — 공개를 "선택"하게

// build.gradle.kts
kotlin { explicitApi() }       // 경고만 원하면 explicitApiWarning()

// 빠뜨리면 — kotlinc 2.3.20 실제 오류
class Money(val won: Long) {
    fun plus(o: Money) = Money(won + o.won)
}
// error: visibility must be specified in explicit API mode.
// error: return type must be specified in explicit API mode.

// 통과하는 형태
public class Money(public val won: Long) {
    public operator fun plus(o: Money): Money = Money(won + o.won)
}
public fun won(v: Int): Money = Money(v.toLong())          // → MoneyKt.won
internal fun format(m: Money): String = "%,d원".format(m.won)  // .api 에 안 나옴
코틀린의 기본 가시성은 public 이라 아무것도 안 쓰면 전부 공개됩니다. explicit API 모드는 그 기본값을 없애 모든 공개 선언에 public 과 반환 타입을 강제합니다 — 실수로 새는 API 가 0 이 됩니다.

apiDump — 공개 API 를 파일로 고정 🟡 중급

api/money.api — 바이트코드 기준 공개 API 의 스냅숏 public final class m/Money { public fun <init> (J)V public final fun getWon ()J public final fun plus (Lm/Money;)Lm/Money; } public final class m/MoneyKt { public static final fun won (I)Lm/Money; } 패키지는 / 로 (JVM 이름) 최상위 함수 = 파일명 + Kt 생성자 J = Long val → getter I = Int 반환 Money internal fun format 은 없습니다 — 공개 API 가 아니니까요 이 파일의 diff 가 곧 "이번 PR 이 API 를 바꿨나"의 답입니다
apiDump 는 공개 API 를 사람이 읽는 파일로 만들고, apiCheck 는 그 파일과 다르면 빌드를 깨뜨립니다.

흐름 — 덤프 · 검사 · 리뷰

# 루트에 binary-compatibility-validator 플러그인 적용 후
./gradlew apiDump     # api/*.api 를 현재 코드로 갱신 → 커밋
./gradlew apiCheck    # check 에 묶여 CI 에서 자동 — 다르면 실패

# 의도한 변경이면: apiDump 후 .api diff 를 PR 에 함께 올림
# 의도하지 않은 변경이면: CI 가 막아 줌
Kotlin 2.2 부터 같은 기능이 Kotlin Gradle 플러그인 안에 실험 기능(abiValidation)으로 들어오고 있습니다. 태스크 이름이 버전마다 바뀌는 중이라 새 프로젝트라도 당장은 플러그인 쪽이 문서가 많습니다.

공개 범위를 다루는 장치 네 가지

장치쓰임
internal모듈 안에서만 — 구현 클래스의 기본값
@PublishedApi internal공개 inline 함수 본문이 부르는 내부 함수 (인라인되면 호출자 쪽에 코드가 복사되므로)
@RequiresOptIn 애너테이션"실험적" API — 쓰는 쪽이 @OptIn 을 적어야 컴파일
@Deprecated(level = …)WARNING → ERROR → HIDDEN 으로 버전마다 한 단계씩 치우기
정리 — 라이브러리는 explicitApi() 로 공개를 강제로 선택하게 하고, apiDump 로 공개 API 를 파일에 고정해 CI 가 지키게 합니다. 선택 기능은 별도 아티팩트, 버전은 -bom 으로 맞춥니다.
STR 10

한 장 요약 — 규모 · 유형별 권장 구조와 규칙 강제

앞의 아홉 섹션을 표 한 장으로 접습니다. 행은 프로젝트 유형, 열은 규모입니다. 그리고 구조가 커지면 반드시 오는 문제 — 리뷰로 지키던 규칙이 새는 것 — 을 테스트 · 린트 · 빌드 검사로 막는 법으로 끝냅니다.

유형 × 규모 — 색이 진할수록 모듈이 많습니다 소 · 1~3명 중 · 팀 하나 대 · 팀 여럿 서버 모듈 1 · 기능별 패키지 Spring · Ktor 동일 domain · infra 모듈 분리 헥사고날은 규칙 많을 때 기능 모듈 + build-logic Modulith · ArchUnit Android 모듈 1 · ui/ · data/ 공식 가이드 패키지 :app + :core 몇 개 feature 는 아직 패키지 :feature 수십 개 build-logic · Konsist KMP shared 하나 + 앱 둘 위저드 기본 구조 shared 를 core 로 쪼갬 iOS 출구는 umbrella 하나 feature 별 KMP 모듈 CMP 면 UI 까지 공유 라이브러리 모듈 1 · explicitApi apiDump 처음부터 핵심 + 선택 기능 모듈 -bom 으로 버전 맞춤 모듈별 공개 API 관리 RequiresOptIn 단계 모듈 1 몇 개 10개 안팎 수십 개 대각선 아래로 내려갈수록 구조 규칙을 사람이 아니라 도구가 지켜야 합니다
같은 "대규모"라도 안드로이드가 가장 잘게 나뉩니다 — 빌드 시간이 먼저 아프기 때문입니다.
유형시작 구조첫 분리 신호 → 분리 방법상세
Spring 서버루트에 메인 · 기능별 패키지배포물 둘 · 빌드 2분 → :domain · :infra · :apiSTR 02 · DEEP 14
Ktor 서버module() 목차 · plugins/ · 기능 폴더기능 10개 · 팀 2개 → 기능 모듈 + :server 조립STR 03 · KTOR 10
Androidui/ · domain/ · data/ 패키지화면 10개 · 빌드 수 분 → :feature:* · :core:*STR 04 · 05 · ANDX 01
KMPshared + 앱 진입점shared 비대 → :shared:* + umbrellaSTR 07 · KMP 02
Compose MPcomposeApp/commonMain화면 · 리소스 증가 → feature · designsystem 모듈STR 08
라이브러리모듈 하나 · explicitApi()선택 기능 등장 → 별도 아티팩트 · BOMSTR 09 · TOOL 12
공통버전 카탈로그모듈 10개 → build-logicSTR 06 · TOOL 02

구조 규칙을 도구로 강제하기 🔴 고급

Konsist — 코틀린 소스를 읽는 아키텍처 테스트

class ArchitectureTest {
    @Test
    fun `계층 의존 방향`() {
        Konsist.scopeFromProject().assertArchitecture {
            val domain = Layer("Domain", "com.example.domain..")
            val data = Layer("Data", "com.example.data..")
            val ui = Layer("UI", "com.example.ui..")
            domain.dependsOnNothing()      // 도메인은 아무것도 모름
            ui.doesNotDependOn(data)       // 화면이 DataSource 를 직접 못 봄
        }
    }

    @Test
    fun `ViewModel 은 ui 패키지에`() {
        Konsist.scopeFromProject()
            .classes()
            .withNameEndingWith("ViewModel")
            .assertTrue { it.resideInPackage("..ui..") }
    }
}
ArchUnit(전문가 탭 DEEP 14)은 바이트코드를 읽고, Konsist 는 코틀린 소스를 읽습니다. 그래서 Konsist 는 파일 이름 · 최상위 함수 · internal 같은 코틀린 고유 규칙도 검사할 수 있습니다. 메서드 이름은 버전마다 조금씩 바뀌니 쓰는 버전 문서를 확인하세요.

Gradle 에서 모듈 의존 규칙 막기

// build-logic — AndroidFeatureConventionPlugin.apply() 안에 추가
val me = target.path                       // 예: ":feature:order"
target.configurations.configureEach {
    dependencies.withType<ProjectDependency>().configureEach {
        // 여기서 path 는 의존 대상 모듈 경로 (Gradle 8.11+)
        if (path.startsWith(":feature:") && !path.endsWith(":api")) {
            throw GradleException("$me → $path : feature 끼리 직접 의존 금지")
        }
    }
}
# detekt.yml — 특정 import 를 모듈 밖에서 금지
style:
  ForbiddenImport:
    active: true
    imports:
      - value: 'androidx.room.*'
        reason: 'Room 은 :core:database 안에서만 씁니다'
detekt 설정은 컨벤션 플러그인이 모듈 종류별로 다른 파일을 붙이게 하면 "feature 는 Room 금지" 같은 규칙이 됩니다. 안 쓰는 의존 · api/implementation 오용은 dependency-analysis 플러그인의 ./gradlew buildHealth 가 찾아 줍니다.
무엇을 막나도구언제 실패하나
모듈 사이 의존 방향모듈 분리 자체 + 컨벤션 플러그인 검사Gradle 설정 단계 — 가장 빠름
패키지 · 계층 의존Konsist · ArchUnit테스트 단계
금지 import · 이름 규칙detekt (ForbiddenImport · 커스텀 규칙)정적 분석 단계
공개 API 변경explicitApi() · apiCheck컴파일 · check 단계
스프링 모놀리스 기능 경계Spring Modulith ApplicationModules.of(…).verify()테스트 단계
정리 — 표의 왼쪽 위에서 시작해 신호가 올 때만 오른쪽 아래로 갑니다. 그리고 모듈이 수십 개가 되기 전에 규칙을 도구로 옮기세요 — 가장 싼 강제는 모듈 분리 자체, 그다음이 Gradle 검사, Konsist, detekt 순입니다.