개인 일정을 관리 할 수 있는 어플리케이션을 개발하였었습니다. 처음 개발 할 당시, 일정에 관련된 CRUD 에 대한 기능만 구현 되도록 작성 했었고, 그때는 `RESTful` 하게 만들지 못하여서 여러 단계를 거쳐 기능 및 앱 구동 방식을 개선해왔습니다.
두번째로 개선할 당시에는 회원관리가 가능하도록 회원에 관련된 CRUD 기능을 추가하고, 일정에 댓글을 달아서 회원간 소통이 가능하도록 댓글 CRUD 기능만 구현하였습니다. 추가적으로 `JWT` 를 활용하여 로그인 시 토큰을 발급하여 앱에 포함된 기능을 사용할 때, 토큰을 검증하는 기능을 추가하였습니다.
이번에는 토큰을 검증하는 로직을, 비즈니스 로직이 아닌 필터에서 검증하여 API 를 요청할 시에 미리 토큰을 검증하도록 수정하고, `GlobalExceptionHandler`를 사용하여 유저에게 HTTP 의 상태만 반환하는 것이 아닌 에러메세지나 성공메세지 등을 좀더 명확하게 전달 할 수 있도록 기능을 개선하고자 하였습니다.
1. Filter 구현
Filter 는 API 를 요청하기 전, Http 에서 가지고 있는 정보를 검증하고 앱에 접근전에 한번 걸러줄 수 있는 역할을 합니다. 필터는 여러겹이 있을 수 있으며, 저는 `Logging` 필터를 거친 후,`Auth` 필터에서 JWT 를 가지고 있는지, 유효한 지, 사용자와 일치하는 지 등을 검증하는 검증 필터를 추가했습니다.
Auth 필터까지 통과하게 된다면 검증된 유저가 API 를 요청하게 되어 기능을 수행하도록 합니다. 이 방식은, 로그인 되어있는 유저가 현재 로그인 상태가 유지되고 있는지를 확인하는 방식이라고 할 수 있습니다.
LoggingFilter
@Component
@Order(1)
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info(url);
chain.doFilter(request, response); // 다음 Filter 로 이동
// 후처리
log.info("비즈니스 로직 완료");
}
}
AuthFilter
@Component
@Order(2)
@RequiredArgsConstructor
public class AuthFilter implements Filter {
private final MemberRepository memberRepository;
private final JwtUtil jwtUtil;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
if (StringUtils.hasText(url) &&
(url.startsWith("/api/members/login") || url.startsWith("/api/members/signup"))
) {
// 회원가입, 로그인 관련 API 는 인증 필요없이 요청 진행
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
// 나머지 API 요청은 인증 처리 진행
// 토큰 확인
String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);
if (StringUtils.hasText(tokenValue)) { // 토큰이 존재하면 검증 시작
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if (!jwtUtil.validateToken(token)) {
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
Member member = memberRepository.findByUsername(info.getSubject()).orElseThrow(() ->
new NullPointerException("Not Found User")
);
request.setAttribute("member", member);
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
throw new IllegalArgumentException("Not Found Token");
}
}
}
}
이러한 필터를 추가함으로써, 매번 서비스에서 비즈니스 로직이 실행될때 번거롭게 토큰에 대한 인증을 거치는 절차를 하지 않아도 됩니다.
2. 관리자 권한 사용(인가)
특정 일정이나 댓글에 있어서, 관리자가 수정하거나 삭제해야하는 경우가 있습니다. `HttpServletRequest` 에서 로그인된 사용자의 `JWT 토큰`을 가져와 그 토큰이 가지고 있는 사용자의 권한을 추출합니다. 권한이 `USER` 일 경우 일반유저에 해당하며, 본인의 일정이나 댓글만 수정 및 삭제할 수 있습니다. 권한이 `ADMIN` 인 경우는 다른 유저가 작성한 일정이나 댓글도 삭제할 수 있도록 합니다.
@Transactional
public void deleteTodo(Long todoId, TodoRequestDto requestDto, Member member) {
Todo todo = todoRepository.findById(todoId).orElseThrow(() -> new IllegalArgumentException("해당 게시물이 존재하지 않습니다."));
if(!Objects.equals(member.getId(),requestDto.getMemberId())
&& member.getRole() != MemberRoleEnum.ADMIN){
throw new UnauthorizedAccessException("작성자 또는 관리자만 삭제 가능합니다.");
}
todoRepository.delete(todo);
}
3. 전체적인 검증 방식 수정
Filter를 추가하였기 때문에, 회원가입과 로그인 할 때를 제외하고 모든 기능에서 로그인된 사용자인지 검증합니다. 즉, JWT 토큰을 검증하기 때문에 회원, 일정, 댓글에 관련된 모든 CRUD 에서 HttpServletRequest 에서 토큰을 추출하여 검증하는 과정을 거칩니다.
4. GlobalExceptionHandler 전역예외처리
전역 예외처리를 진행하여 예외처리의 일관성을 유지하고, 콘솔에서 표시되는 어떤 예외인지에 대한 `에러메시지`를 유저가 보고 알 수 있도록 에러메세지를 `Http 상태코드`와 함께 반환하도록 개선하였습니다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<String> handleEntityNotFoundException(EntityNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("요청한 리소스를 찾을 수 없습니다: "+e.getMessage());
}
@ExceptionHandler(UnauthorizedAccessException.class)
public ResponseEntity<String> handleUnauthorizedAccessException(UnauthorizedAccessException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("접근이 거부되었습니다: " + e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
StringBuilder errorMessages = new StringBuilder("유효성 검사 실패: ");
e.getBindingResult().getFieldErrors().forEach(error ->{
errorMessages.append(error.getField()).append(": ").append(error.getDefaultMessage());
});
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorMessages.toString());
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<String> handleDataIntegrityViolationException(DataIntegrityViolationException e) {
String message = "이미 등록된 이메일 입니다. 다른 이메일을 사용해 주세요.";
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
유저는 응답 Body 가 없는 수정 기능이나 탈퇴기능 삭제기능 등에서 성공적으로 삭제가 되었는지 등을 메세지를 받아서 확인할 수 있고, 이외에 다양한 다른 예외에서도 메세지를 자세히 확인할 수 있습니다.
이번에는 이렇게 좀더 일반적으로 사용되고 있는 어플리케이션의 기능들을 추가하고 구현해 보도록 하였습니다. 다음엔 프로그램 자체의 성능을 향상시킬 수 있는 Early Return 이나, 불필요한 If-else 문을 최소화 하여 프로그램의 최적화를 진행할 예정입니다.
감사합니다.
'Project' 카테고리의 다른 글
[팀 프로젝트] 아웃소싱 - 배달 어플리케이션 구현 (3) | 2024.11.07 |
---|---|
[기능 개선] - Refactoring, Test, AOP (3) | 2024.10.31 |
[팀 프로젝트 KPT+F] Spring JPA & JWT & Github (0) | 2024.10.24 |
[개인과제] 일정 관리 앱 Develop - 댓글 관리 및 트러블 슈팅 (0) | 2024.10.17 |
[개인과제] 일정 관리 앱 Develop - 개발 과정 및 트러블 슈팅 (0) | 2024.10.17 |