다형성과 추상 클래스
게시글 종류마다 복사된 처리 코드를 부모 타입과 오버라이딩으로 하나로 합치고, 직접 객체로 만들 이유가 없는 부모 클래스를 abstract로 선언해 생성과 구현 누락을 막는 방법을 배웁니다.
게시판이 텍스트, 코드, 영상 게시글을 처리한다고 해 봅시다. 게시글을 올리는 순서는 모두 같습니다. 제목을 확인하고, 게시글을 게시판에 공개하고, 조회 수를 기록합니다. 다른 것은 가운데의 공개 방식뿐입니다. 그런데 게시글 종류마다 처리 메서드를 따로 만들면 다음처럼 거의 같은 메서드가 종류 수만큼 생깁니다.
static void processTextPost(TextPost post) { /* 확인 → 텍스트 공개 → 기록 */ }
static void processCodePost(CodePost post) { /* 확인 → 코드 공개 → 기록 */ }
static void processVideoPost(VideoPost post) { /* 확인 → 영상 공개 → 기록 */ }세 메서드는 가운데 한 단계만 다르고 나머지는 같은 코드의 복사본입니다. 확인 단계를 바꾸려면 세 곳을 모두 고쳐야 하고, 이미지 게시글이 추가되면 네 번째 복사본을 만들어야 합니다.
instanceof와 오버라이딩에서 본 동적 디스패치를 쓰면 이 메서드들을 하나로 합칠 수 있습니다. 이 글에서는 먼저 부모 타입 매개변수와 배열로 중복을 없앱니다. 그다음 그 과정에서 생기는 새 질문을 다룹니다. 모든 게시글의 부모인 Post는 직접 객체로 만들어도 될까요? 자식이 publish()를 오버라이딩하는 것을 잊으면 어떻게 될까요? 두 질문의 답이 추상 클래스(abstract class)입니다.
공통 순서는 한 번만 쓰고, 다른 부분은 자식에게 맡기기
다음 예제는 세 메서드를 process(Post post) 하나로 합칩니다. 공통 단계는 process가 직접 하고, 종류마다 다른 공개 단계만 post.publish()로 호출합니다. publish()가 실제로 무엇을 할지는 넘어온 객체가 정합니다. Post 선언 앞의 abstract와 본문 없는 abstract void publish();는 이 글의 뒤에서 설명합니다. 지금은 'Post는 publish()의 구현을 자식에게 맡긴다'는 뜻으로만 읽으면 됩니다.
public final class PolymorphicProcessor {
public static void main(String[] args) {
process(new TextPost("가입 인사", 40));
process(new CodePost("로그인 구현", 50));
}
private static void process(Post post) {
System.out.println("validate=" + post.title());
post.publish();
System.out.println("saved=" + post.viewCount());
}
private abstract static class Post {
private final String title;
private final int viewCount;
Post(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
String title() { return title; }
int viewCount() { return viewCount; }
abstract void publish();
}
private static final class TextPost extends Post {
TextPost(String title, int viewCount) { super(title, viewCount); }
@Override void publish() { System.out.println("publish=text"); }
}
private static final class CodePost extends Post {
CodePost(String title, int viewCount) { super(title, viewCount); }
@Override void publish() { System.out.println("publish=code"); }
}
}validate=가입 인사
publish=text
saved=40
validate=로그인 구현
publish=code
saved=50출력은 세 줄씩 두 묶음입니다. 첫 묶음은 process(new TextPost(...))가, 둘째 묶음은 process(new CodePost(...))가 출력했습니다. 각 묶음의 첫 줄과 셋째 줄은 process가 직접 출력한 것이라 두 묶음의 모양이 같고, 값만 객체마다 다릅니다. 가운데 줄은 post.publish()가 동적 디스패치로 실제 객체의 구현을 실행한 결과라서 publish=text와 publish=code로 갈립니다. validate=와 saved=는 실행 순서를 보여 주려고 붙인 출력 문자열일 뿐, 이 프로그램이 실제로 입력을 검증하거나 무언가를 저장하지는 않습니다.
아래 그림에서 두 호출이 같은 세 단계를 거치는 모습을 따라가 보세요. 공통 시작과 공통 마무리 사이에서, 가운데 단계만 실제 객체의 publish()로 넘어갑니다.
공통 처리의 전후 출력과 자식별 publish 호출
TextPost 처리 후 CodePost 처리에서 공통 시작 출력→자식 publish→공통 마무리 출력이 이어집니다. validate/saved는 실제 검증/저장이 아니라 관찰 메시지라는 조건을 유지한 실행 서사입니다.
PolymorphicProcessor의 process는 제목 출력, 실제 자식 publish 호출, 조회 수 출력을 공유한다. validate와 saved는 출력 문자열이며 실제 유효성 검사와 저장 작업을 수행하지 않는다.
공통 전후 출력 사이에서 실제 자식의 publish를 호출합니다
PolymorphicProcessor의 process(Post post)는 세 단계를 공유합니다. main은 TextPost 호출을 모두 끝낸 뒤 CodePost 호출을 시작합니다.
| 순서와 작업 | TextPost · 가입 인사, 40 | CodePost · 로그인 구현, 50 |
|---|---|---|
| 1. 시작 출력
| validate=가입 인사 | validate=로그인 구현 |
| 2. 자식별 동작
| TextPost.publish()
| CodePost.publish()
|
| 3. 마무리 출력
| saved=40 | saved=50 |
- 첫 호출:
TextPost("가입 인사", 40)제목 출력 →
TextPost.publish()실행 → 조회 수 출력 순서입니다.validate=가입 인사 publish=text saved=40 - 다음 호출:
CodePost("로그인 구현", 50)첫 호출이 끝난 뒤 같은 순서에서
CodePost.publish()을 실행합니다.validate=로그인 구현 publish=code saved=50
validate=와 saved=는 관찰용 출력 문자열입니다. 이 프로그램에는 실제 입력 검증이나 저장 작업이 없으며, 가운데 publish()도 각 자식이 문자열을 출력하는 구현입니다.
이 예의 조건
- validate와 saved는 콘솔 라벨이며 실제 검증·저장은 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 첫 Text 호출은 validate 가입 인사→자식 publish text→saved 40이고, 정상 종료 후 Code 호출은 validate 로그인 구현→publish code→saved 50이다. validate/saved는 출력 라벨이며 실제 검증·저장은 없다. |
| 구분 | 내용 |
|---|---|
| 설명 | 첫 Text 호출은 validate 가입 인사→자식 publish text→saved 40이고, 정상 종료 후 Code 호출은 validate 로그인 구현→publish code→saved 50이다. validate/saved는 출력 라벨이며 실제 검증·저장은 없다. |
| 전체 읽기 정보 | 공통 처리의 전후 출력과 자식별 publish 호출 공통 처리의 전후 출력과 자식별 publish 호출 PolymorphicProcessor의 process는 제목 출력, 실제 자식 publish 호출, 조회 수 출력을 공유한다. validate와 saved는 출력 문자열이며 실제 유효성 검사와 저장 작업을 수행하지 않는다. 공통 전후 출력 사이에서 실제 자식의 publish를 호출합니다 PolymorphicProcessor 의 process(Post post) 는 세 단계를 공유합니다. main 은 TextPost 호출을 모두 끝낸 뒤 CodePost 호출을 시작합니다. TextPost와 CodePost에 같은 process를 적용한 단계별 실제 출력 순서와 작업 TextPost · 가입 인사, 40 CodePost · 로그인 구현, 50 1. 시작 출력 post.title() validate=가입 인사 validate=로그인 구현 2. 자식별 동작 post.publish() TextPost.publish() publish=text CodePost.publish() publish=code 3. 마무리 출력 post.viewCount() saved=40 saved=50 첫 호출: TextPost("가입 인사", 40) 제목 출력 → TextPost.publish() 실행 → 조회 수 출력 순서입니다. validate=가입 인사 publish=text saved=40 다음 호출: CodePost("로그인 구현", 50) 첫 호출이 끝난 뒤 같은 순서에서 CodePost.publish() 을 실행합니다. validate=로그인 구현 publish=code saved=50 validate= 와 saved= 는 관찰용 출력 문자열입니다. 이 프로그램에는 실제 입력 검증이나 저장 작업이 없으며, 가운데 publish() 도 각 자식이 문자열을 출력하는 구현입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공통 시작 process가 각 게시글의 시작 문자열을 출력합니다. |
|
|
| 동적 publish 실제 자식의 publish가 출력합니다. |
|
|
| 공통 마무리 process가 조회 수를 읽어 saved를 출력합니다. |
|
|
공통 처리의 전후 출력과 자식별 publish 호출
TextPost 처리 후 CodePost 처리에서 공통 시작 출력→자식 publish→공통 마무리 출력이 이어집니다. validate/saved는 실제 검증/저장이 아니라 관찰 메시지라는 조건을 유지한 실행 서사입니다.
PolymorphicProcessor의 process는 제목 출력, 실제 자식 publish 호출, 조회 수 출력을 공유한다. validate와 saved는 출력 문자열이며 실제 유효성 검사와 저장 작업을 수행하지 않는다.
공통 전후 출력 사이에서 실제 자식의 publish를 호출합니다
PolymorphicProcessor의 process(Post post)는 세 단계를 공유합니다. main은 TextPost 호출을 모두 끝낸 뒤 CodePost 호출을 시작합니다.
| 순서와 작업 | TextPost · 가입 인사, 40 | CodePost · 로그인 구현, 50 |
|---|---|---|
| 1. 시작 출력
| validate=가입 인사 | validate=로그인 구현 |
| 2. 자식별 동작
| TextPost.publish()
| CodePost.publish()
|
| 3. 마무리 출력
| saved=40 | saved=50 |
- 첫 호출:
TextPost("가입 인사", 40)제목 출력 →
TextPost.publish()실행 → 조회 수 출력 순서입니다.validate=가입 인사 publish=text saved=40 - 다음 호출:
CodePost("로그인 구현", 50)첫 호출이 끝난 뒤 같은 순서에서
CodePost.publish()을 실행합니다.validate=로그인 구현 publish=code saved=50
validate=와 saved=는 관찰용 출력 문자열입니다. 이 프로그램에는 실제 입력 검증이나 저장 작업이 없으며, 가운데 publish()도 각 자식이 문자열을 출력하는 구현입니다.
이 예의 조건
- validate와 saved는 콘솔 라벨이며 실제 검증·저장은 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 첫 Text 호출은 validate 가입 인사→자식 publish text→saved 40이고, 정상 종료 후 Code 호출은 validate 로그인 구현→publish code→saved 50이다. validate/saved는 출력 라벨이며 실제 검증·저장은 없다. |
| 구분 | 내용 |
|---|---|
| 설명 | 첫 Text 호출은 validate 가입 인사→자식 publish text→saved 40이고, 정상 종료 후 Code 호출은 validate 로그인 구현→publish code→saved 50이다. validate/saved는 출력 라벨이며 실제 검증·저장은 없다. |
| 전체 읽기 정보 | 공통 처리의 전후 출력과 자식별 publish 호출 공통 처리의 전후 출력과 자식별 publish 호출 PolymorphicProcessor의 process는 제목 출력, 실제 자식 publish 호출, 조회 수 출력을 공유한다. validate와 saved는 출력 문자열이며 실제 유효성 검사와 저장 작업을 수행하지 않는다. 공통 전후 출력 사이에서 실제 자식의 publish를 호출합니다 PolymorphicProcessor 의 process(Post post) 는 세 단계를 공유합니다. main 은 TextPost 호출을 모두 끝낸 뒤 CodePost 호출을 시작합니다. TextPost와 CodePost에 같은 process를 적용한 단계별 실제 출력 순서와 작업 TextPost · 가입 인사, 40 CodePost · 로그인 구현, 50 1. 시작 출력 post.title() validate=가입 인사 validate=로그인 구현 2. 자식별 동작 post.publish() TextPost.publish() publish=text CodePost.publish() publish=code 3. 마무리 출력 post.viewCount() saved=40 saved=50 첫 호출: TextPost("가입 인사", 40) 제목 출력 → TextPost.publish() 실행 → 조회 수 출력 순서입니다. validate=가입 인사 publish=text saved=40 다음 호출: CodePost("로그인 구현", 50) 첫 호출이 끝난 뒤 같은 순서에서 CodePost.publish() 을 실행합니다. validate=로그인 구현 publish=code saved=50 validate= 와 saved= 는 관찰용 출력 문자열입니다. 이 프로그램에는 실제 입력 검증이나 저장 작업이 없으며, 가운데 publish() 도 각 자식이 문자열을 출력하는 구현입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공통 시작 process가 각 게시글의 시작 문자열을 출력합니다. |
|
|
| 동적 publish 실제 자식의 publish가 출력합니다. |
|
|
| 공통 마무리 process가 조회 수를 읽어 saved를 출력합니다. |
|
|
process는 TextPost나 CodePost라는 클래스가 있다는 것조차 모릅니다. 아는 것은 Post에 title(), publish(), viewCount()가 있다는 사실뿐입니다. 그래서 영상 게시글을 추가할 때는 Post를 상속해 publish()를 구현한 VideoPost만 만들면 되고, process는 한 글자도 고치지 않습니다. 공통 단계를 바꿀 때도 process 한 곳만 고치면 됩니다.
부모 타입 배열로 반복문도 하나로
게시글이 여러 개일 때도 같은 원리가 적용됩니다. 종류마다 TextPost[], CodePost[]처럼 배열을 따로 두면 배열마다 반복문을 복사해야 합니다. 부모 타입 변수로 자식 객체 다루기에서 본 것처럼 Post[] 배열 하나에 모든 종류를 담으면 반복문도 하나면 됩니다. 다음 예제는 세 종류의 게시글을 한 배열에 담고, 반복문 하나로 종류를 출력하고 공개한 뒤 조회 수를 합산합니다.
public final class PostBatch {
public static void main(String[] args) {
Post[] posts = {
new TextPost(40),
new CodePost(50),
new VideoPost(30)
};
int total = 0;
for (Post post : posts) {
System.out.println(post.kind());
post.publish();
total += post.viewCount();
}
System.out.println("total=" + total);
}
private abstract static class Post {
private final int viewCount;
Post(int viewCount) { this.viewCount = viewCount; }
int viewCount() { return viewCount; }
abstract String kind();
abstract void publish();
}
private static final class TextPost extends Post {
TextPost(int viewCount) { super(viewCount); }
@Override String kind() { return "text"; }
@Override void publish() { System.out.println("publish=text"); }
}
private static final class CodePost extends Post {
CodePost(int viewCount) { super(viewCount); }
@Override String kind() { return "code"; }
@Override void publish() { System.out.println("publish=code"); }
}
private static final class VideoPost extends Post {
VideoPost(int viewCount) { super(viewCount); }
@Override String kind() { return "video"; }
@Override void publish() { System.out.println("publish=video"); }
}
}text
publish=text
code
publish=code
video
publish=video
total=120반복문은 원소마다 kind()와 publish()를 호출하는데, 두 메서드 모두 실제 객체의 구현이 실행되므로 원소마다 두 줄씩 다른 내용이 출력됩니다. 반면 viewCount()는 오버라이딩되지 않은 부모의 메서드라서 세 객체가 같은 구현을 씁니다. total은 40, 90, 120으로 늘어나고, 마지막 줄에 total=120이 출력됩니다.
이 코드에서 배열 선언, 반복문, 합계 계산은 모두 한 번씩만 나옵니다. 게시글 종류를 묻는 if나 instanceof도 없습니다. 종류에 따라 달라지는 부분은 모두 각 자식 클래스 안의 오버라이딩한 메서드에 들어 있고, 그 메서드를 고르는 일은 JVM이 실행 중에 객체를 보고 합니다.
Post를 직접 만들 수 있다면
두 예제에서 Post는 모든 게시글의 공통 부분을 모아 둔 부모 클래스입니다. 그런데 Post 자체로 만든 객체는 무엇일까요? 텍스트도 코드도 영상도 아닌 '그냥 게시글'은 게시판에 어떻게 공개해야 할지 정해져 있지 않습니다. PostBatch의 반복문에 new Post(10)이 섞여 들어오면 kind()와 publish()가 무엇을 해야 하는지 답할 수 없습니다.
instanceof와 오버라이딩의 OverrideDispatch처럼 Post를 일반 클래스로 두고 publish()에 기본 구현을 넣으면 이 질문을 피할 수 있을 것 같습니다. 그러나 할 일이 없는 부모 메서드에 빈 본문을 넣으면 다른 문제가 생깁니다. 다음 예제의 VideoPost는 publish()를 오버라이딩하는 것을 깜빡했습니다.
public final class EmptyDefaultPublish {
public static void main(String[] args) {
Post[] posts = { new TextPost(), new VideoPost() };
for (Post post : posts) {
post.publish();
}
System.out.println("done");
}
private static class Post {
void publish() {
// 아무 작업도 하지 않는다
}
}
private static final class TextPost extends Post {
@Override
void publish() {
System.out.println("publish=text");
}
}
private static final class VideoPost extends Post {
// publish()를 오버라이딩하는 것을 깜빡했다
}
}publish=text
done컴파일도 실행도 아무 문제 없이 끝났지만, 영상 게시글은 공개되지 않았습니다. VideoPost 객체의 publish()를 호출하자 JVM이 VideoPost에서 구현을 찾지 못하고 부모 Post의 빈 구현을 실행했기 때문입니다. 오류 메시지가 하나도 없으니 이런 실수는 누군가 '영상 게시글이 안 보인다'고 알려 줄 때까지 드러나지 않습니다.
두 문제의 원인은 같습니다. Post는 '공개하는 방법은 자식마다 다르다'는 사실을 코드로 표현하지 못하고, 그 자리를 빈 구현이나 의미 없는 객체로 채우고 있습니다. Java는 이것을 그대로 표현하는 문법을 제공합니다.
abstract로 미완성 클래스 선언하기
메서드 선언 앞에 abstract를 붙이고 본문 대신 세미콜론을 쓰면 추상 메서드가 됩니다. '이런 메서드가 있지만 구현은 자식이 제공한다'는 선언입니다. 추상 메서드를 하나라도 가진 클래스는 반드시 abstract를 붙여 추상 클래스로 선언해야 합니다. 앞의 두 예제가 Post에 쓴 문법이 바로 이것입니다.
private abstract static class Post {
// ...
abstract void publish();
}추상 클래스는 new로 직접 객체를 만들 수 없습니다. 다음 코드는 추상 클래스 Post의 객체를 만들려고 합니다.
public final class InstantiateAbstractPost {
public static void main(String[] args) {
Post post = new Post("general", 10);
System.out.println(post);
}
private abstract static class Post {
private final String title;
private final int viewCount;
Post(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
abstract void publish();
}
}InstantiateAbstractPost.java:3: error: Post is abstract; cannot be instantiated
Post post = new Post("general", 10);
^
1 errorPost is abstract; cannot be instantiated는 'Post는 추상 클래스라서 객체로 만들 수 없다'는 뜻입니다. 만들 수 있다면 post.publish()를 호출했을 때 실행할 본문이 없습니다. 그래서 컴파일러가 아예 객체를 만들지 못하게 막습니다.
생성자가 있는데도 객체를 만들 수 없다는 점이 이상해 보일 수 있습니다. 추상 클래스의 생성자는 자식 생성자가 super(...)로 호출하기 위해 있습니다. 상속과 메서드 오버라이딩에서 본 것처럼 new TextPost(...)가 만드는 객체 하나 안에는 Post가 선언한 title과 viewCount도 들어 있고, 그 필드를 초기화하는 일은 Post의 생성자가 맡습니다. 추상 클래스로 막는 것은 Post만으로 이루어진 객체를 만드는 일이지, 자식 객체 안의 Post 부분을 초기화하는 일이 아닙니다.
추상 클래스를 변수나 매개변수, 배열의 타입으로 쓰는 것도 자유롭습니다. 앞의 process(Post post)와 Post[] posts가 그랬습니다. new Post[3]처럼 추상 클래스 타입의 배열을 만드는 것도 됩니다. 이것은 Post 객체가 아니라 Post 참조를 담을 칸 세 개를 만드는 것이고, 각 칸은 null로 시작합니다.
이제 구현을 깜빡한 경우를 다시 봅시다. EmptyDefaultPublish의 Post를 추상 클래스로 바꾸고 publish()를 추상 메서드로 선언하면 다음과 같습니다.
private abstract static class Post {
abstract void publish();
}이렇게 바꾸고 다시 컴파일하면, 실행하기 전에 컴파일러가 빠진 구현을 찾아냅니다. 자식 클래스인 TextPost와 VideoPost는 그대로 두고 부모 선언만 바꿨습니다. 오류 메시지가 publish()를 오버라이딩하지 않은 VideoPost를 어떻게 짚는지 보세요.
EmptyDefaultPublish.java:21: error: VideoPost is not abstract and does not override abstract method publish() in Post
private static final class VideoPost extends Post {
^
1 error메시지는 'VideoPost는 추상 클래스가 아닌데 Post의 추상 메서드 publish()를 오버라이딩하지 않았다'는 뜻입니다. 추상 클래스가 아닌 클래스, 즉 객체를 만들 수 있는 구체 클래스는 물려받은 추상 메서드를 모두 구현해야 합니다. 그렇지 않으면 구현이 없는 메서드를 가진 객체가 만들어질 수 있기 때문입니다. 빈 기본 구현에서는 실행 결과를 보고서야 알 수 있던 실수가 이제 컴파일 오류가 되었습니다.
추상 클래스에 담을 수 있는 것
추상 클래스는 '구현이 덜 된 클래스'일 뿐, 일반 클래스가 가질 수 있는 것은 모두 가질 수 있습니다. 앞의 Post에도 private final 필드, 생성자, 완성된 메서드인 title()과 viewCount()가 함께 들어 있었습니다. 자식들이 똑같이 쓰는 상태와 구현은 추상 클래스에 한 번만 두고, 자식마다 달라지는 동작만 추상 메서드로 남겨 두는 것이 추상 클래스의 쓰임새입니다.
자식이 추상 메서드를 일부만 구현할 수도 있습니다. 그 자식은 아직 구현이 덜 되었으므로 그 자신도 abstract로 선언해야 하고, 나머지 구현은 다시 그 아래의 자식에게 맡깁니다.
private abstract static class VideoPost extends Post {
@Override
String kind() { return "video"; } // publish()는 아직 구현하지 않았다
}
private static final class LiveVideoPost extends VideoPost {
@Override
void publish() { System.out.println("publish=live"); }
}VideoPost는 kind()만 구현했으므로 추상 클래스로 남고, 객체로 만들 수 없습니다. LiveVideoPost가 남은 publish()까지 구현해 비로소 객체를 만들 수 있는 구체 클래스가 됩니다. new LiveVideoPost()로 만든 객체의 kind()를 호출하면 video가, publish()를 호출하면 publish=live가 출력됩니다.
반대로 추상 메서드가 하나도 없는 클래스를 abstract로 선언할 수도 있습니다. 모든 메서드가 완성되어 있더라도, 그 클래스로 만든 객체가 의미가 없고 반드시 하위 클래스를 거쳐서 써야 한다면 abstract를 붙여 직접 생성만 막을 수 있습니다.
더 깊이: abstract와 함께 쓸 수 없는 것
추상 메서드는 자식이 오버라이딩해서 구현해야만 의미가 있습니다. 그래서 오버라이딩을 막거나 오버라이딩과 상관없는 수식어와는 함께 쓸 수 없습니다. private은 자식이 물려받지 못하고, final은 오버라이딩을 금지하며, static 메서드는 오버라이딩되지 않습니다. 셋 다 abstract와 함께 쓰면 illegal combination of modifiers 컴파일 오류가 납니다. 같은 이유로 abstract final class도 같은 오류가 납니다. 상속을 막는 final 클래스가 추상 클래스라면 구현해 줄 자식이 생길 수 없기 때문입니다. 추상 메서드에 본문을 쓰면 abstract methods cannot have a body 오류가 납니다.
추상 타입 배열로 게시판 만들기
지금까지 배운 것을 모아 작은 게시판을 만들어 보겠습니다. 다음 Board는 추상 타입 Post의 배열에 게시글을 모으고, 한꺼번에 공개하거나 조회 수 합계를 요약합니다. 배열의 크기는 생성자에서 받은 용량으로 고정되고, 지금까지 넣은 게시글 수는 size가 셉니다.
public final class PolymorphicBoard {
public static void main(String[] args) {
Board board = new Board(3);
board.add(new TextPost("가입 인사", 40));
board.add(new CodePost("로그인 구현", 50));
board.publishAll();
System.out.println(board.summary());
}
private static final class Board {
private final Post[] entries;
private int size;
Board(int capacity) { entries = new Post[capacity]; }
void add(Post post) {
if (size == entries.length) throw new IllegalStateException("full");
entries[size++] = post;
}
void publishAll() {
for (int index = 0; index < size; index++) {
entries[index].publish();
}
}
String summary() {
int total = 0;
for (int index = 0; index < size; index++) {
total += entries[index].viewCount();
}
return "count=" + size + ", total=" + total;
}
}
private abstract static class Post {
private final String title;
private final int viewCount;
Post(String title, int viewCount) { this.title = title; this.viewCount = viewCount; }
int viewCount() { return viewCount; }
String title() { return title; }
abstract void publish();
}
private static final class TextPost extends Post {
TextPost(String title, int viewCount) { super(title, viewCount); }
@Override void publish() { System.out.println("publish=text:" + title()); }
}
private static final class CodePost extends Post {
CodePost(String title, int viewCount) { super(title, viewCount); }
@Override void publish() { System.out.println("publish=code:" + title()); }
}
}publish=text:가입 인사
publish=code:로그인 구현
count=2, total=90add는 배열이 가득 찼는지 확인한 뒤, 게시글의 참조를 entries[size]에 넣고 size를 1 늘립니다. entries[size++] = post는 증감·비교·논리·대입 연산자에서 본 후위 증가라서, 증가하기 전의 size가 인덱스로 쓰입니다. 두 번의 add가 끝났을 때 배열의 상태는 다음과 같습니다.
| 칸 | 내용 | size |
|---|---|---|
entries[0] | TextPost("가입 인사", 40)의 참조 | 첫 add 뒤 1 |
entries[1] | CodePost("로그인 구현", 50)의 참조 | 둘째 add 뒤 2 |
entries[2] | null (아직 쓰지 않은 칸) |
publishAll은 0번과 1번 칸에서 각 객체의 publish()를 호출해 첫 두 줄을 출력하고, summary는 같은 두 칸의 조회 수 40과 50을 더해 count=2, total=90을 만듭니다. 그림으로 보면 Board가 길이 3인 Post 배열을 가리키고, 앞의 두 칸이 서로 다른 자식 객체를 가리키며, 마지막 칸은 비어 있습니다.
PolymorphicBoard는 용량3 Post 배열의0번과1번에 TextPost와 CodePost 참조를 보관하고 size는2다.2번은null이며 publishAll은 사용 중인 두 슬롯의 재정의 메서드를 호출한다.
부모 타입 배열은 서로 다른 자식 객체의 참조를 보관합니다
PolymorphicBoard에서 두 add를 마치고 publishAll()을 호출하기 전입니다. Board(3)의 배열에는 두 자식 객체의 참조와 미사용 슬롯 하나가 있습니다.
- Board와 배열
size = 2인Board의final Post[] entries는 길이3인 배열을 가리킵니다. 배열 칸의 정적 타입은Post입니다.entries[0]· TextPost 참조- 제목
가입 인사, 조회 수40인 객체입니다.publish()호출 시publish=text:가입 인사를 출력합니다. entries[1]· CodePost 참조- 제목
로그인 구현, 조회 수50인 별개 객체입니다.publish()호출 시publish=code:로그인 구현를 출력합니다. entries[2]· 미사용null이며size = 2인 이 순회에서는 접근하지 않습니다.
publishAll()은 인덱스 0, 1에서 실제 자식의 publish()를 호출합니다. 이어지는 summary() 출력은 count=2, total=90입니다. 추상 Post 객체를 직접 만든 것이 아니라 자식 객체를 부모 타입 배열로 참조합니다.
두 반복문이 entries.length가 아니라 size까지만 도는 것은 중요합니다. entries.length는 3이므로 그렇게 쓰면 세 번째 칸까지 가는데, 그 칸은 null이라서 entries[2].publish()에서 NullPointerException이 납니다. 배열의 길이는 담을 수 있는 칸의 수이고, 실제로 들어 있는 게시글 수는 size가 따로 세고 있습니다. 같은 이유로 이 add는 null이 넘어와도 거절하지 않습니다. 그러면 null이 사용 중인 칸에 들어가 publishAll에서 같은 예외가 납니다. 외부 입력을 받는 실제 게시판이라면 add에서 null도 검사해야 합니다.
Board의 코드에는 TextPost나 CodePost라는 이름이 한 번도 나오지 않습니다. Board가 아는 것은 Post에 publish()와 viewCount()가 있다는 사실뿐입니다. 그래서 새 종류의 게시글을 추가해도 저장, 일괄 공개, 합계 코드는 그대로 동작합니다. 연습 문제 1에서 VideoPost를 추가하며 직접 확인해 봅니다.
예측해 보기: 남은 칸과 가득 찬 게시판
위 main에서 두 번째 add 뒤에 조회 수 30인 TextPost("추가 인사", 30)를 세 번째로 추가하고 summary()를 출력한다고 해 봅시다. 그다음 네 번째 게시글을 add하면 full 예외가 나는데, 호출한 쪽에서 이 예외를 잡고 다시 summary()를 출력합니다. 두 summary()의 결과는 무엇일까요? 실패한 네 번째 add 뒤에 size는 4가 될까요?
세 번째 add 뒤의 결과는 count=3, total=120입니다. 네 번째 add에서는 size와 entries.length가 모두 3이므로 첫 줄의 검사에서 예외가 나고, 그 아래의 대입과 증가 entries[size++] = post는 실행되지 않습니다. 그래서 size는 3에 머물고, 두 번째 summary()도 count=3, total=120입니다. 어떤 종류의 게시글을 넣을 수 있는지는 Post 타입이 정하지만, 몇 개까지 넣을 수 있는지는 Board의 용량이 정합니다. 둘은 서로 다른 조건입니다.
정리
- 처리 순서는 같고 일부 단계만 종류마다 다르다면, 그 단계를 부모의 메서드로 선언하고 자식이 오버라이딩하게 합니다. 부모 타입 매개변수와 배열을 쓰면 종류마다 복사하던 메서드와 반복문이 하나로 줄고, 새 종류를 추가해도 그 코드는 고치지 않습니다.
- 할 일이 없는 부모 메서드에 빈 본문을 두면, 자식이 오버라이딩을 빠뜨려도 컴파일되고 실행 중에 조용히 아무 일도 하지 않습니다.
abstract void publish();처럼 본문 없이 선언한 추상 메서드는 구현을 자식에게 맡깁니다. 추상 메서드가 있는 클래스는abstract로 선언해야 합니다.- 추상 클래스는
new로 직접 만들 수 없지만, 변수와 매개변수와 배열의 타입으로는 쓸 수 있습니다. 생성자는 자식 객체 안의 부모 부분을 초기화하는 데 쓰입니다. - 구체 클래스는 물려받은 추상 메서드를 모두 구현해야 합니다. 일부만 구현한 자식은 그 자신도
abstract로 선언해 나머지를 다음 자식에게 넘깁니다. - 추상 클래스는 필드, 생성자, 완성된 메서드도 가질 수 있습니다. 공통 상태와 구현은 부모에 한 번만 두고, 달라지는 동작만 추상 메서드로 남깁니다.
다음 글에서는 추상 클래스와 함께 다형성을 만드는 또 하나의 도구인 인터페이스를 배우고, 두 가지를 언제 골라 쓰는지 비교합니다.
연습 문제
문제 1: 게시판에 영상 게시글 추가하기
PolymorphicBoard에 조회 수와 제목을 받아 publish=video:제목을 출력하는 VideoPost를 추가하세요. main에서 VideoPost("발표 영상", 30)를 세 번째로 추가한 뒤 출력을 확인하고, Board 클래스는 고치지 않았다는 점도 확인합니다.
해설 보기
PolymorphicBoard 안에 다음 클래스를 추가합니다.
private static final class VideoPost extends Post {
VideoPost(String title, int viewCount) { super(title, viewCount); }
@Override void publish() { System.out.println("publish=video:" + title()); }
}그리고 main의 두 번째 add 다음 줄에 board.add(new VideoPost("발표 영상", 30));을 넣습니다. 실행 결과는 다음과 같습니다.
publish=text:가입 인사
publish=code:로그인 구현
publish=video:발표 영상
count=3, total=120Board의 add, publishAll, summary는 Post 타입만 다루므로 고칠 필요가 없었습니다. VideoPost가 Post를 상속하므로 add에 넘길 수 있고, publishAll의 entries[index].publish()는 세 번째 칸에서 동적 디스패치로 VideoPost의 구현을 실행합니다. 만약 VideoPost에 publish()를 쓰는 것을 잊었다면 Post의 publish()가 추상 메서드이므로 VideoPost is not abstract and does not override abstract method publish() in Post 컴파일 오류가 납니다.
문제 2: 추상 클래스로 보고서 항목 만들기
추상 클래스 ReportItem에 final 필드 title과 추상 메서드 render()를 두세요. TextItem은 문자열 값을, NumberItem은 정수 값을 받아 title=값 형식의 문자열을 돌려주도록 render()를 구현합니다. ReportItem 배열에 두 항목을 담고 반복문으로 출력하세요.
해설 보기
public final class AbstractReportItems {
public static void main(String[] args) {
ReportItem[] items = {
new TextItem("title", "가입 인사"),
new NumberItem("viewCount", 40)
};
for (ReportItem item : items) {
System.out.println(item.render());
}
}
private abstract static class ReportItem {
protected final String title;
ReportItem(String title) { this.title = title; }
abstract String render();
}
private static final class TextItem extends ReportItem {
private final String value;
TextItem(String title, String value) { super(title); this.value = value; }
@Override String render() { return title + "=" + value; }
}
private static final class NumberItem extends ReportItem {
private final int value;
NumberItem(String title, int value) { super(title); this.value = value; }
@Override String render() { return title + "=" + value; }
}
}title=가입 인사
viewCount=40두 항목에 공통인 title은 부모 ReportItem의 생성자가 초기화하고, 값의 타입이 다른 부분은 각 자식이 자기 필드로 가집니다. 두 render()는 모양이 같아 보이지만, TextItem은 String 값을 이어 붙이고 NumberItem은 int 값을 문자열로 바꿔 이어 붙입니다. 값을 담는 필드의 타입이 다르므로 이 메서드를 부모에 한 번만 쓸 수는 없고, 그래서 추상 메서드로 남겨 각 자식이 구현합니다. 자식이 title을 직접 읽을 수 있도록 protected로 열었는데, private으로 두고 title() 메서드를 제공하는 방법도 있습니다.
문제 3: 어느 줄이 컴파일 오류일까
추상 클래스 Post가 abstract void publish();를 선언하고 있고, TextPost는 publish()를 구현한 구체 클래스입니다. 다음 네 가지 가운데 컴파일 오류가 나는 것을 모두 고르고 이유를 설명하세요.
Post first = new TextPost("가입 인사", 40);
Post second = new Post("공지", 10);
Post[] posts = new Post[3];
private static final class VideoPost extends Post { /* publish() 구현 없음 */ }해설 보기
둘째 줄과 넷째 줄이 컴파일 오류입니다.
첫째 줄은 구체 클래스 TextPost의 객체를 만들어 추상 타입 Post 변수에 담는 업캐스팅이므로 문제가 없습니다. 둘째 줄은 추상 클래스의 객체를 직접 만들려고 하므로 Post is abstract; cannot be instantiated 오류가 납니다. 셋째 줄은 Post 객체가 아니라 Post 참조를 담을 칸 세 개짜리 배열을 만드는 것이라 허용되고, 세 칸은 모두 null로 시작합니다. 넷째 줄의 VideoPost는 final이 붙은 구체 클래스인데 추상 메서드 publish()를 구현하지 않았으므로 VideoPost is not abstract and does not override abstract method publish() in Post 오류가 납니다. publish()를 구현하거나, 나머지를 하위 클래스에 맡기려면 final을 빼고 abstract로 선언해야 합니다.