멀티쓰레드 설계와 동기화: 공유 메모리 환경의 동시성 제어
멀티쓰레드의 자원 공유 구조와 동기화 문제, 쓰레드 풀, 주요 프로그래밍 모델을 실무 관점에서 정리한다.
2026-08-14 · 최초 발행 2026-01-16
같은 프로세스 안에서 나뉘는 실행 흐름
멀티쓰레드는 하나의 프로세스에서 여러 실행 흐름을 동시에 진행하는 방식이다. 멀티코어 CPU의 병렬 처리를 활용할 수 있고, I/O를 기다리는 동안 다른 작업을 처리할 수 있어 응답성과 처리량을 높이는 데 쓰인다.
쓰레드는 프로세스 내부의 가장 작은 실행 단위이며 경량 프로세스(Lightweight Process, LWP)라고도 한다. 프로세스는 하나 이상의 쓰레드를 포함하고, 각 쓰레드는 독립적으로 실행되면서도 프로세스의 메모리 공간과 자원을 함께 사용한다.
프로세스와 쓰레드의 차이는 주소 공간과 자원 격리 방식에서 분명해진다.
| 구분 | 프로세스 | 쓰레드 |
|---|---|---|
| 메모리 공간 | 독립적인 주소 공간 | 프로세스 내 공유 |
| 생성 비용 | 높음 (메모리 할당) | 낮음 (스택만 할당) |
| 컨텍스트 스위칭 | 오버헤드 큼 | 오버헤드 작음 |
| 통신 방식 | IPC (파이프, 소켓 등) | 공유 메모리 직접 접근 |
| 안정성 | 독립적 (장애 격리) | 하나의 쓰레드 장애가 전체에 영향 |
| 자원 공유 | 별도 메커니즘 필요 | 자연스럽게 공유 |
각 쓰레드가 따로 갖는 자원은 프로그램 카운터(PC), 레지스터 집합, 스택, 쓰레드 ID, 실행·준비·대기 등의 쓰레드 상태다. 반면 코드 세그먼트, 전역 변수가 있는 데이터 세그먼트, 힙, 열린 파일의 파일 디스크립터, 시그널 핸들러는 같은 프로세스의 쓰레드가 공유한다.
병렬 처리의 이점과 공유 상태의 대가
멀티쓰레드는 멀티코어 CPU를 활용하고 I/O 대기 중에도 다른 작업을 진행할 수 있다. UI 쓰레드를 분리해 응답성을 높이거나, 복잡한 작업을 논리적으로 나누고 비동기 처리를 구성할 때도 적합하다. 프로세스보다 생성과 종료가 가볍고, 메모리를 공유하므로 자원 사용과 컨텍스트 스위칭 측면에서도 이점이 있다.
다만 공유 자원은 동기화 문제를 동반한다. 실행 순서가 비결정적이어서 버그 재현이 어렵고, 공유 자원 접근을 제어하지 않으면 레이스 컨디션이 생길 수 있다. 쓰레드 생성·관리, 동기화, 컨텍스트 스위칭 자체도 비용으로 남는다.
사용자 수준과 커널 수준의 쓰레드
사용자 수준 쓰레드는 사용자 공간의 쓰레드 라이브러리가 관리하며, 커널은 그 존재를 인식하지 못한다. 커널 개입 없이 관리하므로 전환이 빠르고 이식성이 높다. 대신 하나의 쓰레드가 블로킹되면 프로세스 전체가 블로킹될 수 있고, 멀티프로세서를 활용할 수 없으며 시스템 콜에서도 프로세스 전체가 대기한다.
커널 수준 쓰레드는 운영체제 커널이 직접 관리하며, 각 쓰레드를 독립된 스케줄링 단위로 인식한다. 멀티프로세서 활용이 가능하고 한 쓰레드의 블로킹이 다른 쓰레드에 영향을 주지 않는다. 그 대가로 생성과 관리에 커널이 개입하며, 사용자 수준 쓰레드보다 전환이 느리고 시스템 자원을 더 사용한다.
다대일 모델은 여러 사용자 쓰레드를 하나의 커널 쓰레드에 매핑한다. 사용자 수준 쓰레드 라이브러리를 사용하며, 하나의 블로킹이 전체에 영향을 준다.
일대일 모델은 사용자 쓰레드와 커널 쓰레드를 1:1로 연결한다. Windows와 Linux의 기본 모델이며, 높은 동시성을 제공하지만 많은 커널 자원을 사용한다.
다대다 모델은 사용자 쓰레드를 같거나 적은 수의 커널 쓰레드에 매핑한다. 스케줄링 유연성이 있지만 구현은 복잡하다.
공유 자원에 접근할 때 생기는 문제
레이스 컨디션은 여러 쓰레드가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 상황이다.
// 문제 상황 예시
// 쓰레드 1: counter++
// 쓰레드 2: counter++
// 기대 결과: 2, 실제 결과: 1 또는 2 (비결정적)
공유 자원에 접근하는 임계 구역은 한 번에 하나의 쓰레드만 실행하도록 제어해야 한다. 이를 위해 뮤텍스, 세마포어, 조건 변수, 모니터를 사용한다.
뮤텍스는 상호 배제를 위한 잠금 메커니즘이다. 잠금과 해제 쌍으로 사용하며, 잠금을 획득한 쓰레드만 해제할 수 있는 소유권 개념이 있다. 세마포어는 카운터 기반으로 여러 쓰레드의 동시 접근을 제어하고, 이진 세마포어는 뮤텍스와 유사하게 사용할 수 있다.
조건 변수는 특정 조건이 충족될 때까지 쓰레드를 대기시키며 뮤텍스와 함께 사용한다. wait와 signal 연산이 여기에 해당한다. 모니터는 상호 배제와 조건 동기화를 캡슐화한 형태로, 객체 지향 언어에서 주로 사용하며 Java의 synchronized 키워드가 대표적인 예다.
데드락은 두 개 이상의 쓰레드가 서로가 가진 자원을 기다리며 무한 대기하는 상태다. 상호 배제, 점유 대기, 비선점, 순환 대기 조건이 모두 충족될 때 발생한다. 라이브락은 쓰레드들이 서로 양보하며 상태만 바꾸고 진행하지 못하는 상태이고, 기아는 특정 쓰레드가 자원을 계속 할당받지 못하는 상태다.
작업량을 조절하는 쓰레드 풀
쓰레드 풀은 미리 생성한 쓰레드를 모아 두고, 작업 요청이 들어오면 풀에서 쓰레드를 할당한 뒤 완료 후 반환하는 패턴이다.
재사용을 통해 쓰레드 생성과 소멸의 오버헤드를 줄이고 응답 시간을 단축할 수 있다. 최대 쓰레드 수를 제한해 시스템 과부하를 막고 예측 가능한 자원 사용을 만들며, 중앙에서 쓰레드를 관리하므로 모니터링과 튜닝, 작업 큐를 통한 부하 조절도 수월해진다.
CPU 바운드 작업의 권장 풀 크기는 CPU 코어 수 + 1이며, 컨텍스트 스위칭을 최소화하는 데 목적이 있다. I/O 바운드 작업은 CPU 코어 수 x (1 + 대기시간/처리시간)으로 정할 수 있어 I/O 대기 중 다른 작업을 처리하도록 한다.
언어와 런타임이 제공하는 동시성 도구
Unix/Linux 환경의 C/C++에서는 POSIX Threads(Pthreads)를 표준 쓰레드 API로 사용한다. pthread_create()는 쓰레드를 생성하고, pthread_join()은 쓰레드 종료를 기다린다. pthread_mutex_lock/unlock()은 뮤텍스 연산에, pthread_cond_wait/signal()은 조건 변수 연산에 사용한다.
Java에서는 Thread 클래스를 상속하거나 Runnable 인터페이스를 구현해 쓰레드를 구성할 수 있다. start() 메소드로 실행을 시작하고 synchronized 키워드로 동기화한다. Executor Framework는 ExecutorService 인터페이스와 ThreadPoolExecutor 구현을 제공하며, Future/Callable로 결과를 반환한다.
Modern C++의 C++11 이상에서는 std::thread, std::mutex, std::lock_guard, std::condition_variable을 사용할 수 있다. C++17의 병렬 알고리즘은 std::execution::par와 자동 병렬화 지원을 제공한다.
작업을 나누고 결과를 회수하는 패턴
Producer-Consumer 패턴은 생산자가 데이터를 만들고 소비자가 이를 처리하는 방식이다. Producer는 데이터 생성 쓰레드, Consumer는 데이터 처리 쓰레드이며, Buffer는 공유 버퍼인 큐다. 버퍼 접근은 동기화로 제어한다.
Worker Thread 패턴은 작업 큐에서 작업을 꺼내 처리하며, 쓰레드 풀의 기본 패턴으로 쓰인다. Future 패턴은 비동기 작업의 결과를 나중에 받는다. 작업 요청 시 Future 객체를 즉시 반환하고 백그라운드에서 작업을 실행한 뒤, 필요할 때 Future.get()으로 결과를 획득한다.
서비스와 애플리케이션에서의 사용 맥락
웹 서버는 요청당 쓰레드 모델이나 쓰레드 풀 기반 처리를 사용하고, 비동기 I/O와 결합할 수 있다. GUI 애플리케이션은 UI를 담당하는 메인 쓰레드와 백그라운드 작업 쓰레드를 분리하며, UI 업데이트는 메인 쓰레드에서만 수행한다.
데이터베이스에서는 연결 풀과 쓰레드 풀, 트랜잭션별 쓰레드, 병렬 쿼리 처리가 관련된다. 게임 엔진은 렌더링, 물리 연산, AI·로직, 오디오를 각각의 쓰레드로 분리해 처리할 수 있다.