TIL
06.22 TIL
2026. 6. 22. 16:10

일정표

시간 할 일 비고
08:00~10:00 코드카타, 프로그래머스  
10:00~13:00 디자인 패턴 공부  
14:00~18:00 오늘의 수학 / 언리얼 게임 만들기  
19:00~21:00 언리얼 게임 만들기  
21:00~22:00 운동  
22:00~23:30 개인 공부  

*오늘의 코드카타*

 

문제. 최소직사각형

더보기

명함 지갑을 만드는 회사에서 지갑의 크기를 정하려고 합니다. 다양한 모양과 크기의 명함들을 모두 수납할 수 있으면서, 작아서 들고 다니기 편한 지갑을 만들어야 합니다. 이러한 요건을 만족하는 지갑을 만들기 위해 디자인팀은 모든 명함의 가로 길이와 세로 길이를 조사했습니다

가장 긴 가로 길이와 세로 길이가 각각 80, 70이기 때문에 80(가로) x 70(세로) 크기의 지갑을 만들면 모든 명함들을 수납할 수 있습니다. 하지만 2번 명함을 가로로 눕혀 수납한다면 80(가로) x 50(세로) 크기의 지갑으로 모든 명함들을 수납할 수 있습니다. 이때의 지갑 크기는 4000(=80 x 50)입니다

모든 명함의 가로 길이와 세로 길이를 나타내는 2차원 배열 sizes가 매개변수로 주어집니다. 모든 명함을 수납할 수 있는 가장 작은 지갑을 만들 때, 지갑의 크기를 return 하도록 solution 함수를 완성해주세요

#include <vector>
#include <algorithm>

using namespace std;

int solution(vector<vector<int>> sizes) {
    int max_width = 0;
    int max_height = 0;
    
    for (const auto& size : sizes)
    {
        int width = max(size[0], size[1]);
        int height = min(size[0], size[1]);
        
        max_width = max(max_width, width);
        max_height = max(max_height, height);
    }
    
    return max_width * max_height;
}

어차피 길이가 긴 쪽이 가로로 몰린다고 가정하고 풀었다. 이렇게 하면 큰 값을 width로 만들고, 계속 max_width, max_heigth를 갱신하면서 최종적으로 남은 값을 곱해서 명함의 크기를 계산하면 된다.


*프로그래머스*

1. [입문] 직사각형 넓이 구하기

더보기

2차원 좌표 평면에 변이 축과 평행한 직사각형이 있습니다. 직사각형 네 꼭짓점의 좌표 [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]가 담겨있는 배열 dots가 매개변수로 주어질 때, 직사각형의 넓이를 return 하도록 solution 함수를 완성해보세요.

#include <vector>
#include <algorithm>

using namespace std;

int solution(vector<vector<int>> dots) {
   
    int min_x = dots[0][0];
    int max_x = dots[0][0];
    int min_y = dots[0][1];
    int max_y = dots[0][1];
    
    for (int i = 1; i < 4; ++i)
    {
        min_x = min(min_x, dots[i][0]);
        max_x = max(max_x, dots[i][0]);
        min_y = min(min_y, dots[i][1]);
        max_y = max(max_y, dots[i][1]);
    }
 
    return (max_x - min_x) * (max_y - min_y);
}

(1) min과 max를 이용하는 방법. for문을 순회하면서 x값 y값을 각각 min, max값으로 바꿔끼워 주는 방법이다. 최종적으로 나온 max값, min값을 이용해서 직사각형 넓이를 구한다.

 

#include <string>
#include <vector>
#include <algorithm>
#include <iostream>

using namespace std;

int solution(vector<vector<int>> dots)
{
    int answer=0;

    sort(dots.begin(), dots.end());
    answer=(dots[3][0]-dots[0][0])*(dots[3][1]-dots[0][1]);

    return answer;
}

 (2) sort를 이용하는 방법. sort를 사용하면 vector 안의 원소들이 기본적으로 오름차순 정렬된다. 따라서 max, min값을 사용할 필요 없이 자동으로 정렬되므로 정렬된 이후의 값을 이용해서 직사각형의 넓이를 구한다.

 

*참고* 만약 변과 평행하다는 조건이 없다면?

x,y 좌표의 최댓값과 최솟값을 빼는 방식이 더이상 통하지 않게 된다. 따라서 두 점 사이의 거리 공식을 활용하는 것이 좋다.

d^2 = (x2 - x1)^2 + (y2 - y1)^2

거리를 구할 때 루트를 바로 씌우는 것이 아니라 거리의 제곱 상태로 연산하는 것이 알고리즘의 정석이다.

가로의 제곱을 w^2, 세로의 제곱을 h^2 라고 할 때, 넓이는 w^2 * h^2 에 루트를 씌운 값으로 계산할 수 있다.

 

다만 문제 상황에서 주의할 점은, dots 배열 안의 점들이 값 크기의 순서대로 들어와있지는 않다는 점이다. 따라서, 어쨌든 sort로 오름차순 분류를 해 놓고 나서 두 점 사이의 거리 공식을 활용해서 직사각형의 넓이를 구해야 한다.

#include <vector>
#include <cmath>
#include <algorithm>

using namespace std;

double solution(vector<vector<int>> dots) {
    vector<long long> dist_sq;

    for (int i = 1; i < 4; i++) {
        long long dx = dots[0][0] - dots[i][0];
        long long dy = dots[0][1] - dots[i][1];
        
        dist_sq.push_back(dx * dx + dy * dy);
    }

    sort(dist_sq.begin(), dist_sq.end());

    return sqrt(dist_sq[0] * dist_sq[1]);
}

 

 

2. [입문] 캐릭터의 좌표

더보기

머쓱이는 RPG게임을 하고 있습니다. 게임에는 updownleftright 방향키가 있으며 각 키를 누르면 위, 아래, 왼쪽, 오른쪽으로 한 칸씩 이동합니다. 예를 들어 [0,0]에서 up을 누른다면 캐릭터의 좌표는 [0, 1], down을 누른다면 [0, -1], left를 누른다면 [-1, 0], right를 누른다면 [1, 0]입니다. 머쓱이가 입력한 방향키의 배열 keyinput와 맵의 크기 board이 매개변수로 주어집니다. 캐릭터는 항상 [0,0]에서 시작할 때 키 입력이 모두 끝난 뒤에 캐릭터의 좌표 [x, y]를 return하도록 solution 함수를 완성해주세요.

[0, 0]은 board의 정 중앙에 위치합니다. 예를 들어 board의 가로 크기가 9라면 캐릭터는 왼쪽으로 최대 [-4, 0]까지 오른쪽으로 최대 [4, 0]까지 이동할 수 있습니다

#include <string>
#include <vector>
#include <unordered_map>

using namespace std;

vector<int> solution(vector<string> keyinput, vector<int> board) {
    
    int x = 0;
    int y = 0;
    
    int limit_x = board[0] / 2;
    int limit_y = board[1] / 2;
    
    unordered_map<string, pair<int, int>> input_direction =
    {
        {"up", {0, 1}},
        {"down", {0, -1}},
        {"left", {-1, 0}},
        {"right", {1, 0}}
    };
    
    for (const string& key : keyinput)
    {
        int temp_x = x + input_direction[key].first;
        int temp_y = y + input_direction[key].second;
        
        if (temp_x >= -limit_x && temp_x <= limit_x &&
           temp_y >= -limit_y && temp_y <= limit_y) 
        {
            x = temp_x;
            y = temp_y;
        }
    }
    return {x, y};
}

(1) 이전에 봤던 유형. 입력 받은 문자열 형태의 키값을 바탕으로 최종 좌표의 위치를 구하는 것이다. 

핵심은 그냥 map 자료구조를 사용하는 것이 아니라, unordered_map 을 사용하는 것이다. map을 사용하면 내부적으로 트리를 생성하기 때문에 key값으로 해당하는 값을 찾을 때 좀 더 시간이 오래 걸리게 된다. 따라서 해시 테이블을 사용하는 unordered_map을 사용하는 것이 훨씬 유리하다.

unordered_map으로 키값을 입력해 둔 다음에는, for문으로 방향키 배열을 순회하면서 다음 위치를 계산해본다. 해당 위치가 경계값(board 를 2로 나눈 값) 안에 있을 때만 실제 위치를 업데이트 한다.

#include <cmath>

...

if (abs(temp_x) <= limit_x && abs(temp_y) <= limit_y)
{
...
}

(2) 경계선 내부의 값인지 확인하는 조건문을 절대값 개념을 이용해서 짧게 바꾸어보았다. 다만 이러면 <cmath> 또는 <cstdlib> 헤더를 포함해야 한다.

#include <string>
#include <vector>
#include <unordered_map>
#include <cmath>

using namespace std;

vector<int> solution(vector<string> keyinput, vector<int> board) {
    int x = 0;
    int y = 0;
    
    int limit_x = board[0] / 2;
    int limit_y = board[1] / 2;
    
    unordered_map<string, pair<int, int>> directions = {
        {"up", {0, 1}},
        {"down", {0, -1}},
        {"left", {-1, 0}},
        {"right", {1, 0}}
    };
    
    for (const string& key : keyinput) {
        auto [dx, dy] = directions[key];
        
        int nx = x + dx;
        int ny = y + dy;
        
        if (abs(nx) <= limit_x && abs(ny) <= limit_y) {
            x = nx;
            y = ny;
        }
    }
    
    return {x, y};
}

(3) C++17 이후부터 도입된 구조적 바인딩 사용하기

이전에는 input_direction[key].first 같이 pair의 값에 먼저 접근을 해야 했으나, 구조적 바인딩을 사용하면 묶여있는 데이터를 직관적으로 여러 변수에 한 번에 나누어 담을 수 있다.

예를 들어, auto[dx, dy] = directions[key]; 와 같은 형태이다. directions[key] 가 반환하는 {x변화량, y변화량} 쌍을 dx, dy라는 변수에 직관적으로 담아준다. 어떤 값을 가져오는 지 변수명만으로 확실하게 드러나므로 선호되는 방식이다.

 

 

3. [입문] 최댓값 만들기 (2)

더보기

정수 배열 numbers가 매개변수로 주어집니다. numbers의 원소 중 두 개를 곱해 만들 수 있는 최댓값을 return하도록 solution 함수를 완성해주세요.

#include <vector>
#include <algorithm>

using namespace std;

int solution(vector<int> numbers) {
    sort(numbers.begin(), numbers.end());
    int len = numbers.size();
    
    int left = numbers[0] * numbers[1];
    int right = numbers[len-2] * numbers[len-1];
    
    return max(left, right);
}

(1) sort로 정렬한 후, 왼쪽의 2개의 수를 곱하고, 오른쪽의 2개의 수를 곱하고 둘을 비교하여 더 큰 값을 반환하는 방법.

#include <vector>
#include <algorithm>

using namespace std;

int solution(vector<int> numbers) {
    sort(numbers.begin(), numbers.end());
    
    int left = numbers[0] * numbers[1]; 
    int right = numbers.back() * *(numbers.rbegin() + 1);
    
    return max(left, right);
}

(2) len이라는 변수를 굳이 선언하지 않고, sort로 정렬한 후 rbegin() + back() 을 사용해서 구하는 방법도 있다.

#include <vector>
#include <algorithm>
#include <climits>

using namespace std;

int solution(vector<int> numbers) {

    int max1 = INT_MIN, max2 = INT_MIN; 
    int min1 = INT_MAX, min2 = INT_MAX;

    for (int num : numbers) {
   
        if (num > max1) {
            max2 = max1;
            max1 = num;
        } else if (num > max2) {
            max2 = num;
        }

        if (num < min1) {
            min2 = min1;
            min1 = num;
        } else if (num < min2) {
            min2 = num;
        }
    }

    return max(max1 * max2, min1 * min2);
}

(3) 사실 시간 복잡도 측면에서 보면, 배열이 길어질 수록 sort를 했을 때 시간 복잡도가 커지기 때문에, 전체 배열을 딱 한번만 순회하면서 가장 큰값/ 그 다음으로 큰 값, 가장 작은 값, 그 다음으로 작은 값 을 계속 바꿔 끼워주는 방식이 제일 효율적이다. 다만 변수를 선언하고 if 조건문으로 계속 바꿔 끼워준 것이 조금 귀찮을 뿐이다.

 

 

4. [입문] 다항식 더하기

더보기

한 개 이상의 항의 합으로 이루어진 식을 다항식이라고 합니다. 다항식을 계산할 때는 동류항끼리 계산해 정리합니다. 덧셈으로 이루어진 다항식 polynomial이 매개변수로 주어질 때, 동류항끼리 더한 결괏값을 문자열로 return 하도록 solution 함수를 완성해보세요. 같은 식이라면 가장 짧은 수식을 return 합니다.

<제한사항>

0 < polynomial에 있는 수 < 100

polynomial에 변수는 'x'만 존재합니다.

polynomial은 양의 정수, 공백, ‘x’, ‘+'로 이루어져 있습니다.

항과 연산기호 사이에는 항상 공백이 존재합니다.

공백은 연속되지 않으며 시작이나 끝에는 공백이 없습니다.

하나의 항에서 변수가 숫자 앞에 오는 경우는 없습니다.

" + 3xx + + x7 + "와 같은 잘못된 입력은 주어지지 않습니다.

0으로 시작하는 수는 없습니다.

문자와 숫자 사이의 곱하기는 생략합니다.

polynomial에는 일차 항과 상수항만 존재합니다.

계수 1은 생략합니다.

결괏값에 상수항은 마지막에 둡니다.

0 < polynomial의 길이 < 50

#include <string>
#include <vector>
#include <sstream>

using namespace std;

string solution(string polynomial) {
    int num_x = 0;
    int num_const = 0;
    
    stringstream ss(polynomial);
    string temp;
    
    while (ss >> temp) {
        if (temp == "+")
        {
            continue;
        }
        
        if (temp.back() == 'x')
        {
            if (temp == "x")
            {
                num_x += 1;
            }
            else
            {
                num_x += stoi(temp.substr(0, temp.length() - 1));
            }
        }
        else
        {
            num_const += stoi(temp);
        }
    }
    
    string answer = "";
    
    if (num_x > 0)
    {
        if (num_x == 1)
        {
            answer += "x";
        }
        else
        {
            answer += to_string(num_x) + "x";
        }
    }
    
    if (num_const > 0)
    {
        if (answer.length() > 0)
        {
            answer += " + ";
        }
        answer += to_string(num_const);
    }
    
    return answer;
}

(풀이)

문제가 어려워보이지만 문제를 3 단계로 분리해 본 뒤에 그냥 그대로 따라서 구현만 하면 되는 문제이다.

1. stringstream을 사용해서 다항식을 단어 단위로 쪼갠다. 그러면, 1) x (그냥 x 또는 앞에 숫자가 붙은 형태의 nx) , 2) 문자열 형태의 숫자, 3) + 기호 가 나올 수 있다.

 

2.

1) 단어 끝에 x가 있다면 일차항이다. 만약 단어가 그냥 "x" 라면 계수는 1이고, x 앞에 숫자가 붙어 있는 형태라면 앞의 숫자를 정수로 변환(stoi)해서 num_x에 더해준다. (나중에 문자열을 최종 조립할 때에 num_x + "x" 꼴로 붙이게 됨).

2) x가 없다면 상수항(const)이므로 바로 정수로 변환해서 num_const에 더하면 된다.

3) 가져온 단어가 + 라면 덧셈 기호일 뿐이므로 일단 무시한다.

 

3. 나온 결과를 answer 문자열에 더한다. 예외 조건을 잘 처리해주기만 하면 되는데, x의 계수가 1일 때는 x로 출력해야 하며, 상수항과 일차항이 모두 존재할 때에는 사이에 +를 넣어주어야 한다. 둘 중에 하나만 존재하는 경우도 미리 잘 생각해서, 깔끔하게 처리하면 된다.

 


*수학 공부*

주제 : 확률과 통계 (Probability & Statistics)

 

물리 엔진과 수학은 게임의 규칙을 만들고, 확률과 통계는 게임에 재미와 긴장감을 불어넣는 요소이다. 더 나아가 '게임 데이터 엔지니어링' 과 살아 숨쉬는 세계와도 깊게 연관되어 있는 개념이라고 할 수 있다.

 

게임에서 사용되는 확률과 통계 수학을 다음의 3가지 주제로 나누어서 알아보자.

1. 자주 사용되는 게임 확률

2. 게임 데이터 엔지니어링

3. 경제 시스템


 

<Theme 1 : 자주 사용되는 게임 확률>

 

1. 가챠와 전리품의 핵심 : 가중치 확률 (Weighted Random)

 

아이템을 떨굴 때 단순히 난수를 뽑아내는 방식으로 하드코딩하면, 나중에 확률표를 수정해야 하거나 아이템을 추가하게 된다면 굉장히 골치아픈 상황이 발생할 수 있다.

 

이 때, 가중치 기반의 확률 수학을 사용하게 된다. 모든 아이템의 가중치를 더한 '전체 무게'를 구하고 그 안에서 난수를 뽑아 하나씩 빼가면서 당첨을 찾는 방식이다.

 

#include "Math/UnrealMathUtility.h"

// 기획자가 데이터 테이블에서 조절할 수 있는 아이템 구조체
struct FLootItem
{
    FName ItemName;
    float DropWeight; // 드랍 확률이 아니라 '가중치'
};

FName AMyBossMonster::CalculateLootDrop(const TArray<FLootItem>& LootTable)
{
    // 1. 모든 아이템의 가중치를 합산 (Total Weight 계산)
    float TotalWeight = 0.0f;
    for (const FLootItem& Item : LootTable)
    {
        TotalWeight += Item.DropWeight;
    }

    // 2. 0.0 ~ TotalWeight 사이의 실수 난수 추출
    // 만약 총합이 1000.0f라면, 0.0 ~ 1000.0f 사이의 무작위 값 하나를 뽑음
    float RandomPoint = FMath::FRandRange(0.0f, TotalWeight);

    // 3. 배열을 돌며 가중치를 빼서 당첨 아이템 찾기 (룰렛을 돌리는 수학적 행위)
    for (const FLootItem& Item : LootTable)
    {
        RandomPoint -= Item.DropWeight;
        
        // RandomPoint가 0 이하가 되는 순간, 그 영역(파이 조각)에 바늘이 멈춘 것임.
        if (RandomPoint <= 0.0f)
        {
            return Item.ItemName; 
        }
    }

    return FName("None"); // 예외 처리
}

 

 

2. 억까를 방지하는 방법 : 가짜 난수 분포 (PRD, Pseudo-Random Distribution)

 

아무리 가중치를 활용한 확률 시스템이 수학적으로 완벽한 방법이라고 하더라도, 사실 인간이 느끼는 마음은 다르다. 예를 들어 명중률이 95%인 공격이라고 해도 중요한 타이밍에 연속 3번 빗나가게 되면 어떡할까? 컴퓨터는 아무 감정 없이 우연찮게 0.05 * 0.05 * 0.05 확률의 난수를 뽑아낸 것에 불과하지만, 이를 직접 몸으로 겪는 사람은 굉장히 열이 받게 되고, 결국 5,700자의 장문으로 부정적 리뷰를 달게 될 수도 있다.

 

따라서, 이러한 확률 시스템에서 나오는 불쾌감을 없애기 위해 가짜 난수 분포라는 통계적 보정 수학을 사용하게 된다.

예를 들어, 크리티컬 확률이 20%라면 실제로는 훨씬 낮은 5% 정도의 확률에서 시작하고, 크리티컬이 발생하지 않을 때마다 확률이 5%씩 누적해서 올라가게 하는 것이다. 그리고 크리티컬이 터지면 다시 5%로 초기화된다. 이렇게 해도 결과적으로 무한히 때리면 통계적으로는 20%에 수렴하게 만드는 것이다. 통계적으로는 20%이지만, 유저 입장에서는 연속으로 안 터지거나 연속으로 크리티컬을 맞는 극단적인 상황을 어느정도 예방할 수 있게 된다.

 

// 헤더에 저장할 변수들
// float BasePRDConstant = 0.05f; // 기획자가 정한 PRD 증가폭 (예: 5%)
// int32 FailCount = 1;           // 실패 횟수 (1부터 시작)

bool AMyCharacter::CheckCriticalHit()
{
    // 1. 현재 타격의 실제 성공 확률 계산 (실패 횟수 * 기본 상수)
    float CurrentChance = BasePRDConstant * FailCount;
    
    // 2. 0.0 ~ 1.0 사이의 난수를 뽑아 확률 검사
    float RandomRoll = FMath::FRand(); 

    if (RandomRoll <= CurrentChance)
    {
        // 크리티컬 성공! 
        // 3. 유저가 이득을 봤으므로 실패 카운트를 1로 초기화
        FailCount = 1;
        return true; 
    }
    else
    {
        // 크리티컬 실패.
        // 4. 다음 타격의 확률을 높이기 위해 카운트 증가 (천장 시스템의 시초)
        FailCount++;
        return false;
    }
}

 

 

 

3. 탄착군과 랜덤 스탯 : 정규 분포 (Gaussian / Normal Distribution)

 

예를 들어, 탄퍼짐과 탄착군을 구현할 때, 언리얼 엔진의 기본 함수인 FMath::FRandRange 를 사용하면 안된다. FRandRange함수는 '균등 분포(Uniform Distribution)'라서 모든 구역에 같은 밀도로 탄착군이 형성되게 된다. 예쁘게 그림을 그릴 것이 아니라면 굳이 균등 분포를 사용할 필요가 없다.

 

'자연스러운 랜덤'은 중앙(평균)에 가장 많이 몰려 있고, 바깥으로 갈 수록 분산되는 종 모양의 곡선, 정규 분포를 따라야 한다. 이를 위해 균등 난수 2개를 섞어 정규 분포를 만들어내는 박스-뮬러 변환(Box-Muller Transform) 수학을 사용해서 중앙에 빽빽하게 박히는 탄착군을 만들어낸다.

 

박스 뮬러 변환은 자연로그와 코사인을 이용해서 균등 분포를 정규 분포로 찌그러뜨리는 것이다. 우선 구체적으로 무슨 공식인지 알기보다는 그런가보다 하고 넘어가보자. 핵심은 '정규 분포'를 만들어내고, 평균과 표준편차를 입력하면 우리가 원하는 자연스러운 랜덤값을 만들어내도록 하는 것이다.

 

#include "Math/UnrealMathUtility.h"

// 평균(Mean)을 중심으로, 표준편차(StdDev)만큼 흩어지는 정규 분포 난수를 반환
float GetNormalRandom(float Mean, float StdDev)
{
    // 1. 0.0 ~ 1.0 사이의 균등 난수 2개 추출 (0은 피함)
    float U1 = FMath::Max(FMath::FRand(), 0.00001f); 
    float U2 = FMath::FRand();

    // 2. 박스-뮬러 알고리즘: 자연로그(Ln)와 코사인(Cos)을 이용해 균등 분포를 정규 분포로 찌그러뜨림
    float Z0 = FMath::Sqrt(-2.0f * FMath::Loge(U1)) * FMath::Cos(2.0f * PI * U2);

    // 3. 평균과 표준편차를 적용하여 최종 값 반환
    return Mean + (Z0 * StdDev);
}

void AMyWeapon::FireShotgun()
{
    FVector AimDir = GetActorForwardVector();

    for (int i = 0; i < 10; ++i) // 10발 발사
    {
        // 조준점(평균 0)을 기준으로, 표준편차 2.0도 내외로 퍼지는 정규 분포 각도 생성
        float PitchOffset = GetNormalRandom(0.0f, 2.0f);
        float YawOffset   = GetNormalRandom(0.0f, 2.0f);

        FRotator SpreadRot = FRotator(PitchOffset, YawOffset, 0.0f);
        FVector FinalBulletDir = SpreadRot.RotateVector(AimDir);

        // 정규분포를 따르기 때문에 10발 중 6~7발은 조준점 중앙에 빽빽하게 박힘
        SpawnBullet(FinalBulletDir); 
    }
}

 


<Theme 2-1 : 데이터 엔지니어링>

 

개발자는 게임을 런칭하고 나서부터는 유저들이 게임 기획 의도대로 플레이하고 있는지, 난이도가 너무 어렵지는 않은지를 계속 확인해야 한다. 결국 이를 위해서는 통계와 데이터를 사용할 수 밖에 없다. 

 

1. 매치 메이킹 : ELO 레이팅기대 승률 (Expected Probability)

 

우선, 플레이어의 '실력'을 어떻게 판별할 수 있을지 생각해보자. 실력을 수학적으로 정의하기 위해서 체스에서 유래한 ELO Rating 시스템이 사용된다.  ELO는 단순히 점수를 더하고 빼는 것이 아니라, 두 유저가 싸웠을 때 누가 이길 확률이 높은가(기대 승률)?를 통계적으로 예측하는 것이 핵심이다.

 

수학적 원리 : A가 B를 이길 확률(기대값 E(A))은 로지스틱 곡선(Logistic Curve) 통계학을 따른다. 즉, 점수 차이가 크면 클수록 승률이 벌어진다.

더보기
점수 차이가 400점 나면 승률이 약 90% vs. 10% 로 벌어진다.

ELO 시스템을 사용하면 점수 차이가 많이 나는 상황에서 고수가 졌을 경우 이길 확률이 낮았던 상대의 점수를 크게 올려줄 수 있게 된다.

 

(사례 : MMR 계산기 만들기)

#include "Math/UnrealMathUtility.h"

// 1. 상대방과의 점수 차이를 바탕으로 나의 '기대 승률(0.0 ~ 1.0)' 계산
float CalculateExpectedWinRate(int32 MyMMR, int32 OpponentMMR)
{
    float RatingDiff = (float)(OpponentMMR - MyMMR) / 400.0f;
    // 통계학 기반 로지스틱 함수 적용
    return 1.0f / (1.0f + FMath::Pow(10.0f, RatingDiff));
}

// 2. 게임 종료 후 새로운 MMR 계산 (K-Factor는 점수 변동의 폭, 보통 32 사용)
int32 UpdateMMR(int32 CurrentMMR, int32 OpponentMMR, bool bWonMatch)
{
    float ExpectedWinRate = CalculateExpectedWinRate(CurrentMMR, OpponentMMR);
    
    // 이겼으면 실제 결과(ActualScore)는 1.0, 졌으면 0.0
    float ActualScore = bWonMatch ? 1.0f : 0.0f;
    
    float KFactor = 32.0f;

    // 통계 보정 공식: 새 점수 = 기존 점수 + K * (실제 결과 - 기대 승률)
    float MMRChange = KFactor * (ActualScore - ExpectedWinRate);
    
    return CurrentMMR + FMath::RoundToInt(MMRChange);
}

 

 

2. 공간 통계와 히트맵 (Spatial Telemetry & Heatmaps)

 

레벨 디자이너 입장에서, 특정 구간에서 계속 유저들이 비정상적으로 많이 실패하거나, 죽는다면 해당 3D 좌표를 확인하고 사망 원인을 데이터베이스에 저장해 놓아야 한다. 해당 정보를 서버 DB에 쏘는 과정을 텔레메트리(Telemetry)라고 부른다.

이 과정이 반복되어 수많은 데이터가 서버에 쌓이게 되면, 이를 통계적으로 분석해 히트맵을 그린다. 만약 맵 구석의 아주 좁은 영역에 데스 마커가 빽빽하게 뭉쳐있다면(Clustering), 그 지점이 밸런스가 붕괴되거나 무언가 문제가 발생한 지점이라는 것을 파악할 수 있게 된다.

 

(사례 : 비동기 텔레메트리 로깅)

#include "HttpModule.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
#include "Serialization/JsonSerializer.h"

void AMyPlayerController::LogDeathTelemetry(FVector DeathLocation, FString CauseOfDeath)
{
    // 1. 통계용 데이터를 JSON 포맷으로 묶기
    TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject);
    JsonObject->SetStringField("PlayerID", GetUniqueID());
    JsonObject->SetStringField("Cause", CauseOfDeath);
    JsonObject->SetNumberField("PosX", DeathLocation.X);
    JsonObject->SetNumberField("PosY", DeathLocation.Y);
    JsonObject->SetNumberField("PosZ", DeathLocation.Z);

    FString JsonString;
    TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&JsonString);
    FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer);

    // 2. 게임 플레이 프레임에 영향을 주지 않도록 HTTP 비동기 통신으로 데이터 분석 서버에 전송
    TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
    Request->OnProcessRequestComplete().BindUObject(this, &AMyPlayerController::OnTelemetrySent);
    Request->SetURL("https://api.mygame.com/telemetry/death");
    Request->SetVerb("POST");
    Request->SetHeader("Content-Type", "application/json");
    Request->SetContentAsString(JsonString);
    Request->ProcessRequest();
}

해당 텔레메트리 로깅 과정 이후 쌓인 데이터를 사용해서 히트맵을 그리는 방식으로 활용할 수 있다.

 

 

3. 통계적 이상치(Outlier) 제거와 밸런싱 : 표준편차 (Standard Deviation)

 

데이터와 통계를 다룬다면 무엇보다도 평균의 함정에 빠지지 말아야 한다. 표준편차는 데이터가 평균에서 얼마나 널뛰기하는지를 보여주는 지표이다.

 

예를 들어, 완벽한 난이도의 보스 스테이지를 구현하기 위해서 클리어 시간을 평균과 표준편차로 나누어서 관찰하고 이를 밸런싱에 반영하게 된다.

또한, 잠수 타는 유저나 핵쟁이 등 통계적 이상치를 걸러내기 위해서 데이터 정제 수학인 Z-Score(표준점수)를 구해서 +- 2sigma 를 벗어나는 비정상 데이터를 분석에서 제외시킨다.

 

(사례 : 평균과 분산을 활용한 이상치 검출)

void AnalyzeBossClearTimes(const TArray<float>& ClearTimes)
{
    if (ClearTimes.Num() == 0) return;

    // 1. 평균(Mean) 계산
    float Sum = 0.0f;
    for (float Time : ClearTimes) Sum += Time;
    float Mean = Sum / ClearTimes.Num();

    // 2. 분산(Variance)과 표준편차(Standard Deviation) 계산
    float VarianceSum = 0.0f;
    for (float Time : ClearTimes)
    {
        VarianceSum += FMath::Square(Time - Mean); // (값 - 평균)의 제곱
    }
    float Variance = VarianceSum / ClearTimes.Num();
    float StandardDeviation = FMath::Sqrt(Variance);

    // 3. Z-Score를 활용한 비정상 유저(Outlier) 걸러내기
    TArray<float> ValidTimes;
    for (float Time : ClearTimes)
    {
        // Z-Score: 이 유저의 기록이 평균에서 몇 표준편차만큼 떨어져 있는가?
        float ZScore = FMath::Abs((Time - Mean) / StandardDeviation);

        if (ZScore < 2.0f) // 통계학의 95% 신뢰구간 (정상 유저)
        {
            ValidTimes.Add(Time);
        }
        else
        {
            UE_LOG(LogTemp, Warning, TEXT("이상치 발견! (핵 또는 잠수 유저 의심): %f 초"), Time);
        }
    }
    
    // ValidTimes를 바탕으로 진짜 밸런싱 지표 도출하기
}

 

이런 식으로 확률과 통계 개념은, 단순한 뽑기 확률을 넘어서 유저의 실력을 수치화하고, 빅데이터를 수집하고, 비정상 데이터를 통계적으로 걸러내는 게임 데이터 엔지니어링에도 깊게 관련되어 있다. 

 

다음 내용에서는 통계와 데이터가 서버/클라이언트 아키텍처에 어떻게 녹아들어있는지 좀 더 알아보기로 했다(심화). 온라인 게임에서 서버는 절대 거짓말을 하지 않아야 하고 유저의 모든 행동을 통제하고 감시해야 한다. 솔플 게임보다 훨씬 더 확률과 통계에 민감해야 한다는 뜻이다.

 


 

<Theme 2-2 : 데이터 엔지니어링(심화)>

 

단순히 클라이언트에서 캐릭터를 움직이는 것을 넘어서 라이브 서버 생태계 전체를 통제하는 통계와 수학 개념을 배워보자.

 

4. (심화) 서버 기반 안티치트 : 에임봇(Aimbot)과 분산(Variance) 통계

 

클라이언트에 보안 프로그램을 깔아놔도 핵 만드는 사람들은 어떻게든 뚫어낸다. 그래서 최신 FPS 게임은 서버 측 통계 분석(Server-side Analytics)으로 에임봇을 잡아낸다. 프로그램으로 보는 것이 아니라 이상하고 기묘한 움직임을 보이는 통계를 잡아내겠다는 뜻이다.

 

사람이 마우스를 움직여서 적의 머리를 조준할 때에는 반드시 오버슈팅과 미세 조정이 발생하게 된다. 이를 수치화해보자면, 시점 회전(Rotation)의 변화량(Delta)값들에 분산(Variance)가 존재한다고 표현할 수 있다.

 

그러나 에임봇은 단 1프레임 만에 적의 머리로 정확히 에임을 가져다 놓는다. 즉, 엄청난 각속도마우스 움직임의 분산이 0.0에 가깝게 수렴하는 현상을 보인다.

 

서버는 이 비정상적으로 낮은 분산을 통계로 잡아내어 밴을 때려버리는 것이다. 그 과정에서 탈인간급 퍼포먼스를 보이는 무고한 희생자가 나올 수도 있지만.

 

(사례 : 마우스 입력 분산 추적기)

// 매 타격 시, 직전 10프레임 동안의 마우스 회전(Yaw/Pitch) 델타값을 서버가 분석
void AMyServerAntiCheat::AnalyzeAimVariance(APlayerController* Player, const TArray<float>& RecentYawDeltas)
{
    if (RecentYawDeltas.Num() == 0) return;

    // 1. 평균 회전 속도 계산
    float Sum = 0.0f;
    for (float Delta : RecentYawDeltas) Sum += Delta;
    float Mean = Sum / RecentYawDeltas.Num();

    // 2. 분산(Variance) 계산: 움직임이 얼마나 불규칙한가?
    float VarianceSum = 0.0f;
    for (float Delta : RecentYawDeltas)
    {
        VarianceSum += FMath::Square(Delta - Mean);
    }
    float Variance = VarianceSum / RecentYawDeltas.Num();

    // 3. 통계적 판단
    // 에임봇의 특징: 엄청나게 빠른 이동(높은 평균 속도) + 이후 미동조차 없음(비정상적으로 낮은 분산)
    if (FMath::Abs(Mean) > 50.0f && Variance < 0.001f)
    {
        // 1번 걸렸다고 바로 정지시키지 않음 (억울한 유저 방지). 의심 스택 누적됨.
        PlayerState->AddSuspicionStack();
        
        if (PlayerState->GetSuspicionStack() > 5)
        {
            BanPlayer(Player, TEXT("Statistical Aimbot Detection: Unnatural Aim Variance"));
        }
    }
}

if 조건문에서 FMath::Abs(Mean) > 50.0f && Variance < 0.001f 로 조건을 걸어 기계적인 움직임을 잡아내는 것을 구현했다. 다만 한번 걸렸다고 바로 정지시키는 것이 아니라 지속적으로 비정상적인 통계값을 보인다면 그제서야 밴을 때리게 하기 위해서 또 한번의 조건(AddSuspicionStack)을 걸어준다.

 

 

5. (심화) 동적 난이도 조절(DDA, Dynamic Difficulty Adjustment) : 지수 이동 평균(EMA, Exponential Moving Average)

 

유저의 현재 실력을 판별하고 게임의 난이도를 동적으로 조절하는 기능을 구현(DDA) 시스템을 구현하기 위해서 지수 이동 평균이라는 개념을 사용한다. 유저가 얼마나 잘하고 있는지를 측정하기 위해서 단순 평균으로 구하게 되면 과거의 잘했던 데이터가 현재 녹슬어버린 실력을 감춰버려 난이도 조절이 어려워지는 문제가 발생하기 때문이다.

 

통계학의 지수 이동 평균은 가장 최근의 데이터에 가장 큰 가중치를 주는 통계 공식이다. 주식 차트 분석에서도 핵심으로 사용되는 수학인데, 어떻게 사용되는지 알아보자.

 

(사례 : AI 디렉터의 스트레스 추적)

// EMA 공식: (현재 값 * 가중치) + (이전 EMA * (1 - 가중치))
// 가중치(Alpha)가 1.0에 가까울수록 최근 일에만 반응하고, 0.0에 가까울수록 과거를 오래 기억함

float CurrentStressEMA = 0.0f; // 현재 플레이어의 스트레스 지수 (0.0 ~ 100.0)
float SmoothingFactor = 0.3f;  // 알파값 (최근 사건에 30%의 비중을 둠)

void AMyAIDirector::UpdatePlayerStress(float DamageTakenThisTick)
{
    // 피격당하면 스트레스가 확 오르고, 안 맞으면 서서히 0으로 떨어짐
    float InstantStress = (DamageTakenThisTick > 0) ? 100.0f : 0.0f;

    // 지수 이동 평균(EMA) 업데이트
    CurrentStressEMA = (InstantStress * SmoothingFactor) + (CurrentStressEMA * (1.0f - SmoothingFactor));

    // 스트레스 지수에 따른 서버 통제 로직
    if (CurrentStressEMA > 80.0f)
    {
        // 유저가 극한의 스트레스(위험) 상태임!
        // -> 좀비 스폰율 감소, 체력 포션 드랍률 증가 (자비 베풀기)
        AdjustSpawnRate(0.5f);
        IncreasePotionDropRate();
    }
    else if (CurrentStressEMA < 20.0f)
    {
        // 유저가 너무 평온함 (게임이 지루해질 수 있음)
        // -> 특수 좀비 스폰, 웨이브 시작 (긴장감 부여)
        TriggerHordeWave();
    }
}

 

 

6. (심화) MMORPG 경제 통제 시스템 : 경제 감시관(Economy Watchdog)

 

MMORPG에서 골드는 몬스터 사냥으로 계속 수급할 수 있지만, 소모되지 않으면 결국 인플레이션이 발생해서 초보자들이 물건을 살 수 없는 최악의 상황이 발생하고 만다. 이러한 게임 내의 인플레이션 현상은 결국 뉴비들의 진입 장벽이 되어버리고 만다.

 

이를 해결하기 위해서는 예를 들어 1시간 단위로 서버 내의 총 골드 생성량과 총 골드 소모량을 추적한다. 만약 서버 내의 시간당 골드 수급량이 기존에 비해 급격하게 높아지는 상황을 생각해보자. 이 때 Z-Score(표준 점수)를 사용해서 통계적으로 일어날 확률을 계산해보고, 해당 확률이 이상치(Anomaly)라고 판단되면 관리자에게 알람을 울리게 된다. 그 다음 과정으로 골드가 너무 많이 풀렸다고 판단이 되면 수수료나 다른 게임 내의 장치를 활용하여 시장의 돈을 회수하는 로직도 구현할 수 있다.

 

(사례 : 경제 감시관)

void AServerEconomyManager::ProcessHourlyEconomyTick()
{
    float TotalGoldGenerated = DB_GetHourlyGoldGenerated();
    
    // 1. 과거 7일간의 이동 평균(Mean)과 표준편차(StdDev)를 DB에서 가져옴
    float HistoricalMean = DB_GetHistoricalMean();
    float HistoricalStdDev = DB_GetHistoricalStdDev();

    // 2. 현재 생성된 골드의 Z-Score 검사 (정상 범위를 얼마나 벗어났는가?)
    float ZScore = FMath::Abs((TotalGoldGenerated - HistoricalMean) / HistoricalStdDev);

    if (ZScore > 4.0f) 
    {
        // Z-Score 4.0 이상은 통계적으로 재난 수준의 이상치 (골드 복사 버그 발생, 또는 문제점 발생 유력)
        // -> 경매장 즉시 폐쇄 및 관리자 디스코드/슬랙으로 긴급 알림 전송
        HaltAuctionHouse();
        SendEmergencyAlert("CRITICAL: Massive Gold Dupe Suspected!");
    }
    else if (TotalGoldGenerated > HistoricalMean * 1.2f)
    {
        // 버그는 아니지만 골드가 평균보다 20% 더 많이 풀리고 있음 (인플레이션 조짐)
        // -> 경매장 수수료를 5%에서 8%로 동적 인상하여 골드 소모량(Sink) 증가 유도
        CurrentAuctionTaxRate = 0.08f;
        UE_LOG(LogEconomy, Warning, TEXT("Inflation detected. Raising Tax to 8%"));
    }
    else
    {
        // 경제가 안정적일 때는 세금 정상화
        CurrentAuctionTaxRate = 0.05f;
    }
    
    // 다음 시간대 분석을 위해 현재 데이터를 DB에 기록
    DB_LogEconomyData(TotalGoldGenerated);
}

 

 

 


<Theme 3 : 경제 시스템>

 

개인적으로 MMORPG의 경제 시스템, 즉 재화의 수급과 소모에 관심이 많아 추가로 더 통계학적인 개념을 공부해보았다. 결국 '거시 경제학(Macroeconomics)' 개념을 접할 수밖에 없다. 배운적도 있고 최근까지도 공부했었기 때문에 간단하게 어떤 개념이 사용되는지 알아보고 차후에 응용해보기로 했다.

 

 

1. 수도꼭지와 배수구 모델 (Source and Sink Model)

 

현실 세계와 달리 게임 세계에서는 허공에서 돈이 창조된다. 이를 수도꼭지(Source 또는 Faucet)라고 부른다. 반대로 NPC에게 물약을 사거나 수리비를 내거나 수수료를 내면 돈이 서버에서 영원히 소멸한다. 이를 배수구(Sink)라고 부른다. 즉, 현실 세계는 돈이 계속 왔다갔다하며 순환하는 구조이지만 게임 세계에서는 어딘가 돈이 만들어지면 어딘가에서 돈이 사라진다는 큰 차이점이 있다.

 

수학적 원리 : 서버 내 총 통화량(M)의 시간에 따른 변화율은 수도꼭지와 배수구의 차이로 표현할 수 있다.

따라서, 만약 좌변이 > 0  인 상태가 계속 유지되면 서버에 돈이 무한히 쌓여서 하이퍼 인플레이션이 발생하는 결과가 초래될 것이다. 서버 프로그래머는 이 기울기가 0에 수렴하도록 수학적으로 실시간으로 튜닝해야 한다.

 

 

2. 피셔의 화폐 수량 방정식 (Fisher's Equation of Exchange)

 

그럼 프로그래머는 통계적으로 말도 안되는 일이 벌어졌을 때만 사후에 해당 문제점을 감지하고 이를 고쳐나갈 수밖에 없는 걸까?

경제학자 피셔가 만든 수학 공식인 화폐 수량 방정식을 게임 DB에 대입해서 인플레이션을 미리 예측해서 발생할 문제점을 미리 예측하고 즉시 대응할 수 있도록 시스템을 만들어 놓을 수 있다.

 

수학적 공식 : M * V = P * Q

M(통화량) : 서버에 풀린 총 골드량

V(화폐 유통 속도) : 유저들끼리 골드를 얼마나 자주 교환하는가?

P(물가 수준) : 경매장 평균 아이템 가격

Q : 경매장에서 거래된 총 아이템 개수, 즉 아이템 생산량(ex) 드롭템 개수)

 

예를 들어 다음 업데이트가 경매장 컨텐츠라고 해보자. 그러면 업데이트 이후에는 화폐 유통 속도(V)가 급격하게 치솟을 것이다. M은 업데이트 이후라도 크게 변화가 없다고 본다면, 결국 Q(아이템 생산량)가 따라가지 못하면 수학적으로 P(가격)가 폭등하게 된다.

따라서 프로그래머는 서버가 V가 상승하는 것을 감지하는 즉시 Q를 늘리기 위해 상점 NPC가 일시적으로 필수 재료를 싸게 팔도록 하거나 몬스터의 드랍률을 일시적으로 올리는 자동 시장 개입을 단행하도록 만들어 놓을 수 있다.

 

 

 

3. 지니 계수 (Gini Coefficient)

 

고인물과 청정수의 빈부 격차로 발생하는 문제를 해결하기 위해 '지니 계수'를 사용할 수 있다. 서버가 주기적으로 유저들의 재산을 랭킹 순으로 줄 세운 뒤에 적분을 활용해서 지니 계수를 계산하고 누진세를 매기거나, 단계별 업그레이드 또는 레벨별 던전 입장료를 크게 올리는 방식으로 사용된다.

 

수학적 원리 : 지니 계수는 0(완벽한 평등)에서 1(부의 독점) 사이의 값을 가지고, 이 계수가 0.6을 넘어가면 심각한 불평등 상태이다.

지니 계수가 위험 수위를 넘으면, 서버는 부자들만 이용하는 콘텐츠의 골드 소모량을 기하급수적으로 늘리거나, 거래소 누진세를 적용해 고인물의 돈을 강제로 태워버리는(Gold Sink) 방식으로 격차를 조절한다.

 

다만 평등함을 과도하게 추구하게 되면 고인물들이 화가 나는 것은 당연. 게임에 흥미를 유발하고 기존 유저들도 만족할 수 있는 적정 수준의 관리가 필요하다.

 

(사례 : 지니 계수 측정과 동적 세금 적용)

#include "Math/UnrealMathUtility.h"

// 매주 월요일 새벽 4시, 서버의 경제 밸런스 측정 스케줄러가 돌아감
void AServerEconomyManager::CalculateGiniAndAdjustTaxes(TArray<float>& AllPlayerWealth)
{
    if (AllPlayerWealth.Num() == 0) return;

    // 1. 모든 유저의 재산을 오름차순(가난배 -> 부자)으로 정렬
    AllPlayerWealth.Sort();

    int32 N = AllPlayerWealth.Num();
    float TotalWealth = 0.0f;
    float CumulativeGiniSum = 0.0f;

    // 2. 로렌츠 곡선(Lorenz Curve) 면적 적분을 위한 수치 계산
    for (int32 i = 0; i < N; ++i)
    {
        TotalWealth += AllPlayerWealth[i];
        // 위치 가중치 부여 (부자일수록 인덱스 값이 커짐)
        CumulativeGiniSum += (i + 1) * AllPlayerWealth[i];
    }

    // 3. 지니 계수 공식 적용 (0.0: 완전 평등 ~ 1.0: 완전 불평등)
    float GiniCoefficient = (2.0f * CumulativeGiniSum) / (N * TotalWealth) - ((N + 1.0f) / N);

    UE_LOG(LogEconomy, Log, TEXT("이번 주 서버 지니 계수: %f"), GiniCoefficient);

    // 4. 거시 경제 통제 로직 (누진세 및 부유세 조정)
    if (GiniCoefficient >= 0.6f) 
    {
        // 빈부격차가 폭동 수준임.
        // 초보자 거래 수수료는 면제(0%)하고, 고액 거래(하이엔드) 수수료를 15%로 폭등시킴
        BaseAuctionTax = 0.0f;
        LuxuryAuctionTax = 0.15f; 
        
        UE_LOG(LogEconomy, Warning, TEXT("빈부격차 심각! 하이엔드 누진세(15%) 긴급 발동!"));
    }
    else if (GiniCoefficient <= 0.3f)
    {
        // 너무 평등해서 성취감이 떨어질 수 있음 (공산주의 메타;;)
        // 전체 수수료를 낮춰 거래 활성화 유도
        BaseAuctionTax = 0.03f;
        LuxuryAuctionTax = 0.05f;
    }
}

*개인 프로젝트 및 개인 공부*

1. 디자인 패턴 공부(별도로 카테고리를 분류해서 정리)

디자인 패턴 중 구조(Structure) 패턴의 3가지 케이스, 컴포넌트/데코레이터/퍼사드 패턴들을 정리하고 언리얼 C++ 실무에도 가볍게 적용해보는 연습을 했다.

 

2. 언리얼 게임 만들기

프로젝트를 만들기 보다는, 떠오르는 게임 진행 흐름 및 게임 기능 하나를 테마로 잡고, 플레이어가 거쳐가는 시간 순서대로 직접 언리얼 게임으로 만들어보는 연습을 했다. 

 



*오늘의 총평*

오전 : C++ 코딩테스트 준비 - C++ 디자인 패턴

오후 : 게임 수학 공부 -  언리얼 게임 만들기

저녁 : 팀프로젝트 - 운동 - 개인 공부

순으로 밀도 있게 학습했다. 만족스러운 학습량이었다. 배우는 게 많아질 수록 바라보는 시각도 넓어지는 것 같아서 좋았다.

'TIL' 카테고리의 다른 글

06.24 TIL  (0) 2026.06.24
06.23 TIL  (0) 2026.06.23
06.19 TIL  (0) 2026.06.19
06.18 TIL  (0) 2026.06.18
06.17 TIL  (0) 2026.06.17