캡스톤 설계 [건물별 소통 플랫폼 BBC]

인터셉터과 트랜잭션을 활용한 게시물 작성 [파일 업로드]

softmoca__ 2024. 3. 25. 15:13
목차

이미지 업로드 방식에는 크게 두가지 방법이 있다.

 

첫번쨰 방식

- 제목,내용, 이미지를 모두 선택한 다음 모든 정보를 한번에 서버로 업로드

- 파일은 업로드가 오래걸려 사용자는 프로그램이 느리다는 인상을 받을 수 있다.

 

두번째 방식

- 한번에 텍스트와 파일을 업로드하는게 아니라 이미지를 선택할 때마다 이미즈는 먼저 업로드를 한다.

- 업로드된 이미지들은 '임시폴더'에 이미지 경로만  잠시 저장해 둔다.(/public/temp)
- 이후 포스트를 업로드 할 때 이미지의 경로만 추가한다.(/public/temp 에서 public/posts로 이동)

-> AWS S3의 Presigned URL 을 사용하는 방식의 일부이다.

 

각각의 방식에는 아래와 같은 장단점이 있다.

결론적으로 사용자의 체감 시간에 더욱 중점을 두어 두번째 방법으로 진행할 예정인다.

 

 

 

 

 

 

npm i multer @types/multer uuid @types/uuid

npm i @nestjs/serve-static

이미지 파일 업로드를 하기 위해 사용하는 라이브러리 설치

 

 

path.const.ts

// 서버 프로젝트의 루트 폴더
export const PROJECT_ROOT_PATH = process.cwd();
// 외부에서 접근 가능한 파일들을 모아둔 폴더 이름
export const PUBLIC_FOLDER_NAME = "public";
// 포스트 이미지들을 저장할 폴더 이름
export const POSTS_FOLDER_NAME = "posts";
// 임시 폴더 이름
export const TEMP_FOLDER_NAME = "temp";

// 실제 공개폴더의 절대경로
// /{프로젝트의 위치}/public
export const PUBLIC_FOLDER_PATH = join(PROJECT_ROOT_PATH, PUBLIC_FOLDER_NAME);

// 포스트 이미지를 저장할 폴더
// /{프로젝트의 위치}/public/posts
export const POST_IMAGE_PATH = join(PUBLIC_FOLDER_PATH, POSTS_FOLDER_NAME);

// 절대경로 x
// /public/posts/xxx.jpg
export const POST_PUBLIC_IMAGE_PATH = join(
  PUBLIC_FOLDER_NAME,
  POSTS_FOLDER_NAME
);

// 임시 파일들을 저장할 폴더
// {프로젝트 경로}/temp
export const TEMP_FOLDER_PATH = join(PUBLIC_FOLDER_PATH, TEMP_FOLDER_NAME);

파일들의 경로를 관리하기 위한 path 상수.

 

 

common.module.ts

 MulterModule.register({
      limits: {
        fileSize: 10000000,
      },
      fileFilter: (req, file, cb) => {
        const ext = extname(file.originalname);

        if (ext !== ".jpg" && ext !== ".jpeg" && ext !== ".png") {
          return cb(
            new BadRequestException("jpg/jpeg/png 파일만 업로드 가능합니다!"),
            false
          );
        }

        return cb(null, true);
      },
      storage: multer.diskStorage({
        destination: function (req, res, cb) {
          cb(null, TEMP_FOLDER_PATH);
        },
        filename: function (req, file, cb) {
          cb(null, `${uuid()}${extname(file.originalname)}`);
        },
      }),
    }),

multer로 파일을 다운 받을때의 옵션을 설정한다.

총 3가지의 옵션으로, limits,fileFilter,storage가 있다.

limit에서 파일의 크기 제한은 10MB로 설정한다.

fileFilter의 3개의 매개변수 중 마지막 파라미터인 cb에서 주요한 로직을 처리한다..

첫번째 매개변수 에는 에러가 있을 경우 에러 정보를 넣는다.

두번쨰 매개변수에는 파일을 받을지 말기 boolean을 넣는다.

extname을 사용해서 확장자만 추출한 뒤 jpg,jpeg,png와 같은 허용한 확장자만 파일을 받게 설정.

storage에는 파일을 다운한 뒤 어디에 저장할지에 대한 정보 destination, 저장 될 파일이름을 uuid를 사용해서 설정한다.

 

common.controller

@Controller('common')
export class CommonController {
  constructor(private readonly commonService: CommonService) {}

  @Post('image')
  @UseInterceptors(FileInterceptor('image'))
  postImage(
    @UploadedFile() file: Express.Multer.File,
  ){
    return {
      fileName: file.filename,
    }
  }
}

FileInterceptor를 사용해서 image라는 키 값에 파일을 넣어 보낸다.

그리고 UploadedFile 데코레이터를 사용해서 file 정보를 가져온다.

그럼 위와 같이 파일이 잘 저장이 되고 백엔드 프로젝트 temp 폴더에도 업로드한 사진이 잘 저장된다.

 

 

app.moudel.ts

    ServeStaticModule.forRoot({
      rootPath: PUBLIC_FOLDER_PATH,
      serveRoot: '/public'
    }),

서버에서 해당 요청의 경로대로  요청 파일을 보여주기 위한 Static File Serving옵션 추가

http://localhost:3000/public/posts/4022.jpg   로 요청을 할 수 있다.

 

 

그럼 이제 위와 같이 Static File파일을 서버 주소를 통해 이미지를 볼수 있다.

 

 

 

 

image.entity.ts

@Entity()
export class ImageModel extends BaseModel {
  @Column({
    default: 0,
  })
  @IsInt()
  @IsOptional()
  order: number;

  @Column({
    type: "enum",
    enum: ImageModelType,
    default: ImageModelType.POST_IMAGE,
  })
  @IsEnum(ImageModelType)
  @IsString()
  type: ImageModelType;

  @Column()
  @IsString()
  @Transform(({ value, obj }) => {
    if (obj.type === ImageModelType.POST_IMAGE) {
      return `/${join(POST_PUBLIC_IMAGE_PATH, value)}`;
    } else {
      return value;
    }
  })
  path: string;

  @ManyToOne((type) => PostModel, (post) => post.images)
  post?: PostModel;
}

다수의 이미지를 업로드 하기 위해 이미지 엔티티 생성.

path 속성에 Transform을 사용해서 image 속성시 파일 명만 나오는게 아닌 접근 경로인 /public/posts를 붙여 준다.

 

 

 

createPost.dto.ts

export class CreatePostDto extends PickType(PostModel, ["postTitle", "postContent",]) {
  @IsString({
    each: true,
  })
  @IsOptional()
  images: string[] = [];
}

이미지를 배열로 여러개를 받을 수 있게 dto수정.

 

 

transaction.interceptor.ts

@Injectable()
export class TransactionInterceptor implements NestInterceptor {
  constructor(private readonly dataSource: DataSource) {}

  async intercept(
    context: ExecutionContext,
    next: CallHandler<any>
  ): Promise<Observable<any>> {
    const req = context.switchToHttp().getRequest();
    const qr = this.dataSource.createQueryRunner();

    await qr.connect();
    await qr.startTransaction();

    req.queryRunner = qr;

    return next.handle().pipe(
      catchError(async (e) => {
        await qr.rollbackTransaction();
        await qr.release();

        throw new InternalServerErrorException(e.message);
      }),
      tap(async () => {
        await qr.commitTransaction();
        await qr.release();
      })
    );
  }
}

트랜잭션 기능을 구현한 인터셉터

 

query-runner.decorator.ts

export const QueryRunnerDecorator = createParamDecorator(
  (data, context: ExecutionContext) => {
    const req = context.switchToHttp().getRequest();

    if (!req.queryRunner) {
      throw new InternalServerErrorException(
        `QueryRunner Decorator를 사용하려면 TransactionInterceptor를 적용해야 합니다.`
      );
    }

    return req.queryRunner;
  }
);

인터셉터에서 넣어준 req의queryRunner를 간단하게 사용하기 위한 커스텀 데코레이터

 

 

 

post.contorller.ts

@Post()
  @UseInterceptors(TransactionInterceptor)
  @UseGuards(AccessTokenGuard)
  async postPosts(
    @CurrentUser() user: UsersModel,
    @Body() body: CreatePostDto,
    @QueryRunnerDecorator() qr: QueryRunner
  ) {
    // 트랜잭션 로직 실행
    const post = await this.postsService.createPost(user.id, body, qr);

   
    for (let i = 0; i < body.images.length; i++) {
      await this.postsImagesService.createPostImage(
        {
          post,
          order: i,
          path: body.images[i],
          type: ImageModelType.POST_IMAGE,
        },
        qr
      );
    }

    return this.postsService.getPostById(post.id, qr);
  }

트랜잭션을 구현한 인터셉터와 쿼리 러너 데코레이터를 사용한 게시글 작성 컨트롤러

 

posts.service.ts

  getRepository(qr?: QueryRunner) {
    return qr
      ? qr.manager.getRepository<PostModel>(PostModel)
      : this.postsRepository;
  }

게시글 작성 트랜잭션을 위해 하나의 같은  quert-runner객체를 사용해서 게시글 레포지토리 가져오는 함수.

즉, 단일 트랜잭션으로 묶기 위함.

 

 

posts.service.ts

  async createPost(
    authorId: number, 
    createPostDto: CreatePostDto,
    qr?: QueryRunner
  ) {
    const repository = this.getRepository(qr);

    const post = repository.create({
      author: {
        id: authorId,
      },
      ...createPostDto,
      images: [],
      postLike: 0,
    });

    const newPost = await repository.save(post);

    return newPost;
  }

같은 query-runner객체를 사용해서 게시글 인스턴스 생성 후 저장.

 

images.service.ts

 getRepository(qr?: QueryRunner) {
    return qr
      ? qr.manager.getRepository<ImageModel>(ImageModel)
      : this.imageRepository;
  }

게시글 작성 트랜잭션을 위해 하나의 같은  quert-runner객체를 사용해서 이미지 레포지토리 가져오는 함수.

 

images.service.ts

 

async createPostImage(dto: CreatePostImageDto, qr?: QueryRunner) {
    const repository = this.getRepository(qr);

    const tempFilePath = join(TEMP_FOLDER_PATH, dto.path);

    try {
      await promises.access(tempFilePath);
    } catch (e) {
      throw new BadRequestException("존재하지 않는 파일 입니다.");
    }

    const fileName = basename(tempFilePath);

    const newPath = join(POST_IMAGE_PATH, fileName);

    const result = await repository.save({
      ...dto,
    });

    await promises.rename(tempFilePath, newPath);

    return result;
  }

이미지의 갯수만큼 temp에서 posts로 이동 시키는 함수

 

posts.service.ts

async getPostById(id: number, qr?: QueryRunner) {
    const repository = this.getRepository(qr);

    const post = await repository.findOne({
      relations: {
        author: true,
        images: true,
      },
      where: {
        id,
      },
    });

    if (!post) {
      throw new NotFoundException();
    }

    return post;
  }

같은 하나의 query-runner로 가져온 레포지토리에서 생성한 게시글 하나를 찾아 반환.

 

그럼이제 이미지를 포함해서 게시물이 잘 생성된다.

응답값으로 작성자의 정보와 이미지의 정보 또한 확인할 수있다.

 

또한 프로젝트 public/posts폴더에 잘 저장이 된다 !

물론 외부에서도 서버 주소를 통해 이미지를 확인할 수 있다.

 

트랜잭션 테스트

트랜잭션이 잘 적용되는지 확인하기 위해

이미지가 없는 게시물 생성 로직과 이미지를  temp에서 posts로 이동하는 로직 사이에 일부러 에러를 던져 롤백이 잘되는지 테스트 해보고자 한다.

 

즉, images속성 정보가 없는 게시물 엔티티가 데이터 베이스로 저장이 되지 않는지 확인 !

 

 

 

 

테스트 이전 DB에 있는 게시글의 목록들이다.

 

post.controller.ts

@Post()
  @UseInterceptors(LogInterceptor)
  @UseInterceptors(TransactionInterceptor)
  @UseGuards(AccessTokenGuard)
  async postPosts(
    @CurrentUser() user: UsersModel,
    @Body() body: CreatePostDto,
    @QueryRunner() qr: QR
  ) {
    // 트랜잭션 로직 실행
    const post = await this.postsService.createPost(user.id, body, qr);
    throw new InternalServerErrorException("트랜잭션 테스트 에러 ");
    for (let i = 0; i < body.images.length; i++) {
      await this.postsImagesService.createPostImage(
        {
          post,
          order: i,
          path: body.images[i],
          type: ImageModelType.POST_IMAGE,
        },
        qr
      );
    }

    return this.postsService.getPostById(post.id, qr);
  }

 

InternalServerErrorException를 던져 트랜잭션 테스트 시작 !

 

그럼 컨트롤러 로직 사이에 일부러 발생시긴 에러 메세지가 나온다.

 

그리고 다시 컨트롤러에 일부러 넣은 에러를 제거한뒤 정상적인 게시물을 작성한 결과

해당 요청 사이 에러로 인해 생성된 게시물은 DB에 저장되지 않음을 확인 !