본문으로 건너뛰기
zoe

마케팅 대시보드에서 발견한 이슈

마케팅 대시보드를 훑어보다가, 마케터님이 남겨둔 이슈 하나에 눈이 멈췄다. 제목은 "UTM 파라미터 유실 이슈". UTM이 뭔지는 몰랐지만 어디선가 들어본 단어라 본문을 열어봤는데, 그 안에는 확실히 아는 단어가 섞여 있었다. 서비스 루트로 들어오면 로그인으로 리다이렉트되면서 URL 쿼리 파라미터(utm_*)가 사라진다는 것이다. 리다이렉트라면 마케팅이 아니라 우리 라우터가 하는 일이다.

마케팅 백로그에 적혀 있던 그 이슈가 결국 우리 라우터 코드의 버그였다는 걸 알게 되기까지의 기록이다.

UTM이라는 것

UTM은 링크를 만드는 쪽이 URL에 미리 붙여두는 꼬리표다. utm_source(어디서 왔는지), utm_medium(어떤 유형인지), utm_campaign(무슨 캠페인인지)을 붙인 링크를 배포하면, 사용자가 클릭해 들어올 때 주소창에 그 값이 실린 채로 사이트가 열린다.

GA4는 page_view 이벤트를 보낼 때 현재 페이지 URL을 dl이라는 필드에 그대로 실어 보내고, GA4 서버가 거기서 utm_*을 파싱해 유입 출처로 집계한다. 그러니까 프론트엔드 코드는 UTM을 만들지도, 읽지도 않는다. 붙이는 건 마케터고 읽는 건 GA4 서버다.

그런데 그 사이에서 URL을 바꾸는 건 프론트엔드다. 문제는 정확히 거기서 생겼다.

어디서 사라졌나

우리 서비스의 루트로 접속하면 라우터가 두 번 리다이렉트한다. 루트는 대시보드로 보내고, 대시보드는 인증이 없으면 로그인으로 보낸다.

첫 번째 리다이렉트에서 쿼리 파라미터가 통째로 떨어져 나간다. 라우터의 redirect()에 목적지 경로만 넘기고 search를 넘기지 않았기 때문이다.

두 번째 리다이렉트는 조금 더 미묘하다. 원래 가려던 주소를 redirect= 파라미터 안에 인코딩해 보관하는데(로그인 후 이전 주소로 되돌아가기 위함), utm이 살아남았더라도 여기 인코딩된 채로 갇히면 GA4는 읽지 못한다. URL 문법상 redirect=%2F...%3Futm_source%3D... 안의 utm은 독립된 쿼리 파라미터가 아니라 redirect라는 파라미터 값의 일부다. 실제로 이 URL을 표준 파서에 넣으면 최상위 파라미터는 redirect 하나뿐이고 utm_source는 나오지 않는다. 그리고 GA4가 캠페인 데이터로 읽는 건 페이지 URL에 ?utm_source=...&utm_medium=... 형태로 실린 쿼리 파라미터다 (Collect campaign data with custom URLs (새 탭에서 열림), 2026-07 확인). 그러니 갇힌 utm은 GA4 눈에는 존재하지 않는 값이다.

마케터님이 지난번 회사 홈페이지 캠페인에서는 유입이 잘 잡혔다고 했던 것도 이걸로 설명됐다. 홈페이지는 리다이렉트가 없다. 랜딩한 URL 그대로 page_view가 나가니 utm이 온전했던 것이다.

우회, 그리고 근본 수정

흥미로웠던 건 마케터님이 이미 우회를 만들어두었다는 점이다. 캠페인 링크의 랜딩을 루트가 아니라 /auth/login으로 직접 지정하면 리다이렉트 자체가 없으니 utm이 온전히 전달된다. 링크 관리 시트에 "이 서비스는 로그인 페이지 랜딩 필수"라는 규칙까지 달아두셨다. 캠페인은 이걸로 굴러가지만, 앞으로 만들 모든 링크가 이 제약을 기억해야 하고 루트 링크는 영영 추적이 안 된다. 사람이 기억해서 유지되는 규칙은 언젠가 잊힌다. 보장은 코드가 하는 게 맞다고 판단해서, 우회는 걷어낼 수 있게 라우터 쪽을 고치기로 했다.

근본 수정은 두 줄이었다. 루트 리다이렉트에서는 search를 그대로 유지하고,

로그인 리다이렉트에서는 utm만 골라 최상위 쿼리로 다시 펼친다.

redirect= 안에 그대로 두는 선택지는 처음부터 배제했다. 위에서 본 대로 GA4가 읽지 못하는 위치이기 때문이다. 반대로 최상위에 펼치는 게 기존 동작을 깨뜨리지 않는지는 확인이 필요했는데, 로그인 흐름이 search에서 소비하는 건 redirect 하나뿐이었고 내부 경로 검증 가드도 그대로 통과했다. 낯선 파라미터가 늘어나도 아무도 읽지 않으면 무해하다.

무엇이 바뀌었나

배포 후 utm을 붙여 루트로 접속하면, 리다이렉트가 다 끝난 로그인 화면의 주소창에 utm이 최상위 쿼리로 그대로 남는다. Network 탭에서 page_view의 collect 요청을 열어보면 dl 필드에 utm 세 개가 실린 URL이 그대로 담겨 나간다. 체인의 어느 시점에 page_view가 나가든 URL에 utm이 있는 구조가 됐다.

dl 값 끝에 utm 세 개가 최상위 쿼리로 실려 나간다

솔직히 말하면 급한 일은 아니었다. 아직 본격적으로 마케팅을 하는 단계가 아니라서, 당장 잃고 있는 데이터도 많지 않았다. 그런데 계측은 소급이 안 된다. 기능 버그는 고치는 순간부터 정상이 되지만, 계측 버그는 고치기 전까지의 데이터가 전부 구멍으로 남는다. 캠페인이 늘어난 뒤에 이 버그를 발견했다면 그 사이의 유입은 영영 direct로 남았을 것이다. 유입이 적은 지금이야말로 측정 파이프를 고치기에 가장 싼 시점이었다.

남은 것

UTM은 마케팅 용어다. 붙이는 것도 읽는 것도 마케팅의 영역이다. 그런데 그 값이 살아서 도착하느냐를 결정하는 건 라우터, 즉 프론트엔드 코드였다. 다른 직군의 백로그에 우리 코드의 버그가 적혀 있었던 셈이다. 요즘 개발을 하면서는 이렇게 직군 간 경계가 사라지는 것이 오히려 이런 문제를 빠르게 해결하는 것의 시작이라고 느껴지기도 한다. 앞으로도 GA4에도 더 관심을 가지면서 수정해야 할 부분을 찾아 나가야 할 것 같다.