변경 회차 기반 이력 모델링: from/to 구간으로 Point-in-Time 조회를 빠르게 만들기
서비스를 운영하다 보면 “**특정 변경 회차(change round)**의 특정 시점(point) 기준으로 데이터를 조회”해야 하는 요구가 자주 생깁니다.
감사(Audit), 정산/회계 기준 스냅샷, 장애 분석(당시 상태 재현), 권한/조직도 변경 이력 등에서 특히 많이 등장합니다.
이번 글에서는 이력 테이블에 from/to(유효 구간, validity interval) 를 적용해,
point가 from ~ to 사이에 포함되는 레코드를 바로 찾는 방식으로 Point-in-Time 조회를 효율화한 데이터 모델링을 정리합니다.
핵심 요약 (TL;DR)
- 이력을 “버전 번호/최신 1건”으로 찾는 대신, 레코드마다 유효 구간(
from,to)을 부여합니다. - “특정 시점(point)” 조회는 결국 아래 조건으로 끝납니다.
WHERE point >= valid_from
AND point < valid_to
문제: “그 시점의 상태”를 매번 비싼 쿼리로 찾게 되는 구조
이력 모델링을 단순화하면 보통 아래 두 방식이 많이 쓰입니다.
- 변경 시점(
updated_at)만 저장하고, 조회 시 “point 이전의 최신 1건”을 찾는 방식 - 버전(version)만 저장하고, “point에 해당하는 버전”을 역으로 계산해서 찾는 방식
하지만 이런 방식은 조회가 다음 형태로 흘러가며 점점 비싸지기 쉽습니다.
point 이전의 최신 1건을 찾기 위해ORDER BY updated_at DESC LIMIT 1- 또는
MAX(version)서브쿼리 - 또는 그룹핑/정렬이 들어가는 패턴
데이터가 커질수록 이런 쿼리는:
- 불필요한 정렬(filesort)
- 넓은 범위 스캔
- 서브쿼리 비용 증가 로 이어지기 쉽습니다.
해결: from/to 구간(Validity Interval)로 “point 포함”을 직접 표현
핵심 아이디어는 단순합니다.
“어떤 이력 레코드는 언제부터(valid_from) 언제까지(valid_to) 유효하다”를 데이터 자체로 표현합니다.
그러면 point-in-time 조회는 “그 point가 포함되는 구간”을 찾는 문제가 됩니다.
유효 구간 규칙(권장)
valid_from: 포함(inclusive)valid_to: 미포함(exclusive)- 이 규칙이 가장 안전합니다. 경계에서 중복/누락을 피하기 좋습니다.
- 최신 레코드는
valid_to = +∞(예:'9999-12-31 23:59:59') 로 열어둡니다. - 같은 대상(entity)에 대해 유효 구간은 겹치지 않고, 필요하면 연속이 되도록 유지합니다.
데이터 모델(예시)
아래는 “변경 회차(change_round)”와 “point(시점)”를 함께 쓰는 모델 예시입니다.
CREATE TABLE entity_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
entity_id BIGINT NOT NULL, -- 이력 대상 (문서/정책/권한/조직 등)
change_round BIGINT NOT NULL, -- 변경 회차 (배포/정산회차/승인회차 등)
valid_from DATETIME(3) NOT NULL, -- 유효 시작 (inclusive)
valid_to DATETIME(3) NOT NULL, -- 유효 종료 (exclusive)
-- 이력 내용 (예: 상태, 금액, 권한, JSON payload 등)
payload JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX ix_round_entity_from (change_round, entity_id, valid_from),
INDEX ix_entity_from (entity_id, valid_from)
);
포인트: 조회 패턴에 맞춰 인덱스를 설계합니다.
“회차 + 대상 + from” 형태로 범위를 빨리 좁히는 것이 핵심입니다.
Point-in-Time 조회 쿼리
1) 특정 변경 회차에서, 특정 시점(point)의 상태 1건 조회
SELECT *
FROM entity_history
WHERE change_round = :round
AND entity_id = :entityId
AND valid_from <= :point
AND :point < valid_to
ORDER BY valid_from DESC
LIMIT 1;
valid_from <= point조건으로 범위 스캔이 가능해지고ORDER BY valid_from DESC LIMIT 1로 가장 최근 유효 시작 구간 1건만 즉시 찾습니다.valid_to는 최종 확인 조건이므로, 보통 스캔 중단이 빨라집니다(특히 구간이 잘 유지되면 더 효과적).
2) 특정 시점(point)의 전체 스냅샷(회차 단위) 조회
SELECT *
FROM entity_history
WHERE change_round = :round
AND valid_from <= :point
AND :point < valid_to;
- 회차 기준 스냅샷 복원이 필요할 때 유용합니다.
쓰기(UPDATE) 시 구간을 어떻게 유지하나?
이 모델의 핵심은 구간 정합성입니다. 업데이트 시에는 아래 두 단계가 한 트랜잭션으로 묶입니다.
- 기존 “열려 있는 최신 레코드”의
valid_to를 닫기 - 새로운 레코드를
valid_from = now(point),valid_to = +∞로 추가하기
-- (트랜잭션 시작)
UPDATE entity_history
SET valid_to = :point
WHERE entity_id = :entityId
AND change_round = :round
AND valid_to = '9999-12-31 23:59:59.999';
INSERT INTO entity_history(entity_id, change_round, valid_from, valid_to, payload)
VALUES (:entityId, :round, :point, '9999-12-31 23:59:59.999', :payload);
-- (트랜잭션 커밋)
동시성 환경에서는 “최신 레코드 1건만 열려 있다”는 불변식을 깨지 않도록
필요한 경우 대상(entity_id)에 대해 잠금/조건 업데이트(낙관적 락)를 함께 고려합니다.
장단점 및 트레이드오프
장점
- Point-in-Time 조회가 쿼리/실행계획 관점에서 단순해지고 빨라집니다.
- “그 시점의 상태”를 복원하는 로직이 명확해집니다(조건이 직관적).
- 회차/시점 기반 감사, 스냅샷, 롤백 시나리오에 잘 맞습니다.
단점/주의점
- 쓰기 로직이 약간 복잡해집니다(구간 닫기 + 새 구간 열기).
마무리
정리하면, “이력”을 단순히 updated_at이나 version으로만 추적하면
Point-in-Time 조회가 점점 비싸지고 복잡해질 수 있습니다.
반대로 from/to 유효 구간을 데이터 모델에 녹이면,
“point가 구간에 포함된다”는 사실 하나로 효율적인 조회 경로를 만들 수 있습니다.
- 변경 회차 기반 이력 모델링을 제안했고
- PoC로 실행계획과 조회 패턴의 단순화를 검증했습니다.