Devin.KR
로그인

자바 인터페이스와 추상클래스 - 설계 기준과 default 메서드가 생긴 이유 (자바 중급 10단원)

개발자 조회 1

이 단원에서 배우는 것

9단원에서 LibraryItemabstract 클래스로 만들고 FeePolicyinterface 로 만들었다. 왜 하나는 추상 클래스고 하나는 인터페이스인지는 설명하지 않고 넘어갔다. 이번 단원이 그 답이다. 문법 차이를 외우는 단원이 아니라, 설계할 때 둘 중 무엇을 고를지를 판단하는 단원이다.

도서관 대출 시스템을 계속 쓴다. 9단원의 LibraryItem, FeePolicy, Loan 이 그대로 이어지고, 여기에 연체 알림 발송이 추가된다.

  • 추상 클래스와 인터페이스를 "무엇을 강제하는가" 기준으로 구분한다
  • Java 8에서 default 메서드가 왜 생겨야만 했는지를 설명할 수 있다
  • 인터페이스에 static, private 메서드까지 쓸 수 있는 Java 17 기준 문법을 안다

왜 필요한가

9단원 마지막에서 LoanFeePolicy 를 필드로 가지게 만들었다. 그 덕에 감면 정책이 늘어나도 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에서 Collectionstream() 을 넣어야 했을 때 이 문제가 정면으로 터졌다. 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원
    }
}

StudentPolicyapply() 를 구현한 적이 없는데 그냥 쓴다. 이게 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. 인터페이스를 구현체 하나마다 하나씩 만든다

LoanServiceLoanServiceImpl, MemberServiceMemberServiceImpl… 구현이 영원히 하나뿐인데 인터페이스만 기계적으로 만드는 코드를 흔히 본다. 파일 수만 두 배가 되고, 메서드를 하나 추가할 때마다 두 파일을 고쳐야 한다. 인터페이스는 구현이 실제로 갈릴 때, 또는 테스트에서 갈아끼워야 할 때 만든다. 그런 이유가 없으면 클래스 하나로 시작하고, 필요해지면 그때 뽑아낸다.

4. 추상 클래스에 추상 메서드가 없다고 이상하게 생각한다

추상 메서드가 하나도 없어도 abstract 클래스는 만들 수 있다. 목적이 "재정의 강제"가 아니라 "직접 인스턴스화 금지"일 때 그렇게 쓴다. 반대로 추상 메서드가 하나라도 있으면 클래스는 반드시 abstract 여야 한다.

스스로 확인하기

  1. 다음 요구사항을 인터페이스와 추상 클래스 중 무엇으로 표현할지 정하고 이유를 쓴다. (가) 모든 알림 채널은 notify(String, long) 을 할 수 있어야 한다. (나) 모든 알림 채널은 발송 실패 시 최대 3회까지 재시도하며, 채널마다 지금까지의 실패 횟수를 기록한다.
  2. FeePolicydefault long apply(long fee) 를 추가했다. 이 인터페이스를 구현한 클래스가 프로젝트 안에 40개 있다면, 그중 몇 개를 고쳐야 컴파일이 통과하는가?
  3. AbstractNotifier.notify() 에서 final 을 떼면 어떤 문제가 생길 수 있는가. "연체료가 0원이면 발송하지 않는다"는 규칙과 엮어서 설명하라.

정답

  1. (가)는 인터페이스. 상태 없이 "무엇을 할 수 있는가"만 선언하면 되고, 한 클래스가 알림 외의 역할을 겸할 수도 있다. (나)는 추상 클래스. "실패 횟수를 기록한다"는 인스턴스 필드가 필요하다는 뜻이고, 인터페이스는 인스턴스 필드를 가질 수 없다. 실무에서는 (가)를 인터페이스로 선언하고 (나)를 그 인터페이스를 구현한 추상 클래스로 두는 2층 구조를 쓴다. 본문의 OverdueNotifier / AbstractNotifier 가 그 형태다.
  2. 0개. default 메서드는 구현체를 고치지 않아도 되게 만들려고 생긴 문법이다. 40개 클래스는 모두 그대로 컴파일되고 apply() 를 쓸 수 있다. 이것이 Java 8에서 Collection.stream() 을 추가할 수 있었던 이유다.
  3. 하위 클래스가 notify() 를 통째로 재정의할 수 있게 되고, 그러면 검증과 "연체료 0원이면 발송 생략" 규칙을 건너뛴 채 발송이 나간다. 새 채널을 추가하는 사람이 compose() 대신 notify() 를 재정의하는 실수를 해도 컴파일러가 막아주지 않는다. 규칙이 한 곳에만 있게 하려면 흐름을 담은 메서드는 final, 위임할 메서드는 protected abstract 로 두는 것이 짝이다.