
1️⃣ 스레드(Thread)란?
- 정의: 프로세스(실행 중인 프로그램) 내에서 실제로 작업을 수행하는 가장 작은 실행 단위입니다.
- 특징:
- 모든 프로그램은 최소한 하나의 스레드(메인 스레드)를 가집니다.
- 같은 프로세스 내의 스레드들은 프로세스의 메모리 영역(Heap 영역 등)을 공유합니다. 이 때문에 스레드 간 데이터 공유가 쉽지만, 동기화 문제가 발생할 수 있습니다.
- 스레드 전환(Context Switching) 비용이 프로세스 전환 비용보다 저렴하여 효율적입니다.
🔢 멀티스레드(Multi-Thread)란?
- 정의: 하나의 프로세스 내에서 여러 개의 스레드가 동시에 작업을 수행하는 것입니다.
- 목적:
- 동시성(Concurrency) & 병렬성(Parallelism): 여러 작업을 빠르게 번갈아 처리(동시성, 싱글 코어)하거나, 여러 코어를 이용해 실제로 동시에 처리(병렬성, 멀티 코어)하여 처리율을 높입니다.
- 응답성 향상: 웹 서버에서 하나의 요청 처리 시간이 길더라도 다른 요청을 처리하는 스레드가 대기 없이 작업을 수행할 수 있어 사용자 경험이 개선됩니다.
🚶♀️🚶♀️🚶♀️ 큐와 스레드 풀의 관계
- 큐(Queue): 데이터를 임시로 저장하는 선입선출(FIFO, First-In, First-Out) 구조의 자료구조입니다.
- 스레드 풀(Thread Pool): 미리 생성해 둔 일정 수의 스레드 묶음입니다.
- 관계: 멀티스레드 환경에서 작업 요청(Task)은 큐(작업 큐)에 쌓이고, 스레드 풀의 유휴(Idle) 스레드가 큐에서 작업을 꺼내 처리합니다.
- ✅ 스레드 풀 설정의 장점: 스레드를 생성하고 제거하는 오버헤드를 줄이고, 시스템이 감당할 수 있는 스레드 수를 제한하여 과부하를 방지할 수 있습니다.
1. 🛠️ 스레드 생성 및 관리 예시
Java에서는 Thread 클래스나 Runnable 인터페이스를 사용하여 스레드를 생성할 수 있지만, 복잡한 멀티스레딩 환경에서는 ExecutorService를 사용해 스레드 풀을 관리하는 것이 일반적입니다.
1. Runnable을 이용한 기본 스레드 구현
가장 기본적인 스레드 작업 정의 방식입니다.
public class BasicThreadExample {
public static void main(String[] args) {
// 1. Runnable 인터페이스 구현
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + "가 작업을 시작합니다.");
try {
Thread.sleep(1000); // 1초 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(threadName + "가 작업을 완료합니다.");
};
// 2. Thread 객체 생성 및 실행
Thread t1 = new Thread(task, "작업-1");
Thread t2 = new Thread(task, "작업-2");
t1.start();
t2.start();
// 메인 스레드 종료와 관계없이 실행됩니다.
System.out.println("메인 스레드 종료.");
}
}
2. ExecutorService와 작업 큐 관리
ExecutorService는 스레드 풀을 생성하고 작업을 관리하는 표준 방식입니다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ExecutorServiceExample {
public static void main(String[] args) {
// 1. 고정된 크기의 스레드 풀 생성 (FixedThreadPool)
// 최대 3개의 스레드를 사용하며, 초과 작업은 내부 큐에 대기합니다.
ExecutorService executorService = Executors.newFixedThreadPool(3);
// 2. 10개의 작업을 제출 (작업 큐에 쌓임)
for (int i = 1; i <= 10; i++) {
final int taskId = i;
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
System.out.println("Task " + taskId + " | Thread: " + threadName + " - 작업 시작");
try {
Thread.sleep(500); // 작업 처리 시간
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + taskId + " | Thread: " + threadName + " - 작업 완료");
};
executorService.submit(task); // 큐에 작업 제출
}
// 3. 더 이상 새 작업을 받지 않음
executorService.shutdown();
// 4. 모든 작업이 완료될 때까지 최대 5초 대기
try {
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
System.err.println("시간 초과! 아직 완료되지 않은 작업이 있습니다.");
executorService.shutdownNow(); // 강제 종료 시도
}
} catch (InterruptedException e) {
executorService.shutdownNow();
}
}
}
2. ⚙️ 멀티스레드 설정 및 관리 (스레드 풀)
Spring Boot 환경에서는 주로 두 가지 방법으로 스레드 풀을 설정 및 관리합니다.
1. 웹 서버(Tomcat) 스레드 풀 설정
Spring Boot의 내장 Tomcat은 들어오는 HTTP 요청을 처리하기 위해 스레드 풀을 사용합니다. application.yml 또는 application.properties 파일에서 간단하게 설정할 수 있습니다.
📝 application.yml 예시
server:
tomcat:
# 최대 스레드 개수. 동시에 처리할 수 있는 최대 요청 수와 관련. (기본값: 200)
threads:
max: 100
# 최소 유휴 스레드 개수. 항상 대기 상태로 유지하는 스레드 수. (기본값: 10)
min-spare: 20
# 작업 큐(Queue)의 크기. 스레드가 모두 사용 중일 때 요청이 대기하는 큐 크기.
accept-count: 50
- threads.max: 이 값을 너무 높게 설정하면 Context Switching 비용과 메모리 사용량이 증가하여 성능 저하가 올 수 있습니다. 서버의 CPU 코어 수, 메모리, 작업 성격(CPU 바운드 vs I/O 바운드)을 고려해야 합니다.
2. @Async와 ThreadPoolTaskExecutor를 이용한 비동기 처리
웹 요청과는 별개로 애플리케이션 내부에서 특정 작업을 비동기로 처리해야 할 때, Spring이 제공하는 @Async 어노테이션과 ThreadPoolTaskExecutor를 사용합니다.
1. 환경 설정
@EnableAsync를 설정 클래스에 추가합니다.
// ApplicationConfig.java (예시)
@Configuration
@EnableAsync // 비동기 메서드 활성화
public class AsyncConfig {
@Bean(name = "threadPoolTaskExecutor")
public Executor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 기본 스레드 수 (항상 유지)
executor.setMaxPoolSize(30); // 최대 스레드 수
executor.setQueueCapacity(100); // 작업 큐 크기
executor.setThreadNamePrefix("Async-Task-"); // 스레드 이름 접두사
// 큐와 MaxPoolSize가 가득 찼을 때의 처리 정책 (예: 호출한 스레드가 직접 실행)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
2. 비동기 메서드 구현
서비스 계층에서 @Async 어노테이션을 사용하여 비동기로 실행할 메서드를 지정합니다.
@Service
public class AsyncTaskService {
// (옵션) 설정 파일에서 정의한 Bean 이름 지정
@Async("threadPoolTaskExecutor")
public Future<String> executeAsyncTask(int taskId) {
System.out.println("작업 " + taskId + " 시작. 스레드: " + Thread.currentThread().getName());
try {
Thread.sleep(2000); // 비동기 작업 시뮬레이션
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("작업 " + taskId + " 완료.");
// 결과를 반환해야 할 경우 Future<T>를 사용
return new AsyncResult<>("Task " + taskId + " Completed");
}
}
3. 🔗 멀티스레드 환경에서의 큐(Queue) 구현 및 활용
Java의 java.util.concurrent 패키지는 멀티스레드 환경에서 안전하게 사용할 수 있는 동시성 컬렉션(Concurrent Collections)을 제공합니다. 특히 스레드 간 작업 전달을 위해 BlockingQueue 계열의 큐가 자주 사용됩니다.
1. BlockingQueue를 이용한 생산자-소비자 패턴
BlockingQueue는 큐가 비어 있을 때 요소를 꺼내려는 스레드를 대기시키고, 큐가 가득 찼을 때 요소를 넣으려는 스레드를 대기시키는 기능을 제공하여 스레드 동기화를 단순화합니다 (생산자-소비자 패턴).
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BlockingQueueExample {
// 큐 크기 10인 BlockingQueue 생성
private static final BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);
// 1. 생산자 (Producer)
static class Producer implements Runnable {
@Override
public void run() {
try {
for (int i = 1; i <= 20; i++) {
String task = "Task-" + i;
System.out.println("생산: " + task);
queue.put(task); // 큐가 가득 차면 대기(Block)
Thread.sleep(100);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// 2. 소비자 (Consumer)
static class Consumer implements Runnable {
@Override
public void run() {
try {
while (true) {
String task = queue.take(); // 큐가 비어 있으면 대기(Block)
System.out.println("소비: " + task + " (by " + Thread.currentThread().getName() + ")");
Thread.sleep(500); // 소비 시간 시뮬레이션
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) {
new Thread(new Producer()).start();
new Thread(new Consumer(), "Consumer-1").start();
new Thread(new Consumer(), "Consumer-2").start();
}
}
2. Spring Boot에서의 큐 활용 (메시지 큐 통합)
실제 엔터프라이즈 환경에서는 Spring Boot와 메시지 큐(Message Queue) 시스템 (예: Kafka, RabbitMQ)을 통합하여 비동기 작업을 처리하는 것이 일반적입니다.
- 원리:
- 웹 요청 처리 스레드(Producer)는 처리 시간이 긴 작업을 로컬 큐 대신 메시지 큐에 메시지 형태로 발행(Publish)합니다.
- 이후 요청에 즉시 응답합니다.
- 별도의 Worker 스레드(Consumer)가 메시지 큐에서 메시지를 가져와 실제 작업을 수행합니다.
- 장점: 요청 스레드의 대기 시간을 없애고, 서비스 간의 결합도를 낮추며, 작업의 안정적인 처리를 보장합니다.
4. 🛡️ 멀티스레드 관리: 동기화와 주의 사항
멀티스레딩의 가장 큰 위험은 여러 스레드가 공유 자원에 동시에 접근하면서 발생하는 동기화 문제입니다.
1. 공유 자원 문제 (Race Condition)
- 두 개 이상의 스레드가 하나의 변수나 객체를 동시에 수정하려고 할 때, 실행 순서에 따라 결과가 달라지는 현상입니다.
2. 해결책: 스레드 동기화
| 방법 | 설명 | 사용 예시 |
| synchronized 키워드 | 메서드나 블록에 락(Lock)을 걸어 한 번에 하나의 스레드만 접근하도록 강제합니다. | public synchronized void addCount() |
| Atomic 클래스 | 원자성(Atomicity)을 보장하는 변수를 사용합니다. 내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용합니다. | AtomicLong, AtomicInteger |
| Lock 인터페이스 | ReentrantLock 등 더 유연하고 명시적인 락 관리 기능을 제공합니다. | Lock lock = new ReentrantLock(); |
| 동시성 컬렉션 | 멀티스레드 환경에서 안전하게 설계된 컬렉션을 사용합니다. | ConcurrentHashMap, CopyOnWriteArrayList |
📌 주의: 지나친 동기화는 스레드를 대기 상태로 만들고 병렬성을 떨어뜨려 성능 저하나 교착 상태(Deadlock)를 유발할 수 있으므로, 꼭 필요한 곳에만 적용해야 합니다.
5. 🩻데드락의 발생 원인과 해결 방법
데드락(DeadLock)은 멀티스레드 환경에서 두 개 이상의 스레드가 서로 상대방이 점유하고 있는 자원(Lock)을 무한정 기다리며 영구적으로 블록되는 현상입니다. 스레드, 멀티스레드, 큐 환경에서는 공유 자원(Shared Resource)과 동기화(Synchronization) 메커니즘을 사용할 때 주로 발생합니다.
🔍 데드락 발생의 4가지 필수 조건 (Coffman Conditions)
데드락은 다음 네 가지 조건이 모두 충족될 때만 발생합니다. 이 중 하나라도 깨뜨리면 데드락을 방지하거나 해결할 수 있습니다.
- 상호 배제 (Mutual Exclusion) 🔒
- 자원은 한 번에 하나의 스레드만 사용할 수 있어야 합니다. (락(Lock) 메커니즘 사용)
- 예: 스레드 A가 자원 R1을 Lock 건 상태.
- 점유와 대기 (Hold and Wait) ⏳
- 자원을 하나 이상 점유하고 있는 스레드가 현재 다른 스레드가 점유하고 있는 자원을 얻기 위해 대기해야 합니다.
- 예: 스레드 A는 R1을 점유한 채 R2를 기다리고, 스레드 B는 R2를 점유한 채 R1을 기다리는 상황.
- 비선점 (No Preemption) 🚫
- 어떤 스레드도 다른 스레드가 점유하고 있는 자원을 강제로 뺏을 수 없습니다. 자원을 점유하고 있는 스레드가 작업을 끝내고 자발적으로 반납해야만 합니다.
- 순환 대기 (Circular Wait) 🔄
- 대기하는 스레드들이 원형으로 순환하는 형태로 자원을 기다려야 합니다.
- 예: 스레드 A ➡️ 자원 R1 ➡️ 스레드 B ➡️ 자원 R2 ➡️ 스레드 A
2. 데드락 해결 및 방지 방법
데드락은 주로 방지(Prevention)나 회피(Avoidance) 기법을 사용하여 4가지 필수 조건 중 하나 이상을 깨뜨림으로써 해결됩니다.
2.1. 방지 기법 (Prevention)
발생 조건을 깨뜨려 데드락을 근본적으로 불가능하게 만듭니다.
| 조건 | 방지 방법 | 설명 및 적용 |
| 상호 배제 | 공유 자원 최소화 | 불가능한 경우가 많지만, Atomic 변수나 Lock-Free 자료구조를 사용하여 락을 걸지 않고도 동시성을 확보합니다. |
| 점유와 대기 | 자원 한 번에 요청 | 필요한 모든 자원을 한 번에 획득하거나, 아무것도 획득하지 못하면 현재 점유 중인 자원까지 모두 반납하게 합니다. (자원 활용률 저하 가능성) |
| 비선점 | 자원 강제 반납 | 대기 중인 자원을 얻지 못하면 현재 가진 자원을 모두 포기하고 재요청하게 합니다. tryLock() 메서드 등을 활용할 수 있습니다. |
| 순환 대기 | 자원 순서 할당 | 모든 자원에 고유한 순서 번호를 부여하고, 스레드가 항상 번호가 증가하는 순서대로만 자원을 요청하도록 강제합니다. (가장 실용적인 방법) |
2.2. Spring Boot 및 Java에서의 해결 전략
1. 순환 대기 방지 (가장 중요)
데드락의 주요 원인은 순환 대기이므로, 락(Lock) 획득 순서를 고정하는 것이 가장 효과적입니다.
// ❌ 데드락 발생 가능성 (획득 순서가 스레드마다 다름)
public void transfer(Account from, Account to, int amount) {
if (Math.random() > 0.5) {
from.lock(); to.lock(); // A 스레드: from -> to 순서
} else {
to.lock(); from.lock(); // B 스레드: to -> from 순서
}
//...
}
// ✅ 데드락 방지 (일관된 순서)
// Account 객체의 해시 코드(고유 ID) 등을 기준으로 획득 순서를 정렬
public void safeTransfer(Account a1, Account a2, int amount) {
// 순서 정의: ID가 작은 계좌를 먼저 Lock
Account first = a1.getId() < a2.getId() ? a1 : a2;
Account second = a1.getId() < a2.getId() ? a2 : a1;
first.lock();
try {
second.lock();
try {
// 자원 획득 성공, 안전하게 로직 수행
} finally {
second.unlock();
}
} finally {
first.unlock();
}
}
2. Timeout을 사용한 회피
Java의 ReentrantLock이나 tryLock() 메서드를 사용하여 자원 획득에 시간 제한(Timeout)을 두는 방법입니다.
Lock lockA = new ReentrantLock();
Lock lockB = new ReentrantLock();
// tryLock(long timeout, TimeUnit unit) 사용
if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) { // 100ms 대기
try {
if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// ... 성공
} finally {
lockB.unlock();
}
} else {
// lockB 획득 실패 시, lockA를 반납하고 재시도
}
} finally {
lockA.unlock();
}
}
3. 큐 기반 시스템 (메시지 큐) 활용
시스템의 병목 구간에서 락을 사용하는 대신, 비동기 메시지 큐(Kafka, RabbitMQ)를 사용하면 데드락 문제를 회피할 수 있습니다.
- 원리: 스레드 A와 B가 공유 데이터베이스를 동시에 Lock 걸고 처리하는 대신, 두 스레드는 메시지 큐에 처리 요청 메시지를 발행하고 즉시 반환합니다. 실제 처리는 큐에 연결된 하나의 Worker 스레드 또는 독립적인 Consumer 그룹이 담당합니다.
- 효과: 공유 자원에 대한 직접적인 Lock 경합이 사라지므로 데드락 발생 가능성이 거의 없어집니다. (다만 큐 시스템 자체의 Lock은 별개)
이 포스팅을 통해 스레드, 멀티스레드, 그리고 큐에 대한 이해를 바탕으로 보다 견고하고 효율적인 Spring Boot 애플리케이션을 개발하는 데 도움이 되기를 바랍니다.
'IT > Backend | All' 카테고리의 다른 글
| Apache Jmeter | 서버에서 jmeter 실행 (0) | 2025.11.24 |
|---|---|
| 🧪테스트주도개발(TDD) 하기 | Apache Jmeter를 이용한 부하테스트 (1) | 2025.11.19 |
| 🧪테스트주도개발(TDD) 하기 | JUnit5 단위 테스트 프레임워크 (0) | 2025.11.12 |
| 무한대(Infinity) 루프: Integer.MAX_VALUE를 활용한 최대 반복문 (0) | 2025.11.12 |
| MDC를 이용한 로그트레이싱 | Slf4j MDC(Mapped Diagnostic Context) (0) | 2025.11.07 |