Skip to content

[재하] 1129(수) 개발기록

박재하 edited this page Nov 30, 2023 · 2 revisions

목차

  • PATCH /post/:id 개선
    • 계획
    • 구현
    • 동작 화면
  • DELETE /post/:id 개선
    • 계획
    • 구현
    • 동작 화면
  • PATCH /star/:id 구현
    • 계획
    • 구현
    • 동작 화면
  • 트랜잭션 적용
    • PATCH /post/:id/like, PATCH /post/:id/unlike
    • PATCH /post/:id, DELETE /post/:id
    • 트랜잭션 구현
    • DELETE /post/:id에 적용
    • PATCH /post/:id에 적용
    • 동작 화면

PATCH /post/:id 개선

계획

  • 게시글에 대한 수정 요청은 생성 요청과 마찬가지로 사진을 받을 수 있어야 한다.
    • 따라서 폼 데이터로 요청받도록 변경하고, 사진 항목이 있을 경우 기존 사진을 삭제하고 덮어씌운다.
  • 별 스타일에 대한 수정 요청은 별도의 API로 두자는 요청이 있었음.
    • 따라서 별 수정이 컬럼으로 들어오면 400 에러를 리턴하고, 별도로 PATCH /star/:id를 만듬

구현

// board.controller.ts
@Patch(':id')
@UseGuards(CookieAuthGuard)
@UseInterceptors(FilesInterceptor('file', 3))
@UsePipes(ValidationPipe)
@UpdateBoardSwaggerDecorator()
updateBoard(
  @Param('id', ParseIntPipe) id: number,
  @Body() updateBoardDto: UpdateBoardDto,
  @GetUser() userData: UserDataDto,
  @UploadedFiles() files: Express.Multer.File[],
) {
  return this.boardService.updateBoard(id, updateBoardDto, userData, files);
}

컨트롤러 단에서 폼 데이터로 받도록 UseInterceptors(multer 인터셉터)를 추가해줌. 파일은 3개까지 받아 별도 파라미터로 처리해서 서비스단에 넘겨줌.

async updateBoard(
  id: number,
  updateBoardDto: UpdateBoardDto,
  userData: UserDataDto,
  files: Express.Multer.File[],
) {
  const board: Board = await this.findBoardById(id);

  // 게시글 작성자와 수정 요청자가 다른 경우
  if (board.user.id !== userData.userId) {
    throw new BadRequestException('You are not the author of this post');
  }

  // star에 대한 수정은 별도 API(PATCH /star/:id)로 처리하므로 400 에러 리턴
  if (updateBoardDto.star) {
    throw new BadRequestException(
      'You cannot update star with this API. use PATCH /star/:id',
    );
  }

  if (files.length > 0) {
    const images: Image[] = [];
    for (const file of files) {
      const image = await this.uploadFile(file);
      images.push(image);
    }
    // 기존 이미지 삭제
    for (const image of board.images) {
      // 이미지 리포지토리에서 삭제
      await this.imageRepository.delete({ id: image.id });
      // AWS S3에서 삭제
      await this.deleteFile(image.filename);
    }
    // 새로운 이미지로 교체
    board.images = images;
  }

  // updateBoardDto.content가 존재하면 AES 암호화하여 저장
  if (updateBoardDto.content) {
    updateBoardDto.content = encryptAes(updateBoardDto.content);
  }

  const updatedBoard: Board = await this.boardRepository.save({
    ...board,
    ...updateBoardDto,
  });
  return updatedBoard;
}

추가된 로직은 다음과 같다.

  1. star에 대한 수정 요청이 있을 경우 400 에러 리턴
  2. file에 대한 수정 요청이 있을 경우 다음을 수행
  3. 요청받은 파일 업로드 후 Image 객체 DB에 저장
  4. 이미지 리포지토리에서 기존 이미지 삭제
  5. S3(NCP Object Storage)에서 기존 이미지 삭제
  6. Board 객체 갱신 시 새로운 이미지 배열로 대체
async deleteFile(filename: string): Promise<void> {
  // NCP Object Storage 삭제
  AWS.config.update(awsConfig);
  const result = await new AWS.S3()
    .deleteObject({
      Bucket: bucketName,
      Key: filename,
    })
    .promise();
  Logger.log('deleteFile result:', result);
}

이에 따라 서비스 하단에 NCP Object Storage에서 파일을 삭제하는 로직도 추가함.

동작 화면

1. POST /post 통해 별글 등록

스크린샷 2023-11-29 오후 2 17 35

별글 등록

스크린샷 2023-11-29 오후 2 23 53

GET /post/:id로 조회. 복호화해보니 content도 잘 들어감

스크린샷 2023-11-29 오후 2 25 08

image filename으로 Object Storage에도 잘 들어간 것 확인

스크린샷 2023-11-29 오후 2 25 31

image 테이블에도 정보 잘 들어간 것 확인

2. PATCH /post/:id 통해 별글 수정

스크린샷 2023-11-29 오후 2 37 27

PATCH /post/:id로 별글 수정. 선별적으로 content, file만 수정해보자.

스크린샷 2023-11-29 오후 2 38 20

이후 기존의 filename으로 조회해보면 정상적으로 이전 파일이 삭제된 것을 확인할 수 있음.

스크린샷 2023-11-29 오후 2 44 29

데이터베이스에서도 기존의 image 레코드(id 67)가 삭제되고 68번이 생성된 것을 확인할 수 있음.

스크린샷 2023-11-29 오후 2 39 14

GET /post/224로 조회해보면 content도 잘 바뀐 것을 확인할 수 있고, image url도 바뀐 것으로 잘 변경되어 있음. 성공!

DELETE /post/:id 개선

계획

  • 삭제 시에도 Update와 유사하게 연관된 다음 내용을 모두 삭제해줘야 한다.
    • star 레코드
    • image 레코드
    • Object Storage의 image 파일
    • like 조인 테이블의 해당 게시물과 연관된 모든 레코드
-- Active: 1693885143266@@192.168.64.2@3306@b1g1
CREATE TABLE `board_likes_user` (
  `boardId` int NOT NULL,
  `userId` int NOT NULL,
  PRIMARY KEY (`boardId`,`userId`),
  KEY `IDX_cc61d27acb747ad30ab37c7399` (`boardId`),
  KEY `IDX_e14a2e3175cb17290e3e23488c` (`userId`),
  CONSTRAINT `FK_cc61d27acb747ad30ab37c73995` FOREIGN KEY (`boardId`) REFERENCES `board` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT `FK_e14a2e3175cb17290e3e23488cb` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

위는 board와 user의 like를 위한 Join Table DDL인데, 외래키 제약조건에서 확인할 수 있듯 ON DELETE CASCADE를 이미 설정해 놓아서 게시글 삭제 시 관련 레코드 자동 삭제됨. 따라서 image, like만 고려하면 된다.

구현

board.service.ts의 deleteBoard()만 수정해주면 된다.

async deleteBoard(id: number, userData: UserDataDto): Promise<void> {
  const board: Board = await this.boardRepository.findOneBy({ id });
  if (!board) {
    throw new NotFoundException(`Not found board with id: ${id}`);
  }

  // 게시글 작성자와 삭제 요청자가 다른 경우
  if (board.user.id !== userData.userId) {
    throw new BadRequestException('You are not the author of this post');
  }

  // 연관된 이미지 삭제
  for (const image of board.images) {
    // 이미지 리포지토리에서 삭제
    await this.imageRepository.delete({ id: image.id });
    // NCP Object Storage에서 삭제
    await this.deleteFile(image.filename);
  }

  // 연관된 별 스타일 삭제
  if (board.star) {
    await this.starModel.deleteOne({ _id: board.star });
  }

  // like 조인테이블 레코드들은 자동으로 삭제됨 (외래키 제약조건 ON DELETE CASCADE)

  // 게시글 삭제
  const result = await this.boardRepository.delete({ id });
}

동작 화면

POST /post로 게시글 생성, PATCH /post/:id/like로 좋아요

like 레코드 자동 삭제 확인을 위해 좋아요를 하나 누르고 테스트해보았다.

스크린샷 2023-11-29 오후 3 15 56

255번 게시글 생성

스크린샷 2023-11-29 오후 3 16 37

좋아요

스크린샷 2023-11-29 오후 3 16 42

잘 반영되었고

스크린샷 2023-11-29 오후 3 16 54

조인 테이블에도 추가됨 (255번 게시글, 5번 유저)

DELETE /post/:id로 게시글 삭제

스크린샷 2023-11-29 오후 3 17 03

DELETE /post/225

스크린샷 2023-11-29 오후 3 17 09

당연히 게시글 삭제 됐고

스크린샷 2023-11-29 오후 3 18 15

이미지도 찾아보니 없음. 잘 삭제됨

스크린샷 2023-11-29 오후 3 17 28

Image 테이블에서도 정상적으로 레코드가 삭제됨

스크린샷 2023-11-29 오후 3 17 15

Like 조인테이블에서도 정상적으로 삭제됨

스크린샷 2023-11-29 오후 3 25 19

마지막으로 Star 컬렉션에서도 정상적으로 삭제됨. 끝!

PATCH /star/:id 구현

계획

  • star 변경은 post_id 기준으로 해달라는 요청이 있어, 여기서의 :id는 board 테이블의 id 값이다.
    • 이렇게 해석하도록 하고, 넘어오는 Object는 조건없이 document에 덮어씌우는 걸로 구현

구현

// star.controller.ts
// 게시글 id를 이용해 별 정보를 수정함
@Patch(':id')
@UseGuards(CookieAuthGuard)
@UpdateStarByPostIdSwaggerDecorator()
updateStarByPostId(
  @Param('id', ParseIntPipe) post_id: number,
  @Body() updateStarDto: UpdateStarDto,
  @GetUser() userData: UserDataDto,
): Promise<Star> {
  return this.starService.updateStarByPostId(
    post_id,
    updateStarDto,
    userData,
  );
}
// star.service.ts
async updateStarByPostId(
  post_id: number,
  updateStarDto: UpdateStarDto,
  userData: UserDataDto,
): Promise<Star> {
  const board: Board = await this.boardRepository.findOneBy({ id: post_id });
  if (!board) {
    throw new BadRequestException(`Not found board with id: ${post_id}`);
  }

  // 게시글 작성자와 수정 요청자가 다른 경우
  if (board.user.id !== userData.userId) {
    throw new BadRequestException('You are not the author of this star');
  }

  // 별 id를 조회하여 없으면 에러 리턴
  const star_id = board.star;
  if (!star_id) {
    throw new BadRequestException(
      `Not found star_id with this post_id: ${post_id}`,
    );
  }

  // 별 스타일이 존재하면 MongoDB에 저장
  const result = await this.starModel.updateOne(
    { _id: star_id },
    { ...updateStarDto },
  );
  if (!result) {
    throw new InternalServerErrorException(
      `Failed to update star with this post_id: ${post_id}, star_id: ${star_id}`,
    );
  } else if (result.matchedCount === 0) {
    throw new BadRequestException(
      `Not found star with this post_id: ${post_id}, star_id: ${star_id}`,
    );
  } else if (result.modifiedCount === 0) {
    throw new BadRequestException(`Nothing to update`);
  }

  const updatedStar = await this.starModel.findOne({
    _id: star_id,
  });

  return updatedStar;
}

에러처리를 최대한 꼼꼼하게 하도록 노력했다.

typeorm과 달리 update 후에 해당 객체가 아닌 결과 object를 리턴해주기 때문에, 다시 findOne() 함수로 변경된 객체를 가져와서 리턴한다.

동작 화면

스크린샷 2023-11-29 오후 4 16 52

POST로 별글 등록. 이제 star는 필수 입력값으로 바뀌었다.

스크린샷 2023-11-29 오후 4 31 53

PATCH /star/226으로 해당 별글의 별 스타일을 변경. 마음대로 속성을 추가할 수 있다.

스크린샷 2023-11-29 오후 4 32 37

기존에 있던 속성은 변경되고, 새로운 속성은 추가되는 등 정상적으로 업데이트가 이루어진 것을 확인할 수 있다. 성공!

트랜잭션 적용

필요한 로직에 트랜잭션을 적용해보자.

PATCH /post/:id/like, PATCH /post/:id/unlike

스크린샷 2023-11-29 오후 5 18 40

사진 하단을 보면 UPDATE, DELETE가 일어나는 로직에 이미 내부적으로 Transaction 처리가 되어있는 것을 확인할 수 있다. 불필요하므로 패스.

PATCH /post/:id, DELETE /post/:id

스크린샷 2023-11-29 오후 5 28 05 스크린샷 2023-11-29 오후 5 21 58

게시글 수정 및 삭제 로직은 이미지에 대한 삭제, 삽입과 Board에 대한 수정, 삭제가 다른 트랜잭션으로 돌아서 합치면 좋을 듯 하다.

트랜잭션 구현

DELETE /post/:id 로직을 트랜잭션 방식으로 구현해보자.

학습메모 1을 참고하여 아래와 같은 형식으로 하면 되시겠다.

// create a new query runner
const queryRunner = dataSource.createQueryRunner();

// establish real database connection using our new query runner
await queryRunner.connect();

// now we can execute any queries on a query runner, for example:
await queryRunner.query('SELECT * FROM users');

// we can also access entity manager that works with connection created by a query runner:
const users = await queryRunner.manager.find(User);

// lets now open a new transaction:
await queryRunner.startTransaction();

try {
	// execute some operations on this transaction:
	await queryRunner.manager.save(user1);
	await queryRunner.manager.save(user2);
	await queryRunner.manager.save(photos);

	// commit transaction now:
	await queryRunner.commitTransaction();
} catch (err) {
	// since we have errors let's rollback changes we made
	await queryRunner.rollbackTransaction();
} finally {
	// you need to release query runner which is manually created:
	await queryRunner.release();
}

dataSource는 멘토링 일지(학습메모 2) 참고하여 아래와같이

image

constructor(private readonly dataSource: DataSource) {}

추가해주고 사용하면 됨.

DELETE /post/:id에 적용

// board.service.ts
constructor(
  ...
  @InjectDataSource()
  private readonly dataSource: DataSource,
) {}
...
async deleteBoard(id: number, userData: UserDataDto): Promise<void> {
  // transaction 생성하여 board, image, star, like 테이블 동시에 삭제
  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();

  // const board: Board = await this.boardRepository.findOneBy({ id });
  const board: Board = await queryRunner.manager.findOneBy(Board, { id });

  if (!board) {
    throw new NotFoundException(`Not found board with id: ${id}`);
  }

  // 게시글 작성자와 삭제 요청자가 다른 경우
  if (board.user.id !== userData.userId) {
    throw new BadRequestException('You are not the author of this post');
  }

  // transaction 시작
  await queryRunner.startTransaction();
  try {
    // 연관된 이미지 삭제
    for (const image of board.images) {
      // 이미지 리포지토리에서 삭제
      // await this.imageRepository.delete({ id: image.id });
      await queryRunner.manager.delete(Image, { id: image.id });
      // NCP Object Storage에서 삭제
      await this.deleteFile(image.filename);
    }

    // 연관된 별 스타일 삭제
    if (board.star) {
      await this.starModel.deleteOne({ _id: board.star });
    }

    // like 조인테이블 레코드들은 자동으로 삭제됨 (외래키 제약조건 ON DELETE CASCADE)

    // 게시글 삭제
    // const result = await this.boardRepository.delete({ id });
    const result = await queryRunner.manager.delete(Board, { id });

    // commit Transaction
    await queryRunner.commitTransaction();
  } catch (err) {
    Logger.error(err);
    await queryRunner.rollbackTransaction();
  } finally {
    await queryRunner.release();
  }
}

조회와 에러처리 외에 실제 삭제하는 메인 로직은 try안에 넣고, 전후에 startTransaction, commitTransaction, rollbackTransaction, release 등을 공식문서대로 적절히 배치해준다.

기존 쿼리들을 모두 queryRunner 안에서 실행시키도록, queryRunner.manager 메소드 아래에서 실행한다.

스크린샷 2023-11-29 오후 5 48 48

만들어둔 포스트 삭제

스크린샷 2023-11-29 오후 5 44 26

진짜 되네? 야호!

PATCH /post/:id에 적용

이번엔 업데이트 로직.

async updateBoard(
  id: number,
  updateBoardDto: UpdateBoardDto,
  userData: UserDataDto,
  files: Express.Multer.File[],
) {
  // transaction 생성하여 board, image, star, like 테이블 동시에 수정
  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();

  // const board: Board = await this.boardRepository.findOneBy({ id });
  const board: Board = await queryRunner.manager.findOneBy(Board, { id });
  if (!board) {
    throw new NotFoundException(`Not found board with id: ${id}`);
  }

  // 게시글 작성자와 수정 요청자가 다른 경우
  if (board.user.id !== userData.userId) {
    throw new BadRequestException('You are not the author of this post');
  }

  // star에 대한 수정은 별도 API(PATCH /star/:id)로 처리하므로 400 에러 리턴
  if (updateBoardDto.star) {
    throw new BadRequestException(
      'You cannot update star with this API. use PATCH /star/:id',
    );
  }

  // transaction 시작
  await queryRunner.startTransaction();
  try {
    if (files.length > 0) {
      const images: Image[] = [];
      for (const file of files) {
        const image = await this.uploadFile(file);
        images.push(image);
      }
      // 기존 이미지 삭제
      for (const image of board.images) {
        // 이미지 리포지토리에서 삭제
        // await this.imageRepository.delete({ id: image.id });
        await queryRunner.manager.delete(Image, { id: image.id });
        // NCP Object Storage에서 삭제
        await this.deleteFile(image.filename);
      }
      // 새로운 이미지로 교체
      board.images = images;
    }

    // updateBoardDto.content가 존재하면 AES 암호화하여 저장
    if (updateBoardDto.content) {
      updateBoardDto.content = encryptAes(updateBoardDto.content);
    }

    // const updatedBoard: Board = await this.boardRepository.save({
    // 	...board,
    // 	...updateBoardDto,
    // });
    const updatedBoard: Board = await queryRunner.manager.save(Board, {
      ...board,
      ...updateBoardDto,
    });

    // commit Transaction
    await queryRunner.commitTransaction();

    delete updatedBoard.user.password; // password 제거하여 반환
    return updatedBoard;
  } catch (err) {
    Logger.error(err);
    await queryRunner.rollbackTransaction();
  } finally {
    await queryRunner.release();
  }
}
스크린샷 2023-11-29 오후 6 02 47

만들어둔 포스트 변경

스크린샷 2023-11-29 오후 6 02 37

반영은 잘 되는데, upload image 내에 있는 새로운 이미지에 대한 레코드 생성은 queryRunner로 돌지 못해서 별도의 트랜잭션이 도는 것을 확인할 수 있다.

이 부분은 별도로 개선해도 좋을 것 같다!

동작 화면

DELETE /post/:id에 적용

  • before
스크린샷 2023-11-29 오후 5 21 58
  • after
스크린샷 2023-11-29 오후 5 44 26

PATCH /post/:id에 적용

  • before
스크린샷 2023-11-29 오후 5 28 05
  • after
스크린샷 2023-11-29 오후 6 02 37

학습메모

  1. TypeORM Transactions
  2. dataSource 활용 (week2 멘토링일지)

소개

규칙

학습 기록

[공통] 개발 기록

[재하] 개발 기록

[준섭] 개발 기록

회의록

스크럼 기록

팀 회고

개인 회고

멘토링 일지

Clone this wiki locally