문제상황 - SuccessHandler에 몰린 책임
처음에는 OAuth2 로그인 후, SuccessHandler 에서 쿠키 유무를 통해 로그인 / 계정 연동 여부를 판별했다
기존 분기 방식
•
쿠키(RefreshToken)가 있을 경우
◦
이미 로그인된 상태에서 다른 로그인을 수행 → 계정 연동 요청으로 판단
◦
쿠키에 담긴 RefreshToken을 파싱 → userId 추출, 해당 유저에게 기존에 연동된 OAuth 정보가 없다면 계정 연동 수행
•
쿠키(RefreshToken)이 없을 경우
◦
메인 로그인 시도로 간주
◦
OAuth 정보를 기반으로 기존 연동 여부를 확인하고 로그인 수행
문제 요약
문제점 | 설명 |
1. SuccessHandler의 책임 과중 | 인증, 연동, 쿠키 파싱 등 모든 흐름을 한 곳에서 처리함 |
2. 쿠키 기반 흐름 분기 | 클라이언트 환경에 따라 쿠키 유무가 달라질 수 있어 흐름 예측이 어려움 |
3. 조건 분기 증가로 인한 유지보수 난이도 증가 | 새로운 시나리오 추가 시 전체 흐름을 수정해야 할 수 있음 |
실제 코드 예시
// SuccessHandler에서 메인 OAuth(FT)를 제외한 다른 OAuth로그인 시 호출되는 메서드
public OauthResult handleExternalOAuthLogin(CustomOAuth2User oauth2User,
HttpServletRequest request, HttpServletResponse response) {
...
// ✅ 관리자 로그인 시에도 쿠키에 의존
if (securityPathPolicy.isAdminContext()) {
cookieService.deleteAdminCookie(request.getCookies(), request.getServerName(),response);
return adminAuthService.handleAdminLogin(oauthMail);
}
// ✅ refresh 토큰이 있는 경우 → 계정 연동 플로우
String refreshToken = cookieService.getCookieValue(req, JwtTokenConstants.REFRESH_TOKEN);
if (refreshToken != null) {
return oauthLinkFacadeService.handleLinkUser(request, oauth2User);
}
// ✅ 토큰이 없다면 → 일반 OAuth 로그인 처리
return oauthLoginService.handleLogin(oauth2User);
}
Java
복사
// 계정 연동 시 쿠키 내부의 refresh 토큰 파싱 및 userID 사용
public OauthResult handleNewLinkUser(HttpServletRequest req,
String providerType,
String providerId,
String oauthMail) {
String refreshToken = cookieService.getCookieValue(req, JwtTokenConstants.REFRESH_TOKEN);
if (refreshToken == null) {
throw new SpringSecurityException(ExceptionStatus.NOT_FT_LINK_STATUS);
}
UserInfoDto userInfoDto = jwtService.validateTokenAndGetUserInfo(refreshToken);
User user = userQueryService.getUser(userInfoDto.getUserId());
if (oauthLinkQueryService.isExistByUserId(userInfoDto.getUserId())) {
throw new SpringSecurityException(ExceptionStatus.OAUTH_EMAIL_ALREADY_LINKED);
}
OauthLink connection = OauthLink.of(user, providerType, providerId, oauthMail);
oauthLinkCommandService.save(connection);
return new OauthResult(user.getId(), user.getRoles(), authPolicyService.getProfileUrl());
}
Java
복사
이처럼 쿠키 기반으로 흐름을 제어하다보니, 인증 로직이 하나로 뭉쳐지며 점점 복잡해지고 테스트도 어려워졌다.
그래서 이후 구조에서는 쿠키 대신 OAuth 요청 자체에 의도를 명시적으로 담고, 이를 state 파라미터로 넘겨 처리하는 방식으로 개선을 진행했다.
기존 SuccessHandler의 전체적인 코드
개선 구조 - 명시적 파라미터 기반 흐름 분기
OAuth2 로그인 요청 자체에 “로그인 / 연동”을 명시적으로 표현하는 방식으로 변경했다.
1. 클라이언트 요청 흐름 - 연동용 토큰 발급
@GetMapping("/link/{provider}")
public LinkOauthTokenDto getOauthLinkRedirectUrl(
@AuthenticationPrincipal UserInfoDto userInfoDto,
@PathVariable("provider") String provider) {
return oauthLinkFacadeService.generateRedirectUrl(
new LinkOauthRedirectUrlServiceDto(userInfoDto.getUserId(), provider));
}
public LinkOauthTokenDto generateRedirectUrl(
LinkOauthRedirectUrlServiceDto linkOauthRedirectUrlServiceDto) {
Long userId = linkOauthRedirectUrlServiceDto.getUserId();
Claims claims = Jwts.claims();
claims.put("userId", userId);
... 필요하면 정보 더 추가
String stateToken = jwtService.generateToken(claims, (long) 30 * 60 * 1000);
return new LinkOauthTokenDto(stateToken);
}
Java
복사
2. Resolver 커스터마이징 - token 파라미터 해석 & state 구성
@Override
public OAuth2AuthorizationRequest customize(HttpServletRequest request) {
...
Claims claims = Jwts.claims();
String token = request.getParameter("token");
if (token != null) {
claims = jwtService.validateAndParseToken(token);
}
... 필요시 다른 파라미터 정보들을 claims에 추가
String stateToken = jwtService.generateToken(claims, (long) 30 * 60 * 1000);
return OAuth2AuthorizationRequest
.from(oauth2Request)
.state(stateToken)
.build();
}
Java
복사
3. SuccessHandler - state 기반 분기 처리
public OauthResult handleExternalOAuthLogin(CustomOAuth2User oauth2User,
HttpServletRequest request) {
String state = request.getParameter("state");
Claims claims = jwtService.validateAndParseToken(state);
StateInfoDto stateInfo = StateInfoDto.fromClaim(claims);
String oauthMail = oauth2User.getEmail();
// 관리자 로그인
if (stateInfo.isAdminContext()) {
return adminAuthService.handleAdminLogin(oauthMail);
}
// 신규 계정 연동
if (stateInfo.isConnectionMode()) {
return oauthLinkFacadeService.handleNewLinkUser(oauth2User, stateInfo);
}
// 일반 로그인
Optional<OauthLink> oauthLink = oauthLinkQueryService.findByProviderIdAndProviderType(oauth2User.getName(), oauth2User.getProvider());
if (oauthLink.isPresent()) {
return oauthLinkFacadeService.handleExistingLinkedUser(oauthLink.get());
}
throw new SpringSecurityException(ExceptionStatus.NOT_FT_LINK_STATUS);
}
Java
복사
기존에는 쿠키 상태에 따라 로그인 / 연동 여부를 분기했지만, 이제는 요청 의도를 명시적으로 표현하고, 그 흐름을 state 파라미터 기반으로 처리하며 인증 흐름이 훨씬 깔끔해졌다.
새로운 흐름 요약
1.
FE가 연동 시도 시
•
/v5/auth/link/{provider} 요청 → BE는 현재 유저 정보를 바탕으로 userId + 연동 flag를 담은 JWT 생성 ⇒ JSON 응답
2.
FE는 해당 JWT를 갖고 Redirect
•
be_domain/oauth2/authorization/{provider}?token=ㅇㅅㅇ 으로 OAuth2 로그인 진입
3.
Custom OAuth2AuthorizationRequestResolver
•
token 파라미터가 있다면 계정 연동으로 판단, state 파라미터에 필요한 정보(userId, 연동 여부)를 토큰으로 만들어 담는다
4.
기존 Security 흐름으로 OAuth 인증 수행
5.
SuccessHandler 에서는 state 값만 보고 분기 처리
•
쿠키 의존성 제거, 상태 기반 판단으로 변경
+) RefreshToken은 로그인 성공 시 클라이언트에게 쿠키 방식으로 넘기지 않고, Redis에 사용중인 토큰들만 저장하는 방식으로 관리해 로그아웃 및 세션관리를 집중시켰다.
마무리
OAuth 로그인 구현 초창기부터 유저에 대한 정보나 분기 처리를 파라미터로 구분하고싶었는데, 커스텀 파라미터를 넣어도 리다이렉트 후에는 모두 무시가되어 걍 파라미터 처리로는 안되는건가.. 싶어 일단 쿠키로 로그인 처리를 진행했다.
state 파라미터의 동작
state 파라미터 존재에 대해서는 알고있었는데, CSRF 방지를 위해 사용한다는 것으로 어렴풋이 알고있어 유저의 데이터를 커스텀해서 넘겨도 되는걸까? 하는 생각이 들었다.
조금 더 깊게 공부를 해보고, Spring Security 공식 문서들을 참고했을 때 Spring Security는 state 값을 자동으로 생성해 세션에 저장하고, 로그인 성공 시 해당 값이 맞는지 비교하는 방식으로 CSRF를 검수하고 있었다.
Spring security에서 state 파라미터의 사용
표현 | 의미 |
"서버는 state 값을 검증하지 말 것" | OAuth 제공자(Google, Kakao 등)는 이 값을 해석하거나 판단하지 않고, 그대로 되돌려만 줘야 한다 |
누가 해석함? | 클라이언트(우리 앱) |
그럼 우리 앱에서 복호화/파싱해도 되나? |
이 state 부분을 쓰더라도 어느 부분에서 어떻게 커스터마이징 해야할지 고민하다 결국 쿠키로 책임을 떠넘겼었는데…. OAuth2AuthorizationRequestResolver 부분을 커스터마이징하면 됐었다. 역시.. 제공해주는 기능은 개많은데 내가 몰라서 못쓰거나 이상하게 쓰는게 많았다……. 제대로 알고 쓰기가 제일 어렵다………
이번 개선을 통해 느낀건, Spring Security는 생각보다 훨씬 유연하고 개방적인 구조라는 점이다. 단지 내가 잘 몰라서, 혹은 기본 흐름에만 기대서 놓쳤던 포인트들이 많았다.
결국 중요한건 구조를 이해해 어디에서 개입할지 판단하는 능력이고, 그런 선택들이 좀 더 나은 구조를 만드는 것 같다. 앞으로도 복잡한 흐름을 설계해야할 때, 당연한 방식보다는 가장 명확한 전체적인 흐름을 고민해보자….