
다형성, Virtual Invocation, 그리고 static/final modifier
자바에서 클래스를 선언할 때 extends를 명시하지 않아도 컴파일러가 자동으로 extends Object를 추가한다. 즉, 모든 자바 클래스는 Object를 암묵적으로 상속받는다. Customer 객체를 생성하면 Metaspace에 Customer 클래스 정보와 함께 Object 클래스 정보가 같이 올라가 있다.

Object 클래스에는 몇 가지 핵심 메서드가 있다.
// Object가 기본 제공하는 주요 메서드들
toString() // 객체 정보를 문자열로 반환 (기본: 클래스명@해시코드)
equals(Object) // 동등성 비교 (기본: == 과 동일, 주소 비교)
hashCode() // 해시 자료구조에서 사용되는 정수값 반환
getClass() // 런타임 시 실제 클래스 타입 반환
clone() // 객체의 얕은 복사본 생성
finalize() // GC가 객체를 메모리에서 제거하기 전에 호출
equals()와 hashCode()는 반드시 함께 오버라이딩해야 한다. 이유는 다음 주에 Set/Map 자료구조를 배울 때 명확해진다. equals()가 true를 반환하는 두 객체는 반드시 hashCode()도 같아야 한다는 계약(contract)이 있다. 이걸 지키지 않으면 HashMap이나 HashSet에서 의도치 않은 동작이 발생한다.
finalize()는 쓰지 않는 게 좋다. 오늘 수업에서도 이 내용이 나왔는데, 이유가 생각보다 심각하다. finalize()는 GC가 객체를 제거하기 전에 호출하는 메서드인데, Oracle 공식 문서에서 Java 9부터 @Deprecated로 지정했고, JEP 421에 따르면 Java 18에서 제거 예정으로 공식화됐다. 성능 저하(핀래라이저가 있는 클래스는 객체 생성 시 GC 오버헤드 추가)에 더해서, 데드락, 실행 순서 보장 안 됨, 심지어 보안 취약점까지 유발할 수 있다. 리소스 정리가 필요하면 try-with-resources나 Java 9에서 도입된 Cleaner API를 쓰면 된다.
오늘 수업 첫 교시에서 "상속이 왜 성능을 느리게 하는가"를 짚어줬다. 이게 C/C++과 자바의 차이를 이해하는 데 핵심 개념이다.
C++에서 메서드 호출 방식은 두 가지다.
virtual 키워드를 붙여야 활성화.반면 자바는 인스턴스 메서드가 기본적으로 모두 동적 바인딩(Dynamic Dispatch) 이다. virtual을 따로 선언하지 않아도 된다. 이게 다형성을 쉽게 구현하게 해주지만, 동시에 모든 메서드 호출마다 vtable(Virtual Method Table) 을 참조하는 오버헤드가 붙는다.
메서드 호출 시 JVM 처리 과정:
1. 참조 변수의 실제 객체 타입 확인
2. 해당 클래스의 vtable 참조
3. vtable에서 메서드 주소 찾기
4. 메서드 실행
→ 상속 계층이 깊어질수록 vtable 탐색 비용 증가
단, 현대 JVM의 JIT 컴파일러는 자주 호출되는 메서드를 분석해서 인라인 캐싱(inline caching)으로 최적화한다. 그래서 이론적 오버헤드보다 실제 체감 차이는 작다. 그래도 성능이 극도로 중요한 영역(게임 엔진, 고빈도 거래 시스템 등)에서는 상속 계층 설계에 주의가 필요하다.
다형성은 "사용 방법은 동일하지만 실행 결과가 다양하게 나오는 성질"이다.
구현하려면 두 가지가 필요하다.
자동 타입 변환(Upcasting) + 메서드 오버라이딩 = 다형성
데이터(필드)의 다형성은 형변환이다.
byte → short → int → long → float → double 자동 변환메서드의 다형성은 Virtual Invocation이다. 부모 타입 참조변수로 자식 객체를 가리켜도, 실제 호출되는 메서드는 런타임 객체 타입 기준으로 결정된다.
Employee emp = new Manager(); // Upcasting: Manager → Employee 자동 형변환
emp.toString();
// Employee 타입으로 선언됐지만
// 실제 객체는 Manager이므로 Manager의 toString()이 호출됨 → Virtual Invocation
스택에는 emp 참조변수가 0xf1을 가리키고, 힙에는 Manager 객체가 있고, Metaspace에는 Manager와 Employee 클래스 정보가 따로 존재한다. emp.toString() 호출 시 JVM은 힙의 실제 객체 타입인 Manager를 확인하고 Manager의 toString()을 실행한다.

다형성 없이 Employee 배열을 관리하려면 타입마다 배열을 따로 선언해야 한다.
// 다형성 없는 설계 - 끔찍한 코드
Employee[] emps = new Employee[10];
Manager[] mgrs = new Manager[10];
Engineer[] engs = new Engineer[10];
// 추가할 직군이 생길 때마다 배열 추가, add 메서드 추가, 검색 메서드 추가...
다형성을 적용하면:
// Employee 타입 배열 하나로 Manager, Engineer 전부 저장 가능
Employee[] emps = new Employee[10];
emps[0] = new Manager(...); // Manager is-a Employee → 자동 Upcasting
emps[1] = new Engineer(...); // Engineer is-a Employee → 자동 Upcasting
// toString() 호출 시 각각의 실제 타입에 맞는 메서드가 호출됨 → Virtual Invocation
for (Employee e : emps) {
System.out.println(e.toString());
}
EmployeeManagerPoly.java에서 이 구조를 직접 짜봤다. 새 직군이 생겨도 Employee를 상속하면 기존 배열과 관리 코드를 수정할 필요가 없다. 확장에는 열려있고 변경에는 닫혀있다. OCP(Open-Closed Principle)의 기초가 여기서 시작된다.
OCP: 객체지향 프로그래밍(OOP)의 5대 원칙(SOLID) 중 하나, 소프트웨어 개체는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다
Upcasting(자식 → 부모)은 자동이지만 반대인 Downcasting은 명시적으로 해야 한다. 그리고 잘못하면 ClassCastException이 터진다.
Employee emp = new Manager("E001", "김철수", 5000000, "부장");
// instanceof로 타입 확인 먼저 (자식부터 확인, else에서 부모 확인)
if (emp instanceof Manager) {
Manager mgr = (Manager) emp; // Downcasting
System.out.println(mgr.getPosition());
}
instanceof를 쓸 때 자식 타입부터 확인하는 게 원칙이다. 부모 타입을 먼저 체크하면 자식 객체도 true가 되어 의도치 않은 분기가 생긴다.
Shadow Impact란 부모 타입 참조 변수로 자식 객체를 다룰 때, 자식 클래스에서 접근 제한자가 default인 메서드는 오버라이딩 메서드로 인식되지 않아 호출이 안 되는 문제다. 해결하려면 해당 메서드를 protected 이상으로 선언하거나, Downcasting으로 실제 자식 타입으로 내려서 호출해야 한다. 실무에서 상속 쓸 때 접근 제한자 설정이 왜 중요한지 보여주는 케이스다.
final class → 상속 불가 (String, Integer 등이 final)
final method → 오버라이딩 불가
final field → 상수 선언, 한 번 할당하면 변경 불가
final double PI = 3.14159; // 이후 PI = 3.14 하면 컴파일 에러
static 멤버는 클래스가 JVM에 로드될 때 딱 한 번 메모리에 올라가고, 모든 인스턴스가 공유한다.
public class Counter {
static int count = 0; // 모든 인스턴스가 공유
public Counter() {
count++;
}
}
Counter a = new Counter();
Counter b = new Counter();
System.out.println(Counter.count); // 2 → 인스턴스 두 개가 하나의 count를 공유
객체 생성이 JVM 입장에서 가장 비싼 연산이다. static을 쓰는 의의가 여기 있다. 인스턴스 변수 없이 기능만 제공하는 유틸리티 클래스는 굳이 객체를 매번 생성할 필요가 없다.
// 대표적인 static 유틸리티 클래스들
Math.random() // Math 클래스는 모든 메서드가 static
Arrays.sort(arr) // Arrays 클래스도 전부 static
Objects.isNull(obj) // Objects 클래스도 동일
class Test {
static { System.out.println("1. static block"); } // 클래스 로드 시 1회
{ System.out.println("2. instance block"); } // 객체 생성마다, 생성자 직전
public Test() { System.out.println("3. 생성자"); }
}
// 출력:
// 1. static block ← 클래스 최초 로드 시 1회
// 2. instance block ← 객체 생성마다
// 3. 생성자
instance block은 컴파일 시 모든 생성자의 첫 번째 줄에 삽입된다.
instance block을 만든 이유는 생성자 오버로딩 시 공통 초기화 코드 중복을 없애기 위해서다.
// instance block 없이 생성자 3개면 중복 발생
class Test {
List<String> list;
public Test() { list = new ArrayList<>(); /* 중복 */ }
public Test(String name) { list = new ArrayList<>(); /* 중복 */ }
public Test(int age) { list = new ArrayList<>(); /* 중복 */ }
}
// instance block으로 공통 처리
class Test {
List<String> list;
{ list = new ArrayList<>(); } // 여기서 한 번만
public Test() { ... }
public Test(String name) { ... }
public Test(int age) { ... }
}
단, 실무에서는 this()로 생성자끼리 연결하는 방식이 가독성이 더 좋아서 instance block은 잘 쓰이지 않는다. "이런 게 있다" 정도로 알아두면 충분하다.
{} 만 있을 때 instance block인지 어떻게 아냐는 위치와 앞에 붙는 키워드로 구분한다.
class Test {
static { } // ① static block : static 키워드 있음
{ } // ② instance block : 클래스 바디에 {} 만 달랑 있음
void method() { } // ③ 메서드 : 반환타입 + 이름 있음
Test() { } // ④ 생성자 : 클래스 이름 있음
public void someMethod() {
{ } // ⑤ 지역 블록 : 메서드 안에 있음 (instance block 아님)
}
}
클래스 바디에 직접 위치하면서, static / 반환타입 / 메서드명이 아무것도 없이 {}만 있으면 instance block이다. 컴파일러도 이 위치 규칙으로 판단한다.
자바가 모든 인스턴스 메서드를 기본적으로 동적 바인딩하는 선택이 과연 옳았는가?
오늘 배운 내용을 정리하면서 이 질문이 떠올랐다.
C++은 virtual을 명시해야 동적 바인딩이 되고, 기본은 정적 바인딩이다. "쓰는 만큼만 비용 낸다(zero-cost abstraction)"는 철학이다. 자바는 반대로 모든 인스턴스 메서드가 기본 동적 바인딩이고, final 메서드만 정적 바인딩이 된다.
자바의 선택이 의도한 것들이 있다. 다형성을 쉽게 쓸 수 있고, 개발자가 virtual을 빼먹어서 다형성이 안 되는 버그를 원천 차단한다. 대규모 협업에서 실수를 줄여주는 설계다.
단점은 미세한 성능 오버헤드다. 근데 현대 JVM은 JIT 컴파일러가 "이 메서드는 항상 같은 구현으로 호출된다"는 걸 프로파일링으로 감지하면 인라인 캐싱으로 사실상 정적 디스패치처럼 최적화한다. 이론적 오버헤드가 실제 프로덕션 환경에서는 많이 상쇄된다는 뜻이다.
결론적으로 자바의 선택은 "성능보다 안전성과 유지보수성"이라는 언어 철학과 일치한다. Day 01에 배웠던 "개발자 인건비 > 서버 비용"과 같은 맥락이다.
Virtual Invocation Dynamic Dispatch vtable Upcasting Downcasting instanceof Shadow Impact Object 클래스 finalize() deprecated static modifier instance block final modifier 유틸리티 클래스 패턴