로그 추적기를 구현하는 과정에서 동시성 문제를 마주했다. 쓰레드 로컬을 사용해서 문제를 해결하기위해 쓰레드 로컬에 대해 알아보고자 한다.
동시성 문제가 발생한 코드를 먼저 살펴보자.
@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();
}
}
}
파라미터로 넘어온 name을 nameStore에 저장하고 1초 뒤에 nameStore를 반환하는 단순한 로직이다.
@Slf4j
public class FieldServiceTest {
private FieldService fieldService = new FieldService();
@Test
void field() {
log.info("main start");
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(100);
threadB.start();
sleep(3000);
log.info("main exit");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch(InterruptedException e) {
e.printStackTrace();
}
}
}
위에서 정의한 서비스를 이용해 작성한 테스트 케이스다.
new Thread(userA)
: start() 메서드를 사용해 실행하면 Runnable 객체 userA가 실행된다.따라서, 테스트 코드의 실행 순서를 살펴보자면 fieldService.logic("userA")
가 실행되고 0.1초 후에 fieldService.logic("userB")
가 실행된다.
지금부터 fieldService.logic("userA")를 taskA
, fieldService.logic("userB")를 taskB
라고 하겠다.
내가 예상한 결과는 taskA
에서 조회한 nameStore 값이 userA였지만 로그를 출력해보니 userB가 출력됐다. 이는 taskA
가 끝나기 전에 taskB
가 실행되기 때문이다.
nameStore의 변화 순서를 살펴보면 null -> userA -> userB 순서이고 userA가 1초를 기다리는 동안 nameStore는 taskB
에 의해 userB로 변하므로 taskA
에서 nameStore를 조회할 시점에 nameStore 값은 userB이다.
쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 말한다.
thread-A
가 데이터를 저장, 조회할 때 쓰레드 로컬은 thread-A
전용 보관소에서 데이터를 저장, 반환한다. thread-B
도 마찬가지다.
자바는 언어차원에서 쓰레드 로컬을 지원하기 위한 java.lang.ThreadLocal
클래스를 제공한다.
쓰레드 로컬을 사용해서 위에서 발생한 동시성 문제를 해결해보자.
@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();
}
}
}
@Slf4j
public class ThreadLocalServiceTest {
private ThreadLocalService threadLocalService = new ThreadLocalService();
@Test
void field() {
log.info("main start");
Runnable userA = () -> {
threadLocalService.logic("userA");
};
Runnable userB = () -> {
threadLocalService.logic("userB");
};
Thread threadA = new Thread(userA);
threadA.setName("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start();
sleep(100);
threadB.start();
sleep(3000);
log.info("main exit");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch(InterruptedException e) {
e.printStackTrace();
}
}
}
동시성 이슈가 발생했던 테스트 결과와 달리 쓰레드 로컬이 각 쓰레드 별로 데이터를 저장, 조회하는 것을 확인할 수 있었다.
쓰레드 로컬의 값은 사용하고 난 후에 제거해야한다. ThreadLocal.remove()
왜냐하면, 쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS처럼 쓰레드 풀을 사용하는 경우에 심각한 문제가 발생할 수 있기 때문이다.
thread-A
할당thread-A
는 사용자A
의 데이터를 쓰레드 로컬에 저장thread-A
전용 보관소에 사용자A
데이터 보관thread-A
를 쓰레드 풀에 반환 -> thread-A
는 아직 쓰레드 풀에 살아있다.thread-A
의 쓰레드 로컬 값을 제거하지 않았을 경우, thread-A
전용 보관소에 사용자A
의 데이터도 존재한다.사용자B가 조회를 위해 새로운 HTTP 요청
WAS는 쓰레드 풀에서 쓰레드 조회
thread-A
할당
thread-A
는 쓰레드 로컬에서 데이터 조회
쓰레드 로컬은 thread-A
전용 보관소에 있는 사용자A
데이터 반환
결과적으로 사용자B는 사용자A의 정보를 조회하게 된다 !!
📌 이러한 심각한 오류를 범하지 않기 위해서 요청이 끝날 때 ThreadLocal.remove()
메서드를 사용해 쓰레드 로컬의 값을 제거해야 한다.