Tiny Star
본문 바로가기
Dev Log/Kotlin

Interface 와 Abstract Class

by 별터 2026. 5. 25.

비슷해 보이지만 다른, 그래서 헷갈리는 둘에 대한 이야기입니다.

 

들어가며

코틀린으로 코드를 짜다 보면 한 번쯤 마주치는 고민이 있습니다.

"이거 인터페이스로 만들까, 추상 클래스로 만들까?"

 

둘 다 공통된 무언가를 추상화한다는 점에서 비슷해 보이지만, 분명한 차이가 있고 상황에 맞게 골라 쓰는 게 좋은 설계의 시작입니다.

오늘은 이 둘의 차이를 알아보고, 언제 무엇을 써야 할지 정리해보겠습니다.

 

정의

Interface

인터페이스는 '할 수 있는 것들의 명세'입니다.

"이 인터페이스를 구현한 애는 이런 걸 할 수 있어야 해"라고 약속하는 것입니다.

interface Clickable {
    fun click()
}

인터페이스는 메서드의 선언만 있을 수 있고(구현은 선택), 상태를 가질 수 없으며(값을 저장 못 함), 한 클래스가 여러 개를 구현할 수 있다는 특징이 있습니다.

 

Abstract Class

추상 클래스는 '미완성된 클래스'입니다.

"공통 부분은 내가 만들어 놓을 테니, 나머지는 자식들이 채워라"라는 설계도와 같습니다.

abstract class Character {
    var hp: Int = 100         // 상태 가능
    abstract fun attack()     // 미완성 부분
}

추상 클래스는 일반 메서드와 추상 메서드를 모두 가질 수 있고, 상태를 가질 수 있으며(필드에 값 저장), 한 클래스가 하나만 상속할 수 있고, 인스턴스화가 불가능합니다(Character()를 만들 수 없음).

 

Interface

인터페이스는 '능력'을 표현합니다

객체가 무엇을 할 수 있는지를 정의합니다. 본질이 아니라 능력에 초점이 있습니다.

interface Flyable {
    fun fly()
}

interface Swimmable {
    fun swim()
}

class Duck : Flyable, Swimmable {
    override fun fly() = println("오리가 난다")
    override fun swim() = println("오리가 헤엄친다")
}

class Airplane : Flyable {
    override fun fly() = println("비행기가 난다")
}

오리와 비행기는 본질이 완전히 다릅니다. 하지만 둘 다 '날 수 있다'는 공통점이 있습니다. 인터페이스는 이렇게 서로 다른 것들을 능력으로 묶어줍니다.

 

1. 다중 구현이 가능합니다

코틀린에서 클래스는 하나만 상속할 수 있지만, 인터페이스는 여러 개를 동시에 구현할 수 있습니다.

class Wizard : Character(), SpellCaster, ManaUser, Damageable {
    // 4개의 능력을 동시에 가짐
}

 

2. 디폴트 구현도 가능합니다.

메서드의 본문을 작성하면 디폴트 구현이 됩니다.

interface Clickable {
    fun click()                                    // 추상 (구현 강제)
    fun showOff() = println("I'm clickable!")     // 디폴트 구현
}

class Button : Clickable {
    override fun click() = println("clicked")
    // showOff는 디폴트 구현을 그대로 사용
}

 

3. 하지만 상태는 가질 수 없습니다

이게 인터페이스의 가장 큰 한계입니다.

interface Damageable {
    val maxHp: Int          // 선언은 가능
    // val maxHp: Int = 100 ❌ 값 저장은 불가능!
}

 

값을 저장할 수 없고, 구현하는 쪽에서 제공해야 합니다.

class Warrior : Damageable {
    override val maxHp = 200    // 여기서 실제로 저장
}

 

Abstract Class

추상 클래스는 '본질'을 표현합니다

객체가 무엇인지, 그 공통된 본질을 정의합니다.

abstract class Character(
    val name: String,
    val maxHp: Int
) {
    var currentHp: Int = maxHp     // 공통 상태
    
    fun takeDamage(amount: Int) {  // 공통 동작
        currentHp -= amount
        println("$name: $amount 데미지")
    }
    
    abstract fun attack()           // 자식이 채워야 할 부분
}

Character는 '캐릭터의 본질'입니다. 모든 캐릭터는 이름과 체력을 갖고, 데미지를 받는 방식도 똑같습니다. 다만 공격 방식만 캐릭터마다 다릅니다.

 

1. 상태와 공통 로직을 한 곳에 모을 수 있습니다

class Warrior : Character("전사", 200) {
    override fun attack() = println("검 공격!")
}

class Mage : Character("마법사", 100) {
    override fun attack() = println("파이어볼!")
}

생성자에 값만 넘기면 됩니다. name, maxHp, currentHp라는 상태를 자식이 다시 선언할 필요가 없습니다.

 

여기서 이런 질문이 떠오를 수 있습니다.

"인터페이스도 디폴트 구현이 있는데, 그러면 똑같지 않나?"

 

메서드는 인터페이스의 디폴트 구현으로도 한 번만 작성할 수 있습니다. 중요한 차이점은 바로 '상태(필드)'에 있습니다.

 

2. 인스턴스화가 안 됩니다

val character = Character("?", 100)  // ❌ 컴파일 에러
val warrior = Warrior()              // ✅ 가능

'그냥 캐릭터'는 세상에 존재할 수 없습니다. 구체적인 전사, 마법사만 존재합니다. 추상 클래스는 이런 개념적인 공통체를 표현합니다(예를 들면 동물, 음식 같은 것들).

 

3. 하나만 상속할 수 있습니다

class Paladin : Character(), HolyKnight()  // ❌ 둘 다 클래스면 불가능

이건 코틀린(과 자바)의 제약입니다. 다중 상속은 복잡한 문제를 일으키기 때문에 막혀있습니다.

 

비교

지금까지의 내용을 '표현하는 것, 상태 보유, 생성자, 다중 사용, 인스턴스화, 디폴트 구현'이라는 여섯 가지 기준으로 정리해보겠습니다.

  인터페이스 추상 클래스
표현하는 것 능력 (~할 수 있다) 본질 (~이다)
상태 보유 불가능  가능
생성자 없음 있음
다중 사용 가능 불가능
인스턴스화 불가능 불가능
디폴트 구현 가능 가능

 

언제 뭘 써야 할까

 

인터페이스는 다음과 같은 상황에 적합합니다.

1. 서로 다른 클래스에 공통된 능력을 부여하고 싶을 때, 2. 여러 능력을 조합해서 사용하고 싶을 때, 3. 구현체에 자유를 주고 싶을 때(저장 방식, 동작 방식 등) 인터페이스를 선택하면 됩니다.

interface Repository<T> {
    fun save(item: T)
    fun findById(id: Long): T?
}
// → 메모리, DB, 파일 등 어떤 방식으로 저장하든 상관없음

 

추상 클래스는 다음과 같은 상황에 적합합니다.

1. 자식들이 공통된 상태를 공유해야 할 때, 2. 공통 로직을 한 곳에 두고 싶을 때, 3. '~이다' 관계가 분명할 때 추상 클래스가 좋은 선택입니다.

abstract class HttpRequest(val url: String, val headers: Map<String, String>) {
    fun send() { /* 공통 로직 */ }
    abstract fun parseResponse(body: String)
}
// → GET, POST 모두 공통된 헤더/URL을 공유

 

실전: 둘은 함께 쓰입니다

실제로는 둘을 조합해서 씁니다. 아래 코드는 게임 캐릭터 시스템을 예로 든 것입니다.

// 능력들 (인터페이스)
interface Damageable {
    fun takeDamage(amount: Int)
}

interface SwordUser {
    fun swingSword()
}

interface ManaUser {
    var mana: Int
    fun castSpell()
}

// 공통 본질 (추상 클래스)
abstract class Character(
    val name: String,
    val maxHp: Int
) : Damageable {
    var currentHp = maxHp
    
    override fun takeDamage(amount: Int) {
        currentHp -= amount
        println("$name: $amount 데미지")
    }
    
    abstract fun attack()
}

// 실제 구현
class Warrior(name: String) : Character(name, 200), SwordUser {
    override fun attack() = swingSword()
    override fun swingSword() = println("$name: 검 휘두름")
}

class Mage(name: String) : Character(name, 100), ManaUser {
    override var mana = 100
    override fun attack() = castSpell()
    override fun castSpell() {
        mana -= 10
        println("$name: 파이어볼!")
    }
}

여기서 추상 클래스 Character는 모든 캐릭터의 공통 본질(이름, 체력, 데미지 처리)을 담당합니다. 인터페이스들은 각 캐릭터가 가질 수 있는 다양한 능력의 조합을 표현합니다.

 

마무리

인터페이스는 자격증과 같습니다. '운전할 수 있다', '요리할 수 있다'는 능력의 증명이고, 한 사람이 여러 자격증을 가질 수 있죠. 자격증 자체가 사람의 본질은 아닙니다.

 

추상 클래스는 미완성 설계도와 같습니다. '사람'의 기본 골격(눈, 코, 입)은 다 있지만, 어떻게 생겼는지의 구체적인 외모는 자식이 채워야 완성됩니다. 미완성이라서 그 자체로는 만들 수 없습니다.

 

핵심 정리

핵심을 간단히 정리하면 이렇습니다. 인터페이스는 '능력'을, 추상 클래스는 '본질'을 표현합니다.

상태가 필요하면 추상 클래스를, 여러 능력을 조합하고 싶다면 인터페이스를 선택하면 됩니다. 그리고 둘은 경쟁 관계가 아니라 보완 관계이기 때문에 보통 같이 사용합니다.

 

"인터페이스만으로 충분하지 않을까?"라는 생각이 들 수 있습니다.
맞습니다. 작은 프로젝트에서는 인터페이스만으로도 충분합니다.
하지만 공통 코드가 반복되기 시작하는 순간, 추상 클래스를 쓰는 날이 올 겁니다.