계정 상태와 익명화
로그인을 막는 정지와 서비스 탈퇴, 개인정보의 파기를 하나의 상태값에 넣은 모순을 재현하고, 접근 상태와 삭제의 수명, 익명화를 서로 다른 축으로 나눕니다. 보존 기한과 법적 보류를 확인한 승인 요청만 익명화하는 프로시저를 만듭니다.
로그인을 막는 정지와 사용자가 떠난 탈퇴는 화면에서 비슷해 보입니다. 그러나 정지는 기간이 끝나면 계정을 다시 활성화하고 게시글도 그대로 쓰는 반면, 탈퇴는 재가입과 보존 기한, 개인정보의 파기 절차를 시작합니다. 둘을 INACTIVE라는 상태 하나에 넣으면, 후속 작업은 어떤 이유로 비활성인지 추측해야 합니다.
익명화(anonymization)도 탈퇴와 같은 순간이 아닙니다. 분쟁의 처리나 법정 보존의 기간 동안 원래의 식별자를 제한적으로 유지하다가, 승인된 기준 시각이 지나면 되돌릴 수 없는 값으로 바꿀 수 있습니다. 그래서 이 글은 접근 권한과 논리 삭제, PII(Personally Identifiable Information, 개인 식별 정보)의 파기를 세 개의 독립된 신호로 모델링합니다.
16장 1편의 members에는 이미 deleted_at 묶음과 접근 상태 status가 있고, 여기에 정지의 정보와 anonymized_at을 더합니다. status는 계속 ACTIVE와 SUSPENDED만 나타내고 삭제 여부는 deleted_at으로 판단합니다. member_erasure_request는 보존이 끝나는 시각과 승인자를 기록하며, 익명화하는 작업자는 요청 상태가 APPROVED이고 execute_after가 기준 시각 이하인 행만 작은 배치로 처리합니다.
이메일은 회원 ID로 만든 가상의 주소로 바꾸고, name은 고정된 표시 이름으로 바꿉니다. 이 예제는 두 열에서 원래의 값을 없앨 뿐이며, 회원 ID로의 연결이나 비밀번호 해시, 다른 저장소까지 익명화하거나 모든 재식별의 가능성을 없앤다는 뜻은 아닙니다.
단일 상태값의 모순
운영 배치가 INACTIVE인 계정을 모두 익명화하면, 일주일 정지된 사용자도 원래의 정보를 잃습니다. 반대로 INACTIVE를 영구히 보존하면 탈퇴한 사람의 PII가 기한 없이 남고, 상태의 이름만으로는 어떤 전이였는지와 언제 실행할지를 되살릴 수 없습니다.
DROP TABLE IF EXISTS member_lifecycle_bad;
CREATE TABLE member_lifecycle_bad (
member_id BIGINT UNSIGNED PRIMARY KEY,
email VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL,
CHECK (status IN ('ACTIVE', 'INACTIVE'))
) ENGINE=InnoDB;
INSERT INTO member_lifecycle_bad VALUES
(1, '[email protected]', 'INACTIVE'),
(2, '[email protected]', 'INACTIVE');
UPDATE member_lifecycle_bad
SET email = CONCAT('anonymous+', member_id, '@invalid.local')
WHERE status = 'INACTIVE';
SELECT member_id, email FROM member_lifecycle_bad ORDER BY member_id;member_id | email
1 | [email protected]
2 | [email protected]정지된 계정 1과 탈퇴한 계정 2가 같은 조건에 걸려 모두 익명화되었습니다. 보존 기한도, 승인도 확인하지 않았으며, 정지된 사용자는 정지가 풀려도 원래의 이메일을 되찾을 수 없습니다.
문제의 본질은 상태의 값이 많아지는 것이 아니라, 서로 다른 질문을 같은 축에 넣은 데 있습니다. "로그인할 수 있는가", "사용자가 탈퇴했는가", "PII가 파기되었는가"는 각각 따로 답해야 합니다. SUSPENDED에는 suspended_until과 사유가, 탈퇴에는 deleted_at 묶음이, 익명화에는 anonymized_at과 승인 요청이 필요하며, 전이마다 필수 정보를 CHECK로 묶으면 활성인데 정지 사유가 남아 있는 불일치도 잡을 수 있습니다.
보존의 기준 시각은 현재 시각에서 임의로 빼서 계산하지 않습니다. 정책이 바뀌거나 법적 보류가 걸리면 deleted_at + 30일의 계산만으로는 부족하므로, 요청 행에 execute_after를 확정하고 legal_hold_until을 따로 보존하며, 작업자는 승인 당시의 정책 결과를 읽습니다.
접근 상태와 데이터의 수명
읽는 쪽은 목적에 맞는 조건을 조합하며, INACTIVE라는 합성 값은 만들지 않습니다.
접근·탈퇴·식별자 교체를 구분하는 세 축
APPROVED 요청을 잠근다. 이메일·이름과 요청 상태를 바꾼다. 각 단계의 값과 전환 이유를 비교합니다.
MEMBER LIFECYCLE · ACCESS / DELETION / ERASURE
정지·탈퇴·식별자 교체를 서로 다른 신호로 판단한다
로그인 여부는 status와 deleted_at을 함께 읽고, 익명화는 승인 요청과 보존 기준·법적 보류를 확인합니다. 이메일·이름 교체 뒤에도 기존 회원 ID와 게시글 참조는 남습니다.
| 축 | 저장하는 신호 | 판단 범위 |
|---|---|---|
| 접근 상태 | status + 정지 기한·사유 | ACTIVE 또는 SUSPENDED; 탈퇴 여부는 별도 확인 |
| 서비스 수명 | deleted_at + 수행자·사유 | 미삭제 또는 탈퇴; 원래 회원 키와 FK 보존 |
| 식별자 교체 | anonymized_at + 승인 요청 | 이메일·이름 교체 완료; DB 밖 파기는 별도 확인 |
- 접근 상태
- 저장하는 신호: status + 정지 기한·사유판단 범위: ACTIVE 또는 SUSPENDED; 탈퇴 여부는 별도 확인
- 서비스 수명
- 저장하는 신호: deleted_at + 수행자·사유판단 범위: 미삭제 또는 탈퇴; 원래 회원 키와 FK 보존
- 식별자 교체
- 저장하는 신호: anonymized_at + 승인 요청판단 범위: 이메일·이름 교체 완료; DB 밖 파기는 별도 확인
승인 요청 한 건 · 실행 조건을 모두 만족
APPROVED + execute_after ≤ cutoff + 법적 보류 종료
대상 deleted_at 있음 + anonymized_at 없음
이메일·이름 교체와 요청 EXECUTED를 함께 COMMIT
cutoff는 신뢰하는 실행 주체가 제공합니다. 이 fixture는 승인자와 대상이 같은 회원이며 승인자 분리 정책을 구현하지 않습니다. 두 열 교체만으로 비밀번호·외부 캐시·감사 JSON과 모든 재식별 가능성까지 없애지는 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 승인 요청 잠금부터 함께 익명화까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 다섯 노드, 네 간선, 한 트랜잭션 영역. 원 프로시저의 여러 조건 검사를 한 조건 마름모로 묶어 요약했다. 유효 cutoff·APPROVED·실행 시각 이하·보류 없음 또는 종료·삭제 상태·미익명 조건을 모두 통과하면 회원 두 열 교체와 요청 EXECUTED를 함께 COMMIT하고, 불충족이면 ROLLBACK과 SIGNAL로 처리한다. 원 코드의 세부 IF·기타 SQL 예외는 본문으로 읽는다. 모바일의 예 경로는 거절 노드를 통과하지 않는다. |
| 표의 값 | 접근 상태. 저장하는 신호: status + 정지 기한·사유. 판단 범위: ACTIVE 또는 SUSPENDED; 탈퇴 여부는 별도 확인. 서비스 수명. 저장하는 신호: deleted_at + 수행자·사유. 판단 범위: 미삭제 또는 탈퇴; 원래 회원 키와 FK 보존. 식별자 교체. 저장하는 신호: anonymized_at + 승인 요청. 판단 범위: 이메일·이름 교체 완료; DB 밖 파기는 별도 확인. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 승인 요청 잠금 APPROVED 요청을 잠근다. |
|
|
| 조건 확인 시각·보존·삭제·미실행 조건을 검사한다. |
|
|
| 함께 익명화 이메일·이름과 요청 상태를 바꾼다. |
|
|
접근·탈퇴·식별자 교체를 구분하는 세 축
APPROVED 요청을 잠근다. 이메일·이름과 요청 상태를 바꾼다. 각 단계의 값과 전환 이유를 비교합니다.
MEMBER LIFECYCLE · ACCESS / DELETION / ERASURE
정지·탈퇴·식별자 교체를 서로 다른 신호로 판단한다
로그인 여부는 status와 deleted_at을 함께 읽고, 익명화는 승인 요청과 보존 기준·법적 보류를 확인합니다. 이메일·이름 교체 뒤에도 기존 회원 ID와 게시글 참조는 남습니다.
| 축 | 저장하는 신호 | 판단 범위 |
|---|---|---|
| 접근 상태 | status + 정지 기한·사유 | ACTIVE 또는 SUSPENDED; 탈퇴 여부는 별도 확인 |
| 서비스 수명 | deleted_at + 수행자·사유 | 미삭제 또는 탈퇴; 원래 회원 키와 FK 보존 |
| 식별자 교체 | anonymized_at + 승인 요청 | 이메일·이름 교체 완료; DB 밖 파기는 별도 확인 |
- 접근 상태
- 저장하는 신호: status + 정지 기한·사유판단 범위: ACTIVE 또는 SUSPENDED; 탈퇴 여부는 별도 확인
- 서비스 수명
- 저장하는 신호: deleted_at + 수행자·사유판단 범위: 미삭제 또는 탈퇴; 원래 회원 키와 FK 보존
- 식별자 교체
- 저장하는 신호: anonymized_at + 승인 요청판단 범위: 이메일·이름 교체 완료; DB 밖 파기는 별도 확인
승인 요청 한 건 · 실행 조건을 모두 만족
APPROVED + execute_after ≤ cutoff + 법적 보류 종료
대상 deleted_at 있음 + anonymized_at 없음
이메일·이름 교체와 요청 EXECUTED를 함께 COMMIT
cutoff는 신뢰하는 실행 주체가 제공합니다. 이 fixture는 승인자와 대상이 같은 회원이며 승인자 분리 정책을 구현하지 않습니다. 두 열 교체만으로 비밀번호·외부 캐시·감사 JSON과 모든 재식별 가능성까지 없애지는 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 승인 요청 잠금부터 함께 익명화까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 다섯 노드, 네 간선, 한 트랜잭션 영역. 원 프로시저의 여러 조건 검사를 한 조건 마름모로 묶어 요약했다. 유효 cutoff·APPROVED·실행 시각 이하·보류 없음 또는 종료·삭제 상태·미익명 조건을 모두 통과하면 회원 두 열 교체와 요청 EXECUTED를 함께 COMMIT하고, 불충족이면 ROLLBACK과 SIGNAL로 처리한다. 원 코드의 세부 IF·기타 SQL 예외는 본문으로 읽는다. 모바일의 예 경로는 거절 노드를 통과하지 않는다. |
| 표의 값 | 접근 상태. 저장하는 신호: status + 정지 기한·사유. 판단 범위: ACTIVE 또는 SUSPENDED; 탈퇴 여부는 별도 확인. 서비스 수명. 저장하는 신호: deleted_at + 수행자·사유. 판단 범위: 미삭제 또는 탈퇴; 원래 회원 키와 FK 보존. 식별자 교체. 저장하는 신호: anonymized_at + 승인 요청. 판단 범위: 이메일·이름 교체 완료; DB 밖 파기는 별도 확인. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 승인 요청 잠금 APPROVED 요청을 잠근다. |
|
|
| 조건 확인 시각·보존·삭제·미실행 조건을 검사한다. |
|
|
| 함께 익명화 이메일·이름과 요청 상태를 바꾼다. |
|
|
익명화는 UPDATE이지만, 되돌릴 수 있는 소프트 삭제와 달리 되돌릴 수 없는 전이입니다. 그래서 프로시저는 대상의 삭제된 행을 잠그고, APPROVED인 요청과 기준 시각, 법적 보류가 없음, 아직 익명화되지 않았음을 모두 확인한 뒤에 PII를 바꿉니다.
posts의 author_id 외래 키는 그대로 둡니다. 분석은 익명이 된 회원의 키로 기간과 카테고리를 집계할 수 있지만 이메일과 name은 사라지며, 외부로 내보낼 때 member_id 자체가 재식별의 위험이라면 별도의 분석용 대리 ID와 접근 정책이 필요합니다.
이메일의 SHA2만 저장하면 사전 공격으로 원래 값을 찾을 수 있습니다. 이 실습은 원문을 가상의 값으로 바꾸고, 같은 사람인지 비교할 필요가 있다면 비밀 키를 넣어 계산하는 HMAC 서비스를 따로 둔다고 가정합니다. 키를 모르는 사람은 후보 이메일의 HMAC 값을 계산해 맞춰 볼 수 없으므로, 키를 데이터베이스 밖에 두면 저장된 값만으로는 사전 공격을 할 수 없습니다. 키 없는 해시와 HMAC의 차이는 네트워크 교재의 암호화의 기초에서 다룹니다.
승인된 익명화의 처리
마이그레이션은 정지의 묶음과 anonymized_at을 더합니다. 실습처럼 기존에 SUSPENDED인 행이 없다는 전제이며, 이미 정지된 회원이 있다면 필수인 기한과 사유를 옮기는 순서를 먼저 정해야 합니다. ck_members_status를 접근 상태만 담도록 바꾸는 일은 16장 1편에서 끝났으므로 반복하지 않습니다.
파기 요청은 회원마다 열린 절차를 하나만 갖고, APPROVED와 CANCELLED, EXECUTED를 기록합니다. 작업자 프로시저는 한 요청을 처리한 뒤, 요청의 상태와 회원의 행을 함께 커밋합니다.
DROP PROCEDURE IF EXISTS anonymize_member;
DROP TABLE IF EXISTS member_erasure_request;
ALTER TABLE members
MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
ADD COLUMN suspended_until DATETIME(6) NULL,
ADD COLUMN suspension_reason VARCHAR(240) NULL,
ADD COLUMN anonymized_at DATETIME(6) NULL,
ADD CONSTRAINT chk_members_access CHECK (
(status = 'ACTIVE' AND suspended_until IS NULL AND suspension_reason IS NULL)
OR
(status = 'SUSPENDED' AND suspended_until IS NOT NULL
AND suspension_reason IS NOT NULL
AND CHAR_LENGTH(TRIM(suspension_reason)) >= 10)
),
ADD CONSTRAINT chk_members_anonymized
CHECK (anonymized_at IS NULL OR deleted_at IS NOT NULL),
ADD INDEX ix_members_suspension (status, suspended_until, id);
CREATE TABLE member_erasure_request (
erasure_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
member_id BIGINT UNSIGNED NOT NULL,
request_status VARCHAR(16) NOT NULL,
requested_at DATETIME(6) NOT NULL,
approved_by BIGINT UNSIGNED NULL,
approved_at DATETIME(6) NULL,
execute_after DATETIME(6) NOT NULL,
legal_hold_until DATETIME(6) NULL,
executed_at DATETIME(6) NULL,
open_slot TINYINT GENERATED ALWAYS AS
(IF(request_status IN ('PENDING', 'APPROVED'), 1, NULL)) STORED,
CONSTRAINT uq_erasure_open UNIQUE (member_id, open_slot),
CONSTRAINT fk_erasure_member FOREIGN KEY (member_id)
REFERENCES members (id) ON DELETE RESTRICT,
CONSTRAINT fk_erasure_approver FOREIGN KEY (approved_by)
REFERENCES members (id) ON DELETE RESTRICT,
CONSTRAINT chk_erasure_status CHECK
(request_status IN ('PENDING', 'APPROVED', 'CANCELLED', 'EXECUTED')),
CONSTRAINT chk_erasure_approval CHECK (
(request_status = 'PENDING' AND approved_by IS NULL AND approved_at IS NULL)
OR (request_status IN ('APPROVED', 'EXECUTED')
AND approved_by IS NOT NULL AND approved_at IS NOT NULL)
OR request_status = 'CANCELLED'
),
INDEX ix_erasure_due (request_status, execute_after, erasure_id)
) ENGINE=InnoDB;
DELIMITER //
CREATE PROCEDURE anonymize_member(
IN p_erasure BIGINT UNSIGNED,
IN p_cutoff DATETIME(6)
)
BEGIN
DECLARE v_member BIGINT UNSIGNED;
DECLARE v_execute DATETIME(6);
DECLARE v_hold DATETIME(6);
DECLARE v_deleted DATETIME(6);
DECLARE v_anonymized DATETIME(6);
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
SELECT member_id, execute_after, legal_hold_until
INTO v_member, v_execute, v_hold
FROM member_erasure_request
WHERE erasure_id = p_erasure AND request_status = 'APPROVED'
FOR UPDATE;
IF p_cutoff IS NULL OR v_member IS NULL OR v_execute > p_cutoff
OR (v_hold IS NOT NULL AND v_hold > p_cutoff) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'erasure not executable';
END IF;
SELECT deleted_at, anonymized_at INTO v_deleted, v_anonymized
FROM members
WHERE id = v_member
FOR UPDATE;
IF v_deleted IS NULL OR v_anonymized IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'member lifecycle incompatible';
END IF;
UPDATE members
SET email = CONCAT('anonymous+', id, '@invalid.local'),
name = '탈퇴한 회원',
anonymized_at = p_cutoff
WHERE id = v_member;
UPDATE member_erasure_request
SET request_status = 'EXECUTED', executed_at = p_cutoff
WHERE erasure_id = p_erasure;
COMMIT;
END//
DELIMITER ;
SET @member_id = (
SELECT id FROM members
WHERE deleted_at IS NOT NULL AND anonymized_at IS NULL
ORDER BY id LIMIT 1
);
INSERT INTO member_erasure_request
(member_id, request_status, requested_at, approved_by, approved_at, execute_after)
VALUES (@member_id, 'APPROVED', '2026-07-16 09:00:00', @member_id,
'2026-07-17 09:00:00', '2026-08-16 09:00:00');
SET @erasure_id = LAST_INSERT_ID();
CALL anonymize_member(@erasure_id, '2026-08-16 09:00:00');
SELECT r.request_status, m.email, m.name, m.anonymized_at
FROM member_erasure_request AS r
JOIN members AS m ON m.id = r.member_id
WHERE r.erasure_id = @erasure_id;
CALL anonymize_member(@erasure_id, '2026-08-16 09:00:00');request_status | email | name | anonymized_at
EXECUTED | [email protected] | 탈퇴한 회원 | 2026-08-16 09:00:00.000000
ERROR 1644 (45000): erasure not executable16장 1편에서 탈퇴한 회원 1의 요청이 승인과 보존 기한을 모두 만족해 EXECUTED가 되었고, 이메일과 이름이 바뀌었습니다. execute_after와 기준 시각이 같은 09:00이지만 v_execute > p_cutoff가 거짓이므로 통과합니다. 같은 요청을 다시 처리하려 하면 더는 APPROVED가 아니어서 거부되고, 게시글의 외래 키는 그대로 남습니다.
작업자는 member_id의 범위를 직접 받지 않고 승인 요청의 ID를 받으므로, 대량 UPDATE가 정책의 판단을 건너뛰지 않습니다. 이 예제는 승인자와 대상에 같은 회원 ID를 써서 승인자를 분리하는 정책은 구현하지 않았고, p_cutoff도 신뢰할 수 있는 스케줄러가 주어야 하며 임의의 호출자가 미래의 값을 정하게 두면 안 됩니다.
배치 스케줄러는 처리 대상 인덱스에서 작은 ID 묶음을 읽고 프로시저를 한 건씩 부르거나, 같은 검사를 집합 단위의 트랜잭션으로 옮깁니다. legal_hold_until이 기준 시각보다 크면 처리하지 않고, 보류가 끝난 뒤 스케줄러가 다시 고를 수 있습니다. CANCELLED가 된 요청은 open_slot이 NULL이 되어 새 요청을 허용하지만, 취소된 과거의 행은 보존됩니다.
정지의 해제는 익명화 작업자와 완전히 나눕니다. suspended_until이 지난 회원은 status를 활성으로 되돌리고 정지 묶음을 비우되, deleted_at이 있는 행은 로그인의 대상이 아니므로 해제에서 제외합니다(연습 문제 1). 이런 조건을 쓸 수 있는 것이 수명의 축을 나눈 효과입니다.
정지와 보존, 미승인 요청의 검증
현재 데이터의 불일치와 처리 대상을 조회합니다. 삭제되지 않은 정지 회원이나 보류 중인 승인 요청, 대기 요청은 각각 삭제와 날짜, 상태의 조건에서 거부되어야 하며, 아래 쿼리는 그런 반례를 만들지 않고 현재 상태만 확인합니다.
SELECT id AS member_id FROM members
WHERE status = 'SUSPENDED' AND deleted_at IS NULL AND anonymized_at IS NOT NULL;
SELECT r.erasure_id
FROM member_erasure_request AS r
JOIN members AS l ON l.id = r.member_id
WHERE r.request_status = 'EXECUTED'
AND (l.anonymized_at IS NULL OR r.executed_at <> l.anonymized_at);
SELECT id AS member_id FROM members
WHERE anonymized_at IS NOT NULL
AND (email <> CONCAT('anonymous+', id, '@invalid.local')
OR name <> '탈퇴한 회원');
SELECT r.erasure_id FROM member_erasure_request AS r
WHERE r.request_status = 'APPROVED'
AND r.execute_after <= '2026-08-16 09:00:00'
AND (r.legal_hold_until IS NULL OR r.legal_hold_until <= '2026-08-16 09:00:00');
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;다섯 쿼리는 모두 빈 결과입니다. 처음 셋은 익명화의 불일치를, 넷째는 처리 대상을, 다섯째는 게시글의 고아를 찾는데, 방금 처리한 요청은 EXECUTED이므로 처리 대상에서 빠집니다. 보류 중인 요청이라면 APPROVED여도 legal_hold_until이 이번 기준 시각보다 늦어 제외되고, 보류의 시각이 지난 뒤에 날짜 조건을 다시 판단합니다.
PII를 찾는 검사는 이메일과 name 외에 감사 JSON과 내보내기 캐시, 검색 인덱스까지 포함해야 하며, 데이터베이스의 행 하나를 바꾸었다고 파기가 다 끝났다고 선언하지 않습니다. 탈퇴한 행의 status가 ACTIVE로 남아도, 로그인 쿼리가 deleted_at IS NULL을 함께 요구하면 접근은 막힙니다. 상태를 DELETED로 바꾸지 않은 것은 접근의 축과 수명의 축을 섞지 않기 위해서이며, 관리 화면은 두 값을 조합해 "탈퇴"라는 표시 이름을 만듭니다.
anonymized_at 이전에는 16장 1편의 복원 정책을 적용할 수 있지만, 파기한 뒤에는 원래의 이메일을 데이터베이스에서 되살릴 수 없어야 합니다. 지원 담당자가 가상의 주소를 임의로 바꾸지 못하도록 프로시저와 권한을 나누고, 파기의 완료는 erasure_id와 대상 저장소의 목록, 실행한 버전, 저장소별 완료 시각을 별도의 조정 로그에 남깁니다. 일부 저장소에서 오류가 났다면 EXECUTED 대신 PARTIAL이라는 상태로 관리할 수도 있으며, 이 글의 단일 데이터베이스 프로시저는 트랜잭션 범위 안의 최소 모델입니다.
| 따져 볼 것 | 질문 |
|---|---|
| 접근 | 일시 정지와 영구 탈퇴의 해제 절차가 다른가 |
| 보존 | 탈퇴한 뒤 PII를 유지해야 하는 정해진 기간이 있는가 |
| 승인 | 파기를 실행하기 전에 사람이나 정책 엔진의 승인이 필요한가 |
| 보류 | 소송이나 분쟁이 자동의 기준 시각보다 우선할 수 있는가 |
| 범위 | 데이터베이스 밖의 검색과 백업, 내보내기의 파기를 추적할 수 있는가 |
정지와 탈퇴의 후속 작업이 다르다면 별도의 축이 필요합니다. 익명화는 되돌릴 수 없으므로 단순한 상태 UPDATE보다 승인 요청을 거치는 편이 안전하고, 시스템마다 법적 요구가 다르므로 기간의 숫자를 코드에 숨기지 않습니다.
정리
- 접근의 정지와 서비스의 탈퇴, 개인정보의 익명화는 서로 다른 질문과 전이를 가집니다.
- 하나의 상태값에 여러 수명을 넣으면, 정지된 계정까지 파기되는 것처럼 후속 작업이 잘못된 행을 고릅니다.
- 승인된 파기 요청만, 보존의 기준 시각과 법적 보류를 데이터베이스에서 확인한 뒤 실행합니다.
- 사실의 외래 키를 유지하면서 식별 속성만 가상의 값으로 바꿀 수 있습니다.
- 되돌릴 수 없는 익명화는 되돌릴 수 있는 소프트 삭제 뒤의 별도 단계입니다.
연습 문제
1. 정지의 해제
suspended_until이 지난 정지 회원만 해제하는 프로시저를 만들어 보세요. deleted_at이나 anonymized_at이 있는 행은 건드리지 않고, 상태와 정지 묶음을 한 UPDATE로 바꾸어야 합니다.
해설 보기
기준 시각과 회원 ID를 받아, SUSPENDED이고 삭제와 익명화가 없으며 종료 시각이 지난 경우에만 활성으로 바꿉니다. 조건을 만족하지 않으면 0행을 바꾸고 성공으로 끝내지 않고, SIGNAL로 드러냅니다.
DROP PROCEDURE IF EXISTS release_member_suspension;
DELIMITER //
CREATE PROCEDURE release_member_suspension(
IN p_member BIGINT UNSIGNED,
IN p_cutoff DATETIME(6)
)
BEGIN
START TRANSACTION;
UPDATE members
SET status = 'ACTIVE', suspended_until = NULL, suspension_reason = NULL
WHERE id = p_member AND status = 'SUSPENDED'
AND suspended_until <= p_cutoff
AND deleted_at IS NULL AND anonymized_at IS NULL;
IF ROW_COUNT() <> 1 THEN
ROLLBACK;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'suspension not releasable';
END IF;
COMMIT;
END//
DELIMITER ;
SET @suspended_member = (
SELECT id FROM members
WHERE deleted_at IS NULL AND anonymized_at IS NULL
ORDER BY id LIMIT 1
);
UPDATE members
SET status = 'SUSPENDED',
suspended_until = '2026-07-30 00:00:00',
suspension_reason = '운영 정책에 따른 기간제 계정 정지'
WHERE id = @suspended_member;
CALL release_member_suspension(@suspended_member, '2026-07-31 00:00:00');
SELECT status, suspended_until, suspension_reason
FROM members WHERE id = @suspended_member;
CALL release_member_suspension(@member_id, '2026-07-31 00:00:00');status | suspended_until | suspension_reason
ACTIVE | NULL | NULL
ERROR 1644 (45000): suspension not releasable16장 1편에서 재가입한 회원을 정지했다가 해제하면, 상태가 ACTIVE로 돌아오고 두 정지 열이 비워집니다. 탈퇴하고 익명화된 회원 1에 같은 호출을 하면 SUSPENDED가 아니어서 0행이 바뀌므로 오류로 끝나고, 삭제와 익명화의 정보는 그대로입니다.
2. 기준 시각과 같은 순간
세 요청이 모두 APPROVED이고, 대상은 탈퇴했으며 아직 익명화되지 않았습니다. 기준 시각은 2026-08-16 09:00:00입니다. A는 execute_after가 같은 09:00:00이고 보류가 NULL, B는 실행 시각이 같고 보류가 09:00:01, C는 실행 시각과 보류가 모두 09:00:00입니다. 다른 오류가 없다면 어느 요청이 실행될까요?
해설 보기
A와 C는 실행되고 B는 거부됩니다. 프로시저는 execute_after > cutoff이거나 hold > cutoff일 때만 거부하므로, 기준 시각과 같은 값은 통과합니다. B는 1초 뒤까지 보류되어 아직 통과하지 못합니다.
APPROVED만 확인해서는 이 차이를 잡을 수 없습니다. 같은 날짜라도 시와 분, 초까지 비교해야 하고, 그 기준 시각은 신뢰할 수 있는 스케줄러가 주어야 합니다.
다음 글 일별 통계 재계산에서는 파생 통계를 원래 데이터와 기준 시각에서 집계값으로 다시 계산하는 방법을 다룹니다.