스크래핑

CI/CD에서 스크레이퍼 실행하기: 프록시 자격 증명, 시크릿, 테스트 실행

프록시 자격 증명을 유출하거나 푸시할 때마다 대역폭을 낭비하지 않고 CI에서 스크레이퍼를 실행하는 방법: 시크릿 저장, 테스트 티어, 세션, 쿼터 확인, 로테이션.

Chris Collins

Chris Collins

2026년 9월 21일 · 9 분 소요

스크레이퍼가 노트북에서는 잘 작동하다가 CI를 만나면 보통 두 가지 방식 중 하나로 문제가 생긴다. 누군가 “일단 초록불로 만들려고” 워크플로 파일에 프록시 비밀번호를 그대로 붙여넣는 경우, 아니면 파이프라인이 매 푸시마다 실제 라이브 스크레이핑을 실행해서 수요일까지 한 달치 대역폭을 조용히 다 써버리는 경우다. 둘 다 피할 수 있으며, 이를 해결하는 것은 대부분 어떤 실행에서만 실제로 라이브 프록시가 필요한지 미리 정하는 문제다.

이 튜토리얼에서는 CI에서 프록시 자격 증명을 어디에 두어야 하는지, 로그에 노출되지 않게 하는 방법, 대부분의 실행이 네트워크를 전혀 건드리지 않도록 테스트를 나누는 방법, 그리고 허둥대지 않고 자격 증명을 교체하는 방법을 다룬다. 예제는 GitHub Actions와 GitLab CI, Shifter의 레지덴셜 게이트웨이를 사용하지만, 구조 자체는 어떤 러너에도 그대로 적용된다.

무엇이 잘못되는가

CI 자격 증명 관련 사고는 거의 전부 네 가지 실패 유형으로 요약된다.

실패 유형발생 방식
자격 증명이 커밋됨.env 파일이나 하드코딩된 프록시 URL이 리포지토리에 들어감
자격 증명이 로그에 노출됨디버그 라인이 프록시 URL을 출력하거나, HTTP 클라이언트가 요청 헤더를 로그로 남김
자격 증명이 신뢰되지 않은 코드에 노출됨팀 외부에서 온 풀 리퀘스트가 시크릿을 사용할 수 있는 상태로 실행됨
대역폭 소모매 푸시마다 실제 사이트를 대상으로 라이브 스크레이핑이 실행됨

앞의 세 가지는 보안 문제다. 네 번째는 비용 문제이면서 동시에 파이프라인을 불안정하게 만드는데, 실제 웹사이트는 변하기 마련이고 다른 누군가의 페이지가 바뀌었다고 해서 여러분의 빌드가 실패해서는 안 되기 때문이다.

1단계: 자격 증명을 시크릿으로 저장하고, 절대 코드에 넣지 않기

레지덴셜 사용자명과 비밀번호는 패널의 요금제 페이지에 있다. 이 둘을 CI 시크릿 두 개로 저장한다.

  • SHIFTER_USERNAME: 표시된 그대로의 전체 사용자명, 예를 들어 customer-USERNAME
  • SHIFTER_PASSWORD: 비밀번호

GitHub Actions. 리포지토리의 Settings, Secrets and variables, Actions 아래에 둘 다 추가한다. GitHub는 로그에서 시크릿 값을 가려주며, GITHUB_TOKEN을 제외하면 워크플로가 포크된 리포지토리에서 트리거될 때 시크릿이 러너에 전달되지 않는다. 두 번째 규칙은 공개 리포지토리에서 중요한데, 외부 기여자의 풀 리퀘스트는 여러분의 프록시 비밀번호를 읽을 수 없지만, 대신 라이브 테스트도 실행할 수 없으며, 이는 3단계에서 다룰 문제다.

GitLab CI. 둘 다 CI/CD 변수로 추가하고, masked로 표시하고, 보호된 브랜치나 태그의 파이프라인에서만 사용할 수 있도록 protected로도 표시한다. 짚고 넘어갈 만한 함정 하나는, GitLab이 마스킹할 수 있는 값은 한 줄로 이루어진 8자 이상의 값뿐이라는 점이다. Shifter의 레지덴셜 비밀번호는 그보다 짧을 수 있고, 그런 경우 GitLab은 마스킹을 거부한다. 해결 방법은 그 쌍을 마스킹된 변수 하나로 저장하는 것이다.

SHIFTER_PROXY_AUTH=customer-USERNAME:PASSWORD

이 값은 여유 있게 8자를 넘고, GitLab이 마스킹된 변수에서 허용하는 문자만 사용한다. 코드에서는 마지막 콜론을 기준으로 분리한다.

같은 커밋에서 .env.gitignore에 추가해서, 로컬 파일이 자격 증명을 따라 리포지토리에 들어가는 일이 없도록 한다.

2단계: 프록시 URL은 코드에서 조립하고, 절대 출력하지 않기

프록시 URL은 실행 시점에 환경 변수로부터 한 곳에서 조립해서, 타기팅과 세션 플래그가 일관되게 추가되고 원본 비밀번호를 다루는 곳이 그 한 곳뿐이도록 한다.

import os

GATEWAY = "p.shifter.io:443"


def shifter_credentials():
    if "SHIFTER_PROXY_AUTH" in os.environ:
        username, password = os.environ["SHIFTER_PROXY_AUTH"].rsplit(":", 1)
        return username, password
    return os.environ["SHIFTER_USERNAME"], os.environ["SHIFTER_PASSWORD"]


def proxy_url(country=None, session=None, ttl=None):
    username, password = shifter_credentials()
    if country:
        username += f"-country-{country}"
    if session:
        username += f"-sid-{session}"
        if ttl:
            username += f"-ttl-{ttl}"
    return f"http://{username}:{password}@{GATEWAY}"


def redact(url):
    # Safe to log: keeps the flags, drops the password.
    creds, host = url.rsplit("@", 1)
    return f"{creds.rsplit(':', 1)[0]}:***@{host}"

어떤 실행에 어떤 플래그가 쓰였는지 확인해야 한다면 redact(url)을 로그로 남긴다. URL 자체는 절대 로그로 남기지 않는다.

시크릿 마스킹에는 알아둘 만한 사각지대가 있다. 마스킹은 저장된 값과 일치하는 것을 찾기 때문에, 변형된 값은 걸러지지 않고 그대로 노출된다. 프록시 인증은 username:password를 base64로 인코딩한 값을 담은 Proxy-Authorization 헤더로 전송되는데, 요청 헤더를 디버그 로그로 남기면 이 인코딩 값이 출력되며, 어떤 CI 마스커도 이를 인식하지 못한다. CI에서는 헤더 단위 디버그 로깅을 꺼두어야 한다. 그 자체로는 시크릿이 아니지만 민감한 문자열을 조립해야 할 경우, 무엇이든 출력하기 전에 GitHub의 ::add-mask:: 명령으로 등록해야 한다.

시크릿은 명령줄 인자가 아니라 환경 변수로 스크레이퍼에 전달한다. GitHub 자체 가이드도 가능하면 프로세스 간에 명령줄로 시크릿을 전달하지 말라고 권한다. 인자는 프로세스 목록에서 쉽게 보이고, 셸 추적에 남는 경향이 있다.

3단계: 대부분의 실행이 프록시를 전혀 필요로 하지 않도록 테스트 나누기

이 단계가 비용과 불안정성의 대부분을 없애준다. 스크레이퍼 테스트를 세 단계로 나눈다.

단계검증 대상프록시 필요실행 시점
파서 테스트저장된 HTML 픽스처에 대한 추출 로직아니오모든 푸시와 풀 리퀘스트
라이브 스모크 테스트게이트웨이를 통한 소수의 실제 요청메인 브랜치와 스케줄
전체 실행실제 스크레이핑별도 스케줄 또는 수동 트리거

파서 테스트는 커버리지 대부분을 차지한다. 실제 응답을 픽스처 파일로 저장하고, 셀렉터가 그 파일에서 올바른 필드를 뽑아내는지 테스트한다. 몇 초 안에 실행되고, 비용이 들지 않고, 시크릿도 필요 없으며, 코드가 잘못되었을 때만 실패한다. 사이트가 레이아웃을 바꾸면 새 픽스처를 저장하고 같은 풀 리퀘스트에서 파서를 업데이트한다.

라이브 스모크 테스트는 모든 페이지가 파싱된다는 것이 아니라, 자격 증명과 타기팅과 연결이 작동한다는 것을 확인한다. 요청 몇 개로 제한한다. 시크릿이 없을 때는 실패하는 대신 건너뛰어야 하는데, 이는 정확히 포크 풀 리퀘스트에서 일어나는 상황이다.

import os
import pytest
import requests

from scraper.proxy import proxy_url

live = pytest.mark.skipif(
    not (os.environ.get("SHIFTER_PASSWORD") or os.environ.get("SHIFTER_PROXY_AUTH")),
    reason="no proxy credentials in this environment",
)


@live
def test_gateway_exits_in_requested_country():
    url = proxy_url(country="de")
    r = requests.get("https://ipinfo.io/json", proxies={"http": url, "https": url}, timeout=30)
    assert r.status_code == 200
    assert r.json()["country"] == "DE"

전체 실행은 푸시가 아니라 스케줄에 속해야 하는데, 그래야 머지 한 번으로 프로덕션 규모의 스크레이핑이 트리거되지 않는다.

4단계: 잡 하나당 세션 하나, 절대 공유하지 않기

고정 세션은 실행 하나를 하나의 종료 IP에 고정하는데, 이는 페이지네이션 같은 다단계 흐름에 필요한 방식이다. Shifter의 세션 ID는 원하는 문자열이면 되고, 기본 수명은 120초이며 ttl로 재정의할 수 있다. 문서의 경고는 CI에도 그대로 적용된다: 동시에 실행되는 워크플로 사이에서 세션 ID를 재사용하지 말아야 하는데, 서로 다른 잡에서 온 요청이 같은 IP에 도달하면 대부분의 안티봇 시스템에 의심스러운 것으로 보인다.

CI는 이미 실행마다 고유한 값을 제공한다. 이를 사용하고, 매트릭스로 실행한다면 잡 인덱스도 함께 사용한다.

import os

run = os.environ.get("GITHUB_RUN_ID") or os.environ.get("CI_PIPELINE_ID", "local")
job = os.environ.get("JOB_INDEX", "0")
url = proxy_url(country="us", session=f"ci{run}j{job}", ttl=600)

사용자명이 대시로 플래그를 구분하므로, ID는 영숫자로만 유지한다. 서로 독립적인 요청에는 세션을 아예 넣지 말고, 각 요청이 새로운 IP로 로테이션되게 둔다.

5단계: 대규모 실행 전에 할당량 확인하기

절반� 실행하다가 대역폭이 바닥나는 예약된 스크레이핑은 처음부터 시작하지 않는 것보다 나쁘다. Shifter의 Usage and Quota API는 요금제에 남은 양을 반환하므로, 사전 확인 단계에서 실행을 깔끔하게 건너뛸 수 있다.

import os
import sys
import requests

MIN_GB = float(os.environ.get("MIN_REMAINING_GB", "5"))

r = requests.get(
    f"https://shifter.io/api/v1/memberships/{os.environ['SHIFTER_MEMBERSHIP']}/usage",
    params={"api_token": os.environ["SHIFTER_API_TOKEN"]},
    timeout=30,
)
r.raise_for_status()
plan = r.json()["data"]

if plan["metered"] and plan["remaining_gb"] < MIN_GB:
    print(f"Only {plan['remaining_gb']} GB left, resets {plan['resets_at']}. Skipping run.")
    sys.exit(78)

API 토큰은 패널의 Account, API Tokens에서 생성한다. 비밀번호와 마찬가지로 시크릿으로 저장한다. 이 토큰은 워크스페이스가 아니라 계정에 속하므로, 여러분이 멤버로 속한 모든 워크스페이스의 사용량을 읽을 수 있다. 이 엔드포인트는 분당 60회 요청으로 속도가 제한되는데, 사전 확인에 필요한 것보다 훨씬 많은 여유가 있다.

Extra Traffic이 비활성화된 상태에서 요금제가 실제로 소진되면, 게이트웨이는 509 Bandwidth Limit Exceeded를 반환한다. 이는 재시도할 대상이 아니라 정지 조건으로 취급해야 한다.

6단계: 인증 오류는 빠르게 실패시키기

재시도는 일시적인 네트워크 오류에는 맞지만 자격 증명 오류에는 맞지 않다. 407 Proxy Authentication Required는 사용자명, 비밀번호, 또는 플래그 중 하나가 틀렸다는 의미이며, 다음 시도에서도 똑같이 틀릴 것이다. CI에서 407을 둘러싼 재시도 루프는 1초짜리 실패를 10분짜리 실패로, 게다가 혼란스러운 로그와 함께 바꿔놓는다. 클라이언트가 407을 치명적 오류로 취급하고, 마스킹된 프록시 URL을 출력하고, 멈추도록 만든다. 흔한 원인과 확인해야 할 순서는 407 프록시 인증 오류 해결하기에서 다룬다.

완전한 GitHub Actions 워크플로

name: scraper

on:
  push:
  pull_request:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

jobs:
  parser-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pytest tests/parsers

  smoke-test:
    if: github.ref == 'refs/heads/main'
    needs: parser-tests
    runs-on: ubuntu-latest
    env:
      SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
      SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pytest tests/live

  full-run:
    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
    needs: smoke-test
    runs-on: ubuntu-latest
    concurrency: scraper-full-run
    env:
      SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
      SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
      SHIFTER_API_TOKEN: ${{ secrets.SHIFTER_API_TOKEN }}
      SHIFTER_MEMBERSHIP: ${{ vars.SHIFTER_MEMBERSHIP }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: python scripts/check_quota.py
      - run: python -m scraper.run

파서 테스트는 시크릿 없이 어디서나 실행된다. 스모크 테스트와 전체 실행은 시크릿이 존재하는 곳에서만 존재한다. concurrency 그룹은 두 예약 실행이 겹쳐서 IP 예산을 공유하는 일을 막는다. 멤버십 ID는 시크릿이 아니므로 일반 리포지토리 변수에 둔다.

한 가지 조정할 만한 부분이 있다. 지금 상태로는 할당량 스크립트의 종료 코드 78이 잡을 실패로 처리한다. 빨간 실행보다 건너뛴 실행을 보고 싶다면, 확인 단계에 id를 부여하고 $GITHUB_OUTPUT에 플래그를 쓰게 한 다음, 스크레이핑 단계를 그 출력값에 조건을 걸도록 한다.

자격 증명 교체하기

정기적으로 교체하고, 시크릿이 유출되었을 가능성이 있을 때는 즉시 교체한다. 공개 로그, 퇴사한 계약자, 확신이 서지 않는 포크된 워크플로 같은 경우다.

Shifter에서는 계정 소유자나 워크스페이스 Admin이 패널의 요금제 페이지에서 새 레지덴셜 비밀번호를 생성할 수 있다. Viewer와 Billing 멤버는 할 수 없다. 게이트웨이는 새 비밀번호를 즉시 적용하며, 이전 비밀번호는 그 순간 바로 작동을 멈추므로, 순서를 계획해야 한다.

  1. 예약된 워크플로를 일시 중지하거나, 진행 중인 실행이 407로 실패하는 것을 감수한다.
  2. 패널에서 새 비밀번호를 생성한다.
  3. CI 시크릿을 바로 업데이트한다.
  4. 같은 요금제를 사용하는 다른 모든 사용처를 업데이트한다. 비밀번호는 하나의 파이프라인이 아니라 요금제에 속하므로, 이를 사용하는 다른 모든 것이 같은 순간에 깨진다.
  5. 스모크 테스트를 다시 실행해서 확인한다.

4번이 중요한 이유는 교체가 필요해지기 전에 어느 요금제의 자격 증명이 어디서 쓰이는지 알아두는 것이 도움이 된다는 점이다. 여러 독립적인 파이프라인이 하나의 요금제를 공유한다면, 교체 한 번으로 모두가 영향을 받는다.

결론

CI에서 스크레이퍼를 실행할 때 작업의 대부분은 프록시가 필요 없는 실행에서 프록시를 배제하는 것이다. 픽스처를 대상으로 하는 파서 테스트는 시크릿도 대역폭도 없이 매 푸시마다 로직을 검증한다. 메인 브랜치의 작은 라이브 스모크 테스트는 자격 증명이 여전히 작동함을 확인한다. 실제 스크레이핑은 스케줄에 따라 실행되고, 먼저 할당량을 확인하고, 다른 누구와도 공유하지 않는 세션 ID를 사용하며, 인증 오류가 처음 발생하면 재시도하지 않고 멈춘다.

자격 증명 자체는 CI 시크릿 저장소에 있고, 함수 하나에서 조립되며, base64를 포함해서 절대 출력되지 않는다. 더 오래 실행되는 수집 작업의 경우, 파이프라인을 지켜보는 운영 측면은 웹 스크레이핑 파이프라인 모니터링하기에서, 여러 지역에 걸쳐 분산시키는 방법은 다중 리전 파이프라인을 위한 레지덴셜 프록시 페일오버에서 다룬다. 브라우저 기반 스크레이퍼를 위한 클라이언트 측 프록시 설정은 Selenium과 Playwright에서 레지덴셜 프록시 설정하기에서 다룬다.

시작할 준비가 되셨나요?

205M개 이상의 IP, 195개 이상의 국가를 지원하는 Shifter의 레지덴셜 프록시를 $0.10/GB부터 이용해보세요.

시작하기