[Spring] 동시성 문제와 해결 (쓰레드 로컬)

동시성이란?

여러 쓰레드 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제를 동시성 문제라고 합니다.

이런 동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 많을 때 자주 발생하게 됩니다.

특히 자바의 스프링은 기본적으로 스프링 빈을 싱글톤으로 등록하기 때문에 싱글톤 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야 합니다.

( 동시성 문제는 값을 읽기만 하면 발생하지 않고, 값을 변경하기 때문에 발생 )

 

동시성 문제는 쓰레드마다 각각 다른 메모리 영역이 할당 지역 변수에서는 발생하지 않고,

동시성 문제가 주로 발생하는 곳같은 인스턴스의 필드(주로 싱글톤에서 자주 발생) 접근, 또는 static 같은 공용 필드에 접근할 때 발생합니다.


동시성 문제 예시

StoreService 객체에 있는 nameStore 필드에 두 쓰레드가 동시에 접근했을 때 어떤 문제가 발생하는 지 아래 예시 코드를 통해 확인 해봅시다.

 

StoreService
@Slf4j
public class StoreService {
    private String nameStore;

    public String saveAndFind(String name) {
        log.info("[저장] | 현재 저장된 값 nameStore = {}, 저장할 값 : name = {} ",nameStore, name);
        nameStore = name;
        sleep(1000);
        log.info("[조회] | 현재 저장된 값  = {}", nameStore);
        return nameStore;
    }

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

 

 

StoreServiceTest - 쓰레드 동시 접근 테스트
@Slf4j
public class StoreServiceTest {
    private StoreService storeService = new StoreService();

    @Test
    void field() {
        log.info("main start");
        Runnable userA = () -> {
            storeService.saveAndFind("userA");
        };

        Runnable userB = () -> {
            storeService.saveAndFind("userB");
        };

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

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

        threadA.start(); // Runnable 로직 실행
        sleep(100); // 동시성 문제 발생 O
        threadB.start(); // Runnable 로직 실행
        
        sleep(3000); // 메인 쓰레드 종료 대기
        log.info("main exit");
    }

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

 

StoreService의 saveAndFind() 메서드는 값을 저장할 때 1초가 걸리고 그 다음 조회합니다.

그런데 여기서 threadA가 saveAndFind() 로직을 실행하고 있는데 threadA의 작업이 끝나려면 1초가 걸리는데

threadB가 threadA의 작업이 끝나기도 전인 0.1초에 개입을 한 상황입니다.

 

원래라면 threadA의 작업이 끝나는 시점인 1초 후에 threadB가 작업을 시작하면 동시성 문제가 발생하지 않아

우리가 원하던 값 threadA는 userA를 저장하고 조회, threadB는 userB를 저장하고 조회 하겠지만

 

지금 상황에선 동시성 문제가 발생하여 원치않는 threadA는 userA를 저장했지만 조회했는데 userB라는 값을 조회하게 되는 다음과 같은 결과를 얻게 됩니다.


동시성 문제 해결 (쓰레드 로컬)

쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 말합니다.

 

창고 보관 서비스를 예시로 들면, 여러 사람이 같은 물건 보관 창구를 사용하더라도 창구 직원은 사용자를 인식해서 사용자별로 확실하게 물건을 구분해줍니다.

 

아래 그림에서 김모씨와 박모씨는 각각 창고 직원을 통해서 자신의 전용 보관소에 물건을 보관하고, 꺼내어 안전하게 보관하고 정확하게 짐을 찾을 수 있습니다.

예시 그림
  • 김모씨가 창고 직원을 통해 "김모씨 전용 보관소"에 짐을 보관

 

  • 박모씨가 창고 직원을 통해 "박모씨 전용 보관소"에  짐을 보관

  • 창고 직원은 각각의 전용 보관소에서 사용자에게 맞는 짐을 수령

 

쓰레드 로컬을 사용하지 않을 때, 여러 쓰레드가 같은 인스턴스의 필드에 접근하면 동시성 문제로 처음 쓰레드가 보관한 데이터가 사라질 수 있지만

 

쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공하여, 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제 없습니다.

 

자바에서는 다음과 같이 쓰레드 로컬을 지원합니다.

java.lang.ThreadLocal

ThreadLocal을 사용한 동시성 문제 해결

이제 쓰레드로컬을 사용하여 동시성 문제를 해결해봅시다.

동시성 문제가 발생하는 nameStore 필드의 타입을 ThreadLocal<>으로만 변경해 주면 됩니다.

 

ThreadLocalService
@Slf4j
public class ThreadLocalService {
    private ThreadLocal<String> nameStore = new ThreadLocal<>();

    public String saveAndFind(String name) {
        log.info("[저장] | 현재 저장된 값 nameStore = {}, 저장할 값 : name = {} ", nameStore.get(), name);
        nameStore.set(name);
        sleep(1000);
        log.info("[조회] | 현재 저장된 값 nameStore = {}", nameStore.get());
        return nameStore.get();
    }

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

 

ThreadLocalServiceTest
@Slf4j
public class ThreadLocalServiceTest {
    private ThreadLocalService service = new ThreadLocalService();

    @Test
    void field() {
        log.info("main start");
        Runnable userA = () -> {
            service.saveAndFind("userA");
        };

        Runnable userB = () -> {
            service.saveAndFind("userB");
        };

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

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

        threadA.start(); // Runnable 로직 실행
        sleep(100); // 동시성 문제 발생 지점

        threadB.start(); // Runnable 로직 실행
        sleep(3000); // 메인 쓰레드 종료 대기
        log.info("main exit");

    }

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

 

결과를 보면 동시성 문제가 발생하는 필드를 쓰레드 로컬을 사용하도록 바꾸어,

우리가 원하던대로 threadA는 userA를 저장하고 조회, threadB는 userB를 저장하고 조회 하는 것을 볼 수 있습니다.

 

- 값 저장: ThreadLocal.set()
- 값 조회: ThreadLocal.get()
- 값 제거: ThreadLocal.remove()

 

주의할점

위에서는 테스트 코드이기 때문에 하지는 않았지만,

해당 쓰레드가 쓰레드 로컬을 모두 사용하고 나면 ThreadLocal.remove() 를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 합니다.

 

WAS는 사용이 끝난 쓰레드를 쓰레드 풀에 반납하고 다음 요청에 쓰레드를 다시 쓰레드 풀에서 쓰레드를 꺼내 재사용하는데,

사용했던 쓰레드에 데이터가 남아 있는 채로 쓰레드 풀에 반납되었기 때문에 그 쓰레드를 다시 쓰레드 풀에서 꺼내 사용하는 사용자는 의도와 다르게 이전에 남아 있던 쓰레드의 데이터를 조회하게 됩니다.

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