목차
1. Socket.io에 대해
- Socket.io 특징
- Socket.io 그룹화 개념
- Socket.io 핵심 메소드
2. 프로젝트 기본 설정
3. Express 및 Socket 설정
4. 웹 프론트
- 웹 프론트 구현
- 웹 실시간 채팅 테스트
5. 네이티브 앱(AOS) 프론트
- 앱 프론트 구현
- 앱 실시간 채팅 테스트
6. 크로스 플랫폼(Flutter) 프론트
- 크로스 플랫폼 프론트 구현
- 크로스 플랫폼 실시간 채팅 테스트
이번 포스팅에서는 4번 웹 프론트에 대해서만 다룰 예정이다.
Socket.io를 활용한 서버 구축 방법이 궁금하다면 이전편을 참고하길 바란다.
https://aeeazip.tistory.com/58
[Node.js] Socket.io 실시간 채팅 서비스 - 서버 구축 (1)
목차1. Socket.io에 대해Socket.io 특징Socket.io 그룹화 개념Socket.io 핵심 메소드2. 프로젝트 기본 설정3. Express 및 Socket 설정 4. 웹 프론트웹 프론트 구현웹 실시간 채팅 테스트5. 앱(AOS) 프론트앱 프론트
aeeazip.tistory.com
4. 웹 프론트
1) 웹 프론트 구현
개발에 앞서 해당 프로젝트는 과제로써 짧은 시간(웹 프론트는 반나절.. 말이되냐 이게) 안에 구현해야했기 때문에 최대한 간단하되, 필수적으로 구현되어야 할 부분을 먼저 꼽아보았다.
1. 실시간 통신이 가능할 것
2. 나와 다른 사용자가 구분되는 UI로 제작할 것
위의 두 가지만 지켜지는 선에서 간단하게 제작한 작고 부끄러운 내 코드를 공개한다.
프론트 코드는 서버 코드와 같은 프로젝트 위에 존재하며 폴더 구조는 아래와 같다.
┌── node_modules
├── public // 프론트 소스
| ├── js
│ | ├── chat.js
│ | ├── chatRoom.js
│ | ├── createRoom.js
| | └── login.js
| ├── chat.html
| ├── chatRoom.html
| ├── createRoom.html
| ├── index.html
| └── style.css
├── src
│ ├── config // 설정 파일
│ ├── controller // API 요청 처리
│ ├── router // API 라우팅
│ ├── jsonServer.js // json server 설정
└───└── server.js // express 설정
├── .babelrc
├── .gitignore
├── db.json
├── index.js
├── nodemon.json
├── package.json
├── package-lock.json
└── README.md
이제 html 코드를 톺아보자.
나는 index.html에 로그인 관련 코드를 넣었다.
▼ public/index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>로그인</h1>
<form id="loginForm">
<label for="userId">사용자 ID:</label>
<input type="text" id="userId" name="userId" required>
<label for="userPwd">비밀번호:</label>
<input type="password" id="userPwd" name="userPwd" required>
<button type="submit">로그인</button>
</form>
<script src="js/login.js"></script>
</body>
</html>
▼ public/js/login.js
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('loginForm');
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
// 로그인 폼에서 입력한 사용자 ID와 비밀번호 가져오기
const userId = document.getElementById('userId').value;
const userPwd = document.getElementById('userPwd').value;
try {
// 로그인 API 호출
const response = await fetch('/member/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId, userPwd })
});
const result = await response.json();
// 로그인 성공 시 처리
if (result.respCode === 200) {
alert('로그인 성공!');
// 로그인 성공 시 userId를 로컬 스토리지에 저장
localStorage.setItem('userId', userId);
// 로그인 성공 후 채팅방 목록 페이지로 리디렉션
window.location.href = '/chatRoom.html'; // 채팅방 목록 페이지로 이동
} else {
// 로그인 실패 시 처리
alert('로그인 실패: ' + result.respMsg);
}
} catch (error) {
console.error('로그인 오류:', error);
alert('로그인 중 오류가 발생했습니다.');
}
});
});
사용자Id와 password를 입력받고 버튼을 클릭하면 login.js 이벤트 리스너가 동작하면서 서버로 로그인 요청을 보낸다.
이때 로그인에 성공하면 localStorage에 userId를 저장한다.
시간 여유가 있었다면 서버도 완성도 있게 만들고 싶었는데 도저히 주어진 시간내에는 역량 이슈로 불가능할 것 같아 토큰 처리는 배제했기 때문에 정석대로였다면 localStorage에 토큰을 저장하는게 맞으나,,
나는 userId를 저장했다.
아무튼 로그인에 성공하면 채팅방 목록 조회 페이지로 이동한다.
▼ public/chatRoom.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>채팅방 목록</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>채팅방 목록</h1>
<ul id="chatRoomList"></ul>
<button id="createRoomButton">채팅방 생성</button>
<script src="js/chatRoom.js"></script>
</body>
</html>
▼ public/js/chatRoom.js
document.addEventListener('DOMContentLoaded', async () => {
// 로그인된 userId를 로컬 스토리지에서 가져옴
const userId = localStorage.getItem('userId');
// userId가 없으면 로그인 페이지로 리디렉션
if (!userId) {
alert('로그인이 필요합니다.');
window.location.href = '/login.html';
return;
}
try {
// 채팅방 목록 조회 API 호출
const response = await fetch(`/chatRoom?userId=${userId}`);
const data = await response.json();
const chatRoomList = document.getElementById('chatRoomList');
// 채팅방 목록 조회 성공 시 처리
if (data.respCode === 200) {
chatRoomList.innerHTML = '';
data.data.forEach(room => {
// 각 채팅방을 리스트로 표시
const li = document.createElement('li');
li.textContent = `채팅방 ID: ${room.id}, 생성자: ${room.createUserId}`;
// 채팅방 클릭 시 입장
li.addEventListener('click', () => {
// 채팅방 입장 페이지로 리디렉션 (채팅방 ID 전달)
window.location.href = `chat.html?roomId=${room.id}&userId=${userId}`;
});
chatRoomList.appendChild(li);
});
} else {
chatRoomList.innerHTML = '채팅방을 불러오는 데 실패했습니다.';
}
} catch (error) {
console.error('채팅방 목록 조회 오류:', error);
document.getElementById('chatRoomList').innerHTML = '채팅방을 불러오는 중 오류가 발생했습니다.';
}
});
// 채팅방 생성 버튼 클릭 시 처리
document.getElementById('createRoomButton').addEventListener('click', () => {
window.location.href = 'createRoom.html'; // 채팅방 생성 페이지로 이동
});
채팅방 목록 화면에서는 아래와 같은 기능을 제공한다.
① 사용자가 포함된 채팅방 목록을 불러와 화면에 보여줌
② 채팅방 생성 버튼을 클릭해 새로운 채팅방 생성
chatRoom.html이 화면에 렌더링될 때 서버로 채팅방 목록을 조회 API 요청을 보내고
채팅방 목록 조회 성공했을 때 목록 중 하나를 선택하면 채팅방 입장 페이지로 리디렉션된다.
▼ public/createRoom.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>채팅방 생성</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>채팅방 생성</h1>
<form id="createRoomForm">
<label for="createUserId">생성자 ID:</label>
<input type="text" id="createUserId" name="createUserId" required>
<label for="inviteList">초대할 사용자 (형식: dataType:dataCode, 쉼표로 구분):</label>
<input type="text" id="inviteList" name="inviteList" required>
<button type="submit">채팅방 생성</button>
</form>
<script src="js/createRoom.js"></script>
</body>
</html>
▼ public/js/createRoom.js
document.addEventListener('DOMContentLoaded', () => {
const createRoomForm = document.getElementById('createRoomForm');
createRoomForm.addEventListener('submit', async (event) => {
event.preventDefault();
const createUserId = document.getElementById('createUserId').value;
const inviteListInput = document.getElementById('inviteList').value;
// 초대할 사용자 목록 파싱 (예: 'type1:code1,type2:code2' 형태로 입력받음)
const inviteList = inviteListInput.split(',').map(pair => {
const [dataType, dataCode] = pair.split(':').map(item => item.trim());
return { dataType, dataCode };
});
// 요청 바디 생성
const requestBody = {
createUserId,
list: inviteList
};
try {
const response = await fetch('/chatRoom', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
const result = await response.json();
if (result.respCode === 200) {
alert('채팅방이 생성되었습니다!');
// 채팅방 생성 후 리디렉션
window.location.href = '/chatRoom.html'; // 채팅방 목록 페이지로 이동
} else {
alert('채팅방 생성 실패: ' + result.respMsg);
}
} catch (error) {
console.error('채팅방 생성 오류:', error);
alert('채팅방 생성 중 오류가 발생했습니다.');
}
});
});
채팅방 목록 조회 화면에서 채팅방 생성 버튼을 클릭하면 createRoom.html 페이지로 이동한다.
생성자 ID 즉, 현재 로그인한 사용자의 ID를 입력하고 초대할 사용자들의 정보를 입력한다.
나는 초대할 사용자들의 정보를 dataType:dataCode의 쌍으로 입력받는데
필드 | 값 |
dataType | U(일반 유저), O(부서원 전체) |
dataCode | dataType이 U일때 : 사용자ID dataType이 O일때 : 부서 코드 |
단순히 특정 유저만을 초대하는게 아니라 부서를 선택해 특정 부서 내의 부서원들을 전부 초대할 수 있게 dataType과 dataCode를 위와 같이 정의했다.
다음은 진짜 진짜 진짜 채팅 수신 및 송신!
▼ public/chat.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>채팅방</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>채팅방</h1>
<div id="chatWindow" class="chat-window">
<ul id="messageList"></ul>
</div>
<form id="messageForm">
<input type="text" id="messageInput" placeholder="메시지를 입력하세요" autocomplete="off" required>
<button type="submit">보내기</button>
</form>
<script src="/socket.io/socket.io.js"></script>
<script src="js/chat.js"></script>
</body>
</html>
채팅 서버를 socket.io를 활용해 구현했기 때문에 html 코드 밑에서 4번째 줄에서 소켓을 임포트해주고 있다.
<script src="/socket.io/socket.io.js"></script>
▼ public/js/chat.js
document.addEventListener('DOMContentLoaded', () => {
const userId = localStorage.getItem('userId'); // 현재 사용자 ID
const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get('roomId'); // 현재 채팅방 ID
if (!userId || !roomId) {
alert('유효한 사용자 ID 또는 채팅방 ID가 없습니다.');
return;
}
// 소켓 연결 설정
const socket = io('/chat'); // '/chat' 네임스페이스로 소켓 연결
// 채팅방에 입장
socket.emit('joinRoom', { userId, roomId });
// 메시지 폼 처리
const messageForm = document.getElementById('messageForm');
const messageInput = document.getElementById('messageInput');
const messageList = document.getElementById('messageList');
messageForm.addEventListener('submit', async (event) => {
event.preventDefault();
const message = messageInput.value.trim();
if (message) {
// 서버에 메시지 전송
try {
const response = await fetch(`/chatRoom/${roomId}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sendUserId: userId,
cRoomId: roomId,
msgType: 'message',
msgContent: message
})
});
if (!response.ok) {
throw new Error('메시지 전송 실패');
}
const result = await response.json();
console.log('채팅 result : ' + result);
if (result.respCode !== 200) {
throw new Error(result.respMsg || '메시지 전송 오류');
}
// 입력창 초기화
messageInput.value = '';
messageInput.focus();
} catch (error) {
console.error('메시지 전송 오류:', error);
alert('메시지 전송 중 오류가 발생했습니다.');
}
}
});
socket.on('newChat', (data) => {
console.log('Received chat message:', data); // 수신된 메시지 로그
const { sendUserId, msgContent, sendTime } = data;
const li = document.createElement('li');
li.className = `message ${sendUserId === userId ? 'user' : 'other'}`;
li.innerHTML = `<strong>${sendUserId}</strong> (${new Date(sendTime).toLocaleTimeString()}): ${msgContent}`;
messageList.appendChild(li);
// 스크롤을 최신 메시지로 이동
messageList.scrollTop = messageList.scrollHeight;
});
// 채팅방 나가기 처리 (페이지를 벗어날 때)
window.addEventListener('beforeunload', () => {
socket.emit('leaveRoom', { userId, roomId });
});
});
현재 프론트에서 서버 API 이외에 웹소켓 이벤트를 발행하거나 구독해야 하는 경우는
(1) 채팅방 입장 이벤트 발행 (emit)
(2) 새로운 채팅 메시지 이벤트 구독 (on)
(3) 채팅방 퇴장 이벤트 발행 (emit)
위의 3가지 경우 뿐이다. (나머지는 서버에서 처리함)
① 채팅방 입장 이벤트 발행 (emit)
/chat 네임스페이스로 소켓을 연결해주고, joinRoom 이벤트를 발행한다.
이름은 반드시 joinRoom이여야하며, userId, roomId를 data로 보내줘야 한다.
사용자가 채팅방 목록 중 하나를 선택해서 특정 채팅방에 넣어줘야 하는 경우 해당 이벤트를 발생시킨다.
▼ 관련 코드
// 소켓 연결 설정
const socket = io('/chat'); // '/chat' 네임스페이스로 소켓 연결
// 채팅방에 입장
socket.emit('joinRoom', { userId, roomId });
② 새로운 채팅 메시지 이벤트 구독 (on)
서버에는 채팅 내용을 조회하는 API가 없다.
채팅 내용은 웹소켓으로 계속해서 구독받는 형식이기 때문이다.
따라서 ①과 마찬가지로 /chat 네임스페이스로 소켓을 연결해주고, newChat 이벤트를 구독한다. 이름은 반드시 newChat이여야하며, 서버에서 발행한 이벤트 반환값으로 sendUserId, msgContent, sendTime을 보내준다.
프론트에서는 이를 활용해 화면에 채팅 메시지를 보여줘야 한다.
▼ 관련 코드
socket.on('newChat', (data) => {
console.log('Received chat message:', data); // 수신된 메시지 로그
const { sendUserId, msgContent, sendTime } = data;
const li = document.createElement('li');
li.className = `message ${sendUserId === userId ? 'user' : 'other'}`;
li.innerHTML = `<strong>${sendUserId}</strong> (${new Date(sendTime).toLocaleTimeString()}): ${msgContent}`;
messageList.appendChild(li);
// 스크롤을 최신 메시지로 이동
messageList.scrollTop = messageList.scrollHeight;
});
③ 채팅방 퇴장 이벤트 발행 (emit)
①과 마찬가지로 /chat 네임스페이스로 소켓을 연결해주고, leaveRoom 이벤트를 발행한다.
이벤트명은 leaveRoom이여야만하며, userId, roomId를 data로 보내줘야 한다.
사용자가 방을 나가거나 뒤로 가기를 눌러 채팅방 목록 조회 화면으로 돌아간 경우 해당 이벤트를 발행한다.
▼ 관련 코드
// 채팅방 나가기 처리 (페이지를 벗어날 때)
window.addEventListener('beforeunload', () => {
socket.emit('leaveRoom', { userId, roomId });
});
2) 웹 실시간 채팅 테스트
채팅방에 입장했을 때 처음엔 polling 방식으로 통신을 시도하지만
websocket이 되는 환경일 때 transport를 websocket으로 변경해 통신에 성공한 모습을 확인할 수 있다!