1년 뒤 오늘 실행한 퀀트 리서치를 다시 돌리려면 무엇을 복원할 수 있어야 할까?

당시 전략 코드는 GitHub에 남아 있고 연구 DB에도 run_id가 있을 수 있다. 하지만 데이터 공급자가 과거 시세를 수정했고 원본 API 응답은 이미 삭제됐을 수 있다. 같은 코드를 다시 실행해도 결과가 달라지는 이유다. Python과 SQL로 팩터·시그널 스냅샷을 쌓는 작은 연구 시스템에서는, 당시 의사결정에 사용한 입력과 코드, 그 시점에 실제로 이용할 수 있던 정보를 함께 되살리는 일이 중요하다.

처음 구상은 PostgreSQL에 연구 인덱스와 시그널, outcome을 두고 큰 raw_json을 Cloudflare R2로 보낸 다음, 검증이 끝나면 DB의 오래된 payload를 정리하는 것이었다. 하지만 gzip이나 S3 SDK보다 어려운 질문이 남았다. 이 정도 복잡성을 언제 감당할 가치가 있을까? 왜 객체 업로드보다 먼저 manifest를 만들까? 원본을 정리한 뒤에도 같은 연구를 재현할 수 있다는 사실을 어떻게 확인할까?

이를 살펴보려고 Python 표준 라이브러리, 인메모리 SQLite, 가짜 MemoryObjectStore로 작은 오프라인 실험을 만들었다. 안전하지 않은 삭제를 거부하는 몇 가지 규칙은 시험했지만 실제 PostgreSQL/R2 장애나 여러 worker 간 동시성은 테스트하지 않았다. 실행한 실험과 문서에 근거한 설계 제안을 구분해서 다룬다.

PostgreSQL만으로 충분한 경우부터 판단한다

개인 프로젝트의 데이터 증가량이 크지 않고 과거 실행을 자주 재생하지 않는다면, PostgreSQL·R2·manifest·reconciliation worker를 한꺼번에 운영할 이유는 적다. 우선 정기 백업, 실제 복구 연습, 전략 commit과 매개변수 기록을 제대로 갖추는 편이 낫다.

이미 성숙한 대안이 있다. PostgreSQL PITR(Point-in-Time Recovery)은 base backup과 보관된 WAL을 이용해 지정된 과거 시점으로 데이터베이스 클러스터를 복원한다. 그 시점의 입력이 PG에 저장돼 있었고 백업 보존 기간이 충분했다면 별도 DB에서 찾아낼 수 있다.

문제는 복원 단위다. PITR은 주로 DB 클러스터 단위로 복구한다. 연구자가 원하는 것은 “run_id=35가 그때 사용한 원시 시세를 달라”는 요청이다. 실행 하나 때문에 오래된 클러스터를 복원하고 긴 기간의 WAL을 보관하며 현재 연구 코드와 맞추는 비용이, 불변 객체 하나를 가져오는 것보다 클 수 있다. 또한 PITR은 애초에 PG에 기록되지 않은 API 응답, Python 의존성, 거래소 캘린더까지 보존해 주지 않는다.

PITR은 DB 재해 복구를 위한 것이고, run별 불변 아카이브는 연구 출처와 재현을 위한 것이다. 함께 쓸 수 있지만 후자가 항상 필요한 것은 아니다.

“100GB를 넘으면 R2” 같은 임의 기준보다 실제 증가율을 보겠다. heap·TOAST·인덱스가 얼마나 차지하는지, 매일 유입되는 payload가 얼마나 되는지, 백업/WAL 증가량과 복구 요구 시간을 측정한다. 아래는 실제 프로젝트 PostgreSQL에서 실행하지 않은 읽기 전용 점검 SQL 예시다.

SELECT
  pg_size_pretty(pg_relation_size('quant_research.signal_snapshot')) AS heap,
  pg_size_pretty(pg_table_size('quant_research.signal_snapshot')) AS table_including_toast,
  pg_size_pretty(pg_total_relation_size('quant_research.signal_snapshot')) AS total_with_indexes;

오래된 원시 데이터가 DB 유지보수에 실질적인 부담을 주고, 개별 run 복구가 필요한 시점에 R2 계층을 더하면 된다.

TOAST와 MVCC를 알면 raw_json 정리 방법이 달라진다

PostgreSQL에는 큰 값을 처리하는 TOAST가 이미 있다. 전체 튜플이 보통 약 2KB 임계치를 넘으면 해당 정책에 따라 가변 길이 값을 압축하고, 필요할 경우 별도의 TOAST 테이블로 옮긴다. 문서에서는 외부 포인터의 온디스크 길이를 18바이트로 설명한다.

그러나 “2KB를 넘는 JSONB는 무조건 행 밖으로 이동한다”는 설명은 정확하지 않다. 튜플 전체 크기, 압축 효과, 컬럼의 storage strategy에 따라 달라진다. 일반 조회가 raw_json을 읽지 않는다면 TOAST만으로도 필요한 최적화가 이미 이뤄졌을 수 있다.

기존 설계에서 다시 생각한 부분은 UPDATE 비용이다. 큰 raw_json을 짧은 요약으로 바꿔도 MVCC는 새로운 행 버전을 만들고 WAL을 남긴다. 오래된 행 버전과 관련 TOAST 데이터는 나중에 정리해야 한다. 과거 레코드를 한 번에 대량 수정하면 쓰기 부하, bloat, 잠금 경합이 생길 수 있다. 그렇다고 모든 UPDATE가 항상 심한 테이블 팽창을 만든다고 단정할 수도 없다.

PostgreSQL VACUUM 설명에 따르면 일반 VACUUM은 주로 DB 내부에서 공간을 재사용하도록 하고, VACUUM FULL은 테이블을 다시 작성해 OS에 더 많은 공간을 돌려줄 수 있지만 훨씬 강한 잠금과 운영 비용이 필요하다. 파일 끝부분을 줄일 수 있는 경우도 있으므로 “물리적 디스크는 절대로 줄지 않는다” 역시 옳지 않다.

새 시스템이라면 원시 payload를 수직 분리하는 방안을 검토하겠다.

signal_snapshot
  snapshot_id | run_id | signal_time | score | raw_hash | archive_key

signal_raw_payload
  snapshot_id | payload_version | raw_json | captured_at

자주 조회하는 시그널 메타데이터는 signal_snapshot에, 원시 JSON은 signal_raw_payload에 둔다. 아카이브 검증 후 payload 테이블만 정리하면 메인 테이블을 대량 UPDATE하지 않아도 된다. 다만 별도 테이블에서 DELETE해도 MVCC/VACUUM 문제가 사라지는 것은 아니다.

보존 기간이 월별 또는 배치별로 깔끔하게 일치한다면 파티션도 선택지다. PostgreSQL partitioning 문서는 만료된 파티션 전체를 제거하는 방법이 행 단위 삭제보다 유리한 이유를 설명한다. 단, 그 파티션 안의 모든 행이 아카이브 검증·보존 기간·삭제 권한 조건을 만족해야 한다. 일부만 가능하다고 전체 파티션을 DROP하면 안 된다.

기존 큰 테이블을 즉시 이관할 필요는 없다. 먼저 heap/TOAST, WAL, 쿼리 비용을 측정하고 TOAST 유지, 수직 분리, 보존 주기별 파티션 중에서 결정하는 게 합리적이다.

왜 upload-first 대신 manifest-first를 택했나

원격 객체와 PG 상태를 기록하는 순서는 적어도 두 가지다.

Upload-first:
package → PUT object → GET/verify → commit PG manifest

Manifest-first:
commit PENDING manifest + expected digest → PUT object
→ GET/verify → mark VERIFIED in PG

Upload-first라면 “manifest는 PENDING인데 객체가 없다”는 상태는 처음부터 생기지 않는다. 하지만 PUT 성공 직후 PG 기록 전에 프로세스가 종료되면 원격 객체가 고아로 남는다. 이를 찾아내려면 객체 키 규칙, 소유자, garbage collection 정책이 필요하다.

오프라인 프로토타입에서는 manifest-first를 선택했다. 업로드 전에 run_id / revision / expected_sha256을 고정해, 실패하더라도 “무엇을 보관하려 했는지” 검색하고 추적할 수 있기 때문이다. 그렇다고 PENDING 행 자체가 분산 잠금이 되는 것은 아니다.

이 선택 때문에 실험 코드에서 실제로 빈틈도 발견했다. 업로드를 거부하도록 모의 실패를 일으키면 manifest는 PENDING으로 남고 객체는 생성되지 않았다. 첫 번째 reconciliation 코드는 이미 올라간 객체만 검사했으므로 이를 복구할 수 없었다.

수정 후에는 손상되지 않은 원본 payload에서 다시 압축하되, 최초 manifest의 SHA-256 및 바이트 수와 정확히 일치할 때만 업로드를 재시도하게 했다.

recovered = pack(self._source_rows(manifest["run_id"]))
if digest(recovered) != manifest["sha256"] or len(recovered) != manifest["byte_size"]:
    raise ArchiveError("pending manifest source changed; cannot re-upload")
self.store.put_if_absent(manifest["object_key"], recovered)

이는 모의 환경에서 확인한 실제 코드 변경이지 운영 장애 보고서가 아니다.

실제 멀티 worker 환경에는 lease와 fencing token이 더 필요하다. worker A가 lease를 얻은 뒤 네트워크에 묶여 있고 그사이 B가 만료된 작업을 이어받았다고 하자. A가 뒤늦게 돌아와 이전 권한으로 상태를 바꾸면 안 된다. PG의 조건부 UPDATE ... RETURNING과 증가하는 lease_epoch을 함께 쓰는 방식이 가능하다. 다음 SQL은 아직 PostgreSQL에서 실행하지 않은 설계 예시다.

UPDATE archive_manifest
SET lease_owner = $2,
    lease_epoch = lease_epoch + 1,
    locked_until = clock_timestamp() + interval '5 minutes',
    status = 'RECONCILING'
WHERE identity = $1
  AND status IN ('PENDING', 'RECONCILING')
  AND (locked_until IS NULL OR locked_until < clock_timestamp())
RETURNING lease_epoch, expected_sha256, object_key;

원격 PUT/GET 동안 긴 PG 트랜잭션을 잡아두지 않는다. 검증 완료 후에는 자신이 받은 lease_owner + lease_epoch과 일치하는 조건으로만 상태를 변경한다. 5분은 예시일 뿐 서비스별 타임아웃을 측정해야 한다.

PG lease만으로 오래된 worker가 이미 보낸 원격 PUT을 취소할 수도 없다. 콘텐츠 기반 불변 키, 다른 바이트의 덮어쓰기를 막는 조건부 쓰기, 충돌 후 재검증이 모두 필요하다. 실제 동시성과 네트워크 분리 테스트 전에는 확정된 안전성이라고 말할 수 없다.

R2의 버전 식별자, ETag, Metadata

Cloudflare의 R2 S3 호환성 표에는 PutObject, HeadObject, GetObject가 있지만 GetBucketVersioning, PutBucketVersioning은 미구현으로 나온다. AWS S3의 버전 기록 기능을 R2에서도 그대로 사용할 수 있다고 가정하면 위험하다.

따라서 raw/v1/run=35/revision=2/sha256=...jsonl.gz처럼 명시적인 불변 객체 키가 낫다. 새 payload는 새 revision을 사용하며 기존 키를 조용히 덮어쓰지 않는다. Bucket Locks와 Object Lifecycle도 연구 데이터 보존 기간에 맞춰야 한다. R2 Bucket Locks를 AWS S3 Object Lock API와 완전히 동일하게 취급해서는 안 된다.

R2 S3 확장 문서는 x-amz-meta-* 커스텀 메타데이터와의 매핑을 설명한다. 압축 데이터의 SHA-256, 행 수, 스키마 버전을 저장해 두면 HEAD만으로 가벼운 사전 점검이 가능하다. 아래는 실제 R2에서 실행하지 않은 단일 PutObject boto3 예시다.

# gzip_bytes and expected_sha256 must refer to the same immutable input
s3.put_object(
    Bucket=bucket,
    Key=object_key,
    Body=gzip_bytes,
    Metadata={
        "sha256": expected_sha256,
        "row-count": str(row_count),
        "schema-version": "1",
    },
    IfNoneMatch="*",
)
head = s3.head_object(Bucket=bucket, Key=object_key)
assert head["Metadata"]["sha256"] == expected_sha256

HEAD의 SHA 문자열은 업로더가 기재한 메타데이터다. 객체의 실제 내용을 서비스가 다시 해싱했다는 증거가 아니다. 원본을 prune하기 전에 전체 GET, 압축 바이트의 SHA-256 재계산, 압축 해제, schema/행 수/레코드 ID 검증을 수행해야 한다. 오프라인 테스트에서는 HEAD는 성공하지만 GET 데이터가 손상된 상황을 주입했고, 정리가 거부됐다.

Multipart의 ETag도 파일 전체 SHA-256이 아니다. 단일 PutObject에서 사용한 조건부 쓰기가 CompleteMultipartUpload에서 똑같이 적용된다고 가정하지 말자. 실제 SDK의 파트 분할, abort, 재시도, 덮어쓰기 차단은 승인된 R2 테스트 bucket에서 별도로 확인해야 한다.

수정된 시세와 as_of는 이중 시간축 문제다

과거 객체를 보관하는 것과 의사결정 당시 그 버전을 실제로 이용할 수 있었는지 아는 것은 별개다.

이것은 bitemporal 모델링의 일부다. event/valid time은 시세가 어떤 시장 세션에 해당하는지를 나타낸다. observed_at 또는 ingested_at은 연구 시스템이 그 버전을 실제 받은 시간을 나타낸다. 공급자의 source_published_at도 따로 저장하는 편이 좋다. 공급자가 공개한 시간에 내 서비스도 받았다고 볼 수는 없다.

합성 예제에서는 같은 거래일의 종가 100을 10월 2일에 처음 수신하고, 10월 5일에 104로 정정된 버전을 받았다.

as_of (UTC)당시 볼 수 있던 close
2026-10-01 09:00아직 없음
2026-10-03 00:00100
2026-10-06 00:00104

이 작은 테스트는 나중 수정된 가격이 이전 시점 조회에 섞이지 않는다는 좁은 성질만 확인한다. 실제 벤더의 철회·정정 우선순위, 수정주가, 상장폐지, 지수 편입 종목 내역은 검증하지 않았다.

운영 연구 API에서는 과거 리플레이에 as_of_utc, dataset_revision, 또는 불변 snapshot_id를 필수로 받는 편이 안전하다. cutoff 없이 백테스트를 요청하면 최신 데이터로 조용히 바꾸지 말고 요청을 거부해야 한다. 실시간 화면용 latest view는 별도로 둘 수 있다. 같은 사건과 관측 시각의 버전이 충돌할 때는 공급자 버전/정정 순서로 판단하거나 격리해야지, 마지막 INSERT가 진실이라고 가정하면 안 된다.

UTC를 쓴다고 거래 세션 문제가 저절로 해결되지는 않는다. 미국 정규장은 뉴욕 현지 시간 16:00에 종료하지만 America/New_York은 EDT/EST로 바뀐다. UTC 시각을 일년 내내 고정하면 여름철에 어긋난다. 버전이 고정된 거래소 캘린더와 IANA 타임존에서 세션별 결정 cutoff를 만들고 UTC로 변환해야 한다. 또한 데이터가 실제 들어온 ingestion watermark도 확인해야 한다.

16:00:00에 모델이 결정하는데 공식 종가가 16:00:02에 도착했다면, 그 2초 뒤의 가격을 미리 써서는 안 된다. 조기 폐장, 거래정지, 장후 정정도 마찬가지다. 조정계수, corporate action, 지수 구성 종목 이력은 필요에 따라 독립적인 버전 자료로 보존해야 한다. 이전 팩터 검증 글은 signal-time snapshot 관점에서 같은 시간 경계를 다뤘다.

오프라인 28개 성공과 1년 뒤 전체 재현은 다르다

실제 실행한 것은 합성 데이터 오프라인 회귀 테스트 28개였고, 모두 통과했다. 중복 아카이브, 모의 업로드 실패, 객체 누락/손상 시 prune 거부, PENDING 복구, 보존 기간·확인 옵션, 간단한 as_of 변경을 다뤘다. 직접 작성한 상태 머신과 주입한 장애를 검사한 것이지 네트워크 분할, 실제 PG 잠금, worker 경쟁, R2 multipart의 올바름을 증명한 것은 아니다.

두 개의 가상 값 10.5와 15.5에서 계산한 13.0 → 13.0 → 13.0도 gzip·해시·간단한 복구 경로를 확인하는 smoke test다. 복잡한 백테스트의 재현성이나 디스크 공간 절감률을 보여주지 않는다.

실제 테스트 기록:

$ python3 verify.py --output results
Ran 28 tests in 0.017s

OK
CASE_SUMMARY {"ran": 28, "passes": 28, "failures": 0, "errors": 0}

전체 테스트 fixture는 비공개 Content Lab 연구 리포지토리에 있으므로 외부 독자가 접근할 수 있는 공개 예제라고 소개할 수 없다.

더 큰 공백은 완전한 reproduction_manifest다. 이번 예시는 객체 해시, schema, 계산 함수, 매개변수, Python 버전과 UTC를 기록했지만 새로운 머신에서 과거 전략 commit을 checkout하고, 의존성을 재구성하고, 과거 PostgreSQL 데이터와 거래소 캘린더를 복원한 뒤 실제 factor/outcome 파이프라인 전체를 돌려보지 않았다. 글 서두의 “1년 후 완전히 재실행”은 아직 입증되지 않았다.

그 주장을 검증하려면 최소한 다음이 필요하다.

  1. 원본 데이터 버전, 객체 SHA-256, 공급자 수정/as_of, 당시 signal snapshot을 고정한다.
  2. 전략 Git commit, 설정 해시, 의존성 lockfile/컨테이너 digest, 거래소 캘린더 버전, 타임존, outcome 정의를 고정한다.
  3. 독립 PostgreSQL에서 실제 트랜잭션, TOAST, 동시 lease, 안전한 삭제를 시험하고, 승인된 R2 sandbox에서 일반/multipart 전송, 중단, 조건부 쓰기, 전체 GET 검증을 한다.
  4. 깨끗한 환경에 오래된 run을 복원해 고정 코드로 실행하고 시그널·의사결정·결과를 직접 대조한다. exit code가 0인지만 보아서는 안 된다.

실제 PG/R2 복원과 애플리케이션 조회 경로가 검증되기 전에는 운영 raw_json 자동 정리를 꺼 두는 편이 안전하다. 오늘 몇 바이트를 더 보관하는 비용보다, 몇 달 뒤 과거 판단의 입력을 다시 설명할 수 없는 상황이 훨씬 더 아프다.