교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅

그룹 교육과정에 *레벨 25* 를 지정해 둔 회원이 *배치고사 건너뛰기* 직후 *레벨 15* 로 배정된 사고. 표면 증상은 하나였지만 진짜 원인은 *Class 동기화 누락 / getEffectiveCurriculum 폴백 누락 / 배치고사 건너뛰기의 currentLevelId 폴백* 세 도메인 버그가 한 사슬을 이루고 있던 점이었다. 폴백 4 단으로 재정렬하고 자동 승급 메서드 두 개를 들여 *EXCELLENT 3 일 + Top 도달* 조건을 한 흐름에 묶은 A 톤 트러블슈팅을 정리한다.


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

  • 증상 — 그룹 교육과정에 레벨 25 를 지정해 둔 회원이 배치고사 건너뛰기레벨 15 로 배정. 그룹 설정은 분명히 25 였고, 응답에는 15 가 들어 있었다.
  • 표면 가설 (모두 탈락) — 그룹 생성 입력 누락 / 배치고사 알고리즘 버그 / 회원 생성 시 currentLevel 계산 오류. 세 가설 모두 데이터로 반증.
  • 진짜 원인세 도메인 버그가 한 사슬. (1) 그룹 생성 시 Curriculum 테이블에만 저장하고 Class 3 필드는 비어 있음 (2) getEffectiveCurriculumCurriculum 테이블을 참조하지 않고 분기 기본값 으로 폴백 (3) 배치고사 건너뛰기가 그 기본값 을 그대로 currentLevelId 에 그대로 할당.
  • 해결 — 폴백을 Member → Class 필드 → Curriculum 테이블 → 분기 기본값 4 단으로 재정렬하고, 그룹 생성·수정 시 Class 3 필드를 명시적으로 동기화. 회원 생성도 그룹 교육과정을 회원 필드로 복사 하는 경로를 추가.
  • 자동 승급 연동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.currentLevelId1525
Member.curriculumCurrentTargetIdNULL25
Class.curriculumCurrentTargetIdNULL25
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 — getEffectiveCurriculumCurriculum 테이블을 참조 안 함

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 단 재정렬 + 그룹 동기화 + 자동 승급 메서드 두 개

직접 정리한 교육과정 폴백 사슬 4 단 흐름도 — Member 개인 설정에서 시작해 Class 3 필드와 Curriculum 테이블 그리고 분기 기본값으로 이어지는 본 머지 신규 단을 포함한 폴백 사슬 구조도
직접 정리한 교육과정 폴백 사슬 4 단 흐름도 — Member 개인 설정에서 시작해 Class 3 필드와 Curriculum 테이블 그리고 분기 기본값으로 이어지는 본 머지 신규 단을 포함한 폴백 사슬 구조도

해결 #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 단으로 재정렬

getEffectiveCurriculumCurriculum 테이블 단 을 끼워 넣어 읽기 경로원본 데이터를 못 보고 지나치지 않게 했다.

// 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 → 1525 → 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-targetsreset 두 액션만 입구로 남기고, resetFE 경고 모달 을 통과한 요청만 받는다.

// 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. 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초로
  55. 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
  56. 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
  57. 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
  58. 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
  59. 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
  60. 60. react-hook-form + Zod 폼 표준 정착기
  61. 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
  62. 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
  63. 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음