안녕하세요. J4J입니다.
이번 포스팅은 thread local을 이용하여 thread 별 독립적으로 변수 관리하는 방법에 대해 적어보는 시간을 가져보려고 합니다.
Thread Local 이란?
thread local은 각각의 thread 내부에서는 전역적으로 공유되지만 서로 다른 thread에게는 공유되지 않는 저장소를 의미합니다.
thread local은 일반적으로 thread 간 발생할 수 있는 동시성 문제를 해결하기 위해 사용될 수 있습니다.
예를 들어 서로 다른 thread가 동시에 동일한 저장소를 바라보고 있을 때, 다음과 같은 상황이 발생할 수 있습니다.
thread A 입장에서는 저장했던 데이터를 그대로 전달받아 내부 로직에 사용하고 싶었지만, 동시에 thread B의 데이터가 저장소에 저장되었기 때문에 thread A는 원하는 데이터를 확인할 수 없게 됩니다.
이런 문제가 발생될 경우 해결할 수 있는 방법은 여러 가지가 존재하겠지만 thread 간의 관계에서는 thread local을 사용하는 것도 하나의 방법이 될 수 있습니다.
thread local을 사용하게 된다면 위와 같은 상황은 다음과 같이 변경될 수 있습니다.
thread local을 사용했기 때문에 각 thread 별로 저장되는 저장소가 구분되는 것을 확인할 수 있습니다.
동일한 저장소, 동일한 저장 방식을 사용했지만 thread local이 thread 별로 각각 구분하여 요청된 행위를 수행하기 때문에 동시성 문제가 해결되는 결과를 만듭니다.
thread local은 역할에 맞게 thread 별 저장소가 구분되어야 하는 모든 경우에 사용해 볼 수 있습니다.
사용되는 기능들을 나열해 보면 사용자 인증 및 세션 관리 / 트랜잭션 관리 등이 존재합니다.
그래서 많은 분들이 spring을 이용하여 프로젝트를 진행할 때 사용자 인증/인가 처리를 위해 사용하고 있는 spring security에서도 thread local이 활용되고 있습니다.
spring security에서 사용자 정보를 보관하기 위해 활용되는 security context가 thread local을 이용하여 내부 로직이 구현되어 있습니다.
그렇기 때문에 client로부터 request가 전달될 때 thread 별 독립적으로 저장소가 관리되어 서로 다른 비즈니스 로직 처리에 영향을 주지 않게 됩니다.
Thread Local 특징
위의 내용을 기반으로 thread local이 무엇인지에 대해 알게 되었습니다.
왜 thread local을 사용하는지, 어떤 이점을 가져오는지에 대해서 확인할 수 있었지만 thread local도 사용했을 때 발생될 수 있는 단점들도 존재합니다.
그래서 thread local의 특징에 대해 간략하게 정리해 보면 다음과 같습니다.
[ 장점 ]
- thread 간 동시성 문제를 해결할 수 있음
- 간단한 코드 사용으로 데이터 공유와 관련된 문제를 해소할 수 있음
[ 단점 ]
- thread가 종료되더라도 메모리에 적재된 데이터가 남아 있어 메모리 낭비를 발생시킬 수 있음
- 여러 thread 간 공유되어야 하는 데이터가 있을 때 코드를 복잡하게 만들 수 있음
단점 중 확인될 수 있는 메모리 낭비와 관련된 이슈는 아래에서 더 자세하게 다뤄보도록 하겠습니다.
Thread Local 기본 사용 방법
이번엔 thread locald을 어떻게 사용할 수 있는지 간단하게 코드를 작성해 보겠습니다.
작성할 예시는 client로부터 전달받은 사용자 아이디를 이용하여 thread local에 사용자 정보를 저장하고, 저장되어 있는 사용자 이름을 출력해 주는 것으로 해보겠습니다.
// user dto
package com.jforj.threadlocal.dto;
import lombok.Builder;
@Builder
public record User(
int index,
String id,
String name
) {
}
// thread local instance
package com.jforj.threadlocal.threadlocal;
import com.jforj.threadlocal.dto.User;
public class UserThreadLocal {
// singleton pattern을 활용한 instance 구성
private static class UserThreadLocalHolder {
private static final ThreadLocal<User> Instance = new ThreadLocal<>();
}
public static ThreadLocal<User> getInstance() {
return UserThreadLocalHolder.Instance;
}
}
// service layer
package com.jforj.threadlocal.service;
import com.jforj.threadlocal.dto.User;
import com.jforj.threadlocal.threadlocal.UserThreadLocal;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Service
public class ThreadLocalService {
public void getName(String id) {
// 3개 thread로 동시에 비즈니스 로직 처리
ExecutorService executorService = Executors.newFixedThreadPool(3);
for (int i = 0; i < 5; i++) {
int index = i + 1;
executorService.submit(() -> {
// 전 처리
preHandle(index, id);
// thread local에서 사용자 정보 조회
User user = UserThreadLocal.getInstance().get();
System.out.println("user index is " + user.index());
System.out.println("user name is " + user.name());
});
}
executorService.shutdown();
}
private void preHandle(int index, String id) {
String name = null;
switch (id) {
case "1234": {
name = "abcd";
break;
}
}
// thread local에 사용자 정보 저장
UserThreadLocal.getInstance()
.set(
User
.builder()
.index(index)
.id(id)
.name(name)
.build()
);
}
}
코드를 확인해 보시면 3개의 thread가 동시에 비즈니스 로직을 처리하고, 매번 동작을 처리할 때 index 처리 후 사용자 이름을 매핑하여 thread local에 저장하는 것을 확인할 수 있습니다.
이 상태에서 테스트 코드를 작성하면 다음과 같이 동시성 문제가 발생하지 않는 것을 볼 수 있습니다.
package com.jforj.threadlocal.service;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ThreadLocalServiceTest {
@Autowired
private ThreadLocalService threadLocalService;
@Test
void getName() {
threadLocalService.getName("1234");
}
}
Thread Local 메모리 낭비 방지와 추가 동시성 문제
thread local의 단점이라고 얘기했던 메모리 낭비에 대해서 더 알아보도록 하겠습니다.
그리고 thread local이 동시성 문제를 해소하지만 추가적으로 발생될 수 있는 동시성 문제가 존재하는 것에 대해서도 알아보겠습니다.
thread local은 위에서 확인할 수 있는 것처럼 thread 마다 저장소를 가지고 있기 때문에 각 thread 별로 독립적인 운영이 가능합니다.
하지만 위의 코드 예시를 기준으로 발생될 수 있는 문제점은 5개의 비즈니스 로직을 3개의 thread가 처리를 수행할 때 1개의 로직 처리를 완료한 thread가 다음 로직을 처리할 때 이전 처리를 위해 저장해 뒀던 정보가 저장소에 남아있다는 것입니다.
즉, thread 가 비즈니스 로직을 처리하면서 저장해뒀던 정보가 메모리에 그대로 남아있기 때문에 메모리 낭비가 발생될 수 있습니다.
또한 동일 thread가 다음 로직을 처리할 때 저장해 놨던 정보 때문에 동시성 문제가 발생할 수 있습니다.
이를 검증하기 위해 예제 코드를 다음과 같이 변경해 보면 문제점을 바로 확인할 수 있습니다.
// user dto (이전과 동일)
// thread local instance (이전과 동일)
// service layer
package com.jforj.threadlocal.service;
import com.jforj.threadlocal.dto.User;
import com.jforj.threadlocal.threadlocal.UserThreadLocal;
import org.springframework.stereotype.Service;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Service
public class ThreadLocalService {
public void getName(String id) {
...
for (int i = 0; i < 5; i++) {
int index = i + 1;
executorService.submit(() -> {
// 이전 thread 처리 정보 확인
User prevUser = UserThreadLocal.getInstance().get();
if (prevUser != null) {
System.out.println("you already have user info");
return;
}
// 전 처리
...
});
}
...
}
private void preHandle(int index, String id) {
...
}
}
처음과 달리 user index의 값이 3까지만 출력이 되고 이후 index 값들은 보이지 않는 것을 볼 수 있습니다.
thread가 이전 처리를 하면서 저장해 놨던 정보가 저장소에 남아있었기 때문에 로직 처리가 막힌 것으로 확인됩니다.
해당 문제를 해결할 수 있는 방법은 간단합니다.
thread local과 관련된 모든 비즈니스 로직 처리가 완료되었을 때 저장소에 저장되어 있던 정보들을 모두 비워주기만 하면 됩니다.
기능을 추가한 코드를 작성해 보면 다음과 같습니다.
// user dto (이전과 동일)
// thread local instance (이전과 동일)
// service layer
package com.jforj.threadlocal.service;
import com.jforj.threadlocal.dto.User;
import com.jforj.threadlocal.threadlocal.UserThreadLocal;
import org.springframework.stereotype.Service;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Service
public class ThreadLocalService {
public void getName(String id) {
...
for (int i = 0; i < 5; i++) {
int index = i + 1;
executorService.submit(() -> {
// 이전 thread 처리 정보 확인
...
// 전 처리
...
// 후 처리
postHandle();
});
}
...
}
private void preHandle(int index, String id) {
...
}
private void postHandle() {
UserThreadLocal.getInstance().remove();
}
}
테스트 결과를 확인해 보면 이전과 다르게 user index의 값이 5까지 모두 출력되는 것을 확인할 수 있습니다.
thread local을 사용하는 경우 thread 처리가 완료되었을 때 메모리를 비워주는 행위는 중요한 역할이기 때문에 thread local을 활용하신다면 빼먹지 말고 사용하시는 것을 권장드립니다.
이상으로 thread local을 이용하여 thread 별 독립적으로 변수 관리하는 방법에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'Spring > SpringBoot' 카테고리의 다른 글
[SpringBoot] SSE (Server-Sent Events) 사용하여 실시간 통신하기 (0) | 2024.05.07 |
---|---|
[SpringBoot] @DataJpaTest In-Memory DB를 활용하여 테스트하기 (0) | 2024.05.06 |
[SpringBoot] Spring Security를 이용하여 인증/인가 처리하기 (3) - API와 JWT Token에 활용하기 (0) | 2024.02.22 |
[SpringBoot] Spring Security를 이용하여 인증/인가 처리하기 (2) - MVC와 Session에 활용하기 (0) | 2024.02.15 |
[SpringBoot] Spring Security를 이용하여 인증/인가 처리하기 (1) - 개념 및 기본 설정 (0) | 2024.02.13 |
댓글