[Spring Boot Websocket] - 커미션 채팅방 (2) DB 없는 채팅방 테스트

jinvicky·2025년 1월 6일
0
post-thumbnail

읽어만 보기 (rabbitMQ)
맨 처음에는 rabbitMQ를 도입해볼 생각에 테스트를 먼저 가볍게 훑었다. 도커를 사용하면 쉽게 테스트를 할 수 있다.

docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 --restart=unless-stopped rabbitmq:management

따라하기 좋은 블로그
🔗 https://velog.io/@choidongkuen/서버-SpringBoot-을-이용한-RabbitMQ-구축하기
🔗 https://adjh54.tistory.com/292
🔗 https://velog.io/@power0080/Message-QueueRabbitMQ를-사용해-메세지-주고-받기

웹소켓 with Simple Java

먼저 자바로만 예제를 따라하면서 인텔리제이 멀티 인스턴스로 학습했다. (인텔리제이는 edit configuration가면 multi instance 설정할 수 있다)
🔗 https://yjkim-dev.tistory.com/65

웹소켓 with Spring Boot

이제 flow를 훑었으니 스프링 의존성부터 추가한다.
중요한건 websocket과 편의성을 위한 lombok, 기본 web 정도가 된다.

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket' // 추가해야 할 핵심

    implementation 'com.cloudinary:cloudinary-http44:1.2.1' // 추후 cloudinary 추가시 필요
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.0' 
    implementation 'com.google.code.gson:gson:2.11.0' // json 형변환을 위해 추가, objectMapper or gson 택1
    //.... 생략
}

WebSocketConfig.java

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final WebSocketHandler webSocketHandler;

    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(50 * 1024 * 1024); // 50MB
        container.setMaxBinaryMessageBufferSize(50 * 1024 * 1024); // 50MB
        return container;
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler, "/chat")
                .setAllowedOrigins("*");
    }
}

WebSocketHandler.java

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

@Component
@Slf4j
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {

    private final CloudinaryConfigure cloudinaryConfigure;

    private static final ConcurrentHashMap<String, WebSocketSession> CLIENTS =
            new ConcurrentHashMap<String, WebSocketSession>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        CLIENTS.put(session.getId(), session);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        CLIENTS.remove(session.getId());
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {

        String payload = message.getPayload();
        log.info("handleTextMessage => {}",payload);

        String id = session.getId();
        CLIENTS.entrySet().forEach(arg-> {
            if(!arg.getKey().equals(id)) {
                try {
                    arg.getValue().sendMessage(message);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        });
    }

    @Override
    protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) {
        byte[] messageBytes = message.getPayload().array();
        log.info("Received file of size: {} bytes", messageBytes.length);

        try {
            CloudinaryResponse uploadResult = cloudinaryConfigure.bytesImageUpload(messageBytes);
            log.info("File uploaded to Cloudinary: {}", uploadResult);
            session.sendMessage(new TextMessage("File uploaded successfully!"));
        } catch (IOException e) {
            log.error("File upload failed", e);
            try {
                session.sendMessage(new TextMessage("File upload failed!"));
            } catch (IOException ex) {
                throw new RuntimeException(ex);
            }
        }
    }

}

TextWebSocketHandler를 상속받은 웹소켓 핸들러를 구현한다.
기본 텍스트 채팅은 handleTextMessage, 파일 업로드 채팅은 handleBinaryMessage가 처리한다.

텍스트 채팅 Test with Postman

postman을 사용하면 웹소켓 요청도 테스트가 가능하다. (localhost:8080 또는 127.0.0.1:8080)
🔗 https://syk531.tistory.com/95

이제 웹소켓으로 간단한 핑퐁 텍스트 채팅 테스트까지 마쳤다. 이제 개인적으로 매우 난감했던 파일 업로드 채팅을 자세히 보자.

Issue - 서버로 파일데이터가 안 온다;;

먼저 기존 프로젝트에 app router 페이지를 추가해서 테스트 환경을 구성했다.

"use client";

import { useRef } from "react";

const Test = () => {
  const socket = useRef<WebSocket | null>(null); // WebSocket 연결을 useRef로 관리
  const fileInput = useRef<HTMLInputElement>(null);
  const textInput = useRef<HTMLInputElement>(null);

  // WebSocket 연결 및 초기화
  const connectSocket = () => {
    socket.current = new WebSocket("ws://localhost:8080/chat");

    socket.current.onopen = () => {
      console.log("WebSocket connected");
      socket.current?.send(JSON.stringify("Hello WebSocket"));
    };
    socket.current.onerror = (error) => {
      console.error("WebSocket error:", error);
    };
  };

  const onClickSubmit = async () => {
    const buffer = await fileInput.current?.files?.[0]?.arrayBuffer();
    if (buffer) {
      const uint8Array = new Uint8Array(buffer);
      if (socket.current?.readyState === WebSocket.OPEN) {
        socket.current?.send(uint8Array);
      }
    }
  };

  return (
    <>
      <div>WebSocket Test</div>
      <input type="file" multiple ref={fileInput} />
      <input type="text" placeholder="Message" ref={textInput} />
      <button onClick={onClickSubmit}>Upload Files</button>
      <button onClick={connectSocket}>Connect WebSocket</button>{" "}
      {/* WebSocket 연결 버튼 */}
    </>
  );
};

export default Test;

백에서 handleBinaryMessage가 파일 요청을 처리하려면 front에서 파일 객체를 byte[]로 변경해서 던져줘야 한다. handleBinaryMessage 내부를 보고 byte[] 또는 ByteBuffer를 인자로 넘겨줘야 한다는 것을 확인한다.

 public BinaryMessage(ByteBuffer payload) {
        super(payload, true);
    }

    public BinaryMessage(ByteBuffer payload, boolean isLast) {
        super(payload, isLast);
    }

    public BinaryMessage(byte[] payload) {
        this(payload, true);
    }

프론트는 byte[] 로 변경하기 위해서 파일을 arrayBuffer로 변경하고 다시 byte[]로 변경해서 전송한다. (test라서 실사용시 좀 더 다듬어야 한다)

onst onClickSubmit = async () => {
    const buffer = await fileInput.current?.files?.[0]?.arrayBuffer();
    if (buffer) {
      const uint8Array = new Uint8Array(buffer);
      if (socket.current?.readyState === WebSocket.OPEN) {
        socket.current?.send(uint8Array);
      }
    }
  };

Uint8Array 형식화 배열(TypedArray)은 플랫폼의 바이트 순서를 따르는 8비트 부호 없는 정수의 배열을 나타냅니다. 배열의 내용은 0으로 초기화됩니다. 배열이 생성되면 객체의 메서드를 사용하거나 표준 배열 인덱스 구문(즉, 대괄호 표기법 사용)을 사용하여 배열의 요소를 참조할 수 있습니다

  • MDN 공식 문서

그런데 백엔드에서는 간단한 log.info 조차 찍히지 않는다;; 프론트를 테스트해봤을 때 바이트배열로 제대로 변환한 것은 확인했으나, 백엔드에 도달하지 않는 점이 이상했다..
그러다가 회사 동료가 했던 말이 기억났는데

😙 그거 웹소켓 할려면 파일 보낼때 chunk 사이즈 해서 잘라서 보내야 한다~

왜 그런 말을 했을까? 고민했다가 애초에 웹소켓 업로드 크기 설정이 얼마였을까?부터 의문이 생겼다.
그래서 테스트 이미지의 바이트 배열은 21419 크기였는데 이를 512로 대폭 줄여서 전송해보았더니? 제대로 백엔드에 로그가 출력되었다.

용량 이슈였다.... 용량이 한계를 넘으면 exception 등으로 예외가 발생할 것이라고 생각했던 내가 안일했다. 실제로는 평온하게 아무 일도 일어나지 않는다. 그리고 실제로 테스트했던 이미지가 생각보다 용량이 컸던 점도 미스였다 (1.6MB였네;;)

그래서 아래 코드가 중요하다. (gpt, 구글링하면 stomp의 설정과 섞여서 혼동을 주기도 하고, yml 설정도 해보았지만 아래 코드로 해결했다. 용량은 기량껏 조절)

 @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(50 * 1024 * 1024); // 50MB
        container.setMaxBinaryMessageBufferSize(50 * 1024 * 1024); // 50MB
        return container;
    }

이제 성공적으로 용량 큰 이미지도 업로드할 수 있다. (50mb는 부담이 크니까 추가 조정해야 한다)

Storage & CDN with Cloudinary

파일 이미지를 저장할 스토리지가 필요하다. 기존에 사용했던 cloudinary 무료 플랜을 이어서 사용했다.
cloudinary는 Next.js와도 호환이 잘 맞고, 자체적으로 가이드 및 기술 지원이 풍부한 플랫폼이다.

  • 업로드하면서 이미지를 원하는 형태로 이미지를 줄이거나 늘리거나 다양한 형태의 미디어를 만들 수 있다.
  • 글로벌 CDN과 통합되어 전세계에서 일관된 로딩 시간을 제공한다.
  • 마지막으로 자체적인 자동 최적화, 압축, 대역폭 사용 최소화와 같은 성능상의 이점에서 훌륭하다.

cloudinary with Spring Boot

회원가입 후 dashboard에 가서 cloudName, apiKey, apiSecret을 application.properties에 설정한다.

application.properties

spring.application.name=chat-room

cloudinary.cloud_name=클라우드명
cloudinary.api_key=api키
cloudinary.api_secret=api시크릿

클라우디너리에서 내가 사용할 메서드는 내부가 이렇게 구성되어 있다.

public Uploader uploader() {
        return new Uploader(this, this.uploaderStrategy);
}

 public Map upload(Object file, Map options) throws IOException {
        if (options == null) {
            options = ObjectUtils.emptyMap();
        }

        Map<String, Object> params = this.buildUploadParams(options);
        return this.callApi("upload", params, options, file);
    }

리턴타입이 map이므로 응답을 받아서 vo와 매핑하기 위해서 간단한 Gson 라이브러리를 추가했다.
upload결과값중에서는 _가 사용되는 변수들도 있기 때문에 GsonConfig도 추가한다.

GsonConfig.java

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.GsonHttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class GsonConfig implements WebMvcConfigurer {

    @Override
    public void configureMessageConverters(java.util.List<HttpMessageConverter<?>> converters) {
        converters.add(gsonHttpMessageConverter());
    }

    @Bean
    public GsonHttpMessageConverter gsonHttpMessageConverter() {
        Gson gson = new GsonBuilder().setFieldNamingPolicy(com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
        return new GsonHttpMessageConverter(gson);
    }
}

_가 들어가는 변수들을 카멜케이스 변수명과 매핑하기 위함이다.
다음으로 cloudinaryUtil을 만든다.

CloudinaryUtil.java
@Value로 주입받아서 @PostConstruct 또는 생성자를 통해서 값을 셋팅할 수 있다.
가독성과 로직 분리를 위해서 @PostConstruct를 선호한다.

@Component
public class CloudinaryUtil {

    private Cloudinary cloudinary;

    @Value("${cloudinary.cloud_name}")
    private String cloudName;

    @Value("${cloudinary.api_key}")
    private String apiKey;

    @Value("${cloudinary.api_secret}")
    private String apiSecret;

// 1 어노테이션으로 
    @PostConstruct
    public void initCloudinary() {
        cloudinary = new Cloudinary(ObjectUtils.asMap(
                "cloud_name", cloudName,
                "api_key", apiKey,
                "api_secret", apiSecret
        ));
    }

// 2. 생성자로
//    public CloudinaryUtil(@Value("${cloudinary.cloud_name}") String cloudName,
//                          @Value("${cloudinary.api_key}") String apiKey,
//                          @Value("${cloudinary.api_secret}") String apiSecret) {
//        this.cloudinary = new Cloudinary(ObjectUtils.asMap(
//                "cloud_name", cloudName,
//                "api_key", apiKey,
//                "api_secret", apiSecret
//        ));
//    }

바이트배열로 업로드하는 util을 윗 내부 코드를 보고 짠다.

 /**
     * 바이트 배열 업로드
     *
     * @param fileBytes
     * @return
     * @throws IOException
     */
    public CloudinaryResponse bytesUpload(byte[] fileBytes, String resourceType) throws IOException {
        Map map = cloudinary.uploader().upload(fileBytes, ObjectUtils.asMap("resource_type", resourceType));
        Gson gson = new Gson();
        String json = gson.toJson(map);
        return gson.fromJson(json, CloudinaryResponse.class);
    }

    public CloudinaryResponse bytesImageUpload(byte[] fileBytes) throws IOException {
        return bytesUpload(fileBytes, "image");
    }

클라우디너리 응답값을 궁금해 하시는 분들 참고용
CloudinaryResponse.java

@Getter
@Setter
@ToString
public class CloudinaryResponse {
    public CloudinaryResponse() {
    }

    private String assetFolder;
    private String signature;
    private String format;
    private String resourceType;
    private String secureUrl;
    private String createdAt;
    private String assetId;
    private String versionId;
    private String type;
    private String displayName;
    private long version;
    private String url;
    private String publicId;
    private String[] tags;
    private String originalFilename;
    private String apiKey;
    private long bytes;
    private int width;
    private String etag;
    private boolean placeholder;
    private int height;

    // 필요시 빌더 생성자 패턴 생성
}

여기까지 오면 로컬로 텍스트, 파일 채팅을 모두 성공하고 스토리지에 이미지 업로드까지 해낼 수 있다.
다음에는 MyBatis + MySQL을 연동할 예정이다.

jinvicky
Front-End, Back-End Developer
✉️ Email: jinvicky@naver.com
💻 Github: https://github.com/jinvicky

profile
Front-End와 Back-End 경험, 지식을 공유합니다.

0개의 댓글