Google 로그인에서 이메일 대신 sub를 식별자로 선택한 이유
Google Identity Services ID Token을 백엔드에서 검증하고 이메일 대신 sub를 식별자로 사용한 이유, 2단계 소셜 가입으로 확장된 흐름을 정리했다.
프로젝트 기간: 2026-08-18 ~ 2026-09-02
글 범위: Google Identity Services ID Token 검증과 소셜 계정 매핑
MeetBack에 Google 로그인을 추가하면서 가장 먼저 정리한 것은 “브라우저가 보내는 어떤 값을 믿을 것인가”였다. 프론트엔드가 이메일과 이름을 JSON으로 보내고 백엔드가 그대로 계정을 만들면 구현은 간단하다. 하지만 공격자가 요청을 직접 만들어 다른 이메일을 넣어 보내는 것도 똑같이 간단해진다.
나는 브라우저가 해석한 사용자 프로필을 신뢰하지 않고, Google Identity Services가 발급한 OpenID Connect ID Token 원문만 서버로 전달하도록 경계를 잡았다. 백엔드는 Google의 공식 검증기로 서명과 issuer, audience, 만료를 확인한 뒤 검증된 claim만 사용한다.
Authorization Code 교환 방식은 아니다
이 기능을 편의상 Google OAuth 로그인이라고 부르기도 하지만, 현재 MeetBack의 흐름은 Authorization Code를 백엔드가 Google Token endpoint와 교환하는 방식이 아니다. Google Access Token이나 UserInfo API, Client Secret도 사용하지 않는다.
흐름은 다음과 같다.
Google Identity Services 버튼
→ 브라우저가 ID Token 획득
→ POST /auth/google
→ 백엔드가 GoogleIdTokenVerifier로 검증
→ 검증된 sub, email, email_verified, name 사용
→ MeetBack 자체 Access/Refresh JWT 발급 또는 가입 단계 진행
GoogleIdTokenVerifier를 만들 때 audience를 MeetBack의 Google Client ID로 제한했다. 서명이 올바른 Token이라도 다른 애플리케이션을 대상으로 발급됐다면 MeetBack에서 사용할 수 없게 하기 위해서다.
tokenVerifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(),
GsonFactory.getDefaultInstance()
)
.setAudience(Collections.singletonList(clientId))
.build();
검증 결과가 없거나 필수 claim이 누락됐거나 이메일이 검증되지 않았다면 로그인을 진행하지 않는다.
이메일이 아니라 sub를 선택했다
소셜 계정의 영구 식별자는 이메일이 아니라 ID Token의 sub로 정했다.
이메일은 사용자 설정이나 공급자 정책에 따라 바뀔 수 있고, 여러 인증 공급자에서 같은 문자열이 등장할 수도 있다. 반면 sub는 해당 Google Client에서 사용자를 구분하기 위해 제공되는 안정적인 식별자다. 그래서 조회 조건을 provider=GOOGLE + providerId=sub로 구성했다.
Social social = socialMapper.selectByProviderAndProviderId(
"GOOGLE",
googleUserInfo.getProviderId()
);
이메일은 연락과 중복 정책에 필요한 속성으로 사용하되, 계정 소유권을 증명하는 유일한 키로 사용하지 않았다.
같은 이메일을 자동 연결하지 않은 이유
신규 Google 사용자의 이메일과 기존 일반 회원의 이메일이 같을 수 있다. 이때 편의를 이유로 두 계정을 자동 연결하면, 기존 계정의 비밀번호나 현재 로그인 상태를 확인하지 않고 계정 소유권을 합치는 문제가 생긴다.
나는 동일 이메일의 기존 계정이 발견되면 자동 가입과 자동 연결을 중단하도록 했다.
if (existingUser != null) {
throw new IllegalStateException(
"같은 이메일의 기존 계정이 있습니다. "
+ "기존 계정으로 로그인한 뒤 Google 계정을 연결해주세요."
);
}
현재 코드에는 안전한 계정 연결 API까지 구현돼 있지 않다. 따라서 정확한 표현은 “자동 연결을 막았다”이지 “계정 연결 기능을 완성했다”가 아니다.
즉시 가입에서 2단계 가입으로 확장됐다
내가 처음 구현한 버전은 신규 Google 사용자를 바로 생성하고 Google 프로필 이름을 닉네임으로 사용했다. 닉네임 충돌이 생기면 Google sub의 일부를 붙여 안정적인 대체 닉네임을 만들었다.
이후 팀 인증 흐름과 통합되면서 신규 사용자는 즉시 DB에 저장되지 않는 구조로 바뀌었다. 내가 만든 Google Token 검증과 계정 매핑 기반 위에, 검증 결과를 담은 10분짜리 SOCIAL_SIGNUP JWT가 추가됐다.
Google ID Token 검증
→ 기존 Google 회원이면 MeetBack JWT 발급
→ 신규 회원이면 SOCIAL_SIGNUP JWT 발급
→ 사용자가 닉네임과 필수 약관 선택
→ POST /auth/social/complete
→ users + social + 약관 동의 저장
→ MeetBack Access/Refresh JWT 발급
두 번째 요청에서 브라우저가 Google 이메일과 provider ID를 다시 평문으로 주장하게 하지 않고, 서버가 서명한 단기 Token으로 검증 결과를 전달한다는 점이 핵심이다. 이 Token에는 provider, email, emailVerified, name, tokenType=SOCIAL_SIGNUP과 만료 시간이 포함된다.
후속 가입 흐름은 팀 통합 과정에서 확장된 결과다. 모든 후속 인증 작업을 내가 혼자 구현했다고 주장하지 않고, 내가 만든 Google 검증·계정 매핑 기반이 팀의 2단계 가입 흐름에 통합됐다고 기록하는 것이 정확하다.
서비스 인증은 MeetBack JWT가 담당한다
Google ID Token은 Google 사용자를 검증하는 입구에만 사용한다. 인증이 끝난 뒤 MeetBack API 접근에는 서비스 자체 Access/Refresh JWT를 발급한다.
현재 로그인 흐름은 새 로그인 때 DB의 tokenVersion을 증가시키고, 최신 version과 role을 담아 Token을 만든다. 이후 요청에서 이전 version의 JWT를 무효화할 수 있는 기반이다. 다만 이미 열린 STOMP WebSocket을 물리적으로 닫는 일은 별도 문제이며, 이 부분은 Redis 실시간 공통 모듈의 인증 무효화 event와 session control 구현이 연결돼야 완성된다.
검증한 것과 남은 것
초기 Google 구현에는 기존 회원 로그인, 신규 가입, 이메일 충돌 등을 다루는 단위 테스트를 추가했다. 그러나 팀 통합 뒤 현재 main의 Google 전용 테스트 상태는 초기 구현과 같지 않다. 따라서 초기 테스트 통과를 현재 전체 인증 흐름의 완전한 회귀 검증으로 과장하지 않는다.
현재 남은 과제는 다음과 같다.
- 동일 이메일 계정을 사용자가 명시적으로 연결할 수 있는 안전한 절차
- 현재 2단계 가입 흐름의 Google 전용 회귀 테스트 보강
- tokenVersion 변경과 기존 WebSocket 세션 종료의 실제 연동
- 운영 환경의 Client ID와 JWT 비밀값을 Secret으로 관리하는 배포 설정
이 작업에서 가장 중요했던 것은 버튼 하나를 붙이는 일이 아니라 신뢰 경계를 정하는 일이었다. 이메일과 sub의 역할을 분리하고, 브라우저가 주장한 프로필이 아니라 서명이 검증된 claim만 사용하도록 만든 판단이 이후 가입 정책이 바뀌어도 유지될 수 있는 기반이 됐다.