오후 1시 16분, 스토어 등록 폼을 열어 놓고 개인정보처리방침 URL 칸에서 멈췄다. 넣을 주소가 없었다. 페이지가 아직 안 만들어진 게 아니라, 그 페이지를 담을 코드조차 없었다
우리가 나간 공모전은 제출물이 파일이 아니라 「스토어에 등록 완료된 링크」다. APK를 내는 게 아니라 실제로 열리는 주소를 내는 것이라, 등록이 막히면 그 앞에 쌓은 산출물이 전부 무의미해진다. 그 등록을 막고 있던 게 방침 URL 한 칸이었다
발견에서 게시 확인까지 비용 0원, 정적 HTML 4장, 게시 바이트 35,156B다. 그런데 이 글에서 남길 건 그 숫자가 아니다. 호스팅을 어디로 고를지 40분 넘게 재고 나서, 그 고민의 절반이 필요 없었다는 걸 알았다. 제출할 URL을 커스텀 도메인으로 잡는 순간 호스팅 선택의 되돌림 비용이 거의 0이 되기 때문이다
제출물이 링크라서 사슬이 걸려 있었다
스토어 등록 폼은 개인정보처리방침 URL을 요구한다. 빈칸으로 다음 단계에 못 간다. 사슬이 이렇게 걸려 있었다
방침 URL 없음 → 스토어 등록 폼을 못 채움 → 등록 완료 링크 없음 → 제출물 무효
이 사슬의 왼쪽 끝은 점수가 0점인 항목이다. 심사 배점은 기획 30 · 완성도 30 · 데이터 활용 20 · 발전성 20이고, 방침 페이지는 이 어디에도 안 붙는다. 그런데 이게 없으면 100점 전체가 열리지 않는다
문서가 하나로 끝나지도 않았다. 우리 앱은 위치기반서비스다. 내 주변 관광지·병원·응급실, 걷기 코스, 모임 위치 공유, SOS. 그래서 개인정보보호법만이 아니라 위치정보의 보호 및 이용 등에 관한 법률이 함께 걸린다. 그 법 제19조 제1항은 위치기반서비스 이용약관을 따로 요구한다. 거기에 구글플레이가 웹 기반 계정 삭제 요청 경로를 요구해서 셋이 됐다
일정은 이랬다
2026-09-21 16:00 1차 심사 자료 제출 마감 ← 제출물은 「스토어 등록 완료된 링크」 2026-10 중 기능심사 2026-10-28 최종심사 (PT 발표) 2026-11-05 시상식
마감까지 19일이다. 여기서 스토어 심사 기간을 빼야 하니 실제로 손에 쥔 날짜는 그보다 짧다. 이 계산이 뒤의 모든 선택을 「그날 안에 끝나는가」로 좁혔다
목록에 있다는 것과 보인다는 것은 다르다
이 항목은 8월 31일 전수 조사 때 이미 목록에 있었다. 제목까지 「개인정보처리방침 게시 페이지 — 코드조차 없다」였다. 326개 항목 중 하나로 묻혀 있었을 뿐이다
오늘 아침에 그걸 다시 꺼낸 계기는 다른 것이었다. 운영 서버의 TLS 인증서가 10월 18일에 만료된다는 걸 발견했고, 하나를 찾았으면 같은 종류가 더 있는지 봐야 한다고 판단했다. 그래서 세 리포에 렌즈 여덟 개를 병렬로 돌렸다
api-인증서 · api-시한부자산 · api-운영갈림 · app-릴리스자산 app-심사동선 · web-현황 · 교차-방침약관 · 교차-문서거짓
렌즈를 나눈 기준은 리포가 아니라 「무엇이 시한을 갖는가」다. 인증서, 만료되는 키, 스토어 심사 동선처럼 날짜가 붙은 자산을 따로 본다. 31건이 나왔고 그중 치명은 둘이었다. 그 하나가 이것이다
[치명 · 시한 2026-09-21] 웹에 개인정보처리방침·위치기반서비스 이용약관·계정삭제 안내
실물이 0건 — 스토어 등록 필수 URL을 채울 수 없다
같은 사실이 이틀 전에도 목록에 있었다. 달라진 건 문장이 아니라 등급과 시한이었다. 목록에 적어 두는 것과 그게 보이는 것은 다른 일이다
반증을 시켰더니 문제의 모양이 바뀌었다
발견 하나에 반증자 셋을 붙여 틀렸음을 증명해 보라고 시켰다. 셋 다 반증에 실패했는데, 대신 원 보고가 놓친 걸 셋 가져왔다
첫째, 생각보다 쉬웠다. 방침 본문은 이미 357줄로 90% 완성이었고, 남은 빈칸 일곱은 대부분 사람이 몇 분이면 채우는 값이었다. 원인은 「글이 없다」가 아니라 「게시할 곳이 없다」였다
둘째, 생각보다 나빴다. 나는 Next.js 웹 리포에 라우트 3개만 추가하면 될 줄 알았다. 반증자가 그 리포의 CI 설정을 직접 열어 보고 이렇게 적었다
ci.yml은 lint → typecheck → test → build만 있고 배포 스텝이 아예 없다. vercel.json·netlify.toml도 0건이다. 이 리포는 지금까지 어디에도 배포된 적이 없다.
직접 확인해 봤다
grep -nE 'deploy|vercel|netlify|pages' .github/workflows/ci.yml # → (출력 없음) ls vercel.json netlify.toml 2>/dev/null | wc -l # → 0
배포 스텝이 없고 호스팅 설정 파일도 없다. 「라우트 3개 추가」가 아니라 「배포 파이프라인을 처음부터 구성」이 앞에 붙는 일이었다. 30분짜리로 잡았던 게 반나절짜리가 됐다
셋째, 범위가 줄었다. 원스토어 기준으로는 개인정보처리방침 URL 하나만 필수다. 위치기반서비스 약관과 계정삭제는 별도 URL로 강제되지 않는다. 그래도 셋 다 만들었고, 이유는 뒤에 적는다
발견자는 문제를 찾고, 반증자는 그 문제를 죽이려다 문제의 진짜 모양을 찾아냈다. 셋 중 둘은 내 추정을 뒤집은 것이고, 그중 하나는 파일을 실제로 열어 봐야만 나오는 것이었다
후보 넷을 「대가」 칸으로 비교했다
시도한 순서대로 적는다
처음 손댄 건 기존 웹 리포였다. 이미 서 있는 리포라 여기가 제일 싸 보였고, 라우트 세 개만 얹으면 끝날 거라고 생각했다. 배포 스텝이 없다는 걸 확인하고 접었다
다음은 기존 운영 서버였다. Caddy가 이미 TLS를 종단하고 있으니 정적 파일 몇 장 얹는 데는 새 인프라가 0이다. 이건 다른 이유로 접었다. 방침 페이지를 앱 서버와 같은 기계에 두면 서버가 죽을 때 방침도 같이 죽는다. 심사위원이 앱을 여는 그 순간에 서버가 죽어 있으면 완성도 30점이 통째로 빈다는 항목을 우리가 이미 적어 뒀었고, 방침을 거기 얹으면 그 위험에 스토어 등록까지 묶이게 된다
세 번째는 S3 + CloudFront였다. AWS 계정이 이미 있고 정적 호스팅으로는 정석이다. 이것도 같은 이유로 접었다. 바구니가 같다. AWS 결제와 예산이 11월 5일까지 버티는지는 계정 소유자만 볼 수 있고, 우리 쪽에서는 확인이 안 된 상태였다
네 번째가 Cloudflare Pages였다. 무료고 private 리포도 된다. 접은 이유는 기술이 아니었다. 계정을 새로 만들어야 했고, 그건 사람 손이 한 번 더 들어간다는 뜻이다. 그날 안에 끝내려면 사람 손을 늘리면 안 됐다
| 후보 | 값 | 대가 |
|---|---|---|
| GitHub Pages + 새 public 리포 | 무료·무기한·빌드 불필요·AWS와 다른 바구니 | 무료 플랜은 public 리포만 |
| S3 + CloudFront | AWS가 이미 있다 | 방침 URL을 AWS와 같은 바구니에 담는다 |
| Cloudflare Pages | 무료 · private 리포도 된다 | 계정 신규 가입(사람 손 +1) |
| 기존 서버(Caddy)에 얹기 | 새 인프라 0 | 서버가 죽으면 방침도 같이 죽는다 |
「값」 칸만 보면 넷 다 통과한다. 결정을 가른 건 「대가」 칸이었고, 그 안에서도 한 줄이었다. 한 바구니에 두 자산을 담으면 어느 한쪽이 흔들릴 때 둘 다 흔들린다
여기서 내가 판단에 실었던 신호 하나는 나중에 내 과장으로 밝혀진다. 그날 아침 운영 서버 인증서를 두고 「자동 갱신이 안 걸려 있을 것」이라고 말했는데, 그 절반이 틀렸다. 뒤에 따로 적는다. 다만 결정 자체는 그 과장에 기대지 않는다. 「다른 바구니」라는 논거는 그 알람이 없었어도 성립한다
진짜 잠금은 호스팅이 아니라 URL이었다
여기까지 오는 데 40분 넘게 썼다. 호스팅을 확정하고 나서야 물어봐야 할 질문이 하나 더 남아 있다는 걸 알았다. 나중에 이 결정을 바꾸고 싶어지면 어떻게 되나
답이 갈렸다
계정명.github.io/<리포>/ 로 제출 → 호스팅이 URL에 묶인다. 옮기면 URL이 바뀐다 example.co.kr/privacy/ 로 제출 → A 레코드만 갈아 끼우면 된다. URL은 그대로다
스토어에 제출한 뒤 URL을 바꾸면 검토에 문제가 생긴다. 그래서 제출 시점에 잠기는 건 호스팅이 아니라 URL이고, 커스텀 도메인을 쓰면 그 잠금이 내가 통제하는 쪽으로 옮겨 간다. 호스팅은 부품이 된다
옮길 때 드는 값도 재 봤다
| 항목 | 값 |
|---|---|
| DNS 전파 | TTL 600 기준 10분 |
| HTTPS 공백 | 순서만 지키면 0 |
| 스토어 재제출 | 불필요 (URL이 안 바뀌므로) |
| 콘텐츠 이관 | 정적 HTML이라 파일 복사 |
HTTPS 공백이 0이 되는 건 순서 때문이다. 새 호스팅에 인증서를 먼저 세우고 DNS를 나중에 돌린다. 반대로 하면 DNS가 새 호스팅을 가리키는데 인증서가 아직 없는 구간이 생기고, 그 구간에 스토어 심사가 걸리면 방침 URL이 브라우저 경고를 띄운다
그래서 호스팅 비교에 40분을 쓴 건 절반쯤 낭비였다. 커스텀 도메인을 먼저 정했다면 그 40분이 10분으로 줄었다. 되돌릴 수 있는 결정과 되돌릴 수 없는 결정을 먼저 갈랐어야 했는데, 나는 둘을 같은 무게로 놓고 재고 있었다
GitHub Free는 public 리포만 Pages가 된다
호스팅을 GitHub Pages로 정하고 나서 걸린 제약이 하나 있다. 공식 문서에 이렇게 적혀 있다
GitHub Pages is available in public repositories with GitHub Free.
기존 웹 리포는 private이다. 무료 플랜에서는 거기에 Pages를 못 켠다. 그래서 「기존 리포에 라우트를 추가한다」는 경로가 요금제 차원에서도 한 번 더 막혔고, 새 public 리포를 파는 게 확정됐다. 커스텀 도메인과 HTTPS는 무료 플랜에서도 된다. 잠금이 걸리는 건 리포 공개 범위 쪽이지 도메인 쪽이 아니다
방침·약관은 어차피 전 세계에 공개할 문서다. 소스가 public이라는 게 여기서는 문제가 되지 않고, 오히려 스토어가 요구하는 「누구나 도달 가능해야 한다」는 조건과 방향이 같다
구글플레이 요건 셋을 이때 처음 봤다. 우리 문서 어디에도 없던 것이다
① PDF는 안 된다 ② 편집 가능한 문서는 안 된다 ← 구글 문서 공유 링크가 여기서 탈락한다 ③ 계정·페이월·지역 제한 없이 도달 가능해야 한다
②가 흔한 함정이다. 방침을 구글 문서로 써서 공유 링크를 붙이는 게 제일 빨라 보이는데, 편집 가능한 문서라서 걸린다. 정적 HTML은 셋을 자동으로 만족한다. 이건 프레임워크를 안 쓰기로 한 결정과도 방향이 같았다
원스토어는 방침 URL 하나만 필수인데도 셋 다 만든 이유가 여기 있다. 같은 앱을 Play에 올리는 순간 계정 삭제 경로가 다시 필요해지는데, 리포가 이미 서 있으면 파일 하나 더 놓는 일이라 추가 비용이 거의 0이었다. 나중에 다시 열 이유를 지웠다
프레임워크를 안 쓴 이유는 셋이었다
정적 HTML로 갔다. 빌드도 프레임워크도 없다
첫째, 레인이 섞이지 않는다. 이날 웹 세션은 admin만 한다고 정해 뒀다. 방침을 같은 리포에 넣으면 한 세션이 둘을 번갈아 하게 된다. 한 주체가 둘을 번갈아 하면 어느 쪽도 안 끝난다는 걸 이 프로젝트가 이미 한 번 겪었다
둘째, 그 리포는 6주째 멈춰 있었다. 마지막 커밋이 7월 19일이고 8월 20일 이후 커밋은 0건인데, 그 사이 백엔드에는 마이그레이션과 새 엔드포인트가 들어갔다. 켜는 순간 「6주치 계약 따라잡기」가 먼저 나올 판이었다. 방침이 그 뒤에 줄을 서면 안 됐다
셋째, 방침은 코드가 아니라 문서다. 본문이 이미 마크다운으로 있었고 필요한 건 정책 페이지 3장이었다. 거기에 셋을 잇는 목차 1장을 더해 최종 4장이 됐다
읽는 사람이 시니어다
우리 앱의 이용자는 시니어다. 방침을 읽는 사람도 그렇다. 그래서 본문 스타일을 먼저 잡고 시작했다
font-size: 18px; /* 본문 18px 이상 */ line-height: 1.85; /* 줄간격을 넓게 */ word-break: keep-all; /* 한글이 단어 중간에서 안 끊기게 */
word-break: keep-all이 한글 문서에서 특히 값을 한다. 기본값이면 좁은 화면에서 단어가 중간에서 잘려 다음 줄로 넘어간다. 법령 용어가 반으로 쪼개지면 읽는 속도가 눈에 띄게 떨어진다
표는 자기 안에서만 가로로 스크롤되게 했다
.table-wrap { overflow-x: auto; }
방침에 표가 여덟 개 있었다. 이 한 줄이 없으면 좁은 화면에서 본문 전체가 옆으로 밀린다. 표 하나 때문에 문단까지 같이 밀리는 화면은 시니어 독자에게 특히 나쁘다
다크모드는 prefers-color-scheme으로 받고, 인쇄도 대응했다. 법적 문서는 출력해 두는 사람이 있다
URL은 파일이 아니라 디렉터리로 잡았다
/privacy.html → 쓰지 않았다 /privacy/ → 이걸 썼다
스토어 폼에 넣기 깔끔하고, 나중에 파일 구조를 바꿔도 URL이 안 흔들린다. 앞 절에서 URL을 잠금 지점으로 봤으니, 그 URL이 파일 확장자에 묶이면 안 된다
게시하면 안 되는 것을 grep이 잡게 만들었다
채워야 하는 자리마다 마크업으로 표시를 남겼다
<!-- 채워야 하는 자리 --> <span class="blank">[상호]</span>
/* 눈으로도 잡히게 */
.blank { background: #fff3b0; }
노란 배경이라 브라우저에서 눈으로 잡히고, 클래스 이름이라 명령으로도 잡힌다. 게시 전 게이트는 이 한 줄이다
grep -rn 'class="blank"' . && echo "🔴 빈칸이 남았다 — 게시하면 안 된다"
grep은 못 찾으면 종료코드 1을 돌려주므로, 빈칸이 없을 때만 && 뒤가 안 돌아간다. 사람이 눈으로 확인하는 단계를 명령 하나로 바꿨다
게시 직전 검사는 이렇게 돌았다
빈칸 grep -rn 'class="blank"' → 0건 (종료코드 1)
내부 메모 grep -rnE '미확인|준비 중|\{\{' → 0건 (종료코드 1)
양성 대조 grep -rn '위치' → 4 · 4 · 32 · 43건
로컬 서버 네 URL + CSS → 전부 200
링크 전수 상대경로 10종 → 전부 정상
양성 대조를 넣은 이유가 있다. 0건이 나왔을 때 그게 정말 없어서 0인지, 명령이 잘못 걸려서 0인지 구분이 안 된다. 그래서 반드시 잡혀야 하는 단어를 하나 걸어 봤다. 「위치」가 파일마다 4·4·32·43건으로 잡히는 걸 보고 나서야 앞의 0건을 믿었다
실제로 한 순서는 DNS가 먼저였다
⓪ 발견 ~ 갈림길 결정 13:16~14:10 · 54분 ① 가비아 DNS에 apex A 레코드 넷 추가 14:10경 ② 정적 HTML 4장 + CSS 작성 14:16~14:19 ③ 빈칸 아홉의 값 결정 ④ 값 채우고 게시 게이트 통과 확인 ⑤ git init + 커밋 14:47 ⑥ public 리포 생성 + 푸시 ⑦ GitHub Pages 활성화 ⑧ 빌드·인증서 대기 폴링 → HTTPS 강제 ⑨ 게시본 실측 검증 14:52
96분 중 54분이 ⓪에 들어갔다. 만드는 것보다 무엇을 어디에 만들지 정하는 게 오래 걸렸다
DNS를 리포보다 먼저 넣은 이유는 두 가지다. 전파 대기가 이 작업에서 가장 긴 항목이고, 리포가 아직 없어도 해가 없다. 그 사이에는 404가 뜰 뿐이다. DNS가 미리 서 있으면 나중에 커스텀 도메인을 넣는 순간 바로 검증돼 인증서 발급이 시작된다
넣은 값은 GitHub Pages의 apex 도메인용 A 레코드 넷이다
185.199.108.153 185.199.109.153 185.199.110.153 185.199.111.153
이 단계에서 제일 위험한 건 새 레코드가 아니라 기존 레코드였다. 같은 도메인에 운영 서버를 가리키는 api A 레코드와 _acme-challenge TXT가 이미 있었다. 새 넷을 추가하다 api가 지워지면 앱이 백엔드와 통신을 못 하고 HTTPS도 같이 끊긴다.
저장 버튼 앞에서 손이 멈췄다. 저장 전에 화면을 그대로 보여 달라고 먼저 요청했고, 목록이 총 6줄(기존 2 + 새 4)인 걸 확인한 뒤 저장했다. 저장 직후 바로 쟀다.
apex A → 185.199.108~111.153 넷 다 api A → (운영 서버 IP) 그대로 api HTTPS → 401 · ssl_verify=0 인증서 유효 apex HTTP → 404 정상 신호
api가 401을 돌려주는 건 인증이 필요한 엔드포인트라서 정상이다. 여기서 중요한 건 상태 코드가 아니라 TLS 검증이 통과했다는 쪽이다
apex의 404가 좋은 신호였다. GitHub Pages 서버가 「이 도메인에 연결된 사이트가 없다」고 답하는 것이라, DNS가 GitHub까지 정확히 닿았다는 증거다. 연결이 안 됐으면 404가 아니라 이름 해석 실패가 나온다
옮기려고 다시 읽다가 반례를 만났다
내부 메모 문제는 감사가 이미 지적한 항목이었다. 발견 제목이 「마크다운→HTML 변환 자체 위험은 낮으나, 게시 전 내부 메모 제거와 표 반응형 처리가 필요하다」였다. 다만 그 위치에 대한 판정이 이랬다
내부 조사/경위 메모는 두 파일 모두 최상단 블록인용으로 작성되어 있고
---구분선으로 게시용 본문과 명확히 분리돼 있어 실수로 섞여 게시될 위험은 구조적으로는 낮지만, 사람이 수작업으로 잘라내야 하는 단계임에는 변함없다.
「구분선 위에만 있다」가 틀렸다. 옮기려고 파일을 처음부터 다시 읽었더니 구분선 아래 본문 안에 둘이 더 있었다
약관 제2조 표 아래: ⚠️ 이 표는 약관의 심장이다 — 여기 안 적힌 용도로 위치를 쓰면 그 자체가 위반이다 약관 제6조 위: > ⚠️ [센터 답을 받은 뒤 확정한다.] 아래는 지금 사실에 맞는 문장이다
첫째는 우리끼리 쓴 설계 메모다. 둘째는 미확정 표시다. 그대로 게시했으면 법적 문서에 내부 메모와 「아직 확정 안 됨」이 실려 나갔다
여기서 배운 건 감사가 틀렸다는 게 아니다. 감사는 제목에서 메모 제거가 필요하다고 이미 말했다. 「구조적으로 분리돼 있다」는 파일 구조를 본 판정이었고, 나는 같은 파일을 옮기려는 목적으로 전문을 읽다가 반례를 만났다. 같은 파일을 다른 목적으로 읽으면 다른 게 보인다
그래서 이런 검사는 구조를 본 판정 하나로 끝내면 안 된다. 앞 절의 grep 게이트를 넣은 것도 이 때문이고, 구조를 믿는 대신 문자열을 세는 쪽으로 바꾼 것이다
주소와 전화는 되돌릴 수 없는 결정이었다
위치정보법 제19조 제1항의 필수 기재사항 첫 번째가 상호, 주소, 전화번호를 포함한 연락처다. 뺄 수 없다. 사업자등록이 없으면 개인 성명과 개인 주소·번호가 그대로 공개된다
이 값들을 나중에 바꿀 수 있는지 확인했다. 답이 절반만 「예」였다
바꿀 수 있는 것은 값 자체다. 방침·약관 개정 절차(시행 7일 전 앱 공지)만 따르면 언제든 고친다. 제출한 URL은 안 바뀌므로 스토어 재제출도 필요 없다. 앞 절에서 URL을 도메인에 묶어 둔 게 여기서도 값을 한다
바꿔도 남는 것이 문제다
| 남는 것 | 왜 |
|---|---|
| 검색엔진 캐시·웹 아카이브 | 게시되는 순간부터 제3자가 복제한다. 나중에 지워도 그 시점 사본이 남는다 |
| 스크래핑 봇 | 사업자 연락처를 긁어 스팸·마케팅 DB로 넣는 봇이 실제로 돈다 |
| 신고서 기재값 | 위치기반서비스 신고를 하면 신고서의 주소·전화가 걸려 변경신고가 붙는다 |
문서 고치는 건 10분인데, 이미 나간 노출을 회수하는 방법은 없다.
완화책도 같이 냈다. 070 인터넷전화나 알뜰폰 유심으로 앱 전용 번호를 두면 개인 번호가 안 나가고, 비상주 사무실 주소를 쓰면 집주소를 감출 수 있다. 다만 나중에 도입하면 그때는 이미 나간 뒤다. 이 완화책은 게시 전에만 값이 있다
위험을 확인하고 나서 그대로 가기로 했다. 비용 0원으로 그날 안에 게시까지 끝내는 쪽을 골랐다. 좋은 선택이라고는 말 못 하지만, 모르고 내린 결정은 아니다
보존기간은 법률 조문이 아니라 고시에 있었다
빈칸 하나가 「위치정보 이용·제공사실 확인자료 보존기간」이었다. 몇 개월을 적어야 하는지 법을 봤다
조문에는 개월 수가 없다. 제19조 제1항 제4호는 확인자료의 보유근거 및 보유기간을 명시하라고만 하고, 몇 개월인지는 정하지 않는다. 그래서 나는 사업자가 알아서 정하는 값이라고 판단했다. 그게 아니었다
하위 고시가 최소값을 정한다. 방송통신위원회고시 제2022-11호(위치정보의 관리적·기술적 보호조치 기준) 제6조 제5항이 위치정보 취급대장을 최소 6개월 이상 보관하도록 한다. 확인한 사업자 세 곳도 전부 6개월이었다
법률 조문 → 기간을 명시하라 (개월 수 없음) 하위 고시 → 최소 6개월 이상 업계 관행 → 확인한 세 곳 모두 6개월
6개월로 정했다. 조문만 보고 정했어도 결론은 같았을 것이다. 그런데 근거가 「관행이 그렇다」인 것과 「법정 최소값이다」인 것은 다르다. 앞쪽이면 나중에 3개월로 줄이자는 논의가 열리고, 뒤쪽이면 안 열린다. 「법대로」를 조문으로만 좁혀 읽은 게 실수였다
취급대장과 이용·제공사실 확인자료가 완전히 같은 것인지는 아직 확인이 안 됐다. 다르다면 조문상 근거가 다시 열린다. 이건 미확인으로 남겨 뒀다
시행일을 일주일 뒤로 잡지 않았다
시행일을 일주일 뒤로 하자는 얘기가 나왔는데 권하지 않았다. 이유가 셋이다
앱이 이미 운영 중이다. 회원이 다섯이라도 운영 중은 운영 중이고, 시행일이 미래면 그때까지 적용되는 방침이 없는 구간이 생긴다. 「시행 7일 전 공지」는 변경할 때 규칙이지 최초 제정 규칙이 아니다. 그리고 스토어 폼에 아직 시행 전인 방침 URL을 내는 게 어색하다
게시하는 날을 그대로 시행일로 적었다
SSH 별칭 때문에 푸시가 막혔다
리포 생성과 푸시를 한 번에 하려다 실패했다
fatal: Could not read from remote repository.
리포는 만들어졌는데 푸시만 실패해서 잠깐 헷갈렸다. 인증 문제인지 리포 문제인지 이 메시지만으로는 안 갈린다. 옆 리포의 remote를 열었다.
git -C ../other-repo remote -v # → origin <별칭>:<계정>/<리포>.git (fetch) git remote set-url origin <별칭>:<계정>/<리포>.git
이 계정은 github.com이 아니라 별도 SSH 호스트 별칭을 쓴다. gh가 만든 remote는 기본 호스트를 가리키고, 그쪽에는 이 계정 키가 안 걸려 있었다. 이런 건 문서에 안 적혀 있고 옆 리포를 보면 5초에 풀린다
인증서가 35초에 나왔다
HTTPS 인증서 발급은 몇 분에서 한 시간까지 걸릴 수 있다. 그렇게 안내하고 15초 간격 폴링을 걸어 뒀다. 세 번째 줄에서 끝났다
[1] pages=built http=200 https=000 [2] pages=built http=200 https=000 [3] pages=built http=200 https=200 ← READY
https=000은 curl이 연결 자체를 못 했다는 뜻이다. 인증서가 아직 없을 때 나오는 값이라, 200으로 바뀌는 순간이 발급 완료 시점이다
DNS를 미리 넣어 둔 게 여기서 값을 했다. GitHub이 커스텀 도메인을 확인할 때 DNS가 이미 서 있어서 검증이 바로 끝났다. 순서를 반대로 했으면 이 자리에서 전파를 기다렸을 것이다
이 숫자를 처음에는 45초로 적었다. 15초 × 3회로 어림한 값이다. 실제 타임스탬프(명령 발신부터 결과 파일 mtime까지)를 재니 약 35초였다. 폴링은 「대기 후 확인」이 아니라 「확인 후 대기」라서 마지막 sleep이 안 들어간다. 그럴듯한 숫자일수록 실측으로 확인해야 한다
200만 보고 끝냈으면 못 봤을 것
이 프로젝트에는 규칙이 하나 있다. 「완료」는 실측 뒤에 쓴다
https://example.co.kr/ 200 1317B ssl=0 https://example.co.kr/privacy/ 200 22912B ssl=0 https://example.co.kr/location-terms/ 200 6389B ssl=0 https://example.co.kr/account-deletion/ 200 4538B ssl=0 http:// → 301 → https:// (HTTPS 강제 켜짐) 인증서 Let's Encrypt · 만료 2026-12-01 게시본 빈칸·미확인·준비중 0건
여기서 실제로 값을 한 건 상태 코드가 아니라 바이트 수다. 1317·22912·6389·4538이 로컬 파일과 바이트 단위로 일치한다. 중간에 잘리거나 변형된 곳이 없다는 뜻이다. 200만 보고 끝냈으면 확인 못 했을 자리다
덤으로 확인된 게 하나 더 있다. 이 인증서는 만료가 12월 1일이고 GitHub이 자동으로 갱신한다. 그날 아침 발견한 운영 서버 인증서는 10월 18일 만료다. 「다른 바구니」라는 논거가 여기서 실물이 됐다. 심사 기간에 서버 쪽이 흔들려도 방침 URL은 안 흔들린다
인증서 오경보 — 내가 틀렸고, 절반만 틀렸다
앞에서 미룬 얘기다. 아침에 운영 서버 인증서를 두고 이렇게 말했다
가비아에
_acme-challengeTXT가 있다. DNS-01 수동 발급 정황이다. 자동 갱신이 안 걸려 있을 가능성이 크고, 10월 18일에 조용히 죽는다.
과장이었다. 리포를 열어 보니 TLS 종단이 Caddy 컨테이너 하나였다. certbot·acme.sh·ACM/ALB는 전부 0건이고, 양성 대조로 검색이 살아 있는 것도 확인했다. Caddy는 자동 발급과 갱신이 기본 동작이며 실제로 한 번 정상 작동한 이력이 있었다
TXT도 권위 서버에 직접 물어보니 정리됐다
_acme-challenge.api.<도메인> → 없다 (권위 있는 부재) _acme-challenge.<도메인> → 있다 (apex 밑)
api 인증서는 tls-alpn-01을 쓴다. 443 포트 자체로 도메인 소유를 증명하는 방식이라 DNS를 아예 안 거친다. 모순이 애초에 없었다. 나는 apex의 TXT를 api의 TXT로 읽고 인과를 지어냈다
절반은 살아남았다. Caddy가 어느 감시망에도 안 걸려 있다. watchdog.sh의 감시 대상 여섯에 Caddy가 없고, docker의 restart: unless-stopped는 프로세스가 죽을 때만 걸린다. 응답 불능이나 갱신만 조용히 실패하는 상태는 못 잡는다. 「갱신이 실패해도 HTTPS가 끊긴 뒤에야 안다」는 핵심은 유효했다
그럴듯한 인과를 만들기 전에 계측기를 먼저 확인해야 한다. 나는 DNS 관리 화면 한 장에서 이야기를 지었고, 그 이야기가 호스팅 결정의 입력으로 들어갔다. 결론이 안 바뀐 건 논거가 하나 더 있었기 때문이지 내가 조심했기 때문이 아니다
이 방법이 통하지 않는 조건
정적 HTML + GitHub Pages + 커스텀 도메인이 항상 답은 아니다
방침에 서버가 필요한 기능이 붙으면 안 된다. 문의 폼 제출이나 계정 삭제 요청 접수를 웹에서 직접 받으면 정적 파일로는 끝나지 않는다. 우리는 접수 경로를 이메일로 뒀기 때문에 정적으로 끝났다
소스를 공개할 수 없으면 이 경로는 막힌다. GitHub Free는 public 리포에서만 Pages가 된다. private이 필요하면 Pro 이상이거나 Cloudflare Pages 쪽으로 가야 한다
도메인이 없으면 이 글의 핵심 논거가 성립하지 않는다. 커스텀 도메인이 되돌림 비용을 없애는 것이지 GitHub Pages가 없애는 게 아니다. 도메인부터 사야 한다면 그건 별도 결정이다
앱 화면의 약관과 게시본이 갈리면 이 작업은 안 끝난 것이다. 위치정보법 제19조 제1항의 동의 대상은 이용자가 화면에서 본 문장이다. 게시본만 고치고 앱 화면을 두면 동의 증빙이 갈린다. 우리도 아직 조문 제목 몇 개가 갈려 있고 다음 티켓에 들어가 있다
사업자등록이 있으면 상호·주소 문제는 다르게 풀린다. 이 글의 트레이드오프는 사업자등록 없는 개인 기준이다
정리하면
한 줄로 요약하면, 스토어에 제출한 뒤 잠기는 건 호스팅이 아니라 URL이다
바로 쓸 수 있는 기준은 이거다. 외부에 제출할 URL이 생기면 호스팅을 고르기 전에 그 URL이 내 도메인인지부터 확인한다. 내 도메인이면 호스팅 비교에 한 시간을 쓸 이유가 없다. 나중에 A 레코드만 갈아 끼우면 되니까. 내 도메인이 아니면 그 비교가 진짜 한 번뿐인 결정이고, 그때 한 시간을 쓰면 된다
용어 정리
DNS와 도메인
apex 도메인 — www 같은 서브도메인이 안 붙은 최상위 형태다. example.co.kr이 apex이고 www.example.co.kr은 아니다. apex는 CNAME을 못 걸어서 A 레코드로 IP를 직접 적어야 한다
A 레코드 — 도메인 이름을 IPv4 주소로 매핑하는 DNS 레코드다. GitHub Pages는 apex용으로 IP 넷을 공개하고, 넷 다 넣는 게 권장 구성이다
TTL — 다른 DNS 서버가 이 레코드를 캐시해도 되는 시간이다. 600이면 10분이고, 값을 바꿨을 때 전파에 걸리는 시간의 상한이 된다
_acme-challenge TXT — DNS-01 방식으로 도메인 소유를 증명할 때 인증기관이 확인하는 TXT 레코드다. 이 레코드가 있다고 해서 그 도메인의 모든 인증서가 DNS-01로 발급된 건 아니다
권위 있는 부재 — 권위 네임서버가 「그 이름은 없다」고 확정적으로 답하는 상태다. 캐시 서버가 모르는 것과 다르다
인증서 발급
ACME — 인증서를 자동으로 발급받는 프로토콜이다. Let’s Encrypt가 이 방식을 쓴다
DNS-01 — 도메인 소유를 DNS TXT 레코드로 증명하는 방식이다. 서버가 외부에 안 열려 있어도 되지만, DNS 쪽에 자동화를 걸어야 갱신이 자동으로 돈다
tls-alpn-01 — 443 포트로 들어오는 TLS 핸드셰이크 자체로 소유를 증명하는 방식이다. DNS를 안 거친다
Enforce HTTPS — GitHub Pages 설정 항목이다. 켜면 http:// 요청이 https://로 301 리다이렉트된다. 인증서가 발급된 뒤에만 켤 수 있다
GitHub Pages
GitHub Pages — 리포의 파일을 그대로 웹에 서빙하는 정적 호스팅이다. GitHub Free에서는 public 리포에서만 쓸 수 있다
CNAME 파일 — 리포 루트에 두는 파일이고, 안에 커스텀 도메인 하나를 적는다. DNS 레코드 종류인 CNAME과 이름만 같고 다른 것이다
.nojekyll — 리포 루트에 두면 GitHub이 Jekyll 빌드를 건너뛰고 파일을 그대로 서빙한다. 정적 HTML만 올릴 때 넣는다
법령
위치정보법 제19조 제1항 — 위치기반서비스사업자가 이용약관에 명시해야 하는 사항을 정한다. 상호·주소·전화번호를 포함한 연락처가 첫 번째 항목이다
위치정보 이용·제공사실 확인자료 — 누가 언제 어떤 위치정보를 이용하거나 제공받았는지 남기는 기록이다. 약관에 보유근거와 보유기간을 적어야 한다
위치정보관리책임자 — 위치정보법이 지정을 요구하는 자리다. 개인정보보호법의 개인정보 보호책임자와 다른 법의 다른 지정이라, 겸직 가능 여부는 따로 확인이 필요하다