오늘은 싱글톤 패턴에 대해서 공부해보겠다.
package com.naver.shopping.singleton;
import com.naver.shopping.AppConfig;
import com.naver.shopping.member.MemberService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
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);
assertThat(memberService1).isNotSameAs(memberService2);
}
}
이렇게 코드를 두 개 생성해보면 각각의 생성된 클래스는 고유 아이디값을 가지게 된다.
memberService1과 memberService2는 각각 id 값이 다르지만 같은 기능을 한다.
이렇게 분리된 서비스를 쓰기 위해 클래스를 계속 새로 만들게 된다면 클래스가 엄청 쌓이고 소멸되면서 프로젝트에 부하를 줄 수 있다.
싱글톤 패턴은 이런 문제를 해결하기 위해 프로그램이 실행되면서 바로 딱 클래스당 하나의 클래스만 새로 생성되면서 여기저기서 클래스를 쓸 때 이미 만들어진 클래스를 불러와 이용할 수 있게 한다.
스프링은 태생이 기업용 온라인 서비스를 지원하기 위해서 탄생했다.
온라인만 처리하는 것이 아니라 서버에 하나가 떠있어서 처리해주는 데몬 프로세스가 있고 한 번에 여러 개로 묶어서 처리하는 배치도 있다.
대부분의 스프링 웹 애플리케이션은 웹 애플리케이션이다.
웹 애플리케이션은 보통 여러 고객이 동시에 요청한다.
- 스프링 컨테이너 덕분에 고객의 요청이 올 때마다 객체를 생성하는 것이 아니라 이미 만들어진 객체를 공유해서 효율적으로 재사용할 수 있다.
package hello.core.singleton;
import hello.core.AppConfig;
import hello.core.member.MemberService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
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);
//계속 객체가 생성됨. 효율적이지 않음.
//memberService1 !== memberService2
assertThat(memberService1).isNotSameAs(memberService2);
}
}
우리가 앞에서 만들었던 스프링 없는 순수한 DI 컨테이너인 AppConfig는 요청을 할 때마다 객체를 새로 생성한다.
고객 트래픽이 초당 100이 나오면 객체가 초당 100개나 생성되고 소멸한다! -> 메모리 낭비
그래서 해결 방안은 해당 객체가 1개만 생성되고 공유하도록 설계하면 된다는 것을 앞에서도 설명했다.
이게 바로 싱글톤.
package com.naver.shopping.singleton;
public class SingletonService {
//1. static 영역에 객체를 딱 1개만 생성해둔다.
private static final SingletonService instance = new SingletonService();
//2. public으로 열어서 객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회하도록허용한다.
public static SingletonService getInstance() {
return instance;
}
//3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다.
private SingletonService() {
}
// 임의로 호출 잘 되는지 확인용 함수
public void logic() {
System.out.println("실행 성공!");
}
}
이 코드는 Singleton 패턴을 구현한 예제이다.
Singleton 패턴은 클래스의 인스턴스가 오직 하나만 생성되고 이 인스턴스에 접근할 수 있는 전역적인 접근점을 제공하는 특징을 가지고 있다.
아래에서 코드의 각 부분을 자세히 설명하겠다.
코드 설명
1. 정적(static) 인스턴스 생성
private static final SingletonService instance = new SingletonService();
- static 키워드는 클래스 레벨에서 메모리에 할당되는 변수를 의미한다.
- instance라는 이름의 SingletonService 타입의 변수를 선언하고 이 클래스의 인스턴스를 한 번만 생성한다.
- final 키워드는 이 변수가 한 번만 초기화될 수 있음을 나타내므로 이후에는 다른 값을 할당할 수 없다.
- 이 줄에서 SingletonService의 객체가 생성되므로, 애플리케이션 전반에서 유일한 인스턴스가 생성된다.
Singleton 패턴에서 static을 사용하는 이유는 인스턴스의 생명 주기를 클래스의 생명 주기와 동일하게 유지하기 위해서이다.
static 변수는 클래스 로드시 메모리에 올라가고 클래스의 모든 인스턴스에서 공유된다.
따라서 싱글톤 인스턴스를 클래스 레벨에서 관리하며 외부에서 인스턴스를 직접 생성할 수 없도록 막는다.
2. 전역 접근 메서드
public static SingletonService getInstance() {
return instance;
}
- 이 메서드는 public으로 선언되어 외부에서 호출할 수 있다.
- static 메서드이므로, 인스턴스를 생성하지 않고도 호출할 수 있다. 이 메서드를 통해서 유일한 SingletonService 인스턴스를 반환하는 것이다.
- 이렇게 하면 외부에서 이 클래스를 사용하는 코드가 인스턴스에 쉽게 접근할 수 있다.
3. 생성자
private SingletonService() {
}
- 생성자를 private으로 선언하여 외부에서 이 클래스를 직접 인스턴스화할 수 없도록 제한한다.
- 싱글톤 패턴의 핵심 요소이다! 왜? 인스턴스가 여러 개 생성되는 것을 방지하므로.
4. 로직 메서드
public void logic() {
System.out.println("실행 성공!");
}
- 이 메서드는 단순히 "실행 성공!" 이라는 메시지를 출력하는 용도로만 사용되지만
- 실제 애플리케이션에서는 이 메서드가 싱글톤 인스턴스가 주요 기능을 수행하는 부분이 될 수 있다.
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
public void singletonServiceTest() {
//private으로 생성자를 막아두었다. 컴파일 오류가 발생한다.
//new SingletonService();
//1. 조회: 호출할 때 마다 같은 객체를 반환
SingletonService singletonService1 = SingletonService.getInstance();
//2. 조회: 호출할 때 마다 같은 객체를 반환
SingletonService singletonService2 = SingletonService.getInstance();
//참조값이 같은 것을 확인
System.out.println("singletonService1 = " + singletonService1);
System.out.println("singletonService2 = " + singletonService2);
assertThat(singletonService1).isSameAs(singletonService2);
singletonService1.logic();
singletonService2.logic();
}
테스트를 통해서 singleton을 호출할 때 같은 객체인지 확인할 수 있다.
그러나! 순수 자바로 만든 싱글톤은 여러 문제가 있다는 것!
김영한 강사님의 인프런 강의를 들으며 알게 된 사실이다.
싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
의존관계상 클라이언트가 구체 클래스에 의존한다. -> DIP를 위반한다.
클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
테스트하기 어렵다. 내부 속성을 변경하거나 초기화하기 어렵다.
private 생성자로 자식 클래스를 만들기 어렵다.
결론적으로 유연성이 떨어진다.
그러나 스프링을 사용한다면 이런 불편함 없이 코드를 짤 수 있다.
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
//1. 조회: 호출할 때 마다 같은 객체를 반환
MemberService memberService1 = ac.getBean("memberService", MemberService.class);
//2. 조회: 호출할 때 마다 같은 객체를 반환
MemberService memberService2 = ac.getBean("memberService", MemberService.class);
//참조값이 같은 것을 확인
System.out.println("memberService1 = " + memberService1);
System.out.println("memberService2 = " + memberService2);
assertThat(memberService1).isSameAs(memberService2);
}
코드를 보면
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
이 부분에서 스프링 컨테이너인 ApplicationContext를 사용하여 AppConfig.class를 통해 스프링 빈을 등록하고 관리한다.
AnnotationConfigApplicationContext는 자바 어노테이션 기반의 설정 파일인 AppConfig를 읽어서 스프링 빈을 관리하는 컨테이너를 생성한다.
MemberService memberService1 = ac.getBean("memberService", MemberService.class);
MemberService memberService2 = ac.getBean("memberService", MemberService.class);
또한 이 부분에서도 ac.getBean("memberService", MemberService.class)라는 스프링 컨테이너에서 빈을 조회하는 메서드를 사용한다.
이 코드를 두 번 호출해서 memberService1과 memberService2를 얻는데, 스프링 컨테이너는 기본적으로 빈을 싱글톤으로 관리하기 때문에 두 번 호출해도 같은 객체를 반환한다.
쨋든 @Configuration을 써서 스프링이 아! 얘는 설정파일이네 라고 알게 해 준 클래스한테만 적용을 시켜준다.
@Configuration을 쓰지 않은 클래스 내에선 아무리 @Bean 데코레이션을 써서 선언한다 하더라도 완벽하게 싱글톤 패턴을 적용시켜주지 않는다.
요약하자면
싱글톤 컨테이너
- 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도 객체 인스턴스를 싱글톤으로 관리한다.
- 이전에 설명한 컨테이너 생성 과정을 보면 컨테이너는 객체를 하나만 생성해서 관리한다
- 스프링 컨테이너는 싱글톤 컨테이너 역할을 한다. 이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라 한다.
- 스프링 컨테이너의 이런 기능 덕분에 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
- 싱글톤 패턴을 위한 지저분한 코드가 들어가지 않아도 된다.
- DIP,OCP,테스트,private 생성자로부터 자유롭게 싱글톤 사용 가능
싱글톤 컨테이너를 적용하면 스프링 빈은 항상 무상태를 유지해야 한다.
무상태로 설계해야 한다는 것이 뭘까?
빈이 인스턴스 변수에 상태(데이터)를 저장하지 않도록 해야 한다는 의미이다.
즉, 스프링 빈은 여러 클라이언트나 요청에 의해 공유될 수 있기 때문에 인스턴스 변수에 데이터를 저장하면 동시엉 문제나 예기치 않은 동작이 발생할 수 있다.
예시 코드를 보자.
package hello.core.singleton;
import hello.core.beanfind.ApplicationContextExtendsFindTest;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
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원 주문
int userAPrice = statefulService1.order("userA", 10000);
//ThreadB: B사용자 20000원 주문
int userBPrice = statefulService2.order("userB", 20000);
//어! 둘 다 같은 객체인데 저장 어떻게 하지? userB가 바꿔치기 해버렸잖아 이런 장애를 어떻게 해결해?
// int price = statefulService1.getPrice();
System.out.println("price = " + userAPrice);
// assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
이 코드는 Spring의 싱글톤 패턴과 관련된 문제를 시뮬레이션하고 이를 해결하기 위한 방안을 찾는 테스트 코드이다.
상태를 가지는 싱글톤 객체가 여러 사용자 간에 상태를 공유할 때 발생할 수 있는 문제를 다루고 있다.
주요 구성:
1. StatefulService 클래스:
이 클래스는 상태를 가지는 서비스 객체이다. order 메소드를 통해 사용자의 주문 금액을 설정하는데 이때 상태를 가지면 문제가 발생할 수 있다.
2. TestConfig 클래스:
Spring의 @Configuration 역할을 대신하는 간단한 설정 클래스이다. 이 클래스는 @Bean을 통해 StatefulService 객체를 싱글톤으로 관리한다.
3. statefulServiceSingleton 테스트 메소드:
이 메소드에서는 두 개의 스레드(ThreadA, ThreadB)가 싱글톤 객체인 StatefulService를 공유할 때 발생하는 문제를 시뮬레이션한다.
- userA는 10,000원을 주문하고, userB는 20,000원을 주문한다.
- 두 사용자는 같은 StatefulService 객체를 사용하고 있으므로 userB의 주문이 userA의 주문 상태를 덮어쓰는 문제가 발생한다.
4. 문제 설명:
스프링은 기본적으로 빈을 싱글톤으로 관리한다.
이때 상태를 가지는 객체가 여러 사용자 사이에서 공유되면 의도치 않은 상태 변형이 발생할 수 있다.
여기서는 userA와 userB가 같은 객체를 사용하지만 userB의 주문이 userA의 주문 상태를 덮어써서 예상치 못한 결과가 나온다.
해결 방법은?
상태를 가지는 싱글톤 객체를 사용하면 안된다.
대신 메소드 호출마다 상태를 유지하지 않고 결과를 반환하는 방식(여기서는 order 메소드가 바로 가격을 반환하는 방식)으로 설계해야 한다.
테스트 코드에서 order 메소드는 상태를 저장하지 않고, 주문한 가격을 반환하도록 수정되어 있다.
코드 작동 흐름:
1. 두 사용자가 싱글톤 객체를 사용:
statefulService1.order("userA", 10000) → userAPrice = 10000
statefulService2.order("userB", 20000) → userBPrice = 20000
2. 같은 객체를 사용했기 때문에 상태 공유 문제 발생:
주석 처리된 statefulService1.getPrice()는 제거된 상태이다.
이를 대신하여 order 메소드에서 바로 금액을 반환하는 구조로 문제를 해결할 수 있다.
봐도봐도 어려운 것 같다. 이번에 포스팅하고 공부하면서 어느정도 이해를 하고 실습 코드도 짤 수 있게 되었으나 꾸준히 계속 복습해야겠다.
'Backend > Spring Boot' 카테고리의 다른 글
@Configuration 없이 @Bean만 사용하면? (0) | 2025.02.09 |
---|---|
@Controller와 @RestController 차이 (1) | 2025.02.04 |
스프링 컨테이너와 스프링 빈 (1) | 2024.10.13 |
JUnit5를 활용한 스프링 빈 조회 Test (0) | 2024.10.06 |
코드로 알아보는 생성자 주입(DI) feat. 리팩토링 (5) | 2024.10.05 |