동시성 문제와 쓰레드 로컬

바그다드·2023년 9월 1일
0

스프링을 공부하다보면 동시성 문제에 대해서 자주 접하게 된다.
동시성 문제는 하나의 인스턴스에 여러 쓰레드가 동시에 접근할 때 발생하는 문제이다.
스프링은 싱글톤으로 빈을 관리하기 때문에 이런 동시성 문제에 자주 마주하게 된다.

이번 포스팅에서는 동시성 문제에 대해서 알아보고, 동시성 문제를 해결하기 위한 기술에 대해 알아보자.

동시성 문제 - 예제

테스트 코드를 통해 동시성 문제에 대해 알아보자.

FieldService 생성

@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(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • String타입의 nameStore를 필드로 가지고 있다.
  • 여기 정의된 로직은 다음과 같다.
    파라미터로 넘어온 name과 필드에 들어있는 nameStore를 로그로 출력하고,
    name을 nameStore에 저장,
    1초 대기 후,
    nameStore의 값 출력

FieldServiceTest 생성

@Slf4j
public class FieldServiceTest {

    private FieldService fieldService = new FieldService();

    // 동시성 문제 발생 X
    @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();
        // 동시성 문제 발생 X
//        sleep(2000); 
		// 동시성 문제 발생 O
        sleep(100); 
        threadB.start();

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

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 쓰레드를 생성해 쓰레드에서 FieldService의 logic메서드를 실행하는 테스트코드이다.
  • userA와 userB 둘 다 하나의 FieldService를 사용하고 있다.

그럼 동시성이 발생하는 경우와 발생하지 않는 경우를 테스트로 확인해보자.

  • 동시성 문제가 발생하지 않는 경우(sleep으로 2초간 대기)

    userA를 실행하고 2초간 대기한 후에 userB를 실행하기 때문에 정상적으로 테스트가 동작한다.

  • 동시성이 발생하는 경우(sleep으로 0.1초간 대기)

    userA를 실행하고 로직이 완전히 종료되기 전에 userB가 실행되기 때문에 userA는 'nameStore=userA'라는 로그가 찍히길 기대했으나 'nameStore=userB'라는 로그가 찍히는 문제가 발생하였다.

이는 userA와 userB가 하나의 FieldService를 함께 사용하기 때문인데, FieldService를 확인해보면 nameStore라는 필드를 가지고 있다. 하나의 FieldService를 함께 사용하기 때문에 nameStore도 함께 사용하고, 이로 인해 userA라는 값이 userB라는 값으로 바뀐 것이다.

이런 동시성 문제는 지역변수에서는 발생하지 않는다. 그 이유는 지역변수가 스택이라는 메모리 공간에 저장되기 때문이다. 쓰레드는 프로세스 메모리 중에서, 코드, 데이터, 힙 영역을 공유하고, 스택 영역은 공유하지 않는다. 여기서 스택에 지역 변수가 저장되기 때문에 지역 변수에서는 동시성 문제가 발생하지 않는다.
반면 인스턴스나 필드, static필드는 힙 영역 즉, 공용 메모리에 저장되기 때문에 동시성 문제가 발생하게 된다.

ThreadLocal

이런 동시성 문제를 해결할 수 있는 것이 쓰레드 로컬이다. 쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장 공간이다. 그래서 처음에는 쓰레드 로컬이 스택 영역을 활용하는 건가?하는 생각을 하였는데, 그게 아니라 힙 영역을 사용하지만 마치 Map과 비슷한 구조처럼 데이터를 보관한다.
간단하게 쿠키에 저장된 세션id와 세션 저장소를 매칭하는 것과 비슷하다고 생각하면 될 것 같다.

그럼 코드로 확인해보자.

ThreadLocalService 생성

@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);
        log.info("조회 nameStore={}", nameStore.get());
        return nameStore.get();
    }

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

}
  • FieildService와 유사하지만 nameStore라는 필드를 ThreadLocal로 수정하였다.

ThreadLocal 사용법

  1. 값 저장 : ThreadLocal.set(값)
  2. 값 조회 : ThreadLocal.get()
  3. 값 제거 : ThreadLocal.remove()

ThreadLocalTest

@Slf4j
public class ThreadLocalServiceTest {

	// 이 부분만 수정
    private ThreadLocalService threadLocalService = new ThreadLocalService();

    // 동시성 문제 발생 X
    @Test
    void field() {
        log.info("main start");
        Runnable userA = () -> {
            threadLocalService.logic("userA");
        };
        Runnable userB = () -> {
            threadLocalService.logic("userB");
        };

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

        threadA.start();
//        sleep(2000); // 동시성 문제 발생 X
        sleep(100); // 동시성 문제 발생 x
        threadB.start();

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

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 다른 코드는 다 동일하고 FieldService를 사용하는 대신 ThreadLocalService를 사용하고 있다.

그럼 앞서 동시성 문제가 발생한 상황과 발생하지 않은 상황을 다시 확인해보자.

  • 2초 대기(기존에 동시성 문제 발생X)

  • 0.1초 대기(기존에는 동시성 문제 발생)

필드에서 쓰레드마다 별도의 저장공간을 사용하고 있어 동시성 문제가 해결된 것을 확인할 수 있다.

쓰레드 로컬 주의사항

쓰레드 로컬을 사용한 후에는 꼭!!! ThreadLocal.remove()를 이용해 쓰레드 로컬의 데이터를 지워줘야 한다.
먼저 아래의 그림을 보자.

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

  1. 사용자A에 HTTP응답 종료
  2. WAS는 쓰레드를 쓰레드풀에 반환
    • 쓰레드를 생성하는 비용이 비싸기 때문에 쓰레드를 제거하지 않고 쓰레드 풀을 이용해 재사용한다.
  3. 이때 반환된 쓰레드의 쓰레드로컬에는 사용자A의 데이터가 남아있음
  • 그렇다면 이때 반환된 쓰레드가 사용자B에게 할당된다면?
    사용자B가 사용자 정보를 조회하는 요청을 한다면, 쓰레드에 남아있는 사용자A의 정보를 사용자B가 조회하게 된다.

때문에 쓰레드로컬을 사용할 때는 꼭!!! 이 부분을 기억해두자!

이번 포스팅에서는 동시성 문제와 그 해결 방법에 대해서 알아보았다.
동시성 문제는 하나의 인스턴스에 둘 이상의 쓰레드가 접근하여 사용할 때 stateful한 필드가 존재할 때 발생하는 문제이다. 이를 해결하는 방법은 ThreadLocal을 이용해 쓰레드별로 별도의 저장 공간을 부여하는 것이었다.

profile
꾸준히 하자!

0개의 댓글