생성자 주입 · 필드 주입 · Setter 주입 — 각각 JVM 메모리에서 무슨 일이 일어나는가 · @RequiredArgsConstructor 원리 · 순환참조 문제와 해결
07c에서 스프링이 Bean을 어떻게 만드는지 봤습니다.
이제 "만든 Bean을 어떻게 넣어주는가" — 즉 주입 방식 3가지를 비교합니다.
각 방식이 JVM 메모리에서 어떻게 다르게 동작하는지, 왜 생성자 주입이 권장되는지, Lombok @RequiredArgsConstructor가 뭘 하는지, 순환참조가 왜 생기고 어떻게 막는지 전부 다룹니다.
@Service public class OrderService { private final PaymentService paymentService; // [읽기 단계] "paymentService 필드가 있고, final이다" 파악 // [이 시점 Heap] 아직 OrderService 객체 없음, 필드도 없음 public OrderService(PaymentService paymentService) { // ↑ [읽기 단계] "PaymentService 없이는 만들 수 없다"는 생성자 정보 파악 // 스프링: "OrderService 만들려면 PaymentService Bean 먼저 있어야 함" // // [스프링 Bean 생성 단계 — 실제 실행] // Step 1: PaymentService Bean 이미 Heap에 있음 (0x1A2B) // Step 2: new OrderService(paymentServiceBean) 호출 // Step 3: Stack에 생성자 프레임 push // 매개변수 paymentService = 0x1A2B (Stack 지역변수) // Step 4: Heap에 OrderService 객체 공간 확보 (0x3C4D) // → paymentService 필드 공간 포함 (아직 null) this.paymentService = paymentService; // Step 5: this(0x3C4D)의 paymentService 필드 = Stack의 0x1A2B // → Heap 0x3C4D 안의 필드가 Heap 0x1A2B를 가리킴 // → DI 완료! // → final이라 이 순간 이후 절대 변경 불가 (컴파일러 보장) // Step 6: 생성자 프레임 pop → Stack 지역변수(0x1A2B 참조) 소멸 // 단, Heap의 OrderService.paymentService 필드는 0x1A2B 그대로 유지 } public void order() { // [요청 처리 시 — 매 호출마다] // Step 7: Stack에 order() 프레임 push paymentService.pay(); // Step 8: this(현재 OrderService Bean 0x3C4D).paymentService = 0x1A2B // → Heap의 PaymentService Bean.pay() 실행 // → Stack에 pay() 프레임 push → 실행 → pop // Step 9: order() 프레임 pop } } // ═══ 최종 Heap 상태 ═══════════════════════════════════════ // 0x1A2B: PaymentService Bean (서버 살아있는 한 유지) // 0x3C4D: OrderService Bean // ↳ paymentService 필드 = 0x1A2B (영구 연결, 변경 불가) // ══════════════════════════════════════════════════════════
① Heap 연결이 생성 시점에 완성: 객체가 Heap에 생기는 순간 모든 의존성이 연결됨. "반쪽짜리 객체"가 없음.
② final + 컴파일러 강제: 생성자에서 초기화 안 하면 컴파일 에러. 런타임 전에 발견.
③ 테스트 시 Heap 직접 제어: new OrderService(mockPaymentService)로 가짜 객체를 직접 Heap에 넣어서 테스트 가능. 스프링 없어도 됨.
@Service public class OrderService { @Autowired // ↑ 스프링한테 "이 필드에 맞는 Bean 알아서 넣어줘" 표시 private PaymentService paymentService; // ↑ final 없음 → 나중에 다른 값으로 바뀔 수 있음 // 생성자가 없음 → 컴파일러가 기본 생성자 자동 추가: // public OrderService() { } ← 매개변수 없음 // 스프링 실행 순서: // Step 1: new OrderService() → Heap에 객체 생성 // ↑ 이 시점에 paymentService = null (아직 아무것도 없음) // ↑ "반쪽짜리 객체" 상태! // Step 2: 생성 완료 후, 스프링이 리플렉션으로 필드에 강제 주입 // Reflection: private 필드도 강제로 접근 가능한 Java 기능 // orderServiceBean.paymentService = paymentServiceBean; (강제 할당) // 이제서야 paymentService = 0x1A2B public void order() { paymentService.pay(); // 만약 Step 2가 일어나기 전에 order()가 호출되면? // paymentService = null → NullPointerException! // (스프링 정상 환경에서는 거의 안 일어나지만 테스트 환경에서는 발생) } } // ─── 문제 1: 테스트 ─── // 스프링 없이 순수 Java 테스트: OrderService os = new OrderService(); // paymentService = null! // @Autowired는 스프링이 처리. 스프링 없으면 null 그대로. // os.order() → NullPointerException 발생 // ─── 문제 2: final 불가 ─── // @Autowired private final PaymentService paymentService; // → 컴파일 에러! final은 선언 시 또는 생성자에서만 초기화 가능 // → @Autowired는 생성자 이후에 주입하므로 final 불가
반쪽짜리 객체 문제: 생성자 없이 new OrderService()로 Heap에 객체 생성 → paymentService = null인 상태로 존재 → 그 후 스프링이 리플렉션으로 주입.
즉, 아주 잠깐이지만 null인 채로 Heap에 객체가 존재하는 순간이 있음.
테스트 불가: 리플렉션 주입은 스프링이 처리. 스프링 없는 단위 테스트에서 paymentService = null → NPE.
리플렉션 오버헤드: private 필드를 강제로 접근하는 리플렉션은 일반 메서드 호출보다 느림.
@Service public class NotificationService { private EmailService emailService; // final 없음 @Autowired @Required // 필수 주입 표시 (선택) public void setEmailService(EmailService emailService) { this.emailService = emailService; // ↑ Setter 메서드. 스프링이 이 메서드를 호출해서 주입. // // 스프링 실행 순서: // Step 1: new NotificationService() → Heap 객체 생성 (emailService = null) // Step 2: 스프링이 setEmailService(emailServiceBean) 호출 // → Stack에 setEmailService() 프레임 push // → this.emailService = emailServiceBean 실행 // → 프레임 pop // // ⚠️ 문제: setEmailService()를 호출하지 않으면 // emailService = null → 나중에 사용 시 NPE } public void sendNotification() { // emailService가 null이면 여기서 NPE emailService.send("알림!"); } } // Setter 주입이 유용한 경우: // - 선택적 의존성: emailService가 없어도 동작해야 할 때 // public void sendNotification() { // if (emailService != null) emailService.send("알림!"); // } // - 순환참조 해결이 필요한 레거시 코드 (거의 없음)
| 관점 | 생성자 주입 ✅ | 필드 주입 ⚠️ | Setter 주입 △ |
|---|---|---|---|
| 객체 생성 시 Heap 상태 | 완성 상태 생성자 실행 중 즉시 연결 |
반쪽 상태 먼저 생성 후 주입 |
반쪽 상태 먼저 생성 후 주입 |
| final 사용 | ✅ 가능 생성자에서 초기화 |
❌ 불가 생성 후 주입이라 불가 |
❌ 불가 생성 후 주입이라 불가 |
| 주입 시점 | 객체 생성 중 (동시에) | 객체 생성 완료 후 (리플렉션) | 객체 생성 완료 후 (메서드 호출) |
| 누락 감지 | 컴파일 에러 | 런타임 NPE | 런타임 NPE |
| 테스트 | new 생성자로 직접 주입 | 스프링 없으면 null | Setter 호출로 주입 가능 |
| 순환참조 감지 시점 | 기동 시 즉시 감지 | 런타임 실행 시 감지 | 런타임 실행 시 감지 |
생성자 주입이 권장되지만 의존성이 많으면 생성자 코드가 길어집니다.
실무에서는 Lombok의 @RequiredArgsConstructor로 이를 해결합니다.
━━━━━━━━ Before (Lombok 없이 — 직접 생성자 작성) ━━━━━━━━ @Service public class BoardService { private final BoardRepository boardRepository; private final UserRepository userRepository; private final JwtUtil jwtUtil; // 의존성 3개 → 생성자 직접 써야 함 public BoardService( BoardRepository boardRepository, UserRepository userRepository, JwtUtil jwtUtil) { this.boardRepository = boardRepository; this.userRepository = userRepository; this.jwtUtil = jwtUtil; } } ━━━━━━━━ After (Lombok 사용 — 실무 표준) ━━━━━━━━━━━━━ @Service @RequiredArgsConstructor // ↑ Lombok: final 필드를 모두 받는 생성자를 컴파일 시 자동 생성 // → 위의 생성자 코드를 자동으로 만들어줌 // → @Autowired 없어도 됨 (생성자가 하나뿐이면 스프링이 자동 인식) public class BoardService { private final BoardRepository boardRepository; private final UserRepository userRepository; private final JwtUtil jwtUtil; // 이게 전부. 생성자 코드 없음. } ━━━━━━━━ @RequiredArgsConstructor 동작 원리 ━━━━━━━━━━ // 컴파일 시 Lombok이 자동으로 아래 코드 생성 (보이지 않지만 .class에 포함) public BoardService( BoardRepository boardRepository, UserRepository userRepository, JwtUtil jwtUtil) { this.boardRepository = boardRepository; this.userRepository = userRepository; this.jwtUtil = jwtUtil; } // 규칙: // - final 필드 = 반드시 포함 (Required) // - final 아닌 필드 = 제외 // - @NonNull 붙은 필드 = 포함 + null 체크 코드 추가 // // JVM 메모리 관점: 완전히 동일 // 생성자 주입을 직접 쓴 것과 동일하게 동작 // Heap 연결, final 보장, Stack 프레임 생성/pop 모두 동일
1차~3차 프로젝트에서 @Service @RequiredArgsConstructor가 붙은 Service 클래스들은 전부 이 패턴입니다.
생성자 코드가 보이지 않아도 컴파일 시 자동 생성된 생성자가 있고, 스프링이 그 생성자로 DI를 처리합니다.
@Controller @RequiredArgsConstructor도 동일. 자세한 건 07e에서 실제 코드로 확인합니다.
A가 B를 필요로 하고, B가 A를 필요로 하는 상황. DI에서 가장 골치 아픈 문제입니다. 왜 생기고, 생성자 주입이 어떻게 이를 조기에 발견하는지 봅니다.
@Service public class ServiceA { private final ServiceB serviceB; // ← ServiceB 필요 public ServiceA(ServiceB b) { this.serviceB = b; } } @Service public class ServiceB { private final ServiceA serviceA; // ← ServiceA 필요 public ServiceB(ServiceA a) { this.serviceA = a; } } // 스프링 Container 입장: // ServiceA 만들려면 → ServiceB 필요 // ServiceB 만들려면 → ServiceA 필요 // ServiceA 만들려면 → ServiceB 필요 // → 무한 루프! 누구도 먼저 만들 수 없음
@Service public class ServiceA { private final ServiceB serviceB; public ServiceA(@Lazy ServiceB b) { // @Lazy: "ServiceB 지금 당장 만들지 말고 // 처음 사용할 때 만들어줘" // → 순환 고리를 끊음 // → ServiceA 먼저 완성 → 나중에 ServiceB 주입 this.serviceB = b; } }
생성자 주입에서 OrderService를 Heap에 만들려면 생성자를 실행해야 함 →
생성자 실행 전에 PaymentService Bean이 Heap에 있어야 함 →
PaymentService Heap에 없으면 → 생성자 실행 불가 → 기동 실패.
즉, Heap에 올리는 과정 자체가 의존성 검증이 됩니다.
순환참조도 이 과정에서 "닭이 먼저냐 달걀이 먼저냐" 문제로 즉시 감지됩니다.