이 글은 누구를 위한 것인가
- 전자책, 소프트웨어, 음원 등 디지털 상품을 판매하려는 팀
- 다운로드 횟수/기간 제한을 구현하려는 개발자
- 직접 링크 공유를 방지하면서 합법 구매자에게 즉시 전달하려는 팀
들어가며
디지털 상품은 재고가 없지만 "누가 얼마나 다운로드했는가"를 추적해야 한다. 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을 발급하면 다운로드 횟수와 기간을 정확히 제어할 수 있다. 라이선스 키 활성화는 소프트웨어에서 기기 수를 제한할 때 유용하다.