본문 바로가기

프로그래밍 언어 기초/KOTLIN

[Kotlin] 객체지향 프로그래밍 - 추상클래스와 인터페이스

개요

오늘은 코틀린의 추상 클래스(Abstract Class)와 인터페이스(Interface)에 대해서 알아보도록 하겠습니다.


코틀린의 추상 클래스(Abstract Class)

지난 포스팅에서는 코틀린의 상속에 대해서 알아보았습니다.

객체지향 프로그래밍에서 상속은 반복되는 코드의 작성을 줄여주고 유지 보수성을 편리하게 하는 이점이 있습니다.


그런데, 다음의 상황을 한 번 생각해보도록 하겠습니다.

코틀린 개발자가 자동차 시뮬레이션 프로그램을 만들기 위해 멋진 객체를 설계해보려고 합니다.

시뮬레이션 프로그램에서는 일반 자동차뿐만 아니라 이륜 오토바이도 함께 주행 테스트를 해야 한다고 합니다.

이때 자동차와 오토바이는 모두 엔진을 가지고 있으므로 공통점이 있지만,

자동차는 4기통 엔진이 들어가고, 오토바이는 2기통 엔진이 들어가서

오토바이와 자동차는 공학적으로 서로 구분되는 엔진을 가져야 한다고 합니다.

그래서, 아래와 같이 멋지게 코드를 설계했다고 합니다.

open class Vehicle() {
    val enginePower:Int = 30;
    fun drive(){
        println("broom.. engine power is $enginePower")
    }
}
class Car : Vehicle() {

}
class Motorcycle : Vehicle() {

}

fun main() {
    val car = Car();
    val motorcylce = Motorcycle();

    car.drive(); // broom.. engine power is 30
    motorcylce.drive(); // broom.. engine power is 30
}

 

 

위 코드를 살펴보면 공통된 `엔진`이라는 부품을 포함하기 위해

프로퍼티 enginePower를 부모 클래스에서 선언하고, 이를 각각 Car 클래스와 Motorcycle 클래스에서 상속받도록 했습니다.

이에 따라 Car 클래스와 Motorcycle 클래스는 클래스 내부에서

별도로 enginePower 변수와 drive() 메서드를 구현할 필요가 없어서 노고를 줄일 수 있었습니다!

하지만, 위 코드에는 한 가지 문제점이 있습니다.

앞선 예시에서 자동차와 오토바이는 공학적으로 서로 엔진이 달라 enginePower 값이 서로 다르고,

주행 기능에 해당하는 drive() 메서드에서도 자동차와 오토바이의 기계적 메커니즘이 달라 출력문이 서로 구분되어야 한다고 합니다.

그렇다면 이 경우에는 상속으로 문제를 해결하기 어려워집니다.

왜냐하면 부모 클래스 Vehicle 클래스를 통해 공통점을 추려야 하는데,

자동차에 맞춰 구성하자니 오토바이의 사양이 맞지 않고 오토바이에 맞춰 구성하자니 자동차의 사양에 맞지 않습니다.

이러한 경우에 활용할 수 있는 것이 바로 추상 클래스(Abstract Class)입니다.

 

추상 클래스는 일반 클래스와 문법적으로 거의 유사하나,

프로퍼티나 메서드의 구현을 자식 클래스에서 강제하여 유연하게 클래스를 상속받고

자식 클래스마다 서로 다른 방식으로 메서드나 변수를 구성할 수 있습니다.

 

abstract class Vehicle() {

    abstract val enginePower:Int;

    abstract fun drive()

}
class Car : Vehicle() {
override val enginePower: Int = 100

    override fun drive() {
        println("Car is driving...")
    }
}
class Motorcycle : Vehicle() {
    override val enginePower: Int = 60

    override fun drive() {
        println("Motorcycle is driving...")
    }
}

fun main() {
    val car = Car();
    val motorcylce = Motorcycle();

    car.drive(); // Car is driving...
    motorcylce.drive(); // Motorcycle is driving...
}

 

코틀린에서 추상 클래스는 abstract 키워드를 붙여서 만들 수 있습니다.

그리고 추상 클래스에 존재하는 프로퍼티와 메서드는 abstract 키워드를 통해

추상 클래스에서 구현하지 아니하고 자식 클래스에서 구현을 강제할 수 있습니다.

따라서 이제 Car 클래스와 Motorcycle 클래스는 변덕스러운 고객의 요구사항에 맞추어 대응할 수 있게 되었습니다.

그런데, 위 코드를 자세히 살펴보니 drive 메서드를 잘 수정하면

하나의 메서드로 재사용할 수 있을 것 같다는 생각이 듭니다.

그러면 굳이 자식 클래스에서 drive() 메서드를 일일이 구현하지 않아도 될 것 같기 때문입니다.

 

abstract class Vehicle() {

    abstract val enginePower:Int;

    fun drive(name:String){
        println("$name is driving...")
    }

}
class Car : Vehicle() {
override val enginePower: Int = 100

}
class Motorcycle : Vehicle() {
    override val enginePower: Int = 60
}

fun main() {
    val car = Car();
    val motorcylce = Motorcycle();

    car.drive("Car"); // broom.. engine power is 30
    motorcylce.drive("Motorcycle"); // broom.. engine power is 30
}


이번에는 위와 같이 코드를 변경하여 Vehicle 클래스에서 drive() 메서드를 구현했습니다.

그리고 자식 클래스에서는 drive() 메서드를 구현하지 않아 작성할 코드의 양을 반으로 줄일 수 있었습니다.

이때, 추상 클래스에서 abstract 키워드를 붙이지 않고 작성한 메서드 drive()를 멤버 메서드라고 합니다.

만약 변수를 미리 구현해 두었다면 이는 멤버 변수라고 합니다.

추상 클래스에서 멤버 메서드는 위의 사례처럼 코드의 중복을 줄이고

공통된 로직을 하나로 합치는 데 활용할 수 있습니다.


코틀린의 인터페이스(Interface)

이번에는 아주 변덕스러운 고객이 자동차, 오토바이에 이어 비행기에 대한 시뮬레이션 프로그램을 요청했습니다.

공학적으로 자동차, 오토바이, 비행기는 모두 '엔진'이라는 공통된 부품을 가집니다.

따라서 코틀린 개발자는 이를 모두 반영해서 새로운 시뮬레이션 프로그램을 만들고자 합니다.

 

이때는 어떻게 할 수 있을까요?

우선, 앞에서 다룬 추상 클래스로 한 번 구현을 시도해보도록 하겠습니다.

 

abstract class Vehicle() {
    abstract val enginePower:Int;

    fun drive(name:String){
        println("$name is driving...")
    }

    fun fly(){
        println("Vehicle is Fly....")
    }
}

class Car : Vehicle() {
	override val enginePower: Int = 100

}
class Motorcycle : Vehicle() {
    override val enginePower: Int = 60
}

class Airplane : Vehicle() {
    override val enginePower: Int = 300
}

fun main() {
    val car = Car();
    val motorcylce = Motorcycle();
    val airplane = Airplane()

    car.drive("Car"); // Car is driving...
    motorcylce.drive("Motorcycle"); // Motorcycle is driving...
    airplane.fly(); // Vehicle is Fly....
}

 

위 코드에서 추상 클래스 Vehicle에는

새롭게 추가된 클래스 Airplane의 비행 기능을 위해 fly()라는 메서드를 새롭게 구현하였습니다.

그런데, 여기서 이상한 점이 한 가지 존재합니다.

Vehicle 클래스에서 자동차와 오토바이를 겨냥한 drive() 메서드도 있고,

 

비행기를 겨냥한 fly() 메서드도 있어서 모든 경우에 대응할 수 있었지만

지금 살펴보니 자동차도 오토바이도 모두 비행 기능 즉, fly() 기능을 사용할 수 있고

비행기도 주행 기능 drive()를 사용할 수 있었습니다!

후자의 경우에는 논리적으로 어떻게든 설명할 수 있다고 해도,

전자의 경우에는 도무지 논리적으로 설명할 근거가 없어 난처한 상황입니다.

이러한 상황을 어떻게 해결할 수 있을까요?

이때 활용할 수 있는 기술이 바로 인터페이스(Interface)입니다.

인터페이스(Interface)는 객체 지향 프로그래밍에서 클래스의 행위를 정의하는 데 사용되는 도구입니다.

인터페이스에서도 마찬가지로 추상 메서드, 프로퍼티를 제공할 수 있으며

추상 클래스의 멤버 변수, 멤버 메서드처럼 공통된 기능의 처리를 위해 사전에 설계된 기본 메서드를 구현할 수 있습니다.

또한, 코틀린에서 모든 객체는 단 하나의 클래스만 상속받을 수 있습니다.

그러므로 만약 어떤 클래스가 추상 클래스를 상속받았다면, 다른 클래스를 상속받을 수 없습니다.

 

하지만, 인터페이스(Interface)는 이와 달리 여러 개를 상속할 수 있습니다.
즉 인터페이스만 다중 상속이 가능합니다.

이를 확인해보기 위해 아래의 코드를 살펴보도록 하겠습니다.

 

interface Driving {
    fun drive(name:String){
        println("$name is driving...")
    }
}

interface Flying {
    fun fly()
    fun startUp(): Boolean
}

abstract class Vehicle() {
    abstract val enginePower:Int;
}

class Car : Vehicle(), Driving {
	override val enginePower: Int = 100
}

class Motorcycle : Vehicle(), Driving {
    override val enginePower: Int = 60
}

class Airplane : Vehicle(), Flying {
    override val enginePower: Int = 300

    override fun fly() {
        if(startUp()){
        	println("Airplane is Flying...")
        }
    }

    override fun startUp(): Boolean {
        return true
    }
}


fun main() {
    val car = Car();
    val motorcylce = Motorcycle();
    val airplane = Airplane()

    car.drive("Car"); // Car is driving...
    motorcylce.drive("Motorcycle"); // Motorcycle is driving...
    airplane.fly(); // Airplane is Flying...
}

 

이번에는 주행 기능과 관련된 메서드 drive() 분리해내기 위해 Driving 인터페이스를 만들었습니다.

마찬가지로 비행 기능과 관련된 함수 fly()를 분리해내기 위해 Flying 인터페이스를 만들었습니다.

그런데, 비행기는 엔진의 시동 여부를 확인하고 있으므로 이를 위해 startUp() 메서드를 추가했습니다.

여기서 인터페이스와 추상 클래스의 큰 차이점을 하나 확인할 수 있습니다.

코틀린에서 추상 클래스는 어떤 프로퍼티나 메서드의 구현을 강제하려고 할 때

반드시 abstract 키워드를 붙여서 설계해야 합니다.

하지만, 인터페이스에서는 이 abstract 키워드를 붙이지 않아도 됩니다.

왜냐하면 인터페이스에 작성된 프로퍼티와 메서드는 모두 abstract이기 때문입니다.

그러므로 인터페이스에서는 프로퍼티에 초깃값을 부여하거나,

메서드에서 {} 스코프로 구분되는 함수의 몸체(Body) 부분을 만들 필요가 없습니다.

인터페이스에서 생략된 프로퍼티의 초기화나 함수의 구현은 

모두 해당 인터페이스를 사용하는 클래스에서 담당하기 때문에 인터페이스에서는 작성할 필요가 없습니다.


그러나, 인터페이스 Driving에서 구현된 drive() 메서드처럼

공통된 기능을 합치기 위해서 인터페이스에서 사전에 구현을 해둘 수도 있습니다.

 

이처럼 인터페이스에서 구현을 강제하지 않고, 미리 구현한 메서드를 기본 메서드라고 합니다.

마지막으로 한 가지 사례만 살펴보도록 하겠습니다.

 

이번에는 자동차에 주유 기능을 추가하려고 합니다.

주유 기능은 Oiling 인터페이스에 구현해 두었는데, 이를 Car 클래스에서도 사용하고자 합니다.

 

interface Driving {
    fun drive(name:String){
        println("$name is driving...")
    }
}

interface Oiling{
    fun refuel(oil:Int):Boolean
}

abstract class Vehicle() {
    abstract val enginePower:Int;
}

class Car : Vehicle(), Driving, Oiling {
override val enginePower: Int = 100
    override fun refuel(oil:Int):Boolean {
        println("This car has been filled with oil [$oil L] ");
        return true;
    }
}

fun main() {
    val car = Car();
    car.drive("Car"); // Car is driving...
    car.refuel(100); // This car has been filled with oil [100 L]
}



위 코드에서처럼 인터페이스는 다중 상속을 지원하기 때문에

Oiling 인터페이스에 추가된 주유 기능은 Car 클래스에서도 사용 가능하고,

메인 함수에서도 주유 기능이 정상적으로 동작하는 것을 확인할 수 있었습니다.

이처럼 서로 구분되는 개념이나 기능에 의해 서로 다른 객체로부터

기능을 가져와 활용해야 할 때에는 인터페이스의 다중 상속 기능을 활용할 수 있습니다.