1. 배경 지식
Spring MVC로 API를 처음 만들 때의 기억은 아직도 생생하다. 컨트롤러 메서드에서 DTO 객체를 반환하기만 하면, Jackson 라이브러리가 마법처럼 JSON으로 변환해 주었다. 코드는 간결했고, 직관적이었다. 하지만 코드 리뷰 겸 다른 사람들이 올린 깃 레파지토리를 보면 단순히 DTO만 반환하지 않는다는것을 발견했다. 대부분의 코드는 DTO를 직접 반환하는 대신, 아래처럼 ResponseEntity<>라는 객체로 감싸서 반환하고 있었던 것이다.
// 나의 코드 작성 방식
@GetMapping("/members/{id}")
public MemberDto getMember(@PathVariable Long id) {
return memberService.findMember(id);
}
// 다른 사람들의 코드 작성 방식
@GetMapping("/members/{id}")
public ResponseEntity<MemberDto> getMember(@PathVariable Long id) {
MemberDto member = memberService.findMember(id);
return ResponseEntity.ok(member);
}
단순히 ResponseEntity.ok()로 감싸는 것이 전부라면, 이 코드는 불필요하게 장황해 보였다. "왜 굳이 이렇게 하는 걸까?" 이 질문에 대한 답을 찾아가는 과정은, 단순한 API를 넘어 '잘 설계된 RESTful API'가 무엇인지 이해하는 과정이었다.
2. 그래서 왜 ResponseEntity<>로 반환하는데?
2.1. HTTP 상태 코드 설정 가능
가장 큰 이유는 HTTP 상태 코드를 내 의도대로 제어하기 위함이었다.
DTO를 직접 반환하면, 예외가 발생하지 않는 한 Spring은 무조건 200 OK 상태 코드를 응답으로 보낸다. 하지만 RESTful API에서 상태 코드는 단순한 성공/실패 여부를 넘어, 요청에 대한 서버의 구체적인 응답 상태를 표현하는 중요한 수단이다.
- 회원 생성(POST) 성공: 단순히 200 OK가 아니라, "새로운 리소스가 성공적으로 생성되었음"을 의미하는 201 Created를 보내주는 것이 더 명확하다.
- 데이터 삭제(DELETE) 성공: Body에 보낼 데이터가 없으므로 "성공했지만, 응답 본문은 없음"을 의미하는 204 No Content가 더 적절하다.
- 잘못된 요청: 클라이언트의 요청 파라미터가 잘못되었다면, 서버 내부 예외(500)가 아니라 "너의 요청이 잘못됐어"라는 의미의 400 Bad Request를 보내야 한다.
ResponseEntity는 .status(), .ok(), .created()와 같은 메서드를 통해 이 모든 상태 코드를 자유자재로 설정할 수 있게 해준다. 이것은 클라이언트와 더 풍부하고 명확한 약속(Contract)을 만드는 행위였던 것이다.
2.2. 응답 헤더, 부가 정보를 담는 그릇
HTTP 응답은 상태 코드와 본문(Body) 외에도 헤더(Header)라는 중요한 구성 요소를 가진다. 헤더에는 캐싱 제어, 인증 정보, 또는 부가적인 메타데이터를 담을 수 있다. ResponseEntity를 사용하면 이 헤더를 매우 쉽게 추가할 수 있다. 가장 대표적인 사례는 리소스 생성 API이다.
@PostMapping("/members")
public ResponseEntity<Void> createMember(@RequestBody MemberCreateDto dto) {
Long newMemberId = memberService.join(dto);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(newMemberId)
.toUri();
// HTTP 201 Created 상태 코드와 함께,
// Location 헤더에 새로 생성된 리소스의 URI를 담아 반환한다.
return ResponseEntity.created(location).build();
}
위 코드는 201 Created 응답을 보내면서, Location이라는 헤더에 방금 생성된 회원의 상세 조회 URI(.../api/members/123)를 담아준다. 클라이언트는 이 헤더 값을 보고 "아, 내가 만든 리소스는 저 주소로 가면 있구나"라고 바로 인지할 수 있다. 이는 RESTful API의 원칙 중 하나인 HATEOAS를 구현하는 기초가 되기도 한다. DTO만 반환하는 방식으로는 절대 불가능한 일이었다.
2.3. 일관된 응답 구조의 완성
어떤 개발자는 @RestControllerAdvice를 이용한 전역 예외 처리기가 있으니, 성공 케이스에서는 DTO만 반환해도 충분하다고 생각할 수 있다. 나 또한 그랬다. 하지만 이 방식은 성공 응답과 실패 응답의 구조가 완전히 달라지는 문제를 낳을 수 있다.
ResponseEntity를 사용하면 성공, 실패 케이스 모두 일관된 응답 래퍼(Wrapper) 클래스를 사용하여 응답 형식을 통일하기가 매우 용이하다.
// 성공 시
ApiResponse<MemberDto> successResponse = new ApiResponse<>("성공", memberDto);
return ResponseEntity.ok(successResponse);
// 실패 시 (전역 예외 처리기에서)
ApiResponse<Void> errorResponse = new ApiResponse<>("실패: 멤버를 찾을 수 없음", null);
return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
이렇게 하면 클라이언트는 언제나 동일한 JSON 구조({ "message": "...", "data": ... })를 받게 되므로 파싱 로직을 일관되게 유지할 수 있다. ResponseEntity는 이러한 통일된 구조를 컨트롤러 단에서부터 완성해나가는 중요한 도구인 것이다.
3. ResponseEntity와 ApiResponse를 통한 일관된 응답 구조
// ✅ 모든 API 응답을 이 형식으로 통일하기 위한 클래스
public class ApiResponse<T> {
private final String status;
private final String message;
private final T data;
// 생성자는 private으로 막아서 정적 팩토리 메서드만 사용하도록 유도할 수도 있습니다.
public ApiResponse(String status, String message, T data) {
this.status = status;
this.message = message;
this.data = data;
}
// 💡 이게 바로 그 정적 팩토리 메서드입니다.
// 성공 응답을 생성하며, 기본 메시지와 함께 데이터를 받습니다.
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>("SUCCESS", "요청에 성공했습니다.", data);
}
// 💡 실패 응답을 위한 정적 팩토리 메서드도 만들어두면 편리합니다.
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>("ERROR", message, null);
}
// Getter...
public String getStatus() { return status; }
public String getMessage() { return message; }
public T getData() { return data; }
}
4. 결론
DTO를 직접 반환하는 것은 '동작하는' API를 만드는 가장 빠른 길일 수 있다. 하지만 ResponseEntity를 사용하는 것은 HTTP라는 프로토콜을 더 깊이 이해하고, 클라이언트와 더 명확하게 소통하는 '잘 설계된' API를 만드는 길이었다. 약간의 코드 복잡성을 감수하더라도, API의 명확성과 확장성을 위해 충분히 지불할 가치가 있는 비용이라고 나는 생각한다.
'Spring > Core' 카테고리의 다른 글
| [Basic-3] 관심사의 분리와 의존관계 주입(DI) (0) | 2025.12.30 |
|---|---|
| [Basic-2] 순수 자바 예제로 이해하는 DIP와 OCP 위반 (0) | 2025.12.30 |
| [Basic-1] 객체 지향 설계와 스프링의 탄생 배경 (0) | 2025.12.30 |
| [Practice-3] Spring Core: 깔끔하고 확장성 있는 예외 처리 (1) | 2025.12.16 |
| [Practice-2] 객체 생성: 빌더 패턴과 정적 팩터리 메서드 (0) | 2025.09.02 |
