이펙티브 코틀린(Effective Kotlin Best Practices) 책을 통해 스터디 진행하면서 정리한 글입니다.
Kotlin을 사용하여 개발을 하다가 특정 문제가 있을 경우에 오류를 던지도록 할 수 있습니다. 사용자가 직접 정의를 하여 예외 명으로 쉽게 알려줄 수 있긴 하지만 그보다도 이미 정의되어 있는 표준 오류를 사용하는 측면이 더 좋습니다. 이미 많은 개발자들이 알고 있고 굳이 새로 정의하는 편보다는 재사용을 하는 편이 좋습니다. 아래 예외는 대표적인 예외만 보여드렸고 기억하고 있으면 좋지만 저희에게는 구글이 있기도 합니다. 😇
- IllegalArgumentException
- IllegalStateException
- IndexOutOfBoundsException
- ConcurrentModificationException
- UnsupportedOperationException
- NoSuchElementException
함수 호출 시에 항상 기대하는 결과만 만들어내지는 않습니다. 파일 읽기/쓰기 실패, 네트워크 통신, 파싱 에러 등 예기치 않은 상황에서 처리하는 메커니즘을 소개합니다.
이 상황을 처리하는 대표적인 방법은 크게 2가지가 있습니다.
null
또는 Failure(sealed class)
를 리턴한다.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()
위 코드와 같이 null
과 Failure
로 예상되는 오류를 표현할 때는 명시적으로 처리할 수 있고 놓치기 어렵습니다. null
로 반환할 경우에는 Elvis 연산자를 활용하여 null-safety 기능도 활용할 수 있다는 장점도 있습니다.
null
: 추가적인 정보를 전달할 일이 없을 경우는 단순하게 null을 반환Failure
: Throwable 과 같은 추가적인 정보 전달이 필요할 경우에 사용
String.toIntOrNull()
,Iterable<T>.firstOrNull(() -> Boolean)
메소드와 같이 null을 반환하더라도 명확한 의미를 갖으면 이를 처리하는 개발자는 편리하게 처리할 수 있습니다. Kotlin에서 nullable 타입을 처리하는 방법은 크게 3가지 방법이 있습니다.
- ?., 스마트 캐스팅, Elvis 연산자 등을 활용
- Exception throw
- 함수, 프로퍼티 리팩토링하여 nullable 타입이 나오지 않게 처리
printer?.print() // 안전호출
if (printer != null) printer.print() // 스마트 캐스팅
printer가 null이 아닐 경우에만 print 메소드를 호출하는 코드로 애플리케이션 사용자 관점에서 안전하고 개발자에게도 편리하여 자주 활용됩니다.
val printerName1 = printer?.name ?: "Unnamed"
val printerName2 = printer?.name ?: return
val printerName3 = printer?.name ?:
throw Error("Printer must be named")
안전 호출과 비슷하게 자주 사용되는 방법은 Elvis 연산자를 활용하여 return, throw 로 처리하는 것입니다.
많은 클래스에서 nullable 관련 처리를 제공합니다. 예를 들어 컬렉션 처리에 Element가 없다는 것을 null보다는 비어 있는 컬렉션을 제공해주는 메소드Collection<T>?.orEmpty()
가 존재합니다.
println("What is your name?")
val name = readLine()
if(!name.isNullOrBlank(){
println("Hello ${name.toUpperCase()}")
val new: List<News>? = getNews()
if(!news.isNullOrEmpty()){
news.forEach { notifyUser(it) }
}
위 코드에서 처리 한번 null check한 이후에 스마트 캐스팅으로 인해 null을 안전하게 처리할 수 있습니다.
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로 인해 실행이 안되는 경우에 대해 고려하지 못했을 경우에는 예상되는 동작이 수행이 되지 않아 이상하게 생각이 될 수 있습니다. 원인도 찾기 어려울 수 있는데 이 경우에는 개발자에게 오류를 강제로 발생시켜 주는 편이 좋습니다. throw
, !!
, requireNotNull
, checkNotNull
을 활용하시면 됩니다.
null 이 절대 아닐 것이라고 확증할 때 사용되는
!!
은 미래에 코드가 어떻게 변할지 모르기 때문에 절대 권장되지 않습니다. 외부 라이브러리를 사용할 때 정도에만 고려해볼 수는 있습니다.
nullability는 null 처리에 대한 오버헤드가 필요하기에 최대한 nullability를 피하는게 좋을 수 있습니다. 이를 위한 대표적인 방법 몇 가지만 소개하도록 하겠습니다.
- 여러 클래스의 nullability 처리를 지원해주는 메소드 적극 활용
ex)List<T>.get()
,List<T>,getOrNull()
- lateinit, notNull Delegate 사용하여 지연 초기화
- 빈 컬렉션 대신 null 리턴 금지
- enum 사용 시 null보다는 내부에 None 정의하여 활용
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()
}
}
위 코드처럼 프로퍼티를 사용할 때마다 !!
을 사용하는 것은 바람직하지 않습니다. JUnit에서 @BeforeEach 처럼 다른 함수들보다 먼저 호출되는 함수에서 프로퍼티를 초기화할 경우에는 null로 초기화하기 보다는 lateinit 을 사용하는 것이 더 좋습니다.
class UserControllerTest{
private lateinit var dao: UserDao
private lateinitvar controller: UserController
@BeforeEach
fun init(){
dao = mockk()
controller = UserController(dao)
}
@Test
fun test(){
controller.doSomething()
}
}
lateinit은 프로퍼티를 처음 사용하기 전에 무조건 초기화 될 것이라고 예상되는 상황에서 주로 사용됩니다. Android, iOS에서 라이프 사이클(lifecycle) 갖는 클래스처럼 메소드 호출에 명확한 순서가 있는 경우가 대표적입니다.
class DoctorActivity:Activity() {
private var doctorId: Int by Delegates.notNull()
private var formNotification: Boolean by Delegates.notNull()
overrid fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
doctorId = intent.extras.getInt(DOCTOR_ID_ARG)
fromNotification = intent.extras.getBoolean(FROM_NOTIFICATION_ARG)
}
}
lateinit은 primitive type을 사용할 수 없습니다. 이 경우에는 lateinit보다 조금은오버헤드가 있지만 Delegates.notNull을 사용하시면 됩니다. lateinit이 Delegates.notNull보다 성능상 이점이 있는 이유는 아래 글로 대체합니다.
AutoClosable을 상속받는 Closable 인터페이스를 구현하는 리소스들은 더 이상 필요로 하지 않을 때 명시적으로 close를 해야 합니다.
- InputStream, OutputStream
- java.sql.Connection
- java.io.Reader(FileReader, BufferedReader, CSSParser)
- java.new.Socket, java.util.Scanner
이런 리소스들 Root Space에서 리소스에 대한 레퍼런스가 없어질 때 GC가 동작을 하는데 굉장히 느리며 리소스를 유지하는 비용이 많이 들어갑니다. 따라서 명시적으로 close 메소드를 호출하는 것이 좋습니다.
fun countCharactersInFile(path: String): Int{
val reader = BufferedReader(FileReader(path))
try{
return reader.lineSequence().sumBy { it.length }
}finally{
reader.close()
}
}
일반적으로 리소스를 사용하고 닫을 때는 try-catch 블록을 사용하게 됩니다. 하지만 위 코드는 복잡하고 리소스를 닫을 때 IOException이 발생할 수 있는데 해당 예외를 별도로 처리하지 않습니다.
fun countCharactersInFile(path: String): Int{
BufferedReader(FileReader(path)).use { reader ->
return reader.lineSequence().sumBy { it.length }
}
}
다행히 코틀린에서는 간결하면서도 try-catch에서 처리하지 못한 예외까지 처리해줄 수 있는 use
메소드를 제공하고 있습니다. use
메소드는 Closable을 구현하는 모든 객체에서 사용할 수 있습니다.
안전한 코드를 만드는 가장 좋은 방법은 테스트 코드를 작성하는 것입니다. 테스트를 통해서 기능 동작의 신뢰성을 확보하고 개발 시점에 빠른 피드백을 받을 수 있습니다. 단위 테스트에서는 일반적으로 아래 내용들을 주로 확인합니다.
- Normal UseCase
- Normal Exception Case : 제대로 동작하지 않을 것으로 예상되는 부분 테스트
- Edge Case
Effective Kotlin의 1장에서는 코드를 안전하게 작성하는 방법에 대해 배웠습니다. 평소에도 개발관점에서 오류가 생기지 않고 안전한 코드 작성하는 방법에 대해 궁금한 점들이 있었는데 대부분은 해결된 것 같습니다.
또한, 권장되는 방식처럼 썼던 방법도 있고 전혀 생각지도 못한 부분이 있는데 모두 적용해보는 습관을 갖도록 해야겠습니다. 😂 (특히, 테스트 코드 ...)