동시성 문제와 ThreadLocal

땡글이·2023년 1월 18일
0

Spring Framework

목록 보기
5/8

동시성 문제는 멀티 스레드 환경에서 여러 스레드가 하나의 자원에 접근했을 때 발생할 수 있는 문제이다. 물론 여러 스레드가 자원에 접근해서 읽기 작업만을 수행한다면 문제가 없지만, 쓰기 & 수정 작업이 이뤄진다면 문제가 있다.

동시성 문제가 발생하는 상황은 다음과 같다.

  • 싱글톤 스코프의 빈 객체의 상태(클래스 변수 혹은 인스턴스 변수)에 여러 스레드가 접근해 수정하려는 상황
  • DB에서 여러 트랜잭션이 동시에 하나의 튜플에 대해 수정작업하려는 상황

이 글에서는 "싱글톤 스코프의 빈 객체의 상태(클래스 변수 혹은 인스턴스 변수)에 여러 스레드가 접근해 수정하려는 상황" 에 대해서 어떻게 동시성 문제를 해결할 수 있는지 확인해보도록 한다.

여러 쓰레드가 지역 변수에 접근하는 것에는 동시성 문제가 발생하지 않는다. 왜?? 쓰레드는 Stack 영역은 별도로 존재하고, 그 이외의 영역을 다른 쓰레드와 공유하기 때문이다.

  • **클래스 변수 (static Variable)**
    • 클래스 영역에 위치한 (메서드 안이 아닌) 변수 중 static 제어자를 가진 변수를 클래스 변수라고 한다.
    • static 제어자가 붙으면 클래스 내에서 단 한번만 생성된다.
    • 해당 클래스 내의 모든 인스턴스가 공유해야 하는 값을 유지할 때 사용한다.
  • **인스턴스 변수 (instance Variable)**
    • 클래스 영역에 위치한 (메서드 안이 아닌) 변수 중 static 제어자를 가지지 않은 변수를 인스턴스 변수라고 한다.
    • 인스턴스 즉 객체마다 가져야 하는 고유의 값을 주기 위해 사용한다.
    • 클래스 변수와 달리 인스턴스 변수를 생성 시 인스턴스에 맞게 초기화된 변수가 새롭게 주어진다.
  • **지역변수 (local Variable)**
    • 클래스 영역 안에서 메서드, 생성자, 초기화 블럭 안에 있는 변수를 지역 변수라고 한다.
    • 지역 변수는 해당 변수가 사용된 메서드, 생성자 내에서만 사용되고 밖을 벗어나면 소멸된다.

동시성 문제 발생 예시


@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(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
@Slf4j
public class FieldServiceTest {
    private FieldService fieldService = new FieldService();

    @Test
    void field() {
        log.info("main start");

        // 스레드 2개 만듦.
        // 아래 람다식은 위와 같음.
        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);    // 동시성 문제 발생X (로직 내에서는 1초 지연만 있기 때문에 겹칠 일 없음)
        sleep(100);  // 동시성 문제 발생O (겹치게 됨)
        threadB.start();
        sleep(3000);    // 메인 스레드 종료 대기
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException exception) {
            exception.printStackTrace();
        }
    }
}

Thread-A가 비즈니스 로직을 통해, fieldService 라는 객체의 인스턴스 변수(nameStore)에 접근해 바꾸려한다. 그리고 1초 뒤, 조회 로그를 찍는다. 만약Thread-B가 fieldService 객체에 접근하기 전, Thread-A가 조회 작업까지 마치고 나간다면 문제가 생기지 않겠지만 위의 그림처럼 Thread-B가 Thread-A가 nameStore에 있는 값을 조회하기 전 수정한다면 Thread-A와 Thread-B 모두 nameStore에 userB라는 이름이 저장돼있는 것을 확인할 것이다.

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

스프링의 싱글톤 스코프의 빈처럼 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야 한다. 웬만하면 빈 객체에는 인스턴스 변수 혹은 클래스 변수를 사용하지 않는 습관을 들이자. 만약 사용해야 한다면 final 키워드를 이용하도록 하자!!

동시성 문제 해결방법 - ThreadLocal

만약 상황 상 싱글톤 객체에 클래스 변수(static) 혹은 인스턴스 변수를 두어야 하는 상황이라면, 어떻게 동시성 문제를 해결할 수 있을까? 답은 ThreadLocal 을 이용하면 된다!

ThreadLocal은 각각의 쓰레드마다 별도의 내부 저장소를 제공함으로써 쓰레드 간 동시성 문제를 막아준다. 그림으로 보면 아래와 같다.

ThreadLocal 내부 구현

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        map.set(this, value);
    } else {
        createMap(t, value); 
    }
}

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null) {
        m.remove(this);
    }
}

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

ThreadLocal 클래스는 public 메서드 get, set, remove 함수들을 지원하고 있다.

  • get() : thread 별로 저장해둔 값 혹은 객체를 리턴해준다.
  • set(T value) : thread 별로 가지고 있는 내부 저장소를 활용해 값 혹은 객체를 저장한다.
  • remove() : 해당 thread가 가지고 있는 저장소를 release 해주며 자원을 반납한다.

ThreadLocal 클래스에서 쓰레드 별로 쓰레드를 구분할 수 있는 이유는 ThreadLocalMap 클래스에 있다.

  • ThreadLocalMap 클래스는 ThreadLocal 클래스의 정적 내부 클래스다. 모두 private 클래스로 구성되어 있어 외부에서 접근 가능한 메서드가 없으며, 내부적으로 해시 테이블 정보를 갖는데, 요소는 WeakReference를 확장하고 ThreadLocal 객체를 키로 사용하는 Entry 클래스다.
    • Entry(엔트리) 클래스란, 키와 값으로 구성되는 데이터를 의미한다. Mapping(매핑)이라고 부르기도 한다.
    • WeakReference 객체는, GC와 관련된 개념이다. 보통 new 키워드를 통해 생성된 객체는 강한 Reference가 적용되지만, WeakReference 로 생성된 객체는 약한 Reference가 적용된다. GCWeakReference 로 생성된 객체는 잠깐 쓰이는 것으로 인식해 빠르게 삭제해 메모리를 최적화한다.

ThreadLocal 사용 방법

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

위의 코드는 아까 봤던 FieldService 클래스의 코드와 같은 기능을 하지만, 동시성 문제를 해결한 클래스인 ThreadLocalService이다.
ThreadLocal.set(T value) 을 이용해서 nameStore 변수에는 쓰레드별로 다른 값이 저장될 수 있도록 구현되었습니다. 조회 시에는 ThreadLocal.get() 메서드를 통해 각 쓰레드가 저장한 값을 리턴받습니다.

@Slf4j
public class ThreadLocalServiceTest {

    private ThreadLocalService service = new ThreadLocalService();

    @Test
    void field() {
        log.info("main start");

        // 스레드 2개 만듦.
        // 아래 람다식은 위와 같음.
        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);  // 동시성 문제 발생X (ThreadLocal을 사용함으로써 동시성 문제 해결)
        threadB.start();
        sleep(3000);    // 메인 스레드 종료 대기
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException exception) {
            exception.printStackTrace();
        }
    }
}


위의 결과 출력문을 보면, 쓰레드별로 잘 저장되고 조회되는 것을 확인할 수 있다.

주의사항

위의 테스트코드에서는 잠깐 실험용 코드라 remove() 함수를 호출하지 않았지만, 해당 스레드에서 값을 다 사용하고 난 뒤에는 remove() 함수를 잊지말고 호출해서 ThreadLocal, 자세히 말하자면 ThreadLocalMap에 저장된 값을 지워줘야 한다.
만약 remove()를 호출하지 않는다면, WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우에는 심각한 문제가 발생할 수 있다.

쓰레드 풀은 쓰레드를 새로 생성하지 않고, 쓰레드를 재사용함으로써 쓰레드 생성 비용, 제거 비용을 줄인 방법이다.


위의 그림처럼, 쓰레드를 재사용하게 되어 Thread-B에서 Thread-A에 접근해 값을 잘못 조회하게 되는 불상사가 있을 수 있다. 사용이 끝난 뒤에는 항상 ThreadLocal.remove() 함수 호출해주자!

Reference

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/dashboard
https://velog.io/@noakafka/Spring-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C
https://crazy-horse.tistory.com/entry/%EC%9E%AC%EA%B3%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9C%BC%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95
https://madplay.github.io/post/java-threadlocal
https://junlab.tistory.com/234

profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

0개의 댓글