Java

[ThreadLocal] 쓰레드 로컬 사용해보기

수수한개발자 2023. 4. 22.
728x90

동시성이란?

동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 적은 상황에서는 확률상 잘 나타나지 않고 트래픽이 점점 만나질 수록 자주 발생합니다.

특히 스프링 빈처럼 싱글톤 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야 합니다.

이런 동시성 문제는 지역 변수에서는 발생하지 않습니다. 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당되기 때문입니다. 동시성 문제가 발생하는 곳은 같은 인스턴스의 필드(주로 싱글톤에서 자주 발생), 또는 static 같은 공용 필드에 접근할 때 발생합니다. 동시성 문제는 값을 읽기만 하면 발생하지 않고 어디선가 값을 변경하기 때문에 발생합니다. 이러한 문제를 해결하기 위한 것이 스레드 로컬입니다.

 

ThreadLocal

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

예를 들면 학교에서 사물함이 있다고 치면 각각의 학생별로 사물함이 있을 것입니다. 

학생 A = 1번 사물함

학생 B = 2번 사물함 

여기서 학생 A가 쓰레드이고 1번 사물함이 스레드 로컬이라고 생각하면 될 것 같습니다.

예제는 이렇습니다. 

순차적으로 증가하는 사물함의 번호를 학생1 과 학생 2가 배정받게 됩니다.

각각의 사물함에 학생을 배정하고 1초 후 조회하여 어떤 학생이 몇번 사물함을 가지고 있는지 조회한다.

이때 동시성 문제를 확인하며 이 동시성 문제를 쓰레드 로컬로 해결할 예정입니다.

 

예제 코드는 깃허브(til/threadlocal)에 있습니다.

예제 코드 

학생

public class Student {
    private final String studentName;

    public Student(String studentName) {
        this.studentName = studentName;
    }

    public String getStudentName() {
        return studentName;
    }
}

사물함

@Slf4j
public class Locker {

    private Integer lockerNumberSequence = 0;   
    private Student lockerOwner;

    public void assignLocker() {
    	++lockerNumberSequence;
        log.info("저장 lockerNumber={} -> lockerOwner={}", lockerNumberSequence, lockerOwner.getStudentName());
        lockerNumber = number;
        sleep(1000);
        log.info("조회 lockerNumber={} -> lockerOwner={}", lockerNumberSequence, lockerOwner.getStudentName());
    }

    public void addStudent(String name) {
        lockerOwner = new Student(name);
    }

    private void sleep(int mills) {
        try {
            Thread.sleep(mills);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
@Slf4j
public class LockerTest {

    private final Locker locker = new Locker();

    @Test
    void field() {
        log.info("main start");
        Runnable userA = () -> {
            locker.addStudent("지수1");
            locker.assignLocker();
        };

        Runnable userB = () -> {
            locker.addStudent("지수2");
            locker.assignLocker();
        };

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

        threadA.start();
//        sleep(2000);
        sleep(100);
        threadB.start();

        sleep(3000);
        log.info("main exit");
    }

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

 

 

예제 코드는 이렇습니다.

Student(학생), Locker( 사물함) 이고 Locker에는 assignLocker()와 addStudent() 메서드가 있습니다.

assignLocker() 메서드는 락커번호를 순차적으로 올려주고 저장 및 ,조회  addStudent() 메서드는 학생을 저장하는 메서드입니다.

 

이제 테스트 코드를 실행 해보면 다음과 같은 결과가 나옵니다.

22:55:50.434 [Test worker] INFO com.example.blogcode.LockerTest -- main start
22:55:50.437 [thread-A] INFO com.example.blogcode.Locker -- 저장 lockerNumber=1 -> lockerOwner=지수1
22:55:50.542 [thread-B] INFO com.example.blogcode.Locker -- 저장 lockerNumber=2 -> lockerOwner=지수2
22:55:51.440 [thread-A] INFO com.example.blogcode.Locker -- 조회 lockerNumber=2 -> lockerOwner=지수2
22:55:51.547 [thread-B] INFO com.example.blogcode.Locker -- 조회 lockerNumber=2 -> lockerOwner=지수2
22:55:53.547 [Test worker] INFO com.example.blogcode.LockerTest -- main exit

분명 실행 LockerTes에서 addStudent() 메서드를 호출하여 순서대로 학생이름을  지수 1, 지수 2로 학생을 추가하고 

locker번호를 순차적으로 올려 조회하였더니 마지막에는 사물함 1번도 없고 사물함 주인인 지수1도 없어졌습니다.

위에서 말했듯이 동시성 문제는 같은 인스턴스(Locker)의 필드 변수(Student, lockerNumberSequence)에서 발생하게 됩니다.

두 개의 스레드가 같은 변수를 공유하게 되면서 assignLocker()의 마지막에서 조회하면  사물함 2번, 지수 2번이 조회되게 됩니다. 

 

이제 이 문제를 해결 할 수 있는 스레드 로컬로 해결해보록 하겠습니다.

 

Locker

@Slf4j
public class Locker {
    private Integer lockerNumberSequence = 0;
    private ThreadLocal<Integer> lockerNumber = new ThreadLocal<>();
    private ThreadLocal<Student> lockerOwner = new ThreadLocal<>();

    public void assignLocker() {
        ++lockerNumberSequence;
        lockerNumber.set(lockerNumberSequence);
        log.info("저장 lockerNumber={} -> lockerOwner={}", lockerNumber.get(), lockerOwner.get().getStudentName());
        sleep(1000);
        log.info("조회 lockerNumber={} -> lockerOwner={}", lockerNumber.get(), lockerOwner.get().getStudentName());
    }

    public void addStudent(String name) {
        lockerOwner.set(new Student(name));
    }

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

 

위에서 쓰레드 로컬은 해당 스레드만 접근할 수 있는 특별한 저장소를 의미한다고 설명했습니다.

변경된 코드는 lockerNumberSequence를 순차적으로 올려준 후 스레드 각각의 저장소인 스레드 로컬에 저장하게 됩니다.

그리고 조회 역시 쓰레드 로컬에서 조회하게 됩니다. 학생을 추가하는 부분도 스레드로컬을 사용하였습니다.

 

 

23:14:00.286 [Test worker] INFO com.example.blogcode.LockerTest -- main start
23:14:00.288 [thread-A] INFO com.example.blogcode.Locker -- 저장 lockerNumber=1 -> lockerOwner=지수1
23:14:00.393 [thread-B] INFO com.example.blogcode.Locker -- 저장 lockerNumber=2 -> lockerOwner=지수2
23:14:01.290 [thread-A] INFO com.example.blogcode.Locker -- 조회 lockerNumber=1 -> lockerOwner=지수1
23:14:01.395 [thread-B] INFO com.example.blogcode.Locker -- 조회 lockerNumber=2 -> lockerOwner=지수2
23:14:03.398 [Test worker] INFO com.example.blogcode.LockerTest -- main exit

thread-A에는 lockerNumber = 1 ,lockerOwner = 지수 1

thread-B에는 lockerNumber = 2 , lockerOwner = 지수 2로 조회되는 것을 확인할 수 있습니다.

 

 

 

스레드 로컬 사용 시 주의 사항

위의 예제에서는 thread-A를 실행 후 애플리케이션이 종료되었기 때문에 스레드 로컬이 공유될 상황이 없지만

만약 웹 애플리케이션에서 쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우에는 심각한 문제가 발생할 수 있습니다. 

 

문제 상황

학생 A 조회 요청

 

1. 학생 A가 저장 HTTP를 요청했다.
2. WAS
는 스레드 풀에서 쓰레드를 하나 조회한다.
3.
쓰레드 thread-A 가 할당되었다.
4.
thread-A는 학생 A의 사물함 1번 데이터를 스레드 로컬에 저장한다.
5.
쓰레드 로컬의 thread-A 전용 보관소에 학생 A, 사물함 1번 데이터를 보관한다.

6. 학생 A의A의 HTTP 응답이 끝난다.
7. WAS
는 사용이 끝난 thread-A를 스레드 풀에 반환한다. 스레드를 생성하는 비용은 비싸기 때문에 쓰레드를 제거하지 않고, 보통 쓰레드 풀을 통해서 쓰레드를 재사용한다.
8.
thread-A는 스레드풀에 아직 살아있다. 따라서 쓰레드 로컬의 thread-A 전용 보관소에 학생 A, 사물함 1번 데이터도 함께 살아있게 된다.

 

학생 B 조회 요청

 

1. 학생 B가B 조회를 위한 새로운 HTTP 요청을 한다.

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

3. 쓰레드 thread-A 가 할당되었다. (물론 다른 스레드가 할당될 수 도 있다.)

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

5. 쓰레드 로컬은 thread-A 전용 보관소에 있는 학생 A,값을 반환한다.

6. 결과적으로 학생 A값이 반환된다.

7. 학생 B는B 학생 A의A 정보를 조회하게 된다.

 

결과적으로 학생 B가 학생 A의 사물함의 데이터를 확인하게 되는 심각한 문제가 발생하게 됩니다.

이런 문제를 예방하기 위해서는 ThreadLocal.remove()를 사용하여 꼭 제거해야 합니다.

 

 

ref

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8

 

 

 

 

728x90

댓글