인터페이스와 추상 클래스 — 설계의 뼈대 잡기
추상 클래스와 인터페이스 둘 다 직접 인스턴스를 만들 수 없고, 하위 클래스에게 구현을 맡긴다. 그런데 왜 두 가지가 따로 존재하는 걸까?
추상 클래스는 공통 상태(필드)와 기본 동작을 공유하면서 일부 메서드 구현을 강제 하는 도구예요. 인터페이스는 "이 메서드를 반드시 구현하겠다"는 계약(contract) 을 정의하는 도구고요. 둘의 용도가 다르기 때문에 함께 존재합니다.
추상 클래스 — 공통 뼈대 + 구현 강제
abstract 키워드로 선언하며, new로 직접 인스턴스를 만들 수 없습니다. 추상 메서드(구현 없는 메서드)와 일반 메서드를 함께 가질 수 있다 는 점이 핵심이에요.
public abstract class Animal {
private String name;
public Animal(String name) {
this.name = name;
}
// 추상 메서드 — 자식이 반드시 구현해야 한다
public abstract String sound();
// 일반 메서드 — 모든 자식이 공유
public void introduce() {
System.out.println(name + "은(는) " + sound() + " 소리를 낸다.");
}
}
Animal의 생성자는 자식이 super()로 호출하기 위해 존재합니다. 직접 new Animal()을 쓸 수는 없어요.
자식 클래스는 추상 메서드를 반드시 구현해야 합니다. 빠뜨리면 컴파일 에러가 나요.
public class Dog extends Animal {
public Dog(String name) { super(name); }
@Override
public String sound() { return "멍멍"; }
}
이 구조의 이점은 분명합니다. introduce()는 부모에서 한 번만 작성하고, sound()만 자식마다 다르게 구현하면 돼요. 공통 로직의 중복을 없애면서도 자식별 차이를 강제할 수 있습니다.
인터페이스 — 능력의 계약
interface 키워드로 선언하며, 클래스가 implements로 구현합니다. 인터페이스는 ** 상태(인스턴스 필드)를 가질 수 없고 **, 여러 개를 동시에 구현할 수 있어요.
public interface Flyable {
void fly(); // public abstract 자동 적용
}
public interface Swimmable {
void swim();
}
public class Duck extends Animal implements Flyable, Swimmable {
// Animal의 추상 메서드 + Flyable + Swimmable 모두 구현
}
인터페이스가 다중 구현이 가능한 이유는 ** 상태를 갖지 않기 때문 **입니다. 클래스 다중 상속에서 생기는 다이아몬드 문제(어떤 부모의 필드를 쓸지 모호해지는 문제)가 발생하지 않아요.
default 메서드 — 인터페이스에 기본 구현 제공
Java 8 이전의 인터페이스는 추상 메서드만 가질 수 있었습니다. 인터페이스에 메서드를 하나 추가하면 그걸 구현하는 ** 모든 클래스를 수정 **해야 했어요. default 메서드는 이 문제를 해결해 줍니다.
public interface Loggable {
String getLogPrefix();
default void log(String message) {
System.out.println("[" + getLogPrefix() + "] " + message);
}
}
Loggable을 구현하는 클래스는 getLogPrefix()만 구현하면 되고, log()는 기본 구현을 그대로 쓸 수 있습니다. 필요하면 오버라이드도 가능해요.
다만 default 메서드가 있다고 인터페이스가 추상 클래스를 대체하는 건 아닙니다. 인터페이스는 여전히 ** 인스턴스 필드를 가질 수 없기 때문 **에, 공통 상태가 필요한 경우에는 추상 클래스를 써야 해요.
private 메서드 (Java 9+)
Java 9부터는 인터페이스 안에서 private 메서드를 쓸 수 있습니다. 여러 default 메서드에서 중복되는 로직을 추출할 때 유용해요.
public interface Reportable {
default void printDailyReport() {
printHeader();
System.out.println("일일 보고서 내용...");
}
default void printMonthlyReport() {
printHeader();
System.out.println("월간 보고서 내용...");
}
private void printHeader() {
System.out.println("===== 보고서 =====");
}
}
다중 구현 시 충돌 해결
두 인터페이스에 같은 시그니처의 default 메서드가 있으면 ** 컴파일 에러 **가 납니다.
public interface Camera {
default void turnOn() { System.out.println("카메라 ON"); }
}
public interface Phone {
default void turnOn() { System.out.println("전화 ON"); }
}
// 컴파일 에러 — 어떤 turnOn()을 쓸지 모호
public class SmartPhone implements Camera, Phone { }
해결법은 직접 오버라이드하는 것입니다. 특정 인터페이스의 구현을 호출하고 싶으면 InterfaceName.super.method() 문법을 쓰면 돼요.
public class SmartPhone implements Camera, Phone {
@Override
public void turnOn() {
Camera.super.turnOn();
Phone.super.turnOn();
}
}
추상 클래스 vs 인터페이스 — 언제 뭘 쓰나
| 기준 | 추상 클래스 | 인터페이스 |
|---|---|---|
| 상속/구현 | 단일 상속 (extends) | 다중 구현 (implements) |
| 인스턴스 필드 | 가능 | 불가 (public static final만) |
| 생성자 | 가능 | 불가 |
| 메서드 구현 | 일반 메서드 가능 | default, static, private 가능 |
| 접근제어자 | 자유롭게 설정 | 기본 public |
| 설계 의도 | "~이다" (is-a) | "~을 할 수 있다" (can-do) |
** 선택 기준을 정리하면 이렇습니다.**
- 하위 클래스들이 ** 공통 상태(필드)**를 공유해야 하면 → 추상 클래스
- 서로 관련 없는 클래스들이 ** 같은 행동 **을 해야 하면 → 인터페이스
- ** 다중 구현 **이 필요하면 → 인터페이스
- API의 ** 계약 **을 정의할 때 → 인터페이스
실무에서는 이 둘을 조합하는 패턴이 흔합니다. 추상 클래스로 공통 뼈대를 잡고, 인터페이스로 추가 능력을 부여하는 식이에요.
주의할 점
인터페이스를 "기본 구현 모음"으로 남용하지 말자
default 메서드가 편하다고 해서 인터페이스에 비즈니스 로직을 과도하게 넣으면, 인터페이스가 "계약"이 아니라 "구현체"가 됩니다. default 메서드는 ** 하위 호환성을 위한 보조 도구 **로 한정해서 써야 해요.
추상 클래스의 생성자 함정
추상 클래스의 생성자에서 추상 메서드를 호출하면 문제가 생깁니다.
public abstract class Parent {
public Parent() {
init(); // 자식이 오버라이딩한 init() 호출
}
abstract void init();
}
public class Child extends Parent {
private String value = "hello";
@Override
void init() {
// value는 아직 초기화 안 됨 → null!
System.out.println(value.length()); // NullPointerException
}
}
부모 생성자가 먼저 실행되기 때문에, 자식의 필드가 초기화되기 ** 전에** 오버라이딩된 메서드가 호출돼요. 추상 클래스 생성자에서는 오버라이딩 가능한 메서드를 호출하지 말아야 합니다.
함수형 인터페이스 맛보기
추상 메서드가 ** 정확히 1개 **인 인터페이스를 함수형 인터페이스라고 합니다. 람다식의 기반이 되는 개념이에요.
@FunctionalInterface
public interface Calculator {
int calculate(int a, int b);
}
Calculator add = (a, b) -> a + b;
Calculator sub = (a, b) -> a - b;
@FunctionalInterface는 선택적 어노테이션이지만, 붙이면 추상 메서드가 2개 이상일 때 컴파일 에러를 띄워 줍니다. default 메서드나 static 메서드는 여러 개 있어도 상관없어요.
정리
| 개념 | 핵심 |
|---|---|
| 추상 클래스 | abstract 키워드. 필드/생성자/일반 메서드 가능. 단일 상속 |
| 인터페이스 | interface 키워드. 상수만. 다중 구현 가능 |
| default 메서드 | 인터페이스에 기본 구현 제공 (Java 8+). 하위 호환용 |
| 다중 구현 충돌 | 같은 시그니처 default가 겹치면 오버라이드 필수 |
| 선택 기준 | 공통 상태 필요 → 추상 클래스. 능력의 계약 → 인터페이스 |
| 함수형 인터페이스 | 추상 메서드 1개. 람다식의 기반 |