프로세스와 스레드

허정석·2025년 12월 6일

TIL

목록 보기
14/19
post-thumbnail

프로세스와 스레드

개요

프로세스와 스레드는 운영체제가 프로그램을 실행하고 관리하는 핵심 개념입니다. 프로세스는 메모리를 격리하여 안정성을 확보하고, 스레드는 메모리를 공유하여 효율적으로 협업합니다. 이 글에서는 컨텍스트 스위칭의 비용, 멀티프로세스와 멀티스레드의 선택 기준, 싱글스레드 환경에서의 이벤트 루프, Race Condition과 해결 방법을 다룹니다.


프로세스

프로세스는 실행 중인 프로그램입니다. 디스크에 저장된 실행 파일이 메모리에 적재되어 동작하는 상태를 프로세스라고 부릅니다.

컴퓨터는 크롬 브라우저, 개발 도구, 메신저, 음악 플레이어를 동시에 실행합니다. 운영체제는 각 프로그램을 독립된 프로세스로 관리하여 메모리를 격리하고, CPU 시간과 메모리 공간을 할당하며, 실행 순서를 스케줄링합니다.

프로세스는 자원 할당의 기본 단위입니다.

스레드

스레드는 프로세스 내에서 실행되는 흐름의 단위입니다. 하나의 프로세스 안에서 여러 작업을 동시에 처리할 때 스레드를 사용합니다.

크롬 브라우저는 사용자 입력을 받고, 네트워크에서 데이터를 다운로드하고, 화면을 렌더링하고, JavaScript를 실행합니다. 하나의 실행 흐름으로 처리하면 다운로드하는 동안 화면이 멈춥니다. 프로세스 내부에 여러 스레드를 만들어서 각각 다른 작업을 담당하게 하면 이 문제를 해결할 수 있습니다.

스레드는 실행의 기본 단위입니다.

프로세스는 각자 독립된 집과 같습니다. 집마다 독립된 공간이 있고, 문을 잠그면 다른 집에서 들어올 수 없습니다. 스레드는 같은 집 안의 가족 구성원과 같습니다. 같은 부엌과 거실을 공유하고, 한 사람이 냉장고에서 물건을 꺼내면 다른 사람도 바로 볼 수 있으며, 한 사람이 가스불을 켜놓고 나가면 집 전체가 위험해집니다.

프로세스와 스레드의 차이

구분프로세스스레드
메모리독립적 메모리 공간같은 프로세스 내 메모리 공유
생성 비용무거움가벼움
통신IPC 필요메모리 공유로 쉬움
독립성한 프로세스 종료 시 다른 프로세스 무관한 스레드 문제 발생 시 전체 프로세스 영향

메모리 격리와 공유

운영체제가 프로세스마다 메모리를 격리하는 이유는 안정성과 보안 때문입니다. 운영체제는 프로세스마다 독립된 메모리 공간을 부여하여 서로를 보호합니다. 이 격리로 인해 페이지 테이블 생성과 메모리 할당 등의 작업이 필요하므로 프로세스 생성 비용이 커집니다.

스레드가 메모리를 공유하는 이유는 효율적인 협업 때문입니다. 같은 데이터 구조에 빠르게 접근할 수 있고, 프로세스 간 통신 없이 바로 데이터를 전달할 수 있습니다. 그러나 한 스레드가 잘못된 메모리 접근을 하면 전체 프로세스가 종료될 수 있고, Race Condition 같은 동기화 문제가 발생할 수 있습니다.

컨텍스트 스위칭

CPU는 한 번에 하나의 작업만 실행합니다. 여러 프로그램이 동시에 실행되는 것처럼 보이는 이유는 컨텍스트 스위칭 때문입니다.

CPU가 스레드 A를 실행하다가 타이머 인터럽트가 발생하면, 운영체제가 개입하여 스레드 A의 현재 상태를 저장합니다. 레지스터 값, 프로그램 카운터 등을 저장한 후 스레드 B의 저장된 상태를 복원하면, CPU가 스레드 B의 실행을 재개합니다.

저장되고 복원되는 상태를 컨텍스트라고 부르고, 전환하는 과정을 스위칭이라고 부릅니다.

비용

컨텍스트 스위칭에는 비용이 발생합니다. 상태를 저장하고 복원하는 시간이 필요하고, 캐시가 무효화되어 새로운 스레드가 사용할 데이터를 다시 적재해야 합니다.

스레드 간 스위칭은 레지스터, 프로그램 카운터, 스택 포인터만 저장하고 복원합니다. 같은 메모리 공간을 사용하므로 페이지 테이블을 유지합니다.

프로세스 간 스위칭은 위의 모든 작업과 함께 메모리 맵을 전환합니다. 페이지 테이블을 교체하고, TLB와 CPU 캐시를 모두 비웁니다. 새 프로세스는 완전히 다른 메모리 주소 공간을 사용하기 때문입니다.

스레드 스위칭은 같은 책의 다른 챕터로 이동하는 것과 같아서 책갈피만 옮기면 됩니다. 프로세스 스위칭은 완전히 다른 책으로 교체하는 것과 같아서 책장 정리부터 다시 해야 합니다.

멀티프로세스

멀티프로세스는 여러 개의 독립적인 프로세스를 동시에 실행하는 방식입니다.

자식 프로세스도 독립적인 프로세스입니다. 부모 프로세스 안에 있는 것이 아니라 별개로 존재합니다. 크롬이 탭마다 별도 프로세스를 생성하면, 한 탭에 문제가 생겨도 다른 탭은 정상적으로 동작합니다.

멀티프로세스 사용 시점

안정성이 중요할 때 멀티프로세스를 사용합니다. 한 작업에 문제가 생겨도 다른 작업에 영향을 주지 않아야 합니다. 크롬 브라우저가 탭마다 프로세스를 사용하는 것이 좋은 예입니다.

작업이 완전히 독립적이고 메모리 공유가 필요 없을 때 멀티프로세스를 사용합니다. 여러 사용자의 요청을 완전히 격리해서 처리해야 하는 경우가 해당합니다.

Python에서 CPU 집약적 작업을 수행할 때 멀티프로세스를 사용합니다. GIL 때문에 멀티스레드로는 CPU 병렬화가 되지 않습니다.

멀티스레드

멀티스레드는 같은 프로세스 내에서 메모리를 공유하면서 여러 작업을 동시에 수행하는 방식입니다.

여러 스레드가 함께 협력합니다. 웹 서버에서 여러 클라이언트 요청을 동시에 처리할 때, 모든 스레드가 같은 데이터베이스 커넥션 풀과 캐시를 공유합니다.

멀티스레드 사용 시점

메모리 공유가 필요할 때 멀티스레드를 사용합니다. 여러 작업이 같은 데이터 구조에 접근해야 합니다. 웹 서버에서 요청마다 스레드를 할당하고 공통 캐시를 사용하는 것이 예입니다.

I/O 작업이 많을 때 멀티스레드를 사용합니다. 네트워크나 파일 읽기/쓰기를 대기하는 동안 다른 작업을 처리할 수 있습니다. Spring Boot 웹 애플리케이션이 대표적입니다.

빠른 통신이 필요할 때 멀티스레드를 사용합니다. 프로세스 간 통신보다 메모리 공유가 훨씬 빠릅니다.

Spring Boot의 멀티스레드

Spring Boot는 기본적으로 멀티스레드 방식으로 동작합니다.

@RestController
public class UserController {
    
    @GetMapping("/users/{id}")
    public User getUser(@PathVariable Long id) {
        System.out.println("Thread: " + Thread.currentThread().getName());
        return userService.findById(id);
    }
}

실행 결과는 다음과 같습니다.

Thread: http-nio-8080-exec-1
Thread: http-nio-8080-exec-2
Thread: http-nio-8080-exec-3

Tomcat이 내부적으로 스레드 풀을 관리하고, 요청마다 스레드를 할당합니다.

Spring Boot가 멀티스레드를 사용하는 이유

웹 요청 처리는 CPU 작업보다 I/O 대기가 훨씬 많습니다. 데이터베이스 쿼리 대기, 외부 API 호출 대기, 파일 읽기/쓰기가 대부분의 시간을 차지합니다. 한 스레드가 데이터베이스 응답을 기다리는 동안, 다른 스레드가 다른 요청을 처리합니다.

웹 서버에서는 여러 요청이 공통 자원을 사용합니다. 데이터베이스 커넥션 풀, 캐시, Spring Bean 인스턴스를 공유합니다.

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private CacheManager cacheManager;
    
    public User findById(Long id) {
        return cacheManager.get("user:" + id);
    }
}

Spring Bean은 싱글톤 객체입니다. Spring 컨테이너가 시작될 때 인스턴스를 하나만 생성하고, 모든 스레드가 이 하나의 인스턴스를 공유합니다. 메모리를 효율적으로 사용하고, 빠르게 접근하며, 상태를 일관되게 유지합니다.

스레드는 프로세스보다 생성 비용이 가볍습니다. 수백 개를 동시에 실행해도 괜찮고, 메모리도 공유하므로 효율적으로 대량 요청을 처리합니다.

# application.yml
server:
  tomcat:
    threads:
      max: 200
      min-spare: 10

요청마다 새로 생성하지 않고 미리 만들어둔 스레드를 재사용합니다.

싱글스레드와 이벤트 루프

싱글스레드 선택 이유

멀티스레드에서 메모리를 공유하면 Race Condition이 발생합니다.

public class Counter {
    private int count = 0;
    
    public void increment() {
        count++;  // 읽기 → 계산 → 쓰기
    }
}

싱글스레드 환경에서는 동시 접근이 원천적으로 불가능합니다. 락이 필요 없으므로 코드가 단순해지고 버그가 줄어듭니다.

I/O 중심 작업에서도 싱글스레드가 효율적입니다. 멀티스레드 방식은 각 스레드가 I/O를 대기하는 동안 블로킹되지만, 싱글스레드와 이벤트 루프는 한 스레드가 논블로킹 방식으로 여러 작업을 처리합니다.

이벤트 루프

이벤트 루프는 발생한 이벤트들을 계속 확인하면서 순차적으로 처리하는 무한 반복 구조입니다.

이벤트는 처리해야 할 일이 발생했다는 알림입니다. 새 요청 도착, 데이터베이스 조회 완료, 타이머 완료, 에러 발생이 이벤트입니다.

while (true) {
    if (eventQueue.hasEvents()) {
        event = eventQueue.getNext();
        processEvent(event);
    }
    
    if (completedTasks.hasResults()) {
        task = completedTasks.getNext();
        executeCallback(task);
    }
}

시각화하면 다음과 같습니다.

[0ms] 이벤트 루프 시작
  │
  ├─ 요청1 도착 → DB 조회 시작 (비동기 - 100ms 예상)
  │
[1ms] 루프 한 바퀴
  │
  ├─ 요청2 처리 시작 → API 호출 시작 (비동기 - 50ms 예상)
  │
[2ms] 루프 한 바퀴
  │
  ├─ 요청3 처리 시작 → 파일 읽기 시작 (비동기 - 80ms 예상)
  │
[50ms] API 호출 완료 감지
  │
  ├─ 요청2의 콜백 실행 → 응답2 전송
  │
[80ms] 파일 읽기 완료 감지
  │
  ├─ 요청3의 콜백 실행 → 응답3 전송
  │
[100ms] DB 조회 완료 감지
  │
  ├─ 요청1의 콜백 실행 → 응답1 전송

완료되는 대로 각각 응답합니다.

블로킹과 논블로킹

블로킹 방식은 스레드가 I/O 응답을 기다리는 동안 멈춥니다.

// Spring Boot - 블로킹
public String getUser(Long id) {
    String result = database.query("SELECT * FROM users WHERE id = " + id);
    return result;
}

논블로킹 방식은 I/O 대기 중에 다른 이벤트를 처리합니다.

// Node.js - 논블로킹
async function getUser(id) {
    const result = await database.query(`SELECT * FROM users WHERE id = ${id}`);
    return result;
}

블로킹 방식의 멀티스레드는 식당의 여러 웨이터와 같습니다. 각 웨이터가 주방에서 음식이 나올 때까지 그 자리에서 대기합니다. 논블로킹 방식의 싱글스레드는 한 명의 슈퍼 웨이터와 같습니다. 주문만 받고 주방에 전달한 후 즉시 다음 손님에게 가고, 주방에서 벨이 울리면 그때 음식을 전달합니다.

Node.js와 Redis

Node.js는 JavaScript가 원래 브라우저에서 싱글스레드로 동작했고, 웹 서버는 I/O 중심 작업이 대부분이므로 이벤트 루프로 효율적인 동시 처리가 가능합니다.

Redis는 인메모리 데이터베이스입니다. 메모리 접근이 워낙 빠르고, 네트워크가 병목이지 CPU 처리는 병목이 아닙니다. 싱글스레드로도 초당 10만 개 이상의 명령어를 처리합니다.

Redis가 싱글스레드를 사용하는 이유는 데이터 일관성 보장코드 단순성 때문입니다. 모든 명령어가 순차적으로 처리되므로 동시에 같은 데이터를 수정할 수 없습니다. 락, 뮤텍스, 세마포어가 필요 없어서 버그 가능성이 낮고 유지보수가 쉽습니다.

Redis는 클라이언트마다 고유 스레드를 할당하지 않습니다. 모든 클라이언트가 하나의 스레드를 공유하고 순차적으로 처리합니다.

Client-1 ─┐
Client-2 ─┼─→ [단일 스레드]
Client-3 ─┘

싱글스레드의 한계

싱글스레드는 CPU 집약적 작업에 취약합니다. 큰 계산 작업이 시작되면 블로킹되어 다른 요청이 모두 대기해야 합니다.

멀티코어를 활용하지 못합니다. 한 개의 코어만 사용하므로, 클러스터링으로 여러 프로세스를 실행하는 것이 해결책입니다.

Race Condition과 Atomic

Race Condition

Race Condition은 여러 스레드나 프로세스가 동시에 같은 데이터에 접근해서 결과가 실행 순서에 따라 달라지는 상황입니다.

int balance = 1000;

// Thread-1: 500원 입금
void deposit() {
    int temp = balance;
    temp = temp + 500;
    balance = temp;
}

// Thread-2: 300원 출금
void withdraw() {
    int temp = balance;
    temp = temp - 300;
    balance = temp;
}

순차적으로 실행되면 다음과 같습니다.

balance: 1000
Thread-1 완료 → 1500
Thread-2 완료 → 1200

Race Condition이 발생하면 다음과 같습니다.

Thread-1: temp = balance (1000 읽음)
Thread-2: temp = balance (1000 읽음)
Thread-1: temp = 1000 + 500 = 1500
Thread-2: temp = 1000 - 300 = 700
Thread-1: balance = 1500 저장
Thread-2: balance = 700 저장

결과: balance = 700

Spring Boot에서도 이 문제가 발생합니다.

@RestController
public class CounterController {
    
    private int counter = 0;
    
    @GetMapping("/increment")
    public int increment() {
        counter++;
        return counter;
    }
}

여러 요청이 동시에 counter에 접근하면 Race Condition이 발생합니다. AtomicInteger로 해결합니다.

@RestController
public class CounterController {
    
    private AtomicInteger counter = new AtomicInteger(0);
    
    @GetMapping("/increment")
    public int increment() {
        return counter.incrementAndGet();
    }
}

Atomic

Atomic은 중간에 끼어들 수 없는 단위 작업입니다. 더 이상 나눌 수 없는 하나의 단위로 실행됩니다.

일반적인 증가 연산은 원자적이지 않습니다.

counter++;
// 실제로는 3단계
// 1. temp = counter (읽기)
// 2. temp = temp + 1 (계산)
// 3. counter = temp (쓰기)

원자적 연산은 중간에 다른 스레드가 끼어들 수 없습니다.

counter.incrementAndGet();
// 읽기-계산-쓰기가 하나의 단위로 실행됨

Redis의 원자적 연산

Redis의 모든 명령어는 원자적으로 동작합니다.

INCR counter

내부적으로 값 읽기, +1 계산, 값 쓰기가 하나의 원자적 작업으로 처리됩니다. 싱글스레드이므로 중간에 다른 명령어가 끼어들 수 없습니다.

두 클라이언트가 동시에 INCR counter를 실행하는 경우를 보겠습니다.

[Redis 단일 스레드]
  │
  ├─ Client-1 처리 (원자적으로)
  │   1. counter 읽기 (5)
  │   2. +1 계산 (6)
  │   3. counter 쓰기 (6)
  │
  ├─ Client-2 처리 (원자적으로)
  │   1. counter 읽기 (6)
  │   2. +1 계산 (7)
  │   3. counter 쓰기 (7)
  │
  결과: counter = 7

싱글스레드가 순차 처리하므로 Race Condition이 발생하지 않습니다. 원자적 실행으로 중간에 끼어들 수 없어 데이터 일관성이 보장됩니다.

자주 발생하는 오해

프로세스가 비즈니스 프로세스인가요?

버튼 클릭으로 화면이 전환되는 과정은 비즈니스 프로세스입니다. 운영체제의 프로세스는 실행 중인 프로그램을 의미합니다. 크롬, 개발 도구, 메신저 각각이 프로세스입니다.

스레드는 작업이 섞이지 않게 격리하는 공간인가요?

스레드는 격리 공간이 아닙니다. 프로세스 내 실행 흐름의 단위이며, 여러 스레드가 같은 메모리를 공유합니다. 오히려 섞일 수 있어서 동기화가 필요합니다.

멀티프로세스는 한 프로세스 안에서 다른 프로세스를 구동하는 것인가요?

멀티프로세스는 여러 개의 독립적인 프로세스를 실행하는 것입니다. 부모-자식 관계여도 별개의 프로세스입니다. 한 프로세스 안에 있는 것이 아니라 독립적으로 존재합니다.

멀티스레드는 단독으로 흐름을 제어하는 것인가요?

멀티스레드는 여러 스레드가 함께 협력하는 것입니다. 메모리를 공유하면서 동시에 여러 작업을 수행합니다. 단독 제어는 오히려 싱글스레드에 가까운 개념입니다.

실행 비용이 커서 메모리를 격리하나요?

원인과 결과가 반대입니다. 안정성과 보안을 위해 메모리를 격리하는 것이 목적입니다. 그로 인해 생성 비용이 커지는 것이 결과입니다.

Redis는 클라이언트마다 고유 스레드를 할당하나요?

Redis는 모든 클라이언트가 단일 스레드를 공유합니다. 하나의 스레드가 모든 요청을 순차적으로 처리합니다.

이벤트 루프는 모든 요청이 완료되면 한꺼번에 반환하나요?

완료되는 대로 각각 응답합니다. 50ms 후 요청2가 완료되면 응답2를 전송하고, 80ms 후 요청3가 완료되면 응답3를 전송하고, 100ms 후 요청1이 완료되면 응답1을 전송합니다.

논블로킹을 이해하기 어렵습니다

블로킹은 I/O 대기 중 스레드가 멈추므로 다른 스레드가 필요합니다.

논블로킹은 I/O 대기 중에도 다른 작업을 처리합니다. 이벤트 루프가 계속 돌면서 완료된 작업을 확인합니다. 이벤트가 완료될 때까지 다른 이벤트가 멈추는 것이 아니라, 이벤트들을 확인하면서 순차적으로 처리하고 완료합니다.

0개의 댓글