[코틀린 인 액션] 4장, 클래스, 객체, 인터페이스

kindof

·

2023. 8. 18. 22:26

[코틀린 인 액션] 책을 읽으면서 이해한 내용을 정리합니다.

책의 설명을 기반으로 하되, Java와의 비교나 주관적인 생각들도 써보려고 하는데요. 코드를 작성하면서 궁금한 부분이나 다른 기본적인 내용들은 최대한 공식 문서를 참고해서 작성해보겠습니다.

 

이번 시간에는 코틀린의 클래스와 객체, 인터페이스에 대해 소개합니다.

 

설명하는 코드는 책에서 소개한 코드에 기반하여 설명을 위해 조금씩 각색 + 추가한 부분이 있을 수 있습니다.

 

 

1. 클래스 계층 정의

1-1. 코틀린 인터페이스

코틀린 인터페이스는 자바 8 인터페이스와 비슷한데, 인터페이스 안에는 추상 메서드뿐 아니라 자바의 Default Method 같은 구현이 있는 메서드도 정의할 수 있습니다.

interface Clickable {
    fun click()
    fun showOff() = println("I'm clickable!")
}

interface Focusable {
    fun setFocus(b: Boolean) = println("I ${if (b) "got" else "lost"} focus.")
    fun showOff() = println("I'm focusable!")
}

 

한편, 자바와 마찬가지로 인터페이스를 구현하는 구체 클래스는 다수의 인터페이스를 구현할 수 있으나 인터페이스의 추상 메서드를 반드시 override 키워드를 통해 오버라이딩 해야 합니다.

class Button : Clickable, Focusable {
    override fun click() = println("I was clicked.")

    override fun showOff() {
        super<Clickable>.showOff()
        super<Focusable>.showOff()
    }
}

위 코드를 보면 Button 클래스는 두 인터페이스를 구현하고 있고, click() 추상 메서드를 override 키워드로 오버라이딩하고 있습니다.

 

또 한 가지 주목할 점은, 두 인터페이스 모두 디폴트 구현이 들어있는 showOff 메서드가 있는데 이 때 코틀린 컴파일러는 두 메서드를 아우르는 구현을 하위 클래스에 직접 구현하도록 강제합니다.

 

위 코드에서는 상위 타입의 이름을 꺾쇠 괄호(<>) 사이에 넣어서 "super"를 지정하고 어떤 상위 타입의 멤버 메서드를 호출할 지 지정하고 있습니다.

 

1-2. open, final, abstract 변경자: 기본적으로 final

Effective Java에서는 "상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라"라는 조언을 합니다. 실제로 이 이야기는 클린 아키텍쳐라는 책에서도 LSP 원칙(리스코프 치환 원칙)에 대해 설명하면서 나오는데요.

 

열린 상속 가능성으로 인한 취약한 기반 클래스(Fragile base class)라는 문제가 존재하기 때문입니다. 아래 코드를 보겠습니다.

class Rectangle {
    protected int width;
    protected int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}

class Square extends Rectangle {
    public Square(int side) {
        super(side, side);
    }

    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

public class Main {
    public static void main(String[] args) {
        Rectangle rectangle = new Square(5);
        rectangle.setWidth(10);
        rectangle.setHeight(20);
        
        System.out.println("width = " + rectangle.width + " " + "height = " + rectangle.height); // width = 20 height = 20

    }
}

정사각형(Square) 객체는 언뜻 직사각형(Rectangle)의 자식이 될 수 있어보입니다.

 

실제로 가로, 세로 길이에 대한 Constructor, Getter, Setter는 적절하게 다시 정의할 수 있는 것처럼 보이죠.

 

그러나 Square 클래스 내부에서 setWidth와 setHeight 메서드를 오버라이드하여 가로와 세로 길이를 동일하게 설정하도록 변경하면, Rectangle 클래스의 메서드와 다른 기능을 가지게 됩니다. 이 경우, Square 클래스는 정상적으로 동작하지 않고 취약해집니다.

 

이러한 철학에 근거하여 코틀린의 클래스와 메서드는 기본적으로 final이며, 상속을 허용하려면 클래스 앞에 open 변경자를 붙여야 합니다. 또한, 오버라이드를 허용하고 싶은 메서드나 프로퍼티의 앞에도 open 변경자를 붙여야 합니다.

 

1-3. 가시성 변경자: 기본적으로 public

기본적으로 코틀린은 자바와 같이 public, protected, private 변경자가 있습니다. 그리고 아무 변경자가 없는 경우 기본 가시성은 public입니다.

 

다만, 자바에서는 아무런 변경자도 없는 경우 '같은 패키지에서만 접근이 가능하도록' 설정이 되는데요. 코틀린에서는 패키지를 가시성 제어에 사용하지 않고 internal이라는 새로운 가시성 변경자를 도입했습니다.

 

internal은 "모듈 내부에서만 볼 수 있다"는 뜻인데, 모듈 내부 가시성은 모듈의 구현에 대해 캡슐화를 제공하기 위한 데 목적이 있습니다.

 

자바와 큰 차이점은 없으므로 예제 코드는 생략하겠습니다.

 

1-4. 내부 클래스와 중첩된 클래스: 기본적으로 중첩된 클래스

자바처럼 코틀린에서도 클래스 안에 다른 클래스를 선언할 수 있습니다. 클래스 안에 다른 클래스를 선언하면 도우미 클래스를 캡슐화하거나 코드 정의를 그 코드를 사용하는 곳에 가까이 두고 싶을 때 유용합니다.

 

자바와의 차이는, 코틀린의 중첩 클래스는 명시적으로 요청하지 않는 한 바깥쪽 클래스 인스턴스에 대한 접근 권한이 없다는 점입니다.

 

아래 코드를 보겠습니다.

data class UserDTO(
    val id: Long,
    val username: String,
    val profile: Profile
) {
    data class Profile(
        val firstName: String,
        val lastName: String,
        val email: String
    )
}

위 코드는 간단한 유저 정보를 전달하는 UserDTO 클래스를 코틀린으로 작성한 것인데요.

 

만약 자바로 위 코드를 작성했다면 아래와 같은 형태가 됐을 것입니다.

public class UserDTO {
    private long id;
    private String username;
    private Profile profile;

    // Constructor
    // Getter
    ...

    public static class Profile {
        private String firstName;
        private String lastName;
        private String email;

        // Constructor
        // Getter
        ...
    }
}

즉, 두 코드의 가장 큰 차이는 자바에서 static class A라는 중첩 클래스(바깥쪽 클래스에 대한 참조X)가 코틀린에서는 class A로 표현된다는 것이고, 자바에서 class A는 코틀린에서 inner class A가 된다는 것입니다.

 

만약 바깥쪽 클래스에 대한 참조를 저장하는 내부 클래스를 작성하고 싶다면, 아래와 같이 작성할 수 있습니다.

data class UserDTO(
    val id: Long,
    val username: String
) {
    inner class Profile(
        val firstName: String,
        val lastName: String,
    ) {
        fun getFullName(): String {
            return "$firstName $lastName"
        }

        fun getUserDTOId(): Long {
            return this@UserDTO.id
        }
    }
}

fun main() {
    val userDTO = UserDTO(
        id = 1,
        username = "johndoe"
    )

    val profile = userDTO.Profile(
        firstName = "John",
        lastName = "Doe",
    )

    val fullName = profile.getFullName()
    println(fullName) // 출력: John Doe

    val userId = profile.getUserDTOId()
    println(userId) // 출력: 1
}

여기서는 Profile 내부 클래스의 getUserDTOId() 메서드에서 this@UserDTO를 사용하여 외부 클래스 UserDTO의 인스턴스에 접근할 수 있습니다.

 

 

2. 뻔하지 않은 생성자와 프로퍼티를 갖는 클래스 선언

뻔하지 않은 생성자와 프로퍼티란 무엇일까요?

 

바로 코드를 보면서 설명하겠습니다.

class Order constructor(val orderId: Int, val customer: Customer, val items: List<Item>) {
    init {
        println("Order instance created: Order ID $orderId, Customer ${customer.name}")
    }

    constructor(orderId: Int, customer: Customer, vararg items: Item) : this(orderId, customer, items.toList()) {
        println("Additional constructor with variable items: ${items.size} items")
    }
}

class Customer(val id: Int, val name: String)

class Item(val itemId: Int, val itemName: String, val price: Double)

fun main() {
    val customer = Customer(1, "John Doe")
    val item1 = Item(101, "Laptop", 999.99)
    val item2 = Item(102, "Mouse", 19.99)

    val order1 = Order(1, customer, item1)
    val order2 = Order(2, customer, item1, item2)
}

// Console output
Order instance created: Order ID 1, Customer John Doe
Additional constructor with variable items: 1 items
Order instance created: Order ID 2, Customer John Doe
Additional constructor with variable items: 2 items

이 예제에서 자바에 없던 constructor, init 키워드를 볼 수 있습니다.

 

constructor 키워드는 주 생성자와 부 생성자를 정의할 때 사용되며 주 생성자 앞에 별다른 애노테이션이나 가시성 변경자가 없다면 constructor를 생략해도 됩니다.

 

부 생성자는 클래스 인스턴스를 생성할 때 파라미터 필드가 다른 생성 방법으로 생성 가능할 때 반드시 필요합니다. 즉, 여기서는 주 생성자의 List<Item> 형태가 아닌, vararg items: Item 형태로도 인스턴스가 생성될 수 있도록 돕는 역할을 하고 있는 것이죠.

 

init 키워드는 초기화 블록을 시작하는데, 초기화 블록은 클래스의 객체가 만들어질 때 실행될 초기화 코드가 들어갑니다.

 

여기까지만 봐도 constructor, init 키워드를 활용하면 자바 코드보다 훨씬 간결하게 인스턴스를 생성하고 초기화할 수 있다는 느낌이 듭니다.

 


 

한편, 코틀린에서는 인터페이스에 추상 프로퍼티 선언을 넣을 수 있습니다.

interface User {
    val nickname: String
}

 

그렇다면, 해당 인터페이스를 구현하는 클래스에서는 인터페이스의 추상 프로퍼티에 어떻게 접근할 수 있을까요?

 

먼저 PrivateMember는 주 생성자 안에 프로퍼티를 직접 선언하는 간결한 구문을 사용합니다. 이 프로퍼티는 Member의 추상 프로퍼티를 구현하고 있으므로 override를 써줘야 합니다.

 

한편, SubscribingMember의 nickname은 매번 호출될 때마다 substringBefore()를 호출해 계산하는 커스텀 Getter를 활용하고 FacebookMember의 nickname은 객체 초기화 시 계산한 데이터를 뒷받침하는 필드에 저장했다가 불러오는 방식을 활용합니다.

 

그냥 그런가보다 하면 될 것 같습니다.

 

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

이전까지의 설명들에서 코틀린은 생성자나 프로퍼티 접근자를 컴파일러가 자동으로 만들어주는 것을 알게됐습니다.

 

여기서 나아가, 코틀린 컴파일러는 데이터를 저장하는 클래스가 반드시 오버라이딩해야 하는 toString, equals, hashCode를 데이터 클래스를 통해 자동으로 만들어줍니다.

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

[1] 인스턴스 간 비교를 위한 equals

[2] HashMap과 같은 해시 기반 컨테이너에서 키로 사용할 수 있는 hashCode

[3] 클래스의 각 필드를 선언 순서대로 표시하는 문자열 표현을 만들어주는 toString

 

한편, 우리는 이전에 취약 계층 클래스에 대해 살펴보면서 코틀린은 기본적으로 클래스를 final로 취급한다는 것, 상속을 염두에 두고 open 키워드로 열어둔 클래스만을 확장할 수 있다는 사실을 공부했습니다.  

 

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

 

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

 

예를 들어, Collection 같이 비교적 단순한 인터페이스를 구현하면서 아무 동작도 변경하지 않는 데코레이터를 만들 때조차도 다음과 같은 코드를 작성해야만 합니다.

class DelegatingCollection<T> : Collection<T> {
    private val innerList = arrayListOf<T>()
    
    override val size: Int get() = innerList.size
    
    ...
    
    override fun containAll(elements: Collection<T>) : Boolean =
        innerList.containAll(elements)
}

하지만 코틀린은 by 키워드를 통해 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있습니다.

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

 

책에서는 이 기법을 이용해서 원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현하는 코드를 보여줍니다.

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(c: Collection<T>): Boolean {
        objectsAdded += c.size
        return innerSet.addAll(c)
    }
}

fun main() {
    val cset = CountingSet<Int>()
    cset.addAll(listOf(1, 2, 3, 4))

    // 4 objects were added, 4 remain
    println("${cset.objectsAdded} objects were added, ${cset.size} remain")
}

 

4. object 키워드: 클래스 선언과 인스턴스 생성

4-1. object

객체지향 시스템을 설계하다 보면 인스턴스가 하나만 필요한 클레스가 유용한 경우가 많습니다.

 

자바에서는 보통 클래스의 생성자를 private으로 제한하고 static 필드에 그 클래스의 유일한 객체를 저장하는 싱글턴 패턴을 통해 이를 구현합니다.

 

코틀린은 객체 선언 기능을 통해 싱글턴을 언어에서 기본 지원하는데, 객체 선언은 클래스 선언과 그 클래스에 속한 단일 인스턴스의 선언을 합친 선언입니다.

object Payroll {
    val allEmployees = arrayListOf<Person>()
    
    fun calculateSalary() {
        for (person in allEmployees) {
            ...
        }
    }
}

싱글턴 객체는 객체 선언문이 있는 위치에서 생성자 호출 없이 즉시 생성되기 때문에 객체 선언에는 생성자 정의가 필요없습니다.

 

책에서는 Comparator 인터페이스가 클래스마다 단 하나씩만 있으면 되기 때문에 이를 생성하는 방법으로 객체 선언을 권장하고 있습니다.

 

 

4-2. companion object

코틀린은 자바의 static 키워드를 지원하지 않습니다. 그 대신 코틀린에서는 패키지 수준의 최상위 함수와 객체 선언을 활용합니다. 그리고 대부분의 경우는 최상위 함수를 활용하는 편을 권장합니다.

 

[1] 최상위 함수는 decompile했을 때 기존에 자바에서 사용하는 Static Utils Class 처럼 간결하게 되기 때문이죠. 그리고 바로 뒤에서 소개할 companion object보다 간결한 호출로 사용할 수 있기 때문입니다.

 

[2] 하지만 최상위 함수는 아래 그림처럼 private으로 표시된 클래스 비공개 멤버에 접근할 수 없습니다. 때문에 클래스의 인스턴스와 관계없이 호출해야 하지만, 클래스의 내부 정보에 접근해야 하는 함수가 필여할 때는 클래스에 중첩된 객체 선언의 멤버 함수로 정의해야 합니다. 그런 함수의 대표적인 예로 팩토리 메서드를 들 수 있습니다.

 

클래스 안에 정의된 객체 중 하나에 companion 키워드를 붙이면 그 클래스의 동반 객체로 만들 수 있습니다. 

 

이전에 살펴봤던 FacebookMember, SubscribingMember 예제를 다시 보겠습니다.

 

위 코드를 보면 최상위 함수를 선언했을 때 호출이 더 단순한 것은 장점이지만, companion object를 활용하면 Class 내부의 private 멤버에 접근이 가능하다는 장점이 있다는 것을 알 수 있습니다.

 

즉, companion object는 private 생성자를 호춞하기 좋은 위치입니다. companion object는 자신을 둘러싼 클래스의 모든 private 멤버에 접근할 수 있고, 바깥쪽 클래스의 privtae 생성자도 호출할 수 있습니다.

 

 

 

5. 정리 / Reference

이번 시간에는 코틀린의 클래스, 객체, 인터페이스에 대해 조금 더 자세히 알아봤습니다.

 

다음 장에서는 코틀린에서 람다를 어떻게 사용하는가에 대해 알아보겠습니다.

 

감사합니다.

 

 

 

Kotlin in Action | 드미트리 제메로프 - 교보문고

Kotlin in Action | 코틀린이 안드로이드 공식 언어가 되면서 관심이 커졌다. 이 책은 코틀린 언어를 개발한 젯브레인의 코틀린 컴파일러 개발자들이 직접 쓴 일종의 공식 서적이라 할 수 있다. 코틀

product.kyobobook.co.kr