추상 클래스(abstract class)는 인스턴스를 만들 수 없는 클래스다. abstract 키워드를 붙이며, 본문 없는 추상 메서드(abstract method)를 가질 수 있다. 자식 클래스는 모든 추상 메서드를 구현해야만 구체 클래스(concrete class, 인스턴스화 가능한 클래스)가 된다.
abstract class Report {
abstract String body(); // 자식이 반드시 구현
String render() { // 공통 구현
return "=== 리포트 ===\n" + body() + "\n=== 끝 ===";
}
}왜 인스턴스화를 막는가? Report는 "리포트라는 개념"이지 어떤 구체적인 리포트가 아니다. new Report()가 가능하면 body()를 호출했을 때 실행할 코드가 없다. 추상 클래스는 "이 클래스는 완성품이 아니라 뼈대다"라는 설계 의도를 컴파일러가 강제하는 장치다.
추상 클래스가 가질 수 있는 것:
| 구성 요소 | 가능 여부 | 비고 |
|---|---|---|
| 인스턴스 필드 (상태) | 가능 | 자식과 공유되는 상태 |
| 생성자 | 가능 | 자식 생성자에서 super(...)로 호출 |
| 추상 메서드 | 가능 | 0개여도 됨 (그래도 인스턴스화 불가) |
| 구체 메서드 | 가능 | 공통 로직 |
| static 메서드 | 가능 | |
| private 메서드 | 가능 |
추상 클래스의 가장 대표적인 활용법이다. 알고리즘의 골격(순서)은 부모가 고정하고, 각 단계의 세부 구현은 자식에게 위임한다. 골격을 정의한 메서드를 템플릿 메서드라고 부르며, 보통 final로 선언해 자식이 순서를 바꾸지 못하게 한다.
abstract class DataExporter {
// 템플릿 메서드: 순서 고정
public final void export(List<Order> orders) {
String header = header();
String rows = orders.stream().map(this::row).collect(Collectors.joining("\n"));
write(header + "\n" + rows);
afterExport(); // 훅(hook) 메서드: 기본 구현 있음, 필요하면 오버라이딩
}
protected abstract String header();
protected abstract String row(Order o);
protected abstract void write(String content);
protected void afterExport() {} // 훅: 아무것도 안 함이 기본
}왜 이 패턴이 유용한가? "CSV 내보내기"와 "JSON 내보내기"는 헤더 형식, 행 형식, 저장 방식만 다르고 전체 흐름은 같다. 흐름이 여러 클래스에 복사되어 있으면 흐름을 바꿀 때(예: 내보내기 전에 권한 검사 추가) 모든 복사본을 고쳐야 한다. 템플릿 메서드는 흐름을 한 곳에 모은다.
Spring의 JdbcTemplate, RestTemplate, Servlet의 HttpServlet.service() → doGet()/doPost()가 전부 이 패턴이다.
인터페이스(interface)는 "이 타입은 이런 메서드를 제공한다"는 계약(contract)만 정의한다. 전통적으로는 구현이 전혀 없었지만, JDK 8부터 default/static 메서드, JDK 9부터 private 메서드가 추가되면서 제한적인 구현을 가질 수 있게 되었다.
interface PaymentGateway {
int MAX_AMOUNT = 10_000_000; // public static final 상수 (자동)
PaymentResult pay(long amount); // public abstract (자동)
default boolean supports(long amount) { // 기본 구현, 구현체가 오버라이딩 가능
return amount > 0 && amount <= MAX_AMOUNT;
}
static PaymentGateway noop() { // 유틸리티/팩토리
return amount -> new PaymentResult(true, "NOOP");
}
private void log(String msg) { // default 메서드들 사이의 중복 제거용 (JDK 9+)
System.out.println("[GW] " + msg);
}
}인터페이스 멤버의 암묵적 수식어:
| 멤버 | 암묵적 수식어 | 설명 |
|---|---|---|
| 필드 | public static final |
상수만 가능. 인스턴스 상태 불가 |
| 추상 메서드 | public abstract |
본문 없음 |
| default 메서드 | public |
본문 있음. 구현체에서 오버라이딩 가능 |
| static 메서드 | public |
인터페이스 이름으로 호출. 상속 안 됨 |
| private 메서드 | — | 인터페이스 내부에서만 사용 (JDK 9+) |
왜 인터페이스에는 인스턴스 필드가 없는가? 인터페이스는 다중 구현이 가능하다. 두 인터페이스가 각각 인스턴스 필드를 가지면 하나의 객체 안에서 두 필드의 초기화 순서, 메모리 레이아웃, 충돌 문제가 생긴다. C++의 다중 상속이 겪는 "다이아몬드 문제"를 피하기 위해 Java는 인터페이스에서 상태를 제거했다.
JDK 8에서 Collection에 stream()을 추가해야 했다. 그런데 Collection은 인터페이스이고, 전 세계에 수만 개의 구현체가 있다. 추상 메서드를 추가하면 그 구현체들이 전부 컴파일 에러가 난다. 그래서 기존 구현체를 깨지 않고 인터페이스에 메서드를 추가하는 수단으로 default 메서드가 도입되었다.
다중 구현에서 같은 시그니처의 default 메서드가 충돌하면 어떻게 되는가?
| 상황 | 결과 |
|---|---|
| 클래스가 직접 구현 (또는 부모 클래스에 있음) | 클래스 것이 이긴다 (class wins) |
| 두 인터페이스 중 하나가 다른 것을 상속 | 더 구체적인(하위) 인터페이스 것이 이긴다 |
| 무관한 두 인터페이스가 같은 default 제공 | 컴파일 에러 — 구현 클래스가 직접 오버라이딩해서 해결해야 함 |
interface A { default String hello() { return "A"; } }
interface B { default String hello() { return "B"; } }
class C implements A, B {
@Override
public String hello() { return A.super.hello() + B.super.hello(); } // 명시적 선택
}| 기준 | 추상 클래스 | 인터페이스 |
|---|---|---|
| 관계 의미 | is-a (Dog is an Animal) | can-do (Dog can Swim, Comparable) |
| 상태(인스턴스 필드) | 가질 수 있음 | 불가 (상수만) |
| 생성자 | 있음 | 없음 |
| 다중 상속 | 불가 (단일 상속) | 가능 (여러 개 implements) |
| 메서드 구현 | 자유롭게 | default/static/private만 |
| 접근 제어자 | 모두 가능 | 사실상 public (private 메서드 예외) |
| 설계 의도 | 밀접한 클래스들의 공통 뼈대와 상태 공유 | 무관한 클래스들에 공통 능력 부여 |
| 진화(메서드 추가) | 구체 메서드 추가는 안전 | default 메서드로 추가 가능 |
| 예시 | AbstractList, HttpServlet, InputStream |
List, Comparable, Runnable |
선택 기준 요약:
Comparable은 String, Integer, LocalDate 등 전혀 다른 클래스들이 구현)List 인터페이스 + AbstractList 골격 + ArrayList 구체). JDK 컬렉션 프레임워크가 정확히 이 구조다.전략 패턴(strategy pattern)은 알고리즘을 인터페이스로 캡슐화하고, 사용하는 쪽은 인터페이스 타입만 바라보게 하여 구현을 런타임에 교체하는 설계다.
┌──────────────┐ uses ┌────────────────────┐
│ OrderService │ ──────▶ │ «interface» │
│ - gateway │ │ PaymentGateway │
└──────────────┘ │ + pay(amount) │
└─────────┬──────────┘
┌──────────────┼──────────────┐
┌──────┴──────┐ ┌─────┴──────┐ ┌─────┴──────┐
│ CardGateway │ │ BankGateway│ │ PointGateway│
└─────────────┘ └────────────┘ └────────────┘OrderService는 PaymentGateway만 알고, 어떤 구현체가 들어오는지는 생성자 주입(constructor injection, 생성자 매개변수로 의존 객체를 받는 것)으로 결정된다. 새 결제 수단은 구현체 클래스 하나를 추가하는 것으로 끝난다. 이것이 개방-폐쇄 원칙(OCP, 확장에는 열려 있고 수정에는 닫혀 있어야 한다)의 실현이다.
추상 메서드가 정확히 하나인 인터페이스를 함수형 인터페이스(functional interface)라고 하며, 람다식으로 구현체를 만들 수 있다. @FunctionalInterface 애노테이션을 붙이면 추상 메서드가 둘 이상이 될 때 컴파일 에러로 막아 준다.
@FunctionalInterface
interface DiscountPolicy {
long apply(long price);
}
DiscountPolicy none = price -> price;
DiscountPolicy tenPct = price -> price * 90 / 100;
DiscountPolicy fixed = price -> Math.max(0, price - 5_000);람다는 "이름 없는 구현 클래스의 인스턴스"를 짧게 쓰는 문법이다. 전략 패턴에서 전략이 메서드 하나짜리라면 클래스를 만들 필요 없이 람다로 넘기면 된다. JDK가 제공하는 Runnable, Comparator<T>, Function<T,R>, Predicate<T>, Supplier<T>, Consumer<T>가 대표적인 함수형 인터페이스다. 자세한 내용은 람다/스트림 레슨에서 다룬다.