
Parent p = new Child();
Java를 공부하다 보면 이 한 줄을 아주 자주 만나게 됩니다.
보통은 이렇게 설명합니다.
부모 타입 변수에 자식 객체를 담는 업캐스팅이다.
문법적으로는 맞는 설명입니다.
그런데 저는 여기서 조금 더 궁금해졌습니다.
Parent와Child객체가 둘 다 만들어지는 걸까?p는 부모 객체를 가리키는 걸까, 자식 객체를 가리키는 걸까?- 필드는 왜 부모 기준으로 보이는데, 메서드는 자식 기준으로 실행될까?
이번 글에서는 객체지향 문법을 길게 다시 설명하기보다, 뒤에서 JVM 실행 원리를 살펴보기 위해 꼭 필요한 내용만 정리해보려고 합니다.
이 글에서는 Parent p = new Child(); 한 줄을 기준으로 업캐스팅이 가능한 이유와 실제로 생성되는 객체가 무엇인지 살펴봅니다. 또한 필드는 선언 타입을 기준으로 접근하고, 오버라이딩된 메서드는 실제 객체를 기준으로 실행되는 이유를 컴파일러와 동적 디스패치 관점에서 정리합니다.
예제 코드부터 살펴보기
class Parent {
int parentAge = 50;
String name = "Parent";
void introduce() {
System.out.println("Parent introduce");
}
}
class Child extends Parent {
int childAge = 20;
String name = "Child";
@Override
void introduce() {
System.out.println("Child introduce");
}
void play() {
System.out.println("Child play");
}
}
그리고 다음과 같이 객체를 생성해보겠습니다.
Parent p = new Child();
이 코드는 왼쪽과 오른쪽을 나누어 보면 조금 더 쉽게 이해할 수 있습니다.
Parent p
→ 참조 변수의 선언 타입
new Child()
→ 실제로 생성되는 객체 타입
즉 p의 선언 타입은 Parent이고, 실제 객체의 타입은 Child입니다.
업캐스팅은 무엇일까?
Parent p = new Child();
자식 타입의 객체를 부모 타입의 참조 변수로 다루는 것을 업캐스팅이라고 합니다.
Child 객체
↓ 부모 타입으로 바라봄
Parent 참조 변수
Child 객체는 Parent 타입으로도 다룰 수 있습니다. 부모의 모든 것을 상속 받기 때문입니다.
Parent 타입에는 introduce()가 선언되어 있으므로 이 p.introduce() 선언이 가능하지만, 반대로 Child에만 존재하는 메서드는 바로 호출할 수 없습니다.
p.play(); // 컴파일 오류
여기서 중요한 점은 p 타입이 Parent라고, Child 객체에서 play()가 사라진 것이 아니라는 점입니다.
실제 객체: Child
참조 변수의 시야: Parent
객체는 여전히 Child이지만, 컴파일러는 p를 Parent 타입으로 보기 때문에 Parent에 선언되지 않은 play() 호출을 허용하지 않습니다.
왜 그럴까요?
내부 구조를 조금 살펴봐야 알 수 있습니다.
그런데, Heap에는 Parent와 Child 객체가 둘 다 생길까요?
Parent p = new Child();
이 코드를 보면 부모 객체와 자식 객체가 각각 하나씩 생성되는 것처럼 느껴질 수 있습니다.
자식이 extends Parent 했으니까 힙에 두 개 객체가 생성되나?
하지만 실제로 new는 한 번만 사용되었습니다. ... = new Child(); 그래서 Heap에는 Child 객체 하나가 생성됩니다.
부모 부분과 자식 부분이 서로 분리된 두 객체로 생기는 것이 아닙니다.
Heap
└── Child Object
├── Parent.parentAge
├── Parent.name
├── Child.childAge
└── Child.name
실제로 스택과 힙 영역을 보면? 이런 형식으로 되어있습니다.
Stack Heap
┌─────────────────┐ ┌─────────────────────────┐
│ p │─────────▶│ Child 객체 │
│ 선언 타입 Parent │ │ │
└─────────────────┘ │ Parent에서 물려받은 필드 │
│ Child가 선언한 필드 │
└─────────────────────────┘
아주 쪼금만 더 자세하게 본다면은,,,
이런 구조입니다.

간단 설명 :
이 그림의 시점은 프로그램이 이미 실행 중인 시점(Runtime)입니다.
Parent p = new Child();가 지금 막 실행되면, 이렇게 이 그림처럼 Heap에는 Child 객체가 생성되고, p는 그 객체를 가리키고 있는 상태입니다.
실제 객체는 Child 전체를 다 가지고 있지만(Heap), 참조 변수(p)가 Parent 타입이라서 Parent의 기능만 골라서 사용하게끔 제한을 걸어둔 구조입니다.
- Heap (힙):
Child객체의 실체(부모와 자식의 모든 필드 및 메서드)가 실제로 생성되어 적재되는 물리적인 저장 공간입니다. - Stack (스택): 참조 변수(
p)가 저장되는 곳으로, 선언된 타입(Parent)을 통해 힙 영역의 데이터에 접근할 수 있는 '필터' 역할을 하며, 타입에 정의된 기능만 호출하도록 접근을 제한합니다.
여기서 이런 궁금증 안 생기셨나요?
"이미 new Child()라는 걸 알고 있고, 컴파일 시점에 그냥 Child.introduce()를 호출하도록 미리 정해두면 더 빠르지 않을까?;"
그렇게 된다면 런타임마다 "이번에는 어떤 메서드를 호출해야 하지?"를 결정할 필요도 없고, 미리 정해진 메서드만 실행하면 되니 더 효율적일 것처럼 보입니다. 하지만 이 그림은 이미 프로그램이 실행된 이후의 모습입니다. 이 질문에 답하려면 프로그램이 실행되기 전, 즉 컴파일 단계부터 살펴봐야 합니다.
컴파일러는 무엇을 확인할까?
컴파일러는 우리가 작성한 .java 소스 코드 파일을 바로 실행하지 않습니다.
이 글에서는 살짝 컴파일러는 TMI 인거 같지만, 간단하게..
컴파일러는 먼저 소스 코드를 분석하면서 토큰을 만들고, 이를 기반으로 AST(Abstract Syntax Tree) 를 생성합니다.
이후 문법과 타입을 검사하고, 최종적으로 JVM이 실행할 수 있는 바이트코드를 생성합니다.
Source Code (.java)
↓
Lexical Analysis (토큰 생성)
↓
Syntax Analysis (AST 생성)
↓
Semantic Analysis (타입 검사, 메서드 검사)
↓
Bytecode(.class) 생성
// 요 아날리즈들이 컴파일 핵심 과정
컴파일러는 개발자가 작성한 소스 코드를 토큰으로 쪼개서 AST를 생성합니다.
이후 이를 순회하면서
- 문법구조 틀린거 있나
- 타입 잘 썼나
- 메서드가 있나~
- @Override가 잘 선언 되어 있나~
검증하고 에러가 있으면? 컴파일 에러를 발생 시킵니다.
그래서
Parent p = new Child();
위 코드를 컴파일 하는 순간 이미 Child가 parent를 상속한다는 사실, Child.introduce() 함수에 보니 @Override키워드가 있으니 부모 메서드 네이밍, 파라미터, 반환타입 잘 선언했는지 문법 체크!
모두 알고 있습니다. 프로그램 실행 전에 말이죠.
그런데 왜 Child.introduce()를 미리 선택하지 않을까?
여기서 다시 처음 질문으로 돌아가 보겠습니다.
컴파일러는 이미 오버라이딩 여부까지 알고 있는데,
"그럼 그냥 Child.introduce()를 호출하도록 바이트코드를 만들면 되는 것 아닐까?"
겉으로 보기에는 충분히 가능하지만. 프로그램 개발자들은 천재라는 사실..
Parent p;
if (condition) {
p = new Child();
} else {
p = new AnotherChild();
}
p.introduce();
컴파일 하는 시점에는 condition의 결과를 알 수 없습니다.
"Child가 생성될지, AnotherChild가 생성될지… 그래서 메서드의 경우에는 컴파일러가 특정 구현체의 메서드를 미리 선택할 수 없습니다. 그래서 컴파일러는 메서드가 존재하는지만 확인하고 존재하면? 런타임떄 JVM 너가 동적(dynamic)하게 결정해~ 실제 어느 코드가 실행될지는 JVM 너에게 맡긴다!"
라는 형태의 바이트 코드를 생성합니다.
이게 바로 동적 디스패치(Dynamic Dispatch) 입니다.
아주 잠깐..!
여기에는 SOLID의 LSP도 숨어 있습니다.
LSP(리스코프 치환 원칙) 는 "자식 객체는 언제나 부모 객체를 대신할 수 있어야 한다." 는 원칙입니다.
그래서 Parent p = new Child();처럼 부모 타입으로 다루더라도, 런타임에 실제 객체가 Child라면 JVM은 안심하고 Child의 메서드를 호출할 수 있습니다.
물론 부모와 전혀 다른 동작을 하라는 의미는 X. 부모가 약속한 역할은 그대로 유지하면서, 자식만의 방식으로 구현하는 것이 LSP의 핵심.
다형성의 자연스러운 사용 방식을 고수분들이 LSP라는 이름의 설계 원칙으로 정리했고, 이게 SOLID 중 하나라는 것!
다시 돌아와서 p.name 필드는 왜 오버라이드가 동작되지 않는건가요?
필드는 조금 다릅니다.
Parent p = new Child();
System.out.println(p.name);
컴파일러가 확실하게 알 수 있는 정보는 p의 선언 타입이 Parent라는 사실입니다.
그리고 필드는 메서드처럼 오버라이딩되는 대상이 아닙니다!, 컴파일러는 Parent에 선언된 name 필드에 접근하는 바이트코드를 생성합니다. 컴파일 시점에 이미 p.name은 항상 Parent.name을 기준으로 접근해~ 기록해둡니다. 바이트코드로.
반면 (override) p.introduce()는 다릅니다. 컴파일러는 Parent에 introduce()가 존재하는지만 확인하고, 실제 어떤 구현이 실행될지는 런타임의 실제 객체에 맡깁니다.
| 구분 | 선택 기준 |
|---|---|
| 필드 접근 | 참조 변수의 선언 타입 |
| 오버라이딩 메서드 호출 | 실제 런타임 객체 타입 |
System.out.println(p.name); // Parent
p.introduce(); // Child introduce
같은 p를 사용해도 결과가 다른 이유는, 필드는 컴파일 시점에 접근 대상이 결정되고 오버라이딩된 인스턴스 메서드는 런타임에 선택되기 때문입니다.
위에 있는 "스택 힙 영역 그림 구조" 보면은 실제로 variables 정보 같은 거만 있고 실제로 클래스에서 선언된 함수들이 보이지 않습니다…

그럼 한 가지 또 궁금해지는 것은. 실제로 힙 영역에 Child Object 인스턴스가 할당되는데, 함수는 어디에서 불러와서 호출하는지, 힙 영역에 포함시키면 좋지 않을까 하는 생각이 들기도 합니다.
힙 영역 이외에도,, 빨간색 선 보면 Method Area가 또 있네요?..
그렇다면 다음 포스트에서는 Stack Frame → Local Variable Table → Heap → 클래스 메타데이터 → Method Table 순서로 따라가며, Parent p = new Child(); 한 줄이 JVM 내부에서 어떻게 실행되는지 살펴보겠습니다.
아 참고로.. 다운캐스팅은 왜 위험할까요?
부모 타입의 참조를 다시 자식 타입으로 좁히는 것을 다운캐스팅이라고 합니다.
Parent p = new Child();
Child c = (Child) p;
위 코드는 실제 객체가 Child이므로 정상적으로 동작합니다. 하지만 다음 코드는 다릅니다.
Parent p = new Parent();
Child c = (Child) p; // 런타임 예외
컴파일은 가능하지만 실제 객체가 Child가 아니므로 실행 중 ClassCastException이 발생합니다.
객체지향적 프로그래밍에서 다운 캐스팅은 좋지는 않다구 합니다.
다운캐스팅은 객체 자체를 바꾸는 것이 아니라, 참조 변수가 객체를 바라보는 타입을 좁히는 작업입니다.
문제는 예외 가능성만이 아닙니다.
다운캐스팅을 사용하려면 호출하는 쪽이 구체적인 구현 타입을 알아야 하므로 구현체 의존성이 높아지고, 추상화와 다형성의 장점도 약해질 수 있습니다.
instanceof는 언제 사용할까?
객체지향 설계에서는 가능한 한 부모 타입이나 인터페이스가 제공하는 기능( e.g. p.introduce(); )만 사용하는 편이 좋습니다. 그러나 객체간 의존성을 느슨하게 하며 개발하다보면, 외부 라이브러리, 레거시 코드, 이벤트 처리처럼 실제 구현체 타입을 명확하게 구분해야 하는 상황도 생깁니다.
그럴 때는 무작정 다운캐스팅하기보다 instanceof로 실제 타입을 확인한 뒤 사용하는 것이 안전합니다.
if (p instanceof Child child) {
child.play();
}
instanceof는 참조 변수의 선언 타입이 아니라, p가 실제로 가리키는 객체가 Child 타입인지 확인합니다.
객체지향적으로는 구현체 확인을 최대한 줄이는 것이 좋지만, 실무에서 구현체 구분이 불가피하다면
instanceof는 현실적인 안전장치가 될 수 있습니다.
이번 글에서 정리한 내용
Parent p = new Child();
이번 글에서는 이 한 줄을 컴파일러의 관점에서 살펴봤습니다.
p의 선언 타입은Parent입니다.- 실제 생성되는 객체는
Child하나입니다. Child객체 안에는 부모로부터 물려받은 필드와 자식 필드가 함께 존재합니다.- 컴파일러는 참조 변수의 선언 타입을 기준으로 접근 가능한 멤버를 검사합니다.
- 필드는 참조 변수의 선언 타입을 기준으로 접근합니다.
- 오버라이딩된 인스턴스 메서드는 런타임의 실제 객체 타입을 기준으로 선택됩니다.
- 이 메서드 선택 방식이 동적(Dynamic) 디스패치입니다.
- 다운캐스팅은 참조 타입을 좁히는 작업이며, 잘못된 타입을 가정하면
ClassCastException이 발생합니다. - 구현체 확인이 불가피한 경우에는
instanceof로 실제 타입을 확인한 뒤 사용하는 편이 안전합니다.
문법과 컴파일러 관점의 이야기는 여기까지입니다.
[Inside Java] 몰아보기
[Inside Java] 01. Java는 왜 바로 기계어로 컴파일하지 않을까? — 바이트코드부터 JVM 메모리까지...
Swift로 iOS 앱을 만들 때는 보통 컴파일 → 빌드 → 실행 파일 생성 → CPU 실행이라는 흐름이 비교적 자연스럽게 느껴졌습니다.C++도 비슷합니다. 소스 코드를 컴파일하면 특정 운영체제와 CPU가 이
dev-with-precious-dreams.tistory.com
[Inside Java] 03. Heap 객체에서 method Area까지! - Klass Pointer와 Dynamic vs Static Dispatch 살펴보기
이전 포스트(누르면 바로가져요) 에서는 다음 코드가 왜 문법적으로 가능한지 살펴봤습니다.Parent p = new Child(); 컴파일러는 표현식 p의 정적 타입을 Parent로 보고, 실제 생성되는 객체는 Child입니
dev-with-precious-dreams.tistory.com
[Inside Java] 04. JVM은 main()을 바로 실행하지 않는다?! #1 -ClassLoader, 클래스 생명주기 #Loading #Linking #In
1편에서는 Java 코드가 바이트코드로 변환되고 JVM 위에서 실행되는 전체 흐름을 살펴봤습니다. 2편에서는 컴파일러가 정적 타입을 기준으로 무엇을 검사하고, 런타임의 JVM이 실제 객체를 기준으
dev-with-precious-dreams.tistory.com
[Inside Java] 05. JVM은 main()을 바로 실행하지 않는다 #2 — class metadata는 언제 초기화 될까? #Active Use,
앞선 4장에서는 .class 파일이 JVM 내부에서 Loading, Linking, Initialization을 거쳐 실행 가능한 타입이 되는 과정을 살펴봤습니다.하지만 여기서 한 가지 궁금증이 생깁니다. “클래스가 메모리에 로드
dev-with-precious-dreams.tistory.com
[Inside Java] 06. 같은 Java 코드가 실행될수록 빨라지는 이유 — JIT 컴파일과 HotSpot JVM 최적화 #Inline #
[Inside Java] 1~5편 까지는 JVM이 메모리를 어떻게 관리하는지를 중심으로 살펴보았습니다. 그런데 지금까지 살펴본 것처럼 JVM이 런타임마다 바이트코드를 하나씩 해석하고, Operand Stack을 이용해 연
dev-with-precious-dreams.tistory.com
[Inside Java] 07. interface는 JVM 메모리에서 어떻게 관리될까? — default 메서드와 enum 객체까지!!
이전 [Inside Java] 시리즈에서는 class로 만든 객체와 메서드 호출이 JVM 안에서 어떻게 연결되는지 살펴봤습니다.그런데 Java에는 class만 있는 것이 아니죠.....interface Animal { void speak();}enum Direction { LEF
dev-with-precious-dreams.tistory.com
[Inside Java] 08. 마무리.. Java 실행 구조 핵심 위키!
앞선 1장부터 7장까지 다룬 내용을 다시 찾기 쉽도록 핵심 개념만 두줄로 압축했습니다.자세한 동작 원리와 예시는 각 챕터에서 확인하고, 이 글에서는 개념의 연결 관계만 빠르게 정리합니다.
dev-with-precious-dreams.tistory.com
References
Java Language Specification (JLS)
https://docs.oracle.com/javase/specs/
- 업캐스팅(Reference Conversion)
- 타입 검사(Type Checking)
javap 공식 문서
https://docs.oracle.com/en/java/javase/21/docs/specs/man/javap.html
- 컴파일된
.class파일 분석 - Constant Pool
- Local Variable Table