대용량 업로드로 서버 메모리가 터진 뒤 바꾼 방식을, 프리사인드 URL 직접 업로드로 옮긴 경험으로 정리한 글입니다.
파일이 전부 서버를 거쳐 가던 구조
처음 만든 첨부 기능은 아주 교과서적이었습니다. 클라이언트가 파일을 multipart/form-data로 API 서버에 보내면, 서버가 그 본문을 받아서 임시로 메모리나 디스크에 쌓아 두고, 다시 스토리지(S3)로 올려 주는 방식이었죠. 그림으로 그리면 파일이 클라이언트 → 서버 → 스토리지 순서로 서버를 한 번 반드시 통과하는 모양입니다. 코드도 단순해서, 프레임워크가 주는 업로드 미들웨어를 붙이고 핸들러에서 버퍼를 스토리지 SDK에 넘기면 끝이었습니다.

문서나 이미지처럼 몇 MB짜리를 다룰 때는 이 구조가 아무 문제가 없었습니다. 그런데 사용자가 첨부하는 파일이 커지기 시작하면서 이야기가 달라졌죠. 서버가 파일 본문을 손에 쥐고 있는 동안에는, 그 파일 크기만큼의 메모리(또는 디스크)를 서버가 대신 짊어져야 합니다. 100MB 파일 하나면 100MB, 그게 열 명이 동시에 올리면 순간적으로 1GB가 서버 프로세스 안에 얹히는 셈이죠.
- 메모리 급증: 파일을 통째로 버퍼에 담는 라이브러리라면 업로드 순간 사용량이 파일 크기만큼 튀어 오릅니다.
- 동시성에 취약: 요청 하나가 무거우니, 동시에 몇 건만 겹쳐도 서버가 휘청입니다.
- 네트워크 왕복 두 번: 클라이언트에서 서버로 한 번, 서버에서 스토리지로 또 한 번 같은 바이트가 흐릅니다. 대역폭도 시간도 두 배죠.
제가 이 구조의 한계를 몸으로 배운 사건이 있었습니다. 어느 날 사용자가 수백 MB짜리 영상을 첨부로 올리기 시작했는데, 그 요청 몇 건이 겹치자 API 프로세스가 OOM(Out Of Memory)으로 그냥 죽어 버렸습니다. 컨테이너가 메모리 한도를 넘겨 커널에 강제 종료당한 것이었죠. 문제는 죽은 프로세스가 그 순간 처리 중이던 다른 정상 요청들까지 같이 끌고 내려갔다는 점이었습니다. 영상 하나 때문에 관계없는 API가 502를 뱉는, 참 억울한 장애였습니다.
처음엔 원인을 몰라 로그만 뒤졌습니다. 그런데 로그에는 뚜렷한 에러 스택 없이 프로세스가 뚝 끊긴 흔적만 남아 있어서 한참을 헤맸죠. 결국 실마리를 준 건 모니터링 대시보드였습니다. 메모리 그래프를 열어 보니, 장애 시각마다 사용량이 톱니처럼 급격히 치솟았다가 한도 선에 닿는 순간 그래프가 바닥으로 뚝 떨어지는 패턴이 또렷하게 반복되고 있었습니다. 그 톱니의 꼭대기가 업로드 요청이 몰린 시각과 정확히 겹친다는 걸 확인하고 나서야, 범인이 파일을 짊어진 서버 자신이라는 걸 인정할 수밖에 없었죠.
프리사인드 URL은 흐름을 어떻게 바꾸는가
원인은 분명했습니다. 서버가 파일 본문을 굳이 손에 쥐고 있는 것, 그 자체가 병목이었죠. 그렇다면 서버는 파일을 만지지 않고, 클라이언트가 스토리지로 곧장 올리게 하면 됩니다. 여기서 등장하는 것이 프리사인드 URL(presigned URL)입니다.
핵심 아이디어는 이렇습니다. 스토리지에 직접 올리려면 원래는 권한 자격증명이 필요한데, 그 자격증명을 클라이언트에게 줄 수는 없죠. 대신 서버가 자기 자격증명으로 "이 경로에, 이 조건으로, 언제까지 업로드해도 좋다"는 서명이 박힌 임시 URL을 발급해 줍니다. 클라이언트는 그 URL 하나만 받아서 스토리지에 PUT으로 파일을 밀어 넣습니다. 서버는 URL을 만들어 주는 짧은 요청만 처리하고, 무거운 파일 본문은 구경도 하지 않죠.

발급 코드 자체는 놀랄 만큼 짧습니다. AWS SDK v3 기준으로, 업로드용 서명 URL을 만드는 부분은 대략 이런 모양이었습니다.
// 업로드용 프리사인드 URL 발급 핸들러
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: "ap-northeast-2" });
async function issueUploadUrl(req, res) {
const key = `uploads/${req.user.id}/${crypto.randomUUID()}`; // 업로드 대상 경로
const command = new PutObjectCommand({
Bucket: "my-media-bucket",
Key: key,
ContentType: req.body.contentType, // 파일 타입 고정
});
// 서명 유효기간 5분
const url = await getSignedUrl(s3, command, { expiresIn: 300 });
res.json({ url, key });
}
클라이언트는 이 url을 받아 fetch(url, { method: "PUT", body: file }) 한 줄로 스토리지에 직접 올리고, 끝나면 서버에는 key만 알려 주면 됩니다. 서버가 오가는 데이터는 URL과 key 같은 몇 백 바이트뿐이라, 파일이 아무리 커져도 API 프로세스의 메모리는 꿈쩍하지 않죠. 저는 이 흐름을 처음 그려 보고 나서, 그동안 서버가 왜 그 무거운 짐을 대신 지고 있었나 싶어 조금 허탈하기까지 했습니다.
부수적인 이득도 있었습니다. 예전 구조는 같은 바이트가 클라이언트에서 서버로, 서버에서 스토리지로 두 번 흐르니 업로드 체감 속도가 느렸는데, 직접 올리는 방식은 왕복이 한 번으로 줄어 큰 파일일수록 완료가 눈에 띄게 빨라졌죠. 서버가 파일 전송이 끝날 때까지 커넥션을 붙잡고 있을 필요도 없어서, 요청 하나가 오래 물고 늘어지던 문제도 자연스럽게 사라졌습니다. 파일을 여러 조각으로 나눠 올리는 multipart 업로드가 필요할 때도, 조각마다 프리사인드 URL을 발급해 주는 식으로 같은 원리를 그대로 확장할 수 있었습니다.
옮기며 주의한 점들
막상 옮겨 보니 "URL만 발급하면 끝"은 아니었습니다. 서버가 파일을 안 만지는 만큼, 통제할 지점을 서명 조건과 사후 검증으로 옮겨 놓아야 했죠. 제가 실제로 챙긴 것들을 정리하면 다음과 같습니다.
- 만료 시간은 짧게. 처음엔 넉넉하게 1시간을 줬다가, 유출되면 그만큼 오래 열려 있다는 걸 깨닫고
expiresIn을 5분으로 줄였습니다. 업로드를 시작하기 직전에 발급받으면 5분으로 충분하더군요. - 크기·타입 제한을 서명에 박기. URL만 발급하면 사용자가 10GB짜리를 올려도 막을 방법이 없죠. 그래서
ContentType을 서명에 고정하고, 브라우저 정책이 필요한 경우엔POST정책의content-length-range로 최대 크기를 서명 조건에 넣어 스토리지가 거부하도록 했습니다. - 업로드 완료 확인·검증. 클라이언트가 "다 올렸다"고 말하는 걸 그대로 믿으면 안 됩니다. 서버가
HeadObject로 실제 객체의 크기·타입을 확인한 뒤에야 첨부를 "유효"로 표시하도록 했죠. 이 검증 단계가 없으면, 발급만 받고 실제로는 안 올린 유령 첨부가 쌓입니다. - 스토리지 CORS 설정. 브라우저에서 스토리지로 직접
PUT을 날리는 순간 CORS가 발목을 잡습니다. 저도 여기서 반나절을 날렸는데, 버킷 CORS에PUT메서드와 우리 도메인 origin,Content-Type헤더를 허용으로 넣고 나서야 프리플라이트가 통과했습니다.

효과는 모니터링 그래프가 그대로 증언해 줬습니다. 전환 전에는 대용량 업로드가 몰릴 때마다 API 컨테이너 메모리가 512MB 한도에 머리를 부딪치며 톱니처럼 치솟다 OOM으로 리셋되곤 했는데, 프리사인드 방식으로 바꾼 뒤에는 같은 트래픽에서도 메모리 그래프가 120MB 언저리에 평탄하게 눕더군요. 업로드 파일 크기와 서버 메모리가 완전히 분리된 셈이죠. 개인적으로 이 전환에서 가장 크게 배운 건, 서버가 꼭 모든 데이터의 길목에 서 있을 필요는 없다는 점이었습니다. 지나가야 할 이유가 없는 바이트라면, 아예 지나가지 않게 흐름을 다시 그리는 편이 낫더군요.