본문 바로가기

추상 클래스와 인터페이스

공통 필드와 구현을 물려주는 추상 클래스와 구현할 메서드 목록을 정하는 인터페이스를 게시글 발행 예제로 비교하고, 언제 무엇을 쓰는지 배웁니다.

앞 글에서는 Post를 추상 클래스로 만들어 new Post(...)처럼 직접 생성하는 것을 막고, 게시글마다 다른 publish는 추상 메서드로 남겨 자식이 반드시 구현하게 했습니다. 추상 클래스는 제목과 조회 수 같은 공통 필드와 완성된 메서드를 자식에게 물려주고, 생성자로 그 필드를 초기화할 수도 있습니다. 그래서 모든 게시글이 공유하는 부분을 한곳에 모으기 좋습니다.

그런데 '발행할 수 있다'는 기능이 게시글에만 필요한 것은 아닙니다. 공지 배너나 이벤트 안내도 발행할 수 있지만, 이들을 제목과 조회 수를 가진 Post의 자식이라고 하기는 어색합니다. 게다가 자바 클래스는 부모를 하나만 상속할 수 있으므로, 이미 다른 부모를 둔 클래스는 Post를 상속할 수조차 없습니다. 이럴 때 쓰는 타입이 인터페이스(interface)입니다. 인터페이스는 '이 타입이라면 이런 메서드를 제공한다'는 메서드 목록을 정해 둔 타입입니다. 인스턴스 필드도 생성자도 없고 클래스 상속과도 별개라서, 이미 부모 클래스가 있는 클래스도 인터페이스를 구현할 수 있습니다.

추상 클래스와 인터페이스는 둘 다 new로 직접 객체를 만들 수 없고, 변수나 매개변수의 타입으로 써서 여러 구현을 한 가지 방식으로 다룬다는 점이 같습니다. 차이는 무엇을 물려주느냐에 있습니다. 이 글에서는 먼저 추상 클래스로 공통 발행 순서를 고정해 보고, 같은 기능을 인터페이스로 표현한 뒤 둘을 비교합니다. 마지막에는 게시글 종류를 전혀 모르는 발행기를 인터페이스로 만듭니다.

추상 클래스로 발행 순서 고정하기

게시글을 발행할 때마다 '제목 확인, 본문 만들기, 저장'을 같은 순서로 실행해야 한다고 해 봅시다. 본문을 만드는 방법만 게시글 종류마다 다르고 앞뒤 단계는 모두 같습니다. 이런 경우 추상 클래스에 전체 순서를 완성해 두고, 달라지는 단계 하나만 추상 메서드로 비워 둘 수 있습니다.

src/AbstractPostTemplate.java
public final class AbstractPostTemplate {
    public static void main(String[] args) {
        Post post = new CodePost("로그인 구현", 50);
        post.publish();
    }

    private abstract static class Post {
        private final String title;
        private final int viewCount;

        Post(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }

        final void publish() {
            System.out.println("validate=" + title);
            renderBody();
            System.out.println("saved=" + viewCount);
        }

        protected abstract void renderBody();
    }

    private static final class CodePost extends Post {
        CodePost(String title, int viewCount) {
            super(title, viewCount);
        }

        @Override
        protected void renderBody() {
            System.out.println("body=code");
        }
    }
}
validate=로그인 구현
body=code
saved=50

main은 CodePost 객체를 Post 타입 변수에 담고 publish()를 한 번 호출합니다. publish는 부모 Post에 있는 메서드이므로 첫 줄 validate=로그인 구현은 부모가 출력합니다. 이어서 부모 코드가 renderBody()를 부르는데, 이 메서드는 Post에 본문 없이 선언만 된 추상 메서드이고 실제 객체는 CodePost입니다. 그래서 CodePost가 오버라이딩한 구현이 실행되어 body=code가 출력됩니다.

renderBody()가 정상적으로 끝나면 실행은 다시 부모의 publish로 돌아와 마지막 줄 saved=50을 출력합니다. 한 번의 publish() 호출 안에서 부모, 자식, 다시 부모 순서로 코드가 실행된 것입니다. validate=와 saved=는 실행 순서를 보여 주려고 붙인 출력 문구일 뿐이고, 실제로 입력을 검사하거나 어딘가에 저장하는 코드는 없습니다.

publish 앞에 붙은 final은 자식이 이 메서드를 오버라이딩하지 못하게 막습니다. 예를 들어 CodePost에 다음 메서드를 추가하면 컴파일 오류가 납니다.

@Override
void publish() {
    renderBody();
}

메시지의 핵심 줄만 옮기면 다음과 같습니다. 두 번째 줄의 overridden method is final이 '재정의하려는 메서드가 final'이라는 뜻입니다.

error: publish() in CodePost cannot override publish() in Post
  overridden method is final

final이 없었다면 자식이 publish를 통째로 다시 정의해서 제목 확인이나 저장 단계를 빼먹을 수 있었을 것입니다. 이처럼 부모가 전체 순서를 정해 두고 일부 단계만 자식에게 맡기는 구조를 템플릿 메서드 패턴(template method pattern)이라고 합니다. 자식은 자기가 맡은 renderBody()만 구현하면 되고, 순서를 바꾸거나 단계를 건너뛸 수는 없습니다. renderBody()에 붙인 protected는 같은 패키지와 자식 클래스에만 접근을 허용하는 접근 제어자입니다. 여기서는 이 메서드가 바깥에서 직접 부르라고 둔 것이 아니라 자식이 구현하라고 둔 단계라는 뜻으로 붙였습니다.

다만 final이 막는 것은 재정의뿐입니다. renderBody()가 실행 중에 예외를 던지면 publish도 그 자리에서 중단되므로 saved=50은 출력되지 않습니다. 부모가 순서를 정했다고 해서 마지막 단계까지 반드시 실행되는 것은 아닙니다. 아래 그림에서 한 번의 호출이 부모와 자식 사이를 오가는 과정을 단계별로 확인해 보세요.

final publish는 공통 순서를, 자식은 한 호출의 구현을 정한다

부모 final publish에서 validate 출력→자식 renderBody→부모 재개/saved 출력이 이어집니다. 자식 예외 시 마지막 출력에 미도달하며 실제 검증/저장이 없다는 조건을 유지한 호출 전환입니다.

final publish는 공통 순서를, 자식은 한 호출의 구현을 정한다

AbstractPostTemplate의 실제 정상 실행에서 부모 publish의 두 println 사이에 CodePost.renderBody가 호출됩니다.

부모의 final publish 순서 안에서 자식 renderBody를 호출합니다

AbstractPostTemplate · Post post = new CodePost("로그인 구현", 50);에서 post.publish()를 한 번 호출합니다.

한 번의 publish 호출 안에서 실행되는 세 문장
순서 · 실행 구현수행 내용콘솔 출력
1 · Post.publishtitle 필드를 읽어 첫 문장을 출력validate=로그인 구현
2 · CodePost.renderBody부모의 renderBody() 호출이 자식 구현을 실행body=code
3 · Post.publish자식 호출이 정상 반환된 뒤 viewCount 필드를 읽어 출력saved=50
부모의 final publish 순서 안에서 자식 renderBody를 호출합니다 같은Post.publish final부모앞부분validate→CodePost renderBody bodycode→정상반환뒤부모saved50;클래스구현소속영역.실제검증저장없고예외시끝출력보장없음. Post.publish · 부모 구현 CodePost · 자식 구현 부모의 final 메서드 validate=로그인 구현 renderBody() 호출 renderBody() body=code 정상 반환 자식 구현 선택 정상 반환 뒤 부모 실행 재개 saved=50 final은 재정의만 금지 validate/saved는 출력라벨 · 실제 검증·저장 없음 자식 호출이 예외로 끝나면 saved 출력에 도달하지 못함
부모의 final publish 순서 안에서 자식 renderBody를 호출합니다 같은Post.publish final부모앞부분validate→CodePost renderBody bodycode→정상반환뒤부모saved50;클래스구현소속영역.실제검증저장없고예외시끝출력보장없음. Post.publish · 앞부분 부모의 final 메서드 validate=로그인 구현 renderBody() 호출 자식 구현 선택 CodePost.renderBody 자식 구현 body=code 정상 반환 정상 반환 뒤 Post.publish · 뒷부분 부모 실행 재개 saved=50 final은 재정의만 금지 출력라벨 ≠ 실제 저장 자식 예외면 뒷부분 실행 안 함
1 · Post.publish
수행 내용: title 필드를 읽어 첫 문장을 출력
콘솔 출력: validate=로그인 구현
2 · CodePost.renderBody
수행 내용: 부모의 renderBody() 호출이 자식 구현을 실행
콘솔 출력: body=code
3 · Post.publish
수행 내용: 자식 호출이 정상 반환된 뒤 viewCount 필드를 읽어 출력
콘솔 출력: saved=50

final publish()는 자식이 이 메서드를 오버라이딩하지 못하게 합니다. renderBody()는 추상 메서드이며 이 예제의 CodePost가 구현합니다.

validate와 saved는 출력 문자열입니다. 실제 유효성 검사나 저장소 쓰기는 없으며, 자식 호출이 예외로 끝나도 마지막 출력이 보장된다는 뜻이 아닙니다.

이 예의 조건

  • final은 재정의 금지이며 자식 예외 때 saved 출력을 보장하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2같은 Post.publish의 final 부모 앞부분 validate→CodePost의 renderBody body code→정상 반환 뒤 부모 saved 50이다. 영역은 클래스 구현의 소속을 나타낸다. 실제 검증·저장은 없고 예외 시 마지막 출력을 보장하지 않는다.
16validate/saved는 출력 라벨 · 실제 검증·저장 없음
원 도표의 설명과 주석
구분내용
설명같은 Post.publish의 final 부모 앞부분 validate→CodePost의 renderBody body code→정상 반환 뒤 부모 saved 50이다. 영역은 클래스 구현의 소속을 나타낸다. 실제 검증·저장은 없고 예외 시 마지막 출력을 보장하지 않는다.
전체 읽기 정보final publish는 공통 순서를, 자식은 한 호출의 구현을 정한다 final publish는 공통 순서를, 자식은 한 호출의 구현을 정한다 AbstractPostTemplate의 실제 정상 실행에서 부모 publish의 두 println 사이에 CodePost.renderBody가 호출됩니다. 부모의 final publish 순서 안에서 자식 renderBody를 호출합니다 AbstractPostTemplate · Post post = new CodePost("로그인 구현", 50); 에서 post.publish() 를 한 번 호출합니다. 한 번의 publish 호출 안에서 실행되는 세 문장 순서 · 실행 구현 수행 내용 콘솔 출력 1 · Post.publish title 필드를 읽어 첫 문장을 출력 validate=로그인 구현 2 · CodePost.renderBody 부모의 renderBody() 호출이 자식 구현을 실행 body=code 3 · Post.publish 자식 호출이 정상 반환된 뒤 viewCount 필드를 읽어 출력 saved=50 1 · Post.publish 수행 내용: title 필드를 읽어 첫 문장을 출력 콘솔 출력: validate=로그인 구현 2 · CodePost.renderBody 수행 내용: 부모의 renderBody() 호출이 자식 구현을 실행 콘솔 출력: body=code 3 · Post.publish 수행 내용: 자식 호출이 정상 반환된 뒤 viewCount 필드를 읽어 출력 콘솔 출력: saved=50 final publish() 는 자식이 이 메서드를 오버라이딩하지 못하게 합니다. renderBody() 는 추상 메서드이며 이 예제의 CodePost 가 구현합니다. validate 와 saved 는 출력 문자열입니다. 실제 유효성 검사나 저장소 쓰기는 없으며, 자식 호출이 예외로 끝나도 마지막 출력이 보장된다는 뜻이 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
부모 시작

Post.publish가 validate=로그인 구현을 출력합니다.

  • 구현 소유: 부모 final
  • 다음: renderBody 호출
  • 부모 템플릿이 실행 순서를 정합니다.
자식 훅

CodePost.renderBody가 body=code를 출력합니다.

  • 구현 소유: 자식
  • 복귀: 정상 반환 전제
  • final 메서드 안에서도 자식의 다른 메서드를 선택합니다.
부모 재개

정상 반환 뒤 saved=50을 출력합니다.

  • 구현 소유: 부모
  • 의미: 출력 라벨, 실제 저장 없음
  • 자식 호출 실패에서는 이후 마무리에 못 갑니다.

final publish는 공통 순서를, 자식은 한 호출의 구현을 정한다

부모 final publish에서 validate 출력→자식 renderBody→부모 재개/saved 출력이 이어집니다. 자식 예외 시 마지막 출력에 미도달하며 실제 검증/저장이 없다는 조건을 유지한 호출 전환입니다.

final publish는 공통 순서를, 자식은 한 호출의 구현을 정한다

AbstractPostTemplate의 실제 정상 실행에서 부모 publish의 두 println 사이에 CodePost.renderBody가 호출됩니다.

부모의 final publish 순서 안에서 자식 renderBody를 호출합니다

AbstractPostTemplate · Post post = new CodePost("로그인 구현", 50);에서 post.publish()를 한 번 호출합니다.

한 번의 publish 호출 안에서 실행되는 세 문장
순서 · 실행 구현수행 내용콘솔 출력
1 · Post.publishtitle 필드를 읽어 첫 문장을 출력validate=로그인 구현
2 · CodePost.renderBody부모의 renderBody() 호출이 자식 구현을 실행body=code
3 · Post.publish자식 호출이 정상 반환된 뒤 viewCount 필드를 읽어 출력saved=50
부모의 final publish 순서 안에서 자식 renderBody를 호출합니다 같은Post.publish final부모앞부분validate→CodePost renderBody bodycode→정상반환뒤부모saved50;클래스구현소속영역.실제검증저장없고예외시끝출력보장없음. Post.publish · 부모 구현 CodePost · 자식 구현 부모의 final 메서드 validate=로그인 구현 renderBody() 호출 renderBody() body=code 정상 반환 자식 구현 선택 정상 반환 뒤 부모 실행 재개 saved=50 final은 재정의만 금지 validate/saved는 출력라벨 · 실제 검증·저장 없음 자식 호출이 예외로 끝나면 saved 출력에 도달하지 못함
부모의 final publish 순서 안에서 자식 renderBody를 호출합니다 같은Post.publish final부모앞부분validate→CodePost renderBody bodycode→정상반환뒤부모saved50;클래스구현소속영역.실제검증저장없고예외시끝출력보장없음. Post.publish · 앞부분 부모의 final 메서드 validate=로그인 구현 renderBody() 호출 자식 구현 선택 CodePost.renderBody 자식 구현 body=code 정상 반환 정상 반환 뒤 Post.publish · 뒷부분 부모 실행 재개 saved=50 final은 재정의만 금지 출력라벨 ≠ 실제 저장 자식 예외면 뒷부분 실행 안 함
1 · Post.publish
수행 내용: title 필드를 읽어 첫 문장을 출력
콘솔 출력: validate=로그인 구현
2 · CodePost.renderBody
수행 내용: 부모의 renderBody() 호출이 자식 구현을 실행
콘솔 출력: body=code
3 · Post.publish
수행 내용: 자식 호출이 정상 반환된 뒤 viewCount 필드를 읽어 출력
콘솔 출력: saved=50

final publish()는 자식이 이 메서드를 오버라이딩하지 못하게 합니다. renderBody()는 추상 메서드이며 이 예제의 CodePost가 구현합니다.

validate와 saved는 출력 문자열입니다. 실제 유효성 검사나 저장소 쓰기는 없으며, 자식 호출이 예외로 끝나도 마지막 출력이 보장된다는 뜻이 아닙니다.

이 예의 조건

  • final은 재정의 금지이며 자식 예외 때 saved 출력을 보장하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2같은 Post.publish의 final 부모 앞부분 validate→CodePost의 renderBody body code→정상 반환 뒤 부모 saved 50이다. 영역은 클래스 구현의 소속을 나타낸다. 실제 검증·저장은 없고 예외 시 마지막 출력을 보장하지 않는다.
16validate/saved는 출력 라벨 · 실제 검증·저장 없음
원 도표의 설명과 주석
구분내용
설명같은 Post.publish의 final 부모 앞부분 validate→CodePost의 renderBody body code→정상 반환 뒤 부모 saved 50이다. 영역은 클래스 구현의 소속을 나타낸다. 실제 검증·저장은 없고 예외 시 마지막 출력을 보장하지 않는다.
전체 읽기 정보final publish는 공통 순서를, 자식은 한 호출의 구현을 정한다 final publish는 공통 순서를, 자식은 한 호출의 구현을 정한다 AbstractPostTemplate의 실제 정상 실행에서 부모 publish의 두 println 사이에 CodePost.renderBody가 호출됩니다. 부모의 final publish 순서 안에서 자식 renderBody를 호출합니다 AbstractPostTemplate · Post post = new CodePost("로그인 구현", 50); 에서 post.publish() 를 한 번 호출합니다. 한 번의 publish 호출 안에서 실행되는 세 문장 순서 · 실행 구현 수행 내용 콘솔 출력 1 · Post.publish title 필드를 읽어 첫 문장을 출력 validate=로그인 구현 2 · CodePost.renderBody 부모의 renderBody() 호출이 자식 구현을 실행 body=code 3 · Post.publish 자식 호출이 정상 반환된 뒤 viewCount 필드를 읽어 출력 saved=50 1 · Post.publish 수행 내용: title 필드를 읽어 첫 문장을 출력 콘솔 출력: validate=로그인 구현 2 · CodePost.renderBody 수행 내용: 부모의 renderBody() 호출이 자식 구현을 실행 콘솔 출력: body=code 3 · Post.publish 수행 내용: 자식 호출이 정상 반환된 뒤 viewCount 필드를 읽어 출력 콘솔 출력: saved=50 final publish() 는 자식이 이 메서드를 오버라이딩하지 못하게 합니다. renderBody() 는 추상 메서드이며 이 예제의 CodePost 가 구현합니다. validate 와 saved 는 출력 문자열입니다. 실제 유효성 검사나 저장소 쓰기는 없으며, 자식 호출이 예외로 끝나도 마지막 출력이 보장된다는 뜻이 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
부모 시작

Post.publish가 validate=로그인 구현을 출력합니다.

  • 구현 소유: 부모 final
  • 다음: renderBody 호출
  • 부모 템플릿이 실행 순서를 정합니다.
자식 훅

CodePost.renderBody가 body=code를 출력합니다.

  • 구현 소유: 자식
  • 복귀: 정상 반환 전제
  • final 메서드 안에서도 자식의 다른 메서드를 선택합니다.
부모 재개

정상 반환 뒤 saved=50을 출력합니다.

  • 구현 소유: 부모
  • 의미: 출력 라벨, 실제 저장 없음
  • 자식 호출 실패에서는 이후 마무리에 못 갑니다.

이 구조에 추상 클래스가 어울리는 이유는 생성자와 인스턴스 필드를 가질 수 있기 때문입니다. Post는 생성자에서 title과 viewCount를 초기화하고, 자식은 super(title, viewCount)로 그 생성자를 부르기만 합니다. 공통 상태와 그 상태를 쓰는 공통 코드가 한 클래스에 모여 있으니, 발행 순서를 바꾸고 싶을 때도 Post 한 곳만 고치면 됩니다.

인터페이스로 '발행할 수 있다'는 역할 정하기

이번에는 같은 발행 기능을 인터페이스로 표현해 보겠습니다. 인터페이스는 class 대신 interface 키워드로 선언하고, 메서드는 본문 없이 반환 타입과 이름, 매개변수만 적습니다. 어떤 클래스가 그 메서드들을 모두 제공하겠다고 선언할 때는 클래스 이름 뒤에 implements 인터페이스이름을 붙입니다. 이것을 '인터페이스를 구현한다'고 말합니다.

src/PublishableInterface.java
public final class PublishableInterface {
    public static void main(String[] args) {
        Publishable[] posts = {
                new TextPost("가입 인사"),
                new CodePost("로그인 구현")
        };

        for (Publishable post : posts) {
            System.out.println("title=" + post.title());
            post.publish();
        }
    }

    private interface Publishable {
        String title();
        void publish();
    }

    private static final class TextPost implements Publishable {
        private final String title;
        TextPost(String title) { this.title = title; }
        @Override public String title() { return title; }
        @Override public void publish() { System.out.println("text=" + title); }
    }

    private static final class CodePost implements Publishable {
        private final String title;
        CodePost(String title) { this.title = title; }
        @Override public String title() { return title; }
        @Override public void publish() { System.out.println("code=" + title); }
    }
}
title=가입 인사
text=가입 인사
title=로그인 구현
code=로그인 구현

Publishable에는 title()과 publish() 두 메서드가 선언되어 있고, TextPost와 CodePost는 둘 다 이 두 메서드를 구현합니다. main에 있는 배열의 타입은 Publishable[]입니다. 인터페이스도 타입이므로 변수, 배열, 매개변수의 타입으로 쓸 수 있고, 그 인터페이스를 구현한 클래스의 객체라면 무엇이든 담을 수 있습니다.

반복문은 원소마다 title()의 결과를 출력한 뒤 publish()를 부르는데, 실제로 실행되는 것은 그 원소가 가리키는 객체의 클래스에 있는 구현입니다. 그래서 첫 원소에서는 TextPost의 title()이 돌려준 제목으로 title=가입 인사가 출력되고, TextPost의 publish()가 text=가입 인사를 출력합니다. 둘째 원소에서는 같은 두 호출이 CodePost의 구현으로 이어져 title=로그인 구현과 code=로그인 구현이 나옵니다. 부모 타입 변수로 자식 객체를 다루던 다형성이 인터페이스 타입 변수에서도 똑같이 동작하는 것입니다.

눈여겨볼 점은 TextPost와 CodePost 사이에 공통 부모 클래스가 없다는 것입니다. 모든 클래스의 조상인 Object를 빼면 둘은 아무 관계도 없습니다. 그런데도 같은 배열에 담기는 것은 둘 다 Publishable을 구현했기 때문이고, 반복문은 원소가 어떤 클래스인지 묻지 않고 Publishable에 선언된 두 메서드만 사용합니다. 추상 클래스로 같은 일을 하려면 두 클래스를 한 부모 아래에 두어야 하지만, 인터페이스는 이미 다른 부모가 있는 클래스에도 덧붙일 수 있습니다.

구현 메서드에 붙은 @Override는 이 메서드가 인터페이스의 메서드를 구현한다는 표시입니다. 컴파일러가 실제로 그런 메서드가 인터페이스에 있는지 확인하므로, 이름이나 매개변수를 잘못 적으면 바로 오류를 알려 줍니다. 모든 구현 메서드에 public이 붙은 데에도 이유가 있는데, 다음 절에서 설명합니다.

인터페이스를 구현할 때 컴파일러가 확인하는 것

implements Publishable이라고 선언한 클래스는 Publishable의 메서드를 모두 구현해야 합니다. 다음 코드는 publish()만 구현하고 title()을 빠뜨렸습니다.

lab/IncompletePublishablePost.java
public final class IncompletePublishablePost {
    private interface Publishable {
        String title();
        void publish();
    }

    private static final class TextPost implements Publishable {
        @Override
        public void publish() {
            System.out.println("publish text post");
        }
    }
}
컴파일 오류
lab/IncompletePublishablePost.java:7: error: TextPost is not abstract and does not override abstract method title() in Publishable
    private static final class TextPost implements Publishable {
                         ^
1 error

메시지는 'TextPost는 추상 클래스가 아닌데 Publishable의 추상 메서드 title()을 오버라이딩하지 않았다'는 뜻입니다. 인터페이스에 본문 없이 선언한 메서드는 추상 메서드이므로, 앞 글에서 본 추상 클래스의 추상 메서드와 같은 규칙이 적용됩니다. 구체 클래스, 즉 new로 객체를 만들 수 있는 클래스에는 구현되지 않은 추상 메서드가 하나도 남아 있으면 안 됩니다. 이 검사 덕분에 메서드를 빠뜨린 구현은 실행하기 전에 컴파일 단계에서 걸러집니다.

고치는 방법은 두 가지입니다. 대개는 빠뜨린 title()을 구현하면 됩니다. 일부 메서드만 구현하고 나머지를 하위 클래스에 맡기고 싶다면 TextPost를 추상 클래스로 선언하는데, abstract와 final은 함께 쓸 수 없으므로 final은 빼야 합니다. 그러면 title()은 TextPost를 상속하는 구체 클래스가 구현하게 됩니다.

private abstract static class TextPost implements Publishable {
    @Override
    public void publish() {
        System.out.println("publish text post");
    }
}

컴파일러가 두 번째로 확인하는 것은 접근 수준입니다. 다음 코드는 publish()를 구현했지만 public을 빠뜨렸습니다.

lab/PackagePrivatePublish.java
public final class PackagePrivatePublish {
    private interface Publishable {
        void publish();
    }

    private static final class TextPost implements Publishable {
        @Override
        void publish() {
            System.out.println("publish text post");
        }
    }
}
컴파일 오류
lab/PackagePrivatePublish.java:8: error: publish() in TextPost cannot implement publish() in Publishable
        void publish() {
             ^
  attempting to assign weaker access privileges; was public
1 error

인터페이스에 본문 없이 선언한 메서드는 따로 적지 않아도 public abstract입니다. 인터페이스는 바깥에 공개하는 기능 목록이기 때문입니다. 그런데 오버라이딩하는 메서드는 원래 메서드보다 접근 범위를 좁힐 수 없습니다. Publishable 타입으로 객체를 다루는 코드는 어느 패키지에 있든 publish()를 부를 수 있어야 하는데, 구현이 같은 패키지 안에서만 부를 수 있는 메서드라면 그 약속을 지킬 수 없기 때문입니다. 메시지의 attempting to assign weaker access privileges; was public이 바로 '원래 public이던 메서드를 더 좁은 접근 수준으로 구현하려 했다'는 뜻입니다.

그래서 인터페이스 메서드를 구현할 때는 항상 public을 붙입니다. 예제에서는 한 파일에 코드를 모두 담으려고 인터페이스 자체를 바깥 클래스 안에 private으로 선언했지만, 그래도 인터페이스 안의 메서드는 public입니다. 인터페이스를 어디서 볼 수 있느냐와, 그 메서드를 구현할 때 지켜야 할 접근 수준은 별개의 문제입니다.

인터페이스에 넣을 수 있는 것

인터페이스에는 추상 메서드 말고도 몇 가지를 더 넣을 수 있습니다. 다음 예제는 상수, 추상 메서드, default 메서드, static 메서드를 하나씩 담았습니다.

src/InterfaceMembers.java
public final class InterfaceMembers {
    public static void main(String[] args) {
        Publishable post = new TextPost("  가입 인사 ");
        System.out.println("max=" + Publishable.MAX_TITLE_LENGTH);
        System.out.println(post.summary());
    }

    private interface Publishable {
        int MAX_TITLE_LENGTH = 50;              // public static final 상수

        String title();                         // public abstract 메서드

        default String summary() {              // 구현을 가진 default 메서드
            return "[" + normalize(title()) + "]";
        }

        static String normalize(String title) { // 인터페이스 이름으로 부르는 static 메서드
            return title.strip();
        }
    }

    private static final class TextPost implements Publishable {
        private final String title;

        TextPost(String title) {
            this.title = title;
        }

        @Override
        public String title() {
            return title;
        }
    }
}
max=50
[가입 인사]

MAX_TITLE_LENGTH처럼 인터페이스에 선언한 필드는 따로 적지 않아도 public static final, 즉 상수가 됩니다. 그래서 선언할 때 값을 정해야 하고, int count;처럼 값 없이 쓰면 컴파일 오류가 납니다. 객체마다 다른 값을 가지는 인스턴스 필드는 인터페이스에 둘 수 없습니다. 첫 줄 max=50은 이 상수를 Publishable.MAX_TITLE_LENGTH처럼 인터페이스 이름으로 읽은 결과입니다.

default 메서드는 본문을 가진 인스턴스 메서드입니다. 구현 클래스는 이 메서드를 그대로 물려받아 쓰거나 오버라이딩할 수 있으므로, TextPost가 summary()를 구현하지 않았는데도 post.summary()가 동작합니다. 다만 인터페이스에는 인스턴스 필드가 없으니 summary()가 제목을 직접 저장해 둘 수는 없습니다. 대신 추상 메서드 title()을 불러 구현 클래스가 가진 값을 빌려 씁니다. 둘째 줄은 TextPost의 제목 " 가입 인사 "에서 normalize가 앞뒤 공백을 지우고, summary()가 그 결과를 대괄호로 감싼 것입니다.

static 메서드는 Publishable.normalize(...)처럼 인터페이스 이름으로 부릅니다. 클래스의 static 메서드와 달리 구현 클래스에 상속되지 않으므로 TextPost.normalize(...)로는 부를 수 없습니다. 이 밖에 private 메서드도 둘 수 있는데, 여러 default 메서드가 함께 쓰는 보조 코드를 인터페이스 바깥에 드러내지 않고 정리할 때 씁니다. default와 static 메서드는 Java 8부터, private 메서드는 Java 9부터 인터페이스에 넣을 수 있습니다.

이렇게 구현을 가진 메서드가 생겼어도 인터페이스와 추상 클래스의 가장 큰 차이는 그대로입니다. 인터페이스에는 인스턴스 필드와 생성자가 없으므로, 객체의 상태를 만들고 관리하는 일은 언제나 구현 클래스의 몫입니다. 또 인터페이스는 그 자체가 추상 타입이라서 new Publishable()처럼 객체를 만들 수 없고, 그렇게 쓰면 추상 클래스 때와 같은 Publishable is abstract; cannot be instantiated 오류가 납니다. 나중에 배울 익명 클래스에서 보게 될 new Publishable() { ... }는 인터페이스 자체가 아니라, 그 자리에서 이름 없는 구현 클래스를 만들고 그 클래스의 객체를 생성하는 문법입니다.

더 깊이: default 메서드가 생긴 이유

Java 8 이전의 인터페이스에는 본문이 있는 메서드를 넣을 수 없었습니다. 그런데 인터페이스에 추상 메서드를 하나 추가하면, 그 인터페이스를 구현한 모든 클래스는 새 메서드를 구현하기 전까지 컴파일되지 않습니다. 표준 라이브러리의 Collection처럼 수많은 라이브러리와 프로그램이 구현한 인터페이스라면 메서드를 추가하는 일 자체가 사실상 불가능했습니다.

Java 8은 Collection에 stream()을 추가하면서 이 문제를 default 메서드로 풀었습니다. 본문이 있는 메서드는 기존 구현 클래스가 아무것도 고치지 않아도 그대로 물려받기 때문입니다. 즉 default 메서드는 원래 공통 코드를 넣으라고 만든 장치라기보다, 이미 널리 구현된 인터페이스에 기존 구현 클래스를 깨뜨리지 않고 메서드를 추가하기 위한 장치입니다. 이 호환성 문제는 기본 메서드와 호환성에서 자세히 다룹니다.

추상 클래스와 인터페이스 중 무엇을 쓸까

지금까지 본 차이를 표로 정리하면 다음과 같습니다.

비교 항목추상 클래스인터페이스
선언과 사용abstract class로 선언하고 extends로 상속interface로 선언하고 implements로 구현
한 클래스가 가질 수 있는 개수하나만 상속여러 개 구현
인스턴스 필드가질 수 있음없음 (상수만 가능)
생성자있음 (자식 생성자가 super(...)로 호출)없음
구현이 있는 메서드일반 메서드 (final로 재정의를 막을 수 있음)default, static, private 메서드
메서드의 접근 수준public, protected, private 등 자유롭게 (추상 메서드는 private 불가)private 메서드를 빼면 모두 public
어울리는 관계같은 종류의 클래스들이 상태와 코드를 공유관련 없는 클래스들이 같은 기능을 제공

판단의 출발점은 공유할 상태가 있느냐입니다. 모든 하위 클래스가 제목과 조회 수를 가지고 같은 생성 과정과 공통 코드를 공유한다면 추상 클래스가 자연스럽습니다. 앞에서 본 템플릿 메서드처럼 전체 순서를 고정해야 할 때도 추상 클래스를 씁니다. 인터페이스의 default 메서드에는 final을 붙일 수 없어서 구현 클래스가 언제든 오버라이딩할 수 있기 때문입니다.

반대로 공통점이 '이런 메서드를 제공한다'는 것뿐이라면 인터페이스가 맞습니다. 특히 서로 관련 없는 클래스에 같은 기능을 붙여야 하거나, 이미 다른 클래스를 상속한 클래스에 기능을 더해야 할 때는 인터페이스만 쓸 수 있습니다.

둘 중 하나만 골라야 하는 것도 아닙니다. 역할은 인터페이스로 정하고, 여러 구현이 공유하는 상태와 코드는 추상 클래스에 모으는 방식을 자주 씁니다. 예를 들어 Post 추상 클래스가 제목과 조회 수를 맡고, 내보내기가 필요한 게시글만 Exportable 인터페이스를 추가로 구현할 수 있습니다. 자바 표준 라이브러리의 List(인터페이스), AbstractList(List를 일부 구현한 추상 클래스), ArrayList(구체 클래스)도 이런 관계입니다.

인터페이스 이름은 구현 방식보다 역할을 드러내도록 짓습니다. Publishable(발행할 수 있는), Exportable(내보낼 수 있는)처럼 '~할 수 있는'이라는 뜻의 이름을 많이 쓰며, 표준 라이브러리의 Comparable과 Runnable도 같은 방식입니다. 반대로 JsonPost처럼 특정 형식을 이름에 넣으면 JSON 구현 하나에 묶인 이름이 되어, 나중에 CSV로 내보내는 구현이 생겼을 때 어색해집니다.

게시글 종류를 모르는 발행기 만들기

인터페이스는 매개변수 타입으로 자주 쓰입니다. 다음 예제의 Publisher는 Publishable을 받아 발행 전후 처리를 합니다. 이 예제의 Publishable은 앞 예제와 달리 publish() 하나만 가지며, 발행 결과를 PublishResult로 돌려줍니다. PublishResult는 제목과 조회 수를 묶은 record입니다.

src/PublicationRunner.java
public final class PublicationRunner {
    public static void main(String[] args) {
        Publisher publisher = new Publisher();
        publisher.publish(new TextPost());
        publisher.publish(new CodePost());
    }

    private static final class Publisher {
        void publish(Publishable post) {
            System.out.println("publisher:validate");
            PublishResult result = post.publish();
            System.out.println("publisher:saved=" + result.viewCount());
        }
    }

    private interface Publishable {
        PublishResult publish();
    }

    private record PublishResult(String title, int viewCount) {}

    private static final class TextPost implements Publishable {
        @Override
        public PublishResult publish() {
            System.out.println("publish text");
            return new PublishResult("가입 인사", 40);
        }
    }

    private static final class CodePost implements Publishable {
        @Override
        public PublishResult publish() {
            System.out.println("publish code");
            return new PublishResult("로그인 구현", 50);
        }
    }
}
publisher:validate
publish text
publisher:saved=40
publisher:validate
publish code
publisher:saved=50

main은 같은 Publisher에 TextPost와 CodePost를 차례로 넘깁니다. publisher:validate와 publisher:saved=도 앞 예제처럼 실행 순서를 보여 주는 출력 문구입니다. 첫 호출에서 Publisher는 publisher:validate를 출력한 뒤 post.publish()를 부릅니다. 실제 객체가 TextPost이므로 그 구현이 publish text를 출력하고 PublishResult("가입 인사", 40)을 돌려줍니다. 실행이 Publisher로 돌아오면 결과의 viewCount()를 읽어 publisher:saved=40을 출력합니다. 두 번째 호출도 같은 순서로 진행되어 CodePost의 출력과 조회 수 50이 나옵니다.

Publisher 코드에는 TextPost나 CodePost라는 이름이 한 번도 나오지 않습니다. 그래서 동영상 게시글이 새로 생겨도 Publishable을 구현한 클래스를 만들어 넘기기만 하면 되고, Publisher는 고칠 필요가 없습니다. 어떤 구현 객체를 만들지는 main이 정하고 Publisher는 받은 객체를 쓰기만 합니다. 객체를 만드는 곳과 쓰는 곳을 이렇게 나누는 설계는 8장의 인터페이스로 역할과 구현 분리하기에서 본격적으로 다룹니다.

Publisher는 두 구현의 반환값에서 viewCount를 읽는다

Publisher가 각 구현을 호출해 별도 PublishResult를 받고 viewCount를 읽어 마무리 출력합니다. 반환 객체와 호출자 재개를 나누어 예외/null일 때 다음 단계가 실패하는 경계를 보여 줄 수 있습니다.

Publisher는 두 구현의 반환값에서 viewCount를 읽는다

PublicationRunner의 Publisher는 Publishable.publish를 호출하고 TextPost와 CodePost가 반환한 PublishResult의 viewCount를 출력합니다.

Publisher는 구현의 반환 결과에서 조회 수를 읽습니다

PublicationRunner · main은 같은 Publisher에 TextPost, CodePost를 차례로 전달합니다. 아래 행은 서로 다른 두 호출입니다.

입력 구현과 PublishResult, 호출자 출력의 대응
전달한 구현구현의 출력과 반환값Publisher가 이어서 출력
첫 호출 · TextPostpublish text

new PublishResult("가입 인사", 40) 반환

publisher:saved=40
둘째 호출 · CodePostpublish code

new PublishResult("로그인 구현", 50) 반환

publisher:saved=50
Publisher는 구현의 반환 결과에서 조회 수를 읽습니다 Text와Code 2호출은순차독립;Publisher validate→실제구현출력및PublishResult(title가입인사/로그인구현,40/50)반환→caller viewCount읽어saved40/50,title사용없음/실제저장없음. 첫 호출 · TextPost Publisher publisher:validate post.publish() 호출 TextPost.publish() publish text 결과 객체 반환 PublishResult title="가입 인사" viewCount=40 Publisher로 복귀 result.viewCount() publisher:saved=40 다음 호출 · CodePost Publisher publisher:validate post.publish() 호출 CodePost.publish() publish code 결과 객체 반환 PublishResult title="로그인 구현" viewCount=50 Publisher로 복귀 result.viewCount() publisher:saved=50 호출마다 다른 결과 객체 호출자는 title 읽지 않음
Publisher는 구현의 반환 결과에서 조회 수를 읽습니다 Text와Code 2호출은순차독립;Publisher validate→실제구현출력및PublishResult(title가입인사/로그인구현,40/50)반환→caller viewCount읽어saved40/50,title사용없음/실제저장없음. 첫 호출 · TextPost Publisher publisher:validate post.publish() 호출 TextPost.publish() publish text 결과 객체 반환 PublishResult title="가입 인사" viewCount=40 Publisher로 복귀 result.viewCount() publisher:saved=40 다음 호출 · CodePost Publisher publisher:validate post.publish() 호출 CodePost.publish() publish code 결과 객체 반환 PublishResult title="로그인 구현" viewCount=50 Publisher로 복귀 result.viewCount() publisher:saved=50 호출마다 다른 결과 객체 호출자는 title 읽지 않음
첫 호출 · TextPost
구현의 출력과 반환값: publish text

new PublishResult("가입 인사", 40) 반환

Publisher가 이어서 출력: publisher:saved=40
둘째 호출 · CodePost
구현의 출력과 반환값: publish code

new PublishResult("로그인 구현", 50) 반환

Publisher가 이어서 출력: publisher:saved=50

각 호출은 먼저 publisher:validate를 출력한 뒤 post.publish()를 실행합니다. 호출자는 반환된 result.viewCount()만 읽으며 title은 사용하지 않습니다.

구현 선택을 위한 종류 분기는 없습니다. validate·saved는 콘솔 라벨이며, 이 프로그램에는 실제 검증이나 데이터 저장 코드가 없습니다.

이 예의 조건

  • 두 호출은 서로 독립된 객체를 대상으로 순차적으로 실행하며 title은 호출자가 읽지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2Text와 Code의 2개 호출은 순차적으로 수행되며 서로 독립적이다. Publisher의 validate→실제 구현 출력 및 PublishResult(title 가입 인사/로그인 구현,40/50) 반환→caller가 viewCount를 읽어 saved 40/50을 출력한다. title 사용은 없으며 실제 저장도 없다.
원 도표의 설명과 주석
구분내용
설명Text와 Code의 2개 호출은 순차적으로 수행되며 서로 독립적이다. Publisher의 validate→실제 구현 출력 및 PublishResult(title 가입 인사/로그인 구현,40/50) 반환→caller가 viewCount를 읽어 saved 40/50을 출력한다. title 사용은 없으며 실제 저장도 없다.
전체 읽기 정보Publisher는 두 구현의 반환값에서 viewCount를 읽는다 Publisher는 두 구현의 반환값에서 viewCount를 읽는다 PublicationRunner의 Publisher는 Publishable.publish를 호출하고 TextPost와 CodePost가 반환한 PublishResult의 viewCount를 출력합니다. Publisher는 구현의 반환 결과에서 조회 수를 읽습니다 PublicationRunner · main 은 같은 Publisher 에 TextPost , CodePost 를 차례로 전달합니다. 아래 행은 서로 다른 두 호출입니다. 입력 구현과 PublishResult, 호출자 출력의 대응 전달한 구현 구현의 출력과 반환값 Publisher가 이어서 출력 첫 호출 · TextPost publish text new PublishResult("가입 인사", 40) 반환 publisher:saved=40 둘째 호출 · CodePost publish code new PublishResult("로그인 구현", 50) 반환 publisher:saved=50 첫 호출 · TextPost 구현의 출력과 반환값: publish text new PublishResult("가입 인사", 40) 반환 Publisher가 이어서 출력: publisher:saved=40 둘째 호출 · CodePost 구현의 출력과 반환값: publish code new PublishResult("로그인 구현", 50) 반환 Publisher가 이어서 출력: publisher:saved=50 각 호출은 먼저 publisher:validate 를 출력한 뒤 post.publish() 를 실행합니다. 호출자는 반환된 result.viewCount() 만 읽으며 title 은 사용하지 않습니다. 구현 선택을 위한 종류 분기는 없습니다. validate · saved 는 콘솔 라벨이며, 이 프로그램에는 실제 검증이나 데이터 저장 코드가 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Publisher 시작

각 호출에서 publisher:validate를 출력합니다.

  • Text: 첫 호출
  • Code: 다음 호출
  • 두 구현이 동일한 호출자 경계를 사용합니다.
구현 결과

publish가 문자열을 출력하고 다른 결과 객체를 반환합니다.

  • Text: PublishResult 가입 인사,40
  • Code: PublishResult 로그인 구현,50
  • 값 반환과 콘솔 출력은 다른 산출 경계입니다.
반환값 조회

Publisher가 result.viewCount만 읽습니다.

  • 첫 출력: publisher:saved=40
  • 다음 출력: publisher:saved=50
  • saved 라벨은 실제 저장을 뜻하지 않으며 title 조회도 없습니다.

Publisher는 두 구현의 반환값에서 viewCount를 읽는다

Publisher가 각 구현을 호출해 별도 PublishResult를 받고 viewCount를 읽어 마무리 출력합니다. 반환 객체와 호출자 재개를 나누어 예외/null일 때 다음 단계가 실패하는 경계를 보여 줄 수 있습니다.

Publisher는 두 구현의 반환값에서 viewCount를 읽는다

PublicationRunner의 Publisher는 Publishable.publish를 호출하고 TextPost와 CodePost가 반환한 PublishResult의 viewCount를 출력합니다.

Publisher는 구현의 반환 결과에서 조회 수를 읽습니다

PublicationRunner · main은 같은 Publisher에 TextPost, CodePost를 차례로 전달합니다. 아래 행은 서로 다른 두 호출입니다.

입력 구현과 PublishResult, 호출자 출력의 대응
전달한 구현구현의 출력과 반환값Publisher가 이어서 출력
첫 호출 · TextPostpublish text

new PublishResult("가입 인사", 40) 반환

publisher:saved=40
둘째 호출 · CodePostpublish code

new PublishResult("로그인 구현", 50) 반환

publisher:saved=50
Publisher는 구현의 반환 결과에서 조회 수를 읽습니다 Text와Code 2호출은순차독립;Publisher validate→실제구현출력및PublishResult(title가입인사/로그인구현,40/50)반환→caller viewCount읽어saved40/50,title사용없음/실제저장없음. 첫 호출 · TextPost Publisher publisher:validate post.publish() 호출 TextPost.publish() publish text 결과 객체 반환 PublishResult title="가입 인사" viewCount=40 Publisher로 복귀 result.viewCount() publisher:saved=40 다음 호출 · CodePost Publisher publisher:validate post.publish() 호출 CodePost.publish() publish code 결과 객체 반환 PublishResult title="로그인 구현" viewCount=50 Publisher로 복귀 result.viewCount() publisher:saved=50 호출마다 다른 결과 객체 호출자는 title 읽지 않음
Publisher는 구현의 반환 결과에서 조회 수를 읽습니다 Text와Code 2호출은순차독립;Publisher validate→실제구현출력및PublishResult(title가입인사/로그인구현,40/50)반환→caller viewCount읽어saved40/50,title사용없음/실제저장없음. 첫 호출 · TextPost Publisher publisher:validate post.publish() 호출 TextPost.publish() publish text 결과 객체 반환 PublishResult title="가입 인사" viewCount=40 Publisher로 복귀 result.viewCount() publisher:saved=40 다음 호출 · CodePost Publisher publisher:validate post.publish() 호출 CodePost.publish() publish code 결과 객체 반환 PublishResult title="로그인 구현" viewCount=50 Publisher로 복귀 result.viewCount() publisher:saved=50 호출마다 다른 결과 객체 호출자는 title 읽지 않음
첫 호출 · TextPost
구현의 출력과 반환값: publish text

new PublishResult("가입 인사", 40) 반환

Publisher가 이어서 출력: publisher:saved=40
둘째 호출 · CodePost
구현의 출력과 반환값: publish code

new PublishResult("로그인 구현", 50) 반환

Publisher가 이어서 출력: publisher:saved=50

각 호출은 먼저 publisher:validate를 출력한 뒤 post.publish()를 실행합니다. 호출자는 반환된 result.viewCount()만 읽으며 title은 사용하지 않습니다.

구현 선택을 위한 종류 분기는 없습니다. validate·saved는 콘솔 라벨이며, 이 프로그램에는 실제 검증이나 데이터 저장 코드가 없습니다.

이 예의 조건

  • 두 호출은 서로 독립된 객체를 대상으로 순차적으로 실행하며 title은 호출자가 읽지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2Text와 Code의 2개 호출은 순차적으로 수행되며 서로 독립적이다. Publisher의 validate→실제 구현 출력 및 PublishResult(title 가입 인사/로그인 구현,40/50) 반환→caller가 viewCount를 읽어 saved 40/50을 출력한다. title 사용은 없으며 실제 저장도 없다.
원 도표의 설명과 주석
구분내용
설명Text와 Code의 2개 호출은 순차적으로 수행되며 서로 독립적이다. Publisher의 validate→실제 구현 출력 및 PublishResult(title 가입 인사/로그인 구현,40/50) 반환→caller가 viewCount를 읽어 saved 40/50을 출력한다. title 사용은 없으며 실제 저장도 없다.
전체 읽기 정보Publisher는 두 구현의 반환값에서 viewCount를 읽는다 Publisher는 두 구현의 반환값에서 viewCount를 읽는다 PublicationRunner의 Publisher는 Publishable.publish를 호출하고 TextPost와 CodePost가 반환한 PublishResult의 viewCount를 출력합니다. Publisher는 구현의 반환 결과에서 조회 수를 읽습니다 PublicationRunner · main 은 같은 Publisher 에 TextPost , CodePost 를 차례로 전달합니다. 아래 행은 서로 다른 두 호출입니다. 입력 구현과 PublishResult, 호출자 출력의 대응 전달한 구현 구현의 출력과 반환값 Publisher가 이어서 출력 첫 호출 · TextPost publish text new PublishResult("가입 인사", 40) 반환 publisher:saved=40 둘째 호출 · CodePost publish code new PublishResult("로그인 구현", 50) 반환 publisher:saved=50 첫 호출 · TextPost 구현의 출력과 반환값: publish text new PublishResult("가입 인사", 40) 반환 Publisher가 이어서 출력: publisher:saved=40 둘째 호출 · CodePost 구현의 출력과 반환값: publish code new PublishResult("로그인 구현", 50) 반환 Publisher가 이어서 출력: publisher:saved=50 각 호출은 먼저 publisher:validate 를 출력한 뒤 post.publish() 를 실행합니다. 호출자는 반환된 result.viewCount() 만 읽으며 title 은 사용하지 않습니다. 구현 선택을 위한 종류 분기는 없습니다. validate · saved 는 콘솔 라벨이며, 이 프로그램에는 실제 검증이나 데이터 저장 코드가 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Publisher 시작

각 호출에서 publisher:validate를 출력합니다.

  • Text: 첫 호출
  • Code: 다음 호출
  • 두 구현이 동일한 호출자 경계를 사용합니다.
구현 결과

publish가 문자열을 출력하고 다른 결과 객체를 반환합니다.

  • Text: PublishResult 가입 인사,40
  • Code: PublishResult 로그인 구현,50
  • 값 반환과 콘솔 출력은 다른 산출 경계입니다.
반환값 조회

Publisher가 result.viewCount만 읽습니다.

  • 첫 출력: publisher:saved=40
  • 다음 출력: publisher:saved=50
  • saved 라벨은 실제 저장을 뜻하지 않으며 title 조회도 없습니다.

그림에서 보듯 Publisher는 돌려받은 결과에서 viewCount()만 읽고 title은 쓰지 않습니다. 그리고 지금의 Publisher는 구현이 언제나 결과 객체를 돌려준다고 믿고 곧바로 result.viewCount()를 부릅니다. 하지만 인터페이스의 메서드 선언이 알려 주는 것은 이름, 매개변수, 반환 타입 정도입니다. PublishResult publish()라는 선언은 '이 타입의 값을 돌려준다'는 뜻이지 null을 돌려주지 않는다는 뜻이 아니어서, 구현이 null을 돌려줘도 컴파일러는 막지 않습니다. 실행 중에 예외가 나서 값을 아예 돌려주지 못하는 경우도 있는데, 이 역시 지금의 선언만 봐서는 알 수 없습니다.

그래서 인터페이스를 설계할 때는 무엇을 돌려주는지, 어떤 경우에 실패하는지도 함께 정해서 주석으로 적어 둡니다. 예를 들어 다음처럼 적어 두면, 구현하는 쪽은 null 대신 예외로 실패를 알려야 한다는 것을 알고, 사용하는 쪽은 결과를 받으면 null 검사 없이 바로 써도 된다는 것을 압니다.

private interface Publishable {
    /**
     * 게시글을 발행하고 결과를 돌려준다.
     * 성공하면 null이 아닌 결과를 돌려주고,
     * 발행할 수 없으면 IllegalStateException을 던진다.
     */
    PublishResult publish();
}
생각해 보기: 구현이 예외를 던지거나 null을 돌려준다면?

main은 그대로 두고 TextPost의 publish()만 바꿔 봅니다. 경우 A에서는 publish text를 출력한 다음 예외를 던지고, 경우 B에서는 같은 문자열을 출력한 다음 null을 돌려줍니다. 예외를 처리하는 코드는 추가하지 않습니다. 각 경우에 어디까지 출력될까요? publisher:saved=40과 두 번째 호출의 publish code는 출력될까요?

풀이: 두 경우 모두 publisher:validate와 publish text까지만 출력되고 프로그램이 예외로 끝납니다. A에서는 post.publish() 호출 자체가 예외로 끝나므로 결과를 result에 대입하는 일도, 그 뒤의 publisher:saved=40 출력도 일어나지 않습니다. B에서는 result에 null이 대입되고, 다음 줄의 result.viewCount()에서 NullPointerException이 납니다.

어느 경우든 예외를 처리하는 코드가 없으므로 예외가 main까지 그대로 올라가 프로그램이 멈춥니다. 그래서 두 번째 publisher.publish(new CodePost())는 실행되지 않고 publish code도 출력되지 않습니다. 반환값의 의미와 실패하는 경우를 인터페이스를 설계할 때 정해 두어야 하는 이유가 여기에 있습니다.

정리

  • 추상 클래스는 공통 필드와 완성된 메서드를 자식에게 물려주고, 생성자로 공통 필드를 초기화하며, 일부 메서드만 추상 메서드로 남깁니다. final 메서드로 전체 순서를 고정하고 한 단계만 자식에게 맡기는 구조를 템플릿 메서드 패턴이라고 합니다.
  • 인터페이스는 구현할 메서드 목록을 정한 타입입니다. 클래스는 implements로 인터페이스를 구현하며, 클래스 계층과 상관없이 어느 클래스든 같은 인터페이스를 구현할 수 있습니다.
  • 인터페이스에 본문 없이 선언한 메서드는 public abstract입니다. 그래서 구체 클래스는 이 메서드를 모두 구현해야 하고, 구현 메서드에는 public을 붙여야 합니다.
  • 인터페이스의 필드는 상수뿐이고 생성자가 없습니다. default, static, private 메서드로 구현을 가질 수는 있지만 상태는 구현 클래스가 가집니다.
  • 공유할 상태와 코드가 있으면 추상 클래스를, 관련 없는 클래스들이 같은 기능을 제공해야 하면 인터페이스를 쓰고, 둘을 함께 쓰기도 합니다.
  • 인터페이스를 매개변수 타입으로 쓰면 구현 클래스를 모르는 코드를 만들 수 있습니다. 다만 반환값의 의미와 실패 조건은 타입만으로 정해지지 않으므로 따로 정해 두어야 합니다.

다음 글에서는 클래스 하나가 인터페이스 여러 개를 동시에 구현하는 경우를 다룹니다. 게시글에 발행, 검수, 내보내기 역할을 골라 붙이고, 두 인터페이스의 default 메서드가 겹칠 때 해결하는 방법도 봅니다.

연습 문제

문제 1: 알림 역할 만들기

PostNotifier 인터페이스에 notify(String message)를 선언하고 ConsoleNotifier와 SilentNotifier가 구현하게 하세요. ConsoleNotifier는 console= 뒤에 메시지를 붙여 출력하고, SilentNotifier는 메시지와 상관없이 silent만 출력합니다. PostNotifier를 매개변수로 받는 send 메서드 하나에 두 구현을 차례로 넘겨 동작 차이를 확인합니다.

해설 보기
src/PostNotifierInterface.java
public final class PostNotifierInterface {
    public static void main(String[] args) {
        send(new ConsoleNotifier(), "post published");
        send(new SilentNotifier(), "post published");
    }

    private static void send(PostNotifier notifier, String message) {
        notifier.notify(message);
    }

    private interface PostNotifier {
        void notify(String message);
    }

    private static final class ConsoleNotifier implements PostNotifier {
        @Override public void notify(String message) {
            System.out.println("console=" + message);
        }
    }

    private static final class SilentNotifier implements PostNotifier {
        @Override public void notify(String message) {
            System.out.println("silent");
        }
    }
}
console=post published
silent

send는 PostNotifier 타입만 알고, 실제로 어떤 알림 방식이 실행될지는 넘겨받은 객체가 정합니다. 그래서 첫 호출은 console=post published를, 두 번째 호출은 silent를 출력합니다. SilentNotifier처럼 알림을 보내지 않는 구현도 같은 인터페이스를 구현하므로, send 안에 알림을 보낼지 묻는 if를 두지 않고 넘기는 객체만 바꾸면 됩니다.

메서드 이름 notify는 모든 클래스가 물려받는 Object의 notify()와 이름이 같습니다. 여기서는 매개변수가 달라 서로 다른 메서드(오버로딩)이므로 문제가 없습니다. 하지만 매개변수 없는 void notify();를 선언하면 Object의 final 메서드를 재정의하려는 것이 되어 컴파일 오류가 납니다.

문제 2: 컴파일되지 않는 이유 찾기

다음 코드가 컴파일되지 않는 이유를 설명하고 고치세요. MAX_PRIORITY가 어떤 종류의 필드인지도 설명하세요.

interface Moderatable {
    int MAX_PRIORITY = 100;
    int priority();
}

final class NoticePost implements Moderatable {
    int priority() {
        return 10;
    }
}
해설 보기

Moderatable의 priority()는 본문 없이 선언되었으므로 자동으로 public abstract입니다. 그런데 NoticePost의 priority()에는 접근 제어자가 없어 같은 패키지에서만 부를 수 있는 메서드가 됩니다. 오버라이딩하면서 접근 범위를 좁힐 수는 없으므로 attempting to assign weaker access privileges; was public 오류가 납니다. public int priority()로 고치면 컴파일됩니다.

MAX_PRIORITY는 따로 적지 않아도 public static final인 상수입니다. 선언할 때 값 100을 정했으므로 문제가 없고, Moderatable.MAX_PRIORITY처럼 인터페이스 이름으로 읽습니다. 만약 int MAX_PRIORITY;처럼 값 없이 선언했다면 그 줄도 컴파일 오류가 됩니다.

문제 3: 추상 클래스와 인터페이스 고르기

다음 세 상황에서 추상 클래스와 인터페이스 중 무엇이 어울리는지 고르고 이유를 설명하세요.

  1. 모든 게시글이 제목, 작성자, 조회 수를 가지고, 생성할 때 제목이 비어 있지 않은지 같은 방식으로 검사해야 한다.
  2. 게시글, 댓글, 회원 프로필을 모두 신고할 수 있어야 하고, 신고 처리 코드는 신고 대상의 reportSummary()만 사용한다.
  3. CodePost는 이미 Post를 상속하고 있는데, 일부 게시글에만 CSV로 내보내는 기능을 붙이고 싶다.
해설 보기

1번은 추상 클래스가 어울립니다. 세 필드라는 공통 상태가 있고, 그 상태를 초기화하면서 제목을 검사하는 생성자도 공유해야 합니다. 인터페이스에는 인스턴스 필드도 생성자도 없으므로 이 일을 맡길 수 없습니다.

2번은 인터페이스가 어울립니다. 게시글, 댓글, 회원 프로필은 '한 종류'로 묶을 수 있는 관계가 아니고, 신고 처리 코드에 필요한 것은 reportSummary() 하나뿐입니다. Reportable 같은 인터페이스를 만들어 세 클래스가 각자 구현하면, 신고 처리 코드는 Reportable 타입만 알면 됩니다.

3번도 인터페이스입니다. CodePost는 이미 Post를 상속했으므로 부모 클래스를 하나 더 둘 수 없습니다. 또 내보내기는 일부 게시글에만 필요한 기능이라 Post에 넣으면 내보낼 수 없는 게시글까지 그 메서드를 갖게 됩니다. Exportable 인터페이스를 만들어 필요한 클래스만 구현하게 하는 방법은 다음 글에서 이어서 다룹니다.