로그인했는데, 사용자 속성이 전부 undefined였다
GTM 사용자 속성, 설정부터 매출 분석까지
프론트 작업을 끝내고 GTM 미리보기를 켰다. 로그인까지 했는데, 구성 태그 안의 user_id도 membership_status도 전부 undefined. 이 한 화면이 나를 GA4가 사람을 식별하고 저장하는 구조 전체로 끌고 들어갔다. 끝에 보니, 이건 매출을 사람 단위로 쪼개 보기 위한 가장 밑바닥 부품이었다.
분명히 로그인했는데, 왜 비어 있지
프론트엔드 트래킹 작업을 막 끝낸 참이었다. 사용자 ID, 회원 상태, 동의 여부 같은 값을 데이터 레이어(dataLayer, 웹페이지가 분석 도구에 값을 넘기는 통로)에 실어 보내도록 코드를 다 심었다. 이제 검증만 하면 된다. GTM(구글 태그 매니저) 미리보기를 켜고, 사이트에서 직접 로그인까지 했다.
그리고 GA4 구성 태그(Configuration, 사이트가 열릴 때 분석을 초기화하는 핵심 태그)를 열었다. 거기 사용자 속성 칸에 이렇게 찍혀 있었다.
| 이름 | 값 |
|---|---|
user_id | undefined |
membership_status | undefined |
is_logged_in | undefined |
device_id | undefined |
marketing_consent | undefined |
분명히 로그인한 상태였다. 회원으로 들어가 있는데, 그 회원이 누군지 알려주는 값이 하나도 없다. 코드를 잘못 심었나? 변수 이름을 틀렸나? 한참을 그 화면만 들여다봤다. 이게 시작이었다. 그리고 이 글은, 그 undefined가 사실은 버그가 아니라 GA4의 동작 원리를 정확히 비춘 거울이었다는 이야기다.
결정적 단서: page_category까지 비어 있었다
변수 이름을 다시 봐도 맞다. 코드도 값을 넣고 있다. 그런데 왜. 화면을 다시 훑다가, 사용자 속성 위쪽의 page_category도 똑같이 undefined인 걸 봤다. 이건 사용자 정보랑 출처가 전혀 다른 값이다. 페이지가 어느 카테고리인지를 담는, 화면이 그려질 때 들어오는 값.
출처가 다른 두 값이 똑같이 비어 있다. 공통점은 하나였다. 둘 다 "나중에" 들어오는 값이라는 것. 여기서 처음으로 의심의 방향이 바뀌었다. 값이 없는 게 아니라, 태그가 값보다 먼저 발동한 게 아닐까. 브라우저 콘솔에 window.dataLayer를 찍어 타임라인을 펼쳐봤다. 그림이 한 번에 보였다.
왜 늦게 올까. 구성 태그는 gtm.js, 즉 컨테이너가 로드되는 가장 이른 순간에 발동한다. 반면 로그인 상태를 확정하는 건 프론트엔드(React 같은 SPA)가 화면을 그리고 인증을 확인한 뒤다. 비동기다. 사용자가 누구인지 아는 코드는 페이지가 뜨고 나서야 값을 데이터 레이어에 밀어넣는다. 구성 태그가 본 undefined는, 그 순간의 진실을 정확히 찍은 것이었다. 값이 잘못된 게 아니라 사진을 너무 일찍 찍었을 뿐.
헛다리 두 번: 구성 태그에 더 박으려다, 이벤트를 새로 만들려다
원인을 알았으니 고치면 된다. 그런데 여기서 두 번 헛다리를 짚었다. 부끄럽지만 남긴다. 같은 길을 다시 헤매지 않으려고, 그리고 이 두 헛다리가 사실 핵심 개념으로 가는 가장 빠른 길이라서.
첫 번째 헛다리. "그러면 구성 태그에 값을 더 잘 박으면 되겠지." 아니다. 구성 태그(GA4의 Google 태그)에 설정한 값은 그 태그가 발동하는 순간 딱 한 번 읽고 굳는다. 정적(static)이다. 발동 시점이 로그인 전이니, 거기 아무리 잘 박아도 영원히 undefined다. 박는 위치를 바꾸지 않는 한 안 풀린다.
두 번째 헛다리. "그럼 로그인 컨텍스트가 준비됐다고 알리는 이벤트를 새로 만들어서, 그걸로 태그를 다시 쏘면 되겠네." 그럴듯했다. 한참 설계를 그렸다. 그런데 실무 표준을 확인하니 과한 짓이었다. 별도 이벤트는 필요 없다. 데이터 레이어가 누적되기 때문이다. 이 "누적"이 이 글에서 두 번째로 중요한 개념인데, 잠깐 정확히 짚고 가자.
누적 dataLayer: 한 번 들어온 값은 사라지지 않는다
데이터 레이어는 덮어쓰는 변수가 아니라, 계속 쌓이며 병합되는 모델이다. 실제 콘솔에서 펼친 배열을 보면 이렇다. 익명 상태의 컨텍스트가 한 번 들어오고(user_id: undefined), 로그인이 확정되자 user_id: '1'이 담긴 객체가 그 위에 push된다. GTM의 데이터 레이어 변수는 이 누적된 상태의 최신값을 읽는다. 그래서 로그인 값이 한 번 push되면, 그 뒤에 발동하는 모든 이벤트 태그가 그 값을 그대로 읽는다. 새 이벤트를 만들 게 아니라, 값을 읽는 위치만 옮기면 되는 거였다.
여기에 함정이 하나 숨어 있어 미리 박아둔다. 데이터 레이어 변수에는 버전이 둘 있다. 기본값인 버전 2는 누적된 값을 병합해 유지하지만, 버전 1은 그 push에 없는 키를 undefined로 본다. 변수를 버전 1로 만들어 두면, 누적의 이점이 사라져 또 비어 보인다. 사용자 속성용 변수는 버전 2여야 한다. 이걸 모르면 "분명히 이벤트 설정으로 옮겼는데 왜 또 undefined냐"에서 한 번 더 막힌다.
헛다리를 두 번 짚고 나서야 핵심이 보였다. 나는 계속 "어느 태그에 박을까"를 묻고 있었다. 그런데 사용자 속성은 애초에 그렇게 동작하는 물건이 아니었다.
전환점: 사용자 속성은 태그가 아니라 사람에 붙는다
여기가 이 글의 심장이다. GA4의 데이터에는 범위(scope)라는 게 있다. 이걸 모르면 평생 엉뚱한 데를 고친다. GA4 공식 문서는 사용자 속성을 "사용자 기반을 설명하는 속성(attributes that describe groups of your user base)"이라 정의한다. 행동이 아니라 사람을 설명하는 값이다. 이걸 이벤트 매개변수와 나란히 놓으면 성격이 완전히 갈린다.
여기서 결정적 성질이 나온다. 사용자 속성은 한 번 GA4에 도착하면, 그 사람에게 저장되어 이후 이벤트에 자동으로 따라붙는다. 값을 바꾸기 전까지 유지된다. 그래서 모든 태그에 일일이 실어 보낼 필요가 없다. 로그인 이벤트 때 "회원"이라는 값이 한 번 도착하면, GA4가 그 사람에게 도장을 찍고 나머지를 알아서 처리한다.
그제야 처음의 undefined가 왜 문제가 아니었는지 완전히 이해됐다. 구성 태그가 비어 있어도 상관없다. 로그인 이벤트가 값을 한 번 전달하면 그걸로 끝이다. 내가 며칠을 노려본 그 구성 태그 화면은, 애초에 값이 들어올 수 없는 자리였다. 잘못된 창문 앞에서 안이 어둡다고 한 셈이다.
그래서 어디에 박나: 이벤트 설정 변수 하나
값을 읽는 위치를 "발동 1회의 구성 태그"에서 "발동 시점마다 다시 읽는 이벤트"로 옮긴다. 그렇다고 태그마다 같은 값을 복붙하는 건 아니다. GTM 공식 문서가 정확히 이걸 위한 도구를 준다. 이벤트 설정 변수(Event Settings Variable). 한 번 만들어, 모든 GA4 이벤트 태그가 그 변수를 참조한다.
핵심은 공식 문서의 이 한 줄이다. "구성 태그가 설정하는 값과 달리, 이벤트 설정 변수의 값은 이벤트가 전송될 때마다 다시 계산된다(dynamically recalculated). 정적 값에 묶이지 않는다." 우리가 처음에 겪은 문제의 근본 해결책이 바로 이 동적 재계산이다.
설정은 이렇다. GTM에서 "Google 태그: 이벤트 설정" 타입 변수를 하나 만든다. 그 안 "사용자 속성" 칸에 is_logged_in, membership_status, device_id, marketing_consent를 각각 데이터 레이어 변수로 연결한다. 딱 한 번. 그리고 각 GA4 이벤트 태그의 "이벤트 설정 변수" 칸에서 이 변수를 고르기만 한다. 나중에 속성을 바꿀 일이 생기면 변수 하나만 고치면 전 태그에 반영된다.
그럼 구성 태그에는 아예 안 넣어도 되나? 여기엔 미묘함이 있다. 결론부터: 이중으로 두는 게 정석이다. 이번 세션처럼 로그아웃 상태로 들어와 로그인하는 흐름은 이벤트 설정 변수가 책임진다. 반대로 이미 로그인된 채로 재방문하면, 그때는 구성 태그가 발동하는 순간 값이 이미 있을 수 있어 첫 페이지뷰에 곧장 user_id가 붙는다. 그래서 실무 가이드도 "구성 태그에도 넣고, 안전장치로 모든 이벤트 태그에도 넣으라"고 권한다. 구성 태그는 재방문의 첫 히트를, 이벤트 설정은 현재 세션을 맡는 분업이다.
검증도 분명해졌다. 미리보기에서 이제 구성 태그(gtm.js)를 보지 않는다. 로그인 이후에 발동한 이벤트, 가령 login 태그를 열어보면 거기 user_id: '1', membership_status: 'member'가 또렷이 찍힌다. 보던 자리를 바꾸니 값이 있었다.
작은 흉터 하나. 실제 타임라인을 더 보니, marketing_consent는 login 이벤트보다 한 박자 늦게 true로 settle됐다. 사용자 객체가 먼저 오고 동의 값이 살짝 뒤에 도착한 거다. 그래서 login 이벤트 태그는 동의를 아직 false로 잡을 수 있다. 동의 여부로 무언가를 게이팅한다면, 그 판단을 이벤트 시점의 데이터 레이어 값에 의존하지 말고 서버가 발송 직전에 원본을 읽게 하는 편이 안전하다. 타이밍은 한 군데서 풀어도 옆에서 또 샌다.
곁가지인 줄 알았던 것: user_id는 사용자 속성이 아니다
속성 넷은 정리됐는데, user_id 하나가 자꾸 따로 놀았다. 알고 보니 user_id는 사용자 속성이 아니라 GA4의 예약된 식별자였다. "이 사람이 어떤 사람인가"를 설명하는 속성이 아니라, "이 데이터가 누구 것인가"를 정하는 신원의 뼈대다. 그래서 다루는 자리가 다르다. 사용자 속성 칸이 아니라, Google 태그의 user_id 전용 필드에 넣는다.
여기서 한 발 더 들어가니, GA4가 사람을 식별하는 방식 전체가 보였다. GA4는 한 가지 ID로 사람을 정하지 않는다. 보고 ID(Reporting identity) 문서대로, 우선순위 사다리를 쓴다. 위에 있으면 쓰고, 없으면 아래로 내려간다.
예전엔 구글 신호(Google signals)도 이 사다리에 끼어 있었지만, 구글이 식별 용도에서는 뺐다. 지금 식별 사다리는 위 셋이다. 그리고 이 사다리를 어디까지 쓸지 고르는 게 보고 ID 설정이다. 세 모드가 있다. Blended는 User-ID에서 시작해 없으면 device ID, 그래도 없으면 모델링까지 내려간다. Observed는 모델링을 빼고 실제 식별자만 쓴다. Device 기반은 User-ID를 아예 무시하고 device ID만 쓴다. 회원 분석이 중요하면 User-ID를 쓰는 Blended나 Observed로 둬야, 공들여 보낸 user_id가 실제 식별에 쓰인다. Device 기반으로 두면 그 값이 버려진다. 여기에 더해 GA4 관리에서 User-ID 보고를 켜야 교차 기기 식별이 리포트에 반영된다.
그 UUID는 뭔가
우리 device_id 값은 a48944fc-b19c-4557-8f2f-7fe0d0da4f36 같은 모양이다. 이게 UUID(범용 고유 식별자)다. 128비트 무작위 값이라, 누가 언제 어디서 만들든 사실상 겹치지 않게 설계돼 있다. 경우의 수가 천문학적이라, 중앙 서버에 묻지 않고 브라우저가 알아서 하나 찍어 보관해도 충돌 걱정이 없다.
중요한 건 이 값에 이름도 전화번호도 들어있지 않다는 점이다. 의미 없는 무작위 번호다. 그래서 사용자 속성에 넣어도 개인정보(PII)가 아니라 안전하다. 다만 여기에 진짜 식별자, 가령 전화번호를 넣으면 얘기가 달라진다. 전화번호를 SHA-256으로 해시해도 한국법상 "익명"이 아니라 가명정보다. 번호 공간이 좁아 받는 쪽이 매칭표를 가지면 되돌릴 수 있다. 그래서 진짜 사람을 가리키는 값은 사용자 속성이 아니라 별도 경로(가령 서버 측 전환 전송)에서, 별도 동의·고지 위에서 다뤄야 한다. 송장번호는 괜찮고, 받는 사람 이름은 안 된다.
설정의 마지막 함정: 보내기만 하면 안 보인다
GTM에서 값을 잘 보내도, GA4 리포트에는 안 나온다. 처음 하는 사람이 꼭 한 번 더 막히는 지점이다. "값을 보내는 것"과 "리포트에 보이게 하는 것"은 별개의 단계다.
GA4 공식 문서는 이렇게 말한다. "코드에 사용자 속성을 추가하면, 사용자 범위 맞춤 측정기준이 그 데이터를 분석할 수 있게 열어준다(unlocks the ability)." 등록을 안 하면 데이터는 들어오는데 리포트에서 안 보인다. 여기서 한 가지 더 깊게 짚을 게 있다. 맞춤 측정기준에는 범위가 둘이다. 이벤트 범위와 사용자 범위. 우리 넷은 반드시 "사용자" 범위로 등록해야 한다. 이벤트 범위로 잘못 등록하면 그 이벤트에만 붙고 사람에 누적되지 않는다. 한도도 다르다. 사용자 범위는 프로젝트당 25개, 이벤트 범위는 50개다.
절차는 이렇다. GA4 관리에서 맞춤 정의로 들어가, 범위를 "사용자"로 잡고, GTM에서 보낸 이름과 글자까지 똑같이 입력해 넷을 각각 등록한다. 이름은 대소문자를 구분한다. membership_status와 Membership_status는 다른 속성으로 잡힌다. 반영까지 하루 이틀 걸린다. 그 사이 제대로 들어오는지 보려면 GA4 DebugView에서 실시간으로 user property가 찍히는지 확인하면 된다. 리포트를 기다릴 필요 없다.
여기까지가 배관이다. 이제 이걸로 매출을 본다
설정은 수단이고, 진짜 목적은 분석이다. 사용자 속성이 들어오면, 이제 데이터를 사람을 기준으로 쪼갤 수 있다. 행동이 아니라 사람으로 자르는 순간, 평균에 가려져 있던 격차가 드러나고, 예산을 어디에 쓸지가 보인다.
구체적으로 매출로 잇는 길은 세 갈래다. 숫자는 예시이고, 핵심은 구조다.
1. 세그먼트 비교로 예산을 옮긴다
membership_status로 회원과 비회원의 전환율을 갈라 본다. 만약 회원의 상담 전환율이 비회원의 다섯 배라면, "신규 유입을 늘리는 광고"보다 "비회원을 회원으로 만드는 가입 유도"에 돈을 더 쓰는 게 맞다는 근거가 생긴다. 평균 전환율 하나만 보면 절대 안 나오는 판단이다. 평균은 늘 진실을 반쯤 가린다.
2. 잠재고객으로 가장 가까운 사람부터 데운다
is_logged_in = true이면서 아직 상담을 신청하지 않은 사람. 이들은 관심은 확인됐는데 전환만 안 된 모수다. 이 조건으로 GA4 잠재고객(Audience)을 만들어 광고 매체로 내보내면, 가장 전환이 가까운 사람부터 건드리게 된다. 차가운 신규보다 데우기 쉬운 미지근한 모수다. 같은 광고비로 전환을 더 뽑는 가장 싼 레버다.
3. 동의자 모수로 보낼 수 있는 채널을 넓힌다
marketing_consent = true인 회원만 추려, 문자나 알림톡처럼 동의가 필요한 채널의 대상으로 쓴다. 동의 여부를 데이터로 들고 있으면 "보내도 되는 사람에게만" 정확히 보낼 수 있다. 동의 없는 사람에게 잘못 보내는 사고를 구조적으로 막으면서, 보낼 수 있는 사람은 빠짐없이 닿는다. 리스크를 줄이는 동시에 도달을 넓히는, 드문 양쪽 이득이다.
한 걸음 더. device_id 같은 조인키가 있으면, 온라인에서 끝나지 않고 며칠 뒤 오프라인에서 일어난 전환(가입·개통·계약)까지 같은 사람으로 되묶어 광고 성과로 돌릴 수 있다. 사용자 속성은 그 사람-단위 연결의 한 조각이다. 다만 이건 다른 글 한 편짜리 주제라 여기선 입구만 가리킨다.
솔직히 짚을 것. 이건 "이렇게 설계하면 매출이 오른다"는 메커니즘이지, 내가 돌려서 받은 성과 숫자가 아니다. 위 전환율 8%와 1.5%도 구조를 보여주려 든 예시다. 진짜 수치는 등록한 측정기준에 데이터가 하루 이틀 쌓인 뒤에야 나온다. 다만 분명한 건, 사람으로 쪼갤 수 없으면 이 판단들 중 어느 것도 시작할 수 없다는 점이다. 사용자 속성은 그 쪼갬을 가능하게 하는 최소 부품이다.
가져갈 것: 사용자 속성 설정과 활용 체크리스트
이커머스 픽셀 하나 까는 감각으로 접근하면 "왜 구성 태그가 비어 있지"에서 며칠을 잃는다. 회원과 비회원이 섞이고, 로그인 전후가 갈리는 사업에서 사용자 속성을 다룬다면, 결론은 결국 몇 개의 원칙으로 모인다.
- 구성 태그의 undefined를 보고 놀라지 마라. 구성 태그는 가장 이른 순간에 발동한다. 로그인 값은 그 뒤에 온다. 검증은 구성 태그가 아니라 "로그인 이후 발동한 이벤트"에서 한다.
- 사용자 속성은 사람에 붙는다, 태그가 아니라. 한 번 도착하면 그 사람에게 저장돼 따라다닌다. 그래서 모든 태그에 복붙할 필요가 없다. 누적 dataLayer가 그걸 가능하게 한다.
- 값은 이벤트 설정 변수 하나에 담아 공유하라. 발동마다 다시 계산되니(동적) 타이밍 문제가 자동으로 풀린다. 정적인 구성 태그에만 박는 게 처음의 그 버그다. user_id는 안전장치로 구성 태그에도 이중으로.
- 데이터 레이어 변수는 버전 2로. 버전 1은 누적값을 못 읽어 또 undefined가 난다.
- user_id는 속성이 아니라 식별자다. Google 태그의 전용 필드에 넣고, 보고 ID를 User-ID 모드(Blended/Observed)로 둬야 식별에 쓰인다. User-ID 보고도 켜라.
- 내가 만든 device_id를 GA4의 Device ID와 혼동하지 마라. 이름만 같은 별개의 값이다. 우리 건 무작위 UUID라 개인정보가 아니지만, 전화번호 같은 진짜 식별자는 속성에 넣지 않는다. 해시해도 가명정보다.
- 설정은 두 단계다. GTM 전송 + GA4 "사용자" 범위 맞춤 측정기준 등록. 1단계만 하면 데이터는 쌓여도 리포트에 안 보인다. 이름은 대소문자까지 일치, 한도는 25개, 검증은 DebugView.
- 설정은 수단, 목적은 쪼갬이다. 회원 상태로 전환율을 가르고, 로그인 미전환자를 잠재고객으로 묶고, 동의자에게만 메시지를 보낸다. 사람으로 쪼개야 예산을 옮길 근거가 생긴다.
화면 하나의 undefined에서 시작했는데, 끝에 보니 그건 GA4가 사람을 어떻게 식별하고 저장하는지를 다 알아야 풀리는 질문이었다. 트래킹을 다 깔아놓고 분석을 나중에 얹는 게 아니라, "이 데이터로 결국 뭘 쪼개 볼 건가"를 처음부터 같이 그려야 하는 거였다. 사용자 속성은 그 쪼갬의 시작점이고, 매출을 사람 단위로 보기 위한 가장 밑바닥 부품이다. 빈 사진 한 장이, 거기까지 데려다줬다.
근거 · 출처
- GA4 사용자 속성(User properties), 구글 공식 도움말. 사용자 속성의 정의(사람을 설명하는 속성), 예약어, 프로젝트당 25개 한도, 이름 대소문자 구분의 근거.
- GA4 사용자 범위 맞춤 측정기준 만들기, 구글 공식 도움말. 수집(코드/GTM)과 등록(GA4 관리)이 별개 단계이고, 등록해야 리포트에서 분석 가능해진다는 근거.
- GTM 이벤트 설정 재사용(Event Settings Variable), 구글 공식 도움말. 사용자 속성을 한 변수에 담아 모든 이벤트 태그에 공유하고, 값이 전송마다 동적으로 재계산된다는 근거.
- GA4 보고 ID(Reporting identity), 구글 공식 도움말. User-ID, Device ID(client_id), 모델링의 우선순위 사다리와 Blended/Observed/Device 기반 모드의 근거.
- GA4 User-ID 보내기, 구글 개발자 문서. user_id가 예약 식별자로서 세션·기기를 가로질러 사용자를 잇는다는 근거.
- Analytics Mania, GA4 User ID 구성. 구성 태그 값이 정적이라 로그인 후에야 들어오는 값은 이벤트 설정으로 다루고, 안전장치로 이중 적용하는 게 실무 표준이라는 해설(2차 자료, 위 공식 문서로 교차 확인).
로그인 후 사용자 속성이 전부 undefined로 찍힌 GTM 디버깅에서 출발해, GA4의 범위·누적 dataLayer·식별자·2단계 설정·분석 활용까지 판 기록. 헛다리 두 번과 미확정(실측 전 예시 수치, 동의 타이밍)을 그대로 남겨, 다음엔 같은 자리에서 멈추지 않으려고.
이 글이 도움이 되었다면 공유해주세요
최신 인사이트
카카오톡 공유 썸네일 안 나올 때 해결법 (Kakao JavaScript SDK 2025)
카카오톡 공유하기에서 썸네일 이미지가 안 나오는 원인과 scrapImage로 KAGE URL을 변환해 해결하는 방법을 단계별로 설명합니다. 지금 확인하세요.
Claude Code 서브에이전트로 CRM 사용자 설명서 20분 만에 완성하는 방법
Claude Code 서브에이전트를 활용해 CRM 코드베이스에서 사용자 설명서와 비주얼 가이드북을 20분 만에 자동 생성한 실전 사례를 공유합니다. 지금 확인하세요.
GEO 생성형 엔진 최적화: 2025년 AI 마케팅 전략 완전 가이드
GEO 생성형 엔진 최적화란 무엇이며 기존 SEO와 어떻게 다른지, 2025년 AI 마케팅 전략의 핵심 실행 방법 5가지를 데이터와 함께 알아보세요.
관련 프로젝트
새 글을 이메일로 받아보세요
마케팅, 분석, 개발 인사이트를 정리해서 보내드려요.