“20번 돌렸고, 20번 다 성공했습니다.”
이 말은 보통 회의를 끝내준다. 초록색 체크가 스무 개면 마음도 초록색이 된다.
그런데 이번에는 그 스무 번의 성공이 버그를 숨기고 있었다. 쿼리는 끝까지 잘 실행됐고 결과도 맞았다. 하지만 중간부터 클라이언트는 프록시를 떠나 데이터베이스로 직접 달리고 있었다.
성공한 테스트가 거짓말을 한 건 아니다. 우리가 너무 적게 물어봤을 뿐이다.
Trino 쿼리는 한 번의 HTTP 요청으로 끝나지 않는다
Trino 계열의 HTTP 프로토콜은 긴 쿼리 결과를 한 번에 다 보내지 않는다.
클라이언트가 SQL을 제출하면 서버는 결과와 함께 nextUri를 준다. “다음 페이지는 여기서 받아가세요”라는 주소다.
POST /v1/statement
→ response: nextUri #1
GET nextUri #1
→ response: nextUri #2
GET nextUri #2
→ ...
웹툰 앱이 다음 화 주소를 계속 받아오는 것과 비슷하다. 첫 요청이 성공했다고 작품을 다 읽은 건 아니다.
프록시가 중간에 있다면 중요한 규칙이 하나 생긴다.
데이터베이스가 내부 주소를 nextUri로 보내더라도, 클라이언트에게는 프록시 주소를 돌려줘야 한다.
DB가 보낸 주소
https://target.example:8443/v1/statement/...
클라이언트가 받아야 할 주소
https://proxy.example:443/v1/statement/...
그래야 다음 요청도 계속 프록시를 통과한다. 접근 정책, 감사 로그, 네트워크 경계도 함께 유지된다.
문제는 쿼리가 아니라 TCP 연결에 기억을 붙여둔 것이었다
프록시는 최초 SQL 요청을 기억하고 있었다.
이 쿼리의 다음 주소는 무엇인가?
어떤 클라이언트 주소로 바꿔야 하는가?
지금 받은 GET이 이전 쿼리의 연속인가?
그런데 이 기억이 “쿼리”가 아니라 “TCP 연결”에 붙어 있었다.
택배 송장 정보를 택배 자체가 아니라 배송 트럭 좌석에 붙여둔 셈이다. 같은 트럭으로 계속 배달하면 아무 문제가 없다. 중간에 트럭만 바뀌면 새 기사에게는 송장이 없다.
HTTP 클라이언트는 언제든 새 TCP 연결을 열 수 있다.
- connection pool 정책이 새 연결을 선택할 수 있다.
- 서버가 keep-alive 연결을 닫을 수 있다.
- 로드밸런서 idle timeout이 연결을 정리할 수 있다.
- 재시도 과정에서 다른 socket을 사용할 수 있다.
쿼리는 그대로인데 운반하는 TCP 연결만 바뀐다.
이때 새 프록시 세션은 이전 쿼리를 모른다. 그러면 다음 response의 nextUri를 프록시 주소로 바꾸지 못하고 데이터베이스가 준 내부 주소를 그대로 전달할 수 있다.
TCP A
POST SQL
→ nextUri는 Proxy 주소로 정상 변환
TCP B
GET nextUri
→ 이전 쿼리 문맥 없음
→ 다음 nextUri는 Target 주소 그대로
TCP C
GET Target 주소
→ 프록시 우회
첫 response는 정상이고, 두 번째 response부터 길이 갈라진다. 그래서 최초 응답만 확인하면 이 버그는 보이지 않는다.
한 번에 하나만 바꾼 A/B 실험
원인을 확인하려면 복잡한 운영 환경을 통째로 복제할 필요가 없었다.
딱 하나만 바꿨다.
A: POST와 GET을 같은 TCP 연결로 보낸다.
B: POST와 GET을 서로 다른 TCP 연결로 보낸다.
A. 같은 TCP 연결
POST → Proxy
response nextUri → Proxy
GET → 같은 TCP → Proxy
response nextUri → Proxy
모든 요청이 프록시에 머물렀다.
B. 매번 새 TCP 연결
POST → TCP A → Proxy
response nextUri → Proxy
GET → TCP B → Proxy
response nextUri → Target
GET → TCP C → Target 직접 연결
변수는 TCP 재사용 여부 하나였다. 결과는 명확했다.
이 실험에서 중요한 증거는 response 문자열만이 아니었다. 각 요청의 실제 TCP peer도 함께 기록했다.
요청 1의 실제 목적지: Proxy
요청 2의 실제 목적지: Proxy
요청 3의 실제 목적지: Target
주소가 무엇처럼 보이는지가 아니라, socket이 실제 어디에 연결됐는지를 확인한 것이다.
그런데 왜 기존 테스트는 전부 성공했을까
로컬 테스트 환경에서는 클라이언트가 프록시와 Target 모두에 접근할 수 있었다.
정상 경로
Client → Proxy → Target
우회 경로
Client → Target
둘 다 통신이 된다. 그러니 쿼리 결과만 보면 둘 다 성공이다.
기존 테스트가 확인한 것은 이런 항목이었다.
연결에 성공했는가?
metadata를 읽었는가?
예상한 row 수를 받았는가?
예외 없이 종료됐는가?
전부 맞았다. 하지만 가장 중요한 질문이 빠져 있었다.
모든 요청이 끝까지 프록시를 통과했는가?
테스트는 틀리지 않았다. “결과가 맞다”를 정확히 증명했다. 다만 “경로도 맞다”는 증명하지 않았다.
이 차이는 보안 프록시에서 특히 크다. 결과가 같아도 경로가 다르면 접근통제, 감사, 마스킹, 네트워크 격리 같은 경계가 달라질 수 있다.
어떤 PC는 되고 어떤 PC는 안 되는 이상한 현상
이제 환경별 차이도 자연스럽게 설명된다.
Target 직접 접근이 가능한 PC
→ 유출된 nextUri를 따라가도 성공
→ 정상처럼 보임
Target 직접 접근이 차단된 PC
→ 유출된 nextUri로 연결하다 timeout
→ 장애로 보임
방화벽이 버그를 만든 게 아니다. 이미 존재하던 Proxy 우회를 어떤 PC에서는 허용하고, 어떤 PC에서는 막았을 뿐이다.
로드밸런서 timeout도 마찬가지다. 새 TCP 연결을 더 자주 만들 수는 있지만, 근본 문제는 “새 연결이 오면 쿼리 문맥이 사라지는 설계”다.
좋은 회귀 테스트는 결과와 경로를 따로 본다
이런 종류의 버그를 막으려면 테스트 계약을 두 층으로 나눠야 한다.
1. 결과 계약
쿼리가 성공한다.
결과 row가 맞다.
metadata가 정상이다.
2. 경로 계약
모든 client-facing nextUri가 Proxy 주소다.
모든 continuation request의 실제 peer가 Proxy다.
TCP 연결을 매번 바꿔도 결과가 같다.
Target에 직접 접근 가능해도 Client는 Target으로 가지 않는다.
특히 “Target에 직접 접근 가능한 테스트 환경”이 중요하다. Target을 막아놓으면 우회가 곧 실패가 되어 쉽게 발견된다. Target이 열려 있으면 우회가 성공으로 위장한다.
그래서 경로를 검사하지 않는 happy-path 테스트는 오히려 가장 위험한 조건을 놓칠 수 있다.
상태의 주인은 누구인가
이번 문제에서 가장 오래 남는 교훈은 nextUri보다 상태의 생명주기다.
쿼리 생명주기 ≠ HTTP 요청 생명주기
쿼리 생명주기 ≠ TCP 연결 생명주기
TCP 연결 재사용 ≠ 프로토콜 계약
여러 요청에 걸쳐 이어지는 상태라면 그 상태의 키도 query, transaction, session token처럼 논리적 생명주기를 따라야 한다. 우연히 오래 살아 있는 socket 객체에만 기대면 keep-alive가 깨지는 순간 설계도 함께 깨진다.
물론 상태를 외부 저장소에 넣는 것만이 정답은 아니다. response를 바꾸는 데 이전 request 상태가 정말 필요한지 다시 볼 수도 있다. 중요한 것은 어떤 방법을 택하든 “새 TCP 연결에서도 같은 보안 경계가 유지된다”는 외부 계약이다.
초록색 체크를 한 번 더 의심해야 할 때
20번 성공한 테스트는 여전히 가치가 있다. 실제 query와 driver와 database가 함께 동작한다는 걸 보여줬다.
하지만 이제 체크리스트가 하나 늘었다.
성공했는가?
그리고, 올바른 길로 성공했는가?
프록시, 게이트웨이, 서비스 메시, 접근통제처럼 “중간 경로 자체가 제품 기능”인 시스템에서는 두 번째 질문이 첫 번째만큼 중요하다.
결과만 보면 택배는 잘 도착했다. 하지만 어느 순간부터 배송 트럭이 회사 물류센터를 건너뛰고 고객 집으로 직행하고 있었다.
그걸 알아내려면 상자만 열어볼 게 아니라, 배송 경로도 봐야 한다.