Kotlin 문법 복습 (1/4)

다음 편: [Kotlin] 2. 클래스와 객체

Kotlin 문법 복습 시리즈 (1/4)

Java 개발자가 Kotlin을 다시 잡을 때 빠르게 복습하는 시리즈다.

다음 편: 2 클래스와 객체

Java를 쓰다가 Kotlin을 잡으면, 가장 먼저 손에 안 익는 게 변수 선언이다. 타입이 앞에 오는 게 아니라 뒤에 오고, final을 붙이는 게 아니라 val을 쓰고, 세미콜론도 없다. 사소한 차이지만 매일 치는 코드의 기본기가 다르면 모든 줄이 어색해진다.

이 문서에서는 Kotlin의 가장 기초적인 요소인 변수, 타입, 함수, 제어문을 다시 훑는다. 단순히 문법만 나열하는 게 아니라, Java와 비교했을 때 왜 이렇게 설계됐는지를 함께 짚는다.

변수 선언

Kotlin에는 변수를 선언하는 키워드가 두 개뿐이다. valvar.

val name: String = "블로그"
var count: Int = 0
  • val — 한 번 할당하면 바꿀 수 없다. Java의 final과 같다.
  • var — 재할당 가능한 변수다.

Java에서 final을 잘 안 붙이는 습관이 있었다면, Kotlin에서는 사고방식을 뒤집어야 한다. Kotlin에서는 val이 기본이고, var는 예외다. 변수를 선언할 때 "이걸 나중에 바꿀 일이 있나?" 한 번 더 생각하게 되는데, 이건 언어가 의도한 것이다. 불변을 기본으로 삼으면 코드의 예측 가능성이 높아진다.

타입 추론

Java에서 var가 Java 10에 추가된 것처럼, Kotlin은 처음부터 타입 추론을 지원한다. 대입하는 값을 보고 컴파일러가 타입을 알아서 결정한다.

val name = "블로그"      // String으로 추론
var count = 0            // Int로 추론
val pi = 3.14            // Double로 추론
val isActive = true      // Boolean으로 추론

그렇다고 모든 곳에서 타입을 생략하는 게 좋은 건 아니다. 오른쪽만 봤을 때 타입이 바로 보이면 생략하고, 아니면 명시하는 게 낫다.

// 명시하는 게 나은 경우 — 함수 반환값이 뭔지 바로 안 보일 때
val result: PostResponse = postService.getPost(1L)

// 생략해도 되는 경우 — 리터럴이나 생성자 호출
val maxRetry = 3
val blogName = "내 블로그"
val post = Post(title = "제목", content = "내용")
팀 프로젝트라면

타입 명시 기준은 팀 컨벤션으로 정하면 된다. 개인 프로젝트라면 본인이 나중에 읽었을 때 헷갈리지 않는 선에서 결정하자.


기본 타입

Java는 int(primitive)와 Integer(wrapper)를 구분한다. int는 null이 안 되고 빠르지만, 제네릭에서는 Integer를 써야 하고 — 이런 이원 구조가 늘 귀찮았다.

Kotlin은 이 구분이 없다. 코드에서는 Int 하나만 쓴다.

val age: Int = 25
val price: Long = 10000L
val rate: Double = 3.14
val initial: Char = 'K'
val flag: Boolean = true

그러면 성능은 어떻게 되냐? 컴파일러가 상황에 따라 알아서 최적화한다. nullable이면 wrapper, 아니면 primitive로 컴파일된다.

val a: Int = 42       // → JVM에서 int (primitive)
val b: Int? = 42      // → JVM에서 Integer (wrapper, null을 담아야 하니까)

개발자가 primitive/wrapper를 신경 쓸 필요 없이 Int 하나로 통일하되, JVM에서는 가능한 한 primitive를 쓰도록 컴파일러가 처리해주는 것이다.


문자열

문자열 템플릿

Java에서 문자열을 조합할 때 +로 이어 붙이거나 String.format()을 쓰는 건 늘 번거로웠다. Kotlin은 $ 기호로 변수를 직접 문자열 안에 넣을 수 있다.

val name = "관리자"
val age = 28
val msg = "안녕, $name! 나이: $age"
  • $변수명으로 단순 변수를 삽입한다.

표현식이 들어가면 {}로 감싼다.

val msg = "글 수: ${posts.size}"
val info = "결과: ${if (success) "성공" else "실패"}"
  • ${표현식} 안에는 함수 호출, 조건문 등 어떤 표현식이든 넣을 수 있다.

Java의 "안녕, " + name + "! 나이: " + age와 비교하면, 가독성 차이가 확연하다. 특히 변수가 3개 이상 들어가는 문자열에서 체감이 크다.

여러 줄 문자열

HTML, JSON, SQL 같은 긴 문자열을 다룰 때 Java에서는 이스케이프 문자와 + 연결이 지저분했다. Kotlin은 """ 세 개로 감싸면 된다.

val html = """
    <div>
        <h1>$title</h1>
        <p>$content</p>
    </div>
""".trimIndent()
  • """...""" 안에서는 이스케이프 문자(\n, \")가 필요 없다.
  • trimIndent()를 붙이면 코드의 들여쓰기를 기준으로 공백을 자동 정리해준다.

함수

기본 선언

fun greet(name: String): String {
    return "안녕, $name"
}
  • fun 키워드로 시작한다.
  • 파라미터는 이름: 타입 순서다. Java와 반대.
  • 반환 타입은 : 뒤에 쓴다.

함수 본문이 한 줄이면 중괄호와 return을 생략하고 =으로 쓸 수 있다. 이것을 단일 표현식 함수라고 한다.

fun greet(name: String) = "안녕, $name"
  • 반환 타입도 추론된다. 짧은 유틸 함수에서 자주 쓴다.

반환값이 없는 함수는 Unit을 반환한다. Java의 void에 해당하는데, 생략할 수 있다.

fun printHello() {
    println("hello")
}
// 위와 아래는 동일하다
fun printHello(): Unit {
    println("hello")
}

기본값 파라미터

Java에서 메서드 오버로딩이 왜 많아지는지 생각해보면, 대부분 "이 파라미터를 안 넘기면 기본값을 쓰고 싶어서"다. Kotlin은 기본값 파라미터로 이 문제를 해결한다.

// Java — 오버로딩 2개
public String greet(String name) { return greet(name, "님"); }
public String greet(String name, String suffix) {
    return "안녕, " + name + suffix;
}

// Kotlin — 기본값 하나로 끝
fun greet(name: String, suffix: String = "님") = "안녕, $name$suffix"
  • suffix에 기본값 "님"을 지정했다.
  • 호출 시 suffix를 넘기지 않으면 기본값이 사용된다.
greet("관리자")              // "안녕, 관리자님"
greet("관리자", "씨")        // "안녕, 관리자씨"

오버로딩 2~3개를 만들어야 했던 상황이 파라미터 기본값 하나로 사라진다.

Named Argument

파라미터가 4~5개 이상인 함수를 호출할 때, Java에서는 어떤 값이 어떤 파라미터인지 알기 어렵다. 그래서 Builder 패턴을 쓰곤 했다.

Kotlin에서는 이름을 명시해서 호출할 수 있기 때문에 빌더가 대부분 필요 없다.

fun createPost(
    title: String,
    content: String,
    thumbnail: String? = null,
    isPublished: Boolean = true
) { /* ... */ }

이 함수를 호출할 때 이름을 붙이면 의도가 명확해진다.

createPost(
    title = "첫 번째 글",
    content = "<p>내용</p>",
    isPublished = false
)
  • thumbnail은 기본값이 null이므로 생략했다.
  • 각 값이 어떤 파라미터인지 호출 지점에서 바로 보인다.

기본값 파라미터 + named argument 조합이 Java의 Builder 패턴을 대체한다. 이건 Kotlin에서 반복적으로 등장하는 중요한 패턴이니 기억해두자.


제어문

Java의 제어문과 생김새는 비슷하지만, 근본적인 차이가 하나 있다. Kotlin에서는 ifwhen이 표현식(expression)이다. 값을 반환한다는 뜻이다.


if 표현식

Java에서 삼항 연산자(? :)를 쓰던 상황을 떠올려보자.

// Java
String status = isPublished ? "발행" : "임시저장";

// Kotlin — 삼항 연산자가 없다
val status = if (isPublished) "발행" else "임시저장"

삼항 연산자가 없는 게 아니라 필요가 없는 것이다. if 자체가 값을 반환하니까. 블록 안에 여러 줄이 있으면 마지막 줄이 반환값이 된다.

val max = if (a > b) {
    println("a가 더 크다")
    a   // 마지막 줄이 반환값
} else {
    println("b가 더 크다")
    b
}
  • 블록의 마지막 표현식이 반환값이라는 규칙은 Kotlin 전반에서 반복된다. when, 람다, run 등 모든 곳에서 동일하다.

when 표현식

Java의 switch를 완전히 대체하면서 훨씬 강력하다.

val message = when (status) {
    200 -> "성공"
    401 -> "인증 실패"
    404 -> "찾을 수 없음"
    in 500..599 -> "서버 에러"
    else -> "알 수 없는 상태"
}
  • break가 필요 없다. fall-through가 없기 때문이다.
  • in 키워드로 범위 매칭이 가능하다.
  • if와 마찬가지로 값을 반환하는 표현식이다.

when의 진짜 강력함은 단순 값 비교를 넘어서 타입 체크, 조건식까지 가능하다는 점이다.

fun describe(obj: Any): String = when (obj) {
    is Int -> "정수: $obj"
    is String -> "문자열 길이: ${obj.length}"
    is Boolean -> if (obj) "참" else "거짓"
    else -> "알 수 없는 타입"
}
  • is 키워드로 타입을 체크하면, 해당 분기 안에서 자동 캐스팅(스마트 캐스트)이 된다. obj.length처럼 캐스팅 없이 바로 쓸 수 있다.

인자 없이 when만 쓰면 if-else if 체인을 대체할 수 있다. 조건이 3개 이상일 때 가독성이 좋다.

fun classify(score: Int) = when {
    score >= 90 -> "A"
    score >= 80 -> "B"
    score >= 70 -> "C"
    else -> "F"
}

for 루프

for (i in 1..10) { }           // 1부터 10까지 (10 포함)
for (i in 1 until 10) { }     // 1부터 9까지 (10 미포함)
for (i in 10 downTo 1) { }    // 10부터 1까지
for (i in 1..10 step 2) { }   // 1, 3, 5, 7, 9
  • ..은 양쪽 끝을 포함하는 범위다.
  • until은 끝을 제외하는 범위다. 배열 인덱스 순회할 때 주로 쓴다.
  • downTo는 역순, step은 간격을 지정한다.

컬렉션 순회는 Java의 향상된 for문과 비슷하다. 인덱스가 필요하면 withIndex()를 쓴다.

for (post in posts) { }

for ((index, post) in posts.withIndex()) {
    println("$index: ${post.title}")
}

while은 Java와 동일하다. 특별한 차이 없다.


심화 분석

val vs const val

val은 런타임에 한 번 할당되는 변수이고, const val컴파일 타임 상수다.

val currentTime = System.currentTimeMillis()   // 런타임에 결정
const val MAX_RETRY = 3                         // 컴파일 타임에 결정
  • const valString과 primitive 타입만 가능하다.
  • 반드시 최상위 레벨 또는 object/companion object 안에 선언해야 한다.

Java의 static final과 같은 역할이다. 상수를 정의할 때 val이 아닌 const val을 쓰면 컴파일러가 사용 지점에 값을 직접 인라인하므로 미세하게 성능이 좋다.

타입 추론의 한계

타입 추론이 만능은 아니다. 함수의 반환 타입은 블록 본문({})일 때 생략할 수 없다.

// 단일 표현식 — 반환 타입 추론 가능
fun double(x: Int) = x * 2

// 블록 본문 — 반환 타입 명시 필수
fun double(x: Int): Int {
    return x * 2
}
  • 단일 표현식 함수(=)는 반환 타입이 추론된다.
  • 블록 본문 함수({})는 반환 타입을 명시해야 한다. 안 쓰면 Unit으로 간주한다.

이건 의도적인 설계다. 블록 본문은 복잡할 수 있으므로, 반환 타입을 명시하게 해서 코드 읽는 사람이 바로 파악할 수 있도록 한 것이다.


자주 하는 실수

val은 "불변"이 아니라 "재할당 불가"다

이 차이를 모르면 나중에 혼란이 온다.

val list = mutableListOf("A", "B")
list.add("C")                      // 가능! 내용을 바꾸는 거다
// list = mutableListOf("X")       // 컴파일 에러! 재할당은 안 된다

val은 변수가 가리키는 참조를 바꿀 수 없다는 뜻이지, 객체의 내부 상태를 바꿀 수 없다는 뜻이 아니다. 비유하자면 "집 주소는 못 바꾸지만, 집 안 가구 배치는 바꿀 수 있다"는 것이다.

진짜 불변 컬렉션을 원한다면 listOf()를 써야 한다. 이건 컬렉션 편에서 다룬다.

타입 변환은 명시적으로

Java에서는 intlong에 대입하면 자동으로 넓어지는 변환(widening)이 일어난다. Kotlin은 이걸 허용하지 않는다.

val intVal: Int = 42
// val longVal: Long = intVal     // 컴파일 에러!
val longVal: Long = intVal.toLong()
  • toInt(), toLong(), toDouble(), toString() 등을 명시적으로 호출해야 한다.

왜 이렇게 했을까? 암묵적 변환은 의도치 않은 정밀도 손실이나 오버플로우를 숨길 수 있다. Kotlin은 "개발자가 모르는 사이에 타입이 바뀌는 것"을 막는 방향으로 설계됐다.

산술 연산에서의 함정

정수끼리 나눗셈하면 Java와 마찬가지로 정수 결과가 나온다. 소수점이 필요하면 한쪽을 Double로 변환해야 한다.

```kotlin

val result = 7 / 2 // 3 (정수 나눗셈)

val result = 7.0 / 2 // 3.5

val result = 7 / 2.toDouble() // 3.5

```