코틀린은
“자바를 줄인 것”이
아니다
코틀린을 자바의 문법 설탕으로만 보면 절반만 쓰게 됩니다. null을 타입으로 다루고, 상태를 sealed로 닫고, 동시성을 suspend로 표현하는 — 설계 방식이 다른 언어입니다. 이 탭은 그 뼈대를 자바 코드와 나란히 놓고, 왜 이렇게 바뀌었는지까지 설명합니다.
코틀린을 실행하는 법
코틀린은 JDK 위에서 도는 언어입니다. 자바를 지우고 시작하는 게 아니라, 쓰던 자바 프로젝트에 .kt 파일 하나를 섞어 넣는 것부터 시작할 수 있습니다.
한 문단 요약
2011년 젯브레인스(IntelliJ를 만든 회사)가 공개했고, 2017년 안드로이드 공식 언어, 2019년부터는 안드로이드 권장 언어가 됐습니다. JVM 바이트코드로 컴파일되므로 자바 라이브러리를 그대로 씁니다 — 스프링·JPA·Jackson·Netty 전부 코틀린에서 부를 수 있습니다.
그래서 도입 비용이 낮습니다. 자바 파일과 코틀린 파일이 같은 모듈 안에 섞여 있어도 컴파일이 됩니다. 새 클래스만 코틀린으로 쓰고 기존 코드는 그대로 두는 방식이 실무에서 가장 흔한 전환 경로입니다.
이 언어를 왜 쓰나 — 네 가지
| 얻는 것 | 구체적으로 |
|---|---|
| null 안전 | NPE가 컴파일 에러로 앞당겨집니다. 운영 장애 원인 1위를 문법이 막습니다 |
| 짧아짐 | DTO·getter·setter·builder가 사라집니다. 같은 기능이 대략 30~40% 적은 줄 |
| 코루틴 | 비동기 코드를 동기처럼 씁니다. 콜백·CompletableFuture 지옥이 없습니다 |
| 한 코드 여러 곳 | 서버·안드로이드·iOS·웹을 같은 코드베이스로 (KMP) |
설치 — 세 가지만 있으면 됩니다
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 판(무료)으로 충분하고, 코틀린 플러그인은 기본 내장입니다.
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 소스 폴더도 같이 봐 준다)
버전 — 무엇이 언제 바뀌었나
| 버전 | 무엇이 들어왔나 |
|---|---|
| 1.3 | 코루틴 정식(2018). 그 전에는 실험 기능이었습니다 |
| 1.5 | value class · sealed interface |
| 1.9 | 구 컴파일러의 마지막. 지금도 레거시 프로젝트에 많습니다 |
| 2.0 | K2 컴파일러 기본 탑재(2024). 컴파일이 눈에 띄게 빨라지고 타입 추론이 정확해졌습니다 |
| 2.1~2.2 | when의 가드 조건, 비지역 break·continue, 컨텍스트 파라미터 |
새로 시작하면 2.x. 버전은 계속 오르므로 프로젝트를 만들 때 공식 릴리스 노트에서 최신 안정판을 확인하세요.
src/main/kotlin 폴더를 안 만들면
IDE가 파일을 알아보지 못합니다. 그리고 Gradle 데몬이 옛 코틀린 버전을 물고 있는 경우가 있어,
버전을 올린 뒤 설명 없는 에러가 나면 ./gradlew --stop 한 번이 답일 때가 많습니다.
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
숫자 — 자동 형변환이 없습니다
자바 — 알아서 넓혀 준다
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 — 세 개의 특별한 타입
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과 같은 성질 —
라이브러리로 배포하면 쓰는 쪽을 다시 컴파일해야 값이 바뀝니다).
null 안전성 — 코틀린의 심장
코틀린이 자바와 갈라지는 가장 큰 지점입니다.
타입에 ?가 없으면 그 값은 절대 null이 아닙니다 — 런타임 약속이 아니라
컴파일러가 강제하는 사실입니다.
타입 두 종류 — 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 |
?.let | null이 아닐 때만 블록 실행 |
?:가 엘비스 연산자인 이유 — 옆으로 눕혀 보면
엘비스 프레슬리의 머리 모양이라서입니다. 농담이 아니라 공식 명칭입니다.
실제로 쓰는 모양
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?>? 둘 다
String!)이 됩니다.
컴파일러가 검사를 강제하지 않으므로 경계에서 한 번 확인해 두어야 합니다.
자세한 내용은 18 · 자바와 섞어 쓰기에서 다룹니다.
문자열 · 템플릿
로그 한 줄, 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
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 하나로 처리됩니다.
조건 · 반복 · when
코틀린에는 삼항 연산자(? :)가 없습니다. 필요가 없기 때문입니다 —
if와 when이 값을 돌려주는 식(expression)이라서요.
자바 — 문장(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 이 컴파일 에러가 난다. 고쳐야 할 자리를 컴파일러가 전부 알려 준다.
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는 부수 효과가 목적일 때
(저장·전송·출력) 남깁니다.
함수 — 기본값 · 이름붙인 인자
자바에서 오버로딩 다섯 개를 만들던 자리가 코틀린에서는 함수 하나입니다. 호출부에서 인자에 이름을 붙일 수 있어서 읽기도 편해집니다.
// 기본 형태
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 · 자바와 섞어 쓰기).
클래스 · 프로퍼티
코틀린에는 필드가 없습니다. 프로퍼티만 있습니다 — 값 저장소와 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를 뺀 것과 같은 효과인데 훨씬 짧습니다.
data class — 값을 담는 클래스
실무 코드의 절반은 데이터를 담아 옮기는 클래스입니다.
data 한 단어면 equals·hashCode·toString·copy·구조 분해가 전부 만들어집니다.
자바 — 롬복이 없으면 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 | 주 생성자의 프로퍼티만 사용 |
| toString | Point(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())
MutableList)을 두지 마세요.
copy가 참조만 복사하고, hashCode도 내용이 바뀌면 함께 바뀌어
Set·Map 안에서 객체가 사라지는 고전적 버그가 납니다.
언제 data class 를 쓰지 않나
- 식별자로 같음을 판단하는 엔티티 — JPA
@Entity는data class로 만들지 마세요. 모든 필드로 equals 를 만들면 지연 로딩 프록시와 충돌하고, id 하나로 비교해야 맞습니다 - 동작이 중심인 클래스 — 서비스·컨트롤러는 상태가 없으니 해당 없음
- 상속이 필요할 때 — data class 는
open이 될 수 없습니다
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 두 개를 뒤바꿔 넣는 사고를 컴파일 단계에서 막습니다.
(확장 함수와 함께 코틀린에서 가장 저평가된 기능)
상속 · 인터페이스
코틀린 클래스는 기본이 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()) // 멍멍
추상 클래스 · 인터페이스
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는 “메서드 하나짜리 인터페이스”를 명시하는 선언입니다.
붙이면 람다로 만들 수 있고, 실수로 메서드를 하나 더 추가하면 컴파일 에러로 막힙니다.
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")
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입니다. 성공은 데이터를, 실패는 코드와 메시지를 갖는 위 형태가 안드로이드·서버 양쪽에서 가장 많이 쓰이는 모양입니다.
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이 나가는” 사고가 구조적으로 막힙니다.
확장 함수 — 코틀린다움의 시작
남이 만든 클래스에 내 함수를 붙여 점 찍고 부를 수 있습니다.
StringUtils.isEmail(s)가 s.isEmail()이 되는 순간,
코드를 읽는 방향이 “무엇에 대해 무엇을 한다”로 정렬됩니다.
// 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" — 변수의 '선언 타입'으로 결정된다!
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()은 공용 파일에.
컬렉션 완전정복
코틀린 컬렉션의 첫 관문은 읽기 전용과 가변이 타입으로 갈린다는 점입니다.
List에는 add가 아예 없습니다 — 런타임 예외가 아니라 자동완성에 뜨지 않습니다.
만들기
// 읽기 전용 (기본으로 이걸 쓴다)
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 이므로)
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)이 됩니다.
컬렉션 연산 — 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 / allMatch | any { } / 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
실전 — 주문 데이터로 리포트 만들기
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 { ... }처럼 이름을 붙여 끊는 편이
디버깅과 리뷰 모두에 낫습니다. 짧은 게 목표가 아니라 읽히는 게 목표입니다.
람다 · 고차 함수
함수를 값처럼 주고받는 문법입니다. 코틀린에서는 함수가 일급 시민이라
타입에도 쓰고((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) -> String | Int 받아 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 }처럼
조기 종료를 자연스럽게 쓸 수 있으니까요.
스코프 함수 6종
let · run · with · apply · also — 하는 일이 비슷해 보여 헷갈리지만,
실은 두 가지 축으로 완전히 갈립니다: 안에서 뭐라 부르나(it / this)와
무엇을 돌려주나(자기 자신 / 람다 결과).
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() }
고르는 기준 — 한 문장씩
| 함수 | 이럴 때 쓴다 |
|---|---|
let | null 이 아닐 때만 실행하고 결과를 받는다 |
run | 객체 멤버를 여러 번 써서 값 하나를 계산한다 |
with | run과 같지만 null 안전이 필요 없을 때 |
apply | 객체를 설정하고 그 객체를 그대로 쓴다 |
also | 흐름을 끊지 않고 로그·검증·부수효과만 끼운다 |
takeIf | 조건을 만족할 때만 값을 통과시키고 아니면 null |
if (a == null) return으로 풀어 쓰세요.
② apply 안에서 값 계산: 반환값이 객체라 계산 결과가 버려집니다 — run을 쓰세요.
③ let 을 “그냥” 붙이기: value.let { it + 1 }은 value + 1보다 나쁩니다.
스코프 함수는 줄이는 도구가 아니라 뜻을 드러내는 도구입니다.
예외 · 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 탭에서 다룹니다.
자바와 섞어 쓰기
실무에서 코틀린을 처음 넣는 곳은 대개 이미 자바로 된 프로젝트입니다. 두 언어가 만나는 지점의 규칙을 알면 파일 하나씩 안전하게 옮길 수 있습니다.
플랫폼 타입 — null 안전성의 구멍
// 자바: 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: String | getName() |
var age: Int | getAge() / setAge() |
val isVip: Boolean | isVip() (is 로 시작하면 그대로) |
최상위 fun f() | 파일명Kt.f() |
object A | A.INSTANCE |
Unit 반환 | void |
Int? | Integer (박싱됨) |
List<String> | java.util.List (읽기 전용 아님!) |
suspend fun | Continuation 인자가 붙는다 — 자바에서 부르기 어려움 |
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> 자리에 넣을 수 없다 (자바에서는 됐다 — 그리고 위험했다)
치트시트 · 로드맵
막힐 때 펼쳐 보는 한 장 요약과, 이 가이드를 어떤 순서로 읽으면 좋은지에 대한 지도입니다.
자바 → 코틀린 대응표
| 자바 | 코틀린 |
|---|---|
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 t | obj is T (스마트 캐스트) |
(T) obj | obj as T · 안전하게 obj as? T |
switch | when (값을 돌려준다) |
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 / 롬복 @Data | data class |
Optional<T> | T? (코틀린은 Optional 을 쓰지 않는다) |
stream().map().collect() | map { } |
StringUtils.isBlank(s) | s.isBlank() (확장 함수) |
| try-with-resources | use { } |
throws IOException | 없음 (필요하면 @Throws) |
CompletableFuture | suspend 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 컴파일러 · 바이트코드 · 코루틴 내부) |
in/out),
inline·reified, 위임 프로퍼티, 연산자 오버로딩, DSL 만들기를 다룹니다.
이 탭의 문법으로 코드를 쓸 수 있게 됐다면, 다음 탭은 라이브러리를 만들 수 있게 되는 단계입니다.
자주 부딪히는 에러 메시지
| 메시지 | 원인 · 해결 |
|---|---|
| Null can not be a value of a non-null type | 타입에 ?가 없다. 타입을 열거나 값을 채우세요 |
| Smart cast to 'X' is impossible | var 프로퍼티라 검사 후 바뀔 수 있음 → 지역 val에 담기 |
| Type mismatch: inferred type is Int but Long was expected | 자동 형변환이 없다 → .toLong() |
| 'when' expression must be exhaustive | sealed 의 경우를 빠뜨렸다 (좋은 에러입니다) |
| This class does not have a constructor | object나 sealed를 new처럼 만들려 함 |
| lateinit property has not been initialized | 주입 전에 접근. 생성자 주입으로 바꾸거나 ::x.isInitialized 확인 |
| Suspend function should be called only from a coroutine body | suspend 함수를 일반 함수에서 호출 — 코루틴 탭 참고 |
| Cannot access '...': it is private in ... | 확장 함수는 private 멤버에 접근 못 한다 |
여기서부터는
“쓰는 쪽”이 아니라
“만드는 쪽”이다
기초 탭의 문법으로 코드를 쓸 수 있게 됐다면, 이 탭은 남이 쓸 것을 만드는 단계입니다.
변성(in/out), reified, 위임 프로퍼티, 연산자 규약, DSL —
Gradle KTS·Compose·Ktor가 그렇게 생긴 이유가 전부 여기 있습니다.
제네릭 — 타입을 인자로 받기
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>()
변성 — in · out
List<Dog>를 List<Animal> 자리에 넣어도 될까요?
이 질문의 답이 변성(variance)입니다. 코틀린은 이것을
선언하는 쪽에서 한 번 정하게 합니다 — 자바의 와일드카드가 호출부마다 등장하던 것과 다릅니다.
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()
out,
인자에만 나오면 in을 붙이세요. 컴파일러가 위치를 검사해 주므로
잘못 붙이면 바로 에러가 납니다. 둘 다 나오면 그냥 두면 됩니다 — 대부분의 클래스가 그렇습니다.
타입 소거와 reified
JVM에서는 실행 시점에 List<String>과 List<Int>가 구분되지 않습니다(타입 소거).
코틀린은 inline + 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 함수 안에 둘 수 없습니다.
inline · noinline · crossinline
람다를 인자로 받으면 보통 객체가 하나 생깁니다. 반복문 안이라면 수만 개가 생기죠.
inline은 그 비용을 0으로 만드는 대신, 몇 가지 규칙을 따라오게 합니다.
언제 붙이나 · 언제 안 붙이나
| 상황 | 판단 |
|---|---|
| 람다를 받는 작은 함수 | ✅ 붙인다 (이게 inline 의 존재 이유) |
| reified 가 필요 | ✅ 필수 |
| 람다를 안 받는 함수 | ❌ 이득이 거의 없다 — IDE 도 경고한다 |
| 본문이 긴 함수 | ❌ 호출 지점마다 복제되어 코드가 부푼다 |
| 공개 API + private 접근 | ❌ 컴파일 에러 (펼쳐진 코드가 private 에 접근 불가) |
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 함수가 어떤 상태 기계로 바뀌었는지 ← 코루틴 탭에서 다시 봅니다
위임 프로퍼티
by lazy의 by가 위임입니다. “이 프로퍼티의 읽기·쓰기를 다른 객체에게 맡긴다”는 선언으로,
캐싱·검증·변경 감지·설정 읽기 같은 반복되는 접근자 코드를 한 번만 쓰게 만듭니다.
표준 위임 넷
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"
}
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를 찾아 보세요.
규약이 단순해서 라이브러리 동작을 금방 파악할 수 있습니다.
연산자 오버로딩 · 규약 함수
a + b는 a.plus(b)를 부릅니다. 코틀린은 이런 규약(convention)을
정해 두고, 정해진 이름의 함수를 만들면 문법이 그 타입에 열리게 합니다.
| 쓰는 모양 | 불리는 함수 | 비고 |
|---|---|---|
a + b | a.plus(b) | minus · times · div · rem |
a += b | a.plusAssign(b) | 없으면 a = a + b로 처리 |
-a | a.unaryMinus() | unaryPlus · not |
a[i] | a.get(i) | 인자 여러 개도 가능 |
a[i] = v | a.set(i, v) | |
a(x) | a.invoke(x) | 객체를 함수처럼 |
a in b | b.contains(a) | |
a..b | a.rangeTo(b) | a..<b는 rangeUntil |
a < b | a.compareTo(b) | Comparable 구현이면 자동 |
a == b | a?.equals(b) ?: (b === null) | null 검사가 포함된다 |
for (x in a) | a.iterator() | 확장 함수로도 가능 |
val (x, y) = a | a.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))가 언제나 낫습니다.
DSL 만들기
Gradle KTS, Compose, Ktor 라우팅, kotest — 문법처럼 보이는 이 코드들은 전부 수신 객체 람다 + 확장 함수 + 후행 람다 세 재료로 만들어집니다. 원리를 알면 직접 만들 수 있습니다.
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(), "빈 주문")
}
data class + 이름붙인 인자가 더 명확하고 디버깅도 쉽습니다.
DSL 은 중첩 구조가 반복될 때(HTML · 라우팅 · UI 트리) 값을 합니다.
시퀀스 — 지연 평가
리스트 연산은 단계마다 새 리스트를 만듭니다. Sequence는 원소 하나를 끝까지 흘려보낸 뒤
다음 원소로 갑니다 — 자바 Stream과 같은 방식이고, 무한 수열도 다룰 수 있습니다.
만들기 · 무한 수열
// 컬렉션에서
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 — 코루틴 탭에서 다룹니다 |
Flow는 suspend가 가능해 DB 조회·HTTP 호출을 사이에 넣을 수 있습니다.
“동기면 Sequence, 비동기면 Flow”로 외워 두면 됩니다.
컬렉션 고급 — 정렬 · 집계 · 성능
기초 탭의 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이 나거나
조용히 원소가 사라집니다. 공유되는 순간 자바 동시성 컬렉션이나 코루틴 도구로 바꾸세요.
typealias · value class
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를 쓰세요.
계약 — 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)에
계약을 달아 두면 호출부 전부에서 !!가 사라집니다.
라이브러리를 만들 때 특히 값을 합니다.
리플렉션 — 실행 중에 타입 들여다보기
스프링의 @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)이 대안입니다 — 다음 섹션.
애너테이션 · KSP — 빌드 때 코드 만들기
Room·Hilt·Moshi·kotlinx.serialization은 전부 빌드 시점에 코드를 생성합니다. 리플렉션 대신 실제 코드가 생기므로 빠르고, 네이티브 빌드에서도 안전합니다.
애너테이션 정의 — 세 가지를 정한다
@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 — 문법 자체를 바꾼다 |
| 리플렉션 | 동적이지만 느리고 네이티브에 약함 |
함수형 스타일
코틀린은 순수 함수형 언어가 아니지만, 불변 데이터 + 함수 합성 + 값으로서의 실패라는 핵심 도구는 전부 갖고 있습니다. 실무에서 어디까지 가져다 쓰면 이득인지가 이 절의 주제입니다.
함수 합성 · 부분 적용 · 커링
// 합성 — 표준 라이브러리에는 없으니 직접 만든다 (두 줄이면 된다)
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("대구")
실패를 값으로 — 세 가지 선택지
① 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
}
연산을 여러 번 이어 붙일 때 편합니다. 팀 학습 비용을 고려해 도입하세요.
표준 라이브러리 지도
“이거 이미 있지 않을까?”의 답은 대개 있습니다. 직접 만들기 전에 여기서 한 번 찾아보세요 — 패키지별로 무엇이 있는지 정리했습니다.
| 패키지 · 모듈 | 들어 있는 것 |
|---|---|
kotlin | 기본 타입 · let/run/apply · require/check · Result · TODO() · repeat |
kotlin.collections | List·Set·Map 과 그 위의 수백 개 확장 함수 |
kotlin.text | 문자열 · Regex · StringBuilder · Charsets |
kotlin.io | File.readText · useLines · println · readln |
kotlin.sequences | Sequence · generateSequence · sequence { yield } |
kotlin.math | abs·pow·sqrt·roundToInt·PI |
kotlin.random | Random.nextInt · list.random() · shuffled() |
kotlin.time | Duration · 3.seconds · measureTime · TimeSource |
kotlin.properties | Delegates.observable · vetoable · notNull |
kotlin.reflect | KClass · KProperty (별도 의존성 필요) |
kotlinx.coroutines | 코루틴 · Flow (별도 라이브러리 — 코루틴 탭) |
kotlinx.serialization | JSON 직렬화 (컴파일러 플러그인 — 실전 도구 탭) |
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로 바로 읽힙니다 — 가장 좋은 코틀린 교재입니다
StringUtils.isEmpty 같은 걸 찾기 전에 isEmpty()·isBlank()를 보세요.
관용구 · 흔한 실수
코드 리뷰에서 반복적으로 지적되는 것들을 모았습니다. 문법을 아는 것과 “코틀린답게” 쓰는 것은 다른 문제입니다.
자주 쓰는 관용구
// 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 > 0 | list.isNotEmpty() |
list.filter { }.size | list.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·수신 객체 람다·시퀀스가 그대로 재료로 쓰입니다.
코루틴은
“가벼운 스레드”가
아니다
코루틴을 스레드의 값싼 대체품으로만 이해하면 취소·예외·스코프에서 반드시 막힙니다. 본질은 “함수를 중간에 멈췄다가 이어서 실행하는 능력”과 “작업의 수명을 트리로 묶는 규칙” 두 가지입니다. 이 탭은 그 두 가지를 컴파일 결과와 실행 순서까지 보면서 익힙니다.
왜 코루틴인가
서버가 느려지는 가장 흔한 이유는 계산이 아니라 기다림입니다 — DB 응답, 외부 API, 파일. 스레드는 기다리는 동안 통째로 묶여 있고, 코루틴은 그 자리를 비워 줍니다.
콜백 지옥에서 나오는 길
// 콜백 — 세 단계만 되어도 읽기 어렵다
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가 없다 |
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") ·
안드로이드는 -android, 스프링은 -reactor를 추가로 씁니다.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) 로 띄워도 코어 수만큼만 동시에 자고 나머지는 줄을 선다)
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 // 다시 메인 스레드
}
코루틴 빌더 — launch · async · runBlocking
코루틴을 시작하는 함수들입니다. 넷만 알면 되고, 언제 어느 것을 쓰는지가 실무에서 갈리는 지점입니다.
| 빌더 | 반환 | 쓰는 자리 |
|---|---|---|
launch | Job | 결과가 필요 없는 작업 시작 (발사 후 잊기) |
async | Deferred<T> | 결과가 필요한 병렬 작업 — await()로 받는다 |
runBlocking | T | 다리 — main 함수·테스트·레거시 진입점에서만 |
coroutineScope | T | suspend 함수 안에서 자식들을 묶어 기다린다 |
withContext | T | 스레드(디스패처)를 바꿔서 실행하고 결과를 받는다 |
supervisorScope | T | 자식 하나가 실패해도 형제를 죽이지 않는다 (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("바로 찍힘") }
구조적 동시성
코루틴의 진짜 발명품입니다. 모든 코루틴은 부모를 가지고, 부모는 자식이 다 끝날 때까지 끝나지 않으며, 부모가 취소되면 자식도 전부 취소됩니다. “실행 중인지 아무도 모르는 작업”이 원천적으로 생기지 않습니다.
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 를 쓴다
CoroutineScope · 생명주기
스코프는 “이 코루틴들이 언제 죽어야 하는가”를 정하는 그릇입니다. 화면이 닫히면, 요청이 끝나면, 서버가 내려가면 — 그 시점에 스코프를 취소하면 전부 정리됩니다.
// 스코프 = Job + 디스패처(+ 이름 · 예외 핸들러 …)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + CoroutineName("worker"))
scope.launch { work1() }
scope.launch { work2() }
scope.cancel() // 안에서 시작한 코루틴이 전부 취소된다
// ⚠️ 한 번 cancel 한 스코프는 재사용할 수 없다 — 다시 만들어야 한다
플랫폼이 주는 스코프
| 스코프 | 수명 |
|---|---|
viewModelScope | ViewModel 이 사라질 때 취소 (안드로이드 · 가장 많이 씀) |
lifecycleScope | 액티비티·프래그먼트 파괴 시 |
rememberCoroutineScope() | Compose — 컴포저블이 화면을 떠날 때 |
스프링 suspend 컨트롤러 | 요청 하나 — 클라이언트가 끊으면 취소된다 |
runTest | 테스트 하나 |
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 필드로 갖는 편이 안전합니다.
디스패처 — 어느 스레드에서 돌 것인가
코루틴은 스레드가 아니지만 결국 어떤 스레드 위에서 돕니다. 그 스레드를 고르는 것이 디스패처이고, 잘못 고르면 성능이 통째로 무너집니다.
| 디스패처 | 스레드 수 | 쓰는 일 |
|---|---|---|
Dispatchers.Default | CPU 코어 수 (최소 2) | CPU 작업 — 정렬·파싱·이미지 처리·암호화 |
Dispatchers.IO | 기본 64개까지 확장 | 블로킹 IO — JDBC·파일·레거시 HTTP 클라이언트 |
Dispatchers.Main | 1 (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로 감싸세요
Thread.sleep · 동기 HTTP) IO,
계산만 하면 Default, UI 를 만지면 Main입니다.
취소 — 협조적이다
코루틴 취소는 강제 종료가 아닙니다. “그만하라”는 표시를 남길 뿐이고, 코루틴 쪽이 그것을 확인해 줘야 실제로 멈춥니다. 이 성질을 모르면 “취소했는데 계속 도는” 상황을 만나게 됩니다.
취소가 안 되는 코드
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을 던진다 (권장) |
isActive | Boolean 검사 — 정리 후 정상 종료하고 싶을 때 |
yield() | 양보 + 취소 확인. 계산 루프에 넣기 좋다 |
모든 suspend 함수 | delay·await 등은 이미 취소를 확인한다 |
자원 정리 — 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 규칙으로 잡을 수 있습니다.
타임아웃 · 재시도
외부 시스템을 부르는 코드에 타임아웃이 없으면 장애가 그대로 전파됩니다. 코루틴은 타임아웃을 취소로 구현하기 때문에, 시간이 지나면 진짜로 작업이 멈춥니다.
// 시간이 지나면 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)
예외 처리
코루틴에서 예외는 부모로 올라가면서 형제를 취소합니다.
이 규칙과 launch·async의 차이를 모르면
“예외가 안 잡힌다” 또는 “왜 다른 작업까지 죽었지”를 겪습니다.
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 | 가장 확실하다 — 코루틴 블록 안에서 감싸기 |
병렬 패턴 — 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 폭탄
coroutineScope { async … await }로 감싸는 정도).
코루틴 컨텍스트 — 값 전파
코루틴은 실행 중 스레드가 바뀔 수 있습니다. 그래서 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 탭에서 해결 방법을 다룹니다.
-Dkotlinx.coroutines.debug를 켜면
스레드 이름 뒤에 @coroutine#3 같은 식별자가 붙습니다.
CoroutineName("주문처리")를 함께 주면 로그에서
어느 코루틴의 로그인지가 바로 보입니다. 운영 진단에서 큰 차이를 만듭니다.
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 의 성질 — 수집할 때마다 처음부터 다시 실행된다
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 로 만들 필요 없다)
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
}
StateFlow · SharedFlow
화면 상태와 일회성 이벤트를 다루는 표준 도구입니다. 안드로이드 MVVM의 뼈대이고, 서버에서도 실시간 브로드캐스트에 씁니다.
| 타입 | 성격 | 쓰는 자리 |
|---|---|---|
StateFlow | Hot · 값 1개 유지 | 상태 — 화면에 보이는 현재 값 (항상 값이 있다) |
SharedFlow | Hot · replay 설정 | 이벤트 — 토스트·화면 이동처럼 한 번만 처리할 것 |
Channel | Hot · 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로 감싸면
여러 서버 인스턴스로도 확장됩니다 — 대규모 트래픽 탭에서 다룹니다.
채널 — 코루틴 사이의 파이프
Flow가 “한 흐름을 여럿이 본다”면, Channel은 “보낸 값을 하나가 가져간다”입니다. 작업 큐, 생산자-소비자, 워커 풀이 전형적인 쓰임입니다.
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("둘 다 조용함") }
}
Flow로 감싸는 편이 안전합니다
(receiveAsFlow() · consumeAsFlow()).
공유 상태 · 동기화
코루틴도 여러 스레드에서 돕니다. 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) }
}
limitedParallelism(1)로 가두면 락은 없어지지만 처리량이 1로 제한됩니다.
상태 갱신이 아주 짧을 때만 이득이고, 그 안에서 IO 를 하면 전체가 줄을 섭니다.
블로킹 코드와 섞기
현실의 코드베이스에는 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 모듈)
경계는 얇게 — 실무 구조
블로킹은 가장 아래층에 가둡니다. 위로 올라올수록 suspend 만 보이게 하면, 나중에 논블로킹 드라이버(R2DBC)로 바꿔도 위층 코드는 그대로입니다.
Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()로
블로킹 작업용 디스패처를 만들 수 있습니다. 스레드 수 제한이 사실상 사라지지만,
DB 커넥션 풀은 여전히 유한하므로 동시 실행 제한은 반드시 남겨 두세요.
코루틴 테스트
코루틴 테스트의 핵심은 가상 시간입니다. 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 | 테스트가 끝나면 자동 취소되는 스코프 (무한 수집에 필수) |
Turbine | Flow 방출을 순서대로 검증 |
StateFlow를 collect하는 코루틴은
끝나지 않습니다. runTest가 그것을 기다리다 타임아웃이 납니다.
backgroundScope.launch { flow.collect { } }를 쓰거나 Turbine 을 쓰세요.
내부 동작 — CPS와 상태 기계
suspend는 마법이 아닙니다. 컴파일러가 함수를 상태 기계로 다시 씁니다.
이 변환을 이해하면 스레드가 바뀌는 이유, 스택 트레이스가 잘리는 이유,
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 는 비용이 없다 — 상태 기계가 호출 자리에 합쳐집니다
DispatchedTask의 실행 경로,
suspendCoroutineUninterceptedOrReturn 같은 저수준 API, 그리고
코루틴 성능을 측정하는 방법까지.
실전 체크리스트
코드 리뷰와 장애 회고에서 반복적으로 나오는 항목들입니다. 이 목록만 지켜도 코루틴 관련 사고의 대부분이 예방됩니다.
하지 말아야 할 것
| 안티패턴 | 왜 · 무엇을 대신 |
|---|---|
GlobalScope.launch | 취소 불가 · 누수 → 수명 있는 스코프 (04) |
요청 처리 중 runBlocking | 스레드를 막아 이점 소멸 → suspend 컨트롤러 (03) |
catch (e: Exception)로 취소 삼킴 | 취소가 안 먹는다 → CancellationException 재던지기 (07) |
Dispatchers.Default에서 블로킹 IO | CPU 풀 고갈 → 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 쿼리 취소 등)
스프링을
코틀린으로 쓰면
무엇이 달라지는가
스프링은 자바를 전제로 설계됐고, 코틀린은 기본이 final·non-null입니다. 이 둘이 만나는 지점에 컴파일러 플러그인·프록시·널 처리라는 고유한 주제가 생깁니다. 이 탭은 그 지점들을 먼저 정리한 뒤, 코루틴 컨트롤러까지 실무 형태로 갑니다.
코틀린으로 스프링 시작하기
프로젝트를 만들 때 플러그인 세 개를 빠뜨리면 앱이 뜨지도 않습니다. 왜 필요한지까지 알고 넘어가면 나중에 원인 모를 에러에서 헤매지 않습니다.
// 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이 붙어 있습니다.
이 옵션을 켜면 코틀린이 그 표시를 플랫폼 타입이 아니라 진짜 타입으로 취급합니다.
첫 애플리케이션
// 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을 붙이지 마세요 — 플러그인이 필요한 것만 열어 줍니다.
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)로
방향을 끊는 것이 정답입니다.
설정 — @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())
}
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)
}
REST 컨트롤러
코틀린 컨트롤러는 자바와 거의 같지만, 단일 표현식 함수와
suspend가 더해지면서 코드가 눈에 띄게 짧아집니다.
@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()
}
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가 늘어나기 시작하면
대개 서비스로 내려야 할 로직이 올라온 것입니다.
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 |
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
검증 — Bean Validation
코틀린에서 검증 애너테이션이 “안 먹는” 일이 자주 생깁니다.
원인은 거의 항상 사용 지점(@field:)을 빠뜨린 것입니다.
@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,
)
init이나 서비스에서.
코틀린의 require·check가 이 자리에 잘 맞습니다
— 그리고 그 예외를 전역 핸들러가 400/409 로 바꿉니다(다음 섹션).
전역 예외 처리
예외를 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로 따로 처리
계층 설계
코틀린은 확장 함수·sealed·data class 덕분에 계층 사이의 변환 코드와 상태 표현이 훨씬 가벼워집니다. 과하게 설계하지 않으면서 바꾸기 쉬운 구조를 만드는 법입니다.
기본 3계층 — 대부분의 서비스에 충분하다
계층 사이 변환 — 확장 함수
// 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()
도메인을 살려 두기 — 코틀린이 잘하는 부분
// 값 객체 — 잘못된 값이 애초에 만들어지지 않는다
@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)
}
}
트랜잭션 — 코틀린에서 특히 조심할 것
@Transactional은 프록시로 동작합니다.
코틀린의 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) { /* ... */ }
}
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 탭에서 더 깊이 다룹니다.
코루틴과 스프링 — 실전 결합
suspend 컨트롤러를 쓸 때 실제로 무엇이 달라지는지,
MVC · WebFlux · 가상 스레드 중 무엇을 고를지 정리합니다.
| 구성 | 스레드 모델 | 고르는 기준 |
|---|---|---|
| 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는 awaitBody<T>()로 자연스럽게 코루틴과 붙습니다.
Spring 6 의 RestClient는 블로킹이므로 withContext(IO)가 필요합니다.
자세한 사용법은 WebFlux 탭에서 다룹니다.
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로 인자로 받아 코루틴 안으로 값을 넘기는 것이
가장 단순하고 안전한 해법입니다.
테스트 — 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)
}
}
})
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 { /* ... */ }
}
필터 · 인터셉터
// 필터 — 요청/응답 전체를 감싼다
@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/**")
}
}
스케줄러 · 비동기 · 캐시
주기 작업, 백그라운드 처리, 캐싱 — 세 가지 모두 애너테이션 한 줄로 시작할 수 있지만 운영에서 문제가 되는 지점이 각각 다릅니다.
스케줄러
@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()
}
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) }
}
}
로깅 · 관측 · 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 를 쓰지 않기 — 알람이 무뎌집니다
빌드 · 배포 · 네이티브
코틀린 스프링 앱을 컨테이너로 만들고, 필요하면 네이티브 이미지로 굽는 과정입니다. 기동 시간과 메모리가 중요한 환경(서버리스·오토스케일)에서 차이가 큽니다.
이미지 만들기 — 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")
}
}
운영 체크리스트
| 항목 | 확인할 것 |
|---|---|
| 기동·종료 | server.shutdown=graceful · 프로브(liveness/readiness) 분리 |
| 메모리 | 컨테이너에서 -XX:MaxRAMPercentage — 고정 -Xmx 대신 |
| 커넥션 풀 | HikariCP 크기와 코루틴 동시 실행 제한을 맞췄는가 |
| 타임아웃 | 외부 호출 · DB · HTTP 클라이언트 전부 설정했는가 |
| 로그 | 구조화(JSON) · traceId · 개인정보 마스킹 |
| 메트릭 | /actuator/prometheus 노출 · 커스텀 지표 · 알람 임계값 |
| 설정 | 비밀값은 환경변수·시크릿으로 (yml 에 넣지 않기) |
| 빌드 | ktlint·detekt·테스트를 CI 에서 강제 |
coRouter 함수형 라우팅, WebClient, R2DBC, 백프레셔를 다룹니다.
데이터 · JPA 탭에서는 코틀린 엔티티 설계의 함정(data class·equals·지연 로딩)과
Exposed·jOOQ 같은 코틀린 네이티브 선택지를 비교합니다.
WebFlux를
코틀린으로 쓰면
Reactor가 거의 안 보인다
flatMap·zipWith 체인을 배우지 않아도 됩니다.
suspend 함수와 Flow로 쓰고, 프레임워크 경계에서만 Mono·Flux로 바꾸면 됩니다.
이 탭은 그 변환 지점과, 정말 WebFlux가 필요한 상황을 구분하는 기준을 다룹니다.
왜 WebFlux인가 — 그리고 언제 아닌가
WebFlux는 “더 빠른 스프링”이 아닙니다. 적은 스레드로 많은 커넥션을 유지하기 위한 모델이고, 그 대가로 전 구간이 논블로킹이어야 합니다. 한 군데라도 막히면 MVC보다 나빠집니다.
WebFlux 가 맞는 상황
- 동시 커넥션이 수천~수만 — 채팅·알림·SSE·롱폴링
- 게이트웨이·프록시 — 요청을 받아 다른 서비스로 넘기는 일이 대부분
- 스트리밍 — 대용량 응답을 조금씩 흘려보내야 할 때
- 전 구간 논블로킹이 가능 — R2DBC·논블로킹 HTTP 클라이언트만 사용
MVC 로 충분한 상황 (대부분)
- JPA·JDBC 를 쓴다 — 어차피 블로킹이라 이득이 제한적
- 초당 수백~수천 요청 — 톰캣 + 가상 스레드로 충분
- 팀에 리액티브 경험이 적다 — 디버깅 비용이 큽니다
- 트랜잭션이 복잡한 업무 시스템 — JPA 생태계의 이점이 큽니다
implementation("org.springframework.boot:spring-boot-starter-webflux") ·
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
(Reactor ↔ 코루틴 변환에 필수) · R2DBC 를 쓰면 spring-boot-starter-data-r2dbcMono와 Flux — 최소한만
코틀린으로 쓸 거라면 Reactor 연산자를 전부 외울 필요가 없습니다. 이 두 타입이 무엇인지와 코루틴으로 바꾸는 법만 알면 됩니다.
두 타입의 뜻
| 타입 | 뜻 · 코루틴 대응 |
|---|---|
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와 대응
repo.save(x)만 부르고 반환값을 버리는 코드가 대표적인 버그입니다 —
코루틴에서는 awaitSingle()을 부르지 않은 것과 같습니다.
Reactor ↔ 코루틴 변환
실무에서 가장 많이 쓰는 표입니다. 경계에서만 변환하고 안쪽은 전부 코루틴으로 쓰는 것이 코틀린 WebFlux의 표준 전략입니다.
| 방향 | 코드 |
|---|---|
| Mono → 값 | mono.awaitSingle() · 비어 있을 수 있으면 awaitSingleOrNull() |
| Mono<Void> → Unit | mono.awaitFirstOrNull() · mono.thenAwait() |
| Flux → Flow | flux.asFlow() |
| Flux → List | flux.collectList().awaitSingle() |
| suspend → Mono | mono { getUser(id) } |
| Flow → Flux | flow.asFlux() |
| Publisher → Flow | publisher.asFlow() |
| Flow → Publisher | flow.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) }
}
suspend·Flow를 이미 이해하므로 ③은 드뭅니다).
나머지 코드는 코루틴만 보이게 유지하세요.
라우팅 — 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() | 본문 없는 응답 |
WebClient — 코루틴으로
WebFlux 생태계의 HTTP 클라이언트입니다. MVC 프로젝트에서도 쓸 수 있고,
코루틴과 붙이면 await 한 단어로 끝납니다.
@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 운영에서 가장 흔한 이슈입니다.
RestClient는 블로킹이지만
WebClient 와 거의 같은 API 를 제공합니다. MVC + 가상 스레드 조합이라면
RestClient가 더 단순합니다. WebFlux 환경이거나 스트리밍이 필요하면 WebClient입니다.
R2DBC — 논블로킹 DB 접근
WebFlux 의 이점을 실제로 얻으려면 DB 접근도 논블로킹이어야 합니다. R2DBC 는 그 자리를 채우지만, JPA 가 해 주던 것들을 스스로 해야 합니다.
엔티티와 리포지터리
// 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 지원) 사용 |
| @Transactional | TransactionalOperator · @Transactional(리액티브 매니저) |
| 스키마 자동 생성 | 없다 — Flyway·Liquibase 로 관리 (JDBC 로 실행) |
| 2차 캐시 | 없다 — 애플리케이션 캐시(Caffeine·Redis)로 직접 |
리액티브 트랜잭션
리액티브 트랜잭션은 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()
}
스트리밍 — SSE · NDJSON
WebFlux 를 쓰는 가장 확실한 이유입니다. 결과를 다 모으지 않고 오는 대로 흘려보내면 첫 응답이 빨라지고 메모리도 아낍니다.
서버 — 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)하는 설계를 같이 하세요.
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)
}
운영에서 챙길 것
- 인증 — 핸드셰이크 시점에 토큰 검증 (쿼리 파라미터·서브프로토콜 헤더)
- 하트비트 — 유휴 커넥션을 프록시가 끊습니다. 핑/퐁 주기 설정
- 재연결 — 클라이언트가 지수 백오프로 다시 붙게, 서버는 중복 세션 처리
- 메시지 크기 제한 —
maxFramePayloadLength를 넘기면 연결이 끊깁니다 - 세션 수 지표 — 현재 연결 수를 메트릭으로 노출 (용량 산정의 기준)
- 느린 클라이언트 — 송신 버퍼가 차면 끊거나 버려야 합니다
복원력 — 타임아웃 · 재시도 · 서킷
게이트웨이 성격의 서비스일수록 남의 장애가 내 장애가 되는 구조입니다. 코루틴 기반으로 타임아웃 예산 · 재시도 · 서킷 브레이커를 겹쳐 씁니다.
// 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 을 돌려준다
- 실패를 그대로 전달 — 결제처럼 거짓 성공이 더 위험한 경우
컨텍스트 전파 · 디버깅
리액티브에서 가장 답답한 순간은 스택 트레이스에 내 코드가 없을 때입니다. 코틀린 코루틴을 쓰면 크게 나아지지만, 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")
}
)
}
}
테스트 — 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)
}
}
운영 · 튜닝
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 초과 |
배압 — 느린 소비자를 다루는 법
리액티브 스트림이 콜백과 갈라지는 지점이 배압(backpressure)입니다. 보내는 쪽이 빠르고 받는 쪽이 느릴 때, 누가 속도를 정하느냐의 문제입니다.
문제가 되는 순간
DB 에서 초당 5만 건이 나오는데 클라이언트가 초당 5천 건밖에 못 받는다고 합시다. 차이인 초당 4만 5천 건이 어딘가에 쌓입니다. 그 "어딘가"를 정하지 않으면 기본값은 메모리이고, 결과는 OOM 입니다.
콜백 기반 코드에는 이 개념 자체가 없습니다. 리액티브 스트림에서는
구독자가 request(n)으로 "n개만 주세요"라고 말하고,
발행자는 그만큼만 보냅니다 — 이게 배압입니다.
배압이 통하는 소스 · 안 통하는 소스
| 소스 | 배압 |
|---|---|
| R2DBC 조회 | 통합니다 — 드라이버가 fetch size 로 조절 |
| WebClient 응답 | 통합니다 — TCP 수신 창까지 연결 |
| Kafka 컨슈머 | 통합니다 — poll 을 안 하면 안 옵니다 |
| 센서 · 마우스 이벤트 | 안 통합니다 — 현실은 기다려 주지 않습니다 |
| 브로드캐스트 알림 | 안 통합니다 — 버리거나 쌓거나 둘 중 하나 |
코틀린 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 전략을 켜 뒀습니다.
버리는 건수를 지표로 남기세요 — 안 남기면 영원히 모릅니다.
파일 업로드 · 다운로드 스트리밍
WebFlux 에서 파일을 다룰 때 가장 흔한 사고는 파일 전체를 메모리에 올리는 것입니다. 500MB 파일 두 개면 그대로 OOM 입니다. 처음부터 흘려보내는 방식으로 써야 합니다.
업로드 — Multipart 를 조각으로 받기
이렇게 쓰면 안 됩니다
@PostMapping("/upload")
suspend fun upload(@RequestBody body: ByteArray): String {
// 이 시점에 파일 전체가 이미 힙에 올라와 있습니다
File("/data/x.bin").writeBytes(body) // 게다가 블로킹
return "ok"
}
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> 를 받아 업로더에 연결합니다.
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만 행이어도 메모리는 일정합니다
StreamingResponseBody 와 커서 조회를 직접 엮어야 합니다.
| 설정 · 항목 | 기준값과 이유 |
|---|---|
spring.codec.max-in-memory-size | 기본 256KB. JSON 본문 상한으로만 조금 올리고, 파일은 스트리밍으로 우회 |
spring.webflux.multipart.max-in-memory-size | 이 크기를 넘는 파트는 디스크로 내려갑니다 — 임시 디렉터리 용량 확인 |
| 임시 파일 정리 | 업로드 실패 · 클라이언트 이탈 시 남는 파일 — 주기적 청소 배치가 필요 |
| 파일 크기 제한 | 애플리케이션보다 앞단(Nginx · ALB)에서 먼저 막는 편이 안전 |
| 다이렉트 메모리 | -XX:MaxDirectMemorySize — 힙과 별도로 감시. 파일 트래픽이 많으면 필수 지표 |
| 바이러스 검사 · 이미지 변환 | 요청 스레드에서 하지 말고 큐에 넘겨 비동기로 — 응답을 먼저 돌려줍니다 |
선택 기준 · 체크리스트
이 탭의 결론입니다 — 무엇을 고를지와 고른 뒤 무엇을 지킬지.
| 상황 | 권장 |
|---|---|
| 일반 CRUD + JPA | MVC (+ 가상 스레드) — 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을 쓸 것 - 취소 예외를 에러 로그로 — 클라이언트 이탈이 에러로 집계된다
코틀린 엔티티는
data class로
만들면 안 된다
코틀린과 JPA는 철학이 정면으로 충돌합니다 — 코틀린은 불변·final·non-null을 좋아하고, JPA는 가변·프록시·지연 초기화를 전제합니다. 이 탭은 그 충돌 지점을 하나씩 정리하고, Exposed·jOOQ 같은 코틀린 네이티브 대안까지 비교합니다.
기술 고르기 지도
코틀린에서는 선택지가 자바보다 넓습니다. 도메인 복잡도와 쿼리 복잡도, 그리고 팀 경험이 결정 요소입니다.
| 기술 | 코틀린 궁합 | 특징 |
|---|---|---|
| JPA + Spring Data | 보통 (플러그인 필요) | 생태계 최강. 코틀린 함정이 많아 규칙이 필요 |
| QueryDSL | 나쁨 (kapt 의존) | 동적 쿼리 표준이지만 코틀린에서 빌드가 느려진다 |
| Kotlin JDSL | 매우 좋음 | 코틀린 전용 JPA 동적 쿼리 — kapt 불필요 |
| Exposed | 매우 좋음 | 젯브레인스 제작 SQL DSL. 가볍고 명시적 |
| jOOQ | 좋음 | DB 스키마에서 코드 생성 — SQL 을 타입 안전하게 |
| Spring Data JDBC | 좋음 | 지연 로딩 없는 단순 매핑 — 애그리거트 단위 |
| R2DBC | 좋음 (코루틴) | 논블로킹. JPA 기능은 없다 (WebFlux 탭 06) |
| MyBatis | 보통 | SQL 을 직접 쓰는 팀. 코틀린에서도 무리 없이 동작 |
코틀린 엔티티 설계 — 함정 총정리
이 섹션 하나가 이 탭에서 가장 중요합니다. 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 |
모든 필드 val | JPA 는 상태를 바꾼다 → 식별자만 val, 나머지 var |
hashCode()를 id 로 | 저장 전 id=0 → 저장 후 값이 바뀌어 Set 에서 사라진다 → 클래스 상수 |
기본 toString() | 연관을 전부 로딩 → id·기본 필드만 출력 |
| 클래스가 final | 지연 로딩 프록시 불가 → allOpen에 @Entity를 등록해 연다 (plugin.jpa 는 열지 않음) |
| 기본 생성자 없음 | JPA 요구 → plugin.jpa(no-arg) |
@Enumerated 기본값 | ORDINAL 은 순서가 바뀌면 데이터가 밀린다 → STRING |
| 가변 컬렉션 노출 | 밖에서 마음대로 추가 → private + 읽기 전용 getter |
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 블록도 실행 (검증 로직이 있다면)
}
연관관계 매핑
연관관계는 “어느 쪽이 외래키를 갖는가”와 “언제 로딩할 것인가” 두 가지 결정입니다. 코틀린에서는 여기에 컬렉션 캡슐화가 더해집니다.
@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 쓰지 말 것
영속성 컨텍스트 · 더티 체킹
JPA 를 쓰면서 “왜 save()를 안 불렀는데 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이 드러나는데,
그것이 고쳐야 할 곳을 알려 주는 신호입니다.
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 · queryBy | SELECT |
| And · Or | AND · OR |
| Between · LessThan · After | 범위 조건 |
| Like · StartingWith · Containing | LIKE |
| In · NotIn | IN |
| IsNull · IsNotNull | IS NULL |
| OrderBy…Desc | ORDER BY |
| Top10 · First | LIMIT |
| Distinct | DISTINCT |
@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() // 상속받기만 하면 된다
쿼리 — 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 | 필요한 컬럼만 · 불변 · 가장 명확. 패키지 전체 경로를 써야 한다 |
| 인터페이스 | 가장 가볍다 · 이름만 맞추면 됨 · 중첩 프로젝션도 가능 |
벌크 연산의 함정
@Transactional
fun expireAll() {
val order = repo.findByIdOrNull(1)!! // 1차 캐시에 올라간다
repo.bulkUpdateStatus(NEW, EXPIRED) // DB 는 바뀌었지만
println(order.status) // 여전히 NEW (캐시가 모른다)
}
// 해결 — @Modifying(clearAutomatically = true) 로 캐시를 비운 뒤 '다시 조회'하거나
// (이미 꺼내 둔 order 변수는 비워도 옛 값 그대로다)
// 벌크 연산을 별도 트랜잭션·별도 메서드로 분리한다
// 벌크 연산은
// · 더티 체킹을 거치지 않는다 (엔티티 변경 감지 없음)
// · @LastModifiedDate 같은 감사 필드가 갱신되지 않는다
// · 그만큼 빠르다 (수십만 건 처리에 적합)
N+1 문제와 해결
JPA 성능 문제의 8할입니다. 목록 10건을 조회했는데 쿼리가 11번 나가는 현상이고, 해결책은 fetch join · EntityGraph · 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개씩 묶어서 가져온다
실무 조합 — 이렇게 씁니다
- 기본값으로
default_batch_fetch_size: 100을 켜 둔다 — 대부분의 N+1 이 2번 쿼리로 줄어듭니다 - ~ToOne 연관은 fetch join 으로 함께 조회
- 컬렉션은 batch size 에 맡기거나, 페이징이 필요하면 id 만 페이징 → 그 id 로 상세 조회 2단계로
- 조회 전용은 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 에서 실패합니다.
동적 쿼리 ① — 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 에 추가
동적 쿼리 작성
@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)
}
}
BooleanBuilder 대신
nullable 을 그대로 넘기는 방식이 코틀린과 잘 맞습니다.
where(...)에 null을 넘기면 그 조건은 무시되므로,
cond.status?.let { o.status.eq(it) } 처럼 만든 값을 그대로 나열하면 됩니다.
동적 쿼리 ② — 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 을 새로 익혀야 |
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),
)
}
}
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 서버 |
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·트랜잭션이 자동으로 연결된다
트랜잭션 심화
전파(propagation)·격리 수준·락은 동시성 사고가 났을 때 비로소 필요해지는 지식입니다. 코틀린 코드로 실제 상황과 함께 정리합니다.
| 전파 속성 | 동작 · 쓰는 곳 |
|---|---|
| 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)로
더 싸게 해결됩니다.
대용량 · 성능
수십만 건을 다루기 시작하면 JPA 를 쓰는 방식 자체를 바꿔야 합니다. 영속성 컨텍스트가 도움이 아니라 부담이 되는 구간입니다.
배치 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(신중히) |
JdbcTemplate.batchUpdate나 jOOQ 로 순수 SQL을 쓰면
수십 배 빠른 경우가 많습니다. 도메인 규칙이 필요 없는 데이터 이관·집계라면
굳이 엔티티를 만들 이유가 없습니다.
스키마 관리 — 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 로 통일하는 편이 낫습니다.
데이터 계층 테스트
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 로
선택 가이드 · 체크리스트
이 탭의 결론입니다 — 코틀린 + JPA 를 안전하게 쓰는 규칙과 언제 다른 도구로 갈아탈지에 대한 기준.
코틀린 JPA 규칙 (팀 컨벤션 후보)
- 엔티티는 일반 class — data class 금지
- 식별자만
val, 나머지var equals는 id 기반,hashCode는 클래스 상수toString에 연관 필드 제외- 모든 연관은
LAZY명시 @Enumerated(STRING)고정- 컬렉션은 private + 읽기 전용 getter
open-in-view: false·ddl-auto: validatedefault_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 |
StateFlow 위에 서 있는지 보게 됩니다.
안드로이드는
화면이 언제든
사라질 수 있다
서버 개발과 가장 다른 점입니다 — 화면을 돌리면 액티비티가 다시 만들어지고, 메모리가 부족하면 프로세스가 통째로 죽습니다. 그래서 안드로이드 코드의 절반은 “상태를 어디에 두고 어떻게 복구할 것인가”에 대한 답입니다. Compose와 ViewModel은 그 답을 구조로 만든 도구입니다.
프로젝트 구조와 시작
안드로이드 프로젝트는 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 버전·제조사 변형까지 고려
생명주기 — 화면은 사라진다
안드로이드에서 버그의 가장 큰 원천입니다. 화면 회전 · 백그라운드 전환 · 프로세스 종료 세 가지가 각각 다르게 동작하고, 각각 다른 복구 수단이 있습니다.
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())
}
TransactionTooLargeException으로 앱이 죽습니다.
목록 데이터는 DB 에 두고 id 만 저장하는 것이 정석입니다.
Compose — 선언형 UI
XML 로 화면을 만들고 findViewById로 값을 바꾸던 방식이,
“상태를 주면 화면이 그려진다”는 함수 하나로 바뀝니다.
기존 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)만 바뀌면 화면은 알아서 다시 그려진다
핵심 개념 넷
| 개념 | 뜻 |
|---|---|
@Composable | UI 를 그리는 함수. 반환값이 아니라 화면을 방출한다 |
| Recomposition | 상태가 바뀌면 그 부분만 다시 실행된다 |
State<T> | Compose 가 관찰하는 값. 바뀌면 recomposition 을 유발 |
Modifier | 크기·여백·클릭·배경을 체인으로 붙이는 방식 |
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(showBackground = true, name = "빈 목록")
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES, name = "다크")
@Composable
private fun OrderListPreview() = AppTheme { OrderList(orders = emptyList(), onClick = {}) }
레이아웃 · 리스트
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)
}
}
}
}
when으로 분기하는 이 형태가
Compose 화면의 표준 골격입니다. 로딩·에러·성공·빈 목록 네 가지를 빠뜨리지 않게
컴파일러가 도와줍니다(Kotlin 기초 11 · sealed).
상태와 재구성(recomposition)
Compose 를 제대로 쓰는 것은 상태를 어디에 둘지를 정하는 일입니다. 잘못 두면 화면이 안 바뀌거나, 반대로 필요 없이 계속 다시 그려집니다.
// ① 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)
}
재구성을 줄이는 기술
- 상태를 읽는 범위를 좁힌다 — 값을 그대로 넘기지 말고 람다로 넘기면
그 자식만 재구성됩니다 (
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 fun A() { count++ } 같은 코드는 재구성마다 실행되어
무한 루프가 됩니다. 상태 변경은 이벤트 콜백이나 부수 효과 API(다음 섹션)에서만.
부수 효과 — Effect API
“화면이 뜨면 데이터를 불러온다”, “에러가 생기면 스낵바를 띄운다” 같은 UI 그리기가 아닌 일은 정해진 API 안에서 해야 합니다. 아무 데서나 하면 재구성마다 반복 실행됩니다.
| API | 언제 쓰나 |
|---|---|
LaunchedEffect(key) | 진입 시 한 번 · key 가 바뀔 때 코루틴 실행 (가장 많이 씀) |
rememberCoroutineScope() | 이벤트 콜백에서 코루틴을 시작할 때 (클릭 → 스크롤) |
DisposableEffect(key) | 등록/해제가 짝인 것 (리스너·브로드캐스트 리시버) |
rememberUpdatedState | 오래 도는 effect 안에서 최신 람다를 참조해야 할 때 |
SideEffect | 재구성이 성공할 때마다 비-Compose 객체에 값 전달 |
produceState | 비-Compose 소스를 State 로 변환 |
snapshotFlow | Compose 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 자리
ViewModel · 화면 상태 설계
안드로이드 아키텍처의 중심입니다. 화면이 재생성돼도 살아남고,
viewModelScope로 화면이 사라지면 코루틴이 자동 취소됩니다.
// 화면 상태 — 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,
)
}
ViewModel 규칙
- Context·View·Activity 를 들고 있지 않기 — 메모리 누수의 정석
(필요하면
AndroidViewModel의application) - UI 모델로 변환해서 내보내기 — 화면이 도메인 객체를 직접 다루지 않게
- 상태는 하나의
StateFlow— 여러 개면 조합 상태가 어긋납니다 - 이벤트와 상태를 구분 — 토스트·이동은 이벤트
init에서 무거운 로딩을 하면 테스트가 어려워집니다 — 화면에서LaunchedEffect로 부르는 방식도 좋습니다
네비게이션
화면 사이 이동과 인자 전달입니다. 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"))
) { /* ... */ }
테마 · 머티리얼 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()
리소스 · 다국어 · 접근성
문자열을 코드에 하드코딩하면 번역도, 접근성도, 화면 크기 대응도 막힙니다. 리소스 시스템은 그 세 가지를 한꺼번에 해결하는 장치입니다.
문자열 리소스
<!-- 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)
네트워크 — 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·데스크톱에서도 돈다 → 멀티플랫폼 탭 참고
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 -> "알 수 없는 오류가 발생했어요"
}
로컬 저장 — Room · DataStore
앱은 오프라인에서도 뭔가 보여 줘야 합니다. 구조화된 데이터는 Room(SQLite), 설정 같은 작은 값은 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 }
}
}
apply()가 메인 스레드를 막는 경우가 있고 에러 처리가 없습니다.
DataStore 는 코루틴 기반이라 안전합니다.
토큰·비밀번호는 EncryptedSharedPreferences나 Keystore 를 쓰세요.
권한 · 시스템 연동
카메라·위치·알림은 사용자가 허락해야 쓸 수 있고, 거부될 수도 있습니다. 거부됐을 때의 흐름까지 만들어야 완성입니다.
// 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> 선언 필요 |
| PendingIntent | FLAG_IMMUTABLE 필수 |
| 정확한 알람 | 별도 권한 · 사용 사유 필요 |
이미지 · 애니메이션
앱의 체감 품질을 좌우하는 두 가지입니다. 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나 시스템 설정을 확인해 줄이는 것이 좋습니다.
디버깅 · 기초 체크리스트
기초 탭의 마무리입니다. 문제를 보는 도구와, 이 탭의 내용을 실제 앱에 적용할 때의 점검 목록입니다.
| 도구 | 보는 것 |
|---|---|
| Logcat | 로그·크래시 스택. 태그와 프로세스로 필터링 |
| Layout Inspector | 실행 중 화면의 컴포저블 트리와 재구성 횟수 |
| Compose 미리보기 | 기기 없이 상태별 화면 확인 |
| Profiler (CPU·메모리·에너지) | 버벅임·누수·배터리 |
| Network Inspector | API 요청·응답 본문 |
| Database Inspector | 실행 중 Room DB 를 SQL 로 조회 |
| StrictMode | 메인 스레드에서의 디스크·네트워크 접근을 잡아 준다 |
| LeakCanary | 메모리 누수 자동 탐지 (디버그 빌드에만 넣는다) |
출시 전 최소 점검
- 화면 회전에서 상태가 유지되는가
- 개발자 옵션 “활동 유지 안 함”으로 프로세스 종료 복구 확인
- 다크 모드에서 글자가 보이는가
- 글자 크기 최대로 키웠을 때 레이아웃이 깨지지 않는가
- 비행기 모드에서 에러 화면과 재시도가 동작하는가
- 느린 네트워크(에뮬레이터 설정)에서 로딩 표시가 뜨는가
- 릴리스 빌드(R8)에서도 정상 동작하는가 — 난독화 문제는 여기서만 드러납니다
자주 만나는 크래시
| 예외 | 원인 |
|---|---|
| NetworkOnMainThreadException | 메인 스레드에서 네트워크 → withContext(IO) |
| TransactionTooLargeException | Bundle·SavedState 에 큰 데이터 |
| IllegalStateException: Fragment not attached | 화면이 사라진 뒤 UI 접근 |
| Room: schema changed | 마이그레이션 누락 |
| ClassNotFound (릴리스만) | R8 난독화 — proguard-rules.pro에 규칙 추가 |
| ANR | 메인 스레드 5초 이상 점유 |
앱은 만드는 것보다
운영하는 게
훨씬 길다
출시 이후에 필요한 것들입니다 — 의존성 주입과 모듈 구조로 코드가 무너지지 않게 하고, 성능·크래시·배포를 관리합니다. 사용자는 앱을 지우면 그만이므로, 느리거나 죽는 앱은 기능이 아무리 많아도 소용없습니다.
아키텍처 · 멀티모듈
화면이 열 개를 넘어가면 구조가 필요해집니다. 구글이 권장하는 3계층(UI · Domain · Data)과 기능별 모듈이 지금의 표준입니다.
멀티모듈 구조
: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 끼리는 서로 의존하지 않는다 (앱 모듈이 연결)
모듈 사이 계약 — 인터페이스
// :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") }
의존성 주입 — Hilt
안드로이드는 객체를 직접 만들 수 없는 자리(Activity·ViewModel·Worker)가 많습니다. Hilt 는 그 자리에 컴파일 시점에 검증된 주입을 넣어 줍니다.
// 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) |
| ViewModelComponent | ViewModel 하나 (@ViewModelScoped) |
| ActivityComponent | 액티비티 하나 |
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)
}
}
}
}
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 를 쓰면 됩니다 —
// 원리를 알면 특수한 배치가 필요할 때 직접 만들 수 있습니다.
Paging — 무한 스크롤
목록이 수천 건이 되면 한 번에 불러올 수 없습니다. Paging 3 는 스크롤에 맞춰 페이지를 요청하고, 캐시하고, 재시도까지 처리합니다.
// 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
}
}
백그라운드 작업 — 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 로
알림 · 푸시(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"] ?: "알림")
}
}
인증 · 보안
모바일 앱은 사용자 기기 위에서 돌기 때문에 서버와 신뢰 모델이 다릅니다. 토큰 저장, 갱신, 그리고 “앱은 신뢰할 수 없다”는 전제가 핵심입니다.
// 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>
성능 ① — 재구성 최적화
Compose 앱이 버벅이는 원인의 대부분은 필요 없는 재구성입니다. 원인을 추측하지 말고 측정한 뒤 고칩니다.
측정 — 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 이 아니다 (문제)
고치는 방법
// ① 불안정 타입 → 안정 타입
@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을 붙이는 것은
거짓말이 될 수 있습니다(내부에 가변 필드가 있으면 화면이 갱신되지 않습니다).
측정해서 문제가 확인된 곳부터 고치세요.
성능 ② — 시작 시간 · 메모리
사용자가 가장 먼저 체감하는 지표는 앱이 뜨는 속도입니다. Play Console 의 Android Vitals가 이 숫자로 앱을 평가하고, 검색 순위에도 영향을 줍니다.
| 지표 | 기준 · 개선 방법 |
|---|---|
| 콜드 스타트 | 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")
테스트 전략
안드로이드 테스트는 느린 것과 빠른 것이 확연히 갈립니다. 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 }
}
// 목과 달리 '실제로 동작'하므로 여러 테스트에서 재사용된다
R8 · 앱 크기
릴리스 빌드에서 코드가 축소·최적화·난독화됩니다. 여기서 처음 드러나는 버그가 있고, 앱 크기는 설치 전환율에 직접 영향을 줍니다.
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
앱 크기 줄이기
- 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
서명 · 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%) |
크래시 · 모니터링
운영 중인 앱에서 무슨 일이 일어나는지 아는 것이 전부입니다. 사용자는 대부분 신고하지 않고 지웁니다.
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 버전별 분포 — 특정 제조사에서만 나는 크래시가 흔합니다
- 사용자 여정 분석 — 어디서 이탈하는지
위젯 · 시스템 통합
홈 화면 위젯, 앱 바로가기, 공유 대상 — 앱을 시스템 안에 자리 잡게 하는 기능들입니다. 재방문율에 직접 영향을 줍니다.
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"
출시 체크리스트
실전 탭의 마무리입니다. 이 목록은 첫 출시와 매 배포에 나눠 쓰면 됩니다.
첫 출시 전
- 서명 키를 안전하게 백업(Play 앱 서명 사용 권장)
- 릴리스 빌드로 전체 흐름 테스트 (R8 문제 확인)
- Crashlytics·Analytics 연동 + 매핑 파일 업로드 확인
- 개인정보 처리방침 URL · 데이터 안전 섹션 작성
- 권한 사용 사유 작성 (민감 권한은 심사 대상)
- 여러 화면 크기·OS 버전에서 확인 (에뮬레이터로도 충분)
- 다크 모드·큰 글자·접근성 스캐너
- 네트워크 없음·느림·서버 500 상황의 화면
매 배포마다
- versionCode 증가 확인 (같으면 업로드 거부)
- DB 마이그레이션 작성·테스트 (기존 사용자 데이터 보존)
- API 하위 호환 — 구버전 앱이 계속 동작하는지
- 단계적 출시로 시작 + Vitals 모니터링
- 강제 업데이트가 필요하면 In-App Update API
- 롤백 계획 — 문제 시 배포 중단 절차 숙지
공유할 것과
공유하지 말 것을
먼저 정한다
KMP의 성패는 기술이 아니라 경계선을 어디에 긋느냐입니다. 도메인·네트워크·저장소는 공유해서 이득이 크고, UI 는 공유할 수도, 안 할 수도 있습니다. 이 탭은 그 판단 기준부터 시작해 iOS 연동의 실제 마찰까지 다룹니다.
무엇을 공유할 것인가
KMP 는 “모든 것을 한 번에 만들자”가 아닙니다. 플랫폼과 무관한 로직만 공유하고 나머지는 각자 두는 점진적 방식이 핵심입니다.
KMP 가 잘 맞는 경우
- 같은 기능을 두 플랫폼에 동시에 내야 한다
- 비즈니스 규칙이 복잡하다 (계산·검증·상태 전이)
- 두 앱의 동작이 미묘하게 달라 버그 리포트가 갈린다
- 안드로이드 팀이 iOS 도 지원해야 하는 인력 구조
- 서버·앱이 모델을 공유하면 좋겠다 (JVM 타깃 추가)
KMP 가 부담이 되는 경우
- iOS 개발자가 코틀린을 원치 않는다 — 조직의 동의가 먼저입니다
- 플랫폼 고유 기능이 대부분인 앱 (카메라·AR·위젯 중심)
- 앱이 아주 작다 — 공유 인프라 구축 비용이 더 큽니다
- 빌드·디버깅 도구 성숙도가 네이티브만 못한 구간이 아직 있습니다
프로젝트 구조 · 소스셋
KMP 프로젝트의 뼈대는 소스셋 계층입니다.
commonMain에 쓴 코드는 모든 타깃으로 가고, 플랫폼별 폴더는 그 타깃에만 갑니다.
소스셋 계층
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)
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 } }
expect / actual
플랫폼마다 다르게 구현해야 하는 것을 잇는 장치입니다. 공통 코드는 “이런 게 있다”만 선언하고, 각 플랫폼이 실제 구현을 제공합니다.
// 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 로 갈 때 가장 먼저 확인할 것은 “쓰던 라이브러리가 KMP 를 지원하는가”입니다. 다행히 핵심 영역은 대부분 갖춰져 있습니다.
| 영역 | KMP 라이브러리 | 대체하는 것 |
|---|---|---|
| 동시성 | kotlinx-coroutines | 모든 플랫폼 공통 |
| 직렬화 | kotlinx-serialization | Jackson · Gson |
| 날짜·시간 | kotlinx-datetime | java.time · Foundation |
| HTTP | Ktor Client | Retrofit · URLSession |
| DB | SQLDelight · Room KMP | Room · CoreData |
| 키-값 저장 | multiplatform-settings · DataStore | SharedPreferences · UserDefaults |
| DI | Koin · kotlin-inject | Hilt |
| 파일·IO | okio | java.io · FileManager |
| 이미지 로딩 | Coil 3 | Coil · Kingfisher |
| 로깅 | Napier · Kermit | Timber · os_log |
| UI | Compose Multiplatform | Jetpack Compose · SwiftUI |
| 테스트 | kotlin-test · Turbine | JUnit · 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 표시가 있으면 그 타깃에서 씁니다.
commonMain에 못 들어갑니다.
그런 경우 인터페이스로 감싸고 플랫폼별 구현을 주입하세요.
네트워크 — 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 불필요) |
로컬 저장 — 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)
저장소를 합쳐 단일 소스 만들기
// 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 는 안 된다" 같은 차이가 사라진다
iOS 연동 — Swift에서 부르기
KMP 의 가장 큰 마찰 지점입니다. 코틀린 코드는 Objective-C 헤더를 거쳐 Swift 에 노출되므로, 제네릭·sealed·기본값이 그대로 보이지 않습니다.
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>> 대신
코루틴을 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)
}
}
}
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() }
}
}
commonMain에서 ViewModel과 viewModelScope를 그대로 쓸 수 있어,
프레젠테이션 로직 공유가 예전보다 훨씬 쉬워졌습니다.
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) {}
}
플랫폼별 지원 수준
| 타깃 | 상태 |
|---|---|
| Android | Jetpack 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 를 공유하지 않는 전략
가장 많이 쓰이는 구성입니다 — 로직은 코틀린으로 공유하고, UI 는 각 플랫폼 네이티브. 팀 갈등이 적고, 플랫폼 경험을 잃지 않습니다.
공유 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() }
}
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+)
테스트 · 공통 검증
공유 모듈의 큰 이득 하나 — 테스트도 한 번만 쓰면 됩니다. 그리고 그 테스트가 모든 타깃에서 실행됩니다.
// 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 로 |
빌드 · 배포
공유 모듈을 iOS 프로젝트에 어떻게 전달할 것인가가 핵심입니다. 직접 참조·CocoaPods·SPM 세 가지 경로가 있고, 팀 구조에 따라 선택이 갈립니다.
| 방식 | 특징 · 언제 |
|---|---|
| 직접 참조(모노레포) | Xcode 빌드 단계에서 Gradle 실행 — 한 저장소에서 함께 개발할 때 가장 단순 |
| CocoaPods | cocoapods {} 블록으로 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 으로 받아 쓸 수 있다
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
리소스 · 다국어를 공유하기
코드는 공유했는데 문구와 아이콘은 플랫폼마다 따로라면, 문구 하나 고칠 때마다 세 군데를 고쳐야 합니다. 실제로 KMP 도입 후 가장 자주 어긋나는 곳이 여기입니다.
공유하지 않으면 이렇게 됩니다
- 안드로이드
strings.xml· iOSLocalizable.strings· 웹 JSON — 같은 문구가 세 벌 - 번역가에게 파일을 세 개 보내고 세 번 받습니다
- 한쪽만 고쳐 배포해 플랫폼마다 다른 문구가 나가는 사고
- 키 이름 규칙도 달라서 어느 문구가 어느 키인지 추적이 안 됩니다
선택지 — 지금은 사실상 둘
| 도구 | 언제 고르나 |
|---|---|
| Compose Multiplatform Resources | UI 까지 Compose 로 공유할 때. 공식 경로이고 문자열·이미지·폰트를 함께 다룹니다 |
| moko-resources | UI 는 각자 만들되 문자열만 공유할 때. 네이티브 리소스로 생성해 줍니다 |
| 직접 구현 | 문자열 수가 아주 적을 때만. expect/actual 로 맵 하나 |
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 로 보내고, 표시할 때만 지역을 적용합니다.
start/end 기준으로 레이아웃을 짜세요.
left/right 로 짜 둔 화면을 나중에 뒤집는 일은
새로 만드는 것과 비용이 비슷합니다.
이미 있는 앱에 KMP 를 밀어 넣기
실무에서 KMP 를 시작하는 경우의 대부분은 새 프로젝트가 아니라 이미 돌고 있는 앱입니다. 전부 갈아엎지 않고 모듈 하나부터 넣는 순서를 정리합니다.
실제 붙이는 순서 — 안드로이드 앱 기준
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 연결
도입 전에 팀과 합의해야 할 것들
| 항목 | 합의해 둘 내용 |
|---|---|
| 공유 범위의 상한 | "UI 는 공유하지 않는다"처럼 선을 먼저 긋습니다 — 안 그으면 매번 논쟁이 됩니다 |
| 빌드 소유자 | KMP 빌드가 깨졌을 때 고치는 사람을 정해 둡니다. 애매하면 아무도 안 고칩니다 |
| 공유 모듈 리뷰 | 양쪽 플랫폼 담당자가 모두 리뷰합니다 — 한쪽만 편한 API 가 나오는 걸 막습니다 |
| 되돌리는 방법 | 공유 모듈을 걷어내고 각자 구현으로 돌아가는 경로를 문서로 남깁니다 |
| CI 비용 | 맥 러너가 필요합니다 — 비용과 대기 시간을 미리 계산해 두세요 |
| 디버깅 경험 | iOS 에서 공유 코드에 브레이크포인트가 걸리는지 먼저 확인합니다 |
파일럿에 좋은 후보
- 할인·배송비 계산 규칙
- 입력값 검증 규칙(전화번호·주소)
- 서버 응답 모델과 파싱
- 기능 플래그 · A/B 분기 판단
파일럿에 나쁜 후보
- 결제 · 인증 — 플랫폼 SDK 의존이 큽니다
- 카메라 · 블루투스 등 하드웨어
- 푸시 알림 — 양쪽 구조가 근본적으로 다릅니다
- 화면 전환 — 네비게이션 모델이 다릅니다
성공 판정 기준
- 같은 버그를 두 번 고치는 일이 줄었나
- 스펙 변경 반영이 한 번에 끝나나
- iOS 팀의 빌드 대기 시간이 늘지 않았나
- 새로 온 사람이 구조를 이해하는 데 걸리는 시간
도입 판단 · 체크리스트
기술보다 조직의 결정이 더 큰 변수입니다. 실제로 도입할 때 확인할 것들을 정리합니다.
도입 전 확인
- 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·검증 규칙을 서버와 공유 |
코루틴은
병목을 옮길 뿐
없애지 않는다
스레드 제약이 풀리면 병목은 DB 커넥션·외부 API·락으로 옮겨갑니다. 이 탭은 어디가 먼저 터지는지를 순서대로 짚고, 캐시·분산 락·멱등성·이벤트 처리를 코틀린 코드로 다룹니다.
트래픽을 숫자로 읽기
“트래픽이 많다”는 말로는 아무것도 결정할 수 없습니다. 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 사용률 |
// 스프링에서 지표 노출 — 이것부터 하고 시작한다
@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)
}
}
}
병목 지도 — 어디가 먼저 터지나
코루틴을 도입하면 스레드 병목이 사라집니다. 그러면 다음 병목이 드러납니다. 순서를 알고 있으면 장애 때 30분을 아낍니다.
// 커넥션 풀과 코루틴 동시성을 '맞춘다'
// 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 쪽 컨텍스트 스위칭이 늘어 오히려 느려진다
캐시 전략 — 2단 구성
가장 효과가 큰 최적화입니다. 로컬 캐시(Caffeine)와 분산 캐시(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 로 받아 처리
}
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 는 단일 스레드라 무거운 명령 하나가 전체를 막습니다.
캐시 3대 재난
캐시는 잘 돌 때는 조용하다가, 무너질 때 한꺼번에 무너집니다. 세 가지 유형을 알고 대비하면 DB 가 캐시 때문에 죽는 사고를 막을 수 있습니다.
@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
동시성 제어 · 분산 락
선착순 쿠폰, 재고 차감, 좌석 예약 — 서버가 여러 대일 때
단일 JVM 의 Mutex로는 막을 수 없습니다.
| 방법 | 특징 · 언제 |
|---|---|
| 원자적 UPDATE | update ... 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() // 임대가 이미 끝났다면 여기서 예외 — 작업이 너무 길었다는 신호
}
}
멱등성 — 중복 요청 막기
네트워크는 언제나 불확실합니다. 클라이언트가 같은 요청을 두 번 보내거나, 재시도가 이미 처리된 요청을 또 보냅니다. 두 번 눌러도 한 번만 처리돼야 합니다.
// 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) — 재전달이 기본이라 필수
- 웹훅 수신 — 외부 서비스가 여러 번 보냅니다
- 재시도가 있는 모든 호출 — 재시도 = 중복 가능성
3층 방어
| 층 | 역할 |
|---|---|
| 클라이언트 | 버튼 비활성화 · 요청 키 생성 |
| 애플리케이션 | Redis 멱등 키 — 빠르게 걸러낸다 |
| DB | 유니크 제약 — 최종 방어선. 절대 빠뜨리지 말 것 |
캐시는 언젠가 비워지고, 애플리케이션은 배포로 재시작됩니다. DB 제약조건만이 영구적입니다.
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 로 |
| 컨슈머 랙 | 가장 중요한 지표 — 밀리기 시작하면 파티션·인스턴스를 늘린다 |
| 파티션 수 | 소비자 인스턴스 수의 한계 — 줄일 수 없으니 여유 있게 |
아웃박스 · 사가
“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 변경 로그에서 바로 이벤트를 만들 수 있습니다
격벽 · 부하 차단
한 기능의 장애가 전체 서비스를 멈추게 하지 않도록 자원을 나눕니다. 코루틴에서는 세마포어와 전용 디스패처가 그 도구입니다.
// ① 격벽(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)
}
// 규칙
// · 하위 타임아웃 합 < 상위 타임아웃 (여유 포함)
// · 상위가 이미 취소됐으면 하위 호출을 시작하지 않는다 (코루틴이 자동 처리)
// · 클라이언트 타임아웃 > 서버 타임아웃 (안 그러면 서버가 헛일을 한다)
처리율 제한 · 대기열
티켓 오픈·한정 판매처럼 순간 트래픽이 평소의 100배가 되는 경우입니다. 서버를 100배 늘릴 수 없으니 들어오는 양을 조절합니다.
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분)"
데이터베이스 확장
애플리케이션은 서버를 늘리면 되지만 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() // 복제본을 다시 조회하지 않는다
}
관측 가능성
대규모 시스템에서 “느리다”는 신고를 받고 원인을 찾는 데 걸리는 시간이 곧 장애 시간입니다. 메트릭·로그·추적 세 가지를 처음부터 갖춰야 합니다.
메트릭 — 무엇이 이상한가
// 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 지연·실패율 · 서킷 상태 |
| 큐 | 컨슈머 랙 · 아웃박스 미발행 건수 |
부하 테스트 · 전체 체크리스트
추측으로 용량을 정하지 않습니다. 측정하고, 한계를 알고, 대비합니다.
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 전파 · 증상 기반 알람 |
| 검증 | 부하·내구·카오스 테스트를 배포 전에 |
“이 문법이
무엇으로 바뀌는가”를
아는 사람
성능 논쟁이 추측에서 사실로 바뀌는 지점입니다.
data class가 만드는 실제 바이트코드, suspend가 되는 상태 기계,
인라인이 지우는 객체, 널 검사가 넣는 명령 — 전부 눈으로 볼 수 있습니다.
그리고 그 지식이 측정과 최적화의 기준이 됩니다.
K2 컴파일러 파이프라인
Kotlin 2.0 의 가장 큰 변화입니다. 프런트엔드가 FIR로 다시 쓰이면서 컴파일이 빨라지고 타입 추론이 정확해졌으며, 모든 타깃이 같은 프런트엔드를 씁니다.
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
코틀린 → 바이트코드
가장 실용적인 학습 도구입니다. 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 |
object | INSTANCE static 필드 + static 초기화 |
suspend | Continuation 인자 + 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)
디스패치를 건너뛰는 경우
// 이미 올바른 스레드에 있으면 디스패치하지 않는다 (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분기 하나. 사실상 공짜
람다 · 인라인의 실제 비용
“람다는 객체를 만든다”는 말은 절반만 맞습니다. 인라인 여부, 캡처 여부, 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이 이걸 막습니다) - 이스케이프 분석 — 지역에서만 쓰이는 객체는 아예 할당되지 않을 수 있습니다(스칼라 치환)
- 결론은 대개 “차이 없음”입니다 — 수천~수만 건에서는 어느 방식이든 마이크로초 단위입니다
널 안전성의 런타임 비용
코틀린의 널 안전성은 대부분 컴파일 시점에 처리되지만, 자바와 만나는 경계에는 실제 검사 코드가 들어갑니다. 그 위치와 비용을 봅니다.
// 공개 함수의 파라미터에는 런타임 검사가 삽입된다
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은 릴리스 빌드에서 이 검사를 일부 제거합니다(코드 크기 절감)
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 로 쓰면 박싱됨)
제네릭 소거 · 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) // ✅
메모리 — 객체 레이아웃과 GC
코틀린 객체도 결국 JVM 힙 위의 객체입니다. 크기·정렬·수명을 알면 대용량 처리에서 메모리를 절반으로 줄일 수 있습니다.
객체 크기 재기 — 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)
| 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 같은 도구에 올리면 정지 시간 분포를 볼 수 있다
프로파일링 — 어디에 시간이 쓰이나
추측을 사실로 바꾸는 도구들입니다. 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 와는 별개)
동시성 심화 — JMM과 원자성
코루틴을 써도 여러 스레드에서 실행된다는 사실은 변하지 않습니다. 자바 메모리 모델의 규칙이 그대로 적용됩니다.
가시성 — @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 의 어려운 규칙 대부분은 가변 공유 상태에서 나오는데,
코틀린은 그것을 줄이는 방향으로 설계돼 있습니다.
컴파일러 플러그인
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. 문법을 바꿔야 하거나 기존 클래스를 수정해야 하면 컴파일러 플러그인
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, ...)
GraalVM 네이티브 + 코틀린
기동 수십 ms, 메모리 1/3~1/5. 서버리스·스케일아웃에서 실제 비용 차이를 만듭니다. 대신 리플렉션을 쓰는 코드가 문제가 됩니다.
코틀린에서 걸리는 지점
- 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
타입 시스템 심화
변성·타입 추론·컨텍스트 파라미터까지, 라이브러리를 설계할 때 알아야 할 코틀린 타입 시스템의 깊은 부분입니다.
// ① 타입 추론의 한계 — 어디서 명시해야 하나
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 | 서로 다른 타입이 합쳐졌다 — 설계 재검토 |
함수형 심화 — 대수적 타입
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 }
}
실무에서 쓸 만한 선
- 불가능한 상태를 만들 수 없게 — sealed 로 모델링 (거의 언제나 이득)
- 부수 효과를 경계로 — 순수 함수로 계산하고, IO 는 바깥에서
- 실패를 값으로 — sealed 에러 타입 (예외는 진짜 예외 상황에만)
- 불변 데이터 —
val+copy - 여기까지가 대부분의 팀에서 순이득입니다
아키텍처 — 헥사고날 · 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계층으로 시작해 필요할 때 옮기는 편이 쌉니다
대규모 코드베이스 운영
코드가 수십만 줄, 개발자가 수십 명이 되면 문법 지식이 아니라 운영 기술이 필요합니다. 빌드 시간, 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(결정 기록) · 코드 리뷰 체크리스트 |
전문가 체크리스트
이 탭의 내용을 실제로 써먹는 순서로 정리했습니다. 성능 문제를 만났을 때 위에서부터 확인하세요.
성능 문제 진단 순서
- 측정한다 — 어느 API 가 느린가 (지표·APM)
- 프로파일링 — CPU 인가 대기인가 (async-profiler wall/cpu)
- DB 쿼리 — 슬로우 쿼리·N+1·인덱스 (대부분 여기)
- 외부 호출 — 타임아웃·직렬 호출을 병렬로
- 캐시 — 반복 조회를 없앤다
- 알고리즘 — O(n²) 를 찾는다 (컬렉션 사용 패턴)
- 할당 — 대량 객체 생성·박싱 (alloc 프로파일)
- GC·JVM 옵션 — 마지막에 본다
알아 두면 좋은 도구 모음
| 도구 | 용도 |
|---|---|
| Show Kotlin Bytecode | 문법 → 실제 코드 확인 |
| JMH | 마이크로 벤치마크 |
| async-profiler | CPU·할당·락·wall 프로파일 |
| JFR + JMC | 운영 중 저부담 프로파일 |
| Eclipse MAT | 힙덤프 분석 (누수) |
| JOL | 객체 크기·레이아웃 |
| DebugProbes | 코루틴 덤프 |
| ArchUnit | 구조 규칙 테스트 |
| detekt · ktlint | 정적 분석 |
매일 쓰는 도구를
제대로 설정하면
팀 전체가 빨라진다
빌드 30초, 코드 스타일 논쟁 0회, 테스트가 믿을 만한 상태 — 이건 도구 설정의 문제이지 개인 실력의 문제가 아닙니다. 이 탭은 코틀린 프로젝트에서 한 번 잘 잡아 두면 계속 이득인 설정들을 모았습니다.
Gradle Kotlin DSL
빌드 스크립트를 코틀린으로 쓰면 자동완성과 컴파일 검사가 따라옵니다. Groovy DSL 에서 오타로 반나절 날리던 일이 사라집니다.
--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)
멀티모듈 · 컨벤션 플러그인
모듈이 열 개가 되면 build.gradle.kts 열 개에 같은 설정을 복사하게 됩니다. 컨벤션 플러그인은 그 중복을 없애는 표준 방법입니다.
// 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— 의존성 전파를 막아 재컴파일 범위를 줄입니다
모듈 의존성 확인
# 의존성 트리
./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() // 충돌을 에러로 (엄격 모드)
}
}
정적 분석 — 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)을 만들어 기존 것은 넘어가고
새 코드만 검사하게 시작하세요. 그다음 조금씩 줄여 나가면 됩니다.
테스트 도구 총정리
코틀린 테스트 생태계는 선택지가 많습니다. 무엇을 언제 쓰는지와 조합 방법을 정리합니다.
| 도구 | 역할 · 언제 |
|---|---|
| JUnit 5 | 실행기(러너). 사실상 기본 — 자바 경험자가 바로 씁니다 |
| Kotest | 러너 + 단언 + 프로퍼티 테스트. 단언만 가져다 쓰는 조합도 흔합니다 |
| MockK | 코틀린 전용 목 — final·suspend·object 지원 |
| Turbine | Flow 방출 검증 |
| kotlinx-coroutines-test | runTest·가상 시간 |
| Testcontainers | 실제 DB·Kafka·Redis 로 통합 테스트 |
| MockWebServer | HTTP 응답 고정 (외부 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")))
직렬화 — 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 · 안드로이드 · 네이티브 |
Ktor 서버 — 스프링 대안
젯브레인스가 만든 코틀린 네이티브 웹 프레임워크입니다. 코루틴이 기본이고 가벼워서, 작은 서비스·게이트웨이·KMP 백엔드에 잘 맞습니다.
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 프로젝트의 백엔드 — 클라이언트와 코드를 공유
- 기동 속도가 중요 — 서버리스·짧은 수명 컨테이너
- 코루틴을 온전히 — 트랜잭션·시큐리티 컨텍스트 마찰이 없다
로깅 · 날짜와 시간
매일 쓰지만 잘못 쓰기도 쉬운 두 가지입니다. 로그는 성능과 보안에, 시간은 정확성에 직결됩니다.
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)
logging.structured.format.console=ecs 한 줄로 JSON 로그가 나옵니다.
로그 수집 시스템(ELK·Loki)에서 필드로 검색할 수 있게 되어 장애 분석 속도가 달라집니다.
파일 · 엑셀 · 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 로 만들고 변환하는 방식이 편하다)
// 한글 폰트를 반드시 임베드해야 글자가 나온다
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이
서비스 계층까지 올라오면 클라이언트 라이브러리를 바꾸는 순간 전부 고쳐야 합니다.
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 로 만드는 편이 낫다
문서화 — 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
// 배경 · 결정 · 결과 · 대안
라이브러리 배포
사내 공용 모듈이나 오픈소스를 Maven 저장소에 올리는 과정입니다. 코틀린 라이브러리는 이진 호환성이라는 추가 고려사항이 있습니다.
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 타깃이 포함된다
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/ 아키텍처 결정 기록
새로 합류한 사람이 클론만 하면 같은 환경이 되는 것이 목표입니다.
의존성 보안 — 내가 안 쓴 코드가 더 많습니다
스프링 부트 프로젝트 하나를 만들면 실제로 딸려 오는 라이브러리는 보통 150~300개입니다. 그중 내가 이름이라도 아는 건 열 개 남짓입니다. 공격은 대부분 나머지에서 들어옵니다.
지금 당장 할 수 있는 것 — 명령 세 줄
무엇이 딸려 오는지 보기
# 특정 설정의 의존성 트리 전체
./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 을 자동으로 열어 줍니다
버전 카탈로그 — 버전을 한 곳에서만 정합니다
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 의 의존성 잠금을 켜면 확실합니다.
JMH — "이게 더 빠를 것 같은데"를 숫자로
JVM 위에서 System.currentTimeMillis() 로 잰 수치는
대부분 틀립니다. JIT 컴파일 · 워밍업 · 죽은 코드 제거가 측정을 통째로 왜곡합니다.
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 를 보세요. 구간이 겹치면 "차이 없음"입니다 |
| 실제 데이터로 쟀나? | 정렬된 배열과 무작위 배열은 분기 예측 때문에 몇 배 차이가 납니다 |
| 같은 기계에서 쟀나? | 노트북은 발열로 클럭이 내려갑니다. 비교는 반드시 같은 실행 안에서 |
목적별 도구 찾기
“이걸 하려면 뭘 써야 하지?”에 대한 답을 모았습니다. 아래 칸에 하려는 일을 한글로 입력해 보세요.
전체 목록 · 위 칸에 하려는 일을 입력해 보세요
| 하려는 일 | 도구 | 비고 |
|---|---|---|
| 빌드·의존성 관리 | Gradle Kotlin DSL + 버전 카탈로그 | 컨벤션 플러그인으로 중복 제거 |
| 코드 스타일 통일 | ktlint | .editorconfig 와 연동 · CI 강제 |
| 위험 패턴 차단 | detekt | !!·GlobalScope 금지 규칙 |
| 단위 테스트 | JUnit 5 (+ Kotest 단언) | 백틱 한글 테스트명 활용 |
| 목(mock) 만들기 | MockK | final·suspend·object 지원 |
| 코루틴·Flow 테스트 | kotlinx-coroutines-test + Turbine | 가상 시간으로 즉시 실행 |
| 실제 DB 로 통합 테스트 | Testcontainers | @ServiceConnection으로 자동 연결 |
| 외부 API 흉내 | MockWebServer · WireMock | 지연·에러 시나리오 재현 |
| JSON 직렬화 | kotlinx.serialization · Jackson | KMP·네이티브면 전자 |
| HTTP 호출 | Ktor Client · Retrofit · WebClient | 스택과 플랫폼에 맞춰 |
| 웹 서버 | Spring Boot · Ktor | 생태계 vs 가벼움 |
| ORM · DB 접근 | JPA · Exposed · jOOQ · Kotlin JDSL | 도메인/쿼리 복잡도로 선택 |
| 스키마 관리 | Flyway · Liquibase | ddl-auto 금지 |
| 캐시 | Caffeine(로컬) + Redis(분산) | 2단 구성 · 스탬피드 방지 |
| 분산 락 | Redisson | 원자적 UPDATE 로 되면 그게 낫다 |
| 비동기 메시징 | Kafka · RabbitMQ | 소비자는 멱등하게 |
| 로깅 | kotlin-logging + Logback | 구조화(JSON) 로그 권장 |
| 메트릭·추적 | Micrometer + Prometheus + OTel | RED/USE 지표 |
| 의존성 주입 | Spring · Hilt(안드로이드) · Koin(KMP) | 플랫폼에 맞춰 |
| 안드로이드 UI | Jetpack Compose | Material 3 · 미리보기 활용 |
| 모바일 로컬 저장 | Room · DataStore · SQLDelight | KMP 면 SQLDelight |
| 이미지 로딩 | Coil 3 | KMP 지원 |
| 백그라운드 작업 | WorkManager | 제약 조건·재시도 내장 |
| 코드 공유 (iOS) | KMP + SKIE | 도메인·데이터부터 |
| 엑셀 읽기·쓰기 | Apache POI (SXSSF) | 대용량은 스트리밍 |
| CSV | kotlin-csv · commons-csv | BOM·따옴표 처리 주의 |
| PDF 생성 | OpenHTMLtoPDF · PDFBox | 한글 폰트 임베드 필수 |
| CLI 도구 | Clikt · kotlin scripting | 네이티브 이미지로 배포 |
| API 문서 | Dokka · springdoc-openapi | REST Docs 도 좋은 선택 |
| 라이브러리 배포 | maven-publish + BCV | 공개 API 를 파일로 고정 |
| 성능 프로파일링 | async-profiler · JFR · JMH | 측정 없이 최적화 금지 |
| 메모리 분석 | Eclipse MAT · LeakCanary(앱) | JOL 로 객체 크기 확인 |
| 부하 테스트 | k6 · Gatling · nGrinder | 운영과 같은 환경에서 |
| 구조 규칙 강제 | ArchUnit · Konsist | 테스트로 경계를 지킨다 |
| 기동 시간 단축 | GraalVM 네이티브 이미지 | 리플렉션 힌트 필요 |
코틀린은 JDK 위에 서고
목적지에 따라
깔 것이 달라집니다
“코틀린을 설치한다”는 말은 사실 정확하지 않습니다. 코틀린 컴파일러는 대개 Gradle 이 알아서 내려받고,
정작 사람이 손으로 깔아야 하는 것은 JDK · IDE · (안드로이드라면) SDK 입니다.
게다가 서버(Spring) · 안드로이드 · 멀티플랫폼(KMP) 세 갈래는 필요한 도구가 서로 다릅니다 —
이걸 모르고 “코틀린 설치법”을 검색하면 남의 갈래 글을 따라 하다 시간을 버립니다.
이 탭은 어느 사이트에서 무엇을 받아, 어느 체크박스를 켜고, 환경 변수 어느 칸에 무엇을 넣고,
어떤 명령으로 확인하는지까지 한 단계도 건너뛰지 않습니다.
끝나면 빈 PC 에서 ./gradlew run 이 도는 상태를 스스로 만들 수 있습니다.
코틀린 환경의 세 갈래 — 무엇을 만들지부터 정합니다
파이썬은 “파이썬을 깐다”가 끝이고, 자바는 “JDK 를 깐다”가 끝입니다. 코틀린은 어디로 컴파일할지에 따라 깔 것이 달라집니다. 서버는 JDK 하나면 되지만, 안드로이드는 SDK 수 기가가 더 필요하고, iOS 까지 노리는 KMP 는 맥과 Xcode가 사실상 필수입니다. 여기서 자기 갈래를 정하고 나면 뒤 섹션에서 읽을 것과 건너뛸 것이 갈립니다.
세 갈래가 실제로 요구하는 것
| 필요한 것 | 서버 | 안드로이드 | KMP(iOS 포함) |
|---|---|---|---|
| JDK | 필수 21/25 | 필수 17+ | 필수 17+ |
| IDE | IntelliJ IDEA | Android Studio | 둘 중 하나 + KMP 플러그인 |
| Android SDK | — | 필수 | 필수 |
| Xcode | — | — | iOS 타깃이면 필수(맥 전용) |
| 에뮬레이터/기기 | — | AVD 또는 실기기 | AVD + iOS 시뮬레이터 |
| 운영체제 제약 | 아무거나 | 아무거나 | iOS 빌드는 macOS 만 |
| 디스크 | 2~4 GB | 10~20 GB | 30~60 GB |
| 첫 빌드 시간 | 1~3분 | 5~15분 | 10~30분 |
2026년 8월 기준 버전 지도
| 항목 | 지금 고를 값 |
|---|---|
| Kotlin | 2.2.x — K2 컴파일러가 기본. 새 프로젝트는 무조건 2.x |
| JDK | 21 LTS(호환 최우선) 또는 25 LTS(새 서버). 안드로이드는 17 권장 |
| Gradle | 프로젝트가 들고 오는 wrapper 버전을 씁니다 — 직접 깔 필요 없음 |
| IntelliJ IDEA | 최신 안정판. 코틀린 플러그인 내장 |
| Android Studio | 최신 안정 채널. AGP 는 스튜디오가 권하는 버전 |
| compileSdk | 최신 정식 API 레벨. minSdk 는 보통 24~26 |
| coroutines | kotlinx-coroutines-core 1.10.x 계열 |
| serialization | kotlinx-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
한 단계 더 — 팀에 들어갔을 때의 순서 🟡 중급
기존 프로젝트를 받았을 때
# 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
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 부터 |
JDK 설치 — Temurin 21 / 25 LTS 와 JAVA_HOME
코틀린 코드가 도는 자리는 JVM 입니다. 그래서 코틀린을 시작하는 첫 단계는 JDK 설치입니다. 여기서 JAVA_HOME 과 PATH 두 개만 제대로 잡아 두면 뒤에 나올 Gradle · IDE 문제의 절반이 사라집니다. 반대로 이 둘이 어긋나면 “Gradle 이 자꾸 옛날 자바로 빌드한다” 같은 이해 못 할 증상이 계속 따라다닙니다.
...\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 → 완료 → 열려 있던 터미널을 전부 닫습니다 ★
명령 한 줄로 깔기 (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
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
...\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/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
}
jvmToolchain(21)(st05)이 프로젝트별 JDK 를
빌드 파일 안에서 정해 주고, 없으면 자동으로 받아 오기 때문입니다.
손으로 전환하는 일은 터미널에서 java -jar 로 직접 돌릴 때 정도입니다.
./gradlew 를 직접 칠 때는 시스템 JDK 가 쓰이므로,
Studio 에서는 되는데 터미널에서만 실패하는 현상이 생깁니다 —
이때는 File → Settings → Build Tools → Gradle → Gradle JDK 에서
Studio 가 쓰는 JDK 를 확인해 시스템 JAVA_HOME 과 맞추면 됩니다(st11).
java -version · javac -version · echo $env:JAVA_HOME · where.exe java 네 줄.
JAVA_HOME 은 언제나 bin 의 부모 폴더입니다.코틀린 컴파일러 · kotlinc — 없어도 되지만 알면 좋은 것
실무 프로젝트에서 kotlinc 를 직접 치는 일은 거의 없습니다 — Gradle 과 IDE 가 대신 부릅니다.
그런데도 이 섹션이 있는 이유는 두 가지입니다. 첫째, “코틀린이 어떻게 실행되는가”를 한 번 눈으로 보면
뒤에 나오는 Gradle 설정이 전부 이해되기 때문이고, 둘째 REPL 과 .kts 스크립트는
프로젝트를 만들지 않고 문법을 실험할 때 정말 편하기 때문입니다.
설치 — 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
# 안녕
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 이 통째로 들어 있음)
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 를 한 번 눌러 볼 때. 프로젝트를 안 만들어도 됩니다 |
| Notebook | Kotlin Kernel for Jupyter — 데이터 실험 |
| 테스트 픽스처 | 테스트 데이터 생성 스크립트 |
@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 스크래치 파일을 만들면 저장 없이 실행 결과가 옆에 뜹니다
kotlinc 는 선택 사항입니다.
다만 kotlinc a.kt -include-runtime -d a.jar → java -jar a.jar 한 줄이
코틀린 실행의 전부라는 것을 알아 두면 Gradle 설정이 “그걸 자동화한 것”으로 보입니다.
실험은 REPL 이나 IntelliJ 스크래치 파일이 가장 빠릅니다.IntelliJ IDEA — 코틀린을 만든 회사의 IDE
코틀린은 젯브레인스가 자기들 IDE 를 더 잘 만들려고 시작한 언어입니다.
그래서 IntelliJ 의 코틀린 지원은 다른 에디터와 비교 대상이 아닙니다 —
자바 코드를 붙여 넣으면 코틀린으로 바꿔 주고, 코루틴 중단 지점에 아이콘이 뜨고,
build.gradle.kts 안에서 의존성 최신 버전을 알려 줍니다.
Community(무료)판으로도 코틀린 개발에는 전혀 부족하지 않습니다.
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 형태)
첫 프로젝트 만들기 — 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.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) |
한 단계 더 — 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 을 엄격히 해석
}
}
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
./gradlew --stop → ③ Invalidate Caches and Restart.
①에서 90% 가 해결됩니다.
-Xmx 를 4096MB 이상으로 올리세요
(RAM 16GB 이상이면 6144MB). Toolbox 를 쓰면 톱니바퀴 → Settings 에서도 바꿀 수 있습니다.
안드로이드 프로젝트라면 여기에 Gradle 데몬 메모리도 따로 올려야 합니다(st10).
Gradle Kotlin DSL — build.gradle.kts 완전 기초
코틀린 개발에서 가장 많이 헤매는 파일이 이것입니다. 문법은 코틀린인데 하는 일은 빌드 설정이라
“이게 왜 되는지” 감이 안 잡히기 때문입니다.
여기서는 ./gradlew build 를 치면 실제로 무슨 일이 일어나는지부터 보고,
settings.gradle.kts · build.gradle.kts · libs.versions.toml
세 파일이 각각 무엇을 맡는지 나눠 설명합니다.
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 만 쓰겠다
}
}
./gradlew build 한 번이면
21 을 자동으로 내려받아 같은 결과를 냅니다.
“나는 되는데 너는 안 되네”가 사라지는 지점입니다.
자주 쓰는 Gradle 명령
| 명령 | 하는 일 |
|---|---|
./gradlew build | 컴파일 + 테스트 + jar |
./gradlew run | application 플러그인이 만든 실행 |
./gradlew test | 테스트만 |
./gradlew clean build | build/ 를 지우고 처음부터 |
./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 을 올립니다
- 안드로이드 프로젝트는 새로 만들면 기본으로 이 구조입니다
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(홈 폴더)에 둡니다.
jvmToolchain(21) 한 줄로 못 박고,
빌드는 항상 래퍼(./gradlew)로 돌립니다.
설명 없는 에러가 나면 ./gradlew --stop 이 첫 처방입니다.Android Studio — SDK · AVD · 실기기까지
안드로이드 환경이 무거운 이유는 깔아야 할 것이 IDE 하나가 아니기 때문입니다. Studio(IDE) 아래에 Android SDK 가 있고, 그 안에 플랫폼 · 빌드 툴 · 플랫폼 툴 · 에뮬레이터 이미지가 각각 따로 관리됩니다. 이 구조를 한 번 보고 나면 “SDK location not found” 같은 에러가 더 이상 무섭지 않습니다.
설치 — 첫 실행 마법사까지
① 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분 걸립니다
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 와 세트
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.apk | APK 설치 |
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 또는 제조사 드라이버가 필요합니다.
adb devices 가 모든 기기 문제의 출발점이라는 것입니다.Kotlin Multiplatform 준비 — Xcode · kdoctor
KMP 는 코드를 공유하되 UI 는 각자(또는 Compose Multiplatform 으로 UI 까지 공유)를 노리는 방식입니다. 환경 세팅의 핵심은 딱 하나 — iOS 타깃을 빌드하려면 macOS 와 Xcode 가 반드시 필요합니다. 이 사실을 먼저 받아들이면 나머지는 어렵지 않습니다. 윈도우·리눅스에서도 공용 · 안드로이드 · 데스크톱 · 웹 타깃은 전부 개발할 수 있습니다.
프로젝트 만들기 — 두 가지 방법
방법 ① 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 과 같습니다
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 # 무엇을 어디서 찾았는지 경로까지 보여 줍니다
윈도우 · 리눅스에서 할 수 있는 것과 없는 것 🔴 고급
윈도우에서 안 되는 것
- 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 iosSimulatorArm64Test | iOS 테스트 (맥 전용) |
./gradlew packageDistributionForCurrentOS | 데스크톱 설치 파일(.msi/.dmg) |
~/.konan 에 받기 때문입니다.
중간에 끊기면 그 폴더를 지우고 다시 받는 편이 빠릅니다.
Spring Boot (Kotlin) 시작 준비 — all-open · no-arg
start.spring.io 에서 Kotlin 을 고르면 build.gradle.kts 에
kotlin("plugin.spring") 과 kotlin("plugin.jpa") 가 자동으로 들어갑니다.
이게 무엇인지 모른 채 지우면 런타임에 이해 못 할 에러가 납니다.
두 플러그인이 왜 필요한지는 “코틀린 클래스는 기본이 final” 이라는 한 문장에서 출발합니다.
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
생성된 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)
}
build.gradle.kts 에 io.ktor.plugin 하나만 넣으면 끝이고,
start.ktor.io 에 같은 방식의 생성기가 있습니다.
기존 스프링 생태계(보안·배치·데이터)를 쓸 것인가가 선택 기준입니다.
build.gradle.kts 의 plugin.spring(final 문제)과
plugin.jpa(기본 생성자 문제)는 지우면 안 되는 필수품입니다.
여기에 jackson-module-kotlin · kotlin-reflect 를 더하면 준비 완료,
실행은 ./gradlew bootRun 입니다.코드 품질 · kotlinx 라이브러리 붙이기
환경 세팅의 마지막 한 겹입니다. ktlint(포맷)와 detekt(정적 분석)를 붙여 “스타일 때문에 코드 리뷰가 길어지는 일”을 없애고, 코틀린 개발에 사실상 표준인 kotlinx 세 형제(coroutines · serialization · datetime)를 프로젝트에 넣습니다. 혼자 하는 프로젝트라도 처음에 넣어 두면 나중에 넣는 것보다 열 배 쉽습니다.
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
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()
그 밖에 처음부터 넣어 두면 좋은 것
| 도구 | 왜 |
|---|---|
| kotest 또는 JUnit5 | 테스트. kotest 는 shouldBe 같은 코틀린다운 단언 |
| MockK | 코틀린 전용 목 라이브러리. final 클래스도 목 처리 |
| Kotlin Power-Assert | 단언 실패 시 식의 중간값까지 출력 (2.x 정식 기능) |
| Dokka | KDoc → 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
-Dkotlinx.coroutines.debug 와 IDE 의 Coroutines 탭이 답입니다.인코딩 · 빌드 속도 — gradle.properties 완전판
한글이 ??? 로 나오는 문제와 빌드가 느린 문제는 성격이 달라 보이지만,
둘 다 “Gradle 이 JVM 을 어떤 옵션으로 띄우는가” 한 파일에서 해결됩니다.
이 섹션은 gradle.properties 한 장을 통째로 읽고 각 줄이 무엇을 바꾸는지 설명합니다.
안드로이드 빌드가 유난히 느릴 때의 체크리스트도 함께 정리합니다.
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" 체크
무엇이 느린지 측정하기 🟡 중급
빌드 스캔 — 한 줄이면 리포트가 나옵니다
# 한 번만 써 보기 (약관 동의 프롬프트가 뜹니다)
./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 리포트
캐시가 실제로 먹는지 확인
# 같은 빌드를 두 번 돌려 보기
./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=true | R 클래스 재생성 범위 축소 |
디버그 전용 debugImplementation | 릴리스 빌드에서 불필요한 의존성 제외 |
| 바이러스 백신 예외 등록 | 윈도우에서 체감이 가장 큽니다. 프로젝트·.gradle·SDK 폴더 ★ |
| SSD · RAM 16GB 이상 | 하드웨어가 답일 때도 있습니다 |
| Gradle · AGP 최신화 | 릴리스마다 빌드 속도 개선이 들어옵니다 |
~/.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 로 남겨 둡니다
2.2.0-2.0.2 처럼 앞자리가 맞아야 합니다(st11).
gradle.properties 에 데몬 · 병렬 · 빌드 캐시 · 설정 캐시 네 줄을 켜고
-Dfile.encoding=UTF-8 을 함께 넣으세요.
느리다고 느끼면 추측하지 말고 --scan 을 돌립니다.
안드로이드라면 디버그 minify 끄기 · kapt→KSP · 백신 예외 세 가지가 효과가 가장 큽니다.
그리고 clean 을 습관적으로 붙이지 마세요.설치 문제 사전 — 증상 · 원인 · 해결
여기까지 왔는데 안 되는 경우를 위한 섹션입니다. 코틀린 환경 문제는 종류가 많아 보이지만 뿌리는 다섯 가지뿐입니다 — JDK 버전 불일치 · 도구 버전 궁합 · 경로 문제 · 네트워크 · 캐시. 아래 진단 흐름을 위에서부터 따라가면 대부분 5분 안에 원인이 나옵니다.
증상 → 원인 → 해결 (자주 나오는 순서대로)
| 증상 (에러 메시지) | 원인 | 해결 |
|---|---|---|
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 compatibilityJava 17 vs Kotlin 21 |
자바 컴파일 대상과 코틀린 컴파일 대상이 다름 | kotlin { jvmToolchain(21) } 한 줄로 통일. 개별 jvmTarget 설정은 지웁니다 |
SDK location not foundDefine 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 version | JDK |
|---|---|
| 52 | Java 8 |
| 55 | Java 11 |
| 61 | Java 17 |
| 65 | Java 21 |
| 67 | Java 23 |
| 69 | Java 25 |
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
에러를 제대로 읽는 법
# 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 가
훨씬 좋은 결과를 줍니다. 버전 숫자는 내 상황에만 있는 값입니다.
java -version · javac -version 이 같은 버전인가 ·
② echo $env:JAVA_HOME 이 bin 의 부모를 가리키는가 ·
③ where.exe java 첫 줄이 그 JDK 인가 ·
④ 프로젝트 경로에 한글·공백이 없는가 ·
⑤ ./gradlew --version 이 정상 출력되는가 ·
⑥ (안드로이드) adb devices 가 도는가 ·
⑦ ./gradlew build 가 통과하는가.
이 일곱 줄이 전부 통과하면 환경 문제는 없습니다.
jvmToolchain 으로 통일,
다운로드가 멈추면 프록시·인증서, 이상한 실패는 경로에 한글·공백,
나머지는 ./gradlew --stop → clean → Invalidate Caches 순서입니다.
그리고 언제나 마지막 Caused by 를 읽으세요.젯브레인스가
코틀린만 보고
다시 만든 서버
Ktor 는 자바를 거치지 않고 코틀린만 보고 설계된 서버 프레임워크입니다.
모든 핸들러가 suspend 이고, 프레임워크가 기본으로 켜 두는 것이 하나도 없습니다.
그래서 기동이 0.3초, 힙이 50MB 대에서 시작하고, 켠 기능만 정확히 돕니다.
이 탭은 스프링 부트를 아는 독자를 가정하고 끝까지 스프링과 대조하면서 갑니다 —
라우팅 · 플러그인 파이프라인 · 직렬화 · 코루틴 동시성 · Exposed · 인증 · WebSocket ·
Koin · 테스트 · 관측 · 배포, 그리고 마지막에 짧은 URL 서비스 하나를 처음부터 끝까지 만듭니다.
이 탭을 마치면 Ktor 서비스를 혼자 설계해서 운영에 올릴 수 있게 됩니다.
Ktor 는 무엇이고 왜 쓰나 — 스프링과 나란히
Ktor 를 "가벼운 스프링"으로 이해하면 계속 헤맵니다. 두 프레임워크는 철학이 반대입니다 — 스프링은 다 켜 두고 끄는 쪽, 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 4 | Ktor 3 | |
|---|---|---|
| 기동 시간(JVM) | 1.8~3.0초 | 0.25~0.5초 |
| 기동 직후 힙 | 180~260MB | 45~90MB |
| fat jar 크기 | 28~55MB | 12~22MB |
| 의존성 주입 | 내장 컨테이너 | 없음 → 생성자 · Koin |
| 러닝 커브 | 넓고 완만 | 좁고 가파름 |
| 생태계 | 압도적 | 좁다(배치 · 시큐리티 얇음) |
| 멀티플랫폼 | JVM 전용 | JVM · Native · JS |
| 동시성 모델 | 스레드(+ 코루틴 어댑터) | 코루틴이 유일 |
고르는 기준 — 질문 네 개면 끝납니다 🟢 기초
Ktor 가 실제로 잘 하는 자리
- 게이트웨이 · BFF — 여러 내부 API 를 코루틴으로 동시에 호출해 합칩니다. Ktor 의 최적 서식지입니다.
- KMP 백엔드 —
commonMain의@SerializableDTO 를 서버와 iOS·안드로이드가 그대로 공유합니다. - 서버리스 · 짧은 수명 컨테이너 — 콜드 스타트가 과금이 되는 자리.
- 사이드카 · 내부 툴 — 메트릭 수집기, 웹훅 수신기처럼 기능은 적고 개수는 많은 서비스.
- 스트리밍 · 실시간 — WebSocket · SSE 가 코루틴 위에서 자연스럽습니다.
Ktor 를 고르면 감수하는 것
| 스프링의 그것 | Ktor 에서는 |
|---|---|
| Spring Data JPA | Exposed · jOOQ 를 직접 조립 |
| Spring Security | Authentication 플러그인 + 직접 구현 |
| Spring Batch | 대응물 없음 — 별도 잡 러너 |
| Actuator | Micrometer 를 손으로 배선 |
| @Transactional | newSuspendedTransaction { } 로 명시 |
| @Scheduled | 코루틴 launch + delay 또는 Quartz |
| 스택오버플로 답변 수 | 대략 1/40 |
엔진 고르기 — 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 | 서블릿 기반 | 사내 표준이 톰캣일 때 |
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)
첫 서버 — 프로젝트 생성부터 핫 리로드까지
스프링 이니셜라이저에 해당하는 것이 start.ktor.io 입니다. 다만 Ktor 는 만들어진 뒤 파일 구조를 내가 정한다는 점이 다릅니다 — 정해진 규약이 없으니, 처음에 잡는 뼈대가 오래 갑니다.
① start.ktor.io
브라우저에서 플러그인을 체크박스로 고르면 그것만 들어간 프로젝트가 나옵니다. 스프링 이니셜라이저와 같은 자리입니다.
② IntelliJ 플러그인
IntelliJ IDEA 의 New Project → Ktor 가 같은 생성기를 IDE 안에 넣은 것입니다. 젯브레인스가 양쪽을 다 만들어서 버전이 항상 맞습니다.
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 — 코드가 주인
// 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
| embeddedServer | EngineMain | |
|---|---|---|
| 포트 · 엔진 | 코드에 | 설정 파일에 |
| 환경별 분리 | 직접 분기 | 환경 변수로 |
| 모듈 지정 | 람다 · 함수 참조 | 문자열(리플렉션) |
| 핫 리로드 | 제한적 | watch 지원 |
| 기동 실패 시점 | 컴파일 · 실행 | 실행(오타는 런타임) |
| 어울리는 곳 | 테스트 · 학습 · 도구 | 운영 서비스 |
EngineMain 을 쓰되
modules 에 모듈 함수 하나만 적고, 그 안에서 나머지를 코드로 부르세요.
오타 위험이 한 줄로 줄어듭니다.
의존성 이름 규칙 — 외우면 검색이 필요 없습니다
| 아티팩트 | 주는 것 |
|---|---|
ktor-server-core | Application · routing · call |
ktor-server-netty | 엔진(cio · jetty · tomcat) |
ktor-server-{플러그인} | 서버 플러그인 하나 |
ktor-serialization-{형식} | 직렬화 구현체 |
ktor-client-{엔진} | HTTP 클라이언트 |
ktor-server-test-host | testApplication |
install(CORS) 가 Unresolved reference 라면
거의 항상 ktor-server-cors 를 안 넣은 것입니다.
스프링 스타터 하나가 서른 개를 끌어오는 것과 정반대의 설계입니다.
라우팅 — URL 을 함수에 잇는 트리
스프링의 @GetMapping 은 애너테이션을 스캔해 표를 만드는 방식입니다.
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() }
}
파일을 나누는 법 — 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)
}
}
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 의 심장
Ktor 를 진짜로 이해했는지는 파이프라인을 설명할 수 있는가로 갈립니다.
스프링의 필터 · 인터셉터 · AOP · @ControllerAdvice 가 하던 일을
Ktor 는 페이즈 다섯 개짜리 파이프라인 하나로 전부 처리합니다.
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 이 만든 예외까지 잡습니다
}
| 플러그인 | 페이즈 | 역할 |
|---|---|---|
CallLogging | Monitoring | 요청 한 줄 로그 |
CallId | Monitoring | 요청 ID 생성 · 전파 |
DefaultHeaders | Plugins | Server · Date 헤더 |
ContentNegotiation | Plugins | 객체 ↔ JSON |
CORS | Plugins | 교차 출처 허용 |
Compression | Plugins | gzip · br |
StatusPages | Plugins | 예외 → 응답 |
Routing | Call | 핸들러 실행 |
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 핸들러는 여러 스레드에서 동시에 돕니다.
직렬화 · 검증 — JSON 을 도메인 객체로
스프링은 잭슨이 리플렉션으로 필드를 뒤집니다. Ktor 는 kotlinx.serialization 이 컴파일 타임에 직렬화 코드를 생성합니다 — 리플렉션이 없어서 빠르고, 널 가능성이 타입 그대로 지켜집니다.
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 자리에 null | BadRequestException |
| 본문을 두 번 읽음 | 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))
}
}
call.receive 는 한 번만 됩니다.
본문은 스트림이라 한 번 읽으면 사라집니다. 서명 검증처럼
원본과 파싱본을 둘 다 써야 하면 install(DoubleReceive) 를 켜세요 —
본문을 메모리에 캐시하므로 큰 업로드가 오는 라우트에는 켜지 마세요.
코루틴이 그대로 서버가 됩니다
Ktor 의 모든 핸들러 람다는 suspend 입니다. 어댑터도, Mono 도,
CompletableFuture 도 없습니다. 코틀린에서 배운 코루틴 지식이 100% 그대로 서버에서 통합니다 —
이 탭에서 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 의 스레드 지도
| 이름 | 개수 | 여기서 하는 일 |
|---|---|---|
| connectionGroup | CPU ÷ 2 + 1 | 연결 수락(accept) |
| workerGroup | CPU ÷ 2 + 1 | 소켓 읽기 · 쓰기 |
| callGroup | CPU 개수 | 내 핸들러가 도는 곳 |
| Dispatchers.IO | 기본 64 | 블로킹 호출 격리 |
| Dispatchers.Default | CPU 개수 | 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 // ★ 반드시 다시 던집니다
}
}
}
while (true) { 계산 } 처럼 suspend 호출이 없는 루프는
클라이언트가 끊어도 끝까지 CPU 를 태웁니다.
ensureActive() 또는 yield() 를 넣어야 합니다.
세 프레임워크 3자 비교 ★
| Spring MVC | WebFlux | Ktor | |
|---|---|---|---|
| 단위 | 스레드 | Reactor 시그널 | 코루틴 |
| 대기 비용 | 스레드 1개 | 거의 0 | 거의 0 |
| 코드 모양 | 평범한 함수 | 연산자 체인 | 평범한 함수 |
| 디버깅 | 쉬움 | 어려움 | 보통 |
| 스택 트레이스 | 완전 | 잘림 | 보통(디버그 모드로 개선) |
| 블로킹 호출 | 괜찮음 | 전체 마비 | IO 로 옮기면 OK |
| 취소 | 사실상 없음 | 있음 | 구조적 · 자동 |
| 컨텍스트 전파 | ThreadLocal | Context | CoroutineContext |
flatMap · zip · switchIfEmpty 체인 대신
if 와 for 를 그대로 쓴다는 것.
코틀린 팀이 WebFlux 를 쓰더라도 결국 kotlinx-coroutines-reactor 로 코루틴에 붙이는데,
Ktor 는 그 변환 계층이 아예 없습니다.
-Dkotlinx.coroutines.debug 를 켜면 코루틴 이름이 스레드 이름에 붙고,
kotlinx-coroutines-debug 아티팩트를 넣으면 DebugProbes.dumpCoroutines() 로
현재 살아 있는 코루틴 전부를 덤프할 수 있습니다.
"요청이 어디서 멈춰 있는지"를 스레드 덤프 대신 이걸로 봅니다.
DB 연동 — Exposed 와 코루틴 트랜잭션
Ktor 에는 Spring Data 가 없습니다. 대신 젯브레인스의 Exposed 를 씁니다 — 테이블을 코틀린 객체로 선언하고 쿼리를 타입 안전한 DSL 로 씁니다. 가장 중요한 주제는 블로킹 JDBC 를 코루틴과 어떻게 섞느냐입니다.
테이블 선언과 커넥션 풀
// 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
}
(코어 수 × 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 | 가벼운 코틀린 ORM | Exposed 가 무겁게 느껴질 때 |
| 순수 JDBC | 직접 | 쿼리 몇 개짜리 서비스 |
org.jetbrains.exposed.sql.* 이 org.jetbrains.exposed.v1.* 로 갈라졌습니다
(JDBC 는 v1.jdbc, R2DBC 는 v1.r2dbc).
옛 예제 코드를 가져올 때 import 만 고치면 대부분 그대로 돕니다.
인증 · 인가 — JWT · 세션 · OAuth
Spring Security 는 필터 체인 서른 개짜리 거대한 프레임워크입니다.
Ktor 의 Authentication 은 "이 요청에서 사용자를 뽑아내는 함수" 하나가 전부입니다.
배울 게 적은 대신 인가 정책은 내가 설계해야 합니다.
// 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())
}
}
}
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")
Dispatchers.Default 로 안 옮기면
로그인 요청 몇 개가 이벤트 루프를 마비시킵니다.
application.conf 에도 두지 않습니다.
HOCON 의 ${?JWT_SECRET} 로 환경 변수에서만 읽고,
로컬 개발은 .env 또는 application-local.conf(gitignore)로 분리하세요.
HMAC 비밀은 최소 32바이트 랜덤이어야 합니다 —
짧은 문자열을 쓰면 서명 위조가 현실적인 공격이 됩니다.
무엇을 고르나 — 인증 방식 한 장 🟢 기초
네 가지 방식 비교
| 방식 | 상태 | 즉시 취소 | 알맞은 곳 |
|---|---|---|---|
| JWT | 없음 | 불가 | 모바일 · SPA · 서버 간 |
| 세션 쿠키 | 서버에 | 가능 | 같은 도메인 웹앱 |
| OAuth | 공급자 | 공급자 | 소셜 로그인 · 위임 |
| API 키 | DB | 가능 | 서버 간 · 웹훅 |
배포 전에 반드시 확인할 것
| 항목 | 확인 |
|---|---|
| HMAC 비밀 길이 | 32바이트 이상 · 환경 변수 |
| access 만료 | 15분 안팎 |
| refresh 저장 위치 | HttpOnly · Secure 쿠키 |
| refresh 회전 | 1회용 · 재사용 감지 |
| 비밀번호 해시 | BCrypt cost 12 이상 |
| 로그인 실패 제한 | IP · 계정별 레이트 리밋 |
| 토큰 검증 항목 | 서명 · 만료 · iss · aud |
| 인증 라우트 위치 | authenticate { } 안인지 |
authenticate { } 블록 밖으로 한 줄이 빠져나가면 그 API 가 조용히 공개됩니다.
"토큰 없이 호출하면 401 이 나온다"는 테스트를 라우트마다 한 줄씩 두세요(kt10).
WebSocket · SSE — 실시간
스프링에서 WebSocket 을 쓰려면 WebSocketHandler · STOMP · 세션 레지스트리를 배워야 합니다.
Ktor 에서는 핸들러 안이 그냥 코루틴이라 for (frame in incoming) 한 줄이면 됩니다 —
대신 세션 집합의 동시성은 내가 책임집니다.
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;
});
| WebSocket | SSE | |
|---|---|---|
| 방향 | 양방향 | 서버 → 클라이언트 |
| 프로토콜 | 업그레이드 | 그냥 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))
}
}
upgrade 헤더 허용과 충분한 proxy_read_timeout(기본 60초는 짧습니다)을 함께 확인하세요.
DI · 테스트 — Koin 과 testApplication
Ktor 에는 DI 컨테이너가 없습니다. 놀랍게도 그게 문제가 안 되는 경우가 많습니다 —
main() 에서 객체를 순서대로 만들어 넘기면 끝이기 때문입니다.
그리고 컨테이너가 없어서 테스트가 압도적으로 빠릅니다.
먼저 — 수동 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)
}
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)
}
}
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) 과는 같지 않다
}
}
ON CONFLICT ·
부분 인덱스 · jsonb · 격리 수준 동작이 다릅니다.
"테스트는 통과하는데 운영에서 터지는" 전형적 원인입니다.
testcontainers 로 운영과 같은 이미지 태그를 쓰세요.
runTest { } 는 가상 시계를 씁니다.
delay(10.minutes) 가 실제로는 즉시 끝나므로,
토큰 만료 · 재시도 백오프 · 캐시 TTL 같은 것을 기다리지 않고 검증할 수 있습니다.
다만 Thread.sleep 이나 System.currentTimeMillis() 는 가상 시계를 모릅니다 —
그래서 Clock 을 주입받는 설계가 여기서 값을 합니다.
테스트 도구 한 장 정리 🟢 기초
무엇을 언제 쓰나
| 도구 | 쓰는 곳 |
|---|---|
kotlin-test · JUnit 5 | 기본 러너 · 단언 |
testApplication | 라우트 · 플러그인 통합 |
runTest | suspend 함수 · 가상 시계 |
| MockK | 협력 객체 스텁 · 검증 |
| Testcontainers | 진짜 DB · Redis · Kafka |
| Kotest / assertk | 읽기 좋은 단언 |
| Turbine | Flow 배출 검증 |
| MockWebServer | 외부 HTTP 흉내 |
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 을 생성자 인자로 두면
"슬러그 충돌 시 재시도" 같은 확률적 경로도 결정적으로 테스트됩니다.
관측 · 운영 — Actuator 가 없는 세상
스프링 부트는 actuator 의존성 한 줄이면 헬스체크 · 메트릭 · 환경 정보가 다 생깁니다.
Ktor 에는 그런 것이 없습니다. 대신 무엇이 왜 필요한지 알고 직접 답니다 —
결과적으로 내 서비스에 필요한 것만 남습니다.
// 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",
)
}
}
/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 | 스레드 폭증 |
| 커스텀 비즈니스 카운터 | 주문 · 결제 · 가입 수 |
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 를 태그로 넣으면
시계열이 수백만 개가 되어 프로메테우스가 먼저 죽습니다.
태그는 값의 종류가 수십 개 이하인 것만(상태 코드 · 메서드 · 결과 구분).
배포 — fat jar · Docker · GraalVM
Ktor 를 고른 이유가 가볍고 빠른 기동이라면, 그 이점은 패키징에서 완성되거나 사라집니다. 같은 코드가 어떻게 포장되느냐에 따라 기동 시간이 2.4초에서 0.04초까지 벌어집니다.
① 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 가 됩니다
③ 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
-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 만에 뜹니다
쿠버네티스 프로브
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
startupProbe.failureThreshold 를 줄일 수 있고,
롤링 배포가 파드당 30초에서 5초로 줄어듭니다.
파드가 50개면 배포 시간이 25분에서 4분이 됩니다.
메모리도 파드당 200MB 를 아끼니 같은 노드에 두 배를 태울 수 있습니다.
./gradlew buildImage 는 Dockerfile 없이
JIB 방식으로 레이어를 나눈 이미지를 만들고, publishImage 는 레지스트리에 바로 올립니다.
도커 데몬 없이도 됩니다. 다만 베이스 이미지 · 사용자 · 헬스체크를 세밀하게 잡아야 하는
운영 환경이라면 Dockerfile 을 직접 쓰는 편이 통제가 낫습니다.
배포 형태 한 장 정리 🟡 중급
네 가지 중 무엇을 고르나
| 형태 | 기동 | 빌드 | 고를 때 |
|---|---|---|---|
| fat jar | 0.42초 | 수십 초 | 기본값 |
| installDist | 0.42초 | 수십 초 | 도커 레이어 캐시 |
| + AppCDS | 0.30초 | + 10초 | 공짜 이득 · 권장 |
| native-image | 0.04초 | 3~8분 | 서버리스 · CLI |
installDist 로 레이어를 나눠 도커 빌드를 빠르게 하고,
AppCDS 를 얹어 기동을 30% 더 줄이는 것.
코드 변경이 필요 없고 되돌리기도 쉽습니다.
배포 전 최종 점검
| 항목 | 확인 |
|---|---|
| 컨테이너 사용자 | root 가 아닌지 |
| 힙 상한 | MaxRAMPercentage 설정 |
| 비밀 | 이미지에 안 구워졌는지 |
| 로그 형식 | JSON · stdout |
| 프로브 세 종 | liveness · readiness · startup |
| preStop | LB 가 빠질 시간 |
| gracePeriod | 가장 긴 요청보다 김 |
| 이미지 태그 | latest 가 아닌지 |
-XX:MaxRAMPercentage=75.0 한 줄이면 끝나고,
Ktor 처럼 힙이 작은 앱일수록 이 실수가 늦게 발견됩니다.
실전 — 짧은 URL + 통계 서비스 끝까지
지금까지의 열두 조각을 하나로 붙입니다. URL 을 줄여 주고, 리다이렉트하고, 클릭을 세고, 관리자 대시보드에 실시간으로 보여 주는 서비스를 처음부터 끝까지 만듭니다. 마지막에 같은 것을 스프링 부트로 만들었을 때와 숫자를 비교합니다.
요구사항과 폴더
| 기능 | 구현 |
|---|---|
| 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 4 | Ktor 3 | |
|---|---|---|
| 기동 시간 | 2.4초 | 0.42초 |
| 기동 직후 RSS | 310MB | 135MB |
| 부하 중 RSS | 420MB | 240MB |
| 이미지 크기 | 125MB | 95MB |
| 리다이렉트 p50 | 1.9ms | 1.6ms |
| 리다이렉트 p99 | 34ms | 11ms |
| 처리량(4vCPU) | 18k rps | 24k rps |
| 코드 줄 수 | 약 1,900 | 약 1,700 |
| 테스트 전체 시간 | 96초 | 11초 |
그래서 무엇을 배웠나 — 체크리스트
- 도메인에
io.ktor.*를 들이지 않기 — 이 한 줄이 테스트 속도와 이식성을 만듭니다. - 트랜잭션은 짧게, 외부 호출은 밖으로 — 커넥션 점유가 처리량의 상한입니다.
- 뜨거운 경로는 캐시 우선 · 쓰기는 비동기 — 리다이렉트에 DB 쓰기를 넣지 않습니다.
- 에러 모양을 처음에 한 번 정하기 —
StatusPages+ sealed 예외. - WebSocket 은
finally부터 — 정리 누락이 유일하고도 확실한 사고 원인입니다. - liveness 와 readiness 를 구분 — 연쇄 장애를 막는 가장 싼 방법.
- CallId 를 첫날에 — 나중에 넣으면 이미 늦습니다.
ktor-server-openapi 로 문서를 붙이거나, 스키마를 먼저 쓰고 코드를 생성하는 방식.
② Ktor Client: 이 탭에서 배운 플러그인 개념이 클라이언트에도 그대로 있습니다(재시도 · 타임아웃 · 인증 갱신).
③ KMP: @Serializable DTO 와 @Resource 경로를 commonMain 으로 올리면
iOS · 안드로이드 · 웹이 서버와 같은 타입을 씁니다 — Ktor 를 고른 값을 가장 크게 회수하는 지점입니다.
돌아가는 코드와
운영할 수 있는 코드는
다릅니다
코틀린 코드는 컴파일만 되면 꽤 안전해 보입니다. 그런데 실제 장애는 컴파일러가 못 보는 곳에서 납니다 —
취소를 삼키는 runCatching, 다른 스레드로 넘어가며 사라지는 traceId,
자바가 넘겨준 null, 필드 하나 추가했더니 깨지는 옛 클라이언트.
이 탭은 테스트 → 로깅 → 장애 패턴 → 호환성 → 빌드 자동화 → 리뷰 순서로
코틀린 서비스(Spring Boot · Ktor · Android)를 실무에서 바로 굴리는 데 필요한 것을 한곳에 모읍니다.
이 탭을 마치면 "테스트가 있고, 로그로 추적되고, 배포 전에 걸러지는" 코틀린 서비스를 만들 수 있습니다.
테스트 피라미드와 도구 선택 — 무엇을 어느 층에서 확인하나
테스트를 "많이" 쓰는 것보다 어느 층에 몇 개를 두느냐가 빌드 속도와 신뢰도를 정합니다. 코틀린에는 JUnit 5 와 Kotest 라는 두 갈래가 있고, 둘 다 같은 JUnit Platform 위에서 돌기 때문에 섞어 쓸 수도 있습니다. 먼저 층을 정하고, 그다음 도구를 고릅니다.
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))
}
}
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-"
SingleInstance) 가변 픽스처를 쓰려면 isolationMode 를 알아야 합니다.
무엇을 어느 층에서 — 대상별 배치표 🟢 기초
| 확인할 것 | 층 | 도구 | 이유 |
|---|---|---|---|
| 가격 계산 · 상태 전이 · 검증 규칙 | 단위 | JUnit5/Kotest 만 | 순수 함수 — 목도 필요 없습니다. 가장 많이 써야 할 곳 |
| 서비스가 저장소 · 외부 API 를 부르는 순서 | 단위 | MockK (02) | "결제 실패면 재고를 되돌리는가" 같은 분기 |
| suspend · Flow · 재시도 · 타임아웃 | 단위 | runTest · Turbine (03) | 가상 시간 — 10분 대기도 1ms |
| 컨트롤러 매핑 · 검증 · 에러 응답 모양 | 슬라이스 | @WebMvcTest · MockMvc DSL | 웹 계층만 띄움 — 2초 안팎 |
| JPA 쿼리 · 마이그레이션 · 인덱스 | 통합 | @DataJpaTest + Testcontainers | H2 는 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*' # 이름으로 골라 돌리기
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 로 전부 고정하면
동작은 그대로인데 구현만 바꿔도 테스트가 깨집니다. 바깥으로 나가는 부수 효과만 검증하고
나머지는 결과값으로 확인하세요.
MockK 실전 — 녹화하고, 돌려주고, 확인한다
코틀린 클래스는 기본이 final 이라 Mockito 로는 목을 만들기 번거롭습니다.
MockK 는 final · suspend · object · 확장 함수 · 생성자까지 목으로 바꿀 수 있는 코틀린 전용 도구입니다.
대신 힘이 센 만큼 잘못 쓰면 테스트가 구현에 묶여 리팩터링을 막습니다.
스텁 — 무엇을 돌려줄지 정하기
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 // 생성자에 목을 꽂아 줌
}
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 가 맞습니다. 단지 지금 구현이 그 순서라서 고정한 것이라면, 그 테스트는 다음 리팩터링 때 거짓 경보를 냅니다.
실무에선 이렇게 — 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 면 람다로
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() // ★ 전역 상태 — 안 풀면 다음 테스트로 샌다
@Execution(SAME_THREAD) 를 붙이거나,
더 근본적으로는 FeatureFlags 를 인터페이스로 주입받게 바꾸세요.
| 증상 | 원인 | 처방 |
|---|---|---|
no answer found for ... | 스텁 안 한 호출 | 스텁 추가 · 정말 상관없는 의존성이면 relaxUnitFun |
| suspend 함수 스텁이 안 먹음 | every 를 씀 | coEvery · coVerify · coJustRun |
| 같은 인자인데 매칭 안 됨 | data class 가 아닌 객체의 equals | match { it.id == 1L } 로 필드 비교 |
| 테스트 단독은 통과, 전체는 실패 | mockkObject/mockkStatic 누수 | @AfterEach unmockkAll() |
| value class 인자 매칭 실패 | 박싱 · 언박싱 차이 | MockK 최신으로 올리고 any() 대신 실제 값 사용 |
| Spring 빈을 목으로 바꾸고 싶다 | Mockito 용 @MockitoBean | springmockk 의 @MockkBean (04) |
co 를 붙입니다. 결과에 영향을 주는 의존성은 엄격하게, 로거 같은 건 relaxUnitFun 으로,
spyk 와 static 모킹이 필요해지면 설계를 먼저 의심하세요.코루틴 테스트 — 가상 시간을 손으로 돌리기
재시도 · 디바운스 · 타임아웃이 들어간 코드를 실제 시간으로 테스트하면 테스트 하나가 몇 초씩 걸리고, 가끔 실패합니다.
runTest 는 가짜 시계를 쓰기 때문에 delay(10.minutes) 도 즉시 지나갑니다.
기본 사용법은 🌀 코루틴 탭(CORO 18)에 있고, 여기서는 시계가 정확히 언제 움직이는지와 실무 함정을 봅니다.
경계를 정확히 — 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
}
}
Module with the Main dispatcher is missing 예외가 납니다.
JVM 단위 테스트에는 안드로이드 메인 루퍼가 없기 때문입니다. JUnit 4 라면 같은 일을 TestWatcher 규칙으로 합니다
(📱 Android 실전 탭).
실무에선 이렇게 — 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 | 끝나지 않는 자식 · 다른 스케줄러의 delay | backgroundScope · StandardTestDispatcher(testScheduler) |
| 값이 하나 덜 나옴 | advanceTimeBy 경계 | 뒤에 runCurrent() |
| SharedFlow 첫 값을 놓침 | 수집기가 붙기 전에 emit | Turbine test { } 안에서 emit · 또는 Unconfined 수집기 |
| 단독은 통과, 전체 실행 시 실패 | Dispatchers.setMain 을 되돌리지 않음 | 확장/규칙에서 resetMain() |
UncaughtExceptionsBeforeTest | 앞 테스트에서 샌 코루틴이 늦게 실패 | 그 앞 테스트의 GlobalScope · 미취소 스코프 정리 (08) |
advanceTimeBy 는 경계를 포함하지 않는다는 것, 테스트 디스패처에는 testScheduler 를 꼭 넘긴다는 것,
끝나지 않는 수집은 backgroundScope 에 둔다는 것 — 이 세 가지가 "가끔 실패하는 테스트"의 대부분을 없앱니다.Spring Boot 통합 테스트 — 슬라이스와 Testcontainers, 그리고 속도
단위 테스트는 "내 코드가 맞나"를, 통합 테스트는 "설정 · SQL · 직렬화 · 메시지가 실제로 맞물리나"를 봅니다. 문제는 속도입니다. 스프링 컨텍스트를 매번 새로 띄우면 테스트 100개가 10분이 됩니다. 필요한 층만 띄우는 슬라이스, 진짜 DB 를 띄우는 Testcontainers, 컨텍스트 캐시 세 가지를 같이 다룹니다.
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 와 메시지 · 동작이 다름
}
}
}
ON CONFLICT, jsonb, 부분 인덱스, 잠금(FOR UPDATE SKIP LOCKED)이
전부 다르게 동작합니다. "테스트는 통과했는데 운영에서 문법 오류"의 가장 흔한 원인입니다.
@DataJpaTest 는 기본으로 테스트마다 롤백하므로 데이터 정리는 신경 쓰지 않아도 됩니다.
웹 계층만 — @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초 기동 시간이 사라진다
@WebMvcTest, 쿼리는 @DataJpaTest + 진짜 PostgreSQL,
전체 흐름은 @SpringBootTest 몇 개로 나누고, 컨테이너는 @ServiceConnection 빈 하나로 연결합니다.
속도의 핵심은 컨텍스트를 몇 번 새로 만드느냐이므로 목 · 프로파일 · Import 조합을 통일하세요.
스프링 테스트 기초는 🍃 Spring Boot Web 탭(12 · 테스트)에 있습니다.로깅 — kotlin-logging · SLF4J · Logback 과 구조적 로그
장애가 나면 가장 먼저 여는 것이 로그입니다. 그런데 로그가 너무 많으면 비용이, 너무 적으면 맹목이, 문자열로만 남으면 검색 불가가 문제가 됩니다. 코틀린에서는 kotlin-logging 의 람다로 비용을 없애고, JSON 구조 로그로 검색을 살리고, 레벨 정책으로 양을 다룹니다.
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 식은 자리표시자로
"$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..."}
"주문 완료 id=1042" 는 정규식으로 잘라야 하지만,
orderId=1042 필드는 Loki · Kibana 에서 바로 조건 검색 · 집계가 됩니다.
"어제 결제 재시도가 3회 넘은 주문"을 한 줄 질의로 찾을 수 있게 됩니다.
레벨 정책 — 팀이 같은 기준으로 쓰기 🟡 중급
| 레벨 | 기준 | 예 | 운영에서 |
|---|---|---|---|
| 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}'
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}건" }
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)
}
}
CallLogging · CallId 플러그인으로 요청 한 줄 로그를 남기고(🚀 Ktor 서버 탭 · 관측),
안드로이드는 SLF4J 대신 Log · Timber 를 쓰고 릴리스 빌드에서 R8 로 DEBUG 호출을 지웁니다(📱 Android 실전 탭).
원리(지연 평가 · 레벨 정책 · 민감정보 차단)는 셋 다 같습니다.
KotlinLogging.logger {} 한 줄, 메시지는 항상 람다로 씁니다.
운영 로그는 JSON + 비동기, 레벨은 표의 기준을 팀 규칙으로 두고, 예외는 경계에서 한 번, 예외 객체째로 찍습니다.
이것만 지켜도 "로그가 너무 많은데 정작 필요한 건 없는" 상태를 벗어납니다.코루틴과 MDC — traceId 가 사라지는 자리
MDC(Mapped Diagnostic Context)는 로그 한 줄마다 traceId · userId 를 자동으로 붙여 주는 장치인데,
내부가 ThreadLocal 입니다. 코루틴은 멈췄다 다른 스레드에서 재개되므로
MDC 가 그 스레드에 없으면 로그에서 traceId 가 빈칸이 됩니다. 요청 하나를 로그로 추적할 수 없게 되는 것입니다.
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 { "결제 끝" }
}
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 유지
}
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 클라이언트 | 다음 서비스에서 새 traceId | Boot 가 주는 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 필드가 없는 로그 비율을 대시보드로 두세요.
MDCContext() 를 코루틴 컨텍스트에 실어야 따라옵니다. 진입점(필터 · CallLogging)에서 한 번 얹고,
값을 더할 때는 withMdc 처럼 새 MDCContext 로 감싸며, 서비스 경계는 헤더로 넘깁니다.
코루틴 컨텍스트 자체는 🌀 코루틴 탭(CORO 11)에서 다룹니다.예외 처리 실무 — runCatching 이 취소를 삼키는 함정부터
코틀린에는 checked exception 이 없어서 "어디서 무엇이 터질지"를 타입이 알려 주지 않습니다.
여기에 코루틴이 취소도 예외(CancellationException)로 전달한다는 규칙을 더하면,
평범해 보이는 try-catch 와 runCatching 이 취소를 먹어 버리는 버그가 생깁니다.
예외를 어디서 잡고 · 무엇으로 표현하고 · 어디까지 퍼지게 할지를 정리합니다.
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<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() 은 스코프를 만들 때만 씁니다.코루틴 장애 패턴 넷 — 증상으로 알아보고 고치기
코루틴 장애는 대개 컴파일도 되고 테스트도 통과합니다. 트래픽이 붙어야 드러나고, 드러날 때는 "메모리가 계속 늘어난다", "가끔 응답이 수 초씩 멈춘다" 같은 증상으로 옵니다. 실제로 가장 많이 겪는 네 가지를 지표의 모양 → 원인 코드 → 고친 코드 → 진단 도구 순서로 봅니다.
패턴 ① ② — 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) } // 원소마다 취소 확인
}
패턴 ③ ④ — 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.print | DefaultDispatcher-worker 가 전부 BLOCKED/WAITING 인지 |
| BlockHound | 논블로킹이어야 할 스레드의 블로킹 호출을 테스트에서 예외로 |
| IntelliJ 코루틴 디버거 | 중단점에서 코루틴 트리와 상태 |
kotlinx-coroutines-debug 를 함께 두면 BlockHound.install() 만으로
Dispatchers.Default 위의 Thread.sleep · JDBC 호출을 잡아 줍니다. 통합 테스트에서만 켜는 것이 보통입니다.
isActive · ensureActive(),
블로킹 자원에는 limitedParallelism, 컨트롤러는 suspend 로 — 그리고 의심되면 코루틴 덤프부터 뜹니다.
내부 스케줄러 동작은 🔬 전문가 탭(03)에 있습니다.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 엔티티와 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)
!! 는 requireNotNull · ?: throw 로 바꿔 이유를 남기며,
엔티티는 코틀린 타입 = DB 제약으로 맞추고 data class 로 만들지 않습니다.
lateinit 은 프레임워크가 초기화를 보장하는 자리에서만 씁니다.직렬화 호환성 — 필드 하나 추가했을 뿐인데 옛 클라이언트가 깨진다
JSON 은 서버 · 앱 · 다른 서비스 · Kafka 에 남은 옛 메시지 · Redis 캐시가 서로 다른 버전으로 동시에 읽습니다. 배포는 한 번에 일어나지 않으므로 "새 코드가 옛 데이터를, 옛 코드가 새 데이터를" 읽을 수 있어야 합니다. kotlinx.serialization 과 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 }
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 이 바뀌지 않게) |
order:v3:42), 앱은 최소 지원 버전을 정해 그보다 옛 응답 모양을 유지합니다.
ignoreUnknownKeys, Jackson 은 Boot 가 준 매퍼를 씁니다.
이름 변경 · 삭제는 두 번의 배포로 나누고, 과거 메시지를 골든 파일 테스트로 고정하면 호환성 사고는 PR 단계에서 걸립니다.
직렬화 기초는 🧰 실전 도구 탭(05)과 🍃 Spring Boot Web 탭(05)에 있습니다.빌드 · 품질 자동화 — 버전 카탈로그부터 CI 파이프라인까지
앞 섹션의 규칙들은 사람이 기억해서는 지켜지지 않습니다. 빌드가 대신 기억하게 만들어야 합니다 — 의존성 버전은 카탈로그 한 곳에, 코드 규칙은 detekt · ktlint 에, 테스트는 CI 에. 그리고 그 CI 가 느리면 아무도 기다리지 않으므로 캐시와 병렬화까지가 한 세트입니다. Gradle 기초는 🧰 실전 도구 탭(01 · 02 · 03)에 있습니다.
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
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
실무에선 이렇게 — 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 # 무엇이 돌지만 미리 보기
libs.versions.toml 한 곳, 빌드는 캐시 + configuration cache,
규칙은 detekt(베이스라인) + ktlint, CI 는 싼 검사부터 · 서로 독립인 job 은 병렬로.
PR 피드백이 10분 안에 오면 사람들은 CI 를 믿기 시작합니다.성능 측정 — 추측 말고 재기 · 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()
}
판단 기준 — 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()) } }
실무에선 이렇게 — 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 인라이닝이 막힌다
IntArray,
value class 는 제네릭 · nullable · 인터페이스 자리에서 박싱된다는 것만 기억하면 됩니다.
JMH 기본 사용법은 🧰 실전 도구 탭(15), 바이트코드 수준은 🔬 전문가 탭(04 · 06)에 있습니다.코드 리뷰 체크리스트 — 이 탭의 함정을 PR 에서 막기
앞의 열두 섹션에 나온 함정은 대부분 한 줄짜리입니다. 그래서 리뷰에서 놓치기 쉽고, 놓치면 운영에서 찾기 어렵습니다. 이 섹션은 그 함정들을 나쁜 예 / 좋은 예로 압축하고, 무엇부터 볼지 우선순위와 배포 전 체크리스트로 묶습니다. PR 템플릿에 그대로 붙여 쓸 수 있게 만들었습니다.
나쁜 예 / 좋은 예 — 한 줄짜리 함정 여섯 🟡 중급
① 취소를 삼킴 · ② 수명 없는 코루틴
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 전부 초록 · 불안정 테스트를 재실행으로 통과시키지 않았다 |
| ☐ | DB 마이그레이션이 이전 버전 앱과도 호환된다 (컬럼 추가 → 배포 → 사용) |
| ☐ | 메시지 · API 응답 모양 변경이 있으면 소비자가 먼저 배포됐다 |
| ☐ | 새 설정 값 · 비밀이 운영 환경에 들어가 있다 (checkNotNull 로 기동 시 검증) |
| ☐ | 새 기능의 성공 · 실패가 로그 필드와 지표로 보인다 |
| ☐ | 그레이스풀 셧다운 — 앱 스코프 cancel · 진행 중 요청 마무리 시간 |
| ☐ | 롤백 방법과 기준(에러율 · p99)을 정해 두었다 |
.github/pull_request_template.md
## 무엇을 · 왜
## 확인한 것
- [ ] suspend 코드의 예외 처리가 취소를 되던진다 (runSuspendCatching)
- [ ] 새 CoroutineScope 의 소유자와 cancel 시점이 있다
- [ ] 블로킹 호출은 전용 디스패처에서 돈다
- [ ] 로그: 람다 · 예외 첫 인자 · 민감정보 없음 · MDCContext
- [ ] null: 자바 경계 nullable · !! 없음 · 엔티티 타입 = DB 제약
- [ ] JSON: 새 필드 기본값 · 계약 파일 갱신
- [ ] 실패 경로 테스트 추가
## 배포 영향
- 마이그레이션: 없음 / 있음(하위 호환 확인)
- 메시지 · API 모양 변경: 없음 / 있음(소비자 선배포)
스택도 큐도
ArrayDeque
하나로 끝난다
코틀린에는 Stack 클래스도 Queue 인터페이스도 따로 없습니다.
대신 표준 라이브러리의 kotlin.collections.ArrayDeque 하나가 스택 · 큐 · 덱을 모두 맡고,
우선순위 큐와 스레드 안전 큐는 JVM 의 java.util · java.util.concurrent를,
코루틴 사이의 큐는 Channel을 씁니다.
이 탭은 2026 년 코틀린 현업이 실제로 쓰는 타입과 관용구, 내부 구조, 그리고
JDK 21 이후 생긴 removeFirst() · removeLast() 함정까지 —
코드를 실제로 컴파일해 돌린 출력과 함께 다룹니다.
한 장 지도 — 코틀린에서 무엇으로 쓰나
자바 개발자가 코틀린에서 처음 헤매는 곳이 여기입니다. Stack 을 쓰려니 낡았다고 하고,
java.util.ArrayDeque 를 쓰려니 코틀린에도 ArrayDeque 가 있습니다.
결론부터 표 한 장과 그림 한 장으로 정리하고, 나머지 섹션에서 하나씩 뜯어 봅니다.
2026 권장 vs 쓰지 말 것
| 용도 | 권장 | 쓰지 말 것 (이유) |
|---|---|---|
| 스택 | ArrayDequeaddLast · removeLast | java.util.Stack — Vector 상속, 모든 메서드 synchronized, 중간 삽입 가능 |
| 큐 | ArrayDequeaddLast · removeFirstOrNull | LinkedList — 원소마다 노드 객체 · ArrayList.removeAt(0) — O(n) |
| 덱 | ArrayDeque (코틀린 것) | — java.util.ArrayDeque 도 괜찮지만 JVM 전용 · null 금지 |
| 우선순위 | java.util.PriorityQueue | 리스트에 넣고 매번 sort() — O(n log n) 반복 |
| 스레드 간 | ArrayBlockingQueue · 용량 준 LinkedBlockingQueue · ConcurrentLinkedQueue | Vector · 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 와 코틀린 표준 라이브러리만으로 돕니다.
ArrayDeque 가 둘 — 코틀린 것과 자바 것
무엇이 다른가
| 항목 | kotlin.collections | java.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() null | removeFirst() 예외 · pollFirst() null |
| 스택 이름 | 없음 — addLast/removeLast | push/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) 로 새로 만들면 충분하고,
크고 자주 바뀌면 구조를 공유하는 영속 리스트가 복사 비용을 줄여 줍니다.
kotlin.collections.ArrayDeque, 우선순위는 java.util.PriorityQueue,
스레드 사이는 BlockingQueue, 코루틴 사이는 Channel. Stack 과 LinkedList 는 새 코드에 쓰지 않습니다.스택 — 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 |
|---|---|---|
| push | push(e) = addFirst | addLast(e) |
| pop | pop() — 비면 예외 | removeLast() — 비면 예외 |
| pop (안전) | pollFirst() → null | removeLastOrNull() → null |
| peek | peek() → null | lastOrNull() → null |
| 비었나 | isEmpty() | isEmpty() · isNotEmpty() |
addFirst · removeFirst 로 써도 똑같이 O(1) 입니다.
다만 addLast 로 쌓으면 println · 디버거 · for 순회가
바닥 → 꼭대기 순서로 보여서 Stack 시절의 눈과 같고, d[i] 의 i 가
"아래에서 i 번째"가 됩니다. 팀에서 한쪽으로 통일하는 게 핵심입니다.
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.
직접 확인한 바이트코드 (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() (코틀린 전용 이름)
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() —
컴파일 환경에 따라 뜻이 바뀌는 호출을 코드에서 없애는 것이 가장 확실합니다.큐(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() 이나 위의 시퀀스로 씁니다.
자바 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 입니다.
큐로 쓰면 안 되는 것
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? 로 받습니다.덱의 속 — 원형 배열 · 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]
}
연산별 비용 — 공식 소스 기준
| 연산 | 비용 | 이유 |
|---|---|---|
addFirst · addLast | 분할 상환 O(1) | 평소 O(1), 꽉 차면 한 번 O(n) 복사 — 1.5배씩 늘어 평균은 상수 |
removeFirst · removeLast | O(1) | head 또는 size 만 바뀜 |
first() · last() · d[i] · d[i] = x | O(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 로 지운다 (배열 크기는 그대로) |
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 원소를 허용합니다.
버스트 뒤에 메모리를 그대로 쥔다
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배 · 축소는 없음.우선순위 큐 — 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() // 결제 환불 알림 메일 로그
}
compareBy<Task> { … } 처럼 첫 단계에 타입을 적어 줘야 thenBy 의 it 이 추론됩니다.
비용과 함정
| 연산 | 비용 | 메모 |
|---|---|---|
add · offer | O(log n) | 맨 끝에 넣고 위로 올림 |
poll · remove() | O(log n) | 비었을 때 poll 은 null, remove() 는 예외 |
peek | O(1) | 0번 칸 |
remove(x) · contains | O(n) | 선형 탐색 — 자주 부르면 설계를 바꿀 신호 |
순회 · toString | O(n) | 정렬 순서 아님 |
| 첫 용량 · 증가 | — | 11 → 24 → 50 → 102 → 153 (작을 땐 약 2배, 64 이상 1.5배) |
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 }) // [필통, 공책, 펜]
}
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 / actual | JVM 은 PriorityQueue 로, 나머지는 직접 구현으로 — 테스트를 공통에 둔다 |
직접 구현에서도 a.removeAt(a.lastIndex) 를 쓴 이유는 SQ 02 의 함정 때문입니다 —
공통 코드는 JVM 타깃에서 JDK 21 로 컴파일될 수 있습니다.
PriorityQueue(compareBy<T> { … }.thenBy { … }), 최대 힙은 compareByDescending · reverseOrder().
순회 순서는 정렬이 아니고, 같은 우선순위 순서도 보장되지 않으며, 넣은 뒤 키를 바꾸면 안 됩니다.스레드 안전 큐 — BlockingQueue 를 코틀린에서
코틀린 표준 라이브러리에는 동시성 컬렉션이 없고, java.util.concurrent 를 그대로 씁니다
(개요는 ADV 09). 여기서는 큐 하나에 집중합니다 — 막을 것인가(blocking) 말 것인가,
크기를 제한할 것인가(bounded), 어떻게 끝낼 것인가(poison pill).
구현체 고르기
| 타입 | 성격 | 언제 |
|---|---|---|
ArrayBlockingQueue(n) | 유한 · 배열 · 잠금 하나 | 기본 선택 — 배압이 필요한 생산자-소비자 |
LinkedBlockingQueue(n) | 선택적 유한 · 노드 · 넣기/빼기 잠금 분리 | 생산 · 소비가 동시에 몰릴 때. 용량을 꼭 준다 |
LinkedBlockingDeque | 양쪽 블로킹 | 작업 훔치기 · 우선 재처리 |
PriorityBlockingQueue | 무한 · 힙 | 스레드 간 우선순위 작업 |
DelayQueue | 무한 · 시각이 된 것만 | 예약 실행 · 만료 처리 |
SynchronousQueue | 용량 0 — 직접 건네기 | 스레드 풀 핸드오프 (채널의 RENDEZVOUS) |
ConcurrentLinkedQueue | 무한 · 논블로킹 · 락 프리 | 막을 필요 없는 다중 생산 · 소비. size() 는 O(n) |
같은 동작, 네 가지 태도
| 예외 | 특수값 | 막힘 | 시간 제한 | |
|---|---|---|---|---|
| 넣기 | add | offer → false | put | offer(e, t, u) |
| 빼기 | remove | poll → null | take | poll(t, u) |
| 보기 | element | peek → 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 이 생산자를 재웁니다. 소비가 느려도 메모리가 무한히 늘지 않고,
생산 속도가 소비 속도에 자동으로 맞춰집니다.
스레드 풀의 큐 · 코루틴과 섞을 때
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() · 고정 스레드 풀)는 의심부터 합니다.코루틴의 큐 — 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) |
| 숫자 n | n 칸 유한 큐 | ArrayBlockingQueue(n) |
CONFLATED | 1칸 + 새 값이 옛 값을 덮어씀 (= Channel(1, DROP_OLDEST)) | 없음 — 최신값 레지스터 |
UNLIMITED | 무한 — send 가 절대 멈추지 않는다 | LinkedBlockingQueue() — 같은 OOM 위험 |
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 | 마지막 값만 — 화면 상태 · 검색어 |
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) |
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()
}
| 기준 | Mutex | Channel (요청 큐) |
|---|---|---|
| 생각 방식 | 상태를 잠깐 잠근다 | 상태 주인이 하나, 나머지는 메시지를 보낸다 |
| 코드 양 | 적다 | 메시지 타입 · 루프가 필요 |
| 호출자 대기 | 잠금을 얻을 때까지 suspend | trySend 면 대기 없음 (fire-and-forget) |
| 순서 | 공정하지만 호출 순서 보장은 약함 | 큐 순서 = 처리 순서 |
| 어울리는 곳 | 짧은 임계 구역, 캐시 갱신 | 순서가 중요한 명령 처리, 상태 기계, 쓰기 직렬화 |
withLock 안에서 같은 Mutex 를 다시 잡으면 영원히 멈춥니다.
actor { } 빌더는 실험적 API 로 남아 있어, 위처럼 Channel + launch 로 직접 쓰는 편이 안전합니다.
send, 최신만 필요하면 CONFLATED · DROP_OLDEST,
UNLIMITED 는 LinkedBlockingQueue() 와 같은 무제한 큐라는 걸 기억하세요.알고리즘 패턴 — 코틀린 관용 코드로
코딩 테스트와 실무에 반복해서 나오는 여섯 가지입니다. 이론(왜 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) // 안녕하세요?
}
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 하나에 담는다 |
ArrayDeque 가 양쪽 O(1) 이라 가능한 방법입니다.
ArrayDeque 하나입니다.
인덱스를 담는 단조 스택 · 덱, 넣을 때 방문 표시, ?: break 로 비우기 — 이 세 습관이 코드를 짧고 안전하게 만듭니다.성능과 메모리 — 박싱 · 재할당 · 캐시
"어느 큐가 빠른가"는 대개 자료구조보다 원소 하나가 메모리에 어떻게 놓이느냐로 갈립니다. 이 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
IntRingQueue 를 처음엔 (head + 1) % cap 으로 짰더니 38 ms 였고,
비교 분기(if (++head == cap) head = 0)로 바꾸자 11 ms 가 됐습니다. 코틀린 표준 ArrayDeque 소스가
% 대신 positiveMod 비교를 쓰는 이유이기도 합니다.
removeAt(0))는 수십 배입니다.원소 하나가 차지하는 메모리 — 64비트 HotSpot, 압축 포인터 기준
| 구조 | 원소당 대략 | 내역 |
|---|---|---|
IntArray 원형 큐 | 4 B | int 값 자체 (+ 빈 칸) |
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 허용 · 인덱스 접근이 더 큽니다.
흔한 실수 — 코드 리뷰에서 실제로 걸리는 것들
문법 오류는 컴파일러가 잡지만, 아래 실수들은 컴파일이 되고 테스트도 통과한 뒤 운영에서 터집니다. 특히 첫 번째는 구형 안드로이드 기기에서만 나타나서 원인을 찾기까지 오래 걸립니다.
list.removeLast() 가 2025 년에 SDK 를 올리는 순간 구형 기기 크래시가 됩니다.한눈에 — 실수와 고치는 법
| # | 실수 | 고치는 법 |
|---|---|---|
| 1 | mutableList.removeFirst() / removeLast() | removeAt(0) · removeAt(lastIndex) · …OrNull() |
| 2 | import java.util.* 로 ArrayDeque 바꿔치기 | 와일드카드 import 금지 · IDE 설정 |
| 3 | 순회 중 큐 수정 → CME | while + removeFirstOrNull() |
| 4 | null 원소 + OrNull 로 빈 큐 판별 | isEmpty() 먼저 · T : Any |
| 5 | PriorityQueue 를 순회 · 출력하며 정렬 기대 | poll() 로 꺼내거나 sortedWith |
| 6 | 무제한 큐 (LinkedBlockingQueue() · UNLIMITED) | 용량 지정 + 넘침 정책 |
| 7 | 코루틴 안 BlockingQueue.take() | Channel · runInterruptible(IO) |
| 8 | "비었나 확인 후 꺼내기" (다중 스레드) | 꺼낸 결과(poll())로 판단 |
| 9 | first() · 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
}
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
java.util.ArrayDeque 로 바뀌어도 addLast · removeFirst 는 이름이 같아
컴파일이 됩니다. 달라지는 건 null 을 넣으면 NPE, d[i] 컴파일 에러, removeFirstOrNull() 이
MutableList 확장이 아니라서 안 보임(자바 것은 MutableList 가 아님) 정도인데, 마지막 것 때문에
누군가 pollFirst() 로 고치기 시작하면 코드베이스에 두 덱이 섞입니다. IntelliJ 의
"Use single name import" · 와일드카드 import 임계값을 크게 잡아 두세요.
MutableList.removeFirst/removeLast(구형 기기 크래시)와 무제한 큐(OOM)입니다.
나머지는 "꺼낸 결과로 판단하고, 비는 게 정상이면 OrNull" 하나로 대부분 막힙니다.다른 언어와 비교 — 같은 일을 무엇으로 하나
코틀린은 서버에서는 자바와, 앱에서는 스위프트 · 다트와, 공통 코드에서는 여러 언어와 나란히 놓입니다. 다른 언어의 코드를 옮길 때 무엇이 무엇에 대응하는지, 그리고 빈 컨테이너에서 꺼내면 무슨 일이 나는지를 한 장으로 봅니다.
| 할 일 | Kotlin | Java 21+ | Python 3.13 | JS / TS | C# (.NET 8+) | C++23 | Rust |
|---|---|---|---|---|---|---|---|
| 스택 | ArrayDeque addLast/removeLast | ArrayDeque push/pop | list append/pop | Array push/pop | Stack<T> | std::stack · vector | Vec push/pop |
| 큐 | ArrayDeque addLast/removeFirst | ArrayDeque offer/poll | collections.deque popleft | 내장 없음 — shift() 는 O(n) | Queue<T> | std::queue | VecDeque |
| 덱 구현 | 원형 배열 · 1.5배 | 원형 배열 | 블록 연결 리스트 | — | 내장 덱 없음 | 청크 배열의 맵 | 원형 배열 |
| 우선순위 큐 | java.util.PriorityQueue (JVM) | PriorityQueue (최소) | heapq 함수 (최소) | 내장 없음 | PriorityQueue<E,P> (최소) | priority_queue (최대) | BinaryHeap (최대) |
| 스레드 간 큐 | ArrayBlockingQueue 등 | 같음 | queue.Queue | Worker postMessage | BlockingCollection · ConcurrentQueue | 표준 없음 (직접 · 라이브러리) | std::sync::mpsc |
| 비동기 채널 | Channel · Flow.buffer | 표준 없음 (가상 스레드 + BlockingQueue) | asyncio.Queue | 표준 없음 (스트림 · 직접 구현) | System.Threading.Channels | 표준 없음 | tokio::sync::mpsc (크레이트) |
| 빈 곳에서 꺼내기 | 예외 또는 OrNull → null | 예외 또는 poll → null | IndexError | undefined | 예외 또는 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/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 것을 빌려 씀 세 가지입니다.실무 사례 — 최근 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]
}
③ 안드로이드 — 일회성 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) | 버퍼에 보관 → 화면이 돌아오면 한 번 전달 |
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 넷 중 하나로 정해집니다.같은 언어,
다섯 가지
폴더 지도
코틀린은 서버 · 안드로이드 · iOS 공유 · 데스크톱 · 라이브러리를 모두 같은 Gradle 위에서 만듭니다.
그래서 바탕(설정 파일 · 버전 카탈로그 · src/main/kotlin)은 같고, 그 위에 올리는 구조만 유형마다 다릅니다.
이 탭은 다른 탭에 흩어진 구조 이야기를 한 장으로 모아 비교하고, "지금 규모면 어디까지 나눠야 하나"에 답합니다.
상세 설명은 해당 탭 섹션으로 이어 둡니다.
internal 경계와 explicit API 모드 오류 메시지는 Kotlin 2.3.20 컴파일러로 직접 돌려 옮겼습니다.
코틀린 프로젝트의 공통 바탕 — 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
규모별 진화 — 언제 다음 단계로 넘어가나 🟢 기초
| 신호 | 무엇이 아픈가 | 다음 단계 |
|---|---|---|
| 파일 30개 · 사람 2명 | "이 클래스 어디 있지?" · 같은 파일을 동시에 고쳐 충돌 | 기능별 패키지 (STR 02) |
| 빌드 2분 넘음 | 한 줄 고쳐도 전체가 다시 컴파일됨 | 모듈 분리 — 바뀐 모듈만 다시 빌드 (STR 05) |
| 배포물이 둘 이상 | 서버 · 배치 · 관리자 앱이 코드를 나눠 씀 | 공통 코드를 :core 모듈로 |
| 모듈 10개 이상 | build.gradle.kts 마다 같은 30줄 | build-logic (STR 06) |
| 팀이 여럿 | 리뷰로 막던 의존 규칙이 새기 시작 | 아키텍처 테스트 · 의존 검사 (STR 10) |
Spring Boot + Kotlin — 기능별 패키지와 코틀린다운 파일 배치
스프링 서버의 구조는 자바와 거의 같습니다 — 루트 패키지에 메인 클래스, 그 아래 기능별 패키지. 코틀린이 바꾸는 것은 파일 단위입니다. 한 파일에 클래스 여러 개와 최상위 함수를 둘 수 있어서, "어떤 파일에 무엇을 묶나"라는 자바에 없던 결정이 생깁니다. 헥사고날로 더 나누는 법은 전문가 탭 DEEP 14 를 보세요.
권장 트리 — 모듈 하나짜리 스프링 서버
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)로 넘깁니다.Ktor 서버 구조 — Application.module 은 조립만
Ktor 는 애너테이션도 컴포넌트 스캔도 없어서, 구조를 정하지 않으면 Application.kt 한 파일이 끝없이 길어집니다.
해법은 하나입니다 — 설정은 Application 확장 함수, 라우트는 Route 확장 함수로 쪼개고
module() 은 그것들을 부르는 목차로만 둡니다. 라우팅 · 플러그인 문법은 Ktor 탭 KTOR 03 · 04 에 있습니다.
권장 트리 — 생성기 구조 + 기능 폴더
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 · Repository | di/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 하지 않게 둡니다.Android 앱 구조 — 공식 아키텍처 가이드를 폴더로 옮기기
안드로이드는 구글이 공식 권장 아키텍처(UI 계층 · 선택적 도메인 계층 · 데이터 계층)를 문서로 정해 두었습니다. 계층의 뜻과 ViewModel 코드는 Android 탭 AND 01, 앱 실전 탭 ANDX 01 에 있으니, 여기서는 그 계층이 모듈 하나짜리 앱에서 어떤 패키지 트리가 되는지와 각 클래스의 자리만 정리합니다.
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 를 들고 있지 않음 |
| UseCase | domain/. 동사 + 대상 + UseCase, operator fun invoke() 하나 |
| Repository | data/<데이터>/. 화면 이름이 아니라 데이터 이름으로 |
| DataSource | 리포지터리 옆. 한 출처만 다룸, 다른 DataSource 를 부르지 않음 |
| DTO · Entity | DataSource 옆에 두고 리포지터리 밖으로 내보내지 않음 — 밖에는 Article 같은 모델만 |
❌ 화면 이름으로 리포지터리를 만듦
FeedRepository · DetailRepository — 두 화면이 같은 기사 데이터를 쓰는데 캐시가 둘로 갈라지고, 상세에서 북마크한 것이 피드에 안 보입니다.
✅ 데이터 이름으로 하나
NewsRepository 하나가 기사의 단일 진실 공급원입니다. 화면은 그 Flow 를 관찰만 하므로 어디서 바꿔도 모든 화면이 따라옵니다.
:feature:feed · :core:data 모듈이 됩니다(STR 05).
패키지를 화면별 · 데이터별로 나눠 두었으면 폴더를 옮기기만 하면 됩니다.
ui/화면/ 에 Screen · ViewModel · UiState, data/데이터/ 에 Repository · DataSource, 도메인은 필요할 때만. 리포지터리는 화면이 아니라 데이터 이름으로 만듭니다.Android 모듈화 — :app · :feature:* · :core:*
구글 공식 샘플 Now in Android 가 사실상 표준이 된 모듈 배치입니다. 규칙은 단 하나로 요약됩니다 — 의존은 아래로만, feature 끼리는 서로 모른다. Hilt 로 모듈 사이를 잇는 코드는 앱 실전 탭 ANDX 01 에 있고, 여기서는 배치와 의존 규칙, 빌드 시간 효과를 봅니다.
모듈 트리와 각자의 책임
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 기본 — 이 둘이 빌드 시간 이득의 전부입니다.build-logic · 컨벤션 플러그인 — 모듈 30개의 빌드 스크립트를 한 장으로
모듈이 열 개를 넘으면 build.gradle.kts 마다 compileSdk · jvmToolchain · Compose 설정 30줄이 복사됩니다.
하나를 올리려면 열 군데를 고쳐야 하고 꼭 하나를 빠뜨립니다. 해법은 "이 모듈은 안드로이드 feature 다"라는 한 줄로 설정 묶음을 입히는 컨벤션 플러그인입니다.
스크립트형(*.gradle.kts) 예제는 도구 탭 TOOL 02 에 있고, 여기서는 Now in Android 가 쓰는 클래스형과 폴더 배치를 봅니다.
폴더 배치와 연결
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 방식 |
includeBuild("build-logic") 로 포함 빌드를 두고, 모듈 종류마다 플러그인 하나(android.application · android.library · android.feature · jvm.library). 모듈의 빌드 스크립트는 plugins 한 줄 + 자기만의 의존성만 남깁니다.KMP 구조 — 공유 모듈과 앱 모듈의 경계
Kotlin Multiplatform 프로젝트는 공유 모듈(shared) 하나 + 플랫폼별 앱 진입점이 기본형입니다.
소스셋 계층(commonMain · androidMain · iosMain …)과 build.gradle.kts 는 KMP 탭 KMP 02 에 있으니,
여기서는 "이 코드는 어느 폴더에 두나"를 판단하는 법과 expect/actual 파일 배치 규칙만 봅니다.
경계가 보이는 트리
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 에 내보낼 것만 public | public 선언이 전부 Objective-C 헤더로 나갑니다 — 나머지를 internal 로 두면 헤더와 바이너리가 작아집니다 |
| shared 가 커지면 쪼개되 iOS 출구는 하나 | :shared:core · :shared:feature-x 로 나누고, iOS 프레임워크는 그것들을 묶는 umbrella 모듈 하나에서만 만듭니다 |
commonMain, 플랫폼 API 는 interface/expect 는 공통 · 구현은 플랫폼. 파일은 Platform.android.kt 처럼 꼬리를 붙이고, 앱 모듈에는 화면만 둡니다.Compose Multiplatform — composeApp 하나로 데스크톱 · 웹까지
Compose Multiplatform 은 KMP 위에 UI 까지 공유하는 구조입니다. 그래서 모듈 이름부터 composeApp 이고,
화면 코드가 commonMain 으로 올라옵니다. 데스크톱(JVM)과 웹(Wasm)은 진입점 파일 하나씩만 따로 가집니다.
헷갈리는 것은 리소스 위치입니다 — 안드로이드의 res/ 가 아니라 composeResources/ 입니다.
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 모듈로 쪼개고 리소스도 따라 옮깁니다.라이브러리 · 멀티 모듈 공개 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 를 파일로 고정 🟡 중급
흐름 — 덤프 · 검사 · 리뷰
# 루트에 binary-compatibility-validator 플러그인 적용 후
./gradlew apiDump # api/*.api 를 현재 코드로 갱신 → 커밋
./gradlew apiCheck # check 에 묶여 CI 에서 자동 — 다르면 실패
# 의도한 변경이면: apiDump 후 .api diff 를 PR 에 함께 올림
# 의도하지 않은 변경이면: CI 가 막아 줌
abiValidation)으로 들어오고 있습니다.
태스크 이름이 버전마다 바뀌는 중이라 새 프로젝트라도 당장은 플러그인 쪽이 문서가 많습니다.
공개 범위를 다루는 장치 네 가지
| 장치 | 쓰임 |
|---|---|
internal | 모듈 안에서만 — 구현 클래스의 기본값 |
@PublishedApi internal | 공개 inline 함수 본문이 부르는 내부 함수 (인라인되면 호출자 쪽에 코드가 복사되므로) |
@RequiresOptIn 애너테이션 | "실험적" API — 쓰는 쪽이 @OptIn 을 적어야 컴파일 |
@Deprecated(level = …) | WARNING → ERROR → HIDDEN 으로 버전마다 한 단계씩 치우기 |
explicitApi() 로 공개를 강제로 선택하게 하고, apiDump 로 공개 API 를 파일에 고정해 CI 가 지키게 합니다. 선택 기능은 별도 아티팩트, 버전은 -bom 으로 맞춥니다.한 장 요약 — 규모 · 유형별 권장 구조와 규칙 강제
앞의 아홉 섹션을 표 한 장으로 접습니다. 행은 프로젝트 유형, 열은 규모입니다. 그리고 구조가 커지면 반드시 오는 문제 — 리뷰로 지키던 규칙이 새는 것 — 을 테스트 · 린트 · 빌드 검사로 막는 법으로 끝냅니다.
| 유형 | 시작 구조 | 첫 분리 신호 → 분리 방법 | 상세 |
|---|---|---|---|
| Spring 서버 | 루트에 메인 · 기능별 패키지 | 배포물 둘 · 빌드 2분 → :domain · :infra · :api | STR 02 · DEEP 14 |
| Ktor 서버 | module() 목차 · plugins/ · 기능 폴더 | 기능 10개 · 팀 2개 → 기능 모듈 + :server 조립 | STR 03 · KTOR 10 |
| Android | ui/ · domain/ · data/ 패키지 | 화면 10개 · 빌드 수 분 → :feature:* · :core:* | STR 04 · 05 · ANDX 01 |
| KMP | shared + 앱 진입점 | shared 비대 → :shared:* + umbrella | STR 07 · KMP 02 |
| Compose MP | composeApp/commonMain | 화면 · 리소스 증가 → feature · designsystem 모듈 | STR 08 |
| 라이브러리 | 모듈 하나 · explicitApi() | 선택 기능 등장 → 별도 아티팩트 · BOM | STR 09 · TOOL 12 |
| 공통 | 버전 카탈로그 | 모듈 10개 → build-logic | STR 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..") }
}
}
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 안에서만 씁니다'
api/implementation 오용은 dependency-analysis 플러그인의 ./gradlew buildHealth 가 찾아 줍니다.
| 무엇을 막나 | 도구 | 언제 실패하나 |
|---|---|---|
| 모듈 사이 의존 방향 | 모듈 분리 자체 + 컨벤션 플러그인 검사 | Gradle 설정 단계 — 가장 빠름 |
| 패키지 · 계층 의존 | Konsist · ArchUnit | 테스트 단계 |
| 금지 import · 이름 규칙 | detekt (ForbiddenImport · 커스텀 규칙) | 정적 분석 단계 |
| 공개 API 변경 | explicitApi() · apiCheck | 컴파일 · check 단계 |
| 스프링 모놀리스 기능 경계 | Spring Modulith ApplicationModules.of(…).verify() | 테스트 단계 |