Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드

Unity 네이티브 클라이언트의 Lobby 씬과 배치고사 씬을 같은 빌드 안에 묶었다. NestJS 응답 모델과 Unity C# 모델의 필드명 불일치, NetworkManager 단일 진입점 래퍼, AuthGuard 패턴, 배치고사 완료 시 첫 숙제 자동 발행 체인, contentUrl 전체 경로 정책까지 6가지 설계 결정과 트레이드오프를 정리한다.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • 무엇: Unity 네이티브 클라이언트의 Lobby 씬배치고사 씬을 같은 빌드 안에 묶고, NestJS API와 한 호흡으로 연동한 통합 작업
  • 핵심 결정: 응답 필드명은 BE 표기 그대로 Unity가 받는다 — 변환 레이어를 BE도 Unity도 아닌 NetworkManager 래퍼 한 곳에 둔다
  • AuthGuard 패턴: 컨트롤러마다 인증 체크를 따로 짜지 않고 유틸리티 한 줄 호출로 분리, 401 침묵 실패 차단
  • 자동 첫 숙제 발행 체인: 배치고사 5지표 완료 → currentLevelId 결정 → Assignment(source: INITIAL) 자동 생성 — Lobby의 todayAssignment null 함정 차단
  • CMS 운영 게이트 분리: DiagnosticVersion.status === ACTIVE가 안 되어 있으면 코드는 정상이고 운영 데이터만 404 — 운영 게이트와 코드 검증을 다른 층에서 봐야 한다
  • 트레이드오프: Standalone 모드 보존을 우선해 Unity 모드에서 HashRouter 누락이 되살아났고, contentUrl 상대 경로가 WebView 컨텍스트 미확정 환경에서 그대로 깨진 두 사례가 통합 단계에서 청구됐다

🎯 배경 — 두 클라이언트가 같은 회원을 보는 첫 통합

이전 편에서는 같은 사람이 BE·FE·QA·PM 역할을 갈아끼우며 학습 클라이언트 웹 프로토타입의 QA 라운드를 5번 돌렸다. 모두 웹 한 종에서 일어난 일이었다. 이번 작업은 다르다. Unity 네이티브 클라이언트가 같은 BE를 호출하기 시작한 첫 시점이다.

두 클라이언트가 같은 회원의 상태를 보면 곧바로 두 가지 함정이 같이 들어온다.

1. 응답 모델 불일치 — BE는 한쪽 표기, Unity는 다른 쪽 표기로 모델을 짠다
2. 인증 컨텍스트 격차 — 웹은 axios interceptor가 토큰을 자동 헤더, Unity는 HttpClient에 손으로 얹는다
3. CMS 운영 데이터 — DRAFT/ACTIVE 같은 운영 게이트가 코드 검증 통과 후에도 404를 만든다
4. 환경별 URL 형태 — 웹은 상대 경로로도 작동, Unity WebView는 상대 경로면 컨텍스트가 빈 채로 깨진다

위 네 가지가 “회원 한 명을 같이 본다”는 한 작업 안에 동시에 등장한다. 한 가지씩 따로 풀면 같은 통합을 다섯 번 반복하게 되니, 한 작업 안에서 같이 묶어 풀기로 했다.

이번 통합의 범위는 셋이다.

  1. Lobby 씬: Unity 진입 직후 회원의 닉네임·트랙 상태·오늘의 숙제 카드를 표시한다
  2. 배치고사 씬: 5개 지표(METRIC_A~METRIC_E)를 순차로 풀고 결과를 BE에 제출한다
  3. 두 씬 간 자동 진입 체인: 배치고사 완료 시점에 회원의 첫 숙제(Assignment)가 자동 생성돼 Lobby로 돌아왔을 때 곧장 활동 가능 상태로 진입한다

📌 핵심: 이 통합의 목표는 화면 두 개를 띄우는 게 아니라, **“배치고사를 처음 푼 회원이 Lobby로 돌아왔을 때 다음 단계가 비어 있지 않다”**는 한 줄을 한 빌드 안에서 보장하는 것이다. 그 한 줄을 위해 응답 모델, 인증 유틸리티, CMS 게이트, 자동 발행 체인까지 네 곳을 동시에 손대야 했다.


⚖️ 설계 결정 — 6건과 트레이드오프

통합 단계에서 내린 결정 여섯 건이다. 각 결정의 채택 사유와 같이 청구되는 비용을 한 표에 모은다.

#결정채택거절트레이드오프
1응답 필드명 정렬BE 표기 그대로 Unity가 받음Unity 쪽에 자유 이름 + 매핑 레이어BE 명세가 단일 출처 vs Unity 코드의 영문 비통일
2응답 변환 위치NetworkManager 래퍼 한 곳에서 wrapper 해제각 Controller가 직접 JsonUtility 호출모든 응답이 같은 진입점 vs ParseResponse<T> 분기 누락 시 침묵 실패
3인증 체크 위치AuthGuard.cs 유틸리티 한 줄각 Controller가 인증 체크 코드 복사누락 0건 vs 한 유틸리티에 정책 집중
4첫 숙제 발행 시점BE에서 배치고사 완료 시 자동 발행Unity가 Lobby 진입 시 명시 호출Lobby가 null 함정 안 봄 vs BE 도메인 서비스에 부수효과 1건
5CMS 운영 게이트DiagnosticVersion.status === ACTIVE코드만으로 활성 분기운영 시점 토글 가능 vs 운영 데이터 누락 시 404
6contentUrl 형식BE가 항상 전체 URL 반환클라이언트가 base URL prefixWebView 컨텍스트 무관 vs 환경(dev/staging/prod) 분기 BE에 잔류

결정 1·2가 같이 가는 이유

응답 필드명을 통일하지 않으면 매핑 레이어가 어디든 한 곳에 들어간다. BE 쪽에 두면 다른 클라이언트(웹·향후 모바일)마다 BE에 분기 코드를 추가해야 하고, Unity 쪽에 두면 똑같은 매핑 코드가 컨트롤러마다 반복된다. 이번엔 BE 명세를 단일 출처로 두고, Unity는 BE 표기 그대로 모델을 짜는 방식을 골랐다.

대신 봉투(wrapper) 해제는 다른 곳에 둔다. NestJS 응답 표준화 인터셉터가 모든 응답을 { success, data, meta } 봉투에 담아 보낸다. Unity가 그 봉투를 직접 까면 안 된다. NetworkManager.ParseResponse<T> 한 메서드에서만 까야, 봉투 규격이 바뀌어도 변경 위치가 한 곳에 머문다.

// Unity/Network/NetworkManager.cs — 발췌
public class NetworkManager : MonoBehaviour
{
    public T ParseResponse<T>(string json) where T : class
    {
        var envelope = JsonUtility.FromJson<ApiEnvelope>(json);
        if (envelope == null || !envelope.success)
        {
            Debug.LogError($"[NetworkManager] failure: {json}");
            return null;
        }

        // 봉투 안의 data만 떼서 T로 다시 deserialize
        return JsonUtility.FromJson<T>(envelope.data);
    }

    [Serializable]
    private class ApiEnvelope
    {
        public bool success;
        public string data;   // raw json string (한 번 더 deserialize)
        public string meta;
    }
}

ApiEnvelope.datastring으로 둔 게 핵심이다. JsonUtility는 제네릭 타입을 한 번에 못 뚫는다. 봉투를 까서 안쪽 json 문자열을 손에 쥔 다음, 컨트롤러가 원하는 타입 T로 다시 deserialize한다. 이 두 단계 분리가 봉투 규격과 데이터 모델을 독립적으로 진화시킨다.

다만 통합 직후 한 컨트롤러가 ParseResponse<T>를 우회하고 JsonUtility를 직접 호출하는 일이 발생했다 — HomeResponseWrapper라는 임시 모델이 그 흔적이다. 결정 2의 침묵 실패가 곧장 회고 거리로 돌아왔다.

⚠️ 주의: 봉투 해제 진입점을 한 곳으로 모으는 결정은 **“이 한 곳을 우회한 코드가 있으면 곧장 발견되어야 한다”**는 검출 장치가 함께 가야 의미 있다. 검출 장치 없이 진입점만 정해 두면 결정 2는 가이드 문서로 남고 통합 단계의 비용으로 청구된다.

결정 3: 인증 체크는 컨트롤러 밖으로

Unity 네이티브 컨트롤러는 한 씬에 여러 개가 붙는다. Lobby에 LobbyController, 배치고사 화면에 DiagnosticController, 콘텐츠 진입에 ContentPlayerController — 통합 직후 8개를 넘었다. 컨트롤러마다 진입 시점에 토큰 유효성을 체크하는 코드를 박으면 누락이 무조건 발생한다.

// Unity/Common/AuthGuard.cs — 발췌
public static class AuthGuard
{
    /// <summary>
    /// 씬 진입 직후 호출. 토큰이 없거나 만료면 로그인 씬으로 강제 이동.
    /// </summary>
    public static bool RequireAuth(MonoBehaviour caller)
    {
        var token = TokenStore.Get();
        if (string.IsNullOrEmpty(token))
        {
            Debug.LogWarning($"[AuthGuard] no token, caller={caller.GetType().Name}");
            SceneManager.LoadScene("Login");
            return false;
        }
        if (TokenStore.IsExpired(token))
        {
            Debug.LogWarning($"[AuthGuard] expired token, caller={caller.GetType().Name}");
            TokenStore.Clear();
            SceneManager.LoadScene("Login");
            return false;
        }
        return true;
    }
}

각 컨트롤러는 Start() 첫 줄에 if (!AuthGuard.RequireAuth(this)) return;만 적는다. 401 침묵 실패가 거의 사라졌다. 정책이 한 유틸리티에 집중되니, 정책 변경(예: refresh token 로직 추가)이 컨트롤러 8개를 도는 작업이 아니라 AuthGuard.cs 한 파일 변경으로 끝난다.

대가는 유틸리티 한 곳에 정책이 집중된다는 점이다. 이 메서드가 깨지면 8개 컨트롤러가 동시에 깨진다. 그래서 AuthGuard.RequireAuth는 단위 테스트가 가장 두꺼운 영역으로 분류됐다.

결정 4: 첫 숙제는 BE가 자동 발행한다

처음엔 Unity가 Lobby 진입 시 “오늘 숙제 있나” 본 다음, 없으면 별도 API를 호출해 발행하는 방식을 검토했다. 거절했다. **“숙제는 회원의 학습 상태가 결정한다”**는 도메인 규칙이 클라이언트로 새어 나가서는 안 됐기 때문이다.

대신 BE 쪽 student-diagnostic.application.service.ts의 5지표 완료 처리 끝단에 자동 발행을 추가했다.

// apps/api/src/application/services/student-diagnostic.application.service.ts — 발췌
async submitDiagnostic(sessionId: number, dto: SubmitDiagnosticDto) {
  // ... 5지표 완료 처리 ...

  // 마지막 지표(PROCESSING_SPEED) 제출이면 currentLevelId 결정 + 첫 숙제 자동 발행
  if (isLastMetric(dto.metricType)) {
    const levelId = this.computeInitialLevel(session.resultMetrics);
    await this.prisma.member.update({
      where: { id: session.memberId },
      data: { currentLevelId: levelId, diagnosticSessionId: session.id },
    });

    // INITIAL source의 첫 숙제 — 회원이 Lobby로 돌아왔을 때 곧장 활동 가능
    await this.assignmentService.createInitialAssignment(session.memberId, {
      classId: session.classId,
      levelId,
      source: 'INITIAL',
      scheduledAt: today(),
    });
  }
}

이 한 토막이 결정 4를 만든다. 회원이 Lobby로 돌아왔을 때 todayAssignment가 null인 함정은 BE가 책임진다. Unity는 Lobby에서 “숙제 없음” 분기를 그릴 의무가 없어지지는 않지만, 적어도 *“배치고사를 막 끝낸 회원에게 숙제가 없다”*는 시나리오는 코드 레벨에서 차단된다.

대가는 student-diagnostic.application.service.ts에 부수효과가 1건 추가된다는 점이다. 도메인 서비스가 자기 도메인 밖의 객체(Assignment)를 만든다. 트랜잭션 범위와 실패 시 정책이 같이 들어와야 안전하다 — 자동 발행이 실패해도 배치고사 제출 자체는 막지 않도록 try/catch로 분리했다. 같은 패턴을 지표 누계 시스템에서 한 번 정리한 적이 있다.

결정 5: CMS 운영 게이트는 코드 외부에 둔다

DiagnosticVersion이라는 모델이 있다. 배치고사 문제 풀세트의 버전을 관리하는 운영 객체로, status 필드를 DRAFT / ACTIVE / ARCHIVED로 둔다. Unity가 POST /diagnostic/start를 때리면 BE는 status === 'ACTIVE'인 버전 1건을 찾아 세션을 생성한다.

통합 첫날, 이 API가 404를 뱉었다. Unity 코드도 BE 코드도 정상이었다. 문제는 운영 데이터였다.

항목
DiagnosticVersion.id1
statusDRAFT
API 응답404 No active version

해결은 코드 변경 0건이었다. SQL 한 줄로 statusACTIVE로 바꾸면 통합이 진행됐다.

UPDATE DiagnosticVersion SET status = 'ACTIVE' WHERE id = 1;

이 사례가 결정 5의 트레이드오프를 분명히 드러낸다. 운영 게이트를 코드 외부(DB 컬럼)에 두면 코드는 깔끔해지지만 운영 데이터가 안 맞으면 통합이 멈춘다. 그렇다고 코드 안에 활성 분기를 박으면 운영 시점에 토글이 안 된다. 이번엔 게이트를 외부에 두는 결정을 유지하되, 통합 작업 시작 전 점검 항목에 **“CMS 운영 게이트 점검”**을 한 줄 추가하는 것으로 비용을 분산했다.

🔍 단서: 코드 통과 + 운영 데이터 미준비 = 404는 본질적으로 두 층의 통합 비용이다. 통합 작업의 첫 30분은 거의 항상 운영 데이터 점검에 들어간다. 이 30분을 줄이는 가장 싼 방법은 운영 데이터 점검 체크리스트를 코드 변경 직전 단계에 박아두는 것이다.

결정 6: contentUrl은 BE가 전체 URL을 책임진다

콘텐츠 선택 API(POST /bundle/:id/select-content)의 응답에서 contentUrl이 처음엔 상대 경로로 나왔다.

{ "contentUrl": "/mock-bundle-matrix_reasoning-1.html" }

웹에서는 잘 동작했다. 브라우저가 현재 origin을 기준으로 상대 경로를 해석해 주기 때문이다. 그런데 Unity WebView로 같은 응답을 넘기자 곧장 깨졌다. WebView 컨텍스트의 base URL이 비어 있거나 about:blank였기 때문이다. WebView는 “현재 origin”이라는 개념이 모호하다.

BE에서 한 번에 정리했다.

// 변경 전
return { contentUrl: `/contents/${contentSeq}.html` };

// 변경 후
return { contentUrl: `${CONTENT_BASE_URL}/contents/${contentSeq}.html` };

CONTENT_BASE_URL은 환경변수로 분기한다. dev은 http://localhost:3000, prod는 https://fnj.or.kr/app. 분기 책임이 BE에 잔류한다는 점이 비용이지만, 클라이언트가 환경별로 prefix를 더하는 책임을 안 지는 게 통합 속도에 더 결정적이었다.

developer.vuplex.com

Vuplex 공식 문서는 LoadUrl 메서드가 절대 URL 또는 file:// 스킴을 요구한다고 명시한다. 상대 경로는 정의되지 않은 동작이라 환경마다 다르게 깨진다. 클라이언트가 환경 prefix를 알도록 만드는 설계는 처음부터 부담을 잘못 분배한 결과였다.


🛠️ 구현 — 두 컨트롤러와 자동 발행 체인

도식으로 통합 전체 흐름을 한 장에 모았다.

Unity Lobby와 배치고사 씬을 NetworkManager·AuthGuard·BE API로 묶는 통합 구조도 — 응답 필드 매핑·자동 첫 숙제 발행 체인 포함

Lobby 씬 — LobbyController.cs

Lobby는 진입 직후 한 번의 API 호출로 회원의 모든 표시 데이터를 받는다.

// Unity/Scenes/Lobby/LobbyController.cs — 발췌
public class LobbyController : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI nicknameText;
    [SerializeField] private GameObject mixBadge;
    [SerializeField] private TodayCardView todayCard;
    [SerializeField] private NewRecordCardView newRecordCard;

    private void Start()
    {
        if (!AuthGuard.RequireAuth(this)) return;

        StartCoroutine(LoadHome());
    }

    private IEnumerator LoadHome()
    {
        var req = NetworkManager.Instance.Get("/student/home");
        yield return req.SendWebRequest();

        var home = NetworkManager.Instance.ParseResponse<HomeResponse>(req.downloadHandler.text);
        if (home == null)
        {
            // ParseResponse가 실패 로그를 이미 찍었음 — 회원에게는 재시도 안내
            todayCard.SetEmpty("일시적인 오류가 발생했습니다.");
            yield break;
        }

        ApplyHome(home);
    }

    private void ApplyHome(HomeResponse home)
    {
        nicknameText.text = home.student.name;
        mixBadge.SetActive(home.student.trackState == "MIX");

        if (home.todayAssignment != null)
        {
            todayCard.SetActive(home.todayAssignment);
        }
        else
        {
            todayCard.SetEmpty("오늘은 활동이 없어요.");
        }

        newRecordCard.SetCount(home.newRecordCount);
    }
}

읽을 때 두 가지에 주의한다. 첫째, AuthGuard.RequireAuth가 첫 줄이다. 토큰이 없으면 곧장 로그인 씬으로 빠진다. 둘째, HomeResponse의 필드명이 BE 응답 그대로다. student.name(nickname 아님), todayAssignment(currentAssignment 아님), newRecordCount(hasNewRecordChallenge 아님). 결정 1의 직접 결과다.

HomeResponse는 BE 표기를 그대로 흉내 낸 단순 클래스다.

// Unity/Models/HomeResponse.cs — 발췌
[Serializable]
public class HomeResponse
{
    public StudentInfo student;
    public CurriculumInfo curriculum;        // null 가능
    public AssignmentInfo todayAssignment;   // null 가능
    public AttendanceInfo attendance;
    public int newRecordCount;
}

[Serializable]
public class StudentInfo
{
    public string id;
    public string name;
    public string trackState;
    public LevelInfo currentLevel;
}

이 클래스가 한 줄이라도 BE와 어긋나면 JsonUtility가 침묵으로 필드를 비워둔다. 그래서 두 표기를 한 사람이 동시에 짠다는 점이 1인 환경의 거의 유일한 안전 장치였다.

배치고사 씬 — DiagnosticController.cs

배치고사 씬은 5지표를 순차로 푼다. 컨트롤러는 진입 시점에 세션을 만들고, 지표 하나 끝날 때마다 다음 지표 문제를 받아 패널을 교체한다.

// Unity/Scenes/Diagnostic/DiagnosticController.cs — 발췌
public class DiagnosticController : MonoBehaviour
{
    [SerializeField] private HeaderPanel headerPanel;
    [SerializeField] private ProblemPanel problemPanel;
    [SerializeField] private MetricCompletePanel metricCompletePanel;
    [SerializeField] private DiagnosticCompletePanel diagnosticCompletePanel;

    private static readonly string[] METRIC_ORDER = {
        "METRIC_A", "METRIC_B", "METRIC_C", "METRIC_D", "METRIC_E"
    };

    private int currentMetricIndex = 0;
    private string sessionId;

    private void Start()
    {
        if (!AuthGuard.RequireAuth(this)) return;

        StartCoroutine(StartSession());
    }

    private IEnumerator StartSession()
    {
        var req = NetworkManager.Instance.Post("/student/diagnostic/start", "{}");
        yield return req.SendWebRequest();

        var start = NetworkManager.Instance.ParseResponse<StartDiagnosticResponse>(
            req.downloadHandler.text
        );
        if (start == null) yield break;

        sessionId = start.sessionId;
        yield return LoadMetricProblems(METRIC_ORDER[currentMetricIndex]);
    }

    private IEnumerator LoadMetricProblems(string metricType)
    {
        headerPanel.SetMetric(currentMetricIndex + 1, METRIC_ORDER.Length, metricType);

        var url = $"/student/diagnostic/{sessionId}/problems?metricType={metricType}";
        var req = NetworkManager.Instance.Get(url);
        yield return req.SendWebRequest();

        var page = NetworkManager.Instance.ParseResponse<MetricProblemsResponse>(
            req.downloadHandler.text
        );
        problemPanel.LoadProblems(page.problems, OnMetricComplete);
    }

    private void OnMetricComplete(MetricResult result)
    {
        StartCoroutine(SubmitMetric(result));
    }

    private IEnumerator SubmitMetric(MetricResult result)
    {
        var body = JsonUtility.ToJson(result);
        var req = NetworkManager.Instance.Post(
            $"/student/diagnostic/{sessionId}/submit", body
        );
        yield return req.SendWebRequest();

        currentMetricIndex++;
        if (currentMetricIndex >= METRIC_ORDER.Length)
        {
            // 5개 지표 모두 완료 — 결과 패널로 전환
            // 첫 숙제 자동 발행은 BE가 책임짐 (결정 4)
            diagnosticCompletePanel.Show();
        }
        else
        {
            metricCompletePanel.ShowAndContinue(() => {
                StartCoroutine(LoadMetricProblems(METRIC_ORDER[currentMetricIndex]));
            });
        }
    }
}

이 컨트롤러의 가장 큰 단순함은 마지막 지표 제출 후 아무것도 안 한다는 점이다. 첫 숙제 생성을 호출하지도 않고, 다음 씬으로 강제 이동하지도 않는다. diagnosticCompletePanel이 결과를 보여 주고, 회원이 직접 Lobby로 돌아가는 버튼을 누른다. 그 시점에 Lobby가 다시 /student/home을 호출하고, BE가 발행해 둔 첫 숙제가 응답에 실려 온다.

자동 발행 체인 — BE 측 흐름

결정 4의 자동 발행은 BE 도메인 서비스 하나의 끝단에 있다. 흐름을 시퀀스로 펴 둔다.

1. Unity → POST /student/diagnostic/{id}/submit  (5번째 지표)
2. BE: 5지표 결과 합산 → currentLevelId 결정
3. BE: Member.currentLevelId · diagnosticSessionId 업데이트
4. BE: AssignmentService.createInitialAssignment(memberId, { source: 'INITIAL' })
5. BE: Assignment 생성 + 첫 Bundle seed
6. Unity → GET /student/home  (회원이 Lobby 진입)
7. BE: todayAssignment에 4번에서 만든 Assignment 응답
8. Unity: Lobby 카드 활성화 — 회원은 활동 시작 가능

이 체인의 절대 제약은 2~5번이 한 트랜잭션 안에서 끝나야 한다는 점이다. Member.currentLevelId만 업데이트되고 Assignment 생성이 실패하면, 회원은 “배치고사는 끝났는데 숙제가 없는 어중간한 상태”에 빠진다. Prisma 트랜잭션으로 두 작업을 묶었다.

// apps/api/src/application/services/student-diagnostic.application.service.ts — 발췌
async submitDiagnostic(sessionId: number, dto: SubmitDiagnosticDto) {
  // ... 지표 결과 저장 ...

  if (isLastMetric(dto.metricType)) {
    const levelId = this.computeInitialLevel(session.resultMetrics);

    await this.prisma.$transaction(async (tx) => {
      await tx.member.update({
        where: { id: session.memberId },
        data: { currentLevelId: levelId, diagnosticSessionId: session.id },
      });

      // 트랜잭션 안에서 호출 — 실패 시 둘 다 롤백
      await this.assignmentService.createInitialAssignment(session.memberId, {
        classId: session.classId,
        levelId,
        source: 'INITIAL',
        scheduledAt: today(),
      }, tx);
    });
  }
}

createInitialAssignment가 트랜잭션 클라이언트(tx)를 인자로 받는 점이 핵심이다. Prisma의 인터랙티브 트랜잭션은 $transaction 콜백 안에서 받은 tx를 사용해야 같은 트랜잭션 안에 묶인다. 다른 인스턴스의 this.prisma를 호출하면 별도 트랜잭션이 돼서 결정 4의 절대 제약이 깨진다.

prisma.io

Prisma 공식 문서는 인터랙티브 트랜잭션의 콜백 인자를 그대로 전파해야 한다고 명시한다. 도메인 서비스 간 호출 시 tx를 인자로 흘려보내는 패턴이 트랜잭션 범위를 유지하는 표준 방법이다.

scheduledAt 어제 날짜 함정 — 별도 사례

자동 발행 체인을 짜는 도중, 통합 첫날에 만든 테스트 회원 TEST001Assignment.scheduledAt2026-01-20(어제)로 박혀 있는 상태를 발견했다. Lobby의 /student/home은 “오늘 숙제”를 WHERE scheduledAt = today() 조건으로 찾기 때문에, 어제 날짜 숙제는 응답에서 빠져 todayAssignment: null이 됐다.

코드 변경은 0건이었다. 데이터만 손봤다.

UPDATE Assignment SET scheduledAt = '2026-01-21 00:00:00' WHERE id = 53;

이 사례는 결정 5(CMS 운영 게이트)와 같은 종류의 비용이다. 코드는 정상이고 운영 데이터가 어제 시점에서 멈춰 있다. 자동 발행 체인이 들어간 뒤로는 같은 함정이 거의 사라졌지만, 통합 직후 시점에서는 “운영 데이터 = 코드 검증 통과 후의 또 다른 검증 층”이라는 점을 한 번 더 확인하는 사례가 됐다.

콘텐츠 진입 — contentUrl 전체 경로

콘텐츠 선택 → WebView 진입은 통합 작업의 마지막 한 칸이었다. 결정 6의 변경 한 줄이 들어가자 Unity의 ContentPlayerController.cs는 받은 URL을 그대로 webViewPrefab.WebView.LoadUrl()에 넘기는 가장 단순한 형태로 정리됐다.

// Unity/Scenes/ContentPlayer/ContentPlayerController.cs — 발췌
private IEnumerator LoadContent(int bundleId, int candidateId)
{
    var body = JsonUtility.ToJson(new { candidateId });
    var req = NetworkManager.Instance.Post($"/student/bundle/{bundleId}/select-content", body);
    yield return req.SendWebRequest();

    var res = NetworkManager.Instance.ParseResponse<SelectContentResponse>(
        req.downloadHandler.text
    );

    // BE가 전체 URL을 책임짐 — Unity는 prefix 없음 (결정 6)
    webViewPrefab.WebView.LoadUrl(res.contentUrl);

    currentContentId = res.contentSeq.ToString();
    currentLevelId = res.levelId;
}

WebView가 로드된 뒤 loadContent PostMessage를 보내는 흐름은 이전 PostMessage 브릿지 글에서 다룬 봉투 규격을 그대로 따른다. 이번 글의 통합 범위 밖이라 코드 인용은 생략한다.


📊 결과 — 한 빌드 안에서 끝까지 가는 첫 회원 흐름

통합 마무리 시점에 측정한 지표 셋이다.

측정 항목통합 전통합 후
Lobby 진입 후 /student/home 호출 횟수1회1회 (변동 없음)
배치고사 5지표 완료 후 Lobby 재진입 시 todayAssignment null 비율100% (자동 발행 0건)0% (자동 발행 체인)
401 침묵 실패 (토큰 만료 후 화면 흰색) 컨트롤러 수8개 / 8개0개 / 8개 (AuthGuard 적용)
contentUrl WebView 로드 실패 비율100% (상대 경로)0% (전체 URL)
응답 필드명 불일치로 인한 null 표시 사례3건 (student.name, todayAssignment, newRecordCount)0건
DiagnosticVersion 운영 게이트 점검 누락 사례1건 (통합 첫날)0건 (통합 체크리스트 추가)

수치보다 중요한 결과는 한 줄이다. 배치고사를 처음 푼 회원이 Lobby로 돌아왔을 때 다음 단계가 비어 있지 않다는 한 줄이 한 빌드 안에서 보장된다. 같은 사람이 BE·Unity·QA 역할을 갈아끼우는 1인 환경에서, 이 한 줄은 같은 회원을 두 클라이언트가 동시에 본다는 합의가 처음으로 코드에 구현된 시점이다.


🔄 회고 — 같은 결정을 다시 한다면

통합을 마무리한 뒤 같은 작업을 다시 한다면 무엇을 바꿀까. 결정 6건 중 세 곳을 다시 짚는다.

결정 2 — 봉투 해제 우회 검출 장치를 같이 넣었어야 한다

NetworkManager.ParseResponse<T> 한 곳에서만 봉투를 까는 결정은 정책으로는 옳았지만, 우회한 코드가 발견되는 메커니즘이 빠져 있었다. 통합 직후 HomeResponseWrapper라는 임시 모델이 다른 컨트롤러에서 JsonUtility를 직접 호출하며 등장했다. 정책 위반을 사람의 눈에만 의존한 셈이다.

다시 한다면 다음 둘 중 하나를 같이 넣는다.

  • Roslyn 분석기JsonUtility.FromJson을 직접 호출하는 코드에 경고. NetworkManager.ParseResponse를 거쳐야 함을 컴파일러가 가르친다
  • 단위 테스트 — 모든 컨트롤러의 API 호출이 NetworkManager 진입점을 거치는지 확인하는 정적 스캔 테스트

정책만 정해 두면 통합 단계의 비용으로 청구된다. 검출 장치가 같이 있어야 정책이 작동한다.

결정 4 — 자동 발행 실패 정책을 명시했어야 한다

자동 첫 숙제 발행을 BE 트랜잭션 안에 넣었지만, 실패 시 정책이 한 줄 빠져 있었다. 트랜잭션이 롤백되면 배치고사 제출 자체가 실패한다. 회원에게는 “5번째 지표 제출 실패”로 보인다. 실제 원인이 첫 숙제 생성 실패라는 단서는 BE 로그에만 남는다.

다시 한다면 두 흐름을 다른 트랜잭션으로 분리하고, 자동 발행 실패는 별도 큐로 보낸다.

// 개선안 — 트랜잭션 분리
await this.prisma.$transaction(async (tx) => {
  await tx.member.update({ ... });
});

try {
  await this.assignmentService.createInitialAssignment(...);
} catch (err) {
  // 별도 큐로 — 자동 발행 실패가 배치고사 제출을 막지는 않음
  await this.retryQueue.enqueue('createInitialAssignment', { memberId });
  this.logger.error('[Diagnostic] auto-assign failed, queued for retry', err);
}

이 변경은 결정 4의 트레이드오프(부수효과 1건)를 더 명시적으로 만든다. 자동 발행은 best-effort라는 점을 코드와 로그가 동시에 말해준다.

결정 6 — 환경별 prefix 분기가 BE에 잔류한다는 점이 진짜 비용이다

contentUrl을 BE가 책임진다는 결정은 클라이언트 통합 속도를 살렸지만, 환경 분기 코드가 BE에 영구히 잔류한다. dev/staging/prod의 CONTENT_BASE_URL 분기, 모바일 클라이언트가 들어왔을 때 추가될 분기, 사내 테스트 환경 분기까지 모두 BE 환경변수와 응답 변환 코드에 쌓인다.

규모가 커지면 콘텐츠 도메인 자체를 환경 무관 URL(예: https://content.example.com/...)로 고정하고, 환경별 라우팅은 인프라 층(CDN, reverse proxy)에서 처리하는 게 옳다. BE 코드에 환경 분기가 누적되는 패턴은 이전 편의 BLOCKED 패턴과 같은 종류의 부채다.


📋 정리 — 핵심 요약

결정 6건 한 표 요약

#결정핵심 채택 사유같이 청구된 비용
1응답 필드명 = BE 표기 그대로BE 명세 단일 출처Unity 코드의 영문 비통일
2봉투 해제는 NetworkManager 한 곳봉투 규격과 데이터 모델 독립우회 검출 장치 누락
3인증 체크는 AuthGuard 유틸리티401 침묵 실패 0건정책 집중에 의한 단일 장애점
4첫 숙제 자동 발행은 BE 트랜잭션회원이 어중간한 상태 안 봄도메인 서비스 부수효과 1건
5CMS 운영 게이트는 DB 컬럼운영 시점 토글 가능통합 첫 30분 운영 데이터 점검
6contentUrl은 BE가 전체 URLWebView 컨텍스트 무관환경 분기 BE 잔류

안티패턴과 권장 패턴

측면안티패턴권장
응답 모델BE-클라이언트 표기 자유, 매핑 레이어 다층한쪽 표기 통일 + 봉투 해제 단일 진입점
인증컨트롤러마다 인증 체크 코드 복사AuthGuard 유틸리티 + 첫 줄 호출
첫 진입 데이터클라이언트가 “없으면 만들기” 호출BE 도메인 트랜잭션 안에서 자동 발행
CMS 게이트코드에 활성 분기 박기DB 컬럼 + 통합 직전 점검 체크리스트
URL 형식클라이언트가 prefix 책임BE가 전체 URL + 인프라 층 라우팅 (장기)
봉투 해제 정책가이드 문서만 둠Roslyn 분석기 또는 정적 스캔 테스트

통합 직전 점검 체크리스트 (8건)

  1. CMS 운영 게이트 — DiagnosticVersion.status === 'ACTIVE' 1건 이상
  2. 시드 회원의 Assignment.scheduledAt — 오늘 날짜로 갱신
  3. CONTENT_BASE_URL 환경변수 — 클라이언트 환경별 prefix 0건
  4. JWT_SECRET 일치 — BE와 Unity TokenStore의 검증 시드
  5. NetworkManager.ParseResponse<T> 우회 코드 — JsonUtility.FromJson 직접 호출 0건
  6. AuthGuard.RequireAuth 미적용 컨트롤러 — 0개
  7. HomeResponse 모델 필드 — BE 응답 키와 1:1 매칭
  8. Prisma $transaction 콜백 안 tx 전파 — 자동 발행 체인의 트랜잭션 범위 유지

숫자로 보는 통합

  • 통합 범위: 2 씬 (Lobby + Diagnostic) + 인증 유틸리티 + 자동 발행 체인 + WebView 진입
  • API 엔드포인트: 4건 (/student/home, /student/diagnostic/start, /diagnostic/:id/problems, /diagnostic/:id/submit)
  • Unity 컨트롤러: 3건 (LobbyController, DiagnosticController, ContentPlayerController)
  • BE 자동 발행 체인 진입점: 1건 (student-diagnostic.application.service.ts 끝단)
  • 운영 데이터 SQL 변경: 2건 (DiagnosticVersion.status, Assignment.scheduledAt)
  • 응답 필드명 불일치 발견: 3건 — 모두 BE 표기로 통일
  • CMS 운영 게이트 함정: 1건 (통합 첫날, 이후 체크리스트로 차단)
  • contentUrl 상대 경로 → 전체 URL 변경: 1건 (BE 도메인 서비스)
  • AuthGuard 적용 컨트롤러: 8건 — 401 침묵 실패 0건

다음 편에서는 배치고사 MVP 전환 작업의 후속(레거시 571줄 삭제 + 단위 테스트 38건)을 다룬다. 이번 글의 자동 발행 체인이 새 배치고사 스키마 위에서 어떻게 작동하는지를 짚는 회고다.

📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날