클래스와 인터페이스
클래스와 인터페이스 (Classes and Interfaces)
초급 투어에서 클래스와 데이터 클래스로 데이터를 저장하고, 코드 곳곳에서 공유할 수 있는 특징들의 집합을 유지하는 법을 배웠어요. 그런데 프로젝트를 만들다 보면 코드를 효율적으로 공유하기 위해 **계층 구조(hierarchy)**를 만들고 싶어질 때가 오죠. 이 장에서는 Kotlin이 코드를 공유하기 위해 제공하는 옵션들이 무엇인지, 그리고 그들이 어떻게 코드를 더 안전하고 유지보수하기 쉽게 만들어 주는지 살펴볼게요.
출처: Kotlin 공식문서
본문
클래스 상속 (Class inheritance)
이전 장에서 확장 함수를 쓰면 원본 소스 코드를 수정하지 않고도 클래스를 확장할 수 있다는 걸 배웠어요. 그런데 지금 하고 있는 작업이 복잡해서 클래스 사이에서 코드를 공유하면 유용할 때가 있죠. 그럴 때 **클래스 상속(class inheritance)**을 쓰면 돼요.
기본적으로 Kotlin의 클래스는 상속될 수 없어요. Kotlin이 이렇게 설계된 건 의도하지 않은 상속을 막고, 클래스를 더 유지보수하기 쉽게 만들기 위해서예요.
Kotlin의 클래스는 **단일 상속(single inheritance)**만 지원해요. 즉 한 번에 하나의 클래스에서만 상속받을 수 있다는 뜻이에요. 이때 상속을 해 주는 클래스를 **부모 클래스(parent)**라고 불러요.
부모 클래스도 또 다른 클래스(조부모 클래스)에서 상속받을 수 있어서 결과적으로 계층 구조가 만들어져요. Kotlin 클래스 계층 구조의 꼭대기에는 공통 부모 클래스인 Any가 있어요. 모든 클래스는 궁극적으로 Any 클래스에서 상속받게 되죠.
Any 클래스는 toString() 함수를 멤버 함수로 자동으로 제공해요. 그래서 이 상속받은 함수를 어떤 클래스에서든 그대로 쓸 수 있어요. 예를 들어:
class Car(val make: String, val model: String, val numberOfDoors: Int)
fun main() {
val car1 = Car("Toyota", "Corolla", 4)
// Uses the .toString() function via string templates to print class properties
println("Car1: make=${car1.make}, model=${car1.model}, numberOfDoors=${car1.numberOfDoors}")
// Car1: make=Toyota, model=Corolla, numberOfDoors=4
}
클래스 사이에 코드를 공유하기 위해 상속을 쓰고 싶다면, 먼저 **추상 클래스(abstract class)**를 고려해 보세요.
추상 클래스 (Abstract classes)
추상 클래스는 기본적으로 상속될 수 있어요. 추상 클래스의 목적은 다른 클래스가 상속하거나 구현할 멤버를 제공하는 거예요. 그래서 생성자(constructor)는 있지만, 그로부터 인스턴스를 만들 수는 없어요. 자식 클래스 안에서는 override 키워드로 부모의 프로퍼티와 함수의 동작을 정의해요. 이렇게 하면 자식 클래스가 부모 클래스의 멤버를 "오버라이드(override)한다"고 말할 수 있어요.
TIP: 상속받은 함수나 프로퍼티의 동작을 정의하는 것을 **구현(implementation)**이라고 불러요.
추상 클래스는 구현이 있는 함수와 프로퍼티뿐 아니라, 구현이 없는 함수와 프로퍼티(추상 함수와 추상 프로퍼티라고 불러요)도 함께 담을 수 있어요.
추상 클래스를 만들려면 abstract 키워드를 쓰면 돼요:
abstract class Animal
구현이 없는 함수나 프로퍼티를 선언할 때도 abstract 키워드를 써요:
abstract fun makeSound()
abstract val sound: String
예를 들어, 다양한 상품 카테고리를 정의하는 자식 클래스를 만들 수 있는 Product라는 추상 클래스를 만든다고 해 볼게요:
abstract class Product(val name: String, var price: Double) {
// Abstract property for the product category
abstract val category: String
// A function that can be shared by all products
fun productInfo(): String {
return "Product: $name, Category: $category, Price: $price"
}
}
이 추상 클래스에는:
- 상품의
name과price를 받는 두 개의 파라미터가 있는 생성자가 있어요. - 상품 카테고리를 문자열로 담는 추상 프로퍼티가 있어요.
- 상품에 대한 정보를 출력하는 함수가 있어요.
전자제품(electronics)용 자식 클래스를 만들어 볼게요. 자식 클래스에서 category 프로퍼티를 구현하기 전에 반드시 override 키워드를 써야 해요:
class Electronic(name: String, price: Double, val warranty: Int) : Product(name, price) {
override val category = "Electronic"
}
Electronic 클래스는:
Product추상 클래스에서 상속받아요.- 생성자에 추가 파라미터
warranty가 있어요. 전자제품에만 있는 값이죠. category프로퍼티를"Electronic"문자열로 오버라이드해요.
이제 이 클래스들을 이렇게 사용할 수 있어요:
abstract class Product(val name: String, var price: Double) {
// Abstract property for the product category
abstract val category: String
// A function that can be shared by all products
fun productInfo(): String {
return "Product: $name, Category: $category, Price: $price"
}
}
class Electronic(name: String, price: Double, val warranty: Int) : Product(name, price) {
override val category = "Electronic"
}
fun main() {
// Creates an instance of the Electronic class
val laptop = Electronic(name = "Laptop", price = 1000.0, warranty = 2)
println(laptop.productInfo())
// Product: Laptop, Category: Electronic, Price: 1000.0
}
추상 클래스는 이렇게 코드를 공유하는 데 아주 좋지만, Kotlin의 클래스는 단일 상속만 지원하므로 제약이 있어요. 여러 곳에서 상속을 받아야 한다면 **인터페이스(interface)**를 고려해 보세요.
인터페이스 (Interfaces)
인터페이스는 클래스와 비슷하지만 몇 가지 차이점이 있어요:
- 인터페이스의 인스턴스를 만들 수 없어요. 생성자나 헤더(header)가 없죠.
- 인터페이스의 함수와 프로퍼티는 기본적으로 암시적으로 상속될 수 있어요. Kotlin에서는 이런 것을 "open"이라고 말해요.
- 구현을 주지 않는다면 함수를
abstract로 표시할 필요 없어요.
추상 클래스와 비슷하게, 인터페이스는 클래스가 나중에 상속하고 구현할 함수와 프로퍼티 집합을 정의할 때 쓰여요. 이 방식은 구체적인 구현 세부 사항보다 인터페이스가 기술하는 추상화에 집중하게 해 줘요. 인터페이스를 쓰면 코드가:
- 서로 다른 부분을 격리해 각자 독립적으로 발전할 수 있게 하므로 더 모듈화돼요.
- 관련 함수들을 한 덩어리로 묶어 파악하기 더 쉬워져요.
- 테스트할 때 구현을 목(mock)으로 빠르게 갈아 끼울 수 있어 테스트하기 쉬워져요.
인터페이스를 선언하려면 interface 키워드를 쓰면 돼요:
interface PaymentMethod
인터페이스 구현 (Interface implementation)
인터페이스는 다중 상속을 지원해서, 하나의 클래스가 여러 인터페이스를 한 번에 구현할 수 있어요. 먼저 클래스 하나가 인터페이스 하나를 구현하는 상황을 생각해 볼게요.
인터페이스를 구현하는 클래스를 만들려면 클래스 헤더 뒤에 콜론(:)을 붙이고, 구현할 인터페이스 이름을 적으면 돼요. 인터페이스는 생성자가 없으므로 이름 뒤에 괄호 ()를 쓰지 않아요:
class CreditCardPayment : PaymentMethod
예를 들어:
interface PaymentMethod {
// Functions are inheritable by default
fun initiatePayment(amount: Double): String
}
class CreditCardPayment(val cardNumber: String, val cardHolderName: String, val expiryDate: String) : PaymentMethod {
override fun initiatePayment(amount: Double): String {
// Simulate processing payment with credit card
return "Payment of $$amount initiated using Credit Card ending in ${cardNumber.takeLast(4)}."
}
}
fun main() {
val paymentMethod = CreditCardPayment("1234 5678 9012 3456", "John Doe", "12/25")
println(paymentMethod.initiatePayment(100.0))
// Payment of $100.0 initiated using Credit Card ending in 3456.
}
이 예시에서:
PaymentMethod는 구현이 없는initiatePayment()함수를 가진 인터페이스예요.CreditCardPayment는PaymentMethod인터페이스를 구현하는 클래스예요.CreditCardPayment클래스는 상속받은initiatePayment()함수를 오버라이드해요.paymentMethod는CreditCardPayment클래스의 인스턴스예요.- 오버라이드된
initiatePayment()함수가100.0파라미터와 함께paymentMethod인스턴스에서 호출돼요.
여러 인터페이스를 구현하는 클래스를 만들려면 클래스 헤더 뒤에 콜론을 붙이고, 구현할 인터페이스들을 쉼표로 구분해 적으면 돼요:
class CreditCardPayment : PaymentMethod, PaymentType
예를 들어:
interface PaymentMethod {
fun initiatePayment(amount: Double): String
}
interface PaymentType {
val paymentType: String
}
class CreditCardPayment(val cardNumber: String, val cardHolderName: String, val expiryDate: String) : PaymentMethod,
PaymentType {
override fun initiatePayment(amount: Double): String {
// Simulate processing payment with credit card
return "Payment of $$amount initiated using Credit Card ending in ${cardNumber.takeLast(4)}."
}
override val paymentType: String = "Credit Card"
}
fun main() {
val paymentMethod = CreditCardPayment("1234 5678 9012 3456", "John Doe", "12/25")
println(paymentMethod.initiatePayment(100.0))
// Payment of $100.0 initiated using Credit Card ending in 3456.
println("Payment is by ${paymentMethod.paymentType}")
// Payment is by Credit Card
}
이 예시에서:
PaymentMethod는 구현이 없는initiatePayment()함수를 가진 인터페이스예요.PaymentType은 초기화되지 않은paymentType프로퍼티를 가진 인터페이스예요.CreditCardPayment는PaymentMethod와PaymentType인터페이스를 모두 구현하는 클래스예요.CreditCardPayment클래스는 상속받은initiatePayment()함수와paymentType프로퍼티를 오버라이드해요.paymentMethod는CreditCardPayment클래스의 인스턴스예요.- 오버라이드된
initiatePayment()함수가100.0파라미터와 함께paymentMethod인스턴스에서 호출돼요. - 오버라이드된
paymentType프로퍼티가paymentMethod인스턴스에서 접근돼요.
인터페이스와 인터페이스 상속에 대한 자세한 내용은 인터페이스(Interfaces) 문서를 참고하세요.
위임 (Delegation)
인터페이스는 유용하지만, 인터페이스에 함수가 많으면 자식 클래스에 **보일러플레이트 코드(boilerplate code)**가 엄청 많아질 수 있어요. 클래스 동작의 아주 일부만 오버라이드하고 싶은데도 같은 코드를 반복해서 적어야 하니까요.
TIP: 보일러플레이트 코드는 소프트웨어 프로젝트의 여러 곳에서 거의 바꾸지 않고 재사용되는 코드 덩어리를 말해요.
예를 들어, 여러 함수와 color라는 프로퍼티 하나를 가진 DrawingTool 인터페이스가 있다고 해 볼게요:
interface DrawingTool {
val color: String
fun draw(shape: String)
fun erase(area: String)
fun getToolInfo(): String
}
DrawingTool 인터페이스를 구현하고 모든 멤버의 구현을 제공하는 PenTool 클래스를 만들었어요:
class PenTool : DrawingTool {
override val color: String = "black"
override fun draw(shape: String) {
println("Drawing $shape using a pen in $color")
}
override fun erase(area: String) {
println("Erasing $area with pen tool")
}
override fun getToolInfo(): String {
return "PenTool(color=$color)"
}
}
이제 PenTool과 동일한 동작을 하되 color 값만 다른 클래스를 만들고 싶다고 해 볼게요. 한 가지 방법은 PenTool 클래스 인스턴스처럼 DrawingTool 인터페이스를 구현하는 객체를 파라미터로 받는 새 클래스를 만드는 거예요. 그리고 클래스 안에서 color 프로퍼티를 오버라이드하면 되죠.
하지만 이 상황에서는 DrawingTool 인터페이스의 각 멤버에 대한 구현을 일일이 추가해야 해요:
interface DrawingTool {
val color: String
fun draw(shape: String)
fun erase(area: String)
fun getToolInfo(): String
}
class PenTool : DrawingTool {
override val color: String = "black"
override fun draw(shape: String) {
println("Drawing $shape using a pen in $color")
}
override fun erase(area: String) {
println("Erasing $area with pen tool")
}
override fun getToolInfo(): String {
return "PenTool(color=$color)"
}
}
class CanvasSession(val tool: DrawingTool) : DrawingTool {
override val color: String = "blue"
override fun draw(shape: String) {
tool.draw(shape)
}
override fun erase(area: String) {
tool.erase(area)
}
override fun getToolInfo(): String {
return tool.getToolInfo()
}
}
fun main() {
val pen = PenTool()
val session = CanvasSession(pen)
println("Pen color: ${pen.color}")
// Pen color: black
println("Session color: ${session.color}")
// Session color: blue
session.draw("circle")
// Drawing circle with pen in black
session.erase("top-left corner")
// Erasing top-left corner with pen tool
println(session.getToolInfo())
// PenTool(color=black)
}
DrawingTool 인터페이스에 멤버 함수가 아주 많다면, CanvasSession 클래스의 보일러플레이트 코드도 그만큼 방대해질 수 있다는 걸 알 수 있어요. 그런데 대안이 있어요.
Kotlin에서는 by 키워드를 써서 인터페이스 구현을 클래스 인스턴스에 위임할 수 있어요. 예를 들어:
class CanvasSession(val tool: DrawingTool) : DrawingTool by tool
여기서 tool은 PenTool 클래스의 인스턴스 이름이고, 이 인스턴스에 멤버 함수의 구현이 위임돼요.
이제 CanvasSession 클래스에 멤버 함수의 구현을 추가할 필요가 없어요. 컴파일러가 PenTool 클래스로부터 그 구현을 자동으로 가져오니까요. 이 덕분에 보일러플레이트 코드를 많이 쓰지 않아도 돼요. 대신, 자식 클래스에서 바꾸고 싶은 동작에 대한 코드만 추가하면 되죠.
예를 들어 color 프로퍼티의 값을 바꾸고 싶다면:
interface DrawingTool {
val color: String
fun draw(shape: String)
fun erase(area: String)
fun getToolInfo(): String
}
class PenTool : DrawingTool {
override val color: String = "black"
override fun draw(shape: String) {
println("Drawing $shape using a pen in $color")
}
override fun erase(area: String) {
println("Erasing $area with pen tool")
}
override fun getToolInfo(): String {
return "PenTool(color=$color)"
}
}
class CanvasSession(val tool: DrawingTool) : DrawingTool by tool {
// No boilerplate code!
override val color: String = "blue"
}
fun main() {
val pen = PenTool()
val session = CanvasSession(pen)
println("Pen color: ${pen.color}")
// Pen color: black
println("Session color: ${session.color}")
// Session color: blue
session.draw("circle")
// Drawing circle with pen in black
session.erase("top-left corner")
// Erasing top-left corner with pen tool
println(session.getToolInfo())
// PenTool(color=black)
}
원한다면 CanvasSession 클래스에서 상속받은 멤버 함수의 동작을 오버라이드할 수도 있어요. 하지만 이제는 상속받은 모든 멤버 함수마다 새 코드를 추가해야 할 필요가 없어요.
자세한 내용은 위임(Delegation) 문서를 참고하세요.
연습 (Practice)
연습 1
스마트 홈 시스템을 만들고 있다고 상상해 볼게요. 스마트 홈에는 보통 다양한 종류의 기기가 있고, 모두 기본적인 기능을 공유하면서도 각자 고유한 동작을 가져요. 아래 코드 샘플에서 SmartDevice라는 abstract 클래스를 완성해서 자식 클래스 SmartLight가 문제없이 컴파일되도록 해 보세요.
그다음, SmartDevice 클래스에서 상속받고 turnOn()과 turnOff() 함수를 구현하는 SmartThermostat라는 또 다른 자식 클래스를 만드세요. 이 함수들은 어떤 난방기가 난방 중인지, 꺼졌는지를 설명하는 출력문을 돌려주도록 해요. 마지막으로 adjustTemperature()라는 함수를 하나 더 추가해서, 온도 측정값을 입력으로 받아 $name thermostat set to $temperature°C.라고 출력하게 만들어 보세요.
SmartDevice 클래스에는 turnOn()과 turnOff() 함수를 추가해서 나중에 SmartThermostat 클래스에서 그 동작을 오버라이드할 수 있게 해 주세요.
abstract class // Write your code here
class SmartLight(name: String) : SmartDevice(name) {
override fun turnOn() {
println("$name is now ON.")
}
override fun turnOff() {
println("$name is now OFF.")
}
fun adjustBrightness(level: Int) {
println("Adjusting $name brightness to $level%.")
}
}
class SmartThermostat // Write your code here
fun main() {
val livingRoomLight = SmartLight("Living Room Light")
val bedroomThermostat = SmartThermostat("Bedroom Thermostat")
livingRoomLight.turnOn()
// Living Room Light is now ON.
livingRoomLight.adjustBrightness(10)
// Adjusting Living Room Light brightness to 10%.
livingRoomLight.turnOff()
// Living Room Light is now OFF.
bedroomThermostat.turnOn()
// Bedroom Thermostat thermostat is now heating.
bedroomThermostat.adjustTemperature(5)
// Bedroom Thermostat thermostat set to 5°C.
bedroomThermostat.turnOff()
// Bedroom Thermostat thermostat is now off.
}
연습 2
Audio, Video, Podcast 같은 구체적인 미디어 클래스를 구현하는 데 쓸 수 있는 Media 인터페이스를 만들어 보세요. 이 인터페이스는 반드시 다음을 포함해야 해요:
- 미디어의 제목을 나타내는
title프로퍼티. - 미디어를 재생하는
play()함수.
그다음, Media 인터페이스를 구현하는 Audio 클래스를 만드세요. Audio 클래스는 생성자에서 title 프로퍼티를 사용하면서, String 타입의 composer라는 추가 프로퍼티도 가져야 해요. 클래스 안에서 play() 함수를 구현해 "Playing audio: $title, composed by $composer"를 출력하게 해 보세요.
클래스 헤더에서 override 키워드를 쓰면 인터페이스의 프로퍼티를 생성자에서 바로 구현할 수 있어요.
interface // Write your code here
class // Write your code here
fun main() {
val audio = Audio("Symphony No. 5", "Beethoven")
audio.play()
// Playing audio: Symphony No. 5, composed by Beethoven
}
연습 3
전자상거래 애플리케이션용 결제 처리 시스템을 만들고 있어요. 각 결제 수단은 결제를 승인하고 거래를 처리할 수 있어야 해요. 일부 결제는 환불도 처리할 수 있어야 하죠.
Refundable인터페이스에 환불을 처리하는refund()함수를 추가하세요.- 결제 금액을 받아 그 금액이 담긴 메시지를 출력하는
authorize()함수를 추가하세요. - 역시 금액을 받는 추상 함수
processPayment()를 추가하세요. "Refunding $amount to the credit card.""Processing credit card payment of $amount."
interface Refundable {
// Write your code here
}
abstract class PaymentMethod(val name: String) {
// Write your code here
}
class CreditCard // Write your code here
fun main() {
val visa = CreditCard("Visa")
visa.authorize(100.0)
// Authorizing payment of $100.0.
visa.processPayment(100.0)
// Processing credit card payment of $100.0.
visa.refund(50.0)
// Refunding $50.0 to the credit card.
}
연습 4
기본적인 기능만 있는 간단한 메시징 앱이 있는데, 코드를 크게 중복하지 않고 스마트 메시지용 기능을 추가하고 싶어요.
아래 코드에서 BasicMessenger 클래스의 인스턴스에 구현을 위임하면서 Messenger 인터페이스에서 상속받는 SmartMessenger 클래스를 정의하세요.
SmartMessenger 클래스에서 스마트 메시지를 보내도록 sendMessage() 함수를 오버라이드하세요. 이 함수는 message를 입력으로 받아 "Sending a smart message: $message" 출력문을 돌려줘야 해요. 추가로 BasicMessenger 클래스의 sendMessage() 함수를 호출하면서 메시지 앞에 [smart]를 붙여 주세요.
NOTE:
SmartMessenger클래스에서receiveMessage()함수를 다시 작성할 필요는 없어요.
interface Messenger {
fun sendMessage(message: String)
fun receiveMessage(): String
}
class BasicMessenger : Messenger {
override fun sendMessage(message: String) {
println("Sending message: $message")
}
override fun receiveMessage(): String {
return "You've got a new message!"
}
}
class SmartMessenger // Write your code here
fun main() {
val basicMessenger = BasicMessenger()
val smartMessenger = SmartMessenger(basicMessenger)
basicMessenger.sendMessage("Hello!")
// Sending message: Hello!
println(smartMessenger.receiveMessage())
// You've got a new message!
smartMessenger.sendMessage("Hello from SmartMessenger!")
// Sending a smart message: Hello from SmartMessenger!
// Sending message: [smart] Hello from SmartMessenger!
}