로그인게임하기
구조 읽기

[100턴 구조 읽기 6] 장애가 바꾼 설계

2026.10.10

졸업까지 100턴 구조 읽기 · 6편

출시한 뒤에 만난 문제가 설계를 바꾸기도 합니다. 브라질에서 첫 접속이 실패한다는 신고에서 시작한 네트워크 재설계와, 웹에서 102MB 를 한꺼번에 받던 에셋 배포를 씬 단위로 나눈 이관을 정리합니다.

PART 1

첫 접속이 60초 뒤에 실패했습니다

부엉이가 같은 별 봉인 편지를 다시 나르고, 성의 서기가 장부에서 같은 봉인을 확인해 같은 영수증을 돌려주는 그림
다시 보내도 같은 요청이라는 것을 알아야 같은 일을 두 번 하지 않습니다.

브라질의 플레이어들이 게임을 켜면 첫 서버 요청이 실패한다는 신고가 들어왔습니다. 우리는 네트워크 계층을 다시 짜면서 원인 후보를 클래스 주석에 그대로 남겼습니다.

ElWebAPIService.csTDClient/Assets/ElFramework/Sources/Base/ElWebAPIService.csr5406 · 12–25줄
/// 구버전의 구조적 문제 (브라질 첫 API 실패 증상의 원인 후보):
///  1. UniRx 외부 Timeout(60초)과 내부 재시도 루프(최대 3회 × 60초)가 서로 경쟁 —
///     첫 시도가 지연되면(장거리 라우팅/TLS 스톨) 재시도가 진행 중인데도 60초에 전체가
///     에러로 종료됐다. 사용자는 60초를 기다린 뒤 실패를 봤다.
///  2. 시도별 타임아웃이 60초라 연결이 스톨된 경우 빠른 재시도가 불가능했다.
///  3. WebGL(IL2CPP)에서 UniRx 스케줄러/스트리핑 이슈 가능성.
///
/// 새 설계:
///  - 외부 타이머 없음. 시도별 타임아웃 에스컬레이션(15→25→40초) + 백오프(1→2초).
///    스톨된 연결은 15초에 끊고 새 연결로 재시도 → 첫 연결 불안정 환경에서 회복 빠름.
///  - 서버가 응답한 4xx는 재시도하지 않음(중복 처리 방지). 연결 실패/타임아웃/5xx만 재시도.
///  - internetReachability는 차단하지 않고 경고만 남김
///    (WebGL의 navigator.onLine 오탐이 정상 기기를 전부 튕겨낼 수 있음).
///  - 진단 로그 강화: URL, 시도 횟수, HTTP 상태, 에러 원문을 남겨 지역 이슈 추적 가능.

원인 후보 1

바깥 타이머 60초와 안쪽 재시도가 경쟁해, 재시도 중이어도 60초에 전체가 실패로 끝났습니다.

원인 후보 2

시도마다 60초를 기다려 멈춘 연결을 빨리 끊고 다시 시도할 수 없었습니다.

원인 후보 3

웹 빌드에서 사용하던 리액티브 라이브러리의 스케줄러 문제 가능성.

RETRY

새 재시도 규칙

  1. 시도 115초 안에 응답이 없으면 끊음
  2. 1초 대기새 연결을 준비
  3. 시도 225초까지 기다림
  4. 2초 대기조금 더 쉼
  5. 시도 340초까지 기다림
ElWebAPIService.csTDClient/Assets/ElFramework/Sources/Base/ElWebAPIService.csr5406 · 34–36줄
// 시도별 타임아웃(초)과 재시도 전 대기(초). 배열 길이 = 최대 시도 횟수.
private static readonly int[] ATTEMPT_TIMEOUTS = { 15, 25, 40 };
private static readonly float[] RETRY_DELAYS = { 1.0f, 2.0f };
응답재시도까닭
연결 실패, 시간 초과한다장거리 연결은 새 연결로 다시 시도하면 회복이 빠릅니다
5xx 서버 오류한다일시적인 서버 문제일 수 있습니다
4xx 요청 거부하지 않는다서버가 이미 판단한 요청이라 다시 보내면 중복 처리될 수 있습니다
ElWebAPIService.csTDClient/Assets/ElFramework/Sources/Base/ElWebAPIService.csr5406 · 253–259줄
for (int attempt = 0; attempt < ATTEMPT_TIMEOUTS.Length; attempt++)
{
    using (UnityWebRequest request = requestFactory())
    {
        request.timeout = ATTEMPT_TIMEOUTS[attempt];

        yield return request.SendWebRequest();

기존 서비스 코드는 고치지 않았습니다. 공개 API, 요청 형식, 오류 문자열을 그대로 두고 안쪽만 바꿨기 때문입니다.

PART 2

웹은 받지 않은 에셋을 바로 불러올 수 없습니다

왼쪽은 성문을 막은 거대한 상자, 오른쪽은 에드윈이 숲 입구에 닿을 때 필요한 작은 상자 두 개가 도착하는 그림
통째로 받던 묶음을 씬마다 필요한 묶음으로 나눴습니다.

기존 코드는 Load("Prefab/Effect/X") 처럼 에셋을 동기로 불러옵니다. 앱에서는 문제가 없지만, 웹에서는 아직 받지 않은 묶음을 동기로 불러올 방법이 없습니다. 게다가 씬 묶음이 하나로 뭉쳐 있어 처음 접속할 때 102MB 를 받아야 했습니다.

이전 AssetBundle 방식

  • 씬 묶음 하나에 102MB.
  • 필요할 때 동기로 불러옴.
  • 웹에서는 받지 않은 묶음을 불러올 수 없음.

Addressables 이관 뒤

  • 씬마다 묶음을 나눔.
  • 씬에 들어가기 전에 필요한 라벨만 받음.
  • 받아 둔 에셋은 동기로 바로 반환.
MIGRATE

호출하는 코드를 고치지 않은 이관

이름 규칙기존 번들 이름 = Addressables 그룹 이름 = 라벨
주소 규칙"번들 이름/에셋 이름". 런타임도 같은 규칙으로 키를 계산합니다
씬 진입그 씬에 필요한 라벨을 먼저 받아 메모리에 둡니다
동기 Load받아 둔 캐시에서 바로 반환합니다

어떤 씬에 어떤 라벨이 필요한지는 게임 쪽 설정이 정합니다. 프레임워크는 게임을 모르고, 게임이 시작할 때 자기 정책을 프레임워크에 꽂아 넣습니다.

TDAddressablesConfig.csTDClient/Assets/Sources/Base/TDAddressablesConfig.csr5406 · 63–69줄
/// <summary>부팅 직후 프레임워크에 라벨 제공자 등록</summary>
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Register()
{
    ElGame.ElAddressables.SceneLabelProvider = GetLabelsForScene;
    ElGame.ElAddressables.BootLabelProvider = () => BootLabels;
}

기존 묶음 구성을 그대로 옮기는 일도 에디터 메뉴로 했습니다. 도구의 설명 주석이 설계를 요약합니다.

ElAddressablesSetup.csTDClient/Assets/ElFramework/Editor/ElAddressablesSetup.csr5406 · 24–29줄
/// 설계:
///   - 번들명(예: prefab^effect) = Addressables 그룹명 = 라벨. 기존 큐레이션 보존.
///   - scenes 번들은 씬별 개별 번들로 분리(PackSeparately) — 102MB 일괄 다운로드 해소.
///   - 일반 에셋 주소 = "번들명/에셋명(소문자)" — 런타임 ElAddressables.Load가 같은
///     규칙으로 키를 계산하므로 기존 ElCore.Load 경로 문자열이 그대로 동작한다.
///   - 씬 주소 = 씬 이름 (ElBehaviour.LoadScene(sceneName)와 호환).
DIAG

캐시 미스가 고칠 설정을 알려 줍니다

미리 받지 않은 에셋을 부르면 null 이 돌아옵니다. 이때 로그가 원인을 둘로 나눠 알려 줍니다. 라벨을 아예 받지 않았는지, 라벨은 받았는데 그 안에 에셋이 없는지.

ElAddressables.csTDClient/Assets/ElFramework/Sources/Base/ElAddressables.csr5406 · 177–198줄
if (assetCache.TryGetValue(key, out var obj)) return obj;

// 미스 원인 자가진단 — 키당 1회만 보고
if (logMissing && reportedMisses.Add(key))
{
    string label = key.Split('/')[0];
    if (loadedLabels.Contains(label))
    {
        Debug.LogErrorFormat(
            "[ElAddressables] 캐시 미스: {0} — 라벨 '{1}'은 프리페치됐지만 이 키가 없음. " +
            "에셋이 그룹에 미등록(Setup Groups 재실행 필요)이거나 원본에 파일이 없는 선택적 로드일 수 있음",
            path, label);
    }
    else
    {
        Debug.LogErrorFormat(
            "[ElAddressables] 캐시 미스: {0} — 라벨 '{1}'이 프리페치되지 않음. " +
            "TDAddressablesConfig의 이 씬 라벨에 추가 필요. (현재 로드된 라벨: {2})",
            path, label, loadedLabels.Count > 0 ? string.Join(", ", loadedLabels) : "없음");
    }
}
return null;

그래서 처음에는 라벨을 넉넉하게 잡고, 운영하며 이 로그를 보고 다듬었습니다.

RETRO

돌아보며

잘된 점

  • 장애 분석이 주석으로 남아 설계의 근거가 됩니다.
  • 4xx 를 재시도하지 않는 원칙으로 중복 처리를 줄였습니다.
  • 기존 호출 코드를 고치지 않고 에셋 시스템을 바꿨습니다.
  • 캐시 미스 로그가 고칠 설정까지 알려 줍니다.

아쉬운 점

  • 결제 같은 요청도 시간 초과면 다시 보냅니다. 서버는 처리했는데 응답만 잃은 경우를 막으려면 요청마다 고유 키가 필요합니다.
  • 받아 둔 라벨을 해제하지 않아 메모리를 넉넉하게 씁니다.
  • 배포 폴더에 예전 묶음이 쌓이는데 정리하는 절차가 없습니다.

정리

  1. 장애를 기록합니다. 원인 후보와 새 설계의 근거를 코드 옆에 남깁니다.
  2. 안쪽만 바꿉니다. 공개 API 와 형식을 지키면 나머지 코드는 그대로 동작합니다.
  3. 로그가 다음 할 일을 말하게 합니다. 실패를 알리는 데서 그치지 않고 고칠 설정까지 적습니다.

여섯 편에 걸쳐 졸업까지 100턴의 구조를 읽었습니다. 데이터가 원본이고, 서버가 결과를 정하고, 행동은 갈아 끼우고, 플랫폼 차이는 빌드에서 가르고, 장애는 설계로 남긴다는 것이 이 게임의 뼈대입니다.

← 개발 노트 목록