Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계

NestJS + Prisma 프로젝트에서 Member·Class·Operator 세 도메인 모델에 deletedAt 한 컬럼을 도입하며 마주친 27곳의 쿼리 필터, 트랜잭션 dependent 정리, 로그인 차단, FE 다이얼로그 흐름까지 도메인 모델 표준의 구현기를 정리한다. delete-check + DELETE 두 단계 API와 Application Service 16곳 + Domain Repository 11곳의 필터 분포까지 한 흐름으로 묶었다.


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

  • deletedAt nullable 컬럼 한 줄이 Prisma 모델 3 개, 마이그레이션 인덱스 3 개, 삭제 API 6 개, 쿼리 필터 27 곳까지 퍼졌다
  • delete-check + DELETE 두 단계 API로 사전 영향도와 실 삭제를 분리해 409 충돌을 클라이언트가 미리 잡게 했다
  • 삭제 트랜잭션은 dependent 정리까지 한 함수에서Member soft delete 한 줄이 ClassMember unassignAssignment EXPIRE 까지 같이 굴린다
  • 필터 누락 핫픽스가 다음 날 또 터졌다 — Application Service 16 곳을 잡았는데 Domain Repository 11 곳이 살아남아 grep -rn "deletedAt" 룰이 정착됐다
  • FE 공통 다이얼로그useCustom delete-check → canDelete 분기 → useCustomMutation 흐름 하나로 세 도메인을 다 받아낸다

🎯 배경 — CASCADE 한 줄을 뜯어내야 했다

직전 편 (devlog-64) 에서 react-hook-form + Zod + shadcn Form 의 폼 표준을 프론트엔드 4 단 파이프라인으로 명문화했다. 본 머지는 그 직후 — 같은 변경 명세서 묶음에서 도메인 모델 표준이 빠져 있던 마지막 한 영역, 삭제 정책을 정리한다.

이전까지 회원·클래스·운영자 세 모델은 물리적 삭제가 기본이었다. Prisma 스키마의 onDelete: Cascade 가 회원 한 명을 지우면 같은 머지에서 ClassMember 묶음·Assignment 진행 묶음·ContentAttempt 활동 기록까지 전부 사라진다. 운영 초기에는 “잘못 등록한 회원을 깨끗이 지운다”가 직관이라 그대로 뒀지만, 두 가지가 동시에 누적되며 한계가 왔다.

첫째 — 학습 기록 보존이 필요해졌다. 회원이 6 개월 활동한 누적 데이터가 단 한 번의 DELETE 로 사라지면 운영자의 분기별 통계 리포트가 빈 칸이 된다. 같은 머지의 변경 명세서 02-11 에서 PM 이 직접 “회원·클래스·운영자 삭제 시 학습 기록은 보존돼야 한다”고 명문화했다.

둘째 — 감사 추적이 필요해졌다. “누가 언제 무엇을 지웠는지” 가 운영자 대시보드에서 보여야 했다. 물리적 삭제는 흔적 자체가 사라지기 때문에 감사 로그를 별도로 짜야 했지만, soft delete 면 deletedAt 타임스탬프가 그대로 흔적이 된다.

세 번째 — 잘못된 삭제의 복원 경로가 필요해졌다. 운영자가 실수로 클래스를 지웠을 때 물리적 삭제 환경에서는 백업 복구 절차가 필요하지만, soft delete 면 deletedAt = null 한 줄이 복원이다.

이 세 가지 요구가 한 명세서로 모이면서 본 머지의 범위가 정해졌다. deletedAt nullable 컬럼 한 줄 + 삭제 API 6 개 + 기존 쿼리 27 곳의 필터 추가 + Auth 차단 5 줄 + FE 공통 다이얼로그 1 개. 시간상으로는 한 머지 11:30 안에 끝낸 BE 7 commits + FE 2 commits, 그리고 다음 날 보완 1 commit 의 묶음이다.

📌 핵심: Soft delete 의 본질은 “삭제”라는 동사를 “deletedAt 컬럼 set”으로 바꾸는 작업이 아니라, “이 도메인 모델을 조회하는 모든 함수가 같은 어휘로 활성을 판단하도록” 만드는 작업이다. 컬럼 한 줄을 추가하는 비용보다 27 곳의 어휘 통일 비용이 훨씬 크다.


⚖️ 설계 결정 6 건 — 도메인 모델 표준 어휘 정착

본 머지의 6 가지 핵심 결정과 각 트레이드오프를 정리한다.

결정 1. deletedAt nullable 한 컬럼 vs 별도 archived_<model> 테이블

대안은 두 가지였다. (A) Member 테이블에 deletedAt DateTime? 한 줄 추가, (B) archived_members 별도 테이블로 row 이동.

(B) 안은 활성 테이블이 깨끗해진다는 장점이 있지만, 외래키 관계가 ClassMember.memberId / Assignment.memberId / ContentAttempt.memberId 처럼 회원 id 를 참조하는 모든 테이블에서 깨진다. soft delete 의 핵심 요구가 “활동 기록 보존” 이었으므로 (A) 안을 선택했다. Prisma 의 @@index([academyId, deletedAt]) 복합 인덱스 한 줄로 활성 회원 조회의 추가 비용도 거의 0 으로 잡았다.

결정 2. status enum 동시 변경 vs deletedAt 만 변경

Member 모델은 이미 status: StudentStatus enum 을 ACTIVE / INACTIVE / WITHDRAWN 으로 갖고 있었다. soft delete 시 status = WITHDRAWN 도 같이 바꿀지가 결정 거리였다.

결론은 두 컬럼 모두 변경. 이유는 기존 통계 쿼리의 호환성이다. 분기별 회원 추이 리포트는 status = ACTIVE 카운트를 그대로 쓰고 있어서 deletedAt 만 set 하면 삭제된 회원이 ACTIVE 카운트에 그대로 잡힌다. 두 컬럼을 같은 트랜잭션 안에서 바꿔야 기존 쿼리도 새 쿼리도 같은 어휘를 갖는다.

결정 3. delete-check + DELETE 두 단계 API 분리

대안은 (A) DELETE 한 번 호출 → 409 응답 → 클라이언트가 사유 표시, (B) GET delete-check → 응답으로 사전 가능 여부 + 영향도 → DELETE.

(A) 안은 호출이 한 번이라 깔끔해 보이지만, 클라이언트가 “취소” 버튼만 누른 경우에도 서버까지 다녀와야 한다. 또 영향도(“이 회원이 진행 중인 과제 2 건이 함께 만료됩니다”)를 보여주려면 결국 DELETE 응답 안에 impacts 필드를 채워야 한다. 그러면 삭제 전 미리보기삭제 후 결과가 같은 응답 모양에 섞인다.

(B) 안을 선택했다. 두 단계 분리의 트레이드오프는 왕복 1 회 증가인데, 운영자 UX 가 “삭제 다이얼로그 열기 → 영향도 표시 → [취소] 또는 [삭제]” 흐름이라 영향도 조회가 다이얼로그를 여는 시점에 한 번만 일어난다. 왕복 비용이 일상 흐름과 맞물려 사라진다.

결정 4. dependent 정리는 같은 트랜잭션 안

대안은 (A) 회원 deletedAt 만 set, dependent (ClassMember.unassignedAt / Assignment.status = EXPIRED) 정리는 별도 배치, (B) 한 트랜잭션 안에서 dependent 까지 같이 정리.

(A) 안은 트랜잭션 짧아지는 대신, 배치 주기 사이에 “deletedAt 은 set 됐는데 ClassMember 는 활성” 인 중간 상태가 발생한다. 다음 절의 27 곳 필터 추가 중 일부는 바로 이 중간 상태를 막기 위해 들어가야 한다.

(B) 안을 선택했다. prisma.$transaction(async (tx) => { ... }) 안에서 세 줄을 한 번에 굴린다. 운영자가 DELETE /academy/members/:id 한 번 호출하면 응답 시점에 이미 dependent 까지 정리된 일관 상태가 보장된다.

결정 5. [academyId, deletedAt] 복합 인덱스

soft delete 모델의 거의 모든 조회 쿼리가 where: { academyId, deletedAt: null } 모양이다. 두 컬럼 모두 high-selectivity 가 아니지만 복합 인덱스 + Index ScanacademyId 단일 인덱스 → row 전체 스캔 후 deletedAt 필터링 보다 빠르다. Postgres EXPLAIN 으로 확인한 결과, 1,000 명 회원 테이블에서 Index Scan using members_academyId_deletedAt_idx 가 일관되게 잡혔다.

비용은 인덱스 3 개 추가 → 쓰기 비용 미세 증가다. soft delete 모델이 쓰기 빈도 낮은 마스터 데이터 (회원·클래스·운영자) 라 트레이드오프가 명확하다.

결정 6. 통계 쿼리는 의도적으로 필터를 적용하지 않는다

같은 머지에서 27 곳의 필터를 추가했지만, 통계·집계 쿼리는 의도적으로 예외로 뒀다. 이유는 두 가지.

첫째 — 분기별 누적 회원 카운트가 과거 활동까지 포함해야 의미가 있다. 작년 4 분기에 활동했던 회원이 올해 1 분기에 탈퇴해도, 작년 4 분기 카운트는 그대로 그 회원을 포함해야 운영자가 분기 비교를 할 수 있다.

둘째 — 운영자 대시보드의 전체 누적 활동 시간지금 활성인 회원의 활동 시간만 보여주면 안 된다. 탈퇴 회원의 누적까지 합쳐야 고객사의 누적 활동량이 정직하게 잡힌다.

⚠️ 주의: 통계 예외 룰이 본 머지의 가장 헷갈리는 결정이다. PR 리뷰에서 “왜 여기에는 deletedAt 필터를 안 걸었지?” 라는 질문이 두 번 나왔고, 그때마다 코드 주석으로 “통계: 의도적 제외” 를 박아 둬야 다음 리뷰에서 같은 질문이 안 돈다. 본 머지 이후 도입한 // note: 통계 — 삭제된 데이터 포함 의도 한 줄 주석이 표준이 됐다.


🛠️ 구현 4 단계 — 스키마부터 FE 다이얼로그까지

본 머지의 9 commits 를 4 단계로 묶어 핵심 코드만 인용한다.

1 단계. Prisma 스키마 + 마이그레이션

prisma/schema.prisma 의 세 모델에 deletedAt 한 줄과 인덱스 한 줄을 추가했다.

/// Member - 회원
model Student {
  id        String @id @default(cuid())
  userId    String @unique
  academyId Int

  name        String
  grade       Int
  // ... 기존 필드 생략

  status      StudentStatus @default(ACTIVE)
  createdAt DateTime  @db.Timestamptz @default(now())
  updatedAt DateTime  @db.Timestamptz @updatedAt
  deletedAt DateTime? @db.Timestamptz // soft delete — null = 활성, set = 삭제됨

  // Relations 생략

  @@index([academyId])
  @@index([academyId, deletedAt])  // 활성 회원 조회 가속용 복합 인덱스
  @@map("students")
}

같은 패턴이 Class·Teacher 모델에도 들어갔다. 마이그레이션 SQL 은 멱등 적용을 위해 IF NOT EXISTS 를 명시적으로 명문화했다.

-- 20260211_task58_soft_delete/migration.sql

-- 1. deletedAt 컬럼 추가
ALTER TABLE "students" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMPTZ;
ALTER TABLE "classes"  ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMPTZ;
ALTER TABLE "teachers" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMPTZ;

-- 2. 인덱스 추가 (academyId + deletedAt 복합)
CREATE INDEX IF NOT EXISTS "students_academyId_deletedAt_idx" ON "students"("academyId", "deletedAt");
CREATE INDEX IF NOT EXISTS "classes_academyId_deletedAt_idx"  ON "classes" ("academyId", "deletedAt");
CREATE INDEX IF NOT EXISTS "teachers_academyId_deletedAt_idx" ON "teachers"("academyId", "deletedAt");

🔍 단서: Prisma 의 prisma migrate devIF NOT EXISTS 가 없어도 첫 적용은 통과한다. 문제는 이미 회원/클래스/운영자 테이블에 deletedAt 컬럼이 들어간 환경에 같은 마이그레이션이 재적용될 때 — 운영 DB 의 백업·복원·복제 시나리오에서 흔하다. IF NOT EXISTS 한 줄이 이런 멱등 재적용 사고를 막는다.

2 단계. 삭제 API — delete-check + DELETE 두 단계

세 모델 각각에 동일 패턴의 두 API 를 추가했다.

// apps/api/src/application/controllers/academy-class.controller.ts

// 삭제 전 영향도 체크
@Get(':id/delete-check')
@Roles('ACADEMY_OWNER')
@ApiOperation({ summary: '클래스 삭제 전 영향도 체크' })
@ApiResponse({ status: 200, type: ClassDeleteCheckResponseDto })
async getClassDeleteCheck(
  @Headers('authorization') authHeader: string,
  @Param('id', ParseIntPipe) id: number,
): Promise<ClassDeleteCheckResponseDto> {
  const payload = this.decodeToken(authHeader);
  return this.classService.getClassDeleteCheck(id, payload.academyId);
}

// 실 삭제 (Soft Delete)
@Delete(':id')
@Roles('ACADEMY_OWNER')
@ApiOperation({ summary: '클래스 삭제 (Soft Delete)' })
@ApiResponse({ status: 204, description: '삭제 성공' })
@ApiResponse({ status: 409, description: '소속 회원이 있어 삭제 불가' })
@HttpCode(HttpStatus.NO_CONTENT)
async deleteClass(
  @Headers('authorization') authHeader: string,
  @Param('id', ParseIntPipe) id: number,
): Promise<void> {
  const payload = this.decodeToken(authHeader);
  await this.classService.softDeleteClass(id, payload.academyId);
}

delete-check 응답은 canDelete: true | false 분기를 가진다. false 면 reason 머신 리더블 코드 (HAS_ACTIVE_STUDENTS / HAS_ASSIGNED_CLASSES) 와 details 가, true 면 impacts 가 채워진다.

// apps/api/src/application/services/academy-class.application.service.ts

async getClassDeleteCheck(classId: number, academyId: number) {
  // 클래스 존재 확인 (deletedAt IS NULL)
  const cls = await this.prisma.class.findFirst({
    where: { id: classId, academyId, deletedAt: null },
    include: {
      classStudents: {
        where: { unassignedAt: null },
        include: { student: { select: { id: true, name: true, deletedAt: true } } },
      },
      classTeachers: {
        where: { unassignedAt: null },
        include: { teacher: { select: { id: true, name: true } } },
      },
      curricula: true,
    },
  });

  if (!cls) throw new NotFoundException('클래스 정보를 찾을 수 없습니다.');

  // 활성 회원 확인 (deletedAt 가 null 인 회원만)
  const activeStudents = cls.classStudents.filter((cs) => cs.student.deletedAt === null);

  if (activeStudents.length > 0) {
    return {
      canDelete: false,
      reason: 'HAS_ACTIVE_STUDENTS',
      details: {
        activeStudentCount: activeStudents.length,
        activeStudentNames: activeStudents.slice(0, 5).map((cs) => cs.student.name),
        assignedTeachers: cls.classTeachers.map((ct) => ct.teacher.name),
      },
    };
  }

  return {
    canDelete: true,
    impacts: {
      assignedTeachers: cls.classTeachers.map((ct) => ct.teacher.name),
      curriculumCount: cls.curricula.length,
    },
  };
}

실 삭제는 결정 4 의 트랜잭션 패턴을 그대로 따른다.

async softDeleteClass(classId: number, academyId: number): Promise<void> {
  // 클래스 존재 + 활성 회원 검증 (생략)

  // 트랜잭션으로 처리 — Class soft delete + 운영자 배정 자동 해제
  await this.prisma.$transaction(async (tx) => {
    const now = new Date();

    // 1. Class soft delete
    await tx.class.update({
      where: { id: classId },
      data: { deletedAt: now },
    });

    // 2. ClassTeacher 활성 배정 해제
    await tx.classTeacher.updateMany({
      where: { classId, unassignedAt: null },
      data: { unassignedAt: now },
    });
  });

  this.logger.log(`Class ${classId} soft deleted successfully`);
}

Member 삭제는 한 단계 더 무거워 세 줄 트랜잭션 이 된다.

async softDeleteStudent(
  studentId: string,
  academyId: number,
  caller: { role: string; userId: string },
): Promise<void> {
  // 회원 존재 확인 + 운영자 권한 검증 (생략)

  await this.prisma.$transaction(async (tx) => {
    const now = new Date();

    // 1. Member soft delete + status WITHDRAWN
    await tx.student.update({
      where: { id: studentId },
      data: {
        deletedAt: now,
        status: 'WITHDRAWN',  // 결정 2 — 기존 통계 쿼리 호환성
      },
    });

    // 2. ClassStudent 활성 배정 해제
    await tx.classStudent.updateMany({
      where: { studentId, unassignedAt: null },
      data: { unassignedAt: now },
    });

    // 3. 진행 중 과제 만료 처리
    await tx.assignment.updateMany({
      where: { studentId, status: 'ACTIVE' },
      data: { status: 'EXPIRED' },
    });
  });
}

📌 핵심: dependent 정리를 한 트랜잭션 안에 묶는 본질은 “운영자 한 번의 클릭이 응답 시점에 일관 상태를 보장한다” 는 약속이다. ClassMember.unassignedAtAssignment.status별도 배치로 정리되면, 같은 운영자가 1 초 뒤에 회원 목록을 새로고침했을 때 “이미 지웠는데 아직 활성 과제가 있는” 모순 상태를 본다. 트랜잭션이 모순 상태의 유일한 방벽이다.

3 단계. Auth 차단 — 5 줄로 막는다

삭제된 회원·운영자가 로그인하면 안 된다. academy-auth.application.service.tsstudent-auth.application.service.ts한 줄짜리 체크를 추가했다.

// academy-auth.application.service.ts — 운영자 로그인
if (user.role === 'TEACHER' && user.teacher?.deletedAt) {
  throw new UnauthorizedException('삭제된 계정입니다');
}
// student-auth.application.service.ts — 회원 로그인 + 토큰 refresh
if (user.student.deletedAt) {
  throw new UnauthorizedException('삭제된 계정입니다');
}

이 5 줄 (운영자 로그인 1 + 회원 로그인 1 + refresh 1 — 같은 패턴 분기 포함 총 5 분기) 이 삭제 후 지속 로그인 세션까지 막는다. JWT 토큰이 살아 있어도 refresh 가 거절되므로 최대 1 시간 안에 세션이 끊긴다.

⚠️ 주의: Auth 차단은 세션 만료까지의 grace period 를 갖는다. JWT access 토큰이 1 시간 짜리이고 refresh 가 30 일이라면, 회원을 지운 직후에도 기존 access 토큰의 잔여 시간 동안은 활동이 가능하다. 즉시 모든 세션을 끊고 싶다면 별도 토큰 블랙리스트 (Redis 등) 가 필요한데, 본 머지에서는 최대 1 시간 grace 를 수용하는 결정을 내렸다. 운영자 UX 가 실수 복원의 여유를 더 가치 있게 봤기 때문.

4 단계. 쿼리 필터 27 곳 — Application Service 16 + Domain Repository 11

본 머지의 가장 묵직한 작업이다. soft delete 모델을 조회하는 모든 함수에 deletedAt: null 한 줄이 들어가야 한다. 1 차 PR 로 Application Service 16 곳을 잡았다.

// academy-student.application.service.ts — getStudentsWithAccess
const where: Prisma.StudentWhereInput = {
  academyId,
  deletedAt: null,  // 도입 단계 추가
  ...(classIds && { classStudents: { some: { classId: { in: classIds } } } }),
  ...(search && { name: { contains: search, mode: 'insensitive' } }),
};

같은 패턴이 과제 조회 / 출석 조회 / 대시보드 카운트 / 배치 프로세스 곳곳에 들어갔다. 분포는 다음 표와 같다.

영역파일적용 함수 수
회원 조회academy-student.application.service.ts1 — getStudentsWithAccess
과제 조회·발급academy-assignment.application.service.ts2 — getAssignments / issueManualAssignments
출석 조회academy-attendance.application.service.ts2 — getAttendanceList × 2 분기
대시보드academy-dashboard.application.service.ts1 — studentsWithPoor
배치 프로세스batch-process.application.service.ts3 — 일일 평가 / 지표 집계 / 과제 필요 회원
관리자 통계admin-dashboard.application.service.ts1 — getStats count
출석 도메인attendance-calculation.service.ts1 — getAcademyAttendanceStats
클래스 조회academy-class.application.service.ts2 — getClasses / getClassById
클래스 출석academy-attendance.application.service.ts3 — 클래스 조인 3 분기
운영자 조회academy-teacher.application.service.ts2 — getTeachers / getTeacherById
소계 (Application Service)16 곳

그러나 1 차 PR 직후 PM 코드 리뷰에서 Domain Repository 11 곳 누락이 추가로 발견됐다. Application Service 만 보고 끝낸 것이다.

// apps/api/src/domain/student/student.repository.ts
async findById(id: string, tx?: TransactionContext) {
  const client = this.getClient(tx);
  return await client.student.findFirst({
    where: { id: String(id), deletedAt: null },  // 보완 단계 추가
    include: STUDENT_INCLUDE,
  }) as StudentWithRelations | null;
}

async findByClassId(classId: number | string, tx?: TransactionContext) {
  const client = this.getClient(tx);
  return await client.student.findMany({
    where: {
      deletedAt: null,  // 보완 단계 추가
      classStudents: { some: { classId: Number(classId) } },
    },
    include: STUDENT_INCLUDE,
    orderBy: { name: 'asc' },
  }) as StudentWithRelations[];
}

같은 패턴이 findActiveStudents / findByConsecutiveDays / findDowngradedStudents / findByTrackState 까지 6 곳, Class Repository 의 findById / findByAcademyId / findByGradeSemester / getDistinctGrades / getDistinctSemesters 5 곳에 들어갔다. Application Service 16 + Domain Repository 11 = 총 27 곳.

🔍 단서: Application Service 만 잡고 Domain Repository 를 놓치는 패턴이 진짜 위험한 이유는 — 배치 프로세스가 Domain Repository 를 직접 호출하기 때문이다. 운영자 화면 조회는 Application Service 를 거치지만 일일 배치·레벨 조정 배치·통계 집계 배치는 Repository 를 곧장 부른다. 이 비대칭이 “화면에서는 안 보이는데 배치에는 잡히는 회원” 이라는 가장 디버깅하기 까다로운 좀비 상태를 만든다.

거기다 다음 날 hotfix 가 한 번 더 터졌다 — student.repository.tsfindByEmail / findByLoginId 두 곳이 더 누락이었다. 운영자가 삭제된 회원의 이메일로 신규 등록을 시도하면 기존 삭제된 row 가 검색돼 “이미 등록된 이메일” 거부 응답이 돌아오는 사고였다. 이 hotfix 한 commit (5af2de6) 으로 27 곳 → 29 곳까지 늘었다.

5 단계. FE 공통 다이얼로그 — useCustom + useCustomMutation 한 흐름

프론트엔드는 세 모델 (Member / Class / Operator) 의 삭제 흐름을 한 컴포넌트로 받게 했다. entityType: 'student' | 'class' | 'teacher' prop 하나로 분기한다.

// apps/academy-portal/src/components/delete-confirmation-dialog.tsx
const CHECK_URL_MAP = {
  student: (id) => `/students/${id}/delete-check`,
  class:   (id) => `/classes/${id}/delete-check`,
  teacher: (id) => `/teachers/${id}/delete-check`,
};

const DELETE_URL_MAP = {
  student: (id) => `/students/${id}`,
  class:   (id) => `/classes/${id}`,
  teacher: (id) => `/teachers/${id}`,
};

export function DeleteConfirmationDialog({
  open, onOpenChange, entityType, entityId, entityName, onSuccess,
}: DeleteConfirmationDialogProps) {
  // delete-check API — 다이얼로그가 열릴 때만 호출
  const { data: checkData, isLoading: isChecking } = useCustom({
    url: CHECK_URL_MAP[entityType](entityId),
    method: 'get',
    queryOptions: { enabled: open },
  });

  const { mutate: deleteMutate, isPending: isDeleting } = useCustomMutation();

  const handleDelete = () => {
    deleteMutate(
      {
        url: DELETE_URL_MAP[entityType](entityId),
        method: 'delete' as any,
        values: {},
      },
      {
        onSuccess: () => {
          toast.success(`${entityName} ${label}이(가) 삭제되었습니다`);
          onOpenChange(false);
          onSuccess();
        },
        onError: (error: any) => {
          const msg = error?.response?.data?.message || '삭제에 실패했습니다';
          toast.error(msg);
        },
      },
    );
  };

  // 렌더링: canDelete 분기 → 영향도 표시 + [삭제] | 사유 표시 + [확인]
  // (생략)
}

한 컴포넌트 + 세 prop 매핑 으로 회원 상세 페이지 / 클래스 상세 페이지 / 운영자 수정 페이지 세 곳에서 동일 다이얼로그가 떴다. 목록 페이지에는 삭제 버튼이 없다. 결정 이유는 실수 방지 — 한 번의 잘못된 클릭이 마스터 데이터를 지우면 안 된다. 상세 페이지로 한 번 더 들어가야 삭제 버튼이 보인다.

마지막 FE 204 No Content 처리 버그가 한 commit 더 붙었다. Refine 의 useCustomMutation 이 DELETE 응답을 JSON 파싱하려다 빈 응답에서 에러를 던지던 문제였다.

// apps/academy-portal/src/providers/rest-data-provider.ts
const response = await httpClient(url, { method, body });

// 204 No Content 처리 — 보완 단계 추가
if (response.status === 204) {
  return { data: { id } };
}

const json = await response.json();
return {
  data: json.success !== undefined ? json.data : json,
};

12 줄짜리 작은 패치지만 세 도메인 모두의 삭제 UX 가 이 분기 한 줄에 달렸다.


📊 결과 — 한 머지 11:30 + 다음날 hotfix 1 commit

본 머지의 변경 라인 분포는 다음과 같다.

단계커밋변경 라인비고
1. 스키마 + 마이그레이션419a2e7+16 / -2모델 3 줄 + 인덱스 3 줄 + SQL 12 줄
2. Member 삭제 API943db82+219 / -0컨트롤러 49 + DTO 23 + 서비스 147
3. Class 삭제 APIfe27ac9+202 / -0컨트롤러 43 + DTO 40 + 서비스 119
4. Operator 삭제 APIefd0b61+156 / -0컨트롤러 43 + DTO 29 + 서비스 84
5. Auth 차단0d5687e+15 / -05 분기 한 줄 체크
6. 쿼리 필터 (Application)a8b81bc+32 / -2116 곳 일괄
7. 쿼리 필터 (Domain Repo 보완)a00f38a+22 / -911 곳 보완
8. FE 공통 다이얼로그 + 페이지26f061f+472 / -884다이얼로그 241 + 페이지 3 곳
9. FE 204 응답 처리a2bdc59+12 / -0rest-data-provider
10. 다음 날 hotfix5af2de6+2 / -0findByEmail / findByLoginId
합계10 commits+1,148 / -91627 → 29 touch points

타임라인은 한 머지 11:30 안에 응축돼 있다.

시각 (KST)커밋단계
10:37419a2e7스키마 + 마이그레이션
10:39943db82Member 삭제 API
10:43fe27ac9Class 삭제 API
10:46efd0b61Operator 삭제 API
10:470d5687eAuth 차단
10:53a8b81bc쿼리 필터 16 곳
11:06a00f38a쿼리 필터 11 곳 보완
11:3726f061fFE 다이얼로그 + 페이지
11:52a2bdc59FE 204 처리
다음 날 23:395af2de6findByEmail / findByLoginId hotfix

10:37 ~ 11:52 의 BE + FE 9 commits 가 같은 머지에서 일관성을 끝까지 끌고 갔다. 그러나 11:06 의 보완 단계다음 날 hotfix 가 같이 보여준 진짜 비용은 — “Application Service 만 봐서는 누락이 무조건 남는다” 였다. 이 학습이 다음 머지부터 grep -rn "deletedAt" apps/api/src/ 룰을 PR 체크리스트에 추가하는 단초가 됐다.

도식으로 정리하면 BEFORE → AFTER 의 차이가 더 명확해진다.

직접 정리한 Soft Delete deletedAt 한 컬럼이 27곳에 퍼지는 구조도
직접 정리한 Soft Delete deletedAt 한 컬럼이 27곳에 퍼지는 구조도

도식의 BOTTOM 트랜잭션 fan-out 영역이 결정 4 의 한 트랜잭션 dependent 정리 패턴을 시각화한 부분이다. 운영자 한 번의 DELETE /academy/students/:idMember.deletedAt + status WITHDRAWN + ClassMember.unassignedAt + Assignment.status EXPIRED 네 줄로 동시에 fan-out 되는 모습을 그렸다.

prisma.io

🔄 회고 — 결정 6 건의 사후 평가

본 머지 이후 5 개월의 운영을 거치면서 결정 6 건이 어떻게 살아남았는지 정리한다.

하나 — deletedAt nullable 단일 컬럼 결정 (결정 1) 은 유지. 별도 archive 테이블로 옮기지 않은 게 정답이었다. 운영 중 복원 요청이 두 번 들어왔는데 (운영자 실수로 클래스를 지운 케이스), UPDATE classes SET "deletedAt" = NULL WHERE id = 30; 한 줄로 끝났다. 별도 archive 테이블이었다면 row 이동 + 외래키 복구 + 운영 중단 시간까지 필요했을 작업이다.

둘 — status WITHDRAWN 동시 변경 (결정 2) 은 예상보다 더 중요했다. 분기별 통계 쿼리가 status = ACTIVE 카운트를 그대로 쓰는 케이스가 처음 잡은 것보다 많았다. 모니터링 대시보드의 5 종 카드 중 3 종이 status 필터만 쓰고 deletedAt 은 안 쓴다. 두 컬럼 동시 변경이 기존 쿼리의 무수정 호환을 보장한 핵심이다.

셋 — delete-check + DELETE 두 단계 분리 (결정 3) 는 유지. UX 가 영향도 미리보기에 의존하는 게 운영자 신뢰의 핵심이었다. 운영자 인터뷰에서 “삭제 누르기 전에 어떤 영향이 있는지 보이는 게 가장 안심된다” 는 피드백이 나왔다.

넷 — 같은 트랜잭션 dependent 정리 (결정 4) 는 유지. 별도 배치였다면 “운영자가 지웠는데 회원 목록에는 안 사라진” 중간 상태가 한 번이라도 발생했을 거다. 트랜잭션 한 함수가 분명한 응답 시점 보장을 만들었다.

다섯 — [academyId, deletedAt] 복합 인덱스 (결정 5) 는 유지. 회원 1,000 명 / 클래스 80 개 / 운영자 30 명 규모 고객사에서 EXPLAIN 결과 일관되게 Index Scan 이 잡혔다. 쓰기 비용 증가는 측정 불가 수준.

여섯 — 통계 의도적 제외 (결정 6) 는 주석 표준이 정착될 때까지 헷갈렸다. PR 리뷰에서 “여기에는 왜 deletedAt 필터를 안 걸지?” 가 두 번 더 나왔고, 본 머지 후 한 달 만에 “통계: 의도적 제외” 한 줄 주석을 코드 곳곳에 남기는 게 표준이 됐다. 명문화되지 않은 결정은 시간이 지나면 같은 질문이 반복된다.

가장 큰 학습은 결정 6 건 외부에 있었다. Application Service 만 봐서는 누락이 남는다. PM 리뷰가 잡지 않았다면 배치 프로세스가 삭제된 회원에게 다음 날 과제를 발행하는 사고로 갔을 가능성이 크다. 본 머지 이후 새 도메인 모델에 soft delete 를 도입할 때는 반드시 grep -rn "<modelName>\." apps/api/src/ 부터 돌려서 Application Service / Domain Repository / Auth / Batch / Statistics 다섯 영역을 한 번에 잡는다.


📋 정리 — 핵심 요약

본 머지의 6 결정과 27 곳의 분포를 한 표로 정리한다.

결정채택안트레이드오프5 개월 평가
1. 컬럼 형태deletedAt nullable 단일 컬럼활성 테이블에 삭제 row 잔존✅ 복원 1 줄로 끝남
2. status 동시 변경deletedAt + status WITHDRAWN 같이 set두 컬럼 모두 변경✅ 기존 통계 무수정 호환
3. API 분리delete-check + DELETE 2 단계왕복 1 회 증가✅ 운영자 신뢰 확보
4. dependent 정리같은 트랜잭션 안에서 fan-out트랜잭션 길어짐✅ 응답 시점 일관 상태
5. 인덱스[academyId, deletedAt] 복합쓰기 비용 미세 증가✅ Index Scan 일관
6. 통계 예외의도적으로 필터 미적용PR 리뷰 헷갈림⚠️ 주석 표준화 한 달 걸림
27 곳 분포어디적용 함수 수
Application Serviceacademy-*.application.service.ts × 6 + attendance-calculation.service.ts16 곳
Domain Repositorystudent.repository.ts + class.repository.ts11 곳
Authacademy-auth + student-auth 로그인·refresh5 분기
SchemaStudent / Class / Teacher 모델3 컬럼 + 3 인덱스
FEDeleteConfirmationDialog + 3 페이지 + 204 처리5 파일
합계27 → 29 (다음날 hotfix 2 곳 포함)

핵심을 세 줄로 다시 정리한다.

  1. Soft delete 의 본질은 “deletedAt 컬럼 set” 이 아니라 “도메인 모델을 조회하는 모든 함수의 어휘 통일” 이다. 컬럼 한 줄 추가의 비용보다 27 곳 어휘 통일의 비용이 훨씬 크다. Application Service 만 보면 Domain Repository 가 살아남는다.
  2. dependent 정리는 같은 트랜잭션 안에서 끝낸다. Member.deletedAt + status WITHDRAWN + ClassMember.unassignedAt + Assignment.status EXPIRED 네 줄이 한 응답 시점에 일관 상태가 된다. 별도 배치로 분리하면 모순 상태 grace period 가 무조건 생긴다.
  3. delete-check + DELETE 두 단계 API 가 운영자 신뢰의 핵심이다. 영향도 미리보기 → 사유 표시 → [취소] | [삭제] 흐름이 “한 번의 잘못된 클릭이 마스터 데이터를 지우는” 사고를 가장 직관적으로 막는다. 왕복 1 회의 비용은 UX 흐름 안에서 사라진다.

다음 편 (devlog-66) 에서는 본 머지가 도메인 모델 표준의 마지막 한 영역 이었다면, 그 직후 본격적으로 시작된 교육과정 자동 승급의 도메인 버그 3 건targetLevelIds 배열 / currentTargetIdx 인덱스 / completedLevelIds 교집합 세 영역에서 자동 승급 로직이 멈추거나 잘못 진행됐던 사고들을 A 톤 트러블슈팅으로 정리한다. 본 머지가 모델 한 줄의 어휘 통일이었다면, 다음 편은 배열 세 줄의 의미 정합성이다.


📚 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 중 실행 묶음