pixelDiff에서 스크린샷 업로드 기능을 구현하면서, 처음에는 서버를 경유하는 방식으로 시작했다. 클라이언트가 이미지를 Base64로 인코딩해서 서버로 보내면, 서버가 S3에 업로드하는 구조였다. 단순하고 CORS 걱정도 없었다.
그런데 파일 크기가 커지면서 문제가 생겼다. 10MB짜리 이미지를 Base64로 인코딩하면 약 13MB가 된다. 이걸 JSON으로 감싸서 서버로 보내고, 서버가 다시 S3로 보내는 동안 서버 메모리와 CPU를 잡아먹었다. 여러 사용자가 동시에 업로드하면 서버 부하가 급격히 증가할 게 뻔했다.
Presigned URL이란
Presigned URL은 "미리 서명된 URL"이다. S3에 업로드할 수 있는 권한이 담긴 임시 URL을 서버가 발급해주면, 클라이언트가 그 URL로 직접 S3에 파일을 업로드한다. 서버는 URL 발급만 하고 실제 파일 전송에는 관여하지 않는다.
흐름은 이렇다:
클라이언트 서버 S3
│ │ │
├── 1. URL 요청 ─────────> │ │
│ (파일명, 크기, 타입) │ │
│ ├── 2. URL 생성 ───────> │
│ │ (PutObjectCommand) │
│<── 3. Presigned URL ────┤ │
│ │ │
├── 4. PUT 요청 ──────────┼──────────────────────>│
│ (파일 직접 전송) │ │
│<── 5. 200 OK ───────────┼───────────────────────┤
선택지 분석
업로드 방식을 선택할 때 고려한 옵션들이다.
| 방식 | 장점 | 한계 | 적합한 상황 |
|---|---|---|---|
| Server-side Upload | CORS 걱정 없음, 구현 단순 | 서버 부하, 메모리 사용, 대용량 불가 | 작은 파일, 서버 리소스 여유 |
| Presigned URL | 서버 부하 없음, 대용량 가능 | CORS 설정 필요, 클라이언트 복잡도 증가 | 대용량 파일, 동시 업로드 많음 |
| Multipart Upload | 초대용량 가능, 재시도 용이 | 구현 복잡도 높음 | GB 단위 파일 |
선택 근거
pixelDiff의 상황:
- 스크린샷 크기 최대 50MB
- 동시 사용자 증가 예상
- 업로드 진행률 표시 필요
Server-side는 서버 부하 때문에 탈락했다. Multipart는 50MB면 과한 구현이었다. Presigned URL이 적합했다.
구현
1. S3 클라이언트 래퍼
프로젝트마다 prefix를 다르게 해서 개발/운영 환경을 분리했다.
// lib/s3.ts
const PROJECT_PREFIX = process.env.DO_SPACES_PROJECT_PREFIX || 'pixelDiff';
export async function generatePresignedPutUrl(
key: string,
contentType: string,
expiresIn = 900
): Promise<string> {
const prefixedKey = `${PROJECT_PREFIX}/${key}`;
const command = new PutObjectCommand({
Bucket: process.env.DO_SPACES_BUCKET,
Key: prefixedKey,
ContentType: contentType,
});
return await getSignedUrl(baseS3Client, command, { expiresIn });
}
2. API 엔드포인트
URL 발급 전에 인증, 소유권, 파일 크기를 검증한다.
// app/api/snapshots/presigned-upload/route.ts
export async function POST(req: NextRequest) {
// 1. 인증 확인
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
throw new AppError(ErrorCode.AUTH_UNAUTHORIZED);
}
// 2. 프로젝트 소유권 확인
const project = await prisma.project.findFirst({
where: { id: projectId, userId: session.user.id },
});
if (!project) {
throw new AppError(ErrorCode.PROJECT_NOT_FOUND);
}
// 3. Presigned URL 생성
const presignedUrl = await generatePresignedPutUrl(
`snapshots/${projectId}/${snapshotId}.png`,
mimeType,
900 // 15분 유효
);
return NextResponse.json({ presignedUrl, id: snapshotId });
}
3. 클라이언트 업로드
fetch 대신 XMLHttpRequest를 썼다. 이유는 업로드 진행률 이벤트 때문이다. fetch는 upload.onprogress를 지원하지 않는다.
// lib/s3/upload.ts
export function uploadToS3(
presignedUrl: string,
blob: Blob,
onProgress?: (percent: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 진행률 이벤트
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) {
const percent = (e.loaded / e.total) * 100;
onProgress(percent);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) resolve();
else reject(new Error(`Upload failed: ${xhr.status}`));
});
xhr.open('PUT', presignedUrl);
xhr.setRequestHeader('Content-Type', blob.type);
xhr.timeout = 120000; // 2분
xhr.send(blob);
});
}
4. CORS 설정
DigitalOcean Spaces 콘솔에서 CORS를 설정해야 한다. 안 하면 클라이언트에서 PUT 요청이 막힌다.
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>https://pixeldiff.app</AllowedOrigin>
<AllowedOrigin>http://localhost:3000</AllowedOrigin>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>GET</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<MaxAgeSeconds>3600</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>
5. 파일 존재 확인
업로드 후 파일이 제대로 올라갔는지 확인할 때 HeadObject를 쓴다.
export async function verifyFileExists(key: string): Promise<boolean> {
try {
const prefixedKey = `${PROJECT_PREFIX}/${key}`;
await baseS3Client.send(
new HeadObjectCommand({
Bucket: process.env.DO_SPACES_BUCKET,
Key: prefixedKey,
})
);
return true;
} catch {
return false;
}
}
왜 fetch가 아니라 XMLHttpRequest인가
2024년에 XMLHttpRequest를 쓴다니 좀 그렇지만, 어쩔 수 없다. fetch API는 ReadableStream으로 다운로드 진행률은 추적할 수 있지만, 업로드 진행률은 지원하지 않는다.
사용자에게 "업로드 중... 43%" 같은 피드백을 주려면 XHR의 upload.onprogress가 필요하다. 진행률 없이 스피너만 돌리면 사용자는 멈춘 건지 진행 중인 건지 알 수 없다.
환경 분리
개발과 운영 환경에서 같은 버킷을 쓰면 데이터가 섞인다. prefix로 분리하면 간단하다.
# 개발 환경
DO_SPACES_PROJECT_PREFIX=pixelDiff-dev
# 운영 환경
DO_SPACES_PROJECT_PREFIX=pixelDiff
결과적으로 S3 경로는 이렇게 된다:
- 개발:
turtle-tail/pixelDiff-dev/snapshots/... - 운영:
turtle-tail/pixelDiff/snapshots/...
Presigned URL 방식의 핵심은 "서버는 권한만 발급하고, 실제 전송은 클라이언트가 직접 한다"는 것이다. 서버 부하 없이 대용량 파일을 처리할 수 있고, XHR을 쓰면 진행률도 표시할 수 있다.
우리 pixelDiff에서는 URL 발급 전에 인증/권한/파일타입을 검증하고, 업로드 완료 후 S3 파일 존재 여부를 확인하는 방식으로 보완했다.
다만 실제 파일이 유효한 이미지인지 검사하는 로직은 없어서, 보안이 중요한 서비스라면 Lambda@Edge나 별도 검증 파이프라인이 필요하다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd' 카테고리의 다른 글
| 풀페이지 스크린샷 타일 스티칭 구현 (0) | 2026.01.20 |
|---|---|
| Pixi.js 줌 레벨별 UI 표시/숨김 구현하기 (0) | 2026.01.17 |
| pixelmatch로 픽셀 비교 알고리즘 구현하기 (0) | 2026.01.15 |
| JavaScript 클로저(Closure) 이해하기 (0) | 2023.05.31 |
