이 글은 자바 상속 연습문제 - 음료 클래스로 extends, 오버라이딩, super 복습하기의 풀이입니다.

먼저 문제를 직접 풀어보고, 막히는 부분이 있을 때 풀이를 확인하는 편이 좋습니다. 정답 코드는 한 가지 방식만 있는 것은 아니지만, 여기서는 상속의 핵심 흐름이 잘 보이도록 단순한 구조로 작성합니다.


문제 1 풀이 - 공통 기능은 부모 클래스에 둡니다

문제 1의 핵심은 CoffeeTea가 공통으로 가지는 부분을 찾는 것입니다. 두 클래스 모두 음료이므로 이름과 가격을 가지고, 음료 정보를 출력할 수 있습니다.

또한 addIce()는 모든 음료가 공통으로 사용할 기능입니다. 그래서 이 기능은 자식 클래스가 아니라 부모 클래스인 Beverage에 두는 편이 자연스럽습니다.

코드

class Beverage {
    String name;
    int price;

    Beverage(String name, int price) {
        this.name = name;
        this.price = price;
    }

    void info() {
        System.out.println(name + " - " + price + "원");
    }

    void addIce() {
        System.out.println("얼음 추가!");
    }
}

class Coffee extends Beverage {
    int shot;

    Coffee(String name, int price, int shot) {
        super(name, price);
        this.shot = shot;
    }
}

class Tea extends Beverage {
    String teaType;

    Tea(String name, int price, String teaType) {
        super(name, price);
        this.teaType = teaType;
    }
}

public class Main {
    public static void main(String[] args) {
        Coffee coffee = new Coffee("아메리카노", 4500, 2);
        Tea tea = new Tea("자스민차", 5000, "자스민");

        coffee.info();
        coffee.addIce();

        tea.info();
        tea.addIce();
    }
}

실행 예시

아메리카노 - 4500원
얼음 추가!
자스민차 - 5000원
얼음 추가!

코드 해설

CoffeeTeaBeverage를 상속합니다. 그래서 Beverage에 있는 name, price, info(), addIce()를 사용할 수 있습니다.

자식 생성자에서는 부모 생성자를 먼저 호출해야 합니다. 이때 사용하는 문법이 super(name, price)입니다.

Coffee(String name, int price, int shot) {
    super(name, price);
    this.shot = shot;
}

super(name, price)는 부모 클래스의 생성자를 호출해서 부모 쪽 필드를 먼저 초기화합니다. 그다음 자식 클래스에서 추가한 shot 값을 초기화합니다.


문제 2 풀이 - private 필드는 직접 접근하지 않습니다

문제 2에서는 priceprivate으로 바꿉니다. 그러면 자식 클래스에서도 price에 직접 접근할 수 없습니다.

그래서 부모 클래스에 protected 메서드인 getPrice()를 만들고, 자식 클래스는 이 메서드를 통해 가격을 가져옵니다.

또한 CoffeeTea는 각자 다른 방식으로 info()를 출력해야 하므로 메서드 오버라이딩을 사용합니다.

코드

class Beverage {
    protected String name;
    private int price;

    Beverage(String name, int price) {
        this.name = name;
        this.price = price;
    }

    void info() {
        System.out.println(name + " - " + price + "원");
    }

    protected int getPrice() {
        return price;
    }

    protected int discount() {
        return price;
    }
}

class Coffee extends Beverage {
    private int shot;

    Coffee(String name, int price, int shot) {
        super(name, price);
        this.shot = shot;
    }

    @Override
    void info() {
        System.out.println(name + " - " + getPrice() + "원 (샷 " + shot + "개)");
    }

    @Override
    protected int discount() {
        return (int) (getPrice() * 0.9);
    }
}

class Tea extends Beverage {
    private String teaType;

    Tea(String name, int price, String teaType) {
        super(name, price);
        this.teaType = teaType;
    }

    @Override
    void info() {
        System.out.println(name + " - " + getPrice() + "원 (" + teaType + ")");
    }
}

public class Main {
    public static void main(String[] args) {
        Coffee coffee = new Coffee("아메리카노", 4500, 2);
        Tea tea = new Tea("자스민차", 5000, "자스민");

        coffee.info();
        System.out.println("커피 할인가: " + coffee.discount());

        tea.info();
        System.out.println("차 할인가: " + tea.discount());
    }
}

실행 예시

아메리카노 - 4500원 (샷 2개)
커피 할인가: 4050
자스민차 - 5000원 (자스민)
차 할인가: 5000

코드 해설

priceprivate이므로 아래처럼 자식 클래스에서 직접 사용할 수 없습니다.

// System.out.println(price); // 자식 클래스에서도 직접 접근할 수 없습니다.

대신 부모 클래스가 제공하는 getPrice()를 사용합니다.

protected int getPrice() {
    return price;
}

protected는 같은 패키지나 상속 관계에서 접근할 수 있습니다. 그래서 자식 클래스인 Coffee, TeagetPrice()를 호출할 수 있습니다.

Coffee는 할인 정책이 다르기 때문에 discount()를 오버라이딩합니다. 반면 Tea는 오버라이딩하지 않았으므로 부모 클래스의 discount()가 그대로 실행됩니다.

@Override
protected int discount() {
    return (int) (getPrice() * 0.9);
}

@Override를 붙이면 오버라이딩이 제대로 되었는지 컴파일러가 확인해줍니다. 메서드 이름이나 매개변수를 잘못 쓰면 오류로 알려주기 때문에 실수를 줄일 수 있습니다.


문제 3 풀이 - 부모 생성자가 먼저 실행됩니다

문제 3은 상속 단계가 3단계입니다. Latte 객체를 만들지만, 실제 생성자 실행은 가장 위 부모인 Beverage부터 시작합니다.

이 흐름을 확인하기 위해 각 생성자에서 출력문을 넣습니다.

코드

class Beverage {
    private String name;
    private int price;

    Beverage(String name, int price) {
        this.name = name;
        this.price = price;
        System.out.println("Beverage 생성");
    }

    final void info() {
        System.out.println(name + " - " + price + "원");
    }
}

class HotBeverage extends Beverage {
    private int temperature;

    HotBeverage(String name, int price, int temperature) {
        super(name, price);
        this.temperature = temperature;
        System.out.println("HotBeverage 생성, 온도: " + temperature + "도");
    }
}

class Latte extends HotBeverage {
    private String milkType;

    Latte(String name, int price, int temperature, String milkType) {
        super(name, price, temperature);
        this.milkType = milkType;
        System.out.println("Latte 생성, 우유: " + milkType);
    }
}

public class Main {
    public static void main(String[] args) {
        Latte latte = new Latte("바닐라라떼", 5500, 65, "오트밀크");
        latte.info();
    }
}

실행 예시

Beverage 생성
HotBeverage 생성, 온도: 65도
Latte 생성, 우유: 오트밀크
바닐라라떼 - 5500원

코드 해설

Latte 객체를 만들면 Latte 생성자만 실행되는 것처럼 보일 수 있습니다. 하지만 자식 객체 안에는 부모 객체 부분도 함께 포함됩니다.

그래서 생성 순서는 아래처럼 진행됩니다.

new Latte(...)
→ Latte 생성자 진입
→ super(name, price, temperature)
→ HotBeverage 생성자 진입
→ super(name, price)
→ Beverage 생성자 실행
→ HotBeverage 생성자 실행
→ Latte 생성자 실행

출력은 실제 실행이 완료되는 순서대로 보입니다. 그래서 가장 위 부모인 Beverage 생성이 먼저 출력됩니다.

Beverageinfo()에는 final이 붙어 있습니다.

final void info() {
    System.out.println(name + " - " + price + "원");
}

이렇게 하면 자식 클래스에서 info()를 다시 정의할 수 없습니다. 아래 코드는 컴파일 오류가 납니다.

// class Latte extends HotBeverage {
//     @Override
//     void info() {
//         System.out.println("라떼 정보");
//     }
// }

final 메서드는 상속은 허용하지만, 메서드의 동작을 자식이 바꾸지 못하게 막습니다. 부모 클래스에서 반드시 유지해야 하는 규칙이 있을 때 사용할 수 있습니다.


실수하기 쉬운 부분

상속 연습문제를 풀 때는 아래 부분에서 자주 막힙니다.

  • super(...)는 생성자 첫 줄에 와야 합니다.
  • 부모 클래스에 기본 생성자가 없으면 자식 생성자에서 부모 생성자를 직접 호출해야 합니다.
  • private 필드는 자식 클래스에서도 직접 접근할 수 없습니다.
  • 오버라이딩할 때 메서드 이름, 매개변수, 반환 타입이 맞아야 합니다.
  • final 메서드는 오버라이딩할 수 없습니다.

특히 privateprotected 차이는 꼭 직접 코드로 확인해보는 편이 좋습니다. 상속 관계라고 해서 부모의 모든 멤버를 자식이 마음대로 사용할 수 있는 것은 아닙니다.


정리

이번 풀이의 핵심은 세 가지입니다.

첫째, 공통 기능은 부모 클래스에 둡니다. addIce()처럼 모든 자식이 사용할 기능은 부모에 두면 중복을 줄일 수 있습니다.

둘째, 자식마다 다르게 동작해야 하는 기능은 오버라이딩합니다. CoffeeTea는 같은 info()를 가지지만 출력 내용은 다르게 만들 수 있습니다.

셋째, 생성자는 부모부터 실행됩니다. Latte를 만들더라도 부모인 Beverage 생성자가 먼저 실행되고, 그다음 단계적으로 자식 생성자가 실행됩니다.

상속은 단순히 코드를 물려받는 문법이 아닙니다. 공통 개념을 부모로 올리고, 구체적인 차이는 자식에서 표현하는 방식입니다.