본문 바로가기
빌드노트 검색

빌드노트

JS는 200인데 integrity 오류로 실행되지 않는다

JS가 HTTP 200이어도 HTML의 integrity 해시와 받은 내용이 다르면 실행은 차단된다. 한 바이트 변경 실험으로 이를 확인하고, 신뢰하는 배포 산출물의 해시·HTML·캐시를 같은 버전으로 맞출 판단 기준을 설명한다.

배포 뒤 JS 요청은 HTTP 200인데 화면 초기화가 멈추고, 콘솔에 integrity의 digest가 맞지 않는다는 오류가 나올 수 있다. HTML에 적힌 해시와 브라우저가 받은 JS 내용이 다르면 SRI(Subresource Integrity, 하위 리소스 무결성 검사)가 실행을 막는다. 파일을 받았다는 사실과 그 파일의 무결성 검사를 통과했다는 사실은 별개다.

이때 콘솔이 보여 준 새 해시를 곧바로 HTML에 붙이면 안 된다. 먼저 받은 파일이 신뢰하는 배포 산출물과 같은지 확인하고, 의도한 버전의 JS와 HTML을 한 쌍으로 맞춰야 한다.

상태 코드 다음에 확인할 것

개발자 도구의 Console에서 오류가 가리키는 JS URL을 확인한다. 같은 URL을 Network에서 찾아 최종 응답 주소와 본문을 읽는다. 로그인 HTML이나 다른 파일이 왔다면 해시부터 고칠 일이 아니다. JS 응답이 맞아도 integrity 오류라면 해당 파일을 참조한 HTML의 값을 대조한다. 서버가 보낸 HTML은 문서 요청의 Response에서, 동적으로 추가한 스크립트는 Elements에서 확인한다.

다음은 실험에서 사용한 정상 조합의 태그다. src는 같은 출처의 파일이고, integrity에는 그 파일에 대한 SHA-384 해시를 Base64로 표현한 값을 넣었다. 자기 페이지에서도 이 두 속성이 어떤 산출물을 가리키는지 확인한다.

<script src="/app.js" integrity="sha384-gf8j2mqZn7wyNbrzS7ZJyi+2PW1u73/A7puUm+8EW+Xa03QImvw2ZP+104uHjPAi"></script>

MDN의 SRI 설명 (새 탭에서 열림)은 브라우저가 실행 전에 내용을 해시와 비교한다고 설명한다. W3C SRI 권고안의 실패 처리 (새 탭에서 열림)는 검사 실패 시 실행을 거부하고 네트워크 오류로 처리하도록 정한다. HTTP 응답이 200이어도 이 단계에서 사용이 거부될 수 있다.

한 바이트만 달라도 실행이 갈렸다

2026년 10월 22일 KST에 Windows의 Chrome 156.0.8078.47을 headless로 실행해 별도로 비교했다. 새 프로필을 만들고 loopback 서버에서 HTML과 JS를 같은 출처로 제공했다. 실제 서비스 장애를 재현한 기록은 아니다.

실행 여부는 JS가 문단을 바꿨는지와 콘솔에 표식을 남겼는지로 확인했다. HTML의 문단은 <p id="mark">not-executed</p>로 시작했고, v1 파일의 전체 내용은 아래 두 줄이었다. 두 번째 줄 끝에도 LF 줄바꿈이 있다.

document.querySelector('#mark').textContent = 'executed-v1';
console.log('SRI_MARKER_EXECUTED');

v2는 첫 줄의 executed-v1을 executed-v2로 바꿨다. 바뀐 것은 ASCII 숫자 한 바이트뿐이며 두 파일은 모두 97바이트였다. 세 조건 모두 /app.js라는 같은 URL, Content-Type: text/javascript; charset=utf-8, X-Content-Type-Options: nosniff를 사용했다. 응답에는 Cache-Control: no-store를 보냈고 브라우저 캐시와 Service Worker 경유도 배제했다.

  • v1 JS와 v1 해시: 브라우저 네트워크 기록의 상태는 200, 문단은 executed-v1, 스크립트 요소의 이벤트는 load였다. 표식 로그가 남았다.
  • v2 JS와 그대로 둔 v1 해시: 상태는 200이었지만 문단은 not-executed, 이벤트는 error였다. 표식 로그가 없었고 integrity 차단 오류가 남았다.
  • 같은 v2 JS와 v2 해시: 상태는 200, 문단은 executed-v2, 이벤트는 load였다. 표식 로그가 다시 남았다.

실패 조건의 콘솔 오류에는 Failed to find a valid digest in the 'integrity' attribute와 The resource has been blocked.가 포함됐다. 브라우저에서 받은 응답 본문도 수집했다. 두 번째와 세 번째 조건의 본문은 같은 v2 바이트였고, HTML의 기대 해시만 달랐다. 이 비교에서는 파일 부재나 MIME 오류가 아니라 해시 불일치로 실행이 갈렸다고 판단할 수 있다.

해시는 신뢰하는 최종 JS에서 계산한다

자기 배포에서는 출처와 버전이 확인된 릴리스 산출물의 최종 JS를 기준으로 삼는다. 번들링과 코드 최소화가 끝난 파일이어야 하며 원본 소스 파일의 해시를 대신 쓰면 안 된다. 받은 JS도 그 산출물과 비교하되, 개발자 도구의 정렬된 화면을 복사해 저장하면 공백이나 줄바꿈이 바뀔 수 있다. 브라우저가 실제 사용한 응답 바이트와, 표시용으로 가공한 텍스트를 혼동하지 않는다.

Node.js가 설치돼 있다면 파일이 있는 폴더의 PowerShell에서 다음 명령으로 SHA-384 SRI 값을 계산할 수 있다. app.js를 확인할 실제 파일 경로로 바꾼다. 파일을 수정하지 않고 바이트로 읽는 명령이다.

node -e "const fs=require('node:fs'),c=require('node:crypto'); console.log('sha384-'+c.createHash('sha384').update(fs.readFileSync(process.argv[1])).digest('base64'))" .\app.js

Node.js 22.23.2에서 위 명령을 합성 v2 파일에 실행한 출력은 다음과 같았다. 이 값은 이 글의 97바이트 파일에만 맞는다. 자기 JS에는 자기 출력이 필요하다.

sha384-85u4c0e8wb7ki/u2rmn5/BzBbf4q/wn8hkMwDLGpXyJZvcrM1V0DJ5nGv4PqHrDZ

이 출력과 기존 태그의 sha384-gf8j…는 달랐다. 여기서는 직접 만든 v2 파일의 변경 내용을 확인한 뒤, 같은 v2를 제공하면서 HTML의 integrity를 v2 값으로 바꿨다. 앞의 세 번째 조건이 그 결과다. Node.js의 Hash API (새 탭에서 열림)는 입력을 해시하고 지정한 인코딩으로 digest를 얻는 방법을 설명한다. SRI에는 흔히 보는 16진수 체크섬 대신 알고리즘 접두사와 Base64 값을 사용한다.

명령이 ENOENT로 실패하면 경로부터 확인한다. 값을 얻었지만 HTML과 다르다면, 그 사실만으로 어느 쪽이 올바른지는 알 수 없다. 해시 일치는 바이트가 같다는 검사이며, 내려받은 파일의 출처를 새로 보증하는 절차가 아니다. 콘솔의 계산값을 무조건 채택하거나 integrity를 삭제하면 확인하려던 보호를 잃는다.

HTML·파일·캐시 중 어느 버전이 어긋났는지 나눈다

아래는 실험 결과를 배포 점검에 적용하는 판단 기준이다. 이 실험에서 운영 캐시의 오동작까지 측정한 것은 아니다.

  • HTML은 이전 릴리스의 해시이고 받은 JS가 검증된 새 산출물과 같다면, 새 릴리스를 제공할 것인지 이전 릴리스로 복구할 것인지 정한 뒤 HTML과 JS를 함께 맞춘다. 해시 하나만 보고 배포 의도를 추측하지 않는다.
  • HTML은 새 릴리스의 해시인데 받은 JS가 이전 산출물과 같다면, 파일 제공 경로와 캐시가 이전 버전을 반환하는지 조사한다. 새 해시를 이전 파일에 맞춰 되돌리는 것으로 끝내지 않는다.
  • 받은 JS가 어느 승인 산출물과도 같지 않다면, 예상 밖의 파일·배포 후 내용 변경·중간 변환 등을 조사한다. 원인을 확인하기 전에는 그 바이트의 해시를 허용 목록에 넣지 않는다.

개발자 도구가 열린 상태에서 Network의 Disable cache를 켜고 다시 불러와 HTML과 JS를 함께 대조할 수 있다. 다만 브라우저 캐시를 배제한 결과를 CDN이나 Service Worker의 응답까지 새로워졌다는 증거로 삼지는 않는다. 문서와 JS 각각의 응답 출처, 캐시 관련 헤더, 실제 본문을 확인해야 한다. MDN의 캐시 안내 (새 탭에서 열림)는 브라우저의 개별 캐시와 CDN 등 공유·관리 캐시를 구분한다.

재발을 줄이려면 내용이 달라질 때 JS의 버전 또는 내용 해시가 포함된 URL도 바꾸고, 최종 산출물에서 src와 integrity를 함께 생성한다. 새 파일을 먼저 제공할 수 있게 한 뒤 해당 HTML을 내보내고, 아직 남아 있는 이전 HTML이 참조할 이전 파일도 필요한 기간 유지한다. 같은 URL의 파일을 덮어쓰는 배포에서는 HTML과 파일의 갱신 시점 차이가 그대로 불일치 구간을 만들 수 있기 때문이다. URL 변경을 이용하는 방식은 MDN의 cache busting 설명 (새 탭에서 열림)에서 확인할 수 있다.

수정 확인은 다시 200을 보는 것으로 끝내지 않는다. 의도한 HTML의 해시, 실제 JS 바이트, integrity 오류 유무와 원래 기대한 초기화 동작을 함께 확인한다. 해시가 맞는데도 실행되지 않는다면 Console의 다음 오류를 읽어야 한다.

직접 확인한 범위는 같은 출처의 일반 스크립트, 단일 SHA-384 값, 압축 전송 없는 세 조합이다. 다른 브라우저, 모듈, 실제 CDN·Service Worker 캐시, gzip·Brotli 전달 경로와 운영 배포는 검증하지 않았다. 다른 출처의 JS에는 SRI 외에 crossorigin과 서버의 CORS 허용 조건도 필요하므로, 이 글의 같은 출처 결과만으로 판단하면 안 된다. 해당 조건은 MDN의 SRI와 CORS 설명 (새 탭에서 열림)을 참고한다.

← 전체 글 보기