가입 경로로 분기했더니 소셜 회원에게 없는 아이디를 보여주고 있었습니다
어떤 급식서비스의 회원 앱에 소셜 로그인을 붙이는 작업이었습니다. 기능을 다 만들고 화면을 하나씩 넘겨보다 계정 찾기에서 멈췄습니다. 소셜로만 가입한 회원에게 가입된 아이디를 찾았다는 안내를 띄우고, 아이디 자리에는 하이픈 하나를 찍고 있었습니다.
소셜 가입자는 아이디라는 값 자체가 없습니다. 없는 것을 찾았다고 말한 뒤 그 자리에 빈 값을 그려 넣은 화면이었습니다. 에러도 아니고 크래시도 아니라서 로그에 안 남고 모니터링에도 안 잡힙니다. 화면만 조용히 사실과 달랐습니다.
화면이 아니라 판단 기준이 틀려 있었습니다
계정 찾기는 회원의 가입 경로를 보고 분기하고 있었습니다. 일반 가입이면 아이디와 비밀번호 찾기를 보여주고, 그 외에는 소셜 안내를 보여주는 구조입니다. 가입 경로는 회원 행이 만들어질 때 한 번 찍히고 그 뒤로 바뀌지 않는 값입니다. 소셜을 연결해도 그대로고, 해제해도 그대로입니다.
운영 데이터를 열어보니 일반 가입으로 기록됐는데 아이디가 없는 회원이 2건 있었습니다. 건수는 작지만 성격이 나쁩니다. 이 두 명에게는 비밀번호 찾기 버튼이 계속 보이고, 눌러도 끝까지 갈 수 없습니다.
가입 경로는 기록이고, 로그인 수단은 상태입니다
어떻게 가입했는가는 기록입니다. 지금 무엇으로 로그인할 수 있는가는 상태입니다. 두 값은 가입 직후에 정확히 같습니다. 그래서 초기에는 어느 쪽을 봐도 결과가 같고, 코드는 대개 손에 먼저 잡히는 가입 경로를 집습니다.
둘이 갈라지는 지점은 연결과 해제입니다. 일반 가입 회원이 소셜을 연결하면 로그인 수단은 둘이 되는데 가입 경로는 여전히 하나를 가리킵니다. 소셜 회원이 하나를 더 붙여도, 반대로 전부 끊어도 그 필드는 움직이지 않습니다. 기록은 원래 안 움직이는 값이라 잘못이 없고, 그 값을 상태로 읽은 쪽이 틀렸습니다.

판단을 세는 쪽으로 바꿨습니다
기준을 가입 경로에서 실제 자격증명으로 옮겼습니다. 계정 찾기는 이제 두 가지만 봅니다. 마스킹된 아이디가 있는가, 연결된 소셜이 무엇이 남아 있는가. 아이디가 없으면 비밀번호 찾기 버튼을 화면에서 뺍니다.
소셜 연결 해지 가드도 같은 원칙으로 짰습니다. 해지 가능 여부를 아이디 유무와 해지한 뒤 남는 소셜 개수로 판단합니다. 아이디가 없고 남는 소셜이 0개면 해지를 막습니다. 새 소셜을 먼저 연결한 뒤에는 옛 소셜을 끊을 수 있습니다. 교체는 되고 계정 잠김은 안 되는 상태입니다.
같은 가드를 가입 경로로 짰다면 통과 여부가 회원이 실제로 가진 수단과 무관해집니다. 소셜을 전부 끊어도 가입 경로는 소셜을 가리키고, 그 값으로는 지금 남은 수단이 몇 개인지 알 수 없습니다.

배포는 해야 하는데 기능은 감춰야 했습니다
앱은 완성됐는데 백엔드 운영 환경에는 소셜 관련 API 가 아직 없는 상태였습니다. 브랜치를 본선에 합쳐 배포는 해야 하고, 기능은 보이면 안 되는 구간이었습니다.
선택지 | 확실한 것 | 걸리는 것 |
|---|---|---|
소셜 코드를 되돌리고 배포 | 운영에 아무 영향이 없다 | 재개할 때 다시 합쳐야 하고, 그동안 코드가 본선에서 검증되지 않는다 |
노출 플래그로 감추고 배포 | 코드가 본선에서 계속 컴파일·테스트된다 | 죽은 분기가 남고, 노출만 막으면 화면이 깨진다 |
재개가 예정돼 있어서 플래그를 골랐습니다. 노출 여부를 상수 하나로 두고 기본을 꺼짐으로 뒀습니다. 재개할 때는 한 글자만 바꾸면 되고, 그동안 소셜 코드는 본선에서 계속 컴파일되고 테스트를 탑니다.
노출만 끄는 것으로는 부족했습니다. 화면에 버튼이 안 보여도 데이터 조회는 그대로 나갑니다. 운영에 없는 엔드포인트를 부르면 404 가 돌아오고, 소셜과 무관한 정보까지 같이 그리던 화면이 통째로 깨집니다. 그래서 플래그가 꺼져 있으면 그 조회를 아예 부르지 않게 했습니다.
이렇게 갈 수 있었던 조건이 하나 있었습니다. 그 API 에서만 얻는 값이 연결된 소셜 목록 하나뿐이었고, 이름·이메일·가입 유형은 기존 조회에 이미 들어 있었습니다. 겹치는 값이 없었다면 화면을 통째로 나누는 쪽이 됐을 것입니다.
합치는 과정에서 하나를 손으로 살렸습니다. 화면 하나는 브랜치 쪽 구조를 채택했는데, 그 사이 본선에는 선택 약관 동의 행이 추가돼 있었습니다. 그대로 덮었으면 기능 하나가 조용히 사라질 자리였습니다. 병합 도구는 같은 줄을 양쪽에서 고친 것은 알려주지만, 한쪽에만 있는 것은 알려주지 않습니다.
이메일이 사실상 필수가 되면서 따라온 것들
소셜을 붙이면 이메일이 회원을 잇는 축이 됩니다. 필수화가 따라오고, 필수화에는 중복 방지가 따라옵니다. 형식 검증 규칙부터 한 곳으로 모아 단일 원천을 만들고, 기존 검증이 쓰던 패턴을 그대로 따랐습니다.
남은 문제는 중복 검사를 어디에 두느냐였습니다. 소셜 가입은 회원 생성 직전에 필수 여부와 중복을 검사합니다. 가입과 연결 두 경로가 같은 요청 객체를 공유하는데 연결 경로에는 이메일이 필요 없어서, 선언적 검증을 객체에 걸 수 없었습니다.
일반 가입은 외부 시스템 등록 전에 검사합니다. 순서가 반대면 외부에는 회원이 만들어졌는데 로컬 저장이 실패해 두 시스템이 갈립니다. 되돌리려면 외부 쪽 삭제를 다시 불러야 하고, 그 호출도 실패할 수 있습니다. 되돌릴 것이 없는 쪽에 검증을 먼저 둡니다.

이메일 변경은 자기 자신을 제외하고 중복을 봅니다. 같이 막은 것이 빈 값입니다. 빈 문자열을 보내면 이메일이 지워지는 구멍이 열려 있었습니다.
앱이 부르지 않는 구버전 엔드포인트 2곳에도 같은 검사를 넣었습니다. 앱이 안 부른다는 것과 아무도 못 부른다는 것은 다릅니다. 살아 있는 주소로 요청이 들어오면 인덱스 위반이 그대로 500 으로 나갑니다.
마지막 수단은 DB 에 둡니다
애플리케이션 검사만으로는 동시 요청을 막지 못합니다. 같은 이메일로 두 요청이 거의 동시에 들어오면 둘 다 검사를 통과하고 둘 다 저장으로 넘어갑니다. 그래서 유니크 제약을 같이 걸었습니다.
형태는 부분 유니크 인덱스입니다. 소문자로 정규화하고 NULL 은 대상에서 뺐습니다. 이유가 둘입니다. 이메일이 비어 있는 기존 회원이 여럿이라 NULL 을 빼야 인덱스가 만들어지고, 대소문자만 다른 중복은 정규화 없이 그대로 통과합니다.
운영 데이터는 중복이 0건이라 인덱스가 그대로 생성됐습니다. 개발 데이터는 중복 1건과 형식이 깨진 값 1건을 정리한 뒤 만들었습니다. 정리하면서 대소문자만 다른 값이 실제로 막히는 것까지 확인했습니다.
제약 위반 예외는 전역에서 잡아 제약 이름을 보고 409 로 번역합니다. 이 번역이 없으면 사용자에게는 500 이 나가고, 화면은 무엇이 잘못됐는지 말할 수 없습니다.
남는 비용
플래그는 코드에 죽은 분기를 남깁니다. 재개가 미뤄질수록 그 분기가 낡고, 정식 오픈 때 걷어내는 비용이 붙습니다.
세어서 판단하는 방식은 로그인 수단이 늘 때마다 세는 곳을 고쳐야 합니다. 소셜이 셋·넷으로 늘면 이 판단이 여러 곳으로 흩어질 위험이 있습니다.
부분 유니크 인덱스는 DB 제품마다 지원이 다릅니다. 옮길 계획이 있으면 애플리케이션 검사 쪽에 더 기대는 편이 안전합니다.
점검 목록
현재 화면이나 권한을 가입 경로·유입 채널·최초 등록 방식으로 분기하는 곳이 있는지
그 값이 연결·해제·전환 이후에 갱신되는지, 한 번 찍히고 마는지
로그인 수단이 하나 남았을 때 그것을 끊을 수 있는 경로가 열려 있는지
외부 시스템 등록과 로컬 검증 중 어느 쪽이 먼저 도는지
앱이 부르지 않지만 살아 있는 엔드포인트에도 같은 검증이 들어 있는지
유니크 제약 위반이 사용자에게 500 으로 나가는지 409 로 나가는지
마무리
이번 작업에서 가장 많이 고친 것은 화면이 아니라 판단 기준이었습니다. 계정 찾기, 비밀번호 찾기 버튼, 연결 해지 가드가 전부 같은 질문을 잘못 던지고 있었습니다.
가입 경로 필드를 지우자는 이야기는 아닙니다. 유입 분석과 통계에는 그 값이 정확합니다. 다만 화면과 가드가 그 값을 볼 때는 한 번 더 봅니다. 이 판단이 묻는 것이 어떻게 왔는가인지 지금 무엇을 가지고 있는가인지를 먼저 가릅니다.