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 또는 그룹 대화에서 읽음 처리, 안 읽은 메시지 계산 등에 유용
클라이언트가 메시지를 읽었을 때 "읽음 이벤트"를 서버에 전송
예시 흐름:
- 클라이언트가 채팅방을 열었거나, 특정 메시지까지 읽음
- 클라이언트 → 서버에 마지막 읽은 messageId 전달
- 서버에서는:
- 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 → 메시지 복사 (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 |