@Service
public class OrderService {
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
앱을 실행하면 PaymentService가 주입된다. new도 없고, 어디서 가져오라는 설정도 없는데. Spring이 알아서 찾아온다.
그러면 Spring은 어떻게 PaymentService를 찾는 걸까. 프로젝트 안에 클래스가 수백 개 있다면 전부 뒤지는 건가. 라이브러리 jar 파일 안까지? 앱 시작 시간이 너무 길어지지 않을까.
@SpringBootApplication 안에는 @ComponentScan이 포함되어 있다. 이게 탐색 범위를 결정한다.
기본값은 @SpringBootApplication이 붙은 클래스가 있는 패키지와 그 하위 패키지 전체다. com.example 패키지에 main 클래스가 있으면 com.example.service, com.example.repository 같은 하위 패키지는 전부 탐색하고, 그 바깥은 보지 않는다. 라이브러리 jar 파일도 대상이 아니다.
그래서 main 클래스를 최상위 패키지에 두는 게 관례다. 하위 패키지 전체를 자동으로 탐색 대상에 포함시키려면 main 클래스가 그 위에 있어야 하기 때문이다.
탐색 범위 안에 있다고 해서 모든 클래스가 빈으로 등록되진 않는다. Spring이 "이 클래스를 빈으로 만들어라"는 표식을 찾는 것이기 때문이다. 그 표식이 @Component다. @Service, @Repository, @Controller는 전부 @Component를 포함하는 어노테이션이다.
표식이 없는 클래스는 탐색 범위 안에 있어도 그냥 지나친다. 그러면 외부 라이브러리 클래스처럼 표식을 붙일 수 없는 경우는?
외부 라이브러리 클래스에는 @Component를 붙일 수 없다. 코드를 수정할 수 없으니까. 이런 경우 @Configuration 클래스에 @Bean 메서드로 직접 등록한다.
@Configuration
public class PaymentConfig {
@Bean
public PaymentService kakaoPayService() {
return new KakaoPayService(); // 직접 생성해서 빈으로 등록
}
}
@Bean 메서드가 반환하는 객체를 Spring이 빈으로 관리한다. 초기화 로직이 복잡하거나 생성 과정을 세밀하게 제어해야 할 때도 이 방식을 쓴다.
@Component를 발견했다고 바로 new로 객체를 만들지 않는다. 먼저 BeanDefinition이라는 설계도를 등록해둔다.
@Service
public class OrderService {
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
ComponentScan이 OrderService를 먼저 발견했다고 가정해보자. 생성자에 PaymentService가 필요한데, PaymentService가 아직 등록되어 있지 않다면? 발견하는 즉시 만들려 하면 주입할 대상이 없어서 실패한다.
그래서 발견할 때마다 설계도(BeanDefinition)만 등록해두고, 전체 설계도가 모이면 의존관계를 파악해서 올바른 순서로 생성한다. PaymentService 먼저, OrderService 나중에.
설계도가 모이고 순서가 결정되면, 실제로 객체를 만들어야 한다. Spring은 Reflection을 사용한다.
// Spring 내부에서 이런 방식으로 인스턴스를 생성한다
Class<?> clazz = Class.forName("com.example.PaymentService"); // 클래스 이름으로 로드
clazz.getDeclaredConstructor().newInstance(); // 생성자를 꺼내 인스턴스 생성
Reflection은 클래스 이름(문자열)만 알면 런타임에 해당 클래스를 로드하고 인스턴스를 만들 수 있다. ComponentScan이 클래스를 발견할 때 클래스 이름을 BeanDefinition에 저장해두고, 인스턴스화 시점에 Reflection으로 꺼내 쓰는 것이다.
생성된 싱글턴 빈은 ApplicationContext에 보관되고, 이후 요청이 오면 저장된 인스턴스를 꺼내 반환한다.
지금까지 설명한 전체 흐름을 정리하면 이렇다.
PaymentService 인터페이스를 구현한 KakaoPayService, NaverPayService가 둘 다 빈으로 등록되어 있으면 Spring이 어느 쪽을 주입할지 모른다. 이 경우 아래 순서로 결정한다.
우선 @Primary가 붙은 빈을 찾는다. 있으면 그걸 주입한다. 없으면 주입받는 변수명과 빈 이름이 일치하는 걸 찾는다. 변수명이 kakaoPayService라면 그 이름의 빈을 주입한다. 그것도 없으면 NoUniqueBeanDefinitionException이 발생한다.
특정 구현체를 명시적으로 지정하고 싶을 땐 @Qualifier("kakaoPayService")를 쓴다. @Primary보다 우선순위가 높다.
Spring의 기본 스코프는 Singleton이다. ApplicationContext당 인스턴스 하나를 공유한다. 반면 @Scope("prototype")을 붙이면 빈을 요청할 때마다 새 인스턴스를 만든다. 요청마다 다른 상태를 담아야 할 때 쓴다.
그런데 Singleton 빈 안에 Prototype 빈을 주입받으면 의도대로 동작하지 않는다.
@Component
@Scope("prototype") // 매번 새 인스턴스 의도
public class ReportGenerator {
private String userId; // 요청마다 다른 유저 데이터
public void setUserId(String userId) { this.userId = userId; }
}
@Service // Singleton
public class ReportService {
private final ReportGenerator reportGenerator; // 시작 시점에 한 번만 주입됨
public ReportService(ReportGenerator reportGenerator) {
this.reportGenerator = reportGenerator;
}
}
Singleton인 ReportService는 앱 시작 시 한 번만 생성된다. 그 때 ReportGenerator도 한 번 주입되고 필드에 고정된다. 이후 ReportService가 100번 호출돼도 같은 ReportGenerator 인스턴스를 재사용한다.
멀티스레드 환경에서 userId에 A 유저 데이터를 넣었는데 다른 스레드가 B 유저 데이터로 덮어쓰면, A 유저 요청에서 B 유저 보고서가 나오는 상황이 생긴다. @Scope("prototype")을 붙였는데도.
이 문제를 피하려면 Singleton 안에 상태를 가진 Prototype 빈을 주입받는 설계 자체를 피하는 게 가장 좋다. 꼭 필요하다면 ObjectProvider<T>를 사용한다.
@Service
public class ReportService {
private final ObjectProvider<ReportGenerator> reportGeneratorProvider;
public ReportService(ObjectProvider<ReportGenerator> reportGeneratorProvider) {
this.reportGeneratorProvider = reportGeneratorProvider;
}
public void createReport(String userId) {
ReportGenerator generator = reportGeneratorProvider.getObject(); // 호출마다 새 인스턴스
generator.setUserId(userId);
}
}
OrderService가 PaymentService를 필요로 하고, PaymentService가 다시 OrderService를 필요로 하는 상황을 가정해보자. 생성자 주입을 사용하면 앱 시작 자체가 실패한다. A를 만들려면 B가 있어야 하고, B를 만들려면 A가 있어야 하니 둘 다 만들 수 없어서 BeanCurrentlyInCreationException이 발생한다.
필드 주입은 다르다. Spring이 내부적으로 미완성 객체를 먼저 노출해서 순환을 우회하기 때문에 앱은 정상적으로 시작된다. 문제는 해당 기능을 실제로 호출했을 때 에러가 터진다.
개발 중에 발견하면 고치면 되지만, 배포 후 사용자가 특정 기능을 처음 쓰는 순간 에러가 나면 얘기가 달라진다. 생성자 주입을 쓰면 문제가 있을 때 서버 자체가 뜨지 않기 때문에, 잘못된 설계가 운영 환경까지 나가는 걸 막을 수 있다.