—
저번 시리즈
🔗 https://velog.io/@jinvicky/chat-project-1
🔗 https://velog.io/@jinvicky/chat-project-2
실제 DB 설계 및 기능에 관한 글이다.
디비 설계는 주로 액셀 시트로 설계한다. 관계, 제약조건 등을 맨 처음부터 너무 신경썼다가 막상 컬럼 설계에서 필수 정보를 놓치는 경우를 막기 위해서다.
채팅은 1:1 오픈카톡 채팅 목적으로 만들었고, 채팅 읽음 처리 기능을 위해서 부재중 메세지
테이블을 추가했다.
MyBatis에 MySQL을 사용했다.
📝 현재는 소규모의 단일 채팅 서버로 이루어져 있지만 이후의 확장성을 대비해서 최소한의 레디스 메시지 큐를 도입했다. 보통 레디스를 대규모 애플리케이션에서 채널 역할로서 다른 채팅 서버들에게 전달하는 것을 목적으로 하지만, 나는 채팅방별로 세션들 집합에 웹소켓으로 메시지를 전달하는 것을 목적으로 코드를 작성했다.
기존의 DB 없는 채팅방 포스팅 에서는 파일 업로드는 handleBinaryMessage
에서 처리했었다.
하지만 채팅 이력을 DB에 저장하기 위해서 필요한 사용자 정보, 채팅방 정보 등을 handleBinaryMessage
로부터 받아올 수 없었다.
WebSocketSession
은 백단의 세션이라서 프론트가 접근할 수 없으며, 프론트는 백엔드에게 소켓 메시지 외의 웹소켓 url에 파라미터를 추가하는 것만 할 수 있다.
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) {
따라서 handleTextMessage
가 처리하게끔 변경했다. (코드 일부)
이미지 외의 확장자 업로드를 대비해서 resourceType
을 받아서 업로드하도록 한다.
Back-End
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String jsonMsg = message.getPayload();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
ChatMsg chatMsg = objectMapper.readValue(jsonMsg, ChatMsg.class);
// 파일 채팅이면 스토리지에 업로드
if (chatMsg.getType().equals("F")) {
byte[] decodedBytes = Base64.getDecoder().decode(chatMsg.getEncodingFile());
CloudinaryResponse uploadResult = cloudinaryUtil.bytesUpload(decodedBytes, chatMsg.getResourceType());
chatMsg.setResourceType(uploadResult.getResourceType());
chatMsg.setPublicId(uploadResult.getPublicId());
chatMsg.setCloudName(cloudinaryUtil.getCloudName());
chatMsg.setFormat(uploadResult.getFormat());
}
chatService.insertChatMsg(chatMsg);
// redis에 채팅 메세지를 발행
redisTemplate.convertAndSend("chatRoom", jsonMsg);
}
기존에 byte[]
로 전송하던 로직을 인코딩으로 한번 더 감쌌다.
Front-End
const [files, setFiles] = useState<FileList | null>(null);
const arrayBufferToBase64 = (buffer: ArrayBuffer) => {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
// ...일부 생략....
if (files) {
for(const f of files) {
const buffer = await f.arrayBuffer();
const base64String = arrayBufferToBase64(buffer);
const msgFile = {
type: "F",
encodingFile: base64String,
chatRoomId: propChatRoomId,
senderEmail: "wkdu0723@naver.com",
resourceType: "image"
}
onSubmitBySocket(JSON.stringify(msgFile));
}
}
제일 고민이 많았던 부분인데 프론트에서 사용자가 현재 채팅방에서 focus 안 잡혔을 때 접속 여부 업데이트 api를 호출해주기로 했다.
채팅방id
+채팅방 사용자의 접속여부 컬럼
조건으로 방 안에 없는 사용자들 기준으로 부재중 메세지
테이블에 데이터를 넣었다. public int insertChatMsg(ChatMsg chatMsg) {
int mngNo = mapper.insertChatMsg(chatMsg);
log.info("mngNo : {}", mngNo);
// chatRoomId가 동일 + senderEmail과 다르면서 accessYn이 N인 사용자들에게 알림
List<ChatRoomUser> absentUserList = mapper.selectAbsentChatRoomUserList(chatMsg.getChatRoomId(), chatMsg.getSenderEmail());
if(absentUserList != null && !absentUserList.isEmpty()) {
// 알림
log.info("absentUserList : {}", absentUserList);
// 부재중 채팅 메세지에 저장
absentUserList.forEach(user -> {
ChatAbsentMsg absentMsg = ChatAbsentMsg.builder()
.chatRoomId(chatMsg.getChatRoomId())
.chatMsgMngNo(mngNo)
.readYn("N")
.rgtrDt(chatMsg.getRgtrTime())
.build();
absentMsg.setReceiverEmail(user.getUserEmail());
mapper.insertChatAbsentMsg(absentMsg);
});
}
// ...생략
}
Caused by: java.lang.IllegalStateException: Attribute 'jakarta.websocket.server.ServerContainer' not found in ServletContext
mapper를 테스트하려는데 에러가 발생한다. 애플리케이션은 동작하는데 junit 한정 오류이므로, 내가 모르는 애플리케이션 환경과 junit 테스트 환경에 차이가 있다고 판단해서 구글링.
(junit test failed jakarta.websocket.server.ServerContainer라고 검색해보자)
🔗 https://stackoverflow.com/questions/73575360/attribute-javax-websocket-server-servercontainer-not-found-in-servletcontext-w
테스트 클래스에 @WebAppConfiguration
어노테이션을 붙이면 말끔히 해결된다.
@WebAppConfiguration
@SpringBootTest
class ChatRoomApplicationTests {
useQuery를 사용하는데 loading
상태일 때 리턴값 분기처리를 하지 않으면 can not read of undefined~
에러가 났다. 쿼리에서 주는 loading
상태값을 내가 체크하지 않고 바로 data
에 접근했기 때문이다.
토스뱅크, 미리디 등 워너비 회사들의 기술 스택에 있는 것을 보고 학습 겸 도전했다.
tanstack-query
다. swr
과 비교하는 글이 많은데 결론적으로 swr
이 더 단순해서 소규모 프로젝트에 적합하다. useQuery
함수에 인자로 옵션 객체({queryKey, queryFn.... })
가 들어가도록 변경되었다. 기존에는 개별 인자였다. []
로 묶어서 처리할 수 있다. 하지만 내부에서 특정 쿼리만 특정 조건이 만족된 후에 실행하고 싶었다. 그래서enabled
속성에 상태값을 넣었음.const queries = useQueries({
queries: [
{
queryKey: ['chatRoomDetail', propsChatRoomId],
queryFn: fetchChatRoomDetail,
},
{
queryKey: ['chatMsgHistory', propsChatRoomId],
queryFn: fetchChatMsgHistory,
},
{
queryKey: ['updateUserAccess', propsChatRoomId],
queryFn: () => fetchUpdateChatRoomUserAccess({
mngId: "JVK",
chatRoomId: propsChatRoomId,
userEmail: "wkdu0723@naver.com",
accessYn: "Y"
}),
enabled: isSocketConnected, // WebSocket 연결 후에만 호출하도록 트리거 설정
},
],
});
백엔드는 회원 연동을 위해서 JWT를 다시 공부중이고, Front-End는 동료분께 부탁을 드렸다. 1월 내로 완성해서 https 사이트로 내놓는 것을 목표로 한다.
crud가 많고 너무 자잘한 시행착오는 포스팅에서 제외했습니다. 내용은 Git
🔗 Git https://github.com/jinvicky/cms-chat-prep
jinvicky
Front-End, Back-End Developer
✉️ Email: jinvicky@naver.com
💻 Github: https://github.com/jinvicky
wkdu0723
Front-End Developer
✉️ Email: wkdu0712@naver.com
💻 Github: https://github.com/wkdu0723