Cute Light Pink Flying Butterfly 비동기 처리의 핵심 | 스레드, 멀티스레드, 큐 :: 놀면서 돈벌기
본문 바로가기
IT/Backend | All

비동기 처리의 핵심 | 스레드, 멀티스레드, 큐

by esclife_ 2025. 11. 14.
반응형

 

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)을 통합하여 비동기 작업을 처리하는 것이 일반적입니다.

  • 원리:
    1. 웹 요청 처리 스레드(Producer)는 처리 시간이 긴 작업을 로컬 큐 대신 메시지 큐에 메시지 형태로 발행(Publish)합니다.
    2. 이후 요청에 즉시 응답합니다.
    3. 별도의 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)

데드락은 다음 네 가지 조건이 모두 충족될 때만 발생합니다. 이 중 하나라도 깨뜨리면 데드락을 방지하거나 해결할 수 있습니다.

  1. 상호 배제 (Mutual Exclusion) 🔒
    • 자원은 한 번에 하나의 스레드만 사용할 수 있어야 합니다. (락(Lock) 메커니즘 사용)
    • 예: 스레드 A가 자원 R1을 Lock 건 상태.
  2. 점유와 대기 (Hold and Wait) ⏳
    • 자원을 하나 이상 점유하고 있는 스레드가 현재 다른 스레드가 점유하고 있는 자원을 얻기 위해 대기해야 합니다.
    • 예: 스레드 A는 R1을 점유한 채 R2를 기다리고, 스레드 B는 R2를 점유한 채 R1을 기다리는 상황.
  3. 비선점 (No Preemption) 🚫
    • 어떤 스레드도 다른 스레드가 점유하고 있는 자원을 강제로 뺏을 수 없습니다. 자원을 점유하고 있는 스레드가 작업을 끝내고 자발적으로 반납해야만 합니다.
  4. 순환 대기 (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 애플리케이션을 개발하는 데 도움이 되기를 바랍니다.

반응형