어바웃피싱 단축링크 시스템 구축 가이드
go.aboutfishing.kr 로 선사별 단축링크를 만들고, 링크 하나로 앱·스토어·웹을 알아서 나눠 보내고, 몇 번 눌렸는지 세는 시스템을 만든다. 이 문서는 해당 작업을 처음 해 보는 사람이 위에서 아래로 그대로 따라 하면 되도록 썼다.
00시작 전에 읽을 것
이 문서를 어떻게 읽어야 하는지.
이 문서의 표시 규칙
| 표시 | 뜻 |
|---|---|
| 파란 「왜 하는가」 | 이 단계가 왜 필요한지. 건너뛰면 나중에 무엇이 깨지는지. |
| 초록 「여기까지 됐는지 확인」 | 다음 단계로 넘어가기 전 반드시 통과해야 하는 확인 절차. 명령어와 기대 결과가 같이 있다. |
| 주황 「주의」 | 순서를 틀리거나 값을 잘못 넣으면 바로 터지는 지점. |
| 빨강 「놓치면 바로 터지는 지점」 | 이 프로젝트에서 실제로 문제가 되는 함정. 여기를 빼고 만들면 동작하지 않는다. |
| 보라 「배경」 | 왜 이렇게 정했는지. 구현에 직접 필요하지는 않지만 판단할 때 쓴다. |
이 시스템에서 가장 자주 막히는 네 가지
순서대로 만들다 보면 아래 네 군데에서 멈춘다. 해당 장에 대응 방법이 있다.
| 증상 | 원인 | 해당 장 |
|---|---|---|
| 어드민 API가 전부 404 | @Get(':slug') 가 루트에 걸려 /admin/shortlinks 까지 단축링크로 해석한다 | 4장 |
| 카카오톡에서 링크를 눌러도 앱이 안 열림 | 카카오톡 인앱 브라우저는 앱 링크(Universal Link)를 처리하지 않는다 | 5장 |
| 앱 안에서 링크를 누르면 무한 반복 | 앱 웹뷰가 go 링크를 열면 → 서버가 다시 앱 열기 스크립트를 내려줌 → 앱이 또 열림 | 4·6장 |
| 앱으로 들어온 유입이 GA4에서 안 잡힘 | 앱으로 분기되는 순간 UTM 값이 사라진다 | 7장 |
0-1전체 그림과 용어
코드를 보기 전에 무엇을 만드는 것인지부터 맞춘다.
한 줄 요약
사업팀이 어드민에서 선사별로 짧은 주소를 하나씩 만든다. 그 주소를 선사에 주면 선사가 블로그·카톡·명함·현수막에 쓴다. 고객이 그 주소를 누르면 시스템이 고객 기기를 보고 앱·스토어·웹 중 맞는 곳으로 보낸다. 동시에 몇 번 눌렸는지 기록한다.
클릭 한 번에 일어나는 일
용어 사전
| 용어 | 뜻 |
|---|---|
| 슬러그(slug) | 단축링크 주소의 뒷부분. go.aboutfishing.kr/ab12cd 에서 굵은 부분. 링크 한 개를 가리키는 고유 이름이다. |
| 목적지 주소(target_url) | 고객을 최종적으로 보낼 실제 웹 주소. 예: 선박 상세 페이지. |
| OG 태그 | 카카오톡·페이스북에 링크를 붙여넣었을 때 뜨는 미리보기(제목·설명·썸네일)를 정하는 정보. HTML 머리말에 넣는다. |
| 딥링크 | 웹 주소를 눌렀을 때 브라우저가 아니라 앱이 열리게 하는 것. |
| Universal Links (iOS) / App Links (안드로이드) | 운영체제가 "이 도메인은 이 앱 것이 맞다"를 확인한 뒤 앱을 여는 방식. 확인용 파일을 서버에 올려야 한다(3장). |
| 커스텀 스킴 | aboutfishing:// 처럼 앱 전용으로 만든 주소 형식. 위 방식이 안 될 때 쓰는 보조 수단. |
| UTM | 주소 뒤에 붙이는 유입 경로 표시. GA4가 이 값을 읽어 "블로그에서 몇 명 왔다"를 집계한다. |
| 인앱 브라우저 | 카카오톡·인스타그램 안에 들어 있는 간이 브라우저. 일반 브라우저와 동작이 다르다(5장). |
| 봇(bot) | 사람이 아니라 미리보기를 만들려고 서버에 접속하는 프로그램. 클릭 수에 넣으면 안 된다. |
0-2착수 전에 확보할 값
이 표가 다 채워지기 전에는 3장 이후로 넘어가지 못한다. 값 하나가 비면 그 뒤 작업이 전부 막힌다.
| 항목 | 누가 | 어디서 가져오는가 |
|---|---|---|
| 서버 공인 IP | 인프라 | 단축링크를 받을 서버. 기존 API 서버와 같아도 된다 |
| 도메인 관리 권한 | 인프라 | aboutfishing.kr DNS 관리 패널 계정 |
| Apple Team ID | 앱 | Apple Developer → Membership → Team ID (10자리 영숫자) |
| iOS Bundle ID | 앱 | Xcode → 프로젝트 → General → Bundle Identifier |
| App Store 앱 ID | 앱 | App Store Connect → 앱 정보 → Apple ID (숫자) |
| Android Package Name | 앱 | android/app/build.gradle 의 applicationId |
| 앱 서명 SHA-256 지문 | 앱 | Play Console → 설정 → 앱 서명 → 앱 서명 키 인증서 |
| 커스텀 스킴 이름 | 앱 | 현재 앱에 이미 있으면 그 값. 없으면 aboutfishing 로 신규 지정 |
| 웹 기본 주소 | 기획 | www.aboutfishing.kr |
| 선박 상세 주소 규칙 | 기획 | 선박 ID 를 넣으면 어떤 주소가 되는지. 웹과 앱이 같은 규칙인지 확인 |
| 기본 OG 이미지 | 디자인 | 1200×630 px, 1MB 이하. 링크별 이미지가 없을 때 쓰는 기본값 |
0-3담당·순서·의존관계
누가 무엇을 하는지, 어떤 작업이 어떤 작업을 기다려야 하는지.
| 장 | 내용 | 담당 | 선행 조건 |
|---|---|---|---|
| 1 | 도메인·서버·SSL | 인프라 | 없음 — 여기부터 시작 |
| 2 | 데이터베이스 테이블 | 백엔드 | 없음 — 1장과 동시 진행 가능 |
| 3 | 앱 연결 파일 배포 | 앱 + 인프라 | 1장 SSL 완료 + 0-2 표의 앱 값 확보 |
| 4 | 리다이렉트 서버 | 백엔드 | 2장 |
| 5 | 카카오·인앱 대응 | 백엔드 | 4장 |
| 6 | 앱 코드 | 앱 | 3장 파일이 서버에 올라가 있어야 검증 통과 |
| 7 | 클릭 측정·GA4 | 백엔드 + 앱 | 4장, 6장 |
| 8 | QR 코드 | 백엔드 | 2장 |
| 9 | 어드민 API·화면 | 백엔드 + 프론트 | 2장 (화면은 API 명세만 있으면 병행 가능) |
| 10 | 보안·개인정보 | 백엔드 + 기획 | 4장 |
| 11–12 | 테스트·검증 | 전원 | 전 장 |
예상 공수
| 담당 | 공수 | 포함 범위 |
|---|---|---|
| 백엔드 | 5~6일 | 1·2·4·5·7·8·9·10장 — 리다이렉트 서버, 인앱 브라우저 분기, 보안 검증, 변경 이력, 일괄 생성 |
| 앱 | 2~3일 | 3·6장 — 앱 연결 파일 값 확보, 딥링크 수신, 앱 내부 요청 식별, UTM 전달 |
| 프론트 | 2일 | 9장 — 목록·등록·수정·통계·일괄 생성·QR 모달 |
01도메인·서버·SSL 준비
go.aboutfishing.kr 이라는 주소를 우리 서버로 연결하고, 자물쇠(https)를 건다.
인프라1-1. DNS 레코드 추가
도메인 관리 패널(가비아 등)에서 A 레코드를 하나 추가한다.
타입 : A
이름 : go
값 : 12.34.56.78 ← 0-2 표의 서버 공인 IP
TTL : 3600dig +short go.aboutfishing.kr
# 입력한 IP가 그대로 나오면 성공. 아무것도 안 나오면 아직 퍼지는 중 —
# 최대 1시간까지 기다린다. 30분 넘게 안 나오면 레코드를 다시 확인한다.
1-2. Nginx 설정
server {
listen 80;
server_name go.aboutfishing.kr;
# 앱 연결 확인 파일은 파일 그대로 내려준다 (3장에서 만든다)
location /.well-known/ {
root /var/www/go;
default_type application/json;
add_header Access-Control-Allow-Origin *;
# 아래 두 줄 중요 — 이 경로는 절대 리다이렉트되면 안 된다
try_files $uri =404;
}
# 나머지는 NestJS 로 넘긴다
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}X-Forwarded-Proto 를 빠뜨리기 쉽다. 이 값이 없으면 NestJS 가 요청을 http 로 인식해
나중에 만드는 링크 주소가 http://go.aboutfishing.kr/... 로 생성된다.
OG 미리보기와 앱 연결이 모두 이 주소를 쓰므로 반드시 넣는다.
sudo lsof -i :3001 로 비어 있는지 확인하고, 이미 쓰고 있으면 다른 번호를 고른다.
같은 NestJS 앱 안에 모듈로 넣어도 된다 — 그 경우 포트는 기존 것을 그대로 쓴다.
1-3. 활성화와 SSL 발급
sudo ln -s /etc/nginx/sites-available/go.aboutfishing.kr /etc/nginx/sites-enabled/
sudo nginx -t # syntax is ok / test is successful 두 줄이 나와야 한다
sudo systemctl reload nginx
sudo certbot --nginx -d go.aboutfishing.kr
certbot 이 "http 를 https 로 돌릴까요?" 라고 물으면 2번(Redirect)을 고른다.
1-4. 파일 폴더 만들기
sudo mkdir -p /var/www/go/.well-known
sudo chown -R www-data:www-data /var/www/go
curl -I https://go.aboutfishing.kr/
# HTTP/2 200 또는 404 가 나오면 성공 (NestJS가 아직 없으니 502도 정상)
# SSL 오류가 나면 certbot 을 다시 돌린다
curl -I http://go.aboutfishing.kr/
# 301 + location: https://... 가 나와야 한다
02데이터베이스
링크 정보를 담는 표 하나, 클릭 기록을 쌓는 표 하나를 만든다.
백엔드2-1. short_links — 링크 본체
CREATE TABLE short_links (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(32) COLLATE utf8mb4_bin NOT NULL,
vessel_id BIGINT, -- 선박 ID. 선사 전용 링크가 아니면 NULL
title VARCHAR(120), -- 어드민 목록에서 사람이 알아볼 이름
target_url VARCHAR(1000) NOT NULL,
og_title VARCHAR(255),
og_description VARCHAR(500),
og_image_url VARCHAR(1000),
utm_source VARCHAR(100),
utm_medium VARCHAR(100),
utm_campaign VARCHAR(100),
click_count INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
expires_at DATETIME, -- 만료일. NULL이면 무기한
memo VARCHAR(500), -- 운영 메모(어디에 배포했는지)
created_by BIGINT,
updated_by BIGINT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_slug (slug),
KEY idx_vessel (vessel_id),
KEY idx_active_created (is_active, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;aB12cd 와 Ab12CD 가 같은 값으로 취급되어, 슬러그를 영문 대소문자 섞어 만들면
"중복" 오류가 계속 난다. COLLATE utf8mb4_bin 을 붙여 대소문자를 구분하게 했다.
updated_at 에 DEFAULT CURRENT_TIMESTAMP 를 빼면
새로 만든 행의 수정일시가 NULL 로 들어간다. 어드민 목록에서 "최근 수정일" 이 빈칸으로 보인다.
- title — 어드민 목록에 슬러그(
ab12cd)만 있으면 사람이 무엇인지 못 알아본다. - expires_at — 행사용 링크는 끝나면 죽어야 한다. 사람이 일일이 끄는 구조면 반드시 방치된다.
- memo — "어느 선사 명함에 썼는지" 를 남겨야 나중에 지울지 판단할 수 있다.
- updated_by — 누가 목적지를 바꿨는지 추적. created_by 만으로는 사고 추적이 안 된다.
2-2. click_logs — 클릭 기록
CREATE TABLE click_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
link_id BIGINT NOT NULL,
slug VARCHAR(32) NOT NULL,
vessel_id BIGINT,
device_type ENUM('ios','android','pc','unknown') NOT NULL DEFAULT 'unknown',
channel ENUM('app','kakao','instagram','naver','browser','unknown') NOT NULL DEFAULT 'unknown',
referer VARCHAR(500),
user_agent VARCHAR(500),
ip_hash CHAR(64), -- 원본 IP가 아니라 해시값 (10장 참고)
clicked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_link_time (link_id, clicked_at),
KEY idx_clicked_at (clicked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;vessel_id 를 함께 적재한다. 두 곳의 기록 항목이 다르면 나중에 두 수치를 대조할 수 없다.
channel 칼럼을 따로 둔 이유. 카카오톡에서 왔는지 네이버 블로그에서 왔는지가 이 프로젝트의 핵심 질문이다. 기기 종류(ios/android/pc)만으로는 그 답이 나오지 않는다.
2-3. TypeORM 엔티티
@Entity('short_links')
export class ShortLink {
@PrimaryGeneratedColumn('increment', { type: 'bigint' }) id: number;
@Column({ length: 32, unique: true }) slug: string;
@Column({ name: 'vessel_id', nullable: true }) vesselId: number;
@Column({ length: 120, nullable: true }) title: string;
@Column({ name: 'target_url', length: 1000 }) targetUrl: string;
@Column({ name: 'og_title', nullable: true }) ogTitle: string;
@Column({ name: 'og_description', nullable: true }) ogDescription: string;
@Column({ name: 'og_image_url', nullable: true }) ogImageUrl: string;
@Column({ name: 'utm_source', nullable: true }) utmSource: string;
@Column({ name: 'utm_medium', nullable: true }) utmMedium: string;
@Column({ name: 'utm_campaign', nullable: true }) utmCampaign: string;
@Column({ name: 'click_count', default: 0 }) clickCount: number;
@Column({ name: 'is_active', default: true }) isActive: boolean;
@Column({ name: 'expires_at', nullable: true }) expiresAt: Date;
@Column({ nullable: true }) memo: string;
@Column({ name: 'created_by', nullable: true }) createdBy: number;
@Column({ name: 'updated_by', nullable: true }) updatedBy: number;
@CreateDateColumn({ name: 'created_at' }) createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date;
}mysql -u USER -p DB -e "SHOW CREATE TABLE short_links\G" | grep -i collate
# slug 줄에 utf8mb4_bin 이 보이면 성공
mysql -u USER -p DB -e "INSERT INTO short_links(slug,target_url) VALUES('aB12cd','https://www.aboutfishing.kr');
INSERT INTO short_links(slug,target_url) VALUES('Ab12CD','https://www.aboutfishing.kr');"
# 두 줄 다 들어가야 정상. 두 번째에서 Duplicate entry 가 나면 COLLATE 가 안 먹은 것이다.
# 확인 후 두 행은 지운다.
03앱 연결 파일 배포
"이 도메인은 어바웃피싱 앱 것이 맞다" 를 운영체제에 증명하는 파일 두 개를 서버에 올린다.
앱인프라3-1. iOS — apple-app-site-association
파일 이름에 확장자가 없다. .json 을 붙이면 안 된다.
{
"applinks": {
"details": [
{
"appIDs": [ "ABCD123456.kr.co.aboutfishing" ],
"components": [
{ "/": "/.well-known/*", "exclude": true },
{ "/": "/admin/*", "exclude": true },
{ "/": "/*" }
]
}
]
}
}| 값 | 채우는 방법 |
|---|---|
ABCD123456 | 0-2 표의 Apple Team ID |
kr.co.aboutfishing | 0-2 표의 iOS Bundle ID |
"appID" + "paths": ["/*"] 는 iOS 13 이전 문법이다. 지금도 동작은 하지만
/* 가 /.well-known/ 까지 앱으로 잡아채서 앱이 자기 검증 파일을 못 읽는 경우가 생긴다.
현재 문법인 appIDs + components 를 쓰고 제외 경로를 명시한다.
3-2. 안드로이드 — assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "kr.co.aboutfishing",
"sha256_cert_fingerprints": [
"AA:BB:CC:...:99", // 앱 서명 키 (Play Console)
"11:22:33:...:FF" // 업로드 키 (내부 테스트 빌드용)
]
}
}]3-3. 서빙 확인
curl -sI https://go.aboutfishing.kr/.well-known/apple-app-site-association | head -3
curl -sI https://go.aboutfishing.kr/.well-known/assetlinks.json | head -3
HTTP/2 200— 301·302 가 나오면 실패다. 운영체제는 리다이렉트를 따라가지 않는다.content-type: application/json- 본문이 JSON 원문 그대로 (HTML 오류 페이지가 아님)
curl -s https://app-site-association.cdn-apple.com/a/v1/go.aboutfishing.kr
여기에 옛 내용이 보이면 기다리는 수밖에 없다. 급하면 개발 빌드에서
Associated Domains 값을 applinks:go.aboutfishing.kr?mode=developer 로 바꾸면 CDN 을 건너뛴다.
04리다이렉트 서버
이 프로젝트의 심장. 짧은 주소를 받아 어디로 보낼지 정하는 부분이다.
백엔드4-1. 라우트 등록 순서 — 가장 먼저 볼 것
@Controller() + @Get(':slug') 는 루트 아래 모든 한 칸 경로를 잡는다.
그래서 /admin, /health, /favicon.ico 까지 전부 "슬러그" 로 해석되어
어드민 API 가 404 를 뱉는다. 실제로 구현하면 첫날 바로 막히는 지점이다.
해결은 두 가지다. 둘 중 하나만 하면 된다.
| 방법 | 내용 | 권장 |
|---|---|---|
| 서버를 나눈다 | 단축링크 전용 NestJS 앱을 3001 포트로 따로 띄우고, 어드민 API 는 기존 서버에 둔다. 도메인이 다르므로 충돌이 없다 | 권장 |
| 한 앱에 둔다 | 모듈 등록 순서를 AdminShortlinkModule → ShortlinkModule 로 하고, 슬러그 컨트롤러에 예약어 차단을 넣는다 | 차선 |
@Module({
imports: [TypeOrmModule.forFeature([ShortLink, ClickLog])],
controllers: [
AdminShortlinkController, // ← 반드시 먼저
ShortlinkController, // ← ':slug' 는 항상 마지막
],
providers: [ShortlinkService],
})
export class ShortlinkModule {}4-2. 슬러그 규칙
| 항목 | 규칙 | 이유 |
|---|---|---|
| 길이 | 자동 생성 6자, 직접 입력 3~24자 | 글자 31종 6자리 = 약 8.9억 가지. 현수막·명함에 들어갈 길이 |
| 사용 글자 | 23456789abcdefghjkmnpqrstuvwxyz | 0·O·1·l·i 를 뺐다. 전화로 불러주거나 손으로 적을 때 틀리지 않게 |
| 직접 입력 | 영문 소문자·숫자·하이픈만 | 대문자를 허용하면 인쇄물에서 대소문자를 틀린다 |
| 중복 | 생성 실패 시 최대 5회 재시도, 그래도 실패하면 오류 반환 | 무한 루프 방지 |
| 금지어 | admin, api, health, static, assets, www, app, qr, well-known, favicon.ico, robots.txt, sitemap.xml | 시스템 경로와 겹치면 서비스가 죽는다 |
const ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz';
export const RESERVED = new Set([
'admin','api','health','static','assets','www','app','qr',
'well-known','favicon.ico','robots.txt','sitemap.xml',
]);
export function randomSlug(len = 6): string {
while (true) {
let s = '';
while (s.length < len) {
// 256 을 31 로 나눈 나머지 때문에 앞 글자가 더 자주 나오는 것을 막는다
for (const b of crypto.randomBytes(len)) {
if (b >= 248) continue; // 31 × 8 = 248
s += ALPHABET[b % ALPHABET.length];
if (s.length === len) break;
}
}
if (!RESERVED.has(s)) return s; // 우연히 예약어가 나오면 다시 뽑는다
}
}
export function validateSlug(slug: string): string | null {
if (!/^[a-z0-9-]{3,24}$/.test(slug)) return '영문 소문자·숫자·하이픈 3~24자만 쓸 수 있습니다.';
if (RESERVED.has(slug)) return '사용할 수 없는 주소입니다.';
return null;
}4-3. 목적지 주소 만들기 (UTM 병합)
`${targetUrl}?${utm}`)을 쓰면 안 된다.
목적지에 이미 물음표가 있으면 ...?event=knto?utm_source=blog 가 되어 주소가 깨진다.
URL 객체로 합쳐야 한다.
// 목적지까지 그대로 넘겨야 하는 값 — 광고 클릭 식별자와 추가 UTM
const PASS_THROUGH = [
'gclid','wbraid','gbraid','dclid','fbclid','ttclid','msclkid',
'n_media','n_query','n_ad_group','n_ad','n_keyword','n_rank',
'utm_content','utm_term','utm_id',
];
buildTargetUrl(link: ShortLink, query?: Record<string, any>): string {
const u = new URL(link.targetUrl);
if (link.utmSource) u.searchParams.set('utm_source', link.utmSource);
if (link.utmMedium) u.searchParams.set('utm_medium', link.utmMedium);
if (link.utmCampaign) u.searchParams.set('utm_campaign', link.utmCampaign);
u.searchParams.set('af_slug', link.slug); // 어느 링크로 들어왔는지 표시
u.searchParams.set('utm_id', link.slug); // GA4 가 기본 수집하는 항목이라 설정 없이도 링크별 분해가 된다
// 목록에 있는 값만 넘긴다. 아무 값이나 넘기면 주소가 주입 통로가 된다
if (query) for (const k of PASS_THROUGH) {
const v = query[k];
if (typeof v === 'string' && v.length && v.length <= 200) u.searchParams.set(k, v);
}
return u.toString();
}4-4. 목적지 주소 검증 (보안)
go.aboutfishing.kr/xxxxxx 가 아무 사이트로나 사람을 보낼 수 있다.
어드민 계정이 한 번 털리면 우리 도메인이 피싱에 쓰인다. 단축링크 서비스의 대표적인 사고 유형이다.
const ALLOWED_HOSTS = [
'aboutfishing.kr', 'www.aboutfishing.kr', 'm.aboutfishing.kr',
'dev-platform.aboutfishing.kr',
];
export function assertSafeTargetUrl(raw: string) {
let u: URL;
try { u = new URL(raw); }
catch { throw new BadRequestException('주소 형식이 올바르지 않습니다.'); }
if (u.protocol !== 'https:')
throw new BadRequestException('https 주소만 등록할 수 있습니다.');
const ok = ALLOWED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith('.' + h));
if (!ok)
throw new BadRequestException('어바웃피싱 도메인 주소만 등록할 수 있습니다.');
}외부 도메인(예: 네이버 폼, 인스타 계정)으로도 보내야 한다면 허용 목록에 하나씩 추가한다. 목록 없이 전부 허용하는 방식은 쓰지 않는다.
4-5. 컨트롤러
@Controller()
export class ShortlinkController {
constructor(private readonly svc: ShortlinkService) {}
@Get(':slug')
async redirect(@Param('slug') slug: string, @Req() req: Request, @Res() res: Response) {
// 0. 예약어·정적 파일은 즉시 404
if (RESERVED.has(slug)) return res.status(404).send();
const link = await this.svc.findBySlug(slug);
const ua = String(req.headers['user-agent'] || '');
const env = detectEnv(ua); // 5장
// 1. 없거나 꺼졌거나 만료된 링크 → 안내 페이지 (빈 404가 아니다)
if (!link || !link.isActive || (link.expiresAt && link.expiresAt < new Date())) {
res.status(404).type('html');
return res.send(this.svc.renderNotFound());
}
// 2. 캐시 금지 — 안 하면 링크를 수정해도 옛 목적지로 간다
res.setHeader('Cache-Control', 'no-store, max-age=0');
res.setHeader('X-Robots-Tag', 'noindex');
// 3. 사람이 누른 것만 기록 (봇 제외)
if (!env.isBot) this.svc.logClick(link, req, env).catch(() => {});
// 4. 분기할 필요가 없는 경우는 곧장 목적지로 보낸다
// · 앱 안 웹뷰 → 다시 앱을 열려 하면 무한 반복이 된다
// · PC 브라우저 → 앱이 없으므로 중간 페이지를 거칠 이유가 없다.
// 302 로 보내면 원래 유입 경로(referer)도 그대로 전달되어 GA4 귀속이 살아난다
if (env.isOurApp || env.device === 'pc') {
return res.redirect(302, this.svc.buildTargetUrl(link, req.query));
}
// 5. 그 외에는 OG 태그 + 분기 스크립트가 담긴 HTML
res.type('html');
return res.send(this.svc.renderRedirectHtml(link, env));
}
}4-6. HTML 생성 — 이스케이프 처리
const esc = (s = '') => String(s)
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
// 자바스크립트 문자열 안에 넣을 때는 JSON.stringify 를 쓴다
const js = (s = '') => JSON.stringify(String(s));
renderRedirectHtml(link: ShortLink, env: Env): string {
const ogTitle = esc(link.ogTitle || '어바웃피싱 낚시배 예약');
const ogDesc = esc(link.ogDescription || '가까운 항구에서 출발하는 낚시배를 바로 예약하세요.');
const ogImage = esc(link.ogImageUrl || 'https://www.aboutfishing.kr/og-default.jpg');
const ogUrl = esc(`https://go.aboutfishing.kr/${link.slug}`);
const web = this.buildTargetUrl(link);
const appPath = link.vesselId ? `vessel/${link.vesselId}` : 'home';
// 앱으로 넘길 때도 UTM 을 잃지 않도록 목적지 전체를 딸려 보낸다 (7장)
const appQuery = '?to=' + encodeURIComponent(web);
...
}4-7. 분기 스크립트
(function(){
var WEB = "…목적지 주소…";
var SCHEME= "aboutfishing://vessel/123?to=…";
var INTENT= "intent://vessel/123?to=…#Intent;scheme=aboutfishing;"
+ "package=kr.co.aboutfishing;S.browser_fallback_url=…;end";
var IOS_STORE = "https://apps.apple.com/kr/app/id0000000000";
var ua = navigator.userAgent;
var isIOS = /iPhone|iPad|iPod/.test(ua);
var isAOS = /Android/.test(ua);
if (isAOS) {
// 안드로이드는 intent 한 방이면 끝. 앱 있으면 앱, 없으면 fallback 주소로 간다
location.replace(INTENT);
return;
}
if (isIOS) {
// 앱이 설치돼 있으면 Universal Link 가 이 스크립트 실행 전에 앱을 연다.
// 여기까지 왔다는 것은 앱이 없거나 Universal Link 가 막힌 환경이라는 뜻.
var t = setTimeout(function(){ location.replace(WEB); }, 1200);
function cancel(){ if (document.hidden) clearTimeout(t); }
document.addEventListener('visibilitychange', cancel);
window.addEventListener('pagehide', function(){ clearTimeout(t); });
location.href = SCHEME;
return;
}
location.replace(WEB); // PC
})();- 앱이 없을 때 App Store 로 바로 보내지 않는다. 웹으로 보내는 편이 전환이 높다 — 예약하러 온 사람에게 앱 설치를 먼저 시키면 이탈한다. 설치 유도는 웹 페이지 상단 배너로 한다.
blur이벤트는 iOS 에서 신뢰도가 낮다.visibilitychange와pagehide를 같이 쓴다.- 1500ms 타이머는 앱이 열려 백그라운드로 간 뒤 앱에서 돌아왔을 때 뒤늦게 실행되어 갑자기 스토어가 뜬다.
S.browser_fallback_url 을 반드시 넣는다.
이 값이 없으면 삼성 인터넷·일부 인앱 브라우저에서 앱 미설치 시 "페이지를 열 수 없음" 으로 끝난다.
그리고 그 값을 반드시 인코딩한다. intent 주소는
; 로 항목을 구분하는데,
목적지 주소에는 ? & 가 들어가고 UTM 때문에 다른 기호가 섞일 수 있다.
인코딩하지 않으면 주소 해석이 깨져 앱도 안 열리고 웹으로도 안 간다.
const intent =
`intent://${appPath}#Intent` +
`;scheme=${APP_SCHEME}` +
`;package=${APP_PACKAGE}` +
`;S.browser_fallback_url=${encodeURIComponent(web)}` +
`;end`;
try { } catch { } 로 감싸고, 오류가 나면 무조건 웹 목적지로 보낸다.
스크립트 한 줄이 실패하면 그 뒤가 전부 멈춰 이동 자체가 일어나지 않는다 — 고객은 "잠시만 기다려 주세요" 에 갇힌다.
스크립트는 </body> 직전에 둔다. <head> 에 두면 화면 요소를 찾지 못해 바로 이 상황이 된다.
location.href 대신 location.replace 를 쓰는 이유는 뒤로가기 때문이다.
href 를 쓰면 고객이 목적지에서 뒤로가기를 눌렀을 때 이 중간 페이지로 돌아오고, 스크립트가 다시 돌아 또 앞으로 간다. 뒤로가기가 먹지 않는다.
# 1) 링크 한 개를 직접 넣고
mysql -e "INSERT INTO short_links(slug,title,target_url,og_title)
VALUES('test01','테스트','https://www.aboutfishing.kr','테스트 링크');"
# 2) PC 브라우저처럼 요청 — OG 태그가 보여야 한다
curl -s https://go.aboutfishing.kr/test01 | grep 'og:'
# 3) 캐시 금지 헤더 확인
curl -sI https://go.aboutfishing.kr/test01 | grep -i cache-control
# cache-control: no-store, max-age=0
# 4) 없는 주소 — 빈 JSON 이 아니라 안내 HTML 이 떠야 한다
curl -s https://go.aboutfishing.kr/zzzzzz | head -5
# 5) 어드민 API 가 안 잡아먹히는지
curl -sI https://go.aboutfishing.kr/admin/shortlinks # 401 또는 200 (404 면 4-1 실패)
05카카오톡·인앱 브라우저 대응
국내 서비스에서는 여기가 유입의 대부분을 차지한다. 빼고 만들면 「카톡에서 안 열려요」 문의를 그대로 받는다.
백엔드5-1. 접속 환경 판별
const BOTS = [
'kakaotalk-scrap', 'facebookexternalhit', 'twitterbot', 'slackbot',
'telegrambot', 'discordbot', 'whatsapp', 'line-poker',
'googlebot', 'yeti', 'daumoa', 'bingbot', 'embedly', 'skypeuripreview',
];
export type Env = {
isBot: boolean; isOurApp: boolean;
device: 'ios'|'android'|'pc'|'unknown';
channel: 'app'|'kakao'|'instagram'|'naver'|'browser'|'unknown';
needsExternal: boolean; // 외부 브라우저로 빼야 하는 환경인가
};
export function detectEnv(uaRaw: string): Env {
const ua = (uaRaw || '').toLowerCase(); // ← 반드시 소문자로 비교
const isBot = BOTS.some(b => ua.includes(b));
const isOurApp = ua.includes('aboutfishingapp'); // 6장에서 앱이 붙여 보낸다
const device =
/iphone|ipad|ipod/.test(ua) ? 'ios'
: /android/.test(ua) ? 'android'
: /windows|macintosh|linux/.test(ua) ? 'pc' : 'unknown';
const channel =
isOurApp ? 'app'
: ua.includes('kakaotalk') ? 'kakao'
: ua.includes('instagram') ? 'instagram'
: ua.includes('naver') ? 'naver'
: device === 'unknown' ? 'unknown' : 'browser';
// 카카오·인스타·네이버 인앱은 앱 링크가 막혀 있다
const needsExternal = ['kakao','instagram','naver'].includes(channel);
return { isBot, isOurApp, device, channel, needsExternal };
}kakaotalk-scrap 이고 대소문자도 일정하지 않다.
ua.includes('Kakaotalk') 처럼 대소문자 그대로 비교하면 카카오 봇의 미리보기 수집까지 클릭 수에 들어간다.
링크를 채팅방에 붙여넣기만 해도 클릭 수가 오르므로 숫자를 믿을 수 없게 된다.
비교 전에 소문자로 바꾸고, 사람이 쓰는 카카오톡(kakaotalk)과 미리보기 봇(kakaotalk-scrap)을 구분한다.
5-2. 인앱 브라우저에서 앱 열기
채널별로 탈출 방법이 다르다.
| 환경 | 방법 |
|---|---|
| 카카오톡 iOS | kakaotalk://web/openExternal?url=… 로 Safari 를 띄운다. Safari 에서는 Universal Link 가 동작한다 |
| 카카오톡 안드로이드 | intent 주소가 그대로 먹는다. 그래도 안 되면 kakaotalk://web/openExternal |
| 인스타그램 안드로이드 | intent 주소 사용 |
| 인스타그램 iOS | 탈출 수단이 없다. 웹으로 보내고 "Safari 로 열기" 안내를 화면에 띄운다 |
var CH = "kakao"; // 서버가 채워 넣는다
var SELF = location.href;
if (CH === "kakao") {
if (isIOS) {
// Safari 로 이 주소를 다시 연다 → 그 다음은 4-7 의 일반 흐름
// _r=1 : 두 번째 요청이라는 표시. 서버가 이 값을 보고 클릭을 다시 세지 않는다
var again = SELF + (SELF.indexOf("?") < 0 ? "?" : "&") + "_r=1&_ch=kakao";
location.href = "kakaotalk://web/openExternal?url=" + encodeURIComponent(again);
return;
}
if (isAOS) { location.replace(INTENT); return; }
}
if (CH === "instagram" && isIOS) {
// 탈출 불가 — 웹으로 보내고 안내 배너를 띄운다
document.getElementById('openHint').style.display = 'block';
setTimeout(function(){ location.replace(WEB); }, 2500);
return;
}5-3. 대기 화면
<body>
<div class="wrap">
<img src="https://www.aboutfishing.kr/logo.svg" alt="어바웃피싱" width="120">
<p>잠시만 기다려 주세요</p>
<a id="manual" href="…목적지…">눌러도 이동하지 않으면 여기를 누르세요</a>
<div id="openHint" style="display:none">
오른쪽 위 ··· 을 눌러 <b>Safari 로 열기</b> 를 선택하면 앱으로 이동합니다.
</div>
</div>
</body>// 4-5 컨트롤러 3단계
const isRetry = req.query._r === '1';
if (!env.isBot && !isRetry) {
this.svc.logClick(link, req, env).catch(() => {});
}
- 카카오톡 나와의 채팅에 링크를 붙여넣는다 → 미리보기 썸네일·제목이 뜬다
- 그 링크를 누른다 → 앱이 열린다 (iOS 는 Safari 를 한 번 거친다)
- 앱을 지우고 다시 누른다 → 웹 상품 페이지가 열린다
- 미리보기만 뜨게 두고 클릭은 하지 않았을 때
click_logs에 행이 안 생긴다 - 카카오톡 iOS 에서 한 번 누르면
click_logs에 1행만 생긴다 (2행이면 재진입 차단이 안 된 것이다)
06앱 코드 (React Native 풀웹뷰)
앱이 링크를 받아 웹뷰를 옮기게 한다. 그리고 앱에서 나가는 요청에 표시를 붙인다.
앱6-1. iOS — Associated Domains
Xcode → 프로젝트 선택 → Signing & Capabilities → + Capability → Associated Domains
applinks:go.aboutfishing.kr
개발 중에는 Apple CDN 캐시를 건너뛰는 값을 함께 넣어 두면 검증이 빠르다. 배포 빌드에서는 지운다.
applinks:go.aboutfishing.kr?mode=developer
- (BOOL)application:(UIApplication *)application
continueUserActivity:(NSUserActivity *)userActivity
restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> *))restorationHandler {
return [RCTLinkingManager application:application
continueUserActivity:userActivity
restorationHandler:restorationHandler];
}
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url
options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
return [RCTLinkingManager application:application openURL:url options:options];
}openURL 메서드를 빠뜨리기 쉽다. 이것이 없으면 커스텀 스킴(aboutfishing://)으로
들어오는 경우 — 즉 Universal Link 가 막힌 인앱 브라우저에서 온 경우 — 앱이 링크를 못 받는다.
6-2. 안드로이드 — App Links
<!-- ① https 링크 (App Links) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="go.aboutfishing.kr" />
</intent-filter>
<!-- ② 커스텀 스킴 (인앱 브라우저 탈출용) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aboutfishing" />
</intent-filter>6-3. 앱 코드
import React, { useRef, useEffect, useCallback } from 'react';
import { Linking } from 'react-native';
import WebView from 'react-native-webview';
const WEB_BASE = 'https://www.aboutfishing.kr';
export default function App() {
const webRef = useRef<WebView>(null);
const go = useCallback((url: string) => {
webRef.current?.injectJavaScript(`location.replace(${JSON.stringify(url)}); true;`);
}, []);
const handle = useCallback((raw?: string | null) => {
if (!raw) return;
// ① 커스텀 스킴: aboutfishing://vessel/123?to=https%3A%2F%2F...
if (raw.startsWith('aboutfishing://')) {
const u = new URL(raw.replace('aboutfishing://', 'https://x/'));
const to = u.searchParams.get('to');
if (to) return go(to); // UTM 이 붙은 최종 주소 그대로
const id = u.pathname.replace('/vessel/', '');
return go(id ? `${WEB_BASE}/vessels/${id}` : WEB_BASE);
}
// ② Universal Link / App Links: https://go.aboutfishing.kr/ab12cd
if (raw.startsWith('https://go.aboutfishing.kr/')) {
// 그대로 웹뷰에 로드한다. 서버가 앱 요청임을 알아보고(6-4) 302 로 목적지까지 보내준다.
return go(raw);
}
}, [go]);
useEffect(() => {
Linking.getInitialURL().then(handle); // 앱이 꺼져 있던 경우
const sub = Linking.addEventListener('url', e => handle(e.url)); // 앱이 켜져 있던 경우
return () => sub.remove();
}, [handle]);
return (
<WebView
ref={webRef}
source={{ uri: WEB_BASE }}
style={{ flex: 1 }}
/* ↓ 6-4 — 서버가 앱 안 요청임을 구분하는 근거 */
applicationNameForUserAgent="AboutFishingApp/1.0"
originWhitelist={['https://*', 'aboutfishing://*']}
setSupportMultipleWindows={false}
/>
);
}6-4. 앱 안 요청 표시 — 무한 반복을 막는 핵심
applicationNameForUserAgent 한 줄로 웹뷰의 UA 끝에 AboutFishingApp/1.0 이 붙는다.
서버는 이것을 보고(5-1 의 isOurApp) 분기 없이 곧장 목적지로 302 한다(4-5 의 4단계).
웹뷰 UA 가 실제로 바뀌었는지 확인하는 방법.
# 앱 웹뷰에서 아래 주소를 열어 UA 문자열을 눈으로 본다
https://www.whatismybrowser.com/detect/what-is-my-user-agent
# 끝부분에 AboutFishingApp/1.0 이 보이면 성공
- 앱을 완전히 종료한 상태에서 카톡의 링크를 누른다 → 앱이 켜지며 해당 선박 페이지가 뜬다
- 앱이 켜져 있는 상태에서 누른다 → 앱이 앞으로 나오며 페이지가 바뀐다
- 앱 안에서 다른 곳에 있는 go 링크를 누른다 → 한 번에 목적지로 간다 (앱이 다시 열리지 않는다)
- 앱 안에서 뒤로가기를 누르면 중간 페이지를 건너뛰고 이전 화면으로 간다
07클릭 측정과 GA4
몇 번 눌렸는지, 어디서 눌렸는지를 남긴다. 그리고 그 값이 GA4 까지 이어지게 한다.
백엔드앱7-1. 클릭 기록
async logClick(link: ShortLink, req: Request, env: Env) {
const ua = String(req.headers['user-agent'] || '').slice(0, 500);
const ip = (req.headers['x-forwarded-for'] as string || req.ip || '').split(',')[0].trim();
await this.clickLogRepo.insert({
linkId: link.id,
slug: link.slug,
vesselId: link.vesselId,
deviceType: env.device,
channel: env.channel,
referer: String(req.headers.referer || '').slice(0, 500) || null,
userAgent: ua,
ipHash: hashIp(ip), // 10장
});
await this.shortLinkRepo.increment({ id: link.id }, 'clickCount', 1);
}app.set('trust proxy', 1) 을 main.ts 에 넣지 않으면
Nginx 뒤에 있는 NestJS 는 모든 접속자의 IP 를 127.0.0.1 로 본다.
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy', 1);
7-2. UTM 이 앱까지 따라가게 하기
aboutfishing://vessel/123 으로 바뀌면서 UTM 이 사라진다.
선사 링크로 들어온 고객의 상당수가 앱 사용자인데, 그 유입이 GA4 에서 "직접 유입" 으로 뭉뚱그려진다.
링크별 성과를 재려고 만든 시스템인데 정작 성과가 안 잡히는 상태가 된다.
해결은 세 군데를 같이 고치면 된다.
| 위치 | 할 일 |
|---|---|
| 서버 | 앱 스킴·intent 주소에 ?to={UTM이 붙은 최종 웹주소} 를 통째로 넣는다 (4-6) |
| 앱 | 받은 to 값을 그대로 웹뷰에 로드한다 (6-3 ①) |
| 웹 | 웹뷰 안에서 GA4 가 평소대로 UTM 을 읽는다 — 추가 작업 없음 |
7-2-1. GA4 쪽에서 해야 하는 설정
| 설정 | 이유 |
|---|---|
원치 않는 리퍼러에 go.aboutfishing.kr 등록GA4 관리 → 데이터 스트림 → 태그 설정 | 중간 페이지를 거치면 최종 도착지가 보는 유입 출처가 go.aboutfishing.kr 로 덮인다. 등록하지 않으면 UTM 이 빠진 링크의 유입이 전부 "우리 사이트에서 온 추천 트래픽" 으로 잡힌다 |
utm_id 로 링크 구분 | 선박 하나에 링크를 여러 개 만들면 캠페인 이름이 같아져 구분이 안 된다. 서버가 utm_id 에 슬러그를 넣어 보내므로(4-3) GA4 가 별도 설정 없이 링크 단위로 분해해 준다 |
| 맞춤 채널 그룹 「선사링크」 | blog · qr · card 같은 값은 GA4 기본 채널 규칙에 없어서 상당수가 "Unassigned" 로 떨어진다. 대행사 월간 리포트에 원인 불명 트래픽이 늘어나므로 미리 만들고 공유한다 |
go.aboutfishing.kr 에서 조회가 발생해
유입 출처가 그쪽으로 확정되고, 0.5초 만에 사라지는 페이지가 이탈로 잡힌다.
대신 「클릭수 대비 목적지 도달 수」 를 주간으로 비교해 중간 페이지에서 새는 양을 본다.
7-3. UTM 작성 규칙
blog, Blog, naver_blog 를 섞어 써서
GA4 에서 합산이 안 된다. 어드민 입력 폼에서 아래 값만 고르게 한다(자유 입력 금지).
| 항목 | 허용 값 | 예 |
|---|---|---|
| utm_source | 선택형: naver · kakao · instagram · youtube · offline · partner | naver |
| utm_medium | 선택형: blog · post · profile · qr · card · banner · dm | blog |
| utm_campaign | {선박코드}_{연월} 자동 생성 | v1023_202610 |
7-4. 어디까지 세는가 — 정의
| 지표 | 정의 |
|---|---|
| 클릭수 | 사람이 링크를 눌러 서버가 응답한 횟수. 미리보기 봇은 제외한다. 같은 사람이 두 번 누르면 2로 센다 |
| 순 클릭수 | 같은 날·같은 링크·같은 IP 해시를 1로 묶은 수. 카톡방에서 여러 번 누른 경우를 걸러낸다 |
| 앱 전환 | 클릭 중 앱으로 분기된 비율. 서버는 분기 시도까지만 알고 실제 앱이 열렸는지는 모른다 — 추정값으로 표기한다 |
| 예약 전환 | 이 링크를 거쳐 들어온 세션에서 발생한 예약 건수. GA4 에서 utm_id(=슬러그) 기준으로 본다. 귀속 기간(7일/30일)은 [기획 확정 필요] — 낚시배는 출조일 사전 예약이라 클릭에서 예약까지 며칠이 걸린다 |
| 도달률 | 목적지에 실제로 닿은 수 ÷ 클릭수. 중간 페이지에서 얼마나 새는지를 보는 유일한 지표다. 주간으로 본다 |
7-5. BigQuery 적재 (선택)
MySQL 만으로도 어드민 통계는 충분하다. 기간이 길어지고 다른 지표와 합쳐 볼 필요가 생기면 그때 붙인다.
this.bigquery.dataset('aboutfishing').table('shortlink_clicks').insert([{
link_id: link.id, slug: link.slug, vessel_id: link.vesselId,
device_type: env.device, channel: env.channel,
referer: req.headers.referer || null,
clicked_at: new Date().toISOString(),
}]).catch(() => {}); // 적재 실패가 리다이렉트를 막으면 안 된다08QR 코드
어드민에서 버튼을 누르면 QR 이미지가 내려받아진다.
백엔드8-1. 무엇으로 만드는가 — 외부 API 를 쓰지 않는다
| 방식 | 판단 |
|---|---|
npm qrcode (채택) | Node 용 QR 생성 라이브러리. MIT 라이선스, 외부 통신 없음, PNG·SVG·문자열 모두 출력. 주간 내려받기 수백만 건으로 사실상 표준이다 |
| QR Server · goQR 등 외부 API | 쓰지 않는다. 우리 링크 주소가 외부 서비스 로그에 남고, 무료 한도·중단 위험이 있으며, 어드민이 그 서비스의 가용성에 묶인다 |
| Google Chart API | 이미 종료됐다. 검색하면 아직 예제가 나오므로 주의 |
| 프론트에서 생성 | 미리보기용으로만 쓸 수 있다. 내려받는 파일은 서버가 만든다 — 인쇄물에 나가는 결과물이라 어디서 만들어도 같은 결과가 나와야 한다 |
| 항목 | 값 |
|---|---|
| 패키지 | qrcode (타입 정의 @types/qrcode) |
| 라이선스 | MIT — 상용 이용에 제약 없음 |
| 네트워크 | 사용하지 않음. 서버가 꺼져 있어도 로컬에서 같은 결과가 나온다 |
| 비용 | 없음 |
| 출력 | PNG(Buffer) · SVG(문자열) · Data URL |
8-2. 설치
npm install qrcode
npm install --save-dev @types/qrcode
import * as QRCode from 'qrcode';
@Get(':id/qr')
async qr(
@Param('id', ParseIntPipe) id: number,
@Query('format') format = 'png',
@Query('disposition') disposition = 'attachment', // inline 이면 미리보기용
@Query('size', new DefaultValuePipe(800), ParseIntPipe) size: number,
@Res() res: Response,
) {
const link = await this.svc.findById(id);
if (!link) throw new NotFoundException();
const url = `https://go.aboutfishing.kr/${link.slug}`;
const opt = {
errorCorrectionLevel: 'H' as const, // 30% 가려져도 읽힌다 — 인쇄물·로고 삽입 대비
margin: 2,
color: { dark: '#1A1A1A', light: '#FFFFFF' },
};
if (format === 'svg') {
const svg = await QRCode.toString(url, { ...opt, type: 'svg' });
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Content-Disposition', `attachment; filename="${link.slug}.svg"`);
return res.send(svg);
}
const buf = await QRCode.toBuffer(url, { ...opt, type: 'png', width: Math.min(size, 2000) });
res.setHeader('Content-Type', 'image/png');
if (disposition === 'inline') res.setHeader('Content-Disposition', 'inline');
else res.setHeader('Content-Disposition', `attachment; filename="${link.slug}.png"`);
return res.send(buf);
}- 오류 정정 수준 H — 기본값은 M(15%)이다. 현수막·명함은 접히고 긁히므로 H(30%)로 올린다. 가운데 로고를 넣을 여지도 생긴다.
- 기본 크기 800px — 400px 은 A4 인쇄에서 흐려진다.
- SVG 제공 — 현수막·대형 출력은 벡터가 필요하다. 디자이너가 PNG 를 키우면 계단 현상이 생긴다.
?disposition=inline 을 붙여 미리보기를 띄우고,
[내려받기] 를 누르면 attachment 로 받는다. 이 구분이 없으면 미리보기를 <img> 로 띄울 수 없다.
가운데 로고는
qrcode 가 해 주지 않는다. 필요하면 생성된 PNG 위에 sharp 로 로고를 합성한다.
로고를 덮는 면적은 전체의 20% 를 넘기지 않는다(정정 수준 H 기준). 1차 범위에 넣을지는 [기획 확정 필요].
09어드민 API
사업팀이 링크를 만들고 고치고 끄는 기능. 화면 설계는 Figma 「원링크 자체구축」 페이지에 따로 있다.
백엔드프론트9-1. API 목록
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /admin/shortlinks | 목록. 검색어·선박·상태·기간·정렬·페이지 |
| GET | /admin/shortlinks/:id | 단건 상세 |
| POST | /admin/shortlinks | 생성 |
| POST | /admin/shortlinks/bulk | 선박 여러 개를 골라 한 번에 생성 |
| POST | /admin/shortlinks/:id/clone | 복제 (행사용) |
| PATCH | /admin/shortlinks/:id | 수정 |
| PATCH | /admin/shortlinks/:id/active | 사용·중지 전환 |
| GET | /admin/shortlinks/:id/stats | 클릭 통계 (일별·기기별·채널별) |
| GET | /admin/shortlinks/:id/qr | QR 내려받기 (?format=png|svg&size=800) |
| GET | /admin/shortlinks/export.csv | 목록 CSV 내려받기 |
| GET | /admin/shortlinks/:id/history | 변경 이력 |
bulk(일괄 생성) · :id/active(상태만 바꾸는 전용 경로) · :id/history(변경 이력) 를 둔 이유.
일괄 생성이 없으면 선박 수백 척에 링크를 다는 데 사람이 며칠을 쓴다. 변경 이력이 없으면 "링크가 엉뚱한 데로 간다" 는 문의가 왔을 때 누가 언제 바꿨는지 알 수 없다.
삭제(DELETE) 는 의도적으로 넣지 않았다. 이미 인쇄된 QR 이 죽기 때문이다. 중지(active=false)만 제공한다.
9-2. 입력값 규격
export class CreateShortlinkDto {
@IsOptional() @IsInt() vesselId?: number;
@IsString() @Length(1, 120) title: string;
@IsOptional() @Matches(/^[a-z0-9-]{3,24}$/) slug?: string;
@IsUrl({ protocols: ['https'], require_protocol: true }) targetUrl: string;
@IsOptional() @Length(0, 40) ogTitle?: string; // 카카오 미리보기 제목 권장 상한
@IsOptional() @Length(0, 80) ogDescription?: string;
@IsOptional() @IsUrl() ogImageUrl?: string;
@IsOptional() @IsIn(['naver','kakao','instagram','youtube','offline','partner']) utmSource?: string;
@IsOptional() @IsIn(['blog','post','profile','qr','card','banner','dm']) utmMedium?: string;
@IsOptional() @Length(0, 100) utmCampaign?: string;
@IsOptional() @IsDateString() expiresAt?: string;
@IsOptional() @Length(0, 500) memo?: string;
}@IsUrl 만으로는 부족하다. 컨트롤러에서 반드시 4-4 의 assertSafeTargetUrl() 을 한 번 더 호출한다.
형식이 맞는 주소와 우리가 보내도 되는 주소는 다른 문제다.
9-3. CSV 내보내기
@Get('export.csv')
async exportCsv(@Query() q: ListQueryDto, @Res() res: Response) {
const rows = await this.svc.findAllForExport(q);
const head = ['링크명','선박','단축주소','목적지','유입경로','클릭수','상태','만료일','등록자','등록일'];
const cell = (v: any) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const body = rows.map(r => [
r.title, r.vesselName, `https://go.aboutfishing.kr/${r.slug}`, r.targetUrl,
[r.utmSource, r.utmMedium].filter(Boolean).join('/'),
r.clickCount, r.isActive ? '사용' : '중지',
r.expiresAt ?? '', r.createdByName, r.createdAt,
].map(cell).join(','));
const csv = '' + [head.map(cell).join(','), ...body].join('\r\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="shortlinks.csv"');
return res.send(csv);
}'' 를 맨 앞에 붙이지 않으면 엑셀에서 한글이 전부 깨진다(어바웃 형태).
줄바꿈도
\n 이 아니라 \r\n 을 쓴다.
9-4. 화면
화면 설계와 기능 상세 정의는 Figma 파일 「기획서_어바웃피싱 250912~ing」 → 페이지 원링크 자체구축 에 있다. 전 12화면이며, 그중 두 장은 어드민이 아니라 고객이 보는 화면이다.
| 화면 ID | 화면명 | 종류 | 주요 요소 |
|---|---|---|---|
| SL-01 | 단축링크 목록 | 어드민 | 검색 필터 5종 · Total · 표 13열 · 페이지 |
| SL-02 | 단축링크 등록 | 어드민 | 기본정보 · 목적지 · 미리보기(OG) · 유입경로 · 운영설정 |
| SL-03 | 단축링크 수정 | 어드민 | 등록과 동일 + 상단 정보 바 + 슬러그 잠금 + 변경 이력 |
| SL-04 | 클릭 통계 | 어드민 | 지표 4종 · 일별 추이 · 채널별 · 기기별 · 최근 20건 |
| SL-05 | 일괄 생성 | 어드민 | 선박 다중 선택 · 공통값 · 생성 미리보기 · 결과 표 |
| SL-06 | QR 내려받기 | 모달 | 미리보기 · 형식(PNG/SVG) · 크기 · 상태 안내 띠 |
| SL-07 | 사용 중지 확인 | 확인창 | 영향 안내 3줄(누적·최근 7일 클릭 포함) · 빨간 실행 |
| SL-08 | 변경 이력 | 모달 | 일시 · 변경자 · 행위 · 항목 · 변경 전후 |
| SL-09 | 이동 중 화면 | 고객 | 중간 페이지. 정상 / 인앱 안내 / 복귀 3상태 |
| SL-10 | 안내 화면 | 고객 | 중지 410 · 만료 410 · 없는 주소 404 · 일시 오류 500 |
| SL-11 | 작성 이탈 확인 | 확인창 | 바뀐 항목 표시 · [계속 작성] / [나가기] |
| SL-12 | 일괄 생성 확인 | 확인창 | 요약 3줄 · 되돌릴 수 없음 안내 · 중복 선박 수 |
9-5. SL-01 목록 13열
| 열 | 값 | 동작 |
|---|---|---|
| 순서 | 전체 기준 역순 번호 | 정렬을 바꾸면 이 번호도 따라 바뀐다 |
| 링크명 | title | 한 줄 말줄임 · 마우스를 올리면 전체 |
| 단축주소 | go…/{슬러그} | 누르면 전체 주소 복사 → 토스트 |
| 선박 | 선박명 | 미지정이면 — |
| 목적지 | 경로만 표시 | 한 줄 말줄임 · 전체는 툴팁 |
| 유입경로 | source/medium | 없으면 — |
| 클릭수 | 누적 클릭 (파란색) | 누르면 SL-04 · 0이어도 누를 수 있다 |
| QR | [받기] | SL-06 모달 |
| 상태 | 사용 / 중지 / 만료 | 누르면 SL-07 확인창 · 만료 건은 열리지 않는다 |
| 만료일 | 날짜 또는 — | — |
| 등록자 | 사람 이름 | 계정 ID 가 아니라 이름 |
| 등록일 | YYYY-MM-DD | — |
| 관리 | [통계] · [수정] | SL-04 · SL-03 |
10보안·개인정보·운영 규칙
이 부분을 빼고 열면 사고가 났을 때 되돌릴 방법이 없다.
백엔드기획10-1. 개인정보 — IP 처리
const SALT = process.env.CLICK_IP_SALT; // 환경변수. 코드에 적지 않는다
export function hashIp(ip: string): string | null {
if (!ip) return null;
// 날짜를 섞어 매일 다른 값이 되게 한다 → 장기 추적 불가, 당일 중복 제거는 가능
const day = new Date().toISOString().slice(0, 10);
return crypto.createHash('sha256').update(SALT + day + ip).digest('hex');
}| 항목 | 정한 값 |
|---|---|
| 수집 항목 | 접속 기기 종류, 접속 경로(referer), 브라우저 정보, 접속 IP의 해시값 |
| 이용 목적 | 링크별 유입 통계 집계, 중복 클릭 제외 |
| 보관 기간 | 원본 기록 90일. 이후 일별 집계만 남기고 원본 삭제 [기획 확정 필요] |
| 처리방침 반영 | 12차 개정 트랙에 포함 [기획 확정 필요] |
10-2. 권한
| 역할 | 목록 | 생성·수정 | 중지 | 통계 |
|---|---|---|---|---|
| 사업팀 | ○ | ○ | ○ | ○ |
| 마케팅 | ○ | ○ | × | ○ |
| CS | ○ | × | × | ○ |
| 개발 | ○ | ○ | ○ | ○ |
10-3. 변경 이력
CREATE TABLE short_link_histories (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
link_id BIGINT NOT NULL,
action ENUM('create','update','activate','deactivate') NOT NULL,
changes JSON, -- {"targetUrl":{"before":"…","after":"…"}}
admin_id BIGINT,
admin_name VARCHAR(60),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_link (link_id, created_at)
);10-4. 부하·남용 대비
| 상황 | 대응 |
|---|---|
| 한 링크에 클릭이 몰린다 | click_count 를 매번 UPDATE 하면 그 행에 잠금이 몰린다. 분당 1회 배치 집계로 바꾼다(1차 오픈은 즉시 반영으로 두고, 분당 1천 클릭을 넘으면 전환) |
| 없는 주소로 대량 요청 | 존재하지 않는 슬러그 요청은 IP 기준 분당 60회로 제한. 슬러그를 무작위로 찍어 남의 링크를 찾는 시도를 막는다 |
| 어드민 계정 유출 | 4-4 의 목적지 도메인 제한이 최후 방어선이다. 이것이 있으면 계정이 털려도 외부 피싱 사이트로는 못 보낸다 |
| 링크 추측 | 슬러그 6자 = 약 8.9억 가지. 비공개 링크는 8자 이상(약 8,500억)으로 만든다 |
10-5. 운영 규칙
- 인쇄물·현수막에 쓴 링크는 중지하지 않는다. 목적지만 바꾼다.
memo에 "○○선사 명함 500장" 처럼 적어 둔다. - 행사용 링크는 반드시
expires_at을 넣는다. 만료된 링크는 안내 페이지로 간다. - 선사가 바뀌어 링크를 옮길 때는 새로 만들지 말고 목적지만 수정한다. 새로 만들면 클릭 통계가 끊긴다.
- 목적지 주소가 살아 있는지 주 1회 자동 점검한다. 404 가 나면 담당자에게 알린다. [2차 과제]
11테스트 방법
무엇을 어떤 도구로 확인하는지.
백엔드앱QA11-1. 서버만 먼저 확인 (앱 없이)
# 아이폰인 척
curl -sA "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15" \
https://go.aboutfishing.kr/test01 | grep -o "aboutfishing://[^\"']*"
# 안드로이드인 척 — intent 주소와 fallback 이 보여야 한다
curl -sA "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36" \
https://go.aboutfishing.kr/test01 | grep -o "intent://[^\"']*"
# 카카오톡 인앱인 척
curl -sA "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) KAKAOTALK/10.5.0" \
https://go.aboutfishing.kr/test01 | grep -o "kakaotalk://[^\"']*"
# 우리 앱 웹뷰인 척 — 302 와 location 헤더가 나와야 한다 (HTML 이면 실패)
curl -sI -A "Mozilla/5.0 (iPhone) AboutFishingApp/1.0" https://go.aboutfishing.kr/test01
# 카카오 미리보기 봇인 척 — 응답은 오되 click_logs 에는 안 쌓여야 한다
curl -sA "kakaotalk-scrap/1.0" https://go.aboutfishing.kr/test01 > /dev/null
mysql -e "SELECT COUNT(*) FROM click_logs WHERE slug='test01';"
11-2. OG 미리보기 확인
| 도구 | 확인 내용 |
|---|---|
| 카카오톡 「나와의 채팅」 | 가장 확실하다. 다만 카카오는 한 번 읽은 링크의 미리보기를 캐시한다 — 수정 후 확인하려면 주소 뒤에 ?v=2 를 붙이거나 카카오 캐시 초기화 도구를 쓴다 |
| Facebook Sharing Debugger | 이미지 로딩 실패 원인까지 알려준다. OG 이미지가 안 뜰 때 여기부터 본다 |
| 네이버 블로그 글쓰기 | 블로그에 링크를 붙였을 때 카드 형태를 확인 |
11-3. 앱 연결 파일 검증
| 대상 | 방법 |
|---|---|
| iOS — 파일 자체 | AASA Validator 에 도메인을 넣는다 |
| iOS — Apple 캐시 | curl -s https://app-site-association.cdn-apple.com/a/v1/go.aboutfishing.kr |
| iOS — 기기 | 메모 앱에 링크를 적고 길게 눌러 "어바웃피싱에서 열기" 가 뜨는지 본다. Safari 주소창에 직접 입력하면 Universal Link 가 동작하지 않는다 — 실패로 오해하기 쉬운 지점 |
| 안드로이드 — 검증 상태 | adb shell pm get-app-links kr.co.aboutfishing → verified 가 보여야 한다 |
| 안드로이드 — 재검증 | adb shell pm verify-app-links --re-verify kr.co.aboutfishing |
| 안드로이드 — 열기 테스트 | adb shell am start -a android.intent.action.VIEW -d "https://go.aboutfishing.kr/test01" |
11-4. 실기기 테스트 표
배포 전에 아래 조합을 모두 돌린다. 기기 2대(iOS·안드로이드)와 앱 설치/미설치 두 상태가 필요하다.
| 기기 | 앱 | 누른 곳 | 기대 결과 |
|---|---|---|---|
| iOS | 설치 | 카카오톡 | Safari 가 잠깐 뜬 뒤 앱이 열리고 해당 선박 페이지 |
| iOS | 미설치 | 카카오톡 | 웹 상품 페이지 (App Store 로 튀지 않는다) |
| iOS | 설치 | 메모 앱 | 앱이 바로 열림 |
| iOS | 설치 | 어바웃피싱 앱 안 | 앱 안에서 페이지만 바뀜 (앱이 다시 열리지 않음) |
| 안드로이드 | 설치 | 카카오톡 | 앱이 열리고 해당 선박 페이지 |
| 안드로이드 | 미설치 | 카카오톡 | 웹 상품 페이지 |
| 안드로이드 | 설치 | 삼성 인터넷 | 앱이 열림 |
| PC | — | 크롬 | 웹 상품 페이지, 주소에 utm 값이 붙어 있음 |
| 모두 | — | QR 스캔 | 위와 동일하게 분기 |
11-5. 측정 확인
- GA4 → 보고서 → 실시간 → 링크를 누른 뒤 30초 안에 세션이 뜨는지 본다.
- 실시간 화면에서 「세션 소스/매체」 카드에
naver / blog가 잡히는지 본다. - 앱으로 분기된 경우에도 같은 값이 잡히는지 본다 — 안 잡히면 7-2 가 안 된 것이다.
SELECT channel, COUNT(*) FROM click_logs WHERE slug='test01' GROUP BY channel;로 채널 분류가 맞는지 본다.
12배포 전 검증 체크리스트
전 항목이 통과해야 선사에 링크를 배포한다.
서버·인프라
pm get-app-links 결과가 verifiedCache-Control: no-store 가 있다trust proxy 설정으로 접속 IP 가 실제 IP 로 잡힌다분기 동작
OG 미리보기
측정
gclid 를 붙여 눌렀을 때 목적지 주소에도 그대로 남아 있다보안
어드민
개인정보·문서
장애 대응
/health 가 200 을 돌려주고, 슬러그로 해석되지 않는다13API 계약
프론트와 백엔드가 같은 날 착수하려면 이 장이 먼저 확정돼야 한다. 여기 없는 값은 전부 추측이 되고, 통합할 때 한쪽을 되돌리게 된다.
백엔드프론트13-1. 공통 응답 규격
{
"items": [ { ... }, { ... } ],
"total": 128, // 필터 적용 후 건수 (Total 표시에 그대로 쓴다)
"page": 1, // 1부터 시작
"size": 20
}{
"id": 128,
"slug": "ab12cd",
"shortUrl": "https://go.aboutfishing.kr/ab12cd", // 서버가 조립해서 내려준다
"title": "스테이지테스트호 예약 — 네이버 블로그",
"vesselId": 1023,
"vesselName": "스테이지테스트호", // 조인해서 같이 내려준다
"targetUrl": "https://www.aboutfishing.kr/vessels/1023",
"ogTitle": "...", "ogDescription": "...", "ogImageUrl": "...",
"utmSource": "naver", "utmMedium": "blog", "utmCampaign": "v1023_202609",
"clickCount": 1284,
"status": "active", // active | inactive | expired ← 서버가 판정해서 내려준다
"expiresAt": "2026-12-31T23:59:59+09:00",
"memo": "...",
"createdBy": 7, "createdByName": "신다훈",
"updatedBy": 9, "updatedByName": "김채린",
"createdAt": "2026-09-18T14:02:11+09:00",
"updatedAt": "2026-09-20T16:41:03+09:00"
}status 를 서버가 내려주는 이유. DB 에는 is_active(참·거짓)와 expires_at(날짜) 두 개뿐이다.
"만료" 는 둘을 조합해 판단해야 하는데, 이 판단을 프론트가 하면 브라우저 시계 기준이 되어 서버와 결과가 달라진다.
중지이면서 만료인 경우도 생기는데(중지된 링크의 만료일이 지남) 그때는 중지가 우선이다 — 사람이 끈 것이 시스템 판정보다 앞선다.
같은 이유로
shortUrl · vesselName · createdByName 도 서버가 조립해서 내려준다. 프론트가 문자열을 이어 붙이면 도메인이 바뀔 때 화면마다 고쳐야 한다.
13-2. 페이지네이션 · 정렬 · 필터
| 파라미터 | 값 | 비고 |
|---|---|---|
page | 1부터. 기본 1 | 범위를 벗어나면 빈 배열 + total 은 그대로 |
size | 기본 20, 최대 100 | 초과 요청은 100으로 깎는다(오류 아님) |
sort | createdAt,desc 형식. 기본 createdAt,desc | 허용 필드: createdAt · updatedAt · clickCount · title · expiresAt. 그 외는 400 |
keyword + keywordType | type: title(기본) · slug · targetUrl · memo | 부분 일치. % _ 는 서버에서 이스케이프 |
vesselId | 단일 선택. none 이면 선박 미지정 건만 | — |
status | all(기본) · active · inactive · expired | 목록 상태 열과 같은 값 |
from · to | YYYY-MM-DD. 기준은 등록일 | 기본 최근 30일. 둘 다 없으면 전체 |
utmSource | 단일 선택 | — |
13-3. 오류 응답
{
"code": "SLUG_DUPLICATED",
"message": "이미 사용 중인 주소입니다.",
"field": "slug" // 특정 입력칸 문제일 때만. 없으면 화면 상단에 띄운다
}| 상태 | code | 화면 문구 |
|---|---|---|
| 400 | VALIDATION_FAILED | 서버 message 를 그대로 띄운다 |
| 400 | TARGET_URL_NOT_ALLOWED | 어바웃피싱 도메인 주소만 등록할 수 있습니다. |
| 400 | TARGET_URL_NOT_HTTPS | https 주소만 등록할 수 있습니다. |
| 400 | SLUG_RESERVED | 사용할 수 없는 주소입니다. |
| 400 | EXPIRES_AT_PAST | 오늘 이후 날짜를 선택해 주세요. |
| 401 | UNAUTHORIZED | 기존 어드민과 같게 처리 — 로그인 화면으로 보낸다 |
| 403 | FORBIDDEN | 권한이 없습니다. |
| 404 | NOT_FOUND | 삭제되었거나 없는 링크입니다. |
| 409 | SLUG_DUPLICATED | 이미 사용 중인 주소입니다. |
| 409 | STALE_UPDATE | 다른 사람이 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요. |
| 413 | BULK_LIMIT_EXCEEDED | 한 번에 100건까지 만들 수 있습니다. |
| 429 | TOO_MANY_REQUESTS | 잠시 후 다시 시도해 주세요. |
message 에 담아 내려주고 프론트는 그대로 띄운다.
프론트가 code 로 문구를 매핑하면 문구를 고칠 때마다 프론트를 배포해야 한다.
다만 어느 입력칸의 문제인지는 프론트만 알 수 있는 정보가 아니므로 field 로 같이 내려준다.
13-4. 동시 수정 충돌
두 사람이 같은 링크를 동시에 고치면 나중에 저장한 쪽이 앞선 수정을 소리 없이 덮는다. 목적지가 걸린 데이터라 그대로 두면 안 된다.
# 수정 요청에 마지막으로 읽은 수정 시각을 같이 보낸다
PATCH /admin/shortlinks/128
{ "targetUrl": "...", "ifUnmodifiedSince": "2026-09-20T16:41:03+09:00" }
# 서버의 updated_at 과 다르면 409 STALE_UPDATE
13-5. 엔드포인트별 계약
| 엔드포인트 | 요청 · 응답 · 유의점 |
|---|---|
GET /admin/shortlinks | 13-2 파라미터 → 13-1 목록 응답. clickCount 는 누적값이며 기간 필터의 영향을 받지 않는다 |
GET /admin/shortlinks/:id | 13-1 단건 응답. SL-03 이 이 API 로 값을 채운다. 중지·만료 건도 정상 조회된다 |
GET /admin/shortlinks/slug-check?slug= | { "available": true }. SL-02 의 [중복 확인] 용. 예약어·형식 위반도 false 로 내려주고 사유를 message 에 담는다 |
POST /admin/shortlinks | 9-2 DTO → 201 + 단건 응답(shortUrl 포함). 등록 완료 화면에서 주소를 바로 복사할 수 있어야 하므로 응답에 반드시 넣는다 |
POST /admin/shortlinks/bulk | 아래 13-5-1 |
PATCH /admin/shortlinks/:id | 바뀐 필드만 보낸다 + ifUnmodifiedSince. slug 는 받지 않는다(보내도 무시) |
PATCH /admin/shortlinks/:id/active | { "active": false } → 단건 응답. 이미 그 상태여도 200(멱등) |
GET /admin/shortlinks/:id/stats | 아래 13-5-2 |
GET /admin/shortlinks/:id/qr | ?format=png|svg&size=800&disposition=inline|attachment |
GET /admin/shortlinks/export.csv | 13-2 와 같은 필터를 그대로 받는다. 상한 1만 건, 초과 시 413 |
GET /admin/shortlinks/:id/history | page/size. 서버가 한글 항목명과 전후 값을 문자열로 만들어 내려준다 |
@Get(':id') 가 먼저 걸리면 export.csv 와 slug-check 를 id 로 해석해 400 이 난다.
export.csv → slug-check → bulk → :id/* → :id 순서로 등록한다.
13-5-1. 일괄 생성
// 요청
{
"vesselIds": [1023, 884, 771], // 최대 100
"titleTemplate": "{선박명} — 네이버 블로그",
"targetMode": "vesselDetail", // vesselDetail | fixed
"targetUrl": null, // fixed 일 때만
"utmSource": "naver", "utmMedium": "blog",
"expiresAt": null, "memo": "9월 선사 블로그 배포분",
"requestKey": "b3f1e0c2-..." // 같은 키로 두 번 오면 두 번째는 무시한다
}
// 응답 200 — 부분 성공을 허용한다
{
"requested": 3, "succeeded": 2, "failed": 1,
"results": [
{ "vesselId": 1023, "vesselName": "스테이지테스트호", "status": "ok",
"id": 129, "slug": "ab12cd", "shortUrl": "https://go.aboutfishing.kr/ab12cd" },
{ "vesselId": 884, "vesselName": "광어호", "status": "ok", "id": 130, "slug": "p7m3kq", "shortUrl": "..." },
{ "vesselId": 771, "vesselName": "참돔호", "status": "failed",
"code": "TARGET_URL_NOT_FOUND", "message": "선박 상세 주소를 만들 수 없습니다." }
]
}| 항목 | 규칙 |
|---|---|
| 트랜잭션 | 선박 1척 = 1트랜잭션. 전체를 한 덩어리로 묶지 않는다 — 100척 동안 잠금이 유지되고, 한 건 실패로 99건이 날아간다 |
| 중복 실행 | requestKey 로 막는다. SL-12 확인창의 버튼 잠금만으로는 부족하다 — 새로고침·네트워크 재시도로 두 번 들어온다 |
| 처리 방식 | 100건 기준 수 초 이내이므로 동기 응답으로 간다. 상한을 올릴 일이 생기면 그때 비동기로 바꾼다 |
| 이미 링크가 있는 선박 | 막지 않는다. 행사별로 여러 개 만드는 것이 정상이다. 대신 SL-05 목록과 SL-12 확인창에서 몇 곳인지 알려준다 |
| 변경 이력 | 만들어진 링크마다 create 로 남기고, changes 에 {"bulk": true, "requestKey": "..."} 를 함께 넣어 한 묶음임을 표시한다 |
| 결과 내려받기 | 결과 표를 CSV 로 받을 수 있어야 한다 — 이 파일이 곧 선사에 배포할 주소 목록이다 |
13-5-2. 통계
{
"range": { "from": "2026-08-23", "to": "2026-09-21", "days": 30 },
"summary": {
"totalClicks": 1284,
"uniqueClicks": 938, // 일별 유니크의 합 (해시가 매일 바뀌므로 기간 전체 유니크는 낼 수 없다)
"appBranchRate": 0.612, // 추정값
"dailyAverage": 42.8
},
"daily": [ { "date": "2026-08-23", "clicks": 22 }, ... ], // 0인 날도 0으로 채워 내려준다
"channel": [ { "key": "kakao", "label": "카카오톡", "clicks": 612 }, ... ],
"device": [ { "key": "android", "label": "안드로이드", "clicks": 548, "rate": 0.427 }, ... ],
"recent": [ { "clickedAt": "2026-09-21T14:22:07+09:00", "channel": "kakao", "device": "ios" }, ... ]
}| 항목 | 규칙 |
|---|---|
| 기간 기본값 | 최근 30일. 최대 90일(기록 보관 기간과 같다) |
| 빈 날짜 | 서버가 0으로 채워 내려준다. 프론트가 채우면 화면마다 결과가 달라진다 |
| 한글 라벨 | 서버가 label 로 같이 내려준다. unknown 은 「알 수 없음」 |
| 최근 20건 | 시각·채널·기기만. 접속 IP 해시와 브라우저 정보 원문은 화면에 내보내지 않는다 |
| 시각 기준 | 일별 구간은 KST(Asia/Seoul) 기준으로 자른다. 13-7 참고 |
| 90일 이전 | 선택 자체를 막는다. 기간 선택기에서 90일 이전 날짜는 고를 수 없다 |
13-6. 고객 화면(SL-09 · SL-10)의 소유권
/var/www/go/_t/redirect.html ← SL-09. 프론트가 만든다
/var/www/go/_t/notice.html ← SL-10. 프론트가 만든다
# 서버는 기동할 때 두 파일을 한 번 읽어 메모리에 올리고, 요청마다 자리표시자만 바꿔 내려준다
| 자리표시자 | 넣는 값 | 처리 |
|---|---|---|
__OG_TITLE__ __OG_DESC__ | 미리보기 제목·설명 | HTML 이스케이프 |
__OG_IMAGE__ __OG_URL__ | 이미지 주소 · 이 링크 주소 | HTML 이스케이프 |
__WEB__ | UTM 이 붙은 최종 목적지 | JSON.stringify |
__SCHEME__ __INTENT__ | 앱 스킴 · intent 주소 | JSON.stringify |
__CHANNEL__ | kakao · instagram · browser | JSON.stringify |
__STATE__ | (SL-10) inactive · expired · notfound · error | JSON.stringify |
| 규칙 | 내용 |
|---|---|
| 외부 요청 0건 | CSS·스크립트·로고를 전부 파일 안에 넣는다. 웹폰트도 부르지 않는다(시스템 글꼴). 0.5초 보이는 화면에서 네트워크를 한 번 더 타면 그만큼 흰 화면이 길어진다 |
| 스크립트 위치 | </body> 직전. <head> 에 두면 화면 요소를 찾지 못해 오류로 멈추고 이동 자체가 안 된다 |
| 전체 예외 처리 | 스크립트 전체를 try/catch 로 감싸고, 오류가 나면 무조건 웹 목적지로 보낸다 |
| 깜빡임 방지 | 본문을 투명하게 시작해 0.3초 뒤 CSS 로만 나타낸다(스크립트에 의존하지 않는다) |
| 테스트 | 개발 환경에서 ?_preview=inactive 같은 값으로 각 상태를 강제로 띄울 수 있게 한다 |
13-7. 시각 처리
| 구간 | 규칙 |
|---|---|
| 저장 | UTC 로 저장한다. TypeORM 설정에 타임존을 명시해 MySQL 세션과 어긋나지 않게 한다 |
| 집계 · 만료 판정 | KST(Asia/Seoul) 기준. 하루 경계는 09:00 UTC |
| 순 클릭 해시 | 해시에 섞는 날짜도 KST 기준 날짜를 쓴다 |
| API 응답 | ISO 8601 + 오프셋 (2026-09-21T14:22:07+09:00) |
| 화면 표기 | 날짜 YYYY-MM-DD · 일시 YYYY-MM-DD hh:mm · 최근 클릭 MM-DD hh:mm |
| 만료 경계 | expires_at 은 날짜+시각으로 저장한다. 날짜만 입력하면 그날 23:59:59 로 채운다 |
13-8. 장애 대응
| 항목 | 내용 |
|---|---|
| 헬스체크 | GET /health — 슬러그 라우트보다 먼저 등록한다. DB 연결까지 확인해서 200/503 |
| 웹서버 대체 응답 | Nginx 에 error_page 502 503 504 로 정적 안내 화면(SL-10 의 오류 상태)을 물려 둔다. 세 줄이면 전면 장애가 안내 화면으로 바뀐다 |
| 외부 감시 | 바깥에서 1분마다 /health 를 확인하는 감시 하나. 죽으면 구글챗으로 알린다 |
| 프로세스 | 기존 서버와 같은 방식(PM2 또는 systemd)으로 별도 단위를 만든다. 로그 경로·회전 설정 포함 |
| 롤백 | 이전 빌드로 되돌리는 절차를 배포 스크립트에 넣는다. 되돌리는 동안에도 링크가 죽으므로 위의 대체 응답이 먼저다 |
| 환경변수 검사 | CLICK_IP_SALT 등 필수값이 없으면 서버가 뜨지 않게 한다. 비어 있는 채로 조용히 도는 쪽이 더 나쁘다 |
14화면 공통 규칙
12화면에 공통으로 적용된다. 화면마다 다르게 만들면 같은 어드민 안에서 동작이 갈린다.
프론트기획14-1. 표기
| 대상 | 형식 | 예 |
|---|---|---|
| 날짜 | YYYY-MM-DD | 2026-09-21 |
| 일시 | YYYY-MM-DD hh:mm | 2026-09-21 14:30 |
| 최근 클릭 시각 | MM-DD hh:mm | 09-21 14:22 |
| 횟수 | 천 단위 쉼표 | 1,284 |
| 비율 | 소수 첫째 자리 + % | 61.2% |
| 값 없음 | — (빈칸으로 두지 않는다) | — |
| 0건 | 0 (— 와 구분한다) | 0 |
| 단축주소 (목록) | go…/{슬러그} | go…/ab12cd |
| 단축주소 (복사·상세) | 전체 주소 | https://go.aboutfishing.kr/ab12cd |
| 사람 | 실제 이름 (계정 ID 아님) | 신다훈 |
| 긴 텍스트 | 한 줄 말줄임 + 마우스오버 전체 | — |
14-2. 화면 상태 4종
모든 목록·상세 화면은 아래 네 가지를 갖는다. Figma 에는 정상 상태만 그려져 있으므로 나머지 셋은 이 규칙을 따른다.
| 상태 | 처리 |
|---|---|
| 불러오는 중 | 표는 뼈대 행 10개 · 상세는 영역별 회색 블록. 필터만 바꿨을 때는 표 영역만 바꾼다 |
| 내용 없음 (최초) | 「아직 만든 단축링크가 없습니다」 + [단축링크 등록] 버튼 |
| 검색 결과 없음 | 「조건에 맞는 링크가 없습니다」 + [조건 초기화] — 최초 상태와 문구를 구분한다 |
| 불러오기 실패 | 「목록을 불러오지 못했습니다」 + [다시 시도]. 화면 전체를 덮지 않고 그 영역만 |
14-3. 알림 방식
| 종류 | 쓰는 경우 |
|---|---|
| 토스트 | 성공했고 화면이 바뀌는 경우 — 저장·복사·내려받기. 2초 |
| 입력칸 아래 문구 | 특정 입력값 문제 (field 가 온 경우) |
| 화면 상단 띠 | 어느 칸인지 특정할 수 없는 오류 |
| 확인창 | 되돌릴 수 없거나 고객에게 영향이 가는 동작 — SL-07 · SL-11 · SL-12 |
14-4. 입력 검증 시점
- 입력하는 동안에는 검사하지 않는다. 글자를 치는 중에 빨간 문구가 뜨면 방해가 된다.
- 입력칸에서 빠져나갈 때(포커스 아웃) 한 번, 저장 누를 때 한 번 검사한다.
- 저장 시 여러 개가 걸리면 첫 번째 항목의 문구만 띄우고 그 칸으로 이동한다.
- 글자수 제한이 있는 칸(OG 제목 40 · 설명 80 · 메모 500)은 카운터를 항상 보여주고, 넘으면 더 입력되지 않게 막는다.
14-5. 이동과 뒤로가기
| 상황 | 동작 |
|---|---|
| 작성 중 이탈 (뒤로가기 포함) | SL-11 확인창. 바꾼 값이 없으면 확인창 없이 이동 |
| 탭 닫기 · 새로고침 | 브라우저 기본 경고에 맡긴다(문구를 바꿀 수 없다) |
| 모달이 열린 상태에서 뒤로가기 | 모달만 닫는다. 뒤 화면은 그대로 |
| 통계 → 뒤로가기 | 목록으로. 필터·정렬·페이지·스크롤 위치를 복원한다 |
| 모달 닫기 수단 | ✕ · [닫기] · ESC · 배경 클릭 모두 같은 동작. 단 SL-11 은 배경 클릭으로 닫지 않는다 |
14-6. 권한
15착수 전 확정 항목
프론트·백엔드 개발자에게 이 문서를 먼저 읽히고 나온 질문 중, 답이 없으면 코드를 쓸 수 없는 것들. 13·14장에서 답한 것은 뺐다.
15-1. 지금 답해야 하는 것
| No | 질문 | 막히는 이유 | 답할 사람 |
|---|---|---|---|
| Q-01 | 단축링크 서버를 별도로 띄우는가, 기존 NestJS 에 모듈로 넣는가 | 모듈 구조·인증·배포 단위·CORS 가 전부 여기서 갈린다. 이것 하나가 나머지 절반을 결정한다 | 개발리드 |
| Q-02 | 어드민 API 는 어느 도메인에 붙는가 | 분리하면 어드민(React)에서 교차 도메인 호출이 되어 CORS·쿠키 설정이 달라진다. 어드민 API 는 기존 도메인에 두는 쪽을 권한다 | 개발리드 |
| Q-03 | 어드민 인증은 기존 어드민의 무엇을 그대로 쓰는가 (세션인가 토큰인가) | 401 처리와 CSV·QR 내려받기 구현 방식이 갈린다. 토큰이면 주소 클릭만으로 파일을 받을 수 없다 | 백엔드 |
| Q-04 | 기존 예약 어드민에 이미 쓰는 응답·오류 규격이 있는가 | 있으면 13-1·13-3 을 그쪽에 맞춘다. 같은 어드민에서 규격이 둘로 갈리면 안 된다 | 백엔드 |
| Q-05 | 선박 검색·목록 API 가 이미 있는가 | SL-02 의 선박 선택과 SL-05 의 다중 선택이 둘 다 막힌다 | 백엔드 |
| Q-06 | vessel_id 는 어느 테이블의 무슨 ID 이고, 서버를 분리하면 그 DB 에 접근할 수 있는가 | 선박명을 붙여 내려줄 수 없으면 목록·CSV·일괄생성이 전부 반쪽이 된다 | 백엔드 |
| Q-07 | 선박 상세 주소 규칙 — 웹과 앱이 같은가 | 문서 안에서도 vessel/{id} 와 /vessels/{id} 로 갈려 있다. 목적지 자동 생성과 앱 딥링크가 이 값에 걸린다 | 프론트·앱 |
| Q-08 | 슬러그 직접 입력을 허용하는가 | SL-02 의 입력칸 존재 여부가 바뀐다. 허용하면 [중복 확인] API 가 필요하다 | 사업팀 |
| Q-09 | OG 이미지를 URL 로 받는가, 업로드 받는가 | 업로드면 저장소와 업로드 API 가 따로 필요하다. 기존 어드민 업로더를 재사용할 수 있는지 확인 | 백엔드·기획 |
| Q-10 | SL-09 · SL-10 의 디자인·문구를 누가 언제 주는가 | 13-6 에서 프론트가 만들기로 정했지만 시안·문구가 없다. 어느 직군 일정에도 잡혀 있지 않다 | CPO·디자인 |
| Q-11 | 기존 어드민의 공통 컴포넌트(표·버튼·모달·토스트)를 그대로 쓰는가 | Figma 목업은 기존 어드민 규격을 따라 그렸지만, 실제 컴포넌트와 다르면 어느 쪽을 따를지 정해야 한다 | 디자인·프론트 |
| Q-12 | 앱 관련 값 7종(Team ID · Bundle ID · App Store 앱 ID · Package Name · SHA-256 · 스킴) | 0-2 표가 아직 비어 있다. 3장·6장이 통째로 멈춘다 | 앱 |
| Q-13 | 개발·스테이징용 단축링크 도메인을 따로 두는가 | 없으면 앱 딥링크를 실기기에서 검증할 방법이 없다. 운영 도메인으로 테스트하면 실제 클릭 수가 오염된다 | 인프라 |
| Q-14 | 오픈 목표일과 9/9 통합앱 론칭의 간격 | 겹치면 기존 서버에 모듈을 붙이는 선택지가 사실상 불가능하다. 앱 변경은 재심사가 걸려 롤백이 안 된다 | CPO |
15-2. 개발자가 정하고 통보하면 되는 것
아래는 답을 기다리지 않고 진행한다. 반대 의견이 있을 때만 알려주면 된다.
- 슬러그 자동 생성에서 예약어가 나오면 다시 뽑기, 난수 분포 편향 보정
- DTO 에 DB 컬럼 길이와 같은 최대 길이 검증 추가, OG 이미지도 https 만 허용
- 목적지 주소에 계정 정보(
https://아이디:비밀번호@…)가 들어오면 거부 - CSV 셀이
=+-@로 시작하면 엑셀이 수식으로 실행하지 않게 처리 - 봇 판별 목록에 일반 크롤러·빈 브라우저 정보·명령행 도구 추가 — 클릭수가 지금 기준보다 줄어든다
- 접속 IP 를 헤더 직접 파싱이 아니라 프레임워크가 계산한 값으로 사용(위조 방지)
- 목적지 도메인 검증을 저장할 때뿐 아니라 이동시킬 때도 한 번 더
go.aboutfishing.kr/만 입력한 경우 www 홈으로 보내기,robots.txt는 전체 수집 거부- 리다이렉트 응답에
Vary: User-Agent추가 - 어드민 최소 지원 해상도 1280px, 그 아래는 가로 스크롤
- 모달은 주소를 갖지 않는다(뒤로가기로 닫기만 처리)
- BigQuery 적재와 링크 조회 캐시는 1차 범위에서 제외
15-3. 이번 반영으로 바뀐 공수
| 담당 | 공수 | 변동 사유 |
|---|---|---|
| 백엔드 | 8~9일 | API 계약 확정 · 일괄생성 트랜잭션과 중복 실행 방지 · 변경 이력 · 장애 대응 · 시각 처리 · 보안 보강 |
| 프론트 | 7~8일 | 어드민 8화면 5~6일 + SL-09 1.5일 + SL-10 0.5일. 기존 2일 산정에는 고객 화면 두 장이 빠져 있었다 |
| 앱 | 2.5~3.5일 | React Native 의 URL 처리 보완 필요 |
| 디자인 | 1일 | SL-09 · SL-10 시안 + 어드민 8화면 검수. 기존 산정에 디자인 항목이 없었다 |
| 기획 | 0.5일 | SL-09 · SL-10 문구 확정 |
| 착수 전 | 1~1.5일 | 15-1 의 14개 항목 확정 (기획·백엔드 합동). 이걸 건너뛰면 프론트가 추측으로 만들고 통합 때 되돌린다 |
A부록 — 자주 나는 오류
| 증상 | 원인과 조치 |
|---|---|
| 어드민 API 가 전부 404 | 4-1. 컨트롤러 등록 순서를 바꾸거나 서버를 분리한다 |
| 링크를 눌러도 앱이 안 열린다 (iOS) | ① .well-known 파일이 리다이렉트되는지 ② Apple CDN 캐시가 옛날 내용인지 ③ Safari 주소창에 직접 쳐 넣고 있는지 — 이 경우는 정상이다 |
| 링크를 눌러도 앱이 안 열린다 (안드로이드) | pm get-app-links 가 verified 가 아니면 SHA-256 지문이 틀렸거나 앱을 재설치하지 않았다 |
| 카톡에서만 안 열린다 | 5장 인앱 분기가 안 들어갔다 |
| 앱이 계속 켜졌다 꺼졌다 한다 | 6-4 의 UA 표시가 안 붙었다. 웹뷰 UA 를 먼저 확인한다 |
| 미리보기가 안 뜬다 | OG 이미지가 http 이거나 1MB 초과이거나 접근이 막혀 있다. Facebook Debugger 로 원인을 본다 |
| 미리보기가 예전 내용으로 뜬다 | 카카오 캐시. 주소 뒤에 ?v=2 를 붙여 확인하고, 실제로는 카카오 캐시 초기화 도구를 쓴다 |
| 클릭수가 실제보다 많다 | 봇 판별이 안 된다. UA 를 소문자로 바꿔 비교하는지 확인(5-1) |
| 클릭수는 오르는데 GA4 에 안 잡힌다 | 앱 분기 경로에서 UTM 이 빠졌다(7-2). 또는 GA4 의 세션 만료로 직접 유입 처리됨 |
| 접속 IP 가 전부 같다 | trust proxy 미설정(7-1) |
| 목적지를 바꿨는데 반영이 안 된다 | 캐시 헤더 누락(4-5). 브라우저 캐시도 한 번 지워 본다 |
| 슬러그 등록 시 중복 오류가 계속 난다 | COLLATE 설정(2-1) |
| CSV 한글이 깨진다 | BOM 누락(9-3) |
B부록 — 결정이 필요한 항목
개발 착수 전에 기획·사업팀이 정해야 하는 것들. 정해지지 않으면 임시값으로 만들고 나중에 다시 손대야 한다.
| No | 항목 | 선택지 | 결정자 |
|---|---|---|---|
| D-01 | 서버 구성 | 단축링크 전용 서버 분리 / 기존 NestJS 에 모듈 추가 | 개발 |
| D-02 | 클릭 기록 보관 기간 | 90일 / 1년 / 무기한 | 기획·법무 |
| D-03 | 처리방침 반영 시점 | 12차 개정에 포함 / 별도 고지 | 기획 |
| D-04 | 어드민 권한 구분 | 1차는 전체 동일 권한 / 역할별 분리 | 기획 |
| D-05 | 목적지 허용 도메인 | 어바웃피싱 도메인만 / 특정 외부 도메인 추가 허용 | 기획 |
| D-06 | 슬러그 직접 입력 허용 여부 | 허용(선사명 등 의미 있는 주소 가능) / 자동 생성만 | 사업팀 |
| D-07 | 선박 상세 주소 규칙 | 웹과 앱의 선박 상세 경로가 같은지 확인 필요 | 개발 |
| D-08 | iOS 앱 미설치 시 동작 | 웹으로 보냄(권장) / App Store 로 보냄 | 기획 |
| D-09 | "앱 전환" 지표 정의 | 분기 시도 기준(추정) / 앱에서 별도 이벤트 수집 | 기획·개발 |
| D-10 | 기본 OG 이미지 | 선박 대표 이미지 자동 사용 / 공통 기본 이미지 1종 | 디자인 |
| D-11 | 목적지 생존 점검 | 1차 범위 포함 / 2차 과제 | 기획 |
| D-12 | 선사 배포 방식 | 어드민에서 뽑아 수동 전달 / 선주 앱에 노출 | 사업팀 |