Kim-Baek 개발자 이야기

Kotlin 상속과 인터페이스 완벽 가이드 | open, abstract, sealed 본문

개발/java basic

Kotlin 상속과 인터페이스 완벽 가이드 | open, abstract, sealed

김백개발자 2025. 12. 27. 23:37
반응형

이 글을 읽으면: Java의 복잡한 상속 구조를 넘어 Kotlin의 안전하고 명확한 상속 시스템을 배울 수 있습니다. open, abstract, sealed 클래스와 인터페이스 다중 상속까지 실전 예제로 완벽하게 마스터하세요.


📌 목차

  1. 들어가며 - Java 상속의 문제점
  2. 기본적으로 final - open 키워드
  3. 추상 클래스 - abstract
  4. 인터페이스 - interface
  5. Sealed Class - 제한된 클래스 계층
  6. 위임 - by 키워드
  7. 실전 디자인 패턴
  8. 마무리 - 다음 편 예고

들어가며 - Java 상속의 문제점

Java로 개발하면서 이런 경험 있으신가요?

// Java - 모든 클래스가 기본적으로 상속 가능
public class User {
    private String name;
    
    public String getName() {
        return name;
    }
}

// 의도하지 않았지만 누군가 상속
public class HackedUser extends User {
    @Override
    public String getName() {
        // 악의적인 코드
        System.out.println("사용자 정보 유출!");
        return super.getName();
    }
}

// final로 막아야 함
public final class SecureUser {
    // ...
}

실제 프로젝트에서 겪은 문제:

상황: 라이브러리 클래스를 상속받아 사용
문제: 라이브러리 업데이트 시 내부 구현 변경으로 하위 클래스 전부 깨짐
원인: Fragile Base Class Problem (깨지기 쉬운 기반 클래스 문제)

Kotlin의 해결책:

// Kotlin - 기본적으로 final (상속 불가)
class User(val name: String)

// 상속하려면 명시적으로 open
open class OpenUser(val name: String)

class CustomUser(name: String) : OpenUser(name)

// Sealed로 제한된 상속만 허용
sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
}

오늘은 Kotlin의 상속 시스템을 완전히 마스터해보겠습니다!


open 키워드

기본적으로 final

Kotlin의 핵심 철학: 기본적으로 상속을 막아 안전성 확보

// 기본 클래스 - final (상속 불가)
class User(val name: String)

// 상속 시도
// class AdminUser : User("admin")  // ❌ 컴파일 에러
// This type is final, so it cannot be inherited from

open으로 상속 허용

// 상속 가능하게 만들기
open class User(val name: String) {
    open fun greet() {
        println("안녕하세요, $name님!")
    }
}

class AdminUser(name: String) : User(name) {
    override fun greet() {
        println("관리자 $name님 환영합니다!")
    }
}

fun main() {
    val user = User("규철")
    user.greet()  // 안녕하세요, 규철님!
    
    val admin = AdminUser("관리자")
    admin.greet()  // 관리자 관리자님 환영합니다!
}

메서드 오버라이드

open class Animal(val name: String) {
    open fun sound() {
        println("동물 소리")
    }
    
    // final 메서드 (오버라이드 불가)
    fun eat() {
        println("먹는 중...")
    }
}

class Dog(name: String) : Animal(name) {
    override fun sound() {
        println("멍멍!")
    }
    
    // override fun eat() { }  // ❌ 에러 (final 메서드)
}

fun main() {
    val dog = Dog("바둑이")
    dog.sound()  // 멍멍!
    dog.eat()    // 먹는 중...
}

프로퍼티 오버라이드

open class Shape {
    open val vertexCount: Int = 0
}

class Rectangle : Shape() {
    override val vertexCount = 4
}

class Triangle : Shape() {
    override val vertexCount = 3
}

fun main() {
    val rect = Rectangle()
    println("사각형 꼭지점: ${rect.vertexCount}")  // 4
    
    val triangle = Triangle()
    println("삼각형 꼭지점: ${triangle.vertexCount}")  // 3
}

생성자와 상속

open class Person(val name: String, val age: Int) {
    init {
        println("Person 생성: $name, $age세")
    }
}

class Student(
    name: String,
    age: Int,
    val studentId: String
) : Person(name, age) {
    init {
        println("Student 생성: 학번 $studentId")
    }
}

fun main() {
    val student = Student("규철", 20, "2024001")
    // Person 생성: 규철, 20세
    // Student 생성: 학번 2024001
}

Java와 비교

// Java - 기본적으로 상속 가능
public class User {
    private String name;
    
    public void greet() {
        System.out.println("안녕하세요");
    }
}

// 상속 막으려면 final 명시
public final class SecureUser {
    // ...
}
// Kotlin - 기본적으로 상속 불가
class User(val name: String)

// 상속하려면 open 명시
open class OpenUser(val name: String)

추상 클래스 - abstract

기본 추상 클래스

abstract class Shape {
    abstract val area: Double
    abstract fun draw()
    
    // 일반 메서드도 가능
    fun describe() {
        println("도형의 넓이: $area")
    }
}

class Circle(val radius: Double) : Shape() {
    override val area: Double
        get() = Math.PI * radius * radius
    
    override fun draw() {
        println("원 그리기")
    }
}

class Rectangle(val width: Double, val height: Double) : Shape() {
    override val area = width * height
    
    override fun draw() {
        println("사각형 그리기")
    }
}

fun main() {
    // val shape = Shape()  // ❌ 추상 클래스는 인스턴스화 불가
    
    val circle = Circle(5.0)
    circle.draw()
    circle.describe()
    // 원 그리기
    // 도형의 넓이: 78.53981633974483
    
    val rect = Rectangle(10.0, 5.0)
    rect.draw()
    rect.describe()
    // 사각형 그리기
    // 도형의 넓이: 50.0
}

실전 예제 - Repository 패턴

abstract class Repository<T> {
    private val items = mutableListOf<T>()
    
    // 추상 메서드
    abstract fun validate(item: T): Boolean
    
    // 구현된 메서드
    fun save(item: T): Boolean {
        return if (validate(item)) {
            items.add(item)
            true
        } else {
            false
        }
    }
    
    fun findAll(): List<T> = items.toList()
}

data class User(val id: Long, val email: String)

class UserRepository : Repository<User>() {
    override fun validate(item: User): Boolean {
        return item.email.contains("@")
    }
}

fun main() {
    val repo = UserRepository()
    
    repo.save(User(1, "user@example.com"))  // true
    repo.save(User(2, "invalid"))           // false
    
    println(repo.findAll())  // [User(id=1, email=user@example.com)]
}

추상 프로퍼티

abstract class Vehicle {
    abstract val maxSpeed: Int
    abstract val fuelType: String
    
    fun displayInfo() {
        println("최고 속도: ${maxSpeed}km/h")
        println("연료: $fuelType")
    }
}

class ElectricCar : Vehicle() {
    override val maxSpeed = 200
    override val fuelType = "전기"
}

class GasCar : Vehicle() {
    override val maxSpeed = 180
    override val fuelType = "휘발유"
}

fun main() {
    val tesla = ElectricCar()
    tesla.displayInfo()
    
    val sedan = GasCar()
    sedan.displayInfo()
}

인터페이스 - interface

기본 인터페이스

interface Clickable {
    fun click()
    
    // 기본 구현 가능
    fun showClick() {
        println("클릭됨!")
    }
}

interface Focusable {
    fun focus()
    fun showFocus() {
        println("포커스됨!")
    }
}

// 다중 인터페이스 구현
class Button : Clickable, Focusable {
    override fun click() {
        println("버튼 클릭")
    }
    
    override fun focus() {
        println("버튼 포커스")
    }
}

fun main() {
    val button = Button()
    button.click()      // 버튼 클릭
    button.showClick()  // 클릭됨!
    button.focus()      // 버튼 포커스
    button.showFocus()  // 포커스됨!
}

인터페이스 프로퍼티

interface Named {
    val name: String  // 추상 프로퍼티
    
    val displayName: String  // 커스텀 getter
        get() = "이름: $name"
}

class Person(override val name: String) : Named

class Company(override val name: String) : Named {
    override val displayName: String
        get() = "회사명: $name"
}

fun main() {
    val person = Person("규철")
    println(person.displayName)  // 이름: 규철
    
    val company = Company("테크컴퍼니")
    println(company.displayName)  // 회사명: 테크컴퍼니
}

인터페이스 충돌 해결

interface A {
    fun foo() {
        println("A의 foo")
    }
}

interface B {
    fun foo() {
        println("B의 foo")
    }
}

class C : A, B {
    override fun foo() {
        super<A>.foo()  // A의 구현 호출
        super<B>.foo()  // B의 구현 호출
        println("C의 foo")
    }
}

fun main() {
    val c = C()
    c.foo()
    // A의 foo
    // B의 foo
    // C의 foo
}

실전 예제 - 플러그인 시스템

interface Plugin {
    val name: String
    val version: String
    
    fun initialize()
    fun shutdown()
    
    fun getDescription(): String {
        return "$name v$version"
    }
}

class LoggingPlugin : Plugin {
    override val name = "Logging"
    override val version = "1.0.0"
    
    override fun initialize() {
        println("로깅 플러그인 초기화")
    }
    
    override fun shutdown() {
        println("로깅 플러그인 종료")
    }
}

class CachePlugin : Plugin {
    override val name = "Cache"
    override val version = "2.0.0"
    
    override fun initialize() {
        println("캐시 플러그인 초기화")
    }
    
    override fun shutdown() {
        println("캐시 플러그인 종료")
    }
}

class PluginManager {
    private val plugins = mutableListOf<Plugin>()
    
    fun register(plugin: Plugin) {
        plugins.add(plugin)
        plugin.initialize()
    }
    
    fun shutdownAll() {
        plugins.forEach { it.shutdown() }
    }
    
    fun listPlugins() {
        plugins.forEach { 
            println(it.getDescription())
        }
    }
}

fun main() {
    val manager = PluginManager()
    
    manager.register(LoggingPlugin())
    manager.register(CachePlugin())
    
    manager.listPlugins()
    manager.shutdownAll()
}

Sealed Class - 제한된 클래스 계층

기본 Sealed Class

같은 파일 내에서만 상속 가능한 제한된 클래스

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
    object Loading : Result()
}

fun handleResult(result: Result) {
    when (result) {
        is Result.Success -> println("성공: ${result.data}")
        is Result.Error -> println("실패: ${result.message}")
        Result.Loading -> println("로딩 중...")
        // else 불필요! (모든 경우 처리됨)
    }
}

fun main() {
    handleResult(Result.Success("데이터 로드 완료"))
    handleResult(Result.Error("네트워크 오류"))
    handleResult(Result.Loading)
}

실전 예제 - API 응답

sealed class ApiResponse<out T> {
    data class Success<T>(val data: T) : ApiResponse<T>()
    data class Error(val code: Int, val message: String) : ApiResponse<Nothing>()
    object Loading : ApiResponse<Nothing>()
}

data class User(val id: Long, val name: String)

fun fetchUser(userId: Long): ApiResponse<User> {
    return when {
        userId > 0 -> ApiResponse.Success(User(userId, "규철"))
        userId == 0L -> ApiResponse.Loading
        else -> ApiResponse.Error(404, "사용자를 찾을 수 없습니다")
    }
}

fun main() {
    val response = fetchUser(1)
    
    when (response) {
        is ApiResponse.Success -> {
            println("사용자: ${response.data.name}")
        }
        is ApiResponse.Error -> {
            println("에러 ${response.code}: ${response.message}")
        }
        ApiResponse.Loading -> {
            println("로딩 중...")
        }
    }
}

실전 예제 - UI 상태 관리

sealed class UiState {
    object Idle : UiState()
    object Loading : UiState()
    data class Success(val message: String) : UiState()
    data class Error(val throwable: Throwable) : UiState()
}

class ViewModel {
    private var state: UiState = UiState.Idle
    
    fun loadData() {
        state = UiState.Loading
        
        try {
            // 데이터 로드
            Thread.sleep(1000)
            state = UiState.Success("데이터 로드 완료")
        } catch (e: Exception) {
            state = UiState.Error(e)
        }
    }
    
    fun render() {
        when (state) {
            UiState.Idle -> println("대기 중")
            UiState.Loading -> println("로딩 중...")
            is UiState.Success -> println((state as UiState.Success).message)
            is UiState.Error -> println("오류: ${(state as UiState.Error).throwable.message}")
        }
    }
}

fun main() {
    val viewModel = ViewModel()
    viewModel.render()    // 대기 중
    
    viewModel.loadData()
    viewModel.render()    // 데이터 로드 완료
}

Enum vs Sealed Class

// Enum - 모든 인스턴스가 동일한 타입
enum class Color(val rgb: Int) {
    RED(0xFF0000),
    GREEN(0x00FF00),
    BLUE(0x0000FF)
}

// Sealed Class - 각 하위 클래스가 다른 데이터 가질 수 있음
sealed class NetworkResult {
    data class Success(val data: String, val timestamp: Long) : NetworkResult()
    data class Error(val exception: Exception, val retryCount: Int) : NetworkResult()
    object Timeout : NetworkResult()
}

fun main() {
    // Enum - 단순한 상수
    val color = Color.RED
    
    // Sealed - 복잡한 데이터
    val result = NetworkResult.Success("데이터", System.currentTimeMillis())
}

위임 패턴 - by 키워드

클래스 위임

interface Printer {
    fun print(message: String)
}

class ConsolePrinter : Printer {
    override fun print(message: String) {
        println("콘솔: $message")
    }
}

class Logger(printer: Printer) : Printer by printer {
    fun log(level: String, message: String) {
        print("[$level] $message")
    }
}

fun main() {
    val logger = Logger(ConsolePrinter())
    
    logger.print("일반 메시지")        // 콘솔: 일반 메시지
    logger.log("INFO", "정보 로그")   // 콘솔: [INFO] 정보 로그
}

프로퍼티 위임

import kotlin.properties.Delegates

class User {
    // lazy - 처음 사용할 때 초기화
    val expensiveData: String by lazy {
        println("데이터 로드 중...")
        "무거운 데이터"
    }
    
    // observable - 값 변경 감지
    var name: String by Delegates.observable("초기값") { prop, old, new ->
        println("${prop.name}: $old -> $new")
    }
    
    // vetoable - 값 변경 검증
    var age: Int by Delegates.vetoable(0) { _, old, new ->
        new >= 0  // 음수면 변경 거부
    }
}

fun main() {
    val user = User()
    
    // lazy 테스트
    println("첫 접근")
    println(user.expensiveData)  // 데이터 로드 중... 무거운 데이터
    println("두 번째 접근")
    println(user.expensiveData)  // 무거운 데이터 (로드 안 함)
    
    // observable 테스트
    user.name = "규철"  // name: 초기값 -> 규철
    user.name = "영희"  // name: 규철 -> 영희
    
    // vetoable 테스트
    user.age = 30   // OK
    user.age = -5   // 거부됨 (age는 여전히 30)
    println(user.age)  // 30
}

커스텀 위임

import kotlin.reflect.KProperty

class UpperCaseDelegate {
    private var value: String = ""
    
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return value.uppercase()
    }
    
    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        this.value = value
    }
}

class User {
    var name: String by UpperCaseDelegate()
}

fun main() {
    val user = User()
    user.name = "kotlin"
    println(user.name)  // KOTLIN
}

실전 패턴 모음

패턴 1: 템플릿 메서드 패턴

abstract class DataProcessor {
    // 템플릿 메서드
    fun process() {
        loadData()
        validateData()
        transformData()
        saveData()
    }
    
    protected abstract fun loadData()
    protected abstract fun validateData()
    protected abstract fun transformData()
    protected abstract fun saveData()
}

class UserDataProcessor : DataProcessor() {
    override fun loadData() {
        println("사용자 데이터 로드")
    }
    
    override fun validateData() {
        println("사용자 데이터 검증")
    }
    
    override fun transformData() {
        println("사용자 데이터 변환")
    }
    
    override fun saveData() {
        println("사용자 데이터 저장")
    }
}

fun main() {
    val processor = UserDataProcessor()
    processor.process()
}

패턴 2: 전략 패턴

interface PaymentStrategy {
    fun pay(amount: Int)
}

class CreditCardStrategy : PaymentStrategy {
    override fun pay(amount: Int) {
        println("신용카드로 ${amount}원 결제")
    }
}

class KakaoPayStrategy : PaymentStrategy {
    override fun pay(amount: Int) {
        println("카카오페이로 ${amount}원 결제")
    }
}

class PaymentProcessor(private var strategy: PaymentStrategy) {
    fun setStrategy(strategy: PaymentStrategy) {
        this.strategy = strategy
    }
    
    fun processPayment(amount: Int) {
        strategy.pay(amount)
    }
}

fun main() {
    val processor = PaymentProcessor(CreditCardStrategy())
    processor.processPayment(10000)  // 신용카드로 10000원 결제
    
    processor.setStrategy(KakaoPayStrategy())
    processor.processPayment(20000)  // 카카오페이로 20000원 결제
}

패턴 3: 상태 패턴 with Sealed Class

sealed class OrderState {
    abstract fun next(): OrderState
    abstract fun cancel(): OrderState
    
    object Created : OrderState() {
        override fun next() = Paid
        override fun cancel() = Cancelled
    }
    
    object Paid : OrderState() {
        override fun next() = Shipped
        override fun cancel() = Cancelled
    }
    
    object Shipped : OrderState() {
        override fun next() = Delivered
        override fun cancel() = this  // 배송 중엔 취소 불가
    }
    
    object Delivered : OrderState() {
        override fun next() = this
        override fun cancel() = this
    }
    
    object Cancelled : OrderState() {
        override fun next() = this
        override fun cancel() = this
    }
}

class Order(private var state: OrderState = OrderState.Created) {
    fun nextState() {
        state = state.next()
        printState()
    }
    
    fun cancel() {
        state = state.cancel()
        printState()
    }
    
    private fun printState() {
        val stateName = when (state) {
            OrderState.Created -> "주문 생성"
            OrderState.Paid -> "결제 완료"
            OrderState.Shipped -> "배송 중"
            OrderState.Delivered -> "배송 완료"
            OrderState.Cancelled -> "주문 취소"
        }
        println("현재 상태: $stateName")
    }
}

fun main() {
    val order = Order()
    
    order.nextState()  // 결제 완료
    order.nextState()  // 배송 중
    order.cancel()     // 배송 중 (취소 불가)
    order.nextState()  // 배송 완료
}

마무리 - 다음 편 예고

오늘 배운 것 ✅

  • open - 명시적 상속 허용
  • abstract - 추상 클래스와 메서드
  • interface - 다중 상속과 기본 구현
  • sealed class - 제한된 클래스 계층
  • by - 위임 패턴
  • 실전 디자인 패턴 - 템플릿, 전략, 상태 패턴

다음 편에서 배울 것 📚

12편: Delegation 완벽 가이드 | by 키워드 심화

  • 클래스 위임 심화
  • 프로퍼티 위임 심화 (lazy, observable, vetoable)
  • Map 위임
  • 커스텀 위임 프로퍼티 만들기
  • 실전 위임 패턴

실습 과제 💪

// 1. 상속 구조 설계
// - 도형 클래스 계층 (Shape, Circle, Rectangle)
// - 동물 클래스 계층 (Animal, Dog, Cat)

// 2. Sealed Class 활용
// - 네트워크 요청 상태 관리
// - 화면 상태 관리

// 3. 디자인 패턴 구현
// - 템플릿 메서드 패턴
// - 전략 패턴
// - 상태 패턴

// 4. 위임 활용
// - 로깅 기능 위임
// - 캐시 기능 위임

자주 묻는 질문 (FAQ)

Q: 왜 기본적으로 final인가요?
A: 의도하지 않은 상속으로 인한 문제를 방지합니다. Effective Java의 "상속을 위한 설계와 문서화, 그렇지 않으면 상속 금지" 원칙을 따릅니다.

Q: abstract class vs interface 언제 뭘 쓰나요?
A: 상태(필드)가 필요하면 abstract class, 다중 상속이 필요하면 interface를 쓰세요.

Q: Sealed class vs Enum의 차이는?
A: Enum은 단일 인스턴스, Sealed는 각 하위 클래스가 다른 데이터를 가질 수 있습니다.

Q: by 위임은 언제 사용하나요?
A: 보일러플레이트 코드를 줄이고, 기능을 재사용할 때 사용합니다.


관련 글


💬 댓글로 알려주세요!

  • Sealed class를 어디에 활용하고 계신가요?
  • 어떤 디자인 패턴을 자주 사용하시나요?
  • 이 글이 도움이 되셨나요?
반응형
Comments