반응형
Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
Tags
- javascript
- 인텔리제이
- string
- 자바
- 자바문법
- 스프링부트
- js
- junit5
- 단위테스트
- 자바스크립트
- java테스트
- 테스트자동화
- 배열
- math
- 문자열
- Visual Studio Code
- Eclipse
- SpringBoot
- junit
- CSS
- input
- Java
- IntelliJ
- html
- vscode
- Array
- HashMap
- list
- ArrayList
- 정규식
Archives
- Today
- Total
어제 오늘 내일
[Spring Security] 11편 - 로그아웃은 싫어! 리프레시 토큰(Refresh Token) 완벽 구현하기 본문
IT/SpringBoot
[Spring Security] 11편 - 로그아웃은 싫어! 리프레시 토큰(Refresh Token) 완벽 구현하기
hi.anna 2026. 2. 23. 00:47지금까지 만든 JWT 시스템은 잘 동작하지만, 실무에 바로 쓰기엔 치명적인 딜레마가 하나 있습니다.
"보안을 위해 토큰 유효기간을 짧게(30분) 줄였더니,
사용자가 글을 쓰다가 30분이 지나면 로그인이 풀려버려서 글이 다 날아갔다!"
그렇다고 유효기간을 1년으로 늘리자니, 해커에게 토큰을 뺏기면 1년 내내 내 계정이 털리게 됩니다.
이 문제를 해결하기 위해 Refresh Token(재발급 전용 토큰)을 도입해야 합니다.
0. 시나리오: 리프레시 토큰이 왜 필요한가?
여러분이 만든 앱을 사용하는 사용자 '철수'의 상황을 비교해 봅시다.
😱 상황 1: Access Token만 있을 때 (현재 상태)
- 오후 1:00 - 철수가 로그인을 합니다. (Access Token 발급, 유효기간 30분)
- 오후 1:20 - 철수가 열심히 게시글을 작성합니다.
- 오후 1:31 - 철수가 '저장' 버튼을 누릅니다.
- 서버: "어? Access Token 만료됐네? (403 Error)"
- 앱: 로그인이 풀렸습니다. 로그인 화면으로 이동합니다.
- 철수: "아악! 내 글 다 날아갔어!" 😡 (서비스 이탈)
😎 상황 2: Refresh Token을 도입했을 때
- 오후 1:00 - 철수가 로그인을 합니다. (Access Token + Refresh Token 발급)
- 오후 1:31 - 철수가 '저장' 버튼을 누릅니다.
- 서버: "Access Token 만료됐네. (401 Error)"
- 앱(프론트엔드): "잠깐만요! 저한테 Refresh Token 있어요. 이걸로 새 Access Token 주세요." (
/reissue요청) - 서버: "DB 확인해보니 철수 맞네. 자, 새 토큰 받아가." (재발급 완료)
- 앱: 새 토큰으로 아까 실패했던 '저장' 요청을 다시 보냅니다.
- 철수: (아무 일도 없었다는 듯) "저장 완료!" 😄
1. 전략: Short Access, Long Refresh
우리는 상황 2를 만들기 위해 다음과 같은 전략을 씁니다.
- Access Token (30분): 실제 출입증. 금방 만료되므로 탈취되어도 피해가 적음.
- Refresh Token (7일): 재발급 전용 티켓. DB에 저장해두고, Access Token이 만료됐을 때만 사용.
Step 1. RefreshToken 저장소 만들기 (Entity)
리프레시 토큰은 Access Token과 달리 서버(DB)에 저장해서 관리해야 합니다.
- 위치:
src/main/java/com/example/board/domain/entity/RefreshToken.java
package com.example.board.domain.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Builder
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {
@Id
@Column(name = "user_id") // DB 컬럼명을 user_id로 지정
private String userId; // 사용자의 ID (username)
@Column(name = "refresh_token")
private String refreshToken;
// 토큰 갱신 시 내용 변경을 위한 메서드
public void updateRefreshToken(String token) {
this.refreshToken = token;
}
}- 위치:
src/main/java/com/example/board/domain/repository/RefreshTokenRepository.java
package com.example.board.domain.repository;
import com.example.board.domain.entity.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
Optional<RefreshToken> findByUserId(String userId);
}💡 왜 DB에 저장하나요?
JWT는 원래 발급하면 서버가 제어할 수 없는(Stateless) 것이 특징입니다. 하지만 Refresh Token은 제어권이 필요합니다.
- 강제 로그아웃: 사용자가 "전체 로그아웃"을 누르면 DB에서 이 토큰을 지워서 더 이상 재발급을 못 받게 막아야 합니다.
- 탈취 감지: 해커가 예전 토큰을 쓰려고 하면 DB의 최신 값과 비교해서 막아야 합니다.
Step 2. JwtTokenProvider 수정 (토큰 2개 생성)
- 위치:
src/main/java/com/example/board/jwt/JwtTokenProvider.java
// ... (기존 코드 생략)
public JwtToken createToken(Authentication authentication) {
// ... (권한 가져오기 로직 동일)
long now = (new Date()).getTime();
// 1. Access Token 생성 (30분)
Date accessTokenExpiresIn = new Date(now + 1800000);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// 2. Refresh Token 생성 (7일)
String refreshToken = Jwts.builder()
.setSubject(authentication.getName()) // ★ 중요: 재발급 용도라도 ID는 포함해야 함
.claim("auth", authorities)
.setExpiration(new Date(now + 604800000))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtToken.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken) // Refresh Token 추가 반환
.build();
}Step 3. Service 로직 (저장 및 검증 핵심 로직)
여기가 가장 중요합니다. RTR(Refresh Token Rotation) 방식을 적용하여 보안을 강화합니다.
- 위치:
src/main/java/com/example/board/service/MemberService.java
// ... (기존 로그인 로직)
@Transactional
public JwtToken login(String username, String password) {
// ... (인증 과정 동일)
JwtToken jwtToken = jwtTokenProvider.createToken(authentication);
// 4. Refresh Token 저장 (핵심!)
RefreshToken refreshToken = RefreshToken.builder()
.userId(authentication.getName())
.refreshToken(jwtToken.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken); // key(ID)가 같으면 update 됨
return jwtToken;
}
// ★ 토큰 재발급(Reissue) 로직
@Transactional
public JwtToken reissue(String refreshToken) {
// 1. Refresh Token 검증
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
}
// 2. 토큰에서 User ID 가져오기
Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken);
// 3. 저장소에서 User ID 를 기반으로 Refresh Token 값 가져옴
RefreshToken dbRefreshToken = refreshTokenRepository.findByUserId(authentication.getName())
.orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));
// 4. 토큰 일치 여부 검사 (핵심!)
if (!dbRefreshToken.getRefreshToken().equals(refreshToken)) {
throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
}
// 5. 새로운 토큰 생성
JwtToken newJwtToken = jwtTokenProvider.createToken(authentication);
// 6. 저장소 정보 업데이트 (Rotation)
dbRefreshToken.updateRefreshToken(newJwtToken.getRefreshToken());
return newJwtToken;
}💡 핵심 로직 설명 (Reissue)
- DB 값과 비교 (
equals):
- 클라이언트가 보낸 Refresh Token이 유효한 토큰이더라도, DB에 저장된 최신 값과 다르면 거절해야 합니다.
- 시나리오: 해커가 어제 탈취한 리프레시 토큰으로 오늘 접속하려 합니다. 하지만 철수(주인)가 그사이 한 번 더 재발급을 받았다면 DB 값은 바뀌어 있습니다. 해커의 요청은 즉시 차단됩니다.
- 토큰 로테이션 (
Rotation):
- 재발급 요청이 오면 Access Token만 새로 주는 게 아니라, Refresh Token도 새로 발급해서 DB를 갱신합니다.
- RTR(Refresh Token Rotation)이라고 부르는 이 방식은, 리프레시 토큰도 일회용으로 만들어버려서 탈취 위험을 극도로 낮추는 실무 필수 기법입니다.
Step 4. Controller (API 추가)
- 위치:
src/main/java/com/example/board/controller/MemberController.java
@PostMapping("/reissue")
public JwtToken reissue(@RequestBody JwtToken jwtToken) {
// refreshToken만 보내도 되지만, DTO 재활용을 위해 전체를 받음
return memberService.reissue(jwtToken.getRefreshToken());
}2. 전체 흐름 보기 (Sequence Diagram)

🔍 다이어그램 핵심 포인트
- 로그인 시 (1~4번):
- Access Token과 Refresh Token을 둘 다 만듭니다.
- 중요: 이때 Refresh Token은 DB에 저장해둡니다. (나중에 비교하기 위해)
- 재발급 시 (5~11번):
- 클라이언트가 보낸 Refresh Token이 유효한지 1차 검사합니다.
- DB에 있는 값과 똑같은지 2차 검사(8번)합니다. (탈취된 구버전 토큰 방지)
- 검증이 끝나면 새 토큰 세트를 만들고, DB에 있는 토큰도 새것으로 교체(10번, Rotation)합니다.
🎉 마무리
이제 여러분의 시스템은 "30분마다 만료되지만, 사용자는 눈치채지 못하는" 안전하고 편리한 시스템이 되었습니다.
[최종 정리]
- 사용자는 로그인 시 7일짜리 Refresh Token을 받습니다.
- 30분 후 Access Token이 만료되면, 앱(프론트엔드)이 자동으로
/reissue를 호출합니다. - 서버는 DB 확인 후 토큰을 교체(Rotation)해서 돌려줍니다.
- 사용자는 로그인 풀림 없이 쾌적하게 서비스를 이용합니다.
반응형
'IT > SpringBoot' 카테고리의 다른 글
| [Spring Security] 13편 - 예외 처리 (401/403 예쁘게 응답하기) (0) | 2026.02.24 |
|---|---|
| [Spring Security] 12편 - Redis 도입 & TTL로 토큰 자동 삭제하기 (0) | 2026.02.23 |
| [Spring Security] 10편 - 한눈에 보는 JWT 동작 원리 (Sequence Diagram & 총정리) (0) | 2026.02.21 |
| [Spring Security] 9편 - JWT 로그인 API 구현 & Postman 테스트 (0) | 2026.02.20 |
| [Spring Security] 8편 - SecurityConfig 대수술 (FormLogin 삭제 & JWT 필터 적용) (0) | 2026.02.20 |
Comments
