TIL
07.29 TIL
2026. 7. 29. 18:55

일정표

시간 할 일 비고
07:00~10:00 코드카타, 오늘의 수학, 멀티플레이 강의  
10:00~13:00 멀티플레이 강의  
14:00~18:00 라이라 파먹기, 멀티플레이 강의  
19:00~21:00 멀티플레이 강의, TIL, 개인 프로젝트  
21:00~22:00 운동  
22:00~23:30 개인 프로젝트  

*오늘의 코드카타*

 

문제. 개인정보 수집 유효기간

더보기

고객의 약관 동의를 얻어서 수집된 1~n번으로 분류되는 개인정보 n개가 있습니다. 약관 종류는 여러 가지 있으며 각 약관마다 개인정보 보관 유효기간이 정해져 있습니다. 당신은 각 개인정보가 어떤 약관으로 수집됐는지 알고 있습니다. 수집된 개인정보는 유효기간 전까지만 보관 가능하며, 유효기간이 지났다면 반드시 파기해야 합니다.

예를 들어, A라는 약관의 유효기간이 12 달이고, 2021년 1월 5일에 수집된 개인정보가 A약관으로 수집되었다면 해당 개인정보는 2022년 1월 4일까지 보관 가능하며 2022년 1월 5일부터 파기해야 할 개인정보입니다.

당신은 오늘 날짜로 파기해야 할 개인정보 번호들을 구하려 합니다.

모든 달은 28일까지 있다고 가정합니다

 

오늘 날짜를 의미하는 문자열 today, 약관의 유효기간을 담은 1차원 문자열 배열 terms와 수집된 개인정보의 정보를 담은 1차원 문자열 배열 privacies가 매개변수로 주어집니다. 이때 파기해야 할 개인정보의 번호를 오름차순으로 1차원 정수 배열에 담아 return 하도록 solution 함수를 완성해 주세요.

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

using namespace std;

int convertToDays(const string& date) {
    int year = stoi(date.substr(0, 4));
    int month = stoi(date.substr(5, 2));
    int day = stoi(date.substr(8, 2));
    
    return (year * 12 * 28) + (month * 28) + day;
}

vector<int> solution(string today, vector<string> terms, vector<string> privacies) {
    vector<int> answer;
    int today_days = convertToDays(today);
    unordered_map<char, int> term_map;
    
    for (const string& term : terms) {
        stringstream ss(term);
        
        char type;
        int month;
        
        ss >> type >> month;
        term_map[type] = month;
    }

    for (int i = 0; i < privacies.size(); ++i) {
        string date = privacies[i].substr(0, 10);
        
        char type = privacies[i][11];
        int collected_days = convertToDays(date);
        int expire_days = collected_days + (term_map[type] * 28); 
 
        if (expire_days <= today_days) {
            answer.push_back(i + 1);
        }
    }
    
    return answer;
}

1) 날짜의 총 일수 변환 공식 : 총 일수 = Year * 12 * 28 + Month * 28 + Day

2) 파기 시작일 = 수집일 총 일수 + 유효기간 개월 수 * 28

3) 단계별 로직

today 날짜 변환: 오늘 날짜 문자열("YYYY.MM.DD")을 총 일수 정수로 변환.

terms 해시 맵 저장: 약관 종류(char)를 key로, 유효기간 개월 수(int)를 value로 갖는 unordered_map을 생성.

privacies 순회 및 비교: 각 개인정보의 수집 날짜와 약관 종류를 추출. 수집 날짜의 총 일수를 구하고, 약관에 해당하는 유효기간을 더해 파기 시작일을 구한다. 이후, 파기 시작일 <= 오늘 총 일수 조건이 성립하면 해당 번호(1-based 인덱스 i + 1)를 정답 배열에 담는다.

4) 주요 사용 메서드

 

substr 및 stoi : "2021.05.02"에서 연/월/일을 위치 인덱스로 즉시 잘라내어 정수로 변환.

stringstream : terms 배열의 "A 6"과 같이 공백으로 구분된 문자열을 char 타입과 int 타입 변수로 분리

 


*수학 공부*

주제 : 제약 조건 물리 솔버(Constraint Solvers) 

 

'제약 조건 물리 솔버 (Constraint Solvers)'는 물리 엔진(PhysX, Box2D, 그리고 언리얼 카오스 물리)의 심장부이다.

단순한 F=ma를 넘어서, 캐릭터의 Ragdoll, 로프, 문고리 관절, 그리고 "물체가 서로 파고들지 못하게 막는 충돌"을 물리 엔진이 어떻게 수학적으로 해결하는지 알아보자.


1. 제약 조건(Constraint)이란 무엇인가?

 

물리 엔진에서 아무런 제약이 없는 물체는 6자유도(X, Y, Z 이동 + 3축 회전)가 가능하다. 하지만 두 물체가 로프(Rope)로 묶여 있거나, 힌지(Hinge) 관절로 연결되어 있거나, 바닥에 대고 충돌해 있다면 자유도에 제한이 생기게 된다. 이 제한을 수학 공식으로 표현한 것이 바로 제약 조건 수식 C(q)이다.

 

① 등가 제약 조건 (Equality Constraint): C(q) = 0

  • 예시: 로프나 관절.
  • 물체 A와 물체 B 사이의 거리가 정확히 L이어야 한다.
  • 수식: C(x_A, x_B) = |x_A  -  x_B| - L = 0

② 부등가 제약 조건 (Inequality Constraint): C(q) >= 0

  • 예시: 물체 간의 충돌(Non-penetration).
  • 두 상자가 스쳐 지나가는 건 괜찮지만(>0), 딱 붙는 것도 괜찮지만(=0), 서로 파고드는 것(<0)은 불가능.
  • 수식: C(x_A, x_B) = 침투 깊이 >= 0

2. 위치에서 속도로: 야코비 행렬(Jacobian, J)의 등판

 

위치 제약 조건 C(q) = 0을 만족시키려면, 물리 엔진은 매 프레임 물체의 '속도(Velocity)'나 '충격량(Impulse)'을 더해주어야 한다.

이때 위치 수식 C(q)를 시간에 대해 미분(연쇄 법칙)하는 수학적 방법을 사용한다.

 

  • J의 역할: "위치 공간의 제약 조건"을 "속도 공간의 구속 조건"으로 바꿔주는 교환창구.

즉, 물리 솔버의 핵심 과제는 J_v = 0 을 만족하도록 물체들의 속도 v에 충격량(Impulse lambda)을 얼마나 가할 것인가?"라는 선형 방정식 시스템을 푸는 문제로 바뀐다.


 

3. 현대 물리 엔진의 2대 솔버 패러다임

 

위의 거대한 행렬 방정식을 매 프레임 수천 개의 충돌체에 대해 푸는 건 컴퓨터에게 부담을 줄 수 있다. 따라서 물리 엔진은 두 가지 대중적인 알고리즘으로 이 문제를 해결한다.

 

① 순차적 충격량 (Sequential Impulses / PGS) - Box2D, PhysX 기본

  • 원리: 거대한 행렬을 한 번에 풀지 않고, 제약 조건(관절 1, 관절 2, 충돌 1...)을 하나씩 순서대로 순회하면서 충격량을 조금씩 누적(Iteration)한다.
  • 특징: 보통 4~8번의 반복(Iteration)만 돌려도 물리적인 제약 조건이 꽤 그럴듯하게 만족된다. 상용 물리 엔진의 표준.

위치 기반 물리학 (Position-Based Dynamics, PBD / XPBD) - 언리얼 5 Chaos Physics

  • 원리: 속도와 충격량을 거치지 않고, 물체의 위치(x)를 제약 조건에 맞게 직접 당겨서 수정한다.
  • 특징: 충격량 기반 솔버에서 발생하는 "로프가 스프링처럼 늘어나는 현상"이나 "랙돌 관절이 터지는 현상"이 전혀 없다. 천(Cloth), 유체, 랙돌, 로프처럼 안정성이 최우선인 최신 엔진(Chaos Physics)의 핵심 솔버.

4. 실무 C++ 개념 코드: PBD 기반 거리 제약 조건 (Distance Constraint)

언리얼 엔진 카오스(Chaos) 물리 솔버의 가장 기초적인 단위인 거리 제약 조건(두 점 사이의 거리를 유지)을 맞추는 PBD C++ 로직

더보기
// PBD(Position-Based Dynamics) 거리 제약 조건 솔버
struct FDistanceConstraint
{
    int32 ParticleA;
    int32 ParticleB;
    float TargetDistance; // 유지해야 할 목표 거리 (L)

    void SolveConstraint(TArray<FVector>& Positions, TArray<float>& InvMasses)
    {
        float InvMassA = InvMasses[ParticleA];
        float InvMassB = InvMasses[ParticleB];
        float TotalInvMass = InvMassA + InvMassB;

        if (TotalInvMass <= 0.0f) return; // 둘 다 고정된(무한대 질량) 물체면 건너뜀

        // 1. 현재 두 물체 사이의 위치 벡터 및 거리 계산
        FVector Delta = Positions[ParticleA] - Positions[ParticleB];
        float CurrentDistance = Delta.Size();

        if (CurrentDistance < 0.0001f) return; // 0으로 나누기 방지

        // 2. 제약 조건 오차 C(q) 계산: (현재 거리 - 목표 거리)
        float ConstraintError = CurrentDistance - TargetDistance;

        // 3. 오차를 질량 비율에 따라 보정할 이동 방향 벡터 도출
        FVector Direction = Delta / CurrentDistance;
        FVector Correction = Direction * (ConstraintError / TotalInvMass);

        // 4. 속도를 거치지 않고 위치를 직접 보정! (PBD의 핵심)
        // 가벼운 물체가 더 많이 끌려옴
        Positions[ParticleA] -= Correction * InvMassA;
        Positions[ParticleB] += Correction * InvMassB;
    }
};

 

<1차 요약>

 

  • 제약 조건: 관절, 로프, 충돌처럼 물체의 자유도를 제한하는 수학적 조건식 C(q) = 0.
  • 야코비 행렬 (J): 위치 공간의 제약 조건을 물리 엔진이 다루기 쉬운 속도/충격량 공간으로 미분 변환해 주는 행렬.
  • 솔버 (Solver): 수천 개의 제약 조건 수식을 매 프레임 수치적으로 반복 연산(PGS 또는 PBD)하여 물체를 제자리에 가져다 놓는 물리 엔진의 연산기.

그렇다면 언제 '제약 조건 솔버'의 원리를 떠올리고 활용해야 할까?

 

1. 물리가 '폭발(Explosion)'하거나 '떨리는(Jittering)' 버그를 고칠 때

 

게임 개발을 하다 보면 래그돌 관절이 미친 듯이 꺾이며 하늘로 튕겨 나가거나, 상자를 쌓아뒀는데 지진이 난 것처럼 달달달 떨리는 현상을 무조건 겪게 된다. 이때 솔버의 원리를 모르면 Mass나 Friction 값만 아무렇게나 바꾸며 기도하는 수밖에 없다. 하지만 원리를 아는 프로그래머는 솔버의 수학적 한계를 파악하고 세 가지 핵심 파라미터를 조절한다.

  • 솔버 반복 횟수 (Solver Iterations): "순차적 충격량(PGS)이나 PBD에서 제약 조건을 푸는 루프 횟수가 부족하다고 판단" -> Iteration을 4에서 8로 올려 오차를 줄임.
  • 서브스텝 (Sub-stepping): "물체의 속도가 너무 빨라서 한 프레임동안 침투 오차 C(q)가 야코비 행렬이 감당할 범위를 넘어섰다고 판단" -> 물리 프레임을 쪼개서 연산함.
  • 질량비(Mass Ratio) 문제: 1000kg짜리 차가 0.1kg짜리 공에 부딪힐 때 M^-1 행렬 연산에서 수치적 불균형(Numerical Instability)이 생김 -> 질량을 오차 범위 내로 보정함.

2. 엔진이 제공하지 않는 '특수 기믹/차량/메카닉'을 직접 만들 때

 

언리얼 엔진이 Physics Constraint Component (경첩, 볼조인트 등)를 제공하지만, 기획서에 특이한 기믹이 들어오면 기본 컴포넌트로는 구현이 불가능하다.

  • 예시 1 (특수 탈것/지게차/로봇): 복잡한 유압식 서스펜션, 톱니바퀴 기어 연결, 다자유도 로봇 팔.
  • 예시 2 (특수 무기): 입체기동장치나 그래플링 훅, 타이트하게 당겨지는 와이어 와이어 로프.
  • 예시 3 (천 및 소프트바디): 펄럭이는 돛단배의 돛, 캐릭터의 머리카락/사슬.

이때 프로그래머는 "두 물체 사이의 위치/각도 관계식 C(q) = 0을 직접 수식으로 정의"하고, 언리얼 5 카오스(Chaos) 물리 엔진의 C++ API를 활용해 커스텀 PBD 제약 조건 노드를 작성해야 한다.

 

3. 역운동학(IK, Inverse Kinematics) 시스템을 짤 때

 

물리 엔진의 제약 조건 솔버에서 쓰이는 야코비 행렬(J)은 캐릭터 애니메이션 IK(Inverse Kinematics)에 그대로 똑같이 쓰인다.

  • 상황: 캐릭터의 손(End Effector)을 목표 지점 X로 이동시키고 싶을 때, 어깨, 팔꿈치, 손목 관절들의 회전각(theta_1, theta_2, theta_3)을 돌려야 하는 상황.
  • 수학적 구조: 관절의 회전 속도 와 손끝의 이동 속도 사이의 관계가 야코비 행렬. (v = J dot_theta)
  • 실무 포인트: 관절이 3개 이상 넘어가는 다구절 IK(로봇 다리, 오징어 다리, 꼬리)를 계산할 때 Jacobian Pseudo-Inverse(의사역행렬)나 Jacobian Transpose 기법을 써서 관절 각도를 역산해 낸다. 물리 솔버의 J 개념을 아는 사람은 IK 알고리즘도 쉽게 이해할 수 있다.

4. 언리얼 5 카오스(Chaos Physics)의 컴플라이언스(Compliance) 튜닝

언리얼 엔진 5는 기존 PhysX를 버리고 XPBD (Extended Position-Based Dynamics) 기반의 Chaos 엔진을 채택했다.

XPBD는 위치 제약 조건에 '컴플라이언스(Compliance, alpha)'라는 강도 수학 개념을 도입했다.

  • alpha = 0 : 절대 늘어나지 않는 딱딱한 강체 (Hard Constraint)
  • alpha > 0 : 스프링처럼 유연하게 늘어나는 제약 조건 (Soft Constraint)

실무에서 천(Cloth)의 뻣뻣함이나, 캐릭터 옷의 살랑거림, 물리 밧줄의 팽팽함을 튜닝할 때 "컴플라이언스와 프레임 타임(Delta t)의 관계식"을 이해하고 있어야 델타 타임에 독립적인 일정한 물리 반응을 끌어낼 수 있다.

 

<2차 요약>

 

  • 평소에는: 엔진이 제공하는 물리 제약 컴포넌트를 그냥 편하게 쓴다.
  • 버그 발생 시: "솔버의 Iteration이 부족하거나 질량비 오차 때문에 야코비 행렬 솔버가 수렴(Convergence)하지 못한 상황을 파악" 하고 파라미터를 정확하게 수정한다.
  • 고급 개발 시: 엔진 기본 기능으로 못 만드는 특수 관절, 와이어, 로봇 IK, 파괴 로직을 C++ 수식(C(q)=0)으로 구현해 낸다.

 


이왕 이렇게 된거 IK까지 연계해서 알아보자.

 

IK는 단순한 애니메이션 기능을 넘어서, '수학(삼각함수, 벡터, 사원수) + 레이캐스트(물리) + 애니메이션 렌더링'이 완벽하게 삼위일체로 맞물려 돌아가는 실무의 정수이다.

IK 시스템에 쓰이는 수학적 원리, 언리얼 에디터의 최신 구현 방식, 그리고 C++ 프로그래머의 실제 코드 작업 파이프라인을 3단계로 정리해 보자.

 

 

1. IK의 수학적 원리: FK vs IK, 그리고 핵심 알고리즘

  • FK (Forward Kinematics, 순운동학): 어깨 각도 -> 팔꿈치 각도 -> 손목 각도를 순차적으로 회전시켜 손 끝의 위치를 구함. (부모 -> 자식 방향 연산)
  • IK (Inverse Kinematics, 역운동학): "손끝(End Effector)이 목표 좌표 (X,Y,Z)에 가야 한다"는 결과만 주고, 그 위치를 맞추기 위해 어깨와 팔꿈치가 몇 도 회전해야 하는지 거꾸로 계산함. (자식의 목표 -> 부모의 회전각 역산)

IK를 풀기 위한 2가지 핵심 수학 알고리즘을 살펴보자.

 

2-Bone IK (코사인 제2법칙) — 다리, 팔 등에 쓰이는 O(1) 수학

허벅지-종아리-발발목처럼 뼈가 2개(관절 3개)인 연쇄 구조는 복잡한 행렬을 풀 필요 없이 '코사인 제2법칙'만으로 정확한 각도가 나온다.

  1. a, b: 허벅지와 종아리 뼈의 고정된 길이.
  2. c: 골반(Root)에서 목표 발바닥 위치(Target)까지의 직선거리.
  3. 위 공식을 이용해 무릎 관절 각도 C를 수학적으로 바로 유도해 낸 다음, 무릎을 그 각도만큼 굽혀버리면 끝. 연산량이 거의 0에 가까워서 매 프레임 수십 명의 캐릭터에 돌려도 렉이 없다.

FABRIK (Forward And Backward Reaching IK) — 척추, 꼬리, 텐터클용

뼈가 3개 이상인 긴 연쇄 구조(예: 오징어 다리, 뱀, 척추)는 삼각함수로 풀 수 없다. 이때는 회전각 대신 점의 위치를 앞뒤로 왕복하며 당기는 geometric(기하학적) 알고리즘인 FABRIK을 쓴다. (삼각함수나 행렬 연산이 없어서 빠름).

 

 

2. 지형 착지(Foot IK)의 수학적 메커니즘 (Normal Vector)

 

캐릭터가 경사로나 계단을 걸어갈 때 발이 공중에 뜨거나 땅을 뚫지 않게 만드는 Foot IK의 3단계 수학 알고리즘이다.

  1. 1단계: 레이캐스트 (Line Trace)
    • 캐릭터의 발 위치에서 바닥 아래로 직하방 레이저(Ray)를 쏘아 충돌 지점 P_hit 와 바닥의 법선 벡터(Impact Normal Vector, N)를 구한다.
  2. 2단계: 높이 보정 (Translation - 2-Bone IK)
    • 왼발과 오른발의 높이가 다르면, 캡슐(Root)의 높이를 낮은 발에 맞춰 아래로 내린 뒤, 양쪽 발 위치에 앞서 배운 2-Bone IK (코사인 제2법칙)를 적용해 무릎을 부드럽게 굽혀준다.
  3. 3단계: 발목 회전 보정 (Rotation - Quaternion Alignment)
    • 발바닥의 평면 벡터 V_up을 바닥의 법선 벡터 N과 완벽히 평행하도록 회전시켜야 한다.
    • 이때 외적(Cross Product)으로 회전축을 구하고, 내적(Dot Product)으로 회전 각도를 구한 뒤, 이를 쿼터니언(Quaternion / FQuat)으로 변환하여 발목 뼈에 회전을 덮어씌운다.

 

3. 언리얼 에디터에서의 적용법 (Control Rig & AnimGraph)

 

언리얼 엔진 5(UE5)에서 IK를 적용하는 방법은 크게 두 가지로 진화했다.

 

① 언리얼 5의 표준: 컨트롤 리그 (Control Rig)

 

과거에는 애니메이션 블루프린트 안에서 어렵게 짰지만, UE5부터는 Control Rig라는 절차적 애니메이션 툴을 사용한다.

  • 에디터 내에서 Basic IK 노드나 Full Body IK (FBIK) 노드를 드래그 앤 드롭해 뼈대 계통을 묶어주고,
  • 리타겟팅, 발 디딤, 손잡기 동작 등을 에디터 상에서 시각적인 그래프 노드로 구현할 수 있다.

② 애니메이션 블루프린트 (AnimGraph)

  • Two Bone IK 노드나 Leg IK 노드를 스켈레탈 메쉬 포즈 흐름 사이에 꽂아 넣는 방식이다.
  • C++에서 계산해 준 "발 목표 위치"와 "발 회전값"을 전달받아 최종 포즈를 변형(Warping)한다.

 

4. C++ 프로그래머의 실제 실무 파이프라인

 

프로그래머는 애니메이터나 에디터 노드에 의존하지 않고, 물리 레이캐스트를 돌려 오프셋과 회전값을 수학적으로 계산한 뒤 애니메이션 시스템으로 전달하는 역할을 한다.

 

[C++ 실전 구현: Foot IK 트레이스 및 회전 도출 로직]

더보기
// CustomAnimInstance.cpp (매 프레임 캐릭터 애니메이션 업데이트 시 호출)
void UMyAnimInstance::UpdateFootIK(float DeltaTime)
{
    ACharacter* Character = Cast<ACharacter>(TryGetPawnOwner());
    if (!Character) return;

    // 1. 왼발의 현재 소켓(Bone) 월드 위치 가져오기
    FVector LeftFootLocation = Character->GetMesh()->GetSocketLocation(TEXT("Foot_L"));

    // 2. 바닥을 향해 물리 레이캐스트(Line Trace) 수행
    FVector TraceStart = FVector(LeftFootLocation.X, LeftFootLocation.Y, Character->GetActorLocation().Z);
    FVector TraceEnd = TraceStart - FVector(0.0f, 0.0f, 110.0f); // 바닥으로 110유닛 쏨

    FHitResult HitResult;
    FCollisionQueryParams QueryParams;
    QueryParams.AddIgnoredActor(Character); // 자기 자신 충돌 제외

    bool bHit = GetWorld()->LineTraceSingleByChannel(HitResult, TraceStart, TraceEnd, ECC_Visibility, QueryParams);

    if (bHit)
    {
        // 3. [높이 오차 계산] 바닥 충돌 지점과 발 소켓 사이의 Z축 차이 도출
        float TargetOffset = (HitResult.ImpactPoint - TraceEnd).Z - 15.0f; // 발바닥 두께 보정
        LeftFootZOffset = FMath::FInterpTo(LeftFootZOffset, TargetOffset, DeltaTime, 15.0f); // Lerp로 부드럽게 보정

        // 4. [회전 보정 계산] 바닥의 법선 벡터(Normal)와 캐릭터 UpVector 사이의 회전 사원수(Quat) 도출
        FVector ImpactNormal = HitResult.ImpactNormal;
        
        // 내적과 외적을 통해 Normal 방향으로 기울이는 Rotator 수학적 추출
        FRotator TargetRotation = FRotationMatrix::MakeFromZX(ImpactNormal, Character->GetActorForwardVector()).Rotator();
        LeftFootRotation = FMath::RInterpTo(LeftFootRotation, TargetRotation, DeltaTime, 15.0f);
    }
    else
    {
        // 공중에 떠 있을 때는 오셋을 0으로 복원
        LeftFootZOffset = FMath::FInterpTo(LeftFootZOffset, 0.0f, DeltaTime, 15.0f);
        LeftFootRotation = FMath::RInterpTo(LeftFootRotation, FRotator::ZeroRotator, DeltaTime, 15.0f);
    }
}

 

이렇게 C++ 코드로 계산된 LeftFootZOffset과 LeftFootRotation 변수를 애니메이션 에디터(AnimGraph)의 Two Bone IK 노드의 Target Location과 Target Rotation에 핀으로 연결해 주면, 캐릭터가 어떤 지형을 걸어가든 계단과 경사면에 발이 찰떡같이 붙어 다니는 IK 시스템이 완성된다.

 

<3차 요약>

 

  • 수학: 관절 2개는 삼각함수(코사인 제2법칙)로 완벽 연산, 발목 회전은 법선 벡터(Normal)와 쿼터니언으로 기울임 보정.
  • 에디터: UE5의 Control RigAnimGraph (Two Bone IK) 노드로 뼈대를 직접 조종.
  • 프로그래머: 매 프레임 C++로 Line Trace를 쏘아 지형의 높이 오차와 법선 벡터를 읽어내고, 이를 부드럽게 보정(Interp)해서 애니메이션 시스템에 쏴주는 역할.

 



*라이라 파먹기*

주제 : Source 폴더(1) _ 기본 파일

 

1) LyraGame.Build.cs

 

Build.cs 파일은 C++ 코드가 아니라 C#으로 작성된 언리얼 빌드 툴(UBT, UnrealBuildTool) 제어 레시피이다. 이 파일에서 어떤 외부 모듈을 의존성(Dependency)으로 추가하느냐에 따라 #include할 수 있는 헤더 파일과 사용할 수 있는 C++ 클래스의 범위가 결정된다.

 

 

① PCH (Precompiled Header) 최적화

PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;

 

역할 및 메모리/빌드 매커니즘: 컴파일 속도를 극대화하기 위한 설정. 자주 변경되지 않는 엔진 표준 헤더(Engine.h, CoreUObject.h 등)를 미리 컴파일된 헤더(PCH)로 묶어서 재사용함으로써 C++ 전체 재빌드 시간을 수 분 이상 단축시킨다.

 

 

② PublicDependencyModuleNames (공개 의존성 모듈)

 

LyraGame 모듈의 공개 헤더 파일(.h)에서 직접 #include하여 외부에 노출되는 모듈들이다. 이 곳에 포함된 모듈들은 LyraGame을 참조하는 다른 모듈(예: LyraEditor)에서도 함께 접근할 수 있게 된다.

  • Core / CoreOnline / CoreUObject / Engine / ApplicationCore: 언리얼 엔진의 가장 근본이 되는 최하위 프레임워크이다. UObject, AActor, UWorld, 메모리 할당자, 컨테이너(TArray, TMap) 등을 제공한다.
  • GameplayTags / GameplayTasks / GameplayAbilities: 언리얼의 GAS(Gameplay Ability System) 핵심 3대장 모듈이다.
  • ModularGameplay / ModularGameplayActors: 라이라 아키텍처의 뿌리가 되는 모듈형 액터 모듈이다. AModularCharacter, AModularPlayerController 등 컴포넌트를 동적으로 주입받아 초기화 단계를 거치는 뼈대 클래스들을 제공한다.
  • GameFeatures: 메인 C++ 코드를 건드리지 않고 플러그인 형태로 기능을 뗐다 붙였다 하는 GameFeatures 프레임워크의 핵심 제어 모듈이다.
  • AsyncMixin: C++ 코드 내부에서 코루틴(Coroutine)과 유사하게 비동기 작업(에셋 로딩, 지연 처리)을 매끄럽게 체이닝할 수 있는 FAsyncMixin 구조를 제공한다.
  • ControlFlows: 여러 개의 비동기 로딩 및 초기화 작업(A 로드 ➔ B 초기화 ➔ C 완료)을 순차적/병렬적 흐름(Flow)으로 제어하게 해주는 고급 비동기 제어 모듈이다.
  • DataRegistry: 엑셀/CSV 데이터 테이블을 런타임 메모리에 고성능으로 캐싱하고 조회하는 엔진 데이터 레지스트리 시스템이다.
  • ReplicationGraph / SignificanceManager: 대규모 액터 네트워크 동기화 최적화(ReplicationGraph) 및 화면 거리/중요도에 따라 틱(Tick) 주기를 자동 조절하는 성능 최적화 모듈(SignificanceManager)이다.
  • Niagara / PhysicsCore: UE5 차세대 이펙트 시스템인 나이아가라 및 물리 코어 연동 모듈이다.

 

③ PrivateDependencyModuleNames (비공개 의존성 모듈)

 

LyraGame 모듈 내부의 .cpp 파일에서만 조용히 #include하여 사용하고, 외부 헤더(.h)에는 노출하지 않는 모듈들이다. 의존성을 캡슐화하여 헤더 오염을 막고 컴파일 속도를 유지한다.

  • EnhancedInput: UE5 표준 입력 시스템인 향상된 입력(Enhanced Input) 모듈이다.
  • Slate / SlateCore / UMG: 언리얼 C++ UI 렌더링 엔진인 Slate와 UMG 프레임워크다.
  • CommonUI / CommonInput / CommonGame / CommonUser / UIExtension: 라이라 UI의 핵심인 CommonUI 스택 프레임워크 모듈들이다. 레이어 기반 UI, 게임패드/키보드 cross-input 통제, UI 동적 확장(UIExtension)을 담당한다.
  • GameplayMessageRuntime: 결합도 0%의 라디오 주파수 통신망인 UGameplayMessageSubsystem의 런타임 모듈이다.
  • DeveloperSettings: C++ 클래스의 변수를 에디터의 Project Settings 메뉴에 아름다운 UI로 자동 노출해 주는 UDeveloperSettings 베이스 클래스를 제공한다.
  • Gauntlet / ClientPilot: 에픽게임즈가 서버 안정성 및 자동화 테스트(CI/CD)를 위해 사용하는 자동 봇 주행 및 프로파일링 프레임워크다.
  • AudioMixer / AudioModulation: C++ 사운드 통제용 오디오 모듈레이션 시스템이다.

 

④ 조건부 매크로 선언 및 보안 세팅

// 1. Shipping 빌드에서 DrawDebug 사용 시 컴파일 에러 유발
PublicDefinitions.Add("SHIPPING_DRAW_DEBUG_ERROR=1");

// 2. 외부 RPC 및 HTTP 서버 기능 통제 (보안 디버깅)
if (Target.Configuration == UnrealTargetConfiguration.Shipping)
{
    PublicDefinitions.Add("WITH_RPC_REGISTRY=0");
    PublicDefinitions.Add("WITH_HTTPSERVER_LISTENERS=0");
}

 

보안 노하우: 최종 배포(Shipping) 빌드에서 개발자용 디버그 시각화 함수(DrawDebugString, DrawDebugSphere 등)가 남아있으면 즉시 컴파일 에러를 뿜도록 강제한다. 또한 툴용 HTTP Listeners나 RPC 백도어를 Shipping에서 완전히 짤라내어(Strip) 해킹 및 보안 취약점을 근본적으로 차단한다.

 

 

⑤ 언리얼 특수 기능 디버거 연동

SetupGameplayDebuggerSupport(Target); // Gameplay Debugger (') 키 연동
SetupIrisSupport(Target);             // UE5 차세대 고성능 복제 엔진 Iris 활성화

 

2) LyraGameModule.cpp

 

메인 모듈 진입점 (LyraGame.cpp)

class FLyraGameModule : public FDefaultGameModuleImpl
{
    virtual void StartupModule() override {}
    virtual void ShutdownModule() override {}
};

IMPLEMENT_PRIMARY_GAME_MODULE(FLyraGameModule, LyraGame, "LyraGame");
  • 메모리 & 생명주기: 게임 DLL/소프트웨어가 메모리에 로드될 때 가장 먼저 실행되는 메인 C++ 모듈 클래스다. StartupModule()은 C++ 전역 서브시스템이나 커스텀 메모리 할당자가 켜질 때 호출되며, ShutdownModule()은 게임 종료 시 자원을 해제한다.
  • IMPLEMENT_PRIMARY_GAME_MODULE: 언리얼 엔진에 이 모듈이 메인 게임 모듈임을 알려주는 매크로다.

 

3) LyraGameplayTags.h / cpp

 

C++ Native GameplayTags (LyraGameplayTags.h / .cpp)

 

라이라는 ini 파일뿐만 아니라 C++ 코드 단에서도 오타 없이 고성능으로 태그를 다룰 수 있도록 Native GameplayTag 매핑 규격을 사용한다.

 

C++ 선언 및 정의 패턴

  • 헤더 (.h): LYRAGAME_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Ability_ActivateFail_IsDead);
  • 소스 (.cpp): UE_DEFINE_GAMEPLAY_TAG_COMMENT(Ability_ActivateFail_IsDead, "Ability.ActivateFail.IsDead", "설명");

핵심 설계 포인트

  1. FNativeGameplayTag의 성능: 문자열("Ability.ActivateFail.IsDead")을 매번 검색(Find)하는 것이 아니라, 엔진 초기화 시 C++ 정적 객체(FGameplayTag)에 주소를 1:1 매핑해 둔다. 코드에서 LyraGameplayTags::Ability_ActivateFail_IsDead로 접근하므로 오타 위험 0%, FName 해시 비교 수준의 속도를 자랑한다.
  2. MovementModeTagMap: 언리얼 CharacterMovementComponent의 레거시 EMovementMode Enum(MOVE_Walking, MOVE_Falling 등)을 GAS 및 애니메이션 시스템이 알아들을 수 있는 FGameplayTag (Movement.Mode.Walking 등)로 1:1 맵핑(TMap<uint8, FGameplayTag>)해 두어 Enum과 Tag 간의 다리를 놓았다.
  3. FindTagByString 비동기/부분 검색 함수: 문자열로 태그를 정밀 검색하거나, 못 찾았을 경우 부분 일치(Partial Match) 태그를 추적해 주는 디버깅용 헬퍼 유틸리티 함수다.

 

4) LyraLogChannels.h / cpp

 

커스텀 로그 채널 및 컨텍스트 (LyraLogChannels.h / .cpp)

DEFINE_LOG_CATEGORY(LogLyra);
DEFINE_LOG_CATEGORY(LogLyraExperience);
DEFINE_LOG_CATEGORY(LogLyraAbilitySystem);
DEFINE_LOG_CATEGORY(LogLyraTeams);
  • DECLARE/DEFINE_LOG_CATEGORY: 라이라는 단순 UE_LOG(LogTemp, ...)를 절대 쓰지 않고, 시스템별로 로그 채널을 엄격히 분리한다. 출력 창이나 텍스트 파일에서 LogLyraExperience만 필터링하여 경험 로딩 파이프라인의 버그를 즉시 추적할 수 있다.

GetClientServerContextString 헬퍼 함수

FString GetClientServerContextString(UObject* ContextObject)

 

디버깅: 이 함수에 UObject(Actor나 Component)를 전달하면, 현재 그 객체가 실행되는 주체가 Server(권한자)인지, Client(복제본)인지, 아니면 PIE(Play In Editor) 에디터 세션인지를 명확한 문자열("Server" / "Client")로 반환해 준다. 멀티플레이 및 네트워크 동기화 버그를 잡을 때 전역 로그 앞에 붙여서 사용되는 에픽게임즈의 유틸리티이다.



*멀티플레이 게임 만들기 강의*

1-1 : 채팅 프로젝트 만들기

GameModeBase, PlayerController, Build.cs, 게임 프레임워크 블루프린트 에셋 생성, 채팅 입력창 위젯 구현, 채팅 위젯 연결, 채팅 메세지 관리, 채팅 메세지 출력


1-2 : 데디케이티드 서버 이해하기

서버를 담당하는 컴퓨터가 따로 있다. 서버-클라이언트 구조이다.

 

1) PIE를 했거나, Server.exe를 실행하는 식으로 서버 프로세스를 실행한다. 실행할 때, Open {Level이름)?Listen 명령어가 인자로 전달된다. Level에는 WorldSettings 속성이 있고, WorldSetting에는 GameMode와 GameState 정보가 있다. 이를 통해 GameMode와 GameState 액터를 생성한다. GameMode 액터는 전체 컴퓨터에서 딱 한 곳 (Server)에만 존재한다.

 

2) 클라이언트는 서버의 IP 주소와 포트 번호로 접속을 시도한다. 서버는 접속을 시도하는 클라이언트에게 Level 정보를 넘긴다. 클라이언트도 해당 Level을 열고, Level을 여는데 성공했다고 데디 서버에 알린다.

 

3) Level을 여는데 성공한 클라이언트 전용 PlayerState, PlayerController, PlayerCharacter가 서버에 생성된다. 이것이 다시 Client들에 복제되고, GameState도 복제된다.

 

4) 클라이언트 간의 PlayerState와 PlayerCharacter도 복제되면서 서로가 보이게 된다.

 

5) 중요한 것은 클라이언트와 클라이언트 간의 통신이 불가능하다는 것이고, 오직 서버와 클라이언트 사이의 통신만 가능하다.


1-3 : 실습 환경 설정

 

데디케이티드 서버 환경 설정 : EditorPreferences에서 Allow Late Joining, Always on top, Multiplayer Options, Number of Players를 설정한다.


1-4 : NetMode, NetConnection, NetDriver

 

1) NetMode : 해당 게임 프로세스가 네트워크 상에서 어떤 역할을 하고 있는지를 의미한다. NM_StandAlone, NM_Listen, NM_DedicatedServer, NM_Client 가 있다. 즉, 월드의 속성이다.

 

- StandAlone : 게임이 원격 클라이언트의 연결을 허용하지 않는 서버로 실행중을 의미한다. 게임에 참여하는 모든 플레이어는 로컬 플레이어이다. 싱글 플레이 및 로컬 플레이 게임에 사용된다. 로컬 플레이어에 맞게 서버측 로직과 클라이언트측 로직을 모두 실행한다.

 

- Client : 게임이 네트워크 멀티플레이 세션에서 서버에 연결된 클라이언트로 실행된다. 서버측 로직을 실행하지 않는다. 서버로부터 복제된 Proxy를 보여주는 역할이다.

 

- Listen Server : 게임이 네트워크 멀티플레이어 세션을 호스팅 하는 서버로 실행중임을 의미한다. 원격 클라이언트의 연결을 수락하고 로컬 플레이어를 서버에 직접 배치한다. 즉, 서버 자기 자신도 게임에 직접 참여한다는 뜻이다. 캐주얼 협동 및 경쟁 멀티플레이어에 자주 사용된다. 

 

- Dedicated Server : 게임이 네트워크 멀티플레이 세션을 호스팅하는 서버로 실행된다. 원격 클라이언트의 연결을 허용하지만 로컬 플레이어가 없다. 따라서 그래픽, 사운드, 입력 및 기타 플레이어 중심 기능이 필요없으니 삭제된다. 이 모드는 지속적이고 안전한 대규모 멀티플레이어가 필요한 게임에 주로 사용된다.

 

로직을 클라이언트 컴퓨터에서 처리한다면 해킹에 취약해진다. 따라서 게임에 중대한 영향을 미치는 처리 로직은 서버 컴퓨터에서만 처리해야 한다. 적어도 데미지와 같은 중요한 값이 패킷에 담기지 않으므로 좀 더 안전하기 떄문이다. 

 

우리가 작성하고 있는 로직이 서버에서 돌고 있는지 클라이언트에서 돌고 있는지를 알아야 하는데 그때 필요한 것이 NetMode다. 

 

2) NetConnection : 다른 PC와의 연결이 발생하면 그에 대응하는 UNetConnection 객체도 생성된다. 서버에 클라이언트가 접속하면 서버에는 ClientConnection 객체가 추가된다. 반대로 클라이언트에는 ServerConnection 객체가 추가된다. 두 PC는 UNetConnection 객체를 통해 통신하게 된다.

// AActor.cpp
UNetConnection* AActor::GetNetConnection() const
{
return Owner ? Owner->GetNetConnection() : nullptr;
// 여기서 오너는 액터/폰/컨트롤러 등등이 될 수 있음.
// 코드에서 볼 수 있듯이 Owner가 지정되어 있지 않으면 통신이 안되게끔 되어 있음.
}

3) NetDriver : 언리얼 네트워크 통신에서 로우레벨 동작들을 관리하는 클래스이다. 생성된 UNetConnection 객체를 소유하고 관리한다. 서버 PC에 생성된 UNetDriver는 접속한 클라이언트 수 만큼 UNetConnection을 관리한다. 클라이언트 PC에 생성된 UNetDriver는 ServerConnection 단 하나만을 관리한다.

싱글플레이에서는 UNetDriver 객체가 생성되지 않는다. 멀티플레이에서만 UWorld::Listen() 함수를 통해 UNetDriver 객체가 생성된다. 멀티플레이에 참여하는 각 PC마다 UNetDriver 객체가 생성된다.

// UWorld.cpp
bool UWorld::Listen( FURL& InURL )
{
#if WITH_SERVER_CODE
...

if (GEngine->CreateNamedNetDriver(this, NAME_GameNetDriver, NAME_GameNetDriver))
{
	NetDriver = GEngine->FindNamedNetDriver(this, NAME_GameNetDriver);
}

 

 

4) 언리얼에서의 Ownership : 하나의 ClientConnection은 하나의 PlayerController를 소유한다. 즉, PlayerController의  Owning Connection은 ClientConnection이다. PlayerController가 빙의하는 폰의 Owner 속성은 해당 PlayerController로 설정된다. 폰에 무기 액터가 생성되고, 무기 액터의 Owner 속성에 해당 폰을 설정할 수도 있다. ClientConnection에서부터 무기 액터에 이르는 소유 관계를 패밀리라고도 부르는데, 소유 관계 속에 있는 액터가 본인의 Owning Connection을 얻으려면 AActor::GetNetConnection() 함수를 호출하면 된다. 이 소유관계가 후에 배울 RPC와 Property Replication과 관련이 있다.

 

5) 멀티플레이 디버깅용 로그 매크로 작성하기

// ChatX.h
#pragma once
#include "CoreMinimal.h"
class ChatXFunctionLibrary

{

public:
static void MyPrintString(const AActor* InWorldContextActor, const FString& InString, float InTimeToDisplay = 1.f, FColor InColor = FColor::Cyan)
	{
		if (IsValid(GEngine) == true && IsValid(InWorldContextActor) == true)
		{
			if (InWorldContextActor->GetNetMode() == NM_Client || InWorldContextActor->GetNetMode() == NM_ListenServer)
			{
				GEngine->AddOnScreenDebugMessage(-1, InTimeToDisplay, InColor, InString);
			}
			else
			{
				UE_LOG(LogTemp, Log, TEXT("%s"), *InString);
			}
		}
	}
	static FString GetNetModeString(const AActor* InWorldContextActor)
	{
		FString NetModeString = TEXT("None");
		if (IsValid(InWorldContextActor) == true)
		{
			ENetMode NetMode = InWorldContextActor->GetNetMode();
			if (NetMode == NM_Client)
			{
				NetModeString = TEXT("Client");
			}
			else
			{
				if (NetMode == NM_Standalone)
				{
					NetModeString = TEXT("StandAlone");
				}
				else
				{
					NetModeString = TEXT("Server");
				}
			}
		}
		return NetModeString;
	}
};
// ChatX.cpp
#include "ChatX.h"
#include "Modules/ModuleManager.h"
IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, ChatX, "ChatX" );
// CXPlayerController.cpp

...

#include "ChatX.h"

...

void ACXPlayerController::PrintChatMessageString(const FString& InChatMessageString)
{
	//UKismetSystemLibrary::PrintString(this, ChatMessageString, true, true, FLinearColor::Red, 5.0f);
	FString NetModeString = ChatXFunctionLibrary::GetNetModeString(this);
	FString CombinedMessageString = FString::Printf(TEXT("%s: %s"), *NetModeString, *InChatMessageString);
	ChatXFunctionLibrary::MyPrintString(this, CombinedMessageString, 10.f);
// 문제 상황이 생기면, 위와 같은 로깅 함수로 다양한 변수의 값들과 함수이름을 확인해서 문제의 원인을 적극적으로 찾아보자.
}

1-5 : NetRole

 

NetRole은 액터가 서버 PC에 스폰되어있는지, 클라이언트 PC에 스폰 되어있는지, 더 나아가서 그 액터의 멤버 함수가 서버 PC에서 실행되고 있는지, 클라이언트 PC에서 실행되고 있는지도 파악한다.

 

1) Authority와 Proxy

서버에 스폰된 액터가 가진 NetRole 속성 값은 언제나 Authority이다. 다시 말해, 서버에 스폰 된 액터에서 수행될 로직은 "권한을 가지고 있다"를 뜻한다. 게임에 중대한 영향을 끼치는 로직은 NetRole이 Authority일 때 수행해야 한다.

 

NetRole이 Authority인 액터가 클라이언트로 복제되었을 때, 클라이언트에 복제된 액터의 NetRole 속성 값은 Proxy이다.

 

2) 로컬 롤과 리모트 롤

게임에 중대한 영향을 끼치는 로직을 작성하기 위해, 해당 액터가 현재 어느 PC에 스폰 되어서 로직이 돌고 있는지 구분해야 한다. 이를 구분하기 위해 현재 동작하는 컴퓨터에서의 롤을 로컬 롤, 커넥션을 연결된 반대편 컴퓨터에서의 롤을 리모트 롤이라고 한다.

 

3) NetRole의 종류

 

- None : 보통의 경우, 레플리케이션 되지 않는 액터를 뜻한다. 다만, 무조건적이지는 않다. 예를 들어, LocalRole은 Authority이고 RemoteRole이 None이라면 서버에서 스폰 되고, 클라쪽으로 레플리케이션 되지 않는 액터라고 볼 수 있다.

 

- Authority : 게임에 중대한 영향을 끼칠 수 있는 권한을 가진 액터를 뜻한다. 서버에서 스폰된 액터가 LocalRole으로 Authority를 가질 수 있다. 예를 들어 GameMode가 있다.

 

- Autonomous Proxy : Authority 액터의 복제본. 서버로부터 데이터를 수신 받아서 동기화도 되면서, 서버로 송신도 가능하다. 예를 들어 PlayerController가 있다. 

 

- Simulated Proxy :  Authority 액터의 복제본. 서버로부터 수신 받아서 동기화 당하기만 한다. 예를 들어, 내 화면에 보이는 다른 플레이어의 PlayerCharacter가 이에 해당한다.

 

4) 매크로를 통한 NetRole 출력하기

 

5) NetRole을 활용하여 정의된 중요 함수들

 

- HasAuthority() 함수 : 게임에 중대한 영향을 끼치는 데미지나 스폰 같은 로직은 서버 컴퓨터에서 실행되어야 한다.

// AActor.cpp
FORCEINLINE_DEBUGGABLE bool AActor::HasAuthority() const
{
return (GetLocalRole() == ROLE_Authority);
}

 

- IsLocalController() : 입력 관련 로직이나 UI는 Autonomous Proxy에서 수행되어야 한다.

// APawn.cpp
bool APawn::IsLocallyControlled() const
{
return ( Controller && Controller->IsLocalController() );
}
// AController.cpp
bool APawn::IsLocallyControlled() const
{
return ( Controller && Controller->IsLocalController() );
}

// AController.cpp
bool AController::IsLocalController() const
{
	const ENetMode NetMode = GetNetMode();
    
	if (NetMode == NM_Standalone)
	{
		// Not networked.
		return true;
	}
    
	if (NetMode == NM_Client && GetLocalRole() == ROLE_AutonomousProxy)
	{
		// Networked client in control.
		return true;
	}
    
	if (GetRemoteRole() != ROLE_AutonomousProxy && GetLocalRole() == ROLE_Authority)
	{
		// Local authority in control.
		return true;
	}
	return false;
}

 

6) 로컬 롤과 리모트 롤로 쪼갠 이유

로컬 롤만으로는 서버에 접속한 플레이어의 캐릭터인지, 서버에서 스폰된건지 구분 불가능하다. 여기서 만약 리모트 롤까지 알게된다면, 서버에 접속한 플레이어의 캐릭터는 Autonomus Proxy이고, 서버에서 스폰된 캐릭터는 None 이므로 구분할 수가 있게 된다.  구분이 가능해야 서버에서 호출되고, 오너 클라이언트에서 실행될 RPC 구현이 가능해진다. 멀티플레이에서 각 액터들을 좀 더 정확하게 분류하게 위해 둘로 쪼개는 것이다.


 


*오늘의 총평*

개인공부도 열심히 하고, 강의도 내용을 정리하면서 성실히 수강했다. Parcel Knight 팀원들과 아쉽지만 작별인사도 나누고 내일부터 다시 멀티플레이 게임 개발 프로젝트에 잘 참여해보려고 한다. 우선 강의를 이번주 내로 수강하고, 과제를 마친 뒤에, 복습과 개인 프로젝트를 병행하며 대기하는 계획을 세웠다.

'TIL' 카테고리의 다른 글

07.31 TIL  (0) 2026.07.31
07.30 TIL  (0) 2026.07.30
07.28 TIL  (0) 2026.07.28
07.27 TIL  (0) 2026.07.27
07.24 TIL  (0) 2026.07.24