외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루

도입 단계 머지가 같은 날 밤 사용자 검토 1회로 폐기된 사고. Parent / ParentStudent / ParentInvitation 3 모델 + 회원가입 단일 트랜잭션 + 별도 JWT 시크릿 흐름 전부 삭제, StudentReport 1 모델 + nanoid 21자 공개 토큰 + 7일 만료 정책으로 같은 dev 머지 사이클 안에 갈아엎은 BE -2,099 / +563 + FE -1,475 / +968 의 갈아엎기 비용과 결정 사유, 사후 평가를 정리한다.


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

  • 도입 단계 머지가 같은 날 밤 사용자 검토 1회로 폐기된 사고 — 직전 머지의 회원가입·인증 흐름 전부를 삭제하고 토큰 기반 공개 페이지로 갈아엎었다
  • Parent / ParentStudent / ParentInvitation 3 모델 + UserRole.PARENT + ParentRelation enum 전부 삭제 — 직전 머지에서 신규로 도입한 스키마가 같은 머지 사이클 안에 폐기됨
  • 신규 모델 1건: StudentReportnanoid(21) 공개 토큰 + 7일 만료 + viewedAt / viewCount 열람 추적, 인증 게이트 없음
  • 신규 컨트롤러 2건student-report.controller.ts(운영자 JWT, 발급·목록·삭제) + report-public.controller.ts(인증 없음, GET /report/:token 단일 엔드포인트)
  • BE -2,099 / +563, FE -1,475 / +968, 같은 dev 머지 사이클 약 4시간 — 단순 회귀가 아니라 결정 자체의 갈아엎기가 핵심 비용
  • FE 1차 갈아엎기 실패 후 완전 리셋(git checkout a8483e9 -- src/)으로 직전 머지 시점으로 되돌린 뒤 데이터 바인딩만 토큰 기반으로 재작업 — UI 보존을 위한 단계별 재작업 룰 정립

🎯 배경 — 같은 날 두 머지가 정반대 방향이었던 이유

직전 머지에서 외부 뷰어(보호자)용 모바일 앱을 신설했다. 별도 컨테이너, 별도 JWT 시크릿, 회원가입 단일 트랜잭션 4 write, 초대 토큰 7일 만료 — 신규 모델 3건과 엔드포인트 6건을 같은 dev 머지 사이클 안에 BE + FE 양쪽으로 정리했다. 머지 직후 사용자 검토를 한 차례 받았고, 곧바로 결정 한 줄이 들어왔다.

“외부 뷰어가 매번 로그인하는 그릇 자체가 무겁다. 한 번 받은 링크로 바로 들어가는 게 맞다.”

같은 날 밤 00:06 에 외부 뷰어 계정 → 토큰 기반 레포트 전환 머지(adf67f28) 가 dev 에 들어갔다. 직전 머지(5f5a52ad) 가 같은 날 저녁 18:07 에 들어간 점을 보면 회원가입 흐름의 수명이 6시간이었던 셈이다. 같은 머지 사이클 안에서 BE 컨트롤러 3건, 서비스 2건, DTO 1건, Prisma 모델 3건이 통째로 삭제됐고, 그 위치에 모델 1건과 엔드포인트 4건(공개 1 + 운영자 JWT 3)이 들어왔다.

이 머지를 “트러블슈팅”으로 부를 수는 없다. 버그가 터진 게 아니라 결정 자체가 뒤집힌 사고다. 표면적으로는 단순 회귀(rollback)처럼 보이지만, 회귀가 아니라 반대 방향의 도입 머지다. 회원가입·로그인·세션 보존이라는 표준 인증 흐름을 폐기하고, 토큰 한 줄만으로 공개 페이지에 들어가는 반대 결정을 새로 도입했다.

📌 핵심: 도입 단계 머지의 산출물은 사용자 검토 한 차례에 절반 이상이 폐기될 가능성을 항상 가정해야 한다. 본 머지의 BE 측 갈아엎기는 -2,099 / +563, FE 측은 -1,475 / +968 이었다. 단순한 삭제·추가 수치만 보면 “다 지우고 다시 짠다”로 읽히지만, 결정 사유의 갈아엎기가 진짜 비용이었다 — 인증 도메인을 가질 것인가 vs 토큰 공개 페이지로 끝낼 것인가는 시스템 전체 분기 구조를 결정하는 루트 수준 결정이다.


⚖️ 설계 결정 6건 — 무엇을 살리고 무엇을 폐기했나

v2 명세 단계에서 결정 6건을 명시했다. 직전 머지에서 합의했던 결정 6건과 대구를 이루는 표로 정리한다.

#결정채택 사유트레이드오프
1외부 뷰어 계정 자체를 폐기Parent / ParentStudent / ParentInvitation 3 모델 삭제외부 뷰어는 시스템 사용자가 아니라 공개 데이터 열람자 / 회원가입·로그인 흐름은 본질적으로 무거움 / 한 번 받은 링크로 바로 진입이 사용자 의도와 일치외부 뷰어 식별·다자녀 매핑·SMS 알림 같은 후속 기능을 다시 짤 때 모델을 새로 도입해야 함. 본 머지 단계에서는 공개 페이지 단일 흐름이 압도적으로 단순해 비용을 감수
2신규 모델 StudentReport 단일token (nanoid 21자) + expiresAt + viewedAt + viewCount외부 뷰어 식별을 링크 그 자체로 흡수 / 토큰 → 회원 매핑 1건만 유지 / 열람 횟수·시점 추적은 운영자 가시성용토큰 노출이 그대로 데이터 노출 — 7일 만료와 재발급 시 기존 토큰 즉시 폐기 정책으로 노출 창을 제한
3공개 API 분리 — GET /report/:token (인증 게이트 없음)인증 가드 통과가 외부 뷰어 입장에서 진입 비용이고, 토큰 자체가 비밀이라 가드 게이트 가치가 낮음 / 운영자 API 와 명확히 분리해 가드·로깅 정책도 분리JwtAuthGuard 가 없는 컨트롤러가 늘면 보안 정책 표면도 늘어남 — 별도 컨트롤러(ReportPublicController)로 격리해 위험 표면을 한 곳에 모음
4신규 발급 시 기존 유효 토큰 즉시 폐기updateMany expiresAt: now()한 회원당 유효 토큰 1건만 유지 / 외부 뷰어가 옛 링크를 들고 들어와도 만료로 차단 / 토큰 회수 흐름이 필요 없음발급 직후 옛 링크는 사용 불가 — 외부 뷰어가 두 명일 때는 누군가 새 링크를 받으면 다른 사람의 옛 링크가 끊김. 본 머지 단계에서 두 명 동시 발급은 운영 흐름 밖이라 허용
5FE 인증 페이지 5건 전부 삭제login / signup / home / children / auth.store.ts토큰 라우트 단일화 / 라우팅 트리 평탄화로 라우트 가드·세션 보존 분기 전부 제거직전 머지에서 작성한 Figma 동기화 UI 자산 중 4탭 골자 외 5 페이지가 모두 삭제됨 — 갈아엎기 비용이 가장 큰 결정
6FE 1차 시도 실패 후 완전 리셋 + 단계별 재작업git checkout a8483e9 -- src/ 로 직전 머지 시점 복원 후 데이터 바인딩만 토큰 기반으로 재작업1차 시도에서 SummaryPage 가 원본 ChildHomePage 와 전혀 다른 UI로 풀려 사용자 검수 실패 / UI 보존이 폐기·재작성보다 더 비싸 원본 복사 + 토큰 매핑만 패치 룰 정립단계별 커밋 강제로 작업 속도 자체는 느려짐 — 다만 갈아엎기 직후 되돌릴 단위가 살아 있다는 안전망이 더 큰 가치

결정 1·5가 본 머지의 가장 무거운 비용이다. 직전 머지에서 방금 작성한 신규 모델 3건과 인증 페이지 5건이 같은 dev 머지 사이클 안에 폐기됐다. 일반화하면 — 도입 단계 머지의 절반은 사용자 검토 한 차례에 폐기될 가능성을 가정하고 짜야 한다. Mock-first 워크플로우와 분리된 BE/FE 머지 단위가 갈아엎기 비용을 최소화한 가장 큰 결정이었지만, 그래도 BE 측 -2,099 줄과 FE 측 -1,475 줄은 그대로 비용으로 받았다.

직접 정리한 외부 뷰어 리포트 v1→v2 토큰 전환 — 같은 날 두 머지 타임라인 / 스키마 삭제·신설 비교 / 공개 API 분리 + FE 라우팅 단순화 도식
직접 정리한 외부 뷰어 리포트 v1→v2 토큰 전환 — 같은 날 두 머지 타임라인 / 스키마 삭제·신설 비교 / 공개 API 분리 + FE 라우팅 단순화 도식

⚠️ 주의: 도입 단계 머지를 “다 짜고 검토 받기”로 진행하면 갈아엎기 비용을 줄일 수 없다. 본 머지의 회원가입 단일 트랜잭션, JWT 시크릿 분리, zustand persist hasHydrated 게이트 같은 기반 결정은 모두 검토 전에 굳어졌고, 검토 결과 반대 방향이 채택됐다. Mock-first 와 분리된 BE/FE 머지가 갈아엎기 단위를 작게 유지한 이유는 — 검토 직전 시점의 코드 트리를 되돌릴 수 있는 단위로 분리해 둔 것뿐이다. 검토 자체를 명세 단계로 당기는 게 더 싼 길이지만, 외부 뷰어처럼 화면을 보고서야 결정되는 종류의 일은 명세 검토만으로 결정을 끝내기 어렵다.

prisma.io

🛠️ 구현 1 — Prisma 스키마: 3 모델 삭제 + StudentReport 신설

스키마 변경은 두 부분이다. 먼저 직전 머지에서 신규로 들어왔던 Parent / ParentStudent / ParentInvitation 3 모델과 ParentRelation enum 을 통째로 삭제했고, UserRole.PARENT 도 제거했다. 그 위치에 StudentReport 모델 1건을 신설했다.

// apps/api/prisma/schema.prisma 인용 (commit adf67f28)

enum UserRole {
  PLATFORM_ADMIN
  ACADEMY_OWNER
  TEACHER
  STUDENT
  // PARENT 항목 삭제 — 직전 머지에서 신규 도입 후 같은 머지 사이클 폐기
}

// enum ParentRelation { ... }  ← 통째로 삭제

model StudentReport {
  id          String   @id @default(cuid())
  token       String   @unique         // nanoid 21자 — 공개 식별자
  studentId   String

  type        String   @default("CURRENT")  // CURRENT / DAILY / WEEKLY / MONTHLY
  periodDays  Int      @default(7)          // 조회 데이터 범위 (일)

  expiresAt   DateTime  @db.Timestamptz     // 만료 (생성 + 7일)
  viewedAt    DateTime? @db.Timestamptz     // 첫 열람 시간
  viewCount   Int       @default(0)         // 열람 횟수

  createdByUserId String                    // 발급한 운영자 User.id
  createdAt   DateTime  @db.Timestamptz @default(now())

  student     Student  @relation(fields: [studentId], references: [id])
  createdBy   User     @relation("ReportCreator", fields: [createdByUserId], references: [id])

  @@index([token])
  @@index([studentId])
  @@map("student_reports")
}

User / Student 측 리버스 relation 도 함께 갱신됐다. User.parent / User.createdInvitations / Student.parentStudents / Student.parentInvitations 4 건 삭제, User.createdReports / Student.reports 2 건 신설.

스키마 통계로 본 갈아엎기 규모는 apps/api/prisma/schema.prisma 단일 파일에서 -72 / +30 이다. 모델 정의가 60 줄 가까이 사라졌고, 그 위치에 30 줄 모델 1건과 리버스 relation 2 줄이 들어왔다.

🔍 단서: Parent / ParentStudent / ParentInvitation 3 모델을 삭제할 때는 Cascade 정책을 먼저 점검해야 한다. 본 머지는 dev 브랜치 단일 환경에서 진행됐고 데이터가 없는 상태였기 때문에 prisma db push 한 줄로 끝났지만, 운영 데이터가 있는 환경이었다면 — parents / parent_students / parent_invitations 3 테이블의 모든 행을 백업하거나, FK Cascade 의 정확한 방향을 사전에 확인해야 했다. 본 머지처럼 도입 직후 폐기가 가능했던 가장 큰 조건은 실제 회원가입이 0 건이었다는 사실이다.


🛠️ 구현 2 — 공개 API 분리: 인증 없는 컨트롤러 1건 + 운영자 JWT 컨트롤러 1건

신규 컨트롤러 2건이 들어왔다. 인증 분리를 컨트롤러 단위로 강제한 결정이다.

// apps/api/src/application/controllers/report-public.controller.ts (commit adf67f28)

@ApiTags('report-public')
@Controller('report')
export class ReportPublicController {
  constructor(
    private readonly reportService: StudentReportApplicationService,
  ) {}

  /**
   * 레포트 조회 (공개)
   * GET /api/v1/report/:token
   */
  @ApiOperation({
    summary: '레포트 조회',
    description: '토큰으로 레포트를 조회합니다. 인증이 필요 없습니다.',
  })
  @ApiParam({ name: 'token', description: '레포트 토큰' })
  @Get(':token')
  async getReport(
    @Param('token') token: string,
  ): Promise<GetReportSuccessResponseDto | GetReportErrorResponseDto> {
    return this.reportService.getReportByToken(token);
  }
}

@UseGuards(JwtAuthGuard)의도적으로 없다. 클래스 전체에 가드가 한 줄도 적용되지 않은 컨트롤러는 본 머지 시점 기준 백엔드 전체에서 본 컨트롤러 1건뿐이고, 모듈 등록 위치도 운영자 컨트롤러와 분리해 ApiTags('report-public') 로 Swagger UI 측에서도 구분된다. 인증 없는 엔드포인트의 위험 표면을 컨트롤러 한 곳에 모은 것이 결정 3의 핵심이다.

운영자 측 컨트롤러는 정반대 모양이다.

// apps/api/src/application/controllers/student-report.controller.ts (commit adf67f28)

@ApiTags('student-report')
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('academy')
export class StudentReportController {
  constructor(
    private readonly reportService: StudentReportApplicationService,
  ) {}

  @Roles('ACADEMY_OWNER', 'TEACHER')
  @Post('students/:studentId/report')
  async createReport(
    @Param('studentId') studentId: string,
    @Body() dto: CreateReportDto,
    @Request() req: any,
  ): Promise<CreateReportResponseDto> {
    const userId = req.user.userId;
    const academyId = req.user.academyId;
    return this.reportService.createReport(studentId, userId, academyId, dto);
  }

  // ...getReports / deleteReport 생략
}

같은 서비스 인스턴스(StudentReportApplicationService)를 두 컨트롤러가 공유하지만, 가드 정책은 컨트롤러 단위로 분리된다. 발급·목록·삭제는 운영자 JWT + 역할 가드, 조회는 인증 없음. 서비스 내부에서 가드 분기를 두는 대신 컨트롤러 자체를 분리한 결정은 — ReportPublicController향후에도 가드를 받지 않을 흐름이라는 의도를 코드 구조로 명시했기 때문이다.

💡 인사이트: “인증 없는 엔드포인트”는 컨트롤러 단위로 격리해야 위험 표면을 통제할 수 있다. NestJS 공식 문서의 Authentication 가이드도 가드의 적용 범위를 컨트롤러·핸들러 단위로 권장하는데, 전역 가드 + 예외 데코레이터(@Public()) 패턴은 의도가 흩어진다. 본 머지는 컨트롤러 자체를 분리해 Public 컨트롤러의 위치만 보면 인증 없는 엔드포인트가 한눈에 보이는 구조로 정리했다.

docs.nestjs.com

🛠️ 구현 3 — 신규 발급 시 기존 유효 토큰 즉시 폐기

StudentReportApplicationService.createReport 의 핵심 로직 한 줄.

// apps/api/src/application/services/student-report.application.service.ts (commit adf67f28)

async createReport(
  studentId: string,
  createdByUserId: string,
  academyId: number,
  dto: CreateReportDto,
): Promise<CreateReportResponseDto> {
  const student = await this.prisma.student.findUnique({ where: { id: studentId } });
  if (!student) throw new NotFoundException('Student not found');
  if (student.academyId !== academyId) {
    throw new ForbiddenException('Not authorized to access this student');
  }

  // 기존 유효한 레포트 폐기 (expiresAt 을 now() 로)
  await this.prisma.studentReport.updateMany({
    where: {
      studentId,
      expiresAt: { gt: new Date() },
    },
    data: { expiresAt: new Date() },
  });

  // 토큰 생성 + 7일 만료
  const token = nanoid(21);
  const expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + REPORT_EXPIRY_DAYS);

  const report = await this.prisma.studentReport.create({
    data: {
      token,
      studentId,
      type: dto.type || 'CURRENT',
      periodDays: dto.periodDays || 7,
      expiresAt,
      createdByUserId,
    },
  });

  const reportUrl = `${this.REPORT_BASE_URL}/${token}`;
  return {
    id: report.id,
    token: report.token,
    reportUrl,
    expiresAt: report.expiresAt.toISOString(),
    studentName: student.name,
  };
}

updateMany 한 번이 한 회원당 유효 토큰 1건 정책의 전부다. findMany → forEach → update 같은 N+1 패턴 없이 단일 쿼리로 처리되고, 기존 만료된 토큰은 건드리지 않는다(expiresAt: { gt: new Date() }). 새 토큰 발급은 별개 트랜잭션이지만 순서 자체가 폐기 → 발급이라 동시성 윈도가 좁다.

getReportByToken 측은 열람 추적을 추가했다.

async getReportByToken(token: string): Promise<GetReportSuccessResponseDto | GetReportErrorResponseDto> {
  const report = await this.prisma.studentReport.findUnique({
    where: { token },
    include: { student: true },
  });

  if (!report) return { valid: false, reason: 'NOT_FOUND', message: '레포트를 찾을 수 없습니다' };
  if (report.expiresAt < new Date()) {
    return { valid: false, reason: 'EXPIRED', message: '레포트가 만료되었습니다' };
  }

  // viewCount 증가, 첫 열람이면 viewedAt 설정
  await this.prisma.studentReport.update({
    where: { id: report.id },
    data: {
      viewCount: { increment: 1 },
      viewedAt: report.viewedAt || new Date(),
    },
  });

  // ...회원 정보 + 레포트 데이터 매핑 (생략)
}

viewedAt || new Date() 한 줄로 첫 열람 시간만 박제했다. 두 번째 열람부터는 viewedAt 갱신 없이 viewCount 만 증가한다. 운영자 측 목록 API 가 status: 'ACTIVE' | 'EXPIRED' | 'VIEWED' 를 응답에 포함하는데, 열람 1회 이상 + 미만료VIEWED, 미열람 + 미만료가 ACTIVE 다. 외부 뷰어가 링크를 받았지만 한 번도 들어오지 않은 상태를 운영자가 바로 인지할 수 있다.


🛠️ 구현 4 — FE 라우팅 단순화: 5 페이지 삭제 + /:token 단일 라우트

FE 측 갈아엎기는 BE 보다 복잡한 사고가 있었다. 1차 시도가 실패해 완전 리셋 후 단계별 재작업으로 마무리했다.

먼저 결과부터 — apps/parent-report/src/App.tsx 의 최종 형태.

// apps/parent-report/src/App.tsx (commit be06cc38)
import { BrowserRouter, Routes, Route } from "react-router"
import { Toaster } from "sonner"

import { useMockModeStore } from "@/stores/mock-mode.store"
import { ReportPage } from "@/pages/report"
import { InvalidPage } from "@/pages/invalid"

function App() {
  const { hasHydrated } = useMockModeStore()

  // zustand persist hydration 대기
  if (!hasHydrated) {
    return (
      <div className="min-h-screen flex items-center justify-center bg-[#FFFDF5]">
        <div className="animate-pulse text-gray-400">로딩 중...</div>
      </div>
    )
  }

  return (
    <BrowserRouter>
      <Toaster position="top-center" richColors />
      <div className="min-h-screen bg-[#FFFDF5]">
        <div className="mx-auto max-w-[430px] min-h-screen bg-white shadow-sm">
          <Routes>
            {/* 토큰으로 레포트 접근 (인증 없음) */}
            <Route path="/:token" element={<ReportPage />} />

            {/* 토큰 없이 접근 시 안내 */}
            <Route path="/" element={<InvalidPage />} />

            {/* 기타 경로 → 안내 페이지 */}
            <Route path="*" element={<InvalidPage />} />
          </Routes>
        </div>
      </div>
    </BrowserRouter>
  )
}

라우트 3건 — /:token, /, *. ProtectedRoute 래퍼가 사라졌고, useAuthStore import 도 사라졌다. zustand persisthasHydrated 게이트만 남아 있다 — useMock 토글용 store 가 hydration 전에 readonly false 로 보이는 함정을 회피하기 위한 한 줄이다.

InvalidPage 는 신규.

// apps/parent-report/src/pages/invalid/index.tsx (commit be06cc38)
import { AlertCircle } from "lucide-react"

export function InvalidPage() {
  return (
    <div className="min-h-screen flex flex-col items-center justify-center px-5 py-10">
      <AlertCircle className="h-16 w-16 text-gray-400 mb-4" />
      <h1 className="text-xl font-bold text-center text-gray-700 mb-2">
        유효하지 않은 링크입니다
      </h1>
      <p className="text-sm text-gray-500 text-center">
        고객사에서 받은 레포트 링크를 확인해주세요.
      </p>
    </div>
  )
}

24 줄 단일 컴포넌트. 라우트 가드 / 세션 보존 / 로그인 리다이렉트 같은 기존 인증 페이지 5건의 200~400줄 코드가 통째로 사라진 위치에 들어온 페이지다.


🔍 함정 — FE 1차 갈아엎기 실패 후 완전 리셋

직전 머지 직후 첫 시도는 SummaryPage 를 신설하는 방향이었다. 토큰 라우트 /:token 에 새 컴포넌트를 만들어 요약 → 상세 두 페이지로 분리하려 했다. 1시간쯤 작업 후 사용자 검수에서 신호 한 줄이 들어왔다.

“이전 요약 리포트, 상세 리포트 복원하고 작업하는 것.”

확인해 보니 신설한 SummaryPage 가 직전 머지의 ChildHomePage(pages/children/index.tsx) 와 전혀 다른 UI로 풀렸다. BE 응답 매핑 한 차례 패치 후 데이터는 표시되지만 디자인이 원본과 다른 상태가 됐고, 검수에서 막혔다.

세 선택지를 두고 결정을 받았다.

옵션내용위험
A토큰 적용 전으로 완전 git revert 후 처음부터 다시 작업직전 머지의 신설 파일이 모두 사라짐 — 가장 보수적
B원본 파일(pages/children/index.tsx commit 2aecda2) 을 정확히 복사 후 토큰만 추가단계별 커밋 강제 — 되돌릴 단위 보장
C현재 상태에서 원본과 비교하며 수동 수정UI 차이가 어디서 났는지 모르는 상태로 진행 — 가장 위험

옵션 B 가 채택됐다. 같은 날 01:20 시점 PM 측 지시문이 단계별 재작업 6단계를 명시했다.

# 1단계: parent-report 완전 리셋 (FE 워크트리에서)
cd apps/parent-report
git checkout a8483e9 -- src/    # Figma 동기화 완료 시점으로 복원

# 2단계: v2 에서 불필요한 페이지 삭제
rm -rf src/pages/login/
rm -rf src/pages/signup/
rm -rf src/pages/home/
rm -rf src/stores/auth.store.ts

# 3단계: App.tsx 라우팅 변경
# 4단계: ChildHomePage 토큰 수정 (childId → token + API 호출)
# 5단계: ReportPage 토큰 수정 (동일 패턴)
# 6단계: InvalidPage 생성

핵심 원칙은 “UI 절대 건드리지 말 것. 데이터 바인딩만 변경. 단계별 커밋” 세 줄이다. git checkout a8483e9 -- src/직전 머지의 신설 UI 자산을 그대로 복원했고, 그 위에 토큰 기반 데이터 바인딩만 패치했다.

🛡️ 예방: 갈아엎기 머지에서 신설 컴포넌트를 도입하면 갈아엎기 비용이 추가로 발생한다. 본 머지의 1차 시도는 SummaryPage 신설이라는 추가 결정을 갈아엎기 안에 끼워 넣었고, UI 차이가 어디서 났는지 모르는 상태로 진행돼 사용자 검수에서 막혔다. 갈아엎기 머지의 룰 한 줄 — “신설 UI 와 데이터 바인딩 변경은 같은 머지에 묶지 않는다”가 정립됐다.

레포트 생성/조회 버그 수정 머지(144b0ff2) 한 건이 같은 머지 사이클 안에서 따라붙었다. BE 측 req.user.userId → req.user.id JWT payload 필드명 정정 1줄, FE 측 json.data 래퍼 추출 누락 정정 1줄 — 직전 머지에서 굳혀 둔 응답 표준을 v2 컨트롤러가 정확히 따르지 않은 잔여 결함이었다.


📊 결과 — 같은 머지 사이클 4시간의 갈아엎기 비용

같은 dev 머지 사이클 안에 들어간 4 커밋의 라인 수 변화를 표로 정리한다.

commit시각영역변화비고
adf67f2802-02 00:06BE-2,099 / +563 (12 files)3 모델 + 컨트롤러 3건 + 서비스 2건 + DTO 1건 삭제, student-report.* 리네임 + report-public.controller.ts 신설
be06cc3802-02 00:15FE-1,475 / +968 (16 files)5 페이지 삭제 + auth.store 삭제 + App.tsx 단순화 + academy-portal 모달 전환 + motion 컴포넌트 신설
144b0ff202-02 00:33BE+FE+4 / -2 (2 files)JWT payload 필드명 정정 + FE 응답 래퍼 추출 정정
476911d102-02 01:25FE+514 / -40 (7 files)완전 리셋 후 ChildHomePage·ReportPage·4탭 토큰 매핑 재작업

순삭감 합계 — BE 측 -1,534 줄, FE 측 -33 줄. FE 가 라우팅 재작업으로 추가 라인을 받아 순삭감 폭이 BE 보다 작지만, 삭제된 5 페이지의 200~400줄은 회수되지 않는다.

v2 전면 변경 명세 커밋(2a79e5f2) 까지 합치면 본 머지 사이클은 직전 머지(2aecda27, 02-01 21:39)로부터 정확히 4시간 19분 안에 종결됐다. 첫 BE 머지(5f5a52ad, 02-01 18:07) 기준으로는 6시간 안의 일이고, 직전 머지의 산출물 절반 이상이 같은 머지 사이클 안에 폐기됐다.

항목도입 단계 (직전 머지)폐기 단계 (본 머지)비고
Prisma 모델+3 (Parent/ParentStudent/ParentInvitation)-3 + 1(StudentReport)직전 도입 모델 전부 폐기
enum+2 (UserRole.PARENT / ParentRelation)-2둘 다 폐기
신규 컨트롤러+2 (parent-auth / academy-parent-invitation)-2 + 2 (student-report / report-public)컨트롤러 갯수는 동일
신규 서비스+2 (parent-auth / parent-invitation)-2 + 1(student-report 리네임·재작성)서비스 1건 감소
FE 페이지+5 (login / signup / home / children / report)-4 + 1(invalid)report 만 살아남음
인증 시스템1 (별도 JWT 시크릿 + 1h/30d)0전면 폐기

report 페이지와 4탭 컴포넌트(AnalysisTab / LevelTab / PatternTab / RecommendTab) 가 본 머지에서도 살아남았고, 컴포넌트 props 가 useMock + data 패턴으로 통일돼 토큰 기반 응답을 받도록 갱신됐다. 직전 머지의 모바일 컨테이너 max-w-[430px] 도 그대로 유지됐다.


🔄 회고 — 도입 단계 폐기를 가정하는 비용은 얼마였나

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

첫째, 외부 뷰어 계정 폐기(결정 1)는 옳았다. 토큰 기반 공개 페이지가 외부 뷰어의 진입 비용을 정확히 0 으로 만들었고, 운영자가 발급 → 링크 복사 → 메시지 전송 흐름을 발급 모달 단일 클릭으로 끝낼 수 있게 됐다. 사후 한 달 안에 다자녀 케이스 / 외부 뷰어 두 명 케이스 / SMS 알림 요구가 들어왔지만, 그 시점에 필요한 만큼만 모델을 다시 도입하는 길이 더 싸다는 판단이 유지됐다.

둘째, 신규 발급 시 기존 토큰 즉시 폐기(결정 4)는 사후 정정이 한 차례 들어왔다. 운영자가 옛 링크가 갑자기 끊긴 사실을 외부 뷰어에게 미리 알리지 못해 지원 문의 1건이 들어왔고, 발급 모달에 “기존 링크는 즉시 만료됩니다” 안내 한 줄을 추가하는 패치가 따라붙었다. 정책 자체는 유지됐지만, 정책의 부작용을 사용자가 인지할 수 있도록 UI 측에 표시하는 결정이 추가됐다.

셋째, FE 1차 갈아엎기 실패(함정)는 가장 비싼 교훈이었다. “갈아엎기 머지에 신설 UI 를 끼워 넣지 않는다” 룰은 본 머지 이후 다른 도메인의 리팩토링 머지에도 적용됐다. 신설 결정과 갈아엎기 결정을 같은 머지에 합치면 되돌릴 단위가 둘로 흩어지고, 사용자 검수에서 어느 결정이 막혔는지 분리하기 어렵다. 단계별 커밋 + git checkout <commit> -- <path> 패턴이 단일 파일 단위 되돌리기의 표준 흐름으로 굳었다.

넷째, 도입 단계의 폐기 가능성을 명세 단계에서 가정하는 비용은 생각보다 컸다. 본 머지의 직전 머지가 회원가입 단일 트랜잭션, JWT 시크릿 분리, zustand persist hasHydrated 게이트 같은 기반 결정을 같은 머지 사이클 안에 한 번에 처리한 이유는 — Mock-first 워크플로우가 갈아엎기 단위를 작게 유지한다는 가정 위에 있었다. 실제로 갈아엎기 비용은 순삭감 BE -1,534 / FE -33 줄로 정량화됐고, 재화 시스템 단일 트랜잭션 패턴모바일 컨테이너 컴포넌트 같은 자산 일부는 살아남았다. 그러나 4탭 UI 골자 외 5 페이지 + 인증 시스템 1식재사용 0으로 폐기됐다 — 도입 단계의 결정 자체가 사용자 검수에서 뒤집힌 비용이다.

💡 인사이트: “도입 단계 머지는 절반이 폐기된다”라는 전제로 작성하면 과잉 설계가 줄어든다. 본 머지의 직전 머지가 회원가입 단일 트랜잭션, JWT 시크릿 분리, 별도 인증 도메인을 한 머지에 묶어 넣은 결정기술적으로는 깔끔했지만, 사용자 검수에서 그 결정 트리 전체가 뒤집혔다. 도입 단계에서는 해체 가능한 단위로 결정을 분리하고, 갈아엎기 머지를 별도 단위로 받아들일 준비를 같이 해 두는 게 비용을 줄인다. 본 시리즈에서 자주 인용한 “Mock-first” 워크플로우의 진짜 가치는 — 갈아엎기 단위를 작게 유지하는 안전망이라는 점에 있다.


📋 정리 — 결정 표와 다음 편

#결정채택사후 평가
1외부 뷰어 계정 폐기 — Parent / ParentStudent / ParentInvitation 삭제토큰 기반 공개 페이지가 진입 비용 0 으로 정착 — 다자녀·SMS 요구는 시점에 다시 도입
2신규 모델 StudentReport 단일 + nanoid 21자 + 7일 만료한 회원당 유효 토큰 1건 정책으로 운영 인지 부담 최소 — viewedAt / viewCount 가 운영자 가시성 자산
3공개 API 컨트롤러 분리(ReportPublicController)인증 없는 엔드포인트의 위험 표면을 컨트롤러 1건에 모음 — Swagger UI 측 report-public 태그도 분리
4신규 발급 시 기존 유효 토큰 즉시 폐기⚠️정책 자체는 유지 — 사용자 인지 안내 UI 1줄 추가 패치
5FE 인증 페이지 5건 + auth.store 전면 삭제라우트 트리 평탄화 + 가드 분기 0 — 다만 직전 머지의 신설 자산 절반 재사용 0
6FE 1차 갈아엎기 실패 후 완전 리셋 + 단계별 재작업“갈아엎기 머지에 신설 UI 끼워 넣지 않기” 룰 정립 — 다른 도메인 리팩토링에도 적용

다음 편(devlog-56)에서는 본 머지에서 살아남은 4탭 UI 가 받아야 했던 대시보드 인사이트 — 활동 데이터를 자연어 문장으로 변환하는 룰 기반 메시지 생성 흐름과, AI 메시지 부재 시점의 폴백 패턴, 공백 응답 시 안내 문구의 결정 사유를 정리할 예정이다.

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

  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초로