배경
서비스를 운영하다 보면 업로드된 이미지·파일이 DB 레코드와의 연결이 끊긴 채 디스크에 남는 경우가 생긴다.
회원 탈퇴, 프로필 삭제, 이미지 교체 등 다양한 경로로 발생하며, 이를 고아 파일(orphan file) 이라 부른다.
어느 평범한 월요일 출근 후 이메일확인 한 순간, 서버업체에서 연락이 왔다.
상기 서버 / 파티션 임계치(95%) 이상 사용 감지되어 안내드립니다.
서버 점검 결과, 고객사 데이터로 확인되며 디스크 가용량 부족 시 시스템 장애로 이어질 수 있으니 가용량 확보를 권고드립니다
Filesystem Size Used Avail Use% Mounted on
/dev/vdb1 200G 200G 65M 100% /data
65MB 남은 상황에서 신규 업로드가 차단될 위기였다.
원인을 분석하고, 안전하게 정리할 수 있는 시스템을 직접 설계·구현했다.
원인 분석
먼저 어디서 공간을 잡아먹는지 파악했다.
```bash
du -sh media/*/ | sort -rh
```
```
146G media/photos/
41G media/userImage/
240M media/staffProfile/
137M media/oldImage/
```
미디어 디렉토리가 대부분을 차지하고 있었다.
왜 media 폴더는 계속 커질까?
Django에서 흔히 사용하는 구조는 이런 형태다.
class Profile(models.Model):
image = models.ImageField(upload_to="profiles/")
문제는 Django는 기본적으로:
- DB row 삭제
- 파일 교체
를 해도 실제 파일을 자동 삭제하지 않는다는 점이다.
예를 들어:
상황 1. 프로필 이미지 교체
기존: profile/a.png
변경: profile/b.png
DB에는 b.png만 남지만,
실제 디스크에는:
a.png
b.png
둘 다 남아있게 된다. 이게 고아 이미지 파일이 생기게 된 원인이였다. 심지어 우리서비스는 이미지를
image = models.ImageField(upload_to="profiles/")
로 하는게 아니라 ImageField 대신 CharField 로 경로를 직접 문자열 저장하므로, Django의 파일 관리 기능이 전혀 없고 100% 수동으로 설정을 해주어야 했다.
동시에 nginx 로그도 확인했다.
```bash
du -sh /var/log/nginx/
```
```
7.4G /var/log/nginx/
```
- nginx로그는 `truncate` 로 현재 로그 비우기, `.gz` 삭제
- 고아 이미지 파일은 전용 커맨드로 탐지한 후 삭제하기로 하였다.
설계 결정
핵심 제약: DB 레코드는 절대 삭제하지 않는다
탈퇴 회원의 프로필 데이터는 회원 수·프로필 등록 수 등 비즈니스 통계에 사용된다. 삭제된 프로필 이미지라도 DB 레코드가 존재하는 한 파일을 보호하고, 어떤 테이블에도 참조되지 않는 파일만 고아 파일로 처리하기로 결정했다. 이 결정은 ADR(Architecture Decision Record) 로 문서화했다.
삭제 전략: 배치 삭제
| 전략 | 설명 | 채택 여부 |
| 즉시 삭제 | 이벤트 발생 시점에 파일 제거 | 채택 |
| 배치 삭제 | 주기적으로 고아 파일 일괄 정리 | 향후 과제 |
평상시에는 배치 삭제가 좋으나 지금은 용량이 95%였으므로 즉시삭제를 우선으로 하기로 하였다.
구현
동작 흐름
[Step 0] 사전 현황 리포트 → 스캔 디렉토리별 파일 수·용량 출력
[Step 1] DB 경로 수집 → MEDIA_FIELD_MAP 기반 전체 모델 조회
[Step 2] 디스크 스캔 → 지정 디렉토리 하위 파일 목록 수집
[Step 3] 차집합 계산 → orphan = 디스크 파일 - DB 참조 경로
[Step 4] 백업 (선택) → orphan_backup/YYYYMMDD_HHMMSS/ 에 복사
[Step 5] 삭제 (선택) → 배치 단위로 단계적 삭제
[삭제 후] 사후 현황 리포트 → 삭제 후 용량·절감량 출력
MEDIA_FIELD_MAP — 확장 가능한 모델-필드 매핑
모든 미디어 참조를 한 곳에서 관리한다.
새 모델이 추가되면 이 맵만 수정하면 된다.
MEDIA_FIELD_MAP = {
ProfileInfo: {
'single': ['profileimage', 'profileimage_tnail'],
'pipe': ['detailimage', 'artimage', 'portfolioName'],
},
ProfileInfoDelete: { # 탈퇴 회원 — DB 레코드 보존, 파일도 보호
'single': ['profileimage'],
'pipe': ['detailimage', 'artimage'],
},
# ... 이하 생략
}
single: 단일 경로를 저장하는 필드pipe:|구분자로 다중 경로를 저장하는 필드
안전장치
경로 순회 공격(Path Traversal) 방지
def _safe_rel_path(rel_path):
normalized = os.path.normpath(str(rel_path))
if any(part == '..' for part in normalized.split('/')):
raise ValueError('parent path traversal is not allowed')
return normalized
DB에 ../../../etc/passwd 같은 경로가 들어오더라도 MEDIA_ROOT 바깥에 접근할 수 없다.
orphan_backup 디렉토리 자동 제외
백업 폴더 자체가 스캔 대상에 포함되면 백업본이 다시 고아 파일로 잡히는 무한 루프가 생긴다. os.walk 순회 시 orphan_backup 디렉토리를 자동으로 제외했다.
백업 실패 파일은 삭제 대상에서 자동 제외
delete_targets = backed_up if do_backup else orphan_files
백업에 성공한 파일만 삭제 대상으로 삼아, 백업 실패 시 데이터 손실을 방지했다.
TDD로 작성된 71개 테스트
Red → Green → Refactor 사이클을 적용했다.
구현 코드보다 테스트를 먼저 작성하고, 테스트가 실패하는 것을 확인한 뒤 구현했다.
HumanSizeTest 4개 — 용량 단위 변환
ScanDiskTest 9개 — 디스크 파일 스캔
BuildOrphanListTest 5개 — 고아 목록 구성
BackupFilesTest 6개 — 백업 동작
DeleteFilesTest 6개 — 삭제 동작
CollectDbPathsTest 17개 — DB 경로 수집
ReportDirSizesTest 7개 — 디렉토리 현황 리포트
HandleFlowTest 17개 — 전체 흐름 통합
────────────────────────
합계 71개 전원 통과
운영 가이드
권장 실행 순서
# 1단계 — 대상 확인 (아무것도 변경하지 않음)
python manage.py cleanup_orphan_media --dry-run
# 2단계 — CSV로 목록 저장해서 파일 하나씩 검토
python manage.py cleanup_orphan_media --dry-run --output orphan_list.csv
# 3단계 — 백업만 먼저
python manage.py cleanup_orphan_media --backup
# 4단계 — 백업 후 삭제 (권장)
python manage.py cleanup_orphan_media --backup --delete
실행 결과 예시
[Step 0] 미디어 디렉토리 현황
photos/ 파일 1,234개 146.0 GB
staffProfile/ 파일 567개 240.1 MB
────────────────────────────────────────────
합계 파일 1,801개 146.2 GB
[Step 3] orphan 파일 계산 중...
참조 중인 파일: 1,750개
orphan 파일: 51개
orphan 총 용량: 45.2 MB (전체의 0.03%)
[Step 5] 단계적 삭제 중 (배치 크기: 100)...
삭제 완료: 51개 / 실패: 0개
[삭제 후] 미디어 디렉토리 현황
photos/ 파일 1,195개 145.9 GB
staffProfile/ 파일 555개 228.4 MB
────────────────────────────────────────────
합계 파일 1,750개 146.2 GB
절감 용량: 45.2 MB
복원 방법
--backup --delete 로 실행했다면 백업 폴더에서 원위치 복원 가능하다.
cp -r media/orphan_backup/20260513_150000/* media/
디스크 100% 대응 절차
대표님의 승인을 기다리다가 결국 100%가 되어 급히 승인받고 아래처럼 실행하였다.
① 용량 현황 파악
du -sh /data/*/ | sort -rh
du -sh /var/log/*/
② nginx 로그 정리 (즉시 수 GB 확보)
truncate -s 0 /var/log/nginx/access.log
truncate -s 0 /var/log/nginx/error.log
find /var/log/nginx/ -name "*.gz" -delete
③ 고아 파일 탐지
python manage.py cleanup_orphan_media --dry-run --scan-dirs "photos,staffProfile,userImage"
④ 고아 파일 삭제
python manage.py cleanup_orphan_media --backup --delete --scan-dirs "photos,staffProfile,userImage"
⑤ 결과 확인
df -h


회고
잘 된 점
DB 레코드 기반 보호 원칙이 설계를 단순하게 만들었다.
탈퇴 회원 프로필은 회원 수·프로필 등록 수 등 비즈니스 통계에 사용되기 때문에 DB 레코드를 삭제할 수 없다는 제약이 있었다. 이 제약을 역으로 활용해 "어떤 테이블에도 참조되지 않는 파일만 고아"라는 규칙 하나로 모든 케이스를 처리했다. 삭제된 프로필 테이블까지 MEDIA_FIELD_MAP에 포함시킨 덕분에, 별도의 유예 기간 로직 없이도 탈퇴 회원 이미지를 안전하게 보호할 수 있었다.
TDD가 실수를 조기에 잡았다.stillPhotography 필드가 실제 DB에서 | 구분자로 다중 경로를 저장하고 있었는데, MEDIA_FIELD_MAP에는 single로 잘못 등록되어 있었다. 실제 DB를 조회해 타입을 pipe로 수정하는 순간, 기존 테스트가 즉시 실패해 영향 범위를 바로 파악할 수 있었다. 테스트가 없었다면 이 오류는 조용히 고아 파일을 과소 집계하는 버그로 남았을 것이다.
단계적 실행 흐름으로 실행 전 파악하여 실수를 방지하였다.
--dry-run 으로 먼저 목록을 눈으로 확인하고, --backup 으로 백업한 뒤, --delete 로 삭제하는 흐름으로 "혹시 잘못되면 어떻게 하지"라는 걱정에 "백업 폴더에서 복원하게 하자"로 설정하고 실행할 수있도록 하였다.
Step 0 / 삭제 후 현황 리포트가 실제 운영에서 유용했다.
실행 전후 디렉토리 용량을 자동으로 출력하도록 설계한 덕분에, 별도로 du 명령어를 치지 않아도 얼마나 절감됐는지 한눈에 확인할 수 있었다.
배너 교체·이미지 수정 이벤트 발생 시 즉시 삭제 트리거 구현
이 사태의 원인은 결국 제때 이미지가 삭제 되지 않아 생긴 문제였으므로, 이미지 수정, 삭제 이벤트 발생 시 즉시 삭제할 수 있도록 하여 최대한의 고아이미지가 없도록 했다. 또한, 이미지 삭제, 수정시 되돌릴 수 없다는 alert창도 추가하여 유저에게 다시한번 상기할 수 있도록 하였다.
아쉬운 점
설계 초반에 비즈니스 도메인을 충분히 파악하지 못했다.
처음에는 탈퇴 프로필 이미지에 유예 기간을 두고 오래된 것부터 DB 레코드째 삭제하는 방향으로 설계를 시작했다. 그러나 회원 수·프로필 통계 데이터가 중요하다는 비즈니스 제약을 뒤늦게 파악하면서 설계를 전면 수정해야 했다. 구현 전에 도메인 제약을 먼저 명확히 정의했어야 했다.
디스크 풀 상황에서 백업이 불가했다.--backup --delete 를 실행했지만 디스크가 꽉 찬 상태라 백업 파일 자체를 쓸 수 없어 백업이 실패했다. 결국 --delete 단독 실행(복구 불가)으로 진행할 수밖에 없었다. 외부 스토리지(오브젝트 스토리지 등)로 백업하거나, 디스크 여유 공간을 먼저 확보한 뒤 백업하는 절차가 필요하다. 이번 경우는 nginx 로그(7.4G)를 먼저 정리해 공간을 확보한 뒤 백업을 진행하는 방식으로 우회했다.
향후 과제
- 정기 점검 스케줄링(cron) 연동으로 운영 데이터 검수 시스템 구축
- 서비스 전체 미디어 저장 경로 목록화 및
DEFAULT_SCAN_DIRS재정의 - 관리자 페이지에서 점검 보고서 받을 수 있도록 하여 관리자가 체크 후 삭제 할 수 있도록 함.