Kotlin 언어 핵심 정리

Kotlin은 JVM에서 실행되는 정적 타입 프로그래밍 언어다. JetBrains에서 개발했으며, Java와 높은 호환성을 가지면서도 더 간결한 문법과 현대적인 언어 기능을 제공하는 것을 목표로 만들어졌다.

현재 Kotlin은 Android 개발에서 널리 사용되고 있으며, 서버 개발에서는 Spring과 함께 사용할 수도 있다. JVM 기반이기 때문에 기존 Java 라이브러리와 생태계를 그대로 활용할 수 있다는 것도 큰 장점이다.

Kotlin 코드는 일반적으로 다음과 같은 흐름으로 실행된다.

Kotlin Source -> Kotlin Compiler -> JVM Bytecode -> JVM -> Machine Code

Java와 마찬가지로 Kotlin 자체와 JVM을 구분해서 보는 것이 좋다. Kotlin은 언어이고 JVM은 Kotlin 코드를 실행하는 런타임 환경이다.


변수 선언

Kotlin에서는 valvar로 변수를 선언한다.

val name = "Player"
var health = 100

val은 재할당할 수 없는 변수다.

val score = 100

// score = 200 // 오류

var는 재할당할 수 있다.

var score = 100

score = 200

Kotlin에서는 타입 추론을 적극적으로 사용한다.

val health = 100
val speed = 3.5
val name = "Player"

필요하면 타입을 명시할 수도 있다.

val health: Int = 100
val name: String = "Player"

val은 C++의 const, C#의 readonly와 비슷한 느낌으로 볼 수 있지만 완전히 같은 개념은 아니다. 특히 객체를 val로 선언해도 객체 내부의 가변 상태까지 불변이 되는 것은 아니다.

val player = Player()

player.health = 50

참조 자체는 바꿀 수 없지만 객체 내부의 health는 변경할 수 있다.


기본 타입

Kotlin에는 다음과 같은 기본적인 타입이 있다.

Byte / Short / Int / Long / Float / Double / Boolean / Char / String

예를 들어:

val age: Int = 24
val distance: Double = 100.5
val alive: Boolean = true
val grade: Char = 'A'
val name: String = "Player"

Java와 달리 Kotlin의 타입 체계에서는 기본 타입을 객체 타입과 별개로 개발자가 직접 구분해서 사용할 필요가 적다.

컴파일 과정에서 JVM의 primitive type으로 최적화될 수 있지만, Kotlin 코드에서는 Int, Double 같은 일관된 타입 모델을 사용한다.


함수

Kotlin의 함수 선언은 상당히 간결하다.

fun add(a: Int, b: Int): Int
{
    return a + b
}

반환 타입이 명확한 단순 함수는 표현식 형태로 작성할 수도 있다.

fun add(a: Int, b: Int) = a + b

함수의 타입도 표현할 수 있다.

val add: (Int, Int) -> Int = { a, b ->
    a + b
}

함수를 변수에 저장하거나 다른 함수에 전달할 수 있다.

이 특성은 Kotlin의 컬렉션 처리, 비동기 처리, DSL 등의 기반이 된다.


클래스

Kotlin의 클래스는 Java보다 상당히 간결하다.

class Player
{
    var health: Int = 100

    fun attack()
    {
        println("Attack")
    }
}

객체를 생성한다.

val player = Player()

player.health = 50
player.attack()

Java처럼 new를 사용할 필요가 없다.

Java -> new Player()
Kotlin -> Player()

생성자

Kotlin에서는 주 생성자를 클래스 선언부에 바로 작성할 수 있다.

class Player(
    val name: String,
    var health: Int
)

이렇게 하면 생성자 매개변수가 프로퍼티로도 사용할 수 있다.

val player = Player("Knight", 100)

println(player.name)
println(player.health)

Java에서는 보통 필드와 생성자, getter/setter 등을 따로 작성해야 하지만 Kotlin에서는 이를 상당 부분 줄일 수 있다.

추가적인 초기화 작업이 필요하면 init 블록을 사용할 수 있다.

class Player(
    val name: String,
    var health: Int
)
{
    init
    {
        println("Player created")
    }
}

프로퍼티

Kotlin에서는 필드와 getter/setter를 직접 구분하기보다 프로퍼티라는 개념을 사용한다.

class Player
{
    var health: Int = 100
}

사용할 때는:

player.health = 50

println(player.health)

하지만 내부적으로는 JVM에서 getter/setter 형태의 메서드가 생성될 수 있다.

직접 getter/setter를 제어할 수도 있다.

class Player
{
    var health: Int = 100
        private set
}

이제 클래스 외부에서는 값을 읽을 수 있지만 변경할 수 없다.

println(player.health)

// player.health = 50 // 오류

상태 변경을 클래스 내부에서만 허용할 때 유용하다.


Null Safety

Kotlin에서 가장 눈에 띄는 기능 중 하나가 Null Safety다.

Java에서는 다음과 같은 코드가 흔하다.

Player player = findPlayer();

player.attack();

findPlayer()null을 반환할 수 있다면 런타임에 NullPointerException이 발생할 수 있다.

Kotlin에서는 nullable 타입을 명시한다.

var player: Player? = null

?가 붙은 타입은 null이 될 수 있다는 의미다.

var player: Player? = null

player = Player()
player = null

nullable 객체의 메서드를 바로 호출할 수는 없다.

// player.attack()

대신 안전 호출 연산자를 사용한다.

player?.attack()

값이 null이면 호출하지 않고 null을 반환한다.


Elvis 연산자

null인 경우 기본값을 사용하고 싶다면 ?:를 사용한다.

val health = player?.health ?: 0

의미는 다음과 같다.

player가 존재한다 -> health 사용
player가 null이다 -> 0 사용

Java에서 null 체크를 반복해서 작성하던 코드를 상당히 간결하게 만들 수 있다.


Non-null Assertion

정말 null이 아니라고 확신하는 경우 !!를 사용할 수 있다.

val player: Player? = findPlayer()

player!!.attack()

하지만 !!는 nullable 타입을 강제로 non-null 타입처럼 취급하는 것이다.

실제로 null이면 런타임 예외가 발생한다.

따라서 Null Safety를 우회하기 위해 습관적으로 사용하는 것은 좋지 않다.


조건문

Kotlin의 if는 값을 반환할 수 있다.

val result =
    if (score >= 60)
    {
        "Pass"
    }
    else
    {
        "Fail"
    }

따라서 삼항 연산자가 별도로 필요하지 않다.

Java -> condition ? a : b
Kotlin -> if (condition) a else b

when

Java의 switch와 비슷한 역할은 Kotlin에서 when이 담당한다.

when (state)
{
    0 -> println("Idle")
    1 -> println("Walk")
    2 -> println("Attack")
    else -> println("Unknown")
}

값을 반환할 수도 있다.

val stateName =
    when (state)
    {
        0 -> "Idle"
        1 -> "Walk"
        2 -> "Attack"
        else -> "Unknown"
    }

조건을 여러 개 조합하는 것도 가능하다.

when
{
    health <= 0 -> "Dead"
    health < 30 -> "Danger"
    else -> "Normal"
}

복잡한 조건 분기에서는 when이 상당히 유용하다.


반복문

기본적인 반복문은 다음과 같다.

for (i in 0 until 10)
{
    println(i)
}

컬렉션도 직접 순회할 수 있다.

for (player in players)
{
    player.attack()
}

범위 표현도 제공한다.

0..10

0부터 10까지 포함한다.

0 until 10

0부터 9까지다.

10 downTo 0

10부터 0까지 감소한다.


컬렉션

Kotlin은 컬렉션을 크게 읽기 전용 컬렉션과 변경 가능한 컬렉션으로 구분한다.

val players: List<Player> = listOf(
    Player("A", 100),
    Player("B", 200)
)

변경 가능한 컬렉션은 다음과 같이 만들 수 있다.

val players = mutableListOf<Player>()

players.add(Player("A", 100))
players.add(Player("B", 200))

대표적인 컬렉션은 다음과 같다.

List / Set / Map / MutableList / MutableSet / MutableMap

읽기 전용 타입을 사용하면 컬렉션을 수정하는 코드가 필요한 곳과 그렇지 않은 곳을 구분할 수 있다.


컬렉션 처리

Kotlin은 컬렉션 처리 함수가 상당히 잘 구성되어 있다.

val scores = listOf(10, 20, 30, 40)

val result = scores
    .filter { it >= 20 }
    .map { it * 2 }

흐름은 다음과 같다.

List -> filter -> map -> Result

대표적으로 다음과 같은 함수들이 있다.

filter / map / flatMap / find / first / any / all / none / count / sorted / groupBy / reduce / fold

간단한 데이터 처리에서는 반복문보다 코드의 의도를 표현하기 좋다.

다만 여러 연산을 무조건 연결하는 것이 좋은 것은 아니다. 복잡한 로직에서는 일반적인 for문이 더 명확할 수 있다.


람다

Kotlin에서는 람다를 상당히 자연스럽게 사용할 수 있다.

val add = { a: Int, b: Int ->
    a + b
}

컬렉션에서도 사용한다.

players.forEach { player ->
    println(player.name)
}

매개변수가 하나라면 it을 사용할 수 있다.

players.forEach {
    println(it.name)
}

람다가 많아지면 코드가 짧아지는 대신 오히려 읽기 어려워질 수 있기 때문에 적절한 수준에서 사용하는 것이 좋다.


확장 함수

Kotlin에서 상당히 편리한 기능 중 하나가 확장 함수다.

기존 클래스를 수정하지 않고 새로운 함수를 추가하는 것처럼 사용할 수 있다.

fun String.isPlayerName(): Boolean
{
    return this.length >= 3
}

사용할 때는:

val name = "Player"

name.isPlayerName()

클래스 자체에 실제 메서드를 추가하는 것은 아니다.

컴파일된 JVM 코드에서는 정적 메서드 형태로 처리되며, 호출 문법만 해당 객체의 메서드처럼 보이게 만들어준다.


데이터 클래스

데이터를 저장하기 위한 클래스는 data class를 사용할 수 있다.

data class Player(
    val id: Int,
    val name: String,
    val health: Int
)

이렇게 하면 equals(), hashCode(), toString(), copy() 같은 기능이 자동으로 생성된다.

val player =
    Player(1, "Knight", 100)

val damagedPlayer =
    player.copy(health = 50)

DTO나 설정값, 상태 데이터 등을 표현할 때 유용하다.


객체와 Singleton

Kotlin에는 object라는 문법이 있다.

object GameManager
{
    var playerCount = 0

    fun start()
    {
        println("Game Start")
    }
}

객체를 별도로 생성하지 않고 사용할 수 있다.

GameManager.start()

하나의 객체만 존재하도록 만드는 Singleton 형태로 사용할 수 있다.

다만 전역 상태를 쉽게 만들 수 있다는 장점과 함께 의존성이 숨겨진다는 문제가 있기 때문에 무조건 object를 사용하는 것은 적절하지 않다.


Companion Object

Java의 static과 비슷한 용도로 companion object를 사용할 수 있다.

class Player
{
    companion object
    {
        const val MAX_HEALTH = 100

        fun create(): Player
        {
            return Player()
        }
    }
}

사용할 때는:

Player.MAX_HEALTH
Player.create()

Kotlin에는 Java의 static 키워드가 없다.

대신 클래스에 소속된 정적 성격의 기능이 필요하면 companion object 등을 사용한다.


상속

Kotlin의 클래스는 기본적으로 상속할 수 없다.

class Player
{
}

상속을 허용하려면 open을 붙인다.

open class Character
{
    open fun attack()
    {
        println("Attack")
    }
}

class Player : Character()
{
    override fun attack()
    {
        println("Player Attack")
    }
}

Java에서는 일반 클래스가 기본적으로 상속 가능하고 final로 막는 방식이지만 Kotlin은 반대다.

기본적으로 상속을 막아 놓고 명시적으로 open을 사용해야 한다.

상속 구조를 제한해서 의도하지 않은 확장을 줄이려는 설계다.


인터페이스

Kotlin에서도 인터페이스를 사용할 수 있다.

interface Damageable
{
    fun takeDamage(amount: Int)
}

구현은 다음과 같다.

class Player : Damageable
{
    override fun takeDamage(amount: Int)
    {
        println("Damage: $amount")
    }
}

여러 인터페이스를 구현할 수도 있다.

class Player :
    Character(),
    Damageable,
    Movable
{
}

Java와 마찬가지로 인터페이스를 이용하면 클래스 간의 기능적 계약을 표현할 수 있다.


sealed class

상태나 결과처럼 제한된 종류의 타입을 표현할 때 sealed class를 사용할 수 있다.

sealed class Result

data class Success(val value: Int) : Result()

data class Failure(val message: String) : Result()

이후 when과 조합할 수 있다.

fun handle(result: Result)
{
    when (result)
    {
        is Success ->
            println(result.value)

        is Failure ->
            println(result.message)
    }
}

컴파일러가 가능한 하위 타입을 추적할 수 있기 때문에 상태 머신이나 성공/실패 결과 같은 구조를 표현할 때 유용하다.


Enum

고정된 값의 집합은 enum class로 표현할 수 있다.

enum class State
{
    IDLE,
    MOVE,
    ATTACK,
    DEAD
}

사용할 때는:

val state = State.ATTACK

게임 개발에서 캐릭터 상태나 아이템 타입처럼 제한된 값을 표현하는 경우 자주 사용할 수 있다.


제네릭

Kotlin에서도 제네릭을 사용할 수 있다.

class Container<T>(
    val value: T
)

사용할 때:

val intContainer = Container(100)
val stringContainer = Container("Hello")

컬렉션도 제네릭을 사용한다.

val players: List<Player>
val scores: List<Int>

Java와 마찬가지로 타입 안정성을 유지하면서 여러 타입에서 재사용할 수 있는 코드를 작성하는 데 사용한다.


스마트 캐스트

Kotlin 컴파일러는 특정 조건에서 타입을 자동으로 좁혀준다.

fun printLength(value: Any)
{
    if (value is String)
    {
        println(value.length)
    }
}

if 내부에서는 valueString이라는 것을 컴파일러가 알고 있기 때문에 별도의 캐스팅을 작성하지 않아도 된다.

이런 기능을 Smart Cast라고 한다.

Java에서 다음과 같이 직접 캐스팅하는 코드보다 간결하다.

if (value instanceof String) {
    String text = (String)value;
}

타입 캐스팅

명시적으로 타입을 변환할 수도 있다.

val value: Any = "Hello"

val text = value as String

잘못된 타입을 변환하면 런타임 예외가 발생할 수 있다.

안전한 캐스팅은 as?를 사용한다.

val text = value as? String

실패하면 null을 반환한다.

as -> 실패하면 예외
as? -> 실패하면 null

고차 함수

함수를 매개변수로 받거나 함수를 반환하는 함수를 만들 수 있다.

fun execute(
    value: Int,
    operation: (Int) -> Int
): Int
{
    return operation(value)
}

사용할 때:

val result = execute(10) {
    it * 2
}

이 기능은 Kotlin API 설계에서도 많이 활용된다.

특히 자원 관리, 컬렉션 처리, DSL 같은 구조를 만들 때 유용하다.


Scope Function

Kotlin에는 객체를 특정 문맥에서 처리하기 위한 Scope Function이 있다.

대표적으로:

let / run / with / apply / also

예를 들어:

val player = Player().apply {
    health = 100
}

객체를 생성한 뒤 여러 프로퍼티를 초기화하는 코드에서 자주 사용한다.

let은 null 처리와 함께 많이 사용한다.

player?.let {
    println(it.name)
}

다만 Scope Function을 과하게 중첩하면 오히려 코드 흐름을 추적하기 어려워진다.


예외 처리

Kotlin의 예외 처리는 Java와 비슷하다.

try
{
    val value = input.toInt()
}
catch (e: NumberFormatException)
{
    println("Invalid number")
}

하지만 Java와 큰 차이가 하나 있다.

Kotlin에는 Java의 checked exception이 없다.

따라서 다음과 같은 메서드 선언에서 throws를 강제하지 않는다.

fun readFile()
{
    // 예외 발생 가능
}

이 부분은 Java와 Kotlin을 비교할 때 상당히 큰 차이다.


문자열

Kotlin은 문자열 처리 문법이 편리하다.

val name = "Player"
val level = 10

val text = "Name: $name, Level: $level"

복잡한 표현식도 사용할 수 있다.

val text = "Level: ${level + 1}"

여러 줄 문자열은 Triple Quote를 사용한다.

val json = """
{
    "name": "Player",
    "level": 10
}
""".trimIndent()

Coroutine

Kotlin에서 비동기 프로그래밍을 이야기할 때 Coroutine을 빼놓기 어렵다.

Coroutine은 스레드와 같은 OS 수준 실행 단위가 아니다. 가벼운 작업 단위를 표현하고, suspend 지점에서 실행을 중단했다가 나중에 재개할 수 있도록 하는 Kotlin의 동시성 추상화다.

간단한 형태는 다음과 같다.

suspend fun loadPlayer(): Player
{
    // 비동기 작업
}

Coroutine을 실행하는 쪽에서는:

launch
{
    val player = loadPlayer()

    println(player.name)
}

Coroutine은 스레드보다 훨씬 가볍게 많은 작업을 구성할 수 있다.

하지만 Coroutine이 알아서 병렬 실행을 보장하는 것은 아니다.

실제로 어떤 스레드에서 실행되는지는 Dispatcher와 Coroutine Context 등에 의해 결정된다.

Coroutine -> Dispatcher -> Thread / Thread Pool

이 차이를 이해하지 않고 Coroutine을 단순히 “가벼운 스레드”라고 생각하면 문제가 생긴다.


suspend

suspend는 Coroutine에서 실행을 중단하고 나중에 다시 재개할 수 있는 함수를 표현한다.

suspend fun loadData()
{
    delay(1000)
}

delay()는 현재 스레드를 1초 동안 붙잡아 두는 Thread.sleep()과 다르다.

Coroutine을 일시 중단하고 스레드를 다른 작업에 사용할 수 있다.

Coroutine A -> suspend -> Thread 반환 -> 다른 Coroutine 실행 -> Coroutine A 재개

이런 구조 때문에 I/O 중심의 비동기 작업을 효율적으로 구성할 수 있다.


JVM과 Kotlin

Kotlin/JVM에서는 Kotlin 코드가 JVM 바이트코드로 컴파일된다.

Kotlin -> Kotlin Compiler -> Bytecode -> JVM

Java와 같은 JVM을 사용하기 때문에 Java 라이브러리를 그대로 사용할 수 있다.

import java.util.ArrayList

Java로 작성된 클래스도 Kotlin에서 사용할 수 있다.

반대로 Kotlin 코드도 JVM 환경에서 Java와 함께 사용할 수 있다.

이런 상호운용성은 Kotlin이 기존 Java 생태계에 빠르게 들어갈 수 있었던 이유 중 하나다.


Java와 Kotlin의 관계

Kotlin을 이해할 때 Java와 비교하면 특징이 명확해진다.

Java -> 명시적인 문법이 많음 / Getter / Setter 코드가 길어질 수 있음 / Checked Exception 존재 / null 안정성을 언어 차원에서 직접 보장하지 않음 / class가 기본적으로 상속 가능

Kotlin -> 간결한 문법 / Property / Null Safety / Checked Exception 없음 / class가 기본적으로 final / 확장 함수 / Data Class / Coroutine

둘은 서로 대체 관계라기보다 JVM이라는 같은 생태계 안에서 함께 사용할 수 있는 언어에 가깝다.

특히 기존 Java 프로젝트에 Kotlin 코드를 점진적으로 추가할 수 있다는 것이 실용적인 장점이다.


Kotlin의 실행 구조

Kotlin을 전체적으로 보면 다음과 같이 연결된다.

Kotlin Syntax -> Type System -> Class / Object -> Property -> Function / Lambda -> Collection -> Null Safety -> Generic -> Extension Function -> Coroutine -> JVM

그리고 실행 환경에 따라 활용 영역이 나뉜다.

Kotlin -> Kotlin/JVM / Kotlin/JS / Kotlin/Native
Kotlin/JVM -> Android / Server

Kotlin은 JVM 언어로 시작했지만 현재는 JVM에만 한정된 언어는 아니다.


Kotlin의 장점과 한계

Kotlin의 가장 큰 장점은 Java 생태계를 그대로 활용하면서 Java보다 간결한 코드를 작성할 수 있다는 점이다.

특히 다음 기능들이 실제 코드의 양을 많이 줄인다.

Type Inference / Property / Data Class / Null Safety / Lambda / Extension Function / Smart Cast / Collection API / Coroutine

반면 Kotlin의 기능이 많은 만큼 처음에는 언어 자체가 상당히 복잡하게 느껴질 수 있다.

Scope Function, Lambda, Extension Function, Operator Overloading, Coroutine 등을 무분별하게 조합하면 짧지만 읽기 어려운 코드가 만들어질 수도 있다.

또한 JVM 위에서 실행되는 Kotlin/JVM에서는 Java와 마찬가지로 GC와 JVM 런타임의 특성을 고려해야 한다.


정리

Kotlin을 큰 흐름으로 정리하면 다음과 같다.

기본 문법 -> 정적 타입 -> Class / Object -> Property -> Null Safety -> Function / Lambda -> Collection -> Generic -> Extension Function -> Data Class / Sealed Class -> Coroutine -> JVM

Kotlin은 Java의 문법을 조금 간결하게 만든 언어 정도로 보면 부족하다.

Java와 같은 JVM 생태계를 사용하면서도 언어 차원에서 null 안정성, 타입 추론, 확장 함수, 데이터 클래스, 함수형 문법, Coroutine 같은 기능을 제공한다.

특히 Kotlin을 이해할 때는 Java와의 문법 차이보다 Kotlin이 어떤 문제를 언어 차원에서 해결하려 했는지를 보는 편이 좋다.

Java에서 반복적으로 작성해야 했던 보일러플레이트를 줄이고, null과 타입 문제를 컴파일 단계에서 잡고, 함수와 객체를 조합해 코드를 구성하는 방향으로 설계되어 있다.

Android에서는 Kotlin이 사실상 주요 개발 언어로 자리 잡았고, 서버에서는 Spring과 결합해 Java 생태계를 그대로 활용할 수 있다.

Kotlin의 핵심을 하나의 흐름으로 보면 결국 정적 타입 + JVM 호환성 + 간결한 객체지향 문법 + 함수형 기능 + Null Safety + Coroutine으로 정리할 수 있다.