[SpringBoot 핵심 원리] 쓰레드 로컬 - ThreadLocal (2)

윤경·2021년 12월 20일
1

Spring Boot

목록 보기
61/79
post-thumbnail

[5] ThreadLocal - 소개

ThreadLocal

: 해당 쓰레드만 접근할 수 있는 특별한 저장소 ( = 물건 보관 창구)

물건 보관 창구와 비슷하다고 생각하면 된다.
물건 보관 창구는 여러 사람이 같은 창구를 사용하더라도 창구 직원(ThreadLocal)이 사용자를 구별해 사용자별로 확실히 물건을 구분해준다.

사용자A, 사용자B 모두 창구 직원을 통해 물건을 보관하고 꺼내지만, 창구 직원이 사용자에 따라 보관한 물건을 구분해준다.

일반적인 변수 필드

여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있다.

➡️ Thread-A가 저장한 userA 값이 사라져버렸다.

쓰레드 로컬

반면, ThreadLocal을 사용하면 각 쓰레드마다 별도의 내부 저장소가 제공되어 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제가 발생하지 않는다.

➡️ thread-AuserA라는 값을 저장하면 별도의 전용 보관소에 데이터를 안전하게 저장하고, thread-B도 마찬가지로 안전하게 별도의 전용 보관소에 저장된다.

그러므로 데이터를 조회할 때도 전용 보관소에서 값을 반환해주기 때문에 아무런 문제가 발생하지 않는다.

자바는 언어 차원에서 쓰레드 로컬을 지원하기 위한 java.lang.ThreadLocal 클래스를 제공한다.


[6] 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와 동일하다.

동시성 문제 발생X 결과

동시성 문제 발생 결과

쓰레드 로컬로 쓰레드마다 각각 별도의 데이터 저장소를 가지게 되어 동시성 문제가 해결되었다.


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

: 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();

...

➡️ 이 부분만 바꾸어주면 이전 테스트와 동일하다.


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

적용 과정은 정말 쉽다.

✔️ LogTraceConfig.java

@Configuration
public class LogTraceConfig {

    @Bean
    public LogTrace logTrace() {    // 인스턴스가 딱 하나만 등록됨
        return new ThreadLocalLogTrace();
    }
}

➡️ ThreadLocalLogTrace()로만 바꾸어주면 된다. (ThreadLocalLogTrace()을 스프링 빈으로 등록함)

이전과 마찬가지로 1초 안에 두번 호출하게 되면 아래와 같은 결과를 얻을 수 있다. 전과는 다르게 트랜잭션ID가 구분되는 원하는 결과를 얻을 수 있다.


[9] 쓰레드 로컬 - ⚠️ 주의사항

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

동작

사용자A 저장 요청

  1. 사용자A가 저장 HTTP 요청
  2. WAS는 쓰레드 풀에서 쓰레드 하나 조회
  3. 쓰레드 thread-A 할당
  4. thread-A는 사용자A의 데이터를 쓰레드 로컬에 저장
  5. 쓰레드 로컬의 thread-A 전용 보관소에 사용자A 데이터를 보관

사용자A 저장 요청 종료

  1. 사용자A의 HTTP 응답 끝남
  2. WAS는 사용이 끝난 thread-A쓰레드 풀에 반환.
    (쓰레드를 생성하는 비용은 비싸기 때문에 제거하지 않고 반환하고 다시 쓰레드 풀을 통해 재사용함)
  3. thread-A는 쓰레드 풀에 아직 살아있기 때문에 쓰레드 로컬의 thread-A 전용 보관소에 사용자A의 데이터도 함께 살아있다. (thread-A가 관리하는 보관소이기 때문에 아직 짤리지 않았기 때문에 사물함이 남아있음(?))

사용자B 조회 요청

  1. 사용자B가 조회를 위한 새로운 HTTP 요청
  2. WAS는 쓰레드 풀에서 쓰레드 하나 조회
  3. (하필!!) 쓰레드 thread-A가 할당 (쓰레드 풀에서는 뭐가 나올지 몰라서 물론 다른 쓰레드가 할당될 수도 있지만 하필 A가 할당되었다고 할 때)
  4. 이번에는 조회하는 요청. thread-A는 쓰레드 로컬에서 데이터를 조회
  5. 쓰레드 로컬은 thread-A 전용 보관소에 있는 사용자A 값을 반환
  6. 결과적으로 사용자A의 값이 반환
  7. 사용자B는 사용자A의 정보를 조회하게 됨

결과적으로 B가 A의 데이터를 확인하게 되는 심각한 문제가 발생한다.

⭐️ 이런 문제를 예방하기 위해서는 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove()를 통해 꼭 제거해주어야 한다.


profile
개발 바보 이사 중

1개의 댓글

comment-user-thumbnail
2023년 12월 26일

thanks

답글 달기