공공부하자개발 · 영어 학습 노트
자바
중급객체지향과 코어 라이브러리0/9 완료
  • 01상속, 다형성, 오버라이딩
  • 02추상 클래스 vs 인터페이스
  • 03예외 처리
  • 04java.lang 심화
  • 05컬렉션 프레임워크 딥다이브
  • 06메서드 활용 패턴 (중급)
  • 07Object 메서드와 비교
  • 08java.time 실무 날짜 계산
  • 09HTTP 와 JSON 기초
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 중급 › 01 / 9

상속, 다형성, 오버라이딩

섹션 7진행 0 / 9
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

상속을 "재사용 수단"이 아니라 "설계 선택"으로 다시 본다. 부모가 골격을 고정하고 자식이 조각만 채우는 형태, 다형성과 패턴 매칭의 트레이드오프, sealed 클래스와 record의 선택 기준, equals 대칭성, 안전한 다운캐스팅을 다룬다.

변형 1: toString은 부모가 고정하고 describe만 확장하기

예제 2의 toString은 부모 한 곳에 있었고 자식은 calculatePay만 바꿨다. 같은 원리로 toString을 final로 잠그고, 자식은 describe()를 오버라이딩하면서 super.describe()에 자기 몫만 덧붙인다. 3단계 이상 계층에서 출력 형식이 흐트러지지 않게 하는 방법이다.

java
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도)
    }
}

변형 2: 오버라이딩 vs 패턴 매칭 — 무엇이 자주 늘어나는가

같은 급여 계산을 두 방식으로 쓴다. 오버라이딩은 새 타입(인턴, 계약직)을 추가할 때 클래스 하나만 만들면 되지만, 새 연산(배지, 보고서)을 추가하려면 모든 클래스를 고쳐야 한다. 패턴 매칭 switch는 정반대다. 타입이 늘어날 계층이면 오버라이딩, 연산이 늘어날 계층이면 switch를 택한다.

java
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 추상 클래스 — 공통 필드가 있을 때

예제 3은 sealed interface + record였다. record는 다른 클래스를 상속할 수 없으므로, 하위 타입들이 공통 필드와 생성자 검증을 공유해야 하면 sealed abstract class를 쓴다. 완전성 검사가 되는 switch는 그대로 쓸 수 있다.

java
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 대칭성 — 자식이 필드를 추가하면 깨진다

예제 4는 단일 클래스의 equals였다. 부모 equals가 instanceof를 쓰고 자식이 필드를 추가하면 parent.equals(child)는 true인데 child.equals(parent)는 false가 되어 규약(대칭성)이 깨진다. getClass() 비교로 막거나, 애초에 값 클래스는 상속하지 않는(컴포지션) 것이 정석이다.

java
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 + 컴포지션으로 풀리는지 먼저 검토한다.

변형 5: 안전한 다운캐스팅 — instanceof 패턴, null, ClassCastException

부모 타입 변수에서 자식 고유 필드가 필요할 때 강제 캐스팅 대신 instanceof 패턴을 쓴다. 검사와 변수 바인딩이 한 번에 끝나고, null은 항상 false라 별도 검사가 필요 없다. 강제 캐스팅은 컴파일은 통과하지만 실제 객체가 다르면 ClassCastException으로 죽는다.

java
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
    }
}
응용 변형 예제
  • 변형 1: toString은 부모가 고정하고 describe만 확장하기
  • 변형 2: 오버라이딩 vs 패턴 매칭 — 무엇이 자주 늘어나는가
  • 변형 3: sealed 추상 클래스 — 공통 필드가 있을 때
  • 변형 4: equals 대칭성 — 자식이 필드를 추가하면 깨진다
  • 변형 5: 안전한 다운캐스팅 — instanceof 패턴, null, ClassCastException
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)