교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
그룹 교육과정에 *레벨 25* 를 지정해 둔 회원이 *배치고사 건너뛰기* 직후 *레벨 15* 로 배정된 사고. 표면 증상은 하나였지만 진짜 원인은 *Class 동기화 누락 / getEffectiveCurriculum 폴백 누락 / 배치고사 건너뛰기의 currentLevelId 폴백* 세 도메인 버그가 한 사슬을 이루고 있던 점이었다. 폴백 4 단으로 재정렬하고 자동 승급 메서드 두 개를 들여 *EXCELLENT 3 일 + Top 도달* 조건을 한 흐름에 묶은 A 톤 트러블슈팅을 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 증상 — 그룹 교육과정에 레벨 25 를 지정해 둔 회원이 배치고사 건너뛰기 후 레벨 15 로 배정. 그룹 설정은 분명히 25 였고, 응답에는 15 가 들어 있었다.
- 표면 가설 (모두 탈락) — 그룹 생성 입력 누락 / 배치고사 알고리즘 버그 / 회원 생성 시 currentLevel 계산 오류. 세 가설 모두 데이터로 반증.
- 진짜 원인 — 세 도메인 버그가 한 사슬. (1) 그룹 생성 시
Curriculum테이블에만 저장하고Class3 필드는 비어 있음 (2)getEffectiveCurriculum이Curriculum테이블을 참조하지 않고 분기 기본값 으로 폴백 (3) 배치고사 건너뛰기가 그 기본값 을 그대로currentLevelId에 그대로 할당.- 해결 — 폴백을
Member → Class 필드 → Curriculum 테이블 → 분기 기본값4 단으로 재정렬하고, 그룹 생성·수정 시Class3 필드를 명시적으로 동기화. 회원 생성도 그룹 교육과정을 회원 필드로 복사 하는 경로를 추가.- 자동 승급 연동 —
checkCurriculumAdvancement+advanceCurriculumTarget두 도메인 메서드를 들여, Top 도달 + EXCELLENT 3 일 연속 조건이 레벨 상승 직후 자동 발화하도록handlePostUpgradeAdvancement한 훅으로 묶었다.- 재발 방지 — Jest 폴백 5 케이스 테스트 + 폴백 출처 로그 (
source: 'CLASS_FIELDS' | 'CURRICULUM_TABLE' | 'SEMESTER') + 그룹 교육과정을 직접 PATCH 하지 못하게append-targets·reset액션 API 로 입구 제한.
🌱 배경 — 두 도메인이 같은 의미를 두 곳에 적어 두고 있었다
본 사고 직전까지 교육과정 목표 레벨 의 저장 위치는 세 곳 으로 갈라져 있었다.
// schema.prisma — 사고 직전 (Soft Delete 머지 직후)
model Member {
id String @id @default(cuid())
currentLevelId Int? // 현재 학습 중인 레벨
curriculumCurrentTargetId Int? // 현재 도달해야 할 Top 레벨
curriculumTargetLevelIds Int[] // 대기 목표 Top 배열
curriculumCompletedLevelIds Int[] // 완료 목표 Top 배열
// ...
}
model Class {
id Int @id @default(autoincrement())
curriculumCurrentTargetId Int? // ← Member 와 동일한 의미를 또 가짐
curriculumTargetLevelIds Int[]
curriculumCompletedLevelIds Int[]
curricula Curriculum[] // ← 레거시 1:N 테이블
// ...
}
model Curriculum {
id Int @id @default(autoincrement())
classId Int
targetLevelIds Int[] // ← 또 같은 의미
currentTargetIdx Int @default(0)
targetDate DateTime?
class Class @relation(fields: [classId], references: [id])
}
같은 의미를 세 모델 이 같이 적고 있었다. 도메인 모델링 관점에서 명백한 이중 저장 이지만, 마이그레이션 비용 때문에 미뤄 두고 폴백 로직으로 덮고 있던 상황이었다.
폴백 로직은 Member → Class 필드 두 단 만 있었다. Curriculum 테이블은 과거 운영자가 만든 데이터 가 일부 남아 있었지만, 조회 경로에 연결되어 있지 않은 채 방치돼 있었다.
📌 핵심: 같은 의미를 두 곳에 적어 두면 반드시 둘 사이가 어긋나는 시점 이 온다. 그 사이가 읽기 경로 로 가려질 때 원본은 데이터 안에 분명히 있지만 읽지 못하는 사고가 발생한다. 본 사고가 정확히 이 패턴이었다.
🔥 증상 — 그룹에 25 를 지정해 둔 회원이 15 로 시작했다
운영자 한 명이 새벽 메시지를 보냈다.
“고객사 Owl Academy 에 레벨 25 시작 그룹 을 새로 만들고 회원 Min 을 등록했습니다. 배치고사 건너뛰기 를 눌렀더니 레벨 15 로 시작했습니다. 그룹 설정은 25 가 분명한데요.”
증상은 한 줄이었다. 그룹 교육과정 25 → 회원 시작 레벨 15.
# 운영 DB 직접 조회 (read replica)
mysql> SELECT
-> m.id AS member_id,
-> m."currentLevelId",
-> m."curriculumCurrentTargetId" AS member_current_target,
-> m."curriculumTargetLevelIds" AS member_targets,
-> c.id AS class_id,
-> c."curriculumCurrentTargetId" AS class_current_target,
-> c."curriculumTargetLevelIds" AS class_targets,
-> cu."targetLevelIds" AS curriculum_table_targets
-> FROM "Member" m
-> JOIN "ClassMember" cm ON cm."memberId" = m.id AND cm."unassignedAt" IS NULL
-> JOIN "Class" c ON c.id = cm."classId"
-> LEFT JOIN "Curriculum" cu ON cu."classId" = c.id
-> WHERE m.id = 'cml7rg35x0003s6016ayvg8yu';
member_id | currentLevelId | member_current_target | member_targets | class_current_target | class_targets | curriculum_table_targets
-----------+----------------+-----------------------+----------------+----------------------+---------------+--------------------------
cml7r..yu | 15 | NULL | {} | NULL | {} | {25}
세 가지가 동시에 보였다.
| 위치 | 값 | 기대 |
|---|---|---|
Member.currentLevelId | 15 | 25 |
Member.curriculumCurrentTargetId | NULL | 25 |
Class.curriculumCurrentTargetId | NULL | 25 |
Class.curriculumTargetLevelIds | {} | {25} |
Curriculum.targetLevelIds | {25} | {25} |
레벨 25 는 분명히 있었다 — Curriculum 테이블 한 곳에. 나머지 다섯 칸은 전부 비어 있었다. 그리고 회원의 시작 레벨 15 는 어디서 왔는지 가 첫 단서였다.
🔍 단서: 원본은 분명히 있는데 읽지 못하는 사고는 원본 위치 ≠ 읽기 경로 시작점 이라는 뜻이다. 어떤 폴백 사슬 이 읽지 않는 위치에 머무르고 있는지 만 찾으면 본진이다.
🔍 탐색 — 가설 셋을 데이터로 차례차례 반증했다
가설 1 — 그룹 생성 입력 누락 (탈락)
먼저 운영자가 그룹 생성 시 25 를 안 넣었을 가능성을 봤다. 그룹 생성 API 의 요청 로그를 봤다.
# Cloud Logging — 그룹 생성 시점
gcloud logging read 'resource.type="cloud_run_revision" AND
jsonPayload.path="/api/v1/tenant/classes" AND
jsonPayload.body.name="레벨25 시작반"' --limit 1 --format json
{
"method": "POST",
"path": "/api/v1/tenant/classes",
"body": {
"name": "레벨25 시작반",
"grade": 5,
"semester": 1,
"curriculumTargetLevelIds": [25] // ← 분명히 25 를 보냈다
}
}
입력은 정상 이었다. 가설 1 탈락 — 받아서 어딘가에 저장 했는데 읽지 못하는 사고.
가설 2 — 배치고사 알고리즘 버그 (탈락)
다음으로 배치고사 건너뛰기 분기 에서 별도의 fallback 로직 이 학기 기본값 으로 회귀하는 경로를 의심했다.
// member-diagnostic.application.service.ts — 사고 직전 코드
async processSkipDiagnostic(memberId: string) {
const member = await this.prisma.member.findUnique({
where: { id: memberId },
include: {
classMembers: {
where: { unassignedAt: null },
include: { class: { select: {
curriculumCurrentTargetId: true,
curriculumTargetLevelIds: true,
curriculumCompletedLevelIds: true,
grade: true, semester: true,
}}},
},
},
});
const classEntity = member.classMembers[0]?.class;
// 폴백 결정기 호출
const effective = await this.curriculumTargetService.getEffectiveCurriculum(
{ /* member 3 필드 */ },
classEntity ? { /* class 3 필드 */ } : null,
);
await this.prisma.member.update({
where: { id: memberId },
data: { currentLevelId: effective.currentTargetId },
});
}
코드 자체는 깔끔 했다. 별도 fallback 분기는 없었다 — 모두 getEffectiveCurriculum 한 곳에 위임. 가설 2 탈락 — 건너뛰기 알고리즘이 아니라 폴백 결정기 가 15 를 돌려주고 있었다.
가설 3 — 회원 생성 시 currentLevel 계산 오류 (탈락)
마지막으로 회원 생성 시점 이 그룹 등록 직전 에 일어나 currentLevel 이 한 번 할당된 후 다시 안 갱신 됐을 가능성을 의심했다. 회원 생성 시점의 Member 행 로그를 봤다.
-- 회원 생성 직후 (배치고사 건너뛰기 전)
SELECT id, "currentLevelId", "curriculumCurrentTargetId", "curriculumTargetLevelIds"
FROM "Member" WHERE id = 'cml7rg35x0003s6016ayvg8yu';
id | currentLevelId | curriculumCurrentTargetId | curriculumTargetLevelIds
-----------+----------------+---------------------------+--------------------------
cml7r..yu | NULL | NULL | {}
회원 생성 직후 세 필드 모두 NULL/빈 배열. 15 는 배치고사 건너뛰기 핸들러에서 할당된 값 이었다. 가설 3 도 탈락 — 15 가 폴백 결정기의 산물 임이 확실해졌다.

세 가설이 차례로 탈락한 뒤 본진은 한 메서드 안에 있었다 — CurriculumTargetService.getEffectiveCurriculum.
⚠️ 주의: 세 가지 표면 가설 이 모두 입력·알고리즘·시점 을 의심하게 만들었지만 진짜 본진은 폴백 결정기 한 메서드 였다. 결과가 학기 기본값 일 때는 알고리즘이 아니라 폴백 사슬의 한 단이 끊겼는지 부터 본다.
🎯 진짜 원인 — 세 도메인 버그가 한 사슬을 이뤘다
본진은 세 도메인 버그가 한 사슬을 이뤄 서로의 증상을 가려 주고 있었다 는 점이었다.
버그 #1 — 그룹 생성 시 Class 3 필드 미동기화
tenant-class.application.service.ts:185 의 그룹 생성 분기는 Curriculum 테이블에만 저장 하고 Class 3 필드는 손대지 않았다.
// tenant-class.application.service.ts — 사고 직전
if (targetLevelIds && targetLevelIds.length > 0) {
await tx.curriculum.create({
data: {
classId: cls.id,
targetLevelIds,
currentTargetIdx: 0,
targetDate: dto.curriculumTargetDate
? new Date(dto.curriculumTargetDate)
: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
},
});
// ❌ Class 3 필드는 비어 있는 채로 끝
}
결과적으로 Class.curriculumCurrentTargetId = NULL, Class.curriculumTargetLevelIds = {} 인 그룹이 만들어졌고, 읽기 경로 인 getEffectiveCurriculum 의 첫 번째 그룹 폴백 단 이 비어 있는 칸만 보고 넘어갔다.
버그 #2 — getEffectiveCurriculum 이 Curriculum 테이블을 참조 안 함
curriculum-target.service.ts:445 의 폴백 사슬은 두 단 만 있었다. 그룹 폴백 단이 Class 3 필드만 보고 비어 있으면 곧장 분기 기본값 으로 떨어졌다.
// curriculum-target.service.ts — 사고 직전
// 1. 회원 개인 설정
if (member.curriculumCurrentTargetId !== null ||
member.curriculumTargetLevelIds.length > 0) {
return { source: 'MEMBER', /* ... */ };
}
// 2. 그룹 — Class 3 필드만 확인
if (classEntity && (
classEntity.curriculumCurrentTargetId !== null ||
classEntity.curriculumTargetLevelIds.length > 0
)) {
return { source: 'CLASS', /* ... */ };
}
// ❌ Curriculum 테이블 단 없음
// 3. 분기 기본값 (5학년 1분기 = 레벨 15)
return { source: 'SEMESTER', currentTargetId: 15, /* ... */ };
레벨 25 가 분명히 있는 Curriculum 테이블 은 조회 경로에 등장조차 하지 못한 채 분기 기본값 15 가 돌아왔다.
버그 #3 — 배치고사 건너뛰기가 그 기본값 을 currentLevelId 에 그대로 할당
배치고사 건너뛰기 핸들러는 getEffectiveCurriculum 의 결과를 그대로 currentLevelId 에 저장했다.
// member-diagnostic.application.service.ts:1005 — 사고 직전
const effective = await this.curriculumTargetService.getEffectiveCurriculum(/* ... */);
await this.prisma.member.update({
where: { id: memberId },
data: { currentLevelId: effective.currentTargetId }, // ← 15 가 저장됨
});
폴백 결정기가 학기 기본값 15 를 돌려준 순간 시작 레벨도 15 가 됐다.
📊 데이터: 세 버그가 따로따로 보면 사소한 누락 이지만 사슬로 묶이면 운영자가 입력한 25 가 끝까지 도달하지 못하는 사고가 된다. 버그 #1 만 잡아도 / 버그 #2 만 잡아도 / 버그 #3 만 잡아도 사고는 증상은 줄지만 사라지지 않는다 — 셋 다 잡아야 한다.
🛠️ 해결 — 폴백 4 단 재정렬 + 그룹 동기화 + 자동 승급 메서드 두 개

해결 #1 — Class 3 필드 명시적 동기화
그룹 생성·수정 모두 Curriculum 테이블 저장 직후 Class 3 필드도 동기화 하도록 했다.
// tenant-class.application.service.ts — After
if (targetLevelIds && targetLevelIds.length > 0) {
// 1. Curriculum 테이블 (히스토리/레거시 호환)
await tx.curriculum.create({
data: { classId: cls.id, targetLevelIds, currentTargetIdx: 0, /* ... */ },
});
// 2. Class 3 필드 동기화 (조회 성능)
const currentTargetId = targetLevelIds[0] ?? null;
const pendingTargetIds = targetLevelIds.slice(1);
await tx.class.update({
where: { id: cls.id },
data: {
curriculumCurrentTargetId: currentTargetId,
curriculumTargetLevelIds: pendingTargetIds,
curriculumCompletedLevelIds: [],
},
});
}
그룹 수정 분기에도 동일한 동기화 블록을 추가했다 — 기존 Curriculum 행이 있을 때와 없을 때 두 경로 모두 Class 3 필드를 갱신.
해결 #2 — 폴백 사슬을 4 단으로 재정렬
getEffectiveCurriculum 에 Curriculum 테이블 단 을 끼워 넣어 읽기 경로 가 원본 데이터를 못 보고 지나치지 않게 했다.
// curriculum-target.service.ts — After (4 단)
// 1. 회원 개인 설정
if (member.curriculumCurrentTargetId !== null ||
member.curriculumTargetLevelIds.length > 0) {
return { source: 'MEMBER', /* ... */ };
}
// 2. 그룹 — Class 3 필드 (폴백 1)
if (classEntity && (
classEntity.curriculumCurrentTargetId !== null ||
classEntity.curriculumTargetLevelIds.length > 0
)) {
this.logger.debug('Using class curriculum from Class fields (fallback 1)');
return { source: 'CLASS', /* ... */ };
}
// 3. 그룹 — Curriculum 테이블 (폴백 2) — 본 머지 신규
if (classEntity?.id) {
const curriculum = await this.curriculumRepository.findByClassId(classEntity.id, tx);
if (curriculum && curriculum.targetLevelIds?.length > 0) {
const idx = curriculum.currentTargetIdx ?? 0;
const currentTargetId = curriculum.targetLevelIds[idx] ?? null;
const completedLevelIds = curriculum.targetLevelIds.slice(0, idx);
const pendingTargetIds = curriculum.targetLevelIds.slice(idx + 1);
this.logger.debug(
`Using class curriculum from Curriculum table (fallback 2): ` +
`currentTargetId=${currentTargetId}, pending=[${pendingTargetIds.join(',')}], ` +
`completed=[${completedLevelIds.join(',')}]`,
);
return {
currentTargetId,
targetLevelIds: pendingTargetIds,
completedLevelIds,
source: 'CLASS', // 출처는 동일 — 단계만 다름
semester: this.formatSemester(classEntity.grade, classEntity.semester),
};
}
}
// 4. 분기 기본값 (폴백 3)
return { source: 'SEMESTER', /* ... */ };
classEntity 의 타입에 id?: number 한 줄을 추가해 Curriculum 테이블 조회 가 가능하게 했다. 호출부 두 곳 (member-diagnostic.application.service.ts:459, :1047) 의 select 절에도 id: true 를 끼워 넣어 4 단 사슬이 정상 작동 하도록 했다.
해결 #3 — 회원 생성 시 그룹 교육과정 자동 복사
배치고사 건너뛰기 사고는 폴백 4 단으로 자동 해결 됐지만, 회원 생성 시점부터 Member 3 필드를 그룹 교육과정으로 복사 하면 런타임에 폴백을 돌릴 필요 없이 안정적이었다.
// tenant-member.application.service.ts — After (회원 생성 분기)
const cls = await this.prisma.class.findFirst({
where: { id: dto.classId, tenantId },
select: {
id: true, name: true, tenantId: true, grade: true,
curriculumCurrentTargetId: true,
curriculumTargetLevelIds: true,
curriculumCompletedLevelIds: true,
},
});
// DTO 명시가 있으면 그것을, 없으면 그룹 교육과정 복사
let curriculumCurrentTargetId: number | null = null;
let curriculumTargetLevelIds: number[] = [];
if (dto.curriculumTargetLevelIds && dto.curriculumTargetLevelIds.length > 0) {
curriculumCurrentTargetId = dto.curriculumTargetLevelIds[0];
curriculumTargetLevelIds = dto.curriculumTargetLevelIds.slice(1);
} else if (
cls.curriculumCurrentTargetId !== null ||
cls.curriculumTargetLevelIds.length > 0
) {
// 그룹 교육과정 복사 (회원 생성 분기 신규)
curriculumCurrentTargetId = cls.curriculumCurrentTargetId;
curriculumTargetLevelIds = [...cls.curriculumTargetLevelIds];
this.logger.log(
`[createMember] Copying class curriculum to member: ` +
`currentTargetId=${curriculumCurrentTargetId}, ` +
`targetLevelIds=[${curriculumTargetLevelIds.join(',')}]`,
);
}
해결 #4 — 자동 승급 메서드 두 개 + 한 훅으로 묶기
본 머지의 다른 한 축 은 Top 도달 + EXCELLENT 3 일 연속 조건의 자동 승급 이었다. 이 도메인은 이전부터 존재한 채 호출되지 않는 코드 였다. 레벨 상승 직후 같은 트랜잭션에서 자동 승급 검사 를 발화하도록 훅 한 줄 로 묶었다.
// level-adjustment-decision.service.ts — After
/** 53.6: 자동 승급 조건 확인 — Top 도달 + EXCELLENT 3 일 연속 */
async checkCurriculumAdvancement(memberId: string): Promise<{
shouldAdvance: boolean;
nextTargetId: number | null;
reason?: string;
}> {
const member = await this.prisma.member.findUnique({
where: { id: memberId },
select: {
currentLevelId: true,
curriculumCurrentTargetId: true,
curriculumTargetLevelIds: true,
excellentConsecutiveDays: true,
},
});
if (!member?.curriculumCurrentTargetId) {
return { shouldAdvance: false, nextTargetId: null, reason: 'NO_CURRICULUM' };
}
if (member.currentLevelId !== member.curriculumCurrentTargetId) {
return { shouldAdvance: false, nextTargetId: null, reason: 'NOT_AT_TARGET_LEVEL' };
}
const requiredDays = V21_THRESHOLDS.CONSECUTIVE_DAYS_REQUIRED; // 3
if ((member.excellentConsecutiveDays ?? 0) < requiredDays) {
return { shouldAdvance: false, nextTargetId: null, reason: 'NOT_ENOUGH_EXCELLENT_DAYS' };
}
return { shouldAdvance: true, nextTargetId: member.curriculumTargetLevelIds[0] ?? null };
}
/** 53.7: 자동 승급 실행 — currentTarget → completed, targets[0] → currentTarget */
async advanceCurriculumTarget(memberId: string) {
const member = await this.prisma.member.findUnique({
where: { id: memberId },
select: {
curriculumCurrentTargetId: true,
curriculumTargetLevelIds: true,
curriculumCompletedLevelIds: true,
},
});
if (!member?.curriculumCurrentTargetId) return { success: false, /* ... */ };
const previousTargetId = member.curriculumCurrentTargetId;
const newCurrentTargetId = member.curriculumTargetLevelIds[0] ?? null;
const newTargetLevelIds = member.curriculumTargetLevelIds.slice(1);
const completedLevelIds = [
...member.curriculumCompletedLevelIds,
member.curriculumCurrentTargetId,
];
await this.prisma.member.update({
where: { id: memberId },
data: {
curriculumCurrentTargetId: newCurrentTargetId,
curriculumTargetLevelIds: newTargetLevelIds,
curriculumCompletedLevelIds: completedLevelIds,
},
});
this.logger.log(
`[AdvanceCurriculum] Member ${memberId} advanced: ` +
`${previousTargetId} → ${newCurrentTargetId ?? 'COMPLETED'}`,
);
return {
success: true,
previousTargetId,
newTargetId: newCurrentTargetId,
isCompleted: newCurrentTargetId === null,
};
}
/** 53.8: 레벨 상승 직후 자동 승급 발화 훅 — 레벨 조정 결과를 입력으로 받음 */
async handlePostUpgradeAdvancement(memberId: string, newLevelId: number) {
const member = await this.prisma.member.findUnique({
where: { id: memberId },
select: { curriculumCurrentTargetId: true, excellentConsecutiveDays: true },
});
if (!member?.curriculumCurrentTargetId) return { curriculumAdvanced: false, /* ... */ };
if (newLevelId !== member.curriculumCurrentTargetId) return { curriculumAdvanced: false, /* ... */ };
if ((member.excellentConsecutiveDays ?? 0) < V21_THRESHOLDS.CONSECUTIVE_DAYS_REQUIRED) {
return { curriculumAdvanced: false, /* ... */ };
}
const result = await this.advanceCurriculumTarget(memberId);
return { curriculumAdvanced: result.success, newCurriculumTargetId: result.newTargetId };
}
세 메서드는 역할이 분명히 갈린다. checkCurriculumAdvancement 는 조건 판별만. advanceCurriculumTarget 은 실행만. handlePostUpgradeAdvancement 는 레벨 조정 결과와 묶는 훅 — 레벨 조정 트랜잭션의 끝 한 줄 에서 호출하면 조정·승급이 같은 묶음 으로 처리된다.
해결 #5 — 그룹 교육과정 직접 PATCH 차단
기존 PATCH /classes/{id} 는 운영자가 세 필드 (currentTargetId · targetLevelIds · completedLevelIds) 를 직접 덮어쓸 수 있는 입구였다. 자동 승급이 들어온 이후 직접 덮어쓰기 는 축적된 completed 이력을 한 번에 날리는 위험이 됐다.
세 필드는 읽기 전용 으로 돌리고, 두 액션 API 만 입구로 남겼다.
POST /api/v1/tenant/classes/{id}/curriculum/append-targets
Body: { levelIds: number[] } // 대기 목표 뒤에 추가
POST /api/v1/tenant/classes/{id}/curriculum/reset
Body: { levelIds: number[] } // 전체 교체 — completed 도 초기화 (경고 모달 필수)
📌 핵심: 도메인 필드를 직접 PATCH 로 노출 하는 순간 부작용 책임이 클라이언트로 새어 나간다. 자동 승급처럼 서버가 일관성을 유지해야 하는 도메인 필드 는 액션 API 로 의도를 묶어 받는다.
✅ 검증 — 폴백 출처를 응답·로그·테스트 세 곳에서 동시에 본다
Jest 폴백 5 케이스 단위 테스트
curriculum-target.service.spec.ts 에 폴백 사슬 5 케이스 를 추가했다.
// curriculum-target.service.spec.ts (요약)
describe('getEffectiveCurriculum 폴백 사슬', () => {
it('TC1: Member 우선 — Member 필드가 채워져 있으면 source=MEMBER', async () => {
const result = await service.getEffectiveCurriculum(
{ curriculumCurrentTargetId: 20, curriculumTargetLevelIds: [25], /* ... */ },
{ id: 1, curriculumCurrentTargetId: 10, /* ... */ },
);
expect(result.source).toBe('MEMBER');
expect(result.currentTargetId).toBe(20);
});
it('TC2: Class 3 필드 폴백 — Member 비어 있고 Class 채워짐', async () => {
const result = await service.getEffectiveCurriculum(
{ curriculumCurrentTargetId: null, curriculumTargetLevelIds: [], /* ... */ },
{ id: 1, curriculumCurrentTargetId: 25, curriculumTargetLevelIds: [27], /* ... */ },
);
expect(result.source).toBe('CLASS');
expect(result.currentTargetId).toBe(25);
});
it('TC3: Curriculum 테이블 폴백 — Class 3 필드 비어 있을 때', async () => {
curriculumRepo.findByClassId.mockResolvedValue({
targetLevelIds: [15, 20, 25],
currentTargetIdx: 1,
});
const result = await service.getEffectiveCurriculum(
{ curriculumCurrentTargetId: null, curriculumTargetLevelIds: [], /* ... */ },
{ id: 1, curriculumCurrentTargetId: null, curriculumTargetLevelIds: [], /* ... */ },
);
expect(result.source).toBe('CLASS'); // 출처는 그룹
expect(result.currentTargetId).toBe(20); // currentTargetIdx=1 → targetLevelIds[1]=20
expect(result.completedLevelIds).toEqual([15]);
expect(result.targetLevelIds).toEqual([25]);
});
it('TC4: 분기 기본값 — Class·Curriculum 모두 비어 있을 때', async () => {
curriculumRepo.findByClassId.mockResolvedValue(null);
const result = await service.getEffectiveCurriculum(
{ curriculumCurrentTargetId: null, curriculumTargetLevelIds: [], /* ... */ },
{ id: 1, grade: 5, semester: 1, /* ... */ },
);
expect(result.source).toBe('SEMESTER');
expect(result.currentTargetId).toBe(15); // 5학년 1분기 기본값
});
it('TC5: 그룹 없음 — Member 비어 있고 classEntity null', async () => {
const result = await service.getEffectiveCurriculum(
{ curriculumCurrentTargetId: null, curriculumTargetLevelIds: [], /* ... */ },
null,
);
expect(result.source).toBe('SEMESTER');
});
});
배포 전 5 케이스 모두 통과 한 뒤 머지했다.
폴백 출처 로그 — 응답에 어디서 결정됐는지 가 같이 기록된다
curriculum-target.service.ts 의 폴백 4 단 모두 logger.debug 로 어디서 결정됐는지 를 찍는다. 운영 중 기본값 폴백이 의외로 자주 발화 하는 그룹이 있다면 로그 한 줄 로 Class 3 필드가 비어 있는지 / Curriculum 테이블이 비어 있는지 가 즉시 보인다.
[CurriculumTarget] Using member curriculum (priority 1)
[CurriculumTarget] Using class curriculum from Class fields (fallback 1)
[CurriculumTarget] Using class curriculum from Curriculum table (fallback 2): currentTargetId=25, pending=[27], completed=[]
[CurriculumTarget] Using semester default (fallback 3): grade=5, semester=1, defaultLevelId=15
사고 회원 재처리 — 25 → 15 가 25 → 25 로 정상화
머지 직후 동일 회원에 대해 그룹 재배정 + 배치고사 건너뛰기 재처리 를 돌렸다.
member_id | currentLevelId | member_current_target | class_current_target
----------+----------------+-----------------------+----------------------
cml7r..yu | 25 | 25 | 25
세 칸이 전부 25 로 정렬 됐다. 운영자에게 동일 결과 캡처를 전달하고 사고 종료.
🛡️ 예방 — 같은 사슬이 한 단이라도 빠지지 않도록
가드 1 — 그룹 생성·수정 통합 테스트
tenant-class.application.service.spec.ts 에 그룹 생성 직후 Class 3 필드가 채워졌는지 를 검증하는 통합 테스트를 추가했다.
it('그룹 생성 시 Curriculum 테이블과 Class 3 필드가 동시에 채워진다', async () => {
await service.createClass(tenantId, {
name: '레벨25 시작반',
grade: 5, semester: 1,
curriculumTargetLevelIds: [25, 27],
});
const cls = await prisma.class.findFirst({ where: { name: '레벨25 시작반' } });
expect(cls.curriculumCurrentTargetId).toBe(25);
expect(cls.curriculumTargetLevelIds).toEqual([27]);
expect(cls.curriculumCompletedLevelIds).toEqual([]);
const curriculum = await prisma.curriculum.findFirst({ where: { classId: cls.id } });
expect(curriculum.targetLevelIds).toEqual([25, 27]);
});
가드 2 — 폴백 출처 메트릭
운영 5 분 윈도우 로 source 별 카운트를 집계한다. SEMESTER 폴백 비율이 평소보다 높은 그룹 이 나오면 그룹 생성 시 동기화 누락 또는 마이그레이션 잔여 데이터 가 의심된다.
-- Cloud Logging → BigQuery sink
SELECT
REGEXP_EXTRACT(jsonPayload.message,
r'source=(MEMBER|CLASS_FIELDS|CURRICULUM_TABLE|SEMESTER)') AS source,
COUNT(*) AS hits
FROM `tenant_api_logs.cloud_run_logs`
WHERE jsonPayload.context = 'CurriculumTarget'
AND timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 5 MINUTE)
GROUP BY source;
가드 3 — 액션 API 만 입구로
PATCH 의 세 필드 직접 수정 은 클래스 밸리데이터 단계에서 금지 한다. append-targets 와 reset 두 액션만 입구로 남기고, reset 은 FE 경고 모달 을 통과한 요청만 받는다.
// tenant-class.dto.ts — PATCH UpdateClassDto
export class UpdateClassDto {
@ApiPropertyOptional() name?: string;
@ApiPropertyOptional() grade?: number;
@ApiPropertyOptional() semester?: number;
// ❌ curriculumCurrentTargetId · curriculumTargetLevelIds · curriculumCompletedLevelIds 제거
// → append-targets / reset 액션 API 로만 변경 가능
}
🛡️ 인사이트: 도메인 필드를 PATCH 로 노출 하면 클라이언트가 일관성 책임을 진다. 도메인 모델이 서버 트랜잭션 안에서만 일관성을 보장 해야 하는 영역은 액션 API 로 입구를 좁혀야 사고가 한 시점 으로 모인다.
📋 정리 — 도메인 버그 3 건이 한 사슬을 이룬 트러블슈팅
| 항목 | Before (사고 상태) | After (수정 후) |
|---|---|---|
| 폴백 사슬 | 2 단 (Member → Class 3 필드 → 분기 기본값) | 4 단 (Member → Class 3 필드 → Curriculum 테이블 → 분기 기본값) |
| 그룹 생성 저장 | Curriculum 테이블만 | Curriculum + Class 3 필드 동시 |
| 회원 생성 시 그룹 교육과정 | 미복사 — 런타임 폴백 의존 | DTO 미지정 시 그룹 3 필드 복사 |
| 폴백 출처 가시화 | 응답 무 / 로그 무 | logger.debug(source=...) 4 단 모두 |
| 자동 승급 | 도메인 메서드 존재하나 호출 없음 | handlePostUpgradeAdvancement 한 훅으로 레벨 상승 직후 발화 |
| 그룹 PATCH 입구 | 세 필드 직접 수정 허용 | append-targets · reset 두 액션 API 로 제한 |
| 단위 테스트 | 폴백 사슬 0 케이스 | 5 케이스 (Member 우선 / Class 3 필드 / Curriculum 테이블 / 분기 기본값 / 그룹 없음) |
이번 사고의 핵심은 세 도메인 버그가 따로 보면 사소한 누락 이었다는 점이다. 하나만 잡아도 다른 둘이 가린다. 입력 → 저장 → 폴백 → 할당 네 단계 중 세 단에서 같은 의미 (그룹 교육과정 25) 가 끊겼다. 트러블슈팅의 본진은 증상 한 건 의 원인 한 건 이 아니라 증상 한 건 안에 숨어 있던 사슬 셋 이었다.
도메인 모델링의 교훈은 같은 의미를 두 곳에 적어 두면 폴백이 사슬이 된다 는 점이다. 사슬이 길어질수록 한 단 누락의 확률 이 모든 단의 누락 확률의 합 으로 늘어난다. 본 머지가 폴백을 4 단으로 명시 한 이유는 읽기 경로를 단순화하기 보다 원본 위치마다 명시적으로 검사 단을 두기 위해서였다. 다음 머지의 방향은 세 모델의 이중 저장 자체를 한 곳으로 통합 하는 방안 C (스냅샷 + 교집합 보존) 이고, 이는 곧 시리즈의 다음 도메인 회고 회차로 이어진다.
📚 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 중 실행 묶음