들어가며
우리가 컴퓨터를 사용할 때 여러 프로그램이 동시에 실행되고 있습니다.
웹 브라우저, 음악 플레이어, 문서 편집기 등이 모두 함께 돌아가고 있죠.
그런데 이들이 어떻게 컴퓨터의 한정된 자원을 나누어 사용하는지 궁금하지 않으셨나요?
프로세스와 스레드가 메모리와 CPU를 어떻게 공유하고,
왜 어떤 상황에서는 스레드가 효율적이고,
어떤 상황에서는 프로세스가 더 안전한지 알아보겠습니다.
프로세스란 무엇인가
먼저 프로세스가 무엇인지부터 시작하겠습니다.
여러분이 메모장을 실행한다고 생각해보세요.
하드디스크에 저장된 메모장 프로그램 파일이 메모리로 로드되고 실행되기 시작합니다.
이렇게 실행 중인 프로그램을 프로세스라고 합니다.
운영체제는 각 프로세스에게 독립된 메모리 공간을 제공합니다.
마치 각 프로세스가 자신만의 방을 하나씩 배정받는 것과 같습니다.
이 방 안에서 프로세스는 자유롭게 작업할 수 있지만,
다른 프로세스의 방에는 함부로 들어갈 수 없습니다.
프로세스 메모리 구조의 이해
프로세스와 스레드의 자원 공유를 제대로 이해하려면, 먼저 가상 메모리 시스템을 알아야 합니다.
가상 주소 공간 (Virtual Address Space)
현대 운영체제에서 각 프로세스는 가상 주소 공간을 할당받습니다.
이는 실제 물리 메모리와는 독립적인 논리적 주소 체계로,
프로세스마다 동일한 주소 범위를 사용할 수 있습니다.
프로세스 A의 가상 주소 공간 (0x10000000) 프로세스 B의 가상 주소 공간(0x10000000) ┌─────────────────────┐ ┌─────────────────────┐
│ 스택 (0x7FFF0000) │ │ 스택 (0x7FFF0000) │
├─────────────────────┤ ├─────────────────────┤
│ 힙 (0x10000000) │ │ 힙 (0x10000000) │
├─────────────────────┤ ├─────────────────────┤
│ 데이터 (0x80000000) │ │ 데이터 (0x80000000) │
├─────────────────────┤ ├─────────────────────┤
│ 코드 (0x80000000) │ │ 코드 (0x80000000) │
└─────────────────────┘ └─────────────────────┘
↓ MMU 변환 ↓ MMU 변환
┌──────────────────────────────────────────────────┐
│ 물리 메모리 (RAM) │
│ 실제 위치: 0x2000 | 0x5000 | 0x8000| 0x1200 | ... │
└──────────────────────────────────────────────────┘
MMU : Memory Management Unit(메모리 관리 장치)
핵심은 같은 가상 주소를 사용하더라도,
페이지 테이블에 의해 서로 다른 물리 메모리 위치로 매핑된다는 점입니다.
이것이 프로세스 간 메모리 격리의 기본 원리입니다.

메모리 영역별 특성
코드 영역 (Code/Text Segment)
- 실행할 기계어 코드를 저장
- 읽기 전용으로 보호되어 실행 중 변경 불가
- 같은 프로그램을 실행하는 여러 프로세스가 공유 가능
데이터 영역 (Data Segment)
- 전역 변수와 정적 변수를 저장
- `.data` (초기값 있는 변수), `.bss` (초기값 없는 변수), `.rodata`(상수) 등으로 세분화
- 프로그램 시작 시 크기가 결정되고 프로세스 종료까지 유지
스택 영역 (Stack)
- 함수 호출 시 생성되는 지역 변수, 매개변수, 리턴 주소 저장
- LIFO(Last In, First Out) 구조로 자동 관리
- 함수 종료 시 자동으로 메모리 해제
힙 영역 (Heap)
- 런타임에 동적으로 할당되는 메모리 영역
- `malloc`, `new` 등을 통해 명시적 할당/해제 필요
- 크기가 실행 중에 자유롭게 변경 가능
스레드의 메모리 공유 메커니즘
스레드별 독립 영역과 공유 영역
스레드는 프로세스 내에서 실행되는 더 작은 실행 단위로,
프로세스의 자원을 선택적으로 공유합니다.

공유하는 자원:
- 코드 영역: 같은 프로그램 코드를 실행하므로 당연히 공유
- 데이터 영역: 전역 변수와 정적 변수를 모든 스레드가 접근 가능
- 힙 영역: 동적으로 할당된 메모리를 스레드 간 공유
독립적으로 가지는 자원:
- 스택 영역: 각 스레드마다 독립적인 함수 호출 스택
- 레지스터 상태: CPU 레지스터 값들 (PC, SP 등)
- 스레드 ID와 상태 정보
스택 독립성의 의미
- 함수 호출의 독립성: 각 스레드는 자신만의 함수 호출 체인을 가집니다
- 지역 변수의 격리: 같은 함수를 호출해도 지역 변수는 스레드별로 분리됩니다
- 재귀 호출의 안전성: 스레드별로 독립적인 재귀 깊이를 가질 수 있습니다
예시 상황: 두 스레드가 동시에 같은 함수를 호출하는 경우
int calculate(int a, int b) {
int result = a + b; // 지역 변수
int temp = result * 2; // 지역 변수
return temp;
}
// Thread 1에서 호출
int value1 = calculate(10, 20);
// Thread 2에서 동시에 호출
int value2 = calculate(5, 15);
만약 스택을 공유한다면 어떻게 될까요?
공유 스택이라면 (잘못된 상황):
┌─────────────────────────┐
│ Thread 1의 result = 30 │ ← Thread 2가 덮어쓸 수 있음!
│ Thread 1의 temp = 60 │ ← Thread 2가 덮어쓸 수 있음!
│ Thread 2의 result = 20 │ ← Thread 1과 섞임!
│ Thread 2의 temp = 40 │ ← Thread 1과 섞임!
└─────────────────────────┘
Thread 1 스택 Thread 2 스택
┌─────────────────┐ ┌─────────────────┐
│ result = 30 │ │ result = 20 │
│ temp = 60 │ │ temp = 40 │
│ a = 10, b = 20 │ │ a = 5, b = 15 │
└─────────────────┘ └─────────────────┘
힙 공유로 인한 이점과 문제점
- 메모리 효율성: 중복 데이터 저장 불필요
// 한 스레드에서 이미지를 로드
char* image_data = malloc(1024 * 1024 * 1024); // 1GB 할당
load_image_from_file(image_data);
// 다른 스레드들이 같은 데이터에 접근
// 포인터만 전달하면 됨!
process_image_filter1(image_data); // Thread 2
process_image_filter2(image_data); // Thread 3
- 빠른 데이터 공유: 포인터 전달만으로 대용량 데이터 공유 가능
- 통신 오버헤드 최소화: 별도의 IPC 메커니즘 불필요
- 동시성 문제: 여러 스레드가 동시에 같은 메모리에 접근 시 데이터 레이스 발생
int account_balance = 100000; // 10만원
// Thread 1: 3만원 출금
void withdraw_30000() {
int current = account_balance; // 1. 현재 잔액 읽기: 100,000
current = current - 30000; // 2. 계산: 70,000
account_balance = current; // 3. 저장: 70,000
}
// Thread 2: 5만원 출금 (동시에!)
void withdraw_50000() {
int current = account_balance; // 1. 현재 잔액 읽기: 100,000 (아직 Thread 1이 저장 안함)
current = current - 50000; // 2. 계산: 50,000
account_balance = current; // 3. 저장: 50,000
}
// 결과: 50,000원 (올바른 결과는 20,000원)
// 30,000원이 사라짐!
- 메모리 관리 복잡성: 어느 스레드가 메모리를 해제할지 결정 필요(댕글링 포인터)
// Thread 1에서 메모리 할당
char* shared_buffer = malloc(1024);
// Thread 2에서 사용
process_data(shared_buffer);
// 누가 free()를 해야 할까?
// Thread 1이 free() 한 후에 Thread 2가 접근하면?
// → 댕글링 포인터 문제!
- 디버깅 어려움: 예측하기 어려운 실행 순서로 인한 버그 발생
컨텍스트 스위칭과 성능
스레드 컨텍스트 스위칭
- 현재 스레드의 레지스터 상태를 TCB(Thread Control Block)에 저장
- 다음 스레드의 레지스터 상태를 복원
- 스택 포인터를 새 스레드의 스택으로 변경
Thread Context Switch 과정
┌─────────────────┐ 1. 저장 ┌─────────────────┐
│ Running Thread │ ────────────> │ Thread Control │
│ - PC: 0x401234 │ │ Block (TCB) │
│ - SP: 0x7FFF00 │ │ - PC: 0x401234 │
│ - R1: 42 │ │ - SP: 0x7FFF00 │
│ - R2: 100 │ │ - R1: 42 │
└─────────────────┘ │ - R2: 100 │
└─────────────────┘
│
2. 복원 │
┌─────────────────┐ ┌─────────────────┐
│ Next Thread │ <──────────── │ Next Thread's │
│ - PC: 0x405678 │ │ TCB │
│ - SP: 0x7FFE00 │ │ - PC: 0x405678 │
│ - R1: 77 │ │ - SP: 0x7FFE00 │
│ - R2: 200 │ │ - R1: 77 │
└─────────────────┘ │ - R2: 200 │
└─────────────────┘
- 현재 스레드 상태 저장
- 프로그램 카운터(PC): 다음에 실행할 명령어 주소
- 스택 포인터(SP): 현재 스택의 위치
- 범용 레지스터들: 계산 중이던 값들
- 상태 레지스터: CPU 플래그들
- 스케줄링
- 어떤 스레드를 다음에 실행할지 결정새 스레드 상태 복원
- 새 스레드의 레지스터 값들을 CPU에 로드
- 스택 포인터를 새 스레드의 스택으로 변경
같은 프로세스 내의 스레드들은 같은 가상 주소 공간을 사용하거든요.
프로세스 컨텍스트 스위칭
반면 Process A에서 Process B로 전환할 때는 훨씬 복잡합니다:
1단계: 현재 프로세스 상태 저장 (스레드와 동일)
Process A의 페이지 테이블 → Process B의 페이지 테이블
가상주소 0x1000 → 물리주소 0x5000 가상주소 0x1000 → 물리주소 0x8000
가상주소 0x2000 → 물리주소 0x6000 가상주소 0x2000 → 물리주소 0x9000
... ...
3단계: TLB 플러시
TLB(Translation Lookaside Buffer)는 최근에 사용한 주소 변환 정보를 캐시하는 하드웨어입니다.
프로세스가 바뀌면 이 캐시가 무효가 되므로 모두 지워야 합니다.
TLB 플러시 전:
가상 0x1000 → 물리 0x5000 (Process A용)
가상 0x2000 → 물리 0x6000 (Process A용)
TLB 플러시 후:
(비어있음) → Process B가 메모리 접근할 때마다 새로 채워짐
CPU 캐시에 있던 이전 프로세스의 데이터도 대부분 쓸모없어집니다.
L1 캐시 무효화:
Process A의 데이터들 → 무효 처리
→ Process B가 메모리 접근할 때마다 캐시 미스 발생
성능 차이의 원인
- 페이지 테이블 교체 불필요: 같은 가상 주소 공간을 사용
- TLB 플러시 불필요: 주소 변환 정보가 여전히 유효
- 캐시 친화적: 공유 데이터가 캐시에 남아있을 가능성 높음
멀티스레드 방식:
- 컨텍스트 스위칭: 10,000 × 3μs = 30ms
- 전체 처리 시간의 3% 정도
멀티프로세스 방식:
- 컨텍스트 스위칭: 10,000 × 50μs = 500ms
- 전체 처리 시간의 50% 정도!
프로세스 간 통신 (IPC)
프로세스 격리의 원리
프로세스는 기본적으로 서로의 메모리에 직접 접근할 수 없습니다.
이는 다음과 같은 메커니즘에 의해 보장됩니다:
프로세스 A의 페이지 테이블 프로세스 B의 페이지 테이블
가상 주소 → 물리 주소 가상 주소 → 물리 주소
0x1000 → 0x5000 0x1000 → 0x8000
0x2000 → 0x6000 0x2000 → 0x9000
0x3000 → 0x7000 0x3000 → 0xA000
같은 가상 주소라도 다른 물리 주소로 매핑되므로, 덕분에 안정성이 보장되지만,
직접 메모리에 접근하려 하면 세그멘테이션 폴트가 터지죠.
그래서 특별한 통신 방법, 즉 IPC(Inter-Process Communication)가 필요합니다.
주요 IPC 메커니즘
파이프 (Pipe) - 가장 간단한 방법
Process A ──┐ ┌─ 커널의 파이프 버퍼 ─┐ ┌── Process B
│ │ │ │
데이터 → └───┤ [Hello World!] ├───┘ → 데이터 수신
전송 │ │
└──────────────────────┘
- 특징
- 단방향 통신 채널 제공 (A → B)
- 커널 버퍼를 통한 데이터 전송
- 부모-자식 프로세스 간 통신에 주로 사용
공유 메모리 (Shared Memory) - 가장 빠른 방법
프로세스 A의 페이지 테이블 프로세스 B의 페이지 테이블
가상 주소 → 물리 주소 가상 주소 → 물리 주소
0x1000 → 0x5000 0x1000 → 0x8000
0x2000 → 0x6000 0x2000 → 0x9000
0x9000 → 0xF000 ← 공유! 0x7000 → 0xF000 ← 같은 물리 메모리!
- 장점
- 여러 프로세스가 같은 물리 메모리 영역에 접근
- 가장 빠른 IPC 방식이지만 동기화 필요
- 대용량 데이터 교환에 적합
- 단점
- 동기화 복잡: 여러 프로세스가 동시에 접근하면 데이터가 망가질 수 있습니다
- 설정 복잡: 공유 메모리를 만들고 관리하는 코드가 복잡합니다
메시지 큐 (Message Queue) - 우편함 방식
이것은 우편함 시스템과 같습니다.
메시지를 보내면 받는 사람이 나중에 확인할 수 있어요.
Process A 메시지 큐 Process B
┌─────────────────┐
메시지1 전송 ──→│ [메시지1] │ ──→ 메시지1 수신
메시지2 전송 ──→│ [메시지2] │
│ [메시지3] │ ──→ 메시지2 수신
└─────────────────┘
- 특징들:
- FIFO 순서: 먼저 보낸 메시지를 먼저 받습니다
- 우선순위 지원: 긴급한 메시지를 먼저 처리할 수 있습니다
- 비동기 통신: 보내는 쪽과 받는 쪽이 동시에 실행될 필요가 없습니다
- 구조화된 데이터: 메시지에 타입이나 우선순위 정보를 포함할 수 있습니다
소켓 (Socket) - 가장 범용적인 방법
소켓은 전화 시스템과 같습니다.
로컬 통신부터 인터넷 통신까지 모두 같은 방식으로 할 수 있어요.
Process A (클라이언트) Process B (서버)
┌────────────────┐ ┌──────────────────┐
│ socket() │ │ socket() │
│ connect() │ ──→ │ bind() │
│ send("Hello") │ ──→ │ listen() │
│ recv() │ ←── │ accept() │
└────────────────┘ │ recv() → "Hello"│
│ send("Hi back") │
└──────────────────┘
- 양방향 통신: 서로 주고받을 수 있습니다
- 위치 투명성: 같은 컴퓨터든 다른 컴퓨터든 똑같이 사용
- 프로토콜 선택: TCP(신뢰성), UDP(속도), Unix 소켓(로컬) 등 선택 가능
- 표준화: 네트워크 프로그래밍의 표준 방식
동기화와 동시성 제어
스레드 간 동기화
데이터 레이스 (Data Race)
초기 계좌 잔액: 1000원
Thread 1 (ATM에서 출금): Thread 2 (온라인 송금):
1. 잔액 읽기 → 1000원 1. 잔액 읽기 → 1000원
2. 계산: 1000 - 300 = 700원 2. 계산: 1000 - 500 = 500원
3. 잔액 저장 → 700원 3. 잔액 저장 → 500원
최종 결과: 500원 (올바른 결과는 200원)
300원이 사라짐!
이런 문제가 왜 생길까요?
원자성(Atomicity)이 깨지기 때문입니다.
counter = counter + 1; // 이것은 사실 3개의 명령어입니다!
실제로는:
LOAD R1, counter ; 1. 메모리에서 값을 읽어 레지스터로
ADD R1, R1, 1 ; 2. 레지스터 값에 1을 더함
STORE counter, R1 ; 3. 레지스터 값을 메모리에 저장
두 스레드가 이 세 단계를 동시에 실행하면 결과가 엉망이 됩니다.
해결 방법들
뮤텍스 (Mutex) - 상호 배제
뮤텍스는 화장실 열쇠와 같습니다.
한 번에 한 사람(스레드)만 들어갈 수 있어요.
mutex lock = MUTEX_INITIALIZER;
void safe_increment() {
mutex_lock(&lock); // 🔒 문 잠그기
counter = counter + 1; // 안전한 영역 (임계 영역)
mutex_unlock(&lock); // 🔓 문 열어주기
}
Thread 1이 뮤텍스 획득 → Thread 2는 대기
Thread 1이 작업 완료 → 뮤텍스 해제
Thread 2가 뮤텍스 획득 → 작업 수행
세마포어 (Semaphore) - 자원 개수 제한
세마포어는 주차장의 빈 자리 카운터와 같습니다.
정해진 개수만큼의 스레드가 동시에 자원을 사용할 수 있어요.
semaphore parking_lot = SEMAPHORE_INIT(3); // 주차 공간 3개
void use_parking() {
sem_wait(&parking_lot); // 🚗 주차 공간 확보 (count--)
// ... 주차 공간 사용 ...
sem_post(&parking_lot); // 🚗 주차 공간 반납 (count++)
}
조건 변수 (Condition Variable) - 특정 조건 대기
조건 변수는 버스 정류장과 같습니다.
pthread_cond_t condition = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int buffer_full = 0;
// 소비자 스레드
void consumer() {
pthread_mutex_lock(&mutex);
while (!buffer_full) { // 버퍼가 찰 때까지 기다림
pthread_cond_wait(&condition, &mutex); // 😴 잠들기
}
// 버퍼에서 데이터 소비
buffer_full = 0;
pthread_mutex_unlock(&mutex);
}
// 생산자 스레드
void producer() {
pthread_mutex_lock(&mutex);
// 버퍼에 데이터 생산
buffer_full = 1;
pthread_cond_signal(&condition); // 🔔 소비자 깨우기!
pthread_mutex_unlock(&mutex);
}
원자적 연산 (Atomic Operation) - 하드웨어 수준 보장
현대 CPU는 분할할 수 없는 연산들을 지원합니다:
atomic_int counter = ATOMIC_VAR_INIT(0);
void atomic_increment() {
atomic_fetch_add(&counter, 1); // 하드웨어가 원자성 보장!
}
프로세스 간 동기화
Named 세마포어
프로세스 간에 공유할 수 있는 세마포어입니다:
// 프로세스 A
sem_t* sem = sem_open("/printer_semaphore", O_CREAT, 0644, 2); // 프린터 2대
sem_wait(sem); // 프린터 사용 권한 획득
// ... 프린터 사용 ...
sem_post(sem); // 프린터 사용 권한 반납
// 프로세스 B (같은 세마포어 사용)
sem_t* sem = sem_open("/printer_semaphore", 0); // 기존 세마포어 열기
sem_wait(sem); // 프린터 사용 권한 획득
// ... 프린터 사용 ...
sem_post(sem); // 프린터 사용 권한 반납
파일 락킹
파일을 통한 동기화입니다:
int fd = open("lockfile.lock", O_CREAT | O_EXCL, 0644);
if (fd == -1) {
// 다른 프로세스가 이미 락을 걸었음 → 대기
} else {
// 락 획득 성공 → 임계 영역 실행
// ...
close(fd);
unlink("lockfile.lock"); // 락 해제
}
공유 메모리 + 뮤텍스 조합
가장 강력하지만 복잡한 방식입니다:
int fd = open("lockfile.lock", O_CREAT | O_EXCL, 0644);
if (fd == -1) {
// 다른 프로세스가 이미 락을 걸었음 → 대기
} else {
// 락 획득 성공 → 임계 영역 실행
// ...
close(fd);
unlink("lockfile.lock"); // 락 해제
}
마무리
프로세스와 스레드의 자원 공유는 운영체제의 핵심 개념 중 하나입니다.
각각의 특성을 정확히 이해하고, 상황에 맞는 적절한 선택을 하는 것이 효율적인 시스템 설계의 핵심입니다.
특히 현대의 멀티코어 환경에서는 단순히 "스레드가 빠르다"라는 것을 넘어서,
동기화 비용, 캐시 일관성, 메모리 모델 등을 종합적으로 고려해야 합니다.
이론적 이해를 바탕으로 실제 시스템에서 최적의 성능과 안정성을 달성하는 것이 우리의 목표입니다.