Kotlin

[Kotlin] 방어적 복사와 깊은 복사, 얕은 복사 (feat. 우테코)

sh1mj1 2024. 3. 8. 10:37

 

Mutablility(가변성)과 방어적 복사, 혹은 방어적 프로그래밍은 공부할 때 봤던 코틀린 인 액션 책에 "6.3.2 읽기 전용과 변경 가능한 컬렉션"에서 잠깐 다룬다.(이 글에서도) 

하지만 뭔가 부족한 느낌이 있었는데, 마침 우테코 수업에서 이 내용을 다루었다!!

 

모든 코드는 Kotlin 으로 작성된다.

얕은 복사(Shallow copy)와 깊은 복사(Deep Copy),  그리고 방어적 복사(Defensive Copy) 순서로 공부해보자.

얕은 복사(Shallow copy) & 깊은 복사(Deep copy)

먼저 방어적 복사 이전에 얕은 복사와 깊은 복사를 알아야 한다.

얕은 복사(Shallow copy)

얕은 복사: 객체의 주솟값을 복사한다. 

복사된 객체의 인스턴스는 원본 객체의 인스턴스와 같은 메모리 주소를 참조한다. 따라서 같은 메모리 주소 값을 참조하기 때문에 복사된 객체의 값이 변경되면 원본 객체의 값도 변경된다.

`variable1` 이 이미 실제 값 "valueString" 을 참조하고 있는 상황에서, `variable2` 에 얕은 복사를 수행하면, `variable2` 역시 같은 `"valueString"` 을 참조하게 된다.

이 때 같은 주소값을 저장하고 있게 되어, 만약 `variable1` 을 통해 `"valueString"` 의 값을 변경하면, 당연히 `variable2` 가 참조하고 있는 값도 변경된다.

깊은 복사(Deep copy)

깊은 복사: 객체의 주솟값이 아닌 값이 복사되는 방법. (새로운 메모리 공간에 객체의 값을 복사)

원본 객체는 그대로 두고, 새로운 메모리 공간에 원본 객체의 값을 모두 복사한다.

따라서 다른 메모리 주소값을 참조하기 때문에 복사된 객체가 변경되어도 원본 객체는 영향을 받지 않는다.

당연히 새 객체를 만드는 것이므로 얕은 복사보다 비용이 많이 든다.

`variable2` 에 깊은 복사를 수행하면, `variable2` 에는 기존 `variable1` 이 참조하고 있던 값의 내용을 복사하여 새 객체(`Stirng`)을 만든다.

이 때는 다른 주소값을 저장하고 있게 되어, `variable1` 을 통해 `"valueString"` 의 값을 변경하더라도, 당연히 `variable2` 가 참조하고 있는 값은 변경되지 않는다.

코드로 얕은 복사, 깊은 복사 살펴보기

그림으로 보니 쉽게 이해가 되는 것 같다. 이제 코드로 샬펴보자.

객체를 얕은 복사하는 경우

data class Car(private val name: String, var position: Int = 0) {
    fun move() { position++ }
}

@Test
fun `Car 를 얕은 복사`() {
    val car = Car("sh1mj1")
    val copyCar = car
    copyCar.move()
    
    assertThat(copyCar).isEqualTo(Car("sh1mj1", 1)) // ✅ 성공!!!
    assertThat(car).isEqualTo(Car("sh1mj1", 0)) // ❌ 실패!!! 실제로는 Car("sh1mj1", 1)
}

 `car` 뿐 아니라, `copyCar` 또한 `position` 이 1 증가하는 것을 알 수 있다.

그렇다면 클래스의 인스턴스를 어떻게 깊은 복사할 수 있을까?

class 의 인스턴스를 깊은 복사 하기

방법1) data class 의 copy 메서드를 사용

@Test
fun `Car 를 깊은 복사 copy 이용`() {
    val car = Car("sh1mj1")
    val copyCar = car.copy()

    copyCar.move()

    assertThat(copyCar).isEqualTo(Car("sh1mj1", 1)) // ✅ 성공!!!
    assertThat(car).isEqualTo(Car("sh1mj1", 0)) // ✅ 성공!!!
}

 코틀린의 data class 는 `copy` 메서드를 제공한다. 그래서 data class 의 인스턴스는 쉽게 깊은 복사를 할 수 있다.

참고로 data class Car 의 코틀린 바이트 코드를 다시 Decompile 하면 아래와 같은 모습을 볼 수 있다.

@NotNull
public final Car copy(@NotNull String name, int position) {
  Intrinsics.checkNotNullParameter(name, "name");
  return new Car(name, position);
}

 결국, 새 `Car` 인스턴스를 생성하는 것이다.

방법2) 깊은 복사를 하는 메서드를 직접 구현

코틀린의 data class 를 사용하지 않고 일반적인 class 를 사용한다면, 깊은 복사를 수행하는 메서드를 직접 구현해주면 된다.

class Car(private val name: String, var position: Int = 0) {
    fun move() { position++ }
    
    fun copy(name: String = this.name, position: Int = this.position): Car =
        Car(name, position)
}

@Test
fun `Car 를 깊은 복사`() {
    val car = Car("sh1mj1")
    val copyCar = car.copy()

    copyCar.move()

    assertThat(copyCar.position).isEqualTo(1) // ✅ 성공!!!
    assertThat(car.position).isEqualTo(0) // ✅ 성공!!!
}

 이번에도 깊은 복사가 되어 `copyCar` 의 `position` 만 변경된다.

 

이외에도 추가로 더 많은 방법들도 있다. (`Cloneable`, `Gson` 복사 이용!) ([Kotlin] 깊은 복사(Deep Copy)하는 3가지 방법)

그렇다면 이제 방어적 복사를 알아보자.

방어적 복사

코틀린 인 액션의 "6.3.2 읽기 전용과 변경 가능한 컬렉션" 에서, 이펙티브 코틀린의 "아이템1 가변성을 제한하라" 에서 , 그리고 이펙티브 자바 "아이템 50 적시에 방어적 복사본을 만들라" 에서도 방어적 복사에 대해 다룬다. 

각 책에서 명확한 정의를 내리지는 않지만, 결국 방어적 복사 원본의 변경을 막기 위해 다른 복사본을 이용하는 하는 것을 말한다.

1급 컬렉션의 방어적 복사

1급 컬렉션에서 방어적 복사를 구현하는 예시를 코드로 보자.

Collection 방어적 복사

class Cars(val cars: List<Car>)

@Test
fun `Cars 리스트 방어적 복사`() {
    val list = mutableListOf(Car("car1"))
    val carsInstance = Cars(list)

    list.add(Car("car2"))

    assertThat(carsInstance.cars).isEqualTo(listOf(Car("car1"))) // ❌ 실패!!!
    // 실제로는 listOf(Car("car1"), Car("car2")) 가 된다
}

 위 코드에서 `carsInstance` 의 속성 `cars` 에 직접 값을 추가해주지 않았음에도 불구하고, `carsInstance` 가 변경된다.

`carsInstance` 의 속성인 `cars` 가 list 의 주소값을 참조하고 있기 때문에 `list` 가 변경되면 당연히 `cars` 도 변경된다.

그림으로 표현하면 아래와 같다

그렇다면 Cars 의 주 생성자의 파라미터와 프로퍼티를 분리해보자.

class Cars(cars: List<Car>) {
    val cars: List<Car> = cars.toList()
}

 위 코드에서 두번째 라인에서, 앞에 있는 cars 는 프로퍼티이고, 뒤에 있는 cars 는 생성자의 파라미터이다.

이제 cars 프로퍼티는 cars.toList() 를 통해서 새로운 List 가 된다.

참고로 toList 의 구현은 아래처럼 되어 있어 깊은 복사라는 것을 알 수 있다.
public fun <T> Iterable<T>.toList(): List<T> {
    if (this is Collection) {
        return when (size) {
            0 -> emptyList()
            1 -> listOf(if (this is List) get(0) else iterator().next())
            else -> this.toMutableList()
        }
    }
    return this.toMutableList().optimizeReadOnlyList()
}​

 

 변경한 `Cars` 를 이용하여 테스트를 해보자

@Test
fun `Collection (List) 는 깊은 복사된다`() {
    val list = mutableListOf(Car("car1"))
    val carsInstance = Cars(list)

    list.add(Car("car2"))
    assertThat(carsInstance.cars).isEqualTo(listOf(Car("car1"))) // ✅ 성공!!!
}

 이제 `list` 에 새로운 `Car` 인스턴스를 추가하더라도, `carsInstance` 에 추가되지는 않는다.

그림으로 보면 아래와 같다.

Collection 내부 객체 방어적 복사

`Collection` 을 방어적 복사하는 것은 완료되었다.

그런데 `Collection` 내부 요소인 `Car` 의 `position` 프로퍼티는 가변인 상태이다.

data class Car(private val name: String, var position: Int = 0) {
    fun move() { position++ }
}

 만약 이전 테스트에서 `carsInstance.cars[0].move()` 를 실행하면, `car` 인스턴스의 `position` 프로퍼티가 바뀌게 된다.

아래 테스트 코드를 보자.

@Test
fun `Collection 내부 원소를 움직인다`() {
    val car1 = Car("car1")
    val list = mutableListOf(car1)
    val carsInstance = Cars(list)

    car1.move()

    assertThat(carsInstance.cars[0].position).isEqualTo(0) // ❌ 실패!!! 실제로는 1 이다  
}

 이 코드에서 문제를 느끼지 못할 수도 있다.

하지만, 만약 프로덕션 코드에서 `car1.move()` 위와 아래에서 굉장히 많은 코드들이 있다고 상상해보면, 이는 문제가 될 수 있다.

그림으로 보면

 

그렇다면 어떻게 수정하면 될까?

`Collection` 안에 있는 원소에 대해서도 깊은 복사를 해주기 위해서 `cars` 프로퍼티를 설정할 때 원소들도 `copy` 메서드를 사용하여 깊은 복사를 해주면 된다.

class Cars(cars: List<Car>) {
    val cars: List<Car> = cars.map { it.copy() }
}

 이렇게 수정한 후 다시 같은 테스트를 돌려보자.

@Test
fun `Collection 내부 원소 객체도 깊은 복사한다`() {
    val car1 = Car("car1")
    val list = mutableListOf(car1)
    val carsInstance = Cars(list)

    car1.move()

    assertThat(carsInstance.cars[0].position).isEqualTo(0) // ✅ 성공!!!
}

 이제 `car1.move()` 를 실행해도, `Cars` 의 `position` 은 변경되지 않는다.

그림으로 보면

외부로 나가는 것에 대한 방어적 복사

지금까지 위에서 했던 깊은 복사는 생성자로 들어오는 List 와 List 에 들어오는 원소 객체에 대한 방어적 복사였다.

마찬가지로 외부에서 `Cars` 내부의 `List` , `List` 내부 원소 객체에 직접 접근해서 조작하는 것에 대한 방어적 복사도 구현할 수 있다.

즉, 1급 컬렉션에서 나가는 것에 대한 방어적 복사를 구현할 수 있다.

@Test
fun `이제 carsInstance 를 통해 Collection 내부 객체를 변경할 수 있다`() {
    val list = mutableListOf(Car("car1"))
    val carsInstance = Cars(list)

    carsInstance.cars[0].move()

    assertThat(carsInstance.cars[0].position).isEqualTo(0) // ❌ 실패!!! 실제로는 1이 됨
}

 위 코드에서 `carsInstance.cars[0].move` 를 실행했으니 당연히 `carsInstance.cars[0].position` 은 1이 되는 것이 자연스러워 보인다.

하지만 어떠한 객체(`carsInstance`)의 속성인 객체(`cars: List<Car>`)에 직접 접근하고, 또 그 객체(`cars: List`)의 속성(원소 `car1`)에 접근하여 명령을 하는 것은 객체 지향 관점에서 좋지 않다.

그림으로 보면,

이러한 문제를 해결하기 위해서, 우리는 `Cars` 클래스 내부에서 노출하는 컬렉션과 원소에 대한 접근을 더욱 제한할 수 있다.

외부에서 `cars` 컬렉션의 내부 원소를 직접 변경하는 대신, `Cars` 클래스 내부에 변경을 위한 메소드를 제공하여 컬렉션의 불변성을 유지하고, 외부에서는 이 메소드를 통해서만 변경이 가능하도록 하는 게 좋다.

 

그러면, 외부에서 내부 원소를 직접 변경하지 못하게 막거나, 변경하더라도, 실제로 값을 읽을 때는 변경되지 않도록 해야 한다.

이 부분을 만들기 위해 Backing Property 를 만들 수 있다.

 

class Cars(cars: List<Car>) {
    private val _cars: MutableList<Car> = cars.map { it.copy() }.toMutableList() // backing property
    val cars: List<Cars>
        get() = _cars.map { it.copy() }
}

 클라이언트 코드에서 인스턴스를 생성할 때 사용한 생성자의 파리미터 `cars` 는 `_cars` 에 담아서 내부 `Car` 까지 다른 `List` 를 담아준다.

그리고 이 `_cars` 는 외부에서 직접 접근할 수 없다.

외부에서 접근할 수 있는(읽을 수 있는) `cars` 또한 방어적 복사를 한다.

이렇게 되면 `Cars` 클래스 내부에서만 추가를 할 수 있고 외부(클라이언트 코드)에서는 오직 값을 읽을 수만 있도록 된다.

@Test
fun `이제 carsInstance 를 통해서만 Collection 내부 객체를 변경할 수 있다`() {
    val car1 = Car("car1")
    val list = mutableListOf(car1)
    val carsInstance = Cars(list)

    car1.move()
    assertThat(carsInstance.cars[0]).isEqualTo(Car("car1", 0)) // 변경되지 않음

    list.add(Car("car2"))
    assertThat(carsInstance.cars).isEqualTo(listOf(Car("car1", 0))) // 추가되지 않음

    carsInstance.cars[0].move()
    assertThat(carsInstance.cars[0]).isEqualTo(Car("car1", 0)) // 추가되지 않음

    carsInstance.addNewCar(Car("car2", 0))
    assertThat(carsInstance.cars).isEqualTo(listOf(Car("car1", 0), Car("car2", 0))) // 추가됨 (Cars 에 생성한 메서드로만 명령이 가능)
}

 

 

 

 

 

참조

https://www.yegor256.com/2014/06/09/objects-should-be-immutable.html

https://jessyt.tistory.com/148

https://seosh817.tistory.com/163#%EB%B0%A9%EC%96%B4%EC%A0%81%20%EB%B3%B5%EC%82%AC%EB%9E%80%3F-1

https://velog.io/@dddooo9/Kotlin-%EA%B9%8A%EC%9D%80-%EB%B3%B5%EC%82%ACDeep-Copy-%ED%95%98%EB%8A%94-3%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95