Spring Security 없이 WebClient를 사용해 Google OAuth로그인을 구현한 과정을 정리했습니다.
사용자가 특정 애플리케이션에 개인정보를 제공하지 않고, Google, Naver, Kakao등 외부 서비스에게 인증을 위임하는 방식입니다.
인증 이후 클라이언트 애플리케이션에게 승인된 범위 내의 사용자 정보에 안전하게 접근할 수 있도록 허용하는 표준 인증 프로토콜입니다.
만약 모든 애플리케이션에서 자체 로그인 시스템을 구축한다면, 사용자는 서비스마다 별도로 가입을 해야한다는 불편함을 겪게됩니다. 이는 접근장벽과 사용자 이탈이 늘어날 가능성이 있습니다.
기업의 경우에도 사용자의 정보를 직접 관리해야하기 때문에 보안 리스크 및 비용이 증가하고, 사용자의 경험 단절이라는 문제가 발생합니다.
OAuth를 사용하면 사용자는 Google, Naver, Kakao 등 가입된 서비스 계정으로 로그인을 할 수 있고, 애플리케이션은 승인된 범위 내에서만 사용자 정보를 제공받기 때문에 보안과 편의성 모두 확보할 수 있는 인증 방식입니다.

사용자가 [구글 로그인] 버튼을 클릭시, 클라이언트는 백엔드 서버로 로그인 요청을 보냅니다.
서버는 요청을 받아 Google OAuth 인증 페이지를 리다이렉트합니다.
사용자는 Google 로그인 페이지에서 ID/PW를 입력하고, 로그인이 완료되면 Google은 등록된 리다이렉트 URI로 인가 코드(authorization code)를 전달합니다.
서버는 전달받은 인가 코드를 이용하여 Google OAuth 서버에 Access Token과 Refresh Token을 요청합니다.
토큰이 발급되면, 서버는 이 Access Token을 사용하여 Google에 사용자 정보를 요청합니다.
응답받은 사용자 정보를 바탕으로, 서버는 세션을 생성하고 로그인 상태를 유지합니다.
이후에는 생성된 세션을 통해 인증이 필요한 요청에서도 사용자를 식별할 수 있게 됩니다.
Spring Security는 인증/인가, 세션 관리 등 보안 처리를 자동화해주는 프레임워크입니다.
특히 OAuth2 Client 설정도 매우 간단하게 적용할 수 있다는 장점이 있습니다.
하지만 내부 흐름이 복잡하게 추상화되어 있어 리다이렉트 구분, 사용자 정보 처리 등 세부 흐름 제어에는 제약이 컸습니다.
또한 이번 프로젝트는 로그인 외 별도 보안 기능이 필요하지 않았기 때문에,
OAuth 인증 흐름을 직접 구현하고 명시적으로 제어할 수 있는 WebClient 방식을 선택했습니다.
Spring Security는 OAuth의 인증 과정인 인가 코드 -> 토큰 -> 사용자 정보 요청 를 자동으로 처리해줍니다.
하지만 이번 프로젝트는 Spring Security 없이 구현했기 때문에, OAuth 서버와의 HTTP 통신을 직접 제어할 도구가 필요했습니다.
스프링에서 외부 API 호출을 처리할 수 있는 대표적인 HTTP Client는 다음과 같습니다.
RestTemplate: 동기 방식의 HTTP 클라이언트 입니다. 간단한 요청에 적합하지만, 요청이 많은 환경에서는 응답이 완료될 때까지 쓰레드가 대기 상태에 빠져 다른 처리를 하지 못하므로, 성능 저하가 발생할 수 있습니다.
FeignClient: 선언형 HTTP 클라이언트로, REST API를 정의할 수 있어 마이크로서비스 간 통신에 많이 사용됩니다. 다만, 설정이 다소 무겁고 단순한 외부 API 호출에는 과할 수 있습니다.
WebClient: 비동기 방식의 HTTP 클라이언트 입니다. 요청 시점과 응답 처리 시점을 분리해 효율적으로 동작하며, OAuth처럼 여러 단계의 요청을 유연하게 구성해야 하는 상황에 적합합니다.
따라서 이번 프로젝트에서는 OAuth 인증 흐름의 세부 제어가 가능하고, 논블로킹 방식으로 효율적인 WebClient를 선택했습니다.
WebClient를 사용하기 위해 아래와 같이 spring-boot-starter-webflux 의존성을 추가해야 합니다.
의존성 추가
// gradle.build
implementation 'org.springframework.boot:spring-boot-starter-webflux'
Config설정
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient(){
return WebClient.builder().build();
}
}
OAuth를 사용하기 위해서는 application.yml에 다음과 같은 설정이 필요합니다.
기존 회원가입 로직에서도 역할에 따라 API가 분리했기 때문에,OAuth의 리다이렉트 URI도 사용자 역할에 따라 구성했습니다.
oauth:
google:
# Google에서 발급하는 OAuth 클라이언트 ID
client-id: ${CLIENT_ID}
# OAuth 등록 시 발급되는 클라이언트 시크릿 키
client-secret: ${CLIENT_SECRET_KEY}
# Google 로그인 성공 시 인가 코드를 전달받을 주소
redirect-uris:
director: http://localhost:8080/api/v2/auth/callback/director
user: http://localhost:8080/api/v2/auth/callback/user
admin: http://localhost:8080/api/v2/auth/callback/admin
# Access Token을 발급받는 Google API URL
token-uri: https://oauth2.googleapis.com/token
#Access Token으로 사용자 정보를 요청하는 API 주소
user-info-uri: https://www.googleapis.com/oauth2/v2/userinfo
yml파일에 작성된 Google OAuth 설정 값을 코드 내에서 사용할 수 있도록 @ConfigurationProperties를 이용하여 객체로 바인딩했습니다.
이렇게 구성하면 시크릿 키와 같은 민감한 정보를 안전하게 관리할 수 있고, 오타 없이 안정적으로 재사용하거나 유지보수할 수 있다는 장점이 있습니다.
@ConfigurationProperties(prefix = "oauth.google")
@Getter
public class GoogleOAuthProperties {
private String clientId;
private String clientSecret;
private String tokenUri;
private String userInfoUri;
private RedirectUris redirectUris;
public GoogleOAuthProperties(String clientId, String clientSecret, String tokenUri, String userInfoUri, RedirectUris redirectUris) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenUri = tokenUri;
this.userInfoUri = userInfoUri;
this.redirectUris = redirectUris;
}
해당 프로젝트의 경우 사용자 역할에 따라 리다이렉트 URI가 달라,RedirectUris 내부 클래스를 이용하여 admin, user,director 역할별로 URI를 나눠 관리하도록 설계했습니다.
또한, getRedirectUriByRole() 메서드를 통해 UserRole에 따라 적합한 URI를 반환받기 때문에, 컨트롤러나 서비스 단에서 조건에 따른 분기가 필요없고, URI 관련 로직을 한 클래스에서 일관되게 관리할 수 있다는 장점이 있습니다.
초반에는 RedirectUris 클래스를 별도로 분리할지 고민했습니다.
하지만 RedirectUris는 사용자 역할에 따라 리다이렉트 URI를 그룹화한 설정 객체입니다.
해당 클래스는 GoogleOAuthProperties 만 사용하기 때문에 내부 static 클래스로 선언하는 것이 더 효율적이라고 판단했습니다.
@Getter
public static class RedirectUris {
private String user;
private String director;
private String admin;
public RedirectUris(String user, String director, String admin) {
this.user = user;
this.director = director;
this.admin = admin;
}
}
public String getRedirectUriByRole(UserRole role) {
return switch (role) {
case ADMIN -> redirectUris.getAdmin();
case DIRECTOR -> redirectUris.getDirector();
default -> redirectUris.getUser();
};
}
}
Google OAuth 인증은 크게 두 단계로 이루어집니다.
1. 인가 코드로 Access Token을 요청
2. Access Token으로 사용자 정보를 조회
이를 위해 WebClient를 활용해 Google OAuth API와 직접 통신하는 GoogleOauthClient 클래스를 구성했습니다.
GoogleOAuthProperties를 주입받아 인증 관련 설정 값을 동적으로 처리하며,
각 사용자 역할별로 리다이렉트 URI를 자동으로 설정할 수 있도록 구성하였습니다.
인가 코드, 인증 정보를 포함시켜, Access Token을 발급받는 요청을 보냅니다.
public String getAccessToken(String code, UserRole userRole) {
return webClient.post()
.uri(googleOAuthProperties.getTokenUri())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.bodyValue("code="+code
+"&client_id="+googleOAuthProperties.getClientId()
+"&client_secret="+googleOAuthProperties.getClientSecret()
+"&redirect_uri="+googleOAuthProperties.getRedirectUriByRole(userRole)
+"&grant_type=authorization_code")
.retrieve()
.bodyToMono(Map.class)
.map(response -> (String)response.get("access_token"))
.block();
}
발급받은 Access Token을 이용하여 사용자 정보를 요청합니다.
Google은 이메일, ID 등의 기본 정보를 JSON 형태로 응답하며, 이 값을 OAuthUser 객체에 매핑합니다.
public OAuthUser getUser(String accessToken){
return webClient.get()
.uri(googleOAuthProperties.getUserInfoUri())
.headers(headers -> headers.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(OAuthUser.class)
.block();
}
}
Google 사용자 정보 응답 값을 담는 DTO입니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OAuthUser {
private String id;
private String email;
private OAuthUser(String id, String email) {
this.id = id;
this.email = email;
}
public static OAuthUser toEntity(String id, String email) {
return new OAuthUser(id, email);
}
}
Google OAuth를 통해 정보를 받아올 경우, 기존 회원가입시 입력받던 필드(password, birth, gender)등의 정보가 존재하지 않기 때문에 해당 필드를 기존 필수 입력에서 선택으로 변경하여 처리하였습니다.
또한, 소셜 로그인 사용자인지를 판단하기 위해 oauthId, oauthProvider를 추가했습니다.
@Column
private String password;
@Column
private LocalDate birth;
@Column
private String oauthId;
@Enumerated(EnumType.STRING)
@Column
private OAuth oauthProvider;
Google OAuth를 통해 받아온 이메일을 기준으로 기존 회원인지 여부를 판단합니다.
@Transactional
public User getOrCreateUser(HttpSession session, String code, UserRole userRole) {
String accessToken = googleOauthClient.getAccessToken(code, userRole);
OAuthUser oauthUser = googleOauthClient.getUser(accessToken);
User user = userQueryService.getUserByEmail(oauthUser.getEmail())
.orElseGet( () -> userRepository.save(
User.toOAuthEntity(oauthUser.getEmail(),
userRole,
oauthUser.getId(),
OAuth.GOOGLE
)));
AuthUser authUser = AuthUser.toEntity(user.getId(), user.getUserRole());
session.setAttribute("authUser", authUser);
return user;
}
해당 메서드의 흐름은 다음과 같이 동작합니다.
1. 인가 코드를 이용하여 Access Token 요청 및 사용자 정보 조회
2. 응답 받은 이메일로 사용자 조회
3. 기존 회원인 경우, 세션 생성 및 로그인 처리
4. 신규 회원인 경우, 임시 계정 생성 뒤 추가 정보 입력 대기
기존 사용자
{
"nickname": "홍길동",
"isNewUser": false
}

응답받은 이메일이 DB에 존재하면, 기존 회원으로 판단하여 로그인 처리 및 세션을 생성합니다.
{
"nickname": null,
"isNewUser": true
}

DB에 해당 이메일이 존재하지 않는 경우, 임시 계정을 생성한 뒤 추가 정보 입력을 대기합니다.
신규 사용자는 아래와 같이 추가 정보를 입력한 후, 정식 회원으로 전환됩니다.
{
"nickname": "닉네임",
"birth": "2020-01-01",
"gender": "FEMALE"
}
@Transactional
public void signup(Long id, String nickname, LocalDate birth, Gender gender) {
User user = userQueryService.getUser(id);
user.oAuthSignup(nickname, birth, gender);
}

OAuth는 최소한의 정보만 제공하므로, 닉네임, 생년월일, 성별 등의 데이터는 별도로 입력받아야 합니다.
따라서 이번 프로젝트는 다음과 같이 구성했습니다.
1. OAuth 로그인 시 임시 계정 생성
2. 추가 정보 입력을 통한 정식 가입 완료
OAuth는 인가 코드를 브라우저 리다이렉트를 통해 전달하기 때문에, 테스트는 브라우저 기반으로 진행했습니다.
GET https://accounts.google.com/o/oauth2/v2/auth?
client_id=구글_클라이언트_ID
&redirect_uri= 인가 코드를 전달 받을 uri
&response_type=code
&scope=openid%20email%20profile
&access_type=offline
위 URL로 접근하면 Google 로그인 페이지로 이동되며, 로그인을 완료하면 등록된 redirect URI로 인가 코드가 포함된 요청이 전송됩니다.
http://localhost:8080/api/v2/auth/callback/user?
code=test_code***
email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.
email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.
profile+openid&authuser=1&prompt=none
서버는 인가 코드로 토큰을 요청한 뒤, 사용자 정보를 조회하고 세션을 생성합니다.
이후에는 세션이 유지되므로, 동일한 브라우저 환경에서는 Swagger에서 인증된 상태로 API 요청이 가능합니다.