Kotlin in Action 2/e 서적을 읽으면서 정리하고자 쓴 글입니다.

널 가능성
코틀린을 비롯한 최신 언어는 `null`에 대한 문제를 런타임 시점에서 컴파일 시점으로 옮겼습니다. 널이 될 수 있는지 확인하는 타입 시스템을 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지하여 런타임에 발생할 수 있는 예외 가능성을 줄일 수 있습니다.
안전한 호출 연산자(Safe Call Operator): ?.
`?.`는 `null` 검사와 메서드 호출을 한 번의 연산으로 수행합니다. 왼쪽 값이 `null`이면 즉시 `null`을 반환하고, `null`이 아닐 때만 오른쪽을 실행합니다.
s?.toUpperCase() // s 문자열 대문자 변환 (아래와 동일하게 동작한다.)
if (s != null) s.toUpperCase() else null
class Address(val country: String)
class Company(val address: Address?)
class Person(val company: Company?)
fun Person.countryName(): String =
this.company?.address?.country ?: "Unknown"
fun main() {
val person = Person(null)
println(person.countryName()) // Unknown
}
`company` 또는 `address`가 `null`이면 전체 표현식은 `null` 임으로 "Unknown"이 출력됩니다. 이처럼 `?.` 연산자를 통해 다른 추가 검사 없이 `Person`의 회사 주소의 `country` 프로퍼티를 단 한 줄로 가져올 수 있습니다.
엘비스 연산자(Elvis Operator): ?:
코틀린은 `null` 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 연산자를 제공합니다.
val result = value ?: "default" // 왼쪽 값이 null이면 오른쪽 "default" 반환
코틀린은 `return`, `throw`도 식이기 때문에 엘비스 연산자 우항에 사용할 수 있습니다.
val address = person.company?.address
?: throw IllegalArgumentException("No address")
class Address(
val streetAddress: String,
val zipCode: Int,
val city: String,
val country: String
)
class Company(val address: Address?)
class Person(val company: Company?)
fun printShippingLabel(person: Person) {
val address = person.company?.address
?: throw IllegalArgumentException("No address")
with(address) {
println(streetAddress) // this.streetAddress (this 생략)
println("$zipCode $city, $country")
}
}
위 예제를 보면 `company` 또는 `address`가 `null`이면 `IllegalArgumentException` 발생되고, `null`이 아니면 정상 출력됩니다.
안전한 캐스트 (Safe Cast) as?
`as?`는 값을 지정한 타입으로 변환을 시도하고, 변환할 수 없으면 예외 대신 `null`을 반환하는 연산자입니다.
val person = obj as? Person // 캐스트 성공 시 Person 타입, 실패 시 null
class Person(
val firstName: String,
val lastName: String
) {
override fun equals(other: Any?): Boolean {
val otherPerson = other as? Person ?: return false
return otherPerson.firstName == firstName &&
otherPerson.lastName == lastName
}
override fun hashCode(): Int =
firstName.hashCode() * 37 + lastName.hashCode()
}
fun main() {
println(p1 == p2) // true
println(p1.equals(42)) // false
}
널 아님 단언(Not-null Assertion): !!
`!!`는 nullable 타입을 강제로 non-null 타입으로 변환하는 연산자입니다.
val sNotNull: String = s!! // 값이 not null이면 정상 동작, null이면 NPE 발생
fun ignoreNulls(s: String?) {
val sNotNull: String = s!!
println(sNotNull.length) // s가 null이면 NPE 발생
}
`!!`는 컴파일러의 `null` 안정성을 우회합니다. 즉, 개발자가 "여기서는 절대 null이 아니다."라고 강제 단언하는 것이기 때문에 사용에 있어 주의가 필요합니다.
또한 아래와 같이 여러 개의 `!!`를 한 줄에 사용하는 경우, 어디서 NPE가 발생했는지 파악하기 어렵고, 유지보수 시 위험성이 커지기 때문에 권장하지 않습니다.
person.company!!.address!!.country // 권장하지 않음
let 함수
`let`은 객체를 람다의 인자로 전달하여 블록을 실행하는 범위 함수입니다. 특히 nullable 타입과 함께 사용할 때 많이 쓰입니다.
email?.let { sendEmailTo(it) }
`email`이 `not-null`이면 블록을 실행하고, `null`이면 아무것도 실행되지 않습니다.
fun sendEmailTo(email: String) {
println("Sending email to $email")
}
fun main() {
var email: String? = "yole@example.com"
email?.let { sendEmailTo(it) } // 실행됨
email = null
email?.let { sendEmailTo(it) } // 실행되지 않음
}
`?.` 연산자와 함께 사용하면 `null` 체크와 값 사용을 한 번에 처리할 수 있습니다.
만약 여러 값을 동시에 `null` 검사가 필요한 경우 `let` 중첩으로 사용하면 가독성이 떨어질 수 있습니다. 이러한 경우는 일반적인 `if`문을 사용하는 것이 더 명확합니다.
a?.let { aa ->
b?.let { bb ->
// 복잡해짐
}
}
if (a != null && b != null) {
// 처리
}
나중에 초기화할 프로퍼티 lateinit
코틀린은 기본적으로 모든 `non-null` 프로퍼티를 생성자에서 초기화해야 합니다.
class Example(val name: String)
테스트 코드나 DI 환경에서는 생성자가 아니라 별도의 메서드에서 초기화해야 하는 경우가 있습니다.
class MyTest {
private var myService: MyService? = null
@Before
fun setUp() {
myService = MyService()
}
@Test
fun testAction() {
myService!!.performAction() // !! 필요
}
}
위처럼 nullable 타입을 사용해야 하고, 매번 `!!` 또는 `?.` 연산자가 사용해야 하는 문제가 생기게 됩니다. 이를 해결하기 위한 방법으로 `lateinit`를 사용하면 초기화를 나중에 미루면서도 `non-null` 타입을 유지할 수 있습니다.
class MyTest {
private lateinit var myService: MyService // lateinit 추가
@Before
fun setUp() {
myService = MyService()
}
@Test
fun testAction() {
myService.performAction() // null 검사 불필요
}
}
이제 `null`이 될 수 없는 타입이라 해도 더 이상 생성자 안에서 초기화할 필요가 없어졌습니다.
코틀린의 원시 타입
코틀린은 자바와 달리 원시 타입(primitive)과 래퍼 타입(wrapper)을 구분하지 않습니다.
// Java: 원시/래퍼 타입으로 나뉨
int a = 10; <-> Integer a = 10;
// Kotlin: 항상 동일한 타입 사용
val a: Int = 10
코틀린은 실행 시점에서 가능한 한 가장 효율적인 방식으로 컴파일됩니다. 즉, nullable 여부에 따라 원시 타입을 쓸지 래퍼 타입을 쓸지 결정됩니다.
- nullable이 아닌 `Int` → `int`
- nullable인 `Int?` → `Integer`
코틀린은 원시 타입도 메서드를 가질 수 있습니다.
fun showProgress(progress: Int) {
val percent = progress.coerceIn(0, 100)
println("We're ${percent}% done!")
}
자바의 `int`는 메서드를 가질 수 없지만 코틀린의 `Int`는 확장 함수 및 멤버 함수를 사용할 수 있습니다.
숫자 변환
코틀린은 숫자 타입을 자동으로 변환하지 않습니다. 자바는 작은 타입에서 큰 타입으로 자동 캐스팅이 가능하지만 코틀린은 명시적 변환이 필요합니다.
// Java
int i = 1;
long l = i; // 자동 변환
// Kotlin
val i = 1
val l: Long = i // 컴파일 오류
val l2: Long = i.toLong() // 정상
Kotlin은 모든 원시 타입에 대해 명시적인 타입 변환 함수를 제공합니다. 변환 함수는 toXxx() 형태로 정의되어 있으며, toByte(), toShort(), toInt(), toLong(), ... 등과 같이 앞에 to를 붙이고 대상 타입명을 사용하는 규칙을 따릅니다.
🤔코틀린은 왜 자동 변환을 허용하지 않을까?
코틀린은 명확성과 타입 안정성을 우선합니다. 자동 변환이 허용되면 예상치 못한 타입 변환이 발생할 수 있으므로 모든 숫자 변환은 명시적으로 수행하도록 설계되었습니다.
Any, Any? 최상위 타입
자바에서 `Object`는 클래스 계층의 최상위 타입이듯 코틀린은 `Any`가 모든 타입의 최상위 타입입니다.
val value: Any = "hello"
val number: Any = 42
val value2: Any? = null
val number2 Any? = null
코틀린은 `Int` 같은 숫자 타입도 `Any`의 하위 타입입니다.
Unit 타입: 코틀린의 void
`Unit`은 자바의 `void`와 같은 역할을 수행합니다. 즉, 의미 있는 값을 반환하지 않는 함수의 반환 타입입니다.
fun printMessage(): Unit {
println("Hello")
}
// 반환 타입 Unit을 생략할 수도 있다.
fun printMessage() {
println("Hello")
}
🤔코틀린의 Unit이 자바 void와 다른 점은 무엇일까?
자바의 `void`는 상태이고, 코틀린의 `Unit`은 타입으로 사용됩니다. 즉 `Unit`은 타입 인자로 사용할 수 있으며, 제네릭을 사용할 때 의미가 분명해집니다.
interface Processor<T> {
fun process(): T
}
// 반환값이 없는 경우에도 Unit 타입을 인자로 넘길 수 있음
class NoResultProcessor : Processor<Unit> {
override fun process() {
// logic...
// 명시적인 return Unit이 없어도 컴파일러가 자동으로 처리합니다.
}
}
`process()`는 `Unit`을 반환하기 때문에 별도의 `return`이 필요하지 않습니다. 자바의 `void`로는 이런 제네릭 인터페이스를 표현할 수 없습니다.
자바에서도 제네릭에 대응하기 위해 `void`의 래퍼 클래스인 `Void`를 사용할 수 있습니다. 하지만 코틀린의 `Unit`과 사용성 면에 차이점이 있습니다.
| 코틀린의 `Unit` | 자바의 `Void` | |
| 성격 | 객체가 하나뿐인 싱글톤 타입 | 인스턴스화할 수 없는 참조 타입 |
| 값의 존재 | `Unit` 객체가 실재 | 항상 `null`만 가짐 |
| 반환 코드 | return 생략 가능 | return null; 필수 |
Nothing 타입: 이 함수는 결코 정상적으로 끝나지 않는다.
코틀린은 결코 성공적으로 값을 돌려주는 일이 없으므로 반환 값이라는 개념 자체가 의미 없는 함수가 존재합니다.
fun fail(message: String) : Nothing {
throw IllegalStateException(message)
}
val address = company.address ?: fail("No address")
println(address.city)
`Nothing`은 아무 값도 포함하지 않습니다. 따라서 컴파일러는 `Nothing`이 반환 타입인 함수가 결코 정상 종료가 되지 않음을 알고 그 함수를 호출하는 코드를 분석할 때 사용합니다.
컬렉션과 배열
컬렉션과 널 가능성
컬렉션에서도 원소가 `null`이 될 수 있는지 여부는 매우 중요합니다.
fun addValidNumbers(numbers: List<Int?>) {
var sum = 0
var invalid = 0
for (number in numbers) {
if (number != null) sum += number
else invalid++
}
println("Sum: $sum")
println("Invalid count: $invalid")
}
`List<Int?>`의 원소를 꺼내면 타입은 `Int?`이므로 사용 전 반드시 `null` 체크가 필요합니다.
코틀린의 표준 라이브러리는 `null` 제거를 위한 `fillterNotNull()`을 제공합니다.
fun addValidNumbers(numbers: List<Int?>) {
val valid = numbers.filterNotNull() // not-null 값만 할당
println("Sum: ${valid.sum()}")
println("Invalid count: ${numbers.size - valid.size}")
}
`null` 제거 후 `Int` 타입으로 안전하게 사용할 수 있게 됐습니다.
읽기 전용 컬렉션 vs 변경 가능 컬렉션
코틀린은 읽기 전용 인터페이스와 변경 가능 인터페이스를 분리합니다.
- 읽기 전용: `Collection`, `List`, `Set`, `Map`
- 변경 가능: `MutableCollection`, `MutableList`, `MutableSet`, `MutableMap`
val list: List<String> = listOf("a", "b")
val mutableList: MutableList<String> = mutableListOf("a", "b")
fun main() {
list.add("c") // 읽기 전용 컬렉션 컴파일 오류 발생
mutableList.add("c") // 변경 가능 컬렉션 추가 가능
}
코틀린 ↔ 자바 상호운용 시 주의점
사실 모든 코틀린의 컬렉션은 자바 컬렉션입니다. 따라서 코틀린에서 읽기 전용 `List`를 선언해도 자바 코드는 수정이 가능합니다.
// Java
public static List<String> uppercaseAll(List<String> items) {
for (int i = 0; i < items.size(); i++) {
items.set(i, items.get(i).toUpperCase());
}
return items;
}
// Kotlin
fun printInUppercase(list: List<String>) {
println(CollectionUtils.uppercaseAll(list))
println(list.first()) // 이미 변경됨
}
코틀린의 읽기 전용은 컴파일러 수준의 제약이지 진짜 불변을 의미하지는 않습니다.
객체의 배열과 원시 타입의 배열
코틀린의 배열은 제네릭 클래스입니다. 또한 코틀린은 배열을 사용하기 보다 컬렉션을 사용하는 것을 권장합니다.
val arr: Array<String>
val arr = arrayOf("a", "b", "c")
val arr = arrayOfNulls<String>(5) // nullable 타입일 때만 사용 가능
val letters = Array(26) { i ->
('a' + i).toString() // 람다를 통해 각 요소 초기화 가능
}
요약
- 코틀린의 널이 될 수 있는 타입을 지원해 NPE를 컴파일 시점에 감지할 수 있다.
- 코틀린의 `?.`, `?:`, `!!`, `let` 함수 등을 사용하면 널이 될 수 있는 타입을 간결하게 다룰 수 있다.
- `as?`를 사용하면 널이 아닌 경우 다른 타입으로 취급할 수 있다.
- 코틀린은 원시/래퍼 타입을 구분하지 않는다. JVM 상황에 맞게 원시/래퍼 타입을 쓸지 정해준다.
- `Any`는 코틀린의 모든 타입의 조상 타입이며, 자바의 `Object`에 해당한다. 또한 `Unit`은 `void`와 비슷하다.
- 정상적으로 끝나지 않는 함수의 반환 타입을 지정할 때 `Nothing` 타입을 사용한다.
- 코틀린의 컬렉션은 자바 컬렉션을 확장해서 사용하며, 읽기 전용, 변경 전용 컬렉션으로 구별하여 제공한다.
- 플랫폼 타입을 확장하는 경우 널 가능성과 변경 가능성에 대해 깊이 생각해야 한다. (플랫폼 타입은 널 인지 아닌지 컴파일러가 체크하지 않기 때문)
- 코틀린의 배열은 Array 제네릭 클래스를 사용한다. 또한 컴파일 시 자바 배열로 컴파일된다.
'Kotlin' 카테고리의 다른 글
| Java와 다른 Kotlin의 인터페이스 (0) | 2026.01.09 |
|---|---|
| [Kotlin in action 2/e] 람다로 프로그래밍 (0) | 2025.12.31 |
| [Kotlin in Action 2/e] 클래스, 객체, 인터페이스 (0) | 2025.12.30 |
| [Kotlin in Action 2/e] 함수 정의와 호출 (1) | 2025.12.30 |
| [Kotlin in Action 2/e] 코틀린의 기초 (0) | 2025.12.12 |