먼저, 아래 간단한 코드를 살펴보자.
package extends1.ex1;
public class ElectricCar {
public void move() {
System.out.println("차를 이동합니다.");
}
public void charge() {
System.out.println("배터리 충전 중...");
}
}
package extends1.ex1;
public class GasCar {
public void move() {
System.out.println("차를 이동합니다.");
}
public void fillUp() {
System.out.println("주유 중...");
}
}
package extends1.ex1;
public class CarMain {
public static void main(String[] args) {
ElectricCar tesla = new ElectricCar();
tesla.move();
tesla.charge();
GasCar porsche = new GasCar();
porsche.move();
porsche.fillUp();
}
}
/*
차를 이동합니다.
배터리 충전 중...
차를 이동합니다.
주유 중...
*/
보다시피 전기차, 가솔린차 인스턴스를 각각 만들고, 각각의 인스턴스 메서드를 실행한 아주 간단한 코드다. 근데 생각해보면, 전기차와 가솔린차 위에는 “자동차” 라는 추상적인 개념이 존재한다. 전기차와 가솔린차는 “자동차” 의 좀 더 구체적인 개념이다. 그래서 코드를 보면, 이동하는 기능을 공통적으로 가지고 있다. 이런 경우에 “상속 관계” 를 사용하면 효과적이다.
상속은 객체 지향 프로그래밍의 핵심 요소 중 하나로, "기존 클래스의 필드나 메서드를 새로운 클래스에서 재사용할 수 있게 해주는 것" 이다. 말 그대로 기존 클래스의 속성과 기능을 자식이 물려받는 것이다. 상속 기능을 사용하려면 extends 키워드를 사용하면 된다. 다만, 여기서 주의할 점은 extends 대상, 즉 상속 받을 대상은 하나만 선택할 수 있다는 점이다.
<용어 정리>
package extends1.ex2;
// 부모 클래스
public class Car {
public void move() {
System.out.println("차를 이동합니다.");
}
}
package extends1.ex2;
// 자식 클래스1
public class ElectricCar extends Car {
public void charge() {
System.out.println("배터리 충전 중...");
}
}
package extends1.ex2;
// 자식 클래스2
public class GasCar extends Car {
public void fillUp() {
System.out.println("주유 중...");
}
}
package extends1.ex2;
public class CarMain {
public static void main(String[] args) {
ElectricCar tesla = new ElectricCar();
tesla.move();
tesla.charge();
GasCar porsche = new GasCar();
porsche.move();
porsche.fillUp();
}
}
/*
차를 이동합니다.
배터리 충전 중...
차를 이동합니다.
주유 중...
*/

이런 식으로 전기차와 가솔린차가 Car 클래스를 상속받으면 중복되는 코드를 줄일 수 있고, 자식 클래스 각각의 메서드도 사용할 수 있다. 말 그대로 자식 클래스는 부모 클래스의 기능을 물려 받기 때문에 접근 가능하지만, 반대로 부모 클래스는 자식 클래스에 접근할 수 없다.
참고로 자바는 다중 상속을 지원하지 않는다. 아까 extends의 대상, 즉 부모를 하나만 선택할 수 있다는 말이다. 만약 비행기와 자동차 클래스를 상속받아서 하늘을 날 수 있는 자동차를 만들고 싶다고 해보자.

위의 그림처럼 2명의 부모에게 상속을 받는다면, Airplane의 move() 메서드를 사용할지, Car의 move() 메서드를 사용할지 애매한 상황이 발생한다. 이를 “다이아몬드 문제” 라고 한다. 추가적으로, 다중 상속을 사용하면 클래스 계층 구조가 엄청 복잡해질 수 있다.
이 부분은 매우 매우 중요하다! 상속 관계를 객체로 생성할 때 메모리 구조는 어떻게 되는지 확인해보는 것이다.

Car 클래스를 상속받은 ElectricCar 클래스를 통해 인스턴스를 만드는 과정이다.
"
new ElectricCar()를 호출하면ElectricCar뿐만 아니라Car까지 포함해서 인스턴스를 생성한다!"
현재 그림처럼 참조값은 하나지만, 2가지 클래스의 정보가 공존하고 있는 것이다. 상속을 받는다고 해서 단순히 부모의 필드나 메서드만 물려 받는 개념이 아니다. 상속을 하게 되면, 인스턴스 안에 본인과 부모 둘 다 생기는 것이다!
그 다음으로 electricCar.charge()를 호출하면 어떻게 될까?

일단 참조값을 통해 메서드를 호출했으니, 그 참조값을 통해 인스턴스를 찾아간다. 근데 그 안에 본인과 부모가 같이 살고 있기 때문에 본인의 메서드를 사용할지, 부모의 메서드를 사용할지 선택해야 한다. 그럼 둘을 어떻게 구분할까?
“둘 중 하나를 선택하는 기준은 호출하는 변수의 타입 클래스를 기준으로 선택한다!”
현재 호출하는 변수(electricCar)의 타입은 ElectricCar이므로 인스턴스 내부에 같은 타입인 ElectricCar 안에 있는 charge() 메서드를 호출하는 것이다.
그리고 electricCar.move()를 호출하는 과정을 살펴보자. 얘도 호출하면 일단 참조값을 통해 인스턴스로 이동한다.

찾아가서 둘 중 하나를 선택해야 하는데, 현재 호출하는 변수의 타입이 ElectricCar이므로 ElectricCar를 쳐다볼 것이다. 근데… 보다시피 ElectricCar에는 move() 메서드가 없다. 이처럼 상속 관계에서 자식 타입에 해당 기능이 없으면 부모 타입으로 올라가서 찾는다. 부모인 Car에는 move() 메서드가 있으므로 부모에 있는 move() 메서드가 호출되는 것이다. 지금이야 바로 위에 있어서 다행이지만, 없으면 계속해서 타고 타고 올라간다. 마지막까지 올라갔는데도 없다면? 컴파일 오류가 발생한다.
핵심을 다시 한번 짚어보자.
Car 클래스에 문을 열 수 있는 openDoor() 기능을 추가하고, 수소차(HydrogenCar) 클래스와 수소를 충전하는 기능(fillHydrogen)을 추가해보자.
package extends1.ex3;
// 부모 클래스
public class Car {
public void move() {
System.out.println("차를 이동합니다.");
}
// 문 여는 기능 추가
public void openDoor() {
System.out.println("문을 엽니다.");
}
}
package extends1.ex3;
public class HydrogenCar extends Car {
public void fillHydrogen() {
System.out.println("수소를 충전합니다.");
}
}
package extends1.ex3;
public class CarMain {
public static void main(String[] args) {
ElectricCar electricCar = new ElectricCar();
electricCar.move();
electricCar.charge();
electricCar.openDoor();
GasCar gasCar = new GasCar();
gasCar.move();
gasCar.fillUp();
gasCar.openDoor();
HydrogenCar hydrogenCar = new HydrogenCar();
hydrogenCar.move();
hydrogenCar.fillHydrogen();
hydrogenCar.openDoor();
}
}
/*
차를 이동합니다.
배터리 충전 중...
문을 엽니다.
차를 이동합니다.
주유 중...
문을 엽니다.
차를 이동합니다.
수소를 충전합니다.
문을 엽니다.
*/

위의 코드처럼 상속을 이용해서 중복을 줄이고, 새로운 클래스에 대해 편리하게 확장할 수 있다는 것을 확인했다.
만약 부모의 기능과 비슷하지만, 조금 다르게 사용하고 싶다면 어떻게 할까? 예를 들어, Car 클래스의 move() 기능을 전기차에서는 다르게 출력하고 싶은 것이다. 이럴 때 "메서드 오버라이딩" 을 하면 된다. "상속 받을 기능을 자식이 재정의하는 것" 이다.
package extends1.ex2;
public class ElectricCar extends Car {
@Override
public void move() {
System.out.println("전기차를 빠르게 이동시킵니다.");
}
public void charge() {
System.out.println("배터리 충전 중...");
}
}
package extends1.overriding;
public class CarMain {
public static void main(String[] args) {
ElectricCar electricCar = new ElectricCar();
electricCar.move();
GasCar gasCar = new GasCar();
gasCar.move();
}
}
/*
전기차를 빠르게 이동시킵니다.
차를 이동합니다.
*/
이처럼 메서드 이름은 같지만, 부모 클래스(Car)의 기능을 자식 클래스(ElectricCar)에서 다르게 사용하고 싶을 때, 메서드 오버라이딩을 하는 것이다. 이렇게 하면, ElectricCar에서 move() 메서드를 호출했을 때, 더 이상 Car의 move() 메서드를 호출하지 않고, ElectricCar의 move() 메서드를 호출한다.
메서드 오버라이딩을 했을 때, 내부적으로 어떻게 동작하는지 알아보자.


일단 electricCar.move()를 호출하면 참조값을 통해 인스턴스를 찾아갈 것이다. 이때 호출한 electricCar의 타입이 ElectricCar이기 때문에 인스턴스 내부의 ElectricCar 부분을 먼저 뒤져본다. ElectricCar에서 move() 메서드를 찾았기 때문에 부모 타입을 찾지 않고 해당 메서드를 호출하는 것이다.
사실 메서드 오버라이딩은 상당히 까다로운 조건을 가지고 있다. 아래 조건들을 모두 만족해야 하지만, 지금은 참고만 하도록 하자.
메서드 이름: 메서드 이름이 같아야 한다.
메서드 매개변수(파라미터): 매개변수(파라미터) 타입, 순서, 개수가 같아야 한다.
반환 타입: “반환 타입이 같아야 한다.” 단 반환 타입이 하위 클래스 타입일 수 있다.
접근 제어자: 오버라이딩 메서드의 접근 제어자는 상위 클래스의 메서드보다 더 제한적이어서는 안된다. 예를 들어, 상위 클래스의 메서드가 protected 로 선언되어 있으면 하위 클래스에서 이를 public 또는 protected로 오버라이드할 수 있지만, private 또는 default로 오버라이드 할 수 없다.
예외: 오버라이딩 메서드는 상위 클래스의 메서드보다 더 많은 체크 예외를 throws로 선언할 수 없다. 하지만 더 적거나 같은 수의 예외, 또는 하위 타입의 예외는 선언할 수 있다.
static , final , private : 키워드가 붙은 메서드는 오버라이딩 될 수 없다.
static 은 클래스 레벨에서 작동하므로 인스턴스 레벨에서 사용하는 오버라이딩이 의미가 없다. 쉽게 이야기해서 그냥 클래스 이름을 통해 필요한 곳에 직접 접근하면 된다.final 메서드는 재정의를 금지한다.”private 메서드는 해당 클래스에서만 접근 가능하기 때문에 하위 클래스에서 보이지 않는다. 따라서 오버라이딩 할 수 없다.생성자 오버라이딩: 생성자는 오버라이딩 할 수 없다.
@이 붙은 부분을 “애노테이션” 이라고 한다. 주석과 비슷하지만, 프로그램이 읽을 수 있는 특별한 주석이라고 생각하면 된다. 오버라이딩한 메서드 위에 이 @Override 애노테이션을 붙이면 상위 클래스의 메서드를 오버라이드하는 것임을 나타낸다. 애노테이션을 생략해도 문제는 없지만, 관례상 붙이는 것을 매우 권장한다. 실수로 메서드명에 오타를 쳤다거나 한다면, 의도와는 다르게 부모 클래스의 메서드를 끌어 올 수도 있다. 이때 애노테이션을 붙여 놓았다면, 컴파일 오류를 터뜨려 빠르게 실수를 바로 잡을 수 있다.

메서드 오버로딩: 메서드 이름이 같고, 매개 변수가 다른 메서드를 여러 개를 정의하는 것이다. 직역하면 적재 용량 초과해서 물건을 담았다는 말이다.
메서드 오버라이딩: 하위 클래스에서 상위 클래스의 메서드를 재정의하는 것을 말한다. 직역하면 무언가를 넘어서 탄다는 건데, 자식의 새로운 기능이 부모의 기존 기능을 넘어 타서 기존 기능을 새로운 기능으로 덮어버린다고 생각하면 편하다.
일단 부모 클래스(Parent)와 자식 클래스(Child)를 각각의 패키지에 담아보자.
package extends1.access.parent;
public class Parent {
public int publicValue;
protected int protectedValue;
int defaultValue;
private int privateValue;
public void publicMethod() {
System.out.println("Parent.publicMethod");
}
protected void protectedMethod() {
System.out.println("Parent.protectedMethod");
}
void defaultMethod() {
System.out.println("Parent.defaultMethod");
}
private void privateMethod() {
System.out.println("Parent.privateMethod");
}
public void printParent() {
System.out.println("--Parent 메서드 안--");
System.out.println("publicValue = " + publicValue);
System.out.println("protectedValue = " + protectedValue);
System.out.println("defaultValue = " + defaultValue);
System.out.println("privateValue = " + privateValue);
// 부모 메서드 안에서 모두 접근 가능
defaultMethod();
privateMethod();
}
}
보다시피 부모 클래스(Parent)에는 public, default, protected, private 접근 제어자를 가진 필드와 메서드를 정의했다.
package extends1.access.child;
import extends1.access.parent.Parent;
public class Child extends Parent {
public void call() {
publicValue = 1;
protectedValue = 1; // 상속 관계 or 같은 패키지
// 'defaultValue' is not public in 'extends1.access.parent.Parent'. Cannot be accessed from outside package
// defaultValue = 1; // 다른 패키지 접근 불가, 컴파일 오류
// 'privateValue' has private access in 'extends1.access.parent.Parent'
// privateValue = 1; // 접근 불가, 컴파일 오류
publicMethod();
protectedMethod(); // 상속 관계 or 같은 패키지
// 'defaultMethod()' is not public in 'extends1.access.parent.Parent'. Cannot be accessed from outside package
// defaultMethod(); // 다른 패키지 접근 불가, 컴파일 오류
// 'privateMethod()' has private access in 'extends1.access.parent.Parent'
// privateMethod(); // 접근 불가, 컴파일 오류
printParent();
}
}
이제 자식 클래스(Child)에서 부모 클래스(Parent)의 어디까지 접근이 가능한지 확인해보자.
package extends1.access;
import extends1.access.child.Child;
public class ExtendsAccessMain {
public static void main(String[] args) {
Child child = new Child();
child.call();
}
}
/*
Parent.publicMethod
Parent.protectedMethod
--Parent 메서드 안--
publicValue = 1
protectedValue = 1
defaultValue = 0
privateValue = 0
Parent.defaultMethod
Parent.privateMethod
*/
결과를 확인해보면, public 접근 제어자가 붙은 필드와 메서드는 잘 호출되는 것을 확인할 수 있고, 현재 패키지는 다르지만 상속 관계에 있기 때문에 protected 접근 제어자가 붙은 경우에도 호출되는 것을 확인할 수 있다. default가 붙은 필드와 메서드는 현재 패키지가 다르기 때문에 접근이 불가능하고, private은 당연히 불가능하다. 반면, Parent.printParent()의 경우, Parent 안에 있는 메서드이기 때문에 얼마든지 호출이 가능하다.

접근 과정을 보면, 본인 타입에 없으면 부모 타입을 찾는데, 이때 접근 제어자가 영향을 준다. 왜냐하면 객체 내부에 부모와 자식이 구분되어 있기 때문이다. 따라서 자식 타입에서 부모 타입의 기능을 호출할 때, 부모 입장에서 보면 외부에서 호출한 것과 다름이 없다.
부모와 자식의 필드명이 같거나 메서드가 오버라이딩 되어 있으면, 자식 클래스에서 찾아서 바로 호출해버리기 때문에 부모 클래스의 필드나 메서드를 호출할 수 없다. 이때 super라는 키워드를 사용하면 부모를 참조할 수 있다. 아래 예제를 보자.
부모의 필드명과 자식의 필드명이 value로 똑같다. 메서드도 hello()로 자식에서 오버라이딩 되어 있는 것을 볼 수 있다. 이때 자식 클래스(Child)에서 부모 클래스(Parent)의 value 필드나 hello() 메서드를 호출하고 싶으면 super 키워드를 사용하면 된다는 것이다.
package extends1.super1;
public class Parent {
public String value = "parent";
public void hello() {
System.out.println("Parent.hello");
}
}
package extends1.super1;
public class Child extends Parent {
public String value = "child";
@Override
public void hello() {
System.out.println("Child.hello");
}
public void call() {
System.out.println("this value = " + this.value); // this 생략 가능
System.out.println("super value = " + super.value);
this.hello(); // this 생략 가능
super.hello();
}
}
package extends1.super1;
public class Super1Main {
public static void main(String[] args) {
Child child = new Child();
child.call();
}
}
/*
this value = child
super value = parent
Child.hello
Parent.hello
*/
결과를 살펴보면, child.call()를 호출하면 본인의 value와 부모의 value(super.value)를 호출하는 것을 볼 수 있다. 메서드도 마찬가지다.

상속 관계의 인스턴스를 생성하고 내부 메모리 구조를 살펴보면, 자식 클래스와 부모 클래스가 각각 만들어지는 것을 볼 수 있었다. 자식 인스턴스를 만들 때, 부모 클래스의 정보도 함께 끌려오기 때문에 각각의 생성자를 호출해줘야 할 필요가 있다. 기억하자…
“상속 관계를 사용하면 자식 클래스의 생성자에서 부모 클래스의 생성자도 반드시 호출해줘야 한다!”
어떻게 호출하는지 바로 예제 코드를 살펴보자.
package extends1.super2;
public class ClassA {
public ClassA() {
System.out.println("ClassA 생성자");
}
}
package extends1.super2;
public class ClassB extends ClassA {
public ClassB(int a) {
super(); // 기본 생성자 생략 가능
System.out.println("ClassB 생성자 a=" + a);
}
public ClassB(int a, int b) {
super(); // 기본 생성자 생략 가능
System.out.println("ClassB 생성자 a=" + a + " b=" + b);
}
}
package extends1.super2;
public class ClassC extends ClassB {
public ClassC() {
super(10, 20); // 부모에 기본 생성자가 없으므로 직접 정의
System.out.println("ClassC 생성자");
}
}
package extends1.super2;
public class Super2Main {
public static void main(String[] args) {
ClassC classC = new ClassC();
}
}
/*
ClassA 생성자
ClassB 생성자 a=10 b=20
ClassC 생성자
*/
일단 위의 코드를 보면, ClassA가 최상위 클래스이고, 매개 변수가 없는 기본 생성자가 있다. 그리고 ClassB가 ClassA를 상속 받았으므로 첫 줄에 부모 클래스(ClassA)의 생성자를 호출해야 하지만, 부모 클래스의 생성자가 기본 생성자이기 때문에 super() 호출을 생략할 수 있다.
근데 이제 ClassC는 ClassB를 상속받았다. 보다시피 ClassB에는 2개의 생성자가 있는데 2개 중에 딱 하나만 호출해야 한다. super(10, 20)을 통해 생성자를 호출했다.
실행해보면, ClassA → ClassB → ClassC 순으로 실행된다. 최상위 부모부터 실행해서 아래로 내려오는 것이다. 왜냐하면 자식 생성자의 첫 줄에서 부모의 생성자를 호출해야 하기 때문이다. 아래 그림을 보고 확실히 이해하자.

new ClassC()를 통해 ClassC 인스턴스를 생성하면 ClassC() 의 생성자가 먼저 호출되는 것이 맞다. 하지만 ClassC()의 생성자는 가장 먼저 super(...)를 통해 ClassB(...)의 생성자를 호출한다. ClassB()의 생성자도 부모인 ClassA()의 생성자를 가장 먼저 호출한다.
ClassA() 의 생성자는 최상위 부모이다. 생성자 코드를 실행하면서 "ClassA 생성자"를 출력한다. ClassA() 생성자 호출이 끝나면 ClassA()를 호출한 ClassB(...) 생성자로 제어권이 돌아간다. ClassB(...) 생성자가 코드를 실행하면서 "ClassB 생성자 a=10 b=20"를 출력한다. 생성자 호출이 끝나면 ClassB(...)를 호출한 ClassC()의 생성자로 제어권이 돌아간다. ClassC()가 마지막으로 생성자 코드를 실행하면서 "ClassC 생성자"를 출력하는 것이다.
최종적으로 다시 한번 정리하자면,
상속 관계의 생성자 호출은 결과적으로 부모에서 자식 순서로 실행된다. 따라서 부모의 데이터를 먼저 초기화하고, 그 다음에 자식의 데이터를 초기화한다.
상속 관계에서 자식 클래스의 생성자 첫 줄에 반드시 super(...)를 호출해야 한다. 단 기본 생성자(super())인 경우에는 생략 가능하다.
예외 사항이 하나 있는데, 생성자 첫 줄에는 this(...)를 사용할 수 있지만, 그 생성자 안에서 언젠가는 super(...)를 반드시 호출해줘야 한다는 점이다.
Book, Album, Movie 총 3가지 상품을 클래스로 만들어야 한다. 코드 중복이 없도록 상속을 이용하고, 부모 클래스는 Item이라는 이름을 사용하자.
공통 속성: name, price
Book : 저자(author), isbn(isbn)
Album: 아티스트(artist)
Movie: 감독(director), 배우(actor)
package extends1.ex;
public class ShopMain {
public static void main(String[] args) {
Book book = new Book("헤드퍼스트 자바", 37800, "캐시 시에라", "12345");
Album album = new Album("Queen-Greatest Hits", 72900, "queen");
Movie movie = new Movie("해리포터 죽음의 성물 part.2", 70000, "데이빗 예이츠", "다니엘 레드클리프");
book.print();
album.print();
movie.print();
int sum = book.getPrice() + album.getPrice() + movie.getPrice();
System.out.println("상품 가격의 합: " + sum);
}
}
/*
이름: 헤드퍼스트 자바, 가격: 37800
- 저자: 캐시 시에라, isbn: 12345
이름: Queen-Greatest Hits, 가격: 72900
- 아티스트: queen
이름: 해리포터 죽음의 성물 part.2, 가격: 70000
- 감독: 데이빗 예이츠, 배우: 다니엘 레드클리프
상품 가격의 합: 180700
*/
// 내가 푼 풀이
package extends1.ex;
public class Item {
private String name;
private int price;
public Item(String name, int price) {
this.name = name;
this.price = price;
}
protected void print() {
System.out.println("이름: " + this.name + ", 가격: " + this.price);
}
protected int getPrice() {
return price;
}
}
package extends1.ex;
public class Book extends Item {
private String author;
private String isbn;
public Book(String name, int price, String author, String isbn) {
super(name, price);
this.author = author;
this.isbn = isbn;
}
@Override
public void print() {
super.print();
System.out.println("- 저자: " + author + ", isbn: " + isbn);
}
}
package extends1.ex;
public class Album extends Item {
private String artist;
public Album(String name, int price, String artist) {
super(name, price);
this.artist = artist;
}
@Override
public void print() {
super.print();
System.out.println("- 아티스트: " + artist);
}
}
package extends1.ex;
public class Movie extends Item {
private String director;
private String actor;
public Movie(String name, int price, String director, String actor) {
super(name, price);
this.director = director;
this.actor = actor;
}
@Override
public void print() {
super.print();
System.out.println("- 감독: " + director + ", 배우: " + actor);
}
}