과거 개발 디코 채널에서 response.setStatus()를 적용했지만 응답이 무조건 200 OK로 내려진다는 이슈에 대한 질문이 있었습니다. (지금은 채널이 사라짐 ㅠ)
실제 테스트 해본 결과 정말 200으로밖에 응답이 내려지지 않았고, 그 이유에 대해서 간단히 디버깅해보고 설명해보고자 합니다.
예제 코드는 아래 저장소에서 확인할 수 있습니다!
https://github.com/HiiWee/hiiwee-lab/tree/master/basic-spring-boot
✅ 문제 코드 및 실행 결과
문제 코드
아래 코드는 그때 상황을 발생시키기 위해 비슷하게 작성한 예시 코드입니다!
BasicController
@Slf4j
@Controller
public class BasicController {
private final ObjectMapper objectMapper;
public BasicController(final ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@GetMapping("/basic")
public void basic(final HttpServletResponse response) throws IOException {
MessageResponse messageResponse = new MessageResponse("response message to Response Body.");
objectMapper.writeValue(response.getOutputStream(), messageResponse);
response.setStatus(400);
}
}
MessageRespone - 간단한 응답 객체
@Getter
public class MessageResponse {
private String message;
private MessageResponse() {
}
public MessageResponse(final String message) {
this.message = message;
}
}
테스트 코드 및 실행 결과
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class BasicApiTest {
@LocalServerPort
private int port;
@BeforeEach
void setPort() {
RestAssured.port = port;
}
@DisplayName("mapper write 이후 status를 set하면 적용되지 않는다.")
@Test
void afterWriteToMapper_statusWillNeverChange() {
// given & when
ExtractableResponse<Response> response = RestAssured.given().log().all()
.contentType(ContentType.TEXT)
.when()
.get("/basic")
.then().log().all()
.extract();
MessageResponse messageResponse = response.body().jsonPath().getObject(".", MessageResponse.class);
// then
assertAll(
() -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()),
() -> assertThat(messageResponse.getMessage()).isEqualTo("response message to Response Body.")
);
}
}
- 기대 응답
GET /basic
요청이 Response Body에 직접 메시지를 출력하고, statusCode는 400 Bad Request로 응답되는것을 기대 - 실제 응답응답 메시지는 정상, 응답 코드가 200 OK
✅ 원인 찾기
setStatus() 디버깅
위처럼 디버깅 포인트를 걸고 테스트를 디버그 모드로 실행해보면
다음과 같이 isCommitted() 메서드에서 true를 반환하여 입력한 400이라는 status가 set 되는것을 막는것을 알 수 있습니다.
isCommitted() 메서드 내부를 살펴보면 org.apache.catalina.connector.Response
의 isAppCommitted() 메서드가 실질적인 커밋 체크를 하고 있습니다.
public boolean isAppCommitted() {
return this.appCommitted || isCommitted() || isSuspended() ||
((getContentLength() > 0) && (getContentWritten() >= getContentLength()));
}
위의 검사는 현재 app이 커밋 되었는지, response object가 커밋 되었는지, response에 쓰여진 content-length 0보다 크지만 실제 쓰여진 바이트수가 response content-lenght보다 큰지 여부를 통해 boolean 값을 계산합니다.
@Override
public boolean isCommitted() {
return getCoyoteResponse().isCommitted();
}
그 중에서 response object가 커밋되었는지 확인하는 isCommitted()메서드는org.apache.coyote.Response
(속칭 CoyoteResponse) 객체의 커밋상태를 확인합니다.
여기까지 살펴보면 응답 상태를 set하려 했지만, 어떤 이유에선지 이미 commit되어 있는 상태라는 걸 확인할 수 있습니다.
coyoteResponse
에는 setCommitted()라는 메서드가 존재하므로 해당 부분을 디버깅해 어디에서 commit flag가 true로 set 되는지 확인할 수 있습니다.
org.apache.coyote.Reponse의 setCommitted() 디버깅
위와 같이 디버깅 포인트를 걸고 실행해보면 BasicController에서 objectMapper를 통해 writeValue()를 호출할때 내부적으로 response에 응답 객체를 쓰고, commit을 true로 set함을 알 수 있습니다.
스택 트레이스를 따라가면 아래와 같이 동작합니다.
- 응답 데이터를 outputBuffer 씀
- close 작업시 내부에서 flush를 통해 버퍼에 있는 데이터를 실제 response로 옮기는 작업을 시작
- 먼저 Response Header 작업이 끝났다는 신호를 보내줌
- Http11OutputBuffer에게 헤더의 유효성을 검증하고, 헤더를 Response에 작성 및 commit하도록 합니다.
- 이때 내부적으로 coyoteResponse는 commit 플래그가 set 됩니다.
- 초기 outputBuffer에 작성한 응답 데이터를 write함
결국 위의 작업 이후에 setStatus를 호출하게 되면 이미 commit이 true값으로 set 되어있으므로 응답 코드를 설정할 수 없게 됩니다.
✅ 결론
setStatus는 반드시 response에 응답 데이터를 write 하기 이전에 하자!
Spring Security를 사용할때 successHandler 혹은 failHandler에서 직접 Response에 응답 데이터 및 코드를 내려주는 상황이 종종 있습니다. 이럴때에 이 부분을 잘 알고있다면 적어도 응답 코드가 바뀌지 않는 문제상황을 피할 수 있지 않을까요?!
잘못되거나 부족한 부분이 있다면 언제든지 댓글로 알려주시면 감사합니다!