교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기

교육과정 목표를 Member 한 곳에만 두면 운영 비용이 폭증한다. Prisma 두 컬럼 추가 + 도메인 서비스 한 메서드로 Member → Class → 분기 기본값 3계층 폴백을 한 곳에 모은 리팩토링. 응답에 출처(curriculumSource)를 함께 실어 운영자 UI도 한 화면에 결론을 보여준다.


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

  • 단일 보유의 한계 — 교육과정 목표를 Member 한 곳에만 두면, 30명 그룹 운영자가 30번 같은 값을 복사해 입력하는 운영이 된다
  • 3계층 폴백: Member → Class → 분기 기본값 우선순위. 첫 비어있지 않은 값이 유효 교육과정으로 결정된다
  • Prisma 스키마: MemberClass 양쪽에 curriculumTargetLevelIds Int[] + curriculumCurrentTargetIdx Int 두 필드 추가
  • 단일 진입점: CurriculumTargetService.getEffectiveCurriculum() 한 메서드로 우선순위 결정을 한 곳에 모은다
  • 출처 동봉: 응답에 curriculumSource: 'MEMBER' | 'CLASS' | 'SEMESTER'를 함께 실어 UI는 분기 없이 배지만 그린다
  • 빈 배열 = 미설정Int[]는 nullable 불가, 빈 배열 하나로 “비어 있음”을 통일해야 폴백 조건이 깨끗해진다

🌱 왜 교육과정 구조를 다시 만져야 했나

이전 편에서 신규 등급 스키마를 마이그레이션했다. Level 모델에 upperLevelIds[] · lowerLevelIds[] · levelType · semester 네 필드가 추가됐고, Member 모델에는 trackState · secondLevelId 같은 트랙 상태 필드, Curriculum 모델에는 targetLevelIds[] · currentTargetIdx가 새로 들어왔다. 빌드도 마이그레이션도 통과했고, 운영자가 회원 한 명을 직접 등록해 보기까지 했다.

다음 날 아침, 운영자 한 명이 다음과 같이 말했다.

“그룹 단위로 한 번에 교육과정을 정해 두면 안 되나요. 한 그룹 30명을 일일이 회원 상세에서 설정하는 게 너무 번거롭습니다.”

타당한 요구였다. 그때까지 교육과정 목표는 Member.curriculumTargetLevelIds: Int[] 한 곳에만 저장되어 있었고, 새 회원을 등록할 때마다 다음 도달 목표 레벨 ID 배열을 직접 입력해야 했다. 같은 그룹의 회원 30명이 모두 같은 목표를 가지는 경우가 흔했지만, 단일 보유 구조라서 30번 같은 값을 입력하는 운영이었다.

운영자의 요구를 풀어 쓰면 두 가지였다.

  1. 그룹 단위 일괄 설정 — 한 그룹 30명을 한 번에 같은 교육과정으로
  2. 분기 기본값 폴백 — 둘 다 비어 있으면 해당 분기의 표준 목표 레벨로 자동 채움

이전 편의 스키마 결정은 *“회원마다 다른 교육과정을 줄 수 있다”*까지만 다뤘다. 그룹 단위 일괄 설정과 분기 기본값 폴백은 다음 단계로 미뤄 둔 상태였다. 이제 그 다음 단계를 해야 했다.

📌 핵심: 단일 위치에만 저장하던 값을, 덮어쓰기 가능한 3계층으로 분리하는 리팩토링이다. Member 개별 설정이 가장 높은 우선순위, 비어 있으면 Class 그룹 단위 설정, 그래도 비어 있으면 분기 기본값. 첫 비어있지 않은 값이 유효 교육과정으로 결정된다.


🔥 증상 — 운영자 피드백과 30번 복사 붙여넣기

전날까지의 흐름은 단순했다.

// ❌ Before — 회원 등록 시 매번 직접 입력
async function createMember(input: CreateMemberInput) {
  return prisma.member.create({
    data: {
      name: input.name,
      classId: input.classId,
      curriculumTargetLevelIds: input.curriculumTargetLevelIds, // ← 매번 필수
      curriculumCurrentTargetIdx: 0,
    },
  });
}

운영자가 그룹 하나를 새로 만들고 30명을 등록할 때마다, 같은 curriculumTargetLevelIds 값을 30번 복사해서 붙여넣었다. 운영 측 피드백을 받기 전에는 *“회원마다 교육과정이 다를 수 있으니 회원별로 받는 게 맞다”*고 봤다.

실제 운영을 시작하니 사정이 달랐다.

  • 같은 그룹 회원 30명 중 28명이 같은 목표 → 그룹이 더 자연스러운 단위였다
  • 분기 시작 직후 그룹은 만들어졌지만 회원이 아직 모집되지 않은 시점 → 개별 설정도 그룹 설정도 비어 있는 상태가 정상이었고, 그때 “분기 기본값으로 안내”하는 흐름이 필요했다
  • 운영자가 그룹 설정을 바꾸면 그 그룹 회원 전체에게 일괄 적용되어야 했다 → 단, 개별로 다르게 설정한 회원은 보존

세 가지를 한 번에 푸는 구조가 3계층 폴백이었다.

운영자 피드백 한 줄로 정리하면

"개별 설정이 있으면 그것, 없으면 그룹 설정, 그것도 없으면 분기 기본값"

이 한 줄을 코드로 옮기는 게 이번 작업의 전부였다.

⚠️ 주의: “한 줄 요구”가 “한 줄 구현”으로 끝나지 않는 이유는, 우선순위 결정출처 추적이 별도라서다. 어떤 값이 실제로 적용됐는지 UI에서 보여주려면 curriculumSource 같은 메타 필드를 응답에 같이 실어야 한다. 그러지 않으면 운영자가 “내가 그룹에 설정한 값이 왜 안 보이지?”라며 다시 같은 페이지를 클릭하기 시작한다.


🔍 탐색 — 어디서 폴백을 결정할 것인가

후보 셋을 두고 비교했다.

후보 1: 클라이언트(FE)에서 폴백 결정

// ❌ FE에서 직접 우선순위 결정
const effective =
  member.curriculumTargetLevelIds?.length
    ? member.curriculumTargetLevelIds
    : classData.curriculumTargetLevelIds?.length
      ? classData.curriculumTargetLevelIds
      : semesterDefault.targetLevelIds;

가장 빠른 구현이지만, FE 코드 3곳에서 같은 분기 로직이 반복되는 결과가 보였다. 회원 상세 페이지, 회원 등록 폼 미리보기, 그룹 상세 페이지 — 세 화면이 각자 폴백 로직을 들고 있으면 한 번 규칙이 바뀔 때 세 곳을 동시에 고쳐야 한다.

게다가 API 응답이 *“원본 값들의 묶음”*이라서, 운영자가 “지금 적용 중인 값이 뭔지” 보려면 FE가 매번 한 번 더 계산을 돌려야 했다. 백엔드 응답을 받기만 해도 결과를 알 수 있는 구조가 더 단순해 보였다.

후보 2: Repository에서 폴백 결정

// Repository가 join + COALESCE로 한 번에 결정
async findEffectiveCurriculum(memberId: number) {
  return prisma.$queryRaw`
    SELECT
      COALESCE(NULLIF(m."curriculumTargetLevelIds", '{}'),
               NULLIF(c."curriculumTargetLevelIds", '{}'),
               cur."targetLevelIds") AS effective_ids,
      ...
    FROM "Member" m
    LEFT JOIN "Class" c ON c.id = m."classId"
    LEFT JOIN "Curriculum" cur ON cur.semester = c.semester
    WHERE m.id = ${memberId}
  `;
}

Prisma $queryRaw로 SQL COALESCENULLIF를 합쳐 한 번에 결정하는 방향. 한 쿼리에서 끝난다는 장점은 있지만, Repository에 비즈니스 규칙(우선순위 정의)이 들어가는 위치 문제가 있었다.

같은 우선순위 결정이 회원 상세 응답, 회원 등록 시 미리보기, 그룹 상세 응답 등 여러 호출에서 재사용되어야 했다. Repository가 그 규칙의 단일 진입점이 되면 Repository 계층이 도메인 규칙까지 함께 알게 되는 구조였다.

또 한 가지, 출처 추적(MEMBER vs CLASS vs SEMESTER)을 Raw SQL의 CASE WHEN으로 같이 만들어야 했는데, SQL 안에서 분기 로직이 늘어날수록 테스트하기 어려운 코드가 늘었다.

후보 3: Domain Service에서 폴백 결정 (채택)

// ✅ 도메인 서비스 한 곳에 우선순위 결정 메서드
@Injectable()
export class CurriculumTargetService {
  async getEffectiveCurriculum(
    memberId: number,
  ): Promise<EffectiveCurriculumResult> {
    // ...
  }
}

Repository는 데이터 조회만 담당하고, 3계층 폴백 규칙은 도메인 서비스에 모은다. 호출부(Application Service / Controller)는 메서드 하나를 호출하면 유효 교육과정 + 출처가 함께 돌아오는 구조였다.

호출부가 늘어나도 우선순위 규칙은 한 곳에서만 바뀐다. 그리고 응답에 *출처(curriculumSource)*를 같이 실으면 FE는 별도 분기 없이 표시만 하면 된다.

후보결정 위치호출부 분기출처 추적채택
1. FE클라이언트❌ 화면마다 중복❌ FE에서 재계산
2. Repository데이터 계층✅ 단일 호출⚠️ Raw SQL CASE
3. Domain Service도메인 계층✅ 단일 호출✅ 응답 필드로 동봉

🔍 단서: “어디서 결정할까”의 답은 그 규칙이 다음 분기에도 같은 의미를 유지할까로 가르는 게 낫다. 운영자 피드백의 한 줄 — “개별 → 그룹 → 분기 기본값” — 은 도메인 규칙이지 화면 규칙이 아니다. 도메인 규칙은 도메인 서비스에 두면, 화면 추가 · 제거에 따라 흔들리지 않는다.


🔬 진짜 범인 — null vs 빈 배열, “비어 있음”의 정의

폴백을 짜기 시작하면서 한 번 더 막힌 곳이 있었다. “비어 있다”는 상태를 어떻게 표현할까.

Prisma의 Int[] 배열 컬럼은 기본값 옵션이 두 가지로 보였다.

// 옵션 A — null 허용 (의도: 미설정 vs 빈 배열 구분)
curriculumTargetLevelIds Int[]?

// 옵션 B — 빈 배열 기본값 (둘 다 비어 있음으로 통일)
curriculumTargetLevelIds Int[] @default([])

처음엔 옵션 A를 시도했다. *“설정 안 됨”*을 null로, *“비어 있음”*을 []로 구분하면 폴백 의미가 더 명확할 것 같았다. 그런데 Prisma는 스칼라 리스트 필드에 nullable을 허용하지 않는다. 마이그레이션이 다음과 같이 거부됐다.

$ npx prisma migrate dev --name curriculum_fallback

error: Validation error count: 1
  Error parsing attribute "@default": Field `curriculumTargetLevelIds` is of
  type `Int[]` which is a list type. List types cannot be made optional
  in the Prisma schema.

옵션 B(@default([]))밖에 없다는 뜻이었다. 그래서 *“빈 배열 = 미설정”*으로 약속을 정했다.

model Member {
  id                          Int       @id @default(autoincrement())
  name                        String
  classId                     Int?
  class                       Class?    @relation(fields: [classId], references: [id])

  // 신규 — 개별 교육과정 오버라이드 (빈 배열 = 미설정 = 폴백 진입)
  curriculumTargetLevelIds    Int[]     @default([])
  curriculumCurrentTargetIdx  Int       @default(0)

  // ...
}

model Class {
  id                          Int       @id @default(autoincrement())
  name                        String
  semester                    Int       // 분기 (예: 202601 = 2026-1분기)

  // 신규 — 그룹 단위 교육과정 (빈 배열 = 미설정 = 분기 기본값으로 폴백)
  curriculumTargetLevelIds    Int[]     @default([])
  curriculumCurrentTargetIdx  Int       @default(0)

  members                     Member[]
}

이 한 가지 약속을 명시하지 않으면 도메인 서비스의 조건문이 흔들린다.

// ❌ Before — 의미가 모호한 truthy 검사
if (member.curriculumTargetLevelIds) {
  // 빈 배열도 truthy → 항상 이 분기로 들어간다
}

// ✅ After — 빈 배열을 명시적으로 분기
if (member.curriculumTargetLevelIds.length > 0) {
  return { source: 'MEMBER', levelIds: member.curriculumTargetLevelIds };
}

📌 핵심: “비어 있음”의 표현이 한 가지로 통일되어야, 그 위에 쌓이는 폴백 규칙도 한 가지 조건문으로 단순해진다. Prisma의 Int[] 컬럼이 nullable을 허용하지 않는다는 제약은 처음엔 불편해 보였지만, 결과적으로 빈 배열 = 미설정이라는 단일 약속을 강제해 줘서 폴백 로직이 깔끔해졌다.


🛠️ 해결 — 두 필드 + 한 메서드로 우선순위 결정

Member → Class → 분기 기본값 3계층 교육과정 폴백 우선순위 흐름도

단계 1: Prisma 스키마에 두 필드 추가

MemberClass 양쪽에 동일한 두 필드를 추가했다. 같은 이름, 같은 타입, 같은 기본값.

model Member {
  // ... 기존 필드 ...

  // 개별 오버라이드 (가장 높은 우선순위)
  curriculumTargetLevelIds    Int[]     @default([])
  curriculumCurrentTargetIdx  Int       @default(0)
}

model Class {
  // ... 기존 필드 ...

  // 그룹 단위 설정 (Member 다음 우선순위)
  curriculumTargetLevelIds    Int[]     @default([])
  curriculumCurrentTargetIdx  Int       @default(0)
}

분기 기본값(가장 낮은 우선순위)은 Curriculum 모델의 targetLevelIds[]를 분기와 매핑하는 별도 조회 함수로 처리한다. 분기 정보는 Class.semester 한 필드로 끌어낼 수 있어서 추가 컬럼 없이 조회 함수로 해결했다.

$ npx prisma migrate dev --name curriculum_fallback
Applying migration `20260115092100_curriculum_fallback`

The following migration(s) have been created and applied:
  20260115092100_curriculum_fallback/
    └─ migration.sql

Your database is now in sync with your Prisma schema.

단계 2: 출처 타입과 결과 인터페이스 정의

// curriculum-target.service.ts
export type CurriculumSource = 'MEMBER' | 'CLASS' | 'SEMESTER';

export interface EffectiveCurriculumResult {
  source: CurriculumSource;
  levelIds: number[];          // 적용된 목표 레벨 ID 배열
  currentTargetIdx: number;    // 현재 진행 인덱스
  semester?: number;           // SEMESTER 출처일 때만 동봉
}

source 한 필드가 어디에서 온 값인지 표시한다. UI에서 MEMBER면 “개별 설정”, CLASS면 “그룹 설정”, SEMESTER면 “분기 기본값” 배지를 보여준다. 운영자의 멘탈 모델(“내가 그룹에 설정했는데 왜 안 보이지?”)이 이 한 필드로 해소된다.

단계 3: getEffectiveCurriculum() — 우선순위를 한 메서드에

@Injectable()
export class CurriculumTargetService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly curriculumRepository: CurriculumRepository,
  ) {}

  async getEffectiveCurriculum(
    memberId: number,
  ): Promise<EffectiveCurriculumResult> {
    const member = await this.prisma.member.findUnique({
      where: { id: memberId },
      include: {
        class: {
          select: {
            id: true,
            semester: true,
            curriculumTargetLevelIds: true,
            curriculumCurrentTargetIdx: true,
          },
        },
      },
    });

    if (!member) {
      throw new NotFoundException(`Member ${memberId} not found`);
    }

    // 우선순위 1: Member 개별 설정
    if (member.curriculumTargetLevelIds.length > 0) {
      return {
        source: 'MEMBER',
        levelIds: member.curriculumTargetLevelIds,
        currentTargetIdx: member.curriculumCurrentTargetIdx,
      };
    }

    // 우선순위 2: Class 그룹 설정
    if (
      member.class &&
      member.class.curriculumTargetLevelIds.length > 0
    ) {
      return {
        source: 'CLASS',
        levelIds: member.class.curriculumTargetLevelIds,
        currentTargetIdx: member.class.curriculumCurrentTargetIdx,
      };
    }

    // 우선순위 3: 분기 기본값
    const semester = member.class?.semester;
    if (semester === undefined) {
      // 그룹조차 없는 회원 (드물지만 가능) — 기본값도 없음
      return { source: 'SEMESTER', levelIds: [], currentTargetIdx: 0 };
    }

    const defaultCurriculum =
      await this.curriculumRepository.findBySemester(semester);

    return {
      source: 'SEMESTER',
      levelIds: defaultCurriculum?.targetLevelIds ?? [],
      currentTargetIdx: 0,
      semester,
    };
  }
}

세 조건문이 우선순위 순서대로 나열되어 있고, 첫 번째로 비어있지 않은 값을 만나면 즉시 반환한다. 진입 조건은 단 하나 — length > 0. 우선순위 추가 시 같은 패턴으로 위/아래 줄에 끼우면 끝난다.

단계 4: 응답 DTO에 출처와 유효값 동봉

회원 상세 응답에 원본 두 필드유효 결과를 함께 담았다.

// member-detail.dto.ts
export class MemberDetailDto {
  id!: number;
  name!: string;
  classId!: number | null;

  // 원본 (운영자가 직접 설정한 값) — 빈 배열로 미설정 표현
  curriculumTargetLevelIds!: number[];
  curriculumCurrentTargetIdx!: number;

  // 폴백 결과 (실제 적용 중인 값 + 출처)
  curriculumSource!: CurriculumSource;
  effectiveCurriculumLevelIds!: number[];
}
// academy-member.application.service.ts
async toMemberDetailDto(member: Member): Promise<MemberDetailDto> {
  const effective = await this.curriculumTargetService
    .getEffectiveCurriculum(member.id);

  return {
    id: member.id,
    name: member.name,
    classId: member.classId,
    curriculumTargetLevelIds: member.curriculumTargetLevelIds,
    curriculumCurrentTargetIdx: member.curriculumCurrentTargetIdx,
    curriculumSource: effective.source,
    effectiveCurriculumLevelIds: effective.levelIds,
  };
}

이 응답을 받은 FE는 원본 입력은 폼 초기값으로, 유효 결과는 “현재 적용 중인 값”으로 사용한다.

💡 인사이트: 원본 값유효 값을 응답에 함께 실으면, FE는 두 값의 차이만 보고 “오버라이드 중인지 폴백 중인지”를 한눈에 표시할 수 있다. 운영자의 의문 한 줄(“그룹에 설정한 값이 왜 안 뜨지?”)이 응답 한 번에 해소되는 구조다. 폴백을 백엔드가 결정하되, 결정 근거까지 응답에 같이 동봉하는 것이 포인트.

단계 5: 운영자 UI — 출처 배지 + 초기화 버튼

// member-detail.tsx (Refine + Material UI 일부)
const sourceLabels: Record<CurriculumSource, string> = {
  MEMBER: '개별 설정',
  CLASS: '그룹 설정',
  SEMESTER: '분기 기본값',
};

const sourceColors: Record<CurriculumSource, 'primary' | 'info' | 'default'> = {
  MEMBER: 'primary',
  CLASS: 'info',
  SEMESTER: 'default',
};

<Chip
  label={sourceLabels[data.curriculumSource]}
  color={sourceColors[data.curriculumSource]}
  size="small"
/>

{data.curriculumSource === 'MEMBER' && (
  <Button onClick={resetToFallback}>
    개별 설정 초기화 (그룹 / 분기 기본값으로 복귀)
  </Button>
)}

초기화 버튼은 회원의 curriculumTargetLevelIds를 빈 배열로 되돌리는 단순한 PATCH 호출이다. 이 한 번의 클릭으로 운영자는 개별 오버라이드를 해제하고 그룹 또는 분기 기본값으로 복귀시킨다.


✅ 검증 — 세 출처 시나리오와 단위 테스트

세 단계를 모두 적용한 뒤 빌드와 서버를 돌렸다.

$ pnpm build
> @api/[email protected] build
> nest build
# 0 errors ✅

$ pnpm start:dev
[Nest] Application successfully started on port 3000

마이그레이션 이후 DI 에러나 타입 충돌은 없었다. 다음으로 세 출처 시나리오를 직접 호출로 확인했다.

# 시나리오 1: Member 개별 설정만 있는 경우
$ curl -s http://localhost:3000/api/v1/academy/members/101 | jq
{
  "id": 101,
  "name": "회원 A",
  "classId": 5,
  "curriculumTargetLevelIds": [12, 18, 22],
  "curriculumSource": "MEMBER",
  "effectiveCurriculumLevelIds": [12, 18, 22]
}

# 시나리오 2: Member 비어 있고 Class에만 설정된 경우
$ curl -s http://localhost:3000/api/v1/academy/members/102 | jq
{
  "id": 102,
  "name": "회원 B",
  "classId": 5,
  "curriculumTargetLevelIds": [],
  "curriculumSource": "CLASS",
  "effectiveCurriculumLevelIds": [10, 16, 20]
}

# 시나리오 3: 둘 다 비어 있는 경우
$ curl -s http://localhost:3000/api/v1/academy/members/103 | jq
{
  "id": 103,
  "name": "회원 C",
  "classId": 6,
  "curriculumTargetLevelIds": [],
  "curriculumSource": "SEMESTER",
  "effectiveCurriculumLevelIds": [8, 14, 19]
}

세 응답 모두 원본 입력유효 결과를 함께 보여준다. 운영자는 유효 결과만 봐도 “현재 적용 중인 목표 레벨”을 알고, 출처 배지가 “왜 이 값인지”를 한 줄로 설명한다.

// curriculum-target.service.spec.ts — 우선순위 단위 검증
describe('CurriculumTargetService — getEffectiveCurriculum', () => {
  it('Member 개별 설정이 있으면 MEMBER 출처', async () => {
    const member = await fixtures.createMember({
      curriculumTargetLevelIds: [12, 18],
    });
    const r = await service.getEffectiveCurriculum(member.id);
    expect(r.source).toBe('MEMBER');
    expect(r.levelIds).toEqual([12, 18]);
  });

  it('Member 비어 있고 Class에 있으면 CLASS 출처', async () => {
    const klass = await fixtures.createClass({
      curriculumTargetLevelIds: [10, 16],
    });
    const member = await fixtures.createMember({
      classId: klass.id,
      curriculumTargetLevelIds: [],
    });
    const r = await service.getEffectiveCurriculum(member.id);
    expect(r.source).toBe('CLASS');
    expect(r.levelIds).toEqual([10, 16]);
  });

  it('둘 다 비어 있으면 SEMESTER 기본값', async () => {
    const klass = await fixtures.createClass({
      semester: 202601,
      curriculumTargetLevelIds: [],
    });
    const member = await fixtures.createMember({
      classId: klass.id,
      curriculumTargetLevelIds: [],
    });
    await fixtures.createDefaultCurriculum(202601, [8, 14, 19]);

    const r = await service.getEffectiveCurriculum(member.id);
    expect(r.source).toBe('SEMESTER');
    expect(r.levelIds).toEqual([8, 14, 19]);
    expect(r.semester).toBe(202601);
  });
});
$ pnpm --filter api test curriculum-target
 PASS  test/curriculum-target.service.spec.ts (3.214 s)
  CurriculumTargetService getEffectiveCurriculum
 Member 개별 설정이 있으면 MEMBER 출처 (87 ms)
 Member 비어 있고 Class에 있으면 CLASS 출처 (62 ms)
 비어 있으면 SEMESTER 기본값 (94 ms)

세 줄 초록. 우선순위 규칙이 코드 한 곳테스트 세 줄로 확정됐다.


🛡️ 예방 — 폴백 변경 시 회귀 차단 체크리스트

이런 폴백 구조는 우선순위 추가 / 제거가 가장 흔한 회귀 진입점이다. 예를 들어 나중에 “고객사 전역 기본값”이 추가되어 4계층이 되면, 조건문 순서를 한 번 잘못 잡으면 상위 우선순위가 침묵하는 회귀가 난다.

같은 실수를 막기 위한 체크리스트.

  • 새 우선순위 추가 시, 단일 메서드(getEffectiveCurriculum)에서만 조건문 추가
  • CurriculumSource 타입에 새 출처 값 추가 + UI 배지 매핑을 같은 PR에서 업데이트
  • 빈 배열 = 미설정 약속 유지 (다른 sentinel 값, 예: [-1], [0] 등 도입 금지)
  • 단위 테스트에 새 우선순위 + 상위 비어 있을 때 시나리오 추가 — 회귀 차단 최소 단위
  • effectiveCurriculumLevelIdscurriculumSource가 응답 DTO에 둘 다 포함되어 있는지 PR 리뷰에서 확인

특히 마지막 항목이 포인트다. 응답에서 출처가 빠지면 FE는 자력으로 “어디서 왔는지” 추론해야 하고, 그 추론이 FE 코드에 분산된 폴백 조건문을 다시 살려낸다. 시작점으로 다시 돌아가는 흐름이다.

⚠️ 주의: 폴백 우선순위가 늘어날수록 비어 있음의 정의가 다양해진다. 어떤 필드는 null, 어떤 필드는 빈 배열, 어떤 필드는 0 — 이걸 하나로 통일하지 않으면 조건문이 매번 달라진다. 도메인 서비스의 폴백 메서드는 비어 있음의 통일된 정의 하나를 강제해 두는 게 회귀 비용을 가장 크게 줄인다.


📋 정리 — 핵심 요약

상황❌ 안티패턴✅ 권장 패턴
교육과정 저장 위치단일 위치(Member)만Member · Class · 분기 기본값 3계층
”비어 있음” 표현null vs [] vs undefined 혼재빈 배열 [] 하나로 통일
우선순위 결정 위치FE 또는 Repository 분산Domain Service 단일 메서드
API 응답원본 값만 반환원본 + 유효 결과 + 출처 동봉
FE 분기화면마다 폴백 재계산curriculumSource 배지 표시만
우선순위 추가 시호출부마다 수정getEffectiveCurriculum 한 메서드만
검증한 시나리오만세 출처 × 폴백 단계별 단위 테스트

숫자로 보는 작업

  • 운영자 피드백까지: 신규 등급 스키마 마이그레이션 후 약 12시간
  • 3계층 분리 결정까지: 약 2시간 (후보 비교 + 트레이드오프 정리)
  • 스키마 + 도메인 서비스 + DTO + UI 구현: 약 8시간
  • 단위 테스트 + 운영자 재확인: 약 2시간
  • 이후 교육과정 폴백 관련 회귀: 0건

폴백은 조건문을 줄세우는 일이 아니라, “비어 있음”의 정의를 통일하고 우선순위를 단일 메서드에 모으는 일이다. 한 번 정리해 두면 우선순위 한 단계를 더 끼워 넣을 때 도메인 서비스 한 곳만 손대면 끝난다. 운영자 피드백의 한 줄을 코드 한 메서드에 매핑한 12시간의 결과다.

다음 편에서는 QR 코드 기반 배치고사 MVP를 다룬다. 회원이 처음 들어왔을 때 어느 레벨부터 시작할지를 어떻게 결정했는지.

📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)

  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 권한 가드 — 목록은 막고 상세는 뚫린 날