인자 순서 하나가 수익률을 +367%로 만들었다

2026-07-16 · 개발 로그

백테스트 결과에 +367%라는 수익률이 찍혔습니다. 그 종목은 그만큼 오른 적이 없습니다.

범인은 함수 하나의 인자 순서였습니다. 그런데 이 글은 "순서를 고쳤더니 해결됐다"로 끝나지 않습니다. 순서를 고치자 그 아래에서 설계 결함이 나왔고, 그걸 걷어내자 더 아래에서 세 번째 문제가 나왔습니다. 세 겹을 차례로 벗긴 기록입니다.

첫 번째 겹 — coalesce의 방향

발단은 DB 용량 절감이었습니다. "과거 그날 상위 종목을 샀다면 지금 얼마인가"를 계산하려면 그날의 종가가 필요한데, 원래는 일별 가격 테이블을 매번 조회했습니다. 이걸 랭킹 스냅샷에 그 시점의 종가를 함께 저장해두는 방식으로 바꿨습니다. 과거 가격 데이터를 통째로 들고 있지 않아도 되게요.

저장된 스냅샷이 재계산되어 다시 쓰일 때(백필 재실행, 배치 재시도, 결측 자동 복구), 기존 행과 충돌이 납니다. 그 충돌 처리에 이렇게 썼습니다.

-- 문제의 코드
saved_close = COALESCE(기존_값, 신규_값)

COALESCE는 앞에서부터 첫 번째 NULL 아닌 값을 반환합니다. 이 순서면 기존 값이 있는 한 신규 값은 영원히 무시됩니다. 의도는 "빈 곳만 채운다"였겠지만, 실제 효과는 최초 1회 저장된 값의 영구 박제입니다.

올바른 방향은 반대였습니다.

-- 수정된 코드: 신규 값 우선, 없으면 기존 값 유지
saved_close = COALESCE(신규_값, 기존_값)

한 가지 뼈아픈 사실 — 같은 테이블의 시가총액 컬럼은 처음부터 올바른 순서로 들어가 있었습니다. 같은 upsert 문 안에서 컬럼마다 컨벤션이 달랐고, 아무도 그 불일치를 눈치채지 못했습니다. 코드 리뷰에서 COALESCE(a, b)COALESCE(b, a)는 둘 다 그럴듯해 보입니다.

왜 박제가 문제인가 — 수정주가는 과거를 다시 쓴다

"한 번 저장한 값이 안 바뀐다"가 왜 문제일까요. 그날의 종가는 그날로 확정된 사실 아닌가요.

아닙니다. 수정주가는 소급 조정되는 값입니다. 액면분할이 나면 과거 전체 가격이 새 비율로 다시 계산됩니다. 배당이 나가면(배당 반영 소스의 경우) 역시 과거 전체가 재조정됩니다. 즉 일별 가격 테이블의 "2026년 3월 3일 종가"는 오늘 조회하면 어제 조회한 값과 다를 수 있는 숫자입니다.

그런데 스냅샷에 박제된 종가는 그 소급 조정을 받을 기회가 없습니다. coalesce가 신규 값을 버리니까요. 시간이 지날수록 일별 테이블과 박제값 사이가 벌어집니다.

이 괴리가 수익률 분모로 들어가면 증상이 나타납니다. 그리고 증상의 방향은 박제된 값이 실제보다 높았느냐 낮았느냐에 따라 갈립니다.

같은 버그가 증상을 양방향으로 냅니다. 어떤 종목은 말도 안 되게 벌었고 어떤 종목은 말도 안 되게 잃은 것으로 나옵니다. 증상만 보면 서로 다른 두 버그처럼 보이지만, 뿌리는 COALESCE 인자 두 개의 순서 하나였습니다.

두 번째 겹 — 순서를 고쳐도 설계는 그대로다

coalesce 순서를 뒤집고, 오염됐을 수 있는 기간을 통째로 재계산해 박제값을 강제로 갱신했습니다. 여기서 끝났다고 생각했습니다.

이 글을 쓰며 검증차 전수 대조를 돌렸습니다. 저장된 박제 종가와 현재 일별 테이블의 같은 날짜 값을 비교 가능한 203만 건에 대해 맞춰봤습니다. 80건이 5% 이상 어긋나 있었습니다. 수정 이후에 생긴 괴리입니다.

대표 사례가 FIZZ(National Beverage)입니다. 7월 초 나흘치 박제값이 현재값보다 정확히 +10.6%씩 일관되게 높았습니다. 원인을 배당 이력에서 찾았습니다 — 7월 13일에 주당 $3.25 특별배당이 나갔고, 배당 반영 소스가 과거 전체를 소급 재조정한 겁니다. $29.36 기준으로 $3.25면 정확히 그 10.6%입니다. 계산상 예상 괴리와 실측이 일치했습니다. MIDD(+24.3% ↔ 조정계수 1.243), STRS(+23.8% ↔ 특별배당 $5)도 같은 패턴입니다.

즉 coalesce 순서를 고친 뒤에도, 박제 시점 이후에 소급 조정이 일어나면 괴리는 다시 생깁니다. 재계산이 돌기 전까지의 창에서요. 순서 수정은 "오염이 영원히 남는" 문제를 "다음 갱신까지 남는" 문제로 줄였을 뿐입니다.

진짜 결함은 인자 순서가 아니라 설계였습니다. 소급 조정되는 시계열에서 한 시점의 값을 떠내 고정 저장한다는 발상 자체가, 그 시계열의 성질과 모순됩니다.

이 컬럼과 그걸 쓰던 기능은 결국 통째로 폐기됐습니다. 지금 FomoBot의 백테스트는 박제값이 아니라 일별 가격에서 매번 실측하는 buy-and-hold/DCA 엔진으로 완전히 교체됐고, 위의 80건은 이제 어떤 코드도 읽지 않는 죽은 컬럼에 남은 화석입니다. 사용자에게 보이는 숫자에는 영향이 없습니다 — 하지만 설계 결함이 순서 수정 후에도 살아 있었다는 증거로는 충분합니다.

세 번째 겹 — "그 시점의 가격"이 없는 종목

80건을 분류하다가 하나가 튀었습니다. CORZW라는 워런트 티커인데, 박제값과 현재값의 괴리가 최대 +105%였고, 배당·분할 어느 쪽으로도 설명이 안 됐습니다.

일별 가격을 통째로 뽑아보니 이 티커는 한 달 내내 거래량 0, 종가 $9.15에 못 박혀 있었습니다. 사실상 거래가 실종된 상태로, 데이터 공급자가 마지막 체결가를 매일 반복 저장하고 있었던 겁니다. 그런데 같은 기간 박제값은 $18.80 → $16.95 → $14.65로 독자적으로 움직였습니다. 두 값이 애초에 같은 것을 가리키고 있지 않았을 가능성이 큽니다. 어느 코드 경로가 어떤 값을 읽었는지는 로그가 남지 않아 확정할 수 없었습니다.

여기서 드러나는 건 coalesce와도, 박제 설계와도 다른 세 번째 층위입니다. 거래가 없는 종목에는 "그 시점의 가격"이라는 것이 단일하게 정의되지 않습니다. 마지막 체결가, 호가, 공급자의 보정값 — 어느 것을 "가격"이라 부를지부터 선택의 문제입니다. 두 시스템이 다른 선택을 하면, 버그가 없어도 값은 어긋납니다.

남는 교훈

1. upsert의 coalesce 순서를 컨벤션으로 못 박으십시오. COALESCE(신규, 기존)이 기본이고, 반대 방향("최초값 보존")이 필요하면 그 의도를 주석으로 강제하십시오. 같은 테이블 안에서 컬럼마다 방향이 다르면, 리뷰로는 못 잡습니다.

2. 소급 조정되는 값은 박제하지 마십시오. 수정주가는 "확정된 과거"가 아니라 "계속 다시 쓰이는 과거"입니다. 그걸 한 번 떠내 저장하는 순간, 원본과의 괴리는 시간문제입니다. 캐시가 필요하면 갱신 경로까지 설계하고, 그게 안 되면 매번 원본에서 계산하십시오. 우리는 결국 후자로 돌아갔습니다.

3. 증상의 방향이 다르다고 다른 버그라 단정하지 마십시오. +367% 폭증과 반토막 왜곡은 정반대 증상이지만 같은 뿌리였습니다. 박제값이 어느 쪽으로 틀렸느냐만 달랐습니다.

4. 고친 뒤에 전수 대조를 돌리십시오. 순서를 고치고 재계산까지 했는데도 80건이 남아 있었습니다. "고쳤다"는 커밋과 "괴리가 없다"는 검증은 다른 것입니다. 이번 80건은 죽은 컬럼이라 무해했지만, 살아 있는 컬럼이었다면 이 글의 제목이 달라졌을 겁니다.

이 사건은 LAG 함수, 거래일이 아닌 날짜와 함께 같은 계열의 세 번째 기록입니다. 셋 다 크래시 없이, 그럴듯한 숫자를 뱉으면서, 데이터가 조용히 틀리는 사고였습니다. 금융 데이터 파이프라인에서 가장 무서운 버그는 예외를 던지는 버그가 아니라 정상으로 보이는 버그입니다.

이 글에 관하여 — 본문의 +367%는 버그 발견 당시(2026-07 초) 관측·기록된 값입니다. 해당 오염 데이터는 수정 커밋과 함께 실행된 재계산으로 덮어써져 현재 DB에서는 재현되지 않으며, 수치의 출처는 당시 작업 기록입니다. 반면 FIZZ·MIDD·STRS·CORZW의 괴리(80건)는 이 글 작성 시점에 재현 가능한 현재 데이터이며, 배당·분할 이력과의 대조로 검증했습니다. 발견 경위 일부(가격 데이터 복구 작업 중 이상 발견)는 작업 타임라인에 근거한 추정입니다. 해당 컬럼과 기능은 현재 폐기되어 사용자에게 제공되는 수치와 무관합니다.

투자 판단에 대하여 — FomoBot은 이미 지나간 가격을 정리해 보여주는 도구입니다. 이 글은 개발 기록이며, 어떤 내용도 투자 자문이 아닙니다.