프로젝트

(4) Rest API : Envelop pattern 봉투 패턴, 에러 공통 응답 형식

nkdev 2025. 3. 28. 10:38
  • Rest API의 응답값을 넘기는 형식 중 하나
  • 데이터를 캡슐화하는 방법

왜 필요한가?

public class Member {
    private Long id;
    private String name;
    private int age;
}
{
	"id" : 1,
	"username" : "홍길동",
	"age" : 15
}

Member라는 객체를 JSON형태로 표현하면 다음과 같다. 

[
    {
        "id":1,
        "username":"홍길동",
        "age":15
    },{
        "id":2,
        "username":"아무개",
        "age":24
    },
    ...
    {
        "id":99,
        "username":"개똥이",
        "age":47
    }
]

이 멤버를 여러 명 조회해야 하는 API를 개발해야 한다면 조회된 리스트를 JSON 형식으로 던져준다.

[
    {
        "id":1,
        "username":"홍길동",
        "age":15,
        "memberCount": 99
    },{
        "id":2,
        "username":"아무개",
        "age":24,
        "memberCount": 99
    },
    ...
    {
        "id":99,
        "username":"개똥이",
        "age":47,
        "memberCount": 99
    }
]

이렇게 사용하던 API에 모든 멤버의 숫자를 추가해달라는 요청이 왔다면 데이터를 어떻게 넘겨줘야 할까?

 

따로 멤버 숫자에 해당하는 데이터를 안 넘겨주고 클라이언트가 JSON으로 전달 받은 member의 Id 개수를 세게 함

-> 누락된 멤버가 있을 수 있음

 

각 멤버 마다 memberCount 필드를 추가함

-> 멤버 각각 가지고 있는 값인지, 조회된 멤버의 카운트인지 알 수 없음

-> 불필요한 데이터 중복으로 인해 네트워크 트래픽이 더 많이 발생함

 

Envelop Pattern

Envelop Pattern으로 API 확장성을 유지해보자. 

data부분을 한 번 감싸는 형태로 API를 바꾼다. 

{
        "code":"success",
        "data":{
            "members":[
                {
                    "id":1,
                    "username":"홍길동",
                    "age":15
                },{
                    "id":2,
                    "username":"아무개",
                    "age":24
                },
                ...
                {
                    "id":99,
                    "username":"개똥이",
                    "age":47
                }
            ]
        },
        "message":null
}

 보통 code, data, message를 보내준다.

- code : http status로 표현할 수 없는 상태값

- data : 실제 api 요청에 대한 데이터 값

- message : api 응답이 성공했을 때 null, 에러 발생 시 에러에 대한 정보

{
        "code":"success",
        "data":{
            "members":[
                {
                    "id":1,
                    "username":"홍길동",
                    "age":15
                },{
                    "id":2,
                    "username":"아무개",
                    "age":24
                },
                ...
                {
                    "id":99,
                    "username":"개똥이",
                    "age":47
                }
            ],
            "memberCount" : 99
        },
        "message":null
}

그래서 memberCount를 추가해야 할 일이 생기면 멤버 마다 추가하는 게 아니라 데이터에 필드 하나를 추가해주면 된다.

-> 원하는 결과를 추가로 얻을 수 있게 되었으니 확장성이 늘어남

 

참고) http status 규격

REST API는 응답에 http status에 담아 보내야 함

-> 이미 정의되어있는 Http status 규격을 따르되

-> 개발 하다 보면 2xx만으로 표현하기 어려운 경우가 있는데 이 때 추가 설명 목적으로 미리 정의된 code값을 넘겨주면 조금 더 명확한 상태를 알 수 있음

 

https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Status

 

 

Java에서 구현 방법

프론트엔드에서 일관성 있는 API 응답 처리를 위해 Envelop Pattern을 다음과 같이 적용하여 공통 응답 형식을 만든다.

{
    statusCode: number;
    timestamp: Date;
    content: object;
    message: string;
}
  • statusCode : 응답의 상태 코드
  • timestamp : 응답이 생성된 시점
  • content : 클라이언트의 요청 처리에 대한 응답 데이터
  • message : api 응답 메시지

예시)

{
    "statusCode": 200,
    "timestamp": "2025-03-01H12:13:56.3514",
    "content": {
        "memberId": 3000,
        "memberName": "John"
    },
    "message": "회원 정보 조회 성공"
}

 

공통 API 형식을 지정하는 공통 dto 클래스 정의

@RequiredArgsConstructor
@Getter
@ToString
public class ApiResponse<C> {
    private final int statusCode;
    private final LocalDateTime timestamp;
    private final String message;
    private final C content
}

static 메소드로만 ApiResponse 객체를 생성할 수 있게 한다. (오버로딩 활용해서 생성하게 함)

@RequiredArgsConstructor(access = AccessLevel.PRIVATE) //final 필드에 생성자 자동 생성, 생성자의 접근 제어자를 Private으로 막아서 외부에서 직접 객체를 생성하지 못하게 함
@Builder(access = AccessLevel.PRIVATE) //Builder 패턴으로 객체 생성을 쉽게 만듦
@Getter
@ToString 
public class ApiResponse<C> {
    private final int statusCode;
    private final LocalDateTime timestamp;
    private final String message;
    private final C content
    
    public static <C> ApiResponse<C> of(C content, int statusCode, LocalDateTime timestamp, String message) {
        return ApiResponse.builder()
                .statusCode(statusCode),
                .timestamp(timestamp)
                .message(message)
                .content(content)
                .build();
    }
    
    public static <C> ApiResponse<C> of(int statusCode, LocalDateTime timestamp, String message) {
	      return of(null, statusCode, timestamp, message);
    }
    
    public static <C> ApiResponse<C> of(C content, int statusCode, LocalDateTime timestamp) {
	      return of(content, statusCode, timestamp, null);
    }
    
    public static <C> ApiResponse<C> of(int statusCode, LocalDateTime timestamp) {
	      return of(null, statusCode, timestamp);
    }
}
  • ApiResponse 클래스를 제네릭 형태로 만들어 객체가 다양한 타입의 content를 가질 수 있게 함
    • 예) String, List<User>, Map<String, Object> 등
  • 클래스의 필드를 모두 final(불변 객체)로 선언하여 값이 변경될 수 없게 만듦
  • 정적 팩토리 메서드 (of)를 호출하여 객체를 생성할 수 있게 함.
  • 생성자는 모두 Private으로 만들어 클래스 외부에서 객체를 직접 생성할 수 없게 함
  • 빌더 패턴을 적용하여 객체를 메서드 체이닝 방식으로 설정할 수 있게 함

에러 응답

사용자 입력이 잘못됐거나 코드 로직상 문제가 발생한 경우 관련 내용을 클라이언트가 정형화된 형식으로 응답 받아야 한다.

이를 위해 공통 에러 응답 포맷을 정의하여 클라이언트에서 에러를 쉽게 처리할 수 있게 할 수 있다.

{
    statusCode: number;
    timestamp: Date;
    errorCode: string;
    errorName: string;
    message: string;
    detail: object;
}
  • statusCode : 현재 에러 응답의 상태 코드
  • timestamp : 에러의 응답이 반환되는 시점의 시간
  • errorCode : 에러의 코드. 상태 코드만으로는 표현할 수 없는 서비스 고유의 에러 코드를 반환
  • errorName : 에러의 이름
  • message : 에러 메시지
  • detail : 에러 응답에 대한 추가 정보

* 서버에서 클라이언트에게 성공적인 응답을 보낼 때의 timestamp는 응답이 생성된 시점이고 에러 응답을 보낼 때의 timestamp는 에러의 응답이 반환되는 시점의 시간이다. 이렇게 timestamp를 기록하는 기준이 다르게 설정된 이유는 각 응답의 의미와 목적이 다르기 때문이다. 성공적인 응답에서는 클라이언트가 요청한 데이터가 어느 시점의 응답인지 알 수 있도록 응답이 생성된 시점의 시간을 기록해야 한다. 에러 응답의 경우는 클라이언트 입장에서 에러가 언제 발생했는지 보다 언제 응답을 보냈는지가 더 중요하다. 특히 네트워크 지연이나 재시도 요청이 있는 경우 에러 응답이 생성된 시점과 보낸 시점이 다를 수 있기 때문에 응답이 반환되는 시점을 기록해서 언제 문제가 발생했는지를 추적할 수 있도록 한다.

 

예시) 

회원 조회 시 해당 회원이 존재하지 않을 경우

3000번 회원을 조회하려고 했으나 해당 회원이 없으므로 알맞은 에러 응답을 클라이언트로 전송

이 때 몇 번 회원이 존재하지 않는지에 대한 정보 또한 에러 응답에 포함시킴

{
    "statusCode": 404,
    "timestamp": "2025-03-01H01:23:22.003",
    "errorCode": "MEM001",
    "errorName": "MEMBER_NOT_FOUND",
    "message": "해당 회원이 존재하지 않습니다.",
    "detail": {
        "memberId": 3000
    }
}

 

Java에서 구현 방법

에러 응답을 구현하기 위해 다음 항목들을 구현해야 함

  • 공통 인터페이스
  • 에러 코드 enum : 도메인 마다 각자 필요한 에러 코드를 정의하기 위해 사용
  • 공통 ResponseDto : 일관된 응답 형식을 보내기 위해 사용
  • 예외를 처리하는 RestControllerAdvice

공통 인터페이스

  • 모든 에러가 공통된 형식을 가지도록 인터페이스를 정의
  • 이 인터페이스를 구현하는 enum 클래스에는 statusCode, errorCode, message 필드가 있어야 하고 @Getter을 붙여야 한다는 것을 알 수 있음
public interface ErrorSpecifiable {

    int getStatusCode();
    
    String getErrorCode();
    
    String getMessage();
    
    String name();
}

위 인터페이스를 enum에서 구현한다.

에러 코드 enum

  • http 표준 상태 코드 외에 우리 서비스의 고유한 에러를 표현하기 위한 에러 코드를 enum으로 정의
  • ErrorSpecifiable 인터페이스를 각 도메인에 맞게 따로 enum으로 구현 -> 필요한 에러 코드를 정의
  • enum으로 구현하면 허용된 에러 코드만 사용할 수 있게 강제할 수 있음
    • 예) String errorCode = "MEM09090909" //존재하지 않는 에러 코드지만 사용 가능
    • 예) MemberErrorCode errorCode = MemberErrorCode.MEMBER_NOT_FOUND; //정의된 에러 코드만 사용 가능
  •  enum의 각 상수 마다 특정 속성(상태 코드, 에러 코드, 메시지)를 저장할 수 있도록 final 필드를 지정함
  • 이렇게 필드가 있는 enum을 사용하려면 생성자가 필요하므로 @RequiredArgsContructor을 붙여줌
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
@Getter
enum MemberErrorCode implements ErrorSpecifiable {
    MEMBER_NOT_FOUND(404, "MEM001", "회원이 존재하지 않습니다."),
    INVALID_PASSWORD(401, "MEM002", "패스워드가 존재하지 않습니다.");
    
    private final int statusCode;
    private final String errorCode;
    private final String message;
}

공통 ResponseDTO 

  • 클라이언트에게 반환할 공통 에러 응답 형식을 정의한 DTO
  • 에러 코드들을 직접 String으로 선언하지 않고 enum을 참조하므로 일관성이 유지되고 실수를 방지할 수 있음
@RequiredArgsConstructor
@Builder
@Getter
@ToString
public class ErrorResponse {
    private final int statusCode;   // HTTP 상태 코드 (ex: 404, 401)
    private final String errorCode; // 내부 시스템에서 정의한 에러 코드 (ex: "MEM001")
    private final String errorName; // 에러 이름 (ex: "MEMBER_NOT_FOUND")
    private final String message;   // 사용자에게 보여줄 메시지 (ex: "회원이 존재하지 않습니다.")
    private final LocalDateTime timestamp; // 에러 발생 시간
    private final Object detail;    // 추가적인 에러 정보 (선택적)
}

 

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Builder(access = AccessLevel.PRIVATE)
@Getter
@ToString
public class ErrorResponse {
    private final int statusCode;
    private final String errorCode;
    private final String errorName;
    private final String message;
    private final LocalDateTime timestamp;
    private final Object detail;
    
    public static ErrorResponse of(ErrorSpecifiable e) {
	      return ErrorResponse.builder()
	              .statusCode(e.getStatusCode())
	              .errorCode(e.getErrorCode())
	              .errorName(e.name())
	              .message(e.getMessage())
	              .timestamp(LocalDateTime.now())
	              .build();
    }
    
    public static ErrorResponse of(ErrorSpecifiable e, Object detail) {
	      return ErrorResponse.builder()
	              .statusCode(e.getStatusCode())
	              .errorCode(e.getErrorCode())
	              .errorName(e.name())
	              .message(e.getMessage())
	              .timestamp(LocalDateTime.now())
	              .detail(detail)
	              .build();
    }
}

 

https://junhyunny.github.io/information/class-diagram-in-uml/