meta charset utf-8: 애드센스 크롤러가 한글 이미지 주소를 깨뜨린 404와 php-fpm 꽉 참 문제

공정위 광고 문구 Ver 3.1
공정위 광고 문구 Ver 3.2(모바일 전용)
테스트 환경RHEL 계열AlmaLinux 9.8Nginx와 Apache 구성캐시 플러그인의 정적 HTML을 nginx가 바로 응답2026-11-04 확인
주소, 경로, 숫자 일부는 예시 값

HTML 앞부분에서 meta charset utf-8 선언을 찾지 못하고 응답 머리에도 charset이 없으면, 읽는 쪽은 문서의 문자 인코딩을 짐작하거나 기본값으로 읽게 됩니다. Nginx와 Apache 구성의 워드프레스 서버에서 애드센스 크롤러가 깨진 한글 이미지 주소로 요청을 몰아 보내 404 처리에 PHP 워커가 모두 찬 일을 로그로 추적한 결과, 캐시본 응답 머리에 charset을 붙이고 없는 정적 파일은 nginx가 바로 404로 처리하게 해 같은 요청이 다시 워커를 채우지 않게 했습니다.

1️⃣ meta charset utf-8: 앞 1,024바이트 안에 있어야 하는 까닭

서버는 문서의 문자 인코딩을 두 곳에서 알릴 수 있습니다. 응답 머리 [Content-Type] 뒤에 붙는 charset과, HTML 안에 적는 meta charset 줄입니다.

HTML 표준은 문자 인코딩 선언이 앞 1,024바이트 안에만 오도록 정하고, 읽는 쪽에도 앞 1,024바이트만 미리 읽어 찾도록 권합니다. 그래서 head 영역 안에 있더라도 1,024바이트 뒤로 밀린 meta charset은 찾지 못하는 읽는 쪽이 생깁니다.

인코딩을 정할 때는 BOM(바이트 순서 표시)을 먼저 보고, 다음이 응답 머리의 charset, 그다음이 앞 1,024바이트 미리 읽기로 찾은 meta charset입니다. 셋 다 없으면 바이트 모양으로 짐작하거나 정해 둔 기본값으로 읽게 되며, HTML 표준이 제안하는 기본값 표에서 대부분의 locale은 windows-1252입니다.

windows-1252로 UTF-8 문서를 읽으면 한글처럼 여러 바이트로 된 글자 하나가 여러 개의 다른 글자로 바뀝니다.

🧭 meta charset utf-8 인코딩 결정 순서
설명 다이어그램
🔍 순서
📄 응답
🔖BOM(바이트 순서 표시)있으면 그 인코딩으로 정함없음없음없음
📨Content-Type 머리의 charset있으면 그 값으로 정함charset 없음charset 없음
🏷️meta charset 줄앞 1,024바이트 안에 있으면 그 값으로 정함1,024바이트 뒤로 밀려 찾지 못함
➜
🔎 읽는 쪽
🔤문서 인코딩찾은 값으로 읽음찾은 값으로 읽음찾은 값으로 읽음짐작하거나 기본값 windows-1252로 읽음
인코딩을 정할 때는 BOM(바이트 순서 표시)을 먼저 보며, BOM이 있으면 그 인코딩으로 읽습니다.
BOM이 없으면 다음은 응답 머리 Content-Type 뒤에 붙는 charset입니다.
그다음이 앞 1,024바이트 미리 읽기로 찾은 meta charset입니다. head 영역 안에 있더라도 1,024바이트 뒤로 밀린 meta charset은 찾지 못하는 읽는 쪽이 생깁니다.
셋 다 없으면 바이트 모양으로 짐작하거나 정해 둔 기본값으로 읽게 됩니다. 머리에 charset이 없고 meta charset이 1,024바이트 뒤에 있으면, 대부분의 locale에서 기본값인 windows-1252로 읽힐 수 있습니다.

2️⃣ 애드센스 크롤러 Mediapartners-Google이 깨뜨린 한글 이미지 주소

앞 1,024바이트 기준으로 고치기 전 캐시본을 확인한 결과, 응답 머리의 Content-Type에는 charset이 없었고 meta charset 줄은 15,199바이트 자리에 있었습니다. 그래서 애드센스 크롤러 Mediapartners-Google이 받은 캐시본은 두 자리 모두에서 인코딩을 찾을 수 없는 상태였습니다.

curl -sI https://example.com/sample-post/ | grep -i '^content-type'
content-type: text/html

이 구성에서는 주소 뒤에 쿼리 문자열을 붙이면 캐시본을 건너뛰고 PHP가 바로 그린 응답이 오며, 그 응답에는 charset이 붙어 있었습니다.

curl -sI 'https://example.com/sample-post/?nocache=1' | grep -i '^content-type'
content-type: text/html; charset=UTF-8

캐시 파일 안에서 meta charset이 시작하는 자리는 [grep -b]로 바이트 단위까지 확인할 수 있습니다.

grep -bo '<meta charset' /var/www/html/wp-content/cache/wp-rocket/example.com/sample-post/index-https.html | head -1
15199:<meta charset

같은 캐시 파일의 img 태그에는 UTF-8 다국어 파일 이름(이 경우 한글)이 퍼센트 인코딩 없이 그대로 들어 있었습니다. 이 HTML을 windows-1252로 읽으면 [대시보드-화면-예시.png]라는 이름이 [대시보드-화면-예시.png] 같은 글자로 바뀌고, 그 글자를 다시 UTF-8로 퍼센트 인코딩해 요청하면 서버에 없는 파일을 찾게 됩니다.

액세스 로그에서 이 크롤러가 받은 그림 404를 뽑으면 깨진 이름의 주소가 이어서 나옵니다.

grep 'Mediapartners-Google' /var/log/nginx/access.log | grep ' 404 ' | grep 'GET /wp-content/uploads/' | cut -d' ' -f1,4,7,9 | head -2
203.0.113.3 [03/Nov/2026:20:40:51 /wp-content/uploads/2025/07/%C3%AB%C5%92%E2%82%AC%C3%AC%E2%80%B9%C5%93%C3%AB%C2%B3%C2%B4%C3%AB%E2%80%9C%C5%93-%C3%AD%E2%84%A2%E2%80%9D%C3%AB%C2%A9%C2%B4-%C3%AC%CB%9C%CB%86%C3%AC%E2%80%B9%C5%93-1024x682.png 404
203.0.113.1 [03/Nov/2026:20:40:51 /wp-content/uploads/2025/07/%C3%AD%CB%9C%C2%B8%C3%AC%C5%A0%C2%A4%C3%AD%C5%92%E2%80%A6-%C3%AB%C2%A9%E2%80%9D%C3%AB%E2%80%B0%C2%B4-%C3%AC%CB%9C%CB%86%C3%AC%E2%80%B9%C5%93-300x159.jpg 404

로그에 남은 주소는 원래 파일 이름의 UTF-8 바이트를 windows-1252 글자로 읽고 다시 UTF-8로 인코딩한 값과 바이트까지 같았습니다. 같은 꼴로 깨진 그림 주소 요청은 그날 로그에서 이 크롤러가 202건, 스크립트 하나(python-requests)가 6건이었고, 방문자에게서 온 요청은 0건이었습니다.

깨진 이름 [대시보드-화면-예시.png]를 UTF-8 변환기로 되돌리면 UTF-8 바이트를 windows-1252로 읽어 깨진 경우로 판정되고, 원래 이름 [대시보드-화면-예시.png]가 나옵니다.

3️⃣ 없는 이미지 404가 php-fpm을 채운 과정

깨진 주소의 그림은 서버에 없으므로, nginx는 이미지 위치 규칙에서 try_files의 마지막 항목에 따라 요청을 Apache로 넘겼고 워드프레스가 404 화면을 PHP로 그렸습니다. 같은 날 낮 시간대에 기록된 404 587건을 보면, 캐시가 없는 404 화면 하나에 중앙값 1.4초가 소요되었습니다.

🌐 Mediapartners-Google깨진 이름의 그림 주소 요청
nginx 이미지 위치(location)try_files로 디스크의 파일 확인
파일 있음nginx가 바로 200 응답
파일 없음try_files 마지막 항목인 백엔드로 넘김
Apache → 워드프레스404 화면을 PHP로 렌더(중앙값 1.4초)
php-fpm 워커요청 하나에 워커 하나
풀 상한(pm.max_children)다 차면 뒤 요청은 대기(499, 504)

깨진 이름의 그림 요청은 20:40부터 3분 동안 200건 남짓 몰렸고, 그중 133건이 404로 끝났습니다. php-fpm 워커는 요청 하나에 하나씩 할당되며, 404 하나에 4~6초가 소요되는 동안 요청이 이어지자 20:40:55에 풀 상한에 닿았습니다.

풀 상한에 닿은 순간은 php-fpm 오류 기록에 php-fpm max_children 경고로 남습니다.

grep 'max_children' /var/log/php-fpm/error.log
[03-Nov-2026 20:40:55] WARNING: [pool www] server reached pm.max_children setting (20), consider raising it

풀이 찬 뒤 들어온 요청은 빈 워커를 기다렸습니다. 가장 오래 기다린 404는 58초 만에 끝났고, 크롤러가 60초를 기다리다 끊은 요청은 499로, 백엔드 응답을 받지 못한 요청 하나는 504로 기록되었습니다.

같은 시각에 돌던 캐시 미리 채우기와 예약 작업(Action Scheduler), 1분마다 실행되는 wp-cron은 확인 결과 원인에서 제외했습니다. 미리 채우기는 하루 내내 같은 낮은 속도로 돌고 있었고, 예약 작업은 같은 시간에 미리 채우기 작업만 처리하고 있었으며, wp-cron은 명령줄 PHP로 실행되어 php-fpm 워커를 사용하지 않습니다.

4️⃣ nginx charset utf-8과 없는 정적 파일 바로 404

캐시본을 내보내는 nginx 위치에 [charset utf-8;] 한 줄을 더해 응답 머리에 charset이 붙게 했고, 업로드 폴더의 그림과 정적 파일은 디스크에 없으면 워드프레스로 넘기지 않고 nginx가 바로 404를 반환하게 했습니다.

# http 블록: 404 응답에만 Cache-Control 값을 정함
map $status $up404_cc { 404 'no-store'; default ''; }

# 캐시본 HTML을 바로 내보내는 위치
location / {
    try_files $cache_file @backend;
    charset utf-8;
}

# 업로드 폴더: 없는 그림은 백엔드로 넘기지 않고 바로 404
location ~* ^/wp-content/uploads/.+[.](jpg|jpeg|png)$ {
    try_files $uri =404;
    add_header Cache-Control $up404_cc always;
}

map은 http 블록에 두며, 값이 빈 문자열이면 nginx는 그 머리를 보내지 않으므로 200 응답에는 no-store가 붙지 않습니다. 정규식 위치는 위에서부터 먼저 맞는 것이 적용되므로 기존 이미지 위치보다 앞에 배치했고, css와 js, 글꼴 같은 정적 파일에도 같은 방식의 위치를 하나 더 추가했습니다.

고친 뒤 같은 주소를 다시 요청한 결과, 캐시본 응답 머리에 charset이 붙었습니다.

curl -sI https://example.com/sample-post/ | grep -i '^content-type'
content-type: text/html; charset=utf-8
curl -sI https://example.com/wp-content/uploads/2025/07/no-such-image.png | grep -i -E '^(HTTP|content-type|cache-control)'
# 고치기 전: 워드프레스가 그린 404
HTTP/2 404
content-type: text/html; charset=UTF-8
cache-control: no-cache, must-revalidate, max-age=0, no-store, private
# 고친 뒤: nginx가 바로 반환한 404
HTTP/2 404
content-type: text/html
cache-control: no-store

없는 그림의 404는 319,058바이트를 1.01초에 그리던 워드프레스 화면에서, 555바이트를 0.015초에 반환하는 nginx 기본 화면으로 바뀌었습니다. 404에만 [Cache-Control: no-store]를 붙여 CDN이 없는 그림의 404를 저장하지 않게 했습니다. PHP가 이미 charset을 붙여 보낸 응답에는 nginx가 charset을 다시 붙이지 않아, 응답 머리에 한 번만 나갔습니다.

확인한 요청고치기 전고친 뒤
없는 그림319,058B, 1.01초(워드프레스 404)555B, 0.015초(nginx 404)
깨진 이름의 그림1.05초(워드프레스 404)0.014초(nginx 404)
캐시본 HTML 응답 머리text/htmltext/html; charset=utf-8
PHP가 바로 그린 응답 머리text/html; charset=UTF-8같음(charset 한 번)
있는 그림200200 그대로

요청이 들어온 뒤에 PHP로 썸네일이나 WebP를 생성하는 이미지 플러그인을 사용하는 사이트에는 이 404 처리가 맞지 않습니다. nginx가 먼저 404를 반환하므로 그림이 생성되지 않습니다. WebP를 업로드할 때 파일로 미리 만들어 두는 구성이라면 그대로 적용할 수 있습니다.

5️⃣ meta charset이 뒤로 밀린 까닭: WP Rocket Delay JS

응답 머리에 charset을 붙인 뒤에도 캐시본 HTML의 meta charset은 15,199바이트 자리에 그대로 남아 있습니다. PHP가 바로 그린 페이지에서는 135바이트 자리에 있던 줄입니다.

WP Rocket의 Delay JS를 켜면 캐시 파일을 만들 때 head 바로 뒤에 lazyload 같은 지연 실행용 스크립트를 넣고, 이어서 meta charset을 다시 head 맨 앞으로 옮기는 단계를 거칩니다. 이 옮기는 단계는 문서 전체에 정규식을 거는 방식이라, 약 1.2MB인 큰 글에서는 PHP의 backtrack 한계를 넘어 실패합니다.

정규식이 실패하면 WP Rocket은 스크립트만 들어가고 meta charset은 옮기지 못한 HTML을 그대로 캐시하게 되며, 그 결과 meta charset이 앞 1,024바이트 밖으로 밀렸습니다. 같은 PHP 설정으로 따로 재현한 결과, backtrack 한계가 기본값 1,000,000일 때와 5,000,000일 때는 실패했고 10,000,000부터 옮기기에 성공했습니다.

이번에는 응답 머리에 charset을 붙이는 쪽으로 해결했고, meta charset을 앞으로 옮기는 고침은 하지 않았습니다. backtrack 한계를 올리면 WP Rocket의 원래 동작은 살아나지만, 같은 PHP에서 도는 모든 정규식의 한계가 10배로 늘어나므로 권하지 않습니다.

🔢 FAQ & 추천 콘텐츠

구글 크롤러 문서는 Mediapartners-Google에 건 크롤링 설정이 애드센스와 Ad Manager 같은 광고 제품에 영향을 준다고 밝히고 있습니다. 이 크롤러는 robots.txt의 전체 규칙 [User-agent: *]를 따르지 않으므로, 막으려면 이름을 따로 적어야 합니다. 깨진 주소의 404가 문제라면 없는 파일을 웹 서버가 바로 404로 처리하는 쪽이 광고 크롤링을 건드리지 않습니다.

HTTP 캐시 표준은 응답을 저장할 때 받은 응답 머리를 함께 저장하도록 정하고 있어, 고치기 전에 CDN에 저장된 사본은 charset이 없는 [Content-Type: text/html] 그대로 나갑니다. 저장 기한이 지나 원본 서버에서 새로 받아 오거나 그 글 주소만 CDN 캐시에서 비우면 charset이 붙은 새 머리로 바뀝니다.

URL 표준은 주소 경로에 있는 ASCII 밖 글자를 UTF-8 퍼센트 인코딩으로 바꿔 요청하도록 정하고 있어, 문서를 UTF-8로 바르게 읽은 쪽은 원래 파일 이름으로 요청합니다. 영문과 숫자만 쓴 파일 이름은 문서를 windows-1252로 잘못 읽어도 같은 글자로 남으므로, 응답 머리와 meta charset이 모두 빠진 페이지에서도 주소가 깨지지 않습니다.

ℹ️ 제휴 안내
본 사이트의 콘텐츠에는 제휴 링크가 포함되어 있습니다. 방문자가 이 링크를 통해 상품 또는 서비스를 구매하면 본 사이트는 판매처로부터 수수료를 지급받습니다. 이 과정에서 구매자가 지불하는 금액(이벤트 할인 시 금액이 내려갑니다↓)은 오르지 않습니다. 게시된 가격·할인·재고 정보는 작성 시점 기준이며 실제와 다를 수 있으므로, 구매 전 판매처에서 최종 확인하시기 바랍니다. 상품 선정과 평가는 자체 기준에 따라 작성되며, 수수료 지급 여부가 소개 순서나 평가 내용에 영향을 주지 않습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

인간임을 증명하세요: 5   +   3   =