본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
선택 심화 · 15장 : 변경 이력과 시간 모델

유효 기간 모델

업무 유효 기간을 반개구간으로 저장하고 부모 행 잠금과 겹침 쿼리로 경쟁 삽입을 직렬화합니다.

공지 게시글의 노출 우선순위가 7월에는 60, 8월부터 90이라면 값 하나를 UPDATE해서는 안 됩니다.

7월의 과거 게시판 화면을 다시 계산할 때 당시 정책이 필요하기 때문입니다.

여기서 묻는 시간은 행이 DB에 입력된 시각이 아니라 정책이 현실에서 효력을 갖는 기간입니다.

기간 모델의 버그는 대개 경계에서 생깁니다.

종료일을 포함한다고 생각한 쓰기 주체와 포함하지 않는 쿼리가 섞이면 하루가 비거나 두 정책이 동시에 선택됩니다.

이 문서는 [valid_from, valid_to) 반개구간 하나로 의미를 고정하고, 임의 길이 기간의 겹침을 INSERT 전에 검사합니다.

회원 게시판은 조회 날짜에 유효한 게시글 노출 정책을 선택합니다.

과거 게시판 화면은 조회일에 유효했던 대상을 사용해야 하며, 미래 예약 정책도 미리 등록할 수 있어야 합니다.

정책이 없는 날짜는 시스템 기본값을 쓰되 두 정책이 같은 날짜를 덮는 상태는 허용하지 않습니다.

MySQL 검사는 한 행의 시작·종료 순서를 검증할 수 있지만 다른 행과의 교차는 볼 수 없습니다.

따라서 게시글 부모를 잠금 토큰으로 사용해 같은 게시글의 기간 등록을 직렬화합니다.


겹치는 유효 기간

UNIQUE(post_id, valid_from)는 시작일이 같은 중복만 막습니다.

7월 1일에 시작한 기간과 7월 15일에 시작한 기간은 키가 다르므로 INSERT됩니다.

조회가 ORDER BY valid_from DESC LIMIT 1로 우연히 하나를 고르면 손상은 감춰집니다.

겹침을 허용하는 유효 기간 table
DROP TABLE IF EXISTS post_visibility_period_bad;
CREATE TABLE post_visibility_period_bad (
  policy_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  post_id BIGINT UNSIGNED NOT NULL,
  valid_from DATE NOT NULL,
  valid_to DATE NOT NULL,
  display_priority SMALLINT UNSIGNED NOT NULL,
  PRIMARY KEY (policy_id),
  UNIQUE KEY uq_visibility_bad_start (post_id, valid_from),
  CHECK (valid_to > valid_from)
) ENGINE=InnoDB;

INSERT INTO post_visibility_period_bad
  (post_id, valid_from, valid_to, display_priority)
VALUES
  (10, '2026-07-01', '2026-08-01', 60),
  (10, '2026-07-15', '2026-09-01', 90);

SELECT policy_id, display_priority
FROM post_visibility_period_bad
WHERE post_id = 10
  AND '2026-07-20' >= valid_from
  AND '2026-07-20' < valid_to;

SELECT a.policy_id AS left_policy, b.policy_id AS right_policy
FROM post_visibility_period_bad AS a
JOIN post_visibility_period_bad AS b
  ON b.post_id = a.post_id AND b.policy_id > a.policy_id
WHERE a.valid_from < b.valid_to AND b.valid_from < a.valid_to;

DDL 위반은 없지만 7월 20일 조회가 두 행을 반환합니다.

오류 실행 결과
as-of rows for 2026-07-20: 2
priorities returned: 60, 90
overlap pairs: (1, 2)
unique violations: 0
check violations: 0

기간 A와 B가 겹치는 정확한 조건은 A.start < B.end AND B.start < A.end입니다.

“새 시작점이 기존 기간 안에 있는가”만 검사하면 새 기간이 기존 기간 전체를 감싸는 경우를 놓칩니다.

두 방향을 대칭으로 비교해야 합니다.

검사 쿼리만 애플리케이션에서 실행해도 충분하지 않습니다.

작업자 A와 B가 동시에 빈 결과를 보고 서로 겹치는 정책을 넣을 수 있습니다.

실제 정책 행이 아직 없다면 잠글 대상도 없으므로 항상 존재하는 posts 부모 행을 FOR UPDATE로 잡습니다.

DATE를 선택한 이유와 자정의 의미

노출 정책은 사용자 현지 날짜에 적용되는 정책입니다.

이 예제는 게시글 정책의 기준 시간대를 UTC 업무일로 확정했으므로 DATE가 적절합니다.

지역별 정책이라면 zone_id를 식별 키에 포함하고 현지 날짜 변환을 API 입력 범위에서 한 번만 수행해야 합니다.


반개구간과 직렬화 지점

valid_to는 마지막 유효 날짜가 아니라 처음으로 유효하지 않은 날짜입니다.

따라서 8월 정책이 2026-08-01에 시작하면 7월 정책도 같은 값을 valid_to로 가집니다.

두 행 사이에 중복도 공백도 없습니다.

기간 배제 제약 조건이 없는 MySQL에서는 잠금과 겹침 검사, INSERT를 프로시저로 묶습니다.

직접 INSERT 권한은 애플리케이션 역할에서 회수합니다.

DB 소유자만 마이그레이션이나 복구 중에 테이블에 접근하고, 작업 뒤 전체 교차 검사를 실행합니다.

누락 구간을 오류로 볼지 정책 부재로 볼지 따로 결정하기

겹침 금지와 연속성은 다른 규칙입니다.

회원 게시판은 정책이 없는 날에 기본 노출 정책을 적용하므로 누락 구간을 허용합니다.

기본 노출 정책처럼 항상 한 행이 있어야 한다면 새 기간 등록 때 앞뒤 이웃과 경계가 맞는지도 검사해야 합니다.

MySQL 8.4 메모: SERIALIZABLE 전체 격리 대신 게시글 한 행을 명시적으로 잠급니다. 충돌 범위가 게시글 단위로 보여 운영자가 잠금 대기의 원인을 해석하기 쉽습니다.


유효 기간 등록

프로시저는 종료값을 NULL로 받지 않습니다.

9999년까지의 열린 정책도 명시적인 센티널 날짜로 저장해 비교식을 단순하게 유지합니다.

무한 기간을 NULL로 표현하려면 모든 쿼리에서 COALESCE 규칙을 일관되게 적용해야 합니다.

겹침 없는 post visibility period DDL과 등록 procedure
DROP PROCEDURE IF EXISTS add_post_visibility_period;
DROP TABLE IF EXISTS post_visibility_period;

CREATE TABLE post_visibility_period (
  policy_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  post_id BIGINT UNSIGNED NOT NULL,
  valid_from DATE NOT NULL,
  valid_to DATE NOT NULL,
  display_priority SMALLINT UNSIGNED NOT NULL,
  policy_reason VARCHAR(240) NOT NULL,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (policy_id),
  CONSTRAINT uq_post_visibility_start UNIQUE (post_id, valid_from),
  CONSTRAINT fk_post_visibility_post FOREIGN KEY (post_id)
    REFERENCES posts (id) ON DELETE RESTRICT,
  CONSTRAINT chk_post_visibility_period CHECK (valid_to > valid_from),
  CONSTRAINT chk_post_visibility_priority CHECK (display_priority BETWEEN 1 AND 100),
  INDEX ix_post_visibility_asof (post_id, valid_from, valid_to)
) ENGINE=InnoDB;

SET @post_id = (SELECT id FROM posts ORDER BY id LIMIT 1);
SET @author_id = (SELECT author_id FROM posts WHERE id = @post_id);

DELIMITER //
CREATE PROCEDURE add_post_visibility_period (
  IN p_post_id BIGINT UNSIGNED,
  IN p_valid_from DATE,
  IN p_valid_to DATE,
  IN p_display_priority SMALLINT UNSIGNED,
  IN p_reason VARCHAR(240)
)
BEGIN
  DECLARE v_post BIGINT UNSIGNED;
  DECLARE v_overlap BIGINT UNSIGNED;
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN
    ROLLBACK;
    RESIGNAL;
  END;

  START TRANSACTION;
  SELECT id INTO v_post
  FROM posts
  WHERE id = p_post_id
  FOR UPDATE;

  IF v_post IS NULL THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'post not found';
  END IF;
  IF p_valid_to <= p_valid_from THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'period must be non-empty';
  END IF;

  SELECT policy_id INTO v_overlap
  FROM post_visibility_period
  WHERE post_id = p_post_id
    AND valid_from < p_valid_to
    AND p_valid_from < valid_to
  ORDER BY valid_from
  LIMIT 1;

  IF v_overlap IS NOT NULL THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'visibility period overlaps existing period';
  END IF;

  INSERT INTO post_visibility_period
    (post_id, valid_from, valid_to, display_priority, policy_reason)
  VALUES
    (p_post_id, p_valid_from, p_valid_to, p_display_priority, p_reason);
  COMMIT;
END//
DELIMITER ;

CALL add_post_visibility_period(
  @post_id, '2026-07-01', '2026-08-01', 60,
  '7월 공지 게시글 기본 노출 우선순위'
);
CALL add_post_visibility_period(
  @post_id, '2026-08-01', '2026-09-01', 90,
  '8월 캠페인에 따른 노출 우선순위 상향'
);

두 정책은 8월 1일을 공유 경계로 삼지만 어느 날짜에도 함께 선택되지 않습니다.

개선 실행 결과
policies inserted: 2
overlap pairs: 0
rows at 2026-07-31: 1, priority=60
rows at 2026-08-01: 1, priority=90
overlapping insert: SIGNAL 45000

SELECT INTO가 0행일 때 로컬 변수는 NULL을 유지하도록 선언했습니다.

여러 겹침이 이미 존재하는 오염 스키마에서도 LIMIT 1만으로 등록을 거절합니다.

기존 손상은 마이그레이션 전에 별도 교차 쿼리로 모두 해소해야 합니다.

부모 잠금은 같은 게시글의 정책 등록을 순서대로 처리하지만 다른 게시글끼리는 병렬로 진행됩니다.

잠금 대기가 길다면 한 트랜잭션 안에 외부 API 호출이나 사용자 입력 대기를 넣었는지 먼저 확인합니다.

달력 끝값을 사람이 읽는 표기로 바꾸기

UI가 “7월 31일까지”라고 표시하려면 DATE_SUB(valid_to, INTERVAL 1 DAY)을 사용해 표현할 수 있습니다.

저장 의미를 시작·끝 포함 종료로 바꾸지 않습니다.

API는 validToExclusive처럼 이름에 경계 규칙을 넣으면 오해를 줄일 수 있습니다.


기간 겹침 검증

7월 31일과 8월 1일 조회는 경계값 하나 차이 오류를 찾습니다.

기존 기간을 완전히 감싸는 6월 1일~10월 1일 요청은 대칭 조건을 확인합니다.

마지막 자기 조인은 운영 일치 검사로 매일 실행할 수 있습니다.

effective date 경계와 오염 탐지 SQL
SELECT policy_id, display_priority
FROM post_visibility_period
WHERE post_id = @post_id
  AND '2026-07-31' >= valid_from
  AND '2026-07-31' < valid_to;

SELECT policy_id, display_priority
FROM post_visibility_period
WHERE post_id = @post_id
  AND '2026-08-01' >= valid_from
  AND '2026-08-01' < valid_to;

CALL add_post_visibility_period(
  @post_id, '2026-06-01', '2026-10-01', 30,
  '기존 두 기간을 감싸는 거절 fixture'
);

SELECT a.post_id, a.policy_id, b.policy_id
FROM post_visibility_period AS a
JOIN post_visibility_period AS b
  ON b.post_id = a.post_id
 AND b.policy_id > a.policy_id
WHERE a.valid_from < b.valid_to
  AND b.valid_from < a.valid_to;

EXPLAIN ANALYZE
SELECT display_priority
FROM post_visibility_period
WHERE post_id = @post_id
  AND '2026-08-12' >= valid_from
  AND '2026-08-12' < valid_to;

첫 두 쿼리는 서로 다른 정책을 정확히 한 건씩 반환합니다.

감싸는 기간 호출은 노출 정책이 기존 기간과 겹쳐 취소됩니다.

자기 조인 일치 검사는 0행이고 실행 계획은 post_idvalid_from 범위를 먼저 좁힙니다.

valid_to까지 한 번에 범위 최적화되지 않더라도 같은 게시글의 정책 수가 작다면 충분합니다.

정책이 수십만 건으로 증가한다면 기간 파티션보다 먼저 업무 집계 단위가 잘못됐는지 검토합니다.

수정은 UPDATE가 아니라 기존 기간 분할로 표현하기

7월 20일부터 노출 우선순위를 75로 바꾸려면 기존 7월 행을 20일에 닫고 [20일, 8월 1일) 행을 새로 넣습니다.

과거 19일까지의 의미가 그대로 남습니다.

두 구문은 게시글 잠금을 가진 하나의 트랜잭션이어야 합니다.

미래 예약을 취소할 때 남길 증거

아직 효력이 시작되지 않은 행도 DELETE만 하면 누가 왜 취소했는지 사라집니다.

policy_statuscancelled_at을 두거나 ch15-3 감사 이벤트를 남깁니다.

유효 기간과 관리 상태는 다른 축이므로 valid_to를 취소 시각으로 오용하지 않습니다.

백필 순서

기존 단일 대상 열에서 기간 테이블로 옮길 때 모든 게시글에 기준일~9999-12-31 행을 만듭니다.

기준 시점 결과와 기존 값을 비교한 뒤 읽기 주체를 전환하고 마지막에 이전 열을 제거합니다.


기간 테이블 도입 기준

판단 축확인할 질문
효력값이 DB 입력 시각과 다른 날짜부터 적용될 수 있는가?
과거이전 정책을 사용해 보고서나 청구를 다시 계산해야 하는가?
공백정책이 없는 날짜를 허용하며 그때 사용할 기본값은 무엇인가?
경쟁같은 소유자에 미래 기간을 동시에 예약하는 쓰기 주체가 여럿인가?
시간대DATE 경계가 어느 지역의 달력으로 해석되는가?

단순 설정값이고 과거 재계산이 없다면 현재 열 하나가 더 낫습니다.

효력 예약과 과거 설명이 필요하면 기간 행이 적합합니다.

모델을 채택할 때 시작·끝 포함/끝 제외 표기, 빈 구간 처리, 직접 INSERT 권한을 설계 문서에 고정합니다.


연습 문제

7월 노출 우선순위 60을 7월 20일부터 75로 바꾸는 분할 프로시저를 작성하세요.

기존 행을 잠그고 종료일을 줄인 뒤 나머지 구간을 삽입해야 합니다.

7월 19일과 20일 조회가 각각 60, 75를 돌려줘야 합니다.

해설과 예시 답안

변경 대상 정책을 FOR UPDATE로 읽고 분할 시각이 기존 구간 내부인지 확인합니다.

원래 valid_to를 로컬 변수에 보존한 뒤 UPDATE와 INSERT를 같은 트랜잭션에 둡니다.

DROP PROCEDURE IF EXISTS split_post_visibility_period;
DELIMITER //
CREATE PROCEDURE split_post_visibility_period (
  IN p_policy_id BIGINT UNSIGNED,
  IN p_split_on DATE,
  IN p_new_priority SMALLINT UNSIGNED,
  IN p_reason VARCHAR(240)
)
BEGIN
  DECLARE v_post BIGINT UNSIGNED;
  DECLARE v_from DATE;
  DECLARE v_to DATE;
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN ROLLBACK; RESIGNAL; END;

  START TRANSACTION;
  SELECT post_id, valid_from, valid_to
    INTO v_post, v_from, v_to
  FROM post_visibility_period
  WHERE policy_id = p_policy_id
  FOR UPDATE;

  IF p_split_on <= v_from OR p_split_on >= v_to THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'split date outside policy';
  END IF;

  UPDATE post_visibility_period
  SET valid_to = p_split_on
  WHERE policy_id = p_policy_id;

  INSERT INTO post_visibility_period
    (post_id, valid_from, valid_to, display_priority, policy_reason)
  VALUES (v_post, p_split_on, v_to, p_new_priority, p_reason);
  COMMIT;
END//
DELIMITER ;

SET @july_policy = (
  SELECT policy_id FROM post_visibility_period
  WHERE post_id = @post_id AND valid_from = '2026-07-01'
);
CALL split_post_visibility_period(
  @july_policy, '2026-07-20', 75, '캠페인 일정 변경에 따른 노출 우선순위 조정'
);

겹침 일치 검사는 여전히 0행이어야 합니다.

7월 19일 기준 시점은 60, 7월 20일 기준 시점은 75를 한 건씩 반환하고 8월 1일은 기존 90 정책을 선택합니다.


핵심 정리

  • 효력 날짜는 저장 시각이 아니라 업무에서 값이 적용되는 달력 구간입니다.
  • [start, end) 표기는 인접 기간의 공유 경계를 중복 없이 처리합니다.
  • 임의 기간 겹침은 검사나 단순 UNIQUE만으로 차단되지 않습니다.
  • 항상 존재하는 부모 행을 잠그면 빈 기간 집합의 첫 경쟁 INSERT도 직렬화됩니다.

다음 문서에서는 변경 사실 자체를 수정할 수 없게 만드는 추가 전용 감사 원장을 설계합니다.