Express에서 Jimp로 이미지 리사이징과 GCP Storage에 업로드하기

GCP에 이미지를 업로드, Jimp를 이용해 리사이징도 해보자

Posted by yoogomja on November 12, 2020

임시로 만드는 프로젝트에서 express + typescript를 이용해 이미지 처리를 해야할 상황이 생겼다. 이는 유저 프로필 이미지를 위해서인데, 몇 가지 고려할 사항들이 있었다.

  1. api 서버와 이미지를 같은 위치에 두지 않아야 한다
  2. 이미지 업로드 시 이미지의 사이즈를 줄일 필요가 있다
  3. 원형으로 사용되므로 이미지를 크롭해야한다

1번의 이유는 api 서버에 이미지를 둘 경우 관리가 어려워질뿐더러, 데이터베이스와 파일을 연관짓기가 까다로울 듯 해서였다. 2번은 너무 큰 파일이 아니더라도 어짜피 크게 보일 필요가 없는 이미지들이므로, 썸네일을 만들듯 어느정도는 이미지를 리사이즈 해도 되겠다는 판단에서였다. 3번은 정사각 이미지 이외에 업로드 되었을 때 자동으로 중앙 크롭되는 것이 처리가 편할 것 같아서라는 이유였다.

먼저 1번을 해결하기 위해서 gcp의 업로드 방법을 알아둘 필요가 있었다.

1. GCP Storage 업로드하기

스토리지에 업로드하기 위해서는 몇 가지 준비가 필요했다.

  • 버킷 생성하기
  • 버킷을 외부에서 접근할 수 있도록 설정하기
  • 버킷에 접근할 수 있는 서비스 계정 만들기
  • 서비스 계정의 key 파일 생성하기

위 과정 중, 외부에서 접근할 수 있도록 설정하기부분은 버킷의 사용자에 allUser에게 보기 권한을 추가해주면 된다. 그 외에 서비스 계정을 만들어주고 그 계정의 key파일을 json으로 만들어 저장해두면 준비는 완료된다.

1.1. 세팅하기

만든 key파일은 서버 코드가 있는 곳에 위치시킨다. 나는 여기서 루트 폴더 아래에 secure라는 폴더를 만들고 거기에 키 파일을 위치 시켰다.

키 파일을 위치시키고 난 뒤에는 node에게 해당 파일이 어디있는지 알려주어야한다. 환경 변수로 해당 위치를 알려주면 된다.

이 때, 구글은 문서에서 GOOGLE_APPLICATION_CREDENTIALS이라는 이름으로 환경변수에 파일 위치를 알려주면 된다고 했는데, 여기에 상대 주소가 적용되지 않는 듯 하다.

그래서 나는 앱이 실행되었을때 직접 환경변수를 세팅해주기로 했는데, 코드는 아래와 같다.

1
2
3
4
5
6
7
8
// 파일 위치 설정
process.env.GOOGLE_APPLICATION_CREDENTIALS = `${process.cwd()}/secure/YOUR-FILE-NAME.json`;

// 설정을 확인
console.log(
    "google authentication installed at",
    process.env["GOOGLE_APPLICATION_CREDENTIALS"]
);

되도록 서버가 실행되는 초기 부분에 해당 부분을 작성해둔다. 그러면, 현재 프로세스의 루트 폴더 주소와 secure 경로등을 포함해서 환경변수로 등록해주고 자동으로 추후에 재사용된다.

그 후, .env등을 이용해 사용하는 버킷의 이름도 알려주어야 한다.

1
2
# 클라우드 스토리지 버킷 이름
GCLOUD_STORAGE_BUCKET=YOUR-BUCKET-NAME

위와 같이 입력해주면, gcloud storage패키지가 자동으로 버킷이름과 인증 관련 정보를 수집해 연결하게 된다.

1.2. 업로드하기

업로드를 하기 위해서는 먼저 파일을 불러올 수 있어야 한다. 문서에서 이야기한대로 여기서는 Multer를 이용해 multipart/form-data를 불러오도록 한다.

설치는 yarn add multer --save를 입력해 할 수 있다.

그 후, 라우터에 다음과 같은 설정이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import * as Multer from "multer";
import { Router, Request, Response } from "express";
const router = Router();

const multer = Multer({
    storage: Multer.memoryStorage(),
    limits: {
        fileSize: 2 * 1024 * 1024, // no larger than 2mb, you can change as needed.
    },
});

router.post(
    "/upload",
    // 파일을 하나만 가져오고, 키 값은 profileImage로
    multer.single("profileImage"),
    (req: Request, res: Response) => {
        if (!req.file) {
            res.status(400).json({
                message: "업로드할 이미지가 없습니다",
            });
            return;
        }
        res.json("upload?");
    }
);

위 처럼, multer는 미들웨어로 사용된다. 사용할 경우, reqfile이라는 이름으로 재사용 할 수 있게된다. 이 req.file을 업로드하는데 사용하게 된다.

그리고 @google-cloud/storage라는 패키지를 미리 설치해 두어야 한다.

실제 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import { Request, Response, Router } from "express";
import * as Multer from "multer";
import { Storage } from "@google-cloud/storage";
import { format } from "util";
import { Buffer } from "buffer";

// 스토리지 초기화
const storage = new Storage();
// 버킷 초기화
const bucket = storage.bucket(process.env.GCLOUD_STORAGE_BUCKET);
// 라우터 초기화
const router = Router();

// multer 초기화
const multer = Multer({
    storage: Multer.memoryStorage(),
    limits: {
        fileSize: 2 * 1024 * 1024, // no larger than 2mb, you can change as needed.
    },
});

router.post(
    "/upload",
    // 파일을 하나만 가져오고, 키 값은 profileImage로
    multer.single("profileImage"),
    (req: Request, res: Response) => {
        if (!req.file) {
            res.status(400).json({
                message: "업로드할 이미지가 없습니다",
            });
            return;
        }

        // 이미지 업로드 준비
        // 로그인 한 사용자의 이름을 따도록 함
        const blob = bucket.file(
            `${req.user.username}/${Date.now()}-${req.file.originalname}`
        );
        // 이미지 업로드 스트림 생성
        const blobStream = blob.createWriteStream();

        // 에러 핸들링
        blobStream.on("error", (err) => {
            console.log("?");
            res.status(500).json({
                message: "업로드 중 오류가 발생했습니다",
                error: JSON.parse(JSON.stringify(err)),
            });
        });

        // 종료 처리
        blobStream.on("finish", () => {
            const publicUrl = format(
                `https://storage.googleapis.com/${bucket.name}/${blob.name}`
            );
            // 최종적으로 업로드 프로세스가 완료되는 시점
            res.status(200).json({
                message: "업로드 성공",
                url: publicUrl,
            });
        });

        // 업로드 스트림 실행
        blobStream.end(req.file.buffer);
    }
);

만약 이를 미들웨어로 분리한다면, req객체에 publicUrl을 담아서 next()해주면 다음 함수에서 접근해 사용할 수 있다.

2. 리사이즈 하기

위 작업을 할 때, 미리 리사이즈 하고 크롭을 해주어야 했다. 여기서는 몇 가지 패키지가 제시되었는데, 가장 최근 커밋이 있는 jimp를 사용하기로 했다. 설치는 yarn add jimp --save해주면 된다.

사용법은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import { Request, Response, NextFunction } from "express";
import * as Multer from "multer";
import * as jimp from "jimp";

const router = Router();
const CROPPED_IMG_SIZE = 200; // 200 * 200으로 자르기로 함
const multer = Multer({
    storage: Multer.memoryStorage(),
    limits: {
        fileSize: 2 * 1024 * 1024, // no larger than 2mb, you can change as needed.
    },
});

router.post(
    "/crop", // 파일을 하나만 가져오고, 키 값은 profileImage로
    multer.single("profileImage"),
    (req: Request, res: Response) => {
        if (!req.file) {
            res.status(400).json({
                message: "크롭할 이미지가 없습니다",
            });
            return;
        }

        // 이미지 리사이즈
        // 파일 읽어오기
        const jimpImg = await jimp.read(Buffer.from(req.file.buffer));

        // 가로가 더 긴 경우
        if (jimpImg.bitmap.width > jimpImg.bitmap.height) {
            // 리사이즈 하기
            jimpImg.resize(jimp.AUTO, CROPPED_IMG_SIZE);
            // 정 가운데를 기준으로 크롭하기
            jimpImg.crop(
                // x 좌표
                jimpImg.bitmap.width / 2 - CROPPED_IMG_SIZE / 2,
                // y 좌표
                0,
                // 너비
                CROPPED_IMG_SIZE,
                // 높이
                CROPPED_IMG_SIZE
            );
        }
        // 세로가 더 긴 경우
        else {
            jimpImg.resize(CROPPED_IMG_SIZE, jimp.AUTO);
            jimpImg.crop(
                // x 좌표
                0,
                // y 좌표
                jimpImg.bitmap.height / 2 - CROPPED_IMG_SIZE / 2,
                // 너비
                CROPPED_IMG_SIZE,
                // 높이
                CROPPED_IMG_SIZE
            );
        }
        const resized = await jimpImg.getBufferAsync(req.file.mimetype);

        res.setHeader("Content-Type", "image/jpeg");
        res.setHeader("Content-Length", resized.byteLength);
        res.write(resized);
    }
);

위와 같이 작성하면, 리사이즈 된 이미지를 응답 받을 수 있다.

3. 완성하기

이 두 스텝을 합쳐서 미들웨어로 만들었는데 그 내용은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import { Request, Response, NextFunction } from "express";
import { Storage } from "@google-cloud/storage";
import * as jimp from "jimp";
import { format } from "util";
import { Buffer } from "buffer";

const storage = new Storage();
const bucket = storage.bucket(process.env.GCLOUD_STORAGE_BUCKET);
const THUMNAIL_IMG_SIZE = 200;

export const uploadImage = async (
    req: Request,
    res: Response,
    next: NextFunction
) => {
    if (!req.file) {
        res.status(400).json({
            message: "업로드된 파일이 없습니다",
        });
        return;
    }

    // 이미지 리사이즈
    const jimpImg = await jimp.read(Buffer.from(req.file.buffer));
    if (jimpImg.bitmap.width > jimpImg.bitmap.height) {
        jimpImg.resize(jimp.AUTO, THUMNAIL_IMG_SIZE);
        jimpImg.crop(
            jimpImg.bitmap.width / 2 - THUMNAIL_IMG_SIZE / 2,
            0,
            THUMNAIL_IMG_SIZE,
            THUMNAIL_IMG_SIZE
        );
    } else {
        jimpImg.resize(THUMNAIL_IMG_SIZE, jimp.AUTO);
        jimpImg.crop(
            0,
            jimpImg.bitmap.height / 2 - THUMNAIL_IMG_SIZE / 2,
            THUMNAIL_IMG_SIZE,
            THUMNAIL_IMG_SIZE
        );
    }
    const resized = await jimpImg.getBufferAsync(req.file.mimetype);

    // 이미지 업로드 준비
    const blob = bucket.file(
        `${req.user.username}/${Date.now()}-${req.file.originalname}`
    );
    // 이미지 업로드 스트림 생성
    const blobStream = blob.createWriteStream();

    // 에러 핸들링
    blobStream.on("error", (err) => {
        console.log("?");
        res.status(500).json({
            message: "업로드 중 오류가 발생했습니다",
            error: JSON.parse(JSON.stringify(err)),
        });
    });

    // 종료 처리
    blobStream.on("finish", () => {
        const publicUrl = format(
            `https://storage.googleapis.com/${bucket.name}/${blob.name}`
        );
        req.gcpImgUrl = publicUrl;
        next();
    });

    // 업로드 스트림 실행
    blobStream.end(resized);
};

이렇게 작성해두고, 실제 사용하는 부분에서는 다음과 같이 사용했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { UserController } from "../controllers";
import * as Middlewares from "../middlewares";
import * as Multer from "multer";

const multer = Multer({
    storage: Multer.memoryStorage(),
    limits: {
        fileSize: 2 * 1024 * 1024, // no larger than 5mb, you can change as needed.
    },
});

router.post(
    "/:username/profile",
    // 기존 미들웨어의 인증 확인
    Middlewares.Auth.isAuthenticated,
    // 이미지 처리
    multer.single("profileImage"),
    // 리사이징 및 업로드 처리
    Middlewares.GCP.uploadImage,
    // 실제 컨트롤러 코드
    UserController.uploadProfileImage
);

위와 같이 사용할 경우, 파일 업로드 부분과 해당 주소를 다루는 부분을 분리 할 수 있어서 오류 제어에 도움이 되었다.