19 분 소요

네트워크에 대하여

지난 강의들에서 다룬 ‘공장(CPU), 창고(Memory), 컨테이너(DataStructure)’는
로컬 환경에 대한 이해를 높였습니다.

이제 우리는 이 공장과 ‘친구의 공장’을
연결하는 정밀 물류 시스템(네트워크)의 복잡성을 파헤쳐 봅니다.

네트워크는 “내 메모리에 있는 0과 1을,
아주 긴 전선을 통해 저쪽 메모리로 복사하는 물류 배송 시스템”입니다.

하지만 이 배송 과정은 공장 내부(Bus)보다 수백만 배 느리고, 불안정하며, 예측 불가능합니다.

따라서 네트워크 프로그래밍의 핵심은 “이 불안정한 환경을 어떻게 안정적인 것처럼 속일 것인가?“에 있습니다.

1. 물류의 기초: 패킷의 물리적 실체와 한계

‘네트워크 통신의 가장 작은 단위’인 패킷(Packet)은 단순한 데이터 조각이 아닙니다.
이것은 목적지까지 가기 위해 겹겹이 포장된 복합적인 물류 상자입니다.

개발자가 보내는 데이터는 이 상자의 가장 깊숙한 곳에 숨겨져 있습니다.

🚛 1-1. 패킷 구조와 직렬화 (Serialization)

메모리에 있는 PlayerHealth(int) 값을 그대로 전선에 흘려보낼 수는 없습니다.
메모리는 0과 1의 전기적 상태일 뿐이기 때문입니다.

이를 전송 가능한 형태(비트 스트림)로 변환하는 과정을 직렬화(Serialization)라고 합니다.

언리얼 엔진에서는 FArchive 클래스가 이 역할을 담당하며,
객체를 바이트 배열로 압축하고 변환합니다.

  • 페이로드 (Payload)
    상자 안의 실제 내용물입니다.
    우리가 보내려는 게임 데이터(이동 좌표, 공격 명령 등)가 여기에 담깁니다.
  • 헤더 오버헤드 (Header Overhead)
    상자의 겉봉투입니다. 이 데이터가 어디로 가야 하는지(IP),
    어떤 순서인지(Sequence ID), 손상되지는 않았는지(Checksum)를 나타내는 제어 정보가 붙습니다.
    • 만약 여러분이 4바이트짜리 int 하나를 보내기 위해 TCP 패킷을 만든다면, 헤더만 수십 바이트가 붙습니다.
      배보다 배꼽이 더 큰 상황이죠. 이를 오버헤드라고 하며, 네트워크 최적화는 이 오버헤드를 줄이는 것에서 시작합니다.
  • MTU (Maximum Transmission Unit)
    한 번에 보낼 수 있는 최대 패킷 크기(표준 1500바이트)입니다.
    이 크기를 넘어가면 패킷이 강제로 쪼개지는 단편화(Fragmentation)가 발생하며,
    이는 처리 비용 증가와 패킷 손실 확률을 높이는 원인이 됩니다.
    • 예를 들어, 2000바이트 데이터를 보낸다면, IP 계층은 이를 1500 + 500으로 쪼갭니다.
      문제는 둘 중 하나만 손실되어도 전체 데이터를 버려야 한다는 점입니다.
      따라서 게임 패킷은 항상 MTU보다 작게 유지하는 것이 좋습니다.

네트워크에서 주고 받는 단위를 ‘패킷’
그 내용물이 ‘페이로드’ 이며
주고 받기 위해 필요한 정보가 ‘헤더 오버헤드’에 존재
(다만, 이러한 패킷은 MTU를 넘지 말아야 함)

⏱️ 1-2. 대역폭(Bandwidth)과 지연시간(Latency)

  • 대역폭 (도로의 폭): 1초에 전송할 수 있는 최대 데이터양입니다.
    • 게임 서버는 수천 개의 액터를 동시에 처리합니다. 만약 액터들의 정보량이 ‘대역폭 한계를 넘어서면’,
      라우터는 처리하지 못한 패킷을 큐(Queue)에 쌓아두다가 결국 ‘폐기’(Drop)합니다.
    • 이것이 패킷 손실(Packet Loss)의 주원인이며, 캐릭터가 순간 이동하는 랙을 유발합니다.
  • 지연 시간 (RTT - Round Trip Time): 데이터가 왕복하는 데 걸리는 시간입니다.
    • 물리적 거리와 라우터 처리 속도에 의해 결정되므로,
      ‘소프트웨어적으로 줄이는 데는 한계가 있습니다’(서울-부산 약 10ms, 서울-미국 약 150ms).
  • 지터 (Jitter): 지연 시간이 불규칙하게 널뛰는 현상을 지터라고 합니다.
    • 핑(Ping)이 50ms로 일정하다면, 클라이언트는 50ms 후의 미래를 예측해서 부드럽게 보여줄 수 있습니다.
      그러나 지터가 심하면 캐릭터가 뚝뚝 끊기거나 텔레포트하는 현상이 발생합니다.

대역폭은 ‘한번에 받는 거리’
지연시간은 ‘왔다갔다 하는 시간’
지터는 이러한 ‘지연시간’이 불규칙한 것

2. OSI 7 계층 모델 (배송 포장 시스템)

데이터가 출발하여 물리적인 전선(광케이블)을 타고 가는 과정은 7단계의 포장 및 라벨링 과정을 거칩니다.

상위 계층에서 하위 계층으로 내려갈 때마다 해당 계층의 헤더(Header)가 붙습니다.
반대로 받을 때는 헤더를 하나씩 까면서(Decapsulation) 올라갑니다.

게임 개발자는 주로 상위 계층(Application)을 다루지만,
문제가 터졌을 때 원인을 파악하려면 하위 계층, 특히 3계층(Network)과 4계층(Transport)을 이해해야 합니다.

계층 (Layer) 역할 (물류 비유) 게임 개발과의 연관성
7. 응용 계층 (Application) 사용자의 최종 요청 생성 RPC 함수 호출, 채팅 메시지, 게임 서버 접속 명령
6. 표현 계층 (Presentation) 데이터 포맷, 압축 언리얼 엔진의 데이터 압축/직렬화(Serialization) 방식 결정
5. 세션 계층 (Session) 대화 채널 생성/유지 클라이언트-서버 간의 게임 세션 연결 유지
4. 전송 계층 (Transport) 배송 방식 결정 TCP(신뢰성) vs UDP(속도) 선택 (가장 중요)
3. 네트워크 계층 (Network) 최종 목적지 라벨 IP 주소를 붙여서 경로 지정 (라우팅)
2. 데이터 링크 계층 (Data Link) 구간별 주소 (Mac) 같은 네트워크(LAN) 내에서 데이터 전달
1. 물리 계층 (Physical) 실제 전선/전파 0과 1을 전기 신호로 변환
  • Please Do Not Throw Sasauge Pizza Away
    각 앞글자를 외우기 쉽게 하는 것
  • TCP/IP 가 유명한 이유(3/4 계층)

3. IP (Internet Protocol, Layer 3)

3계층인 IP 계층은 거대한 인터넷 망에서
‘길 찾기(Routing)’와 ‘배달(Delivery)’을 담당합니다.

📍 3-1. IPv4 주소 체계와 NAT

우리가 흔히 보는 192.168.0.1 같은 주소는 IPv4 체계입니다.
(IPv4 의 주소 표현 한계로 인하여 IPv6가 등장함)

  • 공인 IP vs 사설 IP: 전 세계에서 유일한 공인 IP와, 공유기 내부에서만 쓰이는 사설 IP가 있습니다.
    게임 서버를 열 때 외부에서 접속이 안 되는 이유는, 내 컴퓨터가 공인 IP가 아닌 사설 IP(가짜 주소)를 달고 있기 때문입니다.
    • 공인 IP는 정말 유일한 IP
    • 사설 IP는 공유기 내부에서 구분하기 위한 IP
  • NAT (Network Address Translation): 공유기는 나가는 패킷의 사설 IP를 공인 IP로 바꿔줍니다.
    반대로 들어오는 패킷은 포트 포워딩(Port Forwarding) 설정을 통해 내 컴퓨터로 찾아와야 합니다.
    (이것도 일종의 추상화와 비슷하다)
    (특정한 규약에 따라 판별하기 쉽게 구분한 것)

🛤️ 3-2. 라우팅(Routing)과 TTL

우리가 보낸 게임 데이터는 서울에서 미국 서버까지 직통으로 가는 전용선이 없습니다.
수십 개의 라우터(Router)라는 중계소를 거쳐서 점프하듯 이동합니다.

  • IP 주소: 패킷의 최종 목적지 주소입니다. (예: 192.168.0.1)
    • 여러 라우팅을 거쳐서 도착
    • IP 프로토콜이 이를 가능하게 함
    • 더 빠른 경로를 찾아 보내기에 실시간으로 패킷을 다르게 보낼 수 있음
  • 라우팅 (Routing):
    • 서울에서 미국의 서버로 데이터를 보낼 때, 수많은 중계소(라우터)를 거쳐야 합니다.
      IP는 이 복잡한 인터넷 망에서 “다음 목적지”를 찾아가는 길잡이 역할을 합니다.
    • 각 라우터는 자신의 인접한 라우터들의 상태를 보고 “이쪽으로 보내는 게 빠르겠다”라고 판단하여 패킷을 던집니다.
    • 이 경로는 실시간으로 바뀔 수 있습니다. 1번 패킷은 A 경로로, 2번 패킷은 B 경로로 갈 수 있다는 뜻이며,
      이로 인해 패킷 순서 뒤바뀜(Out-of-Order) 현상이 발생합니다.
    • 그렇기에 도착 후, 이러한 패킷 순서를 재조립 하는 과정이 필요하며,
      이를 통해 패킷 로스를 최종적으로 확인
  • TTL (Time To Live):
    • IP 헤더에는 TTL이라는 값이 있습니다. 라우터를 하나 지날 때마다 1씩 감소합니다.
    • 만약 네트워크 설정 오류로 패킷이 목적지를 못 찾고 뺑뺑이를 돌면(루프), TTL이 0이 되는 순간 패킷은 즉시 삭제(Drop)됩니다.
    • 게임에서 갑자기 패킷 로스가 발생한다면, 특정 구간의 라우터 문제로 TTL이 만료되었을 가능성도 있습니다.
    • 패킷 드랍을 동해, 너무 과하게 살아있는 경우를 방지

⚠️ 3-3. IP의 철학: 비신뢰성 (Unreliable - Best Effort)

이 부분이 가장 중요합니다.
IP는 “최선을 다해 보겠지만, 결과는 책임지지 않는다(Best Effort)”는 무책임한 철학을 가집니다.

  1. 비연결성 (Connectionless):
    1. 편지를 보낼 때 상대방이 집에 있는지 확인하지 않고 우체통에 넣는 것과 같습니다.
    2. 받는 사람이 받을 준비가 안 되어 있어도(서버 다운 등) IP는 신경 쓰지 않고 그냥 보냅니다.
    3. 연결 설정(Handshake) 과정이 없어 ‘빠르지만, 상태 관리가 안 됩’니다.
  2. 비신뢰성 (Unreliable):
    1. 패킷이 가다가 라우터 불량으로 소멸하거나, 네트워크 폭주로 버려져도 IP 계층은 이를 복구하거나 송신자에게 알리지 않습니다.
    2. 또한 패킷 순서가 뒤죽박죽 섞여서 도착해도 이를 재정렬해주지 않습니다. 오직 “전달” 행위 자체에만 집중합니다.
  3. 역할의 한계 (Host-to-Host):
    1. IP는 데이터를 ‘목적지 컴퓨터(Host)’ 대문 앞까지만 가져다줍니다. 그 컴퓨터 안에서 실행 중인 수많은 프로그램 중 ‘리그 오브 레전드’가 받아야 할지, ‘디스코드’가 받아야 할지는 IP가 알 수 없습니다.
    2. ‘최종 수령인(프로세스)’을 구분하는 역할은 4계층(전송 계층)의 포트 번호가 담당하게 됩니다. 즉, IP의 한계 때문에 TCP/UDP가 필연적으로 등장하게 된 것입니다.

4. TCP (Transmission Control Protoco, Layer 4)

TCP는 인터넷의 신뢰성을 지탱하는 기둥입니다.
파일 전송, 이메일, 웹 브라우징은 데이터가 단 1비트라도 깨지면 안 되기 때문에 TCP를 필수로 사용합니다.

하지만 0.01초를 다투는 ‘실시간 게임’(FPS, MOBA)에서 TCP는 ‘지나치게 친절하고 무거운’ 프로토콜입니다.

📦 4-1. 연결 지향(Connection-Oriented)과 3-Way Handshake

TCP는 데이터를 보내기 전에 반드시 송신자와 수신자 사이에
‘논리적인 전용 도로(Session)’를 뚫어야 합니다. 이를 3-Way Handshake라고 합니다.

  1. SYN: 클라이언트가 “똑똑, 데이터 보내도 될까요?”라고 문을 두드립니다.
  2. SYN-ACK: 서버가 “네, 준비됐습니다. 저도 보낼게요.”라고 답합니다.
  3. ACK: 클라이언트가 “확인했습니다. 이제 진짜 보냅니다.”라고 확정합니다.

이 과정은 실제 데이터를 단 1바이트도 보내기 전에 무조건 수행되어야 합니다.

즉, 통신 시작부터 최소 왕복 시간의 지연을 먹고 들어갑니다.
모바일 환경처럼 연결이 자주 끊기는 경우,
유저는 게임 내내 “재접속 중…“이라는 핸드쉐이크 과정만 구경하게 될 수도 있습니다.

🛡️ 4-2. 신뢰성 보장 메커니즘 (Sequence & ACK)

TCP가 데이터를 절대 잃어버리지 않는 비결입니다.

  • 순서 번호 (Sequence Number): TCP는 보낼 데이터를 바이트 단위로 쪼개어 일련번호를 매깁니다. (예: 1번~1000번 바이트)

  • 확인 응답 (ACK): 수신자는 데이터를 받으면 “1000번까지 잘 받았으니, 다음엔 1001번 보내줘”라는 ACK 패킷을 보냅니다.

  • 재전송 (Retransmission): 송신자가 데이터를 보냈는데 일정 시간(RTO, Retransmission Time-Out) 내에 ACK를 받지 못하면,
    패킷이 중간에 사라진 것으로 간주하고 알아서 다시 보냅니다. 개발자가 신경 쓸 필요 없이 데이터의 완벽한 전달을 보장합니다.

🚦 4-3. 흐름 제어와 혼잡 제어 (네트워크 보호)

TCP는 ‘나’뿐만 아니라 ‘네트워크 전체’를 생각하는 신사적인 프로토콜입니다.

  • 흐름 제어 (Flow Control): 수신자(Receiver)의 처리 속도가 느려서 버퍼가 꽉 차면, 송신자에게 “천천히 보내”라고 신호를 보냅니다.

  • 혼잡 제어 (Congestion Control): 네트워크 경로 상의 라우터가 혼잡하여 패킷 손실이 감지되면,
    전송 속도를 확 낮췄다가 서서히 올립니다. 이는 네트워크 붕괴를 막지만, 게임의 반응 속도를 급격히 떨어뜨리는 원인이 됩니다.

⛔ 4-4. 게임에서의 치명적 단점: Head-of-Line Blocking

실시간 게임에서 TCP를 기피하는 가장 결정적인 이유입니다.
TCP는 “보낸 순서대로 받는다”는 것을 절대 원칙으로 합니다.

  • 상황: 1번 패킷(이동), 2번 패킷(공격), 3번 패킷(채팅)을 순서대로 보냈습니다.
  • 사고: 1번 패킷이 손실되고, 2번과 3번은 무사히 도착했습니다.
  • TCP의 행동: “1번이 아직 안 왔네? 2번, 3번은 운영체제 버퍼에 대기시켜! 어플리케이션(게임)에는 절대 주지 마.”

그 결과 1번을 재전송받을 때까지(수백 ms 소요),
게임 로직은 멈춰버립니다. 이미 도착한 최신 데이터(2, 3번)를 사용할 수 있는데도 불구하고,
순서를 맞추느라 기다려야 합니다.

이를 선두 패킷 지연(HOL Blocking)이라고 하며,
FPS 같은 장르에서는 절대 용납할 수 없는 랙을 유발합니다.

  • TCP는 ‘확신’을 가지게 하기 위한 전송
  • 신뢰적이나, 오버헤드가 존재할 수 있음

5. UDP (User Datagram Protocol, Layer 4)

UDP는 TCP의 모든 안전장치(연결, 순서, 신뢰성, 흐름 제어)를
걷어내고 오직 ‘전송’ 그 자체에만 집중한 프로토콜입니다.

🚁 5-1. Fire and Forget

  • 비연결형 (Connectionless): 핸드쉐이크가 없습니다. 상대방 IP와 포트만 알면,
    상대가 받을 준비가 됐든 말든 그냥 던집니다. 초기 지연이 ‘0’입니다.

  • 헤더의 경량화:
    • TCP 헤더: 최소 20바이트 (순서, ACK, 윈도우 크기 등 복잡함)
    • UDP 헤더: 고작 8바이트 (출발 포트, 도착 포트, 길이, 체크섬)
  • 대역폭 이득: 패킷 하나당 12바이트 이상의 이득을 봅니다.
    초당 수천 개의 패킷이 오가는 게임 서버에서 이 차이는 엄청난 동시 접속자 수의 차이로 이어집니다.

던지고 잊어버린다는 뜻

🚀 5-2. No Blocking: 최신 정보 우선주의

UDP는 패킷 순서가 뒤바뀌거나 중간에 사라져도 신경 쓰지 않습니다. 도착한 패킷은 검사 없이 즉시 게임 로직에 반영됩니다.

  • 상황: 0.1초 전의 내 위치(1번 패킷)가 소실되었습니다.

  • UDP의 행동: “상관없어. 어차피 0.1초 뒤의 최신 위치(2번 패킷)가 도착했잖아? 이걸로 갱신해.”

  • 철학: 실시간 게임에서 “과거의 정보는 쓰레기”입니다.
    중요한 건 지금(Present)입니다. TCP처럼 과거를 복구하느라 현재를 희생하지 않는 것이 UDP의 핵심입니다.

🛠️ 5-3. 개발자의 책임: 신뢰성의 직접 구현 (Reliable UDP)

UDP는 너무나도 자유분방해서, “아이템 획득”이나 “레벨업”처럼
절대 잃어버리면 안 되는 데이터조차 그냥 버릴 수 있습니다.

  • 해결책: 그래서 게임 개발자는 UDP를 쓰되, 애플리케이션 계층(Layer 7)에서 직접 신뢰성 로직(Reliability)을 구현해야 합니다.
    • “이 패킷은 중요하니까 내가 직접 헤더에 순서 번호 적어서 보내고, 상대방이 받았는지 체크(ACK)할 거야.”
    • 즉, UDP의 빠른 속도 + TCP의 신뢰성 중 필요한 부분만 섞어서 쓰는 하이브리드 프로토콜을 만들게 됩니다.

기본적으로 Unreal 내부에서 지원하는 것을 가져다 쓰면 됨!

6. 언리얼 엔진 네트워크 아키텍처: Reliable UDP와 채널

그렇다면 언리얼 엔진은 어떤 전송 계층을 사용할까요?
정답은 “기본적으로 UDP를 사용하지만, 엔진 레벨에서 신뢰성을 보장하는 자체 프로토콜(Reliable UDP)을 구현했다”입니다.

🏛️ 6-1. Reliable UDP (RUDP)의 작동 원리

언리얼의 네트워크 코어인 UNetDriver는 데이터의 중요도에 따라 처리 방식을 이원화합니다.

  • Unreliable (순수 UDP): 이동 좌표, 로테이션 등.
    • 패킷 손실 시 재전송하지 않습니다. 다음 프레임에 더 최신 데이터가 올 것이기 때문입니다. 재전송하는 순간 이미 낡은 정보가 되어버립니다.
  • Reliable (TCP 흉내): ServerRPC, 액터 스폰, 게임 스테이트 변경 등.
    • 엔진이 패킷 헤더에 자체적인 순서 번호(Packet Sequence)를 붙입니다.
    • 수신 측에서 ACK(확인)를 안 보내면, 엔진은 다음 프레임에 해당 데이터를 다시 포장해서 보냅니다.
    • 장점: TCP처럼 OS 차원에서 모든 패킷이 막히는 것이 아니라, 해당 액터(Actor)나 채널(Channel)만 관리하므로 전체 ‘게임 멈춤 현상을 최소화’할 수 있습니다.

📺 6-2. 채널(Channel) 시스템: 논리적 분리

언리얼은 하나의 UDP 소켓(Connection)을 통해 데이터를 받지만,
내부적으로는 데이터를 채널(Channel)이라는 가상의 파이프로 분류하여 처리합니다.

  • Control Channel: 로그인, 맵 로딩, 연결 끊기 등 가장 중요한 시스템 신호를 주고받습니다.

  • Actor Channel: 게임 내의 각 액터마다 별도의 채널을 가집니다.
    • 1번 채널: 플레이어 캐릭터 (이동, 공격)
    • 2번 채널: 무기 (탄약 수, 재장전)
  • 의미: 만약 ‘무기’ 정보를 담은 Reliable 패킷이 손실되어 재전송을 기다려야 한다면?
    • TCP였다면 순서를 맞추느라 ‘캐릭터 이동’까지 멈췄을 것입니다.
    • 언리얼은 ‘무기 채널’만 잠깐 대기하고, ‘캐릭터 채널’은 정상적으로 최신 데이터를 처리합니다. 이것이 언리얼 네트워크가 랙에 강한 기술적 비결입니다.

📦 6-3. 패킷 번들링 (Bundling)

네트워크 효율을 위해 언리얼은 데이터를 낱개로 보내지 않고 꽉꽉 채워서 보냅니다.

  1. 여러 채널(캐릭터 이동, 총 발사, 채팅)의 데이터를 모읍니다.
  2. 이를 하나의 거대한 UDP 패킷(Bunch)으로 묶습니다.
  3. MTU 사이즈(약 1000~1200바이트)가 될 때까지 채우거나, 프레임이 끝날 때 한 번에 Socket->SendTo()를 호출합니다.
    • 이를 통해 IP/UDP 헤더 오버헤드를 최소화하고, CPU의 시스템 콜(System Call) 호출 횟수를 줄여 서버 성능을 최적화합니다.

면접 준비

🚧 동기화(Synchronization): 공장의 질서와 교통정리

우리는 지난 강의에서 공장의 생산성을 높이기 위해 ‘일꾼(스레드)을 여러 명 고용하는 법(멀티 코어)’을 배웠습니다.

“일꾼이 1명이면 100개를 만들지만, 10명이면 1000개를 만들겠지?”라는 기대와 함께 말이죠.

하지만 현실은 잔혹합니다. 일꾼들이 하나의 작업대(공유 데이터)를 놓고 서로 쓰겠다고 싸우기 시작하면,
생산품은 엉망이 되고 공장은 멈춰버립니다. 이것이 바로 동기화(Synchronization) 문제입니다.

이번 시간에는 이 쟁탈전에서 발생하는 대참사(Race Condition)와 이를 해결하는 교통정리(Mutex),
그리고 교통정리를 잘못해서 발생하는 교통마비(Deadlock)까지 완벽하게 파헤쳐 봅니다.

1. 문제의 시작: 경쟁 상태 (Race Condition)

💥 1-1. “사라진 나사” 사건

공장 창고에 나사(ScrewCount)가 100개 있습니다.
일꾼 A와 일꾼 B에게 동시에 “나사 1개씩 가져와(ScrewCount--)”라고 지시했습니다.

정상적이라면 나사는 98개가 되어야 합니다.
하지만 결과가 99개가 되는 기이한 현상이 발생합니다.

도대체 무엇이 문제일까요?
이를 이해하려면 CPU의 작업 방식을 뜯어봐야 합니다.

🧠 화이트보드와 개인 수첩

  • 메모리 (RAM): 공장 벽에 걸린 ‘공용 화이트보드’입니다. (ScrewCount 원본: 100)
  • 레지스터 (Register): 일꾼이 각자 주머니에 넣고 다니는 ‘개인 수첩’입니다. (계산은 오직 수첩에서만 가능)

일꾼은 화이트보드에 직접 숫자를 계산해서 쓸 수 없습니다.
반드시 수첩에 적어와서(Load), 계산한 뒤(Sub), 그 결과를 화이트보드에 덮어써야(Store) 합니다.

시간 일꾼 A (스레드 1) 일꾼 B (스레드 2) 화이트보드 (메모리) 설명
T1 LOAD (읽기) 대기 100 A가 화이트보드를 보고 수첩에 100을 적습니다.
T2 SUB (계산) 대기 100 A가 수첩에서 1을 뺍니다. (A 수첩: 99)
T3 ⛔ 멈춤 ▶️ 시작 100 [컨텍스트 스위칭] 운영체제가 A를 강제로 쉬게 하고 B를 투입합니다.
중요: A는 아직 화이트보드를 99로 고치지 못했습니다!        
T4 대기 LOAD (읽기) 100 B가 화이트보드를 봅니다. 여전히 100입니다.
B도 수첩에 100을 적습니다.        
T5 대기 SUB (계산) 100 B가 수첩에서 1을 뺍니다. (B 수첩: 99)
T6 대기 STORE (저장) 99 B가 수첩의 값 99를 화이트보드에 씁니다.
(현재 상태: 나사 1개 감소 성공)        
T7 ▶️ 복귀 종료 99 A가 다시 일어납니다. A의 수첩에는 아까 계산한 99가 그대로 있습니다.
T8 STORE (저장) 종료 99 [최악의 상황] A는 B가 고쳐놓은 99를 무시하고,
자기 수첩에 있는 99를 화이트보드에 덮어써 버립니다.        

일꾼 B는 분명히 일을 했지만,
A가 늦게 덮어쓰는 바람에 B의 작업 내역은 증발(Lost Update)했습니다. 이것이 경쟁 상태(Race Condition)입니다.

// MyFactoryActor.h
int32 ScrewCount = 10000; // 공유 자원 (나사 1만 개)

// MyFactoryActor.cpp
void AMyFactoryActor::BeginPlay()
{
    Super::BeginPlay();

    // 일꾼(스레드) A: 5000번 뺌
    Async(EAsyncExecution::Thread, [this]()
    {
        for (int32 i = 0; i < 5000; i++)
        {
            // [문제의 지점] 
            // 읽기(LOAD) -> 수정(SUB) -> 쓰기(STORE) 
            // 이 3단계 사이에 언제든 스위칭이 일어날 수 있습니다.
            ScrewCount--; 
        }
    });

    // 일꾼(스레드) B: 5000번 뺌
    Async(EAsyncExecution::Thread, [this]()
    {
        for (int32 i = 0; i < 5000; i++)
        {
            ScrewCount--; // 보호받지 못한 위험한 연산
        }
    });

    // [결과]
    // 로그를 찍어보면 0이 나오지 않고 매번 다른 값(예: 152, 890)이 남습니다.
}

2. 해결책 1: 뮤텍스 (Mutex)

가장 직관적이고 강력한 해결책은 “한 번에 한 놈만 들어오게 하는 것”입니다.
이를 상호 배제(Mutual Exclusion), 줄여서 뮤텍스(Mutex)라고 부릅니다.

🔑 2-1. 작동 원리: 자물쇠와 임계 영역

화이트 보드를 자물쇠가 달려 있는 공간에 넣어두고, 사용할 때마다 열쇠를 획득하여 자물쇠를 사용하는 방식입니다.

  1. Lock (잠금): 일꾼 A가 ScrewCount(화이트 보드)를 쓰기 위해 열쇠를 획득하고 자물쇠를 잠급니다.
  2. Wait (대기): 뒤늦게 온 일꾼 B는 열쇠가 없어서 못 들어갑니다. A가 나올 때까지 문 밖에서 대기(Blocking)합니다. 이때 운영체제는 B를 수면 상태(Sleep)로 만들어 CPU 자원을 아낍니다.
  3. Unlock (해제): A가 용무를 마치고 열쇠를 반납합니다. 운영체제는 자고 있던 B를 깨워 열쇠를 쥐여주고 들여보냅니다.

이처럼 한 번에 하나의 스레드만 실행되도록 보호받는 코드 영역,
자물쇠가 달려 있는 공간을 임계 영역(Critical Section)이라고 합니다.

언리얼 엔진에서는 FCriticalSection을 사용하여 자물쇠를 만듭니다.

// 헤더에 선언
int32 ScrewCount = 10000;
FCriticalSection ScrewMutex; // 1. 나사 창고 열쇠(Mutex) 생성

// 소스 코드
void AMyFactoryActor::DecreaseScrewSafe()
{
    Async(EAsyncExecution::Thread, [this]()
    {
        for (int32 i = 0; i < 5000; i++)
        {
            // [스마트한 잠금 (RAII 패턴)]
            {
                // 이 줄에서 Lock이 걸리고, 중괄호 {}를 빠져나가면 자동으로 Unlock 됩니다.
                // B 스레드는 A가 이 블록을 나갈 때까지 여기서 멈춰 있습니다.
                FScopeLock Lock(&ScrewMutex); 
                
                // --- 임계 영역 (Critical Section) ---
                // 이 안에서는 오직 나 혼자만 실행됨을 보장받습니다.
                // LOAD -> SUB -> STORE가 끊기지 않고 한 번에 실행됩니다.
                ScrewCount--; 
                // -----------------------------------
            } // 여기서 자동으로 Unlock!
        }
    });
}

Lock()Unlock()을 수동으로 쓰다가 실수로 Unlock을 안 하면 영원히 잠기는 사고가 납니다.
FScopeLock은 범위를 벗어나면 자동으로 잠금을 해제해 주므로 안전합니다.

3. 해결책 2: 세마포어 (Semaphore) - “수용 인원 제한”

뮤텍스가 ‘열쇠(1개)’라면, 세마포어는 ‘빈자리 카운터(N개)’입니다. 뮤텍스의 확장된 개념입니다.

🚦 3-1. 작동 원리: 신호등과 티켓

세마포어는 정수형 변수(Count)를 가지고 있습니다. (예: Count = 3)

  1. Wait (P연산): 일꾼이 들어올 때 숫자를 1 줄입니다. (3 → 2)
  2. 진입: 숫자가 0보다 크면 통과합니다.
  3. Block: 만약 숫자가 0이라면(자리가 꽉 참), 누군가 나갈 때까지 대기합니다.
  4. Signal (V연산): 일꾼이 나갈 때 숫자를 1 늘립니다. (0 → 1) 대기하던 인원이 들어옵니다.
  • Binary Semaphore (Count=1): 뮤텍스와 거의 똑같이 동작합니다. (잠금/해제)
  • Counting Semaphore (Count=N): 동시에 N명까지 작업하게 할 때 씁니다. (예: 데이터베이스 접속 쿼리를 동시에 5개까지만 허용)

⚔️ 3-2. Mutex vs Semaphore 결정적인 차이

기술 면접 단골 질문입니다. 핵심은 ‘소유권(Ownership)’입니다.

  • Mutex (소유권 있음): 자물쇠 문을 잠근 사람(A)만이 자물쇠를 열 수 있습니다. “결자해지”의 원칙입니다. 다른 스레드가 ‘억지로 열 수 없습니다’.
  • Semaphore (소유권 없음): 소유권 개념이 없습니다. A가 들어갔는데(Wait), 밖에서 B가 강제로 카운터를 올려줄 수도 있습니다(Signal).
    • 용도: 주로 “A 작업이 끝나면 B 작업을 시작해”와 같이 스레드 간 순서 제어(Signaling) 용도로 많이 쓰입니다.

세마포어는 “여러 개가 함께 동작하는데 어떻게 동시성 문제를 해결한다는 거야?”라는 의문이 드실 텐데요,
결론부터 말씀드리면 사용하는 목적과 대상이 다르기 때문입니다.

이해하기 쉽게 ‘ATM 기계’로 비유해서 설명해 드릴게요.

자원이 1개 : Mutex
자원이 N개 : Semaphore(N)

1. 상황: “돈다발(공유 자원)”이 하나일 때 (Mutex 필요)

  • 상황: 은행에 현금 1억 원이 든 ‘금고’가 딱 하나 있습니다.
  • 문제: 만약 세마포어(Count=3)를 써서 직원 3명을 동시에 금고 방에 들여보내면?
    • 직원 A, B, C가 동시에 돈다발을 집으려다가 엉키고 장부가 꼬입니다. (동시성 이슈 발생!)
  • 해결: 이때는 무조건 Count가 1인 세마포어(== 뮤텍스)를 써서 한 명만 들어가게 해야 합니다.

즉, “하나의 변수”를 보호할 때는 세마포어(N)를 쓰면 안 됩니다.

2. 상황: “ATM 기계”가 3대일 때 (Semaphore 사용)

  • 상황: 은행 로비에 ATM 기계가 3대 있습니다. (자원이 3개)
  • 손님: 10명이 줄을 서 있습니다.
  • 세마포어(3): “자, 빈자리가 3개니 3명까지는 동시에 들어오세요!

세마포어(Count=N)는 이럴 때 쓰는 겁니다.

  1. 손님 A1번 ATM 사용
  2. 손님 B2번 ATM 사용
  3. 손님 C3번 ATM 사용

세 명이 동시에 작업을 하고 있지만, 그들이 건드리는 대상은 서로 다른 ATM(자원)입니다.
서로의 돈을 건드리지 않으니 안전합니다.

만약 4번째 손님이 들어오려고 하면? 세마포어가 막습니다. “기계 다 찼어. 기다려.”

세마포어(Counting Semaphore)는 “변수 값 보호”와 더불어 “정원(Capacity) 관리”에 더욱 목적이 있습니다.

  • 따라서 하나의 변수(int A)를 여러 스레드가 건드려야 한다? → 무조건 1명만 입장 (Mutex 사용)
  • 독립된 자원(ATM 기계)이 여러 개 있고, 인원수를 제한하고 싶다? → N명 입장 (Semaphore 사용)
// DB 연결 통로가 10개 있음 (자원 풀)
DBConnection ConnectionPool[10]; 

// 세마포어: 정원 10명
FSemaphore DBSemaphore(10); 

void SaveUserData()
{
    // 1. 입장권 뽑기 (자리가 없으면 대기)
    DBSemaphore.Wait(); 

    // 2. --- 임계 영역 (최대 10명 동시 진입) ---
    
    // [핵심] 10명이 들어왔지만, 각자 '서로 다른' 연결을 씁니다.
    int MyIndex = GetUnusedConnectionIndex(); 
    ConnectionPool[MyIndex].Execute("UPDATE ..."); 
    
    // ----------------------------------------

    // 3. 퇴장 (반납)
    DBSemaphore.Signal();
}

4. 부작용: 교착 상태 (Deadlock) - “최악의 교통 마비”

뮤텍스(자물쇠)는 데이터를 보호하는 훌륭한 도구지만,
잘못 사용하면 ‘아무도 작업을 못 하고 서로 쳐다만 보며 영원히 멈춰버리는’ 끔찍한 상황이 발생합니다.
이를 데드락(Deadlock)이라고 합니다.

🕸️ 4-1. 상황: 두 개의 자물쇠의 비극

데드락은 보통 자물쇠가 2개 이상일 때 발생합니다.
일꾼 A와 B가 작업을 하려면 ‘망치’‘드라이버’ 두 개가 모두 필요하다고 가정해 봅시다.

  1. 일꾼 A: 망치를 먼저 집어 들었습니다. (망치 Lock)
  2. 일꾼 B: 드라이버를 먼저 집어 들었습니다. (드라이버 Lock)
  3. 일꾼 A: “어? 드라이버가 없네? B가 다 쓸 때까지 기다려야지.” (망치 든 채 대기)
  4. 일꾼 B: “어? 망치가 없네? A가 다 쓸 때까지 기다려야지.” (드라이버 든 채 대기)

둘은 서로 상대방이 가진 도구를 내놓기만을 기다리며 영원히(Infinity) 대기합니다.
프로세스를 강제 종료해야 하는 상황입니다.

  • 원인
    • 비선점 : 서로가 가진 자원을 못 뺏음
    • 순환참조 : 서로 필요한 것을 들고 있음
    • 상호 배제 : 망치와 드라이버가 1개 씩 있어야 작업 가능
    • 점유 와 대기 : 서로 들고 내려놓을 생각이 없음
FCriticalSection HammerLock;
FCriticalSection DriverLock;

// 일꾼 A의 로직
void WorkerA()
{
    HammerLock.Lock();             // 1. 망치 획득
    FPlatformProcess::Sleep(0.1f); // (B가 드라이버 집을 시간 벌어줌)
    
    // A는 여기서 멈춥니다. B가 DriverLock을 쥐고 안 놔주니까요.
    DriverLock.Lock();             
    
    // ...작업 (영원히 도달 못함)...
    
    DriverLock.Unlock();
    HammerLock.Unlock();
}

// 일꾼 B의 로직
void WorkerB()
{
    DriverLock.Lock();             // 1. 드라이버 획득 (A와 순서가 반대!)
    FPlatformProcess::Sleep(0.1f);
    
    // B는 여기서 멈춥니다. A가 HammerLock을 쥐고 안 놔주니까요.
    HammerLock.Lock();             
    
    // ...작업 (영원히 도달 못함)...
    
    HammerLock.Unlock();
    DriverLock.Unlock();
}

🛡️ 4-2. 해결책: 순서 정하기 (Lock Ordering)

데드락을 막는 가장 표준적이고 확실한 방법입니다.
공장의 모든 일꾼에게 “도구를 집는 순서”를 강제하는 것입니다.
위의 예시에서는 “모든 일꾼은 무조건 망치를 먼저 집고,
그다음에 드라이버를 집어야 한다”는 규칙을 정합니다.

[시뮬레이션]

  1. 일꾼 A: 망치 획득 성공.
  2. 일꾼 B: 규칙에 따라 망치를 집으려고 시도→ 이미 A가 가져감 → 대기. (중요: 아직 드라이버를 집지 않은 상태로 대기합니다!)
  3. 일꾼 A: 드라이버 획득 시도 → (B가 드라이버를 안 가지고 있으므로) 성공!
  4. 일꾼 A: 작업 완료 후 망치, 드라이버 반납.
  5. 일꾼 B: 이제야 망치 획득 → 드라이버 획득 → 작업 시작.

서로 꼬리를 무는 원형 고리가 끊어지면서 작업이 순차적으로 진행됩니다.

// 일꾼 A와 B 모두 '똑같은 순서'로 락을 겁니다.
void GoodWorker()
{
    // 1. 항상 망치부터!
    HammerLock.Lock();
    
    {
        // 2. 그 다음 드라이버!
        DriverLock.Lock();
        
        // ...안전하게 작업 수행...
        
        DriverLock.Unlock();
    }
    
    HammerLock.Unlock();
}

🚦 4-3. 해결책 2: TryLock과 양보 (Backoff)

만약 구조상 순서를 정하기가 너무 복잡하다면,
“찔러보고 안 되면 포기하기” 전략을 씁니다.

🛠️ 해결 원리

  • Lock(): 열쇠를 줄 때까지 무한정 기다립니다. (데드락 위험)
  • TryLock(): “열쇠 있어?”라고 물어보고, 없으면 false를 리턴하고 즉시 돌아옵니다. (대기 안 함)

[시나리오]

A가 망치를 들고 드라이버를 집으려는데(TryLock), B가 가지고 있습니다.

이때 A는 멍하니 기다리는 게 아니라, “아, 이러다 사고 나겠네. 내가 가진 망치를 일단 내려놓자(Unlock)” 하고 물러섭니다. 이를 Backoff(후퇴)라고 합니다.

void SmartWorker()
{
    HammerLock.Lock(); // 망치 획득

    if (DriverLock.TryLock()) // 드라이버 있나요?
    {
        // 성공: 작업 수행
        DriverLock.Unlock();
        HammerLock.Unlock();
    }
    else
    {
        // 실패: 드라이버가 없으면 망치도 내려놓고 잠시 후퇴!
        HammerLock.Unlock();
        
        // 잠시 대기 후 재시도 (Random Sleep 권장)
        FPlatformProcess::Sleep(0.1f); 
    }
}

⚛️ 4-4. 해결책 3: 아토믹 (TAtomic) - “락을 쓰지 않는다”

가장 근본적인 해결책은 “자물쇠 자체를 없애는 것”입니다.
뮤텍스는 안전하지만 느립니다. 단순히 숫자 하나 바꾸는 작업에 뮤텍스를 쓰는 건 과한 처사입니다.

🛠️ 해결 원리: 하드웨어 레벨의 보호

CPU는 하드웨어 차원에서 “이 변수(ScrewCount) 건드릴 때 절대 끊지 마!”라고 명령하는 기능을 제공합니다.
이를 원자적 연산(Atomic Operation)이라고 합니다.

  • Lock-Free: 운영체제의 스케줄러가 관여하지 않고, CPU가 메모리 버스를 잠시 통제하여 LOAD-SUB-STORE를 한 번에 처리해버립니다. 뮤텍스보다 수십 배 빠릅니다.
// 헤더 파일
// 일반 int32 대신 언리얼의 TAtomic 템플릿 사용
TAtomic<int32> AtomicScrewCount = 10000;

// 소스 코드
void AMyFactoryActor::DecreaseScrewAtomic()
{
    Async(EAsyncExecution::Thread, [this]()
    {
        for (int32 i = 0; i < 5000; i++)
        {
            // 별도의 Lock/Unlock 없이도 안전합니다!
            // ++, --, Exchange 같은 단순 연산은 아토믹이 최고입니다.
            AtomicScrewCount--; 
        }
    });
}

아토믹은 단순 변수 연산에만 사용할 수 있습니다.
if (Money > 100) { Money -= 100; GiveItem(); } 처럼
‘여러 줄의 로직이 묶여서 보호’받아야 한다면, 여전히 뮤텍스를 써야 합니다.

5. 언리얼 엔진에서의 해결책 (Lock Free)

언리얼 엔진은 데드락의 위험이 있는 Mutex 직접 사용을 권장하지 않습니다.
대신 아예 락을 쓰지 않는 구조를 제안합니다.

🚀 4-1. 게임 스레드 몰아주기 (Game Thread Only)

대부분의 게임 로직(액터 이동, 총 발사 등)은 오직 Game Thread(메인 스레드) 하나에서만 실행되도록 강제합니다.

  • 일꾼이 한 명이니 싸울 일(Race Condition)도 없고, 서로 기다릴 일(Deadlock)도 없습니다.
  • 멀티스레드가 필요한 무거운 작업(물리, 로딩)만 별도 스레드로 뺍니다.

핵심 작업용 스레드가 ‘나뉜 이유’

📨 4-2. 메시지 패싱 (Message Passing)

데이터를 공유하지 말고, 복사해서 던져줍니다.

  • 기존: A 스레드와 B 스레드가 Score 변수를 같이 씀. (Lock 필요)
  • 개선: A 스레드가 계산한 결과를 큐(Queue)에 넣어서 B에게 보냄. B는 큐에서 꺼내서 자기 할 일 함.
  • 서로 만날 일이 없으니 데드락도 없습니다. 언리얼의 Task Graph 시스템이 이 원리로 돌아갑니다.

Queue는 선입선출이기에,
작업 순서가 보장됨

  • S-1 명령이 2번 동시에 호출되더라도
    Queue에서 하나씩 꺼내면서 적용하면
    해당 문제를 해결 할 수 있음

데드락 탈출 가이드

  1. 순서 정하기 (Lock Ordering): 락을 여러 개 써야 한다면 무조건 정해진 순서(A → B)대로만 잠그세요. (가장 추천)
  2. 하나만 쓰기: 가능하면 한 번에 하나의 락만 잡으세요. 락을 잡은 상태에서 다른 락을 또 잡는 행위(Nested Lock)데드락의 주범입니다.
  3. TryLock: 정 안 되면 TryLock으로 찔러보고, 안 되면 가진 걸 내려놓고 후퇴하세요.
  4. Atomic 연산: ‘단순한 계산은 Atomic’하게 이루어질 수 있도록 하세요.
  5. 구조 변경: ‘락이 너무 복잡하게 얽힌다면, 설계가 잘못’된 것입니다. 메시지 큐 방식으로 구조를 바꾸세요.

댓글남기기