관리 메뉴

나구리의 개발공부기록

쓰레드 로컬(ThreadLocal), 필드 동기화(개발/적용/동시성문제/예제코드), ThreadLocal(소개/예제코드), 쓰레드 로컬 동기화(개발/적용), 쓰레드 로컬 - 주의사항 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 핵심원리 - 고급편

쓰레드 로컬(ThreadLocal), 필드 동기화(개발/적용/동시성문제/예제코드), ThreadLocal(소개/예제코드), 쓰레드 로컬 동기화(개발/적용), 쓰레드 로컬 - 주의사항

소소한나구리 2024. 11. 5. 16:58

출처 : 인프런 - 스프링 핵심 원리 - 고급편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 필드 동기화 - 개발

1) 정식버전 개발

(1) 프로토타입 버전의 문제점

  • 앞서 개발한 프로토타입의 로그 추적기는 다음 로그를 출력할 때 트랜잭션ID와 level을 동기화를 하는 방법으로 해당 정보를 가지고 있는 TraceId를 파라미터로 넘기도록 구현하였음
  • 동기화는 성공했지만 로그를 출력하는 모든 메서드에 TraceId를 파라미터에 추가해야하는 문제가 있었는데 이를 해결하는 정식 버전을 개발

(2) LogTrace 인터페이스

  • 향후 다양한 구현체로 변경할 수 있도록 LogTrace 인터페이스를 생성
  • 로그 추적기를 위한 최소한의 기능을하는 메서드들을 선언
public interface LogTrace {
    TraceStatus begin(String message);
    void end(TraceStatus traceStatus);
    void exception(TraceStatus traceStatus, Exception e);
}

 

(3) FieldLogTrace

  • 기존의 HelloTraceV2와 대부분의 기능이 동일함
  • beginSync()메서드를 통해 파라미터를 사용하여 TraceId를 동기화했던 부분을 로그 추적기 자체의 변수(TraceId traceIdHolder)에서 보관하고 사용하도록 변경됨
  • 여기서 추가된 syncTraceId()와 releaseTraceId()가 중요함
  • syncTraceId()는 최초 호출일때는 TraceId를 새로 만들고 앞선 로그의 TraceId를 참고해서 동기화 하고 level을 증가시키며 결과를 traceIdHolder에 보관함
  • releaseTraceId()는 메서드 호출이 끝날 때 동작하도록 complete 메서드의 마지막에서 호출하여 동작하고, level이 0(최초호출이면)이면 traceIdHolder를 null로 초기화시켜 TraceId를 제거하고 그게 아니면 level을 1 감소함

** 참고

  • TraceId traceIdHolder 변수를 통해 traceId를 동기화하면 동시성 이슈가 발생하는데 뒤에서 해결함
package hello.advanced.trace.logtrace;

@Slf4j
public class FieldLogTrace implements LogTrace {
    
    // ... PREFIX 상수 동일

    private TraceId traceIdHolder;  // traceId 동기화, **동시성 이슈 발생함

    @Override
    public TraceStatus begin(String message) {
        // traceId를 바로 생성하지 않고 syncTraceId 메서드를 통해서 생성
        syncTraceId();
        TraceId traceId = traceIdHolder;    // syncTraceId 메서드를 통해 생성된 TraceId가 담긴 traceIdHolder에서 꺼내서 사용
        
        // ... 이하 로직 동일
    }

    // TraceId를 생성하거나 level을 1 증가시키는 메서드
    private void syncTraceId() {
        if (traceIdHolder == null) {
            traceIdHolder = new TraceId();
        } else {
            traceIdHolder = traceIdHolder.createNextId();
        }
    }

    // end(), exception() 동일

    private void complete(TraceStatus status, Exception e) {
        // 기존 로직 동일, releaseTraceId()호출 코드 추가
        releaseTraceId();
    }
    
    // TraceId의 level을 감소하거나 null로 초기화 시키는 메서드
    private void releaseTraceId() {
        if (traceIdHolder.isFirstLevel()) {
            traceIdHolder = null;   // 첫번째 레벨이면 traceIdHolder가 동작하지 않도록 null로 초기화
        } else {
            traceIdHolder = traceIdHolder.createPreviousId();
        }
    }

    // addSpace() 로직 동일
}

 

(3) 테스트

  • FieldLogTrace를 테스트해보면 트랜잭션ID도 동일하게 나오고 level을 통한 계층 표현도 잘동작하고 있음
  • 변경된 로그 추적기를 적용하면 TraceId를 파라미터로 전달하지 않아도 애플리케이션의 메서드 파라미터도 변경하지 않아도 됨
package hello.advanced.trace.logtrace;

class FieldLogTraceTest {

    FieldLogTrace trace = new FieldLogTrace();

    @Test
    void begin_end_level2() {
        TraceStatus status1 = trace.begin("hello1");
        TraceStatus status2 = trace.begin("hello2");
        TraceStatus status3 = trace.begin("hello3");

        trace.end(status3);
        trace.end(status2);
        trace.end(status1);
    }

    @Test
    void begin_exception_level2() {
        TraceStatus status1 = trace.begin("hello1");
        TraceStatus status2 = trace.begin("hello2");
        TraceStatus status3 = trace.begin("hello3");

        trace.exception(status3, new IllegalStateException());
        trace.exception(status2, new IllegalStateException());
        trace.exception(status1, new IllegalStateException());
    }
}

/* 실행결과
end() 실행결과
[7f980bfa] hello1
[7f980bfa] |-->hello2
[7f980bfa] |    |-->hello3
[7f980bfa] |    |<--hello3 time=1ms
[7f980bfa] |<--hello2 time=1ms
[7f980bfa] hello1 time=4ms

exception() 실행결과
[fe4f0c39] hello1
[fe4f0c39] |-->hello2
[fe4f0c39] |    |-->hello3
[fe4f0c39] |    |<X-hello3 time=0ms ex=java.lang.IllegalStateException
[fe4f0c39] |<X-hello2 time=0ms ex=java.lang.IllegalStateException
[fe4f0c39] hello1 time=0ms ex=java.lang.IllegalStateException
*/

2. 필드 동기화 - 적용

1) FieldLogTrace를 애플리케이션에 적용

(1) 스프링 빈 등록

  • FieldLogTrace를 수동으로 스프링 빈으로 등록
  • 수동으로 등록하면 향후 구현체를 편리하게 변경할 수 있는 장점이 있음
  • @Configuration으로 컴포넌트스캔이 대상이되며 @Bean으로 FieldLogTrace를 싱글톤으로 스프링 빈에 등록
@Configuration
public class LogTraceConfig {

    @Bean
    public LogTrace logTrace() {
        return new FieldLogTrace();
    }
}

 

(2) v2 -> v3 복사

  • 코드 내부의 의존관계 클래스 변경
  • 매핑정보를 v3/request로 변경
  • HelloTraceV2를 LogTrace 인터페이스를 사용하도록 변경
  • TraceId traceId 파라미터를 모두 제거하고 beginSync()메서드를 begin()로 모두 변경

(3) OrderControllerV3 적용

package hello.advanced.app.v3;

@RestController
@RequiredArgsConstructor
public class OrderControllerV3 {

    private final LogTrace trace;	// LogTrace 인터페이스로 변경

    @GetMapping("/v3/request")		// 매핑 변경
    public String request(String itemId) {
            // getTraceId() 제거
            orderService.orderItem(itemId);
     
     // .. 나머지 코드 동일
}

 

(4) OrderServiceV3 적용

package hello.advanced.app.v3;

@Service
@RequiredArgsConstructor
public class OrderServiceV3 {

    private final LogTrace trace;	// LogTrace 적용

    public void orderItem(String itemId) {  // traceId 파라미터 제거
            // begin()으로 변경
            status = trace.begin("OrderService.orderItem()");
            orderRepository.save(itemId);	// getTraceId() 제거
            
    // ... 기존코드 동일
}

 

(5) OrderRepositoryV3 적용

package hello.advanced.app.v3;

@Repository
@RequiredArgsConstructor
public class OrderRepositoryV3 {

    private final LogTrace trace; // LogTrace 인터페이스 적용

    public void save(String itemId) { // traceId 파라미터 제거
            // begin()으로 변경
            status = trace.begin("OrderRepository.save()");
    
    // ... 기존코드 동일
}

 

(6) 실행 결과

  • 매핑된 url로 요청 파라미터를 전송하면 정상적으로 로그들이 남겨진 것을 확인할 수 있음


3. 필드 동기화 - 동시성 문제

1) 동시성 문제 발생

(1) 동시성 문제 확인

  • 위에서 만든 로그 추적기는 사실 심각한 동시성 문제를 가지고 있음
  • 동시성 문제를 확인하려면 동시에 여러번 호출해보면 알 수 있음

(2-1) 기대하는 결과

  • nio-8080-exec-숫자는 tomcat이 제공해주는 Thread Pool에 있는 쓰레드임
  • 두번 연속으로 요청을 했을 때 기대하는 결과는 각각의 쓰레드에 따라서 트랜잭션ID가 부여되고 계층구조도 각 트랜잭션ID에 따라 표현되도록 동작해야함
[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

 

(2-2) 기대하는 결과 - 로그 분리해서 확인

  • 각 쓰레드 즉, 트랜잭션ID별로 분리해서 로그를 묶어서 확인해보면 아래처럼 로그가 구성되어야 정상적으로 동작한 결과임
  • 로그가 섞여서 출력되더라도 트랜잭션ID나 쓰레드로 구분해서 분류해보면 깔끔하게 분리되어있어야 함
[52808e46]
[nio-8080-exec-3] [52808e46] OrderController.request()
[nio-8080-exec-3] [52808e46] |-->OrderService.orderItem()
[nio-8080-exec-3] [52808e46] |   |-->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

[4568423c]
[nio-8080-exec-4] [4568423c] OrderController.request()
[nio-8080-exec-4] [4568423c] |-->OrderService.orderItem()
[nio-8080-exec-4] [4568423c] |   |-->OrderRepository.save()
[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

 

(3-1) 실제결과

  • 실제 실행된 결과를 보면 쓰레드는 각각 별도의 쓰레드로 실행 되었는데 로그의 출력결과가 이상함
  • 동일한 트랜잭션ID로 출력되었으며 계층구조도 마치 하나의 HTTP 요청인듯 아닌듯 이상하게 표현되었음

 

(3-2) 실제결과 - 로그 분리해서 확인

  • 트랜잭션ID가 같으니 쓰레드별로 구분해서 로그를 확인해보면 9번쓰레드의 결과는 정상적인 것 같은데 1번 쓰레드의 로그를 보면 계층표현이 기대하는 결과와 완전히 상이함
  • 분명히 테스트 코드나 하나씩 실행했을때는 문제가 없었는데 결과를 보면 문제가 발생되었음

 

(4) 동시성 문제가 발생함

  • FieldLogTrace는 이 객체의 인스턴스는 애플리케이션 딱 1개 존재하는 싱글톤으로 등록된 스프링 빈인데, FieldLogTrace.traceIdHolder 필드를 여러 쓰레드가 동시에 접근하기 때문에 이런 동시성 문제가 발생함
  • 실무에서 한번 나타나면 개발자를 가장 괴롭히는 문제도 이런 동시성 문제이며 난이도 있는 면접에서 이런 질문이 나오기도 하기때문에 필수로 학습을 해야함

4. 동시성 문제 - 예제 코드

  • 동시성 문제가 어떻게 발생하는지 단순화해서 알아보기

1) 테스트 세팅

(1) build.gradle

  • 테스트에서 롬복 사용을 위해 build.gradle 설정 추가
  • 이렇게 해야 테스트 코드에서 @Slf4j 같은 애노테이션이 동작함
dependencies {
    ...
    //테스트에서 lombok 사용
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
}

 

(2) FieldService 추가

  • src/test의 ~.threadlocal.code 경로에 따라 패키지를 생성하여 클래스를 작성
  • 파라미터로 넘어온 name을 필드인 nameStore에 저장하고 1초간 쉰다음 필드에 저장된 nameStore를 반환하는 매우 단순한 로직으로 구성
@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();
        }
    }
}

2) 테스트 코드

(1) 동시성 문제가 발생하지 않는 테스트코드

  • Thread를 threadA, threadB 두개를 생성하고 start로 각 스레드를 동작시킬 때 두 쓰레드 사이에 sleep(2000)으로 2초간 대기시간을 두어 threadA가 끝나고 threadB가 실행되도록 테스트 코드를 작성
  • fieldService.login() 메서드의 내부에 sleep(1000) 메서드로 1초를 지연시키는 코드가 있어서 1초 이후에 다음 스레드를 호출하면 순서대로 시작할 수 있음
  • 즉, 해당 코드는 각 쓰레드의 동작 사이에 sleep(2000)메서드가 존재하여 모든 쓰레드가 동시성 문제없이 정상 동작함

** 쓰레드 관련 - 자바 기본

  • 원래는 threadA와 threadB는 각각의 일반쓰레드(데몬쓰레드가 아닌 쓰레드)이므로 원래의 애플리케이션 환경이였다면 main 쓰레드가 종료되어도 각각의 일반쓰레드가 모두 종료될 때까지 애플리케이션이 유지 됨
  • 그러나 지금처럼 테스트인 JUnit에서는 메인 스레드가 종료되면 테스트가 조기에 완료된 것으로 처리되기 때문에 마지막 쓰레드의 작업이 모두 끝날 때까지 sleep()으로 main쓰레드가 종료되지 않도록 대기시간을 주어야 모든 쓰레드가 동작할 수 있음
  • CountDownLatch()를 사용하면 더 효율적이지만 단순한 예제에 적용하면 오히려 코드가 더 복잡해질 수 있어서 지금은 sleep을 적용
package hello.advanced.trace.threadlocal;

@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("threadA");

        Thread threadB = new Thread(userB);
        threadB.setName("threadB");

        threadA.start();
        sleep(2000);    // 동시성 문제가 발생하지 않도록 2초를 쉼
        threadB.start();

        sleep(3000);    // 메인 쓰레드 종료 대기
        log.info("main exit");

    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

(2) 실행 결과

  • 각각의 쓰레드가 저장하고 조회하여 동시성 문제가 없이 정상적으로 수행 된 것을 알 수 있음
  • 로그를 보면 순차적으로 threadA가 동작할 때 파라미터로 넘어온 userA를 nameStore에 저장한뒤 userA가 조회되고 threadB가 동작할 때 파라미터로 넘어온 userB를 userA가 저장되어있는 nameStore에 저장하고 조회함으로써 userB가 반환되고 있음

좌) threadA 동작 표현 / 우) threadB 동작 표현

 

(3) 강제로 동시성 문제를 발생

  • threadA와 threadB사이에 있는 sleep(2000)메서드를 sleep(100)로 변경
  • 각 쓰레드가 동작하는 logic()메서드의 내부에 1초 지연하는 코드가 있어 threadA의 작업이 끝나기 전에 threadB가 실행되어 동시성 문제가 나타남
        threadA.start();
//        sleep(2000);    // 동시성 문제가 발생하지 않도록 2초를 쉼
        sleep(100);    // 동시성 문제 발생
        threadB.start();

 

(4) 동시성 문제가 발생한 실행 결과

  • 결과를 보면 저장하는 부분은 문제가 없어보이는데 조회하는 부분이 threadA, threadB모두 userB를 조회하고있고 동작 순서도 저장 -> 조회, 저장 -> 조회가 아닌 모두 저장 후 조회가 되고있는것을 확인할 수 있음
  • 즉, threadA가 먼저 동작하여 nameStore에 userA를 저장했는데 threadA가 조회하기 전에(1초 후 조회) threadB가 동작(0.1초 만에 동작)하면서 nameStore의 값을 userB로 바꿔버림
  • 그래서 threadA가 조회하는 동작하는 시점에 threadB가 바꿔버린 nameStore의 값 userB를 조회하게 되는데 바로 동시성문제가 발생된 것임

좌) threadA가 동작하는 도중 threadB가 동작 / 우) threadB가 동작한 결과를 threadA가 조회

(5) 동시성 문제

  • ThreadA입장에서는 저장한 데이터와 조회한 데이터가 다른 문제가 발생했는데, 이처럼 여러 쓰레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제를 동시성 문제라고 함
  • 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 발생하기 때문에 트래픽이 적은 상황에서는 확률상 잘 나타나지 않는데 트래픽이 점점 많아질 수록 자주 발생하게되며 특히 스프링 빈처럼 싱글톤 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야함

** 참고

  • 이런 동시성 문제는 쓰레드마다 각각 다른 메모리 영역이 할당되는 지역변수에서는 발생하지 않음
  • 동시성 문제가 발생하는 곳은 같은 인스턴스의 필드(주로 싱글톤에서 자주 발생)나 static 같은 공용 필드에 접근할 때 발생함
  • 또한 값을 읽기(조회)만하는 경우에는 발생하지않고 동시에 접근한 어디에선가 값을 변경할 때 발생함

5. ThreadLocal - 소개

1) ThreadLocal

(1) 소개

  • 쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 뜻하는데 쉽게 이야기하면 물건 보관 창구와 비슷한 느낌임
  • 여러 사람이 같은 물건 보관 창구를 이용하더라도 창구 직원은 사용자를 인식해서 사용자별로 확실하게 물건을 구분해주는 것과 같이 각 쓰레드는 쓰레드로컬이라는 창구 직원을 통해 값을 보관하고 꺼내지만, 창구 직원인 쓰레드로컬이 사용자에 따라 보관한 물건을 구분해주는 것임

(2) 일반적인 변수 필드

  • 여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있음
  • 아래의 이미지 처럼 일반적인 변수 필드는 thread-A가  userA를 저장후 thread-B가 userB를 다시 저장하면 thread-A가 저장한 userA의 값은 사라짐

일반 변수 필드의 값을 동시에 저장할 때

 

(3) 쓰레드 로컬

  • 그러나 쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공하기에 같은 인스턴스의 쓰레드 로컬 필드에 접근을해도 문제가 없음
  • 아래의 이미지처럼 동일 쓰레드 로컬에 thread-A와 thread-B가 접근하여 값을 저장을 해도 쓰레드 로컬은 각각의 전용 보관소에 따로 안전하게 값을 보관해두고 thread-A와 thread-B가 조회를 각 쓰레드의 전용 보관소에 보관해둔 값을 각 쓰레드에 맞게 반환해줌
  • 쓰레드로컬은 완전히 동시에 여러쓰레드가 접근을하여 값을 변경해도 값이 안전하게 보장되도록 동작함
  • 자바는 언어차원에서 쓰레드 로컬을 지원하기 위해 java.lang.ThreadLocal 클래스를 제공함

같은 인스턴스의 쓰레드 로컬 필드에 저장하면 값이 안전함


6. ThreadLocal - 예제 코드

1) 테스트코드에 ThreadLocal 적용

(1) ThreadLocalService

  • 기존 코드와 거의 동일하며 일반 필드였던 nameStore를 new ThreadLocal<>() 객체를 생성한 참조값을 참조하도록 변경
  • ThreadLocal은 객체이기 때문에 값을 저장하고 조회할때 .set()과 .get()메서드를 통해 저장 및 조회를 할 수 있음
  • 값을 제거할 때는 .remove() 메서드를 통해서 할 수 있음

** 주의

  • 해당 쓰레드가 쓰레드 로컬은 모두 사용하고 나면 .remove()메서드를 호출해서 쓰레드 로컬에 저장된 값을 꼭 제거해주어야 함
  • 뒤에서 보충 설명함
@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);    // .set으로 쓰레드 로컬에 값을 저장
        sleep(1000);

        log.info("조회 nameStore={}", nameStore.get());
        return nameStore.get(); // .get으로 쓰레드 로컬의 값을 조회
    }

    // 기존코드 동일
}

 

(2) 테스트 및 결과

  • 테스트 로직은 똑같고 생성하는 Service객체를 ThreadLocalService()로 변경하고 각 쓰레드가 수행할 동작을 service의 메서드로 변경
  • 적용하고 테스트를 해보면 threaA와 threadB사이에 존재하던 sleep() 메서드의 인수를 100으로하든, sleep()메서드를 제거를 하든 각 쓰레드가 저장했던 값이 정상적으로 저장되고 조회됨
  • 즉, 동시성 문제가 해결되었음
@Slf4j
public class ThreadLocalServiceTest {

    private ThreadLocalService service = new ThreadLocalService();

    @Test
    void field() {
        log.info("main start");
        Runnable userA = () -> {
            service.logic("userA");
        };
        Runnable userB = () -> {
            service.logic("userB");
        };
        
        // 이하 테스트 로직은 동일
}


7. 쓰레드 로컬 동기화 - 개발

1) 로그추적기에 쓰레드 로컬 적용

(1) ThreadLocalLogTrace생성

  • 대부분의 로직은 기존 FieldLogTrace와 동일
  • FieldLogTrace의 traceIdHolder 필드를 ThreadLocal로 변경하여 동시성 문제를 해결
  • traceIdHolder가 쓰레드 로컬 객체로 변경됨에 따라 begin(), syncTraceId(), releaseTraceId() 메서드에서 traceIdHolder를 저장, 조회, 삭제하는 코드를 get(), set(), remove() 메서드를 활용하도록 수정
  • 마지막 로그를 출력하고 나면 remove()를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 메모리 누수가 발생하지 않음
package hello.advanced.trace.logtrace;

@Slf4j
public class ThreadLocalLogTrace implements LogTrace {

    // PREFIX 변수 동일

    // 쓰레드 로컬로 변경
    private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();
	
    @Override
    public TraceStatus begin(String message) {
        syncTraceId();
        TraceId traceId = traceIdHolder.get();  // 쓰레드 로컬에서 traceId를 꺼내기 
        
        // 기존 코드 동일
    }

    private void syncTraceId() {
        TraceId traceId = traceIdHolder.get();      // traceIdHolder.get()으로 traceId를 꺼내기
        if (traceId == null) {
            traceIdHolder.set(new TraceId());       // set으로 traceId 생성
        } else {
            traceIdHolder.set(traceId.createNextId());
        }
    }

    // 기존 코드 동일
    
    private void releaseTraceId() {
        TraceId traceId = traceIdHolder.get();
        if (traceId.isFirstLevel()) {
            traceIdHolder.remove();     // .remove로 쓰레드로컬을 제거(각 쓰레드의 전용 보관소를 제거)
        } else {
            traceIdHolder.set(traceId.createPreviousId());
        }
    }
    // 기존 코드 동일
}

 

(2) ThreadLocalLogTrace테스트

  • 기존의 FieldLogTraceTest와 동일한 테스트케이스로 ThreadLocalLogTrace를 생성하여 테스트를 해보면 정상적으로 로그가 수행됨
  • 멀티쓰레드 상황은 아니지만 코드가 정상적으로 수행되는 것을 확인할 수 있음


8. 쓰레드 로컬 동기화 - 적용

1) 애플리케이션 적용

(1) LogTraceConfig수정

  • 스프링 빈으로 등록했던 LogTrace의 구현체만 ThreadLocalLogTrace()로 변경하면 애플리케이션에 적용됨
  • 모든 계층에서 구현체를 직접 의존하는것이 아니라 인터페이스를 의존하고있기 때문에 클라이언트의 코드 수정이 없이 한줄의 수정만으로 OCP를 지키며 의존관계주입을 통해 애플리케이션에 변경된 로그 추적기를 적용할 수 있음
@Configuration
public class LogTraceConfig {

    @Bean
    public LogTrace logTrace() {
        return new ThreadLocalLogTrace();	// 새로운 로그 추적기 적용
    }
}

 

(2) 애플리케이션 실행

  • 테스트를 해보면 기존에 동시성 문제가 해결되어 정상적으로 로그가 출력됨
  • 연속적으로 빠르게 동일 요청을 보내봐도 문제가 없이 각 쓰레드, 트랜잭션ID별로 계층을 표시하며 로그가 출력되는 것을 확인할 수 있음
  • 트랜젝션ID별로 로그를 구분하는 것은 생략(어차피 정상 동작 되었기에..)


9. 쓰레드 로컬 - 주의사항

1) 문제의 상황

  • 쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS처럼 쓰레드 풀을 사용하는 경우에 심각한 문제가 발생할 수 있음

(1) 사용자 A가 저장을 요청 후 종료

  • 사용자 A가 저장 HTTP를 요청하면 WAS는 쓰레드 풀에서 쓰레드를 하나 조회하고 할당된 쓰레드로 사용자 A의 데이터를 쓰레드 로컬에 저장
  • 쓰레드 로컬에 할당된 쓰레드의 전용 보관소에 사용자 A의 데이터를 보관 후 사용자 A의 HTTP응답이 종료됨
  • WAS는 사용이 끝난 쓰레드를 쓰레드 풀에 반환함(쓰레드를 생성하는 비용은 비싸기 때문에 쓰레드를 제거하지 않고 보통 쓰레드 풀을 통해서 쓰레드를 재사용함)
  • 즉, 사용자 A가 저장할 때 사용한 쓰레드가 살아있으므로 쓰레드 로컬에 해당 쓰레드의 전용보관소도 사용자A의 데이터와 함께 살아있음

(2) 사용자 B가 조회를 요청 - 문제 발생

  • 사용자 B가 조회를 위한 새로운 HTTP를 요청하였고 WAS는 쓰레드 풀에서 쓰레드를 할당하는데 하필 사용자 A가 저장할때 사용하였던 쓰레드가 할당됨
  • 조회 요청이기에 할당된 쓰레드는 쓰레드 로컬에서 데이터를 조회하고 쓰레드 로컬은 전용 쓰레드에 보관되어있던 사용자 A의 정보를 반환
  • 결과적으로 사용자 B가 사용자 A의 정보를 조회하게되는 심각한 문제가 발생함

(3) 문제 예방

  • 이와같은 문제를 예방하기 위해서 사용자 A의 요청이 끝났을 때 쓰레드 로컬의 값을 ThreadLocal.remove()로 꼭 제거해야 함
  • 이부분은 쓰레드 로컬을 사용할 때 매우 중요한 부분이므로 꼭 기억해야함