모든 클래스가 정의해야 하는 메서드
모든 클래스가 정의해야 하는 메서드는 다음과 같다. toString, equals, hashCode
위 메서드들은 사실 자바부터 이어져온 것으로, 자바는 IDE에서 이를 자동으로 만들어준다. 하지만, 사람이 직접 생성하지 않는다고 하여도, 코드베이스가 번잡해진다는 면이 있을 것이다.
코틀린은 여기서 한 걸음 더 나아가 아예 이러한 메서드들을 자동으로 생성하는 동시에 숨긴다. 때문에 이를 자유롭게 호출하거나 오버라이드도 가능하다!
클래스 인스턴스의 문자열 표현 얻기: toString()
코틀린 클래스에서는 기본 구현된 toString을 이용하여 클래스 이름과 객체 주소를 얻어낼 수 있다. 이는 디버깅이나 로깅 시 사용될 수도 있지만, 실제로 인스턴스의 정보를 얻어내기엔 약간 부족할 수 있다.
class Customer(val name: String, val postalCode: Int) {
override fun toString() = "Customer(name=$name, postalCode=$postalCode)"
}
그러므로 위와 같이 toString() 함수를 오버라이드 하여 객체의 정보를 좀 더 잘 나타낼 수 있게 바꿀 수 있다!
객체 간의 동등성 검사하기: equals()
class Customer(val name: String, val postalCode: Int)
fun main() {
val customer1 = Customer("Alice", 342562)
val customer2 = Customer("Alice", 342562)
println(customer1 == customer2)
}
코틀린에서 "=="는 내부적으로 equals() 를 호출한다. 또한 위 코드에서 두 객체의 값은 서로 같지만, 기본 구현된 equals()를 호출하게 되면 false, 두 객체가 같지 않다고 나온다. 이는 두 객체의 주소가 다르기 때문일 것이다.
이를 위한 해결방법은 여러가지가 있겠지만, 데이터 클래스 사용, equals() 오버라이드 등이 있을 것이다. 우선은 equals() 오버라이드로 해결하면 다음과 같이 작성해볼 수 있을 것이다.
class Customer(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean {
if (other == null || other !is Customer)
return false
return name == other.name &&
postalCode == other.postalCode
}
override fun toString() = "Customer(name=$name, postalCode=$postalCode)"
}
이제 프로퍼티의 값이 같을 때 true를 반환하는 equals() 메서드가 구현이 완료되었다! 라고 생각했었으나, 아직 다 끝난 것이 아니었다..
해시코드 기반의 비교가 들어가는 곳에서는 다시 false가 나온다
fun main() {
val processed = hashSetOf(Customer("Alice", 342562))
println(processed.contains(Customer("Alice", 342562)))
}
위 코드는 해시셋에 인스턴스를 생성한 뒤에 다시 외부에서 새로운 인스턴스를 생성한 뒤에 해시셋에 포함되어있는지 여부를 검사하는 코드이다.
이 코드는 결과적으로 false가 나오게 되는데, 이는 두 객체가 해시값이 서로 다르기 때문에 발생하는 문제이다.
JVM 언어에는 다음과 같은 규칙이 존재한다고 한다.
equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다.
class Customer(val name: String, val postalCode: Int) {
/* ... */
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}
때문에 위와 같이 hashCode() 메서드를 오버라이드 하면, 객체를 생성할 때마다 생성자의 파라미터에 같은 값을 전달해주게 된다면, 같은 해시코드가 생성될 것이다.
약간의 고찰
이렇게까지 해야하는 이유는 무엇일까? 사실 나도 처음에는 굳이 해시코드까지 맞춰야하는지 의문이 들었다. 하지만 이는 일관성
측면에서 당연한 조치로 생각된다.
개발자가 이 클래스는 객체의 프로퍼티의 값이 같으면 동일한 객체로 간주하고 싶다고 생각하여 equals() 메서드를 오버라이드 하였을 때, 분명 어느 곳에서든 프로퍼티의 값이 같으면 동일한 객체로 간주되게 하고 싶을 것이다.
하지만, 각 객체마다 해시코드가 다르므로, 내부적으로 해시코드를 사용하는 곳에서는 프로퍼티 값이 같아도 같은 객체로 인식하지 못하는 상황이 벌어진다. 이는 엄연히 개발자의 의도와 다른 동작일 것이다.
결론적으로 equals()와 hashCode()는 함께 오버라이드 한다고 생각하는 것이 좋을 것 같다.
이전까지의 작업을 모두 자동으로 해주는 코틀린의 데이터 클래스
사실 지금까지의 복잡한 과정이 간결한 코틀린 언어의 철학과는 약간은 어울리지 않다고 생각할 수 있다. 코틀린에서는 toString(), equals(), hashCode()를 자동으로 알맞게 만들어 준다. 물론 이는 데이터를 저장하는 역할만 수행하는 데이터 클래스에 한정된다.
data class Customer(val name: String, val postalCode: Int)
이제 이 클래스는 방금 했던 모든 메서드들의 오버라이드를 포함한다! 심지어 추가적으로 유용한 메서드도 생성해준다.
불변 객체를 쉽게 사용할 수 있게 하는 copy() 메서드
데이터 클래스의 프로퍼티는 여느 클래스의 프로퍼티처럼 val, var 둘다 사용 가능하다. 하지만 코틀린의 철학에서 알 수 있듯이 클래스의 모든 프로퍼티를 읽기 전용, 즉 val로 만들어서 데이터 클래스를 불변 객체로 만드는 것이 바람직하다고 할 수 있을 것이다.
말이 앞뒤가 안맞긴 하지만, 불변 객체의 프로퍼티를 수정하려면 어떻게 해야할까? val을 var로 바꿔야 할까? 정답은 바로 바꾸려는 값을 갖는 새로운 불변 객체를 만들면 된다!
이에 대해서는 좀 더 자세히 조사하여 정리하려고 한다.
fun main() {
val bob = Customer("Bob", 973293)
println(bob.copy(postalCode = 382555))
}
코틀린의 데이터 클래스에서는 이를 위해서 copy() 메서드를 제공한다. 이를 사용하면, 객체를 복사하면서 일부 프로퍼티만 바꿀 수 있다!
돌아보니, 나 또한 우테코 프리코스 자동차 경주에서 Car() 객체를 불변 객체로 선언하고 copy()를 통해 값을 수정했었던 기억이 있다. 그때 당시에는 사실 쓰면서도 과연 불변 객체를 객체를 계속 복사하는 비용을 지불하면서까지 사용해야 할까? 라는 고민을 했었는데, 이번에 코틀린에 대해 제대로 공부하면서 불변성의 중요성에 대해 정리하고 나니, 이에 대한 의문이 조금 해소된 기분이다.
데코레이터 패턴
이 부분에서 코틀린이 왜 기본적으로 클래스를 final로 취급하는지에 대한 내용이 나온다. 하위 클래스가 상위 클래스의 메서드 중 일부를 오버라이드 한다고 했을 때, 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하게 된다.
즉 상위 클래스의 구현이 바뀌면, 하위 클래스에서는 기존의 예상된 동작을 하지 않을 가능성이 크다. 이로 인해 final로 일단 상속을 막아놓고, 상속을 염두에 둔 설계만 open을 사용하라 라는 의도였던 것이다.
그렇다면 상속을 허용하지 않는 클래스는 아예 확장을 막아놓은 것인가? 라고 하기엔 다른 방법이 있다.
만약 상속을 허용하지 않는 클래스에게 새로운 동작을 추가해야하고 싶다면, 데코레이터(decorator) 패턴을 사용할 수 있다.
데코레이터 패턴의 핵심은 기존 클래스(상속을 제한한 final 클래스) 대신 새로운 클래스를 만들되, 기존 클래스와 같은 인터페이스를 데코레이터가 제공하고, 기존 클래스를 데코레이터 내부 필드로 유지하는 것이다.
새로운 기능은 데코레이터의 메서드로 새로 정의 하는 방식으로 추가할 수 있다.
하지만 이 방법은 결국 새로운 클래스, 인터페이스를 만들어야 하기 때문에 IDE에서 자동 생성을 지원한다 하더라도, 준비코드가 상당히 길어진다.
때문에 코틀린에서는 다음과 같은 기능을 제공한다.
클래스 위임: by 키워드 사용
코틀린에서는 인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다!
class CountingSet<T>(
private val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet {
var objectsAdded = 0
override fun add(element: T): Boolean {
objectsAdded++
return innerSet.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
objectsAdded += elements.size
return innerSet.addAll(elements)
}
}
fun main() {
val cset = CountingSet<Int>()
cset.addAll(listOf(1, 1, 2))
println("Added ${cset.objectsAdded} objects, ${cset.size} uniques.")
}
클래스 선언과 함께 객체 선언하기
코틀린에서는 object 키워드가 다음과 같은 상황에서 쓰인다.
1. 싱글턴 객체 선언(object declaration)
2. 동반 객체 선언(companion object)
3. 객체 식(자바의 익명 내부 클래스 대신 사용)
1. 싱글턴 객체 만들기
객체지향 시스템을 설계하다보면 인스턴스가 하나만 필요한 클래스가 유용한 경우가 많다, 자바에서는 생성자를 private으로 제한하고, 정적인 필드에 그 클래스의 유일한 객체를 저장함으로써 싱글턴 패턴을 구현한다.
하지만 코틀린에서는 이를 언어 차원에서 지원한다.
object CaseInsensitiveFileComparator : Comparator<File> {
override fun compare(file1: File, file2: File): Int {
return file1.path.compareTo(file2.path,
ignoreCase = true)
}
}
위와 같이 object 키워드를 붙여 싱글턴 객체를 만들 수 있다. 당연한 것이지만, 싱글턴 객체는 단 하나만 만들어지기 때문에 생성자를 사용할 수 없다.
2. 동반 객체 선언
동반 객체는 클래스 내부에 있는 객체 중 하나에 companion이라는 표시를 붙인 객체이다. 이는 마치 자바의 정적 멤버를 대신한다.
class A {
companion object {
fun bar() {
println("Companion object called")
}
}
}
fun main() {
A.bar()
}
주의할 점은 동반 객체 멤버는 자바의 정적 멤버와는 달리 자신에 대응하는 클래스에 속한다는 점이다. 때문에 클래스의 인스턴스는 동반객체의 멤버에 접근할 수 없다.
위의 예제에서도 클래스 A에서 직접 접근하는 것을 확인할 수 있다.
3. 객체 식
익명 객체를 정의할 때에도 object 키워드를 사용하여 정의하게 된다. 익명 객체의 가장 흔한 사용 예는 이벤트 처리이다.
interface MouseListner {
fun onEnter()
fun onClick()
}
class Button(private val listner: MouseListner) { /*...*/ }
이러한 인터페이스와 클래스가 있을 때, 익명 객체를 사용하여 MouseListener를 구현하고 Button 생성자에 넘겨줄 수 있다!
fun main() {
Button(object : MouseListener) {
override fun onEnter() { /*...*/ }
override fun onClick() { /*...*/ }
})
}
'Kotlin' 카테고리의 다른 글
| Kotlin in action 2/e: 람다를 사용한 프로그래밍(2) 함수형 인터페이스, 수신 객체 지정 람다 (0) | 2026.02.15 |
|---|---|
| Kotlin in action 2/e: 람다를 사용한 프로그래밍(1) 코틀린의 람다 (0) | 2026.02.14 |
| Kotlin in action 2/e: 클래스, 객체, 인터페이스(1) 인터페이스, 클래스, 생성자 (0) | 2026.02.09 |
| Kotlin in action 2/e: 함수 정의와 호출(2) 로컬 함수, 코드를 확장 함수로 추출하기 (0) | 2026.02.09 |
| Kotlin in action 2/e: 함수 정의와 호출(1) joinToString() 함수 직접 구현 (0) | 2026.02.07 |