user.tagHistory — 임베딩 vs 별도 테이블 분리 검증

마지막 업데이트 2026-07-22

PPI-1103 "음성 불가 사유 태그 변경 히스토리"를 별도 테이블로 분리하는 게 데이터구조 관점에서 타당한지 실측 데이터로 검증.

PPI-1103 DynamoDB · ppi-user 데이터구조 검증
결론. 지금 별도 테이블 분리는 부적절하다. "user 조회 시 히스토리까지 딸려온다"는 오버페치는 실재하지만, 그것이 비용 낭비인지는 경로별로 다르다 — 핫패스(GetItem)는 아이템이 4KB 미만이라 히스토리를 함께 읽어도 RCU가 늘지 않아 분리 이득이 0이고, 목록 Scan에서만 미미한 오버페치가 있다. 두 경로 모두 정답은 테이블 분리가 아니라 읽기에 ProjectionExpression을 붙여 tagHistory를 빼는 것이다. 분리는 이력이 필요한 admin 화면에 2차 쿼리 부담만 더한다.

1. 배경 — 지금 어떻게 저장되나

PPI-1103은 사유 태그 변경 이력을 별도 테이블이 아니라 기존 user 아이템의 tagHistory 속성(임베딩 배열)으로 저장한다. 태그/사유가 바뀔 때만 updateUser()가 diff를 최신순으로 prepend(최대 200개 상한).

ppi-user-{stage} 아이템id · name · tags · tagReasons · … · tagHistory[]
updateUser()db-queries.ts
태그/사유 변경 시 diff 1건 prepend

각 이력 항목: changedAt, changedByName, changedById?, addedTags/removedTags, addedTagReasons/removedTagReasons. guest GET 응답에선 관리자 실명 보호를 위해 destructuring으로 제외.

2. 실측 데이터 (prod scan, 2026-07-19)

118
tagHistory 보유 유저 (전체 1,243)
127
총 이력 항목 수
max 3
유저당 이력 항목 (avg 1.1 / 상한 200)
1,847 B
최대 아이템 크기 (전체 중)
0.5%
400KB 아이템 한도 대비
< 4 KB
모든 아이템이 1 RCU 경계 이내
테이블tagHistory 보유비고
ppi-user-prod118명 / 127건실사용 데이터
ppi-user-staging0명미사용
ppi-user-dev2명테스트 흔적

tagHistory 속성 자체 크기: max 962B / avg 338B. 상한(200)과 실사용(max 3)은 100배 격차 → 아이템 크기 압박 없음.

3. 검토 대상 — "user 조회 시 히스토리까지 오는 게 문제"

분리 근거로 제시된 의도는 오버페치 회피다. 코드상 getUser(GetCommand)·getUsers(Scan) 모두 ProjectionExpression전혀 없어 항상 full item(= tagHistory 포함)을 읽는다. guest 경로는 full fetch 후 메모리에서 버린다. 관찰 자체는 사실이다.

DynamoDB 비용 모델 핵심. RCU는 "읽은 아이템의 전체 크기"로 과금된다. ProjectionExpression은 아이템을 스토리지에서 다 읽은 뒤 서버에서 속성만 걸러내는 것이라 RCU가 아니라 네트워크 페이로드만 줄인다. 이 사실이 "오버페치 = 비용"인지를 경로별로 가른다.

4. 두 읽기 경로 분석

분리 이득 = 0

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. 분리가 정당해지는 조건 (지금은 아님)

분리는 오버페치가 아니라 새로운 접근 패턴 때문에 정당해진다. 대표적으로 교차 유저 audit 쿼리 — "관리자 오니가 바꾼 전체 이력", "기간별 태그 변경 집계". 현재 임베딩으로는 full scan 없이는 불가능하며, 이 패턴이 생기면 changedById/changedAt GSI를 가진 별도 테이블(또는 DynamoDB Stream → 감사 저장소)이 정답이다. 아직 이 요구는 발생하지 않았다.

추가로, 현재 tagHistory는 200개 상한으로 오래된 항목을 조용히 버리므로 "전체 감사 로그"가 아닌 "최근 이력 보존"이다(실사용 max 3건이라 무해). 규정상 영구 감사 추적이 요구된다면 그건 테이블 분리가 아니라 append-only 감사 저장소가 필요한 별개 요구사항이다.

한 줄 요약. 오버페치를 없애고 싶다는 의도는 타당하다. 다만 그 목적엔 테이블 분리가 아니라 읽기 경로에 ProjectionExpression 추가가 정답이다. 분리는 핫패스 RCU를 거의 못 줄이면서 admin 화면에 2차 쿼리 부담만 추가한다. 분리 트리거는 "교차 유저 audit 쿼리"가 유일하며, 아직 발생하지 않았다.
작성: 2026-07-19 / chulsu · 관련: PPI-1103 (커밋 6b4d294a) · 실측: ppi-user-prod scan