카카오 소셜 로그인이 터졌다: 유니크 인덱스와 조회 전략의 불일치
사건 발생
프로덕션에서 카카오 로그인 에러가 올라왔다.
Duplicate entry 'kakao-XXXXXXXXXX' for key 'users.idx_provider_id'
유니크 인덱스 충돌. 이미 있는 provider + provider_id 조합으로 INSERT를 시도하다 터진 거다.
이미 있는 사용자인데 왜 새로 만들려고 했을까? 여기서부터 추적이 시작됐다.
테이블 구조
users 테이블에는 유니크 인덱스가 2개 걸려 있다.
users 테이블: ┌────────────────────────────────────────────────────┐ │ id │ email │ provider │ provider_id │ ├────────────────────────────────────────────────────┤ │ ** │ user****@gmail.com │ kakao │ 1234567890 │ └────────────────────────────────────────────────────┘ UNIQUE INDEX idx_email_provider (email, provider) UNIQUE INDEX idx_provider_id (provider, provider_id)
idx_provider_id는 "같은 소셜 플랫폼에서 같은 사용자 ID로 중복 가입은 불가"를 보장한다. 여기까진 맞다.
문제는 조회할 때 이 인덱스를 타지 않고 있었다는 것.
기존 코드의 조회 로직
CustomOAuth2UserService가 사용자를 찾는 흐름은 이랬다.
카카오 로그인 시도 │ ▼ 카카오 API에서 사용자 정보 수신 providerId: "1234567890" nickname: "홍길동" email: 없음 (카카오는 이메일을 안 준다) │ ▼ 이메일이 없으니 합성 이메일 생성 email = "kakao_1234567890@kakao.user" │ ▼ DB 조회: findByEmailAndProvider("kakao_1234567890@kakao.user", kakao) │ ▼ 결과: 없음 ← 여기서 꼬임 │ ▼ 신규 사용자로 판단 → INSERT 시도 → 유니크 키 충돌!
왜 "없음"이 나왔을까?
DB를 직접 까보니, 이 사용자의 이메일이 user****@gmail.com이었다. 가입 후 프로필에서 이메일을 바꿔놓은 거다. 그런데 코드는 합성 이메일 kakao_1234567890@kakao.user로 찾고 있었으니 매칭될 리가 없다.
DB에 저장된 이메일: user****@gmail.com 코드가 조회한 이메일: kakao_1234567890@kakao.user → 당연히 안 맞음
근본 원인: 변하는 값으로 사용자를 찾고 있었다
┌──────────────────────────────────────────────────────────────┐ │ users 테이블 │ │ │ │ email: 사용자가 변경 가능 (프로필 수정) ← 변하는 값 │ │ provider_id: 소셜 플랫폼이 부여한 고유 ID ← 절대 안 변함 │ │ │ │ 기존 조회: email + provider → 변하는 값 기반 = 불안정 │ │ 유니크 인덱스: provider + provider_id → 불변 값 기반 = 안정 │ └──────────────────────────────────────────────────────────────┘
유니크 인덱스는 provider + provider_id로 만들어 뒀으면서, 기존 사용자 조회는 email + provider로 했다. 이메일은 사용자가 바꿀 수 있는 값이니, 바뀌는 순간 못 찾는다.
구글이나 네이버는 이메일을 직접 줘서 이 문제가 잘 안 드러난다. 카카오는 이메일을 안 주니까 합성 이메일을 만드는데, 사용자가 프로필에서 이메일을 변경하는 순간 불일치가 생긴다. 카카오에서만 터진 이유가 이거다.
수정: 3단계 fallback 조회
이메일 하나로만 찾던 걸 3단계 fallback으로 바꿨다.
카카오 로그인 시도 │ ▼ 1차: provider + providerId로 조회 (유니크 인덱스 활용) → findByProviderAndProviderId(kakao, "1234567890") │ ├─ 찾았다 → 기존 사용자 정보 업데이트 후 반환 │ └─ 못 찾았다 │ ▼ 2차: email + provider로 fallback 조회 → findActiveByEmailAndProvider("kakao_...@kakao.user", kakao) │ ├─ 찾았다 → providerId 채워넣고 반환 │ └─ 못 찾았다 │ ▼ 3차: 진짜 신규 → 계정 생성
1차가 핵심이다. provider + provider_id는 소셜 플랫폼이 부여한 불변 식별자라서, 사용자가 이메일을 뭘로 바꾸든 정확히 매칭된다. 유니크 인덱스와 같은 조합이니 성능도 걱정 없다.
2차는 기존 데이터 호환용이다. 예전 코드에서 가입한 사용자 중 provider_id가 빠진 레코드가 있을 수 있어서 이메일로 한 번 더 찾아본다. 이때 providerId를 채워넣으면 다음 로그인부터는 1차에서 바로 잡힌다. 시간이 지나면 2차까지 내려가는 케이스는 자연히 없어진다.
탈퇴 사용자 재가입
1차에서 찾았는데 탈퇴한 사용자면 어떻게 할까.
1차 조회 결과가 탈퇴 사용자(WITHDRAWN)? │ ▼ 기존 레코드의 email/providerId를 가명화 email → "withdrawn_42_1707700000@deleted.user" providerId → "withdrawn_42_1707700000" │ ▼ flush()로 DB 반영 (유니크 인덱스 비워주기) │ ▼ 새 계정 생성 (같은 카카오 계정으로 재가입)
flush()를 먼저 호출하는 이유가 있다. 기존 레코드의 provider_id가 그대로 남아있으면 새 INSERT에서 또 유니크 키 충돌이 난다. 가명화한 뒤 DB에 반영해야 자리가 비는 거다.
코드
UserRepository에 메서드 하나 추가했다.
Optional<User> findByProviderAndProviderId( Provider provider, String providerId);
Spring Data JPA 네이밍 컨벤션에 맞춰 선언만 하면 쿼리가 자동 생성된다. 유니크 인덱스를 정확히 타니까 조회는 O(1).
loadUser()는 Optional 체이닝으로 3단계를 표현했다.
User user = userRepository .findByProviderAndProviderId(provider, userInfo.providerId()) .map(existing -> handleExistingUser(existing, userInfo)) .or(() -> userRepository .findActiveByEmailAndProvider(userInfo.email(), provider) .map(existing -> updateAndReturn(existing, userInfo))) .orElseGet(() -> createUser(userInfo, provider));
Optional.or()은 앞이 empty일 때만 실행된다. 1차에서 찾으면 2차 쿼리는 아예 안 날아간다.
왜 오래 잠복했나
돌이켜보면 단순한 버그다. 유니크 인덱스는 provider_id로 걸어뒀는데 조회는 email로 했으니, 따로 노는 구조였다.
그런데 서비스 운영한 지 꽤 됐는데 이제서야 터졌다. 이유가 있다. 대부분의 사용자는 이메일을 안 바꾼다. 카카오 사용자 중에서 프로필 이메일을 실제 주소로 바꿔놓은 사람만 영향을 받았으니, 확률적으로 드문 케이스였던 거다.
테스트로 잡기도 어렵다. "가입 → 이메일 변경 → 한참 뒤 재로그인"이라는 시나리오를 E2E에서 커버하기란 쉽지 않다. 결국 설계할 때 "이 값은 변할 수 있나?"를 한 번 더 물어봐야 한다.
소셜 로그인은 한 번 붙여놓으면 끝인 것 같지만, 이메일 변경, 탈퇴 후 재가입, 프로바이더 간 계정 충돌 같은 엣지 케이스가 꽤 있다. 특히 카카오처럼 이메일을 안 주는 프로바이더는 구글/네이버와 같은 패턴으로 처리하면 안 된다. 합성 이메일은 임시 식별자일 뿐, 진짜 식별자가 아니니까.

댓글
댓글을 작성하려면 이 필요합니다.