삭제 판정에 권한 필터가 걸린 조회 쿼리를 재사용하면 데이터가 사라진다
폴더가 비었는지 판정할 때 화면용 목록 조회를 재사용했더니, 숨겨진 파일이 있는 폴더도 빈 폴더로 판정돼 삭제됐습니다. 존재 여부 판정은 권한 필터가 아니라 관계의 단일 진실 원천으로 해야 하는 이유입니다.
폴더 안에 파일이 있으면 삭제를 막는 가드를 넣었는데, 막으려던 사고가 그대로 재현되는 걸 발견했습니다.
폴더가 비었는지는 화면에 파일 목록을 그릴 때 쓰던 기존 함수를 limit: 1 로 재사용해서 판정했습니다.
// 비어 있음 판정에 목록 조회를 재사용
const files = await listFolderFiles(folderId, { viewer, limit: 1 });
const isEmpty = files.length === 0; // viewer 에게 "보이는" 파일만 센다그럴듯하죠? 문제는 이 함수에 권한과 가시성 게이트가 들어 있다는 점입니다. 일부 멤버에게 숨긴 글처럼, 요청자(viewer)마다 행을 걸러냅니다.
보는 사람에 따라 폴더가 비어 있습니다
사용자 A 가 자기에게만 보이는 파일을 폴더에 넣습니다. 일반 멤버 B 에게는 그 파일이 숨겨져 있습니다.
B 가 폴더를 삭제하면 B 기준 조회는 그 파일을 돌려주지 않으니 "빈 폴더"로 판정되고 삭제가 허용됩니다. 폴더만 사라지고 파일 본체는 고아가 되어 아무도 찾지 못합니다. 데이터 손실입니다.
"보이는가"와 "존재하는가"는 다른 질문입니다
가시성과 무관한 관계 테이블만 보도록 바꿨습니다.
// 권한 필터 없이 관계 그 자체만 본다
const hasAny = await folderHasChildren(folderId);
const isEmpty = !hasAny;삭제, 비우기, 덮어쓰기 같은 파괴적 작업의 존재 여부 판정은 표시용 권한 필터가 아니라 관계의 단일 진실 원천(SSOT)으로 해야 합니다. 권한 필터는 "이 사용자가 볼 수 있나"에 답하는 도구인데, "존재하나"에 쓰면 누락이 곧 손실이 됩니다.
트레이드오프도 있습니다. SSOT 는 화면에는 안 보이지만 관계는 남은 죽은 레코드까지 잡아서 삭제를 과하게 막을 수 있습니다. 그래도 데이터 손실보다는 과차단이 낫습니다. 의심스러우면 막고 "먼저 비워 주세요"를 안내하는 쪽이 안전합니다.
같은 함정이 반복되는 자리
- 권한 필터가 걸린
SELECT ... WHERE visible_to(user)를COUNT나EXISTS대용으로 재사용 - 소프트 삭제, 테넌트, 숨김 필터가 끼어든 "자식 있나?" 체크
- ORM 의 default scope(soft-delete, tenant)가 자동으로 붙는 환경에서
.exists?호출
이 판정이 틀리면 데이터가 사라지나? 그렇다는 답이 나오면, 권한이나 가시성 스코프가 빠진 경로로 다시 세어 봅니다.