연동 2장 · 로그인 & 토큰 흐름
아이디·비밀번호를 입력하면 어떤 일이 벌어질까요? 로그인이 성공해 토큰을 받고, 그 뒤로는 모든 요청에 토큰을 실어 '나 인증된 사람'임을 증명합니다. 또 토큰이 만료되면 화면이 어떻게 스스로 다시 로그인하는지(자동 갱신)까지 따라갑니다.
"로그인 버튼을 누른다" → POST /api/auth/login → 토큰 두 개를 받아 보관 →
이후 GET /api/users 같은 요청에 토큰 자동 첨부 → 토큰이 만료되면 자동으로 갱신해 요청을 재시도.
토큰이 왜 필요한가
이 프로젝트의 백엔드는 세션을 서버에 저장하지 않습니다(Spring Security?를 STATELESS로 설정). 즉 서버는 "방금 로그인한 그 사람"을 기억하지 않아요. 대신 매 요청마다 토큰을 보고 누구인지 판단합니다. 그래서 한 번 로그인하면 받은 토큰을 보관해 두고, 요청할 때마다 같이 보내야 합니다.
입장(로그인) 때 손목밴드를 받습니다. 직원은 당신 얼굴을 기억하지 않아요. 놀이기구를 탈 때마다 손목밴드(토큰)만 확인합니다. 밴드가 있으면 통과, 없거나 기한이 지났으면 입장 거부. 서버가 누가 누군지 기억할 필요 없이 밴드만 검사하면 되니 가볍습니다.
액세스 토큰 vs 리프레시 토큰
이 프로젝트는 성격이 다른 토큰 두 개를 함께 씁니다(하이브리드 설계).
액세스 토큰 (Access Token)
· 정체 : 서명된 JWT 문자열 (서버가 저장 안 함, stateless)
· 용도 : 매 요청 헤더에 실어 "나 인증됨" 증명
· 수명 : 12시간 (app.jwt.expirationMs = 43200000)
· 검증 : 서버는 서명만 확인 → DB 조회 없이 빠름
리프레시 토큰 (Refresh Token)
· 정체 : DB에 저장된 UUID 값 (stateful)
· 용도 : 액세스 토큰이 만료됐을 때 새 액세스 토큰을 받기 위함
· 수명 : 7일 (app.jwt.refreshExpirationMs = 604800000)
(※ 사용할 때마다 만료가 7일 뒤로 다시 연장됨 — sliding)
· 검증 : 서버가 DB에서 찾아 만료 여부 확인액세스 JWT?는 서명만 검증하면 돼서 빠르지만, 한번 발급하면 만료 전까지 서버가 취소하기 어렵습니다. 리프레시는 DB에 있으니 지우면 바로 무효화됩니다(로그아웃 시 삭제). 그래서 "평소엔 빠른 액세스로, 갱신·로그아웃 통제는 DB 리프레시로" 역할을 나눕니다.
로그인 흐름 — 단계별
1. 로그인 폼 (React) — 아이디·비밀번호를 입력하고 제출하면
프론트가 POST /api/auth/login 을 보냅니다. (이 요청만은 토큰 없이 보냅니다 — 아직 토큰이 없으니까요.)
id · pw
2. 비밀번호 검증 — 백엔드 AuthEndpoint가
authenticationManager.authenticate(...)로 아이디·비밀번호를 확인합니다.
저장된 비밀번호는 BCrypt? 해시라 입력값을 같은 방식으로 해시해 비교해요.
authenticate
3. 토큰 두 개 발급 + 인증로그 — 인증에 성공하면
jwtTokenProvider.generateToken이 액세스 JWT를,
refreshTokenService.createRefreshToken이 DB에 리프레시 UUID를 만듭니다.
동시에 인증 로그(성공/실패, IP, User-Agent)도 기록해요.
JWT + DB UUID
4. 응답 → 프론트 저장 — 서버는
ActionResult.ok(LoginResp{ token, "Bearer", user, refreshToken }) 형태로 응답합니다.
프론트는 로그인 성공 처리(use-auth.hook.ts)에서 authToken·refreshToken을
localStorage에 저장합니다.
authToken · refreshToken
5. 이후 모든 요청에 자동 첨부 — 로그인 뒤에는
api.ts의 beforeRequest 훅(api.ts:101)이 로그인 요청을 제외한 모든 요청에
Authorization: Bearer <authToken> 헤더를 자동으로 붙입니다. 개발자가 매번 손으로 넣지 않아도 돼요.
Bearer 자동 주입
직접 보내보기 — 로그인
"보내기"를 누르면 POST /api/auth/login 요청과 응답 형태를 흉내 냅니다(실제 네트워크 없음).
응답의 data 안에 token(액세스 JWT)·refreshToken(DB UUID)·user가 들어옵니다.
토큰 만료 → 자동 갱신 — 단계별
액세스 토큰은 12시간이 지나면 만료됩니다. 그러면 평소처럼 보낸 요청이 401로 거절돼요. 이 프로젝트의 프론트는 사용자를 곧장 로그인 화면으로 내쫓지 않고, 리프레시 토큰으로 새 액세스 토큰을 몰래 받아와 원래 요청을 다시 시도합니다. 사용자는 끊김을 거의 느끼지 못해요.
1. 요청이 401로 거절 — 만료된 액세스 토큰으로 GET /api/users를 보내면,
백엔드 JwtAuthenticationFilter가 만료를 감지합니다. 경로가 /api/auth/가 아니면
필터가 직접 401 JSON({"message":"토큰이 만료되었습니다..."})을 돌려줍니다.
만료된 토큰
2. afterResponse 훅이 401을 가로챔 — 프론트
api.ts의 afterResponse 훅(api.ts:111~156)이
response.status === 401 && refreshToken 조건을 보고 갱신을 시도합니다.
이미 다른 요청이 갱신 중이면(isRefreshing) 대기열(refreshSubscribers)에 등록만 하고 기다려요.
401 감지
3. 새 액세스 토큰 발급 — 갱신 중이 아니면 isRefreshing = true로 잠그고
POST /api/auth/refresh를 보냅니다. 백엔드는 요청의 리프레시 UUID를 DB에서 찾아 만료를 검증하고,
통과하면 generateTokenFromUsername으로 새 액세스 JWT를 발급해
ActionResult.ok(TokenRefreshResp{accessToken, refreshToken})로 돌려줍니다.
DB UUID 검증
localStorage 저장
4. 원래 요청 재시도 — 새 액세스 토큰을 저장한 뒤,
실패했던 원래 요청의 Authorization 헤더를 새 토큰으로 갈아끼우고 다시 보냅니다.
이번엔 토큰이 유효하므로 정상 응답을 받아 화면에 데이터가 그려져요.
(만약 갱신마저 실패하면 토큰을 모두 삭제하고 /login으로 이동합니다.)
새 토큰으로 재시도 ✓
5. 동시 401, 중복 갱신 방지 — 화면이 한꺼번에 여러 요청을 보내다 모두 401이 나면?
처음 한 요청만 실제로 refresh를 호출하고, 나머지는 대기열?에
등록돼 기다립니다. 갱신이 끝나면 대기 중이던 요청들이 모두 같은 새 토큰으로 한꺼번에 재시도돼요.
불필요한 중복 갱신을 막습니다.
직접 보내보기 — 토큰 갱신
"보내기"를 누르면 POST /api/auth/refresh 요청과 응답 형태를 흉내 냅니다.
요청 본문에 리프레시 UUID를 담아 보내면, 응답으로 새 accessToken을 받습니다.
- 하이브리드 구조: 빠른 검증(액세스 JWT, stateless) + 통제 가능(리프레시 UUID, DB stateful)의 장점을 합쳤습니다.
- 액세스 수명이 김(12시간): 일반적으로 액세스 토큰은 수 분~1시간으로 짧게 둡니다. 12시간은 그보다 길어요. 액세스가 길수록 탈취됐을 때 위험 구간도 길어지므로, 운영에서는 더 짧게 줄이는 것을 검토할 만합니다.
- localStorage 보관은 토론거리: 토큰을
localStorage에 두면 자바스크립트로 쉽게 읽혀서 편리하지만, 악성 스크립트가 끼어드는 XSS 공격에 노출되면 토큰이 통째로 새어나갈 수 있습니다. 대안으로HttpOnly쿠키 보관 등이 거론됩니다. 정답이 하나는 아니고 트레이드오프가 있는 주제예요.
- "로그인은 됐는데 다음 요청이 401" — 5단계(beforeRequest)에서 헤더가 제대로 붙는지,
authToken이 저장됐는지 확인 - "한참 있다 돌아오니 자동으로 로그인이 풀렸다" — 리프레시 토큰(7일)까지 만료됐거나 DB에서 삭제된 경우. 갱신 실패 →
/login이동 - "새로고침하면 로그인이 유지된다" — 토큰이 메모리가 아닌
localStorage에 있어 새로고침해도 살아있기 때문 - "동시에 여러 요청이 갱신을 중복 호출" — 대기열(refreshSubscribers)로 한 번만 갱신하도록 설계됨(5단계)
백엔드 보안 설정(필터 체인, STATELESS, BCrypt 등) 상세는 백엔드 6장 · 보안에서 이어집니다.