안동민 개발노트

본문 시작

업캐스팅과 다운캐스팅

자동 업캐스팅과 명시적 다운캐스팅의 방향을 비교하고 실제 객체에 없는 자식 부분을 요구할 때의 ClassCastException을 재현합니다.

캐스팅 방향은 상속 계층에서 이동하는 타입 관점의 방향입니다.

자식에서 부모로 넓히는 업캐스팅은 실제 객체 안에 부모 부분이 항상 있어 안전합니다.

부모에서 자식으로 좁히는 다운캐스팅은 실제 객체가 그 자식인지 확인되지 않으므로 실행 실패 가능성이 있습니다.

자식 변수에 보관하지 않고 ((CodePost) post).commit()처럼 한 표현식에서만 쓰는 방식을 일시적 다운 캐스팅이라고 합니다.

문법이 짧아져도 실제 타입이 다를 때의 위험은 같으므로 호환성 검사가 먼저입니다.


잘못된 다운캐스팅

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

post의 실제 객체는 Post 자체이므로 그 안에 CodePost 부분이 없습니다.

명시적 캐스트는 개발자가 책임지겠다는 표시일 뿐 존재하지 않는 자식 부분을 만들지 않습니다.

런타임 타입 검사가 실패해 예외가 납니다.

컴파일러가 캐스트 문법을 허용하는 이유는 다른 실행에서는 post가 CodePost 객체를 담을 수도 있기 때문입니다.

실행 값에 따라 성공 여부가 달라져 런타임 검사가 필요합니다.


업캐스팅의 안전성

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

FeaturedCodePost 인스턴스에는 자신과 부모 CodePost, Post에 해당하는 상태와 행동이 함께 있습니다.

어느 부모 관점으로 보더라도 필요한 부분이 실제 객체에 존재합니다.

그래서 (Post) featured처럼 명시할 필요 없이 자동 업캐스팅합니다.

업캐스팅하면 자식 전용 pinToTop이 삭제되는 것이 아니라 정적 타입 계약에서 보이지 않을 뿐입니다.

올바른 실제 타입을 알고 다시 다운캐스팅하면 접근할 수 있습니다.

세 정적 타입의 참조가 가리키는 하나의 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
publish(), commit(), pinToTop()이 보입니다.
codePost · 정적 타입 CodePost
publish(), commit()이 보입니다. featured와 같은 객체를 가리킵니다.
post · 정적 타입 Post
publish()이 보입니다. 앞의 두 참조와 같은 객체를 가리킵니다.
실제 객체는 하나
세 참조 모두 FeaturedCodePost 객체에 도달합니다. 업캐스팅으로 객체가 복제되거나 pinToTop()이 삭제되지는 않습니다.

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


다운캐스팅 성공 조건

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

변수 선언 Post post만 보면 결과를 알 수 없습니다.

그 참조가 어떤 new 표현식에서 만들어진 객체를 가리키는지 추적해야 합니다.

첫 참조는 CodePost여서 성공하고 둘째는 형제 타입 TextPost여서 실패합니다.

예외를 일반 제어 흐름으로 사용하는 것보다 instanceof로 먼저 검사하는 편이 의도를 드러냅니다.

다음 문서에서 패턴 매칭까지 연결합니다.

같은 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를 출력합니다.

  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 검사로 자식 정보가 필요한 경계를 좁힙니다.


상속 관계와 캐스팅

관련 없는 String과 Post 사이를 캐스팅해 객체 종류를 바꿀 수 없습니다.

컴파일러가 타입 관계상 절대 가능하지 않은 캐스트를 차단합니다.

Object처럼 공통 상위 타입을 거쳐 문법을 우회하더라도 실제 객체 호환성이 없으면 런타임에 실패합니다.

기본형 숫자 캐스팅과 참조형 캐스팅도 의미가 다릅니다.

  • 숫자 캐스팅은 값 표현을 바꾸며 정보가 잘릴 수 있습니다.
  • 참조 캐스팅은 같은 객체 참조를 다른 타입 관점으로 검사·해석합니다.
  • 참조 캐스팅은 객체 필드를 복사하거나 새 자식 객체를 만들지 않습니다.

다운캐스팅의 경계

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

구체 타입 세부 정보를 내보내는 경계 한 곳에 검사와 캐스팅을 모았습니다.

핵심 합계나 실행 로직은 부모 계약과 오버라이딩으로 처리하고, 자식 전용 정보가 실제로 필요한 출력 경계만 좁힙니다.


연습 문제

Post → CodePost → FeaturedCodePost 계층을 만들고 가장 구체적인 객체를 부모 변수에 담으세요.

CodePost와 FeaturedCodePost로 각각 안전하게 다운캐스팅해 메서드를 호출합니다.

해설 보기
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이므로 두 부모 관점에서 다시 좁혀도 호환됩니다.