Kotlin 특징
Kotlin의 대표적인 특징들에 대해서 좀 더 세부적으로 알아보겠습니다.
Java와의 상호 운용성
Kotlin은 Java와 호환성이 매우 뛰어난데 이는 Kotlin의 주된 설계 방향이자 목표 중 하나인 “Java와의 상호 운용성” 때문입니다. 즉, Kotlin은 초기부터 Java와의 상호 운용성을 고려하여 설계되었습니다. 그렇다면 Java, Kotlin이 서로 다른 프로그래밍 언어 임에도 어떻게 호환이 가능한지, 그리고 JVM과 바이트 코드 생성이 이에 어떤 역할을 하는지에 대해 세부적으로 알아보겠습니다.
JVM 위에서 동작하는 언어
Java와 Kotlin은 모두 JVM이라는 가상 머신 위에서 동작하는 언어입니다.
JVM은 Java Virtual Machine을 의미하며 Java 바이트 코드로 변환된 프로그램을 실행하는데 사용되는 가상 머신입니다. 이러한 JVM을 사용하는 언어의 장점 중 하나는 플랫폼 독립성입니다. 즉, JVM이 설치되어 있는 모든 플랫폼에서 동일한 Java 바이트 코드를 실행할 수 있고 이는 작성한 코드가 Windows, Linux, Mac OS 등 다양한 운영 체제에서 변경 없이 실행될 수 있다는 것을 의미합니다.
바이트 코드
바이트 코드란 JVM이 이해할 수 있는 코드를 말합니다.
앞서 바이트 코드를 생성한다고 했는데, 이는 작성한 소스 코드가 컴파일 단계에서 JVM이 이해할 수 있는 바이트 코드로 변환된다는 의미입니다. JVM은 변환된 바이트 코드를 해석하고 실행하며 이러한 과정은 런타임, 즉 프로그램 실행 중에 일어납니다. 이를 통해서 JVM 기반 언어들은 다양한 하드웨어 및 운영 체제에서 동일하게 작동될 수 있으며, 이를 통해 특정 플랫폼에 종속적이지 않고 독립성을 가질 수 있게 되는 것 입니다.
다음은 Java에서 정의된 메소드를 Kotlin에서 호출하는 예시입니다.
1
2
3
4
5
6
7
8
9
10
11
// Java 코드
public class JavaClass {
public static String sayHello(String name) {
return "Hello, " + name;
}
}
// Kotlin 코드
fun main() {
println(JavaClass.sayHello("Kotlin"))
}
결론적으로,
- Kotlin이 JVM 위에서 동작한다는 것은 Java와 같이 바이트 코드를 생성한다는 것이고
- 이를 통해 두 언어간의 상호 운용성이 가능한 것이며
- 추가적으로 다양한 플랫폼에서 동작할 수 있게 되는 것입니다.
쉬운 학습 및 간결함
Kotlin은 기존 Java의 문법과 유사하면서도 Boilerplate Code를 최소화할 수 있는 간결함을 제공합니다. 그 예로 Type Inference, Property, Data Class 등이 있습니다.
Boilerplate Code란?
어떤 프로그램을 작성할 때 반복적으로, 그리고 여러 곳에서 계속해서 사용해야하는 코드들을 의미합니다. 이러한 코드는 프로그램의 특정 기능과는 관련이 없지만 필수적으로 필요한 코드로 간주됩니다.
예를 들어 Java에서 클래스를 만들 때, getter/setter 메소드를 클래스 필드에 대해 작성하는 것이 일반적인 Boilerplate Code 입니다. 이러한 메소드는 클래스의 상태를 가져오거나 설정하는데 필요하지만 실제 비즈니스 로직과는 직접적인 관련이 없습니다.
Type Inference
Kotlin은 타입을 명시적으로 지정하지 않아도 컴파일러가 이를 추론합니다.
Kotlin에서는 Python, JavaScript 등과 같이 변수 또는 반환값의 Type Inference(타입추론) 기능을 제공하고 있습니다. 하지만 Python, JavaScript와 같은 동적 타입 언어와 다르게 정적 타입 언어이기 때문에, 변수가 선언되는 시점에 특정 타입을 가지게 되며 변수의 타입이 한번 정해지면 변경될 수 없습니다. 따라서 Kotlin의 Type Inference는 변수 선언과 동시에 무조건 값을 할당해야 합니다.
다음은 Kotlin에서의 Type Inference 예시 코드입니다.
1
2
3
4
5
6
val str = "Kotlin"
val num = 123
val floatNum = 12.3
val bool = true
fun sum(a: Int, b: Int) = a + b
Property
Kotlin은 Property라는 개념을 통해 필드 및 접근자 메소드를 묶어 표현합니다.
다음 Java 코드를 한번 보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Person {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
Person person = new Person();
person.setName("John");
System.out.println(person.getName());
위의 Java 코드에서 보이듯이 name 필드에 접근하고 수정하기 위해서는 별도의 getter/setter 메소드가 필요합니다. 간단한 속성들도 이러한 방식으로 처리해야하기 때문에 코드가 불필요하게 길어지게 되고 이로 인해 Boilerplate Code가 됩니다.
Kotlin에서는 이 문제를 해결하기 위해 Property라는 개념을 도입했습니다. Property는 필드와 해당 필드에 대한 접근자 메소드들을 하나의 단위로 묶어서 표현하기 때문에 getter/setter 메소드를 직접 작성할 필요가 없으며 이들은 자동으로 생성됩니다.
1
2
3
4
5
6
7
class Person {
var name: String = ""
}
val person = Person()
person.name = "John"
println(person.name)
위 코드에서는 별도의 getter/setter를 선언해주지 않았음에도 자동으로 생성되었기 때문에 name 필드에 접근할 수 있는 것을 확인할 수 있습니다. person.name = "John" 코드는 setter를 호출하고 println(person.name)은 getter를 호출합니다.
추가적으로 다음과 같이 직접 getter/setter를 커스터마이징하여 별도의 로직을 추가할 수도 있습니다.
1
2
3
4
5
6
7
class Person {
var name: String = ""
get() = field
set(value) {
field = "Person Name: $value"
}
}
Data Class
data 클래스를 이용하면 equals(), hashCode()와 같은 메소드들을 자동으로 생성해줍니다.
Java에서는 getter/setter 외에도 equals(), hashCode(), toString() 메소드를 오버라이딩해야 합니다. 물론 필수는 아니지만 이 메소드들을 오버라이딩하지 않을 경우 기본적으로 제공되는 메소드가 사용되는데, 이 메소드들의 기본 동작이 개발자가 원하는 바와 다를 수 있습니다.
예를 들어 equals() 메소드는 기본적으로 두 객체의 참조값을 비교합니다. 즉, 두 객체가 같은 객체인지 확인하는 것이지 두 객체의 내용이 같은지를 확인하는 것은 아닙니다. 때문에 두 객체의 내용을 비교하기 위해서는 equals() 메소드를 오버라이드해야 합니다. 이러한 작업은 이전에 언급한 Java의 getter/setter와 같이 Boilerplate Code를 만들게 되는 원인이 될 수 있기 때문에, Kotlin에서는 이를 해결하기 위해 data라는 키워드를 만들었습니다
1
data class Person(var name: String, var age: Int)
Kotlin에서는 data 키워드를 이용하여 간단하게 데이터를 담는 클래스를 만들 수 있습니다. 이 data 키워드를 사용하여 만들어진 클래스는 자동으로 equals(), hashCode(), toString() 등의 메소드를 생성합니다. 따라서 Kotlin의 data 클래스는 Boilerplate Code 작성을 최소화하고 깔끔하고 읽기 쉬운 코드를 작성하는데 큰 도움을 줍니다.
Null Safety
Java로 프로그래밍을 하다보면 많은 NullPointerException을 겪어보셨을 겁니다. NullPointerException은 특정 참조가 null인 상태에서 해당 요소에 접근하려할 때 발생합니다. 예를 들어 객체의 참조가 null인 상태에서 메소드나 필드에 접근하려고 하는 경우, 또는 배열 참조가 null인 상태에서 배열의 길이를 알아보려고 하는 경우에 NullPointerException이 발생할 수 있습니다.
NullPointerException은 “The Billion Dollar Mistake”라고도 불리는데 소프트웨어 결함으로 인해 발생하는 비용이 매우 크기 때문입니다. 그만큼 NullPointerException에 대한 처리가 중요한데, 문제는 이러한 상황들이 프로그램이 실행되는 도중에 발생하기 때문에 개발자가 해당 상황을 미리 예측하거나 방지하기 어렵다는 것입니다. 이러한 문제를 해결하고자 Kotlin에서는 Null Safety를 도입하였습니다.
Kotlin은 Null Safety를 통해 NullPointerException을 런타임이 아닌 컴파일 단계에서 방지할 수 있습니다. Kotlin의 Null Safety 기능은 가능한 모든 null에 대한 접근을 검사하기 때문에 Null Safety를 준수하는 코드에서는 NullPointerException이 발생할 수 없습니다.
따라서 Kotlin에서는 타입 시스템에 nullability(타입의 값이 null일 수 있는지)를 명시적으로 포함시켜 null이 될 수 있는 타입과 그렇지 않은 타입을 명확하게 구분합니다. 이를 통해 컴파일러가 잠재적인 문제를 미리 찾아낼 수 있고 개발자는 실행 시점에서 예기치 않은 NullPointerException 오류로부터 안전한 코드를 작성할 수 있습니다.
Java NullPointerException
다음은 Java에서 NullPointerException이 발생하는 예시입니다.
1
2
String name = null;
System.out.println(name.length());
name 객체는 현재 null인데, 해당 객체의 길이를 확인하는 length() 메소드를 호출하는 순간 런타임 중 NullPointerException이 발생하게 됩니다.
Kotlin Null Safety
Kotlin에서는 ? 연산자를 이용하여 null을 허용하는 nullable 변수를 선언할 수 있습니다. 반면에, 해당 연산자를 사용하지 않은 변수는 null을 허용하지 않으며 이 상태에서 null을 대입할 경우 컴파일 에러가 발생하게 되며, 이런 방식으로 NullPointerException을 방지합니다. 다음은 각각에 대한 예시 코드입니다.
1
2
var name: String? = null
println(name?.length)
위 코드에서 name은 null을 허용하는 String? 타입으로 name이 null인 상태에서 length 속성에 접근하더라도 NullPointerException이 발생하지 않고 null이 출력됩니다.
? 연산자와 ?. 연산자
? 연산자는 특정 변수가 null값을 가질 수 있는지 없는지를 나타내는 연산자입니다. 이 연산자를 사용하지 않고 null 값을 대입하려고 할 경우 컴파일 에러가 발생합니다.
?. 연산자는 null-safe operator 또는 safe call operator라고 부릅니다. 이 연산자를 사용하면 해당 객체가 null일 경우 null을 반환하고 null이 아닐 경우 해당 객체의 속성에 접근합니다.
1
var name: String = null
위 코드는 컴파일 에러가 발생합니다. Kotlin에서 nullable 변수가 되기 위해서는 해당 변수를 선언할 때 데이터 타입 뒤에 ? 연산자를 붙여줘야 하고 붙이지 않을 경우 nullable이 불가능한 변수가 됩니다. 즉, 위 코드에서 name 객체는 String 데이터 타입을 가지고 있기 때문에 null이 할당될 수 없고 컴파일 시점에 null을 대입하는 부분에서 에러가 발생하게 됩니다.
현대적인 프로그래밍 언어
Kotlin의 구조와 기능을 살펴보면 현대적인 프로그래밍 언어의 특징을 확인할 수 있습니다. 함수형 프로그래밍과 객체지향 프로그래밍의 장점을 모두 취하고 있으며 이는 개발자가 더 나은 코드를 작성할 수 있도록 도와줍니다.
특징 중 대표적인 것을 뽑아보면 함수형 프로그래밍, 확장 함수, Coroutine이 있습니다.
함수형 프로그래밍
함수형 프로그래밍은 순수 함수와 불변성의 개념을 중심으로 하는 프로그래밍 패러다임입니다. Kotlin은 이러한 함수형 프로그래밍의 특징을 지원하며 람다 함수, 고차 함수 등의 기능을 제공합니다.
다음은 람다 함수와 고차함수를 이용한 예시입니다.
1
2
3
val numbers = listOf(1, 2, 3, 4, 5)
val doubled = numbers.map { it * 2 }
println(doubled)
위 코드에서 { it * 2 }는 람다 함수이며, map은 고차 함수 입니다. map은 Kotlin 컬렉션에 존재하는 고차 함수들 중 하나로 다른 함수를 파라미터로 받아 켤렉션의 각 원소에 해당 함수를 적용하고 그 결과를 새로운 컬렉션으로 반환합니다. 즉, 위 코드는 원본 값을 2배한 값을 반환하는 람다 함수를 고차 함수로 전달하여 원본 값들을 각각 2배한 값들이 들어있는 새로운 컬렉션을 반환하는 코드입니다.
확장 함수
Kotlin에서 확장 함수는 기존 클래스에 새로운 기능을 추가할 수 있는 방법으로, 확장 함수를 정의하면 기존 클래스를 수정하지 않고도 해당 클래스의 인스턴스에 새로운 함수를 연결할 수 있습니다.
다음은 확장 함수를 사용한 예시입니다.
1
2
3
4
5
6
fun String.hasSpaces(): Boolean {
return this.contains(" ")
}
val phrase = "Hello World!"
println(phrase.hasSpaces())
위 코드는 기본 클래스인 String에 hasSpaces()라는 확장 함수를 추가한 예시입니다. 기존에 String 클래스에서 제공하지 않는 메소드이지만 위와 같이 확장 함수를 추가하면 다른 곳에서 해당 객체에 추가한 함수를 호출할 수 있습니다.
Coroutine
Coroutine은 비동기 프로그래밍을 더 효과적으로 구현하기 위한 프로그래밍 개념입니다. Thread가 Process보다 더 적은 자원을 사용하기 때문에 경량 Process라고 불리듯이, Coroutine은 Thread보다 더 적은 자원을 사용하기 때문에 경량 Thread라고 불리기도 합니다.
Thread와 Coroutine
Thread와 Coroutine은 큰 차이점이 있는데 바로 Coroutine이 동시성(Concurrency)은 제공하지만 병렬성(Parallelism)은 제공하지 않는다는 점입니다. 즉, Coroutine은 같은 Thread에서 실행될 수는 있지만, 병렬로 처리되려면 각각 별도의 Thread에서 실행되어야 합니다.
Kotlin은 이러한 Coroutine을 이용하여 비동기 프로그래밍을 매우 간단하고 효율적으로 구성할 수 있습니다. suspend 키워드와 관련된 Coroutine Builder 함수들인 launch, async 등을 사용함으로써 비동기 코드를 마치 동기 코드처럼 작성할 수 있게 해주기 때문입니다. 이로 인해 비동기 동작을 하는 코드의 가독성이 크게 향상되고 복잡성이 줄어든다는 큰 장점을 가지고 있습니다.
다음은 Callback 기반의 비동기 코드 예시입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
fun doWork(callback: (result: Int) -> Unit) {
Thread {
Thread.sleep(1000L)
callback(10 * 10)
}.start()
}
fun main() {
doWork { result ->
println("Result: $result")
}
println("Work scheduled")
}
위 코드를 살펴보면 doWork()라는 비동기 작업을 단순히 하나만 실행하는 코드임에도 비교적 복잡한 구조로 되어있는 것을 확인할 수 있습니다. Coroutine을 사용할 경우 다음과 같이 훨씬 간결하게 코드를 작성할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
suspend fun doWork(): Int {
delay(1000L)
return 10 * 10
}
fun main() = runBlocking {
val result = doWork()
println("Result: $result")
println("Work completed")
}
위 코드는 doWork()라는 비동기 작업을 하고 있음에도 마치 동기 코드처럼 간결하게 코드를 작성할 수 있습니다. 이를 통해 Coroutine을 사용했을 때 간결함 및 가독성이 좋아진다는 것을 확인할 수 있습니다.
마무리
Kotlin에 대한 추가적인 정보는 다음 링크를 참고하시면 되겠습니다.
- Kotlin 개요 및 문서와 튜토리얼 등 Kotlin과 관련된 다양한 정보
- Kotlin 기본 문법 및 기능을 배우기 위한 연습 문제 제공
- Google에서 제공하는 Kotlin을 이용한 Android 개발 가이드
