과거의 진실을 기록하는 법: 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])
}

핵심 포인트:

  1. @@index([projectId, changedAt]) — historical 쿼리용 복합 인덱스 필수
  2. changedBy String? — OSS 모드와 시스템 자동 전환까지 담기 위해 nullable
  3. onDelete: 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하게 답하는 것입니다. 그리고 주간 리포트의 주차 경계를 조직 타임존에 따라 계산하는 것도 별개의 주제입니다.