Integration ‐ Google - all-ad/all-ad GitHub Wiki
- 목적: 실제 광고를 집행하는 표준 광고 계정
-
특징:
- 실제 사용자에게 광고 노출 가능
- 결제 설정 및 예산 필요
- 실제 비즈니스/광고주와 연결
- 캠페인, 광고그룹, 광고, 키워드 운영 가능
-
사용 사례:
- 개별 비즈니스의 자체 광고 관리
- 단순한 광고 요구사항을 가진 중소기업
- 직접 광고주 계정
- 목적: 여러 Google Ads 계정을 중앙에서 관리하기 위한 관리 계정
-
특징:
- 직접 광고를 집행하거나 캠페인을 생성할 수 없음
- 관리 및 행정 목적으로만 사용
- 여러 관리 계정에 대한 단일 액세스 포인트 제공
- 통합 청구 및 교차 계정 기능 활성화
- 관리 계정 전체에 대한 통합 보고 제공
-
주요 기능:
- 여러 클라이언트 계정 또는 다른 관리자 계정 관리
- 계층적 계정 구조 생성
- 중앙 집중식 사용자 액세스 관리
- 교차 계정 보고 및 최적화
-
사용 사례:
- 여러 클라이언트를 관리하는 광고 대행사
- 여러 브랜드/부서를 가진 대기업
- 마케팅 서비스 제공업체
- 중앙 집중식 계정 감독이 필요한 조직
MCC (관리자 계정)
├── Account (고객 계정)
│ ├── Campaign (캠페인)
│ │ ├── Ad Group (광고그룹)
│ │ │ ├── Ads (광고)
│ │ │ └── Keywords (키워드)
│ │ └── Ad Group
│ └── Campaign
└── Account
-
MCC (관리자 계정) 레벨
- 최상위 관리 컨테이너
- 여러 클라이언트 계정 관리
- 통합 청구 옵션 제공
- 교차 계정 사용자 관리 활성화
-
Account (고객) 레벨
- 캠페인을 포함하는 개별 Google Ads 계정
- 계정 전체 설정 포함 (시간대, 통화, 청구)
- 전환 추적 및 잠재고객 데이터 저장
-
Campaign 레벨
- 공유 설정을 가진 광고그룹의 전략적 그룹화
- 예산 할당 및 지출 제어
- 타겟팅 매개변수 정의 (위치, 언어, 네트워크)
- 입찰 전략 및 캠페인 목표 설정
-
Ad Group 레벨
- 밀접하게 관련된 광고와 키워드의 전술적 그룹화
- 그룹 내 키워드의 기본 입찰가 설정
- 광고 순환 및 최적화 설정 관리
-
Ads/Keywords 레벨
- Ads: 사용자에게 표시되는 개별 광고
- Keywords: 광고 표시를 트리거하는 검색어
- 여러 클라이언트의 광고를 관리하는 대행사나 컨설턴트
- 여러 브랜드, 부서 또는 자회사를 가진 대기업
- 계정 전체에 대한 통합 보고 및 관리가 필요한 조직
- 여러 Google Ads 계정을 관리하는 애플리케이션을 개발하는 개발자
- 10개 이상의 계정을 효율적으로 관리
- 공유 예산, 전환 액션 또는 제외 키워드 목록 활용
- 여러 계정에 대한 중앙 집중식 청구 관리
- 자체 광고만 관리하는 개별 기업
- 간단한 광고 요구사항을 가진 조직
- 직접적인 실무 계정 관리를 선호하는 비즈니스
- 5개 미만의 계정 관리
- 교차 계정 조정이 필요하지 않은 독립적인 운영
중요: Google Ads API 개발자 토큰을 얻으려면 MCC 계정이 필수입니다.
npm install google-ads-api
const { google } = require("googleapis");
const { GoogleAdsApi } = require("google-ads-api");
class GoogleAdsOAuth {
constructor(clientId, clientSecret, redirectUri) {
this.oauth2Client = new google.auth.OAuth2(
clientId,
clientSecret,
redirectUri,
);
}
// Step 1: Generate authorization URL
generateAuthUrl() {
const scopes = ["https://www.googleapis.com/auth/adwords"];
return this.oauth2Client.generateAuthUrl({
access_type: "offline",
scope: scopes,
prompt: "consent", // Ensures refresh token is always returned
});
}
// Step 2: Exchange authorization code for tokens
async getTokenFromCode(authorizationCode) {
try {
const { tokens } = await this.oauth2Client.getToken(authorizationCode);
return {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_in: tokens.expiry_date,
};
} catch (error) {
throw new Error(`OAuth token exchange failed: ${error.message}`);
}
}
// Step 3: Refresh access token using refresh token
async refreshAccessToken(refreshToken) {
try {
this.oauth2Client.setCredentials({
refresh_token: refreshToken,
});
const { credentials } = await this.oauth2Client.refreshAccessToken();
return {
access_token: credentials.access_token,
expires_in: credentials.expiry_date,
};
} catch (error) {
throw new Error(`Token refresh failed: ${error.message}`);
}
}
}
const { GoogleAdsApi } = require("google-ads-api");
class GoogleAdsClient {
constructor(config) {
this.client = new GoogleAdsApi({
client_id: config.clientId,
client_secret: config.clientSecret,
developer_token: config.developerToken,
});
}
// Create customer instance for specific account
getCustomer(customerId, refreshToken, loginCustomerId = null) {
const customerConfig = {
customer_id: customerId,
refresh_token: refreshToken,
};
// Add login customer ID for MCC accounts
if (loginCustomerId) {
customerConfig.login_customer_id = loginCustomerId;
}
return this.client.Customer(customerConfig);
}
}
// Configuration
const config = {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
developerToken: process.env.GOOGLE_DEVELOPER_TOKEN,
};
const adsClient = new GoogleAdsClient(config);
async function getCampaigns(customer) {
try {
const campaigns = await customer.report({
entity: "campaign",
attributes: [
"campaign.id",
"campaign.name",
"campaign.status",
"campaign.bidding_strategy_type",
"campaign_budget.amount_micros",
],
metrics: [
"metrics.cost_micros",
"metrics.clicks",
"metrics.impressions",
"metrics.conversions",
],
constraints: {
"campaign.status": enums.CampaignStatus.ENABLED,
},
limit: 50,
});
return campaigns;
} catch (error) {
console.error("Error fetching campaigns:", error);
throw error;
}
}
async function getAccountInfo(customer) {
try {
const accountInfo = await customer.query(`
SELECT
customer.id,
customer.descriptive_name,
customer.currency_code,
customer.time_zone,
customer.status
FROM customer
WHERE customer.id = ${customer.credentials.customerId}
`);
return accountInfo[0];
} catch (error) {
console.error("Error fetching account info:", error);
throw error;
}
}
redirect_uri_mismatch
오류는 OAuth 2.0 인증 과정에서 가장 흔한 오류 중 하나입니다. 주요 원인은:
- 정확한 문자열 불일치: 인증 요청의 redirect URI가 Google Cloud Console에 설정된 URI와 정확히 일치하지 않음
-
프로토콜 차이:
http
vshttps
-
도메인 차이:
localhost
vs127.0.0.1
-
포트 번호 차이:
:8080
vs:3000
-
경로 차이:
/oauth/callback
vs/oauth/callback/
- 대소문자 차이: URI는 대소문자를 구분함
- Google Cloud Console 접속
- 프로젝트 선택 또는 새 프로젝트 생성
- APIs & Services → Credentials 이동
- APIs & Services → Library 이동
- "Google Ads API" 검색
- Enable 클릭
- APIs & Services → OAuth consent screen 이동
- External 사용자 유형 선택
- 필수 필드 입력:
- App name: 애플리케이션 이름
- User support email: 지원 이메일
- Developer contact email: 연락처 이메일
- 범위 추가:
https://www.googleapis.com/auth/adwords
- APIs & Services → Credentials 이동
- Create Credentials → OAuth client ID 클릭
- 애플리케이션 유형 선택:
- Web application: 서버 사이드 앱용
- Desktop app: 설치된 애플리케이션용
http://localhost:8080/oauth2callback
http://127.0.0.1:8080/oauth2callback
https://localhost:8080/oauth2callback
https://staging.yourapp.com/oauth2callback
https://dev.yourapp.com/auth/google/callback
https://yourapp.com/oauth2callback
https://www.yourapp.com/auth/google/callback
-
URL 문자별 비교
// 오류 메시지에서 redirect_uri 복사 // Google Cloud Console 설정과 비교
-
환경 변수 검증
console.log("Redirect URI:", process.env.REDIRECT_URI); // 실제 사용되는 URI 확인
-
여러 환경 URI 등록
# Google Cloud Console에 모든 변형 추가 http://localhost:8080/callback http://127.0.0.1:8080/callback https://example.com/callback
-
변경사항 반영 대기
- Google Cloud Console 변경사항은 5분에서 몇 시간까지 걸릴 수 있음
# .env.local
GOOGLE_ADS_DEVELOPER_TOKEN=your_developer_token_here
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GOOGLE_ADS_CUSTOMER_ID=your_customer_id
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
Server Actions는 내부 앱 변경사항과 데이터 작업에 적합합니다:
// actions/google-ads.ts
"use server";
import { createClient } from "@/utils/supabase/server";
import { GoogleAdsApi } from "google-ads-api";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const campaignSchema = z.object({
name: z.string().min(1),
budget: z.number().positive(),
status: z.enum(["ENABLED", "PAUSED"]),
});
export async function createCampaign(prevState: any, formData: FormData) {
try {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { error: "Authentication required" };
}
// Get OAuth tokens from Supabase
const { data: tokenData } = await supabase
.from("oauth_tokens")
.select("*")
.eq("user_id", user.id)
.eq("provider", "google")
.single();
if (!tokenData) {
return { error: "Google OAuth token not found" };
}
// Validate form data
const validatedFields = campaignSchema.safeParse({
name: formData.get("name"),
budget: Number(formData.get("budget")),
status: formData.get("status"),
});
if (!validatedFields.success) {
return {
error: "Invalid form data",
fieldErrors: validatedFields.error.flatten().fieldErrors,
};
}
// Initialize Google Ads API client
const client = new GoogleAdsApi({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN!,
});
const customer = client.Customer({
customer_id: process.env.GOOGLE_ADS_CUSTOMER_ID!,
refresh_token: tokenData.refresh_token,
});
// Create campaign logic here...
revalidatePath("/campaigns");
return { success: true };
} catch (error) {
console.error("Campaign creation error:", error);
return { error: "Failed to create campaign" };
}
}
-- Create OAuth tokens table
CREATE TABLE oauth_tokens (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'google',
access_token TEXT NOT NULL,
refresh_token TEXT,
expires_at TIMESTAMP WITH TIME ZONE,
scope TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Enable RLS
ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY;
-- Create policies
CREATE POLICY "Users can view their own tokens" ON oauth_tokens
FOR SELECT USING (auth.uid() = user_id);
// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options),
);
},
},
},
);
// Refresh session if expired
await supabase.auth.getUser();
return supabaseResponse;
}
- 내부 앱 변경사항
- 폼 제출
- 앱 내 데이터 작업
- 타입 안전성이 중요한 작업
- OAuth 콜백
- 웹훅
- 외부 API 통합
- 특정 HTTP 메서드가 필요한 작업
src/
├── app/ # Next.js App Router
│ ├── api/ # API routes
│ └── (dashboard)/ # Dashboard pages
├── core/ # 핵심 비즈니스 로직
│ ├── domain/ # 도메인 엔티티
│ ├── usecases/ # 유스케이스
│ └── ports/ # 인터페이스
├── infrastructure/ # 외부 의존성
│ ├── api/ # Google Ads API 클라이언트
│ ├── repositories/ # 데이터 접근 구현
│ └── services/ # 외부 서비스 구현
└── presentation/ # UI 레이어
├── components/ # 재사용 가능한 컴포넌트
└── hooks/ # 커스텀 React 훅
// container/container.ts
import { Container } from "inversify";
import { TYPES } from "./types";
import { GoogleAdsRepository } from "../infrastructure/repositories/GoogleAdsRepository";
import { CampaignUseCase } from "../core/usecases/CampaignUseCase";
const container = new Container();
// Repository bindings
container.bind(TYPES.GoogleAdsRepository).to(GoogleAdsRepository);
// Use case bindings
container.bind(TYPES.CampaignUseCase).to(CampaignUseCase);
export { container };
// core/ports/IGoogleAdsRepository.ts
export interface IGoogleAdsRepository {
getCampaigns(customerId: string): Promise<Campaign[]>;
getCampaignById(
customerId: string,
campaignId: string,
): Promise<Campaign | null>;
createCampaign(
customerId: string,
campaign: CreateCampaignRequest,
): Promise<Campaign>;
updateCampaign(
customerId: string,
campaignId: string,
updates: UpdateCampaignRequest,
): Promise<Campaign>;
}
// infrastructure/repositories/GoogleAdsRepository.ts
import { injectable, inject } from "inversify";
import { GoogleAdsApi } from "google-ads-api";
import { IGoogleAdsRepository } from "../../core/ports/IGoogleAdsRepository";
@injectable()
export class GoogleAdsRepository implements IGoogleAdsRepository {
private client: GoogleAdsApi;
constructor(@inject(TYPES.Logger) private logger: ILogger) {
this.client = new GoogleAdsApi({
client_id: process.env.GOOGLE_ADS_CLIENT_ID!,
client_secret: process.env.GOOGLE_ADS_CLIENT_SECRET!,
developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN!,
});
}
async getCampaigns(customerId: string): Promise<Campaign[]> {
// Implementation...
}
}
// shared/types/google-ads.ts
export interface Campaign {
id: string;
name: string;
status: CampaignStatus;
channelType: AdvertisingChannelType;
budget?: Budget;
metrics?: CampaignMetrics;
}
export interface CampaignMetrics {
impressions: number;
clicks: number;
cost: number;
conversions: number;
ctr: number;
cpc: number;
}
export enum CampaignStatus {
ENABLED = "ENABLED",
PAUSED = "PAUSED",
REMOVED = "REMOVED",
}
// presentation/components/campaign/CampaignDashboard.tsx
'use client'
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { CampaignTable } from './CampaignTable';
import { useCampaignDashboard } from '../../hooks/useCampaignDashboard';
interface CampaignDashboardProps {
customerId: string;
dateRange: DateRange;
}
export const CampaignDashboard: React.FC<CampaignDashboardProps> = ({
customerId,
dateRange,
}) => {
const {
data: dashboardData,
isLoading,
error,
refetch,
} = useCampaignDashboard(customerId, dateRange);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div className="space-y-6">
<CampaignTable campaigns={dashboardData.campaigns} />
</div>
);
};
// actions/campaign-toggle.ts
"use server";
import { GoogleAdsApi, enums } from "google-ads-api";
import { createClient } from "@/utils/supabase/server";
import { revalidatePath } from "next/cache";
export async function toggleCampaignStatus(
campaignId: string,
currentStatus: string,
) {
try {
// 1. 사용자 인증 확인
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { error: "Authentication required" };
}
// 2. OAuth 토큰 가져오기
const { data: tokenData } = await supabase
.from("oauth_tokens")
.select("refresh_token")
.eq("user_id", user.id)
.eq("provider", "google")
.single();
if (!tokenData) {
return { error: "Google OAuth token not found" };
}
// 3. Google Ads API 클라이언트 초기화
const client = new GoogleAdsApi({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN!,
});
const customer = client.Customer({
customer_id: process.env.GOOGLE_ADS_CUSTOMER_ID!,
refresh_token: tokenData.refresh_token,
});
// 4. 새로운 상태 결정 (토글)
const newStatus =
currentStatus === "ENABLED"
? enums.CampaignStatus.PAUSED
: enums.CampaignStatus.ENABLED;
// 5. 캠페인 상태 업데이트
const campaignResourceName = `customers/${process.env.GOOGLE_ADS_CUSTOMER_ID}/campaigns/${campaignId}`;
await customer.mutateResources([
{
entity: "campaign",
operation: "update",
resource_name: campaignResourceName,
resource: {
resource_name: campaignResourceName,
status: newStatus,
},
update_mask: ["status"],
},
]);
// 6. 캐시 무효화
revalidatePath("/campaigns");
return {
success: true,
newStatus:
newStatus === enums.CampaignStatus.ENABLED ? "ENABLED" : "PAUSED",
};
} catch (error) {
console.error("Failed to toggle campaign status:", error);
return { error: "Failed to update campaign status" };
}
}
// components/campaign/CampaignToggle.tsx
'use client'
import React, { useState } from 'react';
import { toggleCampaignStatus } from '@/actions/campaign-toggle';
interface CampaignToggleProps {
campaignId: string;
initialStatus: 'ENABLED' | 'PAUSED';
campaignName: string;
}
export function CampaignToggle({
campaignId,
initialStatus,
campaignName
}: CampaignToggleProps) {
const [status, setStatus] = useState(initialStatus);
const [isLoading, setIsLoading] = useState(false);
const handleToggle = async () => {
setIsLoading(true);
try {
const result = await toggleCampaignStatus(campaignId, status);
if (result.success && result.newStatus) {
setStatus(result.newStatus as 'ENABLED' | 'PAUSED');
// Show success toast
toast.success(`Campaign ${result.newStatus === 'ENABLED' ? 'enabled' : 'paused'}`);
} else if (result.error) {
// Show error toast
toast.error(result.error);
}
} catch (error) {
toast.error('An unexpected error occurred');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">
{campaignName}
</span>
<button
onClick={handleToggle}
disabled={isLoading}
className={`
relative inline-flex h-6 w-11 items-center rounded-full
${status === 'ENABLED' ? 'bg-green-600' : 'bg-gray-300'}
${isLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
transition-colors duration-200 ease-in-out
`}
aria-label={`Toggle campaign ${campaignName}`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white
transition-transform duration-200 ease-in-out
${status === 'ENABLED' ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
<span className="text-xs text-gray-500">
{status === 'ENABLED' ? 'Active' : 'Paused'}
</span>
</div>
);
}
// app/(dashboard)/campaigns/page.tsx
import { getCampaigns } from '@/actions/google-ads';
import { CampaignToggle } from '@/components/campaign/CampaignToggle';
export default async function CampaignsPage() {
const campaigns = await getCampaigns();
return (
<div className="container mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Campaign Management</h1>
<div className="bg-white rounded-lg shadow">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Campaign Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{campaigns.map((campaign) => (
<tr key={campaign.id}>
<td className="px-6 py-4 whitespace-nowrap">
{campaign.name}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
campaign.status === 'ENABLED'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{campaign.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<CampaignToggle
campaignId={campaign.id}
initialStatus={campaign.status}
campaignName={campaign.name}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// utils/error-handler.ts
export class GoogleAdsError extends Error {
constructor(
message: string,
public code?: string,
public details?: any,
) {
super(message);
this.name = "GoogleAdsError";
}
}
export function handleGoogleAdsError(error: any): GoogleAdsError {
if (error.errors && error.errors.length > 0) {
const firstError = error.errors[0];
return new GoogleAdsError(
firstError.message,
firstError.error_code,
firstError,
);
}
return new GoogleAdsError("An unknown error occurred", "UNKNOWN", error);
}
이 가이드는 Google Ads API를 Next.js 15 App Router 환경에서 구현하는 완전한 방법을 제공합니다. 주요 포인트:
- MCC 계정 필요성: API 개발자 토큰을 위해 필수
- Opteo 라이브러리: 간편한 Node.js 통합 제공
- OAuth 오류 해결: 정확한 redirect URI 매칭이 핵심
- Next.js 15 통합: Server Actions와 Supabase를 활용한 안전한 구현
- 모듈화 아키텍처: 확장 가능하고 유지보수가 쉬운 구조
- 실제 구현 예제: 캠페인 ON/OFF 토글 기능
이 가이드를 따라 구현하면 확장 가능하고 유지보수가 쉬운 Google Ads API 통합을 완성할 수 있습니다.