
현재 지난 부트캠프 때 미니 프로젝트로 진행한 캠핑온탑 프로젝트를 리팩토링하고 있다.
당시 백엔드에서 JWT 토큰을 access token으로 발급하여 JWT Filter를 통과하면 로그인이 되도록 구현했었다.
하지만 이 방법은 유저가 로그인한 후, access token의 만료 시간이 지나면 이를 갱신할 방법이 없기 때문에 무조건 자동으로 로그아웃이 되어 다시 로그인을 해야 했다.
정리하면, 아래와 같은 이유들로 인해 필수적으로 개선을 해야하는 상황이었다.
access token의 수명이 짧기 때문에 사용자의 세션이 자주 만료되어 로그인을 자주 해야한다.access token은 대부분의 요청에 포함되기 때문에, 만약 탈취될 경우 큰 보안 문제가 발생할 수 있다.- 사용자가 로그인을 반복해서 하면서 서비스에 대한 불만족과 짜증, 분노를 유발할 수 있다.
그래서 refresh token을 access token을 발급할 때 함께 발급하여 access token이 만료되었을 때 refresh token으로 access token을 갱신하는 방식을 도입하기로 했다.
refresh token을 도입하면 아래와 같은 효과들을 기대할 수 있다.
access token의 수명이 짧아도 로그인 세션을 길게 유지할 수 있다.access token이 탈취되더라도refresh token없이 새로운 토큰을 발급받을 수 없으므로, 보안이 강화된다.- 로그인 세션이 자동으로 유지되어 사용자가 로그인을 자주 하지 않아도 된다.
그래서 access token은 자주 사용되고 요청마다 포함되기 때문에 유효 기간을 짧게 설정하고 refresh token은 유효 기간이 길고, 서버에 저장하여 사용하는 토큰으로 설정했다. 그리고 access token이 만료되면 이 refresh token으로 새로운 access token을 발급받는다.
이유
로컬 스토리지는 클라이언트 측 스크립트에서 쉽게 접근할 수 있지만,쿠키는HttpOnly옵션을 통해 클라이언트 측 스크립트에서 접근할 수 없도록 설정할 수 있다.쿠키는 자동으로HTTP요청에 포함되므로, 각 요청에 토큰을 수동으로 포함할 필요가 없다.CSRF공격을 방지하기 위해쿠키의SameSite옵션을 설정할 수 있다.
가장 큰 이유는 현재 상황에서 취할 수 있는 최선의 보안 강화 방법이기 때문이다.
그리고 두 번째 이유는 사실 현재까지는 아직 살리지 못했는데, 백엔드까지 쿠키를 사용하는 방식으로 수정한다면 더 큰 의미를 가질 수 있기에 추후 수정할 것이다.
장점
HttpOnly, Secure, SameSite 옵션을 통해 보안을 강화할 수 있다. 단점
XSS 공격에 취약할 수 있다. 그래서 이를 방지하기 위해 HttpOnly 옵션을 사용한다. 선착순 쿠폰 발급 시스템에서 Redis를 사용했기 때문에 이미 서비스에서 Redis가 사용되고 있어서 추가적으로 설정을 하지 않고 바로 사용할 수 있다.
Redis는 인메모리 데이터베이스로, 매우 빠른 읽기/쓰기 성능을 제공한다.
토큰 검증과 갱신 과정에서 높은 성능이 요구되기 때문에 Redis를 사용하면 전체 시스템의 응답 속도가 향상된다.
Redis는 클러스터링 및 샤딩을 통해 확장성이 뛰어나다.
사용자 수가 증가하더라도 토큰 관리의 부하를 분산시켜 시스템의 안정성을 유지할 수 있다.
Redis는 인메모리 데이터베이스이지만, 필요에 따라 데이터의 지속성을 보장하는 옵션도 제공하여 시스템 장애 시에도 데이터를 복구할 수 있다.
토큰 외에도 사용자 세션 데이터를 효율적으로 관리할 수 있다.
Redis는 다양한 데이터 구조를 지원하여 세션 정보, 캐시 데이터 등을 효과적으로 저장하고 관리할 수 있다.
Redis는 각 키마다 TTL(만료 시간)을 설정할 수 있어, 토큰의 유효 기간을 쉽게 관리할 수 있다.
access token refresh token의 만료 시간을 Redis에서 설정하고 관리하면, 토큰 만료 후 자동으로 삭제되어 메모리 관리를 쉽게 할 수 있다.
access token과 refresh token을 발급받는다.access token이 만료되면 클라이언트 측에서 refresh token을 사용하여 새로운 access token을 발급받는다.access token을 발급받으면 이를 cookie에 다시 저장한다.access token의 만료 시간을 확인하고, 만료되기 1분 전에 refresh token으로 새로운 access token을 발급받는다.cookie에서 삭제하고, 갱신 interval을 해제한다.version: '3'
services:
redis:
image: redis:latest
container_name: redis
ports:
- "6379:6379"
volumes:
- ./redis/data:/data
- ./redis/conf/redis.conf:/usr/local/conf/redis.conf
labels:
- "name=redis"
- "mode=standalone"
restart: always
command: redis-server /usr/local/conf/redis.conf
local 환경에서 Redis를 실행할 docker-compose.yml 파일.
@Configuration
@RequiredArgsConstructor
public class RedisConfig {
private final RedisProperty redisProperty;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperty.getRedisHost(), redisProperty.getRedisPort());
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
@Getter
@Setter
@Component
@ConfigurationProperties("spring.redis")
public class RedisProperty {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
}
이전에 사용했던 Redis 설정 클래스를 그대로 사용한다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class PostLoginUserDtoReq {
private String username;
private String password;
}
@Tag(name="User", description = "User CRUD")
@Api(tags = "User")
@RestController
@Slf4j
@RequiredArgsConstructor
@CrossOrigin("*")
@RequestMapping("/user")
public class UserController {
private final UserService userService;
@Operation(summary = "User 로그인",
description = "회원가입한 유저의 로그인을 하는 API입니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "500",description = "서버 내부 오류")})
@PostMapping( "/login")
public ResponseEntity login(@Valid @RequestBody PostLoginUserDtoReq req) {
return ResponseEntity.ok().body(userService.login(req));
}
@PostMapping("/refresh")
public ResponseEntity<PostLoginUserDtoRes> refreshToken(@RequestBody Map<String, String> refreshTokenRequest) {
String refreshToken = refreshTokenRequest.get("refreshToken");
if (refreshToken == null || refreshToken.isEmpty()) {
return ResponseEntity.badRequest().build();
}
try {
return ResponseEntity.ok(userService.refreshToken(refreshToken));
} catch (UserException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
Spring Security에서 제공하는 비밀번호를 암호화하는 설정 클래스 .
@Service
@Slf4j
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final RedisTemplate<String, String> redisTemplate;
@Value("${jwt.secret-key}")
private String secretKey;
@Value("${jwt.token.expired-time-ms}")
public Integer expiredMs;
@Value("${jwt.token.refresh-expired-time}")
public Integer refreshExpiredMs;
public PostLoginUserDtoRes login(PostLoginUserDtoReq req) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword()));
if (authentication.isAuthenticated()) {
Long id = ((User) authentication.getPrincipal()).getId();
String email = ((User) authentication.getPrincipal()).getEmail();
String nickname = ((User) authentication.getPrincipal()).getNickName();
String jwt = JwtUtils.generateAccessToken(email, nickname, id, secretKey, expiredMs);
String refreshToken = JwtUtils.generateRefreshToken(email, nickname, id, secretKey, refreshExpiredMs);
redisTemplate.opsForValue().set("REFRESH_TOKEN_" + id, refreshToken, refreshExpiredMs, TimeUnit.MILLISECONDS);
PostLoginUserDtoRes loginRes = PostLoginUserDtoRes.builder()
.token(jwt)
.refreshToken(refreshToken)
.build();
return loginRes;
}
throw new UserException(ErrorCode.AUTHENTICATION_FAIL);
}
public PostLoginUserDtoRes refreshToken(String refreshToken) {
Claims claims = JwtUtils.extractAllClaims(refreshToken, secretKey);
String email = claims.get("email", String.class);
String nickName = claims.get("nickname", String.class);
Long id = claims.get("id", Long.class);
String storedRefreshToken = redisTemplate.opsForValue().get("REFRESH_TOKEN_" + id);
if (storedRefreshToken != null && storedRefreshToken.equals(refreshToken) && JwtUtils.validate(refreshToken, secretKey)) {
// 새로운 access token과 refresh token 발급 (보안 강화)
String newAccessToken = JwtUtils.generateAccessToken(email, nickName, id, secretKey, expiredMs);
String newRefreshToken = JwtUtils.generateRefreshToken(email, nickName, id, secretKey, refreshExpiredMs);
// 저장되어 있는 refresh token 갱신
redisTemplate.opsForValue().set("REFRESH_TOKEN_" + id, newRefreshToken, refreshExpiredMs, TimeUnit.MILLISECONDS);
return PostLoginUserDtoRes.builder()
.token(newAccessToken)
.refreshToken(newRefreshToken)
.build();
} else {
throw new UserException(ErrorCode.AUTHENTICATION_FAIL);
}
}
}
앞서 정리한 시나리오대로 아이디와 비밀번호가 맞으면 인증, 인가 과정을 거쳐 필터를 통과해서 최종적으로 성공 시 토큰을 발급받게 된다.
그리고 refresh token은 Redis에 저장된다.
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
String token;
if (header != null && header.startsWith("Bearer ")) {
token = header.split(" ")[1];
} else {
filterChain.doFilter(request, response);
return;
}
String email = JwtUtils.getMemberEmail(token, secretKey);
Long memberId = JwtUtils.getMemberId(token, secretKey);
String memberNickname = JwtUtils.getMemberNickname(token, secretKey);
if (!JwtUtils.validate(token, secretKey)) {
filterChain.doFilter(request, response);
return;
}
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
User.builder().id(memberId).email(email).nickName(memberNickname).build(), null,
null
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
// @/utils/authCookies.js
// 로그인 성공 후 토큰을 쿠키에 저장하는 함수
export function setTokenCookies(accessToken, refreshToken) {
const hour = 3600000; // 밀리초 단위로 1시간
const week = hour * 24 * 7; // 밀리초 단위로 1주일
document.cookie = `accessToken=${accessToken}; path=/; max-age=${hour}; Secure; SameSite=Strict`;
document.cookie = `refreshToken=${refreshToken}; path=/; max-age=${week}; Secure; SameSite=Strict`;
}
// 쿠키에서 토큰을 추출하는 함수
export function getTokenFromCookie(name) {
// Simplify the regex by removing unnecessary escapes
let matches = document.cookie.match(new RegExp(
"(?:^|; )" + name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1') + "=([^;]*)"
));
return matches ? decodeURIComponent(matches[1]) : undefined;
}
// 로그아웃시 쿠키에서 토큰 삭제
export function deleteTokenCookies() {
document.cookie = "accessToken=; path=/; max-age=-1";
document.cookie = "refreshToken=; path=/; max-age=-1";
}
쿠키에 저장하는 방식으로 변경하면서 자주 사용되는 메소드를 빼서 따로 정리.
// @/stores/useMemberStore.js
import { defineStore } from "pinia";
import axios from "axios";
import VueJwtDecode from "vue-jwt-decode";
import { getTokenFromCookie, setTokenCookies, deleteTokenCookies } from "@/utils/authCookies";
// 쿠키 관리 유틸리티 임포트
const backend = "http://localhost:8080";
export const useMemberStore = defineStore("member", {
state: () => ({
isAuthenticated: false,
decodedToken: null,
tokenRefreshInterval: null,
// 토큰 갱신 인터벌을 저장하기 위한 변수
}),
actions: {
async login(email, password) {
try {
let loginMember = { username: email, password: password };
let response = await axios.post(`${backend}/user/login`, loginMember);
if (response.status === 200 && response.data.token) {
setTokenCookies(response.data.token, response.data.refreshToken);
// 로그인 시 받은 토큰을 쿠키에 저장
let userClaims = VueJwtDecode.decode(response.data.token);
this.setDecodedToken(userClaims);
this.isAuthenticated = true;
this.startTokenRefreshInterval();
// 로그인 시 토큰 갱신 인터벌 설정
} else {
console.error("토큰 발급 실패");
}
} catch (error) {
console.error("로그인 실패:", error);
}
},
// 리프레시 토큰을 사용하여 액세스 토큰 갱신
async refreshAccessToken() {
try {
const refreshToken = getTokenFromCookie('refreshToken');
// 쿠키에서 리프레시 토큰을 가져옴
if (!refreshToken) {
throw new Error("사용 가능한 리프레시 토큰 없음.");
// 리프레시 토큰이 없으면 예외 처리
}
let response = await axios.post(`${backend}/user/refresh`, { refreshToken });
if (response.status === 200 && response.data.token && response.data.refreshToken) {
setTokenCookies(response.data.token, response.data.refreshToken);
// 새로운 액세스 토큰과 리프레시 토큰을 쿠키에 저장
let userClaims = VueJwtDecode.decode(response.data.token);
this.setDecodedToken(userClaims);
this.isAuthenticated = true;
} else {
console.error("access token 갱신 실패");
this.logout();
// 토큰 갱신 실패 시 로그아웃 처리
}
} catch (error) {
console.error("refresh token 에러:", error);
this.logout();
}
},
setDecodedToken(decodedToken) {
this.decodedToken = decodedToken;
},
// 로그아웃 시 모든 토큰 삭제 및 갱신 인터벌 해제
logout() {
deleteTokenCookies(); // 모든 토큰을 쿠키에서 삭제
this.isAuthenticated = false;
this.decodedToken = null;
clearInterval(this.tokenRefreshInterval);
// 로그아웃 시 토큰 갱신 인터벌 해제
},
// 액세스 토큰의 만료 시간을 체크하고 만료 1분 전에 갱신
checkTokenExpiration() {
const accessToken = getTokenFromCookie('accessToken');
// 쿠키에서 액세스 토큰을 가져옴
if (accessToken) {
const decoded = VueJwtDecode.decode(accessToken);
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp - currentTime < 60) {
// 1분 전에 갱신
this.refreshAccessToken();
}
}
},
// 주기적으로 토큰 만료를 확인하여 갱신
startTokenRefreshInterval() {
if (this.tokenRefreshInterval) {
clearInterval(this.tokenRefreshInterval);
}
this.tokenRefreshInterval = setInterval(() => {
this.checkTokenExpiration();
}, 30000); // 30초마다 토큰 만료 확인
},
},
});
프론트엔드에서 로그인 후 cookie에 토큰들을 저장하고 만료 여부를 추적해서 만료되면 백엔드에 갱신 요청을 보내는 방식으로 구현했다.
물론 다른 대안들도 존재한다.
Axios Interceptor 사용trigger로 만료 여부를 확인하고 갱신.하지만 Interval만 사용해 구현한 이유는 우선 구현이 간단하고 안정적으로 작동하며, 주기적으로 토큰을 갱신함으로써 사용자들이 자연스럽고 편안하게 서비스를 이용할 수 있기 때문이다. (로그인을 직접 다시 하지 않아도 되니까)
비록 네트워크 요청이 다소 증가할 수 있지만, 이를 통해 사용자 세션이 유지되므로 보안성과 편리성을 동시에 확보할 수 있다. 그리고 설정된 주기로 인해 예측 가능한 방식으로 동작하여 개발 및 디버깅이 용이하고 Interval만 사용해도 충분히 기능이 작동하는데 문제가 전혀 없기 때문에 Interval로 만료 시간을 계산하여 토큰을 갱신하도록 했다.




access token 만료 시 refresh token으로 access token 갱신




refresh token으로 access token이 갱신되는 것을 확인했고, cookie에도 잘 저장되는 모습을 확인했다. 마지막으로 Redis에도 저장된 모습을 확인하는 것을 끝으로 구현을 마쳤다.
비교적 간단한 방법을 사용해서 토큰을 갱신시켜 로그인을 유지할 수 있도록 했는데, 작동하는데 전혀 문제가 없고 로그인 유지가 잘 되어서 다행이라고 생각한다.
수정 이전에는 액세스 토큰만 있었기 때문에 만료 시간이 지나면 자동으로 로그아웃이 되어서 불편했었다. 그렇다고 만료 시간을 무한히 늘릴 수도 없는 노릇이었다.
그래서 내가 만든 서비스이지만 매우 불편했다. 그리고 다른 프로젝트에서도 리프레시 토큰으로 로그인 로직을 만들었었지만 내가 담당하지 않았기에 명확한 이해를 하지 못했었다.
그리고 부트캠프 때 미니 프로젝트 기간에 내가 비슷하게 구현하다가 마감 시간이 임박해 미처 다 완성하지 못했었다. 하지만 이번에 다시 새롭게 구현을 하면서 정리를 한 번 하니까 이제는 스프링 시큐리티의 로그인 관련 부분에 대해서 머리 속에서 어느 정도 정리가 되었다.
한창 수업을 들을 때, 스프링 시큐리티에 대해, 그리고 내부 로직에 대해 (ex. AuthenticationProvider, FilterChain, AuthenticationManager, UserDetails 등) 배우면서 이해하지 못하고 그저 어려운 영역이라고 생각해서 단순한 암기를 하려고 했었다. 물론 여전히 공부해서 내 것으로 만들 영역이 많이 있지만 이번 기회에 전체 흐름에 대해서 이해하고 코드를 보니 수정해야할 부분들이 눈에 보여서 비교적 빠르게 구현할 수 있었다.
추후에 MSA로 분리하게 되면 인증 서버를 따로 둘 것이 아니라면 게이트웨이에서 로그인 인증, 인가를 처리하도록 할 것이므로 아마 스프링 시큐리티 부분은 빠지게 될 것이다. 그리고 취업을 하면 내가 로그인 관련된 부분에 개발을 할 일은 상대적으로 적을 것이다.
하지만 백엔드 엔지니어로서 시큐리티를 명확하게 아는 사람과 모르는 사람은 다르다고 생각한다. 그래서 내 기술 스택에 스프링 시큐리티 적고 분명히 설명할 수 있도록 더 공부를 할 것이다.