이 글은 자바 상속 연습문제 - 음료 클래스로 extends, 오버라이딩, super 복습하기의 풀이입니다.
먼저 문제를 직접 풀어보고, 막히는 부분이 있을 때 풀이를 확인하는 편이 좋습니다. 정답 코드는 한 가지 방식만 있는 것은 아니지만, 여기서는 상속의 핵심 흐름이 잘 보이도록 단순한 구조로 작성합니다.
문제 1 풀이 - 공통 기능은 부모 클래스에 둡니다
문제 1의 핵심은 Coffee와 Tea가 공통으로 가지는 부분을 찾는 것입니다.
두 클래스 모두 음료이므로 이름과 가격을 가지고, 음료 정보를 출력할 수 있습니다.
또한 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원
얼음 추가!
코드 해설
Coffee와 Tea는 Beverage를 상속합니다.
그래서 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에서는 price를 private으로 바꿉니다.
그러면 자식 클래스에서도 price에 직접 접근할 수 없습니다.
그래서 부모 클래스에 protected 메서드인 getPrice()를 만들고, 자식 클래스는 이 메서드를 통해 가격을 가져옵니다.
또한 Coffee와 Tea는 각자 다른 방식으로 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
코드 해설
price는 private이므로 아래처럼 자식 클래스에서 직접 사용할 수 없습니다.
// System.out.println(price); // 자식 클래스에서도 직접 접근할 수 없습니다.
대신 부모 클래스가 제공하는 getPrice()를 사용합니다.
protected int getPrice() {
return price;
}
protected는 같은 패키지나 상속 관계에서 접근할 수 있습니다.
그래서 자식 클래스인 Coffee, Tea는 getPrice()를 호출할 수 있습니다.
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 생성이 먼저 출력됩니다.
Beverage의 info()에는 final이 붙어 있습니다.
final void info() {
System.out.println(name + " - " + price + "원");
}
이렇게 하면 자식 클래스에서 info()를 다시 정의할 수 없습니다.
아래 코드는 컴파일 오류가 납니다.
// class Latte extends HotBeverage {
// @Override
// void info() {
// System.out.println("라떼 정보");
// }
// }
final 메서드는 상속은 허용하지만, 메서드의 동작을 자식이 바꾸지 못하게 막습니다.
부모 클래스에서 반드시 유지해야 하는 규칙이 있을 때 사용할 수 있습니다.
실수하기 쉬운 부분
상속 연습문제를 풀 때는 아래 부분에서 자주 막힙니다.
super(...)는 생성자 첫 줄에 와야 합니다.- 부모 클래스에 기본 생성자가 없으면 자식 생성자에서 부모 생성자를 직접 호출해야 합니다.
private필드는 자식 클래스에서도 직접 접근할 수 없습니다.- 오버라이딩할 때 메서드 이름, 매개변수, 반환 타입이 맞아야 합니다.
final메서드는 오버라이딩할 수 없습니다.
특히 private과 protected 차이는 꼭 직접 코드로 확인해보는 편이 좋습니다.
상속 관계라고 해서 부모의 모든 멤버를 자식이 마음대로 사용할 수 있는 것은 아닙니다.
정리
이번 풀이의 핵심은 세 가지입니다.
첫째, 공통 기능은 부모 클래스에 둡니다.
addIce()처럼 모든 자식이 사용할 기능은 부모에 두면 중복을 줄일 수 있습니다.
둘째, 자식마다 다르게 동작해야 하는 기능은 오버라이딩합니다.
Coffee와 Tea는 같은 info()를 가지지만 출력 내용은 다르게 만들 수 있습니다.
셋째, 생성자는 부모부터 실행됩니다.
Latte를 만들더라도 부모인 Beverage 생성자가 먼저 실행되고, 그다음 단계적으로 자식 생성자가 실행됩니다.
상속은 단순히 코드를 물려받는 문법이 아닙니다. 공통 개념을 부모로 올리고, 구체적인 차이는 자식에서 표현하는 방식입니다.