-
6.1.1 널이 될 수 있는 타입
-
널을 허용하지 않는 함수
-
널을 허용하는 함수
-
널 가능성에 따른 타입 제약
-
널이 될 수 있는 타입을 안전하게 사용하는 방법
-
6.1.2 타입의 의미
-
자바의 타입 문제점
-
코틀린의 널 가능 타입
-
6.1.3 안전한 호출 연산자: ?.
-
6.1.4 엘비스 연산자: ?:
-
throw와 함께 엘비스 연산자 활용하기
-
6.1.5 안전한 캐스트: as?
-
as?의 동작 방식
-
예시: equals 메서드에서 as? 사용하기
-
스마트 캐스트와 함께 사용하기
-
6.1.6 널 아님 단언: !!
-
기본 예제
-
언제 !!가 필요한가?
-
대안 패턴
-
주의: 한 줄에 !! 여러 번 사용하지 말 것
-
6.1.7 let 함수
-
대표 용례: 널이 아닌 값만 받는 함수에 null 가능 값 넘기기
-
예제
-
복잡한 식을 다룰 때도 유용
-
let 중첩 사용에 주의
-
기타 활용: 생성자 초기화 문제
-
6.1.8 나중에 초기화할 프로퍼티
-
lateinit의 규칙
-
DI 프레임워크와의 연동
-
6.1.9 널이 될 수 있는 타입 확장
-
let과 비교
-
6.1.10 타입 파라미터의 널 가능성
-
6.1.11 널 가능성과 자바
-
자바의 널 가능성 애노테이션
-
플랫폼 타입
-
함수 파라미터의 널 검사
-
왜 플랫폼 타입인가?
-
플랫폼 타입의 IDE 표현
-
자바 인터페이스 상속 시 주의점
-
더 알아보기
-
6-2 ~ 6-3 내용
-
도서 링크 바로가기
Kotlin in Action을 공부하며 정리한 내용입니다.
저작권에 문제가 될 시, 글을 모두 내리겠습니다.
제가 공부한 내용이 더 많은 분들에게도 도움이 되었으면 좋겠습니다. 부족한 부분은 댓글을 통해서 피드백을 주신다면 언제나 반영하겠습니다. 감사합니다.
책에 대한 링크는 맨 아래에 있습니다.
https://github.com/Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1
GitHub - Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1: SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스
SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스터디(A). Contribute to Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1 development by creating an account on GitHub.
github.com
내가 정리한 Part (6-1. 널 가능성)
널 가능성(nullability) 은 프로그램에서 흔히 발생하는 NullPointerException(NPE)
오류를 방지하도록 설계된 코틀린 타입 시스템의 중요한 특징이다.
자바에서는 흔히 다음과 같은 메시지로 NPE를 경험하게 된다.
"An error has occurred: java.lang.NullPointerException"
"Unfortunately, the application X has stopped"
이러한 오류는 사용자뿐만 아니라 개발자에게도 혼란을 준다. 코틀린과 같은 최신 언어는 실행 시점(runtime) 이 아닌 컴파일 시점(compile-time) 에 널 관련 문제를 발견하도록 돕는다. 즉, 타입 시스템에 널 가능성을 명시함으로써, 컴파일러가 잠재적인 NPE를 미리 감지해 오류를 예방할 수 있다.
6.1.1 널이 될 수 있는 타입
코틀린과 자바의 중요한 차이점 중 하나는 널이 될 수 있는 타입(nullable type) 을 명시적으로 지원한다는 점이다.
널이 될 수 있는 타입이란, 변수나 프로퍼티가 null
을 저장할 수 있음을 의미한다.
널이 될 수 있는 타입의 변수에 메서드를 호출하면 NullPointerException이 발생할 위험이 있다. 코틀린은 이런 호출을 금지하여 오류를 예방한다.
널을 허용하지 않는 함수
다음은 자바로 작성된 문자열 길이를 반환하는 함수이다.
int strLen(String s) {
return s.length();
}
위의 함수는 매개변수 s
가 null일 경우 NullPointerException이 발생할 수 있다.
코틀린에서는 널을 허용하지 않는 타입으로 아래처럼 정의할 수 있다.
fun strLen(s: String) = s.length
이 경우, 널을 전달하면 컴파일 오류가 발생한다.
strLen(null)
// ERROR: Null can not be a value of a non-null type String
코틀린에서는 별도의 null 체크 없이도 strLen
함수가 절대 NullPointerException을 발생시키지 않는다고 보장할 수 있다.
널을 허용하는 함수
코틀린에서 타입 뒤에 ?
를 붙이면, 그 변수는 널을 허용하는 타입이 된다.
예를 들어, String?
, Int?
, MyCustomType?
는 모두 null을 저장할 수 있다.
널을 허용하는 함수는 아래와 같이 정의할 수 있다.
fun strLenSafe(s: String?) = ...
널이 될 수 있는 타입의 변수로는 직접 메서드를 호출할 수 없다.
fun strLenSafe(s: String?) = s.length()
// ERROR: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type kotlin.String?
널 가능성에 따른 타입 제약
널이 될 수 있는 값(String?
)을 널이 될 수 없는 타입(String
)에 대입하면 오류가 발생한다.
val x: String? = null
val y: String = x
// ERROR: Type mismatch: inferred type is String? but String was expected
널 가능 값(String?
)을 널을 허용하지 않는 함수에 인자로 넘겨도 오류가 발생한다.
strLen(x)
// ERROR: Type mismatch: inferred type is String? but String was expected
널이 될 수 있는 타입을 안전하게 사용하는 방법
널이 될 수 있는 타입은 직접 메서드를 호출할 수 없지만, null
과 비교하면 이후 안전한 영역에서 널이 될 수 없는 값처럼 사용할 수 있다.
예시:
// if 검사를 통해 null 값 다루기
fun strLenSafe(s: String?): Int =
if (s != null) s.length else 0
val x: String? = null
println(strLenSafe(x)) // 0 출력
println(strLenSafe("abc")) // 3 출력
이렇게 하면 null 값을 안전하게 처리할 수 있다.
6.1.2 타입의 의미
타입은 값의 분류(classification) 다.
즉, 타입은 어떤 값이 가능하며, 그 값에 어떤 연산을 수행할 수 있는지 결정한다.
자바의 타입 문제점
자바의 double
타입을 예로 들어보자.
double
타입은 64비트 부동소수점 수를 나타낸다.- 따라서, 이 타입의 변수라면 일반적인 수학 연산을 수행할 수 있다는 사실을 컴파일 단계에서 알 수 있다.
하지만 String
타입의 경우 상황이 다르다.
자바의 String
타입 변수에는 실제 문자열이나 null
이 들어갈 수 있으며, 둘은 전혀 다른 종류의 값이다.
즉, 같은 타입임에도 실제 값이 문자열인지 null
인지에 따라 수행할 수 있는 연산이 달라진다.
이러한 문제는 자바의 타입 시스템이 널(null
)을 제대로 처리하지 못하기 때문에 발생한다.
결과적으로 개발자가 변수의 널 여부를 항상 추가적으로 검사해야 하고, 이를 생략하면 실행 시점에 NullPointerException
이 발생할 수 있다.
코틀린의 널 가능 타입
코틀린은 널 가능성 문제를 근본적으로 해결하기 위해 널이 될 수 있는 타입과 널이 될 수 없는 타입을 엄격히 구분한다.
- 널이 될 수 없는 타입(non-nullable) : 항상 실제 값을 저장한다.
- 널이 될 수 있는 타입(nullable) : 실제 값 또는
null
을 저장할 수 있다.
이렇게 두 타입을 명확히 구분하면 각 값에 대해 사용할 수 있는 연산이 명확히 정해지므로, 실행 시점의 오류 가능성을 미리 방지할 수 있다.
코틀린의 타입 검사는 컴파일 시점에 이루어진다.
널 가능 타입이 실행 시점에 별도의 객체로 감싸지거나 래핑되지 않으므로, 별도의 실행 시점 부가 비용이 발생하지 않는다.
6.1.3 안전한 호출 연산자: ?.
코틀린에서 가장 유용한 기능 중 하나는 안전한 호출 연산자 인 ?.
이다.
이 연산자는 널(null
) 검사와 메서드 호출을 한 번에 처리할 수 있게 해준다.
예를 들어 다음의 코틀린 코드를 보자.
s?.toUpperCase()
이 코드는 자바의 다음 코드와 동일한 동작을 한다.
if (s != null) s.toUpperCase() else null
즉, 호출 대상(s
)이 null
이 아니라면 일반적인 메서드 호출처럼 동작하며,
호출 대상이 null
이면 메서드는 호출되지 않고 결과는 그냥 null
이 된다.
안전한 호출의 결과는 항상 널이 될 수 있는 타입이다.
즉, 원본 메서드의 반환 타입이 String
이더라도, 안전한 호출을 사용하면 반환 타입은 String?
이 된다.
fun printAllCaps(s: String?) {
val allCaps: String? = s?.toUpperCase()
println(allCaps)
}
>>> printAllCaps("abc")
ABC
>>> printAllCaps(null)
null
메서드 호출뿐 아니라 프로퍼티를 읽고 쓸 때도 안전한 호출을 사용할 수 있다.
다음은 프로퍼티에 안전한 호출을 사용하는 예제이다.
class Employee(val name: String, val manager: Employee?)
fun managerName(employee: Employee): String? = employee.manager?.name
>>> val ceo = Employee("Da Boss", null)
>>> val developer = Employee("Bob smith", ceo)
>>> println(managerName(developer))
Da Boss
>>> println(managerName(ceo))
null
객체 그래프에 널이 될 수 있는 중간 객체가 여러 개 존재할 때는, 안전한 호출을 연쇄적으로 사용하면 매우 편리하다.
아래는 여러 클래스의 중첩된 널 가능성을 안전한 호출 연산자를 사용해 간결하게 처리한 예제다.
class Address(val streetAddress: String, val zipCode: Int, val city: String, val country: String)
class Company(val name: String, val address: Address?)
class Person(val name: String, val company: Company?)
fun Person.countryName(): String {
val country = this.company?.address?.country
return if (country != null) country else "unKnown"
}
>>> val person = Person("Dmitry", null)
>>> println(person.countryName())
unKnown
자바에서는 복잡하게 중첩된 널 검사가 자주 등장하지만,
코틀린은 안전한 호출 연산자(?.
)를 통해 훨씬 간단하고 깔끔한 코드 작성이 가능하다.
6.1.4 엘비스 연산자: ?:
코틀린은 널일 경우 대체할 기본값을 간편히 지정할 수 있는 엘비스 연산자(?:
)를 제공한다.
엘비스 연산자는 아래와 같은 형태이다.
fun foo(s: String?) {
val t: String = s ?: ""
}
위 코드에서 ?:
는 다음과 같이 동작한다.
- 좌항의 값을 평가해 널이 아니면 좌항의 값을 사용한다.
- 좌항의 값이 널이면 우항의 값을 대신 사용한다.
다음 예시를 살펴보자.
fun strLenSafe(s: String?): Int = s?.length ?: 0
>>> println(strLenSafe("abc"))
3
>>> println(strLenSafe(null))
0
엘비스 연산자는 한 줄 표현식으로 깔끔하게 작성할 수 있다.
// 한 줄 표현 가능
fun Person.countryName() = company?.address?.country ?: "Unknown"
throw와 함께 엘비스 연산자 활용하기
코틀린에서는 return
, throw
와 같은 문장도 식(expression) 이다.
따라서 엘비스 연산자의 우항에 이런 문장도 사용할 수 있어, 더 명확한 코드 작성이 가능하다.
이러한 방식은 특히 함수의 전제 조건을 검사할 때 유용하다.
아래는 엘비스 연산자를 활용하여 회사 주소를 인쇄하는 함수의 예시다.
class Address(
val streetAddress: String,
val zipCode: Int,
val city: String,
val country: String
)
class Company(
val name: String,
val address: Address?
)
class Person(
val name: String,
val company: Company?
)
fun printShippingLabel(person: Person) {
val address = person.company?.address
?: throw IllegalArgumentException("No address")
with(address) {
println(streetAddress)
println("$zipCode $city, $country")
}
}
fun main() {
val address = Address("Elsestr. 47", 80687, "Munich", "Germany")
val jetbrains = Company("JetBrains", address)
val person = Person("Dmitry", jetbrains)
printShippingLabel(person)
// 출력:
// Elsestr. 47
// 80687 Munich, Germany
printShippingLabel(Person("Alexey", null))
// 예외 발생:
// IllegalArgumentException: No address
}
이 코드에서 printShippingLabel
함수는 다음과 같은 흐름으로 실행된다.
- 모든 정보가 정상적으로 있으면 주소를 출력한다.
- 회사 주소가 없으면 단순히
NullPointerException
을 발생시키는 대신 의미 있는 예외를 발생시킨다. - 주소가 있는 경우,
with
를 사용하여 주소를 반복적으로 참조하지 않고 간결하게 표현했다.
다음 절에서는 자바의 instanceof
검사를 대신할 수 있는 코틀린의 안전한 타입 캐스트 연산자 를 알아볼 것이다. 이 타입 캐스트 연산자는 주로 엘비스 연산자나 안전한 호출 연산자와 함께 사용된다.
6.1.5 안전한 캐스트: as?
코틀린에서는 일반적인 타입 캐스트 연산자로 as
를 사용할 수 있다.
하지만 as
는 자바와 마찬가지로 캐스트가 실패하면 ClassCastException
이 발생한다.
val obj: Any = "Hello"
val str: String = obj as String // 성공
val num: Int = obj as Int // ClassCastException 발생
이러한 문제를 방지하기 위해 코틀린은 안전한 캐스트 연산자 as?
를 제공한다.
as?의 동작 방식
as?
는 지정한 타입으로 캐스트할 수 있으면 해당 값을 반환한다.- 캐스트에 실패하면
null
을 반환한다. - 따라서 예외 없이 안전하게 캐스트할 수 있다.
예시: equals 메서드에서 as? 사용하기
class Person(val firstName: String, val lastName: String) {
override fun equals(o: Any?): Boolean {
val otherPerson = o as? Person ?: return false
return otherPerson.firstName == firstName &&
otherPerson.lastName == lastName
}
override fun hashCode(): Int =
firstName.hashCode() * 37 + lastName.hashCode()
}
>>> val p1 = Person("Dmitry", "Jemerov")
>>> val p2 = Person("Dmitry", "Jemerov")
>>> println(p1 == p2)
true
>>> println(p1.equals(42))
false
이 예제에서 equals
함수는 다음과 같이 동작한다.
- 파라미터
o
를Person
으로 안전하게 캐스트한다. - 타입이 맞지 않으면
null
을 반환하므로false
를 즉시 반환. - 타입이 맞으면 캐스트된 값으로 필드 비교 수행.
이 패턴은 코드가 간결할 뿐 아니라 안전하며, 한 줄로 타입 검사 + 캐스트 + 분기 처리를 수행할 수 있다.
스마트 캐스트와 함께 사용하기
위의 예제에서 as?
와 ?:
(엘비스 연산자)를 사용하면 스마트 캐스트가 적용된다.
컴파일러는 null
체크 이후, 그 값이 원하는 타입임을 추론할 수 있기 때문에otherPerson.firstName
과 같이 별도의 캐스트 없이 프로퍼티에 접근할 수 있다.
6.1.6 널 아님 단언: !!
널 아님 단언(not-null assertion) 은 코틀린에서 널이 될 수 있는 값을 강제로 널이 될 수 없는 타입으로 바꿀 때 사용하는 도구다.
이때 사용하는 연산자는 !!
이며, 이는 매우 강력하지만 위험한 연산자다.
실제로 값이 null
인데 !!
를 사용하면 NullPointerException
(NPE) 이 발생한다.
기본 예제
fun ignoreNulls(s: String?) {
val sNotNull: String = s!!
println(sNotNull.length)
}
>>> ignoreNulls(null)
// Exception in thread "main" kotlin.KotlinNullPointerException
// at <...>.ignoreNulls (07 NotnullAssertions.kt:2)
이 예제에서 s!!
는 s
가 절대 null
이 아니라고 컴파일러에게 개발자가 보증하는 코드이다.
하지만 실제로 null
이면 런타임에 예외가 발생한다.
!!
의 의미: "이 값은 절대 null이 아님을 내가 책임질게. 틀리면 예외 터져도 감수함."
언제 !!가 필요한가?
코틀린은 기본적으로 널 안정성(null-safety)을 제공하지만,
다른 함수나 메서드에서 널이 아님을 보장해도 컴파일러가 이를 알 수 없는 경우가 있다.
예를 들어, UI 프레임워크(Swing 등)에서 다음과 같은 패턴이 자주 등장한다.
class CopyRowAction(val list: JList<String>) : AbstractAction() {
override fun isEnabled(): Boolean =
list.selectedValue != null
override fun actionPerformed(e: ActionEvent) {
val value = list.selectedValue!! // 컴파일러는 여전히 nullable로 간주함
// value를 클립보드로 복사
}
}
isEnabled()
가false
면actionPerformed()
는 호출되지 않는다.- 하지만 컴파일러는 그 사실을 모른다.
- 따라서 강제로
!!
로 단언할 수밖에 없다.
대안 패턴
!!
대신 다음처럼 엘비스 연산자와 조기 반환 패턴을 사용할 수도 있다.
override fun actionPerformed(e: ActionEvent) {
val value = list.selectedValue ?: return
// value는 이제 non-null로 간주됨
}
이렇게 하면 value
가 null일 경우 함수가 종료되므로, 이후 로직에서는 안전하게 사용할 수 있다.
엘비스 연산자가 다소 중복처럼 보일 수 있지만, 나중에 로직이 복잡해질 것을 고려한 방어적인 코드 스타일로 유용하다.
주의: 한 줄에 !! 여러 번 사용하지 말 것
아래와 같은 코드는 피해야 한다.
person.company!!.address!!.country
이유:
- NPE가 발생해도 어느 부분에서 null이었는지 디버깅하기 어렵다.
- 스택 트레이스는 줄 번호만 알려주고, 어떤 식(expression)에서 터졌는지 정보가 부족하다.
6.1.7 let 함수
let
함수는 널이 될 수 있는 식(null 가능 변수) 을 더 쉽게 다루기 위한 도구이다.
특히 안전한 호출 연산자(?.
) 와 함께 사용하면,
널 체크 + 널이 아닌 경우 처리를 간단한 한 줄로 표현할 수 있다
대표 용례: 널이 아닌 값만 받는 함수에 null 가능 값 넘기기
예를 들어, String
타입(널 허용 X)을 인자로 받는 함수가 있다고 하자.
fun sendEmailTo(email: String) { /*...*/ }
이때, String?
타입의 값을 넘기면 컴파일 오류가 발생한다.
val email: String? = ...
sendEmailTo(email)
// ERROR: Type mismatch: inferred type is String? but String was expected
널 체크 후 사용하는 일반적인 방식:
if (email != null) sendEmailTo(email)
→ let
을 사용하면 더 간결하게 작성 가능:
email?.let { email -> sendEmailTo(email) }
혹은 it
키워드를 써서 더 짧게:
email?.let { sendEmailTo(it) }
예제
fun sendEmailTo(email: String) {
println("Sending email to $email")
}
var email: String? = "yole@example.com"
email?.let { sendEmailTo(it) }
// 출력: Sending email to yole@example.com
email = null
email?.let { sendEmailTo(it) }
// 출력 없음 (null이므로 람다 실행되지 않음)
복잡한 식을 다룰 때도 유용
let
은 긴 식의 결과가 null이 아닐 경우에만 동작하는 로직이 있을 때 유용하다.
예를 들어:
val person: Person? = getTheBestPersonInTheWorld()
if (person != null) sendEmailTo(person.email)
→ let
을 사용하면 변수 선언 없이 더 깔끔하게 작성 가능:
getTheBestPersonInTheWorld()?.let { sendEmailTo(it.email) }
이때 getTheBestPersonInTheWorld()
가 null을 반환하면 람다는 실행되지 않는다.
fun getTheBestPersonInTheWorld(): Person? = null
let 중첩 사용에 주의
여러 값을 검사해야 할 때 let
을 중첩해서 사용할 수도 있지만,
가독성이 나빠지기 때문에 일반적인 if
문으로 처리하는 것이 더 낫다.
기타 활용: 생성자 초기화 문제
널이 될 수 없는 프로퍼티지만 생성자 내에서 바로 초기화할 수 없는 경우,let
을 포함한 널 처리 기법들이 자주 사용된다.
이 부분은 이후 내용에서 더 자세히 다룬다.
6.1.8 나중에 초기화할 프로퍼티
많은 프레임워크에서는 객체를 생성한 뒤 나중에 초기화하는 방식을 사용한다.
예:
- 안드로이드에서는
onCreate()
에서 초기화 - JUnit에서는
@Before
메서드에서 초기화
하지만 코틀린에서는 널이 될 수 없는(non-null) 프로퍼티를 생성자 밖에서 초기화하려면 문제가 생긴다.
기본적으로 코틀린은 생성자에서 모든 프로퍼티를 초기화할 것을 요구하며, 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 초기화해야 한다.
그렇지 않으면 다음과 같이 nullable 타입을 사용하고 !! 연산자를 써야 하는 상황이 된다.
class MyService {
fun performAction(): String = "foo"
}
class MyTest {
private var myService: MyService? = null
@Before
fun setUp() {
myService = MyService()
}
@Test
fun testAction() {
Assert.assertEquals(
"foo",
myService!!.performAction()
)
}
}
이 방식은 매번 !!
연산자를 써야 하므로 보기 안 좋고 위험하다.
이를 해결하기 위해 코틀린은 lateinit
키워드를 제공한다.
class MyTest {
private lateinit var myService: MyService
@Before
fun setUp() {
myService = MyService()
}
@Test
fun testAction() {
Assert.assertEquals(
"foо",
myService.performAction()
)
}
}
lateinit
을 사용하면 nullable 타입이 아니어도 된다.!!
없이도 안전하게 사용할 수 있다.- 대신 프로퍼티가 초기화되기 전에 접근하면 다음과 같은 예외가 발생한다:
"lateinit property myService has not been initialized"
이 예외는 NPE보다 명확하게 원인을 알려준다.
lateinit의 규칙
var
로만 선언 가능 (val
은 불가능)
→val
은 final로 컴파일되기 때문에 생성자에서 반드시 초기화해야 함- nullable이 아닌 타입에만 사용 가능
→ nullable 타입이면 애초에null
체크로 처리할 수 있기 때문 - 초기화 전 접근 시 예외 발생
DI 프레임워크와의 연동
의존성 주입(Dependency Injection) 프레임워크에서도 lateinit
프로퍼티는 자주 사용된다.
외부에서 프로퍼티를 주입해주기 때문에 생성자 초기화가 불가능한 경우가 많다.
코틀린은 lateinit
프로퍼티에 대해 자바 프레임워크와 호환성을 높이기 위해 가시성이 동일한 필드를 자동 생성해준다.
public lateinit var
→ 자바에서도public
필드로 접근 가능
결과적으로 lateinit
은 널이 될 수 없는 프로퍼티를 유연하게 나중에 초기화할 수 있는 좋은 도구이며,
UI 프레임워크나 테스트 코드, DI 환경에서 특히 유용하다.
6.1.9 널이 될 수 있는 타입 확장
널이 될 수 있는 타입에 대해 확장 함수를 정의하면, null을 깔끔하게 처리하는 도구로 활용할 수 있다.
수신 객체가 null인지 일일이 확인하지 않고도, 확장 함수가 내부에서 적절히 처리할 수 있도록 만드는 방식이다.
이런 처리는 일반 멤버 함수에서는 불가능하고, 확장 함수에서만 가능하다.
일반 멤버 함수는 디스패치 대상이 반드시 null이 아닌 인스턴스여야 하기 때문이다.
예를 들어, 코틀린 표준 라이브러리에는 String?
타입에 대해 다음과 같은 확장 함수가 정의되어 있다.
fun verifyUserInput(input: String?) {
if (input.isNullOrBlank()) {
println("Please fill in the required fields")
}
}
>>> verifyUserInput("")
Please fill in the required fields
>>> verifyUserInput(null)
Please fill in the required fields
isNullOrBlank()
는 null
일 경우 true
를 반환하고, 그렇지 않으면 isBlank()
를 호출한다.
해당 확장 함수는 다음과 같이 구현되어 있다.
fun String?.isNullOrBlank(): Boolean = this == null || this.isBlank()
여기서 this
는 String?
타입이므로, 내부에서 null
여부를 반드시 직접 검사해야 한다.
자바에서는 this
가 항상 null이 아니지만, 코틀린 확장 함수에서는 this
가 null일 수 있다.
let과 비교
let
함수도 널이 될 수 있는 타입에 대해 호출할 수 있지만,
안전한 호출 연산자(?.
) 없이 호출하면 람다 인자가 null 가능 타입으로 추론된다.
val person: Person? = ...
person.let { sendEmailTo(it) }
// ERROR: Type mismatch: inferred type is Person? but Person was expected
따라서 let
을 쓸 때도 널이 아닌 경우에만 실행하고 싶다면 다음처럼 안전한 호출을 반드시 사용해야 한다.
person?.let { sendEmailTo(it) }
이 절에서 중요한 점은,someVariable.isNullOrBlank()
처럼 직접 메서드를 호출한다고 해서 someVariable
이 널이 될 수 없는 타입이라고 추론되지는 않는다는 것이다.
이 메서드가 널이 될 수 있는 타입에 대한 확장 함수로 정의되어 있을 수 있기 때문이다.
즉, 코드를 짧게 썼다고 해서 값이 null이 아님이 보장되는 것은 아니다.
6.1.10 타입 파라미터의 널 가능성
코틀린에서는 함수나 클래스의 모든 타입 파라미터가 기본적으로 널이 될 수 있다. 즉, 타입 파라미터 T
는 T?
처럼 따로 명시하지 않아도 널이 될 수 있는 타입을 허용한다.
다음 예제를 보면 이를 확인할 수 있다:
fun <T> printHashCode(t: T) {
println(t?.hashCode())
}
>>> printHashCode(null)
null
위 코드에서 T
는 Any?
로 추론되며, t
는 null
이 될 수 있다.
비록 T
에 물음표가 없지만, 타입 파라미터는 널 허용이 기본값이다.
널이 될 수 없는 타입만 허용하고 싶다면 타입 상한(upper bound) 을 사용해야 한다. 즉, T : Any
와 같이 명시하면 T
는 널이 될 수 없게 된다:
fun <T : Any> printHashCode(t: T) {
println(t.hashCode())
}
>>> printHashCode(null)
Error: Type parameter bound for `T` is not satisfied
>>> printHashCode(42)
42
이렇게 하면 null
이 들어오는 것을 컴파일 타임에 막을 수 있다.
결론적으로, 타입 파라미터는 유일하게 ?
없이도 널 가능성이 기본으로 포함되는 예외적인 경우이며, 널이 될 수 없는 타입으로 제한하려면 T : Any
처럼 상한을 지정해야 한다.
6.1.11 널 가능성과 자바
코틀린에서 플랫폼 타입(Platform Type) 이란, Java 코드에서 넘어온 타입에 대해 null 가능성을 명확히 알 수 없을 때 사용하는 특수한 타입입니다.
코틀린은 널 안정성을 갖춘 언어이지만, 자바와의 상호운용성을 지원하기 때문에 자바의 널 가능성 미지원 문제를 처리해야 한다. 그 해결책이 바로 플랫폼 타입이다.
자바의 널 가능성 애노테이션
자바는 기본적으로 널 가능성을 타입 시스템에서 다루지 않지만, @Nullable
, @NotNull
같은 애노테이션을 통해 힌트를 제공할 수 있다.
@Nullable String
→ 코틀린에서는String?
@NotNull String
→ 코틀린에서는String
코틀린이 이해하는 널 관련 애노테이션:
javax.annotation.*
(JSR-305)android.support.annotation.*
org.jetbrains.annotations.*
플랫폼 타입
애노테이션이 없는 자바 타입은 코틀린에서 플랫폼 타입(String!) 으로 간주된다.
플랫폼 타입은 널이 될 수 있는지 여부를 컴파일러가 알 수 없기 때문에, 프로그래머가 책임지고 처리해야 한다.
예제: 자바 클래스
public class Person {
private final String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
예제: 코틀린에서 사용하는 경우
fun yellAt(person: Person) {
println(person.name.toUpperCase() + "!!!")
}
>>> yellAt(Person(null))
java.lang.IllegalArgumentException: Parameter specified as non-null is null: method toUpperCase, parameter $receiver
→ 예외는 NullPointerException
이 아닌, 수신 객체가 널이라는 자세한 예외가 발생.
함수 파라미터의 널 검사
공개 함수의 경우, 코틀린 컴파일러가 자동으로 널 검사를 삽입해준다.
널이 아닌 파라미터에 널이 들어오면 함수 시작 시점에 바로 예외 발생 → 문제를 빠르게 파악 가능.
예제: 안전한 처리
fun yellAtSafe(person: Person) {
println((person.name ?: "Anyone").toUpperCase() + "!!!")
}
>>> yellAtSafe(Person(null))
ANYONE!!!
왜 플랫폼 타입인가?
자바의 모든 타입을 nullable
로 처리하면 불필요한 널 검사가 과도하게 발생한다.
특히 제네릭에서 성능과 코드 가독성 저하가 크다.
예: ArrayList<String>
→ ArrayList<String?>?
로 처리하면 모든 원소 접근 시 널 검사 필요.
→ 코틀린은 실용적인 이유로 책임을 프로그래머에게 넘기고, 플랫폼 타입을 허용함.
플랫폼 타입의 IDE 표현
val i: Int = person.name
// ERROR: Type mismatch: inferred type is String! but Int was expected
String!
→ 플랫폼 타입을 나타내며, 널 가능성 정보가 없음.!
기호는 코드에 직접 사용 불가, 컴파일러 메시지에서만 사용됨.
자바 인터페이스 상속 시 주의점
자바 메서드를 코틀린에서 오버라이드할 때, 널 가능성을 명확히 선언해야 한다.
예제: 자바 인터페이스
/* 자바 */
interface StringProcessor {
void process(String value);
}
예제: 코틀린 구현
class StringPrinter : StringProcessor {
override fun process(value: String) {
println(value)
}
}
class NullableStringPrinter : StringProcessor {
override fun process(value: String?) {
if (value != null) {
println(value)
}
}
}
String
으로 선언 시 → 코틀린 컴파일러가 널 체크 코드 자동 삽입.- 자바에서 널을 넘기면 → 즉시 예외 발생.
- 사용하지 않는 파라미터라도 예외는 발생한다.
더 알아보기
코틀린에서는 Null이 아님을 보장? 체크? 하는 함수가 있는데,
바로 requireNotNull() 또는 checkNotNull() 이다.
<내부 코드 필요>
requireNotNull(value)
값이 널이면 IllegalArgumentException
예외를 발생시킨다.
val name: String? = "John"
val nonNullName = requireNotNull(name) { "name은 null일 수 없습니다." }
checkNotNull(value)
값이 널이면 IllegalStateException
예외를 발생시킨다.
val email: String? = null
val nonNullEmail = checkNotNull(email) { "email은 반드시 있어야 합니다." }
둘 다 널이 아님을 명시적으로 보장하고, 널일 경우 명확한 예외를 던지는 데 사용한다.
!! 연산자보다는 위에 두 함수를 사용하는 것을 추천한다.
6-2 ~ 6-3 내용
Kotlin_In_Action_1/6장. 코틀린 타입 시스템/6-2.md at main · Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1
SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스터디(A). Contribute to Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1 development by creating an account on GitHub.
github.com
Kotlin_In_Action_1/6장. 코틀린 타입 시스템/6-3.md at main · Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1
SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스터디(A). Contribute to Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1 development by creating an account on GitHub.
github.com
도서 링크 바로가기
https://product.kyobobook.co.kr/detail/S000001804588
Kotlin in Action | 드미트리 제메로프 - 교보문고
Kotlin in Action | 코틀린이 안드로이드 공식 언어가 되면서 관심이 커졌다. 이 책은 코틀린 언어를 개발한 젯브레인의 코틀린 컴파일러 개발자들이 직접 쓴 일종의 공식 서적이라 할 수 있다. 코틀
product.kyobobook.co.kr
'Programming Language > Kotlin' 카테고리의 다른 글
[Kotlin In Action] 7장. 연산자 오버로딩과 기타 관례 정리 feat. 내가 정리한 부분만 (0) | 2025.04.03 |
---|---|
[Kotlin In Action] 5장. 람다로 프로그래밍 정리 (1) | 2025.03.21 |
[Kotlin In Action] 4장. 클래스 , 객체 , 인터페이스 정리 (0) | 2025.03.21 |
[Kotlin In Action] 3장. 함수 정의와 호출 정리 (0) | 2025.03.20 |
[Kotlin In Action] 2장. 코틀린 기초 스터디 정리 (0) | 2025.03.01 |
Kotlin in Action을 공부하며 정리한 내용입니다.
저작권에 문제가 될 시, 글을 모두 내리겠습니다.
제가 공부한 내용이 더 많은 분들에게도 도움이 되었으면 좋겠습니다. 부족한 부분은 댓글을 통해서 피드백을 주신다면 언제나 반영하겠습니다. 감사합니다.
책에 대한 링크는 맨 아래에 있습니다.
https://github.com/Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1
GitHub - Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1: SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스
SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스터디(A). Contribute to Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1 development by creating an account on GitHub.
github.com
내가 정리한 Part (6-1. 널 가능성)
널 가능성(nullability) 은 프로그램에서 흔히 발생하는 NullPointerException(NPE)
오류를 방지하도록 설계된 코틀린 타입 시스템의 중요한 특징이다.
자바에서는 흔히 다음과 같은 메시지로 NPE를 경험하게 된다.
"An error has occurred: java.lang.NullPointerException"
"Unfortunately, the application X has stopped"
이러한 오류는 사용자뿐만 아니라 개발자에게도 혼란을 준다. 코틀린과 같은 최신 언어는 실행 시점(runtime) 이 아닌 컴파일 시점(compile-time) 에 널 관련 문제를 발견하도록 돕는다. 즉, 타입 시스템에 널 가능성을 명시함으로써, 컴파일러가 잠재적인 NPE를 미리 감지해 오류를 예방할 수 있다.
6.1.1 널이 될 수 있는 타입
코틀린과 자바의 중요한 차이점 중 하나는 널이 될 수 있는 타입(nullable type) 을 명시적으로 지원한다는 점이다.
널이 될 수 있는 타입이란, 변수나 프로퍼티가 null
을 저장할 수 있음을 의미한다.
널이 될 수 있는 타입의 변수에 메서드를 호출하면 NullPointerException이 발생할 위험이 있다. 코틀린은 이런 호출을 금지하여 오류를 예방한다.
널을 허용하지 않는 함수
다음은 자바로 작성된 문자열 길이를 반환하는 함수이다.
int strLen(String s) {
return s.length();
}
위의 함수는 매개변수 s
가 null일 경우 NullPointerException이 발생할 수 있다.
코틀린에서는 널을 허용하지 않는 타입으로 아래처럼 정의할 수 있다.
fun strLen(s: String) = s.length
이 경우, 널을 전달하면 컴파일 오류가 발생한다.
strLen(null)
// ERROR: Null can not be a value of a non-null type String
코틀린에서는 별도의 null 체크 없이도 strLen
함수가 절대 NullPointerException을 발생시키지 않는다고 보장할 수 있다.
널을 허용하는 함수
코틀린에서 타입 뒤에 ?
를 붙이면, 그 변수는 널을 허용하는 타입이 된다.
예를 들어, String?
, Int?
, MyCustomType?
는 모두 null을 저장할 수 있다.
널을 허용하는 함수는 아래와 같이 정의할 수 있다.
fun strLenSafe(s: String?) = ...
널이 될 수 있는 타입의 변수로는 직접 메서드를 호출할 수 없다.
fun strLenSafe(s: String?) = s.length()
// ERROR: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type kotlin.String?
널 가능성에 따른 타입 제약
널이 될 수 있는 값(String?
)을 널이 될 수 없는 타입(String
)에 대입하면 오류가 발생한다.
val x: String? = null
val y: String = x
// ERROR: Type mismatch: inferred type is String? but String was expected
널 가능 값(String?
)을 널을 허용하지 않는 함수에 인자로 넘겨도 오류가 발생한다.
strLen(x)
// ERROR: Type mismatch: inferred type is String? but String was expected
널이 될 수 있는 타입을 안전하게 사용하는 방법
널이 될 수 있는 타입은 직접 메서드를 호출할 수 없지만, null
과 비교하면 이후 안전한 영역에서 널이 될 수 없는 값처럼 사용할 수 있다.
예시:
// if 검사를 통해 null 값 다루기
fun strLenSafe(s: String?): Int =
if (s != null) s.length else 0
val x: String? = null
println(strLenSafe(x)) // 0 출력
println(strLenSafe("abc")) // 3 출력
이렇게 하면 null 값을 안전하게 처리할 수 있다.
6.1.2 타입의 의미
타입은 값의 분류(classification) 다.
즉, 타입은 어떤 값이 가능하며, 그 값에 어떤 연산을 수행할 수 있는지 결정한다.
자바의 타입 문제점
자바의 double
타입을 예로 들어보자.
double
타입은 64비트 부동소수점 수를 나타낸다.- 따라서, 이 타입의 변수라면 일반적인 수학 연산을 수행할 수 있다는 사실을 컴파일 단계에서 알 수 있다.
하지만 String
타입의 경우 상황이 다르다.
자바의 String
타입 변수에는 실제 문자열이나 null
이 들어갈 수 있으며, 둘은 전혀 다른 종류의 값이다.
즉, 같은 타입임에도 실제 값이 문자열인지 null
인지에 따라 수행할 수 있는 연산이 달라진다.
이러한 문제는 자바의 타입 시스템이 널(null
)을 제대로 처리하지 못하기 때문에 발생한다.
결과적으로 개발자가 변수의 널 여부를 항상 추가적으로 검사해야 하고, 이를 생략하면 실행 시점에 NullPointerException
이 발생할 수 있다.
코틀린의 널 가능 타입
코틀린은 널 가능성 문제를 근본적으로 해결하기 위해 널이 될 수 있는 타입과 널이 될 수 없는 타입을 엄격히 구분한다.
- 널이 될 수 없는 타입(non-nullable) : 항상 실제 값을 저장한다.
- 널이 될 수 있는 타입(nullable) : 실제 값 또는
null
을 저장할 수 있다.
이렇게 두 타입을 명확히 구분하면 각 값에 대해 사용할 수 있는 연산이 명확히 정해지므로, 실행 시점의 오류 가능성을 미리 방지할 수 있다.
코틀린의 타입 검사는 컴파일 시점에 이루어진다.
널 가능 타입이 실행 시점에 별도의 객체로 감싸지거나 래핑되지 않으므로, 별도의 실행 시점 부가 비용이 발생하지 않는다.
6.1.3 안전한 호출 연산자: ?.
코틀린에서 가장 유용한 기능 중 하나는 안전한 호출 연산자 인 ?.
이다.
이 연산자는 널(null
) 검사와 메서드 호출을 한 번에 처리할 수 있게 해준다.
예를 들어 다음의 코틀린 코드를 보자.
s?.toUpperCase()
이 코드는 자바의 다음 코드와 동일한 동작을 한다.
if (s != null) s.toUpperCase() else null
즉, 호출 대상(s
)이 null
이 아니라면 일반적인 메서드 호출처럼 동작하며,
호출 대상이 null
이면 메서드는 호출되지 않고 결과는 그냥 null
이 된다.
안전한 호출의 결과는 항상 널이 될 수 있는 타입이다.
즉, 원본 메서드의 반환 타입이 String
이더라도, 안전한 호출을 사용하면 반환 타입은 String?
이 된다.
fun printAllCaps(s: String?) {
val allCaps: String? = s?.toUpperCase()
println(allCaps)
}
>>> printAllCaps("abc")
ABC
>>> printAllCaps(null)
null
메서드 호출뿐 아니라 프로퍼티를 읽고 쓸 때도 안전한 호출을 사용할 수 있다.
다음은 프로퍼티에 안전한 호출을 사용하는 예제이다.
class Employee(val name: String, val manager: Employee?)
fun managerName(employee: Employee): String? = employee.manager?.name
>>> val ceo = Employee("Da Boss", null)
>>> val developer = Employee("Bob smith", ceo)
>>> println(managerName(developer))
Da Boss
>>> println(managerName(ceo))
null
객체 그래프에 널이 될 수 있는 중간 객체가 여러 개 존재할 때는, 안전한 호출을 연쇄적으로 사용하면 매우 편리하다.
아래는 여러 클래스의 중첩된 널 가능성을 안전한 호출 연산자를 사용해 간결하게 처리한 예제다.
class Address(val streetAddress: String, val zipCode: Int, val city: String, val country: String)
class Company(val name: String, val address: Address?)
class Person(val name: String, val company: Company?)
fun Person.countryName(): String {
val country = this.company?.address?.country
return if (country != null) country else "unKnown"
}
>>> val person = Person("Dmitry", null)
>>> println(person.countryName())
unKnown
자바에서는 복잡하게 중첩된 널 검사가 자주 등장하지만,
코틀린은 안전한 호출 연산자(?.
)를 통해 훨씬 간단하고 깔끔한 코드 작성이 가능하다.
6.1.4 엘비스 연산자: ?:
코틀린은 널일 경우 대체할 기본값을 간편히 지정할 수 있는 엘비스 연산자(?:
)를 제공한다.
엘비스 연산자는 아래와 같은 형태이다.
fun foo(s: String?) {
val t: String = s ?: ""
}
위 코드에서 ?:
는 다음과 같이 동작한다.
- 좌항의 값을 평가해 널이 아니면 좌항의 값을 사용한다.
- 좌항의 값이 널이면 우항의 값을 대신 사용한다.
다음 예시를 살펴보자.
fun strLenSafe(s: String?): Int = s?.length ?: 0
>>> println(strLenSafe("abc"))
3
>>> println(strLenSafe(null))
0
엘비스 연산자는 한 줄 표현식으로 깔끔하게 작성할 수 있다.
// 한 줄 표현 가능
fun Person.countryName() = company?.address?.country ?: "Unknown"
throw와 함께 엘비스 연산자 활용하기
코틀린에서는 return
, throw
와 같은 문장도 식(expression) 이다.
따라서 엘비스 연산자의 우항에 이런 문장도 사용할 수 있어, 더 명확한 코드 작성이 가능하다.
이러한 방식은 특히 함수의 전제 조건을 검사할 때 유용하다.
아래는 엘비스 연산자를 활용하여 회사 주소를 인쇄하는 함수의 예시다.
class Address(
val streetAddress: String,
val zipCode: Int,
val city: String,
val country: String
)
class Company(
val name: String,
val address: Address?
)
class Person(
val name: String,
val company: Company?
)
fun printShippingLabel(person: Person) {
val address = person.company?.address
?: throw IllegalArgumentException("No address")
with(address) {
println(streetAddress)
println("$zipCode $city, $country")
}
}
fun main() {
val address = Address("Elsestr. 47", 80687, "Munich", "Germany")
val jetbrains = Company("JetBrains", address)
val person = Person("Dmitry", jetbrains)
printShippingLabel(person)
// 출력:
// Elsestr. 47
// 80687 Munich, Germany
printShippingLabel(Person("Alexey", null))
// 예외 발생:
// IllegalArgumentException: No address
}
이 코드에서 printShippingLabel
함수는 다음과 같은 흐름으로 실행된다.
- 모든 정보가 정상적으로 있으면 주소를 출력한다.
- 회사 주소가 없으면 단순히
NullPointerException
을 발생시키는 대신 의미 있는 예외를 발생시킨다. - 주소가 있는 경우,
with
를 사용하여 주소를 반복적으로 참조하지 않고 간결하게 표현했다.
다음 절에서는 자바의 instanceof
검사를 대신할 수 있는 코틀린의 안전한 타입 캐스트 연산자 를 알아볼 것이다. 이 타입 캐스트 연산자는 주로 엘비스 연산자나 안전한 호출 연산자와 함께 사용된다.
6.1.5 안전한 캐스트: as?
코틀린에서는 일반적인 타입 캐스트 연산자로 as
를 사용할 수 있다.
하지만 as
는 자바와 마찬가지로 캐스트가 실패하면 ClassCastException
이 발생한다.
val obj: Any = "Hello"
val str: String = obj as String // 성공
val num: Int = obj as Int // ClassCastException 발생
이러한 문제를 방지하기 위해 코틀린은 안전한 캐스트 연산자 as?
를 제공한다.
as?의 동작 방식
as?
는 지정한 타입으로 캐스트할 수 있으면 해당 값을 반환한다.- 캐스트에 실패하면
null
을 반환한다. - 따라서 예외 없이 안전하게 캐스트할 수 있다.
예시: equals 메서드에서 as? 사용하기
class Person(val firstName: String, val lastName: String) {
override fun equals(o: Any?): Boolean {
val otherPerson = o as? Person ?: return false
return otherPerson.firstName == firstName &&
otherPerson.lastName == lastName
}
override fun hashCode(): Int =
firstName.hashCode() * 37 + lastName.hashCode()
}
>>> val p1 = Person("Dmitry", "Jemerov")
>>> val p2 = Person("Dmitry", "Jemerov")
>>> println(p1 == p2)
true
>>> println(p1.equals(42))
false
이 예제에서 equals
함수는 다음과 같이 동작한다.
- 파라미터
o
를Person
으로 안전하게 캐스트한다. - 타입이 맞지 않으면
null
을 반환하므로false
를 즉시 반환. - 타입이 맞으면 캐스트된 값으로 필드 비교 수행.
이 패턴은 코드가 간결할 뿐 아니라 안전하며, 한 줄로 타입 검사 + 캐스트 + 분기 처리를 수행할 수 있다.
스마트 캐스트와 함께 사용하기
위의 예제에서 as?
와 ?:
(엘비스 연산자)를 사용하면 스마트 캐스트가 적용된다.
컴파일러는 null
체크 이후, 그 값이 원하는 타입임을 추론할 수 있기 때문에otherPerson.firstName
과 같이 별도의 캐스트 없이 프로퍼티에 접근할 수 있다.
6.1.6 널 아님 단언: !!
널 아님 단언(not-null assertion) 은 코틀린에서 널이 될 수 있는 값을 강제로 널이 될 수 없는 타입으로 바꿀 때 사용하는 도구다.
이때 사용하는 연산자는 !!
이며, 이는 매우 강력하지만 위험한 연산자다.
실제로 값이 null
인데 !!
를 사용하면 NullPointerException
(NPE) 이 발생한다.
기본 예제
fun ignoreNulls(s: String?) {
val sNotNull: String = s!!
println(sNotNull.length)
}
>>> ignoreNulls(null)
// Exception in thread "main" kotlin.KotlinNullPointerException
// at <...>.ignoreNulls (07 NotnullAssertions.kt:2)
이 예제에서 s!!
는 s
가 절대 null
이 아니라고 컴파일러에게 개발자가 보증하는 코드이다.
하지만 실제로 null
이면 런타임에 예외가 발생한다.
!!
의 의미: "이 값은 절대 null이 아님을 내가 책임질게. 틀리면 예외 터져도 감수함."
언제 !!가 필요한가?
코틀린은 기본적으로 널 안정성(null-safety)을 제공하지만,
다른 함수나 메서드에서 널이 아님을 보장해도 컴파일러가 이를 알 수 없는 경우가 있다.
예를 들어, UI 프레임워크(Swing 등)에서 다음과 같은 패턴이 자주 등장한다.
class CopyRowAction(val list: JList<String>) : AbstractAction() {
override fun isEnabled(): Boolean =
list.selectedValue != null
override fun actionPerformed(e: ActionEvent) {
val value = list.selectedValue!! // 컴파일러는 여전히 nullable로 간주함
// value를 클립보드로 복사
}
}
isEnabled()
가false
면actionPerformed()
는 호출되지 않는다.- 하지만 컴파일러는 그 사실을 모른다.
- 따라서 강제로
!!
로 단언할 수밖에 없다.
대안 패턴
!!
대신 다음처럼 엘비스 연산자와 조기 반환 패턴을 사용할 수도 있다.
override fun actionPerformed(e: ActionEvent) {
val value = list.selectedValue ?: return
// value는 이제 non-null로 간주됨
}
이렇게 하면 value
가 null일 경우 함수가 종료되므로, 이후 로직에서는 안전하게 사용할 수 있다.
엘비스 연산자가 다소 중복처럼 보일 수 있지만, 나중에 로직이 복잡해질 것을 고려한 방어적인 코드 스타일로 유용하다.
주의: 한 줄에 !! 여러 번 사용하지 말 것
아래와 같은 코드는 피해야 한다.
person.company!!.address!!.country
이유:
- NPE가 발생해도 어느 부분에서 null이었는지 디버깅하기 어렵다.
- 스택 트레이스는 줄 번호만 알려주고, 어떤 식(expression)에서 터졌는지 정보가 부족하다.
6.1.7 let 함수
let
함수는 널이 될 수 있는 식(null 가능 변수) 을 더 쉽게 다루기 위한 도구이다.
특히 안전한 호출 연산자(?.
) 와 함께 사용하면,
널 체크 + 널이 아닌 경우 처리를 간단한 한 줄로 표현할 수 있다
대표 용례: 널이 아닌 값만 받는 함수에 null 가능 값 넘기기
예를 들어, String
타입(널 허용 X)을 인자로 받는 함수가 있다고 하자.
fun sendEmailTo(email: String) { /*...*/ }
이때, String?
타입의 값을 넘기면 컴파일 오류가 발생한다.
val email: String? = ...
sendEmailTo(email)
// ERROR: Type mismatch: inferred type is String? but String was expected
널 체크 후 사용하는 일반적인 방식:
if (email != null) sendEmailTo(email)
→ let
을 사용하면 더 간결하게 작성 가능:
email?.let { email -> sendEmailTo(email) }
혹은 it
키워드를 써서 더 짧게:
email?.let { sendEmailTo(it) }
예제
fun sendEmailTo(email: String) {
println("Sending email to $email")
}
var email: String? = "yole@example.com"
email?.let { sendEmailTo(it) }
// 출력: Sending email to yole@example.com
email = null
email?.let { sendEmailTo(it) }
// 출력 없음 (null이므로 람다 실행되지 않음)
복잡한 식을 다룰 때도 유용
let
은 긴 식의 결과가 null이 아닐 경우에만 동작하는 로직이 있을 때 유용하다.
예를 들어:
val person: Person? = getTheBestPersonInTheWorld()
if (person != null) sendEmailTo(person.email)
→ let
을 사용하면 변수 선언 없이 더 깔끔하게 작성 가능:
getTheBestPersonInTheWorld()?.let { sendEmailTo(it.email) }
이때 getTheBestPersonInTheWorld()
가 null을 반환하면 람다는 실행되지 않는다.
fun getTheBestPersonInTheWorld(): Person? = null
let 중첩 사용에 주의
여러 값을 검사해야 할 때 let
을 중첩해서 사용할 수도 있지만,
가독성이 나빠지기 때문에 일반적인 if
문으로 처리하는 것이 더 낫다.
기타 활용: 생성자 초기화 문제
널이 될 수 없는 프로퍼티지만 생성자 내에서 바로 초기화할 수 없는 경우,let
을 포함한 널 처리 기법들이 자주 사용된다.
이 부분은 이후 내용에서 더 자세히 다룬다.
6.1.8 나중에 초기화할 프로퍼티
많은 프레임워크에서는 객체를 생성한 뒤 나중에 초기화하는 방식을 사용한다.
예:
- 안드로이드에서는
onCreate()
에서 초기화 - JUnit에서는
@Before
메서드에서 초기화
하지만 코틀린에서는 널이 될 수 없는(non-null) 프로퍼티를 생성자 밖에서 초기화하려면 문제가 생긴다.
기본적으로 코틀린은 생성자에서 모든 프로퍼티를 초기화할 것을 요구하며, 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 초기화해야 한다.
그렇지 않으면 다음과 같이 nullable 타입을 사용하고 !! 연산자를 써야 하는 상황이 된다.
class MyService {
fun performAction(): String = "foo"
}
class MyTest {
private var myService: MyService? = null
@Before
fun setUp() {
myService = MyService()
}
@Test
fun testAction() {
Assert.assertEquals(
"foo",
myService!!.performAction()
)
}
}
이 방식은 매번 !!
연산자를 써야 하므로 보기 안 좋고 위험하다.
이를 해결하기 위해 코틀린은 lateinit
키워드를 제공한다.
class MyTest {
private lateinit var myService: MyService
@Before
fun setUp() {
myService = MyService()
}
@Test
fun testAction() {
Assert.assertEquals(
"foо",
myService.performAction()
)
}
}
lateinit
을 사용하면 nullable 타입이 아니어도 된다.!!
없이도 안전하게 사용할 수 있다.- 대신 프로퍼티가 초기화되기 전에 접근하면 다음과 같은 예외가 발생한다:
"lateinit property myService has not been initialized"
이 예외는 NPE보다 명확하게 원인을 알려준다.
lateinit의 규칙
var
로만 선언 가능 (val
은 불가능)
→val
은 final로 컴파일되기 때문에 생성자에서 반드시 초기화해야 함- nullable이 아닌 타입에만 사용 가능
→ nullable 타입이면 애초에null
체크로 처리할 수 있기 때문 - 초기화 전 접근 시 예외 발생
DI 프레임워크와의 연동
의존성 주입(Dependency Injection) 프레임워크에서도 lateinit
프로퍼티는 자주 사용된다.
외부에서 프로퍼티를 주입해주기 때문에 생성자 초기화가 불가능한 경우가 많다.
코틀린은 lateinit
프로퍼티에 대해 자바 프레임워크와 호환성을 높이기 위해 가시성이 동일한 필드를 자동 생성해준다.
public lateinit var
→ 자바에서도public
필드로 접근 가능
결과적으로 lateinit
은 널이 될 수 없는 프로퍼티를 유연하게 나중에 초기화할 수 있는 좋은 도구이며,
UI 프레임워크나 테스트 코드, DI 환경에서 특히 유용하다.
6.1.9 널이 될 수 있는 타입 확장
널이 될 수 있는 타입에 대해 확장 함수를 정의하면, null을 깔끔하게 처리하는 도구로 활용할 수 있다.
수신 객체가 null인지 일일이 확인하지 않고도, 확장 함수가 내부에서 적절히 처리할 수 있도록 만드는 방식이다.
이런 처리는 일반 멤버 함수에서는 불가능하고, 확장 함수에서만 가능하다.
일반 멤버 함수는 디스패치 대상이 반드시 null이 아닌 인스턴스여야 하기 때문이다.
예를 들어, 코틀린 표준 라이브러리에는 String?
타입에 대해 다음과 같은 확장 함수가 정의되어 있다.
fun verifyUserInput(input: String?) {
if (input.isNullOrBlank()) {
println("Please fill in the required fields")
}
}
>>> verifyUserInput("")
Please fill in the required fields
>>> verifyUserInput(null)
Please fill in the required fields
isNullOrBlank()
는 null
일 경우 true
를 반환하고, 그렇지 않으면 isBlank()
를 호출한다.
해당 확장 함수는 다음과 같이 구현되어 있다.
fun String?.isNullOrBlank(): Boolean = this == null || this.isBlank()
여기서 this
는 String?
타입이므로, 내부에서 null
여부를 반드시 직접 검사해야 한다.
자바에서는 this
가 항상 null이 아니지만, 코틀린 확장 함수에서는 this
가 null일 수 있다.
let과 비교
let
함수도 널이 될 수 있는 타입에 대해 호출할 수 있지만,
안전한 호출 연산자(?.
) 없이 호출하면 람다 인자가 null 가능 타입으로 추론된다.
val person: Person? = ...
person.let { sendEmailTo(it) }
// ERROR: Type mismatch: inferred type is Person? but Person was expected
따라서 let
을 쓸 때도 널이 아닌 경우에만 실행하고 싶다면 다음처럼 안전한 호출을 반드시 사용해야 한다.
person?.let { sendEmailTo(it) }
이 절에서 중요한 점은,someVariable.isNullOrBlank()
처럼 직접 메서드를 호출한다고 해서 someVariable
이 널이 될 수 없는 타입이라고 추론되지는 않는다는 것이다.
이 메서드가 널이 될 수 있는 타입에 대한 확장 함수로 정의되어 있을 수 있기 때문이다.
즉, 코드를 짧게 썼다고 해서 값이 null이 아님이 보장되는 것은 아니다.
6.1.10 타입 파라미터의 널 가능성
코틀린에서는 함수나 클래스의 모든 타입 파라미터가 기본적으로 널이 될 수 있다. 즉, 타입 파라미터 T
는 T?
처럼 따로 명시하지 않아도 널이 될 수 있는 타입을 허용한다.
다음 예제를 보면 이를 확인할 수 있다:
fun <T> printHashCode(t: T) {
println(t?.hashCode())
}
>>> printHashCode(null)
null
위 코드에서 T
는 Any?
로 추론되며, t
는 null
이 될 수 있다.
비록 T
에 물음표가 없지만, 타입 파라미터는 널 허용이 기본값이다.
널이 될 수 없는 타입만 허용하고 싶다면 타입 상한(upper bound) 을 사용해야 한다. 즉, T : Any
와 같이 명시하면 T
는 널이 될 수 없게 된다:
fun <T : Any> printHashCode(t: T) {
println(t.hashCode())
}
>>> printHashCode(null)
Error: Type parameter bound for `T` is not satisfied
>>> printHashCode(42)
42
이렇게 하면 null
이 들어오는 것을 컴파일 타임에 막을 수 있다.
결론적으로, 타입 파라미터는 유일하게 ?
없이도 널 가능성이 기본으로 포함되는 예외적인 경우이며, 널이 될 수 없는 타입으로 제한하려면 T : Any
처럼 상한을 지정해야 한다.
6.1.11 널 가능성과 자바
코틀린에서 플랫폼 타입(Platform Type) 이란, Java 코드에서 넘어온 타입에 대해 null 가능성을 명확히 알 수 없을 때 사용하는 특수한 타입입니다.
코틀린은 널 안정성을 갖춘 언어이지만, 자바와의 상호운용성을 지원하기 때문에 자바의 널 가능성 미지원 문제를 처리해야 한다. 그 해결책이 바로 플랫폼 타입이다.
자바의 널 가능성 애노테이션
자바는 기본적으로 널 가능성을 타입 시스템에서 다루지 않지만, @Nullable
, @NotNull
같은 애노테이션을 통해 힌트를 제공할 수 있다.
@Nullable String
→ 코틀린에서는String?
@NotNull String
→ 코틀린에서는String
코틀린이 이해하는 널 관련 애노테이션:
javax.annotation.*
(JSR-305)android.support.annotation.*
org.jetbrains.annotations.*
플랫폼 타입
애노테이션이 없는 자바 타입은 코틀린에서 플랫폼 타입(String!) 으로 간주된다.
플랫폼 타입은 널이 될 수 있는지 여부를 컴파일러가 알 수 없기 때문에, 프로그래머가 책임지고 처리해야 한다.
예제: 자바 클래스
public class Person {
private final String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
예제: 코틀린에서 사용하는 경우
fun yellAt(person: Person) {
println(person.name.toUpperCase() + "!!!")
}
>>> yellAt(Person(null))
java.lang.IllegalArgumentException: Parameter specified as non-null is null: method toUpperCase, parameter $receiver
→ 예외는 NullPointerException
이 아닌, 수신 객체가 널이라는 자세한 예외가 발생.
함수 파라미터의 널 검사
공개 함수의 경우, 코틀린 컴파일러가 자동으로 널 검사를 삽입해준다.
널이 아닌 파라미터에 널이 들어오면 함수 시작 시점에 바로 예외 발생 → 문제를 빠르게 파악 가능.
예제: 안전한 처리
fun yellAtSafe(person: Person) {
println((person.name ?: "Anyone").toUpperCase() + "!!!")
}
>>> yellAtSafe(Person(null))
ANYONE!!!
왜 플랫폼 타입인가?
자바의 모든 타입을 nullable
로 처리하면 불필요한 널 검사가 과도하게 발생한다.
특히 제네릭에서 성능과 코드 가독성 저하가 크다.
예: ArrayList<String>
→ ArrayList<String?>?
로 처리하면 모든 원소 접근 시 널 검사 필요.
→ 코틀린은 실용적인 이유로 책임을 프로그래머에게 넘기고, 플랫폼 타입을 허용함.
플랫폼 타입의 IDE 표현
val i: Int = person.name
// ERROR: Type mismatch: inferred type is String! but Int was expected
String!
→ 플랫폼 타입을 나타내며, 널 가능성 정보가 없음.!
기호는 코드에 직접 사용 불가, 컴파일러 메시지에서만 사용됨.
자바 인터페이스 상속 시 주의점
자바 메서드를 코틀린에서 오버라이드할 때, 널 가능성을 명확히 선언해야 한다.
예제: 자바 인터페이스
/* 자바 */
interface StringProcessor {
void process(String value);
}
예제: 코틀린 구현
class StringPrinter : StringProcessor {
override fun process(value: String) {
println(value)
}
}
class NullableStringPrinter : StringProcessor {
override fun process(value: String?) {
if (value != null) {
println(value)
}
}
}
String
으로 선언 시 → 코틀린 컴파일러가 널 체크 코드 자동 삽입.- 자바에서 널을 넘기면 → 즉시 예외 발생.
- 사용하지 않는 파라미터라도 예외는 발생한다.
더 알아보기
코틀린에서는 Null이 아님을 보장? 체크? 하는 함수가 있는데,
바로 requireNotNull() 또는 checkNotNull() 이다.
<내부 코드 필요>
requireNotNull(value)
값이 널이면 IllegalArgumentException
예외를 발생시킨다.
val name: String? = "John"
val nonNullName = requireNotNull(name) { "name은 null일 수 없습니다." }
checkNotNull(value)
값이 널이면 IllegalStateException
예외를 발생시킨다.
val email: String? = null
val nonNullEmail = checkNotNull(email) { "email은 반드시 있어야 합니다." }
둘 다 널이 아님을 명시적으로 보장하고, 널일 경우 명확한 예외를 던지는 데 사용한다.
!! 연산자보다는 위에 두 함수를 사용하는 것을 추천한다.
6-2 ~ 6-3 내용
Kotlin_In_Action_1/6장. 코틀린 타입 시스템/6-2.md at main · Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1
SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스터디(A). Contribute to Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1 development by creating an account on GitHub.
github.com
Kotlin_In_Action_1/6장. 코틀린 타입 시스템/6-3.md at main · Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1
SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스터디(A). Contribute to Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1 development by creating an account on GitHub.
github.com
도서 링크 바로가기
https://product.kyobobook.co.kr/detail/S000001804588
Kotlin in Action | 드미트리 제메로프 - 교보문고
Kotlin in Action | 코틀린이 안드로이드 공식 언어가 되면서 관심이 커졌다. 이 책은 코틀린 언어를 개발한 젯브레인의 코틀린 컴파일러 개발자들이 직접 쓴 일종의 공식 서적이라 할 수 있다. 코틀
product.kyobobook.co.kr
'Programming Language > Kotlin' 카테고리의 다른 글
[Kotlin In Action] 7장. 연산자 오버로딩과 기타 관례 정리 feat. 내가 정리한 부분만 (0) | 2025.04.03 |
---|---|
[Kotlin In Action] 5장. 람다로 프로그래밍 정리 (1) | 2025.03.21 |
[Kotlin In Action] 4장. 클래스 , 객체 , 인터페이스 정리 (0) | 2025.03.21 |
[Kotlin In Action] 3장. 함수 정의와 호출 정리 (0) | 2025.03.20 |
[Kotlin In Action] 2장. 코틀린 기초 스터디 정리 (0) | 2025.03.01 |