UMC 동아리 8기 스프링 부트 스터디 7주차 API 응답 통일 & 에러 핸들러를 진행하며, 공부했던 내용과 미션을 정리하는 포스트입니다.
📚 개념 정리
프론트엔드와 원활한 통신을 위해서는 통일되어 있는 API 응답 양식이 필요하다. 보통은 isSuccess, code, message, result와 같은 양식을 사용한다.isSuccess
는 boolean 타입으로 성공 여부를 알려준다. code
는 흔히 아는 200, 404, 500...과 같은 HTTP 상태 코드와 그 세부적인 결과를, 그리고 message
는 code에 추가적으로 어떤 결과인지를 알려주기 위해 사용한다. result
에는 JSON 형식으로 프론트엔드가 필요로 하는 진짜 값(응답)을 넣어준다.
아래는 @RestControllerAdvice
를 사용하여 응답을 처리하고 전역적으로 에러 처리를 하며 메모한 내용이다. 구체적인 코드를 읽으러면 링크 참고!
📁 /apiPayload/code/
ApiResponse
onSuccess, onFailure 함수를 통해 API 응답이 성공했을 때와 실패했을 때 사용할 클래스를 생성
Interface BaseCode, BaseErrorCode
구체화 하는 Status에서 getReason(), getReasonHttpStatus() 메소드를 Override 하도록 강제
ReasonDTO, ErrorReasonDTO
응답 시 HttpStatus, code, message를 넣도록 함
📁 /status/
enum SuccessStatus, ErrorStatus implements BaseCode
@Getter
@AllArgsConstructor // 모든 필드를 초기화하는 생성자가 자동 생성
public enum SuccessStatus implements BaseCode {
_OK(HttpStatus.OK, "COMMON200", "성공입니다."); // enum 상수 정의와 함께 생성자를 호출
private final HttpStatus httpStatus;
private final String code;
private final String message;
// ...
}
DTO
public class TempResponse {
@Builder // Response에만 해당
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class TempTestDTO{ // static class를 통해 범용적으로 사용
String testString;
}
}
📁 /apiPayload/exception/
✨ ExceptionAdvice ✨
@Slf4j
@RestControllerAdvice(annotations = {RestController.class})
public class ExceptionAdvice extends ResponseEntityExceptionHandler {
// ...
@ExceptionHandler(value = GeneralException.class)
public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) {
ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus();
return handleExceptionInternal(generalException,errorReasonHttpStatus,null,request);
}
private ResponseEntity<Object> handleExceptionInternal(Exception e, ErrorReasonDTO reason,
HttpHeaders headers, HttpServletRequest request) {
ApiResponse<Object> body = ApiResponse.onFailure(reason.getCode(),reason.getMessage(),null);
WebRequest webRequest = new ServletWebRequest(request);
return super.handleExceptionInternal(
e,
body,
headers,
reason.getHttpStatus(),
webRequest
);
}
private ResponseEntity<Object> handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus,
HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) {
ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorPoint);
return super.handleExceptionInternal(
e,
body,
headers,
status,
request
);
}
private ResponseEntity<Object> handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus,
WebRequest request, Map<String, String> errorArgs) {
ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorArgs);
return super.handleExceptionInternal(
e,
body,
headers,
errorCommonStatus.getHttpStatus(),
request
);
}
private ResponseEntity<Object> handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus,
HttpHeaders headers, WebRequest request) {
ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null);
return super.handleExceptionInternal(
e,
body,
headers,
errorCommonStatus.getHttpStatus(),
request
);
}
}
@RestControllerAdvice(annotations = {RestController.class})
전역적으로 에러를 처리해주는 어노테이션을 사용하여 예외 처리를 진행한다.
예외 처리를 해주는 메소드 앞에 @ExceptionHandler(value = GeneralException.class)
처럼 ExceptionHandler 어노테이션을 붙여 어떤 Exception이 발생할 때 이 메소드를 실행할 것인지 작성해준다.
✨ GeneralException ✨
@Getter
@AllArgsConstructor
public class GeneralException extends RuntimeException {
private BaseErrorCode code;
public ErrorReasonDTO getErrorReason() {
return this.code.getReason();
}
public ErrorReasonDTO getErrorReasonHttpStatus(){
return this.code.getReasonHttpStatus();
}
}
RuntimeException을 상속 받는다.
왜
RuntimeException
을 상속 받아야 하는가?
Java에는 Throwable의 하위 클래스로 Error
, Exception
, 그리고 Exception의 하위 클래스로 Checked Exception
과 Runtime Exception
이 있다. Runtime Exception의 하위 클래스로는 Unchecked Exception
이 있다.
Error는 JVM이 발생시키는 것으로, 메모리 부족과 같이 애플리케이션 코드 단에서 잡기가 힘든 에러이다. Check Exception은 복구 가능성이 있는 예외이기에 예외를 처리하는 코드가 없다면 컴파일 에러가 발생해 처리를 강제한다. 그에 반해 Runtime Exception(=Unchecked Exception)은 복구 가능성이 없는 예외이기에 컴파일러가 처리를 강제하지 않는다.
만약 Runtime Exception을 상속 받지 않고 Check Exception을 상속 받았다면 try~catch문 작성이 강제됐을 것이다. 하지만 우리는 ExceptionAdvice 클래스에서 따로 처리를 해주려고 하고 있기 때문에, try~catch문을 따로 작성하지 않기 위해 Runtime Exception을 상속 받는다.
Exception의 종류를 보면 더 명확히 이해할 수 있다. Check Exception의 경우 BufferedWriter, BufferedReader를 사용하면 꼭 작성하게 되는 IOException이 있으며 Uncheck Exception의 경우 NullPointerException이나 IllegalArgumentException이 있다. @Transactional
어노테이션에서 따로 옵션으로 설정을 해주지 않는다면 Check Exception이 발생했을 때는 (이미 try~catch문으로 작성되었을 것임을 믿고 있기에) 롤백이 되지 않고, Uncheck Exception이 발생했을 때는 롤백이 된다.
이후 서비스에서
@Override
public void CheckFlag(Integer flag) {
if (flag == 1)
throw new TempHandler(ErrorStatus.TEMP_EXCEPTION);
}
이와 같은 함수를 사용, GeneralException을 상속받은 커스텀 예외 클래스(ErrorHandler)로 ErrorStatus와 함께 에러를 핸들링하면 응답이 알아서 전송된다.
public class TempHandler extends GeneralException {
public TempHandler(BaseErrorCode errorCode) {
super(errorCode);
}
}
TempHandler(GeneralException을 상속 받았으므로 RuntimeException의 특성도 물려받게 됨)의 경우, 에러가 발생하면
→ @RestControllerAdvice
가 붙은 ExceptionAdvice가 Spring 애플리케이션 전체에서 발생하는 예외를 감지하고 처리
→ ExceptionAdvice 중 @ExceptionHandler(value = **GeneralException**.class)
가 붙은 메소드를 따라가 호출
→ onThrowException
메소드가 실행, 적절한 오류 응답을 반환
@RestControllerAdvice
?
모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리하고 JSON 형태로 응답을 반환한다. 예외를 가로채서 @ExceptionHandler가 등록된 메소드들에서 예외별로 응답을 만들어 반환한다.
전역적인 예외 처리가 가능하고, 일관된 에러 응답 형식에 맞춰서 응답을 제공할 수 있다. 중복된 코드가 줄어드므로 유지보수 하기가 쉬워지고 확장성이 높아진다.
💥 미션 및 해결 과정
> ❓ 웹훅을 사용하여 500 에러가 날 시 자동으로 디스코드에 메세지를 올려보기
웹훅?
데이터가 변경 혹은 이벤트가 발생했을 때 실시간으로 알림을 받을 수 있는 기능이다. 클라이언트가 서버에게 웹훅을 받을 Callback URL을 제공하고, 받고 싶은 이벤트를 등록하면 된다.
구현
구체적인 코드는 커밋 링크 참고!
외부 라이브러리인 OpenFeign을 사용하여 디스코드 웹훅을 연동하고, ExceptionAdvice에서 _INTERNAL_SERVER_ERROR
가 발생한 경우 디스코드 알림 메세지를 전송하도록 코드를 작성하였다.
OpenFeign?
OpenFeign은 넷플릭스가 만든 선언적인 HTTP Client 도구이다. 보통 외부 API를 호출하려면 RestTemplate을 사용해야 하는데, OpenFeign을 사용하면 아래와 같이 어노테이션을 사용하여 간단하게 외부 API를 호출할 수 있다.
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import umc._th.spring.web.dto.DiscordMessage;
@org.springframework.cloud.openfeign.FeignClient(name = "${discord.name}", url = "${discord.webhook-url}")
public interface DiscordFeignClient {
@PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
void sendMessage(@RequestBody DiscordMessage message);
}
플로우는 다음과 같다.
DiscordMessage라는 record 형식을 사용해 discord에 보내질 메세지 형식을 결정한다.
이후 DiscordFeignClient 클래스에서 OpenFeign을 사용하여 웹훅 URL을 지정하고 REST API 호출 로직을 작성한다.
Provider 클래스에 DiscordFeignClient를 사용해 디스코드 에러 메세지를 보내는 구체적인 함수를 작성하고, ExceptionHandler 내부에서 에러 발생 시 HttpStatus를 확인해 _INTERNAL_SERVER_ERROR
가 발생한 경우 Provider를 실행하도록 작성하였다.

성공!
💭 회고
컨트롤러 내에서 일일히 try~catch문을 사용하여 에러를 처리하면 어떤 에러 처리를 덜 했는지 다시 마우스를 올려가며 확인해야 할 때도 있었고 코드가 점점 길어져 가독성도 유지보수성도 나쁘다고 생각했었다. 프로젝트를 작성하다보면 에러들이 보통 비슷비슷한 처리를 필요로 하므로 try~catch를 쓰다보면 중복 코드도 많아졌는데... @RestContollerAdvice를 사용하니 정말 깔끔하게 관심사가 분리됐고 에러 처리가 가능해졌다.
그리고... 특정 컨트롤러에서 예외 처리가 추가적으로 필요하면 try~catch문 쓰면 되겠다고 가볍게 생각했었는데 스터디원의 질문 덕분에 알고 있던 것과 다른 방법은 없나 찾아보게 됐다. @ControllerAdvice
어노테이션의 basePackages
, annotations
, assignableTypes
속성을 통해 특정 패키지나 특정 어노테이션이 붙은 컨트롤러에만 적용하는 방법도 있었다. 제대로 사용하려면 조금 더 공부가 필요하겠지만... 원래 사용하려던 방법 말고 다른 방법도 알게 되었다. 스터디 시간에 서로 가졌던 질문과 찾아본 답을 공유하다보면 지식이 한층 깊어지는 느낌이라 좋다. 생각하지 못 했던 부분까지도 커버할 수 있게 되고... 이래서 스터디를 하는 것 같다😆