쓰레드 로컬 - ThreadLocal
- 김영한님의 스프링 핵심 원리 - 고급편 강의를 바탕으로
TraceId를 필드로 동기화하는 방식의 동시성 문제를 분석하고,ThreadLocal을 활용하여 해결하는 과정을 정리함
필드 동기화 - 개발
- V2에서는
TraceId를 파라미터로 넘기다 보니 모든 메서드에 불필요한 인자가 퍼져나가는 구조가 됐음 -
이를 개선하기 위해
LogTrace인터페이스를 먼저 정의하고, 구현체를 분리하여 향후 유연하게 교체할 수 있도록 설계함
LogTrace인터페이스- 로그 추적의 시작, 종료, 예외 처리를 추상화한 인터페이스임
1 2 3 4 5
public interface LogTrace { TraceStatus begin(String message); void end(TraceStatus status); void exception(TraceStatus status, Exception e); }
FieldLogTraceTraceId를 파라미터로 넘기는 대신 인스턴스 필드traceIdHolder에 저장하여 동기화함
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
@Slf4j public class FieldLogTrace implements LogTrace { private TraceId traceIdHolder; // traceId 동기화, 동시성 이슈 발생 지점 @Override public TraceStatus begin(String message) { syncTraceId(); TraceId traceId = traceIdHolder; } private void syncTraceId() { if (traceIdHolder == null) { traceIdHolder = new TraceId(); } else { traceIdHolder = traceIdHolder.createNextId(); } } private void releaseTraceId() { if (traceIdHolder.isFirstLevel()) { traceIdHolder = null; // 최초 레벨이면 제거 } else { traceIdHolder = traceIdHolder.createPreviousId(); } } // end(), exception(), complete(), addSpace() 생략 }
-
syncTraceId/releaseTraceId동작 원리
메서드 동작 syncTraceId()최초 호출이면 새 TraceId생성, 이후 호출이면createNextId()로 level 증가releaseTraceId()최초 레벨이면 null로 제거, 아니면createPreviousId()로 level 감소 -
FieldLogTraceTest1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
class FieldLogTraceTest { FieldLogTrace trace = new FieldLogTrace(); @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("hello"); TraceStatus status2 = trace.begin("hello2"); trace.exception(status2, new IllegalStateException()); trace.exception(status1, new IllegalStateException()); } }
-
테스트 실행 결과
1 2 3 4 5 6 7 8 9 10 11
// begin_end_level2() [ed72b67d] hello1 [ed72b67d] |-->hello2 [ed72b67d] |<--hello2 time=2ms [ed72b67d] hello1 time=6ms // begin_exception_level2() [59770788] hello [59770788] |-->hello2 [59770788] |<X-hello2 time=3ms ex=java.lang.IllegalStateException [59770788] hello time=8ms ex=java.lang.IllegalStateException
필드 동기화 - 적용
-
FieldLogTrace를 수동으로 스프링 빈에 등록해두면, 나중에 구현체를 교체할 때 설정 파일 하나만 바꾸면 됨 -
LogTraceConfig(스프링 빈 수동 등록)1 2 3 4 5 6 7 8
@Configuration public class LogTraceConfig { @Bean public LogTrace logTrace() { return new FieldLogTrace(); } }
- V3 애플리케이션 코드
- V2에서
TraceId파라미터 전달 코드를 모두 제거하고LogTrace인터페이스를 주입받아 사용함
- V2에서
OrderControllerV3LogTrace를 주입받아try-catch패턴으로 로그를 기록하며TraceId를 파라미터로 전달하지 않음
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
@RestController @RequiredArgsConstructor public class OrderControllerV3 { private final OrderServiceV3 orderService; private final LogTrace trace; @GetMapping("/v3/request") public String request(String itemId) { TraceStatus status = null; try { status = trace.begin("OrderController.request()"); orderService.orderItem(itemId); trace.end(status); return "ok"; } catch (Exception e) { trace.exception(status, e); throw e; } } }
OrderServiceV3,OrderRepositoryV3- 컨트롤러와 동일한
try-catch패턴으로LogTrace를 적용해 로그를 남김 - OrderServiceV3 전체 코드 보기
- OrderRepositoryV3 전체 코드 보기
- 컨트롤러와 동일한
-
V3 정상 실행 로그
1 2 3 4 5 6
[f8477cfc] OrderController.request() [f8477cfc] |-->OrderService.orderItem() [f8477cfc] | |-->OrderRepository.save() [f8477cfc] | |<--OrderRepository.save() time=1004ms [f8477cfc] |<--OrderService.orderItem() time=1006ms [f8477cfc] OrderController.request() time=1007ms
필드 동기화 - 동시성 문제
-
FieldLogTrace는 스프링 싱글톤 빈이므로 인스턴스가 하나뿐인데, 여러 쓰레드가 동시에traceIdHolder필드를 읽고 쓰면서 데이터가 뒤섞이는 문제가 발생함
-
실제 동시 요청 시 로그 (비정상)
1 2 3 4 5 6 7 8 9 10 11 12
[nio-8080-exec-3] [aaaaaaaa] OrderController.request() [nio-8080-exec-3] [aaaaaaaa] |-->OrderService.orderItem() [nio-8080-exec-3] [aaaaaaaa] | |-->OrderRepository.save() [nio-8080-exec-4] [aaaaaaaa] | | |-->OrderController.request() [nio-8080-exec-4] [aaaaaaaa] | | | |-->OrderService.orderItem() [nio-8080-exec-4] [aaaaaaaa] | | | | |-->OrderRepository.save() [nio-8080-exec-3] [aaaaaaaa] | |<--OrderRepository.save() time=1005ms [nio-8080-exec-3] [aaaaaaaa] |<--OrderService.orderItem() time=1005ms [nio-8080-exec-3] [aaaaaaaa] OrderController.request() time=1005ms [nio-8080-exec-4] [aaaaaaaa] | | | | |<--OrderRepository.save() time=1005ms [nio-8080-exec-4] [aaaaaaaa] | | | |<--OrderService.orderItem() time=1005ms [nio-8080-exec-4] [aaaaaaaa] | | |<--OrderController.request() time=1005ms
-
두 요청이 같은 트랜잭션 ID를 공유하고 있으며, exec-4의 level이 0이 아닌 3에서 시작되는 것을 확인할 수 있음
-
동시성 문제 원인
구분 설명 발생 조건 싱글톤 객체의 필드 값을 여러 쓰레드가 동시에 변경할 때 발생하지 않는 경우 지역 변수 (쓰레드마다 별도 메모리 영역 할당) 발생하지 않는 경우 필드를 읽기만 하고 변경하지 않을 때 대표 발생 위치 스프링 빈(싱글톤), static 공용 필드
동시성 문제 - 예제 코드
-
공유 필드
nameStore를 이용한 간단한 예제로 동시성 문제가 어떻게 발생하는지 재현해 봄 -
build.gradle- 테스트 Lombok 설정 추가1 2 3 4
dependencies { testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' }
-
FieldService(테스트 코드 위치)1 2 3 4 5 6 7 8 9 10 11 12 13
@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; } }
-
FieldServiceTest1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
@Slf4j public class FieldServiceTest { private FieldService fieldService = new FieldService(); @Test void field() { Runnable userA = () -> fieldService.logic("userA"); Runnable userB = () -> fieldService.logic("userB"); Thread threadA = new Thread(userA, "thread-A"); Thread threadB = new Thread(userB, "thread-B"); threadA.start(); sleep(2000); // 동시성 문제 발생 X // sleep(100); // 동시성 문제 발생 O threadB.start(); sleep(3000); } }
-
순서대로 실행 (
sleep(2000)) - 정상
1 2 3 4
[Thread-A] 저장 name=userA -> nameStore=null [Thread-A] 조회 nameStore=userA [Thread-B] 저장 name=userB -> nameStore=userA [Thread-B] 조회 nameStore=userB
-
동시 실행 (
sleep(100)) - 동시성 문제 발생
1 2 3 4
[Thread-A] 저장 name=userA -> nameStore=null [Thread-B] 저장 name=userB -> nameStore=userA [Thread-A] 조회 nameStore=userB ← userA가 아닌 userB 반환 (오염) [Thread-B] 조회 nameStore=userB
-
Thread-A가
userA를 저장한 뒤 1초간 대기하는 사이에 Thread-B가userB로 덮어써버려서, Thread-A가 조회할 때userB가 반환됨
ThreadLocal - 소개
- ThreadLocal은 각 쓰레드에게 독립된 전용 저장소를 제공함
-
같은 인스턴스의 ThreadLocal 필드에 여러 쓰레드가 접근해도 각 쓰레드는 자신만의 저장소에서 데이터를 읽고 씀
-
일반 필드와 ThreadLocal 비교

-
ThreadLocal 주요 메서드
메서드 설명 ThreadLocal.set(value)현재 쓰레드의 저장소에 값 저장 ThreadLocal.get()현재 쓰레드의 저장소에서 값 조회 ThreadLocal.remove()현재 쓰레드의 저장소에서 값 제거 - 자바에서는
java.lang.ThreadLocal클래스를 표준 라이브러리로 기본 제공하고 있음
ThreadLocal - 예제 코드
-
nameStore필드를 일반String에서ThreadLocal<String>으로 변경하는 것만으로 동시성 문제가 해결됨 -
ThreadLocalService(테스트 코드 위치)1 2 3 4 5 6 7 8 9 10 11 12 13
@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(); } }
-
ThreadLocalServiceTest1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
@Slf4j public class ThreadLocalServiceTest { private ThreadLocalService service = new ThreadLocalService(); @Test void threadLocal() { Runnable userA = () -> service.logic("userA"); Runnable userB = () -> service.logic("userB"); Thread threadA = new Thread(userA, "thread-A"); Thread threadB = new Thread(userB, "thread-B"); threadA.start(); sleep(100); // 동시 실행 threadB.start(); sleep(2000); } }
-
실행 결과 - 동시 실행임에도 정상
1 2 3 4
[Thread-A] 저장 name=userA -> nameStore=null [Thread-B] 저장 name=userB -> nameStore=null [Thread-A] 조회 nameStore=userA ← 정확히 userA 반환 [Thread-B] 조회 nameStore=userB ← 정확히 userB 반환
쓰레드 로컬 동기화 - 개발
FieldLogTrace에서TraceId traceIdHolder필드를ThreadLocal<TraceId> traceIdHolder로 교체함-
로직 자체는 동일하고 값을 읽고 쓰는 방식만 ThreadLocal 메서드로 변경됨
-
ThreadLocalLogTrace1 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
@Slf4j public class ThreadLocalLogTrace implements LogTrace { private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>(); @Override public TraceStatus begin(String message) { syncTraceId(); TraceId traceId = traceIdHolder.get(); } private void syncTraceId() { TraceId traceId = traceIdHolder.get(); if (traceId == null) { traceIdHolder.set(new TraceId()); } else { traceIdHolder.set(traceId.createNextId()); } } private void releaseTraceId() { TraceId traceId = traceIdHolder.get(); if (traceId.isFirstLevel()) { traceIdHolder.remove(); // 쓰레드 로컬 값 제거 (중요) } else { traceIdHolder.set(traceId.createPreviousId()); } } // end(), exception(), complete(), addSpace()는 FieldLogTrace와 동일 }
-
FieldLogTrace와ThreadLocalLogTrace사용법 비교항목 FieldLogTrace ThreadLocalLogTrace 저장소 타입 TraceId traceIdHolderThreadLocal<TraceId> traceIdHolder값 저장 traceIdHolder = valuetraceIdHolder.set(value)값 조회 traceIdHoldertraceIdHolder.get()값 제거 traceIdHolder = nulltraceIdHolder.remove()동시성 안전 여부 안전하지 않음 안전함 -
ThreadLocalLogTraceTest1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
@Slf4j 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("hello"); TraceStatus status2 = trace.begin("hello2"); trace.exception(status2, new IllegalStateException()); trace.exception(status1, new IllegalStateException()); } }
-
테스트 실행 결과
1 2 3 4 5 6 7 8 9 10 11
// begin_end_level2() [3f902f0b] hello1 [3f902f0b] |-->hello2 [3f902f0b] |<--hello2 time=2ms [3f902f0b] hello1 time=6ms // begin_exception_level2() [3dd9e4f1] hello [3dd9e4f1] |-->hello2 [3dd9e4f1] |<X-hello2 time=3ms ex=java.lang.IllegalStateException [3dd9e4f1] hello time=8ms ex=java.lang.IllegalStateException
쓰레드 로컬 동기화 - 적용
-
FieldLogTrace를ThreadLocalLogTrace로 교체하며 설정 파일 한 곳만 변경하면 되고 애플리케이션 코드(V3)는 수정이 필요 없음 -
LogTraceConfig수정1 2 3 4 5 6 7 8 9
@Configuration public class LogTraceConfig { @Bean public LogTrace logTrace() { // return new FieldLogTrace(); // 동시성 문제 있음 return new ThreadLocalLogTrace(); // 동시성 문제 해결 } }
-
동시 요청 시 로그 (정상)
1 2 3 4 5 6 7 8 9 10 11 12
[nio-8080-exec-3] [52808e46] OrderController.request() [nio-8080-exec-3] [52808e46] |-->OrderService.orderItem() [nio-8080-exec-3] [52808e46] | |-->OrderRepository.save() [nio-8080-exec-4] [4568423c] OrderController.request() [nio-8080-exec-4] [4568423c] |-->OrderService.orderItem() [nio-8080-exec-4] [4568423c] | |-->OrderRepository.save() [nio-8080-exec-3] [52808e46] | |<--OrderRepository.save() time=1001ms [nio-8080-exec-3] [52808e46] |<--OrderService.orderItem() time=1001ms [nio-8080-exec-3] [52808e46] OrderController.request() time=1003ms [nio-8080-exec-4] [4568423c] | |<--OrderRepository.save() time=1000ms [nio-8080-exec-4] [4568423c] |<--OrderService.orderItem() time=1001ms [nio-8080-exec-4] [4568423c] OrderController.request() time=1001ms
-
exec-3과 exec-4 각각 독립된 트랜잭션 ID를 가지며 level도 올바르게 동작함
쓰레드 로컬 - 주의사항
- WAS(톰캣)는 성능을 위해 쓰레드 풀을 사용하며 쓰레드는 요청이 끝나도 제거되지 않고 풀로 반환되어 재사용됨
-
이때 ThreadLocal 값을 제거하지 않으면 이전 요청의 데이터가 다음 요청에서 그대로 노출됨

- 해결책
- 요청 처리가 완전히 끝나는 시점에
ThreadLocal.remove()를 반드시 호출해 주어야 함 ThreadLocalLogTrace에서는releaseTraceId()내부에서level == 0인 경우 자동으로remove()를 호출하므로, 최상위 호출이 끝나는 시점에 정리가 이루어짐
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()); } }
- 요청 처리가 완전히 끝나는 시점에
연습 문제
-
로깅 추적 시 Trace ID를 파라미터로 전달하는 방식의 주요 문제점은 무엇일까요?
a. 여러 메소드의 시그니처를 변경해야 하는 것
- Trace ID를 동기화하기 위해 컨트롤러부터 리포지토리까지 모든 메소드에 파라미터를 추가해야 해서 메소드 시그니처가 계속 변경되는 문제점이 있음
-
필드(멤버 변수)를 사용하여 Trace ID를 동기화할 때 동시성 문제가 발생하는 근본적인 이유는 무엇일까요?
a. 여러 스레드가 동일한 객체의 필드를 동시에 변경하기 때문
FieldLogTrace는 싱글톤 객체의 필드에 Trace ID를 저장하는데 여러 스레드가 동시에 이 필드를 수정하려 할 때 데이터가 꼬이는 동시성 문제가 발생함
-
동시성 문제를 해결하기 위해 소개된 ThreadLocal의 주요 특징은 무엇일까요?
a. 각 스레드에게 독립적인 데이터 저장 공간을 제공
- ThreadLocal을 사용하면 동일한 ThreadLocal 객체에 접근하더라도 각 스레드는 자신만의 독립된 공간에 데이터를 저장하고 조회할 수 있어 동시성 문제를 해결함
-
WAS처럼 스레드 풀 환경에서 ThreadLocal 사용 시 반드시 지켜야 할 가장 중요한 주의사항은 무엇일까요?
a. 사용 후 해당 스레드의 값을 꼭
remove()해야 한다- 스레드 풀에서 스레드가 재사용될 때 이전 요청의 데이터가 남아 데이터 누수나 보안 문제가 발생할 수 있으므로 반드시
remove()를 호출하여 데이터를 제거해야 함
- 스레드 풀에서 스레드가 재사용될 때 이전 요청의 데이터가 남아 데이터 누수나 보안 문제가 발생할 수 있으므로 반드시
-
로깅 추적을 위해 Trace ID를 관리할 때, ThreadLocal 방식이 기존 파라미터 전달 방식보다 가지는 주요 장점은 무엇일까요?
a. 애플리케이션의 메소드 시그니처 변경 없이 추적 정보를 관리할 수 있다
- 파라미터 전달 방식은 모든 메소드에 Trace ID 인자를 추가해야 했지만 ThreadLocal 방식은 전용 저장소를 이용해 비즈니스 로직에 영향을 주지 않고 정보를 전달함
요약 정리
- 필드 동기화(
FieldLogTrace)는TraceId를 인스턴스 필드에 저장해 파라미터 전달 없이 로그를 동기화할 수 있지만, 싱글톤 빈 환경에서는 여러 쓰레드가 동시에 필드를 변경하면서 동시성 문제가 발생함 ThreadLocal은 쓰레드마다 독립된 전용 저장소를 제공하며,FieldLogTrace의 필드를ThreadLocal로 교체한ThreadLocalLogTrace를 적용하면 동시성 문제를 해결할 수 있음- 쓰레드 풀 환경에서는 쓰레드가 재사용되기 때문에, 요청 처리가 끝난 뒤 반드시
ThreadLocal.remove()를 호출해 데이터 누수와 보안 문제를 방지해야 함 - 로그 추적기는 V1(독립 TraceId) → V2(파라미터 전달) → V3/FieldLogTrace(필드 동기화) → V3/ThreadLocalLogTrace(ThreadLocal 동기화) 순서로 점진적으로 개선되었음