소프트 삭제
불리언 삭제 표시가 재가입과 보존 기간의 계산을 막는 한계를 재현하고, deleted_at 묶음과 생성 열 active_email로 탈퇴한 계정의 이메일 재사용과 활성 이메일의 유일성을 함께 제어합니다. 같은 탈퇴 요청의 재전송과 불완전한 삭제도 막습니다.
이 장은 탈퇴와 보존, 익명화, 또는 다시 시도할 수 있는 통계 집계가 실제 요구일 때 읽는 선택 심화입니다. 소프트 삭제는 12장의 최종 모델 바로 뒤에 따로 읽을 수 있고, 일별 집계와 마이크로배치 집계는 8장의 트랜잭션과 12장의 파생 모델을 먼저 알아야 합니다.
탈퇴 버튼이 물리적인 DELETE를 실행하면 게시글과 감사 기록의 참조가 끊깁니다. 그래서 행을 남기고 삭제되었다고 표시하는 소프트 삭제(soft delete)를 씁니다. 그러나 is_deleted 불리언 하나는 언제, 누가, 왜 삭제했는지 말하지 못하고, 모든 쿼리에 같은 필터를 붙여야 한다는 부담도 감춥니다.
회원 게시판은 탈퇴한 이메일로 새 사용자가 다시 가입할 수 있다는 정책을 고릅니다. 기존의 이메일 UNIQUE는 삭제한 행까지 포함하므로 재가입을 막고, 반대로 UNIQUE를 없애면 활성 계정 두 개가 같은 주소를 가질 수 있습니다. 생성 열 active_email은 살아 있는 행에만 정규화한 이메일을 담아 두 요구를 함께 만족시킵니다.
이 마이그레이션은 12장 4편의 members를 직접 확장합니다. 12장에서 status = 'DELETED'로 나타내던 탈퇴 여부를 이 단계부터 deleted_at 묶음으로 옮기고, status는 ACTIVE와 SUSPENDED라는 접근 상태만 나타냅니다. 기존에 삭제된 행이 있다면 ALTER 전에 삭제 시각과 수행자, 사유를 먼저 옮겨야 하며, 이 실습의 데이터에는 아직 삭제된 행이 없다고 가정합니다.
삭제된 회원의 posts는 그대로 보존되고 외래 키도 유지됩니다. 애플리케이션의 일반 목록은 활성 뷰를 쓰고, 운영 조사만 기본 테이블을 읽습니다. deleted_at은 UTC의 DATETIME(6), deleted_by는 작업을 승인한 회원 ID, delete_reason은 사람이 검토할 사유이며, 같은 탈퇴 요청이 다시 와도 처음의 삭제 정보를 덮지 않도록 요청 테이블에 외부 요청 ID를 보존합니다.
삭제 플래그와 유일 제약의 충돌
is_deleted를 1로 바꾼 뒤 같은 이메일로 새 행을 넣으면 이메일 UNIQUE가 거부합니다. 인덱스를 없애면 동시에 들어온 가입 두 건이 모두 들어갈 수 있고, 불리언에는 사건의 시각이 없어 보존 기간도 계산할 수 없습니다.
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 ('[email protected]', '첫 계정');
UPDATE member_delete_bad SET is_deleted = TRUE
WHERE email = '[email protected]';
INSERT INTO member_delete_bad (email, name)
VALUES ('[email protected]', '재가입 계정');
SELECT member_id, email, is_deleted FROM member_delete_bad;ERROR 1062 (23000): Duplicate entry '[email protected]' for key 'member_delete_bad.uq_member_delete_bad_email'
member_id | email | is_deleted
1 | [email protected] | 1재가입은 거부되었고, 남은 행에는 삭제되었다는 사실만 있습니다. 언제 탈퇴했는지 알 수 없으므로 "30일 전에 탈퇴한 계정"을 고르는 보존 기간의 쿼리는 쓸 수 없고, 누가 왜 삭제했는지도 남지 않았습니다.
삭제는 상태가 아니라 사건입니다. 사건을 나중에 처리하려면 일어난 시각과 요청의 식별자가 필요하지만, 불리언은 지금의 참과 거짓만 나타냅니다. 활성 이메일의 유일성은 부분 인덱스가 있으면 자연스럽지만 MySQL에는 WHERE 조건이 붙은 UNIQUE가 없으므로, 삭제한 행에는 NULL, 활성 행에는 LOWER(email)을 내는 생성 열을 만들고, UNIQUE가 NULL을 여러 개 허용하는 성질을 이용합니다.
마이그레이션 전에는 회원을 조회하는 SQL과 외래 키 조인, 관리자 내보내기를 모두 찾습니다. 다만 모든 곳에 deleted_at IS NULL을 무작정 넣지는 않습니다. 과거의 게시글 보고서는 탈퇴한 회원도 보여야 하고, 로그인과 검색, 가입의 중복 확인만 활성 행으로 제한합니다.
삭제 시각과 활성 행
deleted_at이 NULL이면 삭제되지 않은 것이고, 값이 있으면 삭제된 것입니다. 여기서 이메일 유일성의 "활성"은 삭제되지 않았다는 뜻이며, 로그인할 수 있는 status = 'ACTIVE'와는 다릅니다. deleted_by와 사유는 같은 트랜잭션에서 채우고, 세 열 가운데 일부만 채워진 상태는 CHECK로 거부해 반쯤 삭제된 행을 막습니다.
활성 이메일은 입력값이 아니라 파생된 키입니다. 원래의 이메일을 보존해야 하는 기간 동안 이전 행은 그대로 두지만, 그 행의 active_email이 NULL이므로 새 가입자가 같은 주소를 쓸 수 있습니다. 대소문자의 규칙은 LOWER와 열의 정렬 규칙이 일치해야 합니다.
읽기 기본값을 뷰로 한곳에 모으기
active_members_v는 삭제되지 않은 회원을 고르는 뷰이며, SUSPENDED인 회원도 포함합니다. 로그인은 기본 테이블에서 deleted_at IS NULL과 status = 'ACTIVE'를 함께 확인하거나, 그 조건을 드러내는 별도의 뷰가 필요합니다.
뷰 하나로 보안의 범위 전체를 해결할 수는 없지만, 필터가 빠진 쿼리를 코드 리뷰에서 찾기 쉬워집니다. 관리자 기능은 including_deleted처럼 이름에 삭제된 행을 포함한다는 것을 드러낸 별도의 쿼리를 씁니다.
이 예제는 STORED 생성 열에 UNIQUE 인덱스를 둡니다. InnoDB는 VIRTUAL 생성 열의 보조 인덱스도 지원하므로 인덱스 자체가 STORED만의 기능은 아니며, 이메일 정규화의 정책을 바꾼다면 열의 식과 기존 인덱스를 온라인 마이그레이션의 순서로 함께 교체합니다.
활성 이메일의 유일성
DDL은 이전의 uq_members_email과 상태 CHECK를 지운 뒤, 삭제 정보와 active_email, 접근 상태만 담는 새 CHECK를 더합니다. 같은 이름의 CHECK를 한 ALTER 안에서 지우고 다시 만들지 않도록 두 문장으로 나누었습니다. 요청 테이블은 같은 UUID의 두 번째 호출을 판단하고, 프로시저는 대상 행을 잠근 뒤 이미 삭제되었으면 처음의 결과를 돌려줍니다.
프로시저가 START TRANSACTION과 COMMIT을 직접 실행하므로, 아래 CALL은 진행 중인 다른 트랜잭션 밖에서 차례로 실행합니다. 탈퇴 요청의 재전송을 판단하는 것은 삭제 정보를 덮지 않기 위한 규칙이며, API의 인증과 권한 확인까지 이 프로시저가 대신하지는 않습니다.
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 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 delete_reason IS NOT NULL
AND CHAR_LENGTH(TRIM(delete_reason)) >= 10)
),
ADD INDEX ix_members_deleted_at (deleted_at, id);
ALTER TABLE members
ADD CONSTRAINT ck_members_status
CHECK (status IN ('ACTIVE', 'SUSPENDED'));
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' AS 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' AS 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' AS outcome;
END//
DELIMITER ;
SET @member_id = (
SELECT id FROM members
WHERE email = '[email protected]' 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');
SELECT
(SELECT COUNT(*) FROM active_members_v WHERE member_id = @member_id) AS active_rows,
(SELECT COUNT(*) FROM member_delete_request) AS request_rows,
(SELECT COUNT(*) FROM posts WHERE author_id = @member_id) AS kept_posts;member_id | outcome
1 | DELETED
member_id | outcome
1 | REPLAY
active_rows | request_rows | kept_posts
0 | 1 | 112장 4편의 시드 회원 1이 탈퇴했습니다. 첫 호출만 삭제 묶음을 채웠고, 재전송은 같은 회원 ID를 REPLAY로 돌려주며 09:01이라는 새 시각을 적용하지 않았습니다. 회원 1은 활성 뷰에서 사라졌지만, 그가 쓴 게시글은 외래 키와 함께 그대로 남습니다.
소프트 삭제는 자식의 외래 키를 건드리지 않으므로 게시글의 이력과 감사가 남습니다. 같은 이메일로 새로 가입하면 새 행의 active_email이 다시 이메일 값이 되지만, 삭제한 행의 NULL과는 충돌하지 않습니다. deleted_by의 자기 참조 외래 키는 행위자의 행을 물리적으로 지우지 못하게 하며, 시스템 행위자를 별도의 주체 테이블로 빼서 사용자 계정의 수명과 운영 주체의 수명을 나누는 조직도 있습니다.
삭제가 쌓이면 테이블 스캔과 보조 인덱스의 폭은 계속 늘어납니다. active_email의 동등 조회는 생성 열의 유일 인덱스를 쓸 수 있지만, deleted_at IS NULL의 목록이 반드시 같은 인덱스를 쓰는 것은 아니며 보관 쿼리와 백업은 전체 크기의 영향을 받습니다. 그래서 활성 행과 전체 행의 수를 따로 측정하고, 보존 정책이 허용하면 익명화나 콜드 보관의 단계로 넘깁니다.
재가입과 불완전한 삭제의 검증
탈퇴한 [email protected]으로 새 회원을 넣어 활성 유일성의 동작을 확인합니다. 두 번째 활성 행을 만들려는 INSERT는 1062여야 하고, 묶음 CHECK는 deleted_at만 채우는 우회 UPDATE를 막아야 합니다.
INSERT INTO members (email, password_hash, name, status, created_at)
VALUES ('[email protected]', '$2b$12$rejoined', '재가입 회원', 'ACTIVE',
CURRENT_TIMESTAMP(6));
SET @rejoined_id = LAST_INSERT_ID();
INSERT INTO members (email, password_hash, name, status, created_at)
VALUES ('[email protected]', '$2b$12$duplicate', '중복 활성 계정', 'ACTIVE',
CURRENT_TIMESTAMP(6));
UPDATE members SET deleted_at = '2026-07-16 10:00:00'
WHERE id = @rejoined_id;
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 AS s
LEFT JOIN members AS l ON l.id = s.author_id
WHERE l.id IS NULL;ERROR 1062 (23000): Duplicate entry '[email protected]' for key 'members.uq_members_active_email'
ERROR 3819 (HY000): Check constraint 'chk_members_delete_bundle' is violated.첫 재가입은 성공합니다. 탈퇴한 행의 active_email이 NULL이라 새 행의 [email protected]과 겹치지 않기 때문입니다. 같은 주소의 두 번째 활성 행은 UNIQUE가, 삭제 시각만 채우는 UPDATE는 묶음 CHECK가 거부하며, 마지막 세 대조 쿼리는 모두 빈 결과입니다. 앞의 실패한 INSERT들이 AUTO_INCREMENT 값을 썼을 수 있으므로, 재가입한 회원의 id는 @rejoined_id로만 다룹니다.
운영에서는 쿼리 로그에서 members 기본 테이블을 직접 읽는 일반 사용자 엔드포인트를 추적합니다. 탈퇴한 회원이 검색 결과에 나타난다면 그 SQL을 활성 뷰로 옮기고, 회귀 테스트의 데이터에 삭제된 행을 넣습니다.
같은 이메일의 탈퇴·재가입·복원
기존 min 이메일은 하나만 허용된다. 활성 이메일이 충돌한다. 각 단계의 값과 전환 이유를 비교합니다.
SOFT DELETE · EMAIL UNIQUENESS / STABLE ID
탈퇴 주소는 재사용하되 미삭제 주소는 하나만 허용한다
deleted_at이 채워지면 기존 행의 active_email은 NULL이 되어 새 가입이 가능합니다. 회원 ID와 게시글 FK는 유지되며, 이미 주소를 재사용했다면 기존 회원 복원은 거절됩니다.
| 단계 | 기존 회원의 active_email | 새 회원·복원 결과 |
|---|---|---|
| 탈퇴 전 | [email protected] | 같은 활성 이메일 INSERT 거절 |
| 탈퇴 후 | NULL | 같은 이메일의 새 회원 INSERT 허용 |
| 재가입 후 | NULL | 새 회원이 [email protected] 점유 |
| 기존 회원 복원 시도 | 복원 전에는 NULL | 재가입 회원이 있으면 충돌로 롤백 |
- 탈퇴 전
- 기존 회원의 active_email: [email protected]새 회원·복원 결과: 같은 활성 이메일 INSERT 거절
- 탈퇴 후
- 기존 회원의 active_email: NULL새 회원·복원 결과: 같은 이메일의 새 회원 INSERT 허용
- 재가입 후
- 기존 회원의 active_email: NULL새 회원·복원 결과: 새 회원이 [email protected] 점유
- 기존 회원 복원 시도
- 기존 회원의 active_email: 복원 전에는 NULL새 회원·복원 결과: 재가입 회원이 있으면 충돌로 롤백
같은 탈퇴 요청 · 최초 09:00은 보존
09:00 첫 호출 DELETED → 삭제 시각·수행자·사유 함께 기록
09:01 재호출 REPLAY → 요청 1행 · 최초 시각 그대로
이메일 키의 활성은 미삭제입니다. active_members_v에는 SUSPENDED도 포함되므로 로그인은 status=ACTIVE도 검사합니다. deleted_at만 설정한 우회 UPDATE는 묶음 CHECK 3819이며, 소프트 삭제는 개인정보 파기 완료가 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 활성 원본부터 이전 회원 복원 시도까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 세 노드, 두 관계. 실선은 과거 게시글이 탈퇴한 기존 회원 ID를 참조하는 FK이다. 점선은 삭제 행의 active_email=NULL일 때 같은 이메일로 새 회원 가입이 가능하다는 정책 관계이며 FK나 기존 회원이 새 회원으로 바뀌는 전이가 아니다. 새 회원이 이메일을 점유하면 기존 회원 복원은 충돌로 거절된다. 두 레이아웃은 같은 관계를 나타낸다. |
| 표의 값 | 탈퇴 전. 기존 회원의 active_email: [email protected]. 새 회원·복원 결과: 같은 활성 이메일 INSERT 거절. 탈퇴 후. 기존 회원의 active_email: NULL. 새 회원·복원 결과: 같은 이메일의 새 회원 INSERT 허용. 재가입 후. 기존 회원의 active_email: NULL. 새 회원·복원 결과: 새 회원이 [email protected] 점유. 기존 회원 복원 시도. 기존 회원의 active_email: 복원 전에는 NULL. 새 회원·복원 결과: 재가입 회원이 있으면 충돌로 롤백. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 활성 원본 기존 min 이메일은 하나만 허용된다. |
|
|
| 논리 삭제 삭제 메타데이터를 함께 기록한다. |
|
|
| 새 회원 생성 같은 이메일을 새 ID로 사용한다. |
| |
| 이전 회원 복원 시도 활성 이메일이 충돌한다. |
|
|
같은 이메일의 탈퇴·재가입·복원
기존 min 이메일은 하나만 허용된다. 활성 이메일이 충돌한다. 각 단계의 값과 전환 이유를 비교합니다.
SOFT DELETE · EMAIL UNIQUENESS / STABLE ID
탈퇴 주소는 재사용하되 미삭제 주소는 하나만 허용한다
deleted_at이 채워지면 기존 행의 active_email은 NULL이 되어 새 가입이 가능합니다. 회원 ID와 게시글 FK는 유지되며, 이미 주소를 재사용했다면 기존 회원 복원은 거절됩니다.
| 단계 | 기존 회원의 active_email | 새 회원·복원 결과 |
|---|---|---|
| 탈퇴 전 | [email protected] | 같은 활성 이메일 INSERT 거절 |
| 탈퇴 후 | NULL | 같은 이메일의 새 회원 INSERT 허용 |
| 재가입 후 | NULL | 새 회원이 [email protected] 점유 |
| 기존 회원 복원 시도 | 복원 전에는 NULL | 재가입 회원이 있으면 충돌로 롤백 |
- 탈퇴 전
- 기존 회원의 active_email: [email protected]새 회원·복원 결과: 같은 활성 이메일 INSERT 거절
- 탈퇴 후
- 기존 회원의 active_email: NULL새 회원·복원 결과: 같은 이메일의 새 회원 INSERT 허용
- 재가입 후
- 기존 회원의 active_email: NULL새 회원·복원 결과: 새 회원이 [email protected] 점유
- 기존 회원 복원 시도
- 기존 회원의 active_email: 복원 전에는 NULL새 회원·복원 결과: 재가입 회원이 있으면 충돌로 롤백
같은 탈퇴 요청 · 최초 09:00은 보존
09:00 첫 호출 DELETED → 삭제 시각·수행자·사유 함께 기록
09:01 재호출 REPLAY → 요청 1행 · 최초 시각 그대로
이메일 키의 활성은 미삭제입니다. active_members_v에는 SUSPENDED도 포함되므로 로그인은 status=ACTIVE도 검사합니다. deleted_at만 설정한 우회 UPDATE는 묶음 CHECK 3819이며, 소프트 삭제는 개인정보 파기 완료가 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 활성 원본부터 이전 회원 복원 시도까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 세 노드, 두 관계. 실선은 과거 게시글이 탈퇴한 기존 회원 ID를 참조하는 FK이다. 점선은 삭제 행의 active_email=NULL일 때 같은 이메일로 새 회원 가입이 가능하다는 정책 관계이며 FK나 기존 회원이 새 회원으로 바뀌는 전이가 아니다. 새 회원이 이메일을 점유하면 기존 회원 복원은 충돌로 거절된다. 두 레이아웃은 같은 관계를 나타낸다. |
| 표의 값 | 탈퇴 전. 기존 회원의 active_email: [email protected]. 새 회원·복원 결과: 같은 활성 이메일 INSERT 거절. 탈퇴 후. 기존 회원의 active_email: NULL. 새 회원·복원 결과: 같은 이메일의 새 회원 INSERT 허용. 재가입 후. 기존 회원의 active_email: NULL. 새 회원·복원 결과: 새 회원이 [email protected] 점유. 기존 회원 복원 시도. 기존 회원의 active_email: 복원 전에는 NULL. 새 회원·복원 결과: 재가입 회원이 있으면 충돌로 롤백. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 활성 원본 기존 min 이메일은 하나만 허용된다. |
|
|
| 논리 삭제 삭제 메타데이터를 함께 기록한다. |
|
|
| 새 회원 생성 같은 이메일을 새 ID로 사용한다. |
| |
| 이전 회원 복원 시도 활성 이메일이 충돌한다. |
|
|
재가입을 허용하는 것과 개인정보를 보존하는 것은 별개의 정책이므로, 이 글은 이메일을 바로 익명화하지 않았습니다. 사기 방지나 법적 보존의 기간 동안 암호화된 식별자가 필요할 수 있고, 원문이 필요 없어지는 시점에 16장 2편의 익명화 단계가 이메일을 되돌릴 수 없는 값으로 바꿉니다.
잘못된 탈퇴를 되돌릴 때 deleted_at만 NULL로 만들면 CHECK에 걸리므로, 행위자와 사유도 함께 비우고 active_email의 충돌을 먼저 확인합니다. 이미 누군가 같은 이메일로 다시 가입했다면 자동으로 복원하지 않습니다(연습 문제 1). 탈퇴한 회원의 태그나 게시글까지 연쇄적으로 소프트 삭제할지는 업무의 규칙으로 정하며, 부모의 필터를 조인에 붙여 자식을 숨기기만 하면 과거의 집계가 달라질 수 있으므로 인증의 접근과 분석의 보존을 다른 읽기 모델로 나눕니다.
| 따져 볼 것 | 질문 |
|---|---|
| 참조 | 삭제한 뒤에도 자식의 사실을 보존해야 하는가 |
| 복구 | 잘못된 삭제를 되돌릴 업무 창구와 기한이 있는가 |
| 식별자 | 탈퇴한 계정의 이메일 재사용을 허용하는가 |
| 개인정보 | 원문을 보존할 법적 이유와 익명화의 시점이 있는가 |
| 운영 | 모든 읽기 경로의 활성 필터를 추적할 수 있는가 |
감사와 게시글의 참조를 남겨야 하고 복원의 요구가 있다면 소프트 삭제가 알맞고, 규정상 바로 파기해야 하며 참조도 없다면 물리 삭제가 단순합니다. 채택할 때는 활성의 정의와 이메일의 재사용, 보존이 끝난 뒤의 처리까지 문서에 적습니다.
정리
- 소프트 삭제는 불리언이 아니라 시각과 주체, 사유를 가진 수명의 사건입니다.
- 생성 열
active_email은 탈퇴한 주소의 재사용과 활성 주소의 유일성을 함께 나타냅니다. - 묶음 CHECK가 삭제 정보의 일부만 채워진 행을 막고, 요청 테이블이 재전송이 처음의 정보를 덮지 않게 합니다.
- 일반 읽기는 활성 뷰를 쓰고, 과거의 사실은 기본 외래 키로 유지합니다.
- 복원과 익명화, 최종 파기는 삭제와 구분된 이후의 전이입니다.
연습 문제
1. 재가입자가 없을 때만 복원하기
재가입한 사람이 아직 없을 때만 회원을 복원하는 프로시저를 작성해 보세요. 대상과 같은 active_email이 있는지 트랜잭션 안에서 검사하고, 삭제 묶음의 세 열을 함께 NULL로 만들어야 합니다.
해설 보기
삭제된 행을 잠근 뒤, 그 LOWER(email)이 다른 활성 행에 있는지 확인합니다. 충돌하면 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);ERROR 1644 (45000): active email already reused본문에서 같은 이메일로 재가입한 회원이 있으므로 복원은 롤백됩니다. 재가입한 행이 없었다면 세 열이 함께 비워지고, active_email이 다시 계산되어 회원 1이 활성 뷰에 한 행으로 나타납니다. 다음 글은 탈퇴한 회원 1을 이어서 쓰므로 여기서는 재가입한 행을 지우지 않습니다.
2. 요청 ID가 바뀌었을 때의 분기
본문의 첫 삭제가 09:00에 커밋된 직후, 같은 회원에 새 요청 UUID를 쓰고 수행자에는 NULL, 사유에는 짧은 문자열을 넘기면 프로시저는 어느 분기로 갈까요? 같은 요청 UUID를 다른 회원 ID와 함께 다시 보내는 경우도 판단해 보세요.
해설 보기
새 UUID라도 대상 회원이 이미 삭제되어 v_deleted에 값이 있으므로, ALREADY_DELETED 분기에서 요청 행만 더하고 처음의 09:00을 그대로 둡니다. 이 분기는 뒤의 사유 검사와 members의 UPDATE에 이르지 않으므로, NULL 수행자와 짧은 사유는 검사되지도 적용되지도 않습니다.
같은 UUID를 다시 쓰면 첫 요청 조회에서 REPLAY가 되며, 새로 넘긴 회원 ID도 비교하지 않습니다. 그러므로 모든 호출의 입력이 일치하는지나 권한이 있는지를 확인해야 하는 API라면, 프로시저 앞단에서 그 규칙을 따로 정해야 합니다.
다음 글 계정 상태와 익명화에서는 탈퇴와 정지, 익명화, 보존의 만료를 서로 다른 상태의 축으로 나눕니다.