2025년 7월 28일·6분 읽기

B2B 조직과 팀의 혼란 없이 유지되는 데이터베이스 스키마

B2B 조직 및 팀 데이터베이스 스키마: 초대, 멤버십 상태, 역할 상속, 감사 가능한 변경을 다루는 실용적 관계형 패턴.

B2B 조직과 팀의 혼란 없이 유지되는 데이터베이스 스키마

이 스키마 패턴이 해결하는 문제

대부분의 B2B 앱은 단순한 "사용자 계정" 앱이 아닙니다. 사람들은 조직에 속하고 팀으로 나뉘며 직무에 따라 다른 권한을 얻는 공유 작업 공간입니다. 영업, 지원, 재무, 관리자 등은 서로 다른 접근이 필요하고 그 권한은 시간이 지나며 바뀝니다.

너무 단순한 모델은 빠르게 무너집니다. 하나의 users 테이블에 단일 role 열만 두면 "동일한 사람이 한 조직에서는 Admin이고 다른 조직에서는 Viewer" 같은 상황을 표현할 수 없습니다. 또한 한 팀만 볼 수 있어야 하는 계약직이나 프로젝트에서 빠진 직원이 회사에는 계속 남아 있는 경우 같은 흔한 케이스도 처리할 수 없습니다.

초대도 자주 버그를 만드는 지점입니다. 초대가 단순히 이메일 한 줄로만 저장되면 그 사람이 조직에 "속해 있는지", 어떤 팀에 합류해야 하는지, 다른 이메일로 가입하면 어떻게 되는지가 불명확해집니다. 이런 작은 불일치들이 보안 문제로 이어지기 쉽습니다.

이 패턴은 네 가지 목표를 지향합니다:

  • 보안: 권한은 가정이 아니라 명시적 멤버십에서 나옵니다.
  • 명확성: 조직, 팀, 역할 각각에 단일 진실의 근원이 있습니다.
  • 일관성: 초대와 멤버십은 예측 가능한 수명 주기를 따릅니다.
  • 히스토리: 누가 접근을 부여하거나 역할을 변경하거나 제거했는지 설명할 수 있습니다.

약속은 기능이 늘어나도 이해하기 쉬운 하나의 관계형 모델입니다: 사용자당 여러 조직, 조직당 여러 팀, 예측 가능한 역할 상속, 감사 친화적 변경. 오늘 구현하고 나중에 전체를 다시 쓰지 않아도 확장할 수 있는 구조입니다.

핵심 용어: org, team, user, membership

6개월 뒤에도 읽기 쉬운 스키마를 원한다면 몇 가지 용어에 먼저 합의하세요. 대부분의 혼동은 "그 사람이 누구인가"와 "무엇을 할 수 있는가"를 섞는 데서 옵니다.

**Organization (org)**는 최상위 테넌트 경계입니다. 고객 또는 데이터 소유 비즈니스 계정을 나타냅니다. 두 사용자가 서로 다른 org에 있으면 기본적으로 서로의 데이터를 보지 못해야 합니다. 이 규칙 하나로 많은 우발적 교차 테넌트 접근을 막을 수 있습니다.

Team은 조직 내부의 작은 그룹입니다. Sales, Support, Finance, 또는 "Project A" 같은 실제 작업 단위를 모델링합니다. 팀은 조직 경계를 대체하지 않으며 그 아래에 존재합니다.

User는 정체성입니다. 로그인 및 프로필: 이메일, 이름, 비밀번호 또는 SSO ID, 때로는 MFA 설정 등입니다. 사용자는 아직 어떤 접근도 없이 존재할 수 있습니다.

Membership은 접근 기록입니다. "이 사용자가 이 조직(선택적으로 이 팀 포함)에 어떤 상태와 어떤 역할로 속해 있는가"를 답합니다. 정체성(User)과 접근(Membership)을 분리하면 계약직, 오프보딩, 다중 조직 접근을 훨씬 쉽게 모델링할 수 있습니다.

코드와 UI에서 사용할 수 있는 간단한 의미들:

  • Member: 조직이나 팀에 활성 멤버십이 있는 사용자.
  • Role: 권한 묶음의 이름(예: Org Admin, Team Manager).
  • Permission: 단일 허용 액션(예: "청구서 보기").
  • Tenant boundary: 데이터가 조직에 스코프되는 규칙.

멤버십을 불리언이 아닌 작은 상태 머신으로 취급하세요. 일반적인 상태는 invited, active, suspended, removed 등입니다. 이렇게 하면 초대, 승인, 오프보딩이 일관되고 감사 가능해집니다.

단일 관계형 모델: 핵심 테이블과 관계

좋은 멀티테넌트 스키마는 한 가지 아이디어에서 시작합니다: "누가 어디에 속하는가"를 한 곳에 저장하고 나머지는 보조 테이블로 두세요. 이렇게 하면 기본 질문(누가 조직에 있는가, 누가 팀에 있는가, 무엇을 할 수 있는가)을 관련 없는 모델을 헤매지 않고 답할 수 있습니다.

일반적으로 필요한 핵심 테이블:

  • organizations: 고객 계정(테넌트)당 한 행. 이름, 상태, 청구 필드, 불변 ID를 담습니다.
  • teams: 조직 내부의 그룹(Support, Sales, Admin). 항상 하나의 organization에 속합니다.
  • users: 사람당 한 행. 전역적이며 조직별이 아닙니다.
  • memberships: "이 사용자가 이 조직에 속한다"고 말해주는 브리지. 선택적으로 팀 정보도 포함.
  • role_grants(또는 role_assignments): 조직 레벨, 팀 레벨 또는 둘 다에서 멤버십이 가진 역할들.

키와 제약을 엄격하게 유지하세요. 각 테이블에 대리 기본키(UUID 또는 bigint)를 사용하고 teams.organization_id -> organizations.id, memberships.user_id -> users.id 같은 외래키를 추가하세요. 그다음 중복을 생산 환경에 내리기 전에 막는 몇 가지 유니크 제약을 더하세요.

대부분의 나쁜 데이터를 조기에 잡는 규칙:

  • 하나의 org 슬러그 또는 외부 키: unique(organizations.slug)
  • org별 팀 이름: unique(teams.organization_id, teams.name)
  • 중복 조직 멤버십 금지: unique(memberships.organization_id, memberships.user_id)
  • (팀 멤버십을 별도로 모델링하는 경우) 중복 팀 멤버십 금지: unique(team_memberships.team_id, team_memberships.user_id)

무엇을 추가 기록(append-only)으로 둘지 업데이트 가능으로 둘지 결정하세요. 조직, 팀, 사용자는 업데이트 가능하게 두는 게 일반적입니다. 멤버십은 현재 상태(active, suspended)를 위해 업데이트 가능하지만 변경 사항은 따로 append-only 접근 로그에 기록해서 나중에 감사를 쉽게 하는 것이 좋습니다.

일관성 있는 초대와 멤버십 상태

접근을 깔끔하게 유지하는 가장 쉬운 방법은 초대를 반쯤 만들어진 멤버십이 아니라 별도 레코드로 취급하는 것입니다. 멤버십은 "이 사용자가 현재 속해 있다"를 의미하고, 초대는 "접근을 제안했지만 아직 실제가 아니다"를 의미합니다. 이렇게 분리하면 유령 멤버, 반쯤 만들어진 권한, "누가 이 사람을 초대했는가?" 같은 미스터리를 피할 수 있습니다.

단순하고 신뢰할 수 있는 상태 모델

멤버십에는 누구에게나 설명 가능한 작은 집합의 상태를 사용하세요:

  • active: 사용자가 조직(및 자신이 속한 팀)에 접근할 수 있음
  • suspended: 일시적으로 차단되지만 히스토리는 유지됨
  • removed: 더 이상 멤버가 아니며 감사 및 보고를 위해 보관됨

많은 팀은 멤버십에 invited 상태를 두지 않고 초대 테이블에만 invited를 둡니다. 그렇게 하면 멤버십 행은 실제로 접근 권한이 있는 사용자(active)나 과거에 접근이 있었던 사용자(suspended/removed)만을 나타내므로 더 깔끔합니다.

계정이 아직 없을 때 이메일 초대

B2B 앱은 종종 계정이 없는 사용자에게 이메일로 초대합니다. 초대 레코드에 이메일과 초대가 적용되는 위치(org 또는 team), 의도된 역할, 보낸 사람을 저장하세요. 나중에 그 이메일로 가입하면 보류 중인 초대와 매칭해서 수락하도록 할 수 있습니다.

초대가 수락될 때는 한 트랜잭션에서 처리하세요: 초대를 accepted로 표시하고 멤버십을 생성한 다음 감사 항목(누가 수락했고, 언제, 어떤 이메일을 사용했는지)을 기록합니다.

명확한 초대 종료 상태를 정의하세요:

  • expired: 만료되어 더 이상 수락 불가
  • revoked: 관리자가 취소해서 더 이상 수락 불가
  • accepted: 멤버십으로 전환됨

중복 초대를 방지하려면 "org 또는 team 당 이메일 하나의 보류 중인 초대만" 허용하세요. 재초대를 지원하면 기존 보류 초대의 만료를 연장하거나 이전 것을 취소하고 새 토큰을 발급하세요.

접근을 혼란스럽게 만들지 않는 역할과 상속

접근 관리를 중앙화하세요
여러 스크립트를 하나의 앱으로 대체하여 초대, 역할, 접근 변경을 깔끔하게 관리하세요.
AppMaster 사용해보기

대부분의 B2B 앱은 조직 전체에서 할 수 있는 것과 특정 팀 내부에서 할 수 있는 것, 두 수준의 접근이 필요합니다. 이를 하나의 role 필드에 섞어 넣으면 일관성이 깨지기 쉽습니다.

조직 수준 역할은 결제 관리, 사람 초대, 모든 팀 보기 같은 질문에 답합니다. 팀 수준 역할은 특정 팀에서 항목을 편집할 수 있는지, 요청을 승인할 수 있는지, 단순 보기만 가능한지 등을 답합니다.

역할 상속은 다음 규칙 하나를 따르면 관리하기 쉽습니다: 조직 역할은 명시적으로 팀이 달리 지정하지 않는 한 모든 곳에 적용됩니다. 이렇게 하면 동작이 예측 가능해지고 중복 데이터가 줄어듭니다.

이를 모델링하는 깔끔한 방법은 스코프를 가진 역할 할당을 저장하는 것입니다:

  • role_assignments: user_id, org_id, 선택적 team_id (NULL이면 조직 전체), role_id, created_at, created_by

스코프당 하나의 역할만 허용하려면 (user_id, org_id, team_id)에 유니크 제약을 추가하세요.

그다음 팀에 대한 실제 접근을 계산하는 방법:

  1. 팀 전용 할당(team_id = X)을 찾습니다. 존재하면 이를 사용합니다.

  2. 그렇지 않으면 조직 전역 할당(team_id IS NULL)으로 대체합니다.

권한 최소화 원칙을 위해 기본 조직 역할을 최소 권한(보통 "Member")으로 정하고 숨겨진 관리자 권한을 주지 마세요. 새 사용자는 제품이 정말로 필요하지 않다면 암묵적으로 팀 접근을 얻지 않아야 합니다. 자동 부여를 한다면 조직 역할을 조용히 넓히지 말고 명시적 팀 멤버십을 생성하세요.

오버라이드는 드물고 명확해야 합니다. 예: Maria는 조직에서 "Manager"(초대 가능, 리포트 보기 가능)이지만 Finance 팀에서는 "Viewer"여야 한다면, Maria의 조직 전역 할당과 Finance에 대한 팀 범위 오버라이드를 각각 저장합니다. 권한 복사는 하지 말고 예외를 눈에 띄게 기록하세요.

일반 패턴에는 역할 이름을 사용하세요. 진짜 특수 케이스(예: "내보낼 수는 있지만 수정할 수는 없음")나 규정 준수가 필요한 경우에만 명시적 권한 목록을 사용하세요. 그럴 때에도 동일한 스코프 개념을 유지하면 사고 모델이 일관됩니다.

감사 친화적 변경: 누가 접근을 변경했는지 추적하기

멤버십 행에 현재 역할만 저장하면 이야기를 잃어버립니다. 누군가가 "지난 화요일에 누가 Alex에게 관리자 권한을 줬는가?"라고 물으면 신뢰할 수 있는 답을 내놓을 수 없습니다. 현재 상태뿐 아니라 변경 이력이 필요합니다.

가장 단순한 접근법은 접근 이벤트를 기록하는 별도 감사 로그 테이블을 만드는 것입니다. 이를 append-only 저널로 취급하세요: 오래된 감사 행을 편집하지 말고 새 행을 추가하세요.

실용적인 감사 테이블은 보통 다음을 포함합니다:

  • actor_user_id (변경을 수행한 사람)
  • subject_typesubject_id (membership, team, org 등)
  • action (invite_sent, role_changed, membership_suspended, team_deleted 등)
  • occurred_at (발생 시각)
  • reason (선택적 자유 텍스트, 예: "contractor offboarding")

변경 전후를 캡처하려면 관심 있는 필드의 작은 스냅샷을 저장하세요. 접근 제어 관련 데이터로만 제한하세요. 예: before_role, after_role, before_state, after_state, before_team_id, after_team_id. 유연성을 원하면 두 개의 JSON 열(before, after)을 쓰되 페이로드는 작고 일관되게 유지하세요.

멤버십과 팀에는 소프트 삭제가 일반적으로 하드 삭제보다 낫습니다. 행을 제거하는 대신 deleted_at, deleted_by 같은 필드로 비활성화하세요. 이렇게 하면 외래키가 무너지지 않고 과거 접근을 설명하기 쉬워집니다. 만료된 초대 같은 임시 레코드는 하드 삭제가 합리적일 수 있지만 나중에 필요하지 않을 것이라는 확신이 있어야 합니다.

이 구조가 있으면 다음과 같은 규정 요구 질문에 빠르게 답할 수 있습니다:

  • 누가 언제 접근을 부여하거나 제거했나?
  • 정확히 무엇이 변경되었나(역할, 팀, 상태)?
  • 접근 제거가 정상적인 오프보딩의 일부였나?

관계형 데이터베이스에 스키마 설계 단계별 적용

접근 감사 로그 추가하세요
누가 언제 접근을 변경했는지 답할 수 있도록 감사 친화적 접근 이벤트를 만드세요.
지금 빌드

간단하게 시작하세요: 누가 어디에 속하는지를 말하는 한 곳을 만들고 이유를 기록하세요. 작은 단계로 구축하고 데이터가 "대충 맞는" 상태로 흐르지 않도록 규칙을 추가하세요.

PostgreSQL 등 관계형 DB에서 잘 작동하는 실용적인 순서:

  1. organizationsteams를 생성합니다. 각자 안정적인 기본키(UUID 또는 bigint)를 사용하세요. teams.organization_id에 외래키를 추가하고 팀 이름의 org 내 유일성 여부를 조기에 결정하세요.

  2. users를 멤버십과 분리하세요. 정체성 필드를 users에(이메일, 상태, created_at) 두고, 조직/팀 소속은 memberships 테이블에 user_id, organization_id, 선택적 team_id, 그리고 state 열(active, suspended, removed)로 둡니다.

  3. invitations를 별도 테이블로 추가하세요. organization_id, 선택적 team_id, email, token, expires_at, accepted_at을 저장합니다. "org + email + team"당 하나의 열린 초대만 허용하는 유니크를 적용해 중복 생성을 막으세요.

  4. 역할은 명시적 테이블로 모델링하세요. 간단한 접근은 roles(admin, member 등)과 조직 범위( team_id NULL) 또는 팀 범위(team_id 설정)를 가리키는 role_assignments입니다. 상속 규칙을 일관되게 유지하고 테스트하세요.

  5. 처음부터 감사 트레일을 추가하세요. access_events 테이블에 actor_user_id, target_user_id(또는 초대용 이메일), action(invite_sent, role_changed, removed), scope(org/team), created_at 등을 기록하세요.

이 테이블들이 준비되면 기본적인 관리자 쿼리를 몇 개 실행해 현실을 검증하세요: "누가 조직 전역 접근을 가지고 있나?", "관리자가 없는 팀은 어디인가?", "만료됐지만 아직 열려있는 초대는?" 등. 이런 질문들이 초기 제약 누락을 빨리 드러냅니다.

지저분한 데이터를 막는 규칙과 제약

데이터 모델을 깔끔하게 유지하세요
팀, 예외, 감사 이력을 추가해도 읽기 쉬운 데이터 모델을 사용하세요.
데이터베이스 설계

스키마가 유지되려면 데이터베이스가(코드만이 아니라) 테넌트 경계를 강제해야 합니다. 가장 단순한 규칙은: 테넌트 스코프 테이블은 org_id를 가지고 있고 모든 조회에 포함되어야 한다는 것. 누군가 앱에서 필터를 빼먹어도 DB가 교차 조직 연결을 저지해야 합니다.

데이터를 깨끗하게 유지하는 가드레일

항상 "같은 조직 내"를 가리키는 외래키로 시작하세요. 예를 들어 팀 멤버십을 별도로 저장한다면 team_memberships 행은 team_iduser_id를 참조하지만 org_id도 포함해야 합니다. 복합 키로 참조된 팀이 같은 org에 속하도록 강제할 수 있습니다.

가장 흔한 문제를 막는 제약:

  • 사용자당 조직 내 하나의 활성 멤버십: (org_id, user_id)에 대한 유니크(부분 조건 사용 가능)
  • 이메일당 org 또는 team 당 하나의 보류 중인 초대: (org_id, team_id, email)에 대해 state = 'pending' 같은 조건을 둔 유니크
  • 초대 토큰은 전역적으로 유일하고 재사용하지 않음: invite_token에 유니크
  • 팀은 정확히 하나의 org에 속함: teams.org_id NOT NULL 및 orgs(id)에 대한 외래키
  • 멤버십은 삭제하지 말고 종료하세요: ended_at(및 선택적 ended_by)을 저장해 감사 히스토리를 보호

실제로 하는 조회를 위한 인덱싱

앱이 자주 실행하는 쿼리에 인덱스를 추가하세요:

  • (org_id, user_id) — "이 사용자가 어떤 조직에 속해 있나?"
  • (org_id, team_id) — "이 팀의 멤버를 나열"
  • (invite_token) — "초대 수락"
  • (org_id, state) — 관리자 화면(활성 멤버, 보류 중 초대 등)

조직 이름은 이동 가능하게 유지하세요. 불변의 orgs.id를 어디든 사용하고 orgs.name(및 슬러그)은 편집 가능한 필드로 취급하세요. 이름 변경은 한 행만 건드리면 됩니다.

팀을 조직 간에 옮기는 것은 보통 정책적 결정입니다. 가장 안전한 옵션은 금지(또는 팀 복제)입니다. 멤버십, 역할, 감사 이력이 조직 스코프이기 때문에 이동을 허용하면 복잡합니다. 꼭 허용해야 한다면 하나의 트랜잭션에서 하고 org_id를 가진 모든 자식 행을 업데이트하세요.

사용자가 떠날 때 고아 레코드를 방지하려면 하드 삭제를 피하세요. 사용자를 비활성화하고 멤버십을 종료하며 부모 행에 대해 ON DELETE RESTRICT를 설정해 의도치 않은 삭제를 막으세요.

예시 시나리오: 하나의 조직, 두 팀, 안전한 접근 변경

Northwind Co라는 회사가 있고 조직 하나와 Sales, Support 두 팀이 있다고 가정하세요. 계약직 Mia를 한 달 동안 Support 티켓을 돕기 위해 고용했습니다. 이 모델은 다음과 같이 예측 가능해야 합니다: 한 사람, 한 조직 멤버십, 선택적 팀 멤버십, 명확한 상태.

조직 관리자(Ava)가 Mia를 이메일로 초대합니다. 시스템은 org에 묶인 초대 행을 만들고 상태는 pending, 만료일을 설정합니다. 아직 다른 변경은 없으므로 접근이 불명확한 "반쯤 만든 사용자"는 존재하지 않습니다.

Mia가 수락하면 초대는 accepted로 표시되고 org 멤버십 행이 active 상태로 생성됩니다. Ava는 Mia의 조직 역할을 member로 설정하고(관리자 아님), 그다음 Support 팀 멤버십을 추가하고 support_agent 같은 팀 역할을 부여합니다.

변화를 하나 추가해보면: Ben은 조직에서 admin 역할을 가진 정규 직원인데 Support 데이터는 보지 못하게 해야 합니다. 이 경우 Support 팀에 대해 명시적 다운그레이드 팀 오버라이드를 저장하여 조직 전역의 admin 능력은 유지하되 Support 내에서는 Viewer로 만드는 방식으로 처리합니다.

일주일 후 Mia가 규정 위반으로 정학(suspended) 처리됩니다. 행을 삭제하는 대신 Ava는 Mia의 조직 멤버십 상태를 suspended로 설정합니다. 팀 멤버십은 그대로 남을 수 있지만 조직 멤버십이 활성 상태가 아니므로 실질적 접근은 차단됩니다.

감사 기록은 각 변경을 이벤트로 남기므로 깔끔합니다:

  • Ava가 Mia를 초대함(누가, 무엇을, 언제)
  • Mia가 초대 수락
  • Ava가 Mia를 Support에 추가하고 support_agent 할당
  • Ava가 Ben의 Support 오버라이드 설정
  • Ava가 Mia를 정학 처리

이 모델을 사용하면 UI는 명확한 접근 요약을 보여줄 수 있습니다: 조직 상태(활성/정학), 조직 역할, 역할과 오버라이드가 표시된 팀 목록, 그리고 누가 누구를 왜/언제 접근했는지 설명하는 "최근 접근 변경" 피드.

피해야 할 흔한 실수와 함정

다중 테넌트 포털 출시하세요
조직 전환, 팀 뷰, 역할 기반 화면을 갖춘 고객 포털을 구축하세요.
앱 생성

대부분의 접근 버그는 "거의 맞는" 데이터 모델에서 옵니다. 처음에는 괜찮아 보이던 스키마가 엣지 케이스가 쌓이면서 무너집니다: 재초대, 팀 이동, 역할 변경, 오프보딩 등.

흔한 함정은 초대와 멤버십을 하나의 행에 섞는 것입니다. "invited"와 "active"를 같은 레코드에 명확한 의미 없이 섞으면 "이 사람이 수락하지 않았으면 멤버인가?" 같은 불가능한 질문이 생깁니다. 초대를 멤버십과 분리하거나 상태 머신을 명확하고 일관되게 만드세요.

또 다른 흔한 실수는 사용자 테이블에 단일 role 열을 두는 것입니다. 역할은 거의 항상 스코프가 있습니다(조직 역할, 팀 역할, 프로젝트 역할). 전역 역할은 "이 사용자는 한 고객에겐 관리자, 다른 고객에겐 읽기 전용" 같은 꼼수를 강요하게 되어 멀티테넌트 기대를 깨고 지원 골칫거리를 만듭니다.

나중에 문제를 만드는 함정:

  • 실수로 교차 조직 팀 멤버십 허용(예: team_id는 org A를 가리키는데 membership이 org B를 가리키는 경우)
  • 멤버십을 하드 삭제해서 "지난주에 누가 접근했나?"를 알 수 없음
  • 유니크 규칙 누락으로 동일한 사용자가 동일 행으로 중복 접근을 얻음
  • 상속이 조용히 쌓여 아무도 왜 접근이 있는지 설명할 수 없음
  • "초대 수락"을 단순 UI 이벤트로 처리하고 데이터베이스 사실로 기록하지 않음

간단한 예: 계약직이 조직에 초대되어 Team Sales에 가입한 뒤 제거되었다가 한 달 후 재초대되는 경우. 이전 행을 덮어쓰면 히스토리를 잃고, 중복을 허용하면 두 개의 활성 멤버십을 갖게 될 수 있습니다. 명확한 상태, 범위된 역할, 적절한 제약이 둘 다를 방지합니다.

앱에 적용하기 위한 빠른 점검 및 다음 단계

코딩하기 전에 모델을 종이 위에 다시 검토해서 여전히 말이 되는지 확인하세요. 좋은 멀티테넌트 접근 모델은 지루하게 느껴져야 합니다: 동일한 규칙이 모든 곳에 적용되고 "특수 케이스"는 드뭅니다.

일반적인 갭을 찾기 위한 빠른 체크리스트:

  • 모든 멤버십은 정확히 하나의 사용자와 하나의 조직을 가리키며 중복을 방지하는 유니크 제약이 있음
  • 초대, 멤버십, 제거 상태는 명시적이고(널로 암시하지 않음) 전환 경로가 제한됨(예: 만료된 초대는 수락 불가)
  • 역할은 한 곳에 저장되고 유효한 접근은 일관되게 계산됨(상속 규칙 포함)
  • 조직/팀/사용자 삭제가 히스토리를 지우지 않음(감사를 위해 소프트 삭제 또는 보관 필드 사용)
  • 모든 접근 변경은 행위자, 대상, 범위, 타임스탬프, 이유/출처와 함께 감사 이벤트를 발행함

설계에 압력을 주어 다음 질문에 대답 가능한지 확인하세요. 한 쿼리와 명확한 규칙으로 답할 수 없다면 제약이나 추가 상태가 필요합니다:

  • 사용자가 두 번 초대되고 이메일이 변경되면 어떻게 되나?
  • 팀 관리자가 조직 소유자를 그 팀에서 제거할 수 있나?
  • 조직 역할이 모든 팀에 접근을 부여하면, 한 팀이 이를 오버라이드할 수 있나?
  • 역할이 변경된 뒤에 초대가 수락되면 어떤 역할이 적용되나?
  • 지원팀이 "누가 접근을 제거했나"를 물으면 빠르게 증명할 수 있나?

관리자와 지원 스태프가 반드시 이해해야 하는 것을 문서화하세요: 멤버십 상태(및 트리거), 누가 초대/제거할 수 있는지, 역할 상속의 의미를 평이한 언어로, 사건 발생 시 어디서 감사 이벤트를 찾을지 등.

먼저 제약(유니크, 외래키, 허용 전환)을 구현하고 그 위에서 비즈니스 로직을 구축하세요. 데이터베이스가 여러분을 정직하게 유지하게 하세요. 상속 온/오프, 기본 역할, 초대 만료 같은 정책 결정은 코드 상수가 아니라 구성 테이블에 넣어 관리하세요.

만약 수작업으로 모든 백엔드와 관리자 화면을 작성하지 않고도 이걸 만들고 싶다면, AppMaster (appmaster.io)는 PostgreSQL에서 이러한 테이블을 모델링하고 초대 및 멤버십 전환을 명시적 비즈니스 프로세스로 구현하는 데 도움을 줄 수 있으며, 생산 배포용 실제 소스 코드를 생성합니다.

자주 묻는 질문

Why shouldn’t I store a single role column on the users table?

멤버십 레코드를 별도로 사용하세요. 역할과 접근 권한은 전역 사용자 정체성에 묶지 않고 조직(및 선택적으로 팀)에 묶어야 합니다. 이렇게 하면 같은 사람이 한 조직에서는 Admin이고 다른 조직에서는 Viewer가 되는 상황을 별도의 꼼수 없이 표현할 수 있습니다.

Should an invite create a membership row right away?

별도로 유지하세요: **초대(invitation)**는 이메일, 범위, 만료일을 가진 제안이고, **멤버십(membership)**은 실제 접근 권한을 가진 상태를 뜻합니다. 이렇게 분리하면 “유령 멤버”, 불분명한 상태, 이메일 변경 시 발생하는 보안 문제를 피할 수 있습니다.

What membership states should I use?

대부분의 B2B 앱에는 active, suspended, removed 같은 작은 집합이 충분합니다. 만약 invited 상태를 오직 초대 테이블에만 두면 멤버십은 현재 또는 과거의 실제 접근을 나타내므로 더욱 명확합니다.

How do I model org roles vs team roles without confusion?

조직 역할과 팀 역할을 범위(scope)를 가진 할당으로 저장하세요. 조직 전체일 때는 team_id가 NULL이고, 팀 전용일 때는 team_id가 설정됩니다. 팀에 대한 접근을 확인할 때는 팀 전용 할당을 우선하고, 없으면 조직 전역 할당으로 대체합니다.

What’s the simplest rule for role inheritance?

간단하고 예측 가능한 규칙부터 시작하세요: 조직 역할은 기본적으로 모든 곳에 적용되고, 팀 역할은 명시적으로 설정될 때만 이를 덮어씁니다. 오버라이드는 드물고 명확하게 보여야 사람들이 추적할 수 있습니다.

How do I prevent duplicate invites and re-invite cleanly?

org/team + email 조합으로 ‘하나의 대기중인 초대만 허용’하도록 유니크 제약을 걸고 pending/accepted/revoked/expired 수명 주기를 명확히 하세요. 재초대가 필요하면 기존의 대기 초대 만료일을 연장하거나 기존 초대를 취소하고 새 토큰을 발급하세요.

How do I enforce the tenant boundary in the database?

모든 테넌트 범위 행이 org_id를 가지고 있어야 하며, 외래키와 제약으로 조직을 섞지 못하게 하세요(예: 멤버십이 참조하는 team_id는 같은 조직에 속해야 함). 이렇게 하면 애플리케이션 코드에서 필터를 빼먹어도 데이터베이스가 교차 조직 연결을 막아줍니다.

How do I make access changes audit-friendly?

누가, 누구에게, 언제, 어떤 범위(org 또는 team)에서 무엇을 했는지 기록하는 추가 쓰기 전용 접근 이벤트 로그를 만드세요. 변경 전/후의 주요 필드를 기록하면 “지난 화요일에 누가 관리자 권한을 줬나?” 같은 질문에 신뢰할 수 있게 답할 수 있습니다.

Should I hard-delete memberships and invites?

멤버십과 팀에 대해서는 소프트 삭제(예: ended_at, deleted_by)를 권장합니다. 히스토리를 유지하면 감사와 추적이 쉬워집니다. 초대의 경우 만료된 기록까지 보관하면 보안 추적에 도움이 되지만 토큰은 재사용하지 마세요.

What indexes matter most for this schema?

핫 경로를 인덱싱하세요: 조직 멤버십 확인을 위한 (org_id, user_id), 팀 구성원 목록을 위한 (org_id, team_id), 초대 수락을 위한 (invite_token), 관리자 화면을 위한 (org_id, state) 등 실제 쿼리에 맞춰 인덱스를 만드세요.

쉬운 시작
멋진만들기

무료 요금제로 AppMaster를 사용해 보세요.
준비가 되면 적절한 구독을 선택할 수 있습니다.

시작하다