개발을 공부하면서 가장 혼란스러웠던 개념 중 하나가 프로세스와 스레드 그리고 멀티 스레딩이었다.
Process 무언가 실행되는 것... Thread 이것도 무언가 실행되는 것..?
멀티 스레딩? 뭔가 여러 개가 동시에 실행되는 느낌?...
알 것 같으면서도 막상 설명하려 하면 말문이 막혔다.
게임 서버는 동시에 수많은 유저의 요청을 처리해야 하다 보니 싱글 스레드로 감당하기 어려운 경우가 많다.
그래서 멀티스레딩이 무엇인지 또 멀티스레딩에서 발생하는 문제들과 이를 해결하기 위한
뮤텍스(Mutex), 크리티컬 섹션(Critical Section), 세마포어(Semaphore), 락(Lock) 같은 개념들을 접하게 되었다.
이번 글에서는 내가 처음 느꼈던 혼란과 궁금증을 바탕으로 이 개념들이 어떤 의미를 가지는지 정리해 보려 한다.
프로세스와 스레드는 무엇이 다른가?
프로세스(Process)는 실행 중인 프로그램이다. 예를 들어 우리가 게임을 실행하면 운영체제는 그 게임을 하나의
프로세스로 관리한다. 프로세스는 고유한 메모리 공간을 가지고 있고, 다른 프로세스와 메모리를 공유하지 않는다.
스레드(Thread)는 프로세스 안에서 실제로 작업을 수행하는 실행 단위이다. 하나의 프로세스는 하나 이상의
스레드를 가질 수 있고, 이 스레드들은 같은 프로세스의 메모리 공간을 함께 사용한다.
쉽게 말해, 프로세스는 작업장의 건물이고, 스레드는 그 안에서 일하는 작업자들이다. 작업장마다 벽이 쳐져 있어
다른 작업장과는 자료를 공유하지 않지만, 같은 작업장 안의 작업자들은 같은 공간에서 필요한 자원을 함께 쓰며 일한다.
🎮게임 속의 프로세스와 스레드
게임 클라이언트를 예로 들면, 실행 파일 하나가 프로세스가 되고, 그 안에서 UI 렌더링을 담당하는 스레드,
사운드를 처리하는 스레드, 네트워크 데이터를 처리하는 스레드 등이 동시에 동작할 수 있다.
서버도 마찬가지이다. 유저 수가 많아질수록, 각각의 유저 요청을 병렬로 처리하기 위해 여러 스레드를 활용하게 된다.
하나의 유저 요청을 처리하는 동안 다른 유저의 요청을 기다리게 하면 전체 서비스 품질이 떨어지기 때문이다.
Single Thread & Multi Thread
싱글 스레드(Single Thread)는 하나의 작업 흐름만을 가진 구조이다. 한 번에 하나의 작업만 순차적으로 처리하며,
다음 작업은 이전 작업이 끝나야 시작할 수 있다. 구조가 단순하고 디버깅이 쉬우며 동기화 문제가 적다는 장점이 있다.
하지만 처리량이 많은 환경에서는 단점이 명확하다. 한 작업이 오래 걸리면 다른 작업은 모두 기다려야 하므로
전체 성능이 떨어진다. 게임 서버처럼 다수의 유저 요청을 동시에 처리해야 하는 환경에는 적합하지 않다.
멀티 스레드(Multi Thread)는 여러 개의 스레드를 만들어 동시에 작업을 수행하는 구조이다. 하나의 작업이 느리더라도
다른 작업을 동시에 진행할 수 있어서 효율적이다. 예를 들어, 한 스레드는 네트워크 데이터를 받고, 다른 스레드는
계산하고, 또 다른 스레드는 DB 저장을 처리할 수 있다.
🎮게임 속의 싱글 스레드와 멀티 스레드
게임 서버는 수많은 유저가 동시에 접속하여 다양한 요청을 보내는 환경이다. 만약 서버가 싱글 스레드로만 동작한다면,
한 유저가 미션을 완료하는 동안 다른 유저의 요청이 계속 대기하게 된다. 이는 사용자 경험에 안 좋은 영향을 준다.
멀티 스레드를 사용하면 각각의 요청을 별도의 스레드에서 처리함으로써 병목을 줄이고,
전체 서버 처리량을 높일 수 있다. 하지만 일 잘하는 애일수록 실수할 가능성이 높다.
여러 작업을 동시에 처리하다 보니 내가 보던 데이터를 다른 누군가 건드릴 수 있기 때문이다.
즉 동기화 문제가 발생할 가능성이 커진다.
멀티 스레드의 문제와 동기화의 필요성
멀티 스레드는 동시에 여러 작업을 처리할 수 있고, 서버의 응답 속도와 처리량도 향상된다.
하지만 여러 스레드가 동시에 같은 자원에 접근하여 값을 변경할 경우, 실행 순서에 따라 결과가 달라지는
경쟁 상태(Race Condition)가 발생할 수 있다.
예를 들어, 두 유저가 동시에 같은 미션을 완료하려고 하면, 경험치가 중복되거나 누락되는 현상이 생길 수 있다.
그런데 왜 실행 순서가 달라질까? 코드에는 순서가 있는데...
그 이유는 컨텍스트 스위칭(Context Switching) 때문이다. 운영체제는 CPU가 여러 스레드를 동시에 처리할
수 없기 때문에, 아주 짧은 시간 간격으로 스레드들을 번갈아 실행한다. 이 과정에서 스레드의 실행 상태(레지스터,
메모리 등)를 저장하고 불러오는 작업이 이루어지는데, 이걸 컨텍스트 스위칭이라고 한다.
문제는 이 스위칭 타이밍이다. 스레드 A가 데이터를 읽어오고, 그 값을 기반으로 무언가 하려는 순간 스레드 B가
끼어들어 값을 바꾸고 나면, A는 오래된 값을 기준으로 잘못된 처리를 하게 된다.
"적당히 잘 나눠서 코드 짜면 되지 않나?" 싶을 수 있다.
하지만 CPU는 기계어 수준에서 작업을 처리하기 때문에, 언제 컨텍스트 스위칭이 발생할지는 예측할 수 없다.
사람 눈에는 한 번에 처리되는 것처럼 보여도, CPU는 밀리초 단위로 스레드를 바꿔가며 일을 처리하고 있다.
이처럼 아주 짧은 순간에 순서가 뒤섞이면서,
예측하지 못한 결과가 발생한다. 이런 문제를 방지하기 위해 사용하는 것이 동기화(Synchronization)이다.
말 그대로, 동시에 여러 작업이 실행될 때 순서를 정해주는 기술이다.
🔒 대표적인 동기화 기법
락(Lock)
락은 위에 소개한 뮤텍스나 세마포어를 통해 자원 접근을 제어하는 행위 자체를 의미하며, 가장 넓은 개념이다.
C#의 lock(obj)은 내부적으로 Monitor.Enter(obj) / Monitor.Exit(obj)로 구현된다.
C++에서는 std::mutex와 함께 std::lock_guard, std::unique_lock, std::scoped_lock 등을 활용한다.
크리티컬 섹션(Critical Section)
둘 이상의 스레드가 동시에 접근해서는 안 되는 코드 블록 또는 자원을 의미한다.
이를 보호하기 위해 위에서 설명한 뮤텍스 또는 락으로 감싼다.
C# / C++에서는 별도 키워드 없이 락으로 보호된 구간이 크리티컬 섹션이다.
뮤텍스(Mutex : Mutual Exclusion )
한 번에 하나의 스레드만 공유 자원에 접근할 수 있도록 제한하는 동기화 도구이다.
락(lock)이라고도 부르며, 다른 스레드는 락이 해제되기 전까지 대기하게 된다.
C# 코드 예시 :
private static object mutex = new object();
void DoWork() {
lock (mutex) {
// 공유 자원 접근
}
}
※ lock(obj)는 내부적으로 Monitor.Enter/Exit를 사용하여 단일 스레드 접근을 허용한다.
C++ 코드 예시 :
#include <mutex>
std::mutex mtx;
void DoWork() {
std::lock_guard<std::mutex> lock(mtx); // RAII 방식
// 공유 자원 접근
}
std::lock_guard는 스코프를 벗어나면 자동으로 unlock()이 되고 예외가 발생하더라도 자동으로 unlock이 되어 안전하다.
세마포어(Semaphore)
동시에 접근할 수 있는 스레드 수를 제한한다. 주차장 입구에서 차량 수를 제한하는 느낌이다.
뮤텍스보다 유연하지만 복잡하다.
C# 코드 예시 :
SemaphoreSlim semaphore = new SemaphoreSlim(3); // 최대 3개의 스레드 허용
async Task DoWorkAsync() {
await semaphore.WaitAsync();
try {
// 공유 자원 접근
} finally {
semaphore.Release();
}
}
C++ 코드 예시 (C++20부터 지원) :
#include <semaphore>
std::counting_semaphore<3> sem(3); // 초기 3개 허용
void DoWork() {
sem.acquire(); // 진입
// 공유 자원 접근
sem.release(); // 해제
}
※ C++17 이하는 boost::interprocess::interprocess_semaphore 같은 외부 라이브러리를 사용해야 한다.
💡사용할 때 한 번 더 생각해야 할 사항들
락은 꼭 필요한 최소 범위에서만 걸어야 성능을 지킬 수 있다.
과도한 락은 **데드락(deadlock)**을 유발하거나 처리량을 급격히 낮출 수 있다.
더 정교한 락 구조가 필요하다면, C++에서는 scoped_lock,
C#에서는 SemaphoreSlim, ReaderWriterLockSlim 같은 고급 락도 있다.
서버를 만들면서 가장 크게 느낀 건 유저는 언제나 예상을 뛰어넘는 행동을 한다는 점이다.
하나의 요청이 끝나기도 전에 같은 유저가 여러 요청을 동시에 보내는 상황이 반복되면서,
유저 상태 값이 꼬이는 문제가 발생할 수 있다. 이런 문제를 어떻게 방지할 수 있을지 고민하던 끝에,
Redis의 SETNX를 활용한 방식으로 해결해 보기로 했다.
같은 유저가 동시에 여러 요청을 보낼 경우, 하나는 처리하고 나머지는 바로 거절하는 구조를 만들었다.
이렇게 유저 단위로 락을 걸어 상태 꼬임 없이 안정적으로 흐름을 제어할 수 있었다.
막연하게 겁먹었던 뮤텍스, 락, 컨텍스트 스위칭 같은 개념들을 이렇게 정리하고 직접 써보니,
서버 개발이 점점 더 재미있어졌다.
헷갈리던 용어들을 하나씩 정확하게 이해하고, 내 코드에 적용해 보는 과정 자체가 공부가 아니라 실전처럼 느껴졌다.
이론으로만 알고 있던 지식을 직접 활용해 보니 조금은 뿌듯하고, 확실히 내 것이 되는 느낌이었다.
'Game Server Class 101' 카테고리의 다른 글
Nagle 알고리즘? 무엇인가..? 진짜 작동하긴 하는가? (0) | 2025.05.21 |
---|---|
TCP랑 UDP 특. 정확하거나 빠르거나 (0) | 2025.05.15 |