과거의 진실을 기록하는 법: Append-only ProjectStatusLog로 구현한 historical 주간 리포트
이번 주 주간 리포트를 보는데, 지난 달에 완료된 과제가 목록에서 사라졌다. 현재 상태(status=완료)로 필터하면 과거 주차 리포트가 오염된다 — 이 문제를 append-only 로그로 해결한 방법.
1. 문제 상황
1.1 "지난 달 주간 리포트가 왜 비어 있지?"
조직 단위 주간 진행 리포트 기능을 출시하고 며칠 뒤, 사용자가 물었습니다.
"3월 둘째 주 리포트 들어갔더니 과제가 2개밖에 안 떠요. 그 주에는 분명 5개 돌고 있었는데요."
원인은 단순했습니다. 그 시점 이후 3개 과제가 완료 또는 중단으로 바뀌었던 것. 리포트 API가 "현재 상태가 active인 과제"로 필터하고 있었기 때문에, 과거 주차를 조회해도 현재 활성 과제만 보여주고 있었습니다.
1.2 버그의 본질
// ❌ Before: 현재 상태로 historical 리포트를 그리는 버그
const activeProjects = await prisma.project.findMany({
where: {
organizationId,
status: { in: ["PI", "진행중"] }, // ← "지금" active한 과제
},
});
| 시점 | 과제 A 상태 | 리포트에 표시? |
|---|---|---|
| 3월 1주차 | 진행중 | ✅ (당시에는 맞음) |
| 3월 4주차 | 완료 | ✅ |
| 현재 시점에서 3월 1주차 리포트 조회 | (현재 완료) | ❌ 사라짐 |
1.3 왜 단순한 updatedAt이나 completedAt 필드로 해결이 안 되나?
updatedAt은 마지막 수정 시각만 기록 — 중간 이력은 소실.completedAt이 있어도,중단 → PI 재개 → 다시 중단처럼 같은 상태로 여러 번 돌아온 이력은 표현 불가.- 현재 상태와 "특정 시점에서의 상태"는 본질적으로 다른 질문.
2. 원인 분석: 상태는 스냅샷, 역사는 테이블
2.1 "지금의 상태"와 "그때의 상태"
| 구분 | Snapshot | Event Log |
|---|---|---|
| 저장 | 현재 상태만 | 모든 변경 이력 |
| 질문 | "지금 status는?" | "언제 PI→진행중이 됐지?" |
| 공간 | 작음 | 큼 (time-series) |
| 과거 복원 | ❌ 불가 | ✅ 가능 |
2.2 Append-only 로그라는 절충안
- 한 컬럼(
status)의 변경 이력만 별도 테이블에 쌓음 - 기존 Project 테이블은 그대로 유지 (backward compatible)
- 쿼리는 "이 프로젝트의
changedAt <= T중 가장 최근 row"로 과거 상태 복원 - 이 테이블은 절대 UPDATE/DELETE 하지 않는다 — 오직 INSERT만
3. 해결 방법
3.1 스키마 추가
model ProjectStatusLog {
id String @id @default(cuid())
projectId String
status String
changedAt DateTime @default(now())
changedBy String?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User? @relation(fields: [changedBy], references: [id], onDelete: SetNull)
@@index([projectId, changedAt])
@@index([changedBy])
}
핵심 포인트:
@@index([projectId, changedAt])— historical 쿼리용 복합 인덱스 필수changedBy String?— OSS 모드와 시스템 자동 전환까지 담기 위해 nullableonDelete: SetNull— 사용자 삭제되어도 이력 보존
3.2 로그 기록 헬퍼
type TxClient = Prisma.TransactionClient | PrismaClient;
export async function logProjectCreated(
tx: TxClient, projectId: string, status: string, changedBy: string | null,
): Promise<void> {
await tx.projectStatusLog.create({ data: { projectId, status, changedBy } });
}
export async function logProjectStatusChange(
tx: TxClient, projectId: string, newStatus: string, changedBy: string | null,
): Promise<boolean> {
const latest = await tx.projectStatusLog.findFirst({
where: { projectId }, orderBy: { changedAt: "desc" }, select: { status: true },
});
if (latest && latest.status === newStatus) return false;
await tx.projectStatusLog.create({ data: { projectId, status: newStatus, changedBy } });
return true;
}
3.3 Historical 쿼리: "특정 주에 active였는지"
export async function getActiveProjectIdsInWeek(
tx: TxClient, organizationId: string, weekStart: Date, weekEnd: Date,
): Promise<string[]> {
const allProjects = await tx.project.findMany({
where: { organizationId }, select: { id: true },
});
if (allProjects.length === 0) return [];
const projectIds = allProjects.map((p) => p.id);
const logsBeforeOrAtWeekStart = await tx.projectStatusLog.findMany({
where: { projectId: { in: projectIds }, changedAt: { lte: weekStart } },
orderBy: { changedAt: "desc" },
select: { projectId: true, status: true },
});
const statusAtWeekStart = new Map<string, string>();
for (const log of logsBeforeOrAtWeekStart) {
if (!statusAtWeekStart.has(log.projectId)) {
statusAtWeekStart.set(log.projectId, log.status);
}
}
const activatedInWeek = await tx.projectStatusLog.findMany({
where: {
projectId: { in: projectIds },
status: { in: [...ACTIVE_PROJECT_STATUSES] },
changedAt: { gt: weekStart, lte: weekEnd },
},
distinct: ["projectId"], select: { projectId: true },
});
const activatedIds = new Set(activatedInWeek.map((l) => l.projectId));
return projectIds.filter((pid) => {
const s = statusAtWeekStart.get(pid);
const wasActive = s ? ACTIVE_PROJECT_STATUSES.includes(s as never) : false;
return wasActive || activatedIds.has(pid);
});
}
3.4 리포트 생성 시 historical 판정 사용
export async function getOrCreateReport(orgId: string, year: number, week: number, tz: string) {
const { startDate, endDate } = getWeekRange(year, week, tz);
const activeProjectIds = await getActiveProjectIdsInWeek(prisma, orgId, startDate, endDate);
// ...entries를 activeProjectIds로 생성/보충
}
4. 기존 데이터 백필
4.1 Idempotent 백필 스크립트
async function backfill() {
const projects = await prisma.project.findMany({
select: { id: true, status: true, createdAt: true },
});
if (projects.length === 0) return;
const existingLogs = await prisma.projectStatusLog.findMany({
where: { projectId: { in: projects.map((p) => p.id) } },
distinct: ["projectId"], select: { projectId: true },
});
const existingIds = new Set(existingLogs.map((l) => l.projectId));
const toCreate = projects
.filter((p) => !existingIds.has(p.id))
.map((p) => ({ projectId: p.id, status: p.status, changedAt: p.createdAt, changedBy: null }));
if (toCreate.length === 0) return;
await prisma.projectStatusLog.createMany({ data: toCreate });
}
4.2 배포 훅에 연결
pnpm prisma db push
node /app/scripts/backfill-project-status-log.mjs
exec pnpm start
5. 핵심 개념 정리
5.1 "시점 t에서의 상태"를 복원하는 패턴
SELECT status FROM "ProjectStatusLog"
WHERE "projectId" = $1 AND "changedAt" <= $2
ORDER BY "changedAt" DESC LIMIT 1;
5.2 Append-only의 3가지 규칙
- ✅ INSERT만 허용 — UPDATE/DELETE 금지
- ✅ 시간은 서버 기준으로 일원화 —
@default(now()) - ✅ 외래 키는 nullable로 유지 —
onDelete: SetNull
5.3 언제 append-only가 필요한가?
| 상황 | 필요 여부 |
|---|---|
| 현재 상태만 필요 | ❌ 일반 컬럼으로 충분 |
| 과거 특정 시점의 상태를 다시 보여줘야 함 | ✅ append-only |
| 감사(audit) 요구사항 | ✅ append-only |
| 상태 전환 패턴 분석 | ✅ append-only |
| 모든 컬럼의 이력 필요 | ⚠️ event sourcing 고려 |
6. 베스트 프랙티스
- [ ] 이력 테이블에 복합 인덱스
[entityId, changedAt]추가 - [ ] 로그 작성은 반드시 부모 업데이트와 같은 트랜잭션에서
- [ ] "같은 상태로의 업데이트"는 기록하지 않는 가드
- [ ] 이력 도입 시점에 기존 데이터 backfill 스크립트 준비 (idempotent)
- [ ] 외래 키
onDelete: SetNull로 이력 보존
7. FAQ
Q. 왜 enum이 아닌 String인가요?
A. OSS/SaaS 양쪽 스키마 복잡성 때문에 String + 상수 배열로 검증했습니다.
Q. 매번 findMany 2번인데 느리지 않나요?
A. 조직 스케일이 작아서(수십~수백 프로젝트) 체감 지연 없음. 확장 시 DISTINCT ON Raw SQL로 전환 가능.
Q. 이력이 너무 많이 쌓이면?
A. 사내 규모는 연간 수천 row. 필요하면 cold archive 또는 인덱스 활용.
Q. UPDATE/DELETE를 물리적으로 막으려면?
A. PostgreSQL: REVOKE UPDATE, DELETE ON "ProjectStatusLog" — 프로덕션 권장.
8. 참고 자료
9. 다음 단계
append-only 이력으로 "그 주에 active였던 과제"를 복원했다면, 다음 단계는 같은 주에 latest sprint가 무엇이었는지도 historical하게 답하는 것입니다. 그리고 주간 리포트의 주차 경계를 조직 타임존에 따라 계산하는 것도 별개의 주제입니다.