공공부하자개발 · 영어 학습 노트
자바
중급객체지향과 코어 라이브러리0/9 완료
  • 01상속, 다형성, 오버라이딩
  • 02추상 클래스 vs 인터페이스
  • 03예외 처리
  • 04java.lang 심화
  • 05컬렉션 프레임워크 딥다이브
  • 06메서드 활용 패턴 (중급)
  • 07Object 메서드와 비교
  • 08java.time 실무 날짜 계산
  • 09HTTP 와 JSON 기초
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 중급 › 02 / 9

추상 클래스 vs 인터페이스

섹션 7진행 0 / 9
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

전략·템플릿·default 메서드를 다른 도구로 다시 구현해 본다. 구현체 집합이 닫혀 있으면 enum, 단계가 가볍고 상태가 없으면 함수 주입, 규칙이 늘어나면 인터페이스 조합이 더 맞을 때가 있다. 어느 쪽을 고를지 판단하는 근거를 함께 적었다.

변형 1: 전략을 enum으로 — 구현체 집합이 고정일 때

예제 1의 게이트웨이처럼 구현체가 외부에서 추가될 수 있으면 클래스가 맞다. 반대로 배송 방식처럼 컴파일 시점에 목록이 닫혀 있고 이름으로 조회(valueOf)해야 하면, enum이 인터페이스를 구현하게 하는 편이 짧고 안전하다. 인터페이스 타입으로 받으므로 람다 구현체와 섞어 쓸 수도 있다.

java
interface ShippingPolicy {
    long fee(long amount);
}

enum Shipping implements ShippingPolicy {
    STANDARD { public long fee(long amount) { return amount >= 50_000 ? 0 : 3_000; } },
    EXPRESS  { public long fee(long amount) { return 5_000; } },
    PICKUP   { public long fee(long amount) { return 0; } }
}

public class Main {
    static String quote(ShippingPolicy policy, long amount) {
        return amount + "원 주문 → 배송비 " + policy.fee(amount) + "원";
    }

    public static void main(String[] args) {
        for (Shipping s : Shipping.values()) {
            System.out.println(s + ": " + quote(s, 30_000));
        }
        System.out.println(quote(Shipping.valueOf("STANDARD"), 80_000));   // 이름으로 조회
        System.out.println(quote(amount -> 1_000, 0));                     // 람다도 같은 자리에
        // 출력:
        // STANDARD: 30000원 주문 → 배송비 3000원
        // EXPRESS: 30000원 주문 → 배송비 5000원
        // PICKUP: 30000원 주문 → 배송비 0원
        // 80000원 주문 → 배송비 0원
        // 0원 주문 → 배송비 1000원
    }
}

변형 2: 템플릿 메서드 → 함수 주입 (상속 없이)

예제 2의 DataExporter는 단계가 세 개(header, row, footer)이고 단계끼리 상태를 공유하지 않는다. 이런 경우 추상 클래스 대신 단계를 생성자로 주입하면 하위 클래스 없이 정적 팩토리 하나로 끝난다. 단계가 많아지고 서로 중간 상태를 주고받아야 하면 다시 추상 클래스가 낫다.

java
import java.util.List;
import java.util.function.Function;

record Order(String id, String product, int qty) {}

class Exporter {
    private final String header;
    private final Function<Order, String> row;
    private final String footer;

    Exporter(String header, Function<Order, String> row, String footer) {
        this.header = header;
        this.row = row;
        this.footer = footer;
    }

    String export(List<Order> orders) {                     // 골격은 그대로
        StringBuilder sb = new StringBuilder(header);
        for (Order o : orders) sb.append('\n').append(row.apply(o));
        return sb.append(footer).toString();
    }

    static Exporter csv() {
        return new Exporter("id,product,qty", o -> o.id() + "," + o.product() + "," + o.qty(), "");
    }

    static Exporter markdown() {
        return new Exporter("| id | product | qty |\n|---|---|---|",
                o -> "| " + o.id() + " | " + o.product() + " | " + o.qty() + " |", "");
    }
}

public class Main {
    public static void main(String[] args) {
        var orders = List.of(new Order("A1", "키보드", 2), new Order("A2", "마우스", 1));
        System.out.println(Exporter.csv().export(orders));
        System.out.println(Exporter.markdown().export(orders));
        // 출력:
        // id,product,qty
        // A1,키보드,2
        // A2,마우스,1
        // | id | product | qty |
        // |---|---|---|
        // | A1 | 키보드 | 2 |
        // | A2 | 마우스 | 1 |
    }
}

변형 3: 검증 규칙을 인터페이스로 쌓기 — 회원가입 입력

규칙 하나가 Rule 인터페이스 하나이고, 검증기는 규칙 목록을 순서대로 적용해 실패 메시지를 모은다. 규칙이 추가되어도 검증기는 바뀌지 않는다(개방-폐쇄). 규칙마다 클래스를 만드는 대신 Rule.of(메시지, 조건) 정적 팩토리로 익명 구현체를 만들어 선언이 짧다.

java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

record SignUp(String email, String password, int age) {}

interface Rule {
    boolean passes(SignUp s);
    String message();

    static Rule of(String message, Predicate<SignUp> condition) {
        return new Rule() {
            public boolean passes(SignUp s) { return condition.test(s); }
            public String message() { return message; }
        };
    }
}

class SignUpValidator {
    private final List<Rule> rules = new ArrayList<>();

    SignUpValidator add(Rule rule) {
        rules.add(rule);
        return this;
    }

    List<String> validate(SignUp s) {
        List<String> errors = new ArrayList<>();
        for (Rule r : rules) if (!r.passes(s)) errors.add(r.message());
        return errors;
    }
}

public class Main {
    public static void main(String[] args) {
        SignUpValidator validator = new SignUpValidator()
            .add(Rule.of("이메일 형식 오류", s -> s.email().contains("@")))
            .add(Rule.of("비밀번호 8자 이상", s -> s.password().length() >= 8))
            .add(Rule.of("14세 이상", s -> s.age() >= 14));

        System.out.println(validator.validate(new SignUp("a@b.com", "12345678", 20)));
        System.out.println(validator.validate(new SignUp("bad", "123", 10)));
        System.out.println(validator.validate(new SignUp("a@b.com", "short", 30)));
        // 출력:
        // []
        // [이메일 형식 오류, 비밀번호 8자 이상, 14세 이상]
        // [비밀번호 8자 이상]
    }
}

변형 4: default 메서드 충돌 규칙 — 클래스가 이기고, 더 구체적인 인터페이스가 이긴다

예제 3은 두 인터페이스의 default가 정면 충돌해 오버라이딩을 강제하는 경우였다. 충돌이 아닌 경우도 있다. (1) 부모 클래스에 같은 메서드가 있으면 클래스가 이긴다. (2) 한 인터페이스가 다른 인터페이스를 상속해 재정의하면 더 구체적인 쪽이 이긴다. 이 두 규칙 덕분에 기존 클래스에 default 메서드를 가진 인터페이스를 추가해도 동작이 바뀌지 않는다.

java
interface Greeter {
    default String greet() { return "Greeter"; }
}

interface PoliteGreeter extends Greeter {
    @Override
    default String greet() { return "PoliteGreeter"; }     // 더 구체적인 인터페이스
}

class Base {
    public String greet() { return "Base"; }
}

class A extends Base implements Greeter {}                  // 규칙 1: 클래스 > 인터페이스
class B implements Greeter, PoliteGreeter {}                // 규칙 2: 하위 인터페이스 > 상위 → 충돌 아님
class C implements Greeter {
    @Override
    public String greet() { return "C:" + Greeter.super.greet(); }   // 명시적으로 default 재사용
}

public class Main {
    public static void main(String[] args) {
        System.out.println(new A().greet());   // 출력: Base
        System.out.println(new B().greet());   // 출력: PoliteGreeter
        System.out.println(new C().greet());   // 출력: C:Greeter
        Greeter g = new A();
        System.out.println(g.greet());         // 출력: Base   (참조 타입과 무관, 실제 객체 기준)
    }
}

변형 5: 인터페이스의 private/static 메서드 — 도우미를 구현체에 노출하지 않기

default 메서드 두 개가 같은 포맷 로직을 쓴다면 그 로직을 어디에 둘까. 추상 클래스로 옮기면 구현체가 상속 한 칸을 소비한다. JDK 9부터는 인터페이스 안에 private 메서드를 둘 수 있어, 구현체에는 보이지 않는 공통 로직을 default 메서드끼리만 공유할 수 있다. static 팩토리까지 인터페이스에 두면 사용자는 인터페이스 이름 하나만 알면 된다.

java
interface PriceFormatter {
    long price();

    default String formatted() { return format(price()); }
    default String withTax() { return format(price() * 110 / 100); }

    private String format(long v) { return String.format("%,d원", v); }   // 구현체에서 호출 불가

    static PriceFormatter of(long price) { return () -> price; }
}

class Product implements PriceFormatter {
    private final String name;
    private final long price;
    Product(String name, long price) { this.name = name; this.price = price; }

    @Override public long price() { return price; }
    // format(...) 을 여기서 호출하면 컴파일 오류: private in interface
    String label() { return name + " " + formatted(); }
}

public class Main {
    public static void main(String[] args) {
        PriceFormatter p = PriceFormatter.of(12_900);
        System.out.println(p.formatted() + " / 세금 포함 " + p.withTax());   // 출력: 12,900원 / 세금 포함 14,190원
        System.out.println(new Product("키보드", 89_000).label());          // 출력: 키보드 89,000원
    }
}
응용 변형 예제
  • 변형 1: 전략을 enum으로 — 구현체 집합이 고정일 때
  • 변형 2: 템플릿 메서드 → 함수 주입 (상속 없이)
  • 변형 3: 검증 규칙을 인터페이스로 쌓기 — 회원가입 입력
  • 변형 4: default 메서드 충돌 규칙 — 클래스가 이기고, 더 구체적인 인터페이스가 이긴다
  • 변형 5: 인터페이스의 private/static 메서드 — 도우미를 구현체에 노출하지 않기
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)