스프링 핵심 원리 - 고급편 - (6) 쓰레드 로컬 - 쓰레드 로컬 동기화
인프런 스프링 핵심 원리 - 고급편을 학습하고 정리한 내용 입니다.
쓰레드 로컬 동기화 - 개발
FieldLogTrace에서 발생했던 동시성 문제를 ThreadLocal로 해결해 보자.
TraceId traceIdHolder 필드를 쓰레드 로컬을 사용하도록 ThreadLocal traceIdHolder로 변경하면 된다.
필드 대신 쓰레드 로컬을 사용해서 데이터를 동기화 하는 ThreadLocalLogTrace를 새로 만들자.
ThreadLocalLogTrace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@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 TraceId traceIdHolder; // traceId 동기화, 동시성 이슈 발생..
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(); // 파괴
} 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();
}
}
1
2
//private TraceId traceIdHolder; // traceId 동기화, 동시성 이슈 발생..
private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();
traceIdHolder가 필드에서 ThreadLocal로 변경되었다. 따라서 값을 저장할 때는 set(..)을 사용하고, 값을 조회할 때는 get()을 사용한다
1
2
3
4
5
6
7
8
private void syncTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId == null) {
traceIdHolder.set(new TraceId());
} else {
traceIdHolder.set(traceId.createNextId());
}
}
먼저 traceIdHolder에서 traceId를 가져와서 set()을 사용해서 세팅을 한다.
값이 있다면 다음 Level을 세팅하기 위해 createNextId()를 사용한다.
1
2
3
4
5
6
7
8
private void releaseTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId.isFirstLevel()) {
traceIdHolder.remove(); // 파괴
} else {
traceIdHolder.set(traceId.createPreviousId()); // 레벨 감소
}
}
로그 레벨이 감소 할 때도 마찬가지 이다.
하지만 이제 마지막 즉 처음 로그 레벨일 때는 traceIdHolder.remove()를 사용해 삭제 시켜줘야 한다.
ThreadLocal.remove()
추가로 쓰레드 로컬을 모두 사용하고 나면 꼭 ThreadLocal.remove() 를 호출해서 쓰레드 로컬에 저장된 값을 제거 해주어야 한다.
쉽게 이야기해서 다음의 마지막 로그를 출력하고 나면 쓰레드 로컬의 값을 제거해야 한다.
1
2
3
4
[3f902f0b] hello1
[3f902f0b] |-->hello2
[3f902f0b] |<--hello2 time=2ms
[3f902f0b] hello1 time=6ms //end() -> releaseTraceId() -> level==0, ThreadLocal.remove() 호출
여기 서는 releaseTraceId()를 통해 level이 점점 낮아져서 2→1→0이 되면 로그를 처음 호출한 부분으로 돌아온 것이다.
따라서 이 경우 연관된 로그 출력이 끝난 것이다. 이제 더 이상 TraceId 값을 추적하지 않아도 된다.
그래서 traceId.isFirstLevel()(level==0)인 경우 ThreadLocal.remove()를 호출해서 쓰레드 로컬에 저장된 값을 제거해준다.
코드에 문제가 없는지 간단한 테스트를 만들어서 확인해보자.
ThreadLocalLogTraceTest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ThreadLocalLogTraceTest {
ThreadLocalLogTrace trace = new ThreadLocalLogTrace();
@Test
void begin_end_level2() {
TraceStatus status1 = trace.begin("hello1");
TraceStatus status2 = trace.begin("hello2");
trace.end(status2);
trace.end(status1);
}
@Test
void begin_exception_level2() {
TraceStatus status1 = trace.begin("hello1");
TraceStatus status2 = trace.begin("hello2");
trace.exception(status2, new IllegalStateException());
trace.exception(status1, new IllegalStateException());
}
}

잘 된다.
이제 멀티쓰레드 상황에서 문제가 없는지는 애플리케이션에 ThreadLocalLogTrace를 적용해서 확인해보자.
쓰레드 로컬 동기화 - 적용
자 이제 기존 로직들을 크게 바꿀 필요 없이 LogTraceConfig만 잘 수정해 주면 된다.
hello.advanced.LogTraceConfig
1
2
3
4
5
6
7
8
9
10
@Configuration
public class LogTraceConfig {
@Bean
public LogTrace logTrace() {
//return new FiledLogTrace();
return new ThreadLocalLogTrace();
}
}

이렇게 컨트롤러나 사용하는 곳에서 인터페이스로 주입을 받기 때문에 bean에서 구현체만 잘 바꿔주면 변경이 된다.

이제 정상적으로 쓰레드 별로 나누어 진 걸 볼 수 있다.
쓰레드 로컬 - 주의 사항
쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우에 심각한 문제가 발생할 수 있다.
다음 예시를 통해서 알아보자.
사용자A 저장 요청

- 사용자A가 저장 HTTP를 요청했다.
- WAS는 쓰레드 풀에서 쓰레드를 하나 조회한다.
- 쓰레드
thread-A가 할당되었다. thread-A는 사용자A의 데이터를 쓰레드 로컬에 저장한다.- 쓰레드 로컬의
thread-A전용 보관소에 사용자A 데이터를 보관한다.
사용자A 저장 요청 종료

- 사용자A의 HTTP 응답이 끝난다.
- WAS는 사용이 끝난
thread-A를 쓰레드 풀에 반환한다. 쓰레드를 생성하는 비용은 비싸기 때문에 쓰레드를 제거하지 않고, 보통 쓰레드 풀을 통해서 쓰레드를 재사용한다. thread-A는 쓰레드풀에 아직 살아있다. 따라서 쓰레드 로컬의thread-A전용 보관소에 사용자A 데이터도 함께 살아있게 된다.
사용자B 조회 요청

- 사용자B가 조회를 위한 새로운 HTTP 요청을 한다.
- WAS는 쓰레드 풀에서 쓰레드를 하나 조회한다.
- 쓰레드
thread-A가 할당되었다. (물론 다른 쓰레드가 할당될 수 도 있다.) - 이번에는 조회하는 요청이다.
thread-A는 쓰레드 로컬에서 데이터를 조회한다. - 쓰레드 로컬은
thread-A전용 보관소에 있는사용자A값을 반환한다. - 결과적으로
사용자A값이 반환 된다. - 사용자B는 사용자A의 정보를 조회하게 된다.
결과적으로 사용자B는 사용자A의 데이터를 확인하게 되는 심각한 문제가 발생하게 된다.
이런 문제를 예방하려면 사용자A의 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove()를 통해서 꼭 제거해야 한다.
쓰레드 로컬을 사용할 때는 이 부분을 꼭! 기억하자
댓글남기기