
웹소켓이란?
웹 서버와 브라우저 즉, 클라이언트 간의 지속적인 TCP연결을 통해 양방향 실시간 통신을
가능하게 하는 컴퓨터 통신 프로토콜이다.

STOMP란?
Simple Text Oriented Messaging Protocol의 약자로,
웹소켓 위에서 동작하는 서브 프로토콜이다.
클라이언트와 서버 간에 메시지 형식과 내용을 정의하여 효율적으로 메시지를 주고받을 수 있게하며,
메시지 브로커를 활용한 pulisher - subscriber 방식으로 메시지를 쉽게 처리하도록 돕는다.
따라서 Spring Boot, Websocket(STOMP), JPA, MySQL을 이용해서
1:1 채팅을 구현해보았다.
구현
1. 핵심 아키텍처
프로토콜/전달 : Websocket + STOMP
Controller : STOMP 메시지처리 + REST 채팅내역 API
Service : 채팅방 조회/생성, 메시지 저장
Repository : 엔티티 기반 DB 접근
Entity : ChatRoom, ChatMessage
DTO : 네트워크 입출력용 (닉네임/시각 포함해서 프론트에 전달)
2. WebSocket / STOMP 설정 (WebSocketConfig)
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/ws-chat")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 클라이언트에서 /app/** 로 메시지 보냄
registry.setApplicationDestinationPrefixes("/app");
// 서버에서 클라이언트로 보낼 때는 /user/{userId}/queue/** 사용
registry.enableSimpleBroker("/queue");
}
}
@EnavleWebSocketMessageBroker 를 통해서
→메시지 브로커 기반 STOMP 활성화.
즉, 컨트롤러의 @MessageMapping 등을 사용 가능하게 해준다.
오버라이딩을 통해서 아래 두가지
registerStompEndpoints 그리고 configureMessageBroker의
엔드포인트와 라우팅 규칙을 정의해준다.
이때 registerStompEndpoints의 엔드포인트인 "/ws-chat" 는
브라우저가 최초에 접속하는 HandShake 엔드포인트이다.
따라서 브라우저 즉, 클라이언트는 /ws-chat로 연결을 맺는다.
또한 configureMessageBroker에서
setApplicationDestinationPrefixes("/app")는 클라이언트가 서버의
@MessageMapping 메서드로 보내는 목적지의 접두사이다.
만약 클라이언트가 "/app/chat/private"으로 보냈을 경우
서버는 "/app"를 제외하고 "/chat/private" 핸들러로 라우팅을 한다.
enbleSimpleBroker("/queue") 는 내장(Simple) 메시지 브로커 활성화 용도이다.
즉, 두 개 다 쉽게 말하자면
"/app"는 클라이언트 → 서버로 메시지 Send
"/queue"는 서버 → 클라이언트로 Push 용도이다.
3. 엔티티 모델

@Entity
@Table(name = "ChatMessage")
public class ChatMessage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "message_id")
private Long messageId;
@ManyToOne
@JoinColumn(name = "room_id", nullable = false)
private ChatRoom room;
@ManyToOne
@JoinColumn(name = "sender_id", nullable = false)
private User sender;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@Column(name = "sent_at", nullable = false)
private LocalDateTime sentAt;
@Entity
@Table(name = "ChatRoom")
public class ChatRoom {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "room_id")
private Long roomId;
@ManyToOne
@JoinColumn(name = "user1_id", nullable = false)
private User user1;
@ManyToOne
@JoinColumn(name = "user2_id", nullable = false)
private User user2;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
4. 레포지토리 계층 (JPA)
public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {
List<ChatMessage> findByRoomRoomIdOrderBySentAtAsc(Long roomId);
}
↓
select m
from ChatMessage m
where m.room.roomId = :roomId
order by m.sentAt asc
채팅방에 메시지를 시간을 기준으로 오름차순으로 정렬하여 가져온다.
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
@Query("""
SELECT r
FROM ChatRoom r
WHERE (r.user1.userId = :u1 AND r.user2.userId = :u2)
OR (r.user1.userId = :u2 AND r.user2.userId = :u1)
""")
Optional<ChatRoom> findByUsers(
@Param("u1") String user1Id,
@Param("u2") String user2Id
);
}
그리고 채팅방은 유저1, 유저2가 속해있는 채팅방을 가져온다.
5. 서비스 계층 (ChatService)
@Transactional
public ChatRoom getOrCreateRoom(String senderId, String receiverId) {
return roomRepo.findByUsers(senderId, receiverId)
.orElseGet(() -> {
ChatRoom room = new ChatRoom();
room.setUser1(userRepo.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("사용자 없음: " + senderId)));
room.setUser2(userRepo.findById(receiverId)
.orElseThrow(() -> new IllegalArgumentException("사용자 없음: " + receiverId)));
return roomRepo.save(room);
});
}
senderId와 receiverId를 통해 방을 조회한다.
만약 없다면, 방을 새롭게 생성한다.
그리고 만약 senderId 혹은 receiverId가 없다면, "사용자 없음" 을 통해 예외처리를 한다.
@Transactional
public ChatMessage saveMessage(Long roomId, String senderId, String content) {
ChatMessage msg = new ChatMessage();
msg.setRoom(roomRepo.findById(roomId)
.orElseThrow(() -> new IllegalArgumentException("방 없음: " + roomId)));
msg.setSender(userRepo.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("사용자 없음: " + senderId)));
msg.setContent(content);
return msgRepo.saveAndFlush(msg); // ← 원하면 이렇게
}
roomId, senderId, content를 통해서 새롭게 메시지를 저장한다.
이때 방이 찾을 수 없다면 " 방 없음" 으로 예외처리를 하고,
senderId가 없다면, "사용자 없음" 으로 예외처리를 했다.
@Transactional(readOnly = true)
public List<ChatMessage> getHistory(Long roomId) {
return msgRepo.findByRoomRoomIdOrderBySentAtAsc(roomId);
}
roomId를 통해 해당 룸을 검색하고 보낸시간을
오름차순으로 정렬 후 메시지를 조회한다.
채팅창을 열었을 경우 기존 과거의 채팅 내역을 가져오기 위함이다.
6. 실시간 메시지 수신 / 중계 (ChatController)
@RestController
@RequestMapping("/api/chat")
public class ChatRoomController {
private final ChatService chatService;
public ChatRoomController(ChatService chatService) {
this.chatService = chatService;
}
@GetMapping("/room")
public Map<String, Long> getOrCreateRoom(
@RequestParam("senderId") String senderId,
@RequestParam("receiverId") String receiverId
) {
ChatRoom room = chatService.getOrCreateRoom(senderId, receiverId);
return Collections.singletonMap("roomId", room.getRoomId());
}
}
"/api/chat/room?senderId=user&receiverId=userB" 엔드포인트가 호출 되면
앞서 ChatService 에서 만들어뒀던 로직이 실행시켜 방을 가져오거나, 생성한다.
@MessageMapping("/chat/private")
public void handlePrivateMessage(@Payload ChatMessageDTO dto) {
log.info("[WS-IN] senderId={}, receiverId={}, contentLen={}",
dto.getSenderId(), dto.getReceiverId(),
dto.getContent() == null ? 0 : dto.getContent().length());
if (dto.getSenderId() == null || dto.getReceiverId() == null) {
log.warn("[WS-DROP] invalid payload: senderId or receiverId is null");
return;
}
if (dto.getContent() == null || dto.getContent().isBlank()) {
log.warn("[WS-DROP] empty content");
return;
}
try {
ChatRoom room = chatService.getOrCreateRoom(dto.getSenderId(), dto.getReceiverId());
log.info("[ROOM] roomId={}, sender={}, receiver={}",
room.getRoomId(), dto.getSenderId(), dto.getReceiverId());
ChatMessage saved = chatService.saveMessage(
room.getRoomId(),
dto.getSenderId(),
dto.getContent()
);
log.info("[DB-SAVE-OK] roomId={}, senderId={}, at={}",
room.getRoomId(), dto.getSenderId(), saved.getSentAt());
dto.setRoomId(room.getRoomId());
dto.setSenderNickname(saved.getSender().getNickname());
dto.setSentAt(saved.getSentAt().format(F));
messagingTemplate.convertAndSendToUser(
dto.getReceiverId(),
"/queue/messages",
dto
);
log.info("[WS-OUT] toUser={}, dest=/queue/messages", dto.getReceiverId());
} catch (Exception e) {
log.error("[DB-SAVE-FAIL] senderId={}, receiverId={}, err={}",
dto.getSenderId(), dto.getReceiverId(), e.toString(), e);
}
}
다음은 ChatController 코드이다.
크게 나누어 보자면
수신 → 검증 → 처리 → 저장 → 전달
총 5단계로 나누었다.
<수신>
브라우저에서 "/chat/private" 라는 엔드포인트로 메시지를 이 메시지를
ChatMessageDTO 라는 객체로 자동으로 변환해서 dto변수에 담아준다.
<검증>
if 문을 통해 dto 객체의 담긴, SenderId 혹은 ReceiverId가 null이거나
Content이 null 이면 해당되는 로그를 남기며 에러처리를 했다.
<처리>
try-catch 블록으로 전체 로직을 감싸서, 처리 도중 DB 관련 오류 등이
발생해도 서버가 중단되지 않고 에러 로그만 남기도록 했다.
<저장>
chatSerivece.saveMessage() 를 통해 해당 객체를 전달하며
서비스에서 짜두었던 로직을 통해 저장하도록 했다.
<전달>
SimpMessagingTemplate라는 도구를 사용하여 WebSocket을 통해
특정 사용자에게 메시지를 보내어 전달하도록 했다.
7. 이전 채팅 제공 (REST)
@GetMapping("/api/chat/{roomId}")
public List<ChatMessageDTO> getChatHistory(@PathVariable Long roomId) {
return chatService.getHistory(roomId).stream().map(m -> {
ChatMessageDTO dto = new ChatMessageDTO();
dto.setRoomId(roomId);
dto.setSenderId(m.getSender().getUserId());
dto.setReceiverId(null); // 불필요하면 무시
dto.setContent(m.getContent());
dto.setSenderNickname(m.getSender().getNickname());
dto.setSentAt(m.getSentAt().format(F));
return dto;
}).collect(Collectors.toList());
}
엔드포인트인 "/api/chat/{roomId}" 요청이 왔을 경우
chatService에서 getHistory라는 메서드를 통해 이전에 시간순으로 정렬해서
가져온 채팅 내역을 제공한다.
트러블 슈팅
1. 시간 타입 문제
구현은 잘 했는데, 과거 내용의 채팅을 불러올 때 날짜가 맞지 않았다.
날짜가 틀린게 아니라 아예 표기자체가 안 되었다.
프론트에서 날짜 타입을 지정해줘야하나 싶어서
// 날짜 문자열/숫자/Date 어떤 포맷이 와도 Date 객체로 변환
const ensureDate = (val) => {
if (!val) return null;
// 이미 Date인지 & 유효한지
if (val instanceof Date && !isNaN(val.getTime())) return val;
// 숫자 타입 (유닉스 초/밀리초 가정)
if (typeof val === 'number') {
const asMs = val < 1e12 ? val * 1000 : val;
const d = new Date(asMs);
return isNaN(d.getTime()) ? null : d;
}
// 숫자 문자열: 길이로 엄격 판단 (10=초, 13=밀리초만 허용)
if (typeof val === 'string' && /^\d+$/.test(val)) {
if (val.length === 10 || val.length === 13) {
const n = Number(val);
const asMs = val.length === 10 ? n * 1000 : n;
const d = new Date(asMs);
if (!isNaN(d.getTime())) return d;
}
// 10/13 자리가 아니면 날짜 숫자로 보지 않음 (YYYYMMDD 같은 케이스 방지)
}
// 문자열 일반 포맷들
if (typeof val === 'string') {
const s = val.trim();
// 1) ISO / 공백을 T로 치환, 밀리초/오프셋 포함 케이스 허용
{
const isoLike = s.includes('T') ? s : s.replace(' ', 'T');
const d = new Date(isoLike);
if (!isNaN(d.getTime())) return d;
}
// 2) "YYYY-MM-DD HH:mm(:ss[.fff…])(±hh:mm)?" (로컬 해석 + 오프셋 처리)
{
const m = s.match(
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6}))?)?(?:([+-]\d{2}):?(\d{2}))?$/
);
if (m) {
const sec = +(m[6] || 0);
const ms = m[7] ? Number(m[7].slice(0, 3).padEnd(3, '0')) : 0; // 마이크로초 → ms
let d = new Date(+m[1], +m[2]-1, +m[3], +m[4], +m[5], sec, ms);
// 타임존 오프셋(+09:00 등)이 포함된 경우 해당 오프셋만큼 보정
if (m[8] && m[9]) {
const sign = m[8].startsWith('-') ? -1 : 1;
const oh = Math.abs(parseInt(m[8], 10));
const om = parseInt(m[9], 10);
const offsetMinutes = sign * (oh * 60 + om);
d = new Date(d.getTime() - offsetMinutes * 60 * 1000);
}
if (!isNaN(d.getTime())) return d;
}
}
// 3) "M/D H:mm" (프로젝트 메시지 포맷) → 올해로 가정
{
const m2 = s.match(/^(\d{1,2})\/(\d{1,2})[ ]+(\d{1,2}):(\d{2})$/);
if (m2) {
const now = new Date();
const y = now.getFullYear();
const d = new Date(y, +m2[1]-1, +m2[2], +m2[3], +m2[4], 0);
if (!isNaN(d.getTime())) return d;
}
}
// 4) "HH:mm" 단독 포맷 -> 오늘 날짜로 보정
{
const m3 = s.match(/^(\d{1,2}):(\d{2})$/);
if (m3) {
const now = new Date();
const d = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
+m3[1],
+m3[2],
0,
0
);
if (!isNaN(d.getTime())) return d;
}
}
}
return null; // 최종 실패
};
문자열 / 숫자 / Date 어떤 타입으로 들어오던지
프론트에서 처리가능하게끔 구현을 해두었다.
그래도 해당 날짜는 null로 표기가 되길래 설마하고 백엔드 코드를 추적해보았다.
dto.setSentAt(saved.getSentAt().format(timeFormatter));
private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm");

타입을 HH:mm 즉 시간:분 으로만 불러와 엔드 포인트를 url에 입력하여
api 를 호출해서 확인해보니 역시나 HH:mm 을 주고 있었다.
private final DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
그래서 타임 포맷을 년/월/일, 초까지 추가해서 받아오니
아래처럼 정상적으로 시간 타입을 잘 가져오는 것을 확인했다.

그렇게 시간 타입 하나를 놓쳐 채팅에서 가장 중요한 것이
날짜인것을 다시한번 느꼈다.
2. 배포 환경에서의 문제
기존 Local 환경에서 테스트 할 땐 클라이언트 ↔ 서버 직접적으로 통신했다.
하지만, 배포 환경인 EC2 서버는 클라이언트 ↔ NGINX ↔ 서버 로 통신한다.
그러다 보니 NGINX에 websocket에 대해 설정을 따로 안 해주고,
로컬에서 테스트가 끝났다고 하여, 바로 EC2 즉 배포 서버에다가 푸시 후 실행
그렇게 당연히 채팅 또한 안 될 뿐더러, DB채팅 메시지 저장 또한 안됐다.
찾아보니 NGINX를 사용할 때 NGINX에서 또한, 웹소켓에 대한 설정이 필요하다고 한다.
따라서 NGINX 설정 파일인 default.conf 에서 websocket 설정해주었다.
# webSocket 프록시 (최상단 배치 추천)
location ^~ /ws-chat/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# 인증/쿠키/세션 연동을 확실히
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# WebSocket은 버퍼링/타임아웃을 넉넉히
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
코드를 바꿔주고 확인해보니 현재 돌아가고있는 컨테이너에서는
바뀐 코드가 적용이 안 됐다.
이때 가장 좋은 방법은 기존 컨테이너를 삭제하고
새로운 코드에 맞게 컨테이너를 다시 생성해서 동작시키는것이 깔끔한 것 같아서
기존 컨테이너를 삭제 → 새 컨테이너 생성을 하였다
나중에 또 써먹을지 몰라 정리를 해두었다.
# nginx 적용 후 끄고, 컨테이너 지우고 새롭게 다시 키기
# 1. Nginx 컨테이너 종료 + 삭제
docker stop myfcseoul-nginx
docker rm myfcseoul-nginx
# 2. 다시 실행
docker compose up -d nginx
'Spring' 카테고리의 다른 글
| [Spring] CaffeineCache TTL 설정 오류로 인한 NPE 발생 사례 (0) | 2026.04.29 |
|---|---|
| [Spring] 예외 처리 종류 & 상태 코드 (0) | 2025.12.28 |
| [Spring] 출금 및 장부 시스템 구현 (2) | 2025.12.08 |
| [Spring] 충전 기능 구현 with. TossPayments (0) | 2025.11.29 |
| [Spring] 카카오 로그인 API 연결 해보기 (0) | 2025.04.12 |