MeetBack 2026.08.20 6 min read

Google 로그인에서 이메일 대신 sub를 식별자로 선택한 이유

Google 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 발급

두 번째 요청에는 서버가 서명한 단기 Token으로 검증 결과를 전달한다. 브라우저가 Google 이메일과 provider ID를 다시 보내면서 값을 바꿀 수 없도록 한 것이다. 이 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 구현이 연결돼야 완성된다.

검증한 것과 남은 것

초기 구현에서는 기존 회원 로그인, 신규 가입과 이메일 충돌을 다루는 단위 테스트를 추가했다. 팀 통합 뒤 main의 Google 전용 테스트 상태는 초기와 달라졌다. 초기 테스트 통과 기록만으로 통합된 인증 흐름 전체를 검증했다고 볼 수는 없었다.

이 글을 작성할 때 아래 작업이 남아 있었다.

  • 동일 이메일 계정을 사용자가 명시적으로 연결할 수 있는 안전한 절차
  • 현재 2단계 가입 흐름의 Google 전용 회귀 테스트 보강
  • tokenVersion 변경과 기존 WebSocket 세션 종료의 실제 연동
  • 운영 환경의 Client ID와 JWT 비밀값을 Secret으로 관리하는 배포 설정

가입 정책이 즉시 가입에서 두 단계로 바뀌어도, 이메일과 sub의 역할을 나눈 기준은 유지했다. 서버에서 서명을 검증한 claim으로 계정을 찾고 가입을 진행하도록 한 부분을 팀 인증 흐름에도 사용했다.

SEARCH JOURNAL

기록 검색

글 제목, 요약, 태그에서 찾습니다.

검색을 준비하고 있어요.

검색어를 입력해 주세요.

    Ctrl/⌘ K 검색 Esc 닫기 ↑ ↓ 결과 이동