Kotlin

[Kotlin] object 객체: 객체 선언, 동반 객체, 객체 식

sh1mj1 2024. 1. 11. 14:15

 

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

이미지 출처            https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

 

코틀린에서는 `object` 키워드를 다양한 상황에서 사용할 수 있다.

  • 객체 선언(object declaration): 싱글턴을 정의하는 방법
  • 동반 객체(companion object): 주로 어떤 클래스와 관련있는 메서드와 팩토리 메서드를 담을 때 사용
  • 객체 식: 자바의 익명 내부 클래스(anonymous inner class) 대신 사용

모든 경우 클래스를 정의하면서 동시에 인스턴스(객체)를 생성한다.

객체 선언(object declarationi)

인스턴스가 하나만 필요할 때 Singleton(싱글턴) 패턴을 사용하여 이를 구현한다.

자바에서는 보통 클래스의 생성자를 private 으로 하고, 정적 필드에 그 클래스의 유일한 객체를 저장하여 이를 구현한다.

 

코틀린에서는 싱글턴을 객체 선언 기능을 통해 기본으로 지원한다.

객체 선언은 클래스 선언과 그 클래스 단일 인스턴스를 선언하는 것이다.

 

객체 선언은 `object` 키워드로 시작한다. 

클래스와 마찬가지로 객체 선언 안에도 프로퍼티, 메서드, 초기화 블록(`init`) 등이 들어갈 수 있다.

하지만 생성자는 객체 선언에 사용할 수 없다.

싱글턴 객체는 객체 선언문이 있는 위치에서 생성자 호출 없이 즉시 만들어지기 때문이다.

객체 선언해보기

회사 급여 대장을 만들어보자.

이 회사에는 여러 급여 대장이 필요하지 않을 것이라고 하면,  싱글턴을 쓰는게 정당해보인다.

 

`Payroll` object

object Payroll {
    val allEmployees = arrayListOf<Person>()
    fun calculateSalary(): Int {
        // ....
    }
}

@Test
fun testPayroll() {
    Payroll.allEmployees.add(Person("sh1mj1"))
    val salary = Payroll.calculateSalary()
    // ...
}

위처럼 선언하면 된다.

변수처럼, 객체 선언에 사용한 이름 뒤에 `.` 를 붙이면 객체에 속한 메서드나 프로퍼티에 접근할 수 있다.

객체 선언에서 클래스/인터페이스를 확장/구현 하기

객체 선언도 클래스를 확장하거나 인터페이스를 구현할 수 있다.

어떤 프레임워크를 사용하기 위해 특정 인터페이스를 구현해야 하는데 이 구현체가 싱글턴이면 좋을 때 사용하면 된다.

 

예를 들어 `Comparator` 인터페이스를 구현하여 두 파일 경로를 대소문자 관계없이 비교해주는 단일 객체를 만든다고 하자. 

(`Comparator` 는 두 객체를 인자로 받아 어느 객체가 더 큰지 알려주는 정수를 리턴하는 인터페이스)

 

`Comparator` 를 구현하는 `CaseInsentiveFileComparator` 객체 

object CaseInsensitiveFileComparator : Comparator<File> {
    override fun compare(o1: File, o2: File): Int = o1.path.compareTo(o2.path, ignoreCase = true)
}

@Test
fun testCaseInsensitiveFileComparator() {
    val file1 = File("/User")
    val file2 = File("/user")
    assert(CaseInsensitiveFileComparator.compare(file1, file2) == 0)

    val file11 = File("/Z")
    val file22 = File("/a")
    assert(CaseInsensitiveFileComparator.compare(file11, file22) > 1)

    val files = listOf(File("/Z"), File("/a"))
    assert(files.sortedWith(CaseInsensitiveFileComparator) == listOf(File("/a"), File("/Z")))
}
`compareTo` 함수는 (`o1` 의 위치 순서 - `o2` 의 위치 순서) 의 값을 리턴한다. 
즉, `compare` 의 결과가 양수이면 `o1` 이 더 큰 것이며, 음수이면, `o1` 가 더 작은 것이다.

`Comparator` 를 인자로 받는 `sortedWith` 함수를 사용할 수도 있다. 

(`sortedWith` 함수는 인자로 받은 `Comparator` 에 따라 리스트를 정렬해주는 함수이다.)

대규모 시스템의 대규모 컴포넌트에는 싱글턴이 적합하지 않다.
싱글턴에서는 객체 생성을 제어할 방법이 없고, 생성자 파라미터를 지정할 수 없어 여러 문제점이 생기기 때문이다.
이 경우, DI 등을 사용하고, 싱글톤 레지스트리(싱글톤 컨테이너) 등을 사용한다. (관련 글 참조)

클래스 안에 객체(일반) 선언

클래스 안에서도 객체를 선언할 수 있다.

이 때도 인스턴스는 단 하나 뿐이다.

외부 클래스를 가지고 인스턴스를 만들 때마다 하나씩 객체가 생기는 것이 아니다.

 

예를 들어 어떤 클래스의 인스턴스를 비교하는 `Comparator` 를 생각해보자.

이는 클래스 내부에 저정하는 것이 바람직하다.

 

`NameComparator` 를 `Person` 안에 구현

data class Person(val name: String) {
    object NameComparator : Comparator<Person> {
        override fun compare(o1: Person, o2: Person): Int = o1.name.compareTo(o2.name)
    }
}

@Test
fun testPerson() {
    val persons = listOf(Person("Bob"), Person("Alice"))
    assert(
        persons.sortedWith(Person.NameComparator) == listOf(Person("Alice"), Person("Bob"))
    )
}

자바에서 코틀린 객체 사용하기

코틀린 객체 선언은 유일한 인스턴스에 대한 정적인 필드가 있는 자바 클래스로 컴파일된다.

이 때 인스턴스 필드의 이름은 항상 `INSTANCE` 이다.

즉, 자바 코드에서 코틀린 싱글턴 객체를 사용하려면 정적인 `INSTANCE` 필드를 통하면 된다.

@Test
void javaTestPerson() {
    List<Person> persons = Arrays.asList(new Person("Bob"), new Person("Alice"));
    persons.sort(Person.NameComparator.INSTANCE);
    assertEquals(Arrays.asList(new Person("Alice"), new Person("Bob")), persons);
}

companion object(동반 객체)

알다시피 코틀린 클래스 안에는 정적 멤버가 없다.

즉, 자바의 `static` 키워드에 해당하는 것이 없다.

이를 위해 코틀린에서는 최상위 함수나 객체 선언을 활용한다.

그리고 대부분의 경우 최상위 함수가 권장된다.

 

하지만 최상위 함수는 같은 파일 내 class 의 private 멤버에는 접근할 수 없다.

클래스 바깥에 있는 최상위 함수는 private 멤버를 사용할 수 없다.

 

그래서 클래스의 인스턴스와 관계없이 호출해야 하지만, 클래스 내부 정보에 접근해야 하는 함수가 필요할 때는 클래스에 중첩된 객체 선언의 멤버 함수로 정의해야 한다.

대표적인 예는 팩토리 메서드(생성자의 역할을 대신해주는 함수)이다. 

 

클래스 안에 정의된 객체(object) 앞에 `companion` 이라는 키워드를 추가하면 그 클래스의 동반 객체(companion object)가 된다.

이 때 동반 객체의 프로퍼티, 메서드에 접근하려먼, 그 동반 객체가 정의된 클래스 이름을 사용한다.

즉, 동반 객체의 멤버를 사용하는 구문은 자바의 정적 멤버를 사용하는 구문과 같아진다.

 

companion object 를 가진 `FooClazz`

class FooClazz {
    companion object {
        fun foo() = "Companion object called"
    }
}

@Test
fun testFooClazz() = assert(FooClazz.foo() == "Companion object called")

팩토리 메서드를 동반 객체 내에 선언하기

동반 객체는 자신을 둘러싼 클래스의 모든 private 멤버에 접근할 수 있다.(private 생성자도!)

따라서 동반 객체는 팩토리 패턴을 구현하기 가장 적합한 위치이다.

팩토리 메서드를 만들기 전, 부 생성자가 여럿 있는 클래스를 살펴보자.

 

부 생성자가 여러 있는 `User`

class User {
    val nickname: String
    constructor(email: String) {
        nickname = email.substringBefore('@')
    }
    constructor(facebookAccountId: Int) {
        nickname = getFacebookName(facebookAccountId)
    }
}

@Test
fun testUser() {
    val subscribingUser = User(email = "bob@gmail.com")
    val facebookUser = User(facebookAccountId = 4)
    assert(
        subscribingUser.nickname == "bob" && facebookUser.nickname == "sh1mj1"
    )
}

이러한 로직을 팩토리 메서드를 사용하여 더 유용한 방식으로 표현할 수 있다.

 

부 생성자 대신 팩토리 메서드를 사용하는 `User`

class User private constructor(val nickname: String) {
    companion object {
        fun newSubscribingUser(email: String) = User(email.substringBefore('@'))
        fun newFacebookUser(accountId: Int) = User(getFacebookName(accountId))
    }
}


@Test
fun testUser() {
    val subscribingUser = User.newSubscribingUser("bob@gmail.com")
    val facebookUser = User.newFacebookUser(4)
    assert(
        subscribingUser.nickname == "bob" && facebookUser.nickname == "sh1mj1"
    )
}

주 생성자를 비공개로 만든 후 companion object 내에 `User` 를 리턴하는 함수를 만들었다.

 

이제는 클래스 이름을 사용해 그 클래스에 속한 동반 객체의 메서드를 호출할 수 있다.

팩토리 메서드를 사용하면, 많은 장점을 얻을 수 있다.

  • 목적에 따라 팩토리 메서드 이름을 정할 수 있다.
  • 팩토리 메서드가 선언된 클래스의 하위 클래스 객체를 리턴할 수 있다.
  • 생성할 필요가 없는 객체를 생성하지 않을 수 있다. 
    • 싱글턴 패턴처럼 객체를 하나만 생성하게 강제하거나, 최적화를 위해 캐싱 매커니즘을 사용할 수 있다. 

이 밖에도 많은 장점들이 있다. 

팩토리 메서드에 대해서는 이야기할 것이 굉장히 많으므로 나중에 다른 글에서 따로 설명하겠다.

 

참고로 동반 객체 멤버는 하위 클래스에서 오버라이드할 수 없다. 

그래서 여러 부 생성자를 사용하는 것이 더 나은 경우도 있다.

동반 객체를 일반 객체처럼 사용하기

동반 객체(companion object)는 일반 객체(object) 와 본질적으로 다르지 않다.

즉, 동반 객체에도 아래 작업들이 가능하다.

  • 동반 객체에 이름 붙이기
  • 동반 객체가 인터페이스를 구현
  • 동반 객체 안에 확장 함수/프로퍼티를 정의

동반 객체에 이름 붙이기

`Person` 에 대해 객체를 `JSON` 으로 직렬화하거나 역직렬화하려고 한다. 직렬화 로직을 동반 객체 안에 넣을 수 있다.

 

동반 객체에 `Loader` 라는 이름 짓기

class Person(val name: String) {
    companion object Loader {
        fun fromJSON(jsonText: String): Person {
            val name = jsonText
                .removeSurrounding("{", "}")
                .split(":")
                .last()
                .trim()
                .removeSurrounding("'")

            return Person(name)
        }
    }
}

@Test
fun testPerson() {
    val person = Person.Loader.fromJSON("{name: 'sh1mj1'}")
    assert(person.name == "sh1mj1")
}

필요하다면 위처럼 동반 객체에도 이름을 붙일 수 있다.

특별히 이름을 지정하지 않으면 동반 객체의 이름은 자동으로 `Companion` 이 된다.

동반 객체에서 인터페이스를 구현

일반 객체 선언과 마찬가지로, 동반 객체도 인터페이스를 구현할 수 있다.

인터페이스를 구현하는 동반 객체를 참조할 때 그 동반 객체를 둘러싼 클래스 이름을 사용하여 바로 참조할 수 있다!!

 

시스템에서는 `Person` 외에 다양한 객체가 있고, 모든 객체를 역직렬화를 통해 만들어야한다고 하자.

모든 타입의 객체를 생성하는 일반적인 방법이 필요하기 때문에 JSON 을 역직렬화하는 `JSONFactory` 인터페이스를 만들자.

 

`JSONFactory` 와 그를 구현하는 `Person` 의 동반 객체

interface JSONFactory<T> {
    fun fromJSON(jsonText: String): T
}

class Person(val name: String) {
    companion object : JSONFactory<Person> {
        override fun fromJSON(jsonText: String): Person {
            val name = jsonText
                .removeSurrounding("{", "}")
                .split(":")
                .last()
                .trim()
                .removeSurrounding("'")

            return Person(name)
        }
    }
}

그리고 아래 JSON 으로부터 각 원소를 다시 만들어내는 추상 팩토리가 있다.

fun <T> loadFromJSON(factory: JSONFactory<T>): T {
    // // 실제로는 jsonText 를 네트워크나 DB 에서 불러오지만, 여기선 그냥 넣어주자.
    val jsonText = "{name: 'Dmitry'}" 
    return factory.fromJSON(jsonText)
}

그렇다면 이제 `loadFromJSON` 이라는 함수를 통해 json 을 역직렬화하여 여러 객체로 만들 수 있다.

(위 구현에서는 `jsonText` 를 직접 넣어주었고, 그 jsonText 에 따라 현재로써는 `Person` 객체만 가능)

 

`Person` 내 동반객체가 `JSONFactory` 를 구현하고 있으며, 이 `JSONFactory` 구현체를 사용하여 테스트해보자.

@Test
fun testPerson() {
    val person = loadFromJSON(Person) // loadFromJSON(Person.Companion)
    assert(person.name == "Dmitry")
}

여기서 `loadFromJSON` 의 인자로 `Person` 이 들어가고 있다!

 

이제 '인터페이스를 구현하는 동반 객체를 참조할 때 그 동반 객체를 둘러싼 클래스 이름을 바로 사용하여 참조할 수 있다' 가 이해가 될 것이다.

참고로 한 클래스 내에 동반 객체는 하나만 가질 수 있다.
`@JvmStatic` 은 코틀린에서 제공하는 애노테이션이다.
코틀린의 동반 객체 또는 객체 선언에 있는 멤버를 자바의 멤버처럼 사용할 수 있도록 한다.
이 애노테이션은 코틀린과 자바 간의 상호 운용성(interoperability)을 개선하기 위해 사용된다.

동반 객체 확장 함수/프로퍼티

클래스에 동반 객체가 있으면, 그 객체 안에 함수를 정의함으로써 클래스에 대해 호출할 수 있는 확장 함수를 만들 수 있다.

 

앞에서 구현한 `Person` 클래스는 핵심 비즈니스 로직 모듈의 일부라고 하자.

그 비즈니스 모듈이 특정 데이터 타입에 의존하기를 원하지 않는다.

그래서 역직렬화 함수를 비즈니스 모듈이 아닌, 클라이언트/서버 통신을 담당하는 모듈 안에 포함시키고 싶다.

 

확장 함수를 사용하여 아래처럼 수정해보자.

// 비즈니스 로직 모듈에 위치
data class Person(val firstName: String, val lastName: String) {
    companion object {}
}

// 클라이언트/서버 통신 모듈에 위치
fun Person.Companion.fromJSON(json: String): Person {
    val keyValuePairs = json.trim().removeSurrounding("{", "}").trim()
        .split(",")
        .map { it.trim() }.associate {
            val (key, value) = it.split(":").map { part -> part.trim().removeSurrounding("\"") }
            key to value
        }
    return Person(
        firstName = keyValuePairs["firstName"] ?: "",
        lastName = keyValuePairs["lastName"] ?: ""
    )
}

@Test
fun testPerson() {
    val p = Person.fromJSON(
        """
            {
            "firstName" : "John",
            "lastName" : "Jackson"
        }
        """
    )
    assert(p.firstName == "John")
    assert(p.lastName == "Jackson")
}

기존에 `fromJSON` 은 json 문자열을 가지고, `Person` 을 만드는 일종의 팩토리 메서드였다.

이것을 클래스 외부(다른 모듈)에서 `Person` 의 동반 객체에 대한 확장함수로 만들어서 팩토리 메서드를 바깥으로 빼냈다.

 

이렇게 동반 객체에 대한 확장함수를 작성할 수 있으려면, 원래 클래스에 동반 객체를 꼭 선언해주어야 한다.

`Person` 의 동반 객체에 대해 확장함수를 선언한 것과 `Person` 에 대해 확장함수를 선언한 것이 헷갈릴 수 있다. (내가 그랬다.)
동반 객체에 대해 확장 함수를 선언한 것은 기존에 동반 객체 내에 있던 `Person` 인스턴스를 만드는 팩토리 메서드를 만드는 것이다.
즉, `Person` 인스턴스를 만들기 전에, `Person` 인스턴스를 만들기 위한 일종의 생성자 역할이다.
반면에 `Person` 에 대해 확장함수를 선언한 것은 `Person` 인스턴스를 인자로 받는 함수이다.
즉, `Person` 인스턴스를 만든 후, `Person` 인스턴스를 가지고 어떤 동작을 수행하는 것이다.

아래에서 각 확장 함수 내에서 `this` 가 무엇을 가리키는지를 확인하면 이해하기 쉽다.
fromJSON 에서는 this 가 Person.Companion 이고, someMethod 에서는 this 가 Person 이다.

객체 식: 익명 내부 클래스를 다른 방식으로

익명 객체(anonymous object)를 정의할 때도 `object` 키워드를 쓴다.

익명 객체는 자바의 익명 내부 클래스를 대신한다.

 

자바에서 흔히 익명 내부 클래스로 구현하는 이벤트 리스너를 코틀린에서 구현해보자.

코틀린에서 익명 객체를 구현하는 `anonymous` 함수

fun anonymous() {
    val window = Window(Frame("title"))
    window.addMouseListener(
        object : MouseAdapter() { // MouseAdapter 를 확장하는 익명 객체 선언
            override fun mouseClicked(e: MouseEvent?) { // 메서드 오버라이드
                super.mouseClicked(e)
            }
            // ...
        }
    )
}

객체 이름이 빠져있다. 

보통 함수를 호출하면서 인자로 익명 객체를 넘기기 때문에 클래스와 인스턴스 모두 이름이 필요하지 않다.

참고로 자바에서는 아래처럼 생성한다.
public void anonymous() {
    Window window = new Window(new Frame("title"));
    window.addMouseListener(new MouseAdapter() {
        @Override
        public void mouseClicked(MouseEvent e) {
            super.mouseClicked(e);
        }
        // ...
    });
}​

만약 객체에 이름을 붙여야 한다면, 변수에 익명 객체를 대입하면 된다.

val listener = object : MouseAdapter() {
    override fun mouseClicked(e: MouseEvent?) {
        super.mouseClicked(e)
    }
    // ...
}

한 인터페이스만 구현하거나 한 클래스만 확장할 수 있는 자바의 익명 내부 클래스와 달리,

코틀린 익명 클래스는 여러 인터페이스/클래스를 구현/확장 할수 있다.

(여러 인터페이스 구현 가능, 여러 인터페이스 구현 + 한 클래스 상속받기 가능, 당연히 여러 클래스 상속받기는 불가능)

fun anonymousExtendSeveralClass() {
    val threeJobPerson = object : Leader, Writer, Teacher() /*Developer() 당연히 여러 클래스를 상속받는 것은 불가능하다*/ {
        override fun lead() = println("lead pretty well")

        override fun write() = println("write pretty well")

        override fun teach() = println("teach pretty well!")
    }
}

 이러한 익명 객체는 객체 식이 쓰일 때마다 새로운 인스턴스가 생성된다. 즉, 싱글턴이 아니다.

 

자바의 익명 클래스처럼 객체 식 안의 코드는 그 식이 포함된 함수의 변수에 접근할 수 있다.

하지만 자바와 달리 `final` 이 아닌 변수도 객체 식 안에서 사용할 수 있다.

 

어떤 윈도우가 호출된 횟수를 리스너에게 누적하게 만드는 `countClicks`

fun countClicks(window: Window) {
    var clickCount = 0
    window.addMouseListener(object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent?) {
            clickCount++
            super.mouseClicked(e)
        }
        // ...
    })
}

 

자바에서는 객체 식 내에서 변수 수정 불가함을 보여주는 countClicks

public void countClicks(Window window) {
    int clickCount = 0;
    window.addMouseListener(new MouseAdapter() {
        @Override
        public void mouseClicked(MouseEvent e) {
//                clickCount++; // Variable 'clickCount' is accessed from within inner class, needs to be final or effectively final
            super.mouseClicked(e);
        }
    });
}

참고로, 보통 객체 식은 익명 객체 안에서 여러 메서드를 오버라이드할 때 자주 사용한다.

만약 메서드가 하나뿐인 인터페이스를 구현할 때는 코틀린의 SAM(Single Abstract Method, 추상 메서드가 하나만 있는 인터페이스) 변환을 사용하는 게 낫다. (SAM 관련 글)