현재 선택한 정보처리 과정

정보처리기사 필기 이론 학습

이론 목록으로 돌아가기

Java 상속·동적 바인딩·오버로딩·오버라이딩

Java는 클래스 상속과 인터페이스 구현을 통해 다형성을 제공한다. 오버로딩은 매개변수가 다른 동명 메서드의 선택이고, 오버라이딩은 상속한 인스턴스 메서드의 재정의이다. 컴파일 시 선택과 실행 시 동적 바인딩을 나누어 판단한다.

예상 읽기 18

Java는 클래스 상속과 인터페이스 구현을 통해 다형성을 제공한다. 오버로딩은 매개변수가 다른 동명 메서드의 선택이고, 오버라이딩은 상속한 인스턴스 메서드의 재정의이다. 컴파일 시 선택과 실행 시 동적 바인딩을 나누어 판단한다.

그림으로 확인하기

좌우로 이동해 그림을 확인하세요.그림 크게 보기
오버로딩은 컴파일 시 타입으로 선택하고 오버라이딩된 인스턴스 메서드는 실제 객체로 결정한다.
오버로딩은 컴파일 시 타입으로 선택하고 오버라이딩된 인스턴스 메서드는 실제 객체로 결정한다.

상속과 하위 타입

클래스 상속의 기본 형태

하위 클래스는 extends 뒤에 직접 상위 클래스 하나를 지정한다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
class Animal {
    String sound() {
        return "unknown";
    }
}

class Cat extends Animal {
    @Override
    String sound() {
        return "meow";
    }
}

CatAnimal의 직접 하위 클래스이며 AnimalCat의 직접 상위 클래스다. Animal이 다시 Object를 상속하므로 ObjectCat의 간접 상위 클래스가 된다. Object를 제외한 일반 클래스가 extends를 생략하면 직접 상위 클래스는 Object다.

Java 클래스는 직접 상위 클래스를 하나만 가질 수 있다. 다음과 같은 클래스 다중 상속 문법은 허용되지 않는다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
// 컴파일되지 않는 형태
// class AmphibiousCar extends Car, Boat { }

final로 선언된 클래스는 하위 클래스를 만들 수 없다. 반대로 abstract 클래스는 직접 객체를 만들 수 없지만 하위 클래스가 미완성 동작을 구현하도록 상속의 틀을 제공할 수 있다.

대신 클래스는 여러 인터페이스를 구현할 수 있다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
interface Printable {
    void print();
}

interface Storable {
    void save();
}

class Report implements Printable, Storable {
    @Override
    public void print() { }

    @Override
    public void save() { }
}

인터페이스 구현도 하위 타입 관계를 만든다. 따라서 Report 객체는 Report, Printable, Storable, Object 관점으로 다룰 수 있다.

무엇이 상속되는가

상위 클래스의 접근 가능한 필드·메서드·멤버 타입은 하위 클래스에서 상속될 수 있다. 그러나 다음은 구분해야 한다.

  • 생성자는 상속되지 않는다. 하위 생성자는 super(...)를 통해 상위 생성자를 호출할 뿐이다.
  • 상위 클래스의 private 멤버는 하위 클래스에 상속되지 않으며 하위 클래스 코드에서 이름으로 직접 접근할 수 없다.
  • 상위 클래스가 가진 private 필드의 상태는 하위 객체 안에도 존재할 수 있다. 하위 클래스는 상위 클래스가 제공하는 메서드를 통해 그 상태를 간접적으로 다룬다.
  • 같은 이름의 필드를 하위 클래스가 선언하면 필드 오버라이딩이 아니라 필드 숨김이 일어난다.
  • 상위 클래스의 오버로드된 메서드 중 하나만 오버라이딩해도 나머지 오버로드는 별개의 시그니처로 계속 상속될 수 있다.

접근 제어자에 따른 세부 접근 범위는 별도 이론에서 다루되, 현재 문제에서는 public·protected·패키지 접근·private를 서로 같은 범위로 보지 않아야 한다.

상속은 is-a, 구성은 has-a

상속은 “하위 객체를 상위 타입이 요구되는 곳에 넣어도 계약이 유지되는가?”를 기준으로 판단한다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
class Engine { }

class Car {
    private final Engine engine = new Engine();
}

CarEngine의 한 종류가 아니라 Engine을 가진다. 이런 관계는 상속보다 구성(composition) 이 자연스럽다. 코드 재사용만을 이유로 의미 없는 상속을 만들면 상위 구현 변화가 하위 클래스에 강하게 전파될 수 있다.

선언된 타입과 실제 객체 형

업캐스팅

하위 타입 참조값을 상위 타입 변수에 대입하는 것을 흔히 업캐스팅(upcasting) 이라고 한다. 이는 확대 참조 변환이므로 일반적으로 명시적 캐스팅이 필요 없다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
Cat cat = new Cat();
Animal animal = cat;

두 변수는 같은 Cat 객체를 가리킬 수 있다. Animal animal이라고 선언했다고 객체 자체가 Animal 객체로 바뀌는 것은 아니다.

  • 변수 animal의 선언된 타입: Animal
  • 참조된 실제 객체의 클래스: Cat

컴파일러가 허용하는 멤버

컴파일러는 참조 표현식의 선언된 타입에서 접근 가능한 멤버를 찾는다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
class Animal {
    void eat() { }
}

class Cat extends Animal {
    void scratch() { }
}

Animal animal = new Cat();
animal.eat();
// animal.scratch();  // 컴파일 오류: Animal 형에서 scratch를 찾을 수 없음

실제 객체가 Cat이라는 사실만으로 Animal 변수에서 Cat 전용 멤버를 바로 호출할 수는 없다. 필요한 경우 안전성을 확인한 뒤 하위 타입으로 변환한다.

다운캐스팅과 실행 시 검사

상위 타입 참조를 하위 타입으로 좁히는 다운캐스팅(downcasting) 은 명시적 캐스팅이 필요하며, 실행 시 실제 객체가 목표 타입과 호환되는지 검사한다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
Animal animal = new Cat();
Cat cat = (Cat) animal;
cat.scratch();

실제 객체가 목표 타입이 아니면 ClassCastException이 발생한다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
class Dog extends Animal { }

Animal animal = new Dog();
// Cat cat = (Cat) animal;  // 실행 시 ClassCastException

instanceof를 이용하면 변환 전에 형을 확인할 수 있다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
if (animal instanceof Cat) {
    Cat cat = (Cat) animal;
    cat.scratch();
}

null instanceof Cat의 결과는 false다. null을 참조형으로 캐스팅하면 결과도 null이며 캐스팅 자체에서 ClassCastException이 발생하지 않는다. 다만 그 결과로 인스턴스 멤버를 호출하면 NullPointerException이 발생할 수 있다.

캐스팅은 객체의 실제 클래스를 바꾸지 않는다. 같은 객체를 어떤 정적 타입 관점으로 볼지를 바꿀 뿐이다.

다형성과 동적 바인딩

동적 메서드 선택

상위 타입 변수로 인스턴스 메서드를 호출하더라도, 선택된 시그니처가 하위 클래스에서 오버라이딩되어 있으면 실행 시 실제 객체의 구현이 호출된다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
Animal animal = new Cat();
System.out.println(animal.sound());  // meow

컴파일 시에는 Animal 형에 sound()가 존재하고 접근 가능한지 확인한다. 실행 시에는 실제 객체가 Cat이므로 Cat.sound()를 찾는다. 이처럼 같은 상위 타입 호출식이 실제 객체에 따라 다른 구현을 실행하는 성질이 다형성의 핵심이다.

Dogsound()를 오버라이딩했다고 가정하면 다음과 같이 한 배열에서 서로 다른 구현을 호출할 수 있다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
class Dog extends Animal {
    @Override
    String sound() {
        return "woof";
    }
}

Animal[] animals = {new Cat(), new Dog()};
for (Animal value : animals) {
    System.out.println(value.sound());
}

배열 원소를 읽는 식의 형은 모두 Animal이지만 각 원소가 가리키는 실제 객체에 따라 Cat.sound() 또는 Dog.sound()가 실행된다.

super 호출은 상위 구현을 지정한다

하위 클래스의 인스턴스 메서드에서 super.method()를 사용하면 현재 객체의 상위 클래스 구현을 직접 호출한다. 이 호출에서는 다시 하위 오버라이딩 구현을 찾지 않는다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
class Child extends Parent {
    @Override
    String speak() {
        return "child";
    }

    String parentSpeak() {
        return super.speak();
    }
}

this.speak() 또는 단순한 speak()는 현재 객체에 대한 일반 인스턴스 호출이므로 동적 선택의 대상이 될 수 있다. super.speak()는 상위 구현을 명시한다.

필드와 static 메서드는 다르게 선택된다

필드는 오버라이딩되지 않는다. 같은 이름의 필드를 하위 클래스가 선언하면 두 필드는 각각 존재하고, 어느 필드를 읽을지는 참조 표현식의 컴파일 시점 형으로 정해진다.

static 메서드도 인스턴스 메서드처럼 오버라이딩되지 않는다. 하위 클래스가 같은 시그니처의 static 메서드를 선언하면 상위 메서드를 숨긴다(hide). 정적 메서드는 클래스 이름으로 호출해 선택 기준을 명확히 하는 것이 좋다.

호출 대상선택 시점선택 기준상속 관계의 이름
오버라이드 가능한 인스턴스 메서드컴파일 시 시그니처 결정 후 실행 시 구현 결정실제 객체 형오버라이딩
static 메서드컴파일 시수신 표현식의 선언된 타입 또는 명시한 클래스숨김
인스턴스 필드컴파일 시필드 접근 표현식의 형숨김
super로 호출한 인스턴스 메서드컴파일 시 지정직접 상위 구현동적 오버라이딩 선택 안 함

실행 예제: 선언된 타입과 실제 형을 함께 추적하기

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
public class DispatchDemo {
    public static void main(String[] args) {
        Parent ref = new Child();

        System.out.println(ref.label);
        System.out.println(ref.speak());
        System.out.println(Parent.kind());
        System.out.println(Child.kind());

        if (ref instanceof Child) {
            Child child = (Child) ref;
            System.out.println(child.label);
            System.out.println(child.onlyChild());
            System.out.println(child.parentSpeak());
        }
    }
}

class Parent {
    String label = "parent-field";

    String speak() {
        return "parent-method";
    }

    static String kind() {
        return "parent-static";
    }
}

class Child extends Parent {
    String label = "child-field";

    @Override
    String speak() {
        return "child-method";
    }

    static String kind() {
        return "child-static";
    }

    String onlyChild() {
        return "child-only";
    }

    String parentSpeak() {
        return super.speak();
    }
}

실행 결과:

CODE코드 영역 안에서 좌우로 이동할 수 있습니다.
parent-field
child-method
parent-static
child-static
child-field
child-only
parent-method

결과를 다음 순서로 해석한다.

  1. ref.labelref의 선언된 타입 Parent를 기준으로 Parent.label을 읽는다.
  2. ref.speak()는 컴파일 시 Parent.speak() 시그니처를 선택하지만, 실행 시 실제 객체가 Child이므로 Child.speak()를 호출한다.
  3. Parent.kind()Child.kind()는 각각 명시한 클래스의 정적 메서드를 호출한다.
  4. 다운캐스팅한 child.labelChild 형을 기준으로 Child.label을 읽는다.
  5. child.parentSpeak() 안의 super.speak()Parent.speak()를 직접 호출한다.

오버라이딩의 조건

오버라이딩은 하위 클래스가 상위 클래스의 인스턴스 메서드를 같은 이름과 매개변수형으로 재정의하는 것이다. 반환형은 같거나 허용된 공변 반환형이어야 하고, 접근 범위를 더 좁힐 수 없다. 검사 예외의 범위도 상위 메서드보다 넓힐 수 없다.

대상오버라이딩 여부
일반 인스턴스 메서드조건을 만족하면 가능
final 메서드재정의 불가
private 메서드하위 클래스의 오버라이딩 대상이 아님
static 메서드동적 오버라이딩이 아니라 숨김으로 구분
생성자상속되지 않으므로 오버라이딩 대상이 아님

@Override는 올바른 오버라이딩인지 컴파일러가 검사하도록 돕는다. 애너테이션을 썼다는 사실만으로 잘못된 메서드 선언이 올바른 오버라이딩이 되는 것은 아니다.

오버로딩의 조건

오버로딩은 같은 이름의 메서드를 매개변수의 개수·자료형·나열 순서가 다르게 정의하는 것이다. 여기서 순서는 int, StringString, int처럼 자료형이 나열된 순서를 뜻한다. 매개변수 이름이나 반환형만 바꾸는 것으로는 오버로딩할 수 없다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
int sum(int a, int b) { return a + b; }
int sum(int a, int b, int c) { return a + b + c; }
double sum(double a, double b) { return a + b; }

sum(1, 2)는 두 int 인자를 받는 메서드를, sum(1.5, 2.5)는 두 double 인자를 받는 메서드를 선택한다. 이 선택은 인자식과 참조 표현식의 컴파일 시점 타입을 기준으로 한다. 실행 시 객체의 타입을 보고 전혀 다른 매개변수 목록을 가진 오버로드를 다시 고르지 않는다.

기본형 확대 변환·박싱·가변 인자 등이 섞이면 호출 규칙이 더 복잡해질 수 있다. 기본 문제에서는 우선 선언된 타입, 매개변수의 개수와 형, 정확히 일치하는 후보를 확인한다.

오버로딩과 오버라이딩이 함께 있을 때

호출 결과는 컴파일 시 오버로딩 선택 → 실행 시 오버라이딩 선택의 두 단계로 판단한다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
public class TwoStageDispatchDemo {
    public static void main(String[] args) {
        ParentService service = new ChildService();
        Object asObject = "text";
        String asString = "text";

        System.out.println(service.call(asObject));
        System.out.println(service.call(asString));
        System.out.println(service.call(Integer.valueOf(1)));

        ChildService child = new ChildService();
        System.out.println(child.call(Integer.valueOf(1)));
    }
}

class ParentService {
    String call(Object value) {
        return "Parent:Object";
    }

    String call(String value) {
        return "Parent:String";
    }
}

class ChildService extends ParentService {
    @Override
    String call(Object value) {
        return "Child:Object";
    }

    String call(Integer value) {
        return "Child:Integer";
    }
}

실행 결과:

CODE코드 영역 안에서 좌우로 이동할 수 있습니다.
Child:Object
Parent:String
Child:Object
Child:Integer

첫 번째 호출

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
service.call(asObject)
  1. service의 선언된 타입은 ParentService다.
  2. asObject의 선언된 타입은 Object다.
  3. 컴파일 시 ParentService.call(Object)가 선택된다.
  4. 실제 객체는 ChildService이며 같은 시그니처를 오버라이딩했으므로 ChildService.call(Object)가 실행된다.

두 번째 호출

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
service.call(asString)
  1. 인자식의 선언된 타입이 String이므로 ParentService.call(String)이 선택된다.
  2. ChildService는 이 시그니처를 오버라이딩하지 않았다.
  3. 따라서 상속된 ParentService.call(String)이 실행된다.

세 번째 호출

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
service.call(Integer.valueOf(1))
  1. 수신 변수의 선언된 타입 ParentService에서 후보를 찾는다.
  2. ChildService.call(Integer)ParentService 형에서 보이지 않으므로 오버로드 후보가 아니다.
  3. ParentService.call(Object)가 선택된다.
  4. 실제 객체의 ChildService.call(Object)가 실행된다.

네 번째 호출

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
child.call(Integer.valueOf(1))

수신 변수의 선언된 타입이 ChildService이므로 call(Integer)가 오버로드 후보에 포함되고, 정확히 일치하는 ChildService.call(Integer)가 선택된다.

추상 클래스·인터페이스와 다형성

추상 클래스

abstract 클래스는 직접 객체를 만들 수 없지만 필드, 생성자, 구현된 메서드, 추상 메서드를 함께 가질 수 있다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
abstract class Shape {
    abstract double area();

    String name() {
        return "shape";
    }
}

추상 메서드를 물려받은 구체 클래스는 필요한 구현을 제공해야 한다. 그렇지 않으면 그 하위 클래스도 abstract로 선언해야 한다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
class Circle extends Shape {
    private final double radius;

    Circle(double radius) {
        this.radius = radius;
    }

    @Override
    double area() {
        return Math.PI * radius * radius;
    }
}

Shape shape = new Circle(2.0);처럼 상위 추상 타입 변수로 구체 객체를 다루고 shape.area()를 호출하면 Circle.area()가 동적으로 선택된다.

인터페이스 타입의 다형성

인터페이스는 구현이 지켜야 할 타입 계약을 나타낼 수 있다.

JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
interface Payment {
    int pay(int amount);
}

class CardPayment implements Payment {
    @Override
    public int pay(int amount) {
        return amount;
    }
}
JAVA코드 영역 안에서 좌우로 이동할 수 있습니다.
Payment payment = new CardPayment();
System.out.println(payment.pay(1000));

컴파일러는 Paymentpay(int)가 있는지 확인하고, 실행 시 실제 객체 CardPayment의 구현을 호출한다. 클래스는 직접 상위 클래스는 하나만 가질 수 있지만 여러 인터페이스를 구현해 여러 타입 계약을 동시에 만족할 수 있다.

공변 반환형과 private 메서드

공변 반환형은 오버라이딩한 메서드의 참조형 반환을 더 구체적인 하위 타입으로 좁히는 것이다. 상위 메서드가 Animal을 반환할 때 하위 메서드가 Animal의 하위 타입 Dog를 반환하도록 선언할 수 있다. 기본형 int를 long으로 바꾸는 것은 이러한 공변 반환에 해당하지 않는다.

상위 클래스의 private 메서드는 하위 클래스가 오버라이딩하는 대상이 아니다. 하위 클래스가 같은 이름의 메서드를 별도로 선언해도 상위 코드에서 자기 private 메서드를 호출하는 동작을 바꾸지 않는다. 반면 상속된 일반 인스턴스 메서드 안에서 오버라이딩 가능한 다른 메서드를 호출하면 실제 객체의 구현이 선택될 수 있다.