Kotlin – 여러 가지 면모

코틀린을 배우기 위해서 인프런에서 강의를 구매하고 코틀린과 친해지고 기본기를 다지기 위해서 공부하는 중이다. 글 내용은 Kotlin의 이모저모(사물의 이런 면 저런 면)이고 최태현님의 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide) 강의에 소금을 조금 친 내용이다

Type Alias

  • 복잡하거나 긴 타입에 별칭을 부여하여 코드 가독성과 유지보수성을 향상시키는 기능이다

함수 타입 축약

  • 복잡한 함수 타입을 typealias로 간결하게 만들 수 있다
기존 복잡한 함수 타입 예시
// 긴 함수 타입으로 가독성이 떨어짐
fun filterStrings(strings: List<String>, filter: (String) -> Boolean): List<String> {
    return strings.filter(filter)
}

fun processData(
    data: List<String>,
    validator: (String) -> Boolean,
    transformer: (String) -> String,
    comparator: (String, String) -> Int
): List<String> {
    // 복잡한 함수 시그니처
    return data // 실제 구현은 생략
}
typealias 사용 예시
typealias StringFilter = (String) -> Boolean

//  깔끔하고 의미 전달이 명확한 함수 시그니처
fun filterStrings(strings: List<String>, filter: StringFilter): List<String> {
    return strings.filter(filter)
}

typealias StringTransformer = (String) -> String
typealias StringComparator = (String, String) -> Int

fun processData(
    data: List<String>,
    validator: StringFilter, // typealias 사용
    transformer: StringTransformer,
    comparator: StringComparator
): List<String> {
    return data.filter(validator)
        .map(transformer)
        .sortedWith(comparator)
}

복잡한 컬렉션 타입 축약

  • 제네릭 등으로 인해 길어지는 컬렉션 타입도 typealias로 줄일 수 있다
기존 긴 제네릭 타입 예시
data class ShowMeTheMoney(val money: Int)

// 매번 긴 타입 작성해야 함
fun proccessMoneyMap(moneyMap: Map<String, ShowMeTheMoney>): Map<String, ShowMeTheMoney> {
    return moneyMap.filter { it.value.money > 10000 }
}

fun combineMoneyMaps(
    map1: Map<String, ShowMeTheMoney>,
    map2: Map<String, ShowMeTheMoney>
): Map<String, ShowMeTheMoney> {
    return map1 + map2
}
typealias 사용 예시
typealias MoneyMap = Map<String, ShowMeTheMoney>

fun proccessMoneyMap(moneyMap: MoneyMap): MoneyMap {
    return moneyMap.filter { it.value.money > 10000 }
}

fun combineMoneyMaps(map1: MoneyMap, map2: MoneyMap): MoneyMap {
    return map1 + map2
}

Type Alias 사용 시 고려 사항

  • typealias는 코드의 가독성과 유지보수성을 높이지만, 과도하게 사용하면 오히려 혼란을 줄 수 있다
권장하지 않는 사용 예시
// 불필요한 typealias - 의미 없음
typealias MyString = String
typealias MyInt = Int
권장하는 사용 예시
// 의미 전달이 명확한 이름 사용
typealias EmailAddress = String
typealias ProductId = Long
typealias ValidationResult<T> = Result<T, String> // 제네릭 타입도 사용 가능
typealias DatabaseConnection = Connection // java.sql.Connection 등
typealias JsonResponse = Map<String, Any?>
  • typealias는 복잡한 타입을 단순화하고 의미를 명확하게 전달하여 코드의 가독성, 유지보수성, 의미 전달력을 향상시킨다

as import

  • 서로 다른 패키지에 같은 이름의 함수나 클래스가 있을 때 동시에 import하면 이름 충돌이 발생한다. 이때 as 키워드를 사용한 Import Alias를 통해 이름 충돌을 해결할 수 있다

함수 이름 충돌 해결

// package com.kotlin.a
// fun printHelloWorld() { println("Hello World A") }

// package com.kotlin.b
// fun printHelloWorld() { println("Hello World B") }

package com.kotlin.main

import com.kotlin.a.printHelloWorld as printHelloWorldA
import com.kotlin.b.printHelloWorld as printHelloWorldB

fun main() {
    printHelloWorldA() // Hello World A
    printHelloWorldB() // Hello World B
}

같은 이름의 클래스 충돌

// 라이브러리 A (package com.library.a)
// class User(val name: String)

// 라이브러리 B (package com.library.b)
// class User(val id: Long)

package com.kotlin.main

import com.library.a.User as LibraryAUser
import com.library.b.User as LibraryBUser

fun main() {
    val userA = LibraryAUser("혁")
    val userB = LibraryBUser(123L)
    println("User A name: ${userA.name}, User B ID: ${userB.id}")
}

Import Alias 네이밍 컨벤션

  • Alias 이름은 코드의 가독성을 높이는 방향으로 명확하게 지정하는 것이 중요하다

의미 전달이 명확한 이름 사용 (권장)

// 의미 전달이 명확한 이름
import com.payment.PaymentService as PaymentProcessor
import com.user.UserService as UserManager

출처를 나타내는 접두사 (권장)

// 라이브러리 출처를 나타내는 접두사 사용 예시
import retrofit2.Response as Retrofit2Response
import okhttp3.Response as OkHttpResponse // okhttp2 대신 okhttp 사용 예시
import java.net.http.HttpResponse as JavaHttpResponse // Response는 inner class이므로 import 방식 변경
  • Import Alias는 이름 충돌을 해결하고 코드 가동성을 향상시키지만, 의미 있는 이름을 사용하여 코드의 의도를 명확히 전달하는 것이 중요하다

구조분해 선언(Destructuring Declarations)과 componentN 함수

  • 구조분해 선언은 복합적인 값을 분해하여 여러 변수를 한 번에 초기화하는 기능이다

Data Class에서의 구조분해

  • data class는 자동으로 componentN 함수를 생성하여 구조분해를 지원한다
data class Person(
    val name: String,
    val age: Int
)

val person = Person("혁", 100)

// 일반적인 방법
val name = person.name
val age = person.age

// 구조분해 사용
val (pName, pAge) = person
println("이름은 ${pName}, 나이는 ${pAge}") // 이름은 혁, 나이는: 100

ComponentN 함수의 실체

  • 구조분해는 내부적으로 componentN 함수를 호출한다
// 구조분해는 내부적으로 componentN 함수를 호출
val nameFromComponent = person.component1() // person.name과 동일
val ageFromComponent = person.component2() // person.age와 동일

// 따라서 이 두 코드는 완전히 동일함
val (name, age) = person
// ↓ 컴파일러가 변환
// val name = person.component1()
// val age = person.component2()

// 순서를 바꾸면 값이 바뀜 (변수명이 아닌 순서로 매핑)
val (first, second) = person
println("첫 번째 값은 ${first}, 두 번째 값은 ${second}") // 첫 번째 값은 혁, 두 번째 값은: 100
// 중요한 점: 순서 기반이므로, 변수명과 실제 필드의 순서가 다르면 의도와 다른 값이 할당될 수 있다.

일반 클래스에서 구조분해 구현

  • operator 키워드와 함께 conponentN 함수를 직접 구현하여 일반 클래스에서도 구조분해를 사용할 수 있다
class Employee(
    val name: String,
    val age: Int
) {
    operator fun component1(): String {
        return this.name
    }

    operator fun component2(): Int {
        return this.age
    }
    // 코틀린은 단일 표현식 함수(Single-expression function)를 권장한다.
    // operator fun component1(): String = this.name
    // operator fun component2(): Int = this.age
}

fun main() {
    val employee = Employee("김코틀", 30)
    val (eName, eAge) = employee // 일반 클래스이지만 구조분해 가능
    println("직원 이름: $eName, 나이: $eAge")
}

ComponentN 함수 상세 및 추가 활용

  • Data Class 자동 생성 규칙: data class는 선언된 프로퍼티 순서대로 component1(), component2() … 함수를 자동으로 생성한다.
  • 부분적 구조분해: 필요한 부분만 추출하고 싶을 때는 사용하지 않을 변수 위치에 언더스코어(_)를 사용한다
data class User(val name: String, val age: Int, val email: String)
val user = User("홍길동", 30, "hong@example.com")

val (name, _, email) = user // age는 무시
println("이름: $name, 이메일: $email")
  • Map 순회에서의 구조분해: Map.Entry는 component1() Khey)과 component2() (value)를 제공하여 for 루프에서 구조분해를 쉽게 할 수 있다
val map = mapOf(1 to "A", 2 to "B", 3 to "C")

for ((key, value) in map) { // map.entries 없이도 바로 가능
    println("$key -> $value")
}
  • List 인덱스와 함께 사용: withIndex() 확장 함수를 사용하면 리스트를 인덱스와 함께 구조분해하여 순회할 수 있다
val fruits = listOf("사과", "바나나", "오렌지")

for ((index, value) in fruits.withIndex()) {
    println("$index: $value")
}
구조분해 사용 시 주의점
  • 변수명의 함정: 구조분해를 순서 기반이므로, 변수명에만 의존하지 않도록 주의해야 한다
data class Point(val x: Int, val y: Int)

val (yVal, xVal) = Point(10, 20) // 의도와 반대로 xVal = 20, yVal = 10
println("X 값: $xVal, Y 값: $yVal") // X 값: 20, Y 값: 10

// 명확한 사용:
val (posX, posY) = Point(10, 20) // 순서대로 posX=10, posY=20
println("Pos X: $posX, Pos Y: $posY") // Pos X: 10, Pos Y: 20
  • 과도한 사용 피하기: 많은 프로퍼티를 가진 클래스를 구조분해하면 코드의 가독성이 떨어질 수 있다
data class Employee(
    val name: String, val age: Int, val dept: String,
    val salary: Long, val phone: String, val email: String
)
val employee = Employee("철수", 40, "개발", 7000, "010-1111-2222", "chul@example.com")

// val (n, a, d, s, p, e) = employee // 어떤 값이 무엇인지 불분명하여 가독성 저하
// 이 경우, 필요한 프로퍼티만 직접 접근하는 것이 더 좋다.
println("직원 이름: ${employee.name}, 부서: ${employee.dept}")

구조분해는 순서 기반으로 동작하므로 변수 순서를 정확히 맞춰야 하며, 의미가 명확하고 프로퍼티 수가 적을 때 사용하는 것이 좋다

Jump와 Lavel

코틀린의 기본 제어 흐름 키워드인 return, break, continue는 Java와 유사하게 동작하지만, 람다(Lambda)와 함께 사용할 떄 약간의 차이가 있다

forEach에서의 제약사항

  • break와 continue는 람다에서 직접 사용할 수 없다
val numbers = listOf(1, 2, 3)

// 일반 for문 - break/continue 가능
for (number in numbers) {
    if (number == 2) {
        break // 정상 동작: 루프 종료
    }
    println(number) // 1만 출력
}


// forEach - break/continue 불가능 (컴파일 에러)
// numbers.forEach { number ->
//     if (number == 2) {
//         break // 컴파일 에러: break is not allowed here
//     }
//     println(number)
// }

run 및 label을 이용한 break / continue 효과

  • 람다 내에서 break나 continue와 유사한 동작을 하려면 Non-local return 이나 Label을 사용해야 한다

break 효과 (전체 루프 종료)

  • run 블록에 명시적 레이블을 붙이고 return@label을 사용한다
val numbers = listOf(1, 2, 3)

run breaking@ { // 'breaking' 이라는 레이블 정의
    numbers.forEach { number ->
        if (number == 2) {
            return@breaking // breaking 레이블이 붙은 run 블록을 종료 (break 역할)
        }
        println(number) // 1만 출력되고 종료
    }
}
println("run 블록 종료됨")

continue 효과 (현재 반복만 건너뜀)

  • 람다의 암시적 레이블 (@forEach 또는 함수 이름)을 사용한다
val numbers = listOf(1, 2, 3)

numbers.forEach { number ->
    if (number == 2) {
        return@forEach // forEach 람다의 현재 반복만 건너뜀 (continue 역할)
    }
    println(number) // 1, 3 출력 (2는 건너뜀)
}
println("forEach 종료됨")

이 경우 return@forEach는 forEach 람다를 호출한 곳으로 돌아가는 것이 아니라, 해당 람다 함수 실행만 중단하고 다음 리스트 요소를 처리한다

Label 기능 (중첩 루프 제어)

  • Label을 사용하면 중첩된 루프에서 원하는 외부 루프를 제어할 수 있다
// 일반적인 break (내부 루프만 종료)
for (i in 1..3) {
    for (j in 1..3) {
        if (j == 2) {
            break // 내부 루프 (j 루프)만 종료
        }
        println("$i $j")
    }
}
// 출력:
// 1 1
// 2 1
// 3 1


// Label break (외부 루프까지 종료)
outer@ for (i in 1..3) { // 'outer' 라는 레이블 정의
    for (j in 1..3) {
        if (j == 2) {
            break@outer // 'outer' 레이블이 붙은 외부 루프까지 완전 종료
        }
        println("$i $j")
    }
}
// 출력:
// 1 1 (그리고 외부 루프까지 완전 종료)


// continue와 Label
outer@ for (i in 1..3) {
    for (j in 1..3) {
        if (j == 2) {
            continue@outer // 'outer' 레이블이 붙은 외부 루프의 다음 반복으로 이동
        }
        println("$i $j")
    }
}
// 출력:
// 1 1 (j가 2가 되면 i 루프의 다음 반복, 즉 i=2로 건너뛴다)
// 2 1
// 3 1

forEach 보다 for문 권장

  • 복잡한 제어 흐름(예: break, continue)이 필요한 경우에는 forEach와 같은 고차 함수보다 for 루프를 사용하는 것이 가독성과 명확성 면에서 유리하다

복잡한 forEach + Label 예시

val numbers = listOf(1, 2, 3)
run breaking@ {
    numbers.forEach { number ->
        if (number == 2) return@breaking // break 역할
        println(number)
    }
}

간단명료햔 for 문 예시

val numbers = listOf(1, 2, 3)
for (number in numbers) {
    if (number == 2) break // 훨씬 직관적
    println(number)
}

Label 사용을 피해야 하는 이유

  • 가독성 저하: 코드 흐름을 예측하고 따르기 어렵다
  • 복잡도 증가: 디버깅과 유지보수가 어려워질 수 있다
  • 실수 가능성: 잘못된 Label 참조로 인한 버그가 발생할 수 있다
  • 대안 존재: 대부분의 경우 for 루트, filter, takeWhile 등 다른 함수형 접근 방식으로 더 깔끔하게 해결할 수 있다
  • 코드의 가독성과 유지보수성을 위해 복잡한 제어 흐름이 필요할 때는 for 루트를 사용하거나, 함수 분리 형 프로그래밍 스타일을 고려하는 것이 더 나은 선택이다

takeIf와 takeUnless

takeIf와 takeUnless는 조건에 따라 값 또는 null을 반환하는 간결한 방법을 제공하는 표준 라이브러르 함수이다

  • takeIf: 수신 객체가 주어진 predicate(조건)을 만족하면(참이면) 수신 객체 자신을 반환하고, 그렇지 않으면 null을 반환한다
  • takeUnless: 수신 객체가 주어진 predicate(조건)을 만족하지 않으면(거짓이면) 수신 객체 자신을 반환하고, 그렇지 않으면 null을 반환한다(takeIf의 반대)

기존 if-else 방식

val number: Int = 100

fun getPositiveNumberOrNullV1(num: Int): Int? { // 함수 매개변수로 변경
    return if (num > 0) { // 0 보다 크면 num, 아니면 null
        num
    } else {
        null
    }
}
println("V1 (100): ${getPositiveNumberOrNullV1(100)}") // 100
println("V1 (-5): ${getPositiveNumberOrNullV1(-5)}")   // null

takeIf 사용

val number: Int = 100

fun getPositiveNumberOrNullV2(num: Int): Int? {
    return num.takeIf { it > 0 } // 조건이 참이면 num, 거짓이면 null
}
println("V2 (100): ${getPositiveNumberOrNullV2(100)}") // 100
println("V2 (-5): ${getPositiveNumberOrNullV2(-5)}")   // null

takeUnless 사용

val number: Int = 100

fun getPositiveNumberOrNullV3(num: Int): Int? {
    return num.takeUnless { it <= 0 } // 조건이 거짓이면 num, 참이면 null (즉, num > 0 이면 num)
}
println("V3 (100): ${getPositiveNumberOrNullV3(100)}") // 100
println("V3 (-5): ${getPositiveNumberOrNullV3(-5)}")   // null
  • takeIf와 takeUnless는 null 처리를 간결하게 만들어 주고, 함수형 프로그래밍 스타일의 체이닝을 가능하게 하여 코드를 더욱 유연하고 표현적으로 만들 수 있다

출처 – 인프런 강의 중 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide)