9. 판매자-구매자 채팅 기능(Chat Application) (2) - DB 설계 & MVC 코드 읽기

2021. 2. 9. 22:01프로젝트/Salle(살래) 중고거래 웹

728x90

지난 글에서 서버에서 채팅을 주고 받기 위해서 왜 WebSockets 프로토콜을 사용하는지 이유와 함께 STOMP, sockJS에 대한 개념을 설명했습니다. 그리고 Websocket Configuration 코드와 메시지 전달과정(Message Flow)까지 알아보았습니다. 

▶ Github

Salle 프로젝트 코드 Github(진행중)

▶ Chat Application 글 구성

1부: Chat Application 설계, 후기 & WebSocket 설명, Config 설정하기

2부: DB 설계 & 상품 페이지(productInfo)에서 채팅 시작하기

3부:  채팅 리스트(chatList) 만들기 + WIL(What I learned) 정리

이번 글에선 DB-Spring boot 연결방법과 테이블 설계를 소개하고 다음글까지 2부로 나누어 Controller, View 코드를 뜯어보겠습니다. 

Ⅰ. DB 연결 &  테이블 설계


저는 console 조작이 가능하고 사용이 쉬운 H2 SQL Database를 사용했습니다. 사이트에서 다운로드 를 진행하고 Spring boot에 몇가지 설정을 해줘야 합니다. 

 

Spring boot에선 Server port number나 view prefix 설정이 모두 application.properties란 파일에서 이루어집니다.

 

url은 로컬폴더에 DB datasource를 저장하는 경로이며 ...file: 뒤에 저장할 경로를 작성해주시면 됩니다. 그런 다으미 username과 password를 설정한 뒤 만일 여러 Application을 사용할 것이라면 개별 Database가 적용되도록 generate-unique-name을 true로 해주시면 됩니다. 제 프로젝트는 단일 Application이라서 false로 해주었습니다. 그리고 따로 SQL 프로그램을 실행시키지 않고 Spring boot Application을 가동한 상태의 로컬서버 웹 상에서 console로 DB 테이블을 조작(CRUD)할 수 있도록 h2.console.enable을 true로 설정해줍니다. 

 

1
2
3
4
5
6
7
8
9
#12/22 h2 datasource renew
spring.datasource.url= jdbc:h2:file:C:/Users/Documents/
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sample
spring.datasource.password=1234
spring.datasource.generate-unique-name=false
spring.h2.console.enabled=true
 
cs

 

Spring boot application을 가동시켜 프로젝트를 띄어주고 브라우저 도메인에 [로컬서버 주소]/h2-console을 입력하면 아래와 같은 화면이 나오고 설정해준 username과 password로 로그인이 되면 정상적으로 연결이 된 것입니다.

프로젝트 DB의 전체 테이블은 아래와 같이 설계되어 있습니다. CHATROOM 테이블은 ChatApplication에 할당돼 있습니다. 화살표가 테이블 컬럼 간의 연관관계를 나타낸 것입니다. 이를 통해 SQL문으로 JOIN도 가능하며 때로는 다른 테이블에 접근에 데이터를 가져와야 할 경우도 발생합니다.

 

DB와 주고받을 Model의 객체 VO(Value Objects)클래스 입니다. DB 컬럼명과 클래스 전역 변수명을 같게 설정해주는 것이 원칙입니다. 주석으로 처리된 //not in DB 아래에 변수들은 메시지 본문에 들어가는 DB에 저장되지 않는 temporary 데이터 입니다. 다만 Text file에 저장되는 컨텐츠일 뿐이기 때문에 따로 할당되는 DB 테이블 컬럼은 없습니다. 이런 이유는 ChatRoom 객체 안에서 관리하면 View에서 message 관련된 데이터들을 JSON 형태로 넘겨줄 때, ChatRoom으로 한번에 데이터를 받을 수 있기 때문입니다. 그렇지 않다면 나눠서 데이터를 전송해야 하므로 두 번의 request, response가 발생합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example.demo.domain;
 
import java.sql.Timestamp;
 
import lombok.Data;
 
 
public @Data class ChatRoom {
 
    private int id;
    private int pr_id;
    private String sellerId;
    private String buyerId;
    private String fileName;
    private Timestamp createdDate;
    private String sellerName;
    private String buyerName;
    //not in DB
    private String content;
    private String sendTime;
    private String senderName;
    private String pr_title;
    
    public ChatRoom(int id, int pr_id, String sellerId, String buyerId, String fileName,
            Timestamp createdDate, String sellerName, String buyerName) {
        super();
        this.id = id;
        this.pr_id = pr_id;
        this.sellerId = sellerId;
        this.buyerId = buyerId;
        this.fileName = fileName;
        this.createdDate = createdDate;
        this.sellerName = sellerName;
        this.buyerName = buyerName;
    }
 
    public ChatRoom() {
        // TODO Auto-generated constructor stub
    }
    
    public ChatRoom(String content, String senderName, String sendTime) {
        this.content = content;
        this.senderName = senderName;
        this.sendTime = sendTime;
    }
cs

 

Ⅱ. MVC 코드읽기 (1) - ChatProduct


채팅방에 입장하는 경로는 ProductInfo(상품정보) 화면 - 채팅으로 거래하기 버튼과 회원정보 화면 - 채팅 - 참여중인 채팅방 버튼 두 가지가 있습니다. 이번 글은 ProductInfo에서 참여하는 코드를 읽어보겠습니다. 

 

살펴볼 코드는 크게 세 부분으로 나눠집니다. 채팅을 생성하는 채팅으로 거래하기 버튼 클릭 시, HTTP Request(URL)을 받아줄 Controller, DB에 접근해 View에 데이터를 전달해줄 Service 클래스가 필요합니다. 그리고 View 화면(JSP 파일)을 통해 클라이언트에 Response 해주게 됩니다. 

 

Controller 코드


ProductInfo 페이지에서 채팅으로 거래하기 버튼을 클릭하면 chatRoom 객체를 Model에 담아 "/chatMessage"로 request가 들어옵니다. 아래는 처리하는 Controller 클래스며 4가지의 처리를 담당하고 있습니다. 먼저 View에 보낼 parameter들을 chatRoom 객체에 담아 model로 전송하고 있습니다.  그리고 if문으로 이미 생성된 chatRoom이 있는지 검증한 다음 각각의 경우 Service.readChatHistory()로 이전 대화를 불러오거나 Service.addChatRoom()으로 생성해줍니다. 그리고 생성할 때 대화내용을 저장할 txt file을 만들어주는 Service.createFile()까지가 실행됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package com.example.demo.controller;
 
import java.io.IOException;
import java.util.List;
import java.util.Map;
 
import javax.servlet.http.HttpSession;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
 
import com.example.demo.application.ChatRoomService;
import com.example.demo.domain.ChatList;
import com.example.demo.domain.ChatRoom;
import com.example.demo.domain.Login;
import com.example.demo.domain.TimeUtils;
 
@Controller
public class ChatApplicationController {
    
 
    @Autowired
    private SimpMessagingTemplate simpMessage;
    
    @Autowired
    private ChatRoomService chatRoomService;
 
    //채팅으로 거래하기(productInfo 화면)
    @RequestMapping(value="/chatMessage", method=RequestMethod.GET)
    public String getWebSocketWithSockJs(Model model, HttpSession session, 
            @ModelAttribute("chatRoom") ChatRoom chatRoom) throws IOException {
        
        //productInfo화면에서 Chat화면에 전달해줄 parameter
        
        Login login = (Login) session.getAttribute("login");
        String buyerId = login.getEmail();
        String buyerName = login.getNickName();
        chatRoom.setBuyerId(buyerId);
        chatRoom.setBuyerName(buyerName);
        
        
        //이미 chatRoom이 만들어져있는지 확인
        if (chatRoomService.countByChatId(chatRoom.getPr_id(), chatRoom.getBuyerId()) > 0) {
            //get ChatRoomInfo
            ChatRoom chatRoomTemp = chatRoomService.findByChatId(chatRoom.getPr_id(), chatRoom.getBuyerId());
            //load existing chat history
            List<ChatRoom> chatHistory = chatRoomService.readChatHistory(chatRoomTemp);
            //transfer chatHistory Model to View
            model.addAttribute("chatHistory", chatHistory);
            
        } else {
            //chatRoom 생성            
            chatRoomService.addChatRoom(chatRoom);            
            //text file 생성
            chatRoomService.createFile(chatRoom.getPr_id(), chatRoomService.getId(chatRoom.getPr_id(), chatRoom.getBuyerId()));                                
        }
 
            //chatRoom Add 시 생성될 chatId
            chatRoom.setId(chatRoomService.getId(chatRoom.getPr_id(), chatRoom.getBuyerId()));
            
            //chatRoom 객체 Model에 저장해 view로 전달
            model.addAttribute("chatRoomInfo", chatRoom);
        
        return "chatBroadcastProduct";
    }
cs

 

Service 코드


위에서부터 차례대로 addChatRoom()은 DB에 INSERT문으로 신규 ROW를 추가해주는 메서드입니다. 데이터 처리는 최대한 Service 클래스에서 해주고 Controller 클래스에선 Mapping HTTP request와 View response만으로 작성해주는 것이 좋다고 하여 Timestamp로 chatRoom 생성시간을 설정해주었습니다.  

 

readChatHistory 메서드는 이미 생성된 chatRoom의 txt File을 불러온 다음, FileReader로 BufferedReader에 할당해줍니다. 할당된 인스턴스를 br.readLine() (한 줄씩 읽는 메서드)로 반복문을 통해 메시지 1개를 chatRoom 객체에 담아 List에 저장해주었습니다. List는 View에 전달돼 메시지 내용들이 반복해서 출력됩니다.  

 

createFile()은 Java File 클래스와 그 안의 createNewFile() 메서드를 이용해 text file을 지정한 path로 생성해주는 메서드입니다. 저는 path를 프로젝트가 저장되어 있는 로컬 폴더로 지정해줬습니다. 그리고 이미 addChatRoom에서 chatRoom DB row가 만들어졌기 때문에 SQL UPDATE문으로 fileName 컬럼 데이터를 추가해줍니다. 한번에 처리하면 좋겠지만, chatRoom id가 DB에서 자동생성 되기 때문에 Row가 만들어진 다음중복되지 않 도록 상품id(pr_id)와 채팅방id(chatRoom id)를 조합해 fileName에 부여합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
package com.example.demo.application;
 
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.IOException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
 
import javax.transaction.Transactional;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
 
import com.example.demo.domain.ChatList;
import com.example.demo.domain.ChatRoom;
import com.example.demo.mapper.ChatRoomMapper;
 
@Service
@Transactional
public class ChatRoomService implements ChatRoomMapper {
    
    @Autowired
    ChatRoomMapper chatRoomMapper;
    
    //application.properties에 설정
    @Value("${file.upload.path.txt}")
    String fileUploadPath; 
 
    @Override
    public void addChatRoom(ChatRoom chatRoom) throws IOException {
        
        Timestamp createdDate = Timestamp.valueOf(LocalDateTime.now());
        
        chatRoom.setCreatedDate(createdDate);
        
        chatRoomMapper.addChatRoom(chatRoom);
        
    }
    
    //no connection with DB
    public List<ChatRoom> readChatHistory(ChatRoom chatRoom) throws IOException {
        
        String pathName = fileUploadPath + chatRoom.getFileName();
        
        //DB에 저장된 chat.txt 파일을 읽어옴 
        BufferedReader br = new BufferedReader(new FileReader(pathName));
        //View에 ChatRoom 객체로 전달
        ChatRoom chatRoomLines = new ChatRoom();
        List<ChatRoom> chatHistory = new ArrayList<ChatRoom>();
 
        String chatLine;
        int idx = 1;
        
        while ((chatLine = br.readLine()) != null) {
            
            //1개 메시지는 3줄(보낸사람,메시지내용,보낸시간)로 구성돼있음
            int answer = idx % 3;
            if (answer == 1) {
                //보낸사람
                chatRoomLines.setSenderName(chatLine);
                idx++;
            } else if (answer == 2) {
                //메시지내용
                chatRoomLines.setContent(chatLine);
                idx++;
            } else {
                //보낸시간
                chatRoomLines.setSendTime(chatLine);
                //메시지 담긴 ChatRoom 객체 List에 저장
                chatHistory.add(chatRoomLines);
                //객체 초기화, 줄(row)인덱스 초기화
                chatRoomLines = new ChatRoom();
                idx = 1;
            }            
        }
        
        return chatHistory;
    }
    
    @Override
    public void updateFileName(int id, String fileName) {
 
        chatRoomMapper.updateFileName(id, fileName);
    }
    
    public void createFile(int pr_id, int id) throws IOException {
        
        String fileName = pr_id + "_" + id + ".txt";
        String pathName = fileUploadPath + fileName;
        //File 클래스에 pathName 할당
        File txtFile = new File(pathName);
        //로컬경로에 파일 생성
        txtFile.createNewFile();
        
        chatRoomMapper.updateFileName(id, fileName);
    }
cs

 

JSP 코드


MVC 패턴의 View인 JSP파일 코드입니다. 

코드 설명은 각 부분마다 주석으로 남겼습니다. Javascript에 STOMP, SockJS, WebSocket 연결 관련 코드가 적혀있으므로 JS 위주로 봐주시면 됩니다. 

Websocket이 아닌 SockJS를 썼는데, Websocket-like protocol로써 평소엔 Websocket처럼 쓰이다 전송 장애나 오류가 발생할 때 fallback되는 대체 protocol입니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
 
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<title>Web socket STOMP SockJS Example</title>
    <!-- jQuery -->
    <script src="https://code.jquery.com/jquery-3.5.1.js"></script>
    <link rel="stylesheet" href="/resources/css/chatBroadcastProduct.css">
    
 
</head>
<body>
    <div class="container">
        <div class="title_text">
            <h2>${pr_title}</h2>
        </div>
        <div class="row">    
                <%--chatHistory와 member가 실시간 입력하는 메시지 출력 --%>
                <div id="content">
                    <c:forEach var="chatRoom" items="${chatHistory}">
                        <p>
                            <span id="chatRoomSenderName">${chatRoom.senderName}</span><br>
                            <span id="chatRoomContent">${chatRoom.content}</span><br>
                            <span id="chatRoomSendTime">${chatRoom.sendTime}</span><br>
                        </p>    
                    </c:forEach>
                </div>
                <%--메시지 입력창과 보내기 버튼 --%>
                <div class="row_3">
                    <div class="input_group" id="sendMessage">
                        <input type="text" placeholder="Message" id="message" class="form_control"/>
                        <div class="input_group_append">
                            <button id="send" class="btn btn-primary" onclick="send()">보내기</button>
                            <input type="hidden" value="${login.getNickName()}" id="buyerName"/>
                            <input type="hidden" value="${login.getEmail()}" id="buyerId"/>
                            <input type="hidden" value="${chatRoomInfo.pr_id}" id="pr_id"/>
                            <input type="hidden" value="${chatRoomInfo.sellerId}" id="sellerId"/>
                            <input type="hidden" value="${chatRoomInfo.sellerName}" id="sellerName"/>                        
                            <input type="hidden" value="${chatRoomInfo.id}" id="id"/>                        
                        </div>                    
                    </div>                
                </div>
            </div>
    </div>
    
    <%-- STOMP와 sockjs webjars import --%>
    <script src="/webjars/stomp-websocket/2.3.3-1/stomp.js" type="text/javascript"></script>
    <script src="/webjars/sockjs-client/1.1.2/sockjs.js" type="text/javascript"></script>
    
    <script type="text/javascript">
    
        var stompClient = null;
        var buyerName = $('#buyerName').val();
        var buyerId = $('#buyerId').val();
        var pr_id = $('#pr_id').val();
        var sellerName = $('#sellerName').val();
        var sellerId = $('#sellerId').val();    
        var senderName = $('#buyerName').val();
        var id = $('#id').val();
        
        <%-- invoke when DOM(Documents Object Model; HTML(<head><body>...etc) is ready --%>
        $(document).ready(connect());
        
        function connect() {
            <%-- map URL using SockJS--%>
            var socket = new SockJS('/broadcast');
            var url = '/user/' + id + '/queue/messages';
            <%-- webSocket 대신 SockJS을 사용하므로 Stomp.client()가 아닌 Stomp.over()를 사용함--%>
            stompClient = Stomp.over(socket);
            
            <%-- connect(header, connectCallback(==연결에 성공하면 실행되는 메서드))--%>
            stompClient.connect({}, function() {
                <%-- url: 채팅방 참여자들에게 공유되는 경로--%>
                <%-- callback(function()): 클라이언트가 서버(Controller broker)로부터 메시지를 수신했을 때(== STOMP send()가 실행되었을 때) 실행 --%>
                stompClient.subscribe(url, function(output) {
                    <%-- JSP <body>에 append할 메시지 contents--%>
                    showBroadcastMessage(createTextNode(JSON.parse(output.body)));
                });
                }, 
                    <%-- connect() 에러 발생 시 실행--%>
                        function (err) {
                            alert('error' + err);
            });
 
        };
        
        <%-- WebSocket broker 경로로 JSON 타입 메시지데이터를 전송함 --%>
        function sendBroadcast(json) {
            
            stompClient.send("/app/broadcast", {}, JSON.stringify(json));
        }
        
        <%-- 보내기 버튼 클릭시 실행되는 메서드--%>
        function send() {
            var content = $('#message').val();
            sendBroadcast({
                'id': id,
                'buyerName': buyerName, 'content'content,
                'sellerName': sellerName,
                'buyerId': buyerId, 'sellerId': sellerId,
                'pr_id': pr_id,
                'senderName': senderName
                });
            $('#message').val("");
        }
        
        <%-- 메시지 입력 창에서 Enter키가 보내기와 연동되도록 설정 --%>
        var inputMessage = document.getElementById('message'); 
        inputMessage.addEventListener('keyup'function enterSend(event) {
            
            if (event.keyCode === null) {
                event.preventDefault();
            }
            
            if (event.keyCode === 13) {
                send();
            }
        });
        
        <%-- 입력한 메시지를 HTML 형태로 가공 --%>
        function createTextNode(messageObj) {
            console.log("createTextNode");
            console.log("messageObj: " + messageObj.content);
            return '<p><div class="row alert alert-info"><div class="col_8">' +
            messageObj.senderName +
            '</div><div class="col_4 text-right">' +
            messageObj.content+
            '</div><div>[' +
            messageObj.sendTime +
            ']</div></p>';
        }
        
        <%-- HTML 형태의 메시지를 화면에 출력해줌 --%>
        <%-- 해당되는 id 태그의 모든 하위 내용들을 message가 추가된 내용으로 갱신해줌 --%>
        function showBroadcastMessage(message) {
            $("#content").html($("#content").html() + message);
        }
        
 
    
    </script>
</body>
</html>
cs

 

Websocket broker URL '/broadcast'를 처리하는 Controller 코드입니다.

JSP 파일에서 Javascript sendBroadcast()로 전달한 JSON 형태의 String(JSON.stringfy() 사용)을 ChatRoom으로 bind하여 chatRoom id에 할당되어 있는 txt file을 찾아 추가된 내용을 저장시켜주는 Service.appendMessage() 메서드를 실행합니다. 

 

broker는 서버에서 보내온 메시지를 처리한 다음 socket 경로로 전달해주는 역할을 합니다. SimpMessagingTemplate은 특정 URL 또는 유저에게로 message를 전달하는 Spring 인터페이스 입니다. 따라서 convertAndSend가 전달해준 message는 JSP파일 Stomp.subscribe(callback)이 받고 callback 메서드가 실행됩니다. 그리고 View 화면에 출력되는 것이죠.

 

1
2
3
4
5
6
7
8
9
10
11
12
    
    @MessageMapping("/broadcast")
    public void send(ChatRoom chatRoom) throws IOException {
 
        chatRoom.setSendTime(TimeUtils.getCurrentTimeStamp());
        //append message to txtFile
        chatRoomService.appendMessage(chatRoom);
        
        int id = chatRoom.getId();
        String url = "/user/" + id + "/queue/messages";
        simpMessage.convertAndSend(url, new ChatRoom(chatRoom.getContent(), chatRoom.getSenderName(), chatRoom.getSendTime())); 
    }
cs

 

Ⅲ. 정리


이번 글은 H2 SQL DB 연결과 ChatApplication 테이블 설계를 알아보았고 상품정보 페이지에서 채팅 버튼을 클릭할 때 발생하는 Controller - Model - View까지 코드들을 읽어보았습니다. 특히 Spring 에서 File 클래스를 이용해 txt file을 생성하고 BufferedReader로 파일 텍스트를 불러서 View까지 전달하는 기능은 로컬 영역을 건드리는 새로운 경험을 해볼 수 있었습니다. 그리고 글로 정리하면서 이해가 완전하지 못했던 View STOMP -> Controller broker -> View STOMP 메시지 전달경로가 깔끔하게 머리에 정리되어 좋았습니다. 

 

다음 글은 Chat Application을 구현하며 얻은 개념들 정리와 회원정보 채팅하기 경로로 유입 시 실행되는 MVC 코드들을 읽어보며 마무리할 예정입니다. 

 

설 연휴인데 새해 복 많이 받으시고 2021년은 보다 많은 일들이 생각대로 이루어지길 바라겠습니다 :)