현재 수강 중인 스프링 강의에서 싱글톤(Singleton) 패턴에 대해 배우고 있다.
싱글톤 객체는 무상태(stateless) 로 설계되어야 하며, 이를 위해 지역 변수, 파라미터, 그리고 ThreadLocal 등을 활용해야 한다고 한다.
ThreadLocal에 대해 잘 알지 못했던 나는, 이참에 공부하기 좋은 키워드를 얻었다 생각하며 이에 대해 정리해보기로 했다.
스프링에서 대부분의 빈(Bean)은 싱글톤으로 생성된다.
즉, 모든 요청에 대해 같은 객체 인스턴스를 사용한다는 뜻이다.
그런데, 여기에 필드에 상태(state)를 저장하면 어떤 문제가 생길까?
@Component
public class OrderService {
private int price; // 상태 저장
public void order(String user, int price) {
this.price = price; // 사용자 A와 B가 동시에 접근하면 꼬임
}
public int getPrice() {
return price;
}
}
price = 10000price = 20000getPrice()를 호출했는데 10000이 아니라, 20000이 나옴이것이 멀티 쓰레드 환경에서 싱글톤 객체를 상태 있게 쓰면 생기는 문제이다.
이는 동시성 문제라고 불리는 현상 중 하나이다.
✅ 그래서 등장한 해결책 중 하나가 ThreadLocal이다!!
❓여기서 동시성 문제(Concurrency issue)란?
둘 이상의 쓰레드(또는 사용자)가 동시에 같은 자원(변수, 객체 등)에 접근하면서 값이 꼬이거나 예기치 못한 결과가 발생하는 현상을 말한다.
동시성 문제는 여러 유형들을 아우르는 큰 개념이다.
이에 대해서는 다음에 벨로그에서 제대로 다뤄보겠다.
쓰레드(Thread)마다 따로 저장소를 가지는 변수.
즉, 같은 객체라도 쓰레드마다 다른 값을 저장할 수 있다.
카페에서 종업원 한 명이 여러 테이블을 맡고 있는 상황 (싱글톤 객체).
근데 테이블마다 주문을 따로 기록해야 하는데, 종업원이 종이에 하나만 써버리면 꼬임.
→ 그래서 테이블마다 자기 주문서 (ThreadLocal)를 따로 주면 됨!
쓰레드와 싱글톤, 쓰레드로컬 정리
웹 애플리케이션에서 쓰레드는 각 사용자의 요청을 처리하는 단위이다.
여러 사용자의 요청(= 여러 쓰레드)이 하나의 싱글톤 객체를 동시에 사용하다 보면 공유된 값이 꼬일 수 있기 때문에,
요청(Thread) 하나당 독립된 저장소(ThreadLocal)를 부여해서 값을 분리하는 것이다.
우선 쓰레드가 공유하여 사용하는 대표적인 싱글톤 객체로 스프링 빈(Bean)을 가져오자.
스프링에서 Bean은 기본적으로 @Component나 @Service와 같은 어노테이션을 붙이면 싱글톤으로 등록된다.
따라서 여러 사용자의 요청이 와도, 하나의 객체(인스턴스)를 공유하게 된다.
@Service // 싱글톤으로 등록됨
public class UserContextService {
private ThreadLocal<String> username = new ThreadLocal<>();
public void setUsername(String name) {
username.set(name); // 현재 쓰레드의 저장소에 저장
}
public String getUsername() {
return username.get(); // 현재 쓰레드의 저장소에서 꺼냄
}
public void clear() {
username.remove(); // 요청 종료 후 반드시 해제 (메모리 누수 방지)
}
}
여러 요청이 들어올 때
UserContextService는 하나 → 공유됨.ThreadLocal은 쓰레드마다 다른 저장공간을 갖고 있음.setUsername()/getUsername()은 요청마다 분리된 공간을 참조하게 됨.공유 인스턴스 객체(= 싱글톤 방식으로 관리되는 "클래스") 내부에서 ThreadLocal (멤버) 변수를 선언하면, 여러 쓰레드가 그 ThreadLocal 인스턴스를 공유해서 사용한다. 그때, ThreadLocal이 현재 쓰레드 전용 저장소를 참조하여 값을 안전하게 get/set 해준다.
ThreadLocal의 get()과 set() 메서드는 Java 표준 라이브러리(java.lang.ThreadLocal) 내부에 이미 구현돼 있는 기능이다.
그리고 이 메서드들은 "현재 실행 중인 쓰레드"에 자동으로 연결되도록 잘 설계되어 있다.
이때, 참조되는 Thread 전용 저장소를 ThreadLocalMap이라고 한다.
이해를 돕기 위해 코드 내부를 잠깐 살펴보자.
public class Thread implements Runnable{
...
ThreadLocal.ThreadLocalMap threadLocals = null;
}
public class ThreadLocal<T> {
...
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
map.put(this, value); // this = 현재 ThreadLocal 인스턴스
}
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
return map.get(this);
}
...
}
setter
set() 메서드를 통해 들어온 값은 현재 쓰레드(Thread.currentThread())를 기준으로, 그 쓰레드가 가진 ThreadLocalMap에 값을 저장한다.getter
ThreadLocalMap에서 해당 ThreadLocal 객체를 키로 하여 값을 꺼낸다.여기서 핵심은
this= 현재 사용 중인ThreadLocal인스턴스.
이걸 "키(Key)"로 해서 현재 쓰레드의 저장소에 값을 저장하고 꺼내는 것.
이렇듯, ThreadLocal은 현재 실행 중인 쓰레드에 맞는 저장소를 자동으로 참조하도록 자바 라이브러리 내부에서 잘 구현되어 있다.
덕분에 개발자는 복잡한 쓰레드 관리를 신경 쓰지 않고, 단순히 ThreadLocal을 선언하고 set()/get() 메서드를 사용하기만 하면 된다!
public class ThreadLocal<T> {
...
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
}
Remove (remove())
ThreadPool에 있는 쓰레드를 재사용한다.ThreadLocal값을 지우지 않으면, 다음 요청에서도 그 값이 남아있게 된다.ThreadLocal값이 계속 남아 있으면 메모리 누수(Leak)가 발생할 수 있어, 요청이 끝난 뒤에는 반드시 remove()로 삭제해줘야 한다.👉 여기서 쓰레드풀(ThreadPool)이란?
필요할 때마다 새 쓰레드를 만들지 않고,
미리 만들어둔 쓰레드들을 재사용하는 구조를 말한다.
즉, "작업을 처리할 쓰레드들의 묶음(풀)"이다.사용 이유
- 쓰레드를 생성하는 비용(CPU, 메모리 등)이 꽤 크다.
- 동시에 너무 많은 쓰레드를 만들면 서버가 과부하된다.
- 요청이 끝나면 그 쓰레드를 버려야 해서 비효율적이다.
ThreadLocalMap - Entry (Type)
ThreadLocalMap 내부에서 ThreadLocal 인스턴스를 키(Key)로, 실제 값을 value로 저장하는 객체이다.k는 ThreadLocal 인스턴스인데, 이걸 약한 참조(WeakReference)로 저장한다.❗왜 WeakReference인가?
ThreadLocal인스턴스가 더 이상 참조되지 않으면 GC(Garbage Collector)가 회수할 수 있도록 하기 위함이다.
만약 Strong Reference였다면, 해당ThreadLocalMap이 계속 참조하고 있어서 GC가 회수하지 못하고 메모리 누수가 발생했을 것이다.
Entry.key는 약한 참조라서 GC가 회수해버릴 수 있지만, Entry.value는 강한 참조라서 계속 남아있다. 이때, key는 없어지고 value만 남은 entry가 맵에 계속 남으면 메모리 누수가 발생한다.
→ remove()를 꼭 해줘야 하는 이유이다!
[참고]
https://velog.io/@semi-cloud/%EC%8A%A4%ED%94%84%EB%A7%81-%EA%B3%A0%EA%B8%89%ED%8E%B81-ThreadLocal
https://jaehoney.tistory.com/302
https://catsbi.oopy.io/3ddf4078-55f0-4fde-9d51-907613a44c0d