과거 주차 조회에서 '그 당시 최신 스프린트'는 뭐였나: weekEnd 기준 latest sprint 설계
주간 리포트 API가 '이번 주의 latest sprint' 개념으로 태스크를 골라왔는데, 과거 주차를 조회하면 '그 당시'가 아닌 '지금의' latest sprint가 튀어나왔다. carry-over 없이 버려진 태스크까지 리포트에 다시 나타나는 버그였다.
1. 문제 상황
1.1 이미 지나간 스프린트의 태스크가 리포트에 등장
주간 리포트에서 과거 주차를 조회하면 이상한 현상이 보였습니다.
"3월 15주차 리포트를 보는데, 지금은 이미 Sprint 3로 넘어가서 없어진 Sprint 2의 미완료 태스크들이 '진행중'으로 떠요."
3월 15주차 당시엔 Sprint 2가 막 생성된 상태였고, Sprint 1의 태스크 몇 개가 carry-over되지 않고 그대로 남겨져 있었습니다. 이 "남겨진" 태스크들은 의도적으로 버려진 작업(abandoned)이었는데, 현재 시점에서 과거 리포트를 조회하면 여전히 진행중으로 보이고 있었습니다.
1.2 왜 문제인가
"주간 리포트"의 의도는 "그 주에 팀이 무엇을 했는가"를 기록하는 것입니다. 이 관점에서:
- 중단/버려진 태스크는 "그 주에 한 일"이 아닙니다.
- 새 스프린트로 넘어가면서 의도적으로 carry-over하지 않은 태스크는 "관심 밖"으로 치부해야 합니다.
- 하지만 단순히
task.status = "진행중"으로 필터하면 이 구분이 불가능합니다.
문제를 한 문장으로 요약하면:
"그 주차 종료 시점의 가장 최신 스프린트에 속한 진행중 태스크만 표시하자. 이전 스프린트에 남겨진 것은 버려진 것으로 간주."
1.3 "latest sprint"는 시점 의존적인 개념
여기서 핵심은 "latest sprint"가 시점에 따라 다르다는 점입니다.
| 주차 | Sprint 존재 | 그 주의 latest sprint |
|---|---|---|
| Week 5 | Sprint 1만 존재 | Sprint 1 |
| Week 10 | Sprint 1, 2 (Sprint 2가 Mon에 생성) | Sprint 2 |
| Week 15 | Sprint 1, 2, 3 (Sprint 3가 Thu에 생성) | Sprint 3 |
| Week 20 (미래) | 아직 Sprint 4가 없음 | Sprint 3 |
"latest sprint"를 현재 시점 기준으로 단일화해서 쿼리하면, Week 5 리포트에도 Sprint 3 태스크가 출력됩니다. 반대로 주차 시작 시점 기준으로 하면 Week 15는 Sprint 3가 생성되기 전의 Sprint 2를 가리킵니다.
정답은 **주차 종료 시점(weekEnd)**입니다. 이유를 다음 장에서 설명합니다.
2. 원인 분석
2.1 "weekStart" vs "weekEnd" 기준
왜 weekEnd인가? 세 가지 후보를 비교해봅시다:
| 기준 | 장점 | 문제 |
|---|---|---|
| 현재 시점 (now) | 단순 | 과거 주차 조회 시 "미래의 Sprint"가 튀어나옴 |
| weekStart | 이전 스프린트 유지 | 주 중반에 새 Sprint가 생성되면, 그 주의 태스크를 이전 Sprint에서 찾음 |
| weekEnd ✅ | 그 주 동안 사용자가 "실제로 본" 최신 Sprint를 포착 | 없음 |
weekEnd 기준의 직관: "주가 끝났을 때 사용자의 화면에 떠 있었던 Sprint"를 기준으로 리포트를 그립니다. 주 중에 Sprint가 바뀌었다면, 주 끝에는 새 Sprint가 latest였을 것이고, 그 Sprint의 태스크만 집계하는 게 자연스럽습니다.
2.2 과거 vs 현재 vs 미래 주차의 경계
- 과거 주차: weekEnd < now → weekEnd가 명확히 정의된 시점
- 현재 주차: weekStart ≤ now ≤ weekEnd → weekEnd가 "앞으로 올 시점"이지만 쿼리상 문제 없음
- 미래 주차: now < weekStart → weekEnd도 미래 시점 → "현재 시점의 latest sprint"와 동일
미래 주차는 아직 일어나지 않은 일이므로 "현재 Latest sprint"와 같습니다. 쿼리 하나로 세 경우 모두 자연스럽게 커버됩니다.
2.3 첫 번째 시도의 문제
처음엔 이렇게 접근했습니다:
// ❌ 첫 시도: 주차 동안 "최신이었던" 스프린트들을 모두 포함
const sprintsDuringWeek = await prisma.sprint.findMany({
where: {
projectId: entry.projectId,
createdAt: { lte: endDate },
},
orderBy: { sprintNumber: "desc" },
// 주 중에 새 Sprint가 생성되면 이전 Sprint도 "그 주에 존재했음"
});
이 접근의 문제: 전환 주차에 이전 스프린트의 태스크도 함께 표시됩니다. 사용자가 수요일에 Sprint 2를 만들었다면:
- 월~수: Sprint 1이 latest
- 수~일: Sprint 2가 latest
"그 주에 최신이었던" 스프린트는 두 개. 두 Sprint의 진행중 태스크를 모두 보여주면, Sprint 1의 버려진 태스크까지 리포트에 다시 등장합니다. 그게 바로 우리가 피하고 싶었던 그 상황입니다.
3. 해결 방법
3.1 규칙: "weekEnd 시점의 단 하나의 latest sprint"
// ✅ weekEnd 시점의 최신 스프린트 1개만 선택
const latestSprintAtWeekEnd = await prisma.sprint.findFirst({
where: {
projectId: entry.projectId,
createdAt: { lte: endDate }, // 주차 끝 이전에 생성
},
orderBy: { sprintNumber: "desc" },
select: { id: true },
});
핵심 포인트 3가지:
findFirst(단수) — "최신" 하나만 가져옵니다.findMany가 아닙니다.createdAt: { lte: endDate }— 주차 끝까지 존재했던 스프린트만. 미래 스프린트 배제.orderBy: sprintNumber desc— 가장 번호가 큰, 즉 가장 최근 스프린트.
전환 주차의 결과:
| 상황 | findFirst 결과 |
|---|---|
| Week 15 (수요일에 Sprint 2 생성) | Sprint 2 (수~일에 latest) |
| Week 15 (일요일에 Sprint 2 생성) | Sprint 2 (일 마지막 순간) |
| Week 15 (다음 주 월요일에 Sprint 2 생성) | Sprint 1 (아직 Sprint 2 없음) |
어느 경우든 주차 끝 기준의 단일 스프린트를 반환합니다.
3.2 진행중 태스크 쿼리
const inProgress = latestSprintAtWeekEnd
? await prisma.task.findMany({
where: {
projectId: entry.projectId,
sprintId: latestSprintAtWeekEnd.id, // ← 그 Sprint의 태스크만
inProgressAt: { not: null, lte: endDate },
OR: [
{ completedAt: null },
{ completedAt: { gt: endDate } },
],
},
select: TASK_SUMMARY_SELECT,
orderBy: { inProgressAt: "asc" },
})
: [];
로직 설명:
sprintId: latestSprintAtWeekEnd.id— 그 주차의 latest sprint에 속한 태스크만 (이전 sprint의 버려진 태스크 배제)inProgressAt <= endDate— 주차 종료 이전에 진행중으로 전환completedAt IS NULL OR > endDate— 주차 내 미완료 (주차 내 완료된 태스크는 별도 완료 목록으로 분리)
3.3 완료 태스크는 다른 규칙
진행중은 "그 주의 latest sprint" 제약을 받지만, 완료 태스크는 그렇지 않습니다.
const completed = await prisma.task.findMany({
where: {
projectId: entry.projectId,
completedAt: { gte: startDate, lte: endDate }, // 그 주에 완료된 것
// sprintId 필터 없음!
},
select: TASK_SUMMARY_SELECT,
orderBy: { completedAt: "asc" },
});
이유: 완료는 historical event이기 때문입니다.
- 진행중: "그 순간 무엇을 하고 있었나" — 시점 의존적, latest sprint 제약 필요
- 완료: "그 주에 끝났다는 사실" — 불변, sprint가 나중에 뭐가 되든 그 주엔 완료되었음
특히 잠긴 이전 스프린트에서 완료된 태스크도 그 주의 완료 목록에 포함해야 합니다. "Sprint 1의 태스크를 그 주에 완료했다"는 것은 Sprint가 나중에 잠기더라도 변하지 않는 사실입니다.
4. 경계 케이스 다루기
4.1 Week 5 (프로젝트 초기)
- Sprint 1만 존재, 프로젝트가 막 시작됨
latestSprintAtWeekEnd = Sprint 1- Sprint 1의 진행중 태스크가 리포트에 표시됨 ✅
4.2 Week 15 (전환 주차, 목요일에 Sprint 2 생성)
latestSprintAtWeekEnd = Sprint 2- Sprint 2의 진행중 태스크만 표시 (Sprint 1의 carry-over 안 된 태스크는 제외) ✅
- Sprint 1에서 그 주에 완료된 태스크는 완료 목록에 여전히 포함 ✅
4.3 Week 20 (미래 주차)
- 쿼리 시점 기준으로
now < weekStart < weekEnd latestSprintAtWeekEnd는 현재 시점의 latest와 동일 (아직 미래 스프린트가 없음)- 빈 진행중 목록 (태스크들의
inProgressAt이 아직 없음) ✅
4.4 미래 주차에 과제가 생성 예정인 경우
- 프로젝트가 Week 16에 생성 예정이라고 status log가 있으면
- Week 10 조회 시
getActiveProjectIdsInWeek가 이 프로젝트를 excluded - 애초에 entry가 만들어지지 않음 → latest sprint 쿼리까지 안 감 ✅
4.5 carry-over된 태스크
- Sprint 1에서 진행중이던 태스크 T가 Sprint 2로 carry-over (
sprintId변경) - 이제
task.sprintId = Sprint 2 - Week 15 쿼리 시 Sprint 2의 태스크로 정상 포함 ✅
4.6 Sprint에 포함되지 않은 backlog 태스크
task.sprintId = null(backlog)- 진행중 쿼리는
sprintId: latestSprintAtWeekEnd.id로 필터 → backlog는 자동 배제 ✅ - 완료 쿼리는
sprintId필터 없음 → backlog가 완료되면 포함됨
Backlog를 진행중 목록에서 배제하는 것은 의도된 설계입니다. "스프린트에 포함되지 않은 태스크는 active work로 간주하지 않는다"는 프로젝트 규칙이 있었습니다.
5. 핵심 개념 정리
5.1 "historical pin"으로 개념 지어보기
이 문제는 historical pinning 패턴의 한 예입니다:
특정 시점 T를 기준으로 "T 시점의 관점"으로 데이터를 보여줘야 한다.
historical query를 쓸 때 고려할 질문:
- T가 무엇인가: weekStart? weekEnd? custom timestamp?
- 어떤 필드가 T에 영향받는가: status, sprint 소속, 권한?
- 어떤 필드는 T와 무관한가: "완료했다는 사실", "생성 시각"?
이번 구현에서는:
latestSprintAtWeekEnd는 T=weekEnd 영향inProgressAt <= endDate는 T=weekEnd 영향completedAt in [startDate, endDate]는 T=주차 범위 영향 (양방향)
세 가지 조건이 한 쿼리에서 각자 다른 "T"를 참조하는 게 이 설계의 묘미입니다.
5.2 SQL 관점
-- weekEnd 시점의 latest sprint
SELECT id FROM "Sprint"
WHERE "projectId" = $1 AND "createdAt" <= $weekEnd
ORDER BY "sprintNumber" DESC
LIMIT 1;
-- 그 sprint의 진행중 태스크
SELECT * FROM "Task"
WHERE "projectId" = $1
AND "sprintId" = $latestSprintId
AND "inProgressAt" IS NOT NULL
AND "inProgressAt" <= $weekEnd
AND ("completedAt" IS NULL OR "completedAt" > $weekEnd);
-- 그 주에 완료된 태스크 (sprint 무관)
SELECT * FROM "Task"
WHERE "projectId" = $1
AND "completedAt" BETWEEN $weekStart AND $weekEnd;
세 쿼리 모두 현재 상태(status 필드)를 전혀 참조하지 않습니다. 오직 시간 컬럼(inProgressAt, completedAt)만으로 상태를 재구성합니다. 이는 상태 필드의 변경 이력이 없어도 historical query가 가능하다는 의미입니다.
5.3 "진행중이자 완료"의 중복 제거
한 태스크가 같은 주에 진행중 전환 + 완료가 모두 일어날 수 있습니다:
inProgressAt = 2026-04-07 10:00completedAt = 2026-04-08 16:00
이 태스크는 완료 목록에 뜨고 진행중 목록에서는 제외되어야 합니다. 진행중 쿼리의 completedAt: null OR > endDate 조건이 바로 그 역할입니다:
- completedAt가 null: 진행중 목록 ✓
- completedAt > endDate: 주차 이후 완료 → 주 중엔 진행중 ✓
- completedAt ≤ endDate: 주차 내 완료 → 완료 목록으로 ✓ (진행중에서 제외)
6. 베스트 프랙티스
6.1 체크리스트
- [ ] "latest X at time T" 쿼리는
findFirst({ where: { createdAt: { lte: T } }, orderBy: desc }) - [ ] historical view에서 현재 상태 필드(
status) 직접 참조 금지 → 이력 기반 재구성 - [ ] "완료"는 historical event로 취급 — 나중에 무엇이 바뀌어도 불변
- [ ] 진행중 쿼리는
completedAt IS NULL OR > endDate로 중복 제거 - [ ] Backlog(
sprintId: null)가 진행중 목록에 들어가야 하는지 프로덕트 결정 필요 - [ ] 경계 케이스(프로젝트 초기, 전환 주차, 미래)를 모두 테스트 케이스로
6.2 테스트 시나리오
describe("historical latest sprint", () => {
it("Week 15: 수요일 Sprint 2 생성 → Sprint 2 태스크만 진행중", async () => {
// Sprint 1 (Feb 1 생성), Sprint 2 (Apr 8 생성, 2026-W15 수요일)
// Task A: Sprint 1에 남겨진 진행중 (carry-over 안 됨)
// Task B: Sprint 2의 진행중 태스크
const report = await getOrCreateReport(orgId, 2026, 15, "Asia/Seoul");
const entry = report.entries.find((e) => e.projectId === projectId);
expect(entry!.stats.inProgress.map((t) => t.title)).toEqual(["Task B"]);
// Task A는 배제됨
});
it("Week 5: Sprint 1만 존재 → Sprint 1 태스크 표시", async () => {
// ...
});
it("미래 주차: 빈 목록", async () => {
// ...
});
it("잠긴 Sprint 1에서 그 주에 완료된 태스크는 완료 목록에 포함", async () => {
// ...
});
});
6.3 문서화
이 로직은 외부에서 이해하기 어려우므로 API 파일 상단에 명시적 주석이 필수입니다:
// 주차 종료 시점의 최신 스프린트 — 잠긴 스프린트의 stale(abandoned) 태스크 제외 기준
//
// 정책: 이전 스프린트에서 carry-over 없이 남겨진 진행중 태스크는 "버려진 것"으로 간주하여
// 주간 리포트에 표시하지 않음. 사용자가 새 스프린트 생성 시 의도적으로 이관하지 않은
// 태스크는 더 이상 active work로 취급하지 않음.
//
// 과거 주차 조회 시에도 createdAt <= endDate 조건으로 historical 정확성 유지:
// - Week 5 (Sprint 1만 존재): Sprint 1이 최신
// - Week 15 (Thu에 Sprint 2 생성): Sprint 2가 weekEnd 시점 최신 → Sprint 2만
// - Week 20 (미래): 현재 최신 스프린트
const latestSprintAtWeekEnd = await prisma.sprint.findFirst({...});
7. FAQ
Q. 왜 Sprint에도 append-only 상태 로그를 두지 않았나요?
A. Sprint는 자체적으로 historical 속성을 가집니다: createdAt이 시작 시각, sprintNumber가 순서를 고정. 별도 이력 테이블 없이 createdAt <= T 쿼리로 "T 시점의 latest"를 충분히 답할 수 있었습니다. Project status는 "진행중 → 완료 → 중단 → 재개" 같은 양방향 전환이 있어서 이력 테이블이 필요했습니다. 도메인에 따라 다릅니다.
Q. 완료 쿼리에 sprintId 필터가 없는데, 다른 프로젝트의 태스크가 섞이지 않나요?
A. projectId: entry.projectId 필터가 있어서 다른 프로젝트의 태스크는 들어오지 않습니다. 같은 프로젝트의 다른 Sprint 완료 태스크는 의도적으로 모두 포함합니다 — "그 주에 그 프로젝트에서 완료된 모든 태스크"를 보여주는 것이 리포트의 목적.
Q. "진행중"과 "완료" 모두에 포함되는 태스크는 없나요?
A. 없습니다. 진행중 쿼리의 completedAt IS NULL OR > endDate가 "주 내 완료"를 명시적으로 배제합니다. 한 태스크는 주차 내에서 한 목록에만 속합니다.
Q. 사용자가 Task의 inProgressAt을 수동으로 바꿔버리면?
A. 현재 API는 inProgressAt을 자동 설정(status → 진행중 전환 시)만 하고, 수동 수정은 허용하지 않습니다. 만약 허용한다면 historical 쿼리가 어긋날 수 있습니다 — 상태 변경 시각을 임의로 되돌리는 행위는 "역사를 다시 쓰는" 것이기 때문입니다. 필요하다면 ProjectStatusLog처럼 별도 event log로 가야 합니다.
Q. "잠긴 Sprint"는 무슨 개념인가요?
A. 더 새로운 Sprint가 만들어지면 이전 Sprint는 "잠김" 상태가 되어 태스크 수정이 제한됩니다. 하지만 완료 태스크 자체는 이력 이벤트라서, 잠긴 Sprint라도 해당 주차의 완료 목록에 여전히 나타납니다. 잠김이 영향을 주는 건 "진행중 목록에 포함 여부" 뿐입니다.
8. 참고 자료
- Martin Fowler - Temporal Patterns
- Prisma - Pagination and filtering
- 관련 글: 과거의 진실을 기록하는 법: Append-only ProjectStatusLog로 구현한 historical 주간 리포트
9. 다음 단계
latest sprint at weekEnd가 정해지면, 다음 과제는 진행중/완료 태스크 쿼리의 정확한 의미론입니다. "주 중 어느 시점이라도 진행중이었던"과 "주 중 진행중으로 전환된"은 같지 않고, 매 커밋마다 이 두 의미론 사이에서 버그가 나왔습니다. 이 이야기는 별도 글에서 이어집니다.