[JWT를 활용한 로그아웃, 회원탈퇴, 회원 정보 조회 API 구현] 과정을 기록해보려고 한다. 프로젝트 개발 환경은 아래와 같다.
Java: 17
JDK: 17.0.6
IDE: IntelliJ IDEA 2024.1
Framework: Spring Boot 3.3.1
로그아웃 및 회원탈퇴 API 구현
Access Token과 Refresh Token은 각각 유효 시간이 정해져 있다. [로그아웃 및 회원탈퇴 API]는 유효 시간이 남은 Access Token을 통해 호출할 수 있고, API가 호출되면 해당 사용자의 Access Token과 Refresh Token을 모두 사용할 수 없도록 만들어야 한다.
이전 글을 보면 Refresh Token은 Access Token을 재발급할 때만 사용한다. 입력받은 Refresh Token이 Redis에 저장돼 있다면 유효하므로 새로운 Access Token과 Refresh Token을 발급받을 수 있다. 따라서 Refresh Token을 무효화시키려면 Redis에서 삭제하도록 하면 된다.
반면, Access Token은 서버에서 따로 저장해두지 않기 때문에 유효 시간이 끝나기 전엔 쉽게 무효화할 수 없다. 어차피 길어도 2시간 동안만 사용할 수 있기 때문에 로그아웃된 Access Token을 Redis에 저장하고 Access Token을 검증하는 Filter(JwtAuthenticationFilter)에서 로그아웃된 Access Token은 401 UNAUTHORIZED 에러를 보여주도록 하면 된다.
MemberRequestDTO
헤더에 Access Token을 입력하고 탈퇴 사유를 받아와야 한다.
public class MemberRequestDTO {
...
@Getter
public static class MemberWithdrawRequestDTO {
@NotBlank(message = "탈퇴 이유는 필수 입력 값입니다.")
@Length(min = 1, max = 20)
private String reason;
}
}
MemberService
a. logout()
Access Token과 Refresh Token을 모두 무효화시키기 위해 Access Token을 입력받아야 한다. 로그아웃은 토큰 무효화까지만 진행하고, 회원탈퇴는 토큰 무효화에 사용자 정보 영구 삭제까지 진행해야 하므로 파라미터에 type을 입력받아 회원탈퇴의 경우에만 사용자 정보를 영구적으로 삭제하도록 구현했다.
- 입력받은 Access Token을 검증하고, 이메일을 파싱해 Redis에서 Refresh Token을 삭제한다.
- 이후 Access Token을 Redis에 저장해 해당 토큰이 로그아웃됐음을 표시한다.
- 회원탈퇴 API에서 호출했다면 사용자 정보를 DB에서 영구적으로 삭제한다.
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
...
// 리프레시 토큰 Redis에서 제거
// 액세스 토큰 Redis에 저장(로그아웃 확인용)
// 로그아웃과 회원탈퇴에서 모두 사용 -> type이 DELETE면 회원을 찾아 DB에서 영구 삭제
public void logout(String accessToken, String type) {
try {
jwtUtil.validateToken(accessToken);
} catch (JwtExceptionHandler e) {
throw new JwtExceptionHandler(ErrorStatus.NOT_VALID_TOKEN.getMessage());
}
String email = jwtUtil.getEmail(accessToken);
if (redisTemplate.opsForValue().get("RT" + email) != null) {
redisTemplate.delete("RT" + email);
}
Long expiration = JwtUtil.getExpiration(accessToken);
redisTemplate.opsForValue().set(accessToken, "logout", expiration, TimeUnit.MILLISECONDS);
if (type.equals("DELETE")) {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new MemberHandler(ErrorStatus.NO_MEMBER_EXIST));
memberRepository.delete(member);
}
}
}
b. withdrawer()
회원탈퇴 API는 탈퇴 사유를 적고 호출하게 돼있다. 따라서 탈퇴 사유를 저장할 엔티티인 Withdrawer을 만들고 저장하도록 했다. 관련된 코드는 너무 간단해서 생략한다.
- Withdrawer 엔티티를 살펴보면 다른 엔티티와 매핑되지 않고 혼자 존재한다. 원래는 회원의 정보(사용 날짜, 나이 등)도 함께 저장해서 앱 서비스 통계를 낼 때 사용할 수 있는데, 시간상 통계 기능까지 구현하긴 어려워서 혼자 있는 엔티티가 돼버렸다. 굳이 넣어야 했을까 싶기도 하다.
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
...
// 회원탈퇴 사유 저장
public void withdrawer(String reason) {
Withdrawer withdrawer = Withdrawer.builder()
.reason(reason)
.build();
withdrawerRepository.save(withdrawer);
}
}
MemberApiController
@RequestHeader 애노테이션을 사용하면 헤더에 'Authorization: Bearer {token}' 형식으로 입력한 Access Token을 파싱해서 파라미터로 넣어준다. 입력받은 토큰과 로그아웃 타입을 넘겨 처리하도록 구현했다.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
@Tag(name = "member", description = "회원 관련 API")
public class MemberApiController {
...
@PostMapping("/logout")
@Operation(summary = "로그아웃 API", description = "로그아웃을 진행하는 API (토큰 만료 및 블랙리스트화)")
public ApiResponse<MemberResponseDTO> logout(@RequestHeader("Authorization") String accessToken) {
memberService.logout(accessToken, "LOGOUT");
return ApiResponse.onSuccess("OK");
}
@PostMapping("/delete")
@Operation(summary = "회원탈퇴 API", description = "회원탈퇴를 진행하는 API (토큰 만료 및 회원 영구 삭제)")
public ApiResponse<MemberResponseDTO> deleteMember(@Valid @RequestBody MemberRequestDTO.MemberWithdrawRequestDTO reason,
@RequestHeader("Authorization") String accessToken) {
memberService.logout(accessToken, "DELETE");
memberService.withdrawer(reason);
return ApiResponse.onSuccess("OK");
}
}
JwtAuthenticaionFilter
기존의 동작 방식에 Access Token을 추가로 검증하도록 하면 된다. 동작 방식은 아래와 같다.
- HttpServletRequest에서 토큰을 추출한다.
- 추출한 토큰에 값이 담겨 있고, 검증에 실패하지 않았다면 토큰의 로그아웃 여부를 확인한다.
- 로그아웃된 토큰이 아니라면 인증이 완료된 것이므로, 토큰의 claim에 담긴 정보로 Authentication 객체를 생성하고 SecurityContextHolder에 저장한다.
- 로그아웃된 토큰이라면 다음 Filter로 요청이 넘어가기 때문에 401 UNAUTHORIZED 에러를 발생시켜 토큰을 새로 발급받도록 한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final RedisTemplate<String, Object> redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.resolveToken(request);
if (token != null && jwtUtil.validateToken(token)) {
// 로그아웃 여부 확인
String isLogout = (String) redisTemplate.opsForValue().get(token);
// 로그아웃되지 않았다면 인증 정보 저장
if (ObjectUtils.isEmpty(isLogout)) {
Authentication authentication = jwtUtil.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
회원 정보 조회 API 구현
요청 파라미터로 직접 Member의 ID나 이메일을 받아 처리할 수 있지만, @AuthenticationPrincipal 애노테이션을 사용하면 SecurityContextHolder에 저장돼있는 Authentication 객체를 꺼내 저장된 Principal을 가져올 수 있다. 따라서 MemberDetail 객체를 가져오게 된다.
MemberResponseDTO
더보기 화면에 들어갈 회원의 가입일과, 이메일, 이름을 반환하고, 일반 화면에서 보여줄 세이프 박스 금액을 반환할 수 있도록 구현했다.
public class MemberResponseDTO {
...
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class MemberDetailResultDTO {
LocalDate createdAt;
String email;
String name;
}
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class MemberSafeBoxResultDTO {
Long safeBox;
}
}
MemberService
회원의 세부 정보를 조회하기 위한 서비스다. 입력받은 이메일로 회원을 찾고, 세부 정보를 DTO에 맞게 변환한 후 반환한다.
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
...
// 회원 더보기 정보(가입일, 가입 이메일, 닉네임) 조회
public MemberResponseDTO.MemberDetailResultDTO getMemberDetail(String email) {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new MemberHandler(ErrorStatus.NO_MEMBER_EXIST));
return MemberResponseDTO.MemberDetailResultDTO.builder()
.name(member.getName())
.email(member.getEmail())
.createdAt(member.getCreatedAt().toLocalDate()).build();
}
}
MemberApiController
위에서 말한 @AuthenticationPrincipal 애노테이션을 사용해 사용자의 정보를 요청 파라미터로 입력받지 않아도 된다. 각각 회원의 정보를 통일된 응답으로 바꿔 전송한다.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
@Tag(name = "member", description = "회원 관련 API")
public class MemberApiController {
...
@GetMapping("/")
@Operation(summary = "회원정보조회 API", description = "헤더에 있는 토큰으로 회원을 식별하고, 더보기 화면에서 회원의 정보를 조회하는 API")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
})
public ApiResponse<MemberResponseDTO.MemberDetailResultDTO> getMemberDetail(@AuthenticationPrincipal MemberDetail memberDetail) {
String email = memberDetail.getMember().getEmail();
return ApiResponse.onSuccess(memberService.getMemberDetail(email));
}
@GetMapping("/safebox")
@Operation(summary = "회원 세이프박스 조회 API", description = "헤더에 있는 토큰으로 회원을 식별하고, 회원의 세이프박스 금액 조회하는 API")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
})
public ApiResponse<MemberResponseDTO.MemberSafeBoxResultDTO> getMemberSafeBox(@AuthenticationPrincipal MemberDetail memberDetail) {
Long safeBox = memberDetail.getMember().getSafeBox();
return ApiResponse.onSuccess(MemberConverter.toSafeBoxResultDTO(safeBox));
}
}
Swagger 테스트는 생략한다.
참고자료