코드는 정확하고 테스트도 통과하지만, 결코 실행되지 않는 버그 클래스
요약
코드가 문법적으로 정확하고 유닛 테스트를 통과하더라도, 논리적 배선(wiring) 문제로 인해 실제 실행 경로에서 작동하지 않는 버그 유형을 분석합니다. 설정값 오류나 상태 유지형 함수의 분기 처리 미흡이 가져오는 위험성을 경고합니다.
핵심 포인트
- 테스트 통과가 실제 기능의 정상 작동을 보장하지 않음
- 잘못된 기본값 설정(Toggle)은 코드 커버리지가 높아도 치명적 결함 유발
- 상태를 유지하는 함수(Stateful helper) 사용 시 분기 처리에 주의 필요
- 모든 조건부 경로를 실행하거나 상태를 업데이트하도록 설계해야 함
저희 에이전트 중 한 명을 조사했을 때, 실제 결함의 대다수 — 제가 다시 분류했을 때 9개 중 8개 — 가 단 하나의 카테고리에 속해 있었습니다. 오프 바이 원 (off-by-one) 오류도, 레이스 컨디션 (race condition)도, 잘못된 정규 표현식 (regex)도 아니었습니다.
그 카테고리는 바로 이것입니다: 코드는 올바르게 작성되었고, 테스트도 올바르게 수행되었지만, 정작 중요한 경로에서는 결코 실행되지 않았다는 점입니다.
그 결함들은 대시보드상으로는 모두 정상적으로 보였습니다. 모두 유닛 테스트 (unit tests)를 통과했습니다. 테스트가 통과한 이유는 함수를 직접 호출했기 때문이며, 그 함수 자체는 문제가 없었습니다. 문제는 배선 (wiring)에 있었습니다.
사례 1: 배포된 기능이 꺼져 있었던 경우
저희는 예측 구간 (forecast intervals)을 게시하며, 어느 시점에 조건부 로직 (conditioning)을 추가했습니다. 모든 시장 상황에 걸쳐 과거 분위수 (historical quantiles)를 읽는 대신, 현재와 유사한 기간으로 샘플을 필터링하는 방식입니다. 이는 매우 중요합니다. 무조건부 구간 (unconditional intervals)은 평온한 시장이 겪지 않아도 될 난기류에 대한 영구적인 허용치를 포함하기 때문입니다.
코드는 작성되었습니다. 정확했습니다. 그리고 다음과 같은 토글 (toggle)이 있었습니다:
useConditioning = input.bool(false, "Condition on volatility regime")
저 false가 버그의 전부였습니다.
메커니즘은 존재했고, 테스트도 통과했으며, 결코 실행되지 않았습니다. 저희가 몇 주 동안 게시한 모든 차트는 무조건부 밴드 (unconditional band)를 그렸지만, 문서에는 조건부 밴드 (conditioned one)를 설명했습니다. 아무런 에러도 발생하지 않았습니다. 아무것도 잘못되어 보이지 않았습니다. 코드 커버리지 (coverage)는 명시된 50%를 상회하는 84%로 나왔고, 과도한 커버리지는 실패를 일으키지 않기 때문에 (모든 결과값이 너무 넓은 밴드 안에 들어오므로) 조사할 만한 증상이 없었습니다.
기본값 (default value)은 하나의 코드 경로 (code path)입니다. 이를 하나의 경로로 취급하십시오.
사례 2: 분기(branch)가 타야만 진행되는 함수
이 사례는 특정 언어에 국한되지만 그 형태는 일반적이며, 찾아낼 수 있는 토글조차 없기 때문에 훨씬 더 까다롭습니다.
어떤 함수들은 호출 간에 내부 상태 (internal state)를 유지합니다. 이동 평균 (moving averages), 상관관계 (correlations), 또는 롤링 윈도우 (rolling window)를 유지하는 모든 것이 이에 해당합니다. Pine Script에서는 ta.* 계열이 이에 해당하지만, 이 패턴은 틱 (tick)당 한 번 호출된다고 가정하는 상태 유지형 헬퍼 (stateful helper)가 있는 곳이라면 어디든 존재합니다.
이렇게 작성하면 겉보기에는 문제가 없어 보입니다:
ma(src, len, kind) =>
kind == "EMA" ? ta.ema(src, len) : ta.sma(src, len)
컴파일도 잘 됩니다. 그럴듯한 숫자도 반환합니다. 하지만 틀렸습니다.
매 봉(bar)마다 단 하나의 분기(branch)만 실행되므로, 두 이동 평균 중 하나만 내부 상태(internal state)를 업데이트합니다. 다른 하나는 중간에 구멍이 뚫린 이력(history)을 입력받게 됩니다. 이 함수가 반환하는 값은 전체 시리즈의 이동 평균이 아니라, 해당 분기가 우연히 선택되었던 봉들의 부분 집합에 대한 이동 평균입니다.
해결책은 두 가지를 조건 없이 모두 계산한 뒤 나중에 선택하는 것입니다:
ma(src, len, kind) =>
e = ta.ema(src, len)
s = ta.sma(src, len)
...
연산은 약간 더 늘어나지만, 정답을 도출합니다. 저는 같은 날 동일한 코드베이스의 서로 다른 두 곳에서 이 사례를 발견했습니다. 이는 잘못된 방식이 작성하기에 얼마나 자연스럽게 느껴지는지를 잘 보여줍니다.
사례 3: 잘못된 것을 검사하는 검사 로직
패턴을 완성하기 위해, 같은 주에 발견된 더 작은 사례를 소개합니다.
특정 출판 플랫폼이 UTF-8을 레거시 코드페이지(legacy codepage)로 디코딩하여 엠 대시(em dash)가 깨진 글자(mojibake)로 나타나는 문제를 해결하기 위해, 타이포그래피 문자를 ASCII로 변환하는 텍스트 새니타이저(text sanitiser)가 있었습니다. 괜찮습니다. 하지만 여기에는 다음과 같은 코드가 포함되어 있었습니다:
while " - " in text:
text = text.replace(" - ", " - ")
의도는 공백이 포함된 엠 대시(spaced em dash)가 공백이 포함된 하이픈(spaced hyphen)으로 변환될 때 남겨지는 이중 공백을 정리하는 것이었습니다. 하지만 실제 결과는 해당 코드가 닿는 모든 파일의 정렬된 들여쓰기(indentation)를 무너뜨리는 것이었습니다. 체크 모드(check mode)로 실행하면 소스 파일에서 오탐(false positives)을 보고했습니다. 수정 모드(fix mode)로 실행했다면 파일을 조용히 망가뜨렸을 것입니다.
이 규칙은 작성된 특정 케이스에 대해서는 올바랐지만, 그 외의 모든 입력에 대해서는 틀렸습니다. 전역적으로 실행되는 정리 단계(cleanup step)는 정리가 아니라 변환(transformation)입니다.
유닛 테스트가 이를 잡아내지 못하는 이유
유닛 테스트(unit tests)는 함수를 호출하기 때문입니다.
조건부 로직(conditioning logic)에 대한 테스트는 조건부 함수를 임포트(import)하고, 데이터를 입력하며, 출력을 단언(assert)합니다. 테스트는 통과합니다. 하지만 실제 운영 환경(production)이 해당 함수에 도달하는지에 대해서는 아무것도 말해주지 않습니다. ma()에 대한 테스트는 kind="EMA"와 함께 ma()를 호출하고 올바른 EMA를 얻어냅니다. 왜냐하면 해당 테스트에서는 모든 호출이 EMA 분기(branch)를 타게 되고 상태(state)가 적절히 진행되기 때문입니다.
결함은 컴포넌트(components) 간의 관계 속에 존재하며, 유닛 테스트(unit tests)는 특별히 그 부분을 보지 않도록 설계되었습니다. 그것은 보통 미덕이지만, 여기서는 사각지대(blind spot)가 됩니다.
실제로 이를 찾아내는 것들
우리에게 가장 큰 성과를 안겨준 순서대로 세 가지를 나열합니다.
컴포넌트가 아니라 호출 그래프(call graph)를 검토하십시오. 생산적인 질문은 "이것이 정확한가"가 아니라 "어떤 조건에서 이것이 실행되는가, 그리고 내가 그 조건 하에서 이를 검증했는가"입니다. 당신이 신경 쓰는 모든 함수에 대해, 엔트리 포인트(entry point)까지 역추적하여 해당 경로가 운영 설정(production configuration)에서 도달 가능한지 확인하십시오.
모든 기본값(default)을 하나의 결정(decision)으로 취급하십시오. 모든 플래그(flag), 모든 선택적 파라미터(optional parameter), 모든 if enabled 분기 말입니다. 아무도 아무것도 건드리지 않을 때 무엇이 실행되는지 적어두십시오. 왜냐하면 그것이 실제로 실행되는 것이기 때문입니다.
내부(internals)가 아니라 관찰 가능한 출력(observable output)을 단언(assert)하십시오. 우리의 조건부 버그는 게시된 대역폭(band width)과 조건부 대역폭을 비교하여, 두 값이 일치할 때 오류를 발생시키는 체크를 통해 하루 만에 잡혔을 것입니다. 그 체크는 사소한 것이었지만, 우리는 코드가 정확하다는 것을 알고 있었기에 이를 수행하지 않았습니다.
저렴한 해결책
단 한 가지만 기억한다면 이것입니다. 플래그(flag) 뒤에 기능을 추가한 후에는, 해당 플래그를 grep으로 검색하여 이를 참조하는 모든 줄을 읽으십시오. 로직을 확인하기 위해서가 아니라, 운영 환경에서 해당 플래그를 설정하는지 확인하기 위해서입니다.
이는 2분이면 충분한 습관이며, 만약 우리가 이 습관을 가졌더라면 우리가 문서화한 계산 방식과는 미묘하게 다른 수치를 조용히 게시하며 몇 주를 허비하는 일은 없었을 것입니다.
버그는 코드에 있었던 것이 아닙니다. 작성되었다는 것이 실행된다는 것을 의미한다는 가정 속에 있었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기