7차 세미나에서는 이미지를 업로드하고 검색 및 페이징 기능을 추가하는 API를 만드는 과정을 배워볼 수 있었다. 초반에 따라가다 놓쳐서 집와서 영상보고 복습한 건 안 비밀 ㅎㅎ.. 이미지 업로드 하는 것은 합동 세미나 때도 경험해볼 수 있었는데, 7차 세미나에서는 multerS3를 사용해서 로컬에 저장하지 않고 바로 AWS S3 버킷에 올릴 수 있는, 조금은 다른 방식으로 배워볼 수도 있었다. 또한 Nosql로 데이터를 검색하고 페이징하는 것도 배워볼 수 있었다 ~~
application/json
HTTP Body에 들어가는 Message의 Type을 명시
json 형식을 사용해서 HTTP Body를 전송하는 경우의 Content Typemultipart/form-data
Content-Type 필드에 MIME Type을 명시하기 위한 Content-Type
(MIME Type | .png, .jpg 등의 파일 타입)
File 전송을 위해 사용
5차 세미나까지에서 단순 텍스트의 Request Body를 받아올 때는 application/json 형식을 사용했다. 하지만 이미지 등과 같은 파일을 받아오기 위해서는 multipart/form-data 형식을 사용해줘야 함을 알 수 있었다.
Client에서 Server로 파일의 업로드 과정은 어떻게 이루어질까? 다음과 같이 이루어진다.
클라이언트는 Form을 통해서 파일을 서버로 전송
Content-Type이 multipart/form-data로 지정 되어 전송
서버는 해당 multipart 메시지를 part별로 분리하여 처리
서버에서 파일을 처리하고 업로드하는 데 도와주는 모듈은 아래와 같다.
multer : multipart/form-data로 전송된 파일 처리 미들웨어
multer-s3 : 이미지 업로드 시 S3을 사용할 경우 이용
aws-sdk : Node.js용 AWS SDK를 사용하기 위한 모듈
S3의 버킷으로 접근하기 위해서는 액세스 키가 필요하다. 생성하자!
우선 AWS 사이트로 접속하여 계정 부분을 눌러 보안 자격 증명을 클릭한다.
보안 자격 증명 페이지로 접속 후, 액세스 키(액세스 키 ID 및 비밀 액세스 키) 탭을 클릭한다.
그리고 새 액세스 키 만들기 클릭 후 키 파일 다운로드 클릭한다.
그럼 .csv 파일을 받을 수 있는데, 내부에 액세스 키 ID 정보가 있다. 한번 잃어버리면 다시 찾을 수 없으니 private한 공간에 잘 보관해두자. (public한 공간에 공개되면 과금 위험이 있으므로 공개되지 않도록 주의할 것)
> .env
# S3
S3_ACCESS_KEY="ACCESS_KEY"
S3_SECRET_KEY="SECRET_KEY"
BUCKET_NAME="버킷 이름"
.env 파일에 발급 받은 액세스 키 정보와 버킷 이름을 추가한다.
> src/config/index.ts
/**
* AWS S3
*/
s3AccessKey: process.env.S3_ACCESS_KEY as string,
s3SecretKey: process.env.S3_SECRET_KEY as string,
bucketName: process.env.BUCKET_NAME as string,
config의 index.ts 파일에 키 액세스와 버킷 이름을 .env에서 가져와 추가한다.
> src/config/s3Config.ts
import AWS from "aws-sdk";
import config from ".";
const s3: AWS.S3 = new AWS.S3({
accessKeyId: config.s3AccessKey,
secretAccessKey: config.s3SecretKey,
region: "ap-northeast-2",
});
export default s3;
s3Config.ts 파일에서, aws-sdk 모듈을 이용해 access key, secret access key, region을 params로 하는 AWS.S3 객체를 만들어 준다
> src/config/multer.ts
import config from ".";
import multer from "multer";
import multerS3 from "multer-s3";
import s3 from "./s3Config";
// 미들웨어로 사용할 multer 생성
const upload = multer({
storage: multerS3({
s3: s3,
bucket: config.bucketName,
contentType: multerS3.AUTO_CONTENT_TYPE,
acl: "public-read",
key: function (req: Express.Request, file: Express.MulterS3.File, cb) {
cb(null, `${Date.now()}_${file.originalname}`);
},
}),
});
export default upload;
(AWS 스터디 4주차에서는 multerS3 대신 diskstorage를 써서 uploads라는 경로에 이미지 파일을 추가로 저장했었는데, 여기서는 multerS3로 S3 버킷에 바로 업로드 하도록 했다.)
multer.ts 파일에서는 미들웨어로 사용할 upload를 지정한다.
s3: 실질적인 storage는 multerS3 이용해 aws s3로 설정
bucket: s3 bucket name 지정
contentType: mimetype은 자동으로 설정
acl: Access control for the file
key: 파일 이름 정의, bucket 내에서 이름이 겹치면 동일 파일로 인식해서 보통 고유하게 만든다.
originalname: 업로드한 파일의 원래 이름 즉, 그 이름으로 저장됨
> src/routes/FileRouter.ts
import { Router } from "express";
import upload from "../config/multer";
import FileController from "../controllers/FileController";
const router: Router = Router();
// single: 파일 1개, req.file로 받아옴 (key name : file)
router.post("/upload", upload.single("file"), FileController.uploadFileTo3);
export default router;
router에서 위에서 지정한 upload를 middleware로 사용한다.
😜 index.ts에 FileRouter 추가해주는 거 잊지말긔
> src/models/File.ts
import mongoose from "mongoose";
import { FileInfo } from "../interfaces/file/FileInfo";
const FileSchema = new mongoose.Schema(
{
link: {
type: String,
required: true,
},
fileName: {
type: String,
required: true,
},
},
{
timestamps: true, // createAt, updateAt 자동기록
}
);
export default mongoose.model<FileInfo & mongoose.Document>("File", FileSchema);
File Collection을 추가한다. 여기서는 파일 자체를 저장하는 것이 아니고 AWS 버킷에 이미지 저장 후, 해당 이미지의 링크(link)를 받아와 저장하는 방식이다
> src/interfaces/file/FileInfo.ts
export interface FileInfo {
link: string;
fileName: string;
}
File.ts에서 export 할 때 FileInfo.ts를 생성해서 type으로 사용한다.
> src/controllers/FileController.ts
import express, { Request, Response } from "express";
import message from "../modules/responseMessage";
import statusCode from "../modules/statusCode";
import util from "../modules/util";
import FileService from "../services/FileService";
const uploadFileToS3 = async (req: Request, res: Response) => {
// validation 처리
if (!req.file) return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, message.BAD_REQUEST));
// req.file은 기본 Express.Multer.File 타입으로 추론되어서 MulterS3.File로 타입 단언
const image: Express.MulterS3.File = req.file as Express.MulterS3.File;
// multer upload 미들웨어에서 s3에 저장된 파일 주소는 req.file.location에 존재
const { originalname, location } = image;
try {
const data = await FileService.createFile(location, originalname);
res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, message.CREATE_FILE_SUCCESS, data));
} catch (error) {
console.log(error);
res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
}
};
export default {
uploadFileToS3,
};
request body에서 file을 받아와 FileService로 넘겨서 받은 결과를 반환한다.
> src/services/FileService.ts
import { FileResponseDto } from "../interfaces/file/FileResponseDto";
import File from "../models/File";
const createFile = async (link: string, fileName: string): Promise<FileResponseDto> => {
try {
const file = new File({
link,
fileName,
});
await file.save();
const data = {
_id: file._id,
link,
};
return data;
} catch (error) {
console.log(error);
throw error;
}
};
export default {
createFile,
};
controller에서 받아온 link와 fileName을 저장하고, 저장한 데이터의 id와 link 값을 반환한다.
😜 index.ts에서 FileService 추가해주는 거 잊지말자
> src/interfaces/FileResponseDto.ts
import mongoose from "mongoose";
export interface FileResponseDto {
_id: mongoose.Schema.Types.ObjectId;
link: string;
}
service 파일에서 반환하는 타입인 FileResponseDto 파일
🖐 API 테스트 전에 ! 버킷 acl을 활성화 해주어야 한다. (안해주면 권한 문제로 API 에러남!)
s3 bucket > 권한 > ACL > 편집
으로 들어가서 객체 소유권에 ACL 활성화하고 변경사항을 저장해주자!!
그리고 API 테스트를 해주자. File이 포함되어 있으므로 request body를 넘겨줄 때, Body의 form에서 file을 업로드하여 넘겨주고 테스트 해야한다.
🙄 근데 난 왜 자꾸 에러 나지 .. (에러 났으면 주목)
{
"name": "node-typescript-init",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon",
"build": "tsc && node dist"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/express": "^4.17.13",
"@types/jsonwebtoken": "^8.5.8",
"@types/mongoose": "^5.11.97",
"@types/multer": "^1.4.7",
"@types/multer-s3": "^2.7.12",
"@types/node": "^17.0.25",
"nodemon": "^2.0.15",
"ts-node": "^10.7.0",
"typescript": "^4.6.3"
},
"dependencies": {
"aws-sdk": "^2.1143.0",
"bcryptjs": "^2.4.3",
"dotenv": "^16.0.0",
"express": "^4.17.3",
"express-validator": "^6.14.0",
"jsonwebtoken": "^8.5.1",
"mongoose": "^6.3.1",
"multer": "^1.4.4",
"multer-s3": "^2.10.0"
}
}
package.json에 위의 코드로 갈아넣고 터미널에 yarn 쳐준 후 재시도 하니까 성공했다. (덜 깔린 게 있었나 봐 ㅇㅅㅇ..)
🙄 혹시 그래도 안돼면.. AWS 4주차 회고 글을 보면서 IAM으로 키 생성 후 .env 파일에 새로 넣어서 해보는 것도 추천한다.
이제..!! 이미지 여러 장을 한번에 보내고 저장해보자!!!
> src/controllers/FileController.ts
const uploadFilesToS3 = async (req: Request, res: Response) => {
// 유효성 체크
if (!req.files) return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, message.BAD_REQUEST));
const images: Express.MulterS3.File[] = req.files as Express.MulterS3.File[];
try {
/**
* 받아온 file들을 가공하는 작업
* Promise.all을 이용해서 서로 연관성 없는 작업은 동시에 실행 (시간 줄이기용)
*/
const imageList: {
location: string,
originalname: string
}[] = await Promise.all(images.map((image: Express.MulterS3.File) => {
return {
location: image.location,
originalname: image.originalname
};
}));
const data = await FileService.createFiles(imageList);
res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, message.CREATE_FILE_SUCCESS, data));
} catch (error) {
console.log(error);
res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
}
}
controller 파일에 여러장 업로드 API를 추가해주자. request body에서 받아온 파일들을 가공하여 service로 보내고 받은 data를 반환한다.
> src/services/FileService.ts
const createFiles = async (imageList: { location: string, originalname: string }[]): Promise<FileResponseDto[]> => {
try {
const data = await Promise.all(imageList.map(async image => {
const file = new File({
link: image.location,
fileName: image.originalname
});
await file.save();
return {
_id: file._id,
link: file.link
};
}));
return data;
} catch (error) {
console.log(error);
throw error;
}
}
service 파일에도 추가해주자. imageList를 param으로 받아와서 배열의 각 이미지를 저장하고 해당 이미지의 id와 link를 각각 받아온 배열로 반환한다.
> src/routes/FileRouter.ts
/**
* array: 파일 여러개, req.files로 받아옴
* upload.array('file name', maxCount) => 최대 개수 지정 가능
*/
router.post('/uploads', upload.array('file'), FileController.uploadFilesToS3);
single 대신 array로 지정하여 여러 개 파일을 받아올 수 있도록 한다.
maxCount로 최대 파일 개수를 지정할 수도 있다.
눈물 겨운 API 테스트 성공 😭
버킷 확인해보면 파일 잘 올라갔고
DB(compass)에도 잘 저장된 거 확인했다 ㅎㅎ
이전 세미나에서 영화와 리뷰를 생성하는 API 만드는 것을 배웠었다.
이번 세미나에서는 영화 제목으로 검색하는 API 만드는 법도 배웠다 ㅎㅎ
정규 표현식
문자열에서 특정 문자 조합을 찾기 위한 패턴
JS에서는 정규 표현식 역시 객체리터럴 방법: let exp = /abc/
RegExp 생성자 방법: let exp = new RegExp('abc')
정규 표현식에 대해 정리해보았다. 이번 실습에서는 RegExp를 사용했다.
> src/controllers/MovieController.ts
/**
* @route GET /movie?search=
* @desc Get Movie By Search
* @access Public
*/
const getMovieBySearch = async (req: Request, res: Response) => {
const { search } = req.query;
try {
const data = await MovieService.getMovieBySearch(search as string);
res.status(statusCode.OK).send(util.success(statusCode.OK, message.SEARCH_MOVIE_SUCCESS, data));
} catch (error) {
console.log(error);
res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
}
}
controller에 service로 검색어를 넘겨서 받은 영화 리스트 데이터를 반환하는 코드를 추가한다.
> src/services/MovieService.ts
const getMovieBySearch = async (search: string) => {
const regex = (pattern: string) => new RegExp(`.*${pattern}.*`);
try {
const titleRegex: RegExp = regex(search);
const movies = await Movie.find({ title: { $regex: titleRegex } });
return movies;
} catch (error) {
console.log(error);
throw error;
}
}
service에서 RegExp 정규식을 이용해서 pattern에 포함되는 제목을 가지는 영화 리스트를 찾아서 반환한다.
> src/interfaces/movie/MovieInfo.ts
export interface MovieInfo {
title: string;
director: string;
startDate: Date;
thumbnail: string;
story: string;
comments: MovieCommentInfo[];
}
MovieInfo.ts 참고
> src/routes/MovieRouter.ts
router.get('/', MovieController.getMovieBySearch);
라우터 추가하기
테스트 결과 검색에 성공했다 ㅎㅎ
제목만 검색, 내용만 검색, 제목+내용 검색과 같이 블로그에서 검색 조건을 많이 봤을 것이다. 그것도 구현했다!!
option=title : 제목 검색
option=director : 감독 검색
option=title_director : 제목 or 감독 검색
위의 3개 옵션을 두고 검색 옵션 기능을 추가했다!
> src/interfaces/movie/MovieOptionType.ts
export type MovieOptionType = 'title' | 'director' | 'title_director';
영화 옵션 interface 파일을 생성하자.
> src/controllers/MovieController.ts
const getMovieBySearch = async (req: Request, res: Response) => {
const { search, option } = req.query;
const isOptionType = (option: string): option is MovieOptionType => {
return ['title', 'director', 'title_director'].indexOf(option) !== -1;
}
if (!isOptionType(option as string)) {
return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, message.BAD_REQUEST));
}
try {
const data = await MovieService.getMovieBySearch(search as string, option as string);
res.status(statusCode.OK).send(util.success(statusCode.OK, message.SEARCH_MOVIE_SUCCESS, data));
} catch (error) {
console.log(error);
res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
}
}
위에서 만든 영화 검색 contoller 메소드에 option을 추가해서 변경해주자. (query로 받아온 option 값이 제목, 감독, 제목 또는 감독 중에 없으면 에러도 날려줄 것이다.)
> src/services/MovieService.ts
const getMovieBySearch = async (search: string, option: MovieOptionType): Promise<MovieInfo[]> => {
const regex = (pattern: string) => new RegExp(`.*${pattern}.*`);
let movies: MovieInfo[] = [];
try {
const titleRegex: RegExp = regex(search);
if (option === 'title') {
movies = await Movie.find({ title: { $regex: titleRegex } });
} else if (option === 'director') {
movies = await Movie.find({ director: { $regex: titleRegex } });
} else {
movies = await Movie.find({
$or: [
{ title: { $regex: titleRegex } },
{ director: { $regex: titleRegex } }
]
});
}
return movies;
} catch (error) {
console.log(error);
throw error;
}
}
위에서 만든 service 파일의 메소드에서 옵션을 추가하여 수정한다.
여기서 $or
은 title이나 director에 포함됨을 의미한다. ($and
인 경우는 title에도 포함되고 director에도 포함되어야 한다)
director 옵션으로 데이터 검색에 성공했다 !~!~!
페이징도 구현해보자! 데이터를 리스트로 너무 많이 받아오면 보기 힘들다 @! 그래서 페이지 별로 나눠 적당한 양만큼 씩 보여주기 위한 것!
> src/controllers/MovieController.ts
const getMovieBySearch = async (req: Request, res: Response) => {
const { search, option } = req.query;
const isOptionType = (option: string): option is MovieOptionType => {
return ['title', 'director', 'title_director'].indexOf(option) !== -1;
}
if (!isOptionType(option as string)) {
return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, message.BAD_REQUEST));
}
const page: number = Number(req.query.page || 1);
try {
const data = await MovieService.getMovieBySearch(search as string, option as MovieOptionType, page);
res.status(statusCode.OK).send(util.success(statusCode.OK, message.SEARCH_MOVIE_SUCCESS, data));
} catch (error) {
console.log(error);
res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
}
}
페이지네이션을 위해 쿼리에 페이지 번호를 추가해줬으니 page 옵션도 추가해주자!
> src/services/MovieService.ts
const getMovieBySearch = async (search: string, option: MovieOptionType, page: number): Promise<MoviesResponseDto> => {
const regex = (pattern: string) => new RegExp(`.*${pattern}.*`);
let movies: MovieInfo[] = [];
const perPage: number = 2;
try {
const titleRegex: RegExp = regex(search);
if (option === 'title') {
movies = await Movie.find({ title: { $regex: titleRegex } })
.sort({ createAt: -1 }) // 최신순 정렬
.skip(perPage * (page - 1))
.limit(perPage);
} else if (option === 'director') {
movies = await Movie.find({ director: { $regex: titleRegex } })
.sort({ createAt: -1 }) // 최신순 정렬
.skip(perPage * (page - 1))
.limit(perPage);
} else {
movies = await Movie.find({
$or: [
{ title: { $regex: titleRegex } },
{ director: { $regex: titleRegex } }
]
})
.sort({ createAt: -1 }) // 최신순 정렬
.skip(perPage * (page - 1))
.limit(perPage);
}
const total: number = await Movie.countDocuments({}); // 모든 document 개수
const lastPage: number = Math.ceil(total / perPage);
const data = {
movies,
lastPage
}
return data;
} catch (error) {
console.log(error);
throw error;
}
}
MovieService에서 페이지네이션을 위해 page별로 데이터를 가져오는 로직을 추가한다.
perPage: 페이지 당 개수
sort: createAt 기준으로 -1 (최신순) 정렬
skip: 앞에서부터 얼마나 건너뛸지
limit: 개수 제한
Model.countDocuments({}): 모든 도큐먼트 개수 반환
lastPage: 전체 Document 개수 / 페이지 당 개수 (올림)
페이지네이션 추가해서 받아온 데이터들을 MoviesResponseDto 형식으로 반환해준다.
> src/interfaces/movie/MoviesResponseDto.ts
import { MovieInfo } from "./MovieInfo";
export interface MoviesResponseDto {
movies: MovieInfo[];
lastPage: number;
}
MoviesResponseDto : 영화 리스트와 마지막 페이지 번호 넘겨주는 형식
1페이지에 2개 ~
2페이지에 1개 ~
성공...🥰
정리하자. 이미지 업로드! Request body로 받아오고, 미들웨어 (upload)에서 이미지를 S3에 저장하고, controller에서는 해당 이미지의 링크로 저장한다.
계속 생각했던 거지만 이미지 업로드를 너무 어렵게만 생각했던 것 같다 ㅠㅠ (물론 어려운 거 맞음) 세미나와 스터디를 통해서 이미지 업로드에 전보다 익숙해진 것 같아서 뿌듯하다 ㅎㅎ
NoSQL로 해당 데이터를 불러오고 페이지네이션도 구현할 수 있는 것도 신기했다 !