
구작교 서비스에 OAuth 2.0 기반의 소셜 로그인 기능을 구현했다.
이전 글에서는 JWT + Refresh Token + Redis 수조로 백엔드 인증 인프라를 완성했는데,
이제는 사용자가 별도의 회원가입 없이 간편하게 로그인할 수 있는 흐름을 만들었다.
Google / Kakao / Naver OAuth 2.0 연동
Access Token & Refresh Token 통합 구조 유지
신규 유저 자동 회원가입 / 기존 유저 자동 로그인
인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 어플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준
OAuth는 신뢰할 수 있는 중개자처럼 작동
사용자의 민감한 정보는 보호하면서 필요한 기능은 안전하게 공유
대표적으로 2가지의 구현 방식이 존재한다.
프론트 엔드에서 인가 코드를 받고, 나머지 작업은 서버에서 처리하는 방식
(가장 일반적인 방식)
oauth2-client 의존성을 통해 서버에서 인가 코드, Token 발급, 사용자 요청들을 모두 처리하는 방식
최종적으로 사용자에게 JWT Token을 줄 때,
Redirect 방식을 취할 수 밖에 없어서 보안상 취약점 존재
모든 절차가 라이브러리를 통해 통합되어 있고, 자동화 되어 있어
코드 파악의 어려움과 디버깅의 어려움
인가 코드가 Redirect될 때, 인가코드 요청을 했던 동일 서버가 아닌 경우 문제 발생
위 2가지 방식 중에 GooJakGyo는 첫번째 방식으로 구현했습니다.
GooJakGyo가 분산 서버 환경을 기반으로 개발 중이고 또한 여러 보안상 이점과 Code의 관리 및 디버깅 측면에서 첫번째 방식이 유리하다고 판단했습니다.
@PostMapping("/{provider}/doLogin")
public ResponseEntity<?> oauthLogin(@PathVariable String provider, @RequestBody RedirectDto redirectDto) {
SocialType socialType = SocialType.valueOf(provider.toUpperCase());
// access token 발급 및 사용자 정보 얻기
// 회원가입이 되어 있다면 Access Token 및 Refresh Token 발급
var result = oauthLoginService.loginOrCheck(redirectDto.getCode(), socialType);
// 회원가입이 되어 있지 않다면 추가 정보 입력 후 회원가입
if (result.isNewUser()) {
SocialProfile profile = result.getSocialProfile();
return ResponseEntity.ok(Map.of(
"status", "NEED_OAUTH_CREATE",
"socialId", profile.getSocialId(),
"socialType", profile.getSocialType(),
"name", profile.getName(),
"email", profile.getEmail()
));
}
LoginResDto loginResDto = result.getLoginResDto();
//Refresh Token 쿠키로 설정
ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResDto.getRefreshToken())
.httpOnly(true)
.secure(false) // https 아니면 쿠키가 안들어가므로 개발중엔 false
.sameSite("Strict")
.path("/")
.maxAge(Duration.ofDays(3))
.build();
Map<String, Object> loginInfo = new HashMap<>();
loginInfo.put("memberId", loginResDto.getId());
loginInfo.put("accessToken", loginResDto.getAccessToken());
loginInfo.put("name", loginResDto.getName());
return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).body(loginInfo);
}
@Service
@Transactional
public class OAuthLoginService {
// "GOOGLE", "KAKAO", "NAVER"
private final Map<String, OAuthApiClient> clients;
private final MemberRepository memberRepository;
private final AuthService authService;
public OAuthLoginService(Map<String, OAuthApiClient> clients, MemberRepository memberRepository,
AuthService authService) {
this.clients = clients;
this.memberRepository = memberRepository;
this.authService = authService;
}
public LoginOrProfileResDto loginOrCheck(String code, SocialType socialType) {
OAuthApiClient client = clients.get(socialType.name());
var token = client.requestAccessToken(code);
SocialProfile profile = client.requestProfile(token.getAccess_token());
Member member = memberRepository.findBySocialId(profile.getSocialId()).orElse(null);
if (member == null) {
return LoginOrProfileResDto.builder()
.isNewUser(true)
.socialProfile(profile)
.build();
}
LoginResDto loginResDto = authService.issueTokens(member);
return LoginOrProfileResDto.builder()
.isNewUser(false)
.loginResDto(loginResDto)
.build();
}
}
@Component("GOOGLE")
public class GoogleApiClient implements OAuthApiClient{
private final GoogleService googleService;
public GoogleApiClient(GoogleService googleService) {
this.googleService = googleService;
}
@Override
public AccessTokenDto requestAccessToken(String code) {
return googleService.getAccessToken(code);
}
@Override
public SocialProfile requestProfile(String accessToken) {
GoogleProfileDto googleProfileDto = googleService.getGoogleProfile(accessToken);
return SocialProfile.builder()
.socialId(googleProfileDto.getSub())
.name(googleProfileDto.getName())
.email(googleProfileDto.getEmail())
.socialType(SocialType.GOOGLE)
.build();
}
}
@Slf4j
@Service
public class GoogleService {
@Value("${oauth.google.client-id}")
private String googleClientId;
@Value("${oauth.google.client-secret}")
private String googleClientSecret;
@Value("${oauth.google.redirect-uri}")
private String googleRedirectUri;
public AccessTokenDto getAccessToken(String code) {
// 인가 코드, clientId, client_secret, redirect_uri, grant_type
// Spring6부터 RestTemplate 비추천(Future Deprecate)이기 때문에 RestClient 사용
RestClient restClient = RestClient.create();
// MultiValueMap을 통해 자동으로 form-data 형식으로 body 조립 가능
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("code", code);
params.add("client_id", googleClientId);
params.add("client_secret", googleClientSecret);
params.add("redirect_uri", googleRedirectUri);
params.add("grant_type", "authorization_code");
ResponseEntity<AccessTokenDto> response = restClient.post()
.uri("https://oauth2.googleapis.com/token")
.header("Content-Type", "application/x-www-form-urlencoded")
// ?code=xxxx&client_id=yyyy&
.body(params)
// retrieve:응답 body값만을 추출
.retrieve()
.toEntity(AccessTokenDto.class);
log.info("AccessToken JSON : {}", response.getBody());
return response.getBody();
}
public GoogleProfileDto getGoogleProfile(String token) {
RestClient restClient = RestClient.create();
ResponseEntity<GoogleProfileDto> response = restClient.get()
.uri("https://openidconnect.googleapis.com/v1/userinfo")
.header("Authorization", "Bearer " + token)
.retrieve()
.toEntity(GoogleProfileDto.class);
log.info("Profile JSON : {}", response.getBody());
return response.getBody();
}
}
RestClient
getAccessToken()
getGoogleProfile()
1. [Frontend] → code 전달
2. [MemberController]
3. [OAuthLoginService]
4. [GoogleApiClient]
5. [GoogleService → Google API 호출]
6. [MemberRepository] 회원 존재 여부 확인
7. [AuthService.issueTokens()] JWT + Redis 저장
8. [ResponseCookie] Refresh Token 저장 후 응답
- Google / Kakao / Naver 확장 시 Controller는 그대로 유지
- 비즈니스 로직(Service) 레벨에서만 Provider 교체 가능
- 인증 흐름이 완전히 모듈화 되고 유지보수가 쉬운 구조
OAuth는 문서상 "간단한 인증 위임"으로 보이지만,
직접 구현해보니 각 Provider 별 요청 파라미터, 응답 구조, 정책이 전부 달랐다.
각 서비스 사의 공식 문서를 읽어보며 적용했다.
표준 위에 얹힌 수많은 예외를 핸들링하는 과정이 진짜 OAuth의 핵심이었다.
이번 과정을 통해,
API 통신 구조를 더 깊이 이해하게 되었고
JWT 기반 로그인 구조와 완전히 통합된 OAuth 인증 시스템을 완성할 수 있었다.
이제 사용자는 소셜 로그인 한 번이면
자동으로 Access Token과 Refresh Token을 발급받고
"로그인 -> 인증 -> 토큰 재발급" 전체 흐름이 자동화 된다.