교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
교육과정 도메인 BE 완성 머지 직후 7 시간 안에 7 건 핫픽스가 묶여 배포된 하루를 정리한다. Class API 폴백 사슬·level displayName 11 곳·login response teacherId 까지는 기능 보완이었지만, 06:00 킥오프 배치가 2 중 실행되며 회원당 과제 2 개가 생성된 사고는 NestJS ScheduleModule.forRoot() 중복 등록의 고전 함정이었다. forRoot 단일화·배치 1.5 단계 만료 로직·Student Home 조회 가드 세 방어 층을 같은 머지에 묶어 1,502 줄 세션 로그가 끝났다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 교육과정 도메인 BE 완성 머지가 02:00 에 끝났는데 같은 날 17:00 까지 핫픽스 7 건이 묶여 같이 배포됐다
- NestJS
ScheduleModule.forRoot()중복 등록으로 06:00 킥오프 배치가 3 ms 간격으로 두 번 발화 — 회원 20 명에게 과제가 40 개 생성됐다- 고전 함정이다 —
EventModule(@Global)과ApplicationModule두 곳에서forRoot()를 호출하면 모든@Cron작업이 두 번 등록된다- 방어 3 층으로 닫았다 —
forRoot()단일 등록 + 배치 1.5 단계의 잔류 ACTIVE 일괄 만료 + Student Home 조회의scheduledAt가드- 누적 데이터 정리까지 같이 — 1/23 ~ 2/4 잔류 ACTIVE 과제 226 개를 일괄 EXPIRED 처리하며 한 줄짜리 누락이 한 달 데이터를 흐트러뜨린 흔적을 닫았다
🎯 배경 — 교육과정 도메인 완성과 운영자 식별 정정이 한 머지에 묶였다
직전 편 (devlog-66) 에서 교육과정 자동 승급의 도메인 버그 3 건 — Class 3 필드 동기화 누락·getEffectiveCurriculum 폴백 누락·배치고사 건너뛰기의 currentLevelId 폴백 — 을 한 사슬로 풀었다. 본 머지는 그 직후 같은 변경 명세서 묶음의 마지막 한 영역, 교육과정 도메인 BE 완성과 운영자 식별 정정 을 같이 끝낸 하루를 정리한다.
배경에는 두 흐름이 같이 흘렀다. 첫째 — 교육과정 도메인의 BE 완성. 직전 편이 폴백 사슬을 4 단으로 재정렬했다면, 본 머지는 그 폴백을 실제로 클라이언트가 조회하는 두 API 응답에 명시한다. 반 상세 API 가 curricula 테이블이 비어 있어도 Class 3 필드를 폴백 출처로 동작해야 하고, 회원 상세 API 는 curriculumCurrentTargetLevel 한 필드를 새로 만들어 “현재 진행중인 목표 레벨”의 표시명까지 같이 반환해야 한다. 폴백 사슬을 코드 안에 만들어 둬도 API 응답이 한 단을 빼먹으면 클라이언트는 그 단을 본 적이 없는 상태가 된다.
둘째 — 운영자 식별 정정. 본 머지 이전 운영자 (TEACHER 역할) 가 반을 생성할 때 프론트엔드는 자기 자신을 “이름이 같은 운영자” 로 찾고 있었다. teachers.find(t => t.name === identity.name) 한 줄. 이름이 같은 운영자가 두 명이면 잘못된 식별이 일어난다. 본 머지에서 GET /academy/auth/me 엔드포인트를 새로 만들고, 로그인 응답에 teacherId 한 필드를 더해 이름 매칭이라는 안티패턴 자체를 제거한다.
이 두 흐름은 따로 보면 사소한 보완이지만, 같이 묶어 02:00 ~ 02:23 사이에 BE 머지가 끝난 뒤로 핫픽스가 연달아 터졌다. Class API 폴백 응답이 displayName 대신 레벨 코드 (Lxx 형식 식별자) 를 반환하던 사고, 레벨 displayName 11 곳에서 같은 select 누락이 누적돼 있던 사고, 그리고 결정타가 06:00 — 킥오프 배치가 두 번 발화하며 회원 20 명에게 과제가 두 개씩 생성된 사고다. 마지막 사고를 추적하니 근본 원인이 ScheduleModule.forRoot() 의 중복 등록이었고, 거기서 누적 데이터까지 손봐야 한다는 게 드러났다 — 1/23 ~ 2/4 기간 동안 만료되지 않고 살아남은 ACTIVE 과제가 226 개.
📌 핵심: 본 머지는 “교육과정 도메인 BE 완성” 이라는 마일스톤 한 줄로 시작했지만, 실제 배포 게이트를 닫기까지 API 폴백·표시명·운영자 식별·크론 중복 등록·잔류 데이터 정리 다섯 영역을 같이 손봐야 했다. 마일스톤의 무게는 “무엇을 새로 만들었나” 가 아니라 “같이 묶어 닫은 회귀의 폭” 에 있다.
본 글은 그 7.5 시간 묶음 세션을 한 마일스톤의 6 결정으로 다시 묶어 정리한다. 핫픽스 순서대로 나열하면 시간순 트러블슈팅이 되겠지만, B 톤 구현기의 본진은 왜 이렇게 묶어 결정했는가 에 있다.
⚖️ 설계 결정 6 건 — 폴백 사슬·표시명·식별·크론 단일 등록
본 머지에서 내린 6 결정을 한 표로 정리한다.
| 결정 | 무엇을 | 트레이드오프 |
|---|---|---|
| 1. Class API 폴백 사슬 | curricula 테이블이 비어 있으면 Class 3 필드 (curriculumCurrentTargetId · curriculumTargetLevelIds · curriculumCompletedLevelIds) 를 폴백 출처로 사용 | 응답 쿼리가 분기 한 단계 늘어남 vs 같은 의미를 두 곳에 두는 도메인 모델 부채 |
2. curriculumCurrentTargetLevel 신규 필드 | 회원 상세 응답에 “현재 진행중 목표 레벨” 의 levelId·levelName·displayName·grade·semester 묶음 한 필드 추가 | DTO 표면적 증가 vs FE 가 ID 만 받고 다른 API 로 displayName 조회하는 N+1 회피 |
| 3. Level displayName 일괄 통일 | 시스템 전반 11 곳 select/levelMap 에 description 추가 + description ‖ name 폴백 적용 | 11 곳 일괄 수정의 회귀 위험 vs UI 마다 코드 (Lxx 표기) 가 표시명으로 누출되는 안티패턴 차단 |
4. /auth/me + 로그인 응답 teacherId | TEACHER 역할 사용자가 자신의 Operator.id (CUID) 를 안전하게 받게 — 기존 “이름 매칭” 안티패턴 폐기 | DTO 한 필드 추가 + 엔드포인트 신설 vs 동명이인 운영자가 한 명만 있어도 잘못된 식별 폭발 |
5. ScheduleModule.forRoot() 단일 등록 | EventModule(@Global) 한 곳에서만 등록, ApplicationModule 의 호출을 제거 | 모듈 의존성 직관 (각 모듈이 자기 크론을 import) 상실 vs @Global 한 곳에서 한 번만 등록하는 NestJS 공식 패턴 일치 + 모든 @Cron 의 단일 실행 보장 |
| 6. 배치 1.5 단계 만료 + Student Home 가드 | 킥오프 배치가 새 과제 생성 직전에 잔류 ACTIVE 를 일괄 EXPIRED 처리 + Student Home API 의 ACTIVE 조회에 scheduledAt >= today 한 줄 가드 | 배치 한 단계 더 비용 vs 한 단의 누락이 한 달치 잔류 ACTIVE 226 개로 누적되는 운영 부채 |
이 표가 본 머지의 본진이다. 각 결정은 “기능 추가” 가 아니라 “두 곳의 같은 의미를 한 줄로 정렬” 이라는 같은 모양을 띤다. 폴백 사슬은 두 저장 위치의 정렬, displayName 일괄 통일은 11 곳의 같은 select 정렬, teacherId 신설은 User 와 Operator 두 식별자의 정렬, forRoot() 단일 등록은 두 모듈의 같은 스케줄 등록의 정렬이다. 마일스톤 한 머지의 무게는 정렬한 어휘의 수에 비례한다.
⚖️ 인사이트: 마일스톤 머지는 “기능 한 줄 추가” 가 아니라 “같이 정렬한 어휘 N 줄” 로 측정한다. 본 머지는 어휘 6 줄을 정렬했고, 그래서 7 건 핫픽스가 한 묶음으로 닫혔다.
🛠️ 구현 — 핵심 결정 6 건의 코드 단편
각 결정의 핵심 코드를 인용한다. 7 건 핫픽스의 커밋 해시는 본문 표에 같이 표기한다.
1. Class API 폴백 사슬 — curricula 비어 있어도 동작 (커밋 44ac015)
문제는 curricula 테이블 (legacy 1:1 관계) 에 레코드가 없는 반에서 발생했다. 반 생성 직후 또는 마이그레이션 이전 데이터는 curricula 가 비어 있고, 같은 의미가 Class 테이블의 3 필드 (curriculumCurrentTargetId·curriculumTargetLevelIds·curriculumCompletedLevelIds) 에만 들어 있다. 기존 코드는 curricula[0] 하나만 봤기 때문에 신규 반 상세 응답의 교육과정 필드가 전부 빈 배열이었다.
// apps/api/src/application/services/academy-class.application.service.ts
// Before — curricula 테이블만 조회
const curriculum = cls.curricula[0];
const targetLevelIds = curriculum?.targetLevelIds ?? [];
const currentTargetIdx = curriculum?.currentTargetIdx ?? 0;
const currentTargetLevelId =
targetLevelIds[currentTargetIdx] ?? targetLevelIds[0] ?? null;
// After — Class 테이블 3 필드 폴백 추가
const curriculum = cls.curricula[0];
let targetLevelIds: number[];
let currentTargetIdx: number;
let currentTargetLevelId: number | null;
if (curriculum && curriculum.targetLevelIds.length > 0) {
// curricula 테이블에서 조회 (레거시 호환)
targetLevelIds = curriculum.targetLevelIds;
currentTargetIdx = curriculum.currentTargetIdx;
currentTargetLevelId =
targetLevelIds[currentTargetIdx] ?? targetLevelIds[0] ?? null;
} else {
// Class 테이블 3 필드 폴백
const current = cls.curriculumCurrentTargetId;
const pending = cls.curriculumTargetLevelIds ?? [];
const completed = cls.curriculumCompletedLevelIds ?? [];
// 전체 목표 레벨 = completed + current + pending
targetLevelIds = [
...completed,
...(current ? [current] : []),
...pending,
];
currentTargetIdx = completed.length;
currentTargetLevelId = current;
}
결정의 무게는 한 분기 추가 (if (curriculum && curriculum.targetLevelIds.length > 0)) 가 아니라, 두 저장 위치의 명시적 우선순위 다. curricula 가 우선, 비어 있으면 Class 3 필드. 두 위치 모두 비어 있으면 currentTargetLevelId = null 로 떨어진다. 이 우선순위 한 줄이 “같은 의미를 두 곳에 적어 둔 도메인 모델” 의 운영 부채를 명문화한다.
2. curriculumCurrentTargetLevel 신규 필드 — 표시명까지 한 응답에 (커밋 f4fc6fb)
회원 상세 응답이 curriculumCurrentTargetId: 12 만 줬고, FE 는 그 ID 의 displayName 을 따로 조회해야 했다. N+1 회피와 같은 머지에서 levelId·levelName·displayName·grade·semester 묶음 한 필드를 신설했다.
// apps/api/src/application/services/academy-student.application.service.ts
// getStudentById() 내부
let curriculumCurrentTargetLevel = null;
if (student.curriculumCurrentTargetId) {
const currentLevel = await this.prisma.level.findUnique({
where: { id: student.curriculumCurrentTargetId },
select: { id: true, name: true, description: true, semesterStr: true },
});
if (currentLevel) {
const parsed = this.parseSemester(currentLevel.semesterStr);
curriculumCurrentTargetLevel = {
levelId: currentLevel.id,
levelName: currentLevel.name,
displayName: currentLevel.description || currentLevel.name,
grade: parsed.grade,
semester: parsed.semester,
};
}
}
description || name 폴백 한 줄이 3 번 결정 (displayName 일괄 통일) 의 시드다. description 이 NULL 인 레벨이 한 줄이라도 있으면 name 으로 떨어진다.
3. Level displayName 일괄 통일 — 11 곳의 같은 누락 (커밋 0f30612)
운영자 페이지의 여러 화면에서 레벨이 코드 (L1, L2) 로 표시되고 있었다. 원인은 단일한 누락이 아니라 같은 패턴이 11 곳에서 반복된 것이었다. 모든 Level 조회 쿼리가 select: { id: true, name: true } 만 했고, levelMap 도 new Map(levels.map(l => [l.id, l.name])) 만 만들었다. 한 곳을 잡았더니 같은 패턴이 11 곳에서 더 발견됐다.
// 11 곳 공통 수정 패턴
// Before
select: { id: true, name: true }
const levelMap = new Map(levels.map((l) => [l.id, l.name]));
// After
select: { id: true, name: true, description: true }
const levelMap = new Map(
levels.map((l) => [l.id, l.description || l.name]),
);
수정된 파일 11 곳:
academy-assignment.application.service.ts (1 곳)
academy-class.application.service.ts (3 곳)
academy-settings.application.service.ts (1 곳)
academy-statistics.application.service.ts (2 곳)
academy-student-report.application.service.ts (2 곳)
level-adjustment.application.service.ts (1 곳)
student-onboarding.application.service.ts (1 곳)
결정의 무게는 일괄 치환 자체가 아니라 “한 곳에서 잡지 말고 그립으로 11 곳을 같이 잡자” 라는 판단이었다. 한 화면씩 보고하면 11 번 핫픽스가 나간다. 한 그립으로 잡으면 한 머지로 닫힌다. NestJS 도메인 코드를 일괄 변경할 때는 공식 문서가 권장하는 “한 도메인 어휘 = 한 셀렉터” 원칙 을 적용해야 한다. 이 원칙은 NestJS 의 Custom providers 챕터의 “공통 의존성을 한 곳에서 제공한다” 와 같은 결을 띤다.
4. /auth/me 엔드포인트 + 로그인 응답 teacherId (커밋 0a3632e, 81ce3ca)
기존 로그인 응답에는 user.id (User 테이블 PK) 만 있었고 TEACHER 역할 사용자의 Operator.id (Operator 테이블 PK, CUID) 는 없었다. FE 는 운영자 자신을 식별하려면 운영자 목록을 한 번 조회해서 이름으로 매칭해야 했다.
// Before (auth-provider.ts) — 이름 매칭 안티패턴
useEffect(() => {
if (!isOwner && identity?.name && teachers.length > 0) {
const myTeacher = teachers.find((t) => t.name === identity.name);
if (myTeacher) {
setForm((prev) => ({ ...prev, teacherId: myTeacher.id }));
}
}
}, [isOwner, identity?.name, teachers]);
동명이인 운영자가 한 명만 있어도 잘못된 식별이 일어난다. 해결은 두 단계 였다.
먼저 /academy/auth/me 엔드포인트를 신설해 현재 사용자의 모든 식별 정보를 한 응답에 모았다 (커밋 0a3632e).
// apps/api/src/application/services/academy-auth.application.service.ts
// AcademyMeDto 신규 응답
{
id: user.id,
email: user.email,
name: userName,
role: user.role,
academyId: user.academyId,
teacherId: user.teacher?.id || null, // TEACHER 역할일 때만 채워짐
}
그리고 로그인 응답 자체에도 같은 teacherId 한 필드를 추가했다 (커밋 81ce3ca).
// AcademyLoginUserDto — 로그인 응답
return {
success: true,
accessToken,
user: {
id: user.id,
email: user.email || '',
name: userName,
role: user.role as 'ACADEMY_OWNER' | 'TEACHER',
academyId: user.academyId!,
academyName: user.academy?.name || '',
+ teacherId: user.teacher?.id || null, // 추가 — TEACHER 일 때만 채움
},
mustChangePassword: user.mustChangePassword,
};
FE 의 getIdentity() 도 같이 정렬해서 identity.teacherId 한 줄로 직접 사용한다.
// After (auth-provider.ts) — teacherId 직접 사용
useEffect(() => {
if (!isOwner && identity?.teacherId) {
setForm((prev) => ({ ...prev, teacherId: identity.teacherId }));
}
}, [isOwner, identity?.teacherId]);
결정의 무게는 “인증 응답에 도메인 식별자를 같이 담을 것인가” 다. User 와 Operator 는 1:1 관계지만 PK 가 다르다. 한 응답에 둘을 같이 담으면 “User 인증 + Operator 신원” 한 묶음을 클라이언트가 한 번에 받는다. 이 결정이 없으면 FE 마다 운영자 목록을 한 번 더 조회하는 N+1 의 변형이 발생한다.
5. ScheduleModule.forRoot() 단일 등록 — NestJS 의 고전 함정 (커밋 81ce3ca)
06:00 KickoffBatch 가 두 번 발화한 진짜 원인은 ScheduleModule.forRoot() 가 두 모듈에서 호출된 것이었다. NestJS Schedule 의 공식 문서에 따르면 forRoot() 는 “앱 전체에 단 한 번” 등록해야 한다.
문제의 두 모듈은 다음과 같다.
// events/event.module.ts (Line 108)
@Global()
@Module({
imports: [
ScheduleModule.forRoot(), // OutboxPublisher @Cron(EVERY_5_SECONDS) 위해
// ...
],
})
export class EventModule { }
// application/application.module.ts (Line 154)
@Module({
imports: [
DomainModule,
MetricModule,
AttendanceModule,
ScheduleModule.forRoot(), // ← 두 번째 등록 — BatchProcess @Cron 위해
],
})
export class ApplicationModule { }
forRoot() 가 두 번 호출되면 NestJS 가 모든 @Cron 데코레이터를 두 번 등록한다. 결과 — 0 6 * * * 크론이 06:00:00.010 과 06:00:00.013 두 번 발화했다. 3 ms 간격으로. 회원당 과제가 2 개씩 생성됐고, 그날 아침 운영자 대시보드가 “오늘 완료” 와 “오늘 진행중” 두 상태를 동시에 보고했다.
해결은 한 줄이었다.
// apps/api/src/application/application.module.ts
@Module({
imports: [
DomainModule,
MetricModule,
AttendanceModule,
- ScheduleModule.forRoot(),
+ // ScheduleModule.forRoot() 는 EventModule(@Global) 에서 이미 등록됨 — 중복 제거 (2 중 크론 실행 방지)
],
})
결정의 무게는 “한 줄 삭제” 가 아니라 “왜 EventModule 한 곳만 남기는가” 의 명문화다. EventModule 은 @Global() 모듈이고, NestJS 공식 문서가 권장하는 “공통 인프라성 모듈은 한 곳에서 등록한다” 패턴과 일치한다. ApplicationModule 도 크론이 필요하지만 — @Global() 으로 등록된 ScheduleModule 은 import 없이 모든 모듈에서 @Cron() 데코레이터가 동작한다. 두 번째 forRoot() 는 처음부터 필요 없었다.
6. 배치 1.5 단계 만료 + Student Home 가드 — 두 층 방어 (커밋 eee8ead)
5 번 결정으로 코드 측 중복 등록은 닫혔다. 하지만 데이터 측 잔류 ACTIVE가 남았다. 1/23 부터 2/4 까지 12 일 동안 누적된 미만료 ACTIVE 과제가 226 개.
근본 원인은 별도였다 — 킥오프 배치가 새 과제를 생성할 때 이전 날짜의 미시작 ACTIVE 과제를 만료시키지 않았다. 기존 findExpiredAssignments 는 startedAt 이 있는 (시작은 했지만 시간 초과된) 과제만 처리했고, 미시작 상태로 남은 ACTIVE 는 한 달 동안 그대로 살아남았다.
// apps/api/src/application/services/batch-process.application.service.ts
// 시간 초과 만료 단계 와 과제 생성 단계 사이에 중간 단계 1.5 신규 추가
// ========== 중간 단계 1.5: 과거 ACTIVE 과제 일괄 만료 처리 ==========
// findExpiredAssignments 는 startedAt 이 있는 시간 초과 과제만 처리.
// 과거 날짜의 미시작 (startedAt = null) ACTIVE 과제도 EXPIRED 처리 필요.
const today = new Date();
today.setHours(0, 0, 0, 0);
const staleExpired = await this.prisma.assignment.updateMany({
where: {
status: 'ACTIVE',
scheduledAt: { lt: today },
},
data: { status: 'EXPIRED', completedAt: new Date() },
});
if (staleExpired.count > 0) {
this.logger.log(
`[KickoffBatch] 중간 단계 1.5 - Expired ${staleExpired.count} stale ACTIVE assignments (scheduledAt < today)`,
);
// 해당 과제의 IN_PROGRESS 번들 → INCOMPLETE
const staleBundles = await this.prisma.bundle.updateMany({
where: {
assignment: {
status: 'EXPIRED',
scheduledAt: { lt: today },
},
status: 'IN_PROGRESS',
},
data: { status: 'INCOMPLETE' },
});
if (staleBundles.count > 0) {
this.logger.log(
`[KickoffBatch] 중간 단계 1.5 - Set ${staleBundles.count} IN_PROGRESS bundles to INCOMPLETE`,
);
}
}
Student Home API 에도 같은 결의 한 줄을 더했다. 배치가 만료시키기 전이라도, 조회 시점에서 “오늘 이후 scheduledAt” 조건을 한 줄 더하면 잔류 ACTIVE 가 클라이언트에 노출되지 않는다.
// apps/api/src/application/services/student-home.application.service.ts
// Before
assignments: {
where: {
status: 'ACTIVE',
},
// ...
}
// After — scheduledAt 가드 추가
assignments: {
where: {
status: 'ACTIVE',
scheduledAt: { gte: this.getStartOfDay() },
},
// ...
}
이 두 층이 결합되면 방어 깊이가 다르다. 배치는 데이터 측 정리 를, 조회 가드는 API 측 노출 차단 을. 한 층이 누락돼도 다른 층이 받는다.
🛡️ 인사이트: 데이터 부채 정리 는 항상 두 층으로 잡는다. 한 층은 원천 배치 에서 잔류를 만료시키고, 다른 층은 조회 시점 에서 노출을 차단한다. 두 층은 같은 의미 를 다른 측면에서 닫는다.
📊 결과 — 7.5 시간 묶음 세션의 측정값
본 머지 BE 4 commits + 핫픽스 7 commits + FE 5 commits + 운영 DB 정리 1 회로 닫혔다. 측정 가능한 결과를 표로 모은다.
| 영역 | 변경 전 | 변경 후 |
|---|---|---|
| Class API 폴백 경로 | curricula 한 단 — 비어 있으면 빈 응답 | 두 단 (curricula → Class 3 필드) |
| 회원 상세 응답 필드 | curriculumCurrentTargetId (ID 만) | curriculumCurrentTargetLevel 묶음 (levelId·levelName·displayName·grade·semester) |
| Level displayName 셀렉터 | 11 곳에서 name 만 — L1, L2 노출 | 11 곳 통일 — description ‖ name 폴백 |
| 운영자 식별 경로 | FE 이름 매칭 (teachers.find(t.name === identity.name)) | identity.teacherId 직접 사용 + /auth/me 엔드포인트 |
ScheduleModule.forRoot() 호출 수 | 2 회 (EventModule + ApplicationModule) | 1 회 (EventModule(@Global) 만) |
@Cron 실행 횟수 (06:00 KickoffBatch) | 2 회 (3 ms 간격 중복 발화) | 1 회 |
| 회원당 과제 생성 수 (2/5 사고일) | 2 개 (id 306·307, id 308·309 …) | 1 개 (정상) |
| 잔류 ACTIVE 과제 (1/23 ~ 2/4 누적) | 226 개 | 0 개 (중간 단계 1.5 일괄 EXPIRED 후) |
| 잔류 IN_PROGRESS 번들 | 25 개 | 0 개 (INCOMPLETE 일괄 전환 후) |
| Student Home API 잔류 ACTIVE 노출 | 가능 — status: 'ACTIVE' 단일 조건 | 차단 — scheduledAt >= today 가드 추가 |
추가로 같은 머지에 묶인 부수 효과 2 건도 같이 정리한다.
| 보너스 | 무엇을 |
|---|---|
반 목록 인사이트 2 필드 (커밋 ece8a4a) | todayCompleted (오늘 완료 수/전체) + weeklyAttendanceRate (주간 출석률 %) 한 응답에 같이 |
회원 목록 활동 상태 (커밋 78f7c0b) | todayStatus (COMPLETED ‖ IN_PROGRESS ‖ NOT_STARTED) + avgAccuracy (최근 30 일 평균 정확도) 한 응답에 같이 |
이 두 부수 효과는 본 머지의 결정 표에 안 들어갔지만 — 같은 결의 “같은 의미를 두 API 응답에 같이 담는다” 라는 모양으로 묶이는 작업이다. 운영자 대시보드가 반 목록 한 화면에서 “오늘 완료 3/5 + 주간 출석률 85%” 같은 인사이트를 직접 읽을 수 있게 됐다.
도식 한 장에 본 머지의 흐름을 모았다. BEFORE (이중 등록) → 타임라인 (7 시간 핫픽스 파동) → AFTER (단일 등록 + 방어 3 층) 의 3 단 구성이다.

🔄 회고 — 다음에 같은 결정을 한다면
본 머지의 6 결정 중 회고할 만한 4 가지를 정리한다.
A. “폴백 사슬은 명문화 vs 통합” — 통합이 정답이었나
curricula 테이블과 Class 3 필드 둘 다에 같은 의미를 적어 둔 것 자체가 도메인 모델의 부채다. 본 머지는 “폴백 사슬로 두 위치를 명시적으로 정렬” 하는 길을 택했지만, 다음 머지의 방향은 두 위치 중 하나를 원본으로 고정하고 다른 하나를 프로젝션으로 강등하는 것이다. 직전 편 (devlog-66) 의 마무리에서도 “세 모델의 이중 저장 자체를 한 곳으로 통합하는 방안 C” 를 다음 회차로 미뤘다. 본 머지의 폴백 사슬은 그 통합 전의 임시 정렬 이다.
B. “표시명 일괄 통일 vs 셀렉터 한 곳” — 11 곳을 같이 잡은 보람
11 곳을 같이 잡지 않았다면 — 한 화면씩 보고가 올 때마다 한 곳씩 핫픽스가 나갔을 것이다. 한 그립으로 11 곳을 잡은 결정은 옳았다. 하지만 “왜 11 곳에서 같은 누락이 반복됐는가” 는 별도 회고가 필요하다. 답은 “Level 조회 셀렉터가 도메인 공통 함수로 모이지 않고 11 곳에서 따로 작성됐다” 다. 다음 라운드는 getLevelDisplayMap(prisma, levelIds) 한 함수로 모아 11 곳이 다 그 함수를 호출하는 모양으로 정리한다. 이때 NestJS 가 권장하는 Custom Providers 의 결을 따른다.
C. “ScheduleModule.forRoot() 단일 등록” — 더 일찍 잡지 못한 이유
ScheduleModule.forRoot() 가 두 곳에서 호출됐던 시점은 12 일 전이었다. 12 일 동안 매일 회원당 과제가 2 개씩 생성됐는데도, “오늘 완료” 와 “오늘 진행중”의 동시 보고 사고는 사고일 (2/5) 에야 터졌다. 운영자 대시보드의 데이터 일관성 모니터링이 부재했다는 신호다. 다음 라운드는 “같은 회원이 같은 날 ACTIVE 와 COMPLETED 둘 다 가지는 모순” 을 자동 검사하는 단순한 단언 한 줄을 매일 03:00 에 돌린다.
D. “잔류 226 개 일괄 EXPIRED” — 데이터 정리의 책임
코드 측 수정 (중간 단계 1.5) 만으로는 누적 226 개가 자동 정리되지 않았다. 운영 DB 에 직접 updateMany 를 한 번 돌린 수동 정리 가 추가로 필요했다. 다음 라운드는 “코드 측 수정과 같은 머지에 데이터 측 정리 스크립트를 같이 동봉” 하는 패턴으로 정착한다. NestJS 의 Migration 챕터는 스키마 변경의 동봉 패턴만 다루지만, 데이터 정합성 수정 도 같은 결로 동봉돼야 한다.
📋 정리 — 마일스톤 한 줄이 7 건 핫픽스로 닫힌 이유
| 결정 영역 | 안티 패턴 | 권장 패턴 |
|---|---|---|
| 두 저장 위치의 의미 | ❌ 한 위치만 조회, 비어 있으면 빈 응답 | ✅ 명시적 폴백 사슬 (curricula → Class 3 필드) — 임시 정렬 후 다음 라운드 통합 |
| 표시명 vs 코드 | ❌ 11 곳에서 같은 select: { name } 반복 | ✅ 셀렉터 한 곳에 모음 + description ‖ name 폴백 |
| 도메인 식별자 | ❌ FE 가 이름 매칭으로 운영자 식별 | ✅ 인증 응답에 도메인 식별자 같이 담음 (teacherId 한 필드) |
ScheduleModule.forRoot() | ❌ 모듈마다 forRoot() 호출 — 직관적이지만 @Cron 2 중 등록 | ✅ @Global() 한 곳에서만 등록 — NestJS 공식 패턴 |
| 데이터 부채 정리 | ❌ 코드 수정만 머지, 누적 잔류 방치 | ✅ 두 층 방어 — 배치 측 일괄 만료 + 조회 가드 + 운영 DB 일괄 정리 |
| 단일 사고 vs 같은 결의 묶음 | ❌ 핫픽스 한 건씩 따로 머지 | ✅ 마일스톤 한 머지로 묶음 — 7 건의 같은 결을 한 게이트에서 닫음 |
본 머지의 핵심은 “교육과정 도메인 BE 완성” 한 줄이 아니라 같은 결의 어휘 6 줄을 같이 정렬한 점이다. 폴백 사슬·표시명·식별자·크론 등록·데이터 부채 — 다섯 영역의 “두 곳에 흩어진 같은 의미” 를 한 머지에 같이 정렬했다. 마일스톤 한 줄의 무게는 새로 만든 코드의 줄 수 가 아니라 같이 닫은 회귀의 폭 으로 측정한다.
NestJS ScheduleModule.forRoot() 의 중복 등록은 고전적인 함정이다. 공식 문서는 “forRoot() 는 앱 전체에 한 번만 등록” 이라고 명시한다 — 하지만 모듈 분리가 진행되면 각 모듈이 자기 인프라를 자기가 import 하는 직관과 충돌한다. 이 충돌을 푸는 정답은 @Global() 모듈 한 곳에서만 등록하고 나머지 모듈은 import 없이 @Cron 데코레이터를 사용하는 것이다.
다음 편 (devlog-68 예정) 에서는 세 모델의 이중 저장 자체를 한 곳으로 통합 하는 도메인 회고 회차로 이어진다. 본 머지가 두 위치의 임시 정렬이었다면, 다음 편은 원본 위치 한 곳을 고정 하는 도메인 모델 통합기다. 같은 결의 어휘 정렬이 한 단계 더 깊이 들어간다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
- 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초로
- 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
- 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
- 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
- 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
- 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
- 60. react-hook-form + Zod 폼 표준 정착기
- 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
- 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
- 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음