멀티 쓰레드 환경과 ThreadLocal

민씨·2023년 11월 28일
0
post-thumbnail
post-custom-banner

개요

멀티 쓰레드와 동시성에 대한 공부를 하던 중 쓰레드 로컬(Thread Local)이라는 것을 알게 되었습니다. 설명을 읽어보면 분명히 좋아 보이는 것 같기는 한데 개발자가 직접 비즈니스 로직에 사용할 일이 있을까?에 대한 의문이 들었습니다.

이에 쓰레드 로컬을 직접 사용하는 코드를 찾던 중, Spring Security 프로젝트의 ThreadLocalSecurityContextHolderStrategy.java 에서 사용한다는 것을 알게 되었는데요.
(이름이 왜 이렇게 길어, 스프링도 길게 쓰는데 나도 클래스명 길게 만들어야지)

매우 거대한 스프링 프로젝트에서도 보기 드문 쓰레드 로컬은 제 생각대로 개발자가 직접 다룰일은 거의 없어 보이지만 동기화 관련 학습을 하던 중 발생한 의문을 쓰레드 로컬로 해결하였기에 그 과정을 글로 작성해 보았습니다.

멀티 쓰레드 환경의 동시성 문제

ThreadAThreadB 가 동시에 공통된 자원 value 의 값을 변경한다면 어떻게 될까요?

ThreadAvalue 를 조회 했을 때, 5가 나오길 기대했지만 실제 결과는 5가 나올 수도 있고 10 이 나올 수도 있습니다.

이는 동기화를 하지 않았으므로 당연한 결과입니다.

위 과정을 코드로 나타내보겠습니다.

소스코드

Resource 클래스

value 가 인스턴스 변수로 있는 Resuorce 클래스 입니다.

public class Resource {

    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

}

ThreadA, ThreadB 클래스

ThreadA 클래스는 value 를 5로 초기화 합니다.

public class ThreadA extends Thread {

    private final Resource resource;

    public ThreadA(Resource resource) {
        this.resource = resource;
    }

    @Override
    public void run() {
        // ThreadA가 값을 5로 초기화
        resource.setValue(5);

        // ThreadA가 값을 조회
        System.out.println("ThreadA : " + resource.getValue());
    }

}

ThreadB 클래스는 value 를 10으로 초기화 합니다.

public class ThreadB extends Thread {

    private final Resource resource;

    public ThreadB(Resource resource) {
        this.resource = resource;
    }

    @Override
    public void run() {
        // ThreadB가 값을 10으로 초기화
        resource.setValue(10);

        // ThreadA가 값을 조회
        System.out.println("ThreadB : " + resource.getValue());
    }

}

Client 클래스

메인 메서드가 포함된 Client 클래스 입니다.

public class Client {

    public static void main(String[] args) {
        Resource resource = new Resource();

        ThreadA threadA = new ThreadA(resource);
        ThreadB threadB = new ThreadB(resource);

        threadA.start();
        threadB.start();
    }

}

실행 결과

실행 결과는 어떻게 될까요?

케이스 1

가장 일반적인 케이스입니다.

ThreadA : 5
ThreadB : 10
  1. ThreadA : setValue(5) 호출 - 5 초기화
  2. ThreadA : getValue() 호출 - 5 출력
  3. ThreadB : setValue(10) 호출 - 10 초기화
  4. ThreadB : getValue() 호출 - 10 출력

케이스 2

쓰레드는 실행 순서를 보장하지 않으므로 ThreadB가 먼저 출력될 수 있습니다.

ThreadB : 10
ThreadA : 5
  1. ThreadB : setValue(10) 호출 - 10 초기화
  2. ThreadB : getValue() 호출 - 10 출력
  3. ThreadA : setValue(5) 호출 - 5 초기화
  4. ThreadA : getValue() 호출 - 5 출력

케이스 3

ThreadA의 setValue(5) 호출을 ThreadB의 setValue(10) 호출로 덮어버린다면 아래와 같이 출력됩니다.

ThreadB : 10
ThreadA : 10
  1. ThreadA : setValue(5) 호출 - 5 초기화
  2. ThreadB : setValue(5) 호출 - 10 초기화
  3. ThreadB : getValue() 호출 - 10 출력
  4. ThreadA : getValue() 호출 - 10 출력

케이스 4

반대로 ThreadB의 setValue(10) 호출을 ThreadA가 setValue(5) 호출로 덮어버린다면 아래와 같이 출력됩니다.

ThreadB : 5
ThreadA : 5
  1. ThreadB : setValue(10) 호출 - 10 초기화
  2. ThreadA : setValue(5) 호출 - 5 초기화
  3. ThreadB : getValue() 호출 - 5 출력
  4. ThreadA : getValue() 호출 - 5 출력

이를 해결할 방법이 없을까요?

동기화

위 문제는 멀티 쓰레드 환경의 동시성 문제를 잘 나타내고 있습니다.

두 개의 쓰레드 ThreadA, ThreadB 가 공유 자원인 Resource 클래스의 value 를 동시에 수정하고 읽기 때문에 예측할 수 없는 결과가 나타나고 있는 것이죠.

이를 해결하기 위해서는 value 를 동기화하면 됩니다.
= 유식한 말로는 임계 구역(Critical Section)을 생성하면 됩니다.

synchronized

자바에서 동기화를 하는 가장 간단한 방법입니다.

setValue() 메서드 앞에 synchronized 키워드를 붙여 하나의 쓰레드만 해당 메서드를 실행할 수 있도록 변경합니다.

코드 수정

Resource 클래스의 setValue() 메서드를 수정해보겠습니다.

public class Resource {

    private int value;

    public int getValue() {
        return value;
    }

    public synchronized void setValue(int value) {
        this.value = value;
    }

}

실행 결과

value 에 쓰레드가 동시에 접근할 수 없기 때문에, 순서만 다를 뿐 똑같은 결과를 얻을 수 있습니다.

ThreadA : 5
THreadB : 10

혹은

ThreadB : 10
THreadA : 5

모든 문제가 해결되었으니 행복하게 마무리하면 좋겠지만 문제가 있습니다.

문제점

쓰레드의 실행 과정 중에 특수한 로직이 추가되었습니다.

  1. setValue() 메서드 호출
  2. N초가 소요되는 특수한 로직 호출
  3. getValue() 메서드 호출

이 특수한 로직은 파일 읽기, 단순 연산, 복잡한 연산 등 어떤 것이든지 될 수 있습니다. 다만 N초가 걸릴 뿐입니다.

ThreadA 가 특수한 로직을 처리하는데 3초의 시간이 소요된다고 가정해봅시다.

ThreadA 코드 수정

3초가 걸리는 특수한 로직 logic() 메서드를 추가했습니다.

이 로직의 특별한 점은 2초 동안 로직을 실행하다가 value 의 값이 필요해 getValue() 메서드를 호출하는 것입니다.

이 후 1초 동안 로직을 마저 실행한 후 종료됩니다.

public class ThreadA extends Thread {

    private final Resource resource;

    public ThreadA(Resource resource) {
        this.resource = resource;
    }

    @Override
    public void run() {
        // ThreadA가 값을 5로 초기화
        resource.setValue(5);

        // 3초가 걸리는 특수한 로직 호출
        logic();
    }

    private void logic() {
        try {
            Thread.sleep(2_000);
            // ThreadA가 값을 조회
            System.out.println("ThreadA : " + resource.getValue());
            Thread.sleep(1_000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

실행 결과

결과가 어떻게 될까요?

이제 무조건 하나의 결과만 출력됩니다.

ThreadB : 10
ThreadA : 10

아래의 시나리오를 따르기 때문인데요.

  1. ThreadA : setValue(5) 호출 - 5 초기화
  2. ThreadA : logic() 호출 - 2초 실행
  3. ThreadB : setValue(10) 호출 - 10 초기화
  4. ThreadB : getValue() 호출 - 10 출력
  5. ThreadA : getValue() 호출 - 10 출력
  6. ThreadA : logic() - 1초 실행

logic() 메서드에서는 ThreadA 가 설정한 값인 5를 사용하고 싶은데, 특수한 로직이 실행되는 시간에 ThreadB 가 값을 10으로 변경하여 사용할 수 없게 된 것이죠.

이를 해결할 수 있는 방법이 뭐가 있을까요?

해결방안

해결 방법은 간단합니다.

logic() 메서드 자체에 동기화를 적용하면 됩니다.

ThreadA 코드 수정

value 값을 변경하는 setValue() 메서드도 logic() 메서드로 옮긴 뒤, synchroized 키워드를 적용하였습니다.

public class ThreadA extends Thread {

    private final Resource resource;

    public ThreadA(Resource resource) {
        this.resource = resource;
    }

    @Override
    public void run() {
        // 3초가 걸리는 특수한 로직 호출
        synchronized (resource) {
            logic();
        }
    }

    private void logic() {
        try {
            // ThreadA가 값을 5로 초기화
            resource.setValue(5);

            // 2초 대기
            Thread.sleep(2_000);

            // ThreadA가 값을 조회
            System.out.println("ThreadA : " + resource.getValue());

            // 1초 대기
            Thread.sleep(1_000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

실행 결과

원하는 대로 결과가 출력 됩니다.

ThreadA : 5
ThreadB : 10

아래의 시나리오를 따르기 때문입니다.

  1. ThreadA : logic() 호출 - 동기화
  2. ThreadA : setValue(5) 호출 - 5 초기화
  3. ThreadA : logic() - 2초 실행
  4. ThreadA : getValue() 호출 - 5 출력
  5. ThreadA : logic() - 1초 실행 후 종료, 동기화 해제
  6. ThreadB : setValue(10) 호출 - 10 초기화
  7. ThreadB : getValue() 호출 - 10 출력

원하는 결과를 얻었으니 만족해야 할까요?

여기서도 문제가 있습니다.

ThreadBThreadA 의 로직이 끝날 때 까지 대기를 해야합니다.

지금은 3초니까 괜찮아도 이게 1시간, 2시간이 되면 곤란하지 않을까요?

이를 해결하기 위해 ThreadLocal 이 등장합니다.

ThreadLocal

쓰레드 로컬(Thread Local)은 각각의 쓰레드 별로 값을 할당할 수 있게 해줍니다.

아래의 그림과 같이 ThreadA 가 5를 할당하고, ThreadB 가 10을 할당하게 된다면 위에서 알아본 것 처럼 동시성 문제가 발생합니다.

이 때, ThreadLocal 을 사용한다면 어떻게 될까요?

아래와 같이 쓰레드 별 독립된 값을 할당할 수 있도록 해줍니다.

이렇게 된다면, 각 쓰레드별로 할당한 값을 그대로 가져올 수 있습니다.

자 그러면 ThreadLocal 을 이용하여 문제를 해결해봅시다.

문제 해결

코드 수정

Resource 클래스의 valueThreadLocal 을 사용하도록 변경합니다.

또한 각각의 쓰레드 별로 별도의 공간을 제공하므로 임계 구역을 만들 필요도 없습니다. setValue() 메서드의 synchronized 키워드도 제거합니다.

public class Resource {

    private final ThreadLocal<Integer> value = new ThreadLocal<>();

    public int getValue() {
        return value.get();
    }

    // synchronized 제거
    public void setValue(int value) {
        this.value.set(value);
    }

}

ThreadA 클래스의 synchronized 키워드도 제거합니다.

public class ThreadA extends Thread {

    private final Resource resource;

    public ThreadA(Resource resource) {
        this.resource = resource;
    }

    @Override
    public void run() {
        // 3초가 걸리는 특수한 로직 호출
        // synchronized 키워드 제거
        logic();
    }

    private void logic() {
        try {
            // ThreadA가 값을 5로 초기화
            resource.setValue(5);

            // 2초 대기
            Thread.sleep(2_000);

            // ThreadA가 값을 조회
            System.out.println("ThreadA : " + resource.getValue());

            // 1초 대기
            Thread.sleep(1_000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

실행 결과

ThreadA 가 저장할 별도의 공간에 5,
ThreadB 가 저장할 별도의 공간에 10을 쓰레드 로컬이 저장해줍니다.

따라서 아래와 같은 결과가 나타납니다.

ThreadB : 10
ThreadA : 5

이로써 동시성 문제를 해결하면서 각 쓰레드가 할당한 값을 대기 시간 없이 저장할 수 있게 되었습니다.

마치며

위에서 알아본 근본적인 문제점들은 전부 멀티 쓰레드 환경에서 공유 가능한 상태를 만들었기 때문에 발생합니다.

마치 스프링 부트 기반의 웹 애플리케이션 서버의 코드 중 서비스 클래스에 인스턴스 변수가 사용자의 요청에 따라 계속해서 변하는 상황으로 볼 수 있겠습니다.

애초에 공유 가능한 상태를 만들지 않도록 설계하는 것이 가장 이상적일 것이라 생각되지만, 세상 만사가 생각하는 대로 이루어지는 것은 아니라서 이를 해결하는 방법을 알아둬야 합니다.

감사합니다.

profile
進取
post-custom-banner

0개의 댓글