[Kotlin] 자바에서 코틀린으로 넘어가는 이유

들어가기에 앞서
코틀린이라는 언어는 안드로이드 앱을 만들 때 사용하는 프로그래밍 언어로만 알고있을뿐 자세한 내용은 몰랐는데, 이번에 지원한 회사에서 kotlin + spring 으로 개발한다는 것을 보고, 기존에 사용하던 java + Spring 과 비교해서 어떤 이점이 있어서 코틀린을 사용하는지 자세히 알고싶어 해당 글을 작성하게 되었다.
Kotlin이란?
Kotlin 은 JetBrains 사에서 만든 크로스 플랫폼 정적 타입 프로그래밍 언어이다.
일반적으로 Kotlin은 안드로이드 어플이나 서버를 구성하는데 많이 사용되는 것으로 JVM 기반의 언어라고 알고있지만 최근 멀티플랫폼 구성을 보면 Kotlin/JS, Kotlin/Native 와 같이 다른 컴파일러를 구성하여 확장하고 있는 것을 알 수 있다.
본 내용에서는 주제를 고려하여 Kotlin/JVM 에 대해서 다루어볼 생각이다.
Kotlin은 Java와 어떻게 호환되나요?
Kotlin/JVM은 이름에서도 알 수 있듯 Java 언어와 100% 상호 운용이 가능하다.
어떻게 Java 언어와 상호 운용이 가능하다는 걸까? Java 언어와 Kotlin 언어를 기계어 바꾸는 컴파일러 과정에 대해서 알아보자.

[STEP 1]
사용자는 .java 확장자 파일 또는 .kt 확장자 파일의 코드를 작성한다.
[STEP 2]
.java 확장자 파일은 javac 컴파일러 를 통해, .kt 확장자 파일은 kotlinc 컴파일러 를 통해 .class 확장자의 바이너리 파일을 생성한다.
[STEP 3]
JVM 이 설치되어 있는 OS에 맞게 .class 바이너리 파일을 기계어로 번역하여 실행한다.
과정을 보면 알겠지만 OS에 맞게 설치되어 있는 JVM은 공통적으로 .class 확장자의 바이너리 파일을 통해 컴파일러를 수행한다는 것을 알 수 있다. 이러한 특성을 이용해 Kotlin 언어는 고유의 컴파일러를 이용하여 .class 확장자의 바이너리 파일로 만든다는 것을 알 수 있다.
Kotlin의 특징
Kotlin은 Java 언어를 완전히 대체하기 위해서 나온만큼, 기존 Java 언어의 문제점을 해결하고 간결한 문법과 다양한 기능을 추가했다.
기존의 Java 언어는 어떤 문제점이 있었나요?
기존의 Java 언어는 다음과 같은 문제점들이 있었다.
Null 처리
Null은 개발을 하다보면 두려워지는 존재로 런타임 환경에서 Null을 참조하면 NPE (Null Pointer Exception) 가 발생하여 프로그램이 예기치 않게 종료될 수 있어. 이러한 Null 참조를 예방하고 이를 처리하는 것은 필수이다. Java 언어는 이러한 문제를 해결하기 위해서 Java8 부터 나온 Null 이 될 수 있는 객체를 명시적으로 타입화 시키는 Optional 이 나왔다.
따라서 NPE 가 발생하는 것을 막기위한 다양한 로직과 기법으로 인해 자연스럽게 코드가 늘어나 코드 복잡성을 증가시킨다.
public class NullCheckExample {
public static void main(String[] args) {
String str = getString();
// 기본적인 null 체크
if (str != null) {
System.out.println(str.length());
} else {
System.out.println("String is null");
}
}
public static String getString() {
return null;
}
}
<Java 언어의 기본적인 null 체크>
import java.util.Optional;
public class OptionalExample {
public static void main(String[] args) {
Optional<String> optionalStr = getOptionalString();
// Optional을 이용한 null 체크
optionalStr.ifPresentOrElse(
str -> System.out.println(str.length()),
() -> System.out.println("String is null")
);
}
public static Optional<String> getOptionalString() {
return Optional.ofNullable(null);
}
}
<Optional을 이용한 null 체크>
Raw Type
Java 에서 Raw Type은 파라미터가 없는 제네릭 타입을 의미한다.
간단하게 예를 들어서, 우리가 제네릭 타입을 쓰는 클래스인 List를 활용할 때, List<String>, List<Integer>와 같이 타입을 지정해주지 않고 List 만을 이용하여 선언을 할 수 있는데 이를 Raw Type 이라고 부른다.
그럼 Raw Type을 쓰면 어떤 문제점이 생길까? 간단하게 코드를 한번 작성해보았다.
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main (String[] args) {
List intList = new ArrayList();
intList.add(1);
intList.add("2");
Integer first = (Integer) intList.get(0);
Integer second = (Integer) intList.get(1); // 런타임 오류 발생!
System.out.println(first + second);
}
}
<Raw Type 예시>
해당 코드를 작성하면 컴파일 시점에서는 오류가 발생하지 않은 것을 알 수 있다.
타입 파라미터를 지정하지 않은 List를 선언했을 때 기본적으로 'Object' 타입의 요소를 저장하도록 설정되기 때문이다.
하지만 문제는 Class Casting 시점에서 발생한다. String -> Integer 타입으로 변경이 불가능하기 때문에 개발자에게 치명적인 런타임 에러가 발생해버린다.
그렇다면 왜 Java 언어는 이런 Raw Type을 없애지 않고 그대로 유지하고 있을까?
제네릭 타입은 Java5 에서부터 도입이 되었기 때문에 Java5 이전에 사용하던 코드의 호환성 유지를 위해 계속 유지를 하고 있다고 볼 수 있다.
Array 공변성
Java에서의 Array 즉, 배열은 기본적으로 공변성 이다.
예를 들어, 특정 타입의 배열의 요소는 해당 타입의 하위 타입을 삽입할 수 있게 허용된다는 뜻이다.
글로 설명하기에는 이해가 가지 않으니 같이 간단한 코드를 살펴보자.
public class Main {
public static void main (String[] args) {
Number[] numbers = new Number[2];
numbers[0] = 1;
numbers[1] = 3.14;
}
}
<Java 배열 공변성 예시>
Number 라는 타입의 배열을 생성하고 각각 Integer와 Double 타입의 데이터를 넣는 예시이다.
Integer와 Double 타입은 Number 타입의 하위 타입이기 때문에 해당 코드를 문제없이 동작한다.
하지만, 다음과 같은 경우를 살펴보자.
public class Main {
public static void main (String[] args) {
Number[] numbers = new Integer[2];
numbers[0] = 1;
numbers[1] = 3.14; // 런타임 에러 발생!
}
}
<Java 배열 공변성 문제점>
언뜻 보기에는 똑같아 보이는 코드지만, Number 타입의 배열을 만들고 구현체로 Integer 배열을 선언했다.
당연히 오류가 발생해야 될 것 같지만, 아이러니 하게도 컴파일 시점에서는 오류가 발생하지 않는다. 이후 실행하면 런타임 에러가 발생한다.
Java 언어에서는 타입 안정성을 헤치는 배열 공변성을 허용하는걸까?
그 이유는 다형성과 호환성에 있다.
Java 언어에서는 하나의 객체에 다양한 여러가지 타입을 대입할 수 있는 다형성 원칙을 준수하고 있다. 제네릭 타입이 나오기 이전에는 다형성 원칙을 지키기 위해서 배열은 공변성을 유지할 필요가 있었고, Java5 이후로 제네릭 타입이 나왔을 때는 이전 코드와 호환성을 유지하기 위해서 배열 공변성을 유지할 수 밖에 없었다.
Checked Exception
Checked Exception은 애플리케이션이 예상하고 복구해야하는 예외적인 조건이다.
예를 들어, FileReader를 선언하고 파일을 읽는 로직을 작성하는 코드를 살펴보자.
import java.io.FileReader;
public class Main {
public static void main(String[] args) {
FileReader fileReader = new FileReader("test.txt"); // 컴파일 에러 발생!
}
}
<Checked Exception 예시>
FileNotFoundException이 발생하는 예외 상황을 예상하고 복구가 필요하여 try-catch 문 또는 throws를 이용하여 예외를 처리를 하라며 컴파일 에러가 발생한다.
위의 내용대로 Checked Exception은 발생한 예외에 대해서 신뢰성과 복원성을 높이기 위해 만들어진 것으로 강력한 애플리케이션을 구축하는데 도움이 된다고 볼 수 있다.
하지만, Checked Exception은 다음과 같은 이유로 필요하지 않다는 의견이 나왔다.
- Chekced Exception을 처리하기 위해서 많은 try-catch 문을 작성해야 하므로 코드의 가독성을 떨어뜨리고 코드가 길어진다.
- 사실상 Runtime Exception 과 Checked Exception 은 기능적으로 동일하다.
- Checked Exception은 애플리케이션 아키텍처의 연결 부분에서 치명적이다.
- 중간 지점 API는 낮은 지점 API에서 발생한 Checked Exception에 대해 알 필요가 없거나 알고싶어 하지 않는다.
Java's checked exceptions were a mistake (and here's what I would like to do about it)
Java's checked exceptions were a mistake (and here's what I would like to do about it) 1 April 2003 Java's checked exceptions were an experiment. While Java borrows most of its try/catch exception handling from C++, the notion of "checked" exceptions, whic
radio-weblogs.com
Checked exceptions: Java’s biggest mistake | Literate Java
Checked exceptions have always been a controversial feature of the Java language. Advocates claim they ensure checking & recovery from failures. Detractors say “catch” blocks can almost never recover from an exception, and are a frequent source of mist
literatejava.com
가변 컬렉션
Java 에서는 기본적으로 Collection 타입은 요소를 추가, 제거, 수정을 할 수 있다.
이러한 특징은 의도하지 않은 변경으로 인해 불변성을 보장하기 어렵다는 문제점을 가지고 있다. 아래 코드를 같이 살펴보자.
import java.util.ArrayList;
import java.util.Collection;
public class Main {
public static void main(String[] args) {
Collection<String> list = new ArrayList<>();
list.add("item1");
list.add("item2");
System.out.println(list);
modifyList(list);
System.out.println(list);
}
public static void modifyList(Collection<String> list) {
list.add("new item");
}
}
<가변 컬렉션 예시>
만약 modifyList 메서드에서 의도치않게 해당 리스트의 요소가 변경되었다면 버그를 발생시킬 가능성을 높일 수 있다.
따라서, 리스트의 불변성을 보장하기 위해서는 리스트 전체를 복사하거나, 불변성 리스트를 만들어야 한다. 다음 코드를 살펴보자.
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public class Main {
public static void main(String[] args) {
Collection<String> list = new ArrayList<>();
list.add("item1");
list.add("item2");
System.out.println(list);
Collection<String> copy = modifyList(list);
System.out.println(list);
System.out.println(copy);
}
public static Collection<String> modifyList(Collection<String> list) {
Collection<String> copy = new ArrayList<>(list);
copy.add("new item");
return copy;
}
}
<컬렉션 복사 예시>
컬렉션 복사를 하는 경우에는, 기존의 컬렉션에 대해서는 불변성을 보장하지만, 추가적으로 똑같은 컬렉션을 생성하여 값을 복사하는 과정이 들어가므로 메서드가 호출될 때마다 값을 복사하는 연산과 메모리를 차지하게 되어버린다는 단점이 있다.
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
public class Main {
public static void main(String[] args) {
Collection<String> list = new ArrayList<>();
list.add("item1");
list.add("item2");
Collection<String> immutable = Collections.unmodifiableCollection(list);
modifyList(immutable);
System.out.println(list);
}
public static void modifyList(Collection<String> list) {
list.add("new item");
}
}
<불변 컬렉션 생성 예시>
불변 리스트를 생성하면 기존의 코드를 수정하지 않고 불변성을 보장해 줄 수 있지만, 위와 마찬가지로 가변성 컬렉션과 불변성 컬렉션 둘 다 다뤄야 한다는 점에서 코드의 복잡성을 증가시킨다는 단점이 있다.
Kotlin은 해당 문제점을 어떻게 해결했나요?
위에서 나열했던 기존 Java 의 문제점을 통해 하나씩 Kotlin의 해결 방식에 대해서 다뤄보겠다.
Null 처리
Kotlin 은 Null이 될 수 있는 타입과 Null이 될 수 없는 타입을 지원한다.
타입 뒤 명시적 ?
Kotlin 에서는 Null이 될 수 있는 타입이라는 의미로 ? 문법을 지원한다. 아래 코드를 살펴보자.
fun main() {
var nullable : String? = null
nullable = "Hello"
var nonNullable : String = "Hello"
nonNullable = null // 컴파일 에러 발생!
}
<코틀린 nullable 예시>
위의 코드를 살펴보면 Null이 될 수 있는 타입은 명시적으로 타입 뒤에 ? 를 붙여 값을 넣거나 null 값을 넣을 수 있고
Null이 될 수 없는 타입은 null 값을 넣을려고 시도하면 컴파일 에러가 발생한다는 것을 볼 수 있다.
엘비스 연산자 ?:
Kotlin 은 값이 Null일 경우 지정된 값을 반환하는 문법을 지원한다. 아래 코드를 살펴보자.
fun main() {
val nullable : String? = null
val nullableResult = nullable?: "is null"
println(nullableResult)
val nonNullable : String? = "not null"
val nonNullableResult = nonNullable?: "is null"
println(nonNullableResult)
}
<엘비스 연산자 예시>
위의 코드는 null 값일 경우 지정된 값을 반환하고 null 값이 아닐 경우 기존의 값을 그대로 반환한다는 것을 알 수 있다.
안전한 메서드 호출 ?.
Kotlin 에서는 ?. 문법을 이용하여 null일 경우 그대로 null 값을 반환하게 만들 수 있다. 아래 코드를 보자.
fun main() {
val testString : String? = "Hello, World!"
println(testString?.length ?: 0)
}
<안전한 메서드 호출 예시>
다음과 같이 문자열의 길이를 호출할 때, ?. 문법을 이용하여 null일 경우 null 값을 반환하게 만들어 그에 따른 처리를 해줄 수 있다.
안전한 타입 캐스팅 as?
Kotlin 에서 as? 문법을 이용하여 안전하게 타입 캐스팅을 수행할 수 있다. 아래 코드를 보자.
fun main() {
val testString : String? = "Hello, World!"
val castingInt : Int? = testString as? Int ?: -1
println(castingInt)
}
<안전한 타입 캐스팅 예시>
다음과 같이 String 타입을 Int 타입으로 변환하려고 시도할 때, 타입 캐스팅을 수행할 수 없으면 null 값을 반환하게 만들어 그에 따른 처리를 해줄 수 있다.
Raw Type
Kotlin은 Raw Type을 지원하지 않는다. 아래 예시 코드를 보자.
val list: List = listOf("Hello", 123) // 컴파일 에러 발생!
<Raw Type 예시>
제네릭 타입이 선언되어 있는 클래스는 필수적으로 타입을 명시하도록 강제하기 때문에 Raw Type을 사용할 수 없다.
Array 공변성
Kotlin에서의 배열은 기본적으로 공변성이 아닌 불변성을 갖도록 설계되었다. 아래 코드를 보자.
fun main() {
val intArray: Array<Int> = arrayOf(1, 2, 3)
val invalidArray: Array<Any> = intArray // 컴파일 에러 발생!
}
<Array 불변성 예시>
위의 내용과 마찬가지로 Kotlin 에서는 특정 타입으로 강제하기 때문에 상위 타입으로 생성했다고 해도 하위 타입을 선언할 수 없다.
만약, Kotlin에서 공변성을 가지고 싶다면 out 키워드를 이용하면 된다. 아래 코드를 보자.
interface Producer<out T> {
fun produce(): T
}
fun main() {
val strProducer: Producer<String> = object : Producer<String> {
override fun produce(): String = "Hello"
}
val anyProducer: Producer<Any> = strProducer
println(anyProducer.produce())
}
<Array 공변성 예시>
Java 에서의 Extends 키워드와 같이 타입 범위를 지정해줌으로써 공변성 특성을 갖도록 설계할 수 있다.
Checked Exception
Kotlin 에서는 Checked Exception을 사용하지 않아 예외 처리를 강제하지 않는다. 아래 코드를 살펴보자.
import java.io.FileReader
fun main() {
val fileReader = FileReader("file.txt")
}
<Checked Exception 예시>
Java 에서의 FileReader는 해당 파일이 존재하지 않을 경우의 예외 처리를 강제한다.
하지만, Kotlin 에서는 이러한 예외 처리를 강제하지 않고 개발자의 선택에 맡긴다는 것을 알 수 있다.
따라서, 코드의 안전성을 가져가기 위해서는 테스트 코드를 보다 상세하게 작성하여 예외 상황을 대처하는게 중요하다고 생각한다.
가변 컬렉션
Kotlin 에서는 불변 리스트와 가변 리스트를 효율적으로 사용할 수 있도록 지원한다. 아래 예시 코드를 보자.
fun main() {
val mutableList: MutableList<String> = mutableListOf("Apple", "Banana", "Cherry")
modifyList(mutableList)
println(mutableList)
printList(mutableList)
}
// 리스트를 변경할 수 있는 메서드
fun modifyList(list: MutableList<String>) {
list.add("Date")
}
// 리스트를 변경할 수 없는 메서드
fun printList(list: List<String>) {
for (item in list) {
println(item)
}
}
<가변, 불변 리스트 예시>
위의 코드를 봤을 때, 가변 리스트를 선언하고 리스트를 변경할 수 있는 메서드와 변경할 수 없는 메서드를 매개 변수 타입으로 지정할 수 있게 해준다. 해당 방법은 가변, 불변 리스트를 따로 선언하여 메모리를 사용하여 값을 복사하는 연산 과정을 거치지 않고 효율적으로 리스트의 불변성을 보장할 수 있다.
Kotlin의 단점
완벽한 프로그래밍 언어는 없듯이 Kotlin도 단점이 존재한다.
빌드 시간과 크기 증가
clean build를 수행할 경우에는 Java 언어보다 시간이 더 오래걸리고, 추가적인 Kotlin 라이브러리 포함해야 하기 때문에 APK 나 JAR 파일의 크기를 증가시킬 수 있다.
하지만, partial build가 가능한 경우 Kotlin은 파일 단위로 변경된 사항만 컴파일을 수행하는 증분 컴파일을 지원하여, Java 에서의 모듈 단위로 변경된 사항만 컴파일을 수행하는 컴파일 회피 방식보다 효율적이므로 대규모 프로젝트에서 더 빠르다.
학습 레퍼런스 수
Kotlin 언어는 Java 언어보다 역사가 짧아 관련 커뮤니티와 학습 레퍼런스 수가 상대적으로 부족하다는 단점이 있다.
따라서, 문제를 해결하기 위해서 검색을 하는 경우 더 많은 시간을 필요로 할 수 있다.
정리
글을 작성하면서 Kotlin의 특징과 Java의 특징을 정리해볼 수 있었고, 이를 통해 두 언어의 차이점을 명확히 이해하게 되었다. 특히, Java만 써왔던 입장에서 Kotlin을 사용하여 간단한 예시 코드를 작성해본 결과, Kotlin의 간결한 문법과 강력한 기능 덕분에 생산성이 많이 향상될 수 있을 것 같다고 느꼈다. 다음에 기회가 된다면 java + spring 과 kotlin + spring의 개발자 입장에서 차이점을 정리 해볼려고 한다.