Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(69일차) - 네트워크 이론 및 멀티 쓰레드

by 독기품은토끼 2025. 8. 26.
✅ 오늘의 학습 목표
1. Network 이론 학습
- 클라이언트 vs 서버
- 프로토콜
- OSI 7계층
2. Multi Thread 학습

 

1. Network 이론

  • 서버 : 클라이언트의 요청을 받아 요청한 데이터를 반환해주는 역할
  • 클라이언트 :  서버에게 데이터를 요청하고 그 결과를 받아 작업하는 역할

 

1. 서버

전용 서버 / 리슨 서버 / P2P 서버

P2P 같은 경우는 클라이언트와 서버 역할을 모두 하는 것

 

1.1. Web Server

  • 질의 응답 형태
  • 서버에서 클라이언트에 먼저 접근할 일 없음
  • Web Server는 단순히 게임에 국한되지 않고, 웹 서비스를 만드는데도 사용
  • Stateless
  • 구글, 아마존, 네이버 등
  • ASP.NET (C#) / Spring (Java) / NodeJS (Javascript) / Django, Flask (Python) / PHP

1.2. Game Server

  • 실시간 상호작용
  • 요청 / 갱신 횟수가 많음
  • 언제든 접근 가능
  • Stateful

 

2. 프로토콜(Protocol)

네트워크 통신에서 미리 정해놓은 규약(약속)

ex. TCP, UDP, ARP, RARP, ICMP 등

 

2.1. TCP

  • 상대방과 먼저 연결되었는지 확인하고(수신 여부 확인),
  • 보낸 데이터가 순서대로 잘 도착했는지 일일이 검사하며 대화하는 방식
  • 신뢰성이 매우 높지만, 과정이 복잡해 속도는 상대적으로 느리다.
  • TCP 활용 프로토콜 → HTTP, HTTPS, FTP, SFTP, SMTP, POP3, 데이터베이스 연결

2.2. UDP

  • 상대방이 받을 수 있는지 확인하지 않고 그냥 데이터를 보내는 방식
  • 속도가 매우 빠르지만, 신뢰성은 낮다.
  • DNS 서버: IP 주소를 물어보는 단순한 요청을 빠르게 처리하기 위해 사용한다.
  • UDP 활용 게임 네트워크 프레임워크  → Photon, Mirror, Netcode
구분 TCP (Transmission Control Protocol) UDP (User Datagram Protocol)
연결 방식 연결형 (상대방 확인) 비연결형 (확인 없이 전송)
신뢰성 높음 (데이터 도착 보장) 낮음 (데이터 유실 가능)
속도 상대적으로 느림 매우 빠름
핵심 신뢰성과 순서 보장 속도와 효율성
주요 사용처 웹, 이메일, 파일 전송 게임, 스트리밍, 인터넷 전화

 

2.3. IP (Internet Protocol)

네트워크 상에서 다른 컴퓨터와 구별하게끔 해주는 고유 번호

 

- 도메인 (Domain)

IP에 부여한 이름

 

- 포트 (Port)

하나의 컴퓨터에 실행 중인 여러 네트워크 프로그램을 구분하기 위해 부여된 번호

 

3. OSI 7계층

 

3.1. TCP/IP 4계층

- 응용 계층 (Application Layer)

특정 서비스를 제공하기 위해 어플리케이션 끼리 정보를 주고 받을 수 있는 계층

 

- 전송 계층 (Transport Layer)

송신된 데이터를 수신측 어플리케이션에 전달하는 계층

 

- 인터넷 계층 (Internet Layer)

수신측까지 데이터를 전달하는 계층

 

- 네트워크 연결 계층 (Network Access Layer)

네트워크에 직접 연결된 기기 간의 전송을 할 수 있는 계층

패킷 : 데이터 전달의 기본 단위 → 인터넷으로 보내는 모든 데이터(웹페이지, 이메일, 동영상 등)는 이 패킷 단위로 쪼개져서 전송

 

- 링크 계층

물리적인 인터페이스와 관련된 하드웨어적인 부분을 제어

운영체제와 디바이스 드라이버나 그와 관련된 랜카드, 그와 연결된 케이블 같은 것을 제어하는 계층

 

3.2. 네트워크 장비

- 스위치 (Switch) : 데이터링크 계층에 속해 있으므로 MAC주소 기반으로 동작

- 라우터 (Router) : IP주소를 기반으로 작동하여 네트워크 계층에 존재

출처 : https://www.youtube.com/watch?v=BEK354TRgZ8&list=LL&index=6&t=205s

 

 

3.3. Three way Handshake

출처 : https://www.youtube.com/watch?v=BEK354TRgZ8&list=LL&index=6&t=205s

 

 

- SYN (Synchronize) 요청

클라이언트 → 서버에게 "나 연결할래!" 요청을 보냄

 

- SYN + ACK (Acknowledge) 응답

서버 → 클라이언트에게 "좋아! 나도 준비됐어" 응답을 보냄

 

- ACK (Acknowledge) 확인

클라이언트 → 서버에게 "오케이, 연결 완료!" 응답을 보냄

 

 

2. 멀티 쓰레드 (Multi Thread)

CPU는 여러 프로그램(프로세스)에 속한 수많은 스레드들을 아주 빠르게 번갈아가며 실행한다.

하나의 CPU 코어는 물리적으로 한 번에 하나의 일만 할 수 있기 때문에, 이 빠른 전환을 통해 마치 여러 작업이 동시에 처리되는 것처럼 보이게 만든다.

 

Thread

  • 작업을 할 수 있는 직원
  • 직원을 새로 뽑고(new Thread()), 일을 시작시키고(Start()), 끝날 때까지 기다리는(Join()) 모든 과정을 직접 관리

Task

  • 직원에게 업무를 맡길 수 있는 매니저
  • 매니저는 알아서 쉬고 있는 직원(Thread)을 찾아 일을 맡기고, 작업이 끝나면 결과를 보고한다.
  • 개발자는 어떤 직원이 일하는지 신경 쓸 필요 없이 '업무' 자체에만 집중 가능

프로그램 (Program)

  • 프로그램은 특정 작업을 수행하도록 작성된 명령어들의 집합
  • 실행되기 전까지는 하드 디스크나 SSD 같은 저장 장치에 파일 형태로 존재하는 정적인 상태

프로세스 (Process)

  • 프로세스는 실행 중인 프로그램을 의미
  • 사용자가 프로그램을 실행하면, 운영체제는 해당 프로그램의 코드를 메모리에 불러와 실행 상태로 만드는 것

프로세서 (Processor)

  • 프로세서는 흔히 CPU(중앙 처리 장치)라고 부르는 하드웨어 부품
  • 프로세스(작업)에 담긴 명령어들을 실제로 해석하고 계산하여 실행하는 컴퓨터의 두뇌

 

1. Thread 기본

메인 흐름과 별개로 작업을 병렬로 처리하는 가장 기초 방식인 Thread를 이해하고

포어그라운드와 백그라운드 스레드의 수명 차이를 확인해보겠다.

using System;
using System.Threading;

internal class Program
{
    static void SubThread()
    {
        Console.WriteLine("Hello Thread");
    }

    static void Main(string[] args)
    {
        Thread t = new Thread(SubThread);
        t.IsBackground = true;
        t.Start();
        
	// t.Join(); // t가 끝날 때까지 대기
        Console.WriteLine("Hello World!");
    }
}

 

  • 메인 스레드에서 보조 스레드를 생성해 Start()로 실행한다.
  • 보조 스레드는 콘솔 로그를 출력하는 단순 작업을 수행한다.
  • IsBackground를 설정하지 않으면 포어그라운드 스레드라서 보조 스레드가 끝날 때까지 프로세스가 종료되지 않는다.
  • IsBackground = ture로 설정하면 메인 종료 시 즉시 정리될 수 있다.

스케줄링은 비결정적이어서 출력순서는 고장되지 않는다.

작업 완료가 필요하면 Join()으로 명시적으로 대기해야 한다.

구분 포어그라운드 스레드 백그라운드 스레드
프로세스 종료 남아 있으면 종료 안 됨 백그라운드만 남으면 즉시 종료됨
작업 보장 끝까지 실행됨 중간에 끊길 수 있음
사용처 반드시 끝내야 하는 작업 중요도 낮은 데몬성 작업

 

포어그라운드는 종료를 지연시키고, 백그라운드는 종료와 함께 정리된다는 점을 이해해야 한다.

반드시 완료해야하는 작업은 포어그라운드로 두거나 Join()으로 보장해야 한다.

 


 

이번에는 두 스레드가 동일한 주기로 동시에 실행될 때

출력이 어떻게 엇갈리는지와 보조 스레드가 기본값인 포어그라운드일 때 프로세스 종료 타이밍이 어떻게 되는지 확인해보자.

using System;
using System.Threading;

internal class Program
{
    static void Work()
    {
        Console.WriteLine("새 스레드 시작");

        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine("새 스레드" + i);
            Thread.Sleep(1000);
        }

        Console.WriteLine("새 스레드 종료");
    }

    static void Main(string[] args)
    {
        Console.WriteLine("메인 스레드 시작");

        Thread t = new Thread(Work);
        t.Start();

        for (int i = 0;i < 5;i++)
        {
            Console.WriteLine("메인 스레드" + i);
            Thread.Sleep(1000);
        }

        Console.WriteLine("메인 스레드 종료");
    }
}

 

 

  • 메인 스레드는 보조 스레드를 Start()로 실행한 뒤, 본인도 1초 간격으로 5회 로그를 찍는다.
  • 보조 스레드 역시 1초 간격으로 5회 로그를 찍고 마지막에 “새 스레드 종료”를 출력한다.
  • IsBackground를 설정하지 않았으므로 포어그라운드다. 따라서 메인이 “메인 스레드 종료”를 찍어도, 보조 스레드가 끝날 때까지 프로세스는 종료되지 않는다.
  • 두 루프가 동시에 1초씩 Sleep하므로 총 벽시계 시간은 약 5초대다(10초가 아님)
  • 스케줄링은 비결정적이므로 “메인 스레드{i}”와 “새 스레드{i}”의 교차 순서는 매 실행마다 달라질 수 있다.
  • 마지막 출력의 순서도 고정이 아니다. 다만 포어그라운드 규칙상 보조 스레드가 완전히 끝나기 전에는 프로세스가 종료되지 않는다.
  • 만약 종료 순서를 보장하고 싶다면 메인에서 t.Join()으로 보조 스레드 완료를 기다리면 된다.

 

2. ThreadPool과 굶주림 (starvation)

스레드풀은 일정 개수의 스레드를 미리 생성하여 관리하는 기법이다.

스레드 생성 및 삭제에 대한 오버 헤드를 줄이고, 동시에 처리해야할 작업의 개수를 관리할 수 있다.

 

스레드 풀의 최소/최대 워커 수를 조절하고

긴 작업이나 무한 루프가 스레드풀을 점유할 때 대기 작업이 실행되지 않는 현상을 확인해보자.

using System;
using System.Threading;

internal class Program
{
    static void Work(object? message)
    {
        for (int i = 0; i < 10; i++)
        {
            Console.WriteLine("새 스레드");
        }
    }

    static void Main(string[] args)
    {
        ThreadPool.SetMinThreads(1, 1);
        ThreadPool.SetMaxThreads(5, 5);

        for (int i = 0; i < 5; i++)
        {
            ThreadPool.QueueUserWorkItem((obj) =>
            {
                while (true)
                {

                }
            });
        }

        ThreadPool.QueueUserWorkItem(Work);

        Thread.Sleep(1000);
    }
}

 

  • 최대 워커 수를 소수로 제한한 뒤 무한 루프 작업을 그 수만큼 큐에 넣어 모든 워커를 점유한다.
  • 이후 정상 작업을 큐잉해도 가용 워커가 없어서 실행되지 않는다.
  • 콘솔 앱에서는 메인 종료와 함께 백그라운드 워커도 정리되어 로그가 전혀 출력되지 않을 수 있다.
  • 스레드풀은 짧고 많은 작업 처리에 최적화되어 있다.
  • 무한 루프나 장시간 CPU 점유 작업을 스레드풀에 넣으면 새로운 작업이 굶주림에 빠진다.
  • 상시 러너는 전용 스레드나 TaskCreationOptions.LongRunning으로 풀 바깥 전용 스레드를 받아야 한다.
  • 무한 루프에는 취소 토큰과 슬립·블로킹 대기를 넣어 CPU 점유를 낮춰야 한다.

스레드풀은 짧은 작업 전용으로 이해해야 한다.

 

3. Task의 시작 방식과 안전한 중지

Task를 올바르게 시작하고 종료를 보장하는 방법을 익히며

단순 bool 플래그로 중지할 때의 가시성 문제와 바쁜 대기의 위험을 확인해보자.

using System;
using System.Threading;

internal class Program
{
    private static bool isStop = false;

    static void SubThread()
    {
        Console.WriteLine("SubThread 시작");

        while (!isStop)
        {

        }

        Console.WriteLine("SubThread 종료");
    }

    static void Main(string[] args)
    {
        Task t = new Task(SubThread);
        t.Start();

        Thread.Sleep(1000);

        isStop = true;

        Console.WriteLine("Stop 호출");
        Console.WriteLine("종료 대기중");

        t.Wait();
        Console.WriteLine("MainThread 종료");
    }
}
using System;
using System.Threading;

internal class Program
{
    private static bool isStop = false;

    static void SubThread1()
    {
        Console.WriteLine("SubThread 1");
    }

    static void SubThread2()
    {
        Console.WriteLine("SubThread 2");
    }

    static void Main(string[] args)
    {
        Task t = new Task(SubThread1);

        Task.Run(SubThread2);

        Task.Run(() =>
        {
            Console.WriteLine("Run Task");
        });
    }
}

 

  • new Task(...)는 Start()를 호출해야 실행된다.
  • Task.Run(...)은 즉시 스레드풀에 예약되어 실행된다.
  • 단순 플래그를 while(!isStop){}로 검사하면 CPU를 100% 점유하는 바쁜 대기가 된다.
  • 메모리 가시성이 확보되지 않으면 보조 작업이 플래그 변경을 관측하지 못할 수도 있다.
  • 가시성 보장을 위해 volatile, Volatile.Read/Write, 혹은 CancellationToken을 사용해야 한다.
  • 바쁜 대기 대신 Thread.Sleep, SpinWait, 이벤트·채널 같은 블로킹 대기를 써서 CPU를 보호해야 한다.
  • 콘솔 앱에서도 결과가 필요하면 Wait나 await로 명시적으로 대기해야 한다.

작업 중지에는 취소 토큰이 표준 해법이다.

시작은 Task.Run으로 단순화하고 종료는 await 또는 Wait로 보장하며, 바쁜 대기는 피해야 한다.

 

4. Task<int> 사용 및 동기 vs 비동기

Task<int>로 값을 반환하고

Result로 동기 대기하는 방식과 await의 차이점을 확인해보자.

using System;
using System.Threading;
using System.Threading.Tasks;

internal class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Main 시작");

        Task<int> task = Task.Run(() =>
        {
            Console.WriteLine("Run Task");

            return 100;
        });

        Thread.Sleep(1000);

        int result = task.Result;
        Console.WriteLine("Task의 결과값 : " + result);

        Console.WriteLine("Main 끝");
    }
}

 

  • Result와 Wait()는 동기 블로킹이므로 UI나 서버 환경에서는 교착이나 스레드 고갈을 유발할 수 있다.
  • await는 비동기 대기를 제공해 컨텍스트를 막지 않으며 예외도 원형으로 전파한다.
  • Result·Wait()는 예외를 AggregateException으로 래핑한다.

예제는 Result로도 충분히 동작하지만 일반적으로는 await로 대기하는 패턴이 더 안전하다.

 

5. Task.Delay와 Task.WhenAll

여러 작업의 완료를 동시에 기다리고

지연이 필요한 구간을 정확히 비동기로 대기하는 방법을 학습해보겠다.

using System;
using System.Threading;
using System.Threading.Tasks;

internal class Program
{
    static async Task SubThread()
    {
        Console.WriteLine("작업 시작");
        Task t1 = Task.Run(() =>
        {
            Console.WriteLine("작업 1");
        });

        Task.Delay(1000);


        Task t2 = Task.Run(() =>
        {
            Console.WriteLine("작업 2");
        });
        Task.Delay(1000);

        Console.WriteLine("작업 완료 대기");

        try
        {
            await Task.WhenAll(t1, t2);
            Console.WriteLine("작업 완료");
        }
        catch (Exception ex)
        {
            Console.WriteLine("예외 발생 : " + ex.Message);
        }
    }

    static async void Main(string[] args)
    {
        await SubThread();

        Console.WriteLine("메인 스레드 종료");
    }
}

 

  • 두 개의 작업을 순차 간격을 두고 시작하고 Task.WhenAll로 모두 끝날 때까지 대기한다.
  • Task.Delay(...)는 반드시 await해야 실제로 지연이 발생한다.
  • Task.WhenAll은 “모두 완료되면 끝나는 단일 Task”를 반환하므로 이를 await해야 현재 메서드가 반환되지 않는다.
  • 여러 예외가 발생하면 await 시 첫 예외가 던져지며 전체 예외는 WhenAll이 반환한 Task의 Exception.InnerExceptions로 모아볼 수 있다.
구분 잘못된 사용 올바른 사용
지연 Task.Delay(1000); await Task.Delay(1000);
여러 작업 대기 Task.WhenAll(...);만 호출 await Task.WhenAll(...);
예외 수집 단일 예외만 확인 WhenAll 반환 Task의 Exception으로 전체 확인
엔트리 포인트 async void Main static async Task Main

 

지연은 반드시 await을 해야하

여러 작업 동기화는 await Task.WhenAll로 완료를 보장한다.

 

6. 임계 구역 직렬화 (Lock)

동시에 시작된 두 작업을 lock으로 보호하면 출력이 어떻게 직렬화되는지 확인해보고

비동기 코드에서는 어떤 동기화 도구를 써야하는지 비교해보자.

using System;
using System.Threading;
using System.Threading.Tasks;

internal class Program
{
    private static object obj = new object();

    static void SubThread(string message)
    {
        lock (obj)
        {
            Console.WriteLine($"{message} 스레드 시작");
            Thread.Sleep(1000);
            Console.WriteLine($"{message} 실행중");
            Thread.Sleep(1000);
            Console.WriteLine($"{message} 스레드 종료");
        }
    }

    static void Main(string[] args)
    {
        Task.Run(() => SubThread("A"));
        Task.Run(() => SubThread("B"));

        Thread.Sleep(3000);
        Console.WriteLine("Main Thread 종료");
    }
}

 

  • 두 작업이 동일한 오브젝트에 lock을 걸고 진입하므로 한 번에 하나만 임계 구역을 실행한다.
  • 임계 구역 안에서 Sleep을 호출하기 때문에 다른 작업은 그 시간만큼 대기하며 전체 실행이 직렬로 늘어진다.
  • 메인이 충분히 기다리지 않으면 두 번째 작업은 완료 전에 프로그램이 종료될 수 있다.

 

3. Unity에서의 Thread 적용

  • Unity API 호출은 메인 스레드 전용이라서 보조 스레드에서 직접 호출하면 안 된다.
  • 무거운 계산은 작업 스레드에서 처리하고 결과 반영은 메인 스레드에서 해야 한다.
  • 프레임 기반 타이밍은 코루틴이 간편하며 진짜 병렬 계산은 Task나 전용 스레드로 처리한다.
목적 권장 도구   비고
프레임 기반 연출·타이머 코루틴 진짜 병렬 아님
무거운 계산 분리 Task.Run 또는 전용 스레드 결과는 메인 스레드에서 반영
상시 러너 전용 스레드 또는 LongRunning 취소 토큰 필수