...
이미지 리사이징 작업 같은 경우 CPU와 메모리를 많이 사용하기 때문에 로컬 서버에서 작업을 돌려버리면 다른 사용자의 요청을 못받는 현상이 생길수 있다.
특히나 싱글 스레드 기반으로 돌아가는 노드 서버일 겨우 성능 감소의 큰 원인이 된다. 노드는 비동기 프로그래밍 기반으로 I/O작업에 유리하고 CPU 작업에 불리하기 때문이다.
그렇다고 따로 이미지 리사이징 전용 로컬 서버를 만들어 올린다 해도, 모든 사용자가 항상 이미지 리사이징 작업 기능을 이용하는 것도 아니기에 서버를 계속 돌린다는 것은 자원 낭비일수 있다.
바로 이러한 상황에서 서버리스의 서비스는 힘을 발휘한다.
람다는 특정한 동작을 수행하는 로직을 저장하고 요청이 들어올 때 로직을 실행하는 서비스다. 마치 함수처럼 호출할 때 실행하여 FaaS라고 불린다.
이처럼 람다를 이용해 독립적인 서버에서 함수를 실행 시켜, 로컬 서버의 부담을 줄일수 있을 뿐만 아니라 온디멘드(on-demand) 형식이라 쓴만큼 요금을 지불하면 되서 항상 컴퓨팅을 실행시키는 자원 낭비를 줄일수 있다.
이 포스팅에선 Node.js 기반 이미지 리사이징 패키지인 shap 모듈을 이용하여 구현해볼 예정이다.
아래 포스팅에서 shap를 이용하여 이미지 리사이징 API 로컬 서비스를 구현하 바 있다.
이 api 서비스를 람다에 이식시키고, S3와 API Gateway 트리거가 발생할때 람다를 실행시키는 실질적이고 실용적인 서비스를 구현해보자.
주의할 점은 shap 모듈을 설치할때 리눅스 바이너리 버전으로 설치해야 된다는 점이다. (람다는 Amazon Linux 기반으로 돌아감)
> npm install --platform=linux sharp
Lambda 이미지 리사이징 트리거 (S3)
람다 S3 이미지 리사이징 아키텍쳐 실행 순서를 요약하자면 다음과 같다.
- 사용자가 웹페이지에서 이미지를 업로드
- 로컬 서버에서 이미지 요청을 받고 S3 버킷 original/ 경로에 업로드
- S3에 오브젝트 업로드 이벤트가 발생
- S3 트리거가 발생되어 람다 함수 실행
- 람다에서 버킷 original/ 에 있는 이미지를 가져오고 이미지 리사이징 작업한뒤 다시 버킷 thumb/ 경로에 리사이징된 이미지 업로드
- 로컬 서버는 원본과 리사이징 이미지 url을 클라이언트에 응답
이제 실질적으로 위 동작이 되도록 구현해보자.
1. S3 생성 및 버킷 정책 등록
일단 가장 먼저 저장할 S3 버킷을 만들고, 버킷 정책을 다음과 같이 객체를 PUT, GET, DELETE 동작을 허용하도록 하자.
{
"Version": "2012-10-17",
"Id": "Policy1657542910526",
"Statement": [
{
"Sid": "Stmt1657542907707",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::<버킷명>/*"
}
]
}
2. 로컬 서버에서 요청 이미지를 S3로 보내는 로직 구현
Node.js 서버에서 multer-s3 패키지를 이용하여, 폼 전송된 이미지 파일을 S3로 자동으로 업로드 하게 한다.
보다 자세한 구현 설명이 필요하면 아래 링크를 참고하길 바란다.
const express = require('express');
const multer = require('multer');
const path = require('path');
const AWS = require("aws-sdk");
const multerS3 = require('multer-s3');
const dotenv = require('dotenv');
dotenv.config();
const router = express.Router();
//* aws region 및 자격증명 설정
AWS.config.update({
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
region: 'ap-northeast-2',
});
//* AWS S3 multer 설정
const upload = multer({
//* 저장공간
// s3에 저장
storage: multerS3({
// 저장 위치
s3: new AWS.S3(),
bucket: 'test-bucket-inpa',
acl: "public-read",
contentType: multerS3.AUTO_CONTENT_TYPE,
key(req, file, cb) {
cb(null, `${Date.now()}_${path.basename(file.originalname)}`) // original 폴더안에다 파일을 저장
},
}),
//* 용량 제한
limits: { fileSize: 5 * 1024 * 1024 },
});
//* 싱글 이미지 파일 업로드 -> uploads/ 디렉토리에 이미지를 올린다.
router.post('/img', isLoggedIn, upload.single('img'), (req, res) => {
console.log(req.file);
const originalUrl = req.file.location; //? s3 용 : multer s3에서 버킷 객체 경로를 반환함
const url = originalUrl.replace(/\/original\//, '/thumb/');
// 람다에서 리사이징 처리하고 새로 버킷에 압축 이미지를 저장하니, 압축된 이미지 버킷경로로 이미지url을 변경하여 클라이언트에 제공
// 다만, 리사이징은 시간이 오래 걸리기 때문에 이미지가 일정 기간 동안 표시되지 않을 수 있으므로, 리사이징된 이미지를 로딩하는데 실패시 원본 이미지를 띄우기 위해 originalUrl도 같이 전송
res.json({ url, originalUrl });
});
module.exports = router;
추가적으로 프론트쪽도 에러 처리 작업을 해준다.
현재 로직에선 원본 이미지는 original/ 버킷 경로에 저장하고, 리사이징 된 이미지를 thumb/ 경로에 저장하게 되고, 클라이언트에서 리사이징된 이미지 url을 받아와 화면에 띄워주게 구성되어 있는데, 만일에 이미지 리사이징 작업이 어떠한 에러가 생겨 취소가 되었다면 thumb/ 이미지 url이 존재하지 않으니, onerror 이벤트가 발생되면 original/ 이미지 원본 url을 가져오는 작업이다. (이는 꼭 리사이징 뿐만아니라 다른 노드 프로젝트 기법에서 자주 쓰이는 로직이니 꼭 숙지해주자)
<img src="{{ twit.img }}" alt="섬네일" onerror="this.src = this.src.replace(/\/thumb\//, '/original/');" />
3. 람다 함수 구현
이미지 리사이징 하는 작업 코드를 람다 함수 양식에 맞춰 구현한다.
이때 로컬 서버에서 파일을 추가하여 구현하는게 아닌 독립된 프로젝트로 새로 생성해서 index.js 파일에 구현을 해야한다.
왜냐하면 프로젝트 자체를 zip으로 압축한뒤 그대로 람다에 업로드할 것이기 때문이다.
깃헙에도 해당 프로젝트 를 올려두었다. (링크)
> npm install aws-sdk
> npm install --platform=linux sharp
const AWS = require('aws-sdk');
const sharp = require('sharp');
//? 람다에서 돌아가기 때문에, 따로 람다 iam 설정해주지 않는 한 기본으로 CLI 인증없이 바로 돌아감
const s3 = new AWS.S3();
exports.handler = async (event, context, callback) => {
const Bucket = event.Records[0].s3.bucket.name; // 버켓명
const Key = event.Records[0].s3.object.key; // 업로드된 키명
const s3obj = { Bucket, Key };
const filename = Key.split('/')[Key.split('/').length - 1]; // 경로 없애고 뒤의 파일명만
const ext = Key.split('.')[Key.split('.').length - 1].toLowerCase(); // 파일 확장자만
const requiredFormat = ext === 'jpg' ? 'jpeg' : ext; // sharp에서는 jpg 대신 jpeg 사용
console.log('name', filename, 'ext', ext);
try {
//* 객체 불러오기
const s3Object = await s3.getObject(s3obj).promise(); // 버퍼로 가져오기
console.log('original size', s3Object.Body.length);
//* 리사이징
const resizedImage = await sharp(s3Object.Body)
.resize(400, 400, { fit: 'inside' }) // 400x400 꽉 차게
.toFormat(requiredFormat)
.toBuffer();
//* 객체 넣기
await s3
.putObject({
Bucket,
Key: `thumb/${filename}`, // 리사이징 된 이미지를 thumb 폴더에 새로저장
Body: resizedImage,
})
.promise();
console.log('put', resizedImage.length);
// Lambda 함수 내부에서 모든 작업을 수행한 후에는 그에 대한 결과(또는 오류)와 함께 callback 함수를 호출하고 이를 AWS가 HTTP 요청에 대한 응답으로 처리한다.
return callback(null, `thumb/${filename}`);
} catch (error) {
console.error(error);
return callback(error);
}
};
4. 소스를 람다에 업로드
위의 프로젝트를 zip으로 압축하고 람다에 업로드한다.
업로드 방법은 로컬에서 바로 업로드 하는 방법과 S3에 있는 파일을 업로드하는 방법이 있는데 저장소가 로컬이냐 클라우드냐 차이가 있을뿐이라서 손이 가는대로 하면 된다.
5. 람다 스펙 & 권한 설정
이미지 리사이징은 CPU를 많이 먹는 작업이기에 람다의 기본 스펙을 높여준다.
S3에 버킷 정책을 등록했어도, 기본적으로 S3 작업에 엑세스 할수 있게 람다 자체에서도 IAM 권한을 등록해주어야 한다.
6. 람다 ↔ S3 트리거 구현
이렇게 람다 트리거를 이용한 S3 이미지 리사이징 프로젝트를 완성해보았다.
직접 서버를 띄워보고 이미지를 업로드해보자.
Lambda 이미지 리사이징 트리거 (API Gateway)
이번에는 S3 서비스에 한정된 이미지 리사이징 서비스가 아닌, 외부 유저도 브라우저에서 url을 입력하면 리사이징된 이미지를 제공해주는 실질적인 서비스를 API Gateway를 이용해 구현해보자.
다음 url을 브라우저에 요청한다면, 리사이징된 이미지를 화면에 띄우는 서비스를 만들어 볼 것이다.
- http://localhost:8081/thumb/500x700?fname=[이미지url]
- 브라우저에서 api gateway url로 이미지 경로를 보낸다
- api gateway는 요청 데이터를 람다에게 보낸다
- 람다에서 event 인자를 요청 데이터를 받고 이미지 리사이징을 하고 바이너리 데이터를 api gateway에 응답한다
- api gateway는 이진 데이터를 미디어 처리하고, 브라우저에게 리사이징된 이미지를 응답한다
1. 람다 ↔ API Gateway 트리거 연결
새 람다 함수를 만들고 트리거 추가 버튼을 누른다.
2. API Gateway 라우팅 설정하기
3. API Gateway 요청 파라미터 설정하기
백엔드(람다) 로 보낼 요청 쿼리 파라미터를 API 게이트웨이에서 직접 지정할 수 있다.
4. API 이진 미디어 형식 설정하기
우리가 다룰 데이터는 이미지 같은 미디어 데잍이기 때문에 이진 데이터를 처리할수 있게 API 게이트웨이를 설정해주어야 한다.
5. API 배포하기
5. 람다 프로젝트 생성 & 업로드
> npm i --platform=linux sharp
> npm i axios
const sharp = require('sharp');
const axios = require('axios');
//? http://localhost:8081/thumb/500x700?fname=이미지url
exports.handler = async (event, context, callback) => {
console.log('event : ', event);
const imgUrl = event.queryStringParameters.fname; // 이미지 url 파라미터
//* 리사이징 설정
const quality = 100; // 이미지 퀄리티 (기본 80)
const [size1, size2] = event.pathParameters.size.split('x'); // size 경로변수 값 : [500, 700]
const ext = imgUrl.split('.')[imgUrl.split('.').length - 1]; // 파일 포맷
const requiredFormat = ext === 'jpg' ? 'jpeg' : ext; // sharp 에서는 jpg 말고 jpeg를 사용하기에 조치
// //* 이미지를 서버단으로 ajax로 가져오기
const img = await axios.get(imgUrl, { responseType: 'arraybuffer' }).then((response) => Buffer.from(response.data));
//* 이미지 리사이징
const imgCompress = await sharp(img)
.resize(parseInt(size1), parseInt(size2), { fit: 'contain' }) // fit : contain 가로 세로 비율을 강제 유지
.withMetadata() // 원본 이미지의 메타데이터 포함
.toFormat(requiredFormat, { quality }) // 포맷 / 퀄리티 지정
.toFile(`resizeIMG.${requiredFormat}`, (err, info) => {
// 리사이징된 이미지를 로컬에 저장
console.log(`리사이징 이미지 info : ${JSON.stringify(info, null, 2)}`);
})
.toBuffer(); // 리사이징된 이미지를 노드에서 읽을수 있게 buffer로 변환
//* 응답 메소드 설정
const response = {
statusCode: 200,
headers: {
'Content-Type': `image/${requiredFormat}`, // image/png 형태
},
body: imgCompress.toString('base64'), // base64 형태로 변환
isBase64Encoded: true,
};
callback(null, response); // 리사이징된 이미지를 브라우저에 뜨게 응답
};
이렇게 람다와 API 게이트웨이 연결을 하여 이미지 리사이징 서비스를 완성하였다.
브라우저에서 실제로 API 엔드포인트 url로 size값과 이미지 경로를 주게 되면 다음과 같이 리사이징 된 이미지 데이터로 응답 받게 된다.
- API-게이트웨이-엔드포인트/thumb/300x300?fname=[이미지url]
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.