
RefreshToken이 없다면 사용자의 AccessToken이 만료됐을때 재로그인을 해야하는 번거로움이 생긴다.
AccessToken은 DB에 저장하지 않기때문에 만약 해커에게 탈취당했을 경우 재발급 받을 방법이 없다.
그래서 유효 시간을 짧게 설정해놓고 만료될 때까지 기다리는 수 밖에 없다.
로그아웃 => 로그인을 하면 재발급되지만 탈취됐다는 걸 알지 못하는 경우가 많고, 자동로그인이 설정된 경우엔 만료될 때까지 속수무책으로 기다려야 한다.
짧은 AccessToken이 만료됐을때 재발급을 하게되면 사용자는 자동 로그아웃 처리가되고 불편한 상황이 생기게 된다.
그래서 사용자는 모르게 AccessToken을 재발급 할 수 있는 RefreshToken을 사용하는 것이다.
AccessToken은 DB에 저장하지 않는 JWT를 사용하고
RefreshToken은 DB에 저장하는 UUID를 사용한다.
그 이유는 RefreshToken을 JWT로 했을때 탈취당한다면 긴 유효기간이 만료될때까지 기다려야 하기 때문이다.

다음은 RefreshToken 처리 코드이다.
- Refresh Token 없을때
Access Token 30분 만료
↓
다시 로그인 (아이디 + 비밀번호 입력)
↓
30분마다 반복
↓
사용자 짜증남
- Refresh Token 있을때
Access Token 30분 만료
↓
자동으로 Refresh Token으로 재발급 요청
↓
사용자 모르게 새 Access Token 발급
↓
7일 동안 로그인 유지(Refresh Token 유효기간동안 유지)
@Getter
@Builder
public class RefreshToken {
private Long tokenId;
private String username; // refresh_token.username은 UNIQUE 제약 → 기기 1개만 로그인 허용
private String token; // UUID 형태의 랜덤 토큰 값
private LocalDateTime expiresAt; // 만료 시각: 로그인 시 현재 시각 + refreshExpirationMs
private LocalDateTime createdAt;
}
username이지만 user의 ID 이기 때문에 UNIQUE 제약을 해도 괜찮다.
@Mapper
public interface RefreshTokenMapper {
// Refresh Token 저장: username 중복 시 기존 토큰을 덮어쓴다 (UPSERT)
void save(RefreshToken refreshToken);
// 토큰 값으로 단건 조회
Optional<RefreshToken> findByToken(String token);
// username에 해당하는 토큰 삭제 (로그아웃·탈퇴·만료 시 호출)
void deleteByUsername(String username);
}
findByToken에서 Optional을 사용한 이유
: fineByToken에서 refreshToken이 만료 됐거나 로그아웃,탈퇴로 제거됐을때 null값이 올수가 있다.
Optional은 null체크를 깔끔하고 null 처리를 강제하기 때문에 NullPointerException을 방지하기 위해 사용했다.
<mapper namespace="com.aenggukland.letspt.security.RefreshTokenMapper">
<insert id="save" parameterType="com.aenggukland.letspt.security.RefreshToken">
INSERT INTO refresh_token (username, token, expires_at)
VALUES (#{username}, #{token}, #{expiresAt})
ON CONFLICT (username)
DO UPDATE SET token = EXCLUDED.token,
expires_at = EXCLUDED.expires_at,
created_at = CURRENT_TIMESTAMP
</insert>
<select id="findByToken" parameterType="string"
resultType="com.aenggukland.letspt.security.RefreshToken">
SELECT *
FROM refresh_token
WHERE token = #{token}
</select>
<delete id="deleteByUsername" parameterType="string">
DELETE FROM refresh_token
WHERE username = #{username}
</delete>
</mapper>
save에서
ON CONFLICT (username)
DO UPDATE SET token = EXCLUDED.token,
expires_at = EXCLUDED.expires_at,
created_at = CURRENT_TIMESTAMP
부분을 설명하면
username(사용자ID)로 충돌이 나면(이미 refreshToken이 있다면)
방금 INSERT하려 했던 새 토큰으로 token 값과 만료 날짜를 저장하도록 한 것이다.
어제 jwtProvider 부터 오늘 refreshToken 까지 흐름을 정리하면
1. 로그인 요청
↓
2. JwtProvider.createToken()
Access Token (30분) 생성
username + role 담아서 HS256으로 서명
↓
3. Refresh Token (7일) 생성
UUID 랜덤 문자열 → DB에 저장
username UNIQUE → 기기 1개만 허용
↓
4. 둘 다 클라이언트에 내려줌
5. 매 요청마다
↓
6. RateLimitFilter
IP당 버킷 확인 → 초과하면 429 차단
↓
7. JwtFilter
헤더 또는 쿠키에서 Access Token 꺼냄
JwtProvider.validateToken() 호출
↓
8. JwtProvider.parseToken()
.parseClaimsJws() 에서
위조 확인 + 만료 확인 + 형식 확인 동시에
↓
9. 통과하면 SecurityContextHolder에 등록
username + role 담아서
↓
10. SecurityConfig
등록된 정보 보고 경로별 권한 확인
/member/** → MEMBER만
/admin/** → TRAINER·MASTER만
↓
11. 컨트롤러
12. 30분 후 Access Token 만료
↓
13. 클라이언트가 Refresh Token으로 재발급 요청
/api/auth/refresh
↓
14. DB에서 Refresh Token 조회
findByToken() → 있으면 유효
만료시간 확인 → 7일 안이면 유효
↓
15. 새 Access Token 발급
JwtProvider.createToken() 다시 호출
↓
16. 7일 후 Refresh Token도 만료
→ 다시 로그인
이렇게 된다.
보안 설계 포인트 정리
Access Token → 짧게 (30분) → 탈취돼도 금방 만료
Refresh Token → UUID → 탈취돼도 DB에서 즉시 삭제 가능
JWT 페이로드 → username + role만 → 민감 정보 노출 없음
HS256 서명 → 32바이트 이상 키 → 위조 불가능
RateLimitFilter → IP당 제한 → 브루트포스 방어