Java 17 기준으로 인터페이스와 추상 클래스를 언제 선택해야 하는지, default 메서드 활용과 다중 구현 패턴을 실전 관점에서 정리합니다.
도입 (문제 상황)
기능 확장을 하다 보면 “이건 인터페이스로 빼야 할까, 추상 클래스로 묶어야 할까?” 같은 질문을 자주 하게 됩니다. 특히 기존 코드에 공통 로직이 생기거나, 여러 구현체가 섞이기 시작하면 선택이 더 어렵습니다. 오늘은 실무에서 바로 쓰는 구분 기준과 default 메서드, 다중 구현 패턴을 함께 정리해 봅니다.
핵심 개념: Java 인터페이스와 추상 클래스, 무엇이 다를까요?

인터페이스와 추상 클래스는 둘 다 “구현을 강제한다”는 공통점이 있지만, 설계 의도가 다릅니다.
- 인터페이스는 “이 타입은 이런 능력(계약)을 가졌다”를 표현하기 좋습니다. 구현체가 어떤 상속 계층에 있든 가로로 기능을 붙이는 느낌입니다. 예를 들어
Loggable,Retryable,Cacheable같은 능력은 도메인 클래스 계층과 별개로 붙는 경우가 많습니다. - 추상 클래스는 “이 계열은 본질적으로 같은 뿌리(공통 상태/골격)를 공유한다”에 가깝습니다. 공통 필드(상태)와 템플릿 메서드(골격)를 제공하면서, 일부만 구현체가 채우게 만들 때 강합니다.
특히 Java에서는 클래스 상속이 단일 상속이라서, “이미 다른 클래스를 상속 중인데 공통 기능도 재사용하고 싶다”는 상황이 흔합니다. 이럴 때는 추상 클래스보다 인터페이스 + 조합(Composition), 또는 인터페이스 default 메서드가 더 유연한 선택이 됩니다.
default 메서드: “계약”에 안전하게 기능을 추가하는 방법
Java 8부터 인터페이스는 default 메서드를 가질 수 있습니다. 이게 중요한 이유는 간단합니다.
- 이미 배포된 인터페이스에 메서드를 추가하면, 기존 구현체가 전부 깨집니다.
default메서드는 기존 구현체를 깨지 않고 새 기능을 추가할 수 있는 “진화 포인트”가 됩니다.
다만 default 메서드는 “공통 로직을 넣을 수 있으니 추상 클래스 대체”로 과용하면 유지보수가 어려워집니다. 인터페이스는 기본적으로 상태를 갖지 않는 계약이기 때문에, default 메서드는 작고 안정적인 보조 로직에 쓰는 것이 보통 더 안전합니다.
다중 구현 패턴: “여러 능력”을 조합하는 방식
Java는 클래스 다중 상속은 불가하지만, 인터페이스는 다중 구현이 가능합니다. 실무에서는 보통 아래처럼 씁니다.
- 도메인 타입(예:
PaymentService)은 한 계층(클래스/추상 클래스)으로 잡고 - 횡단 관심사(예: 로깅, 재시도, 메트릭)는 인터페이스로 “능력”을 붙입니다.
- 충돌 위험(동일 시그니처 default 메서드)이 있다면 구현 클래스에서 명시적으로 오버라이드해 해결합니다.
아래 표는 선택 기준을 빠르게 정리한 것입니다.
| 구분 | 인터페이스(Interface) | 추상 클래스(Abstract Class) |
|---|---|---|
| 핵심 목적 | “할 수 있다(능력/계약)” 표현 | “같은 계열(공통 뿌리/골격)” 표현 |
| 상속/구현 | 다중 구현 가능 | 단일 상속만 가능 |
| 공통 코드 | default/static 메서드로 제한적 가능 |
필드 + protected 메서드 + 템플릿 메서드로 강력 |
| 상태(필드) | 인스턴스 필드 불가(상수만 가능) | 인스턴스 필드 가능 |
| 변경 영향 | default로 하위 호환성 확보 가능 |
메서드 추가/변경 시 하위 영향 큼 |
| 추천 사용처 | 기능 플래그, 정책, 플러그인 포인트, 횡단 관심사 | 공통 상태/흐름이 강한 프레임워크성 베이스 |
flowchart TB
A["새 타입이 '능력(계약)'인가요?"] -->|예| B["인터페이스 고려"]
A -->|아니오| C["공통 '상태'와 '흐름(골격)'이 필요한가요?"]
C -->|예| D["추상 클래스 고려"]
C -->|아니오| E["조합(Composition) + 작은 인터페이스로 분리"]
B --> F["기존 구현체 호환이 필요하면 'default'로 진화"]
D --> G["템플릿 메서드로 공통 흐름 제공"]
위 다이어그램은 “계약 vs 골격/상태” 기준으로 빠르게 선택하는 흐름을 보여줍니다.
코드 예제: default 메서드 + 다중 구현 + 충돌 해결까지 한 번에
아래 코드는 Java 17에서 그대로 실행됩니다.
Retryable과Auditable은 다중 구현 가능한 능력 인터페이스AbstractPaymentService는 공통 상태/골격(템플릿 메서드)DefaultPaymentService는 둘을 조합하면서 default 메서드 충돌을 명시적으로 해결합니다.
import java.time.Duration;
import java.util.Objects;
public class InterfaceVsAbstractDemo {
// 능력(계약) 1: 재시도
interface Retryable {
default int maxRetries() { return 3; }
default Duration backoff(int attempt) {
// 간단한 선형 backoff (실무에선 지수 backoff도 고려)
return Duration.ofMillis(100L * attempt);
}
default <T> T withRetry(CheckedSupplier<T> supplier) throws Exception {
Exception last = null;
for (int attempt = 1; attempt <= maxRetries(); attempt++) {
try {
return supplier.get();
} catch (Exception e) {
last = e;
Thread.sleep(backoff(attempt).toMillis());
}
}
throw last;
}
}
// 능력(계약) 2: 감사 로그(감사 이벤트)
interface Auditable {
default String auditPrefix() { return "[AUDIT]"; }
default void audit(String message) {
System.out.println(auditPrefix() + " " + message);
}
}
// 일부러 충돌을 만들기 위한 인터페이스: auditPrefix()가 Auditable과 동일 시그니처
interface Prefixed {
default String auditPrefix() { return "[PREFIX]"; }
}
// 공통 골격/상태: 결제 서비스의 템플릿 메서드
static abstract class AbstractPaymentService {
protected final String merchantId;
protected AbstractPaymentService(String merchantId) {
this.merchantId = Objects.requireNonNull(merchantId);
}
// 템플릿 메서드: 공통 흐름은 고정하고, 핵심만 하위에서 구현
public final String pay(int amount) throws Exception {
validate(amount);
String result = doPay(amount);
return postProcess(result);
}
protected void validate(int amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
}
protected String postProcess(String result) {
return result + " (merchant=" + merchantId + ")";
}
protected abstract String doPay(int amount) throws Exception;
}
// 구현체: 추상 클래스(골격) + 인터페이스(능력) 다중 구현
static final class DefaultPaymentService extends AbstractPaymentService
implements Retryable, Auditable, Prefixed {
DefaultPaymentService(String merchantId) {
super(merchantId);
}
@Override
protected String doPay(int amount) throws Exception {
audit("payment requested amount=" + amount);
// 재시도 능력을 조합해서 사용
return withRetry(() -> {
// 데모: 특정 금액이면 실패하도록 해서 재시도 동작 확인
if (amount == 500) throw new RuntimeException("temporary gateway error");
return "PAID " + amount;
});
}
// default 메서드 충돌 해결: Auditable.auditPrefix vs Prefixed.auditPrefix
@Override
public String auditPrefix() {
// 필요에 따라 하나를 선택하거나, 새로운 규칙으로 합칠 수 있습니다.
return Auditable.super.auditPrefix();
}
// 재시도 정책 커스터마이징도 간단
@Override
public int maxRetries() {
return 2;
}
}
@FunctionalInterface
interface CheckedSupplier<T> {
T get() throws Exception;
}
public static void main(String[] args) throws Exception {
DefaultPaymentService service = new DefaultPaymentService("m-1004");
System.out.println(service.pay(100));
try {
System.out.println(service.pay(500));
} catch (Exception e) {
System.out.println("FAILED: " + e.getMessage());
}
}
}
실무 팁
💡 실무에서는
인터페이스 default 메서드를 “공통 로직 저장소”로 키우기 시작하면 나중에 걷잡을 수 없이 커지는 경우가 많습니다. default는 “하위 호환을 위한 기본 동작”이나 “작은 유틸성 보조 로직”에 두고, 상태가 필요하거나 복잡한 흐름이 생기면 클래스(조합/전략 패턴)로 빼는 편이 유지보수에 유리합니다.
💡 실무에서는
다중 구현을 설계할 때 default 메서드 충돌 가능성을 항상 염두에 두세요. 특히 여러 팀/모듈에서 만든 인터페이스를 한 클래스에 모으다 보면 충돌이 발생합니다. 이때는 구현 클래스에서 충돌 메서드를 명시적으로 오버라이드하고, X.super.method()로 어떤 기본 구현을 선택할지 분명히 하는 것이 안전합니다.
핵심 요약
인터페이스는 “능력/계약”, 추상 클래스는 “공통 상태와 골격”에 강합니다.
default 메서드는 인터페이스의 하위 호환을 돕지만 과용하면 설계가 흐려집니다.
다중 구현은 강력하지만 충돌은 구현 클래스에서 명시적으로 정리해 두세요.
다음 글: #10 예외 처리 제대로 하기
'JAVA' 카테고리의 다른 글
| Java 예외 처리 제대로 하기: Checked vs Unchecked부터 실무 전략까지 (0) | 2026.02.17 |
|---|---|
| Java 블로그 로드맵 — Java 17 기준 40편 커리큘럼 한눈에 보기 (0) | 2026.02.16 |
| Java 상속 vs 조합(Composition) — 실무에서의 선택 가이드 (0) | 2026.02.16 |
| Java 접근 제어자와 캡슐화 — public/private/protected/default 제대로 쓰기 (0) | 2026.02.15 |
| Java 메서드 잘 만드는 법: 파라미터 설계부터 반환 타입, 오버로딩, 길이 원칙까지 (0) | 2026.02.14 |