Java 멀티프로세스 & 멀티스레드 완전 정복
들어가며
Java 백엔드 개발을 하다 보면 멀티스레드, 동시성이라는 말을 자주 접하게 됩니다. 그런데 이런 생각을 한 번쯤 해봤을 것입니다.
"멀티프로세스와 멀티스레드가 뭐가 다른 거지? 그냥 여러 개 동시에 돌리는 거 아닌가?"
둘 다 '동시에 여러 작업을 처리'하지만, 메모리 구조와 동작 방식이 완전히 다릅니다. 이 글에서는 기본 개념부터 Java 코드 예제, 그리고 멀티스레드에서 발생하는 실제 문제점까지 차근차근 살펴보겠습니다.
1. 프로세스 vs 스레드 기본 개념
2. 멀티프로세스 — 구조, Java 예제, 한계
3. 멀티스레드 — 구조, Java 예제
4. 멀티스레드 문제점 심화 (경쟁 조건, 데드락, 가시성)
5. 멀티프로세스 vs 멀티스레드 비교 & 선택 기준
프로세스와 스레드란?
멀티프로세스와 멀티스레드를 이해하려면, 먼저 프로세스(Process)와 스레드(Thread)가 무엇인지 알아야 합니다.
프로세스 (Process)
운영체제로부터 독립적인 메모리 공간을 할당받은 실행 단위입니다. 각 프로세스는 Code, Data, Heap, Stack 영역을 독립적으로 가지며, 다른 프로세스의 메모리에 직접 접근할 수 없습니다.
스레드 (Thread)
프로세스 내부에서 실행되는 작업의 단위입니다. 같은 프로세스 내의 스레드들은 Code, Data, Heap 영역을 공유하고, Stack만 독립적으로 가집니다.
메모리 구조 비교
| 영역 | 멀티프로세스 | 멀티스레드 |
| Code 영역 | 각 프로세스 독립 | 스레드 간 공유 |
| Data 영역 | 각 프로세스 독립 | 스레드 간 공유 |
| Heap 영역 | 각 프로세스 독립 | 스레드 간 공유 |
| Stack 영역 | 각 프로세스 독립 | 각 스레드 독립 |
핵심 요약
프로세스는 완전히 독립된 실행 공간이고, 스레드는 프로세스 안에서 메모리를 나눠 쓰는 실행 흐름입니다. 이 차이가 멀티프로세스와 멀티스레드의 모든 특성 차이로 이어집니다.
멀티프로세스 (Multi-Process)
멀티프로세스란 하나의 작업을 여러 개의 독립된 프로세스로 나누어 처리하는 방식입니다. 각 프로세스는 완전히 독립된 메모리를 가지므로, 하나가 비정상 종료되어도 다른 프로세스에 영향을 주지 않습니다.
실생활 비유
각자 독립된 주방(메모리)을 가진 여러 요리사가 별개의 요리를 만드는 것과 같습니다. 한 요리사가 실수해도 다른 주방은 영향을 받지 않습니다.
Java에서 새로운 프로세스를 생성할 때는 ProcessBuilder를 사용합니다.
import java.util.*;
public class MultiProcessExample {
public static void main(String[] args) throws Exception {
List<Process> processes = new ArrayList<>();
// 3개의 독립 프로세스 생성
for (int i = 0; i < 3; i++) {
ProcessBuilder pb = new ProcessBuilder(
"java", "-cp", System.getProperty("java.class.path"),
"Worker", String.valueOf(i)
);
pb.redirectErrorStream(true);
Process process = pb.start();
processes.add(process);
System.out.println("프로세스 " + i + " 시작. PID: " + process.pid());
}
// 모든 프로세스 종료 대기
for (Process p : processes) {
int exitCode = p.waitFor();
System.out.println("프로세스 종료. 코드: " + exitCode);
}
}
}
// 별도 프로세스에서 실행될 Worker 클래스
class Worker {
public static void main(String[] args) throws InterruptedException {
int id = Integer.parseInt(args[0]);
System.out.println("Worker-" + id + ": 작업 시작");
Thread.sleep(1000);
System.out.println("Worker-" + id + ": 작업 완료");
}
}
멀티프로세스의 한계
프로세스 간 데이터를 주고받으려면 IPC(Inter-Process Communication) — 소켓, 파이프, 공유 메모리 등을 사용해야 합니다. 또한 프로세스 생성 시 메모리를 통째로 복제하므로 생성 비용이 매우 크고, 컨텍스트 스위칭 시 PCB 전체를 교체해야 해 오버헤드가 큽니다.
멀티스레드 (Multi-Thread)
멀티스레드란 하나의 프로세스 내에서 여러 스레드를 동시에 실행하는 방식입니다. 스레드들은 같은 메모리 공간을 공유하기 때문에 데이터 교환이 빠르고 생성 비용도 낮습니다.
실생활 비유
같은 주방(메모리)에서 여러 요리사가 재료(데이터)를 함께 쓰며 요리하는 것. 빠르고 효율적이지만, 같은 재료를 동시에 건드리려다 충돌이 날 수 있습니다.
방법 1 — Thread 직접 생성
public class BasicThreadExample {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("[Thread-1] 작업 " + i);
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("[Thread-2] 작업 " + i);
}
});
t1.start(); // start()로 실행 (run() 직접 호출)
t2.start();
t1.join(); // t1 종료 대기
t2.join(); // t2 종료 대기
System.out.println("모든 스레드 완료");
}
}
방법 2 — ExecutorService
import java.util.concurrent.*;
public class ExecutorExample {
public static void main(String[] args) throws Exception {
// 스레드 풀 생성 (CPU 코어 수만큼)
ExecutorService executor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 5; i++) {
final int taskId = i;
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Task-" + taskId + " 완료 by " + Thread.currentThread().getName();
});
futures.add(future);
}
for (Future<String> f : futures) {
System.out.println(f.get()); // 결과 대기
}
executor.shutdown(); // 반드시 종료 호출!
}
}
실무 팁
Thread를 직접 생성하는 방식은 학습용으로만 사용하고, 실무에서는 ExecutorService 또는 Spring의 @Async + ThreadPoolTaskExecutor를 사용하세요. 스레드 생성·종료 비용을 줄이고 스레드 수를 제어할 수 있습니다.
멀티스레드의 문제점
공유 메모리는 멀티스레드의 강점이지만, 동시에 가장 위험한 특성이기도 합니다. 반드시 알아야 할 3가지 핵심 문제를 살펴봅니다.
① 경쟁 조건 (Race Condition)
두 스레드가 동시에 같은 데이터를 읽고 쓸 때 의도치 않은 결과가 발생합니다. count++는 단순해 보이지만, 실제로는 읽기 → 증가 → 쓰기 3단계 연산입니다.

| 시점 | Thread-1 | Thread-2 |
| T1 | count 읽음 → 0 | |
| T2 | count 읽음 → 0 | |
| T3 | 0+1 = 1 → count에 씀 | |
| T4 | 0+1 = 1 → count에 씀 | |
| 결과 | count = 1 (기대값: 2) — 데이터 유실 발생! | |
import java.util.concurrent.atomic.*;
public class RaceConditionDemo {
static int unsafeCount = 0; // 위험
static AtomicInteger safeCount = new AtomicInteger(0); // 안전
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) safeCount.incrementAndGet();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) safeCount.incrementAndGet();
});
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("결과: " + safeCount.get()); // 항상 2000
}
}
해결 방법
synchronized 키워드로 임계 구역 보호, AtomicInteger 등 java.util.concurrent.atomic 패키지 활용, ReentrantLock 사용
② 데드락 (Deadlock)
두 스레드가 서로가 가진 락을 기다리며 영원히 멈춰버리는 상태입니다. 프로그램이 아무 오류 없이 응답만 안 하는 원인 1위입니다.

| Thread-A | Thread-B |
| Lock-1 보유 중 | Lock-2 보유 중 |
| Lock-2 요청 → 대기 | Lock-1 요청 → 대기 |
| 서로 상대방의 락이 풀리길 기다리며 무한 대기 (데드락) | |
public class DeadlockDemo {
static final Object lockA = new Object();
static final Object lockB = new Object();
// 데드락 발생 코드 — 락 획득 순서가 반대
static void badThread1() {
synchronized (lockA) {
synchronized (lockB) { } // lockB 대기 → 데드락!
}
}
static void badThread2() {
synchronized (lockB) {
synchronized (lockA) { } // lockA 대기 → 데드락!
}
}
// 해결: 모든 스레드가 동일한 순서로 락 획득
static void safeThread1() {
synchronized (lockA) { // 항상 A → B 순서
synchronized (lockB) { }
}
}
static void safeThread2() {
synchronized (lockA) { // 동일하게 A → B 순서
synchronized (lockB) { }
}
}
}
해결 방법
락 획득 순서를 모든 스레드에서 동일하게 유지, tryLock() 타임아웃 활용, 락 범위 최소화
③ 가시성 문제 (Visibility Problem)
각 CPU 코어는 캐시 메모리를 가집니다. 한 스레드가 값을 변경해도 다른 스레드의 CPU 캐시에는 아직 이전 값이 남아 있을 수 있어, 변경 사항을 인식하지 못하는 문제입니다.

public class VisibilityDemo {
// volatile 없으면: Thread-1이 변경해도 Thread-2가 인식 못할 수 있음
private volatile boolean running = true;
public void start() {
// Thread-1: running 플래그 모니터링
Thread worker = new Thread(() -> {
while (running) { // volatile로 항상 최신값을 메인 메모리에서 읽음
// 작업 수행...
}
System.out.println("Worker 종료됨");
});
// Thread-2: 1초 후 종료 신호 전송
Thread stopper = new Thread(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) {}
running = false; // volatile이므로 메인 메모리에 즉시 반영
System.out.println("종료 신호 전송");
});
worker.start();
stopper.start();
}
}
주의: volatile ≠ 동기화
volatile은 가시성만 보장하며, 원자성(atomicity)은 보장하지 않습니다. count++처럼 복합 연산은 여전히 Race Condition이 발생합니다. 복합 연산에는 AtomicInteger나 synchronized를 사용해야 합니다.
멀티프로세스 vs 멀티스레드 비교
| 구분 | 멀티프로세스 | 멀티스레드 |
| 메모리 구조 | 독립된 메모리 공간 | 메모리 공유 (Heap, Code) |
| 생성 비용 | 높음 (메모리 복제) | 낮음 (스택만 추가) |
| 컨텍스트 스위칭 | 무거움 (PCB 전체 교체) | 가벼움 (스택/레지스터만) |
| 데이터 공유 | IPC 필요 (소켓, 파이프) | 직접 공유 — 빠름 |
| 안정성 | 높음 (독립 → 충돌 없음) | 낮음 (하나 죽으면 전체 위험) |
| 동기화 이슈 | 없음 | 있음 (Race Condition, Deadlock) |
| Java 구현 | ProcessBuilder, Runtime.exec() | Thread, ExecutorService, @Async |
| 대표 사례 | Chrome 탭, Nginx worker, MSA | 웹 서버, DB 커넥션 풀, 채팅 서버 |
언제 무엇을 선택할까?
멀티프로세스는 프로세스 하나가 죽어도 전체 서비스가 유지돼야 할 때, 보안상 완전한 격리가 필요할 때, 독립 배포와 스케일아웃이 필요한 MSA 구조에 적합합니다.
멀티스레드는 I/O 대기 시간 동안 다른 작업을 처리해야 할 때, 빠른 데이터 공유가 필요할 때, 메모리 제약이 있고 가볍게 동시성을 구현해야 할 때 적합합니다.
마치며
멀티프로세스와 멀티스레드는 단순히 "동시에 여러 개를 실행하는 것"이 아니라, 메모리 구조와 자원 공유 방식이 근본적으로 다른 두 가지 접근 방식입니다.
특히 멀티스레드에서 발생하는 Race Condition, Deadlock, 가시성 문제는 Java 백엔드 면접에서도 자주 나오는 주제이니 각 문제의 원인과 해결책을 함께 이해해두면 좋습니다.
'Java' 카테고리의 다른 글
| [JAVA] 애너테이션(annotaion)이란? (0) | 2026.03.27 |
|---|---|
| [JAVA] 오버로딩(Overloading) 와 오버라이딩(Overriding)차이 (0) | 2024.02.23 |
| [JAVA] 자바의 JVM, JDK, JRE: 자바 개발 환경의 핵심 개념 (0) | 2024.02.23 |