Spring을 처음할 때 @Bean, @Component, @Configuration이 뭔지 설명하는 글은 많은데 읽어도 이해를 하기 힘들었다. 각 어노테이션이 무엇인지 의미는 알겠는데 왜 등록 방법이 두 가지나 필요한지 설명이 부족했다.
이 글은 NestJS 개발에 익숙한 내가 Spring의 Bean을 이해하기 위해 정리한 것이다. 결론부터 말하자면 NestJS의 DI를 이해하고 있다면 새로 배울 개념은 없다.
(이름만 다를뿐)
Bean은 스프링 컨테이너가 생성하고 관리하는 객체다. NestJS의 Provider와 같은 개념이라고 보면 이해하기 쉽다.
// order.service.ts
@Injectable()
export class OrderService {}
// order.module.ts
@Module({
providers: [OrderService]
})
export class OrderModule {}
NestJS에서는 providers에 등록하지 않으면 주입받을 수 없다. Spring도 동일하게, 컨테이너에 등록된 객체만 주입받을 수 있다.
만약 등록하지 않으면 아래와 같은 에러 메시지가 발생한다.
Nest can't resolve dependencies of ...NoSuchBeanDefinitionException등록하는 방법은 클래스를 스캔하는 방법과 메서드의 반환값을 등록하는 방법이 존재한다.
@Service, @Component, @Repository, @RestController를 클래스 위에 붙이면 된다.
@Service
class OrderService(
private val orderRepository: OrderRepository
) {}
어플리케이션이 실행될 때 스프링이 패키지를 훑다가 이 어노테이션을 발견하면 인스턴스를 만들어 컨테이너에 담는다.
네 가지의 어노테이션은 하는 일은 같지만 의미가 다르다. @Service는 @Component에 서비스 계층이라는 의미를 덧입한 것뿐이다. (메타 어노테이션)
@Bean은 메서드가 반환한 객체를 컨테이너에 등록한다.
@Configuration
class PasswordConfig {
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}
어플리케이션이 실행될 때 스프링이 이 메서드를 한 번 호출하고, 반환된 객체를 컨테이너에 담아둔다.
이 부분이 내가 좀 헷갈려했던 부분이다. 세 가지의 경우로 본다면 명확해질 것 같다.
코드를 고칠 수 있기 때문에 어노테이션을 붙이는 쪽이 간단하다. 파일이 늘지 않고 클래스만 보면 빈인지 알 수 있다.
코드를 고칠 수 없기 때문에 어노테이션을 붙일 방법이 없다. 대표적으로 BCryptPasswordEncoder가 있는데, jar 안에 있는 클래스에 @Component를 붙일 수 없다.
이때, @Bean 메서드로 직접 만들어서 사용하겠다고 표현한다.
@Component는 기본 생성자 호출하는 방식이라 인자를 전달할 수 없다. 반면 @Bean은 메서드 본문에서 무엇이든 할 수 있다.
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder(10)
NestJS도 같은 자링에 커스텀 Provieer가 존재한다.
{
provide: 'PASSWORD_ENCODER',
useFactory: () => new BcryptEncoder(10),
}
useFactory와 @Bean이 정확히 대응된다. 두 프레임워크 모두 객체를 만드는 과정 자체가 필요한 경우를 위한 장치를 따로 두고 있는 셈이다.
@Configuration의 역할@Bean 메서드를 담는 그릇이다. 스프링의 이 어노테이션이 붙은 클래스를 찾아 @Bean 메서드를 실행한다.
NestJS의 @Module 데코레이터와 비슷하지만 모듈 경계를 나눌 수 있는 기능은 없다.
NestJS는 모듈마다 자기 Provider 스코프를 갖고 exports로 공개 범위를 바꾸지만 스프링은 기본적으로 하나의 컨테이너에서 모든 Bean이 평평하게 들어간다.
어느 @Configuration에서 등록했든 어플리케이션 전체에서 주입받을 수 있다.
컨테이너에 등록된 Bean은 생성자 파라미터로 주입받을 수 있다.
@Service
class OrderSrevice {
private val orderRepository: OrderRepository,
private val passwordEncoder: PasswordEncoder,
}
타입만 적어주면 스프링 컨테이너에서 해당 Bean을 찾아서 넣어준다. 이 부분은 NestJS와 동일하다.
다만 주입 기준이 다르다. Spring은 Type으로 Bean을 찾고, NestJS는 Token으로 Provider를 찾는다.
이 차이는 같은 타입의 빈이 둘 이상일 때 드러난다.
Bean
fun bcryptEncoder(): PasswordEncoder = BCryptPasswordEncoder()
@Bean
fun noOpEncoder(): PasswordEncoder = NoOpPasswordEncoder.getInstance()
passwordEncoder 타입 Bean이 두 개이므로 Spring은 어느 것을 넣어야 할지 판단할 수 없고, 주입 시점에 실패한다. 이때는 @Qualifier로 이름을 정해줘야한다.
@Service
class UserService(
@Qualifier("bcryptEncoder") private val passwordEncoder: PasswordEncoder,
)
NestJS는 provide: ''PASSWORD_ENCODER'처럼 토큰이 명시적이라 애초에 이 문제가 생기지 않는다. 대신 주입을 받는 쪽에서 @Inject('PASSWORD_ENCODER')를 매번 작성해줘야 한다.
편의와 명시성의 트레이드오프였고, 어느 쪽이 더 낫다기 보다 방식이 다르다.
| NestJS | Spring | |
|---|---|---|
| 개념 | Provider | Bean |
| 내 클래스 | @Injectable() + providers 등록 | @Service / @Component |
| 외부 클래스, 생성 로직 | useFactory | @Bean 메서드 |
| 담는 곳 | @Module() | @Configuration |
| 주입 방식 | 생성자 파라미터 | 생성자 파라미터 |
| 기본 스코프 | 싱글톤 | 싱글톤 |
실무에서 @Bean을 사용하는 상황이 그렇게 많지 않은 건 알고 있다. PasswordEncoder, ObjectMapper커스터마이징, RestClient, SecurityFilterChain 정도가 대표적이고, 나머지는 @Service에서 마무리가 된다.
Spring Bean이 너무 어렵게 느껴졌던 이유는 개념이 새로워서가 아니라 어노테이션 이름이 낯설고, 왜 두 가지 방법이 존재하는지 이해를 못해서였다.
외부 클래스이거나 생성 로직이 필요하다면 @Bean이라는 기준 하나를 잡고나니 정리가 됐다.
이미 다른 프레임워크의 DI를 써봤다면 개념을 처음 배우기보다 대응 관곌를 먼저 잡는 편이 빠르다는 것도 알게됐다.