Skip to main content
Summary

코틀린의 상속은 기본적으로 닫혀 있다

코틀린에서 클래스는 기본적으로 상속할 수 없습니다.

class User(val name: String)

// 오류: User는 기본적으로 final이므로 상속할 수 없다.
// class Admin(name: String) : User(name)

상속을 허용하려면 클래스 앞에 open을 붙여야 합니다.

open class User(val name: String)

class Admin(name: String, val level: Int) : User(name)

이 규칙은 처음에는 불편해 보일 수 있습니다. 하지만 좋은 설계에서는 매우 중요한 기본값입니다.

상속은 부모 클래스의 내부 구조와 자식 클래스를 강하게 묶습니다. 부모 클래스의 작은 변경이 자식 클래스의 동작을 깨뜨릴 수 있습니다. 그래서 코틀린은 "정말 확장해도 되는 클래스인가?"를 먼저 생각하게 만듭니다.

즉, 코틀린의 기본값은 다음과 같습니다.

  • 상속은 예외적으로 허용한다.

  • 확장 가능한 지점은 open으로 명시한다.

  • 재정의 가능한 멤버도 open으로 명시한다.

open class MessageFormatter {
    open fun format(message: String): String {
        return message.trim()
    }

    fun normalize(message: String): String {
        return message.lowercase()
    }
}

위 코드에서 format은 자식 클래스가 재정의할 수 있습니다. 하지만 normalize는 재정의할 수 없습니다. 클래스가 open이어도 모든 멤버가 자동으로 열리는 것은 아닙니다.

상속, 인터페이스, 다형성으로 설계 확장하기

open을 사용할 때의 판단 기준

open은 "이 코드는 나중에 바뀔 수 있다"는 표시가 아닙니다. "이 코드는 다른 코드가 안전하게 확장해도 된다"는 약속입니다.

다음 질문에 답할 수 있을 때만 open을 붙이는 편이 좋습니다.

  • 자식 클래스가 이 클래스를 상속해도 의미가 자연스러운가?

  • 어떤 메서드를 재정의해야 하는지 명확한가?

  • 부모 클래스의 내부 구현이 바뀌어도 자식 클래스가 쉽게 깨지지 않는가?

  • 상속보다 인터페이스나 합성으로 해결할 수는 없는가?

예를 들어 EmailSender와 SmsSender가 모두 메시지를 보낸다면, 둘을 하나의 부모 클래스로 묶고 싶을 수 있습니다. 하지만 실제 전송 방식이 완전히 다르다면 상속보다 인터페이스가 더 나은 선택일 수 있습니다.

반대로 여러 자식 클래스가 공통 상태와 공통 처리 흐름을 공유해야 한다면 추상 클래스나 열린 클래스를 고려할 수 있습니다.

상속으로 기능 확장하기

상속은 기존 클래스의 기능을 물려받고, 필요한 부분만 바꾸거나 추가하는 방법입니다.

open class ReportPrinter {
    open fun print(title: String, body: String) {
        println("[$title]")
        println(body)
    }
}

class TimeStampedReportPrinter : ReportPrinter() {
    override fun print(title: String, body: String) {
        val newTitle = "$title / generated"
        super.print(newTitle, body)
    }
}

TimeStampedReportPrinter는 ReportPrinter를 상속합니다. 그리고 print 함수를 재정의합니다.

여기서 중요한 키워드는 세 가지입니다.

  • open: 재정의를 허용한다.

  • override: 부모의 멤버를 재정의한다.

  • super: 부모의 구현을 호출한다.

override는 반드시 명시해야 합니다. 코틀린은 실수로 부모 멤버를 덮어쓰는 일을 막기 위해 재정의 의도를 코드에 드러내도록 요구합니다.

val printer: ReportPrinter = TimeStampedReportPrinter()
printer.print("월간 보고서", "이번 달 매출이 증가했습니다.")

변수 타입은 ReportPrinter이지만 실제 객체는 TimeStampedReportPrinter입니다. 실행되는 함수는 실제 객체의 print입니다. 이것이 다형성입니다.

재정의된 멤버는 다시 열려 있다

코틀린에서 override한 멤버는 기본적으로 다시 재정의할 수 있습니다.

open class BasePrinter {
    open fun print(message: String) {
        println(message)
    }
}

open class PrefixPrinter : BasePrinter() {
    override fun print(message: String) {
        super.print("[INFO] $message")
    }
}

class DetailPrinter : PrefixPrinter() {
    override fun print(message: String) {
        super.print("detail: $message")
    }
}

만약 더 이상 재정의를 허용하고 싶지 않다면 final override를 사용합니다.

open class SafePrinter : BasePrinter() {
    final override fun print(message: String) {
        println("safe: $message")
    }
}

이렇게 하면 SafePrinter의 자식 클래스는 print를 다시 재정의할 수 없습니다.

프로퍼티도 재정의할 수 있다

함수뿐 아니라 프로퍼티도 재정의할 수 있습니다.

open class Document {
    open val type: String = "문서"
}

class Invoice : Document() {
    override val type: String = "청구서"
}

다만 프로퍼티를 재정의할 때는 의미가 자연스러워야 합니다. 부모 타입으로 보았을 때도 자식 객체가 문제없이 동작해야 합니다.

fun printDocument(document: Document) {
    println("문서 유형: ${document.type}")
}

printDocument(Invoice())

printDocument는 Document만 알고 있습니다. 하지만 Invoice도 Document의 한 종류이므로 문제없이 사용할 수 있습니다. 이것이 상속이 자연스럽게 작동하는 경우입니다.

다형성은 구체 타입을 숨긴다

다형성은 같은 타입으로 여러 구현을 다루는 능력입니다. 이 개념은 상속에서도 쓰이고, 인터페이스에서도 쓰입니다.

open class NotificationSender {
    open fun send(message: String) {
        println("기본 알림: $message")
    }
}

class EmailSender : NotificationSender() {
    override fun send(message: String) {
        println("이메일 전송: $message")
    }
}

class SmsSender : NotificationSender() {
    override fun send(message: String) {
        println("문자 전송: $message")
    }
}

fun notify(sender: NotificationSender, message: String) {
    sender.send(message)
}

사용하는 쪽에서는 EmailSender인지 SmsSender인지 몰라도 됩니다.

notify(EmailSender(), "주문이 완료되었습니다.")
notify(SmsSender(), "배송이 시작되었습니다.")

이 구조의 장점은 새로운 알림 방식이 추가되어도 notify 함수를 바꾸지 않아도 된다는 점입니다.

class PushSender : NotificationSender() {
    override fun send(message: String) {
        println("푸시 전송: $message")
    }
}

새 클래스만 추가하면 됩니다. 기존 코드를 덜 수정할수록 변경에 강한 설계에 가까워집니다.

인터페이스는 계약을 정의한다

인터페이스는 "무엇을 할 수 있어야 하는가"를 정의합니다. 구현 방법은 각 클래스가 결정합니다.

interface Payable {
    val amount: Int

    fun pay(): Boolean

    fun description(): String {
        return "${amount}원 결제"
    }
}

위 인터페이스는 세 가지를 말합니다.

  • amount라는 값을 제공해야 한다.

  • pay 함수를 구현해야 한다.

  • description은 기본 구현을 사용할 수 있다.

인터페이스의 프로퍼티는 값을 저장하는 필드가 아닙니다. "이 이름의 값을 제공해야 한다"는 요구사항입니다.

class CardPayment(
    override val amount: Int,
    private val cardNumber: String
) : Payable {
    override fun pay(): Boolean {
        println("카드 결제: $amount원")
        return cardNumber.isNotBlank()
    }
}

class PointPayment(
    override val amount: Int,
    private val point: Int
) : Payable {
    override fun pay(): Boolean {
        println("포인트 결제: $amount원")
        return point >= amount
    }
}

이제 결제를 처리하는 함수는 구체적인 결제 방식을 몰라도 됩니다.

fun processPayment(payment: Payable) {
    println(payment.description())

    if (payment.pay()) {
        println("결제 성공")
    } else {
        println("결제 실패")
    }
}
processPayment(CardPayment(10_000, "1234-5678"))
processPayment(PointPayment(5_000, 7_000))

processPayment는 CardPayment와 PointPayment를 직접 알지 않습니다. 오직 Payable이라는 계약만 압니다. 이처럼 인터페이스는 코드 사이의 의존성을 줄여 줍니다.

인터페이스의 기본 구현

코틀린 인터페이스는 기본 구현을 가질 수 있습니다.

interface Logger {
    fun log(message: String) {
        println("LOG: $message")
    }
}

class OrderService : Logger {
    fun createOrder() {
        log("주문 생성")
    }
}

OrderService는 log를 직접 구현하지 않았지만 사용할 수 있습니다. 인터페이스의 기본 구현을 물려받았기 때문입니다.

하지만 여러 인터페이스가 같은 이름의 기본 구현을 제공하면 충돌이 생깁니다. 이때는 클래스에서 직접 재정의해야 합니다.

interface ConsoleLogger {
    fun log(message: String) {
        println("console: $message")
    }
}

interface FileLogger {
    fun log(message: String) {
        println("file: $message")
    }
}

class AuditService : ConsoleLogger, FileLogger {
    override fun log(message: String) {
        super<ConsoleLogger>.log(message)
        super<FileLogger>.log(message)
    }
}

super<ConsoleLogger>처럼 어떤 인터페이스의 기본 구현을 호출할지 명확히 지정할 수 있습니다.

추상 클래스는 공통 뼈대를 제공한다

추상 클래스는 직접 객체를 만들 수 없는 클래스입니다. 대신 자식 클래스가 완성해야 할 공통 구조를 제공합니다.

abstract class FileImporter(
    private val fileName: String
) {
    fun import() {
        val rows = readRows(fileName)
        validate(rows)
        save(rows)
    }

    protected abstract fun readRows(fileName: String): List<String>

    protected open fun validate(rows: List<String>) {
        require(rows.isNotEmpty()) { "빈 파일은 가져올 수 없습니다." }
    }

    private fun save(rows: List<String>) {
        println("${rows.size}개의 행을 저장합니다.")
    }
}

FileImporter는 전체 흐름을 갖고 있습니다.

  1. 파일을 읽는다.

  2. 검증한다.

  3. 저장한다.

하지만 파일을 읽는 방법은 정해져 있지 않습니다. 그래서 readRows를 추상 함수로 둡니다.

class CsvImporter(fileName: String) : FileImporter(fileName) {
    override fun readRows(fileName: String): List<String> {
        return listOf("id,name", "1,kotlin")
    }
}

추상 클래스는 "일부는 공통이고 일부는 자식이 채워야 하는 구조"에 어울립니다.

추상 클래스와 인터페이스의 차이

둘 다 추상적인 설계를 표현할 수 있습니다. 하지만 목적이 다릅니다.

구분인터페이스추상 클래스
핵심 목적할 수 있는 일을 정의한다공통 상태와 흐름을 제공한다
상태 보관직접 backing field를 가질 수 없다생성자와 프로퍼티로 상태를 가질 수 있다
다중 사용여러 인터페이스를 구현할 수 있다클래스 상속은 하나만 가능하다
적합한 상황결제 가능, 저장 가능, 출력 가능 같은 능력 표현파일 가져오기, 공통 서비스 처리 흐름 같은 뼈대 제공

간단히 말하면 다음과 같습니다.

  • "이 객체는 무엇을 할 수 있는가?"를 표현하려면 인터페이스를 먼저 고려합니다.

  • "여러 클래스가 같은 상태와 처리 흐름을 공유하는가?"를 표현하려면 추상 클래스를 고려합니다.

예를 들어 Payable, Printable, Serializable처럼 능력을 나타내는 개념은 인터페이스가 자연스럽습니다.

interface Printable {
    fun print()
}

interface Exportable {
    fun export(): String
}

class Receipt : Printable, Exportable {
    override fun print() {
        println("영수증 출력")
    }

    override fun export(): String {
        return "receipt-data"
    }
}

반면 공통 처리 순서가 있고 중간 단계만 바뀐다면 추상 클래스가 적합합니다.

상속보다 합성을 먼저 고려하라

상속은 강력하지만 결합도가 높습니다. 결합도가 높다는 것은 한쪽을 바꾸면 다른 쪽도 영향을 받기 쉽다는 뜻입니다.

그래서 실무에서는 보통 "상속보다 합성"을 먼저 고려합니다.

합성은 객체가 다른 객체를 내부에 포함해서 기능을 사용하는 방식입니다.

상속이 "A는 B이다"에 가깝다면, 합성은 "A는 B를 가지고 있다"에 가깝습니다.

data class OrderLine(
    val price: Int,
    val quantity: Int
)

data class Order(
    val lines: List<OrderLine>
) {
    val totalPrice: Int
        get() = lines.sumOf { it.price * it.quantity }
}

주문 금액을 계산하는 기능을 생각해 보겠습니다. 할인 정책은 자주 바뀔 수 있습니다.

나쁜 방향은 할인 방식이 생길 때마다 상속 클래스를 늘리는 것입니다.

open class OrderCalculator {
    open fun finalPrice(order: Order): Int {
        return order.totalPrice
    }
}

class CouponOrderCalculator : OrderCalculator() {
    override fun finalPrice(order: Order): Int {
        return order.totalPrice - 1_000
    }
}

class VipOrderCalculator : OrderCalculator() {
    override fun finalPrice(order: Order): Int {
        return (order.totalPrice * 0.9).toInt()
    }
}

처음에는 괜찮아 보입니다. 하지만 쿠폰과 VIP 할인을 함께 적용해야 하면 어떻게 할까요? 생일 할인, 첫 구매 할인, 특정 상품 할인까지 추가되면 상속 구조가 빠르게 복잡해집니다.

이럴 때는 할인 정책을 별도 인터페이스로 분리하고, 계산기가 그 정책을 가지게 만드는 편이 좋습니다.

interface DiscountPolicy {
    fun discountAmount(order: Order): Int
}

class NoDiscountPolicy : DiscountPolicy {
    override fun discountAmount(order: Order): Int = 0
}

class FixedDiscountPolicy(
    private val amount: Int
) : DiscountPolicy {
    override fun discountAmount(order: Order): Int {
        return amount
    }
}

class RateDiscountPolicy(
    private val percent: Int
) : DiscountPolicy {
    override fun discountAmount(order: Order): Int {
        return order.totalPrice * percent / 100
    }
}

class OrderCalculator(
    private val discountPolicy: DiscountPolicy
) {
    fun finalPrice(order: Order): Int {
        val discounted = order.totalPrice - discountPolicy.discountAmount(order)
        return discounted.coerceAtLeast(0)
    }
}

이제 할인 정책은 쉽게 교체할 수 있습니다.

val order = Order(
    listOf(
        OrderLine(price = 10_000, quantity = 2),
        OrderLine(price = 5_000, quantity = 1)
    )
)

val fixedCalculator = OrderCalculator(FixedDiscountPolicy(1_000))
println(fixedCalculator.finalPrice(order))

val rateCalculator = OrderCalculator(RateDiscountPolicy(10))
println(rateCalculator.finalPrice(order))

OrderCalculator는 할인 방식의 구체적인 내용을 모릅니다. 오직 DiscountPolicy라는 계약만 압니다. 새로운 할인 정책이 필요하면 새 클래스를 추가하면 됩니다.

class FirstOrderDiscountPolicy : DiscountPolicy {
    override fun discountAmount(order: Order): Int {
        return 3_000
    }
}

기존 OrderCalculator는 수정하지 않아도 됩니다. 이것이 변경에 강한 구조입니다.

합성은 테스트하기도 쉽다

합성을 사용하면 테스트할 때 필요한 동작만 가진 객체를 넣을 수 있습니다.

class TestDiscountPolicy : DiscountPolicy {
    override fun discountAmount(order: Order): Int = 500
}

val calculator = OrderCalculator(TestDiscountPolicy())

상속 구조에서는 부모 클래스의 내부 동작까지 함께 고려해야 할 때가 많습니다. 반면 합성은 필요한 협력 객체만 바꾸면 됩니다.

이 차이는 코드가 커질수록 중요해집니다.

상속을 써도 좋은 경우

상속을 무조건 피해야 하는 것은 아닙니다. 다음 조건을 만족하면 상속은 좋은 선택이 될 수 있습니다.

  • 자식 클래스가 부모 클래스의 진짜 하위 개념이다.

  • 부모 타입으로 자식 객체를 사용해도 의미가 깨지지 않는다.

  • 부모 클래스가 확장을 고려해 설계되어 있다.

  • 재정의할 지점이 명확하다.

  • 공통 상태나 공통 처리 흐름을 공유해야 한다.

예를 들어 CsvImporter가 FileImporter의 한 종류라면 상속이 자연스럽습니다. 파일 가져오기라는 공통 흐름이 있고, 읽는 방식만 달라지기 때문입니다.

하지만 "주문 계산기에 할인 기능을 추가한다"처럼 기능 조합이 중심이라면 합성이 더 유연합니다.

생성자에서 open 멤버 호출은 조심하라

상속을 사용할 때 주의할 점이 있습니다. 부모 클래스의 생성자나 초기화 블록에서 open 멤버를 호출하는 일은 피하는 편이 좋습니다.

자식 클래스의 초기화가 끝나기 전에 재정의된 함수가 호출될 수 있기 때문입니다.

open class Parent {
    init {
        // 피하는 것이 좋다.
        printName()
    }

    open fun printName() {
        println("Parent")
    }
}

class Child(private val name: String) : Parent() {
    override fun printName() {
        println(name)
    }
}

이런 코드는 초기화 순서 때문에 예상과 다르게 동작할 수 있습니다. 상속을 설계할 때는 객체가 완전히 준비된 뒤 재정의 가능한 동작을 호출하도록 구성하는 편이 안전합니다.

설계를 확장하는 순서

상속, 인터페이스, 합성은 서로 경쟁하는 문법이 아닙니다. 각각 쓰임새가 다릅니다.

설계를 확장할 때는 보통 다음 순서로 생각하면 좋습니다.

  1. 먼저 바뀌는 부분이 무엇인지 찾는다.

  2. 그 부분을 인터페이스로 분리할 수 있는지 본다.

  3. 객체가 그 인터페이스를 가지도록 합성한다.

  4. 공통 흐름과 상태가 명확할 때만 추상 클래스나 상속을 고려한다.

  5. 상속을 허용한다면 open 지점을 최소화한다.

코틀린의 상속 규칙은 일부러 엄격합니다. 이 엄격함은 코드를 불편하게 만들기 위한 것이 아닙니다. 설계의 의도를 분명히 드러내기 위한 장치입니다.

좋은 설계는 변경을 막는 설계가 아닙니다. 변경될 부분과 고정될 부분을 나누는 설계입니다. 코틀린의 open, override, interface, abstract class는 그 경계를 코드로 표현하게 해 줍니다.

책 목록

처음 배우는 코틀린: 문법에서 실전 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기