구글애널리틱스 GA4 붙이기 — 태그 설치부터 API로 방문자수 읽기까지
구글애널리틱스(GA4)를 사이트에 붙이는 건 15분이면 끝납니다. 속성을 만들고 G- 로 시작하는 태그 한 줄을 <head> 에 넣으면 방문자수가 쌓이기 시작합니다.
문제는 그다음입니다. 화면으로 보는 것 말고 내 프로그램이 숫자를 직접 읽어오게 하려면 준비물이 완전히 다릅니다. 여기서 대부분 막힙니다. 저도 그랬습니다.
앞부분은 태그 설치, 뒷부분은 자동 조회입니다. 화면으로만 보실 거면 앞부분만 보셔도 됩니다.
GA4 속성 만들기 — 4단계
analytics.google.com 에 들어갑니다.
1. 왼쪽 아래 관리 → 계정 만들기 → 계정 이름 입력
2. 속성 만들기 에서 시간대와 통화를 확인합니다. 여기가 첫 번째 함정입니다. 기본값이 미국으로 잡히는 경우가 있는데, 그러면 화면의 "오늘 조회수"가 실제와 하루씩 어긋납니다. 나중에 바꿔도 과거 데이터는 안 고쳐집니다.
3. 데이터 스트림 → 웹 → 사이트 주소 입력 → 스트림 만들기
4. 만들어진 화면 오른쪽 위에 측정 ID G-XXXXXXXXXX 가 나옵니다. 복사해 둡니다.
태그 넣기
Google이 주는 로더를 <head> 안에 넣습니다. </head> 직전이 아니라 가능한 위쪽이 좋습니다. 페이지를 열자마자 실행돼야 이탈한 방문도 잡힙니다.
<script async src="https://www.googletagmanager.com/gtag/js?id=G-측정ID"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-측정ID');
</script>
정적 사이트를 빌드해서 쓴다면 템플릿의 <head> 에 한 번 넣어 모든 페이지에 자동으로 들어가게 합니다.
이때 설정값을 검증하고 넣어야 합니다. 설정 파일에서 읽은 값을 그대로 <script> 안에 끼우면, 그 값이 잘못됐을 때 스크립트가 주입되는 통로가 됩니다.
_MEASUREMENT_RE = re.compile(r"G-[A-Z0-9]{4,20}")
def ga_snippet(mid: str) -> str:
"""형식이 어긋나면 태그를 아예 넣지 않는다."""
if not _MEASUREMENT_RE.fullmatch(mid):
return ""
return f'<script async src="https://www.googletagmanager.com/gtag/js?id={mid}"></script>…'
넣고 나면 실제로 들어갔는지 확인합니다. 배포했다고 반영된 게 아닙니다.

미리보기 빌드에는 태그를 넣지 마세요. 검토하려고 연 화면이 전부 방문으로 잡혀서 통계가 실제 독자와 섞입니다. 빌드 옵션으로 갈라두는 편이 낫습니다.
여기서부터가 진짜입니다 — 숫자를 프로그램으로 읽기
화면으로 보는 것과 API로 읽어오는 것은 완전히 다른 일입니다. 태그를 넣었다고 읽어지지 않습니다.
함정 하나. 측정 ID로는 조회가 안 됩니다
증상 — 태그도 넣었고 화면에는 숫자가 보이는데, 코드로 조회하면 계속 실패합니다.
원인 — 값이 두 개 필요한데 이름이 비슷해서 하나만 챙기게 됩니다. 저는 측정 ID를 넣고 왜 안 되는지 한참 봤습니다.
해결 — 아래 두 값을 처음부터 둘 다 적어둡니다.
| 값 | 생김새 | 어디에 쓰나 | 어디서 찾나 |
|---|---|---|---|
| 측정 ID | G-XXXXXXXXXX |
사이트에 태그 넣을 때 | 데이터 스트림 화면 |
| 속성 ID | 987654321 같은 숫자 |
API 조회할 때 | 관리 → 속성 세부정보 |
속성 ID를 빨리 찾는 방법이 있습니다. GA4를 연 상태에서 주소창을 보면 p 뒤에 붙은 숫자가 속성 ID입니다.
analytics.google.com/analytics/web/#/a123456789p987654321/admin
└─ 이 숫자
함정 둘. 서비스 계정과 속성 권한이 둘 다 필요합니다
증상 — 인증은 통과하는데 조회에서 HTTP 403 이 납니다.
원인 — API는 사람 로그인이 아니라 서비스 계정으로 접근하는데, 키를 만든 것과 그 계정에게 이 속성을 볼 권한을 준 것은 별개이기 때문입니다.
해결 — 두 가지를 다 해야 열립니다.
1. 구글 클라우드 콘솔에서 서비스 계정을 만들고 JSON 키를 받습니다. 이 파일은 비밀번호와 같습니다. 권한을 600 으로 두고 저장소에 올리지 않습니다.
2. GA4 → 관리 → 속성 액세스 관리 → + → 서비스 계정 이메일을 뷰어로 추가합니다.
둘 중 하나만 하면 안 됩니다. 권한을 뺀 순간 바로 되돌아갑니다 — 캐시된 값이 남아 있어도 갱신이 안 됩니다.
권한은 속성 단위라, 사이트를 여러 개 운영하면 계정 하나를 여러 속성에 뷰어로 넣어 쓸 수 있습니다. 서로의 데이터는 보이지 않습니다. 저는 이미 쓰던 서비스 계정을 새 속성에 뷰어로 추가하는 것만으로 끝냈습니다. 키를 새로 받지 않아도 됐습니다.
둘 다 갖춰지면 이렇게 읽힙니다.

숫자가 0인 것은 정상입니다. 태그를 심은 당일이라 아직 쌓인 게 없습니다. 중요한 건 조회 자체가 200 으로 열렸다는 점입니다. 저는 여기서 한 번 헷갈렸는데, 0이 나오길래 권한이 덜 붙은 줄 알고 설정을 다시 봤습니다. 연결 실패와 데이터 없음은 화면에서 구분되게 만들어두는 편이 낫습니다.
함정 셋. 요청은 5개까지만 묶입니다
증상 — 코드는 맞는데 HTTP 400 만 돌아옵니다. 인증 문제인 줄 알고 키부터 다시 봅니다.
원인 — 여러 보고서를 한 번에 받으려고 batchRunReports 를 쓰는데, 6개를 보내면 전부 실패합니다. 하나가 잘리는 게 아니라 요청 전체가 거부됩니다.

해결 — 요청을 5개 이하로 줄입니다. 저는 요약·직전기간·일별추이·인기페이지·유입채널·유입출처 여섯 개를 한 번에 보내다 막혔고, 차원을 합쳐서 5개로 만들었습니다.
# 채널과 출처를 한 요청에 담고, 받은 뒤에 각각 합산한다
_req(start, end, ("sessions", "activeUsers"),
("sessionDefaultChannelGroup", "sessionSource"), limit=200)
받아온 표를 코드에서 두 번 훑어 채널별·출처별로 각각 더하면 보고서 두 개를 만든 것과 같은 결과가 나옵니다. 요청 하나를 아끼는 대신 코드가 조금 늘어나는 교환입니다.
이 제한은 요청 개수만 세기 때문에, 작은 요청 하나로 먼저 200 을 확인하고 나서 늘리는 순서로 만들면 훨씬 빨리 찾습니다. 저는 여섯 개를 한 번에 만들어 던져놓고 인증부터 의심했습니다.
함정 넷. 검색어는 GA4가 주지 않습니다
블로그를 운영하면 "무슨 검색어로 들어왔나"가 제일 궁금한데, GA4에는 그 데이터가 없습니다. 구글도 네이버도 검색어를 애널리틱스에 넘기지 않습니다.
| 알고 싶은 것 | GA4 | 어디서 봐야 하나 |
|---|---|---|
| 방문자수·조회수 | 됩니다 | GA4 |
| 유입 채널(검색·SNS·직접) | 됩니다 | GA4 |
| 유입 출처(google·naver) | 됩니다 | GA4 |
| 실제 검색어 | 안 됩니다 | 서치콘솔·서치어드바이저 |
검색어까지 보려면 서치콘솔을 따로 붙여야 합니다. GA4에서 찾다가 시간을 쓰지 않으셔도 됩니다.
해결이라기보다 우회입니다. 서치콘솔과 GA4를 연결하면 한 화면에서 같이 보이긴 하는데, 그것도 검색어 데이터를 GA4로 옮겨주는 게 아니라 두 화면을 나란히 놓아주는 수준입니다. 검색어별 방문 흐름을 끝까지 이어보는 방법은 아직 찾지 못했습니다. 두 쪽 데이터를 글 주소 기준으로 직접 맞춰 붙이면 될 것 같은데, 그건 아직 안 해봤습니다.
조회 코드
인증부터 조회까지 최소 형태입니다.
from google.auth.transport.requests import Request
from google.oauth2 import service_account
import json, urllib.request
SCOPE = "https://www.googleapis.com/auth/analytics.readonly"
API = "https://analyticsdata.googleapis.com/v1beta/properties/{}:batchRunReports"
cred = service_account.Credentials.from_service_account_file(KEY_PATH, scopes=[SCOPE])
cred.refresh(Request())
body = {"requests": [{
"dateRanges": [{"startDate": "7daysAgo", "endDate": "today"}],
"metrics": [{"name": "activeUsers"}, {"name": "screenPageViews"}],
}]}
req = urllib.request.Request(API.format(PROPERTY_ID),
data=json.dumps(body).encode(),
headers={"Authorization": f"Bearer {cred.token}",
"Content-Type": "application/json"})
두 가지를 같이 넣어두면 운영이 편합니다.
- 캐시 — 화면을 열 때마다 조회하면 한도를 금방 씁니다. 저는 기간별로 15분씩 담아 두고, 새로고침 버튼을 눌렀을 때만 강제로 다시 읽게 했습니다.
- 폴백 — 조회가 실패하면 예외로 죽이지 말고, 마지막으로 받은 값과 "왜 못 읽었는지"를 화면에 보여줍니다. 권한이 빠졌는지, 네트워크 문제인지, 아직 데이터가 없는 건지가 화면에서 구분돼야 고칠 수 있습니다.
오류 메시지를 만들 때는 키 파일 내용이 섞이지 않게 합니다. 예외를 그대로 문자열로 만들면 인증 정보가 로그에 남을 수 있습니다.
except Exception as exc:
# private_key 가 예외 문자열에 섞이지 않도록 종류만 알린다
raise AnalyticsUnavailable(f"인증 실패 ({type(exc).__name__})") from exc
키 파일 권한도 코드에서 확인하게 해두면 좋습니다. 실수로 644 로 풀린 채 서버에 올라가는 일이 생기는데, 조회할 때마다 검사하면 바로 잡힙니다.
붙이고 나면 확인할 것
| 확인 | 방법 | 정상 |
|---|---|---|
| 태그가 들어갔나 | 페이지 소스에서 googletagmanager 검색 |
1곳 |
| 수집이 시작됐나 | GA4 → 보고서 → 실시간 |
본인 방문이 잡힘 |
| API가 열렸나 | 조회 코드 실행 | HTTP 200 |
| 값이 맞나 | 실시간 화면과 API 결과 비교 | 비슷한 수치 |
태그를 심은 날부터 데이터가 쌓입니다. 과거는 소급되지 않습니다. 그래서 사이트를 만들 때 제일 먼저 붙여두는 편이 낫습니다.
흔한 오해 셋
| 이렇게 알기 쉽다 | 실제 |
|---|---|
| 태그를 넣으면 API도 된다 | 서비스 계정과 속성 권한이 따로 필요합니다 |
| 측정 ID 하나면 된다 | 조회에는 숫자로 된 속성 ID가 따로 있습니다 |
| GA4에 검색어가 있다 | 없습니다. 서치콘솔을 봐야 합니다 |
판정 — 계속 쓴다
무료이고, 태그 설치는 15분이면 끝납니다. 방문자수·유입 채널·인기 글까지 보는 데 다른 선택지를 찾을 이유가 없습니다.
자동 조회는 반나절 잡으시면 됩니다. 위 함정 넷을 미리 알면 훨씬 빠릅니다. 저는 요청 5개 제한을 모르고 한참 헤맸습니다 — 코드는 맞는데 HTTP 400 만 나와서 인증 문제인 줄 알았습니다.
검색어를 기대하고 붙이면 실망합니다. 그건 서치콘솔의 몫입니다.
직접 붙이실 분께
하나. 속성을 만들 때 시간대부터 확인합니다. 나중에 바꿔도 과거 데이터는 안 고쳐집니다.
둘. 측정 ID와 속성 ID를 처음부터 둘 다 적어둡니다. 나중에 찾으려면 화면을 헤매게 됩니다.
셋. 자동 조회를 만들 때는 작은 요청 하나로 먼저 200을 확인하고 나서 보고서를 늘립니다. 여섯 개를 한 번에 만들어 던지면 어디서 틀렸는지 구분이 안 됩니다.
넷. 서비스 계정 키 파일은 저장소에 올리지 않습니다. .gitignore 에 먼저 적어두고 파일을 만드는 순서가 안전합니다.
다음에는 서치콘솔을 붙여서 실제 검색어까지 한 화면에서 보게 만들어보려고 합니다. 되면 이어서 적겠습니다.