기술

이 목록은 중복 차단용이지 완료 판정용이 아니었습니다

2026.09.128분 읽기

회사 이메일을 인증하면 소속이 등록되는 서비스가 있습니다. 담당자가 발급한 코드로 등록하는 경로도 함께 있습니다. 두 경로는 같은 결과를 만들지만 처리하는 코드가 다릅니다. 이 둘이 겹치는 지점에서 버그가 세 건 올라왔습니다.

신고 내용만 보면 셋은 서로 상관없는 문제였습니다. 하나는 다시 신청하려는데 자기 이메일 때문에 막혔다는 것이었고, 하나는 이미 등록된 이메일인데 대문자를 섞으면 그냥 통과된다는 것이었고, 하나는 초대를 수락했는데 화면이 그대로라는 것이었습니다. 접수된 시점도 제각각이었습니다. 세 건을 한자리에 놓고 나서야 원인이 한 종류라는 것이 보였습니다.


값이 무엇을 뜻하는지 정하지 않고 검사에 썼습니다

세 건 모두 어떤 값을 검사에 쓰고 있었습니다. 그런데 그 값이 무엇을 뜻하는지는 어디에도 정의돼 있지 않았습니다. 이메일은 무엇을 같다고 볼지 정하지 않은 채 비교됐고, 중복 검사는 누가 쓰고 있는지를 묻지 않고 있는지만 물었고, 상태 목록은 어떤 판정에 쓰라고 만든 것인지가 이름에 없었습니다. 정의가 없으면 검사하는 쪽이 각자 편한 대로 해석합니다.


본인이 자기 이메일 때문에 막혔습니다

이메일 인증으로 가입했다가 소속이 해제된 회원이 있었습니다. 이 회원이 담당자에게 코드를 받아 다시 신청하면 이미 다른 계정에서 사용 중인 회사 이메일이라는 안내가 떴습니다. 다른 계정은 없었습니다. 해제됐던 본인의 행이 부활하면서 예전에 인증한 이메일 값을 그대로 들고 있었고, 중복 검사가 그 행을 남으로 본 것입니다.

고친 것은 두 가지입니다. 코드 등록은 이메일 인증을 거치지 않는 경로이므로, 재신청 시 인증 이메일 값을 비우게 했습니다. 그리고 중복 검사가 던지는 질문을 존재하는가에서 누가 소유하는가로 바꿨습니다. 소유자를 물으면 본인인지 타인인지가 답에 같이 들어오므로, 안내 문구를 나누는 분기가 자연스럽게 따라 나옵니다.

있는가를 물으면 본인도 걸러냅니다. 누가 갖고 있는가를 물으면 분기와 안내 문구가 같이 나옵니다.

138-1.jpg


대문자 하나로 중복 검사를 지나갈 수 있었습니다

두 번째는 더 단순했습니다. 이메일 정규화가 도메인만 소문자로 바꾸고 로컬 파트는 원문 그대로 저장하고 있었습니다. 비교도 정확 일치였습니다. 같은 주소를 대문자를 섞어 넣으면 다른 값이 되어 중복 검사를 그대로 통과했습니다.

정규화 함수를 하나 만들어 회원 셀프 인증과 담당자 초대 양쪽에 동시에 적용했습니다. 한쪽만 고치면 두 경로가 서로 다른 값을 만들어 조회가 어긋납니다. 적용 전에 기존 데이터에 대문자가 포함된 이메일이 몇 건인지 세어봤고 0건이었습니다. 0건이 아니었다면 마이그레이션이 먼저였습니다. 다만 로컬 파트를 소문자로 바꾸는 것이 표준상 완전히 안전하지는 않다는 점은 알고 씁니다.


이 목록은 중복 차단용이지 완료 판정용이 아니었습니다

세 번째가 가장 오래 걸렸습니다. 승인 대기 중인 회원이 이메일 초대를 수락하면 초대만 수락 처리되고 소속은 대기에 머물렀습니다. 화면에는 아무 변화가 없었습니다.

수락 로직은 건너뛸지를 판정할 때 활성 상태 목록이라는 상수를 쓰고 있었습니다. 대기와 승인이 함께 들어 있는 목록입니다. 그 상수는 원래 중복 신청을 막을 때 쓰려고 만든 것이었습니다. 신청 중이거나 승인된 사람이 새 신청을 못 하게 하는 검사에는 그 목록이 정확합니다. 그런데 수락 로직은 같은 목록을 이미 처리가 끝났는가로 읽었습니다.

두 목록은 겹치지만 같지 않습니다. 대기는 중복 차단 대상이면서 처리 완료는 아닙니다. 겹치는 부분이 넓어서 대부분의 경우에는 맞는 답이 나왔고, 대기 상태에서 초대를 수락하는 한 가지 조합에서만 틀렸습니다. 이런 종류는 예외가 안 나므로 로그에도 안 걸립니다.

판정을 승인 하나로 좁혔습니다. 대기 상태는 건너뛰지 않고 승격 경로를 타게 했습니다. 인증 이메일 기록, 대표 소속 동기화, 승인 알림까지 이어집니다.

138-2.jpg

활성 상태 목록이라는 이름에는 어디에 쓰라는 정보가 없습니다. 중복 차단용 목록이었다면 완료 판정 자리에 그것을 가져다 쓰기 전에 한 번 멈췄을 것입니다.


만료를 표현할 값이 없으면 영구 차단이 생깁니다

세 건을 고치는 동안 네 번째가 나왔습니다. 인증 메일은 한 번 보내면 다시 못 보냈습니다. 중복 검사가 초대 상태만 보는데 만료라는 상태값이 아예 없었습니다. 유효 기간이 지나도 초대는 여전히 대기로 남아 있었고, 검사하는 쪽은 그것을 살아 있는 초대로 봤습니다. 메일을 못 받은 사람은 7일을 기다리거나 담당자에게 문의해야 했습니다.

재발송 규칙은 같은 주소에 60초 간격, 횟수 무제한으로 잡았습니다. 만료 여부는 보지 않습니다. 쿨다운 규칙이 만료 케이스까지 덮기 때문입니다. 만료된 초대든 방금 보낸 초대든 60초가 지났으면 다시 보내면 됩니다. 조건 하나가 두 문제를 덮는지 확인하면 관리할 상태값이 하나 줄어듭니다.

쿨다운 기준 시각은 새 컬럼을 만들지 않고 기존 수정 시각을 썼습니다. 대기 상태에서 그 값을 바꾸는 동작이 재발송뿐이라, 그 값이 곧 마지막 발송 시각입니다. 값이 비어 있으면 통과시킵니다. 다만 만료된 초대를 목록에서 지운다 같은 정리 요구가 생기면 만료 상태가 다시 필요해집니다.


동시 요청은 두 겹으로 막았습니다

재발송 버튼은 연타되는 버튼입니다. 조회에 비관적 락을 걸지 않으면 두 요청이 나란히 통과해 메일이 두 통 갑니다. 락을 먼저 걸었습니다.

그리고 대기 상태에만 걸리는 부분 유니크 인덱스를 추가했습니다. 이미 대기 행이 둘 생긴 상태라면 이후에는 조회할 때마다 500이 납니다. 락은 정상 흐름을 막고, 인덱스는 락이 뚫린 뒤의 최후 방어선입니다. 부분 유니크 인덱스는 DB에 따라 지원 여부와 문법이 다르므로, 지원되지 않는 환경에서는 애플리케이션 락 한 겹에 의존하게 됩니다.

138-3.jpg


재발송은 문구까지가 기능입니다

재발송하면 토큰이 교체됩니다. 이전 메일의 링크는 없는 토큰이 됩니다. 원래 문구는 존재하지 않는 초대 링크입니다였습니다. 방금 받은 메일을 열었는데 그 문구가 뜨면 사용자는 자기가 뭘 잘못했다고 생각합니다. 가장 최근에 받은 메일의 링크를 열어 달라는 문구로 바꿨습니다. 만료 문구도 이제 본인이 직접 다시 받을 수 있으므로 담당자 문의를 유도하지 않게 고쳤습니다.

초대 출처도 같이 손봤습니다. 담당자가 보낸 초대를 회원이 재발송하면 출처를 셀프로 바꿉니다. 담당자 초대로 남겨두면 수락 시 제3자 제공 동의 기록이 누락됩니다. 쿨다운은 회원 경로에만 걸었습니다. 담당자의 발송은 문의 대응용이라 제외했습니다.

검증은 단위 테스트 31건 통과로 마무리했습니다. 재발송, 쿨다운, 만료, 출처 전환, 승격 케이스가 신규였습니다. 개발 환경 앱에서 시나리오 4종을 확인했고, 운영에서는 코드로 등록한 계정이 이메일 초대를 수락해 승격되는 것까지 봤습니다.


같은 실수를 다시 만나지 않으려고 확인하는 것들

상태 집합 상수의 이름에 용도가 들어 있는가. 활성 목록처럼 넓은 이름은 다른 판정에 재사용됩니다

중복 검사가 존재를 묻는가 소유를 묻는가. 존재를 물으면 본인도 함께 걸러집니다

표기 변형이 있는 값은 무엇을 같다고 볼지 먼저 정하고, 모든 입력 경로에 같은 함수를 적용했는가

시간이 지나면 무효가 되는 것이 상태로 표현돼 있는가. 없으면 영구 차단이 생깁니다

동시 요청 방어가 애플리케이션과 DB 양쪽에 있는가


마무리

세 건은 서로 다른 화면에서 올라왔고, 각각 고치면 한나절짜리 수정이었습니다. 같이 놓고 보니 고칠 곳은 화면이 아니라 값의 정의였습니다. 상태 목록에 용도를 담은 이름을 붙이고, 중복 검사가 던지는 질문을 바꾸고, 이메일을 비교하기 전에 무엇을 같다고 볼지를 정했습니다.

상수 하나가 여러 자리에서 쓰이는 것을 보면, 그 자리들이 같은 질문을 하고 있는지 한 번 확인하는 편이 낫습니다. 겹치는 부분이 넓을수록 틀린 케이스는 드물게 나타나고, 드물게 나타나는 버그는 예외 없이 조용히 지나갑니다.

#상태집합#이메일정규화#중복검사#재발송쿨다운#비관적락#부분유니크인덱스