상속과 메서드 오버라이딩
extends로 부모의 필드와 메서드를 물려받고, super로 부모 생성자를 먼저 호출하며, 오버라이딩으로 부모의 동작을 바꾸는 방법을 게시글 예제로 배웁니다.
게시판에 텍스트 게시글과 코드 게시글을 함께 올린다고 해 봅시다. 두 게시글 모두 제목과 조회 수를 가지고, 게시글을 공개(publish)하는 동작도 같습니다. 다른 점은 텍스트 게시글에는 본문 길이가, 코드 게시글에는 브랜치 이름이 더 있다는 것뿐입니다. 이것을 TextPost와 CodePost라는 두 클래스로 따로 만들면 제목·조회 수 필드와 publish 메서드를 두 번 써야 하고, 공개 형식을 바꿀 때도 두 곳을 똑같이 고쳐야 합니다. 게시글 종류가 영상, 이미지로 늘어날수록 같은 코드가 그만큼 복사됩니다.
상속(inheritance)은 이런 공통 부분을 부모 클래스 하나에 두고, 자식 클래스가 그것을 물려받은 뒤 자기만의 필드와 메서드를 덧붙이게 하는 문법입니다. 다만 상속을 코드를 덜 쓰는 기술로만 보면 곤란합니다. TextPost가 Post를 상속하면 Java는 텍스트 게시글을 게시글의 한 종류로 취급하고, 게시글이 필요한 자리에 텍스트 게시글을 넣을 수 있게 해 줍니다. 그래서 상속을 쓸지 정할 때는 공통 코드가 있는지보다 '자식은 부모의 한 종류인가'를 먼저 묻습니다. 이 글에서는 게시글 계층을 만들면서 상속 문법, 생성자가 실행되는 순서, 부모 메서드를 자식이 바꾸는 오버라이딩을 차례로 살펴봅니다.
extends로 부모 클래스 물려받기
클래스 이름 뒤에 extends 부모클래스를 쓰면 그 클래스를 상속합니다. 물려주는 쪽을 부모 클래스(superclass) 또는 상위 클래스라 하고, 물려받는 쪽을 자식 클래스(subclass) 또는 하위 클래스라고 합니다. 가장 작은 예부터 보겠습니다.
public final class FirstInheritance {
public static void main(String[] args) {
TextPost textPost = new TextPost();
textPost.publish();
}
private static class Post {
void publish() {
System.out.println("게시글 공개");
}
}
private static final class TextPost extends Post {
}
}게시글 공개TextPost의 본문은 비어 있는데도 textPost.publish()가 컴파일되고 실행됩니다. TextPost extends Post라고 선언했으므로 Post의 publish 메서드가 TextPost의 멤버가 되었기 때문입니다. 자식 클래스에 같은 메서드를 다시 쓸 필요가 없습니다.
이 교재의 예제는 파일 하나로 컴파일하고 실행할 수 있도록 Post와 그 자식 클래스들을 실행 클래스 안에 중첩 클래스로 선언합니다. 실제 프로젝트라면 Post.java, TextPost.java처럼 클래스마다 파일을 나누는 것이 보통이고, 중첩 클래스 자체는 중첩 클래스의 종류와 정적 중첩 클래스에서 다룹니다. 예제마다 Post라는 같은 이름이 나오지만 모두 서로 다른 클래스이므로, 한 파일에 모아 넣지 말고 예제마다 따로 저장해 실행하세요.
자식 클래스 선언에 붙은 final도 눈여겨보세요. 클래스에 final을 붙이면 그 클래스는 더 이상 상속할 수 없습니다. 예를 들어 TextPost를 상속하는 클래스를 선언하면 cannot inherit from final TextPost라는 컴파일 오류가 납니다. 그래서 부모 역할을 하는 Post에는 final을 붙이지 않고, 계층의 끝에 있는 자식에만 붙여 '여기서 더 확장하지 않는다'는 의도를 컴파일러가 확인하게 했습니다.
extends를 쓰지 않은 일반 클래스는 자동으로 java.lang.Object를 상속합니다. 그러니 Post의 부모는 Object이고, Java의 모든 클래스는 결국 Object까지 이어지는 하나의 계층에 속합니다. Object가 모든 클래스에 물려주는 메서드는 9장에서 다룹니다.
자식 생성자는 부모 생성자를 먼저 호출한다
부모에 상태가 생기면 이야기가 달라집니다. Post가 생성될 때 제목을 꼭 받아야 한다면 Post(String title) 생성자를 두게 됩니다. 이 상태에서 자식 CodePost를 생성자 없이 선언하면 컴파일이 되지 않습니다.
public final class MissingParentConstructor {
public static void main(String[] args) {
CodePost post = new CodePost();
System.out.println(post);
}
private static class Post {
Post(String title) {
System.out.println(title);
}
}
private static final class CodePost extends Post {
}
}MissingParentConstructor.java:13: error: constructor Post in class Post cannot be applied to given types;
private static final class CodePost extends Post {
^
required: String
found: no arguments
reason: actual and formal argument lists differ in length
1 error오류가 가리키는 곳은 main이 아니라 CodePost 선언입니다. 생성자 오버로딩과 this() 호출에서 본 것처럼, 생성자를 하나도 쓰지 않은 클래스에는 컴파일러가 매개변수 없는 기본 생성자를 넣어 줍니다. 상속 관계에서는 여기에 규칙이 하나 더해집니다. 생성자 본문에 super(...)나 this(...) 호출이 없으면, 컴파일러는 본문 맨 앞에 부모의 매개변수 없는 생성자를 부르는 super()가 있다고 봅니다. 그래서 CodePost는 사실상 다음 코드와 같습니다.
CodePost() {
super(); // 컴파일러가 넣은 호출: Post()를 찾는다
}그런데 Post에는 Post(String)만 있고 Post()는 없습니다. 메시지의 required: String과 found: no arguments가 바로 '문자열 하나가 필요한데 인자 없이 불렀다'는 뜻입니다. 생성자는 물려받는 멤버가 아니어서 Post(String)이 있다고 CodePost(String)이 저절로 생기지도 않습니다. 해결하려면 자식 생성자를 직접 쓰고, 부모가 요구하는 값을 super(...)로 넘겨야 합니다.
CodePost(String title) {
super(title);
}super(...)는 부모 클래스의 생성자를 호출하는 문장입니다. 이렇게 고치고 main의 new CodePost()도 new CodePost("로그인 구현")처럼 제목을 넘기게 바꾸면 컴파일되고, 실행하면 Post 생성자가 받은 제목이 먼저 출력됩니다.
자식 객체를 만들 때 부모 생성자가 꼭 실행되어야 하는 이유는 자식 객체 안에 부모가 선언한 필드도 함께 들어 있기 때문입니다. 뒤에서 볼 CodePost 객체의 title은 Post가 선언하고, Post 생성자가 검사해서 채우는 필드입니다. 자식이 부모 생성자를 건너뛸 수 있다면 부모가 정한 초기화 규칙도 건너뛰게 됩니다. 그래서 Java는 모든 생성자가 부모 생성자를 거치게 합니다. Post 생성자에도 보이지 않는 super()가 있어 Object 생성자를 부릅니다. 그래서 생성자 호출은 CodePost에서 시작하지만, super(...) 다음의 본문이 실행되는 순서는 Object, Post, CodePost로 계층의 위에서 아래 방향입니다.
부모의 필드와 메서드를 물려받은 두 게시글
이제 제목과 조회 수를 가진 Post 아래에 자식 두 개를 만들어 보겠습니다. TextPost는 본문 길이와 미리보기 기능을, CodePost는 브랜치 이름과 커밋 기능을 덧붙입니다. Post 생성자는 제목이 비어 있거나 조회 수가 0 이하이면 예외를 던져 잘못된 게시글이 만들어지지 않게 합니다.
public final class PostInheritance {
public static void main(String[] args) {
TextPost textPost = new TextPost("가입 인사", 40, 120);
CodePost codePost = new CodePost("로그인 구현", 50, "feature/login");
textPost.publish();
textPost.previewBody();
codePost.publish();
codePost.commit();
}
private static class Post {
private final String title;
private final int viewCount;
Post(String title, int viewCount) {
if (title.isBlank() || viewCount <= 0) throw new IllegalArgumentException();
this.title = title;
this.viewCount = viewCount;
}
void publish() {
System.out.println("publish=" + title + ":views=" + viewCount);
}
protected String title() {
return title;
}
}
private static final class TextPost extends Post {
private final int bodyLength;
TextPost(String title, int viewCount, int bodyLength) {
super(title, viewCount);
this.bodyLength = bodyLength;
}
void previewBody() {
System.out.println("preview=" + title() + ":chars=" + bodyLength);
}
}
private static final class CodePost extends Post {
private final String branch;
CodePost(String title, int viewCount, String branch) {
super(title, viewCount);
this.branch = branch;
}
void commit() {
System.out.println("commit=" + title() + ":" + branch);
}
}
}publish=가입 인사:views=40
preview=가입 인사:chars=120
publish=로그인 구현:views=50
commit=로그인 구현:feature/login첫째 줄과 셋째 줄은 두 자식이 Post에게서 물려받은 publish가 출력했습니다. 같은 메서드인데 결과가 다른 것은 publish가 읽는 title과 viewCount가 객체마다 다르기 때문입니다. 둘째 줄과 넷째 줄은 각 자식이 새로 추가한 previewBody와 commit이 출력했습니다. 두 메서드는 자기 클래스의 필드(bodyLength, branch)와 함께, 부모의 제목을 title() 메서드로 읽어 씁니다. 반대로 TextPost 객체에 commit을 호출하거나 CodePost 객체에 previewBody를 호출할 수는 없습니다. 형제 클래스끼리는 서로 아무것도 물려받지 않기 때문입니다.
new CodePost("로그인 구현", 50, "feature/login")가 실행되는 순서를 따라가 보겠습니다. CodePost 생성자는 첫 문장 super(title, viewCount)로 제목과 조회 수를 Post 생성자에 넘깁니다. Post 생성자는 두 값을 검사하고 title과 viewCount에 대입한 뒤 정상적으로 끝납니다. 그러면 실행이 CodePost 생성자로 돌아와 this.branch = branch를 실행합니다. 아래 그림에서 단계별로 확인할 수 있습니다.
원문의 super 호출이 초기화하는 부모 상태와 자식 상태
CodePost가 super로 부모 검사/대입을 끝낸 뒤 복귀하여 branch를 대입합니다. 부모 예외 시 자식 대입에 미도달하는 실제 초기화 순서를 보여 줄 수 있습니다.
PostInheritance의 CodePost 생성자는 super(title, viewCount)를 먼저 호출하고, Post가 두 필드를 대입하여 반환한 뒤 branch를 대입한다. 이 순서는 원문의 생성자 본문에 한정한다.
이 생성자는 부모 필드 대입 뒤 자식 필드를 대입합니다
PostInheritance의 CodePost("로그인 구현", 50, "feature/login") 생성에서 소스에 적힌 대입 순서를 읽습니다.
| 원문 순서 | 실행하는 코드 | 이 생성에서 정하는 값 |
|---|---|---|
| 부모 초기화 위임 | super(title, viewCount) | Post 생성자에 "로그인 구현"과 50을 전달합니다. |
| Post 생성자 |
| title = "로그인 구현", viewCount = 50 |
| CodePost 생성자로 복귀 | this.branch = branch | branch = "feature/login"을 대입합니다. |
- 부모 초기화 위임
super(title, viewCount)가Post생성자에"로그인 구현",50을 전달합니다. - Post 생성자
title.isBlank() || viewCount <= 0검사 뒤this.title = title,this.viewCount = viewCount로"로그인 구현",50을 대입합니다. - CodePost 생성자로 복귀
this.branch = branch로"feature/login"을 대입합니다.
이 입력은 부모 검사를 통과합니다. 표는 본문에 명시한 Post와 CodePost의 대입 순서이며, 생성자 전체의 JVM 호출 기록이나 모든 Java 생성자에 대한 순서 보장이 아닙니다. 이 원문은 super 앞에 실행문을 두지 않습니다.
이 예의 조건
- 원 CodePost 생성자는 super 앞 실행문이 없으며 모든 Java 생성자의 불변 순서로 확대하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 PostInheritance의 유효 입력은 CodePost 생성자→super로 Post 검사/필드 대입→정상 반환→branch 대입으로 진행한다. 모든 Java 생성자의 불변 순서가 아니며 예외 시 마지막 대입은 없다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 PostInheritance의 유효 입력은 CodePost 생성자→super로 Post 검사/필드 대입→정상 반환→branch 대입으로 진행한다. 모든 Java 생성자의 불변 순서가 아니며 예외 시 마지막 대입은 없다. |
| 전체 읽기 정보 | 원문의 super 호출이 초기화하는 부모 상태와 자식 상태 원문의 super 호출이 초기화하는 부모 상태와 자식 상태 PostInheritance의 CodePost 생성자는 super(title, viewCount)를 먼저 호출하고, Post가 두 필드를 대입하여 반환한 뒤 branch를 대입한다. 이 순서는 원문의 생성자 본문에 한정한다. 이 생성자는 부모 필드 대입 뒤 자식 필드를 대입합니다 PostInheritance 의 CodePost("로그인 구현", 50, "feature/login") 생성에서 소스에 적힌 대입 순서를 읽습니다. CodePost 생성자의 명시적 super 호출과 그 뒤의 대입 원문 순서 실행하는 코드 이 생성에서 정하는 값 부모 초기화 위임 super(title, viewCount) Post 생성자에 "로그인 구현" 과 50 을 전달합니다. Post 생성자 title.isBlank() || viewCount <= 0 검사 this.title = title this.viewCount = viewCount title = "로그인 구현" , viewCount = 50 CodePost 생성자로 복귀 this.branch = branch branch = "feature/login" 을 대입합니다. 부모 초기화 위임 super(title, viewCount) 가 Post 생성자에 "로그인 구현" , 50 을 전달합니다. Post 생성자 title.isBlank() || viewCount <= 0 검사 뒤 this.title = title , this.viewCount = viewCount 로 "로그인 구현" , 50 을 대입합니다. CodePost 생성자로 복귀 this.branch = branch 로 "feature/login" 을 대입합니다. 이 입력은 부모 검사를 통과합니다. 표는 본문에 명시한 Post 와 CodePost 의 대입 순서이며, 생성자 전체의 JVM 호출 기록이나 모든 Java 생성자에 대한 순서 보장이 아닙니다. 이 원문은 super 앞에 실행문을 두지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 자식 생성 진입 CodePost가 title과 viewCount를 super에 넘깁니다. |
|
|
| 부모 검사·기록 Post 검사를 통과해 부모 필드를 대입합니다. |
|
|
| 자식으로 복귀 정상 super 반환 뒤 branch를 대입합니다. |
|
|
원문의 super 호출이 초기화하는 부모 상태와 자식 상태
CodePost가 super로 부모 검사/대입을 끝낸 뒤 복귀하여 branch를 대입합니다. 부모 예외 시 자식 대입에 미도달하는 실제 초기화 순서를 보여 줄 수 있습니다.
PostInheritance의 CodePost 생성자는 super(title, viewCount)를 먼저 호출하고, Post가 두 필드를 대입하여 반환한 뒤 branch를 대입한다. 이 순서는 원문의 생성자 본문에 한정한다.
이 생성자는 부모 필드 대입 뒤 자식 필드를 대입합니다
PostInheritance의 CodePost("로그인 구현", 50, "feature/login") 생성에서 소스에 적힌 대입 순서를 읽습니다.
| 원문 순서 | 실행하는 코드 | 이 생성에서 정하는 값 |
|---|---|---|
| 부모 초기화 위임 | super(title, viewCount) | Post 생성자에 "로그인 구현"과 50을 전달합니다. |
| Post 생성자 |
| title = "로그인 구현", viewCount = 50 |
| CodePost 생성자로 복귀 | this.branch = branch | branch = "feature/login"을 대입합니다. |
- 부모 초기화 위임
super(title, viewCount)가Post생성자에"로그인 구현",50을 전달합니다. - Post 생성자
title.isBlank() || viewCount <= 0검사 뒤this.title = title,this.viewCount = viewCount로"로그인 구현",50을 대입합니다. - CodePost 생성자로 복귀
this.branch = branch로"feature/login"을 대입합니다.
이 입력은 부모 검사를 통과합니다. 표는 본문에 명시한 Post와 CodePost의 대입 순서이며, 생성자 전체의 JVM 호출 기록이나 모든 Java 생성자에 대한 순서 보장이 아닙니다. 이 원문은 super 앞에 실행문을 두지 않습니다.
이 예의 조건
- 원 CodePost 생성자는 super 앞 실행문이 없으며 모든 Java 생성자의 불변 순서로 확대하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 PostInheritance의 유효 입력은 CodePost 생성자→super로 Post 검사/필드 대입→정상 반환→branch 대입으로 진행한다. 모든 Java 생성자의 불변 순서가 아니며 예외 시 마지막 대입은 없다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 PostInheritance의 유효 입력은 CodePost 생성자→super로 Post 검사/필드 대입→정상 반환→branch 대입으로 진행한다. 모든 Java 생성자의 불변 순서가 아니며 예외 시 마지막 대입은 없다. |
| 전체 읽기 정보 | 원문의 super 호출이 초기화하는 부모 상태와 자식 상태 원문의 super 호출이 초기화하는 부모 상태와 자식 상태 PostInheritance의 CodePost 생성자는 super(title, viewCount)를 먼저 호출하고, Post가 두 필드를 대입하여 반환한 뒤 branch를 대입한다. 이 순서는 원문의 생성자 본문에 한정한다. 이 생성자는 부모 필드 대입 뒤 자식 필드를 대입합니다 PostInheritance 의 CodePost("로그인 구현", 50, "feature/login") 생성에서 소스에 적힌 대입 순서를 읽습니다. CodePost 생성자의 명시적 super 호출과 그 뒤의 대입 원문 순서 실행하는 코드 이 생성에서 정하는 값 부모 초기화 위임 super(title, viewCount) Post 생성자에 "로그인 구현" 과 50 을 전달합니다. Post 생성자 title.isBlank() || viewCount <= 0 검사 this.title = title this.viewCount = viewCount title = "로그인 구현" , viewCount = 50 CodePost 생성자로 복귀 this.branch = branch branch = "feature/login" 을 대입합니다. 부모 초기화 위임 super(title, viewCount) 가 Post 생성자에 "로그인 구현" , 50 을 전달합니다. Post 생성자 title.isBlank() || viewCount <= 0 검사 뒤 this.title = title , this.viewCount = viewCount 로 "로그인 구현" , 50 을 대입합니다. CodePost 생성자로 복귀 this.branch = branch 로 "feature/login" 을 대입합니다. 이 입력은 부모 검사를 통과합니다. 표는 본문에 명시한 Post 와 CodePost 의 대입 순서이며, 생성자 전체의 JVM 호출 기록이나 모든 Java 생성자에 대한 순서 보장이 아닙니다. 이 원문은 super 앞에 실행문을 두지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 자식 생성 진입 CodePost가 title과 viewCount를 super에 넘깁니다. |
|
|
| 부모 검사·기록 Post 검사를 통과해 부모 필드를 대입합니다. |
|
|
| 자식으로 복귀 정상 super 반환 뒤 branch를 대입합니다. |
|
|
이 순서는 부모 생성자가 정상적으로 끝났을 때의 이야기입니다. 조회 수를 0으로 바꿔 new CodePost("로그인 구현", 0, "feature/login")을 실행하면 Post 생성자의 검사가 IllegalArgumentException을 던집니다. 예외가 나면 생성자는 그 자리에서 중단되므로 CodePost 생성자의 this.branch = branch에 도달하지 못하고, new 식도 객체를 돌려주지 못합니다. 부모 몫만 초기화된 게시글이 변수에 담기는 일은 없다는 뜻입니다. 거꾸로 branch가 빈 문자열이어도 Post 생성자는 그 사실을 알 수 없습니다. 자식이 추가한 필드의 규칙은 자식 생성자가 직접 검사해야 합니다.
Java 클래스는 extends 뒤에 부모를 하나만 쓸 수 있습니다. extends Post, Attachment처럼 두 개를 쓰면 문법 오류입니다. 두 부모가 같은 이름의 메서드를 서로 다르게 구현했다면 자식이 어느 쪽을 물려받아야 할지 정할 수 없습니다. 이런 모호함을 다이아몬드 문제(diamond problem)라고 하는데, 두 부모가 같은 조상을 둘 때 상속 관계를 그리면 마름모 모양이 된다는 데서 붙은 이름입니다. Java는 클래스의 다중 상속을 막아 이 문제를 피하고, 여러 역할을 조합해야 할 때는 인터페이스 다중 구현을 씁니다.
자식 객체 하나에 부모의 필드도 함께 들어 있다
new CodePost(...)를 실행하면 객체가 몇 개 만들어질까요? 생성자가 Object까지 세 번 실행되니 부모 객체와 자식 객체가 따로 생긴다고 생각하기 쉽지만, 만들어지는 객체는 CodePost 하나입니다. 그 객체 안에 Post가 선언한 title, viewCount와 CodePost가 선언한 branch가 함께 들어 있습니다. 부모 생성자는 그중 부모가 선언한 필드를, 자식 생성자는 자식이 선언한 필드를 초기화할 뿐입니다.
PostInheritance에서 생성한 codePost 한 객체에 Post 선언의 title와 viewCount, CodePost 선언의 branch가 함께 존재한다. 선언별 묶음은 별도 객체나 실제 메모리 배치를 뜻하지 않는다.
부모와 자식의 필드는 한 CodePost 객체에 존재합니다
PostInheritance의 new CodePost("로그인 구현", 50, "feature/login") 생성이 끝난 뒤입니다.
codePost가 가리키는 것은 CodePost 객체 하나입니다.
- Post에 선언한 필드
title = "로그인 구현",viewCount = 50- CodePost에 선언한 필드
branch = "feature/login"
상자는 필드를 선언한 클래스를 구분한 것입니다. 별도 부모 객체나 실제 메모리 주소·배치를 뜻하지 않습니다. private 필드의 상태가 객체에 존재하는 것과 그 필드가 자식의 상속 멤버인지 여부는 다릅니다.
그림에서 필드를 선언한 클래스별로 칸을 나눈 것은 어느 클래스가 선언한 필드인지 구분하려는 것이지, 부모 객체가 따로 있다거나 메모리에 그런 모양으로 놓인다는 뜻이 아닙니다. 메서드 코드도 객체마다 복사되지 않습니다. publish나 commit의 코드는 메서드 영역에 클래스 정보와 함께 한 번만 저장되고, 객체는 자기 필드 값만 가집니다.
메서드를 호출하면 Java는 객체의 클래스에서 그 메서드를 찾고, 없으면 부모 쪽으로 한 단계씩 올라가며 찾습니다. codePost.commit()은 CodePost에서 바로 찾습니다. codePost.publish()는 CodePost에 없으므로 부모인 Post에서 찾아 실행합니다. 찾는 방향은 언제나 자식에서 부모 쪽이고, Post 객체에 대해 자식 클래스의 메서드를 찾아 내려가는 일은 없습니다. 지금은 변수 타입과 객체의 클래스가 같은 경우만 보고 있는데, 둘이 다를 때 무엇이 달라지는지는 다음 글에서 다룹니다.
private 필드는 자식도 직접 쓸 수 없다
그림에도 적혀 있듯이, 필드가 객체 안에 있다는 것과 자식이 그 필드를 이름으로 쓸 수 있다는 것은 별개입니다. Post의 title은 private이므로 자식이 물려받는 멤버가 아닙니다. CodePost 객체 안에 값은 분명히 들어 있지만, PostInheritance의 previewBody에서 title() 대신 title이라고 직접 쓰면 다음 오류가 납니다.
error: title has private access in Post이 교재의 예제처럼 같은 바깥 클래스 안에 중첩된 클래스끼리는 서로의 private 멤버에 접근할 수 있게 하는 규칙이 따로 있습니다. 그래서 super.title처럼 Post를 거쳐서 쓰면 컴파일이 됩니다. 하지만 이것은 상속 덕분이 아니라 같은 바깥 클래스 안에 있기 때문이고, Post와 TextPost를 각자의 파일로 나누면 이 길도 막힙니다. 예제가 title() 메서드를 거치는 것은 클래스를 나누어도 그대로 동작하는 방법을 보여 주려는 것입니다.
자식이 부모의 상태를 알아야 할 때는 이처럼 필드를 열기보다 필요한 만큼만 메서드로 제공합니다. title()에 붙은 protected는 같은 패키지의 클래스와 다른 패키지의 자식 클래스에서 접근을 허용하는 접근 제어자로, 네 가지 접근 수준은 접근 제어를 다룬 글에서 표로 정리했습니다.
그렇다고 필드 자체를 protected로 열면 자식이 부모의 검사를 우회할 수 있습니다. Post 생성자는 조회 수가 양수인지 검사하지만, viewCount가 final이 아닌 protected 필드라면 어느 자식에서든 viewCount = -5;처럼 바꿔 버릴 수 있습니다. 부모가 지키려던 규칙을 이제는 모든 자식 코드가 각자 지켜야 하는 셈입니다. 그래서 상태는 private으로 두고, 자식에게 필요한 행동만 protected나 public 메서드로 여는 편이 안전합니다.
메서드 오버라이딩: 부모의 동작을 자식에 맞게 바꾸기
지금까지 두 자식은 Post의 publish를 그대로 썼습니다. 그런데 텍스트 게시글은 공개할 때 본문 길이를, 코드 게시글은 브랜치를 함께 보여 주고 싶다면 어떻게 할까요? 이때 자식 클래스에 부모와 같은 모양의 메서드를 다시 선언해 동작을 바꿀 수 있습니다. 이를 메서드 오버라이딩(overriding) 또는 재정의라고 합니다. 다음 예제는 코드를 짧게 하려고 Post의 필드를 protected final로 열었습니다. final이라 자식이 값을 바꿀 수는 없지만, 실무에서는 앞 절처럼 private 필드와 메서드를 쓰는 편이 낫습니다.
public final class OverridePost {
public static void main(String[] args) {
TextPost textPost = new TextPost("가입 인사", 40, 120);
CodePost codePost = new CodePost("로그인 구현", 50, "feature/login");
textPost.publish();
codePost.publish();
}
private static class Post {
protected final String title;
protected final int viewCount;
Post(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
void publish() {
System.out.println("post=" + title + ":views=" + viewCount);
}
}
private static final class TextPost extends Post {
private final int bodyLength;
TextPost(String title, int viewCount, int bodyLength) {
super(title, viewCount);
this.bodyLength = bodyLength;
}
@Override
void publish() {
System.out.println("text=" + title + ":chars=" + bodyLength);
}
}
private static final class CodePost extends Post {
private final String branch;
CodePost(String title, int viewCount, String branch) {
super(title, viewCount);
this.branch = branch;
}
@Override
void publish() {
super.publish();
System.out.println("code=branch/" + branch);
}
}
}text=가입 인사:chars=120
post=로그인 구현:views=50
code=branch/feature/login첫 줄은 TextPost가 오버라이딩한 publish가 출력했습니다. textPost.publish()를 호출하면 Java는 TextPost에서 publish를 찾고, 거기서 찾았으므로 Post의 publish는 실행하지 않습니다. 앞에서 본 '자식에서 부모 쪽으로 찾는' 규칙에 따라 자식 쪽에서 먼저 찾은 메서드가 쓰이는 것입니다. 나머지 두 줄은 codePost.publish() 한 번이 출력했습니다. CodePost의 publish는 super.publish()로 부모 구현을 먼저 실행해 post=로그인 구현:views=50을 출력하고, 돌아와서 자기 줄 code=branch/feature/login을 출력합니다. 부모의 동작을 버리지 않고 그 뒤에 덧붙인 것입니다.
publish 오버라이딩과 super.publish의 실제 출력
TextPost의 한 출력 뒤 CodePost는 자식 진입→super.publish 출력→자식 출력으로 이어집니다. 현재 구현과 누적 출력이 변화하는 실제 두 호출입니다.
OverridePost의 textPost.publish는 자식 구현의 한 줄을 출력하고, codePost.publish는 super.publish로 부모의 한 줄을 출력한 다음 자식의 한 줄을 출력한다. 두 main 호출은 서로 다른 객체를 대상으로 한다.
자식의 publish 구현과 super 호출에 따라 출력이 달라집니다
OverridePost의 main은 textPost.publish() 다음에 codePost.publish()를 호출합니다.
| 호출 대상 | 실행하는 구현 | 해당 호출의 출력 |
|---|---|---|
textPost.publish() | TextPost.publish()만 실행합니다. | text=가입 인사:chars=120 |
codePost.publish() | CodePost.publish() 안에서 super.publish()로 Post.publish()를 먼저 실행한 뒤 자식의 출력문을 실행합니다. | post=로그인 구현:views=50
|
textPost.publish()TextPost.publish()만 실행하여text=가입 인사:chars=120을 출력합니다.codePost.publish()CodePost.publish()의super.publish()가Post.publish()를 실행하여post=로그인 구현:views=50을 출력합니다. 돌아온 뒤code=branch/feature/login을 출력합니다.
super.publish()는 같은 CodePost 객체를 수신자로 부모 구현을 선택합니다. 부모 객체를 따로 만들지 않으며, TextPost의 호출에는 super.publish()가 없습니다.
이 예의 조건
- TextPost 호출이 정상 종료된 뒤 다른 CodePost를 호출합니다. 상자 줄 분리는 실제 출력 줄이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 별도 TextPost 호출은 자식의 한 줄을 출력한다. 다음 CodePost 호출은 super.publish의 부모 출력 뒤 자식으로 정상 복귀하여 출력한다. super는 같은 수신 객체를 사용하며 첫 호출과 대상이 다르다. 긴 문자열의 상자 줄 분리는 실제 출력 줄이 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 별도 TextPost 호출은 자식의 한 줄을 출력한다. 다음 CodePost 호출은 super.publish의 부모 출력 뒤 자식으로 정상 복귀하여 출력한다. super는 같은 수신 객체를 사용하며 첫 호출과 대상이 다르다. 긴 문자열의 상자 줄 분리는 실제 출력 줄이 아니다. |
| 전체 읽기 정보 | publish 오버라이딩과 super.publish의 실제 출력 publish 오버라이딩과 super.publish의 실제 출력 OverridePost의 textPost.publish는 자식 구현의 한 줄을 출력하고, codePost.publish는 super.publish로 부모의 한 줄을 출력한 다음 자식의 한 줄을 출력한다. 두 main 호출은 서로 다른 객체를 대상으로 한다. 자식의 publish 구현과 super 호출에 따라 출력이 달라집니다 OverridePost 의 main 은 textPost.publish() 다음에 codePost.publish() 를 호출합니다. 두 객체의 publish 구현 선택과 출력 순서 호출 대상 실행하는 구현 해당 호출의 출력 textPost.publish() TextPost.publish() 만 실행합니다. text=가입 인사:chars=120 codePost.publish() CodePost.publish() 안에서 super.publish() 로 Post.publish() 를 먼저 실행한 뒤 자식의 출력문을 실행합니다. post=로그인 구현:views=50 code=branch/feature/login textPost.publish() TextPost.publish() 만 실행하여 text=가입 인사:chars=120 을 출력합니다. codePost.publish() CodePost.publish() 의 super.publish() 가 Post.publish() 를 실행하여 post=로그인 구현:views=50 을 출력합니다. 돌아온 뒤 code=branch/feature/login 을 출력합니다. super.publish() 는 같은 CodePost 객체를 수신자로 부모 구현을 선택합니다. 부모 객체를 따로 만들지 않으며, TextPost 의 호출에는 super.publish() 가 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Text 자식 호출 textPost.publish가 자식 구현만 실행합니다. |
|
|
| Code 부모 호출 codePost.publish 안에서 super.publish를 실행합니다. |
|
|
| Code 복귀 출력 부모에서 정상 복귀한 뒤 자식 문장을 실행합니다. |
|
|
publish 오버라이딩과 super.publish의 실제 출력
TextPost의 한 출력 뒤 CodePost는 자식 진입→super.publish 출력→자식 출력으로 이어집니다. 현재 구현과 누적 출력이 변화하는 실제 두 호출입니다.
OverridePost의 textPost.publish는 자식 구현의 한 줄을 출력하고, codePost.publish는 super.publish로 부모의 한 줄을 출력한 다음 자식의 한 줄을 출력한다. 두 main 호출은 서로 다른 객체를 대상으로 한다.
자식의 publish 구현과 super 호출에 따라 출력이 달라집니다
OverridePost의 main은 textPost.publish() 다음에 codePost.publish()를 호출합니다.
| 호출 대상 | 실행하는 구현 | 해당 호출의 출력 |
|---|---|---|
textPost.publish() | TextPost.publish()만 실행합니다. | text=가입 인사:chars=120 |
codePost.publish() | CodePost.publish() 안에서 super.publish()로 Post.publish()를 먼저 실행한 뒤 자식의 출력문을 실행합니다. | post=로그인 구현:views=50
|
textPost.publish()TextPost.publish()만 실행하여text=가입 인사:chars=120을 출력합니다.codePost.publish()CodePost.publish()의super.publish()가Post.publish()를 실행하여post=로그인 구현:views=50을 출력합니다. 돌아온 뒤code=branch/feature/login을 출력합니다.
super.publish()는 같은 CodePost 객체를 수신자로 부모 구현을 선택합니다. 부모 객체를 따로 만들지 않으며, TextPost의 호출에는 super.publish()가 없습니다.
이 예의 조건
- TextPost 호출이 정상 종료된 뒤 다른 CodePost를 호출합니다. 상자 줄 분리는 실제 출력 줄이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 별도 TextPost 호출은 자식의 한 줄을 출력한다. 다음 CodePost 호출은 super.publish의 부모 출력 뒤 자식으로 정상 복귀하여 출력한다. super는 같은 수신 객체를 사용하며 첫 호출과 대상이 다르다. 긴 문자열의 상자 줄 분리는 실제 출력 줄이 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 별도 TextPost 호출은 자식의 한 줄을 출력한다. 다음 CodePost 호출은 super.publish의 부모 출력 뒤 자식으로 정상 복귀하여 출력한다. super는 같은 수신 객체를 사용하며 첫 호출과 대상이 다르다. 긴 문자열의 상자 줄 분리는 실제 출력 줄이 아니다. |
| 전체 읽기 정보 | publish 오버라이딩과 super.publish의 실제 출력 publish 오버라이딩과 super.publish의 실제 출력 OverridePost의 textPost.publish는 자식 구현의 한 줄을 출력하고, codePost.publish는 super.publish로 부모의 한 줄을 출력한 다음 자식의 한 줄을 출력한다. 두 main 호출은 서로 다른 객체를 대상으로 한다. 자식의 publish 구현과 super 호출에 따라 출력이 달라집니다 OverridePost 의 main 은 textPost.publish() 다음에 codePost.publish() 를 호출합니다. 두 객체의 publish 구현 선택과 출력 순서 호출 대상 실행하는 구현 해당 호출의 출력 textPost.publish() TextPost.publish() 만 실행합니다. text=가입 인사:chars=120 codePost.publish() CodePost.publish() 안에서 super.publish() 로 Post.publish() 를 먼저 실행한 뒤 자식의 출력문을 실행합니다. post=로그인 구현:views=50 code=branch/feature/login textPost.publish() TextPost.publish() 만 실행하여 text=가입 인사:chars=120 을 출력합니다. codePost.publish() CodePost.publish() 의 super.publish() 가 Post.publish() 를 실행하여 post=로그인 구현:views=50 을 출력합니다. 돌아온 뒤 code=branch/feature/login 을 출력합니다. super.publish() 는 같은 CodePost 객체를 수신자로 부모 구현을 선택합니다. 부모 객체를 따로 만들지 않으며, TextPost 의 호출에는 super.publish() 가 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Text 자식 호출 textPost.publish가 자식 구현만 실행합니다. |
|
|
| Code 부모 호출 codePost.publish 안에서 super.publish를 실행합니다. |
|
|
| Code 복귀 출력 부모에서 정상 복귀한 뒤 자식 문장을 실행합니다. |
|
|
오버라이딩이 되려면 메서드 이름과 매개변수 목록이 부모와 같아야 합니다. 반환 타입은 void나 기본형이면 부모와 똑같아야 하고, 참조형이면 같은 타입이거나 그 하위 타입이어도 됩니다. 접근 범위는 부모보다 좁힐 수 없습니다. 패키지 접근인 publish()를 자식에서 private으로 선언하면 attempting to assign weaker access privileges라는 오류가 납니다. 부모 타입으로 호출할 수 있던 메서드를 자식 객체에서 호출할 수 없게 되면 안 되기 때문입니다. 그 밖에 private 메서드는 처음부터 물려받지 않으니 오버라이딩할 대상이 아니고, final을 붙인 메서드는 오버라이딩이 금지됩니다.
메서드 위의 @Override는 '이 메서드는 부모 메서드를 오버라이딩한다'는 표시입니다. 붙이지 않아도 오버라이딩은 되지만, 붙여 두면 컴파일러가 정말 오버라이딩하는지 확인해 줍니다. 이름을 pubilsh로 잘못 쓰면 이름이 다른 새 메서드가 되고, 매개변수를 하나 더 붙이면 같은 이름의 오버로딩 메서드가 됩니다. 어느 쪽이든 @Override가 없으면 컴파일은 조용히 통과하고, publish()를 호출하면 여전히 부모 구현이 실행됩니다. @Override가 있으면 method does not override or implement a method from a supertype 오류로 실수를 바로 알려 줍니다. 이 검사는 instanceof와 오버라이딩에서 다시 다룹니다.
오버라이딩은 메서드에만 해당합니다. 부모와 자식에 같은 이름의 필드를 선언하면 자식의 필드가 부모의 필드를 대신하는 것이 아니라 객체 안에 두 필드가 따로 생기고, 어느 쪽이 읽히는지는 변수의 타입에 따라 달라집니다. 혼란을 피하려면 같은 의미의 상태는 부모에 하나만 둡니다.
super로 부모의 구현 부르기
CodePost의 publish 안에 있던 super.publish()를 조금 더 살펴보겠습니다. 이름 때문에 super가 부모 객체를 가리킨다고 생각하기 쉽지만, 앞에서 보았듯 부모 객체는 따로 없습니다. super.publish()는 지금 객체, 즉 this와 같은 객체에 대해 메서드를 부모 클래스에서부터 찾아 실행하라는 뜻입니다. 그래서 이 호출로 실행된 Post의 publish가 읽는 title과 viewCount도 바로 그 CodePost 객체의 필드입니다. super는 객체를 담은 값이 아니므로 변수에 담거나 반환할 수 없고, 언제나 super.publish()나 super(...)처럼 뒤에 무언가를 붙여서만 씁니다.
super.를 빠뜨리면 전혀 다른 일이 일어납니다. 부모의 출력도 남기려고 CodePost의 publish 안에서 그냥 publish()라고 쓰면, 그 호출은 this.publish(), 즉 지금 실행 중인 CodePost의 publish 자신을 다시 부릅니다. 메서드가 자기 자신을 끝없이 부르다가 결국 StackOverflowError로 프로그램이 멈춥니다.
@Override
void publish() {
publish(); // super.가 없어 자기 자신을 다시 호출한다
System.out.println("code=branch/" + branch);
}생성자에서는 this(...)로 같은 클래스의 다른 생성자에 초기화를 넘길 수 있습니다. 이때는 위임받은 생성자가 부모 생성자를 부르고, 위임이 여러 번 이어지면 마지막 생성자가 super(...)를 호출합니다. 생성자 하나에는 this(...)와 super(...) 중 하나만 쓸 수 있으므로, 어느 길로 가든 부모 생성자는 한 번만 호출됩니다.
더 깊이: Java 25에서는 super(...) 앞에 문장을 둘 수 있다
이 장의 생성자들은 모두 super(...)를 첫 문장으로 호출합니다. 오랫동안 이것은 반드시 지켜야 하는 규칙이어서, 부모에게 넘길 인자를 미리 검사하는 if 문조차 super(...) 앞에 둘 수 없었습니다. JDK 21로 그렇게 컴파일하면 call to super must be first statement in constructor 오류가 납니다. Java 22부터 24까지 프리뷰 기능으로 시험하던 이 제한 완화가 Java 25에서 유연한 생성자 본문(flexible constructor bodies, JEP 513)이라는 정식 기능이 되어, 이제는 초기 생성 문맥의 제약을 지키는 실행문을 super(...) 앞에 둘 수 있습니다. 예를 들어 CodePost는 브랜치 이름을 먼저 검사한 뒤 부모 생성자를 부를 수 있습니다.
// Java 25 이상에서 컴파일됩니다
CodePost(String title, int viewCount, String branch) {
if (branch.isBlank()) {
throw new IllegalArgumentException("branch");
}
super(title, viewCount);
this.branch = branch;
}super(...) 앞에서는 부모 초기화가 아직 끝나지 않았으므로 만들고 있는 객체를 쓸 수 없습니다. 필드 값을 읽거나 인스턴스 메서드를 호출하거나 this를 다른 곳에 넘기는 일은 금지되고, 위 코드처럼 매개변수를 검사하고 계산하는 일은 할 수 있습니다. 예외가 하나 있는데, 선언할 때 값을 주지 않은 자기 클래스의 필드에는 값을 대입할 수 있습니다. 이 예외가 다음 절에서 볼 문제를 피하는 방법이 됩니다. 기능의 자세한 내용은 Oracle의 Java 25 언어 안내서에 정리되어 있습니다.
부모 생성자에서 오버라이딩된 메서드를 부르면
생성자 순서를 알면 다음 코드가 왜 이상한 값을 출력하는지 설명할 수 있습니다. Parent 생성자가 report()를 호출하는데, Child가 이 메서드를 오버라이딩했습니다. 실행하기 전에 두 줄의 출력을 먼저 예상해 보세요.
public final class ConstructorCallOrder {
public static void main(String[] args) {
Child child = new Child();
child.report();
}
private static class Parent {
Parent() { report(); }
void report() {}
}
private static final class Child extends Parent {
private String branch = "feature/login";
@Override void report() {
System.out.println("branch=" + branch);
}
}
}branch=null
branch=feature/login오버라이딩한 메서드는 부모 클래스의 코드 안에서 호출되더라도 실제 객체의 구현이 실행됩니다. 지금 만들고 있는 객체가 Child이므로 Parent 생성자 안의 report()도 Child의 report를 실행합니다. 문제는 그 시점에 Child의 필드가 아직 초기화되지 않았다는 것입니다. 필드 선언에 붙은 초기화 식 = "feature/login"은 Child 생성자의 super() 호출이 끝난 뒤에야 실행됩니다. Child에는 생성자를 쓰지 않았지만, 컴파일러가 넣은 기본 생성자도 super()로 시작한다는 점은 같습니다.
| 순서 | 실행 중인 코드 | branch | 출력 |
|---|---|---|---|
| 1 | new Child()가 객체를 만들고 필드를 기본값으로 채운다 | null | |
| 2 | Child 생성자의 super()가 Parent 생성자를 부른다 | null | |
| 3 | Parent 생성자의 report()가 Child의 report를 실행한다 | null | branch=null |
| 4 | Parent 생성자가 끝나고 Child의 필드 초기화 식이 실행된다 | "feature/login" | |
| 5 | 생성이 끝난 뒤 main이 child.report()를 호출한다 | "feature/login" | branch=feature/login |
그래서 첫 줄에는 참조형 필드의 기본값 null이 찍힙니다. 이 예제는 출력이 이상한 정도로 끝나지만, report가 branch.length()처럼 필드의 메서드를 호출했다면 NullPointerException이 났을 것입니다. 그래서 생성자에서는 자식이 오버라이딩할 수 있는 메서드를 호출하지 않는 것이 원칙입니다. 꼭 호출해야 한다면 그 메서드를 private이나 final로 만들어 자식이 바꿀 수 없게 합니다. Java 25를 쓴다면 앞의 '더 깊이'에서 본 대로 Child에 생성자를 직접 두고, 초기화 식 없이 선언한 branch에 super()보다 먼저 값을 대입하는 방법도 있습니다.
언제 상속을 쓸까
상속은 '자식은 부모의 한 종류다(is-a)'라는 관계가 성립할 때 씁니다. '코드 게시글은 게시글이다'는 자연스럽습니다. 반면 게시판 화면 클래스가 입력을 읽으려고 Scanner를 상속한다면 '게시판 화면은 Scanner다'라는 이상한 관계가 됩니다. 실제로 Scanner는 final 클래스라서 상속할 수도 없지만, 상속할 수 있는 클래스였더라도 마찬가지입니다. 다른 클래스의 기능을 쓰고 싶을 뿐이라면 상속하지 말고 그 객체를 필드로 가지고 사용합니다. 이를 구성(composition)이라고 하며, '가지고 있다(has-a)' 관계를 나타냅니다.
final class BoardCli {
private final Scanner scanner;
BoardCli(Scanner scanner) {
this.scanner = scanner;
}
String readTitle() {
return scanner.nextLine();
}
}BoardCli는 Scanner의 많은 메서드 가운데 필요한 nextLine만 골라 쓰고, 바깥에는 readTitle이라는 자기 메서드만 보여 줍니다. 상속했다면 Scanner의 메서드 전부가 BoardCli의 메서드처럼 바깥에 드러났을 것입니다.
is-a 관계가 맞는지 확인하는 실용적인 방법은 부모 타입을 기대하는 코드에 자식 객체를 넣어도 그 코드가 여전히 올바르게 동작하는지 묻는 것입니다. 게시글 목록을 공개하는 코드에 CodePost를 넣어도 '게시글을 공개한다'는 의미는 그대로입니다. 반대로 자식이 publish를 오버라이딩하면서 아무것도 공개하지 않고 예외만 던진다면, 문법상으로는 상속이어도 부모처럼 쓸 수 없는 자식이 됩니다. 이 생각을 원칙으로 정리한 것이 리스코프 치환 원칙(Liskov substitution principle)입니다.
공통 코드가 조금 있다는 이유만으로 계층을 만들면 다른 문제도 생깁니다. 자식은 부모의 구현에 기대어 동작하므로, 부모의 publish 출력 형식을 바꾸면 그 메서드를 물려받거나 super.publish()로 부르는 모든 자식의 출력이 함께 바뀝니다. 서로 관련 없는 클래스들이 부모 하나에 묶여 있으면 한 클래스를 위해 고친 부모가 다른 클래스를 망가뜨릴 수 있습니다.
부모 타입 배열에 자식 객체 담기
마지막으로 상속이 코드 재사용 이상의 의미를 갖는 장면을 미리 보겠습니다. 다음 예제는 Post 배열 하나에 TextPost와 CodePost를 함께 담고, 반복문으로 공개하면서 조회 수를 더합니다.
public final class InheritedPostArray {
public static void main(String[] args) {
Post[] posts = {
new TextPost("가입 인사", 40),
new CodePost("로그인 구현", 50)
};
int total = 0;
for (Post post : posts) {
post.publish();
total += post.viewCount();
}
System.out.println("total=" + total);
}
private 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;
}
protected String title() {
return title;
}
void publish() {
System.out.println("post=" + title);
}
}
private static final class TextPost extends Post {
TextPost(String title, int viewCount) {
super(title, viewCount);
}
@Override
void publish() {
System.out.println("text=" + title());
}
}
private static final class CodePost extends Post {
CodePost(String title, int viewCount) {
super(title, viewCount);
}
@Override
void publish() {
System.out.println("code=" + title());
}
}
}text=가입 인사
code=로그인 구현
total=90자식은 부모의 한 종류이므로 Post 배열의 칸에 TextPost와 CodePost 객체를 넣을 수 있습니다. 반복문은 각 원소를 Post 타입 변수 post로 꺼내 publish()와 viewCount()를 호출합니다. 출력을 보면 publish는 객체마다 자식이 오버라이딩한 구현이 실행되어 text=와 code=로 시작하는 줄이 나왔고, viewCount()는 두 자식이 물려받은 Post의 구현이 40과 50을 돌려주어 합계가 90이 되었습니다.
반복문 안의 코드는 TextPost나 CodePost라는 이름을 전혀 쓰지 않는데도 객체마다 알맞은 동작이 실행됩니다. 변수 타입이 Post인데 어떻게 자식의 publish가 선택되는지, Post 변수로 할 수 있는 일과 없는 일이 무엇인지는 다음 글부터 다형적 참조와 함께 자세히 다룹니다.
정리
class TextPost extends Post는 텍스트 게시글이 게시글의 한 종류라는 선언입니다. 자식은 부모의 필드와 메서드를 물려받고, 자기만의 필드와 메서드를 덧붙입니다. 클래스의 부모는 하나뿐이며,extends가 없으면Object가 부모입니다.- 자식 생성자는
super(...)로 부모 생성자를 호출합니다. 쓰지 않으면 컴파일러가 본문 맨 앞에super()를 넣으므로, 부모에 매개변수 없는 생성자가 없으면 직접super(...)를 써야 합니다. - 자식 객체를 만들어도 객체는 하나이고, 그 안에 부모와 자식이 선언한 필드가 함께 들어 있습니다. 부모의
private필드는 객체에 있어도 자식이 이름으로 쓸 수 없습니다. - 오버라이딩은 부모와 같은 이름·매개변수의 메서드를 자식에 다시 선언해 동작을 바꾸는 것입니다.
@Override를 붙이면 컴파일러가 확인해 주고,super.메서드()로 부모 구현을 함께 실행할 수 있습니다. - 생성자에서 오버라이딩될 수 있는 메서드를 부르면 자식 필드가 초기화되기 전에 자식 구현이 실행될 수 있습니다.
- 상속은 is-a 관계일 때만 쓰고, 기능을 빌려 쓰려는 것이라면 구성을 씁니다.
다음 글에서는 부모 타입 변수에 자식 객체를 담았을 때 컴파일러와 실행 시점이 각각 무엇을 기준으로 메서드를 고르는지 살펴봅니다.
연습 문제
문제 1: 이미지 게시글 추가하기
부모 Post에 title과 publish()를 두고, 자식 TextPost에는 본문 길이 bodyLength를, ImagePost에는 이미지 수 imageCount를 두세요. 각 자식은 생성자에서 super로 title을 넘기고, publish를 오버라이딩해 자기 정보를 함께 출력합니다. 두 객체를 Post 배열에 담아 차례로 공개하세요.
해설 보기
public final class PostInheritanceExercise {
public static void main(String[] args) {
Post[] posts = {
new TextPost("가입 인사", 300),
new ImagePost("행사 사진", 4)
};
for (Post post : posts) {
post.publish();
}
}
private static class Post {
protected final String title;
Post(String title) {
this.title = title;
}
void publish() {
System.out.println("publish=" + title);
}
}
private static final class TextPost extends Post {
private final int bodyLength;
TextPost(String title, int bodyLength) {
super(title);
this.bodyLength = bodyLength;
}
@Override
void publish() {
System.out.println("text=" + title + ":chars=" + bodyLength);
}
}
private static final class ImagePost extends Post {
private final int imageCount;
ImagePost(String title, int imageCount) {
super(title);
this.imageCount = imageCount;
}
@Override
void publish() {
System.out.println("image=" + title + ":count=" + imageCount);
}
}
}text=가입 인사:chars=300
image=행사 사진:count=4모든 게시글에 공통인 title과 publish는 부모에 두고, 게시글 종류마다 다른 상태와 출력 형식은 자식에 두었습니다. 자식 생성자는 super(title)로 부모가 선언한 필드를 먼저 초기화한 뒤 자기 필드를 채웁니다. 반복문의 post.publish()는 배열에 담긴 객체에 따라 TextPost와 ImagePost의 구현을 각각 실행합니다. 해설은 코드를 짧게 하려고 title을 protected final로 열었는데, private 필드와 title() 메서드로 바꿔도 출력은 같습니다.
문제 2: 생성자 출력 순서 예상하기
다음 프로그램의 출력을 순서대로 적고, 그 순서가 되는 이유를 설명하세요. main이 객체를 만드는 줄, 두 생성자의 println, 그리고 super(title)이 놓인 위치를 함께 보면 됩니다.
public final class OrderPrint {
public static void main(String[] args) {
new TextPost("가입 인사", 120);
System.out.println("생성 완료");
}
private static class Post {
private final String title;
Post(String title) {
System.out.println("Post 생성자: " + title);
this.title = title;
}
}
private static final class TextPost extends Post {
private final int bodyLength;
TextPost(String title, int bodyLength) {
super(title);
System.out.println("TextPost 생성자: " + bodyLength);
this.bodyLength = bodyLength;
}
}
}해설 보기
Post 생성자: 가입 인사
TextPost 생성자: 120
생성 완료new TextPost(...)는 TextPost 생성자를 먼저 호출하지만, 그 생성자의 첫 문장이 super(title)이므로 곧바로 Post 생성자로 넘어갑니다. Post 생성자가 첫 줄을 출력하고 끝나야 TextPost 생성자의 나머지 문장이 실행되어 둘째 줄이 나옵니다. 생성이 모두 끝나야 new 식이 끝나므로 main의 생성 완료는 마지막에 출력됩니다. 호출은 자식에서 시작하지만 본문은 부모부터 실행된다는 점이 핵심입니다.
문제 3: 멈추지 않는 publish
CodePost에서 부모의 출력 뒤에 브랜치를 덧붙이려고 publish 안에 publish();를 먼저 쓰고 그다음 줄에서 브랜치를 출력했습니다. 실행하자 아무 줄도 출력되지 않고 StackOverflowError로 멈췄습니다. 이유를 설명하고 고치세요.
해설 보기
메서드 안에서 이름만으로 publish()를 부르면 this.publish()와 같습니다. 현재 객체는 CodePost이므로 이 호출은 지금 실행 중인 CodePost의 publish를 다시 실행하고, 그 안에서 또 자신을 부르기를 끝없이 반복합니다. 출력문에 도달하기 전에 호출이 계속 쌓이므로 아무것도 출력되지 않고, 호출 스택이 가득 차서 StackOverflowError가 납니다. 부모의 구현을 부르려면 super.publish();라고 써서 메서드를 부모 클래스에서부터 찾게 해야 합니다.