어드민에서 회사를 삭제했다. 회사 스위처 목록에서 사라졌고, 화면상으로는 완전히 지워진 것처럼 보였다. 그런데 그 회사에 속했던 직원의 토큰으로 API를 직접 호출하면, 권한 검사를 그냥 통과했다. 삭제한 회사의 데이터가 그대로 응답에 실려 나왔다.
버그는 삭제 방식 자체에 있었다. 부모만 지우고 자식은 살려뒀더니, 자식만 보는 접근제어가 부모의 죽음을 보지 못했다.
왜 부모만 지웠나 (parent-only soft-delete)
“회사 삭제”는 물리 삭제가 아니다. 결제·계약 내역은 법정 5년 의무보존이라 지울 수 없고, 잘못 누른 삭제는 되돌릴 수 있어야 한다. 그래서 soft-delete인데, 범위를 두 가지로 잡을 수 있었다.
| 방식 | 복구 | 문제 |
|---|---|---|
| 자식 테이블까지 전부 cascade soft-delete | 지옥. 원래 개별 삭제된 행과 이번에 삭제된 행을 구분 못 해 “무엇을 되살릴지” 판별 불가 | 마이그레이션 대량, deletedAt 없는 테이블 다수 |
| 회사(부모)만 soft-delete | deletedAt 한 줄 해제 | 자식 접근 차단이 자동이 아님 |
복구 요구가 결정적이었다. cascade는 “어떤 자식이 이번 삭제로 죽었나”를 사후에 복원할 수 없다. 부모 한 행에 deletedAt만 찍으면 복구는 그 한 줄을 비우는 일이고, 결제·계약 데이터는 물리적으로 남아 보존 의무도 저절로 충족된다. 그래서 parent-only를 골랐다.
절반만 막힌다
“부모를 지우면 자식 접근도 막히는 것 아닌가?” 절반만 맞다.
화면 노출 경로는 자동으로 막힌다. 내 회사 목록을 가져오는 쿼리는 TypeORM relation 조인을 타는데, soft-delete된 회사는 조인에서 제외된다. 그래서 회사 스위처에서 삭제된 회사가 사라진다. 여기까지 보고 “지워졌다”고 판단했다.
접근제어 계층엔 구멍이 남는다. 권한을 확인하는 코드는 회사를 조회하지 않는다. 직원·어드바이저 같은 자식 테이블을 userId + companyId로 직접 조회해서, 그 사용자가 이 회사 소속인지만 본다.
requireEmployee(userId, companyId): employee = employeeRepo.findOne({ userId, companyId }) ← 자식만 본다 if (!employee) throw Forbidden return employee ← 부모 생사는 안 본다parent-only면 이 직원 행은 살아있다. 그래서 삭제된 회사의 companyId를 아는 사용자가 API를 직접 때리면, 권한 검사는 “소속 맞네” 하고 통과시킨다. 화면이 알아서 걸러주던 건 이 직접 호출 경로를 우회당한다. UI의 자동 제외를 접근제어로 착각한 것이다.
이건 멀티테넌트 격리에서 반복되는 함정의 변형이다. requireXxx는 “소속”을 확인하지 “리소스가 아직 유효한가”를 확인하지 않는다. 평소엔 소속 확인만으로 충분하다가, 부모가 죽고 자식만 남는 순간 둘이 갈라진다.
입구에서 부모 생존을 함께 본다
고치는 자리는 접근제어의 입구다. 자식을 조회하는 그 지점에서, 부모가 아직 살아있는지(deletedAt IS NULL)를 한 번 더 확인한다.
private async isCompanyActive(companyId: string): Promise<boolean> { // findOne은 기본적으로 soft-delete된 행을 제외한다 → null이면 삭제된 회사 const company = await this.companyRepo.findOne({ where: { id: companyId }, select: ['id'], }); return company != null;}부모를 우회하는 모든 입구(getCallerPosition·requireAdmin·requireEmployee)에 이 확인을 붙였다. 핵심은 추가 비용을 안 만드는 것이다. 기존 자식 조회 쿼리와 Promise.all로 묶으면 라운드트립이 늘지 않는다.
const [employee, companyActive] = await Promise.all([ this.employeeRepo.findOne({ where: { userId, companyId } }), this.isCompanyActive(companyId),]);if (!companyActive) throw new ForbiddenException();if (!employee) throw new ForbiddenException();삭제된 회사면 소속이 맞아도 거부된다. 회귀 테스트는 “삭제된 회사 + 잔존 직원 → 차단”을 그대로 못박았다.
데이터를 살려두면, 데이터에 매달린 것도 산다
parent-only의 더 미묘한 부수효과는 결제였다. 데이터가 물리적으로 남으므로, 회사를 삭제해도 구독 예약결제(webhook 기반)는 회사 상태와 무관하게 계속 청구된다. 삭제했는데 카드는 매달 빠지는 것이다.
soft-delete는 “이 행을 숨긴다”일 뿐, 그 행에 매달린 외부 연동(예약결제·예약발송)을 멈추지 않는다. 그래서 회사 삭제 흐름이 softDelete를 호출하기 전에 예약결제부터 취소하고 구독을 즉시 해지하게 했다. 순서가 핵심이다. 결제 중단을 먼저 커밋해야, 중간에 실패해 재시도해도 “이미 삭제된 회사에 청구되는” 창이 생기지 않는다. 취소는 멱등이라 중복 호출돼도 안전하다.
복구는 일부러 결제를 자동 재개하지 않는다. deletedAt만 비우면 데이터는 돌아오지만, 끊었던 카드 청구가 저절로 되살아나면 안 된다. 재구독은 사람이 다시 누른다.
그래서
“부모만 지우면 자식 접근도 막힌다”는 직관은 틀렸다. soft-delete는 조회를 숨길 뿐, 두 가지를 자동으로 해주지 않는다. 자식 테이블을 직접 보는 접근제어, 그리고 데이터에 매달린 외부 연동이다. parent-only를 고를 땐 이 둘을 손으로 챙겨야 한다. 부모의 생존을 권한 검사 입구에서 함께 보고, 외부 청구는 삭제 시점에 명시적으로 끊는다. 화면에서 사라졌다고 막힌 게 아니다. 막는 건 언제나 입구다.