Platform

Azure 웹 애플리케이션 배포와 스토리지 연동 흐름

Azure App Service, Storage Account, Blob 업로드, Backend API, Frontend 배포를 하나의 애플리케이션 운영 흐름으로 정리합니다.

Azure 웹 애플리케이션 배포와 스토리지 연동 흐름

Azure를 실습할 때 App Service, Storage Account, Blob Container, Backend API, Frontend 배포를 각각 따로 다루면 서비스가 어떻게 연결되는지 감이 잘 잡히지 않는다. 실제 애플리케이션 관점에서는 사용자가 파일을 올리고, 프론트엔드가 API를 호출하고, 백엔드가 스토리지에 객체를 저장하며, 저장된 URL이나 메타데이터가 데이터베이스에 남는 흐름으로 이어진다.

이 글은 Azure Storage와 Web App을 단순 리소스 목록이 아니라 “배포된 웹 애플리케이션이 파일을 받고 보관하는 경로”로 다시 정리한 기록이다. 핵심은 Blob Storage를 만드는 방법보다, 어떤 계층이 어떤 책임을 가져야 하고 어떤 설정이 운영 중 문제를 줄이는가에 있다.

전체 흐름

파일 업로드 기능을 기준으로 보면 구성은 비교적 단순하다.

프론트엔드가 Blob Storage에 직접 파일을 올릴 수도 있지만, 운영 관점에서는 백엔드 API를 경유하는 방식이 다루기 쉽다. 파일 크기 제한, 확장자 검증, 사용자 권한, 바이러스 검사, 메타데이터 저장, 감사 로그를 API에서 통제할 수 있기 때문이다. 대용량 파일이나 비용 최적화가 중요해지면 백엔드가 short-lived SAS URL을 발급하고 클라이언트가 Blob에 직접 업로드하는 구조로 바꿀 수 있다.

리소스 그룹과 배포 단위

작은 실습에서는 모든 리소스를 하나의 리소스 그룹에 넣어도 된다. 하지만 운영을 염두에 두면 리소스 그룹은 삭제와 권한의 단위라는 점을 기억해야 한다. 웹앱, 스토리지, 모니터링, 데이터베이스가 같은 수명주기를 갖는다면 하나의 리소스 그룹으로 묶을 수 있다. 반대로 공용 네트워크, 공용 모니터링, 공유 Key Vault처럼 여러 앱이 함께 쓰는 리소스는 별도 그룹으로 분리하는 편이 안전하다.

az group create \
  --name rg-web-storage-prod \
  --location koreacentral

명령어 자체는 간단하지만 이름 규칙은 나중에 중요해진다. 환경, 워크로드, 지역, 리소스 종류가 이름에 들어가면 포털과 로그에서 리소스를 추적하기 쉽다.

rg-web-storage-prod
stwebstorageprod001
app-webstorage-api-prod
appi-webstorage-prod

Storage Account를 만들 때 보는 설정

Storage Account는 Blob, Queue, Table, File 같은 여러 서비스를 제공한다. 파일 업로드 기능에서는 Blob Storage를 주로 사용한다.

az storage account create \
  --name stwebstorageprod001 \
  --resource-group rg-web-storage-prod \
  --location koreacentral \
  --sku Standard_LRS \
  --kind StorageV2 \
  --min-tls-version TLS1_2 \
  --allow-blob-public-access false

처음에는 Standard_LRS로도 충분하다. 다만 운영 환경에서는 데이터 중요도와 복구 목표에 따라 ZRS, GRS, RA-GRS 같은 복제 옵션을 검토해야 한다. 정적 이미지나 임시 업로드 파일과 계약서, 정산 파일, 감사 대상 파일은 같은 정책으로 둘 수 없다.

Container는 접근 범위를 명확히 정한다.

az storage container create \
  --name uploads \
  --account-name stwebstorageprod001 \
  --auth-mode login \
  --public-access off

공개 이미지 CDN처럼 누구나 읽어도 되는 파일이 아니라면 기본값은 private이 안전하다. Blob의 URL을 안다고 해서 바로 읽을 수 없게 하고, 필요한 경우 API를 통해 권한을 확인한 뒤 다운로드 URL을 발급하는 구조가 좋다.

Storage Account를 만들 때는 Blob만 보더라도 선택지가 많다. 실습에서는 컨테이너를 만들고 이미지를 업로드하는 흐름만 확인하지만, 운영에서는 저장소가 어떤 책임을 갖는지 먼저 정해야 한다.

Blob Storage
  - user uploaded images
  - generated reports
  - exported CSV / PDF
  - static assets

File Share
  - lift-and-shift application shared path
  - legacy app file dependency

Table Storage
  - simple key-value style metadata
  - low-cost lookup data

Queue Storage
  - async processing request
  - image resize / virus scan trigger

파일 업로드 기능에서 흔한 실수는 "Blob에 저장된다"는 사실만 보고 모든 파일을 같은 컨테이너와 같은 수명주기로 다루는 것이다. 임시 업로드, 사용자 프로필 이미지, 감사 대상 문서, 배치 처리 결과물은 접근 권한과 보존 기간이 다르다. 컨테이너를 나누거나 metadata, tag, lifecycle policy를 이용해 보존 정책을 분리해야 한다.

az storage account management-policy create \
  --account-name stwebstorageprod001 \
  --resource-group rg-web-storage-prod \
  --policy @lifecycle-policy.json
{
  "rules": [
    {
      "enabled": true,
      "name": "delete-temp-uploads",
      "type": "Lifecycle",
      "definition": {
        "filters": {
          "blobTypes": ["blockBlob"],
          "prefixMatch": ["uploads/tmp/"]
        },
        "actions": {
          "baseBlob": {
            "delete": {
              "daysAfterModificationGreaterThan": 7
            }
          }
        }
      }
    }
  ]
}

이 정책은 "업로드 실패 후 남은 임시 파일" 같은 운영 찌꺼기를 줄이는 데 도움이 된다. 단, 삭제 정책은 되돌릴 수 없는 영향을 주기 때문에 prefix와 컨테이너 범위를 좁게 시작하고, 처음에는 로그와 모니터링으로 실제 매칭 대상을 확인한 뒤 적용하는 편이 안전하다.

액세스 키보다 Managed Identity를 먼저 생각한다

Storage Account를 만들면 access key가 생성된다. 실습에서는 connection string을 복사해 애플리케이션 환경 변수에 넣기 쉽다. 하지만 운영에서는 key가 유출되면 스토리지 전체 권한으로 이어질 수 있다. 가능하면 App Service에 Managed Identity를 부여하고, Storage Blob Data Contributor 같은 최소 권한 역할을 붙이는 방향을 먼저 검토한다.

az webapp identity assign \
  --name app-webstorage-api-prod \
  --resource-group rg-web-storage-prod

Managed Identity의 principal id를 확인한 뒤 Storage Account 범위에 역할을 부여한다.

PRINCIPAL_ID=$(az webapp identity show \
  --name app-webstorage-api-prod \
  --resource-group rg-web-storage-prod \
  --query principalId \
  --output tsv)

STORAGE_ID=$(az storage account show \
  --name stwebstorageprod001 \
  --resource-group rg-web-storage-prod \
  --query id \
  --output tsv)

az role assignment create \
  --assignee "$PRINCIPAL_ID" \
  --role "Storage Blob Data Contributor" \
  --scope "$STORAGE_ID"

이 방식은 secret을 줄이는 데 도움이 된다. 물론 모든 환경에서 바로 Managed Identity를 쓸 수 있는 것은 아니기 때문에, connection string을 써야 한다면 Key Vault나 App Service Configuration에 넣고 코드에 하드코딩하지 않는다.

Backend API의 책임

파일 업로드 API는 단순히 multipart를 받아 Blob에 저장하는 엔드포인트가 아니다. 운영을 생각하면 다음 책임을 갖는다.

1. 사용자 인증과 권한 확인
2. 파일 크기 제한
3. 허용 확장자와 MIME type 검증
4. 저장 경로 생성 규칙
5. Blob 업로드
6. 메타데이터 저장
7. 실패 시 정리 작업
8. 다운로드 권한 확인
9. 감사 로그 기록

FastAPI로 표현하면 구조는 다음과 비슷하다.

from fastapi import APIRouter, File, UploadFile, HTTPException

router = APIRouter()

ALLOWED_TYPES = {"image/png", "image/jpeg", "application/pdf"}
MAX_SIZE = 10 * 1024 * 1024

@router.post("/files")
async def upload_file(file: UploadFile = File(...)):
    if file.content_type not in ALLOWED_TYPES:
        raise HTTPException(status_code=400, detail="Unsupported file type")

    content = await file.read()
    if len(content) > MAX_SIZE:
        raise HTTPException(status_code=413, detail="File is too large")

    blob_name = build_blob_name(file.filename)
    await upload_to_blob(blob_name, content, file.content_type)
    return {"blobName": blob_name}

실제 구현에서는 파일 전체를 메모리에 올리지 않도록 streaming을 고려해야 한다. 업로드 크기가 커지면 App Service timeout, reverse proxy 제한, 브라우저 재시도, 네트워크 실패를 함께 다뤄야 한다. 이때는 direct upload with SAS, chunk upload, background processing 같은 패턴을 검토한다.

SAS URL을 사용할 때의 경계

SAS는 제한된 시간 동안 특정 Blob 작업을 허용하는 토큰이다. 클라이언트가 대용량 파일을 직접 Blob Storage에 업로드해야 할 때 유용하다. 다만 SAS를 길게 발급하거나 권한을 넓게 주면 access key를 노출한 것과 비슷한 위험이 생긴다.

good
  - permission: write only
  - scope: single blob path
  - expiry: short-lived
  - generated after user authorization

avoid
  - permission: read/write/delete/list all
  - scope: whole container
  - expiry: several days or months
  - generated without audit trail

흐름은 다음처럼 나눌 수 있다.

이 구조에서는 파일 자체는 Blob으로 직접 가지만, 권한 판단과 메타데이터 저장은 여전히 API가 담당한다. 그래서 “프론트엔드가 Blob에 직접 올린다”는 표현보다 “API가 허용한 범위 안에서 클라이언트가 임시 업로드 권한을 사용한다”는 표현이 더 정확하다.

App Service 배포와 환경 변수

App Service는 웹 애플리케이션을 배포하기 쉬운 관리형 서비스다. Node.js, Python, Java, .NET 등 런타임을 선택할 수 있고 GitHub Actions와도 연결하기 쉽다.

az appservice plan create \
  --name plan-webstorage-prod \
  --resource-group rg-web-storage-prod \
  --sku B1 \
  --is-linux

az webapp create \
  --name app-webstorage-api-prod \
  --resource-group rg-web-storage-prod \
  --plan plan-webstorage-prod \
  --runtime "PYTHON:3.11"

애플리케이션 설정은 환경 변수로 주입한다.

az webapp config appsettings set \
  --name app-webstorage-api-prod \
  --resource-group rg-web-storage-prod \
  --settings \
    STORAGE_ACCOUNT_NAME=stwebstorageprod001 \
    STORAGE_CONTAINER_NAME=uploads \
    APP_ENV=prod

connection string이나 secret을 직접 넣어야 한다면 App Service Configuration 또는 Key Vault reference를 사용한다. .env 파일을 Git에 올리는 방식은 피해야 한다.

local development
  .env.local

cloud runtime
  App Service Configuration
  Key Vault reference
  Managed Identity

배포 후에는 “배포 성공”과 “서비스 정상”을 분리해서 확인한다.

az webapp log tail \
  --name app-webstorage-api-prod \
  --resource-group rg-web-storage-prod

curl -v https://app-webstorage-api-prod.azurewebsites.net/health

빌드와 배포는 성공했지만 health check가 실패할 수 있다. 이 경우 런타임 버전, startup command, 환경 변수, outbound network, Storage 권한을 차례로 확인한다.

API와 프론트엔드 배포는 따로 확인한다

API와 프론트엔드를 각각 App Service로 배포하는 경우에는 두 서비스의 책임을 분리해서 본다. API는 파일 검증, 저장, 메타데이터, 권한 확인을 담당하고, 프론트엔드는 사용자가 업로드 상태를 이해할 수 있게 만든다. 실습에서는 .NET API와 Web App을 따로 만들고 zip 배포로 확인했지만, 같은 흐름은 Python FastAPI, NestJS, Spring Boot에도 그대로 적용된다.

az webapp deployment source config-zip \
  --resource-group rg-web-storage-prod \
  --name app-webstorage-api-prod \
  --src api.zip

az webapp deployment source config-zip \
  --resource-group rg-web-storage-prod \
  --name app-webstorage-web-prod \
  --src web.zip

배포 직후에는 브라우저로 화면만 확인하지 않는다. API, 설정, 저장소, 로그를 각각 따로 확인한다.

# API process and runtime
curl -i https://app-webstorage-api-prod.azurewebsites.net/health

# App settings injected into runtime
az webapp config appsettings list \
  --resource-group rg-web-storage-prod \
  --name app-webstorage-api-prod \
  --query "[].name" \
  --output table

# Blob container access path
az storage blob list \
  --account-name stwebstorageprod001 \
  --container-name uploads \
  --auth-mode login \
  --output table

이미지 업로드 기능을 테스트할 때는 성공 케이스보다 실패 케이스가 더 중요하다. 너무 큰 파일, 허용되지 않은 확장자, 인증이 없는 요청, 같은 파일명의 재업로드, 스토리지 장애, 메타데이터 저장 실패를 각각 분리해서 봐야 한다. 특히 Blob 업로드는 성공했지만 DB 저장이 실패한 경우와, DB 저장은 성공했지만 Blob 업로드가 실패한 경우가 다르다. 이런 불일치를 줄이려면 blob name을 먼저 예약하고, 업로드 완료 후 metadata status를 갱신하거나, 실패한 임시 Blob을 정리하는 보상 작업을 둔다.

upload transaction boundary

1. create upload request
2. validate user and file policy
3. reserve blob path
4. upload blob
5. persist metadata
6. publish audit event
7. mark upload as completed

정적 프론트엔드라면 Azure Static Web Apps를 사용할 수도 있다. 이 경우 화면 배포와 API 배포를 더 명확히 나눌 수 있고, GitHub Actions 기반 배포 흐름도 단순해진다. 다만 API 도메인이 별도로 존재하면 CORS, 인증 토큰 전달, 환경별 API base URL 관리가 중요해진다.

local
  VITE_API_BASE_URL=http://localhost:8000

staging
  VITE_API_BASE_URL=https://app-webstorage-api-stg.azurewebsites.net

production
  VITE_API_BASE_URL=https://api.example.com

배포 파이프라인에서는 프론트엔드가 어느 API를 바라보는지 빌드 시점에 고정되는지, 런타임 설정으로 바꿀 수 있는지 확인해야 한다. 잘못된 API base URL은 코드 문제가 아니라 환경 설정 문제인데, 사용자에게는 동일하게 "업로드가 안 된다"로 보인다.

Frontend와 CORS

프론트엔드와 API가 다른 도메인에 있으면 CORS 설정이 필요하다. 개발 환경에서는 localhost:5173, 운영 환경에서는 실제 프론트엔드 도메인을 허용한다.

az webapp cors add \
  --name app-webstorage-api-prod \
  --resource-group rg-web-storage-prod \
  --allowed-origins https://www.example.com

CORS 오류는 브라우저가 차단하는 보안 정책이라 서버 로그만 보고는 헷갈릴 수 있다. API 자체는 200을 반환했는데 브라우저 콘솔에서는 실패로 보일 수 있다. 이때는 Network 탭에서 preflight OPTIONS 요청, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Access-Control-Allow-Methods를 확인한다.

파일 업로드에서는 header가 늘어나기 쉽다. 인증 토큰, content-type, custom header를 사용한다면 preflight가 발생한다. 백엔드가 OPTIONS 요청을 제대로 처리하지 않으면 실제 POST까지 가지 못한다.

Browser
  -> OPTIONS /files
  <- CORS headers
  -> POST /files
  <- upload result

Storage 보안 설정은 기본값을 그대로 믿지 않는다

Storage Account는 만들기 쉽지만 기본 설정을 그대로 두면 의도보다 넓은 접근 경계가 생길 수 있다. 특히 public access, shared key access, TLS version, network access, diagnostic log는 초기에 확인하는 편이 좋다.

az storage account update \
  --name stwebstorageprod001 \
  --resource-group rg-web-storage-prod \
  --min-tls-version TLS1_2 \
  --allow-blob-public-access false \
  --https-only true

가능하면 public endpoint 전체를 열어두기보다 App Service와 Storage 사이의 접근 경계를 좁힌다. 비용과 복잡도를 감당할 수 있다면 Private Endpoint를 검토하고, 최소한 네트워크 규칙과 RBAC를 함께 본다. 네트워크만 막고 access key를 넓게 쓰거나, RBAC만 붙이고 컨테이너를 public으로 열어두면 한쪽 경계가 허술해진다.

storage hardening checklist

identity
  - Managed Identity is enabled for backend runtime
  - RBAC role is scoped to storage account or container
  - shared key usage is reviewed

network
  - public blob access is disabled
  - private endpoint or firewall rules are considered
  - CORS origin is explicit, not wildcard

data
  - lifecycle policy exists for temporary objects
  - soft delete and versioning are evaluated
  - sensitive metadata is not stored in blob names

operations
  - diagnostic settings send logs to Log Analytics
  - storage transaction cost is monitored
  - upload failure rate is visible

Blob name도 보안 설계의 일부다. 홍길동_주민등록증.png 같은 원본 파일명을 그대로 저장하면 URL, 로그, 메타데이터에 민감한 정보가 남을 수 있다. 사용자에게 보이는 display name과 내부 blob path를 분리하고, 내부 path는 추측하기 어렵고 충돌하지 않게 만든다.

avoid
  uploads/user-123/passport-photo.png

prefer
  uploads/2026/07/05/8f4b1e7c-3d21-4b52-a7f4.bin

이름만 난수로 바꾼다고 보안이 완성되는 것은 아니지만, 로그와 URL에 불필요한 개인정보를 남기지 않는 기본선이 된다.

운영 중 확인할 지점

웹앱과 스토리지를 연결하면 확인할 지점이 많아진다.

1. Frontend
   - API base URL이 올바른가
   - CORS가 허용되는가
   - 업로드 실패 메시지가 사용자에게 보이는가

2. Backend API
   - 파일 크기 제한이 있는가
   - 인증과 권한 확인이 있는가
   - Blob 업로드 실패를 재시도하거나 보상 처리하는가

3. Storage
   - public access가 꺼져 있는가
   - container 권한이 최소화되어 있는가
   - lifecycle policy가 필요한가

4. Security
   - access key를 코드에 넣지 않았는가
   - Managed IdentityKey Vault를 사용하는가
   - SAS 만료 시간이 짧은가

5. Observability
   - App Service 로그를 볼 수 있는가
   - 업로드 실패율을 추적하는가
   - storage capacitytransaction 비용을 보는가

Application Insights를 붙이면 요청 실패율, 응답 시간, dependency call을 볼 수 있다.

az monitor app-insights component create \
  --app appi-webstorage-prod \
  --location koreacentral \
  --resource-group rg-web-storage-prod

운영에서 특히 유용한 로그는 업로드 요청 id, 사용자 id, blob name, file size, content type, storage response code다. 파일 원본명은 개인정보나 민감정보를 포함할 수 있으므로 그대로 로그에 남기지 않는 편이 좋다.

정리

Azure App Service와 Storage Account를 연결하는 일은 단순히 “웹앱을 만들고 파일을 올린다”로 끝나지 않는다. 사용자의 업로드 요청이 프론트엔드, API, 권한 검증, Blob Storage, 메타데이터 저장, 로그와 모니터링을 거쳐야 하나의 기능이 된다.

Blob Storage는 편리하지만 공개 범위, access key, SAS 만료, CORS, lifecycle policy를 잘못 설정하면 운영 리스크가 커진다. App Service도 배포는 쉽지만 환경 변수, Managed Identity, 로그, health check, outbound 연결을 확인해야 한다.

작은 파일 업로드 기능이라도 이 흐름을 따라 정리하면 클라우드 리소스를 화면 기능과 연결해서 볼 수 있다. 리소스를 만드는 방법보다 중요한 것은 요청이 어디서 시작해 어디에 저장되고, 실패했을 때 어느 계층에서 확인할 수 있는지 설명할 수 있는 구조를 만드는 것이다.

이 글의 무단 복제, 재배포, 자동 수집을 허용하지 않습니다. 인용이 필요한 경우 출처와 원문 링크를 함께 남겨주세요.