---
title: "삭제 판정에 권한 필터가 걸린 조회 쿼리를 재사용하면 데이터가 사라진다"
description: "폴더가 비었는지 판정할 때 화면용 목록 조회를 재사용했더니, 숨겨진 파일이 있는 폴더도 빈 폴더로 판정돼 삭제됐습니다. 존재 여부 판정은 권한 필터가 아니라 관계의 단일 진실 원천으로 해야 하는 이유입니다."
date: 2026-11-07
updated: 2026-11-07T06:11:28.208Z
tags: [backend, authorization, data-integrity, pitfall]
canonical: https://blog.wooncloud.com/posts/delete-guard-visibility-filter-data-loss
---

폴더 안에 파일이 있으면 삭제를 막는 가드를 넣었는데, 막으려던 사고가 그대로 재현되는 걸 발견했습니다.

폴더가 비었는지는 화면에 파일 목록을 그릴 때 쓰던 기존 함수를 `limit: 1` 로 재사용해서 판정했습니다.

```ts
// 비어 있음 판정에 목록 조회를 재사용
const files = await listFolderFiles(folderId, { viewer, limit: 1 });
const isEmpty = files.length === 0;   // viewer 에게 "보이는" 파일만 센다
```

그럴듯하죠? 문제는 이 함수에 권한과 가시성 게이트가 들어 있다는 점입니다. 일부 멤버에게 숨긴 글처럼, 요청자(viewer)마다 행을 걸러냅니다.

## 보는 사람에 따라 폴더가 비어 있습니다

사용자 A 가 자기에게만 보이는 파일을 폴더에 넣습니다. 일반 멤버 B 에게는 그 파일이 숨겨져 있습니다.

B 가 폴더를 삭제하면 B 기준 조회는 그 파일을 돌려주지 않으니 "빈 폴더"로 판정되고 삭제가 허용됩니다. 폴더만 사라지고 파일 본체는 고아가 되어 아무도 찾지 못합니다. 데이터 손실입니다.

![가시성 필터가 걸린 목록 조회로 빈 폴더를 판정하면 숨겨진 파일이 고아가 되지만, 권한 필터 없는 관계 그 자체로 판정하면 삭제가 거부된다.](/images/posts/delete-guard-visibility-filter-data-loss/4aca5cfe-53cf-48b5-8aa0-5c0a5093f0b6.webp)

![일반 멤버 기준 조회로 빈 폴더를 판정하면 숨겨진 파일이 고아가 되지만, 권한 필터 없는 관계 테이블로 판정하면 삭제가 거부된다.](/images/posts/delete-guard-visibility-filter-data-loss/8dac3cb1-1287-4b74-9381-782e1cbe2080.svg)

## "보이는가"와 "존재하는가"는 다른 질문입니다

가시성과 무관한 관계 테이블만 보도록 바꿨습니다.

```ts
// 권한 필터 없이 관계 그 자체만 본다
const hasAny = await folderHasChildren(folderId);
const isEmpty = !hasAny;
```

**삭제, 비우기, 덮어쓰기 같은 파괴적 작업의 존재 여부 판정은 표시용 권한 필터가 아니라 관계의 단일 진실 원천(SSOT)으로 해야 합니다.** 권한 필터는 "이 사용자가 볼 수 있나"에 답하는 도구인데, "존재하나"에 쓰면 누락이 곧 손실이 됩니다.

트레이드오프도 있습니다. SSOT 는 화면에는 안 보이지만 관계는 남은 죽은 레코드까지 잡아서 삭제를 과하게 막을 수 있습니다. 그래도 데이터 손실보다는 과차단이 낫습니다. 의심스러우면 막고 "먼저 비워 주세요"를 안내하는 쪽이 안전합니다.

## 같은 함정이 반복되는 자리

- 권한 필터가 걸린 `SELECT ... WHERE visible_to(user)` 를 `COUNT` 나 `EXISTS` 대용으로 재사용
- 소프트 삭제, 테넌트, 숨김 필터가 끼어든 "자식 있나?" 체크
- ORM 의 default scope(soft-delete, tenant)가 자동으로 붙는 환경에서 `.exists?` 호출

이 판정이 틀리면 데이터가 사라지나? 그렇다는 답이 나오면, 권한이나 가시성 스코프가 빠진 경로로 다시 세어 봅니다.
