자바/Do it! 자바 완전 정복

Do it! 자바 완전 정복: 12장 추상 클래스와 인터페이스

limdae94 2025. 1. 31.
 
Do it! 자바 완전 정복
프로그래밍 초심자들이 어려워하는 프로그램의 동작을 컴퓨터의 두뇌를 사진 찍듯 그림과 함께 설명한 구성이 눈에 띈다. 단기 코딩 학원에서는 다루지 않는 원리와 배경지식까지 배우며 정통 프로그래머로 거듭나는 뿌듯함을 느껴 보자. 여기에 400여 개의 프로그래밍 문제가 들어 있어 시험과 취업 면접도 대비할 수 있다. 컴퓨터공학과 1학년생부터 실무에서 자바를 쓰는 현직 개발자까지, 자바로 코딩하는 사람이라면 반드시 갖춰야 할 기본기를 이 책과 함께 ‘완전 정복’ 해보자
저자
김동형
출판
이지스퍼블리싱
출판일
2021.09.01

12장에서는 내부에 완성된 메서드만 포함하고 있는 일반 클래스 이외의 자바 문법 요소인 추상 클래스와 인터페이스를 본격적으로 알아본다.

  • 12.1 추상 클래스
  • 인터페이스

12.1 추상 클래스

6장에서 자바는 객체지향 프로그래밍 요소로 클래스와 인터페이스를 제공하고 있으며, 클래스는 다시 일반 클래스와 추상 클래스로 나뉜다고 했다. 추상 클래스의 개념은 11장에서 추상 메서드를 설명하면서 간단히 소개했다. 이제 추상 클래스를 본격적으로 알아보자.

12.1.1 추상 클래스의 정의

추상 메서드(abstract method)는 '메서드의 본체가 완성되지 않은 미완성 메서드'를 말한다. 메서드의 기능을 정의하는 중괄호 안이 비어 있다는 것이 아니라 중괄호 자체가 없으며, 중괄호가 없기 때문에 명령어의 끝을 알리는 세미콜론(;)으로 끝나야 한다.

추상 메서드는 '미완성 메서드'라고도 부른다.

// 추상 메서드
abstract 리턴타입 메서드명(입력매개변수1, [입력매개변수2, ...]);
abstract void abc(); // 메서드의 중괄호{}가 없고, 세미콜론;으로 끝남

추상 메서드를 1개 이상 포함하고 있는 클래스는 반드시 추상 클래스(abstract class)로 정의돼야 한다.
추상 클래스의 형식은 추상 메서드 구문과 비슷하게 class 키워드 앞에 abstract를 붙여 표현한다.

// 추상 클래스
abstract class 클래스명 {}
abstract class A {
    abstract void abc();
    void bcd() { 
        // ...
    }
}

메서드의 완성 기준은?

간혹 완성 메서드와 미완성 메서드의 개념을 혼동하는 때가 있다. 다음 예를 살펴보자.

abstract class A {
    abstract void abc(); // 추상 메서드는 미완성 메서드
}

class B extends A {
    void abc() {
        // 중괄호 안에 어떠한 코드가 없어도 완성된 메서드
    }
}

메서드의 완성과 미완성의 구분 기준은 메서드의 기능을 정의하는 중괄호의 존재 여부다. 기능에 초점을 두다 보니 간혹 중괄호 안에 아무런 코드가 작성되지 않으면 미완성 메서드라고 생각하는 때가 있다. 다시 한번 말하지만, 완성과 미완성 메서드의 유일한 구분점은 중괄호다. 만일 중괄호 안에 아무런 코드가 없다면 그 메서드는 '아무런 일도 하지 말라.'고 기능이 명확히 정의된 완성된 메서드인 것이다.


12.1.2 추상 클래스의 특징

이번에는 추상 클래스의 객체를 생성하고자 할 때를 살펴보자. 추상 클래스는 내부의 미완성 메서드 때문에 객체를 직접 생성할 수 없다. 힙 영역에 생성되는 객체는 내부 요소가 미완성된 상태로 들어갈 수 없기 때문이다. 문법적으로 이야기하면 추상 클래스일 때 A a = new A()와 같이 생성자의 호출 자체를 할 수 없다. 힙 영역에는 값이 비어 있는 필드가 저장될 수 없으므로 초기화하지 않은 필드를 힙 영역에 저장하려고 하면 강제로 값을 초기화한다. 하물며 미완성 형태의 메서드는 당연히 힙 영역에 포함될 수 없는 것이다. 이 개념을 클래스로 설명하면, 추상 클래스로는 직접 객체를 생성할 수 없지만 이 추상 클래스를 상속한 자식 클래스를 생성하면 그 자식 클래스로는 객체를 생성할 수 있는 것이다. 그리고 생성된 객체 내부에는 부모 클래스의 추상 메서드가 구현돼 있을 것이다.

객체를 생성했을 때 추상 클래스와 일반 클래스의 개념적인 역할이다.

추상 클래스를 상속하는 자식 클래스는 부모에게 상속받은 미완성 메서드(추상 메서드)를 반드시 완성(오버라이딩)해야 한다.

여기서 잠시 용어를 정리해 보자. 완성된 메서드이든, 미완성된 메서드이든 부모에게 상속받은 메서드를 자식 클래스에서 재정의하는 것을 통칭해 오버라이딩(overiding)이라 한다. 이 중 부모에게 물려받은 미완성 메서드를 자식 클래스에서 완성하는 것을 특별히 구현한다(implements)라고 말한다.

 

추상 클래스 안에는 반드시 추상 메서드가 포함돼야 할까?

추상 메서드가 1개 이상 존재하면 반드시 추상 클래스로 정의해야 한다고 했는데, 그 반대는 어떨까? 추상 클래스는 반드시 추상 메서드를 포함하고 있을까? 그럴 필요는 없다. 즉, 내부에 모두 완성된 메서드, 즉 일반 메서드만 존재해도 다음과 같이 추상 클래스로 정의할 수 있다.

abstract class A {
    void bcd() { // Ok
        //... 
    }
}

하지만 추상 클래스로 정의하면 객체를 직접 생성하지 못하는 제약 조건이 있으므로 멀쩡한 클래스를 추상 클래스로 정의할 이유가 없는 것이다.


12.1.3 추상 클래스 타입의 객체 생성 방법

앞에서 추상 클래스 자체로는 직접 객체를 생성할 수 없지만, 자식 클래스를 생성해 객체를 생성하고 부모 클래스인 추상 클래스 타입으로 선언할 수 있다고 했다. 이렇게 추상 클래스의 객체를 생성하는 방법은 자식 클래스의 생성 여부에 따라 크게 2가지로 나뉜다. 첫 번째 방법은 다음 예와 같이 추상 클래스를 상속한 일반 클래스를 생성하는 것이다.

자식 클래스 B가 일반 클래스로 정의되기 위해서는 반드시 상속받은 추상 메서드 abc()를 구현해야 한다. 클래스 B는 일반 클래스이므로 객체를 생성할 수 있고, 이렇게 생성한 객체는 다형적 표현으로 부모 추상 클래스 타입으로 선언할 수 있을 것이다.

두 번째 방법은 익명 이너 클래스를 사용하는 것이다. 이는 컴파일러가 내부적으로 추상 클래스를 상속해 메서드 오버라이딩을 수행한 클래스를 생성하고, 그 클래스로 객체를 생성하는 방법이다. 이때 내부적으로 생성된 클래스명은 전혀 알 수 없으므로 개발자의 입장에서는 익명(이름이 없는) 클래스가 되는 것이다. 이너 클래스라는 이름이 붙은 이유는 나중에 알아본다. 익명 이너 클래스의 문법 구조는 다음과 같다.

// 익명 이너 클래스
클래스명 참조변수명 = new 생성자() {
    // 추상 클래스에 포함된 추상 메서드 오버라이딩
}

이 방법으로 추상 클래스 A의 객체를 생성하는 방법은 다음과 같다.

// 방법 2. 익명 이너 클래스 사용
A a = new A() {
    void abc() {
        // ... 추상 메서드(미완성 메서드)의 오버라이딩(완성)
    }
}

이때 A()는 클래스 A의 생성자를 호출하는 것이 아니라 컴파일러가 클래스 A를 상속받아 abc() 메서드를 오버라이딩한 익명 클래스의 생성자를 호출한다는 것을 의미한다. 이러한 2가지 객체 생성 방법은 객체를 생성할 수 없는 다른 객체지향 프로그래밍 요소인 인터페이스에도 그대로 적용되므로 꼭 이해하길 바란다. 익명 이너 클래스 방법은 추상 클래스나 나중에 다룰 인터페이스뿐 아니라 완성된 메서드만 포함하는 일반 클래스를 상속받아 메서드를 추가하거나 재정의하는 데도 사용할 수 있다.

그럼 이 2가지 방법의 장단점은 무엇일까? 즉, 언제 첫 번째 방법, 또 언제 두 번째 방법이 적절할까? 일단 두 번째 방법인 익명 이너 클래스를 활용한 방법이 추가로 자식 클래스를 정의하지 않아도 되고, 코드도 간결해 보이기는 한다. 그렇다면 정말로 두 번째 방법이 좋은지 알아보자. 다음과 같이 자식 클래스 B를 직접 정의하고, 2개의 객체를 생성하는 다음 예제를 살펴보자. 직접 자식 클래스를 생성하므로 일단 한 번 정의한 이후에는 자식 클래스 생성자의 호출만으로도 객체를 몇 개든 생성할 수 있다.

// 추상 클래스의 객체 생성 방법 1. 자식 클래스 활용하기
abstract class A {
    abstract void abc();
}

class B extends A {
    void abc() {
        System.out.println("방법 1. 자식 클래스 생성 및 추상 메서드 구현");
    }
}

public class AbstractClass_1 {
    public static void main(String[] args) {
        // 객체 생성
        A b1 = new B();
        A b2 = new B();

        // 메서드 호출
        b1.abc();
        b2.abc(); 
    }
}
// 실행 결과
방법 1. 자식 클래스 생성 및 추상 메서드 구현
방법 1. 자식 클래스 생성 및 추상 메서드 구현

반면 다음 예제처럼 익명 이너 클래스일 때는 클래스명을 알 수 없기 때문에 객체를 정의할 때 마다 익명 이너 클래스를 정의해야 한다.

// 추상 클래스의 객체 생성 방법 1. 자식 클래스 활용하기
abstract class A {
    abstract void abc();
}

public class AbstractClass_1 {
    public static void main(String[] args) {
        // 객체 생성
        A b1 = new A() {
            void abc() {
                System.out.println("방법 2. 익명 이너 클래스 방법으로 객체 생성");
            }
        };
        A b2 = new A() {
            void abc() {
                System.out.println("방법 2. 익명 이너 클래스 방법으로 객체 생성");
            }
        };

        // 메서드 호출
        b1.abc();
        b2.abc(); 
    }
}
// 실행 결과
방법 2. 익명 이너 클래스 방법으로 객체 생성
방법 2. 익명 이너 클래스 방법으로 객체 생성

이를 정리하면, 객체를 여러 개 만들어야 하는 상황이라면 자식 클래스를 직접 정의하는 첫번째 방법이 적절하다. 하지만 딱 한 번만 만들어 사용할 객체일 때는 익명 이너 클래스를 활용하는 것이 훨씬 간결한 코드를 작성하는 데 도움이 된다.


12.2 인터페이스

12.2.1 인터페이스의 정의와 특징

인터페이스는 내부의 모든 필드가 public static final로 정의되고, static과 default 메서드 이외의 모든 메서드는 public abstract로 정의된 객체지향 프로그래밍 요소다. class 키워드 대신 interface 키워드를 사용해 선언한다. static, default 메서드는 12.2.5에서 알아보고, 지금 시점에서는 인터페이스 내의 모든 메서드가 public abstract라고 생각해도 무방하다.

interface 인터페이스명 {
    public static final 자료형 필드명 = 값;
    public abstract 리턴타입 메서드명();
}
interface A {
    public static final int a = 3;
    public abstract void abc();
}

이처럼 인터페이스 내에서 필드와 메서드에 사용할 수 있는 제어자(modifier)가 확정돼 있으므로 필드와 메서드 앞에 제어자를 생략해도 다음과 같이 컴파일러가 자동으로 각각의 제어자를 삽입한다.

이렇게 제어자를 명시적으로 적어 주지 않은 인터페이스 내의 필드와 메서드 앞에 각각 public static final과 public abstract가 자동으로 추가됐다는 것은 몇 가지 방법으로 확인할 수 있다. 먼저 메서드명 뒤에 중괄호가 없는데도 오류가 발생하지 않으므로 abstract가 자동으로 붙었다는 것을 알 수 있다. 필드 앞에 추가된 제어자는 다음 예제로 확인할 수 있다.

System.out.println(A.a); // 클래스명이나 인터페이스명으로 접근 가능(static 특징)
A.a = 4; // 값 변경 불가능(final의 특징)

static이 붙었다는 것은 클래스명으로 바로 접근할 수 있다는 것, final이 붙었다는 것은 일단 값이 입력된 후 다시 값을 입력할 수 없다는 것으로 유추할 수 있다. 이 예제에서는 public 키워드의 존재를 따로 확인하지 않았지만, 다른 패키지에서 필드값을 사용하거나 메서드를 호출해 보면 쉽게 확인할 수 있다. 메서드 앞에 자동 추가된 public은 다음 절에서 다루는 인터페이스 상속 과정에서도 쉽게 확인할 수 있다.

interface A {
    public static final int a = 3;
    public abstract void abc();
}

interface B {
    int b = 3; // 생략했을 때 자동으로 public static final 
    void bcd(); // 생략했을 때 자동으로 public abstract
}

public class InterfaceCharacteristics {
    public static void main(String[] args) {
        // static 자동 추가 확인
        System.out.println(A.a);
        System.out.println(B.b);

        // final 자동 추가 확인
        // A.a = 5; // 불가능
        // B.b = 5; // 불가능
    }
}
// 실행 결과
3
3

12.2.2 인터페이스의 상속

클래스가 클래스를 상속할 때 extends 키워드를 사용한 반면, 클래스가 인터페이스를 상속할 때는 implements 키워드를 사용한다. 상속에 있어서 인터페이스의 가장 큰 특징은 다중 상속이 가능하다는 것이다. 다음은 클래스가 인터페이스를 상속할 때의 문법 구조를 나타내는데, 1개의 클래스가 여러 개의 인터페이스를 상속할 때 쉼표(,)로 구분해 나열한다.

// 클래스가 인터페이스를 상속하는 구조
클래스명 implements 인터페이스명1 [, 인터페이스명2, ...] {
    // 내용
}

클래스에서는 불가능했던 다중 상속이 인터페이스에서는 어떻게 가능한 것일까? 클래스에서 다중 상속을 할 수 없는 이유는 두 부모 클래스에 동일한 이름의 필드 또는 메서드가 존재할 때 이를 내려받으면 충돌이 발생(ambiguous error)하기 때문이다. 하지만 인터페이스에서는 충돌이 발생할 수 없다. 모든 필드가 public static final로 정의돼 있어 실제 데이터값은 각각의 인터페이스 내부에 존재(즉, 저장 공간이 분리)해 공간상 겹치지 않기 때문이다. 또한 메서드도 모두 미완성이어서 어차피 자식 클래스 내부에서 완성해 사용하므로 문제될 것이 없다. 개념적인 설명이라 이해가 되지 않더라도 '인터페이스는 다중 상속을 할 수 있다.'라는 점은 꼭 기억하자.

만일 클래스와 인터페이스를 함께 상속할 때는 어떨까?

// 클래스와 인터페이스를 동시에 상속하는 구조
클래스명 extends 클래스명 implements 인터페이스명1, [, 인터페이스명2, ...] {
    // 내용
}

// 인터페이스와 클래스의 상속 키워드

interface A {}
interface B {}
class C implements A {} // 단일 상속
class D implements A, B {} // 다중 인터페이스 상속
class E extends C implements A, B {} // 클래스와 인터페이스를 한 번에 상속

인터페이스는 내부에 추상 메서드만 포함할 수 있으므로 만일 인터페이스가 클래스를 상속한다면 상속과 동시에 오류가 발생한다. 자식 클래스는 반드시 다음처럼 미완성 메서드를 완성시켜 줘야 문법적 오류를 피할 수 있다.

interface A {
    public abstract void abc(); // 미완성 메서드
}

class B implements A {
    public void abc() {} // 완성 메서드
}

12.2.3 인터페이스 타입의 객체 생성 방법

인터페이스도 추상 메서드를 포함하고 있으므로 객체를 직접 생성할 수는 없다. 이때는 추상 클래스와 마찬가지로 자식 클래스를 정의하고, 자식 클래스의 생성자로 객체를 생성하는 방법익명 이너 클래스를 이용해 바로 객체를 생성하는 방법을 사용할 수 있다. 각각의 방법은 인터페이스를 상속한다는 것을 제외하고 추상 클래스의 객체 생성 방법과 같다.

// 방법 1. 인터페이스를 일반 클래스로 상속해 객체 생성
interface A { // A a = new A(); 불가능
    int A = 3;
    void abc(); 
}
class B implements A { // A a = new B(); / B b = new B(); 가능
    public void abc() {
        // ... 
    }
}
// 방법 2. 익명 이너 클래스 사용
A a = new A() {
    public void abc() { // 미완성 메서드를 완성한 후 A 객체 생성
        // ...
    }
}

각 방법에서의 장단점 역시 같다. 즉, 여러 개의 객체를 생성해야 할 때는 직접 자식 클래스를 생성해 사용하는 것이 유리하고, 1개의 객체만 생성할 때는 익명 이너 클래스를 사용하는 것이 유리하다.


12.2.4 인터페이스의 필요성

인터페이스를 활용하여 공통 메서드 구현이 가능하고 하나의 인터페이스에서 관리가 가능하다.


12.2.5 디폴트 메서드와 정적 메서드

자바 8이 등장하면서 인터페이스에 몇 가지 기능이 추가됐다. 그 첫 번째가 인터페이스 내에 완성된 메서드인 디폴트(default) 메서드가 포함될 수 있다는 것이다. 디폴트 메서드는 다음과 같이 리턴 타입 앞에 public default를 붙여 표기한다. 인터페이스 내의 default 메서드는 default 접근 지정자와 구분하자. default는 접근 지정자 자체를 생략한다.

// 디폴트 메서드
interface 인터페이스명 {
    public default 리턴타입 메서드명 {
        // 메서드 내용
    }
}

인터페이스 내의 필드 또는 추상 메서드처럼 디폴트 메서드 앞에 접근 지정자 public을 생략해도 컴파일러가 자동으로 삽입한다. 그러면 인터페이스 내부에 디폴트 메서드가 추가된 배경을 알아보자. 먼저 다음과 같이 인터페이스 A를 클래스 B~클래스 F에서 상속해 사용하고있다고 가정해 보자.

여기서는 5개의 자식 클래스를 고려하지만, 대략 100개의 자식 클래스가 있다고 가정해 보자. 이때 필요에 따라 인터페이스 내에 메서드를 1개 더 추가하면 이전에 만들어 사용하던 모든 자식 클래스에서 오류가 발생할 것이다. 그 이유는 당연히 새롭게 추가된 메서드를 구현하지 않았기 때문이다. 자바 8 이전에는 과거의 자식 클래스들을 그대로 사용하기 위해 인터페이스를 새롭게 정의해 사용할 수밖에 없었다. 이 문제점을 해결하기 위한 방법이 디폴트 메서드로, 인터페이스 내부에 완성된 메서드를 삽입하는 것이다. 디폴트 메서드는 다음 예와 같이 이미 완성된 메서드이므로 자식 클래스는 반드시 이 메서드를 오버라이딩할 의무가 없는 것이다.

디폴트 메서드는 심지어 일반 메서드처럼 자식 클래스에서 오버라이딩해 사용할 수도 있다. 다음 예를 살펴보자. 인터페이스 A에는 추상 메서드 1개(abc())와 디폴트 메서드 1개(bcd())가 있다. 클래스 B에서는 인터페이스 A를 상속받아 추상 메서드를 구현했으며, 클래스 C에서는 추상 메서드의 구현은 물론, 인터페이스 A의 디폴트 메서드까지 오버라이딩했다. 오버라이딩 과정을 살펴보면 일반 클래스에서 완성된 메서드를 상속할 때와 동일하게 동작하는 것을 알 수 있다. 인터페이스 내에 완성된 메서드인 디폴트 메서드는 비록 완성된 형태이긴 하지만, 인터페이스 자체가 여전히 객체를 생성할 수 없는 상태이므로 디폴트 메서드를 실행하기 위해서는 일단 상속시켜야한다는 점을 기억하자.

interface A {
    void abc();
    default void bcd() {
        System.out.println("A 인터페이스의 bcd()");
    }
}

class B implements A {
    public void abc() {
        System.out.println("B 클래스의 abc()");
    }
}

class C implements A {
    public void abc() {
        System.out.println("C 클래스의 abc()");
    }
        default void bcd() {
        System.out.println("C 클래스의 bcd()");
    }
}

public class DefaultMetohd_1 {
    public static void main(String[] args) {
        // 객체 생성
        A a1 = new B();
        A a1 = new C();

        // 메서드 호출
        a1.abc(); // B 클래스의 abc()
        a1.bcd(); // A 인터페이스의 bcd()
        a2.abc(); // C 클래스의 abc()
        a2.bcd(); // C 클래스의 bcd()
    }
}

디폴트 메서드가 인터페이스 내부에 속하는 일반 메서드처럼 동작한다고 했으므로 자식 클래스에서 부모 인터페이스 내부의 디폴트 메서드도 호출할 수 있다. 자식 클래스 메서드 내부에서 부모 인터페이스의 디폴트 메서드를 호출하는 방법은 다음과 같다.

// 자식 클래스에서 부모 인터페이스의 디폴트 메서드 호출 방법
부모인터페이스명.spuer.디폴트메서드명

자식 클래스 B의 메서드 abc() 메서드 내에서 A.super.abc()와 같이 부모 인터페이스의 디폴트 메서드를 먼저 호출했다. 따라서 다음과 같이 자식 클래스의 객체를 생성하고 메서드를 호출하면 "A 인터페이스의 abc()"가 먼저 호출되고 이어서 "B 클래스의 abc()"가 호출된다.

B b = new B();
b.abc(); // A 인터페이스의 abc() → B 클래스의 abc()

여기서 부모의 메서드를 호출하는 방식이 클래스와 조금 다르다는 것을 알 수 있다. 부모 클래스의 메서드를 호출할 때는 'super.부모메서드명'인데 인터페이스의 메서드를 호출할 때는 super 앞에 부모 인터페이스명까지 붙는다. 부모가 클래스이든, 인터페이스이든 그냥 super.부모메서드명으로 통일하면 편할 텐데 이렇게 다른 방식을 사용하는 이유는 인터페이스는 다중 상속을 할 수 있으므로 부모 인터페이스가 여럿일 수 있기 때문이다. 앞에서 모든 자바의 클래스는 Object 클래스를 상속한다고 했다. 만일 아무런 클래스도 상속하지 않으면 컴파일러는 다음과 같이 자동으로 Object 클래스를 상속하는 구문을 추가한다.

이때 클래스 C는 부모가 셋(Object, A, B)이 생긴 셈이다. 클래스는 어차피 다중 상속을 할 수 없으므로 부모 클래스를 구분 지을 필요 없이 'super.부모메서드명'처럼 호출하면 될 것이다. 하지만 인터페이스는 다중 상속이 되므로 어떤 부모 인터페이스 내부의 메서드를 호출하 라는 소리인지 구분할 필요가 있다. 그래서 인터페이스 내의 메서드를 호출할 때는 '부모 인터페이스명.super.부모 메서드명'처럼 호출하는 것이다.

댓글