[Kotlin] Effective Kotlin [1장 안정성 (2)]

WonseokOh·2022년 11월 19일
0

Kotlin

목록 보기
2/4
post-thumbnail

이펙티브 코틀린(Effective Kotlin Best Practices) 책을 통해 스터디 진행하면서 정리한 글입니다.


6. 사용자 정의 오류보다는 표준 오류를 사용하라

Kotlin을 사용하여 개발을 하다가 특정 문제가 있을 경우에 오류를 던지도록 할 수 있습니다. 사용자가 직접 정의를 하여 예외 명으로 쉽게 알려줄 수 있긴 하지만 그보다도 이미 정의되어 있는 표준 오류를 사용하는 측면이 더 좋습니다. 이미 많은 개발자들이 알고 있고 굳이 새로 정의하는 편보다는 재사용을 하는 편이 좋습니다. 아래 예외는 대표적인 예외만 보여드렸고 기억하고 있으면 좋지만 저희에게는 구글이 있기도 합니다. 😇

  • IllegalArgumentException
  • IllegalStateException
  • IndexOutOfBoundsException
  • ConcurrentModificationException
  • UnsupportedOperationException
  • NoSuchElementException

7. 결과 부족이 발생할 경우 null과 Failure를 사용하라.

  함수 호출 시에 항상 기대하는 결과만 만들어내지는 않습니다. 파일 읽기/쓰기 실패, 네트워크 통신, 파싱 에러 등 예기치 않은 상황에서 처리하는 메커니즘을 소개합니다.

이 상황을 처리하는 대표적인 방법은 크게 2가지가 있습니다.

  • 충분히 예측할 수 있는 범위의 오류는 null 또는 Failure(sealed class) 를 리턴한다.
  • 예측하기 어려운 예외적인 범위의 오류는 예외를 throw 해서 처리하는 것이 좋다
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()

위 코드와 같이 nullFailure로 예상되는 오류를 표현할 때는 명시적으로 처리할 수 있고 놓치기 어렵습니다. null로 반환할 경우에는 Elvis 연산자를 활용하여 null-safety 기능도 활용할 수 있다는 장점도 있습니다.

  • null : 추가적인 정보를 전달할 일이 없을 경우는 단순하게 null을 반환
  • Failure : Throwable 과 같은 추가적인 정보 전달이 필요할 경우에 사용

8. 적절하게 null을 처리하라

  String.toIntOrNull(), Iterable<T>.firstOrNull(() -> Boolean) 메소드와 같이 null을 반환하더라도 명확한 의미를 갖으면 이를 처리하는 개발자는 편리하게 처리할 수 있습니다. Kotlin에서 nullable 타입을 처리하는 방법은 크게 3가지 방법이 있습니다.

  • ?., 스마트 캐스팅, Elvis 연산자 등을 활용
  • Exception throw
  • 함수, 프로퍼티 리팩토링하여 nullable 타입이 나오지 않게 처리

null 안전하게 처리

안전 호출, Elvis 연산자 활용

  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을 안전하게 처리할 수 있습니다.


오류 throw

  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을 활용하시면 됩니다.


not-null assertion(!!) 주의사항

null 이 절대 아닐 것이라고 확증할 때 사용되는 !!은 미래에 코드가 어떻게 변할지 모르기 때문에 절대 권장되지 않습니다. 외부 라이브러리를 사용할 때 정도에만 고려해볼 수는 있습니다.


의미 없는 nullability 피하기

nullability는 null 처리에 대한 오버헤드가 필요하기에 최대한 nullability를 피하는게 좋을 수 있습니다. 이를 위한 대표적인 방법 몇 가지만 소개하도록 하겠습니다.

  • 여러 클래스의 nullability 처리를 지원해주는 메소드 적극 활용
    ex) List<T>.get(), List<T>,getOrNull()
  • lateinit, notNull Delegate 사용하여 지연 초기화
  • 빈 컬렉션 대신 null 리턴 금지
  • enum 사용 시 null보다는 내부에 None 정의하여 활용

lateinit 프로퍼티와 notNull 델리게이트

  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보다 성능상 이점이 있는 이유는 아래 글로 대체합니다.


9. use를 사용하여 리소스를 닫아라

  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을 구현하는 모든 객체에서 사용할 수 있습니다.


10. 단위 테스트를 만들어라

안전한 코드를 만드는 가장 좋은 방법은 테스트 코드를 작성하는 것입니다. 테스트를 통해서 기능 동작의 신뢰성을 확보하고 개발 시점에 빠른 피드백을 받을 수 있습니다. 단위 테스트에서는 일반적으로 아래 내용들을 주로 확인합니다.

  • Normal UseCase
  • Normal Exception Case : 제대로 동작하지 않을 것으로 예상되는 부분 테스트
  • Edge Case

단위 테스트 장점

  • 신뢰성 있는 개발
  • 유지보수성이 높음, 리팩토링의 편리함
  • 빠른 테스트를 통해 조기에 오류 예방

단위 테스트 단점

  • 단위 테스트 작성에 많은 시간 소요 => 실제 코드량보다 더 비대해질수도 있음.
  • 테스트를 작성을 위한 코드 변경 필요
  • 효과적인 단위 테스트 하는 방법에 대한 학습이 필요

정리

Effective Kotlin의 1장에서는 코드를 안전하게 작성하는 방법에 대해 배웠습니다. 평소에도 개발관점에서 오류가 생기지 않고 안전한 코드 작성하는 방법에 대해 궁금한 점들이 있었는데 대부분은 해결된 것 같습니다.
또한, 권장되는 방식처럼 썼던 방법도 있고 전혀 생각지도 못한 부분이 있는데 모두 적용해보는 습관을 갖도록 해야겠습니다. 😂 (특히, 테스트 코드 ...)

profile
"Effort never betrays"

0개의 댓글