노션 링크
https://earthy-grouse-d42.notion.site/1765723e06ee80adb19ddd059cde05e5
웹 애플리케이션과 싱글톤
- 스프링은 태생이 기업용 온라인 서비스 기술을 지원하기 위해 개발
- 대부분의 스프링 애플리케이션은 웹 애플리케이션
웹이 아닌 애플리케이션 개발도 얼마든지 가능
[웹 애플리케이션은 보통 여러 고객이 동시에 요청을한다.]
- 스프링 없는 순수한 DI 컨테이너를 사용하면 요청을 할 떄마다 객체를 새로 생성
-> 만약 고객 트래픽이 초당 100이라면, 초당 100개의 객체가 생성되고 소멸되어
메모리 낭비 발생
// 순수한 DI 컨테이너
@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);
// 두 객체가 다른 경우 테스트 통과
Assertions.assertThat(memberService1).isNotSameAs(memberService2);
}
/*
결과 - 서로 다른 객체 생성됨
memberService1 = hello.core.member.MemberServiceImpl@709ba3fb
memberService2 = hello.core.member.MemberServiceImpl@3d36e4cd
*/
이를 해결하려면 객체를 1개만 생성하고, 이를 공유하도록 설계하면 됨
-> 이를 [싱글톤 디자인 패턴] 이라고 한다.
싱글톤 패턴
- 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴
-> 한 자바 JVM 안에서 객체 인스턴스가 하나만 생성됨
- 객체 인스턴스를 2개 이상 생성하지 못하도록 막아야 한다.
private 생성자를 사용해서 외부에서 임의로 new 키워드를 사용해 객체를 생성하지 못하도록 해야 함
package hello.core.singleton;
public class SingletonService {
// private - 해당 클래스 파일에서만 접근 가능
// static - 클래스가 메모리에 올라갈 때 사용 가능 (인스턴스를 생성하지 않고도 사용 가능)
// final - 상수화
// SingletonService 클래스가 로드될 때 자기 자신의 인스턴스를 딱 한 번 생성하여, 이후 변경 불가능한 클래스 변수로 참조하게 함
private static final SingletonService instance = new SingletonService();
// instance 참조 변수의 Getter
// 해당 인스턴스를 꺼낼 수 있는 방법은 이 Getter 밖에 없다.
public static SingletonService getInstance() {
return instance;
}
// private 생성자
// 접근 제어자를 private으로 줌으로써 클래스 밖에서 생성하지 못하도록 함
private SingletonService() {
}
public void logic() {
System.out.println("싱글톤 객체 로직 호출");
}
}
1. static 영역에 객체 instance를 미리 하나 생성해서 메모리에 적재
2. 이 객체 인스턴스가 필요하면 getInstance() 메서드를 통해서만 조회하도록 구현
이 메서드를 호출하면 메모리에 미리 생성된 인스턴스를 반환하도록 함
3. 생성자를 private으로 만들어 줌으로써,
클래스 외부에서 new 연산자를 사용해 새로운 객체를 생성하지 못하도록 막음
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
void singletonServiceTest() {
SingletonService singletonService1 = SingletonService.getInstance();
SingletonService singletonService2 = SingletonService.getInstance();
System.out.println("singletonService1 = " + singletonService1);
System.out.println("singletonService2 = " + singletonService2);
// isSameAs - ==와 동일
// isEqualTo - equals() 메서드를 어떻게 정의하느냐에 따라 다름
Assertions.assertThat(singletonService1).isSameAs(singletonService2);
}
/*
결과 - 동일한 객체 출력 됨
singletonService1 = hello.core.singleton.SingletonService@70b0b186
singletonService2 = hello.core.singleton.SingletonService@70b0b186
*/
- 이 방식은 메모리에 클래스가 올라갈 때,
instance가 생성되어 올라간다.(클래스 변수이므로)
** 객체가 메모리를 많이 먹는 것이 아니라면 이 방법이 구현하기 간단하고 안전함 **
** 스프링 컨테이너를 사용하면 기본적으로 싱글톤으로 객체를 만들어서 관리 **
[참고]
- 싱글톤 패턴을 구현하는 방법은 여러가지
- 싱글톤 패턴을 적용하면, 고객이 요청이 올 때마다 새로운 객체를 생성하지 않고
이미 만들어진 객체를 공유해서 효율적으로 사용 가능
** 하지만 싱글톤 패턴은 많은 문제점을 가지고 있다.
싱글톤 패턴 문제점
- 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
→ 로직이 적어도 싱글톤 패턴을 구현해야 함
- 의존관계상 클라이언트가 구체 클래스에 의존한다. → DIP 위반
→ singletonService.getInstance()를 사용해서 꺼내야 하므로 구체 클래스에 의존함
- 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
- 테스트 하기 어렵다.
→ 인스턴스가 이미 만들어져 있어, 유연하게 테스트하기 어렵다.
- 내부 속성을 변경하거나 초기화하기 어렵다.
- private 생성자로 자식 클래스를 만들기 어렵다.
- 유연성이 떨어진다.
- 안티패턴으로 불리기도 한다.
** 스프링 컨테이너는 이런 싱글톤 패턴 문제점을 전부 해결하고 객체를 싱글톤으로 관리해준다. **
싱글톤 컨테이너
- 스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서
객체 인스턴스를 싱글톤(1만개 생성)으로 관리한다.
[싱글톤 컨테이너]
- 스프링 컨테이너는 싱글턴 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리
- 스프링 컨테이너는 싱글톤 컨테이너 역할을 한다.
싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 한다.
- 싱글톤 컨테이너 덕분에 싱글톤 패턴의 모든 단점을 해결하면서
객체를 싱글톤으로 유지
-> 싱글톤 패턴을 위한 코드를 작성하지 않아도 되어 코드 가독성 증가
-> DIP, OCP, 테스트, private 생성자로부터 자유롭게 싱글톤을 사용
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {
ApplicationContext 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);
assertThat(memberService1).isSameAs(memberService2);
}
/*
결과
memberService1 = hello.core.member.MemberServiceImpl@26ceffa8
memberService2 = hello.core.member.MemberServiceImpl@26ceffa8
*/
- memberServiceImpl 코드에 싱글톤 패턴과 관련된 코드가 존재하지 않음
싱글톤 컨테이너를 사용하면 싱글톤 패턴을 사용할 수 있음
- 스프링 컨테이너 때문에 고객의 요청이 올 때 마다 객체를 생성하는 것이 아닌
이미 만들어진 객체를 공유해서 효율적으로 재사용할 수 있다.
[참고]
- 스프링 기본 빈 등록 방식은 싱글톤
싱글톤 방식만 지원하지 않음
- scope 방식은 요청이 들어올 때마다 새로운 객체를 생성햇서 반환
이후 강의에서 자세하게 설명
싱글톤 방식 주의점
- 싱글톤 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든
객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은
여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에
싱글톤 객체는 상태를 유지하게 설계해서는 안 된다.
- 중요 !!!!!!
무상태로 설계해야 한다.
특정 클라이언트에 의존적인 필드가 있으면 안 된다.
즉, 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안 된다.
필드 대신 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.
- 스프링 빈 필드에 공유 값을 설정하면 큰 장애가 발생할 수 있음
// 상태를 유지하는 서비스 클래스
package hello.core.singleton;
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;
}
}
// 상태 유지할 때 테스트 코드
package hello.core.singleton;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
// ThreadA: A 사용자 10000원 주문
statefulService1.order("userA", 10000);
// ThreadBL B 사용자 20000원 주문
statefulService1.order("userB", 20000);
// ThreadA : 사용자A가 주문 금액을 조회
System.out.println("statefulService1.getPrice() = " + statefulService1.getPrice());
// 싱글톤 패턴을 사용해 동일한 객체의 price 변수에 값을 저장하므로 마지막으로 주문한 사용자의 값이 저장됨
Assertions.assertThat(statefulService1.getPrice()).isNotEqualTo(10000);
// ThreadB : 사용자B가 주문 금액을 조회
System.out.println("statefulService2.getPrice() = " + statefulService2.getPrice());
Assertions.assertThat(statefulService2.getPrice()).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
- 단순히 설명하기 위해 실제 쓰레드를 사용하지 않은 코드임
- ThreadA가 사용자 A 코드를 호출하고 ThreadB가 사용자 B 코드를 호출한다고 가정했을 때,
StatefulService의 price 필드는 공유되는 필드인데,
특정 클라이언트가 값을 변경하면
그 결과 사용자 A의 주문 금액이 10000원이 아닌 20000원이 출력된다.
- 즉, 사용자 A와 사용자 B가 순서대로 접근하는 것이 아닌
스레드가 CPU를 번갈아 가며 점유하여 코드가 실행되는 경우
공유 자원인 price 변수의 값에 다른 사용자가 접근하게
되어 다른 사용자의 값으로 변경될 수 있따.
- 그러므로 공유 필드를 조심해야 한다.
스프링 빈은 항상 무상태로 설계해야 한다.
// 무상태로 설계
package hello.core.singleton;
public class StatefulService {
public int order(String name, int price){
System.out.println("name = " + name + " price = " + price);
return price;
}
}
package hello.core.singleton;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
// ThreadA: A 사용자 10000원 주문
int userAPrice = statefulService1.order("userA", 10000);
// ThreadBL B 사용자 20000원 주문
int userBPrice = statefulService1.order("userB", 20000);
// ThreadA : 사용자A가 주문 금액을 조회
System.out.println("statefulService1.getPrice() = " + userAPrice);
// 싱글톤 패턴을 사용해 동일한 객체의 price 변수에 값을 저장하므로 마지막으로 주문한 사용자의 값이 저장됨
Assertions.assertThat(userAPrice).isEqualTo(10000);
// ThreadB : 사용자B가 주문 금액을 조회
System.out.println("statefulService2.getPrice() = " + userBPrice);
Assertions.assertThat(userBPrice).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
Configuration과 싱글톤
- @Configuration은 싱글톤을 위해서 존재하는 것
@Configuration
public class AppConfig {
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public DiscountPolicy discountPolicy() {
return new FixDiscountPolicy();
}
@Bean DiscountPolicy discountPolicy2() {
return new RateDiscontPolicy();
}
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy2());
}
}
@Bean -> new MemoryMemberRepository()
@Bean -> new MemoryMemberRepository()
이러면 싱글톤이 아닌게 아닌가 ??
각각 다른 2개의 MemoryMemberRepository가 생성되면서 싱글톤이 깨지는 것처럼 보임
package hello.core.singleton;
import hello.core.AppConfig;
import hello.core.member.MemberRepository;
import hello.core.member.MemberServiceImpl;
import hello.core.order.OrderServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ConfigurationSingletonTest {
@Test
void configurationTest() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
// 구체 타입으로 꺼내면 안 좋지만 getMemberRepository() 메서드를 사용하기 위해 구체 타입으로 꺼낸다.
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("memberRepository = " + memberRepository);
System.out.println("memberRepository1 = " + memberRepository1);
System.out.println("memberRepository2 = " + memberRepository2);
Assertions.assertThat(memberRepository1).isSameAs(memberRepository);
Assertions.assertThat(memberRepository2).isSameAs(memberRepository);
}
}
/*
결과 - 동일한 MemoryMemberRepository 객체를 사용
memberRepository = hello.core.member.MemoryMemberRepository@63f259c3
memberRepository1 = hello.core.member.MemoryMemberRepository@63f259c3
memberRepository2 = hello.core.member.MemoryMemberRepository@63f259c3
*/
- 자바 코드만 보면 new 연산자가 세 번 호출되므로 서로 다른 MemoryMemberRepository 객체가 생성되어야 함
- 아이러니 하게도 동일한 객체 인스턴스가 사용되고 있음
Configuration과 바이트코드(기계어) 조작
- 스프링 컨테이너는 싱글톤 레지스트리이다. (싱글톤 객체를 관리하는 컨테이너)
따라서 스프링 빈이 싱글톤이 되도록 보장해줘야 한다.
스프링이라고 해도 자바 코드를 건드리기 어렵다.
- 스프링은 클래스의 바이트 코드를 조작하는 라이브러리를 사용한다.
- 모든 것은 @Configuration을 적용한 AppConfig에 있다.
@Test
void configurationDeep() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig been = ac.getBean(AppConfig.class);
System.out.println("been.getClass() = " + been.getClass());
}
/*
출력 결과
been.getClass() = class hello.core.AppConfig$$SpringCGLIB$$0
*/
- 순수한 클래스라면 class hello.core.AppConfig 처럼 출력되어야 함
- 예상과 달리 xxxCGLIB가 붙으면서 상당히 복잡해짐
이 클래스는 내가 만든 클래스가 아니라 스프링이 CGLIB라는 바이트코드 조작 라이브러리를
사용해 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고
[ 다른 클래스를 스프링 빈으로 등록한 것 ]
- AppConfig를 상속 받은 다른 클래스를 만든다.(AppConfig@CGLIB)
원본 AppConfig 코드 동작을 감싸고, 싱글톤 보장을 위해 추가적인 로직을 삽입
- 즉, AppConfig@CGLIB는 기존 AppConfig의 @Bean 애너테이션이 붙은 메서드를
오버라이딩(재정의)해서 싱글톤 보장을 위한 로직을 삽입함
- 스프링 컨테이너에는 이름은 appConfig이지만
AppConfig@CGLIB 객체 인스턴스가 등록됨
- 그 임의의 다른 클래스가 바로 싱글톤이 보장되도록 해준다.
// AppConfig@CGLIB 내부 예상도
@Bean
public MemberRepository memberRepository() {
if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있다면 ?) {
return 스프링 컨테이너에서 찾아서 반환;
} else { // 컨테이너에 없으면
기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
return 반환
}
}
- @Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환
- 스프링 빈이 없으면, 생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어짐
- 덕분에 싱글톤 보장
[참고]
= AppConfig@CGLIB는 AppConfig의 자식 타입으로 AppConfig 타입으로 조회 가능
자세한 처리 과정 (영한님 강의에 없는 내용 - GPT 활용)
[처리 과정]
1. 자바 코드 -> 바이트 코드
- 개발자가 작성한 코드는 컴파일 되어 바이트 코드로 변환됨
- 이 바이트 코드는 JVM의 중간 단계 코드로, 스프링이 동적으로 이 바이트코드를 조작
2. 바이트코드 조작(CGLIB)
- 스프링은 @Configuration이 붙은 클래스에서 동적으로 프록시 클래스를 생성
- 프록시 클래스는 원래 클래스의 메서드를 오버라이딩하면서, 추가적인 싱글톤 로직(캐싱 등)을 삽입
3. 결과적으로 실행되는 기계어는 동일한 자원 사용
- 실행 시에는 프록시 클래스에서 조작된 코드가 실행됨
- 스프링 컨테이너에 존재하면 바로 반환
- 존재하지 않으면 컨테이너에 등록하고 반환
** 프록시 클래스는 바이트 코드 수준에서 생성되고 런타임에만 존재하는 코드 **
public class AppConfig$$EnhancerBySpringCGLIB extends AppConfig {
private final Map<String, Object> singletonCache = new HashMap<>();
@Override
public MemberService memberService() {
if (!singletonCache.containsKey("memberService")) {
singletonCache.put("memberService", super.memberService());
}
return (MemberService) singletonCache.get("memberService");
}
@Override
public OrderService orderService() {
if (!singletonCache.containsKey("orderService")) {
singletonCache.put("orderService", super.orderService());
}
return (OrderService) singletonCache.get("orderService");
}
}
Configuration 애너테이션을 붙이지 않고 Bean 애너테이션만 붙이는 경우
- 붙이지 않아도 @Bean 애너테이션이 붙은 메서드의 객체들이 스프링 컨테이너에 등록됨
- 단, 우리가 작성한 자바 코드가 그대로 실행됨
순수 AppConfig.class가 스프링 빈으로 등록됨
@Bean이 붙은 메서드가 그래도 실행됨
- 각 역할에 주입된 객체가 스프링 빈이 주입되는 것이 아니라
MebmerRepository = new MemoryMebmerRepository()처럼 생성된 객체가
생성자를 통해 주입됨
즉, 컨테이너에 등록된 객체를 주입하는 것이 아닌 new 연산자로 새로 만든 객체를 주입하는 것
// 테스트 결과
// memberRepository()를 세 번 실행해 서로 다른 객체가 3개 생성됨
call AppConfig.memberRepository
call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.orderService
call AppConfig.memberRepository
memberRepository = hello.core.member.MemoryMemberRepository@2dc9b0f5
memberRepository1 = hello.core.member.MemoryMemberRepository@6531a794
memberRepository2 = hello.core.member.MemoryMemberRepository@3b5fad2d
정리
- @Bean만 사용해도 스프링 빈으로 등록된다.
- 하지만 의존관계 주입이 필요해서 메서드를 직접 호출할 때, 싱글톤을 보장하지 않는다.
- 크게 고민할 것 없이 스프링 설정 정보는 항상 @Configuration을 사용하자
내용 출처
인프런 - 스프링 핵심 원리 (기본편)
강사명 - 김영한