🔍 구현 목적
`table-now`는 실시간 식당 예약 서비스입니다.
예약자와 가게 사장님 간의 실시간 소통을 지원하기 위해 1:1 채팅 기능을 구현했습니다.
단순한 메시지 전송이 아닌, 예약 건 단위로 안전하게 주고받을 수 있도록 설계하여, 예약 관련 문의 및 요청사항을 실시간으로 교환할 수 있습니다.
🧩 설계
1. ERD 설계 - 채팅방 없이 메시지만 저장
이 시스템은 예약(reservation) 단위로 채팅이 이뤄지므로, 별도의 채팅방 테이블은 필요하지 않습니다.
각 `reservation_id`는 고유한 채팅방 역할을 하며 예약이 존재해야만 채팅이 가능하므로, 채팅방 개념은 reservation에 종속됩니다.
모든 채팅 메시지는 chat_message 테이블에 저장됩니다.
💡 채팅방 테이블을 만들지 않은 이유
예약이 존재하지 않으면 채팅도 불가능하며, 채팅방 생성과 삭제 시점을 따로 관리할 필요가 없기 때문.
2. chat_message 테이블 구조


| 컬럼명 | 설명 |
| id | 채팅 메시지 ID (PK) |
| sender_id | 메시지 전송자 (user ID, FK, 연관관계) |
| reservation_id | 채팅이 연결된 예약 ID (연관관계 X) |
| owner_id | 가게 사장님 user ID (역정규화) |
| reservation_user_id | 예약자 user ID (역정규화) |
| content | 메시지 내용 |
| image_url | 이미지 첨부 URL |
| is_read | 읽음 여부 |
| created_at | 생성 일시 |
✅ 역정규화 결정 이유
현재 프로젝트의 핵심 도메인은 예약(reservation)입니다.
채팅 기능은 예약을 기준으로 생성 되지만, 예약의 비즈니스와 채팅 메시지는 직접적인 관련이 없습니다.
매 메시지 조회 시 JOIN이 발생하면 성능 저하와 코드 복잡도가 증가하므로, 정규화를 해제하고 단순 ID만 저장하는 구조로 선택했습니다.
⇒ 역할 판별의 단순화와 성능 향상을 위해 `owner_id`와 `reservation_user_id`를 `chat_message` 테이블에 함께 저장하는 역정규화를 적용했습니다.
sender_id가 reservation_user_id와 같으면 → 예약자 메시지
sender_id가 owner_id와 같으면 → 사장님 메시지
🖇️ 연관 관계

`owner_id`와 `reservation_user_id` 필드를 따로 구성하면서 `reservation_id`를 외래키로 참조할 필요가 없어졌습니다.
이미 역정규화한 필드들로 역할 판단이 가능하므로, `reservation` 객체 전체를 불러올 이유가 없기 때문에,
굳이 연관관계를 맺지 않고, ID만 저장하도록 수정했습니다. (단순 Long으로 처리)
반면, `sender_id` 필드는 연관관계 유지했습니다.
엔티티에서 `User sender` 로 사용자 정보 직접 탐색하기 위해 `@ManyToOne` 연관관계 유지하는 판단을 내렸습니다.
(메시지 DTO 구성 시 `sender.getName()` 호출할 예정)
📝 API 설계
1. 채팅 가능 여부 확인
목적: WebSocket 연결 전 사전 검증
조건: 예약 상태가 `RESERVED`일 때만 채팅 가능
API: `GET /api/v1/chats/{reservationId}/availability`
응답 예시:
{
"reservationId": 123,
"userId": 45,
"reason": "RESERVED",
"available": true
}
CANCELED, COMPLETED 상태는 채팅 불가 (403 반환)
2. 채팅 메시지 읽음 처리
목적: 본인이 받지 않은 메시지를 읽음 처리
API: `PATCH /api/v1/chats/{reservationId}/read`
응답 예시:
{
"reservationId": 123,
"userId": 45,
"message": "5개의 메시지 읽음 처리가 완료되었습니다."
}
채팅방 내 본인이 아닌 상대방이 보낸 메시지만 읽음 처리 대상
3. 채팅 메시지 조회
목적: 페이지네이션 기반 채팅 메시지 조회
API: `GET /api/v1/chats/{reservationId}`
응답 예시:
{
"content": [
{
"id": 1,
"senderId": 45,
"senderName": "홍길동",
"content": "안녕하세요!",
"imageUrl": null,
"createdAt": "2025-06-01 14:30:00",
"isRead": false
}
],
"page": 0,
"size": 20,
"totalElements": 35,
"totalPages": 2,
"last": false
}
Pageable을 활용해 페이지네이션 지원, 기본 page=0, size=20
💬 WebSocket 기반 채팅 기능 구현
1. WebSocket 관련 경로
- `CONNECT /ws/chat` WebSocket 연결
- `SUBSCRIBE /topic/chat/{reservationId}` 예약 ID 채팅방 구독
- `SEND /app/chat/message` 메시지 전송 요청
2. 채팅 테스트

`예약 ID: 1 / 가게 ID: 1 / 유저 ID: 1`인 예약이 존재할 때
예약자와 사장님 각각이 WebSocket에 연결하고, 메시지를 주고받는 시나리오 테스트입니다.
- 일반 유저 WebSocket 연결



- 가게 사장 WebSocket 연결



- 메시지 DB 저장

- 예약과 관련 없는 유저가 채팅 참여



3. 구현 코드
https://github.com/spring-team-7/table-now/pull/165
[feat] 1:1 채팅 메시지 저장 및 WebSocket 테스트 페이지 구현 by mannaKim · Pull Request #165 · spring-team-7/ta
🔗 Issue Number close #158 📝 작업 내역 spring-boot-starter-websocket 의존성 추가 WebSocketConfig 설정 및 JwtHandshakeInterceptor 적용 테스트용 WebSocket 클라이언트 페이지 생성 예약 ID, AccessToken 입력 후 WebSocket 연
github.com
💻 API 구현
WebSocket용 컨트롤러 `ChatMessageController`와 분리된 REST API용 컨트롤러 `ChatController` 생성하여 구현했습니다.
- ChatMessageController
- `@Controller` + `@MessageMapping`
- ChatController
- `@RestController` + `@GetMapping` 등
| 구분 | 용도 | Annotation | 사용 예시 |
| `ChatMessageController` | WebSocket 수신 처리 | `@Controller` `@MessageMapping` |
메시지 수신 후 브로드캐스트 |
| `ChatController` | REST API 처리 | `@RestController` `@RequestMapping` |
채팅 가능 여부 확인, 메시지 조회 등 |
https://github.com/spring-team-7/table-now/pull/180
[feat] 채팅 가능 여부 확인 및 메시지 목록/읽음 처리 API 구현 by mannaKim · Pull Request #180 · spring-team
🔗 Issue Number close #168 📝 작업 내역 GET /api/v1/chats/{reservationId}: 메시지 목록 조회 @PageableDefault로 page=0, size=20 자동 매핑 PATCH /api/v1/chats/{reservationId}/read: 사용자 기준 읽음 처리 채팅방(reservatio...
github.com
✅ 결론
- reservation_id를 기준으로 채팅방을 별도로 관리하지 않고, 메시지 중심의 구조로 단순화
- 역할 판별을 위한 owner_id, reservation_user_id 필드 도입으로 JOIN 최소화
- RESTful API를 통해 채팅 가능 여부 확인 및 메시지 CRUD 기능 제공
'Dev Projects' 카테고리의 다른 글
| [table-now] Spring Batch 개념과 정산 자동화 구조 정리 (0) | 2025.06.18 |
|---|---|
| [table-now] 1:1(예약자:가게) 채팅 기능 고도화를 위해 RabbitMQ Relay를 적용한 이유 (0) | 2025.06.05 |
| [table-now] Spring Security의 oauth2Login() 대신 WebClient를 선택한 이유 (3) | 2025.05.28 |
| [table-now] 의사결정 기록 - 외부 연동 시스템 예외 처리 강화 리팩토링 (0) | 2025.05.23 |
| [table-now] S3 이미지 업로드 - Presigned Url 방식으로 구현하기 (1) | 2025.05.23 |