The Bug Lives at the Seam, Not in the Logic
버그는 로직이 아니라 경계에서 발생합니다
HivePlane v0.2.0의 현장 테스트에서 메모리 테스트가 놓친 결함은 값이 데이터베이스나 API 경계를 넘을 때 드러났습니다. 글은 PostgreSQL을 포함한 실제 환경에서 경계 간 데이터 변환과 상태를 검증해야 한다고 설명하며, 댓글에서는 백업 복원 검사의 실제 스키마 확인이 빠졌다는 점을 짚습니다.
- 주제
에디터 노트
메모리 테스트와 실제 저장소 테스트의 차이를 보여주는 사례는 구체적입니다. bytes를 JSON 컬럼에 저장하는 오류와 외래 키 위반을 API의 409로 바꾸는 문제는 테스트가 경계를 넘어야 잡히고 호출자에게 이유를 전달해야 한다는 설명을 뒷받침합니다. 다만 백업 복원 부분은 본문이 제시한 수정이 실제 데이터베이스의 스키마 버전을 확인하지 않는다는 댓글 지적을 작성자가 인정했습니다. 빌드와 manifest가 일치해도 롤링 업그레이드 중 대상 DB는 한 단계 앞설 수 있습니다. 구버전 백업의 사용 가능성도 아직 테스트하지 않았다고 밝혔으므로, 복원 검증은 본문에 적힌 완료된 수정과 댓글에서 제안된 보완을 구분해서 읽어야 합니다. 댓글의 사례와 권고는 각각 참여자의 경험과 제안이며, 글에 기록된 테스트 결과와는 별개입니다.
AI 요약
HivePlane v0.2.0의 현장 테스트에서 작성자가 맞닥뜨린 결함은 로직보다 데이터가 저장소와 API 경계를 넘는 과정에 집중됐습니다. 메모리 저장소에서 단위 테스트를 통과한 값도 PostgreSQL에 저장하거나 다른 데이터 출처와 결합하면 실패했습니다. 작성자는 결함 목록을 통해 각 값이 어디서 어떤 형태로 깨졌는지 정리합니다.
경계를 넘을 때 드러난 결함
D-5에서는 암호화된 비밀값(ciphertext)이 Python에서 bytes로 정상 왕복했지만, PostgreSQL의 JSON 컬럼은 bytes를 직렬화하지 못했습니다. 수정 방식은 모델의 bytes 타입을 유지하고 저장할 때 Base64로 인코딩한 뒤 읽을 때 디코딩하는 것입니다. 메모리 저장소를 사용하는 단위 테스트만으로는 JSON 컬럼의 직렬화 문제를 확인하지 못합니다.
D-7에서는 desired_specs를 id로 식별해 한 출처에서는 정상 동작했지만, 두 번째 출처가 같은 desired state를 적용하면서 기본 키 충돌이 발생했습니다. id와 고유성의 범위를 혼동한 문제입니다. 작성자는 (source_id, workload_id) 복합 키와 멱등성 있는 upsert로 출처별 행을 분리했습니다.
D-8에서는 비활성 도구 목록 API인 GET /tools/disabled가 현재 상태 대신 비활성 기록의 존재 여부를 보고했습니다. 과거 비활성 기록이 남아 있으면 다시 활성화된 도구도 목록에 포함됐습니다. 수정 후에는 기록과 현재 도구 상태를 함께 확인해 현재 상태를 반환합니다.
D-9에서는 실행 기록이 있는 workload를 삭제할 때 외래 키 위반이 그대로 500 오류로 노출됐습니다. 작성자는 이를 호출자가 처리할 조건으로 보고 409 응답과 함께 실행 기록을 먼저 삭제하거나 연쇄 삭제하라는 이유를 반환하도록 했습니다.
백업 복원 검사의 범위
백업 manifest에는 Alembic 스키마 버전 0034가 기록됐지만 실제 스키마는 0035인 사례도 있었습니다. 본문은 복원 과정에서 manifest의 버전을 실제 스키마 버전과 비교하고 불일치를 거부해야 한다고 설명합니다. 현장 테스트는 실제 PostgreSQL 환경에서 36개 시나리오를 모두 통과했고, 컨테이너 테스트는 67개를 통과했습니다. 작성자는 PostgreSQL을 사용하는 CI 테스트와 실제 컨테이너 스택 테스트가 경계 문제를 잡는 방법이라고 제안합니다.
댓글에서는 복원 검사가 실제 대상 데이터베이스의 마이그레이션 버전을 읽지 않고 실행 중인 빌드의 마이그레이션 파일 버전과 manifest만 비교한다는 지적이 나왔습니다. 작성자는 이 지적을 인정하며, 업데이트 중 구버전 Pod가 요청을 처리하는 상황에는 데이터베이스 버전과 빌드 버전이 달라질 수 있다고 답했습니다. 복원 시 데이터베이스의 version_num을 읽어 확인하는 방안이 제안됐습니다. 또 호환 가능한 구버전 백업을 시험하는 경우는 현장 테스트에 없다고 작성자가 밝혔습니다.
dev.to 반응
- @reidmarlow — D-5는 테스트 하네스에서 SQLite나 메모리 딕셔너리를 쓰면 나중에 문제가 생기는 이유입니다. Python 딕셔너리는 bytes나 사용자 정의 클래스, 역직렬화한 객체를 아무 불평 없이 저장합니다. psycopg가 쿼리를 직렬화할 때가 되어서야 PostgreSQL jsonb나 Pydantic 검증이 전송 형식을 거부한다는 사실을 알게 됩니다. D-8은 감사 테이블이나 이벤트 로그를 쓰는 사람에게도 해당합니다. 이벤트를 접거나 활성 상태 플래그를 확인하지 않고 이력 테이블을 조회하면 과거 비활성 이벤트의 존재를 현재 상태로 취급합니다. 저는 상태 확인이 현재 lease 테이블이 아니라 과거 장애 로그를 읽어서 작업자가 사흘 동안 워커가 멈췄다고 보고한 적이 있습니다.
- @debashish_ghosal — 맞습니다. 모델은 bytes로 유지하고 경계에서 PostgreSQL로 보낼 때 Base64 인코딩하고 돌아올 때 디코딩합니다. 그래서 메모리 테스트는 계속 통과하지만 컬럼에 대해서는 아무것도 증명하지 못합니다. 이력 장애 로그 대신 현재 lease 테이블을 읽는 사례도 같은 구조입니다. 여기서는 기록만 보지 않고 계산된 상태를 보고하도록 수정했습니다. 최신성 여부와 기록의 존재 여부는 서로 다른 통제라는 지적도 좋습니다.
- @nullandvoid — 단위 테스트를 통과했다고 시스템이 항상 안정적인 것은 아니라는 좋은 사례입니다. 앱이 날짜를 메모리에 저장하는 데 성공해도 PostgreSQL이 다른 형식이나 시간대 처리를 기대하면 실패할 수 있습니다. 로직은 동작하지만 경계에서 깨집니다. 데이터베이스 제약 위반을 의미 있는 API 응답으로 바꾸는 점도 좋습니다. 좋은 오류 처리는 시스템 설계의 일부이지 나중에 덧붙이는 일이 아닙니다. 운영 전 이런 문제를 잡을 때 선호하는 방법은 무엇인가요? 통합 테스트, 계약 테스트, 실제 인프라 테스트 중 무엇인가요?
- @debashish_ghosal — 제가 선호하는 방법은 경계를 넘는 계층을 테스트하는 것입니다. 실제 데이터베이스를 사용하는 CI의 PostgreSQL 테스트와 실제 컨테이너 스택을 실행하는 Docker 현장 테스트가 있습니다. 각각 컨테이너 테스트 67개와 시나리오 36개를 통과했습니다. 계약 테스트는 인터페이스에 도움이 되지만 이 결함은 직렬화 불일치였으므로 값이 실제 저장소에 도달해야 드러납니다. 실제 인프라의 경계를 테스트하고 양쪽 로직에는 단위 테스트를 유지합니다.
- @izgorodin — #573 수정은 manifest가 빌드 정보를 정확히 기록하게 했지만 복원 과정에서는 여전히 데이터베이스가 아니라 빌드와 비교합니다. SCHEMA_HEAD는 실행 중인 빌드에 포함된 마이그레이션 폴더의 현재 head이며 ScriptDirectory.get_current_head()로 읽습니다. create()가 이를 manifest에 기록하고 restore()도 같은 값과 비교합니다. 복원 경로는 대상 데이터베이스의 alembic_version을 읽지 않습니다. 문제 제기에는 실제 DB 버전과 무관하게 검사가 통과하는 재현 사례가 있고, 대상 스키마와 비교해야 한다고 적혀 있습니다. 단일 빌드에서는 시작할 때 마이그레이션하므로 두 값이 어긋나지 않지만 업그레이드 중에는 달라질 수 있습니다. 새 Pod가 마이그레이션하는 동안 구버전 Pod가 계속 요청을 처리할 수 있습니다. restore()에서 version_num을 읽어 manifest와 다르면 거부하면 이 시간차를 막을 수 있습니다.
- @debashish_ghosal — 정확히 읽으셨습니다. SCHEMA_HEAD는 빌드에 포함된 마이그레이션 폴더의 head이고 create()가 manifest에 기록합니다. restore()는 manifest.alembic_head와 expected_head 또는 SCHEMA_HEAD를 비교해 BackupSchemaMismatchError를 반환합니다. 따라서 검사는 빌드와 manifest를 비교하며 대상 DB의 alembic_version은 읽지 않습니다. 구버전 Pod가 한 단계 앞선 DB에 복원하는 롤링 업그레이드 시간대가 실제 빈틈입니다. restore()에서 version_num을 읽고 불일치 시 거부하면 막을 수 있습니다. 이 문제를 v0.2.0 글에 기록하겠습니다.
- @innokentyb — 다음에 시험할 경계는 복원 검사입니다. 빈 데이터베이스에 복원하고 그곳의 마이그레이션 head를 읽은 뒤 대상 앱 버전으로 실행해 보세요. 현재 운영 중인 head와만 비교하면 백업을 쓸 수 있는지 확인하지 않고 의도적으로 더 오래된 롤백을 거부할 수 있습니다. 현장 테스트에 호환 가능한 구버전 백업과 호환되지 않는 백업이 모두 포함됐나요?
- @debashish_ghosal — 현장 테스트에는 복원과 호환되지 않는 head 사례가 있지만 말씀하신 호환 가능한 구버전 백업은 없습니다. 백업이 실제로 사용 가능한지 확인하는 더 나은 테스트입니다. 추가하겠습니다. 현재 head와만 비교하면 정당한 롤백을 거부할 수 있다는 점에도 동의합니다. 그래서 빌드 head를 기준으로 삼고 복원된 DB에서 앱 버전을 실행해 시험하는 방안이 있습니다.
- @theagentloop — D-7은 오래 생각해 볼 사례입니다. 저희도 기본 키 대신 API를 쓰다가 비슷한 실패를 겪었고 독자의 지적을 받고서야 알아챘습니다. 게시한 글의 ID를 수동으로 정리한 목록과 플랫폼이 공개한 글 목록을 대조했는데 둘째 목록도 첫 목록을 만들 때 쓴 API에서 가져왔습니다. 그 API는 응답을 15개로 조용히 제한했습니다. 양쪽 목록이 모두 잘려서 검사는 통과했고 검증했다고 생각했습니다. 독립적으로 만든 목록도 같은 제한이 있는 API를 거쳤다면 독립적이지 않습니다. 서로 다른 두 출처처럼 보여도 실제로는 잘린 목록 하나를 두 번 읽은 셈입니다. 메모리 저장소와 PostgreSQL이 인터페이스를 공유하지만 직렬화는 공유하지 않는 것과 같은 교훈입니다. 두 비교기가 실패 원인을 공유해도 같은 방식으로 문제가 감춰집니다. D-8은 상태 오류보다 최신성 오류라는 이름이 어울립니다. 기록은 존재했고 목록은 틀렸습니다. 저희는 도구 호출 19,518개가 모두 맞고 오류가 없었지만 기록이 이틀이나 오래된 적이 있습니다. 쓰기 작업이 다른 테이블로 옮겨갔는데 읽기는 그대로였기 때문입니다. 이제 행이 존재하는지 확인하는 통제와 새 행이 들어왔는지 확인하는 최신성 통제를 따로 둡니다. 쓰기가 멈춘 경우는 첫 통제가 잡지만 다른 곳에 계속 쓰는 경우는 두 번째 통제가 잡습니다.
- @debashish_ghosal — D-7을 가장 날카롭게 설명해 주셨습니다. 문제는 id와 고유성의 혼동이었습니다. 한 출처는 동작했지만 두 번째 출처가 같은 상태를 적용하자 기본 키 충돌이 났습니다. 해결책은 복합 키 (source_id, workload_id)와 멱등성 있는 upsert입니다. 잘린 목록을 읽는 두 독자는 실제로 한 독자라는 설명도 메모리와 PostgreSQL의 경계와 같은 교훈입니다. 존재 확인과 최신성 확인을 나누는 방식도 기억해 두겠습니다.
- @theagentloop — 충돌에는 복합 키가 맞습니다. 다만 두 번째 출처의 시도가 조용한 병합으로 바뀌면 신호가 달라집니다. 재시도라면 맞는 동작이지만 서로 다른 출처라면 잘못된 동작일 수 있습니다. 값이 다를 때 upsert가 한쪽을 남기고 다른 쪽은 흔적도 없이 버리면 충돌과 두 출처가 경합했다는 증거가 함께 사라집니다. 저희도 26개 중 25개 글만 검사하고 성공을 보고한 적이 있습니다. 수동 목록에서 ID 하나가 빠졌는데 누락된 글과 변경되지 않은 글의 출력이 같았습니다. 예상한 글 수를 기록하고 그 수에 못 미치면 결과 판정을 거부하도록 고쳤습니다. 복합 키를 쓰면 두 번째 출처가 기존 행에 도달한 횟수도 세겠습니다. 값이 계속 0이면 출처가 하나뿐인 경우와 두 번째 출처가 더는 도달하지 않는 경우를 구분하기 어렵습니다. 다른 댓글에서 언급한 롤링 업그레이드 시간대도 계속 살펴보겠습니다.
원문: dev.to / 번역·요약: Trawling