dev.to

Implementation is where judgements go to become invisible

구현은 판단이 보이지 않게 바뀌는 곳입니다

댓글 응답 추적 도구의 세 가지 버전은 18명, 34명, 2명이라는 서로 다른 수를 냈지만 각자 다른 기준에 따라 정확히 셌습니다. 테스트와 측정값이 맞더라도 범위, 비교 대상, 데이터 수집 위치 같은 판단이 구현에 묻히면 독자는 다른 질문의 답을 받습니다.

AI 요약

작성자는 자신에게 답변을 기다리는 사람이 몇 명인지 세는 도구를 만들었습니다. 도구의 세 버전은 각각 18명, 34명, 2명이라고 보고했습니다. 첫 버전은 상대 댓글 바로 아래에 달린 답글만 셌고, 둘째 버전은 스레드 뒤쪽에 작성자가 남긴 댓글을 모두 셌습니다. 그래서 다른 사람끼리 주고받은 댓글까지 포함했습니다. 셋째 버전은 두 기준을 함께 적용했습니다. 세 수치는 각자 정한 모집단에서는 모두 맞았지만, 어느 버전도 ‘누가 답을 기다리나’라는 질문의 뜻을 사용자에게 알리지 않았습니다.

글은 이 사례를 출발점으로, 테스트와 측정에 숨어드는 판단을 두 달간 Pascal Cescato와 나눈 실제 사례를 따라갑니다. 답이 완성된 듯 보일 때 다른 사람이 실제 실패 사례를 가져왔고, 그 답 안에 어떤 가정이 들어 있었는지 드러나는 과정입니다.

테스트가 실제 동작을 확인하는지 점검합니다

Pascal이 다룬 예약 시스템 사례에서는 두 사람이 동시에 같은 시간대를 예약하는 경쟁 조건을 테스트합니다. PostgreSQL에서는 실제로 발생하는 경쟁이지만, SQLite는 한 번에 한 쓰기 작업만 허용하므로 같은 상황이 발생하지 않습니다. 따라서 SQLite에서 테스트를 실행하면 테스트는 계속 통과하고, 버그는 PostgreSQL 환경에 배포될 수 있습니다. 여기 숨은 판단은 테스트가 실패할 상황 자체가 존재한다는 가정입니다. 작성자는 테스트를 만든 뒤 보호 대상 코드를 일부러 망가뜨려 다시 실행하라고 제안합니다. 코드에 결함을 넣어 테스트가 이를 잡는지 살피는 방식은 mutation testing이라고 부릅니다. 테스트가 계속 통과하면 테스트가 아무것도 감시하지 않았을 수 있습니다.

하지만 테스트 실패만 확인하면 충분하지 않습니다. 작성자의 키 바인딩 코드는 스페이스 키를 " "와 비교했지만 런타임은 키 이름을 "space"로 전달했습니다. 해당 분기는 파일이 존재한 내내 실행되지 않았습니다. 따라서 이 코드에는 퇴행을 일으킬 기존 동작이 없었습니다. Pascal은 진단 순서를 세 단계로 정리합니다. 먼저 코드 경로에 도달하는지 확인하고, 다음으로 테스트가 동작을 관찰하는지 확인합니다. 그 뒤에야 코드를 깨뜨렸을 때 테스트가 실패하는지 살펴봅니다. 테스트가 약하다고 결론 내리기 전에 테스트 대상 코드가 실제로 실행되는지부터 확인해야 합니다.

독립적인 검증은 서로 다른 방식으로 틀릴 여지를 남깁니다

작성자가 만든 API 게이트웨이는 세 가지 형식을 지원했고 자체 점검도 모두 통과했습니다. 하지만 점검에는 작성자가 직접 만든 curl 요청을 사용했습니다. 한 공급자의 SDK는 API 키를 다른 헤더에 담았고, 게이트 앞단 규칙은 그 헤더를 읽지 않았습니다. 요청은 처리 코드에 도달하기도 전에 차단됐습니다. 점검 도구와 점검 대상이 같은 사람의 가정을 공유했기 때문에 같은 사각지대를 놓친 사례입니다.

검사를 더 늘리는 것만으로는 해결되지 않습니다. 세 도구가 서로 다른 코드로 작성됐더라도 모두 같은 파일 목록을 입력으로 받으면 같은 파일을 놓칠 수 있습니다. 실제로 목록에 등록된 파일만 세는 도구와 디스크 전체를 훑는 도구가 같은 저장소를 두고 16개와 2,375개를 보고한 사례가 나옵니다. Pascal은 독립성이란 ‘서로 다른 결과가 나올 가능성을 보존하는 것’이라고 표현합니다. 검증 도구들이 서로 다른 입력과 가정을 바탕으로 동작해야 불일치를 드러낼 수 있습니다.

숫자에는 범위와 처리 방식, 비교 대상을 붙입니다

작성자의 야간 보고서는 ‘우리 글에서 기다리는 답글 0개’라고 표시했습니다. 수치는 맞았지만, 보고서가 읽은 데이터에는 다른 사람의 글에 달린 답글이 없었습니다. 빈 집합과 측정하지 않은 집합은 모두 0으로 나타날 수 있지만 서로 다른 상태입니다. 보고서는 전체 목록을 확인하지 못할 때 그 사실을 표시하도록 바뀌었습니다.

검색 벤치마크에서도 두 설정의 수치는 각각 정확했지만, 한쪽은 문서 3,038개를 검색하고 다른 쪽은 답이 들어 있는 문서 7개만 검색했습니다. 두 결과를 그대로 비교할 수는 없습니다. 문서 ID를 줄여 캐시 키로 쓰는 과정에서는 문서 120개가 키 86개로 합쳐져, 34개 점수가 다른 문서 결과와 뒤섞였습니다. assert len(cache) == len(documents) 검사가 이를 잡았고, 수정 과정에서 반대 방향의 충돌이 생겼을 때도 같은 검사가 발견했습니다.

숫자가 같다고 대상까지 같은 것도 아닙니다. 사용량 로그는 요청 2,488건을 한 공급자 이름으로 기록했지만, 해당 요청을 처리하는 서비스는 실제로 다른 공급자를 가리키면서 이름은 바꾸지 않았습니다. 로그와 서비스가 같은 레이블을 읽으니 둘 다 2,488건이라고 표시했습니다. Pascal은 이름만 믿지 말고 이름이 가리키는 실제 대상을 살펴보라고 말합니다. 측정값에는 값뿐 아니라 범위와 처리 방식, 비교 대상도 함께 기록해야 합니다. 작성자는 기존 원칙에 비교 대상을 추가합니다. 무엇과 비교하느냐에 따라 수치의 뜻이 달라지기 때문입니다.

작성자는 에이전트에 전달한 메모가 무작위 메모보다 행동에 영향을 준다고 보고했습니다. 하지만 벤치마크에서 쓰는 방식대로 대조군을 가장 가까운 오답 메모로 바꾸자 효과가 사라졌습니다. 결과는 35.4% 대 30.8%, p=0.549였습니다. 측정값 자체는 바뀌지 않았지만 비교 대상이 달라지자 주장의 의미가 달라졌습니다.

올바른 질문과 관찰 범위도 검증 대상입니다

작성자는 메모 전달이 ‘시끄럽다’고 판단해 잡음을 측정하고 해결책을 만들었지만 효과가 없었습니다. 동료가 실제 세션을 살펴보라고 권한 뒤에야, 잡음이 아니라 메모 두 개가 전달 과정에서 걸러져 도착하지 않았다는 사실을 찾았습니다. 평균만 측정할 때는 문제를 설명하는 질문부터 잘못 설정할 수 있습니다. 별도의 모델이 작성자의 결정을 검토한 사례에서도, 검토자는 독립적으로 추론했지만 검토 자료에 반대 증거가 빠져 있어 잘못된 결론을 냈습니다. 독립적인 판단과 독립적으로 충분한 정보를 받은 검토는 다릅니다.

실험 기록에서도 구현 속 판단이 결과를 바꿨습니다. 작성자는 메모 10%를 무작위로 보류해 전달 여부를 비교했지만, 보류한 메모는 보류 시점에 기록하고 전달한 메모는 여러 필터를 통과한 뒤에야 기록했습니다. 68일치 데이터는 전체 보류 집단과 살아남은 전달 집단을 비교했습니다. 무작위 배정은 제대로 됐지만 로그 위치가 비대칭이었습니다. 작성자는 기록 지점을 대칭으로 고쳤고, 앞으로는 메모별로 해당 메모가 경고한 실수가 전달된 경우와 보류된 경우에 얼마나 발생하는지 비교하려 합니다. 다만 어떤 실수를 감지할 가치가 있다고 정할지는 여전히 사람의 판단입니다.

글은 점검 질문을 목록으로 제시하면서도 그 목록을 완전한 해답으로 취급하지 말라고 경고합니다. 테스트가 실패할 수 있는지, 대상 코드가 실행되는지, 검사자와 대상이 같은 가정을 공유하는지, 서로 다른 결과가 나올 수 있는지, 무엇을 세고 비교하는지 살펴야 합니다. 그리고 지금까지의 답 가운데 무엇이 코드의 기본값이나 로그 위치, 모집단 경계에 묻혀 선택이 아닌 시스템의 당연한 동작처럼 보이는지도 확인해야 합니다. 판단은 의식적인 선택에서 필드와 기본값, 로그 지점으로 옮겨가며 시간이 지나면 구현 자체처럼 읽힙니다.

dev.to 반응

  • @pascal_cescato_692b7a8a20 — 대화가 어디로 이어졌는지 보니 반갑습니다. 18 → 34 → 2 구조가 훨씬 잘 맞습니다. 한 걸음 물러서 구현을 바라보면 판단이 다시 눈에 들어온다는 점에서 이 글 자체도 좋은 사례입니다. 좋네요. 🙂
    • @tom_jones_230c4659491adcd — 고맙습니다, Pascal. 이 구조는 당신의 것이기도 합니다. 첫 초안을 검토하면서 깔끔한 질문 아홉 개를 다시 그 질문이 나온 대화로 되돌려 주셨습니다.
  • @reidmarlow — curl 점검과 공급자 SDK 사례는 제가 만든 모든 통합 테스트 환경을 떠올리게 합니다. 엔드포인트와 같은 가정으로 픽스처를 만들면 외부 호환성을 시험하는 대신 내부 일관성만 확인하게 됩니다. 지난달 에이전트 도구 호출기에서 똑같은 문제를 겪었습니다. 양쪽이 같은 페이로드 직렬화 도우미를 써서 모의 클라이언트 테스트는 전부 통과했지만, 실제 서드파티 런타임은 인수를 메타데이터 딕셔너리로 한 번 더 감쌌고 진입 지점에서 조용히 실패했습니다. 로컬에서 만든 객체 대신 실제 네트워크 기록을 넣어 테스트를 깨뜨려 본 뒤에야 사각지대가 드러났습니다.
    • @tom_jones_230c4659491adcd — 직렬화 도우미를 공유하면 테스트가 독립적으로 보여도 양쪽이 같은 파일을 가져온다는 점에서 더 까다롭습니다. 실제 기록을 재생할 때는 어디에 재생하느냐도 중요합니다. 저희 사례에서는 요청이 앞단에서 막혔습니다. 공급자 SDK는 x-api-key를 보냈지만 게이트웨이 앞 방화벽 규칙은 Authorization만 확인했습니다. 기록을 핸들러에 곧장 재생했다면 통과했을 겁니다. 실제 SDK를 임시 환경에 설치하고 엣지를 포함한 공개 URL로 요청해 잡았습니다. 이 실행에서 요청 기록만으로는 드러나지 않을 두 번째 실패도 발견했습니다. 스트림에 올바른 마지막 이벤트를 보낸 뒤에도 연결을 닫지 않아 한 클라이언트가 자체 타임아웃까지 기다렸습니다. 요청은 괜찮았습니다. 클라이언트가 응답 종료를 판단하는 방식을 테스트하지 않았던 겁니다.
  • @beusebiu — 여섯 달 뒤 제 코드도 제 눈에는 꼭 이렇습니다. 판단은 사라지고 결과만 남아서, 그때는 그냥 시스템이 작동하는 방식처럼 읽힙니다. 무엇을 했는지가 아니라 왜 했는지를 적는 습관만이 이 문제에 도움이 됐습니다.
    • @tom_jones_230c4659491adcd — 저도 그렇습니다. 무작위 배정 부분이 아직도 마음에 걸립니다. 로그 두 줄은 판단처럼 보이지 않았고 그냥 로깅처럼 보였습니다. 누군가 합리적인 이유로 위치를 정했지만 그 이유를 옆에 적지 않았고, 68일 동안 파이프라인이 원래 그런 식으로 작동하는 것처럼 보였습니다. 왜 그런지 적는 습관을 가장 믿습니다. 아직 배우는 점은 선택이 코드가 되는 바로 그 자리에 이유를 적어야 한다는 겁니다. 판단이 거기서 사라지니까요.
    • @beusebiu — 저에게는 로그 위치보다 기본값이 더 그렇습니다. 로그 지점은 적어도 누군가 의도적으로 놓은 것처럼 보입니다. 기본값은 시스템의 사실처럼 읽혀서 누구도 다시 따져 묻지 않습니다. 옆 주석은 대개 그 숫자가 무엇을 하는지만 설명하지 왜 그 숫자인지는 설명하지 않습니다. 예전에는 커밋 메시지에 이유를 적었는데, 그건 최악의 장소였습니다. 다시 바뀌지 않은 줄을 두고 git blame을 실행하는 사람은 없으니까요.
  • @aprilaide — 독립적인 추론과 독립적으로 충분한 정보를 받은 검토를 구분한 부분이 특히 유용합니다. 검토자는 엄밀하게 살펴도 브리프가 같은 모집단 경계를 물려받으면 잘못된 설명을 확인해 줄 수 있습니다. 실용적인 안전장치 하나는 검토 자료에 근거와 알려진 검증 범위를 함께 적는 것입니다. 그래야 ‘찾지 못했다’가 ‘존재하지 않는다’로 둔갑하지 않습니다.

원문: dev.to / 번역·요약: Trawling