Presigned URL, Lambda를 활용한 이미지 업로드, 성능 최적화 - DonggunLim/Petple_front GitHub Wiki
커뮤니티 기능이 포함된 프로젝트이다 보니 이미지 업로드 기능을 구현할 일이 많았습니다. 이전 프로젝트에는 Express
와 multer
를 사용해 이미지를 로컬 서버에 저장하는 방식으로 구현해본 경험이 있었는데, 이번에는 서버의 부하를 줄이고 보안을 강화하기 위해 AWS S3와 Presigned URL 방식을 적용해보았습니다.
AWS S3 presignedURL 적용
이 방식은 API 서버에서 일정 시간(60초) 동안만 유효한 업로드 URL을 발급하고, 클라이언트가 해당 URL을 통해 이미지를 S3에 직접 업로드한 뒤, 업로드된 이미지의 경로(URL)를 API 서버에 전달하는 구조입니다.
이를 통해 다음과 같은 장점이 있었습니다
- 서버가 파일 데이터를 직접 처리하지 않아도 되어 성능 부담이 줄어줌
- S3 권한을 외부에 직접 노출하지 않으면서도 안전한 업로드가 가능하게함
Presigned URL의 유효 시간은 60초로 짧게 설정해 보안을 강화했습니다.
presignedURL을 생성하는 벡엔드 API
const getPresignedUrl = async (req, res, next) => {
const { fileName, fileType } = req.query;
if (!fileName || !fileType) {
throw createError(400, '파일이름과 타입이 필요합니다.');
}
const params = {
Bucket: config.aws.s3.bucketName,
Key: `images/${fileName}`,
ContentType: fileType,
};
try {
const command = new PutObjectCommand(params);
const presignedUrl = await getSignedUrl(awsS3, command, { expiresIn: 60 });
return res.status(200).json({ success: true, presignedUrl });
} catch (error) {
next(error);
}
};
프론트엔드 이미지업로드 함수
이미지 업로드 로직은 프로필 페이지 등 제가 담당하지 않은 부분에서도 사용 되는 로직이어서 다른 팀원들도 쉽게 사용할 수 있도록 공통 유틸 함수로 분리해 관리했습니다.
// imageUpload.ts
export const imageUpload = async (file: File) => {
try {
const presignedUrl = await getPresignedUrl({
fileName: `${file.name}-${Date.now()}`,
fileType: file.type,
});
const response = await axios.put(presignedUrl, file, {
headers: { "Content-Type": file.type },
});
if (response.status === 200) {
return presignedUrl.split("?")[0];
}
} catch (error) {
throw error;
}
};
export const multipleImageUpload = async (files: File[]) => {
try {
const promises = files.map((file) => imageUpload(file));
const response = await Promise.all(promises);
return response;
} catch (error) {
throw error;
}
};
추가적으로 고려 해본 요소
이미지 크기 리사이징 with lambda
초기에는 사용자가 업로드한 원본 이미지가 그대로 S3에 저장되도록 구현했지만, 이미지 크기가 큰 경우(3MB 이상) 업로드에 30초 이상이 소요되거나, Timeout으로 실패하는 문제가 발생했습니다.
이 문제를 해결하기 위해, 이미지 크기를 자동으로 리사이징해서 저장하는 구조가 필요하다고 판단했고, AWS Lambda를 이용한 이미지 리사이징 처리를 적용하게 되었습니다.
AWS Lambda를 활용한 이미지 리사이징 처리
이미지가 업로드되면 자동으로 리사이징되도록 하기 위해, Lambda와 S3 트리거를 기반으로 다음과 같은 구성을 적용했습니다.
-
IAM 역할에 S3 접근 권한 부여
Lambda 함수가 업로드된 이미지를 가져오고, 리사이징된 이미지를 다시 저장할 수 있도록 권한을 설정했습니다.
-
S3 트리거 설정 (
images/
prefix)
images/
경로로 파일이 업로드되면 Lambda가 자동 실행되도록 설정하여, 별도의 API 호출 없이도 리사이징이 자동으로 수행되도록 구성했습니다.
-
Lambda 코드 및 의존성 패키징 (zip)
겪었던 시행착오
aws-sdk
모듈 인식 오류
테스트 코드를 돌려 보니 모듈을 인식하지 못하고있었습니다.
Response:
{
"errorType": "Runtime.ImportModuleError",
"errorMessage": "Error: Cannot find module 'aws-sdk'\nRequire stack:\n- /var/task/index.cjs\n- /var/runtime/index.mjs",
"trace": [
"Runtime.ImportModuleError: Error: Cannot find module 'aws-sdk'",
"Require stack:",
"- /var/task/index.cjs",
"- /var/runtime/index.mjs",
" at _loadUserApp (file:///var/runtime/index.mjs:1087:17)",
" at async UserFunction.js.module.exports.load (file:///var/runtime/index.mjs:1119:21)",
" at async start (file:///var/runtime/index.mjs:1282:23)",
" at async file:///var/runtime/index.mjs:1288:1"
]
}
람다 함수에 연결할 layer를 만들때 호환되는 nodejs 런타임 버전과 아키텍처를 연결해 주고 재시도 해보니 해결되었습니다.
후에 sharp module에러로 인해서 layer 계층을 사용히지않고 직접 zip파일을 만들어 람다에 추가해주었습니다.
Lambda Timeout 오류
함수의 제한시간을 소폭 조정 해주었습니다.
{
"errorMessage": "2025-03-10T07:48:11.165Z 6866f540-e8cd-420e-a6b7-2b2a6f77c654 Task timed out after 3.06 seconds"
}
sharp
모듈 설치 실패
가장 시간을 많이 소비한 sharp module 이었습니다. 찾아보니 저랑 비슷한 상황인 사람들이 많았고 여러가지 방법으로 해결 했다는 글을 찾아 볼 수 있었습니다.
"Something went wrong installing the \"sharp\" module"
그 중에 저한테 효과가 있었던 방식은 sharp 버전을 다운그레이드 해주고 위 방식으로 해주니 해결이 되었습니다.
ZIP 업로드 중 S3 네트워크 오류
Lambda 함수 코드와 dependency들은 현재 ZIP 파일로 압축한 뒤 S3 버킷에 저장하고, 이를 다시 Lambda에 등록해서 사용 중입니다. 이 과정에서 여러 번의 시행착오로 인해 S3 버킷에 ZIP 파일을 자주 업로드하게 되었는데, 때때로 원인을 알 수 없는 네트워크 오류가 발생했습니다. 처음에는 브라우저 문제라는 이야기를 듣고 브라우저를 바꾸는 등 다양한 시도를 해보았지만, 가장 효과적이었던 방법은 브라우저의 캐시를 주기적으로 삭제해주는 것이었습니다.
Unable to upload file to bucket due to network error
리사이징 이미지가 회전되는 현상 (EXIF 문제)
이미지 리사이징 과정에서 리사이징된 이미지가 갑자기 회전되는 현상이 있었습니다. 원인을 찾아보니 이미지의 EXIF 메타데이터 안에 있는 orientation이라는 정보 때문이었습니다. 이 orientation 메타데이터가 이미지의 회전 방향을 결정하는데, sharp 모듈에서 기본적으로 메타데이터를 유지하지 않기 때문에 이 현상이 발생한 것입니다.
이 문제를 해결하기 위해 sharp에서 EXIF 메타데이터를 유지하도록 .withMetadata() 메서드를 추가해주었습니다.
const resizedImage = await Sharp(originalImage.Body)
.resize({ width: 300 })
.toFormat("jpeg")
.withMetadata()
.toBuffer();
결과 비교 (이미지 리사이징 전/후)
리사이즈 전
리사이즈 후
이번 이미지 업로드 기능 구현을 통해 단순히 파일을 저장하는 것을 넘어서, 이미지를 어떻게 최적화하고 안정적으로 처리할 수 있을지 구조적으로 고민해보는 경험을 할 수 있었습니다. 이를 통해 최적화 관점에서 이미지를 바라보는 시야를 넓힐 수 있었습니다.