객체 지향 프로그래밍의 대표적인 특징으로는 “캡슐화”, “상속”, 그리고 이번에 살펴볼 객체 지향 프로그래밍의 꽃, “다형성” 이 있다. 프로그래밍에서의 다형성이란, 한 객체가 여러 타입의 객체로 취급될 수 있는 능력을 말한다. 분명히 객체는 하나인데, 다양한 형태를 취하는 것이다. 보통 하나의 객체는 하나의 타입으로 고정되어 있지만, 다형성을 이용하면 하나의 객체가 다른 타입으로 사용될 수 있다.
아래 상속 관계를 코드로 만들어보자.
이처럼 부모와 자식이 있고, 각각 다른 메서드를 가지고 있다.
package poly.basic;
public class Parent {
public void parentMethod() {
System.out.println("Parent.parentMethod");
}
}
package poly.basic;
public class Child extends Parent {
public void childMethod() {
System.out.println("Child.childMethod");
}
}
package poly.basic;
public class PolyMain {
public static void main(String[] args) {
// 부모 변수가 부모 인스턴스를 참조
System.out.println("-- Parent -> Parent --");
Parent parent = new Parent();
parent.parentMethod();
// 자식 변수가 자식 인스턴스를 참조
System.out.println("-- Child -> Child --");
Child child = new Child();
child.parentMethod();
child.childMethod();
// 부모 변수가 자식 인스턴스를 참조(다형적 참조)
System.out.println(" -- Parent -> Child --");
Parent poly = new Child();
poly.parentMethod();
// Required type: Child, Provided: Parent
// Child child1 = new Parent();
// Cannot resolve method 'childMethod' in 'Parent'
// poly.childMethod();
}
}
/*
-- Parent -> Parent --
Parent.parentMethod
-- Child -> Child --
Parent.parentMethod
Child.childMethod
-- Parent -> Child --
Parent.parentMethod
*/
위의 코드를 그림을 참고해서 차례대로 분석해보자.

보다시피 부모 타입의 변수 parent가 부모 인스턴스(Parent)를 참조하고 있다. 처음에는 부모 인스턴스만 생성했기 때문에 메모리 상에 Parent만 생성된 것이다. 그리고 parent.parentMethod()를 호출했기 때문에 Parent 클래스의 parentMethod()가 호출되었다.
그 후, 자식 타입의 변수 child가 자식 인스턴스(Child)를 참조한다.

이제 메모리 상에 Child와 Parent가 모두 생성되었다. 당연히 child가 Child 인스턴스의 childMethod()를 호출할 수 있다. 근데 이제부터 다형적 참조, 즉 부모 타입의 변수가 자식 인스턴스를 참조할 수 있다. 아래 그림을 보자.

부모 타입의 변수(poly)가 자식 인스턴스를 참조하는 것인데, new Child()를 통해 현재 메모리 상에 Child를 포함해서 Parent까지 두 가지 모두 생성된 상태다. 이때 생성된 자식 인스턴스의 참조값을 Parent 타입의 변수인 poly에 담아둔 것이다.
이처럼 부모는 자식을 담을 수 있다. 분명히 Parent poly는 부모 타입인데 new Child()를 통해 생성된 결과는 Child 타입이다. 자바에서 부모 타입은 자식 타입을 담을 수 있다. 반대로 자식 타입은 부모 타입을 담을 수 없다. 지금까지 봤던 내용에 따르면 항상 같은 타입에 참조를 대입했고, 보통 한 가지 형태만 참조했다.
Parent parent = new Parent()Child child = new Child()하지만, Parent 타입의 변수는 자기 자신은 물론이고, 자식 타입까지 참조 가능하다. 만약 자식 하위에 손자가 있다면 자식도, 그 손자도 참조할 수 있다.
앞의 그림을 참고해서 계속 말하자면, poly.parentMethod()를 호출하면 먼저 참조값을 이용해 인스턴스를 찾는다. 그리고 인스턴스 안에서 실행할 타입도 찾아야 하는데, poly는 Parent 타입이다. 따라서 Parent 클래스부터 시작해서 필요한 기능을 찾는다. 현재 Parent 클래스에 parentMethod() 메서드가 있으니 이 메서드가 호출된다.
근데 여기서 poly.childMethod()를 호출하면 어떻게 될까? 물론 참조값을 통해 x001 인스턴스에 찾아갈 수 있겠지만, poly가 Parent 타입이기 때문에 당연히 Parent 클래스부터 시작해서 필요한 기능을 찾는다. 그런데, 상속 관계에서는 부모 방향으로 위로 찾아서 올라갈 수는 있지만, 자식 방향으로 내려가는 것은 불가능하다. 그래서 지금 Parent는 부모 타입이고 상위에 부모는 없는 상태이기 때문에 childMethod()를 찾을 방도가 없어 컴파일 오류가 발생한다.
그런데도 childMethod() 메서드를 호출하고 싶다? 이때 필요한 것이 바로 “캐스팅” 이다. 일단 지금 핵심은 “부모는 자식을 품을 수 있다” 는 것이다. 하지만 이런 내용이 아직 와닿지 않는다. 왜 굳이 타입을 바꿔서 어거지로 다른 메서드를 호출하려는지 잘 모르겠다.
부모 타입의 변수는 자식 타입에 있는 기능을 호출할 수 없다. 하지만, 방법이 하나 있다. 바로 “다운 캐스팅” 을 하는 것이다.
package poly.basic;
public class CastingMain1 {
public static void main(String[] args) {
Parent poly = new Child();
// Cannot resolve method 'childMethod' in 'Parent'
// poly.childMethod();
// 다운 캐스팅(부모 타입 -> 자식 타입)
Child child = (Child) poly;
child.childMethod();
}
}
/*
Child.childMethod
*/

먼저 poly.childMethod()를 호출하면 참조값을 통해 인스턴스를 찾고 타입을 체크한다. poly는 Parent 타입이고, 상속 관계는 위로만 올라갈 수 있는데, 현재 호출하려는 childMethod()는 자식 타입에 있기 때문에 원칙적으로는 호출할 수 없기 때문에 자바에서 컴파일 오류를 터뜨려 경고한다.
그럼에도 불구하고 자식의 기능을 사용하고 싶다면, 아래와 같이 강제로 부모 타입을 자식 타입으로 확 내려서 캐스팅 해줘야 한다. 지금 호출하는 타입을 자식 타입인 Child으로 바꿔주면 childMethod()를 호출할 수 있다.

다운 캐스팅이 실행되는 순서를 알아보면…
Child child = (Child) poly: 다운 캐스팅을 통해 부모 타입을 자식 타입으로 변환한 다음에 대입을 시도한다.Child child = (Child) x001: poly에 들어가 있는 참조값을 복사해서 Child 타입으로 바꾼다.Child child = x001: 그리고 나서 child에 대입한다.참고로 캐스팅을 한다고 해서 Parent poly의 자체 타입이 변하는 것은 아니다. 해당 참조값을 꺼내고, 꺼낸 참조값이 Child 타입이 되는 것이다. 따라서 poly의 타입은 Parent로 기존과 같이 유지된다.
근데 매번 캐스팅된 결과를 일일이 변수에 대입하는 과정을 거쳐야 할까? 이런 과정 없이 일시적으로 캐스팅을 해서 인스턴스에 있는 특정 클래스의 기능을 바로 호출할 수도 있다.
package poly.basic;
public class CastingMain1 {
public static void main(String[] args) {
Parent poly = new Child();
Child child = (Child) poly;
child.childMethod();
// 해당 메서드를 호출하는 순간만 다운 캐스팅하도록 일시적 다운 캐스팅
((Child) poly).childMethod();
}
}

Parent 타입의 poly가 임시로 Child로 변경되는 것이다. 그리고 Child 타입에서 메서드를 호출한다. 여기서 정확히 말하자면, poly가 Child 타입으로 바뀌는 것은 아니다. 자바에서는 항상 뭘 하면 일단 그 변수에 있는 값을 꺼낸다.
// 다운 캐스팅을 통해 부모 타입을 자식 타입으로 변환 후 기능을 호출
(Child) poly).childMethod();
// 참조값을 꺼내서 잠깐 Child 타입으로 바꿔 쓴다.
((Child) x001).childMethod();
반대로, Child 타입을 Parent 타입에 대입해야 할 경우에는 타입을 변환하는 캐스팅이 필요하다.
package poly.basic;
public class CastingMain3 {
public static void main(String[] args) {
// 부모는 자식을 담을 수 있다.
Child child = new Child();
Parent parent1 = child; // 권장
// 원래 다른 타입에 들어갈 때는 캐스팅을 해줘야 한다.
// 다만, 업 캐스팅은 생략 가능하고, 권장되는 것이다.
Parent parent2 = (Parent) child;
parent1.parentMethod();
parent2.parentMethod();
}
}
근데 왜 무조건 명시해줘야 하는 다운 캐스팅과는 달리, 업 캐스팅은 생략할 수 있게 해준 걸까?
다운 캐스팅은 잘못하면 심각한 런타임 오류가 발생할 수 있다. 아래 코드를 확인해보자.
package poly.basic;
public class CastingMain4 {
public static void main(String[] args) {
Parent parent1 = new Child();
Child child1 = (Child) parent1; // 다운 캐스팅
child1.childMethod(); // 문제 없음
Parent parent2 = new Parent();
Child child2 = (Child) parent2; // 런타임 오류 - ClassCastException
child2.childMethod(); // 실행 안 됨
}
}

첫 번째 정상적으로 메서드가 호출되는 코드는 new Child()를 통해 부모 타입과 자기 자신의 타입도 같이 메모리 상에 만들어진 후, 참조값 parent1을 복사해서 읽어 들여 다운 캐스팅 후 child1에 대입한 것이다. 그래서 전혀 문제가 없는 것을 볼 수 있다. 하지만 아래의 경우를 살펴보자.

일단 new Parent()와 같이 부모 타입으로 객체를 생성했으므로, 자식 객체는 메모리 상에 애초에 존재하지 않는다. 없는데 어떻게 사용하냐? 자바에서는 이처럼 사용할 수 없는 타입으로 다운 캐스팅 하는 경우에 ClassCastException이라는 클래스 캐스팅이 잘못되었다는 예외를 터뜨린다. 그래서 다음 동작, child2.childMethod()는 실행도 못 하고 종료되는 것이다.
일단 업 캐스팅은 이런 문제가 절대로 발생하지 않는다. 애초에 객체를 생성할 때, 해당 타입의 부모 타입(들)까지 모두 한꺼번에 생성되기 때문에 메모리 상에 인스턴스가 모두 존재하게 된다. 반면, 다운 캐스팅의 경우, 위의 문제처럼 인스턴스에 존재하지 않는 하위 타입으로 캐스팅하는 문제가 발생할 수 있다. 객체를 생성하면 자식 타입은 생성되지 않기 때문이다. 그렇기 때문에 개발자가 항상 주의를 기울여야 한다는 의미로 명시적으로 캐스팅을 부여해주는 것이다.
컴파일 오류는 변수명 오타, 잘못된 클래스 이름을 사용하는 등, 자바 프로그램을 실행하기 전에 발생하는 오류를 말한다. 이런 오류는 IDE에서 즉시 확인할 수 있기 때문에 안전하고 좋은 오류라고 생각하면 된다. 반면, 런타임 오류는 말 그대로 프로그램이 실행하고 있는 와중에 터지는 오류를 말한다. 매우 안 좋은 오류다. 왜냐하면 프로그램이 실행되고 있는 도중에 오류가 발생했다는 것은… 실제 고객이 사용하고 있는 중에… 흠
이처럼 다형성 측면에서 참조형 변수는 다양한 대상으로 참조할 수 있다. 이때, 현재 어떤 인스턴스를 참조하고 있는지 궁금할 수 있다.
package poly.basic;
public class CastingMain5 {
public static void main(String[] args) {
Parent parent1 = new Parent();
System.out.println("parent1 호출");
call(parent1);
Parent parent2 = new Child();
System.out.println("parent2 호출");
call(parent2);
}
// Parent 타입이 올지, Child 타입이 올지 모른다.
private static void call(Parent parent) {
parent.parentMethod();
if (parent instanceof Child) {
System.out.println("-- Child 인스턴스가 맞음! --");
((Child) parent).childMethod();
} else {
System.out.println("-- Child 인스턴스 아님... --");
}
}
}
/*
parent1 호출
Parent.parentMethod
-- Child 인스턴스 아님... --
parent2 호출
Parent.parentMethod
-- Child 인스턴스가 맞음! --
Child.childMethod
*/
call(Parent parent) 메서드를 살펴보면, 매개 변수로 넘어온 parent가 참조하는 타입에 따라 다른 명령을 수행한다. 이때 위의 코드처럼 다운 캐스팅을 수행하기 전에 먼저 instanceof를 사용해서 원하는 타입으로 변경이 가능한지 확인하고 수행하는 것이 안전하다.
참고로 instanceof 키워드는 오른쪽 대상의 자식 타입을 왼쪽에서 참조하는 경우에도 true를 반환한다.
new Parent() instanceof Parent // parent가 Parent의 인스턴스를 참조하는 경우: true
new Child() instanceof Parent // parent가 Child의 인스턴스를 참조하는 경우: true
더 쉽게 말해, 오른쪽에 있는 타입에 왼쪽에 있는 인스턴스의 타입이 들어갈 수 있는지 대입해보면 된다. 대입이 가능하면 true, 불가능하면 false인 것이다.
추가적으로, 자바 16부터는 instanceof를 사용하면서 동시에 변수를 선언할 수 있다.
private static void call(Parent parent) {
parent.parentMethod();
if (parent instanceof Child child) { // 확인 완료되는 동시에 다운 캐스팅
System.out.println("-- Child 인스턴스가 맞음! --");
child.parentMethod();
} else {
System.out.println("-- Child 인스턴스 아님... --");
}
}
다형성을 이루는 또 하나의 중요한 핵심은 바로 “메서드 오버라이딩” 이다. 여기서 기억할 점은 딱 하나다.
“오버라이딩된 메서드가 항상 우선권을 가진다!”
상속에서 알아본 메서드 오버라이딩은 반쪽짜리고, 다형성과 함께 사용할 때 진짜 힘이 나타난다.
코드를 통해 확인해보자. Parent 클래스와 Child 클래스가 있는데, 둘 다 value라는 멤버 변수를 가지고 있다. 이 필드는 오버라이딩 되지 않는다. 그리고 둘 다 method()라는 메서드도 가지고 있는데, Child에서 메서드를 오버라이딩 한 상황이라고 가정하자.
package poly.overriding;
public class Parent {
public String value = "parent";
public void method() {
System.out.println("Parent.parentMethod");
}
}
package poly.overriding;
public class Child extends Parent {
public String value = "child";
@Override
public void method() {
System.out.println("Child.childMethod");
}
}
package poly.overriding;
public class OverridingMain {
public static void main(String[] args) {
// 자식 변수가 자식 인스턴스를 참조
Child child = new Child();
System.out.println("Child -> Child");
System.out.println("value = " + child.value);
child.method();
// 부모 변수가 부모 인스턴스를 참조
Parent parent = new Parent();
System.out.println("Parent -> Parent");
System.out.println("value = " + parent.value);
parent.method();
// 부모 변수가 자식 인스턴스를 참조(다형적 참조)
Parent poly = new Child();
System.out.println("Parent -> Child");
System.out.println("value = " + poly.value);
poly.method();
}
}
/*
Child -> Child
value = child
Child.childMethod
Parent -> Parent
value = parent
Parent.parentMethod
Parent -> Child
value = parent
Child.childMethod
*/
그림을 통해 자세하게 들여다보자.

자식 변수인 child는 Child 타입이다. 따라서 객체를 생성할 때 부모인 Parent 객체와 함께 메모리 상에 생성되었을 것이고, value와 method()를 호출하면 당연히 Child 타입을 쳐다본다.

부모 변수인 parent도 상황은 마찬가지다. 애초에 본인 타입밖에 생성되지 않기 때문에 본인 타입에서 속성과 기능을 찾는다. 하지만… 부모 변수가 자식 인스턴스를 참조할 때를 살펴보자.

일단 new Child()로 객체를 생성했기 때문에 본인을 포함한 부모 타입까지 메모리에 생성된다. 그런데 이때, poly가 Parent 타입이기 때문에 본인 타입에서 value 값과 찾고 method()를 호출하려고 할 것이다. 근데 아까 Child 클래스에서 메서드를 오버라이딩 했었다. 아까 말했다시피 오버라이딩 된 메서드가 항상 우선권을 가진다. 그래서 메서드는 Child.method()가 실행된다.