이전 글에서 Runnable 인터페이스와 Thread 클래스를 사용하여 자바에서 main 스레드 외에 스레드를 직접 만들어서 사용하는 방법을 공부했다.
이번에는 스레드를 활용할 때 생길 수 있는 문제인 동시성 문제에 대해서 공부해볼 것이다.
UserService 객체를 통해 UserId를 등록하면 등록한 userId를 로그로 남겨주는 코드이다.
@Slf4j
public class UserService {
private String userId;
public void setUserId(String userId) {
this.userId = userId;
selfIntro();
}
public void selfIntro(){
log.info(" 등록한 유저 Id는 {}",userId);
}
}
스레드 2개를 만들어서 UserService를 실행시켜보자
public class ThreadLocalExample {
public static void main(String[] args) {
UserService userService = new UserService();
Runnable userA = () -> {
userService.setUserId("userA");
};
Runnable userB = () -> {
userService.setUserId("userB");
};
Thread threadA = new Thread(userA);
threadA.setName("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start();
threadB.start();
}
}
=== 실행 결과 ===
[thread-A] INFO hello.advanced.test.UserService -- 등록한 유저 Id는 userA
[thread-B] INFO hello.advanced.test.UserService -- 등록한 유저 Id는 userB
자, 이것을 웹 애플리케이션에서 쓴다고 상상해보자. 그렇기에 동시 요청이 올 경우 0.001초 딜레이가 생긴다고 가정하자.
@Slf4j
public class UserService {
private String userId;
public void setUserId(String userId) {
this.userId = userId;
sleep(1);
selfIntro();
}
public void selfIntro(){
log.info(" 등록한 유저 Id는 {}",userId);
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
이렇게 수정 될 경우 ThreadLocalExample 실행하면 어떻게 될까?
[thread-B] INFO hello.advanced.test.UserService -- 등록한 유저 Id는 userB
[thread-A] INFO hello.advanced.test.UserService -- 등록한 유저 Id는 userB
thread-A도 등록한 유저Id를 userB라고 하게 된다. UserService 객체의 userId는 thread-A와 thread-B가 공유하고 있다. thread-A가 실행되고 setUserId가 실행되고 잠시 0.001초에 실행되던 thread-B가 userId를 바꾼것이다.
결과적으로 thread-A 입장에서는 userB의 아이디를 소개하는 문제가 발생했다. 이처럼 여러 쓰레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제를 동시성 문제라한다. 동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 적은 상황에서는 확률상 잘 나타나지 않고, 트래픽이 점점 많아 질 수 록 자주 발생한다. 특히 스프링 빈 처럼 싱글톤 객체의 필드를 변경하면서 사용할 때 동시성 문제를 조심해야 한다.
참고로 동시성 문제는 지역 변수에서는 발생하지 않는다. 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당되기 때문이다. 동시성 문제는 주로 인스턴스의 필드나 static 같은 공용 필드에 접근할 때 발생한다. 또한 동시성 문제는 값을 읽기만 한다면 발생하지 않는다. 어디선가 값을 변경하기 때문에 발생하며 이는 곧 사이드 이펙트가 발생할 여지가 생기는 것이다.
멀티 스레드 사용시 동시성 문제가 발생하는 경우 어떻게 해결하면 될까?
ThreadLocal을 각 스레드의 물품 보관함과 같다. 각 스레드에서 발생한 데이터를 각 스레드만 쓸 수 있는 물품 보관함에 데이터를 넣어두는 것과 같은 것이다.
바로 코드로 봐보자
@Slf4j
public class ThreadLocalService {
private ThreadLocal<String> userIds = new ThreadLocal<>();
public void setUserId(String userId) {
userIds.set(userId);
sleep(1);
selfIntro();
}
public void selfIntro(){
log.info(" 등록한 유저 Id는 {}",userIds.get());
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
ThreadLocalService 객체를 가진 ThreadLocalExample2를 실행시키면
public class ThreadLocalExample2 {
public static void main(String[] args) {
ThreadLocalService userService = new ThreadLocalService();
Runnable userA = () -> {
userService.setUserId("userA");
};
Runnable userB = () -> {
userService.setUserId("userB");
};
Thread threadA = new Thread(userA);
threadA.setName("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start();
threadB.start();
}
}
=== 실행 결과 ===
[thread-B] INFO hello.advanced.test.ThreadLocalService -- 등록한 유저 Id는 userB
[thread-A] INFO hello.advanced.test.ThreadLocalService -- 등록한 유저 Id는 userA
thread-A도 userIds에 userA를 넣고 thread-B도 userIds에 userB를 넣는다. 그러나 각 스레드는 ThreadLocal 객체인 userIds 통해 자신만의 독립적인 값을 유지한다. 따라서 userIds.get()을 하게 되면 thread-A가 저장한 userA, thread-B가 저장한 userB 값을 가져올 수 있는 것이다.
정리
동시성 문제르 해결하기 위한 ThreadLocal에 대해서 공부했다. ThreadLocal은 장점만 있는 것인가? 그렇지 않다. 어떤 것이든 트레이드 오프가 있기 마련이다. 다음에는 ThreadLocal사용시 주의할 점에 대해서 공부할 것이다.
꾸준히 성장하고 계시는군요! 대단하십니다!