요약

집안에 내려오는 김치찌개 조리법을 물려받았는데, 내 입맛에 맞게 고쳐서 내 공책에 다시 적어 두는 것과 같다. 그 뒤로 내가 끓이는 찌개는 늘 고쳐 적은 쪽이다. 원래 조리법이 없어진 것은 아니어서 “원래대로 끓인 다음 치즈를 얹는다”처럼 불러다 쓸 수도 있다. 지켜야 할 것이 있다. 이름과 받는 재료가 원래와 똑같아야 고쳐 쓴 것으로 쳐 준다. 조금이라도 다르면 고쳐 쓴 것이 아니라 새 조리법이 하나 더 생길 뿐이고, 원래 것이 그대로 쓰인다. 또 원래 누구나 볼 수 있던 것을 나만 보게 감출 수는 없다.

예시로 보기

상속으로 물려받은 메서드가 자식에게 딱 맞지 않을 때가 있다. 동물은 모두 소리를 내지만 개와 고양이의 소리는 다르다. 자식마다 bark(), meow()처럼 다른 이름의 메서드를 만들면, 부르는 쪽이 동물의 종류를 일일이 확인해야 한다. 물려받은 메서드를 같은 이름으로 다시 쓰면 그럴 필요가 없다. 간단한 예로 본다.

class Parent {
    void method1() { System.out.println("Parent.method1"); }
    void method2() { System.out.println("Parent.method2"); }
}

class Child extends Parent {
    @Override
    void method2() { System.out.println("Child.method2"); }   // 다시 썼다
    void method3() { System.out.println("Child.method3"); }   // 새로 보탰다
}
Child child = new Child();
child.method1();
child.method2();
child.method3();
호출 도는 메서드 출력 까닭
child.method1() Parent의 것 Parent.method1 Child가 다시 쓰지 않았다. 물려받은 것이 돈다
child.method2() Child의 것 Child.method2 Child가 다시 썼다. Parent의 것은 가려진다
child.method3() Child의 것 Child.method3 Child가 새로 보탠 메서드다
classDiagram
    class Parent {
        method1()
        method2()
    }
    class Child {
        method2()
        method3()
    }
    Parent <|-- Child

method2가 두 상자에 모두 있다. Child 객체에게 method2를 시키면 아래 상자의 것이 돈다.

검증: 위 표의 출력, 부모 타입 변수로 불러도 자식 것이 도는 것, super.method() 예(작업 처리1, 작업 처리2), 매개변수가 다른 메서드가 부모 것을 가리지 못하는 것, 카드 C2의 답을 JDK 21에서 실행해 비교. 오류 문구 다섯은 따로 컴파일해 확인 — 22_java-overriding_impl.java

정의

메서드 오버라이딩은 물려받은 메서드를 자식 클래스에서 다시 쓰는 것이다. 다시 쓰면 부모의 그 메서드는 가려지고 자식의 메서드가 먼저 쓰인다.

어느 메서드가 돌지는 실행 중에 객체의 실제 타입을 보고 정한다. 그래서 이것을 동적 다형성이라고도 부른다. 변수의 타입이 부모여도 객체가 자식이면 자식의 것이 돈다.

Parent p = new Child();
p.method2();              // Child.method2
Parent q = new Parent();
q.method2();              // Parent.method2

두 줄 모두 Parent 타입 변수로 method2()를 불렀다. 코드는 같고 결과가 다르다.

지켜야 할 것

  1. 자식 메서드는 부모 메서드와 돌려주는 타입, 이름, 매개변수가 같아야 한다.
  2. 접근 범위를 부모보다 좁힐 수 없다. public이던 것을 private로 바꿀 수 없다.

1번의 돌려주는 타입에는 예외가 하나 있다. 부모가 돌려주던 타입의 자식 타입으로는 바꿔도 된다. 부모가 Animal self()이면 자식은 Dog self()로 다시 쓸 수 있다1.

어긴 코드 번역기의 반응
부모 public void makeSound(), 자식 void makeSound() attempting to assign weaker access privileges; was public
부모 int legs(), 자식 String legs() return type String is not compatible with int
부모 void makeSound(), 자식 void makeSound(int times) 오류 없음. 다시 쓴 것이 아니라 새 메서드가 하나 더 생긴다
부모 void makeSound(), 자식 void makesound() (소문자 s) 오류 없음. 위와 같다
부모 final void sleep(), 자식 void sleep() overridden method is final

표의 값은 실행해 확인한 것이다1. 2번은 넓히는 쪽으로는 된다(부모 default, 자식 public). 부모 타입으로 그 메서드를 부를 수 있던 코드가 자식 객체를 만났을 때도 부를 수 있어야 하기 때문이다.

셋째·넷째 줄이 위험하다. 번역기가 아무 말도 하지 않는다. 이름이 같고 매개변수가 다르면 오버로딩이 되어 부모의 메서드는 가려지지 않고 그대로 남는다.

class LoudDog extends Animal {
    public void makeSound(int times) { ... }   // 다시 쓴 줄 알았지만 새 메서드다
}

Animal a = new LoudDog();
a.makeSound();      // Some sound   ← 개인데 부모 것이 돈다

@Override

다시 쓰는 메서드 위에 @Override를 붙인다. 이 표시는 번역기에게 “이 메서드는 부모 것을 다시 쓴 것이니 확인해 달라”고 부탁한다. 부모에 같은 모양의 메서드가 없으면 번역이 멈춘다1.

class Dog extends Animal {
    @Override
    public void makeSound(int times) { }
    // error: method does not override or implement a method from a supertype
}

위 표의 셋째·넷째 줄이 조용한 실수에서 번역 오류로 바뀐다. @Override를 붙이지 않아도 다시 쓰기는 되지만, 붙여야 이런 실수가 실행 전에 잡힌다.

부모 메서드 부르기

다시 쓰면 부모의 메서드는 가려진다. 자식 메서드 안에서 super.을 앞에 붙이면 가려진 부모 메서드를 부를 수 있다. 부모가 하던 일을 자식에 다시 쓰지 않고 가져다 쓴다.

class Parent {
    public void method() {
        System.out.println("작업 처리1");
    }
}

class Child extends Parent {
    @Override
    public void method() {
        super.method();                       // 부모 메서드 호출
        System.out.println("작업 처리2");
    }
}
// new Child().method() → 작업 처리1, 작업 처리2

스스로 설명해 보기

class X { void hi() { System.out.println("X"); } void run() { hi(); } }와 class Y extends X { @Override void hi() { super.hi(); System.out.println("Y"); } }에서 new Y().run();을 한 줄씩 따라간다.

1. Y에는 run이 없다. new Y().run()은 어느 run을 돌리는가?

X의 run이다. Y가 다시 쓰지 않았으므로 물려받은 것이 돈다.

2. X의 run 안에 있는 hi()는 X의 hi인가 Y의 hi인가?

Y의 hi다. hi()는 this.hi()와 같고, 지금 this는 new Y()로 만든 객체다. 어느 메서드가 돌지는 코드가 적힌 클래스가 아니라 실행 중인 객체의 실제 타입으로 정한다.

3. Y의 hi 안에 있는 super.hi()는 왜 다시 Y의 hi로 돌아오지 않는가?

super.은 실제 객체를 보지 않고 부모 클래스 X에 적힌 메서드를 곧바로 부른다. 그래서 X가 찍히고, 돌아와서 Y가 찍힌다. super.을 빼고 hi()라고 쓰면 자기 자신을 끝없이 부른다.

이 추적의 핵심 생각은?

부모 클래스에 적힌 코드라도 그 안에서 부르는 메서드는 실제 객체의 것으로 바뀌어 돈다. 부모는 뼈대(run)만 정하고, 자식이 알맹이(hi)를 갈아 끼울 수 있다.

같은 생각을 쓸 수 있는 다른 상황은?

System.out.println(객체)는 안에서 그 객체의 toString()을 부른다. println은 오래전에 쓰인 코드지만, 내가 만든 클래스에서 toString()을 다시 쓰면 내 것이 돈다.

활용

  • 알아보는 신호: 부모의 메서드가 하는 일이 자식에게 맞지 않거나(통째로 갈기), 부모가 하는 일에 조금 더 보태고 싶을 때(super.method() 뒤에 덧붙이기).
  • 대표 문제 1: toString()을 다시 써서 객체를 찍을 때 보기 좋은 글이 나오게 한다. 대표 문제 2: 유닛마다 다른 attack()을 부모 타입 배열 하나로 돌린다(객체 지향의 세 원칙의 동물 예)1.
  • 다시 쓸 때는 늘 @Override를 붙인다.
  • 흔한 실수: 오버로딩과 섞는 것. 오버로딩은 매개변수가 다른 메서드를 나란히 두는 것이고 번역할 때 고른다. 오버라이딩은 같은 모양을 자식이 다시 쓰는 것이고 실행할 때 고른다.
  • 메서드에 final을 붙이면 자식이 다시 쓰지 못한다. 필드와 static 메서드는 다시 쓰기의 대상이 아니다. 같은 이름으로 자식에 만들어도 부모 타입 변수로는 부모 것이 쓰인다1.

연결

자주 하는 오해

“변수의 타입이 부모면 부모의 메서드가 불린다”

틀렸다. 그럴듯해 보이는 이유는 코드에 Parent p라고 적혀 있어서 p.method2()도 Parent의 것으로 읽히기 때문이다. 실제로는 변수의 타입은 “무엇을 부를 수 있는가”만 정하고, “누구의 것이 도는가”는 실행 중에 p가 가리키는 객체가 정한다. 확인하는 법: Parent p = new Child(); p.method2();가 Child.method2를 찍는다. 반대로 p.method3()은 번역되지 않는다. Parent에는 method3이 없기 때문이다.

“이름만 같으면 오버라이딩이다”

틀렸다. 이름과 매개변수가 모두 같아야 한다. 매개변수가 다르면 부모 것은 가려지지 않고, 자식에 메서드가 하나 더 생길 뿐이다. 번역기가 오류를 내지 않아서 다시 쓴 줄 알고 지나가기 쉽다. 확인하는 법: LoudDog에 makeSound(int times)만 두고 Animal a = new LoudDog(); a.makeSound();를 돌리면 Some sound가 나온다. @Override를 붙이면 번역 단계에서 method does not override or implement a method from a supertype으로 걸린다.

확인 문제

C1 메서드 오버라이딩이 무엇인지, 지켜야 할 것 두 가지, 가려진 부모 메서드를 부르는 방법을 쓰라.

답: 물려받은 메서드를 자식 클래스에서 다시 쓰는 것이다. 다시 쓰면 부모 것은 가려지고 자식 것이 먼저 쓰인다. 돌려주는 타입·이름·매개변수가 부모와 같아야 하고, 접근 범위를 더 좁힐 수 없다. 자식 메서드 안에서 super.메서드()로 부모 것을 부른다.

C2 class X { void hi() { System.out.println("X"); } void run() { hi(); } }와 class Y extends X { @Override void hi() { super.hi(); System.out.println("Y"); } }에서 new Y().run();의 출력은?

답: X, Y가 한 줄씩 나온다.
이유: run은 X의 것이 돌지만, 그 안의 hi()는 실제 객체 Y의 hi다. Y의 hi가 super.hi()로 X를 찍고 이어서 Y를 찍는다. X만 나온다고 답하는 것은 “X에 적힌 run이니 X의 hi를 부른다”고 본 것이다.

C3 부모가 class Animal { public void makeSound() { } }다. 번역 단계에서 오류가 나는 자식을 모두 고르라. (가) @Override public void makeSound() { } (나) @Override void makeSound() { } (다) @Override public void makeSound(int n) { } (라) public void makeSound(int n) { }

답: (나)와 (다).
이유: (나) public을 default로 좁혔다. (다) 매개변수가 달라 다시 쓴 것이 아닌데 @Override가 붙어 있다. (라)는 @Override가 없으므로 그냥 새 메서드(오버로딩)로 받아들여져 오류가 없다. (라)를 오류로 고르기 쉽다.

C4 @Override가 하는 일을 한 문장으로 쓰라. 붙이지 않으면 다시 쓰기가 안 되는가?

답: 바로 아래 메서드가 부모(또는 인터페이스)의 메서드를 다시 쓴 것이 맞는지 번역기가 확인하게 하고, 아니면 번역을 멈추게 한다. 붙이지 않아도 다시 쓰기는 된다. 다만 이름이나 매개변수를 잘못 쓴 실수가 걸러지지 않는다.

C5 다시 쓸 때 접근 범위를 좁힐 수 없게 한 이유를 Animal a = new Dog(); a.makeSound();로 설명하라.

답: 번역기는 변수 타입 Animal을 보고 makeSound()가 public이니 부를 수 있다고 허락한다. 실행 때 도는 것은 Dog의 것이다. Dog가 이것을 private로 좁힐 수 있다면, 허락받은 호출이 실행 중에 막힌 메서드에 닿게 된다. 그래서 자식은 부모가 내놓은 만큼은 내놓아야 한다1.

  1. 모두 JDK 21에서 직접 실행·컴파일해 확인했다(필드와 static 메서드: 부모 타입 변수로 읽으면 부모의 값이 나온다). ↩ ↩2 ↩3 ↩4 ↩5 ↩6