지금까지의 서버 개발일지는 서버 쪽 구현이 중심이었다. 이번 글부터 두 편은 클라이언트가 서버에 요청을 보내는 흐름을 다룬다. 이번 편에서는 요청을 보내는 기반(전송 계층)과 게스트 로그인을, 다음 편에서는 로그를 서버로 보내는 동기화 전송기를 다룬다. 이 작업에서 가장 크게 배운 것이 비동기라서, 그 개념을 코드와 함께 따라가는 데 비중을 두었다.
| 작업 | 내용 |
|---|---|
| W2-1 | 네트워크 계층: HTTP 요청 전송, JSON 변환 |
| W2-2 | 게스트 인증: 갱신 → 로그인 → 등록 흐름, 계정 정보 저장 |
| W2-3 | 연결 상태와 시작 흐름: 앱 시작 시 로그인하고, 끝나면 타이틀로 |
클라이언트 코드는 Unity에 의존하는 부분과 의존하지 않는 부분으로 나눠서 두 개의 어셈블리에 담았다. 어셈블리에 대해서도 나중에 따로 정리할 필요가 있을 것 같다. 이번 프로젝트에선 대략 "분리된 코드 영역" 정도로만 이해하고 사용하는 중이다.
| 어셈블리 | Unity 의존 | 내용 |
|---|---|---|
BlackHole.Net | 없음 | 요청·응답 타입, 인터페이스, 인증 흐름, DTO. 흐름의 단위 테스트는 여기를 대상으로 한다 |
BlackHole.Net.Unity | 있음 | UnityWebRequest 전송, JsonUtility 변환, 계정 정보 파일 저장, 서버 설정 에셋 |
UnityWebRequest)는 IHttpTransport라는 인터페이스 뒤에 숨긴다.GameBootstrap (시작 시 연결)
▶ AuthSession 로그인 흐름 (갱신 → 로그인 → 등록), 연결 상태
▶ GuestAuthApi 인증 요청 3개(등록, 로그인, 갱신)를 보내고 결과를 해석
▶ IHttpTransport ←── 실제: UnityHttpTransport (UnityWebRequest)
←── 테스트: FakeHttpTransport
▶ ICredentialStore 계정 정보를 파일로 저장 (FileCredentialStore)
모든 계층의 메서드가 Task를 돌려준다. 이것이 이번 작업의 가장 중요한 특징이고, 다음 절에서 설명한다.
대강은 알고 있었지만, 직접 다뤄본 적이 거의 없어 낯설었던 개념이다. 이번 기회에 정리해보자.
서버에 요청을 보내면 응답이 올 때까지 수백 밀리초에서 수 초까지도 걸린다. 서버가 응답하지 않으면 타임아웃(우리는 5~10초)까지 기다린다. 그런데 Unity에서 게임 로직과 화면 그리기는 메인 스레드 하나가 프레임마다 한 번씩 돌면서 처리한다.
만약 요청을 동기 방식으로 보내서 응답이 올 때까지 메인 스레드가 그 자리에서 기다리면, 그동안 프레임이 돌지 못해 화면이 멈춘다. 서버가 느리면 게임 전체가 얼어붙는 것이다.
비동기는 응답을 기다리는 동안에는 메인 스레드를 붙잡지 않고 다른 일(다음 프레임)을 하게 두고, 응답이 오면 이어서 처리한다. 따라서 서버가 응답하는 동안에도 멈추지 않는다.
Task와 async/awaitpublic 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에 닿을 때까지는 호출한 자리에서 그냥 동기적으로 실행된다.
처음에 헷갈렸던 부분이다. "비동기니까 다른 스레드에서 동시에 도는 건가?"라고 생각했지만 그렇지 않다.
await 전후, 그리고 await이 부르는 작업이 같은 스레드에서 실행되는 것을 보장하지 않는다.SynchronizationContext를 공부해보자.UnityEngine의 API를 await 뒤에서 그대로 써도 안전하다.| 동기 | 비동기 | 병렬(멀티스레드) | |
|---|---|---|---|
| 기다리는 동안 | 스레드가 멈춰 있다 | 스레드가 다른 일을 한다 | - |
| 코드가 실행되는 스레드 | 하나 | 하나(메인 스레드) | 여러 개 |
| 이번 프로젝트 | 사용 안 함 | 사용 | 사용 안 함 |
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로 분기를 명시적으로 다룰 수 있다.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;
| 결과 | IsSuccess | Reached | 의미 |
|---|---|---|---|
| 2xx | O | O | 성공 |
| 4xx | X | O | 서버가 거부. 요청 내용 때문에 거절된 것이다 |
| 5xx | X | X | 서버가 처리하지 못했다. 닿지 않은 것으로 본다 |
| 네트워크 오류, 타임아웃 | X | X | 닿지 않았다 |
Reached가 이 구분의 핵심이다. 서버에 닿았는가에 따라 클라이언트가 할 일이 완전히 다르기 때문이다.
UnityHttpTransportpublic 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초)ToResultinternal 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 변환은 Unity의 JsonUtility를 쓴다. 규칙이 몇 가지 있어서 IJsonSerializer 뒤에 숨겼다.
[Serializable] 클래스의 public 필드로 만든다.null이나 다형성(타입마다 다른 payload)을 지원하지 않는다. 그래서 동기화 로그는 봉투와 payload를 따로 직렬화한다. (다음 편)error 객체가 실제로 있는지는 code가 비어 있지 않은지로 판단한다.서버의 게스트 인증은 2편에서 만들었다. 클라이언트는 이 API를 이렇게 쓴다.
앱 시작 → 저장된 계정 정보를 읽는다
│
├ refresh 토큰이 있다 → 갱신 요청
│ ├ 성공 → 온라인
│ ├ 서버에 닿지 않음 → 오프라인(Unreachable)
│ └ INVALID_REFRESH_TOKEN → 토큰을 지우고 ↓
│
├ guestId·guestSecret이 있다 → 로그인 요청
│ ├ 성공 → 온라인
│ ├ 서버에 닿지 않음 → 오프라인(Unreachable)
│ └ INVALID_CREDENTIALS → 오프라인(CredentialsRejected). 새 계정을 만들지 않는다
│
└ 계정 정보가 없다(최초 실행) → 등록 요청 → 아이디·시크릿 저장 → 로그인 요청
전체 흐름은 로그인 시간 예산(8초) 안에서 진행한다. 이 예산을 넘으면 오프라인으로 실행한다.
AuthSession.StartAsyncpublic 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;
}
seq가 1이 아니라서 첫 동기화가 SEQ_GAP으로 거부되고, 응답으로 온 빈 서버 진행도가 로컬 진행도를 덮어쓴다. 플레이 기록이 사라지는 것이다.액세스 토큰은 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을 쥔 채 실행되면 안 되기 때문이다.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초가 기본값이다. 로그인이 최악의 경우 시작을 막는 시간은 이 값들로 제한된다.Start()이다. 콘텐츠와 진행 저장을 읽은 뒤 타이틀로 가기 전에 로그인을 기다린다. 서버가 꺼져 있으면 연결 오류가 곧바로 와서 거의 기다리지 않고, 서버가 응답하지 않으면 최대 8초이다.비동기 흐름은 테스트하기가 까다롭다. 이 프로젝트에서는 두 가지로 풀었다.
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의 완료를 기다린다.| 구성 | 역할 |
|---|---|
IHttpTransport / UnityHttpTransport | 요청 하나를 await로 보내고, 결과를 응답·네트워크 오류·타임아웃으로 분류해 돌려준다 |
HttpResult.Reached | 서버에 닿았는가. 클라이언트가 취할 행동이 갈리는 기준 |
AuthSession | 갱신 → 로그인 → 등록 흐름, 시간 예산, 저장 순서, 401 복구, 연결 상태 |
OfflineAuthSession | 요청을 보내지 않는 같은 인터페이스의 구현 |
GameBootstrap | 코루틴으로 Task의 완료를 기다린 뒤 타이틀로 간다 |
이번 작업에서 정리한 비동기의 핵심은 다음과 같다.
Task는 값이다. 변수에 넣고, 반환하고, 여러 곳이 함께 기다릴 수 있다. (401 복구에서 갱신을 하나로 합친 방식)lock은 결정을 내리는 짧은 구간에만 걸고, 기다리는 일(await)은 lock 밖에서 한다.Start)와 Task는 코루틴으로 완료를 확인하며 이을 수 있다.Task(Task.FromResult)와 직접 끝내는 Task(TaskCompletionSource) 로 시나리오를 제어한다.다음 글에서는 이 로그인 위에서 로그를 서버로 보내는 동기화 전송기를 다룬다. 여러 로그가 동시에 쌓이고, 요청이 진행 중인 동안 새 로그가 생기고, 시간 초과 뒤에 응답이 늦게 도착하는 상황에서 "동시에 하나만 보낸다"를 지키는 비동기 흐름이 중심이다.