이미지 로딩 중...
AI Generated
2025. 11. 22. · 3 Views
1:1 채팅방 생성 및 관리 완벽 가이드
실시간 채팅 서비스에서 가장 중요한 1:1 채팅방을 어떻게 생성하고 관리하는지 단계별로 알아봅니다. 중복 방지부터 참여자 관리, 삭제 처리까지 실무에서 바로 사용할 수 있는 API 구현 방법을 상세히 다룹니다.
목차
1. 개인_채팅방_생성_API
시작하며
여러분이 카카오톡이나 메신저 앱에서 친구와 처음 대화를 시작할 때를 생각해보세요. 상대방을 선택하고 메시지를 보내면 자동으로 채팅방이 만들어지죠.
이런 마법 같은 일이 실제로는 어떻게 일어나는 걸까요? 실제 개발 현장에서는 이 "채팅방 생성"이라는 기능이 생각보다 복잡합니다.
단순히 방을 만드는 것뿐만 아니라, 이미 존재하는 방인지 확인하고, 두 사람의 정보를 저장하고, 여러 예외 상황을 처리해야 하거든요. 바로 이럴 때 필요한 것이 체계적인 채팅방 생성 API입니다.
이 API는 사용자의 요청을 받아 안전하게 채팅방을 만들고, 필요한 모든 정보를 데이터베이스에 저장하며, 즉시 사용 가능한 채팅방 정보를 반환합니다.
개요
간단히 말해서, 채팅방 생성 API는 두 사용자 사이의 대화 공간을 만드는 서버의 입구입니다. 이 API가 필요한 이유는 명확합니다.
사용자가 누군가와 대화를 시작하려 할 때마다, 서버는 안전하고 일관된 방식으로 채팅방을 생성해야 합니다. 예를 들어, 이커머스 앱에서 구매자가 판매자에게 문의하거나, 소셜 앱에서 새로운 친구와 대화를 시작하는 경우에 매우 유용합니다.
전통적인 방법으로는 프론트엔드에서 직접 채팅방을 만들려고 시도했지만, 이제는 서버 중심의 API를 통해 중앙 집중식으로 관리할 수 있습니다. 이렇게 하면 데이터 일관성과 보안을 보장할 수 있죠.
이 API의 핵심 특징은 세 가지입니다: 첫째, RESTful 엔드포인트로 표준화된 접근, 둘째, 트랜잭션을 통한 데이터 무결성 보장, 셋째, 즉각적인 응답으로 사용자 경험 개선. 이러한 특징들이 안정적인 채팅 서비스의 기반이 됩니다.
코드 예제
// POST /api/chat/rooms - 1:1 채팅방 생성 엔드포인트
app.post('/api/chat/rooms', async (req, res) => {
const { userId, targetUserId } = req.body;
// 입력 검증: 필수 파라미터 확인
if (!userId || !targetUserId) {
return res.status(400).json({ error: '사용자 ID가 필요합니다' });
}
try {
// 트랜잭션 시작: 데이터 일관성 보장
const room = await db.transaction(async (trx) => {
// 새로운 채팅방 생성
const [newRoom] = await trx('chat_rooms').insert({
room_type: 'one_to_one',
created_at: new Date(),
updated_at: new Date()
}).returning('*');
// 두 사용자를 참여자로 추가
await trx('room_participants').insert([
{ room_id: newRoom.id, user_id: userId, joined_at: new Date() },
{ room_id: newRoom.id, user_id: targetUserId, joined_at: new Date() }
]);
return newRoom;
});
// 성공 응답 반환
res.status(201).json({
success: true,
roomId: room.id,
message: '채팅방이 생성되었습니다'
});
} catch (error) {
// 에러 처리
console.error('채팅방 생성 실패:', error);
res.status(500).json({ error: '채팅방 생성에 실패했습니다' });
}
});
설명
이것이 하는 일: 클라이언트로부터 두 사용자의 ID를 받아서, 데이터베이스에 새로운 채팅방을 만들고, 두 사용자를 참여자로 등록한 후, 생성된 채팅방의 정보를 반환합니다. 첫 번째로, 요청 검증 단계에서 userId와 targetUserId가 제대로 전달되었는지 확인합니다.
이 검증은 매우 중요한데, 만약 이 정보가 없으면 누구와 누구의 채팅방인지 알 수 없기 때문입니다. 검증에 실패하면 즉시 400 에러를 반환하여 클라이언트에게 문제를 알립니다.
그 다음으로, 데이터베이스 트랜잭션이 시작됩니다. 트랜잭션은 마치 "전부 성공하거나, 전부 실패하거나" 하는 안전장치와 같습니다.
먼저 chat_rooms 테이블에 새로운 방을 만들고, 그 방의 ID를 받아옵니다. 그리고 room_participants 테이블에 두 사용자의 정보를 동시에 추가합니다.
만약 중간에 에러가 발생하면 모든 작업이 취소되어 데이터가 꼬이는 일을 방지합니다. 마지막으로, 모든 작업이 성공하면 201 Created 상태 코드와 함께 새로 만들어진 채팅방의 ID를 반환합니다.
이 ID는 클라이언트가 이후 메시지를 보내거나 채팅방에 접속할 때 사용하는 중요한 식별자입니다. 여러분이 이 코드를 사용하면 안전하고 일관된 방식으로 채팅방을 생성할 수 있습니다.
데이터 무결성이 보장되고, 에러 상황에서도 적절한 응답을 반환하며, 클라이언트는 즉시 채팅방을 사용할 수 있는 상태가 됩니다.
실전 팁
💡 항상 트랜잭션을 사용하세요. 방은 생성됐는데 참여자 추가가 실패하면 유령 채팅방이 생깁니다. 트랜잭션으로 "모두 성공 또는 모두 실패"를 보장해야 합니다.
💡 생성 시각(created_at)과 수정 시각(updated_at)을 반드시 기록하세요. 나중에 "최근 대화방" 정렬이나 비활성 방 정리에 매우 유용합니다.
💡 201 Created 상태 코드를 사용하세요. 200 OK가 아닌 201을 반환하면 RESTful API 표준을 따르고, 클라이언트가 "새로운 리소스가 생성됨"을 명확히 알 수 있습니다.
💡 에러 로깅을 빠뜨리지 마세요. console.error로 서버 로그에 에러를 남기면 나중에 문제 추적이 훨씬 쉬워집니다. 프로덕션에서는 Sentry 같은 에러 추적 도구와 연동하세요.
💡 room_type 필드로 채팅방 유형을 구분하세요. 나중에 그룹 채팅, 공지방 등 다른 타입의 방을 추가할 때 같은 테이블 구조를 재사용할 수 있습니다.
2. 중복_채팅방_방지_로직
시작하며
여러분이 친구와 이미 대화하고 있는 채팅방이 있는데, 실수로 또 다른 채팅방을 만들어버린 적이 있나요? 그러면 같은 사람과의 대화가 두 개의 방에 나뉘어져서 매우 혼란스러워집니다.
이런 문제는 실제 서비스에서 심각한 사용자 경험 저하로 이어집니다. 사용자는 어느 방에서 대화해야 할지 혼란스럽고, 과거 대화 내역을 찾기도 어려워집니다.
더 나아가 데이터베이스에 불필요한 중복 데이터가 쌓이게 되죠. 바로 이럴 때 필요한 것이 중복 채팅방 방지 로직입니다.
이 로직은 채팅방을 만들기 전에 이미 같은 사람들끼리의 채팅방이 존재하는지 확인하고, 존재한다면 새로 만들지 않고 기존 방을 반환합니다.
개요
간단히 말해서, 중복 방지 로직은 채팅방 생성 전에 "이미 이 두 사람의 방이 있나요?"를 확인하는 문지기 역할을 합니다. 이 로직이 필요한 이유는 1:1 채팅의 특성 때문입니다.
그룹 채팅과 달리 1:1 채팅은 같은 두 사람 사이에 여러 개가 존재할 필요가 없습니다. 예를 들어, 고객센터 문의 채팅에서 같은 고객과 상담사 사이에 여러 개의 방이 생기면 이전 문의 내역을 찾을 수 없게 되어 서비스 품질이 떨어집니다.
기존에는 클라이언트에서 중복을 체크하려 했지만, 동시에 여러 요청이 들어오면 실패했습니다. 이제는 데이터베이스 레벨에서 원자적으로 체크하여 완벽하게 중복을 방지할 수 있습니다.
이 로직의 핵심 특징은 세 가지입니다: 첫째, 방향성 무관 검색으로 A-B든 B-A든 같은 방으로 인식, 둘째, 트랜잭션 내에서 체크하여 동시성 문제 해결, 셋째, 기존 방 발견 시 즉시 반환으로 불필요한 작업 방지. 이러한 특징들이 깨끗한 데이터 구조를 유지하게 합니다.
코드 예제
// 중복 채팅방 검사 및 생성 로직
async function findOrCreateRoom(userId, targetUserId) {
// 트랜잭션으로 동시성 문제 방지
return await db.transaction(async (trx) => {
// 기존 채팅방 검색 - 양방향 체크
// A->B 방향과 B->A 방향 모두 확인
const existingRoom = await trx('chat_rooms')
.select('chat_rooms.*')
.join('room_participants as p1', 'chat_rooms.id', 'p1.room_id')
.join('room_participants as p2', 'chat_rooms.id', 'p2.room_id')
.where('chat_rooms.room_type', 'one_to_one')
.where('p1.user_id', userId)
.where('p2.user_id', targetUserId)
.first();
// 기존 방이 있으면 그대로 반환
if (existingRoom) {
return { room: existingRoom, isNew: false };
}
// 기존 방이 없으면 새로 생성
const [newRoom] = await trx('chat_rooms').insert({
room_type: 'one_to_one',
created_at: new Date(),
updated_at: new Date()
}).returning('*');
// 두 사용자를 참여자로 추가
await trx('room_participants').insert([
{ room_id: newRoom.id, user_id: userId, joined_at: new Date() },
{ room_id: newRoom.id, user_id: targetUserId, joined_at: new Date() }
]);
return { room: newRoom, isNew: true };
});
}
설명
이것이 하는 일: 두 사용자 사이에 이미 채팅방이 존재하는지 데이터베이스에서 찾아보고, 있으면 그 방을 반환하고, 없으면 새로운 방을 만들어 반환합니다. 첫 번째로, 트랜잭션을 시작하여 전체 작업의 원자성을 보장합니다.
이게 중요한 이유는, 만약 두 사용자가 정확히 동시에 서로에게 채팅을 시작하면 어떻게 될까요? 트랜잭션 없이는 두 개의 방이 만들어질 수 있지만, 트랜잭션 안에서는 한 번에 하나씩만 처리되어 이런 문제를 방지합니다.
그 다음으로, 복잡해 보이는 JOIN 쿼리가 실행됩니다. 이 쿼리는 매우 영리한데, room_participants 테이블을 두 번 조인하여 "p1의 user_id가 userId이면서 동시에 p2의 user_id가 targetUserId인 방"을 찾습니다.
이렇게 하면 A가 B에게 먼저 말을 걸었든, B가 A에게 먼저 말을 걸었든 같은 방을 찾아냅니다. 방향성에 관계없이 검색할 수 있는 것이죠.
세 번째 단계에서는 검색 결과를 확인합니다. 기존 방이 발견되면 즉시 그 방을 반환하고 isNew: false 플래그를 함께 보냅니다.
이 플래그는 클라이언트가 "새 방이 생겼어요!" 같은 알림을 띄울지 말지 결정하는 데 유용합니다. 기존 방이 없으면 앞서 본 것처럼 새로운 방을 생성하고 isNew: true로 표시합니다.
여러분이 이 로직을 사용하면 사용자들은 항상 깨끗한 채팅 경험을 누릴 수 있습니다. 같은 사람과의 대화는 항상 하나의 방에서 이어지고, 과거 대화 내역도 쉽게 찾을 수 있으며, 데이터베이스에도 불필요한 중복이 쌓이지 않습니다.
실전 팁
💡 복합 인덱스를 반드시 생성하세요. room_participants 테이블에 (room_id, user_id) 복합 인덱스를 만들면 JOIN 쿼리 성능이 극적으로 향상됩니다. 사용자가 많아질수록 차이가 커집니다.
💡 soft delete를 고려하세요. 채팅방을 완전히 삭제하지 않고 deleted_at 필드를 사용하면, 중복 검사 시 "삭제되지 않은 방"만 찾도록 조건을 추가할 수 있습니다.
💡 Redis 캐싱을 활용하세요. 자주 대화하는 사용자 쌍의 room_id를 Redis에 캐싱하면 매번 데이터베이스를 조회하지 않아도 됩니다. 키 형식은 "room:{작은ID}:{큰ID}"처럼 정렬하여 방향성을 제거하세요.
💡 유니크 제약조건을 추가하세요. 데이터베이스 레벨에서 (room_id, user_id) 조합에 UNIQUE 제약을 걸면 같은 사용자가 한 방에 두 번 들어가는 것을 원천 차단할 수 있습니다.
💡 isNew 플래그를 활용하세요. 클라이언트에서 이 값으로 "새로운 채팅방이 시작되었습니다" 같은 UX를 제공하거나, 분석 데이터로 "신규 대화 시작 수"를 추적할 수 있습니다.
3. 채팅방_참여자_추가
시작하며
여러분이 이미 만들어진 채팅방에 새로운 사람을 초대하고 싶을 때가 있죠? 1:1 채팅에서는 흔하지 않지만, 때로는 관리자를 추가하거나, 고객센터 상담을 에스컬레이션할 때 필요합니다.
실무에서는 단순히 "사람을 추가한다"가 끝이 아닙니다. 이미 참여 중인 사람인지 확인하고, 권한을 체크하고, 추가된 사람에게 알림을 보내고, 기존 참여자들에게도 알려야 합니다.
한 단계라도 빠지면 채팅방이 엉망이 되죠. 바로 이럴 때 필요한 것이 체계적인 참여자 추가 API입니다.
이 API는 권한을 확인하고, 중복을 방지하며, 모든 관련자에게 적절히 알림을 전달하는 완벽한 흐름을 제공합니다.
개요
간단히 말해서, 참여자 추가 API는 기존 채팅방에 새로운 사용자를 안전하게 추가하는 게이트키퍼입니다. 이 API가 필요한 이유는 채팅방의 동적인 특성 때문입니다.
초기에는 1:1이었던 대화가 나중에 전문가의 도움이 필요할 수 있고, 팀장의 검토가 필요할 수도 있습니다. 예를 들어, 기술 지원 채팅에서 1차 상담원이 해결하지 못한 문제를 2차 전문가에게 에스컬레이션하는 경우에 매우 유용합니다.
전통적인 방법으로는 그냥 데이터베이스에 레코드만 추가했지만, 이제는 권한 검증, 중복 체크, 실시간 알림까지 포함하는 완전한 비즈니스 로직으로 처리합니다. 이렇게 하면 사용자 경험이 훨씬 부드러워지고 예상치 못한 버그가 줄어듭니다.
이 API의 핵심 특징은 세 가지입니다: 첫째, 권한 기반 접근 제어로 아무나 사람을 추가할 수 없게 함, 둘째, 멱등성 보장으로 같은 사람을 여러 번 추가해도 안전, 셋째, 이벤트 기반 알림으로 모든 관련자에게 실시간 업데이트. 이러한 특징들이 안정적인 채팅 환경을 만듭니다.
코드 예제
// POST /api/chat/rooms/:roomId/participants - 참여자 추가
app.post('/api/chat/rooms/:roomId/participants', async (req, res) => {
const { roomId } = req.params;
const { userId, newParticipantId } = req.body;
try {
// 권한 확인: 요청자가 이 방의 참여자인지 검증
const isParticipant = await db('room_participants')
.where({ room_id: roomId, user_id: userId })
.first();
if (!isParticipant) {
return res.status(403).json({ error: '권한이 없습니다' });
}
// 중복 확인: 이미 참여 중인지 체크
const alreadyExists = await db('room_participants')
.where({ room_id: roomId, user_id: newParticipantId })
.first();
if (alreadyExists) {
return res.status(200).json({
message: '이미 참여 중인 사용자입니다',
participant: alreadyExists
});
}
// 새 참여자 추가
const [participant] = await db('room_participants').insert({
room_id: roomId,
user_id: newParticipantId,
joined_at: new Date()
}).returning('*');
// 실시간 알림 전송 (WebSocket)
io.to(`room_${roomId}`).emit('participant_joined', {
roomId,
participant: newParticipantId,
joinedAt: participant.joined_at
});
res.status(201).json({
success: true,
participant
});
} catch (error) {
console.error('참여자 추가 실패:', error);
res.status(500).json({ error: '참여자 추가에 실패했습니다' });
}
});
설명
이것이 하는 일: 채팅방에 새로운 사용자를 추가하되, 추가하려는 사람이 권한이 있는지 확인하고, 이미 참여 중인지 체크하며, 성공 시 모든 참여자에게 실시간으로 알립니다. 첫 번째로, 권한 검증이 이루어집니다.
누구나 아무 채팅방에 사람을 추가할 수 있다면 보안 문제가 심각하겠죠? 그래서 요청을 보낸 userId가 실제로 이 채팅방의 참여자인지 데이터베이스에서 확인합니다.
참여자가 아니라면 403 Forbidden 에러를 반환하여 접근을 차단합니다. 이는 마치 집에 초대받지 않은 사람이 다른 손님을 데려올 수 없는 것과 같습니다.
그 다음으로, 중복 체크가 진행됩니다. 이미 참여 중인 사람을 또 추가하려고 하면 어떻게 될까요?
에러를 발생시키는 대신, 이 코드는 멱등성(idempotency)을 보장합니다. 즉, "이미 참여 중입니다"라는 200 OK 응답과 함께 기존 참여자 정보를 반환합니다.
이렇게 하면 클라이언트가 실수로 같은 요청을 여러 번 보내도 안전합니다. 세 번째 단계에서는 실제 추가 작업이 일어납니다.
room_participants 테이블에 새로운 레코드를 삽입하고, joined_at 타임스탬프를 기록합니다. 그리고 가장 중요한 부분인 실시간 알림이 전송됩니다.
Socket.io의 io.to() 메서드로 해당 채팅방에 연결된 모든 클라이언트에게 'participant_joined' 이벤트를 보냅니다. 이렇게 하면 채팅방을 보고 있는 모든 사람이 즉시 "새로운 사람이 들어왔어요!"라는 메시지를 볼 수 있습니다.
여러분이 이 코드를 사용하면 채팅방 참여자 관리가 안전하고 예측 가능해집니다. 권한이 없는 사용자의 접근을 막고, 중복 추가의 위험을 제거하며, 모든 관련자가 실시간으로 변화를 인지할 수 있습니다.
실전 팁
💡 권한 레벨을 도입하세요. room_participants 테이블에 role 컬럼(admin, member 등)을 추가하면 "관리자만 사람을 추가할 수 있다"는 더 세밀한 제어가 가능합니다.
💡 추가 한도를 설정하세요. 1:1 채팅이 무한정 확장되면 성능 문제가 생깁니다. 참여자 수를 체크하여 일정 수(예: 10명) 이상이면 그룹 채팅으로 전환하도록 안내하세요.
💡 감사 로그를 남기세요. 누가 언제 누구를 추가했는지 별도 테이블(audit_logs)에 기록하면 나중에 문제 추적이나 사용자 신고 처리에 매우 유용합니다.
💡 읽지 않은 메시지 카운트를 초기화하세요. 새로 추가된 참여자의 unread_count는 0으로 시작해야 합니다. 과거 메시지를 "읽지 않음"으로 표시하면 사용자가 혼란스러워합니다.
💡 초대 알림을 개인화하세요. WebSocket 이벤트에 초대한 사람의 이름을 포함하면 "김철수님이 이영희님을 초대했습니다" 같은 친근한 메시지를 만들 수 있습니다.
4. 채팅방_목록_조회_API
시작하며
여러분이 카카오톡을 열었을 때 가장 먼저 보는 화면이 무엇인가요? 바로 채팅방 목록이죠.
최근 메시지가 온 순서대로 나열되고, 읽지 않은 메시지 개수가 표시되고, 마지막 메시지 내용이 미리 보입니다. 실제 개발에서 이 간단해 보이는 목록 화면은 놀랍도록 복잡합니다.
여러 테이블을 JOIN해야 하고, 읽지 않은 메시지를 계산해야 하며, 상대방의 프로필 정보를 가져와야 하고, 성능을 위해 페이지네이션도 구현해야 합니다. 한 가지라도 놓치면 느리거나 불완전한 목록이 나옵니다.
바로 이럴 때 필요한 것이 최적화된 채팅방 목록 조회 API입니다. 이 API는 복잡한 데이터를 효율적으로 가져오고, 사용자에게 필요한 모든 정보를 한 번의 요청으로 제공합니다.
개요
간단히 말해서, 채팅방 목록 API는 사용자가 참여 중인 모든 채팅방과 관련 정보를 빠르고 완전하게 제공하는 데이터 집계 엔진입니다. 이 API가 필요한 이유는 채팅 앱의 첫인상이 바로 이 목록에서 결정되기 때문입니다.
목록이 느리게 로딩되거나 정보가 부족하면 사용자는 즉시 앱을 떠납니다. 예를 들어, 고객 지원 플랫폼에서 상담원이 담당 중인 모든 대화를 한눈에 보고, 긴급한 문의를 우선 처리해야 하는 경우에 매우 중요합니다.
전통적인 방법으로는 프론트엔드에서 여러 API를 순차적으로 호출했지만, 이제는 백엔드에서 JOIN과 서브쿼리로 모든 데이터를 한 번에 가져올 수 있습니다. 이렇게 하면 네트워크 왕복 횟수가 줄어들고 사용자 경험이 극적으로 개선됩니다.
이 API의 핵심 특징은 세 가지입니다: 첫째, 다중 JOIN으로 채팅방, 참여자, 마지막 메시지를 한 번에 조회, 둘째, 서브쿼리로 읽지 않은 메시지 개수를 효율적으로 계산, 셋째, 페이지네이션으로 대량의 채팅방도 부드럽게 처리. 이러한 특징들이 빠르고 정보가 풍부한 목록을 만듭니다.
코드 예제
// GET /api/chat/rooms - 사용자의 채팅방 목록 조회
app.get('/api/chat/rooms', async (req, res) => {
const { userId, page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
try {
// 복잡한 JOIN 쿼리로 모든 정보 한 번에 가져오기
const rooms = await db('chat_rooms')
.select(
'chat_rooms.id',
'chat_rooms.room_type',
'chat_rooms.updated_at',
// 마지막 메시지 정보
'last_msg.content as last_message',
'last_msg.created_at as last_message_time',
'last_msg.sender_id',
// 상대방 정보 (1:1 채팅인 경우)
'other_user.id as partner_id',
'other_user.name as partner_name',
'other_user.avatar_url as partner_avatar'
)
// 내가 참여 중인 방만 필터링
.join('room_participants as my_part', function() {
this.on('chat_rooms.id', 'my_part.room_id')
.andOn('my_part.user_id', db.raw('?', [userId]));
})
// 상대방 정보 가져오기
.leftJoin('room_participants as other_part', function() {
this.on('chat_rooms.id', 'other_part.room_id')
.andOn('other_part.user_id', '!=', db.raw('?', [userId]));
})
.leftJoin('users as other_user', 'other_part.user_id', 'other_user.id')
// 마지막 메시지 가져오기 (서브쿼리)
.leftJoin('messages as last_msg', 'last_msg.id',
db('messages')
.select('id')
.whereRaw('messages.room_id = chat_rooms.id')
.orderBy('created_at', 'desc')
.limit(1)
)
.orderBy('chat_rooms.updated_at', 'desc')
.limit(limit)
.offset(offset);
// 각 방의 읽지 않은 메시지 개수 계산
const roomIds = rooms.map(r => r.id);
const unreadCounts = await db('messages')
.select('room_id')
.count('* as unread_count')
.whereIn('room_id', roomIds)
.where('sender_id', '!=', userId)
.whereNull('read_at')
.groupBy('room_id');
// 읽지 않은 개수를 각 방에 병합
const unreadMap = Object.fromEntries(
unreadCounts.map(u => [u.room_id, u.unread_count])
);
const result = rooms.map(room => ({
...room,
unread_count: unreadMap[room.id] || 0
}));
res.json({ rooms: result, page, hasMore: rooms.length === limit });
} catch (error) {
console.error('채팅방 목록 조회 실패:', error);
res.status(500).json({ error: '목록 조회에 실패했습니다' });
}
});
설명
이것이 하는 일: 사용자가 참여 중인 모든 채팅방을 최신순으로 가져오면서, 각 방의 상대방 정보, 마지막 메시지, 읽지 않은 메시지 개수를 모두 포함하여 한 번의 API 호출로 완전한 목록을 제공합니다. 첫 번째로, 메인 쿼리에서 여러 테이블을 JOIN합니다.
이 쿼리는 매우 정교한데, 먼저 chat_rooms 테이블에서 시작하여 room_participants를 두 번 JOIN합니다. 첫 번째 JOIN(my_part)은 "내가 참여 중인 방"을 필터링하고, 두 번째 JOIN(other_part)은 "나를 제외한 다른 참여자"의 정보를 가져옵니다.
1:1 채팅에서는 이게 곧 대화 상대방이 되죠. 그리고 users 테이블과 JOIN하여 상대방의 이름과 프로필 사진도 함께 가져옵니다.
그 다음으로, 마지막 메시지를 가져오는 영리한 서브쿼리가 실행됩니다. 각 채팅방의 가장 최근 메시지를 찾기 위해 messages 테이블에서 room_id로 필터링하고, created_at으로 내림차순 정렬한 후 첫 번째 것만 가져옵니다.
이렇게 하면 "마지막 메시지: 안녕하세요!" 같은 미리보기를 목록에 표시할 수 있습니다. updated_at으로 정렬하여 최근 활동이 있는 방이 맨 위에 오도록 합니다.
세 번째 단계에서는 읽지 않은 메시지 개수를 계산합니다. 이는 별도의 쿼리로 처리하는데, 왜냐하면 JOIN에 포함시키면 쿼리가 너무 복잡해지고 느려지기 때문입니다.
messages 테이블에서 각 room_id별로 그룹화하고, 발신자가 나가 아니면서 read_at이 null인 메시지를 카운트합니다. 그리고 JavaScript의 Object.fromEntries로 빠르게 검색 가능한 맵을 만들어, 각 채팅방 정보에 unread_count를 추가합니다.
마지막으로, 페이지네이션을 통해 한 번에 20개씩만 반환합니다. 사용자가 100개의 채팅방을 가지고 있어도 첫 로딩은 빠르게 이루어지고, 스크롤할 때마다 추가로 가져옵니다.
hasMore 플래그는 클라이언트가 "더 보기" 버튼을 표시할지 결정하는 데 사용됩니다. 여러분이 이 API를 사용하면 사용자는 앱을 열자마자 완전하고 최신의 채팅방 목록을 볼 수 있습니다.
로딩 속도도 빠르고, 필요한 모든 정보가 한 번에 제공되며, 대량의 채팅방도 부드럽게 처리됩니다.
실전 팁
💡 인덱스를 전략적으로 배치하세요. (room_id, created_at), (user_id, room_id), (room_id, read_at) 같은 복합 인덱스가 이 쿼리의 성능을 수십 배 개선합니다. EXPLAIN 명령으로 확인하세요.
💡 N+1 문제를 피하세요. 읽지 않은 개수를 각 방마다 별도로 조회하면 안 됩니다. 위 코드처럼 한 번의 쿼리로 모든 방의 개수를 가져온 후 메모리에서 병합하세요.
💡 Redis 캐싱을 고려하세요. 읽지 않은 개수는 Redis에 "unread:{userId}:{roomId}" 형태로 캐싱하면 매번 계산하지 않아도 됩니다. 새 메시지가 올 때마다 INCR, 읽을 때마다 DEL하세요.
💡 cursor 기반 페이지네이션을 사용하세요. offset은 데이터가 많아지면 느려집니다. 대신 마지막 방의 updated_at을 cursor로 사용하여 WHERE updated_at < cursor 조건으로 조회하면 항상 빠릅니다.
💡 그룹 채팅을 별도로 처리하세요. 1:1 채팅은 상대방 한 명의 정보만 보여주지만, 그룹 채팅은 참여자 수나 방 제목을 보여줘야 합니다. room_type을 확인하여 다른 로직을 적용하세요.
5. 채팅방_나가기_기능
시작하며
여러분이 더 이상 필요 없는 채팅방에서 나가고 싶을 때가 있죠? 단체 채팅에서는 흔한 일이지만, 1:1 채팅에서도 "이 대화를 종료하고 싶어요"라는 요구가 있습니다.
실무에서는 "나가기"가 단순한 삭제가 아닙니다. 나간 후에도 상대방은 메시지를 볼 수 있어야 하고, 내 대화 목록에서만 사라져야 하며, 나중에 다시 대화가 시작되면 새로운 방으로 취급해야 할 수도 있습니다.
이런 섬세한 처리가 없으면 사용자가 혼란스러워하거나 데이터가 영구 삭제되는 문제가 발생합니다. 바로 이럴 때 필요한 것이 soft delete 방식의 나가기 기능입니다.
이 기능은 데이터를 실제로 삭제하지 않고 "나간 상태"로 표시하여, 언제든 복구 가능하고 다른 참여자에게 영향을 주지 않습니다.
개요
간단히 말해서, 채팅방 나가기는 물리적 삭제가 아닌 논리적 삭제로, 사용자의 참여 기록에 "나갔음" 표시를 하는 것입니다. 이 기능이 필요한 이유는 데이터 보존과 사용자 경험의 균형 때문입니다.
법적으로 채팅 기록을 보관해야 하는 경우도 있고, 상대방은 여전히 대화 내용을 볼 수 있어야 하기 때문입니다. 예를 들어, 전자상거래 앱에서 구매자가 채팅방을 나가더라도 판매자는 주문 정보와 대화 내용을 확인할 수 있어야 합니다.
전통적인 방법으로는 참여자 레코드를 DELETE했지만, 이제는 left_at 타임스탬프를 기록하는 방식으로 바뀌었습니다. 이렇게 하면 "누가 언제 나갔는지" 기록이 남고, 필요하면 다시 입장시킬 수도 있으며, 통계 분석에도 유용합니다.
이 기능의 핵심 특징은 세 가지입니다: 첫째, soft delete로 데이터 복구 가능성 유지, 둘째, 다른 참여자에게 영향 없음, 셋째, 나간 후에도 히스토리 접근 가능 (선택적). 이러한 특징들이 유연하고 안전한 채팅 환경을 만듭니다.
코드 예제
// DELETE /api/chat/rooms/:roomId/leave - 채팅방 나가기
app.delete('/api/chat/rooms/:roomId/leave', async (req, res) => {
const { roomId } = req.params;
const { userId } = req.body;
try {
// 참여자 확인
const participant = await db('room_participants')
.where({ room_id: roomId, user_id: userId })
.first();
if (!participant) {
return res.status(404).json({ error: '참여하지 않은 채팅방입니다' });
}
// 이미 나간 상태인지 확인
if (participant.left_at) {
return res.status(200).json({
message: '이미 나간 채팅방입니다',
leftAt: participant.left_at
});
}
// soft delete: left_at 타임스탬프 기록
await db('room_participants')
.where({ room_id: roomId, user_id: userId })
.update({
left_at: new Date(),
// 선택: 나간 이유 코드 저장
leave_reason: req.body.reason || 'user_left'
});
// 1:1 채팅에서 상대방에게 알림 전송
const otherParticipants = await db('room_participants')
.where('room_id', roomId)
.where('user_id', '!=', userId)
.whereNull('left_at')
.select('user_id');
// WebSocket으로 실시간 알림
otherParticipants.forEach(p => {
io.to(`user_${p.user_id}`).emit('participant_left', {
roomId,
userId,
leftAt: new Date()
});
});
res.json({
success: true,
message: '채팅방에서 나갔습니다',
leftAt: new Date()
});
} catch (error) {
console.error('채팅방 나가기 실패:', error);
res.status(500).json({ error: '나가기에 실패했습니다' });
}
});
설명
이것이 하는 일: 사용자를 채팅방에서 나가게 하되, 데이터를 실제로 삭제하지 않고 "나간 시각"을 기록하여 논리적으로만 제거하며, 다른 참여자들에게 이를 알립니다. 첫 번째로, 요청의 유효성을 검증합니다.
사용자가 실제로 이 채팅방의 참여자인지 확인하는데, 참여자가 아니라면 404 에러를 반환합니다. 또한 이미 나간 상태인지도 체크합니다.
left_at 필드가 이미 값을 가지고 있다면 "이미 나간 채팅방입니다"라고 알려주고, 중복 처리를 방지합니다. 이는 멱등성을 보장하여 같은 요청을 여러 번 보내도 안전합니다.
그 다음으로, 실제 "나가기" 처리가 진행됩니다. 여기서 핵심은 DELETE 쿼리가 아닌 UPDATE 쿼리를 사용한다는 점입니다.
left_at 필드에 현재 시각을 저장하면, 이 레코드는 여전히 데이터베이스에 존재하지만 "나간 상태"로 표시됩니다. 선택적으로 leave_reason 같은 필드를 추가하여 "사용자가 직접 나감", "신고로 인한 퇴장" 같은 이유를 구분할 수도 있습니다.
이런 정보는 나중에 사용자 행동 분석이나 문제 해결에 매우 유용합니다. 세 번째 단계에서는 남아있는 다른 참여자들을 찾습니다.
whereNull('left_at') 조건으로 현재 활성 상태인 참여자만 필터링하고, 각 사람에게 WebSocket으로 실시간 알림을 보냅니다. 이렇게 하면 상대방이 채팅방을 보고 있을 때 "상대방이 채팅방을 나갔습니다"라는 메시지를 즉시 볼 수 있습니다.
사용자별 소켓 룸(user_{userId})을 사용하여 개인화된 알림을 전달합니다. 마지막으로, 성공 응답을 반환합니다.
leftAt 타임스탬프를 포함하여 클라이언트가 "정확히 언제 나갔는지" 알 수 있게 합니다. 이 정보는 UI에서 "2023년 12월 15일에 나간 채팅방" 같은 표시를 할 때 유용합니다.
여러분이 이 기능을 사용하면 사용자는 안전하게 채팅방을 나갈 수 있고, 실수로 나갔을 때 복구도 가능하며, 다른 참여자들은 계속 대화 내용을 볼 수 있습니다. 데이터 무결성도 유지되고 규정 준수도 쉬워집니다.
실전 팁
💡 목록 조회 시 left_at 필터링을 잊지 마세요. 채팅방 목록 API에서 whereNull('left_at') 조건을 반드시 추가해야 나간 방이 목록에 나타나지 않습니다. 이걸 빠뜨리면 나간 방이 계속 보입니다.
💡 "다시 대화 시작" 기능을 구현하세요. left_at을 null로 되돌리면 같은 방으로 다시 입장할 수 있습니다. 또는 새로운 방을 만들고 싶다면 중복 검사에서 left_at IS NULL 조건을 추가하세요.
💡 마지막 남은 사람이 나가면 방을 아카이빙하세요. 모든 참여자가 나갔는지 확인하고, 그렇다면 chat_rooms.archived_at을 설정하여 더 이상 활성 방이 아님을 표시하세요. 저장 공간 정리에 유용합니다.
💡 나간 후 메시지 접근 정책을 명확히 하세요. 어떤 서비스는 나간 후에도 히스토리를 볼 수 있게 하고, 어떤 서비스는 완전히 차단합니다. 정책에 따라 메시지 조회 API에 left_at 체크를 추가하거나 제외하세요.
💡 재입장 알림을 보내세요. 나간 사람이 다시 left_at을 null로 되돌리면 "사용자가 다시 입장했습니다" 알림을 보내면 상대방이 상황을 이해하기 쉽습니다.
6. 채팅방_삭제_처리
시작하며
여러분이 채팅방을 완전히 없애고 싶을 때가 있죠? "나가기"와는 다르게, 더 이상 이 방이 존재하지 않도록 하고 싶은 경우입니다.
하지만 정말로 데이터를 지워버려야 할까요? 실무에서는 채팅방을 물리적으로 삭제하는 것은 매우 위험합니다.
법적 분쟁이 생기면 증거가 사라지고, 백업 복구 시 데이터 불일치가 발생하며, 관련된 메시지나 파일 레코드가 고아가 되어 시스템이 불안정해집니다. 그래서 대부분의 서비스는 "삭제"를 눈속임으로 처리합니다.
바로 이럴 때 필요한 것이 논리적 삭제 시스템입니다. 이 시스템은 사용자 관점에서는 "삭제됨"이지만, 실제로는 deleted_at 플래그만 설정하여 언제든 복구할 수 있고, 관리자는 여전히 접근할 수 있습니다.
개요
간단히 말해서, 채팅방 삭제는 실제 DELETE 쿼리가 아닌 "삭제됨" 상태로의 전환이며, 데이터는 백그라운드에서 보존됩니다. 이 기능이 필요한 이유는 안전과 규정 준수 때문입니다.
GDPR 같은 개인정보 보호법은 일정 기간 데이터 보관을 요구하고, 법적 분쟁 시 채팅 기록이 증거가 될 수 있습니다. 예를 들어, 중고거래 플랫폼에서 거래 후 분쟁이 발생하면 채팅방이 이미 삭제되었더라도 관리자가 내용을 확인할 수 있어야 합니다.
전통적인 방법으로는 CASCADE DELETE로 모든 관련 데이터를 한 번에 지웠지만, 이제는 soft delete로 deleted_at만 표시하고 실제 삭제는 배치 작업으로 90일 후에 처리합니다. 이렇게 하면 실수로 삭제해도 복구 기간이 있고, 시스템 부하도 분산됩니다.
이 기능의 핵심 특징은 세 가지입니다: 첫째, 즉시 삭제가 아닌 보존 기간 설정, 둘째, 사용자에게는 보이지 않지만 관리자는 접근 가능, 셋째, 자동 청소 배치 작업으로 최종 삭제. 이러한 특징들이 안전하고 규정 준수하는 데이터 관리를 가능하게 합니다.
코드 예제
// DELETE /api/chat/rooms/:roomId - 채팅방 삭제 (soft delete)
app.delete('/api/chat/rooms/:roomId', async (req, res) => {
const { roomId } = req.params;
const { userId, isAdmin = false } = req.body;
try {
// 권한 확인: 참여자 또는 관리자만 삭제 가능
const participant = await db('room_participants')
.where({ room_id: roomId, user_id: userId })
.first();
if (!participant && !isAdmin) {
return res.status(403).json({ error: '삭제 권한이 없습니다' });
}
// 이미 삭제된 방인지 확인
const room = await db('chat_rooms')
.where('id', roomId)
.first();
if (room.deleted_at) {
return res.status(410).json({
error: '이미 삭제된 채팅방입니다',
deletedAt: room.deleted_at
});
}
// soft delete: deleted_at 타임스탬프 기록
await db('chat_rooms')
.where('id', roomId)
.update({
deleted_at: new Date(),
deleted_by: userId,
// 하드 삭제 예정 시각 (90일 후)
scheduled_deletion_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
});
// 모든 참여자의 left_at도 함께 설정
await db('room_participants')
.where('room_id', roomId)
.whereNull('left_at')
.update({ left_at: new Date() });
// 모든 참여자에게 알림 전송
const allParticipants = await db('room_participants')
.where('room_id', roomId)
.select('user_id');
allParticipants.forEach(p => {
io.to(`user_${p.user_id}`).emit('room_deleted', {
roomId,
deletedAt: new Date(),
deletedBy: userId
});
});
res.json({
success: true,
message: '채팅방이 삭제되었습니다',
scheduledDeletion: '90일 후 영구 삭제됩니다'
});
} catch (error) {
console.error('채팅방 삭제 실패:', error);
res.status(500).json({ error: '삭제에 실패했습니다' });
}
});
// 배치 작업: 예정된 채팅방 영구 삭제 (cron job)
async function permanentlyDeleteExpiredRooms() {
const expiredRooms = await db('chat_rooms')
.where('scheduled_deletion_at', '<=', new Date())
.whereNotNull('deleted_at')
.select('id');
for (const room of expiredRooms) {
await db.transaction(async (trx) => {
// 관련 메시지 삭제
await trx('messages').where('room_id', room.id).delete();
// 참여자 레코드 삭제
await trx('room_participants').where('room_id', room.id).delete();
// 채팅방 삭제
await trx('chat_rooms').where('id', room.id).delete();
});
}
console.log(`${expiredRooms.length}개의 채팅방이 영구 삭제되었습니다`);
}
설명
이것이 하는 일: 사용자가 채팅방 삭제를 요청하면 즉시 물리적으로 지우지 않고, "삭제됨" 표시만 하여 사용자에게는 보이지 않게 하되, 90일의 유예 기간을 두고 그 후에 배치 작업이 실제로 삭제합니다. 첫 번째로, 삭제 권한을 엄격히 검증합니다.
참여자이거나 관리자여야 삭제할 수 있습니다. 그리고 이미 삭제된 방인지 확인하는데, 만약 deleted_at이 이미 설정되어 있다면 410 Gone 상태 코드를 반환합니다.
410은 "영구적으로 사라짐"을 의미하는 HTTP 코드로, 404(찾을 수 없음)보다 더 명확한 의미를 전달합니다. 그 다음으로, soft delete의 핵심 로직이 실행됩니다.
deleted_at에 현재 시각을 기록하고, deleted_by에 누가 삭제했는지 저장합니다. 가장 중요한 것은 scheduled_deletion_at 필드인데, 현재 시각에서 90일을 더한 미래 시각을 계산하여 저장합니다.
이 시각이 되면 배치 작업이 이 방을 찾아서 영구 삭제하게 됩니다. 90일은 일반적인 보존 기간이지만, 여러분의 서비스 정책에 따라 30일, 180일 등으로 조정할 수 있습니다.
세 번째 단계에서는 관련된 참여자 레코드도 함께 처리합니다. 모든 참여자의 left_at을 설정하여 누구도 이 방에 접근할 수 없게 만듭니다.
그리고 모든 참여자에게 WebSocket 알림을 보내어 "이 채팅방이 삭제되었습니다"라고 알립니다. 앱을 사용 중이던 사람들은 즉시 채팅방이 사라지는 것을 볼 수 있습니다.
마지막으로, 배치 작업 함수(permanentlyDeleteExpiredRooms)가 정의되어 있습니다. 이 함수는 cron job으로 매일 또는 매주 실행되어, scheduled_deletion_at이 현재 시각을 지난 모든 방을 찾습니다.
각 방에 대해 트랜잭션을 열고, messages, room_participants, chat_rooms 순서로 관련 데이터를 모두 삭제합니다. 이때는 진짜 DELETE 쿼리를 사용하여 데이터베이스에서 완전히 제거합니다.
여러분이 이 시스템을 사용하면 즉각적인 삭제 요청에 빠르게 응답하면서도, 실수나 법적 요구에 대비할 수 있습니다. 사용자는 삭제 즉시 방이 사라진 것처럼 보이지만, 실제로는 90일의 안전 기간이 있어 필요하면 복구할 수 있습니다.
실전 팁
💡 복구 API를 만드세요. deleted_at을 null로 되돌리는 엔드포인트를 추가하면 사용자가 "실수로 삭제했어요" 문의를 할 때 고객센터가 즉시 복구해줄 수 있습니다.
💡 삭제 이유를 수집하세요. deletion_reason 필드를 추가하여 "스팸", "거래 완료", "실수" 등을 기록하면 사용자 행동 분석과 서비스 개선에 유용합니다.
💡 파일 삭제를 별도로 처리하세요. 채팅방에 업로드된 이미지나 파일은 용량이 크므로, 배치 작업에서 먼저 파일 스토리지(S3 등)에서 삭제한 후 데이터베이스 레코드를 지우세요.
💡 관리자 대시보드를 만드세요. deleted_at IS NOT NULL로 필터링하여 삭제된 방 목록을 보여주고, 관리자가 내용을 확인하거나 즉시 영구 삭제할 수 있는 UI를 제공하세요.
💡 삭제 알림을 이메일로도 보내세요. 중요한 비즈니스 채팅(계약, 주문 등)이 삭제되면 이메일로도 알려서, 사용자가 나중에 "삭제된 줄 몰랐어요"라고 하는 것을 방지하세요.
댓글 (0)
함께 보면 좋은 카드 뉴스
Docker 배포와 CI/CD 완벽 가이드
Docker를 활용한 컨테이너 배포부터 GitHub Actions를 이용한 자동화 파이프라인까지, 초급 개발자도 쉽게 따라할 수 있는 실전 배포 가이드입니다. AWS EC2에 애플리케이션을 배포하고 SSL 인증서까지 적용하는 전 과정을 다룹니다.
보안 강화 및 테스트 완벽 가이드
웹 애플리케이션의 보안 취약점을 방어하고 안정적인 서비스를 제공하기 위한 실전 보안 기법과 테스트 전략을 다룹니다. XSS, CSRF부터 DDoS 방어, Rate Limiting까지 실무에서 바로 적용 가능한 보안 솔루션을 제공합니다.
Redis 캐싱과 Socket.io 클러스터링 완벽 가이드
실시간 채팅 서비스의 성능을 획기적으로 향상시키는 Redis 캐싱 전략과 Socket.io 클러스터링 방법을 배워봅니다. 다중 서버 환경에서도 안정적으로 작동하는 실시간 애플리케이션을 구축하는 방법을 단계별로 알아봅니다.
반응형 디자인 및 UX 최적화 완벽 가이드
모바일부터 데스크톱까지 완벽하게 대응하는 반응형 웹 디자인과 사용자 경험을 개선하는 실전 기법을 학습합니다. Tailwind CSS를 활용한 빠른 개발부터 다크모드, 무한 스크롤, 스켈레톤 로딩까지 최신 UX 패턴을 실무에 바로 적용할 수 있습니다.
React 채팅 UI 구현 완벽 가이드
실시간 채팅 애플리케이션의 UI를 React로 구현하는 방법을 다룹니다. Socket.io 연동부터 컴포넌트 설계, 상태 관리까지 실무에 바로 적용할 수 있는 내용을 담았습니다.