개발이군고구마

[전전긍긍 설정세팅] 1. Rest API 규격 (#공통규약) 본문

SERVER/Architecture

[전전긍긍 설정세팅] 1. Rest API 규격 (#공통규약)

김구황 2025. 5. 18. 18:07
728x90

2년 동안 나름 열심히 아등바등 거렸다고 생각했는데, 아뿔싸 그게 서해안 갯벌 위였을 줄이야.

경력자 주제에(?) 감히 어지간히 몰라야지, 이건 뭐 완전히 모르는 수준이라.....^.^

질문받는 쪽도 당황 하는 나도 민망, 하루 하루 쪽팔림과의 전쟁 중이다. 

 

수치스러움은 나의 성.장.동.력 (정신승리)

하지만 도망칠 수 없지!!!!!!
무지의 쪽팔림을 온전히 마주해보자!!!!!! 아...나는 어른이다!!!!

 

용의 (새끼)발톱 때 냄새라도 맡아보자는 목적으로 시작하는 "전전긍긍 시리즈" 1탄


 

1. ResponseEntity로 응답하는 경우 

  • ok 메서드를 사용하는 경우
@GetMapping("/detail")
public ResponseEntity<MemberDetailDto> detail() {
    Member currentMember = MemberUtil.getMember();
    return ResponseEntity.ok(memberService.getMemberById(currentMember.getId()).toMemberDetailDto());
}

제네릭 T - body에 담기게 될 데이터 값을 넘겨준다

 

  • 생성자 객체를 생성하는 경우 
@GetMapping("/detail")
public ResponseEntity<?> detail() {
    Member currentMember = MemberUtil.getMember();
    //...
    return new ResponseEntity<>(dto, header, HttpStatus.OK);
}

바로 ResponseEntity 인스턴스를 생성해준다 

 

 

☑️ 두 방법 모두 호출부로 전달되는 데이터는 같다. 

T body -> 실제 body에 담겨서 보내지는 데이터 
headers -> Http 응답메세지 헤더
status code (100-500) ->  Http 응답메세지 startline에 응답코드

 

 

 

 

2. 커스텀 ApiResponse 만들기 - 정상응답 

ResponseEntity 가 주는 정보 외에

비즈니스 의미, 에러 등에 대한 정보를 담고 싶다면 더 명시적이고 자세하게 알려주고 싶다면? → 래퍼 클래스 설계

이 경우는 응답값이 body에만 담겨서 보내진다. 

 

[ApiResponse 클래스의 구조]

public record ApiResponse<T>(
	boolean success,
	String message,
	T result
) {

	public static <T> ApiResponse<T> ok(ResponseMessage responseMessage, T result) {
		return new ApiResponse<>(true, responseMessage.getMessage(), result);
	}
}
  • 비즈니스 적으로 추가하고 싶은 정보들
    • boolean success 
    • String message 
  • ok 메서드 
    • 무조건 success를 넣어 인스턴스를 만든다
    • message는 인터페이스의 필드에서 가져온다  
      • 현재는 문자 그대로 보내주기 때문에 String message로 하여 상속 없이 가져올 수 있긴함
    • T 제네릭엔 호출부로 넘길 data가 담기게 된다 


👋🏻 여기서 잠깐 문법 학습

    record 클래스

  • 자동 필드 캡슐화 (private 접근제어자 불필요)
  • 생성자 자동생성 (멤버 필드의 값으로 컴파일링 시점에 생성자는 자동 생성) 
  • 클래스 생성을 간단하게 해주는 효과 

    ExceptionMessage 인터페이스를 구현하여 처리하는 걸까? 

  • 유연성과 확장성  (enum이 여러개 관점으로 분리가 가능)
  • 추상화와 결합도 낮추기  (다른 클래스로 교체가 가능함)
  • 🚿 CLEAN Code
public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
    ExceptionMessage exceptionMessage = e.getExceptionMessage(); // 타입에 상관없이!
    return new ResponseEntity<>(
        new ErrorResponse(exceptionMessage.getMessage()),
        exceptionMessage.getHttpStatus()
    );
}
  • 표현력 (그냥 message보단 ExceptionMessage로 쓰는게 명확함) 
  • 테스트와 유닛 테스트 용이성  

 

 

 

+ 실제 사용 예시

@GetMapping("/api-response")
public ApiResponse<?> getApi() {
    return ApiResponse.ok(() -> "성공", "OK");
}

 

result 부분에는 어떤 자료형이 들어와도 상관이 없음. 예제에는 String으로 반환함.

 

 

👋🏻 여기서 잠깐 문법 학습

  인터페이스가 올 자리에 왠 함수?

  • ResponseMessage 인터페이스에서 구현해야할 메서드가 1개이기 때문에
  • 람다식을 사용하여 익명의 구현 객체를 자동으로 생성함 

 

 

☑️ 호출부로 전달되는 값은 다음과 같다 (모두 다 body)

result : T
success : true
message : "성공"

 

 

여기엔 무조건 ok만 정의되어있는데, 만약 에러가 나면 그때는 어떻게 응답할 것인가? 

에러도 규격화해서 사용을 해보자! 

 

 

 

3. 커스텀 ExceptionResponse 만들기 - Exception 

[Exception 계층구도]

  • Error - 도저히 복구 불가! 바로 던져버린다
  • Exception - 잡고 복구하자!
  • RuntimeException: try catch 호출 안하면 놔둬도 됨, 예외 상황에서만 catch 호출해야 함

 

[Exception 처리 후 ExceptionResponse 구조]

[상세분석]

 

1) ExceptionHandler - 리스너 

@Controller 나 @RestController 어노테이션이 붙어 있는 클래스에서 발생하는 예외를 잡아서 특정 메서드로 처리해주는 기능

@ExceptionHandler
public ResponseEntity<ExceptionResponse> handleMethodArgumentTypeMismatchException(
    MethodArgumentTypeMismatchException exception
) {
    log.error("{} : {}", exception.getClass(), extractErrorSpot(exception));
    return buildExceptionResponse(GlobalExceptionMessage.ARGUMENT_TYPE_MISMATCH_MESSAGE);
}
  • Enum 사용하여 에러메세지 간결하고 확실히 보여주기
    • buildExceptionResponse : GlobalExceptionMessage로 값을 변환하기 위한 메서드 
    • CustomException은 필드로 이미 가지고 있어 생성자에서 사용하기 때문에 다시 넣어줄 필요는 없음
  • Exception은 로그가 중요
    • extractErrorSpot : log를 남기기 위한 레코드값 - exception의 정보를 받아 toString으로 변환해준다)
    • extractExceptionLocation : custom Exception의 경우 

 

2) BusinessException extend RundtimeException - CustomException의 뼈대 

@Getter
public class BusinessException extends RuntimeException {

    private final ExceptionMessage exceptionMessage;
    private final String className;
    private final String methodName;
    private final int lineNumber;

    public BusinessException(ExceptionMessage exceptionMessage) {
       super(exceptionMessage.getMessage());
       this.exceptionMessage = exceptionMessage;

       StackTraceElement[] stack = Thread.currentThread().getStackTrace();
       this.className = stack[2].getClassName();
       this.methodName = stack[2].getMethodName();
       this.lineNumber = stack[2].getLineNumber();
    }

    public String extractExceptionLocation() {
       return String.format("[%s][%s][%d]: ", className, methodName, lineNumber);
    }
}
  • 멤버 필드값 분석
    • 메세지와 상태값을 한번에 보여주는 enum ExceptionMessage 
    • 로그를 만들때 사용하는 각종 정보 
  • extractExceptionLocation 는 왜?
    • 로그를 남기기 위해서 custom exception을 만들었기 때문에 그때 보여질 className, methodName, lineNumber을 명확히 보여주기 위해 만든 메서드
    • customException외에 다른 에러들은 exception 내부에서 보내는 값을 사용한다. 

 

2-1) GlobalExceptionMessage implements ExceptionMessage - 공통 메세지 enum 

Enum 클래스로 생성해두면 호출시 메세지의 값이 명확하게, 더 간결하게 인스턴스를 생성할 수 있게 된다. 

@AllArgsConstructor
@Getter
public enum GlobalExceptionMessage implements ExceptionMessage {

    INTERNAL_SERVER_ERROR_MESSAGE(INTERNAL_SERVER_ERROR, "알 수 없는 서버 에러가 발생했습니다."),

    private final HttpStatus httpStatus;
    private final String message;
}
  • enum으로 관리하게 될때의 이점
    • 전역으로 생성된 enum의 name 값만 호출해도 필드로 주입된 HttpStatus와 message를 담은 인스턴스가 생성된다
    • 한 곳에 두고 관리하기도 편함
  • ExceptionMessage 인터페이스를 사용 

 

3) ExceptionResponse - 최종 응답값 생성 

public record ExceptionResponse(
    boolean success,
    String message
) {

    public static ExceptionResponse fail(ExceptionMessage exceptionMessage) {
       return new ExceptionResponse(false, exceptionMessage.getMessage());
    }
}
  • 비즈니스 적으로 추가하고 싶은 정보들
    • boolean success 
  • fail 메서드 
    • 무조건 false 를 넣어 인스턴스를 만든다
    • message는 인터페이스의 필드(여기서는 enum)에서 가져온다

 


+ 실제 사용 예시

@GetMapping("/business-exception")
public void occurBusinessException() {
    throw new BusinessException(new ExceptionMessage() {
       @Override
       public HttpStatus getHttpStatus() {
          return HttpStatus.BAD_GATEWAY;
       }

       @Override
       public String getMessage() {
          return "에러 발생";
       }
    });
}

@GetMapping("/internal-server-exception")
public void occurInternalServerException() {
    throw new InternalException("서버 에러 발생!");
}

@GetMapping("/no-endpoint")
public void occurNoResourceFoundException() throws NoResourceFoundException {
    throw new NoResourceFoundException(HttpMethod.GET, "/no-endpoint");
}

 


☑️ 호출부로 전달되는 값은 다음과 같다 (모두 다 body)

success : false
message : GlobalExceptionMessage 인스턴스의 message 필드 "알 수 없는 서버 에러가 발생!" 

 

 

 

👋🏻 여기서 잠깐 문법 학습

❓  InternalException과 NoResourceFoundException은 문자열이나 개별 파라미터(메시지, HttpMethod 등)를 직접 받을까? 

  • 자주 바뀌거나 다양하게 사용될 예외(비즈니스)는 ExceptionMessage 라는 enum 파일로 관리를 하고 
  • 상황이 고정적이고 명확한 예외(시스템, 자원)는 "별도의 래핑" 없이 바로 전달 

 

 

❓ try{ ..} catch {} 문에서 잡지 못했지만 runtime 하위 영역에 에러가 정의된 경우?

하위 클래스들을 우선으로 해당하는 것이 없을 때 상위 에러를 띄운다. 

 

 

❓ @Validation 에서 message를 정의한 경우 400 에러가 발생하면? 

message를 정의했다면 default 메세지보다 우선한다. 참고자료

ARGUMENT_TYPE_MISMATCH_MESSAGE(BAD_REQUEST, "경로 변수 또는 쿼리 파라미터의 타입이 잘못되었습니다."),​
@NotBlank(message = "제목은 필수입니다.")
@Size(min = 5, max = 100, message = "제목은 5자 이상 20자 이하로 입력해주세요.")
@Schema(description = "게시글 제목", example = "오늘의 공부 일지")
private String title;

 

 

@ExceptionHandler
public ResponseEntity<ExceptionResponse> handleMethodArgumentTypeMismatchException(
    MethodArgumentTypeMismatchException exception
) {
    log.error("{} : {}", exception.getClass(), extractErrorSpot(exception));
    return buildExceptionResponse(GlobalExceptionMessage.ARGUMENT_TYPE_MISMATCH_MESSAGE);
}

- 에러는 느슨하게 가기로 했기 때문에, Enum에서 정한 message가 노출됨 

- valid로 정의한 메세지를 띄우기 위해선 후처리가 필요한 부분 

 

 

 

cf. 해당 코드는 코딩천재 팀원분께서 작성한 코드를 분석 정리한 글입니다.