: 해당 쓰레드만 접근할 수 있는 특별한 저장소 ( = 물건 보관 창구)
물건 보관 창구와 비슷하다고 생각하면 된다.
물건 보관 창구는 여러 사람이 같은 창구를 사용하더라도 창구 직원(ThreadLocal
)이 사용자를 구별해 사용자별로 확실히 물건을 구분해준다.
사용자A, 사용자B 모두 창구 직원을 통해 물건을 보관하고 꺼내지만, 창구 직원이 사용자에 따라 보관한 물건을 구분해준다.
여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있다.
➡️ Thread-A
가 저장한 userA
값이 사라져버렸다.
반면, ThreadLocal을 사용하면 각 쓰레드마다 별도의 내부 저장소가 제공되어 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제가 발생하지 않는다.
➡️ thread-A
가 userA
라는 값을 저장하면 별도의 전용 보관소에 데이터를 안전하게 저장하고, thread-B
도 마찬가지로 안전하게 별도의 전용 보관소에 저장된다.
그러므로 데이터를 조회할 때도 전용 보관소에서 값을 반환해주기 때문에 아무런 문제가 발생하지 않는다.
자바는 언어 차원에서 쓰레드 로컬을 지원하기 위한 java.lang.ThreadLocal
클래스를 제공한다.
ThreadLocalService.java
@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); // 저장하는데 1초 정도 걸린다고 가정한 것
log.info("조회 nameStore={}", nameStore.get());
return nameStore.get();
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
➡️ 기존의 FieldService
와 같은 코드지만, nameStore
필드가 일반 String
타입 → ThreadLocal
을 사용하도록 하였다.
ThreadLocal 사용법
- 값 저장:
ThreadLocal.set(xxx)
- 값 조회:
ThreadLocal.get()
- 값 제거:
ThreadLocal.remove()
해당 쓰레드가 쓰레드 로컬을 모두 사용하고 나면 ThreadLocal.remove()
를 호출해 쓰레드 로컬에 저장된 값을 제거해주어야 한다.
ThreadLocalServiceTest.java
@Slf4j
public class ThreadLocalServiceTest {
private ThreadLocalService service = new ThreadLocalService();
...
}
➡️ 나머지 부분은 FieldServiceTest.java
와 동일하다.
쓰레드 로컬로 쓰레드마다 각각 별도의 데이터 저장소를 가지게 되어 동시성 문제가 해결되었다.
: FieldLogTrace
에서 발생했던 동시성 문제를 ThreadLocal
로 해결하기
TraceId traceIdHolder
필드를 쓰레드 로컬을 사용하도록 ThreadLocal<TraceId> traceIdHolder
로 변경하면 된다.
필드 대신 쓰레드 로컬을 사용해 데이터를 동기화하는 ThreadLocalLogTrace
새로 만들기
ThreadLocalLogTrace.java
@Slf4j
public class ThreadLocalLogTrace implements LogTrace {
private static final String START_PREFIX = "-->";
private static final String COMPLETE_PREFIX = "<--";
private static final String EX_PREFIX = "<X-";
private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();
@Override
public TraceStatus begin(String message) {
syncTraceId();
TraceId traceId = traceIdHolder.get();
Long startTimeMs = System.currentTimeMillis();
// 로그 출력
log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
return new TraceStatus(traceId, startTimeMs, message);
}
private void syncTraceId() {
TraceId traceId = traceIdHolder.get();
if(traceId == null) { // 맨 처음이라면 새로 생성
traceIdHolder.set(new TraceId());
} else {
traceIdHolder.set(traceId.createNextId());
}
}
@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 ex={}", traceId.getId(),
addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs,
e.toString());
}
releaseTraceId();
}
private void releaseTraceId() {
TraceId traceId = traceIdHolder.get();
if(traceId.isFirstLevel()) { // 들어갔다 나오는. 마지막 로그인 상태
traceIdHolder.remove(); // remove 필요 (destroy)
} else {
traceIdHolder.set(traceId.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();
}
}
이전 코드와 크게 달라진 것은 없고 traceIdHolder
가 필드에서 ThreadLocal
로 변경되었기 때문에 값을 저장할 때는 set(..)
, 조회할 때는 get()
을 사용해야 한다.
ThreadLocal.remove()
추가로 쓰레드 로컬을 모두 사용한 뒤 꼭!
ThreadLocal.remove()
를 호출해 쓰레드 로컬에 저장된 값을 제거해주어야 한다.
releaseTraceId()
를 통해level
이 점점 낮아져 2→1→0이 되면 로그를 처음 호출한 부분으로 돌아오게 된 것이므로 연관된 로그 출력이 끝난 것이다.더이상
TraceId
값을 추적하지 않아도 되므로traceId.isFirstLevel()
인 경우ThreadLocal.remove()
를 호출해 쓰레드 로컬에 저장된 값을 제거해주어야 한다.
ThreadLocalLogTraceTest.java
@Slf4j
class ThreadLocalLogTraceTest {
ThreadLocalLogTrace trace = new ThreadLocalLogTrace();
...
➡️ 이 부분만 바꾸어주면 이전 테스트와 동일하다.
적용 과정은 정말 쉽다.
LogTraceConfig.java
@Configuration
public class LogTraceConfig {
@Bean
public LogTrace logTrace() { // 인스턴스가 딱 하나만 등록됨
return new ThreadLocalLogTrace();
}
}
➡️ ThreadLocalLogTrace()
로만 바꾸어주면 된다. (ThreadLocalLogTrace()
을 스프링 빈으로 등록함)
이전과 마찬가지로 1초 안에 두번 호출하게 되면 아래와 같은 결과를 얻을 수 있다. 전과는 다르게 트랜잭션ID가 구분되는 원하는 결과를 얻을 수 있다.
쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우 심각한 문제가 발생할 수 있다.
- 사용자A가 저장 HTTP 요청
- WAS는
쓰레드 풀
에서 쓰레드 하나 조회- 쓰레드
thread-A
할당thread-A
는 사용자A의 데이터를 쓰레드 로컬에 저장- 쓰레드 로컬의
thread-A
전용 보관소에 사용자A 데이터를 보관
- 사용자A의 HTTP 응답 끝남
- WAS는 사용이 끝난
thread-A
를 쓰레드 풀에 반환.
(쓰레드를 생성하는 비용은 비싸기 때문에 제거하지 않고 반환하고 다시 쓰레드 풀을 통해 재사용함)thread-A
는 쓰레드 풀에 아직 살아있기 때문에 쓰레드 로컬의thread-A
전용 보관소에 사용자A의 데이터도 함께 살아있다. (thread-A
가 관리하는 보관소이기 때문에 아직 짤리지 않았기 때문에 사물함이 남아있음(?))
- 사용자B가 조회를 위한 새로운 HTTP 요청
- WAS는 쓰레드 풀에서 쓰레드 하나 조회
- (하필!!) 쓰레드
thread-A
가 할당 (쓰레드 풀에서는 뭐가 나올지 몰라서 물론 다른 쓰레드가 할당될 수도 있지만 하필 A가 할당되었다고 할 때)- 이번에는 조회하는 요청.
thread-A
는 쓰레드 로컬에서 데이터를 조회- 쓰레드 로컬은
thread-A
전용 보관소에 있는 사용자A 값을 반환- 결과적으로 사용자A의 값이 반환
- 사용자B는 사용자A의 정보를 조회하게 됨
결과적으로 B가 A의 데이터를 확인하게 되는 심각한 문제가 발생한다.
⭐️ 이런 문제를 예방하기 위해서는 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove()
를 통해 꼭 제거해주어야 한다.
thanks