실시간 채팅 + 이미지 전송(socket + stomp + sockjs)

코딩을 합시다·2023년 2월 11일
2

저번에 websocket만을 이용해서 실시간 채팅과 이미지 전송을 구현했었다. 하지만 팀 프로젝트 과정에서 stomp와 sockjs를 추가해달란 프론트에 요청이 있었고 그래서 약간 업그레이드 버전으로 stomp, sockjs를 추가해서 만들게 되었다.


Socket s3 + 실시간 채팅 + 이미지전송
https://velog.io/@dirn0568/Socket-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%B1%84%ED%8C%85-%EC%9D%B4%EB%AF%B8%EC%A7%80%EC%A0%84%EC%86%A1


build.gradle

// Web Socket (채팅)
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:stomp-websocket:2.3.3-1'
implementation 'org.webjars:sockjs-client:1.1.2'

// aws s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

// test thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

application.properties

cloud.aws.stack.auto=false
cloud.aws.region.static={지역}
cloud.aws.credentials.access-key=I AM 엑세스키
cloud.aws.credentials.secret-key=I AM 시크릿 엑세스키
cloud.aws.s3.bucket=https://s3.ap-northeast-2.amazonaws.com/{버켓 이름}

WebSockConfig.java

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSockConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/sub"); // sub으로 들어오는 요청을 처리해주기 위해 추가함
        config.setApplicationDestinationPrefixes("/pub"); // pub으로 들어오는 요청을 처리해주기 위해 추가함
    }
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*").withSockJS(); // Endpoint를 지정해주었고 setAllowedOriginPatterns("*")를 이용해서 요청 url을 전부 허용해주었다. + withSockJs() 함수를 통해 ws, wss로 socket을 연결하는 것이 아닌 http, https로 socket을 연결하도록 바꾸어주었다.
    }
    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
        registration.setMessageSizeLimit(50 * 1024 * 1024); // 메세지 크기 제한 오류 방지(이 코드가 없으면 byte code를 보낼때 소켓 연결이 끊길 수 있음)
    }
    @EventListener
    public void connectEvent(SessionConnectEvent sessionConnectEvent){
        System.out.println(sessionConnectEvent);
        System.out.println("연결 성공 감지!_!#!#!#!@#!@@#!@!#!$!@");
        //return "redirect:chat/message";
    }
    @EventListener
    public void onDisconnectEvent(SessionDisconnectEvent sessionDisconnectEvent) {
        System.out.println(sessionDisconnectEvent.getSessionId());
        System.out.println("연결 끊어짐 감지!~!!!!!!!!!!!!!!!!!!!!!!!!");
    }
}

ChatController.java

@RequiredArgsConstructor
@Controller
public class ChatController {
    private final SimpMessageSendingOperations messagingTemplate;
    private final ChatMessageService chatMessageService;
    @ResponseBody
    @MessageMapping("/chats") // MessageMapping을 통하여 socket으로 부터 오는 메세지를 전부 받을 수 있게 해줌
    public void message(ChatMessageRequestDto chatMessageRequestDto) {
        ChatMessageResponseDto chatMessageResponseDto = new ChatMessageResponseDto();
        if (chatMessageRequestDto.getImgCode() != null) {
            chatMessageResponseDto = chatMessageService.BinaryImageChange(chatMessageRequestDto);
        } else {
            chatMessageResponseDto = new ChatMessageResponseDto(chatMessageRequestDto);
        }
        System.out.println("chatMessageResponseDto.getImgCode() : " + chatMessageResponseDto.getImgCode());
        messagingTemplate.convertAndSend("/sub/chat/room" + chatMessageRequestDto.getChatRoomId(), chatMessageResponseDto); // 구독한 방에만 메세지를 뿌려줌
    }
}

ChatRoomController.java

@Controller
@RequiredArgsConstructor
public class ChatRoomController {
    // socket test
    @RequestMapping("/chat/room/{roomId}")
    public ModelAndView chat() {
        ModelAndView mv = new ModelAndView();
        mv.setViewName("chat");
        return mv;
    }

    // socket test
    @RequestMapping("/chat/rooms")
    public ModelAndView chatRooms() {
        ModelAndView mv = new ModelAndView();
        mv.setViewName("chatrooms");
        return mv;
    }
}

ChatMessageRequestDto.java

@Getter
@NoArgsConstructor
public class ChatMessageRequestDto {
    private Long chatRoomId;
    private String userEmail;
    private String message; // 메시지
    private String imgCode; // 바이트 코드로 이미지를 받음
}

ChatMessageResponseDto.java

@Getter
@Setter
@NoArgsConstructor
public class ChatMessageResponseDto {
    private Long chatRoomId;
    private String userEmail;
    private String message;
    private String imgCode;

    public ChatMessageResponseDto(ChatMessageRequestDto chatMessageRequestDto) {
        this.chatRoomId = chatMessageRequestDto.getChatRoomId();
        this.userEmail = chatMessageRequestDto.getUserEmail();
        this.message = chatMessageRequestDto.getMessage();
        this.imgCode = chatMessageRequestDto.getImgCode();
    }
}

ChatMessageService.java

import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.PutObjectRequest;
import org.springframework.beans.factory.annotation.Autowired;
import shop.dodotalk.dorundorun.message.dto.ChatMessageRequestDto;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import shop.dodotalk.dorundorun.message.dto.ChatMessageResponseDto;

import java.util.*;

@Slf4j
@RequiredArgsConstructor
@Service
public class ChatMessageService {
    @Autowired // aws img test
    AmazonS3Client amazonS3Client;
    private String S3Bucket = "mysparta1"; // Bucket 이름
    @Transactional
    public ChatMessageResponseDto BinaryImageChange(ChatMessageRequestDto chatMessageRequestDto) {
        try {
            String[] strings = chatMessageRequestDto.getImgCode().split(","); // ","을 기준으로 바이트 코드를 나눠준다
            String base64Image = strings[1];
            String extension = ""; // if 문을 통해 확장자명을 정해줌
            if (strings[0].equals("data:image/jpeg;base64")) {
                extension = "jpeg";
            } else if (strings[0].equals("data:image/png;base64")){
                extension = "png";
            } else {
                extension = "jpg";
            }

            byte[] imageBytes = javax.xml.bind.DatatypeConverter.parseBase64Binary(base64Image); // 바이트 코드를 

            File tempFile = File.createTempFile("image", "." + extension); // createTempFile을 통해 임시 파일을 생성해준다. (임시파일은 지워줘야함)
            try (OutputStream outputStream = new FileOutputStream(tempFile)) {
                outputStream.write(imageBytes); // OutputStream outputStream = new FileOutputStream(tempFile)을 통해 생성한 outputStream 객체에 imageBytes를 작성해준다.
            }

            String originalName = UUID.randomUUID().toString(); // uuid를 통해 파일명이 겹치지 않게 해준다

            amazonS3Client.putObject(new PutObjectRequest(S3Bucket, originalName, tempFile).withCannedAcl(CannedAccessControlList.PublicRead)); // s3에 tempFile을 저장해준다.

            String awsS3ImageUrl = amazonS3Client.getUrl(S3Bucket, originalName).toString(); // s3에 저장된 이미지 불러오기

            try { 
                FileOutputStream fileOutputStream = new FileOutputStream(tempFile); // 파일 삭제시 전부 아웃풋 닫아줘야함 (방금 생성한 임시 파일을 지워주는 과정
                fileOutputStream.close(); // 아웃풋 닫아주기
                if (tempFile.delete()) {
                    log.info("File delete success"); // tempFile.delete()를 통해 삭제
                } else {
                    log.info("File delete fail");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }

            ChatMessageResponseDto chatMessageResponseDto = new ChatMessageResponseDto(chatMessageRequestDto);
            chatMessageResponseDto.setImgCode(awsS3ImageUrl); // s3 이미지 url로 수정해준다.
            return chatMessageResponseDto;
        } catch (IOException ex) {
            log.error("IOException Error Message : {}",ex.getMessage());
            ex.printStackTrace();
        }
    return new ChatMessageResponseDto(); // 이 부분은 꼭 수정해야할듯
    }
}

chat.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>

<style>
    *{ margin: 0; padding: 0; }

    li { list-style: none; }

    .header { font-size: 14px; padding: 15px 0; background: #F18C7E; color: white; text-align: center; border-radius: 10px 10px 0 0;}

    .chat ul { width: 100%; }
    .chat ul li { width: 100%; }
    .left { text-align: left; }
    .right { text-align: right; }

    .chat ul li > div { font-size: 13px; }
    .sender { margin: 10px 20px 0 20px; font-weight: bold; }
    .message { display: inline-block; word-break:break-all; margin: 5px 20px; max-width: 75%; border: 1px solid #888;
        padding: 10px; border-radius: 5px; background-color: #FCFCFC; color: #555; text-align: left; }
</style>

<body>

<div style="position:relative; width:50rem; height:50rem; margin:auto; border: 1px solid #D5D5D5; border-radius: 15px; overflow-y: scroll;">
    <div class="header">
        들어옴?????
    </div>

    <div id="chat">
        <ul>

        </ul>
    </div>

    <div style="position:absolute; bottom:0; margin-left:2.5rem;">
        <input type="text" style="width: 500px;height: 32px;font-size: 15px;border: 0;border-radius: 15px;
            outline: none;padding-left: 10px;background-color: rgb(233, 233, 233); text-align: center" placeholder="메세지를 입력해주세요" id="sendText">

        <img id="img" src="" />
        <input type='file' id="baseFile" />

        <button type="button" style="border:none; color: #4C4C4C; height: 32px; padding:0px 20px; border-radius:10px;
            background-color:#B2CCFF; box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2); margin-left:15px;" onclick="sendMessage()">
            보내기
        </button>
    </div>
</div>
</body>

<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script>
<script src="/webjars/sockjs-client/1.1.2/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/2.3.3-1/stomp.min.js"></script>
<script>
    let userEmail = localStorage.getItem('userEmail');
    let roomId = localStorage.getItem('roomId');

    let sock = new SockJS("/ws-stomp"); //new SockJS()를 사용하여 아까 핸들러에서 지정해준 endPoint를 사용하여 연결을 해준다.
    let ws = Stomp.over(sock);

    ws.connect({}, function(frame) {
        ws.subscribe("/sub/chat/room" + roomId, function(message) {
            let receive = JSON.parse(message.body);
            alert(receive.imgCode);
            if (receive.imgCode != null) {
                receiveImg(receive);
            } else {
                receiveMessage(receive);
            }
        });
    }, function(error) {
        alert("error"+error);
    });

    function sendMessage() {
        let sendText = $('#sendText').val();
        ws.send("/pub/chats", {}, JSON.stringify({chatRoomId:roomId, userEmail:userEmail, message:sendText})); //receiver:participant,
    }

    function receiveMessage(receive) {
        let tempHtml;
        if (userEmail == receive.userEmail) {
            tempHtml = makeHtmlMessageRight(receive);
        } else {
            tempHtml = makeHtmlMessageLeft(receive);
        }
        //$('#chat').append(tempHtml);
        document.getElementById('chat').innerHTML += tempHtml;
    }

    function makeHtmlMessageRight(receive) {
        return `<li class="right">
                    <div class="sender">${receive.userEmail}</div>
                    <div class="message">${receive.message}</div>
                </li>`
    }

    function makeHtmlMessageLeft(receive) {
        return `<li class="left">
                    <div class="sender">${receive.userEmail}</div>
                    <div class="message">${receive.message}</div>
                </li>`
    }

    function receiveImg(receive) {
        let tempHtml;
        if (userEmail == receive.userEmail) {
            tempHtml = makeHtmlImgRight(receive);
        } else {
            tempHtml = makeHtmlImgLeft(receive);
        }
        document.getElementById('chat').innerHTML += tempHtml;
    }

    function makeHtmlImgRight(receive) {
        return `<li class="right">
                    <div class="sender">${receive.userEmail}</div>
                    <img src="${receive.imgCode}" style="width:500px; height:500px;">
                </li>`
    }

    function makeHtmlImgLeft(receive) {
        return `<li class="left">
                    <div class="sender">${receive.userEmail}</div>
                    <img src="${receive.imgCode}" style="width:500px; height:500px;">
                </li>`
    }

    $(document).ready(function(){ // #baseFile이 변할때마다 감지
        $("#baseFile").change(function(){
            readImage( this );
        });

        $("#baseFile").trigger("change");

    });

    function readImage(input) {
        if ( input.files && input.files[0] ) {
            var FR= new FileReader();
            FR.onload = function(e) {
                ws.send("/pub/chats", {}, JSON.stringify({chatRoomId:roomId, userEmail:userEmail, imgCode:e.target.result})); //receiver:participant,
                //$('#source').text( e.target.result );
            };
            //console.log(FR.readAsDataURL( input.files[0] ));
            FR.readAsDataURL( input.files[0] ); // 이거 없으면 작동 안되나???
        }
    }
</script>

</html>

트러블 슈팅

문제점

  • socket을 통해 이미지를 보낼때 binary 메세지의 형태로 송수신하면 3mb가 넘어갔을때 socket 연결이 끊기는 문제가 있었다.

해결방법

  • 프론트에서 binary 메세지 - > byte code로 변환
  • byte code를 백엔드에서 다시 File로 변환해서 S3에 저장

결과

  • 30mb까지 저장 할 수 있게 되었다.

2개의 댓글

comment-user-thumbnail
2024년 2월 4일

글 잘봤습니다. 감사합니다.
혹시 이미지를 바이트코드로 어떻게 변환하나요? 보여주신 프론트 코드에는 바이트 코드로 변환하는 작업이 없는 것 같아서요

답글 달기
comment-user-thumbnail
2024년 2월 4일

백엔드 서버 부분 코드를 보면 ParseBase64binary는 Base64로 된 것을 다시 디코딩 하는 작업인데, 그러면 프론트엔드에서 값을 보내는 것이 처음부터 Base64형태 아닌가요??

답글 달기