ice rabbit programming

[회고] 괄호 든 파일만 403 본문

Development/문제 진단 회고

[회고] 괄호 든 파일만 403

판교토끼 2026. 9. 8. 23:03
728x90

들어가기 전에

AI 도구를 활용한 개발이 이제는 그 전이 까마득한 옛날이었던 것으로 느껴질만큼 상용화되었다. 다만 26년 중반을 넘어가는 현재 시점에서는 사용자의 능력에 따라서 이끌어낼 수 있는 결과물의 정도가 달라진다고 생각하고, 이슈가 있을 경우 이를 추론하고 판단하는 능력이 더욱 중요해졌다고 생각한다. 그렇기 때문에 6년 반 정도 개발자로 일하면서 자연스럽게 훈련된 프로세스인, 이슈를 찾고 원인을 파악해서 해결하는 과정을 조금씩 정리해보고자 한다.

괄호 든 파일만 403 — 버그를 고치자 반대쪽 클라이언트가 무너졌다

발단

빌드 업로드 클라이언트로 패키징을 돌리는데, 파일명에 (괄호)가 들어간 로컬라이제이션 번들만 파일 PUT이 403으로 떨어졌다. 3회 재시도 후 원격 동기화가 중단됐다. 영숫자와 - _ .만으로 된 나머지 수천 개 파일은 전부 정상 업로드됐다.

간헐도 아니고 무작위도 아니다. 괄호 파일은 전부 실패, 나머지는 전부 성공. 이렇게 결정론적으로 갈린다면 원인은 하나다 — 경로의 내용이 서명 어딘가에 개입하고 있다.

파고들기

첫 질문은 "왜 예전엔 됐나"였다. 버킷에는 똑같이 괄호가 든 키들이 이미 존재했다. 과거 업로드가 성공했다는 물증이다. 달라진 건 하나, AWS S3 업로드 방식이었다.(S3에서 CF를 경유하도록 변경)

구조 차이가 곧 답의 방향이었다. 직접 업로드 시절엔 AWS SDK의 S3 클라이언트가 자기가 보낼 요청을 자기가 서명했다. SDK는 S3의 SigV4 예외 규칙(경로 싱글 인코딩)을 내장하고 있고, 서명한 바이트와 전송한 바이트가 항상 같다. 전환 후에는 클라이언트가 보낸 경로를 CloudFront가 전달하고, Lambda@Edge가 남의 요청을 대신 서명한다.

그 Lambda의 서명자를 열어보니 이랬다.

 
js
const signer = new SignatureV4({ service: 's3', ... });  // uriEscapePath 미지정

 

signature-v4의 uriEscapePath 기본값은 true다. 서명 전에 경로를 한 번 더 인코딩한다. 클라이언트는 이미 %28괄호%29로 인코딩해 보내는데, 서명자가 %까지 다시 인코딩해 %2528괄호%2529 기준으로 canonical request를 계산한다. S3는 싱글 인코딩으로 계산한다. 서명 불일치, 403. 괄호 없는 파일명은 인코딩해도 제자리라 안 터졌을 뿐이다.

내친김에 다운로드용까지 Lambda 4종을 전수 확인했더니 같은 한 줄이 똑같이 빠져 있었다. 그런데 다운로드는 왜 멀쩡했나. origin-request Lambda는 캐시 미스일 때만 실행된다. 엣지에 캐시된 파일은 서명 경로를 타지 않고 나간다. 즉 "캐시가 빠진 지역·시점에서만 403"이라는 간헐 장애로 잠복해 있던 것이다. 터지기 전에 찾은 폭탄이었다.

수정은 한 줄로 보였다. 4개 소스에 uriEscapePath: false를 넣고 재번들, 함수 교체. 괄호 파일 업로드 성공. 상황 종료라고 생각했다.

반전

같은 날, 이번엔 다운로드 403이 보고됐다. 증상이 묘했다. URL의 괄호를 %28괄호%29로 바꿔 치면 200, 리터럴 (괄호) 그대로면 403. 런처와 curl은 괄호를 인코딩하지 않고 리터럴로 보낸다.

정리하면 이런 매트릭스였다.

와이어 경로구버전 (기본 true)1차 수정 (false만)최종 (정규화 + false)
%28괄호%29 — AWS SDK류 이중 인코딩 → 403 200 200
리터럴 (괄호) — 런처/curl 200 (우연) 리터럴 그대로 서명 → 403 회귀 200

구버전에서 리터럴 GET이 됐던 건 올바라서가 아니었다. 잘못된 재인코딩이 리터럴 입력에 한해 우연히 S3의 계산과 일치했던 것이다. 원래 코드는 "SDK 클라이언트엔 틀리고 리터럴 클라이언트엔 우연히 맞는" 코드였고, false만 적용한 1차 수정은 정확히 그 반대였다. 버그를 고치자 버그가 가려주던 쪽이 무너졌다.

진단을 흐린 요소도 하나 있었다. CDN 경유 응답은 AccessDenied로 보여서 서명 문제로 읽히지 않았는데, S3에 직접 재생하니 SignatureDoesNotMatch가 나왔다. 그리고 그 응답에 포함된 canonical request가 결정타였다. 리터럴로 보내든 인코딩해 보내든, S3의 canonical URI는 항상 %28괄호%29 형태였다. S3의 canonical은 "받은 그대로"가 아니라 항상 UriEncode(키)를 1회 적용한 형태다. SigV4 규격 문서도 path segment는 두 번 인코딩하되 S3만 한 번으로 예외 처리한다고 명시하고 있고, AWS 공식 JS S3 클라이언트 역시 경로 재인코딩을 끈 채로 서명한다.

해결

서명 옵션을 만지는 것으로는 부족했다. 경로 자체를 S3 canonical 형태로 정규화해야 어떤 클라이언트든 커버된다.

 
js
// 세그먼트별 decode → S3식 UriEncode 재인코딩 (멱등)
function s3UriEncodeSegment(segment) {
    let decoded;
    try { decoded = decodeURIComponent(segment); }
    catch (e) { decoded = segment; }               // 깨진 %는 리터럴 취급
    return encodeURIComponent(decoded)
        .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
}

request.uri = normalizeS3Path(request.uri);        // 정규화한 형태를 오리진에도 전달
// 그리고 그 형태를 재인코딩 없이 그대로 서명 (uriEscapePath: false)

리터럴이든 인코딩이든, 한글·공백이 섞였든, 서명한 형태 = 전송한 형태 = S3가 계산하는 형태가 항상 일치한다.

검증은 배포한 zip 아티팩트를 로컬에서 그대로 실행해 실제 S3에 재생하는 방식으로 했다. 리터럴·인코딩 GET 200, PUT 정규화·가드 정상, ListObjectsV2 200. Lambda@Edge는 발행 버전 교체 방식이라 롤백 경로(이전 버전 재연결)를 확보한 상태로 진행했다.

운영 함정이 하나 남는다. 이 Lambda는 CDN을 생성할 때만 배포되므로, 서버를 새로 배포해도 기존 배포판의 함수는 바뀌지 않는다. 기존 CDN 전체에 대한 일괄 재교체가 별도 작업으로 필요했다.

교훈

S3만 규칙이 다르다. SigV4 canonical URI에서 S3는 유일한 예외다(1회 인코딩, 정규화 금지). SignatureV4를 직접 쓸 때 경로 재인코딩을 끄는 건 필수이고, 그것만으론 부족하다. 서명자가 경로를 canonical 형태로 정규화까지 해야 모든 클라이언트를 커버한다.

버그 수정은 "우연히 맞던" 경로를 깬다. 클라이언트마다 URL 인코딩 습관이 다르다. SDK는 인코딩하고, curl과 런처는 리터럴로 보낸다. 한쪽 형태만 테스트하면 반대쪽 회귀를 놓친다. 1차 수정이 정확히 그 사례였다.

캐시 뒤의 버그는 간헐 장애로 늦게 온다. origin-request 훅의 결함은 캐시 미스에서만 발현된다. 특수문자 파일명(( ) ! ' *, 공백, 한글)의 업로드→다운로드 왕복을 리터럴·인코딩 양쪽 URL로 릴리스 체크리스트에 넣어야 한다.

마치며

CDN 구성 및 관리 R&R을 기존에 가지고 있지 않았기 때문에(아직도 완전히 가져온 것이 아닌, SDK를 이용하는 부분에 한하여 담당하고 있지만 운영 측면과 완전히 분리할 수가 없어서 경험이 점차 쌓이고 있기는 하다.), 관련된 경험 및 지식이 부족한 상태였다. AWS SA의 자문을 받아서 수행하긴 했으나 미흡한 부분이 있었고, 리소스를 변경하는 과정에서 미치게 될 영향에 대한 사전 탐색이 부족했고, 일반적인 파일명에서는 이슈가 없었기 때문에 테스트도 미흡한 부분이 있었다.

리소스 변경은 큰 작업이기 때문에 좀 더 꼼꼼히 될 수 있도록 체크리스트를 더 세밀하게 하고, 개발자 테스트 시 일반적인 케이스 뿐 아니라 여러 케이스들을 테스트하도록 개선하는 것으로 마무리하였다.

728x90