← 웹/클라우드/인프라로 돌아가기
웹/클라우드/인프라신윤섭·2026년 9월 28일

HTTPS를 켜도 URL은 로그에 그대로 남는다

공유

에러가 안 나는 실수

Claude Code에게 날씨 기능을 붙여달라고 한다. 잠시 뒤 이런 코드가 나온다.

url = f"https://api.example.com/v1/weather?city={city}&api_key={API_KEY}"
r = httpx.get(url)

키는 환경변수에서 잘 읽어왔다. 하드코딩도 아니다. 돌려보면 날씨가 나온다. 경고도 없고 빨간 줄도 없다. 그래서 그냥 넘어간다.

며칠 뒤에는 비밀번호 재설정을 만들어 달라고 한다. 메일에 들어갈 링크가 이렇게 생성된다.

https://myapp.com/reset?token=8f3a91c0d2e7

이것도 잘 동작한다. 메일이 오고, 링크를 누르면 비밀번호가 바뀐다.

두 코드에는 공통점이 하나 있다. 비밀에 해당하는 값을 URL에 실었다는 점이다. 그리고 URL은 요청에서 가장 많이 복사되는 부분이다. 코드에 있는 다른 어떤 값보다도 여러 곳에 베껴 적힌다.

예전에 환경변수를 다루면서 키를 코드 밖으로 빼내는 이야기를 했다. 이 글은 그 뒤에 오는 이야기다. 소스에서 잘 빼낸 키라도 요청을 만드는 순간 다시 흘러나올 수 있다.

바이브 코딩에서 이게 유독 자주 나오는 이유가 있다. 실제로 많은 API가 쿼리 파라미터 방식을 지원한다. 문서에도 ?key=YOUR_API_KEY 형태의 예시가 실려 있으니, 학습 데이터를 따라 쓰는 에이전트 입장에서는 그게 정답처럼 보인다. 게다가 이 실수는 절대 실패하지 않는다. 테스트도 통과하고 배포도 된다. 아무도 고쳐야 한다고 알려주지 않는다.

HTTPS가 감추는 구간과 URL이 남는 자리
HTTPS가 감추는 구간과 URL이 남는 자리

TLS는 터널이지 금고가 아니다

먼저 URL이 어떻게 생겼는지 나눠보자.

https:// api.example.com /v1/weather ?city=seoul&api_key=sk_live_abc123
  스킴      호스트            경로              쿼리스트링

HTTPS를 쓰면 이 중에 뭐가 가려질까. 흔히 도는 말은 두 갈래다. "HTTPS면 URL도 암호화되니 괜찮다"와 "HTTPS라도 URL은 다 보인다". 둘 다 반만 맞다.

정확히는 이렇다. TLS 연결이 맺어진 뒤에 오가는 HTTP 요청은 통째로 암호화된다. 경로도, 쿼리스트링도, 헤더도, 본문도 전부 터널 안에 있다. 카페 와이파이를 같이 쓰는 사람이 패킷을 들여다봐도 api_key=sk_live_abc123은 읽을 수 없다.

대신 터널을 만들기 전에 평문으로 나가는 게 있다. 호스트 이름이다. 브라우저는 먼저 api.example.com의 주소를 물어봐야 한다. 이게 DNS 질의다. 그다음 서버와 암호화 규칙을 맞추는 짧은 대화를 주고받는데, 이 과정을 TLS 핸드셰이크라고 부른다. 여기서 브라우저는 "나 이 이름으로 접속하려고 한다"고 도메인을 알려준다. 한 서버가 여러 사이트를 같이 호스팅하는 일이 흔해서, 어느 사이트의 인증서를 내놓을지 서버가 알아야 하기 때문이다. 결국 이 두 군데에는 도메인 이름이 그대로 실린다. 다만 경로와 쿼리는 여기 들어가지 않는다. 중간에서 지켜보는 쪽이 알 수 있는 건 "누구에게 접속했는가"까지다.

즉 터널 안은 안전하다. 문제는 터널이 끝나는 자리에서 시작된다. 요청이 서버에 도착하면 TLS는 벗겨지고, 거기서부터 URL은 평범한 문자열이 된다. 그 문자열을 받아 적는 프로그램이 경로마다 하나씩 앉아 있다.

웹서버는 요청 줄을 통째로 적는다

nginx의 기본 로그 형식은 이름이 combined다. 설정을 따로 안 건드렸다면 이 형식이 쓰인다.

log_format combined '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';

가운데 $request가 핵심이다. 이 변수에는 요청의 첫 줄이 그대로 들어간다. 그러니까 이런 게 파일에 쌓인다.

203.0.113.7 - - [28/Sep/2026:10:14:22 +0900] "GET /reset?token=8f3a91c0d2e7 HTTP/1.1" 200 1420 "-" "Mozilla/5.0..."

Apache도 사정이 같다. combined 형식의 %r이 nginx의 $request와 같은 역할을 한다. 웹서버를 직접 안 쓰고 관리형 플랫폼에 올렸다고 벗어나는 것도 아니다. CDN이 앞에 있으면 거기서 한 번 더 적힌다. CloudFront 표준 로그에는 cs-uri-query라는 필드가 있고, 설명이 이렇다. "요청 URL의 쿼리스트링 부분, 있는 경우." 경로를 담는 cs-uri-stem 필드는 물음표 뒤를 일부러 떼어내는데, 떼어낸 그 값이 바로 옆 칸에 따로 들어간다.

기록이 한 곳에만 머물면 그나마 낫다. 실제로는 이렇게 퍼진다. 액세스 로그 파일이 남고, 로그 수집 도구가 그걸 빨아들여 중앙 저장소에 넣고, 저장소는 백업된다. 에러가 나면 추적 도구가 요청 URL을 붙여서 이벤트를 만든다. 장애를 들여다보다가 터미널 화면을 캡처해서 팀 채널에 붙여넣기도 한다.

"그래도 우리 서버 안에 있는 거 아닌가" 싶을 텐데, 바로 그게 문제다. 조직은 비밀과 로그를 다른 물건으로 취급한다. API 키는 비밀 저장소에 넣고 꺼내 쓸 사람을 따로 정하지만, 로그는 문제를 빨리 찾으라고 만든 물건이라 권한을 넉넉하게 연다. 새로 합류한 사람에게 가장 먼저 주는 권한이 로그 조회인 경우도 많고, 외주로 붙은 개발자에게도 디버깅하라고 열어준다. 로그 검색 도구를 외부 서비스로 쓰고 있다면 그 문자열은 이미 우리 계정 밖으로 나가 있다. 비밀을 그런 곳에 흘려놓으면, 비밀 저장소를 아무리 잘 잠가둬도 의미가 없어진다.

여기에 시간 문제가 겹친다. 로그 보존 기간은 보통 30일이나 90일로 잡는다. 그런데 API 키에는 만료가 없는 경우가 많다. 작년에 잠깐 켰다 끈 서버의 로그에 올해도 유효한 키가 들어 있는 상황이 그래서 나온다. 키를 URL에 한 번 실으면, 그 키의 수명은 코드가 아니라 로그 보존 정책이 결정한다.

URL이 복사되는 자리들
URL이 복사되는 자리들

브라우저 쪽에서 새는 길은 좀 달라졌다

토큰이 링크에 실려 사용자 브라우저까지 가는 경우에는 이야기가 하나 더 붙는다. 비밀번호 재설정 링크가 딱 그렇다.

이 주제를 다루는 오래된 자료들은 여기서 Referer 헤더를 지목한다. 사용자가 myapp.com/reset?token=... 페이지에 있는 동안 외부 이미지나 광고 스크립트를 불러오면, 그 요청에 현재 주소가 실려 남의 서버로 간다는 이야기다. 지금도 이 설명을 그대로 옮겨놓은 자료가 많은데, 브라우저 기본 동작은 2020년에 바뀌었다.

요즘 브라우저는 페이지가 정책을 따로 지정하지 않으면 strict-origin-when-cross-origin으로 동작한다. 이 정책에서 다른 출처로 나가는 요청에는 출처까지만 실린다. https://myapp.com/reset?token=8f3a91c0d2e7 페이지에서 외부 사이트로 요청이 나가면 상대가 받는 건 https://myapp.com/, 여기까지다. 경로도 쿼리도 잘려나간다.

그래서 Referer 경로는 기본값만 놓고 보면 예전만큼 위험하지 않다. 다만 조건이 두 개 붙는다. 같은 출처 안에서 움직이는 요청에는 경로와 쿼리가 전부 실린다. 그리고 페이지나 태그 단위로 정책을 느슨하게 바꿔놓으면 기본값은 의미가 없어진다.

진짜 문제는 다른 데 있다. 헤더를 거칠 필요가 없는 경로가 열려 있다. 페이지 안에서 도는 스크립트는 location.href를 그냥 읽으면 된다. 애널리틱스, 세션 리플레이, 챗 위젯처럼 <script> 한 줄로 붙이는 도구들은 현재 주소를 수집해서 자기 서버로 보내는 게 원래 하는 일이다. Referrer-Policy는 이걸 막지 못한다. 그 스크립트는 남이 아니라 우리 페이지의 일부로 실행되고 있기 때문이다.

사용자 손에 남는 흔적도 있다. 주소창의 값은 방문 기록에 들어가고, 북마크로 저장되고, 화면 공유 중에 그대로 노출된다. "왜 안 되지" 하며 그 링크를 통째로 복사해 메신저 상담창에 붙여넣는 일도 흔하다.

비밀번호 재설정 토큰처럼 URL에 실을 수밖에 없는 값이라면, 대응은 노출 자체를 막는 쪽이 아니라 노출되더라도 쓸모가 없게 만드는 쪽이다. 유효 시간을 짧게 두고, 한 번 쓰면 바로 폐기한다. 토큰을 검증한 직후 history.replaceState로 주소창에서 지워두면 방문 기록과 스크립트 수집 범위도 줄어든다.

표준 문서가 직접 적어둔 문장

이건 누가 해석한 관행이 아니라 규격에 쓰여 있다. OAuth 2.0의 Bearer 토큰 사용법을 정한 RFC 6750이다. Bearer는 "가진 사람"이라는 뜻이고, 무기명 수표처럼 값을 쥔 쪽이 곧 권한자가 된다는 의미다. 누가 훔쳐서 들고 와도 서버는 구별하지 못한다. 이 규격은 토큰 전달 방법을 세 가지로 정의하는데, 그중 URI 쿼리 파라미터 방식에 이런 단서를 붙였다.

Because of the security weaknesses associated with the URI method (see Section 5), including the high likelihood that the URL containing the access token will be logged, it SHOULD NOT be used unless it is impossible to transport the access token in the "Authorization" request header field or the HTTP request entity-body.

액세스 토큰이 담긴 URL이 로그에 남을 가능성이 높다는 이유로, 헤더나 본문으로 보내는 게 불가능한 경우가 아니면 쓰지 말라고 못을 박았다. 5.3절에서는 이유를 한 번 더 펼친다.

Browsers, web servers, and other software may not adequately secure URLs in the browser history, web server logs, and other data structures.

브라우저 방문 기록과 웹서버 로그를 비롯해 URL이 들어가는 자리들은 애초에 비밀을 안전하게 지킬 목적으로 만들어진 게 아니라는 말이다. 앞에서 로그 권한 이야기로 길게 풀었던 내용이 규격에는 이 한 문장으로 들어가 있다.

규격이 방법을 정의해뒀다는 것과 그 방법이 안전하다는 것은 다른 이야기다. 이 방식이 문서에 남아 있는 이유는 헤더를 못 쓰는 오래된 클라이언트와 맞물려 돌아가야 했기 때문이다. 호환을 위한 비상구지 권장 경로가 아니다.

그러니 API를 붙일 때 순서는 정해져 있다. 그 API가 Authorization 헤더를 받는지 문서에서 먼저 확인하고, 받으면 헤더로 보낸다.

r = httpx.get(
    "https://api.example.com/v1/weather",
    params={"city": city},
    headers={"Authorization": f"Bearer {API_KEY}"},
)

키가 URL에서 빠지는 순간 앞에서 본 복사 경로는 대부분 닫힌다. 헤더는 웹서버 기본 로그 형식에도, CloudFront 표준 로그 필드에도 들어가지 않는다.

쿼리 파라미터 방식과 헤더 방식 비교
쿼리 파라미터 방식과 헤더 방식 비교

프리사인드 URL은 왜 예외처럼 보이나

여기까지 읽으면 걸리는 게 하나 있을 것이다. S3 프리사인드 URL은 서명값이 쿼리스트링에 통째로 들어간다. 클라우드 제공사가 만든 기능인데 왜 이렇게 설계했을까.

AWS 문서는 이 물음에 정면으로 답한다. 프리사인드 URL은 "가진 사람에게 접근 권한을 주는 무기명 토큰"이며 그에 맞게 보호해야 한다고 적혀 있다. 앞에서 본 그 Bearer다. URL 자체가 비밀이라는 걸 감추지 않는 셈이다. 링크만 있으면 로그인 없이 파일을 받을 수 있게 하려는 게 목적이고, 그러려면 비밀이 URL에 들어갈 수밖에 없다.

이 설계가 성립하는 전제는 만료다. 짧게 살다 죽는 URL이라야 유출돼도 손해가 제한된다. 그래서 AWS는 만료 한도를 걸어뒀다. 콘솔에서 만들면 1분에서 12시간 사이에서 고르고, CLI나 SDK로 만들면 최대 7일까지 잡을 수 있다.

에이전트에게 파일 다운로드 기능을 맡기면 이 숫자가 어떻게 나오는지 짐작이 갈 것이다. 한도가 7일이니 ExpiresIn=604800을 채워 넣는다. 잘못된 코드는 아니다. 다만 그 링크가 브라우저 방문 기록과 CDN 로그에 일주일 동안 유효한 상태로 누워 있게 된다. 사용자가 파일 하나 받는 데 필요한 시간은 보통 몇 분이다.

한 가지 덧붙이면, 지정한 만료가 항상 그대로 적용되지도 않는다. EC2나 컨테이너에서 IAM 역할로 서명했다면 그 자격 증명 자체에 수명이 있고, URL은 둘 중 먼저 끝나는 쪽을 따라간다. AWS 문서는 STS AssumeRole 세션이 기본 1시간, EC2 인스턴스 프로파일 자격 증명은 대략 6시간 주기로 돈다고 안내한다. ExpiresIn을 7일로 적어두고 실제로는 한 시간 만에 링크가 죽는 상황이 여기서 나온다. 만료가 짧아서 생긴 버그를 만료를 늘려서 고치려 들면 방향이 반대로 간다.

에이전트에게 뭐라고 말할 것인가

요청할 때 이 문장을 같이 적어주면 대체로 맞게 나온다. "API 키와 액세스 토큰은 쿼리 파라미터가 아니라 Authorization 헤더로 보내줘. 요청 로깅은 경로만 남기고 쿼리스트링은 빼줘. 프리사인드 URL은 만료를 15분으로 해줘."

이미 짜둔 코드를 훑는다면 볼 곳은 세 군데다.

가장 먼저 URL을 문자열로 조립하는 자리를 본다. api_key=, access_token=, token= 세 패턴으로 검색해서 f-string이나 템플릿 리터럴 안에 들어간 것만 추리면 대부분 여기서 걸린다. key=까지 넣고 싶은 마음이 들겠지만 그건 권하지 않는다. 딕셔너리든 쿼리든 아무 데나 붙는 문자열이라 결과가 수백 줄로 늘어나고, 그러면 아무도 끝까지 안 본다.

다음은 로깅 미들웨어다. 요청 로그를 만들어 달라고 하면 에이전트는 req.originalUrl이나 request.url을 통째로 찍는 코드를 잘 쓴다. 경로만 남기도록 바꾸면 우리 앱 로그에서는 쿼리가 빠진다. 다만 앞단의 웹서버와 CDN은 별개로 자기 기록을 남기니 이걸로 끝났다고 보면 안 된다. 순서는 어디까지나 URL에 비밀을 안 싣는 쪽이 먼저다.

마지막은 프리사인드 URL을 만드는 자리의 ExpiresIn 값이다. 7일에 가까운 숫자가 보이면 그 링크가 실제로 얼마나 살아 있어야 하는지 다시 따져볼 만하다.

그리고 이미 로그에 남은 키를 발견했을 때. 이때 로그를 지우는 건 대응이 아니다. 어디까지 복사됐는지 확실히 알 수 없기 때문이다. 키를 새로 발급하고 옛 키를 폐기하는 쪽이 유일하게 확실한 처리다. 그래서 API 키는 처음 붙일 때부터 회전 가능한 형태로 관리해두는 편이 낫다. 발급처 콘솔에서 새 키를 만들고 환경변수만 바꿔 끼우면 끝나게, 코드 어디에도 키가 박혀 있지 않게.

YS

신윤섭

데이너스 대표 | AI 교육 & AX 컨설팅

126개의 AI/AX 교육 과정을 설계하고, 90개 교육·협업 기관에서 강의했습니다. 강남세브란스, 삼성전자, 현대자동차 등 다양한 조직의 AI 역량 강화를 지원하고 있습니다.

AI 교육이 필요하신가요?

조직에 맞는 맞춤형 AI/AX 교육 프로그램을 설계해드립니다. 커리큘럼 상담부터 시작해보세요.

같은 주제의 다른 글