HTTPS 환경에서 HttpOnly Secure 쿠키가 저장되지 않는 문제 해결

오형상·2025년 4월 13일
0

CoinToZ

목록 보기
9/9

문제 상황

React, Spring Boot, Nginx 조합으로 프론트엔드와 백엔드가 분리된 구조의 프로젝트를 구성하였다. 전체 구성은 다음과 같다:

  • 프론트엔드: React (Nginx로 정적 리소스 서빙), 도메인: cointoz.store
  • 백엔드: Spring Boot (Docker로 배포), 도메인: api.cointoz.store, HTTPS 설정 완료
  • 리버스 프록시: Nginx (HTTPS 인증서 적용, 443 포트)

JWT 기반 인증 구조를 구성했으며,

  • AccessToken은 응답 바디에 포함해 클라이언트에 전달하고
  • RefreshToken은 HttpOnly + Secure + SameSite=None 쿠키로 클라이언트에 전달되도록 설정하였다.

문제: 클라이언트 측에서 로그인 요청 이후, 브라우저 개발자 도구(F12)의 Application 탭을 통해 확인해보면 RefreshToken 쿠키가 저장되지 않는 현상이 발생했다.


원인 분석

처음에는 HTTPS 인증서 설정, Nginx의 리버스 프록시 설정, SameSite=None 정책 누락, 도메인 불일치 등 외부 환경의 설정 문제를 의심하여 관련된 부분을 하나씩 점검했다.

그러나 curl을 사용하여 직접 요청을 보내고 해당 요청의 응답을 확인한 결과, Set-Cookie 헤더가 존재하지 않음을 발견했다. 이를 통해 브라우저에 저장되지 않는 원인이 서버 응답에 있다는 점을 알았습니다.

curl -i -X POST https://api.cointoz.store/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test1111@naver.com", "password":"1q2w3e4r!!"}'

현재 로그인 성공 시점에는 Spring SecuritySuccessHandler를 통해 다음과 같은 흐름으로 응답을 구성하고 있습니다.

  1. 응답 상태 코드 설정 (200 OK)
  2. AccessToken을 응답 바디(JSON)에 포함 (response.getWriter() 호출)
  3. RefreshToken을 Set-Cookie 헤더로 설정하여 쿠키로 전달

그러나 response.getWriter()를 통해 바디를 작성하는 순간, 서블릿 응답은 커밋(commit) 되며, 그 이후에 response.addHeader()를 호출해도 Set-Cookie와 같은 헤더는 더 이상 반영되지 않습니다.

즉, 응답 바디를 먼저 작성한 뒤에 쿠키를 설정하려고 했기 때문에 쿠키가 클라이언트에 전달되지 않았던 것.


해결 방안

이 문제를 해결하기 위해 응답 헤더 설정을 먼저 수행한 후, 응답 바디를 작성하도록 순서를 수정했습니다.

@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtService jwtService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        String email = extractUsername(authentication); // 인증 정보에서 email 추출
        String accessToken = jwtService.createAccessToken(email); // JwtService의 createAccessToken을 사용하여 AccessToken 발급
        String refreshToken = jwtService.createRefreshToken(); // JwtService의 createRefreshToken을 사용하여 RefreshToken 발급

        // 1. 응답 상태 코드 설정 (200 OK)
        response.setStatus(HttpServletResponse.SC_OK);
        
        // 2. JwtService에서 refreshToken을 HttpOnly 쿠기에 실어서 클라이언트로 보냄
        jwtService.sendRefreshToken(response, refreshToken);
        
        // 3. JwtService에서 AccessToken을 응답 바디에 실어서 클라이언트로 보냄
        jwtService.sendAccessToken(response, email, accessToken);

        // 4. Redis에 RefreshToken 저장 (Key: "RT:" + email)
        jwtService.saveRefreshTokenInRedis(authentication.getName(), refreshToken);

    }

    private String extractUsername(Authentication authentication) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return userDetails.getUsername();
    }
}

마무리

처음에는 HTTPS 설정, Nginx 설정, SameSite 속성 등 외부 환경의 문제라고 생각해 여러 설정을 점검하느라 시간을 많이 소모했습니다. 하지만 결국 문제의 원인은 응답이 커밋되는 시점에 있었고, response.getWriter() 이후에는 Set-Cookie 헤더가 적용되지 않는다는 사실을 놓치고 있었습니다.

이번 경험을 통해 인증 구조를 설계할 때는 네트워크 환경뿐 아니라 서블릿의 동작 순서와 같은 기본 동작 원리에 대한 이해도 중요하다는 점을 다시금 느꼈습니다.


0개의 댓글