1. 문제 상황
1.1 "이 태스크가 왜 이번 주 리포트에 없죠?"
주간 리포트 초기 버전을 출시하고 나서 사용자가 물었습니다.
"태스크 X는 지난 주부터 지금까지 계속 진행 중인데, 왜 이번 주 리포트엔 안 나와요?"
로그를 보니 그 태스크의 inProgressAt이 지난 주 값이었습니다. 그리고 쿼리는 이랬습니다:
// ❌ Before: "이번 주에 진행중으로 전환된" 태스크만 필터
const inProgress = await prisma.task.findMany({
where: {
projectId: entry.projectId,
inProgressAt: {
gte: weekStart, // ← 이번 주에 시작됐어야 함
lte: weekEnd,
},
completedAt: null,
},
});
이 쿼리는 "이번 주에 새로 진행중이 된" 태스크만 포함합니다. 사용자의 질문은 "이번 주에 진행 중이었던"이었습니다. 두 표현은 한국어로 거의 같지만 SQL로 번역하면 완전히 다른 조건이 됩니다.
1.2 세 가지 가능한 해석
"이번 주 진행중 태스크"라는 한 문장은 최소 세 가지로 해석할 수 있습니다:
| 해석 | 의미 | 쿼리 |
|---|---|---|
| A. 주 중에 전환된 | "이번 주에 새로 시작된 태스크" | inProgressAt BETWEEN weekStart AND weekEnd |
| B. 주 시작 시점에 | "월요일 00:00 기준 진행중이던 태스크" | 복잡 (historical snapshot 필요) |
| C. 주 동안 어느 시점이라도 | "월~일 사이 언제라도 진행중이었던 태스크" | inProgressAt <= weekEnd AND (completedAt IS NULL OR > weekEnd) |
A는 "새로 시작"이고, C는 "아직 끝나지 않은 모든 것"입니다. 사용자의 기대는 C였고, 코드는 A였습니다.
1.3 왜 한 번에 이걸 구분하기 어려운가
자연어로 이 세 의미를 명확히 구분하기 힘듭니다. "이번 주에 진행 중이었던"이라고 말하면 A도 B도 C도 떠오르기 때문입니다. 프로덕트 요구사항을 처음 받을 때 "A인지 C인지"를 명시적으로 물어보지 않으면 개발자가 자기 나름대로 해석하고, 리포트가 눈에 "이상해 보일 때" 버그가 드러납니다.
2. 원인 분석
2.1 시간축과 세 이벤트
태스크의 상태 전환을 시간축에 그려봅시다:
time →
┌──────────────────┐
│ inProgressAt │
(태스크가 "진행중"으로 전환)
┌──────────┐
│ completedAt
(태스크가 "완료"로 전환)
○──────────────────────────●
진행중 완료 (또는 null=아직 진행중)
weekStart weekEnd
├──────────────────────── 주차 범위 ────────────────────────┤
세 타임스탬프가 존재합니다: inProgressAt, completedAt, 그리고 "주차 범위" [weekStart, weekEnd]. 이들의 관계로 "주차 내 진행중"의 의미가 결정됩니다.
2.2 A / B / C 케이스별 Venn Diagram
Case A (주 중에 전환된):
weekStart ≤ inProgressAt ≤ weekEnd
→ 주 동안 새로 시작된 태스크만
Case B (주 시작 시점에):
inProgressAt ≤ weekStart
AND (completedAt IS NULL OR > weekStart)
→ 월요일 00:00 순간 진행중이던 태스크
Case C (주 동안 어느 시점이라도):
inProgressAt ≤ weekEnd
AND (completedAt IS NULL OR > weekEnd)
→ 월~일 사이 어느 순간이라도 진행중이었던 태스크
C는 A와 B를 포함하는 가장 넓은 집합입니다. 주간 리포트의 실용적 의미는 거의 항상 C입니다 — "이 주에 팀이 작업하고 있던 것들"이니까요.
2.3 완료 목록과의 중복
C로 가면 즉시 다음 함정이 기다립니다: 주 내 완료된 태스크가 진행중 목록에도 등장합니다.
예: 태스크 T의 inProgressAt = Tue, completedAt = Thu
- 주 동안 한 번이라도 진행중? Yes → 진행중 목록
- 주 내 완료? Yes → 완료 목록
- 같은 태스크가 두 목록에 동시에 노출 ❌
이를 해결하려면 진행중 쿼리에 "주 내 완료는 배제" 조건을 추가해야 합니다:
// ✅ 최종 수정
where: {
projectId,
inProgressAt: { not: null, lte: weekEnd },
OR: [
{ completedAt: null }, // 아직 진행중
{ completedAt: { gt: weekEnd } }, // 주차 이후 완료
],
// completedAt가 [weekStart, weekEnd] 범위에 있으면 완료 목록 쪽으로
}
3. 해결 방법
3.1 규칙: 진행중은 C, 완료는 주차 범위, 중복은 진행중 쪽에서 배제
// 진행중: 주 동안 어느 시점이라도 in_progress 상태였던 태스크
const inProgress = await prisma.task.findMany({
where: {
projectId: entry.projectId,
sprintId: latestSprintAtWeekEnd.id,
inProgressAt: { not: null, lte: endDate },
OR: [
{ completedAt: null },
{ completedAt: { gt: endDate } },
],
},
select: TASK_SUMMARY_SELECT,
orderBy: { inProgressAt: "asc" },
});
// 완료: 주 내 완료된 모든 태스크
const completed = await prisma.task.findMany({
where: {
projectId: entry.projectId,
completedAt: { gte: startDate, lte: endDate },
},
select: TASK_SUMMARY_SELECT,
orderBy: { completedAt: "asc" },
});
포함되는 케이스 (진행중):
- 이전 주 시작, 현재 진행중
- 이전 주 시작, 다음 주 완료
- 주차 내 시작, 현재 진행중
- 주차 내 시작, 다음 주 완료
제외되는 케이스 (진행중):
- 주차 내 시작 + 주차 내 완료 → 완료 목록으로만 ✅
- 주차 이전 완료 → 이미 종료된 태스크
- 미래 시작 (
inProgressAt > weekEnd) - todo 상태 (
inProgressAt IS NULL)
3.2 inProgressAt: { not: null, lte: endDate }의 두 조건
Prisma에서 이 복합 조건은 SQL로:
"inProgressAt" IS NOT NULL AND "inProgressAt" <= $weekEnd
왜 { not: null }이 필요한가? lte 비교는 NULL과의 비교가 false가 아니라 unknown을 반환합니다. PostgreSQL의 3-value logic 때문에 NULL <= anything은 NULL이고, WHERE 절에서 NULL은 "행 제외"로 동작하긴 하지만, 명시적으로 IS NOT NULL을 두는 게 쿼리 플래너와 독자 모두에게 더 분명합니다.
3.3 중복 방지의 불변성
진행중과 완료 두 목록은 상호 배타적이어야 합니다. 이 불변성을 테스트로 고정:
it("한 태스크가 두 목록에 동시에 나타나지 않는다", async () => {
const report = await getOrCreateReport(orgId, 2026, 15, "Asia/Seoul");
for (const entry of report.entries) {
const inProgressIds = new Set(entry.stats.inProgress.map((t) => t.id));
const completedIds = new Set(entry.stats.completed.map((t) => t.id));
const intersection = [...inProgressIds].filter((id) => completedIds.has(id));
expect(intersection).toEqual([]);
}
});
이런 "cross-list duplication" 테스트는 조건 변경 시 회귀를 잡아줍니다.
4. 경계 케이스
4.1 inProgressAt = weekStart (정확히 주 시작)
inProgressAt: 2026-04-06 00:00:00 (KST)
weekStart: 2026-04-06 00:00:00 (KST)
inProgressAt <= weekEnd는 inProgressAt ≤ 2026-04-12 23:59:59.999이므로 포함됩니다. 경계 포함 여부는 lte (less than or equal)로 명시했습니다.
4.2 completedAt = weekEnd (정확히 주 끝)
completedAt: 2026-04-12 23:59:59.999 (KST 일 마지막 순간)
weekEnd: 2026-04-12 23:59:59.999 (KST)
진행중 쿼리: completedAt > weekEnd 조건 → false → 진행중 목록 배제 ✅
완료 쿼리: completedAt <= weekEnd 조건 → true → 완료 목록 포함 ✅
경계 값은 한쪽 목록에만 들어가야 합니다. gt(진행중)와 lte(완료)를 엇갈리게 쓰는 것이 핵심입니다.
4.3 inProgressAt = null (아직 시작 안 함)
todo 상태. 두 목록 모두에서 배제됩니다.
4.4 completedAt < weekStart (주차 이전에 완료)
이미 지난 일. 진행중/완료 모두에서 배제.
5. 핵심 개념 정리
5.1 상태를 "어떻게 표현하느냐"에 대한 결정
태스크 상태는 보통 두 가지 방식으로 표현됩니다:
| 방식 | 필드 | 장점 | 단점 |
|---|---|---|---|
| Enum | status: "todo"/"in_progress"/"done" |
단순, SQL 직관적 | 전환 시각 없음 → historical query 불가 |
| Timestamps | inProgressAt, completedAt (nullable) |
상태 + 시각 모두 | "현재 상태" 조회 시 OR 조건 필요 |
이 프로젝트는 Enum + Timestamps 병기 전략을 택했습니다:
- UI/간단한 필터는
statusenum - Historical/주간 리포트는 timestamps
두 필드가 동기화되어야 하므로 PATCH 핸들러에서 자동 설정 로직이 필요합니다:
// src/app/api/tasks/[id]/route.ts
const data: Partial<Task> = { status: newStatus };
if (newStatus === "in_progress" && !existing.inProgressAt) {
data.inProgressAt = new Date();
}
if (newStatus === "done" && !existing.completedAt) {
data.completedAt = new Date();
}
5.2 일반적 "주차 기반 쿼리" 패턴
주간/월간/분기 단위 리포트를 만들 때 반복되는 패턴이 있습니다:
"그 기간 동안 [활성/진행중/open]이었던 X"
= X.stateStartAt ≤ periodEnd
AND (X.stateEndAt IS NULL OR X.stateEndAt > periodEnd)
주간 리포트의 "진행중 태스크" 쿼리가 이 패턴의 실예입니다. "주간 활성 구독자", "월간 OPEN 이슈", "분기 활성 고객" 등 모두 같은 구조입니다.
5.3 인덱스 고려사항
이 쿼리가 자주 실행된다면 인덱스 설계가 중요합니다:
model Task {
// ...
inProgressAt DateTime?
completedAt DateTime?
sprintId String?
@@index([projectId, sprintId, inProgressAt]) // 진행중 쿼리용
@@index([projectId, completedAt]) // 완료 쿼리용
}
- 진행중 쿼리는
projectId + sprintId필터 +inProgressAt범위 → 복합 인덱스 - 완료 쿼리는
projectId필터 +completedAt범위 → 복합 인덱스
PostgreSQL이 NULLS LAST 처리를 어떻게 하는지에 따라 inProgressAt IS NOT NULL 조건도 인덱스 스캔에 활용됩니다.
6. 베스트 프랙티스
6.1 체크리스트
- [ ] 자연어 요구사항("주간 진행중")을 정확한 SQL 의미로 번역하기 전에 프로덕트와 재확인
- [ ] "주 동안 어느 시점이라도"와 "주 내 전환"은 다른 쿼리 — 구분해서 설명
- [ ] 완료/진행중 두 목록의 배타성을 테스트로 고정
- [ ] 경계 값(
ltevsgt)을 문서화 - [ ] 복합 인덱스로 성능 확보
- [ ] Timestamps 필드는 PATCH 핸들러에서 자동 설정
6.2 SQL 의미를 Truth Table로 문서화
쿼리 위에 주석으로 포함/제외 케이스 표를 남겨두는 것이 미래의 디버깅 시간을 크게 줄입니다.
/**
* 주 동안 어느 시점이라도 in_progress 상태였던 태스크
*
* 포함되는 케이스:
* - 이전 주 시작, 현재 진행중
* - 이전 주 시작, 다음 주 완료
* - 주차 내 시작, 현재 진행중
* - 주차 내 시작, 다음 주 완료
*
* 제외되는 케이스:
* - 주차 내 시작 + 주차 내 완료 → 완료 목록으로
* - 주차 이전 완료 → 이미 종료
* - 미래 시작 → 아직 진행 안 됨
* - todo 상태 (inProgressAt = null)
*/
const inProgress = await prisma.task.findMany({...});
이 표를 유지하는 비용은 5줄 주석이지만, "왜 이 태스크가 리포트에 없지?" 질문에 5분이면 답할 수 있게 됩니다.
6.3 테스트 케이스 매트릭스
describe("weekly in-progress query semantics", () => {
// 8가지 조합: inProgressAt(주차 이전/주차 내/주차 이후/null) × completedAt(주차 이전/주차 내/주차 이후/null)
it.each([
// inProgressAt, completedAt, shouldBeInProgress, shouldBeCompleted
["before", "null", true, false],
["before", "after", true, false],
["before", "in-week", false, true], // 주 내 완료
["before", "before", false, false], // 이미 종료
["in-week", "null", true, false],
["in-week", "after", true, false],
["in-week", "in-week", false, true], // 주 내 완료
["after", "null", false, false], // 미래 시작
])(
"inProgressAt=%s, completedAt=%s → inProgress=%s, completed=%s",
async (ip, cp, expIp, expCp) => {
// ...setup and assertion
},
);
});
이 매트릭스 하나로 모든 시간 관계가 검증됩니다. 조건 변경 시 어떤 케이스가 깨지는지 즉시 알 수 있습니다.
7. FAQ
Q. B (주 시작 시점 진행중)가 필요한 경우는?
A. "월요일 아침 기준 팀의 작업 큐"를 보여주고 싶을 때. 주차 리포트의 목적이 "이 주에 무엇을 했는가"가 아니라 "이 주를 어떤 상태로 시작했는가"라면 B가 맞습니다. 이 프로젝트는 전자였기 때문에 C를 택했습니다.
Q. 왜 completedAt: null 대신 status !== 'done'을 쓰지 않나요?
A. Historical query의 원칙이 "현재 상태가 아닌 이력"을 참조하는 것이기 때문입니다. task.status는 가장 최근 상태만 알고 있고, 과거 주차를 조회할 때는 그 주차 시점의 상태를 재구성해야 합니다. completedAt이라는 이력 이벤트만이 이를 가능하게 합니다.
Q. Prisma에서 IS NOT NULL 조건은 어떻게 쓰나요?
A. { not: null }입니다:
where: { inProgressAt: { not: null } }
이는 SQL로 "inProgressAt" IS NOT NULL로 컴파일됩니다.
Q. "진행중이자 완료"는 같은 주에 시작하고 끝낸 태스크인가요?
A. 네. inProgressAt과 completedAt이 모두 같은 주 안에 있는 경우입니다. 진행중 쿼리는 이를 명시적으로 완료 목록 쪽으로 밀어냅니다 (completedAt > weekEnd 조건으로). 결과적으로 "주 안에 완료된 태스크"는 완료 목록에만 나타나고, "아직 진행 중인 태스크"는 진행중 목록에만 나타납니다.
Q. 타임존이 중요한가요?
A. 네. weekStart/weekEnd는 조직 타임존 기준으로 계산되지만, inProgressAt/completedAt은 UTC로 저장됩니다. Prisma는 비교 시 자동 변환을 해주므로 코드에서는 동일한 Date 객체로 쓸 수 있지만, 경계 계산은 반드시 tz-aware여야 합니다. 관련 글: Docker는 UTC, 사용자는 KST: Intl.DateTimeFormat으로 만든 tz-aware ISO Week.
Q. 이 로직이 너무 복잡한데, ORM 추상화로 숨길 수 있나요?
A. 가능하지만 숨기면 오히려 위험합니다. "진행중의 의미"는 프로덕트 결정이고, 추상화 뒤에 숨기면 의미가 흐려집니다. 오히려 activeInWeek 같은 명확한 이름의 쿼리 함수로 노출하고, 주석에 truth table을 두는 게 낫습니다.
8. 참고 자료
- PostgreSQL - 3-Valued Logic and NULL
- Prisma - Filter operators
- 관련 글: 과거 주차 조회에서 '그 당시 최신 스프린트'는 뭐였나
- 관련 글: 과거의 진실을 기록하는 법: Append-only ProjectStatusLog로 구현한 historical 주간 리포트
9. 다음 단계
쿼리의 의미론을 고정했다면, 다음은 집계 결과를 어떻게 UI로 표현하느냐입니다. 진행중/완료 수를 동시에 보여주는 compact 모드, 과제별 드릴다운하는 detail 모드, split view에서 한 과제를 선택했을 때의 동작. 이 UX 얘기는 다른 글에서 이어집니다.
📊 주간 리포트 백엔드 시리즈 (4부작)
- Append-only ProjectStatusLog로 과거의 진실 기록하기
- 순차 루프를 10배 빠르게: 병렬화 + 배치 쿼리
- 과거 주차의 '그때 최신 스프린트' 조회하기
- 주차 기반 태스크 상태 쿼리의 3가지 의미 (현재 글)