LY Corporation Tech Blog

LY Corporation과 LY Corporation Group(LINE Plus, LINE Taiwan and LINE Vietnam)의 기술과 개발 문화를 알립니다.

This post is also available in the following languages. Japanese, English

코드 품질 개선 기법 28편: 제약 조건에도 상속세가 발생한다

이 글은 2024년 6월 6일에 일본어로 먼저 발행된 기사를 번역한 글입니다.

LY Corporation은 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.

Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.

이번에 블로그로 공유할 Weekly Report의 제목은 '제약 조건에도 상속세가 발생한다'입니다.

제약에도 상속세가 발생한다

Kotlin에서는 IntArray(Java의 int[]에 해당)가 박싱(boxing, 기본 타입을 참조 타입으로 변환하는 과정)이 필요 없다는 점 때문에 Array<Int>List<Int>보다 효율적인 경우가 많습니다. 다만 IntArray는 가변적이며, List의 유틸리티를 그대로 사용할 수 없습니다. 이에 한 개발자가 다음과 같이 ImmutableIntList라는 클래스를 만들었다고 생각해 봅시다. 이 클래스는 List 인터페이스를 가진 IntArray의 래퍼로, 불변성을 보장할 수 있습니다(참고: Kotlin의 List는 Java의 List와 달리 set이나 add 같은 메서드가 없습니다).

class ImmutableIntList(vararg values: Int): List<Int> {
    // It's safe to assign `values` because `vararg` does not keep the reference to the `*array` argument.
    private val valueArray : IntArray = values

    override operator fun get(index: Int): Int = valueArray[index]

    override val size: Int = valueArray.size

    ... snip
}

이어서 이번에는 다른 개발자가 ImmutableIntList의 다양한 변형을 만들고 싶어한다고 가정해 보겠습니다. 예를 들어 요소가 정렬되어 있는 상태를 보장하는 ImmutableSortedIntList와 같은 것입니다. 다음 코드는 ImmutableIntList를 상속 가능(open class)하게 만들고 ImmutableSortedIntList를 그 자식 클래스로 정의합니다.

open class ImmutableIntList(vararg values: Int): List<Int> {
    
    protected val valueArray : IntArray = values

    override operator fun get(index: Int): Int = valueArray[index]

    ... snip
}

class ImmutableSortedIntList(vararg values: Int) :
    ImmutableIntList(*values.sorted().toIntArray())

class AnotherImmutableIntList(...): ImmutableIntList() {
    ... // may touch `valueArray` here.
}

이 구조에 문제가 있을까요?

불변성에 대한 상속세

불변성을 보장하려면 상속 불가능하게 만드는 것이 좋습니다. 클래스에 Immutable이라는 이름을 붙여도 상속이 가능한 경우 자식 클래스에서 가변적으로 만들 수 있습니다.

특히 ImmutableIntList는 범용적이고 널리 사용되는 이름이므로 예상치 못한 패키지나 모듈에서 '잘못된 상속이 발생할' 가능성이 있습니다. 예를 들어 다음과 같이 불변성을 깨는 자식 클래스가 알 수 없는 곳에서 생성될 수도 있습니다.

class MutableIntList(...): ImmutableIntList(...) {
    operator fun set(index: Int, value: Int) {
        valueArray[index] = value
    }

    ...
}

val mutableIntList = MutableIntList(...)

// Although this is an `ImmutableIntList`, it's actually mutable
// because it's modifiable by accessing `mutableIntList`.
val immutableIntList: ImmutableIntList = mutableIntList

이를 막기 위해 valueArrayprivate으로 설정해도 불변성을 유지하기에는 불충분합니다. 여전히 get 함수가 오버라이딩 가능하기 때문에 valueArray를 무시하는 구현이 되고 맙니다.

class IntListWithMutableSuffix(...): ImmutableIntList(...) {

    var suffix: Int = 0

    override operator fun get(index: Int): Int =
        if (index != super.size) super[get] else suffix

    ...
}

이는 불변성에 국한된 이야기가 아닙니다. 기본적이고 범용적인 클래스를 상속 가능하게 만들 경우 어떤 멤버를 오버라이딩 가능하게 할지 신중히 검토해야 합니다. 또한 상속 가능하게 만드는 대신 상속 관계 없이 독립된 타입을 정의하는 것도 고려해 보세요.

불변성과 상속 관계

일반적으로 가변 객체 클래스와 불변 객체 클래스는 서로 상속 관계에 놓이지 않도록 해야 합니다.

  • 가변 객체가 불변 객체에서 상속받는 경우: 앞서 언급했듯이 불변성 제약 조건이 깨집니다.
  • 불변 객체가 가변 객체에서 상속받는 경우: 변경하는 메서드(add/set 등)가 불변 객체의 클래스에서도 호출 가능해야 하므로 런타임 에러가 발생합니다(예: Guava의 ImmutableList).

가변 및 불변 객체의 공통 부모 클래스가 필요한 경우 '읽기 전용(read-only, unmodifiable)'으로 생성하는 것이 좋습니다(Kotlin의 List도 이런 상속 구조입니다).

  • 가변 객체가 읽기 전용 객체로부터 상속받는 경우: 문제 없음. 부모 클래스의 '메서드'는 자식 클래스 메서드의 부분 집합입니다(자식 클래스에만 set/add 같은 메서드가 존재하고, 부모 클래스에서만 유효한 메서드는 없음).
  • 불변 객체가 읽기 전용 객체로부터 상속받는 경우: 문제 없음. 부모 클래스의 '제약 조건'은 자식 클래스 제약 조건의 부분 집합입니다(자식 클래스만 불변성을 가지고, 부모 클래스에만 해당하는 제약 조건은 없음).

한 줄 요약: 불변성을 보장하고 싶다면 상속 불가능하게 만드는 것이 좋다.

키워드: immutability, inheritance, override