@PostConstruct라는 어노테이션을 학습하다가. 도대체 왜 이 어노테이션을 사용하는지 파보다 보니 스프링이 실제로 빈을 언제 만들고 초기화 하는지를 학습하게 되었다. 그리고 @Autowired로 필드 주입을 안하고, 생성자를 사용하거나 롬복(lombok) 라이브러리에서 제공하는 @RequiredArgsConstructor를 사용하는지 이해해 보자.
1. 실제 스프링 동작 순서는 어떻게 될까?
1. 빈 인스턴스 생성 (생성자 실행)
2. 의존성 주입 (@Autowired 또는 생성자 주입 포함)
3. @PostConstruct 실행
4. 빈 사용 가능 상태

OrderService(주문)을 할때, PaymentService(결제)가 필요하므로 OrderService에 PaymentService를 주입한다고 예시를 들어보자.
@Component
public class PaymentService {
public PaymentService() {
System.out.println("1️⃣ PaymentService 생성자 실행");
}
public void pay() {
System.out.println("PaymentService.pay() 호출");
}
}
가장 먼저 생성되는 의존성 빈으로, 다른 빈의 생성자에서 필요하기 때문에 먼저 실행된다.
@Component
public class OrderService {
private final PaymentService paymentService;
// OrderService 생성자 실행
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
System.out.println("2️⃣ OrderService 생성자 실행");
}
// 의존성 주입 후 실행
@PostConstruct
public void init() {
System.out.println("3️⃣ @PostConstruct 실행");
paymentService.pay();
}
}
PaymentService 객체는 스프링 IoC 컨테이너에 의해 미리 생성되지만, 실제로 OrderService의 생성자가 실행될 때 주입된다. 이것이 생성자 주입이다. 아래에서 @Autowired를 통한 필드 주입을 따로 설명한다.
이때 @PostConstruct는 모든 의존성 주입이 완료된 이후, 해당 빈을 실제로 사용하기 전에 필요한 초기화 작업을 수행하기 위한 메서드에 사용된다. 위 예제에서는 init 메서드에 @PostConstruct를 적용했으며, 이는 생성자를 통해 객체가 생성되고 의존성이 주입된 이후에 실행된다.
여기서 “그렇다면 생성자에서 초기화하면 되지 않을까?”라는 질문이 생길 수 있다. 초기화 자체는 생성자에서 수행해도 동작은 하지만, 생성자 시점에는 스프링 컨테이너의 전체 초기화가 완료되지 않았을 수 있다. 이로 인해 외부 리소스 접근이나 의존성 사용을 포함한 초기화 로직을 생성자에 두면 예기치 않은 문제를 유발할 수 있다.
따라서 생성자는 객체 생성과 필수 의존성 확보라는 최소 역할에 집중하고, 의존성을 실제로 활용하는 초기화 로직은 @PostConstruct에 위임하는 것이 안전하고 명확한 설계 방식이다.
1️⃣ PaymentService 생성자 실행
2️⃣ OrderService 생성자 실행
3️⃣ @PostConstruct 실행
PaymentService.pay() 호출
위를 실행하면 실행 결과가 다음과 같다.
@Component
public class OrderService {
@Autowired
private PaymentService paymentService;
// 기본 생성자 실행
public OrderService() {
System.out.println("2️⃣ OrderService 생성자 실행");
// ⚠️ 이 시점에는 paymentService == null
System.out.println(paymentService);
}
// 의존성 주입 완료 후 실행
@PostConstruct
public void init() {
System.out.println("3️⃣ @PostConstruct 실행");
paymentService.pay();
}
}
@Autowired를 이용해 PaymentService를 주입하고 콘솔에 찍어보면
1️⃣ DeliveryService 생성자 실행
2️⃣ OrderService 생성자 실행
null
3️⃣ @PostConstruct 실행
PaymentService.pay() 호출
paymentSerive의 값이 null이 찍힌다. 그리고 PaymentService.pay( )는 호출된다. ⭐⭐⭐ 이 이유는 스프링이 객체를 만드는 방식 차이 때문에 생긴다. @Autowired 필드 주입은 객체가 생성된 이후에 실행되므로, 생성자 시점에서는 해당 필드가 null이다.그렇다고 의존성 객체가 생성되지 않은것은 아니다. 의존성 객체는 이미 스프링 컨테이너 안에 생성되어 있지만, 그 객체를 사용하는 쪽인 OrderService 쪽의 생성자가 실행될 때는 아직 필드에 주입되지 않은 상태이다. 따라서 paymentService의 값이 null이 찍히게 된다.
[PaymentService] ← 이미 생성됨 (컨테이너에 있음)
↓
new OrderService() ← 생성자 실행
(paymentService 필드 = null)
↓
@Autowired 필드 주입
(paymentService 필드에 연결)
↓
@PostConstruct 실행
의존성 객체는 준비가 되었지만, 연결은 생성자 이후에 된다. 따라서 가끔가다 어떤 글을 보면 @Autowired를 이용한 의존성 주입은 거의 사용을 안하는 추세라고 말하는 것이다. 우리도 의존성 주입을 할 때, 되도록 생성자를 이용해서 주입하는 것이 더 나은 방향이라고 말할 수 있다. 스프링 공식 문서에서도 "필수 의존성은 생성자 주입을 사용하라"라고 말한다.
따라서 의존성 주입으로 객체를 사용할 때는 결론은 아래와 같다.
1. 생성자를 사용한다.
2. 되도록 생성자에는 객체 생성만 담당한다.
3. 나머지 초기화는 @PostConstruct 어노테이션을 사용하자.

암튼 그러하다. 모르면 외우도록 하자.
'Spring' 카테고리의 다른 글
| bad SQL grammar [DELETE FROM SPRING_SESSION WHERE EXPIRY_TIME < ?] 에러 (0) | 2025.12.29 |
|---|---|
| 스프링 부트 단독으로 실행 가능한 파일로 만들기 (0) | 2025.12.26 |
| com.example.demo 패키지 구조 왜 이렇게 만들어 질까? (0) | 2025.12.12 |
| [Spring Security] BCryptPasswordEncoder 암호화된 비밀번호 검증 어떻게 할까? (0) | 2024.11.21 |
| [Spring] 프론트엔드에서 값 입력 누락 시, 백엔드 데이터 JSON 처리 어떻게 될까? + null 값에 .equals() 사용시 에러(24/11/09 수정) (0) | 2024.11.05 |