개인 프로젝트

05. 다중 채팅방 구조로의 확장 + 채팅 시스템 구축

highlev 2025. 7. 18. 21:44

개요

이전 글에서는 질문(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 채팅 메시지 저장 및 전송 흐름

사용자가 채팅 메시지를 보내면 다음과 같은 순서로 동작한다:

  1. 클라이언트 → /app/chat.sendMessage로 메시지 송신
  2. ChatWebSocketController가 해당 메시지를 받아 저장
  3. 저장 후 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