소셜 계정 연결 기능을 붙였더니, 탈퇴 회원이 유효한 토큰을 다시 받았습니다
소셜 로그인을 붙이는 작업은 보통 화면 하나와 API 두어 개로 끝난다고 생각합니다. 이번 작업은 그렇지 않았습니다.
급식서비스에 카카오·애플 로그인을 붙이면서 본인인증(CI/DI)을 함께 묶었고, 이미 아이디로 가입한 회원이 나중에 소셜 계정을 연결하는 흐름까지 신설했습니다. 하루 여덟 시간 동안 새 API 가 여러 개 한꺼번에 생겼습니다.
기능은 다 돌았습니다. 실기기에서 가입도 되고 로그인도 되고 연결도 됐습니다. 그런데 코드리뷰를 돌리자 두 가지가 걸렸습니다. 탈퇴한 회원이 유효한 토큰을 다시 받을 수 있었고, 정지된 회원도 같았습니다.
로그인에는 검문소가 있었고, 연결에는 없었습니다
기존 로그인 API 에는 상태 가드가 있었습니다. 회원을 조회한 뒤 탈퇴나 정지 상태면 토큰을 내주지 않고 막습니다. 문제는 이번에 새로 만든 경로였습니다.
소셜 계정 연결 API 는 본인인증 정보로 회원을 찾아 소셜 계정을 이어 붙이고 토큰을 발급합니다. 그 회원 조회가 상태를 보지 않았습니다.
탈퇴한 회원의 식별 해시는 정리 배치가 돌기 전까지 테이블에 남아 있습니다. 그 구간에 탈퇴자가 소셜 연결을 시도하면 본인인증은 통과하고, 상태를 안 보는 조회가 계정을 찾아주고, 토큰이 나갑니다. 탈퇴한 계정이 되살아나는 셈입니다.
정지 회원은 경로가 하나 더 있었습니다. 이미 연결된 소셜 계정으로 다시 들어오는 재로그인 경로입니다. 빠른 경로라 조회 한 번으로 토큰을 내주게 만들어 뒀는데, 그 조회에도 상태 검사가 없었습니다. 로그인 화면에서 막히는 계정이 다른 문으로는 들어왔습니다.

가드는 API 의 속성이 아니라 토큰의 불변식입니다
두 결함의 원인은 같습니다. 상태 검사를 로그인 API 의 로직으로 생각한 것입니다.
로그인 API 를 고칠 때는 자연스럽게 같이 보지만, 새 API 를 만들 때는 시야에 들어오지 않습니다. 신설 경로는 해피패스부터 만들기 때문에 가드가 비어 있는 채로 태어납니다.
관점을 바꾸면 정리가 됩니다. 검사해야 할 대상은 API 가 아니라 유효 토큰이라는 자원입니다. 토큰이 나가는 경로를 전부 나열하고, 각 경로가 같은 검문을 지나는지 대조하는 일이 됩니다. 저는 이 대조를 경로 패리티 점검이라고 부릅니다.
토큰이 나가는 경로 | 점검 전 | 점검 후 |
|---|---|---|
아이디·비밀번호 로그인 | 상태 가드 있음 | 그대로 |
소셜 로그인 신규 가입 | 상태 가드 있음 | 그대로 |
기존 계정에 소셜 연결 | 없음 | 가드 추가 |
소셜 재로그인 빠른 경로 | 없음 | 가드 추가 |
표로 늘어놓고 나서야 빈칸이 보였습니다. 각 API 를 따로 볼 때는 넷 다 정상으로 보였습니다. 같은 열에 세워 놓으면 어느 경로에 검문이 없는지가 한눈에 드러납니다.
같은 종류의 사고를 전에도 본 적이 있습니다. 인증 필터는 걸려 있는데 새로 추가한 API 주소만 목록에서 빠져 무인증으로 열려 있던 경우입니다. 그때는 인증 자체가 빠졌고 이번에는 상태 검사가 빠졌습니다. 빠진 자리가 신설 경로였다는 점은 같습니다.
판정 규칙은 복제하지 말고 함수를 다시 씁니다
이번 작업에는 판정이 많이 들어갔습니다. 본인인증을 마친 사람이 신규인지 기존 회원인지, 미성년이라 가입을 막아야 하는지, 탈퇴 후 재가입 제한에 걸리는지 판단해야 했습니다.
흐름이 새로 생겼으니 판정도 새로 쓰고 싶어집니다. 그렇게 하지 않았습니다. 사전체크 API 는 분기 흐름만 새로 만들고, 연령 검사와 CI 검사, 연결 가능한 회원을 찾는 조회는 기존 함수를 그대로 호출했습니다.
판정이 두 곳에 살면 언젠가 두 규칙이 됩니다. 한쪽만 고친 날부터 서비스는 같은 사용자에게 다른 답을 내놓습니다. 규칙의 원천을 하나로 두는 편이 흐름을 몇 개 더 만드는 것보다 안전합니다.
재가입 차단 판정이 특히 그렇습니다. 탈퇴 회원의 식별 정보를 어디까지 지우고 어디까지 남길지가 그 판정의 전제인데, 이 전제는 앞선 작업에서 이미 한 번 정리한 것이었습니다.

불가 판정은 약관을 읽기 전에 내립니다
같은 사전체크 API 는 UX 쪽에서도 한 번 더 값을 했습니다. 본인인증을 마친 직후에 가입 가능 여부를 판정하도록 순서를 잡아, 가입할 수 없는 사용자가 약관을 전부 읽고 동의한 뒤에 거절당하는 흐름을 없앴습니다.
기존 회원의 CI 가 일치하는 경우에는 화면을 새로 띄우지 않고, 그 자리에서 연결 확인 다이얼로그만 띄웁니다. 판정 시점을 앞으로 당기는 일은 보안이 아니라 UX 설계지만, 같은 API 하나로 둘을 함께 얻었습니다.
리뷰가 잡는 결함과 실기기가 잡는 결함은 다릅니다
이번 두 결함은 실기기 테스트로는 나오지 않습니다. 테스트에 쓰는 계정이 정상 계정이기 때문입니다. 탈퇴 계정을 만들어 두고 정리 배치가 돌기 전에 소셜 연결을 시도해 보는 시나리오는 손으로 잘 하지 않습니다.
코드리뷰는 다릅니다. 회원을 찾는 조회 한 줄에 상태 조건이 없다는 사실은 코드를 읽으면 바로 보입니다. 두 결함 모두 리뷰에서 나왔습니다.
반대로 실기기에서만 나온 것도 있었습니다. 앱에서 소셜 SDK 를 호출하는 코드가 try 블록 안에서 await 없이 Future 를 반환하고 있었습니다.
예외가 catch 를 건너뛰고 상위로 터지면서, 화면은 아무 반응 없이 로딩만 남았습니다. return await 로 바꾸자 잡혔습니다. 소셜 연동처럼 실패가 흔한 코드일수록 이 한 단어의 유무가 큽니다.
그래서 리뷰 질문을 바꿨습니다. 이 코드가 동작하는가 대신, 이 경로로 토큰을 받는 자는 누구인가를 묻습니다. 탈퇴자와 정지자, 미성년자를 주어로 세워 놓고 흐름을 따라가면 해피패스만 보던 시선으로는 안 보이던 자리가 드러납니다.

신설 경로를 열 때 같이 확인하는 것들
이 경로의 끝에서 유효한 토큰이나 세션이 나가는가
나간다면 기존 로그인 경로와 같은 상태 검사를 지나는가
회원 조회가 상태 조건 없이 식별자만으로 찾고 있지는 않은가
가입·연결 판정을 새로 구현했는가, 기존 함수를 호출했는가
탈퇴자와 정지자를 주어로 이 흐름을 한 번 읽어봤는가
고친 뒤 남은 것
고친 결과는 단순합니다. 탈퇴하거나 정지된 계정은 어느 경로로도 유효한 토큰을 받지 못합니다. 가입 판정 규칙은 함수 한 벌에 남았습니다.
이번에는 경로가 넷이라 같은 가드를 각 경로에 적용하는 것으로 충분했습니다. 경로가 더 늘면 복사는 답이 아닙니다. 토큰을 발급하는 공통 함수 한 곳을 반드시 지나게 만드는 편이 다음 단계입니다.
상태별 계정으로 모든 발급 경로를 도는 자동 테스트도 아직 없습니다. 지금은 리뷰 품질에 기대고 있는 상태입니다.
외부 HTTP 호출이 트랜잭션 안에서 실행되는 것과 식별 해시의 유니크 제약이 없는 것도 알고 미뤘습니다. 결함 목록과 부채 목록은 따로 적어 두었습니다.
새 문을 내는 작업에는 시간을 넉넉히 씁니다. 그 문 옆에 검문소를 세우는 데는 잘 쓰지 않습니다. 신설 API 목록을 열어 토큰이 나가는 경로에 표시를 하는 데는 몇 분이면 됩니다.