멀티 쓰레드와 동시성에 대한 공부를 하던 중 쓰레드 로컬(Thread Local)이라는 것을 알게 되었습니다. 설명을 읽어보면 분명히 좋아 보이는 것 같기는 한데 개발자가 직접 비즈니스 로직에 사용할 일이 있을까?에 대한 의문이 들었습니다.
이에 쓰레드 로컬을 직접 사용하는 코드를 찾던 중, Spring Security 프로젝트의 ThreadLocalSecurityContextHolderStrategy.java 에서 사용한다는 것을 알게 되었는데요.
(이름이 왜 이렇게 길어, 스프링도 길게 쓰는데 나도 클래스명 길게 만들어야지)
매우 거대한 스프링 프로젝트에서도 보기 드문 쓰레드 로컬은 제 생각대로 개발자가 직접 다룰일은 거의 없어 보이지만 동기화 관련 학습을 하던 중 발생한 의문을 쓰레드 로컬로 해결하였기에 그 과정을 글로 작성해 보았습니다.
ThreadA
와 ThreadB
가 동시에 공통된 자원 value
의 값을 변경한다면 어떻게 될까요?
ThreadA
가 value
를 조회 했을 때, 5가 나오길 기대했지만 실제 결과는 5가 나올 수도 있고 10 이 나올 수도 있습니다.
이는 동기화를 하지 않았으므로 당연한 결과입니다.
위 과정을 코드로 나타내보겠습니다.
value
가 인스턴스 변수로 있는 Resuorce
클래스 입니다.
public class Resource {
private int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
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
클래스 입니다.
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();
}
}
실행 결과는 어떻게 될까요?
가장 일반적인 케이스입니다.
ThreadA : 5
ThreadB : 10
쓰레드는 실행 순서를 보장하지 않으므로 ThreadB가 먼저 출력될 수 있습니다.
ThreadB : 10
ThreadA : 5
ThreadA의 setValue(5) 호출을 ThreadB의 setValue(10) 호출로 덮어버린다면 아래와 같이 출력됩니다.
ThreadB : 10
ThreadA : 10
반대로 ThreadB의 setValue(10) 호출을 ThreadA가 setValue(5) 호출로 덮어버린다면 아래와 같이 출력됩니다.
ThreadB : 5
ThreadA : 5
이를 해결할 방법이 없을까요?
위 문제는 멀티 쓰레드 환경의 동시성 문제를 잘 나타내고 있습니다.
두 개의 쓰레드 ThreadA
, ThreadB
가 공유 자원인 Resource
클래스의 value
를 동시에 수정하고 읽기 때문에 예측할 수 없는 결과가 나타나고 있는 것이죠.
이를 해결하기 위해서는 value
를 동기화하면 됩니다.
= 유식한 말로는 임계 구역(Critical Section)을 생성하면 됩니다.
자바에서 동기화를 하는 가장 간단한 방법입니다.
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
모든 문제가 해결되었으니 행복하게 마무리하면 좋겠지만 문제가 있습니다.
쓰레드의 실행 과정 중에 특수한 로직이 추가되었습니다.
setValue()
메서드 호출getValue()
메서드 호출이 특수한 로직은 파일 읽기, 단순 연산, 복잡한 연산 등 어떤 것이든지 될 수 있습니다. 다만 N초가 걸릴 뿐입니다.
ThreadA
가 특수한 로직을 처리하는데 3초의 시간이 소요된다고 가정해봅시다.
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
아래의 시나리오를 따르기 때문인데요.
logic()
메서드에서는 ThreadA
가 설정한 값인 5를 사용하고 싶은데, 특수한 로직이 실행되는 시간에 ThreadB
가 값을 10으로 변경하여 사용할 수 없게 된 것이죠.
이를 해결할 수 있는 방법이 뭐가 있을까요?
해결 방법은 간단합니다.
logic()
메서드 자체에 동기화를 적용하면 됩니다.
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
아래의 시나리오를 따르기 때문입니다.
원하는 결과를 얻었으니 만족해야 할까요?
여기서도 문제가 있습니다.
ThreadB
는 ThreadA
의 로직이 끝날 때 까지 대기를 해야합니다.
지금은 3초니까 괜찮아도 이게 1시간, 2시간이 되면 곤란하지 않을까요?
이를 해결하기 위해 ThreadLocal
이 등장합니다.
쓰레드 로컬(Thread Local)은 각각의 쓰레드 별로 값을 할당할 수 있게 해줍니다.
아래의 그림과 같이 ThreadA
가 5를 할당하고, ThreadB
가 10을 할당하게 된다면 위에서 알아본 것 처럼 동시성 문제가 발생합니다.
이 때, ThreadLocal
을 사용한다면 어떻게 될까요?
아래와 같이 쓰레드 별 독립된 값을 할당할 수 있도록 해줍니다.
이렇게 된다면, 각 쓰레드별로 할당한 값을 그대로 가져올 수 있습니다.
자 그러면 ThreadLocal
을 이용하여 문제를 해결해봅시다.
Resource
클래스의 value
를 ThreadLocal
을 사용하도록 변경합니다.
또한 각각의 쓰레드 별로 별도의 공간을 제공하므로 임계 구역을 만들 필요도 없습니다. 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
이로써 동시성 문제를 해결하면서 각 쓰레드가 할당한 값을 대기 시간 없이 저장할 수 있게 되었습니다.
위에서 알아본 근본적인 문제점들은 전부 멀티 쓰레드 환경에서 공유 가능한 상태를 만들었기 때문에 발생합니다.
마치 스프링 부트 기반의 웹 애플리케이션 서버의 코드 중 서비스 클래스에 인스턴스 변수가 사용자의 요청에 따라 계속해서 변하는 상황으로 볼 수 있겠습니다.
애초에 공유 가능한 상태를 만들지 않도록 설계하는 것이 가장 이상적일 것이라 생각되지만, 세상 만사가 생각하는 대로 이루어지는 것은 아니라서 이를 해결하는 방법을 알아둬야 합니다.
감사합니다.