[김영한 스프링 review] 스프링 핵심 원리 - 기본편 (2)에서 이어지는 내용입니다.
의존 관계 주입 방법은 이전 포스팅에서도 한번 소개한 적이 있다.
한번 더 리마인드 하며 복습해본다.
생성자 주입 (추천)
Setter 주입
필드 주입 (가급적 지양)
메소드 주입
메소드 주입 예제
@Component
public class OrderServiceImpl implements OrderService {
private MemberRepository memberRepository;
private DiscountPolicy discountPolicy;
@Autowired // 아래 init 이라는 메소드의 MemberRepository, DiscountPolicy 의존성을 주입
public void init(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
}
@Autowired 를 통해 의존성을 주입받을 때 옵션을 지정할 수 있다.
아래 예제코드는 Spring Bean으로 관리되지 않는 클래스인 NoSpringBean
의 의존성을 주입받으려는 코드이다.
당연하게도, 의존성 자동 주입은 주입 대상 객체와 주입 받는 객체 둘 다 스프링이 관리하는 빈이어야 한다.
아래 예제에서 메소드가 위치한 클래스는 스프링 빈으로 관리되고, NoSpringBean은 (스프링 빈이 아닌) 일반 클래스라고 가정한다.
//호출 안됨
@Autowired(required = false)
public void setNoBean1(NoSpringBean bean) {
System.out.println("setNoBean1 = " + bean); // 호출 자체가 안됨
}
//null 호출
@Autowired
public void setNoBean2(@Nullable NoSpringBean bean) {
System.out.println("setNoBean2 = " + bean); // setNoBean2 = null
}
//Optional.empty 호출
@Autowired(required = false)
public void setNoBean3(Optional<NoSpringBean> bean) {
System.out.println("setNoBean3 = " + bean); // setNoBean3 = Optional.empty
}
롬복에서 지원하는 @RequiredArgsConstructor
어노테이션을 클래스에 붙여주면
컴파일 단계에서 final 필드들을 모아서 1개의 primary constructor를 생성한다.
이전에 기본 생성자 1개만 존재하면 @Autowired를 생략해도 된다고 작성했는데,
여기서도 그 원리가 적용된다. 즉,
@Component
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
...
}
에서 기본 생성자가 1개이기 때문에 @Autowired를 제거할 수 있고
@Component
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
...
}
@RequiredArgsConstructor
어노테이션을 통해 final 이 붙은 필드를 기본 생성자로 만들어주면
@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
...
}
처럼 쓸 수 있다.
참고로, 본인은 kotlin으로 개발하기 때문에 롬복은 안쓴다.
(kotlin은 class를 자바 바이트코드로 변환될 때 기본적으로 primary constructor를 1개 생성해주기 때문에..)
코틀린 코드는 이렇다. (코틀린 쓰세요, 두번 쓰세요.)
@Component
class OrderServiceImpl(
val memberRepository: MemberRepository,
val discountPolicy: DiscountPolicy,
) : OrderService {
...
}
@Autowired 는 타입(Type)으로 조회한다.
스프링은 빈 주입 시, 동일한 타입이 2개 이상 존재하면 NoUniqueBeanDefinitionException
예외를 발생시킨다.
@Component
public class FixDiscountPolicy implements DiscountPolicy {}
@Component
public class RateDiscountPolicy implements DiscountPolicy {}
@Autowired
private DiscountPolicy discountPolicy
// NoUniqueBeanDefinitionException: No qualifying bean of type 'hello.core.discount.DiscountPolicy' available: expected single matching bean but found 2: fixDiscountPolicy,rateDiscountPolicy
에러메시지를 자세히 읽어보면, 친절하게 1개의 빈을 기대했으나, fixDiscountPolicy, rateDiscountPolicy 2개를 발견했다고 알려준다.
이렇게 2개 이상의 Bean이 조회되는 경우 해결 방법을 알아보자.
@Autowired는 아래와 같은 순서로 Unique한 Bean을 찾아 주입한다.
타입 매칭 > 필드 이름 매칭 > 파라미터 이름 매칭
따라서, 타입 조회 시 2개 이상의 Bean이 조회됐다면, 이후에 필드 이름을 조회한다.
@Autowired
private DiscountPolicy rateDiscountPolicy
위와 같이 코드를 수정하면
DiscountPolicy 타입으로 조회된 FixDiscountPolicy, RateDiscountPolicy 중에
필드명이 일치한 RateDiscountPolicy 1개를 unique하게 선택한다.
@Qualifier는 추가적인 구분자를 명시하는 방법이다. (bean 이름을 지정하는것과는 다르다.)
아래와 같은 순서로 Unique한 Bean을 찾아 주입한다.
@Qualifier끼리 매칭 > 빈 이름 매칭 > NoSuchBeanDefinitionException 예외 발생
@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {}
@Component
@Qualifier("fixDiscountPolicy")
public class FixDiscountPolicy implements DiscountPolicy {}
@Autowired
public OrderServiceImpl(
MemberRepository memberRepository,
@Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy
) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
만약 로직에서 mainDiscountPolicy
라는 Qualifier를 찾지 못하면
Bean 이름이 mainDiscountPolicy
인것을 찾아 매핑하며,
그것도 없다면 NoSuchBeanDefinitionException 예외를 발생시킨다.
@Primary 는 우선순위를 정하는 방법이다.
@Autowired 시에 여러 빈이 매칭되면 @Primary 가 우선권을 가진다.
@Autowired
private DiscountPolicy discountPolicy
위 코드에서 rateDiscountPolicy 가 우선적으로 선택되길 희망한다면,
아래와 같이 RateDiscountPolicy에 @Primary를 사용할 수 있다.
@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {}
@Component
public class FixDiscountPolicy implements DiscountPolicy {}
@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {}
@Component
@Qualifier("mainDiscountPolicy")
public class FixDiscountPolicy implements DiscountPolicy {}
@Autowired
public OrderServiceImpl(
MemberRepository memberRepository,
@Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy
) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
위와 같이 RateDiscountPolicy가 Primary이고,
FixDiscountPolicy 클래스가 mainDiscountPolicy로 Qualifier가 지정되고, 로직에서도 사용한다면 어떻게 될까?
Primary는 단순히 우선권을 부여하는 것이고
Qualifier는 상세하게 추가 구분자를 부여한다.
스프링은 항상 구체적인것을 우선적으로 사용하기 때문에, Qualifier를 우선적으로 매칭하여
FixDiscountPolicy
가 주입된다.
분기에 따라 Bean을 다르게 사용해야할 수 있다.
(할인 정책을 선택적으로 사용할 수 있어야 하는 경우)
이 때는 List와 Map으로 중복되는 Bean들을 모두 받을 수 있다.
하지만 여러명이 협업하여 개발하는 특성 상, List 혹은 Map으로 받으면
해당 타입의 Bean들이 어떤게 있는지 구분하기가 어렵기 때문에 사용을 지양한다.
@Service
class DiscountService {
private final Map<String, DiscountPolicy> policyMap;
private final List<DiscountPolicy> policies;
// 생성자가 1개이기 때문에 Autowired 생략 가능
public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) {
this.policyMap = policyMap;
this.policies = policies;
System.out.println("policyMap = " + policyMap);
System.out.println("policies = " + policies);
}
public int discount(Member member, int price, String discountCode) {
// 분기에 따라 Bean을 선택적으로 사용
}
}
네트워크 IO 등을 지원하는 빈들은 초기화 시점에 커넥션풀을 미리 생성해두어야 한다.
또한, 사용 후 스프링이 종료되기 전에 메모리를 해제하는것 또한 잊지 않아야 한다.
스프링은 위와 같은 기능들을 제공하는데, 대표적으로 아래와 같다.
스프링 빈의 이벤트 라이프사이클
스프링 컨테이너 생성 > 스프링 빈 생성 > 의존관계 주입 > 초기화 콜백 > 사용 > 소멸전 콜백 > 스프링 종료
초기화 콜백: 빈이 생성되고, 빈의 의존관계 주입이 완료된 후 호출
소멸전 콜백: 빈이 소멸되기 직전에 호출
InitializingBean : afterPropertiesSet() 메서드로 초기화를 지원한다.
DisposableBean : destroy() 메서드로 소멸을 지원한다.
public class NetworkClient implements InitializingBean, DisposableBean {
...
@Override
public void afterPropertiesSet() throws Exception {
// 빈이 초기화될 때 수행할 작업
}
@Override
public void destroy() throws Exception {
// 빈이 소멸될 때 수행할 작업
}
}
인터페이스를 사용하는 초기화, 종료 방법은 스프링 초창기에 나온 방법들이고, 지금은 다음의 더 나은 방법 들이 있어서 거의 사용하지 않는다.
구성 정보(configuration) 에 스프링 빈을 등록할 때, 초기화/소멸 메소드를 지정할 수 있다.
구성 정보에 등록하는 정보이기 때문에, 외부 라이브러리에도 초기화/소멸 기능을 적용할 수 있다.
@Configuration
static class LifeCycleConfig {
@Bean(initMethod = "init", destroyMethod = "close")
public NetworkClient networkClient() {
return new NetworkClient();
}
}
public class NetworkClient {
... // 비즈니스 로직
public void init() {
// 초기화 로직
}
public void close() {
// 소멸 로직
}
}
구성 정보에 init메소드를 초기화 대상으로, destroyMethod를 소멸 대상 메소드로 지정했다.
근데 여기서 이상한 점이 있다.
외부 라이브러리에도 초기화/소멸 로직을 적용할 수 있다며?
구성정보에서 호출하는 초기화/소멸 메소드명은 빈에서 설정하는게 아니라
빈 등록 대상 코드에 존재하는데?
관련해서 찾아보니, 인프런에 동일한 질답이 있었다.
결론을 먼저 얘기하자면, 외부 라이브러리에 초기화/소멸 메소드가 정의되어있어야 한다
는건데
외부 라이브러리에 지정이 안돼있으면 못쓰는건가?
외부 라이브러리에서는 걔네를 메소드로만 지정을 해놓나? 애초에 초기화/소멸 로직을 구현하지 않고?
라는 의문점이 든다,, 요건 공부좀 더하고 예제랑 같이 글을 보충해두겠다.
@Bean의 destroyMethod 는 기본값이 (inferred)
(추론)으로 등록되어 있다.
이 추론 기능은 빈 등록 대상 내에 close, shutdown 라는 이름의 메서드가 있으면 자동으로 호출해준다.
추론 기능을 사용하기 싫으면 destroyMethod=""
처럼 빈 공백을 지정하면 된다.
어노테이션을 사용하여 초기화/소멸 메소드를 지정할 수도 있다.
@PostConstruct: 초기화 메소드를 지정
@PreDestroy: 소멸 메소드를 지정
* 참고로, 이 어노테이션들은 스프링의 어노테이션이 아닌
javax.annotation
패키지에 존재하는 어노테이션이다.
따라서 스프링으로 관리되는 객체가 아니더라도 초기화/소멸을 위해 사용할 수 있다.
public class NetworkClient {
... // 비즈니스 로직
@PostConstruct
public void init() {
// 초기화 시 호출
}
@PreDestroy
public void close() {
// 소멸시 호출
}
}
javax.annotation.PostConstruct
패키지에 존재하는 어노테이션이다.빈 스코프란, 빈의 생명주기를 의미한다.
즉, 빈이 생성되어서부터 소멸되기까지의 범위를 말하는데,
우리가 지금까지 알아본 것은 Singleton Scope이다.
스프링 공식 문서에서 예제와 자세한 내용을 확인할 수 있다.
아래와 같이 스코프를 지정할 수 있다.
@Component
@Scope("prototype")
public class MyBean {}
@Bean
@Scope("prototype")
MyBean myBean() {
return new MyBean();
}
프로토타입 스코프는 일반적으로 객체 인스턴스를 생성하는것 처럼, 매번 새로운 객체가 생성된다.
그렇다면, 그냥 로직에서 new MemberService();
로 인스턴스를 생성하는 것과 어떤 차이가 있을까?
싱글톤 빈의 요청 과정 그림은 이전 포스팅에서 확인할 수 있다.
프로토타입 빈은 스프링에서 생성과 의존관계 주입까지만 관여하므로, @PreDestroy
같은 소멸 기능은 수행되지 않는다.
@Component
@RequiredArgsConstructor
class SingletonBean {
private final PrototypeBean prototypeBean;
...
}
과 같이 싱글톤 내에서 프로토타입 빈이 사용되는 경우,
SingletonBean 내의 PrototypeBean은 SingletonBean이 생성되는 최초 1회에만 초기화가 된다. (사용 할 때마다 새로 생성되는 것이 아니다!)
즉, 싱글톤 내에 있는 프로토타입 빈은 싱글톤처럼 사용된다.
싱글톤 빈 내에부에 있는 프로토타입 빈을 호출할 때마다 새로운 객체를 얻기 위해서는, 간단하게도 호출하는 시점에 객체를 새로 만들면 된다.
@Autowired
private ApplicationContext ac;
public void logic() {
PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
prototypeBean.doSomething();
}
하지만, 이 코드는 스프링 컨테이너를 주입받기 때문에 스프링 컨테이너에 종속적이게 된다.
위와 같이 의존성을 외부에서 주입(DI) 받는게 아니라, 의존성을 찾는것을 의존관계 조회, 의존관계 탐색 (DL: Dependency LookUp) 이라고 한다.
그리고, Provider는 이러한 DL 기능을 제공한다.
ObjectFactory는 의존관계를 검색하는 getObject()
메소드만 존재하며
ObjectProvider는 이러한 ObjectFactory를 상속받아 다양한 기능을 제공한다.
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;
public void logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
prototypeBean.doSomething();
}
이제는 ApplicationContext를 직접 주입받아 스프링 컨테이너에 종속적인 코드가 아니다.
JSR-330 Provider는 스프링의 기능이 아닌 자바의 기능이다.
그래서 이 기능을 사용하기 위해서는 build.gradle(.kts) 에 의존성을 추가해줘야한다.
*스프링부트 3.0 미만
build.gradle : implementation 'javax.inject:javax.inject:1'
build.gradle.kts : implementation("javax.inject:javax.inject:1")
*스프링부트 3.0 이상
build.gradle : implementation 'jakarta.inject:jakarta.inject-api:2.0.1'
build.gradle.kts : implementation("jakarta.inject:jakarta.inject-api:2.0.1")
@Autowired
private Provider<PrototypeBean> provider;
public void logic() {
PrototypeBean prototypeBean = provider.get();
prototypeBean.doSomething();
}
웹 스코프는 웹 환경에서만 동작한다.
웹 스코프는 프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리한다.
따라서 @PostConstruct 종료 메서드가 호출된다.
웹 스코프에는 위에서 기술한것 처럼
request, session, application, websocket
이 존재한다. 각각의 작동원리는 비슷하기 때문에, request만 다루도록 한다.
웹 환경을 만들기 위해서는 의존성을 추가해야한다.
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation("org.springframework.boot:spring-boot-starter-web")
을 추가하고, gradle reload 후에 프로젝트를 실행하면 내장 톰캣이 8080프토로 뜨는것을 확인할 수 있다.
포트를 변경하고 싶다면 application.properties 파일에서 server.port=포트
로 변경하면 된다.
@Component
@Scope(value = "request")
public class MyLogger {
private String uuid;
private String requestURL;
public void setRequestURL(String requestURL) {
this.requestURL = requestURL;
}
public void log(String message) {
System.out.println("[" + uuid + "]" + "[" + requestURL + "] " + message);
}
@PostConstruct
public void init() {
uuid = UUID.randomUUID().toString();
System.out.println("[" + uuid + "] request scope bean create:" + this);
}
@PreDestroy
public void close() {
System.out.println("[" + uuid + "] request scope bean close:" + this);
}
}
@Controller
@RequiredArgsConstructor
public class LogDemoController {
private final LogDemoService logDemoService;
private final MyLogger myLogger;
@RequestMapping("log-demo")
@ResponseBody
public String logDemo(HttpServletRequest request) {
String requestURL = request.getRequestURL().toString();
myLogger.setRequestURL(requestURL);
myLogger.log("controller test");
logDemoService.logic("testId");
return "OK";
}
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final MyLogger myLogger;
public void logic(String id) {
myLogger.log("service id = " + id);
}
}
각 사용자별로 UUID, Request URL과 함께 로깅하는 기능이다.
스프링부트를 실행하고 GET /log-demo
를 요청하면 원하는 결과를 얻을 수 있을거라 생각했는데, 의외로 어플리케이션 실행 단계부터 예외가 발생한다.
Error creating bean with name 'myLogger': Scope 'request' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton;
request 스코프는 사용자의 요청이 들어와야 초기화가 되는데, 스프링부트가 실행되는 시점에는 초기화가 되어있지 않기 때문이다.
앞에서 알아봤던 ObjectProvider 를 사용하면 쉽게 해결할 수 있다.
@Controller
@RequiredArgsConstructor
public class LogDemoController {
private final ObjectProvider<MyLogger> myLoggerProvider;
@RequestMapping("log-demo")
@ResponseBody
public String logDemo(HttpServletRequest request) {
...
MyLogger myLogger = myLoggerProvider.getObject();
myLogger.setRequestURL(requestURL);
...
}
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final ObjectProvider<MyLogger> myLoggerProvider;
public void logic(String id) {
MyLogger myLogger = myLoggerProvider.getObject();
myLogger.log("service id = " + id);
}
}
해결은 됐지만, 코드도 지저분하고, 매번 .getObject()
를 사용하는것도 번거롭다.
이것은 프록시를 사용하여 해결할 수 있다.
Provider를 사용하기 이전 코드 기준으로, MyLogger의 Scope에 proxyMode 옵션을 부여하자.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
...
}
ScopedProxyMode.TARGET_CLASS -> 클래스에 사용하는 경우
ScopedProxyMode.INTERFACE -> 인터페이스에 사용하는 경우
위 코드로 실행하면 Provider를 사용한것 처럼 잘 동작될 것이다.
먼저 주입된 myLogger를 확인해보자.
System.out.println("myLogger = " + myLogger.getClass());
출력결과
myLogger = class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$b68b726d
MyLogger에 CGLIB 프록시가 적용된 것을 확인할 수 있다.
스프링이 실행되면서 빈이 주입될 때, 우리가 만든 MyLogger가 아닌 프록시 객체가 주입되며
실제 호출 시점에서는 프록시 객체가 실제 MyLogger를 반환하여 지연생성이 가능해진다.