보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)
회원 레포트와 같은 데이터를 보호자(외부 뷰어) 그릇으로 옮기는 별도 앱 도입 머지. 모바일 우선 컨테이너(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.$transaction4 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 | 데스크탑 가용 면적 대부분이 양옆 여백이 됨. 시안 의도 그대로라 받아들임 |
| 3 | 3 신규 모델 — 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생성 → 외부 인증 콜백 → 관계 테이블 연결 순서로 진행되는 흐름이 가장 흔하게 새는데, 콜백 실패 후 재시도가 멱등하지 않으면 같은 사용자가 두 번 생성되는 더 비싼 사고가 난다.
🛠️ 구현 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 로 분기한다.
둘째, Parent 의 phone 에 @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 만 트랜잭션 안에 남는다.
🛠️ 구현 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 persist 로 localStorage 에 둔다. 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를 명시하는 한 줄로 끝난다.
🛠️ 구현 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건 | UserRole 에 PARENT 추가 / ParentRelation 신설 |
| 신규 엔드포인트 | 6건 | public 4 (초대 조회 / signup / login / refresh) + parent JWT 1 (me) + tenant JWT 1 (초대 발급) |
| 신규 앱 | 1건 | apps/parent-report — max-w-[430px] 컨테이너 |
| 신규 FE 페이지 | 5건 | login / signup / home / children / report |
| BE 변경 라인 | +1,520 | 스키마 95 / 컨트롤러 348 / 서비스 700 / DTO 349 / 모듈 28 |
| FE 변경 라인 (인증 연동) | +800 / -74 | auth.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] | ✅ | 데스크탑 노출도 같은 그릇 — 시안 의도 그대로 살아남음 |
| 3 | 3 신규 모델 + 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. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
- 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
- 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
- 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
- 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
- 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
- 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
- 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
- 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로