
채팅 서비스에서 '차단'은 생각보다 단순한 기능이 아닙니다.
차단을 친구 목록에서 상대를 지우거나, 데이터베이스에서 관계를 끊는 정도로 생각하기 쉽습니다.
하지만 실제 서비스에서의 차단은 "상대방은 내가 차단당했다는 사실을 몰라야 한다"는 요구사항을 동반할 수 있습니다.
차단된 사용자는 여전히 메시지를 보낼 수 있어야 하고, 차단한 사용자에게만 그 이후의 메시지가 노출되지 않아야 합니다.
이 글은 ko_chat 프로젝트에 반영한 친구, 차단, 읽지 않음 처리 구조를 기반으로, 왜 메시지 저장 자체보다 '누가, 언제의 메시지를 볼 수 있는가'라는 가시성 제어 정책이 더 중요한 문제였는지 정리합니다.
기존 채팅 구조는 chat_rooms(채팅방), chat_room_members(참여자), messages(메시지)를 중심으로 구성되어 있었습니다.
메시지가 생성되면 데이터베이스에 저장되고, 해당 방에 속한 멤버라면 REST API를 통한 내역 조회와 WebSocket을 통한 실시간 수신이 가능한 형태였습니다.
구조 자체는 자연스러웠습니다. 도메인의 규모가 작을 때는 "방에 속해 있는가"만 확인해도 대부분의 요구사항을 처리할 수 있기 때문입니다.
하지만 여기에 '상대방이 눈치채지 못하는 차단 기능'이 들어오면 이야기가 달라집니다.
기존 시스템은 이미 운영 중이었습니다.
메시지는 계속 쌓이고 있었고, WebSocket을 통해 실시간 전송도 이루어지고 있었습니다.
여기서 차단당한 사용자에게 전송 실패 피드백을 주거나 메시지 저장 자체를 막아버리면 "차단 사실을 몰라야 한다"는 요구사항을 깨뜨리게 됩니다.
결국 기존 메시지 저장 흐름은 유지하면서, 특정 사용자에게만 일부 메시지를 숨기는 필터가 필요했습니다.
이 프로젝트에서 선택한 방식은 user_blocks 테이블에 차단 시점과 해제 시점을 기록하고, 메시지를 조회하거나 실시간으로 전송할 때 수신자 기준으로 구간 필터링을 적용하는 구조였습니다.
가장 먼저 고민했던 대안은 차단이 일어난 순간 메시지 저장 자체를 막거나, 가짜 성공 응답을 보내는 방식이었습니다.
이 방식은 구현이 직관적이라는 장점이 있습니다.
하지만 단체 채팅방이나 운영자 모니터링 환경에서는 한계가 명확했습니다.
예를 들어 단체방에서 B가 보낸 메시지를, B를 차단한 A는 보지 못해야 합니다.
하지만 차단 관계가 없는 C와 시스템 관리자는 같은 메시지를 정상적으로 볼 수 있어야 합니다.
메시지 저장 단계에서 데이터를 누락시키면 이런 다자간 가시성 제어 조건을 만족하기 어렵습니다.
이 선택으로 얻은 가장 큰 이득은 메시지 원본의 데이터 무결성을 유지하면서, 사용자별 상황에 따라 가시성만 다르게 가져갈 수 있다는 점이었습니다.
메시지 원본은 손대지 않고 그대로 저장하고, 누가 볼 수 있는지는 조회·표현 계층에서 판단하는 구조입니다.
대신 감수해야 하는 비용도 있었습니다.
데이터베이스 조회 쿼리 영역과 WebSocket 전송 경로 양쪽 모두에서 같은 차단 필터링 규칙을 적용해야 했습니다.
이 규칙이 조금이라도 어긋나면 REST 조회 결과와 실시간 수신 결과가 달라질 수 있었습니다.
// 핵심 로직만 발췌
class UserBlockJpaEntity {
var blocker: UserJpaEntity? = null
var blocked: UserJpaEntity? = null
var isActive: Boolean = true
var blockedAt: LocalDateTime = LocalDateTime.now()
var unblockedAt: LocalDateTime? = null
}
친구 차단 역시 단순한 '관계 삭제'가 아니라 '관계 상태 변경'으로 다루었습니다.
차단이 발생하면 기존 친구 관계는 비활성화됩니다.
하지만 훗날 차단을 해제한다고 해서 과거의 친구 관계가 자동으로 복구되지는 않습니다.
필요하다면 다시 친구 신청을 하도록 흐름을 분리했습니다.
fun blockUser(blockerId: Long, blockedId: Long): UserRelationshipDto {
// 1. 기존 친구 관계 비활성화
userFriendJpaRepository.deactivateFriend(blockerId, blockedId)
// 2. 이력을 추적할 수 있는 차단 엔티티 생성 및 저장
val block = UserBlockJpaEntity().apply {
this.blocker = blocker
this.blocked = blocked
this.blockedAt = LocalDateTime.now()
}
return blockToRelationshipDto(userBlockJpaRepository.save(block))
}
이 설계에서 가장 중요한 부분은 메시지를 조회할 때 적용하는 필터 조건이었습니다.
차단 여부를 단순히 '현재 차단 상태인가?'라는 Boolean 값으로만 판단해서는 안 됩니다.
A가 B를 차단했다가 몇 달 후에 해제했을 때, 과거 차단 기간 중에 B가 보낸 메시지들이 갑자기 화면에 나타날 수 있기 때문입니다.
따라서 판단 기준은 '현재 차단 중인가'가 아니라, 이 메시지가 생성된 시점이 사용자의 차단 유효 구간 안에 포함되는가가 되어야 했습니다.
-- 메시지 조회 쿼리의 핵심 필터 조건
AND NOT EXISTS (
SELECT 1 FROM UserBlockJpaEntity ub
WHERE ub.blocker.id = :viewerUserId
AND ub.blocked.id = m.sender.id
AND ub.blockedAt <= m.createdAt
AND (ub.unblockedAt IS NULL OR ub.unblockedAt > m.createdAt)
)
이 조건을 통해 메시지 작성 시간이 차단 시작 시점과 차단 해제 시점 사이에 존재한다면 결과 집합에서 제외됩니다.
WebSocket 전송 로직에도 같은 기준을 적용했습니다.
private fun canReceiveMessage(viewerUserId: Long, message: ChatMessage): Boolean {
// 본인이 보낸 메시지이거나 관리자라면 필터를 통과한다
if (viewerUserId == message.senderId) return true
if (isAdminUser(viewerUserId)) return true
// 메시지 생성 시점이 차단 유효 기간에 걸쳐있는지 검증
return !userBlockJpaRepository.existsBlockCoveringMessage(
blockerId = viewerUserId,
blockedId = message.senderId,
messageCreatedAt = message.timestamp,
)
}
관리자는 예외로 두었습니다.
사후 분쟁 조정이나 모니터링 요구사항상 관리자에게는 차단 필터와 무관하게 전체 대화 원본을 조회할 수 있는 권한이 필요했기 때문입니다.
다만 성능 관점에서는 매 메시지 전송마다 차단 구간을 조회하는 구조가 부담이 될 수 있습니다.
실제 운영 규모가 커진다면 차단 구간 정보를 Redis 같은 캐시 레이어에 올리고, 차단/해제 이벤트가 발생했을 때 캐시를 갱신하는 방식까지 고려해야 합니다.
또한 user_blocks(blocker_id, blocked_id, blocked_at, unblocked_at) 기준의 인덱스와 messages(room_id, created_at) 조회 인덱스도 함께 봐야 합니다.
이 필터는 메시지 목록 조회뿐 아니라 Unread Count 계산에도 반복해서 사용되기 때문에, 기능 구현 이후에는 조회 비용이 병목이 될 수 있습니다.
초기에는 친구와 차단을 하나의 테이블에서 공통 상태 값으로 관리하려고 했습니다.
하지만 차단, 친구 신청, 친구 해제가 엉키면서 '차단을 해제했더니 예전 친구 관계가 다시 살아나는' 문제가 발생했습니다.
해결책: 관계의 성격을 분리했습니다. 친구 관계는 user_friends, 차단 관계는 user_blocks라는 별도 테이블로 나누었습니다.
남은 한계: 도메인이 분리되면서 사용자의 복합적인 관계 상태를 한 번에 조회하는 API의 쿼리 복잡도는 증가했습니다.
REST API로 과거 내역을 조회할 때는 차단된 메시지가 보이지 않는데, 앱을 켜둔 실시간 상태에서는 WebSocket으로 메시지가 그대로 도착하는 문제가 있었습니다.
원인은 간단했습니다.
REST 조회 쪽에는 차단 필터가 들어가 있었지만, WebSocket 전송 경로에는 같은 기준이 적용되어 있지 않았습니다.
해결책: MessageJpaRepository의 조회 조건과 WebSocketSessionManager의 전송 판단 로직에 같은 타임라인 기반 차단 검증을 적용했습니다.
남은 한계: REST, WebSocket, Unread Count 쪽에 유사한 필터 조건이 흩어지면서 중복 냄새가 생겼습니다.
장기적으로는 차단 판정을 전담하는 MessageVisibilityPolicy 같은 정책 객체로 분리하는 편이 더 낫다고 판단했습니다.
가장 까다로웠던 부분은 읽지 않음 카운트였습니다.
보통은 마지막으로 읽은 메시지 ID 이후의 메시지 개수를 세면 됩니다.
하지만 여기에 차단된 메시지가 포함되면 사용자는 "알림 숫자는 떠 있는데 방에 들어가면 볼 수 있는 메시지가 없는" 상황을 겪게 됩니다.
이번 작업에서 차단 정책의 모든 확장 케이스를 한 번에 해결하려고 하지는 않았습니다.
예를 들어 "차단을 해제한 이후 사용자가 원하면 차단 기간 중에 온 메시지도 복구해서 보여줄 것인가?" 같은 정책은 일부러 범위에서 제외했습니다.
이 부분은 기술 문제라기보다 서비스 정책에 가까웠고, 기획 방향에 따라 언제든 바뀔 수 있는 영역이었기 때문입니다.
대신 한 가지는 확실히 지켰습니다.
어떤 정책으로 바뀌든 메시지 원본은 손대지 않는다는 것입니다.
가시성은 조회 시점에 계산하는 값이지 저장 시점에 확정하는 값이 아니라고 정해두면, 정책이 바뀌어도 저장된 데이터를 다시 마이그레이션할 필요가 없습니다.
돌아보면 이 프로젝트에서 가장 잘한 결정은 차단을 Boolean이 아니라 시간 구간으로 본 것이었습니다.
그 덕분에 차단 해제 후에도 과거 대화가 부활하지 않았고, 같은 판단 기준을 REST 조회·WebSocket 전송·Unread Count 세 곳에 그대로 적용할 수 있었습니다.
반대로 가장 아쉬운 부분은 그 판단 로직을 여러 경로에 나누어 심어둔 것입니다.
처음 필터 불일치 버그를 만났을 때는 양쪽 스펙을 맞추는 식으로 급히 막았습니다. 하
지만 지금이라면 차단 판정만 전담하는 정책 객체를 먼저 분리한 뒤, REST 조회·WebSocket 전송·Unread Count 계산이 모두 같은 정책을 사용하도록 만들 것 같습니다.
같은 규칙이 여러 군데에 있으면, 다음에 정책이 바뀔 때 한쪽만 고치고 다른 쪽을 빠뜨릴 위험이 그대로 남습니다.
결국 이 작업의 핵심은 "차단했는가?"가 아니라 "이 사용자가 이 시점의 이 메시지를 볼 수 있는가?"였습니다.
차단 기능은 관계 상태의 문제가 아니라, 메시지 가시성 정책의 문제였습니다.
이전포스팅: DB 대신 MinIO를 선택한 이유