React, Spring Boot, Nginx 조합으로 프론트엔드와 백엔드가 분리된 구조의 프로젝트를 구성하였다. 전체 구성은 다음과 같다:
cointoz.store
api.cointoz.store
, HTTPS 설정 완료JWT 기반 인증 구조를 구성했으며,
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 Security
의 SuccessHandler
를 통해 다음과 같은 흐름으로 응답을 구성하고 있습니다.
200 OK
)response.getWriter()
호출)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
헤더가 적용되지 않는다는 사실을 놓치고 있었습니다.
이번 경험을 통해 인증 구조를 설계할 때는 네트워크 환경뿐 아니라 서블릿의 동작 순서와 같은 기본 동작 원리에 대한 이해도 중요하다는 점을 다시금 느꼈습니다.