본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
선택 심화 · 16장 : 데이터 수명과 집계

소프트 삭제

불리언 삭제 표시의 한계를 재현하고 deleted_at과 생성된 active_email로 탈퇴 계정의 식별자 재사용을 제어합니다.

이 장은 탈퇴·보존·익명화 또는 재시도 가능한 통계 집계가 실제 요구일 때 읽는 선택 심화입니다. 소프트 삭제는 12장의 최종 모델 직후 독립적으로 읽을 수 있고, 일별·마이크로배치 집계는 8장의 트랜잭션과 12장의 파생 모델을 선행으로 요구합니다.

탈퇴 버튼이 물리 DELETE를 실행하면 게시글과 감사 기록의 참조가 끊깁니다. 그래서 행을 남기고 삭제됐다고 표시하는 소프트 삭제를 사용합니다. 그러나 is_deleted 불리언 하나는 삭제 시점, 수행자, 근거를 말하지 못하며 모든 쿼리에 같은 필터를 붙여야 한다는 책임도 숨깁니다.

회원 게시판에서는 탈퇴한 이메일을 새 사용자가 다시 등록할 수 있다는 정책을 선택합니다. 기존 UNIQUE(이메일)은 삭제 행까지 포함하므로 재등록을 막습니다. 반대로 UNIQUE를 없애면 활성 계정 두 개가 같은 주소를 가질 수 있습니다. 생성된 active_email은 살아 있는 행에만 정규화 이메일을 투영해 두 요구를 함께 만족시킵니다.

이 마이그레이션은 ch12의 members를 직접 확장합니다. ch12에서 status='DELETED'로 표현하던 탈퇴 여부는 이 단계부터 deleted_at 묶음으로 옮기고, statusACTIVESUSPENDED라는 접근 상태만 표현합니다. 기존 삭제 행이 있다면 ALTER 전에 삭제 시각·수행자·사유를 먼저 이관해야 하며, 이 실습 fixture에는 아직 삭제 행이 없다고 가정합니다. 삭제된 회원이 가진 posts는 그대로 보존되고 FK도 유지됩니다. 애플리케이션의 일반 목록은 활성 뷰를 사용하며 운영 조사만 기본 테이블을 읽습니다.

deleted_at은 UTC DATETIME(6), deleted_by는 작업을 승인한 회원 ID, delete_reason은 사람이 검토할 근거입니다. 같은 탈퇴 요청이 재전송되어도 최초 삭제 메타데이터를 덮지 않도록 요청 테이블에 외부 요청 ID를 보존합니다.


삭제 플래그와 유일 제약 충돌

is_deleted=1로 바꾼 뒤 같은 이메일을 새 행에 넣으면 uq_email이 거절합니다. 인덱스를 제거하면 동시 가입 두 건이 모두 들어갈 수 있습니다. 불리언에는 사건 시각이 없어 보존 기간 계산도 할 수 없습니다.

삭제 표시는 남지만 재가입과 보존 기준이 막힌 구조
DROP TABLE IF EXISTS member_delete_bad;
CREATE TABLE member_delete_bad(
 member_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
 email VARCHAR(255) NOT NULL,
 name VARCHAR(100) NOT NULL,
 is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
 CONSTRAINT uq_member_delete_bad_email UNIQUE(email)
) ENGINE=InnoDB;
INSERT INTO member_delete_bad(email,name) VALUES('reuse@example.com','첫 계정');
UPDATE member_delete_bad SET is_deleted=TRUE WHERE email='reuse@example.com';
INSERT INTO member_delete_bad(email,name) VALUES('reuse@example.com','재가입 계정');
-- ERROR 1062: uq_member_delete_bad_email
SELECT member_id FROM member_delete_bad
WHERE is_deleted=TRUE AND CURRENT_TIMESTAMP>=NULL;

재가입은 거절되고 삭제 후 경과 시간을 계산하는 쿼리는 어떤 행도 판단하지 못합니다.

오류 실행 결과
deleted rows=1
deletion timestamp=unknown
deletion actor=unknown
same email re-registration=ERROR 1062
retention candidates=0

삭제는 상태가 아니라 사건입니다. 사건을 나중에 처리하려면 발생 시각과 요청 식별자가 필요합니다. 불리언은 현재 참/거짓만 표현하므로 “30일 전에 탈퇴한 계정”과 “방금 탈퇴한 계정”을 구분하지 못합니다.

활성 이메일 유일성은 부분 인덱스가 있으면 자연스럽지만 MySQL에는 WHERE 조건 UNIQUE 문법이 없습니다. 삭제 행에는 NULL, 활성 행에는 LOWER(이메일)을 내는 생성 열을 사용하면 UNIQUE가 NULL 여러 개를 허용하는 성질을 이용할 수 있습니다.

기본 테이블 직접 조회 경로 목록

소프트 삭제 마이그레이션 전에 회원을 조회하는 SQL, FK 조인, 관리자 내보내기를 찾습니다. 모든 곳에 deleted_at IS NULL 조건을 무작정 넣지 않습니다. 과거 게시글 보고서는 탈퇴 회원도 보여야 하고 로그인·검색·가입 중복 확인만 활성 행으로 제한합니다.


삭제 시각과 활성 행

deleted_at이 NULL이면 현재 활성, 값이 있으면 그 시점부터 삭제 상태입니다. deleted_by와 사유는 같은 트랜잭션에서 채웁니다. 세 열 중 일부만 있는 상태를 검사로 거절해 반쪽 삭제를 막습니다.

활성 이메일은 저장 입력이 아니라 파생 키입니다. 원본 이메일을 보존해야 하는 기간 동안 이전 행은 그대로 두되 active_email이 NULL이므로 새 가입자가 같은 주소를 사용할 수 있습니다. 대소문자 규칙은 LOWER와 열 정렬 규칙이 일치해야 합니다.

읽기 기본값을 뷰로 한 곳에 모으기

active_members_v는 로그인과 일반 검색의 규칙을 보여 줍니다. 보안 범위 전체를 뷰 하나로 해결할 수는 없지만 필터 누락을 코드 검토에서 찾기 쉬워집니다. 관리자 기능은 이름에 including_deleted를 드러낸 별도 쿼리를 사용합니다.

MySQL 8.4 메모: 생성 열을 STORED로 두면 유일 제약 인덱스 리프에 실제 값이 유지됩니다. 이메일 정규화 정책을 바꾸면 열 표현식과 기존 인덱스를 온라인 마이그레이션 순서로 함께 교체합니다.


활성 이메일 유일성

DDL은 이전 uq_members_email과 상태 제약을 제거한 뒤 접근 상태 제약, 삭제 메타데이터, active_email을 함께 더합니다. 요청 테이블은 같은 UUID의 두 번째 호출을 판단합니다. 프로시저는 대상 행을 잠그고 이미 삭제됐으면 최초 결과를 반환합니다.

members soft delete migration과 request procedure
DROP PROCEDURE IF EXISTS soft_delete_member;
DROP VIEW IF EXISTS active_members_v;
DROP TABLE IF EXISTS member_delete_request;

ALTER TABLE members
  DROP INDEX uq_members_email,
  DROP CHECK ck_members_status,
  ADD COLUMN deleted_at DATETIME(6) NULL,
  ADD COLUMN deleted_by BIGINT UNSIGNED NULL,
  ADD COLUMN delete_reason VARCHAR(240) NULL,
  ADD COLUMN active_email VARCHAR(255)
    GENERATED ALWAYS AS (IF(deleted_at IS NULL,LOWER(email),NULL)) STORED,
  ADD CONSTRAINT uq_members_active_email UNIQUE(active_email),
  ADD CONSTRAINT ck_members_status
    CHECK(status IN('ACTIVE','SUSPENDED')),
  ADD CONSTRAINT fk_members_deleted_by FOREIGN KEY(deleted_by)
    REFERENCES members(id) ON DELETE RESTRICT,
  ADD CONSTRAINT chk_members_delete_bundle CHECK(
    (deleted_at IS NULL AND deleted_by IS NULL AND delete_reason IS NULL)
    OR
    (deleted_at IS NOT NULL AND deleted_by IS NOT NULL
      AND CHAR_LENGTH(TRIM(delete_reason))>=10)
  ),
  ADD INDEX ix_members_deleted_at(deleted_at,id);

CREATE TABLE member_delete_request(
 request_id BINARY(16) NOT NULL,
 member_id BIGINT UNSIGNED NOT NULL,
 deleted_at DATETIME(6) NOT NULL,
 PRIMARY KEY(request_id),
 CONSTRAINT fk_delete_request_member FOREIGN KEY(member_id)
   REFERENCES members(id) ON DELETE RESTRICT
) ENGINE=InnoDB;

CREATE VIEW active_members_v AS
SELECT id AS member_id,email,name,created_at
FROM members WHERE deleted_at IS NULL;

DELIMITER //
CREATE PROCEDURE soft_delete_member(
 IN p_request BINARY(16),IN p_member BIGINT UNSIGNED,
 IN p_actor BIGINT UNSIGNED,IN p_reason VARCHAR(240),IN p_at DATETIME(6))
proc: BEGIN
 DECLARE v_existing BIGINT UNSIGNED; DECLARE v_deleted DATETIME(6);
 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
 START TRANSACTION;
 SELECT member_id INTO v_existing FROM member_delete_request
 WHERE request_id=p_request FOR UPDATE;
 IF v_existing IS NOT NULL THEN COMMIT; SELECT v_existing AS member_id,'REPLAY' outcome; LEAVE proc; END IF;
 SELECT deleted_at INTO v_deleted FROM members WHERE id=p_member FOR UPDATE;
 IF v_deleted IS NOT NULL THEN
   INSERT INTO member_delete_request VALUES(p_request,p_member,v_deleted);
   COMMIT; SELECT p_member AS member_id,'ALREADY_DELETED' outcome; LEAVE proc;
 END IF;
 IF CHAR_LENGTH(TRIM(p_reason))<10 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='delete reason too short'; END IF;
 UPDATE members SET deleted_at=p_at,deleted_by=p_actor,delete_reason=p_reason
 WHERE id=p_member;
 IF ROW_COUNT()<>1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='member not found'; END IF;
 INSERT INTO member_delete_request VALUES(p_request,p_member,p_at);
 COMMIT; SELECT p_member AS member_id,'DELETED' outcome;
END//
DELIMITER ;

SET @member_id = (
 SELECT id FROM members
 WHERE email='min@example.com' AND deleted_at IS NULL
 ORDER BY id LIMIT 1
);
SET @delete_request=UUID_TO_BIN('77777777-7777-4777-8777-777777777777');
CALL soft_delete_member(@delete_request,@member_id,@member_id,
 '사용자가 직접 탈퇴를 확정한 요청','2026-07-16 09:00:00');
CALL soft_delete_member(@delete_request,@member_id,@member_id,
 '재전송된 동일 탈퇴 요청','2026-07-16 09:01:00');

첫 호출만 삭제 묶음을 채우고 재호출은 같은 회원 ID를 반환합니다.

개선 실행 결과
first outcome=DELETED
second outcome=REPLAY
active view rows for member=0
delete request rows=1
historical posts retained=yes

소프트 삭제는 자식 FK를 건드리지 않습니다. 따라서 게시글 이력과 감사가 남습니다. 애플리케이션에서 회원을 새로 가입시키면 active_email은 다시 이메일 값이 되어 기존 삭제 행의 NULL과 충돌하지 않습니다.

deleted_by 자기 참조 FK는 행위자 행을 물리 삭제하지 못하게 합니다. 시스템 행위자를 별도 주체 테이블로 빼는 편이 더 자연스러운 조직도 있습니다. 그 경우 사용자 계정 수명과 운영 주체 수명을 분리합니다.

활성 행과 전체 행의 카디널리티를 따로 측정하기

삭제가 쌓이면 테이블 스캔과 보조 인덱스 폭은 계속 증가합니다. 활성 조회는 생성된 유일 제약 인덱스를 사용하지만 보관 쿼리와 백업은 전체 크기의 영향을 받습니다. 보존 정책이 허용하면 익명화 또는 콜드 보관 단계로 넘깁니다.


재가입과 불완전 삭제 검증

삭제한 min@example.com으로 신규 회원을 넣어 활성 유일성 동작을 확인합니다. 동시에 두 활성 행을 만들려는 두 번째 INSERT는 1062여야 합니다. 묶음 검사는 deleted_at만 설정하는 우회 UPDATE를 막습니다.

soft delete 경계 fixture와 drift 조회
INSERT INTO members(email,password_hash,name,status,created_at)
VALUES('min@example.com','$2b$12$rejoined','재가입 회원','ACTIVE',CURRENT_TIMESTAMP(6));
SET @rejoined_id=LAST_INSERT_ID();

INSERT INTO members(email,password_hash,name,status,created_at)
VALUES('min@example.com','$2b$12$duplicate','중복 활성 계정','ACTIVE',CURRENT_TIMESTAMP(6));
-- ERROR 1062: uq_members_active_email

UPDATE members SET deleted_at='2026-07-16 10:00:00'
WHERE id=@rejoined_id;
-- ERROR 3819: chk_members_delete_bundle

SELECT id AS member_id FROM members
WHERE (deleted_at IS NULL)<>(deleted_by IS NULL)
   OR (deleted_at IS NULL)<>(delete_reason IS NULL);

SELECT active_email,COUNT(*) FROM members
WHERE active_email IS NOT NULL GROUP BY active_email HAVING COUNT(*)>1;

SELECT s.id AS post_id FROM posts s
LEFT JOIN members l ON l.id=s.author_id
WHERE l.id IS NULL;

min@example.com 재가입 INSERT는 성공하고 그 다음 활성 중복은 이름 있는 유일성 제약 조건에서 거부됩니다. 메타데이터 일부만 바꾸는 UPDATE도 검사를 통과하지 못합니다. 마지막 세 일치 검사 SELECT는 모두 0행입니다.

쿼리 로그에서 members 기본 테이블을 사용하는 일반 사용자 엔드포인트를 추적합니다. 삭제 회원이 검색 결과에 나타나는 경우 SQL을 활성 뷰로 옮기고 회귀 예제 데이터에 삭제된 행을 포함합니다.

이메일을 즉시 익명화하지 않은 이유

재가입 허용과 개인정보 보존은 별도 정책입니다. 사기 방지나 법적 보존 기간 동안 암호화된 식별자가 필요할 수 있습니다. 원문이 불필요해지는 시점에는 ch16-2의 익명화 단계가 이메일을 복구할 수 없는 토큰으로 바꿉니다.

복원은 삭제 묶음 전체를 비운다

관리자가 잘못된 탈퇴를 되돌릴 때 deleted_at만 NULL로 만들면 검사에서 오류가 발생합니다. 행위자와 사유도 함께 NULL로 바꾸고 active_email 충돌을 먼저 확인해야 합니다. 이미 누군가 같은 이메일로 재가입했다면 자동 복원하지 않습니다.

연쇄 대신 명시적 수명주기

논리 삭제된 회원의 태그나 게시글을 연쇄 소프트 삭제할지 업무 규칙으로 결정합니다. 단순히 부모 필터를 조인에 붙여 자식을 숨기면 과거 집계가 달라질 수 있습니다. 인증 접근과 분석 보존을 다른 읽기 모델로 분리합니다.


소프트 삭제 선택 기준

판단 축확인할 질문
참조삭제 뒤에도 자식 사실을 보존해야 하는가?
복구오삭제를 되돌릴 업무 창구와 기한이 있는가?
식별자삭제 계정의 이메일 재사용을 허용하는가?
개인정보원문을 보존할 법적 근거와 익명화 시점이 있는가?
운영모든 읽기 경로의 활성 필터를 추적할 수 있는가?

감사·게시글 참조를 남겨야 하고 복원 요구가 있다면 소프트 삭제가 타당합니다. 규정상 즉시 파기가 필요하고 참조도 없다면 물리 삭제가 단순합니다. 채택 문서에는 활성 정의, 이메일 재사용, 보존 만료 뒤 처리까지 적습니다.


연습 문제

재가입자가 아직 없을 때만 회원을 복원하는 프로시저를 작성하세요. 대상과 동일 active_email 존재 여부를 트랜잭션 안에서 검사하고 삭제 묶음 세 열을 함께 NULL로 만들어야 합니다.

해설과 예시 답안

삭제 행을 잠근 뒤 LOWER(이메일)이 다른 활성 행에 있는지 확인합니다. 충돌하면 SIGNAL로 종료하고, 없으면 묶음을 한 UPDATE로 초기화합니다.

DROP PROCEDURE IF EXISTS restore_member;
DELIMITER //
CREATE PROCEDURE restore_member(IN p_member BIGINT UNSIGNED)
BEGIN
 DECLARE v_email VARCHAR(255); DECLARE v_conflict BIGINT UNSIGNED;
 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
 START TRANSACTION;
 SELECT email INTO v_email FROM members
 WHERE id=p_member AND deleted_at IS NOT NULL FOR UPDATE;
 IF v_email IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='deleted member not found'; END IF;
 SELECT id INTO v_conflict FROM members
 WHERE active_email=LOWER(v_email) LIMIT 1 FOR UPDATE;
 IF v_conflict IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='active email already reused'; END IF;
 UPDATE members SET deleted_at=NULL,deleted_by=NULL,delete_reason=NULL
 WHERE id=p_member;
 COMMIT;
END//
DELIMITER ;
CALL restore_member(@member_id);

재가입 행이 존재하면 활성 이메일이 이미 사용 중이므로 호출이 롤백됩니다. 그 행을 실습 스키마에서 제거한 뒤 다시 호출하면 활성 뷰에 기존 회원이 한 건 나타나고 묶음 일치 검사는 0행입니다.


핵심 정리

  • 소프트 삭제는 불리언이 아니라 발생 시각·주체·사유를 가진 수명 사건입니다.
  • 생성된 active_email은 삭제 주소 재사용과 활성 주소 유일성을 동시에 표현합니다.
  • 일반 읽기 주체는 활성 뷰를 사용하고 과거 사실은 기본 FK를 유지합니다.
  • 복원·익명화·최종 파기는 삭제와 구분된 후속 전이입니다.

다음 문서에서는 탈퇴, 정지, 익명화, 보존 만료를 서로 다른 상태 축으로 나눕니다.