유저 신고 기능을 위한 Redis 채팅 로그 관리
유저 신고를 위한 채팅 로그 관리
게임 내 실시간 채팅 기능을 제공하면서, 유저 신고 기능도 자연스럽게 필요해졌다.
단순히 채팅 한 줄만 저장해서 신고하는 방식은 정확한 맥락 파악이 어렵기 때문에, 신고된 메시지를 기준으로 앞뒤의 대화 흐름까지 함께 저장하는 구조가 필수적이었다.
이를 위해 나는 Redis를 활용한 채팅 로그 저장 시스템을 설계했고, 그 과정에서 여러 가지 현실적인 문제에 직면하게 되었다.
Redis TTL 방식의 한계
처음에는 Redis를 활용해 각 채팅 로그에 TTL을 설정하고, 일정 시간이 지나면 자동으로 삭제되도록 구현했다. 이 방식은 메모리 누수를 방지하고 효율적인 리소스 관리를 가능하게 해주는 좋은 방법이다.
하지만 직접 테스트하면서 아래와 같은 문제점들을 발견했다:
- 시간 기반 TTL의 경우 신고 직전에 채팅 로그가 삭제되는 문제
- 수량 기반의 경우 채팅이 폭주할 때 필요한 채팅로그가 삭제되는 문제
그래서 이렇게 설계했다
채팅 로그 저장 방식
채팅 메시지는 Redis의 Sorted Set(ZSet) 구조를 활용해 저장된다. 각 메시지는 다음과 같은 방식으로 저장된다:
- Redis 키는 채팅 유형에 따라 다음과 같이 구분된다:
- 메시지는 직렬화(serialize)된 후,
sentAt시점을 기준으로 score에 현재 timestamp(millisecond)를 부여하여 저장된다. - 이 구조 덕분에 시간순 정렬, 범위 기반 조회, 시간 기반 삭제 처리가 가능하다.
public void saveChatMessage(ChatType chatType, String identifier, ChatMessage message, String senderId) {
String key = chatKey(chatType, identifier, senderId);
String serialized = jsonSerializer.serialize(message, CHAT_ERROR);
double score = Instant.now().toEpochMilli();
redisTemplate.opsForZSet().add(key, serialized, score);
}시간 기준 TTL 삭제는 유지하되, 정기적인 청소 방식으로 보완
나는 Redis의 TTL을 단순히 설정해놓고 자동 삭제되도록 내버려두기보다는, 내가 직접 삭제 시점을 컨트롤할 수 있도록 변경했다.
이를 위해 아래처럼 구성했다:
ChatLogService에서 Redis Sorted Set을 사용해 메시지를 저장 (score: timestamp)- 메시지 저장 시 TTL이 아닌 타임스탬프 기반으로 수동 정리
@Scheduled로 1분마다 만료 메시지 삭제 작업 실행
@Scheduled(fixedRate = 60000)
public void cleanExpiredChatLogs() {
// 전체 채팅 로그
chatLogService.removeExpiredMessages(ChatType.ALL, null);
// 방별 채팅 로그
Set<String> roomIds = roomRepository.getAllRoomIds();
for (String roomId : roomIds) {
chatLogService.removeExpiredMessages(ChatType.ROOM, roomId);
}
// 개인 채팅 로그
Set<String> privateKeys = chatLogService.getPrivateChatKeys();
for (String key : privateKeys) {
chatLogService.removeExpiredMessagesByKey(key);
}
}이 방식의 장점은 다음과 같다:
- 삭제 주기를 세밀하게 제어할 수 있음
- 데이터가 필요할 때는 TTL과 상관없이 조회 가능
- Redis 자체 TTL보다 유연하게 동작
유저 신고 흐름
- 사용자가 유저를 신고하면, 해당 채팅의
sentAt기준 앞뒤 10개 채팅 로그를 Redis에서 가져온다. - 이 때
ChatLogService.getSurroundingMessages()를 통해 문맥을 함께 저장한다. - 이후 해당 데이터를 기반으로 신고 DB에 저장되고, 관리자 검토로 이어진다.
List<ChatMessage> chatLog = chatLogService.getSurroundingMessages(
reportReq.chatType(),
reportReq.identifier(),
userDetails.getUuid(),
reportReq.reportedChatTime(),
10
);//신고한 채팅을 기준으로 앞뒤 10개의 채팅 가져오기
public List<ChatMessage> getSurroundingMessages(ChatType chatType, String identifier, String senderId, LocalDateTime baseTime, int range) {
String key = chatKey(chatType, identifier, senderId);
long baseTimestamp = baseTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
// 기준 메시지
Set<String> baseMessageRaw = redisTemplate.opsForZSet()
.rangeByScore(key, baseTimestamp, baseTimestamp);
// 앞쪽 메시지 (기준 이전 메시지들)
Set<String> beforeRaw = redisTemplate.opsForZSet()
.reverseRangeByScore(key, 0, baseTimestamp - 1, 0, range);
// 뒤쪽 메시지 (기준 이후 메시지들)
Set<String> afterRaw = redisTemplate.opsForZSet()
.rangeByScore(key, baseTimestamp + 1, Double.MAX_VALUE, 0, range);
// 병합 후 정렬 (총 20개)
List<String> combined = new ArrayList<>(beforeRaw);
combined.addAll(baseMessageRaw);
combined.addAll(afterRaw);
return combined.stream()
.map(raw -> jsonSerializer.deserialize(raw, ChatMessage.class, CHAT_ERROR))
.sorted(Comparator.comparing(ChatMessage::sentAt))
.collect(Collectors.toList());
}이 방식 덕분에 한 줄짜리 채팅만으로 신고 여부를 판단하는 것이 아니라, 대화 흐름 전체를 고려할 수 있게 되었다.
그러나, 이 방식도 완벽하진 않다
현재 설계 방식은 기존 TTL 방식보다 유연하지만, 여전히 몇 가지 단점과 개선 여지가 있다.
1. Redis 메모리 사용 증가
- 채팅 로그가 TTL 없이 Redis에 계속 쌓이기 때문에 활성 유저 수가 많거나 대화량이 많은 상황에서는 Redis 메모리를 빠르게 소비할 수 있다.
- 정리 주기가 짧다 해도 고속으로 채팅이 쌓이면 처리 지연 혹은 메모리 부족 문제가 생길 가능성이 있다.
🔧 개선 방향
- 채팅 로그에 대해 유저 단위, 방 단위의 최대 저장 개수 제한을 두고 초과 시 오래된 메시지부터 제거하는 방식 고려
- Redis 외에 ElasticSearch, Kafka + S3 등 로그 전용 스토리지로 이관하는 방안도 중장기적으로 검토할 수 있다.
2. 1분 주기의 정리 간격 문제
- 정리 주기가 1분이기 때문에 정리 직전의 시점에 저장된 채팅은 여전히 최대 59초간 유지된다.
- 엄밀히 말해 완전히 실시간 삭제는 아니기 때문에 민감한 이슈(욕설, 불법 채팅 등)에 대해 즉각 반응이 어려울 수 있다.
🔧 개선 방향
- 중요도가 높은 채팅 로그에 한해 별도 채널로 실시간 필터링 + 로그 저장 처리
- 신고 발생 시 해당 키에 대해 TTL을 일시 중지하거나 일정 시간 동안 강제로 보존하는 기능 도입
3. 채팅 키 수 증가로 인한 키 관리 부담
chat:room:{roomId}:log,chat:private:{user1}:{user2}:log등으로 다양한 키가 생성되는데,
🔧 개선 방향
- Redis Cluster 환경으로 확장하거나 비활성 키를 주기적으로 모니터링 및 제거하는 유지 관리 루틴 추가
- 비중이 낮은 채널은 저장 주기 또는 정리 빈도를 줄여 분산 처리
정리하며
신고 기능의 신뢰성과 사용성 확보를 위해 단순 TTL이 아닌 수동 삭제 기반의 정리 방식을 도입했다.
이를 통해 데이터 유실을 최소화하고 정확한 채팅 흐름 보존이 가능해졌다.
그러나 여전히 Redis 특성상 메모리 관리, 키 수 관리, 삭제 주기 최적화 등의 과제는 존재하며
이를 해결하기 위해 다음과 같은 방향으로 계속 개선해 나갈 계획이다:
- 저장량 제한 및 우선순위 기반 보존 정책 도입
- 로그 저장소 분리 (e.g., DB, 로그 전용 스토리지)
- 정리 스케줄 최적화 및 키 정리 로직 개선
운영 환경에서의 경험을 통해 얻은 교훈들을 바탕으로 더 효율적이고 견고한 구조로 리팩토링해나갈 예정이다.
함께 읽으면 좋은 글
대규모 트래픽 경험기 - 대기열
이전 글에서 Pessimistic Lock으로 동시성 문제를 해결했다. 이번에는 한 단계 앞으로 가서, "애초에 서버에 동시에 들어오는 요청 수를 제어하면 어떨까?" 라는 질문에서 출발한다. 콘서트 티켓 오픈 시간에 2000명이 동시에 좌석 조회를 요청한다고 가정하자.
대규모 트래픽 경험기 - 동시성 제어
현재 코드는 성능적인 문제를 어느정도 해결했지만 동시성 문제를 해결하지 못한다. 예를들어, 10명의 사람이 한 좌석에 대해 동시에 예약을 한다면 아래와 같이 10명 모두 같은 좌석을 예매할 수 있게 된다.
대규모 트래픽 경험기 - SSE
3편에서 API를 seat-map(정적 메타)과 seat-status(동적 상태)로 분리하고, DTO 프로젝션으로 조회 비용을 줄였다. 2000명 동시 접속 기준 TPS 51 → 136, p95 36초 → 13초. 처리량 2.7배, Tail Latency 2.6배 개선이었다.
대규모 트래픽 경험기 - api 분리
Redis 캐시를 적용했지만 결과는 기대 이하였다. TPS 48 → 57, 평균 응답 19.2초 → 18.2초. DB 접근은 2초에 1회까지 줄였는데도 체감 성능은 거의 그대로였다. 분석 결과 병목은 이미 DB가 아니었다.