# OrgChart Data Contract 상태: **JSON Schema / TypeScript 초안, 승인 전**. ID 기반 분리·동일 계약 사용·Validation·원본 집계는 확정 요구다. 아래 필드명·enum·null·버전 값은 제안이다. 모든 환경이 이 계약과 같은 검증·집계를 공유한다. [Architecture](architecture.md), [API 전환](integration.md)을 함께 읽는다. ## 1. 정규화 모델과 TypeScript 초안 ID는 엔티티별 배열 안에서 유일한 불투명 문자열이다. 이름·배열 위치로 연결하지 않는다. 조직은 `parentId`, 직원은 `organizationId`·`affiliationId`, 조직장은 `leaderId`, 추가 관계는 두 조직 ID를 참조한다. 서로 다른 엔티티의 같은 ID 문자열은 허용할지 TBD이며, DOM에는 엔티티·인스턴스 prefix를 별도로 붙인다. ```ts type ID = string; type OrganizationType = "root" | "division" | "office" | "div" | "cell" | "team"; type WorkStatus = "active" | "dispatched" | "onLeave"; type RelationType = "cooperation" | "support"; interface Organization { id: ID; parentId: ID | null; name: string; type: OrganizationType; leaderId: ID | null; displayOrder: number; } interface Employee { id: ID; organizationId: ID; affiliationId: ID; name: string; position: string; // 직위. jobTitle(직책)과 구별 jobTitle: string | null; profileImage: string | null; workStatus: WorkStatus; } interface Affiliation { id: ID; name: string } interface OrganizationRelation { id: ID; sourceOrganizationId: ID; targetOrganizationId: ID; type: RelationType; } interface PresentationConfig { title: string; description: string | null; hubText: string | null; groups: { showEmployees: boolean; showSummary: boolean; showDispatched: boolean; showOnLeave: boolean; showAffiliations: boolean; }; organizationOrder: ID[]; } interface Metadata { schemaVersion: "0.1-draft"; asOfDate: string; // YYYY-MM-DD, 유효한 실제 날짜 dataVersion: string; updatedAt: string | null; // timezone을 포함한 RFC 3339 갱신 시각 updateNote: string | null; } interface OrgChartData { organizations: Organization[]; employees: Employee[]; affiliations: Affiliation[]; relations: OrganizationRelation[]; presentation: PresentationConfig; metadata: Metadata; } ``` 제안된 `root/division/office/div/cell/team`은 최상위/부문/실/Div/셀/팀 표현이다. 실제 조직 유형·업무 상태·관계 유형 vocabulary, 직위 필수 여부, 무소속·관계사 미지정 허용은 **TBD**다. 새로운 enum은 schema/API 버전과 함께 확장하고 알 수 없는 값을 임의 재직 상태로 치환하지 않는다. `schemaVersion`은 계약 호환성, `dataVersion`은 스냅샷 식별이다. 서로 대신 쓰지 않는다. 부모 null은 root 후보, leader null은 공석 후보, 이미지 null은 가상 fallback을 뜻하는 초안이다. API에서 필드 생략이 가능한지와 null 허용은 승인 후 확정한다. 표현 설정은 표시만 바꾸고 원본 소속·집계 분모를 변경하지 않는다. 순서는 제안상 `organizationOrder`에 있는 ID 우선 → 나머지 형제 `displayOrder` → 동률 ID 순서로 결정한다. 전역 목록이 계층 parent를 바꾸지는 않는다. 실제 정렬 우선순위는 TBD다. PresentationConfig에 CSS·HTML·DOM 좌표·API URL을 받지 않는다. ## 2. JSON Schema 초안 JSON Schema Draft 2020-12 제안이다. `format`을 실제 검사하는 validator 설정이 필요하다. 아래 구조는 JSON 구문 검증 대상이며 runtime validator 구현 또는 Schema 승인 결과가 아니다. 중복 ID·참조·순환·업무 규칙은 §4의 별도 검증이 필요하다. 모든 필드를 required로 하고 nullable을 명시하는 엄격한 초안이며 API 매핑에서 같은 형태로 변환한다. ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "HMDesign OrgChart draft", "type": "object", "additionalProperties": false, "required": ["organizations", "employees", "affiliations", "relations", "presentation", "metadata"], "properties": { "organizations": {"type": "array", "items": {"$ref": "#/$defs/Organization"}}, "employees": {"type": "array", "items": {"$ref": "#/$defs/Employee"}}, "affiliations": {"type": "array", "items": {"$ref": "#/$defs/Affiliation"}}, "relations": {"type": "array", "items": {"$ref": "#/$defs/OrganizationRelation"}}, "presentation": {"$ref": "#/$defs/PresentationConfig"}, "metadata": {"$ref": "#/$defs/Metadata"} }, "$defs": { "ID": {"type": "string", "minLength": 1}, "Text": {"type": "string", "minLength": 1}, "NullableText": {"anyOf": [{"$ref": "#/$defs/Text"}, {"type": "null"}]}, "NullableID": {"anyOf": [{"$ref": "#/$defs/ID"}, {"type": "null"}]}, "Organization": { "type": "object", "additionalProperties": false, "required": ["id", "parentId", "name", "type", "leaderId", "displayOrder"], "properties": { "id": {"$ref": "#/$defs/ID"}, "parentId": {"$ref": "#/$defs/NullableID"}, "name": {"$ref": "#/$defs/Text"}, "type": {"enum": ["root", "division", "office", "div", "cell", "team"]}, "leaderId": {"$ref": "#/$defs/NullableID"}, "displayOrder": {"type": "integer", "minimum": 0} } }, "Employee": { "type": "object", "additionalProperties": false, "required": ["id", "organizationId", "affiliationId", "name", "position", "jobTitle", "profileImage", "workStatus"], "properties": { "id": {"$ref": "#/$defs/ID"}, "organizationId": {"$ref": "#/$defs/ID"}, "affiliationId": {"$ref": "#/$defs/ID"}, "name": {"$ref": "#/$defs/Text"}, "position": {"$ref": "#/$defs/Text"}, "jobTitle": {"$ref": "#/$defs/NullableText"}, "profileImage": {"anyOf": [{"type": "string", "format": "uri-reference", "minLength": 1}, {"type": "null"}]}, "workStatus": {"enum": ["active", "dispatched", "onLeave"]} } }, "Affiliation": { "type": "object", "additionalProperties": false, "required": ["id", "name"], "properties": {"id": {"$ref": "#/$defs/ID"}, "name": {"$ref": "#/$defs/Text"}} }, "OrganizationRelation": { "type": "object", "additionalProperties": false, "required": ["id", "sourceOrganizationId", "targetOrganizationId", "type"], "properties": { "id": {"$ref": "#/$defs/ID"}, "sourceOrganizationId": {"$ref": "#/$defs/ID"}, "targetOrganizationId": {"$ref": "#/$defs/ID"}, "type": {"enum": ["cooperation", "support"]} } }, "PresentationConfig": { "type": "object", "additionalProperties": false, "required": ["title", "description", "hubText", "groups", "organizationOrder"], "properties": { "title": {"$ref": "#/$defs/Text"}, "description": {"$ref": "#/$defs/NullableText"}, "hubText": {"$ref": "#/$defs/NullableText"}, "organizationOrder": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/ID"}}, "groups": { "type": "object", "additionalProperties": false, "required": ["showEmployees", "showSummary", "showDispatched", "showOnLeave", "showAffiliations"], "properties": { "showEmployees": {"type": "boolean"}, "showSummary": {"type": "boolean"}, "showDispatched": {"type": "boolean"}, "showOnLeave": {"type": "boolean"}, "showAffiliations": {"type": "boolean"} } } } }, "Metadata": { "type": "object", "additionalProperties": false, "required": ["schemaVersion", "asOfDate", "dataVersion", "updatedAt", "updateNote"], "properties": { "schemaVersion": {"const": "0.1-draft"}, "asOfDate": {"type": "string", "format": "date"}, "dataVersion": {"$ref": "#/$defs/Text"}, "updatedAt": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]}, "updateNote": {"$ref": "#/$defs/NullableText"} } } } } ``` 빈 문자열·공백만 있는 필수 값은 runtime Validation에서 거부하도록 제안한다. `minLength`만으로 공백 검사가 끝나지 않는다. 입력 길이·배열 크기·깊이 상한은 실제 데이터 규모 검토 후 TBD로 확정한다. ## 3. 가상 JSON 스냅샷 예시 실제 인사 정보와 사진이 없는 예시다. Schema 값은 초안이며 아래 데이터가 운영 계약으로 승인된 것은 아니다. ```json { "organizations": [ {"id": "org-root", "parentId": null, "name": "가상 사업본부", "type": "root", "leaderId": "emp-a", "displayOrder": 0}, {"id": "team-a", "parentId": "org-root", "name": "가상 기획팀", "type": "team", "leaderId": "emp-b", "displayOrder": 1}, {"id": "team-b", "parentId": "org-root", "name": "가상 운영팀", "type": "team", "leaderId": null, "displayOrder": 2} ], "employees": [ {"id": "emp-a", "organizationId": "org-root", "affiliationId": "aff-a", "name": "가상직원 가", "position": "대표", "jobTitle": "본부장", "profileImage": null, "workStatus": "active"}, {"id": "emp-b", "organizationId": "team-a", "affiliationId": "aff-a", "name": "가상직원 나", "position": "책임", "jobTitle": "팀장", "profileImage": null, "workStatus": "active"}, {"id": "emp-c", "organizationId": "team-a", "affiliationId": "aff-b", "name": "가상직원 다", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "dispatched"}, {"id": "emp-d", "organizationId": "team-b", "affiliationId": "aff-b", "name": "가상직원 라", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "onLeave"} ], "affiliations": [{"id": "aff-a", "name": "가상 관계사 A"}, {"id": "aff-b", "name": "가상 관계사 B"}], "relations": [{"id": "rel-a", "sourceOrganizationId": "team-a", "targetOrganizationId": "team-b", "type": "cooperation"}], "presentation": { "title": "가상 조직도", "description": "설계 검토용 가상 데이터", "hubText": "협업 중심", "groups": {"showEmployees": true, "showSummary": true, "showDispatched": true, "showOnLeave": true, "showAffiliations": true}, "organizationOrder": ["org-root", "team-a", "team-b"] }, "metadata": {"schemaVersion": "0.1-draft", "asOfDate": "2026-10-12", "dataVersion": "mock-1", "updatedAt": null, "updateNote": "초안 예시"} } ``` 직원 고유 ID 수 4, 상태별 raw 수 2/1/1은 fixture 산술 기준이다. 업무상 재직/전체 인원과 파견·휴직 포함 여부는 §5의 정책 승인이 필요하다. 조직장 카드와 상태 그룹에 같은 직원이 보이더라도 raw ID 수가 늘어나지 않아야 한다. ## 4. Validation과 오류 상태 제안 순서: 구조·버전 → 각 collection ID index → parent/직원/관계사/leader 참조 → cycle/root → 업무 규칙·입력 안전성. invalid 데이터를 engine로 전달하지 않는다. - `SCHEMA_VERSION`: 승인된 버전과 불일치. 자동 추정·silent migration 금지. - `DUPLICATE_ID`: 같은 collection 내 ID 중복. 임의 last-write-wins 금지. - `REQUIRED_VALUE`: 누락/null 위반/공백뿐인 필수 값/잘못된 타입. - `ORGANIZATION_REFERENCE`: 직원 소속이 존재하지 않음. 삭제 시 parent/직원 이동을 일관된 스냅샷으로 제공해야 한다. - `PARENT_REFERENCE` / `HIERARCHY_CYCLE`: 없는 parent, 자기 parent, 다단계 순환. 정렬로 cycle을 은폐하지 않는다. - `LEADER_REFERENCE`: leader 직원이 없음. leader가 동일 조직/하위 조직/타 조직 소속이어도 되는지는 TBD이며 정책 없는 불일치는 승인 요구 issue로 보고한다. - `AFFILIATION_REFERENCE`: 직원 관계사 없음. 미지정 소속 허용 정책은 TBD. - `INVALID_STATUS` / `INVALID_TYPE`: 허용 vocabulary 외 값. 제안 enum이 실제 승인 목록을 대신하지 않는다. - `RELATION_REFERENCE` / `INVALID_RELATION`: 없는 양 끝 조직, 자기 관계, 중복 양 끝+유형, 허용되지 않은 유형을 검사한다. 관계 방향·대칭·역방향 중복 허용은 TBD. 일반 관계의 cycle은 조직 parent cycle과 구별한다. - `PRESENTATION_REFERENCE`: organizationOrder의 없는 ID·중복·정렬 정책 위반. - `UNSAFE_INPUT`: 실행 가능한 HTML/스크립트 payload·위험한 이미지 URL·제어문자/식별 교란 입력을 검사한다. 표시 문자열은 plain text, external `className/style/nodeTemplate`는 받지 않는다. image scheme/origin·상대 경로 정책·길이 상한은 승인 후 확정한다. JSON Schema `uri-reference`만으로 URL 안전성을 보장하지 않는다. 입력 검사와 출력 안전성은 함께 적용한다. 정상 문자열을 무조건 삭제하지 않고 거부/경고 기준을 문서화한다. DOM 텍스트는 `textContent`로 출력하고 engine 템플릿에는 외부 문자열을 직접 삽입하지 않는다. engine ID는 내부 안전 ID로 변환한다. HTML escaping만으로 이미지 URL 검사를 대체하지 않는다. 구조·참조·cycle·보안 실패는 Error 상태로 전환하고 engine mount/update를 중단한다. 사용자 메시지는 해당 데이터가 표시 불가능함을 설명하되 원본 개인정보를 노출하지 않는다. partial 데이터 자동 제거는 별도 승인 전 하지 않는다. 직원 0명과 조직 0개의 Empty 정의, 갱신 실패 시 직전 유효 화면 유지 여부는 TBD다. 미확정 집계 정책은 차트 유효성과 분리하여 집계를 `unavailable`로 표시하는 방식을 제안한다. fatal Validation issue는 차트 Error로 처리한다. 집계 정책 미확정은 fatal data issue가 아니며 유효한 차트의 ready와 별도 AggregationResult `unavailable`로 공존하는 안을 제안한다. Source의 HTTP/JSON/매핑 실패는 dataSource 위치의 별도 code로 구별한다. warning·업무 승인 필요 issue의 치명도와 공개 반환 형식은 승인 전 TBD다. 어떤 경로도 원본 서버 오류·개인정보를 callback에 그대로 전달하지 않는다. Validation·집계·render는 같은 불변 스냅샷을 사용하며 engine는 별도 복사본만 받는다. Source가 여러 API/페이지를 조합할 때의 시간 일관성은 ID 참조 검사와 별도로 보장해야 한다. Source 캡처·revision 반영 계약은 Architecture/Framework Integration을 따른다. ## 5. 인원 집계와 미확정 정책 원본 `employees`에서 계산하며 DOM 카드·engine 노드 수를 세지 않는다. Validation 통과 직원 ID 집합을 사용하고 parent 트리의 하위 조직을 통해 집계한다. 추가 관계·허브·접힌/숨겨진 카드·표시 설정은 조직 소속을 변경하지 않는다. - 조직 직접 인원: 해당 organizationId의 직원 집합. - 하위 포함 인원: 자기 조직 + 승인된 parent 트리 하위 조직의 직원 ID 합집합. - 직위별 인원: 정해진 대상 집합을 `position`으로 그룹화. 직책과 혼합하지 않는다. - 재직/파견/휴직: 승인된 상태 매핑과 포함 정책을 적용한다. raw 상태 카운트와 재직 정의는 구별한다. - 관계사별 인원: `affiliationId`로 그룹화하고 동일한 승인 정책을 적용한다. - 전체 인원: 승인된 전체 대상의 직원 ID 집합. 하위 포함 조직별 숫자를 더하면 중복되므로 그 합을 총원으로 쓰지 않는다. 집계 정책은 PresentationConfig와 별도로 제공하는 제안이다. 아래 type의 값이나 default는 아직 승인되지 않았다. ```ts interface ApprovedAggregationPolicy { policyVersion: string; includedStatuses: WorkStatus[]; activeStatuses: WorkStatus[]; includeLeaders: boolean; membershipMode: "primaryOnly" | "uniqueEmployee"; } type AggregationResult = | { status: "unavailable"; reason: "policyRequired" } | { status: "ready"; policyVersion: string; total: number }; ``` TBD: 파견·휴직을 재직/전체/팀/관계사에 포함하는지, 조직장 포함·겸직 조직장 배분, 조직 간 중복 소속, 파견 원소속/파견지 기준, 퇴사·예정 상태 필요 여부, 관계사 합과 총원 관계. 초안의 단일 `organizationId`는 복수 소속을 표현하지 못한다. 복수 소속이 필요하면 별도 Membership 계약과 schema revision을 승인하고 집계 mode를 확정한다. `uniqueEmployee` 후보만으로 현재 복수 소속이 지원된다고 주장하지 않는다. 정책 없이 임의 총원을 표시하지 않는다. 향후 테스트에는 leader 중복 표시, 상태 변경, 조직 이동, 하위 포함 중복 제거, 관계사 이동, 표시 숨김, 동일 JSON의 Vanilla/React 집계 일치를 포함한다. 같은 조건에 같은 결과가 나와야 한다. 집계 정책 자체도 policyVersion·지원 membershipMode·허용 status·중복 status·업무상 상호 관계를 검증한다. `activeStatuses`와 `includedStatuses`의 관계, leader 제외가 어느 조직의 어떤 직원에 적용되는지는 승인 전 TBD다. 필드 값만 받아 임의 해석하지 않는다. 환경별 정렬 일치를 위해 ID 비교 기준·locale 의존 정렬 여부도 확정한다.