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

이전 편: 2 클래스와 객체

다음 편: 4 확장 함수, 스코프 함수, 람다

Java 개발자라면 NullPointerException에 한 번쯤은 데여봤을 것이다. 아무리 조심해도 런타임에 갑자기 터진다. null 체크를 빼먹은 건 개발자 실수라고 치부하지만, 사실 이건 언어가 null을 제대로 관리하지 않아서 생기는 문제다.

Kotlin은 이 문제를 타입 시스템 레벨에서 해결한다. 변수가 null이 될 수 있는지 없는지를 컴파일 타임에 구분한다. 덕분에 NPE가 나는 코드는 대부분 컴파일 자체가 안 된다.

컬렉션도 마찬가지다. Java에서는 List를 받으면 그게 수정 가능한지 불가능한지 알 수 없다. Kotlin은 불변 컬렉션과 가변 컬렉션을 타입 레벨에서 분리한다.

이 문서에서는 Kotlin의 null safety와 컬렉션 시스템을 복습한다.

Nullable과 Non-null

Kotlin의 모든 타입은 두 가지 버전이 있다.

var name: String = "블로그"     // Non-null — null 대입 불가
var name: String? = null       // Nullable — null 대입 가능
  • String — 절대 null이 아닌 문자열. null을 넣으려 하면 컴파일 에러.
  • String? — null일 수도 있는 문자열. ?가 붙으면 nullable.

이게 왜 강력하냐면, 함수 시그니처만 봐도 null이 올 수 있는지 없는지 알 수 있기 때문이다.

fun findUser(id: Long): User          // 반드시 User를 반환한다
fun findUser(id: Long): User?         // null을 반환할 수도 있다

Java에서는 @Nullable, @NonNull 어노테이션으로 힌트를 주지만 강제는 아니었다. Kotlin은 타입 시스템이 강제하니까 실수할 여지가 없다.


Null 처리 연산자

nullable 타입을 다룰 때 쓸 수 있는 연산자가 세 가지 있다.

Safe Call (?.)

val length = name?.length
  • name이 null이 아니면 length를 반환하고, null이면 전체 표현식이 null이 된다.
  • Java의 if (name != null) name.length() 를 한 줄로 줄인 것이다.

체이닝도 가능하다. 중간에 하나라도 null이면 전체가 null이 된다.

val cityName = user?.address?.city?.name

Java에서 이걸 쓰려면 3단 중첩 if (... != null) 이 필요하다. Kotlin에서는 한 줄이다.


Elvis 연산자 (?:)

null일 때 대체값을 지정한다.

val displayName = user?.name ?: "익명"
  • user?.name이 null이 아니면 그 값을 쓰고, null이면 "익명"을 쓴다.
  • Java의 삼항 연산자 user != null ? user.getName() : "익명" 과 같은 역할이다.

Elvis 연산자의 오른쪽에 return이나 throw를 넣는 패턴도 실무에서 자주 쓴다.

val user = userRepository.findByIdOrNull(id)
    ?: throw BusinessException("사용자를 찾을 수 없습니다")

val config = loadConfig()
    ?: return defaultConfig()
  • null이면 예외를 던지거나 함수를 조기 종료하는 패턴이다.
  • 이 패턴은 코프링에서 Repository 조회할 때 매우 자주 등장한다.

Non-null Assertion (!!)

val length = name!!.length
  • "이 값은 절대 null이 아니다"라고 개발자가 단언하는 것이다.
  • null이면 KotlinNullPointerException이 터진다.
!! 남용 금지

!!를 쓰는 순간 Kotlin의 null safety를 포기하는 것이다. "이건 절대 null 안 돼"라는 확신이 있어도, 미래의 코드 변경으로 그 확신이 깨질 수 있다. 가능하면 ?.?:로 처리하고, !!는 정말 마지막 수단으로만 쓰자.


스마트 캐스트

null 체크를 하고 나면 Kotlin 컴파일러가 해당 스코프에서 자동으로 non-null 타입으로 캐스팅해준다. 이것을 스마트 캐스트라고 한다.

fun printLength(text: String?) {
    if (text != null) {
        // 여기서는 text가 자동으로 String (non-null)으로 캐스팅됨
        println(text.length)  // ?. 없이 바로 접근 가능
    }
}

Java에서 instanceof 체크 후 캐스팅하던 것과 비슷하다.

fun process(obj: Any) {
    if (obj is String) {
        // obj가 자동으로 String으로 캐스팅됨
        println(obj.uppercase())  // 캐스팅 없이 String 메서드 사용
    }
}
  • is로 타입 체크하면, 해당 블록에서 캐스팅이 자동으로 된다.
  • when에서도 동일하게 작동한다.

단, 스마트 캐스트가 안 되는 경우도 있다. var 프로퍼티는 다른 스레드에서 값을 바꿀 수 있기 때문에 스마트 캐스트가 불가능하다. 이런 경우에는 지역 변수에 먼저 담아야 한다.

class MyClass {
    var name: String? = null

    fun printName() {
        // if (name != null) println(name.length)  // 컴파일 에러!
        val localName = name
        if (localName != null) println(localName.length)  // OK
    }
}

불변 컬렉션과 가변 컬렉션

Java의 Listadd(), remove() 메서드를 가지고 있다. Collections.unmodifiableList()로 감쌀 수는 있지만, 타입 시스템에서 구분이 안 된다. 함수가 List<String>을 받으면 이게 수정 가능한 건지 불가능한 건지 알 수 없다.

Kotlin은 이 문제를 타입 레벨에서 해결한다.

val tags = listOf("Spring", "Kotlin")           // List<String> — 불변
val tags = mutableListOf("Spring", "Kotlin")    // MutableList<String> — 가변
  • listOf()List — 읽기 전용. add(), remove() 메서드가 없다.
  • mutableListOf()MutableList — 읽기/쓰기. add(), remove() 가능.

MapSet도 동일한 규칙을 따른다.

val map = mapOf("key" to "value")              // 불변 Map
val map = mutableMapOf("key" to "value")       // 가변 Map

val set = setOf("A", "B")                      // 불변 Set
val set = mutableSetOf("A", "B")               // 가변 Set
  • toPair를 만드는 중위 함수다. "key" to "value"Pair("key", "value")와 같다.

왜 불변이 기본일까? 변수의 val/var과 같은 철학이다. 수정이 필요 없는데 수정 가능한 컬렉션을 쓰면, 누군가 실수로 내용을 바꿀 수 있다. 불변을 기본으로 삼으면 의도치 않은 변경을 막을 수 있고, 특히 멀티스레드 환경에서 안전하다.

"불변"의 의미

listOf()가 반환하는 List인터페이스 레벨에서 수정 메서드가 없다는 뜻이다. 내부적으로는 java.util.ArrayList일 수 있고, 강제 캐스팅하면 수정할 수 있다. 하지만 그렇게 쓰면 안 된다. Kotlin의 불변 컬렉션은 "계약"이지 "방어"가 아니다.


컬렉션 함수

Java의 Stream API와 비슷하지만, 훨씬 간결하다. stream(), collect() 없이 바로 체이닝한다.

filter와 map

가장 자주 쓰는 두 함수다.

val publishedTitles = posts
    .filter { it.isPublished }
    .map { it.title }
  • filter — 조건에 맞는 요소만 남긴다.
  • map — 각 요소를 변환한다.
  • it은 람다의 단일 파라미터를 가리키는 키워드다.

Java로 쓰면 아래와 같다. 비교해보면 체감이 된다.

List<String> publishedTitles = posts.stream()
    .filter(p -> p.isPublished())
    .map(Post::getTitle)
    .collect(Collectors.toList());

자주 쓰는 컬렉션 함수

// 첫 번째 / 마지막 요소
posts.first()                      // 없으면 예외
posts.firstOrNull()                // 없으면 null
posts.last()

// 존재 여부
posts.any { it.viewCount > 100 }   // 하나라도 조건 만족?
posts.none { it.isPublished }      // 모두 조건 불만족?
posts.all { it.title.isNotBlank() } // 모두 조건 만족?

// 정렬
posts.sortedBy { it.createdAt }          // 오름차순
posts.sortedByDescending { it.viewCount } // 내림차순

// 변환
posts.associate { it.id to it.title }    // Map<Long, String>으로 변환
posts.groupBy { it.category }            // 카테고리별로 그룹핑
posts.flatMap { it.tags }                // 중첩 리스트를 펼침

// 집계
posts.count { it.isPublished }
posts.sumOf { it.viewCount }
posts.maxByOrNull { it.viewCount }
  • 각 함수의 이름이 직관적이라 외우기 쉽다.
  • OrNull 접미사가 붙은 함수는 결과가 없을 때 예외 대신 null을 반환한다. 안전한 버전.
외울 필요 없다

전부 외우려 하지 말고, "이런 게 있구나" 정도만 기억하자. IDE에서 .을 찍으면 자동완성으로 다 나온다. 자주 쓰다 보면 손에 익는다.


심화 분석

빈 컬렉션과 null의 차이

실무에서 의외로 혼동하는 부분이다.

val emptyList: List<Post> = emptyList()   // 비어있지만 존재하는 리스트
val nullList: List<Post>? = null          // 리스트 자체가 없음

함수가 "결과 없음"을 표현할 때 빈 리스트를 반환하는 것이 null을 반환하는 것보다 낫다. 빈 리스트는 그대로 forEach, map 등을 호출해도 안전하지만, null은 매번 체크해야 하기 때문이다.

// 좋은 패턴 — 빈 리스트 반환
fun getPostsByCategory(categoryId: Long): List<Post> {
    return postRepository.findByCategoryId(categoryId)  // 결과 없으면 빈 리스트
}

// 나쁜 패턴 — null 반환
fun getPostsByCategory(categoryId: Long): List<Post>? {
    val posts = postRepository.findByCategoryId(categoryId)
    return if (posts.isEmpty()) null else posts  // 호출하는 쪽이 매번 null 체크해야 함
}

컬렉션 변환 함수 체이닝

여러 함수를 체이닝할 때 각 단계에서 새 컬렉션이 생성된다는 점을 알아야 한다.

posts
    .filter { it.isPublished }    // 새 List 생성
    .map { it.title }             // 또 새 List 생성
    .take(10)                     // 또 새 List 생성

데이터가 적으면 문제없지만, 수만 건 이상이면 성능이 중요해진다. 이때 Sequence를 쓸 수 있는데, 이건 사고방식 시리즈에서 깊이 다룬다.


자주 하는 실수

nullable 컬렉션 vs 컬렉션의 nullable 요소

이 두 가지는 완전히 다른 타입이다.

val a: List<String>? = null       // 리스트 자체가 null일 수 있음
val b: List<String?> = listOf(null, "hello")  // 요소가 null일 수 있음
val c: List<String?>? = null      // 둘 다 가능
  • List<String>? — 리스트가 null이거나, 있으면 요소는 전부 non-null
  • List<String?> — 리스트는 반드시 존재, 요소가 null일 수 있음
  • List<String?>? — 둘 다 가능. 가장 복잡하니 가능하면 피하자.

safe call 체이닝에서 중간 null

val result = list?.filter { it > 0 }?.firstOrNull()

list가 null이면 filter도 실행되지 않고 전체가 null이 된다. 의도한 거라면 괜찮지만, "빈 리스트와 null을 구분해야 하는 상황"에서는 문제가 될 수 있다. 애초에 빈 리스트를 반환하도록 설계하는 것이 좋다.

MutableList를 List로 반환

class PostService {
    private val _posts = mutableListOf<Post>()

    val posts: List<Post> get() = _posts  // 외부에는 읽기 전용으로 노출
}

내부에서는 MutableList로 관리하되, 외부에는 List로 노출하는 패턴이다. 안드로이드의 LiveData에서도 흔히 쓰이는 패턴이고, 캡슐화를 위해 알아두면 좋다.