DI 조감도📚 전체 맵DI IoC·Container
07b · DI 완전 이해 · 시리즈 2/5

CLASS
ANATOMY

클래스 · 필드 · 생성자 · final · this — 코드 한 줄씩 + JVM 메모리 상태 변화 완전 해부

Big Picture First
지금 어디 있나 — 전체 흐름 속 위치 확인

항상 전체 그림부터. 지금 배우는 내용이 전체에서 어느 위치인지 확인하고 들어갑니다.

① 파일 읽기
② 클래스·필드·생성자·final 이해 ← 지금 여기
③ Spring Container + IoC
④ Bean 생성 + DI 실행
⑤ 메서드 호출 + 실행
📌 왜 이게 중요한가 — DI 이해의 기초

DI가 일어날 때 스프링이 하는 일은 결국 "클래스를 읽고, 생성자를 호출하고, 필드에 값을 넣는 것"입니다.
클래스·필드·생성자·final을 모르면 DI가 어디서 뭘 하는지 전혀 안 보입니다.
이 파일을 이해하면 07c~07e가 전부 명확하게 보입니다.

Class
클래스(Class) — 설계도. 실행이 아니다.

가장 많이 헷갈리는 부분. 클래스는 코드가 있어도 실행이 아닙니다.
"있다고 실행되는 게 아니라, 누가 호출해야 실행된다" — 이게 클래스의 핵심입니다.

Class
클래스
🥩 간판 + 메뉴판. 있어도 장사가 되는 게 아님.
설계도. "이런 데이터를 갖고, 이런 기능을 한다"고 정의만 해놓은 것.
클래스 파일이 존재해도 JVM이 실행하지 않으면 아무 일도 일어나지 않음.

클래스를 실제로 동작하는 객체로 만들려면 new 키워드로 인스턴스화해야 합니다.
Instance
인스턴스(객체)
🥩 실제로 영업 중인 가게. 진열대에 고기도 있고 손님도 받음.
new PaymentService()를 실행하면 → Heap에 실제 객체 생성.
같은 클래스로 여러 인스턴스를 만들 수 있음.

스프링에서는 스프링이 new를 대신 실행해서 Heap에 인스턴스를 만들어줍니다.
클래스 읽기 → JVM 메모리 상태
Method Area
PaymentService.class
클래스 구조 정보
pay() 바이트코드
메서드 코드 저장
@Service 메타데이터
어노테이션 정보
Heap
비어있음 — 아직 new 안 함
클래스 읽기만 했을 때
Stack
비어있음 — 아직 메서드 호출 없음
PaymentService.java — 읽기 단계에서 무슨 일이 일어나나
// ─────────────────────────────────────────────────────────────────
// [읽기 단계] JVM이 이 파일을 읽을 때 일어나는 일
// ─────────────────────────────────────────────────────────────────

@Service
// ↑ [읽기 ①] 이 어노테이션을 읽음
//   → Method Area에 "@Service 표시가 있는 클래스"로 등록 예약
//   → 스프링이 나중에 Bean으로 만들 대상 목록에 추가됨
//   → 아직 아무 객체도 만들어지지 않음

public class PaymentService {
// ↑ [읽기 ②] 클래스 이름 읽음
//   → Method Area에 "PaymentService라는 클래스가 있다" 정보 저장
//   → 클래스 구조(필드가 몇 개인지, 메서드가 무엇인지) 파악

    public void pay() {
    // ↑ [읽기 ③] 메서드 정의 읽음
    //   → pay()의 바이트코드를 Method Area에 저장
    //   → "pay()라는 메서드가 있고, 이런 코드를 실행한다"는 정보만 저장
    //   → 실행하는 게 아님! 코드만 저장해두는 것
        System.out.println("결제 완료!");
        // ↑ [읽기 ④] 이 코드도 바이트코드로 저장만 됨
        //   → 출력이 일어나는 게 아님
        //   → pay()가 호출될 때만 이 코드가 실행됨
    }
}

// ─────────────────────────────────────────────────────────────────
// 읽기 완료 후 JVM 메모리 상태:
// Method Area: PaymentService.class, pay() 바이트코드 ← 있음
// Heap:        비어있음 ← PaymentService 객체 없음
// Stack:       비어있음 ← 아무 메서드도 실행 안 됨
// ─────────────────────────────────────────────────────────────────
📌 클래스 = 붕어빵 틀, 인스턴스 = 실제 붕어빵

붕어빵 틀(클래스): "이런 모양의 붕어빵을 만들 수 있다"는 정의. Method Area에 저장.
실제 붕어빵(인스턴스): 틀로 찍어낸 실제 객체. Heap에 저장.

틀은 하나인데 붕어빵은 여러 개 만들 수 있음. 마찬가지로 클래스는 하나인데 객체는 여러 개 만들 수 있음.
스프링 기본 설정(Singleton)에서는 Bean은 딱 하나만 만들어서 재사용합니다.

Field
필드(Field) — 객체 안의 데이터 저장 공간
필드 선언 vs 필드에 값이 들어간 상태 — 메모리 차이
OrderService.java — 필드 선언 완전 분석
public class OrderService {

    private final PaymentService paymentService;
    //   ↑①      ↑②              ↑③
    //
    // ① private: 접근 제어자
    //    → 이 클래스 안에서만 접근 가능
    //    → 외부에서 orderService.paymentService 로 직접 접근 불가
    //    → 왜? 내부 구현을 숨겨서 보호. 캡슐화(Encapsulation)
    //
    // ② final: 한번 할당하면 절대 변경 불가
    //    → 선언 시점에 바로 값 할당 OR 생성자에서 딱 한번만 할당 가능
    //    → 이후 this.paymentService = 다른값; 하면 컴파일 에러
    //    → 왜? OrderService의 라이프사이클 동안 결제 기능이 바뀌면 안 됨
    //
    // ③ PaymentService paymentService: 타입과 이름
    //    → PaymentService 타입의 객체를 가리키는 참조(주소)를 담는 공간
    //    → 이 공간 자체는 클래스 읽기 단계에서 파악됨
    //    → 실제 값(PaymentService 객체 주소)은 생성자 실행 시 채워짐
    //
    // ── 지금 이 시점(선언 직후) ──
    // Method Area: "필드 paymentService가 있다"는 구조 정보 ← 있음
    // Heap:        아직 OrderService 객체 없음 → 필드도 없음
}
📌 필드는 "선언"과 "값 저장"이 다른 시점이다

필드 선언(클래스 읽기 단계): "이 클래스에 paymentService라는 공간이 있다"는 구조 정보만 파악. Method Area.

필드 값 저장(객체 생성 단계): new OrderService(ps)가 실행될 때 Heap에 OrderService 객체가 생기고, 그 객체 안에 paymentService 필드 공간이 생기고, 거기에 ps의 주소가 저장됨.

즉, 필드의 실제 값은 Heap의 객체 안에 존재합니다.

필드 종류와 메모리 위치
필드 종류선언 예메모리 위치언제 생성되나특징
인스턴스 필드 private String name; Heap — 객체 안 new로 객체 생성 시 객체마다 각자의 값 가짐
static 필드 static int count; Method Area 클래스 로딩 시 (JVM 시작) 모든 객체가 공유
final 인스턴스 필드 private final PaymentService ps; Heap — 객체 안 new로 객체 생성 시 한번 값 넣으면 변경 불가
static final 필드 static final String URL = "..."; Method Area — 상수 풀 클래스 로딩 시 모든 객체 공유 + 변경 불가
Constructor
생성자(Constructor) — new가 실행될 때 딱 한 번 일어나는 것

생성자는 객체가 태어날 때 단 한 번 실행되는 특별한 메서드입니다. DI에서 가장 중요한 역할을 합니다 — 스프링이 의존 객체를 여기에 넣어주기 때문입니다.

생성자 실행 순서 — new OrderService(ps)를 호출하면
1
JVM · Heap
Heap에 OrderService 객체 공간 확보
new OrderService(ps) 실행 →
JVM이 Heap에 OrderService 객체가 들어갈 공간 확보.
이때 인스턴스 필드(paymentService)를 위한 공간도 함께 생김.
아직 필드에 값은 없음 (기본값: 참조형은 null).
HEAP — 공간 확보
2
JVM · Stack
Stack에 생성자 프레임 push + 매개변수 저장
생성자 OrderService(PaymentService paymentService) 실행 시작.
Stack에 생성자 프레임 생성 →
매개변수 paymentService(ps의 주소값) → Stack의 지역변수로 저장.
STACK — 생성자 프레임 push
3
생성자 본문 실행
this.paymentService = paymentService — 핵심 한 줄
생성자 실행 시 메모리 흐름
public OrderService(PaymentService paymentService) {
    //                 ↑ Stack의 지역변수: PaymentService 객체의 주소값(예: 0x1A2B)

    this.paymentService = paymentService;
    //  ↑①                  ↑②
    //
    // ① this.paymentService:
    //    this = 지금 만들어지고 있는 OrderService 객체 (Heap)
    //    this.paymentService = Heap의 OrderService 객체 안에 있는 필드 공간
    //
    // ② paymentService (오른쪽):
    //    Stack의 지역변수 = PaymentService 객체의 주소값 (0x1A2B)
    //
    // = 의 의미:
    //    Stack의 주소값(0x1A2B)을 Heap의 필드 공간에 복사해서 넣음
    //    → 이제 OrderService.paymentService 필드가 PaymentService 객체를 가리킴
    //    → final이므로 이후 절대 변경 불가
}
HEAP — OrderService.paymentService 필드에 주소값 저장 STACK — 지역변수(매개변수) 에서 값 읽음
4
JVM · Stack
생성자 완료 → Stack 프레임 pop → 매개변수 소멸
생성자 코드 끝 → Stack에서 생성자 프레임 제거 →
Stack의 지역변수 paymentService(주소값) 소멸.
하지만 Heap의 OrderService.paymentService 필드에는 이미 주소값이 저장돼있으므로 문제없음.
객체 생성 완료. Heap에 OrderService 객체가 살아있음.
STACK — 생성자 프레임 pop, 지역변수 소멸 HEAP — OrderService 객체 완성 (paymentService 필드 연결됨)
생성자 완료 후 Heap 상태 시각화
생성자 완료 직후 메모리 전체 상태
━━━━━━━━━━━━━ Method Area ━━━━━━━━━━━━━━━━━━━━━━━━━━
 PaymentService.class   │ pay() 바이트코드
 OrderService.class     │ order(), 생성자 바이트코드
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━━━━━━━━━━━━━ Heap ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 주소 0x1A2B: [ PaymentService 객체 ]
              ← 필드 없음 (PaymentService에 선언된 필드가 없으므로)
              ← pay() 메서드는 Method Area를 가리킴

 주소 0x3C4D: [ OrderService 객체 ]
              paymentService 필드 = 0x1A2B  ← PaymentService 객체 가리킴
              ← order() 메서드는 Method Area를 가리킴
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━━━━━━━━━━━━━ Stack ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 비어있음 (생성자 프레임 pop 완료)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
this
this — "지금 만들어지고 있는 이 객체"

this는 많이 쓰이지만 정확히 뭔지 모르면 헷갈립니다. 한 줄 정의: 지금 실행 중인 메서드(또는 생성자)가 속한 Heap의 객체 주소입니다.

Q
this.paymentService = paymentService; 에서 this가 왜 필요한가?
A
필드 이름이 paymentService이고, 매개변수 이름도 paymentService이름이 같기 때문에 구분이 필요합니다.
this.paymentService = Heap의 이 객체 안에 있는 필드
paymentService (오른쪽, this 없음) = 생성자에 들어온 매개변수

만약 이름을 다르게 했다면 this 없어도 됩니다: this.ps = paymentService; 이렇게도 가능.
this의 3가지 사용 상황
// ─── 상황 1: 필드 이름 충돌 해결 (DI에서 가장 많이 씀) ───
public OrderService(PaymentService paymentService) {
    this.paymentService = paymentService;
    // this.paymentService → Heap의 이 객체의 필드
    // paymentService      → 매개변수 (Stack의 지역변수)
}

// ─── 상황 2: 메서드 안에서 필드에 접근 ───
public void order() {
    this.paymentService.pay();
    // this 없어도 됨 (이름 충돌이 없으므로)
    // paymentService.pay(); 와 완전히 동일
    // this는 "이 객체의" 를 명시적으로 표현하고 싶을 때 붙임
}

// ─── 상황 3: 다른 생성자 호출 (this()) ───
public OrderService() {
    this(new DefaultPaymentService());
    // this() = 같은 클래스의 다른 생성자 호출
    // DI 환경에서는 잘 사용하지 않음
}
📌 this = Heap에 있는 이 객체의 주소 — 정확한 이해

this는 사실 JVM이 자동으로 관리하는 숨겨진 참조입니다.
order()가 실행될 때 Stack 프레임에 암묵적으로 this(현재 객체 주소)가 함께 들어있습니다.

그래서 paymentService.pay()를 호출할 때 → this를 통해 Heap의 OrderService 객체를 찾고 → 그 안의 paymentService 필드(주소 0x1A2B)를 읽고 → 0x1A2B의 PaymentService 객체의 pay()를 호출하는 것입니다.

final
final — 한 번 할당하면 JVM이 변경을 막는다
1
컴파일 단계
final 필드는 반드시 생성자에서 초기화해야 함 — 안 하면 컴파일 에러
// ✅ 정상 — 생성자에서 초기화
public class OrderService {
    private final PaymentService paymentService;

    public OrderService(PaymentService ps) {
        this.paymentService = ps; // ← 생성자에서 한 번 초기화 ✅
    }
}

// ✅ 정상 — 선언 시 바로 초기화
private final String NAME = "OrderService";

// ❌ 컴파일 에러 — final인데 초기화 안 함
public class OrderService {
    private final PaymentService paymentService; // ← 초기화 없음
    // 생성자도 없음 → 컴파일 에러: variable might not have been initialized
}

// ❌ 컴파일 에러 — final인데 나중에 바꾸려 함
public void changePayment(PaymentService newPs) {
    this.paymentService = newPs; // ← 컴파일 에러: cannot assign a value to final variable
}
2
왜 DI에서 final을 쓰나
안전성 3가지 — 불변성, 누락 방지, 테스트
① 불변성 보장: OrderService가 살아있는 동안 PaymentService가 절대 바뀌지 않음. 버그 방지.

② 누락 방지: final이면 생성자에서 반드시 초기화해야 함 → 의존성 빠뜨리면 컴파일 에러 → 런타임 전에 발견.

③ 테스트 용이: 생성자로만 넣을 수 있으니 테스트할 때 new OrderService(mockPaymentService)처럼 가짜 객체 주입이 명확함.
3
final이 "한 번만 사용"이 아니라 "한 번만 할당"이다
final 필드의 메서드는 무한히 호출 가능
public void order() {
    paymentService.pay(); // ✅ 몇 번이든 호출 가능
}
// final이 막는 건 "다른 PaymentService로 교체"하는 것
// final이 막지 않는 건 "같은 PaymentService의 메서드를 여러 번 호출"하는 것
//
// 즉, final은 "참조(주소)의 불변"을 보장
// paymentService 변수가 가리키는 주소(0x1A2B)는 절대 못 바꿈
// 하지만 0x1A2B에 있는 PaymentService 객체의 메서드는 자유롭게 호출 가능
Default Constructor
기본 생성자 — 눈에 안 보이지만 있는 것
기본 생성자 규칙 — 완전판
// ─── 케이스 1: 생성자 없음 → 컴파일러가 기본 생성자 자동 추가 ───
@Service
public class PaymentService {
    public void pay() { ... }
    // 생성자 없음 → 컴파일러가 자동으로 추가:
    // public PaymentService() { }  ← 이게 숨어있음
}
// 그래서 스프링이 new PaymentService() 호출 가능

// ─── 케이스 2: 매개변수 있는 생성자 정의 → 기본 생성자 자동 추가 안 됨 ───
public class OrderService {
    private final PaymentService ps;

    public OrderService(PaymentService ps) { // ← 생성자 명시
        this.ps = ps;
    }
    // 기본 생성자 자동 추가 안 됨!
    // 그래서 new OrderService() → 컴파일 에러
    // 반드시 new OrderService(paymentServiceBean)으로만 생성 가능
}
// 스프링이 이걸 보고: "OrderService 만들려면 PaymentService 필요하구나"
// → PaymentService Bean 먼저 만들고 → OrderService 생성자에 넣어서 생성

// ─── 케이스 3: @NoArgsConstructor 추가 (Lombok) ───
@NoArgsConstructor // Lombok: 기본 생성자 추가해줌
@AllArgsConstructor // Lombok: 모든 필드를 받는 생성자 추가
public class BoardDto {
    private String title;
    private String content;
    // Lombok이 자동 생성:
    // public BoardDto() { }
    // public BoardDto(String title, String content) { this.title = title; ... }
}
Back to Big Picture
전체 흐름 재확인 — 07b에서 배운 것 전체 흐름에 연결
① 파일 읽기 → Method Area에 클래스/필드/생성자 구조 저장
② 스프링 Container → new 대신 실행 → Heap에 객체 생성
③ 생성자 실행 → Stack 프레임 → this.field = param → Heap 필드 연결
④ final 확정 → 이후 Heap 객체 살아있는 한 불변
⑤ 메서드 호출 → Stack 새 프레임 → this로 Heap 접근 → 실행
📌 07b 핵심 요약
  • 클래스: Method Area에 설계도 저장. 실행 아님.
  • 필드: 구조는 Method Area, 실제 값은 Heap의 객체 안.
  • 생성자: new 실행 시 Stack에 프레임 생김 → 매개변수(DI로 받은 객체 주소) → Heap 필드에 저장 → 프레임 pop.
  • final: 생성자 실행 시 딱 한 번 할당. 이후 Heap에 있는 필드값(주소) 절대 변경 불가. 컴파일러가 강제.
  • this: 현재 실행 중인 Heap 객체의 주소. 필드 이름 충돌 해결에 주로 사용.
DI 조감도📚 전체 맵DI IoC·Container