단위 테스트 400건이 전부 통과하는데, 서비스는 깨져 있었습니다

단위 테스트 380건이 전부 통과했는데, 배포 전 curl 한 번에 500 오류와 200 + 로그인 HTML이 돌아왔습니다. 로직이 아니라 계약이 깨져 있었습니다.

단위 테스트 400건이 전부 통과하는데, 서비스는 깨져 있었습니다 — 오픈소프트랩 기술블로그
AI 정보보호 설계 시리즈를 만들면서 겪은 일입니다. 검사 로직이 아니라 그 앞단의 계약이 깨져 있었습니다.

배포 직전에 발견한 것

안녕하세요. 오픈소프트랩에서 생성형 AI 거버넌스 제품을 만드는 개발팀입니다.

저희는 얼마 전 새로운 API를 하나 열었습니다. 다른 회사 시스템이 "이 문장이 우리 회사 정책에 어긋나는지" 물어보면 판정해서 돌려주는 API입니다.

개발하는 동안 단위 테스트(Unit Test, 함수나 클래스 하나만 떼어내 "이 값을 넣으면 이 값이 나오는지" 확인하는 작은 테스트)를 꾸준히 작성했습니다. 최종적으로 380건을 넘겼고, 전부 통과했습니다.

배포 전에 curl로 API를 직접 한 번 호출해봤습니다. 브라우저 없이 터미널에서 요청을 보내보는 도구입니다.

로그인 정보를 빼고 관리자 API를 호출했습니다. 인증 정보가 없으니 거절 응답이 와야 합니다.

그런데 서버 오류(500)가 돌아왔습니다. 다시 호출하니 이번에는 로그인 페이지 화면(HTML)이 정상(200)으로 표시되어 돌아왔습니다.

둘 다 나와서는 안 되는 응답입니다.

상태 코드(Status Code)는 서버가 처리 결과를 숫자로 알려주는 값입니다. 이 글에서 계속 나오니 먼저 정리하고 가겠습니다.

코드의미
200정상 처리
401누구인지 확인되지 않음 (로그인 필요)
403누구인지는 확인됐지만 권한이 없음
500서버에서 오류 발생

정상이라면 401과 함께, 사람이 보는 화면이 아니라 프로그램이 읽을 수 있는 형식(JSON)으로 응답해야 합니다. 이 API를 호출하는 쪽은 사람이 아니라 다른 회사의 프로그램입니다. 프로그램에 로그인 화면 HTML을 보내면 해석할 수 없습니다.

테스트 400건이 확인하고 있던 것

가장 먼저 확인한 것은 "테스트가 400건인데 왜 발견하지 못했는가"였습니다. 원인은 세 가지였습니다.

모든 테스트가 로그인 성공 이후부터 시작한다

판정 로직을 테스트하려면 로그인은 이미 완료된 상태로 두고 시작해야 합니다. 그래서 테스트마다 목(Mock, 진짜 대신 끼워 넣는 가짜 부품)으로 로그인된 상태를 만들어두고 그 이후를 검사합니다.

따라서 로그인에 실패했을 때 어떤 응답이 나가는지는 400건 중 어느 테스트도 확인하지 않았습니다.

문제가 발생한 지점에는 테스트가 없었다

모든 요청이 들어올 때 인증 여부를 먼저 검사하는 코드가 있습니다. 이런 코드를 인터셉터(Interceptor, 요청이 실제 기능에 닿기 전에 중간에서 가로채 검사하는 코드)라고 합니다. 로그인 확인, 권한 확인, 로그 기록처럼 여러 기능에 공통으로 필요한 작업을 여기서 처리합니다.

이 코드는 다른 부품 네댓 개에 의존하고 있었습니다. 테스트를 작성하려면 가짜 객체를 네댓 개 만들어야 해서 작성 비용이 컸고, 결과적으로 테스트가 없는 상태로 남아 있었습니다. 문제는 이 지점에서 발생했습니다.

기존 통합 테스트 20건도 같은 범위를 벗어나 있었다

저희에게는 E2E 테스트(End-to-End, 프로그램을 실제로 실행한 상태에서 요청을 보내고 응답을 받아 확인하는 테스트)도 20건 있었습니다. 단위 테스트가 부품 하나를 확인한다면, 이쪽은 사용자가 이용하는 것과 동일한 경로로 검증합니다.

이 20건은 기존 화면 주소를 대상으로 작성되어 있었습니다. 이번에 추가한 API는 주소 형식이 기존과 달랐고, 그래서 앞서 설명한 인터셉터에 걸리지 않았습니다. 테스트 역시 이 주소를 확인하지 않고 있었습니다.

로직은 맞았고, 계약이 깨져 있었다

요청 경로별 테스트 분포 — 인증 확인·응답 형식 결정 구간은 0건, 정책 판정 구간은 400건
요청 경로별 테스트 분포 — 인증 확인·응답 형식 결정 구간은 0건, 정책 판정 구간은 400건

테스트 400건은 계산이 맞는지를 확인하고 있었고, 실제로 깨진 것은 약속한 형식대로 응답하는지였습니다.

앞의 것을 로직이라 부르고, 뒤의 것을 계약(contract, API가 다른 프로그램과 맺는 "이런 요청에는 이런 형태로 응답한다"는 약속)이라고 합니다. 약속하고 쓰는 것이므로 약속 자체도 테스트 대상입니다. 저희 테스트는 앞의 것만 400건 있었습니다.

추가한 테스트

서버를 실제로 실행하고 요청을 보내 응답을 확인하는 테스트를 16건 추가했습니다. 기준은 "계산이 맞는가"가 아니라 "약속대로 응답하는가"입니다.

무엇을 확인할지가 중요했습니다. 상태 코드만 확인하면 충분하지 않습니다. 저희가 발견한 응답 중 하나가 200 + 로그인 화면이었기 때문입니다. 숫자만 확인했다면 이 응답도 통과로 처리됐을 것입니다.

그래서 세 가지를 함께 확인합니다.

  • 상태 코드 — 401인가
  • 응답 형식 — 프로그램이 읽는 JSON인가. 화면 HTML이면 실패 처리
  • 에러 코드 — 약속한 식별자가 포함되어 있는가

여기에 한 가지가 더 있었습니다. 같은 주소라도 브라우저의 화면 이동 요청인지, 프로그램이 내부적으로 호출한 요청인지에 따라 인터셉터가 다르게 동작하고 있었습니다. 그래서 주소마다 양쪽을 모두 확인합니다. 주소 3개 × 2가지 = 6건입니다.

기존 화면 로그인이 종전대로 동작하는지도 1건 추가했습니다. 수정 과정에서 기존 기능이 깨지는 경우가 많기 때문입니다. 이렇게 기존 동작이 유지되는지 확인하는 테스트를 회귀 테스트(Regression Test)라고 합니다.

나머지는 다음을 확인합니다.

항목확인 내용
인증인증 정보 누락·형식 오류·미등록 키가 모두 401인가. 해당 키의 존재 여부가 응답에서 드러나지 않는가
브라우저 보안 규칙기존 API들이 종전과 동일하게 동작하는가
전체 흐름키 발급 → 발급한 키로 검증 → 키 폐기 후 거부까지 한 번에
화면필수 입력값, 권한 없는 사용자 차단

가장 위험했던 것은 신규 기능이 아니었다

위 표의 두 번째 항목이 이번 작업에서 가장 위험했습니다. 브라우저 보안 규칙 처리 방식을 변경했는데, 이 변경은 새로 만든 API에만 영향을 주지 않고 기존 API 전체에 영향을 줍니다. 그래서 신규 기능 테스트보다 기존 동작이 유지되는지 확인하는 데 더 비중을 뒀습니다.

실패 응답이 정보를 흘리지 않아야 한다

첫 번째 항목의 "키의 존재 여부가 드러나지 않는가"는 보안상 중요합니다.

존재하지 않는 키에는 "없는 키"라고, 형식이 틀린 키에는 "틀린 키"라고 각각 다르게 응답하면 어떻게 될까요? 공격자는 응답의 차이만 비교해서 어떤 키가 실제로 존재하는지 알아낼 수 있습니다. 그래서 두 경우 모두 동일하게 401로 응답합니다.

문서를 작성하면서 확인한 두 가지

테스트를 추가한 뒤, 수동으로 확인할 때 쓰는 명령어 가이드도 함께 정리했습니다.

규칙을 하나 정했습니다. 가이드에 넣는 모든 예시는 실제로 실행해서 받은 응답을 그대로 붙이고, 기억에 의존해 작성하지 않는다는 것입니다.

이 과정에서 문서가 아니라 저희 이해가 잘못된 부분 두 가지를 확인했습니다.

401과 403은 무엇이 다른가

첫 번째는 폐기한 키로 요청했을 때의 응답입니다. 401이 나올 것으로 알고 있었는데 실제로는 403이 나왔습니다. 그리고 이쪽이 올바른 동작이었습니다.

  • 401 = 누구인지 확인되지 않음 → 처음부터 존재하지 않는 키
  • 403 = 확인은 되지만 허용되지 않음 → 존재했으나 폐기된 키

이 둘은 구분되어야 합니다. API를 사용하는 쪽에서 "키를 잘못 입력했다"와 "키가 폐기됐으니 재발급받아야 한다"를 판단할 수 있어야 하기 때문입니다. 확인하지 않았다면 문서에 401로 잘못 기재할 뻔했습니다.

두 번째는 특정 상태 코드가 한 가지 경우에만 발생한다고 알고 있었으나, 실제로는 검증이 오류로 끝나는 경우에도 발생하고 있었습니다.

문서 작성을 마친 뒤 전체 케이스를 한 번 더 실행해 대조했고, 두 번째 실행에서도 어긋나는 부분이 나왔습니다.

직접 실행해서 붙인다는 규칙 하나가, 테스트 400건이 확인하지 못한 부분을 확인해줬습니다.

테스트가 많다는 것과 안전하다는 것

둘은 다른 이야기였습니다.

테스트 400건은 저희에게 확신을 줬지만, 그 확신의 근거는 "확인하기로 정한 항목은 모두 통과한다"였습니다. 확인 대상으로 정하지 않은 영역은 테스트가 400건이든 4,000건이든 비어 있는 상태로 남습니다.

지금은 새 기능을 추가할 때 다음을 먼저 확인합니다. 테스트 작성을 공부하고 계신 분이라면 이 목록만 가져가셔도 도움이 될 것입니다.

  • 이 주소가 기존 공통 처리(로그인·권한·로그)에 실제로 걸리는가. 주소 형식이 조금만 달라도 걸리지 않습니다
  • 로그인에 실패했을 때 어떤 응답이 나가는가. 상태 코드뿐 아니라 형식까지 맞는가
  • 이번 변경이 기존 동작을 바꾸는가. 바꾼다면 기존 동작을 고정하는 테스트가 있는가
  • 테스트 작성 비용이 커서 건너뛴 지점이 있는가

마지막 항목이 이번에 가장 크게 배운 부분입니다. 테스트 작성을 미룬 지점과 문제가 발생한 지점이 정확히 일치했습니다.

테스트를 작성하기 어렵다는 것은 그 코드가 다른 부품과 많이 얽혀 있다는 뜻이고, 많이 얽혀 있다는 것은 문제가 생겼을 때 영향 범위가 넓다는 뜻입니다. 테스트하기 가장 번거로운 지점이 대체로 가장 중요한 지점이었습니다.


오픈소프트랩 개발팀이 작성합니다. 생성형 AI 사용 통제와 AI 에이전트의 도구 실행 통제를 만들고 있습니다.