page-archive는 웹페이지를 통째로 떠서 보관하는 서비스예요. 안에서는 puppeteer가 크롬을 띄워 페이지를 열고, 이미지도 CSS도 전부 인라인으로 밀어넣어서 한 장짜리 HTML로 만들어요. 원본이 사라져도 그 파일만 있으면 그때 모습이 그대로 남게요.
이번에 puppeteer를 24에서 25로 올렸어요. 별생각 없이 올린 정기 업그레이드였는데, 올리고 나서 문득 궁금해지더라고요.
그래서 이 브라우저는 지금 사이트들한테 자기가 누구라고 말하고 있지?
브라우저가 자기를 소개하는 문자열을 UA(User-Agent)라고 해요. 요청마다 헤더에 실려 나가고, 받는 쪽은 그걸 보고 상대가 크롬인지 사파리인지 봇인지를 1차로 판단해요.
재봤더니, 올라간 게 아무것도 없었어요.
재는 방법부터
봇 탐지 얘기는 추측이 끼어들기 쉬워서, 남의 사이트로 테스트하고 싶지 않았어요. 그쪽이 어떻게 판정하는지는 볼 수가 없으니까요.
그래서 로컬에 HTTP 서버를 하나 띄우고, 아카이버한테 그 주소를 아카이빙하라고 시켰어요. 서버는 도착한 요청 헤더를 그대로 찍기만 해요. 외부 의존이 없으니 몇 번을 돌려도 같은 결과가 나오고, 브라우저가 실제로 무엇을 보냈는지가 추측 없이 그대로 보여요.
수정 전 — 보낸 것과 안 보낸 것
page.setUserAgent()에 문자열을 넘겨주던 시절의 결과예요.
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 PageArchive/1.0
sec-ch-ua (없음)
sec-ch-ua-mobile (없음)
sec-ch-ua-platform (없음)
navigator.userAgent → 위와 같음 (Chrome/120)
navigator.platform → "MacIntel" ← UA는 Windows라고 주장
navigator.webdriver → true
userAgentData.brands → [] ← 빈 배열
브라우저 네이티브 UA → ...HeadlessChrome/151.0.0.0...
한 줄씩 보고 있으니 좀 아찔했어요.
없는 게 신호였어요
제일 놀란 건 sec-ch-ua 세 줄이 통째로 비어 있는 거였어요.
sec-ch-ua로 시작하는 이 헤더들을 User-Agent Client Hints라고 불러요. 한 줄짜리 UA에 모든 정보를 욱여넣는 대신, 브라우저 종류·버전·플랫폼을 헤더 여러 개로 쪼개서 보내는 방식이에요.
이게 왜 문제냐면, Chrome 89부터는 이 헤더들을 항상 보내요. 서버가 달라고 하든 말든, 저엔트로피 힌트라 불리는 이 세 개(sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform)는 기본으로 붙어서 나가요. 즉 진짜 크롬이라면 이 자리가 비어 있을 수가 없어요.
그런데 우리 요청은 “저는 Chrome 120이에요”라고 UA로 주장하면서, 크롬이 반드시 보내는 헤더를 하나도 안 보내고 있었어요. 뭔가를 잘못 보낸 게 아니라 아무것도 안 보낸 건데, 이 경우엔 없는 것 자체가 신호예요. 헤더 하나만 봐도 “UA를 손댄 자동화 브라우저”라고 읽히는 상태였어요.
원인은 page.setUserAgent()였어요. 여기에 문자열만 넘기면 puppeteer가 UA를 덮으면서 Client Hints를 함께 지워버려요. UA를 바꿨으니 예전 UA에 딸린 힌트도 같이 지우는 게 맞는데, 새 힌트를 안 주면 그냥 빈 채로 나가요. (두 번째 인자로 userAgentMetadata를 같이 주면 채울 수 있긴 한데, 그럼 브랜드·플랫폼·버전을 전부 손으로 관리해야 해요.)
나머지도 줄줄이 모순이었어요. navigator.platform은 UA가 주장하는 Windows와 안 맞았고(제 맥에서 쟀으니 MacIntel이 나왔고, 서버 컨테이너에서 재면 Linux가 나와요 — 어느 쪽이든 Windows는 아니에요), userAgentData.brands는 빈 배열이었고, navigator.webdriver는 true였어요. 봇 탐지가 가장 먼저 보는 값이 그대로 켜져 있었던 거예요.
그리고 버전업이 무의미했어요
두 번째 문제가 더 컸어요.
UA에 박힌 Chrome/120.0.0.0은 하드코딩된 문자열이었어요. 언젠가 누군가(저예요) 그때 그럴듯한 UA를 복사해서 넣어둔 거고, 그 뒤로 아무도 안 건드린 거죠.
그동안 실제 브라우저는 계속 올라갔어요. 네이티브 UA를 찍어보니 151이더라고요. 그러니까 브라우저를 부지런히 올려도 사이트에는 계속 120이라고 말하고 있었던 거예요. 업그레이드가 바깥에서는 아무 효과가 없었어요. 오히려 시간이 갈수록 “실제 크롬은 안 쓰는 옛날 버전을 주장하는 클라이언트”가 되니까, 놔둘수록 나빠지는 종류의 값이었고요.
버전을 정확히 대조해봤어요.
| 어디 | 버전 |
|---|---|
| puppeteer 25.5.0이 고정한 Chrome | 151.0.7922.71 |
| 컨테이너의 Debian chromium | 151.0.7922.108 |
| UA가 주장하던 값 | 120.0.0.0 |
puppeteer가 어떤 크롬을 기준으로 만들어졌는지는 node_modules/puppeteer-core/lib/puppeteer/revisions.js에 그대로 적혀 있어요.
export const PUPPETEER_REVISIONS = Object.freeze({
chrome: '151.0.7922.71',
'chrome-headless-shell': '151.0.7922.71',
firefox: 'stable_153.0.1',
});
빌드 번호까지 같고 패치만 Debian이 조금 앞서 있었어요. 궁합은 멀쩡했던 거예요. 말하는 입만 서른 버전쯤 뒤처져 있었어요.
참고로 네이티브 UA도 끝이 Chrome/151.0.0.0이에요. 마이너 자리가 전부 0인 게 이상해 보이지만 정상이에요. 크롬이 몇 해 전부터 UA에서 상세 버전을 지우고 있거든요(UA Reduction). 정확한 빌드 번호를 알려면 서버가 Accept-CH로 따로 요청해서 Client Hints로 받아야 해요. UA를 지우고 Client Hints도 지우면, 버전을 알 방법이 아예 사라지는 셈이에요.
고친 방향 — 하드코딩을 없애기
고칠 방법이 두 가지였어요. 새 UA 문자열을 넣고 userAgentMetadata까지 손으로 채우거나, UA를 아예 안 짓거나.
후자로 갔어요. 이유는 단순해요. 손으로 채운 값은 오늘은 맞아도 다음 업그레이드에 또 늙어요. 방금 그 일을 겪었잖아요.
그래서 브라우저를 한 번 띄워서 자기 UA를 물어보고, 거기서 HeadlessChrome만 Chrome으로 바꾼 값을 쓰기로 했어요. 그리고 그걸 page.setUserAgent()가 아니라 브라우저 레벨 --user-agent 인자로 넘겨요. 브라우저 기동 시점에 UA를 주면 Client Hints가 그 UA에 맞게 정상적으로 만들어져요. 지워지는 게 아니라요.
const LAUNCH_ARGS = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-crash-reporter',
'--disable-gpu',
// navigator.webdriver를 false로. 켜져 있으면 봇 탐지가 가장 먼저 보는 신호다.
'--disable-blink-features=AutomationControlled'
];
/**
* 실제 브라우저의 UA에서 Headless 흔적만 지운 값을 얻는다.
* 하드코딩이 없으므로 puppeteer·chromium을 올리면 UA도 자동으로 따라 올라간다.
*/
async function resolveUserAgent(options) {
let probe = null;
try {
probe = await puppeteer.launch(options);
return (await probe.userAgent()).replace('HeadlessChrome', 'Chrome');
} catch {
return null; // 실패해도 네이티브 UA로 계속 동작한다
} finally {
await probe?.close();
}
}
async function getBrowser() {
if (!browser) {
const base = {
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: LAUNCH_ARGS
};
const userAgent = await resolveUserAgent(base);
browser = await puppeteer.launch({
...base,
args: userAgent ? [...LAUNCH_ARGS, `--user-agent=${userAgent}`] : LAUNCH_ARGS
});
}
return browser;
}
브라우저를 두 번 띄우는 게 낭비 같지만, UA를 물어보려면 한 번은 띄워야 하고 이건 프로세스당 딱 한 번이라 그냥 두기로 했어요. 실패하면 null을 반환해서 네이티브 UA로 계속 도는 것도 의도예요. 여기서 죽으면 아카이빙 자체가 안 되니까요.
그리고 archivePage() 안의 page.setUserAgent() 호출은 지웠어요. 이걸 남겨두면 브라우저 레벨에서 잘 만들어둔 Client Hints를 페이지 단위에서 다시 지워버려요. 지운 자리에는 주석을 남겨뒀어요. 안 그러면 언젠가 제가 다시 “어 UA 설정이 없네?” 하면서 되살릴 게 뻔해서요.
수정 후
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36
sec-ch-ua "Chromium";v="151", "Not=A?Brand";v="99"
sec-ch-ua-mobile ?0
sec-ch-ua-platform "macOS"
navigator.webdriver → false
userAgentData.brands → [{Chromium,151},{Not=A?Brand,99}]
navigator.platform → UA와 일치
판정은 6항목으로 고정해뒀어요. sec-ch-ua 존재 / sec-ch-ua-platform 존재 / UA에 Headless 없음 / UA에 PageArchive 없음 / webdriver=false / brands가 빈 배열이 아님. 전부 통과했고, 실제 사이트를 아카이빙하는 통합테스트 2개(네이버 메인·example.com)도 지나갔어요.
이 글을 쓰면서 위 두 블록을 처음부터 다시 재봤어요. 로컬 서버를 새로 띄우고 수정 전 코드와 수정 후 코드로 각각 한 번씩요. 두 블록 다 한 글자도 안 틀리고 같은 값이 나왔어요. 외부 사이트에 의존하지 않는 측정이라 이게 가능하고, 그래서 이 방식을 권해요.
PageArchive/1.0을 뗀 건 좀 고민했어요
예전 UA 끝에는 PageArchive/1.0이 붙어 있었어요. 자기가 누군지 밝히는 토큰이에요. 이번에 그걸 뗐어요.
솔직히 말하면 이게 이 작업에서 유일하게 마음이 편치 않은 부분이에요. 서버 입장에서 “이건 아카이빙 도구다”라고 알아볼 수 있는 유일한 표식을 없앤 거니까요. 뗀 이유는 실용적이에요. 그 토큰이 차단 사유가 되고, 차단되면 사용자가 자기가 보던 페이지를 저장하지 못하니까요.
다만 이게 “일반 브라우저인 척한다”는 쪽으로 한 칸 간 건 맞아요. 그래서 여기 적어둬요.
대신 반대쪽을 조였어요. robots.txt에 Disallow: /archive/를 넣었어요. 저장된 남의 콘텐츠가 이 도메인 아래 검색 결과로는 안 뜨게요.
User-agent: *
Disallow: /archive/
원래 값은 Disallow:(빈 값), 그러니까 전체 허용이었어요. 링크가 nanoid 8자라 추측으로는 못 찾지만, 한 번이라도 밖으로 새면(공개 게시·referrer·브라우저 동기화) 색인되니까요. 가져오는 쪽에서 덜 밝히기로 했으면, 내보내는 쪽에서는 더 조심하는 게 맞다고 생각했어요.
곁다리 — puppeteer 25로 올릴 때 npm ci가 깨져요
이건 UA와 무관한데, 세트로 걸린 문제라 같이 적어둬요.
puppeteer 25의 @puppeteer/browsers 3은 받아온 크롬 zip을 풀 때 시스템의 unzip 바이너리를 실행해요. 라이브러리로 푸는 게 아니라 이렇게요.
// node_modules/@puppeteer/browsers/lib/fileUtil.js
await execFileAsync('unzip', ['-o', archivePath, '-d', folderPath]);
node:24-slim 이미지에는 unzip이 없어서 여기서 ENOENT가 나요. 폴백이 하나 더 있긴 한데(yauzl) 그건 선택 의존성이라 기본 설치엔 없고요. 둘 다 없으면 이 에러가 나요.
Extraction failed: no zip archiver is available. Install `unzip`
(or `tar.exe`/Powershell on Windows), or add the optional `yauzl` dependency.
빌드가 깨지니까 눈에 확 띄긴 하는데, 원인이 puppeteer 버전이라는 걸 알아채는 데 좀 걸렸어요.
해결은 한 줄이었어요.
ENV PUPPETEER_SKIP_DOWNLOAD=true
어차피 런타임에는 PUPPETEER_EXECUTABLE_PATH로 시스템 chromium을 쓰니까, 번들 크롬을 받을 이유가 없어요. 안 받으면 풀 일도 없고요. 에러 메시지가 시키는 대로 unzip을 깔거나 yauzl을 추가하는 길도 있지만, 그건 안 쓸 걸 받아서 안 쓸 도구로 푸는 것이라 반대 방향이에요.
남은 생각
이 서비스는 예전에도 puppeteer 때문에 한 번 손을 봤어요. 그때는 좀비 프로세스였고, 답이 “매번 새로 짓기”였어요. 이번 것도 결이 비슷하더라고요.
하드코딩된 UA는 조용히 늙는 값이었어요. 틀렸다고 알려주는 사람이 아무도 없어요. 서버는 그냥 차단하거나 다른 페이지를 주고 끝이고, 우리 쪽 로그에는 200이 찍혀요. 넣을 때는 맞는 값이었고, 그래서 더 안 보였고요.
그래서 이번엔 값을 안 적기로 했어요. 읽어오는 코드를 대신 적었어요. 이러면 다음 업그레이드부터는 UA가 알아서 따라 올라가요. 제가 기억하지 않아도요.
기록해둘 만한 교훈이 하나 더 있어요. “안 보낸 헤더”도 정보예요. 요청을 볼 때 보통은 있는 값들을 훑는데, 이번 건은 빈칸 세 개가 전부였어요. 있어야 할 게 없는 상태를 알아보려면 결국 한 번은 실제로 받아봐야 하고요. 로컬 HTTP 서버 하나 띄우는 데 몇 분 안 걸려요.