Open seungriyou opened 10 months ago
객체 지향 애플리케이션을 개발할 수 있도록 도와주는 프레임워크이다.
객체지향을 통해 유연하고 변경이 용이한 소프트웨어를 설계할 수 있으며, 이는 다형성에 의해 달성된다.
다형성에 대해서 알아보기 위해 세상을 역할(= 인터페이스) 과 구현으로 나누어보자.
운전자는 자동차 역할에 대해서만 의존하고 있기 때문에, 자동차 구현이 바뀌어도 운전자에게 영향을 미치지 않는다. 즉, 새로운 자동차가 나와도 운전자는 새로운 것을 배우지 않아도 된다.
더 나아가 클라이언트(= 운전자)에게 영향을 주지 않고 새로운 기능을 제공할 수도 있게 된다.
이처럼 역할과 구현을 분리하는 것의 장점을 정리하면 다음과 같다.
[!Note] 따라서 객체 설계 시 역할을 먼저 부여하고, 그 역할을 수행하는 구현 객체를 만드는 것이 좋다.
자바의 오버라이딩을 생각해보면 오버라이딩 된 메서드가 실행되게 되는데, 이러한 다형성을 통해서 인터페이스를 구현한 객체를 실행 시점에 유연하게 변경할 수 있다. (클래스 상속 관계에서도 적용)
다형성의 본질을 이해하려면 협력이라는 객체 사이의 관계에서 시작해야 한다.
객체의 협력 관점에서 살펴보면, 클라이언트는 요청, 서버는 응답을 하는 객체이고, 서로 협력 관계를 가지게 된다.
[!Note] 다형성의 본질 = 클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경할 수 있다.
스프링은 다형성을 활용해서 역할과 구현을 편리하게 다룰 수 있도록 지원한다.
ex. 제어의 역전(IoC), 의존관계 주입(DI)
단일 책임 원칙 (SRP, Single Responsibility Principle)
개방-폐쇄 원칙 (OCP, Open/Closed Principle)
리스코프 치환 원칙 (LSP, Liskov Substitution Principle)
인터페이스 분리 원칙 (ISP, Interface Segregation Principle)
의존관계 역전 원칙 (DIP, Dependency Inversion Principle)
추상화에 의존해야하며, 구체화에 의존하면 안 된다.
의존 = 내가 어떤 코드에 대해 알기만 해도 의존하는 것이다!
의존성 주입(DI) 은 이 원칙을 따르는 방법 중 하나이다.
즉, 구현 클래스에 의존하지 말고, 인터페이스(= 역할)에 의존하라는 뜻이다.
[!Note] 이때, OCP와 DIP가 매우 중요하다. 다음의 코드에서…
public class MemberService { private MemberRepository memberRepository = new MemoryMemberRepository(); }
- 인터페이스(
MemberRepository
) 뿐만 아니라 구현 클래스(MemoryMemberRepository
)에도 의존하고 있어 DIP를 위반하고 있다. (직접 선택 = 의존)- 따라서 구현 클래스를
MemoryMemberRepository
에서JdbcMemberRepository
로 변경하려면 코드를 변경해야하므로 OCP도 위반된다.
[!Tip] 다형성만으로는 구현 객체를 변경할 때 클라이언트 코드도 함께 변경되므로 OCP, DIP를 지킬 수 없다. (위의 코드 예시 참고)
따라서 무언가가 더 필요한데, 이때 필요한 것은 의존성 주입(DI) 이다!
스프링은 DI(Dependency Injection, 의존성 주입) 컨테이너를 제공함으로써, 다형성, OCP, DIP를 가능하게 한다.
이를 통해 클라이언트 코드의 변경 없이 기능의 확장이 가능하다. (부품 교체하듯이)
peerloop에서 FastAPI로 스프링 컨테이너를 직접 만들려고 했었다..^^
이상적으로는 모든 설계에 인터페이스를 부여하자.
구체적인 기술이 정해지지 않은 경우, 구체적인 할인 정책이 정해지지 않은 경우 등에도 개발이 가능하다.
하지만 인터페이스를 도입하면 추상화라는 비용이 발생하기 때문에, 기능을 확장할 가능성이 없다면 구체 클래스를 직접 사용하고 향후 꼭 필요할 때 리팩터링하여 인터페이스를 도입하는 것도 방법이다.
추상화 → 한 번 더 타고 들어가야 코드를 확인할 수 있다는 단점이 있다.
기획자들도 볼 수 있는 다이어그램
개발자가 작성하는 클래스 다이어그램 (정적)
실제로 실행 시점에 클라이언트가 실제 사용하는 인스턴스를 나타내는 객체 다이어그램 (동적)
주문 도메인 협력, 역할, 책임
주문 도메인 전체 (역할 + 구현)
클래스 다이어그램
객체 다이어그램
저장소와 할인 정책을 변경하더라도 주문 서비스를 변경할 필요가 없다. → 협력 관계를 그대로 재사용 할 수 있다.
[!Tip] 스프링, DB 없이 자바 코드로만 수행하는 단위 테스트가 중요하다.
[!Note] 자바 primitive type
long
이 아닌Long
을 사용하면null
을 넣을 수 있다.
새로운 할인 정책 개발 상황을 가정해보자.
RateDiscountPolicy
) 적용의 문제점역할(인터페이스)과 구현(클래스)도 분리했고, 다형성도 활용했으나,
사실 OCP, DIP와 같은 원칙을 위반하고 있으므로 다음과 같이 코드를 고쳐야 할인 정책을 변경할 수 있다.
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
// private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
// 할인 정책을 변경하려면 클라이언트인 `OrderServiceImpl` 코드를 고쳐야 한다.
OrderServiceImpl
은 추상(인터페이스 DiscountPolicy
)뿐만 아니라 구체(구현) 클래스(FixDiscountPolicy
, RateDiscountPolicy
)에도 의존하고 있다.OrderServiceImpl
의 코드를 변경해야 한다.DIP를 위반하지 않도록 인터페이스에만 의존하도록 변경한다.
인터페이스에만 의존하도록 코드를 변경한다.
final
은 무조건 변수에 값이 할당되거나 생성자로 초기화되어야 하므로, 여기에서는final
은 지워주자.
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
private DiscountPolicy discountPolicy;
누군가가 클라이언트인 OrderServiceImpl
에 DiscountPolicy
구현 객체를 대싱 생성 & 주입해준다.
AppConfig
에서 모든 구성 요소를 생성하고 주입해주자.
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImpl(new MemoryMemberRepository());
}
public OrderService orderService() {
return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
}
}
대상이 되는 곳에서는
// DIP 준수: 인터페이스에만 의존
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
// 생성자 주입
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
// DIP 준수: 이제 추상화 MemberRepository에만 의존하므로
private final MemberRepository memberRepository;
// 생성자 주입
public MemberServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
[!Note]
AppConfig
의 역할
- 실제 동작에 필요한 구현 객체를 생성한다.
- 생성한 객체 인스턴스의 참조(레퍼런스)를 생성자를 통해 주입해준다.
[!Tip] 구현 클래스는 이제부터 의존관계에 대한 고민은 외부에 맡기고, 실행에만 집중할 수 있다.
AppConfig
가 담당한다.Impl
클래스는 추상(인터페이스)에만 의존하면 된다. 즉, 구체 클래스를 몰라도 된다.[!Note] 의존성 주입 (Dependency Injection)
AppConfig
리팩터링역할이 잘 드러나고 중복이 없도록 리팩터링한다.
public class AppConfig {
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
public DiscountPolicy discountPolicy() {
return new FixDiscountPolicy();
}
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
}
구성 영역의 코드만 고치면 된다.
public DiscountPolicy discountPolicy() {
// return new FixDiscountPolicy();
return new RateDiscountPolicy();
}
한 클래스는 하나의 책임만 가져야 한다.
관심사를 분리함으로써 준수한다.
AppConfig
는 구현 객체를 생성 / 연결하는 책임, 클라이언트 객체는 실행하는 책임만 담당한다.
추상화에 의존해야하며, 구체화에 의존하면 안된다.
의존성 주입을 통해 준수한다.
AppConfig
가 객체 인스턴스를 클라이언트 코드 대신 생성해서 클라이언트 코드에 의존관계를 주입한다.
소프트웨어 요소는 확장에는 열려있으나 변경에는 닫혀있어야 한다.
애플리케이션을 사용 영역과 구성 영역으로 나눈다.
AppConfig
가 의존관계를 변경해서 클라이언트 코드에 주입하므로 클라이언트 코드는 변경할 필요가 없다.
AppConfig
의 도입으로 프로그램의 제어권이 외부(AppConfig
)로 넘어가는데, 이를 제어의 역전이라고 한다.
[!Note] 프레임워크 vs. 라이브러리
- 프레임워크가 내가 작성한 코드를 제어하고, 대신 실행하면 그것은 프레임워크이다. (
JUnit
)- 내가 작성한 코드가 직접 제어의 흐름을 담당한다면 그것은 프레임워크가 아니라 라이브러리이다.
DiscountPolicy
인터페이스에 의존하는OrderServiceImpl
은 실제 어떤 구현 객체가 사용될지 모른다.
정적인 클래스 의존 관계
실제로 어떤 객체가
OrderServiceImpl
에 주입될지 알 수 없다.
동적인 객체 인스턴스 의존 관계
[!Tip] 의존관계 주입을 사용하면 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있다.
AppConfig
처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 IoC 컨테이너 또는 DI 컨테이너라 한다. (요즘에는 주로 DI 컨테이너)
혹은 어셈블러, 오브젝트 팩토리 등으로 불리기도 한다.
AppConfig
에 @Configuration
, @Bean
어노테이션을 달아준다.어플리케이션 코드에서 memberService
를 불러오는 부분을 다음과 같이 변경한다.
getBean()
시에 필요한 이름은 메서드 이름이다.
// AppConfig appConfig = new AppConfig();
// MemberService memberService = appConfig.memberService();
// OrderService orderService = appConfig.orderService();
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
ApplicationContext
를 스프링 컨테이너라 한다.@Configuration
이 붙은 AppConfig
를 설정 정보로 사용한다.@Bean
이 붙은 메서드를 모두 호출하여 반환된 객체를 스프링 컨테이너에 등록하며, 이렇게 등록된 객체를 스프링 빈이라 한다.
@Bean(name = "..")
으로 스프링 빈의 이름을 설정할 수도 있다.ac.getBean()
메서드를 사용해서 스프링 빈을 찾을 수 있다.[!Note] 스프링 컨테이너를 사용하면 어떠한 장점이 있을까?
다형성만으로는 안 되는구나, DIP랑 OCP를 지키려면 뭔가가 더 필요하구나!로부터 시작해서,
AppConfig
보다 스프링 컨테이너가 낫다는 것까지 살펴보았다.
다음의 코드로 스프링 컨테이너를 생성하며, ApplicationContext
가 스프링 컨테이너가 된다.
ApplicationContext
= 인터페이스AnnotationConfigApplicationContext
= 구현체사실 스프링 컨테이너는
BeanFactory
,ApplicationContext
로 구분해서 이야기하는데,BeanFactory
는 직접 사용되는 일이 거의 없으므로 일반적으로는ApplicationContext
를 스프링 컨테이너라 한다.
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
스프링 컨테이너 생성
AppConfig.class
를 구성 정보로 지정하여 스프링 컨테이너를 생성한다.스프링 빈 등록
파라미터로 넘어온 설정 클래스 정보를 사용해서 스프링 빈을 등록한다.
빈 이름
@Bean(name = "")
을 통해 직접 이름을 부여할 수도 있다.스프링 빈 의존 관계 설정
[!Tip] 스프링은 빈을 생성하고 의존관계를 주입하는 단계가 나누어져 있는데, 이처럼 자바 코드로 스프링 빈을 등록하면 생성자를 호출하면서 의존관계 주입도 한 번에 처리된다.
public class ApplicationContextInfoTest {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
@Test
@DisplayName("모든 빈 출력하기")
void findAllBean() {
String[] beanDefinitionNames = ac.getBeanDefinitionNames();
for (String beanDefinitionName : beanDefinitionNames) {
Object bean = ac.getBean(beanDefinitionName);
System.out.println("name = " + beanDefinitionName + " object = " + bean);
}
}
@Test
@DisplayName("애플리케이션 빈 출력하기")
void findApplicationBean() {
String[] beanDefinitionNames = ac.getBeanDefinitionNames();
for (String beanDefinitionName : beanDefinitionNames) {
BeanDefinition beanDefinition = ac.getBeanDefinition(beanDefinitionName);
// ROLE_APPLICATION: 직접 등록한 스프링 빈만 출력
// ROLE_INFRASTRUCTURE: 스프링이 내부에서 사용하는 빈
if (beanDefinition.getRole() == BeanDefinition.ROLE_INFRASTRUCTURE) {
Object bean = ac.getBean(beanDefinitionName);
System.out.println("name = " + beanDefinitionName + " object = " + bean);
}
}
}
}
ac.getBeanDefinitionNames()
: 스프링에 등록된 모든 빈 정보 반환ac.getBean()
: 빈 이름으로 빈 객체 반환ac.getBeanDefinition(beanDefinitionName)
: 빈 이름이 beanDefinitionName
인 빈의 definition 반환BeanDefinition.getRole()
을 통해 애플리케이션 빈만 출력할 수 있다.
BeanDefinition.ROLE_APPLICATION
: 일반적으로 사용자가 정의한 빈BeanDefinition.ROLE_INFRASTRUCTURE
: 스프링이 내부에서 사용하는 빈ac.getBean(name, class)
: 이름과 타입을 통해 조회할 수 있다.ac.getBean(class)
: 이름 없이 타입으로만 조회할 수도 있다.
MemberServiceImpl
)으로 조회하면 변경 시 유연성이 떨어질 수 있다. 인터페이스 타입(ex. MemberService
)으로 조회하는 것을 권장한다.ac.getBeansOfType()
: 해당 타입의 모든 빈을 조회할 수 있다.중요!
[!Note] 기본 원칙: 부모 타입으로 조회하면, 자식 타입도 함께 조회된다.
Object
타입으로 조회하면, 모든 스프링 빈이 조회된다.실제로는
getBean()
을 사용할 일은 거의 없을 것이다!
BeanFactory
와 ApplicationContext
BeanFactory
: 스프링 컨테이너의 최상위 인터페이스이다.ApplicationContext
: BeanFactory
를 포함한 여러 인터페이스를 상속받아 부가 기능을 제공하는 인터페이스이다.BeanFactory
를 직접 사용할 일은 거의 없고 주로 ApplicationContext
를 사용한다.우리는 지금까지 설정을 자바 코드인
AppConfig
를 이용하여 작성해왔다. 하지만 스프링 컨테이너는 자바 코드 뿐만 아니라 XML, Groovy 등을 모두 지원한다.
new AnnotationConfigApplicationContext(AppConfig.class)
resources/
디렉토리 밑에 xml 파일을 작성한다.BeanDefinition
BeanDefinition
만 알면 된다.BeanDefinition
을 빈 설정 메타정보라 하며, 이는 @Bean
, <bean>
당 하나씩 생성된다.실무에서 직접
BeanDefinition
을 다룰 일은 거의 없다.
스프링 없는 순수한 DI 컨테이너인 AppConfig
는 요청을 할 때마다 객체를 새로 생성한다.
싱글톤 패턴을 적용하면 객체가 단 1개만 생성되고, 공유될 수 있다.
다음과 같이 싱글톤 패턴을 구현할 수 있다. (private
생성자)
싱글톤 패턴을 구현하는 방법은 여러 가지가 있으나, 이는 가장 간단한 방법이다.
public class SingletonService {
// 자기 자신을 내부의 private static으로 단 하나 가진다. (클래스 레벨)
private static final SingletonService instance = new SingletonService();
// instance를 꺼낼 수 있는 방법은 getInstance() 뿐이다.
public static SingletonService getInstance() {
return instance;
}
// private 생성자 (밖에서 new 키워드로 생성할 수 없도록)
private SingletonService() {
}
public void logic() {
System.out.println("싱글톤 객체 로직 호출");
}
}
[!Note] 스프링 컨테이너를 사용하면 스프링 컨테이너가 기본적으로 객체를 다 싱글톤으로 만들어서 관리해준다.
싱글톤 패턴을 구현하는 코드 자체가 필요하다.
의존관계상 클라이언트가 구체 클래스에 의존한다. (DIP 위반) (OCP 위반 가능성 높음)
구체클래스.getInstance()
테스트하기 어렵다.
내부 속성을 변경하거나 초기화 하기 어렵다.
private 생성자로 자식 클래스를 만들기 어렵다.
유연성이 떨어져 안티패턴으로 불리기도 한다.
[!Tip] 스프링은 싱글톤이 가진 단점을 모두 해결해준다!
스프링 컨테이너는 싱글톤의 문제점을 해결하고, 스프링 빈을 싱글톤으로 관리한다.
- 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도 객체 인스턴스를 싱글톤으로 관리한다.
- 스프링 컨테이너처럼 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라 한다.
- 스프링 컨테이너는 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
[!Note] 스프링의 기본 빈 등록 방식은 싱글톤(99% 이상) 이나, 이것만 지원하는 것은 아니다. 요청할 때마다 객체를 생성해서 반환하는 기능도 제공한다. (→ 빈 스코프)
[!Caution] 싱글톤 객체는 stateful하게 설계하면 안되고, stateless로 설계해야 한다.
스프링 빈의 필드에 공유 값을 설정하면 큰 장애가 발생할 수 있다!
ThreadLocal
등을 사용해야 한다.[!Note] 웹에서 요청이 들어오면 보통 요청마다 thread가 할당된다. 따라서 싱글톤 객체의 필드는 클라이언트에서 변경할 수 없어야 한다.
→ 공유 필드 XXXXX
public class StatefulService {
private int price; // 상태를 유지하는 필드
public void order(String name, int price) {
System.out.println("name = " + name + " price = " + price);
this.price = price;
}
public int getPrice() {
return price;
}
}
class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
// ThreadA: A 사용자 10000원 주문
statefulService1.order("userA", 10000);
// ThreadB: B 사용자 20000원 주문
statefulService2.order("userB", 20000);
// ThreadA: A 사용자 주문 금액 조회
int price = statefulService1.getPrice();
System.out.println("price = " + price);
assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
공유 필드였던 price
를 필드로 사용하는 것이 아니라 order()
의 반환값으로 설정한다.
@Configuration
과 싱글톤
@Configuration
은 싱글톤을 위해 존재한다.
AppConfig
에서 의문점memberRepository()
를 memberService()
와 orderService()
에서 각각 호출한다. 그렇다면 각각 다른 2개의 MemoryMemberRepository
가 생성되어 싱글톤이 깨지는 것이 아닐까?
테스트를 해보면 memberRepository
인스턴스는 모두 같은 인스턴스가 공유되어 사용된다.
memberRepository1 = hello.core.member.MemoryMemberRepository@165b8a71
memberRepository2 = hello.core.member.MemoryMemberRepository@165b8a71
memberRepository = hello.core.member.MemoryMemberRepository@165b8a71
AppConfig
의 스프링 빈 메서드에 메서드 이름 출력 코드를 추가하고, 다시 실행해본다.
AppConfig.memberRepository
AppConfig.memberService
AppConfig.orderService
→ AppConfig.memberRepository
메서드가 세 번 호출되는 것이 아닌, 각 메서드가 단 한 번씩만 호출되는 것을 확인할 수 있다.
@Configuration
과 바이트 코드 조작의 마법스프링은 클래스의 바이트코드를 조작하는 라이브러리인 CGLIB
을 통해 스프링 빈이 싱글톤이 되도록 보장한다. 그리고 이는 @Configuration
이 적용되었기 때문에 가능하다.
AnnotationConfigApplicationContext
에 파라미터로 넘긴 값도 스프링 빈으로 등록되므로 AppConfig
도 스프링 빈이 되어야 하는데, 클래스 정보를 출력해보면 다음과 같이 $$~~
가 붙은 정보가 출력된다.
bean.getClass() = class hello.core.AppConfig$$SpringCGLIB$$0
이는 스프링이 CGLIB
이라는 바이트 코드 조작 라이브러리를 통해 AppConfig
클래스를 상속 받은 임의의 다른 클래스를 만들고, 그 클래스를 스프링 빈으로 등록한 것이다. 이 클래스가 싱글톤이 보장되도록 해주는 역할을 수행한다.
@Bean
이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 스프링 빈이 없으면 생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어진다. 이를 통해 싱글톤이 보장된다.
AppConfig@CGLIB
은AppConfig
의 자식 타입이므로,AppConfig
타입으로 조회할 수 있다.
@Configuration
어노테이션을 제거한다면?다음과 같이 memberRepository()
가 세 번 호출된다.
AppConfig.memberRepository
AppConfig.memberService
AppConfig.memberRepository
AppConfig.orderService
AppConfig.memberRepository
그리고 각각의 호출에서 생성된 객체가 모두 다른 객체가 된다. (싱글톤 X)
스프링 컨테이너가 관리하지 않아
new
로 생성하는 것과 동일해진다.
memberRepository1 = hello.core.member.MemoryMemberRepository@505a9d7c
memberRepository2 = hello.core.member.MemoryMemberRepository@758c83d8
memberRepository = hello.core.member.MemoryMemberRepository@129b4fe2
또한, AppConfig
의 클래스 이름도 다음과 같이 CGLIB
이 없다.
bean.getClass() = class hello.core.AppConfig
[!Tip] 추후 다룰
@Autowired
를 이용하여 빈으로 등록된 것을 주입시켜줄 수는 있다.@Autowired MemberRepository memberRepository;
@Bean
만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다.
의존관계 주입을 위해 메서드를 직접 호출할 때(ex.
memberRepository()
) 싱글톤을 보장하지 않는다.
스프링 설정 정보는 항상 @Configuration
을 사용하자.
@Bean
이나<bean>
으로 직접 스프링 빈을 등록하는 것은 귀찮다. 이러한 설정 정보 없이도 자동으로 스프링 빈을 등록하는 컴포넌트 스캔 기능을 제공하며, 의존관계를 자동으로 주입하는@Autowired
기능도 제공한다.
컴포넌트 스캔을 사용하려면 @ConponentScan
을 설정 정보에 붙여준다.
이렇게 컴포넌트 스캔을 사용하면
@Configuration
이 붙은 설정 정보도 자동으로 등록되므로, 기존 예제 코드를 지우지 않고 보존하기 위해서 컴포넌트 스캔 시 다음과 같이@Configuration
어노테이션이 붙은 것은 제외한다. (보통 실무에서는 제외하지 않으나, 지금은 예제 코드를 지우지 않기 위함이다.)@Configuration @ComponentScan( // 기존의 AppConfig을 제거하기 위함 excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class) ) public class AutoAppConfig { }
컴포넌트 스캔을 이용하면 @Component
어노테이션이 붙은 클래스를 스캔해서 스프링 빈으로 등록한다.
따라서 구현체 클래스들에 @Configuration
어노테이션을 붙여준다.
의존관계를 주입하기 위해서는 해당 부분(ex. 생성자)에 @Autowired
어노테이션을 붙여준다.
@ComponentScan
은 @Component
가 붙은 모든 클래스를 스프링 빈으로 등록한다.
@Component("new-name")
생성자에 @Autowired
를 지정하면, 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입한다.
getBean(MemberRepository.class)
)컴포넌트 스캔 대상이 되는 최상위 패키지를 다음과 같이 설정할 수 있다.
basePackages
: 해당 패키지를 포함해서 하위 패키지를 모두 탐색하며, 여러 개를 지정할 수도 있다.basePackageClasses
: 지정한 클래스의 패키지를 탐색 시작 위치로 지정한다.@ComponentScan
이 붙은 설정 정보 클래스의 패키지가 시작 위치가 된다.[!Tip] 패키지 위치를 지정하지 않고, 설정 정보 클래스(
@ComponentScan
)의 위치를 프로젝트 최상단에 두는 것을 권장한다.
- 스프링 부트를 사용하면, 대표 시작 정보인
@SpringBootAppilcation
을 프로젝트 루트 위치에 두는 것이 관례인데, 해당 어노테이션 안에@ComponentScan
이 포함되어 있다. 그래서 자동으로 스프링 빈으로 등록된 것이다. (부트에서 알아서 해준다.)- 뺄 것만 exclude 해서 사용하자.
@Component
뿐만 아니라…
@Component
: 컴포넌트 스캔에서 사용
@Controller
: 스프링 MVC 컨트롤러에서 사용
→ 스프링 MVC 컨트롤러로 인식
@Service
: 스프링 비즈니스 로직에서 사용
→ 특별한 처리를 하지 않으나, 개발자들이 핵심 비즈니스 로직이 여기에 있다는 것을 인식하는 데에 도움
@Repository
: 스프링 데이터 접근 계층에서 사용
→ 스프링 데이터 접근 계층으로 인식하고, 데이터 계층의 예외를 스프링 예외로 변환
@Configuration
: 스프링 설정 정보에서 사용
→ 스프링 설정 정보로 인식, 스프링 빈이 싱글톤을 유지하도록 추가 처리
includeFilters
: 컴포넌트 스캔 대상을 추가로 지정excludeFilters
: 컴포넌트 스캔에서 제외할 대상 지정FilterType
옵션
@Component
를 사용하면 되므로includeFilters
를 사용할 일은 거의 없으나,excludeFilters
는 간혹 사용할 때가 있다. 하지만 옵션을 변경하기보다는 스프링의 기본 설정에 최대한 맞추어 사용하는 것을 권장한다.
컴포넌트 스캔 시 같은 빈 이름을 등록하면 어떻게 될까?
컴포넌트 스캔에 의해 자동으로 등록된 스프링 빈들이 서로 이름이 같은 경우, 오류가 발생된다. (ConflictingBeanDefinitionException
)
다음과 같이 수동으로 이름이 같은 스프링 빈을 등록해보자.
@Configuration
@ComponentScan(
// 컴포넌트 스캔 대상이 되는 최상위 패키지 설정
basePackages = "hello.core",
// 기존의 AppConfig을 제거하기 위함
excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class)
)
public class AutoAppConfig {
@Bean(name = "memoryMemberRepository")
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
}
수동 빈 등록이 우선권을 가진다.
하지만 이로 인해 잡기 어려운 버그가 발생할 수 있으므로 최근 스프링 부트에서는 에러를 발생하도록 한다. (CoreApplication
실행)
***************************
APPLICATION FAILED TO START
***************************
Description:
The bean 'memoryMemberRepository', defined in class path resource [hello/core/AutoAppConfig.class], could not be registered. A bean with that name has already been defined in file [/Users/seungri/workspace/spring-study/spring-study/02-core_basic/core/build/classes/java/main/hello/core/member/MemoryMemberRepository.class] and overriding is disabled.
Action:
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true
[!Note] 명확한 코드가 베스트다!!!
[!Note] 스프링의 두 가지 사이클
- 스프링 빈 생성 및 등록
- 의존관계 주입 (
@Autowired
)
private final
→ 생성자 호출 시점에 값이 무조건 있어야 한다!@Autowired
를 생략해도 자동 주입된다.스프링 빈 생성 시점에 의존관계 주입이 일어난다.
@Autowired
를 붙인다.@Autowired
의 기본 동작은 주입할 대상이 없으면 오류가 발생하므로, 주입할 대상이 없어도 동작하게 하려면 @Autowired(required = false)
로 지정한다.[!Note] 자바빈 프로퍼티 규약
자바에서는 과거부터 필드의 값을 직접 변경하기 않고 setter와 getter를 통해서 접근하는 규칙을 만들었다.
@Configuration
같은 곳에서만 특별한 용도로 사용한다.[!Tip] 의존관계 자동 주입은 스프링 컨테이너가 관리하는 스프링 빈이어야 동작한다.
자동 주입 대상이 되는 스프링 빈이 없다면 옵션으로 처리해야 한다.
@Autowired(required = false)
: 자동 주입할 대상이 없다면 메서드 자체가 호출이 안 된다.
메서드 호출 자체가 안 된다!
@Nullable
: 자동 주입할 대상이 없다면 메서드는 호출되나 null로 입력된다.Optional<>
: (Java 8 문법) 자동 주입할 대상이 없다면 메서드는 호출되나 Optional.empty가 입력된다.public class AutowiredTest {
@Test
void AutowiredOption() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestBean.class);
}
static class TestBean {
// 1. 의존관계 자동 주입할 스프링 빈이 없다면 메서드 자체가 호출이 안 된다.
@Autowired(required = false)
public void setNoBean1(Member noBean1) {
System.out.println("noBean1 = " + noBean1);
}
// 2. 자동 주입할 대상이 없다면 메서드는 호출되나 null로 입력된다.
@Autowired
public void setNoBean2(@Nullable Member noBean2) {
System.out.println("noBean2 = " + noBean2);
}
// 3. (Java 8 문법) 자동 주입할 대상이 없다면 메서드는 호출되나 Optional.empty가 입력된다.
@Autowired
public void setNoBean3(Optional<Member> noBean3) {
System.out.println("noBean3 = " + noBean3);
}
}
}
noBean2 = null
noBean3 = Optional.empty
[!Note]
@Nullable
,Optional
은 스프링 전반에 걸쳐 지원된다. (ex. 생성자 자동 주입 등에서 특정 필드에만 사용)
다른 DI 프레임워크 대부분이 생성자 주입을 권장한다. 그 이유는?
대부분의 의존관계 주입은 한 번 일어나면 애플리케이션 종료 시점까지 의존관계를 변경할 일이 없다.
수정자 주입을 사용하면 setXxxx
메서드를 public으로 열어두어야 하므로, 실수로 변경할 수 있다.
생성자 주입을 사용하면 객체를 생성할 때 단 1번만 호출되므로 불변하게 설계할 수 있다.
프레임워크 없이 순수한 자바 코드를 단위 테스트 하는 경우, 수정자 의존 관계의 경우에는 주입 데이터를 누락했을 때 컴파일 오류(ex. NPE
)가 발생하게 된다.
final
키워드생성자 주입을 사용하게 되면 필드에 final
키워드를 사용할 수 있다. 따라서 생성자에서 값이 설정되지 않는 경우를 컴파일 시점에 감지할 수 있다.
[!Note]
final
키워드한 번 생성할 때 정해지만 값이 바뀌지 않는다.
→ 생성자에서만 값을 넣어줄 수 있다!
수정자 주입을 포함한 나머지 주입 방식은 모두 생성자 이후에 호출되기 때문에, 필드에
final
키워드를 사용할 수 없다. 오직 생성자 주입만이 사용 가능하다!
항상 생성자 주입을 선택해라. 가끔 옵션이 필요한 경우에는 수정자 주입을 선택하자.
대부분이 다 불변이므로 필드에
final
키워드를 사용하게 된다. 생성자도 만들고, 주입 받은 값을 대입하는 코드도 만들어야 하므로, 이를 필드 주입처럼 편리하게 사용하는 방법은 없을까?
롬복을 사용하면 다음과 같이 편리하게 사용할 수 있다. 실무에서 기본으로 깔고 쓴다!
@Getter
@Setter
@ToString
public class HelloLombok {
private String name;
private int age;
public static void main(String[] args) {
HelloLombok helloLombok = new HelloLombok();
helloLombok.setName("hahahahaha");
String name = helloLombok.getName();
System.out.println("name = " + name);
System.out.println("helloLombok = " + helloLombok);
}
}
롬복의 @RequiredArgsConstructor
를 사용하면 required argument인 final
이 붙은 필수 필드 값을 파라미터로 받는 생성자를 컴파일 시점에 자동으로 만들어준다.
즉, 다음의 코드는 동일한 코드이다.
@Component
public class OrderServiceImpl implements OrderService {
// DIP 준수: 인터페이스에만 의존
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
// 생성자 주입
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
// DIP 준수: 인터페이스에만 의존
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
롬복을 사용한다면 정말 필요할 때만 생성자를 직접 작성하면 된다.
[!Tip] 최근에는 생성자를 딱 하나만 두고,
@Autowired
를 생략하는 방법을 주로 사용한다. 여기에 롬복의@RequiredArgsConstructor
를 사용하면 코드를 더욱 깔끔하게 유지할 수 있다!
@Autowired
는 타입으로 조회하기 때문에, 같은 타입의 스프링 빈이 2개 이상이라면 문제가 발생하게 된다.
예를 들어
DiscountPolicy
의 하위 타입인 FixDiscountPolicy
와 RateDiscountPolicy
를 모두 @Component
을 달아 스프링 빈으로 선언하고,@Autowired
를 통해 의존관계 자동 주입을 실행하게 되면NoUniqueBeanDefinitionException
오류가 발생한다.
Caused by: org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'hello.core.discount.DiscountPolicy' available: expected single matching bean but found 2: fixDiscountPolicy,rateDiscountPolicy
하위 타입으로 지정하게 되면 DIP를 위반하고 유연성이 떨어지게 된다. 또한, 이름만 다르고 완전히 같은 타입의 스프링 빈이 2개 있을 때 해결이 안 된다.
스프링 빈을 수동 등록해서 해결해도 되지만, 자동 주입에서 해결하는 세 가지 방법에 대해 알아보자!
@Autowired
필드명 매칭Autowired
는 매칭은 다음의 순서로 진행된다.
생성자 주입이든, 필드 주입이든 간에!
@Component
public class OrderServiceImpl implements OrderService {
// DIP 준수: 인터페이스에만 의존
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
// 생성자 주입
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy **rateDiscountPolicy**) {
this.memberRepository = memberRepository;
this.discountPolicy = **rateDiscountPolicy**;
}
위의 코드에서는 생성자의 파라미터 이름을 rateDiscountPolicy
로 지정하여 DiscountPolicy
타입의 빈들 중 하나를 지정한다.
@Qualifier
끼리 매칭추가 구분자를 붙여주는 방법이다.
다음과 같이 추가 구분자를 부여할 클래스에 @Qualifier("...")
어노테이션을 붙여주고,
@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {
의존관계 주입이 일어나는 곳(ex. 생성자, 필드, 수정자)에서 파라미터 앞에 동일한 어노테이션을 붙여준다.
// 생성자 주입
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, @Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) {
지정한 추가 구분자를 찾을 수 없다면, 해당 이름의 스프링 빈을 추가로 찾게 된다. 하지만 이렇게 사용하기보다는 항상 명확하게 사용해야 한다!
@Qualifier
의 동작은 다음의 순서로 이루어진다.
@Qualifier
끼리 매칭한다.NoSuchBeanDefinitionException
이 발생한다.@Primary
사용@Autowired
시에 여러 빈이 매칭되면 @Primary
가 우선권을 가진다.
@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {
[!Note]
@Primary
,@Qualifier
활용
- 메인을
@Primary
로 설정하고, 특별하게 가끔 사용하는 것을@Qualifier
로 지정하여 명시적으로 설정하면 코드를 깔끔하게 유지할 수 있다.- 우선순위는
@Qualifier
가@Primary
보다 높다.
@Qualifier("mainDiscountPolicy")
와 같이 문자를 적으면 컴파일 시 타입 체크가 불가능하다.
따라서 다음과 같이 어노테이션을 직접 만들어서 사용할 수 있다.
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Qualifier("mainDiscountPolicy") // 대신하고 싶은 어노테이션
public @interface MainDiscountPolicy {
}
이렇게 생성한 @MainDiscountPolicy
어노테이션을 “[2] @Qualifier
끼리 매칭“ 방법에서 @Qualifier("mainDiscountPolicy")
대신 사용할 수 있다.
[!Tip]
@Primary
로 해결이 어려운 상황이라면 이렇게!
[!Note] 본래 자바의 어노테이션에는 상속이라는 개념이 없다. 이처럼 어노테이션을 여러 개 모아서 사용하는 기능은 스프링이 지원해주는 기능이다. 따라서 다른 어노테이션도 함께 조합해서 사용할 수 있다. 하지만 무분별한 재정의는 지양해야 한다.
List
, Map
(전략 패턴)전략 패턴을 간단하게 구현하는 방법 (ex. 할인의 종류를
rate
,fix
중 선택하도록)
[!Tip] 동적으로 스프링 빈을 선택하는 방법!
public class AllBeanTest {
@Test
void findAllBean() {
// 스프링 빈으로 등록 (AutoAppConfig, DiscountService 모두)
ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);
DiscountService discountService = ac.getBean(DiscountService.class);
Member member = new Member(1L, "userA", Grade.VIP);
int discountPrice = discountService.discount(member, 10000, "fixDiscountPolicy");
assertThat(discountService).isInstanceOf(DiscountService.class);
assertThat(discountPrice).isEqualTo(1000);
int rateDiscountPrice = discountService.discount(member, 20000, "rateDiscountPolicy");
assertThat(rateDiscountPrice).isEqualTo(2000);
}
static class DiscountService {
private final Map<String, DiscountPolicy> policyMap;
private final List<DiscountPolicy> policies;
@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) {
// discountCode와 매칭되는 빈을 꺼낸다.
DiscountPolicy discountPolicy = policyMap.get(discountCode);
return discountPolicy.discount(member, price);
}
}
}
자동 등록 기능을 기본으로 사용하자
어플리케이션에 광범위하게 영향을 미치는 기술 지원 빈은 수동으로 등록하여 설정 정보에 바로 나타나게 한다.
▼ 어플리케이션의 구성
구분 | 사용 시점 | 예시 | 특징 |
---|---|---|---|
업무 로직 빈 | 비즈니스 요구사항을 개발할 때 | 웹을 지원하는 컨트롤러, 비즈니스 로직이 담긴 서비스, 데이터 계층의 로직을 처리하는 리포지토리 등 | - 개수가 많음 - 유사한 패턴 - 어떤 곳에서 문제가 발생했는지 파악 용이 |
기술 지원 빈 | 기술적인 문제나 공통 관심사(AOP)를 처리할 때 | 데이터베이스 연결, 공통 로그 처리 등 업무 로직을 지원하기 위한 기술 | - 개수가 적음 - 어플리케이션 전반에 걸쳐 광범위하게 영향 - 적용이 잘 되고 있는지조차 파악이 어려움 |
단, 스프링, 스프링 부트가 자동으로 등록하는 수많은 빈들은 자동으로 그대로 두자.
비즈니스 로직 중에서도 다형성을 적극적으로 활용하는 경우, 어떤 빈들이 주입될지 코드만 보고서는 쉽게 파악이 불가능하므로 수동 등록을 고려해보자.
핵심은 수동이든 자동이든 다형성(ex.
DiscountPolicy
)을 이루는 빈들을 특정 패키지에 같이 묶어두어 이해가 쉽도록 해야 한다는 것이다!그리고 수동 등록의 경우에는, 다음과 같이 별도의 설정 정보로 만들어 둔다. 이렇게 하면 어떤 빈들이 주입될지 한 눈에 파악이 가능하다.
@Configuration public class DiscountPolicyConfig { @Bean public DiscountPolicy rateDiscountPolicy() { return new RateDiscountPolicy(); } @Bean public DiscountPolicy fixDiscountPolicy() { return new FixDiscountPolicy(); } }
데이터베이스 커넥션 풀이나, 네트워크 소켓처럼 어플리케이션 시작 시점에 필요한 연결을 미리 해두고, 어플리케이션 종료 시점에 연결을 모두 종료하는 작업을 진행하려면 객체의 초기화와 종료 작업이 필요하다.
이러한 작업을 어떻게 진행하는지 알아보자.
public class BeanLifeCycleTest {
@Test
public void lifeCycleTest() {
ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
NetworkClient client = ac.getBean(NetworkClient.class);
// 보통은 직접 close() 할 일이 없으므로, ApplicationContext에서는 제공하지 않는다.
// 따라서 ConfigurableApplicaionContext를 사용해야 한다.
ac.close();
}
@Configuration
static class LifeCycleConfig {
@Bean
public NetworkClient networkClient() {
// 객체를 생성하는 단계에서는 url이 없고, 객체를 생성한 다음에 외부에서 수정자 주입을 통해 url을 설정한다.
NetworkClient networkClient = new NetworkClient();
networkClient.setUrl("http://hello-spring.dev");
return networkClient;
}
}
}
생성자 호출, url = null
connect: null
call: null message = 초기화 연결 메시지
초기화는 의존관계 주입이 완료된 후에 수행해야 하는데, 스프링은 의존관계 주입이 완료되면 스프링 빈에게 콜백 메서드를 통해 그 시점을 알려준다. 마찬가지로 스프링 컨테이너가 종료되기 직전에도 소멸 콜백을 주어 안전하게 종료 작업을 진행할 수 있는 시점을 알려준다.
스프링 컨테이너 생성 → 스프링 빈 생성 (생성자 주입의 경우, 여기에서 의존관계 주입) → 의존관계 주입 → 초기화 콜백 → 사용 → 소멸전 콜백 → 스프링 종료
[!Tip] 객체의 생성과 초기화를 분리하자.
생성자는 객체를 생성하는 책임을, 초기화는 이전에 생성된 값들을 활용해서 커넥션을 연결하는 등 무거운 동작을 수행한다. 따라서 생성자 안에서 무거운 초기화 작업을 함께 하는 것보다는, 객체를 생성하는 부분과 초기화 하는 부분을 명확하게 나누는 것이 유지보수 관점에서 좋다.
또한, 초기화 동작이 필요한 시점까지 그 동작을 지연시킬 수도 있다. (ex. 커넥션 → 액션이 주어지면 그때 초기화 수행)
InitializingBean
, DisposableBean
)public class NetworkClient implements InitializingBean, DisposableBean {
private String url;
public NetworkClient() {
System.out.println("생성자 호출, url = " + url);
// connect();
// call("초기화 연결 메시지");
}
public void setUrl(String url) {
this.url = url;
}
// 서비스 시작 시 호출
public void connect() {
System.out.println("connect: " + url);
}
public void call(String message) {
System.out.println("call: " + url + " message = " + message);
}
// 서비스 종료 시 호출
public void disconnect() {
System.out.println("close " + url);
}
@Override
public void afterPropertiesSet() throws Exception {
// 의존관계 주입이 완료되면 호출
System.out.println("NetworkClient.afterPropertiesSet");
connect();
call("초기화 연결 메시지");
}
@Override
public void destroy() throws Exception {
// 빈 종료 시 호출
System.out.println("NetworkClient.destroy");
disconnect();
}
}
생성자 호출, url = null
NetworkClient.afterPropertiesSet
connect: http://hello-spring.dev
call: http://hello-spring.dev message = 초기화 연결 메시지
NetworkClient.destroy
close http://hello-spring.dev
[!Note] 요즘은 거의 사용하지 않는다.
@Configuration
static class LifeCycleConfig {
@Bean(initMethod = "init", destroyMethod = "close")
public NetworkClient networkClient() {
// 객체를 생성하는 단계에서는 url이 없고, 객체를 생성한 다음에 외부에서 수정자 주입을 통해 url을 설정한다.
NetworkClient networkClient = new NetworkClient();
networkClient.setUrl("http://hello-spring.dev");
return networkClient;
}
}
코드가 아니라 설정 정보를 사용하기 때문에 코드를 고칠 수 없는 외부 라이브러리에도 초기화, 종료 메서드를 적용할 수 있다.
외부 라이브러리 문서에서 어떤 메서드를 사용하도록 지정하고 있는 경우도 있다.
@Bean
의 destroyMethod
속성라이브러리들은 대부분 close
, shutdown
이라는 이름의 종료 메서드를 사용한다.
@Bean
의 destroyMethod
는 default 값이 (inferred)
로 등록되어 있는데, 이는 close
, shutdown
이라는 이름의 메서드를 자동으로 호출해준다.
따라서 직접 스프링 빈으로 등록하면 종료 메서드는 따로 적어주지 않아도 잘 동작한다.
이러한 추론 기능을 사용하기 싫다면 destroyMethod=""
처럼 빈 공백을 지정하자.
@PostConstruct
, @PreDestroy
[!Tip] 이 방법을 쓰면 된다.
@PostConstruct
public void init() {
// 의존관계 주입이 완료되면 호출
System.out.println("NetworkClient.init");
connect();
call("초기화 연결 메시지");
}
@PreDestroy
public void close() {
// 빈 종료 시 호출
System.out.println("NetworkClient.close");
disconnect();
}
javax
or jakarta
)외부 라이브러리에는 적용하지 못하므로, 외부 라이브러리를 초기화, 종료해야 한다면 @Bean
의 기능을 사용해야 한다.
@PostConstruct
, @PreDestroy
어노테이션을 사용하자.@Bean(initMethod, destroyMethod)
를 사용하자.[!Note] 스프링 빈은 기본적으로 싱글톤 스코프로 생성된다.
자동 등록 (컴포넌트 스캔)
@Scope("prototype")
@Component
public class HelloBean {}
수동 등록
@Scope("prototype")
@Bean
PrototypeBean HelloBean() {
return new HelloBean();
}
[!Tip] 싱글톤 스코프는 스프링 컨테이너의 시작부터 끝까지 함께하는 긴 스코프이다. 따라서 싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다.
public class SingletonTest {
@Test
void singletonBeanFind() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SingletonBean.class);
SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
System.out.println("singletonBean1 = " + singletonBean1);
System.out.println("singletonBean2 = " + singletonBean2);
assertThat(singletonBean1).isEqualTo(singletonBean2);
ac.close();
}
@Scope("singleton")
static class SingletonBean {
@PostConstruct
public void init() {
System.out.println("SingletonBean.init");
}
@PreDestroy
public void destroy() {
System.out.println("SingletonBean.destroy");
}
}
}
SingletonBean.init
singletonBean1 = hello.core.scope.SingletonTest$SingletonBean@3fa2213
singletonBean2 = hello.core.scope.SingletonTest$SingletonBean@3fa2213
SingletonBean.destroy
[!Tip] 프로토타입 스코프를 스프링 컨테이너에 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다.
[!Tip] 프로토타입 스코프 빈의 핵심은 스프링 컨테이너는 <프로토타입 빈 생성 → 의존관게 주입 → 초기화> 까지만 처리한다는 것이다. 따라서 스프링 컨테이너는 클라이언트에 빈을 반환한 후에는 생성된 빈을 관리하지 않기 때문에,
@PreDestroy
같은 종료 메서드가 호출되지 않는다.
public class PrototypeTest {
@Test
void prototypeBeanFind() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
System.out.println("find prototypeBean1");
PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
System.out.println("find prototypeBean2");
PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
System.out.println("prototypeBean1 = " + prototypeBean1);
System.out.println("prototypeBean2 = " + prototypeBean2);
assertThat(prototypeBean1).isNotSameAs(prototypeBean2);
ac.close();
}
// @Component를 붙이지 않아도 컴포넌트 스캔 대상이 된다.
// 이는 new AnnotationConfigApplicationContext(PrototypeBean.class)로 지정하면 클래스 자체를 등록하기 때문이다.
@Scope("prototype")
static class PrototypeBean {
@PostConstruct
public void init() {
System.out.println("SingletonBean.init");
}
@PreDestroy
public void destroy() {
System.out.println("SingletonBean.destroy");
}
}
}
find prototypeBean1
SingletonBean.init
find prototypeBean2
SingletonBean.init
prototypeBean1 = hello.core.scope.PrototypeTest$PrototypeBean@3fa2213
prototypeBean2 = hello.core.scope.PrototypeTest$PrototypeBean@3e7634b9
프로토타입 스코프 빈을 사용하는 본래의 의도는 “프로토타입 스코프의 빈을 요청하면 항상 새로운 객체 인스턴스를 생성해서 반환” 받기 위함이다.
하지만 싱글톤 스코프 빈에서 프로토타입 스코프 빈을 의존관계 주입을 통해 주입받는 경우, 이러한 의도대로 동작하지 않을 수 있다.
싱글톤 빈은 보통 스프링 컨테이너 생성 시점에 함께 생성되고, 이어서 의존관계 주입도 발생한다. 따라서 의존관계 주입 시점에 스프링 빈에게 프로토타입 빈을 요청하게 된다.
해당 시점에 스프링 컨테이너는 프로토타입 빈을 생성하여, 요청한 싱글톤 빈에게 반환한다. 싱글톤 빈은 내부 필드에 해당 프로토타입 빈의 참조값을 보관한다.
따라서 사용할 때마다 새로 생성되어야 한다는 본래의 의도와는 다르게 해당 프로토타입 빈은 싱글톤 빈의 의존관계 주입 시점에 단 한 번만 생성되어 계속 재활용 되게 된다. (이런 동작을 위해서라면 그냥 싱글톤 빈을 사용하는 것이 낫다.)
프로토타입 빈을 주입 시점에만 새로 생성하는 것이 아니라, 본래 의도대로 사용할 때마다 새로 생성해서 사용하려면 어떻게 해야할까?
```java @Test void singletonClientUsePrototype() { AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class); ClientBean clientBean1 = ac.getBean(ClientBean.class); int count1 = clientBean1.logic(); assertThat(count1).isEqualTo(1); ClientBean clientBean2 = ac.getBean(ClientBean.class); int count2 = clientBean2.logic(); assertThat(count2).isEqualTo(1); }` ```
싱글톤 빈이 프로토타입 빈을 사용할 때마다 스프링 컨테이너에 새로 요청하기
단순 무식한 방법
다음과 같이 직접 필요한 의존관계를 찾는 것을 DL(dependency lookup, 의존관계 조회)이라 한다.
하지만, 이렇게 하면 스프링 컨테이너에 종속적이게 되어 단위 테스트도 어려워지므로, 딱 DL 정도의 기능만 제공하는 무언가로 대체해야 한다.
@Scope("singleton")
static class ClientBean {
@Autowired
private ApplicationContext ac;
public int logic() {
// 싱글톤 빈이 프로토타입 빈을 사용할 때마다 스프링 컨테이너에 새로 요청하기
PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
ObjectProvider
, ObjectFactory
사용하기
ObjectProvider.getObject()
를 호출하면, 내부에서는 스프링 컨테이너에게 해당 빈을 요청해서(= 찾아서) 반환한다. 따라서 getObject()
호출 시마다 새로운 프로토타입 빈이 생성된다.ObjectFactory
vs. ObjectProvider
ObjectFactory
: 기능이 단순, 별도의 라이브러리 필요 없음, 스프링에 의존ObjectProvider
: ObjectFactory
상속, 옵션, 스트림 처리등 편의 기능이 많고, 별도의 라이브러리 필요 없음, 스프링에 의존@Scope("singleton")
static class ClientBean {
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
JSR-330(자바 표준) Provider
사용하기
gradle에 라이브러리를 추가해야 한다.
implementation 'jakarta.inject:jakarta.inject-api:2.0.1'
딱 필요한 정도의 DL만 제공하며, get()
메서드 하나로 기능이 단순하다.
별도의 라이브러리가 필요하기는 하나, 자바 표준이므로 다른 컨테이너에서도 사용할 수 있다.
2번 코드에서 ObjectProvider
를 Provider
로, getObject()
를 get()
으로 바꾸면 된다.
@Scope("singleton")
static class ClientBean {
@Autowired
private Provider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.get();
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
프로토타입 빈은 사용할 때마다 의존관계 주입이 완료된 새로운 객체가 필요할 때 사용하면 되는데, 실무에서 직접적으로 사용하는 일은 거의 없다.
Provider
의 매뉴얼을 보면 다음과 같은 상황에서 사용할 수 있다고 소개한다.
ObjectProvider
, Provider
등은 프로토타입 뿐만 아니라 DL이 필요한 경우는 언제든지 사용할 수 있다.
[!Tip] 자바 표준 vs. 스프링 제공
(거의 그럴 일은 없겠지만) 코드를 스프링이 아닌 다른 컨테이너에서도 사용할 수 있어야 한다면, 자바 표준
Provider
를 사용해야 한다.하지만 스프링이 제공하는
ObjectProvider
는 DL을 위한 여러 편의기능을 추가로 제공해준다.이처럼 자바 표준과 스프링 제공 기능이 겹치는 경우에는, 대부분 스프링이 더 편리한 기능을 제공해주기 때문에 다른 컨테이너를 사용할 일이 없다면 스프링이 제공하는 기능을 사용하는 것을 권장한다.
ServletContext
)와 동일한 생명주기를 가지는 스코프다음의 그림에서, Controller
와 Service
는, HTTP request 단위로 같은 request 스코프 빈 객체를 참조하게 된다.
동시에 여러 HTTP request가 올 때, 해당 request 마다 구별되도록 로그를 남기는 예제를 다뤄보자. 이때, 로그는 컨트롤러 계층과 서비스 계층에서 모두 남기도록 한다.
공통 포맷: [UUID][requestURL] {message}
사실 이런 동작은 컨트롤러보다는 공통 처리가 가능한 스프링 인터셉터나 서블릿 필터 같은 곳을 활용하는 것이 좋다.
web 라이브러리를 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-web'
해당 라이브러리를 통해 스프링 부트는 내장 톰캣 서버를 활용하여 웹 서버와 스프링을 함께 실행시킨다.
Tomcat started on port 8080 (http) with context path ''
스프링 부트는 웹 라이브러리가 없으면 AnnotationConfigApplicationContext
를 기반으로 어플리케이션을 구동하고, 웹 라이브러리가 있으면 관련 추가 설정 및 환경이 필요하기 때문에 AnnotationConfigServletWebServerApplicationContext
를 기반으로 어플리케이션을 구동한다.
MyLogger
, LogDemoController
, LogDemoService
코드를 작성한다.
request 스코프 빈은 HTTP request 단위로 존재하며, 스프링 컨테이너에게 요청하는 시점에 생성되고 HTTP request가 끝나는 시점에 소멸된다.
따라서
MyLogger
는 다른 HTTP request 때문에 값이 섞일 일은 없다.
uuid
는 빈이 생성되는 시점에 생성해서 저장할 수 있으나, requestURL
은 빈 생성 시점에 알 수 없으므로 setter로 입력 받는다.[!Tip] 스프링 컨테이너에게 request 스코프 빈(
MyLogger
)을 요청하는 것을 의존관계 주입 단계가 아닌, 실제 고객 요청이 왔을 때로 지연시켜야 한다.→
Provider
를 사용해야 한다!
[!Note] 비즈니스 로직이 있는 서비스 계층에서도 로그를 출력할 때, request 스코프를 사용하지 않는다면 모든 정보를 파라미터로 서비스 계층에 넘겨야 한다.
이렇게 되면 지저분해질 뿐만 아니라, 웹과 관련된 정보가 웹과 관련 없는 서비스 계층까지 넘어가게 된다는 것이다. 웹 관련 부분은 컨트롤러까지만 사용하여, 서비스 계층은 웹 기술에 종속되지 않도록 관리하는 것이 유지보수 관점에서 좋다.
LogDemoController
,LogDemoService
에MyLogger
를 DL 할 수 있는 provider가 주입되도록 수정한다.
ObjectProvider
를 사용하여LogDemoController
,LogDemoService
를 수정할 수 있다.
ObjectProvider
를 통해 그 호출 시점까지 빈의 생성(엄밀히 말하면 스프링 컨테이너에게 해당 빈을 요청하는 것)을 지연시킬 수 있다.
```java
@Controller
@RequiredArgsConstructor
public class LogDemoController {
/*
request 스코프 빈은 실제 고객의 요청이 와야지만 생성할 수 있으므로,
스프링 컨테이너에게 request 스코프 빈(MyLogger)을 요청하는 것을 의존관계 주입 단계가 아닌,
실제 고객 요청이 왔을 때로 지연시켜야 한다.
*/
private final LogDemoService logDemoService;
private final ObjectProvider<MyLogger> myLoggerProvider; // MyLogger를 DL 할 수 있는 provider가 주입된다.
@RequestMapping("log-demo")
@ResponseBody // 데이터를 그대로 반환하기 위함
public String logDemo(HttpServletRequest request) {
MyLogger myLogger = myLoggerProvider.getObject();
...
```
```java
@Service
@RequiredArgsConstructor
public class LogDemoService {
/*
request 스코프 빈은 실제 고객의 요청이 와야지만 생성할 수 있으므로,
스프링 컨테이너에게 request 스코프 빈(MyLogger)을 요청하는 것을 의존관계 주입 단계가 아닌,
실제 고객 요청이 왔을 때로 지연시켜야 한다.
*/
private final ObjectProvider<MyLogger> myLoggerProvider;
public void logic(String id) {
MyLogger myLogger = myLoggerProvider.getObject();
...
```
url로 접속하면 다음과 같이 로그가 남게 된다. 요청 별로 uuid가 다르게 생성되는 것도 확인 가능하다.
[b30dcdc9-dc2d-49b6-8408-b881d63edd09] request scope bean create: hello.core.common.MyLogger@626386c1
[b30dcdc9-dc2d-49b6-8408-b881d63edd09][http://localhost:8080/log-demo] controller test
[b30dcdc9-dc2d-49b6-8408-b881d63edd09][http://localhost:8080/log-demo] service id = testId
[b30dcdc9-dc2d-49b6-8408-b881d63edd09] request scope bean close: hello.core.common.MyLogger@626386c1
[7f134d0b-652b-42b0-9d03-d71ca95ab88c] request scope bean create: hello.core.common.MyLogger@662d847d
[7f134d0b-652b-42b0-9d03-d71ca95ab88c][http://localhost:8080/log-demo] controller test
[7f134d0b-652b-42b0-9d03-d71ca95ab88c][http://localhost:8080/log-demo] service id = testId
[7f134d0b-652b-42b0-9d03-d71ca95ab88c] request scope bean close: hello.core.common.MyLogger@662d847d
동시에 여러 요청이 오더라도, 요청마다 각각 객체를 따로 관리된다는 것이 핵심이다. 이는 Thread.sleep(1000)
을 추가하여 확인할 수 있다.
[e422b0e1-8e27-412b-a4ff-0a0a4b095a1f] request scope bean create: hello.core.common.MyLogger@525c9694
[e422b0e1-8e27-412b-a4ff-0a0a4b095a1f][http://localhost:8080/log-demo] controller test
[b1e3eabe-5599-4120-a585-1e8493e6b6e5] request scope bean create: hello.core.common.MyLogger@5ac23cf9
[b1e3eabe-5599-4120-a585-1e8493e6b6e5][http://localhost:8080/log-demo] controller test
[852190ad-ce10-4bdc-82cb-46b29b99d751] request scope bean create: hello.core.common.MyLogger@6836b85e
[852190ad-ce10-4bdc-82cb-46b29b99d751][http://localhost:8080/log-demo] controller test
[e422b0e1-8e27-412b-a4ff-0a0a4b095a1f][http://localhost:8080/log-demo] service id = testId
[e422b0e1-8e27-412b-a4ff-0a0a4b095a1f] request scope bean close: hello.core.common.MyLogger@525c9694
[!Note]
ObjectProvider.getObject()
를 컨트롤러와 서비스에서 각각 한 번씩 따로 호출하더라도, 같은 HTTP request이면 같은 스프링 빈이 반환된다. 이를 직접 구분하려면 스레드 로컬 등등.. 어렵다. (FastAPI로 context local 등등 직접 구현하려고 했었다^^)
provider를 사용하는 예제에서, provider로 받아서 거기에서 다시 꺼내는 것이 귀찮다! 이를 편리하게 개선하려면?
[!Tip] 프록시를 이용하여 provider에서 꺼내는 동작 없이도 마치 provider를 사용했을 때처럼 지연 생성이 가능하다.
ObjectProvider
를 추가했던 부분을 다시 원래대로 돌려놓는다. (싱글톤 사용하듯이)request 스코프 빈인 MyLogger
에 다음의 어노테이션을 추가한다.
적용 대상이 인터페이스이면
INTERFACES
를, 인터페이스가 아닌 클래스이면TARGET_CLASS
를 추가한다.
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
이렇게 하면 MyLogger
의 가짜 프록시 클래스를 만들어두고 HTTP request와 상관 없이 가짜 프록시 클래스를 다른 빈에 미리 주입해둘 수 있다.
가짜 프록시 클래스를 출력해보면 다음과 같이, CGLIB이라는 라이브러리를 통해 생성되어 대신 등록되어 있는 것을 알 수 있다. ac.getBean("myLogger", MyLogger.class)
로 조회해도 이 프록시 객체가 조회된다.
myLogger = class hello.core.common.MyLogger$$SpringCGLIB$$0
따라서 의존관계 주입 시점에서도 이 가짜 프록시 객체가 주입되며, 이는 실제 request 스코프와는 관계 없이 싱글톤처럼 동작한다. 내부에는 진짜 myLogger
를 요청하는 위임 로직이 들어있고, 클라이언트가 myLogger.log()
를 호출하면 사실은 가짜 프록시 객체의 메서드를 호출하게 된다.
가짜 프록시 객체는 원본 클래스를 상속 받아 만들어졌으므로, 클라이언트 입장에서는 원본인지 아닌지도 모르게 사용할 수 있다. (→ 다형성)
특징은 다음과 같다.
주의할 점은 다음과 같다.
[!Tip] provider를 사용하든 프록시를 사용하든, 핵심 아이디어는 진짜 객체 조회를 꼭 필요한 시점까지 지연처리 한다는 점이다.
Contents