자바 인터페이스와 추상클래스 - 설계 기준과 default 메서드가 생긴 이유 (자바 중급 10단원)
이 단원에서 배우는 것
9단원에서 LibraryItem 은 abstract 클래스로 만들고 FeePolicy 는 interface 로 만들었다. 왜 하나는 추상 클래스고 하나는 인터페이스인지는 설명하지 않고 넘어갔다. 이번 단원이 그 답이다. 문법 차이를 외우는 단원이 아니라, 설계할 때 둘 중 무엇을 고를지를 판단하는 단원이다.
도서관 대출 시스템을 계속 쓴다. 9단원의 LibraryItem, FeePolicy, Loan 이 그대로 이어지고, 여기에 연체 알림 발송이 추가된다.
- 추상 클래스와 인터페이스를 "무엇을 강제하는가" 기준으로 구분한다
- Java 8에서
default메서드가 왜 생겨야만 했는지를 설명할 수 있다 - 인터페이스에
static,private메서드까지 쓸 수 있는 Java 17 기준 문법을 안다
왜 필요한가
9단원 마지막에서 Loan 이 FeePolicy 를 필드로 가지게 만들었다. 그 덕에 감면 정책이 늘어나도 Loan 은 안 고쳐도 됐다. 이게 가능했던 진짜 이유는 Loan 이 구체적인 감면 계산 코드를 모르기 때문이다. 아는 것은 "discount(long) 을 부르면 감면액이 나온다"는 약속뿐이다.
이 약속을 코드로 적어둔 것이 인터페이스다. 그리고 이 약속의 가치는 팀이 커질수록 드러난다. 연체 알림을 문자로 보내야 하는데 문자 발송 업체 계약이 아직 안 끝났다고 하자. 인터페이스만 먼저 정하면 나머지 개발이 멈추지 않는다.
public interface OverdueNotifier {
String notify(String memberId, long fee);
}
// 계약이 끝나기 전에 쓰는 가짜 구현. 연체 처리 로직을 다 만들고 테스트까지 할 수 있다
public class FakeNotifier implements OverdueNotifier {
@Override
public String notify(String memberId, long fee) {
return "FAKE:" + memberId + ":" + fee;
}
}
인터페이스가 없다면 업체가 정해질 때까지 기다리거나, 나중에 갈아끼울 때 연체 처리 코드를 전부 고쳐야 한다. 인터페이스는 구현을 나중으로 미루기 위한 장치다.
문법과 예제
둘의 차이를 한 표로
| 항목 | 추상 클래스 | 인터페이스 |
|---|---|---|
| 다중 상속 | 하나만 상속 가능 | 여러 개 구현 가능 |
| 인스턴스 필드 | 가질 수 있다 | 없다(public static final 상수만) |
| 생성자 | 있다 | 없다 |
| 구현 있는 메서드 | 일반 메서드로 가능 | default, static, private 으로 가능(Java 8/9+) |
| 기본 접근 제어 | 클래스와 동일 | 메서드는 public abstract 가 기본 |
| 표현하는 것 | "무엇인가" — 공통 상태와 뼈대 | "무엇을 할 수 있는가" — 역할 |
실무에서 고를 때 쓰는 기준은 문법표가 아니라 이 두 문장이다.
공유해야 할 상태(필드)가 있으면 추상 클래스. 없으면 인터페이스.
한 클래스가 여러 역할을 겸해야 하면 인터페이스. 자바는 클래스 다중 상속이 안 된다.
9단원의 LibraryItem 이 추상 클래스였던 이유가 여기 있다. isbn, title 이라는 공유 상태가 있고 생성자에서 검증까지 한다. 반면 FeePolicy 는 상태가 없고 계산 규칙 하나뿐이라 인터페이스가 맞다.
인터페이스 기본 문법
public interface FeePolicy {
long discount(long fee); // public abstract 가 생략된 것
}
public interface Auditable {
String auditKey();
}
/** 두 역할을 겸한다 — 클래스 상속으로는 불가능한 조합 */
public class StudentPolicy implements FeePolicy, Auditable {
private final int percent;
public StudentPolicy(int percent) {
if (percent < 0 || percent > 100) throw new IllegalArgumentException("0~100 범위");
this.percent = percent;
}
@Override
public long discount(long fee) { return fee * percent / 100; }
@Override
public String auditKey() { return "STUDENT-" + percent; }
}
default 메서드가 생긴 이유
Java 7까지 인터페이스에는 구현이 들어갈 수 없었다. 그래서 이미 배포된 인터페이스에 메서드를 하나 추가하는 것이 사실상 불가능했다. 인터페이스에 메서드를 추가하는 순간 그 인터페이스를 구현한 세상의 모든 클래스가 컴파일 에러를 낸다.
Java 8에서 Collection 에 stream() 을 넣어야 했을 때 이 문제가 정면으로 터졌다. Collection 을 구현한 클래스는 JDK 안에도 수십 개고 세상 전체로는 셀 수 없다. 그래서 구현체를 안 고치고도 인터페이스에 메서드를 추가하는 방법으로 default 메서드가 만들어졌다.
public interface FeePolicy {
long discount(long fee);
/** 나중에 추가된 메서드 — 기존 구현체는 손대지 않아도 된다 */
default long apply(long fee) { return fee - bounded(fee); }
default String preview(long fee) { return fee + "원 -> " + (fee - bounded(fee)) + "원"; }
/** Java 9+ — 여러 default 메서드가 공유하는 계산을 밖으로 노출하지 않는다 */
private long bounded(long fee) {
return Math.min(Math.max(0, discount(fee)), fee); // 감면액은 0 이상, 원금 이하
}
/** Java 8+ — 자주 쓰는 구현을 인터페이스가 직접 제공한다 */
static FeePolicy none() { return fee -> 0; }
static FeePolicy rate(int percent) { return fee -> fee * percent / 100; }
}
public class FeePolicyDemo {
public static void main(String[] args) {
FeePolicy p = new StudentPolicy(50);
System.out.println(p.apply(1500)); // 750
System.out.println(FeePolicy.none().apply(1500)); // 1500
System.out.println(FeePolicy.rate(30).preview(1500)); // 1500원 -> 1050원
}
}
StudentPolicy 는 apply() 를 구현한 적이 없는데 그냥 쓴다. 이게 default 의 효과다. bounded() 를 private 으로 둔 덕에 "감면액을 0 이상 원금 이하로 자른다"는 규칙이 한 곳에만 있고, 구현체 쪽에는 노출되지 않는다.
default 메서드는 상속이 아니다
여기서 오해가 하나 생긴다. default 가 있으니 인터페이스로 상속을 대신할 수 있는 것 아니냐는 것인데, 아니다. 인터페이스는 상태를 못 가진다. default 메서드는 필드를 읽거나 쓸 수 없고, 오직 인터페이스에 선언된 추상 메서드를 호출해 동작을 조립할 뿐이다. 그래서 default 는 "골격 구현"이지 "부모 클래스"가 아니다.
실무 설계 — 둘을 층으로 겹쳐 쓴다
실제 프로젝트에서는 인터페이스와 추상 클래스를 둘 중 하나 고르는 게 아니라 층으로 겹쳐서 쓴다. 인터페이스로 역할을 선언하고, 반복되는 뼈대는 추상 클래스로 한 번 구현하고, 실제 클래스는 달라지는 부분만 채운다. 연체 알림이 딱 이 모양이다.
/** 1층: 역할 선언 — 호출하는 쪽은 이 타입만 안다 */
public interface OverdueNotifier {
String notify(String memberId, long fee);
}
/** 2층: 공통 뼈대 — 검증과 발송 로그는 모든 채널이 똑같이 한다 */
public abstract class AbstractNotifier implements OverdueNotifier {
private final String channel;
protected AbstractNotifier(String channel) { this.channel = channel; }
@Override
public final String notify(String memberId, long fee) { // 흐름은 고정
if (memberId == null || memberId.isBlank()) {
throw new IllegalArgumentException("회원번호 없음");
}
if (fee <= 0) return "[" + channel + "] 발송 생략 (연체료 없음)";
String body = compose(memberId, fee); // 달라지는 부분만 위임
System.out.println("[" + channel + "] 발송: " + body);
return body;
}
/** 하위 클래스가 채운다 */
protected abstract String compose(String memberId, long fee);
}
/** 3층: 달라지는 부분만 */
public class SmsNotifier extends AbstractNotifier {
public SmsNotifier() { super("SMS"); }
@Override
protected String compose(String memberId, long fee) {
return memberId + "님 연체료 " + fee + "원"; // 문자는 길이 제한이 있다
}
}
public class EmailNotifier extends AbstractNotifier {
public EmailNotifier() { super("EMAIL"); }
@Override
protected String compose(String memberId, long fee) {
return "안녕하세요 " + memberId + "님, 현재 연체료는 " + fee + "원입니다. 반납 부탁드립니다.";
}
}
import java.util.List;
public class NotifierDemo {
public static void main(String[] args) {
List<OverdueNotifier> notifiers = List.of(new SmsNotifier(), new EmailNotifier());
for (OverdueNotifier n : notifiers) {
n.notify("M-01", 1500);
}
System.out.println(new SmsNotifier().notify("M-02", 0));
}
}
[SMS] 발송: M-01님 연체료 1500원
[EMAIL] 발송: 안녕하세요 M-01님, 현재 연체료는 1500원입니다. 반납 부탁드립니다.
[SMS] 발송 생략 (연체료 없음)
notify() 에 final 을 붙인 것에 주목한다. 흐름(검증 → 본문 작성 → 발송 로그)은 하위 클래스가 바꾸면 안 되고, 바꿔야 하는 것은 compose() 하나뿐이라는 의도를 코드로 못 박은 것이다. 이렇게 상위에서 흐름을 고정하고 일부만 하위에 맡기는 구조를 템플릿 메서드 패턴이라고 부른다. "연체료가 0이면 발송하지 않는다"는 규칙이 한 곳에만 있으니, 새 채널을 추가하는 사람이 그 규칙을 빠뜨릴 수 없다.
실무에서 자주 틀리는 것
1. "상수 모아두는 곳"으로 인터페이스를 쓴다
// 하지 마라
public interface Constants {
int MAX_LOAN_COUNT = 5;
String DEFAULT_BRANCH = "본관";
}
public class LoanDesk implements Constants { ... } // 상수 쓰려고 implements
인터페이스의 필드는 자동으로 public static final 이라 문법적으로는 된다. 문제는 implements Constants 가 "이 클래스는 Constants 역할을 한다"고 공개적으로 선언한다는 것이다. 내부 구현 세부가 타입 정보로 새어나가고, 나중에 상수를 빼면 그 인터페이스를 구현한 모든 클래스가 깨진다. 상수는 enum(14단원) 또는 final class + static final 필드에 둔다.
2. default 메서드가 여러 인터페이스에서 충돌하는 걸 모른다
interface A { default String name() { return "A"; } }
interface B { default String name() { return "B"; } }
class C implements A, B { } // 컴파일 에러: class C inherits unrelated defaults for name()
같은 시그니처의 default 메서드를 가진 인터페이스를 둘 이상 구현하면 컴파일러가 어느 쪽인지 결정하지 못한다. 반드시 클래스에서 재정의해야 하고, 특정 인터페이스 구현을 쓰고 싶으면 인터페이스명.super.메서드() 로 명시한다.
class C implements A, B {
@Override public String name() { return A.super.name() + B.super.name(); } // "AB"
}
규칙 하나 더. 클래스가 상속한 메서드와 인터페이스의 default 가 겹치면 클래스 쪽이 항상 이긴다. 부모 클래스에 우연히 같은 이름의 메서드가 있으면 인터페이스의 default 는 조용히 무시된다.
interface Greet { default String hello() { return "interface"; } }
class Base { public String hello() { return "class"; } }
class Impl extends Base implements Greet { }
System.out.println(new Impl().hello()); // class
3. 인터페이스를 구현체 하나마다 하나씩 만든다
LoanService 와 LoanServiceImpl, MemberService 와 MemberServiceImpl… 구현이 영원히 하나뿐인데 인터페이스만 기계적으로 만드는 코드를 흔히 본다. 파일 수만 두 배가 되고, 메서드를 하나 추가할 때마다 두 파일을 고쳐야 한다. 인터페이스는 구현이 실제로 갈릴 때, 또는 테스트에서 갈아끼워야 할 때 만든다. 그런 이유가 없으면 클래스 하나로 시작하고, 필요해지면 그때 뽑아낸다.
4. 추상 클래스에 추상 메서드가 없다고 이상하게 생각한다
추상 메서드가 하나도 없어도 abstract 클래스는 만들 수 있다. 목적이 "재정의 강제"가 아니라 "직접 인스턴스화 금지"일 때 그렇게 쓴다. 반대로 추상 메서드가 하나라도 있으면 클래스는 반드시 abstract 여야 한다.
스스로 확인하기
- 다음 요구사항을 인터페이스와 추상 클래스 중 무엇으로 표현할지 정하고 이유를 쓴다. (가) 모든 알림 채널은
notify(String, long)을 할 수 있어야 한다. (나) 모든 알림 채널은 발송 실패 시 최대 3회까지 재시도하며, 채널마다 지금까지의 실패 횟수를 기록한다. FeePolicy에default long apply(long fee)를 추가했다. 이 인터페이스를 구현한 클래스가 프로젝트 안에 40개 있다면, 그중 몇 개를 고쳐야 컴파일이 통과하는가?AbstractNotifier.notify()에서final을 떼면 어떤 문제가 생길 수 있는가. "연체료가 0원이면 발송하지 않는다"는 규칙과 엮어서 설명하라.
정답
- (가)는 인터페이스. 상태 없이 "무엇을 할 수 있는가"만 선언하면 되고, 한 클래스가 알림 외의 역할을 겸할 수도 있다. (나)는 추상 클래스. "실패 횟수를 기록한다"는 인스턴스 필드가 필요하다는 뜻이고, 인터페이스는 인스턴스 필드를 가질 수 없다. 실무에서는 (가)를 인터페이스로 선언하고 (나)를 그 인터페이스를 구현한 추상 클래스로 두는 2층 구조를 쓴다. 본문의
OverdueNotifier/AbstractNotifier가 그 형태다. - 0개.
default메서드는 구현체를 고치지 않아도 되게 만들려고 생긴 문법이다. 40개 클래스는 모두 그대로 컴파일되고apply()를 쓸 수 있다. 이것이 Java 8에서Collection.stream()을 추가할 수 있었던 이유다. - 하위 클래스가
notify()를 통째로 재정의할 수 있게 되고, 그러면 검증과 "연체료 0원이면 발송 생략" 규칙을 건너뛴 채 발송이 나간다. 새 채널을 추가하는 사람이compose()대신notify()를 재정의하는 실수를 해도 컴파일러가 막아주지 않는다. 규칙이 한 곳에만 있게 하려면 흐름을 담은 메서드는final, 위임할 메서드는protected abstract로 두는 것이 짝이다.