자바의 추상화는 무엇일까?

객체지향의 추상화가 필요한 이유를 알아보고, 추상 클래스와 추상 메서드로 공통 규칙과 달라지는 구현을 분리하는 방법을 살펴보자.

1. 들어가기 전

이전 글에서는 상속을 이용해 기존 클래스의 상태와 행동을 하위 클래스에서 이어받는 방법을 살펴봤다. 하지만 여러 클래스에서 비슷한 코드가 발견됐다고 해서 곧바로 상속 관계를 만들어서는 안 된다.

먼저 각 클래스가 어떤 공통 역할을 가지는지, 외부에 어떤 동작을 제공해야 하는지 결정해야 한다. 구현에 필요한 세부 사항과 외부에서 알아야 할 내용을 구분하는 과정도 필요하다. 이때 사용하는 객체지향의 개념이 **추상화(Abstraction)**다.

이 글에서는 추상화를 단순히 복잡한 코드를 감추는 기술이 아니라, 객체가 맡을 역할과 외부에 제공할 동작을 선택하는 설계 과정으로 본다. Java의 abstract 키워드는 그 결과를 코드로 표현하는 방법 가운데 하나다.

2. 추상화는 무엇을 남기는가

추상화는 복잡한 대상에서 현재 프로그램에 필요한 특징만 선택해 표현하는 과정이다. 대상의 모든 정보를 클래스에 담는 것이 아니라, 프로그램이 해결하려는 문제와 관계있는 상태와 행동만 남긴다.

알림을 보내는 프로그램을 예로 들어 보자. 이메일 알림은 메일 서버와 통신해야 하고, 문자 알림은 문자 발송 서비스를 호출해야 한다. 두 방식의 내부 구현은 서로 다르지만 알림을 요청하는 코드는 다음 사실만 알면 된다.

알림은 수신자에게 메시지를 전송한다.

메일 서버의 연결 방식이나 문자 발송 API의 요청 형식은 알림을 사용하는 코드가 반드시 알아야 할 정보가 아니다. 이 세부 구현을 각 알림 객체 내부에 두고, 외부에는 send()라는 동작만 제공하면 호출 코드는 알림이 무엇을 하는지에 집중할 수 있다.

추상화는 단순한 생략과도 다르다. 필요한 정보까지 제거하는 것이 아니라, 외부 코드가 객체를 올바르게 사용하기 위해 알아야 할 역할과 규칙을 남겨야 한다.

추상화의 기준은 목적에 따라 달라진다

같은 대상이라도 프로그램의 목적이 달라지면 선택해야 할 특징도 달라진다.

회원 객체를 로그인 기능에서 사용한다면 이메일과 비밀번호가 중요할 수 있다. 배송 기능에서는 이름과 주소가 더 중요하다. 현실의 회원이 가진 모든 정보를 하나의 클래스에 그대로 옮기는 것이 추상화의 목표는 아니다.

현재 프로그램에서 객체가 맡은 책임을 기준으로 필요한 특징을 선택하는 것이 중요하다.

추상화와 캡슐화

추상화는 외부에 어떤 역할과 동작을 보여 줄지 결정한다. 캡슐화는 객체의 상태와 구현을 내부에 모으고, 외부에서 허용되지 않은 방식으로 접근하지 못하게 제한한다.

private으로 필드를 감췄다고 해서 역할이 명확해지는 것은 아니다. 반대로 역할을 잘 정의했더라도 내부 상태를 아무 제한 없이 변경할 수 있다면 객체의 규칙을 지키기 어렵다. 두 개념은 서로 관련되어 있지만 같은 의미는 아니다.

3. Java에서 추상화를 표현하는 방법

Java에서는 일반 클래스와 메서드만으로도 추상화를 적용할 수 있다. 메서드 이름과 매개변수를 통해 객체가 제공할 동작을 정의하고, 내부 구현을 private 메서드로 감추는 것도 추상화의 한 형태다.

여러 하위 클래스가 공통 역할을 가지면서 일부 동작을 서로 다르게 구현해야 한다면 추상 클래스와 추상 메서드를 사용할 수 있다.

알림 전송의 공통 흐름은 부모 클래스에 두고, 실제 발송 방식만 하위 클래스에 맡겨 보자.

abstract class Notification {

    private final String recipient;

    protected Notification(String recipient) {
        if (recipient == null || recipient.isBlank()) {
            throw new IllegalArgumentException(
                    "수신자는 비어 있을 수 없습니다."
            );
        }

        this.recipient = recipient;
    }

    public final void send(String message) {
        if (message == null || message.isBlank()) {
            throw new IllegalArgumentException(
                    "메시지는 비어 있을 수 없습니다."
            );
        }

        doSend(recipient, message);
    }

    protected abstract void doSend(
            String recipient,
            String message
    );
}

Notification은 모든 알림이 가져야 할 수신자와 메시지 검증 규칙을 정의한다. send()를 호출하면 공통 검증을 수행한 뒤 실제 발송 작업을 doSend()에 맡긴다.

알림의 종류에 따라 발송 방식이 달라지므로 Notification만으로는 doSend()의 구현을 결정할 수 없다. 메서드의 이름, 매개변수와 반환 타입만 선언하고 구현은 하위 클래스가 담당하도록 남겨 둔다.

class EmailNotification extends Notification {

    public EmailNotification(String recipient) {
        super(recipient);
    }

    @Override
    protected void doSend(
            String recipient,
            String message
    ) {
        System.out.println(
                recipient + "에게 이메일 전송: " + message
        );
    }
}
class SmsNotification extends Notification {

    public SmsNotification(String recipient) {
        super(recipient);
    }

    @Override
    protected void doSend(
            String recipient,
            String message
    ) {
        System.out.println(
                recipient + "에게 문자 전송: " + message
        );
    }
}

이 구조에서 추상 클래스는 단순히 중복 코드를 모아 놓은 부모 클래스가 아니다. 모든 알림이 따라야 할 전송 절차와 반드시 제공해야 하는 동작을 함께 정의한다.

send()final로 선언했기 때문에 하위 클래스가 검증 과정을 제거하거나 전송 순서를 바꿀 수 없다. 반면 실제 발송 방식은 doSend()를 재정의해 각 클래스에 맞게 구현한다.

추상 클래스와 추상 메서드의 규칙

추상 클래스는 클래스 선언에 abstract를 붙여 작성한다.

abstract class Notification {
}

추상 메서드는 구현부 없이 선언부만 작성한다. 중괄호 대신 세미콜론으로 선언을 끝낸다.

protected abstract void doSend(
        String recipient,
        String message
);

추상 클래스와 추상 메서드에는 다음 규칙이 적용된다.

  • 추상 메서드를 선언한 클래스는 반드시 추상 클래스여야 한다.
  • 추상 클래스는 new로 직접 객체를 생성할 수 없다.
  • 추상 클래스에는 필드, 생성자, 일반 메서드와 추상 메서드를 함께 선언할 수 있다.
  • 구체적인 하위 클래스는 상속받은 추상 메서드를 모두 구현해야 한다.
  • 추상 메서드를 구현하지 않은 하위 클래스도 다시 abstract로 선언해야 한다.

다음 코드는 컴파일되지 않는다.

Notification notification =
        new Notification("member@example.com");

Notification은 실제 발송 방식을 완성하지 않은 타입이다. 어떤 방식으로 메시지를 전송할지 결정되지 않았으므로 해당 클래스만으로 객체를 만들 수 없다.

추상 클래스에도 생성자를 선언할 수 있다는 점은 객체 생성 가능 여부와 구분해야 한다. EmailNotification 객체를 생성하면 먼저 Notification의 생성자가 호출되어 수신자를 초기화하고, 이후 하위 클래스의 생성 과정이 이어진다. 추상 클래스의 생성자는 하위 객체에 포함되는 부모 부분을 초기화하기 위해 존재한다.

추상 클래스에 추상 메서드가 반드시 필요한가?

추상 메서드가 하나도 없어도 클래스를 abstract로 선언할 수 있다. 클래스의 구현은 모두 존재하지만 해당 타입 자체로 객체를 만들지 않고, 하위 클래스를 통해서만 사용하도록 설계할 때 가능하다.

단순히 객체 생성을 막는 목적이라면 무조건 추상 클래스를 선택하지 않는다. 하위 클래스가 구현을 완성해야 한다는 의미가 없다면 생성자의 접근 범위를 제한하는 방식이 의도를 더 정확하게 표현할 수 있다.

4. 추상화가 잘못되는 경우

abstract 키워드를 사용했다고 해서 좋은 추상화가 만들어지는 것은 아니다. 어떤 역할을 공통으로 묶었는지가 문법보다 중요하다.

모든 중복이 공통 역할을 의미하지는 않는다

두 클래스에 같은 필드나 비슷한 코드가 있다고 해서 반드시 같은 부모 클래스를 가져야 하는 것은 아니다. 우연히 현재 구현이 비슷할 뿐, 변경되는 이유나 객체가 맡은 역할은 다를 수 있다.

중복 제거만을 목적으로 상속 구조를 만들면 한 클래스의 변경이 관계없는 하위 클래스에 영향을 줄 수 있다. 추상화는 코드 모양보다 객체가 같은 종류로 다뤄질 수 있는지를 먼저 확인해야 한다.

알림 예제에서 이메일과 문자를 하나로 묶은 이유도 출력 코드가 비슷해서가 아니다. 두 객체 모두 수신자에게 메시지를 전달한다는 같은 역할을 수행하기 때문이다.

모든 하위 클래스가 계약을 지킬 수 있어야 한다

상위 타입에 선언한 동작은 모든 구체적인 하위 클래스가 의미 있게 구현할 수 있어야 한다.

예를 들어 Document 추상 클래스에 playAudio()를 선언하면 텍스트 문서도 해당 메서드를 구현해야 한다. 텍스트 문서에서 지원하지 않는다는 예외만 던지거나 아무 동작도 하지 않는다면, 처음부터 모든 문서가 제공할 수 없는 기능을 공통 계약에 넣은 것이다.

이런 구조가 나타난다면 하위 클래스의 구현 문제가 아니라 추상화의 범위가 잘못되었는지 살펴봐야 한다.

추상화를 설계할 때는 다음 질문이 도움이 된다.

  • 외부 코드가 여러 객체에 같은 작업을 요청하는가?
  • 구현은 달라도 호출 방법과 결과의 의미는 같은가?
  • 모든 하위 클래스가 해당 동작을 억지 구현 없이 제공할 수 있는가?
  • 상위 타입에 둔 규칙이 하위 클래스 전체에 실제로 적용되는가?

추상화는 호출 코드가 알아야 할 내용을 줄여 주지만, 구체적인 객체의 차이를 무조건 없애지는 않는다. 공통 역할은 상위 타입에 남기고 달라지는 구현은 구체적인 클래스가 맡도록 경계를 정해야 한다.

이렇게 정의된 공통 타입을 이용하면 호출 코드는 구체적인 클래스보다 추상적인 역할을 기준으로 객체를 다룰 수 있다. 같은 메서드 호출이 실제 객체에 따라 다른 구현으로 이어지는 과정은 다음 글의 주제인 다형성에서 살펴본다.

5. 참고 자료

공식 문서

  • The Java Language Specification - Chapter 8. Classes - Oracle 추상 클래스의 인스턴스 생성 제한, 추상 메서드를 가진 클래스의 선언 규칙과 하위 클래스 구현 조건을 확인했다.

  • Abstract Methods and Classes - Oracle Java Tutorials 추상 클래스가 필드, 생성자, 구현된 메서드와 추상 메서드를 함께 가질 수 있다는 설명과 사용 예제를 참고했다. 이 문서는 JDK 8 기준으로 작성되었으므로 세부 문법 규칙은 최신 Java 언어 명세를 기준으로 확인했다.

한글 참고 자료

  • 추상 클래스 - 점프 투 자바 추상 클래스와 추상 메서드의 기본 문법, 객체 생성 제한과 하위 클래스의 구현 규칙을 확인할 수 있다.

  • 추상 클래스(Abstract) 용도 완벽 이해하기 - Inpa Dev 공통 기능과 하위 클래스마다 달라지는 구현을 구분하는 방법, 추상 메서드로 구현을 요구하는 이유를 다양한 예제로 설명한다.

  • 추상화 - 프로그래밍 입문자를 위한 Java 기초 객체지향에서 추상화가 의미하는 바와 Java의 추상 클래스 및 추상 메서드가 어떤 관계인지 함께 살펴볼 수 있다.

Comments

Comments require GitHub sign-in.

댓글을 불러오는 중입니다.