서버는 Spring Boot로, 프론트엔드는 React로 구현하면서
WebSocket을 활용하여 간단한 채팅과 알림 기능을 만들어본다.
데이터베이스 삽입은 REST API로 수행하고, 메시지를 받은 사용자에게는 WebSocket을 통해 즉시 NEW_MESSAGE 알림을 전송한다. 수신자 화면에서는 해당 알림을 기반으로 목록을 다시 조회하여 최신 메시지를 표시하는 방식으로 동작시킨다.
WebSocket은 매우 가벼운 프로토콜로서 커넥션 유지와 양방향 메시지 송수신을 지원하는 역할을 수행한다.
그러나 카카오톡, 인스타그램 DM, 슬랙 DM과 같은 실제 채팅 서비스에서는 메시지 라우팅, 브로드캐스팅, 분산 처리, 구독/토픽/큐잉과 같은 부가 기능을 추가하여 완성도를 높인다. 따라서 실무에서는 WebSocket을 단독으로 사용하기보다는 WebSocket을 단말 입출력 채널로만 활용하고, 메시지 라우팅과 팬아웃(fan-out)은 Redis나 Kafka 같은 메시지 브로커가 담당하는 형태로 구현하는 것이 일반적이다.
이번 포스팅에서는 WebSocket의 동작 흐름을 파악하기 위한 간단한 예제이므로 WebSocket만을 사용하여 구현하였다.
HTTP는 요청 1회에 응답 1회로 끝나는 request-response 모델이다.
(요청 1번 → 응답 1번 → 끝) 이라는 이벤트 단위의 왕복 패턴 으로 동작하므로 transactional하게 느껴진다.
WebSocket은 HTTP 위에서 시작된다.
첫 요청에서 Upgrade 협상에 성공하는 순간부터 WebSocket 프로토콜로 전환된다.
그 이후에는 하나의 TCP 연결을 오래 유지한 채 서버와 클라이언트가 서로 먼저 메시지를 보낼 수 있는 양방향 스트림이 형성된다.
그러면 서버와 클라이언트 모두가 언제든지 먼저 말할 수 있는 상태가 된다.
클라이언트는 일반 HTTP(S) 요청으로 Connection: Upgrade, Upgrade: websocket 헤더와 함께 핸드셰이크를 보낸다. 또한 Sec-WebSocket-Key/Sec-WebSocket-Version 등 표준 헤더로 상호 검증을 수행한다.
서버가 이를 수락하면 101 Switching Protocols 응답을 보내고, 그 순간부터 동일한 TCP 소켓이 HTTP가 아닌 WebSocket 프로토콜로 전환된다. 이후에는 요청/응답 개념이 아니라, 프레임 단위의 전이중 통신이 시작된다.
WebSocket은 프레임이라는 최소 단위로 데이터를 교환한다. 각 프레임에는 opcode(텍스트/바이너리/핑/퐁/종료 등), 길이, 마스킹 정보가 포함된다.
텍스트 프레임은 UTF-8 문자열을, 바이너리 프레임은 임의의 바이트 스트림을 담는다. 한 메시지가 여러 프레임으로 분할(fragmentation)되어 전송될 수 있으며, 마지막 프레임은 FIN 비트로 종료를 표시한다.
브라우저 클라이언트에서 서버로 가는 프레임은 마스킹(masking)이 필수이며, 서버에서 클라이언트로 가는 프레임은 비마스킹이 표준이다.
TCP 연결을 유지하기 때문에 네트워크 단절, 방화벽 타임아웃, 모바일 네트워크 전환 등에 대비하여 ping/pong 하트비트를 운영한다.
하트비트 실패 시 클라이언트는 재연결(backoff 포함) 전략을 가져야 한다. 예를 들어 1초 → 2초 → 5초 식으로 재연결 간격을 늘려가는 방식을 사용한다.
WebSocket 자체는 "파이프"에 불과하다. 그 위에 STOMP/MQTT 등의 서브프로토콜을 얹으면 토픽, 구독, ack 같은 메시지 의미론을 표준화할 수 있다.
Spring에서는 STOMP를 많이 사용하며, 라우팅과 구독 모델을 쉽게 구성할 수 있다. 원시 WebSocket은 자유도가 높은 대신, 메시지 스키마, 에러 및 재전송 규약 등을 직접 정의해야 한다.
WebSocket은 장수 연결이기 때문에 L4/L7 장비가 연결을 오래 유지해야 한다.
수평 확장 시에는 스티키 세션(cookie/IP hash) 또는 외부 브로커(Redis Pub/Sub, Kafka 등)로 노드 간 팬아웃을 보장해야 한다. 단일 서버 메모리에 "userId → session"을 두면 스케일 아웃 시 곧 한계에 도달한다.
브라우저에서는 WSS(SSL/TLS)가 필수다.
인증은 초기 핸드셰이크 시점(쿠키/헤더/쿼리 토큰)과 연결 이후 재검증(예: 토큰 만료 시 재인증) 전략을 함께 설계한다.
Origin 검증, CORS 정책, 메시지 페이로드 유효성 검사(스키마/사이즈 제한/속도 제한)로 남용 및 오용 방지가 필요하다.
11g 는 IDENTITY 없으므로 sequence + trigger 방식으로 가야 한다.
CREATE TABLE USER_TABLE (
id NUMBER PRIMARY KEY,
name VARCHAR2(50)
);
-- 시퀀스
CREATE SEQUENCE user_seq START WITH 1 INCREMENT BY 1;
-- 트리거
CREATE OR REPLACE TRIGGER user_trg
BEFORE INSERT ON USER_TABLE
FOR EACH ROW
BEGIN
SELECT user_seq.NEXTVAL INTO :NEW.id FROM dual;
END;
CREATE TABLE MESSAGE (
id NUMBER PRIMARY KEY,
sender_id NUMBER,
receiver_id NUMBER,
content VARCHAR2(500),
created_at DATE DEFAULT SYSDATE,
read_yn CHAR(1) DEFAULT 'N'
);
-- 시퀀스
CREATE SEQUENCE message_seq START WITH 1 INCREMENT BY 1;
-- 트리거
CREATE OR REPLACE TRIGGER message_trg
BEFORE INSERT ON MESSAGE
FOR EACH ROW
BEGIN
SELECT message_seq.NEXTVAL INTO :NEW.id FROM dual;
END;
이 프로젝트의 서버는 크게 REST API와 WebSocket Handler 두 계층으로 구성된다.
| 계층 | 역할 |
|---|---|
| REST API | 메시지 저장, 조회, 읽음 처리 (HTTP 요청 기반) |
| WebSocket Handler | 특정 userId에게 실시간 알림 푸시 |
메시지 전송은 HTTP로 DB에 저장하고,
상대방에게 NEW_MESSAGE 알림을 보낸다는 시그널만 WebSocket으로 전송한다.
이 구조는 카카오톡, 인스타그램 등에서 사용하는 REST + WebSocket 하이브리드 모델의 기본형이다.
React(5173 포트)와 Spring Boot(8090 포트) 간 요청을 허용하기 위해 CORS와 보안 설정을 구성한다.
프론트엔드에서 서버로 요청을 보낼 수 있도록 허용 Origin을 설정한다.
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173", "http://192.168.2.29:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
};
}
}
설정 내용:
addMapping("/**"): 모든 경로에 대해 CORS 허용allowedOrigins: React 개발 서버 주소 허용allowedMethods: 허용할 HTTP 메서드 지정allowCredentials(true): 쿠키 등 인증 정보 허용Spring Security의 CSRF 보호를 비활성화하고 모든 요청을 허용한다.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
}
설정 이유:
csrf().disable(): REST API와 WebSocket 사용 시 CSRF 토큰 불필요anyRequest().permitAll(): 간단한 예제이므로 모든 요청 허용 (실무에서는 인증/인가 필수)/ws 엔드포인트로 들어오는 WebSocket 연결을 WSHandler에 매핑한다.
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final WSHandler wsHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(wsHandler, "/ws")
.setAllowedOrigins("http://localhost:5173");
}
}
핵심 포인트:
@EnableWebSocket: WebSocket 기능 활성화addHandler(wsHandler, "/ws"): /ws 경로로 들어오는 연결을 WSHandler가 처리setAllowedOrigins: 프론트엔드 Origin 허용연결 예시:
ws://192.168.2.29:8090/ws?userId=3
클라이언트는 이 주소로 WebSocket 연결을 생성하고, 쿼리 파라미터로 자신의 userId를 전달한다.
핵심 아이디어는 userId → session 매핑이다. 연결 시 ?userId=1 같은 쿼리로 넘어온 사용자 식별값을 Map에 저장해두고, 특정 userId에게 notify()로 직접 메시지를 push할 수 있게 한다.
WebSocketHandler 는 스프링 WebSocket 핵심 인터페이스 이름이라서 사용할 수 없다.
WSHandler 는 이걸 직접 구현한 TextWebSocketHandler 의 서브클래스이다.
멀티스레드 환경에서 안전하게 세션을 관리하기 위해 ConcurrentHashMap을 사용한다.
Multi-Thread 환경에서 사용할 수 있도록 나온 ConcurrentHashMap은
읽기 작업에는 여러 쓰레드가 동시에 읽을 수 있지만,
쓰기 작업에는 특정 세그먼트 or 버킷에 대한 Lock을 사용한다.
따라서 여러 요청이 동시에 들어와도 데이터 정합성이 보장된다.
public abstract class TextWebSocketHandler implements WebSocketHandler {
...
}
@Component
public class WSHandler extends TextWebSocketHandler {
// userId → (sessionId → WebSocketSession) 매핑
private final Map<Long, Map<String, WebSocketSession>> sessionsByUser = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
Long userId = extractUserId(session);
if (userId == null) {
session.close(CloseStatus.BAD_DATA);
return;
}
// 동일 유저의 여러 세션(다중 기기 로그인) 지원
sessionsByUser.computeIfAbsent(userId, k -> new ConcurrentHashMap<>())
.put(session.getId(), session);
System.out.println("WS connected user=" + userId + ", sessionId=" + session.getId());
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
Long userId = extractUserId(session);
if (userId != null) {
Map<String, WebSocketSession> sessions = sessionsByUser.get(userId);
if (sessions != null) {
sessions.remove(session.getId());
if (sessions.isEmpty()) {
sessionsByUser.remove(userId);
}
}
}
System.out.println("WS disconnected user=" + userId);
}
// 쿼리 파라미터에서 userId 추출
private Long extractUserId(WebSocketSession session) {
String query = session.getUri().getQuery();
if (query != null && query.startsWith("userId=")) {
try {
return Long.parseLong(query.substring(7));
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
// 특정 userId에게 메시지 푸시 (핵심 메서드)
public void notify(Long userId, String text) throws IOException {
Map<String, WebSocketSession> sessions = sessionsByUser.get(userId);
if (sessions == null) return;
for (WebSocketSession s : sessions.values()) {
if (s.isOpen()) {
s.sendMessage(new TextMessage(text));
}
}
}
// 읽음 처리 알림
public void notifyRead(Long userId, Long messageId) throws IOException {
notify(userId, "READ_MESSAGE:" + messageId);
}
}
다중 세션 관리 구조를 통해 한 사용자가 여러 기기(PC, 모바일 등)에서 동시 로그인했을 때 모든 기기에 알림을 보낼 수 있다.
notify()가 시스템의 가장 핵심 메서드다. 특정 userId를 받아서 해당 유저의 모든 활성 세션에 메시지를 전송한다.
메시지 전송은 WebSocket이 아니라 REST API로 처리한다. DB에 insert가 완료된 뒤, 수신자에게 WebSocket으로 "NEW_MESSAGE" 알림을 보낸다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/message")
public class MessageController {
private final MessageService messageService;
// 메시지 전송
@PostMapping("/send")
public ResponseEntity<Void> send(@RequestBody Message message) {
messageService.send(message);
return ResponseEntity.ok().build();
}
// 특정 두 사용자 간 메시지 목록 조회
@GetMapping("/room")
public ResponseEntity<List<Message>> getRoomMessages(
@RequestParam("senderId") Long senderId,
@RequestParam("receiverId") Long receiverId) {
List<Message> messages = messageService.getMessages(senderId, receiverId);
return ResponseEntity.ok(messages);
}
// 메시지 읽음 처리
@PostMapping("/read/{id}")
public ResponseEntity<Void> markAsRead(@PathVariable("id") Long id) throws Exception {
messageService.readMessage(id);
return ResponseEntity.ok().build();
}
}
1. POST /message/send
{
"senderId": 1,
"receiverId": 5,
"content": "안녕하세요"
}
"NEW_MESSAGE:1" 알림 전송2. GET /message/room?senderId=1&receiverId=5
3. POST /message/read/123
"READ_MESSAGE:123" 알림 전송 (읽음 표시)비즈니스 로직과 WebSocket 알림을 담당한다.
@Service
@RequiredArgsConstructor
public class MessageService {
private final MessageMapper mapper;
private final WSHandler wsHandler;
@Transactional
public void send(Message message) {
// 1. DB에 메시지 저장
mapper.insertMessage(message);
// 2. 수신자에게 실시간 알림 전송
try {
wsHandler.notify(
message.getReceiverId(),
"NEW_MESSAGE:" + message.getSenderId()
);
} catch (Exception e) {
e.printStackTrace();
// WebSocket 전송 실패해도 DB 저장은 성공한 상태
// 수신자가 다음 접속 시 REST API로 메시지 확인 가능
}
}
public List<Message> getMessages(Long senderId, Long receiverId) {
return mapper.select(senderId, receiverId);
}
@Transactional
public void readMessage(Long id) throws Exception {
// 1. 읽음 상태로 업데이트
mapper.updateRead(id);
// 2. 발신자에게 읽음 알림 전송
Long senderId = mapper.findSenderIdByMessageId(id);
if (senderId != null) {
wsHandler.notifyRead(senderId, id);
}
}
}
만약 WebSocket 전송이 실패해도 DB 저장은 성공한 상태이므로, 수신자가 다음에 REST API로 조회하면 메시지를 확인할 수 있다.
readMessage() 메서드에서는 메시지를 읽으면
1. DB에서 read_yn을 'Y'로 업데이트
2. 발신자에게 "READ_MESSAGE:메시지ID" 알림 전송 (카카오톡의 "1" 표시 사라지는 기능과 동일)
@Mapper
public interface MessageMapper {
void insertMessage(Message message);
List<Message> select(@Param("senderId") Long senderId,
@Param("receiverId") Long receiverId);
void updateRead(Long id);
Long findSenderIdByMessageId(Long id);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.MessageMapper">
<!-- 메시지 삽입 -->
<insert id="insertMessage" parameterType="com.example.demo.domain.Message">
INSERT INTO MESSAGE (sender_id, receiver_id, content, created_at, read_yn)
VALUES (#{senderId}, #{receiverId}, #{content}, SYSDATE, 'N')
</insert>
<!-- 두 사용자 간 메시지 조회 -->
<select id="select" resultType="com.example.demo.domain.Message">
SELECT
id,
sender_id AS senderId,
receiver_id AS receiverId,
content,
created_at AS createdAt,
read_yn AS readYn
FROM MESSAGE
WHERE (sender_id = #{senderId} AND receiver_id = #{receiverId})
OR (sender_id = #{receiverId} AND receiver_id = #{senderId})
ORDER BY created_at ASC
</select>
<!-- 읽음 처리 -->
<update id="updateRead">
UPDATE MESSAGE
SET read_yn = 'Y'
WHERE id = #{id}
</update>
<!-- 메시지 ID로 발신자 조회 -->
<select id="findSenderIdByMessageId" resultType="Long">
SELECT sender_id
FROM MESSAGE
WHERE id = #{id}
</select>
</mapper>
1. insertMessage
SYSDATE: Oracle의 현재 시각 함수read_yn을 'N'으로 초기화 (미읽음 상태)2. select
WHERE (sender_id = #{senderId} AND receiver_id = #{receiverId})
OR (sender_id = #{receiverId} AND receiver_id = #{senderId})
양방향 메시지 조회: A→B 메시지와 B→A 메시지를 모두 가져온다. 이것이 "1:1 대화방"의 핵심이다.
3. updateRead
메시지를 읽으면 read_yn을 'Y'로 변경한다. 이를 통해 "읽음/안읽음" 상태를 추적할 수 있다.
@Data
public class Message {
private Long id;
private Long senderId;
private Long receiverId;
private String content;
private LocalDateTime createdAt;
private String readYn;
}
| 계층 | 파일 / 클래스 | 역할 |
|---|---|---|
| WebSocket Layer | WSHandler | userId → session 저장, notify()로 실시간 push |
| WebSocket Layer | WebSocketConfig | /ws 엔드포인트 등록 |
| REST API Layer | MessageController | 메시지 insert, 목록 조회, 읽음 처리 API |
| Service Layer | MessageService | 비즈니스 로직, WebSocket 알림 호출 |
| Repository Layer | MessageMapper | DB insert 및 조회 수행 |
| Config | CorsConfig, SecurityConfig | CORS 및 보안 설정 |
| 역할 | 담당 계층 | 데이터 크기 | 트랜잭션 | 재시도 가능 |
|---|---|---|---|---|
| 메시지 저장/조회 | REST (Controller → Service → Mapper) | 크다 | O | O |
| 실시간 알림 푸시 | WebSocket (WSHandler) | 매우 작다 | X | X |
REST는 데이터의 신뢰성을, WebSocket은 실시간성을 담당하는 구조다.
1. 클라이언트 → POST /message/send (REST)
2. MessageController → MessageService.send()
3. MessageMapper.insertMessage() → DB 저장
4. WSHandler.notify(receiverId, "NEW_MESSAGE:senderId")
5. 수신자 브라우저 WebSocket 수신
6. 수신자 → GET /message/room (REST) → 최신 목록 조회
이렇게 분리하면 메시지 검색, 페이징, 읽음 처리, 재연결 시 동기화, 다중 로그인 등 기능 확장이 깔끔하게 이루어진다.
이 구조는 카카오톡, 인스타그램, 슬랙 등 실제 채팅 서비스의 기본 아키텍처와 동일하다.
프론트엔드는 핵심적으로 3개의 역할로 분리된다.
| 파일 | 역할 |
|---|---|
| ws.js | WebSocket 연결 생성 / onMessage 핸들링 |
| Home.jsx | 알림 수신 화면 → NEW_MESSAGE 수신 시 카운트 증가 |
| ChatRoom.jsx | 실제 1:1 채팅 화면 / 메시지 목록 표시 / 전송 |
중요한 포인트는 메시지 본문을 WebSocket으로 주고받지 않는다는 것이다. WebSocket은 "신호(trigger)" 용도로만 사용하고, 실제 메시지 내용은 REST API로 받아온다.
프론트엔드에서는 userId를 localStorage에 저장하고, 로그인된 상태에서 WebSocket 연결을 생성한다.
앱이 실행되는 동안 WebSocket 연결 객체를 1개만 유지하는 싱글톤 패턴으로 되어있다.
(= 필요할 때마다 새로 connect 하지 않는다)
// src/ws.js
export function connectWS(myId, onMessage) {
const ws = new WebSocket(`ws://192.168.2.29:8090/ws?userId=${myId}`);
ws.onopen = () => console.log("WS connected");
ws.onmessage = (e) => {
console.log("WS msg:", e.data);
if(onMessage) onMessage(e.data);
};
ws.onclose = () => console.log("WS closed");
return ws;
}
?userId=${myId} 형태로 서버에 자신의 ID를 알린다.onMessage 콜백을 외부에서 주입받아 각 화면에서 자기 용도에 맞게 메시지를 처리할 수 있다.ws.close()로 연결을 정리할 수 있다.메시지 전송과 조회를 담당하는 API 함수를 분리한다.
// src/api/messageAPI.js
const BASE = "http://192.168.2.29:8090/message";
export async function sendMessage(message){
return fetch(`${BASE}/send`,{
method:"POST",
headers:{
"Content-Type":"application/json"
},
body: JSON.stringify(message)
})
}
export async function getRoomMessages(senderId, receiverId){
const r = await fetch(`${BASE}/room?senderId=${senderId}&receiverId=${receiverId}`);
return r.json();
}
API를 분리함으로써 화면 로직이 단순해지고, API 엔드포인트 변경 시 이 파일만 수정하면 된다.
Home은 채팅 내용을 보여주지 않고 알림 박스만 관리한다.
// src/pages/Home.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { connectWS } from "../ws";
export default function Home(){
const nav = useNavigate();
const myId = Number(localStorage.getItem("userId"));
const [notiMap, setNotiMap] = useState({});
// 예: { "2": 3, "5": 1 } → 2번 유저로부터 3개, 5번 유저로부터 1개
const loginAs = (id)=>{
localStorage.setItem("userId", id);
alert(id+"번 유저로 접속되었습니다");
window.location.reload();
}
const goRoomManual = ()=>{
const opp = prompt("상대방 ID 입력");
if(opp) nav("/room/"+opp);
}
useEffect(()=>{
if(!myId) return;
const ws = connectWS(myId, (raw)=>{
const [cmd, fromId] = raw.split(":");
if(cmd==="NEW_MESSAGE"){
setNotiMap(prev=>{
const newCount = (prev[fromId] || 0) + 1;
return { ...prev, [fromId]: newCount };
});
}
});
return ()=>ws.close();
},[myId]);
return(
<div style={{padding:40, fontFamily:"sans-serif"}}>
<h2>유저 선택</h2>
<div style={{display:"flex", gap:10, marginBottom:20}}>
<button onClick={()=>loginAs(1)}>사용자로 선택</button>
<button onClick={()=>loginAs(2)}>관리자로 선택</button>
</div>
<button
onClick={goRoomManual}
style={{
padding:"10px 20px",
marginBottom:30,
background:"#e2e2e2",
borderRadius:8,
cursor:"pointer"
}}
>
채팅방 들어가기
</button>
{/* 알림 박스 리스트 */}
<div style={{marginTop:20}}>
{Object.entries(notiMap).map(([from, count])=>(
<div
key={from}
onClick={()=>{
setNotiMap(prev=>{
const copy = {...prev};
delete copy[from];
return copy;
});
nav("/room/"+from);
}}
style={{
background:"#fff3cd",
border:"1px solid #f1c04c",
padding:"15px 20px",
borderRadius:"10px",
cursor:"pointer",
minWidth:"260px",
textAlign:"center",
fontSize:"16px",
fontWeight:"500",
color:"#856404",
boxShadow:"0 2px 5px rgba(0,0,0,0.08)",
marginBottom:"12px"
}}
>
새 메시지 도착! (보낸사람: {from}) - {count}개
</div>
))}
</div>
</div>
);
}
loginAs()로 localStorage에 userId를 저장하고 새로고침connectWS(myId, callback) 호출"NEW_MESSAGE:보낸사람ID" 형태의 메시지 수신notiMap state에 보낸 사람별 카운트 증가/room/:opponentId)으로 이동notiMap에서 해당 알림 제거{ "발신자ID": 카운트 } 형태로 누가 몇 개의 메시지를 보냈는지 추적"NEW_MESSAGE:3" → ["NEW_MESSAGE", "3"]으로 분리ws.close() 호출실제 1:1 채팅이 이루어지는 화면이다.
// src/pages/ChatRoom.jsx
import { useEffect, useState, useRef } from "react";
import { useParams } from "react-router-dom";
import { connectWS } from "../ws";
import { sendMessage, getRoomMessages } from "../api/messageAPI";
export default function ChatRoom(){
const { opponentId } = useParams();
const opponent = Number(opponentId);
const myId = Number(localStorage.getItem("userId"));
const [msgs, setMsgs] = useState([]);
const [txt, setTxt] = useState("");
const messagesEndRef = useRef(null);
// 새 메시지가 추가되면 스크롤을 맨 아래로 이동
useEffect(()=>{
messagesEndRef.current?.scrollIntoView({behavior:"smooth"});
},[msgs]);
// 채팅방 진입 시 메시지 목록 로드 + WebSocket 연결
useEffect(()=>{
if(!opponent) return;
// 1. 기존 메시지 목록 로드
getRoomMessages(myId, opponent).then(setMsgs);
// 2. WebSocket 연결
const ws = connectWS(myId, (raw)=>{
console.log("WS msg:", raw);
const [cmd, fromId] = raw.split(":");
if(cmd==="NEW_MESSAGE"){
// 상대방이 메시지를 보냈을 때만 목록 재조회
getRoomMessages(myId, opponent).then(setMsgs);
}
});
return ()=>ws.close();
},[opponent]);
const onSend = async()=>{
if(!txt.trim()) return;
// 1. REST API로 메시지 전송 (DB insert)
await sendMessage({
senderId: myId,
receiverId: opponent,
content: txt.trim()
});
setTxt("");
// 2. 내가 보낸 메시지는 WebSocket으로 오지 않으므로 직접 갱신
getRoomMessages(myId, opponent).then(setMsgs);
}
if(!opponent) return <div>Loading...</div>;
return (
<div style={{padding:20}}>
<h2>Chat With {opponent}</h2>
<div style={{
border:"1px solid #aaa",
height:300,
overflowY:"auto",
marginBottom:10,
padding:10
}}>
{msgs.map(m=>{
const mine = (Number(m.senderId) === Number(myId));
return(
<div key={m.id}
style={{
display:"flex",
justifyContent: mine ? "flex-end" : "flex-start",
marginBottom:"8px"
}}
>
<div style={{
background: mine ? "#c1e6ff" : "#fce6b0",
border:"1px solid #ccc",
borderRadius:"10px",
padding:"6px 10px",
maxWidth:"60%",
whiteSpace:"pre-wrap"
}}>
{m.content}
</div>
</div>
)
})}
<div ref={messagesEndRef}></div>
</div>
<div>
<input
value={txt}
onChange={e=>setTxt(e.target.value)}
style={{width:"200px"}}
/>
<button onClick={onSend}>보내기</button>
</div>
</div>
);
}
useEffect(()=>{
if(!opponent) return;
// 기존 메시지 목록 조회
getRoomMessages(myId, opponent).then(setMsgs);
// WebSocket 연결
const ws = connectWS(myId, (raw)=>{
const [cmd, fromId] = raw.split(":");
if(cmd==="NEW_MESSAGE"){
getRoomMessages(myId, opponent).then(setMsgs);
}
});
return ()=>ws.close();
},[opponent]);
/room/5 → opponent = 5)NEW_MESSAGE 신호가 오면 목록을 재조회const onSend = async()=>{
if(!txt.trim()) return;
// REST API로 메시지 전송
await sendMessage({
senderId: myId,
receiverId: opponent,
content: txt.trim()
});
setTxt("");
// 내가 보낸 메시지는 WebSocket으로 오지 않으므로 직접 갱신
getRoomMessages(myId, opponent).then(setMsgs);
}
핵심: 내가 보낸 메시지는 WebSocket 알림이 오지 않는다. 서버는 수신자에게만 NEW_MESSAGE 알림을 보내기 때문이다. 따라서 전송 후 직접 REST로 목록을 재조회한다.
{msgs.map(m=>{
const mine = (Number(m.senderId) === Number(myId));
return(
<div style={{
display:"flex",
justifyContent: mine ? "flex-end" : "flex-start"
}}>
<div style={{
background: mine ? "#c1e6ff" : "#fce6b0",
...
}}>
{m.content}
</div>
</div>
)
})}
messagesEndRef를 이용한 자동 스크롤로 새 메시지 추가 시 맨 아래로 이동// src/App.jsx
import {BrowserRouter, Routes, Route} from "react-router-dom";
import Home from "./pages/Home";
import ChatRoom from "./pages/ChatRoom";
export default function App(){
return(
<BrowserRouter>
<Routes>
<Route path="/" element={<Home/>}/>
<Route path="/room/:opponentId" element={<ChatRoom/>}/>
</Routes>
</BrowserRouter>
)
}
/: Home 화면 (알림 수신 및 채팅방 진입)/room/:opponentId: ChatRoom 화면 (실제 1:1 채팅)| 통신 | 내용 | 사용 시점 |
|---|---|---|
| REST | 실제 메시지 payload (insert/select) | 메시지 전송, 목록 조회 |
| WebSocket | 새 메시지 도착 신호 "NEW_MESSAGE:상대ID" | 수신자에게 실시간 알림 (Home/ChatRoom) |
WebSocket은 payload 통로가 아니라 "trigger 전용"이다. 이 구조는 유지보수, DB 동기성, 배포 및 스케일링 측면에서 가장 안전하다.
브라우저 A (사용자 1)
/room/5 진입 → REST로 기존 메시지 목록 조회sendMessage() 호출 → 서버에 POST 요청getRoomMessages() 호출로 UI 갱신서버
/message/send 엔드포인트에서 DB insert 실행wsHandler.notify(5, "NEW_MESSAGE:1") 호출브라우저 B (사용자 5)
"NEW_MESSAGE:1" 수신notiMap["1"] 카운트 증가/room/1 이동이 구조는 카카오톡, 인스타그램 DM, 슬랙이 사용하는 실제 아키텍처와 동일한 패턴이다.
실제로 "실시간 알림"이 동작하는지 브라우저 2개로 테스트한다.
Home 화면에서 각각 다른 사용자로 로그인한다.

이 순간 각 브라우저에서 WebSocket 연결이 생성된다.
크롬 : ws://192.168.2.29:8090/ws?userId=1
엣지 : ws://192.168.2.29:8090/ws?userId=2
브라우저 A에서 "채팅방 들어가기" 버튼을 클릭하고 상대방 ID로 2를 입력하여 /room/2로 진입한다.

사용자와 관리자는 대화를 나눈다.

| 단계 | 발생 위치 | 설명 |
|---|---|---|
| 1 | 브라우저 A | /message/send REST API 호출 (DB insert) |
| 2 | 서버 | insert 성공 후 wsHandler.notify(2, "NEW_MESSAGE:1") 호출 |
| 3 | WebSocket | 브라우저 B가 "NEW_MESSAGE:1" 수신 |
| 4 | 브라우저 A | 전송 완료 후 getRoomMessages() 호출로 UI 갱신 |
관리자(브라우저 B)가 채팅방에서 나와 Home 화면으로 돌아간 상태에서, 사용자(브라우저 A)가 추가 메시지를 보낸다.

브라우저 B의 Home 화면에 즉시 알림 박스가 생성된다.

새 메시지 도착! (보낸사람: 1) - 1개
이것이 WebSocket의 실시간 알림 기능이다. 서버가 "NEW_MESSAGE:1" 신호를 보내면, 브라우저 B의 Home 컴포넌트가 이를 수신하여 notiMap state를 업데이트하고 UI를 즉시 갱신한다.
브라우저 B의 콘솔 로그:
WS msg: NEW_MESSAGE:1
Home.jsx의 WebSocket 핸들러:
const ws = connectWS(myId, (raw)=>{
const [cmd, fromId] = raw.split(":");
if(cmd==="NEW_MESSAGE"){
setNotiMap(prev=>{
const newCount = (prev[fromId] || 0) + 1;
return { ...prev, [fromId]: newCount };
});
}
});
브라우저 B에서 노란색 알림 박스를 클릭하면 /room/1로 이동하고, REST API로 메시지 목록을 조회하여 최신 메시지를 확인할 수 있다.
onClick={()=>{
setNotiMap(prev=>{
const copy = {...prev};
delete copy[from]; // 알림 제거
return copy;
});
nav("/room/"+from); // 채팅방 이동
}}
ChatRoom 진입 시:
getRoomMessages(myId, opponent).then(setMsgs);
이렇게 REST API로 전체 메시지 히스토리를 가져와 화면에 표시한다.
"NEW_MESSAGE:발신자ID" 형태의 가벼운 메시지만 전송이 구조는 카카오톡, 인스타그램 DM, 슬랙이 사용하는 실제 아키텍처와 동일한 패턴이다. WebSocket은 "새 메시지가 왔다는 신호만" 담당하고, 실제 메시지 본문은 REST로 정확하게 가져온다.
이 프로젝트의 핵심은 다음 한 문장으로 요약된다.
메시지는 REST로 저장하고, WebSocket은 변화 시그널만 전달한다
(메시지 동기화는 DB가 책임, 실시간 push는 WebSocket이 책임)
이 분리가 이루어지면 다음과 같은 기능들이 자연스럽게 해결된다:
| 통신 방식 | 역할 | 사용 시점 | 데이터 크기 |
|---|---|---|---|
| REST API | 메시지 저장 및 조회 (DB 동기화) | 전송, 채팅방 진입, 알림 수신 시 | 크다 |
| WebSocket | 실시간 알림 신호 | 상대방이 메시지 보냈을 때만 | 매우 작다 |
WebSocket은 payload를 직접 전달하지 않는다. 단지 "데이터가 변경되었으니 REST로 다시 조회하라"는 트리거 역할만 한다.
클라이언트 A ─┐
├─→ WebSocket Handler (메모리: userId→Session 매핑)
클라이언트 B ─┘
userId → WebSocketSession 매핑 저장한계: 서버가 여러 대로 늘어나면 A와 B가 서로 다른 서버에 연결될 수 있음
클라이언트 A ─→ 서버1 ─┐
├─→ Redis Pub/Sub / Kafka ←─→ 모든 서버
클라이언트 B ─→ 서버2 ─┘
실제 대규모 서비스는 여기서 한 단계 더 나아간다:
인증 및 보안
재연결 전략
메시지 큐잉
읽음 처리
WebSocket은 브라우저에서도 사용 가능한, HTTP 업그레이드 기반의 장수 양방향 스트림이다.
프레이밍, 하트비트, 서브프로토콜 등 프로토콜 레벨 기초를 이해하면, 그 위에 다음 요소들을 왜 얹어야 하는지 자연스럽게 연결된다:
이 모든 것의 시작은 "REST는 데이터, WebSocket은 신호"라는 명확한 역할 분리에서 출발한다.
비타허브 - Spring Boot와 React에서 WebSocket 구현하기: 완벽 가이드
인파 - 웹소켓 역사부터 정리
줌 클론코딩 레포지토리