[프로젝트 회고] 여행 경비 매니저 JWT와 HttpOnly Cookie 트러블슈팅
들어가며
프로그래머스 데브코스 팀 프로젝트 중 여행 경비 매니저 프로젝트에서는 인증/인가 흐름을 깊게 다뤘다.
내가 주로 고민했던 부분은 JWT와 Cookie를 이용한 인증 구조였다.
로그인, 토큰 발급, RefreshToken 저장, 재발급, 로그아웃, 프론트엔드 연동까지 이어지는 흐름을 구현하면서 생각보다 많은 시행착오를 겪었다.
특히 HttpOnly, Secure, SameSite 같은 Cookie 옵션은 문서로 볼 때는 단순해 보였지만, 실제 프론트엔드와 맞춰보면 예상과 다르게 동작하는 경우가 많았다.
이번 글은 그 삽질과 정리의 기록이다.
처음 설계한 인증 흐름
기본적인 인증 흐름은 다음과 같이 잡았다.
- 사용자가 로그인한다.
- 서버가 AccessToken과 RefreshToken을 발급한다.
- AccessToken은 인증에 사용한다.
- RefreshToken은 재발급에 사용한다.
- 로그아웃 시 RefreshToken을 삭제하거나 무효화한다.
이때 보안을 위해 토큰을 Cookie에 담는 방식을 사용했다.
특히 RefreshToken은 탈취되면 위험하기 때문에 HttpOnly Cookie를 고민했다. HttpOnly를 설정하면 JavaScript에서 Cookie를 직접 읽을 수 없어 XSS 공격에 조금 더 안전해진다.
하지만 프론트엔드 연동 과정에서 이 설정이 생각보다 많은 고민을 만들었다.
프론트엔드와 맞추며 생긴 문제
개발 중 가장 많이 부딪힌 문제는 “브라우저에 쿠키가 있는데 요청에 안 실린다” 혹은 “프론트에서 확인하고 싶은데 HttpOnly라 볼 수 없다” 같은 상황이었다.
PR을 여러 번 나누어 올렸던 이유도 이 문제를 계속 확인하고 조정했기 때문이다.
당시에 확인했던 내용은 다음과 같다.
- axios 요청에서 credentials 설정이 필요한지
- 서버의 CORS 설정이 Cookie 요청을 허용하는지
- SameSite 옵션이 요청 상황과 맞는지
- Secure 옵션이 HTTPS 환경과 맞는지
- HttpOnly 설정 때문에 프론트에서 직접 읽을 수 없는 것이 정상인지
- 로그아웃과 재발급 요청은 AccessToken 없이 RefreshToken Cookie만으로 처리할 수 있는지
처음에는 보안을 잠시 낮춰서 Console에서 Cookie를 확인하기도 했다.
이 방법이 최종적으로 좋은 구조는 아니지만, 문제 원인을 좁히는 과정에서는 필요했다.
중요한 것은 확인이 끝난 뒤 다시 보안적으로 맞는 방향으로 되돌리는 것이었다.
RefreshToken 처리 고민
RefreshToken은 단순히 발급만 하면 끝나는 값이 아니었다.
고민했던 지점은 다음과 같았다.
- RefreshToken을 DB에 저장할 것인가?
- 로그아웃 시 서버에 저장된 RefreshToken을 삭제할 것인가?
- 이미 발급된 RefreshToken이 남아 있을 때 새 로그인은 어떻게 처리할 것인가?
- AccessToken이 만료된 상태에서 재발급 요청은 필터를 통과해야 하는가?
- RefreshToken 재사용 공격은 어떻게 막을 것인가?
프로젝트에서는 RefreshToken을 서버에서 관리하고, 로그아웃 시 삭제하는 흐름을 가져갔다. 이후에는 Blacklist 개념도 함께 고민했다.
이 과정에서 인증 기능은 단순히 토큰 문자열을 만드는 일이 아니라, 토큰의 생명주기를 설계하는 일이라는 것을 알게 되었다.
Filter 예외 처리
JWT 인증은 Filter 단계에서 처리된다.
문제는 Filter에서 발생하는 예외가 Controller까지 자연스럽게 전달되지 않는다는 점이었다.
그래서 토큰 만료, 위변조, 형식 오류 같은 상황을 클라이언트가 구분하기 어렵다.
이 부분을 해결하기 위해 인증 실패 케이스를 세분화하고, 클라이언트가 이해할 수 있는 에러 응답을 내려주는 구조를 고민했다.
예외를 크게 나누면 다음과 같았다.
- 토큰 만료
- 토큰 형식 오류
- 서명 오류
- 지원하지 않는 토큰
- 토큰이 없음
프론트엔드는 이 응답을 보고 재로그인을 유도할지, 토큰 재발급을 시도할지 판단할 수 있다.
그래서 백엔드에서 에러를 명확히 구분해주는 것이 사용자 경험과도 연결된다고 느꼈다.
보안과 개발 편의성 사이
이번 작업에서 가장 많이 느낀 것은 보안과 개발 편의성 사이의 균형이었다.
HttpOnly를 켜면 보안은 좋아지지만 프론트에서 디버깅하기 어렵다.
Secure를 켜면 HTTPS 환경에서는 안전하지만, 로컬 HTTP 환경에서는 쿠키가 안 붙을 수 있다.
SameSite 옵션도 프론트와 백엔드 도메인이 어떻게 구성되어 있는지에 따라 다르게 동작한다.
처음에는 이 옵션들이 귀찮게 느껴졌다.
하지만 점점 “브라우저가 왜 이렇게 엄격하게 동작하는지”를 이해하게 되었다.
인증은 사용자의 로그인 상태와 직접 연결되어 있고, 조금만 잘못 설계해도 보안 문제가 생긴다. 그래서 불편하더라도 정확하게 이해하고 써야 했다.
마무리
JWT와 Cookie를 다루면서 가장 많이 배운 것은 인증 기능은 혼자 완성되지 않는다는 점이었다.
백엔드 코드만 맞아도 안 되고, 프론트 요청 설정만 맞아도 안 된다.
브라우저 정책, CORS, Cookie 옵션, 서버 Filter, 토큰 저장 전략이 모두 맞아야 한다.
당시에는 PR을 여러 번 올리며 계속 고치는 과정이 힘들었지만, 지금 돌아보면 인증 구조를 몸으로 이해하게 된 좋은 경험이었다.
다음에 인증 기능을 만들 때는 처음부터 로컬/배포 환경의 Cookie 정책, CORS, 재발급 흐름을 문서화하고 시작해야겠다고 느꼈다.
Leave a comment