Kotlin
고친 사람 github-actions[bot]
Kotlin 은 서버 프로그램과 Android 앱을 짜는 데 쓰는 프로그래밍 언어입니다. Java 로 짜 둔 코드와 한 프로젝트 안에서 섞어 씁니다. 값이 비어 있을 수 있는 변수를 타입에 표시하게 해서, 실행 중에야 드러나던 오류를 컴파일할 때 걸러 줍니다.
쉽고 빠른 이해
Kotlin 은 Java 코드와 한 프로젝트에 섞어 쓰는 언어입니다. 값이 비었는지는 컴파일러가 대신 따집니다. 값이
없을 수도 있는 변수는 타입 뒤에 물음표를 붙여 String? 이라고 적습니다.
비어 있는 변수를 건드려 프로그램이 멈추는 것은 Java 에서 흔한 실수입니다. 그렇다고 쌓아 둔 Java 코드와 라이브러리를 버릴 수도 없습니다. Kotlin 은 둘을 같이 가져가려고 만든 언어입니다.
어떻게 도는가:
- 물음표를 붙인 타입의 변수만 비어 있을 수 있습니다
- 그런 변수는 비었는지 확인하기 전에는 못 쓰게 컴파일러가 막습니다
- 컴파일 결과가 Java 와 같은 형식이라 Java 라이브러리를 손대지 않고 부릅니다
대가도 있습니다. Java 쪽에서 건너온 값은 비었는지 컴파일러가 몰라서 검사가 거기서 느슨해집니다. 클래스는 기본으로 상속이 막혀 있어서, 클래스를 상속해 기능을 덧입히는 Spring 과 쓸 때는 플러그인을 하나 더 켭니다.
상세
이 절은 Kotlin 이 어떤 언어인지 정합니다. 그다음 Java 개발자가 처음 만나는 문법 여섯을 차례로 짚습니다. 견주는 상대는 Java 입니다. Kotlin 이 고쳤다고 밝힌 문제 여럿이 Java 에서 겪던 것입니다. Java 쪽 모습을 옆에 두면 각 문법이 왜 있는지 드러납니다.
컴파일 대상과 Java 와의 상호 운용
Kotlin 은 오픈 소스 정적 타입 프로그래밍 언어입니다. 정적 타입이란 변수와 식의 타입이 컴파일할 때 정해진다는 뜻입니다. JetBrains 가 만들고 있습니다. 2010년에 시작했고 첫 정식판 1.0 은 2016년 2월에 나왔습니다.
한 언어를 여러 대상으로 컴파일합니다. 대상은 JVM(Java Virtual Machine, 자바 가상 머신) · Android · JavaScript · WebAssembly · 네이티브 기계어입니다. 이 절은 JVM 쪽만 봅니다. 나머지는 아래 「갈래」 절이 다룹니다.
JVM 을 대상으로 하면 Kotlin 컴파일러는 Java 와 호환되는 바이트코드를 내놓습니다. 바이트코드는 실제 칩이 아니라 JVM 이 알아듣도록 정한 명령입니다. JVM 은 바이트코드를 읽을 뿐, 그것을 만든 소스가 무슨 언어였는지는 보지 않습니다.
flowchart TD
K["Kotlin 소스"] --> KC["Kotlin 컴파일러"]
J["Java 소스"] --> JC["Java 컴파일러 javac"]
KC --> B["Java 와 호환되는 바이트코드"]
JC --> B
B --> V["JVM"]
두 컴파일러는 받는 소스가 다르지만 내놓는 것은 같은 형식입니다. 여기에 더해 JetBrains 는 이미 쌓아 둔 코드베이스가 Kotlin 과 제대로 맞물리게 하는 데 공을 들였다고 밝혔습니다. Kotlin 코드에서 Java 코드를 부르고, Java 코드에서 Kotlin 코드를 부를 수 있습니다. 이렇게 두 언어가 한 프로그램 안에서 서로 부르는 것을 상호 운용이라고 합니다.
기본으로는 Java 8 과 호환되는 바이트코드가 나옵니다. 더 새로운 Java 의 최적화를 쓰고 싶으면 대상 Java 판을 9 부터 26 까지 골라 적을 수 있습니다. 이때 나온 바이트코드는 더 낮은 판에서 돌지 않을 수 있습니다.
JVM 과 호환되므로 서버 쪽에서는 Spring Boot · vert.x 같은 기존 프레임워크를 씁니다. Ktor 처럼 Kotlin 으로 짠 프레임워크도 있습니다. JVM 쪽 빌드 도구는 주로 Gradle 과 Maven 입니다.
값이 비어 있을 수 있는 타입
Java 의 참조 변수는 무엇이든 null 일 수 있습니다. null 은 아무 객체도 가리키지 않는 상태입니다. null 을 가리키는 변수로 멤버를 건드리면 NPE(NullPointerException)가 납니다. 이것은 Java 를 비롯한 여러 언어에서 가장 흔한 함정 가운데 하나입니다. null 참조에는 「10억 달러짜리 실수」라는 별명도 붙어 있습니다.
Kotlin 은 null 이 될 수 있는지를 타입의 일부로 다룹니다. String 타입 변수에는 null 을 넣을 수
없습니다. 물음표를 붙인 String? 에만 넣을 수 있습니다. 컴파일러는 둘을 다른 타입으로 봅니다.
var a: String = "abc"
a = null // 컴파일 오류
var b: String? = "abc"
b = null // 통과
값이 비었을 수 있는 변수는 쓰기 전에 확인해야 합니다. 확인 문장을 줄마다 적지 않아도 되게
안전 호출 연산자 ?. 를 둡니다. 앞의 값이 null 이 아니면 멤버를 부릅니다. null 이면 NPE 를
던지는 대신 null 을 돌려줍니다.
b?.length // b 가 null 이면 null. 이 식의 타입은 Int?
bob?.department?.head?.name // 사슬 중 하나라도 null 이면 null
비었을 때 쓸 값을 따로 정하려면 엘비스 연산자 ?: 를 씁니다. 왼쪽이 null 이 아니면 왼쪽을,
null 이면 오른쪽을 돌려줍니다. 오른쪽은 왼쪽이 null 일 때만 계산합니다.
val l = b?.length ?: 0
val parent = node.getParent() ?: return null
val name = node.getName() ?: throw IllegalArgumentException("name expected")
return 과 throw 도 식이라 엘비스 연산자 오른쪽에 올 수 있습니다. 그래서 「없으면 여기서
끝낸다」를 한 줄로 적습니다.
이렇게 해도 Kotlin 에서 NPE 가 나는 길은 몇 갈래 남습니다. 직접 throw NullPointerException()
을 부를 때, null 이 아니라고 단언하는 !! 연산자를 쓸 때, 초기화가 덜 끝난 객체를 건드릴 때,
그리고 Java 코드와 섞어 쓸 때입니다. 마지막 경우는 「포기한 것」 절이 다룹니다.
val · var 와 타입 추론
변수는 val 이나 var 로 시작해 선언합니다. val 은 값을 한 번만 넣는 변수입니다. 초기화한 뒤에
다른 값을 다시 넣을 수 없습니다. var 은 다시 넣을 수 있는 변수입니다.
타입은 적지 않아도 됩니다. 컴파일러가 넣은 값을 보고 변수의 타입을 정하기 때문입니다. 이렇게 값에서 타입을 알아내는 것을 타입 추론이라고 합니다.
val x = 5 // Int 타입으로 추론된다
데이터 클래스
값을 담는 것이 주된 일인 클래스에는 data 를 붙입니다. 이런 클래스를 데이터 클래스라고 합니다.
인스턴스를 견주고 글자로 찍고 복사하는 메서드를 컴파일러가 대신 만들어 줍니다.
data class User(val name: String, val age: Int)
클래스 이름 바로 뒤 괄호에 적은 것이 주 생성자입니다. 거기 적은 val name 과 val age 가
프로퍼티입니다. 프로퍼티는 값을 담는 멤버입니다. Java 의 필드와 달리 값을 읽고 쓰는 메서드가
따라 생깁니다.
컴파일러는 주 생성자에 선언한 프로퍼티 전부를 보고 아래 멤버를 대신 만듭니다.
| 만드는 멤버 | 하는 일 |
|---|---|
equals() · hashCode() |
두 인스턴스를 견준다 |
toString() |
User(name=John, age=42) 꼴 글자를 돌려준다 |
componentN() |
선언한 순서대로 프로퍼티를 하나씩 꺼낸다 |
copy() |
인스턴스를 복사한다 |
클래스 몸통 안에 선언한 프로퍼티는 이 멤버들을 만들 때 빠집니다. 데이터 클래스는 abstract ·
open · sealed · inner 가 될 수 없습니다. 여기 나온 open 은 이 클래스의 상속을 허락한다는
표시입니다. 아래 「포기한 것」 절이 다룹니다.
파일 최상위 함수
Java 에서는 모든 메서드가 클래스 안에 있어야 합니다. Kotlin 은 클래스 없이 파일에 바로 함수를 적어도 됩니다. 이런 함수를 최상위 함수라고 합니다.
컴파일하면 이 함수들은 컴파일러가 파일마다 하나씩 지어 주는 클래스에 들어갑니다. org.example
패키지의 app.kt 에 적은 함수와 프로퍼티는 org.example.AppKt 라는 Java 클래스의 정적 메서드가
됩니다. 아래 소절에서 볼 확장 함수도 파일에 적었으면 여기 들어갑니다.
// app.kt
package org.example
class Util
fun getTime() { /*...*/ }
// Java
new org.example.Util();
org.example.AppKt.getTime();
Java 에서 부를 때는 파일 이름을 딴 AppKt 를 씁니다. 같은 파일에 클래스로 적은 Util 은 제 이름
그대로 org.example.Util 로 남습니다. 옮겨 담기는 것은 클래스 밖에 적은 함수와 프로퍼티뿐입니다.
AppKt 라는 이름이 마음에 안 들면 파일 맨 위에 @file:JvmName("DemoUtils") 를 적어 바꿉니다.
확장 함수
확장 함수는 이미 있는 클래스나 인터페이스에 새 기능을 붙이는 문법입니다. 상속도 데코레이터 같은 설계 패턴도 쓰지 않습니다. 소스를 고칠 수 없는 외부 라이브러리의 클래스에 붙일 때 쓸모가 있습니다. 붙인 뒤에는 원래 멤버인 것처럼 부릅니다.
확장 함수는 그 클래스를 고치지 않습니다. 멤버를 더하는 것이 아니라, 같은 문법으로 부를 수 있는
함수를 새로 만들 뿐입니다. 파일에 적은 확장 함수는 앞 소절에서 본 AppKt 같은 클래스의 정적
메서드로 컴파일됩니다.
함수 이름 앞에 점을 찍어 붙인 타입이 이 함수를 받는 쪽입니다. 아래 예의 fun Shape.getName() 은
Shape 에 붙인 확장 함수입니다.
어느 확장 함수를 부를지는 컴파일할 때 정해집니다. 기준은 실행 중의 진짜 객체가 아니라, 받는 쪽 변수에 선언한 타입입니다.
open class Shape
class Rectangle: Shape()
fun Shape.getName() = "Shape"
fun Rectangle.getName() = "Rectangle"
fun printClassName(shape: Shape) {
println(shape.getName())
}
printClassName(Rectangle()) // Shape
넘긴 것은 Rectangle 인데 Shape 가 찍힙니다. 매개변수 shape 를 Shape 로 선언했기 때문입니다.
같은 이름과 맞는 인자를 가진 멤버 함수가 클래스에 이미 있으면 멤버 함수가 이깁니다.
flowchart TD
C["shape.getName() 호출"] --> Q1{"같은 이름 · 맞는 인자의 멤버 함수가 있나"}
Q1 -->|있다| M["멤버 함수를 부른다"]
Q1 -->|없다| Q2{"받는 쪽 변수에 선언한 타입은"}
Q2 -->|"Shape"| E1["Shape.getName() 확장 함수"]
Q2 -->|"Rectangle"| E2["Rectangle.getName() 확장 함수"]
코루틴
한 프로그램 안에서 여러 일을 겹쳐 진행하는 것을 동시성이라고 합니다. 서버가 요청 여럿을 한꺼번에 받으려면 동시성이 있어야 합니다. 흔한 방법은 스레드입니다. 스레드는 운영체제가 관리하는 독립된 실행 흐름입니다.
스레드는 자원을 많이 씁니다. 스레드를 만들면 운영체제가 그 스택에 쓸 메모리를 떼어 줍니다. 스레드 하나가 대개 몇 메가바이트를 씁니다. JVM 은 보통 한 번에 수천 개까지만 감당합니다.
Kotlin 은 여기에 코루틴을 둡니다. 코루틴은 도중에 멈췄다가 나중에 그 지점부터 이어 도는 실행 단위입니다. 멈춰 있는 동안 스레드를 붙잡지 않으므로, 그 스레드는 다른 일을 돌릴 수 있습니다.
sequenceDiagram
participant A as 코루틴 A
participant T as 스레드
participant B as 코루틴 B
A->>T: 실행한다
A->>T: 데이터를 기다리며 멈춘다 · 스레드를 내놓는다
B->>T: 같은 스레드에서 실행한다
Note over A: 데이터가 오면 이어 돈다 · 다른 스레드일 수도 있다
코루틴은 한 스레드에 묶이지 않습니다. 한 스레드에서 멈췄다가 다른 스레드에서 이어 갈 수 있어서, 코루틴 여럿이 스레드 풀 하나를 나눠 쓸 수 있습니다. 덕분에 시스템 자원을 바닥내지 않고 한 프로세스에서 코루틴을 수백만 개 돌립니다. JVM 과 Kotlin/Native 에서는 코루틴도 결국 운영체제가 관리하는 스레드 위에서 돕니다. Kotlin/Native 는 가상 머신 없이 도는 바이너리로 컴파일하는 갈래입니다. 아래 「갈래」 절이 다룹니다.
멈출 수 있는 함수에는 suspend 를 붙입니다. 이 함수는 스레드를 막지 않고 멈췄다 이어 갈 수
있습니다.
suspend fun greet() {
println("The greet() on the thread: ${Thread.currentThread().name}")
// 1초 동안 멈추고 스레드를 내놓는다
delay(1.seconds)
}
suspend 함수는 다른 suspend 함수 안에서만 부를 수 있습니다. 그러면 suspend 가 안 붙은
보통 함수에서 첫 번째 suspend 함수를 부를 길이 필요합니다. 코루틴을 하나 띄우고 그 안에서
부르면 됩니다. 코루틴을 띄우는 함수를 코루틴 빌더라고 합니다. launch() 와 async() 가
그것입니다.
suspend 라는 낱말은 언어에 들어 있지만, 코루틴을 띄우고 다루는 기능 대부분은
kotlinx.coroutines 라이브러리가 줍니다. 빌더도 그 라이브러리가 줍니다. 빌더는
CoroutineScope 의 확장 함수로 되어 있습니다. CoroutineScope 는 거기서 띄운 코루틴이
언제까지 살아 있는지를 정합니다.
flowchart TD
S["CoroutineScope · 코루틴이 언제까지 사나를 정한다"] --> B["코루틴 빌더 launch() · async()"]
B --> C["새 코루틴"]
C --> F1["suspend 함수"]
F1 --> F2["다른 suspend 함수"]
포기한 것
Kotlin 이 안 하기로 한 것 셋입니다. 셋 다 Java 와 다르게 정한 결정입니다. 저마다 무엇을 얻었는지, 그리고 Java 코드나 Java 프레임워크와 섞을 때 어떤 값을 치르는지를 짝으로 적습니다. 값을 치르는 곳은 셋 다 Java 와 맞닿는 경계입니다. 그래서 Java 로 짠 코드와 Java 프레임워크를 많이 끌어다 쓰는 프로젝트일수록 Kotlin 에서 얻는 것이 줄어듭니다.
클래스를 기본으로 닫아 둔다
Kotlin 클래스는 기본으로 final 입니다. 상속할 수 없다는 뜻입니다. 상속을 허락하려면 open 을
붙입니다. 멤버 함수도 같아서, 재정의를 허락하려면 open 을 붙여야 합니다.
open class Base // 상속할 수 있다
Java 는 거꾸로입니다. 아무것도 안 붙이면 상속할 수 있습니다. 막으려면 final 을 붙입니다.
Kotlin 을 만든 쪽은 이 기본값이 『Effective Java』 3판 항목 19 「상속을 염두에 두고 설계하고
문서화하라, 그러지 않을 거면 상속을 금지하라」에 맞춘 것이라고 밝혔습니다. 얻는 것은 상속받을
생각으로 짓지 않은 클래스가 상속되는 일이 기본으로 막힌다는 것입니다. 상속을 열려면 짓는 쪽이
open 이라고 적어야 합니다. 열어 둔 클래스는 코드에 그렇게 적혀 있습니다. 재정의를 허락할 멤버에
open 을 붙이게 한 것도 같은 결정입니다. 드러나게 적게 하는 쪽을 고수한다고 설명했습니다.
값은 Spring 에서 치릅니다. Spring 은 자기가 만들어 관리하는 객체인 빈을 대개 CGLIB(Code Generation
Library) 로 프록시합니다. 프록시는 원래 객체 대신 호출을 받아 넘겨 주는 대리 객체입니다. CGLIB 는
이 프록시를 원래 클래스를 실행 중에 상속해서 만듭니다. @Configuration 클래스가 기본으로 이렇게
상속됩니다.
클래스가 final 이면 이 상속이 안 됩니다. 그래서 이 점을 챙기지 않으면 애플리케이션이 시작하지
못할 수 있습니다. 프록시되는 빈의 클래스와 멤버 함수마다 open 을 붙이면 풀립니다. 이 방법은 금방
번거로워질 수 있습니다. 코드를 간결하고 예측할 수 있게 두려는 Kotlin 의 원칙과도 어긋납니다.
flowchart TD
B["CGLIB 로 프록시하는 Spring 빈 · 예: @Configuration 클래스"] --> Q{"그 클래스를 상속할 수 있나"}
Q -->|"Kotlin 기본값 final"| X["프록시를 못 만든다 · 애플리케이션이 시작하지 못할 수 있다"]
Q -->|"open 을 붙였다"| O["실행 중에 상속해 프록시를 만든다"]
이 번거로움을 덜려고 all-open 컴파일러 플러그인을 따로 둡니다. 정해 둔 애너테이션이 붙은
클래스와 그 멤버를 open 없이 열어 줍니다. Spring 용으로 미리 맞춘 것이 kotlin-spring
플러그인입니다. 동작은 all-open 과 같습니다.
@Service 같은 애너테이션은 그 정의에 @Component 가 붙어 있습니다. 이렇게 애너테이션에 붙은
애너테이션을 메타 애너테이션이라고 합니다. 플러그인은 메타 애너테이션까지 따라가서 클래스를 엽니다.
| 무엇이 열리나 | 애너테이션 |
|---|---|
| 플러그인이 직접 정한 것 | @Component · @Async · @Transactional · @Cacheable · @SpringBootTest |
@Component 를 메타 애너테이션으로 달고 있어서 열리는 것 |
@Configuration · @Controller · @RestController · @Service · @Repository |
start.spring.io 로 만든 프로젝트는 kotlin-spring 이 기본으로 켜져 있습니다. 그래서 Java 에서처럼
open 없이 빈을 적습니다. 설정 클래스는 @Configuration(proxyBeanMethods = false) 로 CGLIB 프록시를
아예 피하는 길도 있습니다.
틈이 하나 남습니다. 프로퍼티가 있는 클래스는 컴파일러가 final 인 getter · setter 를 만듭니다. 이
메서드까지 프록시하려면 메서드에 붙이는 @Bean 대신 타입에 붙이는 @Component 를 고릅니다.
플러그인이 여는 단위는 애너테이션이 붙은 타입과 그 타입의 멤버 함수이기 때문입니다. 메서드에
붙인 애너테이션은 그 단위에 들지 않습니다.
체크 예외를 두지 않는다
Java 의 체크 예외는 메서드가 던질 수 있는 예외를 시그니처의 throws 에 적게 하는 예외입니다.
부르는 쪽은 그 예외를 잡거나 자기 throws 에 다시 적어야 합니다. 컴파일러가 이를 강제합니다.
Kotlin 은 모든 예외를 언체크 예외로 다룹니다. 예외를 잡을 수는 있지만, 처리하거나 선언하라고 강제하지 않습니다. 그 덕에 예외 처리가 단순해집니다.
이유는 여럿이었습니다. Kotlin 을 만든 쪽은 그중 하나를 보이는 예로 JDK(Java Development Kit) 의
Appendable 인터페이스를 들었습니다. StringBuilder 가 구현하는 인터페이스입니다.
Appendable append(CharSequence csq) throws IOException;
이 시그니처대로면 StringBuilder 에 문자열을 하나 붙일 때마다 IOException 을 잡아야 합니다.
Writer 처럼 입출력을 하는 구현도 같은 인터페이스를 쓰기 때문입니다.
classDiagram
class Appendable {
<<interface>>
append(CharSequence)
}
Appendable <|.. StringBuilder : 입출력을 안 한다
Appendable <|.. Writer : 입출력을 한다
note for Appendable "시그니처에 throws IOException 이 붙어 있다"
입출력을 하는 구현과 안 하는 구현이 한 인터페이스를 씁니다. 시그니처의 예외는 양쪽이 같이 떠안습니다. 그 결과 Java 쪽에 아래 같은 코드가 곳곳에 생깁니다.
try {
log.append(message)
} catch (IOException e) {
// Must be safe
}
예외를 받아 놓고 아무것도 안 하는 코드입니다. 「예외를 무시하지 말라」는 『Effective Java』 3판 항목 77 과 어긋납니다.
Bruce Eckel 은 체크 예외를 두고 이렇게 봤습니다. 작은 프로그램만 살피면 예외 명세를 강제하는 것이 생산성과 코드 품질을 둘 다 올릴 수 있다는 결론이 나옵니다. 큰 소프트웨어 프로젝트의 경험은 다른 결과를 가리킵니다. 생산성은 떨어지고 코드 품질은 거의 또는 전혀 오르지 않습니다.
대신 Java 와 맞닿는 경계에서 값을 치릅니다. Kotlin 함수의 Java 시그니처에는 보통 던지는 예외가 적히지
않습니다. 그래서 Kotlin 함수가 IOException 을 던져도, Java 에서 그것을 잡으려 하면 Java 컴파일러가
막습니다.
// Java
try {
demo.Example.writeToFile();
} catch (IOException e) {
// error: writeToFile() does not declare IOException in the throws list
}
이 오류를 피하려면 Kotlin 쪽 함수에 @Throws 를 붙여 Java 시그니처에 예외를 적어 줍니다. 아래
「갈래」 절의 Kotlin/Native 처럼 Kotlin 코드를 Swift · Objective-C 에서 부를 때도 같은 애너테이션을
씁니다.
@Throws(IOException::class)
fun writeToFile() {
throw IOException()
}
반대 방향은 막히지 않습니다. Kotlin 에서 체크 예외를 선언한 Java 메서드를 불러도 아무것도 강제받지 않습니다.
Java 에서 온 값의 null 검사를 늦춘다
Java 의 참조는 무엇이든 null 일 수 있습니다. 그래서 Java 에서 온 객체에 Kotlin 의 엄격한 null 검사를 걸면 다루기가 어렵습니다. Kotlin 은 Java 선언의 타입을 플랫폼 타입으로 다룹니다. 이 타입에는 null 검사를 늦춥니다. 얻는 것은 Java 에서 온 값을 Java 에서 쓰던 것처럼 바로 다룬다는 것입니다.
플랫폼 타입은 코드에 적을 수 없는 타입입니다. 설명할 때는 T! 라고 씁니다. 읽을 때는 「T 이거나
T?」라고 읽습니다. 이 타입의 안전 보장은 Java 와 같은 수준입니다.
val list = ArrayList<String>() // null 이 아니다 (생성자 결과)
list.add("Item")
val item = list[0] // 플랫폼 타입으로 추론된다
item.substring(1) // 허용된다. item 이 null 이면 예외가 난다
val nullable: String? = item // 허용된다. 언제나 된다
val notNull: String = item // 허용된다. 실행 중에 실패할 수 있다
플랫폼 타입 변수로 메서드를 부르면 컴파일러는 null 오류를 내지 않습니다. 대신 실행 중에 실패할 수 있습니다. 물음표 없는 타입으로 받아 두었다면, 컴파일러가 대입하는 곳에 확인 코드를 끼워 넣습니다. null 이 오면 거기서 NPE 가 납니다.
Java 쪽에 null 표시 애너테이션이 붙어 있으면 그 타입은 플랫폼 타입이 아니라 진짜 String? 이나
String 으로 들어옵니다. JetBrains 의 @Nullable · @NotNull 과 JSpecify 의 애너테이션이 그런
예입니다.
flowchart TD
J["Java 메서드가 돌려준 값"] --> Q{"null 표시 애너테이션이 붙었나"}
Q -->|붙었다| K["String? 이나 String 으로 들어온다 · 컴파일러가 검사한다"]
Q -->|안 붙었다| P["플랫폼 타입 String! · 검사를 늦춘다"]
P --> A["String 변수에 넣는 곳에 확인 코드 · null 이면 NPE"]
확인 코드는 Kotlin 의 null 아닌 변수가 null 을 쥐지 않게 막습니다. null 아닌 값을 받는 Kotlin
함수에 플랫폼 값을 넘길 때도 들어갑니다. 컴파일러는 null 이 프로그램 멀리까지 번지지 않게 애씁니다.
다만 제네릭 때문에 완전히 막지 못하는 경우가 있습니다. Java 코드가 Kotlin 의
MutableList<String> 에 null 을 넣는 것이 그런 예입니다. 이것을 제대로 다루려면
MutableList<String?> 이어야 합니다.
예시
도메인이 다른 세 곳입니다. 모바일 앱의 Android, 빌드 도구의 Gradle, 서버 프레임워크의 Spring Framework 입니다. 저마다 Kotlin 을 어디에 쓰는지, 왜 골랐는지를 짚습니다.
Android
Google 은 2019년 Google I/O 에서 Android 개발을 점점 더 Kotlin 우선으로 하겠다고 밝혔습니다. 그 뒤로 그 약속을 지켜 왔다고 Google 스스로 적습니다. Android 앱을 새로 만든다면 Kotlin 으로 시작하라고 권합니다.
이유로 꼽은 것은 넷입니다. 개발자 피드백을 모아 보니 Kotlin 지원을 더 늘려 달라는 요구가 뚜렷했다고 합니다.
| 이유 | 내세운 값 |
|---|---|
| 짧게 쓴다 | Kotlin 을 쓰는 현업 개발자의 67% 가 생산성이 올랐다고 답했다 |
| 실수를 덜 한다 | Kotlin 코드가 들어간 Android 앱은 비정상 종료 가능성이 20% 낮다 |
| Java 와 섞인다 | 프로젝트에 Kotlin 을 얼마만큼 들일지 고를 수 있다 |
| 구조화된 동시성 | 코루틴으로 네트워크 호출 같은 배경 작업을 다룬다 |
「Kotlin 우선」은 Java 를 버린다는 말이 아닙니다. Jetpack 라이브러리 · 예제 · 문서 같은 새 도구는 Kotlin 사용자를 먼저 생각해 짓습니다. Java 에서 API(Application Programming Interface, 프로그램끼리 부르는 약속) 를 쓰는 길도 계속 둡니다. Jetpack 라이브러리는 Google 이 Android 개발용으로 내놓는 라이브러리 묶음입니다. 그중 Jetpack Compose 는 Java 로는 못 쓰고 Kotlin 으로만 씁니다.
Google 자신의 앱도 70개 넘게 Kotlin 으로 짰습니다. Maps · Home · Play · Drive · Messages 가 그렇습니다. Google Home 팀은 새 기능 개발을 Kotlin 으로 옮긴 뒤 코드베이스 크기가 33% 줄고 NPE 로 인한 비정상 종료가 30% 줄었다고 밝혔습니다.
Gradle 빌드 스크립트
Gradle 은 빌드 스크립트를 Groovy 나 Kotlin 으로 적게 합니다. Kotlin 으로 적는 방식을 Kotlin
DSL(Domain-Specific Language, 한 가지 용도에 맞춘 언어) 이라고 부릅니다. Gradle 8.2 부터
gradle init 으로 새 빌드를 만들면 기본으로 Kotlin DSL 이 나옵니다. IntelliJ IDEA 2023.1 과
Android Studio Giraffe 부터는 새 프로젝트를 만들 때도 Kotlin DSL 이 기본입니다.
// build.gradle.kts
// Applies the Java plugin
plugins {
id("java")
}
repositories {
mavenCentral()
}
// Access to 'implementation' (contributed by the Java plugin) works here:
dependencies {
implementation("org.apache.commons:commons-lang3:3.12.0")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") // Add this if needed for runtime
}
plugins 블록은 Java 플러그인을 얹습니다. repositories 는 의존성을 받아 올 저장소로 Maven
Central 을 고릅니다. dependencies 는 컴파일과 테스트에 쓸 라이브러리를 적습니다. implementation
은 Java 플러그인이 보태 주는 설정이라 플러그인을 얹은 뒤에야 쓸 수 있습니다.
Gradle 이 Kotlin 을 고른 이유는 정적 타입 시스템과 짧은 문법, 함수형 문법입니다. 이것들이 읽기 쉽고 조립하기 쉬운 DSL 을 짓게 해 줍니다. Kotlin DSL 은 Kotlin 언어를 바탕으로 IntelliJ IDEA 와 Android Studio 가 빌드 스크립트를 IDE(Integrated Development Environment, 통합 개발 환경) 로서 온전히 돕게 합니다. 자동 완성 · 문서 바로 보기 · 소스로 건너가기 · 문맥을 아는 리팩터링이 됩니다. 제품 코드를 고칠 때와 같은 편집 경험이 빌드 스크립트에도 생깁니다.
대가도 있습니다. Gradle 이 이 기본값을 발표한 2023년 시점에서는, 공유하는 빌드 로직을 Kotlin DSL 로 고치면 스크립트 컴파일이 Groovy DSL 보다 느렸습니다. 그래서 복잡한 빌드 로직을 Groovy DSL 로 굴리는 아주 큰 프로젝트라면 옮기기를 기다리라고 권했습니다. Groovy DSL 지원도 기존 프로젝트를 위해 이어집니다.
Spring Framework
Spring Framework 는 Kotlin 을 일급으로 지원합니다. Kotlin 애플리케이션을 짤 때 Spring Framework 가 거의 원래 Kotlin 프레임워크였던 것처럼 쓰게 해 준다는 뜻입니다. 레퍼런스 문서의 예제 코드도 대부분 Java 와 Kotlin 두 벌로 실려 있습니다. Kotlin 으로 Spring 애플리케이션을 짓는 가장 쉬운 길은 Spring Boot 입니다.
Kotlin 과 붙는 대목은 둘입니다. 하나는 앞 「포기한 것」 절에서 본 기본 final 입니다. start.spring.io
로 만든 프로젝트는 kotlin-spring 플러그인이 켜져 있어서 빈마다 open 을 적지 않습니다.
다른 하나는 null 입니다. Java 타입 시스템에는 null 여부를 적을 칸이 없습니다. 그래서 Spring
Framework 는 API 전체에 JSpecify 애너테이션을 붙여 null 여부를 밝힙니다. Kotlin 2.1 부터는
org.jspecify.annotations 패키지의 애너테이션을 엄격하게 다룹니다. Spring API 에서 받은 값은
플랫폼 타입이 아니라 String? 이나 String 처럼 검사가 걸리는 타입으로 들어옵니다.
Spring 이 Kotlin 지원을 고른 이유는 공식 문서에 적혀 있지 않습니다(미확인).
배경
Kotlin 은 만든 회사 JetBrains 자신의 불편에서 나왔습니다. JetBrains 는 IntelliJ IDEA 를 비롯한 IntelliJ 기반 IDE 들을 거의 전부 Java 로 짜고 있었습니다. 첫째 목표는 자기 생산성이었습니다. 더 표현력이 큰 언어로 옮겨 더 많은 일을 하고 싶었습니다.
다만 두 가지는 양보할 수 없었습니다. 새 언어는 조금씩 들일 것이라 기존 Java 코드베이스와 매끄럽게 섞여야 했습니다. 그리고 이미 javac 로 컴파일하는 데 오래 걸리는 코드베이스라, 컴파일이 그보다 더 느려지면 안 됐습니다.
회사 쪽 셈도 있었습니다. JetBrains 는 JVM 라이브러리 생태계를 갈아엎지 않으니 사용자가 Spring 과 Hibernate 를 계속 쓸 것으로 봤습니다. 그런 프레임워크 지원은 유료판 IntelliJ IDEA Ultimate 에 남겨 IntelliJ IDEA 판매를 끌어올리리라 기대했습니다.
2016년 1.0 을 내며 JetBrains 는 이 설계를 「실용주의」라는 한 낱말로 줄였습니다. 우아함보다 쓸모가 먼저라서, 자체 패키지 관리자나 빌드 시스템을 만들지 않고 이미 있는 Maven 과 Gradle 을 썼습니다. 컬렉션도 새로 설계하지 않고 JDK 컬렉션과 호환되게 지었습니다. 경계에서 데이터를 바꾸는 일이 고통스럽기 때문입니다.
그때 이미 IntelliJ IDEA 와 YouTrack 을 비롯한 JetBrains 제품 열 가지 가까이가 Kotlin 을 쓰고 있었습니다. 이름이 어디서 왔는지는 공식 문서에 적혀 있지 않습니다(미확인).
갈래
같은 Kotlin 소스를 무엇으로 컴파일하느냐가 축입니다. 갈래 이름은 Kotlin/JVM · Kotlin/JS(JavaScript 대상) · Kotlin/Wasm(WebAssembly 대상) · Kotlin/Native 처럼 대상을 뒤에 붙여 짓습니다. 대상마다 나오는 결과물과 그것이 도는 환경, 불러 쓸 수 있는 코드가 갈립니다. 지금 상태는 아래와 같습니다.
| 갈래 | 무엇으로 컴파일하나 | 지금 상태 |
|---|---|---|
| Kotlin/JVM | Java 와 호환되는 바이트코드 | Stable |
| Kotlin/JS | JavaScript | Stable |
| Kotlin/Wasm | WebAssembly | Beta |
| Kotlin/Native | 가상 머신 없이 도는 네이티브 바이너리 | Stable |
| Kotlin Multiplatform | 위 대상 여럿에 코드를 나눠 쓴다 | Stable |
Kotlin/JVM
「상세」 절에서 본 갈래입니다. JVM 위에서 돕니다. Java 코드와 Java 라이브러리를 바로 부릅니다.
위 표의 「지금 상태」는 Kotlin 이 구성 요소마다 매기는 등급입니다. 새 구성 요소는 기본으로 실험(Experimental) 상태입니다. Kotlin/JVM 은 1.0 부터 Stable 이었습니다.
Kotlin/JS
Kotlin/JS 는 Kotlin 코드와 Kotlin 표준 라이브러리, 호환되는 의존성을 JavaScript 로 옮깁니다. 그래서 JavaScript 가 도는 곳이면 어디서든 돕니다. Node.js 대상을 고르면 서버나 서버리스 환경에서 도는 애플리케이션도 만듭니다.
백엔드를 Kotlin 이나 다른 JVM 언어로 짰다면 웹 애플리케이션과 백엔드가 코드를 나눠 쓸 수 있습니다.
DTO(Data Transfer Object, 계층 사이에 값을 나르는 객체), 입력 검증과 인증 규칙,
REST(REpresentational State Transfer) 엔드포인트를 감싼 추상화 같은 것들입니다. dynamic 타입을 쓰면 엄격한 타입 검사를 건너뛸 수 있습니다. 대신
그만큼 타입 안전을 잃습니다.
Kotlin/Wasm
Kotlin/Wasm 은 Kotlin 코드를 WebAssembly 형식으로 컴파일합니다. WebAssembly 는 스택 기반 가상 머신이 읽는 이진 명령 형식입니다. 자기 가상 머신 위에서 돌기 때문에 플랫폼을 가리지 않습니다.
브라우저에서는 Compose Multiplatform 으로 짠 웹 애플리케이션을 돌립니다. Compose Multiplatform 은 화면 코드를 여러 플랫폼이 나눠 쓰게 하는 도구입니다. 브라우저 밖에서는 독립된 WebAssembly 가상 머신에서 돌립니다. 플랫폼 API 에는 WASI(WebAssembly System Interface) 로 닿습니다. 브라우저에서 돌리려면 사용자의 브라우저가 WebAssembly 의 가비지 컬렉션 제안과 예전 방식의 예외 처리 제안을 지원해야 합니다. 이 갈래는 아직 Beta 입니다.
Kotlin/Native
Kotlin/Native 는 Kotlin 코드를 가상 머신 없이 도는 네이티브 바이너리로 컴파일합니다. LLVM(Low Level Virtual Machine 에서 온 이름의 컴파일러 기반 도구 모음) 을 쓰는 백엔드와 네이티브로 구현한 Kotlin 표준 라이브러리가 들어 있습니다.
주로 가상 머신을 두기 싫거나 둘 수 없는 플랫폼을 겨냥합니다. 임베디드 장치와 iOS 가 그런 예입니다. 리눅스 · MinGW(Minimalist GNU for Windows) 를 거친 윈도우 · Android NDK(Native Development Kit) · macOS · iOS · tvOS · watchOS 를 대상으로 합니다.
C 라이브러리나 Swift · Objective-C 프레임워크를 바로 부릅니다. 거꾸로 C · C++ · Swift 프로젝트에 컴파일한 Kotlin 코드를 넣을 수도 있습니다. 메모리는 자체 추적형 가비지 컬렉터가 치웁니다. 이 컬렉터는 Swift · Objective-C 의 ARC(Automatic Reference Counting, 자동 참조 계수) 와 맞물립니다.
Kotlin Multiplatform
Kotlin Multiplatform 은 Android · iOS · 데스크톱 · 웹 · 서버가 코드를 나눠 쓰게 합니다. 네트워크나 저장 같은 모듈 하나만 나누는 데서 시작해 넓혀 갈 수 있습니다. 비즈니스 로직만 나누고 화면은 각 플랫폼 것으로 둘 수도 있습니다. Compose Multiplatform 으로 UI(User Interface, 사용자 화면) 코드까지 나눌 수도 있습니다.
나눠 쓰는 코드는 보통 commonMain 디렉토리에 둡니다. 소스를 어느 대상으로 컴파일할지에 따라 가른
묶음을 소스 셋이라고 합니다. commonMain 에 둔 코드가 공통 소스 셋입니다. 대상마다 자기 소스 셋이
따로 있습니다. 코드 파일을 어느 소스 셋에 두느냐가 그 코드를 어느 플랫폼으로 컴파일할지를 정합니다.
공통 코드는 여러 대상으로 컴파일됩니다. 한 플랫폼에만 있는 함수나 클래스를 쓰면 컴파일러가
막습니다. JDK 에 들어 있는 java.io.File 을 공통 코드에서 못 쓰는 까닭이 이것입니다.
네이티브로 컴파일할 때는 JDK 클래스가 없습니다.
플랫폼마다 다른 API 는 expect 와 actual 로 잇습니다. 공통 코드에는 구현 없이 expect 를 붙여
선언합니다. 각 플랫폼 소스 셋에는 같은 패키지에 같은 선언을 두고 actual 을 붙여 그 플랫폼의
라이브러리로 구현합니다. 컴파일러는 모든 expect 선언이 플랫폼 소스 셋마다 짝이 되는 actual 을
갖는지 확인합니다.
flowchart TD
C["commonMain · 공통 소스 셋 · expect 선언"] --> J["JVM 쪽 소스 셋 · actual 구현"]
C --> N["iOS 쪽 소스 셋 · actual 구현"]
J --> JB["JVM 바이트코드"]
N --> NB["네이티브 바이너리"]
관련 항목
Kotlin 소스가 실행되기까지 거치는 구성 요소
kotlinc · 컴파일러 · 클래스 파일 · 바이트코드 · JVM · JIT 컴파일 · Kotlin 표준 라이브러리 · javac · 클래스패스
Kotlin 언어를 이루는 문법 요소
타입 추론 · 데이터 클래스 · 확장 함수 · 널 안전성 · 안전 호출 · 엘비스 연산자 · 봉인 클래스 · 람다 표현식 · 제네릭 · 최상위 함수 · 동반 객체 · 스마트 캐스트 · 연산자 오버로딩 · 기본 인자 · 이름 붙인 인자
Kotlin 이 동시 처리에 쓰는 장치
코루틴 · Kotlin 코루틴 · kotlinx.coroutines · CoroutineScope · suspend 함수 · 동시성 · 스레드 · 스레드 풀 · 채널 · Flow · StateFlow · 구조화된 동시성 · 운영체제
Kotlin 이 Java 코드와 맞물릴 때 나오는 개념
상호 운용 · 플랫폼 타입 · null · NullPointerException · 체크 예외 · 언체크 예외 · JSpecify · final 클래스 · JDK
Kotlin 의 기본 final 과 부딪히는 Spring 의 장치
Spring Framework · Spring AOP · CGLIB · 프록시 · 스프링 빈 · all-open 플러그인 · kotlin-spring 플러그인 · 메타 애너테이션 · start.spring.io
Kotlin 코드를 빌드하고 배포하는 도구
Gradle · Gradle Kotlin DSL · Maven · Android Studio · IntelliJ IDEA · DSL · Kotlin Symbol Processing
Kotlin 이 컴파일하는 대상 플랫폼
Kotlin 멀티플랫폼 · Compose Multiplatform · 네이티브 컴파일 · JavaScript · Node.js · WebAssembly · WASI · LLVM · iOS · CommonJS · ES 모듈 · 가비지 컬렉션 · ARC · MinGW · Android NDK
Kotlin 으로 짓는 애플리케이션과 그 프레임워크
Android · Spring Boot · Ktor · Vert.x · Hibernate · Jetpack Compose · Gatling
서버 개발을 두고 Kotlin 과 겨루는 프로그래밍 언어
Java · Scala · Groovy · Go · Python · Swift · Objective-C
Kotlin 을 만든 회사와 그 회사의 제품
JetBrains · IntelliJ IDEA Ultimate · YouTrack · RubyMine
Kotlin 이 속하는 상위 분류
다른 이름: 코틀린 · Kotlin 언어 · 코틀린 언어