DB 트랜잭션 실패 시 파일 데이터 유실 방지하는 파일 삭제 전략
DB 트랜잭션 실패 시 파일 데이터가 유실되는 문제를 해결하는 방법. 트랜잭션과 파일 시스템의 비동기적 성격을 고려한 안전한 삭제 전략을 소개합니다.
성경 낭독 완료 처리 과정에서 병합된 오디오 파일이 삭제된 후, 사용자가 완료를 취소하면 원본 조각 파일들이 이미 사라져 복구가 불가능해지는 데이터 유실 문제가 발생했다. DB 트랜잭션과 파일 시스템의 비동기적 성격 때문에 발생하는 정합성 문제를 해결한 과정을 정리한다.
시도와 함정
처음에는 완료 취소 기능을 위해 '취소됨'이라는 중간 상태를 도입하고, 삭제된 조각 파일을 복구하는 로직을 고민했다. 하지만 조각이 1개일 때 ffmpeg를 거치지 않는 'fast path' 로직과 결합하니 상태 관리 복잡도가 기하급수적으로 늘어났다.
// 초기 구현: 트랜잭션과 파일 삭제가 섞여 있음 async function completeReading(readingId) { const chunks = await getChunks(readingId); const mergedFile = await mergeAudio(chunks);
await db.transaction(async (tx) => { await tx.updateStatus(readingId, 'COMPLETED'); await deleteFiles(chunks); // 여기서 삭제가 먼저 일어나면? }); }
위 코드처럼 트랜잭션 내부에서 deleteFiles를 호출하니, DB 커밋 실패 시 파일만 사라지고 DB는 이전 상태로 돌아가는 치명적인 데이터 유실이 발생하더라. 3시간 삽질 끝에 파일 시스템은 DB 트랜잭션의 ROLLBACK 대상이 아님을 다시금 깨달았다.
원인
원인은 파일 삭제 시점이 DB 트랜잭션의 성공 여부와 무관하게 동작했기 때문이다. DB는 원자성을 보장하지만, 로컬 파일 시스템은 트랜잭션 범위 밖에 존재한다. 따라서 DB 업데이트가 실패해도 파일은 이미 삭제된 상태가 되어 낭독 데이터 자체가 증발해 버리는 구조적 결함이 있었다.
해결
파일 삭제 로직을 트랜잭션 밖으로 완전히 분리했다. DB 트랜잭션이 성공적으로 COMMIT된 이후에만 파일 삭제를 수행하도록 순서를 조정했다.
async function completeReading(readingId) { const chunks = await getChunks(readingId); const mergedFile = await mergeAudio(chunks);// 1. DB 트랜잭션 먼저 확정 await db.transaction(async (tx) => { await tx.updateStatus(readingId, 'COMPLETED'); });
// 2. 트랜잭션 성공 후 파일 정리 try { await deleteFiles(chunks); } catch (err) { console.error('파일 삭제 실패, 고아 파일 발생:', err); // 여기서 실패해도 서비스는 이미 완료 상태이므로 데이터 유실 없음 } }
파일 삭제가 실패하더라도 이미 DB는 '완료' 상태로 업데이트되었고, 남은 조각 파일들은 추후 배치 작업으로 정리하면 그만이다. 데이터 유실보다 고아 파일이 남는 것이 시스템 관점에서는 훨씬 안전하다.
결과
- 데이터 정합성 확보: DB 트랜잭션 실패 시에도 원본 조각 파일이 보존되어 낭독 취소 및 재시도 가능.
- 시스템 안정성: 삭제 로직 에러가 전체 프로세스의 롤백을 유도하지 않아 서비스 중단 위험 제거.
- 운영 효율: 고아 파일 정리 작업만 별도로 관리하면 되어 상태 관리 로직이 단순해짐.
정리 — 같은 함정 안 빠지려면
- [ ] DB 트랜잭션과 외부 자원(파일 시스템, 외부 API)의 생명주기를 분리했는가?
- [ ] 삭제 작업이 트랜잭션 내부에 포함되어 롤백 시 데이터가 유실될 위험은 없는가?
- [ ] '데이터 유실'보다 '고아 자원 발생'이 복구 가능한 안전한 상태임을 설계에 반영했는가?
- [ ] 파일 삭제 실패 시 시스템 전체가 멈추지 않도록 예외 처리를 분리했는가?
태그