오늘은 이전에 계획했던대로 가비지 컬렉터의 작동 방식과, 가비지 혹은 GC Alloc을 줄이는 방법에 대해서 정리해보겠다.

 

가비지 컬렉터란?

가비지 수집 기본 사항 | Microsoft Learn

 

가비지 수집 기본 사항

가비지 수집기의 작동 원리와 최적 성능으로 구성하는 방법에 대해 알아봅니다.

learn.microsoft.com

 

가비지 컬렉터에 대한 내용은 마이크로소프트 문서에 자세히 잘 정리되어있다. 요약해보자면, 가비지 컬렉터는 메모리의 할당과 해제를 관리해주기 때문에 개발자에게 편의성을 제공하며, 메모리 보안 또한 제공한다. 가비지 컬렉터는 메모리가 추가로 필요하거나, 프로그래머가 직접 호출할 때 실행되는데, 애플리케이션 루트에서 접근할 수 없는 개체들을 가비지로 간주하고 해당 메모리를 해제한다. 이 과정에서 단편화가 발생하기 때문에 접근 가능한 개체들끼리 붙이는 형식으로 메모리를 압축한다. 그런데 큰 개체(배열인 85,000바이트 이상의 개체)의 경우, 메모리 내에서 이동하는 비용이 크기 때문에 이것을 피하기 위해 별도의 힙에 할당하며, 일반적으로 큰 개체의 메모리는 압축되지 않는다.

 

또한, 가비지 컬렉터는 성능 향상을 위해 힙의 메모리들을 세대 단위로 나눈다. 가비지 컬렉션을 수행하는 메모리의 크기가 클수록 가비지 컬렉션이 오래 걸리는데, 가비지가 될 가능성이 낮은 개체들과 가비지가 될 가능성이 높은 개체들을 다른 세대로 나누는 셈이라고 할 수 있겠다. 이렇게 관리되는 힙은 0세대, 1세대, 2세대 총 3개의 세대로 나뉜다. 새 개체는 0세대에 저장되며, 0세대 가비지 컬렉션에서 수집되지 않은 개체들이 1세대로 승격된다. 0세대 가비지 컬렉션을 수행해도 메모리가 부족하면 1세대 가비지 컬렉션을 수행한다. 1세대 가비지 컬렉션에서 수집되지 않은 개체들은 2세대로 승격된다. 만약 1세대 가비지 컬렉션을 수행해도 메모리가 부족하다면 2세대 메모리 컬렉션을 수행한다.

즉, 새로 할당되어 가비지일 가능성이 높은 개체는 0세대에 할당되어 다음 가비지 컬렉션을 수행할 때 바로 수집될 수 있는 것이고, 이미 최소 2번의 가비지 컬렉션을 거쳤음에도 가비지가 되지 않은 개체들은 후순위로 두어 전체 메모리의 크기를 줄여 가비지 컬렉터의 성능 향상을 도모할 수 있는 것이다. 단, 아까 언급한 큰 개체는 처음부터 3세대라고도 하는 LOH(큰 개체 힙)로 이동하는데, 2세대 가비지 컬렉션을 할 때 같이 수집된다.

 

가비지 컬렉션 성능 문제와 해결 방법

가비지 컬렉션 및 성능 | Microsoft Learn

 

가비지 컬렉션 및 성능

가비지 수집 및 메모리 사용량과 관련된 문제에 대해 알아봅니다. 가비지 수집이 애플리케이션에 미치는 영향을 최소화하는 방법을 알아봅니다.

learn.microsoft.com

위에 포함된 내용들은 물론이고, 이외에도 여러 번거로운 작업들을 가비지 컬렉터가 알아서 처리해주기 때문에 굉장히 편리하다고 생각한다. 개발자가 신경을 쓸만한 부분이 많지는 않은 것 같은데, 마이크로소프트의 문서에서 참고할만한 성능 관련된 문제와 해결 방법, 성능 검사 절차 등을 제시하고 있다.

 

점진적 가비지 컬렉션

점진적 가비지 컬렉션 - Unity 매뉴얼

 

점진적 가비지 컬렉션 - Unity 매뉴얼

점진적 가비지 컬렉션(GC)은 가비지 컬렉션 프로세스를 여러 프레임에 분산시킵니다. 이는 Unity에서 가비지 컬렉션 동작의 기본값입니다.

docs.unity.cn

Unity에서는 2019.1 버전 이후로 점진적 가비지 컬렉션이라는 기능을 지원한다. 가비지 컬렉션 작업을 할 때 원래는 다른 스레드들을 중지하여 가비지 컬렉터에 걸리는 시간만큼 프로그램이 일시정지 되는데, 점진적 가비지 컬렉션 기능은 작업을 여러 프레임에 분할하여 처리하기 때문에 이런 일시정지같은 현상을 막을 수 있다. 다만 아무래도 멈춰놓고 한 번에 처리하는 것 보다 많은 시간이 걸리며, 처리하는 과정에서 프레임이 떨어질 수도 있을 것 같다. 

 

최적화 방법

자동 메모리 관리 이해 - Unity 매뉴얼 (unity3d.com)

 

자동 메모리 관리 이해 - Unity 매뉴얼

오브젝트나 문자열, 배열을 생성한 이후 저장하려면 메모리 공간이 필요합니다. 필요한 공간은 heap이라고 하는 중심 풀에서 할당됩니다. 메모리 공간을 할당받은 항목이 더 이상 사용되지 않게

docs.unity3d.com

Unity Documentation의 자동 메모리 관리 이해에서는 최적화와 관련된 몇 가지 예시들도 보여주고 있다. 최적화를 위한 첫 번째 방법은 먼저 문자열 접합 반복을 피하는 것이다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void ConcatExample(int[] intArray) {
        string line = intArray[0].ToString();
        
        for (i = 1; i < intArray.Length; i++) {
            line += ", " + intArray[i].ToString();
        }
        
        return line;
    }
}


//JS script example
function ConcatExample(intArray: int[]) {
    var line = intArray[0].ToString();
    
    for (i = 1; i < intArray.Length; i++) {
        line += ", " + intArray[i].ToString();
    }
    
    return line;
}

예시에서 보여주는 코드는 위와 같은데, for문에서 i가 1씩 증가할 때마다 line에 문자열이 추가로 붙는 것이 아니라, 이전 내용이 삭제되고 새로운 문자열이 할당된다고 한다. 즉, 이 작업을 할 때마다 계속 새로운 string 개체가 생성되며 가비지가 엄청나게 많이 생성된다고 할 수 있다. 문자열을 동시에 여러 개 연결해야 한다면 System.Text.StringBuilder를 활용하는 것이 좋다고 한다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    float[] RandomList(int numElements) {
        var result = new float[numElements];
        
        for (int i = 0; i < numElements; i++) {
            result[i] = Random.value;
        }
        
        return result;
    }
}


//JS script example
function RandomList(numElements: int) {
    var result = new float[numElements];
    
    for (i = 0; i < numElements; i++) {
        result[i] = Random.value;
    }
    
    return result;
}
//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void RandomList(float[] arrayToFill) {
        for (int i = 0; i < arrayToFill.Length; i++) {
            arrayToFill[i] = Random.value;
        }
    }
}


//JS script example
function RandomList(arrayToFill: float[]) {
    for (i = 0; i < arrayToFill.Length; i++) {
        arrayToFill[i] = Random.value;
    }
}

두 번째로는 배열 값을 반환하는 경우인데, 위의 코드처럼 배열을 반환하는 경우 새로운 메모리가 할당되기 때문에 배열의 크기가 큰 경우 힙 공간이 빠르게 소모된다. 배열은 참조 타입이기 때문에 굳이 반환을 해주지 않아도 아래 코드처럼 파라미터로 전달받은 함수의 값을 수정해주면 값이 유지된다는 점을 활용하면 가비지 생성을 줄일 수 있다는 장점이 있다.

 

 

게임 플레이에 대한 영향을 최소화하는 전략이 크게 2가지가 있는데, 작은 힙과 빠르고 빈번한 가비지 컬렉션을 이용하는 방법, 큰 힙과 느리지만 덜 빈번한 가비지 컬렉션을 이용하는 방법이다. iOS에서 이 방법을 사용할 때 할당할 일반적인 힙 크기는 200KB이며, 이 경우 가비지 콜렉션은 iPhone 3G에서 대략 5ms 정도 걸린다고 한다. 힙의 크기가 1MB로 증가하면 7ms 정도 걸린다고 한다.

if (Time.frameCount % 30 == 0)
{
   System.GC.Collect();
}

위 코드와 같이 일정 프레임마다 가비지 컬렉션을 수행해 힙의 크기를 일정 이하로 유지하여 가비지 콜렉션에 소요되는 시간을 짧게 유지하여 성능 저하를 최대한 막는 방법이라고 볼 수 있겠다. 다만 이 기법은 주의해서 사용한다고 쓰여있는데, 매 프레임마다 생성되는 힙의 크기가 커서 가비지 컬렉션에 걸리는 시간이 길다면 이 방법은 썩 좋지 않을 것으로 생각된다. 매 초마다 툭툭 끊기는 게임이 될 수도 있으니... 힙의 크기를 최대한 작게 유지할 수 있는 경우에만 사용하는 것이 좋겠다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];
        
        // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];
        
        // release reference
        tmp = null;
    }
}


//JS script example
function Start() {
    var tmp = new System.Object[1024];

    // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (var i : int = 0; i < 1024; i++)
        tmp[i] = new byte[1024];

    // release reference
        tmp = null;
}

두 번째는 큰 힙과 느리지만 덜 빈번한 가비지 컬렉션을 사용하는 방법이다. 가비지 컬렉션은 메모리가 부족할 때 수행되기 때문에, 힙의 크기를 처음부터 크게 가져갈 수 있다면 가비지 컬렉션이 덜 빈번하게 발생할 것이다. 물론 크기가 큰 만큼 가비지 컬렉션에 걸리는 시간은 증가하겠지만, 가비지를 모아놨다가 맵을 이동하는 등의 로딩 시간에 한 번에 처리하면 게임 플레이에 영향을 별로 주지 않고 가비지 컬렉션을 실행할 수 있을 것이다. 힙의 크기는 수동으로 확장해야 하는데, 위의 코드는 이를 위해서 무의미한 오브젝트를 생성하는 방식을 활용한다. 다만, 주의할 점으로는 힙이 너무 커지면 운영체제가 시스템 메로리를 확보하려고 앱을 강제종료하는 문제가 생길 수 있으니 이것은 피해야 한다.

 

또한 오브젝트 풀링 기법을 사용하는 것도 예시로 나와있다.

 

(Unity) 유니티 최적화 방법들 (tistory.com)

 

(Unity) 유니티 최적화 방법들

스크립팅 최적화 SendMessage 와 BroadcastMessage 함수 사용자제 Find 관련 함수 사용자제 자식이 많은 오브젝트 transform 변경시 많은 비용 발생 Update , LateUpdate 등 함수가 비어있으면 지워라 , 빈 Update 종

happysalmon.tistory.com

이외에도 인터넷을 찾아보면 다양한 최적화 방법들이 나와있고, 유니티에서 최적화를 하는 방법들을 모아놓은 책도 있다. 방법이 아주아주 많아서 차근차근 익혀나가는 수밖에 없을 것 같다.

======================================

 

오늘은 가비지 컬렉터, 컬렉션에 대해서 알아보고 정리해보았다. 가비지 컬렉터의 일부만 알고 있을 때는 가비지가 되지 않을 것들이나 가비지가 될 것들을 변수를 생성할 때 미리 분류할 수 있으면 더 효율적이지 않을까 싶었는데, 당연스럽게도 이런 부분까지도 자동으로 관리를 해주고 분류해주는 것이 참 대단하게 느껴졌다. 이제는 가비지 컬렉터에 대해서 "어느정도는" 안다고 이야기할 수 있을 것 같다. 가비지 컬렉터 관련된 문서들 중 아직 읽지 않은 문서들도 꽤나 있고, 특히 마이크로소프트의 문서들은 아직 잘 모르는 기술적인 용어들이 들어가 있는 경우가 많아서 아직까지 완벽하게 이해하기는 어려운 것 같다. 지금도 이전에 비해서는 많이 읽기 수월해져서 앞으로 공부를 계속 하다보면 언젠가는 잘 이해할 날이 오지 않을까 기대해본다.

메모리 관리에 대한 탐구 (1) :: 메피카타츠의 블로그 (tistory.com)

 

메모리 관리에 대한 탐구 (1)

CS 면접 관련 질문들을 공부하면서 스마트 포인터에 대해 찾아보게 되었다. 이와 관련해서 안에 쓰여져 있는 내용들 중 잘 모르는 것이나 궁금한 것들을 찾아 꼬리에 꼬리를 물다보니 꽤나 오랜

mepkatatsu.tistory.com

당연한 얘기일지 모르겠지만 가비지 컬렉터에 관한 내용들을 찾다보니 메모리와 관련된 내용으로 귀결되었다. 이전에 메모리 관리에 대한 탐구 (1)이라고 쓴 이후로 2를 안썼는데, '가비지 컬렉터에서 가비지란 무엇인가?' 라는 내용으로 스택 영역과 힙 영역을 중심으로 정리한 이 글이 2탄이 되겠다.

 

스택 영역/힙 영역

가비지 컬렉터 이야기를 하기 위해 스택 영역과 힙 영역의 차이를 설명할 필요가 있겠다. 스택 영역에 생성되는 변수는 함수가 호출될 때 생성되고, 함수 호출이 완료되면 사라진다. 즉, 따로 메모리를 관리해줄 필요가 없는 것이고, 가비지 컬렉터도 관여하지 않는다. 따라서 가비지 컬렉터는 힙 영역에 할당되는 변수들에 한하여 관여를 하는 셈이다.

변수를 스택 영역에 할당하는 경우는 정적 할당이라고 한다. 고정된 크기를 갖기 때문에 메모리를 관리하기 편하고, 연속된 메모리 공간을 사용하여 성능도 좋다. 변수를 힙 영역에 할당하는 경우는 동적 할당이라고 한다. 가변적인 크기를 갖기 때문에 메모리를 관리하기 어렵고, 연속적이지 않은 메모리 공간을 사용하게 될 수도 있어 동적 할당에 비해 성능이 떨어진다. 크기가 정해져있지 않기 때문에 스택 영역에 할당하기 어려울 것이다.

즉, 스택 영역을 활용할 수 있다면 스택 영역을 활용하는 것이 좋은 선택이다. 하지만 동적 할당을 사용해야하는 경우도 있다. 배열의 크기가 계속 확장될 가능성이 있다면, 동적 할당을 하는 것이 현명한 선택일 것이다. 1시간 플레이를 하면 배열의 공간을 100까지 사용한다고 했을 때, 처음부터 10000까지 할당하는 것은 쓸데없이 메모리 공간을 많이 차지하게 된다. 이렇게 메모리 낭비를 감수하면서 여유있게 공간을 할당해도 100시간 이상 연속으로 플레이를 해서 필요한 공간이 10000이상으로 늘어나게 되면 오류가 발생한다. 즉, 이런 경우에는 동적 할당을 하여 힙 영역을 사용하는 것이 현명한 선택이다.

 

기존의 C나 C++에서는 이렇게 할당받은 메모리를 프로그래머가 직접 할당해줘야 했지만, 최근은 C++은 스마트 포인터로, C#과 Java는 가비지 컬렉터라는 기능을 통해 메모리를 자동으로 관리해준다. Unity에서는 C#을 사용하기 때문에 가비지 컬렉터의 도움을 받아 접근할 수 없는 메모리를 자동으로 해제해주며, 아까 말했듯이 가비지 컬렉터는 힙 영역에 할당되는 변수들에 한하여 관여를 한다.

 

값 형식/참조 형식

값 형식 - C# 참조 | Microsoft Learn

 

값 형식 - C# 참조

값 형식과 참조 형식, 값 형식의 종류, C#의 기본 제공 값 형식

learn.microsoft.com

참조 형식 - C# 참조 | Microsoft Learn

 

참조 형식 - C# 참조

참조 형식(C# 참조) 아티클 04/07/2023 기여자 12명 피드백 이 문서의 내용 --> C# 형식은 참조 형식과 값 형식 두 가지가 있습니다. 참조 형식의 변수에는 데이터(개체)에 대한 참조가 저장되며, 값 형

learn.microsoft.com

 

C#에는 값 형식과 참조 형식이라는 2가지 형식이 존재하는데, 값 형식은 구조 형식(struct)이거나 열거 형식(enum) 중 하나이며, 스택 영역에 할당된다.

딱히 생각해 본 적은 없었지만, C#에서 int도 하나의 구조체이다.

 

값 형식의 변수에는 해당 데이터가 직접 포함되지만, 참조 형식의 변수에는 데이터에 대한 참조가 저장된다는 차이점이 있다. 또한 참조 형식은 힙 영역에 데이터가 저장되며, 스택 영역에 힙에 있는 데이터의 주소를 저장한다. 이것은 추측이지만 함수 호출이 종료될 때 스택 영역에 있는 메모리들이 해제되며, 이때 힙에 있는 데이터의 주소들이 사라진다. 즉 힙에 접근할 수 없는 메모리가 생기게 되는 것이고, 이것이 가비지이며 힙에 메모리가 부족해질 때마다 이런 가비지들을 수집하는 것이 가비지 컬렉터의 일이라고 할 수 있을 것 같다.

 

참조 형식을 선언하는 데 사용되는 키워드로는 class, interface, delegate, record가 있다.

string이나 GameObject도 class이기 때문에 참조 형식이고, 동적 할당을 받아 힙 영역을 차지하는 셈이다.

 

이전 글에서 언급했듯, Vector2, Vector3는 new 키워드로 할당하지만 struct이기 때문에 값 형식이고, 정적 할당을 받아 스택 영역을 차지한다. 즉 가비지를 만들어내지 않는 것이다.

 

힙에 할당된 메모리의 크기를 확인

new string("test"); 1000회 반복

또한, 이전 글에서 new string("test"); 를 1000회 반복하고 프로파일러로 GC Alloc을 확인한 바가 있다. GC Alloc은 실제 가비지의 크기가 아닌, 힙에 할당된 메모리의 크기를 의미하는 것이라고 한다. 그도 그럴 것이, 힙에 할당되는 메모리 중에 쓰레기가 되는 메모리가 있고, 쓰레기가 되지 않는 메모리가 있으니 이것을 매 순간 판단하기는 어려울 것이다. 하지만 가비지 컬렉터의 속도는 GC Alloc의 총 크기에 영향을 받기 때문에 GC Alloc이 적게 발생하도록 하는 것이 좋을 것이다.

 

=================================

 

처음에는 가비지 컬렉터에 대해서 알아보려고 했는데, 가비지에 관한 내용을 먼저 정리할 필요를 느껴 먼저 가비지란 무엇인가에 대한 내용을 정리해보았다. 이전에도 스택 영역과 힙 영역에 대해 알아보았지만 많이 모자랐던 것 같다. 이제야 스택 영역과 힙 영역에 대해 확실하게 이해했다고 할 수 있을 것 같다. 다음에는 가비지 컬렉터가 작동하는 방식 등의 내용을 찾아보고, 실제로 가비지를 줄이는 방법 혹은 GC Alloc을 줄이는 방법에 대해 정리해 볼 생각이다.

 

추가로, 이전에 면접을 본 기업에서 1주일 이내로 연락을 준다고 했는데 연락이 없었다. 내가 지원하기 이전에 이미 클라이언트 프로그래머 수가 많이 늘었다고 해서 안 뽑힐 가능성도 염두에 두긴 했는데, 막상 떨어졌다 생각하니 조금 아쉬운 것 같다. 다시 자소서를 쓰고 다른 곳에 지원도 했는데, 여기도 공고가 오래전부터 올라와있어서 어떻게 될지는 잘 모르겠다. 시기도 별로 안 좋은 것 같기도하고, 안 좋은 시기에도 충분히 취업할 수 있을만큼 뛰어나지 못한 것 같기도 하다. 병역의 해결을 위해 취업하겠다는 목표를 세우긴 했으나... 병특을 뽑는 업체가 많지 않아서 내 마음대로 되는 것도 아니다. 이번에도 떨어지면 이전에 생각했던 스토어 출시를 목표로 게임 하나를 개발해볼까 싶은 생각을 가지고 있다. 크게 성공할 수 있을지는 모르겠지만 적어도 실패는 하지 않을 것 같은 게임이다. 일단 결과를 지켜볼 예정이다.

 

이전 글에서 다루었던 이 책에 처음 내용은 기초 필수 수학으로 시작한다. 그 내용 중에서도 가장 먼저 등장하는 것이 벡터이다. 유니티에서 벡터는 굉장히 중요하고 자주 다루는 개념이다. 위치를 나타내거나 이동, 회전, 다양한 부분에 사용된다.

 

책에서는 벡터의 개념이나 기본적인 연산 방법, 단위 벡터, 내적, 외적, 직교화, 위치 벡터, 마지막으로 DirectXMath 라이브러리의 벡터에 대한 내용을 다룬다. 기본적이고 찾아보면 나오는 내용들이기 때문에 따로 정리하지는 않고, 이런 내용들을 보면서 생각한 내용과 추가로 찾아본 것들에 대해서 적어보고자 한다.

 

같은 벡터라도 기준계가 다르면 좌표 표현이 달라진다는 것이다.

...

우리가 어떤 벡터를 좌표로 규정하거나 식별할 때 그 좌표가 절대적인 수치들이 아니라 항상 어떤 기준계에 상대적인 수치이고, 3차원 컴퓨터 그래픽에서는 여러 기준계들을 사용하는 경우가 많으므로, 벡터를 다룰 때에는 주어진 벡터의 좌표가 현재 어떤 기준계에 상대적인지를 기억할 필요가 있다. 또한, 한 기준계에서의 벡터 좌표를 다른 기준계로 변환하는 방법도 알아야 한다.

 

먼저 이 내용을 보면서 유니티의 월드 좌표와 로컬 좌표, 그리고 스크린 좌표가 떠올랐다. 월드 좌표는 월드 내에서 물체의 절대적인 위치를 나타내고, 로컬 좌표는 객체의 부모를 기준으로 한 좌표를 나타낸다. 스크린 좌표는 화면 내에서 표현되는 좌표를 나타낸다. 다른 좌표계도 있을지는 모르겠는데, 주로 이 3가지를 많이 이용하는 것 같다. 분류를 나누기 위한 객체들의 부모는 특별한 이유가 없으면 (0, 0, 0)의 좌표로 설정해주는 것은 지금와서는 자연스러운 행동이지만 이런 것들을 염두에 두고 하는 행동인 셈이다. 스크린 좌표는 보통 UI를 나타낼 때 많이 사용하는 것 같은데, 월드 좌표로 치환하기 위한 ScreenToWorldPoint 등의 함수도 유니티에서 지원한다. 자주 사용하진 않지만 로컬 좌표를 월드 좌표를 변환하기 위한 TransformPoint라는 함수도 있다.

 

https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=sdaff1004&logNo=221192712213

_moveDirection = new Vector3(_inputX, 0, _inputZ).normalized;

그리고 벡터의 정규화에 대한 내용을 보면서 유니티에서 벡터의 정규화를 어떻게 하는지 궁금해졌다. 이전에도 이동 속도를 항상 동일하게 맞추기 위해서 등의 이유로 벡터를 정규화하곤 했다. 여기서 normalized가 어떻게 구현되어있는지를 살펴보았다.

 

// 요약:
        //     Returns this vector with a magnitude of 1 (Read Only).
        public Vector3 normalized
        {
            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            get
            {
                return Normalize(this);
            }
        }

Vector3의 경우, 내부 함수인 Normalize를 호출하는 것을 알 수 있었다.

public static Vector3 Normalize(Vector3 value)
        {
            float num = Magnitude(value);
            if (num > 1E-05f)
            {
                return value / num;
            }

            return zero;
        }
        
public static float Magnitude(Vector3 vector)
        {
            return (float)Math.Sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z);
        }

Normalize 함수를 살펴보면 이렇다. Magnitude 함수를 이용하여 Vector의 크기를 구해주고, 전달받은 Vector의 value를 Vector의 크기로 나눈다. 위에서 본 식과 동일한 방법을 이용하고 있는 셈이다. 단, 이 과정에서 Vector의 크기가 1E-05f보다 커야 한다. 1E-05f는 0.00001를 다르게 표현한 것이다.

 

What’s wrong with Vector3.normalized, or my understanding of it? - Unity Answers

 

What’s wrong with Vector3.normalized, or my understanding of it? - Unity Answers

 

answers.unity.com

포럼을 살펴보면 이에 관한 내용이 나와있다. 내용을 정리하자면 부동소수점 오류로 인해 두 벡터의 값이 일정값 이상 차이가 나지 않으면 같다고 정의를 해놓은 것이라고 볼 수 있겠다. 실제로 유니티의 인스펙터에서 벡터 값을 15로 변경하면 15.0000001이라거나 14.99999999 등이 되어있는 모습을 자주 볼 수 있다. 따라서 이런 예외를 둔 것으로 보인다.

 

이 정규화에서 추가로 든 생각은, normalized는 결국 Normalize 함수를 호출하는데, 한 번 더 과정을 거치기 때문에 바로 Normalize 함수를 호출하는 것이 성능 면에서 더 좋지 않을까? 라는 생각이었다.

 

using UnityEngine;

public class VectorTest : MonoBehaviour
{
    System.Diagnostics.Stopwatch stopwatch = new System.Diagnostics.Stopwatch();

    Vector3 testVector1 = new();
    Vector3 testVector2 = new();

    float runTimeNormalized;
    float runTimeNormalize;

    const long REPEAT_TIME = 10000000000;

    private void Start()
    {
        Debug.Log("Calculating Normalized Time: " + REPEAT_TIME);

        stopwatch.Start();
        ExecuteNormalized(REPEAT_TIME);
        stopwatch.Stop();
        runTimeNormalized = stopwatch.ElapsedMilliseconds;

        Debug.Log("Calculating Normalize Time: " + REPEAT_TIME);

        stopwatch.Restart();
        ExecuteNormalize(REPEAT_TIME);
        stopwatch.Stop();
        runTimeNormalize = stopwatch.ElapsedMilliseconds;

        Debug.Log("Normalized: " +  runTimeNormalized + "ms, Normalize: " + runTimeNormalize + "ms");
    }

    private void ExecuteNormalized(long repeat)
    {
        for (long i = 0; i < repeat; ++i)
        {
            testVector1 = testVector2.normalized;
        }
    }

    private void ExecuteNormalize(long repeat)
    {
        for (long i = 0; i < repeat; ++i)
        {
            testVector1 = Vector3.Normalize(testVector2);
        }
    }
}

실제로 각각 100억번씩 호출하고 소요되는 시간을 확인해보았다.

 

normalized를 호출한 경우는 161017ms, Normalize를 호출한 경우는 145271ms가 소요되었다.

Normalize를 바로 호출하는 것이 성능상으로 약 9.8%정도 빨랐다. 아마 Normalize에서도 Magnitude함수를 호출하지 않고 바로 계산한다면 성능을 더 향상시킬 수 있을 것이다. 하지만 이렇게 하지 않는 이유는 아마 각각의 함수가 하는 역할들을 나눠놓기 위함이 아닐까 싶다. 이런 방법을 통한 성능 향상은 분명 있겠지만, 굉장히 미미하다. 내 컴퓨터에서 사용한 경우, normalized 대신에 Normalize를 1번 사용하면 약 0.0000015746ms라는 성능 상의 이점이 생긴다. 1초에 60프레임 이라고 생각하면, 매 프레임마다 10000번 호출한다고 가정했을 때 1초(1000ms)에 0.94476ms 정도를 절약할 수 있다.

솔직히 실질적인 성능 상의 이점은 거의 없다고도 말할 수 있을 것 같다. 그래도 normalized 대신에 Normalize를 사용하는 것이 크게 어려운 것은 아니기 때문에 앞으로는 Normalize를 사용하도록 노력해야겠다. 만약 혹시라도 호출이 극단적으로 저렇게 많이 늘어나는 경우에는 함수를 커스텀해서 사용함으로써 추가로 성능 향상을 도모해볼 수도 있겠다.

 

 

유니티 Vector3 new 는 스택에 생성된다 :: 3DMP (tistory.com)

 

유니티 Vector3 new 는 스택에 생성된다

http://cafe.naver.com/unityhub/25942 아래 개미개발자님의 코드 스타일의 최적화 이슈의 문제에 질답이 오가던 중, 메테오님의 지적으로 그동안 제가 잘 못 알고 있었던게 있었더군요. 그래서 많은 분들

3dmpengines.tistory.com

new string("test"); 1000회 반복

 

new Vector3(); 1000회 반복

 

또, 위에서 Vector3를 사용할 때 new를 호출할 때마다 가비지가 생성되는 것은 아닌가? 하는 생각에 찾아보면서 새롭게 알게된 것이 있다. 결론을 먼저 말하면 Vector3는 new로 생성해도 가비지가 생기지 않는다. Vector3는 구조체이고, C#에서 구조체는 new로 할당해도 힙이 아니라 스택에 생성되기 때문이라고 한다. 직접 프로파일러를 이용하여 테스트를 해보니 확실히 new string()을 호출할 때는 가비지가 생겼지만, new Vector3()의 경우는 가비지가 생기지 않는 것을 확인할 수 있었다.

 

 

 

오늘은 책을 통해 벡터에 관한 개념을 다시 복습하고, 새로 알게 된 지식으로부터 생긴 궁금증을 2가지 해결할 수 있었다. 유니티에서 정규화를 하는 방법은 특별하지 않고 일반적인 방법을 사용하며, normalized로 정규화를 하는 것보다는 Normalize로 정규화를 하는 것이 정말 미세하게지만 성능이 조금 더 좋다. 그리고 Vector는 구조체이고, C#에서 구조체는 new로 할당해도 힙이 아닌 스택에 생성되기 때문에 가비지가 생기지 않는다는 것이다. 그리고 프로파일러의 존재는 알고 있었지만 사용해본 것은 처음인 것 같다. 앞으로도 종종 사용하면서 익숙해질 필요가 있을 것 같다.

 

마지막으로, new Vector()를 사용해도 가비지가 생성되지 않는다는 것을 새롭게 알게 되면서 여전히 가비지 컬렉터에 대한 이해가 모자란 부분이 있다고 느꼈다. 내일은 가비지와 가비지 컬렉터에 대해서 더 자세히 알아봐야겠다.

모바일 게임과 PC 게임 사이에는 큰 차이가 존재한다. 모바일 게임과 PC 게임의 차이는 어떤 것이 있는지, 이런 차이가 국내 모바일 게임 시장에서 어떤 형태로 나타나는지 나름대로의 생각을 정리해보고자 한다.

 

모바일 게임과 PC 게임의 차이

1. 플레이 환경

모바일 게임과 PC 게임 사이의 가장 큰 차이는 플레이 환경이라고 생각한다.

플레이 환경 차이의 첫 번째는 화면 크기이다. 점점 큰 화면을 가진 모바일 기기가 등장하고, 갤럭시 노트, 아이패드 등의 태블릿도 존재하지만 그럼에도 불구하고 모바일 기기는 휴대성이 중요하기 때문에 모니터의 크기를 넘기는 힘들다. 보통은 스마트폰만 가지고 있는 경우가 많기 때문에 모바일 게임에 비해서 PC 게임은 10배 이상 큰 화면으로 즐길 수 있다는 장점이 있다.

두 번째로는 컨트롤 환경이다. PC는 키보드와 마우스라는 입력 장치가 존재한다. 때문에 다양한 입력이 가능하다는 점과, 입력의 정밀성이 높으며 손가락의 피로도가 적다는 장점이 있다. 또한 키보드와 마우스를 누르면서 느낄 수 있는 타격감 또한 중요하다고 생각한다.

세 번째로는 하드웨어의 성능 차이이다. 모바일 기기의 성능이 비약적으로 성장한 것은 틀림없는 사실이지만, 여전히 고성능 PC의 성능을 뛰어넘기는 힘들다. 고작 부품 하나인 그래픽카드의 크기가 스마트폰 몇 대를 합쳐야 겨우 비슷한 크기가 될 정도이니 말 다 했다고 볼 수 있겠다. 이외에도 차이점들이 많이 있겠지만, 이 세 가지가 플레이 환경에 가장 큰 영향을 주는 요소라고 생각한다.

 

 

안타까운 얘기지만, 이런 요소들로 인해 모바일 게임은 선천적으로 PC 게임에게 게임성으로 승부를 하는 것 자체가 불가능하다. 모바일 기기의 작은 화면은 유저의 시야에서 극히 일부분을 차지하고, 더욱이 화면이 작기 때문에 PC에 비해 그래픽 요소들이 훨씬 적게 배치될 수 밖에 없다. 그마저도 조작이나 각종 정보를 알려주는 UI가 화면에서 큰 비중을 차지하게 된다. 이런 점들은 유저의 몰입을 저하시키는 요인이고, 나아가 게임성을 떨어뜨릴 수 밖에 없는 요인이기도 하다.

 

마우스 혹은 키보드가 주는 조작의 즐거움도 빼놓을 수 없는 중요한 요인 중 하나이다. 대표적으로 FPS게임에서 마우스만큼 대중적이고 편한 입력 기기는 아직까지 없는 것 같다. 모바일 환경에서는 화면의 중앙 상당 공간을 드래그하여 조준을 해야하는데 이것이 굉장히 힘들고, 콘솔도 마찬가지로 조이스틱을 돌려서 조준하는 것이 매우 어렵다. 때문에 이 두 플랫폼에서는 FPS 게임에 에임 보정이 들어가 있는 경우가 많다. 특히나 모바일 환경에서는 조준과 발사에 동시에 두 손가락이 사용되기 때문에 이동을 하면서 조준, 발사를 하는 것이 정말 어려운 일이다. 때문에 모바일 FPS 게임을 하는 사람들은 조작을 도와주는 보조 기기를 구매하여 플레이하는 경우도 있다.

마찬가지로 키보드도 입력에 최적화 된 장치이기 때문에 최대한 편하고 즐겁게 입력할 수 있도록 설계되어있어 편의성이 최대 장점인 터치와는 비교할 수 없을 정도의 즐거움 제공한다. 메이플스토리에서 사냥을 하는 모습을 보면, 키보드의 방향키와 Alt키, Ctrl키, Shift키 +@를 반복하여 누르면서 똑같은 사냥을 반복하는 모습을 볼 수 있다. 옆에서 지켜보면 대체 왜 저런 걸 하고 있나, 싶지만 막상 해보면 생각보다 재밌다. 물론 게임 내에서 볼 수 있는 그래픽 효과라든지, 보상 등도 영향이 있겠지만 키보드의 역할도 상당히 크다고 생각한다. 만약 모바일 기기로 똑같은 플레이를 한다면 상당수의 유저가 떠날 것이다. 우선 키보드의 경우 비슷한 위치에서 손가락을 바꿔가며 키를 누르기 때문에 손가락의 피로도가 적지만, 모바일 기기의 경우 엄지 손가락만을 사용하는 경우가 많기 때문에 엄지 손가락을 반복하여 움직인다면 피로도가 상당히 심할 것이다. 또, 키보드를 누를 때 주는 즐거움이 사라질 것이기 때문에 기존에 비해 유저가 느끼는 즐거움이 줄어들 것이다. 대표적으로 마우스와 키보드가 큰 영향을 주는 두 게임을 예로 들어봤는데, 극히 일부 게임을 제외한다면 키보드와 마우스를 사용하지 않는 것이 유저 경험을 떨어뜨리는 요인이 될 수 있다고 본다.

 

마지막으로 하드웨어의 성능 차이가 만드는 차이에 대해 말해보겠다. 모바일 게임은 PC 게임에 비해 모든 성능이 뒤쳐진다. RAM 용량도 작기 때문에 최적화가 제대로 되지 않은 게임은 플레이를 하다가 튕기기 일쑤다. 따라서 모바일 게임을 개발할 때는 최적화를 하기 위해 다양한 방법을 사용하는데, 성능에서 가장 큰 부분을 차지하는 것이 그래픽이기 때문에 모바일 게임을 만들 때는 캐릭터 모델링 등의 퀄리티를 다소 낮추는 방법을 많이 사용한다고 한다. 폴리곤의 수를 줄이고 텍스쳐의 크기를 줄이는 등 작업을 하면 당연히 용량이 줄어들고 성능은 향상되겠지만, 그만큼 게임의 퀄리티가 떨어진다는 얘기이기도 하다. 고스트 오브 쓰시마와 같은 PC게임에서도 더 나은 유저 환경을 위해 중요성이 떨어지는 풀 등의 텍스쳐 퀄리티를 낮추는 방법을 사용하는데, 모바일 게임에서는 말할 것도 없을 것이다. 만약 퀄리티를 낮추지 않는다면 게임의 프레임이 떨어져 유저 경험을 저하시키는 요인이 될 수도 있다.

 

정리하자면, 모바일 게임과 PC 게임의 플레이 환경 차이는 크게 화면 크기, 마우스와 키보드와 같은 컨트롤 환경, 하드웨어의 성능 차이에서 비롯된다. 화면 크기가 작은 모바일 게임은 PC 게임에 비해 유저의 몰입이 떨어질 수밖에 없고, 조작의 불편함은 유저 경험을 떨어뜨리는 원인이 된다. 하드웨어 성능이 낮은 모바일 게임은 PC 게임보다 게임의 퀄리티가 낮을 수밖에 없다. 이런 것들은 모바일 게임이 PC 게임에 비해서 게임성이 떨어질 수 밖에 없는 이유이기도 하다.

 

2. 접근성

모바일 게임이 가지는 장점도 있다. 바로 접근성이다. 설치, 실행, 플레이의 접근성에 대해서 이야기해보겠다.

 

먼저 설치에 대한 비교이다. PC 게임의 경우, 설치를 하기 위해 전용 툴이 필요한 경우도 많고, 설치에 걸리는 시간도 상당히 길다. 또한, 게임들이 여기저기 흩어져있어 존재조차 알기 어려운 게임들이 많다. 많은 게임들을 모아놓은 스팀이라는 플랫폼이 있긴 하지만, 모든 게임이 모여있는 것도 아니고 대부분의 게임들이 유료라는 문제가 있다. 반면에 모바일 게임의 경우 플레이 스토어 혹은 앱 스토어라는 자체 어플이 반드시 깔려있다. PC 게임에 비해 용량이 작기 때문에 설치에 걸리는 시간도 짧은 편이고, 하나의 스토어에 거의 모든 어플들이 모여있는데다가 상당수의 게임들이 무료이기 때문에 PC 게임에 비하면 접근성이 월등히 좋다. 게임들을 구경하다가 관심이 가면 가볍게 설치했다가 잠깐 플레이해보고 지우는 경우도 많다. 즉, PC 게임에 비해서 모바일 게임의 설치에 대한 접근성이 월등히 좋다는 장점이 있다.

 

실행의 경우, PC게임은 게임 아이콘을 찾아 클릭하며, 전용 앱을 실행하며 로딩을 기다리고, 실행 버튼을 누르고 게임 실행을 잠시 기다리며, 게임이 실행된 이후에도 로딩을 기다리고 로딩이 완료되면 비로소 게임을 할 수 있다. 반면에 모바일 게임은 게임 아이콘을 누르면 로딩 후에 바로 게임을 할 수 있다. 아이콘을 찾을 필요 없이 푸쉬 알림을 눌러서 바로 실행할 수도 있다. 개인적인 생각이지만 PC 게임은 실행하는 것 자체가 힘들게 느껴질 때가 있다. 아무튼, 모바일 게임이 PC 게임에 비해 실행 자체도 훨씬 간편하다는 장점이 있다.

 

마지막으로 플레이에 대한 접근성이다. PC 게임은 플레이를 하기 위해 컴퓨터 앞에 앉아야 한다. 이 상태에서 이동하기도 불가능하다. 반면, 모바일 게임의 경우 어디서든 플레이할 수 있다는 장점이 있다. 집 밖은 물론이고, 지하철 등을 타고 이동할 때나, 앉아서는 물론이고 서서도 플레이할 수 있으며, 조금은 위험하지만 움직이면서 플레이를 하는 것도 가능하다. 원한다면 침대에 누워서 플레이를 할 수도 있고, 밥을 먹으면서 하는 사람들도 있다. 플레이의 접근성이 모바일 게임이 가진 가장 큰 장점이라고 본다.

 

https://gsis.kwdi.re.kr/statHtml/statHtml.do?orgId=338&tblId=DT_1ID0511R
https://www.gallup.co.kr/gallupdb/reportContent.asp?seqNo=1309

 

추가로, PC를 다루는 사람보다 모바일 기기를 다루는 사람들이 더 많다는 점도 있다. 2021년 기준 60대 이상의 1개월 이내 컴퓨터 이용자 비율은 42.2%, 70대 이상은 14.3%에 불과하지만 60대 이상의 스마트폰 사용률은 2021년 기준 83%, 2022년 6월에는 90%까지 상승했다. 마찬가지로 50대의 1개월 이내 컴퓨터 이용자 비율은 71%에 불과하지만, 같은 시기의 스마트폰 이용자 비율은 99%이다. 물론 모든 유저가 게임을 플레이한다고 할 수는 없겠지만, 그것은 컴퓨터도 마찬가지이다. 기기를 보유한 사람이 많다는 얘기는 유저풀이 그만큼 넓다는 이야기이기도 하고, 해당 기기에서 이용 가능한 게임의 접근성이 더 높다는 얘기도 될 수 있다.

 

 

정리하자면, PC 게임은 모바일 게임과 비교하여 설치, 실행 과정이 복잡하고 느리다. 마찬가지로 플레이를 할 수 있는 환경도 모바일에 비해서 상당히 제한되어 있으며 절대적인 유저풀도 좁기 때문에 PC 게임은 모바일 게임이 가진 접근성이 훨씬 떨어질 수 밖에 없다는 것이다.

 

 

모바일 게임과 PC 게임의 차이와 국내 모바일 게임 시장

이제 모바일 게임과 PC 게임의 차이를 생각하며 국내 모바일 게임 시장을 살펴보겠다. 모바일 게임은 PC 게임에 비해 플레이 환경이 열악하기 때문에 유저를 몰입시키기도 힘들고, 컨트롤도 불편하며 게임성도 떨어질 수밖에 없다. 반면에 접근성이 훌륭하다는 장점을 가지고 있다. 따라서 이런 접근성이라는 장점을 극대화시키는 방향으로 발전해온 것으로 보인다.

 

https://www.mobileindex.com/mi-chart/weekly-rank/revenue

 

국내 마켓에서 최상위권을 차지하고 있는 MMORPG 게임들은 전부 자동사냥이 있는 것으로 파악된다. 열악한 플레이 환경을 극복하기 위해 MMORPG에서 가장 많은 시간이 드는 사냥을 자동으로 전환한 것이다. 설치와 실행, 플레이도 간편하기 때문에 빠르게 접속해서 자동사냥을 걸어놓고 방치하는 것도 어렵지 않다. 좋은 접근성을 활용해서 생각날 때마다 들어가서 아이템들을 정리해주는 등 보상을 얻는, 유저에게 즐거움을 주는 부분만 남겼다. 모바일에 많이 존재하는 방치형 게임도 이와 비슷한 부류라고 생각한다.

 

블루 아카이브와 같은 게임의 경우, 유저의 게임 플레이를 제한하여 의도적으로 가벼운 플레이를 하게끔 만들어서 서브 게임으로서의 포지션을 노린 것으로 보인다. 모바일 게임은 설치, 실행, 플레이의 접근성이 뛰어나기 때문에 컴퓨터 게임을 하면서 플레이를 할 수도 있고, 다른 모바일 게임을 하면서도 중간에 잠깐씩 플레이하기도 좋다. 적은 경쟁 요소로 유저의 피로도를 줄이면서도 약간의 경쟁 요소는 유지하고, 또 오타쿠들의 충성심을 이용하여 매출도 잘 뽑아내고 있다.

 

우마무스메 프리티 더비와 같은 게임의 경우, 조작의 피지컬적인 요소를 배제하여 조작을 편리하게 만들었다. 단순히 터치만을 이용해 조작을 하며, 유저가 게임 내에서 영향을 미치는 것은 유저가 어떤 선택을 해서 어떤 요소를 터치하느냐이다. 한 손으로도 충분히 플레이를 할 수가 있으며, 언제든지 중간에 멈추거나 재개할 수도 있다. 하지만 경마라는 주제의 특성상 경쟁 요소가 많은 부분을 차지하고 있고, 특히 챔피언스 미팅의 존재로 인해 체감상 경쟁이 차지하는 비율이 크다. 여기서 우승하기 위해서는 꾸준한 플레이와 투자가 필요하다. 가치가 높은 게임 플레이는 하루에 5번까지 가능한데, 대략 2시간 혹은 그 이상이 걸린다. 투자의 경우, 흔히 가챠라고 얘기하는 뽑기가 필요한데, 뽑기의 난이도가 어렵고 부담감이 매우매우 크다. 이런 요소들로 인해 유저가 많은 피로도를 느끼게 되는 게임이기도 하다. 하지만 이런 단순한 조작만으로도 메인 게임의 자리를 노릴 수 있다는 점이 상당히 흥미로운 게임이다.

 

반대로 탕탕특공대, 궁수의 전설이나 각종 퍼즐, 러닝 액션 게임과 같이, 피지컬적인 요소는 유지하면서 조작을 편리하게 만든 게임들도 있다. 이런 게임들은 보통 한 손으로도 플레이가 가능하며, 탭이나 슬라이드 등 간편한 조작이지만 유저의 피지컬적인 요소를 유지하면서 편리하고 또 즐겁게 게임을 할 수 있게끔 만들었다.

 

이외에는 FIFA모바일이나, 원신 등과 같이 컴퓨터에 비해 다소 불편하지만 모바일에 친화적인 플레이 방식을 이식하고, 게임 플레이 자체도 컴퓨터에서 하는 것과 거의 동일하게 이식한 게임도 존재한다. 모바일 게임에서도 좋은 게임성을 원하는 유저들도 있을 것이기 때문에, 이런 유저들을 타겟으로 한 게임으로 생각된다.

 

 

결과적으로 모바일 플랫폼에서 매출 순위가 높은 게임들을 살펴보았을 때, 게임성을 약간 포기하되 접근성이라는 장점을 많이 활용한 게임들이 대부분을 차지하는 것 같다. 물론 뛰어난 게임성을 가진 게임들도 있지만, 이런 게임들은 대부분 PC에 이미 있는 게임을 모바일로 이식한 게임이기 때문에 예외로 두어도 무방할 것 같다. 

해외의 경우는 우리나라와는 약간 다르기도 한 것 같은데, PC 보급률이라든지, 이동시간이라든지, PC방의 대중성 등 다양한 요인들이 있을 것 같다. 하지만 적어도 우리나라에서 모바일 게임은 접근성을 잘 활용한 게임이 주류인 것으로 보인다. 물론 접근성을 잘 활용하면서 게임성을 얼마나 잘 살리느냐가 관건일 것이다.

 

 

 

이야기가 참 길었던 것 같다. 요약하자면 모바일 게임은 PC 게임에 비해 플레이 환경이 열악하지만 접근성이 좋기 때문에 이런 장점을 활용하기 위한 형태로 모바일 게임들이 발전하였고, 그 결과 주류 모바일 게임들의 형태가 지금과 같아졌다는 것이다. 자동 사냥을 통한 방치형 게임이라든가, 애초부터 서브 게임 포지션을 노리고 가볍게 만든 게임이라든가, 피지컬적인 요소를 배제한 게임도 있고, 조작을 단순하게 만든 게임도 있다. 어찌되었든 결과적으로는 게임이 전체적으로 단순해지는 형태로 변했다. 국내 모바일 게임은 게임성이 떨어진다며 비판하는 사람들도 있고, 개인적으로도 아쉬운 부분이 있다고 생각하지만 이렇게 생각해보니 어쩔 수 없다고도 느껴지는 것 같다. 앞으로 모바일 게임을 개발하면서 지금 정리한 내용들을 바탕으로 어떻게 하면 모바일 게임이 가진 접근성을 최대한 이끌어낼 수 있을지 생각하면서 개발해야겠다.

Tucker님의 "게임 네트워킹의 이해" 영상을 시청한 내가 Race Condition이라는 것에 대해 내가 알고 있었던 내용은, 멀티 쓰레드 방식에서 공유 자원에 접근하는 순서에 따라 결과가 달라질 수 있다는 것이었다. 또한 해당 영상에서는 "Locking을 쓰다보면 서로 간에 경쟁이 심해져서 Race Condition이 발생한다" 고 설명을 해서 Lock을 건 상태에서 발생하는 문제라고 이해하고 있었다. 그렇기 때문에 Race Condition 문제를 어떻게 해결하느냐? 라는 질문에 대해서 전혀 답을 낼 수 없었다. Lock을 걸은 상태이기 때문에 들어오는 순서에 따라 결과가 달라진다면, 들어오는 순서를 항상 일정하게 보장해줘야 할텐데, 언제 누가 들어올지도 모르는 상황에서 순서를 정한다는 것이 말이 안된다고 생각했기 때문이다.

사실 Race Condition은 Lock을 걸지 않은 상태에서 공유 자원에 동시 접근할 때 생기는 문제이다. 완전히 잘못 이해하고 있었다. 다른 개념일지는 모르겠지만, 적어도 찾아본 바에 의하면 영상에서 개념을 잘못 설명해준 것이다. 간단하게 해결책은 Lock을 걸어준다. 라고도 할 수 있을 것 같은데, 나는 Lock이 이미 걸려있는 상태에서 발생하는 문제라고 인식을 했기 때문에 완전히 번지수가 틀렸던 것이다.

 

그래서 오늘은 Race Condition, 그리고 이에 관련된 동기화, 임계 영역, 스핀락, 뮤텍스, 세마포어에 대해 자세히 알아보고 또 정리하고자 한다.

 

경쟁상황 (Race Condition), 뮤텍스, 세마포어 (tistory.com)

 

경쟁상황 (Race Condition), 뮤텍스, 세마포어

경쟁 상황(Race Condition)이란? 동시에 여러 개의 프로세스가 동일한 자료를 접근하여 조작하고, 그 실행 결과가 접근이 발생한 특정 순서에 의존하는 상황을 경쟁 상황(race condition)이라고 합니다.

yongc.tistory.com

https://youtu.be/vp0Gckz3z64

https://youtu.be/gTkvX2Awj6g

맨 위 링크의 글과 2개의 유튜브 영상을 참조했다. 글도 잘 적혀있는 것 같은데, 이해가 살짝 어려운 부분이 있어서 쉬운코드님의 유튜브 영상을 함께 참조했다. 확실히 유튜브 영상이 잘 모르는 사람들을 배려해주는 경향이 있어서 이해하기 편한 것 같다.

 

 

가장 먼저 Race Condition이란, 여러 프로세스/쓰레드가 동시에 같은 데이터를 조작할 때 타이밍이나 접근 순서에 따라 결과가 달라질 수 있는 상황을 의미한다.

https://yongc.tistory.com/53#2.%20%EA%B2%BD%EC%9F%81%20%EC%83%81%ED%99%A9%20%EC%98%88%EC%8B%9C

위와 같은 코드에서 thread_1을 쓰레드1이 실행하고, thread_2를 쓰레드2가 실행한다고 가정하면, 순서에 상관없이 각각 총 1000만씩을 더하고 빼기 때문에 count는 0이 될 것이라고 생각할 수 있지만, 실제로는 그렇지 않을 가능성이 있다.

 

https://yongc.tistory.com/53#2.%20%EA%B2%BD%EC%9F%81%20%EC%83%81%ED%99%A9%20%EC%98%88%EC%8B%9C

count++와 같이 ++ 연산자는 왼쪽 프로세스 A에 있는 3줄의 동작을 수행한다. 문제는 이 일련의 동작을 수행하는 도중에 컨텍스트 스위칭이 발생할 수 있다는 것이다. 프로세스 A에서 수행을 하다가 중간에 멈추고 프로세스 B를 수행하게 되는 것이다. 그러면 프로세스 B를 돌면서 -1을 1000만번 반복하여 count에 -1000만이 들어가게 된다. 문제는 이 다음인데, 위의 사진에서 컨텍스트 스위칭이 발생한 지점에서 다시 실행한다면, 레지스터에 저장된 +1이라는 값이 count에 들어가는 부분부터 시작하게 된다. 즉, count가 +1로 덧씌워지는 것이다. 프로세스 B에서 연산했던 값이 사라지는 셈이다. 이 경우, 2개의 프로세스가 완료되었을 때 count에는 1000만이라는 값이 저장된다.

반대로, 프로세스 B가 먼저 실행되다가 동일한 상황에서 A로 컨텍스트 스위칭이 발생한다면, 2개의 프로세스가 완료되었을 때 count에는 -1000만이라는 값이 저장될 것이다. 즉, 접근 순서에 따라 결과가 달라진 것이다. 만약 컨텍스트 스위칭이 "레지스터가 count에 값 반환" 이후에 일어난다면, 결과는 0이 될 것이다. 이 경우는 타이밍에 따라 결과가 달라지는 셈이다.

 

따라서 Race Condition에서 중요한 것은 동기화를 하는 것이다. 동기화란, 여러 프로세스/쓰레드를 동시에 실행해도 공유 데이터의 일관성을 유지하는 것이다. 싱글 코어의 경우, 위의 예에서 인터럽트가 발생하지 않도록 해주는 방법이 있다. 다만 멀티 코어의 경우에는 여러 코어에서 동시에 프로세스를 실행할 때 문제가 발생할 수 있기 때문에 활용하기 어려운 방법이다. 가령 "레지스터에 count 할당" 부분을 두 코어에서 동시에 실행한다면, 두 프로세스가 돌아도 값은 1번만 증가할 것이다. 따라서 현실적인 방법은 count++ 혹은 count-- 부분에 한 번에 한 쓰레드만 접근할 수 있도록 제한을 걸어주는 것이다. 이를 위해 임계 영역(critical section)이라는 것을 설정한다. 임계 영역이란, 공유 데이터의 일관성을 보장하기 위해 하나의 프로세스/쓰레드만 진입해서 실행 가능한 영역을 의미한다.

https://youtu.be/vp0Gckz3z64

위 사진처럼, critical section에 진입하기 전에, entry section에서 현재 진입이 가능한지 여부를 체크하고, 진입이 가능하다면 다른 쓰레드가 접근하지 못하도록 막아준다. 이후 critical section을 실행하고 벗어날 때 exit section에서 다른 쓰레드가 접근할 수 있도록 작업을 해준다. 이 세 가지 sction을 제외한 section이 remainder section이다. 이렇게 하면 한 번에 하나의 쓰레드만 임계 영역에 접근할 수 있게 되는 것이다.

또한, 이 방식을 제대로 활용하기 위해서 지켜야하는 3가지 조건이 있다. 상호 배제(mutual exclusion), 진행(progress), 한정된 대기(bounded waiting)이다. 먼저 상호 배제는 한 번에 하나의 프로세스/쓰레드만 임계 영역에 접근할 수 있도록 해야한다는 것이다. 진행은 임계 영역이 비어있고, 임계 영역에 진입을 원하는 프로세스/쓰레드가 있을 경우, 실행할 수 있도록 해야 한다는 것이다. 마지막으로 한정된 대기는, 어떤 프로세스/쓰레드가 임계 영역에 들어가기 위해 무한정 대기하는 상태가 되면 안된다는 것이다. 우선순위 스케줄링에서는 대기 시간에 따라 우선순위를 높여주는 방식을 사용해서 이것을 방지하는데, 임계 영역을 구현할 때도 이런 식으로 기아 상태가 발생하지 않도록 방지해줘야 한다는 것이다.

 

https://youtu.be/vp0Gckz3z64

 

mutual exclusion을 보장하는 방법은 lock을 사용하는 것이다. 이전 사진의 entry section에서 acquire lock으로 lock을 획득하여 lock을 획득한 프로세스/쓰레드만 critical section에 접근할 수 있다. 이후 exit section에서 release lock으로 lock을 풀어주어 다른 프로세스/쓰레드가 접근할 수 있도록 한다. 이 lock을 거는 방법은 크게 3가지가 있는데, 각각 스핀락, 뮤텍스, 세마포어 방법이다. 

 

*아래 코드들은 이해를 돕기 위한 예시이며 구체적인 동작 방식은 OS, 프로그래밍 언어에 따라 다를 수 있음

 

https://youtu.be/vp0Gckz3z64

먼저 스핀락(spinlock)방식에 대해서 살펴보겠다. 위 사진에서는 lock 0일 때 임계 영역에 접근이 가능해진다. 만약 lock이 1이라면 while문을 계속 반복해서 돌면서 lock을 얻어올 때 까지 대기하게 된다. 임계 영역에 이미 진입한 프로세스/쓰레드가 있다면 임계 영역 실행을 마치고 lock을 0으로 초기화시켜준 이후에 다른 프로세스/쓰레드가 접근할 수 있게 된다.

 

+ TestAndSet는 CPU의 atomic 명령어로, 실행 중간에 간섭받거나 중단되지 않으며, 같은 메모리 영역에 대해 동시에 실행되지 않는다. 즉, lock을 얻어오는 요청을 동시에 실행한다고 하더라도 동시에 실행되지 않는다.

 

이 방식의 문제점은 lock을 얻어오기 위해 무한정 대기하기 때문에 CPU를 낭비한다는 점이다. 그동안 다른 작업을 처리할 수도 있는데 말이다. 때문에, lock을 얻기 위한 목록인 queue에 등록하고, lock 준비되면 queue에 있는 프로세스/쓰레드 중 하나를 깨우는 방식이 바로 뮤텍스(mutex) 방식이다.

 

https://youtu.be/gTkvX2Awj6g

뮤텍스 방식에서는 lock을 얻어올 수 없다면 현재 쓰레드를 큐에 넣고, 임계 영역을 마칠 때, 큐에 대기 중인 쓰레드가 있다면 그 중에 하나를 깨운다. 없다면 lock을 풀어서 다른 쓰레드가 접근할 수 있도록 해준다. guard는... 위에 왜 저렇게 쓰여 있는지나 영상을 봐서는 이해하기가 조금 어려운데, 검색해본 바에 의하면 C++11부터는 guard를 호출할 때 lock이 호출되고, guard가 소멸할 때 unlock이 호출된다는 것 같다. unlock을 까먹을 가능성을 차단해줘서 조금 더 편리하게 사용할 수 있는 것 같다. C++17부터는 scoped_lock이라는 것이 다중 잠금이 가능하여 lock_guard를 대체할 수 있는 것 같다.

 

보통은 뮤텍스 방법이 CPU를 놀리지 않기 때문에 안정적이고 효율적인 방법이지만, 멀티 코어 환경이고 임계 영역 내에서의 작업이 컨텍스트 스위칭보다 더 빨리 끝난다면 스핀락이 더 빠를 수 있다고 한다. 뮤텍스 방법은 큐에 대기 중인 쓰레드를 깨워서 컨텍스트 스위칭을 하기 때문에, 이것보다 빠르게 임계 영역에서의 작업이 끝나면 더 빠르게 다음 쓰레드로 넘어갈 수 있다는 것이다. 싱글 코어에서는 스핀락에서도 컨텍스트 스위칭이 필요하기 때문에 이점이 전혀 없다고 한다.

 

https://youtu.be/gTkvX2Awj6g

마지막으로 세마포어 방법은 signal mechanism을 가진, 하나 이상의 프로세스/쓰레드가 임계 영역에 접근 가능하도록 하는 장치를 이야기한다. 뮤텍스 방법과의 차이점은, lock이 wait이 되었고, unlock이 signal이라는 이름으로 변경되었다. 그리고 lock을 1, 0으로 설정해주는 것이 아니라 1만큼 더하고 뺀다. 초기 value가 1이라면 동시에 1개만 접근할 수 있지만, 초기 value를 2, 3, 4 등등으로 설정하여 한 번에 초기 value값 만큼의 프로세스/쓰레드가 임계 영역에 접근할 수 있도록 설정할 수 있다. 그리고 세마포어 방법은 signal mechanism을 가졌기 때문에 순서를 정해줄 때 사용할 수 있다.

 

https://youtu.be/gTkvX2Awj6g

위 그림의 경우, task2가 끝난 이후에 wait()를 통해 task1이 끝난 이후 전송되는 signal()을 기다린다. 이 경우, 반드시 task1이 실행된 이후에 task3가 실행된다. task3의 작업을 하기 위해 task1이 실행되어야 하는 경우에 이렇게 구현할 수 있다는 것이다.

 

 

마지막으로, 세마포어 방식의 초기 값이 1인 경우에 뮤텍스와 동일하게 보일 수 있지만, 실제로는 차이가 있다고 한다. 먼저 세마포어 방식은 우선순위가 높은 스레드가 락을 해제할 수 있다. 반면에 뮤텍스는 락의 소유자만 락을 해제할 수 있기 때문에, 어떤 프로세스가 락을 가진 상태에서 컨텍스트 스위칭이 되어 우선순위가 높은 프로세스가 해당 락에 접근하게 되면, 우선순위가 낮은 프로세스에 묶이게 되는 문제가 생긴다. 이를 해결하기 위해서 우선순위가 높은 프로세스가 얻으려고 하는 락을 가진 프로세스의 우선순위를 접근하려는 프로세스와 동등하게 높여주는 방식을 사용한다고 한다. 따라서 뮤텍스는 priority inheritance 속성을 가진다고 한다.

 

 

 

이렇게 Race Condition에 대해서 자세히 알아보고 정리해보았다. 새로운 것에 대해서 알아가는 것이 힘들기도 하지만 또 한편으로는 재밌기도 하고, 또 나의 지식을 확장시키는 일이기 때문에 만족감도 든다. 다만 알아갈수록 내가 모르는 영역이 참 많구나 하는 것을 새삼 깨닫기도 한다. 그래도 오늘 하루 또 새로운 지식을 얻은 것에 대해서 만족한다.

이전에 보았던 Tucker님의 게임 네트워킹의 이해 영상을 마저 시청하였다. 오늘은 Socket Programming과 MMORPG 서버 구조 편까지 전부 시청하여 마무리 정리까지 해보려고 한다.

 

https://youtu.be/aWq9C7RARJI

 

 

1. Socket Programming

컴퓨터의 네트워킹은 NIC에서 이루어진다. NIC에서 데이터를 주고 받는 것을 제어할 수 있게 만들어주는 것을 Socket이라고 하고, 이 Socket을 이용하여 Programming을 하는 것을 Socket Programming이라고 한다. 자세히 보면 복잡하긴 하지만 궁극적으로 추구하는 것은 Input과 Output을 다루는 것이다. 이 I/O를 다룰 떄 중요한 기능 2가지가 Read와 Write이다. Read와 Write를 하는 방식은 크게 2가지가 있는데, 동기식과 비동기식이다. 동기식은 Read를 요청하면 완료될 때까지 대기하는 방식이다. C언어에서 scanf를 사용하면 입력을 받아올 수 있는데, 입력이 될 때까지 프로그램이 잠시 멈춘다. 입력이 들어오면 해당 지점에서 프로그램을 재개하는 식이다. 이 방식의 문제점은 커넥션이 1개 뿐이라면 기다려도 상관없지만, 커넥션이 많아진다면 여러 커넥션을 처리해야하는 서버의 입장상 하나의 커넥션을 위해 계속 대기하고 있는 것이 굉장히 비효율적이라는 것이다. 때문에 입력이 올 때까지 기다리는 것이 아니라, 요청을 보내놓고 다른 일을 처리하다가 입력이 들어오면 일을 처리하도록 해야 한다. 이것이 바로 비동기식이다. 비동기식에는 크게 2가지 방법이 있는데, Select와 WIndows의 IOCP이다. Select 방식은 Read와 Write를 할 수 있는지 지속적으로 확인을 하고, 사용할 수 있는 상태라면 직접 처리하는 방식이다. IOCP 방식은 이런 작업을 OS에게 요청하고 위임하는 방식이다. 요청을 보낼 때 OS에게 요청을 하고, 해당 요청에 대한 응답이 오면 OS가 해당 응답을 받아서 Queue에 쌓아놓는다. 작업을 진행하기 위해서 Queue를 확인하고, 응답이 왔다면 바로 처리할 수 있다. Select 방식은 구현하기 쉽다는 장점이 있고, IOCP 방식은 성능면에서 효율적이라는 장점이 있다. 두 방식 모두 많이 사용하지만, 커넥션 수가 많아지면 IOCP 방식을 사용하는 것이 좋다고 한다.

 

 

2. MMORPG 서버 구조

MMORPG 서버 구조에 대한 이야기는 싱글 쓰레드/멀티 쓰레드와 싱글 프로세스/멀티 프로세스에 대한 이야기가 전부인 것 같다. 그런데 이 이야기가 약간 복잡하다. 먼저 MMORPG 서버의 특징은 작업량이 많다는 것이다. 플레이어가 적게는 수십, 많게는 수천 명을 한꺼번에 컨트롤해야하고, NPC라든지, 흔히 바닥이라고 얘기하는 Control Zone 등 아주 많은 정보를 처리해야한다. 서버는 패킷 처리는 물론이고, 매 프레임마다 Actor(플레이어, NPC 등)들을 업데이트시키는 Tick작업을 해줘야하고, 그 외 경매장 등의 시스템도 처리해야한다. 게임을 10프레임으로 돌린다고 하면 초당 10번의 Tick작업을 해줘야하고, 그 말은 100ms의 시간 안에 모든 작업을 끝내야 한다는 것이다. 만약 5000명의 Actor를 처리해야 한다면 하나의 Actor당 처리시간이 0.02ms밖에 주어지지 않는다. 이것은 굉장히 어려운 일이기 때문에 이를 보완하기 위해 여러 방법들을 사용한다. 먼저 멀티 쓰레드를 활용하는 방식이다. 쓰레드 수를 4개로 늘린다면 1개당 100ms라는 시간이 주어지는 것이기 때문에, 4개의 쓰레드를 동시에 돌린다면 400ms의 시간이 주어지는 셈이다. 서버의 부담이 1/4로 줄어든다. 물론 멀티 쓰레드를 돌리면 신경써야 할 것들이 많아서 4개로 늘린다고 성능이 4배로 늘어나진 않겠지만, 잘 구현해놓면 싱글 쓰레드에 비해 부담이 훨씬 줄어들 것이다. 다만 문제가 있다면, 멀티 쓰레드는 방금 언급했다시피 신경써야 할 것들이 상당히 많아서 구조를 잘 짜놔야 한다는 것이다.

 

먼저 싱글 쓰레드는 Work Thread가 1개인 것을 이야기한다. 즉, 네트워크의 I/O를 처리하는 쓰레드는 따로 있고, 들어온 데이터들을 Queue에 계속 저장한다. Work Thread에서는 이것들을 활용해서 Tick 작업을 해주는 것이다. 단점이 있다면 잘 구현된 멀티 쓰레드에 비해서 처리 가능한 Actor 수가 적을 수 있다는 점이지만, 굉장히 Simple 구조를 가지고 있기 때문에 멀티 쓰레드 환경에서 일어나는 많은 문제들이 일어나지 않는다. 그렇기 때문에 싱글 쓰레드라고 해서 무조건 나쁜 것은 아니라고 한다. 이 싱글 쓰레드에 Dedicate Thread를 추가하여 DB나 경매장만 담당하는 Thread를 추가하는 방법이 있다. 이 방법은 싱글 쓰레드의 Simple함을 유지하면서 싱글 쓰레드의 부담을 줄여줄 수 있다는 장점이 있지만, 여전히 처리 가능한 Actor 수의 한계가 있다.

멀티 쓰레드는 Work Thread가 여러 개인 것을 이야기한다. 싱글 쓰레드와 마찬가지로 네트워크의 I/O를 처리하는 쓰레드는 따로 있고, 저장된 Queue에서 데이터를 가져와 Tick 작업을 여러 쓰레드에서 하는 것이다. 멀티 쓰레드에서는 많은 문제들이 발생할 수 있는데, 한 쓰레드에서 공유 자원에 접근할 때, 다른 쓰레드에서 접근하지 못하도록 Locking을 걸어줘야 한다. 가령 hp가 50인 상태에서 동시에 두 적에게 10데미지, 20데미지를 받는다고 치자. 그러면 체력이 20이 되어야겠지만, hp가 50인 상태를 동시에 가져가버려서 한 쓰레드는 10데미지를 입혀 체력을 40으로 갱신하고, 다른 쓰레드는 현재 hp를 50으로 받았기 때문에 20 데미지를 입혀서 체력이 30이 된 것을 거기에 덮어씌운다면 정상적으로 처리되지 않을 것이다. 때문에 다른 쓰레드에서 접근하지 못하도록 Locking을 해줘야한다. 그런데 MMORPG는 Actor간에 Interaction이 굉장히 빈번하게 일어나기 때문에 이 문제를 어떻게 다루느냐가 굉장히 중요한 문제이다. 여기서 구역을 나누는, 구획화를 어떻게 하느냐가 중요하다.

 

이를 쉽게 해결할 수 있는 방법이 멀티 프로세스이다. 싱글 쓰레드, 멀티 쓰레드는 각각 하나의 프로세스 안에 존재하는 쓰레드의 수를 이야기한다. 즉, 프로세스를 여러 개 돌리고 각각의 프로세스를 싱글 쓰레드로 돌리면 멀티 쓰레드와 같이 성능을 향상시킬 수 있고, 각각의 프로세스는 자원을 공유하지 않기 때문에 멀티 쓰레드에서 일어나는 문제들도 발생하지 않게 된다. 다만, 이 경우 문제가 있는데, 각 프로세스에서 처리하는 정보들에 바로 접근할 수 없기 때문에, A프로세스에 존재하는 유저는 B프로세스에 존재하는 유저를 공격할 수 없다는 문제가 있다. 이것은 대륙을 나눠서 각각의 대륙을 하나의 프로세스가 처리하게 하고, 유저가 넘어갈 때 로딩을 적용해서 다른 프로세스로 넘겨주는 방식을 사용하여 해결할 수 있다. 그게 아니라 만약 하나의 대륙에서 구획을 나누는 방식을 사용한다면 구획과 구획이 만나는 가장자리에 서있는 유저들을 처리하기 위해서 각종 눈속임을 써야한다는 문제가 있다. 때문에 로딩 등 끊김이 없는 Seamless 월드를 만들 계획이 있다면 멀티 쓰레드를 활용하는 것이 좋다고 한다.

 

싱글 프로세스에서는 기기 성능을 향상시키기 위해서는 좋은 기기를 한 대 구입해야한다. 이를 Scale Up이라고 한다. 그러나 기기의 성능이 점점 좋아질수록 성능이 조금만 좋아져도 가격이 기하급수적으로 올라가고, 머신의 성능과 처리 성능이 비례하여 증가하지 않기 때문에 이 방법에는 한계가 있다. 아마 처리하는데 고정적으로 들어가는 비용들이 있다보니 머신 성능과 처리 성능이 비례하지 않는 것 같다. 보통 머신 성능이 좋아질수록 처리 성능의 증가폭은 줄어든다고 한다. 반면에 멀티 프로세스는 적당한 성능과 가격의 기기를 여러 대 들여놓는 Scale Out 방식을 사용하면 되는 것이기 때문에, 싱글 프로세스에 비해 비용 부담이 상당히 적다. 머신은 싸다 라는 말이 있다는데, 비싼 돈을 주고 코딩을 잘 하는 프로그래머를 고용하는 것 보다, 머신의 수를 늘리고 싼 프로그래머를 기용하는 것이 더 효율적이라는 얘기가 있다고도 한다. 멀티 프로세스를 구현하기 위해서 실제로 머신을 구입할 필요는 없고, AWS와 같이 클라우드를 통해 비용을 지불하고 머신의 성능을 가져다가 쓰는 방법이 있다고 한다. 또한 가용성이라는 것이 있는데, 가령 싱글 프로세스로 서버가 돌아갈 때 이 프로세스가 죽으면 해당 서버는 완전히 다운되어버린다. 멀티 프로세스로 서버를 돌린다면 하나의 프로세스가 죽어도 나머지 프로세스로 서버가 돌아갈 수 있다는 것이다. 구현 내용에 따라 속도가 느려지거나 일부 지역이 봉쇄될 수는 있겠지만, 전체 서버의 다운은 막을 수 있따는 것이다. 이것이 가용성이 높다고 한다. 즉 싱글 프로세스는 가용성도 낮고 비용도 높지만, 멀티 프로세스는 가용성도 좋고 비용도 적다. 때문에 Scale Up 방식에서 Scale Out 방식으로 변화하고 있다고 한다. 물론 싱글 프로세스의 장점도 있는데, 구조가 단순하고, 위에서 언급한 Seamless 월드, 즉 하나의 통합된 월드를 만들 수 있다. 더욱이, 버전업을 할 때 각 프로세스에 배포를 해야하는 멀티 프로세스와는 달리 프로세스 하나만 버전업을 하면 되고, 수십~수백 개의 프로세스를 사람이 직접 관리하기는 힘든 멀티 프로세스와 달리 싱글 프로세스는 사람이 관리할 수 있을 정도의 규모가 될 수 있다는 것이다. 멀티 프로세스는 이렇게 많은 프로세스를 가진 서버가 여러 개 있을 수 있기 때문에 사람이 직접 관리하기는 힘들고, 이를 자동으로 관리해줘야한다고 한다. 이를 위한 툴들이 Jenkins, Ansible, Chef 등 아주 많다고 한다. 이런 것들을 전문으로 하는 DevOps라는 직업군까지 생겼다고 할 정도이다. 하지만 이렇게까지 해서라도 가용성과 비용적인 측면 때문에 멀티 프로세스를 많이 사용한다고 하고, 마찬가지로 이런 문제 때문에 싱글 프로세스도 완전히 안 좋은 방법은 아니라는 것이다. 멀티 프로세스를 사용하는 방법은 아까 말했듯이 구역을 나누는 Zone 방법이 있고, 하나의 구역에 채널을 여러 개 두는 Channel 방법이 있다고 한다. 또, 두 가지 방법을 모두 사용할 수 있다. 이 경우 프로세스들이 너무 많아져서 이 프로세스들을 관리하는 서버가 따로 필요할 수도 있고, 배포나 빌드, 이슈를 모니터링하여 판단하고 복구하는 작업 등 많은 문제들이 발생한다고 한다. 이런 것들을 잘 컨트롤해야 좋은 멀티 프로세스 서버를 만들 수 있다고 한다.

추가로, 멀티 프로세스에서는 각각의 프로세스에 싱글 쓰레드를 쓸 수도 있고, 멀티 쓰레드를 쓸 수도 있지만, 싱글 프로세스 방식에서는 아무래도 성능 상의 문제 때문에 멀티 쓰레드를 사용할 수 밖에 없다고 한다.

 

이제 마지막으로 멀티 쓰레드에 대한 얘기이다. 멀티 쓰레드를 사용할 때, 코어 수가 늘어날수록 더 많은 쓰레드들이 자원을 점유하려고 하기 때문에 성능 증가 폭이 상당히 줄어들고, 코딩 하기에 따라 오히려 성능이 줄어들 수도 있다. 떄문에 싱글 쓰레드도 현명한 선택일 수 있다. 그럼에도 불구하고 멀티 쓰레드를 사용하려면 이런 문제로 인해 공유 자원에 대한 접근을 잘 관리할 필요가 있다. 만약 무식하게 구현하여 자원에 마구 접근하고, 접근할 때마다 마구 Lock을 건다면 성능도 떨어지고, 무분별한 접근과 Lock으로 인해 대기 시간도 늘어나고, Deadlock이 발생할 가능성도 높아진다. 또, Race Condition이라는, 어떤 쓰레드가 먼저 접근하는지에 따라 다른 문제가 발생하여 Timing 문제가 발생할 수도 있다. 때문에 공유 자원을 어떻게 분배할 것이냐에 대한 기준을 세워야 한다. 첫 번째로는 Actor를 기준으로 나눌 수 있다. AKKA 방식이라고 하는데, Actor를 기준으로 쓰레드를 나눈다. 때문에 각각의 Actor가 할 일은 해당 Actor를 가진 쓰레드가 처리하면 되는 것이다. 상호작용을 할 때는, 다른 Actor에게 메세지를 보내고, 해당 Actor가 그에 대해 응답을 한다. 즉, 직접적으로 자원에 접근하는 것이 아니라 공유 자원에 대한 여러 문제가 발생하지 않는다. 하지만 메세지를 보내고 그에 대한 응답이 와야 처리를 할 수 있기 때문에 비동기식이라는 문제점이 있다. 또한, A가 B를 공격하기 위해 데이터를 받아오고 처리하는 도중에 C가 B를 공격하기 위해 데이터를 받아온다면, A의 공격이 B에게 적중하기 전의 데이터가 들어갈 것이기 때문에 Timing 관련 이슈가 생긴다는 것이다. 마찬가지로 Lock을 걸면 이런 문제가 해결되겠지만, Lock을 걸면 성능이 저하되고, Deadlock이 발생할 수도 있고 많은 문제들이 발생한다. 메세지를 사용한다면 복잡도가 올라가고 비동기식이 된다는 문제점이 있다. 결국 모든 것은 일장일단인 것이다. Trade Off라고도 하는데, 어떤 것의 장점을 취하려고 한다면 그에 대한 단점도 따라오는 것이고, 어떤 것의 단점을 없애기 위해 장점을 포기해야할 수도 있는 것이다. 안정성을 올리면 성능이 떨어지는 것이 대표 예라고 할 수 있겠다. ECS 방식도 있는데, Entity, Component, System 방식이라고 한다. Entity는 Conponent를 갖고 있는 객체이고, Component는 데이터만을 가지고 있다. 그리고 System이 각 Entity가 가진 Component의 데이터를 사용해서 기능을 처리하는 식으로 나누는 것이다. 이 경우 객체에 어떤 Component를 붙이느냐에 따라서 플레이어, NPC, 몬스터, 자유자재로 만들 수 있기 떄문에 확장성이 좋아지고, 데이터의 Locality가 좋아진다고 한다. 데이터의 Locality란, 같은 데이터가 얼마나 같은 공간에 밀집해있느냐이다. 가령 Render System이 화면을 그릴 때, 화면을 그리는 것과 관련된 Component들을 전부 반복문을 돌려 한꺼번에 처리하면 같은 공간에 데이터들이 모여있게 될 것이다. 캐시에 데이터를 올릴 때, 일정 영역의 데이터를 한꺼번에 올리기 때문에 이렇게 데이터의 Locality가 좋아지면 성능이 향상된다는 것이다. 특히 게임에서는 화면을 그리는 것이 성능의 90%를 차지할 정도로 많은 성능을 요구하기 때문에, 이런 성능의 향상이 크게 작용할 수 있다. 다만, 문제가 있다면 하나의 시스템이 하나의 Component만 가지고 있는 것이 아니라, Component를 공유할 수 있기 때문에 마찬가지로 점유의 문제가 생길 수도 있고, 다른 시스템에게 물어봐야 하는 상황이 올 수 있기 때문에 AKKA 방식과 마찬가지로 메세지로 처리해야할 수도 있다. 또한 ECS 방식이 만들어진지 오래 되지 않아서 아직까지 많이 사용되지 않고 정보도 그렇게 많지는 않은 것 같다.

 

 

 

드디어 정리가 끝난 것 같다. 이로써 게임 네트워킹의 이해를 전부 시청하고 정리하게 되었다. 내 기억에 의존하거나, 기억이 안 나는 부분은 다시 듣기도 하고, 내 방식대로 풀어서 설명했기 때문에 잘못된 부분이 있을 수 있다. 그래도 들으면서 이렇게 글로 나름대로 풀어서 쓰는 것이 이해에 상당히 도움이 된 것 같다. 코드를 짜거나 본 것도 아니고, 아마 많은 부분 중에서도 빙산의 일각, 하물며 겉핥기 식일 테지만 이런 기초적인 개념이라도 있는 것과 이것조차 없는 것은 굉장히 큰 차이라고 생각하기 때문에 나로서는 굉장히 만족스러운 시간이었다. 또 많은 키워드를 얻은 것 같다. 당장에는 필요없을지 모르겠지만, 어떤 형태로든 향후에 도움이 될 것이라고 생각한다. 아무튼 이로써 게임 네트워킹의 이해 정리를 마무리하겠다.

지난번에 이어서 Tucker님의 게임 네트워킹 이해를 시청한 내용을 정리해보겠다.

 

https://youtu.be/CfzYq3GTlX8

 

1. P2P, Relay Server, 홀펀칭

 

영상에서 먼저 P2P에 대해 설명하기 전에 인터넷의 유래에 대한 설명을 먼저 한다.

인터넷이 군사목적으로 탄생했다는 것은 알고 있었지만, 조금 더 재밌는 내용을 알게 되었다. 요약하자면, 미국과 소련의 냉전기에 서로에게 미사일 혹은 핵 타격을 안정적으로 수행하기 위해 인터넷 통신망이 구축되었다는 것이다. 만약 미사일 발사 회선이 하나로만 이루어져있으면 적국의 스파이가 해당 회선을 끊어버리고 선제 타격을 하는 경우 대응을 못하지만, 회선을 복잡하게 구축해서 하나의 회선에 문제가 생겨도 다른 회선을 통해 접근할 수 있다면 대응 사격을 할 수 있을 것이다. 따라서 선제 공격을 하면 대응 사격을 받을 것이므로, 서로가 서로를 견제하며 공격하지 못하는 상황이 되는 것이다. 이 이야기를 왜 하셨을까 생각해봤는데, 이 직후에 IPv4 이야기가 등장한다. 아마 당시에는 군사 목적으로 쓰였기 때문에 많은 IP주소가 필요하지 않았다는 이야기를 하려고 하신 것으로 추정된다.

아무튼, IPv4는 약 43억 개의 IP주소를 할당할 수 있고, 현대에 와서는 컴퓨터가 너무 많아져서 43억개로는 부족하여 IPv6로 넘어간다고 배웠는데, 마냥 그렇지만은 않은 것 같다. 최근에는 게이트웨이와 같은 큰 단위의 기기에만 고정 IP를 할당하고, 그 밑에 있는 기기에는 private한 가상의 IP주소를 부여한다는 것이다. 이렇게 하면 IP주소의 여유분도 확보할 수 있고, 내부 컴퓨터를 외부로부터 숨겨주는, 보안의 기능도 할 수 있다고 한다. 다만, 이 경우 문제가 생기는데 내부에 있는 기기가 가상의 IP 주소를 가지고 있기 때문에 외부에서 IP 주소로 해당 컴퓨터 연결할 수 없다는 것이다.

그러면 외부에 있는 컴퓨터가 게이트웨이 속의 숨겨진 컴퓨터에 어떻게 연결을 할 수 있을까? 그것은 바로 숨겨진 컴퓨터가 먼저 외부의 고정 IP를 가진 컴퓨터에 패킷을 보내는 것이다. 이 경우, 중간에 있는 게이트웨이와 같은 NAT 장비가 테이블을 만들어서 내 private IP주소와 목적지 IP 주소를 저장한다. 그리고 이것을 포트번호로 기억한다. 이것을 포트포워딩이라고 한다. 그러면 외부에 내가 속한 게이트웨이의 고정 IP주소와 포트 번호를 전달하기 때문에, 해당 게이트웨이에 포트 번호와 함께 전송을 보내면 게이트웨이가 포트 번호를 참조하여 private IP주소로 전달을 해주는 것이다. 그리고 포트포워딩은 여러 차례 될 수 있다. 게이트웨이가 라우터를 포트포워딩하고, 라우터가 공유기를 포트포워딩하고, 공유기가 우리들이 쓰는 일반적인 컴퓨터를 포트포워딩하는 것이다.

그래서 P2P란 무엇인가 하면, Peer To Peer의 줄임말로, 클라이언트와 클라이언트가 직접 연결하는 방식을 말한다. 이 방식의 문제는, 위에서 설명했듯 고정 IP주소가 없는 클라이언트에게 먼저 패킷을 보내는 것이 불가능하다는 것이다. 때문에 두 클라이언트가 STUN 서버라고 하는 고정 IP주소를 가진 서버에 패킷을 보낸다. 그러면 STUN 서버는 각각이 속한 게이트웨이의 public IP주소와 포트 번호를 상대방에게 알려주는 것이다. 그러면 서로 연결할 수 있게 되는 것이다. 이것을 홀펀칭이라고 한다. 그런데 여기서 또 문제가, 게이트웨이가 포트포워딩을 할 때, 출발지는 물론이고 목적지도 저장한다는 것이다. 즉, 해당 포트 번호는 STUN서버가 목적지이기 때문에 다른 곳에서 보낸 패킷을 전달하지 않을 수 있다는 것이다. 이것은 순전히 게이트웨이 마음이라고 한다. Relay 서버는 TURN 서버라고도 하는 것 같은데, 간단하게 서버가 플레이어 간 보낸 패킷을 서로에게 전달해주면 되기 때문에 이런 문제가 발생하지 않는다. 그래서 먼저 STUN 서버를 통해 홀펀칭을 시도해보고, 실패하면 TURN 서버를 사용한다고 한다. 홀펀칭 또한 UDP를 기반으로 한 기술인데, Reliable UDP와 마찬가지로 옛날에는 일부 큰 기업에서만 사용하던 비밀 기술이었으나, 현재는 널리 퍼진 기술이라는 것 같다.

 

 

2. Deterministic과 게임핵

 

Deterministic 방식은 이전에 정리했듯 같은 입력이 들어오면 같은 처리를 보장해주는 것이다. 즉, 입력에 강하게 의존한다는 것이다. 영상에서는 예시로 FPS를 들었는데, 내 입장에서는 잘 이해가 되지 않아서 내가 들으면서 생각했던 격투 게임으로 예를 들어보겠다. 영상에서 얘기한 것과 거의 부합하는 내용인 것 같은데, 예시가 틀릴 수도 있다. 철권같은 격투 게임의 경우 적에게 공격을 당하면 피격 모션을 재생하며 공중에 붕 뜨거나 날아간다. 이 도중에 상대방을 공격할 수 없다. 아마 이 도중에 공격 버튼을 누르면 내부 검사를 통해 입력이 무시되어 상대방에게 전달되지 않겠지만, 내부의 검사 코드를 조작하여 피격 중인데도 불구하고 공격 버튼 입력이 상대에게 전달됐다고 해보자. 아마 상대는 신나게 콤보를 때려넣다가 갑자기 맞는 상황이 연출될 것이다. 때문에 이런 입력을 검증하는 작업이 필요하다.

P2P 방식으로 연결된다면, 상대 클라이언트에서 해당 입력을 검증한다. 이 경우, 클라이언트는 게임의 Rule과 현재 상태 등을 가지고 있기 때문에 상대방이 공격할 수 없는 상황이라는 것을 알고 있고, 해당 입력이 부정한 입력이라는 것을 캐치해낼 수 있다.

Relay 서버를 통하는 방법을 사용한다면, 중계 서버에서 부정을 감지하기 위해서는 게임의 Rule과 현재 상태를 가지고 있어야 한다. 단, 이 경우에는 하나의 중계 서버가 여러 개의 게임을 서비스할 수 없다는 단점이 있다. 그렇기 때문에 하나의 중계 서버를 통해 여러 개의 게임을 서비스하기 위해 중계 서버는 패킷을 서로에게 전달해주는 역할만을 하는 것이 좋다는 것 같다.

어떤 방식을 이용하든 검증하는 방법이 있을텐데, 이 검증하는 방법을 악용할 수도 있다는 모양이다. 가령 플레이어가 1초에 10m를 움직일 수 있다고 하면, 오차 범위로 12~13m까지는 허용되게끔 한다는 것이다. 10m로 칼같이 잡아버리면 작은 오류로 아주 작은 오차가 발생해도 핵으로 인식할 수 있기 때문이다. 때문에 핵은 이런 것을 역이용해서 걸리지 않을 정도로만 빠르게 움직이게 할 수도 있다는 것이다.

또, 해킹에는 외부툴을 이용해서 해킹을 하는 외부 해킹이 있고, 내부의 코드를 조작하는 내부 해킹이 있다고 한다. 외부 해킹은 외부 툴을 막는 nProtect 등의 프로그램 등을 사용해서 해킹 프로그램을 실행하지 못하게 하는 방법이 있다. 내부 해킹의 경우, exe파일이 게임의 코드를 기계어로 번역한 것이기 때문에, 이 기계어를 역어셈블리에 넣고 복원하여 코드를 변조시키는 방법을 사용한다고 한다. 이를 막기 위해서 파일 사이즈를 검증하기도 하고, 코드 전체를 Hash코드화해서 Hash코드가 변경되지 않았는지 확인하는 방법도 있다. 또, 코드를 암호화하여 실행할 때 복호화하는 방식도 있다고 한다. 어찌되었든, 해킹의 방어가 강해질수록 해커의 공격도 강해지고, 결국 해커에게 선공권이 있기 때문에 핵이 뚫리는 것은 어쩔 수 없는 일이라는 것 같다. 단, 뚫릴 때마다 방어를 강화하여 겹겹이 씌워 해커를 귀찮게 만든다고 한다.

 

Server Authority 방식에 대한 영상도 보았는데, 내용이 조금 적고 다음 내용과 연계되는 내용인 것으로 보여 다음에 정리할 때 같이 정리해야겠다.

게임을 개발하다보면 네트워크 관련 지식이 필요할 것이라 생각하여 게임 네트워킹에 대한 개념을 익히기로 했다. 나는 클라이언트 프로그래머이지만, 게임을 만들다보면 클라이언트와 서버를 연동하는 작업도 필요할 것이고, 협업을 조금 더 원활하게 하기 위해서는 네트워크 관련 지식도 필요할 것이라 생각한다.

 

관련해서 찾아보다가 유튜브에서 Tucker라는 분이 녹화하여 올리신 '게임 네트워킹의 이해' 라는 영상을 발견했는데, 크게 어렵지 않고 꽤나 흥미로운 내용들로 이루어져있다. 총 13개로 이루어져있고 현재까지 5개 정도 본 것 같다.

 

https://youtu.be/lAhAdnsIN6I

 

이전에 학교 다니면서 데이터통신, 컴퓨터네트워크 라는 과목을 배우기도 했었는데, 관련된 지식도 살짝살짝 나오긴 했지만 주로 새로 접한 내용들이 많았다.

 

아무튼, 영상을 보면서 접했던 내용들이나 생각했던 내용들에 대해서 정리해보고자 한다.

 

1. 네트워킹이란?

주로 컴퓨터끼리 데이터를 주고 받는 것을 네트워킹이라고 한다. 컴퓨터 내부에서 CPU<->RAM<->HDD 등 데이터를 주고 받는 것도 네트워킹이라고 하는데, 물리적인 거리가 가깝고 말 그대로 '연결'되어있기 때문에 일반적인 네트워킹과는 차이가 있다. 일반적인 네트워킹에서 크게 3가지 문제점이 발생하는데, 지연시간, 연결 안정성, 순서 비보장이다. 게임 네트워킹에서 이 3가지를 어떻게 처리하느냐가 중요한 이슈이다. (추후에 패킷 전송 비보장 얘기도 잠깐 나왔음)

 

먼저 지연시간은 빛의 물리적인 속도의 한계에 의해 생길 수 밖에 없다. 빛이 지구를 1바퀴 도는데 걸리는 시간이 대략 133ms이기 때문에, 지구 반대편에 있는 컴퓨터에 데이터를 전송하려면 아무리 빨라도 67ms가 걸린다. 특히나 게임의 경우, 데이터를 주고 받아야하기 때문에 이 시간의 2배까지 걸리게 된다. 이 왕복하는 시간 RTT(Round Trip Time)이라고 한다. 물론 실제로는 모든 컴퓨터들이 직접 연결된 것이 아니기 때문에, 도달하기 위해 여러 거점들을 거쳐야 하고, 일반적인 경우 지구 반대편이라면 500~600ms 정도가 걸린다고 한다.

 

두 번째로 연결 안정성에 대한 문제이다. 요즘에는 주로 생략해서 잘 쓰지 않는 것 같지만 www, world wide web이라는 표현이 있다. 이 이름처럼 인터넷 세상은 거미줄처럼 넓게 연결되어 있다. 때문에 내 컴퓨터나 상대방 컴퓨터에 문제가 없더라도, 데이터가 이동하는 사이 중간 거점에 문제가 생기면 연결에 문제가 생길 수 있다는 것이다. 한국에서 미국으로 데이터를 보낸다고 치면, 일본을 거쳐 태평양 회선을 타고 미국으로 도달한다고 하는데, 해저 케이블이 끊긴다거나 하는 사고가 발생하면 연결이 끊길 수 있다는 것이다. 이외에도 모바일 게임을 한다고 치면, 지하철을 타고 있을 때 중간중간 데이터가 잡히지 않는 구간이 있어서 끊김이 발생한다.

 

세 번째로는 순서 비보장이다. 광자가 순서대로 이동하기 때문에 컴퓨터 내부에서는 데이터가 순서대로 이동하겠지만, 인터넷 상에서는 순서가 보장되지 않는다. 두 명이 동시에 입력을 했다고 해도, 물리적인 거리가 멀다면 지연시간으로 인해 거리가 더 가까운 사람의 데이터가 먼저 도달할 것이다. 이렇게 물리적인 거리의 차이로 문제가 생길 수도 있고, 같은 사람이 보냈다고 하더라도 보내던 회선이 고장나서 다른 회선으로 우회하게 된다면 마찬가지로 나중에 보낸 데이터가 먼저 도달할 수도 있다.

 

===================================================

 

여기까지가 첫 번째 영상 관련 내용인데, 뒷 내용들에 비해서 내용이 조금 많은 것 같다. 전체적으로 어찌보면 상당히 당연한 내용이지만 여태까지 생각해본 적이 거의 없었던 내용들이었다. 먼저 지연시간과 관련해서 든 생각은, 두 플레이어 정 중앙에 서버가 존재한다면, 지연시간을 절반까지 낮출 수 있지 않을까 하는 생각이었다. 

그림을 그려보자면 이런 식인데, 중간에 서버가 양쪽의 데이터를 받아서 처리하고, 서로에게 보내준다면 데이터 왕복에 걸리는 시간을 절반까지 낮출 수 있을 것 같다. 다만 비용이 문제일 것 같다.

두 번째로 든 생각은 지구 내부에 구멍을 뚫어서 다이렉트로 연결하면 더 빠르게 연결할 수 있지 않을까 라는 생각이다. 지구 표면을 따라 이동한다면 반지름x3.14의 거리를 이동하겠지만, 이렇게 이동한다면 반지름x2의 거리를 이동할테니 1.5~1.6배 정도 빠르게 이동할 수 있을 것이다. 다만 아직 인간이 지표면에서 별로 깊이 들어가지도 못했고, 중심에는 핵이 있어서 케이블이 녹을 가능성이 매우 높다는 것이다. 아마 아직까지는 현실성 없는 얘기인 것 같다. ㅋㅋ.

 

=============================

 

2. TCP & UDP

 

TCP와 UDP는 각기 다른 통신 방식인데, 간단히 말하자면 TCP는 안정성을 중시하는 방식이고, UDP는 안정성을 포기하는 대신 속도를 중시하는 방식이다. 위에서 언급했듯 컴퓨터 네트워크에서는 연결 자체가 보장되지 않고, 순서도 보장되지 않으며 보낸 패킷들이 전부 도달한다는 보장도 없다. TCP방식은 이런 것들을 하드웨어 차원에서 보장해준다.

https://seongonion.tistory.com/74

먼저 연결의 경우 3-way-handshake라는 방식을 사용한다고 한다. 연결을 요청하면, 요청받은 쪽에서 응답하고, 이 응답을 잘 받았다는 것을 마지막으로 전달하면서 연결을 하는 방식인 것 같다. 이렇게 총 3번의 절차를 걸쳐 연결을 하기 때문에 3-way-handshake라고 하는 것 같다.

 

두 번째로는 순서 보장인데, 패킷을 보낼 때 번호를 매겨서 보내는 방식을 사용하여 받은 쪽에서는 해당 번호를 기준으로 정렬하면 되기 때문에 순서가 보장된다고 한다. 또, 3, 4, 5번 패킷이 왔는데 1, 2번 패킷이 안왔다면, 1, 2번 패킷을 못받았다는 요청을 보내서 모든 패킷을 전송받을 수 있는 보장도 가능하다는 것 같다.

 

 

다만, TCP 방식의 경우 이런식으로 확인에 대한 절차가 걸리기 떄문에 아무래도 성능적인 면에서 떨어질 수밖에 없다. 때문에 UDP 방식은 이런 보장을 해주지 않는 대신 속도가 빠르다는 장점이 있다. UDP는 연결 확인은 전혀 하지 않고, 바로 데이터를 보내고 '도달하든 말든 보냈으니 땡이다' 라는 방식인 것 같다. 상당히 무책임한 방식이지만... 복잡한 절차가 없으니 속도가 굉장히 빠를 것 같다. 데이터 유실률이 7%정도라고 하니 생각보다 그렇게 높지는 않은 것 같다. 때문에 반응성이 중요한 FPS나 격투 게임 등 대부분의 게임 장르에서는 UDP 방식을 사용한다고 한다. 요즘은 UDP방식을 소프트웨어적으로 보완한 Reliable UDP라는 방식을 사용한다고 한다. 게임에서 안정성이 필요한 부분의 안정성을 높여 순수 UDP에 비해서는 느리지만, 안정성은 좋은 방식이라고 한다. TCP 방식에 비해서는 불필요한 부분을 뺐을테니 TCP에 비하면 속도도 빠를 것이다. 옛날에는 이런 기술을 가진 회사들이 많지 않았다는 것 같은데, 지금은 오픈 소스도 많고 Unity, Unreal에서 지원해주기 때문에 많이들 사용한다고 한다.

 

 

3. Deterministic (+Delay, Rollback 방식)

 

게임 네트워킹 방식 중에 Deterministic이라는 방식이 있다. 이 방식은 반응성이 좋은 UDP를 사용하는 경우에 많이 사용한다고 한다. TCP를 사용하는 경우에는 부정을 방지하기 위해 서버의 강력한 권한이 필요한 MMORPG 등에서 Server Authority라는 방식을 사용한다고 한다.

 

Deterministic 방식은 똑같은 입력이 들어오면 똑같은 결과를 보장해준다. 만약 격투게임에서 원거리 통신 플레이를 할 때, 거리가 멀어서 지연시간이 길다고 가정해보자. 공격 버튼을 누른 후 적에게 공격이 닿을 때까지 100ms가 걸리는 경우, 지연시간이 150ms라면 무슨 일이 벌어질까? A가 100이라는 시간에 공격을 입력하고, B가 200이라는 시간에 공격을 입력하면 A에서는 200이라는 시간에 B를 때리는 결과가 나타날 것이고, B에서는 200이라는 시간에 B가 공격을 시작하고 A는 250에 입력이 들어와서 공격을 시작하기 때문에 결과적으로 300이라는 시간에 B가 A를 때리는 결과가 나타날 것이다. 온라인 게임에서 이렇게 두 플레이어의 결과가 다른 문제가 나타나면 안되기 때문에, 이를 해결하기 위한 방식을 적용한다.

 

첫 번째로는 Delay방식으로, 지연시간만큼 기다렸다가 입력을 처리하는 방식이다. 위의 경우를 똑같이 가정해보면 A의 입장에서는 100에서 입력을 했기 때문에 250에서를 처리를 할 것이다. 250까지 B의 입력이 오지 않았기 때문에 250에서 A의 공격이 나가고, 350에서 A가 B를 공격하는 결과가 나올 것이다. 마찬가지로 B의 입장에서는 200에 공격을 입력했고, 350까지 기다리는데, 250에 A의 공격 입력이 들어오기 때문에 마찬가지로 350에서 B가 A를 때리는 결과가 나타나게 된다. 즉, 두 플레이어에게 똑같은 결과를 보장해주는 것이다. 다만, 문제가 있다면 지연시간이 길 경우, 입력 후 실제 처리까지 대기시간이 있기 때문에 지연시간이 길수록 플레이어 입장에서는 답답하게 느껴질 수 있다.

 

두 번째로는 Rollback 방식인데, 위의 경우에서 B의 입장을 보겠다. B가 200에 공격을 시작하면 300에 A에게 공격이 도달할 것이다. 그런데, 250에 A의 입력이 들어왔을 때 A의 입력이 100에 입력됐다는 정보가 함께 오기 때문에, A가 정보를 보낸시간까지 되감기를 하고, 해당 정보를 넣은 후에 현재 시점까지 앞감기를 해서 적용을 시키는 방식이다. 그러면, A는 100에서 공격을 했기 때문에 200에 B가 맞은 판정이 날 것이다. 마찬가지로 A와 B가 동일한 결과를 보장받는 것이다. 다만, 이 경우의 문제는 B의 입장에서는 게임이 상당히 불합리하게 느껴질 수 있다는 것이다. B는 200에서 250까지 주먹을 날리고 있었는데, 되감기와 앞감기를 한 결과 200에서 공격을 맞게 되고, B가 보는 250의 순간에는 갑자기 주먹을 날리던 내 캐릭터가 적의 공격을 맞아서 날아가는 장면을 보게 될 것이다. 사실은 굉장히 공정한 처리가 이루어졌지만, 플레이어 입장에서는 전혀 공평하게 느껴지지 않을 것이다. 렉 때문에 죽었다고 생각하는 경우가 이런 경우라고 한다. 물론 이 예시가 지연시간이 굉장히 긴 경우를 예시로 들었기 때문에, 지연시간이 짧다면 이렇게까지 큰 차이는 나지 않을 것이고, Delay방식에 비해 훨씬 게임을 깔끔하게 진행시킬 수 있을 것이다.

단, Delay 방식에 비해서 구현하기가 굉장히 복잡하다는 문제가 있고, 중간 접속 처리를 하기가 까다롭다는 문제가 있다. 플레이어의 연결이 끊겼다가 다시 접속되는 경우, Delay방식에서는 게임이 잠시 중단될 것이다. 하지만 Rollback 방식에서는 접속한 플레이어가 이전까지의 다른 플레이어의 모든 입력을 가져와서 빠르게 앞감기를 하며 적용시켜야하기 때문에 중간 접속 처리가 굉장히 어려워지고, 이 과정에서 문제가 발생할 가능성도 높아진다.

 

다음으로는 실무에 많이 사용되는 방식에 대한 이야기인데, Delay 방식과 Rollback 방식과 비슷하지만 약간의 차이가 있다. 첫 번째는 중계 서버를 활용하는 방식이다. Relay Server 혹은 Broadcast Server라고 한다. 모든 입력을 서버를 거쳐서 처리하게 되는데, 서버는 데이터를 받아서 전달하기만 한다. 클라이언트는 입력을 할 때 서버에 데이터를 보내고, 서버에서는 받은 데이터를 일정 시간동안 취합하여 다시 클라이언트로 보낸다. 클라이언트는 서버에서 데이터를 받을 때 입력을 처리한다. 즉, Delay 방식과 거의 유사한 방식으로 보인다.

두 번째는 Rollback 방식에서 약간의 변화를 준 방식이다. 기존의 Rollback방식은 상대방의 입력이 들어오면 해당 입력이 입력된 시점으로 되감기를 하고, 다시 앞감기를 하는 처리를 거치는데, Rollback을 일정 시간마다 계속 반복하도록 하는 것이다. 입력이 들어올 때마다 특별한 동작을 수행하도록 하는 것이 아니라 항상 같은 동작을 수행하기 때문에 구현도 더욱 단순해지고, 문제가 생길 가능성도 낮아진다고 한다. 단점이 있다면 성능에 대한 문제인데, 매 프레임마다 되감기&앞감기를 하는 것이 성능에 큰 영향을 주지는 않는다고 한다. 게임에서 성능 대부분을 차지하는 부분이 그래픽이기 때문에, 되감기&앞감기를 하는 과정에서 화면을 다시 그리는 것이 아니라 연산만 하는 것이기 때문에 성능에 대한 부담은 거의 없다고 한다.

 

 

들은 내용의 기억을 되살려서 내 나름대로 요약을 해보았다. 누락된 부분도 있을 것이고, 왜곡되어 틀린 부분이 있을 수도 있다. 영상에서는 그림과 함께 실제 서버 프로그래머분이 설명을 해주시기 때문에 아마 영상을 보는 것이 더 좋을 것이다. 아무튼, 얕지만 게임 네트워킹에 대한 지식을 조금 알게 되었다. 앞으로 서버 관련 지식들을 익히는 데에 많은 도움이 될 것 같다. 현재 5번째 영상까지 보았는데, 총 13개로 이루어져있으니 더 많은 것들을 배울 수 있을 것 같다. 특히 MMORPG 서버 관련 내용이 기대된다.

+ Recent posts