목차

    개요

    프로젝트 중 대용량 데이터를 프론트엔드에 보내야 할 일이 있었습니다. 이 과정에서 전체를 한 번에 보내려고 하니 백엔드에도 무리가 가고 통신에도 무리가 가는 것으로 보여 청크 단위로 통신하여 전달하는 방식을 고안하고자 ResponseBodyEmitter를 찾게 되었습니다. 우선 더미로 테스트 코드를 진행해보았습니다.

     

    시작하기 앞서

    ResponseBodyEmitter를 쓰려면 비동기가 작동 가능하도록 하는 것이 필수입니다. 저는 이미 비동기 작업을 위해 @EnableAsync를 해줬는데 ResponseBodyEmitter를 쓰려고 하니 아래와 같은 에러 로그가 발생하였습니다.

    더보기

    java.lang.IllegalStateException: Async support must be enabled on a servlet and for all filters involved in async request processing. This is done in Java code using the Servlet API or by adding "true" to servlet and filter declarations in web.xml.
    at org.springframework.util.Assert.state(Assert.java:392)
    at org.springframework.web.context.request.async.StandardServletAsyncWebRequest.startAsync(StandardServletAsyncWebRequest.java:103)
    at org.springframework.web.context.request.async.WebAsyncManager.startAsyncProcessing(WebAsyncManager.java:428)
    at org.springframework.web.context.request.async.WebAsyncManager.startDeferredResultProcessing(WebAsyncManager.java:408)
    at org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitterReturnValueHandler.handleReturnValue(ResponseBodyEmitterReturnValueHandler.java:159)
    at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:81)
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:130)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:827)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:738)
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:85)
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963)
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:897)
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:970)
    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:861)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:489)
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:846)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:583)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:212)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:156)
    at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:181)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:156)
    at cohttp://m.duzon.common.filter.P3PFilter.doFilter(P3PFilter.java:22)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:181)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:156)
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:197)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:181)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:156)
    at org.springframework.web.filter.HttpPutFormContentFilter.doFilterInternal(HttpPutFormContentFilter.java:89)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:181)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:156)
    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:168)
    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)
    at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483)
    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:130)
    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)
    at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:679)
    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:342)
    at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:617)
    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:934)
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1698)
    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
    at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
    at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
    at java.lang.Thread.run(Thread.java:748)

    확인해본 결과 @EnableAsync를 통한 비동기 가능하도록 하는 설정은 스프링 자체에서 비동기 사용 가능하게 해주는 것이고 ResponseBodyEmitter는 Servlet까지 나가기에 별도의 설정이 추가적으로 필요하다고 합니다.
    (참고사항 : 일부 구형 서블릿 컨테이너(Tomcat6 또는 그 이전 버전)에서는 비동기 처리가 지원되지 않습니다. Servlet 3.0 이상이 필요합니다.)

     

    저희는 환경 설정을 xml 파일로 진행하고 있었기에 web.xml 파일의 <filter>태그와 <servlet>태그에

    <async-supported>true</async-supported>

    를 넣어주어 해당 오류를 해결하였습니다.

     

    간단하게 적용하기

    단순히 ResponseBodyEmitter를 사용해본다는 수준으로 한 번 테스트 해보았습니다.

    백엔드 코드 입니다.

    Controller에서 바로 동작을 진행합니다.

    @GetMapping("/test-emitter")
        public ResponseBodyEmitter getTestEmitter() throws IOException {
           ResponseBodyEmitter responseBodyEmitter = new ResponseBodyEmitter();
    
           Executors.newSingleThreadExecutor().submit(() -> {
               try {
                   for (int i=0; i<10; i++) {
                       responseBodyEmitter.send("test\n");
                       Thread.sleep(1000);
                   }
                   responseBodyEmitter.complete();
               } catch (Exception e) {
                   responseBodyEmitter.completeWithError(e);
               }
           });
    
           return responseBodyEmitter;
       }

     

    ResponsBodyEmitter 객체를 생성하고 해당 객체를 별도의 스레드 위에서 실행시키도록 해줍니다.

    예시에서는 Executors.newSingleThreadExecutor()로 별도의 실행 스레드를 만들어주었지만 실제 프로젝트에 적용하고자 하면 청크 통신을 위한 별도의 ThreadPool을 생성하여 작동하도록 해야 할 것 같습니다.

     

    이후, 별도의 스레드에서 responseBodyEmitter에 메시지를 보내면 1초에 한 번씩 메시지가 프론트엔드에 가는 것을 볼 수 있을 것 입니다. 참고로 send 메서드 실행 시, 보내는 메시지의 형태를 정할 수 있습니다. TEXT, JSON 형식 등을 말이죠.
    (예시)

    responseBodyEmitter.send(hashMap, MediaType.APPLICATION_JSON);

    해당 값이 제대로 오는지 확인하기 위해 만약 포스트맨을 쓴다고 하면 제대로 동작하지 않을 것 입니다. 포스트맨은 청크 통신을 지원하지 않는다고 하더군요.

    직접 터미널에서 호출하여 테스트하거나 간단한 프론트엔드 코드를 만들어 테스트하면 됩니다.

     

    테스트를 위한 프론트엔드 코드

    아래의 간단한 프론트엔드 코드를 만들어 테스트를 진행해보았습니다.

    import React, { useState } from 'react';
    
    function App() {
      const [messages, setMessages] = useState([]);
      const [error, setError] = useState(null);
    
      const handleClick = async () => {
        setMessages([]); // Clear previous messages
        setError(null); // Clear previous errors
    
        try {
          const response = await fetch('/tmp/test-emitter');
          if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
          }
    
          const reader = response.body.getReader();
          const decoder = new TextDecoder("utf-8");
    
          while (true) {
            const { done, value } = await reader.read();
            if (done) break; // End of stream
            const chunk = decoder.decode(value);
            setMessages((prev) => [...prev, chunk]);
          }
        } catch (err) {
          setError(`Error: ${err.message}`);
        }
      };
    
      return (
        <div style={{ padding: '20px' }}>
          <h1>Test Emitter</h1>
          <button onClick={handleClick} style={{ marginBottom: '20px' }}>
            동작하기
          </button>
          {error && <p style={{ color: 'red' }}>{error}</p>}
          <div style={{ background: '#f4f4f4', padding: '10px', borderRadius: '5px' }}>
            {messages.length > 0 ? (
              messages.map((msg, index) => <p key={index}>{msg}</p>)
            ) : (
              <p>결과가 여기에 표시됩니다.</p>
            )}
          </div>
        </div>
      );
    }
    
    export default App;

     

    아래와 같은 결과가 나오는 것을 확인 할 수 있었습니다.

     

     

    추후 진행하려고 하는 사항들

    JSON을 연달아 보내면 프론트엔드가 어떻게 처리할까 궁금하여 JSON 형태로 보내는 연습을 해보려고 합니다.

    또한 현재는 간단한 구조라 Controller에서 진행을 했는데 (대부분의 예시도 Controller에서 끝) DB 조회도 나누어 진행하고 보낼 수 있는지 궁금하여 진행해보려고 합니다.

    마지막으로 실제 프로젝트에서는 다른 서버에서 응답을 우리 서버로 받아온 다음 프론트로 넘겨야 하는데 Spring으로 청크 데이터를 받아 프론트엔드로 보내는 연습도 필요할 것으로 보입니다.

     

     

     

    내가 하고 싶었던 최종 형태

     

    백엔드 코드

    더보기
    public void responseBodyEmitterTest(ResponseBodyEmitter responseBodyEmitter) throws IOException {
    
        String[][] strs = {{"columnA|columnB|columnC|columnD|columnE", "a1|b1|c1|d1|e1", "a2|b2|c2|d2|e2"},
                            {"a3|b3|c3|d3|e3", "a4|b4|c4|d4|e4", "a5|b5|c5|d5|e5"},
                            {"a6|b6|c6|d6|e6", "a7|b7|c7|d7|e7", "a8|b8|c8|d8|e8"}};
    
        Executors.newSingleThreadExecutor().submit(() -> {
            try {
                for (int i=0; i<3; i++) {
                    HashMap<String, Object> hashMap = new HashMap<>();
                    hashMap.put("value", strs[i]);
                    responseBodyEmitter.send(hashMap, MediaType.APPLICATION_JSON);
                    Thread.sleep(1000);
                }
                responseBodyEmitter.complete();
            } catch (Exception e) {
                responseBodyEmitter.completeWithError(e);
            }
        });
    }

    프론트엔드 코드

    더보기
    import React, { useState } from 'react';

     

    function DataTable() {
    const [data, setData] = useState([]);
    const [isFetching, setIsFetching] = useState(false);

     

    const fetchData = async () => {
    try {
    const response = await fetch('/wedp/tmp/test-emitter');
    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');

     

    while (true) {
    const { done, value } = await reader.read();
    if (done) break;

     

    const decodedChunk = decoder.decode(value, { stream: true });
    const lines = decodedChunk.split('\n');

     

    for (let line of lines) {
    if (line) {
    try {
    const parsedData = JSON.parse(line).value;
    setData((prevData) => [...prevData, ...parsedData]);
    } catch (e) {
    console.error('JSON parsing error:', e);
    }
    }
    }
    }
    } catch (error) {
    console.error('Error fetching data:', error);
    }
    };

     

    const handleStartClick = () => {
    setIsFetching(true);
    fetchData();
    };

     

    const renderTable = () => {
    if (data.length === 0) return null;

     

    return (
    <table border="1">
    <tbody>
    {data.map((row, rowIndex) => (
    <tr key={rowIndex}>
    {row.split('|').map((cell, cellIndex) => (
    <td key={cellIndex}>{cell}</td>
    ))}
    </tr>
    ))}
    </tbody>
    </table>
    );
    };

     

    return (
    <div>
    <button onClick={handleStartClick} disabled={isFetching}>
    동작 시작
    </button>
    {renderTable()}
    </div>
    );
    }

     

    export default DataTable;

    참고자료

    https://velog.io/@wwlee94/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC-%EA%B8%B0%EC%88%A0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0

     

    https://m.blog.naver.com/jdjhaha/222169740040

     

    https://velog.io/@umtuk/%EB%A0%88%EC%8B%9C%ED%94%BC-5-2-%EC%9D%91%EB%8B%B5-%EC%B6%9C%EB%A0%A5%EA%B8%B0#%EC%9D%91%EB%8B%B5-%EC%B6%9C%EB%A0%A5%EA%B8%B0

    'Spring' 카테고리의 다른 글

    Spring Boot에서 PostgreSQL 연동하기  (0) 2023.10.28