[서버 개발 일지 #10] 클라이언트 요청 전송, 로그인 흐름 구현

KimGwangmin·2026년 10월 20일

지금까지의 서버 개발일지는 서버 쪽 구현이 중심이었다. 이번 글부터 두 편은 클라이언트가 서버에 요청을 보내는 흐름을 다룬다. 이번 편에서는 요청을 보내는 기반(전송 계층)과 게스트 로그인을, 다음 편에서는 로그를 서버로 보내는 동기화 전송기를 다룬다. 이 작업에서 가장 크게 배운 것이 비동기라서, 그 개념을 코드와 함께 따라가는 데 비중을 두었다.

1. 작업 범위와 구조

작업내용
W2-1네트워크 계층: HTTP 요청 전송, JSON 변환
W2-2게스트 인증: 갱신 → 로그인 → 등록 흐름, 계정 정보 저장
W2-3연결 상태와 시작 흐름: 앱 시작 시 로그인하고, 끝나면 타이틀로
  • W2-1, W2-2는 클라이언트 담당이 구현했고, 나는 이 경계의 계약(인터페이스)을 정하고 리뷰하면서 계약과 어긋난 부분을 고쳤다. W2-3은 내가 진행했다.
  • 동기화(W3-4)는 다음 편에서 다룬다.

어셈블리 분리

클라이언트 코드는 Unity에 의존하는 부분과 의존하지 않는 부분으로 나눠서 두 개의 어셈블리에 담았다. 어셈블리에 대해서도 나중에 따로 정리할 필요가 있을 것 같다. 이번 프로젝트에선 대략 "분리된 코드 영역" 정도로만 이해하고 사용하는 중이다.

어셈블리Unity 의존내용
BlackHole.Net없음요청·응답 타입, 인터페이스, 인증 흐름, DTO. 흐름의 단위 테스트는 여기를 대상으로 한다
BlackHole.Net.Unity있음UnityWebRequest 전송, JsonUtility 변환, 계정 정보 파일 저장, 서버 설정 에셋
  • 인증 흐름은 분기가 많아서 테스트가 많이 필요하다. Unity 없이 돌아가는 어셈블리에 두면, 진짜 네트워크 대신 가짜 전송을 끼워서 에디터 테스트로 모든 경우를 확인할 수 있다.
  • 실제 네트워크 코드(UnityWebRequest)는 IHttpTransport라는 인터페이스 뒤에 숨긴다.

호출 구조

GameBootstrap (시작 시 연결)
   ▶ AuthSession          로그인 흐름 (갱신 → 로그인 → 등록), 연결 상태
        ▶ GuestAuthApi    인증 요청 3개(등록, 로그인, 갱신)를 보내고 결과를 해석
             ▶ IHttpTransport  ←── 실제: UnityHttpTransport (UnityWebRequest)
                                ←── 테스트: FakeHttpTransport
        ▶ ICredentialStore    계정 정보를 파일로 저장  (FileCredentialStore)

모든 계층의 메서드가 Task를 돌려준다. 이것이 이번 작업의 가장 중요한 특징이고, 다음 절에서 설명한다.

2. 비동기란 무엇인가

대강은 알고 있었지만, 직접 다뤄본 적이 거의 없어 낯설었던 개념이다. 이번 기회에 정리해보자.

필요한 이유

서버에 요청을 보내면 응답이 올 때까지 수백 밀리초에서 수 초까지도 걸린다. 서버가 응답하지 않으면 타임아웃(우리는 5~10초)까지 기다린다. 그런데 Unity에서 게임 로직과 화면 그리기는 메인 스레드 하나가 프레임마다 한 번씩 돌면서 처리한다.

만약 요청을 동기 방식으로 보내서 응답이 올 때까지 메인 스레드가 그 자리에서 기다리면, 그동안 프레임이 돌지 못해 화면이 멈춘다. 서버가 느리면 게임 전체가 얼어붙는 것이다.

비동기는 응답을 기다리는 동안에는 메인 스레드를 붙잡지 않고 다른 일(다음 프레임)을 하게 두고, 응답이 오면 이어서 처리한다. 따라서 서버가 응답하는 동안에도 멈추지 않는다.

Task와 async/await

public async Task<HttpResult> SendAsync(HttpRequest request)
{
    // ... 요청을 만든다 ...
    await webRequest.SendWebRequest();   // 여기서 응답을 기다린다
    return ToResult(...);                // 응답이 오면 여기서부터 이어서 실행한다
}
  • Task<T>: 아직 끝나지 않았을 수도 있는 작업을 나타내는 객체이다. 작업이 끝나면 T 타입의 결과를 갖는다. 택배의 송장 번호 같은 개념이다. 물건(결과)은 아직 없지만, 이 번호로 나중에 받을 수 있다.
  • async: 이 메서드 안에서 await를 쓸 수 있고, 돌려주는 값이 Task로 감싸진다는 표시이다.
  • await: Task 앞에 쓰이며, 이 작업이 끝날 때까지 기다린다. 단, 기다리는 동안 스레드를 붙잡지 않고 호출한 쪽에 제어를 돌려준다. 끝나면 await 바로 다음 줄부터 이어서 실행한다.

이 메서드가 호출되면 이런 일이 일어난다.

1. SendAsync 호출 → await에 닿을 때까지 실행 (요청을 만들어 보냄)
2. await에서 멈추고, Task(송장)를 호출한 쪽에 돌려준 뒤 빠져나간다
3. 메인 스레드는 그동안 다른 일(다음 프레임)을 한다
4. 응답이 오면 await 다음 줄(return ToResult(...))부터 이어서 실행한다
5. 그 결과가 Task에 채워지고, 이 Task를 기다리던 쪽이 깨어난다

async 메서드는 첫 await에 닿을 때까지는 호출한 자리에서 그냥 동기적으로 실행된다.

비동기는 병렬이 아니다

처음에 헷갈렸던 부분이다. "비동기니까 다른 스레드에서 동시에 도는 건가?"라고 생각했지만 그렇지 않다.

  • Unity에서 메인 스레드에서 시작된 비동기 메서드는 기본적으로 메인 스레드에서 계속 진행된다.
    - 이건 Unity에서의 특수성이다. 일반적인 C#에서 await 전후, 그리고 await이 부르는 작업이 같은 스레드에서 실행되는 것을 보장하지 않는다.
    - 자세한 내용은 다음 글을 참고하자: Unity에서 await이 메인 스레드에서 재개되는 이유 - SynchronizationContext
    - 더 궁금해지면 SynchronizationContext를 공부해보자.
  • 코드가 실행되는 순간에는 항상 하나만 실행 중이다. 겹치는 것은 "기다리는 시간" 이다. 요청 A를 보내 놓고 응답을 기다리는 동안 요청 B를 보낼 수 있다. 둘이 같은 순간에 날아가 있어도, 응답이 도착해서 이어지는 코드는 한 번에 하나씩 메인 스레드에서 실행된다.
  • 그래서 UnityEngine의 API를 await 뒤에서 그대로 써도 안전하다.
동기비동기병렬(멀티스레드)
기다리는 동안스레드가 멈춰 있다스레드가 다른 일을 한다-
코드가 실행되는 스레드하나하나(메인 스레드)여러 개
이번 프로젝트사용 안 함사용사용 안 함

Unity의 생명주기와 Task 연결: 코루틴으로 대기

Unity의 Start, Update 같은 생명주기 메서드는 Task를 기다려 주지 않는다. 시작 시에 로그인을 마치고 타이틀로 가려면 이 Task가 끝나기를 어떻게든 기다려야 한다. 이 프로젝트에서는 Start를 코루틴(IEnumerator)으로 만들고, 프레임마다 Task의 완료를 확인하는 방식을 썼다.

private IEnumerator Start()
{
    _loggingIn = true;
    Task login = _auth.StartAsync();       // 로그인을 시작한다 (아직 안 끝났을 수 있다)

    while (!login.IsCompleted)             // 끝났는가?
        yield return null;                 // 아니면 다음 프레임에 다시 확인한다

    if (login.IsFaulted)                   // 예외로 끝났는가?
        Debug.LogError($"[인증] 로그인 처리 중 예외: {login.Exception}");
    // ... 이어서 시작 동기화 ...
    _loggingIn = false;
    _host.Start();                         // 타이틀로
}
  • Update에서는 _loggingIn 동안 게임 진행을 막는다(if (!_loggingIn) _host.Tick(...)).
  • 이 방식에서는 await와 달리 Task의 예외가 자동으로 다시 던져지지 않는다. 그래서 끝난 뒤 IsFaulted로 예외 여부를 직접 확인한다. 확인하지 않으면 예외가 묻힌다.
  • 다른 방식도 있다. Start를 async void로 만들고, await를 try/catch로 감싸는 것이다. await가 Task에 저장된 예외를 그 자리에서 다시 던지므로 catch로 받을 수 있다.
private async void Start()
{
    try
    {
        await _auth.StartAsync();
        // ... 이어서 시작 동기화 ...
    }
    catch (Exception error)
    {
        Debug.LogError($"[인증] 로그인 처리 중 예외: {error}");
    }
    _loggingIn = false;
    _host.Start();
}
  • 단, async void는 일반적인 비동기 메서드에서 권장되는 패턴은 아니다. 호출한 쪽이 Task를 받지 못해서, 메서드 안에서 잡지 않은 예외는 try/catch로 받을 수도, 끝나기를 await할 수도 없다. (Start는 Unity가 호출하므로 어차피 호출한 쪽에서 받을 곳이 없다.)

실패를 예외가 아닌 값으로 돌려주기

async 메서드에서 예외가 나면 그 예외는 던져지는 대신 Task 안에 저장되고, 그 Task를 await하는 곳에서 다시 터진다. 아무도 await하지 않는 Task의 예외는 조용히 묻힐 수 있다. (앞서 코루틴 방식에서 Task.IsFaulted를 확인한 이유이다.)

그래서 이 프로젝트의 비동기 인터페이스는 모두 예외를 던지지 않는다는 규칙을 정했다. 대신 일어날 수 있는 실패를 값으로 돌려준다.

public interface IHttpTransport
{
    // 예외를 던지지 않는다. 서버에 닿지 않은 경우도 HttpResult(NetworkError, Timeout)로 돌려준다.
    Task<HttpResult> SendAsync(HttpRequest request);
}
  • 서버가 거부한 것, 네트워크가 끊긴 것, 시간이 초과된 것은 모두 정상적으로 일어나는 경우이다. 이를 try/catch가 아닌 결과 값(HttpOutcome)으로 구분하면, 호출하는 쪽이 switch와 if로 분기를 명시적으로 다룰 수 있다.
  • 정말 예상하지 못한 예외는 각 계층의 가장 바깥에서 잡아서 로그를 남기고 버그로 분류한다. (뒤에서 확인)

3. 전송 계층 (W2-1)

결과를 세 갈래로: HttpResult

요청 하나가 끝나는 방식은 세 가지이다.

public enum HttpOutcome
{
    Response,     // 서버가 답했다(2xx, 4xx, 5xx 모두). StatusCode와 Body를 본다.
    NetworkError, // 연결 실패, DNS 실패 등 서버에 닿지 않았다.
    Timeout,      // 시간 안에 답이 없었다.
}

HttpResult는 여기에 두 가지 판정을 더한다.

// 서버가 2xx로 답했다.
public bool IsSuccess => Outcome == HttpOutcome.Response && StatusCode >= 200 && StatusCode < 300;

// 서버가 요청을 처리하고 답했다(5xx가 아닌 응답). 4xx도 여기에 든다 — 서버가 거부한 것이다.
// false면 서버에 닿지 않은 것으로 본다(네트워크 오류, 타임아웃, 5xx).
public bool Reached => Outcome == HttpOutcome.Response && StatusCode < 500;
결과IsSuccessReached의미
2xxOO성공
4xxXO서버가 거부. 요청 내용 때문에 거절된 것이다
5xxXX서버가 처리하지 못했다. 닿지 않은 것으로 본다
네트워크 오류, 타임아웃XX닿지 않았다

Reached가 이 구분의 핵심이다. 서버에 닿았는가에 따라 클라이언트가 할 일이 완전히 다르기 때문이다.

  • 서버가 거부했다면(4xx) 서버가 살아 있고 요청이 잘못된 것이다. 오류 코드를 보고 다음 단계를 정한다.
  • 닿지 않았다면(5xx 포함) 서버 상태를 알 수 없다. 이번 실행은 오프라인으로 두고, 저장된 계정 정보는 건드리지 않는다.

실제 전송: UnityHttpTransport

public async Task<HttpResult> SendAsync(HttpRequest request)
{
    try
    {
        using var webRequest = new UnityWebRequest(_baseUrl + request.Path, request.Method)
        {
            downloadHandler = new DownloadHandlerBuffer(),
            timeout = request.TimeoutSeconds,
        };

        if (request.JsonBody != null)
        {
            webRequest.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(request.JsonBody));
            webRequest.SetRequestHeader("Content-Type", "application/json");
        }

        if (!string.IsNullOrEmpty(request.BearerToken))
            webRequest.SetRequestHeader("Authorization", "Bearer " + request.BearerToken);

        await webRequest.SendWebRequest();      // 응답이 올 때까지 기다린다 (스레드는 붙잡지 않는다)

        return ToResult(webRequest.result, webRequest.responseCode, webRequest.error, webRequest.downloadHandler.text);
    }
    catch (Exception e)
    {
        // 잘못된 URL 등. 계약상 밖으로 던지지 않는다.
        return HttpResult.NetworkError(e.Message);
    }
}
  • using var는 메서드가 끝날 때 webRequest를 정리한다. await로 중간에 멈춰도, 이어서 실행이 끝나고 메서드를 빠져나갈 때 정리된다.
  • 메서드 전체를 try/catch로 감싸서 어떤 예외가 나도 NetworkError 결과로 바꾼다. 인터페이스의 "예외를 던지지 않는다" 계약을 지키는 것이다.
  • timeout은 요청마다 다르게 준다. 요청 종류별로 기다릴 시간이 다르기 때문이다. (인증 5초, 동기화 10초)

결과 분류: ToResult

internal static HttpResult ToResult(UnityWebRequest.Result result, long statusCode, string error, string body)
{
    // 4xx·5xx(ProtocolError)도 서버가 답한 것이다. 연결 실패·시간 초과만 닿지 않은 것이다.
    if (result == UnityWebRequest.Result.ConnectionError
        || result == UnityWebRequest.Result.DataProcessingError)
    {
        bool timedOut = error != null && error.IndexOf("timeout", StringComparison.OrdinalIgnoreCase) >= 0;
        return timedOut ? HttpResult.Timeout(error) : HttpResult.NetworkError(error);
    }

    return HttpResult.Response(statusCode, body, ParseError(body));
}
  • UnityWebRequest는 4xx, 5xx 응답을 ProtocolError라는 이름의 결과로 알리는데, 이건 서버가 답한 것이다. 연결 자체가 실패한 경우는 ConnectionError이다. 이 이름과 의미의 차이를 맞추는 것이 이 함수의 일이다.
  • 타임아웃도 ConnectionError로 오고 오류 문자열에 timeout이 들어 있어서, 문자열로 구별한다.
  • 이 변환 함수는 네트워크 없이 테스트하려고 따로 떼어 두었다. 결과 값을 직접 넣어 모든 분류를 검증한다.
  • 서버의 오류 본문 {"error":{"code","message"}}는 ParseError가 읽어 HttpResult.Error에 넣는다. 4편에서 본 공통 오류 형식이 클라이언트에서 쓰이는 곳이다. 클라이언트는 code로 분기하고 message는 로그에만 남긴다.

JSON 변환

JSON 변환은 Unity의 JsonUtility를 쓴다. 규칙이 몇 가지 있어서 IJsonSerializer 뒤에 숨겼다.

  • DTO는 [Serializable] 클래스의 public 필드로 만든다.
  • null이나 다형성(타입마다 다른 payload)을 지원하지 않는다. 그래서 동기화 로그는 봉투와 payload를 따로 직렬화한다. (다음 편)
  • 본문에 없는 객체 필드도 빈 객체로 채운다. 오류 본문의 error 객체가 실제로 있는지는 code가 비어 있지 않은지로 판단한다.

4. 게스트 로그인 (W2-2)

흐름

서버의 게스트 인증은 2편에서 만들었다. 클라이언트는 이 API를 이렇게 쓴다.

앱 시작 → 저장된 계정 정보를 읽는다
   │
   ├ refresh 토큰이 있다 → 갱신 요청
   │      ├ 성공 → 온라인
   │      ├ 서버에 닿지 않음 → 오프라인(Unreachable)
   │      └ INVALID_REFRESH_TOKEN → 토큰을 지우고 ↓
   │
   ├ guestId·guestSecret이 있다 → 로그인 요청
   │      ├ 성공 → 온라인
   │      ├ 서버에 닿지 않음 → 오프라인(Unreachable)
   │      └ INVALID_CREDENTIALS → 오프라인(CredentialsRejected). 새 계정을 만들지 않는다
   │
   └ 계정 정보가 없다(최초 실행) → 등록 요청 → 아이디·시크릿 저장 → 로그인 요청

전체 흐름은 로그인 시간 예산(8초) 안에서 진행한다. 이 예산을 넘으면 오프라인으로 실행한다.

AuthSession.StartAsync

public async Task StartAsync()
{
    lock (_gate)
    {
        // 로그인은 앱 시작 시 한 번만 한다(계획서 D9).
        if (_started)
            return;
        _started = true;
    }

    OfflineReason reason;
    try
    {
        _credentials = _store.Load();
        DateTime deadline = _now() + _loginBudget;
        reason = await SignInAsync(true, () => TimeoutWithin(deadline));
    }
    catch (Exception error)
    {
        // 예상하지 못한 예외는 버그다.
        _logError("[인증] 로그인 중 예외: " + error);
        reason = OfflineReason.ProtocolError;
    }

    if (reason == OfflineReason.None)
        SetOnline();
    else
        SetOffline(reason);
}
  • 이 메서드가 하는 일은 흐름 시작 → 결과 받기 → 상태 확정 세 줄이다. 실제 흐름은 SignInAsync가 한다. await로 그 결과(오프라인 사유, 성공이면 None)를 받는다.
  • lock은 _started를 확인하고 바꾸는 짧은 구간에만 건다. C#에서는 lock 안에서 await를 쓸 수 없고, 쓸 수 있다 해도 await로 멈춘 동안 잠금이 걸려 있으면 안 되기 때문이다. "결정만 lock 안에서, 기다리는 일은 밖에서" 라는 패턴이 이 코드 전체에 반복된다.
  • 예상하지 못한 예외는 가장 바깥에서 한 번 잡아서 ProtocolError로 처리한다. 앞 절의 "예외를 던지지 않는다" 규칙을 지키는 지점이다.

시간 예산을 await마다 나눠 준다

로그인은 요청이 여러 번(갱신 → 로그인, 또는 등록 → 로그인) 이어진다. 전체가 8초를 넘지 않게 하려면 어떻게 해야 할까?

이 코드는 취소 토큰을 쓰지 않고, 요청마다 타임아웃을 줄이는 방식을 쓴다.

// 요청 하나의 타임아웃: min(요청 타임아웃, 남은 예산), 정수 초, 최소 1. 예산이 남지 않았으면 null.
private int? TimeoutWithin(DateTime deadline)
{
    double remaining = (deadline - _now()).TotalSeconds;

    if (remaining <= 0)
        return null;

    return Math.Max(1, Math.Min(_requestTimeoutSeconds, (int)Math.Floor(remaining)));
}

private async Task<AuthResult> SendAsync(Func<int, Task<AuthResult>> send, Func<int?> timeout)
{
    int? seconds = timeout();

    if (seconds == null)
    {
        _log("[인증] 로그인 시간 예산을 다 써서 요청을 보내지 않는다.");
        return AuthResult.Offline(OfflineReason.Unreachable);
    }

    return await send(seconds.Value);
}
  • 마감 시각(deadline)을 먼저 정해 두고, 요청을 보내기 직전마다 "남은 시간"을 계산해서 요청 타임아웃(5초)과 남은 예산 중 작은 값을 그 요청의 타임아웃으로 준다. 예를 들어 첫 요청이 5초를 쓰고 나면 남은 예산은 3초이므로, 다음 요청은 요청 타임아웃(5초)이 아니라 최대 3초까지만 기다린다.
  • 남은 예산이 없으면 요청을 아예 보내지 않고 오프라인으로 처리한다.
  • 예산 계산은 "다음 요청이 최대 얼마를 기다려도 되는가"를 정하는 순차적인 await의 연결이다. 앞 요청의 await가 끝나야 다음 요청의 타임아웃이 정해진다.
  • 현재 시각을 _now()라는 함수로 주입받아서, 테스트가 시계를 흉내 낼 수 있다. 실제로 8초를 기다리지 않고 "예산이 다 닳은 경우"를 테스트한다.

응답 분기

GuestAuthApi가 요청 하나의 결과를 세 갈래로 정리해서 흐름에 돌려준다.

internal enum AuthResultKind { Success, Rejected, Offline }
  • Success: 성공. 응답 본문을 읽은 값(토큰 등)이 담긴다.
  • Rejected: 서버가 4xx로 거부했다. 오류 코드를 보고 다음 단계를 정한다.
  • Offline: 서버에 닿지 않았거나, 2xx인데 본문 형식이 맞지 않는다.

SignInAsync는 이 세 갈래를 받아서 갱신 → 로그인 → 등록 순서로 분기한다.

AuthResult refreshed = await SendAsync(seconds => _api.RefreshAsync(refreshToken, seconds), timeout);

if (refreshed.Kind == AuthResultKind.Success)
    return Accept(refreshed);

if (refreshed.Kind == AuthResultKind.Offline)
    return refreshed.OfflineReason;

if (refreshed.ErrorCode != ApiErrorCodes.InvalidRefreshToken)
    return Unexpected("갱신", refreshed);

// 만료·폐기·재사용 탐지(저장 전 종료 포함). 토큰을 지우고 아이디·시크릿 로그인으로 넘어간다.
_credentials = _credentials.WithRefreshToken(null);
TrySave(_credentials);
  • 갱신이 INVALID_REFRESH_TOKEN으로 거부되는 것은 정상적으로 일어날 수 있다. 30일이 지나 만료되었거나, 2편에서 본 재사용 탐지로 폐기된 경우이다. 이때는 토큰을 지우고 guestId·guestSecret으로 다시 로그인한다. 사용자는 아무것도 모른다.
  • 그 밖의 거부(예상하지 못한 코드)는 ProtocolError로 보고 오프라인으로 둔다. 클라이언트나 서버의 버그라고 봐야 하기 때문이다.

저장 순서가 중요한 이유

// 등록에 성공하면 로그인하기 전에 저장한다.
if (!TrySave(credentials))
    return OfflineReason.Unreachable;
_credentials = credentials;
// ...
// 새 refresh 토큰은 다른 일을 하기 전에 저장한다.
private OfflineReason Accept(AuthResult tokens)
{
    _credentials = _credentials.WithRefreshToken(tokens.RefreshToken);
    TrySave(_credentials);
    _accessToken = tokens.AccessToken;
    return OfflineReason.None;
}

2편에서 본 리프레시 토큰 회전 때문이다. 서버는 갱신할 때마다 새 리프레시 토큰을 발급하고 옛 토큰을 폐기한다.

  • 갱신 응답을 받았는데 새 토큰을 저장하기 전에 앱이 종료되면, 다음 실행에서 폐기된 옛 토큰으로 갱신을 요청하게 되고 서버의 재사용 탐지에 걸려 계정의 모든 토큰이 폐기된다. 그래서 새 토큰은 다른 일을 하기 전에 가장 먼저 저장한다.
  • 설령 저장에 실패해도 이번 실행은 이어 간다. 다음 실행에서 옛 토큰이 거부되어도 위의 INVALID_REFRESH_TOKEN 처리로 아이디·시크릿 로그인으로 복구되기 때문이다.
  • 등록의 경우는 아이디·시크릿을 저장하기 전에 로그인하지 않는다. 시크릿은 서버가 등록 응답에서 한 번만 알려 주기 때문에, 저장하지 못한 채 진행하다 앱이 종료되면 그 계정에 다시는 로그인할 수 없다. 저장하지 못했으면 그 계정으로 진행하지 않고 다음 실행에서 다시 등록하게 한다.

저장된 계정이 거부되면 새 계정을 만들지 않는다

if (login.ErrorCode == ApiErrorCodes.InvalidCredentials)
{
    // 새 계정을 자동으로 만들지 않는다(계약 2.7). 로컬 진행도가 빈 서버 진행도로 덮어써지는 것을 막는다.
    return OfflineReason.CredentialsRejected;
}
  • 저장된 계정이 거부되는 일은 정상 운영에서는 거의 없다. 대부분 개발 중에 서버 DB를 초기화한 경우이다.
  • 이때 새 계정을 만들면 로컬의 다음 seq가 1이 아니라서 첫 동기화가 SEQ_GAP으로 거부되고, 응답으로 온 빈 서버 진행도가 로컬 진행도를 덮어쓴다. 플레이 기록이 사라지는 것이다.
  • 그래서 자동으로 만들지 않고 오프라인으로 두며, 로컬 진행도는 그대로 남긴다.

실행 중 401: 갱신은 동시에 하나만

액세스 토큰은 15분이 지나면 만료된다. 실행 중에 API가 401 UNAUTHORIZED를 받으면 토큰을 갱신하고 같은 요청을 한 번 다시 보낸다. 이것이 RefreshAfterUnauthorizedAsync이다.

문제는 여러 요청이 동시에 401을 받는 경우이다. 요청 A, B가 같은 순간 401을 받으면 둘 다 갱신을 시도하게 된다. 같은 리프레시 토큰으로 갱신을 두 번 보내면 두 번째가 재사용 탐지에 걸려 계정의 모든 토큰이 폐기된다. 갱신은 동시에 하나만 나가야 한다.

// 진행 중인 401 복구. 겹친 호출은 이것을 함께 기다린다.
private Task<bool> _recovering;

public Task<bool> RefreshAfterUnauthorizedAsync()
{
    lock (_gate)
    {
        if (State != ConnectionState.Online)
            return Task.FromResult(false);

        // 갱신 요청은 동시에 하나만 보낸다(겹치면 서버의 재사용 탐지에 걸린다). 끝난 복구는 다시 쓰지 않는다.
        if (_recovering == null || _recovering.IsCompleted)
            _recovering = RecoverAsync();

        return _recovering;
    }
}

이 코드가 비동기의 중요한 성질을 보여 준다.

  • Task는 값이다. 변수에 저장해 두고, 여러 곳에서 같은 Task를 함께 기다릴 수 있다.
  • 첫 호출이 RecoverAsync()를 시작해서 그 Task를 _recovering에 보관한다. 복구가 진행 중인 동안 들어온 다른 호출은 새로 시작하지 않고 같은 Task를 돌려받는다. 요청 A, B가 같은 _recovering을 await하니, 갱신 요청은 한 번만 나가고 결과도 둘이 같이 받는다.
  • 복구가 끝난 Task(IsCompleted)는 다시 쓰지 않는다. 한참 뒤에 다시 401이 나면 새 복구를 시작해야 하기 때문이다.
  • 여기서 lock은 "진행 중인 복구가 있는가"를 확인하고 _recovering에 넣는 짧은 구간에만 건다. RecoverAsync()를 호출하는 것은 lock 안이지만, async 메서드는 첫 await까지만 동기로 실행하고 Task를 돌려주기 때문에 lock이 오래 걸려 있지 않다.

복구 자체는 SignInAsync(false, ...)로, 갱신 한 번 → 거부되면 로그인 한 번이다. 등록은 하지 않고(allowRegister = false), 시간 예산 없이 요청마다 요청 타임아웃만 건다. 복구가 실패하면 오프라인으로 바뀌고 false를 돌려준다.

연결 상태를 메인 스레드로 알린다

private readonly SynchronizationContext _mainThread;
// 생성자(메인 스레드)에서:  _mainThread = SynchronizationContext.Current;

private void Raise(ConnectionState state)
{
    if (_mainThread == null || SynchronizationContext.Current == _mainThread)
        StateChanged?.Invoke(state);
    else
        _mainThread.Post(_ => StateChanged?.Invoke(state), null);
}
  • AuthSession은 메인 스레드에서 만들고, 그때의 SynchronizationContext를 기억해 둔다. 상태 변경 이벤트(StateChanged)를 구독하는 쪽(UI)은 UnityEngine을 쓰므로 메인 스레드에서 불려야 한다.
  • 메인 스레드가 아닌 곳에서 상태가 바뀌었다면 Post로 메인 스레드에 실행을 맡긴다.
  • 상태 값을 읽고 쓰는 부분은 lock(_gate)로 보호하고, 이벤트는 lock 밖에서 발생시킨다. 이벤트를 받은 쪽의 코드가 lock을 쥔 채 실행되면 안 되기 때문이다.

5. 연결 상태와 시작 흐름 (W2-3)

연결 상태

public enum ConnectionState { Connecting, Online, Offline }

public enum OfflineReason
{
    None,                // Offline이 아니다
    Disabled,            // 설정이 꺼져 있음, 밸런스 프로필을 적용한 실행
    Unreachable,         // 네트워크 오류, 타임아웃, 5xx, 로그인 시간 예산 초과
    CredentialsRejected, // 저장된 guestId·guestSecret이 거부됨(새 계정을 만들지 않는다)
    ProtocolError,       // 예상하지 못한 응답. 버그
    ContentOutdated,     // 콘텐츠 버전 불일치(다음 편)
}
  • 상태는 Connecting → Online 또는 Offline으로 한 번 정해지고, Online에서 Offline으로만 바뀐다. 이번 실행 중에는 다시 연결하지 않는다. (3편의 "재연결은 앱 시작 때만" 결정이다.)
  • 오프라인은 "왜 오프라인인가"를 OfflineReason으로 구분한다. 타이틀 화면의 안내 문구가 이것으로 갈린다.
  • 처음의 오프라인 사유가 유지된다. 이미 오프라인인 상태에서 또 다른 실패가 와도 처음 원인을 덮어쓰지 않는다.

시작 흐름 연결

GameBootstrap이 시작할 때 서버 연결을 구성하고 로그인을 기다린다.

private void CreateServerSession(string saveDirectory, bool profileApplied)
{
    if (_server == null || !_server.Enabled || profileApplied)
    {
        _auth = new OfflineAuthSession(OfflineReason.Disabled);
        return;
    }

    UnityHttpTransport transport = new(_server.BaseUrl);
    UnityJsonSerializer json = new();

    _auth = new AuthSession(transport, json, new FileCredentialStore(saveDirectory),
        _server.RequestTimeoutSeconds, _server.LoginBudgetSeconds, Debug.Log, Debug.LogError);
    // ... (동기화 전송기, 다음 편)
}
  • 서버 설정이 없거나, 꺼져 있거나, 밸런스 프로필을 적용한 실행(콘텐츠가 서버와 다르기 때문)은 OfflineAuthSession 을 쓴다. 같은 IAuthSession 인터페이스를 구현하지만 요청을 보내지 않고 항상 오프라인(Disabled)인 "아무 일도 하지 않는" 구현이다. 쓰는 쪽은 서버를 쓰는 실행인지 아닌지를 if로 구분하지 않고 같은 인터페이스를 쓴다.
  • 서버 주소와 타임아웃은 ServerSettings 에셋(ScriptableObject)에 둔다. 인증 요청 5초, 로그인 전체 8초, 동기화 요청 10초가 기본값이다. 로그인이 최악의 경우 시작을 막는 시간은 이 값들로 제한된다.
  • 시작 흐름은 2절의 코루틴 Start()이다. 콘텐츠와 진행 저장을 읽은 뒤 타이틀로 가기 전에 로그인을 기다린다. 서버가 꺼져 있으면 연결 오류가 곧바로 와서 거의 기다리지 않고, 서버가 응답하지 않으면 최대 8초이다.

6. 테스트

비동기 흐름은 테스트하기가 까다롭다. 이 프로젝트에서는 두 가지로 풀었다.

가짜 전송으로 흐름의 모든 분기를 확인한다

internal sealed class FakeHttpTransport : IHttpTransport
{
    private readonly Queue<Task<HttpResult>> _responses = new();
    private readonly List<HttpRequest> _requests = new();

    public void Enqueue(HttpResult result) => _responses.Enqueue(Task.FromResult(result));

    // 테스트가 직접 끝내는 응답. 요청이 겹치는 경우(갱신 단일 비행 등)를 흉내 낸다.
    public TaskCompletionSource<HttpResult> EnqueuePending()
    {
        var pending = new TaskCompletionSource<HttpResult>();
        _responses.Enqueue(pending.Task);
        return pending;
    }

    public Task<HttpResult> SendAsync(HttpRequest request)
    {
        _requests.Add(request);
        if (_responses.Count > 0) return _responses.Dequeue();
        // ...
    }
}
  • 가짜 전송은 미리 넣어 둔 응답을 순서대로 돌려주고, 받은 요청을 기록한다. 서버 없이 "첫 실행이면 등록 후 로그인하는가", "저장은 로그인 전에 하는가" 같은 것을 확인한다.
  • Task.FromResult(...)는 이미 끝난 Task 를 만든다. 이것을 await하면 기다리지 않고 바로 이어서 실행되므로, 흐름이 한 번의 호출 안에 끝난다. 테스트가 단순해진다.
  • 완료 시점을 테스트가 직접 제어해야 할 때는 TaskCompletionSource를 쓴다. 이것은 "내가 원할 때 끝낼 수 있는 Task"이다. 요청이 아직 응답을 기다리는 도중(진행 중)의 동작, 예를 들어 "401이 동시에 두 번 와도 갱신은 한 번만 나가는가"를 확인하려면, 응답을 일부러 끝내지 않은 채 두 번째 호출을 해 보고 나서 응답을 끝내야 하기 때문이다.

대표 시나리오

시나리오테스트
첫 실행: 등록 → 아이디·시크릿 저장 → 로그인FirstRunRegistersAndSavesBeforeLogin (저장이 로그인 요청보다 앞인지도 확인)
재실행: 갱신하고 새 토큰 저장RestartRefreshesAndSavesTheNewToken
갱신이 거부되면 토큰을 지우고 로그인RejectedRefreshClearsTheTokenThenLogsIn
저장된 계정이 거부되면 새 계정 없이 오프라인RejectedCredentialsStayOfflineWithoutANewAccount
서버에 닿지 않으면 계정 정보를 건드리지 않는다UnreachableKeepsCredentials
요청 타임아웃은 남은 예산을 넘지 않는다RequestTimeoutIsCappedByTheRemainingBudget
예산을 다 쓰면 요청을 보내지 않고 오프라인ExhaustedBudgetGoesOfflineWithoutSending
동시에 온 401은 갱신 하나를 함께 기다린다ConcurrentUnauthorizedCallsShareOneRefresh

실제 서버로도 확인한다

가짜 전송은 우리가 기대하는 서버의 모습을 흉내 낸 것이다. 실제 서버가 다르게 동작하면 가짜 테스트는 통과하고 실제로는 실패한다. 그래서 로컬 서버를 상대로 하는 테스트(AuthLocalServerTests)를 따로 두었다. 이 테스트는 실제 UnityHttpTransport로 서버에 요청을 보낸다. 서버가 켜져 있어야 하므로 평소에는 실행하지 않는 명시적(Explicit) 테스트이다.

  • 실제 요청은 에디터 업데이트(프레임)가 돌아야 진행된다. 그래서 테스트도 yield return null로 프레임을 넘기며 Task의 완료를 기다린다.
  • 리뷰하면서 클라이언트가 서버 명세나 계약과 어긋난 곳(인증 요청 형식, 계정 정보 파일 형식 등)을 찾아 고쳤다.

7. 정리

구성역할
IHttpTransport / UnityHttpTransport요청 하나를 await로 보내고, 결과를 응답·네트워크 오류·타임아웃으로 분류해 돌려준다
HttpResult.Reached서버에 닿았는가. 클라이언트가 취할 행동이 갈리는 기준
AuthSession갱신 → 로그인 → 등록 흐름, 시간 예산, 저장 순서, 401 복구, 연결 상태
OfflineAuthSession요청을 보내지 않는 같은 인터페이스의 구현
GameBootstrap코루틴으로 Task의 완료를 기다린 뒤 타이틀로 간다

이번 작업에서 정리한 비동기의 핵심은 다음과 같다.

  • 비동기는 "기다리는 동안 스레드를 붙잡지 않는 것"이다. 병렬이 아니라, 하나의 메인 스레드가 기다리는 시간을 다른 일에 쓴다.
  • Task는 값이다. 변수에 넣고, 반환하고, 여러 곳이 함께 기다릴 수 있다. (401 복구에서 갱신을 하나로 합친 방식)
  • 비동기 메서드는 예외를 던지지 않고 실패를 값으로 돌려주는 계약을 두면 호출하는 쪽의 분기가 명확해진다.
  • lock은 결정을 내리는 짧은 구간에만 걸고, 기다리는 일(await)은 lock 밖에서 한다.
  • Unity의 생명주기(Start)와 Task는 코루틴으로 완료를 확인하며 이을 수 있다.
  • 비동기 테스트는 즉시 끝나는 Task(Task.FromResult)와 직접 끝내는 Task(TaskCompletionSource) 로 시나리오를 제어한다.

다음 글에서는 이 로그인 위에서 로그를 서버로 보내는 동기화 전송기를 다룬다. 여러 로그가 동시에 쌓이고, 요청이 진행 중인 동안 새 로그가 생기고, 시간 초과 뒤에 응답이 늦게 도착하는 상황에서 "동시에 하나만 보낸다"를 지키는 비동기 흐름이 중심이다.

0개의 댓글