컴파일러가 생성한 메서드: 데이터 클래스와 클래스 위임

유우선·2026년 2월 1일

Kotlin Study📚

목록 보기
6/32

모든 클래스가 정의해야 하는 메서드

자바와 코틀린의 모든 클래스는 toString, equals, hashCode를 오버라이드해야 한다.

각각이 어떤 메서드인지 살펴보고 코틀린이 어떻게 이런 메서드를 자동으로 생성해 주는지 알아보자.

// 고객 이름과 우편번호를 저장하는 간단한 예제 클래스
class Customer(val name: String, val postalCode: Int)
  1. 문자열 표현: toString()

주로 디버깅과 로깅 시 활용하는 메서드로, 인스턴스의 문자열 표현을 반환한다.

기본으로 제공되는 객체의 문자열은 Customer@5e9f23b4 같은 형식이다.

이는 사용자 입장에서 사용하기 어렵다.

이 기본 구현을 바꾸려면 toString 메서드를 오버라이드해야 한다.

class Customer(val name: String, val postalCode: Int) {
    override fun toString() = "Customer(name=$name, postalCode=$postalCode)"
}

fun main(){
    val customer1 = Customer("Alice", 342562)
    println(customer1.toString())
}

//Customer(name=Alice, postalCode=342562)
  1. 객체의 동등성: equals()

서로 다른 두 객페가 내부에 동일한 데이터를 포함하는 경우 그 둘을 동등한 객체로 간주해야 할 수도 있다.

fun main(){
    val customer1 = Customer("Alice", 342562)
    val customer2 = Customer("Alice", 342562)
    println(customer1 == customer2)
}

//false

자바에서는 ==를 기본 타입과 참조 타입을 비교할 때 사용한다.

기본 타입의 경우 ==는 두 피연산자의 값이 같은지 비교한다.

반면 참조 타입의 경우 두 피연산자의 주소가 같은지를 비교한다.

따라서 자바에서 두 객체의 동등성을 비교할 때는 equals를 사용해야 한다.

동등성 연산에 ==를 사용
코틀린에서 ==는 두 객체를 비교하는 기본적인 방법이다.
==는 내부적으로 equals를 호출해서 객체를 비교한다.
따라서 클래스가 equals를 오버라이드 하면 ==를 통해 안전하게
그 클래스의 인스턴스를 비교할 수 있다.
참조 비교를 위해서는 ===를 사용할 수 있다.
=== 연산자는 자바에서 객체의 참조를 비교하는 ==와 같다.

이제 equals를 추가한 Customer 클래스를 살펴보자.

코틀린에서 equals 함수는 Any 타입의 널이 될 수 있는 other 파라미터를 받는다.

Any는 모든 코틀린 클래스의 상위 클래스다.

class Customer(val name: String, val postalCode: Int) {
    override fun toString() = "Customer(name=$name, postalCode=$postalCode)"

    override fun equals(other: Any?): Boolean {
        if(other == null || other !is Customer)
			    return false
			    
        return name == other.name && postalCode == other.postalCode
    }
}

fun main(){
    val customer1 = Customer("Alice", 342562)
    val customer2 = Customer("Alice", 342562)
    println(customer1 == customer2) //true
}

Any는 java.lang.Object에 대응하는 클래스로, 코틀린의 모든 클래스의 최상위 클래스다.
Any?는 널이 될 수 있는 타입이므로 other는 null일 수 있다.

  1. 해시 컨테이너: hashCode()

자바에서는 equals를 오버라이드할 때 반드시 hsahCode도 함께 오버라이드해야 한다.

fun main(){
    val processed = hashSetOf(Customer("오현석", 4122))
    println(processed.contains(Customer("오현석", 4122))) //false
}

hashSet 안에 Customer 인스턴스를 넣었고 프로퍼티가 동일한 또 다른 Customer 인스턴스와 비교하는 코드다.

이 코드의 결과는 false가 출력된다. Customer 클래스에 hashCode 메서드를 정의하지 않았기 때문이다.

JVM 언어에서는 hashCode가 지켜야하는 “equals() 가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다.”라는 규칙이 있다.
하지만 Customer 클래스는 이를 어기고 있다.

HashSet은 원소를 비교할 때 비용을 줄이기 위해 객체의 해시 코드를 비교하고 해시 코드가 같은 경우에만 실제 값을 비교한다.

위의 코드는 두 Customer의 해시 코드가 다르기 때문에 두번째 인스턴스가 집합 안에 들어있지 않다고 판단한다.

이 문제를 고치려면 Customer에 hashCode를 구현해야 한다.

class Customer(val name: String, val postalCode: Int) {
    override fun toString() = "Customer(name=$name, postalCode=$postalCode)"

    override fun equals(other: Any?): Boolean {
        if(other == null || other !is Customer)
            return false
        return name == other.name && postalCode == other.postalCode
    }

    override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}

이제 main 함수는 잘 작동할 것이다.

하지만 코드가 지저분하다.

다행히 코틀린 컴파일러는 이 모든 메서드를 자동으로 생성해 줄 수 있다.


데이터 클래스: 모든 클래스가 정의해야 하는 메서드를 자동으로 생성

어떤 클래스가 데이터를 저장하는 역할만을 수행한다면 toString, equals, hashCode를 반드시 오버라이드해야 한다.

코틀린에서는 data라는 변경자를 클래스 앞에 붙이면 필요한 메서드를 컴파일러가 자동으로 만들어준다.

data 변경자가 붙은 클래스를 ‘데이터 클래스’ 라고 부른다.

data class Customer(val name: String, val postalCode: Int)

fun main(){
    val customer1 = Customer("Alice", 342562)
    println(customer1.toString()) //Customer(name=Alice, postalCode=342562)

    val customer2 = Customer("Alice", 342562)
    println(customer1 == customer2) //true

    val processed = hashSetOf(Customer("오현석", 4122))
    println(processed.contains(Customer("오현석", 4122))) //true
}

이 클래스는 자바에서 요구하는 모든 메서드를 포함한다.

  • 인스턴스간 비교를 위한 equals
  • HashMap과 같은 해시 기반 컨테이너에서 키로 사용할 수 있는 hashCode
  • 클래스의 각 필드를 선언 순서대로 표시하는 문자열 표현을 만들어주는 toString

equals와 hashCode는 주 생성자에 나열된 프로퍼티를 고려해 생성된다.

이 때 주 생성자 밖에 정의된 프로퍼티는 equals나 hashCode를 계산할 때 고려의 대상이 아니라는 점에 유의하자.

fun main(){
    val c1 = Customer("Sam", 11521)
    val c2 = Customer("Mart", 15500)
    val c3 = Customer("Sam", 11521)

    println(c1) //Customer(name=Sam, postalCode=11521)

    println(c1 == c2) //false
    println(c1 == c3) //true

    println(c1.hashCode()) //2580770
    println(c3.hashCode()) //2580770
}

코틀린 컴파일러는 data 클래스에 위에서 설명함 메서드 뿐만 아니라 몇 가지 유용한 메서드를 더 생성해준다.


데이터 클래스의 불변성: copy() 메서드

데이터 클래스의 프로퍼티는 var로 선언할 수 있지만 val로 선언해 읽기 전용으로 만들어 데이터 클래스를 불변 클래스로 만들라고 권장한다.

HashMap 등의 컨테이너에 데이터 클래스 객체를 담는 경우엔 불변성이 필수적이다.

데이터 클래스 객체를 키로 하는 값을 컨테이너에 담은 다음 키로 쓰인 객체의 프로퍼티를 변경하면 컨테이너 상태가 이상해질 수 있다.

게다가 불변 객체를 사용하면 프로그램에 대해 훨씬 쉽게 추론할 수 있다.

특히 다중 스레드 프로그램의 경우 이런 성질은 더 중요하다.

불변 객체를 주로 사용하는 프로그램에서는 스레드가 사용 중인 데이터를 다른 스레드가 변경할지 신경 쓸 필요가 없어진다.

데이터 클래스를 불변 객체로 더 쉽게 사용할 수 있도록 코틀린 컴파일러는 한 가지 편의 메서드를 제공한다.

그 메서드는 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해주는 copy 메서드다.

객체를 메모리에서 직접 바꾸는 대신 복사본을 만드는 것이다.

복사본은 원본과 다른 생면 주기를 가지며 복사를 하면서 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램에서 원본을 참조하는 다른 부분에 전혀 영향을 미치지 않는다.

Customer의 copy를 직접 구현한다면 다음과 같을 것이다.

fun copy(
        name: String = this.name,
        postalCode: Int = this.postalCode
    ) = Customer(name, postalCode)
//copy 메서드 사용 방법
fun main(){
    val lee = Customer("이계영", 4122)
    println(lee.copy()) //Customer(name=이계영, postalCode=4122)
} 

클래스 위임: by 키워드 사용

종종 상속을 허용하지 않는 클래스에게 새로운 동작을 추가해야 할 때가 있다.
이럴 때 사용하는 일반적인 방법이 데코레이터 패턴이다.

이 패턴의 핵심은 상속을 허용하지 않는 클래스 대신 사용할 수 있는 새로운 클래스를 만들되,
기존 클래스와 같은 인터페이스를 데코레이터가 제공하고 기존 클래스를 데코레이터 내부 필드로 유지하는 것이다.

이 때 새로 정의해야 하는 기능은 데코레이터의 메서드로 새로 정의하고 기존 기능이 그대로 필요한 부분은 데코레이터의 메서드가 기존 클래스의 메서드에게 요청을 전달한다.

이런 접근 방법의 단점은 준비 코드가 상당히 많다는 점이다.

class DelegatingCollection<T>: Collection<T> {
    private val innerList = arrayListOf<T>()

    override val size: Int get() = innerList.size
    override fun isEmpty(): Boolean = innerList.isEmpty()
    override fun contains(element: T): Boolean = innerList.contains(element)
    override fun iterator(): Iterator<T> = innerList.iterator()
    override fun containsAll(elements: Collection<T>): Boolean = innerList.containsAll(elements)
}

하지만 위임 기능을 사용하면 이런 준비 코드를 다 만들 필요가 없어진다.

by 키워드를 통해 구현을 다른 객체에 위임할 수 있다.

by 키워드를 사용해서 위의 코드를 재작성 해보자.

class DelegatingCollection<T>(
innerList: Collection<T> = mutableListOf<T>()
): Collection<T> by innerList

위임을 통해 컴파일러가 자동으로 만들어준 메서드 중 일부의 기능을 수정하고 싶다면 컴파일러가 만들어준 메서드를 오버라이드 하면 된다.

이 기법을 사용해서 원소를 추가하려 시도한 횟수를 기록하는 컬렉션을 구현해보자

class CountingSet<T>(
    private val innerSet: MutableCollection<T> = hashSetOf<T>()
): MutableCollection<T> by innerSet {

    var objectAdded = 0

    override fun add(element: T): Boolean {
        objectAdded++
        return innerSet.add(element)
    }

    override fun addAll(elements: Collection<T>): Boolean {
        objectAdded += elements.size
        return innerSet.addAll(elements)
    }
}

fun main() {
    val cset = CountingSet<Int>()
    cset.addAll(listOf(1, 1, 2))
    println("Added ${cset.objectAdded} objects, ${cset.size} uniques.")
}

add와 addAll을 오버라이드 해서 기능을 수정했다.

MutableCollection 인터페이스의 나머지 메서드는 innerSet에 위임했다.

이때 CountingSet과 MutableCollection 사이엔 의존관계가 생기지 않는다는 점이 중요하다.

CountingSet 안에서 발생하는 일은 CountingSet 안에서 마음대로 제어할 수 있다.

내부 클래스가 MutableCollection의 문서화된 API를 변경하지 않는 한 정상 작동 할 것이다.

0개의 댓글