Active, Ongoing, Completed: 주차 기반 태스크 상태 쿼리의 3가지 의미론과 그 함정
'이번 주 진행중인 태스크'를 쿼리하는 방법이 세 가지나 있었다. '주 중에 전환된', '주 동안 어느 시점이라도', '주 시작 시점에'. 말로는 비슷하지만 SQL은 전혀 다르고, 틀리면 이전 주부터 계속 진행되던 태스크가 리포트에서 사라진다.
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 얘기는 다른 글에서 이어집니다.