Thread-safe

Elena·2026년 1월 26일
post-thumbnail

스레드 세이프에 대해서 알아보려고 한다.
우선 스레드가 무엇인지 부터

프로세스 vs 스레드

  • 프로세스: 실행 중인 '프로그램' 그 자체. 식당으로 치면 가게 전체라고 볼 수 있다.

  • 스레드: 프로세스 안에서 실제로 일을 하는 일꾼(점원). 하나의 가게(프로세스) 안에 여러 명의 점원(스레드)이 있을 수 있다.

멀티스레드(Multi-thread)란?

하나의 프로세스(가게) 안에 여러 개의 스레드(점원)가 동시에 일을 하는 방식

  • 싱글 스레드: 점원이 딱 한 명인 경우. 주문받고, 요리하고, 서빙하고, 계산하는 걸 혼자 다 한다. 앞 손님 요리가 늦어지면 뒤 손님은 계속 기다려야 하는 상황.

  • 멀티 스레드: 점원이 여러 명인 경우. 한 명은 주문을 받고, 다른 한 명은 요리를 하고, 또 다른 한 명은 서빙을 한다. 손님 입장에서는 서비스가 훨씬 빠르다고 느낌.

왜 멀티스레드가 어려울까?

스레드들이 사이좋게 일만 하면 좋은데, '가게의 자원'을 공유하기 때문에 문제가 생긴다. 이걸 동시성 이슈라고 부름

  • 공유 영역 (Heap): 주방 공간이나 냉장고는 모든 점원(스레드)이 같이 쓴다.

  • 개별 영역 (Stack): 각 점원이 머릿속으로 생각하는 것(주문서 확인 등)은 본인만 안다.

  • 발생할 수 있는 사고 예시 (Thread-safe 하지 않은 상황)
    냉장고에 우유가 1개 남았다고 가정했을 때

점원 A: "냉장고 보니 우유 있네? 손님한테 라떼 판다고 해야지." (잠시 주문서 적는 중)
점원 B: (그사이) "오, 우유 있네? 내가 꺼내서 써야지!" (우유 사용 완료)
점원 A: "자, 이제 우유 꺼내서 라떼 만들어야... 어? 우유가 어디 갔지?"

이처럼 여러 스레드가 하나의 자원을 두고 서로 먼저 수정하려고 하거나, 상태가 변한 줄 모르고 접근할 때 데이터가 꼬이게 된다
-> 개발자는 이런 사고가 안 나도록 '스레드 세이프(Thread-safe)'하게 코드를 짜야 함

자바/스프링에서의 멀티스레드

자바는 이 점원(스레드)들을 아주 많이 만들 수 있도록 설계된 언어이고, 스프링은 요청이 올 때마다 점원을 하나씩 배정해 준다.

만약 내가 만든 웹 서비스에 동시에 100명이 접속하면, 스프링은 내부적으로 100명의 점원을 투입해 각자의 요청을 처리하게 한다. 이때 '공통으로 사용하는 객체(Bean)'를 점원들이 동시에 건드려도 사고가 안 나게 만드는 것이 공부의 핵심

핵심 전략

냉장고의 우유 사고를 막으려면 점원들 사이에 약속이나 잠금장치가 필요하고 프로그래밍에서는 이를 동기화(Synchronization)라고 한다.

1. 줄 세우기 (잠금, Lock)

가장 확실한 방법은 냉장고에 자물쇠를 다는 것
-> 한 점원이 냉장고 문을 열고 우유를 꺼내는 동안에는 다른 점원이 아예 접근하지 못하게 막음.

Java의 synchronized: 메서드나 특정 코드 블록에 이 키워드를 붙이면, 한 스레드가 사용 중일 때 다른 스레드는 밖에서 대기해야 함

public class Counter {
    private int count = 0;

    // synchronized가 없으면 여러 스레드가 동시에 접근해 데이터가 꼬임
    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
  • 단점: 점원들이 줄을 너무 길게 서면 전체적인 서비스 속도가 느려짐. (성능 저하)

2. 번호표 받기 (원자적 연산, Atomic)

자물쇠를 잠그는 대신, 우유 개수를 확인하고 꺼내는 과정을 '단 하나의 동작'으로 합치는 방법

사고 원인: 1. 확인한다 → 2. 꺼낸다 (이 사이의 틈 때문)

  • 해결책 (Atomic): "확인하고, 있으면 바로 가져간다"라는 동작을 쪼개지지 않는 하나의 단위로 만든다. 자바의 AtomicInteger 같은 클래스가 이 역할을 하며, 내부적으로 CPU의 도움을 받아 아주 빠르게 처리됨
    -> synchronized보다 성능이 좋으며, 숫자 계산 같은 단순 작업에 최적화
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();
    }
}

3. 개인 비품실 만들기 (ThreadLocal)

사고의 원인이 '공용 냉장고' 때문이라면, 점원마다 '개인용 작은 냉장고'를 주는 방법. 비즈니스 로직상 스레드 전체에서 공유해야 하는 데이터(예: 트랜잭션 컨텍스트, 사용자 인증 정보)가 있다면 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(); // 사용 후 반드시 비워줘야 함 (스레드 재사용 대비)
    }
}

4. 공유 자원 없애기 (무상태, Stateless)

사실 가장 고수는 사고 날 만한 공유 자원을 아예 안 만드는 것. 점원들이 공용 냉장고를 쓸 일이 없게 만듦 -> 클래스 내부에 변수를 두지 않는 것이 핵심

  • 방법: 데이터를 점원(객체)의 주머니(필드)에 보관하지 않고, 주문서(파라미터)에 적어서 전달.

  • 프링의 방식: 우리가 만드는 스프링 빈(Service, Controller 등)들이 바로 이 방식을 씀. Bean은 하나지만 내부적으로 공유하는 변수가 없기 때문에 수백 명의 점원이 동시에 그 서비스를 이용해도 안전

@Service
public class OrderService {
    
    // private Long userId; // 절대 금지! 멤버 변수는 모든 스레드가 공유함

    public void processOrder(Long userId, String item) {
        // 지역 변수는 각 스레드의 개별 공간(Stack)에 생기므로 안전함
        System.out.println(userId + " 사용자가 " + item + "을 주문함");
    }
}

5. 불변 객체 (Immutable Object)

우유를 꺼내 마시는 게 아니라, 아예 미개봉 새 우유 팩만 취급하는 방식. 한 번 빨대를 꽂은 우유는 남에게 주지 않고, 내용물을 바꿀 수도 없게 만드는 것

// 우유 팩 자체가 불변(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);
    }
}
  • 결과: 점원 A가 우유를 마셔도 점원 B가 들고 있는 우유 팩의 양은 변하지 않는다. 각자 자기 손에 든 팩만 보기 때문에.

6. 동시성 컬렉션 (Concurrent Collections)

여러 점원이 동시에 사용하는 특수 냉장고. 일반 냉장고는 여러 명이 손을 집어넣으면 팔이 엉키지만, 이 냉장고는 칸칸이 나눠져 있어서(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 알고리즘을 통해 단순 연산 시 최상의 성능 발휘.
ThreadLocalThread-specific Storage스레드 고유의 메모리 영역에 데이터를 격리 저장. 전역 변수처럼 사용하면서도 스레드 간 간섭을 원천 차단하여 인증 정보(SecurityContext) 처리에 주로 사용.
Stateless (권장)Stack-based Isolation인스턴스 멤버 변수 대신 스택 영역에 할당되는 지역 변수와 파라미터만 사용. 상태를 공유하지 않으므로 동기화 오버헤드가 없는 스프링 빈 설계의 표준.
Immutable ObjectRead-only Integrity모든 필드를 final로 선언하고 Setter를 배제하여 생성 후 상태 변경을 불허. Side-effect가 없으므로 별도의 동기화 로직 없이도 여러 스레드에서 안전하게 참조 가능
Concurrent 컬렉션Segment Locking / CASConcurrentHashMap 등 내부적으로 락 분할(Lock Stripping)이나 CAS를 활용. 전체 데이터 구조에 락을 걸지 않고 부분적으로 제어하여 멀티스레드 환경의 처리량(Throughput) 극대화.
BlockingQueueProducer-Consumer / Wait-Notify스레드 간 안전한 데이터 전달을 위해 내부적으로 Lock과 Condition을 관리. 큐가 비거나 가득 찼을 때 스레드를 효율적으로 휴면(Sleep) 상태로 전환하여 CPU 자원 낭비를 방지함. 스레드 풀(ExecutorService)의 작업 큐로 사용됨.
profile
一切唯心造

0개의 댓글