sellen

SpringBoot - OAuth2 로그인 시 프론트의 파라미터 기억하는 방법 본문

Framework/SpringBoot

SpringBoot - OAuth2 로그인 시 프론트의 파라미터 기억하는 방법

sellen 2026. 3. 4. 22:32

프로젝트를 진행하다가 발생했던 OAuth2 과정을 정리한 글입니다.
만약 코드만 보고 싶으시면 4번 항목으로 이동하시면 됩니다.

1. 문제 상황

저희 프로젝트의 기존 OAuth2 로그인 방식은 프론트와 백엔드가 모두 책임을 가지는 하이브리드 구조였습니다.

하지만 보안 강화를 위해 OAuth2 인증 플로우 전체를 백엔드가 책임지는 구조로 재설계하게 되었습니다.

이 과정에서 다음과 같은 요구사항이 발생했습니다.

  • Localhost 환경에서 OAuth2 로그인
  • 배포 환경(운영 도메인)에서 OAuth2 로그인
  • 모바일 네이티브 앱 환경 지원

OAuth2는 리다이렉트 기반 프로토콜입니다.

즉, 인증 완료 후 서버는 브라우저를 다시 특정 프론트 URL로 리다이렉트해야 합니다.

하지만 서버가 "어떤 프론트 환경에서 로그인 요청이 왔는지" 알아야 올바른 주소로 리다이렉트를 할 수 있습니다.

1-1. 왜 단순 Response Body 응답이 안되는가

REST API를 개발할 때처럼 단순하게 Response Body로 응답하면 되지 않을까?

하지만 OAuth2 방식은 다음과 같은 흐름입니다.

  1. 클라이언트에서 OAuth2 로그인 페이지로 리다이렉트
  2. 인증 완료
  3. OAuth 서버에서 우리 서버로 리다이렉트
  4. 우리 서버에서 최종 프론트로 리다이렉트

위와 같이 페이지 이동 기반 프로토콜이므로 콜백 엔드포인트에서 JSON을 반환하면 브라우저 화면에 그대로 렌더링이 됩니다.

SPA처럼 API 호출로 처리하는 구조가 아닙니다.


1-2. OAuth2 요청 출처 식별 방법 비교

올바른 환경으로 리다이렉팅 하기 위해 서버에서 요청 출처를 식별할 수 있는 방법을 검토했습니다.

  • Origin 헤더
    • OAuth2 요청은 GET 리다이렉트 방식입니다.
    • GET 리다이렉트 요청에는 Origin 헤더가 포함되지 않는 경우가 많습니다.
  • Referer 헤더
    • OAuth2 플로우 특성상 로그인을 하기 위해 OAuth 로그인 페이지로 이동했다 우리 서버로 리다이렉트를 합니다.
    • 이 과정에서 Referer가 사라지거나 OAuth2 로그인 페이지 주소로 변경됩니다.
  • IP 기반 식별
    • 모바일 환경은 IP가 고정되지 않습니다.
    • 로드밸런서 / 프록시 환경에서 변경이 가능합니다.
  • 프론트에서 직접 명시
    • 따라서 프론트에서 OAuth2 요청 시 파라미터로 환경 정보를 명시하도록 설계했습니다.
    • EX) /oauth2/authorization/google?local=true

1-3. 상태 유지 방식 비교

1-2. 과정에서 프론트에서 파라미터를 통해 직접 명시하는 방식을 선택했습니다.

하지만 Referer 헤더처럼 외부 로그인 페이지로 이동했다가 다시 돌아오는 구조이므로 프론트의 요청 파라미터가 소실됩니다.

따라서 파라미터를 서버에서 기억하는 방법을 찾아야만 했습니다.

  • 쿠키에 저장
    • 모바일 네이티브 앱 환경에서 쿠키 사용 제약
    • 직렬화 / 역직렬화 설정 필요
    • 수명 (TTL) 관리 필요 - 로그인 지연 시 쿠키 만료 가능
  • Redis / DB 저장
    • 별도 저장소 관리
    • TTL 관리 필요
    • 직렬화 / 역직렬화 설정 필요 - 로그인 지연 시 TTL 만료 가능
    • UserService와 Handler에서의 잦은 호출 가능성
  • OAuth2 State 파라미터 활용
OAuth2 State 파라미터는 OAuth2 CSRF 공격 방지, 요청 - 응답 매칭, 클라이언트 상태 유지를 위해 사용되는 파라미터입니다.
이 때문에 OAuth2 과정 중 항상 일관된 값을 유지해야 합니다.

이러한 특성을 가지고 있기 때문에 OAuth2 State 파라미터를 사용하여 상태를 유지하고자 했습니다.


2. Spring Security OAuth2 내부 동작 분석

Spring Security는 OAuth2 로그인을 다음 두 필터로 처리합니다.

  1. OAuth2AuthorizationRequestRedirectFilter
  2. OAuth2LoginAuthenticationFilter

2-1. OAuth2AuthorizationRequestRedirectFilter

OAuth2AuthorizationRequestRedirectFilter 시퀀스 다이어그램


2-2. OAuth2LoginAuthenticationFilter

OAuth2LoginAuthenticationFilter 시퀀스 다이어그램


3. 최종 설계

설계는 다음과 같습니다.

  1. 프론트에서 local 파라미터 명시 
  2. CustomAuthorizationRequestResolver에서 local 파라미터를 읽고 state 파라미터 수정
  3. OAuth2 서버 로그인
  4. 콜백 시 state 파라미터로부터 local 값을 추출
  5. UserService와 Handler에서 환경에 맞는 URL로 리다이렉트

4. 구현 코드

4-1. OAuth2ValidationFilter

@Slf4j
@RequiredArgsConstructor
public class OAuth2ValidationFilter extends OncePerRequestFilter {

    private final ClientRegistrationRepository clientRegistrationRepository;
    private final Oauth2Handler oauth2Handler;
    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        String uri = request.getRequestURI();

        // OAuth2 로그인 요청일 경우 검증
        if (uri.contains(/oauth/authorization)) {
            if (!validateClientRegistration(uri, request, response)) return;
        }

        filterChain.doFilter(request, response);
    }

    // 허가된 OAuth2 요청인지 검증하는 메서드
    private boolean validateClientRegistration(
        String uri,
        HttpServletRequest request,
        HttpServletResponse response
    ) throws IOException {
        String registrationId = uri.substring(uri.lastIndexOf("/") + 1);
        ClientRegistration client = clientRegistrationRepository.findByRegistrationId(registrationId);

        if (client == null) {
            CustomErrorCode code = CustomErrorCode.NOT_SUPPORTED_SERVER;
            log.warn("Client Registration Id: [{}] - {}", registrationId, code);
            redirectStrategy.sendRedirect(request, response, createRedirectUrl(request, code));

            return false;
        }
        return true;
    }
    
    // 리다이렉트 URL 생성 메서드 - Referer 헤더를 통해 리다이렉트할 URL을 결정
    // 만약 Referer가 없을 경우 운영 사이트로 리다이렉트
    private String createRedirectUrl(HttpServletRequest request, CustomErrorCode code) {
        String referer = request.getHeader("Referer");
        return oauth2Handler.getErrorUrlWithReferer(code, referer);
    }
}

위 코드는 우리 서버에 등록된 OAuth2 요청인지 검증하는 필터입니다.

만약 프론트의 파라미터를 검증하는 로직을 추가하고 싶으시면 해당 필터에서 구현하시면 됩니다.

Resolver에선 예외가 발생하면 해당 예외를 캐치할 방법이 없기 때문입니다.


4-2. CustomOAuth2AuthorizationRequestResolver

@Slf4j
public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {

    private final OAuth2AuthorizationRequestResolver defaultResolver;

    public CustomOAuth2AuthorizationRequestResolver(
        ClientRegistrationRepository clientRegistrationRepository
    ) {
        this.defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(
            clientRegistrationRepository, "/oauth/authorization"
        );
    }

    @Override
    public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
        OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request);
        return saveParamsAndReturn(authorizationRequest, request);
    }

    @Override
    public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
        OAuth2AuthorizationRequest authorizationRequest = 
            defaultResolver.resolve(request, clientRegistrationId);
        return saveParamsAndReturn(authorizationRequest, request);
    }

    // 프론트의 OAuth2 로그인 요청으로부터 파라미터를 추출하여 state 파라미터를 재생성하는 메서드
    private OAuth2AuthorizationRequest saveParamsAndReturn(
        OAuth2AuthorizationRequest authorizationRequest,
        HttpServletRequest request
    ) {
        if (authorizationRequest == null) return null;

        // 1. OAuth2 파라미터 값 추출
        String local = request.getParameter("local") == null ? "false" : "true";

        // 2. OAuth2RedirectFilter에서 생성한 state에 프론트에서 요청한 파라미터를 덧붙임
        String state = authorizationRequest.getState() + "." + local;

        return OAuth2AuthorizationRequest.from(authorizationRequest)
            .state(state)
            .build();
    }
}

저는 local 파라미터가 존재하면 true, 아니면 false를 state 파라미터에 덧붙이는 방식으로 구현을 했습니다.

만약 기존의 state 파라미터 값에 '.' 문자가 존재하면 문제가 발생하는 건 아닌가?
public final class DefaultOAuth2AuthorizationRequestResolver 
    implements OAuth2AuthorizationRequestResolver {
        /* 생략 */

        private static final StringKeyGenerator DEFAULT_STATE_GENERATOR = 
            new Base64StringKeyGenerator(Base64.getUrlEncoder());

        /* 생략 */
}
  • 이건 Spring Security의 기존 Resolver입니다.
  • 임의의 문자열을 Base64 문자열로 변환하는 방식이기 때문에 state 파라미터는 알파벳 대소문자와 숫자로만 구성이 됩니다.
  • 따라서 "." 같은 문자를 구분자로 사용할 수가 있습니다.
    • - _ . ~ 이 4개의 문자는 URL Safe 문자이기 때문에 구분자로 사용하셔도 무방합니다.
    • 그 이외의 문자 같은 경우 URL을 통해 OAuth2 리다이렉팅 하면서 변경될 가능성이 존재하기 때문에 추천드리지 않습니다. 

4-3. OAuth2UserService

@Slf4j
@Service
@RequiredArgsConstructor
public class OAuth2UserService extends DefaultOAuth2UserService {

    private final OAuth2UserFacade oAuth2UserFacade;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {

        // 1. OAuth2 서버로 부터 받은 User 정보 추출
        OAuth2User oAuth2User = loadOAuth2User(request);
        // 2. 요청한 OAuth2 서버 타입 추출
        String registrationId = request.getClientRegistration().getRegistrationId();

        // 3. OAuth2 User 정보에서 필요한 값만 추출하여 OAuth2Response로 변환
        OAuth2Response oAuth2Response = createOAuth2Response(registrationId, oAuth2User);

        // 4. 로그인 로직 수행 및 SuccessHandler로 전달할 DTO 생성
        return loginUser(oAuth2Response);
    }

    // UnitTest를 편의성을 위해서 따로 헬퍼메서드로 분리하였습니다.
    protected OAuth2User loadOAuth2User(OAuth2UserRequest request) {
        return super.loadUser(request);
    }

    // OAuth2 서버로부터 조회한 User 정보에서 필요한 값만 추출
    private OAuth2Response createOAuth2Response(String registrationId, OAuth2User oAuth2User) {
        OauthServerType serverType = OauthServerType.fromName(registrationId);
        OAuth2Response response = serverType.create(oAuth2User.getAttributes());

        if (response.getName() == null && response.getNickname() == null) {
            throw new OAuth2Exception(CustomErrorCode.MISSING_NAME_NICKNAME);
        }

        return response;
    }

    // User 로그인 로직 메서드
    private CustomUserDetails loginUser(OAuth2Response response) {
        User user;
        try {
            user = oAuth2UserFacade.login(response);
        } catch (Exception e) {
            throw new OAuth2Exception(CustomErrorCode.INTERNAL_SERVER_ERROR, e);
        }

        return CustomUserDetails.builder()
            .id(user.getId())
            .role(Role.ROLE_USER)
            .name(user.getName())
            .status(user.getStatus())
            .build();
    }
}

만약 UserService에서 state를 파라미터를 사용해야 한다면 아래의 방법으로 state 값을 추출할 수 있습니다.

private boolean isLocal() {
    // state 파라미터 추출
    RequestAttributes attributes = RequestContextHolder.getRequestAttributes();

    if (!(attributes instanceof ServletRequestAttributes servletAttributes)) {
        throw new IllegalStateException("Must be called in HTTP request context");
    }
    HttpServletRequest request = servletAttributes.getRequest();
    String stateParam = request.getParameter(OAuth2ParameterNames.STATE);

    // state 파라미터 검증
    if (stateParam == null || stateParam.isBlank()) 
    	throw new OAuth2Exception(CustomErrorCode.INVALID_OAUTH_PARAMS);

    // local 파라미터 추출
    String[] parts = stateParam.split("\\.");
    if (parts.length < 2) throw new OAuth2Exception(CustomErrorCode.INVALID_OAUTH_PARAMS);

    if (parts[1].equals("true") return true;
    return false;
}

4-4. CustomSuccessHandler

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    public static final Duration TEMP_CODE_TTL = Duration.ofMinutes(5);
    public static final String OAUTH_CODE_NAMESPACE = "oauth2";

    private final OAuth2Handler oauth2Handler;
    private final RedisRepository redisRepository;

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {

        // 1. state 파라미터로부터 Local 파라미터 추출
        boolean local = isLocal(request);
        // 2. UserService에서 전달한 DTO 추출
        CustomUserDetails oAuth2User = (CustomUserDetails) authentication.getPrincipal();
        // 3. 로그인한 User 정보를 Redis에 저장
        UUID uuid = generateCode(oAuth2User);

        // 4. Local 여부에 따라 동적으로 리다이렉트
        getRedirectStrategy().sendRedirect(request, response, createRedirectUrl(local, uuid));
    }
    
    private boolean isLocal(HttpServletRequest request) {
        // state 파라미터 추출
        String stateParam = request.getParameter(OAuth2ParameterNames.STATE);

        // state 파라미터 검증
        if (stateParam == null || stateParam.isBlank()) 
            throw new OAuth2Exception(CustomErrorCode.INVALID_OAUTH_PARAMS);

        // local 파라미터 추출
        String[] parts = stateParam.split("\\.");
        if (parts.length < 2) throw new OAuth2Exception(CustomErrorCode.INVALID_OAUTH_PARAMS);

        if (parts[1].equals("true") return true;
        return false;
	}

    // Redis에 로그인한 User 정보 저장
    private UUID generateCode(CustomUserDetails oAuth2User) {
        UUID uuid = UUID.randomUUID();
        try {
            redisRepository.setFromDTO(
                OAUTH_CODE_NAMESPACE,
                uuid.toString(),
                InfoDTO.builder()
                    .id(oAuth2User.id())
                    .role(oAuth2User.role())
                    .status(oAuth2User.status())
                    .build(),
                TEMP_CODE_TTL
            );
        } catch (Exception e) {
            throw new OAuth2Exception(CustomErrorCode.INTERNAL_SERVER_ERROR, e);
        }
        return uuid;
    }

    // OAuth2 로그인 성공 시 리다이렉트할 URL 생성
    private String createRedirectUrl(boolean local, UUID uuid) {
        return oauth2Handler.getSuccessUrl(local, uuid);
    }
}
  • Handler에선 HttpServletRequest를 바로 사용할 수 있기 때문에 UserService처럼 따로 RequestContextHolder에서 HttpServletRequest를 추출하는 과정이 필요 없습니다.
  • 모바일 환경에서도 JWT를 발급할 수 있게 하기 위해 Redis에 먼저 User 정보를 저장하고 별도의 API를 호출하여 JWT를 발급해 주는 2-Step 방식으로 구현하였습니다.

5. 보안 고려사항

  • OAuth State 파라미터는 OAuth2 CSRF 공격을 방지하기 위해 함부로 변조를 해선 안됩니다.
    • state 파라미터를 통해 AuthorizationRequestRepository에 저장된 OAuth2AuthorizationRequest 객체를 가져와 요청과 응답을 매칭하기 때문입니다.
    • 따라서 저는 Resolver에서 기존 state 값에 local인지 확인할 수 있는 값을 덧붙이는 방식을 사용했습니다.
    • 임의의 문자열 state 값을 기반으로 확장하였기 때문에 기존 검증 로직이 무력화되진 않습니다.
  • State 파라미터가 위변조가 되었을 경우
    • HMAC 같은 방식을 통해 위변조가 되었는지 확인이 가능합니다.
    • 하지만 복잡도가 증가하는 것을 우려하여 state가 변조되면 운영 사이트의 에러 페이지로 리다이렉트 하도록 했습니다.
    • open redirect 공격을 방지하기 위해 화이트리스트 도메인만 사용했습니다.

6. 회고

  • OAuth2AuthorizationRequestResolver와 AuthorizationRequestRepository를 활용하는 방식도 검토했습니다.
    • 그러나 Resolver는 AuthorizationRequest 생성 역할에 가깝고, Repository를 커스터마이징할 경우 별도 저장소 관리 및 직렬화/TTL 설정이 필요해 요구사항 대비 과도한 설계라고 판단했습니다.
    • 또한 OAuth2AuthorizationRequestRedirectFilter에서 발생한 예외는 ControllerAdvice나 FailureHandler에서 처리할 수 없기 때문에, 흐름을 단순하게 유지하는 것이 더 적절하다고 보았습니다.
  • Redis를 state 저장소로 사용하는 방식도 고려했으나, 최악의 경우 UserService, SuccessHandler, FailureHandler에서 중복 조회가 발생할 수 있어 불필요한 호출 증가 가능성이 있었습니다.
OAuth2를 단순한 로그인 기능이 아니라, 필터 체인과 인가 프로토콜이 결합된 구조로 이해하게 된 계기가 될 수 있었습니다.

'Framework > SpringBoot' 카테고리의 다른 글

SpringBoot - RabbitMQ 사용하기  (0) 2026.06.06
SpringBoot - QueryDSL 설정  (0) 2025.08.12