서론
우아한테크코스의 미션을 진행하며 abstract class와 interface의 차이에 대해서 많은 고민을 하게 되었습니다.
추상 클래스와 인터페이스의 공통점은 추상화입니다. 추상화를 통해 의존성 역전 원칙(DIP)을 적용할 수 있게 되며, 구현체를 사용하는 곳에서 추상화에 의존함으로써 다형성을 통해 구현체를 자유롭게 교체할 수 있습니다.
또한 반대로 클래스를 재활용 할 수 있다는 점에서 클래스 간의 결합도를 느슨하게 만들어 유지보수성을 향상시킬 수 있습니다. 그렇다면 인터페이스와 추상 클래스는 각각 어떤 차이점이 있을까요? 근간에 말하는 상속을 지양하라는 말엔 어떤 이유가 있을까요 ? 이에 대해 알아보겠습니다.
📌 상태를 가질 수 있는가 ?
인터페이스는 상태를 가질 수 없다. 그러나, 추상 클래스는 상태를 가질 수 있다.
상태
객체 지향 프로그래밍에서 상태(State)란 객체가 가지는 데이터(속성, 프로퍼티)를 의미합니다. 객체의 상태는 특정 시점에서 객체가 보유하고 있는 정보를 나타내며 상태를 변경시키는 것은 오직 객체의 행동(Behavior)입니다.
예컨대, Bottle이라는 객체가 외부에서 물을 마시는 행동으로 인해 물의 양이 줄어들 때 물의 양을 감소시키라는 메시지를 전달받아 행동하는 방법(메서드)이 emit, 변경되는 상태는 water입니다.
class Bottle(private var water: Int) {
fun emit() {
water--
}
}
인터페이스에서는 필드를 직접 가질 수 없으며 프로퍼티를 선언할 수는 있지만 반드시 custom getter를 포함해야 합니다.
반면, 추상 클래스는 상태를 가질 수 있습니다. Kotlin의 프로퍼티는 val, var 두 가지로 선언되며 디컴파일시 다음 두 가지 구성 요소를 가지게 되며 val의 경우 final 변수로 선언됩니다.
- val : Field, Getter
- var : Field, Getter & Setter
📌 기본 메서드 구현이 가능한가?
인터페이스와 추상 클래스는 모두 기본 메소드 구현이 가능하다.
추상 클래스를 사용하는 이유 중 하나로 구현체들이 공통으로 가지고 지니는 행동을 최상위 수준으로 끌어올려 재활용해 중복된 코드를 제거할 수 있습니다.
하지만 자바 8부터 인터페이스도 디폴트 메서드를 가질 수 있게 되어 두 메커니즘 모두 인스턴스 메서드를 구현 형태로 제공할 수 있습니다. 자바는 default 키워드를 붙여주면 기본 메서드를 구현할 수 있습니다.
디폴트 메서드가 추가된 이유는 기존에 존재하는 인터페이스에 새 메서드를 추가하면 해당 인터페이스를 구현한 모든 클래스를 모두 수정해야 하는 일이 발생하기 때문입니다. default 메서드를 사용하면 기본 구현을 제공하여 기존 클래스들이 영향을 받지 않도록 할 수 있습니다.
하지만 저는 모든 구현체가 디폴트 메서드를 사용하는 것이 아니라면 인터페이스 분리 원칙(ISP: Interface segregation principle)을 위반하는 행위라고 생각합니다.
ISP는 자신이 호출하지 않는 메서드에 의존하지 않아야 한다는 원칙으로 디폴트 메서드를 사용하지 않는 곳이 존재할 경우 이를 위반한다고 생각합니다.이는 추상 클래스도 마찬가지이며 이런 거대한 인터페이스를 만들기보다 작고 명확한 역할을 가진 인터페이스로 분리해야 합니다.
따라서 디폴트 메서드를 사용하지 않는 구현체가 있다면, 이를 새로운 인터페이스로 분리해야 합니다.
인터페이스에 디폴트 메서드를 추가함으로써 발생할 수 있는 문제들은 Effective Java Item 21 "클래스와 인터페이스"를 읽어보시는 것을 권장드립니다.
앞선 문제들을 고사하고, 저는 구현체들의 공통 로직을 고수준으로 끌어올려 재사용한다는 점에서 인터페이스와 추상 클래스의 용도를 분리할 수 있다고 생각 했습니다. 하지만 Interface에서도 디폴트 메서드로 중복 로직에 대한 추출이 가능해 인터페이스와 추상 클래스의 경계가 모호해졌다고 느꼈습니다.
이를 좀 더 구분해 보고자 인터페이스와 추상 클래스에서 메서드가 정의되는 방식에 대해 알아보겠습니다.
1. 추상 메서드
추상 클래스의 메서드를 하위 클래스에서 상속하기 위해선 abastract 키워드를 명시해야 하는 반면, 인터페이스는 abstract 키워드를 생략할 수 있습니다.
2. 디폴트 메서드
2.1 Inteface
interface Person {
fun foo(){
println("My Name is Peto")
}
}
class Peto: Person
fun main() {
Peto().foo()
}
인터페이스의 디폴트 메서드는 구현체에서 super 키워드를 사용해 호출 또는 오버라이드 할 수 있습니다.
인터페이스의 디폴트 메서드는 다음과 같은 형태로 디컴파일됩니다. 인터페이스에는 메서드의 선언만 들어가며 인터페이스와 함께 생성되는 클래스에는 모든 디폴트 메서드 구현이 정적 메서드로 구현 됩니다.
즉, 디폴트 메서드를 구현하면 컴파일러가 내부적으로 DefaultImpls라는 정적 클래스를 생성해 디폴트 메서드를 구현합니다.
📌 왜 정적 클래스를 생성할까?
Kotlin에서는 인터페이스에 디폴트 메서드를 넣을 수 있지만 Java에서는 인터페이스에 디폴트 메서드를 직접 포함할 수 없기 때문에 "정적 클래스"를 만들어 대신 구현합니다.
분명 자바 8 버전부터 디폴트 메서드를 사용할 수 있다고 했는데 이게 무슨 말일까요 ? 🤔
그 원인은 바로 호환성입니다.
📌 Java 6 및 Java 7과의 호환성 유지
Java 8 이상에서는 인터페이스 내에서 default 메서드를 사용할 수 있지만 본래 Java 6, 7에서는 default 메서드를 지원하지 않습니다.
- Java 8의 default 메서드에 의존하면 Java 6, 7 환경에서는 사용할 수 없는 문제가 발생합니다.
- Kotlin은 기존 Java 버전과도 호환성을 유지해야 하기 때문에 모든 환경에서 동작할 수 있도록 DefaultImpls라는 정적 클래스를 생성하여 해결하는 방식을 사용합니다.
즉, Java 6, 7에서도 정상적으로 동작하기 위해 default 메서드 대신 DefaultImpls를 만든다!
📌 다중 구현시 문제점
만약 두 개의 인터페이스를 구현할 때 이름과 시그니처가 같은 디폴트 메서드가 존재한다면 어떤 일이 일어날까요?
이 경우 두 인터페이스에 정의된 디폴트 메서드 중 어느것도 선택되지 않기 때문에 구현체 클래스가 상위 인터페이스들에 정의된 메서드를 오버라이딩 하지 않으면 다음과 같은 컴파일러 오류가 발생합니다.
이를 해결하기 위해선 구현체 클래스에서 메서드를 오버라이드 하거나 다음과 같이 super <Person1>. foo() 와 같이 상위 타입의 이름을 지정하여 해결 가능하며 두 부모의 메서드를 모두 호출하는 것 또한 가능합니다.
super <Person1>. foo()로 Person1의 상위 메서드를 호출하였기 때문에 디컴파일시 Person1의 정적 메서드를 호출하는 것을 확인할 수 있습니다.
2.2 추상 클래스
abstract class Person {
open fun foo(){
println("My Name is Peto")
}
}
class Peto: Person()
fun main() {
Peto().foo()
}
추상 클래스의 경우 인터페이스와 다르게 디폴트 메서드를 추상 클래스 내부에 바로 구현하고 있습니다. 또한 하위 클래스에서 추상 클래스의 디폴트 메서드를 오버라이드 하기 위해선 메서드가 final이므로 open 키워드를 붙여주어야 합니다. 추상 클래스의 경우 다중 상속이 불가한점, 자바 8 버전 이전과 호환성을 고려하지 않아도 되어 기본 메서드가 그대로 구현되어 있습니다.
📌 가시성 변경자를 가질 수 있는가 ?
Interface의 디폴트 메서드는 가시성 변경자를 지정할 수 있지만 추상 메서드의 가시성 변경자는 모두 public입니다. 반면, 추상 클래스는 protected를 사용해 가시성 변경자를 지정해줄 수 있습니다. 때문에 가시성 변경자의 필요 여부에 따라서 인터페이스와 추상 클래스를 구분지어 사용할 수 있을 것 같습니다.
📌 By Effective Java
Effective Java에선 인터페이스와 추상 클래스를 다음과 같이 비교합니다.
- 추상클래스의 구현체는 추상클래스의 하위 클래스가 된다.
- 인터페이스의 구현체는 같은 타입으로 취급된다.
추상 클래스는 부모-자식 계층 구조를 정의하는 데 적합하며, 인터페이스는 동일한 역할을 수행하는 다양한 구현체들을 위한 공통 계약으로 사용됩니다.
또한 인터페이스는 믹스인(Mixin)이 가능합니다.
믹스인
필요한 기능만 선택적으로 추가할 수 있는 방식으로 "나는 이 기능을 지원합니다"라고 선언하고, 기본 동작도 함께 제공 하는 구조
🦆 오리를 통해 배우는 믹스인
🦆 요구사항
- 오리(Duck)는 여러 종류가 있다.
- 오리는 기본적으로 수영을 할 수 있다.
- 어떤 오리는 날 수 있고, 어떤 오리는 날 수 없다.
- 어떤 오리는 꽥 소리를 낼 수 있고, 어떤 오리는 소리를 낼 수 없다.
- → 모든 오리가 같은 기능을 갖고 있지 않다!
// 오리의 공통 특성
interface Duck {
fun swim()
}
// 믹스인: 날 수 있는 기능
interface Flyable {
fun fly()
}
// 믹스인: 꽥 소리 기능
interface Speakable {
fun quack()
}
// 청둥오리: 날 수 있고 소리도 냄
class MallardDuck(override val name: String) : Duck, Flyable, Speakable
// 고무오리: 날 수 없고 소리도 안 남
class RubberDuck(override val name: String) : Duck
인터페이스는 믹스인(Mixin) 구조를 활용할 수 있어 기능 단위를 선택적으로 조합하거나 데코레이터 패턴처럼 유연한 설계를 할 수 있습니다. 예컨대 오리 예시에서 날 수 있는 기능(Flyable), 소리를 내는 기능(Speakable)을 인터페이스로 선언해두면 각각 필요한 기능만을 선택적으로 조합하여 유연하게 객체를 구성할 수 있습니다.
반면, 템플릿 메서드 패턴처럼 고정된 기능 속에서 변하는 부분만 추상화해야 하는 경우엔 추상 클래스가 더 적절합니다. 템플릿 메서드는 추상 클래스의 대표적인 활용 예로 공통된 로직을 상위 클래스에 정의하고 일부만 하위 클래스에 위임할 수 있도록 구성됩니다. 이는 추상 클래스가 상태를 가질 수 있고 공통 구현을 인터페이스 보다 유연하게 제어할 수 있기 때문입니다.
마무리
오늘은 인터페이스와 추상 클래스의 차이에 대해 알아봤습니다. 저는 그 동안 상속보다 조합, 상속보다 인터페이스를 사용해라 같은 말에 크게 공감해 추상 클래스 사용을 지양해 왔습니다.
하지만 근간에 떠도는 추상 클래스의 문제점들은 추상 클래스의 문제가 아닌 추상 클래스를 잘못 사용했기 때문에 발생하는 문제들 때문입니다. 엘레강트 오브젝트에선 추상 클래스를 다음과 같이 사용할 것을 제안합니다.
클래스를 확장(extend)하는 것이 아니라 정제(refine)할 때 사용해라.
확장은 새로운 행동을 덧붙여 기존의 행동을 부분적으로 보완하는 것을 의미하며, 정제란 부분적으로 불완전한 행동을 완전하게 만드는 것을 의미합니다.
결국 중요한 건 추상 클래스냐 인터페이스냐의 선택이 아니라, 그 도구를 왜 쓰는지, 그리고 어떻게 쓰는지에 대한 고민이라고 생각합니다.
참고 서적
- Effective Java - Joshua Bloch
- Elegant Object - Yegor Bugayenko
'KOTLIN' 카테고리의 다른 글
Kotlin Generic Type System (2) | 2025.04.10 |
---|---|
다시 읽는 Effective Kotlin - Item39. 태그 클래스보다는 클래스 계층을 사용하라 (2) | 2025.03.23 |
다시 읽는 Effective Kotlin - Item33. 생성자 대신 팩토리 함수를 사용하라 (2) | 2025.03.02 |
Channel 내부 동작 분석을 분석해보자 (0) | 2025.02.07 |
Kotlin Value Class With Project Valhalla (4) | 2024.11.22 |