지난 글에서 프로덕션에 올라간 모델이 조용히 틀려 가는 것을 어떻게 잡는지 봤다. 모니터링이 「지금 무엇이 잘못되고 있는가」를 알려 준다면, 이 글은 그 다음에 오는 질문을 다룬다. 반년 전에 만든 모델을, 지금 그대로 다시 만들 수 있는가.
대부분의 팀이 여기서 멈춘다. 코드는 Git에 있고 커밋 해시도 남아 있는데, 그때 그 학습에 들어간 데이터가 지금 손에 없다. 테이블은 그 뒤로 계속 갱신됐고, 전처리해 두었던 파일은 같은 이름으로 몇 번 덮어썼고, 누군가 이상치 몇 건을 정리했다는 이야기는 들었지만 언제 무엇을 지웠는지는 아무도 모른다. 그래서 지표가 떨어졌을 때 「3주 전 데이터셋으로 돌아가 보자」가 말은 되는데 실행은 안 되는 문장이 된다.
이 글은 그 문제를 두 층으로 다룬다. 앞쪽은 데이터를 고정한다는 것이 구조적으로 무엇인지를 본다 — 해시와 포인터, 그리고 원본이 파일일 때와 테이블일 때의 차이다. 뒤쪽은 그것을 실제로 굴리는 도구인 DVC(Data Version Control)의 한 바퀴와, 데이터 파일을 다 고정하고도 남는 구멍을 본다.
재현성과 데이터
코드·데이터·설정
학습 한 번의 결과를 정하는 것은 셋이다. 코드, 데이터, 그리고 설정이다. 이 셋이 모두 같아야 결과가 같은데, 관례적으로 버전 관리가 붙어 있는 것은 하나뿐이다.
재현성(reproducibility)은 같은 입력으로 같은 절차를 다시 밟았을 때 같은 결과가 나오는 성질이다. 여기서 「같은 입력」에 데이터가 빠져 있으면 재현이라는 말 자체가 성립하지 않는다. 실험 기록에 git rev 9c4a1f2 하나만 적혀 있는 실행은, 코드에 관해서는 완벽하게 기록됐고 나머지 절반에 관해서는 아무것도 기록되지 않은 실행이다.
그림의 세 못 중 하나만 빠져도 아래쪽 화살표가 끊긴다는 점이 중요하다. 셋 중 둘을 고정한다고 재현 확률이 3분의 2가 되지 않는다. 재현되거나 안 되거나 둘 중 하나이고, 못 하나가 빠진 쪽은 언제나 후자다.
흔적 없는 변경
셋 중 데이터가 유독 위험한 이유가 있다. 코드가 바뀌면 diff가 보이고 리뷰가 붙는데, 데이터가 바뀌는 것은 아무 흔적을 안 남긴다. 상류 배치가 어제 한 번 더 돌았을 뿐이고, 그건 정상 동작이다. PR도 없고 알림도 없다. 그런데 그 결과로 학습 데이터가 달라졌고, 오늘 돌린 학습은 어제와 다른 모델을 만든다.
이 증상은 특히 진단이 어렵다. 성능이 떨어졌을 때 사람들은 먼저 코드를 본다. 코드는 안 바뀌었으므로 원인이 안 나오고, 그러면 「랜덤 시드 탓인가」 「GPU 드라이버가 바뀌었나」 같은 방향으로 흘러간다. 하루를 그렇게 쓰고 나서야 누군가 상류 테이블을 열어 본다.
data_final_v3_진짜최종.csv 같은 파일명이 어느 조직에서나 살아남는 것도 같은 공백 때문이다. 이름에 버전을 적는 것은 사람이 손으로 남기는 유일한 흔적이고, 손으로 남기는 것이므로 규칙이 없다. v3과 진짜최종 중 어느 쪽이 나중인지 파일명만으로는 알 방법이 없고, 두 파일의 내용이 정말 다른지도 열어 보기 전에는 모른다.
Git의 한계
「그럼 데이터도 Git에 넣으면 되지 않나」가 첫 반응인데, 그게 안 된다. Git은 텍스트 파일의 줄 단위 변경을 다루도록 만들어졌다. CSV는 텍스트이긴 하지만 정렬이나 전처리가 조금만 달라져도 통째로 갈리고, 파일이 GB 단위로 올라가면 줄 단위 델타가 이득을 못 낸다.
숫자를 한 번 따라가 보면 한계가 어디인지 보인다. 12 GB짜리 학습 파일을 하루 한 번 갱신하고 그때마다 커밋한다고 하자. 압축과 델타가 절반을 줄여 준다고 후하게 잡아도 한 달이면 저장소가 180 GB 근처로 간다. 그리고 Git의 기본 클론은 전체 히스토리를 받는다 — 새로 합류한 사람이 코드 한 줄을 보려고 180 GB를 내려받는다. 그 사람이 받는 동안에도 수치는 계속 늘고 있다.
애초에 필요한 것이 바이너리 자체가 아니라는 점도 짚어야 한다. 실무에서 답을 얻고 싶은 질문은 「이 모델은 어떤 데이터로 학습됐는가」이고, 그건 메타데이터로 답할 수 있는 질문이다. 그래서 데이터 버저닝 도구들이 공통으로 쓰는 구조가 있다. 파일 내용의 해시만 저장소에 두고, 실제 바이트는 밖에 둔다.
해시와 포인터
포인터 파일
DVC에서 dvc add data/train.csv를 실행하면 두 가지 일이 일어난다. 첫째, data/train.csv가 .gitignore에 등록된다 — Git이 이 파일을 더 이상 보지 않는다. 둘째, data/train.csv.dvc라는 파일이 생긴다.
# data/train.csv.dvc
outs:
- md5: 8f3a1c9e40b7d2f6a5c81e0d4b73d92b
size: 12884901888
path: train.csv
이게 전부다. 12 GB 파일의 자리를 대신하는 것이 세 줄, 100바이트 남짓이다. Git이 추적하는 것은 이 파일이고 git log에 남는 것도 이 파일의 변경 이력이다. 데이터가 바뀌면 md5 줄이 바뀌므로, 데이터의 변경이 처음으로 diff에 보인다. 앞 절에서 없다고 한 그 흔적이 여기서 생긴다.
그림의 왼쪽이 저장소, 오른쪽이 오브젝트 스토리지다. 21 GB의 실체가 오른쪽에 있고 왼쪽은 3 MB로 유지된다. 둘을 잇는 것은 같은 해시 문자열 하나다.
이 구조가 주는 성질이 좋다. 버전 이동이 코드와 같은 몸짓이 된다. 브랜치를 옮기면 포인터 파일의 md5가 바뀌고, 도구가 그 해시에 해당하는 실체를 받아 온다. 데이터를 되돌리는 일이 「어느 폴더에 백업해 뒀더라」가 아니라 git checkout이 되는 것이다. Git LFS도 비슷한 발상이지만, DVC는 파일 하나가 아니라 전처리부터 학습까지의 파이프라인 전체를 같은 방식으로 묶는 데까지 간다.
내용 주소화 저장
해시를 주소로 쓰는 저장 방식을 내용 주소화 저장(content-addressable storage)이라고 한다. 파일이 어디에 어떤 이름으로 놓여 있느냐가 아니라 내용이 무엇이냐가 위치를 정한다. 그림 오른쪽의 경로가 s3://ml-data/files/md5/8f3a1c... 인 것이 그 뜻이다.
여기서 중복 제거가 저절로 따라온다. 내용이 같으면 해시가 같고, 해시가 같으면 같은 자리를 가리키므로 두 번 저장되지 않는다. 검증 셋을 건드리지 않은 채 학습 셋만 갱신하며 버전을 열 번 끊어도 검증 셋의 바이트는 스토리지에 한 벌만 있다.
이 성질이 파일을 어떻게 쪼갤지에 영향을 준다. DVC는 파일 단위로 해시를 잡으므로, 10 GB짜리 파일 하나에서 뒤쪽 한 달치만 바뀌어도 10 GB 전체가 새 객체가 된다. 같은 데이터를 월별로 나눠 1 GB × 10으로 두면 바뀐 한 조각 1 GB만 새로 올라간다. 올릴 것이 10분의 1이고, 받는 쪽도 마찬가지다. 하루에 한 번씩 갱신하는 데이터라면 이 차이가 그대로 전송 비용과 대기 시간으로 쌓인다. 처음 설계할 때 「단일 큰 파일 대신 시간 축으로 샤딩」을 기본으로 잡는 이유다.
수명 주기 정책
이 구조에는 함정이 하나 있다. 저장소와 실체가 분리돼 있으므로, 한쪽만 남고 다른 쪽이 사라질 수 있다.
가장 흔한 형태가 오브젝트 스토리지의 수명 주기 정책(lifecycle policy)이다. 비용을 아끼려고 「90일 지난 객체는 아카이브 계층으로 내린다」거나 「1년 지나면 삭제한다」는 규칙을 걸어 두는 것은 흔한 운영이고, 대개 데이터 팀과 상의 없이 인프라 쪽에서 걸린다. 그러면 포인터 파일은 저장소에 멀쩡히 남아 있는데 가리키는 실체가 없다.
증상이 고약한 것은 평상시에 아무 문제가 없어 보인다는 점이다. git log도 정상이고 .dvc 파일도 정상이며 해시도 그대로다. 문제는 정확히 되돌려야 하는 순간에만 드러난다 — 규제 감사가 들어왔거나, 사고가 나서 여섯 달 전 모델을 다시 만들어야 할 때. 데이터 버저닝을 도입하는 이유가 되는 바로 그 순간이다.
그래서 도입할 때 정책 확인이 첫 항목이 되어야 한다. 캐시가 놓인 버킷과 프리픽스에 어떤 규칙이 걸려 있는지 보고, 필요하면 그 프리픽스만 정책에서 빼거나 보존해야 하는 태그의 데이터를 별도 프리픽스로 옮긴다. 그리고 정말 살아 있는지는 실제로 받아 봐야 안다 — 「분기에 한 번 옛 태그를 체크아웃해 받아 본다」 정도의 점검이 이 구멍을 막는 가장 싼 방법이다.
DVC 기본 사용
원격 저장소
DVC에서 실체가 놓이는 곳을 원격(remote)이라고 부른다. Git의 원격과 이름은 같지만 다른 것이고, 둘은 서로 독립적으로 설정된다.
dvc remote add -d storage s3://my-bucket/dvc-cache # S3
dvc remote add -d storage gs://my-bucket/dvc-cache # GCS
dvc remote add -d storage /mnt/shared/dvc-cache # 사내 NFS·로컬 경로
-d는 기본 원격으로 삼는다는 뜻이고, 이 설정은 .dvc/config에 적혀 Git으로 함께 추적된다. 인증 정보는 여기 두지 않는다 — 자격 증명은 각자의 환경(AWS 프로파일, 서비스 계정 키, CI의 시크릿)이나 Git이 추적하지 않는 로컬 설정에서 읽어 온다. 설정 파일은 공유하고 열쇠는 공유하지 않는 구조다.
원격을 로컬 경로로도 잡을 수 있다는 점이 처음 익힐 때 특히 유용하다. /tmp 아래에 원격을 하나 만들어 두면 클라우드 계정 없이도 push와 pull이 실제로 도는 것을 확인할 수 있다. 팀에 도입하기 전에 혼자 한 바퀴 돌려 보기에 이만한 것이 없다.
기본 명령
한 바퀴를 처음부터 끝까지 적으면 이렇다.
git init && dvc init # 한 저장소에 둘을 함께 연다
dvc add data/train.csv data/valid.csv # .dvc 포인터 생성, .gitignore 등록
git add data/.gitignore data/*.dvc
git commit -m "feat: 학습 데이터셋 v1"
git tag v1-data # 사람이 부를 이름을 붙인다
dvc push # 실체를 원격으로 올린다
여기서 중요한 것이 하나 있다. git push와 dvc push는 별개이고 둘 다 해야 한다. 포인터만 올리고 실체를 안 올리면 다른 사람이 클론했을 때 해시는 있는데 받아 올 곳이 없다. 협업에서 가장 자주 나는 사고가 이것인데, 다행히 기계적으로 막을 수 있다 — 이 절 마지막의 CI 검증이 그 자리다.
받는 쪽은 두 줄이다.
git clone <repo-url>
dvc pull # .dvc의 해시를 읽어 실체를 내려받는다
자주 쓰는 명령을 한자리에 모으면 이 정도다.
| 명령어 | 하는 일 |
|---|---|
dvc add <file> |
파일을 추적 대상으로 등록하고 .dvc를 만든다 |
dvc push / dvc pull |
실체를 원격에 올리고 원격에서 내린다 |
dvc checkout |
작업 폴더의 파일을 현재 .dvc의 해시에 맞춘다 |
dvc status |
작업 폴더·캐시·원격 사이의 어긋남을 보여 준다 |
dvc repro |
파이프라인에서 바뀐 단계만 다시 돌린다 |
dvc metrics show / dvc metrics diff |
기록된 지표를 출력하고 커밋 사이로 비교한다 |
태그와 체크아웃
되돌리기는 두 명령이 짝을 이룬다.
git checkout v1-data # 포인터 파일이 v1 시점의 해시로 돌아간다
dvc checkout # 작업 폴더의 데이터가 그 해시에 맞춰 바뀐다
첫 줄만 하고 둘째 줄을 잊는 것이 흔한 실수다. 그러면 포인터는 v1인데 디스크의 파일은 여전히 최신인 상태가 된다. 이 상태에서 학습을 돌리면 기록에는 v1이 남고 실제로는 다른 데이터로 학습된다 — 재현하려고 도입한 도구가 오히려 거짓 기록을 만드는 셈이다. dvc status가 정확히 이 어긋남을 보여 주므로, 체크아웃 뒤에 한 번 확인하는 습관이 값싸다.
여기에 dvc metrics diff HEAD~3이나 dvc params diff를 얹으면 되돌리기 전에 무엇이 달라졌는지 먼저 볼 수 있다. 전자는 세 커밋 전과 지금의 지표를, 후자는 하이퍼파라미터의 변경 이력을 보여 준다. Git 태그와 묶으면 코드·데이터·설정·지표 넷이 하나의 이름으로 잠긴다. 앞 절의 세 못에 결과 기록이 하나 더 붙은 모양이다.
PR과 CI 검증
사람이 기억으로 지키는 규약은 오래 못 간다. 두 가지를 기계에 맡기면 대부분의 사고가 막힌다.
첫째, .dvc 파일은 반드시 코드 PR에 함께 들어간다. 데이터 변경과 코드 변경이 다른 커밋으로 갈리면 어느 코드가 어느 데이터로 돌아갔는지가 흐려진다. 포인터가 코드와 같은 PR에 있으면 리뷰어가 「이 PR은 전처리 코드와 학습 데이터를 함께 바꾼다」를 한자리에서 본다.
둘째, CI에서 dvc pull과 dvc status를 돌린다. 학습 잡을 GitHub Actions로 돌린다면 이 두 줄이 자연스럽게 앞에 붙는다.
- run: dvc pull # 원격에 실체가 없으면 여기서 실패한다
- run: dvc status --cloud # 안 올라간 것이 있으면 잡는다
- run: python src/train.py
앞 소절에서 말한 「dvc push를 잊었다」가 여기서 걸린다. 사람이 모르고 지나간 것을 CI가 대신 발견하는 자리다. 원격 인증 정보는 Actions 시크릿에서 읽어 오고, 학습이 끝나면 모델 산출물을 dvc push로 되올려 다음 사람이 받을 수 있게 한다.
테이블 원본 고정
파일 스냅숏과 해시
지금까지는 학습 데이터가 파일이라고 가정했다. 실제로는 데이터 웨어하우스의 쿼리 결과인 경우가 더 흔하고, 이때는 접근이 달라진다.
가장 단순한 답은 스냅숏을 떠서 파일로 만드는 것이다. 학습 시점에 쿼리를 돌려 결과를 Parquet이나 CSV로 저장하고, 그 파일을 앞에서 본 방식대로 해시로 고정한다. 확실하다 — 그 시점의 바이트가 그대로 남으므로 상류 테이블이 그 뒤로 어떻게 바뀌든 재현이 보장된다.
대가는 저장 비용이다. 학습을 주 단위로 돌리고 매번 스냅숏을 뜨면 같은 데이터가 조금씩 다른 모습으로 계속 쌓인다. 앞에서 본 중복 제거가 도움이 되긴 하지만 그건 파일 전체가 같을 때만 듣는다. 행 하나만 달라져도 해시가 달라지고 다른 객체가 된다.
시점 조회
Iceberg나 Delta Lake 같은 테이블 형식(table format)은 다른 답을 준다. 이들은 테이블의 변경을 덮어쓰기가 아니라 스냅숏의 연속으로 쌓아 두고, 「특정 시점의 상태」를 그대로 읽게 해 준다. 시점 조회(time travel)라고 부르는 기능이다.
이 경우 데이터를 복사할 필요가 없다. 학습 기록에 스냅숏 ID나 타임스탬프 한 줄을 남기는 것으로 고정이 끝난다.
SELECT * FROM events
TIMESTAMP AS OF '2026-05-23 18:00:00'
문법은 엔진마다 조금씩 다르지만 하는 일은 같다. 저장 비용이 거의 안 붙는 대신 조건이 하나 붙는다 — 테이블이 옛 스냅숏을 얼마나 오래 들고 있느냐에 재현 가능 기간이 묶인다. 오래된 스냅숏과 그것이 참조하던 파일을 정리하는 유지 관리 작업이 주기적으로 도는 것이 보통이므로, 그 보존 기간이 며칠인지를 먼저 확인해야 한다. 앞에서 본 오브젝트 스토리지 수명 정책과 정확히 같은 형태의 함정이고, 이번에는 인프라가 아니라 테이블 유지 관리 쪽에 걸려 있다.
쿼리 기록
세 번째 방식은 흔하지만 위험하다. 기록에 SQL 문자열과 실행 시각만 남기는 것이다.
「이 쿼리를 3월 4일에 돌렸다」는 기록은 그 테이블에 이력이 남아 있을 때만 재현을 보장한다. 원본이 덮어쓰기 방식이면 3월 4일의 상태는 이미 세상에 없고, 같은 쿼리를 오늘 돌리면 다른 행이 나온다. 기록은 남았는데 재현은 안 되는 상태이고, 기록이 있다는 사실 때문에 오히려 그 사실을 늦게 안다.
셋을 한자리에 놓으면 이렇다.
| 방식 | 저장 비용 | 재현 보장 | 맞는 자리 |
|---|---|---|---|
| 파일 스냅숏 + 해시 | 높음 | 확실함 | 데이터가 크지 않고 규제 대응이 필요한 경우 |
| 테이블 시점 조회 | 낮음 | 보관 기간 안에서 확실함 | 웨어하우스가 이 기능을 지원하는 경우 |
| 쿼리 + 실행 시각만 | 없음 | 보장 안 됨 | 재현이 목적이 아닌 탐색 단계 |
셋 중 하나를 골라 끝까지 가는 문제가 아니라는 점도 짚어 둔다. 탐색 단계에서는 세 번째로 충분하고, 모델이 프로덕션에 올라가는 순간 첫째나 둘째로 옮기면 된다. 처음부터 모든 실험에 스냅숏을 뜨면 다시 볼 일 없는 파일로 스토리지가 차고, 그러면 얼마 못 가 누군가 수명 정책을 건다 — 앞에서 본 그 정책이다.
파이프라인과 실행 기록
dvc.yaml
파일 하나를 고정하는 것에서 한 걸음 더 가면, 그 파일이 어디서 나왔는가를 기록하는 문제가 된다. 원본에서 전처리를 거쳐 학습 데이터가 되고, 그것으로 모델이 나온다. 이 연결을 리니지(lineage)라고 한다.
DVC는 이 연결을 dvc.yaml에 단계로 선언한다. 각 단계가 무엇에 의존하고(deps) 무엇을 내놓는지(outs)를 적는 것이 전부다.
stages:
preprocess:
cmd: python src/preprocess.py
deps: [data/raw.csv, src/preprocess.py]
outs: [data/processed.csv]
train:
cmd: python src/train.py
deps: [data/processed.csv, src/train.py]
outs: [models/model.pkl]
metrics: [metrics/scores.json]
deps에 데이터와 코드가 나란히 적혀 있는 것이 이 파일의 요점이다. 전처리 결과가 원본 데이터만이 아니라 전처리 스크립트에도 달려 있다는 사실이 선언으로 남는다. 사람의 머릿속에만 있던 의존 관계가 파일에 적히는 것이고, 적혀 있으므로 기계가 검사할 수 있다.
단계 캐싱
dvc repro를 실행하면 DVC가 각 단계의 deps 해시를 확인하고 바뀐 단계와 그 아래만 다시 돌린다.
그림의 v1과 v2를 비교하면 동작이 눈에 보인다. preprocess.py의 커밋은 e3f1a로 같은데 원본 데이터의 해시가 a1b2c3에서 b9c8d7로 바뀌었다. 그러면 전처리부터 다시 돌아 train_v1.csv가 train_v2.csv로 갈리고, 그 아래 모델도 다시 만들어져 정확도가 0.82에서 0.87로 옮겨 간다. 반대로 데이터는 그대로인데 학습 스크립트만 고쳤다면 전처리는 건너뛰고 학습만 다시 돈다.
이 절약이 부수 효과가 아니라 목적이다. 전처리에 40분, 학습에 3시간이 걸리는 파이프라인에서 학습 코드 한 줄을 고쳤을 때 40분을 다시 쓰지 않는 것은 하루에 실험을 몇 번 돌릴 수 있느냐를 바꾼다. 세 시간 반이 세 시간이 되면 하루 두 번이 두 번 반이 되고, 그 차이가 닷새면 실험 두세 번이다. 그리고 이 판단이 사람의 기억이 아니라 해시 비교로 이뤄지므로 「전처리는 안 바뀌었을 거야」가 틀렸을 때 조용히 넘어가는 일도 없다.
실행 메타데이터
파이프라인 밖에서 돌리는 학습도 있고, 도구를 아직 안 붙인 팀도 있다. 도구와 무관하게 실행 하나당 이 정도가 남아 있으면 나중에 되짚을 수 있다.
run_id: 2026-05-24-a71c
code:
commit: 9c4a1f2
dirty: false # 커밋 안 된 변경이 있었는지
data:
train: {path: data/train.csv, md5: 8f3a1c...d92b, rows: 1284003}
valid: {path: data/valid.csv, md5: 2b91ef...07ac, rows: 142667}
source_snapshot: "iceberg://events@2026-05-23T18:00:00Z"
config:
params_hash: 5d7e0a1
seed: 42
env:
python: "3.12.4"
key_packages: {torch: "2.7.1", numpy: "2.1.3"}
outputs:
model: s3://ml-models/2026-05-24-a71c/model.safetensors
metrics: {auc: 0.8412, logloss: 0.3187}
dirty 필드가 작지만 중요하다. 커밋 안 된 수정이 작업 디렉터리에 있는 채로 학습을 돌리면 커밋 해시가 거짓말을 한다. 그 사실을 기록해 두면 최소한 「이 실행은 재현 불가」라는 것을 나중에 알 수 있다. git status --porcelain의 출력이 비었는지만 보면 되므로 넣는 비용도 거의 없다.
rows를 함께 적는 것도 값싸고 유용하다. 해시가 다르면 무엇이 달라졌는지 모르지만, 행 수가 128만에서 91만으로 줄었다면 상류에 필터가 하나 더 붙었다는 짐작이 바로 선다. 해시는 같으냐 다르냐만 답하고 행 수는 어느 방향으로 달라졌는지를 알려 준다.
실험 추적 도구를 이미 쓰고 있다면 이 기록을 따로 만들 필요 없이 실행 태그로 넣으면 된다. DVC는 관리 중인 데이터의 위치를 조회하는 API를 함께 제공하므로 두 도구를 잇는 것도 몇 줄이다.
import mlflow, dvc.api
data_url = dvc.api.get_url("data/train.csv", rev="v2-data")
with mlflow.start_run():
mlflow.set_tag("data_version", "v2-data")
mlflow.set_tag("data_url", data_url)
mlflow.log_metrics({"auc": 0.8412})
실험 추적 쪽에 지표가 쌓이고 데이터 버전이 그 옆에 태그로 붙으면, 「이 실험을 그대로 다시 돌려라」가 태그 하나를 조회하는 것으로 시작된다.
데이터 파일 밖의 구멍
숨은 의존
버전을 매겨야 하는 것이 데이터 파일만은 아니라는 점에서 자주 구멍이 난다. 학습에 영향을 주면서 조용히 바뀌는 것이 여럿 있다.
- 전처리 코드 — 파이프라인 스크립트가 사내 라이브러리로 분리돼 있으면 그 라이브러리 버전까지 고정해야 한다. 토큰화 방식이 마이너 업데이트로 달라지는 일이 실제로 있다
- 라벨링 기준 — 같은 데이터라도 어노테이션 가이드가 3판에서 4판으로 바뀌면 다른 라벨이 붙는다. 라벨링 가이드의 판 번호를 데이터셋 메타데이터에 함께 넣는다
- 피처 정의 — 피처 스토어를 쓴다면 각 피처의 계산 로직에 버전이 있어야 한다. 「최근 30일 구매액」의 창이 30일에서 28일로 바뀌었는데 이름이 같으면 아무도 모른다
- 외부 참조 데이터 — 지역 코드표, 카테고리 분류, 금칙어 목록처럼 가끔 갱신되는 작은 파일들. 작아서 신경을 안 쓰는데 결과를 바꾼다
공통점이 있다. 넷 다 크기가 작거나 코드처럼 안 보여서 버전 관리의 눈밖에 난 것들이다. 12 GB 파일은 눈에 띄어서 도구를 붙이는데, 200줄짜리 카테고리 매핑 CSV는 그냥 저장소 한구석에 들어가 있다가 어느 날 조용히 갱신된다. 기준은 크기가 아니다. 그것이 바뀌면 학습 결과가 달라질 수 있는가, 그것뿐이다.
완전 재현과 허용 오차
여기에 한 가지가 더 있다. 위의 것을 다 고정하고 난수 시드까지 박아도 완전히 같은 값이 안 나오는 경우다. 병렬 데이터 로더가 배치를 만드는 순서, GPU에서 부동소수점 덧셈이 누적되는 순서 같은 것이 실행마다 달라질 수 있다. 덧셈의 순서가 다르면 반올림이 다르게 쌓이고, 그 미세한 차이가 학습을 거치며 커진다.
이 자리에서는 목표를 잘 잡는 것이 답이다. 비트 단위 완전 재현을 목표로 잡으면 대개 비용이 감당이 안 된다. 결정적 연산만 쓰도록 강제하면 느려지고, 어떤 연산은 결정적 구현 자체가 없어 우회로를 짜야 한다.
대부분의 팀에게 필요한 것은 「같은 데이터와 설정으로 다시 돌렸을 때 지표가 오차 범위 안에서 재현되는 것」이지 동일한 가중치 파일이 아니다. 그렇다면 실제로 해야 할 일은 그 오차 범위를 숫자로 적어 두는 것이다 — 「AUC ±0.003 안이면 같은 실행으로 본다」처럼 정해 두면 재현 결과가 그 밖으로 나갔을 때 그것이 곧 이상 신호가 된다. 범위를 안 적어 두면 0.841과 0.834를 놓고 매번 처음부터 논쟁한다.
완전 재현이 정말로 요구되는 자리 — 규제 감사, 사고 조사 — 를 먼저 가려내고 그 자리에만 비용을 쓴다. 나머지는 허용 오차로 충분하다.
버전 구분 기준
마지막은 운영의 문제다. 버전을 너무 자주 끊으면 관리가 안 되고 너무 드물게 끊으면 의미가 없다. 실용적인 기준은 앞 소절에 이미 나왔다 — 이 변경 뒤에 학습을 다시 돌리면 결과가 달라질 수 있는가. 달라질 수 있으면 새 버전이고, 아니면 그냥 두면 된다.
그리고 버전에는 사람이 읽을 수 있는 설명을 붙인다. 해시만 늘어선 목록은 여섯 달 뒤에 아무 도움이 안 된다.
| 버전 | 날짜 | 무엇이 달라졌는가 |
|---|---|---|
| v3 | 2026-02-14 | 결측 처리 방식 변경 — 중앙값 대체에서 별도 결측 표시로 |
| v4 | 2026-03-22 | 상반기 데이터 추가, 행 91만에서 128만으로 |
| v5 | 2026-04-30 | 중복 세션 제거 (동일 사용자·동일 시각 12,847건) |
이 목록이 있으면 「v4에서 지표가 뛴 이유는 데이터가 늘어서구나」를 바로 안다. 없으면 매번 데이터를 다시 뒤진다. git tag -a의 메시지에 이 한 줄을 함께 적어 두면 태그 목록이 그대로 이 표가 되므로, 표를 따로 관리할 필요도 없다.
여기까지가 「무엇이 어떤 데이터로 만들어졌는가」를 남기는 이야기다. 남은 것은 그 기록을 사람이 손으로 채우지 않게 만드는 일이다. 데이터를 받아 오고, 전처리하고, 학습하고, 평가해 배포까지 가는 길이 하나의 흐름으로 이어져 자동으로 돌면 실행 기록은 그 흐름의 부산물로 저절로 쌓인다. 다음 글은 그 흐름을 끝까지 잇는 이야기다.
읽어주셔서 감사합니다. 😊

