# Log Router·Pub/Sub·Cloud Functions를 하나로 연결하기

앞에서 개념으로 다룬 평탄화·Log Router·Pub/Sub·Cloud Functions를 실제로 이어, GA4 데이터가 BigQuery에 도착하면 평탄화 마트가 자동 갱신되는 파이프라인을 단계별로 만듭니다. 예약 쿼리를 온디맨드로 저장하고, 적재 완료 로그를 잡는 싱크 필터, Pub/Sub 토픽, Cloud Functions 소스(전송 구성 ID 트리거·날짜 +1일 정렬·중복 방지 삭제)까지 비개발자도 따라올 수 있게 정리합니다.

- 카테고리: BigQuery
- 소요 시간: 약 12분
- 난이도: 중간
- 업데이트: 2026.05.29
- 원문: /wiki/playbook/bigquery-checklist/how-to-trigger-scheduled-query-on-ga4-export

## 목차

- [전체 그림 — 무엇을 연결하는가](#overview)
- [시작 전 준비물](#before)
- [1단계 · 예약 쿼리를 ‘온디맨드’로 저장](#step-1)
- [2단계 · Log Router 싱크 + Pub/Sub 토픽](#step-2)
- [3단계 · Cloud Functions 배포](#step-3)
- [함수 코드가 실제로 하는 일](#code)
- [왜 하루를 더하나 — run_date 정렬](#run-date)
- [동작 확인](#verify)
- [자주 묻는 질문](#faq)

앞선 네 글에서 [평탄화](/wiki/playbook/bigquery-checklist/why-flatten-ga4-bigquery-data)·[Log Router](/wiki/playbook/bigquery-checklist/what-is-log-router)·[Pub/Sub](/wiki/playbook/bigquery-checklist/what-is-pubsub)·[Cloud Functions](/wiki/playbook/bigquery-checklist/what-is-cloud-function-and-run)가 각각 무엇인지 개념으로 다뤘습니다. 이 글은 그 도구들을 한 줄로 이어, **“GA4 데이터가 BigQuery에 도착하면 자동으로 평탄화 마트가 갱신되는”** 파이프라인을 직접 만드는 단계별 가이드입니다. 한 번 세팅해 두면 사람이 손댈 일이 없는 운영 흐름이 됩니다.

## 전체 그림 — 무엇을 연결하는가

만드는 순서는 데이터가 흐르는 순서와 같습니다. **예약 쿼리 → Log Router 싱크(+Pub/Sub 토픽) → Cloud Functions** 순으로 만들어 두면, 매일 GA4 데이터가 도착하는 순간 아래 흐름이 저절로 돌아갑니다. Pub/Sub 토픽은 별도 단계가 아니라 **Log Router 싱크를 만들 때 그 자리에서 함께 생성**합니다.

핵심 아이디어 하나만 먼저 잡고 가면 나머지가 쉽습니다 — **함수는 평탄화 SQL을 직접 들고 있지 않습니다.** SQL은 BigQuery에 ‘예약 쿼리’로 저장해 두고, 함수는 그 예약 쿼리에게 **‘지금 한 번 실행해라’**는 신호만 보냅니다. 그래서 함수 코드는 짧게 유지되고, SQL을 고쳐도 함수를 다시 배포할 필요가 없습니다.

## 시작 전 준비물

네 단계를 시작하기 전에 아래 네 가지가 준비돼 있어야 합니다.

> **팁**
>
> - **GA4 ↔ BigQuery 일일 내보내기 연결** — GA4 관리 > BigQuery 링크가 켜져 있어 매일 `events_YYYYMMDD` 테이블이 들어오는 상태.
> - **결제 계정이 연결된 프로젝트** — 예약 쿼리는 샌드박스에서 쓸 수 없습니다. [샌드박스 해제](/wiki/playbook/bigquery-checklist/why-upgrade-from-bigquery-sandbox)를 먼저 끝내 주세요.
> - **마트를 담을 데이터셋** — 평탄화 결과가 저장될 데이터셋. [BigQuery Studio 인터페이스](/wiki/playbook/bigquery-checklist/what-is-bigquery-studio-interface)의 ‘데이터셋 만들기’에서 미리 하나 만들어 둡니다. **위치(리전)는 예약 쿼리·대상 테이블과 같아야** 하니 보통 asia-northeast3(서울)로 맞춥니다.
> - **평탄화 SQL 한 벌** — 마트를 만드는 SELECT 문. [평탄화가 필요한 이유](/wiki/playbook/bigquery-checklist/why-flatten-ga4-bigquery-data)에서 다룬 그 결과물입니다.

권한 쪽은 ‘로깅 구성 변경 · Pub/Sub 토픽 생성 · Cloud Functions 배포 · BigQuery 데이터 전송 실행’이 가능한 계정이면 충분합니다. 보통 프로젝트 **편집자(Editor)** 권한이면 네 단계를 모두 진행할 수 있습니다.

## 1단계. 예약 쿼리를 ‘온디맨드’로 저장

BigQuery Studio에서 평탄화 SQL을 작성한 뒤 상단 툴바의 일정을 눌러 예약 쿼리를 만듭니다. 이때 가장 중요한 설정은 **반복 빈도를 ‘주문형(On-demand)’으로** 두는 것입니다. 시각을 정해 두는 일반 예약 쿼리와 달리, 우리는 ‘데이터가 도착했을 때 함수가 깨워 주는’ 방식이라 자체 스케줄이 필요 없습니다.

![BigQuery Studio 쿼리 에디터 화면. 평탄화 SELECT 문(event_timestamp로 date 생성, _TABLE_SUFFIX와 @run_date로 전날 분량 필터)이 입력되어 있고, 에디터 상단 툴바에 실행·저장·다운로드·공유·일정·다음에서 열기 버튼이 나열되어 있다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/01-scheduled-query-editor.png)

> **화면 1-1.** 평탄화 SQL을 작성한 뒤 상단 **일정** 버튼을 누릅니다. `@run_date` 매개변수를 쓰는 점에 주목하세요(아래 ‘왜 하루를 더하나’ 참고).

‘예약된 쿼리로 내보내기’ 패널이 열리면 **이름**을 정하고 **반복 빈도 = 주문형**을 고른 뒤, **‘쿼리 결과의 대상 테이블 설정’**을 켜고 평탄화 마트가 저장될 데이터셋·테이블을 지정합니다. 쓰기 방식은 **‘테이블에 추가(append)’**로 둡니다 — 아래 함수가 ‘그날 행을 먼저 삭제하고 다시 적재’하는 방식이라 append와 맞물립니다.

![예약된 쿼리로 내보내기 패널. 예약된 쿼리 이름 입력칸, 일정 옵션의 반복 빈도가 ‘주문형’으로 선택되어 있고 ‘자동 예약이 사용 중지되었습니다’ 안내가 보인다. 하단 ‘쿼리 결과를 저장할 대상’에 대상 테이블 설정 체크박스, 데이터 세트와 Table Id 입력칸이 있다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/02-scheduled-query-form.png)

> **화면 1-2.** 반복 빈도 **주문형** + 대상 데이터셋·테이블 지정. 주문형이라 자동 실행은 꺼지고, 오직 함수의 신호로만 돕니다.

> **주의**
>
> **리전(위치)은 대상 테이블이 저장된 리전과 똑같이 골라야 합니다.** 예약 쿼리는 대상 데이터셋과 **같은 리전**에서만 만들어집니다 — 다르면 `Cannot create a transfer in REGION_… when destination dataset is located in …` 오류가 납니다. 국내 운영 환경에선 보통 **asia-northeast3(서울)**로 지정합니다. ‘자동 위치 선택’을 끄고 대상 테이블이 있는 리전을 직접 골라 주세요.

![예약된 쿼리 패널의 위치 설정 영역. ‘자동 위치 선택’ 체크박스가 해제되어 있고, 위치 유형은 ‘리전’, 리전 드롭다운에 ‘asia-northeast3 (서울)’이 선택되어 있다. 아래에 서비스 계정·고급 옵션·알림 옵션이 보인다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/03-scheduled-query-region.png)

> **화면 1-3.** ‘자동 위치 선택’을 끄고 리전을 직접 지정 — 대상 테이블이 서울에 있으면 **asia-northeast3(서울)**을 고릅니다.

저장하고 나면 예약 쿼리마다 **전송 구성 ID(transfer config ID)**가 생깁니다. 예약 쿼리 세부정보 화면의 리소스 이름 끝에 붙은 `.../transferConfigs/6a8c…e4d6` 형태의 GUID가 그것입니다. 이 ID를 3단계 함수 코드의 `scheduledQueryIdList`에 그대로 넣게 되니 메모해 두세요.

![예약된 쿼리 세부정보 화면. 리소스 이름이 projects/…/locations/us/transferConfigs/6a8cb3ca-…-14223bc0e4d6 형태로 표시되고, 소스=Scheduled Query, 대상 데이터세트·대상 테이블 유형(Native table), 일정 항목은 ‘없음’(주문형), 데이터 소스 세부정보에 Query string과 Destination table이 나열되어 있다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/04-scheduled-query-detail.png)

> **화면 1-4.** 저장 후 세부정보 — **리소스 이름** 끝의 전송 구성 ID(GUID)를 복사해 3단계 함수의 `scheduledQueryIdList`에 넣습니다.

‘리소스 이름’은 슬래시(`/`)로 이어진 긴 문자열인데, 이 전체가 ID가 아닙니다. **맨 끝 `transferConfigs/` 뒤의 GUID 한 토막**만 예약 쿼리 ID입니다.

**세부정보 ‘리소스 이름’ 전체**
```text
projects/<your_project_number>/locations/us/transferConfigs/6a8cb3ca-0000-2195-98d9-14223bc0e4d6
```

| 리소스 이름 구간 | 값(예시) | 의미 |
| --- | --- | --- |
| `projects/…` | <your_project_number> | 프로젝트 번호 |
| `locations/…` | us | 예약 쿼리 리전 |
| **`transferConfigs/…`** | **6a8cb3ca-0000-2195-98d9-14223bc0e4d6** | **← 이 GUID가 예약 쿼리 ID** |

따라서 3단계 함수의 `scheduledQueryIdList`에는 앞의 `projects/…/transferConfigs/` 부분을 떼고 **`6a8cb3ca-0000-2195-98d9-14223bc0e4d6`** 이 GUID만 넣습니다.

## 2단계. Log Router 싱크 만들기 (+ Pub/Sub 토픽)

Cloud Console > 로깅 > 로그 라우터 > + 싱크 만들기로 새 싱크를 하나 추가합니다. 싱크는 ‘**어떤 로그를**(필터) **어디로 보낼지**(도착지)’ 두 가지로 구성된다는 점은 [Log Router 글](/wiki/playbook/bigquery-checklist/what-is-log-router)에서 다뤘습니다. 도착지로는 **Pub/Sub 주제**를 고르는데, 토픽을 미리 따로 만들 필요 없이 **이 화면에서 바로 ‘주제 만들기’**로 생성할 수 있습니다.

![Cloud Console 로깅 > 로그 라우터 페이지. 상단에 ‘싱크 만들기’ 버튼이 있고, 로그 라우터 싱크 목록에 _Default·_Required 시스템 싱크와 함께 Cloud Pub/Sub 주제 유형의 ga4-daily-export-complete 싱크(대상: pubsub.googleapis.com/projects/hurdlers-web/topics/ga4-daily-export-complete)가 표시되어 있다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/05-log-router-page.png)

> **화면 2-1.** 로깅 > 로그 라우터. 상단 **싱크 만들기**로 새 싱크를 추가합니다(여기 예시는 이미 만들어 둔 `ga4-daily-export-complete` 싱크).

![싱크 만들기의 싱크 대상 단계. 싱크 서비스 선택 = Cloud Pub/Sub 주제로 지정되어 있고, ‘Cloud Pub/Sub 주제 선택’ 드롭다운이 열려 기존 토픽 목록이 보인다. 드롭다운 하단에 ‘주제 만들기’ 버튼이 있다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/06-log-sink-topic-select.png)

> **화면 2-2.** 싱크 대상 **= Cloud Pub/Sub 주제**. 주제 선택 드롭다운 하단의 **주제 만들기**로 토픽을 이 자리에서 바로 생성합니다(별도 Pub/Sub 단계가 필요 없음).

![싱크 만들기 화면 오른쪽에 열린 주제 만들기 패널. 주제 ID 입력칸과 ‘주제 이름: projects/…/topics/’ 안내, 스키마·메시지 보관 등 선택 옵션, Google 관리 암호화 키 기본 선택, 만들기 버튼이 있다. 왼쪽에는 싱크 대상 설정이 그대로 보인다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/12-log-sink-topic-create.png)

> **화면 2-3.** ‘주제 만들기’를 누르면 오른쪽에 패널이 열립니다 — **주제 ID**(예: `ga4-daily-export-complete`)만 정해 만들면, 그 토픽이 싱크 도착지로 바로 지정됩니다. 함수 배포(3단계) 시 이 토픽의 구독이 자동 생성됩니다.

필터는 ‘GA4 일일 내보내기가 `events_YYYYMMDD` 테이블 적재를 끝낸 사건’ 한 가지만 잡도록 작성합니다.

**싱크 포함 필터 — GA4 일일 적재 완료 감지**
```text
protoPayload.methodName = "jobservice.jobcompleted"
protoPayload.authenticationInfo.principalEmail = "firebase-measurement@system.gserviceaccount.com"
protoPayload.serviceData.jobCompletedEvent.job.jobConfiguration.load.destinationTable.datasetId = "analytics_<your_property_id>"
protoPayload.serviceData.jobCompletedEvent.job.jobConfiguration.load.destinationTable.tableId =~ "^events_\d+"
```

- **1번째 줄** — “BigQuery 작업이 ‘완료된’ 사건만.”
- **2번째 줄** — “그 작업을 일으킨 주체가 GA4 내보내기 서비스 계정(`firebase-measurement@…`)인 것만.” 사람이 돌린 쿼리와 GA4 자동 적재를 구분해 줍니다.
- **3번째 줄** — “우리 GA4 데이터셋(`analytics_<your_property_id>`)에 적재된 것만.” GA4 export 데이터셋 이름은 `analytics_` + 속성 ID 형식이라, 자기 속성 ID로 바꿔 넣습니다.
- **4번째 줄** — “결과 테이블 이름이 `events_` + 숫자로 시작하는 것만.” 정규식 `^events_\d+`는 `events_20260529`는 통과시키고 `events_intraday_20260529`는 자연스럽게 제외합니다(`events_` 다음이 숫자가 아니라 `i`라서). 우리는 ‘하루치 최종본’만 다룹니다.

> **주의**
>
> **이 필터는 함수 코드와 짝입니다.** 아래 3단계 함수는 로그에서 `protoPayload.serviceData.jobCompletedEvent…` 경로를 읽습니다. 위 필터는 그 경로가 들어 있는 형식의 로그만 보내 주므로 둘이 맞물립니다. 다른 형식의 필터로 바꾸면 함수가 읽을 필드가 사라져 동작하지 않으니 주의하세요.

![싱크 수정 화면의 ‘싱크에 포함할 로그 선택’ 영역. 포함 필터 빌드 편집기에 protoPayload.methodName=jobservice.jobcompleted, principalEmail=firebase-measurement@system.gserviceaccount.com, protoPayload.serviceData.jobCompletedEvent.job.jobConfiguration.load.destinationTable… 필터가 입력되어 있고, 아래에 선택사항인 제외 필터 영역과 싱크 업데이트·취소 버튼이 있다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/07-log-sink-filter.png)

> **화면 2-4.** **포함 필터**에 위 필터식을 붙여 넣습니다. 실제 운영 싱크도 `jobservice.jobcompleted` + `firebase-measurement@…` 형식을 그대로 씁니다.

## 3단계. Cloud Functions 배포

Cloud Console > Cloud Run 함수(또는 Cloud Functions) > 함수 작성으로 새 함수를 만듭니다. 트리거를 **Cloud Pub/Sub**으로, 토픽을 2단계에서 만든 것으로 지정하면, 그 토픽에 메시지가 들어올 때마다 함수가 자동 호출됩니다.

| 배포 설정 | 값 |
| --- | --- |
| 리전 | **asia-northeast3(서울)** — 파이프라인과 동일하게 |
| 런타임 | Node.js 22 |
| 진입점(entry point) | `runScheduledQuery` |
| 트리거 | Cloud Pub/Sub → 2단계에서 만든 토픽 |

> **팁**
>
> **함수 리전도 서울로 맞춰 두세요.** Pub/Sub은 글로벌 서비스라 함수 리전이 토픽과 달라도 하드 에러는 아니지만(예약 쿼리처럼 막히지는 않음), 지연·관리 일관성을 위해 예약 쿼리·데이터셋과 같은 **asia-northeast3(서울)**에 배포하는 것이 권장입니다. 기본값(예: `europe-west1`) 그대로 두지 말고 리전을 직접 골라 주세요.

![Cloud Run 함수 만들기 화면. 상단 배포 방식에서 ‘함수(인라인 편집기를 사용하여 함수 만들기)’가 선택되어 있고, 구성에 서비스 이름 run-scheduled-query, 리전 = asia-northeast3(서울), 런타임 = Node.js 22가 지정되어 있다. 엔드포인트 URL에 …asia-northeast3.run.app이 표시되고, 그 아래 ‘트리거(선택사항)’의 ‘+ 트리거 추가’ 버튼과 인증 설정, 만들기·취소 버튼이 보인다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/10-function-create.png)

> **화면 4-1.** 배포 방식 **함수** + 리전 **서울(asia-northeast3)** + 런타임 **Node.js 22**. 함수 이름을 정하고 **+ 트리거 추가**로 넘어갑니다.

![트리거 추가 메뉴가 펼쳐진 상태. Pub/Sub 트리거·Cloud Storage 트리거·Firestore 트리거·기타 Eventarc 트리거 중 Pub/Sub 트리거의 하위 메뉴가 열려 ‘직접(구독을 직접 만듭니다)’과 ‘Eventarc(Eventarc를 통해 구독을 관리합니다)’ 두 옵션이 보인다.](/wiki-assets/playbook/how-to-trigger-scheduled-query-on-ga4-export/11-function-pubsub-trigger.png)

> **화면 4-2.** 트리거 추가 > **Pub/Sub 트리거** > **직접**을 고른 뒤, 2단계에서 만든 토픽(`ga4-daily-export-complete`)을 지정합니다. 진입점은 다음 코드 편집 단계에서 `runScheduledQuery`로 설정합니다.

`package.json`에는 BigQuery와 데이터 전송 클라이언트 두 개를 의존성으로 넣습니다.

**package.json**
```json
{
  "name": "run-scheduled-query",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "@google-cloud/functions-framework": "^3.0.0",
    "@google-cloud/bigquery": "^7.9.0",
    "@google-cloud/bigquery-data-transfer": "^4.0.0"
  }
}
```

`index.js` 전체는 다음과 같습니다. 위쪽 ‘프로젝트별 수정 필요’ 블록의 값만 자기 프로젝트에 맞게 바꾸면 되고, 그 아래 로직은 손대지 않아도 됩니다.

**index.js — Cloud Functions (2세대)**
```javascript
const functions = require("@google-cloud/functions-framework");
const { BigQuery } = require("@google-cloud/bigquery");
const bigqueryDataTransfer = require("@google-cloud/bigquery-data-transfer");

//////// 프로젝트별 수정 필요 ////////

const projectId = "your-project-id";
const region = "asia-northeast3";

// 갱신 대상 마트 데이터셋·테이블 (자기 환경에 맞게 수정)
const datasetId = "your_mart_dataset";
const tableIdList = ["Event_Flat", "Item_Performance"];

// 실행할 예약 쿼리(전송 구성) ID — 1단계에서 메모해 둔 값
const scheduledQueryIdList = [
  "00000000-0000-0000-0000-000000000000", // Event_Flat 예약 쿼리
  "11111111-1111-1111-1111-111111111111", // Item_Performance 예약 쿼리
];

//////// 아래부터는 수정 불필요 ////////

// 로그 이벤트에서 적재된 테이블 이름을 꺼낸다 (load·query 결과 모두 대응)
const parseDestinationTableId = (eventData) => {
  const jobCompleted = eventData?.protoPayload?.serviceData?.jobCompletedEvent;
  const jobConfig = jobCompleted?.job?.jobConfiguration;
  if (!jobConfig) return null;
  const destinationTable =
    jobConfig.load?.destinationTable || jobConfig.query?.destinationTable;
  return destinationTable?.tableId || null;
};

// 적재된 테이블에서 가장 최근 날짜(MAX(date))를 조회한다
const getLatestDateFromTable = async (bigquery, datasetId, tableId) => {
  const query = `
    SELECT FORMAT_DATE('%Y-%m-%d', MAX(date)) AS latest_date
    FROM \`${projectId}.${datasetId}.${tableId}\`
  `;
  const [rows] = await bigquery.query(query);
  return rows[0]?.latest_date || null;
};

// "YYYY-MM-DD" → 그날 12:00 UTC
const calculateRunTime = (eventDate) => {
  const [year, month, day] = eventDate.split("-");
  return new Date(Date.UTC(year, month - 1, parseInt(day, 10), 12));
};

// startManualTransferRuns가 요구하는 Timestamp 형식으로 변환
const createRequestedRunTime = (runTime) =>
  bigqueryDataTransfer.protos.google.protobuf.Timestamp.fromObject({
    seconds: Math.floor(runTime / 1000),
    nanos: (runTime % 1000) * 1e6,
  });

// 재실행 시 중복을 막기 위해 해당 날짜 행을 먼저 삭제 (date는 DATE 타입)
const deleteDataForEventDate = async (bigquery, tableIdList, datasetId, eventDate) => {
  for (const tableId of tableIdList) {
    const query = `
      DELETE FROM \`${projectId}.${datasetId}.${tableId}\`
      WHERE date = DATE(@eventDate)
    `;
    const [job] = await bigquery.createQueryJob({ query, params: { eventDate } });
    await job.getQueryResults();
    console.log(`Deleted ${tableId} rows for ${eventDate}`);
  }
};

// 예약 쿼리들에 '지금 한 번 실행' 신호를 보낸다
const startManualTransferRuns = async (client, scheduledQueryIdList, requestedRunTime) => {
  for (const scheduledQueryId of scheduledQueryIdList) {
    try {
      const parent = client.projectLocationTransferConfigPath(projectId, region, scheduledQueryId);
      const [response] = await client.startManualTransferRuns({ parent, requestedRunTime });
      console.log("Started:", response);
    } catch (error) {
      console.error("Failed for:", scheduledQueryId, error);
    }
  }
};

// 메인 트리거 — 2세대 CloudEvent 방식
functions.cloudEvent("runScheduledQuery", async (cloudEvent) => {
  const base64data = cloudEvent?.data?.message?.data;
  if (!base64data) return; // 메시지가 없으면 건너뜀

  const eventData = JSON.parse(Buffer.from(base64data, "base64").toString());

  // 1) 적재된 테이블 이름 추출
  const destinationTableId = parseDestinationTableId(eventData);
  if (!destinationTableId) return;

  // 2) 그 테이블의 최신 날짜 조회
  const bigquery = new BigQuery();
  const eventDate = await getLatestDateFromTable(bigquery, datasetId, destinationTableId);
  if (!eventDate) return;

  // 3) 실행 시각 = 최신 날짜 + 1일 (예약 쿼리의 @run_date 정렬용)
  const runTime = calculateRunTime(eventDate);
  runTime.setUTCDate(runTime.getUTCDate() + 1);
  const requestedRunTime = createRequestedRunTime(runTime);

  // 4) 해당 날짜 기존 행 삭제
  await deleteDataForEventDate(bigquery, tableIdList, datasetId, eventDate);

  // 5) 예약 쿼리 실행 요청
  const client = new bigqueryDataTransfer.v1.DataTransferServiceClient();
  await startManualTransferRuns(client, scheduledQueryIdList, requestedRunTime);

  console.log("Scheduled Query Execution Completed");
});
```

> **팁**
>
> **이 코드는 2세대(CloudEvent) 방식입니다.** 진입점이 `functions.cloudEvent("runScheduledQuery", (cloudEvent) => …)` 형태이고, 메시지를 `cloudEvent.data.message.data`에서 꺼냅니다. 1세대로 배포하던 함수는 `exports.runScheduledQuery = (event, context) => …` 형태였고 메시지를 `event.data`에서 꺼냈습니다. 두 세대의 관계는 [Cloud Functions 글](/wiki/playbook/bigquery-checklist/what-is-cloud-function-and-run)을 참고하세요.

## 함수 코드가 실제로 하는 일

진입점 `runScheduledQuery`가 한 번 호출되면 다섯 가지 일이 순서대로 일어납니다. 각 단계에서 ‘읽을 게 없으면 조용히 멈추는’ 안전장치(`if (!…) return`)가 들어 있어, 빈 메시지나 예상치 못한 로그가 와도 오류 없이 건너뜁니다.

1. **메시지에서 테이블 이름 꺼내기** — `parseDestinationTableId`가 `cloudEvent.data.message.data`(base64)를 풀어, 로그가 가리키는 적재 결과 테이블 이름을 얻습니다. `load`로 들어온 경우든 쿼리 결과(`query`)로 만들어진 경우든 모두 대응합니다.
2. **그 테이블의 최신 날짜 조회** — `getLatestDateFromTable`이 해당 테이블에 `SELECT MAX(date)`를 던져 ‘가장 최근 데이터 날짜’(`2026-05-29` 형식)를 가져옵니다. 테이블 이름을 글자로 자르는 대신 데이터에서 직접 날짜를 읽어, 이름 규칙이 달라도 안전합니다.
3. **실행 시각 계산** — `calculateRunTime`으로 그 날짜의 UTC 정오를 만든 뒤 `setUTCDate(+1)`로 **하루를 더합니다**(이유는 바로 아래에서 설명).
4. **해당 날짜 기존 행 삭제** — `deleteDataForEventDate`가 각 마트 테이블에서 `WHERE date = DATE(@eventDate)`로 그 날짜 행을 먼저 지웁니다. 같은 날짜로 함수가 두 번 돌아도 데이터가 중복되지 않게 하는 안전장치입니다.
5. **예약 쿼리 실행 요청** — `startManualTransferRuns`가 `scheduledQueryIdList`의 각 예약 쿼리에 ‘이 시각 기준으로 지금 한 번 실행해라’는 신호를 보냅니다.

여러 마트를 한 번에 갱신하려면 `tableIdList`와 `scheduledQueryIdList`에 짝을 맞춰 줄을 추가하면 됩니다 — 삭제할 테이블 하나, 그 테이블을 다시 채우는 예약 쿼리 하나가 한 쌍입니다.

> **주의**
>
> **마트 테이블에는 `date` DATE 컬럼이 있어야 합니다.** 이 함수는 `MAX(date)`로 날짜를 읽고 `WHERE date = DATE(@eventDate)`로 지우므로, 대상 테이블에 `date`라는 DATE 타입 컬럼이 있다는 전제가 깔려 있습니다. 평탄화 SQL을 짤 때 날짜 컬럼 이름을 `date`(DATE)로 맞춰 두면 그대로 동작합니다.

## 왜 하루를 더하나 — run_date 정렬

코드에서 가장 헷갈리는 부분이 실행 시각을 만든 뒤 붙는 `runTime.setUTCDate(runTime.getUTCDate() + 1)`입니다. 방금 조회한 최신 데이터 날짜는 5월 29일인데, 왜 실행 시각은 5월 **30일**로 만들까요?

이유는 예약 쿼리 쪽 `@run_date` 규칙에 있습니다. 평탄화 SQL은 보통 ‘**실행일의 전날**데이터를 처리’하도록 짜여 있습니다 — 예를 들어:

**예약 쿼리 WHERE 절(예시)**
```sql
WHERE _table_suffix = FORMAT_DATE('%Y%m%d', DATE_SUB(@run_date, INTERVAL 1 DAY))
```

그래서 5월 29일치 데이터를 처리하게 하려면 `@run_date`를 5월 30일로 넘겨야 합니다(30일 − 1일 = 29일). 함수가 ‘최신 날짜 + 1일’을 실행 시각으로 만드는 건 바로 이 한 칸을 맞추기 위해서입니다. 한편 `deleteDataForEventDate`가 지우는 `eventDate`는 원래 날짜(5월 29일) 그대로라, ‘29일 행을 지우고 → 29일 데이터를 다시 채우는’ 짝이 정확히 맞습니다.

> **팁**
>
> **핵심:** 함수에 넘기는 `@run_date`는 ‘처리할 날짜’가 아니라 ‘실행하는 날짜’입니다. 평탄화 SQL이 `DATE_SUB(@run_date, INTERVAL 1 DAY)`로 전날을 보기 때문에 +1일이 필요합니다. SQL을 ‘당일 기준’으로 바꾸면 이 +1도 함께 떼야 합니다.

## 동작 확인

네 단계를 마쳤다면, 다음 GA4 데이터가 도착한 직후 아래 순서로 확인합니다.

1. **Log Router** — 로그 라우터에서 만든 싱크 옆 *‘일치하는 로그 보기’*에 오늘 자 적재 로그가 한 건 잡혔는지.
2. **Cloud Functions 로그** — 함수 상세 > 로그에 `Deleted …`, `Started: …` 메시지가 찍혔는지(에러 없이 `Scheduled Query Execution Completed`로 끝나는지).
3. **예약 쿼리 실행 기록** — BigQuery > 예약 쿼리 > 해당 항목의 실행 기록에 방금 시각의 *수동 실행(manual run)*이 성공으로 남았는지.
4. **마트 테이블** — `Event_Flat` 등에 오늘 날짜 행이 채워졌는지.

> **주의**
>
> **가장 흔한 첫 실패는 권한입니다.** 함수가 쓰는 서비스 계정에 BigQuery 데이터 편집·작업 실행 권한과 ‘BigQuery 데이터 전송 실행’ 권한이 없으면, 로그에 권한 오류가 찍히며 멈춥니다. 함수 로그의 에러 메시지에 부족한 권한 이름이 그대로 나오니 그걸 보고 해당 역할을 서비스 계정에 추가하면 됩니다.

## 자주 묻는 질문

### 예약 쿼리를 그냥 ‘매일 새벽 4시’로 두면 안 되나요?

됩니다. 다만 GA4 데이터 도착 시각이 속성마다 30분~수 시간씩 달라, 고정 시각으로 두면 ‘아직 안 들어왔는데 돌려서 어제 분량만 다시 만들어졌다’는 사고가 흔합니다. 이 파이프라인은 ‘실제 도착 시점’에 맞춰 돌리려고 예약 쿼리를 **온디맨드**로 두고 함수가 깨워 주는 구조입니다.

### 전송 구성 ID(scheduledQueryId)는 어디서 보나요?

BigQuery > 예약 쿼리에서 해당 항목을 열면 **리소스 이름**이 보입니다. `projects/…/locations/…/transferConfigs/여기 GUID` 형태이고, 끝의 GUID가 함수 코드의 `scheduledQueryIdList`에 넣을 값입니다.

### 왜 함수 안에서 평탄화 SQL을 직접 실행하지 않나요?

SQL을 함수가 들고 있으면 SQL을 한 줄 고칠 때마다 함수를 다시 배포해야 하고, 실행 이력·실패도 함수 로그로만 봐야 합니다. ‘SQL은 예약 쿼리로 BigQuery에 저장, 함수는 실행 신호만 전송’으로 나누면 변경과 관찰이 훨씬 깔끔합니다.

### intraday(실시간) 테이블 때문에 함수가 잘못 도나요?

2단계 필터의 정규식 `^events_\d+`가 `events_intraday_…`를 걸러 줍니다. `events_` 다음이 숫자가 아니라 `i`로 시작하기 때문입니다. 그래서 실시간 테이블 적재로는 함수가 호출되지 않고, 하루치 최종본이 들어올 때만 한 번 돕니다.

### 여러 GA4 속성을 한 프로젝트로 모아 처리할 수 있나요?

네. Log Router 싱크의 도착지로 **다른 프로젝트의 Pub/Sub 토픽**을 지정할 수 있어, 여러 사이트의 GA4 데이터를 한 분석 프로젝트로 모아 같은 함수로 처리하는 패턴이 가능합니다. 데이터셋 ID가 속성마다 다르므로 필터·코드의 `datasetId` 처리만 속성별로 맞춰 주면 됩니다.

### 전체 비용이 걱정됩니다.

네 구간 모두 GA4 규모에선 사실상 0원입니다. Log Router 라우팅·Pub/Sub 메시지·Cloud Functions 호출 모두 ‘속성당 하루 한 건’ 수준이라 무료 한도 안이고, 실제 비용은 평탄화 SQL이 스캔하는 BigQuery 데이터량에서만 발생합니다. 자세한 추정은 [BigQuery 예상 비용](/wiki/playbook/bigquery-checklist/how-to-estimate-bigquery-costs)을 참고하세요.

## Navigation

- [전체 플레이북 Markdown sitemap](/wiki/playbook/sitemap.md)

### BigQuery

- [BigQuery](/wiki/playbook/bigquery-checklist)
- [BigQuery 온보딩](/wiki/playbook/bigquery-checklist/bigquery-onboarding-guide)
- [BigQuery 인증 설정](/wiki/playbook/bigquery-checklist/how-to-set-up-bigquery-authentication)
- [BigQuery 데이터셋·테이블 설계](/wiki/playbook/bigquery-checklist/bigquery-dataset-and-table-design-guide)
- [BigQuery 쿼리·비용 제어](/wiki/playbook/bigquery-checklist/bigquery-query-and-cost-control-guide)
- [BigQuery 쓰기·MERGE·CDC](/wiki/playbook/bigquery-checklist/bigquery-data-write-and-cdc-guide)
- [BigQuery 코드 예제 모음](/wiki/playbook/bigquery-checklist/bigquery-code-examples)
- [BigQuery로 들어오는 GA4 데이터](/wiki/playbook/bigquery-checklist/what-is-ga4-data-in-bigquery)
- [BigQuery Studio 인터페이스 이해하기](/wiki/playbook/bigquery-checklist/what-is-bigquery-studio-interface)
- [BigQuery 예상 비용](/wiki/playbook/bigquery-checklist/how-to-estimate-bigquery-costs)
- [무료 버전(샌드박스) 해제해야 하는 이유](/wiki/playbook/bigquery-checklist/why-upgrade-from-bigquery-sandbox)
- [GA4 BigQuery 데이터를 왜 평탄화해야 하나](/wiki/playbook/bigquery-checklist/why-flatten-ga4-bigquery-data)
- [Log Router란?](/wiki/playbook/bigquery-checklist/what-is-log-router)
- [Pub/Sub이란?](/wiki/playbook/bigquery-checklist/what-is-pubsub)
- [Cloud Functions이란?](/wiki/playbook/bigquery-checklist/what-is-cloud-function-and-run)
- [GA4 export 시점에 예약 쿼리 자동 실행하기](/wiki/playbook/bigquery-checklist/how-to-trigger-scheduled-query-on-ga4-export) (현재 문서)
- [테이블 정의서는 왜 필요한가](/wiki/playbook/bigquery-checklist/why-table-definition-doc)
- [Event_Flat 테이블 이해하기](/wiki/playbook/bigquery-checklist/what-is-event-flat-table)
- [Item_Performance 테이블 이해하기](/wiki/playbook/bigquery-checklist/what-is-item-performance-table)
