TypeORM Entity Relation을 통한 외래키 설정과 활용, Join Table

박재하·2023년 11월 24일
0

목표

  • 게시글 본문 암복호화
  • Entity 간 관계 적용 (OneToMany, ManyToOne, OneToOne)
  • POST /board에 author <- nickname 직접 삽입
  • user 외래키 활용하도록 메소드 개선
  • 좋아요 중복 비허용 (조인테이블 생성)

Entity 간 관계 적용 (OneToMany, ManyToOne, OneToOne)

OneToOne 관계 (Board <-> Image)

게시글 하나당 사진 최대 하나이므로 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)에 대한 외래키 설정이 잘 되어 있는 것을 확인할 수 있다.

ManyToOne, OneToMany 관계 (board <-> user / board가 many)

하나의 작성자가 여러 글을 쓸 수 있으므로 이러한 관계가 정립된다.

@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

POST /board에 author <- nickname 직접 삽입

기능 검증

// 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();
}

이제 가드만 넣으면 유저 정보를 언제든 얻을 수 있다.

스크린샷 2023-11-22 오후 2 26 28

요렇게! nickname까지!

Guard를 통해 user 정보 얻기

이제 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 데코레이터는 모두 주석처리.

결과 화면

스크린샷 2023-11-22 오후 2 54 29

이제 author는 삽입되어도 무시하고 원래 닉네임으로 잘 들어간다.

user 외래키 활용하도록 메소드 개선

Entity 재정의

{ 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에 잘 반영되더라

User Repository 의존성 주입

어째선진 모르겠지만 TypeORM에서 관계성이 있는 값을 등록할 때, id가 아닌 객체 자체를 삽입해줘야 한다.

따라서 User Repository를 Board에서도 불러와야 한다.

@Module({
	imports: [TypeOrmModule.forFeature([Board, Image, User]), AuthModule],
	controllers: [BoardController],
	providers: [BoardService],
})
export class BoardModule {}

board 모듈에서 TypeOrmModule에 User를 같이 불러와주고

get userdata 커스텀 데코레이터 및 DTO 작성

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도 사용할 수 있게 각종 데코레이터를 추가했다.

POST /board

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;
}
스크린샷 2023-11-22 오후 6 52 34

PATCH /board/:id, DELETE /board/:id

수정, 삭제 시에는 단순 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 });
}
스크린샷 2023-11-22 오후 7 22 14

본인인 경우

스크린샷 2023-11-22 오후 7 24 02 스크린샷 2023-11-22 오후 7 24 21

다른 사용자인 경우 잘 차단되는 것 확인

트러블 슈팅

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단으로 땡겨서 해당 문제를 해결했다.

서비스 내에서는 암호화된 채로만 사용한다.

GET /board/by-author

마지막으로 /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;
}
스크린샷 2023-11-22 오후 7 38 28

닉네임 입력한 경우

스크린샷 2023-11-22 오후 7 38 47

닉네임 없는 경우

결과 화면

  • POST /board 개선
스크린샷 2023-11-22 오후 6 52 34
  • PATCH /board/:id
스크린샷 2023-11-22 오후 7 22 14 스크린샷 2023-11-22 오후 7 24 02
  • DELETE /board/:id
스크린샷 2023-11-22 오후 7 24 21
  • GET /board/by-author
스크린샷 2023-11-22 오후 7 38 28 스크린샷 2023-11-22 오후 7 38 47

좋아요 중복 비허용 (조인테이블 생성)

Join Table 생성

// board.entity.ts
@ManyToMany(() => User, { eager: true })
@JoinTable()
likes: User[];

@Column({ type: 'int', default: 0 })
like_cnt: number;

Many To Many 및 @JoinTable() 데코레이터로 like에 대한 조인 테이블을 생성한다.

스크린샷 2023-11-22 오후 8 09 04

잘 생성됨

User Data 및 Validation Pipe 적용

@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 비즈니스 로직 처리

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 에러 처리를 해준다.

결과 화면

  • PATCH /board/:id/like
스크린샷 2023-11-22 오후 8 03 46 스크린샷 2023-11-22 오후 8 05 58
  • PATCH /board/:id/unlike
스크린샷 2023-11-22 오후 8 06 06 스크린샷 2023-11-22 오후 8 06 13

학습메모

  1. one-to-one TypeORM 공식문서
  2. one-to-many, many-to-one TypeORM 공식문서
  3. 만들면서 배우는 NestJS 기초
  4. many-to-many TypeORM 공식문서
profile
해커 출신 개발자

0개의 댓글