no image
Spring 청크 통신을 편리하게 쓰게 해주는 ResponseBodyEmitter 써보기
개요프로젝트 중 대용량 데이터를 프론트엔드에 보내야 할 일이 있었습니다. 이 과정에서 전체를 한 번에 보내려고 하니 백엔드에도 무리가 가고 통신에도 무리가 가는 것으로 보여 청크 단위로 통신하여 전달하는 방식을 고안하고자 ResponseBodyEmitter를 찾게 되었습니다. 우선 더미로 테스트 코드를 진행해보았습니다. 시작하기 앞서ResponseBodyEmitter를 쓰려면 비동기가 작동 가능하도록 하는 것이 필수입니다. 저는 이미 비동기 작업을 위해 @EnableAsync를 해줬는데 ResponseBodyEmitter를 쓰려고 하니 아래와 같은 에러 로그가 발생하였습니다.더보기java.lang.IllegalStateException: Async support must be enabled on a se..
2025.01.09
Spring Boot 없이 xml로 환경 설정 할 때 JUnit Bean 주입 (이슈 내용 정리)
개요회사 코드에서 Spring Boot없는 순수 Spring으로 xml 환경 설정을 진행하고 있습니다. 이 때, 평소에 Spring Boot를 쓰던 상황에 편하게 적용하던 테스트 클래스 Component 적용을 Spring 환경에서 어떻게 진행하는지 삽질한 내용을 정리해두고자 합니다.  적용법적용법은 하단의 블로그를 참고했습니다.https://codevang.tistory.com/259 jUnit, Spring-Test 라이브러리 사용법[ jUnit ] 전체 프로젝트(특히 WAS)를 구동하지 않고 단위 코드 테스트를 할 수 있게 해주는 라이브러리 [ Spring-Test ] jUnit을 확장한 스프링의 테스트 라이브러리 스프링 MVC 프로젝트를 진행할 때 코codevang.tistory.com 테스트 폴..
2024.10.28
HashMap을 파라미터로 쿼리 넘기는 상황에서 오류 발생 시 최대한 컴파일러 오류를 내기 위한 노력
개요현재 근무하고 있는 회사에서 MyBatis를 통해 DB와 통신 할 때, 파라미터를 HashMap으로 넘기고 있습니다. 이 경우 VO를 써야한다와 HashMap을 써야 한다에 대해서는 꽤 오래된 논의인 것으로 보입니다. 여기서는 VO를 써야한다 HashMap을 써야한다에 대한 논의는 하지 않으려고 합니다.다만 현재 팀에서 HashMap을 파라미터로 사용하고 있을 때 겪는 문제가 있는데 이 부분을 HashMap을 쓰지 말자가 아닌 쓰더라도 오류가 있을 시 최대한 컴파일러 오류로 나타나게 하여 IDE에서 확인이 되어 배포 전에 확인 가능한 방법을 고민한 결과를 공유하고 혹시 더 좋은 아이디어가 있을지에 대해 정리하고자 합니다. 현재 회사에서 진행되는 코드 방식현재 회사에서는 DBconnection을 만들어..
2024.10.19
no image
JPA로 다중 DB 설정하기
개요 Spring 내에서 다중 DB를 사용하고 싶으면 다중 DB 설정을 진행해야한다고 합니다. 기존에 사용하듯이 application.yml과 같은 설정 파일에 하나의 DB만 설정하면 Spring Boot에서 자동 구성(Auto Configuration)을 통해 문제 없이 사용 할 수 있었지만 다중 DB 설정에는 자동 구성이 되지 않기 때문에 설정 파일 값을 읽어와 연동 할 DB 수 만큼 Datasource를 수동 설정해야한다고 합니다. application.yml 설정 아래와 같이 application.yml을 설정하였습니다. second-datasource는 본인 원하는대로 이름을 붙이면 됩니다. (예를 들면, second.datasource) Datasource 설정에서 주입해줄 것이기 때문에 상관..
2023.10.31
no image
Spring Boot에서 PostgreSQL 연동하기
JPA와 PostgreSQL 연동 라이브러리 설치 MySQL을 연동할 때와 같이 JPA 라이브러리 설치와 PostgreSQL 라이브러리 설치를 진행합니다. build.gradle에 아래의 코드를 추가합니다. implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.postgresql:postgresql:42.6.0' application.yml 설정 MySQL을 연동할 때와 같이 application.yml에 DB 접속 정보 및 JPA 설정을 입력해야 합니다. spring: datasource: url: jdbc:postgresql://localhost:5432/ username: password: ..
2023.10.28
no image
SockJS 간단한 공부(SockJS는 이제 필요없는가?)
개요 Spring Documentation에서 WebSocket API를 공부한 뒤 다음 단계는 SockJS 였습니다. SockJS를 간단히 공부해보았는데요. 공부해봤을 때, 'SockJS가 필요없는건가?'라는 생각이 들어 블로그 글을 정리해봤습니다. 만약 SockJS에 대해 자세히 알아보고 싶으신 분은 아래의 블로그를 참조해주시길 부탁드립니다. [Spring Boot] WebSocket과 채팅 (2) - SockJS [Spring Boot] WebSocket과 채팅 (1) 일전에 WebSocket(웹소켓)과 SockJS를 사용해 Spring 프레임워크 환경에서 간단한 하나의 채팅방을 구현해본 적이 있다. [Spring MVC] Web Socket(웹 소켓)과 Chatting(채팅) 기존 공 dev-go..
2023.10.18
Spring Boot Repository Test 간단하게 실습
개요 Spring Boot를 사용하여 프로젝트 구현 때, DB와 관련된 테스트를 진행하기 위한 방법을 연습하고자 했습니다. 분명 테스트 진행 후 롤백시켜서 DB에 영향을 안끼치는 무언가가 있었다는 점을 알고 있었지만 기억이 나지 않아 다시 공부하고 또 찾을 때를 대비해 기록을 남겨 놓고자 합니다. @DataJPATest 분명 테스트 진행 후 롤백시켜서 DB에 영향을 안끼치는 무언가를 잊어먹었던 그 무언가는 @DataJPATest입니다. @DataJPATest는 우선 기본적으로 메모리 DB를 사용하도록 되어 있고 @Transactional이 걸려있기에 테스트 이후 롤백되어 DB에 영향을 안미치도록 되어있습니다. 또한 만약 실제 DB에서의 테스트를 원한다면 설정을 통해 실제 DB 환경에서의 테스트도 가능하다..
2023.10.17
no image
김영한님의 JPA 책에서 상속 관련된 내용 읽다가 JPA에 직접 등록하는 것이 아닌 JpaRepository를 이용하는 경우 어떨까라는 궁금증에 글을 써봅니다.
개요 김영한님의 JPA 책 중에 코드에서의 객체와 데이터베이스 사이의 패러다임 불균형을 JPA가 해결해준다라는 내용이 있습니다. 그에 관한 예시 중 상속에 관한 이야기가 있었습니다. '상속은 객체의 대표적인 속성인데 데이터베이스로 표현하려면 불편해진다. 이러한것들을 JPA가 해결해준다.' 라는 내용이었습니다. 이 때 들어준 예시 코드가 JPA를 이용하여 직접 등록해주는 방식 등 정석적인 방법처럼 보였는데 현재 저는 데이터베이스에 저장할 객체는 @Entity를 사용해주고 JpaRepository를 상속시켜 DB와 연동시키는 방법을 사용합니다. 이러한 과정에서 현재 제가 사용하는 방법에서도 상속을 이용한 뒤 DB에 저장시키는 방법론이 동일하게 적용되는지 궁금하여 실험을 해보았습니다. (빠른 실험을 위해 만든..
2023.10.16
no image
WebSocket API를 사용해 채팅창 구현하기
코드는 아래의 깃허브 주소에서 WebSocketAPIToy 폴더 속에 있습니다. https://github.com/Dev-Taehee/WebSocketToy 설정 저는 Spring Initializr를 활용하여 위와 같이 설정한 후 시작했습니다. 개요 WebSocket API의 가장 중요한 두 가지는 WebSocketHandler와 WebSocketConfigurer 입니다. public class MyHandler extends TextWebSocketHandler { @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { // ... } } WebSocketHandler는 말그대로 WebSocket..
2023.10.10
no image
AJAX, HTTP Streaming, Long Polling 이란?
개요 웹소켓 공부를 시작하며 본 Spring Documentation에는 다음과 같은 구문이 있었습니다. '그러나 AJAX, HTTP Streaming 또는 Long Polling을 결합하면 간단하고 효과적인 제공할 수 있습니다. 예를 들어 뉴스, 메일, 소셜 피드는 동적으로 업데이트되어야 하지만 몇 분마다 업데이트해도 괜찮습니다. 또한 메시지의 양이 상대적으로 적은 경우에도 포함됩니다.' 웹소켓 공부를 시작하기 전에 대체재로 사용 가능한 AJAX, HTTP Streaming, Long Polling에 대해 간단히 파악하고 싶어 살짝 공부해보고 넘어가보려고합니다. AJAX란? AJAX란 Asynchronous JavaScript and XML의 약자입니다. AJAX는 빠르게 동작하는 동적인 웹 페이지를 ..
2023.09.22

목차

    개요

    프로젝트 중 대용량 데이터를 프론트엔드에 보내야 할 일이 있었습니다. 이 과정에서 전체를 한 번에 보내려고 하니 백엔드에도 무리가 가고 통신에도 무리가 가는 것으로 보여 청크 단위로 통신하여 전달하는 방식을 고안하고자 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

    목차

      개요

      회사 코드에서 Spring Boot없는 순수 Spring으로 xml 환경 설정을 진행하고 있습니다. 이 때, 평소에 Spring Boot를 쓰던 상황에 편하게 적용하던 테스트 클래스 Component 적용을 Spring 환경에서 어떻게 진행하는지 삽질한 내용을 정리해두고자 합니다.

       

       

      적용법

      적용법은 하단의 블로그를 참고했습니다.

      https://codevang.tistory.com/259

       

      jUnit, Spring-Test 라이브러리 사용법

      [ jUnit ] 전체 프로젝트(특히 WAS)를 구동하지 않고 단위 코드 테스트를 할 수 있게 해주는 라이브러리 [ Spring-Test ] jUnit을 확장한 스프링의 테스트 라이브러리 스프링 MVC 프로젝트를 진행할 때 코

      codevang.tistory.com

       

      테스트 폴더에 설정값을 매번 붙여넣기도 번거로울 것 같아 locations를 사용하고 file: 을 붙여주어 full-path로 입력해 실제 개발 상황에 사용 중인 설정값을 읽어오도록 하였습니다. 다만 저희는 각 상황에 따라 설정파일을 다르게 관리하고 있었기에 아래 블로그 예시와 같이 여러 파일 값을 사용하도록 하였습니다. 또한 매번 입력하는 것이 번거로울 것 같아 블로그에 소개해준 내용처럼 상위 클래스에 설정값을 넣고 상속받아 사용하는 방법을 이용하고자 합니다.

      @ContextConfiguration
      (locations = {"file:src/main/webapp/WEB-INF/spring/root-context.xml",
      		"file:src/main/webapp/WEB-INF/spring/appServlet/servlet-context.xml"})

       

      이 방법대로 그대로 잘되었으면 좋았겠지만 2 가지 오류를 만났습니다.

       

       

      Spring Bean 초기화 중 EL (Exprssion Language) 관련 종속성 누락

      제가 만들어둔 설정 Bean들 중 @Valid를 사용하기 위해 만든 MehodValidationConfig 클래스가 있었습니다.

      public class MethodValidationConfig {
      
      	@Bean
      	public MethodValidationPostProcessor methodValidationPostProcessor() {
      		return new MethodValidationPostProcessor();
      	}
      
      	@Bean
      	public LocalValidatorFactoryBean localValidatorFactoryBean() {
      		return new LocalValidatorFactoryBean();
      	}
      
      }

       

      테스트 코드 실행 시 methodValidationPostProcessor에 에러가 발생하였다고 로그가 떴었습니다.

      더보기

      Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'methodValidationPostProcessor' defined in com.duzon.lulu.dataprotection.config.MethodValidationConfig: Invocation of init method failed; nested exception is javax.validation.ValidationException: HV000183: Unable to initialize 'javax.el.ExpressionFactory'. Check that you have the EL dependencies on the classpath, or use ParameterMessageInterpolator instead

      에러 원인은 Spring에서 메서드 유효성 검증 (Bean Validation)을 수행할 때, javax.el.ExpressionFactory가 필요하지만 클래스패스에 EL 라이브러리가 없어 초기화에 실패한 것이라고 하여 아래와 같이 javax.el 라이브러리를 추가해 해결하였습니다.

      <dependency>
          <groupId>org.glassfish</groupId>
          <artifactId>javax.el</artifactId>
          <version>3.0.0</version>
      </dependency>

       

      javax.el의 역할은 아래과 같다고 합니다.

      javax.el.ExpressionFactory는 유효성 검증 어노테이션 내에서 사용되는 표현식을 평가하는 기능을 제공합니다. 예를 들어, @Size(min = "${minSize}", max = "${maxSize}")와 같은 유효성 표현식이 있는 경우, 이 값을 동적으로 평가하려면 javax.el 라이브러리가 필요합니다. methodValidationPostProcessor는 이러한 표현식을 처리하고 유효성을 동적으로 검증할 수 있도록 설정합니다.

      회사 내부 라이브러리 Bean 생성 오류

      현재 재직 중인 회사에서는 내부에서 공용 라이브러리를 사용하도록 되어 있습니다. 이 Bean들을 생성하는데 아래와 같은 오류가 발생했습니다.

      더보기

      Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name '********': Unsatisfied dependency expressed through field '*******'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name '*******': Unsatisfied dependency expressed through field '*****'; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean found for dependency [javax.servlet.http.HttpServletRequest]: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}

      회사 내부 코드에서는 HttpServletRequest를 Autowired해서 사용하는 경우가 꽤 있는데 이 부분 때문에 오류가 발생한 것으로 보입니다.

       

      HttpServletRequest Bean을 자동 주입하려고 하는데 HTTP 요청을 처리할 수 있는 웹 컨텍스트가 없어서 발생하는 문제입니다. 테스트 환경에서 웹 컨텍스트를 명시적으로 설정하면 해결되는 문제라고 합니다. 

      @ExtendWith(SpringExtension.class)
      @WebAppConfiguration
      public class YourTestClass {
          // Test methods here
      }

       

      각 어노테이션의 의미는 아래와 같다고 합니다.

      1. @ExtendWith (SpringExtension.class)

      • 역할: Spring의 테스트 컨텍스트를 JUnit 5에서 사용할 수 있게 해주는 확장 클래스입니다.
      • 설명: 이 어노테이션을 통해 Spring 컨텍스트의 기본 기능을 테스트에 통합할 수 있습니다. @Autowired와 같은 의존성 주입, 트랜잭션 관리, @Transactional 테스트, 애플리케이션 컨텍스트 로딩 등을 지원합니다.
      • 사용법: @ExtendWith(SpringExtension.class)는 기본적으로 모든 Spring 통합 테스트에 포함되어야 하며, Spring의 다양한 테스트 어노테이션과 함께 사용됩니다.

      2. @WebAppConfiguration

      • 역할: Spring MVC 테스트 시 WebApplicationContext (웹 애플리케이션 컨텍스트)를 로딩하여 웹 관련 빈들을 사용할 수 있도록 설정합니다.
      • 설명: Spring MVC 애플리케이션의 웹 환경을 구성하는 WebApplicationContext를 로드하도록 Spring 테스트 컨텍스트에 지시합니다. 이를 통해 MockMvc와 같은 MVC 테스트 기능을 사용하거나 HttpServletRequest 같은 웹 관련 Bean을 사용할 수 있게 됩니다.
      • 사용법: @WebAppConfiguration은 일반적으로 웹 애플리케이션 관련 Bean이 필요한 통합 테스트에 추가하며, Spring MVC 컨트롤러와 서비스 계층 간의 상호 작용을 테스트하는 데 유용합니다.

      'Spring > Testing' 카테고리의 다른 글

      Spring Boot Repository Test 간단하게 실습  (0) 2023.10.17

      목차

        개요

        현재 근무하고 있는 회사에서 MyBatis를 통해 DB와 통신 할 때, 파라미터를 HashMap으로 넘기고 있습니다. 이 경우 VO를 써야한다와 HashMap을 써야 한다에 대해서는 꽤 오래된 논의인 것으로 보입니다. 여기서는 VO를 써야한다 HashMap을 써야한다에 대한 논의는 하지 않으려고 합니다.

        다만 현재 팀에서 HashMap을 파라미터로 사용하고 있을 때 겪는 문제가 있는데 이 부분을 HashMap을 쓰지 말자가 아닌 쓰더라도 오류가 있을 시 최대한 컴파일러 오류로 나타나게 하여 IDE에서 확인이 되어 배포 전에 확인 가능한 방법을 고민한 결과를 공유하고 혹시 더 좋은 아이디어가 있을지에 대해 정리하고자 합니다.

         

        현재 회사에서 진행되는 코드 방식

        현재 회사에서는 DBconnection을 만들어주는 클래스를 Mapper 클래스로 지정하고 있습니다. 

        Mapper 클래스에서는 회사에서 제공해주는 코드를 상속하여 DB와의 연결을 진행하는데 해당 클래스는 SqlSessionSupport를 상속받은 클래스 입니다.

        xml 파일을 통해 SqlSessionFactory 빈을 설정하여 DB Connection에 대한 설정을 해주고 그를 Mapper 클래스에 주입해서 사용하게 합니다.

        @Resource(name = "sqlSessionFactory")
        public void setSqlSessionFactory(SqlSessionFactory sqlSession) {
            super.setSqlSessionFactory(sqlSession);
        }

         

        이후에는 원하는 쿼리문을 작성한 xml 파일의 쿼리 아이디와 연결해주면 끝입니다.

        예를 들어 ID로 특정 상태값을 가진 회원의 정보를 조회하는 쿼리 아이디가 selectMemberByStatus 라면 아래와 같은 메서드를 만들면 되는 것인거죠.

        public HashMap<String, Object> selectMemeberByStatus(HashMap<String, Object> param) {
            return selectOne("mapper.service.memberMapper.selectMemeberByStatus", param);
        }

        정말 단순히 연결하는 것외에는 아무것도 안하는 메서드입니다.

         

        현재 코드 방식의 문제점

        위에서 안내드린 회사 코드 진행 방식으로 작업해오며 제가 겪은 문제는 크게 2가지 입니다.

        1. 조회 조건에 값이 추가되는 경우 해당 메서드를 사용하는 모든 곳을 하나하나 찾아가며 수정해주어야 한다.

        2. HashMap으로 만들어서 넘기는 과정에서 실수가 생기는 경우가 종종 있다.

         

        1. 조회 조건에 값이 추가되는 경우 해당 메서드를 사용하는 모든 곳을 하나하나 찾아가며 수정해주어야 한다.

        예를 들어 서비스 단에서 mapper 메서드를 사용하는 곳이 30곳이 된다고 해보죠. 이 상황에서 조회 조건이 하나, 두 가지 추가된다고 생각해봅니다. memeber를 조회 하는 상태값에 대한 정의가 추가되어 a, b가 추가되었다고 생각해보는거죠.

        그러면 selectMemberByStatus를 참조하는 모든 메서드를 하나하나 찾아서 아래와 같이 코드를 변경해주어야 합니다.

        new HashMap<String, Object> {{
        	put("기존Key", "기존Value");
            put("aKey","a");
            put("bKey","b");
        }};

        IDE에서 해당 메서드를 사용한 곳을 모두 알려주겠지만 간혹 작업하다가 실수로 누락되는 케이스가 발생할 수 있습니다.

         

        2. HashMap으로 만들어서 넘기는 과정에서 실수가 생기는 경우가 종종 있다.

        위 내용에서처럼 HashMap으로 만드는 과정 중에 실수가 생기는 경우가 종종 있습니다. 예를 들어 위와 같은 HashMap을 설정해서 mapper로 넘기는데 아래와 같이 설정을 했다고 해보죠.

        new HashMap<String, Object> {{
        	put("기존key", "기존Value");
            put("aKey","a");
            put("bKey","b");
        }};

        이 경우 실제 동작 시 오류가 발생할 수 있는데 왜 발생하는지는 에러 메시지를 받기 전까지는 알기가 쉽지 않습니다. k를 대문자로 써야하는데 소문자로 써야했다는걸 IDE에서 오류로 보여주지 않는데 눈으로 발견하기는 쉽지 않습니다.

        키 값을 enum으로 처리하여 받는 방법도 있겠지만 그 경우 만약 동일한 키값을 가진 서로 다른 Entity에 대해서는 어떻게 처리할지 걱정되기도 하고 그럴거면 그냥 VO를 쓰는게 낫지 않을까라는 생각을 하였습니다.

         

        위 상황에 대해 최대한 컴파일러 오류를 내기 위한 아이디어

        제가 생각한 방법은 Mapper 클래스에 값을 넘길 때, 완성된 HashMap으로 넘기는 방법이 아닌 각각의 파라미터로 넘긴 후 Mapper 클래스 메서드 내부에서 합쳐 전달하는 것 입니다. 아래의 예시처럼 말이죠!

        public HashMap<String, Object> selectMemeberByStatus(long a, String b) {
        	HashMap<String, Object> param = new HashMap<String, Object>() {{
            	put("aKey", a);
                put("bKey", b);
         	}};
            return selectOne("mapper.service.memberMapper.selectMemeberByStatus", param);
        }

        위와 같은 방식으로 진행하면 만약 파라미터를 추가해야되는 경우 아래와 같이 변경하게 될 것 입니다.

        public HashMap<String, Object> selectMemeberByStatus(long a, String b, Sting c, String d) {
        	HashMap<String, Object> param = new HashMap<String, Object>() {{
            	put("aKey", a);
                put("bKey", b);
                put("cKey", c);
                put("dKey", d);
         	}};
            return selectOne("mapper.service.memberMapper.selectMemeberByStatus", param);
        }

        이렇게 바뀌게 되면 기존에 selectMemberByStatus(a, b)로 사용하던 메서드들에 대해 IDE가 오류를 표시해줄 것이며 컴파일 시에도 에러가 발생할 것입니다. 그렇기에 에러가 발생하는 곳을 가서 고치면 되는 것이죠.

        JPA로 다중 DB 설정하기

        Dev-Taehee
        |2023. 10. 31. 16:02

        목차

          개요

          Spring 내에서 다중 DB를 사용하고 싶으면 다중 DB 설정을 진행해야한다고 합니다. 기존에 사용하듯이 application.yml과 같은 설정 파일에 하나의 DB만 설정하면 Spring Boot에서 자동 구성(Auto Configuration)을 통해 문제 없이 사용 할 수 있었지만 다중 DB 설정에는 자동 구성이 되지 않기 때문에 설정 파일 값을 읽어와 연동 할 DB 수 만큼 Datasource를 수동 설정해야한다고 합니다.

           

          application.yml 설정

          아래와 같이 application.yml을 설정하였습니다.

          second-datasource는 본인 원하는대로 이름을 붙이면 됩니다. (예를 들면, second.datasource)

          Datasource 설정에서 주입해줄 것이기 때문에 상관없습니다.

          spring:
            # primary datasource
            datasource:
              driver-class-name: org.postgresql.Driver
              url: jdbc:postgresql://localhost:5432/testdb
              username: sa
              password: 1234
            # second datasource
            second-datasource:
              driver-class-name: org.mariadb.jdbc.Driver
              url: jdbc:mariadb://localhost:3307/testdb
              username: sa
              password: 1234
            jpa:
              show-sql: true
              hibernate:
                ddl-auto: update
              properties:
                hibernate:
                  format_sql: true

           

          Datasource 설정

          application.yml 설정을 마치면 Datasource 설정을 진행합니다. 2개의 Datasource를 만드려고 합니다. 첫 번째 Datasource는 PrimaryDatasource, 두 번째 Datasource는 SecondDatasource라고 부르도록 하겠습니다. 설정과 관련된 코드를 살펴보겠습니다. (주석으로 간단한 설명을 첨부하겠습니다.)

           

          @Configuration
          @EnableTransactionManagement
          @EnableJpaRepositories(
                  basePackages = "com.multipleDB.repositoryConfig.primary", // 첫번째 Repository가 있는 패키지 경로
                  entityManagerFactoryRef = "primaryEntityManagerFactory", // EntityManager 이름
                  transactionManagerRef = "primaryTransactionManager" // 트랜잭션 매니저 이름
          )
          public class PrimaryDatasourceConfig {
          
              @Bean
              @Primary
              @ConfigurationProperties("spring.datasource") // application.yml에 작성된 첫 번째 DB 설정의 시작 부분
              public DataSourceProperties primaryDatasourceProperties() {
                  return new DataSourceProperties();
              }
          
              @Bean
              @Primary
              @ConfigurationProperties("spring.datasource.configuration")  // application.yml에 작성된 첫 번째 DB 설정의 시작 부분에 .configuration을 붙여준다.
              public DataSource primaryDatasource() {
                  return primaryDatasourceProperties()
                          .initializeDataSourceBuilder()
                          .type(HikariDataSource.class)
                          .build();
              }
          
              @Bean(name = "primaryEntityManagerFactory")
              @Primary
              public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(EntityManagerFactoryBuilder builder) {
                  DataSource dataSource = primaryDatasource();
                  return builder
                          .dataSource(dataSource)
                          .packages("com.multipleDB.member") // 스캔이 필요한 패키지 경로
                          .persistenceUnit("primaryEntityManager")
                          .build();
              }
          
              @Bean(name = "primaryTransactionManager")
              @Primary
              public PlatformTransactionManager primaryTransactionManager(final @Qualifier("primaryEntityManagerFactory") LocalContainerEntityManagerFactoryBean localContainerEntityManagerFactoryBean){
                  return new JpaTransactionManager(localContainerEntityManagerFactoryBean.getObject());
              }
              
          }

           

          위와 같이 4개의 Bean을 만들어주면 된다고 합니다.

          첫 번째 DB 설정에는 @Primary를 붙여야지만 그 이외의 DB 설정에서는 @Primary를 빼주어야 합니다. 맨 처음 코드에서 Class 이름은 'PrimaryDatasource'라고 짓는 실수를 했었습니다. 해당 실수의 결과로 아래의 에러 로그를 만났었습니다.

          위 사진에 나와있는 로그대로 overriding을 허가해주는 방법으로 해결하려 했지만 여전히 아래의 에러 로그로 문제가 생겼었습니다.

          확인해본 결과 Class 이름과 job 이름이 동일해서 발생한 오류라고하여 클래스 이름을 'PrimaryDatasourceConfig'로 변경하여 해결했습니다.

          (또한 overriding을 허가해주던 설정을 삭제하였습니다.)

           

          두 번째 Datasource도 동일한 방법으로 진행하면 됩니다.

          두 번째 Datasource에 대한 코드는 테스트 과정에서 발생한 이슈를 해결한 코드와 함께 보여드리도록 하겠습니다.

           

          테스트 진행

          이슈 발생

          설정을 완료한 후 무사히 진행되는지 확인하기 위해 간단한 Member 클래스와 관련 Controller, Service 클래스를 생성한 후, Postman을 통해 member가 잘 생성되는지 확인하려고 했습니다.

          테스트를 진행하기 위해 생성된 데이터베이스 중 MariaDB 쪽은 member 테이블을 생성하지 않았기에 ddl-auto 설정에 따라 member 테이블 생성하는 쿼리가 아래 사진과 같이 발생하여야 하는데 아무런 쿼리가 발생하지 않았고 확인해본 결과 member 테이블을 생성되지 않았음을 확인하였습니다.

           

          이슈의 원인

          이슈의 원인은 jpa, hibernate 설정을 datasource에 넣어주지 않아서였습니다. 아래와 같은 코드를 datasource 설정 클래스에 추가해주어야 했습니다.

          @RequiredArgsConstructor
          ...
          public class SecondDataSourceConfig {
              private final JpaProperties jpaProperties;
          
              private final HibernateProperties hibernateProperties;
          
              @Bean(name = "primaryEntityManagerFactory")
              @Primary
              public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(EntityManagerFactoryBuilder builder) {
          Map<String, Object> properties = hibernateProperties.determineHibernateProperties(jpaProperties.getProperties(), new HibernateSettings());
                  DataSource dataSource = primaryDatasource();
                  return builder
                          .dataSource(dataSource)
                          .packages("com.multipleDB.member") // 스캔이 필요한 패키지 경로
                          .persistenceUnit("primaryEntityManager")
                          .properties(properties)
                          .build();
              }
              ...
          }

           

          위와 같이 설정해주어야 application.yml에서 설정한 JPA, hibernate 설정 값들이 적용된다고 합니다. 또한 JPA의 Naming Strategy도 위와 같은 설정이 있어야 정상적으로 작동한다고 합니다.

          Naming Strategy란?

          원래 JPA의 기본 설정상으로는 변수명이 camelCase로 작성되어 있으면 DB의 테이블이나 필드 이름이 snake_case로 매칭되도록 합니다. camelCase를 SNAKE_CASE로 변경하는 등 변화를 줄 수 있는데 이러한 전략들을 JPA의 Naming Strategy라고 합니다. 조직 내부의 약속대로 설정하는 법을 알고 싶다면 아래의 블로그 글을 참조해주세요.

          https://velog.io/@mumuni/Hibernate5-Naming-Strategy-%EA%B0%84%EB%8B%A8-%EC%A0%95%EB%A6%AC

          https://velog.io/@devduhan/Spring%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-JPA-Naming-%EC%A0%84%EB%9E%B5

           

          이슈 해결 내용을 적용한 Datasource

          앞선 이슈를 해결한 Datasource 코드를 공유드리도록 하겠습니다.

          @Configuration
          @EnableTransactionManagement
          @EnableJpaRepositories(
                  basePackages = "com.multipleDB.repositoryConfig.second",
                  entityManagerFactoryRef = "secondEntityManagerFactory",
                  transactionManagerRef = "secondTransactionManager"
          )
          @RequiredArgsConstructor
          public class SecondDatasourceConfig {
          
              // jpa, hibernate property 값 주입하기 위해
              private final JpaProperties jpaProperties;
              private final HibernateProperties hibernateProperties;
          
              @Bean
              @ConfigurationProperties("spring.second-datasource")
              public DataSourceProperties secondDatasourceProperties() {
                  return new DataSourceProperties();
              }
          
              @Bean
              @ConfigurationProperties("spring.second-datasource.configuration")
              public DataSource secondDatasource() {
                  return secondDatasourceProperties()
                          .initializeDataSourceBuilder()
                          .type(HikariDataSource.class)
                          .build();
              }
          
              @Bean(name = "secondEntityManagerFactory")
              public LocalContainerEntityManagerFactoryBean secondEntityManagerFactory(EntityManagerFactoryBuilder builder) {
                  DataSource dataSource = secondDatasource();
                  Map<String, Object> properties = hibernateProperties.determineHibernateProperties(jpaProperties.getProperties(), new HibernateSettings());
                  return builder
                          .dataSource(dataSource)
                          .packages("com.multipleDB.member")
                          .persistenceUnit("secondEntityManager")
                          .properties(properties)
                          .build();
              }
          
              @Bean(name = "secondTransactionManager")
              public PlatformTransactionManager secondTransactionManager(final @Qualifier("secondEntityManagerFactory") LocalContainerEntityManagerFactoryBean localContainerEntityManagerFactoryBean) {
                  return new JpaTransactionManager(localContainerEntityManagerFactoryBean.getObject());
              }
          
          }

           

          위와 같이 설정을 완료한 뒤 다시 코드를 실행하니 테이블 생성 쿼리문도 무사히 출력되었고 확인해본 결과 member 테이블이 잘 생성되었음을 확인할 수 있었습니다.

           

          또한 Postman으로 요청을 보낸 결과 무사히 데이터가 들어갔음을 확인 할 수 있었습니다.

           

           

          Reference

          https://velog.io/@juhyeon1114/JPAQuerydsl-Multiple-Databases%EB%8B%A4%EC%A4%91-DB-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0

          https://velog.io/@lehdqlsl/SpringBoot-JPA-Multiple-Databases-%EC%84%A4%EC%A0%95

          https://oingdaddy.tistory.com/178

          https://wave1994.tistory.com/181

          목차

            JPA와 PostgreSQL 연동

            라이브러리 설치

            MySQL을 연동할 때와 같이 JPA 라이브러리 설치와 PostgreSQL 라이브러리 설치를 진행합니다.

            build.gradle에 아래의 코드를 추가합니다.

            implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
            implementation 'org.postgresql:postgresql:42.6.0'

             

            application.yml 설정

            MySQL을 연동할 때와 같이 application.yml에 DB 접속 정보 및 JPA 설정을 입력해야 합니다.

            spring:
              datasource:
                url: jdbc:postgresql://localhost:5432/<DB명>
                username: <PostgreSQL 계정명>
                password: <비밀번호>
                driver-class-name: org.postgresql.Driver
              jpa:
                show-sql: true
                database: postgresql
                hibernate:
                  ddl-auto: update

             

            위 두 설정을 마치면 MySQL과 같은 방식으로 사용하면 된다고 합니다.

            한 가지 주의 할 점은 PostgreSQL에서 Table명으로 "user"를 사용하면 에러가 발생한다고 합니다. 

             

            실습

            앞서 알아본 내용을 적용하여 간단한 테이블을 생성하고 연동해보려고합니다.

             

            프로젝트 생성

            start.spring.io에 방문하여 다음과 같이 프로젝트를 생성하도록 하였습니다.

            Dependencies에서 사용하고자하는 라이브러리들을 미리 선택할 수 있는데 PostgreSQL Driver도 있기에 추가하였습니다.

             

            데이터베이스 생성

            Spring에 연결해줄 데이터베이스를 생성합니다.

             

            PostgreSQL의 SQL Shell을 실행해줍니다.

             

            접속하면 Server부터 Username까지 엔터로 넘겨줍니다.

            암호는 PostgreSQL 설치 때 생성했던 root 계정의 암호를입력해주면 됩니다.

             

            DB 계정 생성 및 권한 부여하기를 진행합니다.

            //계정 생성
            CREATE ROLE [USER] WITH LOGIN PASSWORD '[PASSWORD]';
            
            //CREATEDB 권한 부여
            ALTER USER [USER] WITH CREATEDB;
            
            //SUPERUSER 권한 부여
            ALTER USER [USER] WITH SUPERUSER;
            
            //CREATEROLE 권한 부여
            ALTER USER [USER] WITH CREATEROLE;

             

            저는 sa 라는 이름으로 계정을 생성했습니다. 비밀번호는 1234로 설정하였습니다.

            DB를 생성할 수 있는 권한 또한 부여했습니다.

             

            \du 명령어를 통해 계정과 역할이 잘 생성되었는 확인합니다.

             

            새로운 데이터 베이스를 생성합니다.

            소유자는 앞서 만든 sa로 데이터 베이스 이름은 TESTDB로 하겠습니다.

             

            \l (소문자 L 입니다)을  통해 데이터베이스 생성을 확인합니다.

             

            아래와 같은 명령어를 통해 sa 유저 이름으로 TESTDB에 접속할 수 있습니다.

            (\c [DB Name] [Connection User])

             

            application.yml 설정

            아래와 같이 application.yml 설정을 진행합니다.

            spring:
              datasource:
                url: jdbc:postgresql://localhost:5432/testdb
                username: sa
                password: 1234
                driver-class-name: org.postgresql.Driver
              jpa:
                show-sql: true
                database: postgresql
                hibernate:
                  ddl-auto: update

             

            Member 생성해보기

            간단하게 Member 객체를 생성하는 코드를 만들고 실행합니다.

            DB 내에서 테이블을 생성한 적이 없기 때문에 테이블이 생성되는 SQL 문이 실행되었음을 확인하실 수 있습니다.

             

            \d 명령어를 통해 확인해보시면 기존에는 테이블이 없기 때문에 '관련 릴레이션 찾을 수 없음.'이었다가 테이블 생성이 완료되었기 때문 테이블 목록을 확인할 수 있습니다.

            테이블이 없었을 때
            테이블 생성 후

             

             

            아래와 같이 PostMan을 이용하여 Post 요청을 보내보았습니다.

             

            다음과 같이 잘 생성되었음을 확인할 수 있습니다.

             

             

             

            Reference

            https://ssjeong.tistory.com/entry/Spring-Spring-boot%EC%97%90%EC%84%9C-PostgreSQL-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0

            https://chb2005.tistory.com/193

            https://bluelicht.tistory.com/55

            목차

              개요

              Spring Documentation에서 WebSocket API를 공부한 뒤 다음 단계는 SockJS 였습니다. SockJS를 간단히 공부해보았는데요. 공부해봤을 때, 'SockJS가 필요없는건가?'라는 생각이 들어 블로그 글을 정리해봤습니다. 만약 SockJS에 대해 자세히 알아보고 싶으신 분은 아래의 블로그를 참조해주시길 부탁드립니다.

               

              [Spring Boot] WebSocket과 채팅 (2) - SockJS

              [Spring Boot] WebSocket과 채팅 (1) 일전에 WebSocket(웹소켓)과 SockJS를 사용해 Spring 프레임워크 환경에서 간단한 하나의 채팅방을 구현해본 적이 있다. [Spring MVC] Web Socket(웹 소켓)과 Chatting(채팅) 기존 공

              dev-gorany.tistory.com

               

              SockJS란?

              SockJS는 애플리케이션이 WebSocket API를 사용할 때, 만약 브라우저가 WebSocket을 받아들이지 못하는 상태라면 애플리케이션 코드 변경 없이 런타임에서 대안을 실행하기 위한 것입니다.

               

              https://github.com/sockjs/sockjs-client/

              위 그림에서 처럼 각 브라우저의 버전에 따라 WebSocket을 지원하지 않는 경우가 있습니다. 또한 Server, Client 중간에 위치한 Proxy가 Upgrade Header를 해석하지 못해 서버에 전달하지 못할 수 있습니다. 마지막으로 Server, Client 중간에 위치한 Proxy가 유휴 상태에서 도중에 Connection을 종료시킬 수도 있습니다.

               

              이런 상황에 사용하는 것이 WebSocket Emulation입니다. WebSocket Emulation은 우선 WebSocketㅇ르 시도하고 실패할 경우 HTTP Streamin, Long Polling과 같은 HTTP 기반의 다른 기술로 전환해 다시 연결을 시도하는 것을 뜻합니다.

               

              Spring을 사용하기에 SockJS를 사용합니다.

              (Node.js는 Socket.io라는 것을 사용한다고 합니다. SockJS-node를 사용해도 될 것 같네요.)

               

              필요없다고 생각하는 이유

              브라우저 버전의 고도화

              공부하다보니 SockJS를 사용해야하는가에 대한 의문이 들었습니다. SockJS를 사용하는 대표적인 이유가 WebSocket을 지원하지 않는 브라우저를 사용하는 경우이기 때문인데요. 대표적으로 IE 버전 10미만의 인터넷 익스플로러가 지원이 되지 않습니다. 하지만 마이크로소프트 공식 홈페이지에는 인터넷 익스플로러에 대한 지원이 2022년 6월 15일에 종료되었다고 하더군요. 또한 Firefox 인터넷도 자동 업데이트를 하도록 설정되어있다고 하기에 웬만한 브라우저는 다 지원되지 않을까라는 생각을 했습니다.

              https://support.microsoft.com/ko-kr/windows/internet-explorer-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C%ED%95%98%EA%B8%B0-d49e1f0d-571c-9a7b-d97e-be248806ca70#ID0EBBD=Windows_10

              프록시 서버의 웹소켓 지원

              두 번째로 SockJS를 사용해야하는 이유는 프록시 상황에서 웹소켓을 사용하기 어렵다는 이야기를 들었기 때문입니다. 하지만 찾아보니 Nginx의 경우에는 버전 1.3부터 WebSocket을 지원하며 WebSocket의 로드밸런싱을 수행할 수 있다고 합니다. 

               

              결론

              SockJS에 대해 간단히 알아보고 필요없다고 생각한 이유에 대해서도 알아봤습니다. 혹시 최근에도 SockJS를 이용하여 작업하시는 분이 계시다면 어느 상황에서 사용되는지 알려주시면 대단히 감사드리겠습니다.

               

               

              reference

              https://docs.spring.io/spring-framework/reference/web/websocket/fallback.html

              https://github.com/sockjs/sockjs-client/

              https://dev-gorany.tistory.com/224

              https://hyeon9mak.github.io/nginx-web-socket-proxy-configuration/

               

              목차

                개요

                Spring Boot를 사용하여 프로젝트 구현 때, DB와 관련된 테스트를 진행하기 위한 방법을 연습하고자 했습니다. 분명 테스트 진행 후 롤백시켜서 DB에 영향을 안끼치는 무언가가 있었다는 점을 알고 있었지만 기억이 나지 않아 다시 공부하고 또 찾을 때를 대비해 기록을 남겨 놓고자 합니다.

                 

                @DataJPATest

                분명 테스트 진행 후 롤백시켜서 DB에 영향을 안끼치는 무언가를 잊어먹었던 그 무언가는 @DataJPATest입니다. @DataJPATest는 우선 기본적으로 메모리 DB를 사용하도록 되어 있고 @Transactional이 걸려있기에 테스트 이후 롤백되어 DB에 영향을 안미치도록 되어있습니다.

                또한 만약 실제 DB에서의 테스트를 원한다면 설정을 통해 실제 DB 환경에서의 테스트도 가능하다고 합니다.

                 

                실습

                실습은 간단하게 Member 클래스를 생성한 후 Member 클래스를 생성하고 조회하는 과정을 테스트해보는 것으로 진행했습니다.

                 

                우선 Member 클래스를 생성해 Entity를 만들었습니다.

                @Entity
                @Getter
                @NoArgsConstructor
                public class Member {
                
                    @Id
                    @GeneratedValue(strategy = GenerationType.IDENTITY)
                    private Long id;
                    @Column(nullable = false)
                    private String name;
                    @Column(nullable = false)
                    private String email;
                    @Column(nullable = false)
                    private String password;
                
                    @Builder
                    public Member(String name, String email, String password) {
                        this.name = name;
                        this.email = email;
                        this.password = password;
                    }
                
                }

                 

                이후 JpaRepository를 상속한 MemberRepository를 생성하여 CRUD가 가능하도록 만들었습니다.

                public interface MemberRepository extends JpaRepository<Member, Long> {
                }

                 

                마지막으로 @DataJpaTest를 이용한 테스트를 생성한 후 저장 테스트와 조회 테스트를 진행했습니다.

                @DataJpaTest
                class MemberRepositoryTest {
                
                    @Autowired
                    private MemberRepository memberRepository;
                
                    @Test
                    @DisplayName("멤버 DB 저장 테스트")
                    void saveMember() {
                        Member member = Member.builder()
                                .email("taehee@tistory.com")
                                .name("taehee")
                                .password("1234")
                                .build();
                
                        Member savedMember = memberRepository.save(member);
                
                        assertThat(member).isSameAs(savedMember);
                    }
                
                    @Test
                    @DisplayName("멤버 DB 조회 테스트")
                    void findMember() {
                        Member member = Member.builder()
                                .email("taehee@tistory.com")
                                .name("taehee")
                                .password("1234")
                                .build();
                
                        Member savedMember = memberRepository.save(member);
                
                        Member findMember = memberRepository.findById(member.getId()).orElseThrow(() -> new IllegalArgumentException("Member Not Found, memberId: " + savedMember.getId()));
                
                        assertThat(findMember).isSameAs(savedMember);
                    }
                
                }

                 

                마치며

                정말 간단한 테스트였고 @DataJpaTest를 활용해본다 정도였습니다. 실제 서비스 구현할 때 발생하는 Join이라든가 복잡한 Query로 생성된 결과물을 테스트하는 방법을 실험해봐야 조금 더 감이 잡힐 것 같습니다. 또한 찾아보니 Embedded MongoDB Database라는 것도 있어서 MongoDB를 사용하는 유저에게 유닛 테스트를 위한 인메모리 MongoDB를 지원하는 것으로 보였습니다.

                그리고 Spring Testing Document를 보니 굉장히 뭐가 많던데 하나씩 공부를 해봐야 할 것 같습니다.

                 

                 

                Reference

                https://dublin-java.tistory.com/49

                https://jiminidaddy.github.io/dev/2021/05/20/dev-spring-%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-Repository/

                https://cobbybb.tistory.com/23

                https://0soo.tistory.com/40#Entity%-C%--Repository

                https://docs.spring.io/spring-boot/docs/1.4.2.RELEASE/reference/html/boot-features-testing.html#boot-features-testing-spring-boot-applications-testing-autoconfigured-jpa-test

                https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTest.html

                 

                목차

                  개요

                  김영한님의 JPA 책 중에 코드에서의 객체와 데이터베이스 사이의 패러다임 불균형을 JPA가 해결해준다라는 내용이 있습니다. 그에 관한 예시 중 상속에 관한 이야기가 있었습니다. '상속은 객체의 대표적인 속성인데 데이터베이스로 표현하려면 불편해진다. 이러한것들을 JPA가 해결해준다.' 라는 내용이었습니다. 이 때 들어준 예시 코드가 JPA를 이용하여 직접 등록해주는 방식 등 정석적인 방법처럼 보였는데 현재 저는 데이터베이스에 저장할 객체는 @Entity를 사용해주고 JpaRepository를 상속시켜 DB와 연동시키는 방법을 사용합니다. 이러한 과정에서 현재 제가 사용하는 방법에서도 상속을 이용한 뒤 DB에 저장시키는 방법론이 동일하게 적용되는지 궁금하여 실험을 해보았습니다.

                  (빠른 실험을 위해 만든 코드이기에 DTO 클래스도 없고 db 생성 시 데이터 정확한지 확인하는 과정도 없습니다.)

                   

                  실험 시작

                  우선 책의 예시대로 부모 클래스를 만들어준 뒤 자식 클래스를 만들었습니다.

                  @Entity
                  @Getter
                  abstract class Item {
                      @Id
                      @GeneratedValue(strategy = GenerationType.IDENTITY)
                      Long id;
                      String name;
                      int price;
                  }
                  @Entity
                  @NoArgsConstructor
                  @Getter
                  public class Album extends Item{
                      String artist;
                  }
                  @Entity
                  public class Movie extends Item{
                      String director;
                      String actor;
                  }

                   

                  Album 클래스만 통해서 테스트 해볼 것이기 때문에 Album과 관련하여 Repository, Service, Controller를 생성했습니다.

                  public interface AlbumRepository extends JpaRepository<Album, Long> {
                  }
                  @Service
                  @RequiredArgsConstructor
                  public class AlbumService {
                  
                      private final AlbumRepository albumRepository;
                  
                      public Album createAlbum(Album album) {
                          return albumRepository.save(album);
                      }
                  
                  }
                  @RestController
                  @RequiredArgsConstructor
                  public class AlbumController {
                  
                      private final AlbumService albumService;
                  
                      @PostMapping("/album")
                      public ResponseEntity postAlbum(@RequestBody Album album) {
                          albumService.createAlbum(album);
                          return new ResponseEntity(HttpStatus.CREATED);
                      }
                  
                  }

                   

                  위 과정들을 진행한 후, 코드를 실행하여 DB를 확인해보았습니다.

                   

                  문제 발생

                  확인해본 DB 테이블의 결과는 신기했습니다.

                  '오 진짜로 영한님 책에서 나온 것처럼 DTYPE이 생기네.'

                  하지만 문제가 발생했음을 알 수 있었습니다.

                  '어? 책에서는 ITEM 테이블 따로 ALBUM 테이블 따로 생성되고 필요 데이터 조회할 때 Join 된다고 쓰여져있었는데? 왜 ITEM 테이블만 생성되고 한 테이블 안에 모든 칼럼이 들어가있지? 우선 값을 넣어보자'

                   

                  '이렇게 되면 상황에 따라 null이 많아질텐데 정규화에 문제 생기지 않나?'

                  그래서 문제의 원인을 찾아보았습니다.

                   

                  문제 해결

                  문제 해결법은 @Inheritance라는 애너테이션의 존재였습니다.

                  @Inheritance를 부모 클래스에 달아주고 상속 전략을 Joined로 선택해주면 모든게 해결되는거였습니다.

                  @Entity
                  @Getter
                  @Inheritance(strategy = InheritanceType.JOINED)
                  abstract class Item {
                      @Id
                      @GeneratedValue(strategy = GenerationType.IDENTITY)
                      Long id;
                      String name;
                      int price;
                  }

                  이렇게 하면 각 Entity가 각 테이블로 잘 쪼개어진 것을 확인하실 수 있고요.

                  앞선 예시와 동일한 예시를 넣어주면 아래와 같이 깔끔하게 들어가는 것을 확인하실 수 있습니다.

                   

                  문제는 문제가 아니었음을

                  앞선 설명들을 보시면 한 테이블에 모든게 나오는 것이 문제고 깔끔하게 나오는게 정답처럼 써놓았습니다.

                  하지만 해당 해결 과정을 위해 참고한 블로그를 공부하다보니 각각의 장단점과 함께 전략을 활용하는 방법이었음을 알게 되었습니다.

                  상속 전략을 정하는 방식과 각 장단점 그리고 DTYPE 이름도 정할 수 있음을 공부하고 싶으신 분들은 아래의 Reference 블로그 주소를 참조해주시면 좋을 것 같습니다.

                   

                   

                  reference

                  https://jaime-note.tistory.com/381

                  목차

                    코드는 아래의 깃허브 주소에서 WebSocketAPIToy 폴더 속에 있습니다.

                    https://github.com/Dev-Taehee/WebSocketToy

                    설정

                    https://start.spring.io/

                    저는 Spring Initializr를 활용하여 위와 같이 설정한 후 시작했습니다.

                     

                    개요

                    WebSocket API의 가장 중요한 두 가지는 WebSocketHandler와 WebSocketConfigurer 입니다.

                    public class MyHandler extends TextWebSocketHandler {
                    
                    	@Override
                    	public void handleTextMessage(WebSocketSession session, TextMessage message) {
                    		// ...
                    	}
                    
                    }

                    WebSocketHandler는 말그대로 WebSocket을 다루는 친구입니다. WebSocket을 통해 오는 message들을 처리할 수도 있고 WebSocket이 연결된 후의 행동이나 WebSocket이 종료된 후의 행동을 관리할 수 있습니다.

                    @Configuration
                    @EnableWebSocket
                    public class WebSocketConfig implements WebSocketConfigurer {
                    
                    	@Override
                    	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
                    		registry.addHandler(myHandler(), "/myHandler");
                    	}
                    
                    	@Bean
                    	public WebSocketHandler myHandler() {
                    		return new MyHandler();
                    	}
                    
                    }

                    WebSocketConfigurer는 WebSocket과 관련된 설정들을 관리할 수 있게 해줍니다. WebSocketHandler를 등록해주는 것과 등록된 WebSocketHandler에 접속할 수 있는 URL을 구체적으로 정해주는 등의 작업을 할 수 있습니다.

                     

                    만약 기본 설정인 Tomcat이 아닌 Jetty를 사용해주는 경우에는 아래와 같이 WebSocketServerFactory를 이용하여 사전 설정을 진행해주어야 한다고 합니다.

                    @Configuration
                    @EnableWebSocket
                    public class WebSocketConfig implements WebSocketConfigurer {
                    
                    	@Override
                    	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
                    		registry.addHandler(echoWebSocketHandler(),
                    			"/echo").setHandshakeHandler(handshakeHandler());
                    	}
                    
                    	@Bean
                    	public DefaultHandshakeHandler handshakeHandler() {
                    
                    		WebSocketPolicy policy = new WebSocketPolicy(WebSocketBehavior.SERVER);
                    		policy.setInputBufferSize(8192);
                    		policy.setIdleTimeout(600000);
                    
                    		return new DefaultHandshakeHandler(
                    				new JettyRequestUpgradeStrategy(new WebSocketServerFactory(policy)));
                    	}
                    
                    }

                    단순히 Handler와 Configurer 두 가지를 상속받은 후 구현하면 WebSocket 연결이 완료된다고 하니 굉장히 편리한 것 같습니다. 저는 두 클래스를 이용하여 채팅창을 구현해보는 실습을 통해 이해도를 높여보려고합니다.

                     

                    실습

                    websocket 설정

                    websocket 설정을 위하여 앞서 말씀드린 Handler와 Configurer 2가지를 생성합니다.

                    우선 Handler입니다.

                    @Log4j2
                    public class MyHandler extends TextWebSocketHandler {
                    
                        private static List<WebSocketSession> list = new ArrayList<>();
                    
                        @Override
                        protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
                            log.info("HandleTextMessage 진입");
                            log.info("session: " + session);
                            log.info("message: " + message);
                            for(WebSocketSession webSocketSession : list) {
                                webSocketSession.sendMessage(message);
                            }
                        }
                    
                        @Override
                        public void afterConnectionEstablished(WebSocketSession session) throws Exception {
                            log.info(session + "클라이언트 접속");
                            list.add(session);
                        }
                    }

                    afterConnectionEstablished 메서드는 클라이언트가 접속하면 실행되는 메서드입니다.

                    이를 통해 클라이언트가 채팅창에 들어오면 해당 클라이언트의 WebSocketSession을 등록해주는 작업을 진행하도록 하였습니다.

                    handleTextMessage는 클라이언트가 텍스트 메시지를 보내면 해당 메시지를 채팅창에 참여한 사람들에게 보내주는 역할을 합니다.

                     

                    다음은 Configurer입니다.

                    @Configuration
                    @EnableWebSocket
                    public class WebSocketConfig implements WebSocketConfigurer {
                        @Override
                        public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
                            registry.addHandler(myHandler(), "ws/myHandler")
                                    .setAllowedOrigins("*");
                        }
                    
                        @Bean
                        public WebSocketHandler myHandler() {
                            return new MyHandler();
                        }
                    
                    }

                    Configurer는 앞선 예제 코드와 크게 변한 것이 없습니다.

                    우선 Handler에 접속할 수 있는 url을 "ws/myHandler"로 사용했습니다.

                    웹소켓을 이용하면 통신이 http 통신에서 Upgrade 헤더를 사용해 ws 통신으로 바뀌는 것을 앞선 시간에 확인할 수 있었습니다. 그런 이유로 "ws/myHandler"로 url을 정해봤습니다.

                    setAllowedOrigins를 모두 허락해두었습니다.

                     

                    채팅창 접속 가능한 ChatController 생성

                    채팅창에 접속 가능하도록 ChatController를 생성했습니다.

                    @Controller
                    public class ChatController {
                    
                        @GetMapping("/myHandler")
                        public String getChat() {
                            return "chat";
                        }
                    
                    }

                    endpoint를 "/myHandler"로 설정하고 접속시 chat.html을 열도록 해두었습니다.

                     

                    프론트엔드 구성

                    프론트엔드 구성은 chatGPT로 작성한 후 제가 수정하는 방식으로 작업했습니다.

                    우선 chat.html 입니다.

                    <!DOCTYPE html>
                    <html lang="en">
                    
                    <head>
                        <meta charset="UTF-8">
                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                        <title>채팅 창</title>
                        <link rel="stylesheet" th:href="@{/css/styles.css}">
                    </head>
                    
                    <body>
                        <div class="input-container">
                            <label for="username">유저 이름: </label>
                            <input type="text" id="username" class="username-input" placeholder="유저 이름을 입력하세요...">
                            <button onclick="saveUsername()" class="save-button">저장</button>
                        </div>
                        <div class="chat-box">
                            <div class="chat-container" id="chat-container">
                                <!-- 메시지가 여기에 나타남 -->
                            </div>
                    
                            <div class="input-container">
                                <input type="text" id="message-input" class="message-input" placeholder="메시지를 입력하세요...">
                                <button onclick="sendMessage()" class="send-button">전송</button>
                            </div>
                        </div>
                    
                        <script th:src="@{/js/script.js}"></script>
                    </body>
                    
                    </html>

                    다음은 styles.css 입니다.

                    body {
                        font-family: Arial, sans-serif;
                        margin: 0;
                        padding: 0;
                        background-color: #f0f0f0;
                    }
                    
                    .chat-box {
                        display: flex;
                        flex-direction: column;
                        align-items: center;
                        justify-content: center;
                        height: 100vh;
                    }
                    
                    .chat-container {
                        width: 80%;
                        max-width: 400px;
                        margin: 0 auto;
                        background-color: #fff;
                        border-radius: 10px;
                        box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
                        overflow-y: auto;
                        height: 400px;
                        padding: 20px;
                    }
                    
                    .message {
                        margin-bottom: 10px;
                        padding: 10px;
                        border-radius: 10px;
                        max-width: 70%;
                    }
                    
                    .sender-message {
                        background-color: #f3d6c6;
                        align-self: flex-end;
                    }
                    
                    .user-message {
                        background-color: #ccc;
                    }
                    
                    .input-container {
                        display: flex;
                        margin-top: 20px;
                    }
                    
                    .message-input {
                        flex: 1;
                        max-width: 400px;
                        padding: 10px;
                        border-radius: 5px;
                        border: 1px solid #ccc;
                    }
                    
                    .send-button {
                        margin-left: 10px;
                        padding: 10px 20px;
                        border: none;
                        border-radius: 5px;
                        background-color: #4caf50;
                        color: white;
                        cursor: pointer;
                    }

                     

                    위의 HTML과 CSS를 합쳐 아래의 화면이 나오도록 구성하였습니다.

                     

                    다음은 script.js 입니다.

                    // WebSocket 서버 주소
                    const websocketUrl = 'ws://localhost:8080/ws/myHandler';
                    
                    // WebSocket 연결
                    const socket = new WebSocket(websocketUrl);
                    
                    // 연결이 열렸을 때 실행되는 이벤트 핸들러
                    socket.addEventListener('open', (event) => {
                        console.log('WebSocket 연결이 열렸습니다.');
                    });
                    
                    // 메시지를 받았을 때 실행되는 이벤트 핸들러
                    socket.addEventListener('message', (event) => {
                        // 서버로부터 받은 메시지를 화면에 표시
                        const receivedMessage = JSON.parse(event.data);
                        displayMessage(receivedMessage.sender, receivedMessage.content, false);
                    });
                    
                    // 연결이 닫혔을 때 실행되는 이벤트 핸들러
                    socket.addEventListener('close', (event) => {
                        console.log('WebSocket 연결이 닫혔습니다.');
                    });
                    
                    function sendMessage() {
                        var messageInput = document.getElementById('message-input');
                        var message = messageInput.value.trim();
                        if (message === '') return;
                    
                        var username = localStorage.getItem('username');
                    
                        const chatMessage = {
                            sender: username,
                            content: message
                        }
                    
                        // 메시지를 WebSocket을 통해 서버로 전송
                        socket.send(JSON.stringify(chatMessage));
                    
                        // // 입력한 메시지를 화면에 표시 (옵션)
                        // displayMessage(username, message, true);
                    
                        // 입력 칸 비우기
                        messageInput.value = '';
                    }
                    
                    // 화면에 메시지를 표시하는 함수 (옵션)
                    function displayMessage(sender, message, isSender) {
                        var chatContainer = document.getElementById('chat-container');
                        var messageElement = document.createElement('div');
                        messageElement.textContent = sender + ': ' + message;
                        messageElement.classList.add('message');
                    
                        var username = localStorage.getItem('username');
                    
                        if (username === sender) {
                            messageElement.classList.add('sender-message');
                        } else {
                            messageElement.classList.add('user-message');
                        }
                    
                        chatContainer.appendChild(messageElement);
                    
                        // 최하단으로 스크롤
                        chatContainer.scrollTop = chatContainer.scrollHeight;
                    }
                    
                        // 로컬 스토리지에서 유저 이름을 불러오고 입력 필드에 채우는 함수
                    function loadUsername() {
                        var savedUsername = localStorage.getItem('username');
                        if (savedUsername) {
                            document.getElementById('username').value = savedUsername;
                        }
                    }
                    
                        // 유저 이름을 로컬 스토리지에 저장하는 함수
                    function saveUsername() {
                        var usernameInput = document.getElementById('username');
                        var username = usernameInput.value.trim();
                        if (username === '') return;
                    
                        localStorage.setItem('username', username);
                        alert('유저 이름이 저장되었습니다: ' + username);
                    }
                    
                    // 페이지 로드 시 유저 이름을 불러옴
                    loadUsername();
                    
                    // 아래에 메시지 전송 함수 및 기타 함수들을 추가하면 됩니다.

                     

                    js 파일에서 볼 수 있듯이 앞서 WebSocket Configurer에서 설정한 주소인 ws/Handler로 연결을 요청하도록 되어 있습니다. 또한 웹소켓 연결이므로 http://가 아닌 ws:// 로 시작된다는 점을 확인하실 수 있습니다.

                     

                    앞선 작업들을 통해 다음과 같이 채팅창 기능이 구현되었습니다.

                    목차

                      개요

                      웹소켓 공부를 시작하며 본 Spring Documentation에는 다음과 같은 구문이 있었습니다.

                      '그러나 AJAX, HTTP Streaming 또는 Long Polling을 결합하면 간단하고 효과적인 제공할 수 있습니다. 예를 들어 뉴스, 메일, 소셜 피드는 동적으로 업데이트되어야 하지만 몇 분마다 업데이트해도 괜찮습니다. 또한 메시지의 양이 상대적으로 적은 경우에도 포함됩니다.'

                      웹소켓 공부를 시작하기 전에 대체재로 사용 가능한 AJAX, HTTP Streaming, Long Polling에 대해 간단히 파악하고 싶어 살짝 공부해보고 넘어가보려고합니다.

                       

                      AJAX란?

                      AJAX란 Asynchronous JavaScript and XML의 약자입니다.

                      AJAX는 빠르게 동작하는 동적인 웹 페이지를 만들기 위한 개발 기법의 하나입니다.

                       

                      AJAX는 웹 페이지 전체를 다시 로딩하지 않고도, 웹페이지의 일부분만을 갱신할 수 있습니다.

                      즉, AJAX를 이용하면 백그로운드 영역에서 서버와 통신하여, 그 결과를 웹 페이지의 일부분에만 표시할 수 있습니다.

                       

                      이때 서버와는 다음과 같은 다양한 형태의 데이터를 주고받을 수 있습니다.

                      • JSON
                      • XML
                      • HTML
                      • 텍스트 파일 등

                       

                      AJAX의 장점

                      AJAX를 이용하면 다음과 같은 장점이 있습니다.

                      1. 웹 페이지 전체를 다시 로딩하지 않고도, 웹페이지의 일부분만을 갱신할 수 있습니다.
                      2. 웹 페이지가 로드된 후에 서버로 데이터 요청을 보낼 수 있습니다.
                      3. 웹 페이지가 로드된 후에 서버로부터 데이터를 받을 수 있습니다.
                      4. 백그라운드 영역에서 서버로 데이터를 보낼 수 있습니다.

                       

                      AJAX의 한계

                      AJAX를 이용하면 여러 장점을 가지지만, AJAX로도 다음과 같은 일들을 처리할 수 없습니다.

                      1. AJAX는 클라이언트가 서버에 데이터를 요청하는 클라이언트 풀링 방식을 사용하므로, 서버 푸시 방식의 실시간 서비스는 만들 수 없습니다.
                      2. AJAX로는 바이너리 데이터를 보내거나 받을 수 없습니다.
                      3. AJAX 스크립트가 포함된 서버가 아닌 다른 서버로 AJAX 요청을 보낼 수는 없습니다.
                      4. 클라이언트의 PC로 AJAX 요청을 보낼 수는 없습니다.

                      클라이언트 풀링(client pooling) 방식이란?

                      사용자가 직접 원하는 정보를 서버에게 요청하여 얻는 방식을 의미합니다.

                      이에 반해 서버 푸시(server push) 방식이란 사용자가 요청하지 않아도 서버가 알아서 자동으로 특정 정보를 제공하는 것을 의미합니다.

                      스마트폰에서 각종 앱이 보내는 푸시 알림이 서버 후시 방식의 대표적인 예입니다.

                       

                      AJAX의 작동 원리 등 다양한 활용 방법에 대해 자세히 공부하고 싶으시면 아래의 글을 참고해주세요.

                       

                      코딩교육 티씨피스쿨

                      4차산업혁명, 코딩교육, 소프트웨어교육, 코딩기초, SW코딩, 기초코딩부터 자바 파이썬 등

                      tcpschool.com

                       

                      HTTP Streaming이란?

                      HTTP Streaming은 HTTP 프로토콜을 사용하여 데이터를 조각조각으로 전송하는 방식으로 실시간 미디어 스트리밍, 실시간 웹 애플리케이션 및 다른 실시간 데이터 전송 시나리오에서 사용됩니다. 이것은 브라우저나 앱 클라이언트가 데이터를 조금씩 수신하고 화면에 표시하거나 처리할 수 있도록하는 기술입니다.

                       

                      HTTP Streaming의 장점

                      HTTP Streaming을 사용하면 다음과 같은 장점이 있습니다.

                      1. 미디어 스트리밍을 통해 사용자가 콘텐츠를 실시간으로 시청하거나 듣는 동안 데이터의 일부만 다운로드할 수 있기에 대역폭을 절약하고 사용자 경험을 향상시킬 수 있습니다.
                      2. HTTP를 기반으로 하는 스트리밍은 다양한 플랫폼과 장치에서 지원되므로, 웹 브라우저, 모바일 앱, 스마트 TV 등 다양한 환경에서 사용할 수 있습니다.
                      3. HTTP를 사용하면 콘텐츠를 캐싱하고 CDN(Content Delivery Network)을 통해 전송하여 콘텐츠 전송 성능을 최적화할 수 있습니다.

                       

                      HTTP Streaming의 한계

                      HTTP Streaming에 많은 장점이 있지만 다음과 같은 한계점이 있습니다.

                      1. HTTP Streaming에서 데이터는 조각으로 전송되므로 처음 몇 조각을 수신한 후에 재생이 시작될 수 있습니다. 이로 인해 초기 버퍼링 지연이 발생할 수 있습니다.
                      2. HTTP Streaming은 본질적으로 데이터 전송에 대한 암호화 또는 보안 조치를 제공하지 않습니다. 추가 보안 계층이 없으면 채팅 애플리케이션을 통해 교환되는 정보는 무단 액세스에 취약할 수 있습니다.
                      3. 다양한 스트리밍 프로토콜이 존재하기에 브라우저 및 장치에 따라 호환성에 문제가 발생할 수 있습니다.
                      4. HTTP Streaming은 서버 및 클라이언트 측에서 추가 리소스를 사용하므로 서버에 대한 리소스가 집약적일 수 있습니다. 그렇기에 대규모 미디어 스트리밍 서비스의 경우 서버 인프라 및 대역폭 관리가 중요한 문제가 될 수 있습니다.

                       

                      HTTP Streaming에 대해 자세히 알아보고 싶으신 분은 아래 글을 참고해주세요.

                       

                      What is HTTP Streaming?

                      Read the Real-time communication API Blog now.

                      www.pubnub.com

                       

                       

                      Long Polling 이란?

                      Long Polling 방식은 클라이언트가 서버에 HTTP Request를 요청하면 서버는 대기하고 있다가 이벤트가 발생 시 응답을하고 클라이언트는 응답을 받자마자 다시 서버에 Request를 요청보내 실시간성을 살리는 방식입니다.

                      https://www.educative.io/answers/what-is-http-long-polling

                       

                      Long Polling 장점

                      데이터가 업데이트되면 그 즉시 클라이언트에게 응답을 보내고 전달받은 데이터를 처리하므로 실시간성이 아주 높습니다.

                       

                      Long Polling 단점

                      다수의 클라이언트가 존재하여 동시에 이벤트가 발생할 경우, 서버는 각 클라이언트마다 응답을 해주어야하기 때문에 순간 서버의 부담이 증가하게 됩니다.

                      또한 데이터가 주어지는 즉시 바로 반응하고 보내므로 요청 간격이 줄어든다면 polling보다 훨씬 데이터를 많이 보내게됩니다.

                       

                       

                      reference

                      https://velog.io/@hustle-dev/JavaScript-Long-Polling