완료된 세션의 일시정지를 개별 행으로 분리한 원리
[!NOTE] FE 맥락 보존 및 향후 AI API 연계 (1단계) 본 작업은 향후 AI API 등을 활용한 분석 기능 확장을 염두에 두고, 프론트엔드(FE)에서 실제로 발생한 일시정지 맥락을 데이터베이스에 온전히 전달·저장하기 위해 진행되었습니다. 기존 세션 1개 행 내부에 JSONB 배열(
pause.record)로 뭉뚱그려져 있던 일시정지 내역을 독립된 하위 테이블(session_pauses)의 개별 행으로 정규화하여, "언제 어떤 일시정지가 발생했는가"에 대한 정확한 시간축과 맥락을 확보했습니다.
기존 데이터 모델에서는 완료된 세션 1개 행 내부에 JSONB 배열(pause.record) 형태로 일시정지 내역이 중첩 저장되어 있었습니다. 이 문서는 중첩된 일시정지 데이터를 상위 세션 테이블(timer_sessions)과 독립적인 하위 일시정지 테이블(session_pauses)의 개별 행으로 정규화하여 분리·적재한 마이그레이션 절차를 정리합니다.
배경 및 정규화 목적
과거 레거시 스키마에서는 timer_sessions 1개 행에 pause JSONB 컬럼이 포함되어 있었으며, 내부에 { totalLength: number, record: [{ start, end }] } 형태로 일시정지 구간이 저장되었습니다.
{
"totalLength": 180000,
"record": [
{ "start": 1710000000000, "end": 1710000060000 },
{ "start": 1710000120000, "end": 1710000240000 }
]
}
이러한 비정규화 중첩 구조는 다음과 같은 한계가 있었습니다:
- 인덱싱 및 필터링 제약: 일시정지 구간이 독립 행이 아니므로, 특정 길이 이상의 일시정지나 기간별 집계를 하려면 매번 JSONB 배열을 풀어서(Unnesting) 스캔해야 했습니다.
- 집계 및 외래키 부재: 일시정지 구간 자체에 대한 제약조건이나 세션과의 관계 무결성을 DB 엔진 차원에서 보장하기 어려웠습니다.
따라서 세션과 일시정지를 표준적인 1:N 관계의 독립 테이블로 정규화했습니다.
[!NOTE] 테이블 명칭 이력 마이그레이션 작업 당시에는 데이터 검증을 위해 신규 테이블을
timer_sessions_new로 생성하고 원본을timer_sessions로 유지했습니다. 이관 및 전수 검증이 완료된 직후 원본은timer_sessions_old(백업용 보존)로, 신규 테이블은timer_sessions로 이름을 변경했습니다. 기존 세션의 UUID는 그대로 승계하여pomodoros.timer_session_id등 외래키 참조를 안전하게 유지했습니다.
정규화 스키마 설계 및 제약조건
신규 테이블은 Drizzle 스키마를 통해 생성되었습니다. 일시정지 구간 길이(duration)는 DB 엔진 레벨의 GENERATED ALWAYS 가상/저장 컬럼으로 선언하여 불일치 가능성을 원천 차단했습니다.
도착 테이블 DDL 및 인덱스 정의 (session_pauses & timer_sessions)
CREATE TABLE "session_pauses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"timer_session_id" uuid NOT NULL,
"start_time" bigint NOT NULL,
"end_time" bigint NOT NULL,
"duration" bigint GENERATED ALWAYS AS (end_time - start_time) STORED NOT NULL,
"reason" text,
CONSTRAINT "session_pauses_end_after_start" CHECK ("end_time" >= "start_time"),
CONSTRAINT "session_pauses_timer_session_id_fk"
FOREIGN KEY ("timer_session_id") REFERENCES "timer_sessions"("id") ON DELETE CASCADE
);
CREATE TABLE "timer_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"kind" timer_session_kind NOT NULL,
"start_time" bigint NOT NULL,
"end_time" bigint NOT NULL,
"planned_duration" bigint,
"edited_duration" bigint DEFAULT 0,
"time_counted_down" bigint NOT NULL,
"total_pause_length" bigint DEFAULT 0 NOT NULL,
"is_early_ended" boolean DEFAULT false NOT NULL,
"early_end_reason" text,
CONSTRAINT "timer_sessions_end_after_start" CHECK ("end_time" >= "start_time"),
CONSTRAINT "timer_sessions_planned_duration_nonnegative" CHECK ("planned_duration" >= 0),
CONSTRAINT "timer_sessions_target_duration_nonnegative" CHECK ("planned_duration" + "edited_duration" >= 0),
CONSTRAINT "timer_sessions_countdown_nonnegative" CHECK ("time_counted_down" >= 0),
CONSTRAINT "timer_sessions_pause_length_nonnegative" CHECK ("total_pause_length" >= 0),
CONSTRAINT "timer_sessions_early_end_reason_valid" CHECK ("is_early_ended" OR "early_end_reason" IS NULL)
);
CREATE UNIQUE INDEX "session_pauses_session_id_start_time_index"
ON "session_pauses" ("timer_session_id", "start_time");
삼치 논리(Three-valued logic)를 활용한 레거시 호환
신규 스키마의 CHECK 제약조건들은 PostgreSQL의 삼치 논리 특성을 활용하여 설계되었습니다:
planned_duration >= 0및planned_duration + edited_duration >= 0: 컬럼 값이NULL이면 조건식의 평가 결과가UNKNOWN이 되므로 제약조건 위반(FALSE)으로 판정되지 않고 정상 통과합니다.- 따라서 향후 입력되는 신규 데이터의 음수 저장은 엄격히 차단하면서도, 과거 레거시 행의 알 수 없는 값은
NULL상태 그대로 안전하게 보존할 수 있습니다.
사전 데이터 단위 보정 (4대 그룹 분류)
time_counted_down은 밀리초 단위로 저장되어야 하며, 정상 완료 세션의 경우 실제 벽시계 집중 시간인 focus_wall = end_time - start_time - pause.totalLength와 일치해야 합니다.
백필 직전 레거시 세션 2,108건을 focus_wall과 전수 대조한 결과, 4개 그룹으로 분류되었습니다:
| 데이터 그룹 | 건수 | 원인 및 처리 방식 |
|---|---|---|
time_counted_down = focus_wall | 1,198개 | 이미 밀리초 단위로 정상 기록된 세션. 원본 값 유지 |
값이 1,000 미만이고 값 * 60,000 = focus_wall | 107개 | 과거 클라이언트 buildSessionData 유틸리티의 분 단위 대입 버그. * 60,000 곱하여 밀리초로 사전 보정 |
값 * 1,000 = focus_wall (초 단위) | 4개 | 초기 테스트 시드 세션(25분, 30분, 30분, 5분). * 1,000 곱하여 보정 |
focus_wall과의 차이가 1ms ~ 1,000ms | 799개 | 타이머 도중 End 버튼을 눌러 중단한 세션. 값 유지 및 is_early_ended = true 마킹 |