Post

스레드(Thread)와 멀티스레드 완벽 정리

스레드(Thread)와 멀티스레드 완벽 정리

프로세스(Process)란?

프로세스는 실행 중인 프로그램의 인스턴스입니다. 운영체제로부터 자원을 할당받아 독립적으로 실행되는 작업 단위로, 컴퓨터 시스템의 기본적인 실행 단위로 간주됩니다.

프로세스의 주요 특징

  • 독립적인 메모리 공간: 각 프로세스는 자신만의 메모리 공간(코드, 데이터, 힙, 스택)을 가지며, 다른 프로세스와 격리됩니다.
  • 자원 소유: 파일 핸들, 네트워크 소켓 등 시스템 자원을 개별적으로 소유합니다.
  • 보호 경계: 다른 프로세스의 메모리에 직접 접근할 수 없어 안정성과 보안이 보장됩니다.
  • 높은 전환 비용: 프로세스 간 전환(Context Switching)은 메모리와 자원 상태를 저장하고 복원해야 하므로 비용이 큽니다.

프로세스의 구성 요소

  • 코드 영역(Text): 실행 가능한 기계어 코드가 저장됩니다.
  • 데이터 영역(Data): 전역 변수와 정적 변수가 저장되며, 프로그램 실행 중 유지됩니다.
  • 힙(Heap): 동적으로 할당되는 메모리 영역으로, 런타임에 크기가 변동됩니다.
  • 스택(Stack): 함수 호출 정보(리턴 주소)와 지역 변수가 저장되며, LIFO(Last In, First Out) 방식으로 동작합니다.

Image 사진출처

현업 실무에서의 활용

현업에서는 프로세스의 독립성을 활용해 안정성이 중요한 시스템을 설계합니다. 예를 들어, 웹 브라우저(Chrome)는 각 탭을 별도의 프로세스로 실행하여 한 탭이 크래시되더라도 다른 탭에 영향을 주지 않도록 합니다. 또한, 서버 애플리케이션에서 멀티프로세스 모델(예: Apache의 Prefork MPM)을 사용해 요청을 분리 처리하며 안정성을 확보합니다.


스레드(Thread)란?

스레드는 프로세스 내에서 실행되는 작업의 흐름 단위입니다. 하나의 프로세스는 여러 스레드를 가질 수 있으며, 이들은 프로세스의 자원을 공유합니다. 스레드는 “경량 프로세스(Lightweight Process)”라고도 불립니다.

스레드의 주요 특징

  • 경량 프로세스: 스레드는 프로세스보다 생성 및 관리 비용이 적습니다.
  • 자원 공유: 같은 프로세스 내의 스레드들은 코드, 데이터, 힙 영역을 공유하지만, 스택은 독립적으로 가집니다.
  • 독립적인 실행 흐름: 각 스레드는 자체 프로그램 카운터(PC)를 통해 독립적인 실행 경로를 가집니다.
  • 낮은 전환 비용: 스레드 간 전환은 동일한 메모리 공간 내에서 이루어져 프로세스 전환보다 빠릅니다.

스레드의 구성 요소

  • 스레드 ID: 스레드를 식별하는 고유 번호입니다.
  • 프로그램 카운터(PC): 다음에 실행할 명령어의 메모리 주소를 가리킵니다.
  • 레지스터 집합: CPU 상태(레지스터 값)를 저장하여 실행 상태를 유지합니다.
  • 스택: 스레드별로 독립적인 함수 호출과 지역 변수를 관리합니다.

현업 실무에서의 활용

실무에서 스레드는 주로 자원 효율성을 높이기 위해 사용됩니다. 예를 들어, Java 기반의 웹 애플리케이션 서버(Tomcat)에서는 각 HTTP 요청을 별도의 스레드로 처리하여 단일 프로세스 내에서 수백 개의 요청을 동시에 다룹니다. 이는 프로세스를 새로 생성하는 것보다 훨씬 빠르고 메모리 사용이 효율적입니다.


프로세스와 스레드의 차이점

프로세스와 스레드는 실행 단위라는 공통점이 있지만, 구조와 동작 방식에서 큰 차이가 있습니다. 아래 표는 이를 비교한 것입니다.

항목프로세스스레드
정의실행 중인 프로그램의 인스턴스프로세스 내의 실행 흐름 단위
메모리 공간독립적인 메모리 공간 소유프로세스의 메모리 공간 공유
통신 방식IPC(Inter-Process Communication)공유 변수로 직접 통신 가능
Context Switching비용이 큼비용이 상대적으로 적음
안정성한 프로세스의 문제가 다른 프로세스에 영향 적음한 스레드의 문제가 전체 프로세스에 영향
생성 시간상대적으로 오래 걸림빠름

Image 사진출처

현업 실무에서의 활용

현업에서는 작업의 성격에 따라 프로세스와 스레드를 선택합니다. 예를 들어, 안정성이 중요한 데이터베이스 백업 시스템은 프로세스 단위로 실행해 장애 격리를 보장하고, 반면 실시간 채팅 애플리케이션은 스레드를 활용해 동일 프로세스 내에서 빠른 응답성과 자원 공유를 구현합니다.


멀티스레드(Multi-threading)

멀티스레드는 하나의 프로세스 내에서 여러 스레드가 동시에 작업을 수행하는 기법입니다. 현대 CPU의 멀티코어 아키텍처와 결합하여 병렬 처리와 자원 활용 효율성을 극대화합니다.

멀티스레드의 장점

  • 응답성 향상: 예를 들어, GUI 애플리케이션에서 UI 스레드가 멈추지 않고 백그라운드 작업을 처리할 수 있습니다.
  • 자원 공유: 메모리와 파일 핸들을 효율적으로 활용합니다.
  • 경제성: 프로세스 생성보다 스레드 생성이 빠르고 자원 소모가 적습니다.
  • 확장성: 멀티코어 CPU에서 각 코어가 스레드를 병렬로 실행하여 성능을 극대화합니다.

멀티스레드의 단점

  • 동기화 문제: 공유 자원에 대한 동시 접근으로 데이터 무결성이 깨질 수 있습니다(레이스 컨디션).
  • 데드락(Deadlock): 스레드 간 자원 경쟁으로 교착 상태가 발생할 수 있습니다.
  • 디버깅 어려움: 실행 순서가 비결정적이어서 버그 추적이 복잡합니다.
  • 오버헤드: 스레드 수가 많아지면 스케줄링과 메모리 사용에 따른 부담이 커집니다.

멀티스레드 활용 사례

  • 웹 서버: Apache나 Nginx는 클라이언트 요청을 처리하기 위해 스레드 또는 스레드 풀을 사용합니다.
  • 게임 엔진: 렌더링, 물리 계산, AI 처리를 병렬로 수행합니다.
  • 데이터베이스: 다중 쿼리 처리를 위해 스레드를 활용합니다.

현업 실무에서의 활용

현업에서는 멀티스레드가 성능 최적화에 자주 사용됩니다. 예를 들어, Netflix의 스트리밍 서버는 멀티스레드를 활용해 비디오 인코딩과 데이터 전송을 병렬로 처리하며, 게임 개발에서는 Unity 엔진이 멀티스레드로 물리 연산과 렌더링을 분리해 프레임 속도를 높입니다.


스레드 상태와 생명주기

스레드는 생성부터 종료까지 여러 상태를 거칩니다. 이를 이해하면 스레드 관리와 디버깅에 큰 도움이 됩니다.

스레드의 주요 상태

  • 생성(New): 스레드 객체가 생성되었지만 start()가 호출되지 않은 상태.
  • 실행 가능(Runnable): start()가 호출되어 실행 준비가 된 상태. CPU를 기다립니다.
  • 실행(Running): CPU를 할당받아 실제로 실행 중인 상태.
  • 대기(Waiting/Blocked): I/O 작업, 락 대기, sleep(), wait() 호출 등으로 실행이 중단된 상태.
  • 종료(Terminated): 작업이 완료되거나 예외로 종료된 상태.

Image 사진출처

현업 실무에서의 활용

실무에서는 스레드 상태를 모니터링해 시스템 성능을 최적화합니다. 예를 들어, Java 애플리케이션에서 VisualVM 같은 도구로 스레드 상태를 분석해 대기 상태가 많은 경우 I/O 병목을 개선하거나, 종료되지 않은 스레드로 인한 메모리 누수를 탐지합니다.


스레드 구현 방식

스레드는 여러 프로그래밍 언어에서 다양한 방식으로 구현됩니다. 여기서는 Java와 Python을 예로 들겠습니다.

Java에서의 스레드 구현

1. Thread 클래스 상속

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("스레드 실행 중: " + Thread.currentThread().getName() + ", i=" + i);
            try {
                Thread.sleep(500); // 0.5초 대기
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start(); // 스레드 시작
    }
}

2. Runnable 인터페이스 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Runnable 스레드 실행 중: " + Thread.currentThread().getName() + ", i=" + i);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(new MyRunnable());
        thread.start();
    }
}

3. 람다 표현식 사용 (Java 8+)

1
2
3
4
5
6
7
8
9
10
public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("람다 스레드 실행 중: " + Thread.currentThread().getName() + ", i=" + i);
            }
        });
        thread.start();
    }
}

4. 스레드 풀 사용 (ExecutorService)

스레드 풀은 스레드를 재사용하여 생성 오버헤드를 줄이는 방법입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3); // 3개의 스레드 풀
        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " 실행 중: " + Thread.currentThread().getName());
            });
        }
        executor.shutdown(); // 작업이 끝나면 종료
    }
}

현업 실무에서의 활용

현업에서는 스레드 풀(ExecutorService)이 가장 흔히 사용됩니다. 예를 들어, Spring Boot 기반 웹 애플리케이션에서는 @Async 어노테이션과 스레드 풀을 조합해 비동기 작업(이메일 발송, 로그 기록 등)을 처리하며, 시스템 부하를 줄이고 응답 시간을 단축합니다.


스레드 동기화(Thread Synchronization)

멀티스레드 환경에서 공유 자원에 동시에 접근하면 데이터 일관성 문제가 발생할 수 있습니다(레이스 컨디션). 이를 해결하기 위해 동기화 기법이 필요합니다.

동기화 문제 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Counter {
    private int count = 0;
    
    public void increment() {
        count++; // 이 연산은 원자적이지 않음 (읽기-수정-쓰기 3단계)
    }
    
    public int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("최종 값: " + counter.getCount()); // 2000 미만일 수 있음
    }
}

동기화 기법

1. 상호 배제(Mutual Exclusion)

1
2
3
4
5
6
7
8
9
10
11
class SynchronizedCounter {
    private int count = 0;
    
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}

2. 락(Lock)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class LockCounter {
    private int count = 0;
    private Lock lock = new ReentrantLock();
    
    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock(); // 예외 발생 시에도 락 해제 보장
        }
    }
    
    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

3. 원자적 변수(Atomic Variables)

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);
    
    public void increment() {
        count.incrementAndGet();
    }
    
    public int getCount() {
        return count.get();
    }
}

4. Volatile 변수

공유 변수의 가시성을 보장합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class VolatileExample {
    private volatile boolean running = true;
    
    public void stop() {
        running = false;
    }
    
    public void run() {
        while (running) {
            System.out.println("실행 중...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

현업 실무에서의 활용

실무에서는 동기화 문제를 해결하기 위해 상황에 맞는 기법을 선택합니다. 예를 들어, 금융 시스템에서는 AtomicInteger를 사용해 계좌 잔액 업데이트의 원자성을 보장하고, 멀티스레드 서버에서는 ReentrantLock으로 복잡한 자원 접근을 제어하며, 간단한 플래그 관리에는 volatile을 활용해 성능과 안정성을 동시에 잡습니다.


스레드 간 통신

스레드 간 데이터를 주고받거나 작업을 조율하는 방법입니다.

1. 공유 객체 사용 (생산자-소비자 패턴)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class SharedResource {
    private String message;
    private boolean hasMessage = false;
    
    public synchronized void setMessage(String message) {
        while (hasMessage) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        this.message = message;
        hasMessage = true;
        notify();
    }
    
    public synchronized String getMessage() {
        while (!hasMessage) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        hasMessage = false;
        notify();
        return message;
    }
}

public class Main {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();
        Thread producer = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                resource.setMessage("메시지 " + i);
            }
        });
        Thread consumer = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(resource.getMessage());
            }
        });
        producer.start();
        consumer.start();
    }
}

2. BlockingQueue 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

class Producer implements Runnable {
    private BlockingQueue<String> queue;
    
    public Producer(BlockingQueue<String> queue) {
        this.queue = queue;
    }
    
    @Override
    public void run() {
        try {
            for (int i = 0; i < 5; i++) {
                queue.put("메시지 " + i);
                System.out.println("생산: 메시지 " + i);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

class Consumer implements Runnable {
    private BlockingQueue<String> queue;
    
    public Consumer(BlockingQueue<String> queue) {
        this.queue = queue;
    }
    
    @Override
    public void run() {
        try {
            for (int i = 0; i < 5; i++) {
                System.out.println("소비: " + queue.take());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

public class Main {
    public static void main(String[] args) {
        BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);
        Thread producer = new Thread(new Producer(queue));
        Thread consumer = new Thread(new Consumer(queue));
        producer.start();
        consumer.start();
    }
}
This post is licensed under CC BY 4.0 by the author.