이번에는 로그인쪽 JWT를 통해서 로그인을 구현해볼 예정이다 앞 글 JWT 세션쿠키 전략 및 구현에서 로그인에서 주요 사용되는 것을 공부했고 그 한계점까지 알아봤다.
만약 이글을 보고있으면 앞글을 보고오는게 좋을거같다
보통 기술 구현에만 집중하다 보면 잊게되는데 내가 JWT를 구현하면서 궁금하게된 리스트 목록이다
실제로 토큰을 구현하다보면 사용자 UX를 세울일이 많아진다 UX를 고려하지않으면 사용자가 자주 로그인 해야하는 경우가 생긴다 그렇다고 사용자 UX만 고려하자니 사용자의 정보 탈취 가능성도 높아진다 편의와 보안은 Trade off인경우가 많다 jwt 토큰 또한 그렇다
그럼 여기서 또 궁금한게 생긴다
JWT 토큰은 뭘까? 왜 사용할까? 장단점은?
JWT의 단점을 보완하는 전략은?
우리 프로젝트에 맞는 JWT 보안 전략은?
Json Web Token의 약자로 일반적으로 클라이언트와 서버 사이에 통신할때 권한을 위해 사용하는 토큰이다 웹 상에서 정보를 Json 형태로 주고 받기 위해서 표준 규약에 따라 생성한 암호화된 토큰으로 복잡하고 읽을 수 없는 String 형태로 저장되어있다.
클라이언트 → 서버: 로그인 요청
서버: 사용자 인증 및 토큰 발급
클라이언트: 토큰 저장 및 인증 요청 시 포함
서버: 토큰 검증 및 요청 처리
JWT는 아래 사진과 같이 세가지 파트로 구성되어있다

SHA256은 단순한 해싱 알고리즘으로, 입력된 데이터를 고정된 길이의 해시 값으로 변환하는 역할을 한다. 이는 데이터의 무결성을 검증하는 데 사용되지만, 단독으로는 인증 기능을 제공하지 않는다. 반면, HMAC SHA256은 해싱 과정에서 암호화 키(secret key)를 추가로 사용하는 알고리즘이다. 이 방식은 단순한 SHA256 해시와 달리, 특정한 키를 알고 있는 경우에만 동일한 해시 값을 생성할 수 있도록 한다. 따라서 데이터의 무결성뿐만 아니라, 인증 기능도 함께 제공할 수 있다.
JWT는 클라이언트의 저장소에 의지한다는 점에서 쿠키와 비슷하지만, 데이터 조작 검증 가능성에서 차이가 있다. JWT는 HMAC SHA256 알고리즘 혹은 비대칭키를 사용하기 때문에 해당 토큰이 자신이 발급한 객체인지 확인할 수 있다. 하지만 쿠키와 비슷한 단점이 여전히 존재한다. 탈취되면 지속적으로 악용될 수 있다는 점이다. 이를 막으려면 어떻게 해야 할까? '지속성'을 조정해주거나, '악용'을 막는 방법이 있다. 악용을 막기는 어렵다. 악의적인 사용자는 선량한 사용자의 네트워크를 엿들을 것인데, 이를 서버가 막기는 어렵다. 그렇다면 '지속성'을 조정해주는 방법이 있다. 이를 이용하여 JWT의 단점을 보완하는 방법에 대해 알아보자.
Access Token은 앞서 언급한 JWT의 '지속성' 문제를 보완하는 방법이다. 즉, 만료 기한을 두어 토큰이 악용될 위험을 최소화하는 방식이다. 일반적으로 Access Token의 만료 시간은 30분 내외로 짧게 설정한다.
서버는 토큰을 발급할 때 만료 기한(exp) 을 함께 포함하여 발급하고, 이후 사용자 요청이 들어오면 Access Token의 만료 기한을 확인하여 유효성을 검사한다.
그런데 이런 의문이 들 수 있다.
"만료 기한을 조작하면 어떻게 되지?"
좋은 질문이다!
JWT에서는 만료 기한(exp)이 토큰의 payload에 포함되며, 서명(Signature) 생성 시 함께 사용된다. 즉, 데이터가 조작되면 서명이 달라지기 때문에 토큰 인증이 실패한다. 따라서 Secret Key 또는 개인 키가 탈취되지 않는 이상, JWT의 데이터는 조작될 수 없다.
이제 Access Token을 사용하여 탈취 피해를 줄이는 데 성공했다. 웹 서비스에 적용해보니 해킹 피해는 줄어들었지만, 예상치 못한 문제가 발생했다.
"사용자들의 불만이 폭주한다!"
"30분마다 로그인해야 한다니 너무 불편하다!"
"이럴 거면 차라리 안 쓰겠다!"
보안만 신경 쓴 결과, 사용자 경험(UX)이 최악이 되어버린 것이다.
사실, 보안과 사용자 경험(UX)은 보통 반비례 관계이다.
보안을 강화하면 사용자는 불편해지고, 사용자를 편하게 하면 보안이 취약해질 위험이 높아진다.
따라서 이제 사용자 경험(UX)을 개선하는 방법을 고민해야 한다.
본격적으로 보안과 UX 사이의 균형을 맞출 방법을 정리해보자
Access Token을 짧게 설정하면 보안은 강화되지만, 사용자는 자주 로그인을 해야 하는 불편함을 겪게 된다. 이를 해결하기 위해 사용자가 계속해서 활동하면 세션을 자동으로 연장하는 방식이 Sliding Session이다.
즉, Access Token의 만료 시간이 다가오면, 새로운 토큰을 자동으로 발급하여 연장하는 방식이다.

Sliding session을 이용하니 UX적으로 불편한것은 줄였다 하지만 해킹피해가 발생했다 Sliding session은 사용자의 유의미한 이벤트로 만료기한을 늘려주기 때문에 자칫하면 만료기한이 무한정 늘어날 수 있는 단점이 있다. 사용자 UX를 개선시키니 보안이 다시 악화됐다. 토큰 하나만으로는 보안과 UX적으로 둘다 좋게 만드는데 한계가 있다 그 결과 많은 사람들은 refreshtoken을 사용한다
Refresh token은 access token보다 만료 기한을 더 크게 설정하여 그 결과 네트워크에 덜 보내게 되어 탈취 가능성을 낮추는 원리가 적용된 인증 데이터이다. Refresh token을 이용하여 access token을 새로 발급 받는 방식이라고 생각하면 된다. refresh token은 만료 기한이 더 길기 때문에 네트워크에 노출되는 빈도가 access token 보다 훨씬 낮다(2주 > 30분). 따라서 탈취 가능성 자체도 낮아지게 되는거고 또한 Refresh token은 서버에도 저장된다

이제 어느정도 개념에대한 공부를 했으니 기술을 선택할 시간이다 각자 팀 상황의 맞게 잘 선택하는게 중요하다고 생각한다 우리는 1차프로젝트가때 기본 CRUD이기도 해서 refreshtoken과 accesstoken을 구현해서 refreshtoken은 쿠키에 담고 accesstoken은 responseBody에 보내기로 결정했다
로그인을 구현하려면 두가지 기능이 필요하다 인증과 인가이다 사용자가 서버와 연관된 등록된 사용자인가를 판별하는데 인증, 특정 리소스에 접근할 수 있는 권한이 있는가를 판별하는게 인가이다 인증과 인가를 둘다 필터혹은 인터셉털르 사용해서 구현할 수 있지만 Filter를 사용하여 반복되는 응답로직을 security를 사용하는게 개발 편의성 향상으로 이어진다고 생각한다.
Spring을 사용할 때, Spring Security를 적용하는지 여부에 따라 인증(로그인)과 인가(권한 관리) 방식을 다르게 구성할 수 있다.
✅ 사용자 인증 (Authentication)
✅ 사용자 인가 (Authorization)
✅ Custom 응답 리턴
✅ 사용자 인증 (Authentication)
✅ 사용자 인가 (Authorization)
✅ Custom 응답 리턴
Spring Security에서 기본적으로 /login 요청이 들어오면, UsernamePasswordAuthenticationFilter가 동작하여 인증을 처리한다. 이 필터는 attemptAuthentication 메서드를 실행하여 사용자의 아이디와 비밀번호를 검증하고, 인증이 성공하면 Authentication 객체를 생성한다.
하지만, JWT 기반 인증을 적용하기 위해서는 이 과정이 변경되어야 한다.
현재 코드에서는 UsernamePasswordAuthenticationFilter 대신 JwtAuthenticationFilter를 사용하여 인증을 수행한다. Spring Security의 필터 체인을 활용하면서도 세션 기반 인증이 아닌 JWT 인증 방식으로 변경한 것이다.
JwtAuthenticationFilter는 클라이언트의 요청 헤더에서 JWT를 추출하고, 유효성을 검증하여 인증을 수행하는 역할을 한다. 이를 통해 Spring Security의 기본 인증 방식(세션 기반 로그인) 없이도, JWT를 활용한 인증이 가능하다.
Spring Security를 사용하지 않았다면, 요청을 가로채고 인증을 수행하는 과정을 직접 구현해야 했을 것이다. 하지만 프레임워크의 동작 원리를 이해하고 활용하면, addFilterBefore 메서드를 사용하여 기존 인증 필터 앞에 JWT 인증 필터를 배치하는 방식으로 확장할 수 있다.

Spring Security에서 기본적으로 세션 기반 인증을 지원하지만, 나는 JWT 기반 인증을 사용하기 위해 별도의 토큰 관리 클래스를 구현했다.
그 역할을 하는 것이 바로 JwtTokenProvider이다.
Spring Security의 기본 로그인 방식이 아닌,
인증이 성공하면 직접 createToken()을 호출하여 JWT를 생성하는 방식으로 구현했다.
// JWT 생성 (Access Token)
public String createToken(long id) {
Claims claims = Jwts.claims().setSubject(String.valueOf(id)); // 사용자 ID를 subject에 저장
Date now = new Date();
Date validity = new Date(now.getTime() + validityInMilliseconds); // 6분 유효
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(secretKey, SignatureAlgorithm.HS256) // HS256 알고리즘 사용
.compact();
}
✅ 인증이 성공하면 JWT를 생성하여 반환하는 메서드
✅ JWT의 subject에 사용자 ID 저장
✅ 유효 기간: 6분
// JWT (Refresh Token) 생성
public String createRefreshToken(long id) {
Claims claims = Jwts.claims().setSubject(String.valueOf(id));
Date now = new Date();
Date validity = new Date(now.getTime() + refreshTokenValidityInMilliseconds); // 7일 유효
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
✅ Refresh Token 생성 메서드
✅ Access Token보다 더 긴 7일 유효 기간 설정
클라이언트가 보낸 JWT가 유효한지 검증하는 과정도 필요하다.
// JWT 유효성 검사
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
return true; // 유효하면 true 반환
} catch (Exception e) {
return false; // 유효하지 않으면 false 반환
}
}
✅ JWT의 유효성을 검증하는 메서드
✅ 만료된 토큰이거나 변조된 경우 예외 처리하여 false 반환
// JWT에서 ID 추출
public long getId(String token) {
String id = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
return Long.parseLong(id); // String을 long으로 변환하여 반환
}
블로그 추가 내용:
이제 JwtAuthenticationFilter를 구현하여 요청마다 JWT를 검증하고 사용자 정보를 SecurityContextHolder에 설정하는 과정에 대해 설명하겠다.
JwtAuthenticationFilter) 추가OncePerRequestFilter를 상속하여 모든 요청에서 한 번씩 실행되는 필터를 구현했다.
package com.example.coffee.common.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = getTokenFromHeader(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
long userId = jwtTokenProvider.getId(token);
UserDetails userDetails = new CustomUserDetails(userId);
// Spring Security 인증 객체 설정
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getTokenFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
CustomUserDetails) 추가Spring Security는 사용자 정보를 관리하고 인증을 처리할 때, 기본적으로 UserDetails 인터페이스를 사용한다.
JWT 기반 인증에서도 SecurityContextHolder에 저장할 사용자 정보가 필요하기 때문에 UserDetails를 구현하는 것이 중요하다.
Spring Security는 요청을 처리할 때, 인증된 사용자 정보를 SecurityContextHolder에 저장한다.
이때, UserDetails 객체를 저장하는 것이 일반적이며, 그렇지 않으면 Spring Security가 정상적으로 사용자 정보를 인식하지 못할 수 있다.
package com.example.coffee.common.jwt;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Collections;
@AllArgsConstructor
public class CustomUserDetails implements UserDetails {
private final long userId;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}
@Override
public String getPassword() {
return null; // 비밀번호를 저장하지 않으므로 null 반환
}
@Override
public String getUsername() {
return String.valueOf(userId); // 사용자 ID를 문자열 형태로 반환
}
@Override
public boolean isAccountNonExpired() {
return true; // 계정이 만료되지 않았다고 가정
}
@Override
public boolean isAccountNonLocked() {
return true; // 계정이 잠기지 않았다고 가정
}
@Override
public boolean isCredentialsNonExpired() {
return true; // 인증 정보가 만료되지 않았다고 가정
}
@Override
public boolean isEnabled() {
return true; // 계정이 활성화되어 있다고 가정
}
}
이제 JwtAuthenticationFilter가 요청을 가로채 JWT를 검증하고, CustomUserDetails를 생성하여 SecurityContextHolder에 저장하는 방식으로 인증을 처리할 수 있다.
이제 인증된 사용자는 SecurityContextHolder.getContext().getAuthentication()을 통해 언제든지 접근할 수 있으며, 컨트롤러에서도 @AuthenticationPrincipal을 활용하여 현재 로그인한 사용자의 정보를 가져올 수 있다.
로그인 API를 직접구현해서 accesstoken은 response로 refreshtoken은 따로 쿠키로 보내기로 결정했다 그 이유는 간단한 프로젝트이기도하고 서버에 요청필요없이 바로바로 클라이언측에서 요청할수있기때문에 결정을 내렸다 하지만 나중에 시간이된다면 서버로 설계를 바꿔도 좋을거 같다는 생각이 든다


✅ JWT가 인증된 사용자만 접근 가능
✅ @AuthenticationPrincipal을 사용해 현재 인증된 사용자 정보를 가져옴
✅ JWT가 없거나 유효하지 않으면 401 Unauthorized 응답
https://velog.io/@kskim625/%EC%9D%B8%EC%A6%9D-access-refresh-token-%EB%B0%A9%EC%8B%9D
https://seungwoolog.tistory.com/95