Spring Core Technolohies 1. IoC Container (3) Bean Scope와 Component Scanning

#Spring#IoC#DI#Bean

2023-05-21 13:24

대표 이미지

스프링 공식 Document를 기반으로 스프링 코어 기술들에 대해 정리하는 시리즈물로써, IoC, DI, AOP 세 가지 개념을 중심으로 간단한 예시와 함께 스프링 컨테이너를 이해하고 코드 작성 방법을 익혀봅니다. IoC 마지막 글에서는 스프링에서 제공하는 Bean Scope와 빈을 정의하는 방법에 대해 설명합니다.

개요


마지막 장에서는 앞에서 간단히 설명했던 빈을 본격적으로 사용하기위해 예제 위주로 익혀본다. 빈 스코프에 대한 개념과 종류, 특징에 대해 간단히 알아본 뒤 어노테이션으로 빈을 설정하고 스프링에서 제공하는 컴포넌트 및 클래스패스 스캔을 통한 빈 등록 방법에 대해서 알아본다.

핵심 개념


Scope

  • 생성된 빈 인스턴스가 존재할 수 있는 컨테이너 영역
  • 빈을 생성하고, 소멸시키는 방법을 결정하며 이를 통해 메모리관리와 빈의 생성 시점 등을 세밀하게 제어할 수 있다.
  • 스프링 프레임워크에서 제공하는 기본 스코프에는 singleton, prototype, request, session, application, websocket이 있다.
    • 이 외에도 사용자 정의 스코프를 만들 수 있다.

Lifecycle Callbacks

  • 빈의 생명주기와 관련된 콜백(callback) 메소드를 정의하기 위한 인터페이스를 그룹화한 것.
  • 이 인터페이스를 구현하여, 빈이 생성, 초기화, 소멸될 때 실행될 메소드를 정의할 수 있다.

ApplicationContextAware and BeanNameAware

  • 빈이 애플리케이션 컨텍스트에서 생성될 때, 빈의 이름과 애플리케이션 컨텍스트에 대한 참조를 제공하기 위한 인터페이스를 그룹화한 것.
  • 이 인터페이스를 구현하여, 빈이 생성될 때, 애플리케이션 컨텍스트와 빈의 이름에 대한 정보를 받아서 사용할 수 있다.

Other Aware Interfaces

  • 빈이 다른 객체와 관계를 맺을 때 사용되는 인터페이스를 그룹화한 것.
  • 예를 들어, 빈이 다른 빈을 참조하거나, 스프링의 리소스(resource)를 사용할 때, 해당 인터페이스를 구현하여 사용할 수 있다.

Bean Scope란?


빈 스코프는 스프링 프레임워크에서 관리되는 객체의 생명주기인 인스턴스 생성 및 유지에 관한 규칙과 범위를 정의하는 개념이다. 즉, 스프링은 개발자가 빈 객체를 생성하고 관리할 수 있도록 지원하는데, 이때 빈 객체의 스코프는 객체가 생성되고 존재하는 범위를 의미한다.

예를 들어, 싱글톤 스코프는 하나의 인스턴스만을 생성하고 해당 컨텍스트 내에서 공유한다. 반면에 프로토타입 스코프는 매번 새로운 인스턴스를 생성하여 반환한다. 이외에도 요청(Request), 세션(Session), 어플리케이션(Application) 등이 있다. 이러한 스코프 개념을 통해 스프링은 객체의 범위와 생명주기를 효과적으로 관리하여 애플리케이션의 유연성과 성능을 향상시킬 수 있다.

빈 스코프를 통해 Bean Definition에 따라 생성된 객체에 연결되는 다양한 의존성과 값들을 제어하고 생성된 객체의 범위를 설정할 수 있다. 기본적인 빈 스코프에는 여섯가지가 있다.

이름 생성 범위 설명
singleton Spring IoC 컨테이너 Singleton 스코프를 사용하면 하나의 객체 인스턴스를 모든 빈이 공유한다.
prototype - 매번 요청 시에 새로운 빈 인스턴스를 생성한다.
request 각 HTTP 요청 이 스코프는 Web-aware한 Spring ApplicationContext에서만 사용할 수 있다.
session 각 HTTP 세션 이 스코프는 Web-aware한 Spring ApplicationContext에서만 사용할 수 있다.
application 각 ServletContext 이 스코프는 Web-aware한 Spring ApplicationContext에서만 사용할 수 있다.
websocket 각 WebSocket 연결 이 스코프는 Web-aware한 Spring ApplicationContext에서만 사용할 수 있다.

Web-aware한 Spring ApplicationContext란 일반적인 ApplicationContext과 다르게 웹 어플리케이션에서 사용하는 ServletContext, HttpSession, 그리고 WebSocket 등과 같은 웹 관련된 객체에 대한 지원을 추가적으로 제공하는 컨텍스트를 의미한다.

application 스코프와 websocket 스코프는 각각 ServletContext와 ApplicationContext에 특화된 스코프로써 스프링 프레임워크 이외에 다른 컨테이너에서 지원되지 않을 수 있으니 주의한다.

이제 각 스코프에 대해 알아보도록 하자.

Singleton scope


싱글톤으로 정의된 빈은 스프링 컨테이너 내에 단 하나만 존재하는 공유 인스턴스로 관리된다. 따라서 빈이 처음으로 요청되면 이는 스프링 컨테이너 내부의 캐시에 저장되었다가 해당 BeanDefinition과 일치하는 id를 가진 빈에 대한 모든 요청은 동일한, 캐시된 빈을 반환한다.

주의할 것은 이 때 싱글톤의 개념은 디자인 패턴에서 이야기하는 싱글톤 패턴과는 다르다는 것이다.

디자인 패턴에서의 싱글톤 패턴은 (빈이 아닌) 어떤 클래스가 오로지 하나의 인스턴스만 생성하도록 제한하는 것이며 싱글톤 스코프는 스프링 컨테이너가 관리하는 라이프사이클 하에서 하나의 객체 인스턴스를 생성해 이를 공유하도록 하는 것이다.

그 차이가 모호해 보일 수 있는데, 디자인 패턴과 빈이라는 큰 차이점과 그로 인해서 오는 제어의 주체가 누구인지 생각하면 될 것이다.

Prototype scope


프로토타입 스코프로 정의된 빈은 요청이 있을 때마다 새로운 빈 인스턴스를 생성한다.

다른 빈에 주입되거나 해당 빈이 호출 될 때 컨테이너는 getBean() 메소드를 호출하고 이 때 캐시된 값을 가져오는 싱글톤 빈과 다르게 매번 새로운 객체 인스턴스를 생성한다. 따라서 프로토타입 빈에 종속성을 가지고 있는 여러개의 빈이 있다고 할 때, 그 빈들은 각기 다른 프로토타입 빈을 참조한다.

여러 클라이언트(스코프 빈 등) 간에 공유되지 않는 빈이기 때문에 주로 각 빈의 고유한 상태 정보를 유지해야할 때 사용한다. 예를 들어, HTTP 요청을 처리할 때 사용되는 빈은 일반적으로 Prototype 스코프를 사용하는데 이를 통해 각 요청마다 새로운 인스턴스를 생성하고 유지할 수 있기 때문이다.

특이한 점은 프로토타입 스코프로 정의된 빈은 스프링이 라이프사이클을 전적으로 관리하지 않는다는 사실이다. 호출에 의해 생성된 빈이 요청자에게 전달되면 컨테이너는 해당 빈에 대한 기록을 유지하지 않는다. 따라서 빈의 소멸 또한 스프링에서 자동적으로 관리해주지 않으며 코드 내에서 자원을 직접 정리하고 메모리를 해제해야한다.

이렇게만 보면 new 커멘드를 이용해 개발자가 직접 객체를 생성하는 것과 별 다를 것 없어보이는데 왜 프로토타입 스코프 빈을 사용하는 것일까?   아래 코드를 보며 생각해보자.

public class Order {
    private Customer customer;

    public Order() {
        this.customer = new Customer();
    }

    // ...
}

Order 객체가 생성될 때마다 새로운 Customer 객체를 생성한다.

Order 마다 다른 Customer 객체를 필요로 하므로 정석적으로 작성할 수 있는 코드이다.

크게 문제 없어보이는 간단한 코드지만 유지보수 상의 몇가지 문제가 생길 수 있다. 이를테면 다음과 같은 것들이 있다.

  • Customer 클래스가 변경될 경우, Order 클래스도 함께 변경되어야 한다.
  • Customer 클래스의 생성 방식이 변경될 경우, Order 클래스도 함께 변경되어야 한다.
  • Customer 객체를 사용하는 다른 클래스가 있다면, 해당 클래스도 함께 변경되어야 한다.

즉, 개발자가 직접 Customer를 만들어 Order에게 할당해줘야 하므로 손볼 곳이 자꾸 많아진다. 이전 시리즈에서 설명한 강한 의존성을 가졌을 떄 생기는 문제와 동일하다.

만약 Order를 빈으로 정의하면 어떻게 될까?

@Component
@Scope("prototype")
public class Customer {
    // ...
}

@Component
@Scope("prototype")
public class Order {
    private Customer customer;

    @Autowired
    public Order(Customer customer) {
        this.customer = customer;
    }

    // ...
}

Order는 프로토타입 빈이기 때문에 각기 다른 인스턴스를 여러개 생성할 수 있고, 매번 다른 Customer를 주입받을 것이다.

하지만 스프링 컨테이너에서 빈으로 정의된 Customer 인스턴스를 직접 생성해 주입해 줄 것이기 때문에 사용자는 Customer 클래스에 변경이 있어도 크게 신경 쓸 것이 없어진다.

Request, Session, Application, and WebSocket Scopes


request, session, application, websocket 스코프는 web-aware Spring ApplicationContext 구현체(XmlWebApplicationContext 등)를 사용할 때만 쓸 수 있다.

이 네가지 스코프를 사용하기 위해선 별개의 configuration 설정이 필요하다. 단, Spring Web MVC에서 사용하는 경우에는 DispatcherServlet이 HTTP 요청을 처리할 때 필요한 정보들(ex. HTTP request, response 등)을 expose하기 때문에 추가 설정이 필요하지 않다. DispatcherServlet이 expose한 정보들은 웹 어플리케이션 전반에 걸쳐 접근 가능한 상태가 되기 때문에 이를 활용해 request, session, application, websocket 스코프를 사용할 수 있는 것이다.

Request Scope

@RequestScope
@Component
public class UserInfo {
    // ...
}

RequestScope로 정의된 UserInfo은 각 HTTP 요청마다 새로운 빈 인스턴스가 생성되어 사용되고 요청이 종료될 때 생성됐던 인스턴스는 제거된다.

웹 어플리케이션에서 요청을 보낸 사용자의 정보를 가져오는 기능을 구현할 때 UserInfo를 사용할 수 있을 것이다. 서로 다른 HTTP 요청들은 UserInfo를 공유하지 않고 독립적으로 처리할 수 있다.

Session Scope

@SessionScope
@Component
public class UserPreferences {
    // ...
}

HTTP 세션이 유지되는 동안 UserPreferences는 동일한 인스턴스를 유지한다. 세션이 만료될 때 Session Scope로 설정된 빈들도 함께 제거될 것이다.

웹 어플리케이션 세션에 대한 데이터를 유지하고 관리할 때 유용하게 사용될 수 있다. 예를 들어, 사용자의 로그인 정보나 서비스에서 사용자가 선택한 설정 등을 HTTP 세션에 저장해두면 세션이 유지되는 동안 하나의 빈을 사용하여 여러 HTTP 요청에서 동일한 상태값들을 가져올 수 있다.

Application Scope

@ApplicationScope
@Component
public class AppPreferences {
    // ...
}

ServletContext 레벨에서 유일한 인스턴스를 생성한다. 싱글톤 빈과 유사해보이나 멀티스레드 환경에서 차이점이 발생한다.

HTTP 요청이 들어오면 웹 서버는 요청을 처리하기 위해 새로운 스레드와 서블릿을 생성한다. 따라서 각 스레드에서 Application Scope로 정의된 빈들은 공유되지 않는다. 반면에 Singleton Scope로 정의된 빈들은 모든 스레드에 걸쳐서 공유된다.

스레드풀같은 메커니즘을 사용해 서블릿 인스턴스를 재사용한다해도, 서블릿 스코프로 생성된 빈은 스레드마다 별도의 인스턴스가 생성되어 사용되기 때문에 멀티스레드 환경에서 안전하게 빈을 사용할 수 있다.

WebSocket Scope

@Component
@Scope(scopeName = "websocket", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyWebSocketHandler {
    // ...
}

웹소켓은 HTTP와는 다른 프로토콜을 사용하며, 클라이언트와 서버 간 양방향 통신을 가능하게 합니다. 웹소켓 스코프는 이러한 웹소켓 프로토콜을 사용하는 웹 애플리케이션에서 사용된다.

웹소켓 스코프를 사용하면, 각 웹소켓 세션마다 별도의 빈 인스턴스가 생성되며 웹소켓 세션의 생명주기와 함께 관리되므로, 해당 웹소켓 세션이 종료될 때까지 유지된다.

@WebSocketScope는 Spring의 WebSocket 구현체인 Spring Websocket에서만 사용할 수 있다.

어노테이션 기반 빈 설정하기


XML-based configuration과 Annotation-based configuration 중 뭐가 더 좋을까?

각 방식은 장단점이 있기에 경우에 다르다고 할 수 있다. 명확히 A 보다 B가 낫다고 말 하기가 애매하며 상황에 맞게 적절한 방법을 선택하는 것이 개발자의 몫이다.

스프링은 혼합된 configuration도 모두 수용하기 때문에 두 방식을 섞어서 사용할 수 있다. 예를 들어서 XML 파일에서 빈을 정의하고, 어노테이션을 사용하여 의존성을 주입하는 것도 가능하다.

Spring에서 XML 방식과 Annotation 방식을 섞어서 사용하는 경우, 먼저 XML 파일에서 등록한 빈들은 그대로 사용된다. 그리고나서 Annotation 방식으로 작성된 클래스들이 컴포넌트 스캔을 통해 빈으로 등록된다.

그러나 이렇게 두 가지 방식을 함께 사용하는 경우, 설정이 분산되어 같은 빈을 두 번 등록하는 등의 오류가 발생하는 등 유지보수가 어려울 수 있다. 따라서, 가능하면 한 가지 방식을 사용하는 것이 좋다.

이번 장에서는 가장 많이 사용하는 방식인 Annotation-based configuration을 사용하는 방법에 대해 정리하도록 한다.

@Autowired

@Inject vs. @Autowired @Inject@Autowired는 둘 다 의존성 주입을 위한 어노테이션으로 비슷한 기능을 수행한다.

@Inject는 JSR 330에서 정의한 어노테이션으로, 자바 표준 스펙이므로 스프링 뿐만 아니라 다른 DI 프레임워크에서도 사용될 수 있다. 반면에 @Autowired는 스프링에서 제공하는 어노테이션으로, 스프링 프레임워크에서만 사용할 수 있다.

@Autowired의 경우에는 required 속성을 지정하여 주입할 빈이 반드시 존재해야 하는지 여부를 결정할 수 있다. 반면, @Inject에는 required 속성이 없다. 또한, @Autowired의 경우 생성자, 세터, 필드에 모두 적용할 수 있지만, @Inject는 생성자, 세터에만 적용할 수 있다.

결론적으로, 스프링에서는 @Autowired를 사용하는 것이 일반적이지만, JSR 330을 준수해야 하는 경우 @Inject를 사용할 수도 있다.

@Autowired는 생성자, 세터, 필드에 선언하여 사용한다.

// 생성자에 선언
@Service
public class MyService {
    private final MyRepository myRepository;

    @Autowired
    public MyService(MyRepository myRepository) {
        this.myRepository = myRepository;
    }
}

// 세터에 선언
@Service
public class MyService {
    private MyRepository myRepository;

    @Autowired
    public void setMyRepository(MyRepository myRepository) {
        this.myRepository = myRepository;
    }
}

// 필드에 선언
@Service
public class MyService {
    @Autowired
    private MyRepository myRepository;
}

단, @Autowired를 필드에 사용하는 것은 권장되지 않는데 이유는 다음과 같다.

  1. 테스트하기 어렵다.
    • 일반적으로 테스트에서 필드를 직접 주입하기 어렵기 때문에 생성자나 세터 주입보다 테스트하기 어렵다.
  2. 순환 참조 가능성
    • 스프링 빈이 생성될 때, 모든 필드가 먼저 초기화되므로 두 개의 빈이 서로를 참조하는 경우, 한 쪽이 아직 초기화되지 않아 NullPointerException이 발생할 수 있다.
    • 생성자 주입 방식을 사용하면 해당 빈에 필요한 의존성이 모두 주입된 후에 객체가 생성된다. 즉, 빈이 생성되기 전에 의존하는 다른 빈이 모두 생성되어 있어야 하기에 순환 참조가 발생할 가능성이 매우 적다.
    • 세터 주입 방식에서는 빈이 생성된 후에 해당 빈의 의존성이 주입된다. 이 때문에 의존하는 다른 빈이 생성되지 않은 상태에서 해당 빈의 세터 메서드를 호출하면, 순환 참조가 발생할 가능성이 있지만 필드 주입에 비해 적은 편이다.

빈은 이름만 다르게 설정하면 같은 클래스여도 여러개 선언이 가능한데, 한 클래스에서 파생된 여러 빈을 모두 가지고 오고 싶을 때는 배열 타입으로 작성하여 가져올 수 있다.

@Configuration
public class AppConfig {
    
    @Bean
    public MyBean myBean1() {
        return new MyBean("first");
    }
    
    @Bean
    public MyBean myBean2() {
        return new MyBean("second");
    }
    
    @Bean
    public MyBean myBean3() {
        return new MyBean("third");
    }
    
}

@Component
public class MyComponent {
 
    private MyBean[] myBeans;
 
    @Autowired
    public void setMyBeans(MyBean[] myBeans) {
        this.myBeans = myBeans;
    }
}

@Primary

@Autowired를 사용하는 경우, 어떤 빈을 가져올 것인지에 대한 제어가 필요해진다.

@Primary 사용 시 동일한 클래스에 대해 우선권을 부여할 수 있다.

@Configuration
public class MovieConfiguration {

    @Bean
    @Primary
    public MovieCatalog firstMovieCatalog() { ... }

    @Bean
    public MovieCatalog secondMovieCatalog() { ... }

    // ...
}

public class MovieRecommender {

    @Autowired
    private MovieCatalog movieCatalog;

    // ...
}

위의 예제에서 movieCatalog 빈은 firstMovieCatalog 이름을 가진 빈으로 할당된다.

@Primary가 두 개 이상에 쓰일 경우 어떤 빈을 주입받을지 명확하지 않으므로 반드시 하나의 빈에만 적용하도록 한다.

@Qualifier

@Primary는 여러 인스턴스 중 하나의 디폴트 빈을 설정할 때 유용하게 사용되며, @Primary를 사용하지 않고 @Autowired와 함께 빈의 이름을 직접 지정하는 @Qualifier를 사용할 수도 있다.

필드 주입

public class MovieRecommender {

    @Autowired
    @Qualifier("firstMovieCatalog")
    private MovieCatalog movieCatalog;

    // ...
}

생성자 주입

public class MovieRecommender {

    private final MovieCatalog movieCatalog;

    private final CustomerPreferenceDao customerPreferenceDao;

    @Autowired
    public void prepare(@Qualifier("firstMovieCatalog") MovieCatalog movieCatalog,
            CustomerPreferenceDao customerPreferenceDao) {
        this.movieCatalog = movieCatalog;
        this.customerPreferenceDao = customerPreferenceDao;
    }

    // ...
}

@Autowired + @Qualifier를 사용하는 것과 유사하게 JSR-250의 표준 어노테이션인 @Resource 어노테이션으로도 특정 빈을 주입받을 수 있다.

단, @Autowired + @Qualifier는 스프링의 DI 기능을 사용하므로 Spring의 다른 기능들과 호환성이 좋아 Spring 프레임워크 내에서는 주로 이 방식을 사용한다. 반면에 @Resource어노테이션은 Spring 프레임워크에 의존하지 않으므로 Spring에서 제공하는 다른 기능과의 호환성이 떨어질 수 있다.

Spring에 의존하지 않는 환경에서는 @Resource를 사용하는 것이 더 나을 수도 있다.   두 방식은 작동 방식 또한 미세하게 다르다. @Autowired + @Qualifier는 먼저 타입을 확인 한 뒤 이름으로 빈을 찾는다. @Resource타입을 보지 않고 이름만으로 빈을 가져오려고 시도한다.

@Configuration
public class MyConfiguration {

    @Bean
    @Qualifier("myBean")
    public ATypeBean myBean() {
        return new ATypeBean();
    }

    @Bean
    @Qualifier("mySecondBean")
    public ATypeBean mySecondBean() {
        return new ATypeBean();
    }

    @Bean
    @Qualifier("myBean")
    public BTypeBean bTypeBean() {
        return new BTypeBean();
    }
    
    // ...
}
  • ATypeBeanmyBean, mySecondBean이라는 각기 다른 이름의 두 개의 빈이 존재한다.
  • BTypeBeanmyBean이라는 이름의 빈이 하나 존재하나, ATypeBean인 빈과 이름이 동일하다.

이 빈들을 가져오는 코드를 살펴보자.

public class MyComponent {
    @Autowired
    private ATypeBean myBean;
    // ...
}

이 코드는 @Autowired만 사용했기에 같은 타입(ATypeBean)을 가진 빈이 두 개 이상 존재하여 NoUniqueBeanDefinitionException 예외가 발생한다.

이 때, @Qualifier("myBean")을 추가하면 myBean이라는 이름의 빈을 가져 올 수 있다.

public class MyComponent {
    @Autowired
    @Qualifier("myBean")
    private ATypeBean myBean;
    // ...
}

myBean이라는 이름은 ATypeBean, BTypeBean 두 군데에서 사용하고 있지만 @Autowired + @Qualifier를 사용할 땐 1) 타입을 확인 → 2) 이름으로 구분하기 때문에 적절한 빈을 가져올 수 있는 것이다.   반면에 @Resource를 사용하면 오류가 발생한다.

public class MyComponent {
    @Resource(name = "myBean")
    private ATypeBean myBean;
    // ...
}

@Resource는 이름을 보고 빈을 찾기 때문에 ATypeBean, BTypeBean 중 어떤 것을 가져와야 할지 알 수 없다. 따라서 타입이 다르지만 이름이 같은 빈이 존재한다면 @Autowired + @Qualifier를 사용해야한다.

또한 @Resource에서 name 값을 명시하지 않는다면 필드 이름을 보고 일치하는 것을 찾아오는데,

public class MyComponent {
    @Resource
    private ATypeBean mySecondBean;
    // ...
}

위 코드는 mySecondBean 이라는 이름의 빈을 가져온다.

따라서 name을 생략하는 경우 필드 이름을 반드시 빈 이름과 매치되게 작성해주어야한다.

public class MyComponent {
    @Resource
    private BTypeBean mySecondBean;
    // ...
}

BTypeBeanmySecondBean이라는 이름을 가진 빈이 없기 때문에 해당 코드는 오류가 발생한다.   예시에서 보았듯이 @Resource는 계속 필드에서만 사용되고 있는데, @Autowired + @Qualifier가 필드, 생성자, 다중 인수 세터 메서드에 적용되는 반면 @Resource는 단일 인수가 있는 필드나 세터 메소드에서만 사용 가능하다.

@Qulifier 아름답게 사용하기
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface Offline {
}

@Qualifier 어노테이션을 메타 어노테이션으로 가지는 Offline 어노테이션을 선언한다.

이 때, @Offline@Qualifier("offline") 어노테이션과 동일한 역할을 한다.

public class MovieRecommender {

    @Autowired
    @Offline
    private MovieCatalog offlineCatalog;

    // ...
}

offlineCatalog에는 offline이라는 이름을 가진 MovieCatalog빈이 주입될 것이다.   @Qualifier 메타 어노테이션과 함께 빈을 구분할 수 있는 다양한 필드를 가진 어노테이션을 활용할 수도 있다.

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface MovieQualifier {

    String genre();

    Format format();
}

public enum Format {
    VHS, DVD, BLURAY
}

위와 같은 MovieQualifier어노테이션을 만들고 genre, format을 이용해서 빈을 선언해보자.

@Component
@MovieQualifier(format = Format.VHS, genre = "Action")
public class ActionVhsCatalog implements MovieCatalog {
    // ...
}

@Component
@MovieQualifier(format = Format.VHS, genre = "Comedy")
public class ComedyBluRayCatalog implements MovieCatalog {
    // ...
}

@Component
@MovieQualifier(format = Format.DVD, genre = "Action")
public class ActionDvdCatalog implements MovieCatalog {
    // ...
}

각 빈들은 이름으로도 구분할 수 있지만, formatgenre 값을 이용해서도 구분될 수 있다.

public class MovieRecommender {

    @Autowired
    @MovieQualifier(format=Format.VHS, genre="Action")
    private MovieCatalog actionVhsCatalog;

    @Autowired
    @MovieQualifier(format=Format.VHS, genre="Comedy")
    private MovieCatalog comedyVhsCatalog;

    @Autowired
    @MovieQualifier(format=Format.DVD, genre="Action")
    private MovieCatalog actionDvdCatalog;
    
    // ...
}

자동 주입을 사용할 곳에 @MovieQualifier를 이용하여 보다 복잡한 분류로 정의된 빈들을 가져올 수 있다.

위와 같이 특정 인터페이스의 구현체가 여러개 있고 각각을 빈으로 등록하는 등 여러 개의 빈이 존재하고 특정 조건을 만족하는 빈을 주입해야할 때, 조건이 복잡할수록 커스텀마이징된 어노테이션을 사용하는 것이 가독성과 유지보수 측면에서 좋을 수 있다.

보통은 빈의 이름으로만 구분해서 사용되지만 추가적으로 다른 속성을 사용해야 할 필요가 있을 때 이를 활용해 보는 것을 추천한다.

제네릭을 이용한 @Autowired

여기 제네릭 타입 파라미터를 가지는 Store 클래스와 이 클래스를 상속받는 빈으로 선언된 두 개의 클래스가 있다.

public class Store<T> {
    private T value;
    
    public Store(T value) {
        this.value = value;
    }
    
    public T getValue() {
        return value;
    }
    
    public void setValue(T value) {
        this.value = value;
    }
}

@Component
public class StringStore extends Store<String> {
    // ...
}

@Component
public class IntegerStore extends Store<Integer> {
    // ...
}

위 두 빈을 주입 받기 위해서 어떻게 코드를 짤 수 있을까?

여태 살펴본 방식들을 활용할 수 있을 것이다.

@Component
public class StoreRegister {

    @Autowired
    @Qualifier("stringStore")
    private Store stringStore;
    
    @Resource(name = "integerStore")
    private Store integerStore
    
    //...
}

특이하게도 제네릭 필드를 가지는 경우에는 제네릭 타입을 명시하는 것만으로도 적절한 빈을 가져올 수 있다.

@Component
public class StoreRegister {

    @Autowired
    private Store<String> s1; //stringStore bean

    @Autowired
    private Store<Integer> s2; //integerStore bean
    
    //...
}

StringStore타입으로 자동으로 유추되어 적절한 빈을 가져온다.

Class Scanning and Managed Component


마지막으로 클래스패스를 스캔하여 빈으로 등록된 컴포넌트들을 암시적으로 검색하는 옵션에 대해 설명한다. 빈의 후보가 될 컴포넌트는 필터 조건과 일치하는 클래스이며 어노테이션(@Component 등), AspectJ 타입 표현식 또는 사용자 지정 필터 조건을 사용해 어떤 클래스가 컨테이너에 BeanDefinition으로 등록되어야 하는지 선택할 수 있다.

스테레오 타입 어노테이션

스프링은 @Component 외에 @Repository, @Service, @Controller와 같은 추가 스테레오 타입 어노테이션을 제공한다. @Repository, @Service, @Controller는 레이어 계층을 나눌 때 사용하는 곳에 따라 보다 구체적인 사용 사례를 위해 @Component를 특수화한 버전이다. 이들을 이용해 처리와 관련된 측면과 연관시켜 표현하는 것이 더 적절하며 포인트컷의 이상적인 대상이 된다.

포인트 컷(Pointcut) AOP(Aspect-Oriented Programming)에서 횡단 관심사(cross-cutting concern)를 정의하는 데 사용되는 표현식이다. *횡단 관심사 : 여러 모듈의 객체에 걸쳐 적용되어야하는 기능 (여기저기서 쓰이는 공통 기능이라고 생각하면 쉽다) AOP는 OOP(Object-Oriented Programming)에서는 처리하기 어려운 시스템 전반에 걸쳐 나타나는 코드의 중복과 유지보수성 문제를 해결하는 프로그래밍 패러다임이다. AOP에서는 핵심 기능과 횡단 관심사를 분리해서 관리할 수 있으며, 이를 위해 포인트 컷을 사용한다. 포인트 컷은 어디에서 실행될지를 결정하는데 사용되는 규칙 집합이다. 예를 들어, 모든 메소드 호출, 특정 메소드 호출, 특정 패키지의 메소드 호출 등이 포인트 컷이 될 수 있다. AOP에서는 이러한 포인트 컷에 맞춰서 횡단 관심사를 적용한다.

Meta-annotation과 Composed Annotation

@RestController 어노테이션을 보자.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {

	@AliasFor(annotation = Controller.class)
	String value() default "";

}

@RestController 어노테이션은 메타 어노테이션으로 사용되고 있는 @Controller@ResponseBody를 합쳐서 Composed Annotation으로 활용되고 있다.

여러 메타 어노테이션의 조합으로 생성된 Composed Annotation은 메타 어노테이션으로 사용된 어노테이션들의 특성을 상속받는데, @AliasFor 어노테이션을 사용해 메타 어노테이션 속성을 재정의 할 수 있다.

위 코드에서 @RestController 어노테이션은 @Controller의 속성인 value의 기본값을 빈 문자열로 설정한다.   비슷한 예로 Composed Annotation인 @SessionScope를 한 번 살펴보자.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Scope(WebApplicationContext.SCOPE_SESSION)
public @interface SessionScope {

    @AliasFor(annotation = Scope.class)
    ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;

}

위 어노테이션은 다음처럼 해석할 수 있다.

  • @Scope 어노테이션을 메타 어노테이션으로 가지며, 스코프를 지정하기 위해 WebApplicationContextSCOPE_SESSION 상수를 값으로 사용한다.
  • proxyMode 속성은 @Scope 어노테이션에 이미 정의되어 있는 속성인데, @SessionScope 어노테이션에서는 이 속성을 다시 선언하고, 기본값을 ScopedProxyMode.TARGET_CLASS로 설정한다.
    • @Scope에서 해당 속성은 ScopedProxyMode proxyMode() default ScopedProxyMode.DEFAULT; 으로 선언되어있다.
  • @AliasFor 어노테이션을 사용하여 proxyMode 속성이 @Scope 어노테이션의 proxyMode 속성과 동일하다는 것을 나타낸다. 쉽게 말해 오버라이딩 됐음을 명시한다. 이렇게 함으로써, @SessionScope 어노테이션 사용 시 proxyMode 속성을 지정하면 실제로는 @Scope 어노테이션의 proxyMode 속성 값이 ScopedProxyMode.TARGET_CLASS로 변경되는 효과가 생긴다.

실제로 해당 어노테이션을 사용하는 예제를 보면서 마무리한다.

@Service
@SessionScope
public class SessionScopedService {
    // ...
}

위와 같이 사용할 경우 proxyMode 속성 값은 ScopedProxyMode.TARGET_CLASS이다.

@Service
@SessionScope(proxyMode = ScopedProxyMode.INTERFACES)
public class SessionScopedUserService implements UserService {
    // ...
}

@SessionScope에서 proxyMode속성을 필드로 가지고 있기 때문에 직접 값을 넘겨 다른 값으로 설정할 수도 있다.

클래스 감지 자동화와 BeanDefinition 등록

위에서 설명한 스테레오 타입 어노테이션(@Component, @Repository, @Service, @Controller)이 추가된 클래스 는 자동으로 스캔되어 BeanDefinition을 생성한다.

@Configuration
@ComponentScan(basePackages = "org.example")
public class AppConfig  {
    // ...
}

@Configuration어노테이션으로 정의한 설정 클래스에 @ComponentScan을 이용해 상위 패키지를 등록할 수 있다. 해당 예제에서는 org.example 패키지 하위의 모든 스테레오 타입 어노테이션을 스캔하고 빈으로 등록한다.   어떤 경우에는 스테레오 타입 어노테이션이 추가된 클래스일지라도 빈으로 등록되지 않게 정의하고 싶을 때가 있을 것이다. @ComponentScan은 이런 경우를 위해 includeFilters, excludeFilters 속성을 제공한다.

  • includeFilters: 스캔할 대상을 지정한다.
  • excludeFilters: 스캔 대상에서 제외할 대상을 지정한다.
@Configuration
@ComponentScan(basePackages = "org.example",
        includeFilters = @Filter(type = FilterType.REGEX, pattern = ".*Stub.*Repository"),
        excludeFilters = @Filter(Repository.class))
public class AppConfig {
    // ...
}

위 코드에서 기본적으로 org.example패키지를 스캔한다. 이 때 includeFilters를 사용하는데 FilterType.REGEX 타입을 사용해 명시한 정규식 패턴에 맞는 클래스에 대해서만 빈으로 등록할 것을 지시한다. excludeFilters에서는 Repository 클래스를 상속하는 클래스를 스캔 대상에서 제외하도록 하고 있다.

스테레오 타입 어노테이션을 사용할 때 빈 이름 설정하기

@Bean의 name 속성이나 어노테이션에서 value속성을 명시하면 해당 값으로 빈의 이름을 설정할 수 있다.

@Service("myMovieLister")
public class SimpleMovieLister {
    // ...
}

어떤 방식으로든 이름을 설정하지 않으면 클래스명(첫자리 소문자로 변경)이 빈의 이름이 된다.

만약 이 기본 설정을 바꾸고 싶으면 BeanNameGenerator 인터페이스를 구현해 원하는 방식으로 빈 이름을 자동 생성하게 할 수 있다.

public class MyNameGenerator extends AnnotationBeanNameGenerator {

    @Override
    public String generateBeanName(BeanDefinition definition, org.springframework.beans.factory.support.BeanDefinitionRegistry registry) {
        String name = super.generateBeanName(definition, registry);
        return "myBean_" + name;
    }
}

@Configuration
@ComponentScan(basePackages = "org.example", nameGenerator = MyNameGenerator.class)
public class AppConfig {
    // ...
}

맺음말


이렇게 세 번의 포스팅을 통해 스프링의 핵심 개념인 IoC 컨테이너와 DI, 빈 스코프, 그리고 컴포넌트 스캐닝에 대해 살펴보았다. 스프링 프레임워크는 이러한 개념들을 기반으로 유연하고 확장 가능한 애플리케이션을 개발할 수 있게 해주는 강력한 도구이다. IoC 컨테이너를 통해 객체의 생명주기와 의존성을 관리하고, DI를 통해 객체 간의 결합도를 낮추며 재사용성을 높일 수 있다. 또한 빈 스코프를 설정하여 객체의 범위를 조절하고, 컴포넌트 스캐닝을 통해 자동으로 빈을 등록하는 편리한 기능을 활용할 수 있다.

이번 시리즈를 통해 스프링의 핵심 개념을 이해하고, 빈 설정과 활용에 대한 기본적인 지식을 습득하셨을 것으로 기대한다. 하지만 이것은 시작에 불과하다. 스프링은 더욱 다양하고 심화된 기능들을 제공하며, 실제 프로젝트에 적용하기 위해서는 추가적인 공부와 실습이 필요하다. 지금부터는 실제 프로젝트를 구성하고 개발하는 과정에서 스프링의 다른 모듈과 기능들을 탐구하고 익히길 바란다.

마지막으로, 이 시리즈를 통해 함께 학습하는 것은 필자에게도 매우 소중한 시간들이었다. 앞으로의 개발 여정에서도 꾸준한 공부를 통해 개발 역량을 향상시킬 수 있기를!

참고


- https://docs\.spring\.io/spring\-framework/reference/core/beans/factory\-scopes\.html - https://docs\.spring\.io/spring\-framework/reference/core/beans/annotation\-config\.html - https://docs\.spring\.io/spring\-framework/reference/core/beans/classpath\-scanning\.html