1. 예외처리의 중요성
a. 협업 시 발생하는 문제점
졸업작품을 준비하면서 프론트엔드 개발자 친구와 함께 협업을 진행하던 중, 애플리케이션에서 발생하는 다양한 에러로 인해 여러 가지 문제에 직면하게 되었습니다. 특히, 백엔드에서 발생하는 에러가 클라이언트로 전달될 때마다 500 Internal Server Error 만 반환되는 상황이 반복되었습니다. 이러한 문제는 여러 가지 원인으로 인해 발생할 수 있습니다.
예를 들어, 코드에 버그가 있어 잘못된 요청을 처리할 때, 사용자 인증이나 권한 문제로 특정 기능을 사용할 수 없을 때, 또는 서버 자체의 설정 오류로 인해 문제가 발생할 때마다 동일한 500 에러 코드가 반환된다면, 프론트엔드 개발자는 실제로 어떤 문제가 발생했는지 파악하기 어렵습니다. 이는 협업의 효율성을 저해하고, 문제 해결에 필요한 시간을 불필요하게 소모하게 만듭니다.
b. HTTP 상태 코드의 명확한 사용 필요성
이러한 문제를 해결하기 위해서는 HTTP 상태 코드를 보다 명확하게 사용하여 클라이언트에게 발생한 에러의 원인을 정확히 전달하는 것이 중요합니다. HTTP 상태 코드는 클라이언트와 서버 간의 통신에서 발생하는 다양한 상황을 나타내는 표준화된 방법으로, 각 코드가 특정한 의미를 가지고 있습니다. 이를 통해 클라이언트는 서버에서 발생한 에러의 유형을 쉽게 파악하고, 적절한 대응을 할 수 있습니다.
예를 들어:
- 400 Bad Request: 잘못된 요청으로 인해 서버가 요청을 이해할 수 없을 때 사용합니다.
- 401 Unauthorized: 인증이 필요하거나 인증에 실패했을 때 사용합니다.
- 403 Forbidden: 권한이 없는 사용자가 특정 리소스에 접근하려 할 때 사용합니다.
- 404 Not Found: 요청한 리소스를 서버에서 찾을 수 없을 때 사용합니다.
- 500 Internal Server Error: 서버 내부에서 예기치 못한 오류가 발생했을 때 사용합니다.
이처럼 각 상태 코드는 특정한 상황을 명확히 전달할 수 있기 때문에, 이를 적절히 활용하면 클라이언트는 에러의 원인을 빠르게 이해하고, 사용자에게 적절한 피드백을 제공할 수 있습니다.
c. 일관된 응답 형식의 필요성
또한, ResultDto와 같은 일관된 응답 형식을 사용하는 것은 예외 처리를 더욱 효과적으로 만들 수 있습니다. ResultDto는 성공 여부, 에러 메시지, 상태 코드 등을 포함하는 표준화된 응답 객체로, 모든 API의 응답을 동일한 구조로 통일함으로써 클라이언트 측에서 응답을 처리하기 쉽게 만듭니다.
2. GlobalExceptionHandler 이해하기
a. GlobalExceptionHandler란?
GlobalExceptionHandler는 Spring Boot 애플리케이션에서 발생하는 예외를 중앙에서 관리하고 처리하는 메커니즘입니다. 기본적으로 @ControllerAdvice 애노테이션을 사용하여 모든 컨트롤러에 공통으로 적용되는 예외 처리 로직을 정의할 수 있습니다. 이를 통해 애플리케이션 전반에 걸쳐 일관된 방식으로 예외를 처리하고, 클라이언트에게 명확하고 이해하기 쉬운 에러 메시지를 전달할 수 있습니다.
b. 왜 GlobalExceptionHandler가 필요한가?
프론트엔드와의 협업에서 발생하는 주요 문제 중 하나는 에러 발생 시 클라이언트가 에러의 원인을 정확히 파악하지 못한다는 점입니다. 예를 들어, 서버에서 예외가 발생할 때마다 500 Internal Server Error가 반환된다면, 이는 클라이언트에게 발생한 에러의 구체적인 원인을 전달하지 못하는 것입니다. 이로 인해 클라이언트는 에러가 서버의 문제인지, 인증이나 권한 문제인지, 또는 단순한 요청의 오류인지를 알기 어렵습니다.
GlobalExceptionHandler를 도입하면, 각 예외에 맞는 HTTP 상태 코드와 에러 메시지를 명확하게 설정할 수 있습니다. 이를 통해 클라이언트는 에러의 원인을 쉽게 이해하고, 적절한 대응을 할 수 있게 됩니다. 또한, ResultDto와 같은 일관된 응답 형식을 사용함으로써, 클라이언트와의 통신에서 예측 가능하고 신뢰할 수 있는 데이터 구조를 유지할 수 있습니다.
c. GlobalExceptionHandler의 구성 요소
GlobalExceptionHandler는 주로 다음과 같은 구성 요소로 이루어져 있습니다:
- @RestControllerAdvice 애노테이션: 이 애노테이션을 사용하여 글로벌 예외 처리 클래스를 정의합니다. @ControllerAdvice와 @ResponseBody가 결합된 형태로, 모든 컨트롤러에 공통으로 적용됩니다.
- @ExceptionHandler 애노테이션: 특정 예외를 처리하는 메서드에 적용됩니다. 이 애노테이션을 통해 어떤 예외가 발생했을 때 어떤 로직을 실행할지 지정할 수 있습니다.
- 로그 기록: 예외가 발생했을 때 로그를 남겨, 나중에 문제를 추적하고 분석할 수 있도록 합니다.
- 응답 형식: 클라이언트에게 반환할 응답 객체(ResultDto)를 생성하여, 일관된 형식으로 에러 정보를 전달합니다.
d. GlobalExceptionHandler의 구현 예제
아래는 GlobalExceptionHandler의 기본적인 구현 예제입니다. 이 예제에서는 다양한 커스텀 예외를 처리하고, 일관된 ResultDto 형식의 응답을 반환합니다.
package com.springboot.publicplace.handler;
import com.springboot.publicplace.dto.ResultDto;
import com.springboot.publicplace.exception.InvalidCredentialsException;
import com.springboot.publicplace.exception.InvalidTokenException;
import com.springboot.publicplace.exception.ResourceNotFoundException;
import com.springboot.publicplace.exception.UnauthorizedActionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// 일반적인 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ResultDto> handleAllExceptions(Exception ex) {
logger.error("An unexpected error occurred: {}", ex.getMessage(), ex);
ResultDto resultDto = ResultDto.builder()
.success(false)
.msg("에러 발생: " + ex.getMessage())
.code(HttpStatus.BAD_REQUEST.value())
.build();
return new ResponseEntity<>(resultDto, HttpStatus.BAD_REQUEST);
}
// ResourceNotFoundException 처리
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ResultDto> handleResourceNotFound(ResourceNotFoundException ex) {
logger.error("Resource Not Found: {}", ex.getMessage());
ResultDto resultDto = ResultDto.builder()
.success(false)
.msg("Resource Not Found: " + ex.getMessage())
.code(HttpStatus.NOT_FOUND.value())
.build();
return new ResponseEntity<>(resultDto, HttpStatus.NOT_FOUND);
}
// InvalidTokenException 처리
@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<ResultDto> handleInvalidToken(InvalidTokenException ex) {
logger.error("Invalid Token: {}", ex.getMessage());
ResultDto resultDto = ResultDto.builder()
.success(false)
.msg("Invalid Token: " + ex.getMessage())
.code(HttpStatus.UNAUTHORIZED.value())
.build();
return new ResponseEntity<>(resultDto, HttpStatus.UNAUTHORIZED);
}
// UnauthorizedActionException 처리
@ExceptionHandler(UnauthorizedActionException.class)
public ResponseEntity<ResultDto> handleUnauthorizedAction(UnauthorizedActionException ex) {
logger.error("Unauthorized Action: {}", ex.getMessage());
ResultDto resultDto = ResultDto.builder()
.success(false)
.msg(ex.getMessage())
.code(HttpStatus.FORBIDDEN.value())
.build();
return new ResponseEntity<>(resultDto, HttpStatus.FORBIDDEN);
}
// InvalidCredentialsException 처리
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ResultDto> handleInvalidCredentials(InvalidCredentialsException ex) {
logger.error("Invalid Credentials: {}", ex.getMessage());
ResultDto resultDto = ResultDto.builder()
.success(false)
.msg(ex.getMessage())
.code(HttpStatus.UNAUTHORIZED.value())
.build();
return new ResponseEntity<>(resultDto, HttpStatus.UNAUTHORIZED);
}
// 유효성 검증 예외 처리 (예: @Valid 어노테이션 사용 시)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ResultDto> handleValidationExceptions(MethodArgumentNotValidException ex) {
String errorMessage = ex.getBindingResult().getAllErrors().stream()
.map(error -> error.getDefaultMessage())
.collect(Collectors.joining(", "));
logger.error("Validation Error: {}", errorMessage);
ResultDto resultDto = ResultDto.builder()
.success(false)
.msg(errorMessage)
.code(HttpStatus.BAD_REQUEST.value())
.build();
return new ResponseEntity<>(resultDto, HttpStatus.BAD_REQUEST);
}
}
e. 각 예외 처리 메서드 설명
- handleAllExceptions(Exception ex)
- 모든 종류의 예외를 처리합니다.
- 기본적으로 400 Bad Request 상태 코드를 반환하며, 예외 메시지를 클라이언트에 전달합니다.
- 예상치 못한 오류에 대해 유용하게 사용됩니다.
- handleResourceNotFound(ResourceNotFoundException ex)
- 리소스(예: 사용자, 게시글 등)를 찾지 못했을 때 발생하는 예외를 처리합니다.
- 404 Not Found 상태 코드를 반환하며, 구체적인 에러 메시지를 전달합니다.
- handleInvalidToken(InvalidTokenException ex)
- 유효하지 않은 토큰(예: 만료된 토큰, 위조된 토큰 등)으로 인해 인증에 실패했을 때 발생하는 예외를 처리합니다.
- 401 Unauthorized 상태 코드를 반환하며, 에러 메시지를 전달합니다.
- handleUnauthorizedAction(UnauthorizedActionException ex)
- 권한이 없는 사용자가 특정 작업을 시도했을 때 발생하는 예외를 처리합니다.
- 403 Forbidden 상태 코드를 반환하며, 에러 메시지를 전달합니다.
- handleInvalidCredentials(InvalidCredentialsException ex)
- 잘못된 자격 증명(예: 잘못된 비밀번호)으로 인해 인증에 실패했을 때 발생하는 예외를 처리합니다.
- 401 Unauthorized 상태 코드를 반환하며, 에러 메시지를 전달합니다.
- handleValidationExceptions(MethodArgumentNotValidException ex)
- @Valid 어노테이션을 사용한 DTO 검증에서 실패했을 때 발생하는 예외를 처리합니다.
- 400 Bad Request 상태 코드를 반환하며, 모든 유효성 검증 오류 메시지를 클라이언트에 전달합니다.
f. ResultDto 클래스 정의
GlobalExceptionHandler에서 반환하는 응답 형식을 정의하는 ResultDto 클래스는 다음과 같이 작성할 수 있습니다. 이 클래스는 응답의 일관성을 유지하는 데 중요한 역할을 합니다.
package com.springboot.publicplace.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ResultDto {
private boolean success;
private String msg;
private int code;
}
g. 커스텀 예외 클래스 정의
다양한 예외 상황을 명확히 구분하기 위해 커스텀 예외 클래스를 정의합니다. 이는 코드의 가독성을 높이고, 예외 처리를 더욱 세분화하는 데 도움이 됩니다.
예시로 ResourceNotFoundException을 보여드리겠습니다.
package com.springboot.publicplace.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
h. 서비스에 적용하기
게시글 삭제 기능을 구현하는 상황을 가정해봅시다. 아래는 게시글을 삭제하는 메서드의 기존 구현입니다.
@Override
public ResultDto deletePost(Long postId, HttpServletRequest servletRequest) {
String token = jwtTokenProvider.resolveToken(servletRequest);
String email = jwtTokenProvider.getUsername(token);
User user = userRepository.findByEmail(email);
ResultDto resultDto = new ResultDto();
if(jwtTokenProvider.validationToken(token)){
Post post = postRepository.findById(postId)
.orElseThrow(() -> new RuntimeException("해당 게시글을 찾을 수 없습니다."));
if (post.getUser().equals(user)) {
// 게시글 삭제
postRepository.delete(post);
// 성공 시 결과 설정
resultDto.setSuccess(true);
resultDto.setMsg("게시글이 성공적으로 삭제되었습니다.");
} else {
// 작성자가 아닌 경우
resultDto.setSuccess(false);
resultDto.setMsg("본인이 작성한 게시글만 삭제할 수 있습니다.");
}
}
return resultDto;
}
기존 코드의 문제점
기존의 구현에서는 try-catch 블록을 통해 스프링에서 정의된 RuntimeException을 사용하였습니다. 하지만, 이를 통해 반환되는 HTTP 상태 코드가 불명확하고, 일관된 응답 형식을 제공하지 못하는 문제가 있었습니다. 따라서, 정확한 HTTP 코드와 일관된 응답을 만들기 위해 코드를 다음과 같이 변경하였습니다.
@Override
@Transactional
public ResultDto deletePost(Long postId, HttpServletRequest servletRequest) {
String token = jwtTokenProvider.resolveToken(servletRequest);
String email = jwtTokenProvider.getUsername(token);
User user = userRepository.findByEmail(email);;
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ResourceNotFoundException("해당 게시글을 찾을 수 없습니다."));
if (!post.getUser().equals(user)) {
throw new UnauthorizedActionException("본인이 작성한 게시글만 삭제할 수 있습니다.");
}
postRepository.delete(post);
ResultDto resultDto = ResultDto.builder()
.success(true)
.msg("게시글이 성공적으로 삭제되었습니다.")
.code(HttpStatus.OK.value())
.build();
return resultDto;
}
커스텀 예외 정의
이제 더 명확한 예외 처리를 위해 커스텀 예외를 정의하였습니다. 아래는 ResourceNotFoundException과 UnauthorizedActionException의 코드입니다.
package com.springboot.publicplace.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
package com.springboot.publicplace.exception;
public class UnauthorizedActionException extends RuntimeException {
public UnauthorizedActionException(String message) {
super(message);
}
}
GlobalExceptionHandler에서 처리하기
이제 GlobalExceptionHandler에서 정의한 커스텀 예외를 처리하여 일관된 응답을 반환할 수 있도록 하였습니다.
이렇게 하게 되면 제가 원하는 응답에 알맞는 HTTP Code로 결과가 나오는 것을 알 수 있습니다.
// ResourceNotFoundException 처리
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ResultDto> handleResourceNotFound(ResourceNotFoundException ex) {
log.error("Resource Not Found: {}", ex.getMessage());
ResultDto resultDto = ResultDto.builder()
.success(false)
.msg("Resource Not Found: " + ex.getMessage())
.code(HttpStatus.NOT_FOUND.value())
.build();
return new ResponseEntity<>(resultDto, HttpStatus.NOT_FOUND);
}
// UnauthorizedActionException 처리
@ExceptionHandler(UnauthorizedActionException.class)
public ResponseEntity<ResultDto> handleUnauthorizedAction(UnauthorizedActionException ex) {
log.error("Unauthorized Action: {}", ex.getMessage());
ResultDto resultDto = ResultDto.builder()
.success(false)
.msg("Unauthorized Action: " + ex.getMessage())
.code(HttpStatus.FORBIDDEN.value())
.build();
return new ResponseEntity<>(resultDto, HttpStatus.FORBIDDEN);
}
결과
이러한 방식으로 구현함으로써, 원하는 응답 형식과 알맞은 HTTP 상태 코드로 결과가 반환되는 것을 확인할 수 있었습니다. 이를 통해 사용자에게 더욱 명확하고 일관된 피드백을 제공할 수 있으며, 애플리케이션의 안정성과 유지보수성을 향상시킬 수 있습니다.
'백엔드 > 스프링' 카테고리의 다른 글
[테스트] nGrinder - 성능 테스트 환경 구성하기 (1) | 2024.11.24 |
---|---|
[백엔드] QueryDSL (1) | 2024.11.19 |
[백엔드] Java Stream API에서 map과 collect 사용하기 (1) | 2024.10.14 |
[백엔드] @Scheduled(Cron)를 프로젝트에 응용하기 (1) | 2024.09.30 |
[백엔드] Spring Security와 JWT를 활용한 REST API 인증 및 CORS 문제 해결 (4) | 2024.09.08 |