놓치기 쉬운 코틀린 컨벤션 정리
Kotlin 공식 컨벤션 문서에서 실무에서 놓치기 쉬운 항목들을 추려 정리
Contents
이 글에서는 Kotlin 공식 Coding conventions 문서 중에서 실무에서 놓치기 쉬운 항목들을 중심으로 정리한다.
컨벤션을 사람이 전부 기억해서 맞추는 방식은 옳지 않다. 그러나 린터나 IDE 설정이 모든 엣지케이스를 조정해주길 기대하기도 어렵다. 새로운 약속을 만들고 조정해야하는 경우가 생기기 때문이다.
그 구체적인 논의 호흡을 줄이기 위해서라도 개발자가 기본적인 컨벤션을 읽어두는 것이 손해는 아닐 것이다. 그러한 취지에서 코틀린 공식 컨벤션에서 놓치기 쉬운 주요한 컨벤션을 다시금 정리해본다.
주의: 그렇다고 기존 로컬 룰(사내 룰이나 관용적 표현)을 지적하기 위한 주요 이유가 될 필요는 없다고 생각한다. 컨벤션은 가독성을 위한 것이고, 가독성의 주요 요소 중 하나는 인지 부하의 감소, 즉 개발 구성원이 그 코드를 얼마나 익숙하게 읽을 수 있느냐이기 때문이다.
Kotlin Docs Coding conventions Kotlin 공식 Coding conventions 문서. kotlinlang.org확장 함수는 무조건 Extensions.kt에 모으지 않기
코틀린에서는 확장 함수를 자주 사용한다. 그래서 흔히 다음과 같은 파일을 만들기 쉽다.
UserExtensions.kt
StringExtensions.kt
CollectionExtensions.kt
하지만 공식 컨벤션은 “어떤 클래스의 모든 확장 함수를 한 파일에 모으는 것”을 피하라고 말한다.
확장 함수가 특정 클래스의 모든 사용처에 의미 있다면 해당 클래스와 같은 파일에 두고, 특정 클라이언트에서만 의미 있다면 그 클라이언트 코드 근처에 두는 방식이 권장된다.
// User를 다루는 모든 곳에서 의미 있는 확장
fun User.displayName(): String = "$lastName$firstName"
// 특정 화면, 특정 API 응답 조립에서만 의미 있는 확장
private fun User.toProfileResponse(): ProfileResponse = ...
확장 함수는 전역 함수처럼 퍼지기 쉽다. 그래서 위치와 visibility를 같이 관리해야 한다.
클래스 내부 순서는 “가독성 흐름”을 우선하기
코틀린 공식 컨벤션은 클래스 내부 선언 순서를 다음과 같이 권장한다.
프로퍼티 선언과 initializer block
보조 생성자
메서드
companion object
하지만 메서드를 알파벳순이나 visibility 순서로 정렬하지 말라고도 명시한다. 대신 관련 있는 코드를 함께 배치해서, 위에서 아래로 읽을 때 로직의 흐름을 따라갈 수 있도록 두는 것이 좋다.
class OrderService(
private val orderRepository: OrderRepository,
) {
fun placeOrder(command: PlaceOrderCommand): Order {
validate(command)
return createOrder(command)
}
private fun validate(command: PlaceOrderCommand) {
// ...
}
private fun createOrder(command: PlaceOrderCommand): Order {
// ...
}
}
단순히 public -> private 순서로 분리하면, 실제로는 한 기능을 이해하기 위해 파일 위아래를 계속 이동해야 하는 경우가 생긴다.
오버로드 함수는 반드시 붙여두기
오버로드된 함수는 클래스 안에서 서로 가까이 배치하는 것이 권장된다.
fun findUser(id: Long): User {
// ...
}
fun findUser(username: String): User {
// ...
}
오버로드 함수가 파일 여기저기에 흩어져 있으면 API의 전체 형태를 파악하기 어렵다. 특히 서비스 클래스나 팩토리 객체에서 이 문제가 자주 발생한다.
콜론 앞 공백 규칙은 상황별로 다르다
코틀린에서 : 앞 공백은 항상 같은 규칙이 아니다.
선언과 타입을 구분할 때는 콜론 앞에 공백을 두지 않는다.
val name: String
fun find(id: Long): User
반면 타입과 상위 타입을 구분하거나, 생성자 위임을 할 때는 콜론 앞에 공백을 둔다.
class UserService : Service
constructor(name: String) : this(name, 0)
콜론 뒤에는 항상 공백을 둔다.
이 규칙은 자동 포맷터가 잡아주긴 하지만, 수동으로 코드를 읽을 때도 꽤 눈에 띄는 부분이다.
수평 정렬을 피하기
코드를 보기 좋게 만들겠다는 이유로 다음처럼 맞추는 경우가 있다.
val id = 1L
val username = "kim"
val createdAt = now()
하지만 Kotlin 컨벤션은 수평 정렬을 피하라고 한다. 식별자의 길이가 바뀌어도 선언부나 사용부의 포맷이 영향을 받지 않아야 하기 때문이다.
val id = 1L
val username = "kim"
val createdAt = now()
수평 정렬은 처음에는 깔끔해 보이지만, 리팩터링 시 불필요한 diff를 많이 만든다.
단일 표현식 함수는 expression body를 선호하기
함수 본문이 단일 표현식이라면 expression body를 사용하는 것이 권장된다.
// 덜 권장
fun isActive(): Boolean {
return status == Status.ACTIVE
}
// 권장
fun isActive(): Boolean = status == Status.ACTIVE
타입 추론이 명확한 경우에는 반환 타입도 생략할 수 있다.
fun isActive() = status == Status.ACTIVE
다만 public API나 라이브러리 코드에서는 반환 타입을 명시하는 편이 안전하다. 구현 변경에 따라 외부 API의 타입이 의도치 않게 바뀔 수 있기 때문이다.
Unit 반환 타입은 생략하기
Unit을 반환하는 함수에서는 반환 타입을 생략하는 것이 권장된다.
// 불필요
fun save(): Unit {
// ...
}
// 권장
fun save() {
// ...
}
자바의 void에 익숙하면 Unit을 명시하고 싶어질 수 있지만, 코틀린에서는 대부분 노이즈에 가깝다.
문자열 템플릿에서 불필요한 중괄호 생략하기
단순 변수 삽입에는 중괄호를 사용하지 않는다.
// 불필요
println("${name} logged in")
// 권장
println("$name logged in")
표현식이 들어갈 때만 중괄호를 사용한다.
println("$name has ${children.size} children")
단순 변수에는 중괄호를 쓰지 않고, 긴 표현식에만 사용하는 편이 더 간결하다.
var보다 val, mutable collection보다 read-only interface
코틀린 컨벤션은 변경 가능한 데이터보다 불변 데이터를 선호한다. 초기화 이후 값이 바뀌지 않는다면 var가 아니라 val을 사용한다.
또한 컬렉션을 변경하지 않는다면 MutableList, HashSet 같은 구현체나 mutable 타입 대신 List, Set, Map 같은 read-only 인터페이스로 선언하는 것이 좋다.
// 덜 권장
val allowedValues: HashSet<String> = hashSetOf("A", "B")
// 권장
val allowedValues: Set<String> = setOf("A", "B")
중요한 점은 Kotlin의 List가 “완전한 불변 컬렉션”이라는 뜻은 아니라는 점이다. 인터페이스상 변경 메서드를 노출하지 않는다는 의미에 가깝다. 그래도 API 사용자에게 “이 컬렉션을 수정하지 않는다”는 의도를 전달하는 데 유용하다.
오버로드보다 기본 파라미터 우선
단순히 기본값을 제공하기 위한 오버로드는 피하고, default parameter를 사용하는 것이 권장된다.
// 덜 권장
fun search() = search(limit = 10)
fun search(limit: Int) {
// ...
}
// 권장
fun search(limit: Int = 10) {
// ...
}
코틀린에서는 기본 파라미터가 언어 차원에서 지원되므로, 자바식 오버로드를 그대로 가져올 필요가 적다.
단, 자바 호출부와의 호환성이 필요하다면 @JvmOverloads 사용 여부를 별도로 판단해야 한다.
Boolean 인자와 primitive 인자는 named argument 사용하기
동일한 primitive 타입 인자가 여러 개 있거나, Boolean 인자가 있는 함수 호출에서는 named argument를 사용하는 것이 권장된다.
// 의미 파악이 어려움
drawSquare(10, 10, 100, 100, true)
// 권장
drawSquare(
x = 10,
y = 10,
width = 100,
height = 100,
fill = true,
)
Boolean 인자는 특히 위험하다.
sendEmail(user, true)
위 코드만 봐서는 true가 즉시 발송인지, HTML 사용 여부인지, 재시도 여부인지 알 수 없다.
sendEmail(
user = user,
immediate = true,
)
호출부에서 의미가 드러나도록 하는 것이 좋다.
if, when, try는 expression 형태를 선호하기
코틀린에서는 if, when, try가 값을 반환할 수 있다. 공식 컨벤션은 이런 구문을 statement보다 expression으로 사용하는 것을 선호한다.
// 덜 권장
if (enabled) {
return start()
} else {
return stop()
}
// 권장
return if (enabled) {
start()
} else {
stop()
}
when도 마찬가지다.
return when (status) {
Status.ACTIVE -> "active"
Status.INACTIVE -> "inactive"
}
이 방식은 반환 지점을 줄이고, “이 조건문이 값을 결정한다”는 의도를 분명하게 만든다.
선택지가 둘이면 if, 셋 이상이면 when
코틀린을 쓰다 보면 모든 조건 분기를 when으로 쓰고 싶어질 때가 있다. 하지만 공식 컨벤션은 binary condition에는 if를 선호하고, 선택지가 세 개 이상일 때 when을 선호한다.
// 권장
if (user == null) {
// ...
} else {
// ...
}
// 선택지가 많을 때 적합
when (status) {
Status.READY -> ...
Status.RUNNING -> ...
Status.FINISHED -> ...
}
when은 강력하지만, 단순한 양자택일에는 오히려 과하다.
nullable Boolean은 == true, == false로 비교하기
nullable Boolean을 조건문에 사용할 때는 다음 형태가 권장된다.
if (enabled == true) {
// true인 경우만
}
if (enabled == false) {
// false인 경우만
}
Boolean?에는 true, false, null 세 상태가 있다. 따라서 조건문에서 어떤 상태를 처리하는지 명확히 드러내는 편이 좋다.
forEach를 무조건 선호하지 않기
코틀린에서는 map, filter, forEach 같은 고차 함수를 자주 쓴다. 하지만 공식 컨벤션은 forEach의 경우 일반적인 for 루프를 선호하라고 한다.
예외는 receiver가 nullable이거나, 긴 call chain의 일부로 사용되는 경우다.
// 보통은 이쪽이 더 명확함
for (user in users) {
user.activate()
}
// nullable receiver 또는 체인의 일부라면 forEach도 자연스러움
users
?.filter { it.isActive }
?.forEach { it.notify() }
forEach는 함수형 스타일처럼 보이지만, 단순 반복 부수효과에는 오히려 for가 읽기 쉽다.
반열린 범위 반복에는 ..< 사용하기
인덱스 기반 반복에서 다음과 같은 코드를 자주 본다.
for (i in 0..n - 1) {
// ...
}
공식 컨벤션은 open-ended range에는 ..< 연산자를 사용하라고 권장한다.
for (i in 0..<n) {
// ...
}
0 until n도 많이 쓰이지만, 최근 코틀린에서는 ..<가 더 직접적으로 “끝값 미포함”을 표현한다.
인자 목록에는 trailing comma를 적극 고려하기
Kotlin 스타일 가이드는 선언부에서는 trailing comma 사용을 권장하고, 호출부에서는 재량에 맡긴다. trailing comma는 마지막 요소 뒤에 붙는 쉼표다.
data class User(
val id: Long,
val name: String,
val email: String,
)
장점은 명확하다.
data class User(
val id: Long,
val name: String,
+ val email: String,
)
마지막 줄을 수정하지 않고 새 항목만 추가되므로 diff가 깔끔해진다. 또한 요소 추가와 재정렬도 쉬워진다.
IntelliJ IDEA에서는 다음 경로에서 활성화할 수 있다.
Settings/Preferences
-> Editor
-> Code Style
-> Kotlin
-> Other
-> Use trailing comma
KDoc에서 @param, @return을 습관적으로 쓰지 않기
KDoc에서는 긴 설명이 필요한 경우가 아니라면 @param, @return 태그를 남발하지 않는 것이 권장된다. 대신 본문 안에서 파라미터를 링크로 언급하는 방식이 선호된다.
// 덜 권장
/**
* Returns the absolute value of the given number.
*
* @param number The number to return the absolute value for.
* @return The absolute value.
*/
fun abs(number: Int): Int
// 권장
/**
* Returns the absolute value of the given [number].
*/
fun abs(number: Int): Int
짧은 설명에 기계적으로 @param, @return을 붙이면 문서가 장황해진다.
함수처럼 보이는 프로퍼티, 프로퍼티처럼 보이는 함수 구분하기
인자가 없는 함수는 read-only property와 비슷해 보일 때가 있다.
val User.isActive: Boolean
fun User.isActive(): Boolean
공식 컨벤션은 다음 조건을 만족하면 함수보다 프로퍼티를 선호한다고 설명한다.
예외를 던지지 않음
계산 비용이 작거나 캐시됨
객체 상태가 변하지 않으면 호출 결과도 같음
예를 들어 단순 상태 조회는 프로퍼티가 어울린다.
val User.isAdult: Boolean
get() = age >= 19
반면 비용이 크거나, 매번 다른 결과가 나올 수 있거나, 실패 가능성이 있다면 함수가 더 자연스럽다.
fun User.fetchLatestLoginHistory(): List<LoginHistory>
infix는 “멋있어 보여서” 쓰지 않기
infix 함수는 두 객체가 비슷한 역할을 할 때만 선언하는 것이 좋다. 공식 문서는 좋은 예로 and, to, zip을 들고, 나쁜 예로 add를 든다. 또한 receiver를 변경하는 메서드에는 infix를 선언하지 말라고 한다.
// 자연스러운 infix
a to b
// 애매함
cart add item
infix는 DSL을 만들 때 유용하지만, 일반 비즈니스 코드에서 남발하면 오히려 검색성과 명확성이 떨어진다.
Java API에서 온 platform type은 public 경계에서 타입 명시하기
Java API를 호출하면 Kotlin에서는 platform type이 생긴다. 이 값이 public 함수나 public 프로퍼티의 반환값으로 나갈 때는 Kotlin 타입을 명시해야 한다.
// 권장
fun apiCall(): String = MyJavaApi.getProperty("name")
프로퍼티도 마찬가지다.
class Person {
val name: String = MyJavaApi.getProperty("name")
}
Java와 Kotlin을 함께 쓰는 프로젝트에서는 이 부분이 특히 중요하다. nullable 여부가 흐려진 값이 public API로 퍼지면, 이후 호출부에서 NPE 가능성을 판단하기 어려워진다.