클래스 · 필드 · 생성자 · final · this — 코드 한 줄씩 + JVM 메모리 상태 변화 완전 해부
항상 전체 그림부터. 지금 배우는 내용이 전체에서 어느 위치인지 확인하고 들어갑니다.
DI가 일어날 때 스프링이 하는 일은 결국 "클래스를 읽고, 생성자를 호출하고, 필드에 값을 넣는 것"입니다.
클래스·필드·생성자·final을 모르면 DI가 어디서 뭘 하는지 전혀 안 보입니다.
이 파일을 이해하면 07c~07e가 전부 명확하게 보입니다.
가장 많이 헷갈리는 부분. 클래스는 코드가 있어도 실행이 아닙니다.
"있다고 실행되는 게 아니라, 누가 호출해야 실행된다" — 이게 클래스의 핵심입니다.
new PaymentService()를 실행하면 → Heap에 실제 객체 생성.// ───────────────────────────────────────────────────────────────── // [읽기 단계] 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은 딱 하나만 만들어서 재사용합니다.
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 — 상수 풀 | 클래스 로딩 시 | 모든 객체 공유 + 변경 불가 |
생성자는 객체가 태어날 때 단 한 번 실행되는 특별한 메서드입니다. DI에서 가장 중요한 역할을 합니다 — 스프링이 의존 객체를 여기에 넣어주기 때문입니다.
new OrderService(ps) 실행 →paymentService)를 위한 공간도 함께 생김.OrderService(PaymentService paymentService) 실행 시작.paymentService(ps의 주소값) → Stack의 지역변수로 저장.
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이므로 이후 절대 변경 불가 }
paymentService(주소값) 소멸.━━━━━━━━━━━━━ 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는 많이 쓰이지만 정확히 뭔지 모르면 헷갈립니다.
한 줄 정의: 지금 실행 중인 메서드(또는 생성자)가 속한 Heap의 객체 주소입니다.
paymentService이고, 매개변수 이름도 paymentService로 이름이 같기 때문에 구분이 필요합니다.this.paymentService = Heap의 이 객체 안에 있는 필드paymentService (오른쪽, this 없음) = 생성자에 들어온 매개변수this.ps = paymentService; 이렇게도 가능.
// ─── 상황 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는 사실 JVM이 자동으로 관리하는 숨겨진 참조입니다.
order()가 실행될 때 Stack 프레임에 암묵적으로 this(현재 객체 주소)가 함께 들어있습니다.
그래서 paymentService.pay()를 호출할 때 → this를 통해 Heap의 OrderService 객체를 찾고 → 그 안의 paymentService 필드(주소 0x1A2B)를 읽고 → 0x1A2B의 PaymentService 객체의 pay()를 호출하는 것입니다.
// ✅ 정상 — 생성자에서 초기화 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 }
final이면 생성자에서 반드시 초기화해야 함 → 의존성 빠뜨리면 컴파일 에러 → 런타임 전에 발견.new OrderService(mockPaymentService)처럼 가짜 객체 주입이 명확함.
public void order() { paymentService.pay(); // ✅ 몇 번이든 호출 가능 } // final이 막는 건 "다른 PaymentService로 교체"하는 것 // final이 막지 않는 건 "같은 PaymentService의 메서드를 여러 번 호출"하는 것 // // 즉, final은 "참조(주소)의 불변"을 보장 // paymentService 변수가 가리키는 주소(0x1A2B)는 절대 못 바꿈 // 하지만 0x1A2B에 있는 PaymentService 객체의 메서드는 자유롭게 호출 가능
// ─── 케이스 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; ... } }