iOS·Android 공용 공유 링크 하나와 버튼별 클릭 집계
아이폰은 App Store, 안드로이드는 Google Play로 보내고, 카카오톡·X 미리보기에는 OG 카드를 주며, 어느 공유 버튼에서 왔는지 기록하는 PHP 스마트 링크 설계.
앱 안에서 "친구에게 알려 주기"로 공유하면 문구에 링크가 들어가야 하는데, 받는 사람이 아이폰인지 안드로이드인지 PC인지는 알 수 없습니다. 이 글은 그 문제를 위해 쓰고 있는 "스마트 링크"의 설계입니다. 앱마다 우리 서버의 URL 하나를 두고, 아이폰은 App Store로, 안드로이드는 Google Play로 보내고, 나머지에는 버튼 두 개짜리 페이지를 보여 주며, 각 클릭이 어느 공유 버튼에서 왔는지 기록합니다. 공유 호스팅에 올린 PHP 파일 하나이고, 앱 목록과 랜딩 페이지 HTML까지 합쳐도 300줄이 안 됩니다.
아래 내용은 코드가 실제로 하는 일입니다. 클릭 수는 싣지 않았습니다. 대신 구조와 엣지 케이스, 그리고 이 숫자를 믿기 전에 무엇을 확인해야 하는지를 적었습니다.
왜 스토어 URL 두 개가 아니라 링크 하나인가
공유 기능의 첫 버전은 보통 이렇게 생겼습니다.
오늘의 운세 순위가 나왔어요! #오늘의운세
https://apps.apple.com/app/id…
https://play.google.com/store/apps/details?id=…
문제가 세 가지 있습니다.
- 카드가 되는 건 첫 URL뿐. X와 LINE은 첫 번째 링크로만 미리보기를 만듭니다. Play 링크는 카드 아래에 긴 주소 그대로 남습니다.
- 글자 수를 잡아먹고 스팸처럼 보입니다.
- 셀 수가 없습니다. Firebase Analytics 같은 앱 내 SDK는 이미 설치한 사람만 봅니다. 정말 세고 싶은 건 공유를 보고 링크를 누른 사람인데, 그 사람은 앱 내 분석에 잡히지 않습니다.
그래서 공유 문구에는 어느 버튼에서 나왔는지 꼬리표를 단, 우리 도메인의 링크 하나만 넣습니다.
https://example.com/app/<app-id>/?s=card
플러터 앱 쪽은 한 줄짜리 헬퍼입니다. 공유 링크에 apps.apple.com이나 play.google.com이 절대 들어가지 않는지 단위 테스트로 확인합니다.
static const _base = 'https://example.com/app/<app-id>/';
static String url(String src) => '$_base?s=$src';
해시태그는 링크 앞에 둡니다. 미리보기 카드는 첫 URL로 만들어지니, 링크를 맨 끝에 두면 태그가 카드에 밀려 잘리지 않습니다.
?s= 로 유입 버튼 구분하기
공유 버튼마다 다른 값을 넘깁니다. 저희 앱들에서는 예를 들어 rank(순위 화면), card(개별 결과 카드), result, saved(기록 화면에서), score(게임 오버 화면), board, photo, code, invite를 씁니다.
특이한 값이 하나 있는데, img입니다. 앱 두 개는 공유 이미지 아래쪽에 스마트 링크를 워터마크처럼 글자로 찍어 둡니다(https://는 생략). s=img 클릭은 누군가 그림 속 주소를 보고 직접 치거나 복사했다는 뜻입니다. 누를 수 있는 링크와는 다른 경로라서, 이렇게 해 두지 않으면 보이지 않습니다.
서버에서는 이 값을 저장해도, URL에 넣어도 안전하도록 정규화합니다.
$src = preg_replace('/[^a-z0-9_]/', '', strtolower((string)($_GET['s'] ?? '')));
if ($src === '') $src = 'unknown';
(string)은 ?s[]=x 대비입니다. [a-z0-9_] 밖의 문자는 버립니다. 꼬리표가 없으면 unknown으로 두어, 사라지지 않고 집계에 드러나게 합니다.
요청 흐름
앱마다 폴더에 앱 ID를 정하고 공통 파일을 불러오는 두 줄짜리 index.php가 있고, 나머지는 모두 파일 하나에 있습니다. 앱 목록에는 앱마다 App Store 숫자 ID(없으면 null), Play 패키지명(없으면 null), 랜딩 페이지 언어, 이름, 설명, OG 이미지가 들어 있습니다. 지금은 10개가 등록돼 있고, 두 스토어 모두 있는 앱이 6개, 안드로이드 전용이 4개입니다. 그중 하나는 iOS 출시를 기다리는 중이라 ID를 나중에 채웁니다.
등록되지 않은 앱 ID는 일반 텍스트 404를 받습니다. 운영상 이게 의미하는 바가 있습니다. 공유 링크를 내보내는 앱 버전을 출시하기 전에 서버 쪽 등록을 먼저 배포해야 합니다. 실제로 한 앱은 공유 URL 상수 옆에 바로 그 내용의 TODO를 달아 두었습니다.
흐름은 이렇습니다.
- 클라이언트 분류: 크롤러인지, 그다음 플랫폼
- 스토어 URL 만들기: 캠페인 꼬리표 포함
- 클릭 기록: 사람만. 기록 실패가 리다이렉트를 막지 않게
- 사람은 302로 리다이렉트: 해당 플랫폼 스토어에 앱이 있을 때
- 나머지는 랜딩 페이지: 크롤러, PC, 판별 실패, 안드로이드 전용 앱을 연 아이폰
크롤러에는 리다이렉트 대신 HTML
function isCrawler(string $ua): bool {
if ($ua === '') return true; // UA 없는 요청은 사람으로 보지 않는다
return (bool)preg_match('~(bot|crawler|spider|preview|facebookexternalhit|twitterbot|slackbot|'
. 'discordbot|whatsapp|telegrambot|line\-?spider|linebot|skypeuripreview|embedly|'
. 'quora link preview|pinterest|redditbot|applebot|vkshare|googlebot|bingbot|yeti|daum|'
. 'petalbot|headlesschrome)~i', $ua);
}
미리보기 크롤러에 302를 주면 카드가 스토어 페이지로 만들어져서, 제목도 이미지도 스토어 것이 됩니다. 그래서 크롤러에는 앱 목록 내용으로 og:title, og:description, og:image, twitter:card를 채운 랜딩 페이지를 줍니다. 리다이렉트하지 않고 기록도 하지 않으므로, 미리보기를 가져갈 때마다 클릭 수가 부풀지 않습니다. 네이버의 yeti와 다음의 daum도 목록에 들어 있습니다.
플랫폼 판별
function platform(string $ua): string {
if (preg_match('~(iphone|ipad|ipod)~i', $ua)) return 'ios';
if (preg_match('~macintosh~i', $ua) && preg_match('~mobile/~i', $ua)) return 'ios'; // Mac 을 자칭하는 iPad
if (preg_match('~android~i', $ua)) return 'android';
return 'other';
}
iPadOS 13부터 아이패드는 Mac이라고 자칭할 수 있습니다. 둘째 줄은 그래도 UA에 Mobile/이 남아 있는 경우를 잡습니다. 판별에서 빠진 요청은 랜딩 페이지를 받는데, 버튼을 한 번 더 누르면 될 뿐 제대로 동작합니다.
캠페인 꼬리표가 붙은 스토어 URL
$play = 'https://play.google.com/store/apps/details?id=' . rawurlencode($pkg)
. '&referrer=' . rawurlencode('utm_source=sns_share&utm_medium=app&utm_campaign=' . $src);
$ios = 'https://apps.apple.com/app/id' . rawurlencode($iosId)
. '?ct=' . rawurlencode('sns_share_' . $src) . '&mt=8';
Play는 referrer를 설치 리퍼러로 넘겨줍니다. App Store의 ct는 제공자 토큰(pt)이 없으면 무시될 수 있습니다. 코드 주석에도 이 점을 분명히 적어 두었고, 그래서 실제 판단에는 자체 클릭 기록을 씁니다. 스토어 쪽 집계는 덤입니다.
기록이 리다이렉트를 망가뜨리지 않게
if (!$isCrawler) {
try {
if (!is_file($dbFile)) throw new RuntimeException('DB 설정 없음');
require_once $dbFile;
// 기록: 앱, src, platform, 리퍼러(255자까지), UA(255자까지), IP 해시, UTC 시각
} catch (Throwable $e) {
// 무시한다. 집계를 놓치는 편이 방문자를 잃는 것보다 낫다
}
}
중요한 점이 두 가지 있습니다.
- 없는 파일을
require하면 치명적 오류가 나고,catch (Throwable)로도 잡히지 않습니다. 그래서is_file로 먼저 확인합니다. - INSERT는 프리페어드 스테이트먼트로 하고, 실패는 모두 삼킵니다. 리다이렉트는 반드시 일어납니다.
원래 IP는 저장하지 않습니다. CDN의 클라이언트 IP 헤더, X-Forwarded-For, REMOTE_ADDR 중 먼저 있는 것에서 첫 주소를 꺼내, 고정 솔트를 붙인 MD5만 저장합니다. 유입 버튼별로 대략적인 순 방문자 수를 내는 데는 이걸로 충분합니다.
랜딩 페이지는 절대 캐시되면 안 된다
모두 같은 URL을 열지만 응답은 User-Agent에 따라 다릅니다. 크롤러가 받은 HTML이 공용 캐시에 박히면, 다음에 온 아이폰 사용자는 스토어로 가지 못하고 랜딩 페이지를 보게 됩니다. 코드 주석에는 이 호스트의 LiteSpeed가 Vary: User-Agent를 무시하고 URL만으로 캐시 키를 잡았다는 기록(2026년 11월 3일 확인)이 남아 있습니다. 그래서 페이지에는 이 헤더를 붙입니다.
header('Vary: User-Agent, Accept-Encoding');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
리다이렉트는 301이 아니라 302입니다. 301은 기본적으로 캐시될 수 있고, 실제로 호스트 CDN이 캐시된 301을 계속 내준 적이 있습니다.
코드가 처리하는 엣지 케이스
| 방문자 | 동작 | 기록 |
|---|---|---|
| 아이폰, App Store에 있는 앱 | ct를 달아 App Store로 302 |
O (ios) |
| 안드로이드 | referrer를 달아 Play로 302 |
O (android) |
| 아이폰, 안드로이드 전용 앱 | Play 버튼만 있는 랜딩 페이지(쓸 수 없는 App Store 링크는 안 보임) | O (ios) |
| PC 브라우저 | 버튼 두 개짜리 랜딩 페이지 | O (other) |
| 미리보기 크롤러(X, LINE, Slack 등) | OG 태그가 든 랜딩 페이지, 리다이렉트 없음 | X |
| User-Agent 없음 | 크롤러로 취급, 랜딩 페이지 | X |
| 등록되지 않은 앱 ID | 일반 텍스트 404 | X |
| DB 장애, 설정 파일 없음 | 그래도 리다이렉트 | X (조용히) |
인앱 브라우저용 처리는 따로 없습니다. 아이폰의 카카오톡, 인스타그램, X 인앱 브라우저는 아이폰 UA를 보내므로 사파리와 똑같이 App Store로 가는 302를 받습니다. 그 뒤 App Store 앱이 열리는지 웹 페이지가 뜨는지는 각 인앱 브라우저에 달려 있습니다.
숫자를 믿기 전에 확인할 것
코드는 단순하지만, 구멍은 코드가 깔고 있는 가정에 있습니다. 이 클릭 수로 보고서를 쓴다면 먼저 다음을 확인하겠습니다.
- 카카오톡 미리보기 수집기. 국내에서는 가장 중요한 경로인데, 크롤러 목록에 카카오 전용 토큰은 없습니다. 카카오톡이 미리보기를 가져갈 때 쓰는 UA에 목록의 토큰(
bot,facebookexternalhit등)이 들어 있는지는 로그로 확인해야 합니다. 들어 있지 않다면 미리보기 수집이other클릭으로 집계됩니다. 다만other는 어차피 OG 태그가 든 랜딩 페이지를 받으므로, 카드 자체는 정상으로 만들어집니다. - 크롤러 판정 오탐.
daum,pinterest,preview같은 짧은 토큰은 UA 어디에 있든 걸립니다. 어떤 앱의 인앱 브라우저가 UA에 자기 이름을 덧붙인다면, 그 앱에서 온 실제 사람이 크롤러로 취급되어 랜딩 페이지를 보고 집계에서도 빠집니다. 기록된 UA를 묶어 보고 빠진 앱이 없는지 봅니다. other로 빠지는 아이패드.other중 UA에Macintosh가 든 행을 셉니다. 진짜 Mac이거나Mobile/을 보내지 않은 아이패드입니다.- CDN이 어떤 IP 헤더를 붙이는가. 코드는 CDN의 클라이언트 IP 헤더를 먼저 믿고, 다음으로
X-Forwarded-For를 봅니다. CDN이 붙이지 않는 헤더는 클라이언트가 직접 보낼 수 있습니다. 대략적인 순 방문자 수로는 괜찮지만 무언가의 증거는 되지 못합니다. - 해시는 가명 처리이지 익명화가 아니다. 고정 솔트를 붙인 IPv4의 MD5는 2^32개를 전부 대입하면 되돌릴 수 있습니다. 더 강한 보호가 필요하면 매일 바뀌는 비밀 키로 HMAC을 쓰면 되고, 그래도 하루 안에서의 중복 제거는 됩니다.
- 302 자체가 캐시되지 않는가 — 확인했습니다 (2026-11-09). 코드는 no-store를 랜딩 페이지에만 붙이고, 리다이렉트 분기는 그 전에 끝납니다. 그래서 같은 테스트용 URL을 아이폰 → 안드로이드 → 아이폰 순서로 받아 봤습니다. 세 번 모두 맞는 스토어로 가는 302였고,
x-hcdn-cache-status: MISS와Cache-Control: no-cache, no-store, must-revalidate, max-age=0이 붙어 있었습니다. 코드가 보내지 않은 헤더를 서버 쪽에서 PHP 응답에 붙여 준 것이라, 이 환경에서 302는 캐시되지 않습니다. 그래도 호스트 기본값에 기대지 않도록 리다이렉트 분기에도 직접 붙일 계획입니다.
"버튼별·날짜별 클릭 수와 대략적인 순 방문자 수" 쿼리는 이렇습니다.
SELECT DATE(clicked_at) AS day, src, platform,
COUNT(*) AS clicks, COUNT(DISTINCT ip_hash) AS uniq
FROM clicks
WHERE app_id = ? AND clicked_at >= UTC_TIMESTAMP() - INTERVAL 30 DAY
GROUP BY day, src, platform
ORDER BY day DESC, clicks DESC;
실제로 쓸모 있는 건 비교입니다. 같은 앱의 rank와 card, 또는 img와 텍스트 링크 계열을 나란히 보면 어느 공유 버튼이 정말로 사람을 데려오는지 알 수 있습니다. 애초에 이 링크를 만든 이유가 그것이었습니다.
자주 묻는 질문
공유 문구에 App Store와 Google Play 링크를 둘 다 넣으면 안 되나요?
X나 LINE은 첫 번째 URL로만 미리보기 카드를 만들어서, 두 번째 스토어 링크는 긴 주소가 그대로 남고 글자 수를 잡아먹어 스팸처럼 보입니다. 앱이 없는 사람의 클릭도 셀 수 없습니다. 내 서버 링크 하나로 바꾸면 둘 다 해결됩니다.
스마트 링크는 Twitterbot 같은 크롤러도 스토어로 보내야 하나요?
아닙니다. 미리보기 크롤러에 302를 주면 카드가 스토어 페이지로 만들어져 우리 문구와 이미지가 사라집니다. 크롤러에는 OG 태그가 든 HTML을 주고, 리다이렉트는 사람에게만 합니다.
Vary: User-Agent로 캐시가 응답을 섞는 걸 막을 수 있나요?
저희가 확인한 LiteSpeed 환경에서는 막지 못했습니다. 캐시 키가 URL뿐이었기 때문입니다. 모두가 같은 URL로 다른 응답을 받으므로 랜딩 페이지에는 Cache-Control: no-store를 붙이고, 리다이렉트는 301이 아닌 302를 씁니다.