추상 클래스(Abstract Class)

Jihoon Oh·2022년 3월 15일
0

추상 클래스

추상 클래스란?

자바에는 abstract라는 특수한 키워드를 붙여서 생성하는 추상 클래스(Abstract Class)가 존재한다.

public abstract class Foo {
    // 추상 클래스
}

클래스면 클래스고, 추상화 한거면 인터페이스지, 대체 추상 클래스란 뭘까? 추상 클래스를 이해하기 위해서는 먼저 "추상화"라는 개념에 대해서 짚고 넘어가야 한다.

본래 추상화라 함은 미술에서 대상의 구체적인 형상을 나타낸 것이 아니라 점, 선, 면, 색과 같은 순수한 조형 요소로 표현한 것을 말한다. 자바에서 말하는 추상화도 마찬가지다. 대상의 구체적인 실체를 표현하는 것이 아닌, 대상의 개념만을 표현하는 것을 추상화라고 할 수 있다. 정확한 이해를 위해 IT용어사전을 참고하자.

추상화

컴퓨터 과학 분야에서 주어진 문제나 시스템의 복잡도를 단순화하여 인식하기 쉽게 만드는 개념화 작업.
핵심 요소를 잘 파악하여 필요 이상으로 상세, 복잡한 요소들을 결합하거나 단순화하고, 속성의 일부분만으로 주어진 대상을 간결하고 명확하게 표현한다. 복잡도를 관리하는 핵심 기술이라고 할 수 있다.

방식에 따라 데이터 추상화(data abstraction)와 절차 추상화(procedural abstraction)로 나뉜다. 데이터 추상화는 하나의 데이터와 관련된 조작 및 표현 유형을 결합하는 방식이다. 예를 들어, 학번, 이름, 생일, 연락처, 주소, 성적 등 모든 항목을 언급하기보다 ‘학생’이라는 항목(개체)으로 결합하여 추상화한다. 절차 추상화는 세부적인 실행 절차를 단순화하는 방식이다. 예를 들어, 라면 조리를 설명할 때마다 가스레인지 켜기, 냄비에 물 넣기, 물 끓이기, 라면과 스프 넣기 등 세부 절차를 모두 언급하면 너무 복잡하다. 따라서 이를 ‘라면 조리’라고 추상화하여 간단하게 표현하는 것이다.

[네이버 지식백과] 추상화 [abstraction, 抽象化] (IT용어사전, 한국정보통신기술협회)

설명을 보면 딱 대표적으로 인터페이스 - 구현체의 관계가 떠오르지 않는가? 리스트를 예로 들어보자. 우리는 실제 코드에서 사용할 때 ArrayList나 LinkedList같이 정확히 정의되고 구현된 리스트를 사용하지만 타입으로 정의할 때는 일반적으로 이 구현체들의 인터페이스인 List 타입을 사용하곤 한다. List 인터페이스는 add, remove 같은 동작이 어떤 식으로 작동하는지 알 필요가 없다. 그저 추가, 제거라는 기능의 "추상적인 개념"만 가지고 있으면 되고, 해당 개념의 상세 구현은 구현체에서 하게 된다. 이렇게 인터페이스의 사용은 자바의 대표적인 추상화의 사례라고 볼 수 있다.

추상 클래스란 간단히 말하자면 모든 부분을 완벽하게 구현하지 않고 일부분을 추상화시켜 상속받는 클래스에서 구현을 완성하도록 정의한 클래스라고 할 수 있다. 혹자는 클래스를 설계도에 비유한다면 추상 클래스는 미완성 설계도라고 비유하기도 한다. 여기서 구현이 미완성이라는 것은 메서드를 추상 메서드로 선언 해두고 실제 구현은 해당 클래스를 상속받는 클래스에서 하도록 하는 것을 의미한다.

잠깐, 추상 메서드란?

추상 메서드도 추상 클래스와 동일하게 abstract 키워드를 붙여서 만들 수 있다. 상속하는 클래스들의 공통된 메서드지만 내부 구현 로직이 다른 경우에 사용한다. 추상 메서드는 추상 클래스 내에서만 선언할 수 있으며, 클래스 내에 선언만 존재하고, 구현은 존재하지 않는다. 추상 클래스를 상속하는 클래스는 반드시 추상 메서드를 오버라이딩해야 한다.
주의할 점으로, 추상 클래스가 반드시 추상 메서드를 보유해야만 하는 것은 아니다. 모든 메서드가 일반 메서드여도 추상 클래스로 선언할 수 있다.

추상 클래스는 미완성 클래스기 때문에 생성자를 통한 생성이 불가능하다. (그렇다고 생성자가 없다는 것은 아니다! 추상 클래스 자체의 생성자만을 이용한 인스턴스 생성이 안 될 뿐이다. 또한 상속받은 클래스의 인스턴스를 추상 클래스의 타입으로 선언하는 것도 가능하다.) 당연한 소리다. 미완성 부분이 존재하는 객체를 실체화 하는 것은 있을 수 없는 개념이다.

Foo foo = new Foo(); // 불가능하다.

추상 클래스는 abstract 키워드를 붙이는 것, 추상 메서드의 사용이 가능하다는 것, 자체 생성자를 사용한 인스턴스 생성이 불가능 하다는 점을 제외하면 일반적인 클래스와 같고, 상속 역시 일반 클래스를 상속할 때와 마찬가지로 사용하면 된다. 즉, 추상 클래스도 생성자 자체를 가질 수는 있으며(단지 이걸 호출해서 추상 클래스 객체를 만들지 못할 뿐), 필드를 가지고 있고 구현이 완료된 메서드를 가질 수 있다. 상속받은 클래스에서는 일반 클래스의 상속처럼 부모 클래스의 생성자, 필드, 메서드를 호출할 수 있다. (private이 아니라면)

아니 그래서, 그냥 클래스랑 상속도 같으면 이걸 왜 쓰는데?

우선 당연하게도, 추상 메서드의 사용이 필요할 때 사용한다. 같은 개념의 동작을 가지고 있는데, 동작의 내용과 결과가 다른 상황에서는 같은 이름으로 묶어놓고 구현만 다르게 해서 사용할 수 있다. 예를 들어 보자.

"적금"이라는 개념에는 기본적으로 "이자"가 따른다. 이자라는 개념이 존재하지 않는 적금? 그건 그냥 장롱에다가 돈봉투를 꽁쳐두고 묵히는 것과 다를 것이 없다. 하지만, 적금은 각종 상품마다 그 이율이 다르다. 금융 관련 프로그램을 만드느라 군적금, 청년희망적금이라는 두 가지 적금 기능을 구현해야 할 필요가 있다고 생각해보자. 어차피 매 달 돈을 넣고, 만기가 되면 그 금액에 따라 일정 퍼센트의 이자를 받는 것은 공통된 로직이다. 다만 이자의 이율이 다를 뿐이다. 이율이 다른 것 때문에 군적금과 청년희망적금을 모두 따로 따로 구현할 필요가 있을까? 공통된 부분을 "적금"이라는 개념으로 묶으면 된다.

public abstract class InstallmentSavings {

    private int amount;
    
    public abstract void inputMoney(int amount);
    
    public abstract int expire();
}

이렇게 구현하고 보니, 어차피 매 달 일정 금액을 납입하는 메서드는 납입 금액 만큼 계좌 내의 금액을 증가시키는 똑같은 로직인 것 같아 추상 메서드로 만들 필요가 없을 것 같다.

public abstract class InstallmentSavings {

    private int amount;
    
    public void inputMoney(int amount) {
        this.amount += amount;
    }
    
    public abstract int expire();
}

이제 InstallmentSavings를 상속받는 클래스에서 구현하고자 하는 적금 상품에 만기 메서드를 구현해서 사용하면 된다.

그런데 "어차피 일반 클래스를 상속해도 메서드를 재정의 해서 입맛에 맞게 사용할 수 있는거 아냐?" 라고 말할 수도 있다. 하지만 이렇게 묻고 싶다.

지금이야 클래스 자체가 작고 재정의해야 할 메서드가 적으니 빠뜨릴 가능성이 적겠지만, 클래스가 커지고 재정의해서 사용해야 할 메서드가 많아진다면,
그 메서드를 하위 클래스에서 재정의해서 사용한다고 어떻게 보장하십니까?

저 적금 클래스를 추상 클래스로 만들지 않고, 이율이 정해지지 않았으므로 expire 메서드가 그냥 입금 총액을 반환한다고 구현해보자.

public class InstallmentSavings {

    private int amount;
    
    public void inputMoney(int amount) {
        this.amount += amount;
    }
    
    public int expire() {
        return amount;
    }
}

군 적금은 연 이율 5% 짜리 상품이다. 따라서 InstallmentSavings를 상속받는 군 적금 클래스 MilitaryInstallmentSavings는 5%에 맞게 expire를 재정의해야 한다. 하지만 만약 메서드 오버라이딩을 하지 않는다면?

public class MilitaryInstallmentSavings extends InstallmentSavings {
    // ??? 오버라이딩 어디감?
}

이렇게 해도 이 클래스는 사용이 가능하다. inputMoney도 가능하고, expire도 가능하다. InstallmentSavings 클래스를 상속받는 클래스들이 expire를 재정의해서 알맞게 사용하도록 하고자 하는 의도와는 전혀 다른 결과가 나오는 것이다.

따라서 마치 인터페이스의 구현체 처럼 상속받는 클래스에서 필수적으로 구현해야 하는 부분을 지정하고자 하는 의도를 가진다면 일반 클래스의 상속 대신 추상 클래스와 추상 메서드를 사용해서 해결할 수 있다.

그런데 아까 추상 메서드 없이도 추상 클래스 사용이 가능하다면서요?

맞다. 추상 메서드 없어도 추상 클래스 사용이 가능하다. 그러면 위에서 말한 필수적으로 구현해야 하는 부분을 반드시 재정의하도록 지정하기 위해 추상 클래스를 사용하는 목적에 부합하지 않는다. 그러면 그런 경우에는 추상 클래스는 사용하는 경우가 없을까? 하지만 추상 메서드가 없다고 해도 추상 클래스만의 기능이 모두 사라지는 것이 아니다. 다시 상기해보자. 추상 클래스는 인스턴스를 생성할 수 없다. 추상 메서드 없어도 추상 클래스를 만들면 인스턴스 생성을 막을 수 있다.

근데 솔직히 이 목적으로 사용할거면 애초에 묶을 이유로 있을까?

인터페이스와의 차이

앞서 인터페이스도 대표적인 추상화 케이스라고 했는데, 추상 클래스와 인터페이스 둘 다 그 자체로는 생성자를 통한 인스턴스 생성을 하지 못한다. 또한 선언만 있고 구현은 없는 메서드를 가진다. 그리고 해당 부분은 자식 클래스가 반드시 재정의해서 사용해주어야 한다.

그렇다면 추상 클래스와 인터페이스는 어떤 차이가 있을까? 사실 기본 개념부터가 다르다. 추상 클래스는 상속(extends)이고 인터페이스는 구현(implements)이다.

상속은 extends라는 단어에서 알 수 있듯 상속을 받아서 추가적으로 확장을 하는 개념이고, 구현은 기능의 개념을 정의해두고 해당 인터페이스를 구현한 구현체 클래스에서 세부 사항을 정의해서 사용하는 개념이다. 물론 추상 클래스도 추상화된 메서드가 있으므로 인터페이스처럼 전부 추상으로 만들어 두고 상속받는 클래스에서 세부 사항을 전부 구현하는 식으로 만들 수 있고, 인터페이스도 인터페이스에 선언된 메서드 외에 다른 로직들을 구현체에서 확장할 수 있다. 하지만 각각이 하는 핵심적인 기능에 주목하여 개념을 이해하도록 하자.

또한 구조도 다르다. 추상 클래스는 기본적으로는 클래스 인 만큼 인스턴스 필드 + 일반 메서드의 형태를 가지고 있고, 여기에 추상 메서드가 더해지는 구조다. 또한 인스턴스를 생성할 수 없더라도 기본적으로는 클래스인 만큼 생성자를 정의하고, 정의한 생성자를 상속받는 클래스에서 super로 사용할 수 있다.

하지만 인터페이스는 인스턴스 필드를 가지지 않는다. 물론 인터페이스에도 "필드"는 만들 수 있으나, 이렇게 정의된 필드는 접근 제어자를 붙이지 않아도 무조건 static final로 작동, 즉 인스턴스 필드가 아닌 상수로 작동한다. 인터페이스는 인스턴스 구현이 없으니 당연하다.

또한 추상 메서드는 직접 호출만 못하지 간접적으로 사용할 수 있는 생성자를 만들 수 있지만, 인터페이스에는 생성자를 작성할 수 없다. 작성하려고 하면 컴파일 에러가 발생한다.

또한 인터페이스의 메서드는 기본적으로 추상 메서드다. 자바 7까지는 모든 메서드가 추상 메서드였으며, 자바 8부터 default 메서드가 추가되었지만, default 선언을 하지 않은 메서드들은 모두 추상 메서드다.

여기서 잠깐, 인터페이스의 default 메서드

자바 8 버전부터 추가된 인터페이스의 메서드로, 아무 접근제어자도 붙이지 않을 때의 default 접근 제어자의 의미가 아니며, default라고 명시하여 사용할 수 있다. 기본적으로 public으로 작동하며 일반 메서드처럼 내부 구현을 가진다.

클래스 상속을 할 때 처럼 하위 클래스에서 공통적으로 사용하는 구현을 넣어주기 위해 default 메서드를 사용하는 경우가 있는데, default가 메서드가 등장하게 된 배경은 인터페이스로 이루어진 레거시 라이브러리의 유지 보수(새 기능이 추가되어야 하는데 메서드를 그냥 추가하면 해당 라이브러리를 쓰는 모든 코드가 추가된 메서드를 재정의해서 사용해야 한다.)를 위해 도입된 개념이므로, 그러한 개념으로는 사용하지 않는 것이 좋다.

그리고 추상 메서드는 상속이고 인터페이스는 구현인 만큼, 추상 메서드는 다중 상속이 불가능하지만 인터페이스는 다중 상속(상속이라는 의미에는 안맞지만)이 가능하다는 차이점도 있다.

추상 클래스와 인터페이스를 고르는 기준은?

우선 다중 상속이 필요할 경우 당연히 인터페이스를 선택한다. 추상 클래스는 다중 상속을 지원하지 않는다.

추상 클래스와 인터페이스를 고르는 기준은 개발자마다 다 조금씩 다르겠지만, 그래도 어느 정도 보편적인 기준을 잡을 수는 있다. 구글링을 해보면 Is-A 관계(~가 ~인)에는 추상 클래스와 상속을, Has-A 관계(~가 ~할 수 있는)에는 인터페이스를 사용해라 라는 말이 있는데, 사실 명쾌하게 와닿지는 않는다.

조금 더 풀어서 설명하자면, 서로 관련성이 높은 클래스들의 공통 부분을 하나로 묶자면 추상 클래스를 통한 상속 쪽이, 객체들간의 상태에 관련성은 크게 없으나, 공통된 행동 쪽에 집중하여 구현한다면(ex_ Comparable, Comparator 처럼 관련성이 거의 없는 클래스들에서도 공통적으로 구현하여 사용하는 등) 인터페이스 쪽이 더 맞지 않나 생각한다.

내가 생각하기에 가장 확실한 기준은 공통된 필드와 메서드가 필요한지 여부라고 생각한다. 인터페이스는 인스턴스 필드를 가질 수 없는 만큼, 하위 클래스들이 공통적인 필드 값을 가지도록 구현하고 싶다면 추상 클래스로 구현하는 쪽이 하위 클래스마다 필드를 복사 붙여넣기 하는 것 보다 더 효과적일 것이다.

또한 인터페이스에 default 메서드가 생겼다고 하더라도 공통 부분을 모으는 용도로 사용하는 것이 좋은 방법은 아니기 때문에, 구현이 같은 메서드가 여러개 있어서 중복 구현을 줄이고 하나로 모으고 싶을 때는 인터페이스 대신 추상 클래스를 사용하는 것이 더 좋은 선택이라고 생각한다. default를 해당 용도로 사용하지 않는다는 전제 하에, 인터페이스는 공통된 메서드라 하더라도 모든 메서드를 다 선언만 해놓고 구현체에서 같은 내용으로 재정의 해야 하기 때문이다.

profile
Backend Developeer

0개의 댓글