2026-07-21 TIL (101일차)
TIL — 언리얼 온라인 세션 속성이 “성공적으로” 0을 돌려준 날 (EOS 타입 불일치)
오늘 겪은 문제
EOS 데디서버 세션의 현재 인원수를 커스텀 속성 CURPLAYERS로 광고하고, 클라이언트가 방 목록을 만들 때 이 값을 읽는 구조를 만들었다. 그런데:
- 서버 로그: 인원수 갱신 성공, 세션 생성 성공, EOS SDK 로그에
AddAttribute() named (CURPLAYERS) with value (1)까지 확인 - 클라 로그:
CURPLAYERS읽기=성공, 값=0— 언제나, 영원히 0
쓰기는 백엔드까지 도달한 게 증명됐는데, 읽기는 “성공”이라면서 값만 틀렸다. 이 모순을 파다가 언리얼 온라인 서브시스템의 함정 세 개를 배웠다.
디버깅 여정 (오판의 기록)
이 버그의 진짜 교훈은 원인 자체보다 왜 원인을 찾는 데 오래 걸렸는가에 있어서, 오판 과정을 그대로 남긴다.
1차 가설: “서버가 갱신을 안 보낸다” → 기각
서버 로그에 갱신 요청이 잘 찍혔다.
2차 가설: “UpdateSession이 백엔드에 반영을 못 한다” → 오판 (하지만 그럴듯했음)
UpdateSession 완료 (성공) 후 65초가 지나도 클라는 0을 읽었다. 서버에 Backend total players out of sync with client라는 SDK 경고도 있었다. “플러그인이 성공이라 거짓말하고 실제 반영은 안 하는구나”라고 결론 내리고, 세션 파괴 → 새 인원수로 재생성하는 워크어라운드까지 구현했다.
반전: 재생성도 “실패”
재생성 로그는 완벽했다. EOS SDK가 새 세션에 CURPLAYERS = 1을 직접 박아 넣고 생성 성공까지 확인됐다. 그런데도 클라는 0을 읽었다. 여기서 논리가 갈렸다. 백엔드에 1이 저장된 건 SDK 로그로 증명된 사실이다. 그런데 클라의 읽기는 “성공”이라며 0을 준다. 쓰기가 무죄라면 남는 용의자는 하나 — 읽기가 처음부터 고장 나 있었다.
왜 4단계나 헤맸는가: “우연히 맞는 값”의 함정
서버 부팅 직후 세션의 초기값은 진짜 0이었다. 그래서 클라가 0을 읽었을 때 “읽기는 정상이네”라고 판단했고, 이 오염된 전제 위에서 모든 진단이 쓰기 쪽으로만 향했다. 고장 난 저울이 마침 0g짜리 물건을 0g으로 표시해서 저울을 의심하지 않은 것과 같다.
원인: 세 개의 함정이 겹친 완전범죄
함정 1 — EOS 백엔드는 정수를 int64로 보관한다
서버에서 Settings.Set(PlayerCountKey, Count, ...)로 int32를 넣어도, EOS 백엔드는 자체 규격에 따라 정수 속성을 int64로 저장한다. 클라이언트 검색 결과로 돌아올 때도 int64 타입의 FVariantData로 파싱된다. 데이터가 외부 시스템을 왕복하면 보낸 타입 그대로 돌아온다는 보장이 없다.
함정 2 — SessionSettings.Get()의 성공 판정은 “키 존재 여부”뿐이다
1
2
3
4
5
6
7
8
9
10
11
12
// FOnlineSessionSettings::Get의 실제 동작 (개념적으로)
template<typename ValueType>
bool Get(FName Key, ValueType& Value) const
{
const FOnlineSessionSetting* Setting = Settings.Find(Key);
if (Setting)
{
Setting->Data.GetValue(Value); // ← 타입이 맞든 틀리든
return true; // ← 키가 있으면 무조건 true
}
return false;
}
반환값 true의 의미는 “값을 올바르게 읽었다”가 아니라 “그런 이름의 키가 있더라” 다. 타입 일치 여부는 반환값에 반영되지 않는다.
함정 3 — FVariantData는 타입이 다르면 조용히 기본값을 준다 메모리 오염을 막기 위한 안전장치인데, 실패를 알려주는 통로가 전혀 없어서 호출자 입장에선 “정상적으로 읽은 0”과 구분이 불가능하다.
1
2
3
4
5
6
7
8
// FVariantData::GetValue(int32&)의 동작 (개념적으로)
void GetValue(int32& OutData) const
{
if (Type == EOnlineKeyValuePairDataType::Int32)
OutData = 실제값;
else
OutData = 0; // ← 에러도, 로그도, assert도 없이 그냥 0
}
세 함정의 합체
- 서버:
Set(CURPLAYERS, 1)←int32로 씀 - → EOS 백엔드:
int64로 저장 (함정 1) - → 클라 검색 결과:
int64타입의 값 1 - → 클라:
Get(CURPLAYERS, int32&)→ 키 존재 → returntrue(“성공”) (함정 2) - → 클라 내부 처리: 타입 불일치 → 값에 0 기록 (함정 3)
- → 최종 결과: “읽기 성공, 값=0”
곁가지 학습: 왜 Get<int64>로 그냥 못 읽었나 (LNK2019)
“그럼 int64로 읽으면 되잖아?” 했는데 링크 에러(LNK2019)가 났다. FOnlineSessionSettings::Get은 템플릿 함수인데, 구현이 헤더가 아닌 cpp에 있고 엔진이 명시적 인스턴스화(explicit instantiation)로 Export해둔 타입만 외부 모듈에서 사용할 수 있다. Get<int32>, Get<FString> 등은 Export돼 있지만 Get<int64>는 목록에 없어서, 컴파일은 통과해도 링커가 구현부를 찾지 못한다. 에러 메시지 하단의 Hint에 Export된 인스턴스 목록이 나오는 이유가 이것이다.
- 배운 것: 엔진 API의 템플릿 함수는 “문법상 가능한 타입”과 “실제 링크 가능한 타입”이 다를 수 있다.
해결
템플릿 Get()을 우회해서, 설정 TMap에서 키를 직접 찾아 FVariantData를 문자열로 변환 후 정수로 파싱했다. 내부 타입이 int32든 int64든 String이든 올바른 값이 나오고, int64 링크 에러도 원천 회피된다.
수정 후 다른 클라이언트 목록에 1/4 방이 정상 표시되고, “이미 방이 존재합니다” 판정(방 생성 중복 방지)도 의도대로 복원됐다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int32 AdvertisedCount = -1;
if (const FOnlineSessionSetting* Setting = R.Session.SessionSettings.Settings.Find(PlayerCountKey))
{
// FVariantData를 문자열로 변환 후 파싱 — 타입 불일치와 int64 링크 에러를 동시에 회피
AdvertisedCount = FCString::Atoi(*Setting->Data.ToString());
}
if (AdvertisedCount >= 0)
{
Info.CurrentPlayers = AdvertisedCount; // 서버가 광고한 값 (EOS)
}
else
{
Info.CurrentPlayers = Info.MaxPlayers - R.Session.NumOpenPublicConnections; // LAN 폴백
}
오늘의 교훈
- API의
true가 무엇의 성공인지 확인하라.Get()의true는 “키가 있었다”이지 “값을 올바르게 읽었다”가 아니었다. 반환값의 계약(contract)을 읽지 않고 상식으로 추측한 대가가 컸다. - 외부 백엔드를 왕복한 데이터는 타입이 보존된다고 가정하지 마라. 로컬(Null/LAN)에서는
int32→int32로 돌아와 정상이던 코드가, EOS를 거치자int32→int64가 되어 깨졌다. “로컬에서 되던 코드”는 백엔드 교체 시 재검증 대상이다. - 우연히 기대값과 일치하는 결과가 가장 위험하다. 초기값 0이 “읽기 정상”이라는 착각을 만들어 진단 전체를 오염시켰다. 디버그 로그에 값만 찍지 말고 타입과 원본 문자열까지 찍었다면(
Data.ToString(),Data.GetType()) 첫 로그에서 잡혔을 버그다. - 쓰기 검증과 읽기 검증은 독립적으로 하라. 서버의 SDK 레벨 쓰기 로그(
AddAttribute value (1))와 클라의 읽기 로그를 나란히 대조한 순간 모순이 드러나며 사건이 풀렸다. 한쪽 로그만 보면 반대쪽을 의심하게 된다. - 조용한 실패(silent failure)를 만드는 안전장치를 경계하라.
FVariantData의 “타입 다르면 0” 정책은 크래시는 막지만 버그를 데이터로 위장시킨다. 내 코드를 설계할 때도 실패를 숨기는 기본값 반환보다, 최소한 로그라도 남기는 쪽이 디버깅 비용을 줄인다.
남은 일
- 오판 과정에서 만든 세션 재생성 워크어라운드는 보험으로 유지 중. 읽기가 정상화됐으니 원래의
UpdateSession방식이 실제로 동작하는지 재검증 → 동작하면 재생성 코드 제거 예정 ROOM_NAME(FString 속성)은String→String이라 이번 함정과 무관하지만, 향후 정수형 속성을 추가할 때는 반드시 이번 파싱 방식을 사용할 것