본문 바로가기
오늘은 뭘 배울까?/책

[이펙티브 코틀린] 1장. 안정성

by Kim Juhwan 2022. 11. 4.

1. 책을 열며
   1-1. 책을 읽게 된 계기

   1-2. 지은이의 말
2. 안정성
   2-1. 가변성을 제한하라
   2-2. 변수의 스코프를 최소화하라

   2-3. 최대한 플랫폼 타입을 사용하지 말아라
   2-4. 예외를 활용해 코드에 제한을 걸어라
   2-5. 결과 부족이 발생할 경우 null과 Failure를 사용하라
   2-6. 적절하게 null을 처리하라

 

 

 


 

 

1. 책을 열며

1-1. 책을 읽게 된 계기

회사에서 안드로이드 팀원분들과 스터디를 하기로 했다.

(정확히는... 막내인 나를 위해 해 주시는 것 같다!)

컴포즈 스터디를 진행하게 될 뻔 했으나 기초에 먼저 충실하자는 의미로 코틀린을 먼저 스터디하게 되었다.

스터디하기로 한 책은 바로 이펙티브 코틀린!

1달~2달 정도안에 책을 다 읽고 매번 이렇게 정리해서 포스팅을 하기로 했다.

 

1-2. 지은이의 말

지은이의 말 중 흥미로운 내용 몇 개를 적어보려고 한다.

 

자바스크립트의 프로토타입 버전은 10일 만에 만들어졌다고 한다.

(나는 몇 달에 거쳐 앱 하나 겨우 만드는 데, 10일 만에 언어를 만들었다고요...? 😱)

반면 코틀린은 6년에 가까운 시간 동안 베타 버전을 유지했다고 한다.

그런 덕분인지 코틀린은 요구 사항들이 수준 높게 반영된 언어라고 할 수 있다고 한다.

 

이펙티브 자바에서는 이러한 부분은 자바에서 문제가 될 수 있습니다! 를 알려주는 책이라고 한다.

대부분의 이펙티브 시리즈들이 그렇고...

그런데 코틀린은 문제가 발생할 가능성을 원척적으로 차단하기 때문에 따로 설명할 필요가 없다고 한다.

deprecated 되거나 미래에 수정될 내용에 대해 걱정하지 않아도 되는 이유가,

IDE에서 문제가 될만한 것들은 경고 또는 힌트를 주기 때문이다.

그래서 따로 공부할 필요도 없고 이 책에서도 다루지 않는다.

대신 모범사례를 보여주며 설명하겠다며 지은이는 말한다.

 

2. 안정성

2-1. 가변성을 제한하라

우선 가변성에 대해서 짚고 넘어가 보려 한다.

네이버에 검색하면 위 처럼 나온다. "일정한 조건에서 변할 수 있는 성질"

예를 들어 통장에 있는 돈은 입금과 출금에 따라 변하기 때문에 가변성이 있다. (물론 제 통장은 잔고가 늘어날 생각을 하지 않습니다만)

 

class BankAccount {
    var balance = 0.0
        private set

    fun deposit(depositAmount: Double) {
        balance += depositAmount
    }

    @Throws(InsufficientFunds::class)
    fun withdraw(withdrawAmount: Double) {
        if (balance < withdrawAmount) {
            throw InsufficientFunds()
        }
        balance -= withdrawAmount
    }
}

class InsufficientFunds: Exception()

fun main() {
    val account = BankAccount()
    println(account.balance) // 0.0
    account.deposit(100.0)
    println(account.balance) // 100.0
    account.withdraw(50.0)
    println(account.balance) // 50.0
}

코드로 보면 위와 같다.

balance가 입금과 출금에 따라 값이 변하는 것을 볼 수 있다.

 

이렇게 상태(값)을 가지게 하는 것은

시간의 변화에 따라 변하는 요소를 표현할 수 있다는 점에서 유용하지만

상태를 관리하는 것이 꽤 어렵다. 그 이유를 알아보자면...

 

  • 프로그램을 이해하고 디버그 하기 힘들어진다.
  • 코드의 실행을 추론하기 어려워진다.

비슷한 의미여서 이 두 가지는 한 번에 묶었다.

그냥 어렵게 생각할 것 없이 함수 안에 변수가 무수히 많다고 생각해보자.

그 함수의 동작을 이해하기 위해서는 각 변수가 어떤 역할을 하고

어떤 변수가 어떤 변수한테 영향을 주는지 등 많은 노력이 필요할 것이다.

즉, 코드의 실행 흐름을 파악하기 어렵고 그 결과 디버그 하기가 힘들어진다.

 

나머지 이유는 책을 읽어보면 그냥 끄덕끄덕하고 볼 수 있는데

멀티스레드 프로그램일때의 동기화 문제에 대해서 짚고 넘어가면 좋을 것 같다.

 

fun main() {
    var num = 0
    for (i in 1..1000) {
        thread {
            Thread.sleep(10)
            num += 1
        }
    }
    Thread.sleep(5000)
    print(num) /// 1000이 아닐 확률이 굉장히 높다.
    // 실행할 때마다 다른 숫자가 나온다.
}

위 코드를 보면 결과가 1000이 나와야 할 것 같은데 그렇지 않다.

내가 돌려봤을 땐 995, 991, 995, 998, 994, 991, 995, 991... 와 같은 숫자들이 나왔다.

for문을 1000번 돌면 num이 1000이 되어야 하는 게 맞는 것 같은데 이런 결과가 나오는 이유는 무엇일까?

그 이유는 충돌에 의해서 일부 연산이 이루어지지 않았기 때문이다.

 

의자 앉기 게임. 사람은 7명인데 의자는 5개다.

 

실생활에 빗대어 설명해보자.

어렸을 때 학교에서 의자 앉기 게임을 해본 적이 있을 것이다.

의자 주위를 빙글빙글 돌다가 휘슬소리와 함께 빠르게 의자에 앉으면 살아남는 게임이다.

의자에 앉기 위해 학생들은 서로 부딪히고 충돌하게 된다.

 

좀 전에 본 예시도 마찬가지다.

사람을 스레드, 의자를 리소스(코드에서는 num)라고 생각하자

스레드는 동시에 리소스를 사용하려 접근할 것이고, 이 과정에서 충돌이 일어나 일부 연산이 이루어지지 않는다.

그렇기 때문에 결괏값이 1000이 아닌 값이 나오는 것이다.

 

fun main() {
    val lock = Any()
    var num = 0
    for (i in 1..1000) {
        thread {
            Thread.sleep(10)
            synchronized(lock) {
                num += 1
            }
        }
    }
    Thread.sleep(1000)
    println(num)
}

코루틴을 활용하면 더 적은 스레드를 사용하기 때문에 조금 더 나은 결과를 얻을 수 있지만

그래도 여전히 문제가 사라지는 것은 아니다.

 

스레드간 충돌 문제는 스레드 동기화를 통해서 해결할 수 있다.

위 코드는 synchronized를 사용하여 항상 1000이라는 결과를 보여준다.

동기화에 대한 이야기를 하면 끝이 없기 때문에 정말 간단하게만 설명하자면

스레드가 차례대로 접근하게 해서 충돌을 막는 것이다.

 

결국 멀티스레드 프로그램에서 상태를 가진다는 것은 이러한 위험성을 지니고 있고

이를 해결하기 위해 동기화 처리를 해줘야 한다는 것을 유의하자.

 

코틀린은 가변성을 제한할 수 있게 설계되어 있다. 예를 들어

  • 읽기 전용 프로퍼티(val)
  • 가변 컬렉션과 읽기 전용 컬렉션 구분하기
  • 데이터 클래스의 copy

위와 같은 내용들이 있는데, 책 내용을 다 열거하기엔 간단한 내용이고 저작권상 문제가 될 것 같아 내가 처음 알게 된 내용을 적어보려 한다.

 

val은 읽기 전용 프로퍼티이지만 변경할 수 없음을 의미하지 않는다.

띠용?? val로 선언된 변수는 값을 바꿀 수 없는데 이게 무슨 뜻일까? 🤔

사실 완전 불가능한 것은 아니다. 다음 코드를 보자.

 

var surname = "이"
val fullname
    get() = "$surname 지은"

fun main() {
    println(fullname) // 이지은
    surname = "김"
    println(fullname) // 김지은
}

val을 사용하는 변수에서 var을 활용하는 게터를 정의했다.

그 결과 놀랍게도! 읽기 전용 프로퍼티인 fullname의 값이 변하는 것을 볼 수 있다.

 

fun main() {
    val list = mutableListOf(1, 2, 3)
    // list = mutableListOf('a', 'b', 'c') 객체를 바꿀 순 없다.
    list.add(4) // 객체가 지니는 값은 변경 가능하다.
    
    print(list) // [1, 2, 3, 4]
}

또한 읽기 전용 프로퍼티(val)가 mutable 객체를 담고 있다면 내부적으로 지니는 값은 변할 수 있다.

코틀린 입문할 때 이 개념이 헷갈렸었는데...

 

자, 정리해보자.

아무튼 그렇기 때문에 val은 읽기 전용 프로퍼티이지 변경 불가능한 것은 아니다!

정말 변경이 불가능하도록 하고 싶다면 val 앞에 const 키워드를 붙이면 된다.

그리고 읽기 전용 프로퍼티(val)의 값은 변경될 수 있지만 레퍼런스 자체는 변경할 수 없다.

그렇기 때문에 동기화 문제를 줄이는 데 도움이 되며, 일반적으로 var보다 val을 많이 사용하는 이유이기도 하다.

책에서는 이후에도 mutable의 단점을 이야기하며 immutable을 사용할 것을 권장하고 있다.

 

// 첫 번째 예제(이렇게 사용하지 마세요)
fun main() {
    var user = User("이", "지은")
    user = user.withName(name = "김")
    print(user) // User(name=김, surname=지은)
}

data class User(
    val name: String,
    val surname: String
) {
    fun withName(name: String) = User(name, surname)
}

/////////////////////////////////////////////////////

// 두 번째 예제(이렇게 사용하세요)
fun main() {
    var user = User("이", "지은")
    user = user.copy(name = "김")
    print(user) // User(name=김, surname=지은)
}

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

immutable 객체는 값을 변경하기 위해서 첫 번째 예제처럼 메서드를 만들어 사용해야 한다.

하지만 모든 프로퍼티를 대상으로 매번 이런 함수를 만들어 사용하는 건 귀찮으니

data class에서 기본적으로 제공하는 copy 메서드를 사용하면 된다.

 

지금까지 [목차 2-1]에서는 가변성이란 무엇인지, 왜 제한해야 하는지, 어떻게 제한하는지 등에 대해서 알아보았다.

다음 목차로 넘어가자.

 

2-2. 변수의 스코프를 최소화하라

// 나쁜 예
fun updateWeather(degrees: Int) {
    val description: String
    val color: Int
    if (degrees < 5) {
        description = "cold"
        color = Color.BLUE
    } else if (degrees < 23) {
        description = "mild"
        color = Color.YELLOW
    } else {
        description = "hot"
        color = Color.RED
    }
    // ...
}

//////////////////////////////////////////

// 좋은 예
fun updateWeather(degrees: Int) {
    val (description, color) = when {
        degrees < 5 -> "cold" to Color.BLUE
        degrees < 23 -> "mild" to Color.YELLOW
        else -> "hot" to Color.RED
    }
    // ...
}

이번 목차는 한 번 쭉 읽어보면 내용들이라 따로 작성할 것은 없고

위 예제를 알아두면 좋을 것 같아서 가져왔다.

 

구조분해 선언이라는 것을 이용하면 아래 예제처럼 사용할 수 있다고 한다.

나쁜 예제 코드는 description과 color의 스코프가 if~else 문을 끝난 이후에도 계속되지만

좋은 예제 코드는 when절에서만 유효하니 스코프를 더 작게 가져갈 수 있다.

스코프를 최소화하면 프로그램을 추적 및 관리하기 쉬워진다는 장점이 있다.

 

2-3. 최대한 플랫폼 타입을 사용하지 말아라

public class UserRepo {
    public User getUser() {
        // ...
    }
}

val repo = UserRepo()
val user1 = repo.user           // user1의 타입은 User!
val user2: User = repo.user     // user2의 타입은 User
val user3: User? = repo.user    // user3의 타입은 User?

프로젝트를 하다 보면 자바와 코틀린을 같이 사용할 때도 있을 것이다.

(회사에서 자바로 만들어진 코드를 코틀린으로 바꿔가는 과정 중에 있거나... 등)

 

위 예제처럼 자바 코드와 코틀린 코트가 서로 엮여 있다고 하자

user2나 user3처럼 타입을 지정해주면 문제가 없는데

user1처럼 타입을 지정해주지 않는다면 어떻게 될까?

자바 코드가 리턴해주는 User는 null인지 null이 아닌지 알 수 없는데 코틀린에서 이걸 대체 어떻게 처리할까?

 

이렇듯 코틀린이 null에 대한 정보를 알 수 없는 경우에는 플랫폼 타입을 사용한다.

표기할 때는 눈에 보이지는 않지만 User! 이런 식으로 느낌표 하나를 달아 표기한다.

플랫폼 타입을 사용하면 null에 대한 아무런 경고도 표시해주지 않으며 연산도 그냥 수행하게 해 준다.

대신 exception이 뜨면 자바와 마찬가지로 경고가 뜬다. 책임을 개발자가 알아서 지게 하는 것이다

 

그렇기 때문에 이런 경우는 @NotNull 어노테이션 따위를 자바 코드에 붙여줘서 확실하게 표현해주는 것이 좋다.

그리고 애초에 위험한 코드이니 쓰고 있다면 잠재적인 오류를 만들기 전에 지워버리는 것이 좋다.

 

2-4. 예외를 활용해 코드에 제한을 걸어라

fun main() {
    val balance = 10000
    val chickenPrice = 15000
    
    require(balance >= chickenPrice) {
        print("잔고가 부족해서 치킨을 못먹엉...")
    }
    
    print("아싸 오늘은 치킨이닭!")
}

require를 이용하면 코드에 제한을 걸고 조건식이 true가 아닐 때 exception을 발생시킬 수 있다.

 

class Person(val email: String?)

fun sendEmail(person: Person) {
    require(person.email != null) // null check가 되었으므로 이 밑으로는 null check가 불필요하다.
    val email: String = person.email // require가 없었다면 에러가 났을 것이다.
    print("${email}에게 메시지를 전송했습니다.")
}

fun main() {
    val person = Person("example@gmail.com")
    sendEmail(person)
}

또, require를 이용하면 스마트 캐스팅도 되고 notnull 체크를 할 수 있다는 장점이 있다.

 

class Person(val email: String?)

fun sendEmail(person: Person) {
    val email: String = requireNotNull(person.email)
    print("${email}에게 메시지를 전송했습니다.")
}

fun main() {
    val person = Person("example@gmail.com")
    sendEmail(person)
}

혹은 이렇게 이미 만들어서 지원하고 있는 requireNotNull을 사용해도 된다.

 

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

함수가 원하는 결과를 만들어 낼 수 없을 때 이를 처리하는 메커니즘은 크게 2가지가 있다.

  • null이나 실패를 나타내는 sealed 클래스를 리턴한다.
  • 예외를 throw 한다.

 

이 책에서 예외는 잘못된 특별한 상황을 나타내야 한다고 말한다.

예외는 정보를 전달하는 방법으로 사용되서는 안 되며 말 그대로 예외적인 상황이 발생했을 때 사용하는 것이 좋다.

그 이유는 다음과 같다.

  • 예외를 추적하고 파악하기 힘들다
  • 명시적인 테스트만큼 빠르게 동작하지 않는다.
  • try~catch 블록 내부에 코드를 배치하면, 컴파일러가 할 수 있는 최적화가 제한된다.

 

요즘 동아리 프로젝트도 그렇고 회사 코드도 보면 실제로 sealed 클래스를 사용해서 처리하는 것 같다. 

이에 대해서는 나도 접한 지 한 달이 채 되지 않았기에 직접 코드를 작성해보고 익혀야 할 것 같다.

 

2-6. 적절하게 null을 처리하라

이 목차에서 읽어 봄직한 내용은 not-null assertion(!!)인 것 같다.

사실 가장 편하게 null을 처리할 수 있는 방법이긴 하다. 하지만 좋은 해결 방법은 아니다.

불친절한 설명의 제네릭 예외가 발생해서 원인을 찾기 어렵고

현재 상황에서 "아 이 변수는 무조건 null이 아니니까 !!를 써야겠다" 싶다가도

미래에 코드가 어떻게 변하느냐에 따라 null이 될 수도 있기 때문이다.

 

!! 연산자가 의미 있는 경우는 매우 드물고

대부분의 팀이 !! 연산자를 아예 사용하지 못하게 하는 정책을 가지고 있다고 한다.

난 그것도 모르고... 굉장히 남용을 해왔는데 앞으로는 사용하지 말아야겠다는 생각이 들었다.

 

class DoctorActivity: Activity() {
    private var doctorId: Int by Delegate.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)
    }
}

primitive type은 lateinit을 사용하지 못하는 이유에 대해서 포스팅을 한 적이 있다.

이런 경우는 delegate.notnull를 사용하면 된다고 한다.

사실 이런 경우는 그냥 초기값을 줘도 되지 않나? 🤔

 

근데 만약 할당하는 부분에 문제가 생겨 미처 할당하지 못한다면,

예외가 발생할거고 -> 오히려 이게 좋은 상황이라고 한다.

왜냐면 초기값을 줘버리면 할당하는 부분에 문제가 생겨도 예외가 생기지 않고 마치 정상적인 것처럼 돌아갈 테니 말이다.

 

 


💡 느낀 점

  • 새롭게 알게 된 점이나 흥미로운 점, 정리하면 좋을 것들 위주로 적었는데도.... 양이 엄청나구나야...
  • 평소에 편하다고 막 사용한 것들이 많았던 것 같다. var이나 !! 같은 것들... 생각을 하고 코드를 짜자!
  • 플랫폼 타입에 대해선 전혀 들어본 적이 없어서 되게 흥미로웠다. 접할일은 많이 없겠지만 알아둬서 나쁠 건 없겠지
  • 우리팀 코드에 !! 연산자를 사용하는 곳이 있던가? 있다면 사용 제한하는 걸 건의해봐야겠다.

📘 참고한 자료

  • Effective Kotlin - 마르친 모스칼라 지음, 윤인성 옮김

 

 

반응형

댓글