저에게 엄청난 영감을 주신 코드캠프의 노원두
강사님 감사합니다.
이번에 개인 프로젝트의 목표는 React의 신기술을 최대한 사용해보기이다. 그래서 공식문서를 토대로 새로운 기술들을 사용해보려고 한다. 그 중 하나가 Server Action이다.
React 18버전부터 도입된 기능으로, server에서 특정 작업(action
)을 수행하고 그 결과를 client로 반환하는 방식이다. server와 client 간의 작업 분리를 통해 server 측에서 data fetching
을 효율적으로 수행할 수 있다.
Server Action을 사용하는 이유
- server 와 client를 분리
server에서는data fetching
등을 수행하고 client에서는 UI와 상호작용하는 작업에만 집중할 수 있다.- 데이터 전송의 효율성
Server Action을 통해 server에서 작업을 수행한 후 필요한 데이터만 client로 전송할 수 있기 때문에비용 절감 및 성능 향상
을 할 수 있다.- 더 나은 사용자 경험 제공
server에서 작업을 처리한 후 client 전달하기 때문에, 빠른 응답 시간을 제공할 수 있다. 예를 들어, server에서 미리 데이터 처리와 검증을 하고, 결과를 client에 바로 반영할 수 있다.
간단하게 server action에 대해서 알아보았다. 그럼 이제부터 server action을 이용하여 aws S3를 접근하는 과정을 알아보자. 사실 내가 했던 뻘짓들을 정리해보겠다.
프론트엔드 개발자면 백엔드 지식 알 필요가 없지 않아? 라는 사람에게 해주고 싶은 말이 있다. 진짜. 백엔드를 완벽하게 알 필요는 없지만 백엔드의 핵심 개념들을 알아야 한다고 생각한다. 그래야 백엔드 개발자와의 Communication
이 될 것이고, 이는 작업 속도를 향상시킬 수 있을 것이다. 그래서 이번에 나는 React & Next 신기능들을 적용시켜보면서 NestJS
까지 즉, fullStack Developer
가 되어서 개발자가 되기 위한 초석을 쌓을 것이다.
프로젝트 할 때, 백엔드 개발자들은 이미지 관련된 게 나오면 싫어했었다. 나는,, 뭣도 모르고,, 이미지 하자고 했었는데, 해야할 일이 많아진다면서,, 그땐 몰랐지.. 이미지가 얼마나 복잡한지.. 막상 해보니깐 별거 없는 거 같기두? 이번 개인 프로젝트를 진행하면서 이미지 저장
도 해보려고 한다. 허허...
필자가 백엔드에서 사용하는 기술
NestJS / TypeORM / MongoDB / GraphQL
현재 나는 많은 것을 경험해보기 위해 NestJS로 REST API 뿐만 아니라 GraphQL 통신도 공부하고 있다. 즉 NestJS로 말하자면 Controller 와 Resolver 동시에 존재해야한다. 다음은 내 백엔드 코드 폴더의 일부이다.
원래 기존에는,, Module( controller - service - repository )
이런 형태로 NestJS를 구성했을 것이다. GraphQL
같은 경우 controller 역할을 하는 것이 즉, Query와 mutation을 받아오는 게 resolver 파일이다.
RESTAPI를 먼저 개발하였고, 나는 당연하게도 DTO & Entity를 먼저 만들었다. 우리에게는 class끼리 상속할 때 사용하는 extends라는 게 있다. 그래서 난 상속을 사용하고 위해서.. DTO에 존재하는 파일을 그대로 상속해서 Entity에 사용했다.. 이게 시발점이다.. 하.. 처음에는 사실 "오 재사용성을 높이면서 중복된 코드를 사용하지 않네 ? 개 꿀이네? 라는 생각이 들었다. 그러면서도 어리석게.. 오.. 이게 TypeScript인가 하면서.. extends를 남발했던 거 같다. 다음은 DTO와 Entity를 상속을 통해 작성한 코드이다.. 심지어 GraphQL의 Schema까지.. @Field()
...
// createBoardDTO
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import { Column } from 'typeorm';
@InputType()
@ObjectType()
export class CreateBoardDto {
@IsString()
@IsNotEmpty()
@Column()
@Field()
author: string;
@IsString()
@IsNotEmpty()
@Column()
@Field()
title: string;
@IsString()
@IsNotEmpty()
@Column()
@Field()
content: string;
@IsArray()
@IsOptional()
@IsString({ each: true })
@ArrayMaxSize(3)
@Column('array')
@Field(() => [String], { nullable: true })
imageUrl?: string[];
@IsString()
@IsOptional()
@Column()
@Field({ nullable: true })
youtubeUrl?: string;
@IsString()
@IsNotEmpty()
@Column()
@Field()
password: string;
@IsString()
@IsOptional()
@Column()
@Field({ nullable: true })
address?: string;
@IsString()
@IsOptional()
@Column()
@Field({ nullable: true })
detailAddress?: string;
}
// Board-Entity
import {
Column,
CreateDateColumn,
Entity,
ObjectId,
ObjectIdColumn,
UpdateDateColumn,
} from 'typeorm';
import { Field, ID, Int, ObjectType } from '@nestjs/graphql';
import { CreateBoardDto } from '../dto/create-board.dto';
@Entity()
@ObjectType()
export class Board extends CreateBoardDto {
@ObjectIdColumn()
@Field(() => ID)
_id: ObjectId;
@Column()
@Field(() => Int)
boardId: number;
@CreateDateColumn()
@Field(() => Date)
createdAt: Date;
@UpdateDateColumn()
@Field(() => Date)
updatedAt: Date;
}
이 때만 해도.. 우와.. 이게 상속 되게 잘했네?라는 생각을 했었다.. 하지만 BUT 코드를 작성하면서 계속 느꼈던 게 있다.
그것은 바로 가독성이다.. 분리를 안 하다보니깐,, 너무너무너무너무너무너무 가독성이 떨어졌다. 방금 짠 코드 조차도.. 내가 어떤 코드를 작성했드라 할 정도이다.. 당연히 내가 못해서 그럴 수도 있는데,, 나한테는 가독성이 너무 좋지 않았다. 그래서 하루종일 DTO / Entity / Schema를 분리하는 작업을 진행하였다..
// DTO
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
export class CreateBoardDTO {
@IsString()
@IsNotEmpty()
author: string;
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
content: string;
...
// Entity
import {
Column,
CreateDateColumn,
Entity,
ObjectId,
ObjectIdColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('board')
export class BoardEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column()
author: string;
@Column()
password: string;
@Column()
title: string;
...
}
// Schema
import { Field, ID, Int, ObjectType } from '@nestjs/graphql';
import { BoardAddressOutput } from './board-address-input.schema';
import { ObjectId } from 'typeorm';
@ObjectType()
export class BoardSchema {
@Field(() => ID)
_id: ObjectId;
@Field()
author: string;
@Field()
password: string;
@Field()
title: string;
@Field()
content: string;
...
}
코드가 늘어나고 파일도 많아졌지만 그만큼 장점이 생겼다!! 그것은 가독성과 각 파일별로 하는 역할이 명확하다는 것이다. Clean Code에 의거하여 각 파일이 명확한 역할과 책임을 가지라고 했기 때문에, 나도 각 파일별 (DTO / Entity / Schema)로 정확히 하는 역할을 나누었다. 이러니깐 확실히 가독성이 너무나도 높아졌다. 절대 하루 버렸다고 생각 안함. 이런 게 쌓여야 내가 좋은 개발자가 될 수 있다고 생각함. 분리끝~!
처음에는 이미지 그냥 간단한게 저장하자.. 단,, 데이터베이스에서 저장하지는 말고.. 라는 생각을 가졌었다. 그 다음 필자는 AWS에서 벽을 느꼈기 때문에 cloud storage인 AWS S3를 사용하기가 무서웠다. 그래서 NestJS 공식문서에서는 어떤식으로 이미지를 저장할까? 라는 생각으로 공식문서에 들어가보았다. NestJS 공식문서 File Upload
오 multer를 사용해서 이미지를 저장하는구나? 그럼 multer
가 뭐지 라는 생각이 들었다.
Multer
는 Node.js 환경에서 파일 업로드를 처리하기 위한 middleware이다. 주로 Express.js와 같은 웹 프레임워크
와 함께 사용되며, multipart / form-data 형식으로 전송된 파일을 처리하는 데 특화되어 있다. Multer
를 사용하면 서버 측에서 HTTP 요청에 포함된 파일 데이터를 쉽게 추출하고 처리할 수 있다.
Multer의 주요 기능
파일 업로드 처리 : Multer는 client가 업로드한 파일을
서버에 저장하거나 메모리에 저장
할 수 있도록 도와준다.파일 업로드 경로나 메모리
에 저장할 경우 버퍼 형태로 파일을 다룰 수 있다.
파일 필터링 : 특정 파일 형식만 업로드를 허용할 수 있다. 예를 들어, 이미지 파일만 허용하거나 특정 크기의 파일만 업로드하도록 제한할 수 있다.
저장소 설정 : Multer는 파일을 저장할 때디스크 저장소(로컬 파일 시스템) 또는 메모리 저장소
를 사용할 수 있다. 디스크 저장소를 사용할 경우 파일이 서버의 특정 디렉토리에 저장되며, 메모리 저장소를 사용할 경우 파일이 메모리에 버퍼 형태로 저장된다.
쉽게 설명하면 node.js image를 처리하는데 사용하는 middleware라고 생각하면 편하다.
Disk Storage : Disk Storage는 파일을 server
의 로컬 파일 시스템에 저장한다. Multer는 설정된 destination 경로에 파일을 저장하고, 파일명은 filename 설정에 따라 지정된다. 예를 들어, /uploads 폴더
에 파일을 저장하도록 설정할 수 있다. 파일 저장 경로와 파일명을 알기 때문에, 클라이언트가 GET 요청을 통해 해당 파일을 가져올 수 있다. 예를 들어, 서버에서 /uploads/myimage.jpg 같은 경로로 파일을 제공할 수 있다.
Memory Storage: Memory Storage 파일을 서버 메모리의 buffer에 저장한다. 즉, 파일이 디스크에 저장되지 않고 메모리에 저장된 상태로 req.file.buffer 속성에 저장된다. 메모리에 저장된 파일은 서버가 종료되면 사라지며, 임시적으로 파일을 처리할 때 유용하다. 예를 들어, 업로드된 파일을 즉시 클라우드 스토리지로 전송하거나 데이터베이스에 저장하고 싶은 경우에 사용된다. client에게 파일을 제공할 때는 파일을 별도로 저장한 후에 제공해야 하므로, 직접적인 파일 경로를 제공할 수 없다.
실질적으로 Memory Storage를 사용하게 된다면 영구적으로 이미지를 저장할 수 없기 때문에 필자는 Disk Storage를 사용하려고 했다.
disk storage는 disk를 사용하기 때문에, disk storage에 저장할 때 서버의 디스크에 직접 기록하기 때문에 네트워크 속도 및 disk I/O 성능에 따라 느려질 수 있다. 로컬의 디스크 용량에 의존하기 때문에 대용량 이미지 처리하게 되면 디스크 공간이 부족해질 수 있다. 또한, 보안문제를 생각해보자면, client가 업로드한 파일의 형식이나 크기를 검사하지 않으면 악성 파일이 서버에 저장될 수 있다. 따라서 파일 필터링, 크기 제한, MIME 타입 검사
가 필요하다. 저장된 파일의 경로가 외부에 노출될 경우, 악의적인 사용자가 파일을 무단으로 접근할 수 있다.
그럼 결론적으로 AWS S3를 사용해야한다..하.. AWS 무서워 위 그림은,, 내가 생각했던 로직이다.
- Client -> Server - user가 image를 포함한 Form-Data를 전송한다.
- Server -> Multer - server는 Multer를 사용해 image를 buffer memory에 임시 저장한다.
- Multer -> Server - image를 제외한 rest data를 DB에 저장한다. 이 때, transaction을 실행해 중간에 문제가 발생하면 롤백하여 모든 작업을 취소한다.
- Server -> S3 - Multer에 buffer memory에 존재하는 image를 s3로 전송한다.
- S3 -> Server - 성공적으로 S3에 저장되면 transaction을 commit하여 DB에 S3에서 받아온 URL을 저장한다. 단, 업로드가 실패하면 transaction을 rollback하여 모든 작업을 취소시킨다.
- Server -> Client - 모든 작업이 성공적으로 완료되면 data를 반환하고, 실패하면 error를 반환한다.
오디서 찾아봤드라 기억이 안나는데, 실제 실무에선 이런식으로 이미지를 저장한다고 봤었다. 그래서 나도 나만의 flow를 작성해봤다. 일단 위와 같은 flow를 진행하기 위해서는 Multer와 S3를 Setting을 먼저 진행해야한다.
// controller
@Post()
@UseInterceptors(FilesInterceptor('image'))
createTestImage(@UploadedFiles() files: Express.Multer.File) {
return files;
}
@UseInterceptors()
데코레이터를 사용하여 image 파일을 가로챈다. 이 때, FilesInterceptor를 이용하여 실제 어떤 것을 가로채고, option들을 setting하는데, 첫 번째 parameter로 multipart/form-data
에서 name에 해당하는 값을 받고 두 번째 parameter로 첫 번째 인자로 받는 데이터를 어떤식으로 저장할 지에 대한 option을 처리한다.
dest / storage / limits / etc를 설정할 수 있다. 자주 쓰이는 건 dest와 storage이다. dest란 Multer에게 파일을 어디로 업로드 할 지를 알려준다. stoarge는 어떤 storage를 사용할 지 정한다. 위에 설명한 바와 같이 DiskStorage와 MemoryStorage
가 존재한다.
AWS Setting 같은 경우... 너무 복잡하고,, 공식문서가 더 자세히 적혀 있기 때문에 구글링해서 검색해보시거나, 공식문서를 보는 걸 추천드립니다.
일단 그림으로만 봐도 너무 복잡하다... 너무 사소한 건 생략했지만, 그래도 너무 복잡하다. 이미지를 따로 server memory에 임시 저장하고, 나머지 데이터를 DB에 저장한다음 성공적으로 저장되면 그 때, S3로 보내서 이미지 url을 받아오면, DB에 다시 또 Url을 추가하여 저장하는 게.. ㅋㅋㅋㅋㅋㅋ 또 그러면서, 만약 여기서 뭐라도 에러가 발생하면 다시 transaction을 통해 롤백해야한다는게. 사실 다음 거 듣고, 여기 부분 제대로 코드를 작성하지 않았다. 그럼 어떤 식으로 하면 좀 더 효율적으로 코드를 짤 수 있을까? 고민하다가. 노원두 강사님께서 너무나도 좋은 해결방안을 주셨다. 그것은 바로~
크으 그림만 봐도 너무 깔끔하다. 일단 진짜 너무 깔끔하다.
일단 Next는 React에서 지원하지 않는 가장 큰 특징이 있다. 그것은 바로 Next만의 서버가 있다는 것이다. 서버를 갖고 있다는 장단점이 존재하겠지만, 현재 Next를 이용하기 때문에 최대한 Next의 이점을 사용해보려고 한다.
Client에서 S3를 접근할 수 있지 않아요?
- 보안 문제
- S3 접근 키 노출 위험 : client에서 직접적으로 S3를 접근하게 된다면, key를 노출할 수 있는 가능성이 생긴다. 예를 들어 client code에 key가 포함될 수도 있고, network request.
- bucket auth setting : client에서 s3를 접근하게 되면 버킷 권한을 더 넓혀야한다. 예를들어서, 우리는 모든 사용자가 읽기 또는 쓰기 권한을 부여해줘야하는데, 이는 데이터 유출 & 무단 데이터 수정등의 보안 위험을 초래할 수 있다.- 데이터 검증 문제
- 서버를 거치지 않고 데이터를 직접 업로드하게 된다면 서버에서 검증하는 데이터 로직보다 엄격하게 데이터 검증을 할 수 없다.
- 악의적으로 user가 악의적인 파일을 S3에 업로드해질 수 있다는 얘기다.- CORS(cross-origin resource Sharing) 문제
- CORS는 브라우저에서 발생시키기 때문에, S3에서 CORS 설정을 제대로 하지 않으면 정당한 user조차도 파일 업로드나 다운로드에 문제를 발생시킬 수 있다.
위와 같은 이유로 client에서 직접적으로 S3를 접근하는 건 바람직하지 않다.
부끄럽지만 다음은 필자 코드이다. useActionState
const initialState = {
data: null,
errors: {
author: '',
password: '',
title: '',
content: '',
general: '',
},
};
export default function Form() {
const onClickPush = useOnClickPush();
const path: string = usePathname();
const [state, formAction] = useFormState(createBoardAction, initialState);
if (state.data) {
onClickPush(`${boardsUrlEndPoint}/${state.data.boardId}`);
}
return (
<form className="flex flex-col gap-10" action={formAction}>
<div className="flex gap-10 border-b-[1px] border-gray-200 pb-10">
<NewInputContainer title={ETitle.Author} error={state.errors.author} />
<NewInputContainer title={ETitle.Password} error={state.errors.password} />
</div>
<NewInputContainer title={ETitle.Title} error={state.errors.title} />
<NewTextarea title={ETitle.Content} error={state.errors.content} />
<NewAddressInputContainer title={ETitle.Address} />
<NewInputContainer title={ETitle.YoutubeUrl} />
<NewImageContainer />
<div className="flex w-full items-center justify-end gap-4">
<CommonButton title={EButtonTitle.Cancel} />
<CommonButton
title={path.includes('edit') ? EButtonTitle.Update : EButtonTitle.Sumbit}
/>
</div>
</form>
);
}
현재 Form
이라는 Component를 만들었고, React 18 버전의 useFormState(useActionState)를 사용하였다. 왜냐고? react에서 hook으로 사용하라는데 다른 라이브러리(React-hook-form)을 사용할 이유가 전혀 없다고 느껴졌기 때문이다. 당연히 실험적인 기능이라 사용하기 껄끄러울 수도 있는데, 이번 개인 프로젝트 목표가 react & next 최신기술을 많이 사용해보는 것이다.
import { useActionState } from "react";
async function increment(previousState, formData) {
return previousState + 1;
}
function StatefulForm({}) {
const [state, formAction] = useActionState(increment, 0);
return (
<form>
{state}
<button formAction={formAction}>Increment</button>
</form>
)
}
다음은 react에서 제공하는 useActionState
이다. 사용은 어렵지 않다. 만약 form 내부에서 onSubmit이 실행되면, useActionState에서 넣은 첫 번째 argument 콜백 함수가 실행된다. 즉, 위 예시 코드에서는 increment가 실행된다는 것이다.
increment에서 첫 번째 parameter로 previosState
말 그대로 state 전의 값을 들고 온다.
setState((prev) => prev + 1)
우리는 useState를 사용하면서 이런식으로 state를 변경한 경험이 있을 것이다. 같이 useActionState 또한 마찬가지이다.
두 번째 parameter로 FormData를 들고올 수 있다. 즉 onSubmit
이 실행되면, input value를 formData 형식으로 받을 수 있다는 것이다. 이는 다양한 데이터 처리가 쉬워지면서, 효율적인 에러 처리를 할 수 있다.
// 예시
export async function createPostAction(formData: FormData) {
// Step 1: 필수 입력값 검증
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const image = formData.get('image') as File;
if (!title || !content) {
throw new Error('제목과 내용은 필수 입력값입니다.');
}
// Step 2: 이미지 파일 유효성 검사
const maxSize = 5 * 1024 * 1024; // 5MB
if (image && (image.size === 0 || image.size > maxSize || !['image/jpeg', 'image/png'].includes(image.type))) {
throw new Error('유효하지 않은 이미지 파일입니다. (최대 5MB, JPEG 또는 PNG 형식만 허용)');
}
}
interface 같은 경우 생략하겠다.
const initialState: IFormStateError = {
data: null,
errors: {
author: '',
password: '',
title: '',
content: '',
general: '',
},
};
export default function Form() {
const onClickPush = useOnClickPush();
const path: string = usePathname();
const [state, formAction] = useFormState(createBoardAction, initialState);
if (state.data) {
onClickPush(`${boardsUrlEndPoint}/${state.data.boardId}`);
}
return (
<form className="flex flex-col gap-10" action={formAction}>
...
)
}
즉 내 코드로 설명을 다시하자면, 현재, form
태그에 action 부분에 formAction을 넣어놓고 onSubmit을 실행하면 useFormState
의 첫 번째 argument인 creatBoardAction
이 실행되는 것이다. createBoardAction
의 첫 번째 parameter는 prevState 즉, initialState가 들어오는 것이고, 두 번째 parameter로 input value가 담겨있는 FormData가 들어오는 것이다.
'use server';
import { ICreateFormBoard, IFormLower } from '@/models/board.type';
import { IFormStateError } from '@/models/formBoardError';ㅏ
import { filterFormRequire } from '@/utils/filterFormRequire';
import { isValidImage } from '@/utils/isValidImage';
import postBoard from '../apis/boards/postBoard';
import uploadImageS3 from '@/apis/boards/uploadImageS3';
const requiredFields = ['Author', 'Password', 'Title', 'Content'];
export async function createBoardAction(
prevState: IFormStateError,
formData: FormData,
): Promise<IFormStateError> {
const fieldValues = Object.fromEntries(
requiredFields.map((key) => [key.toLowerCase(), formData.get(key) as string]),
) as unknown as IFormLower;
const youtubeUrl = (formData.get('YoutubeUrl') as string) || '';
const address = (formData.get('Address') as string) || '';
const detailAddress = (formData.get('DetailAddress') as string) || '';
let images = (formData.getAll('image') as File[]) || [];
filterFormRequire(fieldValues, requiredFields);
images = images.filter((image) => image.size !== 0);
let imageUrl: string[] = [];
if (images.length > 0) {
if (!filterFormImage(images)) actionHandleError({}, '이미지 형식에 맞지 않습니다.');
imageUrl = await uploadImageS3(images);
}
const finalData: ICreateFormBoard = {
...fieldValues,
imageUrl,
youtubeUrl,
address,
detailAddress,
};
return await postBoard(finalData);
}
너무 기니 한 줄 한 줄씩 알아보겠다.
const fieldValues = Object.fromEntries(
requiredFields.map((key) => [key.toLowerCase(), formData.get(key) as string]),
) as unknown as IFormLower;
우리는 종종 Object.entires를 사용해본 적이 있을 것이다. 쉽게 설명하자면 객체 -> 배열로 변경할 때 사용한다.
그럼 위 코드에서 사용한 Object.fromEntires는 그 반대이다. 배열에서 -> 객체로 변경할 때 사용한다. 왜 필자는 Object.fromEntires 사용했는가? 이유는 없다. 우리는 for...in / Object.key or Object.value & array es6 고차함수 / Array.map & Object.assign / forEach
방식으로 위 문제를 해결할 수 있을 것이다. 근데 난 최신 기술을 최대한 사용해보고 싶었다. ES10(2019)에서 Object.fromEntires를 발표했고, 필자는 한 번 사용해보고 싶었다.
fieldValues는 전역에 선언한 const requiredFields = ['Author', 'Password', 'Title', 'Content'];
의 key을 이용하여 formData의 필수값들을 한꺼번에 들고오기 위한 코드이다.
// fieldValues
{
author: "string",
password: "string",
title: "string",
content: "string",
}
이렇게 처리하지 않게되면 일일이.. formData.get('string') as string || '';
이런 형태로 불러와야 한다.. 최악의 코드
let images = (formData.getAll('image') as File[]) || [];
처음에는 이미지 별로 하나씩 들고 왔다. 그러다보니 3줄이라는 코드를 짤 수 밖에 없고 필자는 이게 비효율적임을 알게 되었다. formData 메서드를 검색하던 도중 name을 전부 들고오는 getAll
메서드를 발견하게 되었고, 배열로 image file[]를 한꺼번에 들고 왔다.
// filterFormRequire
import { EError } from '@/models/error.type';
import { actionHandleError } from './actionHandlerError';
export function filterFormRequire(fieldValues, requiredFields) {
const errors = Object.fromEntries(
requiredFields.map((key) => [key.toLowerCase(), fieldValues[key] ? '' : EError.REQUIRED]),
);
const hasError = Object.values(errors).some((error) => error);
if (hasError) {
return actionHandleError(errors, '');
}
}
이 코드는 author / password / title / content
의 value의 유효성 검사를 하는 로직이다. 현재 formData에서 값을 들고 왔고, 값이 존재하면 빈 문자열을 넘기고 아니면 required를 넘긴다. 이후 some을 이용하여 빈 문자열이 하나라도 있으면 true를 반환하고 빈 문자열이 있다는 건 값이 제대로 입력이 되지 않았다는 얘기니깐 errors를 return 해줘야한다.
// actionHandleError
import { EError } from '@/models/error.type';
import { IFormStateError } from '@/models/formBoardError';
export const defaultErrors: IFormStateError['errors'] = {
author: '',
password: '',
title: '',
content: '',
general: '',
};
export function actionHandleError(
errors: Partial<IFormStateError['errors']>,
general: string = EError.SERVER,
): IFormStateError {
return {
data: null,
errors: {
...defaultErrors,
...errors,
general,
},
};
}
다음은 actionHandleError
로직이다. 나중에 보면 알겠지만 defaultErrors
를 사용해야 하는 경우가 발생한다. 그래서 따로 파일을 만드는 건 비효율적이라고 생각해서(저거 하나때문에 파일을 파는 건.. 필자가 생각하기엔 비효율적임) actionHandleError
에 정의를 했다.
다음은 Partial이다. 이는,, 음.. IFormStateError
에서 key로 errors 부분을 타입으로 설정할건데,, 객체 타입 IFormStateError['errors']
의 모든 property를 required가 아닌 optional으로 만들어주는 기능이다. 즉, 원래 require로 지정된 property들도 optional으로 변경되어, 해당 property들을 포함하지 않을 수 있게 해준다.
Partial를 사용한 이유는, 현재 IFormStateError는 input value의 필수값들을 정의한 interface이다. 나중에 에러 메세지를 보면, input value 필수 값들은 코드블록 기준 최상단에서 처리를 했기 때문에 input value가 필수 여부를 파악할 필요가 없다. 즉, 나중에는 errors 부분을 빈 객체 {}
로 던진다는 얘기다. 그래서 PartialType을 사용했다. 또한, errors가 모든 값이 없을 수도 있고 몇 개만 errors 객체에 있을 수 있기 때문에 Parital을 사용했다.
general은 input value 필수 값의 유효성 검사가 아닌 다른 에러 처리를 하기 위한 전체 에러 값이다. 예를 들어 이미지 형식에 맞지 않습니다. 서버에서 오류가 발생했습니다. 다시 시도해주세요. 정상적으로 데이터가 저장되지 않았습니다. 와 같은 나머지 에러를 처리하기 위한 것이다.
에러를 처리하기 위한 actionHandleError
이기 때문에, data는 당연하게도 null이고, errors 같은 경우 short-hand property와 Spread operator를 이용하여 객체를 병합하였다.
images = images.filter((image) => image.size !== 0);
let imageUrl: string[] = [];
if (images.length > 0) {
await ttisValidImage(images);
imageUrl = await uploadImageS3(images);
}
먼저 images는 필수 값이 아니다. 그래서 존재할 수도 있고 없을 수도 있기 때문에 filter를 이용하여 images를 처리했다.
[
File {
size: 0,
type: 'application/octet-stream',
name: '',
lastModified: 1729351667936
},
File {
size: 0,
type: 'application/octet-stream',
name: '',
lastModified: 1729351667937
},
File {
size: 0,
type: 'application/octet-stream',
name: '',
lastModified: 1729351667937
}
]
빈 값을 보내면 이런 식으로 데이터가 들어오기 때문에 반드시 filter 처리를 해줘야한다.
이후 images 배열이 존재하면 isValidImage를 처리한다.
import { EError } from '@/models/error.type';
import { actionHandleError } from '../actionHandlerError';
import checkImageResolution from './checkImageResolution';
import hasValidBasicProperties from './hasValidBasicProperties';
export async function isValidImage(images: File[]): Promise<void> {
if (!images.every(hasValidBasicProperties)) {
actionHandleError({}, EError.SIZE_TYPE);
}
}
isValidImage 파일이 하는 역할은 이미지 크기 / 이미지 타입
이다. 사실 size와 type을 분리하고 싶었지만, 중복되는 코드라 하나로 합쳤다.. (이정도는,, 하나로 합쳐도 되지..)
// hasValidBasicProperties
import { ALLOWED_EXTENSIONS, ALLOWED_TYPES, MAX_SIZE } from './Image.const';
function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() || '';
}
export default function hasValidBasicProperties(image: File): boolean {
const extension = getFileExtension(image.name);
return (
image.size > 0 &&
image.size < MAX_SIZE &&
ALLOWED_TYPES.includes(image.type) &&
ALLOWED_EXTENSIONS.includes(extension)
);
}
image 크기를 0부터 MAX_SIZE 만큼 처리했고, MIME Type과 확장자를 allow 한 타입만 가능하게 처리했다. getFileExtension
함수 같은 경우 마지막 확장자명을 얻기 위해서 사용한 함수라고 생각하시면 된다.
// uploadImageS3
import { EError } from '@/models/error.type';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { actionHandleError } from '@/utils/actionHandlerError';
import s3Client from '~/config/s3ClientConfig';
export default async function uploadImageS3(images: File[]): Promise<string[]> {
const uploadPromises = images.map(async (image: File) => {
const Key: string = `${Date.now()}-${image.name}`;
const uploadParams = {
Bucket: process.env.NEXT_PUBLIC_AWS_BUCKET_NAME,
Key,
Body: Buffer.from(await image.arrayBuffer()),
ContentType: image.type,
};
try {
await s3Client.send(new PutObjectCommand(uploadParams));
return `https://${process.env.NEXT_PUBLIC_AWS_BUCKET_NAME}.s3.amazonaws.com/${Key}`;
} catch (error) {
console.error('Error uploading to S3:', error);
actionHandleError({}, EError.S3_ERROR);
}
});
const uploadedUrls = await Promise.all(uploadPromises);
return uploadedUrls.filter((url): url is string => !!url);
}
현재 이미지는 File[] 형태이다!!! 그래서 우리는 여러 개의 이미지를 s3로 전송하기 위해서, Array.prototype.map을 사용해야한다. key같은 경우 실제 S3에서 파일이 저장되는 경로와 이름을 지정하는 역할을 한다. 즉!! 고유의 아이디 값이 필요하다. 주로 고유의 아이디를 할당할 때는, 타임 스탬프 또는 UUID
를 사용하여 고유성을 보장한다.
const uploadParams = {
Bucket: process.env.NEXT_PUBLIC_AWS_BUCKET_NAME,
Key,
Body: Buffer.from(await image.arrayBuffer()),
ContentType: image.type,
};
다음은 PutObjectCommand
의 argument로 넣어야하는 option 값들이고
Bucket: 파일을 업로드할 대상 S3 버킷의 이름을 지정한다.
Key: 파일이 S3에 저장될 때의 고유 경로 및 파일 이름. 고유한 키를 설정함으로써 파일의 식별 및 접근을 용이하게 한다.
Body: 업로드할 파일의 내용. 이 예제에서는 파일의 내용을Buffer 형태로 변환하여 저장하고 있다.
ContentType: 파일의 MIME 타입을 지정합니다. 이를 통해 S3에서 파일의 형식을 인식하고, 브라우저가 파일을 올바르게 처리할 수 있게 도와줍니다.
여기서 Kick인 부분
Body: Buffer.from(await image.arrayBuffer()),
우리는 현재 Image는 File 형태의 데이터이다. 이는 브라우저에 저장되어 있다. 우리는 S3에 이미지를 보낼 때 buffer
형태(Node.js에서 이진 데이터를 처리하기 위한 형식)로 데이터를 보내야한다. File 형태에서는 직접적으로 buffer
형태로 바꿀 수 없다. File 객체는 직접적으로 이진 데이터를 다룰 수 있는 방식을 제공하지 않기 때문이다. 그래서 브라우저에서 이진 데이터를 다룰 수 있는 arrayBuffer
로 바꿔주고 이후 Buffer
형태로 변환한다.
try {
await s3Client.send(new PutObjectCommand(uploadParams));
return `https://${process.env.NEXT_PUBLIC_AWS_BUCKET_NAME}.s3.amazonaws.com/${Key}`;
} catch (error) {
console.error('Error uploading to S3:', error);
actionHandleError({}, EError.S3_ERROR);
}
});
PutObjectCommand
를 이용하여 우리는 실제 S3로 이미지를 보낸다. PutObjectCommand
는 AWS SDK for JavaScript (v3)에서 제공하는 AWS S3와의 통신을 위한 명령어로, S3 버킷에 객체(파일)를 업로드하는 역할을 한다.
실제 URL은 https://${process.env.NEXT_PUBLIC_AWS_BUCKET_NAME}.s3.amazonaws.com/${Key}
이런 형태로 저장되기 때문에 따로 response를 받지 않고도 try - catch
를 이용하여 성공적으로 이미지가 s3에 저장되면 바로 URL을 return 해준다.
// s3Client
import { S3Client } from '@aws-sdk/client-s3';
const s3Client = new S3Client({
region: process.env.NEXT_PUBLIC_AWS_REGION as string,
credentials: {
accessKeyId: process.env.NEXT_PUBLIC_AWS_ACCESS_KEY_ID as string,
secretAccessKey: process.env.NEXT_PUBLIC_AWS_SECRET_ACCESS_KEY as string,
},
});
export default s3Client;
필자는 s3ClientConfig
를 따로 설정해놨다. 여기서는 region / accessKeyId / secretAccessKey
를 통해 S3Client 객체를 생성했다.
// postBoard
import { actionHandleError, defaultErrors } from '@/utils/actionHandlerError';
import { api, boardUrlEndPoint } from '../../../config/axiosConfig';
import { EError } from '@/models/error.type';
import { ICreateFormBoard } from '@/models/board.type';
import { IFormStateError } from '@/models/formBoardError';
export default async function postBoard(data: ICreateFormBoard): Promise<IFormStateError> {
try {
const response = await api.post(boardUrlEndPoint, data);
if (response.data.statusCode === 201) {
return {
data: response.data.data,
errors: defaultErrors,
};
}
return actionHandleError({}, EError.DB_ERROR);
} catch (error) {
console.error(error);
return actionHandleError({});
}
}
다음은 우리가 아는 !!! axios를 이용하여 data fetching을 처리하는 과정이다.. 성공적으로 데이터가 들어오면 원하는 대로 response를 넘겨주고 아니면 error message를 처리한다.
@Post()
@ResponseMessage('board가 성공적으로 생성되었습니다.')
@HttpCode(HttpStatus.CREATED)
create(@Body() createBoardDTO: CreateBoardDTO): Promise<BoardEntity> {
return this.boardService.create(createBoardDTO);
}
controller에서의 createBoardDTO console이다.
CreateBoardDTO {
author: '123',
password: '213',
title: '213',
content: '213',
imageUrl: [
'https://sesac.s3.amazonaws.com/1729362784744-1_CPllEtejNuFpU73oVX3xCA.png'
],
youtubeUrl: '213',
address: '213',
detailAddress: '213'
}
정상적으로 데이터가 들어온 걸 확인할 수 있다. DTO와 Entity가 궁금하신 분들은 위로 올라가시면 있습니다.
async create(createBoard: CreateBoardDTO): Promise<BoardEntity> {
// 비밀번호 암호화 with bcrypt
const hashPassword = await this.transformPassword(createBoard.password);
// 암호화된 비밀번호와 함께 board를 생성한다.
// 이 때 실제 DB에 저장하는 게 아닌 새로운 entity instance를 생성하는 것이다.
const board = this.boardRepository.createBoard({
...createBoard,
password: hashPassword,
});
// mongoDB를 사용하기 때문에
// Primary key를 이용하기 위해 board만의 Couter collection을 생성한다.
const boardId =
await this.boardIdCounterRepository.incrementBoardId('board');
board.boardId = boardId;
// 이후 board-Reaction collection을 like - 0 / hate - 0으로 초기화한다.
this.boardReactionRepository.initializatedBoardReaction(boardId);
// 다 끝나면 실제 mongoDB에 저장한다.
return await this.boardRepository.saveBoard(board);
}
repository 로직은 따로 공유를 하지 않겠습니다..
다음은 mongoDB에 ImageUrl을 저장한 것이다.
최종적으로 client에서 log를 찍으면 다음과 같이 나온다.