CURSOR AI를 활용해서 로그인 기능을 만들기 시작했을 때만 해도 로그인만 되면 끝 아니야?라는 생각을 했다.
실제로 이메일과 비밀번호로 로그인하고, JWT Access Token을 발급하는 것까지는 큰 어려움이 없었다.
문제는 그 다음이었다.
Access Token이 만료되면?
로그인을 해두고 조금 지나면 자연스럽게 Access Token이 만료된다. 그때마다 다시 로그인 화면으로 보내는 건 사용자 경험이 너무 안 좋다.
그래서 등장하는 게 Refresh Token인데, 여기서부터 머리가 복잡해지기 시작했다.
Refresh Token은 왜 HttpOnly 쿠키로 굽는지
왜 Access Token은 DB에 저장하지 않는지
로그아웃은 도대체 어디까지가 로그아웃인지
하나하나 명확히 설명하지 못한 상태에서 구현을 시작하니 로직이 계속 꼬였다.
가장 헷갈렸던 부분: Access와 Refresh의 역할
처음에는 두 토큰의 차이를 이렇게 단순하게 생각했다.
Access Token: 로그인 증명
Refresh Token: 연장용 토큰
그런데 이게 완전히 잘못된 이해였다.
정리해보니 실제 역할은 이랬다.
Access Token
수명이 짧다
서버는 서명과 만료 시간만 보고 검증한다
빠르고 가볍지만, 서버에서 강제로 끊기는 어렵다
Refresh Token
수명이 길다
DB에 저장해서 상태를 관리한다
이게 사실상 로그인 세션이다
이걸 이해하고 나서야 왜 실무에서 Access와 Refresh를 굳이 나누는지 납득이 됐다.
Refresh Token을 DB로 관리해야 했던 이유
Refresh Token을 단순히 쿠키로만 관리하면 이런 문제가 생긴다.
로그아웃해도 서버는 모른다
특정 기기만 로그아웃시키기 어렵다
토큰 탈취 여부를 판단할 수 없다
그래서 Refresh Token은 DB에 해시 값으로 저장하고, 요청이 들어올 때마다 이 세션이 아직 유효한지를 서버가 직접 판단하도록 만들었다.
이 순간부터 아, 이건 그냥 토큰이 아니라 세션 관리구나라는 생각이 들었다.
제일 어려웠던 부분: Refresh 재발행과 재사용 탐지
가장 많이 헤맸던 부분은 Refresh Token 재발행 로직이었다. Refresh Token을 사용하면 기존 토큰은 바로 폐기하고 새 토큰을 발급하는 방식(회전, rotation)을 적용했다. 그리고 이미 한 번 사용해서 폐기된 Refresh Token이 다시 서버로 들어오면? 이건 정상적인 사용이 아니라 토큰이 복제되었을 가능성이 있는 상황으로 판단해야 한다. 그래서 이런 경우에는 해당 디바이스, 또는 계정 전체를 강제 로그아웃시키는 정책을 적용했다.
이 로직을 이해하고 나서야 왜 인증 로직이 길고 복잡할 수밖에 없는지 체감이 됐다.
서버와 프론트의 역할을 나누니 정리가 됐다
중간에 가장 크게 방향을 바꾼 부분은 역할 분리였다.
PHP 서버
토큰 발급
Refresh 검증과 재발행
세션(DB) 관리
Next.js 프론트
로그인 상태 UI
접속한 디바이스 목록 표시
로그아웃 요청
프론트에서 토큰을 직접 다루려고 할수록 복잡해졌고, 모든 판단을 서버에서 하도록 넘기니 구조가 훨씬 깔끔해졌다.
정리하면서 느낀 점
CURSOR AI 덕분에 코드를 못 짜서 막힌 건 아니었다. 대부분은 개념이 정리되지 않은 상태에서 구현을 시작했기 때문이었다.
이번 작업을 통해 느낀 건 이거다.
로그인 기능은 단순한 UI 작업이 아니라
상태와 신뢰를 설계하는 문제라는 것.
다음에 인증 기능을 만들 땐 무작정 코드부터 치기보다는 구조부터 먼저 그려보게 될 것 같다.