쇼핑몰을 만들다 보면 어느 날 문득 깨닫는다. 가입은 되는데 탈퇴가 없다. 이번 글은 그 한 줄짜리 요구에서 시작해, 설계·구현·리뷰·병합을 거쳐 테스트 1221개가 전부 초록불이 되기까지의 기록이다.
AI 에이전트(Claude Code)와 함께 진행했고, 그 과정에서 사람이 개입한 지점과 에이전트가 스스로 잡아낸 결함도 함께 정리했다.
1. 요구는 딱 한 줄이었다
회원탈퇴 없음. 탈퇴시 탈퇴회원 테이블로 이동시킨다. 개인정보는 일정기간후에 삭제
화면 명세도, 정책 문서도 없었다. 그런데 이 한 줄에는 꽤 많은 결정이 숨어 있다.
- 탈퇴하면 어디로 가는가? (탈퇴회원 테이블)
- 개인정보는 언제 지우는가? (일정 기간 후)
- 그 사이 주문·리뷰·게시글은 어떻게 되는가?
- 재가입은 허용하는가?
- 진행 중인 주문이 있는 회원도 나갈 수 있는가?
"기능 하나 추가"처럼 보이지만 새 테이블, 새 흐름, 배치 잡까지 걸치는 작업이었다. 그래서 바로 코드를 짜지 않고 브레인스토밍 → 스펙 → 구현 계획 → 실행 순서로 갔다.
2. 설계의 갈림길: 지울 것인가, 가릴 것인가
코드를 살펴보니 탈퇴 관련 코드는 전혀 없었고, 관리자 화면의 회원 삭제는 users 행을 그냥 hard delete 하고 있었다. 더 큰 문제는 이 DB에 외래키 제약이 하나도 없다는 점이었다.
삭제 자체는 에러 없이 되지만, orders·product_reviews·posts·point_logs 같은 테이블의 user_id가 전부 고아가 된다. 그래서 설계의 핵심은 이 한 가지였다.
users 행을 지우지 않고 마스킹(tombstone)으로 남긴다.
- 개인정보 원본은 신규
withdrawn_users테이블에 스냅샷으로 이관한다. users의 이메일·아이디·닉네임·전화번호 등은 마스킹하거나 NULL로 비우고withdrawn_at을 기록한다.- 주문·리뷰·게시글은 건드리지 않는다. 마스킹된
users행이 참조를 지탱한다. - 보관 기간이 지나면 배치가
withdrawn_users의 개인정보 컬럼만 파기한다. 탈퇴 사유·등급·주문 수 같은 통계용 메타는 남긴다.
이렇게 하면 "개인정보는 일정 기간 후 삭제"라는 요구와 "과거 주문 데이터의 정합성 유지"가 동시에 성립한다.
마스킹이 재로그인을 막아준다
기존 로그인 로직이 is_active=1인 회원만 조회하고, 소셜 로그인은 social_id로 찾는다. 그래서 마스킹만 해도 재로그인 차단이 추가 코드 없이 자동으로 성립했다.
설계하다 발견한 함정들
- 이메일은 UNIQUE, 소셜은
(social_provider, social_id)복합 UNIQUE가 걸려 있었다. 마스킹할 때 이 컬럼들을 반드시 비워야 같은 이메일로 재가입했을 때 충돌하지 않는다. is_active=0은 이미 이메일 미인증 계정이 쓰고 있었다. 탈퇴 판별에 재사용할 수 없어서users.withdrawn_at컬럼을 따로 추가했다.- 쿠폰 소멸은 행 삭제가 아니라
status='expired'UPDATE로 했다.(user_id, coupon_id)UNIQUE 제약이 있어 삭제하면 재발급 이력이 깨진다. - 파기 배치는
0 4 * * *로 잡았다. 등급 승급 배치(0 3 * * *)와 겹치지 않게 하려는 것이다.
보관 기간은 설정값으로
개인정보를 얼마나 보관할지는 법적·운영적 판단이 필요한 문제라, 코드에 박지 않고 settings의 withdrawal_retention_days(기본 30일)로 뺐다. 정책이 바뀌어도 배포 없이 조정할 수 있다. 상법·전자상거래법상 거래 기록 보존 의무 같은 것은 서비스마다 다르니, 실제 운영 전에는 반드시 법무 검토로 기간을 확정해야 한다.
3. 탈퇴를 막아야 하는 경우
아무나 언제든 나갈 수 있으면 안 된다. 차단 조건은 세 가지로 잡았다.
- 관리자 계정
- 진행 중인 주문(결제대기·결제완료·배송준비·배송중 등)
- 반품·교환·환불이 진행 중인 건
탈퇴 처리는 트랜잭션 안에서 이 조건을 다시 한 번 검사한 뒤(검사와 실행 사이에 상태가 바뀌는 TOCTOU 방지) 스냅샷 INSERT → 마스킹 → 부수 데이터 정리(장바구니·위시리스트·배송지 삭제, 미사용 쿠폰 소멸, 포인트 소멸 기록) 순서로 진행한다.
4. 구현 계획서를 쓰다가 찾은 구멍
스펙을 승인받고 9개 태스크짜리 구현 계획을 썼다. 계획에 실제 코드를 넣다 보니 스펙에 없던 문제가 세 가지 나왔다.
- 관리자 회원 목록에 탈퇴회원이 그대로 보인다. 목록이
users전체를 읽으니 tombstone이 일반 회원처럼 나온다. 이 저장소엔 컨트롤러 HTTP 테스트 패턴이 없어서, 필터를 컨트롤러에 인라인으로 두면 검증할 방법이 없었다. 그래서UserModel::activeBuilder()로 추출해 모델 메서드를 테스트하도록 바꿨다. - 기존 테스트가 깨진다. 소셜 계정에는 탭바가 없어야 한다고 단언하는 테스트가 있었는데, 탈퇴 탭이 항상 생기면 전제가 사라진다. 테스트를 지우지 않고 새 동작에 맞게 고치는 단계를 계획에 넣었다.
- 배치 테스트의 캐시 함정. 설정값이 1시간 캐시되기 때문에, 설정을 바꾸고 캐시를 안 지우면 커맨드가 옛 값을 읽어 간헐적으로 실패한다.
계획서를 자체 점검하면서 insertOrder() 테스트 헬퍼가 orders의 NOT NULL 컬럼 4개를 빠뜨린 것도 잡았다. 디스패치 전에 발견하지 못했다면 Task 3·4의 테스트가 전부 INSERT 단계에서 실패했을 것이다.
5. 서브에이전트로 9개 태스크 실행
실행은 태스크마다 새 에이전트를 띄우고, 그 사이에 구현 → 리뷰 → 수정 → 재리뷰를 돌리는 방식(subagent-driven development)으로 했다. 컨텍스트가 깨끗하게 유지되고 단계마다 검증이 들어간다.
| # | 내용 | 테스트 수 |
|---|---|---|
| 1 | 마이그레이션 3건 | 1170 (베이스라인) |
| 3 | canWithdraw() 차단 판정 |
1175 |
| 4 | withdraw() 스냅샷·마스킹·정리 |
1187 |
| 5 | purgeExpired() 파기 + 재가입 |
1192 |
| 6 | 마이페이지 탈퇴 탭 | 1195 |
| 7 | users:purge-withdrawn 배치 |
1197 |
| 8 | 관리자 탈퇴회원 목록 | 1199 |
| 최종 | 전체 리뷰 수정 웨이브 | 1203 |
리뷰가 실제로 잡은 것들
- 동시 탈퇴 요청의 레이스 컨디션. Task 4 리뷰에서, 같은 회원이 동시에 탈퇴를 두 번 요청하면 행 잠금이 없어 멱등성이 깨질 수 있다는 지적이 나왔다. 이 저장소에 이미
FOR UPDATE+ 조건부 UPDATE 선례(재고 차감)가 있어 같은 패턴으로 고쳤고, 잠금을 실제로 검증하는 결정적 테스트도 추가했다. - 계획 자체의 결함. Task 6에서, 탭을 여는 조건은 바꿨는데 짝이 되는
else는 그대로 둬서 탈퇴 탭에도 비밀번호 카드가 같이 렌더링됐다. 구현자의 잘못이 아니라 내가 쓴 계획의 결함이어서, 판정을 기록하고 수정 라운드를 돌렸다. - 엑셀 내보내기의 tombstone 노출. Task 8에서
index()는 고쳤는데export()가 여전히 전체를 읽고 있었다. 같은 클래스의 결함이라 한 줄 수정으로 처리했다. - 최종 whole-branch 리뷰의 5건. 태스크 단위 리뷰로는 안 보이는, 태스크 경계를 넘는 결함이었다.
/admin/schedule의 잡 매핑 누락, 설정 화면에서 도달 불가능한 항목, 관리자 화면 세 곳의 tombstone 노출, 강제 탈퇴 시 세션이 무효화되지 않는 문제, 관리자 계정 정리 경로 소실. 전부 직접 코드를 열어 실제 결함임을 확인한 뒤 수정했다.
태스크별 리뷰만 믿었다면 놓쳤을 문제들이다. 마지막에 전체를 한 번 더 보는 리뷰의 값어치가 여기서 나왔다.
6. 병합 직전에 만난 의외의 실패
push 직전 pre-push 훅에서 내가 건드리지도 않은 기존 테스트(PG 환불, 상품 Q&A) 3건이 실패했다.
원인을 따라가 보니 코드 문제가 아니었다. 세션 내내 에이전트들이 composer test를 반복 실행하면서 테스트 DB의 payments 테이블에 453개 행이 누적돼 있었다. 개별 오염 행을 찾는 것보다 DB를 리셋하는 게 맞다고 판단했다.
그런데 migrate:refresh가 무관한 기존 마이그레이션의 down() 버그에 걸려 제대로 초기화되지 않았다. 결국 테스트 DB를 드롭하고 재생성해 forward 마이그레이션만 태웠다. 40개 마이그레이션이 깨끗하게 적용됐고, 1203개 테스트가 전부 통과했다. 코드 변경은 없었다.
교훈: 테스트가 간헐적으로, 내가 안 건드린 곳에서 실패하면 코드보다 테스트 환경 상태를 먼저 의심하자.
7. 브랜치 베이스가 틀려 있었다
이 저장소의 규칙은 feature/* → dev → main이다. 그런데 작업 브랜치가 실제로는 main에서 분기돼 있었다. 게다가 dev와 main은 서로의 조상이 아니라 각자 독립적으로 진행 중이었다.
잘못된 베이스로 머지하면 되돌리기 어렵기 때문에 임의로 진행하지 않고 먼저 물었고, 확인을 받은 뒤 main 대상으로 PR을 올렸다. 머지 후에는 dev에 이 기능이 없다는 점을 다시 짚었고, 이어서 main을 dev로 병합했다.
dev에만 있던 3개 커밋은 테마·로그인 수정으로 우리 작업과 겹치는 파일이 없었다. 충돌 없이 병합됐고, 병합 결과를 검증했다.
- 1221 tests 전부 통과 (dev의 기존 테마 테스트 18개 + 이번 기능 테스트가 합쳐진 숫자)
- 정적 분석(PHPStan)·코드 스타일(CS Fixer) 클린
pre-push 훅이 전체 테스트를 다시 돌리느라 기본 타임아웃을 한 번 넘겼지만, 타임아웃을 늘려 재시도해 통과했고 push까지 끝났다. 덕분에 dev와 main이 같은 이력을 공유하게 됐고, 다음 배포 PR부터는 정상 흐름으로 돌아간다.
마치며
숫자로 정리하면 이렇다.
- 요구: 한 줄
- 산출물: 설계 스펙 1, 구현 계획 1(9개 태스크), 24개 커밋
- 테스트: 1170 → 1203 → (dev 병합 후) 1221
개인적으로 남은 생각은 세 가지다.
- "삭제"는 생각보다 어렵다. 외래키 없는 DB에서 행을 지우면 조용히 고아가 쌓인다. 지우기 전에 누가 이 행을 참조하는지부터 확인해야 한다.
- 개인정보 보관기간은 코드가 아니라 설정이어야 한다. 정책은 바뀌고, 법적 기준은 서비스마다 다르다.
- 리뷰는 두 겹이어야 한다. 태스크 단위 리뷰는 국소적인 결함을, 마지막 전체 리뷰는 경계를 넘는 결함을 잡는다. 이번에는 후자에서 다섯 건이 나왔다.
탈퇴 기능은 사용자가 가장 마지막에 만나는 화면이지만, 서비스가 데이터를 얼마나 책임감 있게 다루는지가 가장 잘 드러나는 곳이기도 하다.
댓글 0개