로그인 시 사용자별로 토큰을 발급해 인증 체계를 구축하고, 토큰 유효 기간을 조정하도록 기능을 구현했다. 원래는 Access Token만 발급하려다가 실무에서는 Refresh Token도 같이 발급해 보안을 강화하는 쪽으로 한다고 해 이번 프로젝트에서 Refresh Token도 같이 도입해보기도 했다.
@Component
public class JwtUtil {
@Value("${JWT_SECRET}")
private String SECRET_KEY;
private final long ACCESS_EXP_TIME = 1000L * 60 * 60; // 1시간
//private final long ACCESS_EXP_TIME = 1000L * 60 * 10; // 10분
//private final long REFRESH_EXP_TIME = 1000L * 60 * 60 * 24; // 24시간
public String generateAccessToken(User user) {
return Jwts.builder()
.setSubject(user.getStudentNum())
.claim("name", user.getName())
.claim("type", "access")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_EXP_TIME))
.signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256)
.compact();
}
public String generateRefreshToken(User user) {
// 한국표준시 타임존으로 Calendar 생성
TimeZone seoulTz = TimeZone.getTimeZone("Asia/Seoul");
Calendar calendar = Calendar.getInstance(seoulTz);
// 현재 시간 기준으로 '다음 날 오전 9시' 고정
calendar.set(java.util.Calendar.HOUR_OF_DAY, 9);
calendar.set(java.util.Calendar.MINUTE, 0);
calendar.set(java.util.Calendar.SECOND, 0);
calendar.set(java.util.Calendar.MILLISECOND, 0);
if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
calendar.add(java.util.Calendar.DATE, 1);
}
Date expireAt = calendar.getTime();
return Jwts.builder()
.setSubject(user.getStudentNum())
.claim("type", "refresh")
.setIssuedAt(new Date())
.setExpiration(expireAt)
.signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256)
.compact();
}
public TokenStatus validateToken(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(token)
.getBody();
String type = claims.get("type", String.class);
if (!"access".equals(type)) {
return TokenStatus.INVALID;
}
return TokenStatus.AUTHENTICATED;
} catch (ExpiredJwtException e) {
return TokenStatus.EXPIRED;
} catch (JwtException e) {
return TokenStatus.INVALID;
}
}
public TokenStatus validateRefreshToken(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(token)
.getBody();
String type = claims.get("type", String.class);
if (!"refresh".equals(type)) {
return TokenStatus.INVALID;
}
return TokenStatus.AUTHENTICATED;
} catch (ExpiredJwtException e) {
return TokenStatus.EXPIRED;
} catch (JwtException e) {
return TokenStatus.INVALID;
}
}
public String extractUsername(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public String extractUsernameFromRefresh(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
}
public UserResponseDTO.UserResultRsDTO createUser(UserRequestDTO.CreateUserRqDTO request){
// 기존 사용자 확인
User user = userRepository.findByStudentNum(request.getStudent_num());
if (user == null) {
// 없으면 신규 생성
user = userRepository.save(UserConverter.createUser(request));
}
// access token 생성
String accessToken = jwtUtil.generateAccessToken(user);
// refresh token 생성 및 DB 저장
String refreshToken = jwtUtil.generateRefreshToken(user);
user.setRefreshToken(refreshToken);
userRepository.save(user); // 반드시 저장해서 서버가 관리
return UserResponseDTO.UserResultRsDTO.builder()
.access_token(accessToken)
.refresh_token(refreshToken)
.user_id(user.getId())
.build();
}
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
// 로그인, 인증 엔드포인트와 OCR 업로드는 필터 건너뜀
return path.startsWith("/user/login")
|| path.startsWith("/user/ocr");
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.validateToken(token) == TokenStatus.AUTHENTICATED) {
String studentNum = jwtUtil.extractUsername(token);
User user = userRepository.findByStudentNum(studentNum);
if (user != null) {
CustomUserDetails userDetails =
new CustomUserDetails(user.getId(), user.getStudentNum(), user.getName());
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
}
filterChain.doFilter(request, response);
}
}
@GetMapping("/refresh")
public ResponseEntity<?> refreshAccessToken(@RequestHeader("Authorization") String refreshToken) {
if (refreshToken.startsWith("Bearer ")) {
refreshToken = refreshToken.substring(7);
}
// refresh Token 유효성 검증
if (userService.validateRefreshToken(refreshToken) != TokenStatus.AUTHENTICATED) {
return ResponseEntity.status(401).body("유효하지 않거나 만료된 리프레시 토큰입니다");
}
// 토큰에서 studentNum 추출
String studentNum = userService.extractUsernameFromRefresh(refreshToken);
// 사용자 조회 및 저장된 refresh token과 일치 확인 + 새 Access Token 발급
UserResponseDTO.TokenPairRsDTO tokenPairRsDTO = userService.reissueAccessToken(studentNum, refreshToken);
return ResponseEntity.ok(tokenPairRsDTO);
}
모든 기능을 개발해 프론트엔드와 통합한 뒤 배포 환경에서 테스트해 보니, 버튼 클릭 시 요청이 계속 대기 상태로 남아 있었다.
먼저 서버 인스턴스의 CPU를 증설하자 대기 현상이 해소되었지만, 데이터베이스 처리에서 여전히 지연이 발생해 응답 속도가 느렸다.
결국 MySQL에도 CPU 자원을 추가로 할당하니 API 응답 시간이 눈에 띄게 개선되어, 프론트엔드에서 정상적으로 데이터를 받아올 수 있었다.
(결론: 돈을 쓰면 잘 돌아간다.)
정상적으로 보이던 GitHub 커밋 내역이 갑자기 표시되지 않는 경우, 브라우저 개발자 도구 네트워크 탭에서 ‘Disable cache’ 옵션을 체크한 뒤 페이지를 새로 고침하자.
프론트엔드와 백엔드의 도메인을 동일하게 설정하면 CORS 오류 발생을 최소화할 수 있다.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Value("${frontend.domain}")
private String frontendDomain;
@Override
public void addCorsMappings(CorsRegistry corsRegistry) {
corsRegistry.addMapping("/**")
.allowedOriginPatterns(
"https://" + frontendDomain,
"http://localhost:3003",
"https://localhost:3003"
)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("Authorization")
.allowCredentials(true)
.maxAge(3600L);
}
}
사실 이번 프로젝트를 진행하며 예상치 못한 어려움이 많았다.
프로젝트 기간이 생각보다 짧았던 데다, 팀원 한 분이 개인 사정으로 작업을 늦게 시작하셔서 마감 일주일 전부터 본인 담당 업무를 본격적으로 진행해야 했다. 전전날 자정에야 모든 작업이 마무리되어 배포를 완료했고, 이후 하루 동안 배포된 환경에서 발생한 오류를 빠르게 수정했다.
이 과정을 통해 두 가지를 깨달았다.
사람마다 성향과 작업 스타일이 다르니, 세부 사항에 지나치게 연연하기보다는 큰 그림을 유지하는 것이 중요하다.
팀 프로젝트는 개인에게 강제력을 행사하기 어렵기 때문에, 누군가 일정이 늦어질 때는 다른 팀원들이 자연스럽게 빈틈을 메우며 협력해야 한다.
비록 배포 후 예외 처리를 하루밖에 하지 못해 완벽하게 다듬지 못한 부분이 아쉽지만, 첫 배포에서 약 350명의 로그인을 기록하며 큰 보람을 느꼈다. 다음 프로젝트에는 접속자 수를 실시간으로 확인할 수 있는 기능도 추가해, 배포 종료 후에도 총 접속자 수를 확인할 수 있도록 구현하고 싶다.