GitHub Actions 시크릿과 변수
워크플로우를 처음 만들 때는 API 키 하나쯤 YAML 파일에 그냥 적어 두고 싶은 유혹이 생기는데요. 저장소가 공개되어 있다면 그 순간 키는 전 세계에 공개된 셈이죠. 😅 그렇다고 모든 설정값을 꽁꽁 숨기자니, 앱 이름이나 로그 레벨처럼 굳이 감출 필요가 없는 값까지 로그에서 ***로 보여서 디버깅이 괴로워집니다.
GitHub Actions는 그래서 설정값을 두 종류로 나눠 보관합니다. 민감한 값을 담는 시크릿(secret)과, 감출 필요는 없지만 코드 밖으로 빼고 싶은 값을 담는 변수(variable)예요. 이번 글에서는 둘의 차이부터 시작해 저장소, 환경(environment), 조직 범위의 우선순위, gh CLI로 관리하는 방법, 그리고 포크(fork)에서 올라온 PR에서 시크릿이 비어 있는 이유까지 실습 화면과 함께 살펴보겠습니다.
GitHub Actions가 처음이라면 GitHub Actions의 소개와 핵심 개념을 먼저 읽고 오시면 좋습니다.
설정 화면 둘러보기
시크릿과 변수는 저장소의 Settings > Secrets and variables > Actions에서 관리합니다. 들어가 보면 Secrets와 Variables 두 개의 탭이 보이는데요.

Secrets 탭에는 이름과 수정 시각만 나오고 값은 어디에도 보이지 않습니다. 시크릿은 저장되는 순간 암호화되고, 등록한 사람조차 다시 열어볼 수 없어요. 값을 바꾸려면 새 값으로 덮어쓰는 수밖에 없습니다.
화면 위쪽 안내문도 눈여겨볼 만합니다. 저장소에 협업자 권한이 있는 사람은 누구나 이 시크릿을 워크플로우에서 쓸 수 있고, 포크에서 올라온 PR이 실행한 워크플로우에는 전달되지 않는다고 적혀 있어요. 이 두 문장이 시크릿 보안 모델의 핵심인데, 포크 이야기는 뒤에서 자세히 다루겠습니다.
New repository secret 버튼을 누르면 이름과 값 두 칸만 있는 단출한 폼이 나옵니다.

이름에는 몇 가지 규칙이 있습니다. 영문자, 숫자, 밑줄만 허용되고 숫자로 시작하면 안 돼요. GITHUB_로 시작하는 이름은 GitHub이 예약해 두어서 막혀 있습니다. 대소문자는 구분하지 않고 항상 대문자로 저장되니, 처음부터 API_KEY처럼 대문자 스네이크 케이스로 짓는 편이 헷갈리지 않아요.
이번에는 Variables 탭을 열어 보겠습니다.

시크릿과 달리 변수는 값이 평문으로 보이고 복사 버튼까지 달려 있습니다. 저장소에는 APP_NAME과 LOG_LEVEL을, dev와 prod 환경에는 이름은 같고 값만 다른 LOG_LEVEL을 넣어 두었는데요. 이 구성이 뒤에서 우선순위를 확인할 때 쓰입니다.
secrets와 vars 컨텍스트
워크플로우에서는 시크릿을 secrets 컨텍스트(context)로, 변수를 vars 컨텍스트로 읽습니다. 실습에 쓴 워크플로우를 보겠습니다.
name: Config
on:
push:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: 변수 읽기
env:
APP_NAME: ${{ vars.APP_NAME }}
LOG_LEVEL: ${{ vars.LOG_LEVEL }}
run: |
echo "APP_NAME: $APP_NAME"
echo "LOG_LEVEL: $LOG_LEVEL"
- name: 시크릿 읽기
env:
API_KEY: ${{ secrets.API_KEY }}
run: |
echo "API_KEY: $API_KEY"
두 단계 모두 값을 env로 넘겨 셸 환경 변수로 읽도록 똑같이 맞췄습니다. 다른 점은 vars냐 secrets냐 하나뿐이에요. 실행 로그를 열어 보면 그 차이가 한눈에 드러납니다.

변수는 my-app, debug가 그대로 찍혔는데 시크릿은 ***로 가려졌죠. GitHub은 실행 로그에서 등록된 시크릿 값과 똑같은 문자열을 찾아 자동으로 마스킹(masking)합니다. 반면 변수는 처음부터 공개해도 되는 값이라는 전제라 아무 처리도 하지 않아요.
마스킹은 값을 어떻게 읽었는지와 상관없이 적용됩니다. run 안에 ${{ secrets.API_KEY }}를 바로 써도 로그에는 똑같이 ***로 나와요. 그런데도 예제에서 env로 넘긴 이유는 ${{ }} 표현식이 셸이 실행되기 전에 스크립트 본문에 문자열로 치환되기 때문입니다. 값이 스크립트 코드에 그대로 섞이면 따옴표 같은 특수 문자에 스크립트가 깨질 수 있고, 외부 입력이 섞인 값이라면 스크립트 인젝션의 통로가 되기도 해요. 그래서 시크릿이든 변수든 env로 넘겨 셸 환경 변수로 읽는 습관을 들이는 게 좋습니다.
그럼 둘 중 무엇을 골라야 할까요? 기준은 단순합니다. 값이 유출되었을 때 누군가 그걸로 무슨 일을 벌일 만하다면 시크릿, 그렇지 않다면 변수예요. API 키, 데이터베이스 비밀번호, 배포 토큰, 웹훅 URL은 시크릿에 넣고, 배포 리전, 앱 이름, 기능 플래그, 도커 이미지 이름은 변수에 넣으면 됩니다. 변수까지 시크릿으로 만들면 로그에서 무슨 값이 쓰였는지 확인할 수 없어 디버깅만 어려워져요.
한 가지 덧붙이면, 등록하지 않은 시크릿이나 변수를 참조해도 워크플로우는 실패하지 않고 빈 문자열을 돌려줍니다. 오타로 secrets.API_KYE라고 적어도 조용히 빈 값으로 넘어가니, 값이 비어 있으면 일찍 실패하도록 검사 단계를 두는 것도 방법입니다.
조건문에서 시크릿 쓰기
“슬랙 웹훅이 등록되어 있을 때만 알림을 보내자” 같은 조건을 걸고 싶을 때가 있는데요. 직관적으로는 이렇게 쓰고 싶어집니다.
- if: secrets.SLACK_WEBHOOK != ''
run: echo "슬랙으로 알림을 보냅니다."
하지만 이 워크플로우는 아예 실행되지 않습니다.

if 조건에서는 secrets 컨텍스트를 쓸 수 없어서 Unrecognized named-value: 'secrets' 오류와 함께 워크플로우 파일 자체가 잘못되었다고 나와요. 이럴 때는 시크릿을 먼저 env로 옮긴 뒤 env 컨텍스트로 검사합니다.
- name: 시크릿이 있을 때만 실행
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
if: env.SLACK_WEBHOOK != ''
run: echo "슬랙으로 알림을 보냅니다."
실습 저장소에서 SLACK_WEBHOOK을 등록하지 않은 채 돌려 보니 이 단계는 오류 없이 건너뛰었습니다(skipped). 변수는 이런 제약이 없어서 if: vars.USE_SLACK == 'true'처럼 바로 쓸 수 있어요.
환경별로 다른 값 쓰기
개발 서버와 운영 서버의 API 키가 다르다면 어떻게 해야 할까요? DEV_API_KEY, PROD_API_KEY처럼 이름을 나눌 수도 있지만, 그러면 작업(job)마다 다른 이름을 골라 써야 해서 워크플로우가 지저분해집니다. 이럴 때 쓰는 게 환경입니다.
Settings > Environments에서 환경을 만들면 그 환경 전용 시크릿과 변수가 생겨요. 작업에 environment 키를 지정하면 그 작업에서는 해당 환경의 값이 쓰입니다.
deploy:
needs: build
runs-on: ubuntu-latest
environment: prod
steps:
- name: 환경별 값 읽기
env:
API_KEY: ${{ secrets.API_KEY }}
run: |
echo "LOG_LEVEL: ${{ vars.LOG_LEVEL }}"
echo "API_KEY 길이: ${#API_KEY}"
코드에는 여전히 secrets.API_KEY와 vars.LOG_LEVEL이라고만 적었는데, 결과는 이렇게 나옵니다.

저장소의 LOG_LEVEL은 debug였는데 prod 환경의 warn이 찍혔습니다. 시크릿은 값이 가려지니 길이를 대신 찍어 봤는데, prod 환경에 넣은 21자짜리 키와 길이가 일치해요. 이름이 같으면 더 좁은 범위의 값이 이긴다는 뜻입니다. 우선순위는 다음과 같아요.
환경 > 저장소 > 조직
같은 deploy 작업을 environment: dev로 바꾸기만 하면 개발용 값이 들어가니, 앞서 다룬 재사용 가능한 워크플로우와 조합하면 환경 이름만 입력으로 받아 같은 배포 로직을 여러 환경에 돌리면 됩니다.
환경 보호 규칙으로 운영 시크릿 지키기
환경이 값을 나눠 담는 상자에 그친다면 이름 접두어로 구분하는 것과 크게 다르지 않을 텐데요. 환경의 진짜 장점은 보호 규칙(protection rule)입니다.

prod 환경에 두 가지 규칙을 걸어 봤습니다. 먼저 필수 리뷰어(required reviewers)로 지정된 사람이 승인해야만 작업이 시작되도록 했고, 배포 브랜치를 main으로 제한했어요. 이렇게 해 두면 누군가 기능 브랜치에서 environment: prod를 지정한 워크플로우를 몰래 돌려도 운영 시크릿을 꺼낼 수 없습니다.
이 상태로 워크플로우를 실행하면 build 작업은 바로 끝나지만 deploy 작업은 승인을 기다리며 멈춥니다.

Review deployments 버튼으로 승인하기 전까지는 러너가 할당되지 않아서, 환경 시크릿이 메모리에 올라가는 일조차 없어요. 저장소 시크릿은 협업자라면 누구나 워크플로우를 고쳐 꺼낼 수 있다는 점을 생각하면, 운영 자격 증명은 저장소가 아니라 보호 규칙이 걸린 환경에 두는 것이 안전합니다.
조직 시크릿으로 여러 저장소에 공유하기
저장소가 수십 개인 조직에서 같은 배포 토큰을 저장소마다 따로 등록하면, 토큰을 교체하는 날 하루를 통째로 날리게 되는데요. 조직 설정의 Secrets and variables > Actions에 등록하면 한 번에 여러 저장소가 같은 값을 쓸 수 있습니다.
조직 시크릿은 어떤 저장소에 공개할지 접근 범위를 따로 정합니다. 모든 저장소, 비공개 저장소만, 직접 고른 저장소 중 하나를 택하는데, 꼭 필요한 저장소만 고르는 쪽이 안전합니다. 앞서 본 우선순위대로 저장소나 환경에 같은 이름이 있으면 그쪽이 이기니, 특정 저장소만 다른 값을 써야 할 때는 그 저장소에 같은 이름으로 덮어쓰면 됩니다.
개수 제한도 알아 두면 좋습니다. 시크릿은 조직에 1,000개, 저장소와 환경에 각각 100개까지이고, 값 하나는 최대 48KB예요. 인증서처럼 큰 파일을 다뤄야 한다면 파일을 암호화해 저장소에 두고 복호화 비밀번호만 시크릿으로 두는 방법을 씁니다.
gh CLI로 관리하기
시크릿이 몇 개 안 될 때는 웹 화면으로 충분하지만, 새 저장소를 만들 때마다 같은 값을 반복해서 넣다 보면 금방 지치는데요. GitHub CLI를 쓰면 터미널에서 바로 관리할 수 있습니다.
# 저장소 시크릿 (값을 생략하면 프롬프트로 입력받음)
gh secret set API_KEY
# 환경 시크릿과 변수
gh secret set API_KEY --env prod
gh variable set LOG_LEVEL --env prod --body "warn"
# 조직 시크릿을 특정 저장소에만 공개
gh secret set DEPLOY_TOKEN --org my-org --repos repo1,repo2
# 조직 시크릿을 모든 저장소에 공개
gh secret set DEPLOY_TOKEN --org my-org --visibility all
조직 시크릿의 접근 범위는 --visibility 옵션으로 정하는데, 앞서 본 웹 화면의 세 가지 선택지와 같은 all, private, selected 중 하나를 받습니다. 여기서 주의할 점은 기본값이 private라는 건데요. --org만 주고 범위를 빠뜨리면 조직의 모든 비공개 저장소가 그 시크릿을 읽을 수 있게 됩니다. --repos로 저장소를 지정하면 selected로 처리되니, 대부분은 이렇게 필요한 저장소만 나열하는 편이 안전해요.
값을 --body로 넘기면 셸 히스토리에 남으니, 시크릿은 프롬프트로 입력받거나 파일에서 읽는 편이 낫습니다. 특히 -f 옵션으로 dotenv 형식 파일을 통째로 넘기면 여러 개가 한 번에 등록되어 편해요.
gh secret set -f .env
✓ Set Actions secret DB_PASSWORD for DaleSeo/github-actions-secrets
✓ Set Actions secret SLACK_WEBHOOK for DaleSeo/github-actions-secrets
.env 파일 형식은 dotenv 사용법에서 다룬 것과 같습니다. 목록은 list로 확인합니다.
gh secret list
gh variable list --env prod
NAME UPDATED
API_KEY about 27 minutes ago
DB_PASSWORD less than a minute ago
SLACK_WEBHOOK less than a minute ago
NAME VALUE UPDATED
LOG_LEVEL warn about 27 minutes ago
시크릿 목록에는 역시 값이 없고, 변수 목록에는 값이 함께 나옵니다. 변수는 gh variable get LOG_LEVEL로 값 하나만 꺼낼 수도 있지만, 시크릿에는 get 명령이 아예 없어요. 한번 들어간 시크릿은 워크플로우 밖으로 꺼낼 방법이 없다는 원칙이 CLI에도 그대로 적용된 셈입니다.
시크릿이 비어 있는 경우
분명 등록했는데 워크플로우에서 시크릿이 빈 문자열로 들어오는 경우가 있습니다. 대부분 다음 셋 중 하나예요.
우선 포크에서 올라온 PR입니다. pull_request 이벤트로 실행된 워크플로우라도 PR이 포크에서 왔다면 GITHUB_TOKEN을 빼고는 어떤 시크릿도 전달되지 않아요. 아무나 포크해서 echo ${{ secrets.API_KEY }} | base64를 넣은 PR을 올리면 키를 빼낼 수 있으니 당연한 제약입니다. 이걸 피하려고 시크릿에 접근할 수 있는 pull_request_target 이벤트를 쓰면서 PR 코드를 체크아웃해 실행하는 경우가 있는데, 이는 공식 문서에서도 안전하지 않은 예로 드는 패턴이에요.
또 재사용 가능한 워크플로우를 호출할 때도 시크릿이 자동으로 넘어가지 않습니다. 호출하는 쪽에서 필요한 시크릿을 하나씩 넘기거나, secrets: inherit로 전부 물려줘야 해요.
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
secrets: inherit
마지막으로 Dependabot이 만든 PR에서 실행된 워크플로우에는 Actions 시크릿이 전달되지 않습니다. Dependabot은 설정 화면 사이드바에서 본 것처럼 별도의 Dependabot 시크릿을 따로 등록해야 해요.
마치며
GitHub Actions의 시크릿과 변수를 실제 화면과 실행 결과로 살펴봤습니다. 정리하면, 유출되면 곤란한 값은 시크릿에, 나머지는 변수에 넣습니다. 시크릿은 env로 넘겨 셸 변수로 읽고, if에서는 env 컨텍스트로 검사해요. 같은 이름이면 환경, 저장소, 조직 순으로 좁은 범위가 이기고, 운영 자격 증명은 보호 규칙이 걸린 환경에 두는 것이 안전합니다.
시크릿을 GitHub에 직접 넣는 것조차 꺼려진다면 두 가지 방향이 있습니다. 하나는 1Password 같은 외부 금고에서 불러오는 방법이고, 다른 하나는 OIDC로 클라우드에 직접 인증해 장기 비밀 자체를 없애는 방법이에요. 실습에 쓴 워크플로우는 DaleSeo/github-actions-secrets 저장소에서 확인하실 수 있습니다.
더 자세한 내용은 GitHub 공식 문서의 Secrets reference를 참고하세요.
This work is licensed under CC BY 4.0