자바 상속과 다형성 - 오버라이딩 업캐스팅 그리고 상속 대신 조합을 택할 때 (자바 중급 9단원)
이 단원에서 배우는 것
초급 3단원에서 연체료를 if 사슬로 계산했고, 6단원에서 흩어져 있던 제목 배열과 연체일 배열을 Book 클래스로 묶었다. 초급 마지막까지 온 코드는 전부 "클래스 하나가 자기 일을 잘 한다"였다. 실무 코드가 어려워지는 지점은 클래스 하나가 아니라 클래스 여러 개의 관계다. 중급 커리큘럼은 여기서 시작한다.
이 배치는 초급에서 만들던 도서관 대출 시스템을 그대로 이어받아 16단원까지 발전시킨다. 도서만 다루던 시스템에 DVD와 정기간행물이 추가되고, 대출·연체료·통계까지 붙는다. 매 단원 새 예제를 꺼내지 않는 이유는, 같은 시스템이 문법이 바뀔 때마다 어떻게 달라지는지를 보는 것이 문법 자체보다 중요하기 때문이다.
- 오버라이딩 규칙을 정확히 알고, 오버로딩과 헷갈리지 않는다
- 업캐스팅이 왜 실무에서
if-else를 없애는지 코드로 설명할 수 있다 - 상속을 쓸 자리와 조합(composition)을 쓸 자리를 구분한다
왜 필요한가
도서관에 도서 말고 DVD와 정기간행물이 들어왔다. 종류마다 대출 기간과 하루 연체료가 다르다. 상속을 모르면 코드는 거의 예외 없이 이렇게 시작한다.
// 나쁜 예 — 자료 종류가 늘어날 때마다 이 메서드를 찾아 고쳐야 한다
public long overdueFee(String type, int overdueDays) {
if (type.equals("BOOK")) {
return 100L * overdueDays;
} else if (type.equals("DVD")) {
return 500L * overdueDays;
} else if (type.equals("MAGAZINE")) {
return 200L * overdueDays;
}
throw new IllegalArgumentException("알 수 없는 자료 종류: " + type);
}
문제는 이 if 사슬이 한 군데에서 끝나지 않는다는 것이다. 연체료를 계산할 때 한 번, 대출 기간을 정할 때 한 번, 반납 예정일을 찍을 때 한 번, 통계 배치에서 또 한 번. 자료 종류 하나를 추가하려면 프로젝트 전체에서 type.equals( 를 검색해 빠짐없이 고쳐야 한다. 한 군데를 놓치면 그 코드는 IllegalArgumentException 을 던지거나, 더 나쁘게는 조용히 잘못된 연체료를 물린다.
다형성은 이 분기를 객체를 만드는 시점 한 곳으로 모으는 장치다. 만들 때 한 번만 "무엇인지"를 정하고, 그 뒤로는 모두 "연체료를 계산해라"라고만 말한다. 상속은 그렇게 말할 수 있게 하는 문법적 토대다.
문법과 예제
extends 와 생성자 호출 순서
extends 로 부모를 지정하면 자식은 부모의 private 이 아닌 멤버를 그대로 가진다. 중요한 것은 생성자는 상속되지 않는다는 점이다. 자식 생성자는 첫 줄에서 반드시 부모 생성자를 호출하며, 안 쓰면 컴파일러가 super(); 를 몰래 넣는다. 부모에 기본 생성자가 없으면 이때 컴파일 에러가 난다.
public abstract class LibraryItem {
protected final String isbn; // 자식에서 읽을 수 있게 protected
protected final String title;
protected LibraryItem(String isbn, String title) {
if (isbn == null || isbn.isBlank()) throw new IllegalArgumentException("등록번호는 필수다");
this.isbn = isbn;
this.title = title;
}
public String isbn() { return isbn; }
public String title() { return title; }
/** 자료 종류마다 다른 부분 */
public abstract int loanDays(); // 대출 가능 일수
public abstract long dailyFee(); // 하루 연체료
/** 모든 자료가 공통으로 쓰는 부분 */
public long overdueFee(int overdueDays) {
if (overdueDays <= 0) return 0;
return dailyFee() * overdueDays;
}
public String describe() {
return "[" + type() + "] " + title + " / 대출 " + loanDays() + "일 / 연체 하루 " + dailyFee() + "원";
}
protected String type() { return "자료"; }
@Override public String toString() { return title; }
}
public class Book extends LibraryItem {
private final String author;
public Book(String isbn, String title, String author) {
super(isbn, title); // 반드시 첫 줄
this.author = author;
}
@Override public int loanDays() { return 14; }
@Override public long dailyFee() { return 100; }
@Override protected String type() { return "도서"; }
public String author() { return author; }
}
public class Dvd extends LibraryItem {
private final int discs;
public Dvd(String isbn, String title, int discs) {
super(isbn, title);
this.discs = discs;
}
@Override public int loanDays() { return 3; }
@Override public long dailyFee() { return 500; }
@Override protected String type() { return "DVD"; }
public int discs() { return discs; }
}
public class Magazine extends LibraryItem {
private final boolean latestIssue;
public Magazine(String isbn, String title, boolean latestIssue) {
super(isbn, title);
this.latestIssue = latestIssue;
}
/** 최신호는 관내 열람만 가능하다 */
@Override public int loanDays() { return latestIssue ? 0 : 7; }
@Override public long dailyFee() { return 200; }
@Override protected String type() { return "정기간행물"; }
}
오버라이딩 규칙
오버라이딩은 선언부가 같아야 성립한다. 이름, 매개변수 목록, 그리고 반환 타입이다. 여기서 실무자가 자주 틀리는 네 가지를 표로 정리한다.
| 항목 | 규칙 | 어기면 |
|---|---|---|
| 매개변수 목록 | 완전히 같아야 한다 | 오버라이딩이 아니라 오버로딩이 되어 조용히 다른 메서드가 된다 |
| 접근 제어자 | 부모보다 좁힐 수 없다 | 컴파일 에러 |
| 반환 타입 | 같거나, 부모 반환 타입의 하위 타입(공변 반환) | 컴파일 에러 |
| 예외 | 부모가 선언한 checked 예외보다 넓은 것을 던질 수 없다 | 컴파일 에러 |
@Override 는 장식이 아니다. 이걸 붙이면 위 규칙을 어겼을 때 컴파일 단계에서 잡힌다. 안 붙이면 오버로딩된 새 메서드가 만들어지고, 런타임에 부모 구현이 호출되면서 "왜 내 코드가 안 타지"로 반나절을 쓴다. 재정의하는 메서드에는 예외 없이 붙인다.
업캐스팅 — 분기가 사라지는 지점
import java.util.List;
public class LoanDeskDemo {
public static void main(String[] args) {
// 만드는 시점에만 "무엇인지"를 정한다
List<LibraryItem> items = List.of(
new Book("B-001", "자바의 정석", "남궁성"),
new Dvd("D-014", "매트릭스", 2),
new Magazine("M-207", "월간 과학", true)
);
long total = 0;
for (LibraryItem item : items) { // 업캐스팅: 자식 객체를 부모 타입으로 다룬다
System.out.println(item.describe());
total += item.overdueFee(5);
}
System.out.println("연체 5일 기준 합계 " + total + "원");
}
}
[도서] 자바의 정석 / 대출 14일 / 연체 하루 100원
[DVD] 매트릭스 / 대출 3일 / 연체 하루 500원
[정기간행물] 월간 과학 / 대출 0일 / 연체 하루 200원
연체 5일 기준 합계 4000원
item 의 타입은 LibraryItem 이지만 실제 객체는 Book 이다. 그래서 item.dailyFee() 는 자식 구현으로 간다. 이것이 동적 바인딩이고, 위 for 문에는 자료 종류가 몇 개로 늘어나든 손댈 곳이 없다. 자료 종류를 추가하는 작업이 "새 클래스 파일 하나 + 생성하는 곳 한 줄"로 끝난다.
describe() 는 부모에만 있는데 type() 은 자식 것이 불린다는 점도 눈여겨본다. 부모가 짜놓은 흐름 안에서 달라지는 부분만 자식이 채우는 구조인데, 10단원에서 이 구조를 본격적으로 다룬다.
다운캐스팅과 instanceof 패턴 매칭 (Java 16+)
가끔은 특정 자식 타입일 때만 하는 처리가 필요하다. Java 16부터 정식 도입된 instanceof 패턴 매칭을 쓰면 검사와 캐스팅을 한 줄에 쓴다. Java 17에서는 당연히 쓸 수 있다.
// Java 15 이전 스타일
if (item instanceof Dvd) {
Dvd dvd = (Dvd) item;
System.out.println(dvd.discs() + "장짜리");
}
// Java 16+ — 검사에 성공하면 dvd 를 그 블록 안에서 바로 쓸 수 있다
if (item instanceof Dvd dvd && dvd.discs() > 1) {
System.out.println("다중 디스크 자료: " + dvd.title());
}
다만 instanceof 로 자식 타입을 뒤지는 코드가 늘어나기 시작하면, 그건 다형성으로 풀어야 할 것을 캐스팅으로 때우고 있다는 신호다. 처음 만든 if 사슬이 이름만 바꿔서 돌아온 셈이다.
상속보다 조합 — 연체료 감면의 경우
이제 연체료 감면을 넣는다. 학생 회원은 50% 감면, 휴관일이 낀 기간은 전액 면제, 일반 회원은 감면 없음. 상속으로 풀면 이렇게 된다.
// 나쁜 예
class DiscountedBook extends Book { ... }
class HolidayFreeBook extends Book { ... }
class DiscountedHolidayFreeDvd extends Dvd { ... } // 조합이 늘면 클래스가 곱셈으로 늘어난다
자료 종류 3개 × 감면 종류 3개면 클래스가 9개, 감면을 겹쳐 쓰면 더 늘어난다. 자바는 클래스 다중 상속이 안 되므로 이 방향은 막다른 길이다. 게다가 감면은 자료의 성질이 아니라 대출 건의 성질이다. "~이다(is-a)"가 아니라 "~를 가진다(has-a)"인 관계는 필드로 가지는 게 맞다.
/** 연체료 감면 정책 — 대출 건이 "가지는" 것이지 자료의 한 종류가 아니다 */
public interface FeePolicy {
long discount(long fee);
}
public class Loan {
private final String memberId;
private final LibraryItem item;
private final int overdueDays;
private final FeePolicy policy; // 조합
public Loan(String memberId, LibraryItem item, int overdueDays, FeePolicy policy) {
this.memberId = memberId;
this.item = item;
this.overdueDays = overdueDays;
this.policy = policy;
}
public long payableFee() {
long base = item.overdueFee(overdueDays);
return Math.max(0, base - policy.discount(base));
}
public String memberId() { return memberId; }
}
public class LoanDemo {
public static void main(String[] args) {
LibraryItem book = new Book("B-001", "자바의 정석", "남궁성");
FeePolicy none = fee -> 0;
FeePolicy student = fee -> fee / 2;
FeePolicy holidayFree = fee -> fee;
System.out.println(new Loan("M-01", book, 15, none).payableFee()); // 1500
System.out.println(new Loan("M-02", book, 15, student).payableFee()); // 750
System.out.println(new Loan("M-03", book, 15, holidayFree).payableFee()); // 0
}
}
여기 쓴 fee -> 0 같은 표현이 람다다. 16단원에서 제대로 다루니 지금은 "인터페이스 메서드 하나짜리를 짧게 쓴 것" 정도로 읽으면 된다. 핵심은 Loan 코드를 한 줄도 안 고치고 감면 정책이 무한히 늘어난다는 것이다.
판단 기준은 간단하다. 자식이 부모의 모든 메서드를 그대로 물려받아도 말이 되는가.
Dvd는LibraryItem의 모든 동작을 가져도 말이 된다. 하지만 "50% 감면되는 도서"는 도서의 한 종류라기보다 도서 대출에 감면이 붙은 것이다. 후자면 조합이다.
실무에서 자주 틀리는 것
1. 생성자 안에서 재정의 가능한 메서드를 부른다
이게 초보자가 가장 크게 데는 자리다. 부모 생성자가 실행되는 시점에 자식 필드는 아직 초기화되지 않았다.
class Item {
protected final String title;
Item(String title) {
this.title = title;
printLabel(); // 위험: 자식이 재정의한 printLabel() 이 불린다
}
void printLabel() { System.out.println("자료: " + title); }
}
class DvdItem extends Item {
private final int discs;
DvdItem(String title, int discs) {
super(title); // 여기서 printLabel() 이 이미 실행된다
this.discs = discs; // discs 는 그 다음에 채워진다
}
@Override void printLabel() { System.out.println("DVD: " + title + " (" + discs + "장)"); }
}
new DvdItem("매트릭스", 2);
DVD: 매트릭스 (0장)
final 필드인데도 0 이 찍힌다. 참조형이었다면 null 이 되어 NullPointerException 이 났을 것이고, 숫자형이면 이렇게 0으로 계산이 조용히 진행된다. 라벨 출력이면 다행이지만 연체료 계산이었다면 요금이 0원으로 나간다. 규칙은 하나다. 생성자에서는 private, static, final 메서드만 호출한다. 초기화 후처리가 필요하면 생성자가 아니라 별도의 팩토리 메서드에서 객체를 다 만든 다음에 호출한다.
2. equals 를 오버로딩해놓고 오버라이딩한 줄 안다
// 틀린 코드 — @Override 가 없어서 컴파일은 통과한다
public boolean equals(Book other) { // 매개변수가 Object 가 아니다
return this.isbn.equals(other.isbn);
}
Object.equals 의 시그니처는 equals(Object) 다. 위 코드는 오버라이딩이 아니라 그냥 새로 만든 메서드다. 그래서 List.contains(), HashSet, Map 의 키 비교는 전부 Object.equals(즉 참조 비교)를 타고, "분명 같은 책인데 contains 가 false"가 된다. @Override 를 붙였다면 컴파일러가 즉시 막았다. 12단원에서 이 문제를 컬렉션 관점으로 다시 본다.
3. 필드는 오버라이딩되지 않는다
class Parent { String label = "parent"; String name() { return "Parent"; } }
class Child extends Parent { String label = "child"; @Override String name() { return "Child"; } }
Parent p = new Child();
System.out.println(p.label + " / " + p.name());
parent / Child
메서드는 실제 객체를 따라가고, 필드는 참조 변수의 타입을 따라간다. 부모와 자식에 같은 이름의 필드를 두면 이렇게 갈라진다. 애초에 같은 이름의 필드를 양쪽에 두지 않는 것이 답이고, 부모 필드는 private 으로 막고 접근자 메서드로만 열어두면 이 상황 자체가 생기지 않는다.
4. 코드 재사용이 편하다는 이유만으로 상속한다
ArrayList 를 상속해서 "추가된 횟수를 세는 대출 목록"을 만들었다고 하자. add() 와 addAll() 을 둘 다 재정의해 카운트를 올리면, addAll() 내부가 add() 를 부르는 구현이었을 때 3건을 넣었는데 카운트가 6이 된다. 부모의 내부 구현에 자식이 의존하게 되는 것인데, 그 내부 구현은 라이브러리 버전이 올라가면 예고 없이 바뀐다. 이런 경우는 상속 대신 List 를 필드로 가지고 필요한 메서드만 위임한다.
스스로 확인하기
LibraryItem을 상속한AudioBook을 만든다. 대출 기간 21일, 하루 연체료 50원,type()은"오디오북"을 반환한다. 기존LoanDeskDemo의for문은 한 글자도 고치지 않아야 한다.- 다음 코드는 컴파일되지 않는다. 이유를 말하고 고쳐라.
class LibraryItem { public long dailyFee() { return 100; } } class Dvd extends LibraryItem { @Override protected long dailyFee() { return 500; } } - "대출 건에 반납 예정일이 있다"와 "DVD는 자료다" 중 상속으로 표현할 것과 조합으로 표현할 것을 각각 고르고, 그렇게 판단한 근거를 한 문장으로 써라.
정답
class AudioBook extends LibraryItem으로 선언하고 생성자 첫 줄에super(isbn, title)을 호출한다.loanDays(),dailyFee(),type()에@Override를 붙여 재정의한다.List.of(...)에 인스턴스를 하나 추가하는 것 외에 반복문은 그대로 동작한다. 이것이 다형성으로 얻는 것이다.- 오버라이딩은 부모보다 접근 범위를 좁힐 수 없다. 부모가
public이므로 자식도public이어야 한다. 좁힐 수 있다면LibraryItem타입으로 업캐스팅한 뒤dailyFee()를 부르는 코드가 접근 불가능한 메서드를 호출하게 되어 규칙이 깨진다. - "DVD는 자료다"가 상속, "대출 건에 반납 예정일이 있다"가 조합(
Loan이 날짜 필드를 가진다). 판단 근거는 반납 예정일이 대출의 한 종류가 아니라 대출의 구성 요소이기 때문이다. 참고로 반납 예정일 계산 규칙이 자료 종류마다 다르다면 그 규칙 자체를DueDatePolicy로 뽑아 조합하는 편이 나중에 "장기 대출", "연장 대출"이 추가될 때 유리하다.