Effective Kotlin - Part1: Good code (1)

7hong13·2023년 6월 6일
0

Effective Kotlin

목록 보기
1/2
post-thumbnail
* Contents

Part1: Good code
	Chapter1: Safety ✔
    Chapter2: Readability
    
Part2: Code design
	Chapter3: Reusability
    Chapter4: Abstraction desgin
    Chapter5: Object creation
    Chapter6: Class design
    
Part3: Efficiency
	Chapter7: Make it cheap
    Chapter8: Efficient collection processing

해당 글은 Effective Kotlin(Marcin Moskala)의 Chapter1: Safety의 내용을 다룬다.

Chapter1: Safety

코틀린은 안정성에 강점을 지니는 언어이다. 이러한 강점을 더 최대화할 수 있는 코틀린 활용법을 알아보자.

Item 1: Limit mutability

코틀린에서는 아래와 같이 상태를 담을 수 있다.

var a = 10
val list: MutableList<Int> = mutableListOf()

상태를 갖는 다는 건 유용하지만 아래와 같은 단점도 지닌다.

  • 변경점이 많아져 디버깅이 어렵다.
  • 값이 변경됨에 따라 코드의 논리를 추론하기 어려워진다.
  • 멀티스레드 환경에서 동기화 이슈가 있다.
  • 테스트가 어렵다.
  • 값의 변경에 타 클래스들이 영향을 받는다.

따라서 코틀린은 변경성을 제한할 수 있도록 설계되었으며, 그 방법은 아래와 같다.
  • Read-only 프로퍼티인 val
  • mutable과 read-only collections의 분리
  • data class의 copy 메서드

각각에 대해 구체적으로 살펴보자.

Read-only 프로퍼티인 val

val이 read-only라 하여 immutable하거나 final인 것은 아니다.
val은 mutable object를 담을 수도 있고, custom getter를 설정할 수도 있다.

val list = mutableListOf(1, 2, 3)
list.add(4) // mutable object를 담고 있다.

val name = "Marcin"
val surname = "Moskala"
val fullName
	get() = "$name $surname"

따라서 val은 flexible 하다는 장점을 지닌다.

custom getter 설정시 value에 접근할 때마다 값이 새로 생성된다.
즉, 접근할 때마다 값이 달라지므로 smart-cast가 지원되지 않는 점을 유의하자.

val name: String? = "Marcin"
val surname: String? = "Moskala"
val fullName
	get() = name?.let { "$it $surname" }
val fullName2 = name?.let { "$it $surname" }

fun main() {
	if (fullName != null) {
    	println(fullName.length) // Error: 값이 새로 계산되면서 null로 바뀔 수 있다.
    }
    if (fullName2 != null) {
    	println(fullName2.length) // OK.
    }
}
 

mutable과 read-only collections의 분리

mutable collections는 MutableCollection, MutableIterable과 같은 것들이며,
read-only collections는 Collection, Iterable과 같은 것들이다.

각 mutable interface는 이에 상응하는 read-only interface를 상속받으며, 변경을 위한 메서드만 추가한 구조이다.

하지만 read-only interface를 mutable interface로 down-casting 해선 안된다.
read-only를 mutable하게 바꾸고 싶다면 List.toMutableList 함수를 사용해 변경가능한 리스트 복사본을 만들어야 한다.

val list = listOf(1, 2, 3)

// 절대 안된다!
if (list is MutableList) {
	list.add(4)
}

data class의 copy 메서드

immuable object를 사용하면 여러 장점이 있다.

  • 이해하기 쉽다.
  • 프로그램을 병행적으로 실행해도 충돌이 없다.
  • 변하지 않으므로 캐시될 수 있으며, deep copy를 하지 않아도 된다.
  • map의 key로 활용 가능하다.

하지만 immutable object의 최대 단점은 data의 변경이 필요할 때도 분명 있다는 것이다.
따라서 변경을 적용한 새로운 object를 생성하는 방식을 사용한다.

  • Int: plus, minus 메서드는 값이 변경된 새 Int를 반환한다.
  • Iterable: map, filter 메서드는 조건에 맞춰 처리된 새 collection을 반환한다.

이와 마찬가지로 data class의 copy를 활용할 수 있다.

data class User(
	val name: String,
    val surname: String,
)

var user = User("Hello", "World")
user = user.copy(surname = "Kotlin")
print(user) // User(name=Hello, name=Kotlin)

mutable object를 사용하는 것보다 효율성은 떨어지지만, 그 이상의 장점을 갖는 방식이다.

변경가능한 list를 만들 때 둘중 어떤 방법이 더 좋을까?
(1) val list1: MutableList = mutableListOf()
(2) var list2: List = listOf()

(1)의 경우 완전히는 아니지만, 멀티스레딩에 어느정도 대응한다. 또한 (2)에 비해 살짝 더 빠르다.
(2)의 경우 멀티스레딩 환경에서의 동기화에 직접 대응해야 한다. 하지만 custom setter 및 delegate를 통해 object가 수정되는 과정을 추적할 수 있다는 점에서 장점이 있다.
두 방식 모두 맞는 방법이며, var list = mutableListOf<Int>()와 같은 방식만 지양하도록 하자.

Item2: Minimize the scope of variables

상태를 정의할 때, 최대한 좁은 범위로 변수 및 프로퍼티를 활용해야 한다.

  • 프로퍼티 대신 로컬 변수를 사용한다.
  • 변수를 최대한 좁은 범위로 활용한다. (예: loop에서만 사용되는 변수는 loop 안에 선언한다.)
  • 가능하다면 로컬변수는 var 대신 val로 선언한다.

범위를 좁힐 수록 프로그램을 관리하고 추적하기 쉬워지며, 다른 개발자에 의한 잘못된 사용을 방지할 수 있다.

또한 변수를 선언할 때 초기화도 동시에 진행하자.

// Bad
fun updateWeather(degrees: Int) {
	val color: Int
  	val description: String
  	if (degree < 5) {
    	description = "cold"
        color = Color.Blue
    }
    ...
}
                   
// Better
fun updateWeather(degrees: Int) {
	val (description, color) = when {
    	degrees < 5 -> "cold" to Color.Blue
  		...
    }                   
}

Item3: Eliminate platform types as soon as possible

플랫폼 타입은 가능한 빨리 제거하는 것이 좋다.

플랫폼 타입이란?
: 다른 언어로부터 반환되어 nullability가 명확하지 않은 타입이다.
플랫폼 타입은 프로그래머가 코드로 직접 작성할 수 없다. (non-denotable)

예를 들어 자바 API를 호출할 때, @Nullable 혹은 @NotNull annotation이 명시적으로 없으면 반환값의 nullability를 규정하기 어렵다.
(코틀린 단에서 안전하게 모든 반환값을 nullable하다고 해석할 수도 있지만, 이 경우 사실상 불필요한 null checking 로직이 매번 수반되어 코드가 복잡해진다.)

플랫폼 타입을 처리하지 않으면 나중에 NPE 발생시 에러 발생 지점을 캐치하기 어려우며, 최악의 경우 플랫폼 타입이 전파될 수도 있다.
따라서 가능한 빨리 없애는 것이 좋은 방법이다.

아래 예시는 플랫폼 타입을 빨리 제거했을 때 에러 캐치가 더 쉽다는 장점을 보여준다.

// Java
public class JavaClass {
	public String getValue() {
  		return null;
  	}  
}
  
// Kotlin
fun statedType() {
	val value: String = JavaClass().value // 해당 라인에서 NPE가 발생하므로 비교적 쉽게 에러 캐치가 가능하다.
  	println(value.length)
}
  
fun platformType() {
	val value = JavaClass().value
  	println(value.length) // 함수가 호출된 라인에서 NPE가 발생하므로 에러 원인 파악이 비교적 어렵다.
}

Item4: Do not expose inferred types

코틀린의 타입 추론은 매우 유용하지만, 추론된 타입에만 의존하는 것은 위험하다.
타입 추론은 해당 타입의 super class나 interface까지는 대변하지 못한다.

open class Animal
class Zebra: Animal()

fun main() {
	var animal = Zebra()
  	animal = Animal() // Error: Type mismatch (super class임에도 타입 에러가 난다.)
  	
  	var animal2: Animal = Zebra()
  	animal2 = Animal() // OK.
}

특히 외부에 노출되는 API의 경우 타입을 명시적으로 지정하는 것이 좋다.
추론된 타입은 개발 과정에서 쉽게 바뀔 수 있기 때문이다.

interface CarFactory {
	fun produce(): Car
}
  
val DEFAULT_CAR: Car = Fiat126P()

위와 같은 코드가 있다고 하자.
대부분의 차 공장에서 DEFAULT_CAR를 생산하므로, produce()의 디폴트 반환값으로 지정하고자 한다.

interface CarFactory {
	fun produce() = DEFAULT_CAR // 이 때까지 반환 타입은 Car이다.
}

다른 개발자가 컴파일러의 타입 추론에 기대 DEFAULT_CAR의 명시적 타입 지정을 지웠다고 가정해보자. (val DEFAULT_CAR = Fiat126P())
이 때 produce()의 반환 타입이 Fiat126P로 바뀌어 API가 의도와 다르게 훼손된다.

타입 추론은 때때로 제한적이고 쉽게 바뀐다.
따라서 타입이 불확실할 때는 타입을 명시적으로 지정해주는 게 좋으며, 특히 외부에 노출되는 API는 타입을 항상 명시해주어야 한다.

Item5: Specify your expectations on arguments and state

인자와 상태에 대해 처리해야 할 조건이 있다면, 최대한 빨리 선언하는 것이 좋다.
코틀린의 여러 메서드를 사용해 이를 처리할 수 있다.

  • require block: 인자에 대한 조건 처리를 할 때 사용한다.
  • check block: 상태에 대한 조건 처리를 할 때 사용한다.
  • assert block: 특정 조건이 true인지 확인할 때 사용한다.

Arguments

fun factorial(n: Int) {
	require(n >= 0)
  	return if (n <= 1) 1 else factorial(n - 1) * n
}

fun sendEmail(user: User, message: String) {
	requireNotNull(user.email)
    require(isValidEmail(user.email)) { // smart casting 되어 user.email을 non-null value로 인식한다.
    	"user email is invalid"
    }
}

위와 같이 require 함수를 활용해 인자값에 대한 제약 조건을 걸 수 있다.
만약 제약 조건에 맞지 않다면 함수에서 IllegalArgumentException을 발생시킨다.
람다로 exception 메세지 또한 지정해줄 수 있다.
requireNotNull과 같은 특수 함수를 사용해 smart casting의 도움을 받을 수도 있다.

State

fun speak(text: String) {
	check(isInitialized)
    // ...
}

특정 상태(예: 오브젝트가 초기화된 상태 등)에서만 함수 사용을 해야할 때 check 함수를 활용하면 좋다.
제약 조건에 맞지 않을 경우 IllegalStateException을 발생시킨다.
마찬가지로 exception message를 지정해줄 수 있으며, checkNotNull과 같은 null checking 함수도 활용 가능하다.

Assertions

더 효과적인 유닛 테스트를 위해 assert 함수를 활용할 수 있다.

fun pop(num: Int = 1): List<T> {
	// ...
    assert(ret.size == num)
    return ret
}

// Unit test
class StackTest {
	
    @Test
    fun `Stack pops correct number of elements`() {
    	val stack = Stack(20) { it }
        val ret = stack.pop(10)
        assertEquals(10, ret.size)
    }
}

예제코드를 보았을 때, pop() 내부적으로 정상적으로 동작이 되었는지 assert 함수로 확인을 거친다.
따라서 더 많은 케이스에 대응해 테스트의 정확도를 높일 수 있다.
assert 함수는 production 코드에서는 어떠한 에러도 발생시키지 않으며, 테스트를 실행할 때만 동작한다.

이와 같이 require, check, assert를 활용해 제약 조건을 더 가시적으로 표현할 수 있다.

Item6: Prefer standard errors to custom ones

가능하다면 custom exception을 직접 정의해 사용하기 보단, 표준 라이브러리에서 제공하는 기본 exception들을 사용하는 게 좋다.
이 방식이 개발자들이 공통적으로 이해하기도, 재사용하기도 쉽다.

가장 많이 사용되는 exception들은 다음과 같다.

  • IllegalArugmentException, IllegalStateException
  • IndexOutOfBoundsException: collections, arrays 등에 접근할 때 인덱스가 범위에서 벗어나면 발생
  • ConcurrentModificationException
  • UnsupportedOperationException: Interface Segregation Princicple을 위반했을 때 발생
  • NoSuchElementException

Item7: Prefer null or Failure result when the lack of result is possible

함수에서 예상치 못한 결과가 발생할 때가 종종 있다. (예: 네트워크 문제로 서버에서 데이터 로드 실패 등)
이러한 상황을 다루는 방법은 크게 두가지가 있다.

  • null을 반환하거나 failure를 나타내는 sealed class 반환(주로 Failure로 네이밍)
  • exception 발생

이 두가지 중 전자, 즉 null 및 Failure 반환이 더 적절한 방식이다.
exception의 경우 특수한 상황에서만 발생되어야 하며, 일반적으로는 null 혹은 Failure를 반환해야 한다.
exception은 코틀린에서 unchecked, 즉 핸들링이 강제되지 않으며 가독성도 떨어지기 때문이다.

sealed class Result<out T>
class Success<out T>(val result: T) : Result<T>()
class Failure(
	val throwable: Throwable
) : Result<Nothing>()

위와 같이 Failure class를 정의해 활용할 수 있다.

그렇다면 null 혹은 Failure 중 어떤 것을 반환하는 게 더 적절할까?
실패 상황에 대한 추가 정보를 전달하려면 Failure class를, 아니면 null을 반환하면 된다.
Failure는 당연히 class로서 필요한 정보를 담을 수 있다.

Item8: Handle nulls properly

null은 프로퍼티 및 함수의 타입으로 활용되며 여러 의미를 가질 수 있다.
nullable value는 무조건 핸들링 되어야 하기 때문에 null 사용 의도를 분명히 해야 한다.
nullable type을 핸들링하는 방식으론 크게 세 가지가 있다.

  • nullability 안전하게 핸들링하기: safe call, smart casting, elvis operator
  • error 발생시키기
  • 불필요한 nullability 제거하기

각각에 대해 자세히 살펴보자.

nullability 안전하게 핸들링하기

printer?.print() // safe call
if (print != null) {
	printer.print() // smart casting
}
val printerName = printer?.name ?: "unnamed" // elvis operator

safe call, smart casting, 및 elvis operator를 활용해 위와 같이 처리할 수 있다.

error 발생시키기

위 코드 예시와 같이 safe handling을 통해 printer를 처리할 때 단점이 있다.
예기치 못한 문제로 printer가 null이 될 경우, 의도한 메서드 자체가 호출되지 않으므로 개발자 입장에서는 당혹스러울 수 있다는 것이다.
확실하게 맞지 않는 상황에선 null 핸들링 대신 에러를 발생시키는 게 낫다.
requireNotNull, checkNotNull, !!(not-null assertion)을 통해 쉽게 구현할 수 있다.

다만 !! 사용은 최대한 지양되어야 한다. 아무런 설명이 없는 generic exception(NPE)을 발생시켜 문제 파악이 어려울 수 있다.
설사 null이 절대 될일 없어보이는 코드이더라도, 나중에는 코드가 어떻게 변할지 모른다.
사용하기 간단한만큼 남용에 더 유의하고 사용을 지양하자.

불필요한 nullability 제거하기

가능하다면 nullable하지 않은 타입을 유도하는 것이 좋다.
collection의 결과로 아무런 아이템이 없으면, null이 아닌 empty collection을 반환해야 한다.
특정 프로퍼티의 초기화 여부를 체크하기 위해선 nullable type이 아닌 lateinitproperty delegation을 활용해야 한다.

Item9: Close resources with use

  • InputStream, OutputStream
  • java.sql.Connection
  • java.io.Reader (FileReader, BufferedReader, CSSParser)
  • java.new.Socket, java.util.Scanner

위 리소스들은 모두 Closeable 인터페이스를 상속하며, 사용후에는 명시적으로 close 함수를 호출해 닫아줘야 한다.
(호출하지 않아도 결과적으론 GC에 의해 핸들링되나, 시간이 걸리고 그 사이에 불필요한 지출이 생긴다.)

// 지양해야 하는 방식
val reader = BufferedReader(FileReader(path))
try {
	return reader.lineSequence().sumBy { it.length }
} finally {
	reader.close()
}

// 지향해야 하는 방식
BufferedReader(FileReader(path)).use { reader ->
	return reader.lineSequence().sumBy { it.length }
}

전통적으로는 try-finally 구문을 통해 close 함수를 호출해 리소스 처리를 해왔다.
하지만 close 자체도 에러를 발생시킬 수 있으며, 해당 에러는 핸들링되지 않는다는 점에서 위 구조는 옳지 않다.

대신, 표준 라이브러리에서 제공하는 use 함수를 사용하는 것을 권장한다.
use는 호출시 리소스를 자동으로 닫고 에러 핸들링까지 가능하다는 점에서 장점을 가진다.
해당 함수는 Closeable를 상속하는 모든 object에 대해 호출이 가능하다.

Item10: Write unit tests

더 효율적이고 안전한 코드 작성을 위해 유닛 테스트 작성은 적극 권장된다.
유닛 테스트는 아래 케이스를 주로 커버한다.

  • 주로 쓰이는 use case들 (일명 happy path)
  • 주로 발생하는 error case
  • edge cases 및 illegal arguments

유닛 테스트의 장점은 명백하다.

  • 더 안전한 코드
  • 자유로운 리팩토링 (문제 있는 수정을 유닛 테스트로 쉽게 캐치할 수 있으므로)
  • 수동으로 체크하는 것보다 시간적으로 효율적

물론 당장 코드 작성에 오랜 시간이 걸리고, 테스트 가능한 구조로 코드 변경을 해야한다는 점이 단점으로 다가올 수 있다.
하지만 위 단점도 장기적 관점에선 더 효율적인 유지 보수를 위한 길이란 점에서 장점으로 다가온다.

잘못 짜인 유닛 테스트는 오히려 독이다.
코틀린 개발자라면 적어도 주요 로직에 대해서는 적절한 유닛 테스트 코드를 작성할 줄 알아야 한다.

profile
안드로이드/코틀린

0개의 댓글