Project

[Project] Spring 직렬화, 역직렬화 문제 - getWriter() has already been called for this response

육빔 2025. 2. 28. 20:05
728x90

프로젝트 진행 중 처음 보는 새로운 에러이다. 정보도 많이 없어서 게시물을 작성한다.

2025-02-28T09:25:04.226Z  WARN 1 --- [sejong-auth] [io-8080-exec-10] .m.m.a.ExceptionHandlerExceptionResolver : Failure in @ExceptionHandler org.example.backend.common.exception.ExceptionControllerAdvice#handleException(HttpServletRequest, Exception)

java.lang.IllegalStateException: getWriter() has already been called for this response
	at org.apache.catalina.connector.Response.getOutputStream(Response.java:502) ~[tomcat-embed-core-10.1.28.jar!/:na]
	at org.apache.catalina.connector.ResponseFacade.getOutputStream(ResponseFacade.java:179) ~[tomcat-embed-core-10.1.28.jar!/:na]

 

일단 이 에러가 뭔지부터 찾아봤는데

 

https://stackoverflow.com/questions/57670606/java-lang-illegalstateexception-getwriter-has-already-been-called-for-this-re

 

java.lang.IllegalStateException: getWriter() has already been called for this response, Even thought its called only once

I am using spring-boot. I want to send a CSV as the attachment of response for which I am using opencsv to write bean to response. Even though response.getWriter() is called only once, I am getting...

stackoverflow.com

 

response.getOutputStream()과 response.getWriter()가 같이 있으면 이런 에러가 발생한다고 한다. 

 

엥? 뭐지 난 저런걸 둘 다 한번도 써본적이 없어서 놀랏다.

 

그리고 신기한점은 특정 api에서만 저런 에러가 발생한다는 점이었다.

@GetMapping("/{boardId}")
    public ResponseEntity<BoardResDto> getBoard(
            @PathVariable(name = "boardId") Long boardId,
            HttpServletRequest request,
            HttpServletResponse response
    ) {
        boardService.incrementViewCount(boardId, request, response);
        BoardResDto boardResDto = boardService.getBoard(boardId);
        return new ResponseEntity<>(boardResDto, HttpStatus.OK);
    }

근데 아직 직렬화 역직렬화를 제대로 모르는 상태인 것 같아서 우선 공부해봤다.

 

우선 직렬화, 역직렬화가 뭘까?

 

직렬화는 메모리를 디스크에 저장하거나, 네트워크 통신에 사용하기 위한 형식으로 변환하는 것이고 역직렬화는 그 반대로 읽거나 네트워크에서 받은 것을 메모리에 쓰기 위해 변환하는 것이라고 이해하면 될 것 같다.

 

그런데 직렬화, 역직렬화는 왜 필요한걸까?

 

우리는 메모리에 값을 저장해야되는데 이떄 저장하는 데이터 형식은 2가지로 나뉜다.

쉽게 얘기하자면 자바에서 기본 자료형과 참조 자료형으로 나뉘는 걸 알고 있을 것이다. 이거랑 다를게 없다. 그래서 우리는 기본 자료형같은 경우는 저장하기 쉽지만 참조 데이터는 주소값이 저장되어 있으므로 객체의 데이터를 가져올 수 없다. (자바에서 class System.out.print 하면 메모리 주소가 나오는 것처럼) 그래서 이런 참조 자료형을 저장하기위해 직렬화 과정을 걸쳐야된다. 이 과정을 걸치면 바이너리 형태나 스트링 값으로 변환되는데 이때 파싱, 저장이 가능한 데이터로 변한다.

 

그러면 우리 스프링에서는 어떻게 직렬화, 역직렬화를 하고 있을까??

찾아보니

 

  • 클라이언트 → 서버:
    • 클라이언트가 JSON, XML 등의 포맷으로 request body를 전송
    • 스프링의 DispatcherServlet은 적절한 HttpMessageConverter(주로 Jackson2HttpMessageConverter 등)를 찾아서 해당 데이터를 자바 객체로 변환(역직렬화)
    • 컨트롤러 메서드 파라미터에 매핑
  • 서버 → 클라이언트:
    • 컨트롤러 혹은 서비스 로직에서 자바 객체를 반환
    • 스프링이 다시 HttpMessageConverter를 통해 JSON(XML) 형태로 변환(직렬화)
    • HTTP Response 바디로 전송

ArgumentHandler와 연결된 httpMessageConverter에서 잭슨 라이브러리에서 변환되는 것을 확인할 수 있엇다.

그리고 우리가 꽤 자주 사용하는 아래와 같은 어노테이션은 Jackson이라는 걸 확인할 수 있었다.

 

@JsonProperty: 특정 필드의 JSON 프로퍼티 이름을 설정하거나 숨기기

@JsonIgnore: 직렬화/역직렬화 시 특정 필드를 무시

 

결론적으로 스프링에서 우리가 사용하기 편하게 직렬화, 역직렬화를 잘 변환해주고 있는 것을 확인 할 수 있었다. 그리고 현재 에러를 다시 봐보자.

java.lang.IllegalStateException: getWriter() has already been called for this response
    ...
    at org.apache.catalina.connector.Response.getOutputStream(Response.java:502) ~[tomcat-embed-core-10.1.28.jar!/:na]

에러 로그를 봐보면 getWriter()가 호출된 뒤에 getOutputStream()을 다시 부르려 하는 과정에서 문제가 발생하고 있는 것을 확인할 수 있었다.

 

일단 getWriter와 getOutputStream는 무엇을까?

 

 

getWriter():

  • 문자(Char) 기반 출력.
  • 호출 시 내부적으로 텍스트 인코딩(예: UTF-8, EUC-KR 등)이 적용된 상태에서 데이터가 전송됨.
  • PrintWriter 객체를 통해 문자열을 쓰고, 서블릿 컨테이너에서 이를 적절히 바이트로 변환하여 응답에 담는다.

getOutputStream():

  • 바이트(Byte) 기반 출력.
  • 텍스트 인코딩 과정 없이 바이너리 데이터를 그대로 응답 스트림에 쓴다.
  • 주로 파일 다운로드, 이미지, 동영상, ZIP 파일 등 이진 데이터 응답 시 사용된다.

 

https://puzzle-making.tistory.com/101

 

BufferedWriter, OutputStreamWriter, getOutputStream

BufferedWriter, OutputStreamWriter, getOutputStream 이 포스팅은 카카오 로그인을 구현하던중 작성하였던 BufferedWriter bw= new BufferedWriter(new OutputStreamWriter(con.getOutputStream(),"UTF-8")); 이 코드가 제대로 이해가 되

puzzle-making.tistory.com

 

간단하게 문자 스트림 출력과 바이너리 스트림 출력이라고 이해하면 될 것 같다. 

 

그런데 왜 2개가 동시에 사용되면 안되는 제약조건을 만들었을까? 이 제약조건은 서블릿 스펙에 두고 있었다.

그 이유는 인코딩 충돌과 버퍼 충돌로 인해 보통 둘 중 하나만 되게 설정해놓는다고 한다.

 

그런데 에러에서 우선 왜 getWriter()가 먼저 불렸을까?를 생각해보자.

일반적인 controller에서는 위를 보다시피 Jackson으로 직렬화 과정을 진행하는데 찾아보니 response.getWriter()를 통해 문자열을 응답 바디로 기록하는 것을 볼 수 있었다.

 

그런데 해당 컨트롤러에서 getWriter()를 한번 열어 버린 상태에서, 스프링 MVC가 나중에 JSON 직렬화를 시도하며 OutputStream을 쓰려고 하면 IllegalStateException이 터지는 과정이었다.

 

 

 

해당 컨트롤러에서는 쿠키를 사용하기 위해 HttpServletResponse와 HttpServletRequest를 컨트롤러에서 직접적으로 호출하는 코드를 작성했었다... 김영한님 강의를 보고 작성한 코드였다. 하지만 HttpServletResponse를 직접적으로 접근하게 된다면 자연스럽게 response.getWriter()를 또 사용하게 된다는 것이었다. 

 

결론적으로 controller나 service 코드에 HttpServletRequest /  HttpServletResponse 를 파라미터로 받아서 처리하는 로직에서 문제가 발생하고 있었다... 그래서 요즘 최신버전 스프링에서는 @CookieValue를 제공한다고 한다.(처음 알았다)

 

그래서 기존 로직을 CookieValue사용해 변환하니

 @GetMapping("/{boardId}")
    public ResponseEntity<BoardResDto> getBoard(
            @PathVariable(name = "boardId") Long boardId,
            @CookieValue(value = "postView", defaultValue = "") String postViewCookie
    ) {
        String updatedCookieValue = boardService.incrementViewCount(boardId, postViewCookie);

        BoardResDto boardResDto = boardService.getBoard(boardId);

        ResponseCookie cookie = ResponseCookie.from("postView", updatedCookieValue)
                .path("/")
                .maxAge(60 * 60 * 24)
                .build();

        return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, cookie.toString())
                .body(boardResDto);
    }

 

에러가 기분 좋게 없어진 것을 확인할 수 있었다.. ㅎㅎ

728x90