스프링의 이해 : 동시성 문제와 ThreadLocal 처리

    이 게시글은 인프런 김영한님 강의를 듣고 복습하며 기록한 글입니다.


    동시성 문제

    동시성 문제는 멀티 쓰레드 환경에서 발생하는 문제다. 위의 그림처럼 여러 쓰레드가 동시에 동일한 자원에 접근해서 수정을 하는 경우 발생한다. 왜냐하면 동시에 값을 수정했을 때, 각 쓰레드가 기대하던 값과는 다른 형태의 값이 들어올 가능성이 있기 때문이다.

    바꿔 이야기 하면, 멀티 쓰레드 환경이라고 하더라도 싱글톤 객체에 단순히 '조회'만 하는 것이라면 동시성 문제가 발생하지 않는다. 문제는 동시에 동일한 자원에 접근해서 수정을 했을 때 발생한다. 

    또한 동시성 문제는 지역 변수에서 발생하지 않는다. 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당되기 때문이다.

     

    동시성 문제는 결국 같은 공간에 여러 쓰레드가 동시에 수정을 하면서 일어나는 일이기 때문에 수정을 하는 공간을 분리해주면 된다. 이 포스팅에서는 '쓰레드 로컬'을 사용해서, 동시성 문제를 해결하는 방법을 작성할 것이다. 

     


    쓰레드 로컬이란? 

     

    쓰레드 로컬은 창구 직원과 같은 역할을 한다고 보면 된다. 각 쓰레드 별로 별도 공간이 따로 할당되고, 쓰레드 로컬은 특정 쓰레드만 특정 공간에 접근할 수 있도록 해준다.  쓰레드 별로 저장 공간이 분리되기 때문에 각 쓰레드는 유일한 공간에 접근하는 것이 보장된다. 따라서 동시성 문제에서 자유로울 수 있도록 해준다. 

     

    // ThreadLocal 생성
    private ThreadLocal<String> nameStore = new ThreadLocal<>();
    
    // ThreadLocal에 값 저장
    nameStore.set(value)
    
    // ThreadLocal에서 값 받아오기
    nameStore.get()
    
    // ThreadLocal 지우기
    nameStore.remove()

    쓰레드 로컬의 사용법은 위와 같다. 

     


    쓰레드 로컬 사용 시, 주의 사항

    사용이 완료된 ThreadLocal은 반드시 .remove() 명령을 통해서 제거해주어야한다. 많은 쓰레드가 일하고 있는 상황에서 쓰레드가 계속 만들어진다고 가정하자. 사용이 다 된 ThreadLocal이 지워지지 않는다면, 그만큼 ThreadLocal에 배분된 메모리가 계속 남아있게 된다.

    따라서, 사용이 끝난 ThreadLocal은 반드시 코드상에서 삭제를 해주어 메모리 누수를 방지해야한다. 

     


    쓰레드 로컬 / 싱글톤 변수의 접근 방식 차이

    좌 : 싱글톤 인스턴스 / 우 : 쓰레드 로컬

    싱글톤 인스턴스에 동시에 접근한다고 가정해보자. 정확하게는 thread-A의 일이 끝나지 않았는데, thread-B가 싱글톤 인스턴스의 값을 수정한다고 해보자. 이 때 발생하는 문제점을 보자.

    1. Thread-A가 싱글톤 인스턴스 nameStore에 userA를 저장한다.
    2. Thread-A가 10초를 대기하기 시작한다.
    3. Thread-B가 Thread-A가 대기하는 동안 싱글톤 인스턴스 nameStore에 userB를 저장한다.
    4. Thread-B가 10초를 대기한다.
    5. Thread-A는 10초가 완료되어, nameStore에 있는 값을 읽어온다 
      •  기대값 : "userA"
      •  실제값 : "userB"
    6.  Therad-B는 10초가 완료되어, nameStore에 있는 값을 읽어온다.
      •  기대값 : "userB" 
      •  실제값 : "userB"

    싱글톤 변수에 거의 동시에 수정을 하게 되면 위처럼 Thread-A가 기대하던 값을 받지 못하는 동시성 문제가 발생한다. 이것 관련해서는 아래에서 코드로 확인해볼 예정이다.

     

    쓰레드 로컬을 동일하게 한번 확인해보자. 

    1. Thread-A가 쓰레드 로컬을 통해 Thread-A 보관소에 userA를 저장한다.
    2. Thread-A가 10초를 대기하기 시작한다.
    3. Thread-B가 쓰레드 로컬을 통해 Thread-B 보관소에 userB를 저장한다.
    4. Thread-B가 10초를 대기한다.
    5. Thread-A는 10초가 완료되어, 쓰레드 로컬을 통해 Thread-A 보관소에 있는 값을 읽어온다.
      •  기대값 : "userA"
      •  실제값 : "userA"
    6.  Therad-B는 10초가 완료되어, 쓰레드 로컬을 통해 Thread-B 보관소에 있는 값을 읽어온다.
      •  기대값 : "userB" 
      •  실제값 : "userB"

    쓰레드 로컬은 각 쓰레드 별로 서로 다른 공간이 할당되어 있기 때문에 동시성 문제가 발생하지 않았다.  아래에서 코드로 확인해본다. 

     


    싱글톤 인스턴스에서 동시성 문제 확인해보기

    싱글톤 인스턴스용 비즈니스 로직

    @Slf4j
    public class FieldService {
    
        /**
         * name을 nameStore에 저장하는 로직
         * 저장할 때 1초간 쉬고 저장한다.
         */
    
        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(millis);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
    
    }

     

    싱글톤 인스턴스용 테스트 코드

    @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(); // A 실행
    //        sleep(2000); // 동시성 문제 발생 X
            sleep(10); // 동시성 문제 발생 o
    
            threadB.start(); // B 실행
    
    
            sleep(3000);
            log.info("main exit");
    
    
        }
    
        private void sleep(int millis) {
            try {
                Thread.sleep(millis);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
        }
    
    
    }
    • 각 쓰레드는 비즈니스 로직 당 1초를 쉰다.
    • ThreadA를 시작한 후, ThreadB를 1초 안에 시작한다면 ThreadB에 의해 동시성 문제가 발생한다.
    • 반대로 ThreadB를 1초 이후에 시작한다면 동시성 문제가 발생하지 않는다. 

     


    쓰레드 로컬에서 동시성 문제 해결하기

    쓰레드 로컬 비즈니스 로직

    @Slf4j
    public class ThreadLocalService {
    
    
        private ThreadLocal<String> nameStore = new ThreadLocal<>();
    
        public void logic(String name) {
    
            log.info("저장 name = {} -> nameStore = {}", name, nameStore.get());
            nameStore.set(name);
    
            sleep(1000);
    
            log.info("조회 nameStore = {}",nameStore.get());
    
        }
    
        private void sleep(int millis) {
            try {
                Thread.sleep(millis);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
    }

     

    쓰레드 로컬 테스트 코드

    @Slf4j
    public class ThreadLocalTest {
    
        private ThreadLocalService threadLocalService = new ThreadLocalService();
    
    
        @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(); // A 실행
    //        sleep(2000); // 동시성 문제 발생 X
            sleep(10); // 동시성 문제 발생 o
    
            threadB.start(); // B 실행
    
    
            sleep(3000);
            log.info("main exit");
    
    
        }
    
        private void sleep(int millis) {
            try {
                Thread.sleep(millis);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
        }
    
    
    }
    • 각 쓰레드는 비즈니스 로직 당 1초를 쉰다.
    • ThreadA를 시작한 후, ThreadB를 1초 안에 시작해도 쓰레드 로컬로 저장 공간이 분리되었기 때문에 동시성 문제가 발생하지 않는다. 

    댓글

    Designed by JB FACTORY