Spring Advanced

ThreadLocal을 이용해 동시성 문제 해결하기

cwchoiit 2023. 12. 12. 09:20
728x90
반응형
SMALL
728x90
SMALL

 

개발하다보면 빈번하게 겪는 문제인 '동시성 문제'가 있다. JAVA에서 동시성 문제가 발생하기 위해서는 다음과 같은 조건이 필요하다.

 

동시성 문제가 발생할 수 있는 환경

  • 지역 변수가 아닌 전역 변수 또는 클래스 멤버(변수)
  • 읽기 작업만 일어나는 게 아니라 쓰기 작업이 가해지는 변수

 

다음 코드로 동시성 문제에 대한 예시를 살펴보자.

 

FieldService.java

package com.example.advanced.trace.threadlocal.code;

import lombok.extern.slf4j.Slf4j;

@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 ms) {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

위 서비스 코드를 보면, nameStore라는 필드가 있고, logic() 메소드가 있다. 이 메소드에서 파라미터로 name을 받으면 그 name을 nameStore에 저장하는 간단한 코드이다. 저장한 후 1초 뒤에 저장된 값을 조회하는 로그가 있고 로직은 종료된다.

 

FieldServiceTest.java

package com.example.advanced.trace.threadlocal;

import com.example.advanced.trace.threadlocal.code.FieldService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

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

        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(2000);
        threadB.start();
        sleep(3000);

        log.info("main exit");
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

독립적인 Thread 두 개를 생성한다. 생성한 Thread가 실행하는 코드는 주입받은 FieldService의 logic() 메소드이다. 이 두 개의 쓰레드를 실행할 때 threadA를 실행한 후 2초 뒤 threadB를 실행한다. 

 

 

지금 위 코드로는 동시성 문제는 발생하지 않는다. 왜냐하면 FieldService.logic() 메소드는 nameStore에 저장한 뒤 1초뒤에 조회를 하는데 threadA와 threadB의 각 실행 사이의 간격이 2초이기 때문이다. 실행해보면 다음과 같은 결과를 볼 수 있다.

09:03:03.389 [Test worker] INFO com.example.advanced.trace.threadlocal.FieldServiceTest -- main start
09:03:03.395 [thread-A] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 저장 name=userA -> nameStore=null
09:03:04.402 [thread-A] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 조회 nameStore=userA
09:03:05.397 [thread-B] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 저장 name=userB -> nameStore=userA
09:03:06.403 [thread-B] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 조회 nameStore=userB
09:03:08.397 [Test worker] INFO com.example.advanced.trace.threadlocal.FieldServiceTest -- main exit

 

문제 없이 본인이 저장한 값으로 조회되는 것을 확인할 수 있다.

 

그러나, 여기서 threadA와 threadB 실행 사이의 간격을 0.1초로 변경한다면 동시성 문제가 발생한다. 

왜 그럴까? 이유는 필드에 값을 저장하고 조회하기까지 걸리는 시간은 1초인데 새로운 쓰레드가 동일한 필드에 접근하는 시간이 0.1초이기 때문이다. 

....
        threadA.start();
        sleep(100); // 동시성 문제 발생 원인 코드
        threadB.start();
        sleep(3000);
....

 

이렇게 코드를 변경하고 실행해보자. 예상 못했던 결과가 도출된다.

09:06:46.886 [Test worker] INFO com.example.advanced.trace.threadlocal.FieldServiceTest -- main start
09:06:46.891 [thread-A] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 저장 name=userA -> nameStore=null
09:06:46.992 [thread-B] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 저장 name=userB -> nameStore=userA
09:06:47.897 [thread-A] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 조회 nameStore=userB
09:06:47.993 [thread-B] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 조회 nameStore=userB
09:06:49.993 [Test worker] INFO com.example.advanced.trace.threadlocal.FieldServiceTest -- main exit

 

결과는 두 쓰레드 모두 조회값이 "userB"로 출력된다. 이유는 "userA"라는 값을 저장한 후 1초뒤 값을 조회하는 코드가 실행되는데 조회하기 전 다른 쓰레드에서 "userB"라는 값을 저장했기 때문이다. 이게 '동시성 문제'이다.

 

 

이런 동시성 문제를 해결하기 위해 어떠한 조치를 취할 수 있을까? 방법은 다양하겠지만 여기서 설명하고자 하는 건 ThreadLocal이다.

 

 

ThreadLocal

ThreadLocal은 각 쓰레드별 가지는 값의 고유성을 보장하는 방법이다. 즉, 찜질방에서 생김새와 모양이 완전히 똑같은 여러개의 락커가 있지만 그 락커마다의 주인이 딱 한명인 것처럼 말이다.

 

바로 코드로 가보자.

 

ThreadLocalService.java

package com.example.advanced.trace.threadlocal.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ThreadLocalService {

    private final 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();
    }

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

 

기존 FieldService에서 ThreadLocalService로 변경되면서 바뀌는 부분은 nameStore가 ThreadLocal 타입이라는 점이다.

그리고 이 ThreadLocal은 값을 세팅하고 조회하기 위해 .set(), .get()을 사용한다.

 

ThreadLocalServiceTest.java

package com.example.advanced.trace.threadlocal;

import com.example.advanced.trace.threadlocal.code.FieldService;
import com.example.advanced.trace.threadlocal.code.ThreadLocalService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

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

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");

        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(100);
        threadB.start();
        sleep(3000);

        log.info("main exit");
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

FieldServiceTest에서 ThreadLocalServiceTest로 변경했다. 바뀌는 부분은 역시 주입받는 서비스가 ThreadLocalService라는 점.

그리고 기존 코드에서 동시성 이슈를 발생했던 sleep(100)을 그대로 두고 실행해도 동시성 문제는 발생하지 않는다. 결과를 보자.

09:13:41.235 [Test worker] INFO com.example.advanced.trace.threadlocal.ThreadLocalServiceTest -- main start
09:13:41.240 [thread-A] INFO com.example.advanced.trace.threadlocal.code.ThreadLocalService -- 저장 name=userA -> nameStore=null
09:13:41.340 [thread-B] INFO com.example.advanced.trace.threadlocal.code.ThreadLocalService -- 저장 name=userB -> nameStore=null
09:13:42.244 [thread-A] INFO com.example.advanced.trace.threadlocal.code.ThreadLocalService -- 조회 nameStore=userA
09:13:42.345 [thread-B] INFO com.example.advanced.trace.threadlocal.code.ThreadLocalService -- 조회 nameStore=userB
09:13:44.344 [Test worker] INFO com.example.advanced.trace.threadlocal.ThreadLocalServiceTest -- main exit

 

서로 다른 쓰레드가 모두 독립적인 ThreadLocal 필드를 가지기 때문에 최초 조회 시 모두 값은 null이다. 그리고 1초 뒤 조회해도 본인이 저장한 값으로 조회된다.ThreadLocal을 이용해서 동시성 문제를 해결했다. 

 

 

ThreadLocal 사용 시 주의점

ThreadLocal을 사용하면 반드시 사용 후 .remove()로 사용했던 ThreadLocal 메모리를 날려줘야 한다.

그렇지 않으면 ThreadLocal 필드를 사용한 threadA는 종료됐기 때문에 쓰레드 풀에 threadA가 반납되지만 ThreadLocal 값은 그대로 살아있는 상태로 남고 다른 쓰레드(threadB)가 ThreadLocal 필드에 접근할 때 해당 ThreadLocal 필드에 접근할 가능성이 생긴다.

 

즉, 최악의 경우는 다른 쓰레드가 해당 필드에 접근할 수 있는 가능성이 생긴다는 것이고 그렇지 않더라도 메모리 누수 문제가 발생한다. 그러므로 사용을 마친 ThreadLocal은 반드시 remove()를 호출해서 완전히 제거해줘야한다.

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

Advisor, Advice, Pointcut  (0) 2023.12.15
스프링이 지원하는 ProxyFactory  (0) 2023.12.14
Proxy/Decorator Pattern 2 (동적 프록시)  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
Strategy Pattern  (2) 2023.12.12