우테코 8기/Kotlin

가변성을 제한하라

Reddish 2026. 7. 19. 01:10
반응형

본 내용은 이펙티브 코틀린에 있는 “Limit Mutability”에 관련된 내용이다.

Effective Kotlin Item 1: Limit mutability

 

kt.academy

Teaching Kotlin, with focus on the best practices.

kt.academy

 

가변 상태를 왜 제한할까?


가변 상태의 문제점

코틀린을 사용하면서 아래와 같은 프로퍼티나 리스트를 사용해본 적이 있을 것이다.

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

객체나 요소가 위와 같이 읽기, 쓰기가 모두 가능한 프로퍼티(var)나 가변 리스트(MutableList)를 상태로 가질 수 있다. 상태란, 객체나 요소가 갖고 있는 변할 수도 있는 값이다.

요소가 상태를 들고 있게 된다면, 요소가 동작하는 방식은 어떻게 쓰느냐 뿐만 아니라 어떻게 변화했는지에도 영향을 받는다. (* 원문의 표현을 빌려쓰자면, 요소가 동작하는 방식은 어떻게 어떻게 쓰는지 뿐만 아니라 그것의 히스토리(history)에도 의존하게 된다.)

class Wallet{
	var money = 0
	private set
	
	fun addMoney(addAmount: Int) {
		money += addAmount
	}
	
	fun minusMoney(minusAmount: Int) {
		if(money < minusAmount) throw IllegalArgumentException()
		money -= minusAmount
	}
}

Wallet객체에는 변하는 상태인 money를 들고 있다. var 프로퍼티를 private set 을 통해서 세터를 private로 잠그고 있다.

val wallet = Wallet()
println(wallet.money) // 0
wallet.addMoney(100)
println(wallet.money) // 100 
wallet.minusMoney(50)
println(wallet.money) // 50

위를 보면 Wallet 에 얼마의 돈을 갖고 있는지에 대한 상태를 저장하고 있다. 변하는 상태를 직접 들고 있는 것은 객체/요소의 변화가 시간에 따라 변화하는 것을 나타낼 수 있어 유용하다.

하지만, 상태를 관리함에 있어 어려움이 있다.

  1. 코드 이해 및 추적의 어려움

수정의 지점이 여러 곳이 되어서 프로그램을 이해하고 수정하는데 어려움을 준다. 여러 곳에서 함수들이 호출되거나 함수를 많이 만든다면, wallet의 money 값도 계속해서 바뀔 것인데, 이 바뀌는 흐름들을 다양한 곳에서 많이 바꿀 수록 변화를 예측하고 따라가기 어려워진다.

서로서로 변경하는 지점이 많은 클래스이면 이해하고 수정하기가 더 어려워질 수 있다. 나중에 예상하지 못한 상황이나 오류가 발생하면 더 골치 아파질 것이다.

대표적인 예시는 아래와 같다.

class UserRepository {
   private val storedUsers: MutableMap<Int, String> = mutableMapOf()

   fun loadAll(): Map<Int, String> = storedUsers
  
   fun add(id: Int, name: String) {
       storedUsers[id] = name
   }
   //...
}

val userRepository = UserRepository()
val users: Map<Int, String> = userRepository.loadAll()
print(users)
userRepository.add(123, "ABC")
print(users)

이 코드의 행동을 한번 예측해보자. UserRepository엔 내부적으로 수정이 가능한 맵인 유저 정보가 Int-String 형식으로 들어가 있다. loadAll을 통해 유저 정보를 불러오고, add를 통해 추가할 수 있다.

우선적으로 UserRepository()를 통해서 해당 객체를 생성한다. userRepository.loadAll()을 통해서, 새로운 users를 생성한다. 만약 지금 출력을 하면 아무것도 없는 빈 값이 출력 될 것이다.

그리고 users 가 아니라 기존의 userRepository에 add를 통해 유저를 추가한다. 우리가 생각하기론 userRepository 와 users는 별개이니 서로 영향이 없을 것이라고 생각한다. 그러면 print(users) 를 실행하면 아무것도 출력하지 않을 것이다.

val userRepository = UserRepository()
val users: Map<Int, String> = userRepository.loadAll()
print(users) // {}
userRepository.add(123, "ABC")
print(users) // {123=ABC}

하지만 실제로 값은 다르다. 두번째로 출력한 값은 users에 userRepository에 추가한 값이 들어간 것을 볼 수 있다. 변경이 가져오는 예측하지 못한 사이드 이펙트이다.

  1. 멀티 스레드 환경에서의 동기화의 어려움

가변 상태는 멀티스레드 환경에서 적절한 동기화를 필요로 한다. (경쟁 상태, 동기화와 교착 상태 :: Hello, 9ky0!)

아래의 불변 상태의 장점에서 좀 더 다뤄보겠다.

  1. 테스트하기 어려움

모든 가능한 상태를 테스트를 해봐야한다. 상태가 많아질 수록 더 많은 경우의 수를 체크해야한다.

앞선 Wallet 객체를 다시 보자.

class Wallet{
	var money = 0
	  private set
		
	fun canPay(amount: Int): Boolean = money < amount
	
	fun addMoney(addAmount: Int) = money += addAmount
	
	fun minusMoney(minusAmount: Int) = money -= minusAmount
}

아래와 같이 테스트를 만들게 된다고 가정하자.

val wallet = Wallet()

@Test
fun `값을 추가한다`() {
	val money = 1000
		
	wallet.addMoney(money)
		
	assertEquals(1000, wallet.money)
}

여기선 큰 문제가 없어보인다. 하지만 아래와 같은 상황에서 문제가 발생한다.

class User {
	var wallet = Wallet()
	
	fun earn() {
		wallet.addMoney(100)
	}
	
	fun quit() {
		// 종료하는 달에 돈은 번 후 종료한다.
		earn()
		finish()
	}
}

val user = User()

@Test
fun `유저는 일정 금액을 번다`() {
	user.earn()
	assertEquals(100, user.wallet.money)
}

@Test
fun `유저는 그만둘 때 돈을 벌고 그만둔다`() {
	user.quit()
	assertEquals(100, user.wallet.money)
}

위와 같이 같은 객체를 공유하게 될 경우, 두 번째 테스트는 실패하게 된다. 즉, 테스트가 독립적이지 않고 각각의 실행 순서에 따라 달라지게 된다. 이는 가변 상태가 많을수록 이런 현상이 쉽게 발생한다. (JUnit5는 병렬테스트도 지원하는데 그러면 테스트가 랜덤하게 실패할 수도 있다.)

상태 가변성의 단점은 너무 많아 아예 상태 가변을 허용하지 않는 언어들도 있다. 이를 순수 함수형 언어라고 하고, 대표적으론 하스켈이 있다. 하지만 가변성이 너무 제한적이라 프로그래밍이 너무 어려워 주류 개발엔 거의 사용되지 않는다.

가변 상태는 실제 시스템의 상태를 표현하는데 매우 유용하다. 하지만, 가변성이 실질적 가치를 줄 때만 사용을 해야한다.

불변 상태의 장점

결국 가변 상태의 단점과 반대로이다.

  1. 멀티 스레드 환경에서 동시성의 문제가 없다

추가 설명을 하자면,

class Wallet{
	var money = 0
	  private set
		
	fun canPay(amount: Int): Boolean = money < amount
		
	fun addMoney(addAmount: Int) = money += addAmount
		
	fun minusMoney(minusAmount: Int) = money -= minusAmount
}

val wallet = Wallet()
wallet.addMoney(1000)

thread { wallet.addMoney(100) }
thread { wallet.addMoney(200) }
thread { wallet.addMoney(50) }

println(wallet.money) // 결과를 예측할 수 없다. race condition 때문

레이스 컨디션, 경합 상태 때문에 결과를 예측할 수 없게 된다.

class Wallet(
	val money: Int = 0
){				
	fun addMoney(addAmount: Int) = Wallet(money + addAmount)
	
	fun minusMoney(minusAmount: Int) = Wallet(money - minusAmount)
}

val wallet = Wallet()
wallet.addMoney(1000)
thread { wallet.addMoney(100) }
thread { wallet.addMoney(50) }

여기서는 wallet 객체 자체가 절대 변하지 않는다. 공유되는 객체가 변하지 않으므로 객체 자체는 thread-safe 하다 할 수 있다.

2. 추적이 용이하다

3. 테스트가 쉽다.

+ 안드로이드 개발자라면, Compose를 쓰면서 Recomposition 등을 알게 될텐데, 해당 내용에서도 불변 객체의 중요성이 드러나게 된다. 이 내용은 나중에 알아가보자..

 

어떻게 가변성을 제한할까?


읽기 전용 프로퍼티와 컬렉션

  • 읽기 전용 프로퍼티(val)는 새로운 값으로 설정될 수 없다.
  • 그러나, 읽기 전용의 의미가 불변을 의미하지는 않는다.
    • 참조는 변경할 수 없다.
    • 참조를 하고 있는 객체는 얼마든지 변경 가능하다.
val list = mutableListOf(1, 2, 3)
list.add(4)

print(list) // [1, 2, 3, 4]

읽기 전용이라는 것의 근거 1

val로 선언했지만, 내부적으로는 MutableList가 변수에 담겨있다. 해당 변수에 담긴 객체는 내부를 수정할 수 있으므로 참조는 변하지 않지만, Mutablelist라는 객체 내부의 값은 변경될 수 있다.

var name: String = "Marcin"
var surname: String = "Moskała"
val fullName
    get() = "$name $surname"

fun main() {
    println(fullName) // Marcin Moskała
    name = "Maja"
    println(fullName) // Maja Moskała
}

근거 2

name과 surname은 val fullName 이라고 선언했지만, name 프로퍼티를 변경하게 되면 읽는 fullName도 변하게 된다.

그 외에도 오버라이드나, 위임 메서드를 통해서 읽기 전용임을 보여준다.

다시 한번 말하지만, val은 읽기 전용일 뿐, 수정을 막지 않는다. val은 불변성을 의미하지 않는다. 따라서 새로운 방식인 방어적 복사나 toList같은 메서드, 또는 새로운 객체를 만드는 방식이 필요하다.

간단하게만 방어적 복사를 보도록하자

class Lotto(
		numbers: List<Int>
) {
		private val _numbers = numbers.toList()

		val _numbers: List<Int>
			get() = _numbers
}

생성자로만 numbers를 받고, 생성 순간 부터 외부로 주입받은 리스트와는 참조 연결이 끊긴다. 그리고 toList 메서드를 통해서 원본인 numbers를 보호한다.

데이터 클래스의 copy

해당 내용은 아래 블로그를 참조하자…

[Kotlin] data class copy

 

[Kotlin] data class copy

data class는 데이터 보관을 주목적으로 하는 클래스이다. 이 클래스는 컴파일러가 copy와 같은 메서드를 ...

blog.naver.com

 

결론: 가능하면

  1. 프로퍼티를 val로 만들기
  2. 컬렉션도 List 를 사용하기
  3. 원소 자체도 immutable하게 설계하기

 

 

반응형

'우테코 8기 > Kotlin' 카테고리의 다른 글

Flow, StateFlow, SharedFlow  (0) 2026.10.04
Kotlin Annotation Retention  (0) 2026.09.20
Kotlin coroutine - suspend 함수  (0) 2026.09.05
expect / actual 키워드 (Kotlin Multiplatform, KMP)  (0) 2026.08.17
Annotation과 Annotation Target  (2) 2026.06.03