디지털 상품 다운로드 시스템: 서명된 URL과 다운로드 횟수 제한

이커머스

디지털 상품다운로드S3 Presigned URL라이선스이커머스

이 글은 누구를 위한 것인가

  • 전자책, 소프트웨어, 음원 등 디지털 상품을 판매하려는 팀
  • 다운로드 횟수/기간 제한을 구현하려는 개발자
  • 직접 링크 공유를 방지하면서 합법 구매자에게 즉시 전달하려는 팀

들어가며

디지털 상품은 재고가 없지만 "누가 얼마나 다운로드했는가"를 추적해야 한다. S3에 파일을 올리고 직접 URL을 주면 링크가 공유된다. Presigned URL에 만료 시간을 설정하고, 다운로드 횟수를 DB에서 관리하는 방식이 표준이다.

이 글은 bluefoxdev.kr의 디지털 상품 다운로드 시스템 가이드 를 참고하여 작성했습니다.


1. 디지털 상품 전달 아키텍처

[파일 저장소]
  S3 버킷: private (퍼블릭 접근 차단)
  경로: /products/{productId}/{version}/{filename}
  
[다운로드 플로우]
  구매 완료
    → 라이선스 생성 (productLicense 테이블)
    → 이메일: 다운로드 링크 (앱 내 URL, S3 직접 아님)
    
  다운로드 요청 (GET /api/downloads/{licenseId})
    → 라이선스 유효성 확인
    → 다운로드 횟수 확인 (maxDownloads)
    → Presigned URL 생성 (유효 5분)
    → 다운로드 이력 기록
    → 302 Redirect → Presigned URL

[제한 정책]
  횟수 제한: 최대 5회
  기간 제한: 구매 후 1년
  IP 제한: 선택적 (동시 다운로드 방지)
  디바이스 제한: 선택적

2. 디지털 상품 다운로드 구현

import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import crypto from 'crypto';

const s3 = new S3Client({ region: process.env.AWS_REGION });

class DigitalProductService {
  // 구매 완료 시 라이선스 생성
  async createLicense(params: {
    orderId: string;
    orderItemId: string;
    productId: string;
    userId: string;
    maxDownloads?: number;
    validDays?: number;
  }) {
    const licenseKey = crypto.randomBytes(16).toString('hex').toUpperCase()
      .match(/.{4}/g)!.join('-'); // XXXX-XXXX-XXXX-XXXX

    return db.productLicense.create({
      data: {
        licenseKey,
        orderId: params.orderId,
        orderItemId: params.orderItemId,
        productId: params.productId,
        userId: params.userId,
        maxDownloads: params.maxDownloads ?? 5,
        expiresAt: new Date(Date.now() + (params.validDays ?? 365) * 86400_000),
        downloadCount: 0,
      },
    });
  }

  // 다운로드 URL 생성
  async generateDownloadUrl(licenseId: string, userId: string, ip: string) {
    const license = await db.productLicense.findUnique({
      where: { id: licenseId },
      include: { product: true },
    });

    if (!license) throw new Error('유효하지 않은 라이선스');
    if (license.userId !== userId) throw new Error('접근 권한 없음');
    if (license.expiresAt < new Date()) throw new Error('라이선스 만료');
    if (license.downloadCount >= license.maxDownloads) {
      throw new Error(`다운로드 횟수 초과 (${license.maxDownloads}회)`);
    }

    // Presigned URL 생성 (5분 유효)
    const command = new GetObjectCommand({
      Bucket: process.env.S3_BUCKET!,
      Key: `products/${license.productId}/${license.product.currentVersion}/${license.product.fileName}`,
      ResponseContentDisposition: `attachment; filename="${license.product.fileName}"`,
    });

    const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 300 });

    // 다운로드 횟수 증가 및 이력 기록
    await db.$transaction([
      db.productLicense.update({
        where: { id: licenseId },
        data: { downloadCount: { increment: 1 }, lastDownloadAt: new Date() },
      }),
      db.downloadHistory.create({
        data: { licenseId, userId, ip, downloadedAt: new Date() },
      }),
    ]);

    return { downloadUrl: presignedUrl, remainingDownloads: license.maxDownloads - license.downloadCount - 1 };
  }

  // 라이선스 키 검증 (소프트웨어 활성화)
  async validateLicenseKey(licenseKey: string, machineId: string) {
    const license = await db.productLicense.findUnique({ where: { licenseKey } });

    if (!license || license.expiresAt < new Date()) {
      return { valid: false, reason: '유효하지 않은 라이선스 키' };
    }

    // 디바이스 등록
    const activations = await db.licenseActivation.count({ where: { licenseId: license.id } });
    const maxActivations = 3; // 최대 3개 디바이스

    if (activations >= maxActivations) {
      const isRegistered = await db.licenseActivation.findFirst({
        where: { licenseId: license.id, machineId },
      });
      if (!isRegistered) return { valid: false, reason: '활성화 디바이스 초과' };
    } else {
      await db.licenseActivation.upsert({
        where: { licenseId_machineId: { licenseId: license.id, machineId } },
        create: { licenseId: license.id, machineId, activatedAt: new Date() },
        update: { lastSeenAt: new Date() },
      });
    }

    return { valid: true, expiresAt: license.expiresAt };
  }
}

const db = {} as any;

마무리

디지털 상품 보호의 핵심은 파일 URL을 직접 노출하지 않는 것이다. S3 버킷을 private으로 유지하고, 앱 서버를 거쳐 Presigned URL을 발급하면 다운로드 횟수와 기간을 정확히 제어할 수 있다. 라이선스 키 활성화는 소프트웨어에서 기기 수를 제한할 때 유용하다.