동시성 문제
는 멀티 스레드 환경에서 여러 스레드가 하나의 자원에 접근했을 때 발생할 수 있는 문제이다. 물론 여러 스레드가 자원에 접근해서 읽기 작업만을 수행한다면 문제가 없지만, 쓰기 & 수정 작업이 이뤄진다면 문제가 있다.
동시성 문제
가 발생하는 상황은 다음과 같다.
이 글에서는 "싱글톤 스코프의 빈 객체의 상태(클래스 변수 혹은 인스턴스 변수)에 여러 스레드가 접근해 수정하려는 상황" 에 대해서 어떻게 동시성 문제를 해결할 수 있는지 확인해보도록 한다.
여러 쓰레드가 지역 변수에 접근하는 것에는
동시성 문제
가 발생하지 않는다. 왜?? 쓰레드는 Stack 영역은 별도로 존재하고, 그 이외의 영역을 다른 쓰레드와 공유하기 때문이다.
**클래스 변수 (static Variable)**
- 클래스 영역에 위치한 (메서드 안이 아닌) 변수 중
static
제어자를 가진 변수를 클래스 변수라고 한다.- static 제어자가 붙으면 클래스 내에서 단 한번만 생성된다.
- 해당 클래스 내의 모든 인스턴스가 공유해야 하는 값을 유지할 때 사용한다.
**인스턴스 변수 (instance Variable)**
- 클래스 영역에 위치한 (메서드 안이 아닌) 변수 중
static
제어자를 가지지 않은 변수를 인스턴스 변수라고 한다.- 인스턴스 즉 객체마다 가져야 하는 고유의 값을 주기 위해 사용한다.
- 클래스 변수와 달리 인스턴스 변수를 생성 시 인스턴스에 맞게 초기화된 변수가 새롭게 주어진다.
**지역변수 (local Variable)**
- 클래스 영역 안에서 메서드, 생성자, 초기화 블럭 안에 있는 변수를 지역 변수라고 한다.
- 지역 변수는 해당 변수가 사용된 메서드, 생성자 내에서만 사용되고 밖을 벗어나면 소멸된다.
@Slf4j
public class FieldService {
private String nameStore;
public String logic(String name) {
log.info("저장 name={} -> nameStore={}", name, nameStore);
nameStore = name;
sleep(1000);
log.info("조회 nameStore={}", nameStore);
return nameStore;
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
@Slf4j
public class FieldServiceTest {
private FieldService fieldService = new FieldService();
@Test
void field() {
log.info("main start");
// 스레드 2개 만듦.
// 아래 람다식은 위와 같음.
Runnable userA = () -> {
fieldService.logic("userA");
};
Runnable userB = () -> {
fieldService.logic("userB");
};
Thread threadA = new Thread(userA);
threadA.setName("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start();
// sleep(2000); // 동시성 문제 발생X (로직 내에서는 1초 지연만 있기 때문에 겹칠 일 없음)
sleep(100); // 동시성 문제 발생O (겹치게 됨)
threadB.start();
sleep(3000); // 메인 스레드 종료 대기
log.info("main exit");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException exception) {
exception.printStackTrace();
}
}
}
Thread-A가 비즈니스 로직을 통해, fieldService 라는 객체의 인스턴스 변수(nameStore
)에 접근해 바꾸려한다. 그리고 1초 뒤, 조회 로그를 찍는다. 만약Thread-B가 fieldService 객체에 접근하기 전, Thread-A가 조회 작업까지 마치고 나간다면 문제가 생기지 않겠지만 위의 그림처럼 Thread-B가 Thread-A가 nameStore에 있는 값을 조회하기 전 수정한다면 Thread-A와 Thread-B 모두 nameStore에 userB라는 이름이 저장돼있는 것을 확인할 것이다.
이런 동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 적은 상황에서는 확률상 잘 나타나지 않지만, 트래픽이 많아질수록 이런 문제는 자주 발생할 것이다.
스프링의 싱글톤 스코프의 빈처럼 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야 한다. 웬만하면 빈 객체에는 인스턴스 변수 혹은 클래스 변수를 사용하지 않는 습관을 들이자. 만약 사용해야 한다면
final
키워드를 이용하도록 하자!!
만약 상황 상 싱글톤 객체에 클래스 변수(static) 혹은 인스턴스 변수를 두어야 하는 상황이라면, 어떻게 동시성 문제를 해결할 수 있을까? 답은 ThreadLocal
을 이용하면 된다!
ThreadLocal은 각각의 쓰레드마다 별도의 내부 저장소를 제공함으로써 쓰레드 간 동시성 문제를 막아준다. 그림으로 보면 아래와 같다.
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this);
}
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
ThreadLocal
클래스는 public 메서드 get
, set
, remove
함수들을 지원하고 있다.
get()
: thread 별로 저장해둔 값 혹은 객체를 리턴해준다. set(T value)
: thread 별로 가지고 있는 내부 저장소를 활용해 값 혹은 객체를 저장한다. remove()
: 해당 thread가 가지고 있는 저장소를 release 해주며 자원을 반납한다.
ThreadLocal
클래스에서 쓰레드 별로 쓰레드를 구분할 수 있는 이유는ThreadLocalMap
클래스에 있다.
ThreadLocalMap
클래스는ThreadLocal
클래스의 정적 내부 클래스다. 모두 private 클래스로 구성되어 있어 외부에서 접근 가능한 메서드가 없으며, 내부적으로 해시 테이블 정보를 갖는데, 요소는WeakReference
를 확장하고ThreadLocal
객체를 키로 사용하는Entry
클래스다.
Entry(엔트리)
클래스란, 키와 값으로 구성되는 데이터를 의미한다.Mapping(매핑)
이라고 부르기도 한다.WeakReference
객체는,GC
와 관련된 개념이다. 보통new
키워드를 통해 생성된 객체는 강한 Reference가 적용되지만,WeakReference
로 생성된 객체는 약한 Reference가 적용된다.GC
는WeakReference
로 생성된 객체는 잠깐 쓰이는 것으로 인식해 빠르게 삭제해 메모리를 최적화한다.
@Slf4j
public class ThreadLocalService {
private ThreadLocal<String> nameStore = new ThreadLocal<>();
public String logic(String name) {
log.info("저장 name={} -> nameStore={}", name, nameStore.get());
nameStore.set(name);
sleep(1000);
log.info("조회 nameStore={}", nameStore.get());
return nameStore.get();
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
위의 코드는 아까 봤던 FieldService
클래스의 코드와 같은 기능을 하지만, 동시성 문제를 해결한 클래스인 ThreadLocalService
이다.
ThreadLocal.set(T value)
을 이용해서 nameStore
변수에는 쓰레드별로 다른 값이 저장될 수 있도록 구현되었습니다. 조회 시에는 ThreadLocal.get()
메서드를 통해 각 쓰레드가 저장한 값을 리턴받습니다.
@Slf4j
public class ThreadLocalServiceTest {
private ThreadLocalService service = new ThreadLocalService();
@Test
void field() {
log.info("main start");
// 스레드 2개 만듦.
// 아래 람다식은 위와 같음.
Runnable userA = () -> {
service.logic("userA");
};
Runnable userB = () -> {
service.logic("userB");
};
Thread threadA = new Thread(userA);
threadA.setName("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start();
sleep(100); // 동시성 문제 발생X (ThreadLocal을 사용함으로써 동시성 문제 해결)
threadB.start();
sleep(3000); // 메인 스레드 종료 대기
log.info("main exit");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException exception) {
exception.printStackTrace();
}
}
}
위의 결과 출력문을 보면, 쓰레드별로 잘 저장되고 조회되는 것을 확인할 수 있다.
위의 테스트코드에서는 잠깐 실험용 코드라 remove()
함수를 호출하지 않았지만, 해당 스레드에서 값을 다 사용하고 난 뒤에는 remove()
함수를 잊지말고 호출해서 ThreadLocal
, 자세히 말하자면 ThreadLocalMap
에 저장된 값을 지워줘야 한다.
만약 remove()
를 호출하지 않는다면, WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우에는 심각한 문제가 발생할 수 있다.
쓰레드 풀은 쓰레드를 새로 생성하지 않고, 쓰레드를 재사용함으로써 쓰레드 생성 비용, 제거 비용을 줄인 방법이다.
위의 그림처럼, 쓰레드를 재사용하게 되어 Thread-B에서 Thread-A에 접근해 값을 잘못 조회하게 되는 불상사가 있을 수 있다. 사용이 끝난 뒤에는 항상 ThreadLocal.remove()
함수 호출해주자!
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard
https://velog.io/@noakafka/Spring-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C
https://crazy-horse.tistory.com/entry/%EC%9E%AC%EA%B3%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9C%BC%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95
https://madplay.github.io/post/java-threadlocal
https://junlab.tistory.com/234