user.tagHistory — 임베딩 vs 별도 테이블 분리 검증
마지막 업데이트 2026-07-22
PPI-1103 "음성 불가 사유 태그 변경 히스토리"를 별도 테이블로 분리하는 게 데이터구조 관점에서 타당한지 실측 데이터로 검증.
ProjectionExpression을 붙여 tagHistory를 빼는 것이다. 분리는 이력이 필요한 admin 화면에 2차 쿼리 부담만 더한다.
1. 배경 — 지금 어떻게 저장되나
PPI-1103은 사유 태그 변경 이력을 별도 테이블이 아니라 기존 user 아이템의 tagHistory 속성(임베딩 배열)으로 저장한다. 태그/사유가 바뀔 때만 updateUser()가 diff를 최신순으로 prepend(최대 200개 상한).
태그/사유 변경 시 diff 1건 prepend
각 이력 항목: changedAt, changedByName, changedById?, addedTags/removedTags, addedTagReasons/removedTagReasons. guest GET 응답에선 관리자 실명 보호를 위해 destructuring으로 제외.
2. 실측 데이터 (prod scan, 2026-07-19)
| 테이블 | tagHistory 보유 | 비고 |
|---|---|---|
| ppi-user-prod | 118명 / 127건 | 실사용 데이터 |
| ppi-user-staging | 0명 | 미사용 |
| ppi-user-dev | 2명 | 테스트 흔적 |
tagHistory 속성 자체 크기: max 962B / avg 338B. 상한(200)과 실사용(max 3)은 100배 격차 → 아이템 크기 압박 없음.
3. 검토 대상 — "user 조회 시 히스토리까지 오는 게 문제"
분리 근거로 제시된 의도는 오버페치 회피다. 코드상 getUser(GetCommand)·getUsers(Scan) 모두 ProjectionExpression이 전혀 없어 항상 full item(= tagHistory 포함)을 읽는다. guest 경로는 full fetch 후 메모리에서 버린다. 관찰 자체는 사실이다.
ProjectionExpression은 아이템을 스토리지에서 다 읽은 뒤 서버에서 속성만 걸러내는 것이라 RCU가 아니라 네트워크 페이로드만 줄인다. 이 사실이 "오버페치 = 비용"인지를 경로별로 가른다.
4. 두 읽기 경로 분석
GetItem 핫패스
guest/host 세션, session-card, ai-session, livekit/call …
아이템 max 1,847B < 4KB → tagHistory가 있든 없든 1 RCU 고정. 분리해도 user는 여전히 GetItem 1회 = 같은 1 RCU. 히스토리 테이블을 안 읽는다고 절감되는 RCU가 애초에 없다. 남는 건 순수 페이로드(≤962B)뿐 → ProjectionExpression으로 해결.
Scan (getUsers 목록)
관리자 user-table 전체 조회
Scan은 스캔한 총 바이트로 과금. tagHistory가 118명×avg 338B ≈ ~40KB를 더해 풀스캔당 약 10 RCU 추가 — 유일한 실질 오버페치. 그러나 이것도 Scan에 ProjectionExpression으로 tagHistory 제외 시 분리와 동일 절감 + 스키마 변경 0.
5. 판정 매트릭스
| 주장 | 판정 | 근거 |
|---|---|---|
| "user 조회 시 히스토리까지 온다" | 사실 | getUser/getUsers에 Projection 없음 → full item |
| 그게 핫패스 RCU 낭비다 | 아님 | 아이템 < 4KB → 1 RCU 고정, 히스토리 유무 무관 |
| 별도 테이블 분리가 해법이다 | 비효율 | 핫패스 RCU 절감 0 · Scan은 Projection으로 동일 달성 · admin에 2차 쿼리 부담 |
| 올바른 해법 | Projection | 핫패스/Scan 읽기에서 tagHistory 제외 → 오버페치 제거, 스키마 불변 |
6. 권장 해법 — ProjectionExpression
ProjectionExpression이다. 스키마 변경도, admin 화면의 2차 쿼리도 없이 오버페치를 제거한다.
주의: DynamoDB ProjectionExpression은 포함할 속성을 나열하는 방식이라 "tagHistory만 제외"가 아니라 필요한 속성을 열거해야 한다. guest 경로는 애초에 몇 개만 쓰므로 특히 잘 맞는다.
// AS-IS — getUser: full item(=tagHistory 포함) 항상 조회 const data = await ddbDocClient.send(new GetCommand({ TableName: `ppi-user-${stage}`, Key: { id }, })); // TO-BE — 히스토리가 불필요한 경로(guest 등)는 필요한 속성만 project const data = await ddbDocClient.send(new GetCommand({ TableName: `ppi-user-${stage}`, Key: { id }, ProjectionExpression: "id, #nm, tags, tagReasons, conversationMode, lessonTypes", ExpressionAttributeNames: { "#nm": "name" }, })); // getUsers(Scan)도 목록에 쓰는 컬럼만 project → 풀스캔 ~40KB/~10 RCU 절감
7. 분리가 정당해지는 조건 (지금은 아님)
changedById/changedAt GSI를 가진 별도 테이블(또는 DynamoDB Stream → 감사 저장소)이 정답이다. 아직 이 요구는 발생하지 않았다.
추가로, 현재 tagHistory는 200개 상한으로 오래된 항목을 조용히 버리므로 "전체 감사 로그"가 아닌 "최근 이력 보존"이다(실사용 max 3건이라 무해). 규정상 영구 감사 추적이 요구된다면 그건 테이블 분리가 아니라 append-only 감사 저장소가 필요한 별개 요구사항이다.