[Kotlin] Java와의 차이 및 장점

yb__char·2024년 10월 13일
0
post-thumbnail

Thumnail Maker Made By sumi-0011

서론

현재 회사에서 Kotlin과 Spring Boot를 사용하고 있어요. 처음 JVM 기반 언어를 사용하게 된 회사였고, 뭔가 Kotlin을 사용하면서도 Java처럼 코딩하는 느낌이 강했어요. 그러다가 사이드 프로젝트에서는 Java와 Spring Boot를 사용하고 있었는데, 프로젝트를 진행하면서 몇 번이나 "아, 코틀린으로 이걸 더 쉽게 할 수 있을 텐데..."라는 생각이 들었습니다. 그래서 요즘 Kotlin에 끌리는 이유와, 왜 많은 개발자들이 Kotlin을 선택하는지에 대해 설명하고 싶어 이 글을 작성하게 되었습니다.

이번 글에서는 Kotlin과 Java의 명확한 차이점, 사용된 예시 및 각 언어의 특징, 장점에 대해 간단하게(?) 이야기해볼게요.

Java vs Kotlin

1. 문법적 간결성

Java는 오랫동안 사용된 언어로, 매우 명확한 구조와 문법을 가지고 있지만, 종종 그 길이와 복잡성으로 인해 불편할 수 있습니다. Kotlin은 이러한 문제를 해결하고자 더 간결하고 직관적인 문법을 제공하고 있습니다.

public class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Java같은 경우 사람이라는 Person 객체를 만들기 위해 getter, setter, 그리고 생성자까지 작성해줘야 하는 번거로움이 있었습니다. 그러나 Kotlin에서는

data class Person(val name: String)

뭐지... 한 줄 땡?
이렇게 간단한 예시만으로도 코드의 간결성이 명확하게 보여지고 있다는 것을 알 수 있습니다.
data class라는 키워드로 Kotlin에서 제공되는 데이터 중심 클래스의 boilerplate 코드를 자동으로 생성해줄 수 있다는 점이죠

2. Null Safety

Java에서 널 포인터 예외(NullPointerException, NPE)는 흔히 발생하는 문제 중 하나입니다. Java에서는 객체가 null인지 항상 체크해야 하며, 이로 인해 많은 런타임 오류가 발생할 수 있습니다. 반면, Kotlin은 컴파일 타임에 이러한 문제를 미리 방지합니다.

아마 Java에서부터 인스턴스를 생성하고 인스턴스에 대한 Null Exception을 처리하기 위해 아래 코드와 유사하게 작성한 기억이나 경험이 있으실 겁니다. (저 또한..)

String name = person.getName();
if (name != null) {
    throw new CustomException("이름의 값이 존재하지 않습니다.");
}

그러나 Kotlin에서는

val name: String? = person.name
println(name?.length)

이렇게 변수에 null 값이 넣을 수도 있다는 것을 의미하고, 변수가 null일 수도 있지만 null 검증이 완료되어 !! 키워드를 사용해 보장성을 줄 수도 있습니다.

init {
   validateName(name)
}

또한 객체 클래스에서 init이라는 초기화 하는 키워드를 통해 name이라는 변수의 null 또는 길이 등등 다양한 조건을 가지고 예외 처리를 진행할 수도 있습니다. 그렇다면 객체 생성 시 생성 후 예외 처리가 아닌, 생성 시 발생되는 예외를 처리할 수 있어 class 간의 역할 책임이 명확하게 보일 수 있습니다.

3. 편리성을 위한 다양한 키워드 제공

Java와 다르게 명확하게 Kotlin에서만 제공되는 키워드가 즈으어어어엉말 많습니다.
실제로 면접장에 가서 Kotlin을 써봤다고 하면 어떤 키워드를 써봤는 지? 키워드의 동작 원리와 사용했던 사례에 대해서 이야기 해볼 수 있는 지?에 대한 질문도 받았었습니다.
몇 가지 키워드에 대해 이야기 해볼텐데요,

1. data 키워드
앞서서 data 키워드에 대한 코드 간결성에 대해서 이야기 했었습니다.
data 키워드는 자동적으로 equals(), hashCode(), toString(), copy(), componentN() 등등 메서드를 생성해주어 불필요한 코드를 줄여줍니다.

장점

  • boilerplate 코드 제거: 데이터 중심 클래스에서 필수적으로 작성해야 하는 코드를 자동으로 생성해줍니다.
  • 데이터 조작에 최적화: copy() 함수로 객체를 쉽게 복사하고, 일부 필드만 변경할 수 있습니다.
  • 구조 분해: componentN() 함수가 제공되어, 객체의 필드를 쉽게 분해하여 사용할 수 있습니다.
data class Person(val id: Long, val name: String)

fun main() {
    val person1 = Person(1, "Alice")
    val person2 = user1.copy(name = "Bob")  // name만 변경하여 복사

    println(person1)  // 출력: Person(id=1, name=Alice)
    println(person2)  // 출력: Person(id=1, name=Bob)

    // 구조 분해
    val (id, name) = person1
    println("Person ID: $id, Person Name: $name")
}

이처럼 구조 분해를 통해 Pair 형식으로도 사용할 수 있습니다.

2. sealed 키워드

sealed 클래스는 클래스 상속을 제한하는 클래스입니다. sealed 클래스는 같은 파일 내에서만 서브클래스를 가질 수 있고, 이를 통해 특정 클래스 계층을 제한하고, 이를 기반으로 안전한 데이터 모델링이 가능합니다. 보통 when 표현식에서 sealed 클래스를 활용하면 컴파일러가 모든 가능한 하위 클래스에 대한 처리를 강제하기 때문에 더욱 안전합니다.

장점

  • 계층적 데이터 구조 표현: 제한된 클래스 계층을 정의하여 데이터 구조를 명확히 표현할 수 있습니다.
  • 안전한 타입 검사: when 블록에서 하위 클래스가 모두 처리되었는지 컴파일러가 체크합니다.
  • 확장 제어: 외부에서 임의로 서브클래스를 생성하지 못하도록 제어할 수 있습니다.
sealed class Shape {
    data class Circle(val radius: Double) : Shape()
    data class Rectangle(val width: Double, val height: Double) : Shape()
}

fun describeShape(shape: Shape): String = when (shape) {
    is Shape.Circle -> "Circle with radius ${shape.radius}"
    is Shape.Rectangle -> "Rectangle with width ${shape.width} and height ${shape.height}"
}

fun main() {
    val circle = Shape.Circle(5.0)
    val rectangle = Shape.Rectangle(4.0, 7.0)

    println(describeShape(circle))       // 출력: Circle with radius 5.0
    println(describeShape(rectangle))    // 출력: Rectangle with width 4.0 and height 7.0
}

이처럼 하위 클래스에 대한 처리를 강제하여 안전하게 처리하기 용이합니다.

3. inline 키워드
inline 키워드는 함수가 호출될 때 실제 함수 호출을 없애고, 대신 함수의 본문이 호출된 위치에 인라인으로 복사되도록 하는 키워드입니다. 이는 주로 성능 향상을 위해 사용됩니다. 또한 고차 함수에서 inline 키워드를 사용하면 람다 표현식을 효율적으로 처리할 수 있습니다.

장점

  • 성능 향상: 함수 호출 오버헤드를 줄여 성능을 최적화할 수 있습니다.
  • 람다 표현식 효율화: 고차 함수에서 람다를 인라인으로 처리하여 불필요한 객체 생성 및 호출 오버헤드를 줄일 수 있습니다.
  • non-local returns: inline 함수 내에서 람다 표현식이 인라인되면, return을 사용해 상위 함수로 바로 빠져나올 수 있습니다.
inline fun performOperation(operation: () -> Unit) {
    println("Before operation")
    operation()
    println("After operation")
}

fun main() {
    performOperation {
        println("Performing operation")
    }
}

이처럼 인라인 처리되어 호출된 곳에 함수 본문이 복사되어 오버헤드를 줄일 수 있도록 개선할 수 있습니다.

앞으로의 키워드들은 세세하게 포스팅하도록 하겠습니다!.!
아직 copy, value class, runCatching 등등 있는데 저도 아직 많이 사용해보질 않아서 예시와 함께 공부하면서 작성해보록 할게요

4. 함수형 프로그래밍 및 코루틴

1. 함수형 프로그래밍
원래 Java 8에서부터 람다 표현식이나 컬렉션 처리가 가능했습니다. 이후 Kotlin 에서는 함수형 프로그래밍으로 더욱 직관적인 표현과 작성이 가능합니다.

List<String> names = Arrays.asList("Charlie", "Beat", "Wade");
names.stream()
     // 앞글자 C인 filter 함수
     .filter(name -> name.startsWith("C"))
     // iterator에 따른 출력
     .forEach(System.out::println);

위처럼 Java 8부터 람다 표현식과 컬렉션 처리가 가능하지만

val names = listOf("Charlie", "Beat", "Wade")
names.filter { it.startsWith("C") }
     .forEach { println(it) }

Kotlin에서는 엄... 이렇게 간단해도 될까 싶었습니다.
진짜 처음 겪었고 직관적인 작성법이였습니다.

2. 코루틴 (간단하게)

코루틴, 영어로 하면 Co-Routine입니다.
Kotlin이니까 Ko-인줄 아실테지만, 다른 언어에서도 코루틴이 제공되고 있습니다.
Co는 협력, Routine은 간단히 함수라고 표현할 수 있습니다.
협력하는 함수라는 뜻으로, 프로그래밍에서 함수도 서로의 호출과 반환을 주고 받으며 협력을 합니다.

스코프 및 컨텍스트 구조는 코틀린의 비동기 프로그래밍을 지원하는 코루틴 공식 라이브러리에서 쉽게 찾아볼 수 있습니다. Kotlin은 비동기 프로그래밍을 쉽게 처리할 수 있도록 코루틴을 제공합니다. 코루틴을 시작하고 중단하는 함수는 반드시 코루틴 스코프(CoroutineScope) 안에서만 호출하여 사용할 수 있도록 강제합니다.

Java에서는 비동기 프로그래밍을 위해 CompletableFuture나 스레드 풀을 사용해야 하지만, Kotlin에서는 코루틴을 사용하여 간단하고 효율적으로 비동기 작업을 처리할 수 있습니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        delay(1000L)
        println("Hello, Coroutines!")
    }
    println("Start")
}

위 코드에서 runBlocking 함수는 일반 루틴 세계와 코루틴 세계를 연결하는 함수입니다.
다음으로 lanuch 라는 키워드도 보이는데, 반환값이 없는 새로운 코루틴을 만드는 데에 사용됩니다.
delay와 같이 중단 함수를 보았을때 코루틴 스코프가 설정되어 있거나 이와 동일한 효과를 가져오는 suspend 키워드가 함수에 설정된 것을 확인할 수도 있습니다.

코루틴 스코프에 전달된 비동기 작업(Job)은 코루틴 스코프 안에 자동으로 주입되어 숨겨진 CoroutineContext를 통해 안전하게 처리됩니다. 아래 코드와 같이 CoroutineScope 인터페이스에는 CoroutineContext가 프로퍼티로 정의되어 있으며,

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

코루틴은 이에 대한 BlockingCoroutine 및 GlobalScope와 같은 다양한 스코프 구현체들을 제공합니다.

코루틴을 사용하면 스레드 관리가 훨씬 쉬워지며, 비동기 작업을 동기 코드처럼 간단하게 작성할 수 있습니다. 이는 서버 프로그래밍이나 대규모 비동기 작업에 매우 유리합니다. 다만 그에 대한 러닝커브는 저는 크다고 생각합니다.

확장 함수 (Extension Functions)

이거야말로 Java -> Kotlin으로 넘어갔을 때 신세계이지 않을까라는 생각해봅니다.
Kotlin은 기존 클래스에 새로운 메서드를 추가할 수 있는 확장 함수를 제공합니다. Java에서는 이를 위해 유틸리티 클래스를 작성해야 하는데,

fun String.isPalindrome(): Boolean {
    return this == this.reversed()
}

val word = "madam"
println(word.isPalindrome())  // true

이처럼 확장 함수는 코드 가독성을 높이고, 특정 클래스에 대해 보다 직관적인 방식으로 기능을 추가할 수 있는 방법을 제공합니다. 또한 코틀린의 확장 함수는 별도의 디자인 패턴이나 특정 클래스에 대한 상속 없이 메서드를 확장할 수 있게 해줍니다.
저도 확장 함수 사용하면서 편리성을 느끼고 직접 커스텀하면서 유틸 패키지를 분리한 경험이 있었습니다. 특히 String과 같이 문자 조작이나 참조 타입에 대한 Boolean 값 여부를 판별하는데에 용이했어요. 앞으로 규모가 커진다면 common 모듈에 라이브러리화하여 선언도 가능할 수 있도록 좋을 거 같습니다.


지금까지 Java와 비교를 하면서, Kotlin에 대한 장점을 알아보았습니다.
저도 아직은 Java도 잘하는 편은 아니고 익숙해져야 할 필요가 있지만, 굳이 현 시점에서 Java만을 고집할 필요는 없고, 점점 녹아들어서 Kotlin에 대해 병렬적으로 공부해볼 필요는 있다고 생각합니다.
간결성, Java와의 상호 운용성, Null Safety와 같은 안정성, Android 개발과의 호환성과 같은 장점들을 나열해볼 수 있고, 팀 내부에서 논의 후 스택 선정 시 Kotlin도 좋은 고려 사항인 듯 해보입니다.


참고

profile
안녕하세요 백엔드 개발자 차윤범입니다 :)

0개의 댓글