백엔드 데이터 인프라 14편 — DELETE 데이터 삭제와 소프트 삭제 패턴

2026-05-17백엔드 데이터 인프라

백엔드 데이터 인프라 14편. DELETE·TRUNCATE·소프트 삭제(deleted_at) 표준 패턴과 외래 키 제약·CASCADE 동작 풀어쓴 학습 노트.

📚 백엔드 데이터 인프라 · 14편 — DELETE 데이터 삭제와 소프트 삭제 패턴

이 글은 백엔드 데이터 인프라 시리즈 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

BIGSERIAL(자동 증가 정수 PK)로 만든 orders 가 users 를 참조하니, 해당 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 주의 — GDPR(EU 개인정보 보호법) 의 "개인정보 삭제 요청" 은 진짜 삭제 또는 익명화가 필요하다. 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편 Repositorydelete():

@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(PG 자동 청소 데몬) 이 돌 때까지 기다린다.

ANALYZE users;
🎯 한국 회사 표준 — Soft Delete

비즈니스 데이터 = 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_at UPDATE
  • 모든 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 후 = 시퀀스 안 줄어듦
  • 운영 = 데이터 한 번 사라지면 복구 어려움 — 백업 필수

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!