[kotlin] Companion Object (1) – 자바의 static과 같은 것인가?

개요top

코틀린(Kotlin)Companion object는 단순히 자바(Java)의 static 키워드를 대체하기 위해서 탄생했을까요? 이 갑작스러운 질문은 코틀린에서 왜 static을 안 쓰게 되었는지 이해하는 데 큰 도움이 될 수 있습니다.

자바의 static 키워드는 클래스 멤버(member)임을 지정하기 위해 사용합니다. static이 붙은 변수와 메소드를 각각 클래스 변수, 클래스 메소드라 부릅니다. 반면, static이 붙지 않은 클래스 내의 변수와 메소드는 각각 인스턴스 변수, 인스턴스 메소드라 합니다. static이 붙은 멤버는 클래스가 메모리에 적재될 때 자동으로 함께 생성되므로 인스턴스 생성 없이도 클래스명 다음에 점(.)을 쓰면 바로 참조할 수 있습니다.

public final class MyClass{
  static public final String TEST = "test";  //클래스 변수
  static public method(int i):int{     //클래스 메소드
    return i + 10
  }
}

System.out.println(MyClass.TEST);		//test
System.out.println(MyClass.method(1)); 	//11

자바(Java) 개발 경험이 있는 사람들에게는 코틀린(Kotin)에 static이 없다는 사실에 당황할 수 있습니다. 대신 코틀린은 companion object라는 키워드와 함께 블록({}) 안에 멤버를 구성합니다.

class MyClass{
    companion object{
        val TEST = "test"
        fun method(i:Int) = i + 10
    }
}
fun main(args: Array<String>){
    println(MyClass.TEST);		//test
    println(MyClass.method(1)); 	//11
}

여기까지만 보면 단순히 static 키워드 대신 companion object 블록으로 대체한 느낌만 듭니다. 그러면 아래처럼 static 키워드를 써도 문제없다고 판단할지도 모르겠습니다.

class MyClass{
    static val TEST = "test"
    static fun method(i:int) = i + 10
}
fun main(args: Array<String>){
    println(MyClass.TEST);		//test
    println(MyClass.method(1)); 	//11
}

코틀린은 static을 버리고 companion object를 도입했으며 더욱 명쾌하고 멋진 방법으로 문제를 해결하는 데 도움을 주도록 했습니다. 이 글은 companion object와 static의 차이점을 이해하고 static의 한계를 파악하는 동시에 companion object 학습에 도움을 주는 데 목적이 있습니다.

코틀린의 class 키워드 기초top

companion object를 다루기 전에 먼저 코틀린의 class와 object를 먼저 이해해야 할 것 같습니다. 사실 이 주제만으로도 엄청난 분량이지만 여기서는 간단하게 사용법만 소개합니다.

class WhoAmI(private val name:String){
    fun myNameIs() = "나의 이름은 ${name}입니다."
}
fun main(args: Array<String>){
    val m1 = WhoAmI("영수")
    val m2 = WhoAmI("미라")
    println(m1.myNameIs()) //나의 이름은 영수입니다.
    println(m2.myNameIs()) //나의 이름은 미라입니다.
}

클래스 WhoAmI를 정의하면서 생성자의 인자로 val name:String을 받습니다. 자바에 익숙하나 코틀린을 처음 접한 분에게는 이게 무슨 생성자인가 생각이 들 수 있습니다. WhoAmI클래스를 자바 개발자가 이해하기 쉽게 풀어 쓰면 다음과 같습니다.

class WhoAmI{
    private val name:String
    constructor(name:String){
        this.name = name
    }
    fun myNameIs() = "나의 이름은 ${name}입니다."
}

위처럼 클래스 생성자는 constructor 키워드를 써서 정의합니다. 그리고 생성자에서 받은 인자 name:String의 값을 속성인 this.name을 통해 private val name:String에 할당하고 있습니다. 이 코드는 자세히 보면 너무 중복이 많습니다. 그래서 코틀린 언어 설계자는 이를 단순화했습니다. 즉, 클래스를 정의하자마자 바로 생성자 및 속성까지 정의한 것입니다. 덕분에 코틀린으로 코드를 짜면 자바보다 훨씬 짧아집니다(이것 때문만은 아니지만 코틀린으로 코딩하면 경험상 코딩량이 최소 50% 이상 줄어드는 것 같습니다. 아니면 그보다 더….).

각설하고, 여기서 중요한 것은 클래스로부터 객체를 생성하기 위해 val m1 = WhoAmiI("영수")처럼 한다는 점입니다. 자바는 객체 생성을 위해 new 키워드를 쓰지만 코틀린에서는 new 키워드 없이 클래스 명 뒤에 괄호()를 붙인다는 점을 기억하세요(코틀린에서 new를 쓰지 않는다는 점이 과연 무슨 의미인지 생각해보는 것도 나쁘지 않을 것 같습니다). 마치 함수 호출처럼요.

코틀린의 object 키워드 기초top

코틀린에는 자바에 없는 독특한 싱글턴(singletion; 인스턴스가 하나만 있는 클래스) 선언 방법이 있습니다. 아래처럼 class 키워드 대신 object 키워드를 사용하면 됩니다.

object MySingleton{
    val prop = "나는 MySingleton의 속성이다."
    fun method() = "나는 MySingleton의 메소드다."
}
fun main(args: Array<String>){
    println(MySingleton.prop);		//나는 MySingleton의 속성이다.
    println(MySingleton.method()); 	//나는 MySingleton의 메소드다.
}

object는 특정 클래스나 인터페이스를 확장(var obj = object:MyClass(){}또는 var obj = object:MyInterface{})해 만들 수 있으며 위처럼 선언문이 아닌 표현식(var obj = object{})으로 생성할 수 있습니다. 싱글톤이기 때문에 시스템 전체에서 쓸 기능(메소드로 정의)을 수행하는 데는 큰 도움이 될 수 있지만, 전역 상태를 유지하는 데 쓰면 스레드 경합 등으로 위험할 수 있으니 주의해서 사용해야 합니다.

언어 수준에서 안전한 싱글턴을 만들어 준다는 점에서 object는 매우 유용합니다.

이 글은 object가 아닌 companion object를 다룰 것이므로 이 정도만 소개하는 것으로 마무리 짓겠습니다. 앞서 미리 말씀드린 companion object는 클래스 내부에 정의되는 object의 특수한 형태입니다. 이제 companion object를 자세히 알아봅시다.

Companion object는 static이 아닙니다.top

사실 코틀린 companion object는 static이 아니며 사용하는 입장에서 static으로 동작하는 것처럼 보일 뿐입니다. 다음 코드를 보세요.

class MyClass2{
    companion object{
        val prop = "나는 Companion object의 속성이다."
        fun method() = "나는 Companion object의 메소드다."
    }
}
fun main(args: Array<String>) {
    //사실은 MyClass2.맴버는 MyClass2.Companion.맴버의 축약표현이다.
    println(MyClass2.Companion.prop)
    println(MyClass2.Companion.method())
}

MyClass2 클래스에 companion object를 만들어 2개의 멤버를 정의했습니다. 이를 사용하는 main() 함수를 보면 이 멤버에 접근하기 위해 클래스명.Companion 형태로 쓴 것을 확인할 수 있습니다. 이로써 유추할 수 있는 것은 companion object{}MyClass2 클래스가 메모리에 적재되면서 함께 생성되는 동반(companion)되는 객체이고 이 동반 객체는 클래스명.Companion으로 접근할 수 있다는 점입니다(클래스와 동반자라고 하면 정감이 가려나요?).

fun main(args: Array<String>) {
    //사실은 MyClass2.맴버는 MyClass2.Companion.맴버의 축약표현이다.
    println(MyClass2.prop)
    println(MyClass2.method())
}

위 코드에서 MyClass2.propMyClass2.method()MyClass2.Companion.propMyClass2.Companion.method() 대신 쓰는 축약 표현일 뿐이라는 점을 이해해야 합니다. 언어적으로 지원하는 축약 표현 때문에 companion object가 static으로 착각이 드는 것입니다.

Companion object는 객체입니다.top

Companion object에서 기억해야 할 중요한 점은 객체라는 것입니다. 그래서 다음과 같은 코딩이 가능해 집니다.

class MyClass2{
    companion object{
        val prop = "나는 Companion object의 속성이다."
        fun method() = "나는 Companion object의 메소드다."
    }
}
fun main(args: Array<String>) {
    println(MyClass2.Companion.prop)
    println(MyClass2.Companion.method())

    val comp1 = MyClass2.Companion  //--(1)
    println(comp1.prop)
    println(comp1.method())

    val comp2 = MyClass2  //--(2)
    println(comp2.prop)
    println(comp2.method())
}

위 코드에서 주석 (1)을 확인해 주세요. companion object는 객체이므로 변수에 할당할 수 있습니다. 그리고 할당한 변수에서 점(.)으로 MyClass2에 정의된 companion object의 맴버에 접근할 수 있습니다. 이렇게 변수에 할당하는 것은 자바의 클래스에서 static 키워드로 정의된 멤버로는 불가능한 방법입니다.

위 코드에서 주석 (2)는 .Companion을 빼고 직접 MyClass2로 할당한 것입니다. 이것도 또한 MyClass2에 정의된 companion object입니다. 위에서 MyClass2.Companion.prop 대신 MyClass2.prop 로 해도 같다는 점을 생각해보면 쉽게 가능함을 유추할 수 있습니다. 꼭 기억해야 할 것은 클래스 내 정의된 companion object는 클래스 이름만으로도 참조 접근이 가능합니다. 이 특징은 코틀린 설계자의 신의 한 수인지도 모릅니다(왜일까요? ^^).

여기서 알 수 있듯이 static 키워드만으로는 클래스 멤버를 companion object처럼 하나의 독립된 객체로 여겨질 수 없겠죠? 이것도 또한 static과 큰 차이점이기도 합니다.

Companion object에 이름을 지을 수 있습니다.top

companion object의 기본 이름은 Companion입니다. 앞서 MyClass2.Companion.prop처럼 사용할 수 있음을 기억해 보시면 이해가 됩니다. 이 이름은 바꿀 수 있습니다.

class MyClass3{
    companion object MyCompanion{  // -- (1)
        val prop = "나는 Companion object의 속성이다."
        fun method() = "나는 Companion object의 메소드다."
    }
}
fun main(args: Array<String>) {
    println(MyClass3.MyCompanion.prop) // -- (2)
    println(MyClass3.MyCompanion.method())

    val comp1 = MyClass3.MyCompanion // -- (3)
    println(comp1.prop)
    println(comp1.method())

    val comp2 = MyClass3 // -- (4)
    println(comp2.prop)
    println(comp2.method())
    
    val comp3 = MyClass3.Companion // -- (5) 에러발생!!!
    println(comp3.prop)
    println(comp3.method())
}

위 코드에서 주석 (1)을 확인해 주세요. companion object 이름을 MyCompanion으로 지었음을 확인하세요. 주석 (2), (3)을 보시면 이제 기본 이름인 Companion 대신 MyCompanion을 사용할 수 있습니다.

그러나 여전히 주석 (4)를 보시면 생략할 수 있습니다.

하지만 주석 (5) 처럼 기존 이름인 Companion을 쓰면 Unresolved reference: Companion 에러가 납니다.

val comp4:MyClass3.MyCompanion = MyClass3
println(comp4.prop)
println(comp4.method())

위 코드처럼 타입 추론(참고로 코틀린은 타입 추론 때문에 코드량이 급격히 줄어듭니다)을 사용하지 않고 명시적으로 타입을 정하면 MyClass3의 결과는 MyClass3.MyCompanion이기 때문에 이렇게 사용해도 컴파일 에러 없이 정상 동작할 수 있음을 유추할 수 있습니다.

클래스내 Companion object는 딱 하나만 쓸 수 있습니다.top

클래스 내에 2개 이상 companion object를 쓰는 것은 안 됩니다. 지금까지 학습한 것을 유추해 보면 당연한 건데 코틀린은 클래스 명만으로 companion object 객체를 참조할 수 있기 때문에 한 번에 2개를 참조하는 것은 애초부터 불가능한 것이지요.

class MyClass5{
    companion object{
        val prop1 = "나는 Companion object의 속성이다."
        fun method1() = "나는 Companion object의 메소드다."
    }
    companion object{ // -- 에러발생!! Only one companion object is allowed per class
        val prop2 = "나는 Companion object의 속성이다."
        fun method2() = "나는 Companion object의 메소드다."
    }
}

위처럼 만들면 Only one companion object is allowed per class 에러가 발생할 것입니다.

class MyClass5{
    companion object MyCompanion1{
        val prop1 = "나는 Companion object의 속성이다."
        fun method1() = "나는 Companion object의 메소드다."
    }
    companion object MyCompanion2{ // --  에러발생!! Only one companion object is allowed per class
        val prop2 = "나는 Companion object의 속성이다."
        fun method2() = "나는 Companion object의 메소드다."
    }
}

위 코드처럼 companion object 이름을 별도로 부여해도 마찬가지입니다.

덕분에 자바에서 static 멤버를 클래스에 아무 데나 쓰는 게 제약이 없었다면 코틀린에서는 자동으로 한곳에 모이게 됩니다. 물론 이 목적으로 companion object를 한 개만 있도록 코틀린이 설계된 것은 아닙니다.

인터페이스 내에도 Companion object를 정의할 수 있습니다.top

코틀린 인터페이스 내에 companion object를 정의할 수 있습니다. 덕분에 인터페이스 수준에서 상수항을 정의할 수 있고, 관련된 중요 로직을 이곳에 기술할 수 있습니다. 이 특징을 잘 활용하면 설계하는 데 도움이 될 것입니다.

interface MyInterface{
    companion object{
        val prop = "나는 인터페이스 내의 Companion object의 속성이다."
        fun method() = "나는 인터페이스 내의 Companion object의 메소드다."
    }
}
fun main(args: Array<String>) {
    println(MyInterface.prop)
    println(MyInterface.method())

    val comp1 = MyInterface.Companion
    println(comp1.prop)
    println(comp1.method())

    val comp2 = MyInterface
    println(comp2.prop)
    println(comp2.method())
}

이제 위 코드가 이해되시죠? 완벽히 이해가 안 되면 다시 위에서부터 차근차근 읽으세요.

상속 관계에서 Companion object 멤버는 같은 이름일 경우 가려집니다(섀도잉, shadowing).top

부모 클래스를 상속한 자식 클래스에 모두 companion object를 만들고 같은 이름의 멤버를 정의했다고 가정합니다. 이때, 자식 클래스에서 이 멤버를 참조하면 부모의 멤버는 가려지고 자식 자신의 멤버만 참조할 수 있습니다. 말이 어려우니 코드로 이해해 보겠습니다.

open class Parent{
    companion object{
        val parentProp = "나는 부모값"
    }
    fun method0() = parentProp
}
class Child:Parent(){
    companion object{
        val childProp = "나는 자식값"
    }
    fun method1() = childProp
    fun method2() = parentProp
}
fun main(args: Array<String>) {
    val child = Child()
    println(child.method0()) //나는 부모값
    println(child.method1()) //나는 자식값
    println(child.method2()) //나는 부모값
}

위 코드처럼 부모/자식의 companion object의 멤버가 다른 이름이라면 자식이 부모의 companion object 멤버를 직접 참조할 수 있습니다. 하지만 같은 이름이면 어떻게 될까요? 다음 코드를 봅시다.

open class Parent{
    companion object{
        val prop = "나는 부모"
    }
    fun method0() = prop //Companion.prop과 동일
}
class Child:Parent(){
    companion object{
        val prop = "나는 자식"
    }
    fun method1() = prop //Companion.prop 과 동일
}
fun main(args: Array<String>) {
    println(Parent().method0()) //나는 부모
    println(Child().method0()) //나는 부모
    println(Child().method1()) //나는 자식

    println(Parent.prop) //나는 부모
    println(Child.prop) //나는 자식

    println(Parent.Companion.prop) //나는 부모
    println(Child.Companion.prop) //나는 자식
}

위 코드에서 부모/자식 모두 자신의 companion object의 prop의 값을 반환하고 있습니다. 같은 이름(prop)을 사용했다는 것을 확인하세요. main() 함수의 결과를 보면 자식클래스의 companion object 속성인 prop이 부모에서 정의 되어 있지만 가려져서 무시되는 것을 볼 수 있습니다.

여기서 조금 헷갈릴 수 있는 부분은 Child().method0()의 결과일 것입니다. method0() 메소드는 Parent클래스 것입니다. 그래서 부모의 companion object의 prop값인 “나는 부모”가 출력되었습니다.

이제 위 코드를 조금 변경해 보겠습니다. 자식클래스의 companion object에 이름을 부여합니다.

open class Parent{
    companion object{
        val prop = "나는 부모"
    }
    fun method0() = prop
}
class Child:Parent(){
    companion object ChildCompanion{ // -- (1) ChildCompanion로 이름을 부여했어요.
        val prop = "나는 자식"
    }
    fun method1() = prop
    fun method2() = ChildCompanion.prop
    fun method3() = Companion.prop
}
fun main(args: Array<String>) {
    val child = Child()
    println(child.method0()) //나는 부모
    println(child.method1()) //나는 자식
    println(child.method2()) //나는 자식
    println(child.method3()) // -- (2)
}

위 코드에서 주석 (1)에 자식 클래스의 companion object에 ChildCompanion로 이름을 부여했습니다. 그리고 자식 클래스에 3개의 메소드를 정의했습니다. child.method0()은 부모의 method이므로 어렵지 않게 “나는 부모”가 출력된 것을 예상할 수 있습니다. 그리고 child.method1()child.method2()도 역시 자식의 companion object의 속성을 가리킨다는 것을 알 수 있습니다.

이제 진짜 문제인데, 주석(2)는 어떤 결과가 나올까요? 답부터 말씀드리면 “나는 부모”입니다. 자식의 companion object를 뛰어넘어서 부모의 companion object에 직접 접근이 가능하게 되었습니다. fun method3() = Companion.prop에서 Companion.prop를 하면 이때 Companion은 자식 것이 아닙니다. 왜냐하면 자식은 ChildCompanion로 이름을 바꿨으니깐요. 그래서 여기서 Companion은 부모가 됩니다.

아래 코드에서는 부모의 companion object마저 이름을 붙여봅니다.

open class Parent{
    companion object ParentCompanion{ // -- (1) ParentCompanion로 이름을 부여했어요.
        val prop = "나는 부모"
    }
    fun method0() = prop
}
class Child:Parent(){
    companion object ChildCompanion{
        val prop = "나는 자식"
    }
    fun method1() = prop
    fun method2() = ChildCompanion.prop
    fun method3() = Companion.prop // -- (2) Unresolved reference: Companion 에러!!
}

위 코드에서 주석(1)을 보시면 부모 companion object의 이름을 ParentCompanion로 했습니다. 그러자마자 주석(2) 부분은 컴파일 에러가 납니다. 에러가 안 날려면 fun method3() = Companion.prop이 아닌 fun method3() = ParentCompanion.prop 바꿔야 합니다.

이 실험을 통해 부모/자식의 companion object에 정의된 멤버는 자식 입장에서 접근할 수 있지만, 같은 이름을 쓰면 섀도잉(shadowing) 되어 감춰진다는 점을 알 수 있었습니다.

다형성 문제top

companion object 문제가 아닌 코드를 보겠습니다. 이 글의 보너스 파트 정도로 봐주세요.

open class Parent{
    companion object{
        val prop = "나는 부모"
    }
    open fun method() = prop //Companion.prop과 동일
}
class Child:Parent(){
    companion object{
        val prop = "나는 자식"
    }
    override fun method() = prop //Companion.prop 과 동일
}
fun main(args: Array<String>) {
    println(Parent().method()) //나는 부모
    println(Child().method()) //나는 자식

    val p:Parent = Child()
    println(p.method()) // -- (1)
}

클래스 정의를 먼저 자세히 보면 Parentmethod() 메소드가 open이고 Child에서 이를 override했습니다. main() 함수에서 첫 번째와 두 번째 실행 결과는 쉽게 예측이 됩니다. 하지만 주석(1)의 결과는 무엇일까요? 분명 Child 클래스의 객체를 생성했으나 Parent 부모로 형 변환 했습니다. 그럼 이때 호출한 method() 결과는 대체 무엇인가요? 부모 형이니 “나는 부모”라고 출력할까요? 아니면 태생이 자식이니 “나는 자식”일까요?

이 문제는 compaion object문제가 아닙니다. 게다가 코틀린 문제도 아닙니다. 사실 이는 객체지향 언어에서 약속한 다형성(Polymorphism)에 대한 질문입니다. 다형성은 두 가지를 만족해야 합니다.

  1. 대체 가능성(substitution) – 어떤 형을 요구한다면 그 형의 자식형으로 그 자리를 대신할 수 있다.
  2. 내적 동질성(internal identity) – 객체는 그 객체를 참조하는 방식에 따라 변화하지 않는다. 즉 업다운캐스팅해도 여전히 최초 생성한 그 객체라는 것입니다.

이 두 가지 조건을 만족하면 다형성을 만족한다고 볼 수 있고 거꾸로 다형성을 만족하면 객체지향 언어라고 볼 수 있습니다. 다형성의 1번 조건인 대체 가능성 때문에 var c:Parent = Child()를 할 수 있었던 것입니다. 그리고 2번 조건인 내적 동질성으로 인해 아무리 자식을 부모 형으로 대체해도 자식은 자식이죠. 태어날 때의 본질은 어디 가지 않습니다! 그래서 p.method()의 결과는 Child().method()결과와 같습니다. 그러므로 결과는 “나는 자식” 입니다.

정리하며top

조금 코틀린 companion object에 대해서 이해가 되셨나요? 자바의 static과는 다르며 더 많은 일을 할 수 있다는 것도 느껴지시나요? 하지만 이제 맛보기만 했을 뿐입니다. companion object에 대해서 더 학습이 필요하지만, 글이 많이 길어지는 관계로 이 정도로 마무리 짓고 다음에 2번째 글을 올리도록 하겠습니다. 긴 글 읽어주셔서 감사하며, 부족한 점 있거나 잘못된 내용이 있다면 댓글 부탁드립니다.