Spring으로 개발하다 보면 스레드 풀, 트랜잭션, @Async 같은 것들을 설정할 일이 생긴다. 근데 이걸 그냥 외워서 쓰면 언젠가 반드시 막히는 순간이 온다. 왜 스레드 풀 사이즈를 이렇게 잡는지, @Transactional이 왜 내부 호출에서 안 먹히는지, 이런 것들이 결국 OS 레벨의 동작 원리와 연결되어 있기 때문이다!!!!
요즘 멀티 스레드 쪽을 깊게 공부해보다가 그냥 스레드부터 냅다 글을 적을까 했지만 학부생일 때 공부했던 운영체제부터 정리해서 쓰면 어떨까 싶어서 이 주제로 정리해봤다!
CPU는 한 번에 하나밖에 못 한다
CPU 코어 1개는 한 번에 명령어 1개만 실행할 수 있다. 그런데 우리는 컴퓨터로 유튜브 보면서 카카오톡 하고 IDE도 켜놓는다. 어떻게 가능한 걸까? CPU 코어가 많아서인가.... 그것도 맞지만!
답은 CPU가 엄청나게 빠른 속도로 여러 작업을 번갈아 실행하기 때문이다. 1초에 수천 번씩 프로세스를 바꿔가며 실행하니까 사람 눈에는 동시에 돌아가는 것처럼 보이는 것뿐이다. 이걸 가능하게 해주는 게 OS의 핵심 역할이다.
그러면 OS는 어떻게 여러 프로세스를 관리하는 걸까. 이걸 이해하려면 프로세스가 뭔지부터 알아야 한다.
프로그램 vs 프로세스
프로그램과 프로세스는 다르다.
프로그램은 디스크에 저장된 실행 파일이다. 그냥 파일이다. 더블클릭 하기 전의 상태. IntelliJ를 아직 실행 안 했다면 그건 그냥 파일이다.
프로세스는 프로그램을 실행한 것이다. OS가 프로그램을 메모리에 올리고 CPU를 할당해서 실제로 동작하는 상태를 말한다.
같은 프로그램을 두 번 실행하면 프로세스가 두 개 생긴다. 프로그램은 하나지만 프로세스는 별개로 동작한다. 각 프로세스는 독립적인 메모리 공간을 가지기 때문에 하나가 죽어도 다른 하나에 영향을 주지 않는다.
프로세스의 메모리 구조
프로세스가 메모리에 올라갈 때 4개의 영역으로 나뉜다.
높은 주소
+------------------+
| |
| Stack | ← 함수 호출, 지역 변수 (↓ 방향으로 증가)
| ↓ |
+------------------+
| | Stack과 Heap은 같은 공간을 공유하며
| 빈 공간 | 서로 반대 방향으로 자란다
| |
+------------------+
| ↑ |
| Heap | ← 동적 할당 데이터 (↑ 방향으로 증가)
| |
+------------------+
| Data | ← 전역 변수, static 변수
+------------------+
| Code | ← 실행할 코드 (기계어), 읽기 전용
+------------------+
낮은 주소
Code 영역
우리가 작성한 소스 코드가 컴파일되어 기계어(0과 1)로 변환된 후 저장되는 공간이다. CPU는 이 영역에서 명령어를 하나씩 가져와서 실행한다.
읽기 전용이라 실행 중에 내용이 바뀌지 않는다. 레시피를 보고 요리하는 것처럼, 코드는 읽기만 하고 수정하지 않는다.
Data 영역
전역 변수, static 변수가 저장되는 공간이다. 프로그램 시작 시 할당되고 종료될 때 해제된다.
쉽게 언어를 예로 들면...
static int count = 0; // Data 영역
int globalValue = 100; // Data 영역
프로그램이 살아있는 동안 계속 메모리에 올라가 있다. 그래서 static 변수를 남발하면 메모리를 계속 점유하게 된다.
Heap 영역
런타임에 동적으로 할당되는 공간이다. 크기가 실행 전에 정해지지 않고, 실행 중에 필요할 때 할당하고 필요 없으면 해제한다.
// new 키워드로 객체를 만들면 Heap에 올라간다
String name = new String("안녕");
List<Integer> list = new ArrayList<>();
C언어에서는 개발자가 직접 할당하고 해제해야 하지만, Java에서는 GC(Garbage Collector)가 이걸 자동으로 관리해 준다. GC가 Heap을 주기적으로 검사해서 더 이상 참조되지 않는 객체를 제거한다.
Heap은 낮은 주소에서 높은 주소 방향으로 채워진다.
Stack 영역
함수 호출과 지역 변수가 저장되는 공간이다. LIFO(Last In First Out) 구조로 동작한다. 함수를 호출하면 스택에 쌓이고(push), 함수가 끝나면 제거된다(pop).
void methodA() {
int x = 10; // Stack에 저장
methodB(); // methodB 호출 시 Stack에 쌓임
} // methodA 끝나면 x도 Stack에서 제거
void methodB() {
int y = 20; // Stack에 저장
} // 끝나면 y도 제거
methodB() 호출 시 Stack 상태
+----------+
| y = 20 | ← methodB의 지역변수
+----------+
| x = 10 | ← methodA의 지역변수
+----------+
| main() |
+----------+
methodB() 종료 후
+----------+
| x = 10 | ← methodB 프레임 제거됨
+----------+
| main() |
+----------+
Stack은 높은 주소에서 낮은 주소 방향으로 채워진다. Heap과 반대 방향으로 자라기 때문에, 둘이 서로 침범하면 overflow가 발생한다.
- Stack이 Heap을 침범하면 → Stack Overflow
- Heap이 Stack을 침범하면 → Heap Overflow
재귀 함수가 끝없이 호출되면 스택이 계속 쌓이다가 Heap 영역을 침범한다. 이게 StackOverflowError가 발생하는 이유다.
PCB가 필요한 이유
여러 프로세스가 동시에 돌아가는 것처럼 보이려면, CPU가 프로세스 A를 실행하다가 B로 넘어갔다가 다시 A로 돌아올 때 A가 어디까지 실행했는지 정확히 기억해야 한다.
이걸 저장하는 자료구조가 PCB(Process Control Block) 이다. OS는 프로세스를 생성할 때 PCB도 함께 만들고, 프로세스가 종료되면 PCB도 제거한다.
PCB (Process Control Block)
+------------------------------+
| PID: 1234 | ← 프로세스 고유 번호
+------------------------------+
| 상태: RUNNING | ← 현재 상태 (실행/대기/준비)
+------------------------------+
| PC: 0x004A21F0 | ← 다음에 실행할 명령어 주소
+------------------------------+
| 레지스터: R1=5, R2=99 ... | ← CPU 레지스터 값들
+------------------------------+
| 메모리의 주소: 0x7FFF0000 | ← 메모리 공간 위치
+------------------------------+
| 열린 파일 목록: [fd1, fd2] | ← 사용 중인 파일들
+------------------------------+
| 우선순위: 10 | ← 스케줄링 우선순위
+------------------------------+
프로세스가 100개 실행 중이면 OS는 PCB를 100개 가지고 있다. PCB는 보안상 일반 사용자가 접근하지 못하는 보호된 커널 메모리 영역에 저장된다.
PCB가 실제로 쓰이는 순간 - 컨텍스트 스위칭
CPU가 프로세스 A를 실행하다가 B로 전환할 때 이런 일이 일어난다:
1. 현재 실행 중인 프로세스 A의 상태를 PCB_A에 저장
(PC 값, 레지스터 값 등 전부)
↓
2. 다음 실행할 프로세스 B의 PCB_B를 불러옴
↓
3. PCB_B에 저장된 값들을 CPU 레지스터에 복원
↓
4. 프로세스 B가 이어서 실행됨
이 전환 과정을 컨텍스트 스위칭(Context Switching) 이라고 한다. 이 과정 자체에도 시간이 걸리기 때문에 컨텍스트 스위칭이 너무 자주 일어나면 오버헤드가 발생한다. 이게 나중에 스레드 풀 사이즈를 무한정 늘리면 안 되는 이유와 연결된다.
프로세스 상태
프로세스는 실행되는 동안 상태가 계속 바뀐다. OS는 이 상태를 PCB에 기록하면서 관리한다.
생성 (New)
↓ OS가 메모리 할당, PCB 생성
준비 (Ready) ◀──────────────────┐
↓ CPU 할당 (Dispatch) │ 타임 슬라이스 만료
실행 (Running) │ (타이머 인터럽트)
↓ │
┌───────┴───────┐ │
↓ ↓ │
대기 (Waiting) 종료 (Terminated) │
↓ │
I/O 완료 후 ──────────────────────────── ┘
- 준비 (Ready): CPU를 받을 준비는 됐는데 아직 차례가 안 된 상태. Ready Queue에서 대기
- 실행 (Running): 현재 CPU를 점유하고 실행 중
- 대기 (Waiting): I/O 작업이나 이벤트를 기다리는 상태. CPU를 안 쓰기 때문에 Ready Queue에서 빠져나와 Wait Queue로 이동
- 종료 (Terminated): 실행 완료. PCB 제거
실행 → 준비 전환이 일어나는 이유가 중요하다. CPU는 한 프로세스를 무한정 실행하지 않는다. OS가 타이머 인터럽트를 발생시켜 일정 시간(타임 슬라이스, 보통 수 밀리초)이 지나면 강제로 CPU를 뺏는다. 이렇게 해야 모든 프로세스가 공평하게 CPU를 받을 수 있다.
실행 → 대기 전환은 파일 읽기, 네트워크 요청같은 I/O 작업을 할 때 일어난다. I/O는 CPU 연산보다 훨씬 느리기 때문에 기다리는 동안 CPU를 다른 프로세스에게 넘겨주는 것이다. I/O가 끝나면 다시 준비 상태로 돌아가서 CPU를 받을 차례를 기다린다.
정리
개념 핵심
| 프로그램 | 디스크에 저장된 실행 파일 |
| 프로세스 | 프로그램을 실행해서 메모리에 올린 것 |
| Code 영역 | 기계어로 된 실행 코드. 읽기 전용 |
| Data 영역 | 전역변수, static 변수. 프로그램 종료까지 유지 |
| Heap 영역 | 동적 할당 데이터. Java는 GC가 관리 |
| Stack 영역 | 함수 호출, 지역 변수. LIFO 구조 |
| PCB | OS가 프로세스 상태를 저장하는 자료구조 |
| 컨텍스트 스위칭 | CPU가 프로세스 바꿀 때 PCB에 상태 저장/복원하는 과정 |