2주차 Unit 2.4 — new 연산자가 실제로 하는 일

Psj·2026년 5월 15일

F-lab

목록 보기
59/240

Unit 2.4 — new 연산자가 실제로 하는 일

F-LAB JAVA · 2주차 · Phase 2 · JVM 메서드 실행 메커니즘
🏁 Phase 2 마지막 Unit


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • new Shipment() 한 줄이 하는 4가지 작업의 순서는?
  • 생성자가 호출되기 전에 객체는 이미 메모리에 존재하는가?
  • 생성자 체인 (super() → 자식) 의 동작을 정확히 추적할 수 있는가?
  • new 없이 객체를 만드는 5가지 방법은?
  • 객체 생성 비용은 얼마이고, 언제 최적화해야 하나?
  • Spring/JPA/Jackson은 어떻게 객체를 만드는가?

🎯 핵심 한 문장

new는 4가지 작업을 한 줄로 응축한 키워드다.
클래스 확인 → 메모리 할당 → 멤버 초기화 → 생성자 호출.
그러나 자바에는 new 외에도 객체를 만드는 5가지 우회 경로가 있고,
Spring·JPA·Jackson은 모두 이 우회 경로를 사용한다.

비유 — 자동차 출고 과정

new의 작업자동차 비유
① 클래스 정보 확인어떤 차종? 사양은? (스펙 확인)
② Heap 메모리 할당출고장에 차량 1대 공간 확보
③ 멤버 변수 초기화차체 출고 (모든 부품 기본값)
④ 생성자 호출옵션 장착, 검수, 번호판 부착

중요: ②번 시점에 자동차는 이미 출고장에 있다. 그러나 ④번 끝나기 전까지는 운행 불가.
→ 생성자 호출 전 객체는 메모리에는 존재하지만 사용 불가 상태.


🧭 9개 섹션 로드맵

1. new의 본질 — 한 키워드, 4가지 작업
2. 작업 1 — 클래스 정보 확인
3. 작업 2 — Heap 메모리 할당
4. 작업 3 — 멤버 변수 초기화
5. 작업 4 — 생성자 호출
6. 생성자 체인 — super와 this
7. new 없이 객체 만들기 — 5가지 방법
8. ILIC 실무 — 객체 생성 비용과 최적화
9. 면접 질문 + 자기 점검 + Phase 2 정리

1️⃣ new의 본질 — 한 키워드, 4가지 작업

1.1 단순해 보이는 한 줄

Shipment s = new Shipment("BL-001", LocalDate.now());

소스 코드: 1줄.
JVM 동작: 4단계 + 그 안의 다단계 작업.

1.2 4가지 작업

┌─────────────────────────────────────────────┐
│ 작업 1: 클래스 정보 확인                       │
│   - Method Area에서 Shipment Klass 확인    │
│   - 없으면 Class Loading 발동                │
├─────────────────────────────────────────────┤
│ 작업 2: Heap 메모리 할당                      │
│   - 객체 크기 계산                            │
│   - Eden Space (TLAB) 에서 공간 확보         │
│   - Object Header 설정                       │
├─────────────────────────────────────────────┤
│ 작업 3: 멤버 변수 초기화                       │
│   - 모든 인스턴스 변수를 기본값으로            │
│   - int=0, boolean=false, ref=null          │
├─────────────────────────────────────────────┤
│ 작업 4: 생성자 호출                           │
│   - super() 호출 (자동)                      │
│   - 인스턴스 초기화 블록                       │
│   - 본문 코드 실행                            │
└─────────────────────────────────────────────┘

1.3 시간 흐름

시간 →
[작업 1] 클래스 확인  ──┐
                       ▼
[작업 2] 메모리 할당  ──── 객체 등장 (단, 사용 불가)
                       │
[작업 3] 기본값 초기화 ──── 객체가 사용 가능 직전
                       │
[작업 4] 생성자 실행  ──── 객체 완전체

작업 2 직후부터 객체는 메모리에 존재. 하지만 생성자 끝나기 전엔 완전한 상태가 아님.

1.4 바이트코드 매핑 (Unit 2.3 복습)

Shipment s = new Shipment("BL-001", date);
0: new           #2     // ← 작업 1, 2, 3 한 번에
3: dup
4: ldc           #3     // "BL-001"
6: aload_X              // date
8: invokespecial #4     // ← 작업 4 (생성자)
11: astore_1

new 바이트코드 명령어 하나가 작업 1~3을 통합 수행.
invokespecial이 작업 4 (생성자).

객체 생성과 생성자 호출은 별개의 바이트코드.
→ 이 분리가 자바의 객체 생성 메커니즘의 핵심.


2️⃣ 작업 1 — 클래스 정보 확인

2.1 첫 질문 — "Shipment 클래스가 메모리에 있나?"

new Shipment(...) 실행 시점:
  ↓
JVM이 Method Area 검색
  ↓
Shipment Klass 발견?
  Yes → 작업 2로
  No  → Class Loading 발동

2.2 처음 사용되는 클래스의 로딩

Unit 2.3 Stage 1에서 본 3단계:

1. Loading
   - .class 파일 찾기 (classpath 검색)
   - 바이트 읽기
   - Class 객체를 Heap에 생성
   - Klass를 Method Area에 배치

2. Linking
   - Verification: 바이트코드 검증 (악성 코드, 잘못된 점프 등)
   - Preparation: static 필드 메모리 할당 + 기본값
   - Resolution: 심볼 참조 해소 (lazy, 보통 처음 사용 시)

3. Initialization
   - static 변수 초기값 대입
   - static {} 블록 실행

2.3 부모 클래스가 먼저

public class Shipment extends BaseEntity {
    // ...
}

Shipment 로딩 시:

1. BaseEntity 로딩되어 있나?
   No → 먼저 BaseEntity 로딩 (재귀적)
   Yes → 다음 단계
2. 인터페이스 로딩 (있으면)
3. Shipment 로딩

부모 클래스 → 자식 클래스 순서.
→ Object 클래스는 JVM 시작 시 미리 로딩.

2.4 Initialization의 트리거 — 정확한 시점

JLS(Java Language Specification)에 따른 클래스 Initialization 시점:

  1. new T()인스턴스 생성
  2. T.staticMethod() — static 메서드 호출
  3. T.staticField 접근 (단, 상수 static final은 제외)
  4. T.class.newInstance() 등 Reflection
  5. 부모 클래스가 초기화될 때 자식이 먼저 초기화되어야 한다면

단순히 클래스 import만 해선 Initialization 안 됨.
→ 진짜 "사용" 시점에 실행.

2.5 static {} 블록의 사이드 이펙트

public class Shipment {
    static {
        System.out.println("Shipment 클래스 초기화");
        registerToFramework();   // 어떤 작업
    }
    
    private String blNo;
}

// 어디선가
new Shipment();   // ← 이 시점에 static {} 가 1번 실행

→ static 블록은 클래스당 1번만.
→ 비싼 작업을 여기 넣으면 첫 객체 생성 시 매우 느려질 수 있음.

2.6 작업 1의 결과

이 시점에 보장되는 것:
  ✓ Shipment Klass가 Method Area에 있음
  ✓ Class Metadata Zone에 필드/메서드 정보
  ✓ Static Zone에 static 필드 + 메서드 바이트코드
  ✓ Non-Static Zone에 인스턴스 메서드 바이트코드
  ✓ static {} 실행 완료

이제 실제 객체를 만들 준비 완료.


3️⃣ 작업 2 — Heap 메모리 할당

3.1 객체 크기 계산

public class Shipment {
    private Long id;             // 4 or 8 bytes (참조)
    private String blNo;         // 4 or 8 bytes
    private int weight;          // 4 bytes
    private boolean delivered;   // 1 byte
}

Compressed Oops 활성화 가정:

Header:              16 bytes (Mark Word 8 + Class Pointer 4 + Padding 4)
id (참조):           4 bytes
blNo (참조):         4 bytes
weight:              4 bytes
delivered:           1 byte
Padding:             3 bytes  (8-byte 정렬)
─────────────────────────────
총:                  32 bytes

→ JVM이 클래스 정보로 자동 계산. 객체마다 일정 크기.

3.2 Eden Space에서 할당

Heap의 Young Generation:
  ┌──────────────────────────────────────────┐
  │ Eden Space                                │
  │ ┌──────┬──────┬──────┬──────┬──────┐    │
  │ │ obj1 │ obj2 │ obj3 │      │      │    │
  │ └──────┴──────┴──────┴──────┴──────┘    │
  │                       ↑                   │
  │                 다음 할당 위치             │
  └──────────────────────────────────────────┘

JVM은 Bump Allocation 패턴:

1. 현재 할당 포인터 위치 확인
2. 객체 크기만큼 포인터를 이동시킬 수 있나? (= 공간 충분?)
   Yes → 포인터 이동 + 객체 메모리 확정
   No  → Minor GC 발동 → 재시도

객체 할당은 평균 매우 빠름 (포인터 +N).
→ GC가 발동하면 비용 폭증.

3.3 TLAB — 멀티스레드 최적화

Eden Space:
  ┌──────────────────────────────────────────┐
  │ Thread A의 TLAB │ Thread B의 TLAB │ ...  │
  └──────────────────────────────────────────┘

각 스레드는 자기 TLAB(Thread Local Allocation Buffer) 안에서:

  • 동기화 없이 빠르게 할당
  • TLAB이 가득 차면 새 TLAB 할당받음

→ Unit 1.5에서 본 메커니즘. 동시 객체 생성이 빠른 비밀.

3.4 메모리 0 클리어

할당된 메모리는 0으로 초기화.

할당 직후 메모리:
  Header 영역: (잠시 후 설정)
  Instance 영역: 모두 0x00으로 채워짐

→ 작업 3 (멤버 초기화)이 이미 부분적으로 처리된 셈.

3.5 Object Header 설정

Object Header (16 bytes):
  ┌─────────────────────────┐
  │ Mark Word (8 bytes)      │
  │   초기 상태:               │
  │   - hashcode 미계산        │
  │   - GC age = 0            │
  │   - unlocked              │
  ├─────────────────────────┤
  │ Class Pointer (4 bytes)  │  ← Shipment Klass 주소
  └─────────────────────────┘

→ 이 시점에 객체 = 클래스 연결 완료.
→ getClass(), instanceof 등이 작동 가능.

3.6 작업 2 완료 시점의 객체 상태

Heap:
  ┌──────────────────────────┐
  │ Shipment @0x7f4a2c01      │
  │   Mark Word: 초기 상태     │
  │   Class ptr → Shipment    │
  │   id = null               │  ← 0이지만 참조 타입이라 null
  │   blNo = null             │
  │   weight = 0              │
  │   delivered = false       │
  └──────────────────────────┘

중요: 이 시점에 객체는 이미 메모리에 존재.

  • getClass() 가능 → ShipmentClass 반환
  • hashCode() 가능 → 새 해시 계산
  • 하지만 사용자가 만든 생성자는 아직 실행 안 됨

4️⃣ 작업 3 — 멤버 변수 초기화

4.1 작업 2와 작업 3의 경계 모호

엄밀히 말하면 작업 2(메모리 할당 + 0 클리어)가 작업 3의 절반.
정확한 의미:

  • 작업 2의 0 클리어 → 모든 필드가 기본값
  • 작업 3의 명시적 초기값 → 필드 선언 시 = ...

4.2 필드 선언 초기값

public class Shipment {
    private int weight = 100;                          // ← 명시적 초기값
    private LocalDate createdAt = LocalDate.now();     // ← 명시적 초기값
    private List<Cargo> cargoes = new ArrayList<>();   // ← 명시적 초기값
    private String blNo;                                // (기본값 null)
}

컴파일러가 이 초기값들을 생성자의 시작 부분에 자동 삽입.

// 컴파일러가 만드는 모습 (개념적)
public Shipment() {
    super();                              // 1) 부모 생성자
    this.weight = 100;                    // 2) 필드 초기값
    this.createdAt = LocalDate.now();
    this.cargoes = new ArrayList<>();
    // this.blNo = null (이미 0 클리어로 처리됨)
    // 3) 사용자 생성자 본문 (있다면)
}

4.3 인스턴스 초기화 블록

public class Shipment {
    private final String id;
    
    {  // ← 인스턴스 초기화 블록 (생성자 아님)
        System.out.println("Shipment 생성 중");
        if (someCondition) {
            // 어떤 작업
        }
    }
    
    public Shipment() {
        // ...
    }
}

블록 {}모든 생성자의 시작 부분에 자동 삽입.

// 컴파일러가 만드는 모습
public Shipment() {
    super();
    // 필드 초기값
    // 인스턴스 초기화 블록
    {
        System.out.println("Shipment 생성 중");
        // ...
    }
    // 생성자 본문
}

4.4 작업 3 전체 흐름

1. 부모 클래스의 필드 초기값 + 초기화 블록 + 생성자 (재귀)
2. 자식 클래스의:
   a. 필드 선언의 명시적 초기값
   b. 인스턴스 초기화 블록 {}
   c. 생성자 본문

순서가 중요. 부모가 항상 먼저.

4.5 final 필드의 초기화

public class Shipment {
    private final String blNo;        // ← 반드시 초기화 필요
    private final LocalDate eta;
    
    public Shipment(String blNo, LocalDate eta) {
        this.blNo = blNo;             // ✓ 생성자에서
        this.eta = eta;
    }
}

final 필드 규칙:

  • 선언 시 초기화 OR 모든 생성자에서 반드시 할당
  • 한 번 할당되면 변경 불가
  • 컴파일러가 강제 검증

→ 불변 객체(Immutable) 패턴의 기초.


5️⃣ 작업 4 — 생성자 호출

5.1 생성자가 호출되는 진짜 시점

new Shipment("BL-001", date);

바이트코드:

0: new           #2     // 작업 1, 2, 3
3: dup
4: ldc           #3     // "BL-001"
6: aload_X              // date
8: invokespecial #4     // 작업 4: 생성자 호출 ★

invokespecial 이 작업 4의 핵심.
→ Unit 1.5의 invokespecial이 정확히 여기서 사용됨 (생성자, private, super).

5.2 invokespecial의 동작

1. Operand Stack에서 인자들 pop
   Stack: [obj_ref, "BL-001", date_ref] → 모두 가져감
   
2. 새 스택 프레임 생성:
   <init>() 프레임
     LVA[0] this = obj_ref       ← 작업 2에서 만든 객체
     LVA[1] blNo = "BL-001"
     LVA[2] eta = date_ref
   
3. 생성자 바이트코드 실행

5.3 생성자 바이트코드 분석

public Shipment(String blNo, LocalDate eta) {
    this.blNo = blNo;
    this.eta = eta;
    validate();
}

바이트코드 (대략):

 0: aload_0                    // this
 1: invokespecial #1           // super() — Object의 <init>
 4: aload_0                    // this
 5: aload_1                    // blNo
 6: putfield #2                // this.blNo = blNo
 9: aload_0
10: aload_2                    // eta
11: putfield #3                // this.eta = eta
14: aload_0
15: invokespecial #4           // this.validate()
18: return

핵심 관찰:

  • 첫 번째가 항상 super() 호출 (자동 삽입)
  • 그 다음 필드 할당
  • 그 다음 본문 코드

5.4 super() 자동 호출

public class Shipment extends BaseEntity {
    public Shipment() {
        // super() — 컴파일러가 자동 추가
        // 그 다음 본문 코드
    }
}

명시 안 해도 컴파일러가 자동으로 추가.

예외 1: 명시적으로 부모 생성자 호출

public Shipment(String blNo) {
    super(blNo);    // 명시
    // ...
}

예외 2: this() 호출 시

public Shipment() {
    this("DEFAULT");    // 다른 생성자 호출 (super 자동 안 됨)
}

public Shipment(String blNo) {
    super();
    this.blNo = blNo;
}

this()super()둘 다 생성자의 첫 줄이어야 함.

5.5 작업 4 완료 — 사용 가능한 객체

Heap:
  ┌──────────────────────────┐
  │ Shipment @0x7f4a2c01      │
  │   Mark Word               │
  │   Class ptr → Shipment    │
  │   blNo = "BL-001"          │  ← 생성자가 설정
  │   eta = 2024-12-01         │  ← 생성자가 설정
  │   weight = 100             │  ← 필드 초기값
  │   cargoes = [] (ArrayList) │
  │   delivered = false        │
  └──────────────────────────┘

이제 외부에서 사용 가능 (메서드 호출, 필드 접근 등).

new 표현식의 결과로 이 객체의 참조가 Operand Stack에 남음 (dup 덕분).


6️⃣ 생성자 체인 — super와 this

6.1 생성자 체인의 본질

class Animal {
    String species;
    
    Animal() {
        this.species = "unknown";
        System.out.println("Animal()");
    }
    
    Animal(String species) {
        this.species = species;
        System.out.println("Animal(" + species + ")");
    }
}

class Dog extends Animal {
    String name;
    
    Dog() {
        super();    // 자동
        System.out.println("Dog()");
    }
    
    Dog(String name) {
        super("Dog");   // 명시
        this.name = name;
        System.out.println("Dog(" + name + ")");
    }
    
    Dog(String name, String species) {
        this(name);    // 다른 생성자 호출
        // species는 무시 (예시)
    }
}

6.2 호출 추적

new Dog("Rex");

호출 흐름:

Dog("Rex") 시작
  ↓ super("Dog")
  Animal("Dog") 시작
    ↓ super()
    Object() 시작
    Object() 완료
  Animal("Dog") 본문 실행
    this.species = "Dog"
    print "Animal(Dog)"
  Animal("Dog") 완료
  ↓
Dog("Rex") 본문 실행
  this.name = "Rex"
  print "Dog(Rex)"
Dog("Rex") 완료

출력:

Animal(Dog)
Dog(Rex)

항상 부모가 먼저, 자식이 나중.

6.3 스택 프레임의 동시 존재

new Dog("Rex");

호출 깊이가 깊은 어느 순간 (Animal의 본문 실행 중):

Stack:
  ┌────────────────────────────┐
  │ Animal.<init>(String) 프레임 │  ← 현재 실행 중
  │   LVA[0] this = obj_ref     │
  │   LVA[1] species = "Dog"    │
  ├────────────────────────────┤
  │ Dog.<init>(String) 프레임    │
  │   LVA[0] this = obj_ref     │  ← 같은 this!
  │   LVA[1] name = "Rex"       │
  ├────────────────────────────┤
  │ 호출자 프레임 (main 등)        │
  └────────────────────────────┘

Heap:
  Dog 객체 @0x7f4a2c01 (Animal의 species 필드 설정 중)

핵심: 부모 생성자와 자식 생성자가 같은 this 참조를 공유.
→ 같은 객체를 단계적으로 초기화.

6.4 this() 체인

class Shipment {
    private String blNo;
    private LocalDate eta;
    private BigDecimal freight;
    
    public Shipment(String blNo) {
        this(blNo, LocalDate.now());   // 두 인자 생성자 호출
    }
    
    public Shipment(String blNo, LocalDate eta) {
        this(blNo, eta, BigDecimal.ZERO);   // 세 인자 생성자 호출
    }
    
    public Shipment(String blNo, LocalDate eta, BigDecimal freight) {
        super();
        this.blNo = blNo;
        this.eta = eta;
        this.freight = freight;
    }
}

호출:

new Shipment("BL-001");

흐름:

Shipment("BL-001") 시작
  ↓ this("BL-001", LocalDate.now())
  Shipment("BL-001", now) 시작
    ↓ this("BL-001", now, ZERO)
    Shipment("BL-001", now, ZERO) 시작
      ↓ super()
      Object() 실행 완료
      this.blNo = "BL-001"
      this.eta = now
      this.freight = ZERO
    Shipment 3개 인자 완료
  Shipment 2개 인자 완료
Shipment 1개 인자 완료

→ 모든 작업이 최종적으로 한 객체에 누적.

6.5 생성자에서 다른 메서드 호출의 위험

class Parent {
    public Parent() {
        init();    // ❌ 가상 메서드 호출
    }
    
    protected void init() {
        System.out.println("Parent init");
    }
}

class Child extends Parent {
    private String name;
    
    public Child() {
        super();   // Parent() 호출 → init() 호출 → Child의 init() 실행!
        this.name = "Rex";
    }
    
    @Override
    protected void init() {
        System.out.println("Child init: " + name);   // ❌ name 아직 null!
    }
}

문제:

  • Parent 생성자가 실행 중일 때, Child의 init이 호출됨 (다형성)
  • 그런데 Child의 name은 아직 할당 전 → null

생성자에서 오버라이드 가능한 메서드 호출 금지 (Effective Java Item 19).

해결:

  • final 메서드 호출
  • private 메서드 호출
  • 다른 패턴 사용 (Builder, Factory)

7️⃣ new 없이 객체 만들기 — 5가지 방법

7.1 방법 1 — Reflection

Class<?> clazz = Class.forName("com.ilic.Shipment");
Shipment s = (Shipment) clazz.getDeclaredConstructor().newInstance();
// 또는 인자가 있다면
Shipment s2 = (Shipment) clazz
    .getDeclaredConstructor(String.class)
    .newInstance("BL-001");

동작:

  • 클래스 정보 조회 (= 작업 1)
  • Heap 할당 (= 작업 2)
  • 필드 초기화 (= 작업 3)
  • 생성자 호출 (= 작업 4)

new와 동일한 4단계 모두 수행. 단지 컴파일 타임이 아닌 런타임에 결정.

사용처:

  • Spring DI (Bean 생성)
  • JUnit (테스트 인스턴스)
  • Jackson (역직렬화)

7.2 방법 2 — clone()

public class Shipment implements Cloneable {
    @Override
    public Shipment clone() throws CloneNotSupportedException {
        return (Shipment) super.clone();
    }
}

Shipment original = new Shipment("BL-001");
Shipment copy = original.clone();    // ← new 없이 객체 생성!

동작:

  • 작업 1: 같은 클래스 사용
  • 작업 2: Heap에 새 메모리 할당
  • 작업 3 변형: 원본 객체의 필드를 그대로 복사 (얕은 복사)
  • 작업 4: 생성자 호출 안 함!
public class Shipment implements Cloneable {
    private String blNo;
    
    public Shipment() {
        this.blNo = "DEFAULT";    // 생성자에서 설정
        System.out.println("Constructor called");
    }
}

Shipment a = new Shipment();        // 출력: "Constructor called"
Shipment b = a.clone();              // 아무 출력 없음
b.getBlNo();                          // "DEFAULT" (필드 복사됨)

생성자 우회. 디자인 시 주의.

7.3 방법 3 — Deserialization

public class Shipment implements Serializable {
    private static final long serialVersionUID = 1L;
    private String blNo;
}

// 직렬화 후 역직렬화
byte[] bytes = serialize(shipment);
Shipment restored = (Shipment) deserialize(bytes);
//                              ↑ new 없이 객체 생성

동작:

  • ObjectInputStream이 바이트를 읽어 객체 구성
  • 생성자 호출 안 함
  • 필드를 바이트에서 직접 복원

→ 1주차 Unit 7.4의 Serializable 함정 중 하나.
→ 생성자에서 했던 검증이 우회됨 → 보안 사고 가능.

7.4 방법 4 — Unsafe.allocateInstance (위험)

import sun.misc.Unsafe;

Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);

Shipment s = (Shipment) unsafe.allocateInstance(Shipment.class);

동작:

  • Heap에 메모리만 할당
  • 필드 초기화도 안 함
  • 생성자 호출도 안 함

→ 가장 원시적. 위험.
→ 객체가 미초기화 상태 (모든 참조 필드는 null, 숫자는 0).
→ 일반 코드에선 절대 사용 금지.

사용처 (라이브러리 내부):

  • Mockito, ByteBuddy 등이 내부적으로 사용
  • 일부 직렬화 라이브러리

7.5 방법 5 — 동적 프록시 / CGLIB

// JDK Dynamic Proxy
Shipment proxy = (Shipment) Proxy.newProxyInstance(
    classLoader,
    new Class[] { Shipment.class },
    (proxyObj, method, args) -> {
        // 가로채기
        return realObj.invoke(method, args);
    }
);

// CGLIB
Shipment proxy = (Shipment) Enhancer.create(
    Shipment.class,
    (MethodInterceptor) (obj, method, args, methodProxy) -> {
        // 가로채기
    }
);

동작:

  • 런타임에 새 클래스를 동적 생성 (Shipment를 상속하거나 인터페이스 구현)
  • 그 클래스의 인스턴스 생성 (내부적으로 new 또는 Unsafe)

→ Spring AOP, Mockito, Hibernate Proxy의 기반.
→ 박승제씨가 이미 코드에서 매일 마주치는 메커니즘.

7.6 정리 — 5가지 방법의 비교

방법생성자 호출?사용처
new일반 코드
ReflectionSpring DI, JUnit, Jackson
clone()복사 패턴
DeserializationSerializable, Caching
Unsafe.allocateInstance라이브러리 내부
Dynamic Proxy / CGLIB✓ (다른 클래스의)AOP, Mockito

현대 자바 앱은 new 외의 경로가 더 많음.

7.7 자기 점검 답변

생성자가 호출되기 전에 객체는 이미 메모리에 존재하는가?

: 예. 작업 2 직후에 메모리에는 존재. 단, 작업 4(생성자) 전엔 사용 가능 상태는 아님.

  • 작업 2: 메모리 할당 + 0 클리어 + Class Pointer 설정
  • 작업 3: 필드 기본값
  • 작업 4: 생성자 → 완성

Unsafe.allocateInstance는 정확히 작업 4를 건너뛴 상태의 객체를 만든다.


8️⃣ ILIC 실무 — 객체 생성 비용과 최적화

8.1 객체 생성 비용 측정

@Benchmark
public Shipment createShipment() {
    return new Shipment("BL-001", LocalDate.now());
}

JMH 결과 (가상):

Cost: ≈ 30 ns per allocation
  - Bump allocation: ~10ns
  - Header 설정: ~5ns
  - 생성자 실행: ~15ns

→ 일반적인 객체 생성은 30ns 정도.
→ 초당 3천만 개 생성 가능.

8.2 객체 생성이 진짜 병목인가?

대부분 아니다.

  • DB 쿼리: 1-10ms (1000-10000μs)
  • HTTP 호출: 10-100ms
  • 객체 생성: 30ns = 0.03μs

→ 객체 생성을 줄여서 얻는 이득은 거의 항상 다른 곳에서 더 큰 손실.

그러나 다음 경우는 다름:

  • 매우 많은 객체 생성 (수천만 개)
  • GC 압박이 큰 환경
  • 초저지연 요구 시스템

8.3 최적화 패턴 1 — Object Pool

public class ConnectionPool {
    private final BlockingQueue<Connection> pool;
    
    public Connection borrow() {
        Connection conn = pool.poll();
        if (conn == null) {
            conn = createNew();    // 새로 만들기
        }
        return conn;
    }
    
    public void release(Connection conn) {
        pool.offer(conn);   // 풀에 반환
    }
}

언제 효과적?

  • 생성 비용이 매우 비싼 객체 (DB Connection, Thread, 큰 버퍼)
  • 빈번한 생성/소멸

언제 비효율?

  • 일반 객체 (Shipment, Cargo 등) → 풀링 오버헤드가 더 큼

→ 박승제씨가 직접 풀링할 일은 거의 없음. JDBC, ThreadPool은 이미 풀링.

8.4 최적화 패턴 2 — Flyweight (불변 객체 공유)

public class Status {
    public static final Status ACTIVE = new Status("ACTIVE");
    public static final Status INACTIVE = new Status("INACTIVE");
    public static final Status DELETED = new Status("DELETED");
    
    private final String code;
    private Status(String code) { this.code = code; }
}

→ 객체를 미리 만들어 공유.
→ Integer.valueOf의 캐시(-128 ~ 127)와 같은 원리 (Unit 1.6).

ILIC 적용:

public enum ShipmentStatus {
    DRAFT, IN_TRANSIT, DELIVERED, CANCELLED
}

enum은 자동 Flyweight. 4개 객체만 메모리에 존재.

8.5 최적화 패턴 3 — Builder 재사용

// ❌ 매번 새 StringBuilder
public String generateBlNo(List<String> parts) {
    StringBuilder sb = new StringBuilder();
    for (String p : parts) sb.append(p);
    return sb.toString();
}

// ✓ ThreadLocal로 재사용
private static final ThreadLocal<StringBuilder> SB_CACHE = 
    ThreadLocal.withInitial(StringBuilder::new);

public String generateBlNo(List<String> parts) {
    StringBuilder sb = SB_CACHE.get();
    sb.setLength(0);    // 재사용
    for (String p : parts) sb.append(p);
    return sb.toString();
}

→ 미세 최적화. 정말 hot path에서만.
→ ThreadLocal 누수 위험 주의.

8.6 Escape Analysis — JIT의 자동 최적화

public BigDecimal calculate(int weight, int rate) {
    BigDecimal w = new BigDecimal(weight);   // 메서드 내부에서만 사용
    BigDecimal r = new BigDecimal(rate);
    return w.multiply(r);   // 반환은 결과만
}

JIT의 분석:

  • w, r은 메서드 밖으로 escape 안 함
  • Stack에 할당 (Scalar Replacement)
  • → Heap 할당 비용 제거
  • → GC 부담 감소

JIT가 자동으로 객체 → 스택 할당 변환.
→ 박승제씨는 코드를 자연스럽게 쓰면 JIT가 알아서 최적화.

8.7 ILIC 운영 관점

일반적인 Spring Boot 앱의 객체 생성:
  - 요청당 ~수십 ~ 수백 개 객체
  - 대부분 짧은 수명 (Young Gen에서 회수)
  - GC가 효율적으로 처리

문제가 생기는 경우:
  - 캐시가 너무 자랑 (Old Gen 압박)
  - 큰 객체 생성 (배열, 컬렉션)
  - GC 로그에서 Full GC 빈번

우선 측정, 그 다음 최적화. 80%는 측정 후 다른 곳이 병목.


9️⃣ 면접 질문 + 자기 점검 + Phase 2 정리

9.1 면접 단골 질문 매핑

Q핵심 답변
new의 4가지 작업?클래스 확인 → 메모리 할당 → 필드 초기화 → 생성자 호출
생성자 호출 전 객체 존재?예. 작업 2 직후 메모리에 있음. 단 사용 가능 상태는 아님
new 바이트코드와 invokespecial 관계?new는 작업 1~3, invokespecial이 작업 4 (생성자)
dup이 필요한 이유?new 객체 참조를 invokespecial이 소비. astore도 써야 해서 복제
super() 자동 호출 시점?생성자 첫 줄. this() 호출 시 제외
생성자 체인 순서?부모가 먼저 (Object → ... → 자식)
new 없이 객체 만드는 5가지?Reflection, clone, Deserialization, Unsafe, Dynamic Proxy
생성자에서 오버라이드 메서드 호출 위험?자식의 메서드가 호출됨. 자식 필드 초기화 전
객체 생성 비용?일반 ~30ns. 거의 항상 다른 곳이 병목
Escape Analysis?JIT가 메서드 안에서만 쓰는 객체를 Stack에 할당

9.2 자기 점검 체크리스트

기본 이해

  • new의 4가지 작업을 순서대로 안다
  • 작업 2 직후의 객체 상태를 그릴 수 있다
  • 생성자 체인의 호출 순서를 추적할 수 있다
  • new 없이 객체 만드는 5가지 방법을 안다
  • 각 방법이 생성자를 호출하는지 안다

실전 적용

  • Spring/JPA/Jackson이 어떻게 객체 만드는지 안다
  • 생성자에서 오버라이드 메서드 호출 피한다
  • enum으로 Flyweight 적용 가능
  • 객체 풀링이 언제 효과적인지 안다
  • Escape Analysis의 자동 최적화를 안다

면접 대비 — 5분 답변

  • new의 4단계와 바이트코드
  • 생성자 호출 전 객체 상태
  • new 없이 객체 만들기 5가지
  • 생성자 체인 메커니즘
  • 객체 생성 비용과 최적화 시점

9.3 🏁 Phase 2 완주 — JVM 메서드 실행 메커니즘 정리

Phase 2에서 본 것 (Unit 2.1 ~ 2.4)

Unit 2.1 — 메서드 호출의 2단계 처리
  ↓ Resolution (시그니처 확인) + Invocation (바이트코드 실행)

Unit 2.2 — Static vs 인스턴스 호출 경로
  ↓ 4가지 케이스, this의 전달 메커니즘

Unit 2.3 — 인스턴스 메서드 호출의 전 과정 (Case Study)
  ↓ 5단계 추적, JIT의 1000배 최적화

Unit 2.4 — new 연산자의 실제 동작
  ↓ 4가지 작업, new 없는 5가지 객체 생성

9.4 Phase 2 졸업 자가 진단

즉답할 수 있어야 할 질문:

  1. Model2 m2 = new Model2(); m2.hap(1, 2); 두 줄이 JVM에서 어떤 단계를 거치는가?
  2. 다형성 메서드 호출 시 어느 메서드가 실행될지 어떻게 결정하나?
  3. AOP self-invocation 함정의 근본 원인은?
  4. Spring Boot 배포 직후 첫 요청이 느린 이유는?
  5. new 없이 객체를 만드는 방법 5가지와 각각의 생성자 호출 여부는?

모두 답할 수 있다면 Phase 2 졸업. Phase 3 (정점) 진입.


🎯 핵심 요약 — 3줄 정리

1. new는 4가지 작업의 응축

  • 클래스 확인 → 메모리 할당 → 필드 초기화 → 생성자 호출
  • 바이트코드: new (작업 1~3) + invokespecial (작업 4)
  • 작업 2 직후 객체는 존재하지만 사용 불가

2. 생성자 체인은 부모부터

  • super() 자동 호출 (생성자 첫 줄)
  • this() 로 다른 생성자 위임 가능
  • 생성자에서 오버라이드 메서드 호출은 위험

3. new 외에 5가지 우회 경로

  • Reflection (Spring/Jackson)
  • clone (생성자 우회)
  • Deserialization (생성자 우회)
  • Unsafe (모두 우회, 위험)
  • Dynamic Proxy / CGLIB (AOP, Mockito)

📚 다음으로...

🚀 Phase 3 — 바이트코드와 상수 풀 ★ 2주차의 정점

이번 Unit까지 Phase 2를 완주했다면, Phase 3가 모든 것의 정점이다.

지금까지 본 모든 메커니즘:

  • invoke 4종, dup, new, invokespecial, ...
  • #2, #3, #15 같은 상수 풀 인덱스
  • Method Table, VMT, Klass

이게 모두 javap -v Shipment.class 한 줄로 직접 확인 가능.
Phase 3에서 박승제씨는 눈으로 바이트코드를 읽는 능력을 갖게 된다.

  • Unit 3.1 — 바이트코드란 무엇인가
  • Unit 3.2 — 상수 풀(Constant Pool)의 생성과 구조
  • Unit 3.3 — 심볼 참조 (Symbolic Reference)
  • Unit 3.4 — 바이트코드 실전 분석

2주차 진행 상황

✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
🎯 Phase 3 — 바이트코드와 상수 풀 ★ 2주차의 정점
  ⏭ Unit 3.1 바이트코드란 무엇인가
  ⏭ Unit 3.2 상수 풀의 생성과 구조
  ⏭ Unit 3.3 심볼 참조
  ⏭ Unit 3.4 바이트코드 실전 분석
⏭ Phase 4 — G1 GC 심화
⏭ Phase 5 — 컬렉션 내부 구조
⏭ Phase 6 — Reflection & Iterator
⏭ Phase 7 — Buffer

작성한 2주차 학습자료 정리

Phase 1 (메모리 매핑):
  ✅ Unit 1.1 자바 변수의 3종류
  ✅ Unit 1.2 변수별 저장 위치
  ✅ Unit 1.3 Method Area의 3개 존 ★
  ✅ Unit 1.4 Stack Area의 동작
  ✅ Unit 1.5 Heap과 객체-Metadata 연결
  ✅ Unit 1.6 Literal Pool Area

Phase 2 (메서드 실행):
  ✅ Unit 2.1 메서드 호출의 2단계 처리
  ✅ Unit 2.2 Static vs 인스턴스 호출 경로
  ✅ Unit 2.3 인스턴스 메서드 호출의 전 과정
  ✅ Unit 2.4 new 연산자의 실제 동작 ← 여기, Phase 2 완주

10개 Unit. 2주차의 절반.

profile
Software Developer

0개의 댓글