Spring

[Spring] 필드 동기화와 ThreadLocal

이덩우 2024. 3. 1. 18:07

1. 문제 상황

- 예제와 요구사항

본 예제는 일반적인 스프링 애플리케이션 상황을 기반으로 구성되며 아래와 같은 요구사항이 추가되어 개발 중인 상황이다.

  1. 모든 public 메서드의 호출과 응답 정보를 로그로 출력
    • 애플리케이션의 흐름을 변경하면 안된다.
      • 로그를 남긴다고 해서 비지니스 로직의 동작에 영향을 주면 안된다.
  2.  메서드 호출에 걸린 시간 출력
  3. 정상 흐름과 예외 흐름을 구분
    • 예외 발생 시 예외 정보가 남아야 한다.
  4. HTTP 요청을 구분 
    • 트랜잭션 ID를 생성해 하나의 HTTP 요청을 구분해야 한다. 

 

- 현재 상황

현재 위 요구사항을 만족하는 가장 Low한 버전의 로그 추적기가 완성된 상태이다. 

HTTP 요청의 트랜잭션 ID를 `Controller` 부터 `Repository`까지 동기화 시키고 호출의 depth를 동기화하기 위해 `LogTrace` 내부에 공유 필드를 가지고 있다. LogTrace의 구체 클래스를 스프링 컨테이너를 통해 싱글톤 빈으로 등록해 어떤 계층에서 호출하여도 동기화된 공유 필드를 요청하고 수정할 수 있도록 만들어져 있다.

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 tranceIdHolder; // 공유 필드

    @Override
    public TraceStatus begin(String message) {
        syncTraceId();
        TraceId traceId = tranceIdHolder;
        Long startTimeMs = System.currentTimeMillis();
        log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
        return new TraceStatus(traceId, startTimeMs, message);
    }
    .
    .
    .
}

 

 

- 문제 상황

예상되는 문제이지만, 현재 상태는 *동시성 이슈*가 발생한다. 싱글톤 빈으로 등록된 `LogTrace`를 모든 계층에서 접근해 *상태를 공유*하기 때문이다.

실제 Service에서는 Thread.sleep(1000)으로 1초 간 대기하는 흐름을 갖는다. 정상 흐름일 경우 동시 요청에서 뒤늦게 호출한 요청은 잘못된 호출 depth와 트랜잭션 ID를 갖게 된다.

따라서 로그를 확인해보면 기대하는 결과와 전혀 다른 로그를 확인하게 된다.

 

기대하는 결과

 

실제 결과

 


2. ThreadLocal

- 개념

이러한 필드 공유에서 동시성 이슈를 해결하는 대안으로 `ThreadLocal`이라는 개념이 존재한다.

쓰레드 호컬은 해당 쓰레드만이 접근할 수 있는 특별한 저장소를 의미한다. 쉽게 말해 물건 보관 창구를 떠올리면 된다. 

여러 사람이 같은 물건 보관 창구를 사용하더라도, 창구 직원은 사용자를 인식해 사용자 별로 물건을 확실하게 격리해 보관하게 된다. 

개념

 

- 주의사항

쓰레드 로컬을 사용한 뒤 값을 제거하지 않고 그냥 두면 WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우에 심각한 문제가 발생할 수 있다. 

아래 예시를 살펴보자.

유저A 저장 요청

 

요청 종료

 

쓰레드 A를 통해서 사용자 A가 특정 정보를 저장하고 쓰레드 로컬의 값을 지우지 않고 요청을 마무리 지었다.

이후에 사용자 B가 정보를 조회하는 요청을 보내게 되는데, 이 때 공교롭게도 쓰레드 A를 통해 요청을 보내게 되었다.

 

 

이 경우 기존 쓰레드 로컬에 존재하는 기존 사용자 A의 정보가 조회되게 된다..

 

*사실 가능하면 공유 필드를 두지 않는 것 자체가 최선의 선택이지만*, 쓰레드 로컬을 사용해 필드 동기화를 해야한다면 꼭 사용 후 리소스를 정리해주어야한다.


3. 적용(해결)

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 tranceIdHolder; // 동시성 이슈 발생
    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);
    }
    .
    .
    .
}

 

`LogTrace`인터페이스의 새로운 구체 클래스인 `ThreadLocalLogTrace`를 생성했다.

단순히 ThreadLocal을 통해 `TraceId`객체에 접근할 수 있도록 설정해주었다.

동시성 이슈 해결

 

동시성 이슈가 해결되었다.

 

 

 

출처 : 인프런, 김영한의 스프링 핵심 원리 - 고급편