이력 테이블 설계와 SQL 조회: 유효기간 경계·과거 급여·중복 구간 처리


1장. 3월 급여는 330이었나, 340이었나#

직원 7의 급여가 다음과 같이 변경됐다고 하겠습니다.

1월 1일부터
300

3월 1일부터
330

6월 1일부터
360

4월 10일이 되었습니다.

인사 담당자가 말합니다.

3월 급여를 잘못 입력했습니다. 실제로는 3월 1일부터 340이었습니다.

시스템에서 330을 340으로 수정했습니다.

이제 질문을 해보겠습니다.

3월 20일의 정확한 급여는 얼마인가?

현재 알고 있는 사실을 기준으로 하면:

340

입니다.

그런데 다른 질문은 어떨까요?

4월 1일에 작성했던 보고서에서는 3월 급여를 얼마로 알고 있었는가?

당시 시스템에는 정정 전 값인:

330

이 기록되어 있었습니다.

따라서 두 질문의 정답은 다릅니다.

이력 설계의 핵심은 단순히 과거 데이터를 많이 저장하는 것이 아닙니다.

어느 시간의 관점에서 과거를 조회하려는 것인가?

를 구분하는 것입니다.


2장. 현재값 하나만 저장하면 과거가 사라진다#

직원 테이블에 다음 열만 있다고 하겠습니다.

employee

emp_id = 7
salary = 360

현재 급여는 쉽게 알 수 있습니다.

하지만 다음 질문에는 답하기 어렵습니다.

2월 급여는 얼마였는가?

4월 급여는 얼마였는가?

급여가 언제 변경됐는가?

현재값만 저장하면 과거 상태가 사라집니다.

그래서 시간에 따라 값이 바뀌는 데이터에는 이력 모델이 필요할 수 있습니다.


3장. 모든 컬럼을 이력화할 필요는 없다#

직원 데이터에 다음 속성이 있다고 하겠습니다.

이름

급여

부서

마지막 로그인 시각

모든 변경을 동일한 방식으로 영구 보존해야 하는 것은 아닙니다.

예를 들어:

급여
→ 과거 정산·감사 때문에 이력 필요

부서
→ 조직 변경 분석 때문에 이력 필요

마지막 로그인
→ 현재값만 필요할 수도 있음

입니다.

이력 설계는 먼저:

어떤 과거 질문에 답해야 하는가?

부터 정해야 합니다.


4장. 이력을 표현하는 대표적인 두 가지 방식#

이력은 크게 두 방식으로 생각할 수 있습니다.

변경 사건 모델#

1월 1일
급여 300으로 변경

3월 1일
급여 330으로 변경

6월 1일
급여 360으로 변경

변경이 발생한 시점을 저장합니다.

유효 구간 모델#

1월 1일 ~ 3월 1일
300

3월 1일 ~ 6월 1일
330

6월 1일 ~ 현재
360

값이 적용되는 기간을 직접 저장합니다.

둘 다 장단점이 있습니다.


5장. 변경 사건 테이블부터 만들어 보자#

CREATE TABLE salary_event (
    event_id bigint PRIMARY KEY,
    emp_id integer NOT NULL,
    effective_at timestamptz NOT NULL,
    salary integer NOT NULL CHECK (salary >= 0),
    UNIQUE (emp_id, effective_at)
);

데이터:

INSERT INTO salary_event
VALUES
    (
        1,
        7,
        '2026-01-01 00:00+00',
        300
    ),
    (
        2,
        7,
        '2026-03-01 00:00+00',
        330
    ),
    (
        3,
        7,
        '2026-06-01 00:00+00',
        360
    );

6장. 4월 15일의 급여를 조회하자#

질문:

2026-04-15 12:00 UTC
직원 7의 급여는?

해당 시점보다 이전에 발생한 변경 중 가장 최근 값을 찾으면 됩니다.

SELECT
    salary
FROM salary_event
WHERE emp_id = 7
  AND effective_at
      <= '2026-04-15 12:00+00'
ORDER BY effective_at DESC
LIMIT 1;

결과:

330

입니다.


7장. 사건 모델은 구조가 단순하다#

변경이 있을 때마다:

새로운 이벤트 한 행 추가

하면 됩니다.

예:

1월 1일
300

3월 1일
330

6월 1일
360

입니다.

종료 시각을 직접 관리하지 않아도 됩니다.


8장. 하지만 조회할 때마다 가장 최근 사건을 찾아야 한다#

4월 15일을 조회하려면:

4월 15일 이전 사건
↓
가장 최근 사건

을 찾아야 합니다.

대량 데이터에서는 적절한 인덱스가 중요합니다.

예를 들어:

CREATE INDEX idx_salary_event_emp_time
ON salary_event (
    emp_id,
    effective_at DESC
);

같은 인덱스를 검토할 수 있습니다.


9장. 하루 단위 키만 사용하면 하루 두 번 변경을 표현하지 못할 수 있다#

다음 키를 사용한다고 하겠습니다.

PRIMARY KEY (
    emp_id,
    change_date
)

그런데 같은 날:

09:00
급여 수정

14:00
재수정

이 발생하면 같은 날짜에 두 이력을 넣지 못할 수 있습니다.

그래서 업무 정밀도에 따라:

date

timestamp

event_id

중 어떤 식별 방식이 필요한지 결정해야 합니다.


10장. 같은 유효 시각에 두 사건을 허용할지도 정해야 한다#

다음 두 이벤트가 있다고 하겠습니다.

2026-03-01 00:00
330

2026-03-01 00:00
340

둘 다 존재한다면 질문이 생깁니다.

3월 1일 00시에 어느 급여가 적용되는가?

따라서:

같은 effective_at 중복 금지

또는:

우선순위 컬럼

수정 버전

기록 시각

같은 추가 규칙이 필요합니다.


11장. 이제 기간 모델을 만들어 보자#

이번에는 각 급여가 유효한 기간을 직접 저장합니다.

CREATE TABLE salary_period (
    emp_id integer NOT NULL,
    valid_from timestamptz NOT NULL,
    valid_to timestamptz,
    salary integer NOT NULL CHECK (salary >= 0),

    PRIMARY KEY (
        emp_id,
        valid_from
    ),

    CHECK (
        valid_to IS NULL
        OR valid_from < valid_to
    )
);

데이터:

INSERT INTO salary_period
VALUES
    (
        7,
        '2026-01-01 00:00+00',
        '2026-03-01 00:00+00',
        300
    ),
    (
        7,
        '2026-03-01 00:00+00',
        '2026-06-01 00:00+00',
        330
    ),
    (
        7,
        '2026-06-01 00:00+00',
        NULL,
        360
    );

12장. 기간을 어떻게 포함할지 먼저 정해야 한다#

첫 번째 기간:

1월 1일
~
3월 1일

두 번째 기간:

3월 1일
~
6월 1일

입니다.

만약 양쪽 끝을 모두 포함하면:

3월 1일 00:00

이 두 기간에 동시에 포함될 수 있습니다.

이 문제를 피하기 위해 자주 사용하는 방식이:

[시작, 종료)

입니다.


13장. 시작 포함·종료 제외 반개구간#

표현:

[2026-01-01, 2026-03-01)

의 의미는:

1월 1일
포함

3월 1일
제외

입니다.

다음 구간:

[2026-03-01, 2026-06-01)

에서는 3월 1일을 포함합니다.

따라서 정확히 경계 시각인:

2026-03-01 00:00

에는 두 번째 급여 330만 적용됩니다.


14장. 경계 조회 SQL#

SELECT
    salary
FROM salary_period
WHERE emp_id = 7
  AND valid_from
      <= '2026-03-01 00:00+00'
  AND (
        valid_to
            > '2026-03-01 00:00+00'
        OR valid_to IS NULL
      );

결과:

330

한 행입니다.


15장. 종료 조건에 >=를 사용하면 경계가 중복될 수 있다#

잘못된 예:

valid_from <= target
AND valid_to >= target

3월 1일 00:00에:

첫 번째 구간:

valid_to = 3월 1일

이므로 포함됩니다.

두 번째 구간:

valid_from = 3월 1일

도 포함됩니다.

결과적으로:

300

330

두 행이 나올 수 있습니다.

그래서 반개구간에서는:

target < valid_to

를 사용합니다.


16장. 경계 테스트는 세 시점을 조회해야 한다#

변경 시각:

2026-03-01 00:00

이라고 하겠습니다.

다음 세 시점을 시험합니다.

직전#

2026-02-28 23:59:59.999...

예상:

300

정확한 경계#

2026-03-01 00:00

예상:

330

직후#

2026-03-01 00:00:00.001

예상:

330

이 테스트를 통과해야 기간 규칙이 제대로 구현됐다고 볼 수 있습니다.


17장. 날짜 데이터라면 하루 빼기로 처리하고 싶어질 수 있다#

예:

이전 급여 종료일
2월 28일

새 급여 시작일
3월 1일

처럼 저장할 수 있습니다.

하지만 시간 단위가 분·초·밀리초까지 필요해지면:

종료 시각
=
시작 시각 - 1초

같은 방식은 위험해집니다.

시간 정밀도가 바뀔 때마다 규칙이 흔들리기 때문입니다.


18장. 반개구간은 정밀도에 덜 의존한다#

[09:00, 12:00)

[12:00, 17:00)

처럼 두 기간을 맞붙이면:

11:59:59

11:59:59.999999

12:00

같은 시간 정밀도 문제를 별도로 계산할 필요가 줄어듭니다.

그래서 시간 구간 모델에서 [start, end) 표현이 자주 사용됩니다.


19장. 현재 이력의 종료를 NULL로 둘 수 있다#

현재 급여:

360

은 아직 종료 시점을 모릅니다.

따라서:

valid_to = NULL

로 둘 수 있습니다.

조회에서는:

valid_to > target
OR valid_to IS NULL

처럼 처리합니다.


20장. NULL 대신 먼 미래 날짜를 사용할 수도 있다#

예:

9999-12-31

을 종료값으로 사용할 수도 있습니다.

장점:

단순 비교 가능

단점:

실제 날짜처럼 보임

외부 시스템 범위 문제

임의의 마법 값

이 있습니다.

NULL과 최대 날짜 중 어느 방식을 사용할지는 DBMS 기능과 인터페이스 요구에 따라 결정합니다.


21장. 기본키와 CHECK만으로 기간 중복은 막을 수 없다#

현재 제약:

PRIMARY KEY (
    emp_id,
    valid_from
)

CHECK (
    valid_from < valid_to
)

가 있습니다.

그래도 다음 행은 들어갈 수 있습니다.

직원 7

2026-04-01
~
2026-07-01

급여 350

기존에는:

3월 1일
~
6월 1일

330

이 있습니다.

두 기간이 겹칩니다.


22장. 중복 구간이 생기면 한 시점에 두 값이 나온다#

4월 15일 조회:

기존:

330

신규 겹침:

350

두 행 모두 조건을 만족합니다.

결과:

330

350

입니다.

업무가:

한 시점에 급여는 반드시 하나다.

라고 정의한다면 심각한 무결성 오류입니다.


23장. 기간 중복 조건을 수식으로 이해해 보자#

기간 A:

[a_start, a_end)

기간 B:

[b_start, b_end)

두 기간이 겹치는 대표 조건은:

a_start < b_end

AND

b_start < a_end

입니다.

종료가 무한대인 경우에는 별도의 처리도 필요합니다.


24장. SQL로 중복 기간을 찾아볼 수 있다#

SELECT
    a.emp_id,
    a.valid_from AS a_from,
    a.valid_to AS a_to,
    b.valid_from AS b_from,
    b.valid_to AS b_to
FROM salary_period AS a
JOIN salary_period AS b
  ON a.emp_id = b.emp_id
 AND a.valid_from < COALESCE(
        b.valid_to,
        'infinity'::timestamptz
     )
 AND b.valid_from < COALESCE(
        a.valid_to,
        'infinity'::timestamptz
     )
 AND a.valid_from < b.valid_from;

같은 직원의 겹치는 기간 후보를 찾을 수 있습니다.


25장. 자기 자신과의 비교를 제거해야 한다#

같은 테이블을 자기 자신과 조인하면 각 행은 자기 자신과도 겹칩니다.

그래서:

a.valid_from < b.valid_from

같은 조건을 사용해 한 방향의 쌍만 비교할 수 있습니다.

또는 별도의 이력 ID를 사용해:

a.history_id < b.history_id

로 비교할 수도 있습니다.


26장. PostgreSQL 범위 타입을 활용할 수도 있다#

PostgreSQL은 시간 범위를 표현할 수 있습니다.

예:

tstzrange(
    valid_from,
    valid_to,
    '[)'
)

입니다.

[)는:

시작 포함

종료 제외

를 의미합니다.


27장. 범위 연산자로 중복을 찾을 수 있다#

개념적으로:

tstzrange(
    a.valid_from,
    a.valid_to,
    '[)'
)
&&
tstzrange(
    b.valid_from,
    b.valid_to,
    '[)'
)

의 &&는 두 범위가 겹치는지를 확인합니다.

기간 로직을 직접 비교식으로 반복 작성하는 부담을 줄일 수 있습니다.


28장. PostgreSQL에서는 중복 자체를 제약으로 막는 방법도 있다#

업무 규칙:

같은 직원에게 겹치는 급여 유효기간을 허용하지 않는다.

를 DB 제약으로 표현할 수 있습니다.

예:

CREATE EXTENSION IF NOT EXISTS btree_gist;

그 뒤 범위 열을 사용하는 구조라면 제외 제약을 검토할 수 있습니다.

개념적으로:

EXCLUDE USING gist (
    emp_id WITH =,
    valid_period WITH &&
);

입니다.

같은 직원의 기간이 겹치면 삽입을 거부합니다.


29장. 중복이 없다는 것과 빈 기간이 없다는 것은 다르다#

다음 이력이 있다고 하겠습니다.

1월 1일 ~ 3월 1일
300

4월 1일 ~ 6월 1일
330

기간은 겹치지 않습니다.

하지만:

3월 1일 ~ 4월 1일

에는 아무 급여도 없습니다.

즉:

중복 없음

과:

공백 없음

은 다른 무결성 규칙입니다.


30장. 공백을 허용할지 여부도 업무 결정이다#

급여라면 일반적으로 재직 기간 동안:

항상 하나의 급여가 있어야 한다

고 정할 수 있습니다.

반면 할인 정책은:

할인이 없는 기간

이 정상일 수 있습니다.

따라서 이력 테이블에서 모든 기간을 연속시키는 것이 항상 정답은 아닙니다.


31장. 상품 가격도 같은 문제를 가진다#

상품 P1:

9월 1일 00:00
500원

9월 10일 00:00
600원

이라고 하겠습니다.

기간:

[9월 1일, 9월 10일)
500

[9월 10일, 무한대)
600

로 만들면 정확히:

9월 10일 00:00

부터 600이 적용됩니다.


32장. 그런데 9월 8일에 소급 정정이 들어왔다#

9월 8일 담당자가 말합니다.

실제로 9월 5일부터 가격은 550원이었습니다.

현재 알고 있는 사실을 기준으로 보면:

9월 1일 ~ 9월 5일
500

9월 5일 ~ 9월 10일
550

9월 10일 이후
600

이어야 합니다.

기존 기간을 분리해야 합니다.


33장. 소급 정정은 기존 한 행을 여러 구간으로 나눌 수 있다#

기존:

[9월 1일, 9월 10일)
500

정정 후:

[9월 1일, 9월 5일)
500

[9월 5일, 9월 10일)
550

이 됩니다.

그리고:

[9월 10일, ∞)
600

은 그대로 유지됩니다.


34장. 이 구조로 현재 관점의 과거는 정확하게 조회할 수 있다#

오늘 9월 6일 가격을 다시 조회하면:

550

입니다.

현재 알고 있는 사실을 기준으로:

9월 6일에 실제로 적용됐어야 하는 가격

을 보여주는 것입니다.


35장. 하지만 9월 6일 당시 시스템이 알고 있던 가격은 복원할 수 없다#

정정이 들어온 날은:

9월 8일

입니다.

9월 6일 당시 시스템은 아직:

500

이라고 알고 있었습니다.

현재 유효기간 테이블을 550으로 고쳐버리면:

9월 6일 당시 시스템이 무엇을 알고 있었는가?

라는 질문에 답할 수 없습니다.

여기서 두 번째 시간 축이 등장합니다.


36장. 유효 시각과 기록 시각은 다르다#

유효 시각#

업무상 이 값이 언제부터 사실인가?

예:

2026-03-01
급여 340 적용

기록 시각#

시스템이 언제 이 사실을 알게 되었는가?

예:

2026-04-10
정정 입력

두 시각은 같을 수도 있고 다를 수도 있습니다.


37장. 소급 정정에서 두 시간을 표로 보면#

항목 시각
실제 적용 시작 3월 1일
잘못된 값 330 입력 이전 시점
정정 340 입력 4월 10일

따라서:

현재 기준 3월 급여
340

이지만:

4월 1일 당시 시스템이 알고 있던 3월 급여
330

입니다.


38장. 두 시간축을 모두 보존하는 모델을 바이템포럴 모델이라고 부를 수 있다#

개념적으로 다음 두 기간을 관리합니다.

Valid Time
→ 업무에서 유효한 시간

System Time
→ 시스템 기록이 유효한 시간

예:

salary = 330

valid
3월 1일 ~ 6월 1일

system
3월 1일 ~ 4월 10일

그리고 정정 후:

salary = 340

valid
3월 1일 ~ 6월 1일

system
4월 10일 ~ 현재

처럼 볼 수 있습니다.


39장. 모든 시스템에 바이템포럴 모델이 필요한 것은 아니다#

두 시간축을 관리하면:

테이블 복잡도 증가

쿼리 복잡도 증가

수정 로직 복잡도 증가

저장량 증가

가 발생합니다.

따라서 먼저 질문해야 합니다.

현재 기준 과거만 알면 되는가?

당시 시스템이 알고 있던 과거까지 재현해야 하는가?

두 번째가 필요한 경우에만 기록 시간축을 추가하는 편이 합리적일 수 있습니다.


40장. 감사·정산에서는 기록 시점이 중요할 수 있다#

예를 들어 감사자가 묻습니다.

4월 1일 보고서는 어떤 데이터를 근거로 작성됐습니까?

현재 정정된 데이터를 다시 조회하면 340이 나옵니다.

하지만 당시 보고서는 330을 사용했습니다.

이를 설명하려면:

당시 기록 상태

를 재현할 수 있어야 합니다.

감사·회계·규제 업무에서는 이런 요구가 중요할 수 있습니다.


41장. 현재 관점의 과거와 당시 관점의 과거를 구분하자#

질문 A#

지금 알고 있는 사실로 보면 3월 15일 급여는 얼마인가?

답:

340

질문 B#

4월 1일 당시 시스템이 알고 있던 3월 15일 급여는 얼마인가?

답:

330

두 질문을 SQL 요구사항에 명시하지 않으면 이력 설계를 잘못 선택하기 쉽습니다.


42장. 주문 단가를 이력 가격으로 다시 계산하면 안 되는 이유도 같다#

상품 현재 가격이:

600

이라고 하겠습니다.

과거 주문 당시 가격은:

500

이었습니다.

주문 금액을 조회할 때 상품 가격 이력을 찾아 다시 계산하면 가격 정정이나 이력 변경으로 과거 주문 금액이 바뀔 수 있습니다.

그래서 주문에는 일반적으로:

주문 당시 단가

를 거래 사실로 저장하는 것이 중요합니다.


43장. 마스터 이력과 거래 스냅샷은 역할이 다르다#

상품 가격 이력#

특정 시점의 기준 가격

주문 품목 단가#

실제로 거래에 적용된 확정 가격

둘은 비슷해 보여도 다른 사실입니다.

프로모션·쿠폰·수동 할인 때문에 주문 단가는 당시 기준 가격과 다를 수도 있습니다.


44장. 현재값 테이블과 이력 테이블을 함께 둘 수도 있다#

예:

employee#

emp_id
7

current_salary
360

salary_period#

과거 전체 이력

이 구조는 현재값 조회를 단순하게 만들 수 있습니다.

하지만 새로운 문제가 생깁니다.


45장. 현재값과 이력이 서로 달라질 수 있다#

급여를 360으로 변경하면서:

salary_period
→ 360 추가 성공

employee.current_salary
→ 갱신 실패

했다고 하겠습니다.

결과:

이력상 현재 급여
360

현재 테이블
330

입니다.

두 곳의 사실이 충돌합니다.


46장. 이력과 현재값 갱신을 하나의 트랜잭션으로 묶을 수 있다#

개념적으로:

BEGIN

기존 이력 종료

새 이력 추가

현재 급여 수정

COMMIT

처럼 한 업무 단위로 처리합니다.

중간에 실패하면 전체를 롤백합니다.


47장. 그래도 재시도와 소급 수정 정책은 필요하다#

트랜잭션으로 묶어도:

요청 재전송

동시 수정

소급 정정

같은 문제가 남습니다.

예를 들어 두 관리자가 동시에 급여를 변경한다면 기간 겹침이 발생할 수 있습니다.

DB 무결성 제약과 트랜잭션 설계를 함께 고려해야 합니다.


48장. 현재값은 이력에서 계산할 수도 있다#

현재 급여:

SELECT
    salary
FROM salary_period
WHERE emp_id = 7
  AND valid_to IS NULL;

처럼 가져올 수도 있습니다.

장점:

중복 저장 감소

단점:

현재행 조회 규칙 의존

잘못된 중복 현재행 발생 가능

입니다.


49장. 현재 유효 행이 두 개 생겨도 안 된다#

다음 두 행:

6월 1일 ~ NULL
360

7월 1일 ~ NULL
380

이 존재하면 현재 시점에서 두 급여가 동시에 유효합니다.

따라서:

현재 구간 하나만 존재

라는 규칙도 필요합니다.

기간 중복 금지 제약이 있다면 자연스럽게 방지할 수 있습니다.


50장. 빈 기간도 조회 결과를 NULL로 만들 수 있다#

이력:

1월 1일 ~ 3월 1일
300

4월 1일 ~ 6월 1일
330

3월 15일을 조회하면:

결과 없음

입니다.

이 상황이:

급여 0

을 의미하는 것은 아닙니다.

단순히 이력에 값이 없는 것입니다.


51장. 값 없음과 값 0을 구분해야 한다#

급여:

0

이라는 데이터가 실제 업무상 허용될 수 있다고 하겠습니다.

그러면:

행 없음

과:

salary = 0

은 완전히 다른 상태입니다.

이력 조회에서 결과가 없을 때 임의로 0을 반환하면 안 됩니다.


52장. 시점 조회 함수로 공통화할 수도 있다#

애플리케이션에서 반복적으로:

특정 직원

특정 시각

당시 급여

를 조회한다고 하겠습니다.

SQL 함수나 뷰로 규칙을 공통화할 수 있습니다.

핵심은 모든 코드가 같은:

valid_from <= target
AND target < valid_to

규칙을 사용하도록 만드는 것입니다.


53장. 팀마다 다른 경계 규칙을 쓰면 같은 시점에 값이 달라진다#

서비스 A:

valid_to >= target

서비스 B:

valid_to > target

를 사용한다고 하겠습니다.

정확히 변경 시각에는 두 시스템이 다른 결과를 낼 수 있습니다.

시간 구간 규칙은 조직 차원의 데이터 계약으로 관리하는 것이 좋습니다.


54장. 시간대도 이력 경계를 바꾼다#

급여 변경이:

2026-03-01 00:00 KST

부터라고 하겠습니다.

UTC로는:

2026-02-28 15:00 UTC

입니다.

시간대 변환을 잘못하면 9시간 동안 이전 급여나 새 급여를 잘못 적용할 수 있습니다.


55장. 날짜만 저장할지 타임스탬프를 저장할지도 업무 규칙이다#

급여가 항상:

하루 단위

로만 변경된다면 날짜만으로 충분할 수 있습니다.

반면 가격이나 권한이:

14:30부터 적용

될 수 있다면 타임스탬프가 필요합니다.

무조건 가장 정밀한 타입을 사용하는 것보다 실제 업무 변경 단위를 기준으로 결정합니다.


56장. 타임스탬프를 사용한다면 시간대 정책을 고정해야 한다#

예:

DB 저장
UTC

화면 표시
Asia/Seoul

처럼 정책을 정할 수 있습니다.

어떤 시스템은 지역 현지 시간을 업무 기준으로 사용할 수도 있습니다.

핵심은 모든 서비스가 같은 의미를 공유하는 것입니다.


57장. 이력 데이터에도 자연키와 기술키가 모두 필요할 수 있다#

예:

history_id

라는 단일 기술키를 둘 수 있습니다.

동시에 업무 무결성으로:

emp_id + valid_from

의 중복을 막을 수 있습니다.

기술키와 업무 식별 규칙은 역할이 다릅니다.


58장. 유효기간을 기본키 전체로 사용하는 것은 불편할 수 있다#

예:

PRIMARY KEY (
    emp_id,
    valid_from,
    valid_to
)

라고 하면 valid_to를 수정할 때 기본키 자체가 바뀝니다.

이력 행을 다른 테이블에서 참조해야 한다면 단일 history_id가 더 편리할 수 있습니다.


59장. 종료 시각은 다음 이력의 시작과 같도록 만들 수 있다#

기존 현재 급여:

6월 1일 ~ NULL
360

7월 1일부터 380으로 변경한다고 하겠습니다.

트랜잭션 안에서:

기존 valid_to
=
7월 1일

신규 valid_from
=
7월 1일

로 맞춥니다.

결과:

[6월 1일, 7월 1일)
360

[7월 1일, ∞)
380

입니다.


60장. 변경 SQL에서는 기존 현재행 잠금도 고려할 수 있다#

동시에 두 관리자가 현재 급여를 변경하면 경쟁이 발생할 수 있습니다.

개념적으로:

SELECT ...
FROM salary_period
WHERE emp_id = 7
  AND valid_to IS NULL
FOR UPDATE;

로 현재 행을 잠근 뒤 갱신하는 방식을 검토할 수 있습니다.

정확한 동시성 전략은 시스템 요구와 트랜잭션 격리 수준에 따라 달라집니다.


61장. 소급 정정은 단순 현재행 변경보다 어렵다#

현재:

1월 ~ 3월
300

3월 ~ 6월
330

6월 ~ 현재
360

입니다.

4월 10일에:

3월부터
340이었다

는 정정이 들어옵니다.

3월~6월 구간만:

330
→
340

으로 바꾸면 현재 관점에서는 해결됩니다.

하지만 기록 관점까지 필요하면 기존 330을 삭제해서는 안 됩니다.


62장. 정정과 변경을 구분할 필요가 있다#

변경#

7월부터 급여 380

새로운 업무 사실입니다.

정정#

3월부터 330이 아니라 340이었다

과거 기록의 오류 수정입니다.

둘 다 UPDATE처럼 보일 수 있지만 의미가 다릅니다.

감사 시스템에서는 정정 사유도 기록할 수 있습니다.


63장. 정정 이력에 사유와 행위자를 남길 수 있다#

예:

corrected_at
2026-04-10

corrected_by
HR102

reason
승급 반영 누락

이런 메타데이터가 있으면 나중에:

왜 과거 값이 바뀌었는가?

를 설명할 수 있습니다.


64장. 삭제도 이력 모델에서는 신중해야 한다#

잘못 입력한 이력이라고 해서 바로 DELETE하면 감사 흔적이 사라질 수 있습니다.

업무에 따라:

무효화 상태

삭제 사유

정정 이벤트

로 남길 수도 있습니다.

모든 시스템에 영구 보존이 필요한 것은 아니지만 삭제 정책은 명확해야 합니다.


65장. SCD Type 2와 유효기간 이력은 비슷한 개념을 가진다#

데이터 웨어하우스에서는 차원 변경 이력을 보존하기 위해 SCD Type 2 구조가 자주 사용됩니다.

예:

customer_key

customer_id

address

valid_from

valid_to

current_flag

처럼 한 업무 객체의 여러 버전을 행으로 보관합니다.

핵심은 기간별 버전을 저장한다는 점입니다.


66장. 하지만 운영 이력 테이블과 DW 차원은 목적이 다를 수 있다#

운영 이력:

업무 판단

정산

권한

실시간 조회

DW 이력:

분석

과거 보고

차원 변화 추적

에 초점을 둘 수 있습니다.

비슷한 컬럼을 사용한다고 동일한 모델로 취급할 필요는 없습니다.


67장. 이력 테이블의 행 수는 빠르게 증가할 수 있다#

직원:

10만 명

속성 변경:

평균 월 2회

라면 1년 이력:

100,000
×
2
×
12

=
2,400,000행

입니다.

여러 속성을 각각 이력화하면 더 커질 수 있습니다.


68장. 그래서 무엇을 한 행에 묶을지도 중요하다#

예를 들어:

급여 변경

부서 변경

직책 변경

을 한 이력 행에 모두 넣으면 부서만 바뀌어도 급여 값이 반복 저장됩니다.

반대로 모두 별도 이력으로 분리하면 조합 조회가 복잡해집니다.

이 역시 업무 질문에 따라 결정해야 합니다.


69장. 모든 변경 속도를 같은 테이블에 묶으면 불필요한 버전이 늘 수 있다#

예:

주소
1년에 1회 변경

로그인 상태
하루 수십 회 변경

를 같은 버전 행에 넣는다면 로그인 변화 때문에 주소가 반복 저장될 수 있습니다.

변경 주기와 조회 목적을 함께 고려해야 합니다.


70장. 이력 모델에서도 정규화와 조회 편의 사이의 균형이 필요하다#

과도하게 분리하면:

과거 상태를 복원하기 위해
여러 이력 테이블을 시점 조인

해야 합니다.

과도하게 합치면:

변경되지 않은 속성까지 반복 저장

됩니다.

어느 쪽이 적절한지는 데이터 규모와 대표 조회 패턴을 측정해서 결정해야 합니다.


71장. 과거 조직도를 조회하려면 관리자 관계도 이력화해야 한다#

현재 직원 테이블:

emp_id = 7
mgr_id = 1

만 가지고 있다면 현재 관리자만 알 수 있습니다.

작년 관리자:

mgr_id = 4

였다는 사실을 복원하려면 관리자 관계에도:

valid_from

valid_to

가 필요할 수 있습니다.


72장. 현재 외래키만으로 과거 관계 무결성이 보장되지 않을 수 있다#

과거에는 관리자 4가 재직 중이었지만 현재는 퇴직했다고 하겠습니다.

현재 직원 테이블만 참조하면 과거 관계의 의미가 흐려질 수 있습니다.

시간이 포함된 관계는:

누가 누구의 관리자였는가?

뿐 아니라:

언제 그 관계가 유효했는가?

를 함께 표현해야 합니다.


73장. 가격·부서·권한·계약에도 같은 모델을 적용할 수 있다#

유효기간 이력은 급여에만 사용하는 개념이 아닙니다.

예:

상품 가격

직원 소속 부서

사용자 권한

계약 상태

고객 등급

배송 정책

세율

시간에 따라 하나의 기준값이 변하는 데이터에 적용할 수 있습니다.


74장. 권한 이력에서는 경계 정확성이 특히 중요하다#

사용자의 관리자 권한이:

2026-10-01 09:00

에 종료된다고 하겠습니다.

종료 포함 여부를 잘못 처리하면 정확히 09:00에 권한이 남거나 사라지는 문제가 생길 수 있습니다.

보안 관련 이력에서는 [start, end) 같은 시간 계약을 더욱 명확하게 적용해야 합니다.


75장. 계약 기간에서도 같은 경계 문제가 발생한다#

계약 A:

1월 1일 ~ 3월 1일

계약 B:

3월 1일 ~ 6월 1일

양쪽이 모두 3월 1일을 포함하면 두 계약이 동시에 활성 상태로 조회될 수 있습니다.

구간 경계는 단순 데이터 타입 문제가 아니라 업무 규칙입니다.


76장. 이력 조회에서는 현재 시각을 여러 번 호출하는 것도 주의할 수 있다#

복잡한 쿼리 안에서 기준 시각을 여러 곳에서 각각 계산하는 것보다:

기준 시각
2026-10-01 12:00

을 한 번 정해 일관되게 사용하는 것이 좋습니다.

특히 긴 쿼리나 여러 시스템을 연결할 때 기준 시점이 흔들리지 않게 합니다.


77장. 보고서에는 “현재 기준 과거”인지 표시하는 것이 좋다#

예:

2026년 3월 급여
340

만 표시하면 사용자는 이것이:

현재 정정된 값

인지:

당시 보고된 값

인지 알 수 없습니다.

감사 성격의 보고서라면 기준을 명확히 써야 합니다.


78장. 이력 데이터를 수정한 뒤 파생 데이터도 확인해야 한다#

3월 급여:

330
→
340

으로 정정했습니다.

그러면 다음도 영향을 받을 수 있습니다.

월 인건비

부서 평균

연봉 합계

퇴직금 계산

분석 데이터마트

원본 이력을 정정했다고 파생 결과가 자동으로 모두 수정되는 것은 아닙니다.


79장. 이벤트 기반 시스템이라면 정정 이벤트를 발행할 수도 있다#

예:

SALARY_CORRECTED

emp_id = 7

valid_from = 2026-03-01

old_salary = 330

new_salary = 340

같은 이벤트를 발행할 수 있습니다.

다운스트림 시스템은 이를 받아 자신의 파생 데이터를 다시 계산합니다.


80장. 정정 이벤트도 멱등성이 필요하다#

같은 정정 이벤트가 두 번 전달될 수 있습니다.

따라서:

correction_id

같은 식별자를 두고 중복 적용을 막을 수 있습니다.

이력 모델과 스트림 재처리는 실제 시스템에서 연결되는 주제입니다.


81장. 이력 테이블 조회 성능도 패턴에 따라 다르다#

대표 조회:

직원 7의 현재 급여

직원 7의 특정 시점 급여

모든 직원의 현재 급여

특정 시점 전체 직원 급여

각 쿼리의 접근 패턴이 다릅니다.

인덱스도 실제 대표 조회에 맞춰 설계해야 합니다.


82장. 특정 직원의 시점 조회라면 emp_id와 시간 조건이 중요하다#

예:

SELECT salary
FROM salary_period
WHERE emp_id = ?
  AND valid_from <= ?
  AND (
        valid_to > ?
        OR valid_to IS NULL
      );

이 패턴이 매우 자주 실행된다면:

emp_id

valid_from

중심의 인덱스를 검토할 수 있습니다.

정확한 계획은 데이터 분포와 DBMS 실행 계획을 확인해야 합니다.


83장. 범위 타입을 사용한다면 GiST 계열 인덱스도 검토할 수 있다#

PostgreSQL 범위 타입은:

포함 여부

겹침

포함 관계

같은 연산을 지원합니다.

대량 범위 조회와 중복 제약에서는 GiST 기반 인덱스가 활용될 수 있습니다.

단순 B-tree와 역할이 다릅니다.


84장. 이력 데이터가 많으면 보존 정책도 필요할 수 있다#

모든 변경을 영구 저장하면 저장량이 계속 증가합니다.

업무 요구에 따라:

7년 보존

10년 보존

영구 보존

법정 기간 이후 삭제

같은 정책이 필요할 수 있습니다.

특히 개인정보 이력은 보존 필요성과 삭제 요구를 함께 검토해야 합니다.


85장. 현재값을 삭제해도 과거 이력이 남아 있을 수 있다#

고객이나 직원 계정을 삭제했습니다.

하지만 이력 테이블에:

주소

전화번호

급여

가 남아 있을 수 있습니다.

개인정보 삭제 정책은 현재 테이블뿐 아니라:

이력

백업

로그

분석 복제본

까지 범위를 확인해야 합니다.


86장. 이력 데이터에도 접근 권한을 따로 둘 수 있다#

현재 급여는 인사 담당자에게 필요합니다.

하지만 모든 과거 급여 이력은 더 민감할 수 있습니다.

따라서:

현재값 조회 권한

과거 이력 조회 권한

을 분리할 수도 있습니다.

이력 보존은 보안 책임도 늘립니다.


87장. 이력 모델 테스트에는 정상 변경만 넣으면 부족하다#

최소한 다음 시나리오를 준비하는 것이 좋습니다.

정상 변경

정확한 경계 조회

소급 정정

같은 시작 시각 중복

기간 겹침

기간 공백

현재행 두 개

시간대 변환

동시 변경

이런 경계 데이터에서 모델의 문제가 잘 드러납니다.


88장. 경계값 테스트 표#

조회 시각 기대 급여
2월 28일 23:59:59 300
3월 1일 00:00:00 330
3월 1일 00:00:01 330
5월 31일 23:59:59 330
6월 1일 00:00:00 360

이 결과가 일관되게 나오는지 확인합니다.


89장. 중복 구간 테스트도 별도로 준비하자#

기존:

[3월 1일, 6월 1일)
330

시험 삽입:

[4월 1일, 7월 1일)
350

예상:

삽입 거부

입니다.

업무가 중복을 허용하지 않는다면 DB 수준에서도 가능하면 이를 보장하는 것이 좋습니다.


90장. 공백 테스트#

기존:

[1월 1일, 3월 1일)

[4월 1일, 6월 1일)

3월 15일 조회:

0행

입니다.

이 결과가:

정상적인 미적용 기간

인지:

데이터 오류

인지 업무 규칙으로 판단해야 합니다.


91장. 이력 모델 설계 체크리스트#

  1. 어떤 속성의 이력이 필요한가?
  2. 과거의 어떤 질문에 답해야 하는가?
  3. 변경 사건을 저장할 것인가 유효 구간을 저장할 것인가?
  4. 시간 단위는 날짜인가 타임스탬프인가?
  5. 업무 시간대는 무엇인가?
  6. 시작과 종료 중 어느 경계를 포함하는가?
  7. [start, end) 규칙을 사용할 것인가?
  8. 현재 구간 종료를 NULL로 표현하는가?
  9. 같은 대상의 기간 중복을 허용하는가?
  10. 빈 기간을 허용하는가?
  11. 소급 정정이 가능한가?
  12. 정정 전 값을 보존해야 하는가?
  13. 유효 시각과 기록 시각을 분리해야 하는가?
  14. 현재값을 별도 테이블에 복제할 것인가?
  15. 현재값과 이력을 같은 트랜잭션으로 변경하는가?
  16. 동시 수정에서 겹침을 어떻게 막는가?
  17. 이력 정정이 파생 데이터에 어떻게 전달되는가?
  18. 과거 이력의 접근 권한은 어떻게 관리하는가?
  19. 보존 기간은 얼마인가?
  20. 경계·중복·공백 테스트를 자동화했는가?

92장. 핵심 정리#

이력 테이블을 설계할 때 가장 먼저 해야 하는 것은 날짜 컬럼을 두 개 만드는 일이 아닙니다.

먼저 질문해야 합니다.

우리가 복원하려는 과거는 어떤 과거인가?

현재 기준에서 과거의 정확한 상태만 필요하다면:

valid_from

valid_to

와 같은 유효기간 모델로 충분할 수 있습니다.

예를 들어:

[1월 1일, 3월 1일)
300

[3월 1일, 6월 1일)
330

[6월 1일, ∞)
360

처럼 시작을 포함하고 종료를 제외하는 반개구간을 사용하면 정확한 변경 순간을 한쪽 기간에만 배정할 수 있습니다.

조회 조건의 핵심은:

valid_from <= target

AND

target < valid_to

입니다.

현재 구간의 종료를 NULL로 관리한다면 NULL 조건을 추가합니다.

하지만 이 구조만 있다고 기간 무결성이 자동으로 보장되는 것은 아닙니다.

다음 두 기간:

[3월 1일, 6월 1일)

[4월 1일, 7월 1일)

은 서로 겹칩니다.

기본키와:

valid_from < valid_to

검사만으로는 이런 중복을 막을 수 없습니다.

따라서 PostgreSQL 범위 타입과 제외 제약 또는 트랜잭션 안의 겹침 검사를 사용해:

한 시점에 하나의 급여만 유효하다.

라는 업무 규칙을 보장할 수 있습니다.

또한:

기간 중복 없음

과:

기간 공백 없음

은 서로 다른 규칙입니다.

겹치는 이력이 없어도 3월 한 달의 이력이 빠질 수 있습니다.

소급 정정이 가능한 시스템에서는 더 중요한 문제가 생깁니다.

4월 10일에:

3월 1일부터 급여는
330이 아니라 340이었다.

라는 사실을 알게 됐다고 하겠습니다.

현재 기준 과거를 조회하면:

3월 급여
340

이어야 합니다.

하지만:

4월 1일 당시 시스템은 3월 급여를 얼마라고 알고 있었는가?

라는 질문의 답은:

330

입니다.

그래서 감사·정산처럼 당시 인지 상태까지 복원해야 하는 시스템에서는:

유효 시각

기록 시각

이라는 두 시간축을 구분해야 할 수 있습니다.

이력 모델에서 가장 중요한 원칙은 결국 이것입니다.

값이 언제부터 사실이었는가와 시스템이 언제 그 사실을 알았는가는 같은 시간이 아닐 수 있다.

그리고 이력을 설계한 뒤에는 반드시 세 가지 시점을 시험해야 합니다.

변경 직전

정확한 변경 순간

변경 직후

여기에:

기간 겹침

기간 공백

소급 정정

동시 변경

까지 추가해야 합니다.

좋은 이력 테이블은 단순히 과거 행을 많이 보존하는 테이블이 아닙니다.

특정 시점의 상태를 한 가지 의미로 재현할 수 있고, 왜 그 값이 당시 유효했는지 설명하며, 정정이 들어와도 과거의 시간 의미를 잃지 않는 구조입니다.

이 페이지의 목차