싱글톤으로 등록된 스프링 빈은 해당 객체의 인스턴스가 애플리케이션에 딱 1개만 존재한다. 이러한 인스턴스에 여러 쓰레드가 동시에 접근하면 문제가 발생한다. 이러한 문제를 동시성 문제라 한다.
FieldLogTrace.java
//...
@Slf4j
public class FieldLogTrace implements LogTrace {
private static final String START_PREFIX = "-->";
private static final String COMPLETE_PREFIX = "<--";
private static final String EX_PREFIX = "<X-";
private TraceId traceIdHolder; //traceId 동기화, 동시성 이슈 발생
@Override
public TraceStatus begin(String message) {
syncTraceId();
TraceId traceId = traceIdHolder;
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
return new TraceStatus(traceId, startTimeMs, message);
}
@Override
public void end(TraceStatus status) {
complete(status, null);
}
@Override
public void exception(TraceStatus status, Exception e) {
complete(status, e);
}
private void complete(TraceStatus status, Exception e) {
Long stopTimeMs = System.currentTimeMillis();
long resultTimeMs = stopTimeMs - status.getStartTimeMs();
TraceId traceId = status.getTraceId();
if (e == null) {
log.info("[{}] {}{} time={}ms", traceId.getId(),
addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(),
resultTimeMs);
} else {
log.info("[{}] {}{} time={}ms", traceId.getId(),
addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(),
resultTimeMs, e.toString());
}
releaseTraceId();
}
private void syncTraceId() {
if (traceIdHolder == null) {
traceIdHolder = new TraceId();
} else {
traceIdHolder = traceIdHolder.createNextId();
}
}
private void releaseTraceId() {
if (traceIdHolder.isFirstLevel()) {
traceIdHolder = null; //destroy
} else {
traceIdHolder = traceIdHolder.createPreviousId();
}
}
private static String addSpace(String prefix, int level) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < level; i++) {
sb.append( (i == level - 1) ? "|" + prefix : "| ");
}
return sb.toString();
}
}
어느 정도 텀을 두고 같은 요청을 여러 번 호출하면 다음과 같은 결과가 나타난다.
동시 요청 시에도 이와 같은 결과가 나타나길 기대하고 있다.
하지만, 1초 안에 같은 요청을 여러 번 호출하면 실제론 다음과 같은 결과가 나타난다.
위 결과를 보면 기대한 바와 전혀 다른 문제가 발생한다.
Transaction ID도 동일하고, level도 많이 꼬였다.
동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 적은 상황에선 확률상 잘 나타나지 않고, 트래픽이 점점 많아질수록 자주 발생한다.
특히 스프링 빈처럼 싱글톤 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야 한다.
📌 참고
동시성 문제는 지역 변수에선 발생하지 않는다. 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당된다. 동시성 문제가 발생하는 곳은 같은 인스턴스의 필드(주로 싱글톤), 또는 static 같은 공용 필드에 접근할 때 발생한다. 동시성 문제는 값을 읽기만 하면 발생하지 않고, 어디선가 값을 변경하기 때문에 발생한다.
쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 말한다.
일반적인 변수 필드의 경우, 여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있다.
쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공한다. 따라서 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제 없다.
Java는 언어 차원에서 쓰레드 로컬을 지원하기 위해 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();
}
}
}
ThreadLocal.set(xxx)
ThreadLocal.get()
ThreadLocal.remove()
@Slf4j
public class ThreadLocalServiceTest {
private ThreadLocalService service = new ThreadLocalService();
@Test
void threadLocal() {
log.info("main start");
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);
threadB.start();
sleep(2000);
log.info("main exit");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
쓰레드 로컬 덕분에 쓰레드마다 각각 별도의 데이터 저장소를 가지게 되었고, 결과적으로 동시성 문제도 해결되었다.
쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우 심각한 문제가 발생할 수 있다.
이런 문제를 예방하려면 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove()
를 통해 꼭 제거해야 한다.
Controller가 받은 요청 쓰레드는 Tomcat의 쓰레드 풀에 있는 쓰레드를 할당 뒤 스프링 애플리케이션으로 이동해 필요한 로직 수행 뒤 다시 Tomcat으로 이동하여 응답/반환 후 쓰레드 풀로 돌아간다.
WAS가 여러 대일 경우에도 쓰레드 로컬이 동시성 이슈를 막을 수 있다.