본문 바로가기

업캐스팅과 다운캐스팅

업캐스팅은 언제나 안전한데 다운캐스팅은 실행 중에 실패할 수 있는 이유를 실제 객체를 기준으로 따져 보고, ClassCastException을 피하는 방법을 배웁니다.

앞 글에서는 Post 변수에 담긴 객체가 CodePost라는 것을 알고 있을 때 (CodePost) post로 다운캐스팅해 commit()을 호출했습니다. 그런데 그 판단이 틀렸다면 어떻게 될까요? 괄호 속 타입 이름은 컴파일러에게 '이 객체는 CodePost가 맞다'고 알려 주는 표시일 뿐, 객체를 CodePost로 바꿔 주지는 않습니다. 실제 객체가 CodePost가 아니면 프로그램은 실행 도중 ClassCastException이라는 예외로 멈춥니다.

이 글에서는 캐스팅을 방향에 따라 나눠서 봅니다. 상속 계층에서 자식 타입을 부모 타입으로 넓히는 업캐스팅(upcasting)은 왜 언제나 안전한지, 반대로 부모 타입을 자식 타입으로 좁히는 다운캐스팅(downcasting)은 언제 성공하고 언제 실패하는지를 실제 객체를 기준으로 따져 봅니다. 마지막에는 예외가 나기 전에 객체의 타입을 미리 확인하는 방법으로 이어 갑니다.

업캐스팅이 언제나 안전한 이유

쉬운 방향부터 보겠습니다. 다음 예제는 Post → CodePost → FeaturedCodePost로 이어지는 3단 계층을 만들고, 맨 아래 클래스인 FeaturedCodePost의 객체 하나를 세 가지 타입의 변수에 담습니다. 게시글을 목록 맨 위에 고정하는 pinToTop()은 FeaturedCodePost에만 있는 메서드입니다.

src/SafeUpcasting.java
public final class SafeUpcasting {
    public static void main(String[] args) {
        FeaturedCodePost featured = new FeaturedCodePost();

        CodePost codePost = featured;
        Post post = featured;

        codePost.commit();
        post.publish();
        System.out.println("same=" + (featured == codePost && codePost == post));
    }

    private static class Post {
        void publish() {
            System.out.println("publish");
        }
    }

    private static class CodePost extends Post {
        void commit() {
            System.out.println("commit");
        }
    }

    private static final class FeaturedCodePost extends CodePost {
        void pinToTop() {
            System.out.println("pinned");
        }
    }
}
commit
publish
same=true

new FeaturedCodePost()는 객체를 하나만 만듭니다. 상속과 메서드 오버라이딩에서 본 것처럼 자식 객체 하나 안에는 부모들이 선언한 필드와 메서드가 함께 들어 있으므로, 이 객체에는 Post의 publish(), CodePost의 commit(), 자신의 pinToTop()이 모두 있습니다. 이어지는 두 대입은 이 객체를 가리키는 참조값을 복사할 뿐 새 객체를 만들지 않습니다. 그래서 세 변수를 ==로 비교한 마지막 줄이 same=true를 출력합니다.

달라지는 것은 각 변수로 호출할 수 있는 메서드의 범위입니다. 부모 타입 변수로 자식 객체 다루기에서 보았듯이 컴파일러는 변수의 타입만 보고 호출을 허락하기 때문입니다.

변수변수의 타입호출할 수 있는 메서드
featuredFeaturedCodePostpublish(), commit(), pinToTop()
codePostCodePostpublish(), commit()
postPostpublish()

업캐스팅이 안전한 이유가 이 표에 있습니다. 부모 타입이 약속하는 메서드는 자식 객체에 언제나 들어 있습니다. Post 변수로 할 수 있는 일은 publish() 호출뿐인데, Post를 상속한 객체라면 어느 것이든 publish()를 가지고 있습니다. 실패할 경우가 없으니 컴파일러는 (Post) featured처럼 캐스트를 적지 않아도 대입을 허락합니다. 캐스트를 적는다고 오류가 나지는 않지만 하는 일은 없습니다.

세 정적 타입의 참조가 가리키는 하나의 FeaturedCodePost

SafeUpcasting의 featured, codePost, post는 서로 다른 정적 타입의 지역 참조이지만 모두 하나의 FeaturedCodePost 객체를 가리키며 같음 비교는true다. 타입 관점은 바뀌지만 객체가 복제되거나 기능이 사라지지 않는다.

부모 타입으로 보아도 실제 객체와 기능은 유지됩니다

SafeUpcasting의 세 지역 참조입니다. 본문의 세 메서드가 각 정적 타입에서 보이는 범위를 비교하며, 선은 메서드 호출이 아니라 같은 객체를 가리키는 참조입니다.

featured, codePost, post와 하나의 실제 객체 FeaturedCodePost 타입의 featured, CodePost 타입의 codePost, Post 타입의 post는 모두 같은 FeaturedCodePost 객체를 가리킨다. 상위 타입에서는 자식 전용 메서드가 보이지 않지만 객체의 종류와 기능은 유지된다. featured FeaturedCodePost publish · commit · pinToTop codePost CodePost publish · commit post Post publish 같은 객체 실제 타입: FeaturedCodePost publish(), commit() pinToTop() 객체의 기능은 유지
부모 타입으로 보아도 실제 객체와 기능은 유지됩니다 featured FeaturedCodePost/codePost CodePost/post Post 3참조동일FeaturedCodePost 한객체;정적멤버범위3/2/1,객체기능유지,same true,main pin호출없음.실선객체참조. featured FeaturedCodePost publish · commit pinToTop 사용 가능 codePost CodePost publish · commit post Post publish만 사용 가능 같은 객체 하나 FeaturedCodePost publish · commit pinToTop 기능 유지 원 main은 pin 호출 안 함 업캐스트 뒤에도 same=true 실선: 같은 객체의 참조
featured · 정적 타입 FeaturedCodePost
publish(), commit(), pinToTop()이 보입니다.
codePost · 정적 타입 CodePost
publish(), commit()이 보입니다. featured와 같은 객체를 가리킵니다.
post · 정적 타입 Post
publish()이 보입니다. 앞의 두 참조와 같은 객체를 가리킵니다.
실제 객체는 하나
세 참조 모두 FeaturedCodePost 객체에 도달합니다. 업캐스팅으로 객체가 복제되거나 pinToTop()이 삭제되지는 않습니다.

원문은 codePost.commit()으로 commit, post.publish()으로 publish, 세 참조의 같음 비교로 same=true를 출력합니다. pinToTop()은 선언되어 있지만 이 main에서 호출하지 않습니다.

업캐스팅을 했다고 pinToTop()이 객체에서 사라지는 것은 아닙니다. post나 codePost 변수로는 보이지 않을 뿐, 객체에는 그대로 있습니다. 그래서 이 객체가 FeaturedCodePost라는 것을 알고 다시 FeaturedCodePost 타입으로 좁히면 pinToTop()을 호출할 수 있습니다. 이 글의 연습 문제 1에서 직접 해 봅니다.

다운캐스팅이 실패하는 경우

반대 방향은 사정이 다릅니다. 다음 코드는 Post 객체를 만들어 Post 변수에 담고, 그 변수를 CodePost로 다운캐스팅합니다. 앞 글의 첫 예제처럼 캐스트 없이 CodePost codePost = post;라고 썼다면 컴파일 오류가 났겠지만, 이번에는 캐스트를 적었으므로 컴파일은 통과합니다.

lab/InvalidDowncast.java
public final class InvalidDowncast {
    public static void main(String[] args) {
        Post post = new Post();
        CodePost codePost = (CodePost) post;
        codePost.commit();
    }

    private static class Post {}

    private static final class CodePost extends Post {
        void commit() {
            System.out.println("commit");
        }
    }
}
Exception in thread "main" java.lang.ClassCastException: class InvalidDowncast$Post cannot be cast to class InvalidDowncast$CodePost (InvalidDowncast$Post and InvalidDowncast$CodePost are in unnamed module of loader 'app')
	at InvalidDowncast.main(InvalidDowncast.java:4)

출력의 첫 줄은 main 스레드에서 처리되지 않은 예외가 났다는 뜻이고, 둘째 줄은 예외가 난 위치입니다. 첫 줄의 class InvalidDowncast$Post cannot be cast to class InvalidDowncast$CodePost는 '실제 객체의 클래스는 Post인데, 이것을 CodePost로 캐스팅할 수 없다'는 말입니다. 앞 글에서 본 것처럼 $는 중첩 클래스라는 표시이고, 괄호 안의 모듈과 클래스 로더 이야기는 지금은 넘어가도 됩니다. 둘째 줄은 캐스트를 한 4번째 줄에서 예외가 났다고 알려 줍니다. 그러니 codePost에는 아무것도 대입되지 않았고, 5번째 줄의 commit()은 실행되지도 않았습니다.

실패한 이유는 객체에 있습니다. new Post()로 만든 객체에는 Post가 선언한 부분만 있고, CodePost가 덧붙인 commit()은 없습니다. 캐스트는 없는 부분을 만들어 주지 않습니다. (CodePost) post가 실행되면 JVM은 post가 가리키는 객체를 정말 CodePost로 다룰 수 있는지 검사하고, 그럴 수 없으면 바로 ClassCastException을 던집니다. 이 검사가 없다면 다음 줄의 codePost.commit()이 객체에 없는 메서드를 실행하려 들 것입니다.

그렇다면 실패할 것이 뻔한 코드를 컴파일러는 왜 통과시켰을까요? 사람은 바로 윗줄의 new Post()를 보고 알 수 있지만, 컴파일러가 따지는 것은 변수의 타입입니다. Post 타입 변수에는 Post 객체뿐 아니라 CodePost 객체도 담길 수 있고, 메서드 매개변수라면 호출할 때마다 다른 객체가 들어옵니다. 캐스트가 성공할지는 실행해서 실제 객체를 봐야 알 수 있으므로, 컴파일러는 개발자가 적은 캐스트를 믿고 통과시키는 대신 실행 시점에 검사하게 합니다. 정리하면 캐스트 없이 자식 타입 변수에 대입하면 컴파일 시점에 막히고, 캐스트를 적으면 실행 시점에 검사를 받습니다.

앞 글에서 본 ((CodePost) post).commit()처럼 결과를 변수에 담지 않고 한 식 안에서만 쓰는 일시적 다운캐스팅도 똑같은 검사를 거칩니다. 코드가 짧아질 뿐, 실제 객체가 CodePost가 아니면 같은 예외가 납니다.

더 깊이: 캐스트는 무엇으로 컴파일되는가

javap -c로 클래스 파일을 들여다보면 두 방향의 차이가 그대로 드러납니다. SafeUpcasting의 업캐스팅 대입에는 참조값을 다른 지역 변수로 옮기는 명령만 있고 검사 명령은 없습니다. 반면 InvalidDowncast의 (CodePost) post 자리에는 checkcast라는 명령이 들어갑니다. 이 명령은 참조가 가리키는 객체의 클래스가 대상 타입이거나 그 하위 타입인지 확인해, 맞으면 같은 참조값을 그대로 넘기고 아니면 ClassCastException을 던집니다. 참조가 null이면 검사할 객체가 없으므로 그대로 통과시킵니다. 캐스트가 객체를 복사하거나 변환하지 않는다는 것, 그리고 뒤에서 볼 것처럼 null이 캐스트를 통과한다는 것이 모두 이 동작에서 나옵니다.

같은 Post 변수라도 결과는 객체가 정한다

캐스트의 성공과 실패를 가르는 것이 변수가 아니라 객체라는 점을 더 분명히 보겠습니다. 다음 예제는 Post 매개변수를 받는 메서드 하나에 CodePost 객체와 TextPost 객체를 차례로 넘깁니다. 메서드 안에서는 매개변수를 CodePost로 캐스팅하고, 실패하면 catch에서 예외를 잡아 메시지를 출력합니다.

src/CastOutcomeTable.java
public final class CastOutcomeTable {
    public static void main(String[] args) {
        Post first = new CodePost();
        Post second = new TextPost();

        printCast(first);
        printCast(second);
    }

    private static void printCast(Post post) {
        try {
            CodePost codePost = (CodePost) post;
            codePost.commit();
        } catch (ClassCastException exception) {
            System.out.println("not-code-post=" + post.getClass().getSimpleName());
        }
    }

    private static class Post {}

    private static final class CodePost extends Post {
        void commit() {
            System.out.println("commit");
        }
    }

    private static final class TextPost extends Post {}
}
commit
not-code-post=TextPost

두 호출에서 printCast의 매개변수 타입은 똑같이 Post이고, 다른 것은 넘어온 객체뿐입니다. 첫 호출의 객체는 CodePost이므로 검사를 통과하고 commit()이 실행되어 commit이 출력됩니다. 둘째 호출의 TextPost는 CodePost와 부모가 같은 형제 클래스일 뿐 CodePost의 하위 클래스가 아닙니다. 그래서 캐스트에서 ClassCastException이 나고, 같은 메서드의 catch가 이를 잡아 not-code-post=TextPost를 출력합니다. getClass()는 모든 객체가 Object에게서 물려받는 메서드로 실제 객체의 클래스를 알려 주고, getSimpleName()은 그 클래스의 짧은 이름을 돌려줍니다. 변수 타입이 Post인데도 TextPost가 출력된 것은 이 메서드가 변수가 아니라 객체를 보기 때문입니다.

이처럼 Post post라는 선언만 봐서는 (CodePost) post가 성공할지 알 수 없습니다. 그 참조가 어떤 new 식으로 만든 객체를 가리키는지까지 따라가야 합니다. 규칙으로 정리하면, (CodePost) post는 post가 가리키는 객체가 CodePost이거나 CodePost의 하위 클래스일 때만 성공합니다.

post가 가리키는 것(CodePost) post의 결과이유
new CodePost()로 만든 객체성공대상 타입과 같은 클래스
new FeaturedCodePost()로 만든 객체성공CodePost의 하위 클래스라 CodePost 부분이 있음
new Post()로 만든 객체ClassCastException부모 클래스라 CodePost 부분이 없음
new TextPost()로 만든 객체ClassCastException형제 클래스라 CodePost 부분이 없음
null성공, 결과도 null검사할 객체가 없음

아래 그림은 InvalidDowncast의 Post 객체와 CastOutcomeTable의 두 호출을 나란히 놓고 단계별로 보여 줍니다. 세 경우 모두 캐스트 직전 참조의 타입은 Post이고 대상은 CodePost입니다. 실패한 두 경우가 어떻게 다르게 끝나는지도 눈여겨보세요. InvalidDowncast에는 예외를 잡는 곳이 없어 예외가 main 밖으로 전파되고 프로그램이 끝납니다. 반면 CastOutcomeTable에서는 printCast 안의 catch가 예외를 잡았기 때문에 메시지를 출력하고 다음 일을 계속합니다.

같은 Post 정적 타입에서도 달라지는 다운캐스팅 결과

독립 Post/CodePost/TextPost 입력에서 cast가 실패하면 commit에 도달하지 않고 성공하면 실행합니다. checkcast 게이트와 catch/미처리 전파의 차이를 단계로 설명할 수 있습니다.

같은 Post 정적 타입에서도 달라지는 다운캐스팅 결과

InvalidDowncast의 Post 객체는 CodePost 캐스트에서 처리되지 않은 예외로 끝난다. 별도 CastOutcomeTable의 CodePost는 commit을 실행하고 TextPost는 캐스트 실패를 잡아 not-code-post를 출력한다.

다운캐스팅의 성공은 실제 객체와의 호환성에 달려 있습니다

세 경우 모두 캐스트 직전 참조의 정적 타입은 Post이고 대상은 CodePost입니다. 첫 행과 나머지 두 행은 서로 다른 프로그램입니다.

실제 객체와 예외 처리 위치에 따른 CodePost 캐스트 결과
원문 호출실제 객체캐스트 이후
InvalidDowncastnew Post()ClassCastException이 밖으로 전파됩니다.

codePost에 참조를 대입하지 못하고 commit() 호출에 도달하지 않습니다.

CastOutcomeTable

첫 번째 printCast

new CodePost()캐스트 성공 후 commit()을 호출합니다.

출력: commit

CastOutcomeTable

두 번째 printCast

new TextPost()ClassCastException을 같은 메서드의 catch가 처리합니다.

commit()은 호출하지 않고 not-code-post=TextPost를 출력합니다.

다운캐스팅의 성공은 실제 객체와의 호환성에 달려 있습니다 InvalidDowncast Post→CCE외부전파/commit미도달;별도CastOutcomeTable CodePost cast정상commit출력,TextPost CCE catch not-code-post=TextPost출력. 세경우동일정적Post/원프로그램범위유지. 별도 InvalidDowncast Post 참조 new Post() (CodePost) post cast 실패 ClassCastException 밖으로 전파 commit 도달 못함 표 예제 첫 호출 Post 참조 new CodePost() (CodePost) post CodePost cast 성공 commit() 호출 출력: commit 표 예제 둘째 호출 Post 참조 new TextPost() (CodePost) post cast 실패 → catch ClassCastException not-code-post= TextPost 같은 정적 타입 ≠ 같은 대상 각 상자는 독립 입력의 결과
다운캐스팅의 성공은 실제 객체와의 호환성에 달려 있습니다 InvalidDowncast Post→CCE외부전파/commit미도달;별도CastOutcomeTable CodePost cast정상commit출력,TextPost CCE catch not-code-post=TextPost출력. 세경우동일정적Post/원프로그램범위유지. 별도 InvalidDowncast Post 참조 new Post() (CodePost) post cast 실패 ClassCastException 밖으로 전파 commit 도달 못함 표 예제 첫 호출 Post 참조 new CodePost() (CodePost) post CodePost cast 성공 commit() 호출 출력: commit 표 예제 둘째 호출 Post 참조 new TextPost() (CodePost) post cast 실패 → catch ClassCastException not-code-post= TextPost 같은 정적 타입 ≠ 같은 대상 각 상자는 독립 입력의 결과
  1. InvalidDowncast · new Post()

    CodePost 캐스트에서 ClassCastException이 밖으로 전파됩니다. codePost에 참조를 대입하지 못하고 commit() 호출에 도달하지 않습니다.

  2. CastOutcomeTable의 첫 호출 · new CodePost()

    캐스트 성공 후 commit()을 호출하여 commit을 출력합니다.

  3. CastOutcomeTable의 둘째 호출 · new TextPost()

    캐스트 실패를 같은 printCast의 catch가 처리합니다. commit()은 호출하지 않고 not-code-post=TextPost를 출력합니다.

성공한 참조 캐스트도 새 자식 객체를 만들지 않습니다. 실제 객체와의 호환성을 확인할 뿐이며, 뒤의 BoundaryDowncast는 예외 대신 instanceof 검사로 자식 정보가 필요한 경계를 좁힙니다.

이 예의 조건

  • InvalidDowncast와 표 예제는 별도 프로그램입니다. 모두 정적 Post 타입입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2InvalidDowncast의 Post→CCE 외부 전파/commit 미도달이다. 별도 CastOutcomeTable의 CodePost는 cast 정상 통과 후 commit 출력, TextPost는 CCE catch 후 not-code-post=TextPost 출력이다. 세 경우 모두 동일한 정적 Post 타입이며 원 프로그램의 범위를 유지한다.
원 도표의 설명과 주석
구분내용
설명InvalidDowncast의 Post→CCE 외부 전파/commit 미도달이다. 별도 CastOutcomeTable의 CodePost는 cast 정상 통과 후 commit 출력, TextPost는 CCE catch 후 not-code-post=TextPost 출력이다. 세 경우 모두 동일한 정적 Post 타입이며 원 프로그램의 범위를 유지한다.
전체 읽기 정보같은 Post 정적 타입에서도 달라지는 다운캐스팅 결과 같은 Post 정적 타입에서도 달라지는 다운캐스팅 결과 InvalidDowncast의 Post 객체는 CodePost 캐스트에서 처리되지 않은 예외로 끝난다. 별도 CastOutcomeTable의 CodePost는 commit을 실행하고 TextPost는 캐스트 실패를 잡아 not-code-post를 출력한다. 다운캐스팅의 성공은 실제 객체와의 호환성에 달려 있습니다 세 경우 모두 캐스트 직전 참조의 정적 타입은 Post 이고 대상은 CodePost 입니다. 첫 행과 나머지 두 행은 서로 다른 프로그램입니다. 실제 객체와 예외 처리 위치에 따른 CodePost 캐스트 결과 원문 호출 실제 객체 캐스트 이후 InvalidDowncast new Post() ClassCastException 이 밖으로 전파됩니다. codePost 에 참조를 대입하지 못하고 commit() 호출에 도달하지 않습니다. CastOutcomeTable 첫 번째 printCast new CodePost() 캐스트 성공 후 commit() 을 호출합니다. 출력: commit CastOutcomeTable 두 번째 printCast new TextPost() ClassCastException 을 같은 메서드의 catch 가 처리합니다. commit() 은 호출하지 않고 not-code-post=TextPost 를 출력합니다. InvalidDowncast · new Post() CodePost 캐스트에서 ClassCastException 이 밖으로 전파됩니다. codePost 에 참조를 대입하지 못하고 commit() 호출에 도달하지 않습니다. CastOutcomeTable 의 첫 호출 · new CodePost() 캐스트 성공 후 commit() 을 호출하여 commit 을 출력합니다. CastOutcomeTable 의 둘째 호출 · new TextPost() 캐스트 실패를 같은 printCast 의 catch 가 처리합니다. commit() 은 호출하지 않고 not-code-post=TextPost 를 출력합니다. 성공한 참조 캐스트도 새 자식 객체를 만들지 않습니다. 실제 객체와의 호환성을 확인할 뿐이며, 뒤의 BoundaryDowncast 는 예외 대신 instanceof 검사로 자식 정보가 필요한 경계를 좁힙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
실제 Post 입력

new Post를 CodePost로 캐스트합니다.

  • 결과: ClassCastException 외부 전파
  • commit: 미도달
  • 정적 타입 일치가 실제 객체 호환성을 보장하지 않습니다.
실제 CodePost 입력

표 첫 호출의 CodePost 객체가 캐스트를 통과합니다.

  • cast: 성공
  • 호환되는 실제 객체만 자식 메서드에 진입합니다.
실제 TextPost 입력

표 둘째 호출은 캐스트 실패를 catch합니다.

  • 예외: ClassCastException
  • 출력: not-code-post=TextPost
  • 같은 실패라도 catch 범위가 관측 결과를 바꿉니다.

같은 Post 정적 타입에서도 달라지는 다운캐스팅 결과

독립 Post/CodePost/TextPost 입력에서 cast가 실패하면 commit에 도달하지 않고 성공하면 실행합니다. checkcast 게이트와 catch/미처리 전파의 차이를 단계로 설명할 수 있습니다.

같은 Post 정적 타입에서도 달라지는 다운캐스팅 결과

InvalidDowncast의 Post 객체는 CodePost 캐스트에서 처리되지 않은 예외로 끝난다. 별도 CastOutcomeTable의 CodePost는 commit을 실행하고 TextPost는 캐스트 실패를 잡아 not-code-post를 출력한다.

다운캐스팅의 성공은 실제 객체와의 호환성에 달려 있습니다

세 경우 모두 캐스트 직전 참조의 정적 타입은 Post이고 대상은 CodePost입니다. 첫 행과 나머지 두 행은 서로 다른 프로그램입니다.

실제 객체와 예외 처리 위치에 따른 CodePost 캐스트 결과
원문 호출실제 객체캐스트 이후
InvalidDowncastnew Post()ClassCastException이 밖으로 전파됩니다.

codePost에 참조를 대입하지 못하고 commit() 호출에 도달하지 않습니다.

CastOutcomeTable

첫 번째 printCast

new CodePost()캐스트 성공 후 commit()을 호출합니다.

출력: commit

CastOutcomeTable

두 번째 printCast

new TextPost()ClassCastException을 같은 메서드의 catch가 처리합니다.

commit()은 호출하지 않고 not-code-post=TextPost를 출력합니다.

다운캐스팅의 성공은 실제 객체와의 호환성에 달려 있습니다 InvalidDowncast Post→CCE외부전파/commit미도달;별도CastOutcomeTable CodePost cast정상commit출력,TextPost CCE catch not-code-post=TextPost출력. 세경우동일정적Post/원프로그램범위유지. 별도 InvalidDowncast Post 참조 new Post() (CodePost) post cast 실패 ClassCastException 밖으로 전파 commit 도달 못함 표 예제 첫 호출 Post 참조 new CodePost() (CodePost) post CodePost cast 성공 commit() 호출 출력: commit 표 예제 둘째 호출 Post 참조 new TextPost() (CodePost) post cast 실패 → catch ClassCastException not-code-post= TextPost 같은 정적 타입 ≠ 같은 대상 각 상자는 독립 입력의 결과
다운캐스팅의 성공은 실제 객체와의 호환성에 달려 있습니다 InvalidDowncast Post→CCE외부전파/commit미도달;별도CastOutcomeTable CodePost cast정상commit출력,TextPost CCE catch not-code-post=TextPost출력. 세경우동일정적Post/원프로그램범위유지. 별도 InvalidDowncast Post 참조 new Post() (CodePost) post cast 실패 ClassCastException 밖으로 전파 commit 도달 못함 표 예제 첫 호출 Post 참조 new CodePost() (CodePost) post CodePost cast 성공 commit() 호출 출력: commit 표 예제 둘째 호출 Post 참조 new TextPost() (CodePost) post cast 실패 → catch ClassCastException not-code-post= TextPost 같은 정적 타입 ≠ 같은 대상 각 상자는 독립 입력의 결과
  1. InvalidDowncast · new Post()

    CodePost 캐스트에서 ClassCastException이 밖으로 전파됩니다. codePost에 참조를 대입하지 못하고 commit() 호출에 도달하지 않습니다.

  2. CastOutcomeTable의 첫 호출 · new CodePost()

    캐스트 성공 후 commit()을 호출하여 commit을 출력합니다.

  3. CastOutcomeTable의 둘째 호출 · new TextPost()

    캐스트 실패를 같은 printCast의 catch가 처리합니다. commit()은 호출하지 않고 not-code-post=TextPost를 출력합니다.

성공한 참조 캐스트도 새 자식 객체를 만들지 않습니다. 실제 객체와의 호환성을 확인할 뿐이며, 뒤의 BoundaryDowncast는 예외 대신 instanceof 검사로 자식 정보가 필요한 경계를 좁힙니다.

이 예의 조건

  • InvalidDowncast와 표 예제는 별도 프로그램입니다. 모두 정적 Post 타입입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2InvalidDowncast의 Post→CCE 외부 전파/commit 미도달이다. 별도 CastOutcomeTable의 CodePost는 cast 정상 통과 후 commit 출력, TextPost는 CCE catch 후 not-code-post=TextPost 출력이다. 세 경우 모두 동일한 정적 Post 타입이며 원 프로그램의 범위를 유지한다.
원 도표의 설명과 주석
구분내용
설명InvalidDowncast의 Post→CCE 외부 전파/commit 미도달이다. 별도 CastOutcomeTable의 CodePost는 cast 정상 통과 후 commit 출력, TextPost는 CCE catch 후 not-code-post=TextPost 출력이다. 세 경우 모두 동일한 정적 Post 타입이며 원 프로그램의 범위를 유지한다.
전체 읽기 정보같은 Post 정적 타입에서도 달라지는 다운캐스팅 결과 같은 Post 정적 타입에서도 달라지는 다운캐스팅 결과 InvalidDowncast의 Post 객체는 CodePost 캐스트에서 처리되지 않은 예외로 끝난다. 별도 CastOutcomeTable의 CodePost는 commit을 실행하고 TextPost는 캐스트 실패를 잡아 not-code-post를 출력한다. 다운캐스팅의 성공은 실제 객체와의 호환성에 달려 있습니다 세 경우 모두 캐스트 직전 참조의 정적 타입은 Post 이고 대상은 CodePost 입니다. 첫 행과 나머지 두 행은 서로 다른 프로그램입니다. 실제 객체와 예외 처리 위치에 따른 CodePost 캐스트 결과 원문 호출 실제 객체 캐스트 이후 InvalidDowncast new Post() ClassCastException 이 밖으로 전파됩니다. codePost 에 참조를 대입하지 못하고 commit() 호출에 도달하지 않습니다. CastOutcomeTable 첫 번째 printCast new CodePost() 캐스트 성공 후 commit() 을 호출합니다. 출력: commit CastOutcomeTable 두 번째 printCast new TextPost() ClassCastException 을 같은 메서드의 catch 가 처리합니다. commit() 은 호출하지 않고 not-code-post=TextPost 를 출력합니다. InvalidDowncast · new Post() CodePost 캐스트에서 ClassCastException 이 밖으로 전파됩니다. codePost 에 참조를 대입하지 못하고 commit() 호출에 도달하지 않습니다. CastOutcomeTable 의 첫 호출 · new CodePost() 캐스트 성공 후 commit() 을 호출하여 commit 을 출력합니다. CastOutcomeTable 의 둘째 호출 · new TextPost() 캐스트 실패를 같은 printCast 의 catch 가 처리합니다. commit() 은 호출하지 않고 not-code-post=TextPost 를 출력합니다. 성공한 참조 캐스트도 새 자식 객체를 만들지 않습니다. 실제 객체와의 호환성을 확인할 뿐이며, 뒤의 BoundaryDowncast 는 예외 대신 instanceof 검사로 자식 정보가 필요한 경계를 좁힙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
실제 Post 입력

new Post를 CodePost로 캐스트합니다.

  • 결과: ClassCastException 외부 전파
  • commit: 미도달
  • 정적 타입 일치가 실제 객체 호환성을 보장하지 않습니다.
실제 CodePost 입력

표 첫 호출의 CodePost 객체가 캐스트를 통과합니다.

  • cast: 성공
  • 호환되는 실제 객체만 자식 메서드에 진입합니다.
실제 TextPost 입력

표 둘째 호출은 캐스트 실패를 catch합니다.

  • 예외: ClassCastException
  • 출력: not-code-post=TextPost
  • 같은 실패라도 catch 범위가 관측 결과를 바꿉니다.

CastOutcomeTable은 캐스트가 실패하는 모습을 보여 주려고 예외를 잡았지만, 실제 코드에서 이렇게 쓰는 것은 좋지 않습니다. 예외는 예상하지 못한 문제를 알리는 수단인데, 여기서는 'CodePost가 아니면 다른 일을 한다'는 평범한 분기를 예외로 처리하고 있습니다. 읽는 사람은 catch까지 내려가 보고 나서야 이 코드가 원래 분기를 의도했다는 것을 알게 됩니다. 이 글의 마지막 절에서 볼 instanceof 검사가 그 의도를 코드 앞쪽에 드러내는 방법입니다.

null은 캐스트를 통과한다

표의 마지막 줄을 따로 보겠습니다. 참조가 null이면 캐스트는 예외를 던지지 않고, 결과도 null입니다.

Post empty = null;
CodePost child = (CodePost) empty;  // 예외 없음, child는 null
child.commit();                     // 여기서 NullPointerException

둘째 줄은 문제없이 실행되고 child에 null이 들어갑니다. 가리키는 객체가 없으니 '이 객체는 CodePost가 아니다'라고 판단할 대상도 없기 때문입니다. Java 언어 명세의 참조형 좁힘 변환 규칙도 실행 시점의 값이 null이면 변환을 허용한다고 정해 두었습니다. 문제는 셋째 줄입니다. 메서드를 실행할 객체가 없으므로 null과 NullPointerException에서 본 NullPointerException이 납니다. 캐스트가 성공했다고 해서 그 뒤의 메서드 호출까지 안전한 것은 아닙니다.

예측해 보기: printCast에 null을 넘기면?

CastOutcomeTable의 main 끝에 printCast(null);을 한 줄 더 넣는다고 해 봅시다. 캐스트, commit() 호출, catch (ClassCastException exception) 가운데 어디까지 진행될까요? not-code-post=null이 출력될까요?

캐스트는 null을 그대로 통과시키므로 codePost에 null이 대입됩니다. 그다음 codePost.commit()에서 NullPointerException이 납니다. 이 catch는 ClassCastException만 잡으므로 NullPointerException은 잡히지 않고 printCast 밖으로, 다시 main 밖으로 전파됩니다. 그래서 commit과 not-code-post=TextPost 두 줄이 출력된 뒤 예외 메시지가 나오고 프로그램이 끝나며, not-code-post=null은 출력되지 않습니다. null을 받으면 안 되는 메서드라면 첫 줄에서 null인지 검사하고, null을 허용한다면 그때 무엇을 할지 따로 정해 두어야 합니다.

컴파일러가 막는 캐스트

캐스트를 적으면 무엇이든 컴파일되는 것은 아닙니다. 다음 두 캐스트는 컴파일 단계에서 거절됩니다.

Post post = new Post();
String text = (String) post;              // 컴파일 오류

TextPost textPost = new TextPost();
CodePost codePost = (CodePost) textPost;  // 컴파일 오류

첫 캐스트를 컴파일하면 incompatible types: Post cannot be converted to String 오류가, 둘째 캐스트는 incompatible types: TextPost cannot be converted to CodePost 오류가 납니다. 두 경우 모두 성공할 가능성이 전혀 없기 때문입니다. String과 Post는 어느 쪽도 다른 쪽의 하위 클래스가 아니므로 Post이면서 동시에 String인 객체는 있을 수 없습니다. TextPost 변수에 담길 수 있는 것은 TextPost와 그 하위 클래스의 객체뿐인데, 그중 어느 것도 CodePost가 아닙니다. 앞에서 (CodePost) post를 통과시킨 것은 Post 변수에 CodePost 객체가 담길 가능성이 있었기 때문이고, 그런 가능성이 아예 없으면 컴파일러가 미리 막아 줍니다.

그런데 공통 부모를 거치면 이 검사를 피해 갈 수 있습니다. 모든 클래스의 조상인 Object 타입 변수에는 어떤 객체든 담길 수 있으니, Object 변수를 Post로 캐스팅하는 코드는 컴파일됩니다.

Object value = "hello";
Post post = (Post) value;  // 컴파일은 되지만 실행하면 ClassCastException

하지만 실행 시점의 검사까지 피할 수는 없습니다. value가 가리키는 객체는 여전히 String이므로, 둘째 줄에서 String을 Post로 캐스팅할 수 없다는 ClassCastException이 납니다. 어떤 경로로 캐스팅하든 객체의 종류는 바뀌지 않습니다.

이 점에서 참조형 캐스트는 같은 괄호 문법을 쓰는 기본형 형변환과 다릅니다. 변수 스코프와 숫자 형변환에서 본 (int) 42.9는 소수부를 버린 새 값 42를 만듭니다. 반면 참조형 캐스트는 참조값도 객체도 그대로 둡니다. 필드를 복사하거나 자식 객체를 새로 만들지도 않고, 같은 객체를 그 타입으로 다뤄도 되는지 검사만 합니다. 그래서 기본형 형변환은 정보를 잃을 수는 있어도 예외를 던지지 않고, 참조형 캐스트는 정보를 잃지 않는 대신 검사에 실패하면 예외를 던집니다.

더 깊이: 컴파일러가 캐스트를 거절하는 기준

Java 언어 명세의 캐스트 규칙은 두 타입에 공통인 객체가 있을 수 없다는 것을 컴파일 시점에 알 수 있을 때 캐스트를 거절합니다. 두 타입이 모두 클래스라면 한쪽이 다른 쪽의 하위 클래스일 때만 허용합니다. 클래스와 인터페이스 사이는 조금 다릅니다. Post가 Runnable을 구현하지 않았더라도 (Runnable) post는 컴파일됩니다. Post를 상속하면서 Runnable을 구현하는 하위 클래스가 있을 수 있기 때문이고, 실제 객체가 그런 하위 클래스가 아니면 실행 시점에 ClassCastException이 납니다. 반면 Post를 final로 선언하면 하위 클래스가 생길 수 없으므로, 같은 캐스트가 incompatible types 컴파일 오류가 됩니다.

캐스팅하기 전에 instanceof로 확인하기

다운캐스팅이 실패한다는 것을 예외로 알게 되는 대신, 캐스팅하기 전에 실제 객체의 타입을 물어볼 수 있습니다. 앞 글의 PostDetails에서 쓴 instanceof 연산자가 그 일을 합니다. post instanceof CodePost는 post가 null이 아니고, 가리키는 객체가 CodePost이거나 그 하위 클래스이면 true가 되는 boolean 식입니다. 앞의 표에서 캐스트가 성공하는 조건과 같고 null만 다릅니다. 캐스트는 null을 통과시키지만 instanceof는 false를 돌려줍니다. 다음 예제는 코드 게시글의 브랜치 이름을 출력하는 메서드에서 이 검사를 먼저 하고, 코드 게시글이 아니면 건너뜁니다.

src/BoundaryDowncast.java
public final class BoundaryDowncast {
    public static void main(String[] args) {
        Post[] posts = {
                new CodePost("feature/login"),
                new TextPost()
        };

        for (Post post : posts) {
            exportCodeDetail(post);
        }
    }

    private static void exportCodeDetail(Post post) {
        if (!(post instanceof CodePost)) {
            System.out.println("skip-non-code-post");
            return;
        }
        CodePost codePost = (CodePost) post;
        System.out.println("branch=" + codePost.branch);
    }

    private static class Post {}

    private static final class CodePost extends Post {
        private final String branch;
        CodePost(String branch) { this.branch = branch; }
    }

    private static final class TextPost extends Post {}
}
branch=feature/login
skip-non-code-post

배열의 첫 객체는 CodePost이므로 검사가 true이고, 그 아래의 캐스트는 실패할 수 없습니다. 방금 확인한 사실을 컴파일러에게 다시 알려 주는 것뿐이기 때문입니다. 그래서 branch=feature/login이 출력됩니다. 둘째 객체 TextPost는 검사가 false여서 skip-non-code-post를 출력하고 바로 return합니다. 캐스트까지 가지 않으니 예외도 나지 않습니다. null이 넘어와도 instanceof가 false이므로 같은 길로 빠집니다.

이 예제에서 눈여겨볼 점은 타입 검사와 캐스팅을 exportCodeDetail 한 곳에 모았다는 것입니다. 조회 수 합계나 게시처럼 모든 게시글에 공통인 일은 부모 타입의 메서드와 오버라이딩으로 처리하고, 코드 게시글의 브랜치처럼 특정 자식에게만 있는 정보가 정말 필요한 곳에서만 타입을 좁힙니다. 이런 곳이 프로그램 여기저기로 늘어난다면 앞 글에서 이야기한 것처럼 그 동작을 부모 타입에 선언할 수 없는지 먼저 따져 봐야 합니다.

검사한 뒤에 캐스트를 한 번 더 적는 것이 번거롭게 느껴질 수 있습니다. CodePost라는 이름을 검사와 캐스트, 변수 선언에 세 번이나 썼으니까요. 다음 글에서는 검사와 캐스트를 한 번에 하는 패턴 매칭을 배우고, 이어서 타입을 묻는 대신 객체가 알아서 자기 동작을 실행하게 하는 오버라이딩으로 넘어갑니다.

정리

  • 업캐스팅은 자식 타입을 부모 타입으로 넓히는 것입니다. 부모 타입이 약속하는 메서드는 자식 객체에 언제나 있으므로 캐스트 없이 자동으로 일어나고, 객체는 그대로 둔 채 변수로 호출할 수 있는 메서드의 범위만 줄어듭니다.
  • 다운캐스팅은 부모 타입을 자식 타입으로 좁히는 것으로, 괄호로 명시해야 합니다. 컴파일러는 성공할 가능성이 있으면 통과시키고, 실행할 때 실제 객체가 그 타입이거나 하위 타입인지 검사해 아니면 ClassCastException을 던집니다.
  • 캐스트는 객체를 바꾸거나 없는 부분을 만들지 않습니다. 성공 여부는 변수의 타입이 아니라 실제 객체가 정합니다.
  • null은 캐스트를 통과하고 결과도 null입니다. 문제는 그 뒤의 메서드 호출에서 NullPointerException으로 나타납니다.
  • 공통인 객체가 있을 수 없는 타입 사이의 캐스트는 컴파일 오류입니다. Object를 거쳐 컴파일을 통과시켜도 실행 시점의 검사는 피할 수 없습니다.
  • 예외를 잡아 분기하기보다 instanceof로 먼저 확인하고, 그런 검사는 자식 전용 정보가 꼭 필요한 곳에 모아 둡니다.

연습 문제

문제 1: 세 단계 계층에서 두 번 좁히기

Post → CodePost → FeaturedCodePost 계층을 만들고, 맨 아래 클래스인 FeaturedCodePost의 객체를 Post 변수에 담으세요. 이 변수를 CodePost로 좁혀 commit()을 호출하고, 다시 FeaturedCodePost로 좁혀 pinToTop()을 호출합니다.

해설 보기
src/MultiLevelCast.java
public final class MultiLevelCast {
    public static void main(String[] args) {
        Post post = new FeaturedCodePost();

        CodePost codePost = (CodePost) post;
        codePost.commit();

        FeaturedCodePost featured = (FeaturedCodePost) codePost;
        featured.pinToTop();
    }

    private static class Post {}

    private static class CodePost extends Post {
        void commit() { System.out.println("commit"); }
    }

    private static final class FeaturedCodePost extends CodePost {
        void pinToTop() { System.out.println("pinned"); }
    }
}
commit
pinned

실제 객체가 맨 아래의 FeaturedCodePost이므로 그 위의 어느 타입으로 좁혀도 검사를 통과합니다. (CodePost) post는 FeaturedCodePost가 CodePost의 하위 클래스라서 성공하고, (FeaturedCodePost) codePost는 클래스가 정확히 같아서 성공합니다. 두 번 캐스팅하는 동안 객체는 처음 만든 하나뿐이고, 변수가 바뀔 때마다 호출할 수 있는 메서드만 늘어납니다. 반대로 첫 줄에서 new CodePost()를 담았다면 첫 캐스트는 성공해도, FeaturedCodePost로 좁히는 둘째 캐스트에서 ClassCastException이 났을 것입니다.

문제 2: 컴파일될까, 실행될까

Post를 상속하는 CodePost와 TextPost가 있고, CodePost를 상속하는 FeaturedCodePost가 있습니다. Post post = new CodePost(); 다음에 아래 다섯 줄을 하나씩 따로 붙여 본다고 할 때, 각 줄이 컴파일 오류인지, 컴파일은 되지만 실행 중 예외가 나는지, 정상 실행되는지 답하세요.

Object object = (Object) post;
CodePost codePost = (CodePost) post;
FeaturedCodePost featured = (FeaturedCodePost) post;
TextPost textPost = (TextPost) post;
String text = (String) post;
해설 보기
  1. 정상 실행됩니다. Object는 모든 클래스의 조상이므로 업캐스팅이고, 캐스트는 적지 않아도 됩니다.
  2. 정상 실행됩니다. 실제 객체가 CodePost입니다.
  3. 컴파일은 되지만 실행 중 ClassCastException이 납니다. Post 변수에 FeaturedCodePost 객체가 담길 수 있으니 컴파일러는 통과시키지만, 실제 객체인 CodePost는 FeaturedCodePost의 부모 클래스라서 FeaturedCodePost가 덧붙인 부분이 없습니다.
  4. 컴파일은 되지만 실행 중 ClassCastException이 납니다. TextPost도 Post의 하위 클래스이므로 컴파일러는 가능성을 인정하지만, 실제 객체인 CodePost는 TextPost의 형제 클래스입니다.
  5. 컴파일 오류입니다. String과 Post는 어느 쪽도 다른 쪽의 하위 클래스가 아니어서 두 타입에 공통인 객체가 있을 수 없습니다.

3번과 4번이 핵심입니다. 컴파일러는 변수의 타입으로 가능성만 따지고, 실제로 성공하는지는 실행 시점의 객체가 정합니다.