careerly-data-mcp 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,33 @@
1
- # Careerly GA4 MCP Server
1
+ # Careerly Data MCP Server
2
2
 
3
- Claude Code에서 자연어로 GA4 데이터를 분석하는 MCP(Model Context Protocol) 서버입니다.
3
+ Claude Code에서 **자연어로 GA4 BigQuery 데이터를 분석**하는 MCP(Model Context Protocol) 서버입니다.
4
+
5
+ ## 왜 MCP를 쓰나요? (vs API 직접 호출)
6
+
7
+ ### 기존 방식의 문제점
8
+
9
+ ```
10
+ GA4 데이터 보고 싶다 → GA4 API 문서 읽기 → 인증 설정 → 코드 작성 → 결과 해석
11
+ BigQuery 분석하고 싶다 → SQL 작성 → UNNEST 문법 검색 → 쿼리 수정 → 결과 해석
12
+ ```
13
+
14
+ ### MCP 방식
15
+
16
+ ```
17
+ "지난 7일 세션수 보여줘" → 끝!
18
+ "page_view → post_detail_view 전환율 분석해줘" → 끝!
19
+ ```
20
+
21
+ ### 핵심 장점
22
+
23
+ | 기존 API 직접 호출 | MCP 사용 |
24
+ |------------------|---------|
25
+ | API 문서 읽고 코드 작성 필요 | 자연어로 질문하면 끝 |
26
+ | GA4 event_params 추출 = 복잡한 UNNEST SQL | `bq_event_params` 도구로 한 줄 |
27
+ | 퍼널 분석 = 10줄 이상 SQL | `bq_funnel` 도구로 자동 계산 |
28
+ | 결과는 숫자만 | **인사이트 + 다음 액션 제안**까지 |
29
+
30
+ ---
4
31
 
5
32
  ## 빠른 시작
6
33
 
@@ -8,110 +35,189 @@ Claude Code에서 자연어로 GA4 데이터를 분석하는 MCP(Model Context P
8
35
  # 1. Service Account JSON 키 파일이 있는 폴더에서 실행
9
36
  cd /path/to/your/project
10
37
 
11
- # 2. 설정 실행 (키 파일 자동 탐지 + Claude Code 자동 등록!)
12
- npx careerly-ga4-mcp setup
38
+ # 2. 설정 실행 (키 파일 자동 탐지 + Claude Code 자동 등록)
39
+ npx careerly-data-mcp setup
13
40
 
14
41
  # 3. Claude Code 재시작 후 /mcp 확인!
15
42
  ```
16
43
 
17
44
  ### 자동 설정 기능
18
45
 
19
- `setup` 명령어는 다음을 자동으로 처리합니다:
20
-
21
- 1. **Service Account 키 파일 자동 탐지** - 현재 폴더에서 JSON 파일 스캔
22
- 2. **GA4 연결 테스트** - Property ID와 키 파일 검증
23
- 3. **Claude Code MCP 자동 등록** - `claude mcp add` 명령어로 자동 설정
46
+ `setup` 명령어가 알아서 처리합니다:
24
47
 
25
- ```
26
- Careerly GA4 MCP Server
27
- ─────────────────────────────────────
48
+ 1. **Service Account 키 파일 자동 탐지** - 폴더에서 JSON 파일 스캔
49
+ 2. **GA4 + BigQuery 연결 테스트** - Property ID와 키 파일 검증
50
+ 3. **Claude Code MCP 자동 등록** - 재시작만 하면 바로 사용 가능
28
51
 
29
- ✔ GA4 Property ID 517059569
30
- ✔ 1개의 Service Account 키 파일 발견
31
- ✔ 사용할 키 파일 선택 my-project-key.json (my-project-id)
32
- ✔ 연결 성공!
33
- ✔ Claude Code MCP 서버 등록 완료
34
-
35
- ✓ 설정 완료!
36
- ```
52
+ ---
37
53
 
38
- ## 사전 준비
54
+ ## 사전 준비 (관리자)
39
55
 
40
56
  ### 1. Google Cloud 설정
41
57
 
42
58
  1. [Google Cloud Console](https://console.cloud.google.com/)에서 프로젝트 생성
43
59
  2. **Google Analytics Data API** 활성화
44
- 3. **서비스 계정** 생성 후 JSON 키 다운로드
60
+ 3. **BigQuery API** 활성화
61
+ 4. **서비스 계정** 생성 후 JSON 키 다운로드
45
62
 
46
63
  ### 2. GA4 권한 설정
47
64
 
48
- 1. [GA4 Admin](https://analytics.google.com/) > 속성 설정 > 속성 액세스 관리
49
- 2. 서비스 계정 이메일 추가 (뷰어/읽기 전용 권한)
65
+ - GA4 Admin > 속성 설정 > 속성 액세스 관리
66
+ - 서비스 계정 이메일 추가 (뷰어 권한)
67
+
68
+ ### 3. BigQuery 권한 설정
69
+
70
+ - IAM & Admin > 서비스 계정에 **BigQuery 데이터 뷰어** 역할 추가
71
+
72
+ ---
73
+
74
+ ## 제공 도구 (Tools)
75
+
76
+ ### GA4 Data API 도구
50
77
 
51
- > **Tip**: Service Account 이메일은 JSON 파일의 `client_email` 필드에서 확인할 수 있습니다.
78
+ | Tool | 설명 | 언제 쓰나요? |
79
+ |------|------|------------|
80
+ | `ga4_query` | GA4 데이터 조회 + 인사이트 | 세션, 사용자, 전환 등 기본 지표 |
81
+ | `ga4_metadata` | 지표/차원 목록 | 어떤 데이터를 볼 수 있는지 확인 |
82
+ | `ga4_status` | 연결 상태 확인 | 연결 문제 진단 |
52
83
 
53
- ## 기능
84
+ ### BigQuery 기본 도구
54
85
 
55
- | Tool | 설명 |
56
- |------|------|
57
- | `ga4_query` | GA4 데이터 조회 + 인사이트 생성 |
58
- | `ga4_metadata` | 지표/차원 목록 확인 |
59
- | `ga4_status` | 연결 상태 확인 |
86
+ | Tool | 설명 | 언제 쓰나요? |
87
+ |------|------|------------|
88
+ | `bq_query` | BigQuery 테이블 조회 | 일반 테이블 데이터 조회 |
89
+ | `bq_metadata` | 스키마/테이블 목록 | 데이터 구조 파악 |
90
+ | `bq_status` | 연결 상태 확인 | 연결 문제 진단 |
91
+ | `bq_ga4_events` | GA4 Export 이벤트 조회 | event_params 자동 추출 |
92
+
93
+ ### BigQuery 고급 도구 (v2.1.0 신규)
94
+
95
+ | Tool | 설명 | 언제 쓰나요? |
96
+ |------|------|------------|
97
+ | `bq_raw_sql` | SQL 직접 실행 | 복잡한 JOIN, 윈도우 함수 등 |
98
+ | `bq_event_params` | 이벤트 파라미터 추출 | page_location, page_title 등 쉽게 추출 |
99
+ | `bq_user_journey` | 사용자 여정 분석 | 특정 사용자가 어떤 행동을 했는지 |
100
+ | `bq_funnel` | 퍼널 분석 | 단계별 전환율 자동 계산 |
101
+
102
+ ---
60
103
 
61
104
  ## 사용 예시
62
105
 
63
- Claude Code에서 자연어로 질문:
106
+ Claude Code에서 자연어로 질문하세요:
107
+
108
+ ### 기본 질의
64
109
 
65
110
  ```
66
111
  "지난 7일 세션수 보여줘"
67
112
  "채널별 전환수 알려줘"
68
113
  "어제 대비 오늘 트래픽 어때?"
69
114
  "랜딩 페이지별 이탈률 분석해줘"
70
- "캠페인별 성과 비교해줘"
71
115
  ```
72
116
 
117
+ ### 고급 질의 (v2.1.0)
118
+
119
+ ```
120
+ "page_view 이벤트에서 page_location 파라미터 추출해줘"
121
+ "사용자 ID 12345678의 행동 여정 보여줘"
122
+ "page_view → post_detail_view → post_create_start 퍼널 분석해줘"
123
+ "디바이스별 전환율 비교해줘"
124
+ ```
125
+
126
+ ### 실제 결과 예시 (퍼널 분석)
127
+
128
+ ```
129
+ ## 요약
130
+ page_view → post_create_start 전환율: 2.3% | 1,215 → 28명
131
+
132
+ ## 인사이트
133
+ 1. 전체 전환율: 2.3% (1,215명 중 28명 완료)
134
+ 2. 최대 이탈 구간: page_view → post_detail_view (89.8% 이탈)
135
+ 3. post_detail_view → post_create_start: 22.8% 전환 (개선 필요)
136
+
137
+ ## 퍼널 시각화
138
+ 1. page_view ██████████████████████████████ 1,215명 (100%)
139
+ 2. post_detail_view ███░░░░░░░░░░░░░░░░░░░░░░░░░░░ 123명 (10.1%)
140
+ 3. post_create_start █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 28명 (2.3%)
141
+ ```
142
+
143
+ ---
144
+
145
+ ## v2.1.0 주요 업데이트
146
+
147
+ ### 버그 수정
148
+
149
+ - `bq_ga4_events` GROUP BY 오류 해결
150
+ - 이제 metrics 있을 때/없을 때 모두 정상 동작
151
+
152
+ ### 새 기능
153
+
154
+ - **복합 필터 지원**: `filterLogic: "AND" | "OR"` 옵션 추가
155
+ - **Raw SQL 실행**: 복잡한 쿼리도 직접 작성 가능 (SELECT만 허용)
156
+
157
+ ### 새 도구 4개 추가
158
+
159
+ | 도구 | 하는 일 |
160
+ |------|--------|
161
+ | `bq_raw_sql` | SQL 직접 실행 (UNNEST, JOIN 등) |
162
+ | `bq_event_params` | event_params 쉽게 추출 |
163
+ | `bq_user_journey` | 사용자별 이벤트 시퀀스 분석 |
164
+ | `bq_funnel` | 전환 퍼널 자동 계산 |
165
+
166
+ ---
167
+
73
168
  ## CLI 명령어
74
169
 
75
170
  ```bash
76
171
  # 인터랙티브 메뉴
77
- npx careerly-ga4-mcp
172
+ npx careerly-data-mcp
78
173
 
79
- # 초기 설정 (자동 키 파일 탐지 + Claude Code 자동 등록)
80
- npx careerly-ga4-mcp setup
174
+ # 초기 설정
175
+ npx careerly-data-mcp setup
81
176
 
82
- # 서버 정보 확인
83
- npx careerly-ga4-mcp info
177
+ # 연결 테스트
178
+ npx careerly-data-mcp test-connection
84
179
 
85
- # GA4 연결 테스트
86
- npx careerly-ga4-mcp test-connection
180
+ # 서버 정보
181
+ npx careerly-data-mcp info
87
182
 
88
183
  # MCP 서버 시작 (수동)
89
- npx careerly-ga4-mcp serve
184
+ npx careerly-data-mcp serve
90
185
  ```
91
186
 
92
- ## 인터랙티브 메뉴
187
+ > **별칭**: `careerly-ga4-mcp`, `careerly-ga4`도 동일하게 동작합니다.
93
188
 
94
- ```bash
95
- npx careerly-ga4-mcp
96
- ```
189
+ ---
97
190
 
98
- ```
99
- Careerly GA4 MCP Server
100
- ─────────────────────────────────────
191
+ ## 문제 해결
192
+
193
+ ### MCP 서버가 `/mcp`에 안 보여요
194
+
195
+ 1. Claude Code 완전 종료 후 재시작
196
+ 2. `claude mcp list`로 등록 확인
197
+ 3. `npx careerly-data-mcp setup` 재실행
198
+
199
+ ### PERMISSION_DENIED 에러
200
+
201
+ **GA4 권한 문제:**
202
+ - GA4 Admin > 속성 액세스 관리 > Service Account 이메일 추가
203
+
204
+ **BigQuery 권한 문제:**
205
+ - IAM & Admin > Service Account에 "BigQuery 데이터 뷰어" 역할 추가
101
206
 
102
- Status: ✓ connected
103
- Property ID: 123456789
104
- Credentials: ~/project/service-account.json
105
- Tools: 3 tools
207
+ ### MCP 제거
106
208
 
107
- ? 선택하세요
108
- View tools
109
- Reconnect
110
- Test connection
111
- Disable
209
+ ```bash
210
+ # CLI로 삭제
211
+ claude mcp remove careerly-ga4 -s user
212
+
213
+ # 또는 메뉴에서
214
+ npx careerly-data-mcp
215
+ # → Disable 선택
112
216
  ```
113
217
 
114
- ## 수동 설정
218
+ ---
219
+
220
+ ## 수동 설정 (선택)
115
221
 
116
222
  ### Claude CLI로 추가
117
223
 
@@ -119,112 +225,52 @@ Tools: 3 tools
119
225
  claude mcp add careerly-ga4 \
120
226
  -s user \
121
227
  -e GA4_PROPERTY_ID=123456789 \
122
- -e GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json \
123
- -- npx -y careerly-ga4-mcp serve
228
+ -e BQ_PROJECT_ID=your-project-id \
229
+ -e GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json \
230
+ -- npx -y careerly-data-mcp serve
124
231
  ```
125
232
 
126
233
  ### 설정 파일 직접 수정
127
234
 
128
- `~/.claude/settings.local.json`에 직접 추가:
235
+ `~/.claude/settings.local.json`:
129
236
 
130
237
  ```json
131
238
  {
132
239
  "mcpServers": {
133
240
  "careerly-ga4": {
134
241
  "command": "npx",
135
- "args": ["-y", "careerly-ga4-mcp", "serve"],
242
+ "args": ["-y", "careerly-data-mcp", "serve"],
136
243
  "env": {
137
244
  "GA4_PROPERTY_ID": "123456789",
138
- "GOOGLE_APPLICATION_CREDENTIALS": "/path/to/service-account.json"
245
+ "BQ_PROJECT_ID": "your-project-id",
246
+ "GOOGLE_APPLICATION_CREDENTIALS": "/path/to/key.json"
139
247
  }
140
248
  }
141
249
  }
142
250
  }
143
251
  ```
144
252
 
145
- ## 문제 해결
146
-
147
- ### MCP 서버가 `/mcp`에 안 보여요
148
-
149
- 1. Claude Code를 완전히 종료 후 재시작
150
- 2. 설정 확인: `claude mcp list`
151
- 3. 재설정: `npx careerly-ga4-mcp setup`
152
-
153
- ### PERMISSION_DENIED 에러
154
-
155
- GA4에 Service Account 권한이 없습니다:
156
-
157
- 1. GA4 Admin > 속성 설정 > 속성 액세스 관리
158
- 2. Service Account 이메일 추가 (JSON의 `client_email`)
159
- 3. 역할: 뷰어 (읽기 전용)
160
-
161
- ### 설정 삭제
162
-
163
- ```bash
164
- # Claude CLI로 삭제
165
- claude mcp remove careerly-ga4 -s user
166
-
167
- # 또는 인터랙티브 메뉴에서
168
- npx careerly-ga4-mcp
169
- # → Disable 선택
170
- ```
171
-
172
- ## Tool 스키마
173
-
174
- ### ga4_query
175
-
176
- ```typescript
177
- {
178
- metrics: string[] // 필수: ["sessions", "totalUsers", "conversions"]
179
- dimensions?: string[] // 선택: ["date", "sessionDefaultChannelGroup"]
180
- startDate?: string // 기본: "7daysAgo"
181
- endDate?: string // 기본: "today"
182
- datePreset?: string // "last_7_days", "last_30_days" 등
183
- limit?: number // 기본: 10
184
- }
185
- ```
186
-
187
- ### 지표 (Metrics)
253
+ ---
188
254
 
189
- | 지표 | 설명 |
190
- |------|------|
191
- | `sessions` | 세션 수 |
192
- | `totalUsers` | 전체 사용자 |
193
- | `newUsers` | 신규 사용자 |
194
- | `activeUsers` | 활성 사용자 |
195
- | `bounceRate` | 이탈률 |
196
- | `conversions` | 전환 수 |
197
- | `screenPageViews` | 페이지뷰 |
198
- | `engagementRate` | 참여율 |
255
+ ## 보안 원칙
199
256
 
200
- ### 차원 (Dimensions)
257
+ - `bq_raw_sql`은 **SELECT/WITH 문만 허용** (INSERT, DELETE 등 차단)
258
+ - Service Account 키 파일은 **개인 로컬에서만 관리**
259
+ - 민감 데이터 접근 시 권한 최소화 원칙 적용
201
260
 
202
- | 차원 | 설명 |
203
- |------|------|
204
- | `date` | 날짜 |
205
- | `sessionDefaultChannelGroup` | 채널 |
206
- | `sessionSourceMedium` | 소스/매체 |
207
- | `sessionCampaignName` | 캠페인 |
208
- | `landingPage` | 랜딩 페이지 |
209
- | `deviceCategory` | 디바이스 |
210
- | `country` | 국가 |
261
+ ---
211
262
 
212
263
  ## 개발
213
264
 
214
265
  ```bash
215
- # 의존성 설치
216
- npm install
217
-
218
- # 빌드
219
- npm run build
220
-
221
- # 개발 모드
222
- npm run dev
223
-
224
- # 타입 체크
225
- npm run typecheck
266
+ npm install # 의존성 설치
267
+ npm run build # 빌드
268
+ npm run dev # 개발 모드 (watch)
269
+ npm run typecheck # 타입 체크
226
270
  ```
227
271
 
272
+ ---
273
+
228
274
  ## 라이선스
229
275
 
230
276
  MIT
@@ -2,7 +2,7 @@
2
2
  * BigQuery 클라이언트
3
3
  * @google-cloud/bigquery 래퍼
4
4
  */
5
- import type { BigQueryConfig, BigQueryRequest, BigQueryResponse, GA4EventsRequest, DatasetInfo, TableInfo } from "./types.js";
5
+ import type { BigQueryConfig, BigQueryRequest, BigQueryResponse, GA4EventsRequest, RawSqlRequest, DatasetInfo, TableInfo } from "./types.js";
6
6
  export declare class BigQueryClient {
7
7
  private client;
8
8
  private projectId;
@@ -19,6 +19,11 @@ export declare class BigQueryClient {
19
19
  * 파라미터 기반 쿼리 실행
20
20
  */
21
21
  runQuery(request: BigQueryRequest): Promise<BigQueryResponse>;
22
+ /**
23
+ * Raw SQL 쿼리 실행 (고급 사용자용)
24
+ * 보안: SELECT 문만 허용, DML/DDL 차단
25
+ */
26
+ runRawQuery(request: RawSqlRequest): Promise<BigQueryResponse>;
22
27
  /**
23
28
  * GA4 Export 이벤트 쿼리 실행
24
29
  */
@@ -42,6 +47,8 @@ export declare class BigQueryClient {
42
47
  /**
43
48
  * GA4 Export 쿼리 빌드
44
49
  * CTE를 사용하여 event_params/user_properties 추출 후 집계
50
+ *
51
+ * 수정: GROUP BY 오류 해결 - metrics 유무에 따른 분기 처리 개선
45
52
  */
46
53
  private buildGA4EventsQuery;
47
54
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/bigquery/client.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,SAAS,EAKV,MAAM,YAAY,CAAC;AAIpB,qBAAa,cAAc;IACzB,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,QAAQ,CAAS;gBAEb,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC;IAW5C;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAoBtE;;OAEG;IACG,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA+BnE;;OAEG;IACG,iBAAiB,CACrB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,gBAAgB,CAAC;IA+B5B;;OAEG;IACG,YAAY,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAqB5C;;OAEG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAgCzD;;OAEG;IACG,cAAc,CAClB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,SAAS,CAAC;IAgCrB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IA4ExB;;;OAGG;IACH,OAAO,CAAC,mBAAmB;IAoK3B;;OAEG;IACH,OAAO,CAAC,yBAAyB;IAwCjC;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAmE3B;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAsBxB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAWxB;;OAEG;IACH,OAAO,CAAC,eAAe;IAevB;;OAEG;IACH,OAAO,CAAC,WAAW;IAuDnB;;OAEG;IACH,YAAY,IAAI,MAAM;IAItB;;OAEG;IACH,WAAW,IAAI,MAAM;CAGtB;AAKD,wBAAgB,iBAAiB,CAC/B,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAC/B,cAAc,CAKhB;AAED,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/bigquery/client.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EACb,WAAW,EACX,SAAS,EAKV,MAAM,YAAY,CAAC;AAIpB,qBAAa,cAAc;IACzB,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,QAAQ,CAAS;gBAEb,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC;IAW5C;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAoBtE;;OAEG;IACG,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA+BnE;;;OAGG;IACG,WAAW,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA4DpE;;OAEG;IACG,iBAAiB,CACrB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,gBAAgB,CAAC;IA+B5B;;OAEG;IACG,YAAY,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAqB5C;;OAEG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAgCzD;;OAEG;IACG,cAAc,CAClB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,SAAS,CAAC;IAgCrB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IA6ExB;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IA0L3B;;OAEG;IACH,OAAO,CAAC,yBAAyB;IAwCjC;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAmE3B;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAsBxB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAWxB;;OAEG;IACH,OAAO,CAAC,eAAe;IAevB;;OAEG;IACH,OAAO,CAAC,WAAW;IAuDnB;;OAEG;IACH,YAAY,IAAI,MAAM;IAItB;;OAEG;IACH,WAAW,IAAI,MAAM;CAGtB;AAKD,wBAAgB,iBAAiB,CAC/B,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAC/B,cAAc,CAKhB;AAED,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C"}
@@ -68,6 +68,56 @@ export class BigQueryClient {
68
68
  throw this.handleError(error);
69
69
  }
70
70
  }
71
+ /**
72
+ * Raw SQL 쿼리 실행 (고급 사용자용)
73
+ * 보안: SELECT 문만 허용, DML/DDL 차단
74
+ */
75
+ async runRawQuery(request) {
76
+ try {
77
+ // SQL 검증: SELECT 문만 허용
78
+ const normalizedSql = request.sql.trim().toUpperCase();
79
+ if (!normalizedSql.startsWith("SELECT") && !normalizedSql.startsWith("WITH")) {
80
+ throw new BigQueryError("Raw SQL은 SELECT 또는 WITH 문만 지원합니다. INSERT, UPDATE, DELETE, DROP 등은 사용할 수 없습니다.", "INVALID_QUERY");
81
+ }
82
+ // 위험한 키워드 차단
83
+ const dangerousKeywords = [
84
+ "INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE",
85
+ "CREATE", "ALTER", "GRANT", "REVOKE", "MERGE"
86
+ ];
87
+ for (const keyword of dangerousKeywords) {
88
+ // 문장 시작에 있는 경우만 차단 (서브쿼리 내부는 허용)
89
+ if (normalizedSql.match(new RegExp(`^\\s*${keyword}\\s`, "i"))) {
90
+ throw new BigQueryError(`${keyword} 문은 지원하지 않습니다. SELECT 문만 사용해주세요.`, "INVALID_QUERY");
91
+ }
92
+ }
93
+ // LIMIT 자동 추가 (없으면 기본값 적용)
94
+ let finalSql = request.sql.trim();
95
+ if (!normalizedSql.includes("LIMIT")) {
96
+ const limit = Math.min(request.limit || 1000, 10000);
97
+ finalSql = `${finalSql}\nLIMIT ${limit}`;
98
+ }
99
+ const [job] = await this.client.createQueryJob({
100
+ query: finalSql,
101
+ params: request.params,
102
+ location: this.location,
103
+ useLegacySql: false,
104
+ });
105
+ const [rows] = await job.getQueryResults();
106
+ const [metadata] = await job.getMetadata();
107
+ const headers = rows.length > 0 ? Object.keys(rows[0]) : [];
108
+ return {
109
+ headers,
110
+ rows: rows,
111
+ totalRows: rows.length,
112
+ jobId: job.id,
113
+ bytesProcessed: Number(metadata.statistics?.totalBytesProcessed || 0),
114
+ cacheHit: metadata.statistics?.query?.cacheHit || false,
115
+ };
116
+ }
117
+ catch (error) {
118
+ throw this.handleError(error);
119
+ }
120
+ }
71
121
  /**
72
122
  * GA4 Export 이벤트 쿼리 실행
73
123
  */
@@ -215,14 +265,15 @@ export class BigQueryClient {
215
265
  const projectId = request.projectId || this.projectId;
216
266
  const tableRef = `\`${projectId}.${request.datasetId}.${request.tableId}\``;
217
267
  parts.push(`FROM ${tableRef}`);
218
- // WHERE
268
+ // WHERE (복합 필터 로직 지원)
219
269
  if (request.where?.length) {
220
270
  const conditions = request.where.map((w) => {
221
271
  const result = this.buildWhereCondition(w, paramIndex, params);
222
272
  paramIndex = result.nextIndex;
223
273
  return result.condition;
224
274
  });
225
- parts.push(`WHERE ${conditions.join(" AND ")}`);
275
+ const logic = request.filterLogic || "AND";
276
+ parts.push(`WHERE ${conditions.join(` ${logic} `)}`);
226
277
  }
227
278
  // GROUP BY
228
279
  if (request.groupBy?.length) {
@@ -250,12 +301,16 @@ export class BigQueryClient {
250
301
  /**
251
302
  * GA4 Export 쿼리 빌드
252
303
  * CTE를 사용하여 event_params/user_properties 추출 후 집계
304
+ *
305
+ * 수정: GROUP BY 오류 해결 - metrics 유무에 따른 분기 처리 개선
253
306
  */
254
307
  buildGA4EventsQuery(request) {
255
308
  const params = {};
256
309
  const projectId = request.projectId || this.projectId;
257
310
  const tableRef = `\`${projectId}.${request.datasetId}.events_*\``;
258
311
  let paramIndex = 0;
312
+ // metrics 유무에 따른 집계 모드 결정
313
+ const isAggregateMode = !!(request.metrics?.length);
259
314
  // WHERE 조건 구성
260
315
  const whereConditions = [];
261
316
  whereConditions.push(`_TABLE_SUFFIX BETWEEN '${request.startDate}' AND '${request.endDate}'`);
@@ -314,77 +369,88 @@ export class BigQueryClient {
314
369
  FROM ${tableRef}
315
370
  ${whereClause}
316
371
  )`;
317
- // 메인 쿼리 SELECT 파트
318
- const mainSelectParts = [];
319
- const groupByParts = [];
320
- let selectIndex = 1;
321
- // event_name은 항상 포함
322
- mainSelectParts.push("event_name");
323
- groupByParts.push(String(selectIndex++));
324
- // eventParams를 메인 쿼리에 추가 (집계하지 않을 경우 GROUP BY에도 추가)
372
+ // paramFilters 처리 (추출된 event_params에 대한 필터)
373
+ const mainWhereConditions = [];
374
+ if (request.paramFilters?.length) {
375
+ request.paramFilters.forEach((pf, idx) => {
376
+ const condition = this.buildParamFilterCondition(pf, paramIndex + idx, params);
377
+ if (condition) {
378
+ mainWhereConditions.push(condition.condition);
379
+ paramIndex = condition.nextIndex;
380
+ }
381
+ });
382
+ }
383
+ // 집계 모드: GROUP BY 사용
384
+ if (isAggregateMode) {
385
+ const mainSelectParts = [];
386
+ const groupByFields = [];
387
+ // event_name은 항상 GROUP BY에 포함
388
+ mainSelectParts.push("event_name");
389
+ groupByFields.push("event_name");
390
+ // dimensions는 GROUP BY에 포함
391
+ if (request.dimensions?.length) {
392
+ request.dimensions.forEach((dim) => {
393
+ const alias = dim.includes(".") ? dim.replace(/\./g, "_") : dim;
394
+ mainSelectParts.push(alias);
395
+ groupByFields.push(alias);
396
+ });
397
+ }
398
+ // eventParams는 집계 모드에서 GROUP BY에 포함 안함 (집계 결과만)
399
+ // 대신 첫 번째 값만 보여주거나 제외
400
+ // 참고: 만약 eventParams를 GROUP BY에 포함하려면 dimensions에 추가해야 함
401
+ // metrics (집계)
402
+ request.metrics.forEach((agg) => {
403
+ mainSelectParts.push(this.buildAggregation(agg));
404
+ });
405
+ // 기본 이벤트 카운트도 추가
406
+ mainSelectParts.push("COUNT(*) AS event_count");
407
+ // 메인 쿼리 구성
408
+ let mainQuery = `SELECT
409
+ ${mainSelectParts.join(",\n ")}
410
+ FROM extracted_events`;
411
+ if (mainWhereConditions.length > 0) {
412
+ mainQuery += `\nWHERE ${mainWhereConditions.join("\n AND ")}`;
413
+ }
414
+ mainQuery += `\nGROUP BY ${groupByFields.join(", ")}
415
+ ORDER BY event_count DESC
416
+ LIMIT ${Math.min(request.limit || 100, 10000)}`;
417
+ return { query: `${cte}\n${mainQuery}`, params };
418
+ }
419
+ // 비집계 모드: raw 데이터 반환 (GROUP BY 없음)
420
+ const mainSelectParts = [
421
+ "event_name",
422
+ "event_timestamp",
423
+ "user_pseudo_id",
424
+ ];
425
+ // eventParams 포함
325
426
  if (request.eventParams?.length) {
326
427
  request.eventParams.forEach((paramKey) => {
327
- const safeKey = this.escapeIdentifier(paramKey);
328
- mainSelectParts.push(safeKey);
329
- // metrics가 없으면 GROUP BY에 포함
330
- if (!request.metrics?.length) {
331
- groupByParts.push(String(selectIndex));
332
- }
333
- selectIndex++;
428
+ mainSelectParts.push(this.escapeIdentifier(paramKey));
334
429
  });
335
430
  }
336
- // user_properties 추가
431
+ // userProperties 포함
337
432
  if (request.userProperties?.length) {
338
433
  request.userProperties.forEach((propKey) => {
339
- const safeKey = this.escapeIdentifier(propKey);
340
- mainSelectParts.push(`user_${safeKey}`);
341
- if (!request.metrics?.length) {
342
- groupByParts.push(String(selectIndex));
343
- }
344
- selectIndex++;
434
+ mainSelectParts.push(`user_${this.escapeIdentifier(propKey)}`);
345
435
  });
346
436
  }
347
- // dimensions 추가
437
+ // dimensions 포함
348
438
  if (request.dimensions?.length) {
349
439
  request.dimensions.forEach((dim) => {
350
440
  const alias = dim.includes(".") ? dim.replace(/\./g, "_") : dim;
351
441
  mainSelectParts.push(alias);
352
- groupByParts.push(String(selectIndex++));
353
442
  });
354
443
  }
355
- // metrics (집계)
356
- if (request.metrics?.length) {
357
- request.metrics.forEach((agg) => {
358
- mainSelectParts.push(this.buildAggregation(agg));
359
- });
360
- }
361
- else {
362
- // 기본 집계: 이벤트 수
363
- mainSelectParts.push("COUNT(*) AS event_count");
364
- }
365
- // paramFilters 처리 (추출된 event_params에 대한 필터)
366
- const mainWhereConditions = [];
367
- if (request.paramFilters?.length) {
368
- request.paramFilters.forEach((pf, idx) => {
369
- const condition = this.buildParamFilterCondition(pf, paramIndex + idx, params);
370
- if (condition) {
371
- mainWhereConditions.push(condition.condition);
372
- paramIndex = condition.nextIndex;
373
- }
374
- });
375
- }
376
- // 메인 쿼리 구성
444
+ // 메인 쿼리 구성 (GROUP BY 없음)
377
445
  let mainQuery = `SELECT
378
446
  ${mainSelectParts.join(",\n ")}
379
447
  FROM extracted_events`;
380
448
  if (mainWhereConditions.length > 0) {
381
449
  mainQuery += `\nWHERE ${mainWhereConditions.join("\n AND ")}`;
382
450
  }
383
- mainQuery += `\nGROUP BY ${groupByParts.join(", ")}
384
- ORDER BY event_count DESC
451
+ mainQuery += `\nORDER BY event_timestamp DESC
385
452
  LIMIT ${Math.min(request.limit || 100, 10000)}`;
386
- const query = `${cte}\n${mainQuery}`;
387
- return { query, params };
453
+ return { query: `${cte}\n${mainQuery}`, params };
388
454
  }
389
455
  /**
390
456
  * event_params 필터 조건 빌드