|

추상 클래스와 인터페이스 차이: 자바와 객체지향 설계 관점에서 언제 무엇을 써야 할까

추상 클래스와 인터페이스 선택 기준을 설명하는 자바 객체지향 대표 이미지
공통 상태를 묶을지, 역할 계약을 열어둘지에 따라 선택 기준이 달라진다

추상 클래스와 인터페이스 차이를 표로만 외우면 실제 설계에서 자주 막힙니다. 이 글에서는 자바 문법 나열보다 한 단계 더 들어가서, 공통 상태를 묶어야 하는지, 역할 계약만 열어두면 되는지, 다중 타입 표현이 필요한지를 기준으로 언제 무엇을 쓰면 좋은지 정리해보겠습니다.


판단 기준

추상 클래스와 인터페이스를 고를 때는 정의보다 질문이 먼저입니다. 공통 상태가 필요한지, 여러 구현체가 같은 역할만 약속하면 되는지, 서로 관련 없는 클래스도 이 타입을 구현해야 하는지를 먼저 보면 선택이 훨씬 쉬워집니다.

즉, 추상 클래스 vs 인터페이스는 문법 취향이 아니라 변경을 어디에 묶을지 정하는 문제입니다.

추상 클래스와 인터페이스 선택 흐름도
공통 상태와 계약 중심 여부를 먼저 보면 선택이 빨라진다

추상 클래스

Oracle의 자바 공식 문서는 추상 클래스를 서로 밀접하게 관련된 클래스들이 코드와 상태를 공유할 때 고려하라고 설명합니다. 실무적으로 보면 공통 필드가 있고, 생성 시점 검증이 필요하고, 하위 클래스가 같은 골격을 따라야 할 때에 가깝습니다.

abstract class Payment {
    protected final BigDecimal amount;

    protected Payment(BigDecimal amount) {
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
        this.amount = amount;
    }

    public final void pay() {
        validateBusinessRule();
        requestPayment();
        writeAuditLog();
    }

    protected void validateBusinessRule() {
        if (amount.compareTo(new BigDecimal("1000000")) > 0) {
            throw new IllegalStateException("limit exceeded");
        }
    }

    protected abstract void requestPayment();

    private void writeAuditLog() {
        System.out.println("audit :: amount=" + amount);
    }
}

class CardPayment extends Payment {
    public CardPayment(BigDecimal amount) {
        super(amount);
    }

    @Override
    protected void requestPayment() {
        System.out.println("request card payment");
    }
}

class BankTransferPayment extends Payment {
    public BankTransferPayment(BigDecimal amount) {
        super(amount);
    }

    @Override
    protected void requestPayment() {
        System.out.println("request bank transfer");
    }
}

여기서 중요한 것은 pay()라는 이름만 맞추는 일이 아닙니다. 금액 상태를 함께 들고, 공통 검증을 거치고, 기본 흐름을 강제하는 것이 목적입니다. 이런 경우 인터페이스보다 추상 클래스가 더 자연스럽습니다.

  • 공통 필드가 필요하다
  • 생성자에서 공통 검증을 해야 한다
  • 하위 클래스에 protected 수준의 기본 로직을 열어두고 싶다
  • 기본 흐름은 고정하고 일부 단계만 바꾸고 싶다

인터페이스

반대로 인터페이스는 상태 공유보다 계약이 핵심일 때 힘이 좋습니다. 자바 공식 문서도 서로 관련 없는 클래스들이 같은 동작 약속을 따라야 할 때 인터페이스를 고려하라고 설명합니다.

interface PaymentNotifier {
    void notify(String message);
}

class EmailNotifier implements PaymentNotifier {
    @Override
    public void notify(String message) {
        System.out.println("EMAIL :: " + message);
    }
}

class SlackNotifier implements PaymentNotifier {
    @Override
    public void notify(String message) {
        System.out.println("SLACK :: " + message);
    }
}

class PushNotifier implements PaymentNotifier {
    @Override
    public void notify(String message) {
        System.out.println("PUSH :: " + message);
    }
}

이 구조에서 중요한 것은 세 구현체가 같은 부모 상태를 공유하느냐가 아닙니다. 알림을 보낼 수 있다는 역할 계약이 중요합니다. 그래서 호출하는 쪽은 구현체 세부를 몰라도 됩니다.

class PaymentService {
    private final PaymentNotifier notifier;

    public PaymentService(PaymentNotifier notifier) {
        this.notifier = notifier;
    }

    public void complete() {
        notifier.notify("payment completed");
    }
}
  • 구현체 사이에 공통 상태는 없다
  • 역할만 맞으면 된다
  • 서로 다른 계층이나 모듈의 클래스가 함께 구현할 수 있어야 한다
  • 주입, 대체, 테스트가 더 중요하다

상태

많은 비교 글이 다중 구현 가능 여부에만 집중하지만, 설계에서 더 큰 차이는 공통 상태를 함께 들고 갈 필요가 있느냐입니다. Oracle 문서도 추상 클래스는 non-static, non-final 필드를 둘 수 있지만, 인터페이스 필드는 자동으로 public static final이라고 설명합니다.

즉, 인터페이스는 인스턴스별 상태를 위한 그릇이 아닙니다. 그래서 생성자, 공통 필드, protected 훅, 기본 흐름을 한 구조 안에 묶고 싶다면 추상 클래스가 더 자연스러울 수 있습니다.


default method

자바 8 이후 인터페이스에도 default method가 생겼습니다. 하지만 그 핵심 목적은 기존 구현체를 깨지 않으면서 인터페이스에 새 기능을 추가하는 데 있습니다.

interface PaymentNotifier {
    void notify(String message);

    default String channelLabel() {
        return getClass().getSimpleName();
    }
}

이 정도의 공통 동작은 인터페이스에 두기 좋습니다. 하지만 default method가 있다고 해서 인터페이스가 상태 공유, 생성자, protected 내부 훅까지 자연스럽게 대신하는 것은 아닙니다.

default method는 인터페이스를 더 쓸모 있게 만들었지만, 추상 클래스의 역할까지 지우지는 못했습니다.


다중 구현

자바는 클래스 다중 상속을 허용하지 않지만 여러 인터페이스 구현은 허용합니다. 그래서 한 클래스가 여러 역할 타입을 함께 가져야 할 때 인터페이스가 더 잘 맞는 경우가 많습니다.

interface Payable {
    void pay();
}

interface Retryable {
    boolean canRetry();
}

interface Auditable {
    void writeAuditLog();
}

class SubscriptionPayment implements Payable, Retryable, Auditable {
    @Override
    public void pay() {
        System.out.println("subscription pay");
    }

    @Override
    public boolean canRetry() {
        return true;
    }

    @Override
    public void writeAuditLog() {
        System.out.println("subscription audit");
    }
}

이런 경우 인터페이스는 역할 조합에 강합니다. 반대로 추상 클래스는 한 번 상속하면 구조 축이 고정되기 때문에 정말 같은 뿌리를 공유하는지 더 엄격하게 봐야 합니다.


오해

인터페이스 우선

인터페이스는 느슨한 결합에 좋지만, 구현체가 하나뿐이고 가까운 미래에도 계약 분리 이점이 거의 없고 공통 상태와 기본 흐름이 더 중요하다면 추상 클래스가 더 단순할 수 있습니다. 이름만 분리된 SomethingServiceSomethingServiceImpl이 늘어나는 구조는 오히려 읽기만 어려워질 수 있습니다.

상속 회피

상속이 위험해지는 이유는 보통 약한 관계에 강한 부모-자식 구조를 억지로 만들기 때문입니다. 하지만 공통 상태와 흐름이 안정적이고 하위 타입이 같은 골격을 따라야 한다면 추상 클래스는 여전히 좋은 선택입니다. 문제는 상속 자체가 아니라 약한 관계에 강한 구조를 강요하는 것입니다.


선택 기준

  1. 공통 필드와 생성 규칙이 필요한가
  2. 하위 클래스에 protected 로직이나 기본 흐름을 공유해야 하는가
  3. 서로 관련 없는 클래스도 이 역할을 구현해야 하는가
  4. 한 클래스가 여러 역할 타입을 함께 가져야 하는가
  5. 지금 필요한 것은 코드 공유인가, 계약 분리인가

짧게 줄이면 이렇습니다. 상태와 골격을 묶고 싶으면 추상 클래스, 역할과 계약을 열어두고 싶으면 인터페이스입니다. 이 기준만 있어도 꽤 많은 선택이 쉬워집니다.


함께 쓰기

실무에서는 바깥 계약은 인터페이스로 열어두고, 내부 공통 구현은 추상 클래스로 묶는 식으로 둘을 함께 쓰는 경우도 많습니다.

interface PaymentNotifier {
    void notify(String message);
}

abstract class AbstractNotifier implements PaymentNotifier {
    protected final String prefix;

    protected AbstractNotifier(String prefix) {
        this.prefix = prefix;
    }

    protected String format(String message) {
        return prefix + " :: " + message;
    }
}

class EmailNotifier extends AbstractNotifier {
    public EmailNotifier() {
        super("EMAIL");
    }

    @Override
    public void notify(String message) {
        System.out.println(format(message));
    }
}

이 구조는 외부 의존성은 인터페이스에 두고, 내부 중복은 추상 클래스로 줄이는 방식입니다. 그래서 정답은 둘 중 하나를 영원히 고정하는 것이 아니라, 어떤 층에서 어떤 문제를 풀고 있는지 보는 것입니다.


정리

추상 클래스와 인터페이스 차이는 문법표만 외우면 금방 헷갈립니다. 반대로 설계 기준으로 보면 꽤 단순해집니다. 추상 클래스는 공통 상태, 기본 구현, 생성 규칙, 보호된 확장 포인트를 묶는 데 강하고, 인터페이스는 계약, 구현 분리, 역할 조합, 다중 타입 표현에 강합니다.

그래서 앞으로는 추상 클래스가 더 좋나, 인터페이스가 더 좋나보다 지금 묶고 싶은 것은 상태와 골격인가, 역할과 계약인가를 먼저 물어보는 편이 좋습니다.

관련 글로는 객체지향은 왜 클래스 많이 만드는 기술이 아닐까, 좋은 객체지향 설계는 의존성을 어떻게 다룰까, 상속보다 조합이 낫다는 말은 언제 맞을까, 객체지향에서 책임을 잘 나누는 기준을 이어서 보면 흐름이 더 잘 잡힙니다.

사실 근거를 확인하고 싶다면 Oracle의 Abstract Methods and Classes, Interfaces, Multiple Inheritance of State, Implementation, and Type, Default Methods 문서를 함께 보면 좋습니다.

함께보면 좋은 글