0. 작성 목적

Spring 프로젝트나 Java 프로젝트를 설계할 때마다 헷갈리는 개념이 추상 클래스와 인터페이스의 명확한 역할이었던 것 같다.
그래서 각각이 어떤 식으로 동작하고 사용되어야 하는지 알아보기 위해 작성한다.
또한 인터페이스에 대한 개념이 부족하다고 생각하여 이참에 정리하고자 작성한다.
 


 

1. 상속 vs 포함

본격적으로 둘의 차이를 알아보기 앞서 기본적으로 상속 관계와 포함 관계의 차이에 대해 알아보고자 한다.

// 두 경우 모두 코드 재사용성을 높이는 데 도움을 준다.

// 상속 관계
class Circle extends Point {
    int r;
}

// 포함 관계
class Circle {
    Point c = new Point();
    int r;
}
  • 상속 관계 : 기존의 클래스를 재사용하여 새로운 클래스를 작성. 공통적 관리 가능 및 코드 추가·변경에 용이하다.
  • 포함 관계 : 클래스 내부에 멤버 변수로 다른 클래스 타입의 참조 변수를 선언하는 것. 단위별로 여러 클래스를 작성하기 때문에 재사용성이 높고 클래스를 간결하게 작성할 수 있다.
     

구별하는 법

Circle is a Point. (원은 점이다.)   → 상속 관계
Circle has a Point. (원은 점을 가지고 있다.)  → 포함 관계
  • is-a 관계라면 → 상속
  • has-a 관계라면 → 포함

 

2. 각각의 정의

 

2-1. 추상 클래스

개념 : 클래스 내에 추상 메서드가 선언되어 있는 클래스. 미완성 설계도라고 표현할 수 있다.

 

특징

  1. 추상 클래스도 생성자가 있어야 한다. (자동 생성 가능)
  2. 일반 메서드도 추상 클래스 내부에 선언 및 구현 가능하다.
  3. 메서드 강제 구현을 강요하기 위한 클래스이다.
abstract class AbstractTest {
    abstract void move();
}

 
상속하는 경우

abstract class Parent {
    int x, y;
    abstract void methodA();
    abstract void methodB();
    void stop() { /* 메서드 구현 */ }
}

// 1. 일반 클래스: 모든 추상 메서드를 구현해야 함
class Child1 extends Parent {
    @Override
    void methodA() { /* 구현 */ }
    @Override
    void methodB() { /* 구현 */ }
}

// 2. 추상 클래스: 구현을 안 하거나 일부만 해도 됨
abstract class Child2 extends Parent {
    @Override
    void methodA() { /* methodA만 구현, methodB는 여전히 추상 상태 */ }
}

// 사용 예시
Child1 c1 = new Child1();
c1.methodA();
c1.stop(); // 일반 메서드 사용 가능

  • 일반 클래스로 상속 시에는 모든 추상 메서드를 구현해야 한다.
  • 추상 클래스를 추상 클래스로 상속 시에는 구현을 안 하거나 일부만 해도 된다.
  • 공통된 부분은 미리 추상 클래스 내부에 일반 메서드로 구현함으로써 다형성을 유지할 수 있다. (필드도 마찬가지)

 


 

2-2. 인터페이스

개념 : 일종의 추상 클래스이다. 추상 클래스보다 추상화 정도가 높으며, 일반 메서드 또는 멤버 변수를 구성원으로 가질 수 없다. 기본 설계도라고 표현할 수 있다.

특징

  1. 오직 추상 메서드와 상수만을 멤버로 가질 수 있다.
  2. 모든 멤버 변수는 public static final (상수)이어야 하며, 이는 생략 가능하다.
  3. 모든 메서드는 public abstract (추상 메서드)이어야 하며, 이는 생략 가능하다.
  4. 단, static 메서드와 default 메서드는 예외이다.
interface 인터페이스이름 {
    public static final double PI = 3.14; // 상수
    public abstract void methodA();       // 추상 메서드
}

 


 

2-3. 인터페이스의 상속

인터페이스는 인터페이스로부터만 상속이 가능하다. 클래스와 달리 다중 상속이 가능하다.

interface Movable {
    void move(int x, int y);
}

interface Attackable {
    void attack(Unit u);
}

interface Fightable extends Movable, Attackable { /* 구현 */ }

 


 

2-4. 인터페이스의 구현

추상 클래스처럼 그 자체로는 인스턴스 생성이 불가하며, implements 키워드를 이용하여 구현한다.

// 일반 클래스로 구현
class Fighter implements Fightable {
    public void move(int x, int y) { /* 구현 */ }
    public void attack(Unit u) { /* 구현 */ }
}

// 일부만 구현하는 경우 → 추상 클래스로 선언
abstract class Fighter implements Fightable {
    public void move(int x, int y) { /* 구현 */ }
    // attack()은 미구현 → abstract 클래스여야 함
}

// 상속과 구현을 동시에
class Fighter extends Unit implements Fightable {
    public void move(int x, int y) { /* 구현 */ }
    public void attack(Unit u) { /* 구현 */ }
}

 


 

2-5. 인터페이스의 다형성

자식 클래스의 인스턴스를 부모 타입의 변수로 참조하는 것 (업캐스팅)이 가능하듯이, 인터페이스 역시 구현 클래스의 조상이므로 참조가 가능하다.

Fightable f = (Fightable) new Fighter();
// 또는
Fightable f = new Fighter(); // 업캐스팅

// 인터페이스 타입의 참조 변수(f)는 Fightable에 정의된 멤버들만 호출 가능

 
인터페이스의 여러 사용법

// 1. 매개변수로 사용되는 인터페이스
class Fighter extends Unit implements Fightable {
    public void move(int x, int y) { /* 구현 */ }
    public void attack(Fightable f) { /* 구현 */ }
}

// 2. 인터페이스 타입의 반환형
Fightable method() {
    Fighter f = new Fighter();
    return f; // Fighter가 Fightable을 구현한 클래스임을 알 수 있다.
}

💡 핵심 : 리턴 타입이 인터페이스라는 것은 메서드가 해당 인터페이스를 구현한 클래스의 인스턴스를 반환한다는 의미이다.
나중에 Fighter를 리뉴얼한 StrongFighter 클래스로 변경되어도 해당 부분만 바꿔주면 된다. (관리 용이)

 


 

2-6. 인터페이스의 활용 (1) — StarCraft 건물 예시

Barrack, Factory는 건물을 들어 올리는 기능(Lift)이 있는데, 이 기능을 메서드로 추가하고자 한다.
 


 
문제점

void liftOff() { /* 구현 */ }
void move(int x, int y) { /* 구현 */ }
void stop() { /* 구현 */ }
void land() { /* 구현 */ }
  • Barrack, Factory 클래스 모두에 위 코드를 작성하면 → 코드 중복 문제
  • 부모 클래스인 Building에 추가하면 → Academy, Bunker 클래스도 해당 코드를 상속받는 불필요한 상속 문제
     

인터페이스로 해결

interface Liftable {
    void liftOff();
    void move(int x, int y);
    void stop();
    void land();
}

class LiftableImpl implements Liftable {
    public void liftOff() { /* 구현 */ }
    public void move(int x, int y) { /* 구현 */ }
    public void stop() { /* 구현 */ }
    public void land() { /* 구현 */ }
}

 


 
이후 위와 같이 Liftable 을 구현한 클래스를 각각에 적용하면된다.
 
 

코드 적용

class Barrack extends Building implements Liftable {

    LiftableImpl l = new LiftableImpl();

    public void liftOff() { l.liftOff(); }
    public void move(int x, int y) { l.move(x, y); }
    public void stop() { l.stop(); }
    public void land() { l.land(); }

    // 이하 기타 메서드...
}
// Factory도 동일하게 적용

💡 왜 바로 Barrack에 구현하지 않고 LiftableImpl을 통해 구현했을까?

  • 기능 수정이 필요할 시 LiftableImpl 수정만으로 적용된 전체 클래스 조정 가능
  • 클래스는 자신의 본래 목적(건물 생산)에만 집중하고, 부가 기능은 전담 객체에 위임
  • 바로 클래스에 구현 시 재사용성이 떨어짐

 


 

2-7. 인터페이스의 활용 (2) — 인터페이스의 본질

💡 핵심 본질

  1. 클래스를 사용하는 쪽(User)과 클래스를 제공하는 쪽(Provider)이 있다.
  2. 메서드를 호출하는 쪽(User)에서는 사용하려는 메서드(Provider)의 선언부만 알면 된다. (내용은 몰라도 된다.)

 

인터페이스 없이 직접 의존하는 경우

class A {
    public void methodA(B b) {
        b.methodB();
    }
}

class B {
    public void methodB() {
        System.out.println("methodB()");
    }
}

class InterfaceTest {
    public static void main(String[] args) {
        A a = new A();
        a.methodA(new B());
    }
}

// 실행 결과
// methodB()

A 클래스가 B 클래스에 직접 의존하고 있어, B가 변경되면 A도 함께 변경해야 하는 불편함이 발생한다.
이때 인터페이스를 통해 두 클래스 간의 관계를 느슨하게(간접적으로) 변경할 수 있다.

 
 

인터페이스를 통해 결합도를 낮추는 경우

class A {
    public void autoPlay(I i) {
        i.play();
    }
}

interface I {
    public abstract void play();
}

class B implements I {
    public void play() {
        System.out.println("play in B class");
    }
}

class C implements I {
    public void play() {
        System.out.println("play in C class");
    }
}

class InterfaceTest2 {
    public static void main(String[] args) {
        A a = new A();
        a.autoPlay(new B());
        a.autoPlay(new C());
    }
}

// 실행 결과
// play in B class
// play in C class

인터페이스 I를 통해 매개변수로 구현체를 동적으로 제공받을 수 있다.

 
 

제3의 클래스를 통해 제공받는 경우

class InterfaceTest3 {
    public static void main(String[] args) {
        A a = new A();
        a.methodA();
    }
}

class A {
    void methodA() {
        I i = InstanceManager.getInstance(); // 제3의 클래스 메서드 사용 (원래는 new B())
        i.methodB();
        System.out.println(i.toString());    // Object 클래스 메서드 사용
    }
}

interface I {
    public abstract void methodB();
}

class B implements I {
    public void methodB() {
        System.out.println("methodB in B class");
    }
    public String toString() { return "class B"; }
}

class InstanceManager {
    public static I getInstance() {
        return new B(); // 다른 인스턴스로 변경 시 여기만 수정하면 된다.
    }
}

// 실행 결과
// methodB in B class
// class B

getInstance() 메서드를 통해 인스턴스를 제공받으면, 나중에 다른 클래스로 변경되어도 A 클래스는 건드리지 않고 getInstance()만 수정하면 된다.

 


 

2-8. 디폴트 메서드와 static 메서드

  • static 메서드는 JDK 1.8 이후부터 인터페이스에 추가 가능

 

디폴트 메서드란

인터페이스에 메서드를 추가한다는 것 = 해당 인터페이스를 구현한 기존 클래스들이 새로 메서드를 추가해야 한다는 것.
이런 문제를 해결하기 위해 등장한 것이 디폴트 메서드이다.
추상 메서드의 기본적인 구현을 제공하며, 추가되어도 기존 구현체를 수정하지 않아도 된다.

interface MyInterface {
    void method();              // 추상 메서드
    default void newMethod() {} // 디폴트 메서드
}

 

static 메서드와 디폴트 메서드 사용 예시

class DefaultMethodTest {
    public static void main(String[] args) {
        // MyInterface inter = new Child();
        // inter.method2(); // child 가 오버라이딩 하지 않으면 MyInterface 의 method1() 도 호출 가능
        // 위 처럼 다형성을 통해 디폴트 메서드 호출 가능
        Child c = new Child();
        c.method1();
        c.method2();
        MyInterface.staticMethod();
        MyInterface2.staticMethod();
    }
}

class Child extends Parent implements MyInterface, MyInterface2 {
    public void method1() {
        MyInterface.super.method1(); // 인터페이스의 디폴트 메서드 명시적 호출
        System.out.println("method1() in Child"); // 오버라이딩
    }
}

class Parent {
    public void method2() {
        System.out.println("method2() in Parent");
    }
}

interface MyInterface {
    default void method1() {
        System.out.println("method1() in MyInterface");
    }
    default void method2() {
        System.out.println("method2() in MyInterface");
    }
    static void staticMethod() {
        System.out.println("staticMethod() in MyInterface");
    }
}

interface MyInterface2 {
    default void method1() {
        System.out.println("method1() in MyInterface2");
    }
    static void staticMethod() {
        System.out.println("staticMethod() in MyInterface2");
    }
}

// 실행 결과
// method1() in MyInterface
// method1() in Child
// method2() in Parent   ← 디폴트 메서드 충돌 시 우선순위: 클래스 >> 인터페이스
// staticMethod() in MyInterface
// staticMethod() in MyInterface2

※ Java 9부터는 인터페이스 내부에 private 메서드 구현이 가능하다. (디폴트 메서드 간 코드 공유 목적)

public interface Calculator {
    default void logStart() {
        commonLog("작업 시작");
    }

    default void logEnd() {
        commonLog("작업 종료");
    }

    // 외부에서는 호출 불가 — 인터페이스 내부에서만 사용
    private void commonLog(String message) {
        System.out.println("로그 기록: " + message);
    }
}

 


 

3. 추상 클래스 vs 인터페이스

공부 이후에 느낀 것이지만, 이 둘은 서로 비교할 대상이 아니다.

구분 추상 클래스 (abstract class) 인터페이스 (interface)
목적 상속을 통한 확장 (기능의 계층 구조) 기능 구현 강제 (다형성과 행위 정의)
핵심 키워드 "Is-a" (~이다) "Can-do" (~할 수 있다)
멤버 변수 모든 형태 가능 (상수, 일반 변수 등) public static final 상수만 가능
메서드 구현부 존재 가능 (일반/추상 혼합) default, static, private (Java 9+) 구현 가능
상속 단일 상속만 가능 다중 구현 가능
주된 용도 공통된 기능을 공유하고 계층을 구성할 때 서로 다른 클래스에서 공통된 행위를 정의할 때

참고 서적 : Java의 정석 3rd Edition (저자 : 남궁성)

+ Recent posts