공모전 제안서를 써 보신 분은 이 장면을 아실 겁니다. 데이터 카탈로그의 목록을 훑고, "이 정도면 되겠는데" 하고 기능을 적습니다. 실제로 호출해 보기 전에.
제 경우 그 문장은 이랬습니다. "이동통신·신용카드·내비게이션 빅데이터로 관광지별 시간대별 방문객을 분석해 혼잡도를 산출하고, 최적 방문 시간대를 알려준다."
착상 자체는 한 줄로 설명됩니다. 관광 빅데이터로 혼잡도를 계산하고, 그 순서를 뒤집어서 추천합니다. 리뷰 많은 곳을 더 많이 보여주는 게 아니라, 한산하면서 괜찮은 곳을 먼저 보여줍니다. 오버투어리즘을 데이터로 완화한다는 얘기입니다.
3월 말에 제안서를 넣고 예비심사를 통과했습니다. 5월에 앱을 App Store에 출시했고, 8월 10일에 1차 심사 자료를 제출했습니다. 그 사이 4개월 반 동안 앱은 Apple 심사 거절 세 번과 "화면이 조용히 비는" 버그 열 번쯤을 지났고, 제안서에 적었던 기능 하나는 통째로 접었습니다.
접은 이유부터 씁니다.
약속한 데이터가 존재하지 않았습니다
구현에 들어가서 확인한 사실은 이렇습니다.
- 신용카드·내비게이션 데이터는 이 API 계열에 없습니다. 호출 건수 0건. 애초에 엔드포인트가 없었습니다.
- 방문자 데이터는 있습니다. 단 시군구 단위 월별입니다. 관광지 단위도 아니고 시간 단위도 아닙니다.
- 관광지 단위 데이터도 있습니다. 단 일별 집중률(%) 입니다. 시간대는 없습니다.
즉 "몇 시에 가면 한산한가"를 답할 데이터가 개방 API에 없습니다. 카탈로그의 이름만 보고 "있을 것"이라 가정한 게 틀렸습니다.
그런데 앱에는 이미 그 기능이 있었습니다. 상세 화면에 "혼잡도 추이" 차트가 있고, "최적 방문 시간"이 표시됐습니다. 데이터가 없는데 화면이 있었습니다.
값의 출처를 따라가 보니 배치 코드에 이렇게 있었습니다.
visitors_by_hour: Array.from({length: 24}, () => Math.random() * 100),
visitors_by_weekday: Array.from({length: 7}, () => Math.random() * 100),개발 초기에 UI를 먼저 만들려고 넣은 mock이었습니다. 실데이터로 교체하는 걸 잊었고, 그대로 출시됐습니다. 심사위원이 아무 관광지나 열면 난수 그래프를 "빅데이터 분석 결과"로 보게 되는 상태였습니다.
이게 이 프로젝트에서 가장 위험했던 순간입니다. 버그는 고치면 되는데, 이건 고칠 수 있는 종류가 아니었습니다. 데이터가 없으므로 기능이 존재할 수 없습니다.
여기서 당신이라면 어떻게 하시겠습니까. 그럴듯한 추정 모델을 급조해 차트를 살리시겠습니까, 아니면 제안서에서 기능을 빼고 감점을 감수하시겠습니까?
저는 세 갈래로 처리했습니다.
- 차트를 플래그로 껐습니다. 지우지 않고 게이트를 닫았습니다. 시간 단위 데이터가 언젠가 개방되면 다시 켭니다.
- 대신 일별 집중률의 최근 30일 평균을 상세 화면에 넣었습니다. 있는 데이터로 답할 수 있는 질문으로 바꾼 겁니다. "몇 시에 한산한가"는 못 답해도 "이 관광지가 평소 얼마나 붐비는가"는 답합니다.
- 제안서를 다시 썼습니다. 신용카드·내비게이션·소비 관련 서술을 전부 제거하고, 시간대 추천은 "현재 개방 API로 제공되지 않아 자체 모델로 준비 중"이라고 발전계획으로 옮겼습니다.
3번이 겁났습니다. 제안서에서 기능을 빼는 게 감점으로 보였습니다. 그런데 공모전 FAQ에 이런 문장이 있었습니다 — "기 제출한 제안서에서 변경사항이 있을 경우 반영하여 진행하시면 됩니다." 심사자료 제출 안내에도 "개발 완료한 최종 서비스의 설명을 반영하여 작성"하라고 쓰여 있었습니다.
주최 측은 제안서와 결과물이 달라지는 걸 예상하고 있었습니다. 난수를 그대로 두는 것보다 기능을 접고 사유를 쓰는 게 낫습니다. 다만 이 판단을 내리는 데 며칠이 걸렸습니다.
두 기관이 같은 지역에 다른 번호를 씁니다
지역특화 부문에 부산으로 신고했습니다. 가점이 붙고 지역 관광기관 특별상 트랙도 따로 있습니다. 문제는 심사위원이 앱을 열었을 때 "전국 서비스인데 부산이라고 신고했다"고 보이면 안 된다는 것이었습니다.
부산에서만 되는 기능이 필요했습니다. 가장 자연스러운 건 구·군 단위입니다. 같은 부산 안에서도 해운대는 붐비고 금정은 한산합니다. 그 격차를 보여주면 "광역 이동 없이 대안을 제시한다"는 서사가 완성됩니다.
데이터는 이미 있었습니다. 구·군별 집중률이 테이블에 적재돼 있었습니다. 그런데 구·군 단위 화면을 만들 수 없었습니다.
이유가 허탈합니다. 관광 API의 시군구 코드는 2자리이고, 집중률 데이터의 시군구 코드는 행정안전부 체계의 5자리입니다. 같은 해운대구인데 한쪽은 1, 다른 쪽은 26350입니다. 규칙으로 변환되지 않습니다. 관광 API의 2자리는 그 API 안에서만 유효한 자체 번호라서, 5자리와 이어 붙일 방법이 없습니다.
이걸 몰라서 몇 달간 구·군 단위를 못 만들고 있었습니다. 데이터가 없는 줄 알았는데, 데이터는 양쪽에 다 있고 이어붙일 열이 없었습니다.
해결은 차원 테이블 하나였습니다.
create table sigungu_dim (
region_code text, -- 광역 (관광 API 체계)
sigungu_code text, -- 2자리 (관광 API)
signgu_code text, -- 5자리 (행정안전부)
name_ko text, name_en text, name_ja text, name_zh text
);부산 16개 구·군을 시드했습니다. 이름은 관광 API의 지역코드 조회로 4개 언어를 받아 채웠습니다. 표를 한 번 만들자 구·군 필터와 구·군별 혼잡도 비교 카드가 이틀 만에 나왔습니다. 실측값이 금정구 45.0%에서 중구 70.1%까지 벌어졌고, 그 막대그래프가 결국 기능설명서에서 지역특화를 설명하는 유일한 근거가 됐습니다.
조회 함수는 security definer가 필요했습니다. 기준 테이블의 익명 조회 권한을 이미 회수해 둔 상태였기 때문입니다. 그리고 파라미터를 추가할 때 오버로드로 만들면 REST 계층이 모호성으로 실패합니다 — drop 후 기본값 있는 단일 시그니처로 재생성하는 게 맞습니다.
교훈이 유쾌하진 않습니다. 공공데이터를 두 계열 이상 섞을 때, 지역 코드가 같은 체계라고 가정하면 안 됩니다. 이름으로 조인하는 것도 안 됩니다("중구"가 여러 도시에 있습니다). 매핑 표를 손으로 만드는 게 정석이고, 그게 몇 달을 아낍니다.
추천 로직이 한국어에서만 작동했습니다
앱은 4개 언어를 지원합니다. 각 언어의 관광정보를 언어별 원본 API에서 따로 받아 옵니다. 기계 번역이 아니라는 게 차별점이었습니다.
영어로 앱을 열어 봤습니다. 목록은 나오는데 카드의 점수 배지가 전부 0이었습니다. 혼잡도 역순 정렬이 안 되고 있었습니다. 이 앱의 존재 이유가 혼잡도 역순 추천인데, 한국어에서만 작동하고 있었습니다.
원인: 관광 API는 같은 장소라도 언어별로 콘텐츠 ID가 다릅니다. 해운대의 국문 ID와 영문 ID가 서로 다른 값입니다. 혼잡도 점수 테이블은 국문 ID 기준으로만 쌓여 있었으므로, 영문 목록은 조인에 실패했습니다. 4,310건 중 231건만 매칭됐습니다.
좌표 근접(55m 이내)으로 이으면 절반쯤 복구된다는 걸 계산해 봤지만, 그건 대증요법입니다. 근본은 배치가 언어별 관광지에도 점수를 매기게 하는 것이었습니다. 배치 로직이 국문만 채점하고 있었다는 게 실체입니다. 전 로케일을 채점하도록 고치자 조인율이 100%가 됐습니다.
이 버그의 성질이 고약합니다. 개발자는 한국어로 앱을 씁니다. 그래서 4개월 동안 아무도 못 봤습니다. 다국어를 차별점으로 내세우는 서비스에서, 핵심 기능이 나머지 3개 언어에서 죽어 있었습니다. 같은 계열의 사고를 시뮬레이터가 언어 플래그를 무시한 건과 죽은 /ja 링크에서도 한 번씩 겪었습니다. 로케일은 제 반복 실패 지점입니다.
심사위원이 열 주소에 다른 앱이 있었습니다
제출 서류에 서비스 URL을 적습니다. 데이터 출처 표기가 게시된 페이지이기도 하고, 데이터 저장 승인 신청서에도 같은 주소를 명시했습니다.
제출 6일 전에 그 주소를 확인했습니다. 다른 앱의 페이지가 서빙되고 있었습니다. 소개·약관·개인정보·지원 4개 파일 전부. 며칠 전 배포에서 다른 앱 디렉터리의 내용이 잘못 올라간 것이었습니다(같은 사고를 전에도 썼습니다).
발견이 늦은 이유가 따로 있습니다. 이 도메인은 CDN이 비브라우저 요청에 챌린지 페이지를 줍니다. curl로 상태 코드를 보면 브라우저가 아니라서 403이 옵니다. HTTP 응답으로는 배포 성공을 판정할 수 없습니다. 정상일 때도 403이고 오염됐을 때도 403입니다.
그래서 검증 방법을 바꿨습니다. SSH로 서버 파일의 md5를 로컬과 대조합니다. 크기만 봐도 드러납니다 — 정상 페이지는 2779KB인데 잘못 올라간 스텁은 15KB였습니다.
ssh <host> 'cd ~/public_html/<app> && for f in *.html; do
echo "$f|$(stat -c%s $f)|$(md5sum $f | cut -d" " -f1)"; done'
# 로컬 md5 와 대조index.html |27531|f7370646663da6a3905be0d6e499aaf0
privacy.html|79397|fac1f82a1503d51d2ffd1c5a40745a95복구 후 50개 앱 디렉터리를 한 번에 훑어 나머지도 확인했습니다. 제출 당일 다시 대조했고 4개 파일 md5가 전부 일치했습니다.
교훈: 배포 성공 판정에 쓰는 신호가 실패를 구분하지 못하면, 그 검증은 검증이 아닙니다.
컴플라이언스 방향이 중간에 뒤집혔습니다
공고에 "서비스명·로고에 주최 기관 명칭 사용 금지"가 있었습니다. 규정을 넓게 해석해서, 앱과 사이트에서 기관명 표기 41곳을 전부 제거했습니다. 4개 언어 × 4개 파일을 일괄 치환했습니다.
두 달 뒤 공지 FAQ가 나왔습니다. 정리하면 이렇습니다.
- 데이터 출처 표기로서 기관명 명시는 필수
- API 서비스명 단독 표기는 지양
- 금지 대상은 서비스명·로고에 기관 명칭을 쓰거나 CI/BI 이미지를 무단 사용하는 것
즉 규정의 취지는 "주최 기관이 만든 서비스처럼 오인시키지 말라"였고, 출처 표기는 반대로 의무였습니다. 41곳을 지운 건 과했습니다. 되돌려야 했고, 이번엔 앱 설정에 데이터 출처 섹션을 새로 만들고 상세·이미지·지역 인사이트에 제공기관 표기를 넣었습니다. 스토어 메타데이터 4개 로케일도 같이 고쳤습니다.
두 번 일한 셈입니다. 규정을 좁게 지키는 것과 넓게 지키는 것 중 후자가 안전해 보였는데, 이 경우엔 넓게 해석한 쪽이 규정 위반에 가까워졌습니다.
제출 당일에 나온 함정 네 개
제출 마감은 6주 뒤였지만 접수 시작일에 냈습니다. 시작일에 낸 덕에 발견한 것들입니다.
첨부 파일이 용량 제한을 넘었습니다. 기능설명서는 PDF 10MB 이내였고 만들어 둔 파일이 10.41MB였습니다. 400KB 초과입니다. 원인을 찾으려고 PDF 안 이미지의 실제 배치 크기를 조사했더니, 무게가 앱 화면 캡처가 아니었습니다. 주최 측이 배포한 양식 파일의 배경 그래픽이 3999×2250으로 박혀 있었고 표지 한 장에 2.7MB였습니다.
2375KB 1320x2868 -> 1.39x3.03in DPI=948 <- 앱 캡처 (심사위원이 확대해 볼 대상)
2748KB 3999x2250 -> 13.33x7.5in DPI=300 <- 양식 배경 그래픽 (표지 한 장)그 7장만 다운샘플해서 6.82MB가 됐습니다. 심사위원이 확대해 볼 앱 캡처와 흐름도는 손대지 않았습니다. 내가 넣은 것보다 남이 준 템플릿이 무거웠습니다. 압축 대상을 고를 때 자기 자산부터 의심하는 습관이 여기선 틀렸습니다.
폼의 라디오 버튼이 엉뚱하게 선택돼 있었습니다. 테스트 계정 방식이 "ID 형태"로 선택돼 있었는데 실제로 만들어 둔 계정은 이메일 형태였습니다. 그대로 냈으면 심사위원 로그인이 실패하고, 규정상 그건 심사 제외 사유입니다. 화면을 눈으로 보기 전까지 몰랐습니다. 폼 검수는 텍스트 덤프가 아니라 스크린샷으로 해야 합니다 — 라디오와 체크박스의 상태는 텍스트에 안 나옵니다.
활용 API 체크가 6개였는데 하나는 안 쓰는 것이고 셋이 빠졌습니다. 특히 집중률 API가 빠져 있었습니다. 그게 없으면 첨부한 기능설명서(구·군 비교의 근거로 명시)와 제출 내역이 서로 어긋납니다. 반대로 안 쓰는 언어 서비스 하나는 체크돼 있었는데, 호출 이력이 0건인 API를 신고하면 검증에서 걸립니다.
인증키 두 칸에 같은 값을 넣으니 폼이 거부했습니다. "동일한 인증키 값이 존재합니다"가 떴습니다. 공공데이터포털은 인증키를 두 형태로 줍니다 — 원본(디코딩)과 URL 인코딩본입니다. 보통 +가 %2B로, =가 %3D로 바뀌어서 두 값이 다릅니다. 그래서 접수 폼이 칸을 둘로 나눠 두고, 같은 값이면 중복 입력으로 판정합니다.
그런데 우리 계정 키는 64자 16진수였습니다. URL 예약 문자가 하나도 없어서 인코딩해도 값이 변하지 않습니다.
from urllib.parse import quote
k = '<64자 hex>' # 0-9a-f 만
quote(k, safe='') == k # True — URL 예약문자가 없다
# 비교: Base64형이면
# 'abc+def/ghi==' -> 'abc%2Bdef%2Fghi%3D%3D' (두 값이 다르다)포털의 두 복사 버튼을 각각 눌러도 같은 값이 나옵니다. 우리 입력 오류가 아니고 키 형태 문제입니다.
여기서 키 재발급을 검토했는데, 하지 않은 게 맞았습니다. 주최 측은 제출한 인증키로 개발 기간 내 호출 건수를 조회합니다. 지금까지 34만 건 넘는 적재가 현재 키로 이뤄졌으므로, 새 키를 내면 그 키의 호출 이력은 0일 수 있습니다. 게다가 배치 크론 29개가 이 키로 서명하고 있어서 재발급 순간 운영 중인 파이프라인이 멈춥니다. 발급 정책상 다시 받아도 또 16진수일 것이고요.
인코딩 칸에 억지로 퍼센트 인코딩을 하거나 대문자로 바꿔서 두 값을 다르게 만드는 방법도 있었습니다. 폼은 통과합니다. 그리고 주최 측이 그 키로 호출 내역을 조회하면 아무것도 안 나옵니다. 검증을 통과하려고 검증의 근거를 버리는 짓입니다. 문의했더니 운영 측이 동일 값도 접수되도록 폼을 고쳐 줬습니다. 이건 우리가 고칠 문제가 아니었습니다.
자가진단 3개
지금 당신 프로젝트에 그대로 대입할 수 있는 것만 남깁니다.
Math.random()·mock·TODO·dummy를 전체 검색해 보세요. 그중 하나라도 출시 빌드가 읽는 경로에 있으면, 그건 화면에 이미 나가고 있습니다.- 배포 검증에 쓰는 신호가 실패와 성공을 구분합니까? 정상일 때도 403이면 그 403은 검증이 아닙니다.
- 개발자가 쓰지 않는 로케일로 앱을 한 번 열어 보세요. 목록만 나오는지 점수·정렬까지 나오는지 봅니다.
실패와 한계
정직하게 남깁니다.
- mock 난수가 출시 버전에 들어갔습니다. UI를 먼저 만들려고 넣은 임시값이 교체되지 않았고, 심사 대상 앱에 그대로 실렸습니다. 발견은 코드를 다시 읽다가 했습니다. 자동으로 잡아 주는 장치가 없었습니다. "임시값"에 마커를 남기고 릴리스 전에 검색하는 절차가 없다는 게 진짜 결함입니다.
- 다국어 결함을 4개월간 못 봤습니다. 개발자가 쓰는 언어에서만 정상이었습니다. 로케일별 스모크 테스트가 없습니다. 지금도 없습니다.
- 컴플라이언스에서 두 번 일했습니다. 규정을 넓게 해석해 41곳을 지웠다가 되돌렸습니다. 공지·FAQ가 나올 예정인 사안이면 확정 전까지 되돌리기 쉬운 방식으로 처리해야 했습니다.
- 신청 서류를 서명 없이 보냈습니다. 데이터 저장 승인 신청서를 작성하면서 체크리스트에 "서명·날인" 항목을 만들어 두고, 그 항목이 미완인 상태로 발송했습니다. 반려됐습니다. 체크리스트를 만드는 것과 보는 것은 다른 일입니다. 재제출까지 나흘이 걸렸고, 이 글을 쓰는 시점에도 승인 회신을 기다리고 있습니다.
- 실기기에서만 잡히는 버그가 있었습니다. 지도 SDK가 시뮬레이터에서 마커를 렌더하지 않고, AR은 카메라가 없어 아예 못 돌립니다. 그래서 지도 핀이 엉뚱한 곳에 뭉치는 버그를 실기기에서 처음 봤습니다. 근처 관광지 조회가 거리순이 아니라 점수순으로 200개를 자르고 있었고, 점수가 시군구 단위라 한 구가 200칸을 독식했습니다. 서울 동남부에서 열면 북부 핀만 나왔습니다. 심사위원이 지도 탭을 열면 바로 보일 문제였습니다.
- AR은 약속만큼 안 됩니다. 제안서에서 상위 50개 관광지에 AR 가이드를 제공한다고 썼습니다. 실제 앵커는 5곳입니다. 참조 이미지 없이 메타데이터만 늘리면 인식이 깨지므로 늘리지 않았고, 기능설명서 흐름도에서 제외했습니다. 기능 목록에만 남겼습니다.
- 시간대 추천은 결국 못 만들었습니다. 데이터가 없어서입니다. 자체 모델로 하겠다고 발전계획에 썼지만 아직 안 했습니다. 근거 없는 시간대 추정은 제공하지 않는다는 원칙만 지켰습니다.
Apple 심사는 세 번 거절됐습니다. 순서대로 (1) 스토어 문구에 두 언어가 섞임 (2) 연령 등급의 사용자 생성 콘텐츠 항목이 아니오인데 커뮤니티가 있음 + 그 커뮤니티가 빈 상태 (3) 차단 기능 부재. 셋 다 사유가 달랐고, 마지막 것만 새 빌드가 필요했습니다. 나머지는 메타데이터와 데이터 시드로 처리했습니다.
누적 적재는 347,315행입니다(4개 언어 관광정보 + 방문자수 + 집중률 + 수요강도·다양성). 에러 0으로 도는 상태에서 제출했습니다.
결론
4개월 반에서 제일 크게 남은 건 기술이 아니라 순서입니다. 제안서는 카탈로그를 보고 쓰는 문서가 아니라, 한 번 호출해 보고 쓰는 문서입니다. 저는 반대로 했고, 그 대가로 기능 하나를 접었습니다.
그리고 접는 판단이 생각보다 안전했습니다. 규정 문서를 읽어 보니 주최 측은 제안서와 결과물의 차이를 이미 전제하고 있었습니다. 위험한 건 기능을 빼는 게 아니라 없는 데이터를 있는 것처럼 그리는 쪽이었습니다.
지금 당신 저장소에서 Math.random(을 한 번 검색해 보세요. 결과가 UI 경로에 하나라도 걸리면, 댓글로든 어디로든 그 얘기를 듣고 싶습니다. 저만 그런 게 아닐 거라고 생각합니다.
앱: HiddenGem