Theme:

"추상 클래스랑 인터페이스가 비슷해 보이는데 뭐가 다른 거예요?" — 객체지향을 공부하다 보면 반드시 부딪히는 질문이다. 둘 다 직접 인스턴스를 만들 수 없고, 하위 클래스에게 구현을 맡긴다는 점에서 비슷해 보인다. 하지만 용도가 다르고, 쓰이는 장면도 다르다. 이번 글에서 그 차이를 확실하게 정리해보자.

추상 클래스란?

추상 클래스는 직접 인스턴스를 만들 수 없는 클래스다. abstract 키워드를 붙여서 선언한다.

JAVA
// 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 계층

동물 예제로 추상 클래스가 왜 필요한지 살펴보자.

JAVA
// 추상 클래스: 모든 동물의 공통 뼈대
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() + " 소리를 낸다.");
    }
}
JAVA
// Dog는 Animal의 추상 메서드를 반드시 구현해야 한다
public class Dog extends Animal {

    public Dog(String name) {
        super(name); // 부모 생성자 호출
    }

    @Override
    public String sound() {
        return "멍멍";
    }
}
JAVA
public class Cat extends Animal {

    public Cat(String name) {
        super(name);
    }

    @Override
    public String sound() {
        return "야옹";
    }
}
JAVA
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로 구현한다.

JAVA
// 인터페이스 선언
public interface Flyable {

    // 추상 메서드 (public abstract가 자동 적용)
    void fly();
}
JAVA
// 인터페이스 구현
public class Bird implements Flyable {

    @Override
    public void fly() {
        System.out.println("새가 날개를 펼치고 난다.");
    }
}

인터페이스의 기본 규칙을 정리하면 이렇다.

  • 메서드는 기본적으로 public abstract다 (안 써도 자동 적용)
  • 필드는 public static final만 가능하다 (상수만 됨)
  • 생성자를 가질 수 없다
  • 클래스는 여러 인터페이스를 동시에 구현할 수 있다
JAVA
// 다중 구현 예시
public class Duck extends Animal implements Flyable, Swimmable {
    // Animal의 추상 메서드 + Flyable, Swimmable의 메서드를 모두 구현
}

default 메서드 (Java 8+)

Java 8 이전의 인터페이스는 추상 메서드만 가질 수 있었다. 문제가 뭐냐면, 인터페이스에 메서드를 하나 추가하면 그걸 구현하는 모든 클래스를 수정해야 했다.

default 메서드는 이 문제를 해결한다. 인터페이스 안에 기본 구현을 제공할 수 있다.

JAVA
public interface Loggable {

    // 추상 메서드
    String getLogPrefix();

    // default 메서드 — 기본 구현 제공
    default void log(String message) {
        System.out.println("[" + getLogPrefix() + "] " + message);
    }
}
JAVA
public class OrderService implements Loggable {

    @Override
    public String getLogPrefix() {
        return "ORDER";
    }

    // log()는 구현하지 않아도 된다 (default 구현 사용)
}
JAVA
OrderService service = new OrderService();
service.log("주문이 생성되었습니다."); // [ORDER] 주문이 생성되었습니다.

default 메서드는 언제 쓰나?

  • 기존 인터페이스에 하위 호환성을 깨지 않고 새 메서드를 추가할 때
  • 공통 유틸리티 로직을 인터페이스 레벨에서 제공하고 싶을 때
  • 필요하면 구현 클래스에서 오버라이드할 수 있다

공부하다 보니 여기서 하나 중요한 점이 있었다. default 메서드가 있다고 인터페이스가 추상 클래스를 완전히 대체하는 건 아니다. 인터페이스는 여전히 **상태(인스턴스 필드)**를 가질 수 없다.

static 메서드와 private 메서드

static 메서드 (Java 8+)

인터페이스에도 static 메서드를 선언할 수 있다. 유틸리티 메서드를 인터페이스에 직접 묶어둘 수 있다.

JAVA
public interface StringUtils {

    // static 메서드 — 인터페이스 이름으로 직접 호출
    static boolean isNullOrEmpty(String s) {
        return s == null || s.isEmpty();
    }
}
JAVA
// 사용
if (StringUtils.isNullOrEmpty(input)) {
    System.out.println("입력값이 비어있습니다.");
}

주의할 점은, static 메서드는 상속되지 않는다. 구현 클래스에서 StringUtils.isNullOrEmpty()로 호출해야지, 클래스 이름으로 호출할 수 없다.

private 메서드 (Java 9+)

Java 9부터는 인터페이스 안에서 private 메서드를 쓸 수 있다. 여러 default 메서드에서 중복 로직을 추출할 때 유용하다.

JAVA
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는 하나만 쓸 수 있다. 하지만 인터페이스는 여러 개를 동시에 구현할 수 있다.

JAVA
public interface Printable {
    void print();
}

public interface Scannable {
    void scan();
}

public interface Faxable {
    void fax();
}
JAVA
// 복합기 — 세 인터페이스를 모두 구현
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 메서드가 있으면 어떻게 될까?

JAVA
public interface Camera {
    default void turnOn() {
        System.out.println("카메라 전원 ON");
    }
}

public interface Phone {
    default void turnOn() {
        System.out.println("전화기 전원 ON");
    }
}
JAVA
// 컴파일 에러! — 어떤 turnOn()을 쓸지 모호하다
public class SmartPhone implements Camera, Phone {
    // ERROR: class SmartPhone inherits unrelated defaults for turnOn()
}

해결법은 간단하다. 직접 오버라이드하면 된다.

JAVA
public class SmartPhone implements Camera, Phone {

    @Override
    public void turnOn() {
        // 방법 1: 완전히 새로 구현
        System.out.println("스마트폰 전원 ON");
    }
}

특정 인터페이스의 default 메서드를 호출하고 싶다면 super를 쓸 수 있다.

JAVA
public class SmartPhone implements Camera, Phone {

    @Override
    public void turnOn() {
        // 방법 2: 특정 인터페이스의 구현을 선택
        Camera.super.turnOn();
        Phone.super.turnOn();
        System.out.println("모든 기능 준비 완료");
    }
}
JAVA
SmartPhone phone = new SmartPhone();
phone.turnOn();
// 카메라 전원 ON
// 전화기 전원 ON
// 모든 기능 준비 완료

여기서 기억해야 할 점은, InterfaceName.super.method() 문법이다. 일반 클래스의 super.method()와 다르게, 인터페이스 이름을 명시해야 한다.

인터페이스 vs 추상 클래스 — 언제 뭘 쓰나?

이 둘은 비슷해 보이지만 설계 의도가 다르다. 표로 비교하면 명확해진다.

기준추상 클래스인터페이스
키워드abstract classinterface
상속/구현단일 상속 (extends)다중 구현 (implements)
인스턴스 필드가능불가 (public static final만)
생성자가능불가
메서드 구현일반 메서드 가능default, static, private 가능
접근제어자자유롭게 설정기본 public
설계 의도"~이다" (is-a) 관계"~을 할 수 있다" (can-do) 능력

선택 기준

추상 클래스를 쓸 때:

  • 하위 클래스들이 **공통 상태(필드)**를 공유해야 할 때
  • "~이다" 관계일 때 (Dog is an Animal)
  • 관련된 클래스들 사이에 코드를 공유하고 싶을 때

인터페이스를 쓸 때:

  • 서로 관련 없는 클래스들이 같은 행동을 해야 할 때
  • "~을 할 수 있다" 능력을 표현할 때 (Bird can Fly)
  • 다중 구현이 필요할 때
  • API의 **계약(contract)**을 정의할 때

공부하다 보니 실무에서는 이 둘을 조합해서 쓰는 경우가 많았다. 추상 클래스로 공통 뼈대를 잡고, 인터페이스로 추가 능력을 부여하는 패턴이 흔하다.

실전 예제 — Payable 인터페이스

실전에 가까운 예제로 정리해보자. 급여를 지급받는 대상을 Payable 인터페이스로 설계한다.

JAVA
// 급여를 받을 수 있는 모든 대상의 계약
public interface Payable {
    double calculatePay();
    String getPayInfo();
}
JAVA
// 공통 필드와 로직을 가진 추상 클래스
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;
    }
}
JAVA
// 정규직 — 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() + "원";
    }
}
JAVA
// 프리랜서 — 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() + "원";
    }
}
JAVA
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 + "원");
    }
}

실행 결과:

PLAINTEXT
김개발 (정규직) - 월급: 4000000.0원
이디자인 (프리랜서) - 시급: 50000.0원 × 80시간 = 4000000.0원
박기획 (정규직) - 월급: 3500000.0원
총 지급액: 1.15E7원

이 예제에서 핵심을 뽑으면 이렇다.

  • Worker(추상 클래스): 이름, ID 같은 공통 상태를 관리
  • Payable(인터페이스): 급여 계산이라는 능력의 계약을 정의
  • Employee, Freelancer: 각각 다른 방식으로 급여를 계산하지만, Payable 타입으로 통일해서 처리

함수형 인터페이스 맛보기

추상 메서드가 딱 1개인 인터페이스를 **함수형 인터페이스(Functional Interface)**라고 한다. 여기서 살짝만 맛보고, 다음 글에서 람다와 스트림을 다룰 때 본격적으로 정리하겠다.

JAVA
// 함수형 인터페이스 — 추상 메서드가 1개
@FunctionalInterface
public interface Calculator {
    int calculate(int a, int b);
}

@FunctionalInterface는 선택적 어노테이션이다. 안 붙여도 추상 메서드가 1개면 함수형 인터페이스지만, 붙이면 컴파일러가 검증해준다 (추상 메서드가 2개 이상이면 에러).

JAVA
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의 안전망이 어떻게 설계되어 있는지 정리해보겠다.

댓글 로딩 중...