GPS 영토 걷기 앱에 공유 기능을 붙이고 있었습니다. 사용자가 걸어서 딴 영역을 짧은 세로 영상으로 만들어 내보내는 기능입니다. 영상 배경에는 그 사람이 실제로 걸은 동네 지도가 한 장 깔려야 합니다.
지도 이미지는 Apple 의 Maps Web Snapshots 로 받기로 했습니다. URL 하나에 좌표와 크기를 넣으면 PNG 를 돌려주는 API 입니다. 서버에서 한 번 받아 캔버스에 깔면 끝입니다.
그리고 저에게는 이미 토큰 발급 경로가 있었습니다. 같은 프로젝트가 웹에서 MapKit JS 지도를 띄우고 있었으니까요. /api/mapkit-token 이 몇 달째 잘 돌고 있었습니다.
그래서 그 토큰을 그대로 Authorization: Bearer 에 실어 보냈습니다.
401제가 의심한 것들
여기서 무엇을 의심하시겠습니까?
저는 전부 토큰 쪽 을 봤습니다. 만료됐나. 유효기간이 너무 짧나. origin 제한이 걸렸나. 이 키에 스냅샷 권한이 따로 필요한가. 발급 파라미터를 바꿔가며 다시 만들고, 다시 보내고, 다시 401 을 받았습니다.
가설이 네 개였지만 전제는 하나였습니다 — "신분증이 잘못됐다". 401 이 그렇게 읽히니까요. 401 은 "당신이 누군지 모르겠다" 는 뜻이고, 그러면 자연스럽게 신분증을 고치게 됩니다.
같은 키로 만든 토큰으로 웹 지도는 그 순간에도 정상이었습니다. 그 사실이 힌트였는데, 저는 그걸 "키는 멀쩡하니 발급 옵션이 문제겠지" 로 읽었습니다. 실제로는 문이 두 개 라는 뜻이었습니다.
실체
Maps Web Snapshots 는 Bearer 토큰을 아예 받지 않습니다. 인증 체계가 다릅니다.
- 쿼리 문자열에
teamId와keyId를 넣습니다. "<경로>?<쿼리>"문자열 전체를 그.p8개인키로 ES256 서명합니다.- 서명을 base64url 로 인코딩해
&signature=로 URL 끝에 붙입니다.
헤더는 없습니다. 요청 URL 자체가 신분증입니다.
export function signedSnapshotUrl(params, { teamId, keyId, privateKeyPem }) {
const q = new URLSearchParams(params);
q.set("teamId", teamId);
q.set("keyId", keyId);
// 서명은 Apple 이 받게 될 요청 라인을 그대로 덮습니다.
// 쿼리 문자열은 한 번만 직렬화하고 그걸 그대로 재사용해야 합니다.
const pathAndQuery = `${SNAPSHOT_PATH}?${q.toString()}`;
const signer = crypto.createSign("SHA256");
signer.update(pathAndQuery);
signer.end();
const signature = base64url(signer.sign(crypto.createPrivateKey(privateKeyPem)));
return `${SNAPSHOT_HOST}${pathAndQuery}&signature=${signature}`;
}200 image/png. 같은 키, 같은 팀, 같은 회사입니다. 다른 문이었을 뿐입니다.
여기서 한 번 더 헤맬 뻔한 것
ES256 서명에는 인코딩이 두 가지 있습니다. DER 과 IEEE P1363 입니다. JWT 표준은 P1363 을 강제하는데, Node 의 createSign(...).sign(key) 기본 출력은 DER 입니다. 그래서 JWT 를 직접 만들어 본 사람은 보통 여기서 변환 코드를 한 번 씁니다.
그래서 둘 다 만들어 던져봤습니다. 둘 다 200 이었습니다.
스냅샷 API 는 두 인코딩을 모두 받습니다. 즉 Node 기본값 그대로 쓰면 되고, 변환 코드는 애초에 필요가 없었습니다. 예상되는 함정을 막는 코드를 먼저 쓰는 대신 일단 한 번 던져본 것 이 20줄을 아꼈습니다. 방어 코드도 재고입니다 — 안 쓰이는 재고는 나중에 읽는 사람을 헷갈리게 합니다.
그림을 받은 다음에 알게 된 두 가지
지도 타입 기본값이 영상과 안 어울립니다. t=standard 는 채도 높은 청록으로 나옵니다. 어두운 배경 위에 오버레이로 얹으면 그 부분만 튀어서 스티커처럼 보입니다. t=mutedStandard 로 바꾸니 배경으로 물러났습니다.
좌하단에 Apple Maps 표기가 이미지에 구워져 나옵니다. 지우거나 덮을 수 없고, 덮으면 안 됩니다. 그래서 영상 레이아웃을 짤 때 그 모서리는 처음부터 비워둬야 합니다. 자막이나 로고를 거기 놓고 나중에 발견하면 레이아웃을 다시 짜야 합니다.
자가진단 3가지
- "이미 그 회사 토큰이 있으니 될 것"이라고 가정한 API 가 있습니까? 같은 벤더의 API 두 개가 서로 다른 인증 체계를 쓰는 건 흔합니다. 문서의 인증 섹션만 30초 읽으면 갈립니다.
- 401 을 받았을 때, 자격증명 말고 다른 걸 의심해본 적이 있습니까? 401 은 "신분증이 틀렸다" 뿐 아니라 "신분증을 그런 식으로 내는 게 아니다" 일 수 있습니다.
- 예상되는 함정을 막는 코드를 쓰기 전에 한 번 실행해봤습니까? 저는 필요 없는 인코딩 변환기를 쓸 뻔했습니다. 문서에서 읽은 제약이 이 엔드포인트에도 적용되는지는 던져봐야 압니다.
솔직한 부분
이건 제 실수였고, 코드 문제가 아니라 읽기 순서 문제였습니다.
저는 문서보다 제 코드베이스를 먼저 봤습니다. /api/mapkit-token 이 있는 걸 보고 "인증은 이미 풀린 문제" 라고 판단했습니다. 그 판단이 그 다음 네 번의 시도 방향을 전부 정했습니다. 문서의 인증 섹션을 먼저 읽었으면 첫 시도에 됐을 일입니다.
같은 성질의 실패를 최근에 또 겪었습니다 — 테스트 200개가 통과했고, 버튼은 아무것도 하지 않았습니다. 그때도 "이 부분은 이미 검증됐다" 는 믿음이 검증 안 된 구간을 가렸습니다. 콘솔은 조용했고, 화면은 비어 있었습니다 도 결국 같은 이야기입니다: 증거가 제가 안 보던 곳에 있었습니다.
지금 당신 프로젝트에 "그건 이미 돌아가니까" 로 건너뛰고 있는 구간이 있습니까? 그게 정말 같은 문입니까?