운영체제 프로세스와 스레드 완벽 가이드: 개념부터 실무 활용까지

1. 운영체제란 무엇인가

운영체제(Operating System, OS)는 컴퓨터 하드웨어와 사용자 사이에서 중재자 역할을 하는 시스템 소프트웨어이다. 우리가 일상적으로 사용하는 Windows, macOS, Linux 등이 모두 운영체제에 해당한다. 운영체제의 핵심 역할은 크게 네 가지로 나눌 수 있다.

첫째, 자원 관리(Resource Management)이다. CPU, 메모리, 디스크, 네트워크 등 한정된 하드웨어 자원을 여러 프로그램이 효율적으로 공유할 수 있도록 관리한다. 둘째, 프로세스 관리(Process Management)이다. 실행 중인 프로그램인 프로세스의 생성, 스케줄링, 종료를 담당한다. 셋째, 메모리 관리(Memory Management)이다. 물리 메모리와 가상 메모리를 관리하여 각 프로세스가 독립적인 메모리 공간을 가질 수 있도록 보장한다. 넷째, 파일 시스템 관리(File System Management)이다. 디스크에 저장된 데이터를 파일과 디렉토리 구조로 체계적으로 관리한다.

커널(Kernel)의 역할

커널은 운영체제의 핵심 구성 요소로, 하드웨어와 직접 상호작용하는 가장 낮은 수준의 소프트웨어이다. 커널은 다음과 같은 핵심 기능을 수행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────┐
│          사용자 응용 프로그램           │
├─────────────────────────────────────┤
│          시스템 콜 인터페이스           │
├─────────────────────────────────────┤
│     프로세스 관리  │  메모리 관리       │
│     파일 시스템    │  장치 드라이버     │
│     네트워크 관리  │  보안/권한 관리    │
├─────────────────────────────────────┤
│              커널 (Kernel)           │
├─────────────────────────────────────┤
│              하드웨어                 │
└─────────────────────────────────────┘

커널 모드(Kernel Mode)와 사용자 모드(User Mode)는 운영체제가 시스템을 보호하기 위해 사용하는 이중 모드이다. 사용자 모드에서는 제한된 명령어만 실행할 수 있고, 하드웨어에 직접 접근할 수 없다. 커널 모드에서는 모든 명령어를 실행할 수 있으며, 하드웨어에 직접 접근이 가능하다.

시스템 콜(System Call)

시스템 콜은 사용자 모드에서 실행되는 프로그램이 커널의 기능을 요청하기 위한 인터페이스이다. 프로그램이 파일을 읽거나, 네트워크 통신을 하거나, 새로운 프로세스를 생성할 때 모두 시스템 콜을 통해 커널에게 요청한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Java에서 시스템 콜이 내부적으로 발생하는 예시
import java.io.*;

public class SystemCallExample {
    public static void main(String[] args) throws IOException {
        // 파일 열기 - 내부적으로 open() 시스템 콜 발생
        FileInputStream fis = new FileInputStream("data.txt");

        // 파일 읽기 - 내부적으로 read() 시스템 콜 발생
        byte[] buffer = new byte[1024];
        int bytesRead = fis.read(buffer);

        // 파일 닫기 - 내부적으로 close() 시스템 콜 발생
        fis.close();

        // 프로세스 생성 - 내부적으로 fork() + exec() 시스템 콜 발생
        ProcessBuilder pb = new ProcessBuilder("ls", "-la");
        Process process = pb.start();
    }
}

주요 시스템 콜의 종류는 다음과 같다. 프로세스 제어: fork(), exec(), wait(), exit(). 파일 관리: open(), read(), write(), close(). 장치 관리: ioctl(), read(), write(). 정보 유지: getpid(), alarm(), sleep(). 통신: socket(), bind(), listen(), accept(), connect().


2. 프로세스의 개념과 메모리 구조

프로세스란?

프로세스(Process)는 실행 중인 프로그램을 의미한다. 프로그램이 디스크에 저장된 정적인 코드라면, 프로세스는 그 프로그램이 메모리에 적재되어 CPU에 의해 실행되고 있는 동적인 상태를 말한다. 하나의 프로그램은 여러 개의 프로세스로 실행될 수 있다. 예를 들어, 웹 브라우저를 여러 개 열면 각각이 독립적인 프로세스이다.

프로세스의 메모리 구조

각 프로세스는 운영체제로부터 독립적인 가상 메모리 공간을 할당받으며, 이 메모리는 네 가지 영역으로 구분된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
높은 주소 ┌──────────────────────┐
         │    Stack 영역         │  ← 지역변수, 매개변수, 리턴 주소
         │    (아래로 성장 ↓)     │     함수 호출 시 할당, 반환 시 해제
         ├──────────────────────┤
         │                      │
         │    (빈 공간)          │  ← Stack과 Heap이 서로를 향해 성장
         │                      │
         ├──────────────────────┤
         │    Heap 영역          │  ← 동적 메모리 할당 (malloc, new)
         │    (위로 성장 ↑)      │     프로그래머가 관리 (GC가 대신하기도)
         ├──────────────────────┤
         │    BSS 영역           │  ← 초기화되지 않은 전역/정적 변수
         ├──────────────────────┤
         │    Data 영역          │  ← 초기화된 전역/정적 변수
         ├──────────────────────┤
낮은 주소 │    Code(Text) 영역    │  ← 실행할 기계어 코드 (읽기 전용)
         └──────────────────────┘

Code(Text) 영역은 프로그램의 실행 코드가 저장되는 영역이다. 컴파일된 기계어 명령어가 순서대로 저장되며, 읽기 전용(Read-Only)으로 설정되어 코드가 변경되는 것을 방지한다. 여러 프로세스가 같은 프로그램을 실행할 경우, Code 영역을 공유하여 메모리를 절약할 수 있다.

Data 영역은 전역 변수와 정적(static) 변수가 저장되는 영역이다. 프로그램 시작 시 할당되고 프로그램 종료 시 해제된다. 초기화된 변수는 Data 영역에, 초기화되지 않은 변수는 BSS(Block Started by Symbol) 영역에 저장된다.

Heap 영역은 프로그래머가 동적으로 할당하는 메모리 영역이다. Java에서 new 키워드로 생성한 객체는 모두 Heap 영역에 저장된다. C/C++에서는 malloc()/free()로 관리하고, Java에서는 가비지 컬렉터(GC)가 자동으로 관리한다. 낮은 주소에서 높은 주소 방향으로 성장한다.

Stack 영역은 함수 호출과 관련된 정보가 저장되는 영역이다. 지역 변수, 매개변수, 리턴 주소, 이전 스택 프레임의 포인터 등이 저장된다. 함수가 호출될 때마다 새로운 스택 프레임이 쌓이고(push), 함수가 반환되면 제거된다(pop). 높은 주소에서 낮은 주소 방향으로 성장한다.

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
public class MemoryExample {
    // Data 영역 - 클래스 변수 (static)
    static int globalCounter = 0;
    static final String CONSTANT = "상수값";

    public static void main(String[] args) {
        // Stack 영역 - 지역 변수 (참조값)
        int localVar = 10;

        // Heap 영역 - new로 생성된 객체
        String name = new String("Hello");
        int[] array = new int[100];

        // Stack에는 참조(주소)가, Heap에는 실제 객체가 저장됨
        List<String> list = new ArrayList<>();  // list 참조 → Stack, ArrayList 객체 → Heap

        calculate(localVar);  // 새로운 스택 프레임 생성
    }

    // 함수 호출 시 새로운 스택 프레임이 생성됨
    static int calculate(int n) {
        int result = n * 2;  // Stack 영역
        int[] temp = new int[n];  // temp 참조 → Stack, 배열 객체 → Heap
        return result;  // 함수 반환 시 이 스택 프레임 제거
    }
}

3. 프로세스 상태 전이

프로세스는 생성부터 종료까지 여러 상태를 거치며 전이(transition)된다. 이를 프로세스 상태 다이어그램(Process State Diagram)이라 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                    ┌─────────┐
         생성 ──────→│   New   │
                    └────┬────┘
                  admit  │
                    ┌────▼────┐  dispatch   ┌──────────┐
            ┌──────│  Ready  │────────────→│ Running  │
            │      └────▲────┘             └──┬───┬───┘
            │           │                     │   │
            │  I/O완료  │   I/O 또는 이벤트 대기│   │ exit
            │           │                     │   │
            │      ┌────┴────┐                │  ┌▼────────┐
            │      │ Waiting │←───────────────┘  │Terminated│
            │      └─────────┘                   └──────────┘
            │
        interrupt(선점)

각 상태에 대해 상세히 살펴보자.

New(생성): 프로세스가 막 생성된 상태이다. 운영체제가 프로세스를 위한 자료구조를 만들고, 메모리를 할당하는 중이다. 아직 Ready Queue에 들어가지 않은 상태이다.

Ready(준비): 프로세스가 CPU를 할당받기 위해 대기하고 있는 상태이다. 메모리에 적재되어 실행 준비가 완료되었지만, CPU 스케줄러에 의해 선택되기를 기다리고 있다. Ready Queue에서 대기한다.

Running(실행): CPU를 할당받아 실제로 명령어를 실행하고 있는 상태이다. 단일 코어 시스템에서는 한 번에 하나의 프로세스만 Running 상태일 수 있다.

Waiting(대기/블록): 프로세스가 I/O 작업이나 특정 이벤트가 완료되기를 기다리는 상태이다. 디스크에서 파일을 읽거나, 네트워크 데이터를 기다리거나, 다른 프로세스의 시그널을 기다릴 때 이 상태가 된다. I/O가 완료되면 Ready 상태로 전이된다.

Terminated(종료): 프로세스의 실행이 완료된 상태이다. 운영체제가 프로세스에 할당된 자원을 회수하고, PCB를 제거한다.

상태 전이의 종류를 정리하면 다음과 같다.

전이 설명
New → Ready 프로세스 생성 완료, 스케줄링 대기 (admit)
Ready → Running CPU 스케줄러가 프로세스 선택 (dispatch)
Running → Ready 타임 슬라이스 만료 또는 높은 우선순위 프로세스 도착 (interrupt/preempt)
Running → Waiting I/O 요청 또는 이벤트 대기 (I/O or event wait)
Waiting → Ready I/O 완료 또는 이벤트 발생 (I/O or event completion)
Running → Terminated 프로세스 실행 완료 (exit)

4. PCB (Process Control Block)

PCB는 운영체제가 각 프로세스를 관리하기 위해 유지하는 자료구조이다. 커널 영역에 저장되며, 프로세스의 모든 정보를 담고 있다. 컨텍스트 스위칭이 발생할 때 현재 프로세스의 상태를 PCB에 저장하고, 다음 프로세스의 상태를 PCB에서 복원한다.

PCB에 포함되는 주요 정보는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────────────────┐
│        PCB (Process Control Block)│
├──────────────────────────────────┤
│ PID (Process ID)                  │  ← 프로세스 고유 식별자
│ Process State                     │  ← New, Ready, Running, Waiting, Terminated
│ Program Counter (PC)              │  ← 다음에 실행할 명령어의 주소
│ CPU Registers                     │  ← 범용 레지스터, 스택 포인터 등
│ CPU Scheduling Info               │  ← 우선순위, 스케줄링 큐 포인터
│ Memory Management Info            │  ← 페이지 테이블, 세그먼트 테이블
│ Accounting Info                   │  ← CPU 사용 시간, 시간 제한
│ I/O Status Info                   │  ← 할당된 I/O 장치, 열린 파일 목록
│ Parent/Child Process Info         │  ← 부모/자식 프로세스 포인터
└──────────────────────────────────┘

PID(Process ID)는 운영체제가 각 프로세스에 부여하는 고유한 정수 식별자이다. Java에서는 ProcessHandle.current().pid()로 현재 프로세스의 PID를 확인할 수 있다.

Program Counter(PC)는 다음에 실행할 명령어의 메모리 주소를 저장하는 레지스터이다. 컨텍스트 스위칭 시 반드시 저장/복원되어야 하는 가장 중요한 정보 중 하나이다.

CPU Registers는 프로세스가 실행 중에 사용하던 모든 레지스터의 값이다. 범용 레지스터, 인덱스 레지스터, 스택 포인터, 베이스 레지스터 등이 포함된다.

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
public class PCBExample {
    public static void main(String[] args) {
        // Java에서 현재 프로세스 정보 확인
        ProcessHandle current = ProcessHandle.current();

        System.out.println("PID: " + current.pid());
        System.out.println("명령어: " + current.info().command().orElse("N/A"));
        System.out.println("시작 시간: " + current.info().startInstant().orElse(null));
        System.out.println("CPU 시간: " + current.info().totalCpuDuration().orElse(null));
        System.out.println("사용자: " + current.info().user().orElse("N/A"));

        // 부모 프로세스 정보
        current.parent().ifPresent(parent -> {
            System.out.println("부모 PID: " + parent.pid());
            System.out.println("부모 명령어: " + parent.info().command().orElse("N/A"));
        });

        // 모든 프로세스 목록 조회
        ProcessHandle.allProcesses()
            .filter(ph -> ph.info().command().isPresent())
            .limit(10)
            .forEach(ph -> System.out.printf("PID: %d, CMD: %s%n",
                ph.pid(), ph.info().command().orElse("N/A")));
    }
}

5. 프로세스 스케줄링 알고리즘

CPU 스케줄링은 Ready Queue에 있는 프로세스 중 어떤 프로세스에게 CPU를 할당할지 결정하는 메커니즘이다. 효율적인 스케줄링은 시스템 성능에 직접적인 영향을 미친다.

스케줄링 평가 기준

  • CPU 이용률(CPU Utilization): CPU가 유휴 상태가 아닌 비율. 높을수록 좋다.
  • 처리량(Throughput): 단위 시간당 완료되는 프로세스 수. 높을수록 좋다.
  • 대기 시간(Waiting Time): Ready Queue에서 대기하는 총 시간. 낮을수록 좋다.
  • 응답 시간(Response Time): 요청 후 첫 번째 응답이 나올 때까지의 시간. 낮을수록 좋다.
  • 반환 시간(Turnaround Time): 프로세스 제출부터 완료까지의 총 시간. 낮을수록 좋다.

비선점형(Non-preemptive) 스케줄링

비선점형 스케줄링에서는 실행 중인 프로세스가 자발적으로 CPU를 반환할 때까지 다른 프로세스가 CPU를 빼앗을 수 없다.

FCFS (First-Come, First-Served)는 가장 단순한 스케줄링 알고리즘으로, 먼저 도착한 프로세스가 먼저 실행된다. 큐(Queue) 자료구조를 사용하여 구현한다.

1
2
3
4
5
6
7
예시: P1(24ms), P2(3ms), P3(3ms) 순서로 도착

실행 순서: P1 → P2 → P3
|--- P1 (24ms) ---|-- P2 (3ms) --|-- P3 (3ms) --|
0                 24             27              30

평균 대기 시간: (0 + 24 + 27) / 3 = 17ms

FCFS의 문제점은 호위 효과(Convoy Effect)이다. 실행 시간이 긴 프로세스가 먼저 도착하면, 뒤에 있는 짧은 프로세스들이 오랫동안 기다려야 한다. 위 예시에서 P2, P3가 먼저 도착했다면 평균 대기 시간은 (0 + 3 + 6) / 3 = 3ms로 대폭 줄어든다.

SJF (Shortest Job First)는 실행 시간이 가장 짧은 프로세스를 먼저 실행하는 알고리즘이다. 평균 대기 시간을 최소화하는 최적의 알고리즘이지만, 실제로는 프로세스의 실행 시간을 미리 알기 어렵다는 한계가 있다.

1
2
3
4
5
6
7
예시: P1(6ms), P2(8ms), P3(7ms), P4(3ms) 동시 도착

실행 순서: P4 → P1 → P3 → P2
|-- P4 (3ms) --|--- P1 (6ms) ---|--- P3 (7ms) ---|---- P2 (8ms) ----|
0              3                9                16                  24

평균 대기 시간: (3 + 16 + 9 + 0) / 4 = 7ms

SJF의 문제점은 기아(Starvation) 현상이다. 실행 시간이 긴 프로세스는 짧은 프로세스가 계속 도착하면 영원히 실행되지 못할 수 있다. 이를 해결하기 위해 에이징(Aging) 기법을 사용한다. 오래 기다린 프로세스의 우선순위를 점진적으로 높여주는 것이다.

선점형(Preemptive) 스케줄링

선점형 스케줄링에서는 실행 중인 프로세스를 중단시키고 다른 프로세스에게 CPU를 할당할 수 있다. 현대 운영체제는 대부분 선점형 스케줄링을 사용한다.

Round Robin (RR)은 각 프로세스에게 동일한 시간 할당량(Time Quantum)을 부여하고, 할당 시간이 지나면 다음 프로세스로 전환하는 방식이다. 공정성을 보장하며, 대화형 시스템에 적합하다.

1
2
3
4
5
6
7
8
9
예시: P1(24ms), P2(3ms), P3(3ms), Time Quantum = 4ms

|P1(4)|P2(3)|P3(3)|P1(4)|P1(4)|P1(4)|P1(4)|P1(4)|
0     4     7    10    14    18    22    26    30

P1 대기시간: (0 + (10-4)) = 6ms (총 실행 중간중간 대기)
P2 대기시간: 4ms
P3 대기시간: 7ms
평균 대기 시간: (6 + 4 + 7) / 3 = 5.67ms

Time Quantum의 크기가 매우 중요하다. 너무 크면 FCFS와 동일해지고, 너무 작으면 컨텍스트 스위칭 오버헤드가 커진다. 일반적으로 10~100ms 정도가 적당하다.

Priority Scheduling은 각 프로세스에 우선순위를 부여하고, 우선순위가 가장 높은 프로세스를 먼저 실행하는 방식이다. 선점형과 비선점형 모두 가능하다.

Multilevel Queue Scheduling은 Ready Queue를 여러 개의 큐로 분리하여 각 큐마다 다른 스케줄링 알고리즘을 적용하는 방식이다. 예를 들어, 포그라운드 프로세스는 Round Robin으로, 백그라운드 프로세스는 FCFS로 스케줄링할 수 있다.

1
2
3
4
5
6
7
┌─────────────────────────────┐  우선순위 높음
│  시스템 프로세스 (높은 우선순위)  │  ← Round Robin
├─────────────────────────────┤
│  대화형 프로세스 (중간 우선순위)  │  ← Round Robin
├─────────────────────────────┤
│  배치 프로세스 (낮은 우선순위)   │  ← FCFS
└─────────────────────────────┘  우선순위 낮음

6. 컨텍스트 스위칭 (Context Switching)

컨텍스트 스위칭은 CPU가 현재 실행 중인 프로세스를 중단하고 다른 프로세스를 실행하기 위해 상태를 전환하는 과정이다. 이 과정에서 현재 프로세스의 상태(컨텍스트)를 PCB에 저장하고, 다음 프로세스의 상태를 PCB에서 복원한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
프로세스 P0                  커널                    프로세스 P1
 실행 중
    │     인터럽트/시스템콜
    ├────────────────────→
    │                    P0 상태를 PCB0에 저장
    │                    P1 상태를 PCB1에서 복원
    │                         │
    │                         ├──────────────────→ 실행 중
    │                         │                      │
    │                         │     인터럽트/시스템콜   │
    │                         ←──────────────────────┤
    │                    P1 상태를 PCB1에 저장
    │                    P0 상태를 PCB0에서 복원
    ←────────────────────┤
 실행 중

컨텍스트 스위칭의 오버헤드

컨텍스트 스위칭은 순수한 오버헤드(overhead)이다. 스위칭이 진행되는 동안에는 어떤 유용한 작업도 수행되지 않는다. 일반적으로 컨텍스트 스위칭에는 수 마이크로초(μs)가 소요된다.

오버헤드가 발생하는 주요 원인은 다음과 같다. 첫째, PCB에 현재 프로세스의 모든 레지스터 값을 저장해야 한다. 둘째, 다음 프로세스의 레지스터 값을 PCB에서 복원해야 한다. 셋째, 메모리 맵(페이지 테이블)을 전환해야 한다. 넷째, CPU 캐시(L1, L2, L3)와 TLB(Translation Lookaside Buffer)가 무효화(flush)될 수 있어 캐시 미스가 증가한다.

이러한 이유로 과도한 컨텍스트 스위칭은 시스템 성능을 크게 저하시킨다. 따라서 프로세스 간 전환보다 스레드 간 전환이 더 효율적인데, 같은 프로세스의 스레드들은 메모리 공간을 공유하므로 메모리 맵 전환이 필요 없기 때문이다.

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
// Java에서 컨텍스트 스위칭 오버헤드 간접 측정
public class ContextSwitchBenchmark {
    public static void main(String[] args) throws Exception {
        final int ITERATIONS = 1_000_000;
        Object lock = new Object();

        long start = System.nanoTime();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < ITERATIONS; i++) {
                synchronized (lock) {
                    lock.notify();
                    try { lock.wait(); } catch (InterruptedException e) { break; }
                }
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < ITERATIONS; i++) {
                synchronized (lock) {
                    lock.notify();
                    try { lock.wait(); } catch (InterruptedException e) { break; }
                }
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        long elapsed = System.nanoTime() - start;
        System.out.printf("총 소요 시간: %d ms%n", elapsed / 1_000_000);
        System.out.printf("스위칭당 평균: %d ns%n", elapsed / (ITERATIONS * 2));
    }
}

7. 스레드의 개념

스레드(Thread)는 프로세스 내에서 실행되는 가장 작은 실행 단위이다. 하나의 프로세스는 하나 이상의 스레드를 포함하며, 같은 프로세스 내의 스레드들은 코드, 데이터, 힙 영역을 공유한다.

프로세스 vs 스레드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────── 프로세스 A ───────┐     ┌─────── 프로세스 B ───────┐
│                          │     │                          │
│  Code │ Data │ Heap      │     │  Code │ Data │ Heap      │
│  ─────────────────────── │     │  ─────────────────────── │
│                          │     │                          │
│  Thread1  │  Thread2     │     │  Thread1                 │
│  ┌──────┐ │ ┌──────┐    │     │  ┌──────┐                │
│  │Stack │ │ │Stack │    │     │  │Stack │                │
│  │ PC   │ │ │ PC   │    │     │  │ PC   │                │
│  │Reg   │ │ │Reg   │    │     │  │Reg   │                │
│  └──────┘ │ └──────┘    │     │  └──────┘                │
│           │              │     │                          │
└───────────┴──────────────┘     └──────────────────────────┘
         메모리 공유                    독립적 메모리
비교 항목 프로세스 스레드
메모리 독립적인 메모리 공간 프로세스의 메모리 공유
생성 비용 높음 (fork) 낮음 (clone)
통신 IPC 필요 (파이프, 소켓 등) 공유 메모리로 직접 통신
컨텍스트 스위칭 비용 높음 (메모리 맵 전환) 비용 낮음 (스택만 전환)
안정성 한 프로세스 오류가 다른 프로세스에 영향 없음 한 스레드 오류가 프로세스 전체에 영향
독립성 높음 낮음 (공유 자원으로 인한 동기화 필요)

사용자 수준 스레드 vs 커널 수준 스레드

사용자 수준 스레드(User-Level Thread)는 커널의 지원 없이 사용자 공간에서 라이브러리로 구현된 스레드이다. 커널은 이 스레드의 존재를 모른다. 스레드 전환 시 커널 모드로의 전환이 필요 없어 빠르지만, 하나의 스레드가 블로킹 시스템 콜을 호출하면 프로세스 전체가 블로킹된다.

커널 수준 스레드(Kernel-Level Thread)는 운영체제 커널이 직접 관리하는 스레드이다. 커널이 각 스레드를 독립적으로 스케줄링할 수 있어 하나의 스레드가 블로킹되어도 다른 스레드는 실행을 계속할 수 있다. 다만 스레드 생성과 전환 시 커널 모드 전환이 필요하여 오버헤드가 더 크다.

Java의 스레드는 JVM 구현에 따라 다르지만, 대부분의 현대 JVM(HotSpot 등)에서는 1:1 모델로 커널 수준 스레드에 매핑된다. Java 21부터는 Virtual Thread(가상 스레드)가 도입되어 M:N 모델도 지원한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 커널 스레드 (플랫폼 스레드) - 전통적인 방식
Thread platformThread = new Thread(() -> {
    System.out.println("플랫폼 스레드: " + Thread.currentThread());
});
platformThread.start();

// 가상 스레드 (Java 21+) - 경량 스레드
Thread virtualThread = Thread.ofVirtual().start(() -> {
    System.out.println("가상 스레드: " + Thread.currentThread());
});

// 가상 스레드 대량 생성 (10만 개도 가볍게)
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            Thread.sleep(java.time.Duration.ofSeconds(1));
            return "done";
        });
    }
}

8. 멀티프로세스 vs 멀티스레드

멀티프로세스(Multi-Process)는 여러 개의 독립적인 프로세스를 사용하여 병렬 처리하는 방식이다. 각 프로세스가 독립적인 메모리 공간을 가지므로 안정성이 높다. 하나의 프로세스가 비정상 종료되어도 다른 프로세스에는 영향을 주지 않는다. Chrome 브라우저가 대표적인 멀티프로세스 구조로, 각 탭이 독립적인 프로세스로 실행된다.

멀티스레드(Multi-Thread)는 하나의 프로세스 내에서 여러 스레드를 사용하여 병렬 처리하는 방식이다. 메모리를 공유하므로 통신이 빠르고 자원 효율적이지만, 동기화 문제에 주의해야 한다. 웹 서버(Tomcat 등)가 대표적인 멀티스레드 구조이다.

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
// 멀티프로세스 방식 - ProcessBuilder 사용
public class MultiProcessExample {
    public static void main(String[] args) throws Exception {
        List<Process> processes = new ArrayList<>();

        for (int i = 0; i < 4; i++) {
            ProcessBuilder pb = new ProcessBuilder(
                "java", "-cp", ".", "Worker", String.valueOf(i)
            );
            pb.inheritIO();
            processes.add(pb.start());
        }

        // 모든 프로세스 완료 대기
        for (Process p : processes) {
            int exitCode = p.waitFor();
            System.out.println("프로세스 종료 코드: " + exitCode);
        }
    }
}

// 멀티스레드 방식 - ExecutorService 사용
public class MultiThreadExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(4);
        List<Future<String>> futures = new ArrayList<>();

        for (int i = 0; i < 4; i++) {
            final int taskId = i;
            futures.add(executor.submit(() -> {
                System.out.println("Task " + taskId + " 실행 중 - "
                    + Thread.currentThread().getName());
                Thread.sleep(1000);
                return "Task " + taskId + " 완료";
            }));
        }

        for (Future<String> future : futures) {
            System.out.println(future.get());
        }

        executor.shutdown();
    }
}

실무에서는 보통 멀티스레드를 기본으로 사용하되, 안정성이 매우 중요한 경우(예: 브라우저 탭, 데이터베이스 연결 등)에는 멀티프로세스를 함께 사용한다.


9. IPC (Inter-Process Communication)

프로세스는 기본적으로 독립적인 메모리 공간을 가지므로, 프로세스 간 데이터를 교환하려면 별도의 통신 메커니즘이 필요하다. 이를 IPC(Inter-Process Communication)라 한다.

파이프 (Pipe)

파이프는 한 방향으로만 데이터를 전송할 수 있는 단방향 통신 채널이다. 부모-자식 프로세스 간 통신에 주로 사용된다. 양방향 통신이 필요하면 두 개의 파이프를 생성해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Java에서 파이프를 통한 프로세스 간 통신
public class PipeExample {
    public static void main(String[] args) throws Exception {
        // ls의 출력을 grep으로 전달하는 파이프
        ProcessBuilder ls = new ProcessBuilder("ls", "-la");
        ProcessBuilder grep = new ProcessBuilder("grep", ".java");

        // Java 9+ 파이프라인
        List<Process> pipeline = ProcessBuilder.startPipeline(
            List.of(ls, grep)
        );

        // 마지막 프로세스의 출력 읽기
        Process last = pipeline.get(pipeline.size() - 1);
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(last.getInputStream()))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        }
    }
}

공유 메모리 (Shared Memory)

공유 메모리는 두 개 이상의 프로세스가 동일한 메모리 영역에 접근하는 방식이다. 커널이 아닌 사용자 공간에서 직접 데이터를 교환하므로 속도가 가장 빠르다. 다만, 동기화를 직접 관리해야 한다.

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
// Java NIO의 MappedByteBuffer를 통한 메모리 맵 파일 (공유 메모리와 유사)
import java.nio.*;
import java.nio.channels.*;
import java.io.*;

// 프로세스 1: 데이터 쓰기
public class SharedMemoryWriter {
    public static void main(String[] args) throws Exception {
        RandomAccessFile file = new RandomAccessFile("shared.dat", "rw");
        FileChannel channel = file.getChannel();

        // 메모리 맵 파일 생성 (1KB)
        MappedByteBuffer buffer = channel.map(
            FileChannel.MapMode.READ_WRITE, 0, 1024
        );

        // 데이터 쓰기
        buffer.putInt(42);
        buffer.putDouble(3.14159);
        buffer.put("Hello from Process 1".getBytes());

        System.out.println("데이터를 공유 메모리에 작성했습니다.");
        channel.close();
        file.close();
    }
}

// 프로세스 2: 데이터 읽기
public class SharedMemoryReader {
    public static void main(String[] args) throws Exception {
        RandomAccessFile file = new RandomAccessFile("shared.dat", "r");
        FileChannel channel = file.getChannel();

        MappedByteBuffer buffer = channel.map(
            FileChannel.MapMode.READ_ONLY, 0, 1024
        );

        int intValue = buffer.getInt();
        double doubleValue = buffer.getDouble();
        byte[] strBytes = new byte[20];
        buffer.get(strBytes);

        System.out.println("정수: " + intValue);
        System.out.println("실수: " + doubleValue);
        System.out.println("문자열: " + new String(strBytes).trim());

        channel.close();
        file.close();
    }
}

소켓 (Socket)

소켓은 네트워크를 통해 서로 다른 호스트에 있는 프로세스 간 통신을 가능하게 하는 메커니즘이다. 같은 호스트 내에서도 사용 가능하며, 클라이언트-서버 모델의 기반이 된다.

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
// 서버 소켓
public class SocketServer {
    public static void main(String[] args) throws Exception {
        ServerSocket server = new ServerSocket(8080);
        System.out.println("서버 시작 - 포트 8080");

        while (true) {
            Socket client = server.accept();
            new Thread(() -> handleClient(client)).start();
        }
    }

    static void handleClient(Socket client) {
        try (BufferedReader in = new BufferedReader(
                new InputStreamReader(client.getInputStream()));
             PrintWriter out = new PrintWriter(
                client.getOutputStream(), true)) {

            String message = in.readLine();
            System.out.println("수신: " + message);
            out.println("서버 응답: " + message.toUpperCase());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

메시지 큐 (Message Queue)

메시지 큐는 커널이 관리하는 메시지 버퍼를 통해 프로세스 간 메시지를 교환하는 방식이다. 비동기적으로 동작하며, 메시지에 타입을 지정하여 선택적으로 수신할 수 있다. Java에서는 JMS(Java Message Service)나 외부 메시지 브로커(RabbitMQ, Kafka)를 사용한다.


10. 동기화 문제

멀티스레드 환경에서는 여러 스레드가 공유 자원에 동시에 접근하면서 다양한 동기화 문제가 발생할 수 있다.

Race Condition (경쟁 조건)

두 개 이상의 스레드가 공유 데이터에 동시에 접근하여 읽기/쓰기를 수행할 때, 실행 순서에 따라 결과가 달라지는 상황이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RaceConditionExample {
    private static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100_000; i++) counter++;
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100_000; i++) counter++;
        });

        t1.start(); t2.start();
        t1.join(); t2.join();

        // 기대값: 200,000이지만 실제로는 더 작은 값이 나옴
        System.out.println("Counter: " + counter);  // 예: 156,832
    }
}

Critical Section (임계 영역)

공유 자원에 접근하는 코드 영역을 임계 영역이라 한다. 임계 영역 문제를 해결하기 위한 세 가지 조건이 있다.

  1. 상호 배제(Mutual Exclusion): 하나의 스레드가 임계 영역에 있으면 다른 스레드는 진입할 수 없다.
  2. 진행(Progress): 임계 영역에 있는 스레드가 없다면, 진입하려는 스레드 중 하나가 유한 시간 내에 진입할 수 있어야 한다.
  3. 한정 대기(Bounded Waiting): 임계 영역에 진입하려는 스레드가 무한정 기다려서는 안 된다.

Mutex, Semaphore, Monitor

Mutex(뮤텍스)는 상호 배제를 보장하는 가장 기본적인 동기화 도구로, 하나의 스레드만 접근 가능한 잠금(lock) 메커니즘이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Java의 ReentrantLock = Mutex
import java.util.concurrent.locks.ReentrantLock;

public class MutexExample {
    private final ReentrantLock mutex = new ReentrantLock();
    private int sharedResource = 0;

    public void increment() {
        mutex.lock();
        try {
            sharedResource++;  // 임계 영역
        } finally {
            mutex.unlock();
        }
    }
}

Semaphore(세마포어)는 카운터 기반의 동기화 도구로, 지정된 수만큼의 스레드가 동시에 접근할 수 있도록 허용한다. 카운터가 1인 세마포어는 Mutex와 동일하게 동작한다(이진 세마포어).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    // 최대 3개 스레드 동시 접근 가능
    private final Semaphore semaphore = new Semaphore(3);

    public void accessResource(int threadId) {
        try {
            semaphore.acquire();  // 카운터 감소 (P 연산)
            System.out.println("Thread " + threadId + " 진입 (허가: "
                + semaphore.availablePermits() + ")");
            Thread.sleep(2000);  // 자원 사용
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            semaphore.release();  // 카운터 증가 (V 연산)
            System.out.println("Thread " + threadId + " 퇴장");
        }
    }
}

Monitor(모니터)는 상호 배제와 조건 변수를 결합한 고수준 동기화 도구이다. Java의 synchronized 키워드가 모니터를 구현한 것이다. 모든 Java 객체는 하나의 모니터를 가진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Java synchronized = Monitor
public class MonitorExample {
    private final Queue<Integer> buffer = new LinkedList<>();
    private final int MAX_SIZE = 10;

    public synchronized void produce(int item) throws InterruptedException {
        while (buffer.size() == MAX_SIZE) {
            wait();  // 버퍼가 가득 차면 대기
        }
        buffer.add(item);
        System.out.println("생산: " + item + " (버퍼 크기: " + buffer.size() + ")");
        notifyAll();  // 대기 중인 소비자 깨우기
    }

    public synchronized int consume() throws InterruptedException {
        while (buffer.isEmpty()) {
            wait();  // 버퍼가 비면 대기
        }
        int item = buffer.poll();
        System.out.println("소비: " + item + " (버퍼 크기: " + buffer.size() + ")");
        notifyAll();  // 대기 중인 생산자 깨우기
        return item;
    }
}

11. 데드락 (Deadlock)

데드락은 두 개 이상의 프로세스(또는 스레드)가 서로가 보유한 자원을 요청하면서 무한히 대기하는 상태이다.

데드락 발생 조건 4가지 (Coffman 조건)

데드락이 발생하려면 다음 네 가지 조건이 동시에 성립해야 한다.

  1. 상호 배제(Mutual Exclusion): 자원은 한 번에 하나의 프로세스만 사용할 수 있다.
  2. 점유와 대기(Hold and Wait): 자원을 보유한 프로세스가 다른 자원을 요청하며 대기한다.
  3. 비선점(No Preemption): 프로세스가 보유한 자원을 강제로 빼앗을 수 없다.
  4. 순환 대기(Circular Wait): 프로세스 간 자원 요청이 순환 형태를 이룬다.
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
// 데드락 발생 예제
public class DeadlockExample {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread 1: lockA 획득");
                try { Thread.sleep(100); } catch (InterruptedException e) {}

                System.out.println("Thread 1: lockB 대기...");
                synchronized (lockB) {  // lockB를 기다림 → 데드락!
                    System.out.println("Thread 1: lockB 획득");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lockB) {
                System.out.println("Thread 2: lockB 획득");
                try { Thread.sleep(100); } catch (InterruptedException e) {}

                System.out.println("Thread 2: lockA 대기...");
                synchronized (lockA) {  // lockA를 기다림 → 데드락!
                    System.out.println("Thread 2: lockA 획득");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

데드락 해결 방법

예방(Prevention): 네 가지 조건 중 하나를 원천적으로 차단한다. 가장 실용적인 방법은 순환 대기를 방지하기 위해 자원에 순서를 부여하고, 항상 같은 순서로 자원을 요청하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 데드락 예방 - Lock 순서 통일
public class DeadlockPrevention {
    private static final Object lockA = new Object();  // 순서 1
    private static final Object lockB = new Object();  // 순서 2

    // 항상 lockA → lockB 순서로 획득
    public static void method1() {
        synchronized (lockA) {
            synchronized (lockB) {
                System.out.println("안전한 실행");
            }
        }
    }

    // 마찬가지로 lockA → lockB 순서
    public static void method2() {
        synchronized (lockA) {
            synchronized (lockB) {
                System.out.println("안전한 실행");
            }
        }
    }
}

회피(Avoidance): 자원 할당 시 시스템이 안전한 상태(Safe State)를 유지할 수 있는지 확인한 후 할당한다. 대표적인 알고리즘이 은행원 알고리즘(Banker’s Algorithm)이다.

탐지(Detection) & 복구(Recovery): 데드락 발생을 허용하되, 주기적으로 데드락을 탐지하고 발견되면 프로세스를 강제 종료하거나 자원을 선점하여 복구한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Java에서 데드락 탐지
import java.lang.management.*;

public class DeadlockDetector {
    public static void detectDeadlock() {
        ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
        long[] deadlockedThreads = threadBean.findDeadlockedThreads();

        if (deadlockedThreads != null) {
            System.out.println("데드락 발견!");
            ThreadInfo[] threadInfos = threadBean.getThreadInfo(deadlockedThreads, true, true);
            for (ThreadInfo info : threadInfos) {
                System.out.println("스레드: " + info.getThreadName());
                System.out.println("상태: " + info.getThreadState());
                System.out.println("대기 중인 락: " + info.getLockName());
                System.out.println("락 소유자: " + info.getLockOwnerName());
                System.out.println("---");
            }
        } else {
            System.out.println("데드락 없음");
        }
    }
}

식사하는 철학자 문제 (Dining Philosophers Problem)

데드락의 대표적인 예제로, 다섯 명의 철학자가 원형 테이블에 앉아 있고, 각 철학자 사이에 젓가락이 하나씩 놓여 있다. 철학자는 식사하려면 양쪽의 젓가락 두 개를 모두 집어야 한다. 모든 철학자가 동시에 왼쪽 젓가락을 집으면 오른쪽 젓가락을 집을 수 없어 데드락이 발생한다.

해결 방법으로는 짝수 번째 철학자는 오른쪽 먼저, 홀수 번째는 왼쪽 먼저 집도록 순서를 정하는 방법이 있다.

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
52
53
54
public class DiningPhilosophers {
    private static final int NUM = 5;
    private static final ReentrantLock[] chopsticks = new ReentrantLock[NUM];

    static {
        for (int i = 0; i < NUM; i++) {
            chopsticks[i] = new ReentrantLock();
        }
    }

    static class Philosopher extends Thread {
        private final int id;

        Philosopher(int id) { this.id = id; this.setName("철학자-" + id); }

        public void run() {
            try {
                while (!Thread.interrupted()) {
                    think();
                    eat();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        void think() throws InterruptedException {
            System.out.println(getName() + " 생각 중...");
            Thread.sleep((long) (Math.random() * 1000));
        }

        void eat() throws InterruptedException {
            // 데드락 방지: 항상 번호가 작은 젓가락 먼저 집기
            int first = Math.min(id, (id + 1) % NUM);
            int second = Math.max(id, (id + 1) % NUM);

            chopsticks[first].lock();
            chopsticks[second].lock();
            try {
                System.out.println(getName() + " 식사 중 🍝");
                Thread.sleep((long) (Math.random() * 1000));
            } finally {
                chopsticks[second].unlock();
                chopsticks[first].unlock();
            }
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < NUM; i++) {
            new Philosopher(i).start();
        }
    }
}

12. Java에서의 프로세스/스레드 활용

ProcessBuilder를 이용한 프로세스 관리

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
public class ProcessManagement {
    public static void main(String[] args) throws Exception {
        // 외부 프로세스 실행
        ProcessBuilder builder = new ProcessBuilder("ping", "-c", "3", "google.com");
        builder.redirectErrorStream(true);  // 에러 스트림을 표준 출력으로 합침

        Process process = builder.start();

        // 출력 읽기
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        }

        int exitCode = process.waitFor();
        System.out.println("종료 코드: " + exitCode);

        // 타임아웃 설정
        ProcessBuilder pb2 = new ProcessBuilder("sleep", "30");
        Process p2 = pb2.start();
        boolean finished = p2.waitFor(5, java.util.concurrent.TimeUnit.SECONDS);
        if (!finished) {
            p2.destroyForcibly();
            System.out.println("프로세스 강제 종료됨");
        }
    }
}

스레드풀과 CompletableFuture

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
import java.util.concurrent.*;

public class ThreadPoolExample {
    public static void main(String[] args) throws Exception {
        // 고정 크기 스레드풀
        ExecutorService pool = Executors.newFixedThreadPool(
            Runtime.getRuntime().availableProcessors()
        );

        // CompletableFuture를 활용한 비동기 처리
        CompletableFuture<String> future = CompletableFuture
            .supplyAsync(() -> {
                // 비동기로 사용자 정보 조회
                return fetchUser("user123");
            }, pool)
            .thenApplyAsync(user -> {
                // 사용자의 주문 목록 조회
                return fetchOrders(user);
            }, pool)
            .thenApplyAsync(orders -> {
                // 주문 요약 생성
                return createSummary(orders);
            }, pool)
            .exceptionally(ex -> {
                System.err.println("에러 발생: " + ex.getMessage());
                return "기본 요약";
            });

        String result = future.get(10, TimeUnit.SECONDS);
        System.out.println("결과: " + result);

        pool.shutdown();
    }

    static String fetchUser(String id) { return "User-" + id; }
    static String fetchOrders(String user) { return user + "-orders"; }
    static String createSummary(String orders) { return "Summary: " + orders; }
}

13. 면접에서 자주 나오는 OS 질문과 답변

Q1: 프로세스와 스레드의 차이점을 설명하세요.

프로세스는 운영체제로부터 독립적인 메모리 공간(Code, Data, Heap, Stack)을 할당받는 실행 단위입니다. 스레드는 프로세스 내에서 Code, Data, Heap을 공유하고 각자의 Stack만 독립적으로 가지는 실행 단위입니다. 스레드는 프로세스보다 생성/전환 비용이 낮고 통신이 빠르지만, 공유 자원에 대한 동기화 문제가 발생할 수 있습니다.

Q2: 컨텍스트 스위칭이란 무엇이며, 프로세스와 스레드의 컨텍스트 스위칭의 차이점은?

컨텍스트 스위칭은 CPU가 현재 작업을 중단하고 다른 작업으로 전환할 때, 현재 상태를 PCB에 저장하고 다음 상태를 복원하는 과정입니다. 프로세스 컨텍스트 스위칭은 메모리 맵(페이지 테이블)까지 전환해야 하므로 TLB 플러시가 발생하여 비용이 큽니다. 스레드 컨텍스트 스위칭은 같은 프로세스 내에서 스택과 레지스터만 전환하므로 비용이 상대적으로 적습니다.

Q3: 데드락의 발생 조건 4가지와 해결 방법은?

상호 배제, 점유와 대기, 비선점, 순환 대기 네 가지가 동시에 성립해야 합니다. 해결 방법으로는 예방(조건 하나를 차단), 회피(은행원 알고리즘), 탐지 후 복구가 있습니다. 실무에서는 주로 자원 획득 순서를 통일하여 순환 대기를 방지하거나, tryLock()으로 타임아웃을 설정합니다.

Q4: 사용자 수준 스레드와 커널 수준 스레드의 차이점은?

사용자 수준 스레드는 라이브러리에서 관리하며 커널이 존재를 모릅니다. 전환이 빠르지만 하나가 블로킹되면 전체 프로세스가 블로킹됩니다. 커널 수준 스레드는 OS가 직접 관리하여 독립적 스케줄링이 가능하지만 전환 오버헤드가 큽니다. Java의 플랫폼 스레드는 1:1로 커널 스레드에 매핑되고, Java 21의 가상 스레드는 M:N 모델입니다.

Q5: 뮤텍스와 세마포어의 차이점은?

뮤텍스는 오직 하나의 스레드만 임계 영역에 접근하도록 하는 잠금으로, 소유권 개념이 있어 잠금을 획득한 스레드만 해제할 수 있습니다. 세마포어는 카운터 기반으로 지정된 수만큼 동시 접근을 허용하며, 소유권 개념이 없어 다른 스레드가 시그널을 보낼 수 있습니다. 이진 세마포어(카운터=1)는 뮤텍스와 유사하게 동작합니다.

Q6: 멀티프로세스 대신 멀티스레드를 사용하는 이유는?

스레드는 프로세스보다 생성과 컨텍스트 스위칭 비용이 낮습니다. 같은 프로세스 내 스레드들은 메모리(Code, Data, Heap)를 공유하므로 IPC 없이 직접 데이터를 교환할 수 있어 통신이 빠르고 효율적입니다. 다만 공유 자원 접근 시 동기화에 주의해야 하며, 하나의 스레드 오류가 프로세스 전체에 영향을 줄 수 있다는 단점이 있습니다.


정리

운영체제의 프로세스와 스레드는 백엔드 개발자에게 가장 기본이 되는 CS 지식이다. 특히 Java 백엔드 개발에서는 Tomcat과 같은 웹 서버가 멀티스레드 모델로 요청을 처리하고, Spring의 @Async, CompletableFuture 등으로 비동기 처리를 구현하기 때문에 프로세스와 스레드의 동작 원리를 깊이 이해하는 것이 필수적이다.

핵심 정리 포인트는 다음과 같다. 프로세스는 독립 메모리, 스레드는 공유 메모리. 컨텍스트 스위칭은 순수 오버헤드이며 스레드가 더 가볍다. 동기화는 Mutex/Semaphore/Monitor로 처리한다. 데드락은 네 가지 조건이 동시에 만족해야 발생하며, 자원 순서 통일이 가장 실용적인 예방법이다. 실무에서는 직접 스레드를 생성하기보다 ExecutorService와 CompletableFuture를 사용하는 것이 권장된다.