7 분 소요

BigQuery에서 개인 정보가 포함된 테이블을 운영하다 보면, 단순히 권한을 나누는 수준만으로는 부족한 경우가 많습니다. 특히 ODS나 분석용 테이블이 자동 생성되거나 재생성되는 환경에서는, 민감 컬럼에 대한 정책 적용이 한 번만 누락되어도 그대로 노출 위험으로 이어질 수 있습니다.

이 글은 GCP Audit Log를 기반으로 BigQuery Dynamic Data Masking, 이하 DDM 적용을 자동화했던 파이프라인을 정리한 글입니다. 단순히 “마스킹 정책을 한 번 적용한다”가 아니라, 테이블 생성 이벤트를 실시간으로 감지하고, 정책 누락을 줄이고, 호출량 한도와 재시도까지 고려해 파이프라인을 개선해간 과정을 중심으로 정리했습니다.

왜 DDM 자동화가 필요했는가

PII가 포함된 테이블은 사람이 수동으로 관리하면 언젠가는 빠집니다. 특히 아래 상황에서 위험이 커집니다.

  • BigQuery 테이블이 자동 생성되는 경우
  • ODS clone 작업처럼 짧은 시간에 여러 테이블이 한꺼번에 생기는 경우
  • Dataform 이관 등으로 DDL 이벤트가 급증하는 경우
  • 정책은 Git에 정의되어 있는데 실제 BigQuery 반영은 별도 작업인 경우

저희가 해결하려던 문제도 비슷했습니다. BigQuery에서 테이블이 새로 만들어질 때, DDM 대상 테이블인지 판별하고 필요한 tag policy를 자동 적용해야 했습니다. 이 과정이 빠지면, 권한 모델은 존재해도 실제 테이블에는 마스킹이 안 들어간 상태가 발생할 수 있었습니다.

데이터 엔지니어 관점에서 보면, 이런 종류의 문제는 데이터 처리 문제라기보다 운영 제어 문제에 가깝습니다. 민감 정보 보호를 데이터 생성 이후의 수동 절차에 맡기지 않고, 이벤트 기반 파이프라인으로 강제해야 했습니다.

1차 파이프라인: Audit Log → Pub/Sub → Eventarc → Cloud Function

처음에는 비교적 단순한 구조로 출발했습니다.

  • Log Router가 BigQuery DDL 관련 audit log를 Pub/Sub로 전달
  • Eventarc가 Pub/Sub 메시지를 받아 Cloud Function 호출
  • Cloud Function이 Git 레포의 DDM 대상 목록과 tag policy를 가져와 적용 여부 점검 및 반영

이 구조는 BigQuery 테이블 생성 이벤트를 실시간으로 잡아 처리하기에 충분히 직관적이었습니다.

구성 요소는 아래와 같았습니다.

  • log router: pubsub-stream-router
  • pub/sub: projects/my-project/topics/audit-log-pubsub-stream
  • eventarc: audit-log-pubsub-stream-manager-511039
  • cloud function: audit-log-pubsub-stream-manager

Cloud Function의 역할은 단순했습니다. 새로 생성된 테이블을 보고,

  1. DDM 대상 테이블인지 확인하고
  2. 이미 정책이 적용됐는지 점검한 뒤
  3. 누락 시 BigQuery 정책을 업데이트하는 구조였습니다.

이 시점의 장점은 구성 단순성이었습니다. Audit Log를 이벤트 소스로 삼고, 테이블 생성 직후 반응할 수 있다는 점이 좋았습니다.

1차 구조의 한계

하지만 운영 환경이 바뀌면서 이 구조의 약점이 드러났습니다.

짧은 시간에 Pub/Sub 메시지가 많이 발생하면, Cloud Function이 동시에 여러 개 호출되면서 BigQuery update_table 호출이 한꺼번에 몰릴 수 있습니다. 평소에는 큰 문제가 아니었지만, Dataform 이관이나 ODS clone 작업처럼 DDL 이벤트가 폭증하는 시점에는 quota 한도에 걸릴 가능성이 생겼습니다.

실제로 전체 DDM 대상 테이블에 대해 update_table 호출을 수행하는 과정에서 권한 오류가 간헐적으로 발생했고, 이 문제를 건별 시간 제어 없이 보완하기 어려웠습니다.

즉, 1차 구조는 “빠르게 반응한다”는 점은 좋았지만, “순차 제어와 재시도”에는 약했습니다.

2차 파이프라인: Cloud Tasks를 중간에 넣었습니다

이 문제를 풀기 위해 2차 구조에서는 Cloud Tasks를 중간에 추가했습니다.

핵심 의도는 명확했습니다.

  • 메시지를 바로 Function/Run으로 흘려보내지 않고
  • Queue에 적재해서
  • 순차적으로 처리하고
  • 실패하면 재시도할 수 있도록 만드는 것

구성도를 먼저 보면 전체 구조는 아래와 같습니다.

Audit Log
  -> Log Router
  -> Pub/Sub
  -> Subscription(push)
  -> Cloud Tasks
  -> Cloud Run

아키텍처를 도식으로 보면 아래와 같습니다.

graph LR
    A["Audit Log"] --> B["Log Router"]
    B --> C["Pub/Sub Topic"]
    C --> D["Push Subscription"]
    D --> E["Cloud Tasks Queue"]
    E --> F["Cloud Run Worker"]

    style A fill:#eef6ff,stroke:#3b82f6,stroke-width:2px
    style B fill:#f3f4f6,stroke:#6b7280,stroke-width:2px
    style C fill:#ecfdf5,stroke:#10b981,stroke-width:2px
    style D fill:#fff7ed,stroke:#f59e0b,stroke-width:2px
    style E fill:#fef2f2,stroke:#ef4444,stroke-width:2px
    style F fill:#f5f3ff,stroke:#8b5cf6,stroke-width:2px

여기서 중요한 변화는 push subscription 이 Cloud Run을 직접 부르는 게 아니라, Cloud Tasks의 bufferTask API를 호출한다는 점입니다. 이렇게 하면 Cloud Tasks가 큐 레벨에서 dispatch rate, concurrency, retry 정책을 제어해줄 수 있습니다.

Log Router 설정

이 파이프라인의 시작점은 Log Router입니다.

아래 필터를 보면, 단순한 로그 전체가 아니라 BigQuery CREATE_TABLE_AS_SELECT, CREATE_TABLE 같은 생성 이벤트만 골라 Pub/Sub 토픽으로 전달하도록 구성했습니다.

gcloud logging sinks describe pubsub-stream-router
createTime: '2023-07-19T06:29:46.969908278Z'
description: 실시간으로 처리하는 로그 라우터
destination: pubsub.googleapis.com/projects/my-project/topics/audit-log-pubsub-stream
filter: |-
  severity=INFO AND
  resource.type="bigquery_resource" AND
  logName="projects/my-project/logs/cloudaudit.googleapis.com%2Fdata_access" AND
  protoPayload.serviceData.jobCompletedEvent.job.jobConfiguration.query.statementType = ("CREATE_TABLE_AS_SELECT" OR "CREATE_TABLE") AND
  protoPayload.serviceData.jobCompletedEvent.job.jobConfiguration.query.destinationTable.datasetId !~ "^(_script|_[0-9])"
name: pubsub-stream-router
resourceName: projects/my-project/sinks/pubsub-stream-router
updateTime: '2023-07-19T06:29:46.969908278Z'
writerIdentity: serviceAccount:service-000000000000@gcp-sa-logging.iam.gserviceaccount.com

이 필터에서 중요한 점은 두 가지입니다.

  • 관심 있는 BigQuery DDL 이벤트만 좁혀서 잡는 점
  • _script, 임시 dataset 같은 노이즈를 제외하는 점

이런 필터링이 중요합니다. 이벤트 기반 파이프라인은 시작점에서 노이즈를 줄이지 않으면 downstream이 금방 불안정해집니다.

Pub/Sub와 Push Subscription

토픽은 비교적 단순합니다.

name: projects/my-project/topics/audit-log-pubsub-stream

그 다음 단계인 Subscription은 조금 더 흥미롭습니다. 일반적인 패턴이라면 Push Subscription이 바로 HTTP endpoint를 호출할 텐데, 여기서는 Cloud Tasks의 tasks:buffer endpoint로 push 하도록 구성했습니다.

ackDeadlineSeconds: 10
expirationPolicy:
  ttl: 86400s
messageRetentionDuration: 86400s
name: projects/my-project/subscriptions/eventarc-asia-northeast3-ddl-change-trigger
pushConfig:
  oidcToken:
    serviceAccountEmail: 000000000000-compute@developer.gserviceaccount.com
  pushEndpoint: https://cloudtasks.googleapis.com/v2beta3/projects/my-project/locations/asia-northeast3/queues/ddl-change-queue2/tasks:buffer
retryPolicy:
  maximumBackoff: 600s
  minimumBackoff: 10s
state: ACTIVE
topic: projects/my-project/topics/audit-log-pubsub-stream

즉, Pub/Sub는 “이벤트 전달”만 하고, 실제 처리 순서와 재시도는 Cloud Tasks로 넘긴 셈입니다.

Cloud Tasks를 넣은 이유

Cloud Tasks를 넣은 핵심 이유는 세 가지였습니다.

1. 순차 처리

짧은 시간에 DDL 이벤트가 몰려도 maxConcurrentDispatches=1 로 제어하면 동시에 여러 테이블을 업데이트하지 않도록 막을 수 있습니다.

2. 속도 제어

maxDispatchesPerSecond=1.0 처럼 dispatch rate를 제한해 BigQuery update quota를 덜 자극할 수 있습니다.

3. 재시도 정책

한 번 실패했다고 바로 유실되지 않고, backoff 기반 재시도가 가능해집니다.

실제 생성 커맨드는 아래처럼 구성됐습니다.

REGION=asia-northeast3
PROJECT=my-project

SERVICE=apply-bigquery-ddm
SERVICE_URL=https://apply-bigquery-ddm-000000000000.asia-northeast3.run.app
SERVICE_HOST=$(echo $SERVICE_URL | sed 's,http[s]*://,,g')

TOPIC=audit-log-pubsub-stream
QUEUE=ddl-change-queue2

QUERY="__GCP_CloudEventsMode=CUSTOM_PUBSUB_projects%2F${PROJECT}%2Ftopics%2F${TOPIC}"

gcloud tasks queues create $QUEUE \
  --location=$REGION \
  --http-uri-override="host:$SERVICE_HOST,query:$QUERY" \
  --http-oidc-service-account-email-override=000000000000-compute@developer.gserviceaccount.com \
  --max-concurrent-dispatches=1 \
  --max-dispatches-per-second=1.0 \
  --max-attempts=10 \
  --min-backoff=2s \
  --max-backoff=600s \
  --max-doublings=10 \
  --max-retry-duration=10s \
  --project=$PROJECT

이 설정은 Cloud Tasks를 단순 큐가 아니라, 운영 안정성을 위한 rate limiter로 쓴 사례라고 볼 수 있습니다.

Queue 레벨 routing과 Cloud Run

Cloud Tasks에서 bufferTask 를 쓰려면 queue 자체에 HttpTarget routing 이 등록되어 있어야 합니다. 콘솔에서 간단히 누르는 식으로는 안 되고, gcloud 나 IaC를 통해 queue를 생성하거나 업데이트해야 했습니다.

실제 queue 정보는 아래와 같이 확인할 수 있습니다.

httpTarget:
  oidcToken:
    serviceAccountEmail: 000000000000-compute@developer.gserviceaccount.com
  uriOverride:
    host: apply-bigquery-ddm-000000000000.asia-northeast3.run.app
    pathOverride: {}
    queryOverride:
      queryParams: __GCP_CloudEventsMode=CUSTOM_PUBSUB_projects%2Fmy-project%2Ftopics%2Faudit-log-pubsub-stream
name: projects/my-project/locations/asia-northeast3/queues/ddl-change-queue2
rateLimits:
  maxBurstSize: 10
  maxConcurrentDispatches: 1
  maxDispatchesPerSecond: 1.0
retryConfig:
  maxAttempts: 10
  maxBackoff: 600s
  maxDoublings: 10
  maxRetryDuration: 10s
  minBackoff: 2s
state: RUNNING

즉 queue에 task가 들어가면, queue 설정에 따라 Cloud Run이 자동 호출되는 구조였습니다.

Cloud Run 쪽은 Python 3.11 기반으로 동작했고, DDM 적용 로직을 실제 수행하는 worker 역할을 맡았습니다.

URL:     https://apply-bigquery-ddm-000000000000.asia-northeast3.run.app
Ingress: all
Traffic:
  100% LATEST (currently apply-bigquery-ddm-00007-lmt)

Last updated on 2024-12-04T01:14:51.099543Z:
  Revision apply-bigquery-ddm-00007-lmt
  Container worker
    Base Image:      asia-northeast3-docker.pkg.dev/serverless-runtimes/google-22-full/runtimes/python311
    Port:            8080
    Memory:          256M
    CPU:             167m
    Env vars:
      GIT_TOKEN      [****GIT_TOKEN****]
      LOG_EXECUTION_ID true
  Service account:   000000000000-compute@developer.gserviceaccount.com
  Concurrency:       1
  Max Instances:     100
  Timeout:           60s

이 구조를 조금 더 운영 관점으로 보면 아래처럼 볼 수도 있습니다.

flowchart TD
    A["BigQuery Table Created"] --> B["Audit Log Captured"]
    B --> C["Filtered by Log Router"]
    C --> D["Pub/Sub Message Published"]
    D --> E["Task Buffered"]
    E --> F["Cloud Run Applies DDM"]
    F --> G{"Success?"}
    G -- "Yes" --> H["DDM Applied"]
    G -- "No" --> I["Retry by Cloud Tasks Policy"]
    I --> E

    style A fill:#eef2ff,stroke:#6366f1,stroke-width:2px
    style B fill:#eff6ff,stroke:#3b82f6,stroke-width:2px
    style C fill:#f3f4f6,stroke:#6b7280,stroke-width:2px
    style D fill:#ecfdf5,stroke:#10b981,stroke-width:2px
    style E fill:#fff7ed,stroke:#f59e0b,stroke-width:2px
    style F fill:#fdf2f8,stroke:#ec4899,stroke-width:2px
    style H fill:#ecfccb,stroke:#65a30d,stroke-width:2px
    style I fill:#fef2f2,stroke:#ef4444,stroke-width:2px

여기서 중요한 점은 Cloud Run 서비스가 단순 API 서버가 아니라, queue에 의해 직렬화된 작업을 받는 실행기라는 점입니다.

호출 방식

Cloud Tasks의 bufferTask 는 API 호출 기반이기 때문에, 직접 수동 호출하거나 배치 적재 시에도 같은 인터페이스를 재사용할 수 있습니다.

예시는 아래와 같습니다.

curl -X POST "https://cloudtasks.googleapis.com/v2beta3/projects/my-project/locations/asia-northeast3/queues/ddl-change-queue/tasks:buffer" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-d '{
  "projectId":"my-project",
  "datasetId":"personal_greenie",
  "tableId":"new_table"
}'

이렇게 해두면 운영 중 실시간 이벤트뿐 아니라, 특정 테이블을 수동으로 다시 DDM 대상 큐에 넣는 것도 같은 경로로 처리할 수 있습니다. 데이터 엔지니어링에서는 이런 “재사용 가능한 운영 인터페이스”가 생각보다 중요합니다.

일괄 적용 코드와 GitOps

이 파이프라인이 더 안정적이었던 이유 중 하나는 DDM 대상 테이블 자체도 코드로 관리했다는 점입니다.

DDM 대상 테이블 목록은 별도 레포의 table_ddm.yaml 같은 선언형 설정 파일에서 관리하고, 이 파일이 수정되어 배포 브랜치에 머지되면 GitHub Actions를 통해 대상 테이블을 Cloud Tasks에 적재하는 흐름이 구성되어 있었습니다.

즉 구조를 보면 아래 세 층이 분리되어 있었습니다.

  • 정책 정의: YAML
  • 정책 반영 트리거: GitHub Actions
  • 실제 적용 실행: Cloud Tasks + Cloud Run

이 패턴은 운영 측면에서 장점이 큽니다.

  • 어떤 테이블이 DDM 대상인지 Git에서 추적 가능
  • 반영 히스토리가 남음
  • 실시간 이벤트 외에 일괄 반영도 동일 경로 사용 가능

정책 정의부터 실행까지의 흐름을 보면 아래와 같습니다.

flowchart LR
    A["table_ddm.yaml"] --> B["GitHub Actions"]
    B --> C["Cloud Tasks"]
    C --> D["Cloud Run"]
    D --> E["BigQuery Policy Update"]

    style A fill:#f5f3ff,stroke:#8b5cf6,stroke-width:2px
    style B fill:#eff6ff,stroke:#3b82f6,stroke-width:2px
    style C fill:#fff7ed,stroke:#f59e0b,stroke-width:2px
    style D fill:#ecfdf5,stroke:#10b981,stroke-width:2px
    style E fill:#fefce8,stroke:#eab308,stroke-width:2px

데이터 엔지니어 관점에서 얻은 교훈

이 작업을 하면서 몇 가지를 명확히 느꼈습니다.

1. 보안 정책도 데이터 파이프라인처럼 다뤄야 합니다

보안은 운영 문서가 아니라 실행 가능한 파이프라인이어야 합니다. 사람이 기억해서 적용하는 정책은 시간이 지나면 반드시 빠집니다.

2. 실시간 반응보다 안정적인 순차 처리가 더 중요할 때가 있습니다

초기 구조는 더 단순했지만, burst traffic과 quota에 취약했습니다. Cloud Tasks를 넣으면서 처리 속도는 약간 희생했지만, 운영 안정성은 훨씬 올라갔습니다.

3. 이벤트 기반 자동화는 결국 메타데이터 자동화입니다

여기서 다룬 것은 데이터 자체가 아니라 “어떤 테이블이 생성되었는가”, “그 테이블은 PII 대상인가”, “정책이 적용되었는가” 같은 메타데이터 문제였습니다. 데이터 플랫폼 운영에서는 메타데이터를 자동으로 제어하는 능력이 점점 더 중요해진다고 생각합니다.

마무리

BigQuery DDM 적용은 한 번 세팅하고 끝나는 일이 아니었습니다. 테이블 생성, 재생성, 대량 DDL 이벤트, quota, 재시도, Git 기반 정책 관리까지 함께 고려해야 하는 운영 문제였습니다.

초기에는 Audit Log 기반의 단순한 실시간 반영 구조로 시작했지만, 운영 환경이 커지면서 Cloud Tasks를 넣어 순차 처리와 재시도를 보장하는 방향으로 진화했습니다.

결국 이 경험에서 가장 크게 느낀 것은 하나입니다. 데이터 플랫폼에서 보안 정책 자동화는 선택이 아니라 운영 필수 요소라는 점입니다. 특히 PII를 다루는 환경이라면, “누락돼도 괜찮은” 구조가 아니라 “누락되기 어려운” 구조를 설계해야 합니다.

댓글 남기기

스팸 방지를 위해 짧은 시간에 반복 등록은 제한됩니다.

댓글 목록

관리자 보기