Spring

Controller에서 왜 반환값을 ResponseEntity로 안 해?

개발자성장기 2025. 6. 20. 21:56
반응형

 

 

 

레벨 2 미션을 처음 했을 때, 습관처럼 컨트롤러에서 반환 값을 ResponseEntity로 감싸서 처리했다.

예를 들어, 아래와 같은 코드였다.

@GetMapping("/users/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
    return ResponseEntity.ok(userService.findUser(id));
}

사실 이전 프로젝트에서도 큰 고민 없이 이렇게 사용했고, "왜?"라는 질문조차 던지지 않았다.
미션을 제출한 후에도 자연스레 의심 없이 지나쳤다.

 

그러다 우연히 다른 크루의 코드 리뷰를 보며 처음으로 궁금증이 생겼다.

 

 

이때부터 생각했다.  

`ResponseEntity`가 무엇이고 왜 써야 하지? 

안 쓰면 어떻게 될까? 

 

🚀 ResponseEntity란 ?

먼저 ResponseEntity가 무엇인지부터 간단히 정리하면 다음과 같다.

ResponseEntity<T>는 스프링의 HttpEntity<T>를 확장한 클래스로, HTTP 상태 코드와 헤더를 함께 담아 응답을 유연하게 제어할 수 있다.

 

기본적으로 컨트롤러에서 객체를 반환하면 HTTP 본문(body)만 전달된다. 그러나 ResponseEntity를 쓰면 상태 코드와 헤더를 직접 제어할 수 있다.

 

1. 상태 코드 제어

단순 반환은 200 OK만 반환되지만, ReponseEntity로는 200대, 300대, 400대, 500 대 등 다양한 코드를 직접 지정할 수 있다.

 

2. 커스텀 헤더 추가

예를 들어 ETag, location, 인증 토큰 등을 응답 헤더에 넣고 싶을 때 유용하게 활용할 수 있다.  

 

3. 예외 처리 시 일관된 응답 포맷

@ControllerAdvice와 함께 ReponseEntityExceptionHandler를 상속해 오류 등답을 한 곳에서 관리할 수 있다.

 

 

😎 ResponseEntity 없이 상태코드와 헤더 관리하기

그렇지만 다른 방법도 있다.  

그냥 dto만 반환하는 거다.  

그럼 상태코드와 헤더는? 할 것이다.                                                      

 

상태코드는 `@ResponseStatus` 어노테이션을 활용하면 가능하다. 

                                       

그럼 헤더는?? 

아쉽게도 헤더는 어노테이션이 없다.  

 

그 이유는 Spring-framework issues에서 찾을 수 있다. 

쉽게 요약하자면 아래와 같다.

Java 어노테이션은 컴파일 시점에 상수만 허용하기 때문에 헤더 값이 요청 비즈니스 로직, DB 결과 등에 따라지는 "런타임 계산"이 필요한 경우를 처리할 수 없다.   

 

더 쉽게 이야기하면 

`@ResponseStatus`는 항상 고정된 HTTP 상태 코드(예: 404, 201 등)를 선언할 때 쓰이고, 이 경우엔 어노테이션 속성으로 충분히 표현이 가능 반면 Location, ETag, Set-Cookie 같은 헤더는 대개 "저장된 객체의 ID"나 "해시 값"처럼 동적으로 생성된 값을 담아야 한다.  

정적 헤더는 예를 들어 Content-Type: application/json처럼 변하지 않는 값이고,
동적 헤더는 Location: /users/1234처럼 사용자의 요청 결과에 따라 변하는 값이다.

 

모든 정적과 동적 모든 경우를 다 커버하는 `@ResponseHeader` 어노테이션을 추가하려면 

동적 값 바인딩 로직, 여러 타입 반환, 예외 처리 등 복잡도가 크게 늘어나고 오히려 프레임워크가 무거워진다.  

따라서 헤더도 어노테이션으로 달 수야 있지만, 동적인 특성이 강하니 굳이 프레임워크 차원에서 `@RsponseHeader` 같은 어노테이션은 만들지 않은 것 같다.  

 

🤔 ResponseEntity를 쓸 때와 쓰지 않을 때 어떻게 동작할까?

 

클라이언트에서 요청을 받아 컨트롤러 메서드 실행하는 단계로 가면

`ServletInvocableHandlerMethod.invodkeAndHandle(...)` 호출하게 된다.  

그런 다음 리턴값을 처리할 핸들러를 선택한다.  

`HandlerMethodReturnValueHandlerComposite.handleReturnValue()`가 호출된다.

 

`handleReturnValue`메서드는 위와 같이 생겼다.  

여기서 `selectHandler`가 동작할 때 `this.returnValueHandlers`를 순서대로 돌면서 `supportsReturnType()`이 true인 핸들러를 선택한다.

 

핸들러의 종류는 아래와 같다.  

`HandlerMethodReturnValueHandler` 구현체들
RequestResponseBodyMethodProcessor @ResponseBody 또는 @RestController가 붙은 컨트롤러의 리턴값 JSON/XML로 변환해서 응답 본문에 직접 씀 (@ResponseBody 기반)
HttpEntityMethodProcessor ResponseEntity, HttpEntity 상태 코드, 헤더, 본문 등을 직접 지정해서 반환
ModelAndViewMethodReturnValueHandler ModelAndView 타입 뷰 이름과 모델 정보를 함께 반환
ViewNameMethodReturnValueHandler String 뷰 이름 String을 반환하면 뷰 이름으로 해석
ModelMethodProcessor Model, Map<String, Object> 등 모델 객체를 직접 반환
ViewMethodReturnValueHandler View 타입 뷰 객체를 직접 반환 (ThymeleafView, InternalResourceView 등)
RedirectViewMethodReturnValueHandler String + "redirect:" prefix redirect:로 시작하면 리다이렉트 처리
CallableMethodReturnValueHandler Callable<T> 비동기 처리용
DeferredResultMethodReturnValueHandler DeferredResult<T> 비동기 처리용
ResponseBodyEmitterReturnValueHandler ResponseBodyEmitter, SseEmitter 서버-푸시 또는 SSE 처리
StreamingResponseBodyReturnValueHandler StreamingResponseBody 스트리밍 응답 처리
AsyncHandlerMethodReturnValueHandler 위 비동기 관련 핸들러들의 공통 super type 역할 내부에서 조건 분기할 때 사용

 

여기서 우리가 주목해야 할 것은 `HttpEneityMethodProcessor` 와 `RequestResponseBodyMethodProcessor`이다.  

왜냐하면 대부분  `ResponseEntity`를 반환하거나  `@ResponseBody`가 붙은 메서드를 사용하니까 말이다.  

 

 

HttpEntityMethodProcessor

HttpEntityMethodProcessor는 스프링 MVC에서 컨트롤러 메서드의 반환값이 HttpEntity나 ResponseEntity일 때 그 결과를 직접 HTTP 응답으로 만들어주는 클래스이다.

헤더·상태코드·본문을 ResponseEntity 객체로부터 꺼내서 HttpServletResponse에 세팅한다.

⁉️ HttpEntity vs ResponseEntity

`HttpEntity`는 본문 + 헤더
`ResponseEntity`는 본문 + 헤더 + 상태코드

 

RequestResponseBodyMethodProcessor

반환 타입이 그 외(예: DTO 객체, String, void 등)이고 메서드나 클래스에 `@ResponseBody` 또는 `@RestController` 가 붙어 있을 때 동작한다.
이 핸들러는 상태 코드를 항상 200 OK로, 헤더는 기본값으로 두고
반환 객체를 `HttpMessageConverter` (예: MappingJackson2 HttpMessageConverter)로 직렬화해서 응답 바디에 쓴다.

 

❓ResponseEntity를 사용하지 않는 이유?

정확히 말하면 필요한 곳이 아니면 사용하지 않는다. 

실제로 받은 코드 리뷰

지금은 코드 리뷰에서 작성된 것에서 조금은 수정되었다.
📌 RestController에서 반환값 전체를 ResponseEntity로 했다가 제거한 이유

처음에는 RestController에서 모든 메서드가 ResponseEntity를 반환하도록 했습니다.
상태 코드 제어 및 헤더 조작이 가능하기에 해당기능이 필요할 때만 사용하기보다는 일관성 있게 모든 RestController 메서드에서 동일한 값을 반환하도록 해서 일관성을 확보하고자 했습니다.

하지만 지금 다시 생각해 보니 잘못 생각한 것 같았습니다.
일단 반환할 때마다 불필요한 객체생성이 됩니다.
물론 해당 기능으로 조작이 필요할 때는 유용하지만 다른 어노테이션으로 일부는 커버가 가능합니다.
단순 상태 코드 지정을 위해서는 @ResponseStatus어노테이션을 사용했습니다.
즉,  헤더를 조작하거나 동적으로 상태코드를 조작할 필요가 없다면 ResponseEntity를 쓸 이유를 전혀 찾지 못했습니다. 

또한 ResponseEntity 객체로 반환 값을 감싼다는 것은 해당 컨트롤러에서 여러 가지 케이스에 대해서 대체하여 그에 따른 HTTP Status Code를 포함하여 Response Body를 내려주겠다는 의미입니다.
그렇다는 건 개별 컨트롤러 단에서 예외에 대한 핸들링을 진행할 수도 있다는 의미입니다.
이러한 코드는 바람직하지 않다고 생각하여 GlobalExceptionHandler를 만들고 거기서 ResponseEntity를 사용하도록 하였습니다. 
이렇게 만들어주면 API 서버에서 발생하는 모든 예외를 해당 객체에서 처리하여 통일성 있는 예외 Response를 내려주고 각 개별적인 컨트롤러에 예외에 대한 핸들링에 대한 책임을 부여하지 않아 컨트롤러 계층을 작게 가져가기 위함입니다.

 

🚀 ResponseEntity 사용하기 for 캐싱

(이 부분은 그냥 건너뛰어도 된다)  

 

결론부터 말하면 ETag 기반 캐시 처리를 제대로 사용하려면 `ResponseEntity`를 반환해야 한다.

왜냐하면 `HttpEntityMethodProcessor`만이 ETag와 관련된 조건부 요청(If-None-Match 등)을 해석하고 처리하기 때문이다.

 

`handleReturnValue` 메서드를 살펴보다 우연히 발견한 것이 있다. 

아래 코드를 보면 200 OK 만 따로 선별해서 처리하고 있다.  

이걸 보는 순간 "왜 200 OK만 따로 처리하지?"라는 궁금증이 생겼고  

이것이 캐시와 연관되어 있다는 것을 알게 되었다. 

 

 

🔖 ETag란?

ETag는 서버가 리소스(파일, JSON, HTML 등)의 고유한 식별자를 만들어 클라이언트에게 응답 헤더로 보내주는 값이다.
이 값은 주로 리소스의 내용 기반 해시 또는 버전 번호 등으로 만들어지며, 클라이언트는 이 값을 캐시에 저장하고 다음 요청 시 서버에 보내 비교하게 된다.

자주 조회되지만 자주 바뀌지 않는 데이터에 사용할 때 가장 효과적이다. 
(ex 사용자 프로필, 게시글, 상품 상세 등) 

ETag는 서버가 저장해서 기억하고 있는 게 아니라 리소스의 현재 상태를 기반으로 매번 계산해서 비교하기 때문에 서버가 여러대라도 대응이 가능하다.
즉, 매번 동일한 리소스 상태라면 매번 동일한 ETag가 생성된다.  

 

ETag를 활용한 예시

 

📌서버 응답 시

HTTP/1.1 200 OK
ETag: "abc123"
Content-Type: application/json

{ "name": "cheolwon" }

 

클라이언트는 "abc123"이라는 ETag를 캐시에 저장한다.

 

📌 클라이언트가 재요청 시

GET /user HTTP/1.1
If-None-Match: "abc123"

서버는 이 ETag와 현재 리소스의 ETag를 비교한다. 

 

만약 같다면 

HTTP/1.1 304 Not Modified

 

위와 같이 응답하고 클라이언트에서 캐시 된 데이터를 사용한다.  

만약 다르다면

HTTP/1.1 200 OK
ETag: "new456"
Content-Type: application/json

{ "name": "cheolwon updated" }

새로운 ETag와 함께 새로운 데이터를 반환한다.  

 

 

해당글은 ResponseEntity에 관한 글이기 때문에 ETag설명은 여기까지 하고 더 궁금하다면 아래 글을 참고해 보면 좋을 것 같다.

Etag를 이용하여 더 나은 Restful API 만들기

 

 

📚 Reference

- ResponseBody

- ResponseEntity

- Using Spring ResponseEntity to Manipulate the HTTP Response

- 제제의 ResponseBody vs ResponseEntity

- [Spring] ResponseEntity vs DTO

반응형