Search Console 성과 보고서에서 페이지 노출 합계는 보이는데 페이지·검색어 표에 행이 적거나 검색어가 나타나지 않을 수 있습니다. 이를 곧바로 “검색 수요가 없다”거나 “다른 페이지와 겹치지 않는다”고 해석하면 안 됩니다. 먼저 같은 속성·기간·검색 유형·필터를 보고 있는지 확인하고, 페이지 합계와 검색어 행을 서로 다른 데이터로 기록하세요.
이 글은 Search Console 화면과 Search Analytics API의 데이터를 점검하는 방법에 한정합니다. 아래 표와 CSV는 설명을 위한 합성 데이터이며, 실제 계정·실적·사용자 검색어가 아닙니다. 검색량·순위·콘텐츠 성과를 예측하거나 보장하지 않습니다.
먼저 구분할 두 가지 표
Search Console 성과 보고서의 차트 합계와 표의 행은 집계 단위가 다를 수 있습니다.[2] 페이지·검색어 행은 전체 검색어 목록과 같지 않을 수 있습니다. Google은 일부 익명 검색어를 개인정보 보호를 위해 표에서 생략하지만, 검색어 필터를 적용하지 않으면 차트 합계에는 포함한다고 안내합니다. 또 내부 데이터 제한으로 익명 검색어 외에도 모든 행이 표에 나타나는 것은 아닙니다.[1] 따라서 아래 두 질문을 분리합니다.
- 이 페이지의 지정 기간 전체에서 어떤 페이지 성과가 관찰됐나?
- 그 중 화면/API 응답이 페이지·검색어 차원으로 보여 준 행은 무엇인가?
두 번째 표에 보이지 않는 항목을 0으로 채우지 마세요. 표에 없는 행은 “확인된 0”이 아니라 “이 보고서에서 확인되지 않은 값”으로 기록합니다.
확인 1. 정확한 페이지와 속성을 맞춥니다
Search Console 속성은 도메인 속성 또는 URL 접두어 속성일 수 있습니다.[6] 내보낸 페이지 URL이 해당 속성 범위에 포함되는지, 다른 호스트(www 여부 포함)를 확인했는지 기록하세요.[6] URL Inspection의 사용자 선언 canonical과 Google 선택 canonical은 서로 다른 필드입니다. 화면에서 확인한 URL과 Google 선택 canonical을 혼동하지 말고 따로 기록하세요.[7]
| 기록 | 반드시 남길 내용 |
|---|---|
| 속성 | 화면에 표시된 정확한 속성 |
| 대상 페이지 | 전체 URL과 확인한 canonical |
| 출처 | UI 내보내기인지 Search Analytics API 응답인지 |
| 상태 | 사용자 권한 및 확인 일시. 권한 토큰 등 인증정보는 저장하지 않음 |
페이지 필터를 적용했다면 화면에 표시된 필터 유형(예: URL 포함, URL 미포함, 맞춤 정규식)을 그대로 기록합니다.[8] Search Analytics API의 equals, contains, notContains, notEquals, includingRegex, excludingRegex 연산자와 Search Console 화면의 필터 표기는 서로 구분하세요.[3] URL 접두어 속성은 속성 유형이지 페이지 필터 연산자가 아닙니다. 필터가 달라지면 행 수와 합계를 직접 비교하지 마세요. 예제는 받은 정수 값을 비교하며, API의 숫자 필드를 CSV로 바꿀 때는 직렬화된 표현을 확인하세요.[3]
확인 2. 날짜·검색 유형·차원을 한 세트로 맞춥니다
기간이 다르면 노출·클릭 합계가 달라지는 것이 자연스럽습니다. 비교 전 다음 항목을 같은 표에 고정합니다.
- 시작일과 종료일. Search Analytics API의
startDate·endDate는 PT(미국 태평양 시간) 기준이며 양 끝 날짜를 포함합니다. 성과 데이터는 보통 2–3일 늦게 반영되고 최근 날짜 자료는 불완전할 수 있으므로, 비교 시 최신 며칠은 제외하거나 잠정치로 표시합니다.[3][4] - 검색 유형(예: 웹 검색)
- 페이지 및 검색어 차원
- 적용한 페이지·검색어 필터
- 기기·국가 등 추가 차원이나 필터
수정 전후를 비교할 때는 두 기간의 길이와 각 기간에 해당 글이 실제 공개된 시점을 확인합니다. 수정 전에 끝난 보고서로 수정 후 성과를 판단하지 마세요. 기간 중 제목·본문·색인 상태 등 여러 요소가 바뀌었다면 어느 하나를 원인으로 단정하지 않습니다.
확인 3. API의 행 제한과 미반환을 고려합니다
Search Analytics API의 query 요청은 dimensions로 결과 행을 그룹화하고, dimensionFilterGroups로 조건을 적용합니다. 필터는 그룹화 차원 외의 차원에도 적용할 수 있으며, 여러 필터 그룹은 모두 일치해야 합니다. 현재 API는 groupType: "and"만 지원합니다.[3] rowLimit은 요청당 1–25,000행(기본 1,000), startRow는 0부터 시작(기본 0)합니다. 더 가져올 행이 있으면 startRow를 받은 행 수만큼 늘려 다음 묶음을 요청하고, 빈 응답까지 반복합니다. 이는 검색 유형별 일 최대 50,000행 데이터 제한과 별개의 요청당 한도입니다.[3][4] API가 내부 제한으로 모든 행을 반환하지 않을 수도 있습니다. 요청 조건·응답 행 수·페이징 상태를 보존하되, 결과 행이 적다는 사실만으로 검색 수요나 누락 원인을 단정하지 마세요.
Search Console 성과 보고서의 평균 게재순위는 검색 결과에 나타난 해당 속성 또는 페이지의 가장 위쪽 결과(topmost result) 위치를 평균한 값입니다.[5] 차트는 사이트 전체의 해당 결과 평균을, 표는 표시 중인 URL 또는 그룹 차원의 평균을 나타냅니다.[2] 따라서 고정 순위나 모든 개별 검색어의 현재 위치로 해석하지 않습니다.[5] 아래 합성 CSV 예제는 position을 보여 주기만 하며, 점수를 더하거나 평균을 재계산하지 않습니다.
확인 4. 페이지 합계와 검색어 행을 따로 대조합니다
다음은 같은 페이지·기간·검색 유형을 조회했다고 가정한 합성 예시입니다.
| 집계 범위 | 클릭수 | 노출수 | 주의 |
|---|---|---|---|
| 페이지 전체 | 12 | 480 | 페이지 기준 합계(합성) |
| 화면/API에서 확인한 페이지·검색어 행의 합 | 8 | 230 | 반환된 행만 합산(합성) |
| 숫자 차이 | 4 | 250 | 숨은 검색어 수나 원인을 뜻하지 않음 |
숫자 차이가 있다고 해서 오류, 숨은 특정 쿼리, 추가 검색 수요라고 단정할 수 없습니다. 반환된 행과 상위 집계가 다른 사실만 기록하고, 기간·검색 유형·필터·API 페이징을 다시 확인합니다.
아래 코드는 같은 페이지·기간·검색 유형, 페이지와 검색어 두 차원만 사용한 내보내기를 정규화한 CSV를 입력으로 받습니다. 헤더는 예시처럼 정확히 page,query,clicks,impressions,position이어야 합니다. UI에서 특정 페이지를 필터링했는데 CSV에 page 열이 없다면 원본을 보존하고, 그 한 페이지의 검색어 행만 복사한 작업본에 해당 URL을 page 값으로 채워 넣으세요. 지역화된 열 이름은 작업본에서 명시적으로 대응시키고, 여분 차원·요약행은 제거하거나 별도 처리하세요. 페이지 전체 합계는 별도 보고서에서 입력합니다. 여러 페이지의 행을 한 페이지 합계와 섞지 마세요.
코드는 클릭·노출 합계를 비교할 뿐 원인을 추측하지 않습니다. 헤더만 있는 파일은 숫자 0으로 바꾸지 않고 None으로 구분합니다. Python 3.11 이상 표준 라이브러리만 필요합니다.
import csv
import io
import math
CSV_TEXT = """page,query,clicks,impressions,position
https://example.test/guide,n8n 웹훅 오류,4,90,6.0
https://example.test/guide,n8n 웹훅 재시도,3,80,8.7
https://example.test/guide,워크플로 항목 누락,1,60,9.5
"""
EXPECTED_PAGE = "https://example.test/guide"
PAGE_TOTALS = {"clicks": 12, "impressions": 480} # 합성 예시
def summarize_query_rows(csv_text: str, expected_page: str) -> dict[str, int] | None:
reader = csv.DictReader(io.StringIO(csv_text))
required = ("page", "query", "clicks", "impressions", "position")
if not expected_page.strip():
raise ValueError("expected_page must be non-empty")
received = reader.fieldnames or []
if received != list(required):
duplicates = sorted({name for name in received if received.count(name) > 1})
missing = [name for name in required if name not in received]
extra = [name for name in received if name not in required]
order_mismatch = not duplicates and not missing and not extra
raise ValueError(
f"CSV headers must be exactly {list(required)} in this order; "
f"received={received}; duplicates={duplicates}; missing={missing}; "
f"extra={extra}; order_mismatch={order_mismatch}"
)
rows = list(reader)
if not rows:
return None # 검색어 행이 없음: 수치 0과 구별
for row in rows:
if None in row:
raise ValueError("CSV row has extra cells; check delimiters and headers")
if any(row[field] is None or not row[field].strip() for field in required):
raise ValueError("CSV row has a missing required value")
if row["page"].strip() != expected_page.strip():
raise ValueError("CSV contains a page outside the selected page")
try:
clicks = int(row["clicks"])
impressions = int(row["impressions"])
position = float(row["position"])
except (TypeError, ValueError) as exc:
raise ValueError("clicks and impressions must be integers; position must be numeric") from exc
if clicks < 0 or impressions < 0:
raise ValueError("clicks and impressions cannot be negative")
if clicks > impressions:
raise ValueError("clicks cannot exceed impressions")
if not math.isfinite(position) or position <= 0:
raise ValueError("position must be finite and greater than zero")
return {
"clicks": sum(int(row["clicks"]) for row in rows),
"impressions": sum(int(row["impressions"]) for row in rows),
}
if __name__ == "__main__":
query_totals = summarize_query_rows(CSV_TEXT, EXPECTED_PAGE)
assert query_totals == {"clicks": 8, "impressions": 230}
empty = summarize_query_rows(
"page,query,clicks,impressions,position\n", EXPECTED_PAGE
)
assert empty is None # 행 없음은 0과 다름
def expect_value_error(call, message_fragment: str | None = None):
try:
call()
except ValueError as exc:
if message_fragment is not None:
assert message_fragment in str(exc), str(exc)
return
raise AssertionError("expected ValueError")
expect_value_error(lambda: summarize_query_rows(CSV_TEXT, "https://example.test/other"))
expect_value_error(lambda: summarize_query_rows(CSV_TEXT, " "))
expect_value_error(lambda: summarize_query_rows(CSV_TEXT.replace("4,90,6.0", "-1,90,6.0", 1), EXPECTED_PAGE))
expect_value_error(lambda: summarize_query_rows(CSV_TEXT.replace("4,90,6.0", "4,90,6.0,extra", 1), EXPECTED_PAGE))
expect_value_error(lambda: summarize_query_rows(CSV_TEXT.replace("4,90,6.0", "4,90", 1), EXPECTED_PAGE))
expect_value_error(lambda: summarize_query_rows(CSV_TEXT.replace("6.0", "nan", 1), EXPECTED_PAGE))
expect_value_error(lambda: summarize_query_rows(CSV_TEXT.replace("6.0", "0", 1), EXPECTED_PAGE))
expect_value_error(lambda: summarize_query_rows(CSV_TEXT.replace("4,90,6.0", "91,90,6.0", 1), EXPECTED_PAGE))
expect_value_error(
lambda: summarize_query_rows("query,page,clicks,impressions,position\n", EXPECTED_PAGE),
"order_mismatch=True",
)
expect_value_error(
lambda: summarize_query_rows("page,page,clicks,impressions,position\n", EXPECTED_PAGE),
"duplicates=['page']",
)
expect_value_error(
lambda: summarize_query_rows("page,query,clicks,impressions\n", EXPECTED_PAGE),
"missing=['position']",
)
expect_value_error(
lambda: summarize_query_rows("page,query,clicks,impressions,position,country\n", EXPECTED_PAGE),
"extra=['country']",
)
gap = {
key: PAGE_TOTALS[key] - query_totals[key]
for key in PAGE_TOTALS
}
assert gap == {"clicks": 4, "impressions": 250}
print({"page_totals": PAGE_TOTALS, "visible_query_rows": query_totals, "difference": gap, "empty_export": empty})
자기 점검은 합성 CSV의 합계·대상 페이지와 빈 파일 처리, 잘못된 헤더·셀·수치 입력을 확인합니다. API가 전체 데이터를 반환했는지, 검색어가 왜 표에 없었는지, 실제 페이지가 성과를 얻었는지는 검증하지 않습니다. 내보낸 CSV를 공유하거나 보관하기 전에는 계정·사용자·민감 검색어 등 필요한 비공개 정보를 최소화하세요.
확인 5. 콘텐츠 조치는 검색어 한 줄만으로 정하지 않습니다
반환된 검색어 행은 이미 사이트가 관찰한 일부 검색 맥락이지 전체 검색 수요나 사용자 의도의 완전한 목록이 아닙니다. 다음은 편집 판단을 위한 권고이며 Google의 공식 순위 규칙은 아닙니다.
- 검색어가 페이지의 실제 주제와 맞고, 독자가 필요한 답이 빠져 있다면 기존 페이지에서 그 답과 근거를 보완할지 검토합니다.
- 검색어가 현재 글의 목적과 다르다고 해서 문구만 끼워 넣지 않습니다. 그 질문을 별도 글로 다룰 독자·상황·고유한 답이 있는지 먼저 적습니다.
- 페이지·검색어 행이 없거나 일부만 보인다면 새 글·삭제·병합 결정을 보류하고, 속성·기간·차원·필터·데이터 반환 조건을 확인합니다.
- 비슷한 주제의 페이지가 이미 있으면 두 글의 독자 과업, 실제 답, 근거, URL별 데이터를 비교합니다. 제목 유사도만으로 합치거나 삭제하지 않습니다.
- 결과를 기록할 때 관찰된 사실(기간/필터/행/합계)과 편집 판단(보완/별도 검토/보류)을 분리합니다.
뉴스·트렌드 자료에서 새 주제를 찾고 초안까지 자동화하는 접근은 별도의 범위입니다. 이 글은 이미 공개된 페이지의 Search Console 데이터를 해석할 때 페이지 합계와 검색어 행을 혼동하지 않는 데 초점을 둡니다.
기록용 체크리스트
- ☐ 속성과 대상 페이지의 전체 URL을 기록했다.
- ☐ 기간·검색 유형·차원·필터가 비교 대상과 같은지 확인했다.
- ☐ 페이지 전체 합계와 페이지·검색어 행을 따로 보관했다.
- ☐ API의 요청 행 수·페이징 및 데이터 제한을 기록했다.
- ☐ 검색어 행에 없는 값을 0이나 검색 수요 부재로 바꾸지 않았다.
- ☐
position을 평균 지표로 설명하고 고정 순위라고 부르지 않았다. - ☐ 합성 예제를 실제 계정 자료·사례처럼 표시하지 않았다.
- ☐ 수정 후 기간이 없다면 성과 평가를 보류했다.
- ☐ 보완·별도 글·보류 결정의 이유와 근거를 남겼다.
Sources
[1] https://support.google.com/webmasters/answer/17011259?hl=en [2] https://support.google.com/webmasters/answer/7576553?hl=en — Performance report (Search results): Overview and basic setup [3] https://developers.google.com/webmaster-tools/v1/searchanalytics/query — Search Analytics: query [4] https://developers.google.com/webmaster-tools/v1/how-tos/all-your-data — Getting your performance data [5] https://support.google.com/webmasters/answer/7042828?hl=en — What are impressions, position, and clicks? [6] https://support.google.com/webmasters/answer/34592?hl=en — Add a website or platform property to Search Console [7] https://support.google.com/webmasters/answer/9012289?hl=en — URL Inspection tool [8] https://support.google.com/webmasters/answer/17011165?hl=en — Performance report (Search results): Advanced filtering and comparison
'🤖 1인 에이전트 구축기' 카테고리의 다른 글
| 콜드 이메일 자동 후속 발송: 추적 한계·수신 동의·도메인 인증 (0) | 2026.06.22 |
|---|---|
| 뉴스 데이터에서 핫토픽을 추출하고 콘텐츠 초안을 자동화하는 방법 (0) | 2026.06.19 |
| 유튜브 숏폼·블로그 자동 추출, 아직도 직접 타이핑하나요? (0) | 2026.06.18 |
| 인스타그램 DM 자동화 구축: Meta API·LLM 답변·상담원 전환 (0) | 2026.06.18 |
| 경쟁사 광고 분석 리포트 자동화 설계 가이드 (0) | 2026.06.18 |