2024년 12월 12일·6분 읽기

PostgreSQL에서 조직도 모델링: 인접 리스트 vs 클로저

인접 리스트와 클로저 테이블을 비교해 PostgreSQL에서 조직도를 모델링하는 방법을 설명합니다. 필터링, 리포팅, 권한 검사 예제를 포함합니다.

PostgreSQL에서 조직도 모델링: 인접 리스트 vs 클로저

조직도에 필요한 것들

조직도는 누가 누구에게 보고하는지, 팀이 어떻게 부서로 묶이는지 보여주는 지도입니다. PostgreSQL에서 조직도를 모델링할 때 단순히 각 사람에게 manager_id를 저장하는 것 이상의 일을 하게 됩니다. 실제 업무(조직 탐색, 리포팅, 접근 규칙)를 지원해야 합니다.

대부분의 사용자는 세 가지를 즉각적으로 기대합니다: 조직 탐색, 사람 찾기, 그리고 결과를 "내 영역"으로 필터링하는 것. 업데이트도 안전해야 합니다. 매니저가 바뀌면 차트가 보고서나 권한을 망가뜨리지 않고 모든 곳에서 즉시 반영되어야 합니다.

현실적으로 좋은 모델은 몇 가지 반복되는 질문에 답할 수 있어야 합니다:

  • 이 사람의 지휘 계보(최상위까지)는 무엇인가?
  • 이 매니저 아래에는 누가 있는가(직속 보고와 전체 서브트리)?
  • 대시보드를 위해 사람들이 어떻게 팀과 부서로 그룹화되는가?
  • 재조직은 어떻게 글리치 없이 일어나나?
  • 조직 구조를 기반으로 누가 무엇을 볼 수 있는가?

단순 트리보다 더 어려운 점은 조직이 자주 변한다는 사실입니다. 팀이 부서를 옮기고, 매니저가 그룹을 바꾸고, 일부 뷰는 단순히 "사람이 사람에게 보고한다"로 표현되지 않습니다. 예를 들어: 사람은 팀에 속하고 팀은 부서에 속합니다. 권한은 또 다른 층을 추가합니다: 조직의 형태가 단지 다이어그램이 아니라 보안 모델의 일부가 됩니다.

몇 가지 용어를 정리하면 설계가 명확해집니다:

  • 노드(node): 계층 구조의 한 항목(사람, 팀, 또는 부서).
  • 부모(parent): 바로 위의 노드(매니저 또는 팀을 소유한 부서).
  • 선조(ancestor): 임의의 거리 위에 있는 노드(당신의 매니저의 매니저).
  • 후손(descendant): 임의의 거리 아래에 있는 노드(당신 아래의 모든 사람).

예시: Sales가 새로운 VP 아래로 옮겨졌다면 두 가지는 즉시 유지되어야 합니다. 대시보드는 여전히 "Sales 전체"로 필터링되고, 새로운 VP의 권한은 Sales를 자동으로 포함해야 합니다.

테이블 설계 전에 내려야 할 결정들

스키마를 확정하기 전에 앱이 매일 답해야 하는 질문을 명확히 하세요. "누가 누구에게 보고하나?"는 시작일 뿐입니다. 많은 조직도는 누가 부서를 이끄는지, 누가 팀의 휴가를 승인하는지, 누가 리포트를 볼 수 있는지를 보여줘야 합니다.

화면과 권한 검사가 묻는 정확한 질문을 적어보세요. 질문을 구체적으로 이름 지을 수 없다면, 보기에는 맞아 보이지만 쿼리하기 어려운 스키마가 나오기 쉽습니다.

모든 것을 결정짓는 선택들:

  • 어떤 쿼리를 빠르게 만들어야 하나: 직속 매니저, CEO까지의 계보, 리더 아래의 전체 서브트리, 혹은 "이 부서의 모든 사람"?
  • 이것이 엄격한 트리(하나의 매니저)인가, 아니면 매트릭스 조직(둘 이상의 매니저 또는 리드)이 필요한가?
  • 부서는 사람과 같은 계층의 노드로 표현할 것인가, 아니면 각 사람에 department_id 같은 별도 속성으로 둘 것인가?
  • 누군가가 여러 팀에 속할 수 있는가(공유 서비스, 스쿼드)?
  • 권한은 트리 아래로 흐르는가, 위로 흐르는가, 아니면 양쪽 모두인가?

이 선택들이 "정확한" 데이터가 무엇인지 정의합니다. Alex가 Support와 Onboarding 둘 다 이끈다면 단일 manager_id나 "팀별 한 명의 리드" 규칙은 작동하지 않을 수 있습니다. 리드-팀 조인 테이블이나 "주요 팀 1개와 점선 팀" 같은 명확한 정책이 필요할 수 있습니다.

부서는 또 다른 갈림길입니다. 부서를 노드로 다루면 "Department A가 Team B를 포함하고 Team B가 Person C를 포함한다"를 표현할 수 있습니다. 부서를 별도로 두면 department_id = X로 필터링하는 것이 더 단순하지만, 팀이 부서를 가로지를 때 무너질 수 있습니다.

마지막으로 권한을 평이한 언어로 정의하세요. "매니저는 아래의 모든 사람의 급여를 볼 수 있지만 동료는 볼 수 없다"는 아래-트리 규칙입니다. "누구나 자신의 관리 계보는 볼 수 있다"는 위-트리 규칙입니다. 이 부분을 일찍 결정하세요. 어떤 모델이 자연스럽게 느껴질지, 어떤 것이 나중에 값비싼 쿼리를 강요할지 달라집니다.

인접 리스트: 매니저와 팀에 적합한 단순 스키마

최소한의 구성 요소를 원한다면 인접 리스트(adjacency list)가 고전적인 출발점입니다. 각 사람이 직접 매니저를 가리키는 포인터를 저장하고, 그 포인터를 따라 트리를 만들어 갑니다.

최소한의 설정은 다음과 같습니다:

create table departments (
  id bigserial primary key,
  name text not null unique
);

create table teams (
  id bigserial primary key,
  department_id bigint not null references departments(id),
  name text not null,
  unique (department_id, name)
);

create table employees (
  id bigserial primary key,
  full_name text not null,
  team_id bigint references teams(id),
  manager_id bigint references employees(id)
);

별도 테이블을 생략하고 department_nameteam_nameemployees에 컬럼으로 두는 것도 가능합니다. 시작은 빠르지만 정리 유지가 어렵습니다(오타, 이름 변경, 불일치 등). 별도 테이블은 필터링과 권한 규칙을 일관되게 표현하기 쉽습니다.

초기부터 가드레일을 추가하세요. 잘못된 계층 데이터는 나중에 고치기 고통스럽습니다. 최소한 자기 자신을 매니저로 지정하는 것을 방지(manager_id <> id)하고, 매니저가 같은 팀/부서 밖에 있을 수 있는지, 소프트 삭제나 변경 이력이 필요한지(감사 목적) 결정하세요.

인접 리스트에서는 대부분의 변경이 단순한 쓰기입니다: 매니저 변경은 employees.manager_id를 업데이트하고, 팀 이동은 employees.team_id를 업데이트합니다(종종 매니저와 함께). 문제는 작은 쓰기가 큰 하류 영향을 줄 수 있다는 점입니다. 리포팅 집계가 바뀌고, "매니저는 모든 보고서를 볼 수 있다"는 규칙은 새로운 체인을 따라야 합니다.

이 단순함이 인접 리스트의 최대 장점입니다. 반면 약점은 "이 매니저 아래의 모든 사람"을 자주 필터링할 때 드러납니다. 보통 매번 트리를 순회하는 재귀 쿼리에 의존해야 합니다.

인접 리스트: 필터링과 리포팅을 위한 일반 쿼리들

인접 리스트로 모델링하면 자주 쓰는 조직도 질문들이 재귀 쿼리 패턴으로 변합니다. PostgreSQL에서 이렇게 모델링하면 자주 사용하는 패턴은 다음과 같습니다.

직속 보고(한 단계)

가장 단순한 경우는 매니저의 즉각적인 팀입니다:

SELECT id, full_name, title
FROM employees
WHERE manager_id = $1
ORDER BY full_name;

이 쿼리는 빠르고 읽기 쉽지만 한 단계만 내려갑니다.

계보(위로 올라가기)

누군가가 누구에게 보고하는지(매니저, 매니저의 매니저 등)를 보여주려면 재귀 CTE를 사용하세요:

WITH RECURSIVE chain AS (
  SELECT id, full_name, manager_id, 0 AS depth
  FROM employees
  WHERE id = $1

  UNION ALL

  SELECT e.id, e.full_name, e.manager_id, c.depth + 1
  FROM employees e
  JOIN chain c ON e.id = c.manager_id
)
SELECT *
FROM chain
ORDER BY depth;

승인 경로, 에스컬레이션, 매니저 브레드크럼에 유용합니다.

전체 서브트리(아래로)

리더 아래의 모든 사람을 얻으려면 재귀를 반대로 사용합니다:

WITH RECURSIVE subtree AS (
  SELECT id, full_name, manager_id, department_id, 0 AS depth
  FROM employees
  WHERE id = $1

  UNION ALL

  SELECT e.id, e.full_name, e.manager_id, e.department_id, s.depth + 1
  FROM employees e
  JOIN subtree s ON e.manager_id = s.id
)
SELECT *
FROM subtree
ORDER BY depth, full_name;

흔한 리포트는 "리더 Y 아래의 부서 X의 모든 사람"입니다:

WITH RECURSIVE subtree AS (
  SELECT id, department_id
  FROM employees
  WHERE id = $1
  UNION ALL
  SELECT e.id, e.department_id
  FROM employees e
  JOIN subtree s ON e.manager_id = s.id
)
SELECT e.*
FROM employees e
JOIN subtree s ON s.id = e.id
WHERE e.department_id = $2;

인접 리스트 쿼리는 권한 검사에서 위험해질 수 있습니다. 접근 체크는 종종 전체 경로(뷰어가 이 사람의 선조인가?)에 의존하기 때문입니다. 어떤 엔드포인트가 재귀를 빼먹거나 필터를 잘못 적용하면 행이 노출될 수 있습니다. 또한 사이클이나 누락된 매니저 같은 데이터 문제를 주의하세요. 한 건의 잘못된 레코드가 재귀를 망치거나 놀라운 결과를 반환할 수 있으므로 권한 쿼리에는 안전장치와 좋은 제약조건이 필요합니다.

클로저 테이블: 전체 계층을 저장하는 방법

조직도 API 생성
핸드코딩 없이 조직 구조를 프로덕션용 API로 바꾸세요.
백엔드 생성

클로저 테이블은 직접 매니저 링크뿐 아니라 모든 선조-후손 관계를 저장합니다. 트리를 한 단계씩 걷는 대신 "이 리더 아래에 누가 있는가?"를 한 번의 조인으로 바로 물어볼 수 있습니다.

보통 노드(사람 또는 팀) 테이블 하나와 경로를 저장하는 클로저 테이블 하나를 둡니다.

-- nodes
employees (
  id bigserial primary key,
  name text not null,
  manager_id bigint null references employees(id)
)

-- closure
employee_closure (
  ancestor_id bigint not null references employees(id),
  descendant_id bigint not null references employees(id),
  depth int not null,
  primary key (ancestor_id, descendant_id)
)

클로저 테이블은 (Alice, Bob)처럼 "Alice가 Bob의 선조다"를 나타내는 쌍을 저장합니다. 또한 ancestor_id = descendant_iddepth = 0 행도 저장합니다. 처음 보면 이상하지만 많은 쿼리를 더 깔끔하게 만듭니다.

depth는 두 노드 간 거리를 알려줍니다: depth = 1은 직속 매니저, depth = 2는 매니저의 매니저 등입니다. 직속과 간접 관계를 구분해야 할 때 중요합니다.

주요 이점은 예측 가능하고 빠른 읽기입니다:

  • 전체 서브트리 조회가 빠릅니다(디렉터 아래의 모든 사람).
  • 계보 조회가 단순합니다(어떤 직원 위의 모든 매니저).
  • depth로 직속/간접 관계를 구분할 수 있습니다.

대신 업데이트 시 유지비용이 듭니다. Bob이 Alice에서 Dana로 매니저를 바꾸면 Bob과 Bob 아래의 모든 사람에 대한 클로저 행을 재계산해야 합니다. 일반적인 접근은 해당 서브트리에 대한 이전 선조 경로를 삭제한 뒤, Dana의 선조들과 Bob의 서브트리의 모든 노드를 결합해 새 경로를 삽입하는 것입니다.

클로저 테이블: 빠른 필터링을 위한 일반 쿼리들

역할 기반 포털 출시
뷰가 자동으로 '내 조직'으로 제한되는 직원 포털을 제공하세요.
포털 만들기

클로저 테이블은 모든 선조-후손 쌍을 사전에 저장합니다(보통 org_closure(ancestor_id, descendant_id, depth)). 따라서 대부분의 조직 필터가 단일 조인으로 해결되어 빠릅니다.

리더 아래의 모든 사람을 나열하려면 한 번만 조인하고 depth로 필터링하세요:

-- Descendants (everyone in the subtree)
SELECT e.*
FROM employees e
JOIN org_closure c
  ON c.descendant_id = e.id
WHERE c.ancestor_id = :manager_id
  AND c.depth > 0;

-- Direct reports only
SELECT e.*
FROM employees e
JOIN org_closure c
  ON c.descendant_id = e.id
WHERE c.ancestor_id = :manager_id
  AND c.depth = 1;

계보(한 직원의 모든 선조)를 보려면 조인을 뒤집습니다:

SELECT m.*
FROM employees m
JOIN org_closure c
  ON c.ancestor_id = m.id
WHERE c.descendant_id = :employee_id
  AND c.depth > 0
ORDER BY c.depth;

필터링은 예측 가능해집니다. 예: "리더 X 아래의 모든 사람 중 부서 Y에만 속한 사람":

SELECT e.*
FROM employees e
JOIN org_closure c ON c.descendant_id = e.id
WHERE c.ancestor_id = :leader_id
  AND e.department_id = :department_id;

계층이 사전 계산되어 있기 때문에 카운트도 간단합니다(재귀 불필요). 이는 대시보드와 권한 범위 합계에 도움이 되며, 페이지네이션과 검색에서도 ORDER BY, LIMIT/OFFSET, 필터를 하위 집합에 바로 적용할 수 있어 잘 동작합니다.

각 모델이 권한과 접근 검사에 미치는 영향

일반적인 조직 규칙은 단순합니다: 매니저는 자신의 아래에 있는 모든 것을 볼 수 있다(때로는 편집까지 가능). 어느 스키마를 선택하느냐에 따라 "누가 누구 아래에 있는가"를 알아내는 비용을 얼마나 자주 치러야 하는지가 달라집니다.

인접 리스트의 경우 권한 검사는 보통 재귀가 필요합니다. 사용자가 200명의 직원 목록 페이지를 열면 보통 재귀 CTE로 후손 집합을 만들고 대상 행을 그 집합으로 필터링합니다.

클로저 테이블을 쓰면 같은 규칙을 단순한 존재성 검사로 확인할 수 있습니다: "현재 사용자가 이 직원의 선조인가?"이면 허용합니다.

-- Closure table permission check (conceptual)
SELECT 1
FROM org_closure c
WHERE c.ancestor_id = :viewer_id
  AND c.descendant_id = :employee_id
LIMIT 1;

이 단순함은 행 수준 보안(RLS)을 도입할 때 중요합니다. 모든 쿼리에 규칙이 자동으로 포함되면 인접 리스트보다 정책이 튜닝하기 쉽습니다.

가장 자주 문제를 일으키는 엣지 케이스들:

  • 점선 보고(dotted-line): 한 사람이 두 명의 매니저를 실질적으로 가질 때.
  • 어시스턴트와 대리: 접근이 계층에 기반하지 않는 경우, 만료 시간을 가진 명시적 권한을 별도로 저장하세요.
  • 임시 접근: 시간 제한 권한은 조직 구조에 포함시키지 마세요.
  • 교차 팀 프로젝트: 관리 체인 대신 프로젝트 멤버십으로 접근을 부여하세요.

AppMaster로 구축한다면, 클로저 테이블은 시각적 데이터 모델에 깔끔하게 매핑되고 접근 검사가 웹/모바일 앱 전반에 걸쳐 단순하게 유지되는 경우가 많습니다.

트레이드오프: 속도, 복잡성, 유지보수

계층 권한 검증
모든 화면에서 '누가 누구를 볼 수 있는가'를 간단한 검사로 일관되게 유지하세요.
접근성 테스트

가장 큰 선택은 무엇을 최적화할지입니다: 단순한 쓰기와 작은 스키마인가, 아니면 "이 매니저 아래의 누구"를 빠르게 읽는 것인가.

인접 리스트는 테이블이 작고 업데이트가 쉬운 반면, 읽기에서는 전체 서브트리를 위해 재귀가 필요합니다. 조직이 작거나 UI가 몇 단계만 로드하거나 계층 기반 필터가 드물면 괜찮습니다.

클로저 테이블은 반대로 읽기가 빠릅니다. 하지만 이동이나 재조직 시 많은 관계 행을 삽입/삭제해야 해서 쓰기가 복잡해집니다.

실무에서는 보통 다음과 같은 패턴으로 보입니다:

  • 읽기 성능: 인접 리스트는 재귀가 필요하고, 클로저는 주로 조인으로 조직이 커져도 빠릅니다.
  • 쓰기 복잡성: 인접 리스트는 한 parent_id만 업데이트하면 되지만, 클로저는 단일 이동에 많은 행을 업데이트해야 합니다.
  • 데이터 크기: 인접 리스트는 사람/팀 수에 비례해 증가하지만, 클로저는 관계 수(최악의 경우 깊은 트리에서는 대략 N^2)에 근접할 수 있습니다.

인덱싱은 두 모델 모두에서 중요하지만 타겟이 다릅니다:

  • 인접 리스트: 부모 포인터(manager_id)와 활성 플래그 같은 자주 쓰는 필터에 인덱스를 걸어라.
  • 클로저 테이블: (ancestor_id, descendant_id)와 일반 조회를 위한 descendant_id 인덱스를 준비하라.

간단한 규칙: 계층으로 필터링을 거의 하지 않고 권한 검사가 "매니저는 직속 보고만 본다" 정도라면 인접 리스트로 충분합니다. 반대로 "VP X 아래의 모든 사람" 같은 리포트를 자주 돌리거나, 많은 화면에서 계층 기반 권한을 강제해야 한다면 클로저 테이블이 추가 유지비용을 정당화할 수 있습니다.

단계별: 인접 리스트에서 클로저 테이블로 이동하기

첫날부터 모델을 하나로 고집할 필요는 없습니다. 안전한 경로는 기존 인접 리스트(manager_id 또는 parent_id)를 유지하면서 옆에 클로저 테이블을 추가하고, 점진적으로 읽기를 옮기는 것입니다. 이렇게 하면 새로운 계층이 실제 쿼리와 권한 검사에서 어떻게 동작하는지 검증하면서 위험을 낮출 수 있습니다.

시작 방법:

  • 클로저 테이블(org_closure)과 인덱스를 생성하되, 인접 리스트를 진실의 출처로 유지하세요.
  • 현재 매니저 관계로부터 클로저 행을 백필하세요. 자기 자신에 대한 행(depth 0)도 포함하세요.
  • 몇몇 매니저를 샘플로 골라 두 모델이 같은 하위 집합을 반환하는지 검증하세요.
  • 읽기 경로부터 전환하세요: 리포트, 필터, 계층 권한 검사는 먼저 클로저에서 읽게 하세요.
  • 모든 쓰기에 대해 클로저를 업데이트하도록 하세요. 안정되면 재귀 기반 쿼리를 폐기하세요.

검증 시 권한 규칙을 가장 자주 깨는 케이스(매니저 변경, 최상위 리더, 매니저가 없는 사용자)에 집중하세요.

AppMaster로 구축한다면 기존 엔드포인트를 계속 운영하면서 클로저에서 읽는 새 엔드포인트를 추가한 뒤 결과가 일치하면 전환하는 식으로 진행할 수 있습니다.

조직 필터링이나 권한을 망치는 흔한 실수들

팀 운영 환경에 배포
내부 도구를 AppMaster Cloud나 선호하는 클라우드 제공자에 배포하세요.
앱 배포

조직 기능을 망치는 가장 빠른 방법은 계층을 일관성 없이 만드는 것입니다. 행별로 데이터가 멀쩡해 보여도 작은 실수는 잘못된 필터, 느린 페이지, 권한 누출을 만들 수 있습니다.

고전적인 문제는 사이클이 생기는 경우입니다: A가 B를 관리하는데 나중에 누군가 B의 매니저를 A로 설정하는 경우(또는 3~4명을 통해 긴 루프가 생기는 경우). 재귀 쿼리는 무한히 실행되거나 중복 행을 반환하거나 타임아웃될 수 있습니다. 클로저 테이블에서도 사이클은 선조/후손 행을 오염시킬 수 있습니다.

또 다른 흔한 문제는 클로저 드리프트입니다: 누군가의 매니저를 바꿨는데 직접적인 관계만 업데이트하고 서브트리에 대한 클로저 행을 재계산하지 않은 경우입니다. 그러면 "이 VP 아래의 모든 사람" 같은 필터가 옛 구조와 새 구조가 섞인 결과를 반환할 수 있습니다. 개인 프로필은 여전히 올바르게 보이기 때문에 발견하기 어렵습니다.

부서와 보고 라인을 명확한 규칙 없이 섞어두면 조직도가 복잡해집니다. 부서는 보통 행정적 그룹인 반면 보고 라인은 매니저 관계입니다. 둘을 같은 트리로 취급하면 "부서 이동"이 접근 권한을 예기치 않게 바꾸는 일이 발생할 수 있습니다.

권한은 직속 매니저만 보는지로만 판별할 때 가장 자주 실패합니다. viewer is manager of employee만 검사하면 전체 체인을 보지 못해 부당 차단되거나(상위 매니저가 자신의 조직을 보지 못함) 과도한 공유가 발생할 수 있습니다(임시 직속 매니저 설정으로 접근이 생김).

느린 목록 페이지는 보통 매 요청마다 재귀 필터링을 실행하기 때문에 발생합니다(받은편지함, 티켓 목록, 직원 검색 등). 같은 필터를 여러 곳에서 쓰면 미리 계산된 경로(클로저)나 허용된 직원 ID 집합 캐시를 마련하는 편이 낫습니다.

몇 가지 실무적 안전장치:

  • 매니저 변경을 저장하기 전에 사이클을 막는 검증을 하세요.
  • "부서"가 무엇인지 정의하고 보고 라인과 분리하세요.
  • 클로저 테이블을 쓴다면 매니저 변경 시 서브트리의 후손 행을 재구축하세요.
  • 권한 규칙은 직속 매니저만 보지 말고 전체 체인을 기준으로 작성하세요.
  • 목록 페이지에서 사용되는 조직 범위는 매번 재귀하는 대신 사전 계산하세요.

AppMaster에서 어드민 패널을 만든다면 change manager 작업을 민감한 워크플로로 다루세요: 검증을 하고 관련 계층 데이터를 업데이트한 다음에야 필터와 접근에 반영되도록 하세요.

출시 전 빠른 점검 목록

두 계층 모델 프로토타입
먼저 인접 리스트를 시도하고, 읽기 성능이 필요할 때 클로저 테이블을 추가하세요.
지금 프로토타입

조직도를 "완료"라고 부르기 전에 누가 무엇을 볼 수 있는지 평이한 언어로 설명할 수 있어야 합니다. 누군가 "X를 누가 볼 수 있으며 그 이유는 무엇인가요?"라고 물으면, 하나의 규칙과 하나의 쿼리(또는 뷰)를 가리켜 증명할 수 있어야 합니다.

성능도 현실적인 점검 항목입니다. 인접 리스트에서는 "이 매니저 아래의 모든 사람"이 재귀 쿼리가 되어 깊이와 인덱싱에 따라 속도가 달라집니다. 클로저 테이블에서는 읽기가 보통 빠르지만 쓰기 경로가 모든 변경 후에 테이블을 정확히 유지한다고 믿어야 합니다.

간단한 출시 전 체크리스트:

  • 한 명의 직원을 골라 가시성을 끝까지 추적하세요: 어떤 계보가 접근을 허용하고 어떤 역할이 접근을 거부하는가?
  • 예상 규모(예: 5단계, 50,000명)를 사용해 매니저 서브트리 쿼리 벤치마크를 하세요.
  • 잘못된 쓰기를 막으세요: 사이클, 자기 관리, 고아 노드를 제약과 트랜잭션으로 차단하세요.
  • 재조직 안전성 테스트: 이동, 병합, 매니저 변경, 중간 실패 시 롤백을 검증하세요.
  • 현실적인 역할(HR, 매니저, 팀 리드, 지원)에 대해 허용/거부 접근을 단정하는 권한 테스트를 추가하세요.

검증할 실용적 시나리오: 지원 담당자는 지정된 부서의 직원만 볼 수 있고, 매니저는 자신의 전체 서브트리를 볼 수 있다. PostgreSQL에서 조직도를 모델링하고 두 규칙을 테스트로 증명할 수 있으면 출시에 가까워집니다.

AppMaster로 내부 도구를 만든다면, 이 체크들을 단순히 DB 쿼리가 아니라 조직 목록과 직원 프로필을 반환하는 엔드포인트 주위의 자동화된 테스트로 유지하세요.

예시 시나리오와 다음 단계

예를 들어 Sales, Support, Engineering 세 부서가 있고, 각 부서에 두 팀이 있으며 각 팀마다 리드가 있다고 합시다. Sales 리드 A는 팀에 대한 할인 승인을 할 수 있고, Support 리드 B는 부서의 모든 티켓을 볼 수 있으며, Engineering VP는 Engineering 아래 모든 것을 볼 수 있습니다.

그런 다음 재조직이 일어납니다: 한 Support 팀이 Sales 아래로 옮겨지고, Sales 디렉터와 두 팀 리드 사이에 새 매니저가 추가됩니다. 다음 날 누군가가 접근을 요청합니다: "Jamie(영업 분석가)가 Sales 부서의 모든 고객 계정을 보게 해달라, Engineering은 아님." 라고요.

인접 리스트로 모델링하면 스키마는 단순하지만 앱에서 쿼리와 권한 검증 작업이 더 많이 필요합니다. "Sales 아래의 모든 사람" 같은 필터는 보통 재귀가 필요합니다. 승인 규칙(예: 요청자 위의 가장 가까운 매니저만 승인 가능)이 추가되면 재조직 후에 엣지 케이스가 중요해집니다.

클로저 테이블을 쓰면 재조직 시 더 많은 쓰기 작업(선조/후손 행 업데이트)이 필요하지만 읽기는 간단해집니다. 필터링과 권한은 종종 단순한 조인으로 해결됩니다: "이 사용자가 그 직원의 선조인가?" 또는 "이 팀이 이 부서 서브트리 안에 있는가?" 같은 방식입니다.

이 차이는 화면 설계에 바로 나타납니다: 부서 범위의 사람 선택기, 요청자 위의 가장 가까운 매니저로 승인 라우팅, 부서 대시보드용 관리자 뷰, 특정 날짜에 왜 접근이 있었는지 설명하는 감사 로그 등이 그렇습니다.

다음 단계 제안:

  1. 권한 규칙을 평이한 언어로 작성하세요(누가 무엇을 보고 왜 볼 수 있는지).
  2. 가장 흔한 검사에 맞는 모델을 선택하세요(빠른 읽기 vs 단순한 쓰기).
  3. 재조직, 접근 요청, 승인 흐름을 끝까지 테스트할 수 있는 내부 관리자 도구를 만드세요.

내부 조직 인식형 관리자 패널과 포털을 빠르게 만들고 싶다면 AppMaster (appmaster.io)이 실용적인 선택일 수 있습니다: PostgreSQL 기반 데이터를 모델링하고, 시각적 비즈니스 프로세스로 승인 로직을 구현하며, 같은 백엔드로 웹과 네이티브 앱을 제공할 수 있습니다.

자주 묻는 질문

언제 조직도에 인접 리스트를 쓰고 언제 클로저 테이블을 써야 하나요?

인접 리스트는 조직이 작고 업데이트가 잦으며 대부분의 화면이 직속 보고나 몇 단계만 필요할 때 적합합니다. 반면 클로저 테이블은 “이 리더 아래의 모든 사람” 같은 조회가 빈번하고, 부서 범위 필터나 계층 기반 권한을 여러 화면에서 일관되게 적용해야 할 때 적합합니다. 읽기는 간단한 조인으로 빠르게 해결되지만 쓰기(재배치)는 더 많은 작업이 필요합니다.

PostgreSQL에 "누가 누구에게 보고하는가"를 가장 간단하게 저장하는 방법은 무엇인가요?

employees(manager_id)처럼 시작하고, 직속 보고자는 WHERE manager_id = ?로 가져오는 것이 가장 간단한 방법입니다. 전체 계층(상위/하위 모두)이 필요한 기능이 생길 때만 재귀 쿼리를 추가하세요(승인, “내 조직” 필터, 건너뛰기 레벨 대시보드 등).

사이클(예: A가 B를 관리하고 B가 A를 관리하는 경우)을 어떻게 방지하나요?

최소한 manager_id <> id 같은 검사를 통해 자기 자신을 관리자로 설정하는 것을 막으세요. 업데이트 시에는 새 매니저가 이미 해당 직원의 하위트리에 속해 있는지(즉 계보상 이미 하위인지) 확인하는 검증을 추가해야 합니다. 실제로는 매니저 변경을 저장하기 전에 ancestry(선조 관계)를 확인하는 것이 가장 안전합니다.

부서를 사람과 같은 계층의 노드로 다뤄야 하나요?

권장되는 기본은 부서를 행정적 그룹으로 보고, 보고 라인은 별도의 매니저 트리로 유지하는 것입니다. 이렇게 하면 ‘부서 이동’이 누군가의 보고 라인을 의도치 않게 바꾸지 않고, 보고 라인이 부서 경계와 일치하지 않을 때도 "Sales의 모든 사람" 같은 필터가 명확해집니다.

누군가가 두 명의 매니저를 갖는 매트릭스 조직은 어떻게 모델링하나요?

일반적으로 직원 레코드에 기본 보고 매니저(primary manager)를 저장하고, 점선 보고(dotted-line) 관계는 별도의 테이블(예: secondary manager 매핑이나 팀 리드 매핑)로 표현합니다. 이렇게 하면 기본 계층 쿼리를 깨뜨리지 않으면서 프로젝트 접근이나 승인 위임 같은 특별 규칙을 구현할 수 있습니다.

누군가 매니저를 바꿀 때 클로저 테이블에서 무엇을 업데이트해야 하나요?

이전 조상 경로(이동한 직원의 하위 트리에 해당하는 ancestor 경로)를 삭제한 뒤, 새로운 매니저의 조상들과 해당 직원의 서브트리 노드들을 결합해 새 경로를 삽입하고 depth를 재계산합니다. 이 작업은 트랜잭션으로 묶어 중간에 실패했을 때 절반만 업데이트되는 일을 막아야 합니다.

조직도 쿼리에 어떤 인덱스가 가장 중요한가요?

인접 리스트의 경우 거의 모든 조직 쿼리가 manager_id에서 시작하므로 해당 칼럼에 인덱스를 걸어야 합니다. 클로저 테이블에서는 (ancestor_id, descendant_id)의 기본 키 인덱스와 descendant_id 단독 인덱스가 중요합니다. 후자는 “이 행을 누가 볼 수 있나?” 같은 존재성 검사에 도움됩니다.

"매니저는 그 아래의 모든 사람을 볼 수 있다" 규칙을 안전하게 구현하려면?

클로저 테이블을 쓸 때 흔한 패턴은 EXISTS를 이용한 검사입니다: 뷰어가 대상 직원의 선조인지 확인하면 접근을 허용합니다. 이 방식은 행 수준 보안(RLS)과 결합하면 데이터베이스가 일관되게 규칙을 적용하므로 API마다 재귀 로직을 중복할 필요가 없습니다.

-- 클로저 테이블 권한 검사(개념)
SELECT 1
FROM org_closure c
WHERE c.ancestor_id = :viewer_id
  AND c.descendant_id = :employee_id
LIMIT 1;
재조직 히스토리와 감사 추적은 어떻게 처리해야 하나요?

감사와 재조직 이력을 위해서는 현재 값을 덮어쓰지 말고 별도 테이블에 변경 이력을 날짜와 함께 기록하세요. 이렇게 하면 특정 날짜에 누가 누구에게 보고했는지를 정확히 조회할 수 있어 리포트와 감사가 일관됩니다.

인접 리스트에서 클로저 테이블로 앱을 깨뜨리지 않고 이전하려면?

기존 manager_id를 진실의 출처(source of truth)로 유지하고, 그 옆에 클로저 테이블을 만들어 점진적으로 전환하세요. 절차는 보통 다음과 같습니다: 클로저 테이블 생성 및 인덱스 추가 → 현재 트리로 백필(backfill) → 샘플 매니저로 결과 검증 → 읽기 경로를 클로저로 전환 → 쓰기 시 두 쪽을 모두 업데이트 → 충분히 검증되면 재귀 기반 쿼리 폐기.

쉬운 시작
멋진만들기

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

시작하다