From ecc
코루틴, 널 안전성, DSL 빌더를 사용하여 견고하고 효율적이며 유지보수가 쉬운 코틀린 애플리케이션을 구축하기 위한 관용적인 패턴, 모범 사례 및 컨벤션입니다.
npx claudepluginhub sam42-lab/everything-claude-code-krThis skill uses the workspace's default tool permissions.
견고하고 효율적이며 유지보수가 쉬운 애플리케이션을 구축하기 위한 관용적인(idiomatic) 코틀린 패턴과 모범 사례입니다.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
견고하고 효율적이며 유지보수가 쉬운 애플리케이션을 구축하기 위한 관용적인(idiomatic) 코틀린 패턴과 모범 사례입니다.
이 스킬은 7가지 핵심 영역에서 관용적인 코틀린 컨벤션을 적용합니다: 타입 시스템과 안전 호출 연산자를 사용한 널 안전성, 데이터 클래스에서 val과 copy()를 통한 불변성 유지, 철저한 타입 계층 구조를 위한 봉인된(sealed) 클래스 및 인터페이스 사용, 코루틴과 Flow를 활용한 구조적 동시성, 상속 없이 기능을 추가하는 확장 함수, @DslMarker와 수신 객체 지정 람다를 사용한 타입 안전 DSL 빌더, 그리고 빌드 구성을 위한 Gradle 코틀린 DSL입니다.
엘비스 연산자를 사용한 널 안전성:
fun getUserEmail(userId: String): String {
val user = userRepository.findById(userId)
return user?.email ?: "unknown@example.com"
}
철저한 결과 처리를 위한 봉인된 클래스:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Failure(val error: AppError) : Result<Nothing>()
data object Loading : Result<Nothing>()
}
async/await를 사용한 구조적 동시성:
suspend fun fetchUserWithPosts(userId: String): UserProfile =
coroutineScope {
val user = async { userService.getUser(userId) }
val posts = async { postService.getUserPosts(userId) }
UserProfile(user = user.await(), posts = posts.await())
}
코틀린의 타입 시스템은 널이 될 수 있는 타입과 널이 될 수 없는 타입을 구분합니다. 이를 최대한 활용하세요.
// 좋음: 기본적으로 널이 될 수 없는 타입 사용
fun getUser(id: String): User {
return userRepository.findById(id)
?: throw UserNotFoundException("User $id not found")
}
// 좋음: 안전 호출과 엘비스 연산자
fun getUserEmail(userId: String): String {
val user = userRepository.findById(userId)
return user?.email ?: "unknown@example.com"
}
// 나쁨: 널이 될 수 있는 타입을 강제로 단언(unwrapping)
fun getUserEmail(userId: String): String {
val user = userRepository.findById(userId)
return user!!.email // 널일 경우 NPE 발생
}
var보다는 val을, 가변 컬렉션보다는 불변 컬렉션을 우선적으로 사용하세요.
// 좋음: 불변 데이터
data class User(
val id: String,
val name: String,
val email: String,
)
// 좋음: copy()를 사용한 변환
fun updateEmail(user: User, newEmail: String): User =
user.copy(email = newEmail)
// 좋음: 불변 컬렉션
val users: List<User> = listOf(user1, user2)
val filtered = users.filter { it.email.isNotBlank() }
// 나쁨: 가변 상태
var currentUser: User? = null // 가변 전역 상태는 피하세요
val mutableUsers = mutableListOf<User>() // 정말 필요한 경우가 아니면 피하세요
간결하고 읽기 쉬운 함수를 위해 표현식 본문(expression bodies)을 사용하세요.
// 좋음: 표현식 본문
fun isAdult(age: Int): Boolean = age >= 18
fun formatFullName(first: String, last: String): String =
"$first $last".trim()
fun User.displayName(): String =
name.ifBlank { email.substringBefore('@') }
// 좋음: 표현식으로서의 when
fun statusMessage(code: Int): String = when (code) {
200 -> "OK"
404 -> "Not Found"
500 -> "Internal Server Error"
else -> "Unknown status: $code"
}
// 나쁨: 불필요한 블록 본문
fun isAdult(age: Int): Boolean {
return age >= 18
}
주로 데이터를 담는 타입에는 데이터 클래스를 사용하세요.
// 좋음: copy, equals, hashCode, toString이 포함된 데이터 클래스
data class CreateUserRequest(
val name: String,
val email: String,
val role: Role = Role.USER,
)
// 좋음: 타입 안전성을 위한 값 클래스 (런타임 오버헤드 없음)
@JvmInline
value class UserId(val value: String) {
init {
require(value.isNotBlank()) { "UserId cannot be blank" }
}
}
@JvmInline
value class Email(val value: String) {
init {
require('@' in value) { "Invalid email: $value" }
}
}
fun getUser(id: UserId): User = userRepository.findById(id)
// 좋음: 철저한 when 처리를 위한 봉인된 클래스
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Failure(val error: AppError) : Result<Nothing>()
data object Loading : Result<Nothing>()
}
fun <T> Result<T>.getOrNull(): T? = when (this) {
is Result.Success -> data
is Result.Failure -> null
is Result.Loading -> null
}
fun <T> Result<T>.getOrThrow(): T = when (this) {
is Result.Success -> data
is Result.Failure -> throw error.toException()
is Result.Loading -> throw IllegalStateException("Still loading")
}
sealed interface ApiError {
val message: String
data class NotFound(override val message: String) : ApiError
data class Unauthorized(override val message: String) : ApiError
data class Validation(
override val message: String,
val field: String,
) : ApiError
data class Internal(
override val message: String,
val cause: Throwable? = null,
) : ApiError
}
fun ApiError.toStatusCode(): Int = when (this) {
is ApiError.NotFound -> 404
is ApiError.Unauthorized -> 401
is ApiError.Validation -> 422
is ApiError.Internal -> 500
}
// let: 널이 될 수 있는 값의 변환 또는 범위 내 결과 생성
val length: Int? = name?.let { it.trim().length }
// apply: 객체 구성 (객체 자체를 반환)
val user = User().apply {
name = "Alice"
email = "alice@example.com"
}
// also: 부수 효과 수행 (객체 자체를 반환)
val user = createUser(request).also { logger.info("Created user: ${it.id}") }
// run: 수신 객체를 사용하여 블록 실행 (결과값 반환)
val result = connection.run {
prepareStatement(sql)
executeQuery()
}
// with: run의 비확장 함수 형태
val csv = with(StringBuilder()) {
appendLine("name,email")
users.forEach { appendLine("${it.name},${it.email}") }
toString()
}
// 나쁨: 범위 함수 중첩
user?.let { u ->
u.address?.let { addr ->
addr.city?.let { city ->
println(city) // 가독성이 떨어짐
}
}
}
// 좋음: 안전 호출 체이닝 사용
val city = user?.address?.city
city?.let { println(it) }
// 좋음: 도메인 특화 확장
fun String.toSlug(): String =
lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("\\s+"), "-")
.trim('-')
fun Instant.toLocalDate(zone: ZoneId = ZoneId.systemDefault()): LocalDate =
atZone(zone).toLocalDate()
// 좋음: 컬렉션 확장
fun <T> List<T>.second(): T = this[1]
fun <T> List<T>.secondOrNull(): T? = getOrNull(1)
// 좋음: 특정 범위 내 확장 (전역 네임스페이스 오염 방지)
class UserService {
private fun User.isActive(): Boolean =
status == Status.ACTIVE && lastLogin.isAfter(Instant.now().minus(30, ChronoUnit.DAYS))
fun getActiveUsers(): List<User> = userRepository.findAll().filter { it.isActive() }
}
// 좋음: coroutineScope를 사용한 구조적 동시성
suspend fun fetchUserWithPosts(userId: String): UserProfile =
coroutineScope {
val userDeferred = async { userService.getUser(userId) }
val postsDeferred = async { postService.getUserPosts(userId) }
UserProfile(
user = userDeferred.await(),
posts = postsDeferred.await(),
)
}
// 좋음: 자식 코루틴이 독립적으로 실패할 수 있는 경우 supervisorScope 사용
suspend fun fetchDashboard(userId: String): Dashboard =
supervisorScope {
val user = async { userService.getUser(userId) }
val notifications = async { notificationService.getRecent(userId) }
val recommendations = async { recommendationService.getFor(userId) }
Dashboard(
user = user.await(),
notifications = try {
notifications.await()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
emptyList()
},
recommendations = try {
recommendations.await()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
emptyList()
},
)
}
// 좋음: 적절한 에러 처리가 포함된 차가운(Cold) Flow
fun observeUsers(): Flow<List<User>> = flow {
while (currentCoroutineContext().isActive) {
val users = userRepository.findAll()
emit(users)
delay(5.seconds)
}
}.catch { e ->
logger.error("Error observing users", e)
emit(emptyList())
}
// 좋음: Flow 연산자 활용
fun searchUsers(query: Flow<String>): Flow<List<User>> =
query
.debounce(300.milliseconds)
.distinctUntilChanged()
.filter { it.length >= 2 }
.mapLatest { q -> userRepository.search(q) }
.catch { emit(emptyList()) }
// 좋음: 취소 요청 존중
suspend fun processItems(items: List<Item>) {
items.forEach { item ->
ensureActive() // 비용이 큰 작업을 하기 전에 취소 여부 확인
processItem(item)
}
}
// 좋음: try/finally를 사용한 리소스 정리
suspend fun acquireAndProcess() {
val resource = acquireResource()
try {
resource.process()
} finally {
withContext(NonCancellable) {
resource.release() // 취소 중에도 항상 리소스 해제
}
}
}
// 지연 초기화
val expensiveData: List<User> by lazy {
userRepository.findAll()
}
// 관찰 가능한 프로퍼티
var name: String by Delegates.observable("initial") { _, old, new ->
logger.info("Name changed from '$old' to '$new'")
}
// Map 기반 프로퍼티
class Config(private val map: Map<String, Any?>) {
val host: String by map
val port: Int by map
val debug: Boolean by map
}
val config = Config(mapOf("host" to "localhost", "port" to 8080, "debug" to true))
// 좋음: 인터페이스 구현 위임
class LoggingUserRepository(
private val delegate: UserRepository,
private val logger: Logger,
) : UserRepository by delegate {
// 로깅을 추가하고 싶은 메서드만 오버라이드
override suspend fun findById(id: String): User? {
logger.info("Finding user by id: $id")
return delegate.findById(id).also {
logger.info("Found user: ${it?.name ?: "null"}")
}
}
}
// 좋음: @DslMarker를 사용한 DSL
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HTML {
private val children = mutableListOf<Element>()
fun head(init: Head.() -> Unit) {
children += Head().apply(init)
}
fun body(init: Body.() -> Unit) {
children += Body().apply(init)
}
override fun toString(): String = children.joinToString("\n")
}
fun html(init: HTML.() -> Unit): HTML = HTML().apply(init)
// 사용 예시
val page = html {
head { title("My Page") }
body {
h1("Welcome")
p("Hello, World!")
}
}
data class ServerConfig(
val host: String = "0.0.0.0",
val port: Int = 8080,
val ssl: SslConfig? = null,
val database: DatabaseConfig? = null,
)
data class SslConfig(val certPath: String, val keyPath: String)
data class DatabaseConfig(val url: String, val maxPoolSize: Int = 10)
class ServerConfigBuilder {
var host: String = "0.0.0.0"
var port: Int = 8080
private var ssl: SslConfig? = null
private var database: DatabaseConfig? = null
fun ssl(certPath: String, keyPath: String) {
ssl = SslConfig(certPath, keyPath)
}
fun database(url: String, maxPoolSize: Int = 10) {
database = DatabaseConfig(url, maxPoolSize)
}
fun build(): ServerConfig = ServerConfig(host, port, ssl, database)
}
fun serverConfig(init: ServerConfigBuilder.() -> Unit): ServerConfig =
ServerConfigBuilder().apply(init).build()
// 사용 예시
val config = serverConfig {
host = "0.0.0.0"
port = 443
ssl("/certs/cert.pem", "/certs/key.pem")
database("jdbc:postgresql://localhost:5432/mydb", maxPoolSize = 20)
}
// 좋음: 여러 연산이 포함된 큰 컬렉션에는 시퀀스 사용
val result = users.asSequence()
.filter { it.isActive }
.map { it.email }
.filter { it.endsWith("@company.com") }
.take(10)
.toList()
// 좋음: 무한 시퀀스 생성
val fibonacci: Sequence<Long> = sequence {
var a = 0L
var b = 1L
while (true) {
yield(a)
val next = a + b
a = b
b = next
}
}
val first20 = fibonacci.take(20).toList()
plugins {
kotlin("jvm") version "2.3.10"
kotlin("plugin.serialization") version "2.3.10"
id("io.ktor.plugin") version "3.4.0"
id("org.jetbrains.kotlinx.kover") version "0.9.7"
id("io.gitlab.arturbosch.detekt") version "1.23.8"
}
group = "com.example"
version = "1.0.0"
kotlin {
jvmToolchain(21)
}
dependencies {
// Ktor
implementation("io.ktor:ktor-server-core:3.4.0")
implementation("io.ktor:ktor-server-netty:3.4.0")
implementation("io.ktor:ktor-server-content-negotiation:3.4.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.0")
// Exposed
implementation("org.jetbrains.exposed:exposed-core:1.0.0")
implementation("org.jetbrains.exposed:exposed-dao:1.0.0")
implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.0.0")
// Koin
implementation("io.insert-koin:koin-ktor:4.2.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
// Testing
testImplementation("io.kotest:kotest-runner-junit5:6.1.4")
testImplementation("io.kotest:kotest-assertions-core:6.1.4")
testImplementation("io.kotest:kotest-property:6.1.4")
testImplementation("io.mockk:mockk:1.14.9")
testImplementation("io.ktor:ktor-server-test-host:3.4.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
tasks.withType<Test> {
useJUnitPlatform()
}
detekt {
config.setFrom(files("config/detekt/detekt.yml"))
buildUponDefaultConfig = true
}
// 좋음: 코틀린의 Result 또는 커스텀 봉인된 클래스 사용
suspend fun createUser(request: CreateUserRequest): Result<User> = runCatching {
require(request.name.isNotBlank()) { "이름은 비어 있을 수 없습니다" }
require('@' in request.email) { "유효하지 않은 이메일 형식입니다" }
val user = User(
id = UserId(UUID.randomUUID().toString()),
name = request.name,
email = Email(request.email),
)
userRepository.save(user)
user
}
// 좋음: 결과 체이닝
val displayName = createUser(request)
.map { it.name }
.getOrElse { "Unknown" }
// 좋음: 명확한 메시지가 포함된 전제 조건 확인
fun withdraw(account: Account, amount: Money): Account {
require(amount.value > 0) { "금액은 양수여야 합니다: $amount" }
check(account.balance >= amount) { "잔액이 부족합니다: ${account.balance} < $amount" }
return account.copy(balance = account.balance - amount)
}
// 좋음: 연산 체이닝
val activeAdminEmails: List<String> = users
.filter { it.role == Role.ADMIN && it.isActive }
.sortedBy { it.name }
.map { it.email }
// 좋음: 그룹화 및 집계
val usersByRole: Map<Role, List<User>> = users.groupBy { it.role }
val oldestByRole: Map<Role, User?> = users.groupBy { it.role }
.mapValues { (_, users) -> users.minByOrNull { it.createdAt } }
// 좋음: Map 생성을 위한 associate
val usersById: Map<UserId, User> = users.associateBy { it.id }
// 좋음: 분할을 위한 partition
val (active, inactive) = users.partition { it.isActive }
| 관용구 | 설명 |
|---|---|
var보다 val | 불변 변수를 우선 사용 |
data class | equals/hashCode/copy가 필요한 값 객체용 |
sealed class/interface | 제한된 타입 계층 구조용 |
value class | 오버헤드 없는 타입 안전 래퍼용 |
표현식 when | 철저한 패턴 매칭 |
안전 호출 ?. | 널 안전 멤버 접근 |
엘비스 ?: | 널일 경우의 기본값 지정 |
let/apply/also/run/with | 깔끔한 코드를 위한 범위 함수 |
| 확장 함수 | 상속 없이 기능 추가 |
copy() | 데이터 클래스의 불변 업데이트 |
require/check | 전제 조건 단언(assertion) |
코루틴 async/await | 구조적 동시 실행 |
Flow | 차가운 리액티브 스트림 |
sequence | 지연 평가 컬렉션 |
위임 by | 상속 없이 구현 재사용 |
// 나쁨: 널이 될 수 있는 타입을 강제 단언
val name = user!!.name
// 나쁨: 자바로부터 플랫폼 타입 누출
fun getLength(s: String) = s.length // 안전
fun getLength(s: String?) = s?.length ?: 0 // 자바에서 온 널 처리
// 나쁨: 가변 데이터 클래스
data class MutableUser(var name: String, var email: String)
// 나쁨: 제어 흐름을 위해 예외 사용
try {
val user = findUser(id)
} catch (e: NotFoundException) {
// 예상되는 케이스에는 예외를 쓰지 마세요
}
// 좋음: 널이 될 수 있는 반환값 또는 Result 사용
val user: User? = findUserOrNull(id)
// 나쁨: 코루틴 스코프 무시
GlobalScope.launch { /* GlobalScope를 피하세요 */ }
// 좋음: 구조적 동시성 사용
coroutineScope {
launch { /* 적절한 범위 내 실행 */ }
}
// 나쁨: 깊게 중첩된 범위 함수
user?.let { u ->
u.address?.let { a ->
a.city?.let { c -> process(c) }
}
}
// 좋음: 직접적인 널 안전 체인
user?.address?.city?.let { process(it) }
기억하세요: 코틀린 코드는 간결하면서도 읽기 쉬워야 합니다. 안전을 위해 타입 시스템을 활용하고, 불변성을 선호하며, 동시성을 위해 코루틴을 사용하세요. 의심스러울 때는 컴파일러의 도움을 받으세요.