게시글 하나당 사진 최대 하나이므로 OneToOne. 없을 수도 있으므로 nullable을 넣었다.
학습메모 2 참고. Image Entity에는 별도로 설정이 필요 없다.
// board.entity.ts
@Entity()
export class Board extends BaseEntity {
...
@OneToOne(() => Image, { nullable: true })
@JoinColumn()
image_id: number;
}
image_id가 FK로 Image 엔티티의 PK인 id를 참조하게 되는 거임.
-- Active: 1694011841232@@192.168.64.2@3306@b1g1
CREATE TABLE `board` (
...
UNIQUE KEY `REL_1e34245d55ca9414293c6e4276` (`imageIdId`),
...
CONSTRAINT `FK_1e34245d55ca9414293c6e4276c` FOREIGN KEY (`imageIdId`) REFERENCES `image` (`id`),
...
) ENGINE=InnoDB AUTO_INCREMENT=138 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
board 테이블의 DDL을 확인해보면 image(id)
에 대한 외래키 설정이 잘 되어 있는 것을 확인할 수 있다.
하나의 작성자가 여러 글을 쓸 수 있으므로 이러한 관계가 정립된다.
@Entity()
export class Board extends BaseEntity {
...
@ManyToOne(() => User, (user) => user.boards, { onDelete: 'CASCADE' })
user: User;
}
@Entity()
export class User {
...
@OneToMany(() => Board, (board) => board.user)
boards: Board[];
}
author를 외래키로 설정하고 user(nickname)을 참조하고 싶은 상태인데, 그런 설정은 찾아봐도 안나옴.
추후 가능하면 넣자. user.nickname으로 지금도 접근할 수 있다.
ON DELETE CASCADE
문도 삽입
-- Active: 1694011841232@@192.168.64.2@3306@b1g1
CREATE TABLE `board` (
`id` int NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`content` text,
`author` varchar(50) NOT NULL,
`like_cnt` int NOT NULL DEFAULT '0',
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
`imageId` int DEFAULT NULL,
`userId` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `REL_6c2a2c2b30fbb895ef2dc41294` (`imageId`),
KEY `FK_c9951f13af7909d37c0e2aec484` (`userId`),
CONSTRAINT `FK_6c2a2c2b30fbb895ef2dc412947` FOREIGN KEY (`imageId`) REFERENCES `image` (`id`),
CONSTRAINT `FK_c9951f13af7909d37c0e2aec484` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=138 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
// cookie-auth.guard.ts
request.user = { userId, username, nickname };
이제 Guard를 통해 req.user에 정보를 넣어주므로
@Get()
@UseGuards(CookieAuthGuard)
findAllBoards(@Req() req): Promise<Board[]> {
console.log(req.user);
return this.boardService.findAllBoards();
}
이제 가드만 넣으면 유저 정보를 언제든 얻을 수 있다.
요렇게! nickname까지!
이제 create dto로 author를 입력받는 게 아닌 서버에서 자동 삽입해주도록 바꿔보자.
@Post()
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
@ApiOperation({ summary: '게시글 작성', description: '게시글을 작성합니다.' })
@ApiCreatedResponse({ status: 201, description: '게시글 작성 성공' })
@ApiBadRequestResponse({
status: 400,
description: '잘못된 요청으로 게시글 작성 실패',
})
createBoard(
@Req() req,
@Body() createBoardDto: CreateBoardDto,
): Promise<Board> {
if (req.user && req.user.nickname)
createBoardDto.author = req.user.nickname;
return this.boardService.createBoard(createBoardDto);
}
// 서버에서 직접 삽입해주도록 변경 (validation 제거)
// @IsNotEmpty({ message: '게시글 작성자는 필수 입력입니다.' })
// @IsString({ message: '게시글 작성자는 문자열로 입력해야 합니다.' })
// @MaxLength(50, { message: '게시글 작성자는 50자 이내로 입력해야 합니다.' })
author: string;
validation 데코레이터는 모두 주석처리.
이제 author는 삽입되어도 무시하고 원래 닉네임으로 잘 들어간다.
{ eager: true }
구문을 사용하지 않아 바로 반영이 안되어 수많은 삽질을 반복했다..
@Entity()
export class User {
@OneToMany(() => Board, (board) => board.user, { eager: false })
boards: Board[];
}
@Entity()
export class Board extends BaseEntity {
@ManyToOne(() => User, (user) => user.boards, {
eager: true,
onDelete: 'CASCADE',
})
user: User;
}
결과적으로 보드쪽에서 {eager: true}
, 유저쪽에서 {eager: false}
, 해주면 DB에 잘 반영되더라
어째선진 모르겠지만 TypeORM에서 관계성이 있는 값을 등록할 때, id가 아닌 객체 자체를 삽입해줘야 한다.
따라서 User Repository를 Board에서도 불러와야 한다.
@Module({
imports: [TypeOrmModule.forFeature([Board, Image, User]), AuthModule],
controllers: [BoardController],
providers: [BoardService],
})
export class BoardModule {}
board 모듈에서 TypeOrmModule에 User를 같이 불러와주고
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
export const GetUser = createParamDecorator((_, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.user;
});
@Req
로 request 전체를 불러오지 않고 cookie의 jwt 토큰에서 추출한 User에 대한 데이터(id, username, nickname)만 추출하도록
@GetUser()
데코레이터를 새로 만들었다.
또한 이렇게 가져온 값의 유효성을 입증하기 위해 user-data.dto.ts를 board모듈에 추가해 필요한 곳에 타입을 지정하도록 했다.
import { IsInt, IsNotEmpty, IsString } from 'class-validator';
export class UserDataDto {
@IsNotEmpty()
@IsInt()
userId: number;
@IsNotEmpty()
@IsString()
username: string;
@IsNotEmpty()
@IsString()
nickname: string;
}
Validation Pipe도 사용할 수 있게 각종 데코레이터를 추가했다.
board의 생성 시엔 author를 집어넣는 대신, 참조된 user 객체의 nickname 항목을 가져다 쓰면 된다.
그렇기 때문에 해당 board entity에서 author를 아예 제거하고, user repository에서
직접 user 객체를 찾아 저장 시 삽입하도록 변경했다.
async createBoard(
createBoardDto: CreateBoardDto,
userData: UserDataDto,
): Promise<Board> {
const { title, content } = createBoardDto;
const user = await this.userRepository.findOneBy({ id: userData.userId });
const board = this.boardRepository.create({
title,
content: encryptAes(content), // AES 암호화하여 저장
user,
});
const createdBoard: Board = await this.boardRepository.save(board);
createdBoard.user.password = undefined; // password 제거하여 반환
return createdBoard;
}
수정, 삭제 시에는 단순 Guard 인증 뿐만이 아니라, 내가 작성한 게시글인 경우에만
처리가 가능해야 한다. 따라서 user data를 활용해 이를 검사한다.
async updateBoard(
id: number,
updateBoardDto: UpdateBoardDto,
userData: UserDataDto,
) {
const board: Board = await this.findBoardById(id);
// 게시글 작성자와 수정 요청자가 다른 경우
if (board.user.id !== userData.userId) {
throw new BadRequestException('You are not the author of this post');
}
// updateBoardDto.content가 존재하면 AES 암호화하여 저장
if (updateBoardDto.content) {
updateBoardDto.content = encryptAes(updateBoardDto.content);
}
const updatedBoard: Board = await this.boardRepository.save({
...board,
...updateBoardDto,
});
return updatedBoard;
}
async deleteBoard(id: number, userData: UserDataDto): Promise<void> {
const board: Board = await this.findBoardById(id);
// 게시글 작성자와 삭제 요청자가 다른 경우
if (board.user.id !== userData.userId) {
throw new BadRequestException('You are not the author of this post');
}
const result = await this.boardRepository.delete({ id });
}
본인인 경우
다른 사용자인 경우 잘 차단되는 것 확인
update, delete 간 findBoardById()의 호출로 인해
수정이 발생하지 않았을 때 평문으로 복호화된 레코드가 저장되어버리는 문제 발생
async findBoardById(@Param('id', ParseIntPipe) id: number): Promise<Board> {
const found = await this.boardService.findBoardById(id);
// AES 복호화
if (found.content) {
found.content = decryptAes(found.content); // AES 복호화하여 반환
}
return found;
}
decrypt 로직을 controller단으로 땡겨서 해당 문제를 해결했다.
서비스 내에서는 암호화된 채로만 사용한다.
마지막으로 /board/by-author는 닉네임으로 조회를 하되
author 컬럼이 아닌 user.nickname을 조회하도록 변경했고,
아무런 파라미터가 없으면 본인 닉네임을 삽입하도록 했다.
findAllBoardsByAuthor(
@Query('author') author: string,
@GetUser() userData: UserDataDto,
): Promise<Board[]> {
// 파라미터 없는 경우 로그인한 사용자의 게시글 조회
author = author ? author : userData.nickname;
return this.boardService.findAllBoardsByAuthor(author);
}
async findAllBoardsByAuthor(author: string): Promise<Board[]> {
const boards = await this.boardRepository.findBy({
user: { nickname: author },
});
return boards;
}
닉네임 입력한 경우
닉네임 없는 경우
// board.entity.ts
@ManyToMany(() => User, { eager: true })
@JoinTable()
likes: User[];
@Column({ type: 'int', default: 0 })
like_cnt: number;
Many To Many 및 @JoinTable()
데코레이터로 like에 대한 조인 테이블을 생성한다.
잘 생성됨
@Patch(':id/like')
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
patchLike(
@Param('id', ParseIntPipe) id: number,
@GetUser() userData: UserDataDto,
): Promise<Partial<Board>> {
return this.boardService.patchLike(id, userData);
}
@Patch(':id/unlike')
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
patchUnlike(
@Param('id', ParseIntPipe) id: number,
@GetUser() userData: UserDataDto,
): Promise<Partial<Board>> {
return this.boardService.patchUnlike(id, userData);
}
컨트롤러 단에서 위에서 작업했던 것과 같은 방식으로 user data를 받아 서비스로 넘긴다.
like_cnt를 단순히 더하고 빼는 것이 아닌, jointable에 삽입하고 빼는 과정으로 처리하고,
그 배열의 길이를 like_cnt에 넣는 형식으로 해서 예외 상황을 없앤다.
async patchLike(id: number, userData: UserDataDto): Promise<Partial<Board>> {
const board = await this.findBoardById(id);
console.log(board);
if (board.likes.find((user) => user.id === userData.userId)) {
throw new BadRequestException('You already liked this post');
}
const user = await this.userRepository.findOneBy({ id: userData.userId });
if (!user) {
throw new NotFoundException(`Not found user with id: ${userData.userId}`);
}
board.likes.push(user);
board.like_cnt = board.likes.length;
const updatedBoard = await this.boardRepository.save(board);
return { like_cnt: updatedBoard.like_cnt };
}
async patchUnlike(
id: number,
userData: UserDataDto,
): Promise<Partial<Board>> {
const board = await this.findBoardById(id);
if (!board.likes.find((user) => user.id === userData.userId)) {
throw new BadRequestException('You have not liked this post');
}
const user = await this.userRepository.findOneBy({ id: userData.userId });
if (!user) {
throw new NotFoundException(`Not found user with id: ${userData.userId}`);
}
board.likes = board.likes.filter((user) => user.id !== userData.userId);
board.like_cnt = board.likes.length;
const updatedBoard = await this.boardRepository.save(board);
return { like_cnt: updatedBoard.like_cnt };
}
잘못된 요청이 있으면 400 및 404 에러 처리를 해준다.