탈퇴시킨 회원이 다시 로그인됐다고? 본인인증 CI 연동에서 빠져 있던 가드 한 줄
탈퇴 처리가 끝난 계정으로 다시 로그인이 됐습니다. 관리자 화면에서 상태는 탈퇴로 바뀌어 있었고 회원 목록에서도 빠져 있었습니다. 그런데 앱에서 소셜 로그인 버튼을 누르니 서비스가 그대로 열렸습니다.
한 급식서비스에 본인인증 기반 통합회원 가입과 회원탈퇴를 붙이는 작업이었습니다. 회원의 원천은 우리 DB가 아니라 외부에 있는 그룹 통합 멤버십 시스템이었고, 로컬 회원 테이블은 그 위에 얹힌 미러였습니다. 가입도 탈퇴도 두 곳을 함께 다뤄야 하는 구조입니다.
이 작업에서 그은 경계는 셋입니다. 민감 식별값인 CI를 어디까지 흘릴 것인가, 외부 시스템과 로컬 DB 중 무엇을 진실로 볼 것인가, 탈퇴라는 상태를 어느 지점에서 강제할 것인가입니다. 위의 버그는 세 번째 경계가 비어 있어서 생긴 일입니다.
CI를 클라이언트가 들고 다닐 것인가
본인인증을 통과하면 연계정보, 흔히 CI라고 부르는 값이 나옵니다. 이 값은 사람 한 명을 여러 서비스 사이에서 같은 사람으로 이어 붙이는 식별자입니다. 한 번 밖으로 새면 되돌릴 방법이 없습니다. 가입 흐름을 설계하면서 가장 먼저 걸린 질문이 이 값을 누가 들고 있느냐였습니다.
가장 쉬운 방식은 본인인증 결과에서 CI를 받아 클라이언트가 보관했다가 가입 API에 같이 보내는 것입니다. 구현이 단순하고 서버는 상태를 안 들어도 됩니다. 대신 CI가 단말 메모리와 요청 로그, 네트워크 구간, 경우에 따라 크래시 리포트까지 흩어집니다.
구분 | 클라이언트가 CI 보관·전송 | 서버가 세션에서 재조회 |
|---|---|---|
구현 | 단순. 서버를 무상태로 유지 | 세션 저장소와 만료 관리가 추가 |
CI 노출 범위 | 단말·로그·네트워크 구간 | 백엔드 내부 |
값 신뢰 | 클라이언트가 보낸 값을 믿어야 함 | 서버가 인증 세션에서 직접 조회 |
사고 시 | 유출되면 회수 불가 | 노출 지점 자체가 없음 |
택한 쪽은 서버 재조회입니다. 클라이언트는 본인인증 토큰만 들고 오고, 서버가 그 토큰으로 인증 세션에서 CI를 다시 꺼냅니다. 프런트 코드에도 요청 본문에도 CI가 없습니다. 이 값은 백엔드 경계를 한 번도 넘지 않습니다.
여기에 하나를 더 나눴습니다. 가입 가능 여부를 판정하는 API와 실제로 회원을 만드는 API를 분리했습니다. 판정 API는 CI를 기준으로 신규인지, 이미 회원인지, 재가입이 막혀 있는지를 셋 중 하나로 돌려줍니다. 앱은 그 결과로 14세 미만 안내나 기존 회원 안내로 화면을 가르고, 가입 API는 판정을 통과한 흐름만 받습니다.

외부 시스템과 로컬 DB 중 무엇이 진실인가
회원의 원천이 외부에 있으면 로컬에만 회원을 만드는 선택은 처음부터 무너집니다. 두 곳의 회원 목록이 갈리고, 어느 쪽이 맞는지 판정할 기준이 사라집니다. 그래서 가입과 탈퇴를 모두 외부에 반영하고 로컬은 미러로 유지하는 이중화를 택했습니다.
가입은 세 단계로 묶었습니다. 외부 멤버십 시스템에 가입을 요청하고, 로컬 회원 행을 만들고, 자동 로그인 토큰을 내려줍니다. 약관은 이용·개인정보·서비스·위치처럼 항목별 코드를 매핑해 함께 보냈고, 마케팅 수신 여부만 사용자 선택을 그대로 실었습니다.
손이 더 간 쪽은 탈퇴입니다. 보유 포인트를 전 매장 기준으로 소멸시키면서 만료 로그를 남기고, 외부 시스템에 탈퇴를 반영하고, 로컬 상태를 탈퇴로 바꾸고, 비밀번호와 이메일을 비우고, 탈퇴 사유와 시각을 기록합니다. 한 번의 요청에 다섯 가지 일이 붙어 있습니다.
탈퇴 처리에서 늘 부딪히는 것은 지우라는 요구와 남기라는 요구가 같이 온다는 점입니다. 개인정보는 지워야 하는데, 재가입 차단은 판정할 수 있어야 합니다. 이 충돌을 어디서 갈랐는지는 따로 정리해 둔 글이 있습니다.

탈퇴 가드는 한 곳만 막으면 뚫립니다
처음의 버그로 돌아옵니다. 로컬 회원 상태는 탈퇴로 정확히 바뀌어 있었습니다. 문제는 로그인 경로가 하나가 아니었다는 점입니다.
일반 로그인(아이디·비밀번호) — 탈퇴 상태 차단이 있었습니다
소셜 로그인 — 차단이 없었습니다
카카오 로그인 — 차단이 없었습니다
상태를 바꾸는 일과 그 상태를 강제하는 일은 서로 다른 작업입니다. 데이터에 탈퇴라고 적는 것만으로는 아무것도 막히지 않습니다. 그 값을 읽어 거절하는 지점이 있어야 상태가 규칙이 됩니다. 진입점이 셋인데 한 곳에만 있으면 나머지 둘은 그냥 열린 문입니다.
수정 자체는 단순했습니다. 세 경로 모두에 같은 차단을 넣었습니다. 확인은 실제 흐름으로 했습니다. 탈퇴한 계정으로 외부 시스템을 조회하니 탈퇴 30일 이내라는 응답이 왔고, 같은 정보로 재가입을 시도하니 차단으로 판정됐습니다.

남은 구멍은 부분 실패입니다
외부 시스템 호출과 로컬 갱신은 한 트랜잭션이 아닙니다. 외부 탈퇴가 실패하면 로컬만 탈퇴 상태가 되고, 반대 방향도 가능합니다. 지금은 실패를 로그로 남기고 사람이 확인하는 수준입니다. 재시도와 보상 처리는 다음 과제로 남아 있습니다.
세션 재조회에도 비용이 있습니다. 인증 세션을 둘 저장소와 만료 정책이 따라옵니다. 서버를 무상태로 유지하려는 시스템이라면 이 부분이 그대로 트레이드오프입니다.
재가입 차단 30일 같은 규칙의 주인이 외부라는 점도 남습니다. 외부 정책이 바뀌면 우리 분기와 안내 문구가 같이 흔들립니다. 그래서 기간을 우리 코드에 상수로 박아두는 대신, 외부 판정 결과를 그대로 따르도록 뒀습니다.
같은 자리에서 반복되는 세 가지
민감 식별값의 이동 범위를 최소화합니다. 클라이언트와는 토큰만 주고받고, 값 자체는 서버가 필요한 순간에 꺼내 씁니다.
상태 전이 가드는 모든 진입점에 넣습니다. 진입점 목록을 먼저 세고, 그 수만큼 확인합니다.
외부 원천과 미러를 함께 둘 때는 양방향을 다 설계합니다. 가입만 맞추면 유령 회원이 남습니다.
세 번째가 이번에 가장 비쌌습니다. 가입 흐름은 새로 만드느라 모든 경로를 훑었습니다. 반면 탈퇴는 기존에 있던 로그인 코드를 건드리는 일이라 시야에서 빠졌습니다. 새 기능이 기존 진입점의 조건을 바꾸는 경우가 이런 식으로 조용히 새는 자리입니다.
도입 전 점검 항목
본인인증 결과의 식별값이 클라이언트 코드나 서버 로그, URL 파라미터에 남는 구간이 있는지
회원 상태를 거절 조건으로 읽어야 하는 지점이 몇 개인지 세어봤는지. 로그인뿐 아니라 토큰 재발급, 비밀번호 찾기, 자동 로그인도 포함합니다
외부 시스템 호출이 실패했을 때 로컬을 어떻게 되돌릴지 정해져 있는지
탈퇴 시 지울 항목과 보존할 항목이 문서상 갈려 있는지
재가입 차단 기간 같은 규칙의 주인이 우리인지 외부인지
마무리
CI는 백엔드 안에만 머물렀고, 가입과 탈퇴는 외부 시스템과 로컬 양쪽에 같이 반영됐습니다. 탈퇴한 계정은 세 경로 어디로도 들어오지 못합니다. 세 경계 중 앞의 둘은 설계 단계에서 정한 것이고, 세 번째는 버그를 보고 나서야 메운 것입니다.
본인인증이나 통합회원처럼 외부와 엮이는 기능을 붙일 때는, 새로 만드는 흐름보다 기존 진입점 목록을 먼저 세어보는 편이 낫습니다. 새 상태값 하나가 늘면 그 값을 읽어야 하는 곳도 같이 늘어납니다.