온라인 게임에서는 몬스터 처치, 아이템 사용, 퀘스트 보상 수령처럼 많은 일이 동시에 일어난다. 플레이어 입장에서는 각자 화면에서 따로 벌어지는 일처럼 보이지만, 실제로는 게임 서버가 이런 행동을 받아 게임 상태를 계속 갱신한다.
이런 서버 프로그램은 보통 하나의 흐름으로만 동작하지 않는다. 많은 플레이어의 요청을 처리해야 하므로 여러 프로세스나 스레드로 일을 나누어 처리한다. 어떤 실행 흐름은 클라이언트 요청을 받고, 어떤 실행 흐름은 전투 결과를 계산하며, 또 다른 실행 흐름은 인벤토리나 퀘스트 상태를 갱신할 수 있다.
하지만 게임 로직의 많은 부분은 서로 완전히 독립되어 있지 않다. 예를 들어 어떤 캐릭터가 몬스터를 처치했다면, 서버는 그 캐릭터의 경험치를 올리고, 재화를 지급하고, 퀘스트 진행도를 바꾸고, 변경된 정보를 다시 플레이어에게 알려줘야 한다. 이 과정에서 여러 실행 흐름이 같은 캐릭터 정보나 게임 상태를 함께 사용할 수 있다.
운영체제에서는 이렇게 프로세스나 스레드가 함께 사용하는 자원을 공유 자원이라고 부른다. 공유 자원은 경험치, 골드 같은 변수(메모리)가 될 수도 있고, 파일이나 입출력장치가 될 수도 있다. 그런데 여러 프로세스나 스레드가 동시에 공유 자원에 접근하면 문제가 생길 수 있다. 이런 코드 영역을 임계 구역(Critical Section)이라고 한다.
예를 들어 전투 로직 스레드가 몬스터 처치 결과를 캐릭터 상태에 기록하고, 퀘스트 로직 스레드가 그 상태를 읽어 퀘스트 진행도를 갱신한다고 해보자. 전투 결과가 먼저 기록되고 퀘스트 처리가 그 뒤에 실행된다면 문제가 없다. 반대로 퀘스트 로직 스레드가 먼저 상태를 읽고, 그 이후에 전투 결과가 기록된다면 퀘스트 진행도는 갱신되지 않을 수 있다.
캐릭터 저장 데이터에서도 비슷한 문제가 생긴다. 한 스레드가 인벤토리 변경 내용을 저장하는 도중 다른 스레드가 같은 캐릭터 데이터를 읽거나 다시 저장하면, 둘 중 하나의 변경 사항이 사라질 수 있다.
또 스레드 A가 재화 보유량을 읽고 새로운 값을 계산했지만 아직 저장하지 않은 상황에서, 스레드 B가 끼어들어 같은 재화 보유량을 수정하고 저장할 수 있다. 이후 스레드 A가 이전에 계산한 값을 저장하면 스레드 B의 변경 사항은 덮어써진다.
공유 자원에 접근하는 코드의 실행 순서를 제어하지 않으면 이런 문제가 생긴다. 위 예시처럼 프로세스나 스레드가 동시에 임계 구역 코드를 실행해 문제가 발생하는 상황을 레이스 컨디션(Race Condition)이라고 한다. 레이스 컨디션이 발생하면 자원의 일관성이 손상될 수 있다. 따라서 2개 이상의 프로세스나 스레드가 임계 영역에 진입하려고 할 때는, 둘 중 하나가 작업이 끝날 때까지 기다려야 한다.
레이스 컨디션을 일으키는 예시 코드를 확인해보자. 다음은 2개의 스레드를 만들어 하나는 공유 변수(플레이어 재화)를 100,000번 1씩 증가시키고, 다른 하나는 공유 변수를 100,000번 1씩 감소시키는 C++ 코드다.
#include <iostream>
#include <thread>
int playerGold = 0; // 공유 데이터
void giveQuestReward()
{
for (int i = 0; i < 100'000; ++i)
{
playerGold += 1;
}
}
void buyPotion()
{
for (int i = 0; i < 100'000; ++i)
{
playerGold -= 1;
}
}
int main()
{
std::thread rewardThread(giveQuestReward);
std::thread shopThread(buyPotion);
rewardThread.join();
shopThread.join();
std::cout << "Expected gold: 0\n";
std::cout << "Actual gold: " << playerGold << '\n';
return 0;
}
이 코드는 실행 결과로 0이 출력되기를 기대하지만, 실제로는 다음과 같이 일정하지 않은 결과가 나온다. 즉 레이스 컨디션이 발생한 것이다.


레이스 컨디션을 방지하면서 임계 구역을 관리하려면 프로세스와 스레드가 동기화되어야 한다. 동기화는 다음 2가지 조건을 지키며 실행하는 것을 의미한다.
- 실행 순서 제어: 프로세스 및 스레드를 올바른 순서로 실행하기
- 상호 배제: 동시에 접근해서는 안 되는 자원에 하나의 프로세스 및 스레드만 접근하기
위에서 본 예시 중 '공유 자원 접근 순서'에는 실행 순서 제어 동기화가 필요하고, '캐릭터 저장 데이터'와 '골드 갱신'에는 상호 배제 동기화가 필요하다.
1. 동기화 기법
이런 레이스 컨디션을 해결하기 위해 운영체제와 프로그래밍 언어는 실행 순서를 제어하고 상호 배제를 보장하는 여러 동기화 기법을 제공한다.
| 기법 | 핵심 역할 |
| 뮤텍스 락 | 한 번에 하나만 임계 구역에 들어가게 함 |
| 세마포어 | 동시에 접근 가능한 개수를 제한함 |
| 조건 변수 | 특정 조건이 만족될 때까지 스레드를 재움 |
| 모니터 | 공유 데이터 + 락 + 조건 변수를 객체로 묶음 |
1. mutex lock
뮤텍스 락은 임계 구역에 한 번에 하나의 프로세스 또는 스레드만 들어가도록 제한하는 동기화 기법이다. 공유 자원 앞에 락을 두고, 락을 획득한 프로세스나 스레드만 공유 자원에 접근하게 만든다.
뮤텍스 락은 보통 공유 변수 lock과 두 가지 연산 acquire(), release()로 설명한다.
- lock: 현재 임계 구역이 사용 중인지 나타내는 값
- acquire(): 임계 구역에 들어가기 전에 락을 획득하는 연산
- release(): 임계 구역에서 작업을 마친 뒤 락을 반납하는 연산
어떤 스레드가 acquire()를 호출해 락을 얻으면, 그 스레드는 임계 구역에 들어갈 수 있다. 이때 다른 스레드가 같은 임계 구역에 들어가려고 acquire()를 호출해도, 이미 락이 사용 중이므로 진입할 수 없다. 먼저 들어간 스레드가 작업을 마치고 release()를 호출해야 락이 해제되고, 대기하던 다른 스레드 중 하나가 락을 획득해 임계 구역에 들어간다.
앞서 본 재화 갱신 예시에서 playerGold를 읽고, 계산하고, 저장하는 구간은 임계 구역이다. 이 구간을 뮤텍스 락으로 보호하면 퀘스트 보상 스레드와 상점 구매 스레드가 동시에 골드 값을 수정하지 못한다. 한 스레드가 락을 획득해 골드 갱신을 끝낸 뒤에야 다른 스레드가 같은 구간에 들어올 수 있으므로 레이스 컨디션을 막을 수 있다.
#include <iostream>
#include <mutex>
#include <thread>
int playerGold = 0; // 공유 데이터
std::mutex goldMutex; // 뮤텍스
void giveQuestReward()
{
for (int i = 0; i < 100'000; ++i)
{
std::lock_guard<std::mutex> lock(goldMutex); // lock
playerGold += 1;
}
// scope 벗어나면 자동으로 unlock
}
void buyPotion()
{
for (int i = 0; i < 100'000; ++i)
{
std::lock_guard<std::mutex> lock(goldMutex);
playerGold -= 1;
}
}
int main()
{
std::thread rewardThread(giveQuestReward);
std::thread shopThread(buyPotion);
rewardThread.join();
shopThread.join();
std::cout << "Expected gold: 0\n";
std::cout << "Actual gold: " << playerGold << '\n';
}
C++의 std::mutex에는 acquire, release에 대응되는 함수로 lock()과 unlock()을 제공한다. 다만 실제 코드에서는 lock()과 unlock()을 직접 호출하지 않고, 스코프를 벗어나면 자동으로 unlock()되는 std::lock_guard를 주로 사용한다.
2. semaphore
뮤텍스 락은 임계 구역에 한 번에 하나의 프로세스나 스레드만 들어가게 만드는 동기화 도구다. 하지만 모든 자원이 반드시 하나씩만 사용되어야 하는 것은 아니다.
파티 단위로 입장할 수 있는 던전을 생각해보자. 한 파티가 던전에 입장하면 서버는 그 파티를 위한 던전 인스턴스를 하나 생성하거나 할당해야 한다. 하지만 던전 인스턴스는 메모리와 CPU를 사용하는 서버 자원이므로 무한정 만들 수 없다.
예를 들어 동시에 실행할 수 있는 던전 인스턴스를 최대 3개로 제한한다면, 서버는 한 번에 3개 파티까지만 던전에 입장시킬 수 있다. 이미 3개의 던전 인스턴스가 실행 중이라면 다음 파티의 입장 요청은 기존 인스턴스 중 하나가 종료될 때까지 기다려야 한다.
이처럼 사용할 수 있는 자원의 개수를 세고, 그 개수만큼만 동시에 접근을 허용하는 동기화 도구가 세마포다. 뮤텍스가 “한 번에 하나만”을 보장한다면, 세마포는 “최대 N개까지”를 보장하는 더 일반적인 동기화 도구라고 볼 수 있다.
세마포는 다음과 같은 하나의 변수와 2개의 함수로 구성된다.
- 변수 S: 현재 사용할 수 있는 공유 자원 개수
- wait(): 자원을 사용하기 전에 호출하는 함수
- signal(): 자원 사용이 끝난 뒤 호출하는 함수
세마포의 동작은 다음과 같은 의사코드로 표현할 수 있다.
wait()
{
S--; // 자원 하나 사용 시도
if (S < 0) // 남은 자원이 없다면
{
sleep(); // 현재 스레드 대기
}
}
signal()
{
S++; // 자원 하나 반납
if (S <= 0) // 대기 중인 스레드가 있다면
{
wakeup(p); // 그중 하나를 깨움
}
}
던전 예시로 보면 S는 현재 사용 가능한 던전 인스턴스 슬롯 수다. 파티가 던전에 입장하려면 먼저 wait()를 호출해 슬롯을 하나 획득해야 한다. 사용 가능한 슬롯이 남아 있다면 입장할 수 있지만, 모든 슬롯이 사용 중이라면 해당 스레드는 빈 슬롯이 생길 때까지 기다린다. 던전이 종료되면 signal()을 호출해 슬롯을 반납하고, 대기 중인 다른 파티가 다시 입장할 수 있게 한다.
#include <chrono>
#include <iostream>
#include <mutex>
#include <semaphore>
#include <thread>
#include <vector>
constexpr int MaxDungeonInstances = 3;
std::counting_semaphore<MaxDungeonInstances> dungeonSlots(MaxDungeonInstances);
std::mutex coutMutex;
void enterDungeon(int partyId)
{
dungeonSlots.acquire(); // wait(): 던전 슬롯 하나 획득
{
std::lock_guard<std::mutex> lock(coutMutex);
std::cout << "Party " << partyId << " entered dungeon.\n";
}
std::this_thread::sleep_for(std::chrono::seconds(1));
{
std::lock_guard<std::mutex> lock(coutMutex);
std::cout << "Party " << partyId << " left dungeon.\n";
}
dungeonSlots.release(); // signal(): 던전 슬롯 하나 반환
}
int main()
{
std::vector<std::thread> parties;
for (int i = 1; i <= 8; ++i)
{
parties.emplace_back(enterDungeon, i);
}
for (auto& party : parties)
{
party.join();
}
}
3. condition variable
뮤텍스 락과 세마포는 주로 공유 자원에 동시에 접근할 수 있는 프로세스 및 스레드 수를 제어한다. 하지만 어떤 작업은 공유 자원에 접근할 수 있느냐보다 실행 조건이 만족되었느냐가 더 중요하다.
예를 들어 게임 서버의 매칭 시스템을 생각해보자. 매칭 스레드는 대기 큐에 플레이어가 2명 이상 모이면 매치를 만들 수 있다. 하지만 큐가 비어 있거나 플레이어가 1명뿐이라면 매칭을 진행할 수 없다. 이때 매칭 스레드가 계속 큐를 확인하면서 반복문을 도는 것은 비효율적이다.
이런 상황에서 사용할 수 있는 동기화 도구가 조건 변수다. 조건 변수는 특정 조건이 만족될 때까지 스레드를 잠들게 하고, 조건이 만족되었을 때 다른 스레드가 이를 깨울 수 있게 한다.
조건 변수는 보통 wait()와 signal() 연산으로 설명한다.
- wait(): 현재 실행 조건이 만족되지 않았을 때 호출한다. 이 연산을 호출한 스레드는 실행을 멈추고 대기 상태가 된다.
- signal(): 조건을 만족시킬 수 있는 변화가 생겼을 때 호출한다. 대기 중인 스레드가 있다면 그중 하나를 깨워 다시 실행할 수 있게 한다.
매칭 시스템에 대입해보면, 매칭 스레드는 대기 큐에 플레이어가 충분하지 않으면 wait()을 호출해 잠든다. 이후 네트워크 스레드가 새 플레이어를 큐에 넣고, 큐의 인원이 매칭 가능한 수에 도달하면 signal()을 호출한다. 그러면 잠들어 있던 매칭 스레드가 깨어나 매치를 생성한다.
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <string>
#include <thread>
std::mutex queueMutex;
std::condition_variable matchCondition;
std::queue<std::string> waitingPlayers;
void matchmakingThread()
{
std::unique_lock<std::mutex> lock(queueMutex);
// wait 걸 때 mutex를 잠시 해제
matchCondition.wait(lock, [] {
return waitingPlayers.size() >= 2;
});
// notify 받으면 다시 mutex 획득
std::string playerA = waitingPlayers.front();
waitingPlayers.pop();
std::string playerB = waitingPlayers.front();
waitingPlayers.pop();
std::cout << "Match created: "
<< playerA << " vs " << playerB << '\n';
}
void networkThread()
{
{
std::lock_guard<std::mutex> lock(queueMutex);
waitingPlayers.push("PlayerA");
waitingPlayers.push("PlayerB");
}
matchCondition.notify_one(); // signal
}
int main()
{
std::thread matcher(matchmakingThread);
std::thread network(networkThread);
matcher.join();
network.join();
}
4. monitor
조건 변수는 실행 순서 제어에 유용하지만, 실제 프로그램에서는 공유 자원, mutex, condition_variable, 그리고 이를 다루는 함수들이 흩어져 있으면 관리하기 어렵다. 이들을 하나의 구조로 묶어 다루는 개념이 모니터다.
모니터는 공유 자원과 그 공유 자원에 접근하는 연산을 하나로 묶은 동기화 구조다. 외부에서는 모니터가 제공하는 함수만 호출하고, 공유 자원에 직접 접근하지 않는다.
모니터 내부에서는 한 번에 하나의 프로세스나 스레드만 공유 자원 연산을 수행할 수 있다. 따라서 기본적으로 상호 배제를 제공한다. 또한 조건 변수를 함께 사용하면 특정 조건이 만족될 때까지 기다렸다가, 조건이 만족되었을 때 다시 실행되는 흐름도 만들 수 있다.
매칭 큐를 모니터처럼 만든다고 생각해 보자. MatchQueue 객체는 내부에 대기 중인 플레이어 목록, mutex, condition_variable을 가지고 있다. 외부 스레드는 Join()이나 MakeMatch() 같은 함수만 호출한다. 대기 큐에 직접 접근하지 못하게 하면, 큐를 수정하는 모든 작업이 객체 내부의 동기화 규칙을 따르게 된다.
여기에 조건 변수를 함께 사용하면 실행 순서 제어도 가능하다. 예를 들어 makeMatch()는 대기 중인 플레이어가 2명 이상일 때만 매치를 만들 수 있다. 조건이 만족되지 않으면 cv.wait()로 대기하고, join()가 새 플레이어를 추가한 뒤 cv.signal()을 호출하면 대기 중이던 매칭 스레드가 깨어나 다시 모니터에 진입할 수 있다.
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <string>
#include <thread>
class MatchQueue
{
public:
void join(std::string player)
{
{
std::lock_guard<std::mutex> lock(mutex_);
players_.push(std::move(player));
}
condition_.notify_one();
}
std::pair<std::string, std::string> makeMatch()
{
std::unique_lock<std::mutex> lock(mutex_);
condition_.wait(lock, [this] {
return players_.size() >= 2;
});
std::string playerA = players_.front();
players_.pop();
std::string playerB = players_.front();
players_.pop();
return {playerA, playerB};
}
private:
std::mutex mutex_;
std::condition_variable condition_;
std::queue<std::string> players_;
};
스레드 안전(thread safety)이란 말이 있다. 멀티스레드 환경에서 어떤 변수나 함수, 객체에 동시 접근이 이뤄져도 실행에 문제가 없는 상태를 의미한다. 레이스 컨디션이 발생했다면 이는 '스레드 안전하지 않다'라고 말한다.
2. 교착 상태(Deadlock)
앞에서는 공유 자원에 동시에 접근할 때 생기는 문제를 막기 위해 뮤텍스 락, 세마포, 조건 변수, 모니터 같은 동기화 기법을 살펴봤다. 동기화 도구는 공유 자원을 안전하게 다루기 위해 필요하지만, “락을 걸었다”는 사실만으로 항상 안전한 코드를 보장하지는 않는다. 여러 락을 함께 사용하는 순간에는 스레드들이 서로의 락을 기다리며 멈춰버릴 수 있다. 이를 교착 상태(deadlock)라 부른다.
예를 들어 게임 서버에서 아이템 거래를 처리한다고 해보자. 거래가 정상적으로 처리되려면 플레이어의 인벤토리 정보와 거래소 목록을 함께 수정해야 한다. 그래서 한 스레드는 인벤토리 락을 획득한 뒤 거래소 락을 기다리고 있고, 다른 스레드는 거래소 락을 획득한 뒤 인벤토리 락을 기다리고 있다고 해보자.
첫 번째 스레드는 거래소 락이 풀려야 진행할 수 있고, 두 번째 스레드는 인벤토리 락이 풀려야 진행할 수 있다. 그런데 각 락을 풀어줄 스레드가 서로 상대방의 락을 기다리고 있으므로, 어느 쪽도 더 이상 앞으로 나아가지 못한다.
1. 발생 조건
교착 상태가 발생하는 4가지 필요 조건이 있다. 상호 배제, 점유 & 대기, 비선점, 원형 대기이다. 이 4가지 조건을 모두 만족하면 교착 상태가 발생할 가능성이 생긴다.
1. 상호 배제
근본적으로 한 번에 하나의 실행 흐름만 해당 자원을 이용할 수 있는 '상호 배제' 상황이므로 교착 상태가 발생할 수 있다.
2. 점유 & 대기
한 실행 흐름이 자원을 점유한 채 다른 자원을 할당받으려고 대기한다면 교착 상태가 발생할 수 있다.
3. 비선점
자원을 이용하는 실행 흐름의 작업이 끝나야만 그 자원을 다시 이용할 수 있다. 즉 해당 자원을 강제로 빼앗지 못하는, '선점할 수 없는' 특성 때문에 교착 상태가 발생할 수 있다.
4. 원형 대기
각 실행 흐름이 서로 점유한 자원을 할당받으려고 한다면, 즉 사이클이 생긴다면 교착 상태가 발생할 수 있다. 앞의 거래 예시에서는 인벤토리 락과 거래소 락을 서로 다른 순서로 획득하면서 원형 대기가 만들어졌다.
2. 해결 방법
운영체제는 교착 상태가 발생하지 않도록 자원을 잘 분배해 사전에 예방할 수도 있고, 교착 상태가 발생할 것 같으면 자원 할당을 미뤄 회피할 수도 있다. 또는 제약 없이 자원을 할당하다가 교착 상태를 검출하면 회복할 수도 있다. 각 방법을 살펴보자.
1. 예방
앞서 살펴본 발생 조건 4가지 중 하나를 충족하지 못하게 하는 방법이다. 가령 한 실행 흐름에 모든 자원을 몰아주고, 그다음에 다른 실행 흐름에 자원을 모두 몰아주면 점유 & 대기 조건을 만족하지 않아 교착 상태가 발생하지 않는다. 또 할당 가능한 모든 자원에 번호를 매기고 오름차순으로 할당하면 원형 대기 조건을 만족하지 않아 교착 상태가 발생하지 않는다.
앞의 거래 예시에서는 스레드 A가 인벤토리 락을 잡은 뒤 거래소 락을 기다리고, 스레드 B가 거래소 락을 잡은 뒤 인벤토리 락을 기다리면서 원형 대기가 생겼다. 이를 막기 위해 모든 코드에서 락을 획득하는 순서를 통일할 수 있다. 예를 들어 거래 처리, 정산 처리, 아이템 등록 처리 모두 항상 인벤토리 락 -> 거래소 락 순서로만 락을 획득하게 만드는 것이다. 그러면 어떤 스레드도 '거래소 락을 가진 채 인벤토리 락을 기다리는' 형태가 될 수 없으므로 원형 대기를 막을 수 있다.
2. 회피
자원을 할당할 때마다 해당 할당이 시스템을 안전 상태로 유지하는지 검사하는 방식이다. 만약 어떤 자원 할당이 시스템을 불안전 상태로 만들 수 있다면, 운영체제는 해당 자원을 즉시 할당하지 않고 대기시킨다.
이는 교착 상태를 한정된 자원을 무분별하게 할당한 결과로 바라보는 관점에 가깝다. 따라서 자원 요청이 들어왔다고 바로 할당하는 것이 아니라, 이후에도 모든 프로세스가 정상적으로 종료될 수 있는 할당 순서가 남아 있는지 확인한 뒤 자원을 할당한다.
게임 서버로 비유하면, 어떤 던전 인스턴스나 DB 커넥션 같은 자원이 남아 있다고 해서 요청이 들어오는 즉시 모두 배정하지 않는 것이다. 지금은 자원이 남아 있어 보여도, 이후 반드시 처리해야 할 작업들이 더 이상 진행하지 못하는 상태가 될 수 있다면 해당 요청을 잠시 대기시킨다.
교착 상태 회피를 위한 알고리즘으로 은행원 알고리즘(banker's algorithm)이 있다.
3. 검출 후 회복
예방과 회피가 교착 상태 발생을 막는 사전 조치였다면, 검출 후 회복은 사후 조치이다. 운영체제는 실행 흐름이 자원을 요구할 때마다 할당해 주고 주기적으로 교착 상태 발생 여부를 검사한다. 그러다 교착 상태가 검출되면 실행 흐름이 가진 자원을 강제로 빼앗거나, 교착 상태에 놓인 실행 흐름을 강제로 종료해 회복시킨다.
예를 들어 서버가 특정 작업이 비정상적으로 오래 락을 기다리고 있다는 사실을 감지하면, 해당 작업을 실패 처리하거나 롤백하고 다시 시도하게 만들 수 있다. 더 강한 방식으로는 특정 작업을 강제 종료해 그 작업이 점유하던 자원을 회수할 수도 있다.

'Computer Science > Operating System' 카테고리의 다른 글
| 가상 메모리 (0) | 2025.10.17 |
|---|---|
| 프로세스와 스레드 (3) | 2025.10.13 |
| Stack 메모리 vs Heap 메모리 (0) | 2025.09.25 |
| 운영체제의 큰 그림 (0) | 2025.05.09 |


