📌 고정 게시글

📢 공지합니다

이 게시글은 메인 페이지에 항상 고정되어 표시됩니다.

최코딩의 개발

웹 서비스 실시간 채팅 구현하기 (관리자 : 사용자, AWS 환경) 본문

개발 팁

웹 서비스 실시간 채팅 구현하기 (관리자 : 사용자, AWS 환경)

seung_ho_choi.s 2024. 10. 10. 00:18
728x90

개요

 아 오랜만에 작성해보는 블로그이다... 요즘 취업준비 때문에 이거에 신경쓸시간이 없다.... 포스팅하고 싶은게 많은데 ㅜㅜㅜㅜ 일단 여기까지 하고 

오늘은 드디어 필자가 시간이 조~~~금 남아서 실시간 채팅 구현법을 알려주겠다. 간단하다!! 

하지만 AWS의 환경에서는 조금 변경이 필요하다! 

 

구현화면 

 

왼쪽이 관리자이고, 오른쪽이 사용자이다! 즉 사용자들은 채팅을 관리자한테만 보낼수 있으며, 관리자는 카카오톡 처럼 
여러 사용자들을 확인하여 보낼 수 있다. 

적용

초기 설정

//소켓
implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

build.gradle에 다음과 같이 의존성을 추가해둔다.

 

package amcn.amcn.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;

@Configuration
@EnableWebSocketMessageBroker
public class SocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOrigins("https://amcn.kr/")
                .withSockJS();
    }

}

 

SocketConfig이다. 여기서 도메인은 로컬이면 로컬로하고 배포했으면 배포한 주소를 적는다. 

 

이에 앞서 먼저 필자의 객체 설계를 설명하겠다. 

 

객체

package amcn.amcn.socket.domain;


import amcn.amcn.member.domain.member.Member;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Setter
public class AdminMessage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long adminMessageId;

    @Column(nullable = false)
    private String content;

    @Column(nullable = false)
    private LocalDateTime timestamp;

    @Column(nullable = false)
    private boolean confirm;

    @ManyToOne
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    @ManyToOne
    @JoinColumn(name = "user_member_id", nullable = false)
    private Member member2;



    // Getters and Setters
}


AdminMessage 객체 클래스

 

여기서 member2는 관리자가 사용자한테 보낼때 그 해당 사용자의 ID이다. 또한 member1은 관리자의 memberId이다. 관리자는 여러명일수 있으니깐 넣었다.  comfirm은 카카오톡 처럼 상대가 읽었는지 안읽었는지 확인하기 위해 넣었는데 지금은 필요없다.

 

package amcn.amcn.socket.domain;

import amcn.amcn.member.domain.member.Member;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Setter
public class UserMessage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userMessageId;

    @Column(nullable = false)
    private String content;

    @Column(nullable = false)
    private LocalDateTime timestamp;

    @Column(nullable = false)
    private boolean confirm;


    @ManyToOne
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

}

 

UserMessage 객체 클래스

 

@Getter
@Setter
public class ListMessage {
    private String type;
    private Long userMessageId;
    private String message;
    private LocalDateTime timestamp;
    private Long adminMessageId;

}

 

소켓으로 인해 사용자 및 관리자가 대화할때 그 내용의 정보(아이디, 내용 등)를 서버에 넘겨야되는데 그 객체가 이 ListMessage이다. 사실상 DTO! 

 

 

일단 객체 설명은 여기까지 하겠다. 필자는 이렇게 구성했다. 다음은 Controller를 보자

 

컨트롤러 및  JS

@GetMapping("/chat")
public String getChat(Model model,
                      @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
                      Member loginMember) {

    if (loginMember.getRoleType().equals(RoleType.MASTER)) {
        return "redirect:/";
    }
    Optional<Member> findMember = memberRepository.findMemberId(loginMember.getMemberId());
    if (findMember.isPresent()) {
        Member member = findMember.get();
        model.addAttribute("type", member.getRoleType().name());
        model.addAttribute("member", member);
        model.addAttribute("messages", chatRepository.findAllMessage(member));
        model.addAttribute("userId", member.getMemberId());


    } else {
        return null;
    }
    return "chat/userChat";
}

 

이 페이지는 유저전용 채팅이다!  즉 관리자랑 채팅하는 공간이다! 


쉽게 말해서 위사진 페이지이다!! 

 

@GetMapping("/superchat")
public String getSuperChat(Model model,
                           @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
                           Member loginMember) {
    if (!loginMember.getRoleType().equals(RoleType.MASTER)) {
        return "redirect:/";
    }
    Optional<Member> findMember = memberRepository.findMemberId(loginMember.getMemberId());
    if (findMember.isPresent()) {
        Member member = findMember.get();
        model.addAttribute("type", member.getRoleType().name());
        model.addAttribute("member", member);
        model.addAttribute("users", chatRepository.findAllMembers());
    } else {
        return null;
    }
    return "chat/listChat";
}

 

이 페이지는 관리자 전용 페이지이다.  즉 리스트 목록!! 아래 사진과 같다.

하 css... 

 

@GetMapping("/superchat/{memberId}")
public String getSuperChat2(@PathVariable String memberId, Model model,
                            @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
                            Member loginMember) {

    if (!loginMember.getRoleType().equals(RoleType.MASTER)) {
        return "redirect:/";
    }
    Optional<Member> findMember = memberRepository.findMemberId(loginMember.getMemberId());
    if (findMember.isPresent()) {
        Member member = findMember.get();

        model.addAttribute("type", member.getRoleType().name());
        model.addAttribute("member", member);

        Optional<Member> findMember2 = memberRepository.findMemberId(memberId);
        if (findMember2.isPresent()) {
            Member member1 = findMember2.get();

            model.addAttribute("messages", chatRepository.findAllMessage(member1));
            model.addAttribute("mId", memberId);
            model.addAttribute("userMember", member1);
        } else {
            return null;
        }
    } else {
        return null;
    }
    return "chat/masterChat";
}

 

이 페이지는 관리자가 사용자랑 채팅하는 페이지이다. 즉 위 사진에서 아무나 한명 클릭해서 들어가면 위 컨트롤러로 오는것이다.

 

@ResponseBody
@PostMapping("/userchat")
public String postChat(@RequestParam("user_content") String userContent,
                       @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
                       Member loginMember) {
    Optional<Member> findMember = memberRepository.findMemberId(loginMember.getMemberId());
    if (findMember.isPresent()) {
        Member member = findMember.get();
        chatRepository.saveMemberMessage(userContent, member);

    } else {
        return null;
    }
    return "성공";
}
@ResponseBody
@PostMapping("/superchat/{memberId}")
public String postSuperChat(@RequestParam("user_content") String userContent,
                            @PathVariable String memberId,
                            @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
                            Member loginMember) {
    Optional<Member> findMember = memberRepository.findMemberId(loginMember.getMemberId());
    if (findMember.isPresent()) {
        Member member = findMember.get();
        Optional<Member> findMember2 = memberRepository.findMemberId(memberId);
        if (findMember2.isPresent()) {
            Member member2 = findMember2.get();
            chatRepository.saveAdminMessage(userContent, member, member2);

        } else {
            return null;
        }

    } else {
        return null;
    }
    return "성공";
}

 

이 친구들은 각각 위 코드가 사용자 -> 관리자 ,  아래 코드가 관리자 -> 사용자 이다. 즉  Ajax 통신을 위해 넣었다. 

필자는 보험을 위하여 코드 흐름을 2개로 설정했다.

1. 사용자가 관리자한테 문자 보내기, 관리자가 사용자한테 문자 보내기를 하면 1차로 ajax를 통해서 먼저 DB에 저장이된다!

2. Ajax 통신이 성공적으로 끝났으면 2차로 웹 소켓을 활용해 실시간으로 문자가 보내진다. (Html 코드에서 이따가 설명)  

 

즉 비슷한 작업이 2번 실행된다. 2번에다가 그냥 DB에 저장하는 코드를 넣으면 되는게 아닌가?? 라는 생각을 할 수 있지만..  웹소켓이 서버상 오류났을때 DB에 저장이 안되고, 사용자 및 관리자의 대화가 날라갈 수 있다. 따라서 위와 같이 비슷한 작업을 2번 설정했다!

 

JavaScript

<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.5.1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>

 

이거 2개 추가해야된다!

 

당연히 2개의 html이 있다. 관리자 - > 사용자 , 사용자 -> 관리자 이렇게 2개!!

 

사용자 -> 관리자 이것부터 보자. 

<script>
    $(document).ready(function () {
        function scrollChatBox() {
            $('#messages').scrollTop($('#messages')[0].scrollHeight);
        }

        // 페이지 로드 시 스크롤을 맨 아래로 이동
        scrollChatBox();

        // 메시지 전송 로직을 함수로 분리
        function sendMessage() {
            let userContent = $('#user-content2').val();



            if (userContent.trim() !== "") {
                // 사용자 메시지를 바로 추가하기
                $('#messages').append(
                    `<div class="message parker">
                            ${userContent}
                        </div>`
                );
                $('#user-content2').val('');
                scrollChatBox();

                // // Typing 애니메이션을 추가하기
                // $('#messages').append(
                //     `<div class="message stark typing">
                //             <div class="typing typing-1"></div>
                //             <div class="typing typing-2"></div>
                //             <div class="typing typing-3"></div>
                //         </div>`
                // );
                scrollChatBox();

                // 사용자 메시지를 서버에 전송
                $.ajax({
                    type: 'POST',
                    url: "/userchat",
                    data: {
                        'user_content': userContent,
                        'csrfmiddlewaretoken': '{{ csrf_token }}'
                    },
                    success: function () {

                    }
                });
            }
        }

        // Enter 키를 눌렀을 때 메시지 전송
        $('#user-content2').on('keypress', function (event) {
            if (event.which === 13) { // Enter 키를 감지

                sendMessage2();
                event.preventDefault(); // 기본 Enter 동작을 막기
                sendMessage();
            }
        });

        // a 버튼을 클릭했을 때 메시지 전송
        $('.send').on('click', function (event) {
            event.preventDefault(); // a 태그의 기본 동작을 막기
            sendMessage();
        });

        // Resizable 설정
        $("#chat").resizable({
            minWidth: 400,
            minHeight: 400
        });
    });
</script>

 

이 코드는 앞서 말한 Ajax 코드 사실상 DB 저장하기 위함 함수이며, 사용자가 보낸답이 바로 화면에 띄우기 위함이다! 

<script>
    var stompClient = null;
    var userId = document.getElementById('socketMemberId').textContent;
    function connect() {
        var socket = new SockJS('/ws', null, { transports: ['websocket'] });
        stompClient = Stomp.over(socket);

        stompClient.connect({}, function (frame) {
            // console.log('Connected: ' + frame);

            // 관리자 메시지 구독
            stompClient.subscribe('/topic/user/' + userId, function (message) {
                showMessage(JSON.parse(message.body));
            });
        });
    }

    function sendMessage2() {
        var messageContent = document.querySelector('#user-content2').value;
        var memberId = document.getElementById('socketMemberId').textContent;
        if (messageContent && stompClient) {
            var chatMessage = {
                memberId: memberId,  // 사용자 ID
                content: messageContent
            };
            stompClient.send("/app/chat.userToAdmin", {}, JSON.stringify(chatMessage));
        }
    }

    function showMessage(message) {
        var messageElement = document.createElement('div');
        messageElement.classList.add('message');
        messageElement.innerHTML = message.content;
        document.querySelector('#messages').appendChild(messageElement);
    }

    connect();
</script>

 

이 코드가 웹소켓 JS이다!! 

여기서 구체적인 설명을 하자면 사용자가 입력을 하면 먼저 sendMessage2가 실행이된다.

그 후 /app/chat.userToAdmin이라는 곳으로 서버가 이동이 된다! 

// 사용자 메시지 전송 본격적인 웹 소켓
@MessageMapping("/chat.userToAdmin")
public void userToAdmin(@Payload ChatMessage chatMessage) {
    messagingTemplate.convertAndSend("/topic/admin/", chatMessage);
}

 

그럼 여기 컨트롤러로 이동한다. 이때 소켓라이브러리인 messgingTemplate을 활용해 /topic/admin으로 사용자가 보낸 메시지와 함께 이동하게 된다. 참고로 실시간이라서 url이 바뀌는게 아니다!! 

 

그럼 여기 html 즉 관리자 -> 사용자

<script>
    var stompClient = null;

    function connect() {
        var socket = new SockJS('/ws', null, { transports: ['websocket'] });
        stompClient = Stomp.over(socket);

        stompClient.connect({}, function (frame) {
            // console.log('Connected: ' + frame);

            // 사용자 메시지 구독
            stompClient.subscribe('/topic/admin/', function (message) {
                showMessage(JSON.parse(message.body));
            });
        });
    }

    function sendMessage2() {
        var memberId = document.getElementById('socketMemberId').textContent;
        var messageContent = document.querySelector('#admin-content').value;
        if (messageContent && stompClient) {
            var chatMessage = {
                memberId: memberId,  // 사용자 ID
                content: messageContent,
            };

            stompClient.send("/app/chat.adminToUser", {}, JSON.stringify(chatMessage));
        }else {
        }
    }

    function showMessage(message) {
        var messageElement = document.createElement('div');
        messageElement.classList.add('message');
        messageElement.innerHTML = message.content;
        document.querySelector('#messages').appendChild(messageElement);
    }

    connect();
</script>

 

 저기 connect 함수부분!! 즉 topic/admin으로 이동하게되고 showMessage 함수가 실행되어 바로 관리자 채팅에 사용자가 보낸 문자를 확인할  수 있다!! 

 

즉 쉽게 말해서 크로스 형식이다! 

A.html B.html이 있다고 가정하자. 

 

A에는 B로부터 받을 수 있는 api가 열려있고 B도 마찬가지다. 따라서 둘이 계속 통신하면서 정보를 볼  수 있는 것이다. 

 

 

주의점 

로컬에서는 분명히 실행이 잘됐는데..... AWS 환경으로 배포하니깐 잘 안됐다....

오류 내용을 봤는데 계속 소켓  연결 실패라고 뜨는것이다..!! 

var socket = new SockJS('/ws', null, { transports: ['websocket'] });

 

connect 함수 부분에서 위와 같이 /ws로 바꿔야된다.  그래야지 소켓이 어떤 환경에서든 다 받을 수 있다고 한다!! 

 

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws")
            .setAllowedOrigins("https://amcn.kr/")
            .withSockJS();
}

 

SocketConfig에서 위와 같이 도메인 설정을 해줘야된다. 안그럼 모든 호스트에서 받아들여서 오류가 뜰 것이다..

proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket 관련 헤더 추가
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # CORS 헤더 추가 (필요한 경우만 사용, 모든 도메인 허용)
        add_header 'Access-Control-Allow-Origin' '*';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept';

 

그리고 위와 같이 nginx 파일에다가 위 코드들을 추가해주자!! 

 

자세한 코드는  아래 링크를 참고하자

https://github.com/chltmdgh522/AutoMakeCardNews

 

GitHub - chltmdgh522/AutoMakeCardNews: 생성형 AI를 활용한 카드뉴스 자동생성기 졸업작품입니다!

생성형 AI를 활용한 카드뉴스 자동생성기 졸업작품입니다! Contribute to chltmdgh522/AutoMakeCardNews development by creating an account on GitHub.

github.com

 

이로써 실시간 채팅 구현도 끝!! 다음 포스팅은 스프링 및 쟝고 배포를 작성할것이다.. 시간이 된다면 ㅜㅜㅜㅜㅜ 

 

728x90