Azure Key Vault로 시크릿 없애기: 관리 ID로 자격 증명을 0개로 만드는 4단계

Azure Key Vault로 시크릿과 비밀번호를 안전하게 관리하기

“코드에서 비밀번호를 없애자”는 말은 자주 하지만, 실제로는 비밀번호를 Key Vault로 옮기고 Key Vault 접근용 자격 증명을 또 코드에 넣는 경우가 많습니다. 문제가 한 단계 뒤로 밀렸을 뿐 사라지지 않았습니다.

이 글은 자격 증명을 정말로 0개로 만드는 것을 목표로, 관리 ID(Managed Identity)를 축으로 단계적으로 전환하는 방법을 다룹니다.

단계별로 무엇이 남는지 보기

단계구성코드/설정에 남는 비밀
0연결 문자열을 소스에 하드코딩DB 비밀번호 (최악)
1환경변수·앱 설정으로 분리DB 비밀번호
2Key Vault + 서비스 주체 시크릿Key Vault 접근 시크릿 (문제 이동)
3Key Vault + 관리 ID없음
4관리 ID로 DB에 직접 인증없음 (Key Vault도 불필요)

목표는 3단계, 가능하면 4단계입니다. 많은 조직이 2단계에서 멈춰 있는데, 2단계는 1단계보다 별로 나아진 게 없습니다. 서비스 주체 시크릿이 유출되면 Key Vault 안의 모든 비밀이 함께 털리므로, 오히려 단일 실패 지점이 생긴 셈입니다.

3단계 — 관리 ID로 Key Vault 접근

관리 ID는 Azure가 리소스에 자동으로 부여하고 관리하는 신원입니다. 비밀번호도 인증서도 없고, 만료도 없고, 회전할 필요도 없습니다.

#!/usr/bin/env bash
set -euo pipefail
APP="app-api-prod"; RG="rg-prod-app"; KV="kv-api-prod-krc-01"

# 1) 앱 서비스에 시스템 할당 관리 ID 켜기
PRINCIPAL_ID=$(az webapp identity assign \
  --name "$APP" -g "$RG" --query principalId -o tsv)

# 2) Key Vault를 RBAC 모드로 (액세스 정책보다 권장)
az keyvault update --name "$KV" -g "$RG" \
  --enable-rbac-authorization true

# 3) 읽기 권한만 부여 (최소 권한)
KV_ID=$(az keyvault show --name "$KV" -g "$RG" --query id -o tsv)
az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Key Vault Secrets User" \
  --scope "$KV_ID"

2번의 RBAC 모드를 권합니다. 기존 액세스 정책 방식은 Key Vault마다 별도 목록을 관리해야 하고, 권한 단위가 거칠어서 “읽기만” 같은 세밀한 제어가 어렵습니다. RBAC은 Azure 전체 권한 체계와 통합됩니다.

역할 선택도 중요합니다. Key Vault Secrets User는 읽기만 가능합니다. Key Vault Administrator를 주면 애플리케이션이 시크릿을 지울 수도 있게 됩니다. 애플리케이션에는 항상 읽기 역할만 주세요.

코드에서 쓰기

// .NET - DefaultAzureCredential이 환경에 맞게 알아서 인증
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;

var client = new SecretClient(
    new Uri("https://kv-api-prod-krc-01.vault.azure.net/"),
    new DefaultAzureCredential());

KeyVaultSecret secret = await client.GetSecretAsync("db-password");
var dbPassword = secret.Value;
# Python - 동일한 패턴
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

credential = DefaultAzureCredential()
client = SecretClient(
    vault_url="https://kv-api-prod-krc-01.vault.azure.net/",
    credential=credential)

db_password = client.get_secret("db-password").value

DefaultAzureCredential의 장점은 환경에 따라 인증 방식을 알아서 고른다는 것입니다. Azure에서는 관리 ID를, 로컬 개발에서는 az login 자격 증명을 씁니다. 코드를 바꾸지 않고 로컬과 운영을 오갈 수 있습니다.

다만 이 편의성에 함정이 있습니다. 인증에 실패하면 여러 방식을 순서대로 시도하느라 오류 메시지가 길고 원인이 묻힙니다. 운영 환경에서는 방식을 명시하는 편이 진단에 유리합니다.

// 운영: 관리 ID만 사용하도록 명시
var credential = new ManagedIdentityCredential();

// 또는 불필요한 시도를 끄기
var options = new DefaultAzureCredentialOptions
{
    ExcludeVisualStudioCredential = true,
    ExcludeAzureCliCredential = true,
    ExcludeInteractiveBrowserCredential = true
};

코드를 아예 안 고치는 방법: Key Vault 참조

App Service나 Functions라면 SDK를 쓰지 않고도 됩니다. 앱 설정 값에 참조 구문을 넣으면 플랫폼이 대신 읽어서 환경변수로 주입합니다.

az webapp config appsettings set \
  --name app-api-prod -g rg-prod-app \
  --settings "DB_PASSWORD=@Microsoft.KeyVault(SecretUri=https://kv-api-prod-krc-01.vault.azure.net/secrets/db-password/)"

애플리케이션 코드는 그냥 DB_PASSWORD 환경변수를 읽으면 됩니다. 기존 코드를 한 줄도 안 고치고 Key Vault로 전환할 수 있다는 뜻입니다. 레거시 애플리케이션에 특히 유용합니다.

부가 이점으로 콜드 스타트가 빨라집니다. SDK로 매번 Key Vault를 호출하면 토큰 획득 + 네트워크 왕복이 시작 시간에 더해지는데, 참조 방식은 플랫폼이 캐시합니다. 관련 내용은 Functions 콜드 스타트 글에 정리했습니다.

주의할 점은 시크릿 URI에서 버전을 생략하는 것입니다. 버전까지 명시하면 시크릿을 갱신해도 앱은 옛 값을 계속 봅니다. 버전 없이 쓰면 최신 버전을 자동으로 따라갑니다.

# 좋음 - 버전 생략, 갱신 시 자동 반영
.../secrets/db-password/

# 나쁨 - 버전 고정, 회전해도 반영 안 됨
.../secrets/db-password/8f4e2c1a9b3d4e5f...

4단계 — Key Vault마저 필요 없게 만들기

가장 좋은 시크릿은 존재하지 않는 시크릿입니다. DB 비밀번호를 Key Vault에 안전하게 보관하는 것보다, 비밀번호 자체를 없애는 쪽이 낫습니다.

Azure SQL, Storage, Cosmos DB 등은 관리 ID로 직접 인증할 수 있습니다.

-- Azure SQL: 관리 ID를 DB 사용자로 등록
CREATE USER [app-api-prod] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [app-api-prod];
ALTER ROLE db_datawriter ADD MEMBER [app-api-prod];
// 연결 문자열에 비밀번호가 없다
Server=tcp:sql-api-prod-krc-01.database.windows.net,1433;
Database=sqldb-api-prod;
Authentication=Active Directory Default;
Encrypt=True;
// Storage도 키 없이 접근
using Azure.Storage.Blobs;
using Azure.Identity;

var blobClient = new BlobServiceClient(
    new Uri("https://stprodkrc01.blob.core.windows.net"),
    new DefaultAzureCredential());

Storage에 관리 ID로 접근하려면 데이터 평면 역할이 필요합니다. 관리 평면 역할(Contributor)만으로는 Blob을 읽을 수 없다는 점이 헷갈리는 부분입니다.

az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Reader" \
  --scope "$(az storage account show --name stprodkrc01 -g rg-prod-data --query id -o tsv)"

여기까지 오면 스토리지 계정 키를 아예 비활성화할 수 있습니다. 키가 없으면 유출될 것도 없습니다.

az storage account update \
  --name stprodkrc01 -g rg-prod-data \
  --allow-shared-key-access false

시스템 할당과 사용자 할당, 무엇을 쓸까

관리 ID에는 두 종류가 있습니다. 선택을 잘못하면 나중에 번거로워집니다.

시스템 할당사용자 할당
수명리소스와 함께 생성·삭제독립적으로 존재
공유리소스 1개에 1개여러 리소스가 공유
권한 부여리소스마다 반복한 번만
적합단일 리소스, 단순 구성확장 그룹, 여러 인스턴스

기본은 시스템 할당이 단순해서 좋습니다. 리소스를 지우면 신원도 함께 정리되므로 고아 객체가 안 남습니다.

다만 인스턴스가 여러 개이거나 자주 재생성되는 환경에서는 사용자 할당이 낫습니다. 시스템 할당은 리소스를 다시 만들 때마다 새 신원이 생기므로, 권한을 매번 다시 부여해야 합니다. IaC로 리소스를 재생성하는 파이프라인이 있다면 여기서 자주 막힙니다.

# 사용자 할당 관리 ID 생성 (한 번만)
az identity create --name id-api-prod -g rg-prod-app

MI_ID=$(az identity show --name id-api-prod -g rg-prod-app --query id -o tsv)
MI_CLIENT_ID=$(az identity show --name id-api-prod -g rg-prod-app --query clientId -o tsv)

# 여러 리소스에 같은 신원을 붙인다
az webapp identity assign --name app-api-prod-1 -g rg-prod-app --identities "$MI_ID"
az webapp identity assign --name app-api-prod-2 -g rg-prod-app --identities "$MI_ID"

사용자 할당을 쓸 때는 코드에서 어떤 신원을 쓸지 명시해야 합니다. 리소스에 여러 신원이 붙어 있을 수 있기 때문입니다.

var credential = new DefaultAzureCredential(
    new DefaultAzureCredentialOptions
    {
        ManagedIdentityClientId = Environment.GetEnvironmentVariable("MI_CLIENT_ID")
    });

이걸 빠뜨리면 “관리 ID를 붙였는데 인증이 안 된다”는 상황이 됩니다. 시스템 할당만 있을 때는 자동으로 잡히지만, 여러 개가 붙으면 어느 것을 쓸지 알 수 없어 실패합니다.

시크릿 회전을 자동화하려면

4단계까지 못 가고 시크릿을 유지해야 하는 경우도 있습니다. 외부 서비스의 API 키처럼 관리 ID로 대체할 수 없는 것들입니다. 이때는 만료 알림이라도 걸어두세요.

# 시크릿에 만료일 설정
az keyvault secret set \
  --vault-name kv-api-prod-krc-01 \
  --name external-api-key \
  --value "..." \
  --expires "2027-01-31T00:00:00Z"

# 만료 임박 시크릿 조회
az keyvault secret list --vault-name kv-api-prod-krc-01 \
  --query "[?attributes.expires!=null].{name:name, expires:attributes.expires}" -o table

Key Vault는 시크릿 만료가 임박하면 Event Grid로 이벤트를 발행합니다. 이걸 Logic Apps나 Functions에 연결하면 알림을 자동화할 수 있습니다. 만료된 키 때문에 서비스가 멈추는 사고가 이걸로 예방됩니다.

Key Vault 자체 보호 설정

Key Vault를 만들 때 기본값으로 두면 안 되는 것들이 있습니다.

az keyvault create \
  --name kv-api-prod-krc-01 -g rg-prod-app \
  --location koreacentral \
  --enable-rbac-authorization true \
  --enable-purge-protection true \
  --retention-days 90 \
  --public-network-access Disabled
  • 제거 보호 — 실수로 지워도 보존 기간 동안 완전 삭제가 불가능해집니다. 한 번 켜면 못 끕니다. 운영에는 켜는 게 맞습니다
  • 공용 네트워크 차단 — Private Endpoint와 함께 씁니다. Private Endpoint 구성 글 참고
  • 진단 로그 — 누가 어떤 시크릿을 언제 읽었는지 기록됩니다. 기본 꺼짐이므로 별도로 켜야 합니다
// 비정상적인 시크릿 접근 탐지
AzureDiagnostics
| where ResourceType == "VAULTS"
| where TimeGenerated > ago(24h)
| where OperationName in ("SecretGet", "SecretList")
| summarize Count = count() by identity_claim_appid_g, CallerIPAddress, OperationName
| sort by Count desc

예상하지 못한 앱 ID나 IP가 나오면 조사 대상입니다. 경고 규칙으로 만들어두는 것을 권합니다.

정리

  1. Key Vault만 도입하고 서비스 주체 시크릿을 쓰면 문제가 이동했을 뿐이다
  2. 관리 ID를 켜서 자격 증명을 0개로 만든다
  3. Key Vault는 RBAC 모드로, 앱에는 읽기 역할만
  4. App Service·Functions는 Key Vault 참조로 코드 수정 없이 전환 (버전은 생략)
  5. 가능하면 4단계까지 — DB·Storage에 관리 ID로 직접 인증해 시크릿 자체를 없앤다
  6. Key Vault에 제거 보호·진단 로그를 켠다

CI/CD 쪽 자격 증명도 같은 원리로 없앨 수 있습니다. GitHub Actions vs Azure Pipelines의 OIDC 부분을 참고하세요. 계정 보호는 조건부 액세스 MFA가 이어집니다.

댓글 남기기