ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [iOS][Spring] Google login 붙이기
    기타개발지식/풀스택개발 2026. 6. 19. 15:26

    개요

    Google login은 앱과 백엔드를 같이 붙일 때 ID token 중심으로 생각하면 편하다.

    iOS 앱은 Google Sign-In SDK로 사용자를 로그인시키고, idToken을 받는다.

    Spring 백엔드는 그 id token을 Google 공개키/라이브러리로 검증한 뒤, 우리 서비스 회원을 찾거나 만들고, 우리 서비스 토큰을 발급한다.

    중요한 건 앱에서 받은 Google user id를 서버가 그냥 믿으면 안된다는 것이다.

    Google 공식 문서도 백엔드에서는 plain user ID를 믿지 말고, 검증 가능한 ID token을 사용하라고 설명한다.

    전체 흐름

    iOS 앱
      -> GoogleSignIn SDK 로그인
      -> idToken 획득
      -> POST /api/auth/google-login
    
    Spring 백엔드
      -> Google ID token 검증
      -> aud, iss, exp 확인
      -> provider = GOOGLE, providerUserId = sub 로 회원 조회
      -> 없으면 회원 생성
      -> 우리 서비스 JWT access token / refresh token 발급
    
    iOS 앱
      -> 우리 서비스 토큰 저장
      -> 이후 API 요청에 Authorization: Bearer {accessToken} 붙임

    Google access token은 Google API 호출 권한에 가깝고, 로그인 식별에는 id token이 더 맞다.

    앱-백엔드 로그인에서는 백엔드에 id token을 보내고, 백엔드가 검증하는 흐름을 기본으로 잡자.

    Google Cloud Console 설정

    먼저 Google Cloud Console에서 프로젝트를 만든다.

    Google Cloud Console
      > APIs & Services
      > OAuth consent screen

    앱 이름, 지원 이메일, 개발자 연락처를 채우고 저장한다.

    기본 로그인만 할 거면 scope는 보통 openid, email, profile 정도로 시작한다.

    그 다음 OAuth client를 만든다.

    APIs & Services
      > Credentials
      > Create Credentials
      > OAuth client ID

    iOS 앱용 client를 하나 만든다.

    Application type: iOS
    Bundle ID: com.example.myapp

    이 client id는 iOS 앱이 Google SDK를 초기화할 때 쓴다.

    그리고 백엔드 인증용으로 Web application 타입 client도 하나 만든다.

    Application type: Web application

    Google 문서 기준으로, 백엔드에 사용자를 안전하게 식별하려면 server client ID가 필요하다.

    iOS 쪽 GIDServerClientID에 이 Web client ID를 넣으면, 앱에서 백엔드 검증용 id token을 받을 수 있다.

    Google Auth Platform에서 실제로 누르는 순서

    위 설명만 보면 좀 추상적이다.

    실제로는 Google Cloud Console에서 아래 흐름으로 들어간다.

    Google Cloud Console
      > 프로젝트 선택
      > Google Auth Platform
      > Branding
      > Audience
      > Clients

    예전 문서나 블로그에서는 APIs & Services > OAuth consent screen이라고 많이 나오는데, 요즘 콘솔에서는 Google Auth Platform으로 보이는 경우가 있다.

    결국 하는 일은 같다.

    동의 화면을 만들고, OAuth client를 추가하는 것이다.

    처음 OAuth client를 만들려고 하면 consent screen 설정이 먼저 필요하다고 나온다.

    Google Cloud Console consent screen 설정 필요 화면

    Branding 화면에서 Get started를 누른다.

    Google Auth Platform Branding 시작 화면

    여기서 앱 이름, 유저 지원 이메일, Audience, 개발자 연락처를 넣는다.

    개인 앱이면 처음에는 Audience를 External로 두는 경우가 많다.

    Google Auth Platform Branding 설정 화면

    Branding 설정이 끝나면 왼쪽 메뉴의 Clients로 간다.

    그리고 Create client를 누른다.

    Google Auth Platform Clients 화면

    iOS 앱용 client를 만들 때는 Application type을 iOS로 고른다.

    그리고 Xcode Bundle Identifier를 넣는다.

    Team ID는 당장 필수는 아니지만, App Check까지 붙일 생각이면 넣어두는 게 좋다.

    Google OAuth iOS client 입력 화면

    생성 후에는 Client ID와 plist 다운로드가 나온다.

    여기서 Client ID는 iOS의 GIDClientID에 들어가고, plist 안의 Reversed Client ID는 URL Scheme에 들어간다.

    Google OAuth client 생성 완료 화면

    백엔드 검증용 server client는 같은 Clients 화면에서 한 번 더 만든다.

    Clients
      > Create client
      > Application type: Web application

    이 Web client ID를 iOS의 GIDServerClientID에 넣고, Spring의 google.client-id에도 같은 값으로 넣는다.

    헷갈리면 아래처럼 정리해두면 된다.

    iOS client ID
      - Application type: iOS
      - iOS Info.plist GIDClientID
      - Reversed Client ID는 URL Scheme
    
    Server client ID
      - Application type: Web application
      - iOS Info.plist GIDServerClientID
      - Spring google.client-id
      - 백엔드에서 idToken aud 검증 기준

    여기서 iOS client ID와 server client ID를 섞는 순간, 앱 로그인은 된 것 같은데 Spring 검증에서 계속 실패한다.

    iOS 프로젝트 설정

    Google Sign-In SDK를 추가한다.

    https://github.com/google/GoogleSignIn-iOS

    Google 공식 문서 기준으로 Swift Package Manager에서는 보통 아래 product를 쓴다.

    GoogleSignIn
    GoogleSignInSwift  // SwiftUI 버튼이 필요하면 추가

    Info.plist에는 iOS client id와 URL scheme을 넣는다.

    <key>GIDClientID</key>
    <string>YOUR_IOS_CLIENT_ID</string>
    
    <key>GIDServerClientID</key>
    <string>YOUR_WEB_SERVER_CLIENT_ID</string>
    
    <key>CFBundleURLTypes</key>
    <array>
      <dict>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>YOUR_REVERSED_IOS_CLIENT_ID</string>
        </array>
      </dict>
    </array>

    reversed client id는 Google Cloud Console의 iOS URL scheme에 보이는 값이다.

    예를 들어 client id가 아래라면,

    1234567890-abcdefg.apps.googleusercontent.com

    URL scheme은 이런 식이다.

    com.googleusercontent.apps.1234567890-abcdefg

    이거 안 넣으면 로그인 후 앱으로 돌아오는 callback이 안 잡힌다.

    iOS App에서 URL callback 처리

    import SwiftUI
    import GoogleSignIn
    
    @main
    struct MyApp: App {
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .onOpenURL { url in
                        GIDSignIn.sharedInstance.handle(url)
                    }
            }
        }
    }

    카카오와 마찬가지로 callback 처리가 빠지면 로그인 화면까지 갔다가 앱 세션이 안 이어진다.

    iOS 로그인 코드

    Google 로그인은 presenting view controller가 필요하다.

    SwiftUI 앱에서는 현재 root view controller를 찾아서 넘기는 헬퍼를 하나 두면 된다.

    import GoogleSignIn
    import UIKit
    
    @MainActor
    final class GoogleLoginService {
        private let authClient: AuthClient
    
        init(authClient: AuthClient) {
            self.authClient = authClient
        }
    
        func login() async throws -> LoginResponse {
            guard let presentingViewController = UIApplication.shared.rootViewController else {
                throw AuthError.missingPresenter
            }
    
            let result = try await GIDSignIn.sharedInstance.signIn(
                withPresenting: presentingViewController
            )
    
            guard let idToken = result.user.idToken?.tokenString else {
                throw AuthError.missingProviderToken
            }
    
            return try await authClient.googleLogin(
                idToken: idToken,
                accessToken: result.user.accessToken.tokenString,
                deviceId: DeviceID.current,
                timezone: TimeZone.current.identifier,
                locale: Locale.current.identifier
            )
        }
    }

    accessToken은 fallback이나 Google API 호출이 필요할 때 같이 넘길 수 있다.

    하지만 백엔드 로그인 검증의 기준은 idToken으로 잡는 게 낫다.

    iOS 요청 형태

    {
      "idToken": "google-id-token",
      "accessToken": "google-access-token",
      "deviceId": "device-uuid",
      "timezone": "Asia/Seoul",
      "locale": "ko-KR"
    }

    로그인 성공 후 앱은 백엔드가 내려준 우리 서비스 토큰을 저장한다.

    Google id token을 앱 내부 API 인증용으로 계속 쓰는 구조가 아니다.

    Spring 요청 DTO

    public record GoogleLoginRequest(
            String idToken,
            String accessToken,
            String deviceId,
            String timezone,
            String locale
    ) {
    }

    Spring 의존성

    Google ID token 검증은 Google API Client를 쓰면 편하다.

    implementation "com.google.api-client:google-api-client"

    버전은 프로젝트의 BOM이나 현재 Spring Boot 환경에 맞춰 잡으면 된다.

    직접 JWT 라이브러리로 검증할 수도 있지만, 처음에는 공식 라이브러리를 쓰는 쪽이 덜 헷갈린다.

    Spring에서 Google ID token 검증

    @Component
    public class GoogleTokenVerifier {
    
        private final GoogleIdTokenVerifier verifier;
    
        public GoogleTokenVerifier(
                @Value("${google.client-id}") String googleClientId
        ) throws GeneralSecurityException, IOException {
            this.verifier = new GoogleIdTokenVerifier.Builder(
                    GoogleNetHttpTransport.newTrustedTransport(),
                    GsonFactory.getDefaultInstance()
            )
                    .setAudience(List.of(googleClientId))
                    .build();
        }
    
        public GoogleUser verify(String idTokenValue) {
            try {
                GoogleIdToken idToken = verifier.verify(idTokenValue);
                if (idToken == null) {
                    throw new AuthException("Invalid Google id token");
                }
    
                GoogleIdToken.Payload payload = idToken.getPayload();
    
                return new GoogleUser(
                        payload.getSubject(),
                        payload.getEmail(),
                        Boolean.TRUE.equals(payload.getEmailVerified()),
                        (String) payload.get("name"),
                        (String) payload.get("picture")
                );
            } catch (Exception e) {
                throw new AuthException("Google login failed", e);
            }
        }
    }

    google.client-id에는 iOS client id가 아니라 backend 인증용 Web client ID를 넣는 경우가 많다.

    iOS Info.plist의 GIDServerClientID와 Spring의 google.client-id가 맞아야 audience 검증이 통과한다.

    여기가 틀리면 token은 있어 보이는데 서버 검증이 계속 실패한다.

    회원 처리

    Google token payload의 sub를 provider user id로 저장한다.

    @Service
    public class AuthService {
    
        private final GoogleTokenVerifier googleTokenVerifier;
        private final MemberRepository memberRepository;
        private final TokenService tokenService;
    
        public LoginResponse loginWithGoogle(GoogleLoginRequest request) {
            GoogleUser googleUser = googleTokenVerifier.verify(request.idToken());
    
            Member member = memberRepository
                    .findByProviderAndProviderUserId(Provider.GOOGLE, googleUser.id())
                    .orElseGet(() -> memberRepository.save(
                            Member.social(Provider.GOOGLE, googleUser.id(), googleUser.email())
                    ));
    
            TokenPair tokenPair = tokenService.issue(member.getId(), request.deviceId());
    
            return LoginResponse.from(member, tokenPair);
        }
    }

    DB 유니크 키는 아래처럼 잡는다.

    provider + provider_user_id unique

    이메일은 바뀔 수 있고, provider마다 정책이 달라서 외부 로그인 연결 키로는 sub가 더 적합하다.

    컨트롤러

    @RestController
    @RequestMapping("/api/auth")
    public class AuthController {
    
        private final AuthService authService;
    
        public AuthController(AuthService authService) {
            this.authService = authService;
        }
    
        @PostMapping("/google-login")
        public LoginResponse googleLogin(@RequestBody GoogleLoginRequest request) {
            return authService.loginWithGoogle(request);
        }
    }

    실제 프로젝트에서는 응답을 공통 포맷으로 감싸면 된다.

    {
      "data": {
        "accessToken": "service-access-token",
        "refreshToken": "service-refresh-token",
        "memberId": 1,
        "isExistingMember": true
      }
    }

    앱 세션 저장

    앱은 로그인 성공 후 우리 서비스 토큰만 저장한다.

    final class AuthSession {
        func apply(_ response: LoginResponse) {
            Keychain.set(response.accessToken, forKey: "accessToken")
            Keychain.set(response.refreshToken, forKey: "refreshToken")
            UserDefaults.standard.set(response.memberId, forKey: "memberId")
        }
    }

    이후 API 요청은 아래처럼 보낸다.

    Authorization: Bearer {service-access-token}

    401이 오면 refresh API를 한 번 호출하고, 성공하면 원래 요청을 한 번만 재시도한다.

    무한 재시도는 금방 지옥이 된다.

    자주 막히는 부분

    1. iOS OAuth client ID와 Web server client ID를 헷갈림
    2. GIDServerClientID를 안 넣어서 id token audience가 서버와 안 맞음
    3. reversed client id URL scheme을 안 넣어서 로그인 후 앱으로 안 돌아옴
    4. 백엔드에서 Google user id 문자열만 받고 검증을 안 함
    5. Spring의 google.client-id가 실제 id token의 aud와 다름
    6. Simulator에서는 되는데 실기기에서 Bundle ID나 signing 차이로 안 됨

    Google login은 설정값 이름이 비슷해서 헷갈린다.

    iOS client ID, reversed client ID, server client ID 이 세 개를 표로 적어두고 시작하면 삽질이 많이 줄어든다.

    최종 정리

    Google login은 iOS 앱에서 GoogleSignIn SDK로 id token을 받고, Spring 백엔드에서 그 id token을 검증하는 구조로 잡으면 된다.

    백엔드는 Google token의 sub를 provider user id로 쓰고, 우리 서비스 토큰을 새로 발급한다.

    가장 많이 틀리는 건 client ID 종류다.

    앱에는 iOS client ID와 reversed URL scheme, 백엔드 검증에는 Web server client ID를 맞춰 넣는다고 기억하면 된다.

    참고 링크

Designed by Tistory.