이미지 로딩 중...
AI Generated
2025. 11. 22. · 3 Views
그룹 멤버 관리 시스템 완벽 가이드
초대부터 강퇴까지, 실시간 그룹 채팅 앱의 멤버 관리 시스템을 완벽하게 구현하는 방법을 배워봅니다. 멤버 초대, 권한 관리, 온라인 상태 표시까지 실무에 바로 적용할 수 있는 핵심 기능들을 다룹니다.
목차
1. 멤버 초대 API
시작하며
여러분이 그룹 채팅 앱을 만들 때 가장 먼저 고민하게 되는 것이 "어떻게 새로운 사람을 초대할까?"입니다. 카카오톡 단체방에 친구를 추가하듯이, 우리 앱에서도 멤버를 초대하는 기능이 필요하죠.
하지만 단순히 "추가하기" 버튼만 만들면 될까요? 아닙니다.
이미 있는 멤버를 또 초대하면 어떻게 할지, 초대받은 사람이 거절하면 어떻게 처리할지, 초대 권한은 누구에게 줄지 등 많은 문제들이 생깁니다. 바로 이럴 때 필요한 것이 체계적인 멤버 초대 API입니다.
초대 요청을 데이터베이스에 저장하고, 상태를 관리하며, 권한을 체크하는 안전하고 확실한 시스템을 만들어야 합니다.
개요
간단히 말해서, 멤버 초대 API는 특정 사용자를 그룹에 초대하는 요청을 생성하고 관리하는 서버 엔드포인트입니다. 실무에서는 초대 시스템이 단순해 보이지만 매우 중요합니다.
잘못 설계하면 중복 초대, 권한 없는 사용자의 초대, 스팸 초대 등의 문제가 발생할 수 있습니다. 예를 들어, 일반 멤버가 무분별하게 초대를 보내면 그룹이 스팸으로 가득 찰 수 있죠.
기존에는 멤버를 바로 추가하는 방식이었다면, 이제는 초대-수락-가입의 3단계 프로세스로 처리합니다. 이렇게 하면 받는 사람이 원하지 않는 그룹에 강제로 들어가는 일이 없습니다.
이 API의 핵심 특징은 첫째, 초대 권한 검증(관리자만 초대 가능), 둘째, 중복 초대 방지(이미 초대된 사람은 다시 초대 불가), 셋째, 초대 상태 관리(대기중/수락됨/거절됨)입니다. 이러한 특징들이 안전하고 사용자 친화적인 그룹 시스템을 만드는 핵심입니다.
코드 예제
// POST /api/groups/:groupId/invitations
async function inviteMember(req: Request, res: Response) {
const { groupId } = req.params;
const { userId: inviteeId } = req.body;
const inviterId = req.user.id; // 인증된 사용자
// 1. 초대자의 권한 확인 (관리자 또는 소유자만 초대 가능)
const inviter = await db.query(
'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2',
[groupId, inviterId]
);
if (!inviter.rows[0] || !['admin', 'owner'].includes(inviter.rows[0].role)) {
return res.status(403).json({ error: '초대 권한이 없습니다' });
}
// 2. 이미 멤버인지 확인
const existingMember = await db.query(
'SELECT id FROM group_members WHERE group_id = $1 AND user_id = $2',
[groupId, inviteeId]
);
if (existingMember.rows.length > 0) {
return res.status(400).json({ error: '이미 그룹 멤버입니다' });
}
// 3. 대기중인 초대가 있는지 확인
const pendingInvitation = await db.query(
'SELECT id FROM invitations WHERE group_id = $1 AND invitee_id = $2 AND status = $3',
[groupId, inviteeId, 'pending']
);
if (pendingInvitation.rows.length > 0) {
return res.status(400).json({ error: '이미 초대를 보냈습니다' });
}
// 4. 초대 생성
const invitation = await db.query(
'INSERT INTO invitations (group_id, inviter_id, invitee_id, status, created_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING *',
[groupId, inviterId, inviteeId, 'pending']
);
// 5. 실시간 알림 전송
await sendNotification(inviteeId, {
type: 'group_invitation',
groupId,
inviterId,
message: '그룹 초대를 받았습니다'
});
res.json({ invitation: invitation.rows[0] });
}
설명
이것이 하는 일: 이 API는 그룹에 새로운 멤버를 초대하는 전체 프로세스를 관리합니다. 마치 아파트 관리실에서 방문객 출입을 허가하는 것처럼, 누가 누구를 초대했는지 기록하고 승인 대기 상태로 만듭니다.
첫 번째로, 초대를 보내는 사람이 정말 권한이 있는지 확인합니다. 데이터베이스에서 이 사람의 역할(role)을 조회해서 'admin'이나 'owner'인지 체크하죠.
만약 일반 멤버라면 403 에러를 반환하여 초대를 막습니다. 이렇게 하는 이유는 아무나 초대할 수 있으면 스팸이나 악의적인 초대가 발생할 수 있기 때문입니다.
두 번째와 세 번째 단계에서는 중복을 방지합니다. 이미 그룹 멤버인 사람을 또 초대하는 것은 의미가 없고, 이미 대기중인 초대가 있는데 또 보내면 알림이 중복으로 가서 사용자를 귀찮게 합니다.
각각의 경우를 체크해서 400 에러로 막아줍니다. 네 번째 단계에서 드디어 초대 레코드를 생성합니다.
invitations 테이블에 그룹 ID, 초대한 사람 ID, 초대받은 사람 ID, 그리고 'pending' 상태를 저장합니다. 이 레코드는 나중에 수락이나 거절 처리를 할 때 사용됩니다.
마지막으로, 실시간 알림을 보냅니다. 초대받은 사람에게 즉시 알림이 가서 "누가 어떤 그룹에 초대했어요"라고 알려줍니다.
여러분이 이 시스템을 사용하면 안전하고 체계적인 멤버 초대 기능을 구현할 수 있으며, 스팸 방지와 중복 방지가 자동으로 처리되어 사용자 경험이 크게 향상됩니다.
실전 팁
💡 초대 횟수 제한을 추가하세요. 한 사용자가 1분에 10번 이상 초대를 보내면 rate limiting을 걸어서 스팸을 방지할 수 있습니다.
💡 초대 만료 시간을 설정하세요. 7일이 지나면 자동으로 'expired' 상태로 변경하여 오래된 초대가 쌓이지 않게 합니다.
💡 초대 링크 방식도 고려하세요. 초대 코드를 생성해서 링크로 공유하면 여러 명을 한번에 초대할 수 있어 편리합니다.
💡 초대 히스토리를 기록하세요. 누가 언제 누구를 초대했는지 로그를 남기면 나중에 문제가 생겼을 때 추적이 가능합니다.
💡 차단된 사용자는 초대할 수 없게 하세요. blocked_users 테이블을 체크해서 서로 차단한 사용자끼리는 초대가 안 되도록 막아야 합니다.
2. 초대 수락/거절 처리
시작하며
여러분이 그룹 초대 알림을 받았을 때, "수락"과 "거절" 버튼을 누르면 무슨 일이 일어날까요? 단순히 버튼만 누르면 끝일까요?
아닙니다. 뒤에서는 데이터베이스 업데이트, 멤버 추가, 알림 전송 등 복잡한 과정이 진행됩니다.
실무에서 이 부분을 잘못 처리하면 큰 문제가 생깁니다. 수락 버튼을 두 번 눌렀을 때 중복으로 멤버가 추가되거나, 거절했는데도 그룹에 들어가지는 버그가 발생할 수 있죠.
또한 초대가 이미 만료됐는데 수락할 수 있다면 보안 문제가 생깁니다. 바로 이럴 때 필요한 것이 트랜잭션을 사용한 안전한 초대 응답 처리 시스템입니다.
초대 상태를 업데이트하고, 수락했다면 멤버로 추가하고, 모든 관련된 사람들에게 알림을 보내는 원자적(atomic) 처리가 필요합니다.
개요
간단히 말해서, 초대 수락/거절 처리는 초대받은 사람이 초대에 대한 응답을 할 때 실행되는 비즈니스 로직입니다. 수락하면 그룹 멤버로 추가되고, 거절하면 초대 레코드만 업데이트됩니다.
이 기능이 중요한 이유는 사용자의 의사를 정확히 반영해야 하기 때문입니다. 수락하지 않았는데 멤버가 되거나, 수락했는데 멤버가 안 되는 일은 절대 있어서는 안 됩니다.
예를 들어, 회사 프로젝트 그룹에 잘못 초대받았을 때 거절 기능이 제대로 작동하지 않으면 민감한 정보가 노출될 수 있습니다. 기존에는 단순히 상태만 업데이트했다면, 이제는 트랜잭션으로 묶어서 모든 작업이 성공하거나 모두 실패하도록 합니다.
중간에 에러가 나면 전체를 롤백해서 데이터 일관성을 보장합니다. 이 시스템의 핵심 특징은 첫째, 원자성 보장(트랜잭션 사용), 둘째, 멱등성 보장(같은 요청을 여러 번 보내도 결과는 동일), 셋째, 실시간 알림(초대한 사람과 그룹 멤버들에게 결과 통지)입니다.
이러한 특징들이 신뢰할 수 있는 그룹 멤버 시스템의 기반이 됩니다.
코드 예제
// PATCH /api/invitations/:invitationId
async function respondToInvitation(req: Request, res: Response) {
const { invitationId } = req.params;
const { action } = req.body; // 'accept' or 'decline'
const userId = req.user.id;
// 트랜잭션 시작
const client = await db.pool.connect();
try {
await client.query('BEGIN');
// 1. 초대 정보 조회 및 잠금
const invitation = await client.query(
'SELECT * FROM invitations WHERE id = $1 AND invitee_id = $2 FOR UPDATE',
[invitationId, userId]
);
if (invitation.rows.length === 0) {
throw new Error('초대를 찾을 수 없습니다');
}
const inv = invitation.rows[0];
// 2. 이미 처리된 초대인지 확인
if (inv.status !== 'pending') {
throw new Error('이미 처리된 초대입니다');
}
// 3. 만료 확인 (7일)
const createdDate = new Date(inv.created_at);
const now = new Date();
const daysDiff = (now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24);
if (daysDiff > 7) {
await client.query('UPDATE invitations SET status = $1 WHERE id = $2', ['expired', invitationId]);
throw new Error('초대가 만료되었습니다');
}
// 4. 초대 상태 업데이트
const newStatus = action === 'accept' ? 'accepted' : 'declined';
await client.query(
'UPDATE invitations SET status = $1, responded_at = NOW() WHERE id = $2',
[newStatus, invitationId]
);
// 5. 수락한 경우 멤버 추가
if (action === 'accept') {
await client.query(
'INSERT INTO group_members (group_id, user_id, role, joined_at) VALUES ($1, $2, $3, NOW())',
[inv.group_id, userId, 'member']
);
// 그룹의 멤버 수 증가
await client.query(
'UPDATE groups SET member_count = member_count + 1 WHERE id = $1',
[inv.group_id]
);
}
await client.query('COMMIT');
// 6. 알림 전송 (트랜잭션 외부)
if (action === 'accept') {
await sendNotification(inv.inviter_id, {
type: 'invitation_accepted',
message: '초대를 수락했습니다',
userId
});
}
res.json({ success: true, status: newStatus });
} catch (error) {
await client.query('ROLLBACK');
res.status(400).json({ error: error.message });
} finally {
client.release();
}
}
설명
이것이 하는 일: 이 함수는 초대 수락이나 거절 요청을 받아서 데이터베이스를 안전하게 업데이트하고, 필요한 경우 멤버를 추가하는 전체 프로세스를 관리합니다. 은행 거래처럼 중간에 실패하면 전체를 취소하는 트랜잭션 방식을 사용합니다.
첫 번째로, 데이터베이스 연결을 얻고 트랜잭션을 시작합니다. 'BEGIN' 명령으로 시작하면 이후 모든 쿼리는 임시로 실행되고, 'COMMIT'을 하기 전까지는 실제로 저장되지 않습니다.
초대 정보를 조회할 때 'FOR UPDATE'를 사용하는데, 이는 다른 요청이 동시에 같은 초대를 처리하지 못하도록 잠그는 역할을 합니다. 두 번째와 세 번째 단계에서는 유효성 검증을 합니다.
초대 상태가 'pending'이 아니면 이미 처리된 것이므로 에러를 던집니다. 또한 생성된 지 7일이 넘었는지 계산해서, 만료된 초대는 처리할 수 없도록 막습니다.
이런 검증이 없으면 몇 달 전 초대를 수락해서 그룹에 들어가는 이상한 상황이 발생할 수 있습니다. 네 번째와 다섯 번째 단계가 핵심입니다.
초대 상태를 'accepted' 또는 'declined'로 업데이트하고, 수락한 경우에만 group_members 테이블에 새 레코드를 추가합니다. 동시에 groups 테이블의 member_count도 1 증가시켜서 통계를 업데이트합니다.
이 모든 작업이 하나의 트랜잭션 안에서 이루어지므로, 중간에 에러가 나면 ROLLBACK으로 모든 변경사항이 취소됩니다. 마지막으로, 트랜잭션이 성공하면 초대한 사람에게 "누가 초대를 수락했어요" 알림을 보냅니다.
이 알림은 트랜잭션 외부에서 실행되는데, 알림 전송이 실패해도 멤버 추가는 이미 완료되었기 때문에 괜찮습니다. 여러분이 이 시스템을 사용하면 동시성 문제 없이 안전하게 초대 처리를 할 수 있고, 데이터 일관성이 완벽하게 보장됩니다.
실전 팁
💡 낙관적 잠금(Optimistic Locking) 대신 비관적 잠금(FOR UPDATE)을 사용하세요. 초대 응답은 빈번하지 않으므로 확실한 잠금이 더 안전합니다.
💡 멱등성 키를 사용하세요. 클라이언트에서 같은 요청을 두 번 보내도 한 번만 처리되도록 idempotency_key를 체크하세요.
💡 거절 사유를 선택적으로 저장하세요. decline_reason 컬럼을 추가하면 나중에 왜 거절했는지 분석할 수 있어 그룹 운영에 도움이 됩니다.
💡 초대 수락 후 환영 메시지를 자동으로 보내세요. 새 멤버가 그룹에 들어오면 "환영합니다!" 메시지를 시스템이 자동으로 보내면 친근한 분위기를 만들 수 있습니다.
💡 재초대 기능을 추가하세요. 거절한 사람을 일정 시간 후(예: 30일) 다시 초대할 수 있도록 하면 마음이 바뀐 사용자를 다시 초대할 수 있습니다.
3. 멤버 강퇴 기능
시작하며
여러분의 그룹에 규칙을 어기거나 스팸을 보내는 사용자가 있다면 어떻게 하시겠어요? 그냥 두면 다른 멤버들이 불편해하고 그룹이 망가집니다.
이럴 때 필요한 것이 바로 멤버 강퇴(kick) 기능입니다. 하지만 아무나 강퇴할 수 있으면 안 됩니다.
일반 멤버가 관리자를 강퇴하거나, 그룹 소유자를 강퇴하는 일이 생기면 큰 혼란이 발생하죠. 또한 강퇴된 사람이 즉시 다시 들어올 수 있다면 강퇴의 의미가 없습니다.
바로 이럴 때 필요한 것이 권한 기반의 안전한 멤버 강퇴 시스템입니다. 역할(role)에 따라 누구를 강퇴할 수 있는지 제어하고, 강퇴 기록을 남기며, 재가입을 방지하는 체계적인 시스템이 필요합니다.
개요
간단히 말해서, 멤버 강퇴 기능은 권한이 있는 사용자가 특정 멤버를 그룹에서 제거하는 관리 기능입니다. 카카오톡 단체방에서 사람을 내보내는 것과 같지만, 더 세밀한 권한 제어와 기록 관리가 포함됩니다.
실무에서 강퇴 기능은 그룹 건전성을 유지하는 핵심 도구입니다. 잘못 설계하면 권한 남용이나 악의적인 강퇴가 발생할 수 있습니다.
예를 들어, 일반 멤버 A가 일반 멤버 B를 강퇴하는 일이 생기면 안 되고, 관리자도 그룹 소유자는 강퇴할 수 없어야 합니다. 기존에는 단순히 멤버 레코드를 삭제했다면, 이제는 강퇴 기록을 별도 테이블에 저장하고, 재가입 방지 로직을 추가하며, 관련된 모든 사람에게 알림을 보내는 방식으로 진화했습니다.
이 기능의 핵심 특징은 첫째, 계층적 권한 제어(owner > admin > member), 둘째, 강퇴 히스토리 기록(누가 언제 왜 강퇴했는지), 셋째, 연쇄 삭제(멤버의 메시지, 반응 등도 처리)입니다. 이러한 특징들이 공정하고 투명한 그룹 관리를 가능하게 합니다.
코드 예제
// DELETE /api/groups/:groupId/members/:memberId
async function kickMember(req: Request, res: Response) {
const { groupId, memberId } = req.params;
const { reason } = req.body;
const kickerId = req.user.id;
const client = await db.pool.connect();
try {
await client.query('BEGIN');
// 1. 강퇴하는 사람과 당하는 사람의 역할 조회
const roles = await client.query(
'SELECT user_id, role FROM group_members WHERE group_id = $1 AND user_id IN ($2, $3)',
[groupId, kickerId, memberId]
);
const kickerRole = roles.rows.find(r => r.user_id === kickerId)?.role;
const memberRole = roles.rows.find(r => r.user_id === memberId)?.role;
// 2. 권한 검증
if (!kickerRole || !['admin', 'owner'].includes(kickerRole)) {
throw new Error('강퇴 권한이 없습니다');
}
// owner는 강퇴할 수 없음
if (memberRole === 'owner') {
throw new Error('그룹 소유자는 강퇴할 수 없습니다');
}
// admin은 다른 admin을 강퇴할 수 없음 (owner만 가능)
if (kickerRole === 'admin' && memberRole === 'admin') {
throw new Error('관리자는 다른 관리자를 강퇴할 수 없습니다');
}
// 자기 자신을 강퇴할 수 없음
if (kickerId === memberId) {
throw new Error('자기 자신을 강퇴할 수 없습니다');
}
// 3. 멤버 제거
await client.query(
'DELETE FROM group_members WHERE group_id = $1 AND user_id = $2',
[groupId, memberId]
);
// 4. 강퇴 기록 저장
await client.query(
'INSERT INTO kick_history (group_id, kicked_user_id, kicker_id, reason, kicked_at) VALUES ($1, $2, $3, $4, NOW())',
[groupId, memberId, kickerId, reason || '사유 없음']
);
// 5. 멤버 수 감소
await client.query(
'UPDATE groups SET member_count = member_count - 1 WHERE id = $1',
[groupId]
);
// 6. 재가입 방지 설정 (선택적)
await client.query(
'INSERT INTO group_bans (group_id, user_id, banned_at, banned_by) VALUES ($1, $2, NOW(), $3)',
[groupId, memberId, kickerId]
);
await client.query('COMMIT');
// 7. 알림 전송
await sendNotification(memberId, {
type: 'kicked_from_group',
groupId,
message: '그룹에서 제거되었습니다'
});
// 그룹 멤버들에게 알림
await broadcastToGroup(groupId, {
type: 'member_kicked',
kickedUserId: memberId,
kickerUserId: kickerId
});
res.json({ success: true, message: '멤버를 강퇴했습니다' });
} catch (error) {
await client.query('ROLLBACK');
res.status(400).json({ error: error.message });
} finally {
client.release();
}
}
설명
이것이 하는 일: 이 함수는 관리자가 문제가 있는 멤버를 그룹에서 제거하는 전체 프로세스를 안전하게 처리합니다. 학교에서 문제를 일으킨 학생을 퇴학시키는 것처럼, 정해진 절차와 기록을 통해 공정하게 진행합니다.
첫 번째와 두 번째 단계는 매우 중요한 권한 검증입니다. 강퇴하는 사람(kicker)과 강퇴당하는 사람(member)의 역할을 데이터베이스에서 조회합니다.
그리고 여러 규칙을 체크하죠. owner는 절대 강퇴할 수 없고, admin은 다른 admin을 강퇴할 수 없으며, 자기 자신도 강퇴할 수 없습니다.
이런 규칙들이 없으면 혼란스러운 권한 싸움이 일어날 수 있습니다. 세 번째 단계에서 실제로 멤버를 제거합니다.
group_members 테이블에서 해당 레코드를 DELETE합니다. 하지만 단순히 삭제만 하면 안 됩니다.
나중에 "왜 이 사람이 나갔지?"라고 궁금해할 수 있으니까요. 네 번째 단계에서 강퇴 히스토리를 기록합니다.
kick_history 테이블에 누가 누구를 언제 왜 강퇴했는지 모두 저장합니다. 이 기록은 나중에 분쟁이 생겼을 때 증거가 되고, 악의적인 강퇴를 방지하는 감시 도구가 됩니다.
다섯 번째와 여섯 번째 단계에서는 그룹의 멤버 수를 업데이트하고, 재가입 방지를 위해 group_bans 테이블에 기록합니다. 마지막으로, 알림을 보냅니다.
강퇴당한 사람에게는 "그룹에서 제거되었습니다"라고 알려주고, 그룹의 나머지 멤버들에게는 "누가 제거되었습니다"라고 브로드캐스트합니다. 여러분이 이 시스템을 사용하면 권한 남용 없이 공정하게 멤버를 관리할 수 있고, 모든 행동이 기록되어 투명성이 보장됩니다.
실전 팁
💡 소프트 삭제를 고려하세요. 멤버 레코드를 완전히 삭제하지 않고 is_kicked 플래그를 true로 설정하면 나중에 복구할 수 있습니다.
💡 강퇴 전 경고 시스템을 만드세요. 1차 경고, 2차 경고, 3차 강퇴 같은 단계별 시스템을 추가하면 더 공정합니다.
💡 임시 강퇴 기능을 추가하세요. 영구 강퇴 대신 7일, 30일 같은 기간 제한 강퇴를 지원하면 유연한 관리가 가능합니다.
💡 강퇴 이의제기 시스템을 만드세요. 강퇴당한 사람이 이의를 제기할 수 있는 채널을 제공하면 억울한 강퇴를 줄일 수 있습니다.
💡 자동 강퇴 규칙을 설정하세요. 스팸 메시지를 5번 이상 보내면 자동으로 강퇴되는 규칙을 추가하면 관리자의 부담이 줄어듭니다.
4. 멤버 권한 변경
시작하며
여러분의 그룹에서 열심히 활동하는 멤버가 있다면 관리자로 승진시키고 싶을 거예요. 반대로 관리자가 역할을 제대로 못하면 일반 멤버로 강등시켜야 할 수도 있죠.
이런 권한 변경은 그룹 운영에서 매우 중요한 관리 기능입니다. 하지만 권한 변경을 잘못 처리하면 심각한 보안 문제가 발생합니다.
일반 멤버가 자기 자신을 관리자로 만들거나, 관리자가 소유자의 권한을 빼앗는 일이 생기면 그룹 전체가 무너집니다. 또한 권한 변경 기록을 남기지 않으면 누가 언제 권한을 바꿨는지 알 수 없어 문제가 생깁니다.
바로 이럴 때 필요한 것이 계층적 권한 구조와 감사 로그를 갖춘 멤버 권한 변경 시스템입니다. 오직 상위 권한자만 하위 권한자의 권한을 변경할 수 있고, 모든 변경은 기록되며, 소유자는 절대 변경할 수 없는 규칙이 필요합니다.
개요
간단히 말해서, 멤버 권한 변경 기능은 그룹 소유자나 관리자가 다른 멤버의 역할(role)을 member, admin, owner 중 하나로 변경하는 관리 기능입니다. 회사에서 승진이나 강등을 하는 것과 비슷합니다.
실무에서 권한 시스템은 그룹의 안정성을 결정하는 핵심 요소입니다. 잘못 설계하면 권한 탈취, 무단 승진, 혼란스러운 관리 구조 등의 문제가 발생합니다.
예를 들어, 대형 커뮤니티 그룹에서 권한 체계가 무너지면 스팸과 악의적인 행동을 막을 수 없게 됩니다. 기존에는 단순히 role 컬럼만 업데이트했다면, 이제는 권한 변경 가능 여부를 복잡한 로직으로 검증하고, 변경 히스토리를 기록하며, 권한 변경으로 인한 부수 효과(예: 관리자 권한 박탈)를 처리하는 방식으로 발전했습니다.
이 기능의 핵심 특징은 첫째, 계층적 권한 모델(owner만 admin을 임명, admin은 member만 관리), 둘째, 불변 규칙(owner는 변경 불가, 최소 1명의 owner 유지), 셋째, 감사 추적(모든 권한 변경 기록)입니다. 이러한 특징들이 안전하고 예측 가능한 그룹 관리를 가능하게 합니다.
코드 예제
// PATCH /api/groups/:groupId/members/:memberId/role
async function changeMemberRole(req: Request, res: Response) {
const { groupId, memberId } = req.params;
const { newRole } = req.body; // 'member', 'admin', 'owner'
const changerId = req.user.id;
const client = await db.pool.connect();
try {
await client.query('BEGIN');
// 1. 현재 역할 조회
const roles = await client.query(
'SELECT user_id, role FROM group_members WHERE group_id = $1 AND user_id IN ($2, $3)',
[groupId, changerId, memberId]
);
const changerRole = roles.rows.find(r => r.user_id === changerId)?.role;
const currentRole = roles.rows.find(r => r.user_id === memberId)?.role;
if (!changerRole || !currentRole) {
throw new Error('멤버를 찾을 수 없습니다');
}
// 2. 권한 변경 가능 여부 검증
// owner만 admin을 임명하거나 해임할 수 있음
if ((newRole === 'admin' || currentRole === 'admin') && changerRole !== 'owner') {
throw new Error('관리자 권한 변경은 소유자만 가능합니다');
}
// owner로 변경하거나 owner를 변경하는 것은 불가
if (newRole === 'owner' || currentRole === 'owner') {
throw new Error('소유자 권한은 변경할 수 없습니다');
}
// 자기 자신의 권한은 변경 불가
if (changerId === memberId) {
throw new Error('자신의 권한은 변경할 수 없습니다');
}
// 일반 멤버는 권한 변경 불가
if (changerRole === 'member') {
throw new Error('권한 변경 권한이 없습니다');
}
// 3. 역할 변경
await client.query(
'UPDATE group_members SET role = $1, role_updated_at = NOW() WHERE group_id = $2 AND user_id = $3',
[newRole, groupId, memberId]
);
// 4. 권한 변경 히스토리 기록
await client.query(
'INSERT INTO role_change_history (group_id, user_id, changed_by, old_role, new_role, changed_at) VALUES ($1, $2, $3, $4, $5, NOW())',
[groupId, memberId, changerId, currentRole, newRole]
);
// 5. admin 수 업데이트
if (newRole === 'admin' || currentRole === 'admin') {
const increment = newRole === 'admin' ? 1 : -1;
await client.query(
'UPDATE groups SET admin_count = admin_count + $1 WHERE id = $2',
[increment, groupId]
);
}
await client.query('COMMIT');
// 6. 알림 전송
await sendNotification(memberId, {
type: 'role_changed',
groupId,
oldRole: currentRole,
newRole,
message: `역할이 ${currentRole}에서 ${newRole}로 변경되었습니다`
});
res.json({ success: true, newRole });
} catch (error) {
await client.query('ROLLBACK');
res.status(400).json({ error: error.message });
} finally {
client.release();
}
}
설명
이것이 하는 일: 이 함수는 그룹 관리자가 멤버의 권한을 안전하게 변경하는 전체 프로세스를 처리합니다. 회사에서 인사팀이 직원의 직급을 조정하는 것처럼, 정해진 규칙과 절차에 따라 권한을 변경합니다.
첫 번째와 두 번째 단계에서 철저한 권한 검증을 수행합니다. 권한을 변경하려는 사람(changer)과 변경될 사람(member)의 현재 역할을 조회합니다.
그리고 복잡한 규칙들을 체크하죠. owner만 admin을 임명하거나 해임할 수 있고, owner 권한 자체는 절대 변경할 수 없으며, 자기 자신의 권한도 변경할 수 없습니다.
이런 규칙들이 권한 시스템의 무결성을 보호합니다. 예를 들어, 일반 멤버가 자기 자신을 admin으로 만들려고 시도하면 "자신의 권한은 변경할 수 없습니다" 에러가 발생합니다.
또한 admin이 다른 admin의 권한을 빼앗으려 해도 "관리자 권한 변경은 소유자만 가능합니다" 에러로 막힙니다. 이렇게 여러 겹의 보안 체크가 있어야 안전합니다.
세 번째와 네 번째 단계에서 실제 변경과 기록을 수행합니다. group_members 테이블의 role 컬럼을 업데이트하고, role_change_history 테이블에 누가 누구의 권한을 언제 어떻게 바꿨는지 모두 기록합니다.
이 히스토리는 나중에 "누가 이 사람을 관리자로 만들었지?" 같은 질문에 답하는 증거가 됩니다. 다섯 번째 단계에서는 통계를 업데이트합니다.
groups 테이블의 admin_count를 증가시키거나 감소시켜서 "이 그룹에 관리자가 몇 명인지"를 항상 정확하게 유지합니다. 마지막으로, 권한이 변경된 사람에게 알림을 보내서 "여러분의 역할이 member에서 admin으로 변경되었습니다" 같은 메시지를 전달합니다.
여러분이 이 시스템을 사용하면 복잡한 권한 구조를 안전하게 관리할 수 있고, 모든 변경이 추적되어 투명한 운영이 가능합니다.
실전 팁
💡 권한 변경 승인 시스템을 추가하세요. 중요한 권한 변경은 여러 관리자의 승인을 받도록 하면 독단적인 결정을 방지할 수 있습니다.
💡 권한 변경 횟수를 제한하세요. 한 멤버의 권한을 하루에 3번 이상 변경할 수 없게 하면 권한 남용을 막을 수 있습니다.
💡 임시 권한 부여 기능을 만드세요. 7일간만 admin 권한을 주고 자동으로 member로 돌아가는 기능은 이벤트 운영 시 유용합니다.
💡 권한별 기능 매트릭스를 만드세요. owner는 A, B, C 기능 사용 가능, admin은 B, C만 가능 같은 명확한 문서를 작성하면 혼란이 줄어듭니다.
💡 권한 변경 알림을 그룹 전체에 보내세요. 새로운 관리자가 임명되면 모든 멤버에게 알려서 투명성을 높이고 권한 남용을 억제할 수 있습니다.
5. 멤버 목록 조회
시작하며
여러분이 그룹 채팅 앱에 들어가면 "멤버 목록"을 보고 싶을 거예요. 누가 이 그룹에 있는지, 누가 관리자인지, 언제 가입했는지 같은 정보를 확인하고 싶죠.
이것이 바로 멤버 목록 조회 기능입니다. 하지만 멤버가 1,000명인 대형 그룹이라면 어떻게 할까요?
1,000명의 정보를 한번에 다 불러오면 서버도 느려지고 사용자도 기다려야 합니다. 또한 각 멤버의 프로필 사진, 온라인 상태, 역할 등을 함께 보여주려면 여러 테이블을 조인해야 하는데, 이것도 성능 문제를 일으킵니다.
바로 이럴 때 필요한 것이 페이지네이션과 필터링을 지원하는 효율적인 멤버 목록 조회 API입니다. 한 번에 20명씩만 불러오고, 역할별로 필터링하며, 검색도 지원하는 유연한 시스템이 필요합니다.
개요
간단히 말해서, 멤버 목록 조회 기능은 그룹의 멤버들을 페이지 단위로 불러오고, 필요한 정보를 효율적으로 제공하는 읽기 전용 API입니다. 전화번호부를 보는 것처럼, 필요한 정보를 빠르게 찾을 수 있어야 합니다.
실무에서 목록 조회는 가장 자주 호출되는 API 중 하나입니다. 사용자가 앱을 열 때마다 멤버 목록을 확인하기 때문이죠.
잘못 설계하면 서버에 엄청난 부하가 걸리고, 응답 시간이 느려져서 사용자 경험이 나빠집니다. 예를 들어, 인덱스 없이 LIKE 검색을 하면 데이터베이스가 전체 테이블을 스캔해서 매우 느려집니다.
기존에는 SELECT * FROM group_members로 모든 데이터를 가져왔다면, 이제는 LIMIT/OFFSET으로 페이징하고, 필요한 컬럼만 선택하며, JOIN으로 관련 데이터를 효율적으로 가져오는 방식으로 최적화합니다. 이 API의 핵심 특징은 첫째, 커서 기반 페이지네이션(안정적인 페이징), 둘째, 선택적 필터링(역할, 가입일, 온라인 상태), 셋째, 검색 기능(이름, 이메일)입니다.
이러한 특징들이 대규모 그룹에서도 빠른 응답 속도를 보장합니다.
코드 예제
// GET /api/groups/:groupId/members?limit=20&cursor=xxx&role=admin&search=john
async function getGroupMembers(req: Request, res: Response) {
const { groupId } = req.params;
const { limit = 20, cursor, role, search } = req.query;
const userId = req.user.id;
// 1. 멤버 권한 확인
const membership = await db.query(
'SELECT id FROM group_members WHERE group_id = $1 AND user_id = $2',
[groupId, userId]
);
if (membership.rows.length === 0) {
return res.status(403).json({ error: '그룹 멤버만 조회할 수 있습니다' });
}
// 2. 쿼리 빌드
let query = `
SELECT
gm.user_id,
gm.role,
gm.joined_at,
u.username,
u.avatar_url,
u.email,
CASE
WHEN u.last_seen_at > NOW() - INTERVAL '5 minutes' THEN true
ELSE false
END as is_online
FROM group_members gm
JOIN users u ON gm.user_id = u.id
WHERE gm.group_id = $1
`;
const params: any[] = [groupId];
let paramIndex = 2;
// 3. 커서 기반 페이징
if (cursor) {
query += ` AND gm.joined_at < $${paramIndex}`;
params.push(cursor);
paramIndex++;
}
// 4. 역할 필터
if (role && ['owner', 'admin', 'member'].includes(role as string)) {
query += ` AND gm.role = $${paramIndex}`;
params.push(role);
paramIndex++;
}
// 5. 검색 필터
if (search) {
query += ` AND (u.username ILIKE $${paramIndex} OR u.email ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
// 6. 정렬 및 제한
query += ` ORDER BY gm.joined_at DESC LIMIT $${paramIndex}`;
params.push(Number(limit) + 1); // 다음 페이지 존재 여부 확인용
// 7. 실행
const result = await db.query(query, params);
const members = result.rows;
// 8. 다음 페이지 여부 확인
const hasNextPage = members.length > Number(limit);
if (hasNextPage) {
members.pop(); // 마지막 항목 제거
}
const nextCursor = hasNextPage ? members[members.length - 1].joined_at : null;
res.json({
members,
pagination: {
nextCursor,
hasNextPage,
limit: Number(limit)
}
});
}
설명
이것이 하는 일: 이 함수는 그룹의 멤버 목록을 페이지 단위로 불러오고, 사용자가 원하는 필터와 검색 조건을 적용하여 필요한 정보만 효율적으로 제공합니다. 도서관에서 책을 찾을 때 카테고리와 저자로 필터링하는 것처럼, 원하는 멤버를 빠르게 찾을 수 있습니다.
첫 번째 단계에서 권한을 확인합니다. 그룹 멤버가 아닌 사람은 멤버 목록을 볼 수 없도록 막습니다.
이는 프라이버시 보호를 위한 중요한 보안 조치입니다. 두 번째 단계에서 SQL 쿼리를 작성합니다.
group_members 테이블과 users 테이블을 JOIN해서 멤버의 기본 정보(이름, 프로필 사진, 이메일)와 그룹 내 정보(역할, 가입일)를 함께 가져옵니다. 특히 CASE 문으로 온라인 상태를 계산하는데, last_seen_at이 5분 이내면 온라인으로 표시합니다.
이렇게 하면 별도의 쿼리 없이 한 번에 모든 정보를 얻을 수 있습니다. 세 번째부터 다섯 번째 단계는 동적 쿼리 빌딩입니다.
cursor가 있으면 "joined_at이 cursor보다 이전인 것들만" 조건을 추가합니다. 이것이 커서 기반 페이징의 핵심으로, OFFSET보다 훨씬 안정적입니다.
OFFSET은 중간에 데이터가 추가되면 중복이나 누락이 생기지만, 커서 방식은 특정 시점 이후의 데이터만 가져오므로 안정적입니다. role 필터와 search 조건도 쿼리에 동적으로 추가됩니다.
여섯 번째와 일곱 번째 단계에서 쿼리를 실행합니다. LIMIT을 실제 요청보다 1개 더 가져오는 것이 중요합니다.
예를 들어 20개를 요청하면 21개를 가져와서, 21개가 있으면 "다음 페이지가 있다"는 뜻입니다. 이 방식으로 추가 카운트 쿼리 없이 페이징 정보를 알 수 있습니다.
마지막으로, 결과를 가공해서 반환합니다. hasNextPage를 계산하고, nextCursor를 마지막 멤버의 joined_at으로 설정합니다.
클라이언트는 이 nextCursor를 다음 요청에 사용하면 됩니다. 여러분이 이 시스템을 사용하면 수천 명의 멤버가 있어도 빠르게 목록을 로드할 수 있고, 검색과 필터링으로 원하는 멤버를 쉽게 찾을 수 있습니다.
실전 팁
💡 인덱스를 반드시 추가하세요. (group_id, joined_at)과 (group_id, role) 복합 인덱스가 있어야 쿼리가 빠릅니다.
💡 캐싱을 적극 활용하세요. Redis에 멤버 목록 첫 페이지를 5분간 캐시하면 서버 부하를 크게 줄일 수 있습니다.
💡 무한 스크롤 UI를 구현하세요. 클라이언트에서 스크롤이 끝에 도달하면 자동으로 nextCursor로 다음 페이지를 로드하면 사용자 경험이 좋아집니다.
💡 Full-text search를 고려하세요. 멤버 수가 많다면 PostgreSQL의 tsvector나 Elasticsearch를 사용해서 더 빠른 검색을 제공할 수 있습니다.
💡 정렬 옵션을 추가하세요. 가입일순, 이름순, 활동량순 등 다양한 정렬을 지원하면 사용자가 원하는 방식으로 멤버를 찾을 수 있습니다.
6. 멤버 온라인 상태 표시
시작하며
여러분이 메신저를 사용할 때 친구 옆에 초록색 점이 있으면 "지금 온라인이구나"라고 알 수 있죠. 이런 온라인 상태 표시는 실시간 커뮤니케이션에서 매우 중요한 기능입니다.
상대방이 온라인이면 메시지를 보낼 때 바로 답을 받을 수 있다는 기대감이 생기니까요. 하지만 온라인 상태를 정확하게 표시하는 것은 생각보다 어렵습니다.
사용자가 앱을 끄거나 인터넷이 끊겼을 때 어떻게 감지할까요? 또한 수백 명의 멤버가 있는 그룹에서 모든 사람의 온라인 상태를 실시간으로 업데이트하면 서버 부하가 엄청나게 커집니다.
바로 이럴 때 필요한 것이 효율적인 온라인 상태 추적 시스템입니다. WebSocket 연결과 heartbeat 메커니즘으로 실시간 상태를 감지하고, Redis 같은 인메모리 저장소로 빠르게 조회하며, 브로드캐스트로 변경 사항을 효율적으로 전파하는 시스템이 필요합니다.
개요
간단히 말해서, 멤버 온라인 상태 표시는 각 사용자가 현재 온라인인지 오프라인인지를 실시간으로 추적하고 다른 멤버들에게 보여주는 기능입니다. 카카오톡의 초록색 점이나 디스코드의 상태 표시와 같은 기능입니다.
실무에서 온라인 상태 기능은 사용자 참여도를 크게 높입니다. 연구에 따르면 상대방이 온라인일 때 메시지를 보낼 확률이 3배 이상 높다고 합니다.
하지만 잘못 구현하면 서버에 초당 수천 개의 상태 업데이트 요청이 쏟아져서 시스템이 다운될 수 있습니다. 예를 들어, 1,000명이 있는 그룹에서 1초마다 상태를 체크하면 초당 1,000번의 쿼리가 발생합니다.
기존에는 데이터베이스의 last_seen_at 컬럼을 계속 업데이트했다면, 이제는 Redis에 온라인 사용자 집합(Set)을 유지하고, WebSocket으로 실시간 변경을 전파하며, 배치로 데이터베이스를 업데이트하는 하이브리드 방식을 사용합니다. 이 시스템의 핵심 특징은 첫째, heartbeat 메커니즘(30초마다 "살아있어요" 신호), 둘째, Redis 기반 빠른 조회(O(1) 시간 복잡도), 셋째, WebSocket 브로드캐스트(상태 변경 즉시 전파)입니다.
이러한 특징들이 확장 가능하고 실시간인 온라인 상태 시스템을 만듭니다.
코드 예제
// WebSocket 연결 시 온라인 설정
async function handleWebSocketConnection(socket: WebSocket, userId: string) {
// 1. Redis에 온라인 상태 저장 (TTL 60초)
await redis.setex(`user:${userId}:online`, 60, '1');
// 2. 사용자가 속한 모든 그룹 조회
const groups = await db.query(
'SELECT group_id FROM group_members WHERE user_id = $1',
[userId]
);
// 3. 각 그룹에 온라인 상태 브로드캐스트
for (const group of groups.rows) {
await redis.sadd(`group:${group.group_id}:online`, userId);
// 그룹의 다른 멤버들에게 알림
io.to(`group:${group.group_id}`).emit('member:online', {
userId,
timestamp: Date.now()
});
}
// 4. Heartbeat 설정 (30초마다)
const heartbeatInterval = setInterval(async () => {
try {
socket.ping();
await redis.expire(`user:${userId}:online`, 60);
} catch (error) {
clearInterval(heartbeatInterval);
}
}, 30000);
// 5. 연결 종료 시 처리
socket.on('close', async () => {
clearInterval(heartbeatInterval);
// Redis에서 온라인 상태 제거
await redis.del(`user:${userId}:online`);
// 모든 그룹에서 제거
for (const group of groups.rows) {
await redis.srem(`group:${group.group_id}:online`, userId);
io.to(`group:${group.group_id}`).emit('member:offline', {
userId,
timestamp: Date.now()
});
}
// 데이터베이스에 마지막 접속 시간 저장
await db.query(
'UPDATE users SET last_seen_at = NOW() WHERE id = $1',
[userId]
);
});
}
// 그룹의 온라인 멤버 조회
async function getOnlineMembers(req: Request, res: Response) {
const { groupId } = req.params;
// Redis에서 온라인 멤버 조회 (빠름)
const onlineUserIds = await redis.smembers(`group:${groupId}:online`);
// 사용자 정보 조회 (배치로 한번에)
const users = await db.query(
'SELECT id, username, avatar_url FROM users WHERE id = ANY($1)',
[onlineUserIds]
);
res.json({
onlineMembers: users.rows,
count: users.rows.length
});
}
설명
이것이 하는 일: 이 시스템은 사용자가 앱에 접속하고 나가는 것을 실시간으로 감지하고, 그 정보를 다른 멤버들에게 즉시 알려주는 전체 프로세스를 관리합니다. 건물 출입문에 센서가 있어서 누가 들어오고 나가는지 실시간으로 표시하는 것과 비슷합니다.
첫 번째와 두 번째 단계는 연결 초기화입니다. WebSocket 연결이 성립되면 Redis에 "user:123:online" 같은 키를 생성하고 60초 TTL을 설정합니다.
TTL(Time To Live)은 자동 만료 시간으로, 60초 안에 갱신되지 않으면 자동으로 삭제됩니다. 또한 사용자가 속한 모든 그룹을 조회해서, 각 그룹의 온라인 멤버 Set에 추가합니다.
세 번째 단계에서 브로드캐스트를 수행합니다. Socket.io의 room 기능을 사용해서 "group:456"이라는 방에 있는 모든 클라이언트에게 'member:online' 이벤트를 전송합니다.
이러면 그룹에 있는 다른 멤버들의 화면에 즉시 "누가 온라인이 되었습니다"라는 표시가 나타납니다. 네 번째 단계는 heartbeat 메커니즘입니다.
30초마다 socket.ping()을 보내서 연결이 살아있는지 확인하고, Redis의 TTL을 갱신합니다. 만약 사용자가 인터넷이 끊기거나 앱을 강제 종료하면 ping이 실패하고, 60초 후에 Redis 키가 자동으로 만료되어 오프라인으로 표시됩니다.
이렇게 하면 연결이 끊긴 사용자를 자동으로 감지할 수 있습니다. 다섯 번째 단계는 정상적인 연결 종료 처리입니다.
사용자가 앱을 정상적으로 닫으면 'close' 이벤트가 발생하고, Redis에서 온라인 상태를 즉시 제거하며, 모든 그룹에 'member:offline' 이벤트를 브로드캐스트합니다. 또한 데이터베이스에 last_seen_at을 업데이트해서 "마지막 접속 시간"을 기록합니다.
getOnlineMembers 함수는 조회를 담당합니다. Redis의 Set에서 온라인 멤버 ID 목록을 가져오고(O(n) 하지만 빠름), 데이터베이스에서 사용자 정보를 배치로 한번에 조회합니다.
여러분이 이 시스템을 사용하면 수천 명의 멤버가 있어도 실시간으로 온라인 상태를 추적할 수 있고, 서버 부하를 최소화하면서 빠른 응답을 제공할 수 있습니다.
실전 팁
💡 상태를 세분화하세요. 단순한 온라인/오프라인 대신 온라인, 자리비움, 다른용무중, 오프라인 같은 상태를 제공하면 더 풍부한 정보를 줄 수 있습니다.
💡 마지막 접속 시간을 표시하세요. 오프라인 사용자에게 "5분 전 접속" 같은 정보를 보여주면 언제쯤 답장을 기대할 수 있는지 알 수 있습니다.
💡 온라인 상태 비공개 옵션을 제공하세요. 프라이버시를 중요하게 생각하는 사용자를 위해 온라인 상태를 숨길 수 있는 설정을 만드세요.
💡 배치 업데이트로 DB 부하를 줄이세요. last_seen_at을 매번 업데이트하지 말고, 5분마다 배치로 한번에 업데이트하면 데이터베이스 부하가 크게 줄어듭니다.
💡 타이핑 표시를 추가하세요. 온라인 상태와 함께 "누가 입력 중입니다" 표시를 추가하면 더 생동감 있는 채팅 경험을 제공할 수 있습니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
그룹 채팅 고급 기능 완벽 가이드
그룹 채팅 애플리케이션에서 사용자 경험을 극대화하는 필수 고급 기능들을 배워봅니다. 공지사항 관리부터 멤버 멘션, 고정 메시지까지 실무에서 바로 적용 가능한 구현 방법을 초급자도 이해할 수 있게 설명합니다.
그룹 채팅 메시지 및 권한 관리 완벽 가이드
실시간 그룹 채팅에서 메시지 브로드캐스트부터 읽음 상태 관리, 관리자와 일반 멤버의 권한 설정까지 완벽하게 구현하는 방법을 배워봅니다. 실무에서 바로 사용할 수 있는 권한 검증 미들웨어까지 포함된 완벽한 가이드입니다.
그룹 채팅방 생성 및 관리 완벽 가이드
실시간 그룹 채팅 애플리케이션을 만들 때 필요한 핵심 기능들을 단계별로 배워봅니다. API 설계부터 Socket.io를 활용한 실시간 통신, 그룹 관리 기능까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.
메시지 기능 확장 완벽 가이드
채팅 앱의 메시지 기능을 한 단계 업그레이드하는 방법을 알려드립니다. 메시지 수정, 삭제부터 이모지 반응, 답장, 전달 기능까지 실무에서 꼭 필요한 고급 기능들을 단계별로 구현해봅니다. 초급 개발자도 쉽게 따라할 수 있도록 친절하게 설명합니다.
실시간 채팅의 핵심 읽음 표시와 타이핑 인디케이터 완벽 가이드
카카오톡이나 슬랙처럼 메시지를 읽었는지 표시하고, 상대방이 타이핑 중인지 보여주는 기능을 직접 구현해보세요. 실시간 소켓 통신부터 상태 관리, 성능 최적화까지 실무에서 바로 사용할 수 있는 모든 것을 담았습니다.