writing
2026-05-01 FinAI Build KO 조회

[4개월 개발기] 에피소드 5 — 데이터 무결성 사냥

#finai-build#data-integrity#leakage#financial-ai
EN English version

*“4개월간의 금융 AI 개발기” 5편. 에피소드 4까지는 주로 ‘어떤 아키텍처를 왜 골랐는가’에 대한 화려하고 우아한 설계 이야기를 다루었다. 하지만 이번 편은 그 아키텍처가 의미 있는 숫자를 내뱉기 전에, 모델의 입으로 들어가는 **‘입력 데이터’*가 과연 올바른지 의심하고 확인했던 개발 과정의 기록이다. 피할 수 없는 일반적인 데이터 무결성 검증 작업이 세 번의 반복 끝에 어떤 모습으로 안정되었는지 살펴본다.

첫 번째 리키지(Leakage) — has_nba 중복 컬럼의 덫

첫 번째 제거 실험(Ablation v1)이 끝났을 때, 전체 성능(AUC) 지표가 의심스러울 정도로 높게 치솟았다. 우리가 아주 보수적으로 잡았던 베이스라인 모델의 성능이 0.68 수준이었는데, 막 엮어낸 복잡한 이종 전문가 구성이 무려 0.87이라는 높은 숫자를 뱉어낸 것이다. 이 정도의 압도적인 차이는 알고리즘의 우수함을 증명하는 것이 아니라, 십중팔구 데이터 쪽에 주요한 구멍이 뚫렸다는 강력한 경고 시그널이다.

원인을 파고들자 범인은 has_nba 컬럼이었다. ‘Next Best Action (NBA)‘는 우리가 예측하려는 핵심 태스크 중 하나로, “이 고객에게 다음에 추천할 최적의 상품은 무엇인가”를 맞히는 과제였다. 그런데 예상외로 원본 CSV 파일에 이미 has_nba라는 필드가 버젓이 피처(Feature)로 들어가 있었던 것이다. 즉, 모델은 타겟에 대한 정답의 일부를 입력 피처로 훔쳐보면서 명확하게 학습을 진행한 셈이다.

해당 컬럼을 색출하여 제거하자, AUC는 0.87에서 0.71로 곤두박질쳤다. 뛰어난 성과가 깎여나간 순간엔 잠시 실망감이 스칠지 몰라도, 사실 이는 엄청난 안도감을 안겨주는 순간이다. 비로소 알고리즘이 아무런 반칙 없이 정정당당하게 땀 흘린 진짜 실력을 측정할 수 있게 되었기 때문이다. 이것이 바로 리키지 탐지의 진정한 가치다. 겉보기 숫자는 초라하게 떨어뜨릴지언정, 그 초라한 숫자야말로 진짜가 된다.

두 번째 리키지 — Ground Truth 파일의 Glob 정렬 순서

has_nba 문제를 수정한 바로 그 세션 안에서, 우리의 든든한 파트너 Claude Code는 쉴 틈 없이 다음 문제를 중점적으로 다뤘다 (에피소드 2에서 “같은 세션에서 일어난 연쇄 탐지”라고 극찬했던 바로 그 짜릿한 케이스다).

정답지(Ground Truth)가 담긴 파일들을 파이썬의 glob 모듈로 불러올 때, 시스템은 파일 이름들을 알파벳 순서(Alphabetical)대로 무심코 읽어 들이고 있었다. 그런데 소름 돋게도 이 파일들의 정렬 순서가 학습(Train)과 검증(Validation) 데이터셋을 나누는 분할 경계와 우연히 강한 상관관계를 맺고 있었다.

구체적으로 설명하자면 이렇다. 파일 이름의 고객 ID가 앞쪽에 위치한 파일일수록 오래된 충성 고객이 많았고, 뒤쪽 파일일수록 최근에 가입한 신규 고객이 매우 많았다. 이 미묘한 분포의 차이가 검증 데이터셋에 체계적인 편향(Bias)을 만들어 버린 것이다. 모델은 데이터의 본질을 학습한 것이 아니라, “최근에 가입한 고객들은 주로 ABC 상품을 산다”는 식의 알맹이 없는 잘못된 규칙(Spurious Rule)을 학습해 버렸고, 그 결과 검증 단계에서 정답률이 뻥튀기되어 높게 나온 것이다.

해결책은 의외로 간단했다. glob으로 불러온 파일 목록을 **명시적인 ID 기반의 무작위 섞기(Shuffle)**로 교체했다. 검증 데이터의 분포가 비로소 학습 데이터와 성공적으로 일치하게 되자, AUC는 0.71에서 0.66으로 또다시 아프게 깎여나갔다. 거듭 강조하지만, 이 낮아진 수치가 바로 과장 없는 진짜 실력이다.

세 번째 리키지 — 제너레이터(Generator)의 레이블 훔쳐보기

그 치열했던 세션의 말미에 마침내 세 번째 리키지가 모습을 드러냈다. 피처를 생성하는 우리 제너레이터(Feature Generator) 모듈 중 일부가, 학습 과정에서 예측해야 할 정답(Label)을 교묘하게 입력값으로 받아들여 조각(Piecewise) 피처를 만들어내고 있었다. 예를 들어 특정 제너레이터가 “이 고객이 특정 세그먼트에 속할 확률”을 정교하게 계산해 내야 하는데, 그 확률 연산 공식 안에 검증용 레이블 값이 조용히 들어가 버린 것이다.

이 예상치 못한 사고의 근본 원인은 어댑터(Adapter)와 제너레이터 간의 관심사 분리(Separation of Concerns) 원칙이 특정 지점에서 무너졌기 때문이다 (이것이 에피소드 3에서 CLAUDE.md의 §1.2 조항을 그토록 강조했던 이유다). 제너레이터가 DataFrame에서 레이블 컬럼을 명시적으로 버리지(Drop) 않고, 자신이 필요한 컬럼만 쏙쏙 골라내어(Select) 쓰는 구조였는데, 그 선택 목록에 개발자의 실수로 레이블 컬럼이 조용히 끼어 들어가 버렸던 것이다.

결국 이 구멍을 틀어막자 AUC는 0.66에서 0.62로 또 한 번 바닥을 쳤다. 빠르게 이어진 세 번의 연쇄 탐지를 통해, 무려 0.25라는 거대한 AUC 수치가 완전히 허상에 불과했다는 중요한 진실이 드러났다. 이 모든 발견과 수정이 단 한 번의 세션, 며칠의 작업 만에 이루어졌다. 만약 인간 엔지니어가 이 3개의 숨겨진 리키지를 각자 따로 떼어놓고 수동으로 디버깅했다면 족히 수 주일은 걸렸을 복잡한 버그들이었다. 세션 전체의 맥락을 유지하는 Claude Code가 “앞서 수정한 논리적 맥락을 그대로 기억한 상태에서, 곧바로 다음 의심점으로 파고드는” 연계 추론 능력을 발휘해 준 덕분에 이 과정이 단축될 수 있었다.

18개에서 13개로 태스크 축소 — 결정론적 리키지의 덫

리키지 3연쇄를 무사히 진압한 이후에도, 18개 중 일부 태스크들은 여전히 뒷맛이 찜찜할 정도로 비정상적인 수치를 뱉어내고 있었다. 특히 income_tier, tenure_stage, spend_level, engagement_score 같은 태스크들은, 제거 실험에서 전문가 구성을 어떻게 비틀어도 어김없이 0.99+라는 비정상적으로 높은 AUC를 기록했다. 0.99라는 숫자는 결코 우리 모델이 경이로울 정도로 훌륭하다는 뜻이 아니다. 그것은 애초에 **태스크의 정의 자체가 기존 피처의 단순한 수학적 변환(Deterministic Transformation)**에 불과하다는 중요한 조롱이다.

실제로 데이터를 뜯어보니 예상 밖이었다. income_tier 레이블은 기존 income 피처를 단순히 5개의 구간(Bin)으로 기계적으로 나눈 결과물이었고, tenure_stage는 고객 유지 기간(tenure_months) 피처를 6단계로 단순 그룹화(Bucketing)한 것에 불과했다. 모델 입장에서는 입력으로 주어진 피처값만 빠르게 훑어봐도 정답 레이블을 100% 성공적으로 복원해 낼 수 있었으니, AUC가 1.0에 수렴하는 것은 당연한 이치였다.

이런 무의미한 껍데기 태스크들을 굳이 다중 태스크 학습(MTL) 파이프라인에 남겨두면 두 가지 심각한 부작용이 발생한다. 첫째, 너무나 쉽게 정답을 맞혀버리니 해당 태스크의 손실(Loss) 값이 비정상적으로 작아져서, 모델이 다른 진짜 어려운 태스크들을 학습하는 데 전혀 기여하지 못하는 애물단지가 된다. 둘째, 0.99라는 뻥튀기된 수치가 전체 전이 학습(Transfer Learning)의 평균 평가 지표를 더럽히고 오염시킨다.

결론은 명확했다. 단순 산수 변환에 불과한 5개의 ‘결정론적(Deterministic) 태스크’를 파이프라인에서 즉시 도려냈다. 18개였던 태스크는 비로소 정예화된 13개로 압축되었다. 이 중요한 교훈 직후, 우리의 기본 규약인 CLAUDE.md 문서 §1.3 조항에는 **“기존 피처의 단순한 변환만으로 파생되는 레이블은 절대 태스크로 사용하지 않는다”**는 엄격한 규칙이 즉각 추가되었다. 당연히 이후 진행된 모든 제거 실험(Ablation Study)에서 이런 유형의 껍데기 태스크들은 처음부터 논의의 대상조차 되지 못했다.

합성 데이터(Synthetic Data)의 늪: v2 → v3 → v4 이터레이션

벤치마크용으로 우리는 Kaggle의 Santander Customer Transaction Prediction 공개 데이터셋(약 94만 건)과, 이를 분포 특성으로 흉내 낸 100만 행 규모의 합성 데이터(Synthetic Data)를 병렬로 사용해 실험을 진행했다. 그런데 문제가 생겼다. 초기 버전인 v1 합성 데이터로 모델을 충분히 학습시켜 놓아도, 막상 Santander 실데이터에 전이(Transfer)시키면 성능이 무너져 내렸다. (합성 데이터 AUC 0.82 vs Santander 실데이터 AUC 0.54)

우리는 원인을 찾기 위해 지난한 이터레이션(Iteration)에 돌입했다.

  • v2: 피처들의 전체적인 분포 매칭을 훨씬 정교하게 다듬었다. 여전히 실제 데이터로의 전이는 실패.
  • v3: 피처들 간의 복잡한 상관관계(Correlation)까지 수학적으로 꼼꼼하게 맞췄다. 약간의 성능 개선은 있었으나 여전히 부족했다.
  • v4: 마침내 데이터에 숨겨진 **시계열 의존성(Temporal Dependency)**까지 성공적으로 매칭해 냈다. 그제야 비로소 실데이터로의 전이가 극적으로 성공했다.

v1에서 v4로 넘어가면서 통계적 매칭의 수준은 놀랍도록 정교해졌지만, 사실 이 모든 시행착오의 근본적인 원인은 딱 하나였다. 바로 합성 데이터 생성기가 은연중에 ‘자기 자신을 복제하는’ 주요한 경향성에 빠져 있었던 것이다.

초기의 v1 생성기는 GAN(적대적 생성 신경망) 기반이었는데, 이 복잡한 GAN 모델은 실제 데이터가 가진 패턴 중에서 학습하기 “쉬운 부분”만 매우 쏙쏙 빼먹고, 학습하기 “어려운 복잡한 부분”은 명확하게 무시해 버렸다. 결과적으로 우리 추천 모델이 v1 합성 데이터에서 뼈 빠지게 배운 것은, 그저 현실 세계의 “너무나 쉽고 뻔한 패턴”에 지나치게 훈련되어 버린 과적합(Overfitting) 덩어리였던 것이다.

그래서 v4 버전에서는, 시계열 데이터 특유의 끈적한 의존성을 상태 공간 모델(State-Space Model)을 통해 명시적으로 강력하게 인코딩했다. 생성기가 회피하던 그 “어려운 부분”을 데이터 안에 강제로 욱여넣은 것이다. 현실의 복잡성을 제대로 담아낸 독한 데이터로 훈련받고 나서야, 모델은 Santander 실데이터 앞에서도 무너지지 않고 훌륭하게 전이(Transfer)될 수 있었다.

왜 이 일반적인 작업을 가장 먼저 풀어야만 했는가

“그래서 HGCN이 훌륭한가, LightGCN이 더 우월한가?” 같은 흥미진진한 아키텍처 논쟁은, 오직 모델에 들어가는 입력 데이터가 한 치의 오점 없이 깨끗할 때만 의미를 가진다. 앞서 언급한 리키지나 오염 요소 중 단 하나라도 데이터에 숨어 있다면, 수십 번의 제거 실험(Ablation) 결과는 “어떤 알고리즘이 본질적으로 더 우수한가?”를 측정하는 것이 아니라, 그저 **“어느 알고리즘이 데이터에 뚫린 구멍(Leakage)을 더 얌체같이 잘 분석하여 점수를 조작하는가?”**를 측정하는 예상치 못한 코미디 대회가 되어버린다. 그런 오염된 결과로 도배된 논문은 실제 프로덕션 환경에 배포되는 순간 박살 나게 마련이다.

솔직히 말해, 데이터 무결성 사냥은 전혀 매력적이지 않은 고역이다. 번뜩이는 새로운 알고리즘을 코딩하며 쾌감을 느끼는 대신, 이미 존재하는 일반적인 데이터 더미를 끝없이 의심하고 또 의심해야 하는 막노동이다. 며칠 밤을 새워도 성취감은커녕 겉으로 보여줄 만한 진척조차 없다. 특히나 우리처럼 3명이 4개월이라는 긴장되는 시간 압박 속에서 달리는 팀에게, “하루빨리 그럴듯한 결과를 눈으로 보고 싶다”는 달콤한 유혹을 떨쳐내는 것은 정말 어려운 고통이었다.

하지만 우리가 이 유혹에 무너지지 않고 꿋꿋이 버틸 수 있었던 비결은 딱 하나였다. 초반에 잡아냈던 리키지들이 우리의 헛된 AUC를 얼마나 가차없이 끌어내렸는지, 그 아찔한 추락의 기억이 뇌리에 깊게 박혀 있었기 때문이다. 0.87이라는 허상의 숫자가 0.62라는 냉정한 현실로 곤두박질치는 것을 세 번이나 연달아 목격하고 나면, “설마 이번 데이터엔 또 어떤 함정이 숨어있을까?”라는 체질화된 의심이 아주 자연스러운 기본 장착 태도가 될 수밖에 없다.

이 사투가 남긴 방법론적 함의

이 데이터 무결성 사냥의 경험은, 결국 우리 팀의 기본 규약인 CLAUDE.md 문서에 다음과 같은 5가지 피 묻은 **‘리키지 방지 절대 조항’**을 강제로 박아 넣게 만들었다.

  1. 스케일러(Scaler)는 오직 TRAIN 분할 셋에서만 fit을 수행할 것.
  2. 시계열(Temporal) 분할 시 반드시 충분한 간격(gap_days, 최소 7일)을 강제로 띄울 것.
  3. 시퀀스 데이터의 가장 마지막 타임스텝(Timestep)이 예측해야 할 레이블(Label) 시점과 조금이라도 겹치지 않는지 이중으로 검증할 것.
  4. 모델 학습을 시작하기 직전에는 예외 없이 무조건 LeakageValidator를 호출하여 검사받을 것.
  5. 기존 피처를 단순히 수학적으로 변환하여 파생된 껍데기 레이블은 절대 태스크(Task)로 취급하지 말 것.

에피소드 3에서 우리가 왜 CLAUDE.md를 ‘기본 규약’이라고 불렀는지 그 진정한 이유가 바로 여기에 있다. 새로운 실험을 세팅할 때마다 이 복잡하고 피곤한 조항들을 일일이 인간의 머리로 기억해 낼 필요가 없다. 새로운 팀원이 중간에 합류하더라도 별도의 길고 일반적인 인수인계 없이, 규약 파일 하나만으로 성공적으로 안전망이 가동된다.

다음 편

이어지는 에피소드 6에서는, 이 심각한 데이터 무결성 늪을 간신히 빠져나온 직후에 마주해야 했던 또 다른 형태의 심각한 절망을 다룬다. 불확실성 가중치(Uncertainty Weighting) 모듈에 숨어 있던 교묘한 구현 버그 하나를 간신히 수정했더니, 이전까지 압도적이던 Sigmoid 게이트 아키텍처의 우위가 하루아침에 뒤집혀 Softmax에게 뒤처진 충격적인 역전극에 대한 이야기다. **“단순한 훈련 환경의 버그 하나가, 거대한 아키텍처 설계에 대한 철학적 결론마저 통째로 오염시킬 수 있다”**는 뼈아픈 방법론적 교훈을 생생하게 해부한다.

원문 자료: 개발 스토리 §9 “데이터 무결성 감사”.