섹션 5. 싱글톤 컨테이너
본 시리즈는 우아한형제들 개발 팀장이신
김영한
님의스프링 핵심 원리 - 기본편
강의를 들으며 개인적으로 정리한 내용을 담고 있습니다. 제가 들은 강의는 인프런에 등록되어 있습니다. 모든 다이어그램을 포함한 사진의 출처는 위 강의의 강의록임을 밝힙니다. 개인적으로 정리한 내용이기 때문에 글 내용에 오류가 있을 수 있으며 이에 대한 피드백은 댓글로 부탁드립니다.
이번 섹션에서 다룰 내용
- 웹 app에서 싱글톤이 중요한 이유
- 싱글톤 패턴이 무엇이고 어떻게 사용하는지
@Configuration
에 대해서
웹 애플리케이션과 싱글톤
- 웹 app은 보통 여러 고객이 동시에 요청을 하는 특성을 가진다.
- 그렇다보니 우리가 이전에 작성한
AppConfig
같은 겨우 한 번에 여러 요청이 갈 수 있는데, 그 때마다 빈을 새로 생성하게 된다.
테스트를 통해 검증해보자.
public class SingletonTest {
@Test
@DisplayName("스프링 없는 순수한 DI 컨테이너")
void pureContainer() {
AppConfig appConfig = new AppConfig();
//1. 조회: 호출할 때마다 객체를 생성
MemberService memberService1 = appConfig.memberService();
//2. 조회: 호출할 때마다 객체를 생성
MemberService memberService2 = appConfig.memberService();
//참조값이 다른 것을 확인
System.out.println("memberService1 = " + memberService1);
System.out.println("memberService2 = " + memberService2);
//memberServicec1 != memberService2
assertThat(memberService1).isNotSameAs(memberService2);
}
}
- 두
memberService
의 참조값이 다르게 나오는 것을 확인할 수 있다. - 이는
AppConfig
에 요청이 올 때마다 객체를 새로 생성하기 때문이다.- 메모리 낭비가 매우 심하다!
- 그래서 싱글톤 패턴이 필요하다.
싱글톤 패턴
- 싱글톤 패턴을 적용하여 문제를 해결해보자!
- 싱글톤 패턴은 어떤 클래스의 인스턴스가 딱 한 개만 생성되도록 하는 디자인 패턴이다.
- 즉, 객체를 두 개 이상 생성하지 못하게 막으면 된다.
- 어떻게??? 코드 예시를 보자.
public class SingletonService {
private static final SingletonService instance = new SingletonService();
public static SingletonService getInstance() {
return instance;
}
private SingletonService() {} // private 생성자
public void logic() {
System.out.println("싱글톤 객체 로직 호출");
}
}
- static 영역에 객체 instance를 미리 하나 생성해둔다.
static
으로 선언되면 프로그램이 실행되자마자 바로 객체가 생성된다.
- 객체 인스턴스가 필요하면
getInstance()
메소드로만 호출할 수 있다. - 딱 하나의 인스턴스만 존재하도록 기본 생성자를
private
으로 설정해서 외부에서 호출할 수 없도록 한다.
테스트도 해보자.
public class SingletonTest {
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
void singletonServiceTest() {
SingletonService singletonService1 = SingletonService.getInstance();
SingletonService singletonService2 = SingletonService.getInstance();
System.out.println("singletonService1 = " + singletonService1);
System.out.println("singletonService2 = " + singletonService2);
assertThat(singletonService1).isSameAs(singletonService2);
//same: == (레퍼런스 비교)
//equal: equals (값 비교)
}
}
- 잘 된다!
- 객체를 새로 생성하지 않고 있는 객체를 그대로 재활용하니까 성능이 상당히 좋아진다.
- 참고로
assertThat
뒤에 연속으로 붙는 함수로isSameAs()
가 들어갔는데, 이 때는 레퍼런스를 비교하는 것이다.
그렇다면 매번 이렇게 모든 객체를 싱글톤 패턴을 이용해 만들어야할까? NO!
스프링에게 부탁하면 스프링이 알아서 해준다! (기가 막히죠)
그런데 싱글톤 패턴도 문제가 있다.
싱글톤 패턴의 문제점
- 싱글톤 패턴을 적용하느라 메인 로직과 무관한 각종 코드가 들어가야 한다.
- 의존관계 상 클라이언트가 구현 클래스에 의존해야한다. 그래서 DIP를 위반하게 된다.
- 정확히는
AppConfig
같은 클래스를 이용하여 이런 문제를 회피할 수 있지만 싱글톤 패턴의 의도 자체가getInstance()
를 호출할 때 구현 클래스를 의존하게 하는 것이다. (?)
- 정확히는
- 구현 클래스에 의존하다보니 OCP를 위반하게 될 가능성이 높다.
- 테스트하기 어렵다.
- 유연성이 떨어진다.
근데 신기하게도 스프링에서는 이런 단점을 모두 제거하고 객체를 싱글톤으로 관리해준다고 한다. 그게 어떻게 가능하지?
싱글톤 컨테이너
스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서 각 객체 인스턴스를 싱글톤으로 관리한다.
지금까지 배운 스프링 빈이 바로 싱글톤으로 관리되는 빈이다.
- 스프링 컨테이너는 알아서 객체 인스턴스를 싱글톤으로 관리한다.
- 즉, 컨테이너 전역에 객체 당 인스턴스를 딱 하나만 갖고 있다.
- 스프링 컨테이너는 싱글톤 컨테이너 역할을 한다. 이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 한다.
- 스프링 컨테이너의 이런 기능 덕분에 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
- 싱글톤 패턴만을 위한 추가적인 코드가 필요치 않다.
- 유연성을 유지할 수 있다.
스프링 컨테이너를 이용해서 싱글톤 테스트를 해보자.
public class SingletonTest {
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService1 = ac.getBean("memberService", MemberService.class);
MemberService memberService2 = ac.getBean("memberService", MemberService.class);
//참조값이 같은 것을 확인
System.out.println("memberService1 = " + memberService1);
System.out.println("memberService2 = " + memberService2);
//memberServicec1 == memberService2
assertThat(memberService1).isSameAs(memberService2);
}
}
- 스프링이 싱글톤으로 객체를 생성해주기 때문에 두 객체의 참조값이 같은 것을 확인할 수 있다.
- 스프링 컨테이너를 사용해야 할 큰 이유 중 하나이다! (직접
AppConfig
를 만드는 것보다 스프링을 쓰는 게 나은 이유)
참고: 스프링의 기본 빈 등록 방식은 싱글톤이지만, 싱글톤 방식만 지원하는 것은 아니다. 요청할 때마다 새로운 객체를 생성해서 반환하는 기능도 제공한다. 그러나 대개는 싱글톤으로 생성된 인스턴스를 사용하게 된다. 자세한 내용은 뒤에 빈 스코프에서 설명한다.
싱글톤 방식의 주의점
- 싱글톤 방식은 여러 클라이언트가 객체 인스턴스를 공유하여 사용하기 때문에 상태를 유지(stateful)하게 설계하면 안된다.
- 무상태(stateless)로 설계해야 한다!
- 특정 클라이언트에 의존적인 필드가 있으면 안된다.
- 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다!
- 가급적 읽기만 가능해야 한다!
- 필드 대신에 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.
- 스프링 빈의 필드에 공유값을 설정하면 큰 장애가 발생할 수 있다!
- 실무에서도 이것 때문에 굉장히 고생한다고 함... 중요한 내용인듯
어떤 문제가 발생할 수 있는지 코드로 알아보자
StatefulService
: 상태를 갖는 객체를 가정한 클래스
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;
}
}
StatefulServiceTest
: 테스트 코드
class StatefulServiceTest {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
@Test
void statefulServiceSingleton() {
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); //20000원이 나옴. 엥?
assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
order
객체가 싱글톤으로 생성되었고, 사용자 A가 주문 금액을 조회하기 직전에 사용자 B가 주문 금액(price
필드)을 수정했기 때문에 의도하지 않은 오류가 발생했다.- 실무에서도 종종 발생하는 일이고, 아주 치명적인 오류가 된다. (특히 돈이랑 관련되면.. ㄷㄷㄷ)
- 이 문제는 결국 클라이언트가 필드의 값을 변경했기 때문에 발생한 에러이다.
- 그래서 스프링 빈은 항상 무상태(stateless)로 설계해야한다.
- 요컨대 위 코드에서는
order
함수가 그냥 매개변수로 넘겨받은price
를 반환하게 하고price
필드는 지워버리면 된다.
- 요컨대 위 코드에서는
(이런 문제가) 몇 년에 한 번씩 꼭 나오더라고요. 공유필드는 진짜 조심해야되고요, 스프링 빈은 항상 무상태로 설계해야 한다는 걸 꼭 기억하셔야됩니다.
@Configuration과 싱글톤
@Configuration
은 싱글톤을 위해 존재하는 것이다.
@Configuration
의 역할에 대해 알아보기 위해서, AppConfig
를 다시 한 번 보자.
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public MemoryMemberRepository 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()
가 두 번 호출되어서 싱글톤이 깨지는 게 아닌가? 하는 의문을 가질 수 있다.
과연 진짜 그럴까? 테스트를 해보자.
먼저 OrderServiceImpl
과 MemberServiceImpl
에 임시로 테스트용 메소드를 만든다. (getMemberRepository()
) 그리고나서 테스트 코드를 작성한다.
public class ConfigurationSingletonTest {
@Test
void configurationTest() {
ApplicationContext 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("memberService -> memberRepository1 = " + memberRepository1);
System.out.println("orderService -> memberRepository2 = " + memberRepository2);
System.out.println("memberRepository = " + memberRepository);
assertThat(memberRepository).isSameAs(memberRepository1).isSameAs(memberRepository2);
}
}
-
모든
memberRepository
가 같은 주소를 갖고 있음을 확인할 수 있다. -
상식적으로 생성자가 여러 번 불렸는데 이게 어떻게 가능한 일일까? 혹시 생성자가 두 번 이상 호출되지 않는 건 아닐까?
AppConfig
의 각 메소드에 호출될 때마다 콘솔 출력을 남기도록 추가하고 테스트해보자.- 총 세 번의 콘솔 출력이 발생한다.
call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.orderService
memberRepository가 세 번 호출되어야 할텐데 왜 한 번만 호출되었을까?
@Configuration과 바이트코드 조작의 마법
스프링 컨테이너는 싱글톤 레지스트리이므로 스프링 빈이 싱글톤이 되도록 보장해줘야 한다. 위 사례는 스프링 컨테이너가 memberRepository
객체를 싱글톤으로 잘 유지한 사례이다. 그런데 어떻게 이게 가능했을까? 자바 코드 상으로는 new MemoryMemberRepository()
가 세 번 호출되어야 하는 상황인데..
비밀은 바로 @Configuration
에 있다. AppConfig
스프링 빈을 조회해서 클래스 정보를 출력해보자.
@Test
void configurationDepp() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean.getClass());
}
결과는 다음과 같이 나온다.
bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$25fba07b
hello.core.AppConfig
까진 알겠는데 그 뒤에 요오상한 $$EnhancerBySpringCGLIB$$
는 뭘까?
이는 내가 만든 클래스가 아니라 스프링이 CGLIB
라는 바이트코드 조작 라이브러리를 사용해서 AppConfig
클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록했다는 것을 보여준다. 엥?
스프링이 새로 만든 이 요오상한 클래스가 바로 싱글톤이 보장되도록하는 역할을 한다. 그니까 실제로 스프링 컨테이너에 스프링 빈으로 등록되는 건 내가 만든 클래스가 아니고 스프링이 새로 만든 클래스다.
AppConfig@CGLIB 예상 코드
@Bean
public MemberRepository memberRepository() {
if (memoryMemberRepository가 이미 스프링 컨테이너에 있으면) {
return 스프링 컨테이너에서 찾아서 반환;
} else { //스프링 컨테이너에 없으면
기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
return 반환
}
}
@Bean
이 붙은 모든 각 메소드에 대해 이런 코드가 자동으로 생성된다. 이 덕분에 싱글톤이 보장된다.- 참고로
AppConfig@CGLIB
은AppConfig
의 자식 클래스기 때문에AppConfig
타입으로도 조회가 가능한 것이다.
@Configuration
을 적용하지 않고 @Bean
만 적용하면 어떻게 될까?
@Configuration
을 붙이면 바이트코드를 조작하는 CGLIB
기술을 사용해서 싱글톤을 보장하지만, 만약 @Configuration
을 제거하고 @Bean
만 남기면 어떻게 될까?
//@Configuration
public class AppConfig {
...
}
@Configuration
을 제거하고 동일한 테스트를 해보면 다음과 같은 결과가 다온다.
bean = class hello.core.AppConfig
- 테스트 결과를 보면 우리가 작성한
AppConfig
가 그대로 스프링 빈을 등록되었음을 알 수 있다. - 또한
memberService
와orderService
가 가진memberRepository
의 레퍼런스를 출력해보면, 서로 다른 인스턴스임을 알 수 있다. - 그리고 문제가 하나 더 있는데,
memberService
와orderService
에 생성자로 주입된memberRepository
는 스프링 빈이 아니다. 즉, 스프링 컨테이너에서 관리하지 않는다.
정리
@Bean
만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다.memberRepository()
처럼 의존관계 주입이 필요해서 메소드를 직접 호출할 때 싱글톤을 보장하지 않는다.- 이는 일반적인 자바 코드로 인식해서 고대로 생성자를 호출하기 때문이다. (혹은, 사용자가 만든 클래스를 그대로 스프링 컨테이너에 등록하기 때문이다.)
- 애초에 고민할 거리가 아니다. 스프링 설정 정보는 항상
@Configuration
을 사용하자.
이번 섹션에서 배운 @Configuration
의 역할은
- 사용자가 만든 클래스를 스프링 빈으로 등록하는 척하면서 사실은 바이트코드 단위로 어떠한 수정을 가해서 스프링 컨테이너가 만든 새로운 클래스(사용자의 클래스를 상속받는 클래스)를 등록하여 설정 정보에 등록된 각 스프링 빈이 싱글톤을 유지하도록 해주는 역할을 한다.
- 특히 어떤 스프링 빈을 등록하는 과정에서 다른 스프링 빈을 직접 호출하는 경우, 이미 등록된 빈에 대해 추가적인 인스턴스 생성을 막아준다.
사용한 단축키
- 클래스 이름에 대고
Alt + Enter
: 여러 메뉴가 있는데, 테스트를 만들 수 있다.
Author And Source
이 문제에 관하여(섹션 5. 싱글톤 컨테이너), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@kir3i/섹션-5.-싱글톤-컨테이너저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)