-
Notifications
You must be signed in to change notification settings - Fork 1
[재하] 1129(수) 개발기록
- 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에 적용
- 동작 화면
- 게시글에 대한 수정 요청은 생성 요청과 마찬가지로 사진을 받을 수 있어야 한다.
- 따라서 폼 데이터로 요청받도록 변경하고, 사진 항목이 있을 경우 기존 사진을 삭제하고 덮어씌운다.
- 별 스타일에 대한 수정 요청은 별도의 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;
}
추가된 로직은 다음과 같다.
- star에 대한 수정 요청이 있을 경우 400 에러 리턴
- file에 대한 수정 요청이 있을 경우 다음을 수행
- 요청받은 파일 업로드 후 Image 객체 DB에 저장
- 이미지 리포지토리에서 기존 이미지 삭제
- S3(NCP Object Storage)에서 기존 이미지 삭제
- 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에서 파일을 삭제하는 로직도 추가함.
별글 등록
GET /post/:id로 조회. 복호화해보니 content도 잘 들어감
image filename으로 Object Storage에도 잘 들어간 것 확인
image 테이블에도 정보 잘 들어간 것 확인
PATCH /post/:id로 별글 수정. 선별적으로 content, file만 수정해보자.
이후 기존의 filename으로 조회해보면 정상적으로 이전 파일이 삭제된 것을 확인할 수 있음.
데이터베이스에서도 기존의 image 레코드(id 67)가 삭제되고 68번이 생성된 것을 확인할 수 있음.
GET /post/224로 조회해보면 content도 잘 바뀐 것을 확인할 수 있고, image url도 바뀐 것으로 잘 변경되어 있음. 성공!
- 삭제 시에도 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 });
}
like 레코드 자동 삭제 확인을 위해 좋아요를 하나 누르고 테스트해보았다.
255번 게시글 생성
좋아요
잘 반영되었고
조인 테이블에도 추가됨 (255번 게시글, 5번 유저)
DELETE /post/225
당연히 게시글 삭제 됐고
이미지도 찾아보니 없음. 잘 삭제됨
Image 테이블에서도 정상적으로 레코드가 삭제됨
Like 조인테이블에서도 정상적으로 삭제됨
마지막으로 Star 컬렉션에서도 정상적으로 삭제됨. 끝!
- 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() 함수로 변경된 객체를 가져와서 리턴한다.
POST로 별글 등록. 이제 star는 필수 입력값으로 바뀌었다.
PATCH /star/226으로 해당 별글의 별 스타일을 변경. 마음대로 속성을 추가할 수 있다.
기존에 있던 속성은 변경되고, 새로운 속성은 추가되는 등 정상적으로 업데이트가 이루어진 것을 확인할 수 있다. 성공!
필요한 로직에 트랜잭션을 적용해보자.
사진 하단을 보면 UPDATE, DELETE가 일어나는 로직에 이미 내부적으로 Transaction 처리가 되어있는 것을 확인할 수 있다. 불필요하므로 패스.
게시글 수정 및 삭제 로직은 이미지에 대한 삭제, 삽입과 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) 참고하여 아래와같이
constructor(private readonly dataSource: DataSource) {}
추가해주고 사용하면 됨.
// 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 메소드 아래에서 실행한다.
만들어둔 포스트 삭제
진짜 되네? 야호!
이번엔 업데이트 로직.
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();
}
}
만들어둔 포스트 변경
반영은 잘 되는데, upload image 내에 있는 새로운 이미지에 대한 레코드 생성은 queryRunner로 돌지 못해서 별도의 트랜잭션이 도는 것을 확인할 수 있다.
이 부분은 별도로 개선해도 좋을 것 같다!
- before
- after
- before
- after
© 2023 debussysanjang
- 🐙 [가은] Three.js와의 설레는 첫만남
- 🐙 [가은] JS로 자전과 공전을 구현할 수 있다고?
- ⚽️ [준섭] NestJS 강의 정리본
- 🐧 [동민] R3F Material 간단 정리
- 👾 [재하] 만들면서 배우는 NestJS 기초
- 👾 [재하] GitHub Actions을 이용한 자동 배포
- ⚽️ [준섭] 테스트 코드 작성 이유
- ⚽️ [준섭] TypeScript의 type? interface?
- 🐙 [가은] 우리 팀이 Zustand를 쓰는 이유
- 👾 [재하] NestJS, TDD로 개발하기
- 👾 [재하] AWS와 NCP의 주요 서비스
- 🐰 [백범] Emotion 선택시 고려사항
- 🐧 [동민] Yarn berry로 모노레포 구성하기
- 🐧 [동민] Vite, 왜 쓰는거지?
- ⚽️ [준섭] 동시성 제어
- 👾 [재하] NestJS에 Swagger 적용하기
- 🐙 [가은] 너와의 추억을 우주의 별로 띄울게
- 🐧 [동민] React로 멋진 3D 은하 만들기(feat. R3F)
- ⚽️ [준섭] NGINX 설정
- 👾 [재하] Transaction (트랜잭션)
- 👾 [재하] SSH 보안: Key Forwarding, Tunneling, 포트 변경
- ⚽️ [준섭] MySQL의 검색 - LIKE, FULLTEXT SEARCH(전문검색)
- 👾 [재하] Kubernetes 기초(minikube), docker image 최적화(멀티스테이징)
- 👾 [재하] NestJS, 유닛 테스트 각종 mocking, e2e 테스트 폼데이터 및 파일첨부
- 2주차(화) - git, monorepo, yarn berry, TDD
- 2주차(수) - TDD, e2e 테스트
- 2주차(목) - git merge, TDD
- 2주차(일) - NCP 배포환경 구성, MySQL, nginx, docker, docker-compose
- 3주차(화) - Redis, Multer 파일 업로드, Validation
- 3주차(수) - AES 암복호화, TypeORM Entity Relation
- 3주차(목) - NCP Object Storage, HTTPS, GitHub Actions
- 3주차(토) - Sharp(이미지 최적화)
- 3주차(일) - MongoDB
- 4주차(화) - 플랫폼 종속성 문제 해결(Sharp), 쿼리 최적화
- 4주차(수) - 코드 개선, 트랜잭션 제어
- 4주차(목) - 트랜잭션 제어
- 4주차(일) - docker 이미지 최적화
- 5주차(화) - 어드민 페이지(전체 글, 시스템 정보)
- 5주차(목) - 감정분석 API, e2e 테스트
- 5주차(토) - 유닛 테스트(+ mocking), e2e 테스트(+ 파일 첨부)
- 6주차(화) - ERD
- 2주차(화) - auth, board 모듈 생성 및 테스트 코드 환경 설정
- 2주차(목) - Board, Auth 테스트 코드 작성 및 API 완성
- 3주차(월) - Redis 연결 후 RedisRepository 작성
- 3주차(화) - SignUpUserDto에 ClassValidator 적용
- 3주차(화) - SignIn시 RefreshToken 발급 및 Redis에 저장
- 3주차(화) - 커스텀 AuthGuard 작성
- 3주차(수) - SignOut시 토큰 제거
- 3주차(수) - 깃헙 로그인 구현
- 3주차(토) - OAuth 코드 통합 및 재사용
- 4주차(수) - NestJS + TypeORM으로 MySQL 전문검색 구현
- 4주차(목) - NestJS Interceptor와 로거
- [전체] 10/12(목)
- [전체] 10/15(일)
- [전체] 10/30(월)
- [FE] 11/01(수)~11/03(금)
- [전체] 11/06(월)
- [전체] 11/07(화)
- [전체] 11/09(목)
- [전체] 11/11(토)
- [전체] 11/13(월)
- [BE] 11/14(화)
- [BE] 11/15(수)
- [FE] 11/16(목)
- [FE] 11/19(일)
- [BE] 11/19(일)
- [FE] 11/20(월)
- [BE] 11/20(월)
- [BE] 11/27(월)
- [FE] 12/04(월)
- [BE] 12/04(월)
- [FE] 12/09(금)
- [전체] 12/10(일)
- [FE] 12/11(월)
- [전체] 12/11(월)
- [전체] 12/12(화)