교육자료

온디맨드 이미지 리사이징 (Ondemand Image Resizing) 원리 및 예제

이병록 2020. 2. 9. 01:21

최종수정일자 : 2020-06-15

 

 

 

 글의 목적과 뱡향은 다음과 같습니다.

 

1. 제자 또는 초보자를 위한 참고문서

2. 원리를 숙지한다.

3. 이미지를 업로드하는 법을 알아본다.

4. 온디맨드 이미지 리사이징의 개념과 원리를 알아본다.

5. 온디맨드 이미지 리사이징을 구현한다.(서버)

6. 온디맨드 이미지 리사이징을 구현한다.(서버리스)

 

이미 인터넷에 온디맨드 이미지 리사이징 관련하여, 예제가 많습니다.

 

여기서는 단순히 서버리스 예제만 하는 것이 아니라 직접 온디맨드 리사이징을 서버, 서버리스 방식으로 둘다 구현할 예정입니다.

구현하는 것에는 상관 없을지라도 혹시나 좀 더 깊게 공부하고 싶은 사람이라면 직접 구현해보면서 원리를 깊게 느껴보는 것도

좋은 방법입니다.

 

 

개념과 원리가 중요하다. 아키텍처 뿐만 아니라 모든지.

 

 


 

온디맨드 이미지 리사이징이란?

 

이미지 요청시에 원본의 이미지를 원하는 해상도에 맞게 리사이징하여 전송하는 것을 말합니다.

 

 

구성

온디맨드 이미지 리사이징은 3가지 Layer 또는 3가지 컴포넌트 방식으로 구성됩니다.

 

이미지 캐시서버, 이미지 리사이징 서버, 이미지 스토리지

 

 

  • 이미지 캐시 서버 : 이미지는 캐시서버로 요청된다. 캐시된 이미지가 없을 경우 이미지 이미지 리사이징 서버에 이미지를 요청한다.
  • 이미지 리사이징 서버 : 원본의 이미지를 이미지 이미지 스토리지에서 가져와서 리사이징한다.
  • 이미지 스토리지 : 원본의 이미지를 저장한다.

 

 

원리

 

 

 

온디맨드 이미지 리사이징의 간략한 순서는 다음과 같습니다.

 

 

1. URI에 파라미터를 담아 원하는 해상도의 이미지를 요청한다.

Http 프로토콜 버전은 크게 상관 없으나 GET방식으로 요청할 수 있도록 설계되어야 한다.

이유는 URI를 통해 캐싱하기 쉽게 만들어야 하기 때문이다.

 

캐싱에는 로컬 캐싱과 이미지 캐시 서버의 캐싱 두 가지를 이용 가능하다.

예) HTTP GET /image/image_1.jpg?w=400&h=200

 

2. 이미지 캐시 서버에서 캐싱된 이미지가 있으면 이미지를 전송한다.

만약에 이미지가 캐싱되어 있지 않다면 다음의 단계를 진행한다.

 

3. 캐싱된 이미지가 없으므로 이미지 리사이징 서버API 또는 SDK를 통해 리사이징된 이미지를 요청한다.

 

4. 이미지 리사이징 서버API 또는 SDK를 통해 이미지 스토리지 서버에 원본 이미지를 요청한다.

 

5. 이미지 스토리지 서버이미지 리사이징 서버에 이미지를 전송한다. 다만 이미지가 없을 시 에러 코드를 보낸다.

 

예) 정상 : HTTP GET 200, 에러 : HTTP GET 40X 

 

6. 이미지 리사이징 서버는 에러코드가 아닌 정상적인 이미지를 받을 시 URI를 해석하여 원하는 이미지로 리사이징 후 이미지 캐시 서버로 전송한다.

다만 이미지 스토리지에 서버에서 에러코드를 받으면 이미지 캐시 서버에러코드를 전송한다.

 

예) 정상 : HTTP GET 200, 에러 : HTTP GET 40X 

 

 

 

왜? 온디맨드 이미지 리사이징을 하는가?

 

항상 왜?가 중요합니다. 우리는 왜?라는 의문을 품고 생각해봐야 합니다.

 

 

 

 

기존의 다양한 해상도의 이미지를 대응하는 방식

 

 

기존의 이미지 처리 방식

기존에 다양한 해상도의 이미지를 대응하는 방식은

다양한 해상도의 이미지로 미리 저장을 하는 문제 해결방식에 초점이 맞춰져 있었습니다. 

그러므로 생기는 고질적인 문제는 다음과 같습니다.

 

 

  • 업로드 시에 이미지를 리사이징하거나 렌더링 해야하기 때문에 렌더링 서버 사양이 좋아야 한다. (여러 해상도로 저장해야 하므로..연산이 많다.)
  • 새로운 해상도의 이미지가 필요할 경우 대응 방법이 골치 아프다. (기존에 저장하지 않고 있던 해상도의 이미지가 필요할 경우)
  • 다양한 해상도의 이미지를 저장하기 때문에 스토리지 비용이 원본 하나 저장할 때 보다 증가한다.
  • 모든 해상도의 이미지가 자주 쓰이지는 않는다. 그러므로 여러 해상도로 저장하는 것 대비 비효율적일 가능성이 높다.(주로 쓰는 해상도가 900x900이고 나머지는 요청을 안하는 경우도 많다.)
  • 이미지 스토리지 마이그레이션 시간과 비용이 장난이 아니다.

 

 

 

 

온디맨드 이미지 리사이징이 다양한 해상도의 이미지를 대응하는 방식

 

온디맨드 이미지 리사이징은 이미지 로딩 시 필요할 때만 해상도 또는 렌더링하여 문제를 해결하는 방식으로 접근합니다.

온디맨드라는 단어에 맞게 필요할 때(수요)만 그 때 마다 문제를 해결하자는 방식입니다.

 

리사이징 또는 렌더링하는 연산이 가볍지 않기 때문에 요청 때 대응한다는 방식은 사실 쉽지 않습니다.

이미지 처리 연산능력이 받쳐주지 않으면 이미지 로딩이 느려지기 때문에 고객은 문제가 생겼다고 생각할 수 있습니다.

 

그러나 하드웨어가 지속적으로 발전하고, 인프라 환경이 클라우드로 넘어감에 따라 이론적으로만 생각했던 설계방식을 실제로 운영환경에서 이용해볼 수 있게 됩니다. 물론 트렌드도 바뀌기도 했습니다.

 

 

  • 원본 이미지만 저장하기 때문에 스토리지 비용이 상대적으로 감소한다.
  • 이미지 업로드의 연산이 상대적으로 가볍다.
  • 새로운 해상도의 이미지가 필요할 경우 상대적으로 해상도 대응 문제를 해결하기 쉽다.
  • 자주쓰이지 않는 해상도의 이미지를 저장할 필요가 없다. 

 

 

 

온디맨드 이미지 리사이징 방식의 장점

 

장점은 위에서 작성한 내용과 같습니다.

 

  • 원본 이미지만 저장하기 때문에 스토리지 비용이 감소한다.
  • 기존의 방식보다 다양한 해상도 문제 대응이 쉽다.
  • 자주쓰이지 않는 해상도의 이미지를 저장할 필요가 없다.

 

 

온디맨드 이미지 리사이징 방식의 단점

 

온디맨드 이미지 리사이징 방식도 장점만 있는 것은 아닙니다.

장점만 있다면 항상 좋겠지만 단점도 분명 존재합니다.

 

  • 최초 이미지를 요청 시 이미지를 리사이징 해야 하므로 로딩이 느리다.
  • 캐싱되어 있지 않은 해상도의 이미지는 요청 시에 이미지를 리사이징 해야 하므로 기존의 방식보다 로딩이 느릴 수 있다.
  • 캐싱되어 있지 않은 해상도의 이미지를 대응하기 위해 로딩시 많은 연산량이 필요하다. 보통 업로드보다 다운로드 비율이 높기 때문에 과부화 발생 가능성이 높다.
  • 이미지 캐싱 서버의 중요도와 의존도가 상대적으로 더 높다. Cache hit가 굉장히 중요하다. hit율이 낮으면 매번 이미지를 리사이징하므로 과부화 발생 가능성이 높다.
  • 캐시 서버의 모든 이미지를 캐싱할 수 없는 한계점 때문에 같은 해상도의 이미지를 특정 시점 이후에 재 요청 시 재 리사이징을 할 수밖에 없다. (Cache miss)

 

 

 

 

 


구현해보기 1 - 이미지 업로드

 

이미지 업로드를 이미 자유롭게 구현하고 머리속에 개념이 있는 경우는 패스합니다.

 

 

간단한 이미지 업로드 서버 구현해보기 

이미지 업로드 서버 간략 flow

이미지 업로드 개념은 간단합니다.

서버는 클라이언트로 부터 binary 파일을 전송받아서 이미지 스토리지에 저장을 합니다.

그리고 저장한 경로 패스 + 파일명을 클라이언트에 반환하면 됩니다.

 

 

그래서 이번에는 간단하게

JAVA 언어로 Spring Boot의 Embedded Tomcat을 이용하여 이미지 업로드 서버를 만들어 보겠습니다.

단순히 로컬에 이미지를 저장하고 파일패스와 파일명을 반환하는 서버입니다.

3개의 클래스만 만들면 됩니다.

 

복잡할 것이 없습니다.

 

 

Result.java - 응답형식을 만들어내는 객체 DTO

@Getter
public class Result {
    private boolean isSuccess;
    private String message;

    private Result() {
    }

    public static Result createResult(boolean isSuccess, String message) {
        Result result = new Result();
        result.isSuccess = isSuccess;
        result.message = message;
        return result;
    }
}

 

ImageUploadService.java

@Service
public class ImageUploadService {

    public Result uploadImage(MultipartHttpServletRequest multipartHttpServletRequest) {

        /**
         * 업로드 된 이미지가 한 장일 경우만 고려한다.
         */

        Iterator<String> fileNames = multipartHttpServletRequest.getFileNames();

        while (fileNames.hasNext()) {
            String fileName = fileNames.next();
            MultipartFile multipartFile = multipartHttpServletRequest.getFile(fileName);

            // TODO : 바이너리를 통한 Mime-Type 체크는 생략
            String mimeType = multipartFile.getOriginalFilename().split("\\.")[1];

            String newFileName = String.format("%d%d.%s", System.currentTimeMillis(), System.nanoTime(), mimeType);

            try (InputStream initialStream = new BufferedInputStream(multipartFile.getInputStream())){

                byte[] buffer = new byte[initialStream.available()];
                initialStream.read(buffer);

                // TODO : 로컬에 저장해보자.
                String path = String.format("%s%s", "src/main/resources/", newFileName);

                File targetFile = new File(path);
                OutputStream outStream = new FileOutputStream(targetFile);
                outStream.write(buffer);

                outStream.close();

                return Result.createResult(true, newFileName);

            } catch (IOException e) {
                System.out.println(e);
                return Result.createResult(false, "이미지 업로드에 실패했어요.");
            }
        }
        return Result.createResult(false, "업로드된 이미지가 존재하지 않습니다.");
    }
}

 

ImageUploadController.java

@RestController
@RequestMapping(value = "/image")
public class ImageUploadController {

    private ImageUploadService imageUploadService;

    public ImageUploadController(ImageUploadService imageUploadService) {
        this.imageUploadService = imageUploadService;
    }

    @CrossOrigin(origins = "http://localhost:63342")
    @PostMapping
    public Result uploadImage(MultipartHttpServletRequest multipartHttpServletRequest, HttpServletResponse httpServletResponse) {
        Result result = imageUploadService.uploadImage(multipartHttpServletRequest);
        if (!result.isSuccess()) {
            httpServletResponse.setStatus(400);
        }
        return result;
    }
}

 

 

간단한 이미지 업로드 클라이언트

 

서버를 만들었으면 이미지를 업로드 할 수 있는 클라이언트도 필요합니다.

어느 클라이언트든 상관없습니다. html이 빠르게 구현 가능하고 코드가 짧아서 html로 만들었을 뿐입니다.

네트워크 라이브러리는 axios를 사용했습니다.

 

UploadPage.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <title>이미지 업로드 페이지</title>
</head>
<body>

<img id="image" style="display:block;width:400px;height:200px;"/>
<input type="file" name={"img"} accept="image/jpg, image/jpeg" onchange="fileUploadChange(this.files)"/>

</body>


<script>
    function fileUploadChange(files) {
        const file = files[0];
        const formData = new FormData();
        formData.append("file", file);

        const headers = {
            "Content-Type": "multipart/form-data",
        };

        axios.create({
            baseURL: "http://localhost:8080",
        }).post('/image', formData, {
            headers: headers,
        }).then(function (response) {
            document.getElementById("image").src = "http://localhost:8080/images/"+response.data.message;
        }).catch(function (error) {
            alert(error.response.data.message);
        });
    }

</script>

</html>

 

 

이미지 업로드 테스트

 

 

간단하게 파일명을 얻을 수 있습니다.

 

 

간단한 이미지 다운로드 서버

이미지 다운로드 서버 간략

 

 

기존에 만들었던 서버에 코드를 추가해 봅시다.

 

파일명을 포함한 특정 URI를 요청하면 binary 이미지 파일을 응답하는 코드입니다.

위에서 특정 로컬 주소에 저장했으니, 특정 로컬 주소에서 파일을 가져와서 응답하면 됩니다.

 

 

ImageDownloadController.java

@RestController
@RequestMapping(value = "/images")
public class ImageDownloadController {

    @CrossOrigin(origins = "http://localhost:63342")
    @GetMapping(value = "{filename}")
    public void downloadImage(
            @PathVariable("filename") String filename,
            HttpServletResponse response) {

        try (InputStream inputStream = new FileInputStream(new File(String.format("%s%s", "src/main/resources/", filename)))){
            byte[] buffer = new byte[inputStream.available()];
            inputStream.read(buffer);
            response.getOutputStream().write(buffer);
        } catch (IOException ex) {
            response.setStatus(404);
        }
    }
}

 

 

 

이미지 다운로드 테스트

 

 

잘 나오는군요. 좋습니다.

 

 

결론

이미지 업로드, 다운로드 흐름을 볼 수 있도록 최대한 간단히 구현해봤습니다.

간단히만 맛보고 본격적으로 해봅시다.

이제부터는 좀 더 복잡한 것을 할 예정입니다.

 

 

 

위에 작성되어 있는 풀 코드는 여기서 확인할 수 있습니다.

https://github.com/roka88/tistory_image_upload_download

 

roka88/tistory_image_upload_download

Contribute to roka88/tistory_image_upload_download development by creating an account on GitHub.

github.com

 

 


 

구현해보기 2 - 온디맨드 이미지 리사이징(서버)

 

이제 본격적으로 온디맨드 이미지 리사이징을 위해 여러가지 컴포넌트를 만들어야 합니다.

 

서버리스로 구현할 때는 AWS의 Lambda를 node.js로 이용할 예정이기 때문에 일관성을 맞추고 비교하기 쉽게

이번엔 모두 node.js로 구현할 예정입니다. (python과 Django든, PHP과 Laravel든 무엇이든 용도에 맞게 개발하면됩니다.)

 

위의 간략 이미지 업로드 다운로드 서버는 JAVA와 Spring boot를 이용했지만 node.js로 구현해도 상관 없습니다.

개념과 아키텍처만 알면 구현하는건 쉽습니다.

 

 

준비물

 

아키텍처 구성을 합치거나 쪼개는 방식으로 할 수 있습니다.

 

  • 프로젝트 단위로 쪼갠다
  • 인스턴스 단위로 쪼갠다

 

1번은 Scale-Up을 고려하여 하나의 컴퓨팅 환경에서 구성하는 방법

2번은 Scale-Out을 고려하여 여러개의 컴퓨팅 환경에서 구성하는 방법

 

우리는 1번 방법으로 할 겁니다. 이유는 하나만 개념을 잘 알면 다른 것도 잘 할 수 있기 때문입니다. (사실 귀찮습니다;;)

1번 방법을 할 줄 알면 2번 방법도 할 수있습니다. 구성만 나누면 되니까요. 실무에서는 1번은 당연하고 2번 방법도 잘 안쓰고 서버리스 방식으로 갑니다. (클라우드를 사용하면 안되는 환경이 아닌이상)

 

하지만 우리는 개념과 원리를 깨닫기 위해 구성해보는 것이니 열심히 해봅시다.

 

 

 

 

세 가지 프로젝트

 

세 가지 프로젝트는 다음으로 구성됩니다.

 

  • NodeImageUploadServer : 이미지를 업로드 하는 서버 (Image Storage, http://localhost:3000)  
  • NodeImageResizingServer : 이미지를 리사이징 하는 서버 (Image Resizing, http://localhost:4000)
  • NodeImageCacheServer : 이미지를 다운로드시 캐싱 하는 서버 (Image Caching, http://localhost:5000)

 

실제로 이렇게 구성됩니다.

 

노드를 잘 이용하지 않아서 모르지만 일단 만들어 보겠습니다. (노알못;)

Koa Framework와 axio로 구현해볼 예정입니다.

 

 

풀코드는 내용 맨 밑에 있으니 걱정하지 마세요.

 

 

 


 

NodeImageUploadServer

 

먼저 원본 이미지를 업로드하고 다운로드 할 수 있는 서버를 만들어 보겠습니다.

 

이미지 업로드 flow

해당 프로젝트 서버가 하는 일은 다음과 같습니다.

 

  1. 이미지를 POST Image Upload & Download Server(http://localhost:3000/image) 로 보낸다. (요청)
  2. Image Upload & Download Server는 클라이언트로 받은 이미지를 로컬 이미지 스토리지에 저장한다.
  3. Image Upload & Download Server는 이미지 업로드 성공 시 {"isSuccess":true, message:"1251231241.jpg"}로 응답한다. 

 

 

ImageUploadRoute.js

const Router = require('koa-router');
const router = new Router();

const fs = require('fs');
const Path = require('path');


module.exports = router;




router.post('/image', async (ctx, next) => {


    try {
        const file = ctx.request.files.file;

        const fileName = file.name;
        /*
            binary를 통한 Mime-Type으로 확인하는 것이 맞으나 생략
        */
        const fileType = fileName.split(".")[1];
        let ts = Date.now();
        const newFileName = ts+"."+fileType;

        const syncFile = fs.readFileSync(file.path);

        fs.writeFileSync(Path.join(__dirname, "../../public/images/"+newFileName), syncFile);

        ctx.set("Content-Type", "application/json");
        ctx.body = JSON.stringify({isSuccess:true, message:newFileName});
        
    } catch (err) {
        console.log(err);
        ctx.status = 500;
        ctx.set("Content-Type", "application/json");
        ctx.body = JSON.stringify({isSuccess:false, message:"이미지 업로드에 실패했습니다."});
    }
});

module.exports = router;

 

이미지를 업로드 하고 로컬 스토리지에 저장하는 라우트입니다.

성공하면 파일명을 보내고, 실패하면 에러메세지를 전송합니다.

 

 

ImageDownloadRoute.js

const Router = require('koa-router');
const router = new Router();

const fs = require('fs');
const Path = require('path');


module.exports = router;

router.get('/images/:name', async (ctx, next) => {

    const { name } = ctx.params;
    ctx.set("Content-Type", "image/jpg");
    ctx.body = fs.readFileSync(Path.join(__dirname, "../../public/images/"+name));

});

 

이미지를 다운로드하는 라우트 입니다.

/images/:파일명 을 전송하면 로컬에서 파일을 읽어들여 body로 보냅니다.

Content-Type을 대충 jpg로 정합니다. 테스트 해볼꺼니까요.

어려운 내용은 없습니다.

 

 

 

UploadPage.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <title>이미지 업로드 & 다운로드 페이지</title>
</head>
<body>

<div>
    <h2>이미지 업로드</h2>
    <img id="image_upload" style="display:block;width:400px;height:200px;"/>
    <input type="file" name={"img"} accept="image/jpg, image/jpeg" onchange="fileUploadChange(this.files)"/>

</div>

<div>
    <h2>이미지 다운로드</h2>
    <img id="image_download" style="display:block;width:400px;height:300px;object-fit:none;"/>
    <input type="text" id="uri" style="display:block;width:400px;height:30px">
    <button style="display:block;" onclick="onClickHandler()">이미지 다운로드</button>
</div>



</body>


<script>

    function onClickHandler() {
        document.getElementById("image_download").src = "http://localhost:3000" + document.getElementById("uri").value;
    }

    function fileUploadChange(files) {
        const file = files[0];
        const formData = new FormData();
        formData.append("file", file);

        const headers = {
            "Content-Type": "multipart/form-data",
        };


        axios.create({
            baseURL: "http://localhost:3000",
        }).post('/image', formData, {
            headers: headers,
        }).then(function (response) {
            document.getElementById("image_upload").src = "http://localhost:3000/images/"+response.data.message;
            document.getElementById("uri").value = "/images/"+response.data.message;
        }).catch(function (error) {
            alert(error.response.data.message);
        });
    }

</script>

</html>

이미지를 업로드하고 다운로드하는 클라이언트 페이지도 후딱 만들어봅니다.

별 내용없습니다. 이미지를 업로드하고, 다운로드 테스트 할 수 있는 페이지입니다.

 

대략 이렇게 생긴 페이지입니다.

 

테스트도 무사히 성공합니다.

 

 

 


 

NodeImageResizingServer

이젠 원본 이미지를 요청 시 파라미터에 따라 리사이징 하고 다운로드 할 수 있는 서버를 만들어 보겠습니다.

 

이미지 리사이징 flow

 

 

해당 프로젝트 서버가 하는 일은 다음과 같습니다.

 

  1. 이미지를 Image Resizing Server(http://localhost:4000/images/:name) 으로 요청한다.
  2. Image Resizing Server는 원본 이미지를 다운로드 할 수 있는 Image Upload & Download Server(http://localhost:3000/images/:name) 으로 요청한다.
  3. Image Upload & Download Server는 원본 이미지가 Local Image Storage에 존재시 메모리로 읽어들여 Image Resizing Server로 전송한다.
  4. Image Resizing Server는 원본 이미지를 파라미터의 옵션에 따라 원본 또는 리사이징 된 이미지를 클라이언트에게 전송한다.

 

 

 

ImageResizeRoute.js

const Router = require('koa-router');
const router = new Router();
const API = require('../utils/API');

const Sharp = require('sharp');


module.exports = router;


router.get('/images/:name', async (ctx, next) => {

    const {request, url} = ctx;
    const {query} = request;
    const {name} = ctx.params;



    const response = await API.async({"Content-Type": "image/jpg"}, `http://localhost:3000${url}`);

    let resizedImageFile = response.data;
    if (query.w && query.h) {
        resizedImageFile = await Sharp(resizedImageFile).resize(query.w.toInt(), query.h.toInt(), {fit: "fill"}).withMetadata().rotate().toFormat("jpg").toBuffer();
    } else if (query.w) {
        resizedImageFile = await Sharp(resizedImageFile).resize({width: query.w.toInt()}).withMetadata().rotate().toFormat("jpg").toBuffer();
    } else if (query.h) {
        resizedImageFile = await Sharp(resizedImageFile).resize({height: query.h.toInt()}).withMetadata().rotate().toFormat("jpg").toBuffer();
    }
    ctx.set("Content-Type", "image/jpg");
    ctx.body = resizedImageFile;

});

module.exports = router;

 

 

이미지 리사이징 라이브러리로 sharp를 사용했습니다.

 

Sharp 라이브러리 퍼포먼스 테스트

node.js 이미지 리사이징 라이브러리 중에서 퍼포먼스가 상당히 좋은편입니다.

이름을 sharp로 지었지만 퍼포먼스는 섹시한거 같습니다. 헛솔..

 

쿼리 파라미터로는 w, h로 지정하였고 w는 너비를 뜻하고, h는 높이를 뜻합니다. 

w 또는 h는 비율대로 수정하도록 되어있습니다. w나 h를 둘다 보내면 기준에 따라 리사이징합니다.

해당 내용은 여기서 확인하세요.

 

 

 

 

API.js

const axios = require('axios');

const async = (headers, uri) => {

    return axios.get(uri, {
        responseType: 'arraybuffer',
        headers: headers
    });
};

module.exports.async = async;

axio로 이미지를 다운로드 받는 함수입니다. 별 내용이 없습니다.

 

 

 

 

 

 

UploadPage.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <title>이미지 업로드 & 다운로드 페이지</title>
</head>
<body>

<div>
    <h2>이미지 업로드</h2>
    <img id="image_upload" style="display:block;width:400px;height:200px;"/>
    <input type="file" name={"img"} accept="image/jpg, image/jpeg" onchange="fileUploadChange(this.files)"/>

</div>

<div>
    <h2>이미지 다운로드</h2>
    <img id="image_download" style="display:block;width:400px;height:300px;object-fit:none;"/>
    <input type="text" id="uri" style="display:block;width:400px;height:30px">
    <button style="display:block;" onclick="onClickHandler()">이미지 다운로드</button>
</div>



</body>


<script>

    function onClickHandler() {
        document.getElementById("image_download").src = "http://localhost:4000" + document.getElementById("uri").value;
    }

    function fileUploadChange(files) {
        const file = files[0];
        const formData = new FormData();
        formData.append("file", file);

        const headers = {
            "Content-Type": "multipart/form-data",
        };


        axios.create({
            baseURL: "http://localhost:3000",
        }).post('/image', formData, {
            headers: headers,
        }).then(function (response) {
            document.getElementById("image_upload").src = "http://localhost:3000/images/"+response.data.message;
            document.getElementById("uri").value = "/images/"+response.data.message;
        }).catch(function (error) {
            alert(error.response.data.message);
        });
    }

</script>

</html>

위에 있던 UploadPage.html을 수정합니다. 이미지 다운로드를 http://localhost:4000에서 할 것이므로 onClickHandler()의 URI 포트를 3000 -> 4000 으로수정합니다.

 

 

 

다시 테스트 해봅니다. 

 

 

파라미터에 w=140을 붙여서 다운로드 버튼을 누릅니다.

width를 140px로 비율대로 줄여서 다운로드를 했습니다.

 

다른 걸로 한번 더 해보죠

 

 

파라미터에 w=140, h=200을 붙여서 다운로드 버튼을 누릅니다.

이렇게 나오는군요.

 

 

 


 

 

원본 요청, 리사이징 후 요청의 비교

 

 

원본 이미지 해상도는 1920x1281 이고 용량은 544kb 입니다.

width 140 으로 리사이징된 이미지 해상도는140x93 이고 용량은 4.3kb 입니다.

width 140, height 200 으로 리사이징된 이미지 해상도는 140x200 이고 용량은 7.5kb 입니다.

 

요청후 응답 시간은 원본 요청이 훨씬 빠릅니다. 확실히 리사이징하는 시간이 걸리는 것을 알 수 있습니다.

물론 로컬이라 더 빠르긴 합니다. 544kb를 네트워크 타면 더 느려지겠지요.

 

여기서 말하고자 하는 핵심은 리사이징 하는 시간이 걸린다는 것입니다.

 

캐싱을 하지 않으면 매번 이미지 리사이징을 하기 때문에 리사이징 서버의 과부화 뿐만 아니라, 이미지 로딩시간도 길어질 것이 자명합니다.

 

그래서 우리는 캐시 서버를 도입합니다. 

 

 

 

 


 

NodeImageCacheServer

 

이젠 원본 이미지를 요청 시 파라미터에 따라 리사이징 하고 리사이징된 이미지를 캐싱 하고 다운로드 할 수 있는 서버를 만들어 보겠습니다.

 

 

 

이미지 캐시 서버 Flow

 

해당 프로젝트 서버가 하는 일은 다음과 같습니다.

 

  1. 클라이언트 로컬에 캐싱이 되어있고 캐싱 정책이 올바르게 되어 있으면 로컬 캐싱(disk or memory)을 통해 이미지를 로드한다.
  2. HTTP/1.1 protocol 에 따라 Cache-Control ttl(Time To Live)이 Expired 시 Image Cache Server(http://localhost:5000/images/:name) 로 이미지를 요청한다.
  3. Image Cache Server는 해당 유효성 검사 후 이미지가 수정 되지 않았으면 status 304 Not Modifed를 클라이언트에 보내거나 Image Cache Server에 Cache되어 있는 이미지를 전송합니다. 다만 Image Cache Server에 이미지가 캐시되어있지 않아 Cache Miss 발생 시 이미지를 Image Resizing Server(http://localhost:4000/images/:name) 에 이미지를 요청한다.
  4. Image Resizing Server는 원본 이미지를 다운로드 할 수 있는 Image Upload & Download Server(http://localhost:3000/images/:name) 으로 요청한다.
  5. Image Upload & Download Server는 원본 이미지가 Local Image Storage에 존재시 메모리로 읽어들여 Image Resizing Server로 전송한다.
  6. Image Resizing Server는 원본 이미지를 파라미터 옵션에 따라 원본 또는 리사이징 된 이미지를 Image Cache Server로 전송한다.
  7. Image Cache Server는 해당 이미지를 캐시 후 클라이언트에게 적절한 캐시 정책 Header를 이미지와 함께 전송한다.

 

 

 

Image Cache Server에 메모리 캐시를 하기 위해 node-cache 라이브러리를 쓸 예정입니다.

ttl을 설정할 수 있거든요. 직접 만들어도 되긴 한데 귀찮..

 

 

 

ImageCacheRoute.js

const Router = require('koa-router');
const router = new Router();
const API = require('../utils/API');


const NodeCache = require("node-cache");
const imageCacheStore = new NodeCache();

module.exports = router;

router.get('/images/:name', async (ctx, next) => {

    const {url, params} = ctx;
    const {name} = params;

    const etag = Buffer.from(url).toString('base64').replace(/=/gi,"");

    ctx.set('etag', etag);
    ctx.set("Cache-Control", "public, max-age=60");
    ctx.set("Content-Type", "image/jpg");

    ctx.status = 200;

    if (ctx.fresh) {
        console.log("Not Modified");
        ctx.status = 304;
        return;
    }

    const cacheImage = imageCacheStore.get(etag);

    if (!cacheImage) {
        console.log("캐시 Miss!");

        const response = await API.async({"Content-Type": "image/jpg"}, `http://localhost:4000${url}`);
        const success = imageCacheStore.set(etag, response.data, 60 * 20);

        if (!success) {
            console.log("이미지 다운로드 실패");
            ctx.status = 400;
        }
        
        ctx.body = response.data;

    } else {
        console.log("캐시 Hit!");
        ctx.body = cacheImage;
    }

});

 

ETag(entity tag)는 유효성을 검사하기 위한 문자입니다.

최초 이미지 요청 시 ETag를 클라이언트(브라우저)에 전송(응답)하게 되면 두 번째 동일 URI 요청시에 If-None-Match 를 요청 헤더에 전송하게 됩니다. (모던 브라우저 자체내에서 처리합니다.)

 

Koa Framework에서 context.fresh라는 변수가 있는데 'fresh'라는 모듈에 의존성을 가지고 있습니다.

 

 

대략 fresh 모듈의 내용이 아래와 같습니다.

function fresh (reqHeaders, resHeaders) {
  // fields
  var modifiedSince = reqHeaders['if-modified-since']
  var noneMatch = reqHeaders['if-none-match']

  // unconditional request
  if (!modifiedSince && !noneMatch) {
    return false
  }

  // Always return stale when Cache-Control: no-cache
  // to support end-to-end reload requests
  // https://tools.ietf.org/html/rfc2616#section-14.9.4
  var cacheControl = reqHeaders['cache-control']
  if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) {
    return false
  }

  // if-none-match
  if (noneMatch && noneMatch !== '*') {
    var etag = resHeaders['etag']

    if (!etag) {
      return false
    }

    var etagStale = true
    var matches = parseTokenList(noneMatch)
    for (var i = 0; i < matches.length; i++) {
      var match = matches[i]
      if (match === etag || match === 'W/' + etag || 'W/' + match === etag) {
        etagStale = false
        break
      }
    }

    if (etagStale) {
      return false
    }
  }

  // if-modified-since
  if (modifiedSince) {
    var lastModified = resHeaders['last-modified']
    var modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))

    if (modifiedStale) {
      return false
    }
  }

  return true
}

 

그래서 전 속 편하게 이 모듈을 이용하여 유효성을 검사했습니다.

 

 

캐시관련 순서 설명은 프로젝트 서버가 하는 일에 작성되어 있으므로, 위에서 다시한번 보시길 바랍니다.^^

 

추가로 캐시 헤더에 대해 더 자세히 알고 싶으신 분은 여기 블로그에 쉽게 정리하여 잘 작성되어있고 RFC 문서인 Conditional-Request(번역) 또는 Caching(번역) 프로토콜 문서를 잘 참조하시기 바랍니다.

 

 

 

 

 

 

다시 본론으로 돌아가서

 

ETag를 Path + 쿼리 파라미터 문자를 base64로 인코딩하고 '==' 문자를 제거한 것으로 지정했습니다.

weak validator가 아닌 strong인 이유는 정책상 '이미지 파일명과 이미지 binary를 절대로 변경하지 않는다. 동일한 파일명으로 덮어쓰거나 수정하지 않는다.' 라는 규칙을 기반으로 strong으로 해도 상관 없을 것 같습니다.

 

 

 

 

그리고 우리는 테스트를 위해 다시 UploadPage.html 파일을 수정합시다..

 

UploadPage.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <title>이미지 업로드 & 다운로드 페이지</title>
</head>
<body>

<div>
    <h2>이미지 업로드</h2>
    <img id="image_upload" style="display:block;width:400px;height:200px;"/>
    <input type="file" name={"img"} accept="image/jpg, image/jpeg" onchange="fileUploadChange(this.files)"/>

</div>

<div>
    <h2>이미지 다운로드</h2>
    <img id="image_download" style="display:block;width:400px;height:300px;object-fit:none;"/>
    <input type="text" id="uri" style="display:block;width:400px;height:30px">
    <button style="display:block;" onclick="onClickHandler()">이미지 다운로드</button>
</div>



</body>


<script>

    function onClickHandler() {
        document.getElementById("image_download").src = "http://localhost:5000" + document.getElementById("uri").value;
    }

    function fileUploadChange(files) {
        const file = files[0];
        const formData = new FormData();
        formData.append("file", file);

        const headers = {
            "Content-Type": "multipart/form-data",
        };


        axios.create({
            baseURL: "http://localhost:3000",
        }).post('/image', formData, {
            headers: headers,
        }).then(function (response) {
            document.getElementById("image_upload").src = "http://localhost:3000/images/"+response.data.message;
            document.getElementById("uri").value = "/images/"+response.data.message;
        }).catch(function (error) {
            alert(error.response.data.message);
        });
    }

</script>

</html>

위에 있던 UploadPage.html을 수정합니다. 이미지 다운로드를 Image Cache Server(http://localhost:5000)에서 할 것이므로 onClickHandler()의 URI 포트를 4000 -> 5000 으로수정합니다.

 

 

 

테스트를 해봅시다.

 

 

 

 

 

최초 요청 시

이미지를 최초 요청 하면 브라우저와 Image Cache Server에 캐시 되어있는 데이터가 없기 때문에 이미지를 새롭게 요청합니다.

 

 

 

Cache Control max-age=60, 60초 이전

이미지를 동일한 URI로 요청 시 Cache Control에 작성된 TTL을 계산하여, Image Cache Server에 요청하지 않고 클라이언트(브라우저) 자체 내에서 Memory 또는 Disk를 통해 캐시된 이미지를 로드합니다. 

 

 

 

Cache ttl Expired, 만료

이미지를 동일한 URI로 요청 시 TTL이 만료되면 Image Cache Server에 이미지를 요청합니다. 다만 Etag와 If-None-Match 태그를 통해 Image Cache Server에서는 유효성 검사를 합니다. 유효성 검사시 이미지가 아직까지 수정되지 않았다고 판단되면(서버 내) Status 304 코드를 전송합니다. 그럼 클라이언트(브라우저) 자체 내에서 Memory 또는 Disk를 통해 캐시된 이미지를 로드합니다. 

 

 

 

Image Cache Server의 캐시된 것을 확인하는 법

 

브라우저 자체 내 캐시를 삭제하지 않는 이상 Image Cache Server의 캐시된 이미지를 테스트 하기가 쉽지 않습니다.

그래서 우리는 로컬 캐시되어있지 않은 새로운 브라우저를 실행시켜 Image Cache Server의 Cache Hit 상황을 만들어보겠습니다.

 

 

 

 

OK! 드디어 Image Cache Server의 캐시 Hit를 봤습니다.

Image Cache Server에서 Image Resizing Server로 요청 없이 서버 메모리에 있던 이미지를 바로 전송했습니다.

로컬 캐싱보다 빠르진 않지만, 서버에 캐시되어 있지 않은 최초 요청보다는 빠른 것을 확인할 수 있었습니다.

 

 

 

 

결론

 

직접 온디맨드 이미지 리사이징을 개념과 원리로 프로토 타입을 구현해봤습니다.

얻을 수 있는 교훈은 이것이지요. 캐싱 빡세게하자. 캐싱이 생명이다.^^;

 

 

--------------------------------------

 

 

이번 글도 꽤 길어지는 것 같습니다. 아직 서버리스도 안했는데..

최대한 상세하게 쓰려다보니 길어지는 것 같습니다. 

 

사실 프로토콜 관련해서도 더 작성하고 싶었는데 저번에 작성한 

AWS를 이용한 간략 CI & CD CodeBuild, CodeDeploy, CodePipeLine

글 마냥 엄청 길어질까봐 가급적 다음으로 넘기고 있습니다.

 

그래도 직접 이렇게 개념으로 알고 있던 것을 구현하다보면 CS나 아키텍처 방면에서 좀 더 확신을 가지게 됩니다.

머리속에 구체화 된다고 할까나요.

 

앞으로 갈길이 멉니다.

부지런하게 가봅시다.

 

 

 

위에 코드와 연관있는

 

이미지 업로드 다운로드 서버입니다.

https://github.com/roka88/tistory_node_image_upload_download_server

 

roka88/tistory_node_image_upload_download_server

티스토리 온디맨드 이미지 리사이징 관련 글 프로젝트. Contribute to roka88/tistory_node_image_upload_download_server development by creating an account on GitHub.

github.com

 

이미지 리사이징 서버입니다.

https://github.com/roka88/tistory_node_image_resizing_server

 

roka88/tistory_node_image_resizing_server

티스토리 온디맨드 이미지 리사이징 관련 글 프로젝트. Contribute to roka88/tistory_node_image_resizing_server development by creating an account on GitHub.

github.com

 

이미지 캐시 서버입니다.

https://github.com/roka88/tistory_node_image_cache_server

 

roka88/tistory_node_image_cache_server

티스토리 온디맨드 이미지 리사이징 관련 글 프로젝트. Contribute to roka88/tistory_node_image_cache_server development by creating an account on GitHub.

github.com

 

 

 

 


 

구현해보기 3 - 온디맨드 이미지 리사이징(서버리스)

 

드디어 본론입니다.

 

AWS에서 제공해주는 인프라를 이용하여, 온디맨드 이미지 리사이징을 구현해볼겁니다.

서버리스 방식은 직접 인프라를 관리 하는 비용이 줄어들 수 있지만, 생각보다 이용 비용이 저렴한 것은 아닙니다.

만약에 트래픽이 일정하고 많다면 트래픽 비용과 CDN 비용이 꽤 많이 나옵니다.

 

트래픽이 들죽 날죽하고 적다면 적극적으로 추천할만 합니다. (살아남을지 죽을지 모르며, 성장하고 있는 스타트업에게는 추천!)

 

 

준비물

 

  • CloudFront : CloudFront는 짧은 지연시간과 빠른 전송 속도로 데이터, 동영상, 애플리케이션 및 API를 전 세계 고객에게 안전하게 전송하는 고속 콘텐츠 전송 네트워크(CDN) 서비스 입니다. (AWS CloudFront 소개글)
  • Lambda@Edge : Lambda@Edge는 CloudFront의 기능 중 하나로서 애플리케이션의 사용자에게 더 가까운 위치에서 코드를 실행하여 성능을 개선하고 지연 시간을 단축할 수 있게 해 줍니다. (AWS Lambda@Edge 소개글)
  • Lambda : 서버를 관리 할 필요 없이, 백엔드 서비스에 대한 코드를 업로드 하면 별도의 관리 없이 실행하며, 코드를 업로드하면 실행 및 확장하는데 모든 것을 처리합니다.  (AWS Lambda 소개글)
  • S3 : 확정성과 데이터 가용성 및 보안과 성능을 제공하는 개체 스토리지 서비스 입니다. (AWS S3 소개글)

 

ps.

엣지 컴퓨팅에 관한 글은 여기서 잘 설명 되어있군요.

엣지 컴퓨팅은 쉽게 말해 분산 컴퓨팅인데 좀 더 가벼운 컴퓨터이며 컴퓨팅 할 수 있는 것이라고 볼 수 있습니다.

굳이 메인 프레임 서버까지 가지 않고 요청 시 가까운 지역에 컴퓨터가 있다면 거기서 연산을 한다는 얘기 입니다.

 

 

AWS의 인프라를 이용한 온디맨드 리사이징 이미지 Flow

 

 

우리가 직접 온디맨드 이미지 리사이징 서버 아키텍처를 구현한 것과 비슷합니다. 크게 다르지 않습니다.

 

 

  1. 클라이언트 로컬에 캐싱이 되어있고 캐싱 정책이 올바르게 되어 있으면 로컬 캐싱(disk or memory)을 통해 이미지를 로드한다.
  2.  HTTP/2.0 Protocol에 따라 Cache-Control ttl(Time To Live)이 Expired 시 CloudFront(CDN)으로 이미지를 요청한다. 다만 최초 요청이 아니며, 두 번째 이후 요청이고 요청 헤더를 통해 유효성을 확인하여 이미지가 수정되지 않은 것으로 판단되면, Cloud Front에서 304 Not Modifired를 클라이언트에게 전송하며 클라이언트는 로컬 캐싱(disk or memory)을 통해 이미지를 로드한다. 
  3. CloudFront에서 캐시 Miss인 경우 Lambda@Edge 컴퓨팅을 통해 배포된 Lambda 코드를 실행 시키며, Lambda는 원본 이미지가 존재하는 S3에서 이미지를 요청한다. 캐시 Hit인 경우 바로 클라이언트에게 이미지를 전송한다.
  4. 원본 이미지가 존재 시 Lambda는 이미지 리사이징을 실행하며 정상적으로 완료시 이미지는 CloudFront에 캐시됩니다.
  5. Cache-Control, etag, last-modified 등 브라우저가 인식할 수 있는 캐시 정책 관련 헤더와 함께 이미지를 클라이언트에게 전송한다.

 

사실상 Lambda@Edge에 Lambda에서 작성된 코드가 속해(Lambda 프로젝트를 Edge로 배포시)있다고 볼 수 있습니다.

작성된 코드가 Lambda@Edge에서 실행되니까요. 

그러므로 CloudFront가 Cache Server의 역할과 Image Resizing Server의 역할도 하게됩니다.

 

 

 

 

 

IAM 역할 지정

 

AWS의 Lambda가 사용할 IAM 역할을 만들겁니다.

 

 

 

역할 만들기 버튼을 클릭합니다.

 

 

 

 

우리는 Lambda에 역할을 부여할 것이므로, '사용 사례 선택' 에서 Lambda를 클릭하고 다음을 클릭합니다.

 

 

 

 

AWSLambdaFullAccess 찾아 권한을 체크합니다.

 

 

 

 

AmazonS3FullAccess 찾아 권한을 체크하고 다음을 클릭합니다.

 

 

 

 

태그는 생략하고 다음을 클릭합니다.

 

 

 

역할이름을 작성하고 역할 만들기를 클릭합니다. 그리고 잘 기억해둡시다^^

 

 

 

 

 

 

만들어진 역할을 확인 합니다.

 

 

 

 

 

 

 

만들어진 역할을 클릭 후 '신뢰 관계' 탭을 클릭합니다. 그리고 '신뢰 관계 편집'을 클릭합니다.

 

 

 

 

 

 

Service에 'edgelambda.amazonaws.com'을 추가합니다. 그리고 '신뢰 정책 업데이트'를 클릭합니다.

 

 

 

 

 

'신뢰할 수 있는 개체'에 자격 증명 공급자가 추가된 것을 확인할 수 있습니다.

 

 

 

 

 

 

S3 Bucket 준비

 

Image Storage를 만들기 위해 S3 Bucket을 만들 예정입니다.

 

 

 

Amazon S3의 Bucket에 들어갑니다. '버킷 만들기'를 클릭합니다.

 

 

 

 

'버킷 이름'을 작성하고 '다음' 버튼을 클릭합니다.

 

 

 

 

 

나머지는 생략하고 '다음' 버튼을 클릭합니다.

 

 

 

 

 

나머지는 생략하고 '다음' 버튼을 클릭합니다.

 

 

 

 

 

'버킷 이름'과 '리전'을 확인 후 '버킷 만들기' 버튼을 클릭합니다.

 

 

 

 

 

만들어진 Bucket을 확인 후 Bucket을 클릭합니다.

 

 

 

 

 

테스트 할 이미지를 업로드 해볼겁니다. '업로드' 버튼을 클릭합니다.

 

 

 

 

임의의 이미지를 추가 한 후 좌측의 '업로드' 버튼을 클릭합니다.

 

 

 

Bucket에 테스트 할 이미지가 업로드 된 것을 확인할 수 있습니다.

 

 

 

 

CloudFront 생성

 

CDN이자 Lambda@Edge를 이용하기 위해 CloudFront를 생성해야 합니다.

 

 

 

CloudFront Page에 들어가서 'Create Distribution' 버튼을 클릭합니다.

 

 

 

 

Web 단락의 'Get Started' 버튼을 클릭합니다.

 

 

 

 

 

'Origin Domain Name' 을 아까전에 만들었던 S3 Bucket을 선택해줍니다.

'Restrict Bucket Access'를 Yes로 변경합니다.

'Origin Access Identity'를 'Create a New Identity'로 변경합니다.

'Grant Read Permissions on Bucket'를 'Yes, Update Bucket Policy'로 변경합니다.

'Query String Forwarding and Caching'를 'Forward all, cache based on whitelist' 로 변경합니다.

'Query String Whitelist'를 스크린샷과 같이 변경합니다.

그리고 마지막으로 'Create Distiribution' 버튼을 클릭합니다.

 

 

 

 

생성하는데 꽤 걸립니다.. 대략 10~15분 이상 걸린 것 같습니다.

 

 

완료. 일단 다음 단계로 넘어갑시다.

 

 

 

 

 

 

Lambda 프로젝트 준비

 

Image Resizing Module을 만들기 위해 Node.js 프로젝트를 만들 예정입니다.

 

 

먼저 Node.js 프로젝트를 만들고 index.js 파일을 생성합니다.

그리고 이번에도 이미지 리사이징 라이브러리인 'sharp'를 사용할 겁니다.

 

sharp 라이브러리는 별도로 설치해야 합니다.

 

index.js

const querystring = require('querystring'); // Node.js를 실행할 Lambda 머신이 가지고 있기에 별도의 설치를 하지 않습니다.
const AWS = require('aws-sdk'); // Node.js를 실행할 Lambda 머신이 가지고 있기에 별도의 설치를 하지 않습니다.
const S3 = new AWS.S3({
    region: "ap-northeast-2" // S3 Bucket Region 명
});


const Sharp = require('sharp');
const BUCKET = 'test-images-storage'; // S3 Bucket 이름

exports.handler = async (event, context, callback) => {

    const response = event.Records[0].cf.response;
    const request = event.Records[0].cf.request;
    
    const params = querystring.parse(request.querystring);
    const uri = request.uri;
    const [, imageName, extension] = uri.match(/\/(.*)\.(.*)/);
    const requiredFormat = extension == "jpg" ? "jpeg" : extension;

    response.headers["content-type"] = [{
        key: "Content-type",
        value: "image/" + requiredFormat
    }];

    if (!response.headers['cache-control']) {
        response.headers['cache-control'] = [{
            key: 'Cache-Control',
            value: 'public, max-age=86400'
        }];
    }

    if (!params.w && !params.h) {
        callback(null, response);
        return;
    }

    try {
        const originalKey = imageName + "." + extension;

        const s3Object = await S3.getObject({
            Bucket: BUCKET,
            Key: originalKey
        }).promise();


        let resizedImage;

        if (params.w && params.h) {
            const width = parseInt(params.w);
            const height = parseInt(params.h);
            resizedImage = await Sharp(s3Object.Body).resize(width, height, {fit: "fill"}).withMetadata().rotate().toFormat(requiredFormat).toBuffer();
        } else if (params.w) {
            const width = parseInt(params.w);
            resizedImage = await Sharp(s3Object.Body).resize({width: width}).withMetadata().rotate().toFormat(requiredFormat).toBuffer();
        } else if (params.h) {
            const height = parseInt(params.h);
            resizedImage = await Sharp(s3Object.Body).resize({height: height}).withMetadata().rotate().toFormat(requiredFormat).toBuffer();
        } else {
            return callback(null, response);
        }

        response.status = 200;
        response.body = resizedImage.toString('base64');
        response.bodyEncoding = "base64";

        return callback(null, response);

    } catch (error) {
        console.log(error);
        return callback(error);
    }
};



 

 

 

# npm install --arch=x64 --platform=linux --target=10.15.0 sharp

sharp를 install 시 linux-x64 platform 에서 실행가능하게 install 해야 합니다.

그렇지 않으면 'darwin-x64' binaries cannot be used on the 'linux-x64' platform.

Please remove the 'node_modules/sharp/vendor' directory and run 'npm install' 

에러를 확인하게 됩니다.

sharp 측에서 Lambda를 위한 설치 방법을 작성하였으니 내용은 여기서 확인 가능합니다.

 

 

 

 

 

sharp 모듈과 index.js 파일이 담겨 있는 조촐한 폴더입니다.

 

 

 

 

index.js와 node_modules 폴더를 압축하고 이름도 바꿔줍니다.

 

 

 

 

 

 

 

 

Lambda 생성

 

 

함수를 생성하고, 만들었던 Node.js 모듈을 Lambda로 업로드 후 Lambda@Edge로 배포 할 예정입니다.

 

 

 

 

AWS Lambda Page로 들어갑니다.

 

 

Lambda를 생성할 Region을 미국 동부 (버지니아 북부) us-east-1로 지정합니다.

현재시점 (2020-02-07) 서울 리전에서 Lambda@Edge 배포를 할 수 없기 때문에 리전을 변경 해야합니다...

 

 

 

 

미국 동부 Region으로 변경 후 '함수 생성' 버튼을 클릭합니다.

 

 

 

 

'함수 이름'을 작성 후, 권한에서 맨 처음 만들었던 IAM 역할을 '기존 역할'로 지정해줍니다. 그리고 '함수 생성'을 클릭합니다.

 

 

 

 

우리는 아까 압축해서 만들었던 Node.js 프로젝트가 있으므로 .zip 파일 업로드하여 배포할 예정입니다.

'Code entry type' 에서 .zip 파일 업로드를 클릭 후 하단의 '함수 패키지'의 '업로드' 버튼을 클릭하여

압축파일을 업로드 합니다.

 

그리고 'Runtime'을 'Node.js 10.x'로 변경합니다.

현재(2020-02-08) Lambda@Edge에서 런타임 가능한 노드 버전이 Node.js 10.x 버전만 가능합니다.

 

 

 

하단에 '기본 설정'의 편집 버튼을 눌러 제한 시간을 10초 정도로 변경해줍시다.

 

 

 

 

save' 버튼이 활성화 됩니다. 'save' 버튼을 클릭합니다.

 

 

 

 

 

 

이제 CloudFront의 Lambda@Edge에 배포할 겁니다.

 

 

 

 

배포를 이전에 만든 CloudFront arn으로 변경 후 CloudFront 이벤트를 '오리진 응답'으로 선택하고, Lambda@Edge로 배포 확인을 체크합니다.

'배포' 버튼을 클릭합니다.

 

 

 

 

CloudFront로 돌아가면 'In Progress' 상태로 변경됩니다.

 

 

 

약 20분 가까이 걸려 Deploy가 완료되었습니다.

 

 

 

 

테스트

 

준비는 다 끝났습니다. 이제 테스트만 남았군요.

 

CloudFront에서 제공된 Domain Name으로 S3에 올렸던 파일을 요청 해봤습니다.

 

 

 

 

최초 요청

 

최초 요청이 무려 시간이 1.78초가 걸렸습니다. 이미지 스토리지에서 버지니아 동부까지 이미지를 다운로드 후 리사이징 하는 시간이 꽤 걸립니다.

 

 

 

 

 

 

재 요청

재 요청 시에는 CloudFront에서 304 Not Modified를 보내줍니다.

0.018초가 걸렸네요.

304를 받고 브라우저에서 로컬 캐시에 있는 이미지를 로드합니다.

<img> 태그를 통해 요청한 것이 아니고 직접 리소스를 요청한 것이라서 Cache-Control이 작동하지 않습니다.

원래라면 Cache-Control을 통해 CloudFront로 요청이 가지 않아야 하거든요.

 

 

 

 

 

다른 브라우저로 재 요청

CloudFront에서 Cache Hit된 이미지를 다운로드합니다.

0.087초가 걸렸네요.

최초 이미지 사용자의 요청을 통해 CloudFront에 캐시되면

다른 이미지 사용자가 요청시에 저 정도 시간이 걸리는 것을 뜻합니다.

 

 

 

 

 

이미지 태그를 통한 요청

위에서 만들었던 이미지 다운로드 클라이언트를 살짝 조정해서 다시 요청해봤습니다.

'Memory cache'가 되고 status 200으로 나옵니다.

 

 

 

 

 

결론

 

 

드디어 길고 긴 글의 내용이 끝났습니다.

 

이미지 업로드 부터 시작해서 온디맨드 이미지 리사이징 서버로 구현, 서버리스로 구현까지 꽤 긴글이었습니다.

그럼에도 불구하고 따라오셨다면 정말 고생많으셨습니다.

 

도움이 되는 사람이 있다면 정말 좋겠네요.

 

 

길이 글다 보니 결론은 짧게 가겠습니다.

 

 

혹시나 문제 있으면 연락주세요!^^