백엔드 데이터 인프라 14편. DELETE·TRUNCATE·소프트 삭제(deleted_at) 표준 패턴과 외래 키 제약·CASCADE 동작 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 14편이에요. 13편 UPDATE 까지 "수정" 을 다뤘으니, 이번 14편은 데이터 삭제.
삭제의 두 가지 철학
데이터 삭제 = "진짜 지움" vs "안 보이게만" 두 갈래.
| 방식 | 의미 | 사용 |
|---|---|---|
| Hard Delete | 진짜 행 제거 (DELETE) |
임시·중복·로그 정리 |
| Soft Delete | deleted_at 박고 안 보이게 |
비즈니스 데이터 (사용자·주문 등) |
한국 회사 백엔드 대부분 = Soft Delete. 운영·감사·복구 모두 유리.
DELETE 기본 형태
DELETE FROM 테이블 WHERE 조건 RETURNING *;
단건
DELETE FROM users WHERE id = 1;
여러 행
DELETE FROM orders WHERE status = 'CANCELED' AND created_at < '2025-01-01';
USING — 다른 테이블 참조
DELETE FROM users
USING bans
WHERE users.id = bans.user_id;
12편 JOIN 의 LEFT JOIN과 비슷한 "다른 테이블 기준으로 삭제".
RETURNING
DELETE FROM users WHERE id = 1
RETURNING id, name, email;
삭제된 행을 결과로. 감사 로그·아카이브 매우 유용.
TRUNCATE — 빠른 전체 삭제
TRUNCATE TABLE users;
DELETE 와 차이:
| DELETE WHERE 1=1 | TRUNCATE | |
|---|---|---|
| 속도 | 느림 (행 단위) | 매우 빠름 |
| WAL | 행마다 기록 | 적게 기록 |
| 트리거 | 행마다 발동 | 발동 안 함 |
| SEQUENCE | 유지 | 옵션으로 초기화 |
| 외래 키 | OK | 참조하는 테이블 있으면 거부 |
-- 시퀀스 초기화 함께
TRUNCATE TABLE users RESTART IDENTITY;
-- CASCADE — 참조 테이블도 함께
TRUNCATE TABLE users CASCADE;
운영 = TRUNCATE는 "테스트 환경 초기화" 또는 "임시 테이블 비우기" 에 자주. 프로덕션 데이터 삭제엔 거의 안 씀.
외래 키 — DELETE의 함정
-- users 테이블
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT
);
-- orders 가 users 를 참조
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id),
...
);
-- 사용자 삭제 시도
DELETE FROM users WHERE id = 1;
-- ❌ ERROR: update or delete on table "users" violates foreign key constraint
orders가 user를 참조하니 — 해당 user 삭제 못 함.
FK 동작 옵션 — ON DELETE
외래 키 만들 때 "부모 삭제 시 자식 어떻게" 옵션.
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
...
);
옵션 5가지:
| 옵션 | 의미 |
|---|---|
NO ACTION (기본) |
거부 |
RESTRICT |
거부 (즉시) |
CASCADE |
자식도 함께 삭제 |
SET NULL |
자식 컬럼을 NULL |
SET DEFAULT |
자식 컬럼을 기본값 |
CASCADE 신중 — 사용자 한 번 삭제로 주문 1만 건 함께 사라질 수 있음. 운영 = NO ACTION + 앱 코드에서 "의도적 cascade" 가 안전.
Soft Delete 패턴
-- 컬럼 추가
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
-- 삭제 = UPDATE
UPDATE users SET deleted_at = NOW() WHERE id = 1;
-- 조회는 모두 deleted_at IS NULL 필터
SELECT * FROM users WHERE deleted_at IS NULL;
부분 인덱스 — 활성 사용자만
CREATE INDEX users_email_unique_active
ON users (email)
WHERE deleted_at IS NULL;
활성 사용자 사이에서만 unique. 삭제된 사용자가 같은 email로 재가입 가능.
뷰로 깔끔하게
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL;
-- 이후 모든 조회
SELECT * FROM active_users WHERE city = 'Seoul';
deleted_at IS NULL 조건을 매번 박지 않아도 됨.
Soft Delete vs Hard Delete — 선택
| 데이터 | 추천 |
|---|---|
| 사용자·주문·결제 (비즈니스) | Soft Delete |
| 임시·세션·캐시 | Hard Delete |
| 로그·이벤트 (오래된) | Hard Delete (또는 아카이브) |
| GDPR 등 법적 삭제 요청 | Hard Delete (또는 익명화) |
| 테스트 데이터 | TRUNCATE |
GDPR 주의 — "개인정보 삭제 요청" 은 진짜 삭제 또는 익명화 필요. Soft Delete만으로는 법적 의무 미충족 가능.
대량 삭제 — chunk
-- 한 번에 1만 건씩
WITH targets AS (
SELECT id FROM logs
WHERE created_at < NOW() - INTERVAL '30 days'
LIMIT 10000 FOR UPDATE
)
DELETE FROM logs WHERE id IN (SELECT id FROM targets);
대량 DELETE = 락·WAL·트랜잭션 부담. chunk 처리 + 작업 외 시간.
또는 파티셔닝 (39편 파티셔닝 와 별도 - 추후) 으로 "파티션 단위로 빠르게 DROP".
Spring JPA 의 DELETE
자바 백엔드 입문 45편 Repository 의 delete():
@Transactional
public void delete(Long id) {
userRepository.deleteById(id);
}
→ 자동 생성:
DELETE FROM users WHERE id = ?;
Soft Delete with Hibernate @SQLDelete
@Entity
@SQLDelete(sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class User { ... }
@SQLDelete=delete()호출 시 실제로 UPDATE 박힘@Where= 모든 SELECT 에 자동 필터
JPA 만으로 Soft Delete 패턴 구현.
안전 가드 — 운영 표준
BEGIN;
-- 1. 영향 행 확인
SELECT COUNT(*) FROM users WHERE deleted_at IS NOT NULL
AND deleted_at < NOW() - INTERVAL '90 days';
-- 53 출력
-- 2. 진짜 삭제
DELETE FROM users
WHERE deleted_at IS NOT NULL
AND deleted_at < NOW() - INTERVAL '90 days'
RETURNING id, email;
-- DELETE 53
-- 3. 검증 후
COMMIT;
-- 또는 의심스러우면 ROLLBACK;
UPDATE와 같은 5단계 흐름.
함정 5가지
(1) WHERE 누락
DELETE FROM users; -- ❌ 모든 행
UPDATE 와 같은 무서운 사고.
(2) 외래 키 무지
DELETE FROM users WHERE id = 1;
-- ERROR: orders 가 참조
FK 동작 옵션을 알고 시작.
(3) CASCADE 남용
운영 = NO ACTION + 의도적 코드 처리. CASCADE는 "의도가 명확한 의존 관계" 에만.
(4) Soft Delete 인데 unique 안 됨
email TEXT UNIQUE -- ❌ 삭제된 사용자도 unique 차지
부분 인덱스로 해결.
(5) DELETE 후 통계 안 갱신
대량 삭제 후 — PG 통계가 오래된 상태. ANALYZE 명령으로 갱신 또는 autovacuum 대기.
ANALYZE users;
비즈니스 데이터 = deleted_at TIMESTAMPTZ 컬럼 + UPDATE로 삭제 표시. 모든 SELECT 에 deleted_at IS NULL 필터. 부분 인덱스로 unique 유지. 90~365일 후 batch로 진짜 DELETE (감사·법적 요건 고려).
한 줄 정리 — DELETE는 WHERE 안전 가드 + RETURNING + FK 옵션 이해 3가지. TRUNCATE는 빠르지만 FK 한정. 한국 백엔드 표준 = Soft Delete + 부분 인덱스 + 주기적 진짜 삭제. CASCADE 신중.
시험 직전 한 번 더 — DELETE 입문자가 매번 헷갈리는 것
DELETE FROM t WHERE 조건 RETURNING *- WHERE 누락 = 모든 행 (가장 무서움)
- USING = 다른 테이블 참조 삭제
- TRUNCATE = 빠른 전체 삭제 (FK 없는 테이블)
- TRUNCATE RESTART IDENTITY = 시퀀스 초기화
- TRUNCATE CASCADE = 참조 테이블도 비움
- DELETE vs TRUNCATE = 행 단위 vs 전체
- FK ON DELETE = CASCADE·SET NULL·NO ACTION
- CASCADE 신중 = 자식 함께 삭제
- 운영 표준 = NO ACTION + 앱 코드
- Soft Delete =
deleted_atUPDATE - 모든 SELECT 에
deleted_at IS NULL필터 - 부분 인덱스 =
WHERE deleted_at IS NULL - 활성 사용자 뷰 =
CREATE VIEW active_users AS ... - Hibernate =
@SQLDelete+@Where조합 - GDPR = 진짜 삭제 또는 익명화
- 대량 DELETE = chunk 처리
- 운영 5단계 = BEGIN-SELECT-DELETE-검증-COMMIT
- 삭제 후
ANALYZE= 통계 갱신 - 한국 백엔드 99% = Soft Delete
- 임시·로그·세션 = Hard Delete 또는 TRUNCATE
- 파티셔닝 = 대량 데이터 삭제 최적
- BIGSERIAL DELETE 후 = 시퀀스 안 줄어듦
- 운영 = 데이터 한 번 사라지면 복구 어려움 — 백업 필수
시리즈 다른 편
- Part 1 PostgreSQL 튜토리얼: 1편 · 9편 CREATE TABLE · 10편 INSERT · 11편 SELECT · 12편 JOIN · 13편 UPDATE · 14편 (현재 글)
시리즈 다음 글
다음 글(15편)에서는 외래 키 — 참조 무결성 — REFERENCES·ON DELETE·ON UPDATE 표준.
공식 문서: PostgreSQL 18 — Tutorial: Deletions에서 더 자세한 사양을 확인할 수 있어요.