본문 바로가기

다운캐스팅으로 자식 메서드 호출하기

부모 타입 변수를 자식 타입 변수에 바로 대입할 수 없는 이유와, 명시적 다운캐스팅이 객체를 바꾸지 않고 변수의 타입만 좁힌다는 사실을 배웁니다.

앞 글에서 본 것처럼 부모 타입 변수는 여러 자식 객체를 담을 수 있어서, Post 배열 하나로 모든 게시글을 처리할 수 있습니다. 그 대가로 변수가 Post 타입이면 컴파일러가 Post에 선언된 메서드만 허락합니다. 그런데 게시판 관리 화면에서 코드 게시글의 브랜치를 보여 주어야 한다면 어떨까요? 객체는 분명히 CodePost이고 branch() 메서드도 있는데, Post 변수로는 그 메서드를 부를 수 없습니다.

이럴 때 '이 변수가 가리키는 객체는 사실 CodePost다'라고 컴파일러에게 알려 주는 문법이 다운캐스팅(downcasting)입니다. 부모 타입을 자식 타입으로 좁히므로, 업캐스팅과 반대로 상속 관계의 아래쪽으로 내려간다는 뜻입니다. 이 글에서는 다운캐스팅이 왜 필요하고, 실행될 때 무슨 일이 일어나며, 무엇이 바뀌고 무엇이 바뀌지 않는지를 살펴봅니다. 미리 말하면 캐스팅은 새 객체를 만들지도, 객체를 다른 클래스로 바꾸지도 않습니다. 바뀌는 것은 그 객체를 다루는 타입뿐입니다.

부모 타입 변수를 자식 타입 변수에 바로 담을 수 없는 이유

먼저 형변환 없이 대입해 보겠습니다. post에는 CodePost 객체가 들어 있으니 CodePost 변수에 옮겨 담으면 될 것 같습니다.

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");
        }
    }
}
ParentToChildAssignment.java:4: error: incompatible types: Post cannot be converted to CodePost
        CodePost codePost = post;
                            ^
1 error

컴파일러는 post의 타입이 Post라는 것만 보고 판단합니다. Post 변수에는 Post 객체 자체가 들어 있을 수도, TextPost가 들어 있을 수도, CodePost가 들어 있을 수도 있습니다. 그중 CodePost 변수에 담아도 되는 것은 CodePost 객체뿐이니, 언제나 성공한다고 장담할 수 없는 대입은 자동으로 허용하지 않습니다. 바로 윗줄에서 new CodePost()를 대입한 것이 사람 눈에는 보이지만, 앞 글에서 보았듯 컴파일러는 변수에 실제로 무엇이 들어 있는지 따라가지 않고 변수의 타입만 봅니다.

담긴 객체가 CodePost라는 것을 개발자가 알고 있다면, 괄호 안에 타입을 적어 명시적으로 형변환할 수 있습니다. 이렇게 괄호에 타입을 적는 것을 캐스트(cast)라고 합니다.

CodePost codePost = (CodePost) post;

캐스트를 쓰면 컴파일러는 '실제 객체가 CodePost일 수 있다'는 이유로 대입을 허락합니다. 대신 정말로 CodePost인지는 실행할 때 확인합니다. JVM이 post가 가리키는 객체의 클래스를 보고, CodePost이거나 그 하위 클래스이면 같은 참조값을 codePost에 담습니다. 그렇지 않으면 ClassCastException을 던집니다. 변수가 null이면 확인할 객체가 없으므로 캐스트는 예외 없이 null을 그대로 넘기는데, 이 경우는 다음 글에서 따로 다룹니다. 정리하면 캐스트는 검사를 없애는 것이 아니라, 컴파일할 때 하던 검사를 실행할 때로 미루는 것입니다. 그 사이에 실제 객체가 맞는지는 코드를 쓴 개발자가 확인해야 합니다.

다운캐스팅해도 객체는 그대로다

다운캐스팅이 실제로 무엇을 바꾸는지 확인해 보겠습니다. 다음 예제는 CodePost 객체 하나를 세 변수로 다룹니다. original로 객체를 만들고, Post 타입 parentView에 업캐스팅해 담은 뒤, 그것을 다시 CodePost 타입 childView로 다운캐스팅합니다.

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

첫 줄은 parentView.publish()가 출력했습니다. publish는 Post에 있으므로 Post 변수로 호출할 수 있습니다. 둘째 줄은 다운캐스팅한 childView로 commit()을 호출한 결과입니다. 여기서 branch 값 feature/login이 그대로 출력된다는 점이 중요합니다. 업캐스팅하는 동안 CodePost가 덧붙인 필드가 사라졌다가 다운캐스팅할 때 다시 만들어진 것이 아니라, 객체는 처음부터 끝까지 branch를 가진 채 그대로 있었던 것입니다. 셋째 줄의 same=true는 original과 childView가 같은 객체를 가리킨다는 뜻입니다. ==는 참조형에서 두 변수에 든 참조값이 같은지 비교합니다.

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

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
업캐스팅과 다운캐스팅 뒤에도 세 참조는 한 객체를 가리킵니다 original CodePost,parentView Post,childView CodePost 3참조모두동일branch feature/login CodePost한객체;대입캐스트객체복사없음/parentView정적Post유지/동일참조same true. original CodePost new로 받은 참조 parentView Post original 참조값 대입 childView CodePost (CodePost) parentView CodePost 객체 하나 branch = "feature/login" same=true 업·다운캐스트 ≠ 객체 복사 실선: 같은 객체의 참조

세 변수는 같은 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이므로 캐스트가 성공하며, 다른 실제 타입의 캐스팅 안전성을 보장하지 않습니다.

그림처럼 세 변수는 모두 하나의 CodePost 객체를 가리킵니다. 업캐스팅과 다운캐스팅은 객체를 복사하지도, 새로 만들지도 않았습니다. 달라진 것은 각 변수의 타입, 그리고 그 타입으로 호출할 수 있는 메서드의 범위뿐입니다. parentView로는 publish()만, original과 childView로는 publish()와 commit()을 모두 부를 수 있습니다. 캐스팅이 끝난 뒤에도 parentView의 타입은 계속 Post입니다.

한 번만 쓸 때는 캐스트 식으로

자식 메서드를 한 번만 부를 거라면 자식 타입 변수를 따로 만들지 않고, 캐스트한 결과에 바로 메서드를 호출할 수도 있습니다. 다음 코드의 ((CodePost) post).commit()이 그런 호출입니다. 캐스트는 이 식 안에서만 효과가 있으므로, 다음 줄의 post.publish()에서 post는 여전히 Post 타입 변수로 쓰입니다.

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).commit()에서 바깥 괄호는 꼭 필요합니다. Java에서 .으로 메서드를 호출하는 것은 캐스트보다 먼저 묶이기 때문입니다. 괄호 없이 (CodePost) post.commit()이라고 쓰면 Java는 post.commit()을 먼저 계산한 뒤 그 결과를 CodePost로 캐스트하려고 합니다. post는 여전히 Post 타입이니 commit()을 찾을 수 없다는 컴파일 오류가 나고, 이 예제처럼 그것이 문장 전체라면 not a statement 오류가 먼저 납니다. 바깥 괄호로 (CodePost) post를 먼저 묶어야 캐스트한 결과에서 commit()을 찾습니다.

캐스트로 타입이 바뀌는 것은 (CodePost) post라는 식의 결과뿐이고, 변수 post의 타입은 바뀌지 않습니다. 그래서 다음 줄의 post.publish()는 여전히 Post 타입 변수로 하는 호출이고, 이 줄을 post.commit()으로 바꾸면 앞 글에서 본 cannot find symbol 오류가 다시 납니다. 아래 그림에서 두 호출의 타입을 단계별로 비교할 수 있습니다.

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

일회성 캐스트 표현식으로 commit 후 원래 Post 변수에서 publish를 호출하며 같은 객체/선언 타입을 유지합니다. 두 호출 사이에 타입/객체가 바뀌지 않는 실제 전환을 보여 줄 수 있습니다.

일시적 캐스트 표현식과 바뀌지 않는 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

캐스트는 표현식의 타입을 바꾸고 원래 변수의 타입은 유지합니다 같은CodePost객체를가리키는일회성CodePost cast표현식과원Post변수;앞commit허용/출력commit,뒤publish허용/출력publish;원변수타입·대상변경없음,실선참조. 일회성 표현식 (CodePost) post commit() 허용 출력 commit 원래 변수 post 정적 타입 계속 Post publish() / 출력 publish CodePost 객체 하나 참조 대상은 바뀌지 않음 commit는 자식 구현 publish는 부모 구현 호출 순서: commit → publish 실선: 같은 객체의 참조 · 객체 복사나 상속 방향 아님
캐스트는 표현식의 타입을 바꾸고 원래 변수의 타입은 유지합니다 같은CodePost객체를가리키는일회성CodePost cast표현식과원Post변수;앞commit허용/출력commit,뒤publish허용/출력publish;원변수타입·대상변경없음,실선참조. 일회성 표현식 (CodePost) post commit() 허용 출력 commit 원래 변수 post 정적 타입 계속 Post publish() / 출력 publish CodePost 객체 하나 참조 대상은 바뀌지 않음 commit는 자식 구현 publish는 부모 구현 호출 순서: commit → publish 실선: 같은 객체의 참조
  1. ((CodePost) post).commit()

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

  2. post.publish()

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

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

이 예의 조건

  • 같은 CodePost 객체이며 원 post 변수 정적 타입은 Post입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2같은 CodePost 객체를 가리키는 일회성 CodePost cast 표현식과 원 Post 변수를 비교한다. 앞에서는 commit 허용/commit 출력, 뒤에서는 publish 허용/publish 출력이며 원 변수의 타입·대상 변경은 없다. 실선은 참조이다.
9publish()
10출력 publish
13commit은 자식 구현
원 도표의 설명과 주석
구분내용
설명같은 CodePost 객체를 가리키는 일회성 CodePost cast 표현식과 원 Post 변수를 비교한다. 앞에서는 commit 허용/commit 출력, 뒤에서는 publish 허용/publish 출력이며 원 변수의 타입·대상 변경은 없다. 실선은 참조이다.
전체 읽기 정보일시적 캐스트 표현식과 바뀌지 않는 post 변수 일시적 캐스트 표현식과 바뀌지 않는 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 ((CodePost) post).commit() (CodePost) post 표현식의 타입은 CodePost 입니다. CodePost.commit() 을 실행하여 commit 을 출력합니다. post.publish() 변수 post 의 선언 타입은 계속 Post 입니다. Post.publish() 를 실행하여 publish 를 출력합니다. 변수 post 가 잠시 CodePost 타입으로 바뀌었다가 돌아오는 것이 아닙니다. 캐스트 결과 표현식만 CodePost 이며, 원래 변수의 선언 타입과 참조 대상은 바뀌지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원 변수

post는 Post 타입으로 CodePost 참조를 보관합니다.

  • 정적 타입: Post
  • 실제 객체: CodePost
  • 변수 타입과 참조 대상의 클래스가 다릅니다.
일회성 캐스트

(CodePost)post 표현식으로 commit을 호출합니다.

  • 표현식 타입: CodePost
  • 출력: commit
  • 캐스트가 원 변수 선언 타입을 바꾸지 않습니다.
원 변수 후속

post.publish를 호출합니다.

  • 정적 타입: 여전히 Post
  • 출력: publish
  • 참조 대상은 그대로이며 객체 복사도 없습니다.

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

일회성 캐스트 표현식으로 commit 후 원래 Post 변수에서 publish를 호출하며 같은 객체/선언 타입을 유지합니다. 두 호출 사이에 타입/객체가 바뀌지 않는 실제 전환을 보여 줄 수 있습니다.

일시적 캐스트 표현식과 바뀌지 않는 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

캐스트는 표현식의 타입을 바꾸고 원래 변수의 타입은 유지합니다 같은CodePost객체를가리키는일회성CodePost cast표현식과원Post변수;앞commit허용/출력commit,뒤publish허용/출력publish;원변수타입·대상변경없음,실선참조. 일회성 표현식 (CodePost) post commit() 허용 출력 commit 원래 변수 post 정적 타입 계속 Post publish() / 출력 publish CodePost 객체 하나 참조 대상은 바뀌지 않음 commit는 자식 구현 publish는 부모 구현 호출 순서: commit → publish 실선: 같은 객체의 참조 · 객체 복사나 상속 방향 아님
캐스트는 표현식의 타입을 바꾸고 원래 변수의 타입은 유지합니다 같은CodePost객체를가리키는일회성CodePost cast표현식과원Post변수;앞commit허용/출력commit,뒤publish허용/출력publish;원변수타입·대상변경없음,실선참조. 일회성 표현식 (CodePost) post commit() 허용 출력 commit 원래 변수 post 정적 타입 계속 Post publish() / 출력 publish CodePost 객체 하나 참조 대상은 바뀌지 않음 commit는 자식 구현 publish는 부모 구현 호출 순서: commit → publish 실선: 같은 객체의 참조
  1. ((CodePost) post).commit()

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

  2. post.publish()

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

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

이 예의 조건

  • 같은 CodePost 객체이며 원 post 변수 정적 타입은 Post입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2같은 CodePost 객체를 가리키는 일회성 CodePost cast 표현식과 원 Post 변수를 비교한다. 앞에서는 commit 허용/commit 출력, 뒤에서는 publish 허용/publish 출력이며 원 변수의 타입·대상 변경은 없다. 실선은 참조이다.
9publish()
10출력 publish
13commit은 자식 구현
원 도표의 설명과 주석
구분내용
설명같은 CodePost 객체를 가리키는 일회성 CodePost cast 표현식과 원 Post 변수를 비교한다. 앞에서는 commit 허용/commit 출력, 뒤에서는 publish 허용/publish 출력이며 원 변수의 타입·대상 변경은 없다. 실선은 참조이다.
전체 읽기 정보일시적 캐스트 표현식과 바뀌지 않는 post 변수 일시적 캐스트 표현식과 바뀌지 않는 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 ((CodePost) post).commit() (CodePost) post 표현식의 타입은 CodePost 입니다. CodePost.commit() 을 실행하여 commit 을 출력합니다. post.publish() 변수 post 의 선언 타입은 계속 Post 입니다. Post.publish() 를 실행하여 publish 를 출력합니다. 변수 post 가 잠시 CodePost 타입으로 바뀌었다가 돌아오는 것이 아닙니다. 캐스트 결과 표현식만 CodePost 이며, 원래 변수의 선언 타입과 참조 대상은 바뀌지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원 변수

post는 Post 타입으로 CodePost 참조를 보관합니다.

  • 정적 타입: Post
  • 실제 객체: CodePost
  • 변수 타입과 참조 대상의 클래스가 다릅니다.
일회성 캐스트

(CodePost)post 표현식으로 commit을 호출합니다.

  • 표현식 타입: CodePost
  • 출력: commit
  • 캐스트가 원 변수 선언 타입을 바꾸지 않습니다.
원 변수 후속

post.publish를 호출합니다.

  • 정적 타입: 여전히 Post
  • 출력: publish
  • 참조 대상은 그대로이며 객체 복사도 없습니다.

이렇게 변수에 담지 않고 한 식에서만 쓰는 캐스트를 일시적 다운캐스팅이라고 부르기도 합니다. 한 번 쓸 때는 간단하지만, 같은 객체의 자식 메서드를 여러 번 불러야 한다면 매번 괄호를 겹쳐 쓰는 것보다 CodePost codePost = (CodePost) post;처럼 자식 타입 변수에 이름을 붙여 두는 편이 읽기 쉽습니다.

타입을 확인하고 캐스트하기

지금까지는 post에 CodePost가 들어 있다는 것을 바로 윗줄에서 보았기 때문에 안심하고 캐스트했습니다. 하지만 배열이나 매개변수로 받은 Post에는 무엇이 들어 있을지 미리 알 수 없습니다. 다음 예제는 게시글 목록을 돌면서 모든 게시글의 제목을 출력하고, 코드 게시글일 때만 브랜치를 함께 출력합니다.

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
가입 인사

post instanceof CodePost는 post가 가리키는 객체가 CodePost와 호환되는지, 즉 CodePost이거나 그 하위 클래스인지를 실행 중에 검사해 true나 false를 돌려줍니다. 첫 원소는 CodePost라서 검사가 참이 되고, 캐스트한 뒤 브랜치를 출력합니다. 둘째 원소는 TextPost라서 검사가 거짓이 되므로 제목만 출력하고 캐스트는 실행하지 않습니다.

if 줄과 그 닫는 중괄호를 지워 모든 원소를 캐스트하면 어떻게 될까요? 첫 원소는 그대로 성공하지만, 둘째 원소에서 실행이 멈춥니다.

로그인 구현
branch=feature/login
가입 인사
Exception in thread "main" java.lang.ClassCastException: class PostDetails$TextPost cannot be cast to class PostDetails$CodePost (PostDetails$TextPost and PostDetails$CodePost are in unnamed module of loader 'app')
	at PostDetails.main(PostDetails.java:10)

TextPost와 CodePost는 같은 부모를 둔 형제일 뿐, TextPost 객체에는 CodePost가 덧붙인 branch가 없습니다. 그래서 캐스트를 실행하는 순간 JVM의 검사가 실패해 ClassCastException이 납니다. 메시지에 나오는 PostDetails$TextPost는 PostDetails 안에 중첩된 TextPost 클래스의 이름입니다. 캐스트 문법은 컴파일을 통과시켜 줄 뿐, 실제 객체가 맞는지까지 확인해 주지는 않습니다. 업캐스팅과 다운캐스팅의 성공 조건, 그리고 이 예외가 나는 경우는 다음 글에서 더 자세히 따져 보고, instanceof의 검사와 캐스트를 한 번에 하는 패턴 매칭은 instanceof와 오버라이딩에서 다룹니다.

캐스팅이 자주 필요하다면 설계를 다시 보기

캐스팅은 필요할 때 쓰는 도구이지만, 캐스팅이 자꾸 늘어난다면 설계를 다시 볼 신호이기도 합니다. 게시글 목록을 돌 때마다 다음처럼 게시글 종류를 하나씩 확인하고 캐스트하는 코드를 생각해 봅시다.

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

이 코드는 새 게시글 종류가 생길 때마다 else if를 하나씩 늘려야 하고, 이런 분기가 여러 곳에 있다면 그 모두를 찾아 고쳐야 합니다. 하나라도 빠뜨리면 새 게시글은 그 자리에서 아무 일도 하지 않고 지나갑니다. 만약 commit과 previewBody가 사실 '게시글을 게시판에 공개한다'는 같은 목적을 게시글마다 다르게 처리하는 것이라면, 부모 Post에 publish()를 두고 각 자식이 오버라이딩하는 편이 낫습니다. 그러면 위 분기 전체가 post.publish(); 한 줄이 되고, 새 게시글은 자기 publish만 구현하면 됩니다.

반대로 commit이 코드 게시글에만 의미가 있고, 관리 화면처럼 특정한 곳에서만 필요하다면 instanceof로 확인한 뒤 캐스트하는 것이 자연스러운 선택일 수 있습니다. 앞의 PostDetails가 그런 경우입니다. 모든 게시글에 공통인 동작은 부모의 메서드와 오버라이딩으로 처리하고, 캐스트는 자식만의 정보가 꼭 필요한 몇몇 자리에 모아 둡니다. 사용하는 쪽이 구체적인 자식 클래스를 많이 알수록, 새 하위 클래스를 추가할 때 고쳐야 할 곳도 늘어납니다.

더 깊이: 기본형 형변환과 참조형 캐스트는 다르다

(int) 3.9와 (CodePost) post는 생김새가 같지만 하는 일이 다릅니다. 기본형 형변환은 값 자체를 다른 표현으로 바꿉니다. double 값 3.9를 int로 바꾸면 소수 부분을 버린 새 값 3이 만들어지고, 원래 값의 정보 일부가 사라집니다. 반면 참조형 캐스트는 참조값을 전혀 바꾸지 않습니다. 같은 참조값을 다른 타입으로 다룰 수 있는지 검사하고, 통과하면 그대로 넘겨줄 뿐입니다. 그래서 참조형 캐스트에서는 정보가 잘려 나가는 일이 없고, 대신 검사에 실패하면 예외가 납니다.

참조형 캐스트는 상속 관계로 이어진 타입 사이에서만 의미가 있습니다. Post 타입 값을 String으로 캐스트하면 incompatible types: Post cannot be converted to String이라는 컴파일 오류가 납니다. Post와 String은 어느 쪽도 다른 쪽의 하위 클래스가 아니라서 성공할 가능성이 전혀 없기 때문입니다. 컴파일러는 이렇게 절대 성공할 수 없는 캐스트는 미리 막고, 성공할 수도 있는 캐스트만 실행할 때 검사하도록 남겨 둡니다. 이 규칙은 다음 글에서 다시 다룹니다.

정리

  • 부모 타입 변수를 자식 타입 변수에 그냥 대입하면 컴파일 오류가 납니다. 변수에 어떤 하위 클래스의 객체가 들어 있을지 컴파일러가 알 수 없기 때문입니다.
  • (CodePost) post처럼 캐스트를 쓰면 컴파일은 통과하고, 실제 객체가 그 타입과 호환되는지는 실행할 때 JVM이 검사합니다. 호환되지 않으면 ClassCastException이 납니다.
  • 캐스트는 객체를 복사하거나 바꾸지 않습니다. 같은 참조값을 다른 타입으로 다룰 뿐이며, 원래 변수의 타입도 그대로입니다.
  • 캐스트한 결과에 바로 메서드를 부를 때는 ((CodePost) post).commit()처럼 바깥 괄호가 필요합니다.
  • 무엇이 들어 있을지 모르는 변수는 instanceof로 먼저 확인하고 캐스트합니다. 같은 분기가 여러 곳에 반복된다면 부모의 메서드와 오버라이딩으로 바꿀 수 있는지 먼저 검토합니다.

다음 글에서는 업캐스팅이 언제나 안전한 이유와 다운캐스팅이 실패하는 조건을 계층이 더 깊은 예제로 정리합니다.

연습 문제

문제 1: 영상 게시글에 자막 추가하기

Post를 상속하는 VideoPost에 addCaption(String language) 메서드를 만드세요. Post 타입 변수에 VideoPost 객체를 담은 뒤, 다운캐스팅해서 addCaption을 호출합니다. 그다음 원래 Post 변수로 publish()를 호출하고, 두 변수가 같은 객체를 가리키는지도 출력해 확인하세요.

해설 보기
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.addCaption("en");  // 컴파일 오류: Post에는 addCaption이 없다
        post.publish();
        System.out.println("same=" + (post == video));
    }

    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
same=true

video와 post는 같은 VideoPost 객체를 가리키므로 마지막 줄이 same=true입니다. 그래도 두 변수로 호출할 수 있는 메서드는 다릅니다. VideoPost 타입인 video로는 addCaption을 부를 수 있지만, 캐스트한 뒤에도 post의 타입은 Post이므로 주석 처리한 post.addCaption("en")의 주석을 풀면 cannot find symbol 오류가 납니다. 반대로 publish()는 Post에 선언되어 있으므로 post로 호출할 수 있습니다.

문제 2: 캐스트는 언제 실패할까

Post를 상속하는 TextPost와 CodePost가 있습니다. 다음 코드는 컴파일될까요? 실행하면 어느 줄까지 출력하고, 어디서 어떤 일이 일어날까요?

Post post = new TextPost();
TextPost textPost = (TextPost) post;
System.out.println("text ok");
CodePost codePost = (CodePost) post;
System.out.println("never");
해설 보기

컴파일은 됩니다. 두 캐스트 모두 Post를 그 하위 클래스로 좁히는 것이라서, 컴파일러는 post에 해당 타입의 객체가 들어 있을 가능성이 있다고 보고 허락합니다.

실행하면 text ok까지 출력됩니다. 첫 캐스트는 실제 객체가 TextPost이므로 검사를 통과합니다. 둘째 캐스트에서는 TextPost 객체를 CodePost로 다루려고 하는데, 형제 클래스인 TextPost는 CodePost가 아니므로 검사가 실패해 ClassCastException이 납니다. 예외가 처리되지 않았으므로 프로그램은 거기서 멈추고 never는 출력되지 않습니다. 둘째 캐스트 앞에 if (post instanceof CodePost)로 확인했다면 이 캐스트는 실행되지 않았을 것입니다.