포스트

[TIL] 2026-07-24 — 방코드 위에 얹은 세 겹: 스팀 초대·방찾기·로비 슬롯 재사용

방코드밖에 없던 세션 계층에 스팀 오버레이 초대, 방찾기 세션 브라우저, 로비 슬롯 재사용 수정을 하루에 얹은 기록. presence 세션이면 초대가 공짜라는 발견, 검색을 클라 측으로 넘긴 판단, 그리고 슬롯 번호가 새어 나가 유령 인원이 생기던 버그를 스캔 재사용으로 잡은 이야기.

[TIL] 2026-07-24 — 방코드 위에 얹은 세 겹: 스팀 초대·방찾기·로비 슬롯 재사용

목차


들어가며 — 세션 계층에 세 겹을 얹은 날

우리 팀 프로젝트(협동 이사 게임, 저장소는 예전 TeamCarry에서 Coupeng으로 이관했다)의 세션 계층은 얼마 전까지 방코드 하나로 돌아갔다. 호스트가 방을 만들면 6자리 코드가 나오고, 참가자가 그 코드를 입력해 들어오는 방식이다. 실기기 스팀 멀티까지 검증을 끝낸 그 흐름은 예전 TIL에 라운드별로 정리해 뒀다.

이날은 그 위에 친구를 부르는 방법을 늘리는 작업을 했다. 방코드를 주고받지 않고 ①스팀 친구 목록에서 바로 초대하고, ②코드 없이도 열려 있는 방을 목록에서 찾아 들어가고, ③그렇게 사람이 드나들어도 로비가 흐트러지지 않게 하는 것. 초대·방찾기는 브랜치를 쌓아 만든 뒤 PR로 develop에 머지했고, 로비 슬롯 문제는 그 뒤에 발견해 직접 손봤다. 세 가지가 결국 “세션에 들어오고 나가는 경로”라는 한 줄기라, 하루치로 묶어 정리한다.

1. 스팀 초대 — presence 세션이면 초대가 공짜였다

가장 먼저 놀란 지점은, 초대 기능을 새로 만들 필요가 거의 없었다는 것이다.

스팀 오버레이(Shift+Tab)의 “게임 참가/초대” 흐름은 세션이 presence 세션으로 광고돼 있으면 그냥 열린다. 그런데 우리가 방을 만들 때 쓰던 HostSteamSession 설정이 이미 그 조건을 채우고 있었다 — bAllowInvites, bUsesPresence, bUseLobbiesIfAvailable이 다 켜져 있었다. 방코드 매칭을 위해 넣어둔 설정이 초대의 전제 조건과 같았던 셈이다. 그래서 “초대를 보내는” 쪽은 얇았다.

  • SendSessionInviteToFriend(SteamID64) BP 래퍼 하나
  • 콘솔에서 바로 쏘는 Steam_Invite Exec

정작 품이 든 건 초대를 받는 쪽, 즉 수락 이후 조인까지 안전하게 잇는 체인이었다. OnSessionUserInviteAccepted 델리게이트가 발화하면:

  1. GameInstance 수명에 델리게이트를 등록해 두고 (콜백이 늦게 와도 살아 있도록),
  2. 세션 키(TC_SESSION_KEY)를 검증한다 — 우리 게임이 아닌 다른 게임의 초대가 잘못 흘러들어오는 걸 막는 방어다,
  3. 이미 진행 중인 세션이 있으면 Destroy로 정리한 뒤,
  4. 기존 Travel 경로를 그대로 재사용해 조인한다.

새 조인 경로를 또 만들지 않고 방코드 조인이 쓰던 트래블에 합류시킨 게 핵심이다. 입구가 둘(코드/초대)이어도 안쪽 길은 하나여야 나중에 안 엇갈린다.

게임을 안 켠 상태에서 수락하면?

제일 궁금했던 예외가 이거였다. 친구가 게임을 아직 실행하지도 않았는데 스팀에서 초대를 수락하면 어떻게 되나. 게임이 그때 켜지면서, 초대 정보를 커맨드라인 인자로 파싱해야 하는 것 아닌가 싶었다.

그런데 실제로는 그럴 필요가 없었다. Steam 온라인 서브시스템은 이 경우 초대를 PendingInvite로 물고 있다가, 게임이 뜨고 델리게이트가 바인딩된 다음 Tick에서 그 대기 초대를 발화시킨다. 즉 게임 실행 중 수락이든 미실행 수락이든 같은 델리게이트 한 곳으로 들어온다. 커맨드라인을 따로 뜯어볼 필요가 없다는 걸 확인한 게 이날의 소득이었다.

2. 방찾기 — Steam이 부분검색을 못 해서 필터를 클라로 옮겼다

초대가 “아는 사람”을 부르는 길이라면, 방찾기는 코드 없이 열려 있는 방을 그냥 둘러보는 길이다. 이걸 위해 세션 브라우저의 뼈대를 세웠다.

먼저 주고받을 데이터 모양부터 정했다.

  • FTCSessionSearchResult — 방 이름·호스트·현재 인원·핑·인덱스·참가 가능 여부(bCanJoin)
  • FTCSessionSearchFilter — 이름 부분검색·빈 방만 보기(bHideFull)·최대 개수(MaxResults)

여기서 설계 판단이 하나 갈렸다. 검색어로 방 이름을 거르는 걸 어디서 할 것인가. 처음엔 “Steam 쿼리에 이름 조건을 걸어서 서버가 걸러 주면 되지” 싶었는데, Steam의 세션 검색 쿼리는 문자열 부분검색을 지원하지 않는다. 정확히 일치하는 값은 걸러도 “이름에 이 글자가 포함된 방”은 못 거른다.

그래서 방향을 바꿨다. 원시 결과 50개까지 받아온 다음, 필터링은 클라이언트 쪽에서 한다. 부분검색·빈 방 숨기기·개수 제한을 전부 받아온 배열 위에서 처리하는 것이다. 방 개수가 수백 단위로 폭발할 규모가 아니라서, 서버 쿼리의 한계를 클라 필터로 우회하는 편이 훨씬 단순했다.

방을 만드는 쪽에도 최소한만 얹었다. HostCreateRoom(RoomName)에 방 이름 파라미터를 더하고(비우면 “○○○의 방”), TCRoomName 키로 광고하게 했다. 방코드 흐름 자체는 건드리지 않았다 — 목록에 뜰 이름표만 얹은 것이다. 목록 새로고침은 RefreshSessionListOnSessionListReady(배열), 참가는 JoinSessionByIndex로 이어지고, 기존 방코드 조인 경로와는 bWantsListAfterSearch 플래그로 갈라 놓아 서로 간섭하지 않게 했다.

임시 UI는 개인 폴더에, 소프트 참조로

목록을 눈으로 확인하려면 위젯이 필요했다. 다만 이건 당장 검증용 임시 UI라, 팀 공용 UI 폴더를 어지럽히고 싶지 않았다. 그래서 위젯 두 개를 개인 개발 폴더(/Game/Developers/goldb/UI/)에 두고, 나중에 공용으로 옮기기 쉽도록 소프트 참조로 연결했다.

  • WBP_SessionRow — 방 이름·호스트·인원·핑·[참가] 버튼을 한 줄로. 자기 행의 ResultIndex를 안에 들고 있어서 [참가]를 누르면 그 인덱스로 조인한다
  • WBP_SessionBrowser — 상단바(검색·빈방만·새로고침·방만들기) + ScrollBox 목록 + 상태 텍스트

세션 브라우저 목록 — 참가 활성/비활성 두 행 목록 두 행. 참가 가능한 방은 [참가]가 살아 있고, 꽉 찬 방(bCanJoin=False)은 버튼이 비활성이다

두 위젯은 서로를 참조하지 않는다. 행도 브라우저도 각자 GameInstance 서브시스템(TCSessionFlow)에 직접 붙는다. 위젯끼리 물리면 나중에 하나를 갈아끼울 때 같이 흔들리는데, 공통 데이터 원천 한 곳에만 매달아 두면 UI를 통째로 바꿔도 로직은 그대로다.

위젯 조작 — Construct 시 자동 새로고침(“방 0개 검색됨”)부터 방 만들기·목록 갱신까지

3. 검증 이야기 — 테스트 환경이 검증할 경로를 실제로 타는가

방찾기를 만들고 나서 제일 많이 헤맨 건 코드가 아니라 테스트를 어떻게 돌리느냐였다. 예전 방코드 검증에서도 같은 함정을 밟은 적이 있는데, 이번에 그게 더 또렷하게 보였다.

PIE(에디터 안 플레이)를 멀티로 돌릴 때 Net Mode를 Play As Listen Server로 두면, 클라이언트가 호스트에 자동으로 붙는다. 편하지만, 이러면 “목록에서 방을 찾아 들어가는” 경로 자체를 밟지 않는다. 자동으로 이미 붙어 버렸으니 방찾기를 테스트할 수가 없는 것이다.

그래서 방찾기 검증의 정석은 Play Standalone 창 두 개다. 창 두 개가 각자 독립 인스턴스로 뜨고, 한쪽이 방을 만들고 다른 쪽이 목록에서 그 방을 찾아 들어가야 진짜 그 경로를 밟는다. (스팀 실검증은 결국 실기기 2대가 필요하지만, 로컬에서 코드 경로를 훑는 데는 Standalone×2가 맞다.)

실제 E2E는 이렇게 통과했다.

창 B의 목록에 창 A의 방이 뜬 화면 창 A가 만든 방이 창 B 목록에 잡힌다 — 인원 1/4, 핑 101ms

참가 후 호스트 로비에 2명 창 B가 [참가]를 누르자 호스트 로비 PlayerArray가 2명이 됐다

창 A가 방을 만들고 → 창 B 목록에 그 방이 뜨고 → [참가] → 호스트 로비에 2명. 여기까지가 한 번에 이어져야 “만든 게 실제로 도는” 것이다.

곁가지 — F9가 이미 임자가 있었다

검증용으로 방찾기 창을 토글하는 디버그 키를 심는데, 습관적으로 F9를 잡았다가 안 먹었다. 알고 보니 F9는 이미 DefaultInput.ini에서 가구 그랩 디버그 토글(TC.GrabDebugToggle)이 쓰고 있었다. 새 키를 아무 데나 꽂기 전에 기존 디버그 키를 전수 스캔해 빈 자리를 확인하고 F10으로 배정했다. (이건 스캔보다 “F9 어디서 쓰지 않았나?”라는 기억이 먼저 떠올라서 확인만 한 케이스였다.)

그리고 토글 바인딩을 어느 컨트롤러에 걸지도 잠깐 고민했는데, 타이틀 화면 PC가 게임 내 PC(ATCPlayerController)의 파생이 아니라서, 두 곳 다 공통으로 물고 있는 기저 컨트롤러(AGameUIPlayerController)에 바인딩을 얹었다. 입구가 여러 개인 기능은 그 여러 입구가 공유하는 가장 낮은 지점에 붙여야 빠짐이 없다.

4. 로비 슬롯 재사용 — 번호가 새어 나가면 유령이 생긴다

앞의 초대·방찾기가 develop에 머지된 뒤, 사람이 실제로 드나드는 걸 반복하다 로비에서 이상한 증상을 잡았다. 이건 직접 손본 수정이라 조금 자세히 적는다.

증상과 원인

로비 보드에는 슬롯이 0~3, 네 칸이다. 그런데 사람이 나갔다 다시 들어오기를 반복하면, 재진입한 사람이 보드에 안 보이는데 인원 수에는 잡히는 일이 생겼다. 2인 플레이에서 나갔다 들어온 사람이 슬롯 2번을 배정받는 걸 확인했다 — 두 명뿐인데 번호가 2까지 올라간 것이다.

원인은 슬롯 번호를 매기는 방식이었다. NextSlotIndex단조 증가만 했다. 누가 들어올 때마다 0, 1, 2, 3… 다음 번호를 주는데, 나간 사람이 반납한 번호를 회수하지 않는다. 그러니 드나듦을 반복하면 번호가 계속 위로 새어 나가고, 결국 보드 범위(0~3)를 넘긴다.

문제는 그 다음이다. 슬롯 4번은 보드에는 그릴 칸이 없는데, 준비 여부를 세는 AreAllPlayersReady()의 인원 카운트에는 포함된다. 그래서 화면엔 없고 숫자엔 있는 유령 인원이 생긴다. 보이지 않는 사람이 준비를 안 했다며 시작이 막히는 식이다.

수정 — 나가는 코드가 필요 없는 스캔 방식

고친 방향은 “다음 번호를 발급”하는 대신 “지금 비어 있는 가장 낮은 칸을 찾아 준다”였다.

FindFreeSlotIndex()를 새로 두고, 배정할 때마다 현재 PlayerArray스캔해서 이미 쓰이고 있는 슬롯을 bUsed[4]에 표시한 뒤, 그중 가장 낮은 빈 슬롯을 돌려준다. 슬롯을 배정하는 두 경로(PostLogin, Seamless 도착) 모두 이 함수로 갈아끼웠다. 혹시 빈 칸이 하나도 없으면(INDEX_NONE) 방어 로그를 남기게 했는데, 이건 애초에 세션 계층이 정원 초과를 먼저 막았어야 하는 상황이라 여기까지 오면 안 되는 경보다.

이 방식의 좋은 점은, 나갈 때 슬롯을 반납하는 코드가 아예 필요 없다는 것이다. 명시적으로 “이 번호 회수” 하고 관리하려 들면 나가는 경로마다 그 처리를 빠짐없이 심어야 하는데(그리고 하나라도 빠지면 오늘 같은 누수가 난다), 스캔 방식은 PlayerStatePlayerArray에서 빠지는 순간 그 칸이 자동으로 “빈 칸”으로 잡힌다. 상태를 별도로 관리하는 대신, 매번 실제 상태를 다시 읽는 쪽을 택한 것이다. 회수 로직이라는 “빠뜨리기 쉬운 단계”를 통째로 없앤 셈이다.

곁들여 MaxLobbySlots=4를 상수로 뽑았다. S_Lobby 보드의 4칸, 세션 MaxPlayers와 같은 숫자를 세 군데가 제각기 들고 있으면 나중에 정원을 바꿀 때 어긋난다.

위젯을 거쳐 호스팅한 뒤의 로비 방을 만든 뒤 들어온 로비 — 슬롯이 낮은 번호부터 채워진다

재검증

한 번 되는 걸로는 못 믿어서, 나갔다 들어오기를 세 번 반복했다. 매번 슬롯 1번을 재사용했고, 보드에도 정상적으로 표시됐다. 번호가 위로 새지 않는다.

오늘 배운 것

세 작업이 결국 한 가지를 말하고 있었다. 입구를 늘리되 안쪽 길은 하나로, 상태는 관리하지 말고 다시 읽어라.

  • 초대는 만들기 전에 이미 있는지 본다. presence 세션 설정이 초대의 전제 조건과 같아서, 방코드용으로 켜 둔 플래그가 오버레이 초대를 공짜로 열어 줬다. 그리고 게임 미실행 수락도 Steam OSS의 PendingInvite 덕에 같은 델리게이트 한 곳으로 들어온다 — 커맨드라인 파싱이 필요 없었다.
  • 서버가 못 하는 걸 클라로 내리는 판단. Steam 검색은 문자열 부분검색을 못 한다. 원시 결과를 받아와 클라에서 거르는 쪽이, 규모가 작을 땐 서버 쿼리와 씨름하는 것보다 단순했다.
  • 테스트 환경이 검증할 경로를 실제로 타는지부터. Listen Server PIE는 클라가 자동 접속해서 방찾기 경로를 아예 밟지 않는다. Standalone×2라야 “목록에서 찾아 들어가는” 길을 검증한다.
  • 회수 코드는 빠뜨리기 쉬우니, 아예 안 만든다. 슬롯 번호를 발급·회수로 관리하면 나가는 경로마다 반납을 심어야 하고 하나라도 빠지면 유령 인원이 샌다. 매 배정 때 PlayerArray를 스캔해 빈 칸을 찾으면, 나가는 처리 없이도 자동으로 맞는다.

오늘 배운 것 — 슬롯·핸들 같은 자원을 “발급하고 나중에 회수”하는 방식은, 나가는 경로가 여러 개면 그중 하나만 회수를 빠뜨려도 번호가 새어 나가 유령 인원이 생긴다. 회수를 관리하는 대신, 배정할 때마다 현재 상태(PlayerArray)를 스캔해 비어 있는 칸을 찾으면 반납 코드 자체가 필요 없어진다. 상태를 따로 들고 있지 말고, 필요할 때 실제 상태를 다시 읽는 게 누수에 강하다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.