개요
이전 글에서는 질문(Question) 기능을 중심으로 한 실시간 Q&A 시스템의 기본 구조를 소개했다. 이번 글에서는 그 구조를 한 단계 확장하여, 다중 채팅방 구조를 도입하고, 답변(Answer) 기능과의 연결, 시스템 메시지 처리 등 실시간 상호작용을 강화하는 과정을 정리해본다.
1. ChatRoom 구조 설계 및 연동
기존에는 모든 유저가 하나의 /topic/public 채널을 통해 실시간으로 질문/답변을 주고받는 구조였지만, 이는 실제 서비스에는 적합하지 않다. 질문별로 독립된 채팅방을 구성하기 위해 ChatRoom 엔티티를 설계하고, 질문과 1:1 매핑되도록 리팩토링하였다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoom {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private Boolean isResolved = false;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id")
private Question question;
@OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ChatMessage> messages = new ArrayList<>();
}
하나의 질문당 하나의 채팅방을 생성하고, 해당 방에서만 메시지가 흐르도록 설계하였다.
2. 실시간 메시지 처리 흐름
ChatMessageService, ChatWebSocketController, ChatMessageDto 등을 활용해 다음과 같은 흐름을 구현했다.
- 유저가 채팅방 입장 시 JOIN 메시지 전송
- 채팅 입력 시 해당 방에만 broadcast
- 퇴장 시 LEAVE 메시지 전송
- 질문 또는 답변 작성 시 시스템 메시지 생성 및 전송
@MessageMapping("/chat.sendMessage")
public void sendMessage(ChatMessageDto dto, @AuthenticationPrincipal User user) {
ChatMessageDto saved = chatMessageService.save(dto, user);
messagingTemplate.convertAndSend("/topic/room/" + dto.getRoomId(), saved);
}
3. Answer 작성 시 실시간 시스템 메시지 전송
AnswerService에서는 답변을 저장하는 동시에, 해당 질문과 연결된 채팅방에 자동으로 시스템 메시지를 전송하도록 처리하였다.
public AnswerDto create(String content, Long questionId, User writer) {
Question question = questionService.getQuestionById(questionId);
Answer answer = answerMapper.toEntity(content, question, writer);
answerRepository.save(answer);
ChatRoom room = chatRoomService.getRoomByQuestionId(questionId);
chatMessageService.sendSystemMessage(room.getId(), writer.getUsername() + "님의 답변이 등록되었습니다.", ChatMessage.MessageType.ANSWER_CREATED);
return answerMapper.toDto(answer);
}
4. 실시간 채널 구조 정리
기존의 /topic/public 구조에서 /topic/room/{roomId} 형태로 다중 채팅방을 분리했다. 질문이 등록되면 자동으로 채팅방이 생성되고, 사용자는 해당 방에만 입장해 대화를 주고받게 된다.
- 질문 등록 시: /topic/room/{roomId}에 시스템 메시지
- 채팅 입력 시: 해당 방으로 메시지 broadcast
- 답변 등록 시: 해당 방으로 시스템 메시지
5. 리팩토링한 Answer Entity
답변 엔티티는 다음과 같이 단순화하되, 연관된 투표와 신고 수는 실시간으로 계산하여 DTO로 제공하도록 구성했다.
@Entity
@Getter
@Builder
public class Answer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private Boolean accepted = false;
@ManyToOne(fetch = FetchType.LAZY)
private Question question;
@ManyToOne(fetch = FetchType.LAZY)
private User writer;
@OneToMany(mappedBy = "answer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Vote> votes = new ArrayList<>();
@OneToMany(mappedBy = "answer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Report> reports = new ArrayList<>();
private LocalDateTime createdAt;
}
6. 채팅방 연동 구조로의 확장
앞선 구조에서는 모든 질문과 답변이 하나의 채널(/topic/public)로 송수신되었지만, 이는 사용자가 많은 경우 실시간 메시지의 충돌이나 혼란을 야기할 수 있다. 따라서 질문마다 고유한 채팅방을 생성하고, 그 채팅방에서만 실시간 메시지가 오가는 구조로 개선하였다.
6.1 ChatRoom Entity 설계
질문과 채팅방은 일대일 관계이며, 질문 생성 시 ChatRoom도 자동으로 생성된다. 채팅방에서는 일반 사용자 메시지뿐만 아니라 시스템 메시지(입장/퇴장, 답변 생성 등)도 함께 전송된다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoom {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private Boolean isResolved = false;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id")
private Question question;
@OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ChatMessage> messages = new ArrayList<>();
}
6.2 채팅 메시지 저장 및 전송 흐름
사용자가 채팅 메시지를 보내면 다음과 같은 순서로 동작한다:
- 클라이언트 → /app/chat.sendMessage로 메시지 송신
- ChatWebSocketController가 해당 메시지를 받아 저장
- 저장 후 SimpMessagingTemplate을 통해 /topic/room/{roomId}로 broadcast
@MessageMapping("/chat.sendMessage")
public void sendMessage(ChatMessageDto dto, @AuthenticationPrincipal User user) {
ChatMessageDto saved = chatMessageService.save(dto, user);
messagingTemplate.convertAndSend("/topic/room/" + dto.getRoomId(), saved);
}
6.3 답변 생성 시 시스템 메시지 전송
답변이 등록되면 해당 질문에 연결된 채팅방으로 시스템 메시지가 자동 전송된다. 이를 위해 AnswerService에서 답변 저장 이후 다음 로직을 추가했다.
ChatRoom chatRoom = chatRoomService.getRoomByQuestionId(questionId);
chatMessageService.sendSystemMessage(chatRoom.getId(), "[답변이 등록되었습니다]", MessageType.ANSWER_CREATED);
6.4 채팅방 목록 및 최근 메시지 조회
전체 채팅방을 정리한 목록을 제공하고, 각 채팅방에서 마지막 메시지 내용을 함께 보여줄 수 있도록 다음 메서드를 구현하였다.
public List<ChatRoomSummaryDto> getAllRoomSummaries() {
return chatRoomRepository.findAll().stream()
.map(room -> {
Optional<ChatMessage> latest = getLatestMessage(room.getId());
return ChatRoomSummaryDto.builder()
.roomId(room.getId())
.title(room.getTitle())
.isResolved(room.getIsResolved())
.latestMessage(latest.map(ChatMessage::getContent).orElse(null))
.latestTime(latest.map(ChatMessage::getSentAt).orElse(null))
.build();
}).collect(Collectors.toList());
}
회고
다중 채팅방 구조로의 확장은 단일 채널 구조보다 구현 난이도는 다소 높지만, 실시간 Q&A 서비스의 사용자 경험 측면에서 큰 개선을 가져왔다. 특히 질문마다 독립된 대화 흐름이 유지되며, 메시지 혼잡도가 현저히 줄어들었다.
또한, 시스템 메시지를 통해 사용자 행동(입장, 퇴장, 답변 등록 등)을 명시적으로 알릴 수 있어 대화 흐름이 자연스럽고 직관적이다.
이후 과제로는 다음과 같은 확장이 가능하다:
- 답변 채택 시 채팅방 종료 및 알림 전송
- 비동기 알림(Push Notification) 연동
- WebSocket 세션 만료/복구 처리
'개인 프로젝트' 카테고리의 다른 글
| 모니터링 시스템 (0) | 2026.09.26 |
|---|---|
| 04. 질문 기능 관련 구현 (1) | 2025.07.14 |
| 03. 이메일 인증 구현 (1) | 2025.07.10 |
| 02. 인증 시스템 구축 (1) | 2025.07.09 |
| 01. Entity 설계 (3) | 2025.07.08 |