개발 구현 가이드

어바웃피싱 단축링크 시스템 구축 가이드

go.aboutfishing.kr 로 선사별 단축링크를 만들고, 링크 하나로 앱·스토어·웹을 알아서 나눠 보내고, 몇 번 눌렸는지 세는 시스템을 만든다. 이 문서는 해당 작업을 처음 해 보는 사람이 위에서 아래로 그대로 따라 하면 되도록 썼다.

NestJS + TypeORM + MySQL React 어드민 React Native 풀웹뷰 2026-09-21

00시작 전에 읽을 것

이 문서를 어떻게 읽어야 하는지.

이 문서의 표시 규칙

표시
파란 「왜 하는가」이 단계가 왜 필요한지. 건너뛰면 나중에 무엇이 깨지는지.
초록 「여기까지 됐는지 확인」다음 단계로 넘어가기 전 반드시 통과해야 하는 확인 절차. 명령어와 기대 결과가 같이 있다.
주황 「주의」순서를 틀리거나 값을 잘못 넣으면 바로 터지는 지점.
빨강 「놓치면 바로 터지는 지점」이 프로젝트에서 실제로 문제가 되는 함정. 여기를 빼고 만들면 동작하지 않는다.
보라 「배경」왜 이렇게 정했는지. 구현에 직접 필요하지는 않지만 판단할 때 쓴다.

이 시스템에서 가장 자주 막히는 네 가지

순서대로 만들다 보면 아래 네 군데에서 멈춘다. 해당 장에 대응 방법이 있다.

증상원인해당 장
어드민 API가 전부 404@Get(':slug') 가 루트에 걸려 /admin/shortlinks 까지 단축링크로 해석한다4장
카카오톡에서 링크를 눌러도 앱이 안 열림카카오톡 인앱 브라우저는 앱 링크(Universal Link)를 처리하지 않는다5장
앱 안에서 링크를 누르면 무한 반복앱 웹뷰가 go 링크를 열면 → 서버가 다시 앱 열기 스크립트를 내려줌 → 앱이 또 열림4·6장
앱으로 들어온 유입이 GA4에서 안 잡힘앱으로 분기되는 순간 UTM 값이 사라진다7장

0-1전체 그림과 용어

코드를 보기 전에 무엇을 만드는 것인지부터 맞춘다.

한 줄 요약

사업팀이 어드민에서 선사별로 짧은 주소를 하나씩 만든다. 그 주소를 선사에 주면 선사가 블로그·카톡·명함·현수막에 쓴다. 고객이 그 주소를 누르면 시스템이 고객 기기를 보고 앱·스토어·웹 중 맞는 곳으로 보낸다. 동시에 몇 번 눌렸는지 기록한다.

클릭 한 번에 일어나는 일

1. 고객이 누름go.aboutfishing.kr/ab12cd
2. 서버가 받음ab12cd 로 DB 조회 → 목적지 확인
3. 클릭 기록기기종류·유입경로 저장 (봇은 제외)
4. 분기 판단앱 웹뷰 / 카카오 / iOS / 안드로이드 / PC
5. 이동앱 · 스토어 · 웹 중 하나로

용어 사전

용어
슬러그(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 IDApple Developer → Membership → Team ID (10자리 영숫자)
iOS Bundle IDXcode → 프로젝트 → General → Bundle Identifier
App Store 앱 IDApp Store Connect → 앱 정보 → Apple ID (숫자)
Android Package Nameandroid/app/build.gradle 의 applicationId
앱 서명 SHA-256 지문Play Console → 설정 → 앱 서명 → 앱 서명 키 인증서
커스텀 스킴 이름현재 앱에 이미 있으면 그 값. 없으면 aboutfishing 로 신규 지정
웹 기본 주소기획www.aboutfishing.kr
선박 상세 주소 규칙기획선박 ID 를 넣으면 어떤 주소가 되는지. 웹과 앱이 같은 규칙인지 확인
기본 OG 이미지디자인1200×630 px, 1MB 이하. 링크별 이미지가 없을 때 쓰는 기본값
Play Console 의 SHA-256 은 업로드 키앱 서명 키 두 개가 나온다. App Links 검증에 쓰는 것은 앱 서명 키 쪽이다. 둘 다 넣어 두면 내부 테스트 빌드에서도 동작하므로 두 개를 다 받아 두는 편이 낫다.

0-3담당·순서·의존관계

누가 무엇을 하는지, 어떤 작업이 어떤 작업을 기다려야 하는지.

내용담당선행 조건
1도메인·서버·SSL인프라없음 — 여기부터 시작
2데이터베이스 테이블백엔드없음 — 1장과 동시 진행 가능
3앱 연결 파일 배포앱 + 인프라1장 SSL 완료 + 0-2 표의 앱 값 확보
4리다이렉트 서버백엔드2장
5카카오·인앱 대응백엔드4장
6앱 코드3장 파일이 서버에 올라가 있어야 검증 통과
7클릭 측정·GA4백엔드 + 앱4장, 6장
8QR 코드백엔드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)를 건다.

인프라
앱 연결(3장)은 운영체제가 https 로만 확인한다. http 로는 아예 검증을 시도하지 않는다. 그래서 SSL이 먼저 끝나야 그 뒤 모든 작업이 의미가 있다.

1-1. DNS 레코드 추가

도메인 관리 패널(가비아 등)에서 A 레코드를 하나 추가한다.

DNS 관리 패널
타입 : A
이름 : go
값   : 12.34.56.78        ← 0-2 표의 서버 공인 IP
TTL  : 3600
dig +short go.aboutfishing.kr
# 입력한 IP가 그대로 나오면 성공. 아무것도 안 나오면 아직 퍼지는 중 —
# 최대 1시간까지 기다린다. 30분 넘게 안 나오면 레코드를 다시 확인한다.

1-2. Nginx 설정

/etc/nginx/sites-available/go.aboutfishing.kr
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 미리보기와 앱 연결이 모두 이 주소를 쓰므로 반드시 넣는다.
기존 aboutfishing.kr 서버와 같은 서버를 쓴다면 포트가 겹치지 않는지 먼저 본다. 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 — 링크 본체

MySQL
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;
MySQL 기본 정렬 규칙은 대소문자를 구분하지 않는다. 그래서 aB12cdAb12CD 가 같은 값으로 취급되어, 슬러그를 영문 대소문자 섞어 만들면 "중복" 오류가 계속 난다. COLLATE utf8mb4_bin 을 붙여 대소문자를 구분하게 했다.
updated_atDEFAULT CURRENT_TIMESTAMP 를 빼면 새로 만든 행의 수정일시가 NULL 로 들어간다. 어드민 목록에서 "최근 수정일" 이 빈칸으로 보인다.
추가한 칼럼 네 개의 이유.
  • title — 어드민 목록에 슬러그(ab12cd)만 있으면 사람이 무엇인지 못 알아본다.
  • expires_at — 행사용 링크는 끝나면 죽어야 한다. 사람이 일일이 끄는 구조면 반드시 방치된다.
  • memo — "어느 선사 명함에 썼는지" 를 남겨야 나중에 지울지 판단할 수 있다.
  • updated_by — 누가 목적지를 바꿨는지 추적. created_by 만으로는 사고 추적이 안 된다.

2-2. click_logs — 클릭 기록

MySQL
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;
접속 IP 원본을 그대로 저장하면 안 된다. IP 주소는 개인정보보호법상 개인정보로 다뤄진다. 처리방침에 수집 항목·보관기간을 적지 않고 쌓으면 위반이다. 해시로 바꿔 중복 클릭 판별은 되게 하면서 원본은 남기지 않는다. 자세한 내용은 10장.
BigQuery(7-5)에도 vessel_id 를 함께 적재한다. 두 곳의 기록 항목이 다르면 나중에 두 수치를 대조할 수 없다.
channel 칼럼을 따로 둔 이유. 카카오톡에서 왔는지 네이버 블로그에서 왔는지가 이 프로젝트의 핵심 질문이다. 기기 종류(ios/android/pc)만으로는 그 답이 나오지 않는다.

2-3. TypeORM 엔티티

shortlink/entities/short-link.entity.ts
@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앱 연결 파일 배포

"이 도메인은 어바웃피싱 앱 것이 맞다" 를 운영체제에 증명하는 파일 두 개를 서버에 올린다.

인프라
이 파일이 없으면 iOS·안드로이드가 링크를 눌러도 앱을 열지 않고 브라우저로만 간다. 파일 내용이 한 글자라도 틀리면 조용히 실패한다 — 오류 메시지가 뜨지 않으므로 검증 절차(아래)를 반드시 거친다.

3-1. iOS — apple-app-site-association

파일 이름에 확장자가 없다. .json 을 붙이면 안 된다.

/var/www/go/.well-known/apple-app-site-association
{
  "applinks": {
    "details": [
      {
        "appIDs": [ "ABCD123456.kr.co.aboutfishing" ],
        "components": [
          { "/": "/.well-known/*", "exclude": true },
          { "/": "/admin/*",       "exclude": true },
          { "/": "/*" }
        ]
      }
    ]
  }
}
채우는 방법
ABCD1234560-2 표의 Apple Team ID
kr.co.aboutfishing0-2 표의 iOS Bundle ID
"appID" + "paths": ["/*"] 는 iOS 13 이전 문법이다. 지금도 동작은 하지만 /*/.well-known/ 까지 앱으로 잡아채서 앱이 자기 검증 파일을 못 읽는 경우가 생긴다. 현재 문법인 appIDs + components 를 쓰고 제외 경로를 명시한다.

3-2. 안드로이드 — assetlinks.json

/var/www/go/.well-known/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 오류 페이지가 아님)
파일을 고친 뒤 바로 반영되지 않는다. iOS 는 Apple CDN 이 이 파일을 최대 24시간 캐시한다. 캐시된 내용은 아래 주소로 확인할 수 있다.
curl -s https://app-site-association.cdn-apple.com/a/v1/go.aboutfishing.kr
여기에 옛 내용이 보이면 기다리는 수밖에 없다. 급하면 개발 빌드에서 Associated Domains 값을 applinks:go.aboutfishing.kr?mode=developer 로 바꾸면 CDN 을 건너뛴다.
안드로이드는 앱을 설치하는 순간 1회 이 파일을 확인한다. 파일을 고쳤으면 앱을 지우고 다시 설치해야 반영된다. 그냥 업데이트로는 재검증하지 않는다.

04리다이렉트 서버

이 프로젝트의 심장. 짧은 주소를 받아 어디로 보낼지 정하는 부분이다.

백엔드

4-1. 라우트 등록 순서 — 가장 먼저 볼 것

@Controller() + @Get(':slug')루트 아래 모든 한 칸 경로를 잡는다. 그래서 /admin, /health, /favicon.ico 까지 전부 "슬러그" 로 해석되어 어드민 API 가 404 를 뱉는다. 실제로 구현하면 첫날 바로 막히는 지점이다.

해결은 두 가지다. 둘 중 하나만 하면 된다.

방법내용권장
서버를 나눈다단축링크 전용 NestJS 앱을 3001 포트로 따로 띄우고, 어드민 API 는 기존 서버에 둔다. 도메인이 다르므로 충돌이 없다권장
한 앱에 둔다모듈 등록 순서를 AdminShortlinkModuleShortlinkModule 로 하고, 슬러그 컨트롤러에 예약어 차단을 넣는다차선
shortlink/shortlink.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([ShortLink, ClickLog])],
  controllers: [
    AdminShortlinkController,   // ← 반드시 먼저
    ShortlinkController,        // ← ':slug' 는 항상 마지막
  ],
  providers: [ShortlinkService],
})
export class ShortlinkModule {}

4-2. 슬러그 규칙

길이·글자·중복 처리·금지어를 정하지 않으면 현장에서 바로 문제가 된다. 아래를 규칙으로 쓴다.
항목규칙이유
길이자동 생성 6자, 직접 입력 3~24자글자 31종 6자리 = 약 8.9억 가지. 현수막·명함에 들어갈 길이
사용 글자23456789abcdefghjkmnpqrstuvwxyz0·O·1·l·i 를 뺐다. 전화로 불러주거나 손으로 적을 때 틀리지 않게
직접 입력영문 소문자·숫자·하이픈만대문자를 허용하면 인쇄물에서 대소문자를 틀린다
중복생성 실패 시 최대 5회 재시도, 그래도 실패하면 오류 반환무한 루프 방지
금지어admin, api, health, static, assets, www, app, qr, well-known, favicon.ico, robots.txt, sitemap.xml시스템 경로와 겹치면 서비스가 죽는다
shortlink/slug.util.ts
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 객체로 합쳐야 한다.
shortlink/shortlink.service.ts
// 목적지까지 그대로 넘겨야 하는 값 — 광고 클릭 식별자와 추가 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 가 아무 사이트로나 사람을 보낼 수 있다. 어드민 계정이 한 번 털리면 우리 도메인이 피싱에 쓰인다. 단축링크 서비스의 대표적인 사고 유형이다.
shortlink/url.util.ts
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. 컨트롤러

shortlink/shortlink.controller.ts
@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번 단계를 빼면 생기는 증상. 앱 웹뷰에서 go 링크를 열면 → 서버가 "앱을 열어라" 스크립트를 내려주고 → 앱이 다시 열리고 → 그 링크를 웹뷰가 또 열고 … 가 반복된다. 앱에서 온 요청은 분기 없이 곧장 302 로 목적지까지 보내야 한다. 앱을 식별하는 방법은 6-4 에 있다.

4-6. HTML 생성 — 이스케이프 처리

OG 제목·설명을 문자열에 그대로 끼워 넣으면 안 된다. 제목에 큰따옴표가 하나만 들어가도 메타 태그가 깨지고, 악의적으로는 태그를 주입할 수 있다. 목적지 주소도 자바스크립트 문자열 안에 그대로 들어가 같은 문제가 있다.
shortlink/shortlink.service.ts
const esc = (s = '') => String(s)
  .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
  .replace(/"/g,'&quot;').replace(/'/g,'&#39;');

// 자바스크립트 문자열 안에 넣을 때는 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. 분기 스크립트

서버가 내려주는 HTML 의 <script> 부분
(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
})();
iOS 분기에서 자주 틀리는 세 가지.
  • 앱이 없을 때 App Store 로 바로 보내지 않는다. 웹으로 보내는 편이 전환이 높다 — 예약하러 온 사람에게 앱 설치를 먼저 시키면 이탈한다. 설치 유도는 웹 페이지 상단 배너로 한다.
  • blur 이벤트는 iOS 에서 신뢰도가 낮다. visibilitychangepagehide 를 같이 쓴다.
  • 1500ms 타이머는 앱이 열려 백그라운드로 간 뒤 앱에서 돌아왔을 때 뒤늦게 실행되어 갑자기 스토어가 뜬다.
안드로이드 intent 주소에 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카카오톡·인앱 브라우저 대응

국내 서비스에서는 여기가 유입의 대부분을 차지한다. 빼고 만들면 「카톡에서 안 열려요」 문의를 그대로 받는다.

백엔드
선사가 링크를 배포하는 경로는 대부분 카카오톡 단체방·오픈채팅·블로그다. 그런데 카카오톡 안에서 링크를 누르면 카카오톡 내장 브라우저가 열리고, 이 브라우저에서는 iOS Universal Link 와 안드로이드 App Links 가 동작하지 않는다. 이 대응이 없으면 카카오톡으로 들어온 고객은 전부 앱을 열지 못한다.

5-1. 접속 환경 판별

shortlink/env.util.ts
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. 인앱 브라우저에서 앱 열기

채널별로 탈출 방법이 다르다.

환경방법
카카오톡 iOSkakaotalk://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. 대기 화면

분기에는 0.5~2초가 걸린다. 그동안 흰 화면이면 고객은 링크가 고장 난 줄 알고 닫는다. 로고와 "앱으로 이동 중" 한 줄, 그리고 수동으로 누를 수 있는 버튼을 반드시 넣는다. 자동 분기가 실패해도 버튼 하나로 복구된다.
서버가 내려주는 HTML 의 body
<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>
이 재진입을 그냥 두면 카카오 iOS 유입의 클릭수가 두 배가 된다. Safari 가 같은 주소를 다시 열면 서버가 한 번 더 응답하면서 클릭이 또 쌓이기 때문이다. 게다가 첫 번째는 카카오톡으로, 두 번째는 일반 브라우저로 기록되어 유입 채널 분포까지 틀어진다 — "카카오톡에서 몇 명이 왔는가" 가 이 시스템의 핵심 질문인데 그 답이 절반 틀리게 나온다.
// 4-5 컨트롤러 3단계
const isRetry = req.query._r === '1';
if (!env.isBot && !isRetry) {
  this.svc.logClick(link, req, env).catch(() => {});
}
실제 휴대폰으로 확인해야 한다. 아래 5개를 각각 해 본다.
  • 카카오톡 나와의 채팅에 링크를 붙여넣는다 → 미리보기 썸네일·제목이 뜬다
  • 그 링크를 누른다 → 앱이 열린다 (iOS 는 Safari 를 한 번 거친다)
  • 앱을 지우고 다시 누른다 → 웹 상품 페이지가 열린다
  • 미리보기만 뜨게 두고 클릭은 하지 않았을 때 click_logs 에 행이 안 생긴다
  • 카카오톡 iOS 에서 한 번 누르면 click_logs1행만 생긴다 (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
ios/AppDelegate.mm
- (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

android/app/src/main/AndroidManifest.xml (MainActivity 안)
<!-- ① 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>
② 블록을 빠뜨리기 쉽다. 4-7·5-2 의 intent 주소와 커스텀 스킴이 이 블록을 통해 앱에 도착한다.

6-3. 앱 코드

App.tsx
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. 앱 안 요청 표시 — 무한 반복을 막는 핵심

앱 웹뷰가 go 링크를 열면 서버는 그것이 앱인지 브라우저인지 모른다. 그래서 "앱을 열어라" 스크립트를 내려주고, 앱이 다시 열리고, 웹뷰가 또 그 링크를 열어 반복된다.

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. 클릭 기록

shortlink/shortlink.service.ts
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 이 앱까지 따라가게 하기

측정에서 가장 큰 구멍이 나는 지점이다. PC·모바일 웹으로 가는 고객은 UTM 이 주소에 붙어 GA4 가 유입 경로를 잡는다. 그런데 앱으로 분기되는 순간 주소가 aboutfishing://vessel/123 으로 바뀌면서 UTM 이 사라진다. 선사 링크로 들어온 고객의 상당수가 앱 사용자인데, 그 유입이 GA4 에서 "직접 유입" 으로 뭉뚱그려진다. 링크별 성과를 재려고 만든 시스템인데 정작 성과가 안 잡히는 상태가 된다.

해결은 세 군데를 같이 고치면 된다.

위치할 일
서버앱 스킴·intent 주소에 ?to={UTM이 붙은 최종 웹주소} 를 통째로 넣는다 (4-6)
받은 to 값을 그대로 웹뷰에 로드한다 (6-3 ①)
웹뷰 안에서 GA4 가 평소대로 UTM 을 읽는다 — 추가 작업 없음

7-2-1. GA4 쪽에서 해야 하는 설정

서버·앱을 아무리 잘 만들어도 GA4 에서 아래 세 가지를 하지 않으면 링크별 성과가 나오지 않는다. 개발이 아니라 계정 설정이므로 마케팅이 맡는다.
설정이유
원치 않는 리퍼러에 go.aboutfishing.kr 등록
GA4 관리 → 데이터 스트림 → 태그 설정
중간 페이지를 거치면 최종 도착지가 보는 유입 출처가 go.aboutfishing.kr 로 덮인다. 등록하지 않으면 UTM 이 빠진 링크의 유입이 전부 "우리 사이트에서 온 추천 트래픽" 으로 잡힌다
utm_id 로 링크 구분선박 하나에 링크를 여러 개 만들면 캠페인 이름이 같아져 구분이 안 된다. 서버가 utm_id 에 슬러그를 넣어 보내므로(4-3) GA4 가 별도 설정 없이 링크 단위로 분해해 준다
맞춤 채널 그룹 「선사링크」blog · qr · card 같은 값은 GA4 기본 채널 규칙에 없어서 상당수가 "Unassigned" 로 떨어진다. 대행사 월간 리포트에 원인 불명 트래픽이 늘어나므로 미리 만들고 공유한다
UTM 을 선택 입력으로 두지 않는다. 링크를 만들 때 매체·수단을 비워 두면 그 유입은 출처를 잃는다. 900척 규모에서 누락은 반드시 생기므로 등록 화면에서 필수로 막는다.
중간 페이지에 GA4 태그를 넣지 않는다. 넣으면 go.aboutfishing.kr 에서 조회가 발생해 유입 출처가 그쪽으로 확정되고, 0.5초 만에 사라지는 페이지가 이탈로 잡힌다. 대신 「클릭수 대비 목적지 도달 수」 를 주간으로 비교해 중간 페이지에서 새는 양을 본다.

7-3. UTM 작성 규칙

규칙을 정해 두지 않으면 담당자마다 blog, Blog, naver_blog 를 섞어 써서 GA4 에서 합산이 안 된다. 어드민 입력 폼에서 아래 값만 고르게 한다(자유 입력 금지).
항목허용 값
utm_source선택형: naver · kakao · instagram · youtube · offline · partnernaver
utm_medium선택형: blog · post · profile · qr · card · banner · dmblog
utm_campaign{선박코드}_{연월} 자동 생성v1023_202610

7-4. 어디까지 세는가 — 정의

지표정의
클릭수사람이 링크를 눌러 서버가 응답한 횟수. 미리보기 봇은 제외한다. 같은 사람이 두 번 누르면 2로 센다
순 클릭수같은 날·같은 링크·같은 IP 해시를 1로 묶은 수. 카톡방에서 여러 번 누른 경우를 걸러낸다
앱 전환클릭 중 앱으로 분기된 비율. 서버는 분기 시도까지만 알고 실제 앱이 열렸는지는 모른다 — 추정값으로 표기한다
예약 전환이 링크를 거쳐 들어온 세션에서 발생한 예약 건수. GA4 에서 utm_id(=슬러그) 기준으로 본다. 귀속 기간(7일/30일)은 [기획 확정 필요] — 낚시배는 출조일 사전 예약이라 클릭에서 예약까지 며칠이 걸린다
도달률목적지에 실제로 닿은 수 ÷ 클릭수. 중간 페이지에서 얼마나 새는지를 보는 유일한 지표다. 주간으로 본다
"앱 전환" 은 서버가 확정할 수 없다. 앱이 실제로 열렸는지는 앱에서 별도 이벤트를 보내야만 알 수 있다. 어드민 화면에 그냥 숫자로 띄우면 사업팀이 확정값으로 읽는다. 「추정」 표기를 반드시 붙인다.

7-5. BigQuery 적재 (선택)

MySQL 만으로도 어드민 통계는 충분하다. 기간이 길어지고 다른 지표와 합쳐 볼 필요가 생기면 그때 붙인다.

shortlink/shortlink.service.ts — logClick 뒤에 추가
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(() => {});   // 적재 실패가 리다이렉트를 막으면 안 된다
MySQL 적재와 BigQuery 적재의 칼럼을 똑같이 맞춰 둔다. 두 곳이 다르면 나중에 두 수치를 대조할 수 없다.

08QR 코드

어드민에서 버튼을 누르면 QR 이미지가 내려받아진다.

백엔드

8-1. 무엇으로 만드는가 — 외부 API 를 쓰지 않는다

QR 은 "이 문자열을 이런 규칙으로 점으로 바꾼다" 가 국제 표준(ISO/IEC 18004)으로 정해져 있다. 그래서 외부 서비스에 요청할 필요가 없고, 서버 안에서 라이브러리로 직접 그리면 된다. 외부 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
shortlink/admin-shortlink.controller.ts
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);
}
QR 설정에서 지킬 세 가지.
  • 오류 정정 수준 H — 기본값은 M(15%)이다. 현수막·명함은 접히고 긁히므로 H(30%)로 올린다. 가운데 로고를 넣을 여지도 생긴다.
  • 기본 크기 800px — 400px 은 A4 인쇄에서 흐려진다.
  • SVG 제공 — 현수막·대형 출력은 벡터가 필요하다. 디자이너가 PNG 를 키우면 계단 현상이 생긴다.
미리보기와 내려받기를 한 엔드포인트로 처리한다. 어드민 QR 모달(SL-06)은 같은 주소에 ?disposition=inline 을 붙여 미리보기를 띄우고, [내려받기] 를 누르면 attachment 로 받는다. 이 구분이 없으면 미리보기를 <img> 로 띄울 수 없다.

가운데 로고는 qrcode 가 해 주지 않는다. 필요하면 생성된 PNG 위에 sharp 로 로고를 합성한다. 로고를 덮는 면적은 전체의 20% 를 넘기지 않는다(정정 수준 H 기준). 1차 범위에 넣을지는 [기획 확정 필요].
QR 은 한 번 인쇄되면 회수할 수 없다. 그래서 QR 에 넣는 주소는 항상 단축링크 주소여야 하고, 목적지는 어드민에서 언제든 바꿀 수 있어야 한다. 목적지 주소를 QR 에 직접 넣으면 나중에 수정이 불가능하다. 이것이 이 시스템을 만드는 가장 큰 실익이므로 운영에 반드시 공유한다.

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/qrQR 내려받기 (?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. 입력값 규격

shortlink/dto/create-shortlink.dto.ts
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 내보내기

shortlink/admin-shortlink.controller.ts
@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-06QR 내려받기모달미리보기 · 형식(PNG/SVG) · 크기 · 상태 안내 띠
SL-07사용 중지 확인확인창영향 안내 3줄(누적·최근 7일 클릭 포함) · 빨간 실행
SL-08변경 이력모달일시 · 변경자 · 행위 · 항목 · 변경 전후
SL-09이동 중 화면고객중간 페이지. 정상 / 인앱 안내 / 복귀 3상태
SL-10안내 화면고객중지 410 · 만료 410 · 없는 주소 404 · 일시 오류 500
SL-11작성 이탈 확인확인창바뀐 항목 표시 · [계속 작성] / [나가기]
SL-12일괄 생성 확인확인창요약 3줄 · 되돌릴 수 없음 안내 · 중복 선박 수
SL-09 · SL-10 은 고객이 실제로 마주치는 화면이다. 어드민 8화면만 보고 "화면 다 나왔다" 고 판단하면, 전 고객 유입이 통과하는 두 장이 디자인·문구·담당자 없이 코드 조각으로만 남는다. 이 두 장은 프론트가 템플릿 파일로 만들고 서버는 값만 끼워 넣는 구조로 간다 — 13-6 참고.

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
최소 지원 해상도는 1280px 로 잡는다. 그보다 좁으면 표에 가로 스크롤이 생기고, 목적지·메모 열이 먼저 줄어든다. 어드민은 데스크톱 전용이며 모바일 대응은 하지 않는다.

10보안·개인정보·운영 규칙

이 부분을 빼고 열면 사고가 났을 때 되돌릴 방법이 없다.

백엔드기획

10-1. 개인정보 — IP 처리

접속 IP 는 개인정보보호법상 개인정보로 다뤄진다. 수집 항목·이용 목적·보관 기간을 처리방침에 적지 않고 저장하면 위반이다. 어바웃피싱 처리방침은 11차 개정본이 시행 중이므로, 이 시스템을 열기 전에 항목을 반영해야 한다.
shortlink/hash.util.ts
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××
개발
위 표는 제안이다. 현재 어드민 계정에는 권한 구분 기능이 없다(관리자 등록 폼에 권한 항목이 없음). 권한을 쓰려면 어드민 계정 체계부터 손봐야 하므로, 1차 오픈은 전체 관리자 동일 권한으로 열고 변경 이력으로 추적하는 방식을 권한다. [기획 확정 필요]

10-3. 변경 이력

MySQL
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)
);
선사에 이미 배포된 링크의 목적지를 누군가 바꾸면 그 순간부터 고객이 엉뚱한 데로 간다. 이력이 없으면 원인을 찾는 데만 며칠이 걸린다. 표기는 계정 ID 가 아니라 사람 이름으로 남긴다.

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테스트 방법

무엇을 어떤 도구로 확인하는지.

백엔드QA

11-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 이미지가 안 뜰 때 여기부터 본다
네이버 블로그 글쓰기블로그에 링크를 붙였을 때 카드 형태를 확인
OG 이미지는 절대 주소여야 하고, https 여야 하고, 접근에 로그인이 필요하면 안 된다. 권장 크기 1200×630, 파일 1MB 이하. 카카오는 1MB 를 넘으면 썸네일을 포기한다.

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.aboutfishingverified 가 보여야 한다
안드로이드 — 재검증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"
iOS 에서 go.aboutfishing.kr 페이지 안에 있는 상태로 같은 도메인 링크를 누르면 앱이 열리지 않는다. 같은 도메인 내 이동은 Universal Link 로 처리하지 않는 것이 iOS 의 규칙이다. 버그가 아니다.

11-4. 실기기 테스트 표

배포 전에 아래 조합을 모두 돌린다. 기기 2대(iOS·안드로이드)와 앱 설치/미설치 두 상태가 필요하다.

기기누른 곳기대 결과
iOS설치카카오톡Safari 가 잠깐 뜬 뒤 앱이 열리고 해당 선박 페이지
iOS미설치카카오톡웹 상품 페이지 (App Store 로 튀지 않는다)
iOS설치메모 앱앱이 바로 열림
iOS설치어바웃피싱 앱 안앱 안에서 페이지만 바뀜 (앱이 다시 열리지 않음)
안드로이드설치카카오톡앱이 열리고 해당 선박 페이지
안드로이드미설치카카오톡웹 상품 페이지
안드로이드설치삼성 인터넷앱이 열림
PC크롬웹 상품 페이지, 주소에 utm 값이 붙어 있음
모두QR 스캔위와 동일하게 분기

11-5. 측정 확인

  1. GA4 → 보고서 → 실시간 → 링크를 누른 뒤 30초 안에 세션이 뜨는지 본다.
  2. 실시간 화면에서 「세션 소스/매체」 카드에 naver / blog 가 잡히는지 본다.
  3. 앱으로 분기된 경우에도 같은 값이 잡히는지 본다 — 안 잡히면 7-2 가 안 된 것이다.
  4. SELECT channel, COUNT(*) FROM click_logs WHERE slug='test01' GROUP BY channel; 로 채널 분류가 맞는지 본다.

12배포 전 검증 체크리스트

전 항목이 통과해야 선사에 링크를 배포한다.

서버·인프라

https://go.aboutfishing.kr 접속 시 정상 응답
http 로 접속하면 https 로 301 이동
.well-known 두 파일이 200 + application/json 으로 응답 (리다이렉트 없음)
Apple CDN 캐시에 현재 파일 내용이 반영됨
안드로이드 pm get-app-links 결과가 verified
리다이렉트 응답에 Cache-Control: no-store 가 있다
trust proxy 설정으로 접속 IP 가 실제 IP 로 잡힌다

분기 동작

11-4 표의 9개 조합이 모두 기대대로 동작
앱 안에서 go 링크를 눌러도 무한 반복이 없다
목적지에서 뒤로가기를 누르면 중간 페이지를 건너뛴다
자동 이동이 실패해도 화면의 수동 버튼으로 이동된다

OG 미리보기

카카오톡에 붙여넣으면 썸네일·제목·설명이 뜬다
제목에 큰따옴표를 넣어도 미리보기가 깨지지 않는다
OG 이미지를 비워 둔 링크는 기본 이미지가 뜬다

측정

클릭 시 click_logs 에 행이 생기고 click_count 가 1 오른다
카카오 미리보기 봇의 접근은 기록되지 않는다
카카오톡 iOS 에서 한 번 누르면 클릭이 1건만 쌓인다 (재진입 중복 차단)
PC 에서 누르면 중간 페이지를 거치지 않고 바로 목적지로 간다
주소에 gclid 를 붙여 눌렀을 때 목적지 주소에도 그대로 남아 있다
GA4 「원치 않는 리퍼러」에 go.aboutfishing.kr 이 등록되어 있다
한국 시간 오전 9시 이전 클릭이 그날 날짜로 집계된다
channel 값이 kakao / browser / app 으로 정확히 분류된다
앱으로 분기된 유입도 GA4 에서 utm 값이 잡힌다
click_logs 에 IP 원본이 아니라 해시가 저장된다

보안

어바웃피싱 외 도메인을 목적지로 넣으면 등록이 거부된다
http 주소를 목적지로 넣으면 등록이 거부된다
admin, api 등 예약어로는 슬러그를 만들 수 없다
어드민 API 가 로그인 없이 호출되면 401
목적지를 수정하면 변경 이력에 사람 이름으로 남는다

어드민

링크 생성 / 수정 / 복제 / 중지가 동작한다
일괄 생성으로 선박 10척에 한 번에 링크가 만들어진다
QR PNG·SVG 가 내려받아지고 스캔하면 링크와 같이 분기한다
CSV 를 엑셀로 열었을 때 한글이 깨지지 않는다
중지된 링크를 누르면 안내 페이지가 뜬다 (빈 오류 화면이 아니다)
만료일이 지난 링크가 자동으로 안내 페이지로 간다
중지·만료·없는 주소가 각각 다른 문구로 안내된다 (같은 404 가 아니다)
QR 모달에서 미리보기가 다운로드 없이 화면에 보인다
작성 중 뒤로가기를 누르면 확인창이 뜬다
일괄 생성 버튼을 두 번 눌러도 링크가 두 배로 생기지 않는다
두 사람이 같은 링크를 동시에 수정하면 나중 사람이 경고를 받는다

개인정보·문서

수집 항목·보관기간이 처리방침에 반영되었다
운영 규칙(10-5)이 사업팀에 공유되었다

장애 대응

/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. 페이지네이션 · 정렬 · 필터

파라미터비고
page1부터. 기본 1범위를 벗어나면 빈 배열 + total 은 그대로
size기본 20, 최대 100초과 요청은 100으로 깎는다(오류 아님)
sortcreatedAt,desc 형식. 기본 createdAt,desc허용 필드: createdAt · updatedAt · clickCount · title · expiresAt. 그 외는 400
keyword + keywordTypetype: title(기본) · slug · targetUrl · memo부분 일치. % _ 는 서버에서 이스케이프
vesselId단일 선택. none 이면 선박 미지정 건만
statusall(기본) · active · inactive · expired목록 상태 열과 같은 값
from · toYYYY-MM-DD. 기준은 등록일기본 최근 30일. 둘 다 없으면 전체
utmSource단일 선택
목록의 필터·정렬·페이지는 브라우저 주소에 그대로 남긴다. 그래야 수정 화면에 다녀와도 보던 화면으로 돌아오고, 담당자끼리 "이 조건으로 본 목록" 을 주소로 주고받을 수 있다.

13-3. 오류 응답

공통 오류 바디
{
  "code": "SLUG_DUPLICATED",
  "message": "이미 사용 중인 주소입니다.",
  "field": "slug"          // 특정 입력칸 문제일 때만. 없으면 화면 상단에 띄운다
}
상태code화면 문구
400VALIDATION_FAILED서버 message 를 그대로 띄운다
400TARGET_URL_NOT_ALLOWED어바웃피싱 도메인 주소만 등록할 수 있습니다.
400TARGET_URL_NOT_HTTPShttps 주소만 등록할 수 있습니다.
400SLUG_RESERVED사용할 수 없는 주소입니다.
400EXPIRES_AT_PAST오늘 이후 날짜를 선택해 주세요.
401UNAUTHORIZED기존 어드민과 같게 처리 — 로그인 화면으로 보낸다
403FORBIDDEN권한이 없습니다.
404NOT_FOUND삭제되었거나 없는 링크입니다.
409SLUG_DUPLICATED이미 사용 중인 주소입니다.
409STALE_UPDATE다른 사람이 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요.
413BULK_LIMIT_EXCEEDED한 번에 100건까지 만들 수 있습니다.
429TOO_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/shortlinks13-2 파라미터 → 13-1 목록 응답. clickCount 는 누적값이며 기간 필터의 영향을 받지 않는다
GET /admin/shortlinks/:id13-1 단건 응답. SL-03 이 이 API 로 값을 채운다. 중지·만료 건도 정상 조회된다
GET /admin/shortlinks/slug-check?slug={ "available": true }. SL-02 의 [중복 확인] 용. 예약어·형식 위반도 false 로 내려주고 사유를 message 에 담는다
POST /admin/shortlinks9-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.csv13-2 와 같은 필터를 그대로 받는다. 상한 1만 건, 초과 시 413
GET /admin/shortlinks/:id/historypage/size. 서버가 한글 항목명과 전후 값을 문자열로 만들어 내려준다
어드민 컨트롤러 안에서도 등록 순서를 지켜야 한다. @Get(':id') 가 먼저 걸리면 export.csvslug-check 를 id 로 해석해 400 이 난다. export.csvslug-checkbulk:id/*:id 순서로 등록한다.
복제(clone) API 는 두지 않는다. 대신 SL-02 등록 화면에 "기존 링크에서 값 가져오기" 를 둔다. 복제를 별도 API 로 두면 어떤 값이 따라오고 어떤 값이 새로 생기는지(만료일·메모·통계) 규칙을 또 정해야 하는데, 등록 화면에서 값을 채워 주고 사람이 확인하게 하면 그 규칙이 필요 없다.

13-5-1. 일괄 생성

POST /admin/shortlinks/bulk
// 요청
{
  "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. 통계

GET /admin/shortlinks/:id/stats?from=&to=
{
  "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)의 소유권

이 두 장을 서버 코드 안의 문자열로 두면 문구 한 줄, 여백 4px 을 고칠 때마다 전 고객 유입이 통과하는 서버를 배포해야 한다. 가장 자주 고치는 화면을 가장 위험한 배포에 묶는 구조가 된다.
/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 · browserJSON.stringify
__STATE__(SL-10) inactive · expired · notfound · errorJSON.stringify
규칙내용
외부 요청 0건CSS·스크립트·로고를 전부 파일 안에 넣는다. 웹폰트도 부르지 않는다(시스템 글꼴). 0.5초 보이는 화면에서 네트워크를 한 번 더 타면 그만큼 흰 화면이 길어진다
스크립트 위치</body> 직전. <head> 에 두면 화면 요소를 찾지 못해 오류로 멈추고 이동 자체가 안 된다
전체 예외 처리스크립트 전체를 try/catch 로 감싸고, 오류가 나면 무조건 웹 목적지로 보낸다
깜빡임 방지본문을 투명하게 시작해 0.3초 뒤 CSS 로만 나타낸다(스크립트에 의존하지 않는다)
테스트개발 환경에서 ?_preview=inactive 같은 값으로 각 상태를 강제로 띄울 수 있게 한다

13-7. 시각 처리

시각 기준을 정하지 않으면 한국 시간 오전 9시 이전에 발생한 클릭이 전날로 집계된다. 서버가 UTC 로 돌고 있으면 그대로 벌어지는 일이고, 통계 화면과 순 클릭 중복 제거가 동시에 틀어진다.
구간규칙
저장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. 장애 대응

이 서버가 죽으면 이미 인쇄된 QR·명함·현수막이 전부 오류 화면이 된다. 회수할 수 없는 물건이 물려 있으므로, 기능보다 "죽었을 때 무엇이 보이는가" 를 먼저 정한다.
항목내용
헬스체크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-DD2026-09-21
일시YYYY-MM-DD hh:mm2026-09-21 14:30
최근 클릭 시각MM-DD hh:mm09-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. 권한

1차 오픈은 전체 관리자 동일 권한으로 간다. 현재 어드민 계정 체계에 권한 구분 기능이 없고, 그것부터 손대면 이 프로젝트가 계정 체계 개편에 묶인다. 누가 무엇을 했는지는 변경 이력(10-3)으로 추적한다. 역할별 분리는 2차 과제. [기획 확정 필요]

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-06vessel_id 는 어느 테이블의 무슨 ID 이고, 서버를 분리하면 그 DB 에 접근할 수 있는가선박명을 붙여 내려줄 수 없으면 목록·CSV·일괄생성이 전부 반쪽이 된다백엔드
Q-07선박 상세 주소 규칙 — 웹과 앱이 같은가문서 안에서도 vessel/{id}/vessels/{id} 로 갈려 있다. 목적지 자동 생성과 앱 딥링크가 이 값에 걸린다프론트·앱
Q-08슬러그 직접 입력을 허용하는가SL-02 의 입력칸 존재 여부가 바뀐다. 허용하면 [중복 확인] API 가 필요하다사업팀
Q-09OG 이미지를 URL 로 받는가, 업로드 받는가업로드면 저장소와 업로드 API 가 따로 필요하다. 기존 어드민 업로더를 재사용할 수 있는지 확인백엔드·기획
Q-10SL-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 가 전부 4044-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-08iOS 앱 미설치 시 동작웹으로 보냄(권장) / App Store 로 보냄기획
D-09"앱 전환" 지표 정의분기 시도 기준(추정) / 앱에서 별도 이벤트 수집기획·개발
D-10기본 OG 이미지선박 대표 이미지 자동 사용 / 공통 기본 이미지 1종디자인
D-11목적지 생존 점검1차 범위 포함 / 2차 과제기획
D-12선사 배포 방식어드민에서 뽑아 수동 전달 / 선주 앱에 노출사업팀
어바웃피싱 어바웃피싱 단축링크 구축 가이드 · 2026-09-21