Pomodoro와 일시정지에서 세션 구간을 복원한 원리
[!NOTE] FE 맥락 보존 및 향후 AI API 연계 (2단계) 본 작업은 향후 AI API를 활용한 심층 분석에 대비하여, 프론트엔드(FE)에서 사용자가 실제로 수행했던 세션 내 작업 전환과 집중·휴식 맥락을 DB에 손실 없이 재현하기 위해 진행되었습니다. 1단계에서 분리된 일시정지 타임라인을 기준으로, 과거 분 단위 집중 요약 기록(
pomodoros)을 밀리초 단위 세그먼트(session_segments)로 역산 복원한 계산 로직을 다룹니다.
session_segments 테이블이 도입되기 이전에 저장된 과거 세션 데이터에는 상세 구간 배열이 존재하지 않습니다. 대신 세션 전체의 시작·종료 시각, 분 단위로 집계된 pomodoros, 그리고 초 단위의 정확한 시작·종료 시각을 가진 session_pauses 기록이 남아 있습니다. 이 세 가지 원천 데이터를 바탕으로 과거 세션의 시계열 타임라인을 정밀하게 역복원하는 작업이 세그먼트 백필(Backfill)입니다.
이 백필 작업은 향후 AI API 연동 시 과거 세션 기록까지 연속적인 타임라인과 비정형 작업 맥락(Context)을 단절 없이 온전히 활용할 수 있도록 하기 위해 선제적으로 수행되었습니다.
본 문서는 현재 백필 스크립트(backfill-session-segments.ts)의 assignPomodoros, reconstructSessionSegments 구현 로직 및 단위 테스트를 기준으로 타임라인 복원 계산 원리를 설명합니다. 초기 조사 문서에 기록되었던 후보 수와 예상 행 수는 사전 분석 당시의 수치이며, 2026-10-18에 feature/ai-report Neon DB에 실제로 반영된 최종 수치는 데이터베이스 적재 결과에 기술되어 있습니다.
새 기록 저장과 역복원의 차이
클라이언트가 새로 저장하는 세션은 카테고리·Task·일시정지 변경 시각을 실시간으로 통합하여 ms 단위의 session_segments를 먼저 생성합니다. 이후 focus 구간의 길이를 합산하고 60초(1분) 단위로 내림(floor) 집계한 요약본이 pomodoros에 저장됩니다.
반면 과거 세션 백필은 이미 1분 단위로 요약 및 내림 처리된 pomodoros로부터 거꾸로 세그먼트를 복원하는 작업입니다. 따라서 과거 데이터를 역변환하더라도 원래 발생했던 모든 중간 변경 시각이나 1분 미만의 미세 focus 구간을 100% 완벽하게 원형 그대로 되살리는 것은 불가능하며, 수학적이고 일관된 예산 배치 규칙에 따라 근사 복원해야 합니다.
분 단위 기록을 예산으로 쓰는 이유
데이터베이스에 저장된 pomodoros 1개 행은 실제 시계상의 연속된 시각 범위를 의미하지 않습니다. 과거 클라이언트(category-change-utility.ts)의 세션 집계 방식은 다음과 같았습니다.
timestampsToSegmentsReducer가 카테고리, Task, 일시정지(pause) 변경 시각마다 타임라인 구간을 쪼갭니다.- 일시정지(pause) 상태에서 카테고리나 Task가 바뀌면, 구간 타입은 여전히
pause로 유지되면서currentStartTime만 해당 변경 시각으로 갱신됩니다. segmentsToDurationsReducer는type === 'focus'인 구간의 밀리초만 동일한 카테고리·Task별로 누적 합산합니다. 즉, 중간 에 일시정지가 끼어 있더라도 동일한 작업의 집중 시간은 하나의 행으로 합쳐집니다.convertMilliSecToMin2가 누적된 밀리초를Math.floor(ms / 60000)으로 변환하여 분 단위duration을 생성하며, 1분 미만(0분)인 행은 버려집니다.
따라서 pomodoros의 duration은 일시정지 시간을 완전히 제외한 순수 집중 분량입니다. 또한 startTime 역시 해당 작업이 실제로 집중을 시작한 시각과 다를 수 있습니다. 일시정지 중에 작업이 전환된 경우, 새 행의 startTime은 일시정지 구간 한가운데가 되지만 실제 분으로 인정된 시간은 일시정지가 끝난 이후에 진행된 집중 시간뿐이기 때문입니다.
만약 세션 전체 시간(endTime - startTime)에서 pause만 제외하고 단순 focus 단일 구간으로 일괄 처리한다면, 분 단위 pomodoro 행에 기록된 세부 작업 내역과 개별 작업 길이가 모두 유실됩니다. 반대로 행이 여러 개일 때 단순 보간을 수행하면 작업 사이의 비어 있는 시간까지 어느 한 작업의 집중으로 왜곡될 수 있습니다.
따라서 복원 로직은 분 단위로 기록된 양수 pomodoro마다 duration × 60,000ms를 해당 작업의 **집중 시간 예산(Focus Budget)**으로 부여합니다. focus를 배치하던 중 일시정지가 시작되면 그 길이만큼 focus를 둘로 쪼개고, 커서를 pause 종료 시각으로 건너뜁니다. 이때 pause 길이는 순수 집중 예산에서 차감하지 않습니다.
또한 세션의 time_counted_down 필드로 이 예산을 임의로 증액하지 않습니다. 실제 세션 데이터 전수 조사 결과 time_counted_down은 세션 전체 길이에서 pause 합을 뺀 값과 1초 오차 범위 내에서 일치했습니다. 즉, 분 단위 pomodoro 기록이 미기록 시간이나 공백을 임의로 채우는 근거가 될 수는 없습니다.
1. 세션에 속하는 원천 행을 모은다
각 세션의 timerSessionId를 기준으로 관련 session_pauses와 pomodoros를 조회한 뒤 시작 시각순으로 정렬합니다. 길이가 양수(> 0)인 pause와 duration > 0인 pomodoro만 타임라인 복원 계산에 사용하며, 0분짜리 pomodoro는 집중 시간 복원의 근거로 사용하지 않습니다.
과거 데이터 중에는 외래키(timerSessionId)가 누락된 pomodoro도 일부 존재합니다. 백필 스크립트는 연결된 양수 pomodoro가 없는 집중 세션에 한해, 동일한 사용자 ID이면서 세션 시작 시각(start_time)과 밀리초 단위까지 정확히 일치하는 시작 시각을 가진 미연결 양수 pomodoro를 찾아 FK 보정 대상으로 수집합니다. 단순히 세션 시간 범위 내에 포함된다는 모호한 이유로는 절대 연결하지 않습니다.
반대로 이미 세션에 연결되어 있으나, 시작 시각이 동일한 사용자의 다른 세션 시작 시각과 일치하는 pomodoro는 경계 충돌 행으로 간주하여 복원 입력에서 제외합니다. 이 제외 처리는 원본 DB 행을 삭제하는 것이 아니라 복원 계산 대상에서만 배제하는 안전 조치입니다. feature/ai-report DB 반영 전 사전 검증에서는 FK 보정 대상 26건, 경계 제외 대상 3건이 정확히 검출되었으며, 수치가 달라질 경우 스크립트가 실행을 즉시 중단하도록 설계되었습니다. (세 경계 행의 상세 ID는 데이터베이스 적재 결과 참조)
이미 클라이언트에서 생성한 정상 segment가 존재하는 세션은 덮어쓰지 않고 온전히 보존합니다.
설계 딜레마: 시간 근접(±2분) 휴리스틱을 배제하고 밀리초 일치(26건)만 보정한 이유
초기 데이터 분석 당시, 외래키(timerSessionId)가 누락된 세션들을 복구하기 위해 세션 시작 전후 2분(±2분) 범위 내에 존재하는 pomodoro를 탐색하는 시간 근접 휴리스틱 연결 방식을 검토했습니다.
그러나 시간 근접성만으로 연결할 경우 심각한 정합성 왜곡이 발생할 수 있었습니다:
- 사용자가 이전 세션을 취소하고 곧바로 새로 시작했을 때 남은 잔여 pomodoro가 엉뚱한 세션에 결합될 위험
- 2분 탐색 창 내에 복수의 pomodoro가 존재할 때의 연결 모호성
따라서 스크립트는 동일한 사용자이면서 밀리초 단위까지 정확히 일치하는 26건만 안전하게 FK를 채우고, 시작 시각이 어긋나거나 근처에 행이 없는 30개 세션은 어떠한 카테고리나 Task도 자의적으로 추정하지 않은 채 owner = NULL인 순수 진행 구간으로 안전하게 격리 복원했습니다.
2. 집중 시간을 pause를 건너뛰며 배치 한다
각 pomodoro의 duration × 60,000ms를 우선적으로 배치할 집중 시간 예산으로 사용합니다. 예를 들어 duration = 20이면 1,200,000ms의 집중 시간을 확보하여 타임라인에 배치합니다. 실제 시계상에서 소요되는 시간은 중간에 위치한 일시정지 때문에 20분보다 길어질 수 있습니다.
placeFocusBudget 함수의 실행 절차는 다음과 같습니다.
- pomodoro의
startTime에서 배치를 시작합니다. 시작 시각이 일시정지(pause) 구간 내부라면 해당 pause가 종료되는 시각부터 배치를 시작합니다. - 다음 일시정지가 나타나기 전까지의 구간에 focus를 배치하고, 배치한 길이만큼 남은 집중 예산을 차감합니다.
- 중간에 일시정지를 만나면 pause 길이는 집중 예산에서 차감하지 않은 채 커서만 pause 종료 시각으로 이동시킵니다.
- 남은 집중 예산이 0이 될 때까지 focus를 이어서 배치합니다. 단, 세션 종료 시각(
endTime)을 초과하는 구간은 생성하지 않습니다.
원본 pomodoro에 기록되어 있던 카테고리 ID 및 Task 정보를 분할된 각 focus 조각에 매핑합니다. DB의 endTime은 생성 컬럼(Generated Column)이므로 실제 저장 시에는 startTime과 duration만 계산하여 전달합니다.
설계 딜레마: 고정 시간 창(Window) 탐색 시 발생하는 26분 예산 증발 문제
만약 복원 로 직이 pomodoro의 startTime부터 startTime + duration까지의 고정된 시간 창(Fixed Window) 안에서만 focus 구간을 찾으려 했다면 어떤 문제가 발생하는지 59f5f957-9e60-4e79-a103-e0425f60b6b7 세션으로 확인할 수 있습니다.
- 세션 실제 기록 상황:
- 세션 시작:
0초(Job Search 21분 집중 완료) - 긴 일시정지 발생:
1,304.632초 ~ 5,726.615초(약 73분간 지속) - 일시정지 도중 작업 전환: Implementation 26분(
duration = 1,560초),startTime = 1,336.509초
- 세션 시작:
과거 클라이언트는 일시정지 중 작업이 바뀌면 startTime만 해당 시각으로 기록하고, 실제 집중 시간은 일시정지가 끝난 뒤에 누적했습니다. 만약 startTime을 기준으로 고정된 시간 창 안에서만 focus를 찾았다면, 2,896.5초까지의 창 전체가 73분짜리 일시정지 내부에 갇혀 Implementation focus 행이 단 1줄도 생성되지 않는 치명적 결함이 발생했을 것입니다.
따라서 복원 로직은 startTime이 pause 안이라면 pause 종료 시각으로 즉시 이동하여 26분의 예산을 온전히 배치하도록 설계되었습니다.