안녕하세요. 첫번째 글을 올리고 난 후 시간이 꽤 지난것 같네요. 그럼 이번에는 실제 연동을 해봅시다.
이전 글 :
구글 OAuth2.0 - Flutter와 연동하기(1)
현재 개발을 진행중인 프로젝트에 대해서...추가할 API나 로직을 위주로, 예상 문제 → 해결방법 → 얻게되는 점으로 트러블 슈팅 과정을 작성할 예정입니다. 앞으로 열심히 작성해볼테니 틀린
0110020321.tistory.com
들어가기 앞서 저번 시간에 웹과 앱을 따로 분리할 예정이라고 언급한 적이 있습니다. 그렇다면 어떻게 분리하는게 과연 진행 중인 프로젝트와 어울릴지 생각을 해볼 필요가 있습니다.
모바일과 웹 OAuth 방식 차이
Google OAuth를 연동하기 전에 먼저 모바일과 웹에서의 인증 흐름 차이를 정리했습니다.
웹에서는 일반적으로 OAuth 2.0의 Redirect 흐름을 사용합니다. 사용자가 브라우저에서 Google 로그인 페이지로 이동하고, 로그인 완료 후 등록된 Redirect URI로 돌아오는 방식입니다.
반면 모바일 앱에서는 google_sign_in과 같은 네이티브 SDK를 통해 Google 계정 로그인을 처리하는 방식이 자연스럽습니다. 앱 내부에서 Google 로그인 창을 띄우고, 로그인 성공 후 ID Token을 발급받아 백엔드로 전달할 수 있기 때문입니다.
| 구분 | 모바일 | 웹 |
| 방식 | NativeSDK | OAuth 2.0 URL Redirect |
| 흐름 | 앱에서 Google 로그인 → ID Token 발급 → 백엔드 전송 | 브라우저 이동/팝업 → 로그인 → Redirect URI로 복귀 |
| 장점 | 앱 환경에 자연스러운 UX | 표준 OAuth Redirect 흐름에 적합 |
| 백엔드 연동 | ID Token 검증 후 서비스 JWT 발급 | Authorization Code 검증 후 서비스 세션/JWT 발급 가능 |
현재 프로젝트는 Flutter 기반으로 개발하고 있으며, 웹은 포트폴리오 확인용, 앱은 Android 배포용으로 사용하고 있습니다. 따라서 OAuth 로그인은 웹 Redirect 방식보다 Android 앱 환경에 맞는 google_sign_in 패키지를 사용하는 방향으로 구현했습니다.
최종 흐름은 Flutter 앱에서 Google 로그인을 수행하고, 발급받은 Google ID Token을 Spring Boot 백엔드로 전달하는 방식입니다. 백엔드는 전달받은 ID Token이 실제 Google에서 발급된 유효한 토큰인지 검증한 뒤, 서비스 자체 JWT accessToken을 발급합니다.
Flutter Google 로그인 → Google ID Token 발급 → /api/auth/google로 ID Token 전송 → Spring Boot에서 Google ID Token 검증 → 서비스 자체 JWT accessToken 발급 → Flutter에서 accessToken 저장 → 이후 API 요청 시 Authorization 헤더로 인증 유지
초기에는 Flutter의 웹/앱 환경을 모두 고려해 kIsWeb을 사용한 OAuth 분기도 생각했습니다. 하지만 현재 프로젝트에서는 OAuth 로그인 자체를 웹과 앱으로 동시에 구현하기보다, Android 앱 기준으로 먼저 완성하는 것이 더 적절하다고 판단했습니다.
따라서 이번 OAuth 로그인 구현에서는 kIsWeb을 핵심 분기 기준으로 사용하지 않았습니다. 대신 kIsWeb은 웹 포트폴리오 환경에서 앱 전용 기능을 제한하는 용도로 사용할 수 있습니다.
예를 들어 만보기처럼 모바일 센서나 네이티브 기능에 의존하는 기능은 웹에서 동일하게 동작하기 어렵습니다. 이런 경우 kIsWeb을 사용해 웹에서는 기능을 제한하거나 안내 문구를 보여주고, Android 앱에서는 실제 기능이 동작하도록 구성할 수 있습니다.
즉, OAuth 로그인은 google_sign_in을 이용한 Android 앱 중심 흐름으로 구현하고, kIsWeb은 웹과 앱의 기능 차이를 처리하기 위한 보조적인 분기 기준으로 사용하는 방향이 적절하다고 판단했습니다.
실제 구현하기
프론트 테스트하기
1. pubspec.yaml에 라이브러리 추가
각자 flutter, Dart 버전에 따라서 맞추어 설정해주시면 될 것 같습니다. 저의 경우 버전이 낮아서 ^6.1.4 버전을 택했습니다.
dependencies:
flutter:
sdk: flutter
google_sign_in: ^6.1.4 # google OAuth 사용을 위한 라이브러리 추가
http: ^0.13.6 # flutter에서 백엔드로 요청을 보내기 위해 추가
# 터미널에 flutter pub get으로 라이브러리 추가 설정하기
2. OAuth 로그인과 백엔드 인증 연동을 위한 서비스 로직 추가
import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:dio/dio.dart' as dio;
import '../api/dio_client.dart';
class GoogleAuthService {
final GoogleSignIn _googleSignIn = GoogleSignIn(
scopes: ['email', 'profile'],
serverClientId: '<OAuth Web Client ID>',
);
Future<GoogleSignInAccount?> signInWithGoogle() async {
try {
await _googleSignIn.signOut();
final GoogleSignInAccount? user = await _googleSignIn.signIn();
return user;
} catch (e) {
debugPrint('구글 로그인 오류: $e');
return null;
}
}
Future<dio.Response> loginWithBackend(String idToken) async {
final response = await DioClient.dio.post(
'/api/auth/google',
data: {
'idToken': idToken,
},
options: dio.Options(
extra: {
'noAuth': true,
},
),
);
return response;
}
Future<void> signOut() async {
await _googleSignIn.signOut();
}
Future<void> disconnect() async {
await _googleSignIn.disconnect();
}
}
- serverClientId에는 백엔드 토큰 검증에 사용할 OAuth Web Client ID를 설정합니다.
- loginWithBackend()를 통해 Google idToken을 서버로 전달하고, 서버는 검증 후 서비스 JWT를 발급합니다.
3. 로그인 페이지 수정하기
제 경우 JWT+Spring Security를 활용한 로그인 페이지가 따로 존재하기에 해당 페이지 아래에다가 코드를 추가했습니다.
final _googleAuthService = GoogleAuthService();
...
Future<void> _loginWithGoogle() async {
FocusScope.of(context).unfocus();
setState(() => _loading = true);
try {
final user = await _googleAuthService.signInWithGoogle();
if (!mounted) return;
if (user == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('구글 로그인이 취소되었거나 실패했습니다.')),
);
return;
}
final auth = await user.authentication;
final idToken = auth.idToken;
if (idToken == null || idToken.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Google ID Token을 가져오지 못했습니다.')),
);
return;
}
final response = await _googleAuthService.loginWithBackend(idToken);
final accessToken = response.data['accessToken'];
if (accessToken == null || accessToken.toString().isEmpty) {
throw Exception('서버에서 accessToken을 받지 못했습니다.');
}
await TokenStorage.saveAccessToken(accessToken);
DioClient.clearTokenCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'구글 로그인 성공\n이름: ${user.displayName ?? "이름 없음"}\n이메일: ${user.email}',
),
),
);
Navigator.pushReplacementNamed(context, '/home');
} catch (e) {
if (!mounted) return;
debugPrint('구글 로그인 실패: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('구글 로그인 실패: $e')),
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
- _loginWithGoogle()는 Google 로그인 성공 후 user.authentication을 통해 idToken을 발급받고, 이 값을 벡엔드로 전달합니다.
- 백엔드는 전달받은 Google idToken을 검증한 뒤 서비스 자체 accessToken을 반환합니다. Flutter에서는 response.data['accessToken']으로 토큰을 꺼내 로그인 상태 유지에 사용합니다.
- 발급받은 accessToken을 TokenStorage에 저장하고, DioClient의 토큰 캐시를 초기화합니다. 이후 API 요청에서는 저장된 토큰이 Authorization 헤더에 자동으로 포함되어 일반 로그인과 동일하게 인증 상태가 유지됩니다.
4. 테스트하기
이후 Android를 통해서 테스트 할 경우 아래와 같이 이름과 이메일 값이 들어가는 로그가 보이게 됩니다.(값은 다 삭제했습니다.)

여기까지 프론트엔드에서 Google OAuth 연동이 정상적으로 동작하는 것을 확인했습니다. 이제는 배겐드와 연동하여 로그인한 사용자 정보를 서비스 계정과 연결하고, 마이페이지를 포함한 기능 전반에서 인증 상태가 유지될 수 있도록 구현해보겠습니다.
백엔드 연동하기
앞서 Google OAuth 연동 테스트가 정상적으로 동작한 것을 확인했습니다. 백엔드 연동하기에 앞서 단순히 로그인 기능을 붙이는 것을 넘어 이 인증 결과를 서비스의 로그인 구조와 어떻게 연결할지 먼저 정리할 필요가 있습니다.
1. 사용자를 무엇으로 식별할 것인가?
현재 제 프로젝트 JWT는 사용자 이메일과 ID를 기반으로 생성되며, 실제 인증 처리에서는 토큰에서 사용자 ID를 꺼내 인증 정보를 가져오는 구조로 구현되어 있습니다.
즉, 서비스 내부에서는 이미 user.id를 중심으로 인증 흐름이 구성되어 있기에 Google 로그인 도입 이후에도 최종적인 서비스 사용자 식별은 기존과 동일하게 user.id를 기준으로 유지하는 것이 자연스럽다고 판단했습니다.
하지만 Google 로그인은 외부 인증이기에 서비스 내부 식별자와는 별도의 Google 계정을 구분할 수 있는 값 또한 함께 고려해야 했습니다.
2. 회원 테이블 구조를 어떻게 가져갈 것인가?
기존 회원 테이블은 id, 이메일, 비밀번호, 이름, 역할, 제재 상태, 제재 종료일로 구성 되어 있습니다.
Google 로그인을 도입하면서 별도의 소셜 계정 테이블을 분리하는 방식도 고려할 수 있었습니다. 다만 현재 프로젝트에서는 하나의 사용자가 여러 소셜 계정을 연결하는 구조가 아니라, 일반 로그인 또는 Google 로그인 중 하나의 방식으로 서비스를 이용하는 단순한 인증 구조입니다.
따라서 별도 테이블을 추가하기보다 기존 User 테이블에 provider와 providerId 컬럼을 추가하여 소셜 로그인 정보를 함께 관리하는 방식으로 정리했습니다.
3. 구글 로그인 시 회원가입/로그인 흐름을 어떻게 할 것인가?
Google 로그인을 사용하는 사용자는 최초 로그인 시 자동 회원가입 처리하는 방식으로 고려하고 있습니다.
이미 Google 계정 인증이 완료된 사용자인 만큼 별도의 회원가입 절차를 강제로 한 번 더 거치게 하기보다는 로그인 성공 후 필요한 최소 정보를 바탕으로 바로 회원가입 및 로그인을 이뤄지는 흐름이 더 자연스럽다고 생각했습니다.
4. 백엔드에서 무엇을 검증할 것인가?
프론트엔드에서 Google 로그인에 성공하여도 백엔드는 Google 토큰이 실제로 유효한지, 만료되지 않았는지, 해당 애플리케이션을 대상으로 발급된 값이 맞는지 등을 직접 검증해야 한다.
5. 로그인 성공 후 우리 서비스 인증은 무엇으로 유지할 것인가?
기존 프로젝트에서 쿠키 기반 유틸 클래스가 존재하지만 실제 인증 흐름은 JWT 기반으로 구성되어 있습니다.
따라서 Google 로그인 도입 이후에 인증 유지 방식을 새로 바꾸는 것 보다 기존과 동일한 JWT를 유지하는게 좋겠다고 판단했습니다.
그렇다면 이와 같은 고려 사항을 유의하며 개발을 진행해보겠습니다.
1. build.gradle에 라이브러리 추가

웹 로그인 리다이렉트 방식은 당장 구현할 계획은 없으나 앱 배포 후 웹으로도 OAuth를 발급 받을 예정이라서 냅뒀습니다.
프론트에서 Google 로그인 SDK를 사용해 ID 토큰을 얻을 후 토큰을 백엔드에서 검증하는 과정이기에 토큰 검증을 위해
api-client:google-api-client와 http-client:google-http-client-gson을 추가했습니다.
2. application.yml 파일 수정
Spring OAuth2 리다이렉트 설정이 아닌 Google ID 토큰 검증에 필요한 값을 넣으면 되니 아래와 같이 작성하면 됩니다.
google:
oauth:
web-client-id: 여기에_웹_애플리케이션_클라이언트_ID
jwt:
issuer: issuer값
secret-key: secret_key값
3. 요청 DTO 작성하기
프론트에서 보내는 값 중 idToken만을 받아서 처리하도록 진행하겠습니다.

4. Google 토큰 검증 서비스 만들기
application.yml에 넣은 google.oaut.web-client-id 값을 주입받아 프론트가 보낸 idToken 을 검증한다.
@Component
public class GoogleTokenVerifier {
@Value("${google.oauth.web-client-id}")
private String webClientId;
public GoogleIdToken.Payload verify(String idTokenString) {
try {
idTokenString = idTokenString.trim();
String clientId = webClientId.trim();
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
GsonFactory.getDefaultInstance()
)
.setAudience(Collections.singletonList(clientId))
.setIssuers(Arrays.asList(
"accounts.google.com",
"https://accounts.google.com"
))
.build();
GoogleIdToken idToken = verifier.verify(idTokenString);
if (idToken == null) {
throw new IllegalArgumentException("유효하지 않은 Google ID 토큰입니다.");
}
GoogleIdToken.Payload payload = idToken.getPayload();
return payload;
} catch (Exception e) {
throw new IllegalArgumentException("Google 토큰 검증 실패", e);
}
}
}
- GoogleTokenVerifier는 Flutter에서 전달받은 Google ID Token이 실제 Google에서 발급된 유효한 토큰인지 검증하는 클래스입니다. 검증에 성공하면 토큰 안에 담긴 사용자 정보 Payload를 반환합니다.
- webClientId는 Google ID Token의 audience 검증 기준으로 사용됩니다. Flutter의 serverClientId와 백엔드의 google.oauth.web-client-id 값이 동일해야 하며, 토큰의 aud 값이 이 clientId와 일치해야 검증에 성공합니다.
- GoogleIdTokenVerifier는 토큰의 audience, issuer, 만료 시간, 서명 등을 검증합니다. 검증 결과가 null이면 유효하지 않은 토큰으로 판단하고 예외를 발생시킵니다.
5. 서비스 로직 작성
@Service
@RequiredArgsConstructor
@Transactional
public class GoogleAutheService {
private final GoogleTokenVerifier googleTokenVerifier;
private final UserRepository userRepository;
private final TokenProvider tokenProvider;
public TokenResponse loginWithGoogle(String idToken){
GoogleIdToken.Payload payload = googleTokenVerifier.verify(idToken);
String providerId = payload.getSubject(); //Google 고유 식별값
String email = payload.getEmail();
String name = (String) payload.get("name");
User user = userRepository.findByProviderAndProviderId("GOOGLE",providerId)
.orElseGet(()-> registerGoogleUser(email, name, providerId));
String accessToken = tokenProvider.generateToken(user, Duration.ofHours(2));
return new TokenResponse(accessToken);
}
private User registerGoogleUser(String email, String name, String providerId){
User user = User.builder()
.email(email)
.name(name != null ? name : "Google User")
.password(UUID.randomUUID().toString()) //일반 로그인 비밀번호와 분리된 임의의 값
.role(Role.USER)
.provider("GOOGLE")
.providerId(providerId)
.build();
return userRepository.save(user);
}
}
- GoogleAutheService는 Google ID Token 검증 이후, 서비스 로그인 처리를 담당하는 클래스입니다. Flutter에서 전달받은 idToken을 GoogleTokenVerifier로 검증하고, 검증된 Payload에서 Google 사용자 정보를 추출합니다.
- provider와 providerId를 기준으로 기존 Google 가입 사용자인지 확인합니다. 기존 사용자가 있으면 해당 사용자를 그대로 사용하고, 없으면 Google 계정 정보를 기반으로 신규 사용자를 자동 등록합니다.
- 사용자 확인 또는 등록이 완료되면, 기존 자체 로그인과 동일하게 서비스 JWT accessToken을 발급합니다. 이 accessToken을 Flutter에 반환하여 이후 API 요청에서 로그인 상태를 유지할 수 있도록 합니다.
6. 레파지토리 작성

7. 유저 엔티티 작성

8. 토큰 응답 dto 작성

이후 확인하면 OAuth가 잘 적용된 걸 확인할 수 있습니다.
OAuth 연동 오랜만에 해보는데 모바일로 해보는건 처음이라서 많은 고민의 시간이 필요했던 것 같습니다. 트러블슈팅 과정을 상세히 작성하고 싶었는데 설명이 부족했다고 느껴지네요...
다음에는 더 좋은글로 돌아오는걸 기약하며, 아마 다음 시간에는 만보기 API 내용을 작성할 것 같습니다. 언젠가 사용자가 많은 환경에서 대규모 트래픽에 관련해서 병목 현상 성능 이슈를 직접 해결하는 경험을 작성해보고 싶네요.
이상으로 OAuth 연동에 대한 얘기는 마치겠습니다.