사용자 정의 오류보다는 표준 오류를 선호하라
require
, check
, assert
함수들은 가장 흔한 코틀린 오류들을 다루지만, 그 외에도 예상치 못한 상황을 표시해야 할 경우가 있다.
예를 들어, JSON 포맷을 파싱하는 라이브러리를 구현할 때, 제공된 JSON 파일의 형식이 올바르지 않은 경우에는 JsonParsingException
을 던지는 것이 합리적이다.
inline fun <reified T> String.readObject(): T {
// ...
if (incorrectSign) {
throw JsonParsingException()
}
// ...
return result
}
표준 라이브러리에 이 상황을 나타낼 적절한 오류가 없기 때문에 사용자 정의 오류를 사용하였다.
가능한 표준 라이브러리에서 제공하는 예외를 사용하고, 사용자 정의 예외는 정의하지 않는 것이 좋다.
표준 라이브러리는
- 개발자에게 잘 알려져 있으며,
- 재사용될 수 있다.
- API를 배우고 이해하기 쉽게 만들 수 있다.
IllegalArgumentException
과IllegalStateException
: 이는require
와check
를 사용하여 던질 수 있다.IndexOutOfBoundsException
: 인덱스 매개변수 값이 범위를 벗어났음을 나타낸다. 주로 컬렉션과 배열에서 사용되며, 예를 들어ArrayList.get(Int)
에서 던져진다.ConcurrentModificationException
: 동시 수정이 금지되어 있음에도 불구하고 탐지된 경우를 나타낸다.UnsupportedOperationException
: 해당 객체가 선언된 메서드를 지원하지 않음을 나타낸다. 이러한 상황은 피해야 하며, 메서드가 지원되지 않는 경우 해당 메서드는 클래스에 존재해서는 안 된다.NoSuchElementException
: 요청된 요소가 존재하지 않음을 나타낸다. 예를 들어,Iterable
을 구현할 때 더 이상 요소가 없을 때 클라이언트가next
를 요청하는 경우 사용된다.
결과가 없을 가능성이 있을 때는
null
또는Failure
를 선호하라
때로는 함수가 원하는 결과를 생성할 수 없는 경우가 있다.
- 서버에서 데이터를 가져오려고 하지만, 인터넷 연결에 문제가 있는 경우
- 특정 기준에 맞는 첫 번째 요소를 가져오려고 하지만, 리스트에 그런 요소가 없는 경우
- 텍스트에서 객체를 파싱하려고 하지만, 해당 텍스트가 잘못된 형식인 경우
이러한 상황을 처리하는 주요 메커니즘은 두 가지이다.
- 실패를 나타내는 null 또는 sealed 클래스(Failure라고 자주 명명됨)를 반환하기
- 예외를 던지기
모든 예외는 잘못된, 특별한 상황을 나타내며 그렇게 취급되어야 한다.
예외는 오직 예외적인 상황에서만 사용해야 한다.
- 예외가 전파되는 방식은 대부분의 프로그래머에게 덜 읽기 쉽고, 코드에서 쉽게 놓칠 수 있다.
- 코틀린에서 모든 예외는 체크되지 않은 예외이다. 사용자들은 이를 처리하도록 강제되거나 권장되지 않으며, 종종 잘 문서화되지 않는다. API를 사용할 때도 정말로 눈에 띄지 않다.
- 예외는 예외적인 상황을 위해 설계되었기 때문에, JVM 구현자들이 이를 명시적인 테스트만큼 빠르게 만들 동기가 적다.
- try-catch 블록 내부에 코드를 배치하면 컴파일러가 수행할 수 있는 특정 최적화가 방해될 수 있다.
반면에, null이나 Failure는 예상된 오류를 나타내기에 완벽하다.
이들은 명시적이고 효율적이며, 코틀린의 관용적인 방식으로 처리할 수 있다. 그렇기 때문에 오류가 예상될 때는 null이나 Failure를 반환하고, 오류가 예상되지 않을 때는 예외를 던져야 한다는 규칙이 있다.
inline fun <reified T> String.readObjectOrNull(): T? {
//...
if(incorrectSign) {
return null
}
//...
return result
}
inline fun <reified T> String.readObject(): Result<T> {
//...
if(incorrectSign) {
return Failure(JsonParsingException())
}
//...
return Success(result)
}
sealed class Result<out T>
class Success<out T>(val result: T): Result<T>()
class Failure(val throwable: Throwable): Result<Nothing>()
class JsonParsingException: Exception()
val age = userText.readObjectOrNull<Person>()?.age ?: -1
위와 같은 방식으로 오류를 처리하면 쉬우며 놓치기 어렵다. 세이프 콜이나 엘비스 연산자도 사용가능하다.
// 오타 아닌가?
val age = when(val person = userText.readObjectOrNull<Person>()) {
is Success -> person.age
is Failure -> -1
}
When 표현식 사용 가능
이러한 오류 처리는 try-catch 블록보다 더 효율적일 뿐만 아니라, 종종 사용하기도 쉽고 더 명시적이다.
예외는 놓칠 수 있으며, 애플리케이션 전체를 멈출 수 있다.
반면에 null 값이나 sealed 결과 클래스는 명시적으로 처리해야 하지만, 애플리케이션의 흐름을 방해하지 않는다.
nullable 결과와 sealed 결과 클래스를 비교할 때, 실패 시 추가 정보를 전달해야 할 경우에는 후자를 선호하고, 그렇지 않으면 null을 사용해야 한다.
Failure
에는 필요한 모든 데이터를 담을 수 있다는 점을 기억하자
실패가 발생할 수 있음을 예상하는 함수와 실패를 예기치 않은 상황으로 처리하는 함수의 두 가지 변형을 가지는 것이 일반적이다. 좋은 예로는 List
가 다음 두 가지를 모두 제공하는 경우가 있다:
get
: 해당 위치에 요소가 있을 것으로 기대할 때 사용하며, 요소가 없을 경우IndexOutOfBoundsException
을 던진다.getOrNull
: 범위를 벗어난 요소를 요청할 수 있을 것으로 예상될 때 사용하며, 그럴 경우 null을 반환한다.
개발자가 안전하게 요소를 가져온다고 알고 있을 때는 nullable 값을 처리할 필요가 없어야 하며, 동시에 약간의 의심이 있다면 getOrNull
을 사용하고 값이 없는 경우를 적절히 처리해야 한다.
null을 적절하게 처리하라
null은 값이 없음을 의미한다. 프로퍼티의 경우, 이는 값이 설정되지 않았거나 제거되었음을 나타낼 수 있다. 함수가 null을 반환하는 경우, 그 의미는 함수에 따라 다를 수 있다
String.toIntOrNull()
은 문자열을Int
로 올바르게 파싱할 수 없을 때 null을 반환한다.Iterable<T>.firstOrNull(() -> Boolean)
은 인자로 제공된 조건에 맞는 요소가 없을 때 null을 반환한다.
이와 같은 모든 경우에 null의 의미는 가능한 한 명확해야 한다. 이는 nullable 값이 반드시 처리되어야 하며, 이를 어떻게 처리할지는 API 사용자(즉, API 요소를 사용하는 프로그래머)가 결정해야 하기 때문이다.
val printer: Printer? = getPrinter()
printer.print() // 컴파일 오류: printer는 nullable이므로 직접 사용할 수 없습니다.
printer?.print() // 안전 호출, printer가 null이 아닌 경우에만 사용됨
if (printer != null) printer.print() // 스마트 캐스팅
printer!!.print() // Not-null 단언, printer가 null일 때 NPE를 던짐
일반적으로 nullable 타입을 처리하는 세 가지 방법이 있다:
- 안전 호출
?.
, 스마트 캐스팅, 엘비스 연산자?:
등을 사용해 null 가능성을 안전하게 처리하기 - 오류를 던지기
- 이 함수나 프로퍼티를 리팩토링하여 nullable하지 않도록 만들기
세이프 콜과 스마트 캐스팅을 사용하는 것
printer?.print() // 안전 호출
if (printer != null) printer.print() // 스마트 캐스팅
위 두 경우 모두 print 함수는 printer가 null이 아닐 때만 호출된다. 따라서 가장 안전하다.
val printerName1 = printer?.name ?: "Unnamed"
val printerName2 = printer?.name ?: return
val printerName3 = printer?.name ?: throw Error("Printer must be named")
- return 과 throw 둘 다 사용 가능
스마트 캐스팅은 코틀린의 계약 기능에 의해 지원되며, 이 기능은 함수 내에서 스마트 캐스팅을 가능하게 한다:
println("What is your name?")
val name = readLine()
if (!name.isNullOrBlank()) {
println("Hello ${name.toUpperCase()}")
}
val news: List<News>? = getNews()
if (!news.isNullOrEmpty()) {
news.forEach { notifyUser(it) }
}
방어적 프로그래밍
- null일 때 사용하지 않는 것
- 코드가 프로덕션 환경에서 더 안정적이게끔 함
- 현재로서는 불가능한 상황에 대해 방어하는 관행들을 포함
- 가능한 모든 상황을 올바르게 처리할 방법이 있을 때 최고다.
공격적 프로그래밍
- 예상치 못한 상황이 발생하면 이를 크게 알리고,
- 이러한 상황을 초래한 개발자에게 이를 수정하도록 강제하는 것
require
,check
,assert
특정 상황에 대해 강한 기대가 있으며, 그 기대가 충족되지 않으면 예외를 던져 예상치 못한 상황에 대해 프로그래머에게 알리는 것이 좋다.
fun process(user: User) {
requireNotNull(user.name)
val context = checkNotNull(context)
val networkService = getNetworkService(context) ?: throw NoInternetConnection()
networkService.getData { data, userData ->
show(data!!, userData!!)
}
}
오류를 던지는 함수들을 사용해야 할 수도 있다.
- 자바에서 발생하는 일과 유사하다
- null이 아니라고 생각했지만 잘못된 경우 NPE가 발생
- 아무것도 설명하지 않는 일반적인 예외를 던진다.
fun largestOf(a: Int, b: Int, c: Int, d: Int): Int = listOf(a, b, c, d).max()!!
리스트가 비어있지 않을거라고 생각한 사람은 !!을 쓸 수 있음
하지만 리스트가 비어있다면? max가 null을 반환함
class UserControllerTest {
private var dao: UserDao? = null
private var controller: UserController? = null
@BeforeEach
fun init() {
dao = mockk()
controller = UserController(dao!!)
}
@Test
fun test() {
controller!!.doSomething()
}
}
이 경우, 매번 이러한 프로퍼티를 언팩킹해야 하는 것은 번거로울 뿐만 아니라, 향후 해당 프로퍼티에 의미 있는 null을 실제로 할당할 수 있는 가능성을 차단하게 된다. 이 아이템의 뒷부분에서 볼 수 있듯이, 이러한 상황을 처리하는 올바른 방법은 lateinit
이나 Delegates.notNull
을 사용하는 것이다.
코드가 어떻게 발전할지는 아무도 예측할 수 없다. 만약 not-null !!
이나 명시적인 오류 던지기를 사용한다면, 언젠가 오류가 발생할 것을 가정해야 한다. 예외는 예상치 못한 잘못된 상황을 나타내기 위해 던져진다( Item 7: 결과가 없을 가능성이 있을 때는 null 또는 Failure를 선호하라 참조). 하지만 명시적인 오류는 일반적인 NPE보다 훨씬 많은 정보를 제공하며, 거의 항상 선호되어야 한다.
not-null !!
이 합리적인 드문 경우는 null 가능성이 제대로 표현되지 않은 라이브러리와의 상호 운용성 문제로 발생한다. 코틀린에 맞게 제대로 설계된 API와 상호 작용할 때, 이 방법은 표준이 되어서는 안 된다.
일반적으로 not-null !!
사용을 피해야 합니다. 이 제안은 우리 커뮤니티에서도 상당히 널리 받아들여지고 있다. 많은 팀이 이를 차단하는 정책을 가지고 있으며, 일부는 Detekt
정적 분석기를 설정해 사용될 때마다 오류를 발생시키기도 한다. 이러한 접근이 너무 극단적이라고 생각할 수 있지만, 일반적으로 이는 코드 냄새로 간주된다. 이 연산자가 생긴 모양 자체도 우연이 아닌 것 같습니다. !!
은 "조심하라" 또는 "여기 뭔가 문제가 있다"라고 외치는 것처럼 보인다.
null 가능성은 비용이 따르므로 적절하게 처리해야 하며, 필요하지 않은 경우 null 가능성을 피하는 것이 좋다. null은 중요한 메시지를 전달할 수 있지만, 다른 개발자에게 의미 없는 상황에서는 null 사용을 피해야 한다. 그렇지 않으면 개발자는 안전하지 않은 not-null !!
을 사용하거나 코드만 어수선해지는 일반적인 안전 처리를 반복하게 될 수 있다. 클라이언트에게 의미가 없는 null 가능성을 피하는 것이 좋다. 이를 위한 가장 중요한 방법은 다음과 같다:
- 클래스는 결과가 예상되는 함수와 값이 없음을 고려하여 nullable 결과나
sealed
결과 클래스를 반환하는 함수를 제공할 수 있다. 간단한 예는List<T>
의get
과getOrNull
이다. 이러한 내용은 Item 7: 결과가 없을 가능성이 있을 때는 null 또는 Failure를 선호하라에서 자세히 설명되어 있다. - 값이 확실히 사용 전에 설정되지만 클래스 생성 시점 이후에 설정되어야 할 경우,
lateinit
프로퍼티나notNull
대리자를 사용하자. - 빈 컬렉션 대신 null을 반환하지 말자. 예를 들어,
List<Int>?
또는Set<String>?
같은 컬렉션을 다룰 때, null은 빈 컬렉션과는 다른 의미를 가진다. 이는 컬렉션이 존재하지 않음을 의미합니다. 요소가 없음을 나타내려면 빈 컬렉션을 사용하자 - Nullable 열거형과
None
열거형 값은 두 가지 다른 메시지이다. null은 별도로 처리해야 하는 특별한 메시지이지만, 열거형 정의에 포함되지 않으므로 필요한 경우 열거형에 추가할 수 있다.
class UserControllerTest {
private lateinit var dao: UserDao
private lateinit var controller: UserController
@BeforeEach
fun init() {
dao = mockk()
controller = UserController(dao)
}
@Test
fun test() {
controller.doSomething()
}
}
lateinit
의 비용은 초기화되지 않은 상태에서 값을 얻으려고 하면 예외가 발생한다는 점다. 이는 무섭게 들리지만 실제로는 바람직한 것다. 첫 번째 사용 전에 프로퍼티가 초기화될 것이라는 확신이 있을 때만 lateinit
을 사용해야 한다.
lateinit
과 nullable 사이의 주요 차이점은 다음과 같다:
- nullable로 매번 프로퍼티를 "언팩"할 필요가 없다.
- null을 사용하여 의미 있는 것을 나타내야 할 경우, 이를 쉽게 nullable로 만들 수 있다.
- 일단 프로퍼티가 초기화되면 다시 초기화되지 않은 상태로 돌아갈 수 없다.
lateinit
은 프로퍼티가 첫 번째 사용 전에 초기화될 것이라고 확신할 때 좋은 관행입니다. 우리는 주로 클래스에 라이프사이클이 있고, 속성을 나중에 사용할 첫 번째 메서드 중 하나에서 설정할 때 이러한 상황을 다룹니다. 예를 들어, Android Activity
의 onCreate
, iOS UIViewController
의 viewDidAppear
, 또는 React Component
의 componentDidMount
에서 객체를 설정할 때가 있다.
lateinit
을 사용할 수 없는 경우는, JVM에서 Int
, Long
, Double
, Boolean
과 같은 프리미티브와 연결된 타입으로 프로퍼티를 초기화해야 할 때이다. 이러한 경우, 조금 더 느리지만 해당 타입을 지원하는 Delegates.notNull
을 사용해야 한다
class DoctorActivity: Activity() {
private var doctorId: Int by Delegates.notNull()
private var fromNotification: Boolean by Delegates.notNull()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
doctorId = intent.extras.getInt(DOCTOR_ID_ARG)
fromNotification = intent.extras.getBoolean(FROM_NOTIFICATION_ARG)
}
}
리소스를
use
로 닫아라
자동으로 닫을 수 없는 리소스가 있으며, 더 이상 필요하지 않을 때 close
메서드를 호출해야 힌다. 코틀린/JVM에서 사용하는 자바 표준 라이브러리에는 이러한 리소스가 많이 포함되어 있다.
예를 들어:
InputStream
과OutputStream
java.sql.Connection
java.io.Reader
(예:FileReader
,BufferedReader
,CSSParser
)java.net.Socket
과java.util.Scanner
이 모든 리소스는 Closeable
인터페이스를 구현하며, 이는 AutoCloseable
을 확장한다.
문제는 이러한 모든 경우에 리소스를 더 이상 필요로 하지 않을 때
close
메서드를 호출해야 한다는 점이다.
이러한 리소스는 상당히 비용이 많이 들며, 스스로 쉽게 닫히지 않기 때문이다(가비지 컬렉터가 이 리소스에 대한 참조를 더 이상 유지하지 않으면 결국 이를 처리하지만, 시간이 걸릴 수 있다).
따라서 리소스를 닫지 못하는 상황을 피하기 위해, 전통적으로 이러한 리소스를 try-finally
블록에 감싸고 close
를 호출했다:
fun countCharactersInFile(path: String): Int {
val reader = BufferedReader(FileReader(path))
try {
return reader.lineSequence().sumBy { it.length }
} finally {
reader.close()
}
}
이러한 구조는 복잡하고 잘못된 방식입니다. 잘못된 이유는 close
가 오류를 던질 수 있으며, 이러한 오류가 잡히지 않기 때문이다. 또한, try
블록의 본문과 finally
블록에서 모두 오류가 발생할 경우, 하나만 제대로 전파된다. 우리가 기대해야 할 동작은 새로운 오류에 대한 정보가 이전 오류에 추가되는 것이다.
fun countCharactersInFile(path: String): Int {
val reader = BufferedReader(FileReader(path))
reader.use {
return reader.lineSequence().sumBy { it.length }
}
}
- 가독성을 향상시키며 리소스를 올바르게 닫고 예외를 처리할 수 있다.
fun countCharactersInFile(path: String): Int {
File(path).useLines { lines ->
return lines.sumBy { it.length }
}
}
- useLines로 파일을 줄 단위로 읽기 가능
단위 테스트를 작성하라
@Test
fun `fib works correctly for the first 5 positions`() {
assertEquals(1, fib(0))
assertEquals(1, fib(1))
assertEquals(2, fib(2))
assertEquals(3, fib(3))
assertEquals(5, fib(4))
}
위 테스트에서는 일반적으로 다음을 확인한다:
- 일반적인 사용 사례 (행복 경로): 요소가 사용될 것으로 예상되는 일반적인 방법. 위 예제처럼, 작은 숫자 몇 개에 대해 함수가 제대로 작동하는지 테스트한다.
- 일반적인 오류 사례 또는 잠재적 문제: 제대로 작동하지 않을 가능성이 있거나, 과거에 문제가 되었던 사례.
- 경계 사례와 잘못된 인자:
Int
의 경우Int.MAX_VALUE
와 같은 매우 큰 숫자를 테스트할 수 있다. nullable 객체의 경우, null이거나 null 값으로 채워진 객체일 수 있다. 음수 위치에는 피보나치 수가 없기 때문에 이 함수가 이러한 상황에서 어떻게 동작하는지 확인할 수 있다.
테스트는 계속해서 축적되므로, 회귀를 쉽게 확인할 수 있다.
- 잘 테스트된 요소는 더 신뢰할 수 있으며, 심리적인 안정감을 제공한다. 요소가 잘 테스트된 경우, 더 자신 있게 작업할 수 있다.
- 요소가 제대로 테스트되면 리팩토링에 대한 두려움이 줄어든다. 결과적으로 잘 테스트된 프로그램은 점점 더 나아지는 경향이 있다. 반면, 테스트되지 않은 프로그램에서는 개발자가 레거시 코드를 건드리는 것을 두려워할 수 있다. 이는 무의식적으로 오류를 도입할 수 있기 때문이다.
- 단위 테스트를 사용하는 것이 수동으로 테스트하는 것보다 올바르게 작동하는지 확인하는 데 훨씬 더 빠른 경우가 많다. 버그를 빨리 찾을수록, 수정 비용이 저렴해집니다.
- 단위 테스트를 작성하는 데 시간이 걸리지만 장기적으로는 좋은 단위 테스트가 시간을 절약해준다. 단위 테스트를 실행하는 것이 수동 테스트나 다른 종류의 자동화 테스트보다 훨씬 빠ㄹ르다.
- 코드를 테스트 가능하게 만들기 위해 코드를 조정해야 한다. 이러한 변경은 어렵지만, 일반적으로 개발자가 좋은 아키텍처를 사용하도록 강제한다.
- 좋은 단위 테스트를 작성하는 것은 어렵다. 이는 개발의 다른 부분과는 다른 기술과 이해를 필요로 한다. 잘못 작성된 단위 테스트는 오히려 해로울 수 있다.