여러 스레드가 동시에 같은 객체나 자원에 접근할 때, 예상치 못한 동작이나 데이터 손상이 발생하지 않도록 보장하는 성질
여러 스레드가 동시에 공유 자원에 접근하지 못하도록 제어하는 메커니즘
synchronized 키워드를 사용할 때, JVM이 자동으로 제공하며 모든 객체(Object)는 자동으로 모니터 락을 사용 JVM이 해당 객체의 락을 자동으로 획득/해제
어떤 메서드가 락을 걸고 있는데, 내부에서 같은 락을 사용하는 메서드를 다시 호출할 때
락이 중복으로 걸린다면 교착상태(deadlock) 발생할 수 있어 하나의 스레드가 같은 락을 여러 번 획득할 수 있는 성질
public class ReentrantExample {
public synchronized void outer() {
System.out.println("outer()");
inner(); // 내부에서도 같은 락 사용
}
public synchronized void inner() {
System.out.println("inner()");
}
}
한 스레드가 어떤 값을 수정해도 그 값이 즉시 다른 스레드에게 보이지 않을 수 있는 현상
CPU 캐시와 레지스터를 이용할때 각 스레드는 자신만의 작업 메모리에 변수 값을 복사해서 사용
해당 오래된 데이터를 스테일 데이터(Stale Data)라고 한다.
volatile은 가시성 보장을 제공한 스레드가 즉시 메인 메모리에 반영되고, 다른 스레드가 다음에 읽을 때 메인 메모리에서 최신 값을 읽도록 강제 가시성만 보장하지, 원자성(atomicity) 은 보장하지 않음private static volatile boolean running = true;
프로그램의 실행 순서가 코드에 작성된 순서와 다르게 CPU와 컴파일러에 의해 재배열 되는 현상
성능 최적화로 인해 컴파일러가 최적화를 통해 불필요한 명령 제거 및 순서 변경
병렬 실행을 통해 CPU 파이프라인 & 캐시 최적화, 자바에서는 JMM을 정의
// x = 0, y = 0 결과 가능성 존재
// why? 컴파일러나 CPU가 순서 변경할 수 도 있음
// 초기 값: x = 0, y = 0
int a = 0, b = 0;
int x = 0, y = 0;
// Thread 1
a = 1; // (1)
x = b; // (2)
// Thread 2
b = 1; // (3)
y = a; // (4)
volatile을 추가해 재배치 방지 synchronized 블록, final 필드 초기화하나의 객체(또는 데이터)를 오직 하나의 스레드에서만 접근하도록 설계 단계에서부터 제한하는 것
| 유형 | 설명 | 예시 |
|---|---|---|
| 지역 변수(Local variable confinement) | 메서드 내부 지역 변수는 기본적으로 해당 스레드에만 존재 | int sum = 0; |
| 스택 한정(Stack confinement) | 지역 변수뿐 아니라 스택에 존재하는 객체 참조 | List<String> list = new ArrayList<>(); (메서드 안에서만 사용) |
| ThreadLocal confinement | 스레드별로 독립된 값을 보관 | ThreadLocal<T> 사용 |
| 프레임워크 한정 | 특정 스레드 전용 환경(ex. Servlet 요청, Reactor pipeline 등) | HttpServletRequest, Reactive Context |
스레드의 호출 스택(Call Stack) 에 존재하는 변수(지역 변수)는 오직 그 스레드만 접근
스레드마다 완전히 독립된 변수이며 동기화가 전혀 필요 없음
객체 참조를 외부로 넘기지 않을 때만 유효
public List<String> unsafe() {
List<String> list = new ArrayList<>();
// ...
return list; // ❌ 외부로 유출됨 — 더 이상 스택 한정 아님
}
각 스레드별로 독립적인 저장 공간을 제공, 스레드마다 같은 변수 이름으로 다른 값을 저장
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
Spring은 @Transactional을 사용할 때, 현재 트랜잭션의 상태(커넥션, 동기화 여부 등)를 ThreadLocal에 저장
@Transactional 메서드 진입멀티스레드 환경에서 여러 스레드가 동시에 같은 객체를 읽더라도, 객체의 상태가 절대 변하지 않기 때문에 안전(Thread-safe) 하다는 뜻 즉, 동기화(synchronization) 없이도 안전하게공유 가능
| 구분 | 의미 | 예시 | 동기화 필요 여부 |
|---|---|---|---|
| 가변 객체 (mutable) | 상태가 변할 수 있음 | ArrayList, HashMap 등 | 필요함 (synchronized, Lock) |
| 불변 객체 (immutable) | 상태가 변하지 않음 | String, Integer, LocalDateTime 등 | 필요 없음 |
✅ 스레드 안전성 — 동기화가 필요 없고, 경쟁 상태(race condition)가 없음
✅ 단순성 — 동기화 코드가 사라져서 코드가 단순해짐
✅ 캐싱 및 공유 가능 — 같은 값을 여러 스레드에서 안전하게 재사용 가능
✅ 디버깅 쉬움 — 상태 변화가 없으므로 추적이 간단함
⚠️객체 생성 비용과 메모리 사용량이 늘어날 수 있음
JMM에서는 스레드간 가시성이 기본적으로 보장되지 않기 때문에 객체가 한 스레드에서 생성되어 다른 스레드에 안전하게 공개되는 것을 보장하는 방법
멀티스레드 환경에서 여러 스레드가 동시에 공유 자원에 접근할 때, 일관성과 안전성을 보장하기 위한 메커니즘
스레드는 동시에 같은 메모리 공간을 공유

⚠️원자성(Atomicity) - count++은 단일 연산이 아닌 읽기 -> 증가 -> 쓰기의 복합 연산이다 때문에 중간에 다른 스레드가 끼어들면 문제가 발생
⚠️가시성(Visibility) - 한 스레드의 변경을 다른 스레드가 즉시 보지 못할 수 있음(캐시등)
⚠️명령 재정렬 - 컴파일러나 CPU가 최적화를 위해 실행 순서를 바꿀 수 있음
스프링은 자바의 동기화 개념 위에 추상화된 동시성 제어를 제공
스프링의 기본 Bean 스코프는 싱글톤 즉, 하나의 인스턴스가 여러 스레드에서 동시에 사용
@Service
public class UserService {
private int counter = 0;
public void increment() {
counter++; // 위험 ❌
}
}
해결 방법
✅@Scope("prototype") 으로 Bean을 요청 단위로 분리
✅메서드 내부에 지역 변수만 사용 (Thread-safe)
✅synchronized 또는 Lock 사용
✅상태 없는(stateless) Bean 설계로 변경
트랜잭션 격리 수준(isolation level) 을 통해 DB 단위의 동시성 제어를 수행 DB에 대한 동시에 접근하는 트랜잭션 제어
스프링은 비동기(@Async)나 스케줄(@Scheduled) 작업을 스레드 풀(ThreadPoolTaskExecutor) 기반으로 실행
Spring에서는 로컬 락 대신 분산 환경에서의 분산 락을 자주 사용
▶️Redis 기반 락 -> Redisson, Lettuce
▶️DB 기반 락 → select ... for update
▶️JPA → @Lock(LockModeType.PESSIMISTIC_WRITE)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.id = :id")
User findByIdForUpdate(Long id);
| 구분 | 설명 | 동기화 주체 |
|---|---|---|
| 자바 synchronized | JVM 레벨 락 | 객체/클래스 |
| ReentrantLock | 고급 락 제어 | 객체 |
| volatile | 가시성 보장 | 변수 |
| @Transactional | DB 트랜잭션 단위 | DB |
| Spring Singleton Bean | 공유 객체로 주의 필요 | Bean 인스턴스 |
| 분산 락(Redis 등) | 멀티 서버 간 락 | 외부 저장소 |
ArrayList, HashMap, HashSet 같은 컬렉션들(Legacy)은 기본적으로 동기화되어 있지 않음
ArrayIndexOutOfBoundsException이나 데이터 손상이 발생 할 수 있음
한 번에 하나의 스레드만 컬렉션에 접근하도록 보장하는 컬렉션 래퍼(wrapper) 즉, 내부적으로 synchronized 블록으로 보호된 컬렉션
List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());
반복(iteration) 할 때는 반드시 명시적 동기화 블록으로 감싸야 안전
synchronized (list) {
for (int n : list) {
System.out.println(n);
}
}
JDK 1.5부터 등장한 java.util.concurrent 동시성(concurrency)을 고려해 설계된 더 효율적인 대안
▶️ConcurrentHashMap : 락 분할(Lock Striping)로 고성능 Map
▶️CopyOnWriteArrayList : 읽기 위주에 유리한 List :
▶️ConcurrentLinkedQueue : 비차단(Non-blocking) Queue
▶️ConcurrentSkipListMap, ConcurrentSkipListSet : 정렬 + 동시 접근 가능
| 구분 | 예시 | 동기화 방식 | 특징 |
|---|---|---|---|
| Legacy | Vector, Hashtable, Stack | 메서드 전체 synchronized | 간단하지만 느림 |
| Wrapper | Collections.synchronizedList(...) | 외부에서 synchronized wrapper 제공 | 범용성 좋지만 iterator 주의 |
| Concurrent | ConcurrentHashMap, CopyOnWriteArrayList | 세밀한 락 or Lock-free | 성능 우수, 최신 방식 |
고성능의 동시성(Map) 구현체로, 다수 스레드가 동시에 읽기/쓰기할 때 synchronized 전체 락 대신 세분화된 동기화(CAS + per-bin locking / lock-free 읽기) 로 높은 병렬성을 제공
▶️읽기 작업은 대부분 락 없이(lock-free) 수행되어 매우 빠름
▶️쓰기 작업은 CAS(비차단) + 해당 bin(버킷) 단위의 synchronized 로 처리
▶️원자적(key 단위) 연산: putIfAbsent, remove, replace, computeIfAbsent, compute, merge 등 전체 맵을 잠그지 않음
▶️ Node<K,V>[] table — 각각의 인덱스에 단일 연결 리스트(chain) 혹은 트리(트리화된 bin) 가 존재
생산자–소비자(Producer–Consumer) 패턴의 핵심이자, 스레드 간 안전하게 데이터를 교환하기 위한 큐 락이나 wait()/notify() 없이도 스레드 간 안전한 통신
▶️큐가 비어 있으면: 데이터를 꺼내려는 스레드는 대기
▶️큐가 가득 차 있으면: 데이터를 넣으려는 스레드는 대기
스레드풀(ThreadPoolExecutor) 내부에서도 BlockingQueue가 사용
▶️스레드가 바쁠 때는 큐에 작업을 put()
▶️큐가 꽉 차면 정책(예: RejectedExecutionHandler)에 따라 처리 거부
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100)
);
메소드가 즉시 반환하지 않고, 특정 조건이 충족될 때까지 현재 스레드를 멈추는(대기하는) 메소드
=> 논블로킹(Non-blocking) 또는 비동기(Asynchronous) 로 대체하려는 추세
스레드가 블로킹된 상태일 때, 다른 스레드가 interrupt() 를 호출하면 즉시 깨어나 InterruptedException을 던지는 메소드
| 구분 | 블로킹 메소드 | 인터럽터블 메소드 |
|---|---|---|
| 정의 | 조건이 충족될 때까지 스레드가 멈춤 | 블로킹 중에도 interrupt() 호출 시 깨어남 |
| 대표 예시 | Thread.sleep(), InputStream.read(), BlockingQueue.take() | Thread.sleep(), BlockingQueue.take(), Lock.lockInterruptibly() |
| 인터럽트 반응 여부 | 반응하지 않을 수도 있음 | 반드시 InterruptedException 발생 |
| 사용 목적 | 대기 상태로 CPU 낭비 방지 | 대기 중 스레드 제어 가능하게 함 |
| 사용 위치 | I/O, 큐, 락, 리소스 대기 | 스레드 종료 제어, 타임아웃 제어 |
상태 정보를 사용해 스레드 간의 작업 흐름을 조절할 수 있도록 만들어진 모든 클래스
하나 이상의 스레드가 어떤 작업이 완료될 때까지 기다리도록 하는 카운트 다운 기반 동기화 도구
카운트가 0이 되면 종료 및 다시 사용 불가, 단일/다중 스레드 동시 대기 가능
CountDownLatch latch = new CountDownLatch(3);
Runnable worker = () -> {
System.out.println(Thread.currentThread().getName() + " 작업 완료");
latch.countDown();
};
new Thread(worker).start();
new Thread(worker).start();
new Thread(worker).start();
latch.await(); // 3번 카운트 다운될 때까지 대기
System.out.println("모든 작업 완료");
비동기 작업의 결과를 표현하고, 나중에 결과를 가져올 수 있도록 하는 Future 구현체
한 번만 실행 가능하며 이미 완료된 작업은 get() 시 즉시 결과 반환
Callable<Integer> task = () -> {
Thread.sleep(1000);
return 42;
};
FutureTask<Integer> future = new FutureTask<>(task);
new Thread(future).start();
System.out.println("결과: " + future.get()); // 작업 완료될 때까지 블로킹
동시에 허용 가능한 스레드 수를 제한하는 동시성 제어 도구
공유 자원 제한 (예: DB 커넥션 풀, 쓰레드 풀 등)
Semaphore semaphore = new Semaphore(5); // 최대 5 스레드 허용
Runnable task = () -> {
try {
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + " 접근 허용");
Thread.sleep(1000);
semaphore.release();
} catch (InterruptedException e) {}
};
for (int i = 0; i < 5; i++) new Thread(task).start();
여러 스레드가 일정 지점까지 작업 완료를 기다린 후 동시에 다음 단계 진행
반복적 동기화 필요할 때 적합, 선택적으로 barrier action 지정 가능 (모든 스레드 도착 시 실행)
CyclicBarrier barrier = new CyclicBarrier(5, () -> System.out.println("모두 도착! 다음 단계 시작"));
Runnable task = () -> {
System.out.println(Thread.currentThread().getName() + " 준비 완료");
barrier.await(); // 모든 스레드 대기
};
for (int i = 0; i < 5; i++) new Thread(task).start();
| 클래스 | 블로킹 방식 | 재사용 가능 | 용도 | 특징 |
|---|---|---|---|---|
| CountDownLatch | await() 블로킹 | ❌ | 작업 완료 대기 | 한 번만 사용 가능 |
| FutureTask | get() 블로킹 | ❌ | 비동기 결과 | Callable/Runnable 기반 |
| Semaphore | acquire() 블로킹 | ✅ | 동시 접근 제한 | permit 수로 동시성 제어 |
| CyclicBarrier | await() 블로킹 | ✅ | 여러 스레드 동기화 | 반복 단계 동기화 가능, barrier action |
모든 작업을 main 스레드에서 순차 실행
public class SequentialExample {
public static void main(String[] args) {
task("A");
task("B");
task("C");
}
static void task(String name) {
System.out.println(name + " 작업 시작");
try { Thread.sleep(1000); } catch (InterruptedException e) {}
System.out.println(name + " 작업 완료");
}
}
join()으로 이전 스레드 종료 대기
public class JoinExample {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> task("A"));
Thread t2 = new Thread(() -> task("B"));
Thread t3 = new Thread(() -> task("C"));
t1.start();
t1.join(); // t1이 끝날 때까지 기다림
t2.start();
t2.join(); // t2가 끝날 때까지 기다림
t3.start();
t3.join(); // t3가 끝날 때까지 기다림
}
static void task(String name) {
System.out.println(name + " 작업 시작: " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
System.out.println(name + " 작업 완료");
}
}
내부 큐로 순서 보장하며 실행
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SingleThreadExecutorExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
// 작업 큐에 순서대로 쌓은 후 하나씩 실행
executor.submit(() -> task("A"));
executor.submit(() -> task("B"));
executor.submit(() -> task("C"));
executor.shutdown();
}
static void task(String name) {
System.out.println(name + " 작업 시작: " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
System.out.println(name + " 작업 완료");
}
}
| 항목 | 설명 |
|---|---|
| ① 긴 작업(Blocking) | 하나의 스레드가 모든 요청을 처리하므로, 한 작업이 오래 걸리면 전체가 멈춤 |
| ② CPU 점유율 불균형 | 하나의 코어만 사용하므로 멀티코어 CPU 성능 활용 불가 |
| ③ UI/이벤트 블로킹 | (특히 GUI) 한 스레드에서 UI 갱신과 로직을 같이 처리하면 화면 멈춤 현상 |
| ④ 예외 발생 시 전체 종료 | 예외가 잡히지 않으면 프로그램 전체가 중단됨 |
| ⑤ 테스트 중 착각 | 단일 스레드에서는 잘 동작하지만, 멀티 환경에서 race condition이 숨겨질 수 있음 |
public class ParallelWithThread {
public static void main(String[] args) {
Thread t1 = new Thread(() -> task("A"));
Thread t2 = new Thread(() -> task("B"));
Thread t3 = new Thread(() -> task("C"));
t1.start();
t2.start();
t3.start();
}
static void task(String name) {
System.out.println(name + " 시작: " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
System.out.println(name + " 완료");
}
}
import java.util.concurrent.*;
public class ParallelExecutor {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 1; i <= 5; i++) {
int id = i;
executor.submit(() -> task(id));
}
executor.shutdown();
}
static void task(int id) {
System.out.println("작업 " + id + " 실행 중: " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
System.out.println("작업 " + id + " 완료");
}
}
import java.util.stream.IntStream;
public class ParallelStreamExample {
public static void main(String[] args) {
IntStream.rangeClosed(1, 10)
.parallel() // 병렬 모드로 전환
.forEach(i -> {
System.out.println("작업 " + i + " 실행 중: " + Thread.currentThread().getName());
});
}
}
import java.util.concurrent.*;
public class ParallelCompletableFuture {
public static void main(String[] args) {
CompletableFuture<Void> f1 = CompletableFuture.runAsync(() -> task("A"));
CompletableFuture<Void> f2 = CompletableFuture.runAsync(() -> task("B"));
CompletableFuture<Void> f3 = CompletableFuture.runAsync(() -> task("C"));
CompletableFuture.allOf(f1, f2, f3).join(); // 모두 끝날 때까지 대기
System.out.println("모든 작업 완료!");
}
static void task(String name) {
System.out.println(name + " 시작: " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
System.out.println(name + " 완료");
}
}
| 항목 | 설명 |
|---|---|
| ① 공유 자원 접근 문제 (Race Condition) | 여러 스레드가 동시에 같은 변수를 수정하면 데이터가 꼬임 |
| ② 가시성(Visibility) | 한 스레드가 변경한 값이 다른 스레드에 즉시 보이지 않을 수 있음 |
| ③ 원자성(Atomicity) | 복합 연산(x = x + 1)은 중간 상태에서 끼어들 수 있음 |
| ④ 교착상태(Deadlock) | 여러 스레드가 서로 락을 기다리며 멈춤 |
| ⑤ 스레드 폭발(Thread Explosion) | 작업마다 스레드를 무한 생성 → 메모리 부족 / Context Switching 과다 |
| ⑥ 스케줄링 예측 불가 | 스레드 실행 순서는 JVM과 OS 스케줄러에 따라 매번 다름 |
| ⑦ 예외 전파 문제 | 스레드 내부에서 던진 예외는 main으로 전파되지 않음 |
| ⑧ 테스트 복잡도 증가 | 타이밍 이슈, 동기화 문제로 재현 어려움 |
하나의 스레드가 요청을 순서대로 처리
▶️한 번에 하나의 요청만 처리
▶️요청 처리 중이면 다음 요청은 대기
▶️간단하지만 성능이 매우 낮음
public class SingleThreadWebServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("단일 스레드 서버 시작");
while (true) {
Socket socket = serverSocket.accept(); // 요청이 올 때까지 대기 (Blocking)
handleRequest(socket); // 요청 하나 처리 완료 후 다음 요청 처리
}
}
private static void handleRequest(Socket socket) throws IOException {
System.out.println("요청 처리 중: " + socket);
try (socket) {
socket.getOutputStream().write("200OK");
}
}
}
▶️요청마다 새 스레드가 생성
▶️동시에 여러 요청을 처리 가능
▶️메모리 부족 & 컨텍스트 스위칭 과다 발생
public class MultiThreadWebServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("멀티 스레드 서버 시작");
while (true) {
Socket socket = serverSocket.accept(); // 요청 수신
new Thread(() -> handleRequest(socket)).start(); // 요청마다 새 스레드 생성
}
}
private static void handleRequest(Socket socket) {
try (socket) {
System.out.println("요청 처리 중: " + Thread.currentThread().getName());
socket.getOutputStream().write("200OK");
} catch (IOException e) {
e.printStackTrace();
}
}
}
스레드 풀로 미리 일정 수의 스레드를 만듬
import java.io.*;
import java.net.*;
import java.util.concurrent.*;
public class ThreadPoolWebServer {
public static void main(String[] args) throws IOException {
ExecutorService executor = Executors.newFixedThreadPool(5); // 스레드 5개
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("스레드풀 서버 시작");
while (true) {
Socket socket = serverSocket.accept(); // 요청 수신
executor.submit(() -> handleRequest(socket));
}
}
private static void handleRequest(Socket socket) {
try (socket) {
System.out.println("요청 처리 중: " + Thread.currentThread().getName());
socket.getOutputStream().write("200OK");
} catch (IOException e) {
e.printStackTrace();
}
}
}
스레드를 매번 새로 만들고 버리는 대신, 미리 만들어둔(또는 재활용 가능한) 스레드들을 모아놓은 풀(Pool)
ExecutorService pool = new ThreadPoolExecutor(
5, // corePoolSize : 항상 유지되는 기본 스레드 개수
10, // maximumPoolSize : 최대 허용 스레드 개수
60L, TimeUnit.SECONDS, // keepAliveTime : idle 스레드 유지 시간
new LinkedBlockingQueue<>(100), // 작업 큐
Executors.defaultThreadFactory(), 스레드 생성 방법 지정
new ThreadPoolExecutor.AbortPolicy() // 큐 초과 시 예외
);