Files
MH-DashBoard-organization/docs/HISTORY_ASOF_DB_PLAN.md
2026-03-30 08:54:52 +09:00

295 lines
9.1 KiB
Markdown

# History / As-Of DB Plan
## Goal
월간 스냅샷 파일을 따로 만드는 대신, DB 자체를 시간축이 있는 구조로 전환한다.
목표는 다음과 같다.
- 조직도와 자리배치도를 수정할 때마다 과거 값이 사라지지 않게 누적 저장
- 사용자가 특정 날짜 또는 기간을 선택하면 그 시점 기준 상태를 다시 조회
- 날짜가 원래 없는 데이터도 `유효 시작일``유효 종료일`을 부여해 과거 버전 조회 가능하게 만들기
핵심 원칙은 아래 한 줄이다.
- 최신 값을 덮어쓰지 않고, `valid_from`, `valid_to` 기반 버전 행을 누적한다
## Why This Instead Of Snapshots
- 월간 스냅샷 파일은 생성 시점만 남고 중간 변경 추적이 약하다
- 원하는 날짜 기준으로 바로 조회하기 어렵다
- 조직도만 따로 파일로 남으면 자리배치도, 권한, 운영 이력을 함께 맞추기 어렵다
따라서 이 프로젝트에는 "파일 스냅샷"보다 "시점 조회 가능한 버전 DB"가 더 맞다.
## Query Model
조회 기준은 `as_of` 또는 `date_from`, `date_to` 이다.
- 특정 날짜 조회:
- `GET /api/members?as_of=2026-03-01`
- `GET /api/seat-maps/active/layout?as_of=2026-03-01`
- 기간 비교:
- `GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31`
공통 조회 조건은 아래다.
```sql
WHERE valid_from <= :as_of
AND (valid_to IS NULL OR valid_to > :as_of)
```
## Recommended Data Model
### 1. Stable Base Tables
식별자와 최소 메타만 유지하는 기준 테이블.
```sql
CREATE TABLE members (
id SERIAL PRIMARY KEY,
employee_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
```sql
CREATE TABLE seat_assignment_targets (
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
설명:
- `members` 는 "사람 자체" 식별자 역할
- 실제 이름, 조직, 직급, 연락처, 좌석 같은 표시 데이터는 버전 테이블로 이동
### 2. Member Version Table
```sql
CREATE TABLE member_versions (
id BIGSERIAL PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
name TEXT NOT NULL,
company TEXT NOT NULL DEFAULT '',
rank TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '',
grp TEXT NOT NULL DEFAULT '',
division TEXT NOT NULL DEFAULT '',
team TEXT NOT NULL DEFAULT '',
cell TEXT NOT NULL DEFAULT '',
work_status TEXT NOT NULL DEFAULT '',
work_time TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
photo_url TEXT NOT NULL DEFAULT '',
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ,
revision_no BIGINT NOT NULL,
changed_by_user_id BIGINT,
change_reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX member_versions_member_time_idx
ON member_versions (member_id, valid_from, valid_to);
```
설명:
- 날짜가 원래 없던 조직도 데이터도 이 테이블에서 과거 버전 관리
- 어떤 시점에 이름, 조직, 직책, 연락처가 어땠는지 재구성 가능
### 3. Seat Assignment Version Table
```sql
CREATE TABLE seat_assignment_versions (
id BIGSERIAL PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
seat_slot_id INTEGER REFERENCES seat_slots(id) ON DELETE CASCADE,
seat_label TEXT NOT NULL DEFAULT '',
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ,
revision_no BIGINT NOT NULL,
changed_by_user_id BIGINT,
change_reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX seat_assignment_versions_member_time_idx
ON seat_assignment_versions (member_id, valid_from, valid_to);
```
설명:
- 현재 `seat_positions` 가 맡는 "최신 좌석 상태"를 버전형으로 저장
- 특정 날짜의 자리배치도를 다시 그릴 수 있음
### 4. Optional Change Event Table
```sql
CREATE TABLE entity_change_events (
id BIGSERIAL PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id BIGINT NOT NULL,
action_type TEXT NOT NULL,
revision_no BIGINT NOT NULL,
changed_by_user_id BIGINT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
change_reason TEXT NOT NULL DEFAULT '',
patch_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
```
설명:
- 버전 테이블은 "그 시점의 전체 값"
- 이벤트 테이블은 "무엇이 바뀌었는지"
- 초기에는 없어도 되지만, 추후 비교 UI와 감사로그에 유용
### 5. Revision Table
```sql
CREATE TABLE history_revisions (
id BIGSERIAL PRIMARY KEY,
scope TEXT NOT NULL DEFAULT 'organization',
revision_label TEXT NOT NULL,
created_by_user_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
note TEXT NOT NULL DEFAULT ''
);
```
설명:
- 버전 묶음을 사람 친화적으로 관리할 때 사용
- 예: `2026-03-27 1차 조직개편 반영`
## How Writes Change
현재 구조:
- `UPDATE members SET ...`
- `UPSERT seat_positions ...`
바꿀 구조:
1. 현재 유효한 버전 행을 조회
2. 값이 달라지면 기존 행의 `valid_to` 를 닫음
3. 새 값을 가진 행을 `valid_from = now()` 로 insert
4. 필요하면 최신 캐시 테이블도 함께 갱신
예시:
```sql
UPDATE member_versions
SET valid_to = NOW()
WHERE member_id = :member_id
AND valid_to IS NULL;
INSERT INTO member_versions (
member_id, name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, photo_url,
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
)
VALUES (
:member_id, :name, :company, :rank, :role, :department, :grp, :division, :team, :cell,
:work_status, :work_time, :phone, :email, :photo_url,
NOW(), NULL, :revision_no, :changed_by_user_id, :change_reason
);
```
## How Date-Bearing And Date-Less Data Coexist
### 날짜가 원래 있는 데이터
- `integration_work_logs.work_date`
- `integration_vouchers.issue_date`
이 데이터는 원래 날짜 컬럼이 있으므로 그대로 사용하면 된다.
### 날짜가 원래 없는 데이터
- 조직도 인원 기본 정보
- 조직 소속
- 자리배치 상태
- 사진 경로
이 데이터는 `valid_from`, `valid_to` 를 붙여 시점 조회가 가능하게 만든다.
즉, "날짜가 없는 데이터"가 아니라 "유효기간을 부여한 버전 데이터"로 바꾸는 것이다.
## API Direction
### Common UI Input
사용자가 실제 HTML에서 고르는 기준은 헤더의 날짜 제어를 공통 입력으로 쓰는 것이 맞다.
권장안:
- 프로젝트/팀 분석: 기존처럼 `시작일 ~ 종료일`
- 조직도/자리배치도: 우선 `기준일(as_of)` 1개를 사용
- 필요하면 조직도 비교 화면에서 `비교 시작일`, `비교 종료일` 확장
현재 상태:
- 헤더 날짜 제어는 `프로젝트별 분석`, `팀/개인별 분석` iframe에 이미 전달되고 있음
- 조직도/자리배치도는 아직 헤더 날짜를 실제 조회 조건으로 사용하지 않음
권장 API:
```text
GET /api/members?as_of=2026-03-27
GET /api/members/{id}?as_of=2026-03-27
GET /api/seat-maps/active/layout?as_of=2026-03-27
GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31
```
## Migration Strategy
### Phase 1. History Tables Add
- `member_versions`
- `seat_assignment_versions`
- `history_revisions`
- 필요 시 `entity_change_events`
현재 `members`, `seat_positions` 는 그대로 유지
### Phase 2. Backfill
- 현재 `members` 최신값을 `member_versions(valid_from = NOW(), valid_to = NULL)` 로 적재
- 현재 `seat_positions` 최신값을 `seat_assignment_versions(valid_from = NOW(), valid_to = NULL)` 로 적재
- 이 단계에서는 과거 진짜 이력은 없고 "현재 상태를 버전 구조에 싣는 것"이 목표
### Phase 3. Dual Write
- 조직도 수정 시:
- 기존 `members` 갱신
- 동시에 `member_versions` 에 append
- 자리배치 저장 시:
- 기존 `seat_positions` 갱신
- 동시에 `seat_assignment_versions` 에 append
### Phase 4. As-Of Read APIs
- 조직도 API에 `as_of` 지원
- 자리배치도 API에 `as_of` 지원
- 헤더 날짜 제어와 연결
### Phase 5. Full History-First Read
- 최신 조회도 버전 테이블 기준으로 전환
- `members`, `seat_positions` 는 캐시 또는 편의 테이블로 축소 가능
## Recommended First Scope
처음부터 모든 테이블을 이력화하지 말고 아래부터 시작하는 것이 안전하다.
1. `members` -> `member_versions`
2. `seat_positions` -> `seat_assignment_versions`
3. 조직도/자리배치도 조회 API에 `as_of`
이 세 가지가 되면 사용자는 원하는 날짜의 조직 상태와 좌석 상태를 볼 수 있다.
## Explicitly Removed From Scope
- 월간 스냅샷 파일 생성
- 스냅샷 다운로드 기능
- 조직도만 따로 파일로 내보내는 방식
이 프로젝트의 방향은 "파일 스냅샷"이 아니라 "시점 조회 가능한 버전 DB"다.