프록시를 고치다가 아주 그럴듯한 결론에 도착할 때가 있다.

“클라이언트에게 나가는 주소를 프록시 주소로 바꾸면 끝 아닌가?”

반은 맞고, 반은 위험하다.

긴 쿼리나 대용량 응답을 처리하는 시스템은 보통 한 번의 요청으로 끝나지 않는다. 첫 응답은 “다음 결과는 이 주소로 오세요”라는 링크를 남긴다. 중간 프록시가 있다면 그 링크는 당연히 프록시 주소여야 한다.

그런데 여기서 주소를 하나만 들고 있으면 문제가 시작된다. 사용자에게 보여줄 주소서버가 실제 일을 처리할 주소는 같은 역할이 아니기 때문이다.

다음 페이지 링크는 안내문이다

먼저 긴 결과를 페이지로 나눠 보내는 HTTP 흐름을 보자.

클라이언트: SQL 제출
서버:      첫 결과 + 다음 페이지 주소
클라이언트: 다음 페이지 주소 요청
서버:      다음 결과 + 또 다음 페이지 주소

원래 서버는 자기 내부 주소를 다음 페이지 주소로 줄 수 있다.

https://data-server.internal:8443/results/...

하지만 회사의 접근 제어, 감사, 마스킹을 거쳐야 한다면 클라이언트는 이 주소를 직접 열면 안 된다. 프록시는 안내문을 이렇게 고친다.

https://data-proxy.example:443/results/...

이제 클라이언트는 계속 프록시로 돌아온다. 좋아 보인다.

문제는 “취소”가 등장할 때다

쿼리가 오래 걸리다가 오류가 났다고 해보자. 프록시는 더 이상 결과를 기다리지 않고, 뒤쪽 서버에 “이 작업을 멈춰 주세요”라고 알려야 한다.

그런데 프록시가 다음 페이지 주소를 고치면서 원래 주소까지 잃어버렸다면 어떻게 될까?

클라이언트에게 보여 준 주소
= 프록시 주소

실제 작업을 멈춰야 하는 주소
= 뒤쪽 서버 주소

둘은 다르다.

택배 비유로 생각하면 쉽다.

  • 고객에게 주는 배송 조회 링크는 고객센터 주소여야 한다.
  • 물류센터에 배송을 멈추라고 보낼 지시는 실제 물류센터 주소로 가야 한다.

고객센터 링크만 남기고 물류센터 주소를 지우면, 고객은 편하게 조회할 수 있어도 긴급 정지는 엉뚱한 곳으로 간다.

반대로 물류센터 주소를 고객에게 그대로 주면, 고객은 고객센터를 건너뛰고 창고로 바로 들어가려 한다.

그래서 좋은 프록시는 주소를 두 개로 관리한다.

1. Client-facing URI
   - 사용자가 따라갈 주소
   - 항상 프록시를 가리킨다

2. Raw upstream URI
   - 프록시가 뒤쪽 서버를 호출·취소할 주소
   - 외부에 노출하지 않는다

이건 데이터를 중복 저장하는 게 아니다. 서로 다른 책임을 섞지 않는 것이다.

연결이 바뀌면 기억도 사라진다

여기에 또 하나의 함정이 있다. 쿼리는 오래 살아도 TCP 연결은 짧게 바뀔 수 있다는 점이다.

하나의 쿼리:  POST → GET → GET → GET
TCP 연결:       A      B      C

connection pool, keep-alive, load balancer, retry는 모두 새 TCP 연결을 자연스럽게 만든다. 그런데 프록시가 “이 요청은 이전 쿼리의 다음 페이지인가?”라는 기억을 TCP 연결 객체에만 적어 두면 TCP B에서는 그 기억이 사라진다.

그러면 프록시는 두 가지 중 하나를 잘못하기 쉽다.

A. 다음 주소를 고치지 못하고 내부 서버 주소를 그대로 전달한다.
B. 잘못된 쿼리의 상태를 억지로 이어 붙인다.

A는 보안 경계를 우회하게 만들고, B는 다른 사용자의 작업을 섞을 수 있다. 둘 다 나쁘다.

해결의 핵심은 오래가는 상태를 오래가는 기준으로 찾는 것이다.

TCP 연결이 아니라
→ 인증된 사용자
→ 프록시/서비스 경계
→ 다음 요청의 경로
→ 짧은 유효기간

이 조합으로 “이 새 연결은 이전 작업의 연속인가?”를 확인한다.

여기서 중요한 원칙은 복구에 실패했을 때다. 시스템은 친절하게 추측하면 안 된다.

context를 찾지 못했다
→ 내부 주소를 그대로 전달하지 않는다
→ 다른 작업 상태를 가져오지 않는다
→ 안전하게 중단하고 재실행을 안내한다

보안 프록시에서 이 선택은 불편해 보여도 맞다. 모르는 주소를 사용자에게 넘기는 것보다, 다시 요청하게 하는 편이 낫다.

“성공” 테스트만으로는 부족하다

이런 버그는 테스트에서 특히 얄밉다. 뒤쪽 서버에 직접 접근할 수 있는 개발 환경에서는 프록시가 주소를 잘못 줘도 쿼리가 끝까지 성공할 수 있다.

정상 경로
Client → Proxy → Server

숨은 우회 경로
Client → Server

결과 row가 맞고, 에러도 없고, 테스트는 초록색이다. 하지만 접근 통제와 감사 경로는 사라졌다.

그래서 프록시나 게이트웨이를 테스트할 때는 질문을 두 개로 나눠야 한다.

결과 계약
- 결과가 맞는가?
- 오류 없이 끝났는가?

경로 계약
- 모든 다음 페이지 링크가 프록시를 가리키는가?
- 새 TCP 연결에서도 프록시로 돌아오는가?
- 실제 socket의 peer가 끝까지 프록시인가?
- 취소 요청은 실제 upstream 작업에 도달하는가?

특히 “매 요청마다 새 TCP 연결”이라는 테스트는 생각보다 강력하다. 오래된 세션 객체에 숨겨 둔 가정을 한 번에 드러내기 때문이다.

프록시는 주소 변환기가 아니라 경계 관리자다

프록시를 단순히 host와 port를 바꾸는 기능으로 보면 이 문제는 작은 문자열 처리처럼 보인다.

하지만 실제 프록시는 더 많은 일을 한다.

사용자에게는 안전한 경로를 안내한다.
뒤쪽 서버에는 필요한 원본 요청을 전달한다.
실패하면 정확한 작업을 취소한다.
새 연결이 와도 같은 작업을 안전하게 이어 준다.
끝난 작업의 기억은 제때 지운다.

그래서 주소 하나를 고치는 patch도 lifecycle 전체를 봐야 한다. 정상 응답만이 아니라 새 연결, 취소, 오류, 재시도, 만료까지.

이 원칙은 쿼리 프록시만의 이야기가 아니다. 결제의 다음 단계 URL, 파일 업로드의 multipart session, 비동기 작업의 polling endpoint, 배치 작업의 cancel handle에도 그대로 적용된다.

사용자에게 보여 주는 식별자와 시스템이 실제로 처리하는 식별자는 역할이 다를 수 있다.

그 차이를 숨기지 않고 분리해 두는 것. 그것이 연결이 바뀌어도 경계를 지키는 시스템을 만든다.