0+ 스프링/0 + SpringBoot(스프링부트)

[스프링] 동시성 문제 해결(ThreadLocal)

힘들면힘을내는쿼카 2023. 1. 17. 20:11
728x90
반응형

[Spring] 동시성 문제 해결(ThreadLocal)

스프링 핵심 원리 - 고급편 - 인프런 | 강의
이 글은 인프런에서 스프링 핵심 원리 - 고급편 강의를 참고하여 작성했습니다.

 

스프링 핵심 원리 - 고급편 - 인프런 | 강의

스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

 

 

🏃‍♂️오픈 런 이라는 말을 들어보신적 있으신가요?
“매장문이 열리자마다 달려가서 구매함” 을 의미합니다.
이렇게 되면 매장은 1개인데 사람은 여러명이 동시에 들어가게 되니 매장이 마비가 됩니다.

 

웹 서버에서도 마찬가지 입니다.
매장을 자원, 사람을 스레드라고 비유하면
하나의 자원에 여러 스레드가 동시에 접속하는 현상 입니다.

 

이게 왜 문제일까요? 🤔

스프링에서 빈(bean)은 싱글톤으로 등록 됩니다.
이말은 해당 객체의 인스턴스가 딱 1개만 생성된다는 의미 입니다.

이렇게 1개만 존재하는 인스턴스에 스레드가 동시에 접근한다면 어떤 일이 발생할까요?
해당 인스턴스에 속한 로직들이 꼬이게 되고, 흔히 장애가 발생했다고 표현합니다.

그렇다면 어떻게 이 문제를 해결할 수 있을까요?

먼저 동시성 문제를 생각하지 않고 작성한 코드를 살펴 봅시다.

ThreadLocal 적용 전(동시성 문제)

TestRepository

@Slf4j
public class TestRepository {
    private String name;

    public void logic(String name) {
        save(name);
        sleep(1000); // 1초 지연
        find();
    }

    public void save(String name) {
        this.name = name;
        log.info("저장 name={}", name);
    }

    public String find() {
        log.info("조회 name={}", this.name);
        return this.name;
    }

    public void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

ThreadLocalTest

@Slf4j
public class ThreadLocalTest {

    TestRepository testRepository = new TestRepository();

    // CountDownLatch는 어떤 쓰레드가 다른 쓰레드에서 작업이 완료될 때 까지 기다릴 수 있도록 해주는 클래스
    private CountDownLatch countDownLatch = new CountDownLatch(3);

    @Test
    void 동시성문제() throws InterruptedException {
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                testRepository.logic("쿼카1");
                countDownLatch.countDown();
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                testRepository.logic("쿼카2");
                countDownLatch.countDown();
            }
        });

        thread1.setName("쿼카1 스레드");
        thread1.start();

        testRepository.sleep(100);

        thread2.setName("쿼카2 스레드");
        thread2.start();

        countDownLatch.countDown(); // 메인 스레드 카운트
        countDownLatch.await(); // Latch의 숫자가 0이 될 때까지 기다림

        log.info("테스트 끝!");
    }
}

 

결과

쿼카1 스레드name쿼카1 이라고 저장했지만, 쿼카2가 조회된다.
쿼카1 스레드가 name에 저장한 쿼카1을 조회하는 작업이 끝나기 전에,
쿼카2 스레드가 name에 쿼카2를 저장하기 때문
입니다.

18:05:26.673 [쿼카1 스레드] - 저장 name=쿼카1
18:05:26.741 [쿼카2 스레드] - 저장 name=쿼카2
18:05:27.700 [쿼카1 스레드] - 조회 name=쿼카2
18:05:27.746 [쿼카2 스레드] - 조회 name=쿼카2
18:05:27.749 [Test worker] - 테스트 끝!

 

이렇게 여러 스레드가 동시에 동일한 인스턴스의 필드 값을 변경하면서 발생하는 문제동시성 문제라고 합니다.

동시성 문제는 여러 스레드가 동일한 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 적은 상황에서는 확률상 잘 나타지 않습니다.
그러다가 서비스가 잘되서 트래픽이 많아지게 되면 자주 발생하게 됩니다..!
(특히, 스프링 빈 처럼 싱글톤 패턴을 사용하여 객체의 필드를 변경할 때, 동시성 문제에 대해서 고민하면서 설계를 해야겠죠?)

결국에는 동시성 문제가 발생하는 원인은 어디선가 값을 변경하기 때문에 발생한다고 볼수 있습니다.

 

참고

참고로 동시성 문제는 지역 변수에서는 발생하지 않습니다.
지역 변수는 스레드마다 각각 다른 메모리 영역이 할당 되기 때문이죠.
동시성 문제는 주로 인스턴스 필드(주로 싱글톤), 또는 static 같은 공용 필드에 접근할 때 발생합니다.

ThreadLocal 적용 후(동시성 문제 해결)

ThreadLocal해당 스레드만 접근할 수 있는 특별한 저장소를 의미합니다.
이말은 스레드마다 별도의 내부 저장소를 제공하기 때문에, 같은 인스턴스의 스레드 로컬 필드에 접근해도 문제가 없습니다.

TestRepositoryApplyThreadLocal

@Slf4j
public class TestRepositoryApplyThreadLocal {
      // 스레드 로컬 추가
    private ThreadLocal<String> nameLocal = new ThreadLocal<>();

    public void logic(String name) {
        save(name);
        sleep(1000);
        find();
        finish(); // 스레드 로컬 제거
    }

    public void save(String name) {
        nameLocal.set(name);
        log.info("저장 name={}", name);
    }

    public String find() {
        log.info("조회 name={}", nameLocal.get());
        return nameLocal.get();
    }

    public void finish() {
        // 스레드 로컬 제거
        nameLocal.remove();
    }

    public void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

ThreadLocalTest

@Slf4j
public class ThreadLocalTest {

    TestRepositoryApplyThreadLocal testRepositoryApplyThreadLocal = new TestRepositoryApplyThreadLocal();

    // CountDownLatch는 어떤 쓰레드가 다른 쓰레드에서 작업이 완료될 때 까지 기다릴 수 있도록 해주는 클래스
    private CountDownLatch countDownLatch = new CountDownLatch(3);

    @Test
    void 동시성문제해결() throws InterruptedException {
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                testRepositoryApplyThreadLocal.logic("쿼카1");
                countDownLatch.countDown();
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                testRepositoryApplyThreadLocal.logic("쿼카2");
                countDownLatch.countDown();
            }
        });
        thread1.setName("쿼카1 스레드");
        thread1.start();

        testRepositoryApplyThreadLocal.sleep(10);

        thread2.setName("쿼카2 스레드");
        thread2.start();

        countDownLatch.countDown(); // 메인 스레드 카운트
        countDownLatch.await(); // Latch의 숫자가 0이 될 때까지 기다림

        log.info("테스트 끝!");
    }
}

 

결과

각 스레드에 저장된 name이 알맞게 조회 되는것을 확인할 수 있습니다.
동시성 문제를 해결했습니다..!

18:11:31.138 [쿼카2 스레드]  - 저장 name=쿼카2
18:11:31.138 [쿼카1 스레드]  - 저장 name=쿼카1
18:11:32.170 [쿼카1 스레드]  - 조회 name=쿼카1
18:11:32.170 [쿼카2 스레드]  - 조회 name=쿼카2
18:11:32.177 [Test worker]  - 테스트 끝!

ThreadLocal.remove()

TestRepositoryApplyThreadLocalfinsh() 메서드를 확인해보셨나요?
스레드 로컬을 모두 사용하고 나면 반드시 ThreadLocal.remove()를 호출해서 스레드 로컬에 저장된 값을 제거해야 합니다.

@Slf4j
public class TestRepositoryApplyThreadLocal {
      // 스레드 로컬 추가
    private ThreadLocal<String> nameLocal = new ThreadLocal<>();

    public void logic(String name) {
        save(name);
        sleep(1000);
        find();
        finish(); // 스레드 로컬 제거
    }
      // .. //

    public void finish() {
        // 스레드 로컬 제거
        nameLocal.remove();
    }

    // .. //
}

 

스레드 로컬 값을 사용한 후에 제거하지 않으면 스레드풀을 사용하는 경우(톰캣)에 심각한 장애가 발생할 수 있기 때문입니다.

스레드 로컬 값을 사용한 후 제거하지 않으면,
신규로 스레드를 사용한다는 요청이 들어왔을 때 스레드 로컬 값을 제거하지 않은 스레드를 제공할 수 있기 때문입니다.
(스레드 생성 비용은 비싸기 때문에 보통 스레드 풀을 통해서 스레드를 재사용 합니다.)

이렇게 되면 동시성 이슈와 비슷한 현상이 발생하게 됩니다.

 

예를 들면, 당신이 화장실에 들어갔습니다.
그 화장실에는 다음과 같은 사연이 있었습니다.
3시간 전, 어떤 사람이 변기에 일을 보고 물을 안 내립니다.
2시간 전, 어떤 사람이 변기에 일을 보고 물을 안 내립니다.
30분 전, 어떤 사람이 변기에 일을 보고 물을 안 내립니다.

 

그리고 당신이 화장실을 사용하려고 합니다…..

 

 

이렇듯 스레드 로컬 사용후에는 반드시 스레드 로컬 값을 제거해야 합니다.!!!

 

 

 

 

728x90
반응형