
스레드 세이프에 대해서 알아보려고 한다.
우선 스레드가 무엇인지 부터
프로세스: 실행 중인 '프로그램' 그 자체. 식당으로 치면 가게 전체라고 볼 수 있다.
스레드: 프로세스 안에서 실제로 일을 하는 일꾼(점원). 하나의 가게(프로세스) 안에 여러 명의 점원(스레드)이 있을 수 있다.
하나의 프로세스(가게) 안에 여러 개의 스레드(점원)가 동시에 일을 하는 방식
싱글 스레드: 점원이 딱 한 명인 경우. 주문받고, 요리하고, 서빙하고, 계산하는 걸 혼자 다 한다. 앞 손님 요리가 늦어지면 뒤 손님은 계속 기다려야 하는 상황.
멀티 스레드: 점원이 여러 명인 경우. 한 명은 주문을 받고, 다른 한 명은 요리를 하고, 또 다른 한 명은 서빙을 한다. 손님 입장에서는 서비스가 훨씬 빠르다고 느낌.
스레드들이 사이좋게 일만 하면 좋은데, '가게의 자원'을 공유하기 때문에 문제가 생긴다. 이걸 동시성 이슈라고 부름
공유 영역 (Heap): 주방 공간이나 냉장고는 모든 점원(스레드)이 같이 쓴다.
개별 영역 (Stack): 각 점원이 머릿속으로 생각하는 것(주문서 확인 등)은 본인만 안다.
발생할 수 있는 사고 예시 (Thread-safe 하지 않은 상황)
냉장고에 우유가 1개 남았다고 가정했을 때
점원 A: "냉장고 보니 우유 있네? 손님한테 라떼 판다고 해야지." (잠시 주문서 적는 중)
점원 B: (그사이) "오, 우유 있네? 내가 꺼내서 써야지!" (우유 사용 완료)
점원 A: "자, 이제 우유 꺼내서 라떼 만들어야... 어? 우유가 어디 갔지?"
이처럼 여러 스레드가 하나의 자원을 두고 서로 먼저 수정하려고 하거나, 상태가 변한 줄 모르고 접근할 때 데이터가 꼬이게 된다
-> 개발자는 이런 사고가 안 나도록 '스레드 세이프(Thread-safe)'하게 코드를 짜야 함
자바는 이 점원(스레드)들을 아주 많이 만들 수 있도록 설계된 언어이고, 스프링은 요청이 올 때마다 점원을 하나씩 배정해 준다.
만약 내가 만든 웹 서비스에 동시에 100명이 접속하면, 스프링은 내부적으로 100명의 점원을 투입해 각자의 요청을 처리하게 한다. 이때 '공통으로 사용하는 객체(Bean)'를 점원들이 동시에 건드려도 사고가 안 나게 만드는 것이 공부의 핵심
냉장고의 우유 사고를 막으려면 점원들 사이에 약속이나 잠금장치가 필요하고 프로그래밍에서는 이를 동기화(Synchronization)라고 한다.
가장 확실한 방법은 냉장고에 자물쇠를 다는 것
-> 한 점원이 냉장고 문을 열고 우유를 꺼내는 동안에는 다른 점원이 아예 접근하지 못하게 막음.
Java의 synchronized: 메서드나 특정 코드 블록에 이 키워드를 붙이면, 한 스레드가 사용 중일 때 다른 스레드는 밖에서 대기해야 함
public class Counter {
private int count = 0;
// synchronized가 없으면 여러 스레드가 동시에 접근해 데이터가 꼬임
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
자물쇠를 잠그는 대신, 우유 개수를 확인하고 꺼내는 과정을 '단 하나의 동작'으로 합치는 방법
사고 원인: 1. 확인한다 → 2. 꺼낸다 (이 사이의 틈 때문)
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
// "확인하고 증가시킨다"를 하나의 동작으로 처리
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
사고의 원인이 '공용 냉장고' 때문이라면, 점원마다 '개인용 작은 냉장고'를 주는 방법. 비즈니스 로직상 스레드 전체에서 공유해야 하는 데이터(예: 트랜잭션 컨텍스트, 사용자 인증 정보)가 있다면 ThreadLocal을 고려할 수 있다.
개념: ThreadLocal을 사용하면 데이터가 스레드별로 격리된다. 점원 A가 자기 냉장고에 우유를 넣어도 점원 B는 그 우유를 볼 수 없고 건드릴 수도 없음
활용: 로그인한 사용자의 정보를 처리할 때 주로 사용. A 사용자의 요청을 처리하는 점원(스레드)은 오직 A의 정보만 들고 있는 방식.
주의사항: ThreadLocal을 사용한 후에는 반드시 remove()를 호출해야 한다. 스프링의 스레드 풀 환경에서는 스레드가 재사용되기 때문에, 이전 요청의 데이터가 남아서 보안 사고가 발생할 수 있음
public class UserContext {
private static final ThreadLocal<String> userHolder = new ThreadLocal<>();
public static void setUserName(String name) {
userHolder.set(name); // 현재 스레드 전용 저장소에 저장
}
public static String getUserName() {
return userHolder.get(); // 나만 꺼낼 수 있음
}
public static void clear() {
userHolder.remove(); // 사용 후 반드시 비워줘야 함 (스레드 재사용 대비)
}
}
사실 가장 고수는 사고 날 만한 공유 자원을 아예 안 만드는 것. 점원들이 공용 냉장고를 쓸 일이 없게 만듦 -> 클래스 내부에 변수를 두지 않는 것이 핵심
방법: 데이터를 점원(객체)의 주머니(필드)에 보관하지 않고, 주문서(파라미터)에 적어서 전달.
스프링의 방식: 우리가 만드는 스프링 빈(Service, Controller 등)들이 바로 이 방식을 씀. Bean은 하나지만 내부적으로 공유하는 변수가 없기 때문에 수백 명의 점원이 동시에 그 서비스를 이용해도 안전
@Service
public class OrderService {
// private Long userId; // 절대 금지! 멤버 변수는 모든 스레드가 공유함
public void processOrder(Long userId, String item) {
// 지역 변수는 각 스레드의 개별 공간(Stack)에 생기므로 안전함
System.out.println(userId + " 사용자가 " + item + "을 주문함");
}
}
우유를 꺼내 마시는 게 아니라, 아예 미개봉 새 우유 팩만 취급하는 방식. 한 번 빨대를 꽂은 우유는 남에게 주지 않고, 내용물을 바꿀 수도 없게 만드는 것
// 우유 팩 자체가 불변(Immutable)
public final class MilkPack {
private final String brand;
private final int amount; // final이라 수정 불가
public MilkPack(String brand, int amount) {
this.brand = brand;
this.amount = amount;
}
public int getAmount() { return amount; }
// 우유를 마시고 싶다면? 기존 팩을 수정하는 게 아니라
// 마시고 남은 양이 담긴 '새로운 우유 팩'을 만들어서 줌
public MilkPack drink(int take) {
return new MilkPack(this.brand, this.amount - take);
}
}
여러 점원이 동시에 사용하는 특수 냉장고. 일반 냉장고는 여러 명이 손을 집어넣으면 팔이 엉키지만, 이 냉장고는 칸칸이 나눠져 있어서(Segment) 각자 자기 칸을 열 때는 서로 기다릴 필요가 없다.
1) ConcurrentHashMap (칸막이 냉장고)
여러 종류의 우유(딸기, 초코, 흰 우유)가 있을 때, 각 우유가 든 칸을 따로 잠글 수 있는 장부
import java.util.concurrent.ConcurrentHashMap;
public class MilkStorage {
// 여러 점원이 동시에 우유 개수를 적거나 읽어도 장부가 찢어지지 않음
private final ConcurrentHashMap<String, Integer> inventory = new ConcurrentHashMap<>();
public void stockMilk(String type, int count) {
inventory.put(type, count);
}
public int getStock(String type) {
return inventory.getOrDefault(type, 0);
}
}
2) CopyOnWriteArrayList (메뉴판 복사)
냉장고에 붙은 '우유 재고 리스트'
누군가 리스트를 수정할 때(Write), 기존 리스트를 건드리지 않고 새 종이에 복사(Copy)해서 수정본을 만든다.
그동안 다른 점원들은 원래 있던 리스트를 편하게 읽는다.(Read). 수정이 끝나면 새 종이로 교체.
import java.util.concurrent.CopyOnWriteArrayList;
public class MilkMenu {
// "오늘 들어온 우유 종류"처럼 읽기는 많고 수정은 드문 데이터에 적합
private final CopyOnWriteArrayList<String> milkTypes = new CopyOnWriteArrayList<>();
public void addType(String newType) {
milkTypes.add(newType); // 수정 시 내부적으로 리스트 전체를 복사함
}
}
3) BlockingQueue
점원이 냉장고를 직접 뒤지는 게 아니라, 주방과 홀 사이에 '우유 전용 배달함'을 두는 방식
주로 생산자-소비자 패턴에서 사용.
- 생산자(주방 점원): 우유를 채워 넣는다. 함이 가득 차면 자리가 날 때까지 기다림
- 소비자(홀 점원): 우유를 꺼내간다. 함이 비어 있으면 우유가 배달될 때까지 그 앞에서 대기(Wait)
- 사고 방지: 우유가 없는데 빈 컵만 내가는 사고나, 좁은 함에 우유를 억지로 밀어 넣어 터지는 사고를 원천 차단
한쪽에서 데이터를 넣고 다른 쪽에서 뺄 때, 뺄 데이터가 없으면 스레드를 대기(Wait)시켜줌. 스프링 내부의 스레드 풀(Thread Pool)도 이 구조를 사용해 작업을 관리
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class MilkDeliverySystem {
// 최대 5개까지만 담을 수 있는 배달함 (Queue)
private final BlockingQueue<String> milkBox = new ArrayBlockingQueue<>(5);
// 생산자: 우유를 보급함
public void produceMilk(String milk) throws InterruptedException {
// 배달함이 가득 찼다면, 자리가 날 때까지 이 지점에서 스레드가 대기(Blocking)함
milkBox.put(milk);
System.out.println("주방: " + milk + " 보급 완료");
}
// 소비자: 우유를 가져감
public String consumeMilk() throws InterruptedException {
// 배달함이 비어 있다면, 우유가 들어올 때까지 이 지점에서 스레드가 대기(Blocking)함
String milk = milkBox.take();
System.out.println("홀: " + milk + " 서빙 시작");
return milk;
}
}
| 해결책 | 핵심 기술 메커니즘 | 주요 특징 및 활용 |
|---|---|---|
| Synchronized | Monitor Lock / Blocking | 특정 객체에 락을 걸어 임계 영역(Critical Section)을 동기화. 가장 확실한 제어 방식이나 컨텍스트 스위칭 비용으로 인한 성능 저하 가능성 존재. |
| Atomic 클래스 | CAS (Compare-And-Swap) | CPU 수준의 원자적 연산을 사용하여 락 프리(Lock-free)로 동작. 하드웨어가 지원하는 Non-blocking 알고리즘을 통해 단순 연산 시 최상의 성능 발휘. |
| ThreadLocal | Thread-specific Storage | 스레드 고유의 메모리 영역에 데이터를 격리 저장. 전역 변수처럼 사용하면서도 스레드 간 간섭을 원천 차단하여 인증 정보(SecurityContext) 처리에 주로 사용. |
| Stateless (권장) | Stack-based Isolation | 인스턴스 멤버 변수 대신 스택 영역에 할당되는 지역 변수와 파라미터만 사용. 상태를 공유하지 않으므로 동기화 오버헤드가 없는 스프링 빈 설계의 표준. |
| Immutable Object | Read-only Integrity | 모든 필드를 final로 선언하고 Setter를 배제하여 생성 후 상태 변경을 불허. Side-effect가 없으므로 별도의 동기화 로직 없이도 여러 스레드에서 안전하게 참조 가능 |
| Concurrent 컬렉션 | Segment Locking / CAS | ConcurrentHashMap 등 내부적으로 락 분할(Lock Stripping)이나 CAS를 활용. 전체 데이터 구조에 락을 걸지 않고 부분적으로 제어하여 멀티스레드 환경의 처리량(Throughput) 극대화. |
| BlockingQueue | Producer-Consumer / Wait-Notify | 스레드 간 안전한 데이터 전달을 위해 내부적으로 Lock과 Condition을 관리. 큐가 비거나 가득 찼을 때 스레드를 효율적으로 휴면(Sleep) 상태로 전환하여 CPU 자원 낭비를 방지함. 스레드 풀(ExecutorService)의 작업 큐로 사용됨. |