Spring/Debugging Spring

[Tomcat 디버깅 해보기] mapper.writeValue() 이후 response.setStatus()를 하면 안되는 이유

HiiWee 2023. 8. 23. 17:30

과거 개발 디코 채널에서 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에 응답 데이터 및 코드를 내려주는 상황이 종종 있습니다. 이럴때에 이 부분을 잘 알고있다면 적어도 응답 코드가 바뀌지 않는 문제상황을 피할 수 있지 않을까요?!

잘못되거나 부족한 부분이 있다면 언제든지 댓글로 알려주시면 감사합니다!