본문 바로가기

코틀린

코틀린(Kotlin) - 데이터 클래스와 클래스 위임(by)

자바와 마찬가지로 코틀린 클래스도 toString, equals, hashCode 등을 오버라이드 할 수 있고, 코틀린은 이런 메소드 구현을 자동으로 생성해줄 수 있습니다.

데이터 클래스

어떤 클래스에 대해 toString, equals, hashCode를 수행하려면 반드시 오버라이드해야 합니다.

다행히도 인텔리J IDE는 자동으로 이런 메소드를 정의해주기도 합니다.

하지만, 코틀린은 더 편리합니다!

이제 이런 메소드를 IDE를 통해 생성할 필요도 없이 "data" 라는 키워드를 클래스 앞에 붙이면 위와 같이 필요한 메소드들을 컴파일러가 자동으로 만들어줍니다! 

이렇게 "data" 가 붙은 클래스를 "데이터 클래스" 라고 부릅니다.

 

/** Client를 데이터 클래스로 선언하기 */
data class Client(val name: String, val age: Int)

 

엄청나게 간단하지 않나요?!

이제 이 Client 클래스는 자바에서 요구하는 모든 메소드를 포함하게 됩니다.

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

이러한 메소드 외에도, 데이터 클래스에는 몇 가지 유용한 메소드들이 더 생성됩니다.

데이터 클래스와 불변성 : copy() 메소드

데이터 클래스의 프로퍼티가 반드시 val일 필요는 없습니다.

원한다면 var 프로퍼티로 선언해도 됩니다.

 

하지만, 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변(immutable)클래스로 만들라고 권장합니다.

주로, 멀티쓰레드 프로그램의 경우 이런 규칙이 더 중요합니다.

이유는, 쓰레드가 사용 중인 데이터를 다른 쓰레드가 변경할 수 없으며, 이로 인해 쓰레드를 동기화해야 할 필요가 줄어들기 때문입니다.

 

데이터 클래스 인스턴스를 불변 객체로 더 쉽게 활용할 수 있도록 코틀린 컴파일러는 편의 메소드를 제공해줍니다.

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

 

객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 편이 더 나으며, 복사본은 원본과 다른 생명주기를 갖고,

복사를 하면서 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램에서 원본을 참조하는 다른 부분에 전혀 영향을 끼치지 않기 때문입니다.

 

/** copy 메소드 사용 방법 */
>>> val client = Client("자바", 30)
>>> println(client.copy(name="코틀린"))
Client(name=코틀린, age=30)

 

클래스 위임(Class Delegation) : by 키워드 사용

대규모 객체지향 시스템을 설계할 때 시스템을 취약하게 만드는 문제는 보통 구현 상속(implementation inheritance)에 의해 발생합니다.

하위 클래스가 상위 클래스의 메소드 중 일부를 오버라이드하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존적이게 됩니다.

시스템이 변함에 따라 상위 클래스의 구현이 바뀌거나, 상위 클래스에 새로운 메소드가 추가되는 과정에서, 하위 클래스와 상위 클래스의 요구사항이 서로 맞지 않아, 코드가 정상적으로 실행되지 않는 경우가 발생할 수 있습니다.

 

그렇기 때문에, 코틀린의 설계는 기본적으로 클래스를 final로 취급하는 이유입니다.

 

모든 클래스를 기본적으로 final로 취급하면, 상속에 필요한 클래스만 open 으로 열어두어 상속을 할 수가 있습니다.

이렇게 되면, 나중에 코드를 수정할 때 open이 붙은것을 보고 해당 클래스를 다른 클래스가 상속하고 있구나 예상하며 코드 변경 시, 하위 클래스에 영향이 가지 않도록 좀 더 조심을 할 수 있기 때문입니다.

 

하지만! 우리에겐 언제나 예외가 존재하듯이, 상속을 허용하지 않은 클래스에 새로운 동작을 추가해야 할 때가 생기기도 합니다.

[중요치 않음] 이럴 때 사용하는 일반적인 방법이 데코레이터(Decorator) 패턴인데, 이 패턴에 대해 간단히 설명드리자면,

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

이때 새로운 동작을 추가하는 경우에는 새로운 클래스의 메소드로 새로 정의하고, 기존 기능이 그대로 필요한 부분은 기존 클래스의 메소드를 호출하는 방식입니다.

하지만, 굉장히 복잡하고, 준비 코드가 상당히 많이 필요하다고 느끼셨을겁니다...!

 

그렇기 때문에, 우리의 코틀린은 이런 기능을 간편하게 지원해줍니다!

 

인터페이스를 구현할 때 "by" 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 것을 명시할 수 있습니다.

 

/** 일반적인 데코레이터 패턴 방식 */
class NewCollection<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)
    ...
    ...
}

/** 코틀린 by 키워드 사용 */
class NewCollection<T>(
   val innerList: Collection<T> = ArrayList<T>()
) : Collention<T> by innerList { }

 

클래스 안에 있던 모든 메소드 정의가 싹~ 사라졌습니다!

이제 혹시 메소드를 변경하고 싶은 경우 오버라이드해서 구현하면, 컴파일러가 알아서 기존 메소드 대신 새로 오버라이드한 메소드로 쓰이게 됩니다.

물론, 기존과 동일한 기능으로 충분한 메소드들은 오버라이드 할 필요가 없습니다. 기존껄 그대로 쓰면 되기 때문이죠~

 

/** 코틀린 by 키워드 사용하여 메소드 동작 변경하기 */
class NewCollection<T>(
   val innerList: Collection<T> = ArrayList<T>()
) : Collention<T> by innerList {

   override fun isEmpty(): Boolean {
      println("리스트가 비었군")
      innerList.isEmpty()
   }
   
}

 

이때 중요한 점은! 새로운 클래스는 기존 클래스의 구현 방식에 대해 의존관계가 생기지 않는다는 점이 제일 중요합니다!

기존 클래스의 메소드 구현을 바꾸더라도, 새로운 클래스는 전혀 영향없이 자기에 맞는 구현을 사용하기 때문에, 서로 다른 방식의 구현을 하면서도 의존관계 없이 독립적으로 재정의하여 사용할 수 있습니다.