서버가 서울에 있는 줄 알았습니다. 응답 헤더를 보니 함수는 워싱턴에 있었습니다. 한 줄 요약 응답 헤더 x-vercel-id: icn1::iad1::... 가 보여 준 길은 이랬습니다 — 손님(한국) → 엣지(서울) → 함수(미국 동부) → DB(서울) → 함수(미국) → 손님. 매 요청이 태평양을 두 번 건넌습니다. 함수 리전을 서울( icn1 )로 옮기고, 엣지 캐시를 통째로 막던 force-dynamic 을 걷어 내 전 페이지를 프리렌더 로 바꿨습니다. 이후 캐시 적중 응답은 0.16~0.20초입니다(단, 전 값은 재지 않았습니다). 배포 설정에는 하나를 더 넣었습니다. 개인정보처리방침에 적은 보유 기간 1년이 지난 문의를 매일 지우는 Cron 입니다. 설정이 빠지면 아무것도 지우지 않고 실패를 알리게 만들었습니다. 헤더가 그대로 보여 준 것 Vercel은 응답에 x-vercel-id 헤더를 붙입니다. 요청이 어느 지역을 거쳤는지가 적혀 있습니다. x-vercel-id: icn1::iad1::... 앞의 icn1 은 요청이 들어온 엣지(서울), 뒤의 iad1 은 함수가 실행된 곳(미국 워싱턴) 입니다. Vercel 함수의 기본 실행 지역이 미국 동부였는데, 그걸 바꾸지 않고 있었습니다. 그런데 DB(Supabase)는 서울에 있습니다. 그래서 요청 하나가 이렇게 움직였습니다. 손님(KR) → 엣지(KR) → 함수(US) → DB(KR) → 함수(US) → 손님(KR) DB를 한 번 읽을 때마다 태평양을 두 번 건넍니다. 고친 것 셋 커밋 하나( c9a0c8a )로 세 가지를 같이 고쳤습니다. 1 함수를 서울에서 실행합니다. { "regions": ["icn1"] } 2 콘텐츠 페이지의 force-dynamic 을 revalidate = 60 으로 바꿨습니다. 이게 리전보다 더 컸습니다. force-dynamic 은 응답에 cache-control: no-store 를 붙여 엣지 캐시를 통째로 막습니다. 방문할 때마다 함수를 새로 돌리고 DB를 다시 읽고 있었습니다. 회사 소개 성격의 사이트라 1분 늦게 반영되는 건 문제가 안 됩니다. 관리자가 글을 고치면 최대 1분 뒤에 반영됩니다. 이 1분과 매 요청 태평양 왕복을 바꾼 겁니다. 3 상세 페이지를 빌드 때 미리 만듭니다. A/S 사례와 공지 상세에 generateStaticParams 를 넣었습니다. 사례 29건은 검색으로 들어오는 첫 페이지가 되는 경우가 많아서, 첫 방문이 빨라야 했습니다. 결과적으로 빌드 결과표에서 전 페이지가 프리렌더(●/○)로 바뀌었고, 매번 실행되는 함수(ƒ)는 API 두 개만 남았습니다. 얼마나 빨라졌나 — 정직하게 전 값은 재지 않았습니다. 헤더로 원인을 확인하고 바로 고쳤기 때문입니다. 그래서 "몇 배 빨라졌다"는 말은 할 수 없습니다. 말할 수 있는 건 이후에 잰 값입니다. 2주 뒤 성능 작업을 하면서 네트워크부터 쟀을 때(이 시리즈의 번들 글), 캐시는 x-vercel-cache: HIT 이었고 TTFB는 0.16~0.20초 였습니다. 그때 네트워크를 후보에서 바로 지울 수 있었던 건 이 작업 덕분입니다. 개인정보를 지우는 일도 배포의 일부다 문의 폼으로 받은 개인정보는 방침에 보유 기간 1년 이라고 적어 두었습니다. 적어 두기만 하고 아무도 안 지우면, 방침이 거짓말이 됩니다. 그래서 지우는 일을 사람 손이 아니라 배포 설정에 넣었습니다. { "crons": [{ "path": "/api/retention", "schedule": "0 18 * * *" }] } UTC 18시, 한국 시각으로 새벽 3시에 매일 돕니다. 보유 기간은 코드에 RETENTION_DAYS = 365 로 한 곳에 둡니다. 이 기능에서 신경 쓴 건 실수로 지우는 사고 였습니다. 결정 이유 서버 상태 확인용 /api/ping 에 얹지 않고 주소를 따로 뒀다 /api/ping 은 인증 없는 공개 주소다. 지우는 일과 살아 있는지 보는 일을 한 주소에 두지 않는다 CRON_SECRET 이 맞아야만 실행한다 남의 개인정보를 실제로 지우는 길이라 아무나 부르면 안 된다 CRON_SECRET 이 설정 안 됐으면 아무것도 안 지우고 503 "설정 안 했는데 조용히 지워졌다"가 제일 나쁜 사고다 지운 건수와 기준 날짜를 로그로 남긴다 파기했다는 사실을 나중에 증명할 수 있어야 한다 if (!secret) { return NextResponse.json( { ok: false, error: "not-configured", hint: "CRON_SECRET 미설정 — 아무것도 지우지 않았습니다" }, { status: 503 }, ); } // ... console.log(`[retention] ${r.deleted}건 파기 (기준 ${r.cutoff}, 보유 ${RETENTION_DAYS}일)`); 지우는 것만큼 되살리는 것도 출시 전 점검을 하다가 반대쪽 구멍도 찾았습니다. 무료 플랜에는 시점 복구(PITR)가 없어서 , 관리자 화면에서 실수로 지우면 되돌릴 방법이 아무것도 없었습니다. 그래서 DB를 통째로 내려받는 스크립트를 만들었습니다(커밋 41cdcf8 ). npm run backup → backups/2026-10-06T07-19-11.json (products 17 · cases 33 · notices 5 · inquiries 18) 여기서도 개인정보가 걸렸습니다. 받은 파일에는 문의의 이름·연락처·이메일이 들어 있습니다. backups/ 를 .gitignore 에 넣어 저장소에 안 올라가게 했고, 방침의 보유 기간 1년은 백업 파일에도 그대로 적용된다 고 적어 두었습니다. DB에서는 지웠는데 백업에 남아 있으면 지운 게 아닙니다. 마무리 — 무엇을 배웠나 첫째, "서버가 어디서 도는지"는 헤더로 확인합니다. 설정 화면이 아니라 실제 응답의 x-vercel-id 가 사실을 말해 줬습니다. 둘째, 리전보다 캐시가 먼저입니다. 함수를 서울로 옮겨도 매 요청 함수를 돌리면 빠르지 않습니다. force-dynamic 하나가 엣지 캐시를 통째로 막고 있었습니다. 셋째, 방침에 적은 약속은 코드로 지킵니다. 보유 기간은 적는 순간이 아니라 지우는 Cron이 돌 때 사실이 됩니다. 넷째, 위험한 작업은 설정이 빠졌을 때 멈추게 만듭니다. 실패를 숨기는 것보다, 아무것도 안 하고 503을 내는 쪽이 안전합니다. 이 글에서 남는 것 x-vercel-id: icn1::iad1 은 "엣지는 서울, 함수는 미국"이라는 뜻이다. DB가 서울이면 함수도 regions: ["icn1"] . force-dynamic 은 no-store 를 붙여 엣지 캐시를 막는다. 1분 지연이 괜찮은 콘텐츠는 revalidate 로 바꾼다. 검색 유입 상세 페이지는 generateStaticParams 로 미리 만든다. 전 값을 못 재으면 "몇 배 빨라졌다"고 쓰지 않는다. 개인정보 파기는 Cron으로 자동화하고, 비밀값이 없으면 아무것도 안 지우고 실패를 알린다. 지운 건수는 로그로 남긴다. 백업 파일에도 보유 기간이 적용된다.
보안 헤더는 처음부터 다 붙어 있었습니다. 문제는 그중 한 줄의 별표 하나였습니다. 한 줄 요약 이 사이트는 예전 사이트가 뚫린 적이 있어서 처음부터 CSP를 걸었습니다. 다만 script-src 의 'unsafe-inline' 은 남겼습니다 — 막으려면 엣지 캐시를 포기해야 했기 때문입니다. 그래서 실질적인 방어선은 "스크립트가 주입돼도 데이터가 밖으로 못 나가게" 하는 connect-src 였습니다. 그런데 거기에 https://*.supabase.co 가 적혀 있었습니다. 이 별표는 남의 Supabase 프로젝트까지 전부 허용 합니다. 빌드할 때 환경변수에서 우리 프로젝트 주소를 뽑아 하나로 좁혔고 , 같이 점검한 네 가지는 고칠 것이 없었습니다. 왜 처음부터 CSP였나 유진콤프레샤의 예전 사이트는 그누보드(PHP)였고, 뚫린 적이 있습니다. 그래서 새 사이트는 처음부터 공격면을 좁혔습니다. 지금 구조에는 PHP도, 서버에 올라가는 파일도, 직접 조립하는 SQL도 없습니다. 그 위에 브라우저가 강제하는 방어선을 한 겹 더 얹은 것이 보안 헤더입니다. Content-Security-Policy · X-Frame-Options · X-Content-Type-Options Referrer-Policy · Permissions-Policy 'unsafe-inline' 을 남긴 이유 — 트레이드오프 CSP의 정석은 인라인 스크립트를 막는 것입니다. 그런데 Next.js는 정적 페이지에 인라인 부트스트랩 스크립트를 넣습니다. 이걸 허용하면서 막으려면 요청마다 새 nonce 를 발급해야 하고, 그러려면 모든 페이지를 요청마다 새로 렌더 해야 합니다. 그러면 앞서 잡아 둔 엣지 캐시가 통째로 사라집니다. 설정 파일 주석에는 그 대가를 응답 0.08초 → 0.3초 이상 으로 적어 두었습니다. 회사 소개 사이트에서 매 요청을 서버가 다시 그리는 건 손님에게 드는 비용이 너무 컸습니다. 그래서 'unsafe-inline' 을 남기는 대신, 방어선을 다른 곳에 세웠습니다. 그래서 진짜 방어선은 "나가는 길" 스크립트 주입을 CSP로 완전히 막을 수 없다면, 주입된 스크립트가 할 수 있는 일 을 줄여야 합니다. 주입된 스크립트가 노리는 건 대개 데이터를 밖으로 보내는 것입니다. 그 출구가 connect-src (fetch·XHR가 갈 수 있는 곳)와 form-action (폼이 제출될 수 있는 곳)입니다. script-src 'self' 'unsafe-inline' ... (외부 도메인 스크립트 로드는 막힘) connect-src 'self' <허용한 곳만> ← 데이터가 나갈 수 있는 곳 form-action 'self' 외부 도메인 스크립트는 못 불러오고, 데이터가 나갈 수 있는 곳은 허용 목록뿐입니다. 설령 스크립트가 주입돼도 밖으로 빼내기가 어렵게 만드는 것이 이 구성의 핵심입니다. 별표 하나가 연 길 9월 11일 보안 점검에서 connect-src 와 img-src 를 다시 읽었습니다. connect-src 'self' https://*.supabase.co ... 이 와일드카드는 우리 Supabase 프로젝트만이 아니라 남의 Supabase 프로젝트까지 전부 허용합니다. 그리고 이 사이트의 관리자 로그인 토큰은 브라우저 저장소에 있습니다(Supabase 기본 동작). 그러면 이런 시나리오가 성립합니다. 어떤 경로로든 스크립트가 주입된다 ( 'unsafe-inline' 이 남아 있으니 CSP가 막지 못한다). 스크립트가 브라우저 저장소에서 관리자 토큰을 읽는다. 공격자가 만든 자기 Supabase 프로젝트 주소 로 그 토큰을 보낸다. 그 주소는 *.supabase.co 에 맞으니 CSP가 통과시킨다. CSP로 막으려던 바로 그 길이 열려 있던 셈입니다. 방어선을 "나가는 길"에 세워 놓고, 그 길에 별표를 적어 둔 겁니다. 고친 방법 — 빌드할 때 출처를 하나로 빌드할 때 NEXT_PUBLIC_SUPABASE_URL 에서 출처를 뽑아 CSP에 그대로 박습니다(커밋 420c509 ). 이제 connect-src 와 img-src 에는 우리 프로젝트 주소 하나만 들어갑니다. 환경변수가 없을 때(로컬에서 .env 없이 켤 때)는 예전 와일드카드로 되돌립니다. 화면이 통째로 깨지는 것보다는 낫고, 배포 환경에는 항상 값이 있기 때문 입니다. 같이 확인한 것 — 고칠 것 없음 와일드카드 하나를 찾은 김에, 같은 종류의 구멍이 있을 만한 자리를 다 확인했습니다. 네 가지 모두 고칠 것이 없었습니다. 없었다는 것도 확인해야 아는 일이라 같이 적어 둡니다. 점검 결과 dangerouslySetInnerHTML 3곳 전부 검색엔진용 JSON-LD이고, < > & 를 이스케이프한다. 관리자가 DB에 무엇을 적어도 </script> 로 빠져나갈 수 없다 DB 행 단위 보안(RLS) 실측 — 문의가 17건 있는데 공개 키(anon)로 조회하면 0건. 고객 개인정보는 막혀 있다 서버 전용 비밀키 SUPABASE_SERVICE_ROLE_KEY 는 NEXT_PUBLIC_ 없이 서버 전용이고, 한 파일에서만 쓴다 민감한 API 개인정보 파기( /api/retention )는 Cron 비밀값을, 번역( /api/translate )은 관리자 토큰을 검증한다 RLS는 설정 화면을 보는 것으로 끝내지 않고, 실제로 공개 키로 조회해서 0건이 나오는 것까지 봤습니다. 같은 원칙을 다음에도 같은 날 오류 트래킹(Sentry)을 붙일 때, connect-src 에 Sentry 주소를 넣어야 했습니다. 이때도 *.ingest.sentry.io 와일드카드를 쓰지 않고 DSN에서 뽑은 호스트 하나만 넣었습니다. 설정 파일에 이 이유를 주석으로 남겨, 다음에 출처를 추가하는 사람이 같은 실수를 하지 않게 했습니다. 마무리 — 무엇을 배웠나 첫째, 막지 못하는 것이 있으면 방어선을 옮깁니다. 'unsafe-inline' 을 남긴 대신, 데이터가 나가는 길을 좁히는 것이 실질적인 방어였습니다. 둘째, 방어선의 가치는 가장 넓은 구멍이 정합니다. connect-src 를 아무리 촘촘히 적어도, 별표 하나가 있으면 그 줄 전체가 무력해집니다. 셋째, "없다"도 확인해서 적습니다. RLS가 막고 있다는 건 설정이 아니라 실제 조회 결과 0건으로 확인했습니다. 이 글에서 남는 것 Next.js 정적 페이지에서 nonce를 쓰면 엣지 캐시를 잃는다. 'unsafe-inline' 을 남긴다면 방어선은 connect-src · form-action 으로 옮긴다. *.supabase.co 같은 서비스 와일드카드는 남의 프로젝트까지 허용한다. 토큰을 공격자 프로젝트로 보낼 수 있다. 출처는 빌드 때 환경변수에서 뽑아 하나로 박는다. 로컬 폴백은 두되 배포에는 항상 값이 있게 한다. 와일드카드를 하나 찾으면 같은 종류(JSON-LD, RLS, 서버 키, API 인증)를 같이 점검한다. 새 출처를 추가할 때도 같은 원칙 — 호스트 하나만.
오늘 한 것 최종 프로젝트(6) 오늘 배운 점 & 느낀 점 오늘 배운 점은 Unity는 enum을 숫자로 저장하고 [SerializeField] 값은 씬에 저장된 값이 코드 기본값보다 우선이라, 새 아이템(인버터)은 enum 맨 끝에 추가하고 itemPool을 static으로 바꿔야 실제로 등장한다는 것을 배웠다. 오늘 생겼던 작은 문제는 OBJ 모델이 o 태그로만 나뉘어 있어서 Unity에서 부품이 default 하나로 합쳐졌고, 부품을 분리해도 피벗이 전부 모델 원점이라 레버를 바로 돌릴 수 없었다. 내일은 원작 모드에 있던 새로운 아이템인 아드레날린이나, 상한 약 등등을 만들어 봐야겠다. 내일 할 일 최종 프로젝트 복습
0.5px 차이는 아무도 못 봅니다. 그런데 기준은 흐려집니다. 한 줄 요약 사이트 전체를 디자인 감사하는데 "무엇과 비교할지"가 없었습니다. Figma 커뮤니티 파일은 값을 코드로 읽을 수 없어서, Figma가 GitHub에 공개한 Simple Design System의 토큰 을 잣대로 썼습니다. 나란히 놓고 세니 우리 사이트의 글자 크기는 11종이었고, 그중 13·13.5·14·14.5·15가 2px 안에 다섯 개 몰려 있었습니다. 제가 새로 만든 값 셋만 지워 8종 으로 줄였습니다. 나머지를 표준 스케일로 접는 건 대표가 눈으로 맞춰 둔 화면을 흔드는 일이라 하지 않았습니다. 잣대를 어디서 구했나 9월 5일, 사이트 전체 디자인 감사를 했습니다. 10쪽을 데스크톱 1280 · 태블릿 760 · 모바일 390 세 폭에서 전부 찍어 놓고, 제목 구조·반복·밀도·정렬을 하나씩 봤습니다. 그런데 "글자 크기가 너무 많다"고 느껴도, 무엇에 비해 많은지 를 말할 수 없었습니다. 잣대가 필요했습니다. 처음엔 Figma 커뮤니티의 UI 킷을 참고하려 했습니다. 그런데 커뮤니티의 .fig 파일은 편집기 안에서만 열리고, 그 안의 값(토큰)을 코드로 읽을 방법이 없습니다. 눈으로 보고 베끼면 또 하나의 "느낌"이 될 뿐입니다. 대신 찾은 것이 Figma가 만든 Simple Design System 입니다. 이건 토큰이 GitHub에 오픈소스(CC BY 4.0)로 공개돼 있습니다( github.com/figma/sds 의 src/theme.css ). 여기서 실제 스케일 값을 읽어 우리 사이트와 비교했습니다. 남의 킷 화면을 베끼는 게 아니라, 킷이 지키는 규칙의 수치를 잣대로 쓴 것 입니다. 나란히 놓고 센 결과 Simple Design System 유진 사이트 글자 크기 10단 고정 · 12·14·16·20·24·32·40·48·64·72 Tailwind 기본 + 임의 11종 · 11·11.5·12·13·13.5·14·14.5·15·17·19·26 간격 13단 임의 픽셀값 98곳 모서리 4종 6종 혼용 문제는 "임의값"이 아니라 "밀도" 임의값을 쓰는 것 자체는 문제가 아닙니다. 대표가 화면을 보며 맞춘 15px, 17px에는 이유가 있습니다. 문제는 13 / 13.5 / 14 / 14.5 / 15가 2px 안에 다섯 개 있다는 것이었습니다. 0.5px 차이는 화면에서 아무도 구별하지 못합니다. 그런데 코드에는 각각 다른 값으로 남습니다. 다음에 새 화면을 만드는 사람은 "본문 보조 글자는 13이었나, 13.5였나?"를 매번 고르게 되고, 그렇게 화면마다 조금씩 다른 값이 굳어지면 나중에는 무엇이 기준인지 아무도 모르게 됩니다. 간격이 98곳, 모서리가 6종으로 흩어진 것도 같은 현상입니다. 값이 계속 늘어나는 건 디자인 토큰이 없기 때문입니다. 무엇을 지웠고 무엇을 남겼나 지운 건 셋입니다 — 11.5 · 13.5 · 14.5 (각각 11 / 13 / 14로). 전: 11 11.5 12 13 13.5 14 14.5 15 17 19 26 (11종) 후: 11 12 13 14 15 17 19 26 ( 8종) 이 셋을 고른 이유가 있습니다. 셋 다 제가 진행 절차 화면을 고치면서 새로 만든 값 이었습니다. 그래서 지워도 대표가 다듬어 놓은 화면은 하나도 바뀌지 않고, 사이트의 크기 종류만 줄어듭니다(커밋 8795d0e ). 왜 끝까지 안 접었나 남은 8종을 Tailwind 기본 스케일(12·14·16·18·20·24·30)로 마저 접으면 깔끔해집니다. 하지만 하지 않았습니다. 15px이 15곳, 17px이 4곳 쓰이고 있었습니다. 이걸 16·18로 옮기면 대표가 눈으로 맞춰 둔 화면이 전부 조금씩 달라집니다. 눈금을 맞추자고 이미 맞춰 놓은 것을 흔드는 셈입니다. 이건 코드가 아니라 화면을 보며 결정할 일이라, 대표가 보는 앞에서 한 화면씩 하기로 남겨 두었습니다. 토큰 정리는 "값을 줄이는 것"보다 "누가 정한 값인가"를 먼저 보는 일 이었습니다. 내가 만든 값은 내가 지울 수 있지만, 남이 의도를 갖고 맞춘 값은 그 사람과 같이 바꿔야 합니다. 같은 감사에서 "내 오판"이었던 것 이 감사에서는 고친 것만 있었던 게 아닙니다. 문제라고 적었다가 확인해 보니 이미 있었던 것 이 둘 있었습니다. "A/S·수리 페이지에 거르는 수단이 없다" → 이미 있었습니다. 제가 못 본 것입니다. "회사소개 목차가 따라다니지 않는다" → 이미 따라다녔습니다. 감사 문서에서 두 항목을 지우지 않고 줄을 그어 "내 오판"으로 남겼습니다. 틀린 지적을 조용히 지우면, 다음 감사 때 같은 오판을 또 합니다. 점검해서 문제가 없던 것 도 숫자로 남겼습니다. 10쪽 × 3폭 = 30회 측정에서 가로 넘침 0건, alt 없는 이미지 0건, 다섯 언어의 lang 속성 모두 정확. 남은 숙제 항목 지금 다음 글자 크기 8종 대표와 한 화면씩 Tailwind 스케일로 간격 임의 픽셀 98곳 토큰으로 묶을 후보 정리 모서리 6종 혼용 4종으로 마무리 — 무엇을 배웠나 첫째, "많다"는 잣대가 있어야 말할 수 있습니다. 공개된 디자인 시스템의 토큰은 남의 화면이 아니라 남의 규칙을 빌려 오는 방법이었습니다. 둘째, 값의 개수보다 밀도가 문제입니다. 구별 안 되는 값이 여러 개 있으면 기준이 흐려집니다. 셋째, 정리는 "누가 정한 값인가"부터 봅니다. 제가 만든 값은 바로 지웠고, 대표가 맞춘 값은 같이 보면서 바꾸기로 했습니다. 넷째, 틀린 지적도 지우지 않고 남깁니다. 오판도 감사의 결과입니다. 이 글에서 남는 것 Figma 커뮤니티 파일은 토큰을 코드로 못 읽는다. Figma의 Simple Design System은 github.com/figma/sds 에 토큰이 공개돼 있다. 남의 화면이 아니라 남의 스케일(글자 10단·간격 13단·모서리 4종)을 잣대로 쓴다. 임의값이 아니라 "2px 안에 다섯 개" 같은 밀도가 기준을 흐린다. 내가 만든 값부터 지운다. 남이 눈으로 맞춘 값은 그 사람과 같이 바꾼다. 감사에서 틀린 지적은 줄 그어 "오판"으로 남긴다.