험난한 쿠키 전송

SeungHoon·2025년 5월 6일

Spring

목록 보기
13/15

0. 일의 배경

  • 서울시 공공데이터 창업 경진대회에 나가면서 발생했던 문제와 해결과정을 담았다. 같은 학교 학생들과 협업하는 과정이라 뜻 깊은 프로젝트 였다. 다양한 외부 API도 연동해보고 Oauth 2.0 도 처음 도입했는데, 과정이 좀 험난하긴 했지만 유의미한 경험이었다.
  • 이번 글에서는 Oauth 2.0과 access token을 사용하면서 겪었던 어려움에 대해서 작성해보고자 한다.

1. 카카오 로그인 도입

1.1 access token 발급

  • 로그인 과정을 간소화하기 위해 카카오 로그인을 도입했다. (사진은 카카오 디벨로퍼에 있다)
  • 위 사진과 다르게 구현한 점은 프론트에서 "카카오 로그인" 버튼을 누르면 바로 Kakao Auth Server로 인가 코드를 Get 요청 ("/oauth/authorize")을 하게 되고, 서버는 302 Redirect URI로 인가 코드를 전달받게 하였다.
  • 카카오 로그인을 통해 기본적인 회원가입을 하고, 그 후 사용자 정보를 (이름, 주소, 프로필) 입력받는 로직으로 구성했다.
    • 카카오 로그인만 사용자는 PRE_MEMBER 권한을 가지고, 회원가입까지 모두 마친 사용자는 MEMBER 권한을 가지게 하여, 회원가입 페이지는 PRE_MEMBER 권한을 가진 사용자만 접근 가능하게 하였다.
        .requestMatchers("/member/signup")
        		.hasAuthority("PRE_MEMBER")
    • 권한을 제한할 때 hasAuthority() 을 사용할 수 있고, hasRole()을 사용할 수도 있는데, hasRole() 의 경우 접두사로 "ROLE_"을 자동으로 붙여 비교하기 때문에 hasAuthority() 을 사용하였다.
    • 물론 사용자는 카카오 로그인 버튼에만 접근할 수 있고, 회원가입 버튼은 별도로 없기 때문에 정상적인 접근으로는 MEMBER 권한을 가진 사용자가 회원가입 페이지에 접근할 수 없다. (그래도 postman으로 접근하는 이상한 사람들이 있을 수 있어요)
  • 카카오 로그인, 회원가입이 끝나면 access token을 지급해준다. access token에 담겨 있는 정보는 다음과 같다.
    • 카카오 로그인만 완료한 사용자의 경우
      {
        "sub": "42239~~",
        "role": "PRE_MEMBER",
        "iat": 1746516973,
        "exp": 1746524173
      }
    • 회원가입까지 마친 사용자의 경우
      {
        "sub": "42239~~~",
        "role": "MEMBER",
        "iat": 1746516973,
        "exp": 1746524173
      }
  • 우리의 원래 의도는 카카오 로그인을 한 유저는 바로 회원가입을 하는 것이다. 그래서 카카오 로그인만 한 유저의 경우 access token이 아니라 유효 시간이 10분 정도로 짧은 임시 토큰을 발급하여 관리할 수도 있었다.
    • 하지만 이는 redis에 저장하여 유효 시간을 관리하고, 임시 토큰에 관한 코드를 추가해야 하는데, 당장 구현해야 되는 기능들이 생각보다 많아서 일단 access token 1개만 사용했다.
  • 원래 refresh token 과 같이 발행해줘야 하는데, 프로젝트의 제한된 시간 상 생략했다. 추후 프로젝트를 발전시킬 기회가 있다면 추가할 예정이다.

1.2 로그인 후 리다이렉트

  • 사용자가 카카오 로그인 버튼을 누르고 서버는 리다이렉트를 해야하는데 여기에는 2가지 경로가 있다.
    • 만약 사용자가 신규 사용자라면 회원가입 화면으로 리다이렉션을 한다.
    • 만약 사용자가 기존 사용자라면 홈 화면으로 리다이렉션을 한다.
  • 이 과정에서 우리가 만든 access token을 같이 보내줘야 하는데, 여기에도 다시 2가지 방법이 있다.
    • access token을 query parameter 로 전송한다.
    • access token을 쿠키에 넣어서 전송한다.
  • 이 중에서 보안상의 이유로 쿠키에 넣는 것이 안전하다고 판단하여 쿠키에 넣어서 전송했다.
  • 문제는 여기서 발생한다.

2. 쿠키 전송이 안됩니다!

2.1 쿠키 sameSite() 설정

  • 문제는 서버에서 클라이언트로 쿠키 전송이 안되는 것부터 시작했다. 쿠키를 만들 때 sameStie() 설정을 해줘야 하는데 처음에는 다음과 같이 쿠키를 발급했다.
ResponseCookie accessCookie = ResponseCookie.from("access", token)
                .httpOnly(true)
                .path("/")
                .sameSite("Strict")
                .maxAge(Duration.ofHours(2))
                .build();
  • .sameSite("Strict") 을 사용하면 서로 다른 도메인에서 쿠키 전송이 아예 되지 않는다. 따라서 이렇게 설정하면 안된다. (잘 찾아보지도 않고 보안만 신경쓴다면서 쓴 바보 같은 나)
  • 그래서 다음은 .sameSite("Lax") 값으로 설정했다 (Chrome에서는 기본값이 "Lax" 이기 때문에 따로 설정하지 않았다)
ResponseCookie accessCookie = ResponseCookie.from("access", token)
                .httpOnly(true)
                .path("/")
                // sameSite을 따로 설정하지 않고 기본값으로 둔다.
                .maxAge(Duration.ofHours(2))
                .build();
  • 하지만 이 경우에도 아래와 같은 경우에는 쿠키가 전송되지 않아 수정이 필요했다.
    • POST 요청으로 리디렉션되는 경우
    • 자바스크립트에서 fetch/ajax로 cross-site 요청할 때
    • 크로스 사이트 POST/PUT/DELETE 등의 non-safe 요청
  • 그래서 남은 것은 .sameSite("None") 이었다. 하지만 이는 반드시 .secure(true) 설정이 필요했고, 이는 HTTPS 가 필요하다는 것을 의미했다.
return ResponseCookie.from(key, value)
                .httpOnly(true)
                .path("/")
                .sameSite("None")
                .secure(true)
                .maxAge(Duration.ofHours(2))
                .build();
  • 명시적으로 프론트엔드와 백엔드에서 HTTPS 가 필요한 이유는 다음과 같다.
    - 프론트가 브라우저가 보안 경로(HTTPS) 아니면 SameSite=None 쿠키를 아예 차단함.
    - 백엔드가 Set-Cookie 헤더를 받을 때 Secure 쿠키 전송하려면 HTTPS 통신이어야 함

    따라서 AWS EC2 에 HTTP로 배포된 내 서버를 HTTPS로 배포해야 되는 것이었다.

  • 가비아에서 적당한 도메인을 하나 골라서 이를 사용해서 배포하였다.

  • 이제는 되겠지? 하지만 아직 안된다... 왜 안되는데?

2.2 도메인 설정

  • 쿠키를 만들 때 사용될 도메인을 따로 설정해줄 수 있었다. 그래서 프론트의 배포 주소를 도메인으로 설정하고 쿠키를 넘겨보았다.
public static ResponseCookie createCookie(String key, String value) {
        return ResponseCookie.from(key, value)
                .httpOnly(true)
                .path("/")
                .sameSite("None")
                .secure(true)
                .domain(".morak.vercel.app")
                .maxAge(Duration.ofHours(2))
                .build();
    }
  • 결과는 실패였다. 여전히 쿠키는 프론트로 전송되지 않았다. 마지막으로 찾을 수 있는 원인은 프론트엔드와 백엔드에서 서로 다른 도메인을 사용하고 있다는 점이었다.
  • 따라서 프론트엔드의 주소를 "morak.site", 백엔드의 주소를 "api.morak.site"로 변경하여 서브 도메인을 같게 설정해두었고, 도메인도 이에 맞게 수정해주었다.
public static ResponseCookie createCookie(String key, String value) {
        return ResponseCookie.from(key, value)
                .httpOnly(true)
                .path("/")
                .sameSite("None")
                .secure(true)
                .domain(".morak.site")
                .maxAge(Duration.ofHours(2))
                .build();
    }
  • 그런데 그래도 안된다... 머리가 아프지만 마지막으로 원인을 살펴보니, CORS Preflight Request 가 문제의 원인이었다.

2.3 CORS Preflight Request 별도 처리

  • Preflight Request이란, CORS 요청이 서버에 의해 허용될 수 있는지를 확인하기 위해 브라우저가 자동으로 보내는 사전 요청이다.
  • 이 요청은 HTTP method 중에서 OPTIONS 을 사용한다.
  • OPTIONS 요청에 대해서는 인증, 인가 작업이 이루어지고 있어 쿠키 전송이 안되는 것이었다!!
  • 따라서 OPTIONS 요청은 인증, 인가 과정이 이뤄지지 않도록 예외 처리를 해주었다.
// 커스텀 필터에서 인증 예외 처리하기
	@Override
    protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response,
                                    @NotNull FilterChain filterChain)
            throws ServletException, IOException {
        String requestURI = request.getRequestURI();

        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            filterChain.doFilter(request, response);
            return;
        }
        ...
// 인가 예외 처리하기
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()

3. 결론

  • 쿠키를 전송하기 위해서는 3가지 조건이 필요하다
  1. 프론트엔드와 백엔드의 서브 도메인이 같을 것. (CORS 때문에)
  2. .sameSite("None").secure(true) 설정을 반드시 사용할 것.
  3. HTTPS 배포를 할 것.
  • 정말 정말 쿠키 전달하기 어려웠다.. 다음에는 잘할 수 있겠지?
profile
공유하며 성장하는 Spring 백엔드 취준생입니다

0개의 댓글