QR 배치고사 + Firebase Hosting 멀티 사이트 배포

Unity 안에 카메라 입력 흐름을 끌어들이는 대신, QR 코드 한 장으로 모바일 외부 웹앱을 띄우는 결정. 인증 없는 Public Submit API, PROCESSING_SPEED 서버 자동 계산, sessionId 폴링 흐름, 그리고 같은 결정 트리의 연장선에서 Level-Test SPA 와 3개 운영 포털을 Firebase Hosting 멀티사이트로 분리 배포한 구현기.


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

  • Unity 안에서 카메라·터치 UI 를 직접 구현하지 않고 QR 한 장으로 모바일 외부 웹앱을 분리 — 데스크톱·콘솔용 런타임의 책임 범위를 좁히는 결정
  • POST /api/v1/diagnostic/session/:sessionId/submit 인증 게이트 0sessionId 유효성과 1시간 만료만 검증하는 Public 컨트롤러 분리
  • PROCESSING_SPEED 지표는 서버가 평균 응답 시간으로 자동 계산 — 클라이언트 제출 지표는 4건으로 축소, 11단계 점수표는 서버 단일 함수
  • needsDiagnostic 필드 폐기/student/diagnostic/status 단일 엔드포인트로 SSoT 정착
  • Firebase Hosting 멀티사이트 3 + 1 — 운영 포털 3건과 외부 뷰어 1건 자체 도메인 분리firebase.json 한 파일에서 사이트별 빌드 경로·캐시 정책 분리
  • Vite base path 와 Linux 케이스 센시티브 이미지 경로 — CI/CD 빌드 실패 한 차례 + 런타임 누락 한 차례를 이번 머지 안에 정정

🎯 배경 — 데스크톱 런타임이 카메라를 들고 있을 이유가 없다

직전 머지로 외부 뷰어 응답 누적 RTT 가 1초 SLO 코앞까지 내려간 직후, 사용자 검토에서 결정 한 줄이 들어왔다.

“엔드유저 첫 진입의 배치고사를 데스크톱 런타임 안에서 끝내지 말 것. 부모 휴대폰으로 빠지는 게 맞다.”

배치고사는 회원 가입 직후 첫 진입의 지표 평가 흐름이다. 데스크톱 런타임(Unity) 이 카메라·터치 UI 를 직접 잡으려면 권한 모달·세로 화면 강제·iOS Safari 자동재생 정책까지 모두 안에서 처리해야 한다. 데스크톱 런타임의 책임 범위는 이미 활동 흐름과 콘텐츠 브릿지로 충분히 무거운 상태였고, 카메라 흐름을 밖으로 빼는 결정이 더 싸다는 판단이 들어왔다.

같은 dev 머지 흐름 안에 결정 두 개가 차례로 들어갔다. 첫째, QR 한 장을 데스크톱 런타임이 띄우고, 모바일 외부 웹앱이 흐름을 받는다. 둘째, 외부 웹앱은 별도 SPA 로 분리해 자체 도메인으로 배포한다. 두 결정 모두 책임 범위를 런타임 밖으로 밀어내는 같은 방향의 결정이다. 본 머지의 BE 측 변경은 student-diagnostic.application.service.ts 함수 4건 추가 + public-diagnostic.controller.ts 신설 + Prisma 스키마 1 모델 정정이고, FE 측 변경은 apps/contents/level-test SPA 의 BE 연동 + Vite 배포 설정 + 이미지 경로 케이스 정정 + Firebase Hosting 멀티사이트 4 건 분리 배포다.

📌 핵심: 데스크톱 런타임 안에 카메라·터치 UI 같은 외부 단말 흐름을 끌어들이면 런타임의 책임 범위가 부풀어 오른다. 본 머지의 가장 큰 결정은 책임 범위를 외부 모바일 SPA 로 분리한 한 줄이고, 그 결정을 인증 없는 Public API + sessionId 한 줄로 받아내는 가장 가벼운 형태의 데이터 흐름을 설계한 점이다.


⚖️ 설계 결정 6건 — 무엇을 분리하고 무엇을 자동화했나

본 머지의 결정 6건을 트레이드오프 비교표로 정리한다.

#결정채택 사유트레이드오프
1QR 코드 분리 — 데스크톱 런타임은 QR 이미지만 표시, 모바일 외부 웹앱이 흐름 수신카메라·세로 화면 강제·iOS Safari 자동재생 정책 같은 모바일 전용 처리를 데스크톱 런타임 밖으로 분리 / 모바일 단말의 폰트·터치·해상도 차이를 외부 SPA 가 직접 흡수데스크톱 런타임과 외부 SPA 사이의 동기화는 세션 폴링으로 처리 — 인터벌 2 초 폴링이 본 머지의 단일 동기화 채널, 향후 SSE 또는 WebSocket 으로 갈아탈 여지 남김
2POST /api/v1/diagnostic/session/:sessionId/submit 인증 게이트 0sessionId 유효성 + 1시간 만료만 검증외부 SPA 가 카메라 권한·QR 디코드 직후 바로 첫 응답을 받아야 하는데, 로그인 흐름 한 번 더는 진입 비용을 무겁게 함 / sessionId 자체가 발급 시점부터 1시간 비밀 토큰 역할인증 게이트 없는 엔드포인트가 늘면 보안 정책 표면도 늘어남 — 별도 컨트롤러(PublicDiagnosticController) 로 격리해 위험 표면을 한 곳에 모음
3PROCESSING_SPEED 지표 서버 자동 계산 — 클라이언트 제출 지표는 4건으로 축소처리속도 지표는 콘텐츠 정답률과 다른 응답 시간 평균 자체가 본질 / 외부 SPA 가 따로 계산해 보내면 지표 5건의 제출 순서와 누락 처리가 복잡해짐점수 변환 함수가 서버 단일 함수로 모이지만, 변환 정책(11 단계 1초~10초 구간) 갱신이 백엔드 머지 대상이 됨 — 정책 변경이 흔하지 않다는 전제
4needsDiagnostic 필드 폐기/student/diagnostic/status 단일 엔드포인트로 SSoTLogin/Home 응답에 needsDiagnostic부산물 표시 필드로 들어가면 현재 진행 중·완료·만료 세 상태를 외부 SPA 가 다시 조회해야 함 / 진행 상태는 세션이 진실이라는 원칙을 직접 표현데스크톱 런타임 측 호출 한 번 추가 — Login → status 한 단계, 다만 폴링 채널과 동일한 엔드포인트라 추가 비용 미미
5Firebase Hosting 멀티사이트 4건 분리 — 운영 포털 3건(<project>-admin / <project>-tenant / <project>-viewer) + Level-Test SPA 1건사이트별 프로덕션 빌드 산출물 분리 + 캐시·헤더 정책 분리 + 자체 도메인 분리 / 운영 포털 3건이 서로 빌드 산출물을 침범하지 않음각 SPA 당 .env.production 1건 + firebase.jsonhosting 배열 1건 추가 — 배포 명령--only hosting:<id> 단위로 늘어남, 다만 운영 포털 3건은 독립 변경 주기가 명확해 트레이드오프 수용
6Vite base path + removeConsole + Linux 케이스 센시티브 정정 — 이번 머지 안에 빌드·런타임 두 함정 정정같은 외부 SPA 가 자체 도메인 루트가 아닌 서브 경로 마운트로 들어갈 가능성 대비 base path 명시 / 프로덕션 빌드 console 호출 일괄 제거로 보안·성능 양쪽 정리Linux 케이스 센시티브 이미지 경로 한 줄 정정이 CI/CD 1차 빌드 실패를 일으킴 — macOS 로컬·Cloudflare CDN 캐시·Linux 빌드 컨테이너 사이의 대소문자 차이최초 머지 한 번은 거의 무조건 잡힌다는 교훈 정착

결정 1·5 가 본 머지의 책임 범위 재배치 핵심이다. 외부 단말 흐름외부 도메인 분리가 같은 방향의 결정이고, 둘 다 데스크톱 런타임의 책임을 안쪽으로 좁히는 결정이다. 결정 2·3·4 는 외부 SPA 가 가장 가벼운 데이터 흐름을 받도록 BE 측 인터페이스를 다듬은 것이고, 결정 6 은 배포 함정을 이번 머지 안에 잡은 부속 정정이다.

직접 정리한 QR 배치고사 + Firebase Hosting 멀티 사이트 배포 — Unity QR 분리 / Public API sessionId 게이트 / PROCESSING_SPEED 서버 자동 계산 / Firebase Hosting 4 사이트 분리 도식
직접 정리한 QR 배치고사 + Firebase Hosting 멀티 사이트 배포 — Unity QR 분리 / Public API sessionId 게이트 / PROCESSING_SPEED 서버 자동 계산 / Firebase Hosting 4 사이트 분리 도식

⚠️ 주의: 데스크톱 런타임 안에 모바일 단말 흐름을 끌어들이면 런타임 자체의 책임 표면이 부풀어 오른다. 본 머지의 결정 1 이 채택된 가장 큰 이유는 카메라 권한 모달·세로 강제·iOS 자동재생 정책 같은 모바일 전용 처리를 외부 SPA 가 흡수하면 데스크톱 런타임 코드 어디에도 이 흐름이 안 들어온다는 점이다. 외부 단말과 데스크톱 런타임이 세션 폴링 단일 채널만 공유하면, 두 쪽 모두 각자의 책임만 단순화된다.

firebase.google.com

🛠️ 구현 1 — Public Submit API: 인증 게이트 0, 만료만 검증

PublicDiagnosticController 는 단일 엔드포인트만 노출한다. 운영자 JWT 가드가 붙는 운영자용 컨트롤러와 완전히 분리된 위치다.

// apps/api/src/application/controllers/public-diagnostic.controller.ts
// commit e08620c

import {
  Controller, Post, Body, Param, HttpCode, HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam } from '@nestjs/swagger';
import { StudentDiagnosticApplicationService } from '../services/student-diagnostic.application.service';
import {
  SubmitMetricDto, SubmitMetricResponseDto,
} from '../dtos/student-diagnostic.dto';

@ApiTags('diagnostic-public')
@Controller('diagnostic/session')
export class PublicDiagnosticController {
  constructor(
    private readonly diagnosticService: StudentDiagnosticApplicationService,
  ) {}

  /**
   * QR 배치고사 지표 제출 (Public)
   * POST /api/v1/diagnostic/session/:sessionId/submit
   *
   * - 인증 없음
   * - sessionId 유효성만 검증
   * - 1시간 만료 체크
   */
  @Post(':sessionId/submit')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({
    summary: 'QR 배치고사 지표 제출 (Public)',
    description:
      '외부 웹앱에서 QR 코드로 접근. 인증 없이 sessionId만으로 지표 제출. ' +
      '세션 유효성, 상태, 1시간 만료 여부를 검증한다.',
  })
  @ApiParam({ name: 'sessionId', example: '12345' })
  async submitMetric(
    @Param('sessionId') sessionId: string,
    @Body() dto: SubmitMetricDto,
  ): Promise<SubmitMetricResponseDto> {
    return this.diagnosticService.submitMetricPublic(sessionId, dto);
  }
}

JwtAuthGuard 가 붙지 않은 컨트롤러는 본 머지 기준 두 곳뿐이다 — ReportPublicController(직전 시리즈에서 도입) 와 PublicDiagnosticController. 두 컨트롤러 모두 발급 시점부터 단명 토큰을 비밀로 가정하는 흐름이라, 공개 표면이 컨트롤러 단위로 명확히 분리됐다. NestJS 공식 문서는 가드 적용을 컨트롤러 스코프 또는 글로벌 로 선택하라고 명시하는데, 본 머지는 컨트롤러 스코프 분리를 표준으로 채택했다.

서비스 측 submitMetricPublic 는 세션 존재·소유 검증을 제외하고 만료·상태만 본다. 운영자용 submitMetric 와 같은 BE 로직(점수 계산, 누적 저장) 을 재사용하면서, 입력 가드만 가벼운 형태다.

// apps/api/src/application/services/student-diagnostic.application.service.ts
// 인용 (commit e08620c)

async submitMetricPublic(
  sessionId: string,
  dto: SubmitMetricDto,
): Promise<SubmitMetricResponseDto> {
  const session = await this.prisma.diagnosticSession.findUnique({
    where: { id: BigInt(sessionId) },
  });

  if (!session) {
    throw new NotFoundException({
      code: 'SESSION_NOT_FOUND',
      message: '세션을 찾을 수 없습니다.',
    });
  }

  if (session.status === 'COMPLETED') {
    throw new BadRequestException({
      code: 'SESSION_ALREADY_COMPLETED',
      message: '이미 완료된 세션입니다.',
    });
  }

  // 1시간 만료 체크
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
  if (session.startedAt && session.startedAt < oneHourAgo) {
    throw new BadRequestException({
      code: 'SESSION_EXPIRED',
      message: '세션이 만료됐습니다. QR 을 다시 받으십시오.',
    });
  }

  // 운영자용 로직 재사용 (점수 계산 + 누적 저장)
  return this.submitMetricInternal(session, dto);
}

검증 가드는 세 줄 — 존재·완료·만료. 소유는 검증하지 않는다. 외부 SPA 에 진입하는 사용자는 현재 사용자 = 세션 소유자 라는 암묵적 가정 위에 있고, 그 가정의 근거는 QR 자체가 운영 단말에서 발급된 1시간 비밀이라는 점이다.

🔍 단서: Public 엔드포인트 분리의 실제 이득은 보안 표면을 좁히는 게 아니라 코드 리뷰 시 가드 결정을 더 단순하게 만드는 것이다. 컨트롤러 한 곳에 가드가 유무만 명확하면, 공개·비공개 정책컨트롤러 파일 첫 줄로 결정할 수 있다. 라우트마다 가드 데코레이터를 추가·제거하는 변경이 머지 단위로 쌓이면, 어디까지 공개됐는지가 코드 리뷰에서 잘 안 보인다.


🛠️ 구현 2 — QR 세션 생성 + qrcode 라이브러리 + 폴링 흐름

데스크톱 런타임 측 첫 진입에서 호출하는 세션 생성 엔드포인트는 운영자 JWT 로 가드된다. 외부 SPA 가 진입할 비밀 토큰 자체를 발급하는 위치이기 때문에, 발급 측 인증은 강하게 유지한다.

// apps/api/src/application/services/student-diagnostic.application.service.ts
// 인용 (commit ee85e73)

import * as QRCode from 'qrcode';

async createQrSession(studentId: string): Promise<QrSessionResponseDto> {
  // 1. 이미 완료된 세션 확인
  const completedSession = await this.prisma.diagnosticSession.findFirst({
    where: { studentId, status: 'COMPLETED' },
  });

  if (completedSession) {
    throw new ConflictException({
      code: 'DIAGNOSTIC_ALREADY_COMPLETED',
      message: '이미 배치고사를 완료했습니다.',
    });
  }

  // 2. 기존 진행 중 세션 ABANDONED 처리
  await this.prisma.diagnosticSession.updateMany({
    where: { studentId, status: 'IN_PROGRESS' },
    data: { status: 'ABANDONED' },
  });

  // 3. 활성 DiagnosticVersion 조회
  const version = await this.prisma.diagnosticVersion.findFirst({
    where: { isActive: true, status: 'ACTIVE' },
  });

  // 4. 새 세션 생성
  const session = await this.prisma.diagnosticSession.create({
    data: {
      studentId,
      diagnosticVersionId: version.id,
      status: 'IN_PROGRESS',
      startedAt: new Date(),
      resultMetrics: {},
    },
  });

  // 5. QR URL 생성
  const baseUrl = process.env.DIAGNOSTIC_WEB_URL || 'https://example.com';
  const qrCodeUrl = `${baseUrl}/app/contents/level-test?sessionId=${session.id}`;

  // 6. QR 코드 Base64 생성 (256x256)
  const qrCodeBase64 = await QRCode.toDataURL(qrCodeUrl, {
    width: 256,
    margin: 1,
  });

  // 7. 만료 시간 (1시간)
  const expiresAt = new Date(Date.now() + 60 * 60 * 1000);

  return {
    sessionId: session.id.toString(),
    qrCodeUrl,
    qrCodeBase64,
    expiresAt: expiresAt.toISOString(),
  };
}

세션 생성 흐름은 기존 진행 세션 ABANDONED 처리 → 새 세션 INSERT → QR URL 생성 → Base64 인코딩 다섯 단계다. QR 라이브러리는 qrcode v1.5.4 를 의존성에 추가했고, Base64 Data URL 형태로 응답에 함께 실어 보낸다. 클라이언트는 별도 이미지 요청 없이 응답 그대로 <img src="data:image/png;base64,..."> 에 바인딩한다.

QR 코드 이미지 크기 100 → 256px 변경(31feab6) 이 이번 머지 안에 따라붙었다. 운영 단말 화면에서 카메라 인식 안정도가 100px 에서 한계 임계값에 가깝다는 검수 신호를 받고 256px 로 키운 정정이다.

상태 조회 엔드포인트는 두 가지 호출 패턴을 받는다. 세션 ID 지정 패턴은 데스크톱 런타임이 발급 직후 폴링에서 쓰고, 세션 ID 미지정 패턴은 진입 직후 마지막 세션 조회에 쓴다.

// apps/api/src/application/services/student-diagnostic.application.service.ts
// 인용 (commit ee85e73)

async getStatus(
  studentId: string,
  sessionId?: string,
): Promise<DiagnosticStatusResponseDto> {
  let session;

  if (sessionId) {
    // sessionId 지정: 해당 세션 + 본인 소유 검증 (데스크톱 런타임 폴링용)
    session = await this.prisma.diagnosticSession.findFirst({
      where: { id: BigInt(sessionId), studentId },
    });

    if (!session) {
      throw new NotFoundException({
        code: 'SESSION_NOT_FOUND',
        message: '세션을 찾을 수 없습니다.',
      });
    }
  } else {
    // 기존: 최근 세션 조회
    session = await this.prisma.diagnosticSession.findFirst({
      where: { studentId },
      orderBy: { createdAt: 'desc' },
    });
  }

  // ... 진행 상태 매핑 (NOT_STARTED / IN_PROGRESS / COMPLETED)
}

데스크톱 런타임 측 DiagnosticQRController 가 2 초 인터벌로 GET /student/diagnostic/status?sessionId=<id> 를 폴링하다가 COMPLETED 응답을 받으면 다음 화면으로 전환한다. 폴링 한 채널만 동기화 경로이고, BE 측에는 별도 WebSocket / SSE 채널 없음이라 운영 인프라가 RPS 4 미만의 가벼운 부하만 받는다.

DiagnosticAttempt 모델은 본 머지 직전 BE 검수에서 FK 위반이 한 차례 잡혀 이번 머지 안에 정정됐다. 외부 SPA 가 보내는 problemId콘텐츠 내부 1~10 순번이라 DB FK 인 Problem.id값 도메인이 어긋난 상태였다.

// apps/api/prisma/schema.prisma 변경 (commit 8ff7df5)

model DiagnosticAttempt {
  id                  String     @id @default(uuid())
  diagnosticSessionId BigInt
  metricCode          MetricCode

-  problemId           BigInt
+  problemOrder        Int        // 콘텐츠 내 문제 순서 (1~N), FK 아님

  isCorrect           Boolean
  // ...

  diagnosticSession   DiagnosticSession @relation(fields: [diagnosticSessionId], references: [id], onDelete: Cascade)
-  problem             Problem           @relation(fields: [problemId], references: [id], onDelete: Cascade)

  @@index([diagnosticSessionId])
-  @@index([problemId])
  @@index([metricCode])
}

FK 를 제거하고 순번 컬럼으로 갈아탔다. 외부 SPA 가 내부 콘텐츠 ID 체계와 무관한 1~N 순번만 알면 되는 구조이기 때문에, 콘텐츠 ID 와의 매핑분석 시점에서 콘텐츠 메타로 추가 조회한다.

📊 데이터: 본 머지의 BE 측 변경 라인은 컨트롤러 1건 신설 +69 / 서비스 +316 / Prisma 정정 -3 / Debug 모듈 +79 — 총 +523 / -3. 외부 단말 흐름을 받기 위한 추가 코드가 523 줄이고, 정정된 FK 한 줄내부 도메인 체계와 외부 단말 도메인 체계의 매핑이 다르다는 한 신호가 이번 머지 안에 잡힌 흔적이다.


🛠️ 구현 3 — PROCESSING_SPEED 서버 자동 계산 + needsDiagnostic 폐기

지표 5건 중 PROCESSING_SPEED콘텐츠 자체의 정답률과 다르게 응답 시간 평균이 본질이다. 외부 SPA 가 별도로 평균 시간을 계산해 보내면, 지표 5건의 제출 순서·누락·재시도 흐름이 복잡해진다. 본 머지에서 클라이언트 제출 지표를 4건으로 축소하고, 서버가 4 지표 응답 시간 평균으로 자동 계산하는 흐름으로 갈아탔다.

// apps/api/src/application/services/student-diagnostic.application.service.ts
// 인용 (commit c70e993)

/**
 * 지표 순서 및 한글명 (5개 전체, 표시용)
 */
private readonly METRIC_ORDER: { code: MetricCode; displayName: string }[] = [
  { code: 'MATRIX_REASONING', displayName: '행렬추리' },
  { code: 'QUANTITATIVE_REASONING', displayName: '수량추리' },
  { code: 'NUMERICAL_REASONING', displayName: '수개념' },
  { code: 'WORKING_MEMORY', displayName: '작업기억' },
  { code: 'PROCESSING_SPEED', displayName: '처리속도' },
];

/**
 * 클라이언트가 제출하는 지표 (4개)
 * PROCESSING_SPEED 는 서버 자동 계산
 */
private readonly SUBMITTABLE_METRICS: MetricCode[] = [
  'MATRIX_REASONING',
  'QUANTITATIVE_REASONING',
  'NUMERICAL_REASONING',
  'WORKING_MEMORY',
];

/**
 * 평균 응답 시간 → PROCESSING_SPEED 점수 변환 (11 단계)
 */
private calculateProcessingSpeedScore(avgResponseTimeMs: number): number {
  const avgTimeSec = (avgResponseTimeMs || 0) / 1000;

  if (avgTimeSec <= 1) return 100;
  if (avgTimeSec <= 2) return 90;
  if (avgTimeSec <= 3) return 80;
  if (avgTimeSec <= 4) return 70;
  if (avgTimeSec <= 5) return 60;
  if (avgTimeSec <= 6) return 50;
  if (avgTimeSec <= 7) return 40;
  if (avgTimeSec <= 8) return 30;
  if (avgTimeSec <= 9) return 20;
  if (avgTimeSec <= 10) return 10;
  return 0;
}

점수 변환 함수가 서버 단일 함수로 모이면 정책 갱신(예: 11 단계 → 5 단계, 1초 구간 → 0.5초 구간) 이 백엔드 머지 대상이 된다. 이 정책은 자주 갱신될 일이 없다는 가정 위에서 결정 사유가 성립한다.

getStatus 응답의 total: 5 → 4 변경과 getProblemsPROCESSING_SPEED 요청 차단이 함께 들어갔다. 클라이언트가 문제 데이터를 받지 않는 지표는 처음부터 요청 자체를 거절하는 흐름이 추가됐다. 또 순서 유연화를 위해 remainingMetrics 필드를 응답에 추가해, 외부 SPA 가 남은 지표 목록을 받아 임의 순서로 진행할 수 있게 됐다.

needsDiagnostic 필드 폐기는 별도 결정이다. Login / Home 응답에 부산물 표시 필드가 들어가 있으면, 외부 SPA 는 진행 상태가 바뀐 직후에 다시 Login / Home 호출을 반복해야 한다. 본 머지에서 /student/diagnostic/status 단일 엔드포인트로 진행 상태 SSoT 화를 명시했고, 두 응답에서 해당 필드를 제거했다. 데스크톱 런타임 측 호출 한 번이 추가됐지만, 폴링 채널과 동일한 엔드포인트추가 비용 미미다.

💡 인사이트: 진행 상태 같은 시간이 지나면서 바뀌는 사실을 응답 부산물 필드로 노출하면, 응답을 받은 시점부터 그 필드의 신선도를 클라이언트가 관리해야 한다. 상태 조회 단일 엔드포인트로 SSoT 를 명시하는 결정은 부산물 필드의 신선도 책임을 서버로 모으는 결정이다. 동일 원칙이 직전 시리즈의 외부 뷰어 토큰 발급 시점 즉시 만료 결정에도 적용됐다 — 시간이 지나면 바뀌는 사실그 사실의 단일 함수에서만 결정한다.


🛠️ 구현 4 — Firebase Hosting 멀티사이트 + Vite base + Linux 케이스 센시티브

외부 SPA(apps/contents/level-test) 와 운영 포털 3건이 각자 도메인 분리가 필요했다. Firebase Hosting 의 멀티사이트 기능이 단일 프로젝트 안에서 사이트별 빌드 산출물 분리를 지원한다. Firebase 공식 문서사이트 ID 분리 → firebase.jsonhosting 배열 → 사이트별 빌드 경로 분리 흐름을 표준으로 명시한다.

본 머지에서 사이트 4건이 들어갔다.

사이트 ID도메인용도
<project><project>.web.app기본 (여분)
<project>-admin<project>-admin.web.app운영자 페이지(관리자)
<project>-tenant<project>-tenant.web.app고객사 운영자 페이지
<project>-viewer<project>-viewer.web.app외부 뷰어 리포트

<project> 는 GCP 프로젝트 ID 의 가명이다. 운영 포털 3건 + 기본 1건 — Level-Test SPA 는 본 머지에서 별도 자체 호스팅 경로(/app/contents/level-test) 로 배포됐지만, 같은 결정 트리의 연장선상에서 후속 머지 한 차례 안에 Firebase Hosting 멀티사이트로 합류하는 길이 열려 있다.

# Firebase 멀티사이트 배포 흐름 (인용 — 운영 명령)

# 1. Firebase CLI 설치 + 로그인
npm install -g firebase-tools
firebase login

# 2. firebase.json 멀티사이트 설정 (사이트별 빌드 경로 분리)
# 3. .firebaserc 사이트 ID → 빌드 디렉토리 매핑
# 4. 각 SPA 프로덕션 빌드 + 배포
firebase deploy --only hosting:<project>-admin
firebase deploy --only hosting:<project>-tenant
firebase deploy --only hosting:<project>-viewer

firebase.jsonhosting 항목은 배열이어야 한다. 사이트 ID 가 4건이면 배열 원소 4건. 사이트별 빌드 경로 + 재작성 규칙 + 캐시 정책 헤더가 각자 분리되며, 공통 헤더를 따로 빼고 싶다면 사이트별 항목에 같은 정책을 반복 명시하는 게 표준이다.

Level-Test SPA 의 Vite 배포 설정에는 두 가지 정정이 들어갔다. 서브 경로 마운트 대비 base path 명시와, 프로덕션 빌드 console 호출 일괄 제거 다.

// vite.config.ts (Level-Test) — 인용 (commit 666c1e9)

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  base: '/app/contents/level-test/',  // 서브 경로 마운트 대비
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,  // 프로덕션 빌드 console 호출 일괄 제거
      },
    },
  },
});

Linux 케이스 센시티브 이미지 경로 함정은 macOS 로컬Linux 빌드 컨테이너파일 시스템 차이에서 출발한다. macOS HFS+/APFS 는 기본 대소문자 비구별인 반면, Linux ext4 는 대소문자 구별이다. circle_Red.png<img src="circle_red.png"> 로 참조하면, macOS 에서는 정상 로드되지만 Linux 빌드 산출물에서는 런타임 404 가 된다.

# 정정 (commit f4f1a27)
# circle_Red.png → circle_red.png 로 파일명 자체를 소문자화

f7edcfb 의 GitLab CI/CD 파이프라인 추가가 Linux 빌드 컨테이너에서의 1차 빌드 실패를 처음 노출시켰고, 정정 한 줄(f4f1a27) 이 이번 머지 안에 따라붙었다. 같은 함정은 Cloudflare CDN 캐시S3 호스팅 환경에서도 동일 패턴으로 나타나며, 최초 머지 한 번은 거의 무조건 잡힌다는 점이 본 머지에서 정착된 교훈이다.

Level-Test SPA 측 결과 처리 버그도 이번 머지 안에 두 건 정정됐다. 오답을 정답으로 기록하던 버그(d4b5eb7) 는 workingMemory / processingSpeed 콘텐츠에서 stopTimer(true)하드코딩된 위치를 stopTimer(isCorrect) 또는 stopTimer(false) 로 분기시킨 정정이고, Complete 페이지 PixiJS null 접근(00670b1) 은 app.destroy() 직후 app.stage / app.renderernull 인 상태에서 cleanup 콜백이 한 번 더 호출되는 흐름을 null 체크 두 줄로 막은 정정이다.

// PixiJS 컴포넌트 언마운트 (인용 — commit 00670b1)
useEffect(() => {
  // ... PixiJS app 초기화

  return () => {
    if (app?.stage) {     // null 체크 추가
      app.stage.removeChildren();
    }
    if (app?.renderer) {  // null 체크 추가
      app.destroy(true);
    }
  };
}, []);

세 정정 모두 외부 단말 흐름의 실전 진입에서 잡힌 함정이다. 카메라 권한 → QR 디코드 → Vite 빌드 산출물 로드 → PixiJS 콘텐츠 마운트 흐름은 데스크톱 런타임 안에서는 한 번도 노출되지 않는 경로이고, 외부 모바일 단말에서 처음 실행될 때 한꺼번에 잡힌 신호다.


📊 결과 — 이번 머지의 변경 라인과 사이트 분리

본 머지의 BE / FE / 인프라 변경을 정리한다.

영역항목변경
BEPublicDiagnosticController 신설+69 줄 (1 파일)
BEstudent-diagnostic.application.service.ts 함수 4건 추가 (createQrSession / getStatus(sessionId?) / submitMetricPublic / calculateProcessingSpeedScore)+316 줄
BEDiagnosticAttempt FK 제거 (problemIdproblemOrder)-3 줄 + 마이그레이션 SQL
BEDebug 컨트롤러 + 서비스 (create-test-session / reset)+28 / +79 줄
BE의존성 qrcode@^1.5.4 추가package.json
FELevel-Test SPA BE API 연동 리팩토링4 파일 / 응답 래퍼 { success, data } 해체
FEVite base path + terserOptions.drop_console1 파일
FELinux 케이스 센시티브 정정 (circle_Red.pngcircle_red.png)1 파일 + 참조 갱신
FEPixiJS null 체크 + stopTimer 분기 정정2 파일
인프라Firebase 멀티사이트 4건 생성 (<project> / <project>-admin / <project>-tenant / <project>-viewer)GCP 콘솔
인프라.env.production 3건 + firebase.json 멀티사이트 + .firebaserc 프로젝트 연결운영 포털 3건
인프라Cloud Run BE asia-east1 1 인스턴스 + Artifact Registry + Secret Manager별도 머지(후속 인프라)

배포 URL 4건 — 운영 포털 3건 + 외부 뷰어 1건 — 이 자체 도메인으로 분리됐다. BE API 는 https://academy-api-518812751494.asia-east1.run.app 로 Cloud Run 단일 인스턴스에 묶였고, 외부 뷰어·운영 포털 측 .env.productionVITE_API_BASE_URL 이 모두 동일 BE URL 을 가리키도록 통일됐다.

폴링 채널 측정값은 2 초 인터벌 × 평균 세션 시간 12 분 = 약 360 회 폴링, 제출 4 회 × 평균 200 ms. RPS 측정은 운영 단말 동시 사용자 10 명 기준 순간 RPS 약 5, 평균 RPS 약 0.4부하 자체가 가벼운 수준. BE 측 별도 캐시·메모이제이션 없이 DB 조회 한 번 + 응답 매핑만 한다.

Linux 케이스 센시티브 함정은 1차 CI 빌드 실패 → 정정 머지 1건 흐름으로 끝났다. macOS 로컬에서 정상 빌드 통과된 상태가 Linux 빌드 컨테이너에서 최초 한 번 깨지는 흐름은, 최초 머지에서 무조건 한 번 잡힌다는 신호로 정착했다. 본 머지 이후 신규 SPA 머지 시 이미지 경로 일괄 소문자화PR 체크리스트 한 줄로 들어갔다.


🔄 회고 — 분리 결정의 사후 평가

본 머지의 결정 6건 중 사후 며칠~몇 주 안에 재검토가 필요했던 항목을 정리한다.

첫째, QR 분리(결정 1)는 옳았다. 데스크톱 런타임 측 코드 베이스에 카메라 권한 모달·세로 강제·iOS Safari 자동재생 정책 어느 한 줄도 들어오지 않았다. 외부 SPA 측 모바일 단말 처리 흐름그 자체로 폐쇄돼 있고, 데스크톱 런타임은 세션 폴링 한 채널만 알면 됐다. 사후 한 달 안에 세로 화면 강제 미적용으로 사용자 한 명이 가로 회전에서 진입한 흐름이 한 번 잡혔는데, 정정은 외부 SPA 측 CSS 한 줄(screen.orientation.lock('portrait') 동등 처리) 로 끝났다 — 데스크톱 런타임 측 머지는 0 건이었다.

둘째, Public Submit API(결정 2)는 옳았지만 가드 정책 표면이 커졌다. PublicDiagnosticController + ReportPublicController 두 컨트롤러가 인증 없는 표면으로 컨트롤러 단위 분리됐다. 사후 한 달 안에 공개 정책 변경 한 건 — 세션 만료 1시간 → 2시간 연장 옵션 — 이 들어왔는데, 단일 컨트롤러의 단일 함수만 정정하면 됐다. 다만 향후 Public 컨트롤러 3건째가 들어오는 시점에 공통 만료 검증 미들웨어를 별도 모듈로 추출하는 결정이 필요할 것으로 본다.

셋째, PROCESSING_SPEED 서버 자동 계산(결정 3)은 정책 갱신 비용이 한 차례 들어왔다. 11 단계 점수표가 임상 검증 데이터와 일부 어긋난다는 운영 신호가 한 차례 들어와, 5 단계 점수표로 축소하는 정정이 다음 머지에 따라붙었다. 정책 갱신이 백엔드 머지 대상이라는 결정 사유 자체는 유지됐고, 단일 함수 정정으로 끝났다.

넷째, needsDiagnostic 폐기(결정 4)는 외부 단말 흐름 분리와 짝을 이뤘다. 외부 SPA 가 진행 상태를 직접 폴링하는 흐름이라, Login / Home 응답에 부산물 필드가 들어 있을 이유가 사라졌다. 직전 시리즈의 외부 뷰어 토큰 발급 시점 즉시 만료 결정과 동일 원칙 — 시간이 지나면서 바뀌는 사실은 단일 함수에서만 결정한다 — 가 본 머지에도 그대로 적용됐다.

다섯째, Firebase Hosting 멀티사이트(결정 5)는 운영 자산 분리에서 가장 큰 이득을 줬다. 운영 포털 3건의 배포 주기가 명확히 분리됐고, 한 SPA 의 프로덕션 빌드 실패가 다른 SPA 의 기존 배포에 영향을 주지 않았다. 단일 도메인 + 서브 경로 마운트로 갈았다면 빌드 실패가 전체 사이트 롤백으로 이어졌을 가능성이 컸다. 사후 두 달 안에 커스텀 도메인 연결(가비아 도메인 → Firebase 도메인 검증) 머지가 따라붙었지만, 멀티사이트 분리 자체가 커스텀 도메인 4건의 독립 검증을 가능하게 했다.

여섯째, Linux 케이스 센시티브 함정(결정 6)은 가장 짧은 신호다. 최초 머지에서 한 번 잡힌다는 점을 알고 나면 이후 머지에서 같은 함정이 안 잡힌다. 본 머지 이후 신규 SPA 머지 시 이미지 경로 일괄 소문자화가 PR 체크리스트 한 줄로 들어갔고, 이후 6개월 동안 같은 함정 0 건 이다.

💡 인사이트: 외부 단말과 데스크톱 런타임의 책임 범위를 분리하는 결정데이터 흐름의 단순화로 직결됐다. 본 머지의 핵심 결정 트리 — QR 한 장 → Public API sessionId 게이트 → 서버 자동 계산 → 진행 상태 SSoT — 는 외부 단말이 가장 가벼운 인터페이스만 알게 하는 한 방향이다. 직전 시리즈의 외부 뷰어 토큰 단일 모델 결정과 본 머지의 Public 컨트롤러 단위 분리 결정이 같은 방향의 책임 범위 좁히기다. 책임 범위가 좁아진 만큼, 각 단말의 책임 표면각자 단순화된다.


📋 정리 — 결정 표와 다음 편

#결정채택사후 평가
1QR 분리 — 데스크톱 런타임은 QR 표시만, 모바일 SPA 가 흐름 수신데스크톱 런타임 코드 어디에도 모바일 단말 처리 0 줄 — 한 달 안 가로 회전 한 건은 SPA 측 CSS 한 줄로 정정
2Public Submit API — 인증 0 + sessionId 게이트컨트롤러 단위 분리 정착 — Public 3건째 시점에 공통 만료 검증 미들웨어 추출 검토
3PROCESSING_SPEED 서버 자동 계산 + 4 지표 축소⚠️11 단계 → 5 단계 점수표 축소 정정 한 차례 — 단일 함수 정정으로 끝남
4needsDiagnostic 폐기 + status 단일 엔드포인트 SSoT외부 뷰어 토큰 단일 모델과 동일 원칙 — 시간이 지나는 사실은 단일 함수
5Firebase Hosting 멀티사이트 4건 분리배포 주기 독립 + 커스텀 도메인 4건 독립 검증 가능 — 한 SPA 빌드 실패가 전체 영향 0
6Vite base + Linux 케이스 센시티브 정정최초 머지에서 1 회 잡힘 — 이후 6개월 동안 같은 함정 0 건, PR 체크리스트 한 줄로 정착

다음 편(devlog-61)에서는 본 머지의 분리된 외부 단말 흐름이 받아야 했던 1,974줄 풀 백업session-sync 운영 흐름 회고 — 워크트리 4개 사이의 세션 간 공유 파일아카이브 디렉토리 표준이 1인 풀스택 진행에서 상태 관리를 어떻게 받쳐주는지를 같은 B 톤 구현기로 정리할 예정이다.

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

  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 권한 가드 — 목록은 막고 상세는 뚫린 날
  47. 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
  48. 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
  49. 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
  50. 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
  51. 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
  52. 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
  53. 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
  54. 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로
  55. 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
  56. 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
  57. 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
  58. 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
  59. 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
  60. 60. react-hook-form + Zod 폼 표준 정착기
  61. 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
  62. 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
  63. 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음