04. DI 처리 - 묵시적 DI
- -
묵시적 DI
앞선 포스트에서 @Configuration과 @Bean을 이용해서 명시적으로 빈을 선언하는 방법에 대해 알아보았다. 이 방식은 빈 설정이 빈 클래스 외부 즉 @Configuration 클래스에 존재하는 형태로 비지니스 로직과 빈 관리 로직이 잘 분리되어 관심사의 분리라는 주제에 아주 이상적이다.
단 이 방식은 빈을 만들 때 마다 설정 파일에 명시적으로 빈을 선언해주어야 한다.
묵시적 빈 설정
묵시적인 빈 설정 방식은 빈으로 사용할 클래스에 @Component 라고 선언한다. 즉 빈에 대한 설정이 빈 클래스 내부에 존재하기 때문에 관심 사항의 분리라는 부분에서는 일보 후퇴하는 복합적인 관심사를 다루게 된다.
@Component라고 선언된 클래스는 바로 빈이 되는것은 아니고 뒤에서 언급할 @ComponentScan을 통한 scan 과정을 거쳐서 빈으로 등록된다.
@Component
@Component는 빈으로 사용될 클래스에 선언하는 애너테이션이다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Component {
String value() default ""; // 생성되는 빈의 이름을 재정의 하려는 경우 사용
}
스프링은 기본적으로 타입 기반으로 빈을 관리하는데 동일한 타입의 빈이 2개 이상인 경우 이름이 중요한 구분자 역할을 한다. 따라서 @Component로 빈을 등록할 때도 이름에 대한 지정이 필요하다. @Component를 이용해 만들어진 빈의 이름은 클래스 이름이 Pascal case인 경우 camel case로 변경해서 사용하고 그렇지 않은 경우 클래스 이름을 그대로 사용한다.
pascal case 인 경우: IronMan -> ironMan
pascal case가 아닌경우: SPhone -> SPhone
그런데 만약 이름 기준으로 빈을 사용하다가 클래스 이름을 refactoring 하는 일이 발생하면 어떤 일이 벌어질까?
@Component
public class IronMan{}
ctx.getBean("ironMan", IronMan.class);
@Component
public class SteelMan{}
ctx.getBean("ironMan", SteelMan.class);
빈의 이름은 변경되겠지만 참조하는 이름은 단순 문자열이어서 변경되지 않기 때문에 참조되는 곳을 모두 쫒아다니면서 변경해줘야 한다. 따라서 이름 기반으로 빈을 구별해야 한다면 자동으로 생성되는 이름에 의존하지 말고 value속성을 이용해 빈에 적절한 이름을 명시적으로 주기를 권장한다.
@Autowired
빈을 등록할 때는 단순히 빈 객체를 생성하는 것 뿐 아니라 의존성에 대한 주입 즉 DI 과정이 필요하다. @Component는 단지 빈 객체를 생성할 뿐이다. 그럼 주입은 어떻게 처리될까?
빈 주입은 @Autowired를 사용한다. 말그대로 자동으로 연결해버린다는 말이다.
@Target({ElementType.CONSTRUCTOR, ElementType.FIELD,
ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Autowired {
boolean required() default true;
}
@Target 정보를 살펴보면 @Autowired는 생성자, 필드, 메서드, 애너테이션에 선언할 수 있다. 다음은 @Autowired의 특징들이다.
- 타입 기반으로 빈을 자동 주입하며 해당 타입의 빈이 반드시 하나만 존재해야 한다. 만약 타입 충돌이 발생할 경우 이름 기반의 사용을 위해서는 @Qualifier를 사용한다.
- 생성자와 메서드에 사용 시 파라미터 모두가 스프링 빈이어야 한다. 이때 여러 개의 파라미터에 대해 모두 주입이 이뤄진다. 또는 @Value에 의한 scalar 값 주입도 가능하다.
- 한 클래스에 @Autowired가 적용되는 생성자는 최대 하나만 가능하며 메서드는 생성자와 달리 여러번 사용 가능하다.
- 생성자가 1개일 경우 어차피 그 생성자가 호출되어야 객체가 생성되므로 @Autowired를 생략해도 된다.
앞선 포스트의 단위 테스트에서 묻지마 코딩으로 ApplicationContext에 @Autowired를 적용해서 사용한 적이 있다. ApplicationContext도 알고보면 하나의 빈이었던 샘이다.
@ComponentScan
@Component를 선언했다고 해서 빈이 뚝딱 만들어지지는 않는다. 실제로 빈을 만들기 위해서는 @Configuration에서 @ComponentScan을 통해 해당 빈을 찾아주는 과정이 필요하다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Repeatable(ComponentScans.class)
public @interface ComponentScan {
@AliasFor("basePackages")
String[] value() default {};
@AliasFor("value")
String[] basePackages() default {}; // @Component를 찾아볼 package 등록
}
package com.doding.di;
@Component
public class Component1{}
package com.doding.di.some;
@Component
public class Component2{}
@Configuration
@ComponentScan({"com.doding"})
public class MyConfig{}
그런데 여기서 스프링 부트의 친절함이 묻어나는 부분이 있다. 앞서 살펴봤던 @SpringBootApplication은 아주 물건이다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootConfiguration // 내부적으로 @Configuration을 갖는다.
@EnableAutoConfiguration
@ComponentScan(...)
public @interface SpringBootApplication {
...
}
@SpringBootApplication은 @ComponentScan을 통해서 하위 패키지에서 빈으로 쓸만한 녀석들이 있는지 이미 찾아보고 있다. 따라서 우리가 빈을 @SpringBootApplication이 선언된 클래스의 하위 패키지에 선언한다면 번거롭게 추가로 스캔할 필요가 없다. 물론 필요하다면 다른 곳에 배치할 수도 있다.
빈 생성 실습
사용할 빈 클래스 작성
기존의 프로젝트에 다음과 같은 구조의 클래스를 추가하고 묵시적 빈 설정으로 Spring Container에 등록해보자.
package com.doding.di.heroes;
public interface Heroes {}
IronMan 클래스와 Hulk 클래스는 모두 @Component 선언이 되어있으므로 빈으로 활용될 계획이다.
package com.doding.di.heroes;
import org.springframework.stereotype.Component;
@Component // 묵시적 빈 선언: 이 클래스는 빈이 될꺼다. 이름은?
public class IronMan implements Heroes{ }
package com.doding.di.heroes;
import org.springframework.stereotype.Component;
@Component
public class Hulk implements Heroes { }
HulkBuster는 IronMan을 상속받았음을 기억하자.
package com.doding.di.heroes;
import org.springframework.stereotype.Component;
@Component("hb") // 빈의 이름을 명시적으로 부여
public class HulkBuster extends IronMan { }
다음은 IronMan등을 사용하는 클래스인 Avengers이다. @Autowired의 사용에 눈여겨 보자.
package com.doding.di.heroes;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
@Component
@Slf4j
public class Avengers {
private IronMan iman;
private Hulk hulk;
private HulkBuster hb;
@Autowired // 생성자를 통한 주입: 하나의 생성자만 있을 경우는 생략 가능
public Avengers(IronMan iman, Hulk hulk) {
log.debug("avengers 생성, iman: {}, hulk: {}", iman, hulk);
this.iman = iman;
this.hulk = hulk;
}
@Autowired // setter를 통한 주입
public void setHulkBuster(HulkBuster hb) {
log.debug("hulkBuster 설정: {}", hb);
this.hb = hb;
}
}
Avengers에서는 2개의 @Autowired가 설정되었다. 먼저 생성자에서는 IronMan과 Hulk 타입의 빈을 주입 받고 setHulkBuster 메서드에서는 HulkBuster 타입의 빈을 주입 받는다.
위 코드는 잘 동작할까? 단위테스트를 통해 우리 생각대로 빈이 생성되었는지 살펴보자. 앞선 단위 테스트에서는 ApplicationContext를 @Autowired로 주입 받은 후 getBean 메서드를 사용했는데 어차피 빈을 사용할 꺼라면 해당 빈을 직접 주입 받아도 된다. 이렇게 될 수 있는 이유는 단위테스트 클래스에 선언된 @SpringBootTest 덕분인데 이녀석은 @SpringBootApplication의 설정을 이용해서 애플리케이션의 모든 설정 정보를 활용한다.
package com.doding.di.heroes;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import com.doding.di.heroes.Avengers;
import com.doding.di.heroes.HulkBuster;
import com.doding.di.heroes.IronMan;
@SpringBootTest
public class BootHeroesTest {
@Autowired
Avengers avenger;
@Test
public void avengerTest() {
assertNotNull(avenger);
}
}
정답은 실패다! 위 테스트를 실행시켜 보면 다음과 같은 오류 메시지가 확인된다.
Error creating bean with name 'avengers' defined in file [Avengers.class]:
Unsatisfied dependency expressed through constructor parameter 0:
- No qualifying bean of type 'com.doding.di.heroes.IronMan' available:
- expected single matching bean but found 2: hulkBuster,ironMan
오류의 내용을 살펴보면 avengers를 생성하면서 실패했는데 생성자의 0번째 파라미터로 IronMan 타입의 빈 하나를 기대했는데 hulkBuster와 ironMan 2녀석이 있어서 누구를 넣어야 할지 모르겠다는 내용이다. HulkBuster가 IronMan을 상속 받았기 때문에 둘 다 IronMan 타입이라고 이야기 할 수 있는 것이다.
역시 이름 기반의 빈 선정 메커니즘이 필요하다.
@Qualifier
묵시적인 빈 등록 방식에서 @Autowired는 타입 기반으로 빈을 자동 주입한다. 이름 기반으로 주입될 빈을 한정짓기 위해서는 @Qualifier 애너테이션이 사용된다.
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE,
ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Qualifier {
String value() default "";
}
@Qualifier는 @Autowired와 함께 사용되며 value 속성에 주입할 빈의 이름을 적어주면 된다.
@Qualifier를 쓰면서 많이 하는 실수가 생성자에 사용하려는 경우이다. @Autowired가 생성자에 사용되기 때문에 많이 헤깔려하는데 @Qualifier는 생성자에 사용할수 없고 생성자의 파라미터에 하나씩 적용해 주어야 한다.
다음과 같이 코드를 수정하고 실행해보자.
public Avengers(@Qualifier("ironMan") IronMan iman, Hulk hulk) {
log.debug("avengers 생성, iman: {}, hulk: {}", iman, hulk);
this.iman = iman;
this.hulk = hulk;
}
이제 테스트가 잘 동작하는 것을 볼 수 있다.
이번에는 Field 에 @Autowired와 @Qualifier를 적용해보자.
@Autowired
@Qualifier("hb") // IronMan 타입 중 hb라는 이름으로 등록된 빈은?
IronMan iman;
@Test
public void ironManTest() {
assertEquals(iman.getClass(), HulkBuster.class); // iman은 HulkBuster 타입의 빈
}
스프링 5.X에서는 @Autowired로 빈을 주입할 때 타입이 충돌하는 경우 다음의 절차를 거쳤다.
@Qualifier("name")에 해당하는 빈 주입 > 파라미터 이름에 해당하는 빈 주입 > 예외 발생
하지만 6.1.13 버전에서 확인한 바로는 2번째 단계가 더이상 동작하지 않고 있다.
@RequiredArgsConstructor에서 빈의 타입이 충돌하는 경우 2단계 방법을 썼었는데 이제는 여의치 않다. lombok.config 파일에 작성하는 방법등이 있긴 한데 그냥 생성자를 만들어 쓰는걸로 타협(?) ㅎ
추가적인 에너테이션들
스테레오타입 애너테이션(Stereotype Annotation)
빈을 묵시적으로 선언하기 위해서 @Component를 사용할 수 있었는데 이녀석은 날것의 타입으로 단순히'빈'이라는 정보 이외에 다른 의미를 주지 못한다. 스프링에서는 @Component를 용도에 따라 미리 여러 타입으로 정형화 해 놓았는데 이것을 스테레오 타입이라고 부른다. 이를 통해 용도별로 빈을 구분해서 관리할 수 있고 특정 타입의 빈에 AOP를 적용하는 등의 작업이 가능하다.
스테레오타입 애너테이션들은 내부적으로 @Component를 포함하고 있기 때문에 @ComponentScan을 통해서 빈으로 관리되는것은 기본이다.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Repository {
@AliasFor(
annotation = Component.class
)
String value() default "";
}
다음은 스테레오 타입 애네테이션의 종류이다.
annotation | 설명 |
@Repository | MVC에서 Model의 Repository(DAO) 계열 빈에 사용 RuntimeException을 DataAccessException으로 변환 가능(https://goodteacher.tistory.com/698) |
@Service | MVC에서 Model의 Service 계열의 빈에 사용 |
@Controller | MVC에서 controller로 사용되는 빈에 사용 |
@Configuration | java 기반의 메타정보 클래스에 사용 |
@Component | 다른 스테레오 타입 애너테이션에 해당되지 않을 경우 사용 |
@Value
@Autowired는 타입 기반으로 객체를 주입 받을 때 사용하는 애너테이션이다. 이에 반해 @Value는 객체가 아닌 스칼라 값(문자열, 숫자 등)을 주입 받는데 사용된다. 일전에 application.yml의 설정을 확인할 때 사용했던 경험이 있다.
@Target({ElementType.FIELD, ElementType.METHOD,
ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Value {
String value(); // 반드시 설정되어야 한다.
}
@Value는 Field, Method, Parameter에 선언할 수 있고 value 속성이 반드시 설정되어야 한다.
일반적으로 @Value는 .properties 또는 .yml 등에 설정된 값을 참조할 경우 사용된다.
// application.yml
server:
url: localhost:8080
connection:
max-pool-size: 5
단위 테스트를 통해서 위 값들을 사용해보자. application.properties의 값을 참조할 때는 ${} 안에 사용할 property를 넣어주면 된다. 편리한 점은 maxPoolSize 처럼 원하는 형태로 자유로운 형변환이 가능하다는 점이다.
package com.doding.di.heroes;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class EtcAnnotations {
@Value("${server.url}")
String serverUrl;
@Value("${connection.max-pool-size}")
int maxPoolSize;
@Test
public void valueTest() {
assertEquals(serverUrl, "localhost:8080");
assertEquals(maxPoolSize, 5);
}
}
@Inject와 @Resource
@Autowired와 유사한 역할을 하는 애너테이션으로 @Inject와 @Resource가 있다.
먼저 @Inject는 자바 표준인 JSR-330에 소개됐는데 역할이 @Autowired와 동일하다. 차이점은 주입하려는 대상이 없을 때 @Autowired는 예외를 발생시키는 반면 @Inject는 null이 할당된다. (@Autowired도 required=false 속성을 사용하면 null이 할당된다.)하지만 무려 표준임에도 불구하고 대부분 코드에서는 @Autowired가 사용된다. 그래서인지 JDK 17에서는 제외되었다.
@Resource는 이름 기반으로 빈을 주입 받는다는 점에서 @Autowired + @Qualifier의 형태라고 볼 수 있다. @Resource는 다음의 절차로 빈을 주입한다.
- 타입 기반으로 빈을 주입한다.
- 동일한 타입의 빈이 2개 있으면 이름 기반으로 필터링하는데 기본적으로 변수의 이름에 해당하는 빈을 찾는다.
- 만약 빈의 이름을 지정하려는 경우는 name 속성을 이용한다.
참고로 @Autowired도 사실 1차적으로 타입으로 검색하고 동일 타입의 빈이 있다면 변수의 이름으로 빈을 찾는다. @Resource와의 차이점은 새로운 이름을 지정할 수 없다는 점이다.
@Autowired
@Qualifier("hulkBuster")
IronMan imanAutowired;
@Resource(name = "hulkBuster")
IronMan imanResource;
@Test
public void resourceTest() {
assertEquals(imanAutowired, imanResource);
}
@SpringBootApplication
@SpringBootApplication은 앞서 잠깐 언급 했듯이 SpringBoot 애플리케이션의 시작점인 클래스에 선언해주는 애너테이션이다.
@Target(ElementType.TYPE)
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { ...})
public @interface SpringBootApplication {
@AliasFor(annotation = EnableAutoConfiguration.class)
Class<?>[] exclude() default {};
@AliasFor(annotation = ComponentScan.class, attribute = "basePackageClasses")
Class<?>[] scanBasePackageClasses() default {};
}
@SpringBootApplication은 다음 3개의 애너테이션이 합쳐진 것이다.
- @SpringBootConfiguration: @SpringBootApplication이 선언된 클래스가 메타정보 설정 클래스로 동작하도록 한다. 내부적으로 @Configuration을 가지고 있기 때문에 간단히 @Bean으로 다른 빈에 대한 정의도 가능하다. 하지만 메타 정보의 유지 보수를 위해 용도에 따라 빈 선언을 별도의 @Configuration이 적용된 클래스로 분리해서 관리하는 것을 권장한다.
- @EnableAutoConfiguration: XXXAutoCinfiguration들을 이용해 설정을 자동화하는 기능을 활성화 시키는 것이다.
- 예를 들어 의존성에서 spring-web-mvc.jar를 발견하면 WebMvcAutoConfiguration을 이용해 DispatcherServlet과 web.xml을 구성한다.
- 만약 자동으로 등록된 설정을 제거하고 싶다면 exclude 속성을 이용할 수 있다.
- @ComponentScan: @SpringBootApplication이 선언된 클래스의 하위 패키지를 스캔해서 @Component 들을 빈으로 등록할 수 있게 한다.
DI 방법의 선택
빈 주입 방법 비교
스프링에서 빈을 주입할 수 있는 방법은 생성자, 세터, 그리고 필드 주입이 있다.
@Component
public class Avengers {
@Autowired
private IronMan iman; // Field 주입
@Autowired // 생성자 주입
public Avengers(IronMan iman) {
this.iman = iman;
}
@Autowired // setter를 통한 주입
public void setIronMan(IronMan iman) {
this.iman = iman;
}
}
- 생성자 주입: 가장 권장 되는 방식으로 빈의 모든 의존성이 반드시 필요하다는 것을 명시적으로 보여줄 수 있다.
- 많은 경우 field를 blank final로 선언하고 생성자에서 주입하는데 lombok의 @RequiredArgsConstructor를 활용한다.
- 혹시나 발생할 수 있는 빈의 순환 의존성 문제를 컴파일 타임에 방지할 수 있다.
- 세터 주입: 선택적인 의존성을 가진 빈의 주입에 적합하다.
- 필드 주입: 코드가 가장 간결하기는 하지만 테스트하기가 어렵고 빈의 불변성을 보장하지 않으므로(객체 생성 후 값이 변경될 수 있음) 권장하지 않는다. 따라서 단위테스트 처럼 특별한 목적을 위해 작성된 경우에만 사용하기를 권한다.
참고로 주입의 적용 순서는 생성자 > 필드 > 세터이다.
명시적 DI vs 묵시적 DI
이제까지 명시적 DI와 묵시적 DI 두가지 방법을 학습했는데 어떤 DI를 사용하는 것이 유리할까?
명시적 DI | 묵시적 DI | |
관심사 분리 | 비즈니스 로직과 빈 관리 로직의 분리 가능 - 의존성 주입 코드가 명확하게 보임 |
비지니스 로직과 빈 관리 로직의 결합 - 전체적인 빈의 구조를 파악하기 어려움 |
설정 작성 | 빈 설정 코드가 별도로 관리되어야 함 - 의존성 주입 코드가 복잡해질 수 있음 |
개발자가 의존성 주입 코드를 작성하지 않아도 됨 |
외부 라이브러리를 빈으로 활용 | 가능 | 제한적 |
일단 관리 측면에서는 명시적 DI가 좋아 보이지만 직접적으로 코드를 작성해야 한다는 점은 큰 부담이다.그래서 일반적으로 묵시적 DI가 선호되기는 한다. 그리고 요즘은 툴들이 좋아서 전체적인 빈의 구조를 쉽게 보여주기 때문에 큰 문제가 되지 않는다.
프로젝트에서 어떤 DI 방식을 사용할지는 개발 상황과 요구사항에 따라 달라질 수 있다. 복잡성이 높거나 외부라이브러리를 많이 사용하는 프로젝트에서는 명시적 DI가 유리할 수 있고 간단한 프로젝트에서는 묵시적 DI가 유리할 수 있다. 또한 한 가지 방식만 고집할 필요도 없다.
향후 예에서는 묵시적 DI를 기본으로 하고 필요 시 명시적 DI를 사용하기로 하자.
'Spring Core > 02. Spring Container와 빈 관리' 카테고리의 다른 글
06. 빈의 스코프 (0) | 2024.02.22 |
---|---|
05. Profile (0) | 2020.06.17 |
03. DI 처리 - 명시적 DI (0) | 2020.06.16 |
02. Dependency와 Dependency Injection (0) | 2020.06.15 |
01. Spring Application의 구성 요소 (0) | 2020.06.15 |
소중한 공감 감사합니다