본문 바로가기

학습 기록 (Learning Logs)/Today I Learned

개인 채팅, 단체 채팅 : 테이블 설계와 Kafka 토픽 설계

 

1:1 대화 vs 단체방에서의 테이블 설계와 Kafka 토픽 설계

  • 1:1 채팅그룹 채팅 모두 처리해야 함
  • Kafka 토픽 설계는 순서 보장 + 확장성 고려
  • 데이터베이스 테이블은 채팅방, 메시지, 참여자를 명확히 분리해야 함

💡 최종 설계

구조 설정
Kafka Topic chat-messages (단일 or 여러 파티션)
Kafka Consumer 서버마다 서로 다른 groupId 사용
WebSocket 서버 Kafka 메시지를 받아 → Redis에서 유저 조회 → WebSocket으로 push

📌 1. 데이터베이스 테이블 설계

p_chat_rooms (채팅방)

@Entity
@Table(name = "p_chat_rooms")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Getter
public class ChatRoomsEntity extends BaseEntity {
    @Id
    @GeneratedValue(generator = "UUID")
    @Column(name = "chat_room_id", updatable = false, nullable = false, unique = true)
    private UUID chatRoomId;


    @Column(name = "chat_room_name", length = 50, nullable = false)
    private String chatRoomName;

    // 주인, 저장O
    @ManyToOne
    @JoinColumn(name = "owner_id", nullable = false)
    private UsersEntity ownerId;//FK

    @Enumerated(EnumType.STRING)
    @Column(name = "room_type", nullable = false)
    private ChatTypeEnum room_type;

    //종속, 저장X
    @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.REMOVE)
    private List<ChatRoomParticipantsEntity> chatParticipants = new ArrayList<>();

    //종속, 저장X
    @OneToMany(mappedBy = "ChatMessagesEntity", cascade = CascadeType.REMOVE, orphanRemoval = true)
    private List<ChatMessagesEntity> chatMessages = new ArrayList<>();

p_chat_room_participants(참여자)

/**
 * 채팅방 하나당 유저 수만큼 row가 늘어남
 * "채팅방"과 "유저"는 다대다(M:N) 관계
 * 유저가 속한 채팅방 목록
 * 채팅방의 전체 유저 조회
 * 유저가 채팅방에 존재하는지 체크
 * 채팅방 나가기
 */
@Entity
@Table(name = "p_chat_room_participants")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Getter
public class ChatRoomParticipantsEntity extends BaseEntity {

    @Id
    @GeneratedValue(generator = "UUID")
    @Column(name = "chat_room_participant_id", updatable = false, nullable = false, unique = true)
    private UUID chatRoomParticipantId;//PK

    //주인, 저장O
    @ManyToOne
    @JoinColumn(name = "chat_room_id", nullable = false)
    private ChatRoomsEntity chatRoom;//FK

    //주인, 저장O
    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private UsersEntity userId;//FK

    @Column(name = "is_active", nullable = false)
    private boolean isActive;//퇴장 여부
}
 

채팅방 하나당 유저 수만큼 row가 늘어남

 

"채팅방"과 "유저"는 다대다(M:N) 관계

  • 한 채팅방에는 여러 유저가 참여할 수 있고 (Group Chat)
  • 한 유저는 여러 채팅방에 참여할 수 있음
  • chat_room_members는 이 채팅방-유저 풀어주는 중간 테이블 (Join Table) 역할

할 수 있는 것

  • 유저가 속한 채팅방 목록
  • 채팅방의 전체 유저 조회
  • 유저가 채팅방에 존재하는지 체크
  • 채팅방 나가기

단체 채팅에 메세지 보내기 위해 대상 조사

  • Kafka Consumer가 메시지를 처리할 때, 해당 room_id 기준으로 chat_room_members에서 참여자 목록을 조회해서 전송 대상 결정
  • 자주 조회되는 방은 chat_room_members를 Redis 캐시 저장
    • key: chat:room:members:{roomId}
    • value: [userId1, userId2, ...]

p_chat_messages(메시지)

@Entity
@Table(name = "p_chat_messages")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Getter
public class ChatMessagesEntity extends BaseEntity {
    @Id
    @GeneratedValue(generator = "UUID")
    @Column(name = "chat_message_id", updatable = false, nullable = false)
    private UUID chatMessageId;//메시지 고유 ID (PK)

    @Column(name = "chat_room_id", nullable = false)
    private UUID chatRoomId;//채팅방 ID (FK), 객체지향으로 할까 말까?

    @Column(name = "sender_id", nullable = false)
    private UUID senderId;  // 메시지 보낸 사용자

    @Column(name = "content", nullable = false)
    private String content;//메시지 본문

    @Enumerated(EnumType.STRING)
    @Column(name = "message_type", nullable = false)
    private MessageTypeEnum message_type;

 

→ 유지보수성이나 JPA 기능 활용 측면에서는 객체 매핑으로 바꾸는 걸 추천

 

  • 조회 시 join 가능
  • 방 삭제 시 cascade 처리 가능
  • 실시간 업데이트 / JPA cache 등 관리 용이 

p_chat_room_read_cursors(읽음 처리)

/*
단순히 "이 사람은 이 방에서 어느 메시지까지 읽었는지"만 저장
읽음 처리 부담이 줄어듦 (특히 단체방에서 수백 개 메시지 읽을 때)
MongoDB
 */

@Entity
@Table(name = "p_chat_room_read_cursors")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ChatRoomReadCursorEntity {
    @Id
    @GeneratedValue(generator = "UUID")
    @Column(name = "chat_room_read_cursor_id", updatable = false, nullable = false, unique = true)
    private UUID chatRoomReadCursorId;// PK

    @ManyToOne
    @JoinColumn(name = "chat_room_id", nullable = false)
    private ChatRoomsEntity chatRooms;//FK

    @ManyToOne
    @JoinColumn(name = "chat_message_id", nullable = false)
    private ChatMessagesEntity chatMessage;//FK

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private UsersEntity user;// FK
}

 

chat_room_id user_id last_read_message_id read_at
room-abc user-456 msg-123 now()
  • 단순히 "이 사람은 이 방에서 어느 메시지까지 읽었는지"만 저장
  • 읽음 처리 부담이 줄어듦 (특히 단체방에서 수백 개 메시지 읽을 때)

 

🧠 실전 기준 추천


조건 저장 위치
단순 읽음 상태 (최근 메시지까지 읽었는지)  Redis (TTL, 빠름, 자주 바뀜)
읽음 이력 저장 필요 (예: 단체방, 누가 언제 읽었는지)  MongoDB / Cassandra
데이터 정합성이 가장 중요 (예: 법적 근거, 메시지 열람 증빙 등)  RDB (PostgreSQL, MySQL)

 

 

읽음 처리 - RDB

더보기

 

chat_message_status

     
message_id UUID 메시지 ID
user_id UUID 수신자 ID
read_at TIMESTAMP 읽은 시간

1:1 또는 그룹 대화에서 읽음 처리, 안 읽은 메시지 계산 등에 유용

 

클라이언트가 메시지를 읽었을 때 "읽음 이벤트"를 서버에 전송

 

예시 흐름:

  1. 클라이언트가 채팅방을 열었거나, 특정 메시지까지 읽음
  2. 클라이언트 → 서버에 마지막 읽은 messageId 전달
  3.  서버에서는:
    • chat_message_status 테이블에 userId + messageId 기준으로 read_at 저장
    • chat_message_read_cursor 테이블에 userId + roomId + lastReadMessageId 저장 (성능 위해)

 

message_id user_id read_at
msg-123 user-456 2025-03-27 12:00
msg-123 user-789 2025-03-27 12:03

→ 메시지별로 누가 읽었는지 알 수 있음 

 

RDB vs NoSQL

더보기

✅ 1. 읽음 정보  DB에 저장?

✔️ 저장하냐 마냐는 비즈니스 요구 + 데이터 보존 기간 + 읽음 기능 복잡도에 따라 달라.

 

1:1 채팅 읽음 여부만 표시 (✔️✔️) ❌ 생략 가능 상태만 유지하면 됨 (ex. userA가 userB 메시지까지 읽었는지만)
그룹 채팅에서 누가 뭘 읽었는지 목록 ✅ 저장 필요 메시지 ID 기준으로 여러 사람의 읽음 상태 추적해야 함
읽음 통계 / 분석 메시지별 도달률, 리포트 등

📌 즉, 읽음 이력이 기능에 중요하면 저장해야 하고,
그냥 실시간 "읽음 표시"만 하려면 메모리 or Redis만으로도 충분해.

 

✅ 2. RDB vs NoSQL (읽음 정보 저장 관점)

📌 관계형 DB (RDB)


장점 단점
정합성 보장 읽음 정보는 엄청 자주 쓰고 거의 안 읽음 → RDB 성능 부담
조인으로 방/유저 기준 검색 가능 대량 쓰기 시 I/O 병목 위험
읽음 기준 인덱스 쿼리 용이 (room_id, user_id, message_id) 인덱스 많아지면 쓰기 성능 하락

→ 메시지가 많아지고 유저도 많아지면 성능 이슈가 생길 수 있음
→ 샤딩, 테이블 파티셔닝 등을 고려해야 함

 

📌 NoSQL (ex: MongoDB, Cassandra)

장점 단점
쓰기/읽기 성능 우수 (특히 MongoDB는 읽기 지향) 복잡한 쿼리 / 조인 힘듦 (따라서 구조 잘 잡아야 함)
유연한 구조 → 읽음 정보 간단히 넣기 쉬움 정합성 이슈 가능성 있음
인덱스 설정 자유도 높음 (room_id + user_id 복합 인덱스 등) TTL, 데이터 보존 정책 직접 관리 필요

 

 


📌 2. Kafka Topic 설계 

 

topic: chat-messages 

기본 구조: chat-messages 라는 공용 토픽 사용

roomId 기준으로 partition key 설정해서 같은 방 메시지는 같은 partition에

kafkaTemplate.send("chat-messages", roomId, messageJson);

{
  "roomId": "abc-123",
  "senderId": "user-111",
  "receiverIds": ["user-222", "user-333"],
  "type": "TEXT",
  "message": "안녕하세요",
  "timestamp": 1711526600
}
receiverIds: 서버에서 이 유저들한테 메시지 보내야 함 (1:1이면 1명, 그룹이면 N명)
  • 읽음 처리, 푸시 알림, 사용자 퇴장 등은 전부 별도 Kafka Consumer나 Redis 이벤트로 분리 가능

 

 


 topic: chat-read-events

✅ 읽음 확인 (✔️✔️)

{
  "roomId": "room-abc",
  "readerId": "user-B",
  "lastReadMessageId": "msg-123",
  "readAt": "2025-03-27T12:34:56Z"
}
kafkaTemplate.send("chat-read-events", roomId, eventJson);

chat_room_members - readerId 제외

 


📌3. 일대일vs 단체방의 처리 흐름 차이

  일대일 단체
참여자 2명 2명 이상
Kafka 메시지 구조 receiverIds = [상대방] receiverIds = 전체 참여자 - 나
Redis에서 조회 상대방 1명 서버만 확인 N명 모두 서버 조회
WebSocket 전송 1명에게 N명에게
DB 저장 한 메시지 한 메시지 + N개의 읽음 정보

읽음 처리 

📲 상황 예시

유저 A, B, C가 단체방에 있어
유저 A가 메시지 10개를 보내고
유저 B가 채팅방을 열었을 때:

→ 유저 B가 "읽음" 처리하면서
→ 유저 A, C에게 "유저 B가 읽었다"는 WebSocket 메시지를 보냄

 

 

🔁 흐름 전체 설명

① 유저가 읽음 이벤트 발생

  • 유저 B가 채팅방을 열거나 스크롤해서 메시지를 읽음
  • 이때 클라이언트에서 서버로 API 전송:
POST /chat/rooms/{roomId}/read
Body: { "lastReadMessageId": "msg-123" }

 

 

② 서버는 Kafka에 읽음 이벤트 전송

Kafka에 chat-read-events라는 토픽을 만들어서 이벤트 발행:

{
  "roomId": "room-abc",
  "readerId": "user-B",
  "lastReadMessageId": "msg-123",
  "readAt": "2025-03-27T12:34:56Z"
}
kafkaTemplate.send("chat-read-events", roomId, eventJson);

 

③ Kafka Consumer가 이벤트를 수신

  • chat-read-events를 구독하는 WebSocket 서버들이 이 메시지를 받아서
  • 해당 채팅방 참여자 중 자기 서버에 붙은 유저에게 WebSocket 메시지를 push함

 

④ WebSocket으로 읽음 알림 전송

예: 서버 C가 user-A, user-C한테 WebSocket으로 전송

{
  "type": "READ",
  "roomId": "room-abc",
  "readerId": "user-B",
  "lastReadMessageId": "msg-123",
  "readAt": "2025-03-27T12:34:56Z"
}

 

→ 클라이언트에서 이 메시지를 받고,
→ "user-B가 메시지를 읽었습니다" 식으로 표시하거나 체크마크 활성화

 

 


카프카는 같은 컨슈머그룹은 하나의 파티션에밖에 못붙는데 다른서버들에서 어떻게 컨슈밍해감??
하나의 파티션에는 하나의 컨슈머그룹아이디만 붙을 수 있으니 전체서버에 전파를 못시키지

 

 

🔥 Kafka의 Consumer Group 동작 방식

항목 설명
같은 Consumer Group 한 파티션의 메시지는 오직 한 인스턴스만 받음
다른 Consumer Group 같은 메시지를 서로 각각 받음 (== 브로드캐스트 가능)
👉 같은 groupId → 메시지 분산 (load balancing)
👉 다른 groupId → 메시지 복사 (broadcast)

 

 

✅ 카카오톡 구조: 모든 서버가 같은 메시지를 받아야 함

💬 예: user123이 메시지를 보냄 → 전체 서버가 알아야 누구에게 보낼지 판단 가능해야 함

Kafka Topic: chat-messages
┌────────────────────┬────────────────────┐
│   WebSocket 서버 A │   WebSocket 서버 B │
│  groupId = ws-A    │  groupId = ws-B    │
│  Consume O         │  Consume O         │
└────────────────────┴────────────────────┘

→ 이 구조에서는 같은 메시지를 서버 A, B가 둘 다 수신 가능
→ 왜냐? 서로 다른 groupId 이기 때문이야!

 

 

"Kafka 파티션 하나에는 group 하나만 붙을 수 있다?"
→ ❌ 아님!

 

 

  • Kafka 파티션은 여러 Consumer Group이 동시에 읽을 수 있음
  • 단, 한 Consumer Group 안에서는 같은 파티션을 여러 Consumer가 공유할 수 없음

 

'학습 기록 (Learning Logs) > Today I Learned' 카테고리의 다른 글

7장 스케쥴링  (0) 2025.03.31
📘 6장. 제한적 직접 실행 원리  (0) 2025.03.31
정렬  (0) 2025.03.26
원자성  (0) 2025.03.25
비관적락, 낙관적락  (0) 2025.03.25