10편에서는 클라이언트가 서버에 요청을 보내는 기반과 게스트 로그인을 다뤘다. 이번 편에서는 그 로그인 위에서 쌓아 둔 로그를 서버로 보내는 동기화 전송기를 다룬다(W3-4). 로그인보다 비동기가 훨씬 까다로워지는 지점이다. 요청이 서버에서 돌아오기를 기다리는 동안에도 게임이 계속 돌아가면서 새 로그가 쌓이기 때문이다.
W3-4의 목표는 다음과 같다.
이 작업은 클라이언트 담당이 구현했고, 나는 계약(5.6~5.8)을 정하고 리뷰하면서 어긋난 부분을 고쳤다.
ProgressStore (진행도 + 로그 장부, 저장)
│ 로그를 더하고 저장한 뒤 LogAppended 이벤트
▼
SyncSender 언제, 어떻게 보낼지. 응답을 받은 뒤 무엇을 할지
▶ ISyncApi.SendAsync (구현: SyncApi) 요청 하나를 보내고 결과를 네 갈래로 돌려준다
▶ IHttpTransport / IAuthSession (10편)
▶ ISyncStore.ApplyResponse 응답을 장부와 진행도에 반영한다
| 클래스 | 역할 |
|---|---|
SyncApi | 요청 하나를 보낸다. 401이면 토큰을 갱신하고 다시 보낸다. 결과를 네 갈래로 분류한다 |
SyncSender | 언제 보낼지를 정한다. 동시에 하나만, 나눠서, 순서대로. 응답 후 처리와 실패 처리 |
SyncLedger / ProgressStore | 대기 로그와 다음 seq를 들고 있고, 응답을 반영하는 규칙(계약 5.7)을 가진다 |
SyncApi와 SyncSender는 10편의 로그인과 같은 이유로 Unity에 의존하지 않는 어셈블리(BlackHole.Net)에 있다. 가짜 ISyncApi로 테스트한다.LogAppended 이벤트가 올라온다"는 것만 알면 된다. 이벤트가 올라올 때는 이미 저장이 끝난 뒤이므로, 전송 도중에 앱이 종료되어도 로그가 사라지지 않는다.구현된 코드를 리뷰하면서 다음을 고쳤다. 대부분 비동기 흐름의 조건과 상태에 관한 것이다.
seq가 보낸 것보다 클 때 실패로 보아 매 실행이 오프라인이 되던 검사SyncApi와 SyncSender가 둘 다 오프라인 전환을 호출하던 이중 처리 → 한 곳(SyncSender)으로 정리Awake에서 멈추던 연결, 게임 빌드에 들어가던 테스트용 가짜 구현SyncApipublic async Task<SyncCallResult> SendAsync(IReadOnlyList<PendingLog> logs)
{
try
{
// 로그는 만들 때의 JSON 글자를 그대로 이어 붙인다(계약 C12).
string body = SyncJson.ComposeRequest(logs.Select(log => log.Json));
HttpResult result = await SendRequestAsync(body);
if (result.Outcome == HttpOutcome.Response && result.StatusCode == 401)
{
// 갱신에 실패하면 IAuthSession이 이미 오프라인으로 바꿨다.
if (!await _auth.RefreshAfterUnauthorizedAsync())
return SyncCallResult.Failed(SyncCallOutcome.Unauthorized);
result = await SendRequestAsync(body); // 같은 본문을 한 번 더 보낸다
}
return Read(result);
}
catch (Exception error)
{
// 예외를 던지지 않는다(ISyncApi). 여기까지 오면 버그다.
_logError("[동기화] 요청 중 예외: " + error);
return SyncCallResult.Failed(SyncCallOutcome.ProtocolError);
}
}
await가 세 곳 있다. 요청을 보낸다 → (401이면) 토큰 갱신을 기다린다 → 같은 본문으로 다시 보낸다. 각 await는 순서대로 하나씩 끝나야 다음으로 넘어가므로, 코드는 동기 코드처럼 위에서 아래로 읽힌다. 이것이 async/await의 장점이다. 콜백으로 얽혔을 흐름이 평범한 순서 코드가 된다.seq는 서버가 건너뛴다.RefreshAfterUnauthorizedAsync)는 10편에서 본 "동시에 하나만" 갱신이다. 동기화 요청과 다른 요청이 동시에 401을 받아도 갱신은 한 번만 나간다.public enum SyncCallOutcome
{
Response, // 200, 응답 본문을 읽었다
Unreachable, // 네트워크 오류, 타임아웃, 5xx
Unauthorized, // 갱신 후에도 401
ProtocolError, // 400, 413, 형식이 맞지 않는 200
}
| 결과 | 원인 | 이번 실행 |
|---|---|---|
Response | 200 | 응답을 반영한다 |
Unreachable | 서버에 닿지 않음 | 오프라인(Unreachable) |
Unauthorized | 갱신했는데도 401 | 오프라인 |
ProtocolError | 400·413·형식 오류 = 클라이언트 버그 | 오프라인(ProtocolError), 오류 로그 |
SyncApi는 오프라인으로 전환하지 않는다. 분류만 하고, 어떻게 할지는 결과를 받은 SyncSender가 한 곳에서 정한다. 리뷰에서 고친 "이중 처리"가 이것이다. 같은 일을 두 곳에서 하면 한쪽이 먼저 상태를 바꾼 뒤 다른 쪽이 또 바꾸는 식으로 어긋나기 쉽다.SyncSender가 전송을 시작하는 경우는 세 가지이다.
| 시작점 | 호출 | 설명 |
|---|---|---|
| 앱 시작 | SyncOnStartupAsync() | 로그인 직후 쌓인 로그를 전부 보낸다. 로그가 없어도 빈 배열을 보내 서버 진행도를 받는다 |
| 온라인 중 | LogAppended 이벤트 | 새 로그가 생기면 바로 보낸다 |
| 이어서 | 이전 전송이 끝난 뒤 | 보내는 중에 쌓인 로그와 나눠 보내는 나머지 |
// 시작 동기화 전에 쌓인 로그는 시작 동기화가 함께 보낸다.
private void OnLogAppended()
{
if (_disposed || _failedThisRun || !_startupAttempted || _auth.State != ConnectionState.Online)
return;
_sendRequested = true;
EnsurePump();
}
_sendRequested = true로 표시하고 EnsurePump()를 부를 뿐이다. 직접 요청을 보내지 않는다. 왜 이렇게 했는지가 다음 절의 주제이다.로그는 플레이 중에 계속 생긴다. 노드를 연달아 사거나, 판을 끝내자마자 구매하면 요청이 아직 서버에서 돌아오지 않은 사이에 새 로그가 쌓인다. LogAppended가 올 때마다 곧바로 요청을 보내면 요청이 겹친다.
그래서 계약(C26)은 "요청은 동시에 하나만, 앞 응답을 반영한 뒤 다음을 보낸다" 로 정했다.
private Task _pump;
private bool _sendRequested;
// 보내는 중이면 그 흐름에 합친다(동시에 하나만, 계약 C26).
private Task EnsurePump()
{
if (_disposed || _failedThisRun)
return Task.CompletedTask;
if (_pump == null || _pump.IsCompleted)
_pump = PumpAsync();
return _pump;
}
private async Task PumpAsync()
{
try
{
while (!_disposed && !_failedThisRun && _sendRequested)
{
_sendRequested = false; // 이번 확인은 시작했다
// ...
while (!_disposed && !_failedThisRun && _auth.State == ConnectionState.Online && _store.Pending.Count > 0)
{
List<PendingLog> batch = CreateBatch(_store.Pending); // 지금 쌓인 것에서 한 묶음
// ...
if (!await SendAndApplyAsync(batch)) // 보내고 응답을 반영할 때까지 기다린다
return; // 거부·실패면 멈춘다
}
}
}
catch (Exception error)
{
Fail(SyncCallOutcome.ProtocolError, "동기화 중 예외: " + error);
}
}
PumpAsync)는 보내기를 맡은 단 하나의 루프이다. 로그가 쌓였다는 신호가 오면 EnsurePump()가 호출되는데, 이미 펌프가 돌고 있으면 새로 만들지 않고 그 Task를 돌려준다. (10편의 _recovering과 같은 패턴이다.)_pump == null 또는 IsCompleted) 새로 시작한다._sendRequested는 "다시 확인해 줘" 표시이다. 신호가 올 때마다 true로 만든다. 펌프가 한 바퀴를 돌고 나서도 이 값이 true면 다시 돈다. 응답을 기다리는 동안 새 로그가 쌓이면 OnLogAppended가 이 표시를 켜 두므로, 펌프는 응답을 반영한 뒤 다음 바퀴에서 그 로그를 이어서 보낸다.로그 3개가 연달아 쌓이는 경우이다. (테스트 LogsAppendedWhileSendingFollowAfterTheResponse와 같다.)
시각 사건 펌프 / 요청
1 로그 1이 쌓임 → LogAppended 펌프 시작 → [1]을 보냄 → await (응답 대기)
2 로그 2가 쌓임 → _sendRequested = true 이미 펌프가 돈다 → 새로 안 만든다
3 로그 3이 쌓임 → _sendRequested = true (같음)
4 응답 도착 (seq 1 반영) await 다음부터 이어서 → 반영
5 Pending에 2, 3이 남아 있다 → [2, 3]을 한 묶음으로 보냄
6 응답 도착 (seq 3 반영) Pending이 비었다 → 펌프 종료
lock이 필요 없나10편에서는 lock으로 상태를 보호했는데, 펌프에는 lock이 없다. EnsurePump()의 _pump == null || _pump.IsCompleted를 확인하고 _pump에 넣는 사이에 다른 코드가 끼어들면 펌프가 둘이 생길 것 같은데 왜 괜찮을까?
await 사이의 코드는 중간에 끊기지 않는다. 다른 코드는 await에서 멈춘 틈에만 끼어들 수 있다._pump를 확인하고 만드는 구간에는 await가 없으므로 그 사이에 다른 코드가 끼어들 수 없다. 이것이 싱글 스레드 비동기의 이점이다. 멀티스레드였다면 lock이 필요했을 것이다. (메서드 주석에도 "메서드와 이벤트는 모두 메인 스레드에서 부른다"고 적었다.)한 요청의 한도는 로그 500개, 본문 1MB이다(계약 3.5). 오프라인으로 오래 쌓았다면 한 번에 다 보낼 수 없다.
// seq 순서대로 500개, 본문 1 MB 이하(계약 3.5). 로그 글자는 그대로 이어 붙이므로 크기를 미리 셀 수 있다.
private static List<PendingLog> CreateBatch(IReadOnlyList<PendingLog> pending)
{
var batch = new List<PendingLog>();
int bytes = EmptyRequestBytes;
for (int i = 0; i < pending.Count && batch.Count < SyncLimits.MaxLogsPerRequest; i++)
{
// 두 번째 로그부터 앞에 쉼표 1바이트가 붙는다.
int added = SyncJson.Utf8Length(pending[i].Json) + (batch.Count > 0 ? 1 : 0);
if (bytes + added > SyncLimits.MaxRequestBytes)
break;
batch.Add(pending[i]);
bytes += added;
}
return batch;
}
while 안에서 Pending을 다시 읽어 CreateBatch를 부른다.) 거부가 나오면 SendAndApplyAsync가 false를 돌려주어 펌프가 멈춘다.batch.Count == 0) 어떤 묶음도 만들 수 없으니 ProtocolError로 처리한다.ProtocolError로 멈춘다. 비동기 루프에서 흔한 "끝나지 않는 반복" 방지 장치이다.Task.WhenAny와 Task.Delay시작 동기화는 타이틀로 가기 전에 한다. 서버가 느려도 게임을 영원히 막을 수는 없다. 그렇다고 보내던 요청을 취소하면, 서버가 이미 반영했는데 클라이언트는 모르는 상태가 될 수 있다.
public Task SyncOnStartupAsync()
{
// ...
_startupAttempted = true;
_sendRequested = true;
_sendEmptyOnNextPump = true; // 로그가 없어도 빈 요청을 보내 서버 진행도를 받는다
return WaitForStartupAsync(EnsurePump());
}
private async Task WaitForStartupAsync(Task pump)
{
Task timeout = Task.Delay(TimeSpan.FromSeconds(_startupTimeoutSeconds));
if (await Task.WhenAny(pump, timeout) == timeout)
_log("[동기화] 시작 동기화가 시간 안에 끝나지 않아 먼저 시작한다. 보내던 요청은 계속 처리한다.");
}
Task.Delay(시간)은 그 시간이 지나면 끝나는 Task(타이머)이다.Task.WhenAny(a, b)는 둘 중 먼저 끝나는 쪽이 끝나면 끝나는 Task이고, 먼저 끝난 Task를 돌려준다. 돌려받은 것이 timeout이면 시간이 먼저 지난 것이고, 펌프이면 정상적으로 먼저 끝난 것이다.WhenAny는 진 쪽을 취소하지 않는다. 시간이 먼저 지나도 펌프는 계속 돌고, 보내던 요청도 계속 기다린다. WaitForStartupAsync가 끝나서 GameBootstrap이 타이틀로 넘어갈 뿐이다.늦게 온 응답도 그대로 반영된다. 이 동작을 테스트로 확인했다.
TaskCompletionSource<SyncCallResult> response = _api.EnqueuePending(); // 직접 끝낼 수 있는 응답
SyncSender sender = Sender(startupTimeout: 1);
Task startup = sender.SyncOnStartupAsync();
Assert.IsTrue(startup.Wait(TimeSpan.FromSeconds(5)), "시작 대기 시간이 지나면 끝나야 한다.");
Assert.IsTrue(sender.IsSending, "보내던 요청은 계속 기다린다.");
response.SetResult(SyncResults.Applied(0)); // 시간이 지난 뒤에 응답이 도착한다
Assert.IsFalse(sender.IsSending);
Assert.AreEqual(1, _store.Applied.Count, "늦게 온 응답도 반영한다.");
startup은 끝났는데 IsSending은 여전히 true이다.SetResult) 펌프가 이어서 그 응답을 반영한다.시작 대기 시간이 지나 타이틀로 넘어간 뒤, 빈 요청의 응답이 오기 전에 사용자가 새 게임을 고르면 RESET 로그가 쌓인다. 이때도 위의 펌프 구조가 그대로 처리한다.
OnLogAppended가 _sendRequested를 켜 두었으므로, 빈 요청의 응답을 반영한 뒤 쌓인 RESET을 이어서 보낸다. (테스트 LogQueuedWhileTheEmptyStartupRequestIsPendingIsSentAfterIt)GameBootstrap에서의 연결// 로그인(10편)이 끝난 뒤:
if (_syncSender != null)
{
Task sync = _syncSender.SyncOnStartupAsync();
while (!sync.IsCompleted)
yield return null; // 10편과 같은 방식으로 프레임마다 확인한다
if (sync.IsFaulted)
Debug.LogError($"[동기화] 시작 동기화 중 예외: {sync.Exception}");
}
_loggingIn = false;
_host.Start(); // 응답이 반영된 진행도로 타이틀을 연다
SyncTimeoutSeconds(10초)이다. 서버가 꺼져 있으면 오프라인이므로 보내지 않고 바로 끝난다.await 뒤에는 상황이 바뀌어 있을 수 있다SendAndApplyAsyncprivate async Task<bool> SendAndApplyAsync(IReadOnlyList<PendingLog> batch)
{
SyncCallResult result = await _api.SendAsync(batch); // 응답을 기다린다
if (result == null || result.Outcome != SyncCallOutcome.Response || result.Response == null)
{
// ... 실패 분류
Fail(outcome, $"요청 실패: {outcome}");
return false;
}
// AppliedWithDrift의 경고 로그는 저장소가 남긴다.
if (_store.ApplyResponse(result.Response) != SyncApplyOutcome.Rejected)
return true; // 거부 없이 반영했다 → 이어서 보내도 된다
// 거부: 남은 로그는 이미 지워졌고 서버 진행도를 채택했다
RejectedLogDto rejection = _store.LastRejection;
// ... 알림 이벤트, 콘텐츠 버전 불일치면 StopForThisRun(ContentOutdated)
return false;
}
await _api.SendAsync(batch)에서 기다리는 동안 세상이 바뀐다. 로그가 더 쌓였을 수 있고, 앱이 정지되었을 수 있다. 그래서 await 뒤에서는 앞에서 알고 있던 값이 아니라 지금의 상태를 다시 읽는다.
Pending에는 새 로그가 더해져 있을 수 있다. 반영은 _store.ApplyResponse(...)가 지금의 Pending 을 기준으로 한다.SyncLedger.Apply)에 있다.| 응답 | 장부의 처리 |
|---|---|
| 거부 없음 + 남은 로그 있음 | 반영된 로그만 지운다. 진행도는 덮어쓰지 않는다 |
| 거부 없음 + 남은 로그 없음 | 반영된 로그를 지운다. 서버 진행도가 로컬과 다르면 서버 값으로 맞춘다(AppliedWithDrift: 규칙 이식 버그의 신호) |
| 거부 있음 | 남은 로그를 모두 지우고 서버 진행도로 덮어쓴다 |
서버 진행도 + 남은 로그의 효과이다. 이 상태에서 서버 진행도로 덮어쓰면 남은 로그의 효과(방금 산 노드 등)가 사라진다. 그래서 남은 로그가 있으면 덮어쓰지 않는다.seq 다음부터 다시 쓰도록 NextSeq를 맞춘다. (테스트 RejectionAlsoDiscardsLogsAppendedWhileSending)RejectionReceived 이벤트로 알린다. 이 이벤트를 받는 쪽(알림 UI)에서 예외가 나도 이미 반영을 마친 동기화를 실패로 만들지 않도록 try/catch로 감싼다.CONTENT_VERSION_MISMATCH는 다른 거부와 똑같이 반영하되, 이번 실행을 오프라인(ContentOutdated)으로 전환한다. 버전이 다른 채로 계속 보내면 로그마다 거부되어 진행도가 매번 서버 값으로 돌아가기 때문이다.// 이번 실행은 더 보내지 않는다. 로그는 그대로 두어 다음 실행이 보낸다(계획서 D9).
private void Fail(SyncCallOutcome outcome, string message)
{
if (_failedThisRun)
return;
// ... 로그
StopForThisRun(outcome == SyncCallOutcome.Unreachable ? OfflineReason.Unreachable : OfflineReason.ProtocolError);
}
private void StopForThisRun(OfflineReason reason)
{
if (_failedThisRun)
return;
_failedThisRun = true;
_sendRequested = false;
_auth.GoOffline(reason); // 오프라인 전환은 여기서만 한다
}
_failedThisRun이 한 번 켜지면 펌프의 루프 조건(!_failedThisRun)이 거짓이 되어 멈추고, OnLogAppended도 더 이상 전송을 시작하지 않는다.Task의 예외OnLogAppended는 EnsurePump()를 부르고 돌려받은 Task를 쓰지 않는다. 아무도 await하지 않는 Task이다. 10편에서 봤듯이 async 메서드의 예외는 Task 안에 저장되는데, 아무도 이 Task를 확인하지 않으면 예외가 조용히 사라진다.
그래서 PumpAsync는 전체를 try/catch로 감싸고, 잡은 예외를 Fail(ProtocolError, ...)로 처리한다. 아무도 기다리지 않는 Task는 자기 안에서 예외를 끝까지 처리해야 한다. 예외를 던지지 않는다는 계약을 코드의 가장 바깥에서 지키는 것이다.
가짜 ISyncApi와 실제 장부(SyncLedger)로 전송 규칙을 확인한다. 10편과 마찬가지로 즉시 끝나는 Task와 TaskCompletionSource를 구분해서 쓴다.
internal sealed class FakeSyncApi : ISyncApi
{
private readonly Queue<Task<SyncCallResult>> _results = new();
public void Enqueue(SyncCallResult result) => _results.Enqueue(Task.FromResult(result));
// 테스트가 직접 끝내는 결과. 응답을 기다리는 동안의 동작을 본다.
public TaskCompletionSource<SyncCallResult> EnqueuePending() { /* ... */ }
}
Task.FromResult로 넣은 응답은 곧바로 끝나므로, 앞으로 가는 흐름(보내고 → 반영하고 → 다음을 보내고)이 한 번의 호출 안에 끝난다.EnqueuePending으로 넣은 응답은 테스트가 SetResult를 부를 때까지 끝나지 않는다. "응답을 기다리는 도중에 로그가 쌓이면?"을 만들려면, 응답을 일부러 붙잡아 둔 채 로그를 더하고 나서 응답을 끝내야 한다.| 시나리오 | 테스트 |
|---|---|
| 로그 없이 시작하면 빈 요청 하나를 보낸다 | StartupWithoutLogsSendsOneEmptyRequest |
| 오프라인이면 아무것도 보내지 않는다 | StartupOfflineSendsNothing |
| 시작 대기 시간이 지나도 보내던 요청은 계속하고 늦은 응답도 반영 | StartupStopsWaitingAfterTimeoutAndKeepsTheRequest |
| 온라인 중 쌓인 로그는 즉시 보낸다 | LogAppendedWhileOnlineIsSentImmediately |
| 보내는 중 쌓인 로그는 응답을 반영한 뒤 이어서 보낸다 (요청은 하나만) | LogsAppendedWhileSendingFollowAfterTheResponse |
| 500개, 1MB 경계에서 나눠 보낸다 | SplitsAt500LogsAndSendsTheNextAfterApplying, FillsABatchUpToExactly1MB |
| 거부되면 남은 로그를 폐기하고 서버 진행도를 따른다 | RejectionStopsAndDiscardsTheRestButKeepsOnline |
| 보내는 중 쌓인 로그도 함께 폐기한다 | RejectionAlsoDiscardsLogsAppendedWhileSending |
| 콘텐츠 버전이 다르면 반영하고 오프라인으로 전환 | ContentVersionMismatchIsAppliedLikeOtherRejectionsThenGoesOfflineAsContentOutdated |
| 실패하면 로그를 남기고 멈춘다 | FailureKeepsTheLogsAndStopsForThisRun |
| 나눠 보내다 실패하면 보내지 못한 로그만 남는다 | FailureAfterAPartialSendKeepsOnlyTheUnsentLogs |
가짜가 아닌 실제 SyncApi, SyncSender, ProgressStore로 로컬 서버를 상대로 한 시나리오(SyncLocalServerTests, 서버가 필요한 Explicit 테스트)도 돌렸다.
첫 실행: 등록·로그인 → 시작 동기화
→ RESET, 판 시작, 결산, 구매가 즉시 전송 (seq 4까지 반영, Gold 18)
오프라인 실행: 구매 로그(seq 5)만 저장, 보내지 않음
다음 실행의 시작 동기화로 반영 (Gold 11, 노드 2개)
다시 불러와도 대기 로그가 없다
7편의 서버 엔진과 이 클라이언트가 실제로 맞물려 동작하는 것을 확인한 시나리오이다.
| 구성 | 핵심 |
|---|---|
SyncApi | 요청 하나. 401이면 갱신 후 같은 본문 재전송. 결과를 네 갈래로 분류하고 오프라인 전환은 하지 않는다 |
SyncSender 펌프 | 하나의 루프 + "다시 확인해 줘" 표시로 요청을 동시에 하나만. 쌓인 로그는 응답 반영 후 모아서 이어 보낸다 |
| 시작 동기화 | Task.WhenAny + Task.Delay로 기다림에만 제한을 둔다. 요청은 계속하고 늦은 응답도 반영한다 |
| 응답 반영 | await 뒤에는 지금의 상태를 다시 읽는다. 남은 로그가 있으면 진행도를 덮어쓰지 않는다 |
| 실패 처리 | 로그를 남기고 이번 실행은 멈춘다. 오프라인 전환은 한 곳에서만 |
이번 편에서 비동기에 대해 추가로 정리한 것이다.
await 사이의 코드는 끊기지 않는다. 메인 스레드 하나에서 도는 비동기에서는 이것을 이용해 lock 없이 "동시에 하나만"을 만들 수 있다.await를 지나면 세상이 바뀌어 있을 수 있다. 기다리기 전에 읽은 값을 가정하지 말고 지금의 상태를 다시 읽어야 한다.Task.WhenAny로 기다리는 쪽만 먼저 포기하고, 작업은 계속하게 둘 수 있다.Task는 자기 안에서 예외를 처리해야 한다. 그렇지 않으면 실패가 조용히 사라진다.Task와 직접 끝내는 Task(TaskCompletionSource) 를 구분해서, 비동기 시나리오를 결정적으로 재현한다.이 두 편으로 클라이언트가 서버와 대화하는 기본 흐름(로그인과 동기화)이 갖춰졌다.