인터페이스와 추상 클래스 — 설계의 뼈대 잡기
"추상 클래스랑 인터페이스가 비슷해 보이는데 뭐가 다른 거예요?" — 객체지향을 공부하다 보면 반드시 부딪히는 질문이다. 둘 다 직접 인스턴스를 만들 수 없고, 하위 클래스에게 구현을 맡긴다는 점에서 비슷해 보인다. 하지만 용도가 다르고, 쓰이는 장면도 다르다. 이번 글에서 그 차이를 확실하게 정리해보자.
추상 클래스란?
추상 클래스는 직접 인스턴스를 만들 수 없는 클래스다. abstract 키워드를 붙여서 선언한다.
// abstract 키워드로 추상 클래스 선언
public abstract class Shape {
String color; // 일반 필드
// 생성자도 가질 수 있다
public Shape(String color) {
this.color = color;
}
// 추상 메서드 — 구현 없이 선언만
public abstract double area();
// 일반 메서드 — 구현을 가질 수 있다
public String getColor() {
return color;
}
}
핵심 포인트를 정리하면 이렇다.
abstract클래스는new Shape()처럼 직접 인스턴스를 만들 수 없다- 추상 메서드(구현 없는 메서드)를 가질 수 있다
- 동시에 일반 메서드, 필드, 생성자도 가질 수 있다
- 추상 메서드가 하나도 없어도
abstract로 선언할 수 있다
공부하다 보니 헷갈렸던 부분
"추상 클래스에 생성자가 있는데, 인스턴스를 못 만든다고?" — 여기서 좀 헷갈렸다. 추상 클래스의 생성자는 자식 클래스가 super()로 호출하기 위해 존재한다. 직접 new로 쓰는 게 아니다.
추상 클래스 예제 — Animal 계층
동물 예제로 추상 클래스가 왜 필요한지 살펴보자.
// 추상 클래스: 모든 동물의 공통 뼈대
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() + " 소리를 낸다.");
}
}
// Dog는 Animal의 추상 메서드를 반드시 구현해야 한다
public class Dog extends Animal {
public Dog(String name) {
super(name); // 부모 생성자 호출
}
@Override
public String sound() {
return "멍멍";
}
}
public class Cat extends Animal {
public Cat(String name) {
super(name);
}
@Override
public String sound() {
return "야옹";
}
}
public class Main {
public static void main(String[] args) {
Animal dog = new Dog("바둑이");
Animal cat = new Cat("나비");
dog.introduce(); // 나는 바둑이이고, 멍멍 소리를 낸다.
cat.introduce(); // 나는 나비이고, 야옹 소리를 낸다.
}
}
이 구조의 장점은 명확하다.
Animal을 상속하면sound()를 반드시 구현해야 한다 (빠뜨리면 컴파일 에러)introduce()는 공통 로직이니 부모에서 한 번만 작성Animal타입으로 다형성 활용 가능
인터페이스란?
인터페이스는 **"이 메서드를 반드시 구현하겠다"는 계약(contract)**이다. interface 키워드로 선언하고, 클래스가 implements로 구현한다.
// 인터페이스 선언
public interface Flyable {
// 추상 메서드 (public abstract가 자동 적용)
void fly();
}
// 인터페이스 구현
public class Bird implements Flyable {
@Override
public void fly() {
System.out.println("새가 날개를 펼치고 난다.");
}
}
인터페이스의 기본 규칙을 정리하면 이렇다.
- 메서드는 기본적으로
public abstract다 (안 써도 자동 적용) - 필드는
public static final만 가능하다 (상수만 됨) - 생성자를 가질 수 없다
- 클래스는 여러 인터페이스를 동시에 구현할 수 있다
// 다중 구현 예시
public class Duck extends Animal implements Flyable, Swimmable {
// Animal의 추상 메서드 + Flyable, Swimmable의 메서드를 모두 구현
}
default 메서드 (Java 8+)
Java 8 이전의 인터페이스는 추상 메서드만 가질 수 있었다. 문제가 뭐냐면, 인터페이스에 메서드를 하나 추가하면 그걸 구현하는 모든 클래스를 수정해야 했다.
default 메서드는 이 문제를 해결한다. 인터페이스 안에 기본 구현을 제공할 수 있다.
public interface Loggable {
// 추상 메서드
String getLogPrefix();
// default 메서드 — 기본 구현 제공
default void log(String message) {
System.out.println("[" + getLogPrefix() + "] " + message);
}
}
public class OrderService implements Loggable {
@Override
public String getLogPrefix() {
return "ORDER";
}
// log()는 구현하지 않아도 된다 (default 구현 사용)
}
OrderService service = new OrderService();
service.log("주문이 생성되었습니다."); // [ORDER] 주문이 생성되었습니다.
default 메서드는 언제 쓰나?
- 기존 인터페이스에 하위 호환성을 깨지 않고 새 메서드를 추가할 때
- 공통 유틸리티 로직을 인터페이스 레벨에서 제공하고 싶을 때
- 필요하면 구현 클래스에서 오버라이드할 수 있다
공부하다 보니 여기서 하나 중요한 점이 있었다. default 메서드가 있다고 인터페이스가 추상 클래스를 완전히 대체하는 건 아니다. 인터페이스는 여전히 **상태(인스턴스 필드)**를 가질 수 없다.
static 메서드와 private 메서드
static 메서드 (Java 8+)
인터페이스에도 static 메서드를 선언할 수 있다. 유틸리티 메서드를 인터페이스에 직접 묶어둘 수 있다.
public interface StringUtils {
// static 메서드 — 인터페이스 이름으로 직접 호출
static boolean isNullOrEmpty(String s) {
return s == null || s.isEmpty();
}
}
// 사용
if (StringUtils.isNullOrEmpty(input)) {
System.out.println("입력값이 비어있습니다.");
}
주의할 점은, static 메서드는 상속되지 않는다. 구현 클래스에서 StringUtils.isNullOrEmpty()로 호출해야지, 클래스 이름으로 호출할 수 없다.
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 메서드 — 인터페이스 내부에서만 사용
private void printHeader() {
System.out.println("===== 보고서 =====");
System.out.println("생성일: " + java.time.LocalDate.now());
}
}
다중 구현
Java의 클래스 상속은 단일 상속이다. extends는 하나만 쓸 수 있다. 하지만 인터페이스는 여러 개를 동시에 구현할 수 있다.
public interface Printable {
void print();
}
public interface Scannable {
void scan();
}
public interface Faxable {
void fax();
}
// 복합기 — 세 인터페이스를 모두 구현
public class AllInOnePrinter implements Printable, Scannable, Faxable {
@Override
public void print() {
System.out.println("인쇄 중...");
}
@Override
public void scan() {
System.out.println("스캔 중...");
}
@Override
public void fax() {
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 {
// ERROR: class SmartPhone inherits unrelated defaults for turnOn()
}
해결법은 간단하다. 직접 오버라이드하면 된다.
public class SmartPhone implements Camera, Phone {
@Override
public void turnOn() {
// 방법 1: 완전히 새로 구현
System.out.println("스마트폰 전원 ON");
}
}
특정 인터페이스의 default 메서드를 호출하고 싶다면 super를 쓸 수 있다.
public class SmartPhone implements Camera, Phone {
@Override
public void turnOn() {
// 방법 2: 특정 인터페이스의 구현을 선택
Camera.super.turnOn();
Phone.super.turnOn();
System.out.println("모든 기능 준비 완료");
}
}
SmartPhone phone = new SmartPhone();
phone.turnOn();
// 카메라 전원 ON
// 전화기 전원 ON
// 모든 기능 준비 완료
여기서 기억해야 할 점은, InterfaceName.super.method() 문법이다. 일반 클래스의 super.method()와 다르게, 인터페이스 이름을 명시해야 한다.
인터페이스 vs 추상 클래스 — 언제 뭘 쓰나?
이 둘은 비슷해 보이지만 설계 의도가 다르다. 표로 비교하면 명확해진다.
| 기준 | 추상 클래스 | 인터페이스 |
|---|---|---|
| 키워드 | abstract class | interface |
| 상속/구현 | 단일 상속 (extends) | 다중 구현 (implements) |
| 인스턴스 필드 | 가능 | 불가 (public static final만) |
| 생성자 | 가능 | 불가 |
| 메서드 구현 | 일반 메서드 가능 | default, static, private 가능 |
| 접근제어자 | 자유롭게 설정 | 기본 public |
| 설계 의도 | "~이다" (is-a) 관계 | "~을 할 수 있다" (can-do) 능력 |
선택 기준
추상 클래스를 쓸 때:
- 하위 클래스들이 **공통 상태(필드)**를 공유해야 할 때
- "~이다" 관계일 때 (Dog is an Animal)
- 관련된 클래스들 사이에 코드를 공유하고 싶을 때
인터페이스를 쓸 때:
- 서로 관련 없는 클래스들이 같은 행동을 해야 할 때
- "~을 할 수 있다" 능력을 표현할 때 (Bird can Fly)
- 다중 구현이 필요할 때
- API의 **계약(contract)**을 정의할 때
공부하다 보니 실무에서는 이 둘을 조합해서 쓰는 경우가 많았다. 추상 클래스로 공통 뼈대를 잡고, 인터페이스로 추가 능력을 부여하는 패턴이 흔하다.
실전 예제 — Payable 인터페이스
실전에 가까운 예제로 정리해보자. 급여를 지급받는 대상을 Payable 인터페이스로 설계한다.
// 급여를 받을 수 있는 모든 대상의 계약
public interface Payable {
double calculatePay();
String getPayInfo();
}
// 공통 필드와 로직을 가진 추상 클래스
public abstract class Worker {
private String name;
private String id;
public Worker(String name, String id) {
this.name = name;
this.id = id;
}
public String getName() {
return name;
}
public String getId() {
return id;
}
}
// 정규직 — Worker를 상속하고 Payable을 구현
public class Employee extends Worker implements Payable {
private double monthlySalary;
public Employee(String name, String id, double monthlySalary) {
super(name, id);
this.monthlySalary = monthlySalary;
}
@Override
public double calculatePay() {
return monthlySalary;
}
@Override
public String getPayInfo() {
return getName() + " (정규직) - 월급: " + calculatePay() + "원";
}
}
// 프리랜서 — Worker를 상속하고 Payable을 구현
public class Freelancer extends Worker implements Payable {
private double hourlyRate;
private int hoursWorked;
public Freelancer(String name, String id, double hourlyRate, int hoursWorked) {
super(name, id);
this.hourlyRate = hourlyRate;
this.hoursWorked = hoursWorked;
}
@Override
public double calculatePay() {
return hourlyRate * hoursWorked;
}
@Override
public String getPayInfo() {
return getName() + " (프리랜서) - 시급: " + hourlyRate + "원 × " + hoursWorked + "시간 = " + calculatePay() + "원";
}
}
public class PayrollSystem {
public static void main(String[] args) {
// Payable 타입으로 통일해서 다룬다
Payable[] payables = {
new Employee("김개발", "E001", 4_000_000),
new Freelancer("이디자인", "F001", 50_000, 80),
new Employee("박기획", "E002", 3_500_000)
};
double totalPay = 0;
for (Payable p : payables) {
System.out.println(p.getPayInfo());
totalPay += p.calculatePay();
}
System.out.println("총 지급액: " + totalPay + "원");
}
}
실행 결과:
김개발 (정규직) - 월급: 4000000.0원
이디자인 (프리랜서) - 시급: 50000.0원 × 80시간 = 4000000.0원
박기획 (정규직) - 월급: 3500000.0원
총 지급액: 1.15E7원
이 예제에서 핵심을 뽑으면 이렇다.
Worker(추상 클래스): 이름, ID 같은 공통 상태를 관리Payable(인터페이스): 급여 계산이라는 능력의 계약을 정의Employee,Freelancer: 각각 다른 방식으로 급여를 계산하지만,Payable타입으로 통일해서 처리
함수형 인터페이스 맛보기
추상 메서드가 딱 1개인 인터페이스를 **함수형 인터페이스(Functional Interface)**라고 한다. 여기서 살짝만 맛보고, 다음 글에서 람다와 스트림을 다룰 때 본격적으로 정리하겠다.
// 함수형 인터페이스 — 추상 메서드가 1개
@FunctionalInterface
public interface Calculator {
int calculate(int a, int b);
}
@FunctionalInterface는 선택적 어노테이션이다. 안 붙여도 추상 메서드가 1개면 함수형 인터페이스지만, 붙이면 컴파일러가 검증해준다 (추상 메서드가 2개 이상이면 에러).
public class Main {
public static void main(String[] args) {
// 익명 클래스로 구현 (기존 방식)
Calculator add = new Calculator() {
@Override
public int calculate(int a, int b) {
return a + b;
}
};
// 람다 표현식으로 구현 (Java 8+)
Calculator subtract = (a, b) -> a - b;
System.out.println(add.calculate(10, 3)); // 13
System.out.println(subtract.calculate(10, 3)); // 7
}
}
함수형 인터페이스에서 기억할 점은 이것이다.
- 추상 메서드가 정확히 1개여야 한다
default메서드나static메서드는 여러 개 있어도 상관없다- Java가 기본 제공하는 함수형 인터페이스:
Runnable,Comparator,Function<T,R>,Predicate<T>등
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
정리
| 개념 | 핵심 |
|---|---|
| 추상 클래스 | abstract 키워드, 필드·생성자·일반 메서드 가능, 단일 상속 |
| 인터페이스 | interface 키워드, 상수만, 다중 구현 가능 |
| default 메서드 | 인터페이스에 기본 구현 제공 (Java 8+) |
| 다중 구현 충돌 | 같은 시그니처 default가 겹치면 오버라이드 필수 |
| 선택 기준 | is-a → 추상 클래스, can-do → 인터페이스 |
| 함수형 인터페이스 | 추상 메서드 1개, 람다식의 기반 |
추상 클래스와 인터페이스는 객체지향 설계의 뼈대를 잡는 도구다. "공통 상태가 필요하면 추상 클래스, 능력의 계약이 필요하면 인터페이스"라고 기억하면 된다. 실무에서는 이 둘을 조합하는 패턴을 자주 보게 될 것이다.
다음 글에서는 **예외 처리(Exception)**를 다룬다. try-catch가 단순한 에러 잡기가 아니라, Java의 안전망이 어떻게 설계되어 있는지 정리해보겠다.