로그인 관련하여 사용자 경험, 보안을 모두 고려해서 refresh token 을 설계하는 것은 매우 기본적이면서도 중요한 문제이다.
이번에는 frontend- backend 각각에서 refresh token 처리를 위한 논리 중 기본적인 뼈대를 정리해보았다.
각각의 구현 방법과 장/단점도 포함되어있다.
- 사용자가 로그인하면
- 서버는 Access Token과 Refresh Token을 발급한다.
- Access Token은 메모리(React의 State, Redux, Vuex 등)에 저장한다.
- Refresh Token은 보안을 위해 HttpOnly Secure Cookie에 저장하거나, 로컬 스토리지에 저장(이 방식은 위험한 편)한다.
- API 요청 시
- 프론트엔드는 Access Token을 Authorization: Bearer {accessToken} 헤더에 추가해 API 요청을 보낸다.
- Access Token이 만료되면
- 서버에서 401 Unauthorized 응답을 반환한다.
- 프론트엔드는 401 응답을 감지하고, 저장된 Refresh Token을 사용해 새로운 Access Token을 요청한다.
- Refresh Token을 이용해 Access Token 재발급 요청
- 프론트엔드는 /auth/refresh 와 같은 엔드포인트로 요청을 보낸디.
- 이때 Refresh Token은:
- HttpOnly Cookie에 저장된 경우 → 자동으로 요청에 포함됨
- 로컬 스토리지에 저장된 경우 → 요청 헤더에 추가 (Authorization: Bearer {refreshToken})
- 서버의 응답 처리
- 서버는 Refresh Token의 유효성을 검증한다.
- 유효하면 새로운 Access Token을 발급하여 응답한다.
- 프론트엔드는 이 Access Token을 다시 메모리(Redux 등)에 저장하여 이후 요청에 사용한다.
- Refresh Token도 만료되었을 경우
- 사용자는 다시 로그인해야 한다.
프론트엔드 코드 예시 (JavaScript/React)
// Axios 인스턴스 생성 (Access Token을 자동으로 추가)
import axios from 'axios';
// 기본 Axios 인스턴스
const api = axios.create({
baseURL: '<http://localhost:8080/api>',
withCredentials: true // HttpOnly Cookie 사용 시 필요
});
// Access Token을 요청 헤더에 추가
api.interceptors.request.use((config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// 응답 인터셉터 (Access Token이 만료되면 Refresh Token으로 새로 요청)
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
try {
const refreshResponse = await axios.post(
'<http://localhost:8080/auth/refresh>',
{},
{ withCredentials: true }
);
const newAccessToken = refreshResponse.data.accessToken;
localStorage.setItem('accessToken', newAccessToken);
// 원래 요청 재시도
error.config.headers.Authorization = `Bearer ${newAccessToken}`;
return api(error.config);
} catch (refreshError) {
// Refresh Token도 만료된 경우 로그아웃 처리
console.error('Session expired, please login again');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default api;
일반적인 요청 흐름 - backend
- 로그인 시 Refresh Token 발급 및 저장
- 사용자가 로그인하면 Access Token과 Refresh Token을 발급한다.
- Access Token은 JWT 형식으로 프론트엔드에 전달한다.
- Refresh Token은 서버에 저장하거나 HttpOnly Secure Cookie를 통해 클라이언트에 전달한다.
- Access Token이 만료되었을 때
- 프론트엔드는 저장된 Refresh Token을 사용하여 새로운 Access Token을 요청한다.
- 백엔드는 저장된 Refresh Token의 유효성을 검증한 후 새 Access Token을 발급한다.
- Refresh Token 사용 후 처리
- 보안을 위해 사용한 Refresh Token은 즉시 폐기하고 새로 발급한다. 이를 통해 도난된 Refresh Token이 재사용되는 것을 방지한다.
- 로그아웃 시 Refresh Token 제거
- 사용자가 로그아웃하면 해당 사용자의 Refresh Token을 서버에서 삭제한다. 이를 통해 더 이상 해당 토큰으로 Access Token을 발급받을 수 없게 한다.
Refresh Token의 저장 방법 - backend
1. Redis에 저장
- 빠른 속도와 만료 시 자동 삭제 기능 제공
- 대규모 시스템에서도 성능에 영향이 적음
- TTL 설정으로 Refresh Token이 만료되면 자동 삭제됨
Redis에 저장하는 방식 예시
- Key: refresh_token:{userId}
- Value: Refresh Token 문자열
- Expiration: Refresh Token의 만료 시간 (예: 7일)
Redis에 Refresh Token 저장 예시 (Spring Boot 코드)
@Service
public class RefreshTokenService {
@Autowired
private StringRedisTemplate redisTemplate;
private final long REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60; // 7일 (단위: 초)
// Refresh Token 저장
public void saveRefreshToken(String userId, String refreshToken) {
redisTemplate.opsForValue().set("refresh_token:" + userId, refreshToken, REFRESH_TOKEN_EXPIRATION, TimeUnit.SECONDS);
}
// Refresh Token 조회
public String getRefreshToken(String userId) {
return redisTemplate.opsForValue().get("refresh_token:" + userId);
}
// Refresh Token 삭제 (로그아웃 시 사용)
public void deleteRefreshToken(String userId) {
redisTemplate.delete("refresh_token:" + userId);
}
}
Redis에 저장된 Refresh Token의 예시
Key: refresh_token:12345
Value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Expiration: 604800초 (7일)
2: 데이터베이스에 저장 (MySQL, PostgreSQL, MongoDB)
- 데이터가 영구적으로 저장되므로 로그 및 감사에 적합
- 사용자가 로그아웃하거나 토큰이 만료되면 수동으로 삭제 필요
Refresh Token 저장 및 검증 로직
@Service
public class RefreshTokenService {
@Autowired
private RefreshTokenRepository refreshTokenRepository;
private final long REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60 * 1000; // 7일 (단위: 밀리초)
// Refresh Token 저장
public void saveRefreshToken(String userId, String refreshToken) {
RefreshToken token = new RefreshToken();
token.setUserId(userId);
token.setToken(refreshToken);
token.setExpiryDate(Instant.now().plusMillis(REFRESH_TOKEN_EXPIRATION));
refreshTokenRepository.save(token);
}
// Refresh Token 검증 및 조회
public Optional<RefreshToken> validateRefreshToken(String refreshToken) {
return refreshTokenRepository.findByToken(refreshToken)
.filter(token -> !token.getExpiryDate().isBefore(Instant.now()));
}
// Refresh Token 삭제 (로그아웃 시 사용)
public void deleteRefreshToken(String refreshToken) {
refreshTokenRepository.deleteByToken(refreshToken);
}
}
3: 클라이언트(HttpOnly Cookie)에만 저장 (서버에 저장하지 않음)
- Stateless 방식으로 서버의 부하가 최소화됨
- 서버가 상태를 유지할 필요 없음 (특히 마이크로서비스 환경에 유리함)
- 단점:
- Refresh Token이 탈취될 경우 이를 무효화할 방법이 없음.
- 보안이 중요한 서비스에서는 권장되지 않음
- Refresh Token을 HttpOnly Secure Cookie로 프론트엔드에만 저장하는 방식으로 구현 가능.
- Access Token이 만료되면 프론트엔드가 Cookie에 저장된 Refresh Token을 사용해 Access Token을 재발급 요청
보안상 주의할 점
1. Refresh Token의 만료 기간 설정
- 너무 긴 수명은 보안에 위험하므로 7일 ~ 30일 정도로 해야한다.
- 보안이 더 중요한 경우 단기 Refresh Token(예: 7일)과 장기 Refresh Token(예: 30일)을 조합하여 사용하기도 한다.
2. Refresh Token 재발급 원칙 (One-Time Use)
- Refresh Token은 사용 시마다 새로운 Refresh Token을 발급하여 기존 토큰을 즉시 폐기해야한다.
- 만약 유출되었을 경우 바로 사용될 가능성을 차단하게 됨.
3. Refresh Token 사용 제한
- 사용자의 IP 주소 및 기기 정보를 기록해서, 인증 요청이 동일한 환경에서만 허용되도록 제한하는 것도 중요하다.
4. Refresh Token 재사용 방지 (Replay Attack 방지)
- Redis를 사용할 때 Refresh Token의 사용 여부를 표시한다. (flag 등)
- 또는 한 번 사용된 Refresh Token은 즉시 삭제하여 재사용 공격을 방지한다.
5. 로그아웃 시 Refresh Token 삭제
- 기본 중의 기본.
- 사용자가 로그아웃하면 서버에서 해당 사용자의 Refresh Token을 즉시 삭제한다.
- 프론트엔드에서도 HttpOnly Cookie를 제거하거나 로컬 스토리지에서 삭제한다.
'개발 > TIL' 카테고리의 다른 글
java record 알차게 사용하기 (0) | 2025.02.25 |
---|---|
@EntityGraph 어노테이션 (0) | 2025.02.24 |
spring boot 애플리케이션에서 null 데이터 처리하기 (0) | 2025.02.22 |
단 한 줄로 spring boot 애플리케이션 성능 올리기 - gzip (0) | 2025.02.21 |
Java의 Optional<T>로 안전하게 null 데이터 처리하기 (0) | 2025.02.20 |