SSAFY 팀 프로젝트 Let's Git It에서 구글 소셜 로그인을 구현하면서 정리한 내용입니다.
Let's Git It 프로젝트는 Git 명령어를 게임으로 배우는 WebSocket 기반 멀티플레이어 서비스다. 인증 시스템으로 JWT + Redis 조합을 사용 중인데, 사용자 편의를 위해 구글 소셜 로그인을 추가했다.
기존에 이미 이런 구조가 잡혀 있었다.
Member 엔티티에 provider, providerId, authType 필드 존재Provider enum에 GOOGLE 정의AuthType enum에 LOCAL, OAUTH 정의/api/v1/auth/token 엔드포인트가 TODO 상태로 대기 중즉, 처음부터 OAuth를 염두에 두고 설계된 구조라 비교적 깔끔하게 붙일 수 있었다.
처음엔 프론트엔드가 구글 OAuth URL로 직접 이동 → authorization code를 백엔드로 전달하는 방식을 검토했는데, API 명세를 보고 방향을 바꿨다.
최종 채택 방식: Spring Security OAuth2 Client + UUID 임시코드
① GET /api/v1/oauth2/authorization/google
↓ Spring Security가 구글 로그인 URL로 자동 리다이렉트
② 구글 로그인 완료 → 구글이 /login/oauth2/code/google 으로 콜백
↓ Spring Security + CustomOAuth2UserService 자동 처리
③ OAuth2SuccessHandler 실행
- 구글 유저 정보로 Member 조회 or 신규 생성
- UUID 임시코드 발급 → Redis 저장 (TTL 30초)
- 프론트로 리다이렉트: /auth/callback/google?code={UUID}
↓ 프론트가 UUID 수신
④ POST /api/v1/auth/token { "code": "UUID" }
- Redis에서 UUID로 email 조회
- JWT 발급 → LoginResponse 반환
왜 UUID 임시코드를 중간에 넣었나?
구글의 authorization code를 프론트가 그대로 백엔드로 전달하면 되지 않냐는 생각이 들 수 있다. 하지만 그 방식은 구글 code가 URL에 노출되는 구간이 생기고, 백엔드가 구글 API를 직접 호출하는 RestTemplate 코드가 필요하다. UUID 임시코드 방식은 구글 흐름을 백엔드가 완전히 처리하고, 프론트엔드에는 30초짜리 일회용 코드만 넘겨준다. 책임 분리가 깔끔하다.
이미 준비되어 있던 ofOAuth() 팩토리 메서드 덕분에 신규 회원 생성 코드가 단순했다.
public static Member ofOAuth(String email, Provider provider, String providerId) {
Member member = new Member();
member.email = email;
member.provider = provider;
member.providerId = providerId;
member.authType = AuthType.OAUTH;
member.onboardingStatus = OnboardingStatus.NONE;
return member;
}
기존 redis-auth 인스턴스(포트 6379)를 그대로 활용했다. 임시코드 키 패턴만 추가:
auth:oauth:tempcode:{UUID} → email값 (TTL: 30초)
기존 키 패턴과 네이밍 컨벤션을 통일했다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
redirect-uri: "{baseUrl}/login/oauth2/code/google"
scope:
- email
- profile
oauth2:
frontend-redirect-uri: ${FRONTEND_REDIRECT_URI:http://localhost:5173/auth/callback/google}
Google은 Spring Security의 well-known provider라 provider 블록을 별도로 정의하지 않아도 자동 설정된다.
구글에서 유저 정보를 받아 Member를 조회하거나 신규 생성한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberService memberService;
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
Map<String, Object> attributes = oAuth2User.getAttributes();
String providerId = (String) attributes.get("sub");
String email = (String) attributes.get("email");
// 신규면 자동 가입, 기존이면 조회
memberService.findOrCreateOAuthMember(email, Provider.GOOGLE, providerId);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
attributes,
"sub"
);
}
}
가장 핵심적인 부분이다. 구글 로그인 성공 후 JWT를 바로 발급하지 않고 UUID 임시코드를 Redis에 저장한 뒤 프론트로 리다이렉트한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final AuthRedisRepository authRedisRepository;
@Value("${oauth2.frontend-redirect-uri}")
private String frontendRedirectUri;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = (String) oAuth2User.getAttributes().get("email");
// UUID 임시코드 발급 → Redis 저장 (TTL: 30초)
String tempCode = UUID.randomUUID().toString();
authRedisRepository.saveOAuthTempCode(tempCode, email);
// 프론트로 리다이렉트
String redirectUrl = UriComponentsBuilder.fromUriString(frontendRedirectUri)
.queryParam("code", tempCode)
.build().toUriString();
response.sendRedirect(redirectUrl);
}
}
// AuthRedisRepositoryImpl.java 추가
private String oauthTempCodeKey(String code) {
return "auth:oauth:tempcode:" + code;
}
@Override
public void saveOAuthTempCode(String code, String email) {
authStringRedisTemplate.opsForValue()
.set(oauthTempCodeKey(code), email,
AuthConstants.OAUTH_TEMP_CODE_TTL_SECONDS, TimeUnit.SECONDS);
}
@Override
public String getOAuthTempCode(String code) {
return authStringRedisTemplate.opsForValue().get(oauthTempCodeKey(code));
}
@Override
public void deleteOAuthTempCode(String code) {
authStringRedisTemplate.delete(oauthTempCodeKey(code));
}
@Override
@Transactional
public AuthResponse.LoginResponse loginWithOAuth(String tempCode, HttpServletResponse response) {
// 1. Redis에서 임시코드로 email 조회
String email = authRedisRepository.getOAuthTempCode(tempCode);
if (email == null) {
throw new BusinessException(ErrorCode.INVALID_AUTH_CODE);
}
// 2. 임시코드 즉시 삭제 (1회용)
authRedisRepository.deleteOAuthTempCode(tempCode);
// 3. Member 조회
Member member = memberService.findByEmail(email);
String memberId = member.getId().toString();
// 4. 기존 토큰 블랙리스트 처리 (동시접속 차단)
// ... 기존 로컬 로그인과 동일한 로직
// 5. JWT 발급 + Cookie 세팅
String accessToken = jwtProvider.createAccessToken(email);
String refreshToken = jwtProvider.createRefreshToken(email);
// ... RT HttpOnly Cookie 세팅
return AuthResponse.LoginResponse.from(member, accessToken);
}
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint ->
endpoint.baseUri("/api/v1/oauth2/authorization"))
.redirectionEndpoint(endpoint ->
endpoint.baseUri("/login/oauth2/code/*"))
.userInfoEndpoint(userInfo ->
userInfo.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler))
authorizationEndpoint.baseUri를 /api/v1/oauth2/authorization으로 설정해서 API 명세의 GET /api/v1/oauth2/authorization/google 진입점을 맞췄다.
프론트엔드가 리다이렉트를 받는 순간 즉시 토큰 교환 API를 호출하는 구조라 30초면 충분하다. 너무 길면 코드 탈취 위험이 있고, 너무 짧으면 네트워크 지연 상황에서 실패할 수 있다.
로컬 로그인에서 이미 구현된 "기존 AT/RT 블랙리스트 등록 → 새 토큰 발급" 흐름을 OAuth 로그인에서도 그대로 재사용했다. 소셜 로그인 사용자도 동시접속 차단 대상이므로 일관성 있는 처리가 가능하다.
OAuth 로그인은 별도의 회원가입 단계가 없다. findOrCreateOAuthMember()에서 providerId로 조회해 없으면 자동 생성, 있으면 바로 반환한다. 닉네임은 null 상태로 생성되고 이후 온보딩에서 설정하는 구조다.
OAuth 흐름은 브라우저 리다이렉트가 필수라 Swagger만으로는 전체 테스트가 불가능하다. 두 단계로 나눠서 진행했다.
Step 1. 브라우저에서 진입
http://localhost:8080/api/v1/oauth2/authorization/google
Step 2. 구글 로그인 완료 후 주소창에서 임시코드 복사
http://localhost:5173/auth/callback/google?code=550e8400-xxxx
프론트가 없으면 연결 오류 페이지가 뜨지만, 주소창 URL에 ?code=값은 표시된다.
Step 3. Swagger에서 토큰 교환 (30초 이내)
POST /api/v1/auth/token
{ "code": "복사한_UUID값" }
DB에 Member 레코드가 생성되고 JWT가 발급되면 성공.
Spring Security OAuth2 Client를 활용하면 구글과의 토큰 교환, 유저 정보 조회 같은 저수준 HTTP 통신은 프레임워크가 처리해준다. 직접 구현해야 하는 건 "로그인 성공 후 우리 서비스에 맞는 처리"뿐이다.
UUID 임시코드 패턴은 백엔드가 OAuth 흐름을 완전히 제어하면서 프론트엔드에는 단순한 인터페이스만 노출할 수 있어 만족스러운 설계였다.
다음 포스팅에서는 WebSocket STOMP 기반 실시간 게임 서버 설계를 다룰 예정이다.
Let's Git It — Git 명령어를 게임으로 배우는 WebSocket 멀티플레이어 프로젝트
SSAFY 14기 A304팀