OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 63 / 90
stack-analysis
CHAPTER 63 / 90
읽기 약 2
FUNCTION

사용자 관리: 초대/역할/팀


핵심 개념

초대 토큰·역할(RBAC)·팀 멤버십 — 협업 SaaS 표준 패턴.

본문

데이터 모델

SQL📋 코드 (31줄)
CREATE TABLE tenants (
  id UUID PRIMARY KEY,
  name VARCHAR NOT NULL
);

CREATE TABLE users (
  id UUID PRIMARY KEY,
  email VARCHAR UNIQUE NOT NULL,
  name VARCHAR
);

-- N:M 관계 (한 사용자가 여러 tenant 가입 가능)
CREATE TABLE tenant_members (
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  user_id UUID NOT NULL REFERENCES users(id),
  role VARCHAR NOT NULL,  -- owner, admin, member, viewer
  joined_at TIMESTAMP DEFAULT NOW(),
  PRIMARY KEY (tenant_id, user_id)
);

CREATE TABLE invitations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  email VARCHAR NOT NULL,
  role VARCHAR NOT NULL DEFAULT 'member',
  token VARCHAR UNIQUE NOT NULL,  -- 한 번만 사용 가능
  invited_by UUID REFERENCES users(id),
  expires_at TIMESTAMP NOT NULL,
  accepted_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW()
);

초대 흐름

TYPESCRIPT📋 코드 (63줄)
// 1. 초대 발송
async function inviteUser(tenantId: string, email: string, role: string, inviterId: string) {
  // 이미 멤버인가?
  const existing = await db.tenantMember.findFirst({
    where: { tenantId, user: { email } },
  });
  if (existing) throw new ConflictError('Already a member');

  // 진행 중 초대 있는가?
  const pending = await db.invitation.findFirst({
    where: { tenantId, email, acceptedAt: null, expiresAt: { gt: new Date() } },
  });
  if (pending) {
    // 재발송만
    return resendInvitation(pending.id);
  }

  const token = crypto.randomBytes(32).toString('base64url');
  const invitation = await db.invitation.create({
    data: {
      tenantId, email, role, token,
      invitedBy: inviterId,
      expiresAt: new Date(Date.now() + 7 * 86400 * 1000),
    },
  });

  await emailQueue.add('invitation', {
    email,
    inviterName: (await db.user.findUnique({ where: { id: inviterId } }))?.name,
    tenantName: (await db.tenant.findUnique({ where: { id: tenantId } }))?.name,
    acceptUrl: `${APP_URL}/invitations/${token}`,
  });

  return invitation;
}


// 2. 초대 수락
async function acceptInvitation(token: string, userId: string) {
  const invitation = await db.invitation.findUnique({ where: { token } });
  if (!invitation) throw new NotFoundError('Invitation');
  if (invitation.acceptedAt) throw new ConflictError('Already accepted');
  if (invitation.expiresAt < new Date()) throw new BadRequestError('Expired');

  const user = await db.user.findUnique({ where: { id: userId } });
  if (user!.email !== invitation.email) {
    throw new ForbiddenError('Email mismatch');
  }

  return db.$transaction([
    db.tenantMember.create({
      data: {
        tenantId: invitation.tenantId,
        userId,
        role: invitation.role,
      },
    }),
    db.invitation.update({
      where: { id: invitation.id },
      data: { acceptedAt: new Date() },
    }),
  ]);
}

역할 기반 권한 (RBAC)

TYPESCRIPT📋 코드 (45줄)
const PERMISSIONS = {
  owner: ['*'],  // 모든 권한
  admin: [
    'project:read', 'project:write', 'project:delete',
    'member:read', 'member:invite', 'member:remove',
    'billing:read',
  ],
  member: [
    'project:read', 'project:write',
    'member:read',
  ],
  viewer: [
    'project:read',
    'member:read',
  ],
} as const;


function hasPermission(role: string, permission: string): boolean {
  const perms = PERMISSIONS[role as keyof typeof PERMISSIONS];
  if (!perms) return false;
  return perms.includes('*') || perms.includes(permission);
}


// 미들웨어
function requirePermission(permission: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!hasPermission(req.tenantRole!, permission)) {
      return res.status(403).json({
        error: 'forbidden',
        required: permission,
      });
    }
    next();
  };
}


// 사용
router.post('/projects',
  tenantMiddleware,
  requirePermission('project:write'),
  createProject,
);

조직 전환 (multi-tenant)

TYPESCRIPT📋 코드 (14줄)
// 사용자가 여러 tenant 소속 시
// 헤더 X-Tenant-Id로 현재 활성 tenant 지정

app.get('/api/me/tenants', async (req, res) => {
  const tenants = await db.tenantMember.findMany({
    where: { userId: req.user!.id },
    include: { tenant: { select: { id: true, name: true, slug: true } } },
  });
  res.json(tenants);
});


// UI — 좌측 사이드바에 tenant 전환
// Slack/Discord 스타일

다음 챕터

CH.64 "알림 시스템: 이메일/푸시/인앱".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 프로젝트의 사용자 관리 부분을 분석해서
실전 분석 + 개선 우선순위를 알려줘.
ChatGPT

무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro

사용자 관리 관련 실제 서비스 5개를
비교 분석해서 패턴 추출를 알려줘.
Gemini

무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro

내 코드베이스에서 사용자 관리
최적화 가능 위치를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 풀스택 시장의
사용자 관리 트렌드를 솔직히 알려줘.

⭐ 이것만 기억하세요
사용자 관리: 초대/역할/팀 이 3가지만 확실히 잡으세요
1.초대 토큰 = 1회용·만료 시간 7일 — 보안 + UX
2.RBAC = 역할별 권한 매트릭스 — owner/admin/member/viewer 4단
3.한 사용자가 여러 tenant 소속 — N:M 관계 + 활성 tenant 헤더


공유하기
진행도 63 / 90