티스토리 뷰

300x250

"둘 다 구현 못 하는 거 아닌가요?"

"추상 클래스와 인터페이스의 차이가 뭔가요?"라고 물으면, 대부분 이렇게 답합니다.

"추상 클래스는 일부 구현이 가능하고, 인터페이스는 구현이 없는 것 아닌가요?" 틀린 말은 아니지만, 이 대답은

문법 수준의 차이만 설명할 뿐 설계 의도의 차이를 전혀 담고 있지 않습니다.

이 글에서는 문법 차이를 넘어, 두 개념이 객체지향 설계에서 어떤 역할을 하고, 어떤 사고방식으로 선택해야 하는지를 하나씩 짚어 보겠습니다.

 


1. 추상 클래스 "is-a" vs 인터페이스 "can-do"

1-1. 추상 클래스: "너는 ~이다" (is-a)

추상 클래스의 본질은 분류(classification) 입니다. "이 객체가 본질적으로 무엇인가?"를 정의합니다.

예를 들어, Dog, Cat, Bird는 본질적으로 동물(Animal) 입니다.

이들이 공유하는 속성(이름, 나이)과 행위(먹다, 자다)가 있고, 이 공통 부분을 한 곳에 모아 놓은 것이 추상 클래스입니다.

여기서 핵심 단어는 "본질" 입니다. Dog가 Animal이라는 사실은 변하지 않습니다. 태어날 때부터 죽을 때까지 동물입니다. 이처럼 추상 클래스는 태생적으로 변하지 않는 정체성을 코드로 표현하는 도구입니다.

추상 클래스 = "이 객체들은 같은 종류다. 공통된 상태와 기본 행위를 공유한다."

 

1-2. 인터페이스: "너는 ~할 수 있다" (can-do)

인터페이스의 본질은 능력(capability)의 계약입니다. "이 객체가 어떤 행동을 할 수 있는가?"를 정의합니다.

예를 들어, Duck은 수영할 수 있고, 날 수도 있습니다.

하지만 수영할 수 있다는 사실이 오리의 본질을 규정하지는 않습니다.

인터페이스는 객체의 정체성이 아니라 역할을 부여합니다. 하나의 객체가 여러 역할을 동시에 수행할 수 있기 때문에, Java에서 인터페이스는 다중 구현(multiple implementation) 이 허용됩니다. 반면, 하나의 객체가 두 가지 본질을 동시에 가질 수는 없으므로 클래스의 다중 상속은 허용되지 않습니다.

인터페이스 = "이 객체는 특정 역할을 수행할 수 있다. 그 방법은 각자 알아서 정한다."

 

1-3. 한 문장 비교

구분 추상 클래스 인터페이스
설계 질문 "이 객체는 본질적으로 무엇인가?" "이 객체는 무엇을 할 수 있는가?"
관계 표현 is-a (수직적 분류) can-do (수평적 역할)
핵심 가치 공통 상태·행위의 재사용 구현과 무관한 계약 정의
728x90

2. 구조적 차이 - "내부에서 무슨 일이 벌어지는가"

2-1. 상태(State)를 가질 수 있는가

추상 클래스는 인스턴스 변수(필드) 를 가질 수 있습니다.

protected String name; 처럼 하위 클래스가 공유할 상태를 선언하고, 생성자를 통해 초기화할 수 있습니다.

이것이 의미하는 바는, 추상 클래스가 데이터를 소유하는 설계 단위라는 것입니다.

인터페이스는 인스턴스 변수를 가질 수 없습니다. 인터페이스 안에 선언한 필드는 자동으로 public static final, 즉 상수가 됩니다. 인터페이스는 "이런 행동을 해라"라는 약속만 정의할 뿐, 그 행동에 필요한 데이터를 직접 들고 있지 않습니다.

 

2-2. 생성자의 유무

추상 클래스는 생성자를 가집니다. 직접 new로 인스턴스화할 수는 없지만, 하위 클래스가 super()를 호출하여 공통 초기화 로직을 실행합니다. 이 생성자가 있다는 것 자체가, 추상 클래스가 "객체의 생명주기에 관여하는 설계 단위"임을 보여 줍니다.

인터페이스는 생성자가 없습니다. 객체의 탄생 과정에 관여하지 않습니다. 오직 "태어난 후 무엇을 할 수 있는가"만 정의합니다.

 

2-3. 한눈에 보는 구조 비교

특성 추상 클래스 인터페이스
인스턴스 변수 ✅ 선언 가능 ❌ (상수만 가능)
생성자 ✅ 있음 ❌ 없음
메서드 구현 ✅ 일반 메서드 + 추상 메서드 ✅ default/static 메서드로 제한적 허용
접근 제어자 자유롭게 사용 (private, protected 등) public만 허용 (추상 메서드 기준)
다중 상속/구현 ❌ 단일 상속만 ✅ 다중 구현 가능

 


3. 코드로 이해하기 — Bad Practice vs Good Practice

3-1. 추상 클래스를 잘못 사용하는 경우

📄 [Bad Practice — 관계 없는 클래스를 억지로 상속시키기]

// ❌ Robot은 Animal이 아닌데 상속을 받고 있다
abstract class Animal {
    protected String name;

    public Animal(String name) {
        this.name = name;
    }

    public void eat() {
        System.out.println(name + "이(가) 먹는다.");
    }

    public abstract void makeSound();
}

// Robot이 Animal을 상속? "로봇은 동물이다"는 거짓
class Robot extends Animal {
    public Robot(String name) {
        super(name);
    }

    @Override
    public void makeSound() {
        System.out.println("삐빅-");
    }
    // eat()이 상속되지만, 로봇은 먹지 않는다 → 설계 오류
}

 

이 코드의 문제는 명확합니다. Robot은 Animal이 아닙니다. "로봇은 동물이다"라는 문장 자체가 성립하지 않습니다.

하지만 makeSound()를 재사용하고 싶다는 이유만으로 상속을 택한 것입니다.

그 결과, Robot은 eat()이라는 의미 없는 메서드를 물려받습니다.

이처럼 is-a 관계가 성립하지 않는 곳에 추상 클래스 상속을 적용하면, 불필요한 의존성과 논리적 모순이 코드에 스며듭니다.

 

📄 [Good Practice — is-a 관계가 명확한 상속 + 역할은 인터페이스로 분리]

// ✅ 동물 계층은 추상 클래스로
abstract class Animal {
    protected String name;

    public Animal(String name) {
        this.name = name;
    }

    public void eat() {
        System.out.println(name + "이(가) 먹는다.");
    }

    public abstract void makeSound();
}

// ✅ "소리를 낼 수 있다"는 역할은 인터페이스로
interface SoundEmittable {
    void makeSound();
}
 
// ✅ Dog는 Animal이다 (is-a 성립)
class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    @Override
    public void makeSound() {
        System.out.println(name + ": 멍멍!");
    }
}

// ✅ Robot은 Animal이 아니다. 소리를 낼 수 있을 뿐이다.
class Robot implements SoundEmittable {
    private String model;

    public Robot(String model) {
        this.model = model;
    }

    @Override
    public void makeSound() {
        System.out.println(model + ": 삐빅-");
    }
}

 

이제 Dog는 Animal을 상속하여 "개는 동물이다" 라는 관계를 표현합니다. Robot은 Animal과 아무 관계가 없고, 단지 SoundEmittable을 구현하여 "로봇은 소리를 낼 수 있다" 라는 역할만 부여받습니다.

설계 원칙: 상속을 적용하기 전에, "A는 B이다"라는 문장을 소리 내어 읽어 보세요. 어색하면 인터페이스가 정답입니다.

 


3-2. 인터페이스를 잘못 사용하는 경우

📄 [Bad Practice — 인터페이스에 공통 로직을 반복 구현하기]

interface Reportable {
    void generateReport();
}

// ❌ 동일한 로직이 두 클래스에 복붙되어 있다
class SalesReport implements Reportable {
    @Override
    public void generateReport() {
        System.out.println("=== 리포트 헤더 ===");  // 중복
        System.out.println("매출 데이터 출력");
        System.out.println("=== 리포트 푸터 ===");  // 중복
    }
}

class InventoryReport implements Reportable {
    @Override
    public void generateReport() {
        System.out.println("=== 리포트 헤더 ===");  // 중복
        System.out.println("재고 데이터 출력");
        System.out.println("=== 리포트 푸터 ===");  // 중복
    }
}

 

SalesReport와 InventoryReport는 모두 리포트라는 같은 종류입니다. 헤더와 푸터를 출력하는 공통 로직이 있지만, 인터페이스만으로는 이 로직을 공유할 방법이 없습니다. 결국 동일한 코드가 복사·붙여넣기됩니다.

 

📄 [Good Practice — 공통 로직은 추상 클래스로, 변하는 부분만 하위에 위임]

// ✅ 추상 클래스가 공통 흐름(템플릿)을 잡는다
abstract class AbstractReport {

    // 템플릿 메서드: 전체 흐름을 정의
    public final void generateReport() {
        printHeader();
        printBody();    // 하위 클래스마다 다른 부분
        printFooter();
    }

    private void printHeader() {
        System.out.println("=== 리포트 헤더 ===");
    }

    private void printFooter() {
        System.out.println("=== 리포트 푸터 ===");
    }

    // 변하는 부분만 추상으로 선언
    protected abstract void printBody();
}
 
 
// ✅ 각 리포트는 Body만 구현하면 된다
class SalesReport extends AbstractReport {
    @Override
    protected void printBody() {
        System.out.println("매출 데이터 출력");
    }
}

class InventoryReport extends AbstractReport {
    @Override
    protected void printBody() {
        System.out.println("재고 데이터 출력");
    }
}

 

이 구조가 바로 템플릿 메서드 패턴(Template Method Pattern) 입니다. 추상 클래스가 전체 흐름(generateReport)을 고정하고, 하위 클래스는 변하는 부분(printBody)만 구현합니다. 중복은 사라지고, 새로운 리포트 종류를 추가할 때도 printBody()만 작성하면 됩니다.

 


4. 다형성 관점에서 바라보기

추상 클래스와 인터페이스 모두 다형성(Polymorphism)의 도구입니다. 하지만 다형성을 발휘하는 이 다릅니다.

 

4-1. 추상 클래스의 다형성: 같은 종류, 다른 행동

Animal dog = new Dog("바둑이");
Animal duck = new Duck("도날드");

// 같은 타입(Animal)으로 묶어서, 각각 다르게 소리 냄
for (Animal animal : List.of(dog, duck)) {
    animal.makeSound();
}
// 바둑이: 멍멍!
// 도날드: 꽥꽥!

 

Animal이라는 하나의 타입으로 Dog와 Duck을 묶었습니다. makeSound()를 호출하면 각 객체의 실제 타입에 맞는 행동이 실행됩니다. 이것이 추상 클래스 기반 다형성입니다. 핵심은, 이 다형성이 같은 계층(같은 종류) 안에서 발생한다는 점입니다.

 

4-2. 인터페이스의 다형성: 다른 종류, 같은 역할

Swimmable duck = new Duck("도날드");
Swimmable penguin = new Penguin("뽀로로");

// 전혀 다른 동물이지만, "수영할 수 있다"는 역할로 묶임
for (Swimmable swimmer : List.of(duck, penguin)) {
    swimmer.swim();
}

 

Duck과 Penguin은 매우 다른 동물입니다. 하지만 Swimmable이라는 역할을 기준으로 묶으면, 같은 코드에서 동일하게 처리할 수 있습니다. 심지어 Animal이 아닌 SwimBot(로봇)도 Swimmable을 구현하면 이 리스트에 들어올 수 있습니다.

이것이 인터페이스 다형성의 힘입니다. 계층을 초월하여, 역할 기준으로 객체를 묶습니다.

 


5. 설계 판단 기준 — 언제 무엇을 선택하는가

코드를 작성하기 전에 스스로에게 세 가지 질문을 던져 보세요.

질문 1: "A는 B이다" 라는 문장이 자연스러운가?

"Dog는 Animal이다" → 자연스럽다 → 추상 클래스 상속을 고려합니다.
"Robot은 Animal이다" → 부자연스럽다 → 상속은 부적절합니다.

 

질문 2: 공유해야 할 상태(필드)나 공통 로직이 있는가?

공통 필드(name, age)와 공통 메서드(eat(), sleep())가 있다면, 이들을 한 곳에 모아야 합니다. 인터페이스는 상태를 가질 수 없으므로, 이 경우 추상 클래스가 적합합니다.

 

질문 3: 하나의 클래스가 여러 역할을 수행해야 하는가?

Duck이 Swimmable이면서 Flyable이어야 한다면, 인터페이스만이 이 요구를 충족할 수 있습니다. Java는 다중 상속을 허용하지 않으므로, 여러 역할의 조합이 필요하면 인터페이스를 사용합니다.

• is-a 관계 + 공통 상태/로직 → 추상 클래스

• can-do 역할 + 다중 조합 가능성 → 인터페이스

• 대부분의 실무 설계에서는 둘을 함께 사용합니다.

 


6. 실무에서 자주 만나는 조합 패턴

실무 프로젝트에서는 추상 클래스와 인터페이스를 대립적으로 선택하는 것이 아니라, 레이어를 나누어 조합합니다. 가장 흔한 패턴은 다음과 같습니다.

 

6-1. 인터페이스 → 추상 클래스 → 구현 클래스 (3계층 패턴)

[인터페이스]        →  계약을 정의한다 (무엇을 하는가)
    ↓
[추상 클래스]       →  공통 구현을 제공한다 (기본적으로 어떻게 하는가)
    ↓
[구현 클래스]       →  세부 사항을 완성한다 (구체적으로 어떻게 하는가)

 

Java 컬렉션 프레임워크가 대표적인 예시입니다.

  • List (인터페이스) → "순서가 있는 컬렉션이다"라는 계약
  • AbstractList (추상 클래스) → indexOf(), iterator() 등 공통 로직 제공
  • ArrayList, LinkedList (구현 클래스) → 내부 자료구조에 따른 구체적 구현

이 패턴의 장점은 명확합니다. 새로운 구현체를 추가할 때 AbstractList를 상속받으면, 이미 구현된 공통 메서드들을 그대로 활용하면서 핵심 메서드(get(), size())만 오버라이드하면 됩니다.

 


마무리

추상 클래스와 인터페이스의 차이는 문법이 아니라 설계 의도에 있습니다.

추상 클래스는 "이것은 무엇인가"를 정의하여 공통 상태와 행위를 상속 계층으로 묶습니다.

인터페이스는 "이것은 무엇을 할 수 있는가"를 선언하여 구현과 무관하게 역할을 부여합니다.

실무에서 이 둘은 양자택일의 대상이 아닙니다. 인터페이스로 계약을 정의하고, 추상 클래스로 공통 구현을 제공하며, 구현 클래스에서 세부 사항을 완성하는 3계층 패턴이 가장 흔하고 효과적인 구조입니다. 어떤 도구를 쓸지 고민될 때는, "A는 B이다"라는 문장을 먼저 만들어 보세요. 그 문장의 자연스러움이 여러분의 설계 방향을 알려 줄 것입니다.

  • 상 클래스는 is-a(정체성), 인터페이스는 can-do(역할) 를 표현한다.
  • 공통 상태(필드)와 초기화 로직이 필요하면 추상 클래스를 사용한다.
  • 다중 역할 조합이 필요하면 인터페이스를 사용한다.
  • 인터페이스 → 추상 클래스 → 구현 클래스 3계층 조합이 가장 효과적이다.
  • 설계 판단의 출발점: "A는 B이다"라는 문장이 자연스러운가?

 

728x90
댓글