Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
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 정리까지 한 함수에서 —
Membersoft delete 한 줄이ClassMember unassign과Assignment 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 Scan 이 academyId 단일 인덱스 → 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 dev는IF 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.unassignedAt과Assignment.status가 별도 배치로 정리되면, 같은 운영자가 1 초 뒤에 회원 목록을 새로고침했을 때 “이미 지웠는데 아직 활성 과제가 있는” 모순 상태를 본다. 트랜잭션이 모순 상태의 유일한 방벽이다.
3 단계. Auth 차단 — 5 줄로 막는다
삭제된 회원·운영자가 로그인하면 안 된다. academy-auth.application.service.ts 와 student-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.ts | 1 — getStudentsWithAccess |
| 과제 조회·발급 | academy-assignment.application.service.ts | 2 — getAssignments / issueManualAssignments |
| 출석 조회 | academy-attendance.application.service.ts | 2 — getAttendanceList × 2 분기 |
| 대시보드 | academy-dashboard.application.service.ts | 1 — studentsWithPoor |
| 배치 프로세스 | batch-process.application.service.ts | 3 — 일일 평가 / 지표 집계 / 과제 필요 회원 |
| 관리자 통계 | admin-dashboard.application.service.ts | 1 — getStats count |
| 출석 도메인 | attendance-calculation.service.ts | 1 — getAcademyAttendanceStats |
| 클래스 조회 | academy-class.application.service.ts | 2 — getClasses / getClassById |
| 클래스 출석 | academy-attendance.application.service.ts | 3 — 클래스 조인 3 분기 |
| 운영자 조회 | academy-teacher.application.service.ts | 2 — 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.ts 의 findByEmail / 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 삭제 API | 943db82 | +219 / -0 | 컨트롤러 49 + DTO 23 + 서비스 147 |
| 3. Class 삭제 API | fe27ac9 | +202 / -0 | 컨트롤러 43 + DTO 40 + 서비스 119 |
| 4. Operator 삭제 API | efd0b61 | +156 / -0 | 컨트롤러 43 + DTO 29 + 서비스 84 |
| 5. Auth 차단 | 0d5687e | +15 / -0 | 5 분기 한 줄 체크 |
| 6. 쿼리 필터 (Application) | a8b81bc | +32 / -21 | 16 곳 일괄 |
| 7. 쿼리 필터 (Domain Repo 보완) | a00f38a | +22 / -9 | 11 곳 보완 |
| 8. FE 공통 다이얼로그 + 페이지 | 26f061f | +472 / -884 | 다이얼로그 241 + 페이지 3 곳 |
| 9. FE 204 응답 처리 | a2bdc59 | +12 / -0 | rest-data-provider |
| 10. 다음 날 hotfix | 5af2de6 | +2 / -0 | findByEmail / findByLoginId |
| 합계 | 10 commits | +1,148 / -916 | 27 → 29 touch points |
타임라인은 한 머지 11:30 안에 응축돼 있다.
| 시각 (KST) | 커밋 | 단계 |
|---|---|---|
| 10:37 | 419a2e7 | 스키마 + 마이그레이션 |
| 10:39 | 943db82 | Member 삭제 API |
| 10:43 | fe27ac9 | Class 삭제 API |
| 10:46 | efd0b61 | Operator 삭제 API |
| 10:47 | 0d5687e | Auth 차단 |
| 10:53 | a8b81bc | 쿼리 필터 16 곳 |
| 11:06 | a00f38a | 쿼리 필터 11 곳 보완 |
| 11:37 | 26f061f | FE 다이얼로그 + 페이지 |
| 11:52 | a2bdc59 | FE 204 처리 |
| 다음 날 23:39 | 5af2de6 | findByEmail / findByLoginId hotfix |
10:37 ~ 11:52 의 BE + FE 9 commits 가 같은 머지에서 일관성을 끝까지 끌고 갔다. 그러나 11:06 의 보완 단계와 다음 날 hotfix 가 같이 보여준 진짜 비용은 — “Application Service 만 봐서는 누락이 무조건 남는다” 였다. 이 학습이 다음 머지부터 grep -rn "deletedAt" apps/api/src/ 룰을 PR 체크리스트에 추가하는 단초가 됐다.
도식으로 정리하면 BEFORE → AFTER 의 차이가 더 명확해진다.

도식의 BOTTOM 트랜잭션 fan-out 영역이 결정 4 의 한 트랜잭션 dependent 정리 패턴을 시각화한 부분이다. 운영자 한 번의 DELETE /academy/students/:id 가 Member.deletedAt + status WITHDRAWN + ClassMember.unassignedAt + Assignment.status EXPIRED 네 줄로 동시에 fan-out 되는 모습을 그렸다.
🔄 회고 — 결정 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 Service | academy-*.application.service.ts × 6 + attendance-calculation.service.ts | 16 곳 |
| Domain Repository | student.repository.ts + class.repository.ts | 11 곳 |
| Auth | academy-auth + student-auth 로그인·refresh | 5 분기 |
| Schema | Student / Class / Teacher 모델 | 3 컬럼 + 3 인덱스 |
| FE | DeleteConfirmationDialog + 3 페이지 + 204 처리 | 5 파일 |
| 합계 | 27 → 29 (다음날 hotfix 2 곳 포함) |
핵심을 세 줄로 다시 정리한다.
- Soft delete 의 본질은 “deletedAt 컬럼 set” 이 아니라 “도메인 모델을 조회하는 모든 함수의 어휘 통일” 이다. 컬럼 한 줄 추가의 비용보다 27 곳 어휘 통일의 비용이 훨씬 크다. Application Service 만 보면 Domain Repository 가 살아남는다.
- dependent 정리는 같은 트랜잭션 안에서 끝낸다.
Member.deletedAt + status WITHDRAWN + ClassMember.unassignedAt + Assignment.status EXPIRED네 줄이 한 응답 시점에 일관 상태가 된다. 별도 배치로 분리하면 모순 상태 grace period 가 무조건 생긴다. - delete-check + DELETE 두 단계 API 가 운영자 신뢰의 핵심이다. 영향도 미리보기 → 사유 표시 → [취소] | [삭제] 흐름이 “한 번의 잘못된 클릭이 마스터 데이터를 지우는” 사고를 가장 직관적으로 막는다. 왕복 1 회의 비용은 UX 흐름 안에서 사라진다.
다음 편 (devlog-66) 에서는 본 머지가 도메인 모델 표준의 마지막 한 영역 이었다면, 그 직후 본격적으로 시작된 교육과정 자동 승급의 도메인 버그 3 건 — targetLevelIds 배열 / currentTargetIdx 인덱스 / completedLevelIds 교집합 세 영역에서 자동 승급 로직이 멈추거나 잘못 진행됐던 사고들을 A 톤 트러블슈팅으로 정리한다. 본 머지가 모델 한 줄의 어휘 통일이었다면, 다음 편은 배열 세 줄의 의미 정합성이다.
📚 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 중 실행 묶음