우리 앱은 심사가 평가할 공개 콘텐츠를 보여주지 못했다
계획했던 것
서비스에 광고를 붙이기로 했다. 웹 앱이 있으니 거기에 슬롯을 넣고 심사를 신청하면 되는 줄 알았다.
앱은 Flutter로 만들었고 웹으로 빌드해 배포한다. 렌더링 방식은 CanvasKit이다. 이 조합에서 무엇이 문제인지 알아채는 데 시간이 좀 걸렸다.
캔버스에 그린 글자는 HTML이 아니다
일반적인 웹 페이지는 HTML 요소로 이루어진다. 문단은 <p>, 제목은 <h1>이고, 크롤러는 그 구조를 읽어 무슨 내용인지 판단한다.
CanvasKit은 그렇게 하지 않는다. 화면 전체를 하나의 캔버스로 두고 거기에 직접 그린다. 글자도 그려진 픽셀이다. 페이지 소스에는 캔버스 하나와 스크립트 몇 개만 있다.
여기서 흔한 오해가 하나 있다. "요즘 크롤러는 자바스크립트를 실행하니 괜찮지 않나?" 실행은 한다. Google은 헤드리스 브라우저로 자바스크립트를 돌린 뒤 그 결과로 만들어진 HTML을 파싱한다고 설명한다. 문제는 파싱 대상이 여전히 HTML이라는 데 있다. 캔버스 위의 픽셀은 아무리 렌더링이 성공해도 HTML 요소가 되지 않는다. Google은 캔버스에 렌더링된 콘텐츠를 색인하지 않으며 본문 텍스트는 DOM에서 접근 가능해야 한다고 명시한다.
그러니까 "자바스크립트가 실행되는가"와 "그 결과가 읽을 수 있는 HTML이 되는가"는 별개의 질문이다. 우리 앱은 앞은 통과하고 뒤에서 걸린다.
덧붙이면, 원인은 WebGL 지원 여부가 아니다. CanvasKit은 WebGL 없이 CPU로만 그리게 할 수도 있는데, 그래도 글자는 여전히 캔버스에 그려진다. 문제는 가속 방식이 아니라 글자가 DOM에 없다는 것 자체다.
실제로 재 봤다
배포된 앱의 첫 화면을 받아 <body> 안의 텍스트 길이를 셌다.
https://app.leva.ai.kr/ → 200 text/html (2,899 bytes)
body 안에서 읽히는 텍스트: 0자
사람 눈에는 멀쩡한 화면인데 문서로서는 글자가 한 자도 없다. 앱 구조와 정확히 들어맞는 값이다.
다만 이 숫자가 증명하는 범위는 분명히 해 두는 게 맞다. 이건 우리가 받은 HTML에 본문 텍스트가 없다는 사실이고, 심사 시스템이 이 화면을 어떻게 처리했는지까지 말해 주지는 않는다.
심사와 검색은 같은 시스템이 아니다
여기서 내가 한 번 건너뛴 곳이 있다. 검색 색인 동작을 근거로 광고 심사 결과를 결론지으려 했다.
둘은 다른 크롤러다. 검색은 Googlebot이 돌고, 광고 쪽은 별도의 크롤러가 따로 있다. 문서를 보면 광고 크롤러는 robots.txt의 전역 규칙(User-agent: *)을 무시하고 자기 이름으로 쓴 규칙만 따른다고 되어 있다. 같은 회사의 크롤러라고 같은 규칙으로 움직이지 않는다는 뜻이다.
그러니 정확한 표현은 이렇다. 검색 기준으로 캔버스 본문은 색인되지 않는다는 것은 문서에 적힌 사실이고, 광고 심사가 내부적으로 어떻게 판단하는지는 공개돼 있지 않다. 다만 공개 화면에 평가할 HTML 콘텐츠가 없다는 구조적 사정은 어느 쪽에도 불리하게 작용한다.
Flutter를 금지하는 정책 같은 건 없다. 렌더링 방식 때문에 자동으로 거절되는 것이 아니라, 우리 경우엔 심사가 들여다볼 공개 콘텐츠 자체가 없었다.
여기에 하나가 더 얹힌다. 우리 앱의 화면은 대부분 로그인 뒤에 있다. 캔버스 문제가 없었더라도 심사하는 쪽은 거기까지 도달하지 못했을 것이다.
그래서 대상이 바뀌었다
앱을 심사에 낼 수 없다면 낼 수 있는 것이 있어야 한다. 로그인 없이 볼 수 있고, HTML로 된 내용이 있고, 우리 것인 페이지. 마케팅 홈페이지다.
홈페이지는 레포에 있었다. 파일도 있고 스타일도 있고 문구도 다듬어져 있었다.
배포된 적은 한 번도 없었다.
도메인의 루트를 열어 보니 도메인 등록기관의 기본 안내 페이지가 나왔다. 아무도 그 주소를 열어 본 적이 없었던 것이다. 팀이 작으면 「당연히 되어 있겠지」가 아무 근거 없이 성립한다.
하나 더 딸려 나온 것
홈페이지를 살펴보다가 문의용 메일 주소가 눈에 띄었다. 예전 도메인의 주소였고, 그 도메인은 이미 우리 손을 떠나 파킹 상태였다. 메일 수신 설정도 없었다.
즉 홈페이지에 적힌 연락처로 보낸 메일은 아무 데도 도착하지 않는다. 반송되는지조차 확인할 수 없다. 문의가 통째로 사라지는 자리였다.
교정하면서 레포 전체를 훑었더니 같은 옛 도메인이 일곱 군데에 남아 있었다. 처음 눈으로 훑었을 때는 네 군데로 봤는데, 「이 문자열은 이제 없어야 한다」를 기계로 검사하니 구조화 데이터와 메일 링크 안에 숨어 있던 것들이 더 나왔다.
남은 판단
렌더링 방식은 성능이나 그래픽 표현력으로만 고르는 것이 아니다. 그 화면을 사람 말고 무엇이 읽어야 하는가가 함께 걸린다. 검색엔진, 링크 미리보기, 광고 심사, 접근성 도구가 전부 여기에 딸려 온다.
앱 자체는 CanvasKit으로 두기로 했다. 로그인 뒤의 화면들이라 검색될 필요가 없고, 그 대가로 얻는 렌더링 일관성이 크다. 대신 바깥에서 읽혀야 하는 것 — 홈페이지, 소개, 지금 쓰고 있는 이 개발 기록 — 은 전부 평범한 정적 HTML로 만든다.
경계를 어디에 그을지의 문제였지, 어느 한쪽이 옳은 문제가 아니었다.
이 글을 고쳤다
처음 쓴 판에서는 제목을 「Flutter Web 앱은 광고 심사를 받을 수 없었다」로 달았다. 검증을 받고 고쳤다.
캔버스 텍스트가 색인되지 않는다는 부분은 맞았지만, 거기서 「그러므로 심사가 불가능하다」로 건너뛴 것이 틀렸다. 검색 크롤러의 동작을 근거로 광고 심사 시스템의 판단을 단정할 수 없고, Flutter를 막는 정책도 없다. 실제로 있었던 일은 그보다 좁다 — 우리 구조에서는 심사가 평가할 공개 콘텐츠를 내놓지 못했다.
측정값이 맞아도 거기서 끌어내는 결론은 한 걸음 더 나갈 수 있다. 이 글이 그 사례가 됐다.