안동민 개발노트

본문 시작

다형성과 캐스팅

부모 참조를 자식 변수에 바로 대입할 수 없는 이유와 명시적 다운캐스팅이 참조 관점만 바꾼다는 사실을 확인합니다.

부모 타입 참조는 여러 자식 객체를 담을 수 있어 유연하지만 자식 전용 멤버는 보이지 않습니다.

실제 객체가 특정 자식임을 알고 그 행동이 꼭 필요할 때 다운캐스팅으로 참조의 정적 타입을 좁힐 수 있습니다.

캐스팅은 새 객체를 만들거나 기존 객체의 본질을 바꾸지 않습니다.


다운캐스팅 컴파일 오류

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

    private static class Post {}

    private static final class CodePost extends Post {
        void commit() {
            System.out.println("commit");
        }
    }
}
컴파일 실패 관찰
error: incompatible types: Post cannot be converted to CodePost

post 변수의 타입만 보면 Post 자체, TextPost, CodePost 등 어느 하위 객체든 담길 수 있습니다.

컴파일러가 항상 CodePost라고 보장할 수 없으므로 자동 대입을 막습니다.

실제 초기화 식을 사람이 볼 수 있어도 변수는 이후 다른 객체를 받을 수 있다는 타입 계약을 따릅니다.

명시적 캐스트로 개발자가 조건을 책임질 수 있습니다.

CodePost codePost = (CodePost) post;

실행 시 실제 객체가 CodePost 호환인지 검사하고, 맞으면 같은 참조값을 자식 타입 변수에 담습니다.


올바른 다운캐스팅

src/ValidDowncast.java
public final class ValidDowncast {
    public static void main(String[] args) {
        CodePost original = new CodePost("feature/login");
        Post parentView = original;

        parentView.publish();
        CodePost childView = (CodePost) parentView;
        childView.commit();

        System.out.println("same=" + (original == childView));
    }

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

    private static final class CodePost extends Post {
        private final String branch;

        CodePost(String branch) {
            this.branch = branch;
        }

        void commit() {
            System.out.println("commit=" + branch);
        }
    }
}
publish
commit=feature/login
same=true

original, parentView, childView는 모두 같은 객체를 가리킵니다.

업캐스팅 때 CodePost 부분이 제거되지 않았고 다운캐스팅 때 새로 생기지도 않았습니다.

변수의 정적 타입이 제공하는 멤버 목록만 달라집니다.

다운캐스팅 뒤에도 세 변수가 가리키는 객체는 하나

ValidDowncast에서 original을 parentView에 대입하고 다시 childView로 캐스팅해도 세 변수는 branch가feature/login인 같은 CodePost 객체를 가리킨다. parentView의 선언 타입 Post는 바뀌지 않는다.

ValidDowncast에서 childView 대입까지 마친 뒤의 참조 관계입니다. 객체는 new CodePost("feature/login")에서 한 번 생성합니다.

세 정적 타입의 참조가 같은 CodePost를 가리킨다 CodePost original, Post parentView, CodePost childView는 branch feature/login인 같은 CodePost 객체를 가리킨다. parentView에는 original을 대입하고 childView에는 CodePost로 캐스팅한 parentView를 대입한다. CodePost original new로 얻은 참조 Post parentView parentView = original CodePost childView childView = (CodePost) parentView CodePost 객체 하나 branch = "feature/login" 업캐스팅: 객체 복사 없음 다운캐스팅: 새 객체 없음 parentView의 선언 타입은 계속 Post

세 변수는 같은 CodePost 객체를 가리키며 그 객체의 branch는 "feature/login"입니다.

  1. CodePost original: new로 얻은 참조입니다.
  2. Post parentView = original: 같은 참조를 부모 타입 변수에 담습니다.
  3. CodePost childView = (CodePost) parentView: 실제 객체가 호환되는지 검사한 뒤 같은 참조를 자식 타입 변수에 담습니다.

업캐스팅·다운캐스팅으로 객체를 복사하거나 새로 만들지 않습니다. parentView의 선언 타입은 계속 Post입니다.

parentView.publish()는 publish, childView.commit()은 commit=feature/login을 출력합니다. 원문의 original == childView 비교 출력은 same=true입니다.

화살표는 객체 참조이지 상속 방향이나 실행 순서가 아닙니다. 이 원문의 대상은 CodePost이므로 캐스트가 성공하며, 다른 실제 타입의 캐스팅 안전성을 보장하지 않습니다.


일회성 캐스트 표현식

자식 변수를 별도로 만들지 않고 한 호출에만 다운캐스팅할 수 있습니다.

src/TemporaryDowncast.java
public final class TemporaryDowncast {
    public static void main(String[] args) {
        Post post = new CodePost();

        ((CodePost) post).commit();
        post.publish();
    }

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

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

괄호가 필요한 이유는 멤버 접근보다 캐스트 범위를 먼저 묶기 위해서입니다.

캐스트 결과 표현식만 CodePost 타입이고 원래 post 변수의 선언 타입은 계속 Post입니다.

다음 줄에서 post.commit()은 여전히 컴파일되지 않습니다.

일시적 캐스팅은 한 번만 사용할 때 간단하지만 같은 자식 기능을 반복 호출한다면 자식 타입 지역 변수로 이름을 붙이는 편이 읽기 쉽습니다.

일시적 캐스트 표현식과 바뀌지 않는 post 변수

TemporaryDowncast에서 CodePost로 캐스팅한 표현식으로 commit을 호출한 다음 Post 타입인 원래 post로 publish를 호출한다. 출력은commit,publish이고 선언 타입은 처음부터 끝까지Post이다.

TemporaryDowncast는 Post post = new CodePost() 뒤 두 호출을 실행합니다.

일시적 캐스트와 다음 호출의 정적 타입 범위
실제 호출 순서멤버를 선택하는 표현식의 타입실행 구현 / 출력
((CodePost) post).commit()(CodePost) post 표현식은 CodePost입니다.CodePost.commit()

commit

post.publish()원래 변수 post의 선언 타입은 계속 Post입니다.Post.publish()

publish

  1. ((CodePost) post).commit()

    (CodePost) post 표현식의 타입은 CodePost입니다. CodePost.commit()을 실행하여 commit을 출력합니다.

  2. post.publish()

    변수 post의 선언 타입은 계속 Post입니다. Post.publish()를 실행하여 publish를 출력합니다.

변수 post가 잠시 CodePost 타입으로 바뀌었다가 돌아오는 것이 아닙니다. 캐스트 결과 표현식만 CodePost이며, 원래 변수의 선언 타입과 참조 대상은 바뀌지 않습니다.


캐스팅과 설계 신호

다형적 배열을 순회하며 매번 다음처럼 타입별 캐스팅을 한다면 공통 계약이 부족할 수 있습니다.

if (post instanceof CodePost) {
    ((CodePost) post).commit();
} else if (post instanceof TextPost) {
    ((TextPost) post).previewBody();
}

각 게시글이 수행해야 할 공통 목적이 게시판 발행이라면 부모의 publish()를 오버라이딩하는 편이 낫습니다.

반면 commit이 코딩 게시글에만 의미 있고 특정 관리 화면에서만 필요하다면 안전한 타입 검사와 캐스팅이 정당할 수 있습니다.

캐스팅은 타입 설계의 빈틈을 메우는 기본 도구가 아니라, 실제 구체 타입의 기능이 필요한 제한된 경계에서 사용합니다.

클라이언트가 구체 타입을 많이 알수록 새 하위 타입 추가 시 분기를 수정해야 합니다.


하위 타입 정보 접근

src/PostDetails.java
public final class PostDetails {
    public static void main(String[] args) {
        Post[] posts = {
                new CodePost("로그인 구현", "feature/login"),
                new TextPost("가입 인사", 120)
        };

        for (Post post : posts) {
            System.out.println(post.title());
            if (post instanceof CodePost) {
                CodePost codePost = (CodePost) post;
                System.out.println("branch=" + codePost.branch());
            }
        }
    }

    private static class Post {
        private final String title;

        Post(String title) {
            this.title = title;
        }

        String title() {
            return title;
        }
    }

    private static final class CodePost extends Post {
        private final String branch;

        CodePost(String title, String branch) {
            super(title);
            this.branch = branch;
        }

        String branch() {
            return branch;
        }
    }

    private static final class TextPost extends Post {
        private final int bodyLength;

        TextPost(String title, int bodyLength) {
            super(title);
            this.bodyLength = bodyLength;
        }
    }
}
로그인 구현
branch=feature/login
가입 인사

타입 검사 없이 둘째 TextPost를 CodePost로 캐스팅하면 실행 중 ClassCastException이 발생합니다.

다음 문서에서 업캐스팅이 안전한 이유와 다운캐스팅의 실패 조건을 더 정확히 추적합니다.


연습 문제

부모 타입 변수에 VideoPost 객체를 담고, 올바르게 다운캐스팅해 addCaption(String)을 호출하세요.

원래 부모 변수의 타입은 바뀌지 않는다는 것을 주석과 출력으로 확인합니다.

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

        video.addCaption("ko");
        post.publish();
    }

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

    private static final class VideoPost extends Post {
        void addCaption(String language) {
            System.out.println("caption=" + language);
        }
    }
}
caption=ko
publish

video와 post는 같은 객체를 가리키지만 각 변수에서 컴파일 가능한 멤버 범위가 다릅니다.