· 13분 읽기

문자 속 120자짜리 링크를 7자 코드로 – 자체 단축 URL 을 백엔드·웹·어드민에 걸쳐 만든 기록


1. 배경: 문자에 무엇이 찍히고 있었나

설로인의 알림톡은 카카오로 받지 못하는 사람(카카오톡 미사용, 채널 차단 등)에게 같은 내용을 대체 문자(LMS)로 보낸다. 대체 문자에는 카카오 버튼 대신 버튼 이름과 주소를 글자로 적는다. 아래는 dev 에서 월간 어워즈 당첨 안내를 받은 실제 화면이다.

단축 URL 반영 전 대체 문자: 신청하기 아래 4줄에 걸친 긴 원본 주소가 그대로 찍혀 있다
스냅샷 1 · 단축 URL 반영 전 대체 문자(dev). 받는 사람 이름과 당첨 상품명, 도메인, 수신거부 번호는 가렸다. 링크 한 줄이 화면 4줄을 차지하고, 문자인데 utm_source=kakao 다. 실제 UTM 은 이보다 훨씬 길다.

여기서 두 가지 문제를 봤다.

  1. 주소가 길다. 이 링크는 84자로 짧은 편이다. dev 에 쌓인 원본 주소는 평균 122자, 가장 긴 것은 180자다. utm_campaign=crm_grade_monthlyspecial_closing 같은 캠페인 UTM 이 붙기 때문이다. 문자에서는 주소가 그대로 보이므로 길수록 읽기 어렵고 수상해 보인다.
  2. 유입 집계가 섞인다. 문자 링크에도 카카오 버튼과 같은 UTM 이 붙어 있었다. 문자를 눌러 들어온 방문이 GA 에서 kakao / brand_message 로 잡힌다. 카카오와 문자 중 어느 채널이 효과가 있었는지 나눌 수 없다.

1-1. 원래는 줄이고 있었다: NCP 단축 URL 종료

예전 코드는 대체 문자를 만들 때 네이버 클라우드(NCP) 단축 URL API 를 부르고 실패하면 원본을 쓰도록(getShortUrl(url)?.result?.url ?: url) 되어 있었다. 이 서비스가 2025-03-20 에 종료되자 호출은 매번 401 로 실패했다. 실패하면 원본을 쓰는 구조라 장애 없이 원본 주소가 나가기 시작했다. 이후 이 죽은 호출을 걷어냈고 원본을 그대로 붙이는 코드만 남아 이번 작업 직전까지 이어졌다.

개선 전 LmsMessageServiceImpl.createReContent: 버튼 url_pc를 그대로 본문에 붙인다
스냅샷 2 · 개선 전 대체 문자 생성 코드. 주석에 종료 사실이 남아 있고, button.url_pc 를 줄이지 않고 그대로 붙인다.

1-2. 다른 외부 서비스가 아니라 직접 만든 이유

기준외부 단축 서비스(bit.ly 등)자체 단축 URL
종료·정책 변경이번처럼 끝나면 다시 원본으로 돌아간다우리 코드가 살아 있는 한 유지된다
문자에 보이는 도메인낯선 단축 도메인이라 스팸처럼 보인다회사 도메인 그대로라 받는 사람이 출처를 안다
발송 경로 의존건마다 외부 API 왕복이 생기고 호출량 제한도 있다이미 쓰고 있는 RDS 를 한 번 조회하고 같은 링크는 워커 메모리 캐시를 쓴다
필요한 것API 키, 비용테이블 하나, 조회 API 하나, 웹 페이지 하나

만들 것이 작고(테이블 1개 + 조회 1개 + 리다이렉트 페이지 1개), 서비스 종료를 한 번 겪었기 때문에 직접 만들기로 했다.

2. 전체 구조

어드민이 버튼별 UTM 을 저장하고, 배치가 카카오용과 문자용 링크를 나눠 알림톡 큐에 싣고, 워커가 문자 링크를 줄여 short_links 에 저장한 뒤 비즈뿌리오로 보낸다. 고객이 문자 링크를 누르면 웹이 API 서버에서 원본을 받아 302 로 보낸다
그림 1 · 만드는 쪽(워커)과 푸는 쪽(API 서버·웹)은 테이블 하나만 공유한다. 코드를 만드는 HTTP API 는 일부러 두지 않았다. 그런 API 가 있으면 누구나 우리 도메인으로 아무 주소에나 보내는 오픈 리다이렉터가 된다.

2-1. 설계에서 정한 것

결정이유
코드: base62 랜덤 7자 (627 ≈ 3.5조)순번(1, 2, 3 …)이면 앞뒤 번호를 넣어 남의 링크를 열어 볼 수 있다. 주문 알림 링크에는 주문번호가 들어 있다.
같은 원본 = 같은 코드 (원본 SHA-256 에 UNIQUE)캠페인 대량 발송은 수천 명에게 같은 링크가 나간다. 사람마다 행을 만들면 발송 건수만큼 테이블이 커진다.
원본은 항상 https:// 절대 URL 로 저장카카오 버튼 변수(#{url})는 스킴을 뗀 값이다. 그대로 저장하면 웹이 상대 경로로 풀어 /s/{도메인}/… 로 보낸다.
실패하면 원본으로 발송 (500ms 제한, 실패 후 30초 쉼)단축이 안 돼도 문자는 나가야 한다. RDS 가 막혔을 때 대량 발송이 건마다 500ms 를 기다리지 않도록, 한 번 실패하면 30초간 DB 를 건너뛴다.
302 + Cache-Control: no-store301/308(영구)이면 브라우저와 인앱 브라우저가 목적지를 기억한다. 그러면 나중에 원본을 고칠 수 없다.
카카오 버튼은 줄이지 않음 (플래그 기본 off)버튼 주소는 받는 사람에게 보이지 않는다. 그리고 카카오가 버튼 링크에 리다이렉트 주소를 허용하는지 아직 확인하지 못했다. 확인되면 short-link.kakao-button-enabled 를 켠다.

3. 만들기: 워커가 문자를 만들 때 줄인다

대체 문자는 두 경로에서 만든다. 주문·선물 같은 알림톡의 대체 문자(createReContent)와, 혜택 알림 같은 브랜드 메시지의 대체 문자(BrandMessageNotificationFacade)다. 두 경로 모두 LmsMessageService.shortenLink(url) 하나를 거치게 했다.

ShortLinkCode 규칙과 shortenLink 구현: 캐시, 500ms 타임아웃, 실패 시 30초 일시정지, findOrCreateCode
스냅샷 3 · 위: 코드 규칙(공통 모듈, 워커와 API 서버가 같이 쓴다). 아래: 워커의 shortenLink. 초록은 정상 경로, 빨강은 "쉬는 중이면 원본으로 보낸다" 분기다.

3-1. 같은 링크가 동시에 처음 들어오면

워커 파드가 여럿이거나 같은 링크가 다른 큐에서 함께 나가면, 같은 원본이 두 곳에서 동시에 처음 줄여질 수 있다. 각자 랜덤 코드를 만들어 넣으면 한 원본에 코드가 둘 생긴다. 그래서 원본 해시에 UNIQUE 를 걸고 INSERT … ON CONFLICT DO NOTHING 으로 넣었다.

두 워커가 같은 원본을 동시에 넣을 때: A 가 먼저 커밋해 코드 aB3xK9q 가 들어가고, B 는 ON CONFLICT 로 넣지 못한 뒤 해시로 다시 읽어 같은 코드를 쓴다
그림 2 · 진 쪽은 같은 트랜잭션에서 해시로 다시 읽는다. PostgreSQL 기본 격리 수준(READ COMMITTED)에서는 먼저 커밋된 행이 보이기 때문에 가능하다. 해시로도 없으면 원본 충돌이 아니라 랜덤 코드끼리 겹친 것이고, 그때는 새 코드로 다시 넣는다(최대 3번). 그림의 코드는 예시 값이다.
short_links DDL: code PK, target_url, target_url_hash, created_at, target_url_hash 유니크 인덱스
스냅샷 4 · 배포 전에 적용하는 DDL. 테이블이 없어도 워커는 원본으로 보내고 warn 을 남길 뿐 발송은 계속된다.

dev 에 적용한 뒤 일주일 동안 쌓인 행을 읽기 전용으로 조회했다.

dev short_links 25행: 코드, 해시 앞 12자, 원본 URL. 주문번호는 가림. yU0vyPs 와 di3spZS 행은 초록으로 표시
스냅샷 5 · dev short_links 전체 25행. 주문번호는 가렸고 도메인은 example 로 바꿨다. 초록 행은 반영 전 문자의 원본(yU0vyPs)과 반영 후 문자에 실린 코드(di3spZS)다.

행을 읽어 보면 설계가 의도대로 동작하는지 확인할 수 있다.

  • 첫 행 yU0vyPs 는 스냅샷 1 문자의 링크와 같은 원본이다. 그 문자를 단축 URL 로 다시 보냈다면 이 코드가 나간다. 같은 원본이니 같은 코드다.
  • di3spZS 는 7장 반영 후 문자에 실린 코드다. 이 행이 생기고 6시간 뒤에 나간 문자가 새 행을 만들지 않고 같은 원본의 기존 코드를 다시 썼다.
  • 어드민에서 템플릿별 문자 UTM 을 저장한(6장) 직후부터 생긴 혜택 알림 행은 utm_source=lms&utm_medium=push_lms_t 로 바뀌었다.
  • 예외가 하나 있다. z0AM7oH(기념일 쿠폰 발급)는 문자 링크인데 utm_source=kakao 다. 어드민을 확인해 보니 이 템플릿의 "문자 UTM" 칸에 카카오 값이 저장돼 있었다. 코드는 저장된 값을 그대로 붙였다. 운영 전에 고칠 설정 하나를 데이터로 찾은 셈이다.
  • 주문·선물 알림(gXyKBg6, TzM09vx)은 주소에 주문번호가 있어 주문마다 한 행이 생긴다. 지금은 지우지 않는다. 문자 속 링크는 몇 달 뒤에도 눌리기 때문이다. 테이블이 커지면 created_at 기준으로 보존 기간을 정하기로 했다.

5. 풀기: 웹과 API 서버

문자를 연 사람은 대개 로그인 상태가 아니다. 그래서 API 서버는 GET /api/short-links/{code} 하나만 로그인 없이 열었다. 웹은 이 응답을 받아 서버에서 바로 302 를 보낸다. 화면을 그릴 필요가 없으므로 클라이언트 JS 를 거치지 않는다.

웹 /s/[code].tsx getServerSideProps 와 resolveShortLink: 코드 모양 검사, 3초 타임아웃, http(s)만 허용, Sentry
스냅샷 6 · 웹(Next.js). 위: 페이지. 아래: 원본 조회 함수. 코드 모양이 아니면 API 를 부르지 않고, javascript: 같은 원본은 보내지 않는다. 404 가 아닌 실패는 Sentry 로 보낸다.

웹에서는 실패가 조용히 묻히지 않게 하는 데 신경을 썼다. 없는 코드도, API 장애도 사용자에게는 똑같이 404 페이지다. 그런데 장애라면 그 순간 나간 모든 문자의 링크가 "없는 페이지"가 된다. 그래서 404 가 아닌 실패(5xx, 3초 초과, 이상한 원본)만 Sentry 에 남겼다.

dev 에서 API 조회와 /s/{code} 리다이렉트를 curl 로 확인: 302 no-store, 없는 코드 404
스냅샷 7 · dev 에서 직접 호출한 결과(도메인은 example 로 바꿨다). yU0vyPs 는 302 로 원본을 돌려준다. ZZZZZZZ(없는 코드)와 not-a-code(모양이 틀림)는 404 다.

dev 워커의 origin 은 www 가 없는 도메인이다. 그래서 문자 속 주소를 누르면 기존 도메인 규칙(301 → www.)을 한 번 거친 뒤 /s/{code} 의 302 를 받는다. 7장 문자의 di3spZS 를 curl -IL 로 따라가 보면 301 → 302(no-store) → 200 순서다. 짧은 주소를 위해 www 를 뺀 대가로 왕복이 한 번 늘었다. 운영 origin 을 정할 때 같이 볼 부분이다.

/s/c1r8QkA 를 모바일 브라우저로 열면 홈으로 이동하고 UTM 이 유지된다. 배너 등 일부는 흐리게 처리
스냅샷 8 · /s/c1r8QkA 를 모바일 화면(390px)으로 연 결과. 홈으로 이동했고, 주소에 UTM 이 그대로 남아 GA 가 유입을 집계한다. 배너와 기획전 영역은 흐리게 처리했다.

6. 어드민: 카카오 UTM 과 문자 UTM 을 나눠 저장한다

링크를 줄이는 것만으로는 1장의 두 번째 문제(유입 집계가 섞임)가 풀리지 않는다. 문자 링크에 붙일 UTM 을 따로 받아야 했다. 원래 어드민 UTM 세팅은 버튼마다 모바일 UTM / PC UTM 을 받았다. 그런데 실제로 UTM 이 갈려야 하는 기준은 기기가 아니라 채널이었다. 그래서 칸을 카카오 UTM(모바일·PC 공통) / 문자(LMS) UTM 으로 바꿨다.

어드민 UTM 세팅 브랜드메시지 탭: 버튼별 카카오 UTM 과 문자(LMS) UTM 입력칸
스냅샷 9 · 어드민 UTM 세팅(브랜드메시지 탭)에서 "등급 유지 Lock-in" 템플릿을 펼친 화면. 문자 칸 설명에 "단축 URL 로 나감"을 적어 운영자가 결과를 예상할 수 있게 했다. 변경한 실제 컴포넌트를 로컬에서 띄웠고, 목록 데이터는 dev DB 에서 읽은 템플릿 값 그대로다. dev 어드민에 로그인하지 않으려고 API 응답만 로컬에서 넣었다.

저장값은 notification_template.button_utms(jsonb)의 { ordering, name, utmKakao, utmLms } 다. 배치는 발송 대기열을 중계할 때 이 값을 읽어 링크 두 개를 만든다.

// 배치 · TemplateButtonUtms
fun kakaoButtonUrl(baseUrl: String): String = UtmLink.append(baseUrl, kakaoQuery())

/** 카카오 UTM 만 있으면 UTM 없는 주소 — 카카오 UTM 을 문자에 섞지 않는다.
 *  버튼 UTM 이 아예 없으면 null — 워커가 buttonUrl 을 그대로 쓴다 */
fun lmsButtonUrl(baseUrl: String): String? {
    if (kakaoQuery() == null && lmsQuery() == null) return null
    return UtmLink.append(baseUrl, lmsQuery())
}

워커는 받은 메시지에서 fallbackButtonUrl ?: buttonUrl 을 줄여 문자에 싣고 카카오 버튼에는 buttonUrl 을 그대로 쓴다. fallbackButtonUrl 은 새로 생긴 필드다. 워커의 JSON 컨버터는 모르는 키를 무시하도록(FAIL_ON_UNKNOWN_PROPERTIES=false) 설정돼 있어서 배치와 워커 중 무엇을 먼저 배포해도 깨지지 않는다.

대상바꾼 것
어드민 화면버튼 행 입력을 카카오 UTM / 문자(LMS) UTM 으로 바꿈. 링크가 없는 버튼만 두 칸을 잠근다.
어드민 서버button_utms 원소를 utmKakao/utmLms 로 바꿈. 옛 키(utmMobile/utmPc)는 읽을 때 utmKakao 로 해석한다.
배치 어플리케이션중계 메시지에 buttonUrl(카카오) + fallbackButtonUrl(문자)을 실음. UTM 을 읽지 못하면 WARN 을 남기고 UTM 없이 보낸다.
API 서버·워커short_links, 워커 shortenLink, API 조회 엔드포인트, fallbackButtonUrl 수신
웹/s/[code] 리다이렉트 페이지

7. 결과: 같은 문자, 반영 전과 후

반영 전 대체 문자: 긴 원본 주소와 utm_source=kakao
반영 전 · 원본 84자가 4줄을 차지하고, 문자인데 utm_source=kakao
반영 후 대체 문자: 신청하기 아래 짧은 단축 URL 이 2줄로 찍혀 있다
반영 후 · https://{도메인}/s/di3spZS 35자, 원본에는 utm_source=lms. 받는 사람 이름과 당첨 상품명, 도메인, 수신거부 번호는 가렸다.

같은 월간 어워즈 당첨 안내를 반영 전과 후에 dev 에서 받은 화면이다. 링크가 4줄에서 2줄로 줄었다. 단축 주소 뒤의 원본은 /mypage/awards/apply?utm_source=lms&utm_medium=push_lms_t&utm_campaign=crm_grade_awards_win(120자)이다. 반영 전 문자보다 원본은 오히려 길어졌는데, 문자 UTM 을 따로 붙였기 때문이다. 그래도 받는 사람 눈에는 35자만 보인다. 이 문자로 들어온 방문은 이제 GA 에서 lms / push_lms_t 로 잡힌다. 그 사이 버튼 목적지도 /mypage/grade 에서 /mypage/awards/apply 로 바뀌었다. 그래서 두 화면의 원본이 같은 주소는 아니다.

8. 배운 것, 남은 것

  • 외부 서비스 종료는 조용히 온다. NCP 단축 URL 이 끝났을 때 장애는 없었다. 실패하면 원본을 쓰는 코드였기 때문이다. 대신 문자에 긴 주소가 실리기 시작했고 그 상태로 1년 반이 지났다. 이번에는 "실패하면 원본으로" 를 유지하면서 의존 대상을 우리 테이블로 옮겼다.
  • 줄이는 것과 집계를 나누는 것은 다른 문제였다. 링크만 짧게 했다면 문자 유입은 계속 카카오로 잡혔을 것이다. 그래서 어드민 화면(입력) → 어드민 서버(저장) → 배치(링크 2개) → 워커(단축)까지 다섯 레포를 같이 바꿨다.
  • 데이터를 보면 설정 실수도 보인다. short_links 를 읽다가 문자 UTM 칸에 카카오 값이 들어간 템플릿을 찾았다.
  • 남은 일
    • 카카오 버튼 단축: 카카오의 버튼 링크 정책을 확인한 뒤 플래그를 켠다.
    • 보존 기간: 주문 알림 행이 쌓이는 속도를 보고 created_at 기준으로 정한다.
    • 클릭 수: 지금은 단축 단계에서 세지 않는다. 302 + no-store 라 UTM 이 GA 로 그대로 넘어가므로 집계는 GA 를 기준으로 본다.

요약

  • 문제: 네이버 클라우드(NCP) 단축 URL 서비스가 2025-03-20 에 종료된 뒤로, 알림톡이 카카오로 닿지 않아 문자로 대신 나갈 때 UTM 이 붙은 원본 주소가 그대로 실렸다. 문자 링크에도 카카오용 UTM(utm_source=kakao)이 붙어 문자로 들어온 방문이 카카오 유입으로 잡혔다.
  • 해결: 외부 서비스를 다시 쓰지 않고 {우리 도메인}/s/{7자 코드} 를 직접 만들었다.
    • 만들기: 워커가 문자를 만들 때 줄인다. RDS short_links 에 저장하고 같은 원본이면 같은 코드를 쓴다.
    • 풀기: 웹(Next.js) /s/[code] 가 API 서버에서 원본을 받아 302 로 보낸다.
    • UTM 분리: 어드민에서 버튼마다 카카오 UTM / 문자 UTM 을 따로 저장하고 배치가 둘을 나눠 워커로 넘긴다.
  • 원칙: 단축은 부가 기능이다. 500ms 안에 못 줄이거나 DB 가 실패하면 원본 주소로 보낸다. 문자 발송은 막지 않는다.
  • dev 결과: short_links 에 25행이 쌓였다. 원본 길이는 평균 122자(최대 180자)이고, 실제로 받은 문자의 단축 주소는 35자다.

댓글

댓글을 불러오는 중…