Spring

Spring Boot 통일된 응답과 예외 처리

Written by 개발자서동우 · 2 min read >
스프링부트, 통일된 응답과 예외처리

안녕하세요 Devloo 입니다 :). 혹시, 여러분은 웹 서비스를 개발할 때 다양한 곳에서 비슷한 반환 형식을 작성하거나 예외 처리를 다루면서 부담을 느낀 적이 있나요?

저는 주니어 시절, 응답 값과 예외 처리를 어떻게 깔끔하게 할 수 있을까 고민을 했던 것 같습니다. 때로는, 예외 처리에 대해 귀찮아서 제대로 구현을 하지 않았던 적도 있는 것 같습니다.

스프링부트: 통일된 응답과 예외 처리
스프링부트: 통일된 응답과 예외 처리

저의 경험을 바탕으로는 이러한 사소해 보이는 문제들이 쌓여 프로젝트에 큰 영향을 미칠 수 있는 것 같습니다.

저는 이번 글에서 통일된 결과 값 반환 방법통일된 예외 처리 방법을 논의하고자 합니다. 이것은 코드의 표준화를 넘어서 팀 협업 효율을 높이고, 프로젝트의 유지보수 난이도를 줄이며, 코드를 이해하고 확장하기 쉽게 만듭니다.

상세한 설명과 예제를 통해, 여러분의 프로젝트에 쉽게 적용하여 코드 품질을 향상시키고 개발 부담을 줄일 수 있는 명확한 가이드를 제공해드리겠습니다. 자 그럼 시작해 볼까요 🙂

통일된 결과 값 반환

통일된 결과 값 반환은 모든 응답 결과가 동일한 표준을 따르도록 공통 반환 포맷을 지정하는 것입니다.

이를 통해 코드 일관성을 높이고, 중복된 코드 작성을 줄이며, 클라이언트가 API 응답을 이해하고 처리하기 쉽게 만듭니다. (백엔드 개발자라면, 프론트엔드 개발자와 협업을 하기 쉽게 만듭니다.)

통일된 결과 값 반환은 코드 구조를 표준화할 뿐만 아니라 팀 협업 효율성을 높이고 프로젝트 유지보수의 난이도를 낮춥니다. 아래, Spring Boot의 예제를 통해 구현하는 방법을 알아보겠습니다.

1. 공통 응답 객체 정의하기

통일된 결과 값을 반환할 때는 성공 및 실패에 대한 시나리오를 정의해야하고, 이 공통 반환 객체가 인터페이스에서 사용되도록 보장하는 공통 응답 객체를 만들어야 합니다.

아래는 공통 응답 객체에 대한 예시입니다:

@Setter
@Getter
public class ResultResponse<T> implements Serializable {
    private static final long serialVersionUID = -1133637474601003587L;

    /**
     * API 응답 상태 코드
     */
    private Integer code;

    /**
     * API 응답 메시지
     */
    private String message;

    /**
     * API 응답 데이터
     */
    private T data;
}

공통 응답 객체를 사용하면 코드의 일관성을 유지하고, 클라이언트가 API 응답을 더 쉽게 이해하고 처리할 수 있게 됩니다.

2. API 응답 상태 코드 정의하기

통일된 결과 값을 반환할 때의 핵심 요소 중 하나는 공통의 상태 코드를 정의하는 것입니다.

이렇게 하면 클라이언트가 API 응답을 더 쉽게 이해하고 처리할 수 있으며, 개발자에게 일관된 표준을 제공합니다. 일반적으로 몇 가지 많이 사용되는 HTTP 상태 코드는 다음과 같습니다:

  • 200 OK: 요청이 성공적으로 처리되었음을 나타냅니다.
  • 201 Created: 리소스가 성공적으로 생성되었음을 나타냅니다.
  • 204 No Content: 요청이 성공적으로 처리되었지만 반환할 콘텐츠가 없음을 나타냅니다.

오류 상황에서는 다음과 같은 일반적인 HTTP 상태 코드를 사용할 수 있습니다:

  • 400 Bad Request: 클라이언트 요청 오류.
  • 401 Unauthorized: 인증되지 않은 접근.
  • 404 Not Found: 요청한 리소스가 존재하지 않음.
  • 500 Internal Server Error: 내부 서버 오류.

HTTP 상태 코드 외에도 특정 상황을 나타내기 위해 애플리케이션 전용 상태 코드를 정의할 수 있습니다.

각 상태 코드의 의미를 문서에 명확하게 설명하여 개발 팀원 분들이 이를 올바르게 해석하고 처리할 수 있도록 도와야 합니다.

public enum ResultStatus {
    SUCCESS(200, "요청이 성공적으로 처리되었습니다"),
    UNAUTHORIZED(401, "사용자 인증 실패"),
    FORBIDDEN(403, "권한이 부족합니다"),
    SERVICE_ERROR(500, "서버 오류, 나중에 다시 시도해 주세요"),
    PARAM_INVALID(1000, "잘못된 파라미터");

    public final Integer code;
    public final String message;

    ResultStatus(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
}

이렇게 정의된 상태 코드를 통해 클라이언트와 개발자 모두가 응답의 의미를 명확히 이해할 수 있습니다.

3. 성공 및 실패 반환 값을 처리하는 메서드 구현
/**
 * 성공 응답을 캡슐화하는 메서드
 * @param data 응답 데이터
 * @return 응답
 * @param <T> 응답 데이터의 타입
 */
public static <T> ResultResponse<T> success(T data) {
    ResultResponse<T> response = new ResultResponse<>();
    response.setData(data);
    response.setCode(ResultStatus.SUCCESS.code);
    return response;
}

/**
 * 오류 응답을 캡슐화하는 메서드
 * @param resultStatus 오류 응답 상태
 * @return 응답
 * @param <T>
 */
public static <T> ResultResponse<T> error(ResultStatus resultStatus) {
    return error(resultStatus, resultStatus.message);
}

/**
 * 사용자 지정 오류 메시지로 오류 응답을 캡슐화하는 메서드
 * @param resultStatus 오류 응답 상태
 * @param errorMsg 사용자 지정 오류 메시지
 * @return 응답
 * @param <T>
 */
public static <T> ResultResponse<T> error(ResultStatus resultStatus, String errorMsg) {
    ResultResponse<T> response = new ResultResponse<>();
    response.setCode(resultStatus.code);
    response.setMsg(errorMsg);
    return response;
}

위와 같이 성공 및 실패 반환 값 처리에 대한 메서드를 정의하면, 코드의 일관성을 유지하고 오류 처리를 보다 쉽게 관리할 수 있습니다. 이를 통해 개발자는 일관된 방식으로 성공 및 실패 응답을 처리할 수 있으며, 클라이언트는 예측 가능한 응답 형식을 받을 수 있습니다.

4. 표현 계층(웹 레이어)에서의 통일된 응답

표현 계층(웹 레이어)에서 통일된 결과를 반환하는 것은 비즈니스 로직 처리 결과를 사전에 정의된 공통 형식으로 구성하여 코드의 일관성가독성을 높여줄 수 있습니다.

@RestController
@RequestMapping("/users")
@Validated
@Slf4j
public class UserController {

    private UserService userService;
    
    public UserController(UserService userService){
      this.userService = userService;
    }

    /**
     * 사용자 생성
     * @param request
     * @return 응답
     */
    @PostMapping
    public ResultResponse<Void> createUser(@Validated @RequestBody UserCreateRequest request) {
        userService.createUser(request);
        return ResultResponse.success(null);
    }

    /**
     * 사용자 ID로 사용자 정보 조회
     * @param userId 사용자 ID
     * @return 사용자 정보
     */
    @GetMapping("/{id}")
    public ResultResponse<UserInfoResponse> getUser(@PathVariable("id") @NotBlank(message = "사용자를 선택해 주세요") String userId) {
        UserInfoResult result = userService.getUserInfoById(userId);
        return ResultResponse.success(result);
    }
}

API 호출 시 결과 값은 아래와 같습니다:

{
    "code": 200,
    "message": null,
    "data": null
}
{
    "code": 200,
    "message": null,
    "data": {
        "userId": "121",
        "userName": "Danny Seo"
    }
}

통일된 결과 값 반환은 공통 반환 포맷을 정의하고, 성공/실패 반환 값을 처리하며, 이를 컨트롤러에서 사용하는 것을 포함합니다. 이를 통해 코드의 일관성, 가독성, 유지보수성을 향상시킵니다.

통일된 응답 형식을 사용하면 비즈니스 로직 처리가 간소화되고, 개발자가 성공 및 실패 시나리오를 더 쉽게 처리할 수 있으며, 클라이언트가 API 응답을 이해하고 처리하기도 더 쉬워집니다.

이러한 실천은 유지보수 비용을 줄이고, 팀 협업 효율성을 높이며, 코드 표준화를 촉진하는 데 도움이 됩니다.

통일된 예외(Exception) 처리

통일된 예외(Exception) 처리를 정의하면 애플리케이션 전반에서 일관된 예외 처리를 보장하고, 중복된 예외 처리 로직 작성을 줄이며, 적절한 오류 메시지를 통해 개발자와 유지보수 담당자가 문제를 신속하게 파악하고 해결할 수 있도록 도와줍니다. 이는 애플리케이션의 유지보수성과 가독성을 향상시킵니다.

1. 통일된 예외 클래스 정의

서비스에서 발생할 수 있는 사용자 정의 예외 클래스를 정의해야 합니다.

이 예외 클래스들은 RuntimeException을 상속하며, 예외에 관한 관련 정보를 포함할 수 있습니다. 특정 비즈니스 프로세스에서 발생하는 로컬 예외로 이해할 수 있으며, 필요할 때 수동으로 throw 할 수 있습니다.

아래는 사용자 정의 예외 클래스의 예시입니다:

@Getter
public class ServiceException extends RuntimeException {

    private static final long serialVersionUID = -3303518302920463234L;

    private final ResultStatus status;

    public ServiceException(ResultStatus status, String message) {
        super(message);
        this.status = status;
    }

    public ServiceException(ResultStatus status) {
        this(status, status.message);
    }
}

위의 사용자 정의 예외 클래스를 통해 특정 상황에서 발생하는 예외를 명확하게 처리하고 관련 정보를 포함할 수 있습니다. 이를 통해 코드의 유지보수성과 가독성이 크게 향상됩니다.

2. Exception Handler

@ControllerAdvice 또는 @RestControllerAdvice 어노테이션과 @ExceptionHandler 어노테이션을 사용하여 다양한 유형의 예외를 캡처하고 처리 로직을 정의하는 전역 Exception Handler를 만듭니다.

2.1 @ControllerAdvice 어노테이션

전역 컨트롤러 어드바이스를 선언하는 데 사용되며, @ExceptionHandler, @InitBinder, @ModelAttribute로 어노테이션된 메서드를 한 곳에 모아둡니다.

이것은 주로 여러 컨트롤러에 걸쳐 발생하는 예외를 처리하기 위한 특정 클래스 위에 선언합니다.

@ControllerAdvice를 사용할 때는 예외 처리 메서드에 @ResponseBody를 추가해야 합니다. 이는 웹 인터페이스에서도 마찬가지입니다. 하지만 @RestControllerAdvice를 사용하면 이를 추가할 필요가 없습니다.

2.2 @ExceptionHandler 어노테이션

특정 유형의 예외를 처리하기 위한, 예외 처리 메서드를 정의하는 데 사용됩니다. 전역적인 예외 처리를 위해 만든 클래스(아래의 코드의 ExceptionAdvice) 내의 특정 메서드 위에 선언합니다.

이 두 애노테이션을 결합하면 글로벌 예외 처리를 구현할 수 있습니다.

컨트롤러에서 예외가 발생하면, Spring Boot는 자동으로 일치하는 @ExceptionHandler 메서드를 호출하여 예외를 처리하고 정의된 응답을 반환합니다.

@Slf4j
@ControllerAdvice
public class ExceptionAdvice {

    /**
     * ServiceException 처리
     * @param serviceException ServiceException
     * @param request 요청
     * @return 응답
     */
    @ExceptionHandler(ServiceException.class)
    @ResponseBody
    public ResultResponse<Void> handleServiceException(ServiceException serviceException, HttpServletRequest request) {
        log.warn("Request {} threw ServiceException \n", request, serviceException);
        return ResultResponse.error(serviceException.getStatus(), serviceException.getMessage());
    }

    /**
     * 기타 예외 처리
     * @param ex 예외
     * @param request 요청
     * @return 응답
     */
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public ResultResponse<Void> handleException(Exception ex, HttpServletRequest request) {
        log.error("Request {} threw unexpected exception \n", request, ex);
        return ResultResponse.error(ResultStatus.SERVICE_ERROR);
    }
}

이와 같이 전역 예외 처리를 위한 클래스(ExceptionAdvice)를 만들고 예외 처리 관련 어노테이션(@ControllerAdvice, @ExceptionHandler)들을 설정하면 애플리케이션 전체에서 일관된 예외 처리를 보장하고, 각 예외 상황에 대한 명확한 응답을 제공할 수 있습니다.

3. 통일된 예외 처리 적용하기

해당 예외 처리는 비지니스 로직을 개발할 때 서비스 계층에 적용하여 비즈니스 예외를 수동으로 throw 할 수 있습니다.

아래 코드 예시를 보겠습니다.

@Service
@Slf4j
public class UserServiceImpl implements UserService {
    private UserManager userManager;
    
    public UserServiceImpl(UserManager userManager){
        this.userManager = userManager;
    }

    /**
     * 사용자 생성
     *
     * @param request 요청 파라미터
     */
    @Override
    public void createUser(UserCreateRequest request) {
        User selectedUser = userManager.selectUserByName(request.getUserName());
        if (selectedUser != null) {
            throw new ServiceException(ResultStatus.PARAM_INVALID, "이미 존재하는 사용자 이름입니다");
        }
    }
}

해당 코드에서 ServiceExceptionthrow 한다면 위에 구현한 ExceptionAdvice 클래스의 @ExceptionHandler에 의해 ServerException을 캐치하는 handleServiceException 메서드에 의해 처리됩니다.

@RestController
@RequestMapping("/users")
@Validated
@Slf4j
public class UserController {
    private UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }

    /**
     * 사용자 생성
     * @param request
     * @return 응답
     */
    @PostMapping
    public ResultResponse<Void> createUser(@Validated @RequestBody UserCreateRequest request) {
        userService.createUser(request);
        return ResultResponse.success(null);
    }
}

인터페이스를 요청할 때, 만약 사용자 이름이 이미 존재하면 인터페이스는 다음과 같은 응답을 반환합니다:

{
    "code": 1000,
    "message": "이미 존재하는 사용자 이름입니다",
    "data": null
}

통일된 예외 처리를 통해 얻을 수 있는 이점은 일관된 예외 응답 형식을 제공하고, 예외 처리 로직을 단순화하며, 더 나은 오류 로그를 기록하고, 문제를 쉽게 찾아 해결할 수 있도록 하는 것입니다.

예외를 일관되게 처리함으로써 애플리케이션 전반에 걸쳐 일관된 예외 처리를 보장하고, 중복 코드를 줄이며, 코드 표준화를 향상시킵니다.

단순화된 예외 처리 로직은 개발자의 작업 부담을 줄이고, 더 나은 오류 로그는 문제를 신속하게 찾아 해결하는 데 도움을 주며, 궁극적으로 애플리케이션의 유지보수성과 안정성을 향상시킵니다.

다른 유형의 예외 처리

프로젝트 개발 중에는 MethodArgumentNotValidException, UnexpectedTypeException과 같은 특정 예외 유형이 자주 발생합니다. 이러한 예외에 대해 대응하는 예외 처리 로직을 정의할 수 있습니다.

이러한 특정 예외는 요청 파라미터 검증 실패나 예상치 못한 데이터 타입 문제로 인해 발생할 수 있으므로, 별도로 처리하여 보다 구체적이고 사용자 친화적인 예외 응답을 제공하는 것이 필요합니다.

1. MethodArgumentNotValidException

이 예외는 주로 @Valid@Validated 어노테이션을 사용한 요청 파라미터 검증 실패로 인해 발생합니다.

@ExceptionHandler 메서드를 작성하여 MethodArgumentNotValidException을 캡처하고 처리하며, 검증 오류 메시지를 추출하고 상세한 오류 응답을 반환할 수 있습니다.

/**
 * 유효하지 않은 메서드 인수 처리
 * @param ex 예외
 * @return 응답
 */
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public ResultResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
    try {
        List<ObjectError> errors = ex.getBindingResult().getAllErrors();
        String message = errors.stream().map(ObjectError::getDefaultMessage).collect(Collectors.joining(","));
        log.error("유효하지 않은 파라미터: {}", message);
        return ResultResponse.error(ResultStatus.PARAM_INVALID, message);
    } catch (Exception e) {
        return ResultResponse.error(ResultStatus.SERVICE_ERROR);
    }
}

@Valid@Validated를 사용한 요청 파라미터 검증 실패 시 응답은 다음과 같습니다:

{
    "code": 1000,
    "message": "주소 정보를 입력해 주세요, 사용자 나이는 60세 이하이어야 합니다, 취미를 입력해 주세요",
    "data": null
}
2. UnexpectedTypeException

UnexpectedTypeException 예외는 주로 프로그램 실행 중 예상치 못한 데이터 타입 문제가 발생했음을 나타냅니다. 일반적으로 데이터 변환 또는 타입 처리를 할 때 발생합니다.

예를 들어, Spring 폼 바인딩 또는 데이터 바인딩에서 예상 타입과 일치하지 않는 값을 특정 타입으로 변환하려고 하면 UnexpectedTypeException이 발생할 수 있습니다.

이는 문자열을 숫자, 날짜 등으로 변환할 때, 문자열 형식이 목표 타입 요구사항을 충족하지 않으면 자주 발생합니다.

@ExceptionHandler 메서드를 작성하여 UnexpectedTypeException을 캡처하고 처리하며, 오류를 기록하고 적절한 응답을 반환할 수 있습니다.

@ExceptionHandler(UnexpectedTypeException.class)
@ResponseBody
public ResultResponse<Void> handleUnexpectedTypeException(UnexpectedTypeException ex, HttpServletRequest request) {
    log.error("UnexpectedTypeException 발생, 오류 메시지: \n", ex);
    return ResultResponse.error(StatusEnum.PARAM_INVALID, ex.getMessage());
}

예외 발생 시 응답은 다음과 같습니다:

{
    "code": 500,
    "message": "서버 오류, 나중에 다시 시도해 주세요",
    "data": null
}
3. ConstraintViolationException

javax.validation.ConstraintViolationException은 Java Bean Validation(JSR 380)에서 발생하는 예외입니다.

이 예외는 주로 Bean Validation을 사용하여 데이터 검증에 실패할 때 발생합니다. Custom Valid Annotation을 사용할 때, 검증 규칙을 충족하지 않으면 이 예외가 발생합니다.

@ExceptionHandler(ConstraintViolationException.class)
@ResponseBody
public ResultResponse<Void> handleConstraintViolationException(ConstraintViolationException ex, HttpServletRequest request) {
    log.error("Request {}에서 ConstraintViolationException 발생 \n", request, ex);
    return ResultResponse.error(StatusEnum.PARAM_INVALID, ex.getMessage());
}
4. HttpMessageNotReadableException

이 예외는 HTTP 메시지를 읽을 수 없음을 나타내며, 주로 잘못되거나 처리할 수 없는 요청 본문으로 인해 발생합니다.

@ResponseBody
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResultResponse<Void> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex, HttpServletRequest request) {
    log.error("Request {}에서 HttpMessageNotReadableException 발생 \n", request, ex);
    return ResultResponse.error(ResultStatus.SERVICE_ERROR);
}
5. HttpRequestMethodNotSupportedException

Spring Framework에서 이 예외는 요청한 HTTP 메서드가 지원되지 않음을 나타냅니다. 클라이언트가 서버에서 지원하지 않는 HTTP 메서드(GET, POST, PUT, DELETE 등)를 사용하여 요청을 보낼 때 발생할 수 있습니다.

@ExceptionHandler({HttpRequestMethodNotSupportedException.class, HttpMediaTypeException.class})
@ResponseBody
public ResultResponse<Void> handleMethodNotSupportedException(Exception ex) {
    log.error("HttpRequestMethodNotSupportedException 발생 \n", ex);
    return ResultResponse.error(ResultStatus.HTTP_METHOD_NOT_SUPPORT);
}

이와 같은 방식으로 다양한 유형의 예외를 처리하여 애플리케이션의 예외 처리 일관성을 유지하고, 보다 구체적이고 사용자 친화적인 예외 응답을 제공할 수 있습니다.

마무리

Spring Boot에서 통일된 결과 반환과 예외 처리는 코드의 일관성, 가독성, 유지보수성을 크게 향상시킬 수 있는 중요한 기법입니다. 이 글에서는 통일된 결과 반환과 예외 처리를 구현하는 방법을 자세히 살펴보았습니다.

이 글을 통해 여러분이 Spring Boot 프로젝트에서 해당 부분들을 고려하여 더 나은 코드 품질과 개발 효율성을 달성할 수 있기를 바랍니다.

긴 글을 끝까지 읽어주셔서 정말 감사합니다. (_ _)

Written by 개발자서동우
안녕하세요! 저는 기술 분야에서 활동 중인 개발자 서동우입니다. 명품 플랫폼 (주)트렌비의 창업 멤버이자 CTO로 활동했으며, AI 기술회사 (주)헤드리스의 공동 창업자이자 CTO로서 역할을 수행했습니다. 다양한 스타트업에서 일하며 회사의 성장과 더불어 비즈니스 상황에 맞는 기술 선택, 개발팀 구성 및 문화 정착에 깊은 경험을 쌓았습니다. 개발 관련 고민은 언제든지 편하게 연락주세요 :) https://linktr.ee/dannyseo Profile

Leave a Reply

Your email address will not be published. Required fields are marked *