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

안동민 개발노트

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

계정 상태와 익명화

접근 상태와 삭제 수명, 개인정보 파기 단계를 분리하고 보존 만료 배치가 승인된 행만 익명화하도록 설계합니다.

로그인을 막는 정지와 사용자가 떠난 탈퇴는 화면에서 비슷해 보입니다. 그러나 정지는 기간이 끝나면 계정을 다시 활성화하고 게시글을 그대로 사용합니다. 탈퇴는 재가입, 보존 기한, 개인정보 파기 절차를 시작합니다. 둘을 상태='INACTIVE' 하나에 넣으면 후속 작업이 어떤 이유로 비활성인지 추측하게 됩니다.

익명화도 탈퇴와 같은 순간이 아닙니다. 분쟁 처리나 법정 보존 동안 원문 식별자를 제한적으로 유지한 뒤 승인된 기준 시각을 지나면 되돌릴 수 없는 토큰으로 바꿀 수 있습니다. 이 문서는 접근 권한, 논리 삭제, PII(Personally Identifiable Information, 개인 식별 정보) 파기를 세 개의 독립 신호로 모델링합니다.

ch16-1의 members에는 deleted_at 묶음과 접근 상태 status가 이미 있습니다. 여기에 정지 메타데이터와 anonymized_at을 더합니다. statusACTIVESUSPENDED만 표현하며 삭제 여부는 계속 deleted_at으로 판단합니다.

member_erasure_request는 보존 만료 시각과 승인자를 기록합니다. 익명화 작업자는 요청 상태가 APPROVED이고 execute_after가 기준 시각 이하인 행만 작은 배치로 처리합니다. 실제 이메일은 복원 불가능한 가상 주소로 교체하고 name은 고정 표시명으로 바꿉니다.


단일 상태값의 모순

운영 배치가 INACTIVE 계정을 전부 익명화하면 일주일 정지된 사용자도 원문을 잃습니다. 반대로 INACTIVE를 영구 보존하면 탈퇴자의 PII가 기한 없이 남습니다. 상태 이름만으로는 전이 원인과 실행 시점을 복구할 수 없습니다.

서로 다른 수명을 한 enum에 접어 넣은 예
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,'suspended@example.com','INACTIVE'),
 (2,'withdrawn@example.com','INACTIVE');
UPDATE member_lifecycle_bad
SET email=CONCAT('anonymous+',id,'@invalid.local')
WHERE status='INACTIVE';
SELECT member_id,email FROM member_lifecycle_bad ORDER BY member_id;

정지 계정과 탈퇴 계정이 동일 조건식에 걸려 모두 익명화됩니다.

오류 실행 결과
inactive rows selected=2
suspended member anonymized=yes
withdrawn member anonymized=yes
retention cutoff checked=no
approval evidence checked=no

상태 열거 값이 많아지는 것이 문제의 본질은 아닙니다. 서로 다른 질문을 같은 축에 넣은 것이 문제입니다. “로그인 가능한가?”, “사용자가 탈퇴했는가?”, “PII가 파기됐는가?”는 각각 독립적으로 답해야 합니다.

SUSPENDED에는 suspended_until과 사유가 필요합니다. 탈퇴에는 deleted_at 묶음이 있습니다. 익명화에는 anonymized_at과 승인 요청이 필요합니다. 전이마다 필수 메타데이터를 검사로 묶으면 활성인데 정지 사유가 남는 불일치도 잡을 수 있습니다.

보존 기준 시각은 현재 시각에서 임의로 빼지 않기

정책이 바뀌거나 법적 보류가 걸리면 deleted_at+30일 계산만으로는 부족합니다. 요청 행에 execute_after를 확정하고 hold_until을 별도 보존합니다. 작업자는 승인 당시의 정책 결과를 읽습니다.


접근 상태와 데이터 수명

status는 인증·API 접근을 결정합니다. deleted_at은 서비스 수명 종료를 뜻합니다. anonymized_at은 식별 정보가 되돌릴 수 없게 변환된 시점입니다. 읽기 주체는 목적에 맞는 조건을 조합하며 INACTIVE라는 합성 값은 만들지 않습니다.

익명화는 UPDATE지만 복구 가능한 소프트 삭제와 달리 되돌릴 수 없는 전이입니다. 프로시저는 대상 삭제 행을 잠그고 APPROVED 요청, 기준 시각, 법적 보류 부재, 미익명 상태를 모두 확인한 뒤 PII를 바꿉니다.

사실 보존과 식별자 파기를 양립시키기

postsauthor_id FK는 그대로 유지합니다. 분석은 익명 회원 키로 기간과 카테고리을 집계할 수 있지만 이메일과 name은 사라집니다. 외부 내보내기에서 member_id 자체가 재식별 위험이면 별도 대리 분석 ID와 접근 정책이 필요합니다.

MySQL 8.4 메모: SHA2(이메일)만 저장하면 사전 공격으로 복원될 수 있습니다. 이 실습은 원문을 가상 값으로 교체하고, 일치가 필요하다면 별도 비밀키 HMAC 서비스를 사용한다고 가정합니다.


승인된 익명화 처리

마이그레이션은 정지 묶음과 anonymized_at을 추가합니다. ck_members_status를 접근 상태만 담도록 교체하는 작업은 ch16-1의 소프트 삭제 마이그레이션에서 이미 끝났으므로 여기서 반복하지 않습니다. 요청은 회원당 한 건의 열린 파기 절차를 갖고 APPROVED, CANCELLED, EXECUTED를 기록합니다. 작업자 프로시저는 한 요청을 처리한 뒤 요청 상태와 회원을 함께 커밋합니다.

lifecycle 확장과 보존 만료 익명화 procedure
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 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 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');

승인과 보존 기준 시각을 만족한 요청만 EXECUTED가 되고 게시글 FK는 그대로 남습니다.

개선 실행 결과
erasure status=EXECUTED
email=anonymous+<member_id>@invalid.local
name=탈퇴한 회원
historical post orphans=0
re-execution=SIGNAL 45000

작업자는 member_id 범위를 직접 받지 않고 승인 요청 ID를 받습니다. 이렇게 해야 대량 UPDATE가 정책 판단을 건너뛰지 않습니다. 배치 스케줄러는 처리 대상 인덱스에서 작은 ID 묶음을 읽고 프로시저를 건별 호출하거나 동일 검사를 집합 기반 트랜잭션으로 옮깁니다.

legal_hold_until이 기준 시각보다 크면 처리하지 않습니다. 보류 종료 뒤 스케줄러가 다시 선택할 수 있습니다. CANCELLED 요청은 open_slot이 NULL이 되어 새 요청을 허용하지만 과거 취소 행은 보존됩니다.

계정 정지 해제 작업은 익명화 작업자와 완전히 분리하기

suspended_until이 지난 회원은 status를 활성으로 되돌리고 묶음을 비웁니다. deleted_at이 있는 행은 로그인 대상이 아니므로 해제 작업에서 제외합니다. 이 조건식이 수명주기 축을 분리한 효과입니다.


정지·보존·미승인 요청 검증

세 종류의 반례를 별도 요청으로 만듭니다. 활성 정지 회원은 삭제 조건에서, 보류 행은 날짜 조건에서, 대기 행은 상태 조건에서 각각 거절되어야 합니다.

파기 eligibility와 lifecycle drift 대사
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 r JOIN members 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 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 s LEFT JOIN members l ON l.id=s.author_id
WHERE l.id IS NULL;

첫 세 쿼리와 고아 검사는 0행입니다. 네 번째 처리 대상 쿼리에서도 방금 처리한 요청은 EXECUTED 상태라서 제외됩니다. 보류 예제 데이터를 승인 상태로 두면 기준 시각 이후에도 처리 대상 결과에 나타나지 않습니다.

PII 스캔은 이메일과 name 외에 감사 JSON, 내보내기 캐시, 검색 인덱스를 포함해야 합니다. DB 행 하나를 바꿨다는 이유로 전체 파기가 끝났다고 선언하지 않습니다.

탈퇴 뒤 상태 값의 의미

삭제 행의 status가 활성으로 남아도 로그인 쿼리가 deleted_at IS NULL을 함께 요구하면 접근은 차단됩니다. 상태를 DELETED로 바꾸지 않은 이유는 접근 축과 수명 축을 섞지 않기 위해서입니다. 관리 화면은 두 값을 조합해 “탈퇴” 표시명을 만듭니다.

복구 가능한 기간과 되돌릴 수 없는 단계

anonymized_at 이전에는 ch16-1 복구 정책을 적용할 수 있습니다. 파기 뒤에는 원문 이메일을 DB에서 되살릴 수 없어야 합니다. 지원 담당자가 임의로 가상 주소를 바꾸지 못하도록 프로시저와 권한을 분리합니다.

파기 완료 증거

erasure_id, 대상 저장소 목록, 실행 버전, 각 저장소 완료 시각을 별도 조정 로그에 남깁니다. 일부 저장소 오류는 EXECUTED 대신 PARTIAL 상태로 관리할 수도 있습니다. 이 교재의 단일 DB 프로시저는 트랜잭션 범위 안의 최소 모델입니다.


수명 상태 설계 기준

판단 축확인할 질문
접근일시 정지와 영구 탈퇴가 다른 해제 절차를 가지는가?
보존탈퇴 뒤 PII를 유지해야 하는 확정 기간이 있는가?
승인파기 실행 전에 사람 또는 정책 엔진 승인이 필요한가?
보류소송·분쟁이 자동 기준 시각보다 우선할 수 있는가?
범위DB 밖 검색·백업·내보내기의 파기 추적이 준비됐는가?

정지와 탈퇴의 후속 작업이 다르면 별도 축이 필요합니다. 익명화는 원복할 수 없는 만큼 단순 상태 UPDATE보다 승인 요청을 거치는 편이 안전합니다. 시스템마다 법적 요구가 다르므로 기간 숫자를 코드에 숨기지 않습니다.


연습 문제

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');

해제된 행은 활성이며 두 정지 열이 NULL입니다. 탈퇴 예제 데이터에 같은 호출을 실행하면 정지 해제 불가 오류로 끝나고 삭제 메타데이터는 변하지 않습니다.


핵심 정리

  • 접근 정지, 서비스 탈퇴, 개인정보 익명화는 서로 다른 질문과 전이를 가집니다.
  • APPROVED 파기 요청은 보존 기준 시각과 법적 보류를 DB 판단에 포함합니다.
  • 사실 FK를 유지하면서 식별자 속성만 가상 값으로 바꿀 수 있습니다.
  • 되돌릴 수 없는 전이는 복원 가능한 소프트 삭제 이후의 별도 단계입니다.

다음 문서에서는 파생 통계를 원본과 기준 시각에서 절대값으로 다시 만드는 방법을 다룹니다.