어제 오늘 내일

[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. 오후 1:00 - 철수가 로그인을 합니다. (Access Token 발급, 유효기간 30분)
  2. 오후 1:20 - 철수가 열심히 게시글을 작성합니다.
  3. 오후 1:31 - 철수가 '저장' 버튼을 누릅니다.
  4. 서버: "어? Access Token 만료됐네? (403 Error)"
  5. 앱: 로그인이 풀렸습니다. 로그인 화면으로 이동합니다.
  6. 철수: "아악! 내 글 다 날아갔어!" 😡 (서비스 이탈)

😎 상황 2: Refresh Token을 도입했을 때

  1. 오후 1:00 - 철수가 로그인을 합니다. (Access Token + Refresh Token 발급)
  2. 오후 1:31 - 철수가 '저장' 버튼을 누릅니다.
  3. 서버: "Access Token 만료됐네. (401 Error)"
  4. 앱(프론트엔드): "잠깐만요! 저한테 Refresh Token 있어요. 이걸로 새 Access Token 주세요." (/reissue 요청)
  5. 서버: "DB 확인해보니 철수 맞네. 자, 새 토큰 받아가." (재발급 완료)
  6. 앱: 새 토큰으로 아까 실패했던 '저장' 요청을 다시 보냅니다.
  7. 철수: (아무 일도 없었다는 듯) "저장 완료!" 😄

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)

  1. DB 값과 비교 (equals):
  • 클라이언트가 보낸 Refresh Token이 유효한 토큰이더라도, DB에 저장된 최신 값과 다르면 거절해야 합니다.
  • 시나리오: 해커가 어제 탈취한 리프레시 토큰으로 오늘 접속하려 합니다. 하지만 철수(주인)가 그사이 한 번 더 재발급을 받았다면 DB 값은 바뀌어 있습니다. 해커의 요청은 즉시 차단됩니다.
  1. 토큰 로테이션 (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. 로그인 시 (1~4번):
    • Access Token과 Refresh Token을 둘 다 만듭니다.
    • 중요: 이때 Refresh Token은 DB에 저장해둡니다. (나중에 비교하기 위해)
  2. 재발급 시 (5~11번):
    • 클라이언트가 보낸 Refresh Token이 유효한지 1차 검사합니다.
    • DB에 있는 값과 똑같은지 2차 검사(8번)합니다. (탈취된 구버전 토큰 방지)
    • 검증이 끝나면 새 토큰 세트를 만들고, DB에 있는 토큰도 새것으로 교체(10번, Rotation)합니다.

🎉 마무리

이제 여러분의 시스템은 "30분마다 만료되지만, 사용자는 눈치채지 못하는" 안전하고 편리한 시스템이 되었습니다.
[최종 정리]

  1. 사용자는 로그인 시 7일짜리 Refresh Token을 받습니다.
  2. 30분 후 Access Token이 만료되면, 앱(프론트엔드)이 자동으로 /reissue를 호출합니다.
  3. 서버는 DB 확인 후 토큰을 교체(Rotation)해서 돌려줍니다.
  4. 사용자는 로그인 풀림 없이 쾌적하게 서비스를 이용합니다.
반응형
Comments