[Springboot] 스프링 API 구현, 커스텀 응답객체, REST API, HttpsCode, GlobalExceptionHandler

winluck·2023년 11월 5일
1

Springboot

목록 보기
10/18

API란?

  • Application Programming Interface
  • 응용 프로그램 사이 통신에 사용되는 언어나 형식
  • 주로 클라이언트와 서버가 소통하기 위한 메서드 및 데이터 형식을 정의
  • 클라이언트가 서버로 보내는 요청 데이터 형식 = RequestDto
    • 게시물 수정, 회원정보 수정 등
  • 서버가 클라이언트로 보내는 응답 데이터 형식 = ResponseDto
    • 게시물 목록 조회, 내 정보 조회 등
  • Springboot에서 주로 Controller 계층에서 구현

REST API의 주요 메서드

REST API의 주요 4가지 상황에 대해 기본적으로 정리해보자.

  1. GET: 주로 리소스의 조회나 검색에 사용, 서버에서 클라이언트로 데이터(ResponseDto)를 반환
  2. POST: 주로 새로운 리소스를 생성하는 데 사용, 클라이언트가 서버에 데이터(RequestDto)를 보내고, 서버가 이를 처리하고 관련 리소스 관리
  3. PUT: 주로 특정 리소스를 수정하는 데 사용
  4. DELETE: 주로 특정 리소스를 삭제하는 데 사용

중요: POST vs PUT

  • 멱등성: 같은 요청을 서버에 연속으로 가했을 때 결과가 동일한 성질
  1. POST
    • POST는 주로 리소스를 생성하는 데 사용
    • POST 요청은 멱등성을 갖지 않음
    • 새 게시물을 생성하는 POST 요청을 여러 번 실행하면 각각 다른 게시물이 생성
  2. PUT
    • PUT은 주로 리소스를 수정하는 데 사용
    • PUT 요청은 멱등성을 가짐
    • 특정 게시물을 수정하는 PUT 요청을 여러 번 실행하면 항상 동일한 게시물 상태가 유지

HttpsCode

  • 클라이언트가 서버에게 받게 될 요청의 결과에 대한 상태 코드에 대해 기본적으로 정리하였다.

성공 상태 코드

  • 200 OK: 요청이 성공적으로 처리되었고 적절한 응답을 제공하였다.
  • 201 Created: 요청이 성공적으로 처리되어 새로운 리소스가 생성되었다. (주로 POST)

실패 상태 코드

  • 400 Bad Request: 클라이언트의 요청이 잘못되었거나 유효하지 않음. 보통 요청 데이터의 형식이나 내용이 올바르지 않을 때 나타난다.
  • 403 Forbidden: 클라이언트에 요청한 리소스에 대한 권한이 없을 때 나타난다. 서버가 요청을 이해했으나, 게시물 삭제 요청 등 클라이언트가 해당 리소스에 접근할 권한이 있어야만 할 때 사용한다.
  • 404 Not Found: 요청한 리소스를 서버에서 찾을 수 없음을 나타내며, 클라이언트가 존재하지 않은 리소스에 접근할 때 발생한다.
  • 405 Method Not Allowed: 클라이언트가 요청한 http 메서드가 해당 리소스에서 허용되지 않음을 의미한다. 예를 들어 GET 요청만 가능한 리소스에 POST 요청을 보낼 때가 해당된다.
  • 409 Conflict: 서버가 요청을 처리하던 중 상태 충돌이 발생했음을 의미한다. 예를 들어 이미 좋아요를 누른 게시물에 다시 좋아요를 누르려 한다거나, 이미 구독을 취소한 태그에 다시 구독을 취소하려는 시도 등이 해당된다. 동일한 존재로 확인된 유저가 다시 회원가입을 시도하는 경우 등에 해당한다.
  • 500 Internal Servor Error: 서버에서 처리 중 예상치 못한 오류가 발생하여 요청을 처리할 수 없는 경우에 나타난다.

기본 제공되는 스프링 응답 객체: ResponseEntity

  • 스프링에서 기본적으로 제공하는 응답 객체
  • ResponseEntity 등의 형태로 사용
  • 참고: <>는 제네릭(Generics)이라 하는데, 이 <>안에 어떠한 타입을 선언해주어 해당 ArrayList, List 등이 사용할 객체의 타입을 지정해준다는 뜻이다. 이는 다룰 객체의 타입을 미리 명시하여 객체의 형변환이 필요없게 하며, 내가 사용하고 싶은 데이터 타입만 사용할 수 있게 해 준다.
  • 사용 예시
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/user")
public class UserController {

    private final UserService userService;

    @GetMapping("{userId}/info/simple")
    public ResponseEntity<ResponseSimpleUserDto> getSimpleUserInfo(@PathVariable Long userId) {
        ResponseSimpleUserDto userInfo = userService.getSimpleUserInfo(userId);
        return ResponseEntity.ok(userInfo);
    }
}
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ResponseSimpleUserDto {

    private String name;
    private String image;

		public static ResponseSimpleUserDto of(String name, String image) {
        return new ResponseSimpleUserDto(name, image);
    }
}
  • 응답 JSON
{
	"name": "John Doe",
	"image": "https://example.com/johndoe.jpg"
}
  • ResponseEntity의 단점
    • 실전에선 응답 상태 코드 및 헤더, 일부 응답 메시지를 설정하는 추가적인 작업이 필요
    • 응답을 완성하기 위한 추가 로직이 Controller에 섞여들어갈 것을 강요한다. (관심사 분리 실패)
@RequestMapping(value = "/{customerId}", method=GET)
public ResponseEntity<Customer> findById(@PathVariable int customId) throws DataNotFoundException{ //1
 
  Customer customer = customerService.findById(customerId);
 
  HttpHeaders headers = new HttpHeaders();
  headers.setContentType(new MediaType("text","xml", Charset.forName("UTF-8"));
  headers.set("My-Header","MyHeaderValue");
 
  return new ResponseEntity<Customer>(customer, headers, HttpStatus.OK); //3
 
}
  • 응답 객체 관리를 따로 분리시켜, Controller은 본래의 목적에 맞게 응답 그 자체에만 초점이 맞추게 해보자!

커스텀 응답객체: ApiResponse

@Getter
@RequiredArgsConstructor
public class ApiResponse<T> {

    private ApiHeader header;
    private T data;
    private String msg;

    private static final int SUCCESS = 200;

    private ApiResponse(ApiHeader header, T data, String msg) {
        this.header = header;
        this.data = data;
        this.msg = msg;
    }

    public static <T> ApiResponse<T> success(T data, String message) {
        return new ApiResponse<T>(new ApiHeader(SUCCESS, "SUCCESS"), data, message);
    }

    public static <T> ApiResponse<T> fail(ResponseCode responseCode, T data) {
        return new ApiResponse<T>(new ApiHeader(responseCode.getHttpStatusCode(), responseCode.getMessage()), data, responseCode.getMessage());
    }
}
@Getter
@AllArgsConstructor
public class ApiHeader {

    private int code;
    private String message;
}
  • API 응답을 좀 더 구체적으로 표현하고 헤더와 data로 분리하여 구체적인 정보를 포함하는 데 최적화
  • 보낼 데이터와 보낼 http 메시지의 생성 방법을 정적 메서드로 고정시킴과 동시에, 성공과 실패라는 가장 핵심적인 요소로 응답객체 생성 방법을 분리
  • 사용 예시
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/user")
public class UserController {

    private final UserService userService;

		@GetMapping("{userId}/info/simple")
		public ApiResponse<ResponseSimpleUserDto> getSimpleUserInfo(@PathVariable Long userId) {
		    return ApiResponse.success(userService.getSimpleUserInfo(userId), ResponseCode.USER_CREATE_SUCCESS.getMessage());
		}
}
  • 응답 JSON
{
  "header": {
    "code": 200,
    "message": "SUCCESS"
  },
  "data": {
    "name": "John Doe",
    "image": "https://example.com/johndoe.jpg"
  },
  "msg": "User created successfully."
}
  • 헤더에 응답코드와 성공 여부, 구체적인 응답 데이터와 응답 메시지를 빠르고 간결하게 생성 가능
  • 근데 fail은 언제 쓰지? 아래에서 설명하겠다.

ResponseCode 열거형

  • 위에서 언급했다시피 서버는 클라이언트에게 성공/실패 관련 메시지와 Http 코드 등을 전달해야 한다.
    • “유저 조회 성공”, “유저 삭제 성공” 등
    • 굳이 직접 한 메서드마다 하드코딩해야하나?
    • 그냥 한 곳에 모아놓고 관리하면 안 되나? 에서 출발
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public enum ResponseCode {

    // 400 Bad Request
    BAD_REQUEST(HttpStatus.BAD_REQUEST, false, "잘못된 요청입니다."),

    // 403 Forbidden
    FORBIDDEN(HttpStatus.FORBIDDEN, false, "권한이 없습니다."),

    // 404 Not Found
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "사용자를 찾을 수 없습니다."),
    // 405 Method Not Allowed
    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, false, "허용되지 않은 메소드입니다."),

    // 409 Conflict
    USER_ALREADY_EXIST(HttpStatus.CONFLICT, false, "이미 가입한 사용자입니다."),
    USER_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, false, "이미 존재하는 닉네임입니다."),

    // 500 Internal Server Error
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "서버에 오류가 발생하였습니다."),

    // 200 OK
    USER_READ_SUCCESS(HttpStatus.OK, true, "사용자 정보 조회 성공"),
    USER_UPDATE_SUCCESS(HttpStatus.OK, true, "사용자 정보 수정 성공"),
    USER_LOGIN_SUCCESS(HttpStatus.OK, true, "사용자 로그인 성공"),

    // 201 Created
    USER_CREATE_SUCCESS(HttpStatus.CREATED, true, "사용자 생성 성공"),

    private final HttpStatus httpStatus;
    private final Boolean success;
    private final String message;

    public int getHttpStatusCode() {
        return httpStatus.value();
    }
}
  • 메시지에 대응하는 HttpStatus 코드와 성공/실패 여부, 메시지 내용 등을 열거형으로 통합적인 관리 가능
    • 유지보수, 재사용성에 이점
    • 그냥 필요할 때마다 추가만 하면 끝

Exception

  • 여러 API 요청에 대해 처리할 때 서버 내부에서 발생하는 예외처리에 대응하는 기본적인 방법에 대해 알아보자.
@AllArgsConstructor
@Getter
public class BaseException extends RuntimeException {

    private final ResponseCode responseCode;

    @Override
    public String getMessage() {
        return responseCode.getMessage();
    }
}

public class UserException extends BaseException {
    public UserException(ResponseCode responseCode) {
        super(responseCode);
    }
}
  • 위에서 만들어둔 ResponseCode 열거형을 기반으로 만든 Exception 객체이다. 어떤 이유로 Exception이 발생했는지 관련된 정보를 포함할 수 있다.

GlobalExceptionHandler

회원정보 조회 기능에 대한 구체적인 코드 실행 상황에 대해 살펴보자.

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/user")
public class UserController {

    private final UserService userService;

		// 회원정보 조회
		@GetMapping("{userId}/info/simple")
		public ApiResponse<ResponseSimpleUserDto> getSimpleUserInfo(@PathVariable Long userId) {
		    return ApiResponse.success(userService.getSimpleUserInfo(userId), ResponseCode.USER_CREATE_SUCCESS.getMessage());
		}
}
  • 먼저 Client가 userId 1을 기준으로 회원정보 조회를 요청한다고 가정하자. (baseUrl/api/user/1/info/simple에 GET 요청)
  • UserController는 userService.getSimpleUserInfo 메서드를 호출하여 그 반환값인 ResponseSimpleUserDto를 받아오려고 시도할 것이다.
@Transactional(readOnly = true)
public ResponseSimpleUserDto getSimpleUserInfo(Long userId) {
    User user = userRepository.findById(userId)
										.orElseThrow(() -> new UserException(ResponseCode.USER_NOT_FOUND));
    return ResponseSimpleUserDto.of(user.getName(), user.getImage());
}
  • 만약 findById의 결과가 존재하지 않는, 즉 1을 id로 갖는 유저가 없을 때 서버는 UserException을 발생시켜 문제가 생겼음을 내부에 알린다.
  • 그런데 이 실패 상황을 클라이언트에게 어떻게 전달해야 할까?
  • 이렇게 예외가 발생했을 때는 더 이상의 진행이 의미가 없기 때문에, 사고를 수습해줄 전문가(?)가 필요하다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserException.class)
    public ApiResponse<Void> handleUserException(UserException e) {
        log.info("UserException: {}", e.getMessage());
        return ApiResponse.fail(e.getResponseCode(), null);
    }
}
  • GlobalExceptionHandler는 @ControllerAdvice를 통해 스프링 애플리케이션 내에서 발생하는 특정 예외에 대응하는 Exception Handling을 중앙에서 통제할 권한을 갖는다.
    • @ControllerAdvice + @ResponseBody → @RestControllerAdvice
  • Springboot에서 Controller의 메서드가 요청받은 작업을 완수하지 못하고 Exception을 당했을(?) 때 이를 수습하고 질서를 유지하는 스프링 경찰(?)같은 존재라고 이해하면 편하다.
    • fail 객체를 반환하기 때문에 클라이언트는 구체적인 사고의 원인을 Http 코드 및 메시지로 알 수 있다.
profile
Discover Tomorrow

2개의 댓글

comment-user-thumbnail
2024년 6월 22일

덕분에 깔끔히 정리하고 갑니다 감사합니다!

답글 달기
comment-user-thumbnail
2024년 8월 6일

고민이 많았던 부분 깔끔히 해결하고 갑니다. 감사합니다!

답글 달기