밍쎄의 코딩공간
스프링 핵심 원리 - 싱글톤 본문
싱글톤 컨테이너
웹 애플리케이션과 싱글톤
@Test
@DisplayName("스프링 없는 순수한 DI 컨테이너")
void pureContainer() {
AppConfig appConfig = new AppConfig();
MemberService memberService1 = appConfig.memberService();
MemberService memberService2 = appConfig.memberService();
System.out.println("memberService1 = " + memberService1);
System.out.println("memberService2 = " + memberService2);
Assertions.assertThat(memberService1).isNotSameAs(memberService2);
}
이전에 만든 스프링 없는 순수한 DI 컨테이너인 AppConfig는 요청을 할 때마다 객체를 새로 생성합니다. 문제는 보통의 웹 애플리케이션은 여러 고객이 동시에 요청을 합니다. 고객 트래픽 초당 100이 나오면, 초당 100개 객체가 생성되고 소멸되는 꼴입니다. 이 문제를 해결하기 위해 해당 객체가 딱 1개만 생성되고 공유하도록 설계한 싱글톤 패턴을 이용합니다.
싱글톤 패턴
public class SingletonService {
private static final SingletonService instance = new SingletonService();
public static SingletonService getInstance() {
return instance;
}
private SingletonService() {
}
}
- static 영역에 객체를 미리 하나 생성해서 올려둡니다.
- 이 객체 인스턴스가 필요하면 오직 getInstance 메서드를 통해서만 조회할 수 있습니다. (항상 같은 객체를 반환)
- 오직 1개의 객체 인스턴스만 존재해야 하므로, 생성자를 private로 막아서 외부에서 생성되는 것을 막도록 합니다.
하지만 싱글톤 패턴에는 여러 문제점이 있습니다.
- 싱글톤 패턴을 구현하는 코드 자체가 많이 들어갑니다.
- 의존 관계상 클라이언트가 구체 클래스에 의존합니다. 결국 DIP를 위반합니다.
- 클라이언트가 구체 클래스에 의존해서 OCP 원칡을 위반할 가능성이 높습니다.
- 테스크 코드 작성이 어렵습니다.
- 내부 속성을 변경하거나 초기화하기 어렵습니다.
- private 생성자로 자식 클래스를 만들기 어렵습니다.
결론적으로, 유연성이 떨어져서 싱글톤 패턴은 안티패턴으로 불리기도 합니다.
싱글톤 컨테이너
스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하며 객체 인스턴스를 싱글톤으로 관리합니다. (싱글톤 컨테이너 역할을 하는 기능을 싱글톤 레지스트리라 합니다.)
- 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리합니다.
- DIP, OCP, 테스트 코드 작성, private 생성자로 부터 자유롭게 싱글톤을 사용할 수 있습니다.
싱글톤 방식의 주의점
이 방식을 사용하면 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안되고, 무상태(stateless)로 설계해야 합니다.
- 특정 클라이언트에 의존적인 필드가 있으면 안됩니다.
- 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안됩니다.
- 가급적 읽기만 가능해야 합니다.
- 필드 대신 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 합니다.
@Configuration과 싱글톤
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
}
memberService와 orderService 빈을 만드는 코드를 보면 각각 memberRepository()를 호출해서 new MemoryMemberRepository()가 호출됩니다. 결과적으로 서로 다른 2개의 객체 인스턴스가 생성되면서 싱글톤이 깨지는 것처럼 보이지만, 스프링 컨테이너는 이를 하나의 객체로 유지시킵니다.
@Test
void configurationTest() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);
MemberRepository memberRepository1 = memberService.getMemberRepository();
MemberRepository memberRepository2 = orderService.getMemberRepository();
System.out.println("memberRepository1 = " + memberRepository1);
System.out.println("memberRepository2 = " + memberRepository2);
System.out.println("memberRepository = " + memberRepository);
Assertions.assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
Assertions.assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
}
직접 테스트 코드를 작성해서 확인해봐도, memberRepository1과 memberRepository2는 같은 객체로 조회니다.
바이트코드 조작
스프링 컨테이너는 싱글톤 레지스트리로, 스프링 빈이 싱글톤이 되도록 보장해야 합니다. 그러나 자바 코드까지 조작하기는 어려우므로, 클래스의 바이트 코드를 조작하는 리이브러리를 사용합니다.
@Test
void configurationDeep() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean);
//출력: bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$bd479d70
}
순수한 클래스라면 class hello.core.AppConfig가 출력되어야 하겠지만, @Configuration을 적용한 AppConfig는 CGLIB라는 바이트 코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록합니다.
해당 임의의 다른 클래스가 싱글톤이 보장되도록 해줍니다.
- @Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 스프링 빈이 없으면 생성해서 빈으로 등록하고 반환하는 코드가 동적으로 만들어집니다.
컴포넌트 스캔
기본 개념
이전까지 스프링 빈을 등록할 때는 자바 코드에 @Bean을 통해서 설정 정보에 직접 등록할 빈을 나열했습니다. 하지만 이렇게 등록해야하는 빈의 수가 커지면 단순 반복, 설정 정보의 증가, 누락 등의 문제가 발생할 수 있습니다. 그래서 스프링은 설정 정보가 없어도 자동으로 스프링 빈을 등록하는 컴포넌트 스캔 기능을 제공합니다. 더불어서, 의존 관계를 자동으로 주입하는 @Autowired 기능도 제공합니다.
- 컴포넌트 스캔을 사용하려면 @ComponentScan을 설정 정보에 붙여주면 됩니다. (기존의 AppConfig와는 다르게 @Bean으로 등록한 클래스가 하나도 없습니다.)
- 컴포넌트 스캔은 @Component 애노테이션이 붙은 클래스를 스캔해서 스프링 빈으로 등록합니다. 따라서 각 클래스가 스캔의 대상이 되도록 @Component 애노테이션을 붙여주어야 합니다.
- 스프링 빈과의 의존 관계 주입은 각 클래스 안에서 해결해야 합니다. 이때 @Autowired를 사용합니다.
@Configuration
@ComponentScan(
// basePackages = {"hello.core"},
excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class)
)
public class AutoAppConfig {
}
@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;
}
}
컴포넌트 스캔을 사용하면 @Configuration이 붙은 설정 정보도 자동으로 등록되기 때문에, AppConfig, TestConfig등 앞서 만들어두었던 설정 정보도 함께 등록되고 실행되어 버립니다. 그래서 excludeFilters를 사용해서 설정 정보는 컴포넌트 스캔 대상에서 제외했습니다. 보통은 설정 정보를 컴포넌트 스캔 대상에서 제외하지 않지만, 기존 예제 코드를 최대한 남기기 위해서 이 방법을 선택했습니다.
- @ComponenetScan
- @Component가 붙은 모든 클래스를 스프링 빈으로 등록합니다.
- 스프링 빈의 기본 이름은 클래스명을 사용하되 맨 앞 글자만 소문자를 사용합니다.
- @Autowired 의존 관계 자동 주입
- 생성자에 @Autowired를 지정하면, 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입합니다.
- 기본 조회 전략은 타입이 같은 빈을 찾는 것입니다.
탐색 위치
모든 클래스를 스캔하면 시간이 오래 걸릴 수 있기 때문에 특정 위치부터 탐색하도록 시작 위치를 지정할 수 있습니다.
@ComponentScan(
basePackages = {"hello.core"}
)
- basePackages로 시작 위치를 지정해서, 해당 패키지를 포함한 하위 패키지를 모두 탐색합니다.
- 만약 지정하지 않으면 @ComponentScan이 붙은 설정 정보 클래스의 패키지가 시작 위치가 됩니다.
권장하는 방법은 패키지 위치를 별도로 지정하지 않고, 설정 정보 클래스의 위치를 프로젝트 최상단에 두는 것입니다. 이렇게 하면 하위 패키지가 모두 스캔 대상이 됩니다.
컴포넌트 스캔 기본 대상
컴포넌트 스캔은 @Component 뿐만 아니라 여러 대상을 추가로 포함합니다.
- @Component
- 컴포넌트 스캔에서 사용합니다.
- @Controller
- 스프링 MVC 컨트롤러로 인식합니다.
- @Service
- 특별한 처리를 하지 않지만, 보통 개발자들이 핵심 비즈니스 로직을 여기에 위치시켜서 비즈니스 계층을 인식하는데 도움이 됩니다.
- @Repository
- 스프링 데이터 접근 계층으로 인식하고, 데이터 계층의 예외를 스프링 예외로 변환해줍니다.
- @Configuration
- 스프링 설정 정보로 인식하고, 스프링 빈이 싱글톤을 유지하도록 추가 처리를 합니다.
필터
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyIncludeComponent {
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyExcludeComponent {
}
@MyIncludeComponent
public class BeanA {}
@MyExcludeComponent
public class BeanB {}
public class ComponentFilterAppConfigTest {
@Test
void filterScan() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ComponentFilterAppConfig.class);
BeanA beanA = ac.getBean("beanA", BeanA.class);
assertThat(beanA).isNotNull();
assertThrows(
NoSuchBeanDefinitionException.class,
() -> ac.getBean("beanB", BeanB.class)
);
}
@Configuration
@ComponentScan(
includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class),
excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = MyExcludeComponent.class)
)
static class ComponentFilterAppConfig {
}
}
- includeFilters: 컴포넌트 스캔 대상을 추가로 지정합니다.
- excludeFilters: 컴포넌트 스캔에서 제외할 대상을 지정합니다.
중복 등록과 충돌
- 자동 빈 등록 vs 자동 빈 등록
- 컴포넌트 스캔에 의해 자동으로 스프링 빈이 등록되는데, 이름이 같은 경우 스프링은 오류를 발생시킵니다.
- 수동 빈 등록 vs 자동 빈 등록
- 수동 빈이 자동 빈을 오버라이딩 해버려서, 수동 빈 등록이 우선권을 가집니다.
의존 관계 자동 주입
주입 방법
DI에는 크게 4가지 방법이 있습니다.
- 생성자 주입
@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번만 호출되는 것이 보장되는 것이 특징으로, 불변, 필수 의존 관계에 사용합니다.그리고 생성자가 1개인 스프링 빈이라면, @Autowired를 생략해도 자동 주입이 됩니다.
- setter 주입
@Component
public class OrderServiceImpl implements OrderService {
private MemberRepository memberRepository;
private DiscountPolicy discountPolicy;
@Autowired
public void setMemberRepository(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Autowired
public void setDiscountPolicy(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
필드 값을 변경하는 수정자 메서드를 통해서 , 선택, 변경 가능성이 있는 의존 관계에 사용합니다.
- 필드 주입
@Component
public class OrderServiceImpl implements OrderService {
@Autowired
private MemberRepository memberRepository;
@Autowired
private DiscountPolicy discountPolicy;
}
필드에 바로 주입하는 방법으로, 코드가 간결하지만 DI 프레임워크가 없으면 아무것도 할 수 없게 되므로 실제 코드에서는 사용하지 않은 것이 좋습니다. 테스트 코드 혹은 스프링 설정을 목적으로 하는 @Configuration 같은 곳에서만 특별한 용도로 사용합니다.
- 일반 메서드 주입
@Component
public class OrderServiceImpl implements OrderService {
private MemberRepository memberRepository;
private DiscountPolicy discountPolicy;
@Autowired
public void init(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
}
한번에 여러 필드를 주입 받을 수 있으나 일반적으로 잘 사용하지 않습니다.
생성자 주입
최근에는 DI 프레임워크 대부분이 다음과 같은 이유로 생성자 주입을 권장합니다.
- 대부분의 의존 관계는 애플리케이션 종료 전까지 변경할 일이 없습니다.
- 수성자 주입을 사용하려면 메서드를 public으로 열어두어야 하는데, 이는 좋은 설계법이 아닙니다.
- 필요한 의존 관계가 누락되었을 때, 컴파일 오류로 쉽게 고칠 수 있습니다. (추가로, final 키워드를 사용할 수도 있습니다.)
따라서 개발을 할 때 대부분의 경우 생성자에 final 키워드를 사용해서 만드는데, 이를 간편하게 해주는 라이브러리 롬복이 있습니다.
@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
}
롬복 라이브러리가 제공하는 @RequiredArgsConstructor 기능을 사용하면 final이 붙은 필드를 모아서 생성자를 자동으로 만들어줍니다.
의존 관계의 조회되는 빈이 2개 이상인 경우
@Autowired
private DiscountPolicy discountPolicy
@Component
public class FixDiscountPolicy implements DiscountPolicy {}
@Component
public class RateDiscountPolicy implements DiscountPolicy {}
@Autowired는 타입으로 조회하기 때문에 선택된 빈이 2개 이상일 때 NoUniqueBeanDefinitionException 오류가 발생합니다. 이때 하위 타입으로 지정할 수도 있지만, DIP를 위배하고 유연성이 떨어지기 때문에 @Autowried에 필드명을 적용해서 해결합니다.
- @Autowired 필드명 매칭@Autowired는 타입 매칭을 시도하고 ,이때 여러 빈이 있으면 필드 이름, 파라미터 이름으로 빈 이름을 추가 매칭합니다. 필드명이 rateDiscountPolicy이므로 정상 주입됩니다.
- @Autowired private DiscountPolicy rateDiscountPolicy
- @Qualifier 사용
@Component @Qualifier("fixDiscountPolicy") public class FixDiscountPolicy implements DiscountPolicy {}
빈 등록시 @Qualifier를 붙여서 등록하고, 의존 관계 주입시에 @Qualifier로 등록한 이름을 적어줍니다. 만약 주입할 때 @Qualifier로 등록한 이름이 없다면, 빈 이름을 추가로 찾습니다.@Autowired public OrderServiceImpl(MemberRepository memberRepository, @Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; }
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented @Qualifier("mainDiscountPolicy") public @interface MainDiscountPolicy { }
@Component @MainDiscountPolicy public class RateDiscountPolicy implements DiscountPolicy {}
애노테이션에는 상속이라는 개념은 없습니다. 다만 스프링은 여러 애노테이션을 모아서 사용하는 기능을 제공합니다.@Autowired public OrderServiceImpl(MemberRepository memberRepository, @MainDiscountPolicy DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; }
- 추가로, @Qualifier("mainDiscountPolicy") 이렇게 문자를 적으면 컴파일시 타입 체크가 안됩니다. 다음과 같은 애노테이션을 만들어서 문제를 해결할 수 있습니다.
- @Component @Qualifier("mainDiscountPolicy") public class RateDiscountPolicy implements DiscountPolicy {}
- @Primary 사용
@Component public class FixDiscountPolicy implements DiscountPolicy {}
@Primary로 우선 순위를 정해서 의존 관계를 주입할 수 있습니다.@Autowired public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; }
- @Component @Primary public class RateDiscountPolicy implements DiscountPolicy {}
@Primary는 마치 기본값처럼 동작하는 것이고, @Qualifier는 매우 상세하게 동작합니다. 두 경우를 모두 사용한 경우 자동보다는 수동이, 넓은 범위의 선택권보다는 좁은 범위의 선택권이 우선 순위가 높아서 @Qualifier의 우선권이 높습니다.
조회한 빈이 모두 필요한 경우
public class AllBeanTest {
@Test
void findAllBean() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);
DiscountService discountService = ac.getBean(DiscountService.class);
assertThat(discountService).isInstanceOf(DiscountService.class);
Member member = new Member(1L, "userA", Grade.VIP);
int discountPrice = discountService.discount(member, 10000, "fixDiscountPolicy");
assertThat(discountPrice).isEqualTo(1000);
}
static class DiscountService {
private final Map<String, DiscountPolicy> policyMap;
private final List<DiscountPolicy> policies;
public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) {
this.policyMap = policyMap;
this.policies = policies;
}
public int discount(Member member, int price, String discountCode) {
DiscountPolicy discountPolicy = policyMap.get(discountCode);
return discountPolicy.discount(member, price);
}
}
}
- Map<String, DiscountPolicy>: 맵의 키에 스프링 빈의 이름을 넣어주고, 그 값으로 DiscountPolicy 타입으로 조회한 모든 스프링 빈을 담아줍니다.
- List<DiscountPolicy>: DiscountPolicy 타입으로 조회한 모든 스프링 빈을 담아줍니다. (만약 해당하는 타입의 스프링 빈이 없으면, 빈 컬렉션이나 맵을 주입합니다.)
스프링 핵심 원리 기본편 정리
스프링 핵심 원리 기본편 강좌 정리본입니다.
velog.io
'개념정리' 카테고리의 다른 글
스프링부트를 통해 계좌 만들기 (0) | 2023.08.27 |
---|---|
Service, ServiceImpl (0) | 2023.08.27 |
스프링 핵심 원리 기본편 정리 (0) | 2023.08.20 |
깊이 우선 탐색 ( DFS ) (0) | 2023.08.13 |
그리디(Greedy) (0) | 2023.08.13 |