Rental Application (React & Spring boot Microservice) - 34 : 채팅(2)

yellow_note·2021년 10월 5일
0

#1 메시지 흐름

메시지 전송, 리스트 읽기 과정에서 sender, receiver에 혼동이 있을 수 있으므로 정리를 하고 모듈을 작성하도록 하겠습니다.

메시지는 sender, receiver를 기준으로 userList, chatList를 받아오도록 합니다. 여기서 sender는 송신자, receiver는 수신자인데 케이스를 살펴 보도록 하겠습니다.

채팅창으로 가기 위한 경로는 총 3가지입니다.

1) 게시글의 메시지 버튼
1-1) sender: 로그인 유저, receiver: 게시글의 writer
1-2) userList(sender), chatList(sender, receiver), send(sender, receiver)

2) 유저리스트의 item클릭
2-1) sender: 로그인 유저, receiver: 유저 리스트 item의 sender
2-2) userList(sender), chatList(sender, receiver), send(sender, receiver)

3) 마이페이지의 메시지함 버튼
2) ~

message-service는 이와 같은 흐름을 가지게 되므로 3가지 케이스를 바탕으로 모듈을 작성하도록 하겠습니다.

#2 리덕스 모듈

앞서 message-service를 수정하고 관련 UI를 만들어보았습니다. 그러면 이 프론트엔드와 백엔드를 연결해주기 위한 리덕스 모듈이 필요한데, 이 모듈을 만들어 보도록 하겠습니다.

우선 endpoint연결을 위한 chat파일을 만들도록 하겠습니다.

다음의 패키지를 설치하겠습니다.

npm install qs

qs라이브러리는 query string을 위한 라이브러리입니다. query string은 api 요청시 ?a=:a & b=:b 와 같은 파라미터입니다. 이 라이브러리를 이용하여 api endpoint를 만들도록 하겠습니다.

  • ./src/lib/api/chat.js
import client from './client';

export const send = ({
    sender,
    receiver,
    content
}) => client.post("/message-service/send", {
    sender,
    receiver,
    content
});

export const getUserList = receiver => client.get("/message-service/user-list", receiver);

export const getMessageList = ({
    sender,
    receiver
}) => client.get("/message-service/message-list", {
    sender,
    receiver
});
  • ./src/modules/send.js
import { createAction, handleActions } from "redux-actions";
import createRequestSaga, {
    createRequestActionTypes
} from "../lib/createRequestSaga";
import * as chatAPI from '../lib/api/chat';
import { takeLatest } from "redux-saga/effects";

const INITIALIZE = 'send/INITIALIZE';
const CHANGE_FIELD = 'send/CHANGE_FIELD';
const [
    SEND_CHAT,
    SEND_CHAT_SUCCESS,
    SEND_CHAT_FAILURE,
] = createRequestActionTypes('send/SEND_CHAT');

export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
    key,
    value
}));
export const sendChat = createAction(SEND_CHAT, ({
    sender,
    receiver,
    content
}) => ({
    sender,
    receiver,
    content
}));

const sendChatSaga = createRequestSaga(SEND_CHAT, chatAPI.send);
export function* sendSaga() {
    yield takeLatest(SEND_CHAT, sendChatSaga);
}

const initialState = {
    sender: '',
    receiver: '',
    content: '',
    chat: null,
    chatError: null
};

const send = handleActions(
    {
        [INITIALIZE]: state => initialState,
        [CHANGE_FIELD]: (state, { payload: { key, value }}) => ({
            ...state,
            [key]: value,
        }),
        [SEND_CHAT]: state => ({
            ...state,
            chat: null,
            chatError: null,
        }),
        [SEND_CHAT_SUCCESS]: (state, { payload: chat }) => ({
            ...state,
            chat,
        }),
        [SEND_CHAT_FAILURE]: (state, { payload: chatError }) => ({
            ...state,
            chatError,
        }),
    },
    initialState,
);

export default send;

채팅을 보내기 위한 모듈입니다. sender, receiver, content를 담은 액션을 앞서 작성한 api endpoint와 매칭시켜 POST 요청을 하도록 도와주는 모듈입니다.

이어서 messageList 모듈도 만들도록 하겠습니다.

  • ./src/modules/messageList.js
import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
    createRequestActionTypes,
} from '../lib/createRequestSaga';
import * as chatAPI from '../lib/api/chat';
import { takeLatest } from 'redux-saga/effects';

const CHANGE_FIELD = 'messages/CHANGE_FIELD';
const [
    READ_CHATLIST,
    READ_CHATLIST_SUCCESS,
    READ_CHATLIST_FAILURE,
] = createRequestActionTypes('messages/READ_CHATLIST');
const [
    READ_USERLIST,
    READ_USERLIST_SUCCESS,
    READ_USERLIST_FAILURE,
] = createRequestActionTypes('messages/READ_USERLIST');

export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
    key,
    value
}));
export const readChatList = createAction(READ_CHATLIST, ({
    receiver,
    sender
}) => ({
    receiver,
    sender,
}));
export const readUserList = createAction(READ_USERLIST, sender => sender);

const readChatListSaga = createRequestSaga(READ_CHATLIST, chatAPI.getMessageList);
const readUserListSaga = createRequestSaga(READ_USERLIST, chatAPI.getUserList);

export function* messageListSaga() {
    yield takeLatest(READ_CHATLIST, readChatListSaga);
    yield takeLatest(READ_USERLIST, readUserListSaga);
}

const initialState = {
    userList: null,
    chatList: null,
    error: null,
};

const messageList = handleActions(
    {
        [CHANGE_FIELD]: (state, { payload: { key, value }}) => ({
            ...state,
            [key]: value,
        }),
        [READ_CHATLIST_SUCCESS]: (state, { payload: chatList }) => ({
            ...state,
            chatList,
        }),
        [READ_CHATLIST_FAILURE]: (state, { payload: error }) => ({
            ...state,
            error
        }),
        [READ_USERLIST_SUCCESS]: (state, { payload: userList }) => ({
            ...state,
            userList,
        }),
        [READ_USERLIST_FAILURE]: (state, { payload: error }) => ({
            ...state,
            error,
        }),
    },
    initialState,
);

export default messageList;

messageList의 주요 state는 userList, chatList입니다. userList는 좌측 컴포넌트에 채팅룸을 만들어주기 위한 리스트이며, chatList는 채팅메시지들을 담은 리스트입니다.

모듈들을 만들었으니 리듀서에 모듈들을 담아주도록 하겠습니다.

  • ./src/modules/index.js
import { combineReducers } from "redux";
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import user, { userSaga } from "./user";
import write, { writeSaga } from "./write";
import post, { postSaga } from "./post";
import postList, { postListSaga } from "./postList";
import writeComment, { writeCommentSaga } from "./writeComment";
import send, { sendSaga } from './send';
import messageList, { messageListSaga } from "./messageList";
import loading from './loading';

const rootReducer = combineReducers(
    {
        loading,
        auth,
        user,
        write,
        post,
        postList,
        writeComment,
        send,
        messageList,
    },
);

export function* rootSaga() {
    yield all([
        authSaga(), 
        userSaga(), 
        writeSaga(), 
        postSaga(), 
        postListSaga(),
        writeCommentSaga(),
        sendSaga(),
        messageListSaga(),
    ]);
}

export default rootReducer;

리듀서에 담았으니 컴포넌트에서 이 모듈들을 호출하여 연동을 하도록 하겠습니다.

#3 모듈, 컴포넌트 연동

우선 포스트에서 메시지 아이콘 클릭 시, 채팅창으로 넘어가는 경우를 먼저 작성하도록 하겠습니다.

  • ./src/components/posts/PostViewer.js
...

    return(
        <PostViewerBlock>
            ...
                    <MessageArea>
                        <StyledShorcut path={ `/messages/${post.writer}` }
                                       src={ paper_plane_outline }
                        />
                    </MessageArea>
                    ...
        </PostViewerBlock>
    );
};

export default PostViewer;

writer는 해당 글의 작성자입니다. 즉, 메시지 아이콘을 클릭하게 되면 receiver는 writer가 되며 sender는 현재 로그인한 유저가 자리하게 됩니다.

  • ./src/components/message/SendForm.js
import React, { useEffect } from 'react';
import styled from 'styled-components';
import InputBar from './common/InputBar';
import InputButton from './common/InpuButton';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, sendChat } from '../../modules/send';
import { readChatList } from '../../modules/messageList';
import { withRouter } from 'react-router-dom';

...

const SendForm = ({ match }) => {
    const dispatch = useDispatch();
    const {
        nickname,
        sender,
        receiver,
        content,
    } = useSelector(({
        send,
        user
    }) => ({
        nickname: user.user.nickname,
        sender: send.sender,
        receiver: send.receiver,
        content: send.content
    }));

    const onChange = e => {
        e.preventDefault();

        const { value, name } = e.target;

        dispatch(changeField({
            key: name,
            value
        }));
    };

    const onSend = e => {
        e.preventDefault();

        dispatch(sendChat({
            receiver,
            sender,
            content,
        }));

        dispatch(readChatList({
            sender,
            receiver,
        }));
    };

    // 메시지 아이콘 클릭 후 sender = writer로 변경
    useEffect(() => {
        dispatch(changeField({
            key: 'sender',
            value: nickname
        }));
    }, [dispatch, nickname]);

    useEffect(() => {
        dispatch(changeField({
            key: 'receiver',
            value: match.params.writer
        }))
    }, [dispatch, match]);

    return (
        <SendContainer>
            <StyledInputBar name="content"
                            placeholder="메시지를 입력해주세요" 
                            onChange={ onChange } />
            <InputButton text="보내기"
                         onClick={ onSend } />            
        </SendContainer>
    );
};

export default withRouter(SendForm);

onSend 함수에 readChatList함수도 같이 넣어주었습니다. 그 이유는 메시지를 보내면 실시간으로 보낸 메시지도 보아야 하므로 sendChat함수, readChatList함수를 순차적으로 호출하도록 하였습니다.

이어서 좌측 컴포넌트의 채팅룸을 구현하도록 하겠습니다.

  • ./src/components/message/MessageListContainer.js
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import SearchBar from './common/InputBar';
import SearchButton from './common/InpuButton';
import MessageCard from './MessageCard';
import { useDispatch, useSelector } from 'react-redux';
import { readUserList } from '../../modules/messageList';
import { changeField } from '../../modules/send';
import { withRouter } from 'react-router-dom';
import palette from '../../lib/styles/palettes';
import { formatMs } from '@material-ui/core';

const Wrap = styled.div`
    float: left;
    padding-top: 150px;
    width: 30%;
    height: calc(100vh - 150px);
    overflow-x: hidden;
    overflow-y: auto;
    border-right: 1px solid ${palette.gray[3]};
`;

const Header  = styled.div`
    width: 80%;
    display: flex;
    align-items: center;
    justify-content: center;
    padding-left: 1.5rem;
`;

const ListBox = styled.div`
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
    padding-top: 20px;
    width: 100%;
`;

const EmptyBox = styled.div`
    width: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
`;

const MessageListContainer = () => {
    const dispatch = useDispatch();
    const {
        nickname,
        sender,
        userList, 
        error 
    } = useSelector(({
        messageList,
        send,
        user
    }) => ({
        nickname: user.user.nickname,
        sender: send.sender,
        userList: messageList.userList,
        error: messageList.error,
    }));

    useEffect(() => {
        dispatch(readUserList(sender));
    }, [dispatch, sender]);

    useEffect(() => {
        dispatch(changeField({
            key: 'sender',
            value: nickname
        }));
    }, [dispatch, nickname]);

    const onClick = e => {
        const item = e.target.innerText.split(' ');

        dispatch(changeField({
            key: 'receiver',
            value: item[0]
        }));
    };

    useEffect(() => {
        userList &&
        userList.forEach((user, index, array) => {
            for(var i = 0; i < userList.length; i++) {
                if(index === i) {
                    continue;
                } else if(
                    user.sender === userList[i].receiver &&
                    user.receiver === userList[i].sender
                ) {
                    userList.splice(i, 1);
                }
            }
        });
    }, [userList]);

    return(
        <Wrap>
            <Header>
                <SearchBar placeholder="닉네임을 입력해주세요" />
                <SearchButton text="검색" />            
            </Header>
            <ListBox>
                {
                    userList ?
                    userList.map((item, i) => {
                        return <MessageCard item={ item } 
                                            i={ i } 
                                            onClick={ onClick }
                               />
                    }) : 
                    <EmptyBox>
                        메시지함이 비어있습니다!
                    </EmptyBox>
                }
            </ListBox>
        </Wrap>
    );
};

export default withRouter(MessageListContainer);

onClick, userList 중복 제거에 대한 부분을 설명해야 할 것 같습니다.

1) onClick: e.target.innerText.split(' ')의 경우 영역 선택 시 해당 영역 내에 존재하는 텍스트를 공백 단위로 자르는 메서드입니다. 제 경우에는 영역 내에 '? 님과의 채팅'이라는 텍스트가 존재하므로 ?, 님과의, 채팅으로 배열의 요소를 갖게 되며 첫 번째 요소인 ?를 receiver state에 저장합니다. 즉 유저리스트 요소를 클릭하게 되면 선택한 유저의 nickname을 receiver에 저장하는 메서드입니다.

2) userList 중복 제거: 채팅을 보내면 sender와 receiver가 반대가 되는 경우가 있습니다. 즉, MessageRepsitory의 sql을 실행하면 sender: a, receiver:b / receiver: a, sender: b 이렇게 2가지가 한 리스트안에 담겨 오는데 이 중복 케이스를 제거하기 위해 아래의 코드를 이용했습니다.

useEffect(() => {
    userList &&
    userList.forEach((user, index, array) => {
        for(var i = 0; i < userList.length; i++) {
            if(index === i) {
                continue;
            } else if(
                user.sender === userList[i].receiver &&
                user.receiver === userList[i].sender
            ) {
                userList.splice(i, 1);
            }
        }
    });
}, [userList]);

forEach를 이용하여 리스트안의 요소를 탐색합니다. 한 요소를 기준으로 다시 한 번 for구문을 사용하여 첫 번째 조건문에서는 자기 자신을 제외하고, 두 번째 조건문에서 sender와 receiver가 서로 반대되는 경우를 찾아 리스트에서 제거하도록 합니다. 그리고 중복이 제거된 리스트를 map함수를 이용해서 MessageCard에 담아 유저에게 보여줍니다.

  • ./src/components/message/MessageCard.js
import React from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';

const Card = styled.div`
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 80px;
    &:hover {
        background-color: ${palette.gray[2]}
    }
    padding-top: 1rem;
    padding-left: 4rem;
    cursor: pointer;
`;

const Nickname = styled.div`
    width: 100%;
    height: 40px;
`;

const MessageCard = ({ 
    item, 
    i, 
    onClick,
}) => {
    const { nickname } = useSelector(({ user }) => ({ nickname: user.user.nickname }));
    
    return(
        <Card onClick={ onClick }>
            <Nickname>
                { item.receiver === nickname ? item.sender : item.receiver } 님과의 채팅 
            </Nickname>
        </Card>
    );
};

export default MessageCard;

현재 로그인한 유저의 값을 가진 데이터외의 다른 데이터를 선택하여 대화 상대의 닉네임이 유저에게 보여지게 됩니다.

마지막으로 우측 컴포넌트인 채팅창을 만들도록 하겠습니다.

  • ./src/components/message/RoomContainer.js
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { withRouter } from 'react-router-dom';
import styled from 'styled-components';
import { readChatList } from '../../modules/messageList';
import { changeField } from '../../modules/send';
import RoomTemplate from './RoomTemplate';
import SendForm from './SendForm';

const Wrap = styled.div`
    padding-top: 150px;
    width: 69%;
    height: 600px;
    overflow-x: hidden;
    overflow-y: auto;
`;

const RoomContainer = ({ match }) => {
    const dispatch = useDispatch();
    const {
        sender,
        receiver,
        chatList,
    } = useSelector(({ 
        send,
        messageList,  
        user,
    }) => ({
        sender: user.user.nickname,
        receiver: send.receiver,
        chatList: messageList.chatList
    }));

    useEffect(() => {
        dispatch(readChatList({
            sender,
            receiver,
        }));
    }, [dispatch, sender, receiver]);

    useEffect(() => {
        dispatch(changeField({
            key: 'sender',
            value: sender
        }));
    }, [dispatch, sender]);

    return(
        <Wrap>
            <RoomTemplate chatList={ chatList }/>
            <SendForm />
        </Wrap>
    );
};

export default withRouter(RoomContainer);

readChatList는 메시지 아이콘 클릭 시 선택된 receiver, 좌측 컴포넌트의 onClick 메서드를 이용해 선택된 receiver의 값 그리고 현재 로그인한 유저인 sender를 이용하여 이 둘과 관련된 모든 메시지를 가져오는 함수입니다. 이를 이용하여 가져온 chatList를 RoomTemplate 컴포넌트에 보냅니다.

  • ./src/components/message/RoomTemplate.js
import React from 'react';
import styled from 'styled-components';
import ChatCard from './ChatCard';

const Wrap = styled.div`
    width: 100%;
    height: 560px;
`;

const RoomTemplate = ({ chatList }) => {
    return(
        <Wrap>
            {
                chatList ?
                chatList.map((item, i) => {
                    return <ChatCard item={ item }/>
                }) :
                <></>
            }
        </Wrap>
    );
};

export default RoomTemplate;
  • ./src/components/message/ChatCard.js
import React from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';

const Card = styled.div`
    width: 100%;
`;

const SenderDate = styled.span`
    float: left;
    width: 98.5%;
    text-align: right;
`;

const ReceiverDate = styled.span`
    float: left;
    width: 100%;
    padding-left: 15px;
`;

const SenderCard = styled.div`
    float: right;
    display: inline-block;
    padding: 20px;
    margin: 10px;
    background-color: ${palette.blue[2]};
    color: white;
    border-radius: 10px;
`;

const ReceiverCard = styled.div`
    float: left;
    display: inline-block;
    padding: 20px;
    margin: 10px;
    background-color: ${palette.gray[2]};
    border-radius: 10px;
`;

const ChatCard = ({ item }) => {
    const { nickname } = useSelector(({ user }) =>  ({ nickname: user.user.nickname }));
    
    return(
        <>
            {
                item.sender === nickname ?     
                <Card>
                    <SenderCard>
                        { item.content }
                    </SenderCard>
                    <SenderDate>
                        { item.createdAt }
                    </SenderDate>
                </Card> :
                <Card>
                    <ReceiverCard>
                        { item.content }
                    </ReceiverCard>
                    <ReceiverDate>
                        { item.createdAt }
                    </ReceiverDate>
                </Card>
            }
        </>
    );
};

export default ChatCard;

sender가 현재 로그인한 유저라면 SenderCard 컴포넌트에 담도록 하며, 그게 아니라면 Receiver 컴포넌트에 담아 반대되는 경우에도 sender는 현재 로그인한 유저, 상대방은 receiver가 되어 서로 대화하는 형식이 됩니다. 그러면 테스트를 진행하도록 하겠습니다.

#4 테스트

1) 메시지 아이콘 클릭 후 채팅페이지로 이동

현재 로그인한 유저의 닉네임입니다.

asd의 게시물에 있는 아이콘을 클릭합니다.

asd와의 채팅 화면입니다.

현재 messages 테이블에 있는 채팅 기록들입니다. asd, bbb은 총 5가지의 채팅이 이루어졌으며 이 리스트들을 잘 가져오는 모습을 확인할 수 있습니다.

2) 메시지 보내기

asdasd 닉네임을 가진 유저로 로그인하여 메시지를 보내도록 하겠습니다.

메시지가 잘 보내지는 모습입니다.

3) 유저 리스트

앞서 2명의 유저가 asd닉네임을 가진 유저에게 메시지를 보냈습니다. 즉, 유저리스트에는 2명의 유저가 있어야겠죠. 잘 있는지 확인을 하겠습니다.

2명의 유저가 유저리스트에 있음이 확인 되었습니다. 그러면 유저 리스트를 클릭하여 채팅창이 호출되는지 확인하겠습니다.

4) 유저 리스트 클릭 시 채팅 리스트 호출
asdasd 유저와의 채팅창 화면

bbb와의 채팅창 화면

2명의 유저와의 채팅이 잘 호출이 되네요. 이렇게 message-service에 대한 구현도 마무리가 되었습니다. 다음 포스트에서는 대여 관련해서 rental-service를 구현하도록 하겠습니다.

0개의 댓글