본문 바로가기
Case Studies (실무)

변경 회차 기반 이력 모델링: 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

문제: “그 시점의 상태”를 매번 비싼 쿼리로 찾게 되는 구조

이력 모델링을 단순화하면 보통 아래 두 방식이 많이 쓰입니다.

  1. 변경 시점(updated_at)만 저장하고, 조회 시 “point 이전의 최신 1건”을 찾는 방식
  2. 버전(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) 시 구간을 어떻게 유지하나?

이 모델의 핵심은 구간 정합성입니다. 업데이트 시에는 아래 두 단계가 한 트랜잭션으로 묶입니다.

  1. 기존 “열려 있는 최신 레코드”의 valid_to를 닫기
  2. 새로운 레코드를 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로 실행계획과 조회 패턴의 단순화를 검증했습니다.