Active, Ongoing, Completed: 주차 기반 태스크 상태 쿼리의 3가지 의미론과 그 함정

'이번 주 진행중인 태스크'를 쿼리하는 방법이 세 가지나 있었다. '주 중에 전환된', '주 동안 어느 시점이라도', '주 시작 시점에'. 말로는 비슷하지만 SQL은 전혀 다르고, 틀리면 이전 주부터 계속 진행되던 태스크가 리포트에서 사라진다.

Active, Ongoing, Completed: 주차 기반 태스크 상태 쿼리의 3가지 의미론과 그 함정

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 <= weekEndinProgressAt ≤ 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/간단한 필터는 status enum
  • 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 의미로 번역하기 전에 프로덕트와 재확인
  • [ ] "주 동안 어느 시점이라도"와 "주 내 전환"은 다른 쿼리 — 구분해서 설명
  • [ ] 완료/진행중 두 목록의 배타성을 테스트로 고정
  • [ ] 경계 값(lte vs gt)을 문서화
  • [ ] 복합 인덱스로 성능 확보
  • [ ] 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. 네. inProgressAtcompletedAt이 모두 같은 주 안에 있는 경우입니다. 진행중 쿼리는 이를 명시적으로 완료 목록 쪽으로 밀어냅니다 (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. 참고 자료


9. 다음 단계

쿼리의 의미론을 고정했다면, 다음은 집계 결과를 어떻게 UI로 표현하느냐입니다. 진행중/완료 수를 동시에 보여주는 compact 모드, 과제별 드릴다운하는 detail 모드, split view에서 한 과제를 선택했을 때의 동작. 이 UX 얘기는 다른 글에서 이어집니다.