Spring IoC와 DI에 대해 정리.
토비의 스프링 3.1과 스프링 철저입문 도서, 타 블로그의 정리 내용을 보고 정리했습니다.
책 위주로 정리한 내용이고 풀어서 정리되어있기 때문에 간단한 정리는 타 블로그 참고를 추천드립니다.
Reference
- 토비의 스프링 3.1
- 스프링 철저 입문
IoC와 DI에 대한 정리는 아래와 같은 순서로 진행.
- IoC Container란
- IoC Container의 종류와 사용방법
- Web Application의 IoC Container 구성
- Bean 설정 방식, DI 방식
- Autowiring, ComponentScan
- Bean Scope
- Bean 설정 분할과 profile별 설정
- Bean 생명주기, IoC Container 종료
아래 순서로 정리한다.
1. Autowiring
2. ComponentScan
1. Autowiring
Autowiring(오토와이어링)은 자바 기반 설정 방식에서 @Bean 메소드를 사용하거나 XML 기반 방식에서 <bean> 요소를 사용하는 것처럼 명시적으로 Bean을 정의하지 않고도 IoC 컨테이너에 Bean을 자동으로 주입하는 방식이다.
오토와이어링에는 type을 사용한 방식(Autowiring by type)과 이름을 사용한 방식(Autowiring by name)이 있다.
그리고 이렇게 하나만 가져오는 방법 외에 Collection이나 Map 타입으로 오토와이어링하는 방법이 있다.
타입으로 오토와이어링하기
흔히 스프링 웹 개발 공부를 하면서 볼 수 있는 @Autowired 어노테이션은 타입으로 오토와이어링을 하는 방식이다.
타입으로 오토와이어링을 하는 방식은 Setter Injection, Constructor Injection, Field Injection의 세가지 의존성 주입 방식에서 모두 사용할 수 있다.
타입으로 오토와이어링을 할 때는 기본적으로 의존성 주입이 반드시 성공한다고 가정한다.
그래서 주입할 타입에 해당하는 Bean을 IoC 컨테이너에서 찾지 못한다면 NoSuchBeanDefinitionException이 발생한다.
이런 필수조건을 완화하고 싶다면 @Autowired(required = "false")로 설정하면 된다.
그럼 해당 타입의 Bean을 찾지 못하더라도 예외가 발생하지 않고 의존성 주입은 실패했기 때문에 해당 필드의 값은 null이 된다.
오토와이어링의 조건을 완화해 필드 인젝션을 하는 방법으로 두가지가 있다.
하나는 방금 언급한 required = "false"를 설정하는것이고 다른 하나는 Optional을 사용해 처리할 수 있는데 이건 스프링 4부터 사용이 가능하다.
//required = "false"로 조건 완화
@Autowired(required = "false")
PasswordEncoder passwordEncoder;
//Optional로 조건 완화
@Autowired
Optional<PasswordEncoder> passwordEncoder;
public void createUser(User user, String rawPassword) {
String encodedPassword = passwordEncoder.map(x -> x.encode(rawPassword))
.orElse(rawPassword);
}
타입으로 오토와이어링 할때 IoC컨테이너에 같은 타입의 Bean이 여러개 있다면 그 중에서 어떤 것을 사용해야 할 지 알 수가 없다.
그래서 이런 경우에는 NoUniqueBeanDefinitionException이 발생한다.
보이는 의미 그대로 Bean 정의가 유일하지 않은 예외이다.
이처럼 같은 타입의 Bean이 여러개 정의된 경우에는 @Qualifier 어노테이션을 추가하면서 Bean 이름을 지정하면 같은 타입의 Bean 중에서 원하는 Bean만 선택할 수 있다.
//같은 타입의 Bean 여러개를 자바 기반 방식으로 정의
@Configuration
@ComponentScan
public class AppConfig {
@Bean
PasswordEncoder sha256PasswordEncoder() {
return new Sah256PasswordEncoder();
}
@Bean
PasswordEncoder bcryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
//PasswordEncoder를 오토와이어링
@Compoent
public class UserServiceImpl implements UserService {
@Autowired
@Qualifier("sha256PasswordEncoder")
PasswordEncoder passwordEncoder;
....
}
이렇게 동일한 PasswordEncoder 타입이고 이름이 다른 두개의 Bean이 존재할 때
그냥 @Autowired 어노테이션 하나만으로 처리하면 NoUniqueBeanDefintionExcpetion이 발생하는 것이다.
둘다 동일하게 PasswordEncoder 타입이기 때문에 어떤것을 가져와야 할지 알 수 없기 때문이다.
그래서 @Qualifier 어노테이션에 원하는 Bean의 이름(sha256PasswordEncoder)을 추가로 명시하면 그에 해당하는 Bean을 선택할 수 있게 된다.
이걸 해결하는 또 다른 방법이 하나 더 있는데 만약 100번 정도의 오토와이어링에서 90번은 bcryptPasswordEncoder를 사용하는데 10번만 sha256PasswordEncoder를 가져와야 한다고 가정했을 때 100번 모두 @Qualifier 어노테이션을 전부 다 부여해서 처리하기에는 작성할 때 오타가 날 수도 있고 작성해야 하는 코드 양도 너무 많아진다.
그래서 우선적으로 선택할 수 있도록 하는 어노테이션이 존재한다.
//같은 타입의 Bean 여러개를 자바 기반 방식으로 정의
@Configuration
@ComponentScan
public class AppConfig {
@Bean
PasswordEncoder sha256PasswordEncoder() {
return new Sah256PasswordEncoder();
}
@Bean
@Primary
PasswordEncoder bcryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
//PasswordEncoder타입 중 bcryptPasswordEncoder를 오토와이어링
@Compoent
public class UserServiceImpl implements UserService {
@Autowired
PasswordEncoder passwordEncoder;
....
}
//PasswordEncoder타입 중 sha256PasswordEncoder를 오토와이어링
@Compoent
public class UserServiceImpl implements UserService {
@Autowired
@Qualifier("sha256PasswordEncoder")
PasswordEncoder passwordEncoder;
....
}
이렇게 우선적으로 가져오도록 할 Bean에 @Primary 어노테이션을 부여해주게 되면 @Qualifier가 붙어있지 않은 오토와이어링에서 먼저 선택해 가져오게 된다.
그럼 남은 10개의 오토와이어링에서만 예제 제일 아래처럼 @Qualifier로 sha256PasswordEncoder를 가져오도록 하면 100번의 오토와이어링을 좀 더 편하게 처리할 수 있게 된다.
하지만 이렇게 @Qualifier로 수식하는 Bean의 이름에 구현 클래스의 이름이 포함된다거나 구현과 관련된 정보가 포함되어 있다면 명명 방법이 바람직하다고 볼 수는 없다.
즉, 코드에서 '나는 지금 여기에 sha256PasswordEncoder를 오토와이어링 했어요!' 라고 알리는것은 좋지 않다는 것이다.
결합도를 낮추기 위해 DI 방식을 채택했는데 Bean을 사용할 때 특정 구현체가 사용될 것으로 의식한 이름을 지정해버리면 굳이 DI를 사용하는 의미가 없어지기 때문이다.
굳~이 이렇게 사용해야 한다면 DI 를 사용하지 않는것이 더 나을 수도 있다.
이런 경우네는 Bean의 이름으로 구현체의 이름을 쓰는 대신 역할이나 사용목적, 혹은 용도로 이름을 쓰는 것이 좋다.
sha256은 bcrypt에 비해 '경량'이기 때문에 책에서 예제는 'lightweight'라는 이름으로 지어 작성했다.
@Configuration
@ComponentScan
public class AppConfig {
@Bean(name = "lightweight")
PasswordEncoder sha256PasswordEncoder() {
return new Sha256PasswordEncoder();
}
@Bean
@Primary
PasswordEncoder bcryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Autowired
@Qualifier("lightweight")
PasswordEncoder passwordEncoder;
이렇게 @Bean에서 name 속성에 이름을 명시함으로써 직접적인 구현체의 이름을 쓴다거나 클래스에 대한 정보가 포함되는 것을 방지할 수 있다.
이런 Bean의 역할이나 용도는 문자열 형태의 이름이 아닌 타입(어노테이션)으로 표현할 수도 있다.
//LightWeight 어노테이션 생성
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Qualifier
public @interface LightWeight {
}
//Bean 에 적용
@Configuration
@ComponentScan
public class AppConfig {
@Bean
@LightWeight
PasswordEncoder sha256PasswordEncoder() {
return new Sha256PasswordEncoder();
}
@Bean
@Primary
PasswordEncoder bcryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
//오토와이어링에서 사용
@Autowired
@LightWeight
PasswordEncoder passwordEncoder;
이렇게 어노테이션을 생성해 Bean을 정의하고 필드 인젝션시에도 사용할 수 있다.
이처럼 직접 어노테이션을 정의하는 방식은 문자열로 된 Bean 이름을 지정하는 방식과 달리 오타가 발생하게 되었을 때 IDE에서 잡아내 주기 때문에 편하게 확인할 수 있다.
그리고 여러개의 유사한 Bean을 정의해야 한다면 최적의 방법이다.
물론 이렇게 처리하더라도 해당 구현에 대한 정보를 담고 있는 @Sha256 이러한 명명은 피해야 한다.
이름으로 오토와이어링하기
Bean의 이름이 필드명이나 프로퍼티명과 일치할 경우에 Bean 이름으로 필드 인젝션을 하는 방법도 있다.
이 방법에서는 JSR 250 사양을 지원하는 @Resource 어노테이션(javax.annotation.Resource)을 활용한다.
@Component
public class UserServiceImpl implements UserService {
@Resource(name = "sha256PasswordEncoder")
PasswordEncoder passwordEncoder;
....
}
이 예제는 @Qualifier 대신 @Resource가 들어간 것이다.
그리고 이전에 타입으로 오토와이어링 할 때 봤던 예제 중에서 @Bean의 name 속성이나 @Lightweight 어노테이션을 사용하지 않은 경우이다.
하지만 @Resource 어노테이션의 name 속성에 Bean의 이름을 명시함으로써 sha256으로 선택이 되는것이다.
//필드 이름과 일치하는경우
@Component
public class UserServiceImpl implements UserService {
@Resource
PasswordEncoder sha256PasswordEncoder;
....
}
//프로퍼티 이름과 일치해 세터인젝션을 하는 경우
@Component
public class UserServiceImpl implements UserService {
private PasswordEncoder passwordEncoder;
@Resource
public void setSha256PasswordEncoder(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
....
}
@Resource 어노테이션은 이렇게 두가지 경우로 사용할 수 있다.
이 두가지의 경우에 해당하지 않는다면 이름으로 오토와이어링을 하는것이 아닌 타입으로 오토와이어링을 해야 한다.
@Resource 방식은 조금 복잡한 편이라 동작방식을 제대로 이해한 후에 사용하는 것이 좋으며 컨스트럭터 인젝션에서는 @Resource 어노테이션을 사용하지 못한다.
Collection이나 Map 타입으로 오토와이어링
스프링에서는 위에서 봤던 방법들처럼 하나로 정의된 Bean을 가져오는 방법 외에도 같은 인터페이스를 구현한 Bean을 Collection이나 Map에 담아 가져오는 방법도 제공한다.
//IF 인터페이스를 구현한 여러개의 Bean 정의
public interface IF<T> {
}
@Component
public class IntIF1 implements IF<Integer> {
}
@Component
public class IntIF2 implements IF<Integer> {
}
@Component
public class StringIF implements IF<String> {
}
이렇게 하나의 인터페이스를 구현한 Bean이 여러개 존재하는 경우 오토와이어링을 아래와 같은 형태로 할 수 있다.
@Autowired
List<IF> ifList;
@Autowired
Map<String, IF> ifMap;
이렇게 오토와이어링 하게 되면 ifList에는 IntIF1, IntIF2, StringIf Bean이 List 형태로 주입된다.
그리고 ifMap에는 '빈 이름 = 빈' 형태로 주입된다.
'intIF1 = IntIF1, intIF2 = IntIF2, stringIF = StringIF'
IF 인터페이스를 보면 T 타입으로 제네릭 타입이다.
@Autowired
List<IF<Integer>> ifList;
@Autowired
Map<String, IF<Integer>> ifMap;
그래서 이렇게 IF의 Integer 타입으로 받게 되면 주입될 Bean의 파라미터가 Integer로 한정되기 때문에
IntIF1, IntIF2처럼 Integer 타입의 Bean만 주입되게 된다.
그럼 만약에 Bean을 정의할 때 처음부터 List나 Map 형태로 정의한다면??
@Bean
List<IF> ifList(){
return Arrays.asList(new IntIF1(), IntIF2(), StringIF());
}
@Bean
Map<String, IF> ifMap(){
map.put("intIF1", new IntIF1());
map.put("intIF2", new IntIF2());
map.put("StringIF", new StringIF());
return map;
}
//@Autowired 어노테이션을 통한 필드 인젝션(인젝션 불가)
@Autowired
@Qualifier("ifList")
List<IF> ifList;
@Autowired
@Qualifier("ifMap")
Map<String, IF> ifMap;
//@Resource 어노테이션을 통한 필드 인젝션(인젝션 가능)
@Resource
List<IF> ifList;
@Resource
Map<String, IF> ifMap;
Bean을 정의할 때 List나 Map 형태로 정의하는 것에 대한 문제는 없다.
다만 인젝션 시에 @Autowired를 통해서는 주입이 불가능하고 @Resource 어노테이션을 통해서만 주입이 가능하다는 제한이 있다.
2. ComponentScan
이전 포스팅인 Bean 설정방식, DI 방식에서 ComponentScan이 언급되었었다.
컴포넌트 스캔은 Class Loader를 스캔하면서 특정 클래스를 찾은 다음 IoC 컨테이너에 등록하는 방법을 말한다.
별도의 설정이 없는 기본 설정에서는 특정 어노테이션이 붙은 클래스가 탐색대상이 되고 탐색된 컴포넌트는 IoC 컨테이너에 등록되게 된다.
그 특정 어노테이션의 종류는 아래와 같다.
- @Component (org.springframework.stereotype.Component)
- @Controller (org.springframework.stereotype.Controller)
- @Service (org.springframework.stereotype.Service)
- @Repository (org.springframework.stereotype.Repository)
- @Configuration (org.springframework.context.annotation.Configuration)
- @RestController (org.springframework.web.bind.annotation.RestController)
- @ControllerAdvice (org.springframework.web.bind.annotation.ControllerAdvice)
- @ManagedBean (javax.annotation.ManagedBean)
- @Named (javax.inject.Named)
컴포넌트 스캔을 하기 위해서는 자바기반 설정방식에서는 @ComponentScan을,
XML 방식에서는 <context:component-scan>을 사용한다.
컴포넌트 스캔을 할 때는 ClassLoader에서 위와 같은 어노테이션을 찾아야 하기 때문에 탐색 범위가 넓고 처리하는 시간도 오래걸린다.
이 시간은 결국 처리시간을 증가시키는 원인이 되기 때문에 탐색범위를 넓게 설정하는 것은 성능면에서 좋지 않다.
가능한 한 광범위한 범위설정을 피해야 하기 때문에 통상 애플리케이션의 최상위나 한단계 아래의 패키지 까지를 스캔 대상으로 잡는것이 적절하다.
//범위가 광범위한경우(설정이 부적절)
@ComponentScan(basePackages = "com")
@ComponentScan(basePackages = "com.example")
//범위가 적절한 경우(설정이 적절)
@ComponentScan(basePackages = "com.example.demo")
@ComponentScan(basePackages = "com.example.demo.app")
이때 속성은 basePackages 대신 value 속성을 써도 된다.
어느쪽을 사용해도 스캔하는데는 문제가 없으나 이 속성을 생략하는 경우 @ComponentScan이 설정된 클래스가 속한 패키지부터 하위 패키지만 스캔한다는 것에 유의해야 한다.
이 컴포넌트 스캔 대상이 되는 어노테이션 중 가장 많이 활용되는 네가지는 다음과 같다.
1. @Controller
MVC 패턴에서의 C. 즉, 컨트롤러 역할을 하는 컴포넌트에 붙이는 어노테이션.
클라이언트에서 오는 요청을 받고 비즈니스 로직의 처리 결과를 응답으로 돌려보내는 기능을 한다.
이때 실제 비즈니스 로직은 @Service가 붙은 컴포넌트에서 처리하도록 위임한다.
2. Service
비즈니스 로직(service)을 처리하는 컴포넌트에 붙이는 어노테이션.
컨트롤러에서 받은 입력 데이터를 활용해 비즈니스 로직을 실행하는 기능을 한다.
이때 영속적으로 보관해야 하는 데이터가 있다면 @Repository가 붙은 컴포넌트에서 처리하도록 위임한다.
3. @Repository
영속적인 데이터 처리를 수행하는 컴포넌트에 붙이는 어노테이션.
ORM 관련 라이브러리를 활용해 데이터의 CRUD를 처리하는 기능을 한다.
4. @Component
위의 세 경우에 해당하지 않는 컴포넌트(유틸리티 클래스나 기타 지원 클래스 등)에 붙이는 어노테이션.
이 컴포넌트 스캔 대상들 외에도 추가로 다른 컴포넌트를 더 포함하고 싶다면 필터를 적용해 스캔 범위를 커스터마이징 할 수 있다.
스프링에서는 아래와 같은 필터를 제공한다.
- 어노테이션을 활용한 필터(ANNOTATION)
- 할당 가능한 타입을 활용한 필터(ASSIGNABLE_TYPE)
- 정규 표현식 패턴을 활용한 필터(REGEX)
- AspecjJ 패턴을 활용한 필터(ASPECTJ)
아래는 필터 예제다.
//할당 가능한 타입으로 필터링(인터페이스)
public interface DomainService {
}
//할당 가능한 타입으로 필터링(자바 기반 설정 방식)
@ComponentScan(basePackages = "com.example.demo"
, includeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE
, classes = {DomainService.class})
})
//정규표현식 패턴으로 필터링(자바 기반 설정 방식)
@ComponentScan(basePackages = "com.example.demo"
, includeFilters = {
@ComponentScan.Filter(type = FilterType.REGEX
, pattern = {".+DomainService$"})
})
<!-- 할당 가능한 타입으로 필터링(XML 기반 설정 방식) -->
<context:component-scan base-package="com.example.demo">
<context:include-filter type="assignable" expression="com.example.demo.domain.DomainService"/>
</context:component-scan>
<!-- 정규 표현식 패턴으로 필터링(XML 기반 설정 방식) -->
<context:component-scan base-package="com.example.demo">
<context:include-filter type="regex" expression=".+DomainService$"/>
</context:component-scan>
여기서 주의해야 할 점은 필터를 적용해서 컴포넌트 스캔을 할 때 앞서 살펴본 어노테이션이 붙은 스캔 대상도 함께 탐색 범위에 포함된다는 것이다.
만약 기본 설정에서 어노테이션이 붙은 스캔 대상을 무시하고, 순수하게 필터를 적용해서 탐색되는 컴포넌트만 사용하고 싶다면 아래와 같이 useDefaultFilters 속성을 false로 설정하면 된다.
그리고 기본 스캔 대상에 필터를 적용해 특정 컴포넌트를 추가하는 것과 반대로 빼는 방법도 있는데 이때는 excludeFilter 속성을 사용하면 된다.
//기본 스캔 대상을 제외하고 필터로만 스캔(자바 기반 설정 방식)
@ComponentScan(basePackages = "com.example.demo"
, useDefaultFilters = false
, includeFilters = {
@ComponentScan.Filter(type = FilterType.REGEX
, pattern = {".+DomainService$"})
})
//기본 스캔 대상과 특정 컴포넌트(@Exclude 어노테이션이 붙은 컴포넌트)를 제외하고 필터로만 스캔
@ComponentScan(basePackages = "com.example.demo"
, useDefaultFilters = false
, includeFilters = {
@ComponentScan.Filter(type = FilterType.REGEX
, pattern = {".+DomainService$"})
})
, excludeFilters = {
@ComponentScan.Filter(type = FilterType.ANNOTATION
, pattern = {"Exclude.class"})
})
<!-- 기본 스캔 대상을 제외하고 필터로만 스캔(XML 기반 설정 방식) -->
<context:component-scan base-package="com.example.demo" use-default-filters="false">
<context:include-filter type="regex" expression=".+DomainService$"/>
</context:component-scan>
<!-- 기본 스캔 대상과 특정 컴포넌트(@Exclude 어노테이션이 붙은 컴포넌트)를 제외하고 필터로만 스캔 -->
<context:component-scan base-package="com.example.demo" use-default-filters="false">
<context:include-filter type="regex" expression=".+DomainService$"/>
<context:exclude-filter type="annotation" expression="com.example.demo.Exclude"/>
</context:component-scan>
만약 포함(include)하는 필터와 제외(exclude)하는 필터 모두에 해당하는 컴포넌트가 있는 경우 제외하는 필터가 포함하는 필터보다 우선순위가 높기 때문에 해당 컴포넌트는 스캔 대상에서 제외되고 결과적으로 IoC 컨테이너에도 등록되지 않는다.
'Spring' 카테고리의 다른 글
IoC와 DI(7. Bean 설정 분할과 profile별 설정) (0) | 2022.08.31 |
---|---|
IoC와 DI(6. Bean Scope) (0) | 2022.08.29 |
IoC와 DI(4. Bean 설정 방식, DI 방식) (0) | 2022.08.24 |
IoC와 DI(3. WebApplication의 IoC Container 구성) (0) | 2022.08.22 |
IoC와 DI(2. IoC Container의 종류와 사용방법) (0) | 2022.08.18 |