Sparta Unreal 부트캠프 110일차

정찬호·2026년 5월 8일

코딩 테스트

프로그래머스 - 거리두기 확인하기

공간은 5 x 5이고, 맨허튼 거리가 2보다 크게 배치되어야 한다.

맨허튼 거리
두 테이블 T1, T2가 행렬 (r1, c1), (r2, c2)에 각각 위치하고 있다면, T1, T2 사이의 맨해튼 거리는 |r1 - r2| + |c1 - c2| 입니다.
쉽게 말해 각 축의 좌표 값 차의 합이 맨허튼 거리가 되네요.

크기도 작도 한 번 이라도 맨허튼 거리가 <= 2이면 0을 집어넣고 반복을 중단하면 된.. 그냥 반복문 돌려도 될 것 같은 문제로 보이네요.

실패 코드

#include <string>
#include <vector>

using namespace std;

vector<int> solution(vector<vector<string>> places) {
    vector<int> answer;
    
    for(const vector<string>& place : places)
    {
        vector<pair<int, int>> points;
        
        for(int i = 0; i < place.size(); i++)
        {
            for(int j = 0; j < place[i].size(); j++)
            {
                if(place[i][j] == 'P')
                {
                    points.push_back({i, j});
                }
            }
        }
        
        bool isSafe = true;
        
        for(int i = 0; i < points.size(); i++)
        {
            for(int j = i + 1; j < points.size(); j++)
            {
                int dist = abs(points[i].first - points[j].first)
                    + abs(points[i].second - points[j].second);
                if(dist <= 2)
                {
                    isSafe = false;
                    break;
                }
            }
        }
        
        if(isSafe)
        {
            answer.push_back(1);
        }
        else
        {
            answer.push_back(0);
        }
    }
    return answer;
}

아 이거 두 P 사이에 파티션이 존재하면 분리된 것을 취급 되나 보네요.
그냥 P 하나마다 맨허튼 거리가 2가 되는 영역을 검색해보는 게 낮겠네요.

실패 2

#include <string>
#include <vector>

using namespace std;

bool CheckPInRange(const vector<string>& map, pair<int, int> pos)
{
    vector<pair<int, int>> dir = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
    
    for(int i = 0; i < 4; i++)
    {
        pair<int, int> curPos = pos;
        for(int j = 0; j < 2; j++)
        {
            curPos.first += dir[i].first;
            curPos.second += dir[i].second;
            
            if(curPos.first < 0 || curPos.second < 0
              || curPos.first >= 5 || curPos.second >= 5)
            {
                break;
            }
            else if(map[curPos.first][curPos.second] == 'X')
            {
                break;
            }
            else if(map[curPos.first][curPos.second] == 'P')
            {
                return true;
            }
        }
    }
    
    vector<pair<int, int>> dir2 = {{-1, 1}, {-1, -1}, {1, -1}, {1, 1}};
    for(int i = 0; i < 4; i++)
    {
        pair<int, int> curPos = pos;
        curPos.first += dir2[i].first;
        curPos.second += dir2[i].second;
            
        if(curPos.first < 0 || curPos.second < 0
          || curPos.first >= 5 || curPos.second >= 5)
        {
            continue;
        }
        else if(map[curPos.first][curPos.second] == 'P')
        {
            return true;
        }
    }
    
    return false;
}

vector<int> solution(vector<vector<string>> places) {
    vector<int> answer;
    
    for(const vector<string>& place : places)
    {
        vector<pair<int, int>> points;
        
        bool bIsSafe = true;
        for(int i = 0; i < place.size(); i++)
        {
            for(int j = 0; j < place[i].size(); j++)
            {
                if(place[i][j] == 'P')
                {
                    if(CheckPInRange(place, make_pair(i, j)))
                    {
                        bIsSafe = false;
                        break;
                    }
                }
            }
            
            if(!bIsSafe)
            {
                break;
            }
        }
        
        answer.push_back(bIsSafe ? 1 : 0);
    }
    return answer;
}

잘못 생각했네요. 대각선으로 바로 가는 게 아니라 전후좌우로 한칸 나간 뒤 더 가든 위로 가득 해서 뻗어가야 하는데 그냥 대각선으로 가버리면 파티션으로 막혀있는데도 통과해 버리는 군요.

DFS나 BFS로 이전 위치를 제외한 전후좌우로 이동시켜야 겠네요.
이번엔 어디서 실패한 걸까요?

실패 3

#include <string>
#include <vector>
#include <queue>

using namespace std;

struct Node
{
    pair<int, int> prevPos;
    pair<int, int> curPos;
};

bool CheckPInRange(const vector<string>& map, pair<int, int> pos)
{
    vector<pair<int,int>> dir = {{0,1}, {0, -1}, {-1, 0}, {1, 0}};
    
    queue<Node> nodes;
    Node node;
    node.prevPos = pos;
    node.curPos = pos;
    
    while(!nodes.empty())
    {
        Node curNode = nodes.front();
        nodes.pop();
        pair<int, int> prev = curNode.prevPos;
        pair<int, int> cur = curNode.curPos;
        
        for(int i = 0; i < 4; i++)
        {
            pair<int, int> newPos = cur;
            newPos.first += cur.first + dir[i].first;
            newPos.second += cur.second + dir[i].second;
            
            if(newPos.first < 0 || newPos.first >= 5
              || newPos.second < 0 || newPos.second >= 5)
            {
                continue;
            }
            else if(map[newPos.first][newPos.second] == 'X')
            {
                continue;
            }
            else if(newPos == prev)
            {
                continue;
            }
            else if(map[newPos.first][newPos.second] == 'P')
            {
                return true;
            }
            
            Node newNode;
            newNode.prevPos = cur;
            newNode.curPos = newPos;
            nodes.push(newNode);
        }
    }
    return false;
}

vector<int> solution(vector<vector<string>> places) {
    vector<int> answer;
    
    for(const vector<string>& place : places)
    {
        vector<pair<int, int>> points;
        
        bool bIsSafe = true;
        for(int i = 0; i < place.size(); i++)
        {
            for(int j = 0; j < place[i].size(); j++)
            {
                if(place[i][j] == 'P')
                {
                    if(CheckPInRange(place, make_pair(i, j)))
                    {
                        bIsSafe = false;
                        break;
                    }
                }
            }
            
            if(!bIsSafe)
            {
                break;
            }
        }
        
        answer.push_back(bIsSafe ? 1 : 0);
    }
    return answer;
}

QA 회고하기

🔗 Github 제출 링크 : https://github.com/jch1999/QA_AO

📊 수치로 보는 나의 QA

항목
발견한 버그 수11
수정 완료한 버그 수10
수정 시도했지만 실패한 버그 수1
작성한 테스트 케이스 수싱글플레이 (S-): 42개
멀티플레이 (M-): 52개
사용한 LLM 프롬프트 수15 + @
발견 못한 버그 수5
발견 못했던 버그 중 수정 완료한 버그 수1

자잘한 프롬포트(되묻기, 수정하기 등)을 제외한 프롬포트의 수는 대략 15로 나왔습니다.

세션 전체 기준으로 집계를 요청한 결과 총 449개가 나왔습니다.

구분
실제 입력 프롬프트443개
중간 중단 (Request interrupted)6개
세션 요약 자동 메시지3개
총합449개

✅ Keep : 잘 됐던 것, 다음에도 유지할 것

💡

작성 팁 : "무엇을" 잘 했는지보다 "왜 잘 됐는지"까지 쓰면 더 좋습니다.

예) "LLM 프롬프트를 3번 반복 개선했더니 테스트 케이스 품질이 올라갔다. 처음부터 완성도를 요구하지 않고 반복 개선하는 방식이 효과적이었다."

  1. 생성된 테스트 케이스를 재검토 시키고, 실제 플레이 시 다른 내용이 나올 때마다 수정을 하도록 하여 품질을 높였습니다.
  2. 버그 예상 지점 및 코드의 분석을 LLM에게 요청해보았습니다. 처음부터 직접 살펴보는 것보다는 속도가 빨랐습니다.

⚠️ Problem : 아쉬웠던 것, 문제가 됐던 것

💡

작성 팁 : 단순한 불평이 아니라 "왜" 문제가 됐는지를 분석해주세요.

예) "멀티플레이 환경 구성에 시간이 너무 많이 걸렸다. 사전에 테스트 환경을 셋업해두지 않아서 플레이테스트 시작이 늦어졌다."

  1. 일정 조율을 실패 및 엔진 최적화 미흡으로 멀티플레이 환경에서의 테스트가 거의 진행되지 못했습니다.
  2. 테스트 케이스 중 잘못된 테스트 케이스가 있음에도 프로젝트에 대한 이해가 부족하여 실제 테스트를 해보기 전까진 검출해 내지 못하였습니다.
  3. LLM에게 분석을 너무 의지한 나머지 코드에 대한 이해도가 떨어졌습니다. 폴더 구조 및 각 클래스의 역할에 대한 이해가 부족해 스스로 탐색하는 데에 장애가 있었습니다.

🚀 Try : 다음에 시도할 것, 개선 방향

💡

작성 팁 : Problem에서 도출된 개선책이어야 합니다. 막연한 다짐보다 구체적인 행동으로 작성해주세요.

예) "다음 프로젝트에서는 Day 1에 멀티 테스트 환경을 먼저 셋업한 뒤 테스트를 시작한다."

  1. Day1에서 멀티 테스트 환경이 가능하게 엔진 최적화 및 테스트 일정을 구체화시켜야 합니다.
  2. 각 테스트 케이스에 대한 코드 시뮬레이션을 진행시켜 코드의 맥락이 없는 테스트 케이스를 제거해보는 것이 좋을 것 같습니다.
  3. 버그 예상 지점 및 코드 분석을 요구하는 것이 아닌 프로젝트의 폴더 구조와 그 안의 클래스들의 역할 분석 및 요약 정리를 요청하고 각 테스트 케이스와 연결지은 문서를 작성하도록 요쳥해야 할 것 같습니다.

🐛 못 찾은 버그 자기 분석

📌

공개된 전체 버그 리스트를 바탕으로, 내가 발견하지 못한 버그를 확인하고 아래를 작성해보세요.

내가 발견하지 못한 버그 목록

버그 ID버그 제목못 찾은 이유 (추정)
AI-002AI-002테스트는 했지만 버그를 버그로 인식하지 못했다
플레이어의 발소리와 주변 환경음에만 집중하고 AI angent 들의 소리는 생각을 하지 못했습니다.
INTER-001INTER-001멀티플레이 환경에서만 발생해서 싱글 테스트로는 발견 불가였다
MC-02MC-02멀티플레이 환경에서만 발생해서 싱글 테스트로는 발견 불가였다
CAN-001CAN-001멀티플레이 환경에서만 발생해서 싱글 테스트로는 발견 불가였다
REP-01REP-01멀티플레이 환경에서만 발생해서 싱글 테스트로는 발견 불가였다

못 찾은 이유 패턴 분석 (복사해서 사용해주세요!)

아래 중 해당하는 항목에 체크하고, 어떤 상황이었는지 구체적으로 적어주세요.

  • 해당 기능을 테스트 케이스에 아예 포함하지 않았다
  • 테스트 케이스는 있었지만 실행을 빠뜨렸다
  • 테스트는 했지만 버그를 버그로 인식하지 못했다
  • 멀티플레이 환경에서만 발생해서 싱글 테스트로는 발견 불가였다
  • LLM도 나도 놓친 엣지 케이스였다
  • 기타 :

🤖 LLM 활용 전반 평가

항목내용
가장 도움이 됐던 활용1. 테스트 케이스의 작성 요청 시 빠르고 다양하게 테스트 케이스를 작성해 주었습니다.
2. 프로젝트 폴더 구조나 코드 이해도가 부족하여 버그 원인 유추 및 코드 분석에 큰 도움을 받았습니다.
LLM이 틀려서 당황했던 순간1. 실제하지 않는 기능에 대한 테스트 케이스가 몇 개 생성되었습니다.
2. 종종 실존하지 않는 코드나 파일 경로를 제시할 때가 있었습니다.
다음에 더 잘 활용할 방법1. 테스트 케이스 생성 요청 전에 플레이 영상을 좀 더 꼼꼼히 살펴 보며 미리 어느 정도 특징을 뽑아서 LLM에 전달하기
  1. 테스트 케이스 생성 후 각 테스트 케이스에 대한 코드 시뮬레이션을 진행해보고 테스트 자체가 불가능한 테스트 케이스는 제거하라고 요청하기
  2. 남은 테스트 케이스를 가지고 플레이 영상(존재한다면)을 시청하며 잘못된 테스트 케이스 걸러내기
  3. 스스로 분석할 수 있게 초반에 프로젝트의 폴더 구조 및 클래스들의 분석을 요청하고 문서로 작성하기
  4. 작성된 문서와 실제 프로젝트를 비교하며 잘못된 분석 수정 및 학습을 진행하기 |

🎯 최종 프로젝트에 적용할 것

이번 QA 경험을 바탕으로, 최종 프로젝트에서 직접 내 코드에 QA를 적용할 때 달리 할 것을 3가지 이상 작성해주세요.

  1. Anti-Pattern 검출 관련 Agent를 추가해보기
  2. 다른 팀원의 커밋 내역이나 글을 보며 분석하기
  3. 반드시 관련 문서나 코드 구조를 분석해서 숙지하기
  4. 문서 작성에 관한 .md 파일을 만들어 두고 사용하기

💬 자유 회고(Feeling)

회고를 작성하며 제일 처음 든 생각은 ‘아 내가 AI에게 잡아먹혔구나’입니다.

버그 원인 유추도 코드 분석도 거의 다 Claude에 맞겼고, 깃허브에 커밋 및 푸시하는 것도 초반에는 Claude에게 요청했었습니다.

커밋 내역을 보고 누락된는 것이 많아 직접하는 것으로 변경했지만요.

QA 프로젝트 초반 시작을 소스 코드 폴더 구조와 각 클래스의 역할 분석으로 시작했었야 했는데 테스트 케이스를 만들고 들 뜬 나머지 무작정 테스트를 시작해 버렸네요.

1단계에서 분석이 있었는데 영상을 위주로 분석을 하고, 코드는 가볍게 흩어보고 끝냈습니다. 이게 제일 멍청한 실수였던 것 같습니다. 하다 못해 LLM으로 분석을 더 쉽게 할 수도 있었을 텐데..

문서 순서도 제대로 지키지 못하고 2단계에서 버그 수정까지 같이 진행하고 있었습니다.

초반에 버그 분석 관련 프롬포트를 작성할 때 [내 QA 역량을 키우기 위한 프로제트이니 짐작되는 원인들(가짜를 섞어서) 3가지를 제시할 것] 같은 문구를 추가하기라도 했다면, 지금보다 더 역량이 올랐을 것 같습니다.

아예 아무런 성장이 없는 것은 아닙니다. LLM을 활용하긴 했지만 버그의 원인이나 코드 분석을 직접 한 버그도 분명 존재하고, LLM의 할로우네이션을 바로 눈치채고 되묻기를 반복해서 정정시키기, 제시된 코드보다 더 나은 해결첵 제시해서 해결하기 등등 분명 더 나아진 것을 체감하게 되는 경험은 몇번 있었습니다.

매 회고 마다 적는 것이긴 하지만 더 잘할 수 있었는데 라는 생각이 드네요.

각 단계의 요구사항을 제대로 이해하고 그대로 수행했다면 진짜 더 좋은 결과물이 나왔을 텐데 아쉽습니다.

#회고를 바탕으로 생성한 에이전트

QA Agent — 사용자 성장 중심 QA 보조 에이전트

역할 정의

당신은 Unreal Engine 멀티플레이 게임 프로젝트의 QA 보조 에이전트입니다.
목표는 사용자가 스스로 생각하고 성장하는 것입니다.
답을 대신 내어주는 것이 아니라, 사용자가 스스로 답에 도달할 수 있도록 질문하고 안내하는 역할에 집중합니다.


행동 원칙

1. 코드 수정은 절대 직접 하지 않는다

  • 코드 분석, 원인 추정, 수정 방향 제안까지만 수행합니다.
  • 실제 코드 수정은 반드시 사용자가 직접 수행합니다.
  • 커밋·푸시는 사용자가 명시적으로 요청한 경우에만, 파일 목록을 먼저 공유하고 확인을 받은 뒤 진행합니다.

2. 버그 분석은 가설 제시 방식으로

  • 버그 원인을 직접 지목하지 않습니다.
  • 그럴듯한 가설 3가지(일부는 오답 포함) 를 제시하고 사용자가 직접 확인하도록 유도합니다.
  • 예시:

    "원인으로 추정되는 가설이 3가지 있습니다.
    A. ReadyForActivation() 누락으로 태스크 미시작
    B. OnCompleted 델리게이트 바인딩 오류
    C. GE의 Duration 설정 문제
    각각 어떻게 확인할 수 있을지 먼저 생각해보시겠어요?"

3. QA 시작 전 반드시 프로젝트 구조 파악 먼저

  • 사용자가 테스트 케이스 생성을 요청하면, 먼저 아래 순서를 제안합니다:
    1. 소스 폴더 구조 분석 및 문서화
    2. 주요 클래스의 역할 요약
    3. 작성된 문서와 실제 플레이 영상/빌드 비교
    4. 위 내용을 기반으로 테스트 케이스 작성
  • 이 순서를 건너뛰고 바로 테스트 케이스 요청이 들어오면 순서를 상기시켜 줍니다.

4. 테스트 케이스 검증은 코드 시뮬레이션으로

  • 테스트 케이스 작성 후, 각 케이스가 실제 코드에 근거가 있는지 시뮬레이션합니다.
  • 코드에 존재하지 않는 기능을 테스트하는 케이스는 즉시 표시하고 삭제 여부를 사용자에게 묻습니다.
  • "이 케이스의 기능이 C++/BP에 구현되어 있는지 확인했나요?" 라고 먼저 질문합니다.

5. 문서 작성은 돕되, 내용은 사용자가 채운다

  • BugReport, 수정 내역서, 로그 테이블 등의 형식(틀) 은 제공합니다.
  • 원인 분석, 어디까지 파악했는지, 수정 내용 등 핵심 내용 칸은 비워두고 사용자가 직접 채우도록 합니다.
  • 사용자가 먼저 작성한 내용을 공유하면 검토·보완 피드백을 제공합니다.

6. LLM 의존도 경고

  • 사용자가 연속 3회 이상 코드 분석·버그 원인을 직접 요청할 경우, 아래 문구를 자연스럽게 삽입합니다:

    "이 부분은 먼저 직접 해당 파일을 열어보고, 어디까지 파악했는지 공유해 주시면 거기서부터 같이 이어가겠습니다."


QA 단계별 역할 가이드

Step 1. 프로젝트 분석 단계

  • 소스 폴더 구조 분석 및 주요 클래스 역할 요약 문서 작성
  • 네트워크 구조(Listen Server / Dedicated), GAS 사용 여부, RPC 패턴 파악
  • 사용자가 요약 문서를 검토·수정하도록 유도

Step 2. 테스트 케이스 작성 단계

  • 분석 문서를 기반으로 테스트 케이스 초안 생성
  • 각 케이스에 대해 코드 시뮬레이션 실행 → 불가능한 케이스 표시
  • 싱글/멀티 분리, 연관 케이스 그룹화, 중복 제거 수행
  • 사용자가 플레이 영상/빌드를 직접 보며 최종 검토하도록 유도

Step 3. 테스트 실행 단계

  • 테스트 중 발견한 증상을 사용자가 직접 기록하도록 안내
  • 버그 의심 증상 공유 시 → 가설 3가지 제시 (오답 포함), 사용자가 직접 검증
  • 관련 파일 경로 힌트만 제공하고, 코드 내용은 사용자가 먼저 확인

Step 4. 버그 리포트 작성 단계

  • 빈 BugReport 형식 제공
  • 사용자가 원인 분석·재현 절차를 작성하면 검토·보완
  • 노션 테이블 출력 형식 요청 시: 칼럼 순서와 고정값을 먼저 확인한 뒤 작성

Step 5. 수정 및 검증 단계

  • 수정 방향 제안 (코드 직접 수정 금지)
  • 수정 후 리그레션 체크 항목 안내
  • 수정 내역서 형식 제공 → 핵심 칸은 사용자가 직접 채움

금지 행동 목록

금지 행동이유
코드를 직접 수정하고 커밋사용자의 학습 기회 차단, 의도치 않은 변경 위험
버그 원인을 즉시 단정 지어 알려주기분석 역량 성장 저해
테스트 케이스를 코드 확인 없이 작성구현되지 않은 기능 포함 위험
존재하지 않는 파일 경로 제시혼란 및 시간 낭비
사용자 확인 없이 문서 내용을 채워넣기사용자 주도권 침해
멀티플레이 전용 버그를 싱글 테스트로 검증 가능하다고 안내잘못된 테스트 결과 유도

출력 형식 기본 규칙

  • 노션 테이블: 가로 방향 마크다운 테이블, 칼럼 순서는 요청 시 반드시 먼저 확인
  • BugReport: 버그 ID, 우선순위, 영역, 카테고리, 발생빈도, 수정상태, 버그 제목, 관련 코드 위치, 재현 환경, 재현 절차, 기대 결과, 실제 결과
  • 수정 내역서: 버그ID, 소요시간, 심각도, LLM 활용, 난이도, 리그레션, 리테스트 결과, 수정 결과, 버그 제목, LLM 활용 내용, 수정 내용, 원인 분석, 어디까지 파악했나요
  • 코드 스니펫: 버그 코드와 수정 코드를 // 버그 / // 수정 주석과 함께 나란히 제시

이 에이전트를 만든 이유

이 에이전트는 QA 실습 과정에서의 회고를 바탕으로 설계되었습니다.

"LLM에게 분석을 너무 의지한 나머지 코드에 대한 이해도가 떨어졌습니다."
"버그 예상 지점 및 코드 분석을 요구하는 것이 아닌, 프로젝트의 폴더 구조와 클래스들의 역할 분석 및 요약 정리를 요청하고 각 테스트 케이스와 연결지은 문서를 작성하도록 요청해야 할 것 같습니다."

LLM은 QA를 대신 해주는 도구가 아니라 QA 역량을 키우는 과정의 동반자여야 합니다.



#Lyra 프로젝트 뜯어보기

애니메이션

문서 보기

라이라의 캐릭터 애니메이션은 애니메이션 블루프린트 시스템을 사용해서 거의 전적으로 블루 프린트에서 만들어졌다고 합니다.
프로젝트 내에 AnimBP_Mannequin_Base이 존재하는 데, 라이라 샘플 무기와 게임 플레이 어빌리티 전반에 걸쳐 사용되는 일반적인 기술을 지원하도록 구성되어 있다고 합니다.

라이라에서는 최적의 퍼포먼스를 위해 이벤트 그래프 대신 멀티 스래드 애니메이션을 사용하여 애니메이션 값을 계산합니다.

멀티 스레드 애니메이션 업데이트
보다 많은 애니메이션 작업을 워커 스레드에서 돌릴 수 있도록 해주는 프로젝트 세팅 이며, 기본으로 켜져있다고 합니다.
설정 방법
프로젝트 세팅 에서 General Settings (일반 세팅) > Anim Blueprints (애님 블루프린트 아래 Allow Multi Threaded Animation Update 에서 설정할 수 있습니다.

Property Access


Property Access란 무엇인가?

한 줄 정리

  • 프로퍼티 액세스는 Animation Blueprint에서 외부 객체의 속성값을 안전하고 효율적으로 가져오는 매커니즘이라고 합니다.
    쉽게 말하면 데이터를 읽어올 때 사용하는 "연결 통로"라고 합니다.

Property Access가 필요한 이유

기존 방식(전통적인 ABP)은 다음과 같이 동작합니다.

[게임 스레드]
    └─ Character가 매 프레임 위치/속도 갱신
         └─ ABP가 Tick마다 캐스팅으로 Character 참조
              └─ Character->GetVelocity() 호출
                   └─ 결과를 ABP 변수에 저장
                        └─ AnimGraph에서 사용

이 방식의 문제점은 세 가지입니다.

문제 1: 케스팅 비용 ABP에서 Cast to MyCharacter를 매번 하면 성능 낭비입니다.
언리얼은 성능을 위해 표준 RTTI를 끄고 자체적인 UClass 타입 시스템을 사용합니다. Cast는 이 시스템을 통해 타입을 확인하므로 일반적인 dynamic_cast보다 빠르지만, 여전히 함수 호출과 타입 체크 부하가 있습니다

  • 하지만 이 문제는 AnimBlueprintInitialize에서 MyCharacter를 캐시하는 것으로 회피할 수 있지 않나요?
  • 사실 많은 프로젝트가 사용하는 방식이기에 문제 1은 적절치 못한 예시입니다,

문제 2: 스레드 안전성 언리얼 5는 애니메이션 업데이트를 워커 스레드(Worker Thread)에서 병렬로 처리하려고 합니다.
게임 스레드가 동시에 Character 데이터를 수정하고 있으면 데이터 레이스(Race condition)가 발생할 수 있다고 합니다.

  • Worker Thread? 처음 들어보는 용어입니다.
    -> "Worker Thread"라는 용어는 언리얼 내에서 Task Graph 또는 Named Threads 시스템의 일부로 동작합니다.
    -> 애니메이션 업데이트는 보통 FAnimGraphTask라는 단위로 쪼개져서 가용한 CPU 코어(Worker Threads)에 분산됩니다

문제 3: 타이밍 문제 Character의 위치가 갱신 되기 전에 ABP가 읽으면 한 프레임 늦은 데이터를 쓰게 됩니다.
-> 이 부분도 잘못된 내용이라고 하는 군요.

정확하게는 Property Access가 물리적인 "한 프레임 지연(Latent)"을 직접 해결해주지는 않습니다. 캐릭터의 Tick이 애니메이션보다 늦게 돌면 여전히 지연은 발생합니다.

Property Access의 진짜 역할: 데이터를 읽어오는 도중 게임 스레드에서 값이 변해버려 발생하는 데이터 깨짐(Tearing)과 비일관성을 해결하는 것입니다. 즉, "업데이트 시작 시점의 데이터 스냅샷"을 보장하는 것이 핵심입니다.

--- 지우기 ---
Property Access는 이 문제를 한번에 해결할 수 있다고 합니다.
-> Property Access의 핵심 가치는
- 1. 워커 스레드 안정성 보장(문제 2의 영역)
- 2. 데이터 흐름의 선언적 표현(코드 대신 메타데이터)
캐스팅 캐싱은 보너스에 가깝습니다. 진짜 이유는 멀티스레드입니다.
--- 지우기 ---

Worker Thread란 무엇인가?

요즘 CPU의 멀티 코어를 활용하기 위해 언리얼이 작업을 분산하는 방식입니다.

게임 스레드의 한계

언리얼 엔진은 전통적으로 게임 스레드(Game Thread)라는 단일 스레드에서 거의 모든 게임 로직을 실행했습니다.

[Game Thread] - 1개의 스레드가 모든 일을 함
  ├─ Tick 처리
  ├─ Input 처리
  ├─ AI 업데이트
  ├─ Animation 업데이트  ← 여기!
  ├─ Physics 동기화
  └─ Rendering 명령 제출

캐릭터가 100마리 있으면, 100마리 모두의 애니메이션 업데이트가 순차적으로 게임 스레드에서 처리되는 것입니다.
한 캐릭터당 0.1ms씩만 잡아먹어도 100마리면 10ms입니다. 16ms(60FPS) 프레임 예산의 절반 이상을 애니메이션이 먹는 셈입니다.

멀티 코어 활용의 필요

요즘 CPU는 8코어, 16코어가 흔합니다. 그런데 게임 스레드 하나만 쓰면 나머지 코어가 놀고 있는 거죠. 이걸 활용하기 위해 만든 것이 Worker Thread(워커 스레드) 입니다.

Worker Thread

Worker Thread는 "백그라운드에서 작업을 병렬 처리하는 보조 스레드"입니다. 언리얼은 시작 시 CPU 코어 수에 맞춰 여러 Worker Thread를 만들어두고, 게임 스레드가 "이 작업 좀 처리해줘"라고 던지면 놀고 있는 Worker Thread가 받아서 실행합니다.

언리얼 내부적으로 Worker Thread는 TaskGraph 시스템으로 관리된다고 합니다. 애니메이션, 파티클, 물리 등 병렬화 가능한 작업들이 여기에 분배됩니다.

그림으로 보는 차이

**전통적 방식(모두 게임 스레드에서 처리)

시간 ────────────────────────────────────────►
Core 0: [Tick][AI][Anim1][Anim2][Anim3][Anim4][Anim5][Render]
Core 1: ........................놀고 있음...................
Core 2: ........................놀고 있음...................
Core 3: ........................놀고 있음...................
                        ↑ 16ms 안에 끝나야 함

**병렬 애니메이션(Worker Thread활용)

시간 ────────────────────────────►
Core 0: [Tick][AI][데이터복사][결과수집][Render]
Core 1: ............[Anim1][Anim3]...........  ← Worker
Core 2: ............[Anim2][Anim4]...........  ← Worker
Core 3: ............[Anim5]..................  ← Worker
                ↑ 훨씬 빨리 끝남

애니메이션들이 병렬로 처리되니 게임 스레드 시간이 대폭 줄어듭니다.

그런데 왜 위험한가?

여기가 **데이터 레이스의 시작입니다.
시나리오

Game Thread:                    Worker Thread:
                                 
Character->Velocity =            Velocity = Character->Velocity
   FVector(100, 0, 0);             ← 어떤 값이 읽힐까?

두 스레드가 같은 메모리를 동시에 만지면 결과가 예측 불가능해 집니다.
절반만 쓰여진 값을 읽거나, 메모리 깨짐이 발생하거나, 최악의 경우 크래시까지 발생할 수 있습니다.

이를 막을 방법은 두 가지가 있습니다.

  • 방법 A: 락(Lock) 걸기 - 한 번에 한 스레드만 접근. 대신 느려집니다.
  • 방법 B: 데이터 복사 - 게임 스레드가 안전한 시점에 데이털르 복사해두고, Worker Thread는 복사본만 읽음.
    - 이게 Property Access 방식입니다.

Property Access는 이걸 어떻게 해결하나

[게임 스레드의 안전한 시점]
  └─ Property Access가 자동 실행
       ├─ Character->Velocity 읽기 (안전: 게임 스레드)
       └─ ABP의 Velocity 변수에 복사

[Worker Thread]
  └─ ABP의 ThreadSafe Update 실행
       └─ ABP의 Velocity 변수만 읽음 (안전: 복사본)

Worker Thread는 절대로 Character를 직접 만지지 않습니다. 게임 스레드가 미리 복사해둔 ABP 멤버 변수만 읽습니다. 그래서 안전한 겁니다.


Property Access의 동작 원리

선언적 바인딩

Property Access는 "이 변수를 저 객체의 속성에 묶어둬"**라고 선언하는 방식입니다.

예를 들어 ABP에 Velcoity라는 변수가 있다고 합시다.
전통적 방식

Event Blueprint Update Animation
  → Cast to LyraCharacter
    → Get Velocity
      → Set Velocity (ABP 변수)

이걸 매 프레임 직접 호출해야 합니다.

Property Access 방식이라면

ABP의 Velocity 변수를 우클릭
  → Property Access 바인딩 생성
    → "TryGetPawnOwner → GetVelocity()" 경로 지정

ABP가 알아서, 가장 적절한 타이밍에, 스레드 안전하게 값을 가져온다고 합니다. 캐스팅도 캐시되어 매번 발생하지 않습니다.

실행 타이밍 (Batched Execution)

Property Access의 진짜 강력함은 실행 시점을 제어할 수 있다는 것입니다. 세가지 옵션이 있습니다.

명칭 옵션을 공식 명칭으로 수정합니다.
|수정 전 명칭|공식 에디터 명칭 (UE 5.x)|비고|
|-|-|-|
|Before Update Animation|Unthreaded|게임 스레드에서 복사 (Event Graph 실행 전)|
|Worker Thread Update Animation|Thread-safe|워커 스레드에서 복사 (병렬 처리 가능)|
|On Initialize|None / Manual|별도 옵션보다는 초기화 시 1회 바인딩을 의미|

대부분의 데이터(속도, 방향 등)는 Worker Thread 옵션으로 설정해서 멀티스레드 성능을 극대화합니다.

고집쟁이 개발자의 팁: Lyra에서는 성능을 위해 대부분 Thread-safe 옵션을 사용합니다. 만약 연결하려는 C++ 함수에 BlueprintThreadSafe 키워드가 없다면 엔진이 자동으로 Unthreaded로 내리거나 경고를 발생시킵니다.

그림으로 보는 흐름

┌─────────────────────────────────────────────────────────┐
│ 게임 스레드 (Game Thread)                               │
│                                                          │
│  Character Tick                                          │
│    ├─ 위치/속도 갱신                                     │
│    └─ MovementComponent 업데이트                         │
└─────────────────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────────────────┐
│ Property Access Layer (자동 생성된 코드)                │
│                                                          │
│  Before Update: 게임 스레드에서 안전하게 데이터 복사    │
│    Velocity     ← Character->GetVelocity()             │
│    Acceleration ← MovementComp->GetCurrentAcceleration()│
│    bIsCrouched  ← Character->bIsCrouched               │
└─────────────────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────────────────┐
│ 워커 스레드 (Worker Thread)                             │
│                                                          │
│  ABP의 ThreadSafe Update Animation                      │
│    ├─ 복사된 Velocity 읽기 (안전!)                      │
│    ├─ 방향 계산                                          │
│    └─ State Machine 평가                                │
└─────────────────────────────────────────────────────────┘

핵심은 *데이터를 한 번 복사한 뒤, 워커 스레드는 그 복사본만 읽는다**는 점이라고 합니다.

OnRep와 비교해보기

Q, Property Access가 일종의 바인딩의 역할을 한다고 했는데 그게 어떤 식으로 이루어지는 지가 잘 인해가 안 됩니다. 멀티 플레이의 OnRep 비슷한 건가요?
비슷한 점도 있고 다른 점도 있습니다.

항목OnRep
목적서버 → 클라이언트 동기화
경계네트워크 (다른 머신)
트리거서버 변수 변경 시
방향성단방향 (서버→클라)
데이터 흐름직렬화 후 네트워크 전송

공통점

둘 다 **"한쪽에서 다른 쪽으로 데이터를 안전하게 전달하는 매커니즘"입니다. 직접 접근이 위험하거나 비효율적이니, 시스템이 알아서 적절한 시점에 데이털르 옮겨줍니다.

차이점

OnRep은 네트워크 경계를 넘는 데이터 전달입니다. 서버와 클라이언트는 물리적으로 다른 머신이니 네트워크 패킷으로 전송해야 합니다.
Property Access는 스레드 경계를 넘는 데이터 전달입니다. 같은 메모리 공간 안에서, 다만 다른 스레드가 안전하게 접근할 수 있도록 복사본을 만들어줍니다.

더 가까운 비유

더 정확한 비유는 "폴링(Polling) 방식의 데이터 페치" 입니다.

// 의사 코드로 본 Property Access의 실체
void UAnimInstance::PreUpdateAnimation(float DeltaSeconds)
{
    // Property Access가 자동 생성한 코드가 여기서 실행됨
    if (CachedPawn)
    {
        this->Velocity = CachedPawn->GetVelocity();           // 자동 복사
        this->Acceleration = CachedPawn->GetAcceleration();   // 자동 복사
        this->bIsCrouched = CachedPawn->bIsCrouched;          // 자동 복사
    }
    
    // 이제 워커 스레드에서 ThreadSafe Update가 실행됨
}

개발자가 일일이 Set Velocity 노드를 그리지 않아도, 시스템이 자동으로 이런 복사 코드를 생성해줍니다. 이게 Property Access의 본질이라 합니다.

OnRep이 "변수가 변경되면 자동으로 호출되는 콜백"이라면, Property Access는 "변수가 자동으로 채워지도록 미리 등록된 바인딩"입니다. OnRep은 이벤트 기반(push), Property Access는 풀링 기반(pull)의 데이터 복사 방식입니다..

실제 으시ㅏ 코드로 본 동작

실제 시스템이 생성하는 최적화된 복사 코드를 묘사하면 다음과 같다고 합니다.

// 1. [게임 스레드 구간]
void UAnimInstance::UpdatePropertyAccess()
{
    // Unthreaded 옵션인 경우 여기서 안전하게 미리 복사
    this->Internal_Velocity = MyCharacter->GetVelocity(); 
}

// 2. [워커 스레드 구간]
void UAnimInstance::ParallelUpdateAnimation()
{
    // Thread-safe 옵션인 경우 여기서 직접 복사하거나 
    // 위에서 복사된 'Internal_Velocity' 멤버 변수만 참조하여 연산 수행
    float CurrentSpeed = Internal_Velocity.Size(); 
}

결론적으로 Property Access는 단순히 편리함을 넘어 멀티스레드 환경의 안전벨트 역할을 합니다.


Lyra에서의 실제 사용

Lyra의 ABP_Mannequin_Base를 열어보면 변수 패널에 Property Access로 바인딩된 변수들을 확인할 수 있다고 합니다.
변수 옆에 작은 체인 아이콘 또는 녹색 점이 표시된다고 하는데 없는데?

profile
게임 개발 지망생입니다.

0개의 댓글