기존 auth 도메인에는 회원가입과 로그인 기능만 구현되어 있으며, accessToken만 발급하여 사용하기 때문에 accessToken 만료 시 재로그인이 필요했다.
➡︎ 이를 개선하기 위해 JWT RefreshToken 기능을 추가해 사용자의 불편을 해소하고, 로그아웃 기능을 추가하여 보다 견고한 API를 만들고자 하였다.
JWT는
클라이언트(프론트엔드, 브라우저 or 앱)에 로그인 정보가 저장된 상태이다.
즉, 서버는 별도의 로그인 정보를 유지하지 않고 클라이언트가 보유한 토큰을 통해 인증을 처리한다.
https://jwt.io 에서 확인할 수 있다.
[인코딩 된 JWT의 모습]

[디코딩 된 JWT의 모습]

헤더, 페이로드, 서명키로 구성되어 있고 JWT에 포함된 데이터는 기본적으로 Base64URL 인코딩[❌암호화❌]하여 만든다.
암호화된 것이 아니기 때문에 해당 정보는 누구나 쉽게 디코딩하여 확인 할 수 있다.
따라서, 🚨민감한 정보를 담으면 안된다.🚨
💡 데이터가 늘어날 수록 인코딩된 값이 늘어난다.
💭
로그아웃을 하기 위해서는 주어진 토큰을 삭제하면 된다. 그런데, 저장되지도 않은 토큰을 서버에서 어떻게 삭제할까 . . . ?
➡︎ 서버는 회원가입이나 로그인 시 토큰을 발급하고 토큰에 대한 제어를 완전히 잃는다. 따라서, DB에 토큰을 저장하지 않는 이상 서버에서 안전한 로그아웃 기능을 구현할 수 없다.
🟢 로그아웃은 아래와 같이 2가지 방법을 통해 구현할 수 있다.
- 클라이언트 측에서 삭제
- 서버에 JWT 토큰 정보 저장
다만, 클라이언트측에서 토큰을 삭제하는 경우에 악성 해커가 토큰을 탈취했을 때, 서버는 만료 시간이 될 때까지 기다리는 방법 밖에 존재하지 않으며 Refresh 토큰마저 탈취 당할 경우에는 해커가 만료시간을 계속해서 갱신하며 서버에 요청을 할 수 있는 문제가 발생하게 된다.
⚠️ 서버는 토큰에 저장되어 있는 만료 시간과 사용자 정보로 인증을 처리하기 때문에 외부에서 무슨짓을 해도 확인할 방도가 없다.
➡︎ 따라서, 이러한 문제를 해결하기 위해 약간의 Stateful 특성을 추가하여 토큰 정보를 저장하면 사용자의 경험 개선 뿐만아니라 보안도 강화할 수 있게된다.
1. 회원가입 or 로그인 시 accessToken과 RefreshToken 생성
💡
회원가입에 토큰을 생성하는 이유:회원가입 후 별도의 로그인 과정 없이 바로 사용할 수 있도록 하여 사용자의 불편을 줄인다.
2. 생성된 RefreshToken을 DB에 저장
3.만료 시간으로 accessToken이 유효하지 않을 경우 저장되어 있던 RefreshToken 정보를 통해 accessToken 재발급 여부 확인
- RefreshToken의 만료시간이 남아있는 경우
➡︎ accessToken과 RefreshToken[선택 사항] 재발급
- RefreshToken의 만료시간이 남아있지 않거나 로그아웃을 한 경우
➡︎ 로그인 하라는 메세지를 클라이언트측에 보내고 재로그인을 하도록 한다.
🟢RefreshToken을 저장할 테이블 생성 (RefreshToken)
@Getter
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
//유저 아이디
private Long userId;
//Refresh Token 저장
private String token;
//Refresh Token 상태
@Enumerated(EnumType.STRING)
private TokenStatus status;
public RefreshToken () {
}
public RefreshToken (Long userId) {
this.userId = userId;
this.token = UUID.randomUUID().toString(); //jwt Token은 긴 문자열이기 때문에 성능을 높이기 위해 UUID를 사용
this.status = VALID; //Token 상태 저장
}
public void updateStatus(TokenStatus status) {
this.status = status;
}
}
🟢RefreshToken의 상태 (TokenStatus)
public enum TokenStatus {
VALID, //로그인 및 회원가입 시
INVALIDATED //로그아웃, 만료시간
}
🔵 상태 저장을 통해 사용자의 활동을 추적할 수 있다.
🟢AuthContoller
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/auths/signup")
public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
return authService.signup(signupRequest);
}
@PostMapping("/auths/login")
public SigninResponse login(@Valid @RequestBody SigninRequest signinRequest) {
return authService.login(signinRequest);
}
//로그아웃 추가 구현
@PostMapping("/logout/{userId}")
public void logout(@PathVariable Long userId) {
authService.logout(userId);
}
//Refresh 토큰을 통해 Access 토큰 재발급 (refresh token rotation)
@PostMapping("/auths/refresh")
public TokenResponse reissueToken(
@Valid @RequestBody RefreshTokenRequest request
) {
return authService.reissueToken(request);
}
}
🟢AuthService
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenService tokenService;
@Transactional
public SignupResponse signup(SignupRequest signupRequest) {
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new InvalidRequestException("이미 존재하는 이메일입니다.");
}
/*유효하지 않는 UserRole 검증*/
UserRole userRole = UserRole.of(signupRequest.getUserRole());
String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
User newUser = new User(
signupRequest.getEmail(),
encodedPassword,
userRole
);
User savedUser = userRepository.save(newUser);
String accessToken = tokenService.createAccessToken(savedUser);
String refreshToken = tokenService.createRefreshToken(savedUser);
return new SignupResponse(accessToken, refreshToken);
}
@Transactional(readOnly = true)
public SigninResponse login(SigninRequest signinRequest) {
User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
() -> new InvalidRequestException("가입되지 않은 유저입니다."));
// 로그인 시 이메일과 비밀번호가 일치하지 않을 경우 401을 반환합니다.
if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
throw new AuthException("잘못된 비밀번호입니다.");
}
String accessToken = tokenService.createAccessToken(user);
String refreshToken = tokenService.createRefreshToken(user);
return new SigninResponse(accessToken, refreshToken);
}
//로그아웃 추가 구현
@Transactional
public void logout(Long userId) {
tokenService.revokeRefreshToken(userId);
}
//토큰 재발급
@Transactional
public TokenResponse reissueToken(@RequestBody RefreshTokenRequest request) {
User user = tokenService.refresh(request);
String accessToken = tokenService.createAccessToken(user);
String refreshToken = tokenService.createRefreshToken(user);
return new TokenResponse(accessToken,refreshToken);
}
}
🟢TokenService
@Service
@RequiredArgsConstructor
public class TokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final UserService userService;
private final JwtUtil jwtUtil;
public String createAccessToken(User user) {
return jwtUtil.createAccessToken(user.getId(), user.getEmail(), user.getUserRole());
}
public String createRefreshToken(User user) {
//RefreshToken 저장
RefreshToken savedToken = refreshTokenRepository.save(new RefreshToken(user.getId()));
return savedToken.getToken();
}
public void revokeRefreshToken(Long userId) {
RefreshToken refreshToken = refreshTokenRepository.findById(userId).orElseThrow(
() -> new InvalidRequestException("해당 유저의 Token이 존재하지 않음."));
//Token 무효화
refreshToken.updateStatus(INVALIDATED);
}
public User refresh(RefreshTokenRequest request) {
String token = request.getRefreshToken();
RefreshToken refreshToken = refreshTokenRepository.findByToken(token).orElseThrow(
() -> new InvalidRequestException("유저 찾을 수 없음"));
if (INVALIDATED == refreshToken.getStatus()) {
throw new InvalidRequestException("다시 로그인 해주세요");
}
return userService.findByIdElseThrow(refreshToken.getUserId());
}
}
회원가입 후 accessToken과 RefreshToken을 응답한다.

DB에 VALID라는 상태와 함께 RefreshToken 저장


사용자가 로그아웃을 한 경우

DB에서 토큰의 상태가 INVALIDATED로 저장됨

➡︎ 토큰의 상태와 modified정보를 통해 사용자의 활동을 알 수 있다.

💭 A 사용자가 회원가입 후 다른 페이지에서 다시 로그인하면 2개 이상의 토큰을 가지게 될 수 있다. 현재처럼 상태를 기반으로 사용자의 활동을 추적하면 DB에 토큰 데이터가 계속해서 누적된다. 만약 많은 사용자가 이런 방식으로 서비스를 이용한다면, DB에 데이터가 과도하게 저장되지 않을까? 하는 의문이 들었다.
해결법? ➡︎ 배치를 이용해서 기간 단위로 삭제 or 테이블 따로 만들어서 만료된 것만 모으기
현재는 0.11.5 버전을 사용하여 JWT를 구현하였지만, 최신 버전인 0.12.6으로 구현해볼 예정이다.
Spring Security를 추가하여 JWT를 구현해보고 싶다.
지금까지 배우고 활용한 Cookie, Session, JWT의 개념을 통해 각 차이점을 정확히 비교하고 분석하여 다음 프로젝트에서 활용해 보고싶다.
📌 JWT 방식에서 로그아웃, Refresh Token 만들기(1): JWT의 Stateless한 특징을 최대한 살리려면?
📌 [Spring Security + JWT] 로그아웃 구현하기
📌[Spring] DB를 사용한 JWT 인증에서 로그아웃을 구현해보자
📌Access Token과 Refresh Token이란 무엇이고 왜 필요할까?
📌JWT 토큰 기반의 상태 관리시 로그아웃 처리 문제와 간단한 해결 방법