Ayden's journal

05. AWS S3 이미지 업로드 구현

클라이언트에서 보내온 이미지를 저장하는 데는 백 중 백은 AWS S3를 사용한다. 여러 이유가 있겠지만 저장소의 크기가 이론상 무한하고, 사용한 만큼 요금을 내며, REST API를 통해 자원에 접근할 수 있기 때문에 무척이나 편하다. 이 게시글에서는 이미지를 저장할 AWS S3가 이미 존재한다는 가정 하에 진행된다.

S3에 이미지를 보내는 방법에는 크게 두 가지가 있다. 하나는 multer와 multer-s3 라이브러리를 사용해 Express가 직접 요청을 처리하는 방식이다. 다른 하나는 AWS S3가 제공하는 presigned URL 기능을 사용해 S3가 직접 요청을 처리하는 방식이다. 두 방식 모두 장단점은 있겠으나, 전자의 경우 백엔드 자원이 더 많이 사용되고 후자는 프론트 자원이 더 많이 사용된다는 것 외에는 크게 다르지 않은 것 같다.

 

multer

multer는 multipart/form-data를 처리하기 위한 라이브러리이다. 따라서 이미지 외에도 동영상이나 PDF 파일 등 다양한 종류의 값을 처리할 수 있다. 그러나 여기서는 S3 이미지 업로드에 대한 내용만을 다루려 한다. 사용하려는 라이브러리는 위에서 언급한 multer와 multer-s3 외에도 aws 인가를 획득하기 위한 aws-sdk가 필요하다.

 

multer middleware

multer 미들웨어를 통해 우리는 저장할 s3와 버켓에 대한 정보, AWS 사용자에 대한 정보, 파일 이름에 대한 처리 사항 등을 정의할 수 있다. 만약 multer-s3를 사용하지 않으면 S3 버켓이 아닌 EC2에서 내부 경로 어딘가에 직접 이미지를 보관하도록 할 수도 있다. 하지만 앞서 설명한 S3의 장점들이 있는데 굳이 일을 그렇게 처리할 필요는 없을 것 같다.

multerS3의 key 프로퍼티는 React에서 map 메소드를 사용해 랜더링할 때 key가 필요한 것과 정확히 같은 이유로 사용되는 듯하다. 들어온 각각의 이미지를 구분하기 위해서 말이다. 나는 업로드되는 이미지의 이름이 모두 중복되지 않을 수 있도록 Date 객체를 사용하였다. 

import multer from 'multer';
import multerS3 from 'multer-s3';
import { S3Client } from '@aws-sdk/client-s3';
export const imageRoutes = Router();
const s3 = new S3Client({
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
}
region: 'ap-northeast-2',
})
const upload = multer({
storage: multerS3({
s3,
bucket: process.env.S3_BUCKET_NAME as string,
contentType: multerS3.AUTO_CONTENT_TYPE,
key: (
req: Request,
file: Express.Multer.File,
cb: (error: any, key?: string) => void,
) => {
cb(null, Date.now().toString());
},
}),
});

 

이미지 업로드

칼같이 컨트롤러 서비스 레포지토리를 나누는 편이지만, 이미지 업로드 부분에서는 딱히 그러고 있지 않다. 코드의 양이 적기도 하고 좀 특수 목적을 가지고 있다고 판단해 일단은 하나의 파일 안에서 관리하고 있다.

multer 미들웨어는 array 메소드를 사용해 여러 이미지를 받을 수 있게 하였고, 다만 한 번에 10개까지만 받도록 하였다. 이것은 비즈니스 정책이기는 한데 필요에 따라서는 두 번째 인자를 넘기지 않음으로써 무제한의 이미지를 받을 수 있도록 할 수도 있다.

imageRoutes.post(
'/upload',
upload.array('images', 10),
(req: Request, res: Response) => {
try {
const files = req.files as Express.MulterS3.File[];
const locations = files.map((file) => file.location);
res
.status(200)
.send({ message: 'Files uploaded successfully!', locations });
} catch (error) {
res.status(500).send({ error: 'Failed to upload files.' });
}
},
);

 

Postman으로 테스트해보면 잘 동작하는 것을 확인해볼 수 있다!

 

 

presigned URL

multer를 사용한 이미지 업로드는 클라이언트가 이미지를 백엔드로 전송하고, 이를 백엔드 서버가 S3로 전송하는 방식이다. 따라서 큰 사이즈의 이미지 여럿을 한 번에 처리하거나 해야하는 경우라면 백엔드 자원에 무리가 갈 수 있다. presigned URL(이하 pURL)은 이러한 문제를 해결하기 위해 클라이언트가 직접 S3로 이미지 저장 요청을 보낼 수 있도록 해준다.

pURL은 백엔드 서버에서 S3에 대한 권한을 '미리 서명(presigned)'하여 만든 URL이다. 따라서 이 URL로 요청을 보내면 S3는 요청을 보낸 주체가 누구인지와 상관 없이 미리 서명된 권한을 이용하여 요청을 처리하게 된다. 당연히 무제한으로 언제 어디서나 S3에 이미지를 저장하게 두어서는 안 되기 때문에 pURL에는 시간 제한이 존재한다.

 

이러한 pURL을 생성하는 주체는 여전히 백엔드 서버이다. 클라이언트는 이미지를 백엔드로 보내는 대신 백엔드 서버에게 이미지 업로드를 위한 pURL 생성 요청을 보내게 된다. 

imageRoutes.get('/presigned-url', async (req: Request, res: Response) => {
try {
const fileName = Date.now().toString();
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME as string,
Key: fileName + '.jpg', // 파일 형식에 따라 변경
ContentType: 'image/jpeg', // 파일 형식에 따라 변경
});
const preSignedUrl = await getSignedUrl(s3, command, {
expiresIn: 360,
}); // pURL 만료 시간 설정 (초 단위)
res.send({ preSignedUrl, fileName: fileName + '.jpg'});
} catch (error) {
console.error('Failed to generate presigned URL:', error);
res.status(500).json({ error: 'Failed to generate presigned URL.' });
}
});

pURL를 사용하는 데에는 몇 가지 주의해야할 점이 있는데, 우선 pURL은 한 번에 하나의 자원에 대해서만 요청이 가능하다는 것이다. 따라서 이미지 10개를 업로드하기 위해서는 10개의 pURL가 필요하다. 또한 pURL은 요청을 처리하고 문제 없이 저장하면, 따로 응답 바디에 뭘 담지 않고 status만 200으로 보내준다. 따라서 pURL을 만들어서 보내줄 때, 저장하게 될 파일의 이름을 함께 보내주는 것이 좋다.

블로그의 프로필 사진

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기