상속을 "재사용 수단"이 아니라 "설계 선택"으로 다시 본다. 부모가 골격을 고정하고 자식이 조각만 채우는 형태, 다형성과 패턴 매칭의 트레이드오프, sealed 클래스와 record의 선택 기준, equals 대칭성, 안전한 다운캐스팅을 다룬다.
예제 2의 toString은 부모 한 곳에 있었고 자식은 calculatePay만 바꿨다. 같은 원리로 toString을 final로 잠그고, 자식은 describe()를 오버라이딩하면서 super.describe()에 자기 몫만 덧붙인다. 3단계 이상 계층에서 출력 형식이 흐트러지지 않게 하는 방법이다.
class Vehicle {
protected final String plate;
Vehicle(String plate) { this.plate = plate; }
protected String describe() { return "번호=" + plate; }
@Override
public final String toString() { // 형식은 부모가 고정
return getClass().getSimpleName() + "(" + describe() + ")";
}
}
class Truck extends Vehicle {
private final int maxLoadKg;
Truck(String plate, int maxLoadKg) {
super(plate);
this.maxLoadKg = maxLoadKg;
}
@Override
protected String describe() { return super.describe() + ", 적재=" + maxLoadKg + "kg"; }
}
class RefrigeratedTruck extends Truck {
private final int minTemp;
RefrigeratedTruck(String plate, int maxLoadKg, int minTemp) {
super(plate, maxLoadKg);
this.minTemp = minTemp;
}
@Override
protected String describe() { return super.describe() + ", 냉장=" + minTemp + "도"; }
}
public class Main {
public static void main(String[] args) {
Vehicle[] fleet = {
new Vehicle("56다1111"),
new Truck("34나7890", 3000),
new RefrigeratedTruck("12가3456", 5000, -18)
};
for (Vehicle v : fleet) System.out.println(v);
// 출력:
// Vehicle(번호=56다1111)
// Truck(번호=34나7890, 적재=3000kg)
// RefrigeratedTruck(번호=12가3456, 적재=5000kg, 냉장=-18도)
}
}같은 급여 계산을 두 방식으로 쓴다. 오버라이딩은 새 타입(인턴, 계약직)을 추가할 때 클래스 하나만 만들면 되지만, 새 연산(배지, 보고서)을 추가하려면 모든 클래스를 고쳐야 한다. 패턴 매칭 switch는 정반대다. 타입이 늘어날 계층이면 오버라이딩, 연산이 늘어날 계층이면 switch를 택한다.
import java.util.List;
abstract class Employee {
final String name;
final long base;
Employee(String name, long base) {
this.name = name;
this.base = base;
}
abstract long pay(); // 방식 A: 타입마다 자기 계산
}
class Manager extends Employee {
final long bonus;
Manager(String name, long base, long bonus) { super(name, base); this.bonus = bonus; }
@Override long pay() { return base + bonus; }
}
class Engineer extends Employee {
final int overtime;
Engineer(String name, long base, int overtime) { super(name, base); this.overtime = overtime; }
@Override long pay() { return base + overtime * 30_000L; }
}
public class Main {
static long total(List<Employee> staff) { // A: 타입을 몰라도 됨
long t = 0;
for (Employee e : staff) t += e.pay();
return t;
}
static String badge(Employee e) { // 방식 B: 연산 하나를 한 곳에 모음
return switch (e) {
case Manager m when m.bonus >= 1_000_000 -> "★ " + m.name;
case Manager m -> "M " + m.name;
case Engineer en -> "E " + en.name;
default -> "? " + e.name; // 새 타입이 생기면 여기로 빠짐 — 놓치기 쉬움
};
}
public static void main(String[] args) {
List<Employee> staff = List.of(
new Manager("이팀장", 5_000_000, 1_000_000),
new Engineer("박개발", 4_000_000, 10),
new Manager("최팀장", 4_500_000, 500_000)
);
System.out.printf("총 인건비: %,d원%n", total(staff));
staff.forEach(e -> System.out.println(badge(e)));
// 출력:
// 총 인건비: 15,300,000원
// ★ 이팀장
// E 박개발
// M 최팀장
}
}예제 3은 sealed interface + record였다. record는 다른 클래스를 상속할 수 없으므로, 하위 타입들이 공통 필드와 생성자 검증을 공유해야 하면 sealed abstract class를 쓴다. 완전성 검사가 되는 switch는 그대로 쓸 수 있다.
import java.util.List;
sealed abstract class Shape permits Circle, Rect {
final String label;
Shape(String label) {
if (label == null || label.isBlank()) throw new IllegalArgumentException("label 필수");
this.label = label; // 공통 필드 + 검증을 한 곳에
}
abstract double area();
}
final class Circle extends Shape {
final double r;
Circle(String label, double r) { super(label); this.r = r; }
@Override double area() { return Math.PI * r * r; }
}
final class Rect extends Shape {
final double w, h;
Rect(String label, double w, double h) { super(label); this.w = w; this.h = h; }
@Override double area() { return w * h; }
}
public class Main {
static String describe(Shape s) {
return switch (s) { // default 없음: permits 목록으로 완전성 검사
case Circle c -> c.label + " 원 r=" + c.r;
case Rect r when r.w == r.h -> r.label + " 정사각형 " + r.w;
case Rect r -> r.label + " 직사각형 " + r.w + "x" + r.h;
};
}
public static void main(String[] args) {
List<Shape> shapes = List.of(new Circle("A", 1), new Rect("B", 2, 2), new Rect("C", 2, 3));
for (Shape s : shapes) {
System.out.printf("%s (넓이 %.2f)%n", describe(s), s.area());
}
// 출력:
// A 원 r=1.0 (넓이 3.14)
// B 정사각형 2.0 (넓이 4.00)
// C 직사각형 2.0x3.0 (넓이 6.00)
try {
new Circle(" ", 1);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // 출력: label 필수
}
}
}예제 4는 단일 클래스의 equals였다. 부모 equals가 instanceof를 쓰고 자식이 필드를 추가하면 parent.equals(child)는 true인데 child.equals(parent)는 false가 되어 규약(대칭성)이 깨진다. getClass() 비교로 막거나, 애초에 값 클래스는 상속하지 않는(컴포지션) 것이 정석이다.
import java.util.Objects;
class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
return o instanceof Point p && x == p.x && y == p.y; // 하위 타입도 통과
}
@Override
public int hashCode() { return Objects.hash(x, y); }
}
class ColorPoint extends Point {
final String color;
ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
@Override
public boolean equals(Object o) {
return o instanceof ColorPoint cp && super.equals(o) && color.equals(cp.color);
}
@Override
public int hashCode() { return Objects.hash(super.hashCode(), color); }
}
class StrictPoint {
final int x, y;
StrictPoint(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false; // 정확히 같은 클래스만
StrictPoint p = (StrictPoint) o;
return x == p.x && y == p.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
}
class StrictColorPoint extends StrictPoint {
final String color;
StrictColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
}
public class Main {
public static void main(String[] args) {
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "red");
System.out.println(p.equals(cp) + " " + cp.equals(p)); // 출력: true false (대칭성 위반)
StrictPoint sp = new StrictPoint(1, 2);
StrictColorPoint scp = new StrictColorPoint(1, 2, "red");
System.out.println(sp.equals(scp) + " " + scp.equals(sp)); // 출력: false false (대칭성 유지)
System.out.println(sp.equals(new StrictPoint(1, 2))); // 출력: true
}
}getClass() 방식은 대칭성은 지키지만 "리스코프 치환"을 희생한다(자식은 부모 자리에서 같다고 인정받지 못함). 값 객체에 상속이 필요해지면 대개 record + 컴포지션으로 풀리는지 먼저 검토한다.
부모 타입 변수에서 자식 고유 필드가 필요할 때 강제 캐스팅 대신 instanceof 패턴을 쓴다. 검사와 변수 바인딩이 한 번에 끝나고, null은 항상 false라 별도 검사가 필요 없다. 강제 캐스팅은 컴파일은 통과하지만 실제 객체가 다르면 ClassCastException으로 죽는다.
class Payment {
final long amount;
Payment(long amount) { this.amount = amount; }
}
class CardPayment extends Payment {
final String cardNo;
CardPayment(long amount, String cardNo) { super(amount); this.cardNo = cardNo; }
}
class CashPayment extends Payment {
CashPayment(long amount) { super(amount); }
}
public class Main {
static String cardTail(Payment p) {
if (p instanceof CardPayment card) { // 검사 + 바인딩
return card.cardNo.substring(card.cardNo.length() - 4);
}
return "카드 아님";
}
public static void main(String[] args) {
Payment a = new CardPayment(10_000, "1234-5678-9012-3456");
Payment b = new CashPayment(5_000);
Payment c = null;
System.out.println(cardTail(a)); // 출력: 3456
System.out.println(cardTail(b)); // 출력: 카드 아님
System.out.println(cardTail(c)); // 출력: 카드 아님 (null instanceof는 항상 false)
try {
CardPayment forced = (CardPayment) b; // 컴파일 OK (부모→자식 캐스팅은 허용)
System.out.println(forced.cardNo);
} catch (ClassCastException e) {
System.out.println("CCE: CashPayment는 CardPayment가 아님"); // 출력: CCE: CashPayment는 CardPayment가 아님
}
CardPayment nothing = (CardPayment) c; // null 캐스팅은 예외 없음
System.out.println(nothing == null); // 출력: true
}
}