보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입

회원 레포트와 같은 데이터를 보호자(외부 뷰어) 그릇으로 옮기는 별도 앱 도입 머지. 모바일 우선 컨테이너(max-w-[430px]), Parent/ParentStudent/ParentInvitation 3 신규 모델, 초대 토큰 + 회원가입 단일 트랜잭션(4 write), 별도 JWT 시크릿(1h access / 30d refresh), 전화번호 = 로그인 ID 결정을 같은 dev 머지 사이클 4시간 안에 BE + FE Mock + FE 인증 연동까지 묶은 도입 단계 마일스톤이다.


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

  • 보호자(외부 뷰어)에게 회원 활동 데이터를 모바일 그릇으로 전달하는 별도 앱 도입 머지apps/parent-report 신규, 인증 체계까지 메인 앱과 분리
  • 모바일 우선 컨테이너 max-w-[430px] 강제 — 데스크탑에서도 같은 모바일 그릇으로 노출, 디자인·UX 흔들림 차단
  • Prisma 스키마 3 신규 모델Parent(1:1 User), ParentStudent(N:M 브릿지), ParentInvitation(nanoid 21자 토큰 + 7일 만료)
  • 회원가입은 단일 prisma.$transaction 4 write — 토큰 검증 → User + Parent 생성 → ParentStudent 연결 → 초대 usedAt 갱신
  • 별도 JWT 시크릿 + 1h access / 30d refresh — 메인 앱 토큰과 격리, 전화번호를 로그인 ID로 채택
  • 같은 dev 머지 사이클 4시간 안에 BE(1,520줄) + FE Mock + FE 인증 연동 완료 — Mock-first 워크플로우, 다음 머지에서 토큰 기반 공개 페이지로 전면 전환 예고

🎯 배경 — 같은 데이터를 다른 그릇으로 옮긴다

직전 머지에서 운영자용 회원 레포트 5탭 명세를 확정했다. 같은 데이터를 보호자(외부 뷰어)에게도 전달해야 한다는 요구가 같은 주에 들어왔다. 운영자는 데스크탑에서 회원 리스트를 훑는 그릇을 쓰지만, 보호자는 핸드폰에서 자녀 한 명의 활동을 본다. 같은 응답 데이터에 그릇이 둘이라는 사실이 본 머지의 모든 결정을 결정했다.

처음 떠올렸던 선택지는 메인 관리자 페이지 안에 /parent 라우트를 따로 두는 방법이었다. 디자인 시안을 받아 보니 그 선택지는 빠르게 빠졌다 — 시안 전체가 모바일 너비 단일 컬럼(360px 기준 디자인, 430px 안전 영역)이고, 운영자가 쓰는 데스크탑 그리드와 컴포넌트 트리가 거의 겹치지 않는다. 같은 앱 안에 두 그릇을 공존시키면 컴포넌트 재사용보다 분기 비용이 커진다는 판단이 빠르게 섰다.

또 하나의 결정 요소는 인증 도메인 분리다. 보호자는 고객사 소속이 아니고, 이메일도 안정적으로 받기 어렵다. 운영자 인증 흐름(이메일/비밀번호 + tenant 매핑)을 그대로 재사용하면 User.academyId가 nullable 이어야 하고, 가드와 인터셉터의 모든 분기가 한 단계 깊어진다. 별도 인증 체계가 처음부터 합리적이었다.

📌 핵심: “같은 데이터, 다른 그릇”이 분명할 때는 앱·인증·라우팅을 처음부터 분리하는 게 싸다. 단일 앱 안에서 두 그릇을 공존시키려는 결정은 디자인이 비슷할 때만 성립하고, 모바일 단일 컬럼 vs 데스크탑 그리드 같은 그릇 차이는 분기 비용을 사후에 받게 된다.


⚖️ 설계 결정 6건 — 무엇을 처음부터 분리했나

명세 + 머지 단계에서 결정 6건을 명시했다. 본문은 표의 결정 순서대로 Prisma 스키마 → 회원가입 트랜잭션 → 별도 JWT → 모바일 컨테이너 코드를 따라간다.

#결정채택 사유트레이드오프
1별도 앱 분리 — apps/parent-report 신규시안 전체가 모바일 단일 컬럼 / 운영자 그리드와 컴포넌트 트리 거의 겹치지 않음 / 인증 도메인 자체가 다름모노레포 앱 한 개가 추가되고, 디자인 토큰·컴포넌트 라이브러리 일부가 양쪽에 복제됨. 공통 토큰은 추후 packages/ui-tokens로 빼는 길을 열어둠
2모바일 우선 컨테이너 max-w-[430px] 강제시안 안전 영역이 430px / 데스크탑 노출도 같은 그릇으로 강제해 디자인 흔들림 차단 / 보호자가 PC로 접근해도 같은 UX데스크탑 가용 면적 대부분이 양옆 여백이 됨. 시안 의도 그대로라 받아들임
33 신규 모델 — Parent 1:1 / ParentStudent N:M / ParentInvitation다자녀·다보호자 지원 / 한 보호자가 여러 회원과 연결 / 초대 토큰을 회원가입과 분리해 lifecycle 관리모델 3개가 늘면서 Student.parentStudents·User.createdInvitations·User.parent 같은 리버스 relation 4건 추가. ts 측 타입 추론은 깔끔
4초대 토큰 + 회원가입 단일 prisma.$transaction(4 write)회원가입의 부분 실패가 가장 비싼 결함이라 4 write를 원자적으로 묶음. User 생성만 되고 ParentStudent 연결이 빠지는 부분 실패는 운영 정합성 사고로 직결트랜잭션 범위가 길어 잠금 비용·재시도 비용이 일반 단일 write보다 큼. 회원가입 자체가 동시성 충돌이 드문 작업이라 받아들임
5별도 JWT 시크릿 + 1h access / 30d refresh / 전화번호 = 로그인 ID메인 앱과 토큰 격리 / 보호자는 이메일 없음 / 전화번호가 가장 안정적 식별자 / refresh 30d 는 모바일 재로그인 빈도 고려시크릿 키 운영 항목이 2개로 늘고, 토큰 검증 가드도 별도 인스턴스. 격리의 이득이 운영 비용보다 큼
6같은 머지 사이클에 BE + FE Mock + FE 인증 연동 — Mock-first 워크플로우BE 1차 머지(스키마 + auth API) → FE Mock 머지 → FE 실 API 연동 머지 순으로 같은 4시간 안에 종결 / Mock-first 로 화면 결정의 비용을 BE 측에 떠넘기지 않음같은 날 안에 응답 DTO 변경이 한 차례 발생하면 BE/FE 양쪽 머지를 다시 잡아야 함. 본 머지 사이클에서는 ParentInvitation.phone 1건만 phone → email → phone 왕복

직접 정리한 보호자 외부 뷰어 대시보드 도입 머지 — 같은 날 머지 타임라인 / 스키마 + 회원가입 트랜잭션 / 모바일 컨테이너 + 엔드포인트 표 도식
직접 정리한 보호자 외부 뷰어 대시보드 도입 머지 — 같은 날 머지 타임라인 / 스키마 + 회원가입 트랜잭션 / 모바일 컨테이너 + 엔드포인트 표 도식

결정 4가 본 머지의 가장 무거운 결정이다. 회원가입은 단일 write 처럼 보이지만 실제로는 User 생성, Parent 생성, ParentStudent 연결, ParentInvitation 사용 처리까지 4 write 가 동시에 일관성을 유지해야 한다. 어느 한 단계라도 실패한 채 다음 단계가 커밋되면 — 예컨대 User는 생성됐는데 ParentStudent 연결이 빠지면 — 보호자는 로그인은 되지만 자녀가 보이지 않는 상태로 진입한다. 운영 정합성 사고를 막는 가장 싼 방법이 단일 $transaction 으로 4 write 를 묶는 것이다.

⚠️ 주의: 회원가입을 단일 트랜잭션으로 묶지 않으면 부분 실패가 사후 정합성 비용으로 돌아온다. User 생성 → 외부 인증 콜백 → 관계 테이블 연결 순서로 진행되는 흐름이 가장 흔하게 새는데, 콜백 실패 후 재시도가 멱등하지 않으면 같은 사용자가 두 번 생성되는 더 비싼 사고가 난다.

prisma.io

🛠️ 구현 1 — Prisma 스키마: 3 신규 모델과 enum 확장

스키마 변경은 두 부분이다. 먼저 UserRole enum 에 PARENT 를 추가하고, 보호자–회원 관계를 표현할 ParentRelation enum 을 신설했다. 그 다음 Parent, ParentStudent, ParentInvitation 3 모델을 추가하고 User / Student 측 리버스 relation 을 함께 갱신했다.

// apps/api/prisma/schema.prisma 인용 (이번 마이그레이션의 핵심만 발췌)

enum UserRole {
  PLATFORM_ADMIN
  ACADEMY_OWNER
  TEACHER
  STUDENT
  PARENT       // 신규 — 보호자
}

enum ParentRelation {
  PARENT       // 부모
  GUARDIAN     // 후견인
  OTHER        // 기타
}

model Parent {
  id     String @id @default(cuid())
  userId String @unique     // User 1:1 — 한 User 가 한 Parent
  name   String
  phone  String @unique     // 로그인 ID — 전화번호

  createdAt DateTime @db.Timestamptz @default(now())
  updatedAt DateTime @db.Timestamptz @updatedAt

  user            User               @relation(fields: [userId], references: [id], onDelete: Cascade)
  children        ParentStudent[]
  usedInvitations ParentInvitation[] @relation("UsedByParent")

  @@map("parents")
}

model ParentStudent {
  id        String         @id @default(cuid())
  parentId  String
  studentId String
  relation  ParentRelation @default(PARENT)
  linkedAt  DateTime       @db.Timestamptz @default(now())

  parent  Parent  @relation(fields: [parentId], references: [id])  // CASCADE 없음 — 분리 시 이력 유지
  student Student @relation(fields: [studentId], references: [id])

  @@unique([parentId, studentId])
  @@index([parentId])
  @@index([studentId])
  @@map("parent_students")
}

model ParentInvitation {
  id              String    @id @default(cuid())
  token           String    @unique     // nanoid 21자
  studentId       String
  phone           String                // 초대 대상 전화번호
  expiresAt       DateTime  @db.Timestamptz  // 7일 후
  usedAt          DateTime? @db.Timestamptz
  usedByParentId  String?
  createdByUserId String                // 초대를 발급한 운영자 User.id
  createdAt       DateTime  @db.Timestamptz @default(now())

  student      Student @relation(fields: [studentId], references: [id])
  createdBy    User    @relation("InvitationCreator", fields: [createdByUserId], references: [id])
  usedByParent Parent? @relation("UsedByParent", fields: [usedByParentId], references: [id])

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

세 가지 결정을 짚어둔다.

첫째, ParentStudent 는 N:M 브릿지이고 @@unique([parentId, studentId]) 가 핵심이다. 같은 보호자가 한 회원과 여러 번 연결되는 사고를 DB 레벨에서 차단한다. 동일 키 재시도는 Prisma 가 P2002 로 던지고, 애플리케이션 쪽에서 ALREADY_LINKED 로 분기한다.

둘째, Parentphone@unique 를 걸었지만 Student.parentPhone 측의 unique 는 제거했다. 한 보호자가 여러 자녀를 둔 경우 같은 전화번호가 여러 Student 행에 나타날 수 있다. 운영 데이터의 정합성 단위는 ParentInvitation 의 token 이지 회원 측 parentPhone 이 아니라는 결정이다.

셋째, ParentInvitation.usedByParentId 가 optional 이다. 토큰이 미사용 상태인 시점에는 어떤 Parent 와도 연결되지 않은 상태가 정상이고, 사용 후에만 채워진다. null 가능성을 명시한 이유는, 미사용 초대 목록 조회 쿼리가 가장 흔한 운영 액션이기 때문이다.

마이그레이션 자체는 enum 추가 + 모델 3개 + 인덱스 5개로 끝났고, 기존 테이블 변경은 User, Student 양측 리버스 relation 만 갱신했다 — 데이터 변경 없는 추가형 마이그레이션이라 운영 위험은 낮았다.


🛠️ 구현 2 — 회원가입 트랜잭션: 토큰 검증 + 4 write 원자성

회원가입 흐름은 다음과 같다. 보호자가 운영자에게 받은 초대 URL 로 접속한다 → 토큰을 서버에 검증 → 이름/비밀번호 입력 → 회원가입 API 호출 → 단일 트랜잭션으로 4 write. 본 머지의 코드를 그대로 인용한다.

// apps/api/src/application/services/parent-auth.application.service.ts (signup 핵심만 발췌)

async signup(dto: ParentSignupDto): Promise<ParentAuthResponseDto> {
  // 1) 토큰 검증
  const invitation = await this.prisma.parentInvitation.findUnique({
    where: { token: dto.token },
    include: { student: { include: { academy: true, classStudents: { take: 1 } } } },
  });

  if (!invitation) throw new BadRequestException('INVALID_TOKEN');
  if (invitation.usedAt) throw new BadRequestException('TOKEN_ALREADY_USED');
  if (invitation.expiresAt < new Date()) throw new BadRequestException('TOKEN_EXPIRED');

  // 2) 기존 보호자(같은 전화번호) 확인
  const existingParent = await this.prisma.parent.findUnique({
    where: { phone: dto.phone },
  });

  if (existingParent) {
    const alreadyLinked = await this.prisma.parentStudent.findUnique({
      where: { parentId_studentId: { parentId: existingParent.id, studentId: invitation.studentId } },
    });
    if (alreadyLinked) throw new ConflictException('ALREADY_LINKED');
  }

  // 3) 단일 트랜잭션으로 4 write
  const result = await this.prisma.$transaction(async (tx) => {
    let parentId: string;
    let parentUserId: string;

    if (existingParent) {
      // 기존 보호자에 회원만 추가 연결 (2 write)
      parentId = existingParent.id;
      parentUserId = existingParent.userId;
    } else {
      // 신규 보호자: User + Parent 생성 (4 write)
      const passwordHash = await bcrypt.hash(dto.password, this.SALT_ROUNDS);
      const user = await tx.user.create({
        data: {
          loginId: dto.phone,       // 전화번호 = 로그인 ID
          email: null,
          passwordHash,
          role: 'PARENT',
          academyId: null,          // 고객사 소속 없음
        },
      });
      const newParent = await tx.parent.create({
        data: { userId: user.id, name: dto.name, phone: dto.phone },
      });
      parentId = newParent.id;
      parentUserId = newParent.userId;
    }

    // 회원 연결
    await tx.parentStudent.create({
      data: { parentId, studentId: invitation.studentId, relation: 'PARENT' },
    });

    // 초대 사용 처리
    await tx.parentInvitation.update({
      where: { id: invitation.id },
      data: { usedAt: new Date(), usedByParentId: parentId },
    });

    return { parentId, parentUserId };
  });

  // 4) JWT 발급 + 자녀 목록 응답
  const children = await this.getChildrenForParent(result.parentId);
  const tokens = this.generateTokens(result.parentId, result.parentUserId);
  return { ...tokens, parent: { id: result.parentId, name: dto.name, phone: dto.phone }, children };
}

세 가지 패턴을 짚어둔다.

첫째, 토큰 검증을 트랜잭션 바깥에서 한다. findUnique 한 번으로 4 종 판정(NOT_FOUND / USED / EXPIRED / OK)을 끝낸 다음 트랜잭션에 진입한다. 검증과 write 를 한 트랜잭션에 묶으면 잠금 범위가 불필요하게 늘어나고, findUnique 가 락을 잡지도 않는다.

둘째, 기존 보호자 분기는 같은 트랜잭션 안에서 2 write 로 끝난다. 한 보호자가 두 자녀를 두는 경우, 두 번째 회원가입 흐름은 새 User/Parent 를 만들지 않고 ParentStudent 연결 + ParentInvitation 사용 처리만 한다. existingParent 분기를 트랜잭션 바깥에서 미리 판정해 안 쪽 로직을 단순하게 유지했다.

셋째, ALREADY_LINKED 충돌은 트랜잭션 진입 전에 잡는다. 같은 보호자가 같은 회원과 이미 연결돼 있는 경우는 트랜잭션 안에서 P2002 로 잡힐 수도 있지만, 그러면 트랜잭션 롤백 비용을 받게 된다. 진입 전 findUnique 한 번으로 끝낸다.

🔍 단서: 회원가입 트랜잭션은 진입 전 판정원자적 write 두 단계로 나누면 잠금 비용을 최소화할 수 있다. 검증을 트랜잭션 안에 묶는 패턴은 흔한 안티패턴이고, findUnique/findFirst 의 비잠금 read 를 적극 활용해야 진입 후 write 만 트랜잭션 안에 남는다.

docs.nestjs.com

🛠️ 구현 3 — 별도 JWT 시크릿 + 1h access / 30d refresh

토큰은 access 1시간, refresh 30일이다. 짧은 access 는 토큰 탈취 노출 시간을 줄이고, 긴 refresh 는 모바일에서 매번 로그인 화면을 보지 않게 한다. 메인 앱 토큰과 시크릿 자체를 분리해, 한 쪽 시크릿이 노출돼도 다른 쪽 토큰이 그대로 살아남도록 했다.

// apps/api/src/application/services/parent-auth.application.service.ts (token 발급 핵심)

private readonly JWT_SECRET =
  process.env.JWT_SECRET || 'parent-secret-key';
private readonly JWT_REFRESH_SECRET =
  process.env.JWT_REFRESH_SECRET || 'parent-refresh-secret-key';
private readonly ACCESS_TOKEN_EXPIRES_IN = '1h';
private readonly REFRESH_TOKEN_EXPIRES_IN = '30d';

private generateTokens(parentId: string, userId: string) {
  const accessToken = jwt.sign(
    { sub: userId, parentId, type: 'access' },
    this.JWT_SECRET,
    { expiresIn: this.ACCESS_TOKEN_EXPIRES_IN },
  );
  const refreshToken = jwt.sign(
    { sub: userId, parentId, type: 'refresh' },
    this.JWT_REFRESH_SECRET,
    { expiresIn: this.REFRESH_TOKEN_EXPIRES_IN },
  );
  return { accessToken, refreshToken };
}

async refreshToken(dto: ParentTokenRefreshDto) {
  const decoded = jwt.verify(dto.refreshToken, this.JWT_REFRESH_SECRET) as {
    sub: string; parentId: string; type: string;
  };
  if (decoded.type !== 'refresh') throw new UnauthorizedException('Invalid refresh token');

  const user = await this.prisma.user.findUnique({
    where: { id: decoded.sub },
    include: { parent: true },
  });
  if (!user || user.role !== 'PARENT' || !user.parent) {
    throw new UnauthorizedException('User not found');
  }
  return this.generateTokens(user.parent.id, user.id);
}

type: 'access' | 'refresh' 필드를 payload 에 둔 이유는 두 시크릿 중 하나가 우연히 같은 값으로 운영되더라도, refresh 토큰을 access 쪽에 끼워 넣는 흐름을 차단하기 위해서다. 본 머지의 디폴트 시크릿 두 개는 서로 다른 문자열이지만, 환경 변수 누락 시 fallback 이 같은 패턴을 따르는 만큼 payload 측 분리를 한 단계 더 둔 셈이다.

FE 측 토큰 저장은 zustand persistlocalStorage 에 둔다. hydration 이 끝나기 전에 컴포넌트가 그려지면 비로그인 상태로 잠시 노출되는 잔존 함정이 있어, hasHydrated 게이트를 명시했다.

// apps/parent-report/src/stores/auth.store.ts (핵심만 발췌)

export const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      accessToken: null,
      refreshToken: null,
      parent: null,
      children: [],
      hasHydrated: false,

      setAuth: (data) => set({
        accessToken: data.accessToken,
        refreshToken: data.refreshToken,
        parent: data.parent,
        children: data.children,
      }),
      clearAuth: () => set({
        accessToken: null, refreshToken: null, parent: null, children: [],
      }),
      setHasHydrated: (v) => set({ hasHydrated: v }),

      isAuthenticated: () => {
        const { accessToken } = get();
        if (!accessToken) return false;
        try {
          const payload = JSON.parse(atob(accessToken.split('.')[1]));
          return payload.exp * 1000 > Date.now();
        } catch { return false; }
      },
    }),
    {
      name: 'alp_parent_auth',
      onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
    },
  ),
);

isAuthenticated() 가 만료 시각을 같이 본다는 점이 사소하지만 결정적이다. accessToken 존재만으로 인증 상태를 판단하면, 토큰이 만료된 채로 보호 라우트에 진입해 401 응답을 받고 나서야 로그아웃 처리가 된다. payload 의 exp 를 클라이언트에서 한 번 더 보는 비용은 0이고, 만료 직전 토큰을 들고 진입한 사용자에게 즉시 재로그인 화면을 띄울 수 있다.

📌 핵심: zustand persist 와 인증을 묶을 때는 hasHydrated 게이트가 사실상 필수다. hydration 직전 첫 렌더에서 비로그인으로 잠시 보였다가 hydration 후 로그인 상태로 바뀌는 깜빡임은 사용자 검토에서 항상 지적된다. onRehydrateStorage 콜백에서 hasHydrated=true 를 명시하는 한 줄로 끝난다.

zustand.docs.pmnd.rs

🛠️ 구현 4 — 모바일 우선 컨테이너 + 별도 앱 라우팅

apps/parent-report/src/App.tsx 가 모바일 우선 컨테이너를 강제한다. max-w-[430px] 컨테이너 안에 라우팅을 둬, 데스크탑에서도 같은 그릇으로 노출된다.

// apps/parent-report/src/App.tsx (핵심만 발췌)

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

  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="/login"  element={<LoginPage />} />
            <Route path="/signup" element={<SignupPage />} />
            <Route path="/"       element={<HomePage />} />
            <Route path="/children/:childId"          element={<ChildHomePage />} />
            <Route path="/children/:childId/report"   element={<ReportPage />} />
            <Route path="*" element={<Navigate to="/" />} />
          </Routes>
        </div>
      </div>
    </BrowserRouter>
  );
}

두 가지를 짚어둔다.

첫째, hydration 게이트가 인증 hook 보다 한 단계 위에 있다. Mock 모드와 인증 hydration 두 가지가 모두 끝난 다음에 라우트가 그려진다. Routes 안의 각 페이지가 다시 useAuthStore.isAuthenticated() 를 호출하지만, 첫 렌더에서 깜빡임을 차단하는 책임은 컨테이너 레벨에 둔다.

둘째, mx-auto max-w-[430px] 한 줄로 데스크탑 노출도 강제한다. 데스크탑에서 같은 URL 로 접근한 사용자는 양쪽 여백이 큰 모바일 그릇을 본다. 시안 의도가 명확했고, 보호자가 PC 로 접근하는 비중이 운영자 측 가설보다 낮아 받아들였다.

운영자 측 흐름은 한 화면이 추가된다. 회원 상세 페이지에 [초대 발급] 버튼이 생기고, 클릭 시 전화번호와 관계 입력 모달이 열린다. 발급 응답이 돌아오면 초대 URL 을 모달에서 바로 복사할 수 있다.

// apps/api/src/application/services/parent-invitation.application.service.ts (createInvitation 핵심)

async createInvitation(studentId, createdByUserId, academyId, dto) {
  const student = await this.prisma.student.findUnique({
    where: { id: studentId }, include: { academy: true },
  });
  if (!student) throw new NotFoundException('Student not found');
  if (student.academyId !== academyId) throw new ForbiddenException('Not authorized');

  const phone = dto.phone || student.parentPhone;
  if (!phone) throw new BadRequestException('Phone number is required');

  const token = nanoid(21);
  const expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + 7);

  const invitation = await this.prisma.parentInvitation.create({
    data: { token, studentId, phone, expiresAt, createdByUserId },
  });

  return {
    id: invitation.id,
    token: invitation.token,
    inviteUrl: `${this.INVITE_BASE_URL}?token=${token}`,
    phone,
    studentName: student.name,
    relation: dto.relation || 'PARENT',
    expiresAt: invitation.expiresAt.toISOString(),
  };
}

student.academyId !== academyId 체크 한 줄이 멀티 테넌트 격리의 마지막 방어선이다. 토큰을 가진 운영자가 다른 고객사의 회원을 대상으로 초대를 발급하는 흐름을 가드 레벨에서 차단한다.


📊 결과 — 같은 머지 사이클 4시간, BE 1,520줄 + FE Mock + FE 인증 연동

같은 dev 머지 사이클 약 4시간 안에 BE 1차 머지 → BE 명세 갱신 → FE Mock 머지 → FE 인증 연동 머지가 순차로 들어갔다.

$ git log --oneline 2026-02-01 -- 'apps/api/**' 'apps/parent-report/**' 'apps/academy-portal/**'
18:07  5f5a52ad  feat(be): 외부 뷰어 인증 + 초대 시스템 도입                (8 files +1,520)
20:50  edac062c  docs(pm): BE 완료 — 스키마/초대/인증 API
21:25  a8483e9d  feat(fe): 외부 뷰어 모바일 앱 신설 + Figma 동기화           (parent-report 앱 신규)
21:39  2aecda27  feat(fe): 외부 뷰어 초대 흐름 + 인증 통합                   (6 files +800 / -74)

본 머지 사이클의 핵심 지표를 한 표로 정리한다.

항목도입 단계비고
신규 Prisma 모델3건Parent / ParentStudent / ParentInvitation
enum 변경2건UserRolePARENT 추가 / ParentRelation 신설
신규 엔드포인트6건public 4 (초대 조회 / signup / login / refresh) + parent JWT 1 (me) + tenant JWT 1 (초대 발급)
신규 앱1건apps/parent-reportmax-w-[430px] 컨테이너
신규 FE 페이지5건login / signup / home / children / report
BE 변경 라인+1,520스키마 95 / 컨트롤러 348 / 서비스 700 / DTO 349 / 모듈 28
FE 변경 라인 (인증 연동)+800 / -74auth.store 신규 112 / signup 310 / login 127 / home 120 / Academy 측 초대 모달 156

발견된 흔들림 한 건을 별도로 둔다. ParentInvitation.phone 필드는 초대 발급 단계에서 명세 갱신이 한 번 발생했다 — 처음에는 phone, 사용자 검토 후 email 로 바꿨다가, 모바일 사용 컨텍스트가 분명해진 시점에 다시 phone 으로 되돌렸다. 본 머지 사이클 안에서 같은 필드를 3번 다시 잡은 셈이고, 마이그레이션 1회 + DTO 갱신 1회의 비용을 받았다. 일반화하면 “외부 인증의 식별자 결정은 사용자 검토 1회 안에 끝나지 않는다 — 모바일/데스크탑 컨텍스트의 마찰이 한 차례 더 발생할 가능성을 명세 단계에서 가정”이다.

ParentInvitation 의 토큰 만료 정책도 7일로 결정했다. 너무 짧으면 보호자가 초대 메시지를 놓치고, 너무 길면 만료된 회원을 외부 뷰어로 노출하는 위험이 커진다. 7일은 평균 보호자 재방문 주기보다 살짝 길게 잡은 값이고, 다음 머지 사이클에서 7일이 길다는 신호가 들어오면 3일로 단축할 여지를 응답 DTO 의 expiresAt 그대로 둔 상태로 남겼다.


🔄 회고 — 모바일 우선 결정은 옳았나, 별도 인증은 무거웠나

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

첫째, 별도 앱 분리(결정 1)는 옳았다. 모바일 단일 컬럼 디자인이 운영자 그리드와 거의 겹치지 않았다는 1차 판단이 적중했고, 같은 앱 안에서 두 그릇을 공존시켰을 때 발생했을 분기 비용이 사후에 발생하지 않았다. 모노레포 안에 앱 한 개가 늘어나는 비용은 받아들였고, 디자인 토큰 공유는 추후 packages/ui-tokens 분리로 풀 여지를 응답 트리에 남겨뒀다.

둘째, 별도 인증 시스템(결정 5)은 무거운 결정이었지만 옳았다. JWT 시크릿 2개를 운영하는 비용보다, 운영자 인증 흐름에 보호자 컨텍스트를 끼워 넣었을 때 발생했을 가드 분기 비용이 훨씬 컸다. 보호자는 고객사 소속이 아니라 academyId 가 null 이고, 운영자 인증의 모든 가드가 nullable academy 분기를 한 단계 더 받았어야 했다. 별도 인증 한 세트가 그 분기를 통째로 차단했다.

셋째, 회원가입 트랜잭션 단일화(결정 4)는 사후에도 그대로 유지됐다. 다음 머지 사이클의 v2 전환에서 회원가입 흐름 자체가 폐기됐지만, 단일 트랜잭션으로 4 write 를 묶은 패턴은 다른 도메인(재화 시스템 deposit/withdraw, 수강생 일괄 등록)에 그대로 재사용됐다. 회원가입 패턴 한 건이 팀 컨벤션으로 굳었다.

넷째, 그러나 보호자 회원가입 흐름 자체는 다음 머지 사이클에서 전면 폐기됐다. 사용자 검토 후 *“보호자가 매번 로그인하는 그릇 자체가 무겁다”*는 신호가 한 차례 들어왔고, 토큰 기반 공개 페이지로 갈아엎는 결정이 내려졌다. 본 머지의 Parent / ParentInvitation 모델과 모든 인증 흐름이 같은 주 안에 삭제됐고, 본 머지의 산출물 중 살아남은 것은 모바일 우선 컨테이너 컴포넌트와 4탭 라우팅 골자뿐이다. 도입 단계 머지가 그대로 살아남기를 기대하기 어렵다는 점에서, Mock-first 워크플로우 + 같은 머지 사이클 안의 분리된 BE/FE 머지가 갈아엎기 비용을 최소화한 가장 큰 결정이었다.

💡 인사이트: 도입 단계의 인증 시스템은 사용자 검토 한 차례에 갈아엎힐 가능성을 항상 가정해야 한다. 신규 외부 사용자(보호자/뷰어/파트너)의 그릇은 화면을 보고서야 결정되는 종류의 일이고, “회원가입 → 로그인 → 자녀 선택”이 너무 무겁다는 신호는 명세 단계에서 미리 잡기 어렵다. 폐기 비용을 받아들이기보다, Mock-first + 분리된 BE/FE 머지로 갈아엎기 단위를 작게 유지하는 게 사후 비용을 줄인다.


📋 정리 — 결정 표와 다음 편

#결정채택사후 평가
1별도 앱 분리 — apps/parent-report모바일 단일 컬럼 vs 데스크탑 그리드 분기 비용 차단 — 1차 판단 적중
2모바일 우선 컨테이너 max-w-[430px]데스크탑 노출도 같은 그릇 — 시안 의도 그대로 살아남음
33 신규 모델 + N:M 브릿지⚠️모델 자체는 깔끔했으나 v2 전환에서 전부 폐기 — 도입 단계 폐기 가능성 가정 필요
4회원가입 단일 prisma.$transaction(4 write)패턴 자체는 재화 시스템 / 일괄 등록에 그대로 재사용됨 — 팀 컨벤션 진입
5별도 JWT 시크릿 + 1h/30d + 전화번호 로그인⚠️격리는 옳았으나 흐름 자체가 v2 에서 폐기 — 시크릿 운영 항목 2개의 사후 비용 일부 잔존
6같은 머지 사이클 BE + FE Mock + FE 연동 — Mock-first갈아엎기 단위가 작게 유지됨 — v2 전환에서 남길 것/버릴 것이 명확히 분리됨

다음 편(devlog-55)에서는 본 머지 산출물의 절반 이상을 폐기한 v2 전환 — 토큰 기반 공개 페이지 도입 머지 “가장 길었던 하루” 의 1,490줄 갈아엎기, Parent/ParentStudent/ParentInvitation 삭제와 StudentReport 신설, 인증 흐름 전체 폐기의 결정·트레이드오프·갈아엎기 비용을 정리한다.

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