개발놀이터

동시성문제와 스레드 로컬 본문

Spring/Spring

동시성문제와 스레드 로컬

마늘냄새폴폴 2022. 1. 10. 21:01

이 포스팅은 인프런 김영한 님의 스프링 핵심 원리 고급 편을 보고 각색한 포스팅입니다. 자세한 내용은 강의를 확인해주세요

 

 

이번 시간에는 스프링을 사용할 때 주의할 점과 해결방법인 스레드 로컬에 대해 알아보겠다.

 

스프링을 사용할 때 우리는 컴포넌트스캔을 이용하고 스프링 빈을 자동 등록하든 수동 등록하든 자연스럽게 사용하고 있다. 스프링의 장점중 하나는 이 스프링 빈이 싱글톤 패턴으로 만들어지기 때문에 메모리를 효율적으로 사용할 수 있다는 점이다. 하지만 이러한 싱글톤 패턴은 조심해서 사용해야 한다. 

 

바로 동시성 문제 때문이다. 

 

동시성 문제에 대한 가벼운 예시를 들어 설명할 예정이다. 

 

FieldService.java

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

 

nameStore라는 멤버변수(필드변수)에 값을 저장하고 조회하는 로직을 만들었다. sleep으로 조회하는데 시간이 어느정도 걸린다고 가정했다.

 

 

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

스레드를 사용해 로직을 두번 실행하는 코드를 작성했다. 

 

userA는 스레드A를 이용해 자신의 이름을 nameStore에 저장하였고 userB도 스레드B를 이용해 자신의 이름을 nameStore에 저장하는 로직이다. 

 

1. 저장과 조회에 1초가 걸리고

2. userA가 실행하고 2초 뒤 userB가 실행한다

3. 3초뒤 메인스레드가 종료된다. 

 

이를 그림으로 나타내면 다음과 같다.

스레드A가 userA를 nameStore에 저장하고 nameStore를 조회해 userA를 반환받는다. 

 

이후에 스레드B가 userB를 nameStore에 저장하고 nameStore를 조회해 userB를 반환받는다.

 

실행 결과

 

아직까지 동시성 문제가 발생하지 않는다. 

 

이제 동시성 문제가 발생하게 코드를 바꿀 것이다. 스레드A의 시작과 스레드B사이에 있는 sleep을 100으로 설정해주면 된다. 

 

그렇게 되면 순서는 이렇게 된다.

1. 저장과 조회에 1초가 걸린다.

2. userA가 실행하고 0.1초 뒤에 userB가 실행한다.

3. 3초뒤 메인스레드가 종료된다.

 

저장과 조회에 1초가 걸리는데 userA가 실행한지 0.1초만에 userB가 실행해버렸다. 이제 결과가 어떻게 되나 지켜보자

 

우리가 원했던 결과와 다르게 나왔다. 스레드A가 가진 nameStore엔 userA가 찍혀야하고 스레드B가 가진 nameStore엔 userB가 찍혀야 하는데 둘다 userB가 나와버렸다. 

 

이제 이 상황을 그림으로 더 알기쉽게 설명해보겠다. 

 

1번

1. 먼저 스레드A가 userA로 nameStore에 저장한다.

 

2번

2. 0.1초 뒤에 스레드B가 userB를 nameStore에 저장한다. 기존에 nameStore에 보관되어 있던 userA값은 제거되고 userB 값이 저장된다. 

 

3번

3. 스레드A의 호출이 끝나면서 nameStore의 결과를 반환받는데 이때 nameStore는 앞의 2번에서 userB의 값으로 대체되었다. 따라서 기대했던 userA가 아니라 userB의 값이 반환된다.

 

 

결과적으로 스레드A입장에서 저장한 데이터와 조회한 데이터가 다른 문제가 발생한다. 이처럼 여러 스레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제를 동시성 문제라 한다. 이런 동시성 문제는 여러 스레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 적은 상황에서는 확률상 잘 나타나지 않고 트래픽이 점점 많아질 수록 자주 발생한다.

 

 

cf) 이런 동시성 문제는 지역 변수에서는 발생하지 않는다. 지역 변수는 스레드마다 각각 다른 메모리 영역이 할당된다. 동시성 문제가 발생하는 곳은 같은 인스턴스의 필드 또는 static같은 공용 필드에 접근할 때 발생한다. 또한 동시성 문제는 값을 읽기만 하면 발생하지 않는다. 어디선가 값을 변경하기 때문에 발생한다.

 

 

이런 동시성 문제를 해결할 수 있는 것이 바로 스레드로컬이다.

 

 

스레드로컬은 해당 스레드만 접근할 수 있는 특별한 저장소를 말한다. 쉽게 이야기해 물건 보관 창구를 떠올리면 되는데, 여러 사람이 같은 물건 보관 창구를 사용하더라도 창구 직원은 사용자를 인식해서 사용자별로 확실하게 물건을 구분해준다.

 

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

 

하지만 스레드 로컬은 각 스레드별로 별도의 내부 저장소를 제공한다. 따라서 같은 인스턴스의 스레드로컬 필드에 접근해도 문제없다.

 

 

이제 예제 코드를 다음과 같이 바꾸면 스레드로컬을 사용하여 동시성 문제를 해결할 수 있다.

 

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

nameStore를 단순한 필드 변수가 아닌 스레드로컬로 선언한다. 스레드 로컬을 지정해줄 때는 set메서드를 사용하고 꺼낼 땐 get메서드를 사용한다. 

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

 

실행결과

실행결과 우리가 원한대로 스레드A는 userA를 스레드B는 userB를 반환받았다. 

 

이제 동시성 문제 해결! 

 

이 아니라 하나 더 남았다. 바로 동시성 문제를 해결하려 할때 스레드로컬을 사용하는데 스레드로컬에는 주의해야 하는 점이 있다. 바로 리소스를 삭제해줘야 한다는 것이다. 

 

예를 들어서 설명해보겠다.

 

1. 사용자A가 저장 HTTP를 요청했다. 

2. WAS는 스레드풀에서 스레드를 하나 조회한다.

3. 스레드A가 할당된다.

4. 스레드A는 사용자A의 데이터를 스레드 로컬에 저장한다.

5. 스레드 로컬의 스레드A 전용 보관소에 사용자A 데이터를 보관한다.

 

1. 사용자A의 HTTP응답이 끝난다.

2. WAS는 사용이 끝난 스레드A를 스레드 풀에 반환한다. 스레드를 생성하는 비용은 비싸기 때문에 스레드를 제거하지 않고 보통 스레드 풀을 통해서 스레드를 재사용한다.

3. 스레드A는 스레드풀에 아직 살아있다. 따라서 스레드로컬의 스레드A 전용보관소에 사용자A데이터도 함께 살아있게 된다.

 

1. 사용자B가 조회를 위한 새로운 HTTP요청을 한다.

2. WAS는 스레드 풀에서 스레드 하나를 조회한다.

3. 우연찮게 스레드A가 선택되었다. (물론 다른 스레드가 할당될 수도 있다.)

4. 이번에는 조회하는 요청이니 스레드A는 스레드로컬에서 데이터를 조회한다.

5. 스레드로컬은 스레드A 전용 보관소에 있는 사용자A 값을 반환한다.

6. 결과적으로 사용자A값이 반환된다.

7. 사용자B는 사용자A의 정보를 조회하게 된다.

 

이처럼 사용자B는 사용자A의 데이터를 확인하게 되는 심각한 문제가 발생하게 된다. 이런 문제를 예방하려면 사용자A의 요청이 끝날 때 스레드로컬의 값을 ThreadLocal.remove()를 통해서 꼭 제거해야 한다.

 

스레드로컬을 사용할 때는 이 부분을 꼭꼭 기억하자