본문으로 건너뛰기
  1. 게시물/

Django와 Tigris로 자체 호스팅 Expo OTA 업데이트 서버 구축하기

· loading · loading ·
인재덕
작성자
인재덕
A Kiwi living in Korea
목차

OTA(Over-the-Air) 업데이트를 사용하면 App Store 심사를 기다리지 않고도 JavaScript 변경, 버그 수정, 새로운 기능을 사용자에게 배포할 수 있습니다. Expo의 호스팅 서비스(EAS Update)는 잘 작동하지만, 자체 호스팅에는 그만한 이유가 있습니다: 대규모 비용 관리, 데이터 주권 요구사항, 또는 단순히 인프라를 직접 소유하고 싶은 경우입니다.

이 글에서는 Django REST Framework와 Tigris S3를 사용하여 자체 호스팅 Expo 업데이트 서버를 구축한 방법을 다룹니다. 제 Curtain Estimator 앱에서 iOS와 Android 모두에 OTA 업데이트를 제공하는 아키텍처입니다.

왜 Expo 업데이트를 자체 호스팅해야 할까요?
#

비용 관리
#

EAS Update 가격은 사용량에 따라 증가합니다. 대규모 사용자 기반에 자주 업데이트를 배포하는 경우, Tigris와 같은 저렴한 S3 호환 스토리지로 자체 호스팅하면 실제로 비용을 절감할 수 있습니다.

데이터 주권
#

일부 산업에서는 애플리케이션 자산이 저장되는 위치에 대한 완전한 제어가 필요합니다. 자체 호스팅은 모든 업데이트 번들이 자체 인프라에 유지되도록 보장합니다.

커스텀 비즈니스 로직
#

특정 사용자 세그먼트에 업데이트를 배포해야 하나요? 다른 번들로 A/B 테스트를 하고 싶으신가요? 커스텀 서버를 사용하면 전체 업데이트 흐름을 제어할 수 있습니다.

벤더 종속 없음
#

업데이트 인프라가 Expo의 서비스 가용성이나 가격 변경에 종속되지 않습니다.

아키텍처 개요
#

시스템은 네 가지 주요 구성 요소로 이루어져 있습니다:

  1. Django 백엔드: 업데이트 매니페스트를 제공하고 메타데이터를 저장
  2. Tigris S3 스토리지: 실제 번들 및 에셋 파일을 호스팅
  3. 퍼블리싱 파이프라인: 내보내기, 업로드, 업데이트 등록을 수행하는 스크립트
  4. 모바일 앱: 서버에서 업데이트를 확인하도록 구성

작동 방식은 다음과 같습니다:

┌─────────────────┐
│   Mobile App    │
│  (expo-updates) │
└────────┬────────┘
         │ 1. Request manifest
         │    (with headers: platform, runtime-version)
┌─────────────────┐
│  Django Server  │
│  /api/expo-     │◄─── 2. Query DB for latest update
│   updates/      │
│   manifest/     │
└────────┬────────┘
         │ 3. Generate presigned URLs
┌─────────────────┐
│   Tigris S3     │
│  (Asset Files)  │◄─── 4. App downloads bundles directly
└─────────────────┘

구현 세부사항
#

Django 모델
#

기반이 되는 것은 두 개의 Django 모델입니다: ExpoUpdateExpoUpdateAsset.

ExpoUpdate는 각 업데이트의 메타데이터를 저장합니다:

class ExpoUpdate(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    runtime_version = models.CharField(max_length=50, db_index=True)
    platform = models.CharField(
        max_length=10,
        choices=[("ios", "iOS"), ("android", "Android")],
        db_index=True
    )
    is_active = models.BooleanField(default=True, db_index=True)
    manifest_data = models.JSONField()
    description = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
        ]

주요 설계 결정사항:

  • runtime_version: app.jsonruntimeVersion과 일치합니다. 이것은 매우 중요합니다—클라이언트는 자신의 런타임 버전과 일치하는 업데이트만 다운로드합니다.
  • platform: iOS와 Android는 번들이 다르므로 별도의 업데이트를 사용합니다.
  • is_active: 문제가 있는 업데이트를 비활성화하여 롤백을 지원합니다.
  • manifest_data: 완전한 Expo Updates v1 프로토콜 매니페스트를 JSON으로 저장합니다.

ExpoUpdateAsset은 개별 파일을 추적합니다:

class ExpoUpdateAsset(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE, related_name="assets")
    hash = models.CharField(max_length=255, db_index=True)
    key = models.CharField(max_length=255)
    content_type = models.CharField(max_length=100)
    file_extension = models.CharField(max_length=10)
    file_path = models.CharField(max_length=500)
    file_size = models.IntegerField(default=0)

에셋은 SHA-256 해시로 참조되어 불변이며 캐시 가능합니다. 동일한 에셋을 여러 업데이트에서 공유할 수 있습니다.

매니페스트 엔드포인트
#

/api/expo-updates/manifest/ 엔드포인트가 시스템의 핵심입니다. Expo Updates v1 프로토콜을 구현합니다.

@action(detail=False, methods=["get"], url_path="manifest")
def manifest(self, request):
    # Extract required headers
    protocol_version = request.META.get("HTTP_EXPO_PROTOCOL_VERSION")
    platform = request.META.get("HTTP_EXPO_PLATFORM")
    runtime_version = request.META.get("HTTP_EXPO_RUNTIME_VERSION")

    # Validate protocol version
    if protocol_version != "1":
        return Response(
            {"error": f"Unsupported protocol version: {protocol_version}"},
            status=400
        )

    # Find latest active update for this runtime + platform
    update = ExpoUpdate.objects.filter(
        runtime_version=runtime_version,
        platform=platform,
        is_active=True,
    ).order_by("-created_at").first()

    # No update available - client uses embedded bundle
    if not update:
        response = Response(status=204)
        response["expo-protocol-version"] = "1"
        return response

    # Generate presigned URLs for all assets
    manifest_data = self._generate_manifest_with_presigned_urls(update)

    return Response(manifest_data, status=200)

핵심 세부사항:

  1. 204 No Content: 업데이트가 없으면 204를 반환합니다. 앱은 내장 번들을 사용합니다.
  2. Presigned URL: 앱이 Tigris CDN에서 직접 다운로드할 수 있는 시간 제한 URL을 생성합니다—Django를 통해 프록시하는 것보다 훨씬 빠릅니다.
  3. 헤더: 응답에 expo-protocol-version 헤더가 필수입니다.

업데이트 퍼블리싱
#

퍼블리싱 워크플로우는 Django 관리 명령과 셸 스크립트 래퍼를 통해 자동화됩니다.

관리 명령 (publish_expo_update.py)은 다음을 처리합니다:

  1. expo export 출력 읽기
  2. 모든 에셋의 SHA-256 해시 계산
  3. 번들과 에셋을 Tigris S3에 병렬 업로드
  4. 업데이트 및 에셋에 대한 데이터베이스 레코드 생성
  5. 선택적으로 프로덕션 동기화를 위한 임포트 JSON 업로드

핵심 흐름은 다음과 같습니다:

def _publish_platform(self, platform, runtime_version, export_dir, ...):
    # 1. Find the bundle file
    bundle_files = list(bundle_dir.glob("entry-*.hbc"))
    bundle_file = bundle_files[0]

    # 2. Calculate hash
    with open(bundle_file, "rb") as f:
        bundle_content = f.read()
    bundle_hash = self._calculate_hash(bundle_content)

    # 3. Collect all assets and their hashes
    for asset_file in assets_dir.rglob("*"):
        # Calculate hash, determine content type...
        assets_metadata.append({...})

    # 4. Upload to S3 in parallel
    with ThreadPoolExecutor(max_workers=10) as executor:
        futures = {executor.submit(upload_asset, a): a for a in assets_metadata}

    # 5. Create database records
    with transaction.atomic():
        # Deactivate previous updates
        ExpoUpdate.objects.filter(
            runtime_version=runtime_version,
            platform=platform,
            is_active=True
        ).update(is_active=False)

        # Create new update
        update = ExpoUpdate.objects.create(...)

셸 스크립트 (publish-ota-update.sh)는 사용자 친화적인 인터페이스를 제공합니다:

# Publish to local environment
./scripts/publish-ota-update.sh ios

# Publish to production
./scripts/publish-ota-update.sh ios --production

# Dry run to validate
./scripts/publish-ota-update.sh --dry-run

주요 기능:

  • 프로덕션 환경 변수로 앱을 자동 내보내기
  • 속도를 위해 에셋을 병렬 업로드
  • 플랫폼별 또는 멀티 플랫폼 업데이트 지원
  • API 엔드포인트를 통한 프로덕션 동기화

API를 통한 프로덕션 동기화
#

프로덕션 배포의 경우 시스템은 영리한 2단계 프로세스를 사용합니다:

  1. 로컬 퍼블리시: Tigris에 에셋을 업로드하고 JSON 스냅샷을 생성
  2. 원격 임포트: S3 경로와 함께 프로덕션 API를 호출하여 메타데이터를 임포트

이 접근 방식은 로컬 머신에 프로덕션 자격 증명을 두지 않아도 됩니다:

@action(detail=False, methods=["post"], url_path="import-update")
def import_update(self, request):
    # Authenticate via Bearer token
    secret = settings.OTA_IMPORT_SECRET
    token = request.META.get("HTTP_AUTHORIZATION", "")[7:]  # Strip "Bearer "
    if not hmac.compare_digest(token, secret):
        return Response({"error": "Invalid token"}, status=401)

    # Download import JSON from Tigris
    s3_key = request.data.get("s3_key")
    obj = s3_client.get_object(Bucket=bucket_name, Key=s3_key)
    data = json.loads(obj["Body"].read())

    # Import to production database
    with transaction.atomic():
        ExpoUpdate.objects.update_or_create(id=data["id"], defaults={...})
        for asset_data in data["assets"]:
            ExpoUpdateAsset.objects.update_or_create(...)

    # Clean up the import JSON
    s3_client.delete_object(Bucket=bucket_name, Key=s3_key)

모바일 앱 구성
#

app.json에서 업데이트 URL과 런타임 버전을 구성합니다:

{
  "expo": {
    "runtimeVersion": "1.0.0",
    "updates": {
      "url": "https://your-server.com/api/expo-updates/manifest/"
    }
  }
}

중요: runtimeVersion은 앱과 서버 간에 일치해야 합니다. 네이티브 코드를 변경하거나 Expo SDK를 업그레이드할 때 런타임 버전을 증가시키고 새 업데이트를 퍼블리시하세요.

Tigris를 사용한 스토리지
#

Tigris는 AWS S3보다 훨씬 저렴한 S3 호환 객체 스토리지 서비스로, 글로벌 엣지 캐싱이 포함되어 있습니다.

Django에서의 구성:

# settings.py
BUCKET_NAME = os.getenv("BUCKET_NAME")
AWS_ENDPOINT_URL_S3 = os.getenv("AWS_ENDPOINT_URL_S3")
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
AWS_REGION = os.getenv("AWS_REGION", "auto")

S3 클라이언트 생성:

import boto3

def create_s3_client(endpoint_url, region, access_key, secret_key):
    return boto3.client(
        "s3",
        endpoint_url=endpoint_url,
        region_name=region,
        aws_access_key_id=access_key,
        aws_secret_access_key=secret_key,
    )

파일 업로드는 CDN 직접 전달을 위해 presigned URL을 사용합니다:

presigned_url = s3_client.generate_presigned_url(
    "get_object",
    Params={"Bucket": bucket_name, "Key": asset.file_path},
    ExpiresIn=3600,  # 1 hour
)

보안 고려사항
#

인증
#

매니페스트 엔드포인트는 설계상 인증 없이 접근 가능합니다—모바일 앱은 사용자가 로그인하기 전에 업데이트가 필요합니다. 그러나 임포트 엔드포인트는 공유 비밀키를 필요로 합니다:

OTA_IMPORT_SECRET = os.getenv("OTA_IMPORT_SECRET")

# Constant-time comparison prevents timing attacks
if not hmac.compare_digest(token, secret):
    return Response({"error": "Invalid token"}, status=401)

에셋 무결성
#

모든 에셋은 SHA-256 해시로 검증됩니다. 에셋이 변조되면 해시가 일치하지 않아 업데이트가 실패합니다.

Presigned URL
#

URL은 1시간 후 만료되어 번들에 대한 장기 무단 접근을 방지합니다.

성능 최적화
#

데이터베이스 인덱스
#

(runtime_version, platform, is_active, -created_at) 복합 인덱스는 수천 개의 업데이트가 있어도 매니페스트 쿼리가 빠르도록 보장합니다:

class Meta:
    indexes = [
        models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
    ]

병렬 업로드
#

퍼블리싱 스크립트는 ThreadPoolExecutor를 사용하여 에셋을 동시에 업로드합니다:

with ThreadPoolExecutor(max_workers=10) as executor:
    futures = {executor.submit(upload_asset, asset): asset for asset in assets}
    for future in as_completed(futures):
        # Track progress

50개의 에셋이 있는 일반적인 업데이트의 경우, 퍼블리시 시간이 2분에서 15초로 단축됩니다.

CDN 배포
#

Tigris에는 글로벌 엣지 캐싱이 포함되어 있습니다. 에셋은 사용자 근처의 엣지 로케이션으로 자동 배포되어 다운로드 시간을 줄입니다.

운영 워크플로우
#

일일 개발
#

# 1. Make code changes in mobile app
cd mobile-app && git commit -am "Fix bug"

# 2. Publish OTA update to local environment
yarn publish-update:ios

# 3. Test on device
# App automatically downloads and applies update

프로덕션 배포
#

# 1. Publish to production
yarn publish-update:prod:ios

# 2. Monitor
# Check Django admin for update records
# Verify assets in Tigris dashboard

롤백
#

# Mark problematic update as inactive in Django admin
# or via management shell:
python manage.py shell

>>> from jobs.models import ExpoUpdate
>>> bad_update = ExpoUpdate.objects.get(id="uuid-here")
>>> bad_update.is_active = False
>>> bad_update.save()

# Clients will now receive the previous active update

비용 분석
#

약 500명의 활성 사용자가 있는 제 Curtain Estimator 앱의 경우:

Tigris 스토리지:

  • 스토리지: ~200 MB (이전 업데이트들) = $0.02/월
  • 이그레스: ~50 GB/월 (업데이트 다운로드) = $1.00/월
  • 합계: ~$1/월

Django 호스팅 (Fly.io):

  • 기존 앱 호스팅에 포함
  • 추가 비용: $0

전체 OTA 인프라 비용: ~$1/월

유사한 사용량의 EAS Update 가격과 비교하면 연간 $300-500입니다. 자체 호스팅 접근 방식은 즉시 비용을 절감합니다.

모니터링 및 디버깅
#

로깅
#

ViewSet은 모든 매니페스트 요청을 로깅합니다:

logger.info(f"Manifest request: platform={platform}, runtime={runtime_version}")

Django Admin
#

쉬운 검사를 위해 Django admin에 모델을 등록합니다:

@admin.register(ExpoUpdate)
class ExpoUpdateAdmin(admin.ModelAdmin):
    list_display = ["platform", "runtime_version", "is_active", "created_at"]
    list_filter = ["platform", "is_active", "runtime_version"]
    search_fields = ["description"]

클라이언트 측 디버깅
#

앱에서 업데이트 로그를 활성화합니다:

import * as Updates from 'expo-updates';

Updates.checkForUpdateAsync().then(update => {
  console.log('Update available:', update.isAvailable);
  console.log('Manifest:', update.manifest);
});

제한사항 및 주의사항
#

런타임 버전 매칭
#

가장 흔한 문제: 클라이언트는 자신의 런타임 버전과 일치하는 업데이트만 다운로드합니다. 앱이 런타임 1.0.0에 있는데 1.0.1용으로 퍼블리시하면 업데이트가 전달되지 않습니다.

해결책: 런타임 버전을 앱 빌드와 동기화하고, 네이티브 코드가 변경될 때만 증가시키세요.

createdAt 타임스탬프
#

expo-updates 클라이언트는 매니페스트의 createdAt 타임스탬프를 내장 번들의 commitTime과 비교합니다. 업데이트는 createdAt이 더 새로운 경우에만 적용됩니다.

수정: ViewSet에서 createdAt을 데이터베이스 타임스탬프로 오버라이드합니다:

manifest_data["createdAt"] = update.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")

로컬 개발에서 OTA를 퍼블리시한 후에 바이너리를 빌드하면, 더 새로운 타임스탬프를 보장하기 위해 OTA를 다시 퍼블리시하세요.

에셋 정리
#

오래된 업데이트가 Tigris에 누적됩니다. 현재 정리는 수동입니다:

# Delete updates older than 30 days
from datetime import timedelta
from django.utils import timezone

cutoff = timezone.now() - timedelta(days=30)
old_updates = ExpoUpdate.objects.filter(created_at__lt=cutoff, is_active=False)

for update in old_updates:
    # Delete assets from S3
    for asset in update.assets.all():
        s3_client.delete_object(Bucket=bucket_name, Key=asset.file_path)
    # Delete DB records
    update.delete()

예약된 작업으로 추가하는 것을 고려하세요.

향후 개선사항
#

롤아웃 제어
#

업데이트를 점진적으로 릴리스하기 위해 rollout_percentage 필드를 추가합니다:

rollout_percentage = models.IntegerField(default=100)

# In the manifest view:
if update.rollout_percentage < 100:
    # Hash user ID and check if they're in rollout group
    user_hash = int(hashlib.sha256(user_id.encode()).hexdigest(), 16)
    if (user_hash % 100) >= update.rollout_percentage:
        return Response(status=204)  # No update

멀티 환경 지원
#

스테이징과 프로덕션에 다른 업데이트를 제공하기 위해 environment 필드를 추가합니다:

environment = models.CharField(max_length=20, default="production")

# Client sends environment in custom header
environment = request.META.get("HTTP_X_UPDATE_ENVIRONMENT", "production")
update = ExpoUpdate.objects.filter(environment=environment, ...).first()

분석
#

다운로드 메트릭을 추적합니다:

class ExpoUpdateDownload(models.Model):
    update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE)
    user_id = models.CharField(max_length=255, null=True)
    platform = models.CharField(max_length=10)
    downloaded_at = models.DateTimeField(auto_now_add=True)

결론
#

Django와 S3 호환 스토리지로 자체 호스팅 Expo OTA 업데이트는 생각보다 간단합니다. 전체적으로 필요한 것:

  • ~150줄의 Django 모델 및 뷰
  • ~200줄의 퍼블리싱 스크립트
  • ~$1/월의 인프라 비용

여기의 코드 예제는 제 프로덕션 Curtain Estimator 앱에서 가져온 것입니다. 구축하시는 것에 맞게 활용하세요.


전체 사양은 Expo Updates Protocol Specification을 확인하세요.