동시성을 구현하는 재료

동시성을 구현하는 재료

동시성을 구현하는 재료

운영체제는 실행(execution)을 실제 하드웨어에 배정하는 역할을 한다. 동시성을 구현하기 위한 기본 재료는 프로세스스레드이며, 이 둘의 특성을 이해하는 것이 동시성 프로그래밍의 출발점이다.


1. 프로세스 (Process)

1.1 프로세스란?

프로세스는 컴퓨터에서 실행 중인 프로그램의 한 인스턴스를 가리킨다. 각 프로세스는 하나 또는 그 이상의 실행 스레드를 가지며, 프로세스에 포함되지 않은 스레드는 존재할 수 없다.

프로그램이 디스크에 저장된 정적인 코드라면, 프로세스는 그 코드가 메모리에 올라와 실행 중인 동적 상태이다.

┌──────────────────────────────────────────────────────────────┐
│                     프로그램 vs 프로세스                        │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  디스크                         메모리                       │
│  ┌──────────────┐              ┌──────────────┐             │
│  │  program.exe │  ── 실행 ──→ │  프로세스 A   │             │
│  │  (정적 코드)  │              │  (실행 상태)  │             │
│  └──────────────┘              └──────────────┘             │
│                                                              │
│        같은 프로그램을           ┌──────────────┐             │
│        여러 번 실행 가능  ────→  │  프로세스 B   │             │
│                                └──────────────┘             │
│                                                              │
│  하나의 프로그램에서 여러 프로세스(인스턴스)를 만들 수 있다      │
└──────────────────────────────────────────────────────────────┘

1.2 프로세스의 메모리 구조

운영체제는 각 프로세스에 독립된 주소 공간을 할당한다.

┌──────────────────────────────────────────────────────────────┐
│                   프로세스 메모리 레이아웃                      │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  높은 주소                                                    │
│  ┌──────────────────────┐                                    │
│  │     Stack (스택)      │  지역 변수, 함수 호출 정보          │
│  │         ↓            │  (위에서 아래로 성장)               │
│  ├──────────────────────┤                                    │
│  │     (빈 공간)         │                                    │
│  ├──────────────────────┤                                    │
│  │         ↑            │  (아래에서 위로 성장)               │
│  │     Heap (힙)        │  동적 할당 (new, malloc)           │
│  ├──────────────────────┤                                    │
│  │     Data (데이터)     │  전역 변수, 정적 변수              │
│  ├──────────────────────┤                                    │
│  │     Code (텍스트)     │  실행할 기계어 명령어              │
│  └──────────────────────┘                                    │
│  낮은 주소                                                    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

1.3 프로세스의 특성

특성설명
독립된 주소 공간각 프로세스는 별도의 메모리 영역을 가짐
격리성한 프로세스의 오류가 다른 프로세스에 영향을 주지 않음
자원 소유메모리, 파일 핸들, 소켓 등 독립적인 자원 보유
높은 생성 비용새 주소 공간 할당, 메모리 복사 등 비용이 큼

1.4 프로세스 간 통신 (IPC)

프로세스끼리는 거의 독립적이므로 데이터를 직접 공유할 수 없다. 운영체제가 제공하는 IPC(Inter-Process Communication) 메커니즘을 사용해야 한다.

IPC 방식설명특성
파이프 (Pipe)한 프로세스의 출력을 다른 프로세스의 입력으로 연결단방향, 부모-자식 관계
소켓 (Socket)네트워크 프로토콜을 통한 통신양방향, 원격 가능
공유 메모리같은 물리 메모리 영역을 여러 프로세스가 매핑가장 빠름, 동기화 필요
메시지 큐메시지를 큐에 넣고 꺼내는 비동기 통신느슨한 결합
┌──────────────────────────────────────────────────────────────┐
│                   프로세스 간 통신 (IPC)                       │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────┐                        ┌──────────┐           │
│  │ 프로세스 A │                        │ 프로세스 B │           │
│  │          │  ─── 파이프/소켓 ───→   │          │           │
│  │ 주소공간A │  ←── 메시지 큐 ────    │ 주소공간B │           │
│  │          │                        │          │           │
│  └─────┬────┘                        └─────┬────┘           │
│        │          ┌──────────┐             │                │
│        └─────────→│ 공유 메모리│←────────────┘                │
│                   └──────────┘                               │
│                   (운영체제가 관리)                            │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Java 예제 - ProcessBuilder로 프로세스 생성:

import java.io.*;

public class ProcessExample {

    public static void main(String[] args) throws Exception {
        System.out.println("=== 프로세스 생성 예제 ===\n");
        System.out.println("현재 프로세스 PID: " + ProcessHandle.current().pid());

        // 새 프로세스 생성 (자식 프로세스)
        ProcessBuilder pb = new ProcessBuilder("java", "-version");
        pb.redirectErrorStream(true);

        Process process = pb.start();

        // 자식 프로세스의 출력 읽기 (파이프를 통한 IPC)
        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);
    }
}

2. 스레드 (Thread)

2.1 스레드란?

스레드는 어떤 특정한 결과를 내기 위해 만들어진 독립적인 인스트럭션의 집합이며 실행의 단위다. 또한 운영체제가 독립적으로 실행하고 관리한다.

프로세스가 “작업장” 이라면, 스레드는 그 작업장에서 일하는 “작업자” 에 해당한다. 한 작업장(프로세스)에 여러 작업자(스레드)가 있으면 도구와 자재(메모리)를 공유하면서 동시에 작업할 수 있다.

2.2 스레드의 메모리 구조

같은 프로세스의 스레드끼리 Code, Data, Heap을 공유하지만, 각 스레드는 독립적인 Stack을 가진다.

┌──────────────────────────────────────────────────────────────┐
│                    프로세스 내 스레드 구조                      │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                   공유 영역                           │   │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────────────┐   │   │
│  │  │   Code   │  │   Data   │  │      Heap        │   │   │
│  │  │ (명령어)  │  │ (전역변수) │  │  (동적 할당)      │   │   │
│  │  └──────────┘  └──────────┘  └──────────────────┘   │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                   비공유 영역 (스레드별)               │   │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐          │   │
│  │  │ Stack T1 │  │ Stack T2 │  │ Stack T3 │          │   │
│  │  │ PC    T1 │  │ PC    T2 │  │ PC    T3 │          │   │
│  │  │ 레지스터  │  │ 레지스터  │  │ 레지스터  │          │   │
│  │  └──────────┘  └──────────┘  └──────────┘          │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

2.3 공유 영역과 비공유 영역

구분공유 (프로세스 전체)비공유 (스레드별)
메모리Code, Data, HeapStack
레지스터-PC(프로그램 카운터), SP(스택 포인터)
기타파일 핸들, 소켓, 시그널 핸들러스레드 ID, 우선순위, 에러 번호

2.4 Java에서 스레드 생성

방법 1: Thread 상속

public class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println(getName() + " 실행 중");
    }

    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        t1.start();  // 새 스레드에서 run() 실행
        t2.start();
    }
}

방법 2: Runnable 구현 (권장)

public class MyRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println(
            Thread.currentThread().getName() + " 실행 중");
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new MyRunnable(), "작업자-1");
        Thread t2 = new Thread(new MyRunnable(), "작업자-2");
        t1.start();
        t2.start();
    }
}

방법 3: 람다식 (Java 8+)

public class LambdaThread {

    public static void main(String[] args) {
        Thread t = new Thread(() ->
            System.out.println(
                Thread.currentThread().getName() + " 실행 중"),
            "람다-스레드"
        );
        t.start();
    }
}

start()는 새 스레드를 만들어 run()을 실행하고, run()을 직접 호출하면 현재 스레드에서 그냥 메서드만 실행된다.


3. 프로세스 vs 스레드

3.1 핵심 비교

┌──────────────────────────────────────────────────────────────┐
│                   프로세스 vs 스레드 비교                      │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  프로세스                          스레드                     │
│  ──────────                       ──────                     │
│                                                              │
│  ┌─────────┐  ┌─────────┐        ┌─────────────────┐       │
│  │ 프로세스A│  │ 프로세스B│        │    프로세스 C     │       │
│  │         │  │         │        │ ┌───┐┌───┐┌───┐ │       │
│  │ [메모리] │  │ [메모리] │        │ │T1 ││T2 ││T3 │ │       │
│  │ [파일]  │  │ [파일]  │        │ └───┘└───┘└───┘ │       │
│  └─────────┘  └─────────┘        │  [공유 메모리]    │       │
│   완전 분리     완전 분리          └─────────────────┘       │
│                                    자원 공유                 │
│                                                              │
│  장점: 안전, 격리               장점: 빠름, 공유 쉬움         │
│  단점: 느림, 통신 비용          단점: 동기화 필요, 오류 전파   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

3.2 상세 비교표

구분프로세스스레드
정의실행 중인 프로그램 인스턴스프로세스 내 실행 단위
주소 공간독립프로세스 내 공유
생성 비용높음 (새 주소 공간 할당)낮음 (스택만 할당)
전환 비용높음 (TLB 플러시 등)낮음 (레지스터만 교체)
데이터 공유IPC 필요 (느림)직접 접근 (빠름)
안정성높음 (한 프로세스 죽어도 다른 프로세스 무관)낮음 (한 스레드 오류가 프로세스 전체에 영향)
동기화일반적으로 불필요반드시 필요 (공유 자원 보호)
디버깅상대적으로 쉬움어려움 (비결정적 동작)

3.3 Java 예제 - 스레드의 자원 공유 확인

public class SharedResourceDemo {

    // 힙 영역 - 모든 스레드가 공유
    private static int sharedCounter = 0;

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== 스레드의 자원 공유 확인 ===\n");

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                sharedCounter++;  // 공유 변수 접근
            }
            System.out.println("T1 완료");
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                sharedCounter++;  // 같은 공유 변수 접근
            }
            System.out.println("T2 완료");
        }, "Thread-2");

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

        // 기대값: 2000, 실제값: 2000 이하 (경쟁 조건!)
        System.out.println("기대값: 2000");
        System.out.println("실제값: " + sharedCounter);
        System.out.println("\n→ 동기화 없이 공유 자원에 접근하면 데이터 오염이 발생한다");
    }
}

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

4.1 컨텍스트 스위칭이란?

CPU가 현재 실행 중인 작업 단위에서 다른 작업 단위로 전환할 때, 현재 상태를 저장하고 다음 상태를 복원하는 과정이다.

┌──────────────────────────────────────────────────────────────┐
│                  컨텍스트 스위칭 과정                          │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  시간 ──────────────────────────────────────→                │
│                                                              │
│  Thread A    ████████░░░░░░░░░░████████░░░░                 │
│  Thread B    ░░░░░░░░████████░░░░░░░░░████                  │
│                      ↑               ↑                       │
│                   전환(저장/복원)   전환(저장/복원)             │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  전환 시 수행하는 작업                                   │ │
│  │  ① 현재 스레드의 PC, 레지스터, 스택 포인터 저장          │ │
│  │  ② 다음 스레드의 PC, 레지스터, 스택 포인터 복원          │ │
│  │  ③ (프로세스 전환 시) 메모리 매핑 교체, TLB 플러시       │ │
│  └────────────────────────────────────────────────────────┘ │
│                                                              │
└──────────────────────────────────────────────────────────────┘

4.2 스레드 전환 vs 프로세스 전환

비교 항목스레드 전환프로세스 전환
저장/복원 대상레지스터, PC, SP레지스터 + 메모리 매핑 정보
메모리 매핑변경 불필요 (같은 주소 공간)교체 필요
TLB (캐시)유지플러시 (무효화)
비용낮음높음

스레드끼리는 같은 주소 공간을 공유하므로 전환 시 메모리 관련 작업이 필요 없어 프로세스 전환보다 훨씬 가볍다. 이것이 동시적 애플리케이션에서 스레드를 선호하는 핵심 이유다.

4.3 컨텍스트 스위칭의 비용

컨텍스트 스위칭은 “공짜"가 아니다. 전환 자체에 CPU 시간을 소비하며, 캐시 미스가 발생하면 추가 지연이 생긴다.

public class ContextSwitchDemo {

    public static void main(String[] args) throws InterruptedException {
        int iterations = 100_000_000;

        // 단일 스레드: 컨텍스트 스위칭 없음
        long start = System.currentTimeMillis();
        long sum = 0;
        for (int i = 0; i < iterations; i++) {
            sum += i;
        }
        long singleTime = System.currentTimeMillis() - start;

        // 두 스레드: 컨텍스트 스위칭 발생
        start = System.currentTimeMillis();
        final long[] sums = new long[2];

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < iterations / 2; i++) {
                sums[0] += i;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = iterations / 2; i < iterations; i++) {
                sums[1] += i;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        long multiTime = System.currentTimeMillis() - start;

        System.out.println("=== 컨텍스트 스위칭 비용 관찰 ===\n");
        System.out.println("단일 스레드: " + singleTime + "ms");
        System.out.println("두 스레드:   " + multiTime + "ms");
        System.out.println("\n→ 단순 연산은 스레드를 나눠도 빨라지지 않을 수 있다");
        System.out.println("  (컨텍스트 스위칭 오버헤드 + 캐시 미스)");
    }
}

5. 스레드 생명주기

5.1 상태 전이

운영체제와 JVM은 스레드의 상태를 관리한다. Java의 Thread.State는 다음 6가지 상태를 정의한다.

┌──────────────────────────────────────────────────────────────┐
│                   스레드 생명주기                               │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│                  new Thread()                                │
│                      │                                       │
│                      ▼                                       │
│                ┌──────────┐                                  │
│                │   NEW    │                                  │
│                └────┬─────┘                                  │
│                     │ start()                                │
│                     ▼                                        │
│                ┌──────────┐                                  │
│           ┌──→ │ RUNNABLE │ ←──────────────────────┐        │
│           │    └────┬─────┘                        │        │
│           │         │                              │        │
│           │    ┌────┴─────────────────────────┐    │        │
│           │    │                              │    │        │
│           │    ▼              ▼               ▼    │        │
│           │ ┌────────┐ ┌─────────┐ ┌──────────┐   │        │
│           │ │BLOCKED │ │WAITING  │ │  TIMED   │   │        │
│           │ │        │ │         │ │ WAITING  │   │        │
│           │ │ 락 대기 │ │ 알림대기 │ │ 시간대기  │   │        │
│           │ └────┬───┘ └────┬────┘ └─────┬────┘   │        │
│           │      │          │            │        │        │
│           │      └──────────┴────────────┘        │        │
│           │              복귀 ────────────────────┘        │
│           │                                                │
│           │         run() 완료                              │
│           │              │                                  │
│           │              ▼                                  │
│           │        ┌───────────┐                            │
│           │        │TERMINATED │                            │
│           │        └───────────┘                            │
│           │                                                  │
└───────────┴──────────────────────────────────────────────────┘

5.2 상태 설명

상태설명진입 조건
NEW생성됨, 아직 시작 전new Thread()
RUNNABLE실행 가능 (CPU 대기 또는 실행 중)start() 호출
BLOCKED모니터 락 획득 대기synchronized 진입 시도
WAITING무기한 대기wait(), join(), park()
TIMED_WAITING지정 시간 대기sleep(ms), wait(ms)
TERMINATED실행 완료run() 종료 또는 예외

Java 예제 - 스레드 상태 관찰:

public class ThreadStateDemo {

    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();

        Thread thread = new Thread(() -> {
            try {
                // TIMED_WAITING 상태
                Thread.sleep(1000);

                synchronized (lock) {
                    // WAITING 상태
                    lock.wait();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "관찰-스레드");

        // NEW 상태
        System.out.println("생성 후: " + thread.getState());

        thread.start();
        Thread.sleep(100);

        // TIMED_WAITING 상태 (sleep 중)
        System.out.println("sleep 중: " + thread.getState());

        Thread.sleep(1100);

        // WAITING 상태 (wait 중)
        System.out.println("wait 중: " + thread.getState());

        // notify로 깨우기
        synchronized (lock) {
            lock.notify();
        }

        thread.join();

        // TERMINATED 상태
        System.out.println("종료 후: " + thread.getState());
    }
}

6. 스레드의 위험성

6.1 왜 위험한가?

한 프로세스 안에서 여러 실행 스레드가 서로 자원을 공유할 수 있다. 스레드끼리는 주소 공간을 공유하므로 데이터 공유 속도가 훨씬 빠르다. 그러나 그만큼 접근 제어나 동기화에 주의하지 않으면 데이터 오염을 일으키기도 쉽다.

6.2 세 가지 주요 위험

┌──────────────────────────────────────────────────────────────┐
│                    스레드의 세 가지 위험                        │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ① 경쟁 조건 (Race Condition)                                │
│  ────────────────────────────────                            │
│  여러 스레드가 동시에 같은 데이터를 수정할 때                   │
│  결과가 실행 순서에 따라 달라지는 문제                         │
│                                                              │
│  ② 가시성 문제 (Visibility)                                  │
│  ────────────────────────────                                │
│  한 스레드가 변경한 값을 다른 스레드가 보지 못하는 문제         │
│  CPU 캐시, 컴파일러 최적화로 인해 발생                        │
│                                                              │
│  ③ 재배치 (Reordering)                                      │
│  ────────────────────────                                    │
│  컴파일러/프로세서가 성능을 위해 명령어 순서를 바꾸는 문제      │
│  단일 스레드에서는 문제없지만, 멀티스레드에서 예상 밖의 결과    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

6.3 경쟁 조건 예제

public class RaceConditionDemo {

    private static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== 경쟁 조건 시연 ===\n");

        for (int trial = 1; trial <= 5; trial++) {
            counter = 0;

            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();

            System.out.println("시도 " + trial
                + " → 기대: 200000, 실제: " + counter);
        }

        System.out.println("\n→ 매번 다른 결과가 나온다 (비결정적 동작)");
    }
}
시도 1 → 기대: 200000, 실제: 153847
시도 2 → 기대: 200000, 실제: 167231
시도 3 → 기대: 200000, 실제: 189456
시도 4 → 기대: 200000, 실제: 145892
시도 5 → 기대: 200000, 실제: 172064

6.4 해결 방향

위험해결 방법
경쟁 조건synchronized, Lock, Atomic 클래스
가시성 문제volatile, synchronized
데이터 오염불변 객체, 스레드 한정(Thread Confinement)
import java.util.concurrent.atomic.AtomicInteger;

public class SafeCounterDemo {

    // AtomicInteger로 경쟁 조건 해결
    private static AtomicInteger counter = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100_000; i++) {
                counter.incrementAndGet();  // 원자적 연산
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100_000; i++) {
                counter.incrementAndGet();  // 원자적 연산
            }
        });

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

        System.out.println("결과: " + counter.get());  // 항상 200000
    }
}

7. 정리

핵심 개념 요약

개념설명핵심 포인트
프로세스실행 중인 프로그램 인스턴스독립 주소 공간, 격리, IPC 필요
스레드프로세스 내 실행 단위메모리 공유, 생성/전환 비용 낮음
컨텍스트 스위칭실행 단위 전환 시 상태 저장/복원스레드 전환이 프로세스 전환보다 가벼움
스레드 생명주기NEW → RUNNABLE → TERMINATEDBLOCKED, WAITING 등 대기 상태 존재
경쟁 조건공유 자원 동시 접근 시 데이터 오염동기화로 해결

스레드를 쓰는 이유와 대가

┌──────────────────────────────────────────────────────────────┐
│                 스레드의 장점과 대가                            │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  장점 (왜 스레드를 쓰는가)      대가 (무엇을 치러야 하는가)    │
│  ─────────────────────         ─────────────────────────     │
│  ✓ 전환 비용이 낮다            ✗ 동기화가 필요하다            │
│  ✓ 데이터 공유가 빠르다        ✗ 경쟁 조건 위험이 있다        │
│  ✓ 생성 비용이 적다            ✗ 가시성 문제가 발생한다       │
│  ✓ 응답성이 향상된다           ✗ 디버깅이 어렵다             │
│                                                              │
└──────────────────────────────────────────────────────────────┘

핵심 정리:

  1. 프로세스는 독립된 주소 공간을 가지며, 통신에 IPC가 필요하다
  2. 스레드는 프로세스 내에서 메모리를 공유하며, 생성과 전환이 가벼워 동시성 구현에 적합하다
  3. 컨텍스트 스위칭은 공짜가 아니며, 스레드 전환이 프로세스 전환보다 가볍다
  4. 스레드의 편리함을 얻으려면 동기화라는 대가를 반드시 치러야 한다
  5. 경쟁 조건, 가시성, 재배치 문제를 이해하는 것이 안전한 동시성 코드의 시작이다

참고 자료

  • “Grokking Concurrency” by Kirill Bobrov, Chapter 4