싱글톤 방식의 주의점
무상태(stateless)로 설계해야 하는 이유
웹 어플리케이션 설계에서 유용한 싱글톤 방식을 사용할 때는 싱글톤 클래스를 반드시 무상태로 설계해야 한다. 다음 예제를 보자.
: StatefulService.java
: StatefulServiceTest.java
해당 코드에서는 ThreadA에서 userA가 주문을 하고, 가격을 확정하기 전에 ThreadB에서 userB가 다른 상품을 주문한다.
싱글톤 클래스라 클래스 안의 필드도 공유하고 있기 때문에, 이 경우 userA가 주문했던 금액과는 다른 금액이 나오게 된다.
무상태로 설계하기 위해서는 기존의 클래스 안의 멤버 변수(필드)를 지역 변수로 바꾸거나, ThreadLocal을 사용해야 한다.
지역 변수를 사용한 예시
StatelessService.java
package hello.core.singleton;
public class StatelessService {
public int order(String name, int price){
System.out.println("name = " + name + ", price = " + price);
return price;
}
}
StatelessServiceTest.java
public class StatelessServiceTest {
@Test
@DisplayName("싱글톤 클래스를 무상태로 설계해야 한다.")
void statelessServiceSingleton() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
StatelessService statelessService1 = ac.getBean("statelessService", StatelessService.class);
StatelessService statelessService2 = ac.getBean("statelessService", StatelessService.class);
int priceA = statelessService1.order("userA", 10000);
int priceB = statelessService2.order("userB", 20000);
assertThat(priceA).isEqualTo(10000);
assertThat(priceB).isEqualTo(20000);
}
@Configuration
static class TestConfig {
@Bean
public StatelessService statelessService() {
return new StatelessService();
}
}
}
ThreadLocal이란?
스레드(Thread)의 개념
프로그램을 실행할 때, 스레드(thread)나 프로세스(process)라는 말을 사용한다.
✅ 프로세스: 운영체제로부터 자원을 할당받는 작업의 단위.
여러 개의 어플리케이션을 사용한다면, 여러 개의 프로세스(multi-process)가 동작 중이다.
✅ 스레드: 프로그램(프로세스) 실행의 단위.
하나의 어플리케이션(프로세스)를 실행할 때도 여러 개의 스레드(multi-thread)를 사용할 수 있다.
⭕ 멀티 프로세스 -> 멀티 스레드
❌ 멀티 스레드 -> 멀티 프로세스
변수의 생성 영역(메모리)
객체나 변수를 생성할 때 Heap영역, 또는 Stack영역에 위치시킬 수 있다.
✅ Heap: 모든 스레드에서 공유하는 영역.
ex) 정적(static) 변수
✅ Stack: 하나의 스레드에서 사용하는 영역.
ex) 지역(local) 변수
ThreadLocal의 구조
ThreadLocal은 스레드 정보를 key로 사용하여 값을 저장하는 Map 구조를 갖고 있다.
멀티 스레드 환경에서 Stateful 클래스를 싱글톤으로 선언하고 싶을 때, ThreadLocal을 사용하면 각 스레드 별로 다른 변수를 사용할 수 있다.
ThreadLocalService.java
package hello.core.threadlocal;
public class ThreadLocalService {
private static ThreadLocal<String> product = new ThreadLocal<>();
private static ThreadLocal<Integer> price = new ThreadLocal<>();
public ThreadLocalService() {}
public void setProduct(String item) {
product.set(item); // set()으로 value 값 지정 가능
}
public void setPrice(Integer value) {
price.set(value);
}
public void printOrder() {
System.out.println("product = " + product.get() + ", price = " + price.get());
// get()으로 value 값 반환 가능
}
}
ThreadLocalServiceTest.java
package hello.core.threadlocal;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
public class ThreadLocalServiceTest {
@Test
void threadLocalOrder() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
ThreadLocalService tls = ac.getBean("threadLocalService", ThreadLocalService.class);
Integer priceA = 10000;
Integer priceB = 20000;
String productA = "strawberry";
String productB = "watermelon";
Thread threadA = new Thread(()->{
tls.setPrice(priceA);
tls.setProduct(productA);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
tls.printOrder();
});
Thread threadB = new Thread(()->{
tls.setPrice(priceB);
tls.setProduct(productB);
tls.printOrder();
});
threadA.start();
threadB.start();
}
@Configuration
static class TestConfig {
@Bean
ThreadLocalService threadLocalService() {
return new ThreadLocalService();
}
}
}
ThreadLocal 사용할 때 주의점
⚠️ 메모리 문제
ThreadLocal은 사용 시 메모리를 관리해야 한다.
ThreadPool을 사용하면 기존에 생성했던 ThreadLocal을 재사용할 수 있는데, 이때 이전에 삭제되지 않은 메모리가 남아있을 수 있다.
ThreadPool을 사용할 경우 반드시 ThreadLocal.remove()를 이용하여 남은 데이터를 제거해 주어야 한다.
*참고한 포스트들*
- https://sabarada.tistory.com/163
- https://yeonbot.github.io/java/ThreadLocal/
- https://wooody92.github.io/os/멀티-프로세스와-멀티-스레드/
- https://www.baeldung.com/java-threadlocal
- https://velog.io/@ljs0429777/10주차-과제-멀티쓰레드-프로그래밍
- https://honbabzone.com/java/java-thread
@Configuration과 싱글톤
스프링 컨테이너는 @Configuration 으로 등록한 클래스에서 빈을 생성 및 조회하는데, 이 빈들을 모두 싱글톤 타입으로 만든다고 했다.
그런데 @Configuration으로 등록된 AppConfig 클래스의 각 빈 메소드에서는 new() 생성자로 객체를 생성한다.
어떻게 각 빈을 호출하면서 각 객체는 한 번만 생성하는 것이 가능할까?
: AppConfig.java
테스트를 위해서 각 구현 클래스(MemberServiceImpl.java, OrderServiceImpl.java)에 MemberRepository 객체를 조회할 수 있는 클래스를 임시로 작성하고, 결과를 보자.
: ConfigurationSingletonTest.java
모든 MemberRepository 객체의 주소값이 같다.
즉 MemberRepository 객체는 딱 한 번만 생성되고, 다른 클래스는 오직 하나의 객체를 공유하고 있다.
⇒ 싱글톤 패턴을 잘 준수한다.
AppConfig.java 파일을 변경하여, 각 빈이 호출될 때마다 로그를 남겨서 확인해 보자.
결과는 다음과 같다.
빈이 한 번씩만 호출되었다. 스프링 컨테이너는 인스턴스를 한 번 생성하고 나면 해당 빈 메소드를 더 이상 호출하지 않은 것이다.
@Configuration과 바이트코드 조작
빈이 한 번씩만 호출된 이유는 스프링 컨테이너가 바이트 코드를 조작해서, 사용자가 입력한 @Bean과는 다른 클래스를 만들고 그 클래스를 통해 싱글톤 패턴을 사용하고 있기 때문이다.
: ConfigurationTest.java
스프링 컨테이너는 싱글톤 패턴을 유지하기 위해서 사용자가 @Configuration에 등록한 코드를 조작해서 새로운 클래스를 만든다. 그 클래스가 스프링 빈으로 등록된 것이다.
바이트 코드 조작
바이트 코드란?
✅ 바이트 코드
고급 언어(프로그래밍 언어. 자바 언어)로 작성된 코드를 가상머신(여기서는 JVM)이 이해할 수 있는 중간 단계의 코드로 컴파일한 언어.
기계어보다는 추상적(high-level)이고 고급 언어보다는 low-level이다.
코드 조작이 가능한 이유
🔑 바이트 코드를 조작하면 실행 결과가 달라지는 이유
자바 코드는 1차로 JVM이 처리할 수 있는 바이트 코드로 변환된다. 그리고 그 바이트 코드를 읽어서 비로소 프로그램이 실행된다.
바이트 코드 내용대로 실행이 되기 때문에, 중간 단계에서 코드가 조작된다면 결과값도 바뀌게 된다.
참고한 포스트: [Java] 바이트코드 조작 (tistory.com)
코드를 조작하는 방법(예시)
💡 byteBuddy 라이브러리
바이트 코드를 조작하는 코드를 작성하고, 결과를 다른 파일에 저장한다고 치자.
저장한 파일은 원본과는 다른 코드를 갖고 있다.
참고한 포스트: 자바 바이트 코드 조작하는 방법 (tistory.com)
바이트 코드 조작의 결과
아마도 바이트 코드를 조작해서, 이런 싱글톤 클래스를 만들었을 것이다.
- 사용자가 빈으로 등록한 클래스가 이미 스프링 컨테이너에 있다 → 해당 인스턴스 반환.
- 스프링 컨테이너에 사용자가 빈으로 등록한 클래스가 없다 → 새로운 인스턴스 생성.
*정리*
@Configuration은 싱글톤 패턴으로 빈을 생성하는 어노테이션이다. @Configuration이 없어도 빈은 생성된다! 다만 싱글톤 타입이 아니다.
@Configuration을 지우고 AppConfig.java 를 참고하여 코드를 실행한 결과는 다음과 같다:
'server-side > spring' 카테고리의 다른 글
스프링 부트 기본편 - 섹션 8. 빈 생명주기 콜백 (0) | 2022.05.31 |
---|---|
스프링 부트 기본편 - 섹션 6. 컴포넌트 스캔 (0) | 2022.05.23 |
spring 개발일지 21-22강 (0) | 2022.03.08 |
spring 개발일지 18-19강 (0) | 2022.03.08 |
spring 개발일지 15-16강 (0) | 2022.03.05 |