NestJS, TypeORM 이해하기
Table of Content
논리적 문제 해결 프로세스
문제 해결 프로세스에 따른 과정
•
각 팀 또는 솔루션마다 단계별 문제 해결 모델들을 보여주고 있다.
•
문제 해결 과정에서 공통적으로 필수적이라 생각되는 부분은 아래와 같이 구분 해보았다.
◦
문제의 인식과 원인 분석
◦
문제 해결 방안 탐색과 타당성 검토
◦
문제 해결 실행
◦
해결된 문제의 테스트 또는 검증
◦
과정에서 발생하는 문제에 대한 트러블 슈팅 또는 추가 검토, 정리
•
개발중인 프로젝트에서의 문제를 발견하고 해결하는 과정을 가정하여 진행하고자 한다.
1. User와 Board 엔터티의 현재 상황(문제의 인식)
데이터베이스 설계의 핵심 요소
•
SQL 데이터 모델링은 세 가지 주요 요소를 포함한다.
◦
엔터티(Entities)
◦
속성(Attributes)
◦
관계(Relationships)
•
우리는 엔터티 클래스를 정의하면서 엔터티의 명칭과 속성을 이미 정의했다.
◦
User 엔터티와 Board 엔터티와 객체가 가져야 할 속성은 설계되었지만 서로 어떠한 관계를 가지고 있지 않다.
◦
하지만 실제 서비스의 입장으로 생각해보면 두개의 관계의 정의될 필요성을 느낄 수 있다.
▪
현재 게시글은 User의 로그인 상태를 요구하고 있지만 게시글에 어떠한 User의 정보도 담겨있지 않다.
▪
현재 유저는 로그인 할 수 있지만 실제 본인의 작성한 글을 구분 할 수 없다.
1.1 엔터티 관계 설계 시 기본 과정(문제해결방안 탐색1)
데이터베이스 설계 시 관계에 대한 정의 방법
•
엔터티간의 관계가 정의되지 않은 상황, 데이터베이스 설계 초기에
•
개발자는 각 엔터티의 입장에서 아래와 같은 물음을 해야 한다.
◦
개발자 혼자 생각하는 것이 아니라 실제로 팀 동료 또는 이해 관계자들과의 소통이 필요하다.
◦
물어보면서 기능 기획과의 일치가 필요하기에 입 밖으로 내뱉어 보면서 그 물음의 답이 명확해야 한다.
“하나의 유저는 여러개의 글을 작성할 수 있는가?”
•
실제 우리가 사용하는, 사용해오던 게시판들을 생각해보자.
•
우리는 로그인해서 여러 게시글을 작성한다.
•
전혀 현실과 동떨어진 이야기가 아니다. 또한 우리는 그런 서비스를 기획하고 개발중이다.
•
이럴 때 User : Board의 관계를 1 : N 으로 볼 수 있다. (One To Many == 일대다)
◦
하지만 아직 정의가 끝나지 않았다. 반대쪽 입장도 생각해야 한다.
“하나의 게시글은 여러 작성자(유저)를 가질 수 있는가?"
•
어떤 게시글이 있는데, 여러 작성자가 포함 될 수 있는가?
•
우리가 생각하는 일반적인 게시판과는 조금 다른 형태이다.
◦
기본적인 게시글은 1명의 작성자가 작성한다.
◦
우리는 멀티 유저에 대한 게시판을 기획하지 않고있다.
▪
여러 작성자를 가질 수 있는가에 대해서 “댓글처럼 여러 사용자를 담고 있지 않는가?” 라고 생각 할 수도 있다. 하지만 엄연히 게시글과 댓글은 전혀 다른 기능이며 이것은 Board 엔터티와 새로운 Comment라는 엔터티를 생성하여 이 또한 연관 관계로 풀어나가야 한다.
◦
결과 Board : User 는 N : 1의 관계로 볼 수 있다. (Many To One == 다대일)
위에 양쪽 입장에서의 명확한 논리적인 관계가 통일 될 때
•
우리는 User : Board 는 1 : N 의 관계이다 라고 정의한다.
1.2 관계에 대한 방향 설정 과정(문제해결방안 탐색2)
User : Board 는 1 : N으로 관계를 정리한 것 같지만. 아직 방향에 대한 정리가 되어있지 않다.
•
관계가 설정 되면 한단계 더 물어봐야 하는 것이 있다.
User : Board 가 1 : N인데 그럼 User는 Board의 데이터를 조회 할 필요가 있는가?
•
회원은 여러 게시글을 작성 할 수 있다.(이미 정의된 엔터티 관계)
◦
하지만, 그럼 회원을 조회 할 때 그 회원으로부터
▪
그 회원이 가진 여러 게시글들의 각 제목, 내용 등을 알 필요가 있는가?
▪
그 회원이 가진 여러 게시글들을 수정 할 필요가 있는가?
•
그 필요성이 하나라도 있을 때, User는 Board로 단방향 연관관계를 갖는다.
Board : User 는 N : 1인데 그럼 Board는 User의 데이터를 조회할 필요가 있는가?
•
여러 게시글은 각각 특정 회원으로부터 작성되게 된다.(이미 정의된 엔터티 관계)
◦
하지만, 그럼 게시글을 조회 할 때 그 게시글로부터
▪
그 게시글을 작성한 회원의 이름, 이메일 등 정보를 알 필요가 있는가?
▪
그 게시글을 작성한 회원을 수정 할 필요가 있는가?
◦
그 필요성이 하나라도 있을 때, Board는 User로 단방향 연관관계를 갖는다.
•
위 처럼 서로 단방향 관계를 가지고 있을 때, 이것을 양방향 연관관계이다 라고 정의한다.
2. User : Board - 1 : N 관계 맺기(문제의 해결 과정)
유저와 게시글의 관계를 1 : N으로 설정 했으면 Entity 클래스에서도 정의되어야 한다.
•
왼쪽을 User, 오른쪽을 Board로 두었을 때 블록 처리한 것 처럼 서로를 참조하는 필드가 추가되어야 한다.
두 코드를 살펴보면서 아래 내용을 자세히 볼 필요가 있다.
user.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { UserRole } from "./user-role.enum";
import { Board } from "src/boards/board.entity";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
@Column({ unique: true }) // 이메일은 중복되지 않도록 한다.
email: string;
@Column()
role: UserRole;
@OneToMany(Type => Board, board => board.author, { eager: false })
boards: Board[];
}
TypeScript
복사
•
User는 1 참조되는 Board가 N(다수)
◦
User에서는 여러 Board를 가지게 되므로 Board[] 과 같이 배열로 참조해야 한다.
•
@OneToMany() 라는 데코레이터로 해당 배열 필드의 설명을 추가한다
◦
앞에 있는 것이 본인(User) 따라서 (OneTo..) 이라 생각
•
@OneToMany() 데코레이터에 추가되는 속성들은 다음과 같다.
◦
Type은 참조되는 엔터티 객체인 Board 타입
◦
board ⇒ board.author 부분은 해당 user가 board의 어떤 부분에 들어가야 하는가를 말하는데, board의 author 부분에 user의 정보가 들어가도록 맵핑하는 것이다.
◦
eager는 방향 관계의 조회에 대한 부분이다.
▪
서로의 필드를 가지고 있기 때문에 기본적으로 양방향 연관관계가 설정되어 있다.
▪
그럼 회원을 통해서도 게시글들을 알 수 있고, 게시글을 통해서는 작성자를 알 수 있다고 위에서 설명했다.
▪
하지만 이러한 무분별한 조회는 성능적, 운영적 문제가 발생 할 수 있기 때문에 조회 방식에 대한 옵션이 제공된다.
•
eager : true = 즉시 조회 Eager Loading 옵션
◦
즉시 로딩이라고도 불린다.
◦
User는 Board[]에 즉시로딩이 설정되어 있으므로, User를 조회하면 관련 게시글들이 함께 조회된다.
▪
그만큼 많은 조회 쿼리가 불필요하게 생성 될 수도 있다는 것을 생각해야 함
▪
실제로 true로 하면 회원가입에서 문제가 생긴다. 없는 게시글을 가져오려고 하기 때문, 따라서 아래 레이지로딩을 선택
•
eager : false = 지연 조회 Lazy Loading옵션
◦
레이지, 지연 로딩이라고도 불린다.
◦
Board는 User에 지연로딩이 설정되어 있으므로, Board를 조회하면 기본적으로는 게시글의 필드들만 나타난다.
▪
board.user 처럼 명시적으로 접근 할 때만 추가적으로 조회 할 수 있다.
board.entity.ts
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { BoardStatus } from "./board-status.enum";
import { User } from "src/auth/user.entity";
@Entity()
export class Board {
@PrimaryGeneratedColumn()
id: number;
@Column()
author: string;
@Column()
title: string;
@Column()
contents: string;
@Column()
status: BoardStatus;
@ManyToOne(Type => User, user => user.boards, { eager: false })
user: User;
}
TypeScript
복사
•
Board가 N참조되는 User는 1
◦
Board는 여러개가 될 수 있지만 각 게시글은 각각 하나의 작성자를 가지므로 user 단수가 참조되어야 한다.
•
@ManyToOne() 라는 데코레이터로 해당 배열 필드의 설명을 추가한다
◦
앞에 있는 것이 본인(Board) 따라서 (ManyTo..) 이라 생각
•
@ManyToOne() 데코레이터에 추가되는 속성들은 다음과 같다.
◦
Type은 위 설명과 동일 참조된 필드는 User 타입을 가져야 한다.
◦
user ⇒ user.boards는 해당 board 객체들이 User가 가지고 있는 board[]에 들어가는 게시글들과 맵핑된다. user의 boards 부분에 board의 객체가 여러개 들어가도록 맵핑하는 것이다.
▪
이 부분에서 PK - FK 관계 정의가 생기고 실제 FK의 reference 필드가 정해진다. FK를 가진 부분이 연관관계의 주인이 된다.
•
Board - User의 boards와의 연관관계 주인은 본인(board)가 된다.
◦
eager는 방향 관계의 조회에 대한 부분이다.
▪
eager : false = 지연 조회 Lazy Loading옵션
▪
위에 지연 로딩 설명이 적용된 부분이라 볼 수 있다.
3. 로그인 된 User가 게시글의 작성자로 등록되도록 리팩토링(문제해결의 결과)
현재까지 로그인과 게시판 기능은 전혀 관계가 없었다.
•
현재 로그인 기능도 구현되었으며, 게시글 작성 기능도 구현되었다. 하지만 데이터베이스 설계 단계에서 엔터티간의 관계를 생각하지 않은 상태로 진행되었기 때문에 사실상 서로 관련이 없는 기능처럼 다뤄지고 있다.
•
하지만 처음 기획은 로그인된 사용자가 게시글을 작성하는 것이 목적이다. 따라서 JWT 인증을 배우며 로그인 유저가 게시글 작성에 접근 할 수 있는 권한 설정, 유저 정보를 획득하는 것까지 배워 왔다. 이제 게시글을 작성 할 때 로그인된 유저의 정보가 필요한 상태인 것이다.
•
현재 게시글의 작성자인 author를 요청 쿼리에 작성자명을 JSON 데이터로 입력받고 CreateBoardDto로 변환하여 작성해오고 있었다.
•
이제 인증/인가를 통해 로그인된 유저의 정보를 획득 할 수 있고
•
이를 통해 게시글 작성 접근 권한도 설정 할 수 있었다.
•
이것들을 활용하여 게시글 작성자를 로그인된 회원으로 설정하고자 한다.
◦
이 부분 까지 완성하면 많은 기능들을 이 베이스를 응용하여 구현 할 수 있게 된다.
boards.controller.ts
•
커스텀 데코레이터로 로그인 회원 정보를 획득하는 @GetUser() 를 활용
•
createBoard() 게시글 생성 기능의 파라미터로 CreateBoardDto 뿐만 아니라 User 도 함께 넣어주도록 수정
•
Service계층의 createBoard()메서드를 호출 할 때도 user 객체를 함께 전달
...
import { GetUser } from 'src/auth/get-user.decorator';
import { User } from 'src/auth/user.entity';
@Controller('api/boards')
@UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증과 role 커스텀 가드를 적용
export class BoardsController {
// 생성자 주입(DI)
constructor(private boardsService: BoardsService){}
// 게시글 작성 기능
@Post('/') // PostMapping 핸들러 데코레이터
@Roles(UserRole.USER) // User만 게시글 작성 가능
createBoard(@Body() createBoardDto: CreateBoardDto, @GetUser() logginedUser: User): Promise<Board> {
return this.boardsService.createBoard(createBoardDto, logginedUser)
}
// 게시글 조회 기능
...
}
TypeScript
복사
boards.service.ts
•
Controller로부터 User정보를 파라미터로 받아 오게 될 것이며 이를 저장하기 위하여 create() 로 엔터티 인스턴스를 만들게 된다.
•
기존 author를 직접 요청을 통해 받아왔지만 user객체에 담긴 사용자 이름으로 변경
•
그 외 user객체 자체를 넣어줘서 게시글로부터 user의 정보를 접근 할 수 있도록 함
◦
현재는 유저의 password 등 불필요한 정보를 모두 보내주지만,
◦
ResponseDto 등으로 변환하여 응답으로 반환할 객체를 정리하는 것이 옳다.
...
import { CreateBoardDto } from './dto/create-board.dto';
import { User } from 'src/auth/user.entity';
@Injectable()
export class BoardsService {
constructor(
@InjectRepository(Board)
private boardsRepository: Repository<Board>
){}
// 게시글 작성 기능
async createBoard(createBoardDto: CreateBoardDto, logginedUser: User): Promise<Board> {
const { title, contents } = createBoardDto;
if (!title || !contents) {
throw new BadRequestException('Title, and contents must be provided');
}
const newBoard = this.boardRepository.create({
author: logginedUser.username,
title,
contents,
status: BoardStatus.PUBLIC,
user: logginedUser
});
const createdBoard = await this.boardRepository.save(newBoard);
return createdBoard;
}
// 전체 게시글 조회
...
}
TypeScript
복사
•
POSTMAN을 통한 테스트
4. 기능을 개선하기(문제해결을 통한 추가적인 문제 검토)
4.1 특정 유저의 게시글만 조회 할 수 있는 기능 (QueryBuilder 사용해보기)
해당 기능은 보통 MyPage와 연관되어 있다.
•
어떤 회원은 MyPage를 통해서 “내가 작성한 글” 목록을 보고 싶을 수 있다.
•
그러면 특정 회원의 모든 게시글을 조회 하는 API를 추가하면 된다.
•
현재까지는 TypeORM이 기본적으로 제공해주는 Repository 클래스의 기본 메서드들을 활용했다.
•
이번에는 QueryBuilder라는 기능을 통해서 내가 원하는 쿼리문으로 튜닝 할 수 있는 기능을 사용하고자 한다.
QueryBuilder란?
•
QueryBuilder는 TypeORM의 가장 강력한 기능 중 하나입니다. 이 기능을 사용하면 편리한 구문을 사용하여 SQL 쿼리를 작성하고 이를 실행하여 자동으로 변환된 엔터티를 얻을 수 있습니다.
•
QueryBuilder는 객체지향적인 쿼리를 작성 할 수 있는 기술
•
메서드 체이닝을 통해 쿼리를 구성한다 ( . 도트 연산자를 사용)
◦
개발자가 SQL문을 직접 작성하는 것은 비효율적이다. (가독성, 유지보수성
)
▪
QueryBuilder는 복잡한 쿼리를 동적으로 생성
▪
조인, 서브쿼리, 그룹화, 정렬 등 다양한 SQL 기능을 쉽게 사용
Spring에서 쿼리 튜닝을 위해 사용되는 QueryDSL을 구현하는 기술 중 하나인 QueryBuilder와 유사하다.
•
물론 Q객체와 같은 타입안정성을 추가하는 JPA의 기능은 TypeORM에서 활용되고 있지는 않다.
•
위와 같은 차이점은 있지만 결국 아래 예시를 보면 TypeORM과 JPA의 QueryDSL 코드가 상당히 유사한 것을 볼 수 있다.
◦
TypeORM QueryBuilder
const users = await getConnection()
.getRepository(User)
.createQueryBuilder("user")
.where("user.salary > :salary", { salary: salaryThreshold })
.orderBy("user.salary", "DESC")
.getMany();
TypeScript
복사
◦
JPA QueryDSL
import static com.example.domain.QUser.user; // QUser 클래스 임포트
...
List<User> users = queryFactory
.selectFrom(user)
.where(user.salary.gt(salaryThreshold))
.orderBy(user.salary.desc())
.fetch();
TypeScript
복사
boards.service.ts
•
getMyAllBoards() 메서드는 Controller로부터 로그인된 회원인 User 객체를 파라미터로 받는다.
•
아래 체이닝 메서드를 통해 결과를 반환한다.
◦
boardsRepository를 통해 board 테이블에 대한 QueryBuilder를 생성
◦
WHERE 절을 사용하여 특정 사용자의 게시글만 조회하도록 조건을 설정
▪
{userId : user.id} = 전달받은 user객체의 id를 할당
▪
'board.userId = :userId' = 게시글의 board.userId와 위 userId가 동일한 것
•
:는 SQL 쿼리에서 바인딩 변수를 나타내는 표기법(Type 표기와 햇갈리지 말 것)
◦
getMany() = 조건에 맞는 모든 게시글을 배열 형태로 반환
...
import { CreateBoardDto } from './dto/create-board.dto';
import { User } from 'src/auth/user.entity';
@Injectable()
export class BoardsService {
constructor(
@InjectRepository(Board)
private boardsRepository: Repository<Board>
){}
// 게시글 작성
...
// 전체 게시글 조회
...
// 로그인된 유저가 작성한 게시글 조회 기능
async getMyAllBoards(logginedUser: User): Promise<Board[]> {
// 기본 조회에서는 엔터티를 즉시로딩으로 변경해야 User에 접근 할 수 있다.
// const foundBoards = await this.boardRepository.findBy({ user: logginedUser });
// 쿼리 빌더를 통해 lazy loading 설정된 엔터티와 관계를 가진 엔터티(User) 명시적 접근이 가능하다.
const foundBoards = await this.boardRepository.createQueryBuilder('board')
.leftJoinAndSelect('board.user', 'user') // 사용자 정보를 조인(레이지 로딩 상태에서 User 추가 쿼리)
.where('board.userId = :userId', { userId : logginedUser.id })
.getMany();
return foundBoards;
}
// 특정 번호의 게시글 조회
...
}
TypeScript
복사
boards.controller.ts
•
기존 게시글 조회 기능은 모든 게시글을 볼 수 있는 기본 기능이므로 그대로 둔다.
•
로그인한 회원의 글만 보여주는 기능은 추가기능이므로 새로운 API 핸들러 메서드를 구성해준다.
•
getMyAllBoards() 라는 메서드에 파라미터로 커스텀 데코레이터로 회원의 정보를 가져올 수 있는 @GetUser() 데코레이터로 User 객체를 추가한다.(이는 로그인된 회원의 정보)
•
받아온 로그인된 회원 정보 user를 Service계층의 getMyAllBoards(user)라는 메서드를 호출하는 인수로 전달
•
Service 계층의 결과는 다수 일 수 있으므로 Board[] 로 배열로 반환한다.
,,,
import { User } from 'src/auth/user.entity';
@Controller('api/boards')
@UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증과 role 커스텀 가드를 적용
export class BoardsController {
// 생성자 주입(DI)
constructor(private boardsService: BoardsService){}
// 게시글 작성 기능
...
}
// 게시글 조회 기능
...
}
// 나의 게시글 조회 기능(로그인 유저)
@Get('/myboards')
async getMyAllBoards(@GetUser() logginedUser: User): Promise<BoardResponseDto[]> {
const boards: Board[] = await this.boardsService.getMyAllBoards(logginedUser);
const boardsResponseDto = boards.map(board => new BoardResponseDto(board));
return boardsResponseDto;
}
// 특정 번호의 게시글 조회
...
}
}
TypeScript
복사
•
POSTMAN을 통한 테스트
•
로그인된 회원 “김회원”의 userId에 해당되는 글만 가져오게 된다.
•
실제로 이름은 동일한 경우를 배제 할 수 없기 때문에, userId 가 다른 동명이인 회원의 경우 윗 글이 조회되지 않는다.
◦
실제 서비스에선 이름이 동일 할 수 있는 경우가 있기 때문에, PK인 userId 또는 Unique 속성이 있는 다른 필드로 회원을 명확하게 구분할 필요가 있다.
4.2 로그인된 유저만이 자신의 게시글을 삭제 할 수 있는 기능과 부분 수정의 기획 변경 적용
현재 게시글 삭제 기능의 리팩토링
•
게시글 삭제에서 역할에 대한 권한 제한은 있지만 특정 유저인 부분이 없다.
...
// 게시글 삭제 기능
@Delete('/:id')
async deleteBoardById(@Param('id') id: number): Promise<void> {
await this.boardsService.deleteBoardById(id);
}
...
TypeScript
복사
boards.controller.ts
•
@GetUser() 데코레이터로 User 객체를 추가한다.(이는 로그인된 회원의 정보)
•
받아온 로그인된 회원 정보 user를 Service계층의 deleteBoardById(id, user)라는 메서드를 호출하는 인수로 전달
...
import { User } from 'src/auth/user.entity';
@Controller('api/boards')
@UseGuards(AuthGuard(), RolesGuard) // JWT 인증과 role 커스텀 가드를 적용
export class BoardsController {
// 생성자 주입(DI)
constructor(private boardsService: BoardsService){}
...
// 특정 번호의 게시글 삭제
@Delete('/:id')
@Roles(UserRole.ADMIN, UserRole.USER) // ADMIN, USER만 게시글 삭제 가능
deleteBoardById(@Param('id') id: number, @GetUser() user: User): void {
this.boardsService.deleteBoardById(id, user);
}
...
}
TypeScript
복사
boards.service.ts
...
import { UserRole } from 'src/auth/user-role.enum';
@Injectable()
export class BoardsService {
constructor(
@InjectRepository(Board)
private boardsRepository: Repository<Board>
){}
...
// 게시글 삭제 기능
async deleteBoardById(id: number, logginedUser: User): Promise<void> {
const foundBoard = await this.getBoardDetailById(id);
// 작성자와 요청한 사용자가 같은지 확인
if (foundBoard.user.id !== logginedUser.id) {
throw new UnauthorizedException('Do not have permission to delete this board')
}
await this.boardRepository.delete(foundBoard);
}
...
}
TypeScript
복사
4.3 삭제 기능에 대한 기획의 변경
현재 게시글 삭제 기능에 대해 다시 생각해 보기
•
ADMIN이 삭제하는 권한을 가지는 것이 맞는가? 에 대한 생각
◦
이는 기획에 따라 다르지만 게시글 삭제 권한은 해당 유저에게만 있어야 된다고 생각이 되었음
◦
공개글을 비공개글로 바꾸는 상태 업데이트 기능이 있기 때문에, 관리자는 부적절한 글에 대해서 비공개로 바꾸는 권한만을 주는것이 더 옳다고 판단됨
board.entity.ts
•
우선 전에 잠시 학습했던 즉시 로딩과 지연 로딩에 대한 처리가 필요하다.
•
현재 게시글에서 User의 정보는 eager : false로 설정되어 있다.
◦
이말은 Lazy 로딩 상태이며, 직접 접근하지 않는 이상 게시글에 User 정보를 주지는 않는다는 상태.
▪
Lazy 상태를 유지하고 QueryBuilder로 board.user 처럼 접근 할 수도 있다.
▪
Eager 상태를 활성화 시켜서 board 조회 시 user까지 모두 기본적으로 조회 가능하도록 할 수 있다.
▪
이는 불필요한 조회가 생기는가 vs 추가 쿼리가 필요한가 등 고려할 점이 많으므로 하나가 무조건 옳다라는 개념은 아니다. 기획에 따라 결정해야 되는 부분이라는 것.
•
eager : false 로 유지하고 게시글 id를 통해 조회하는 getBoardDetailById() 공통 메서드 자체에서 쿼리 빌더를 통해 user 정보까지 획득하도록 설정했다.
◦
이에 따라 특정 게시글의 조회 메서드가 사용되는 기능(특정 게시글 수정, 삭제 등)들에 기본적으로 게시글의 user 정보에 접근 할 수 있는 상태가 되었다.
@Injectable()
export class BoardsService {
// Repository 계층 DI
constructor(
@InjectRepository(Board)
private boardRepository : Repository<Board>
){}
...
// 특정 게시글 조회 기능
async getBoardDetailById(id: number): Promise<Board> {
const foundBoard = await this.boardRepository.createQueryBuilder('board')
.leftJoinAndSelect('board.user', 'user') // 사용자 정보를 조인
.where('board.id = :id', { id })
.getOne();
if (!foundBoard) {
throw new NotFoundException(`Board with ID ${id} not found`);
}
return foundBoard;
}
...
}
TypeScript
복사
boards.controller.ts
•
게시글의 삭제
◦
회원의 개인의 자유로만 관리되어야 한다 생각한 점을 고려, 회원 상태만을 가드를 통해 제한(USER or ADMIN)
◦
게시글의 주인(board에 관계된 user)만 실제로 삭제 할 수 있도록 변경
•
게시글의 부분 수정(공개 상태 status 수정) - ADMIN 전용 기능으로 변경
◦
관리자는 최소한의 관리 기능은 있어야 한다고 판단하여, 게시글의 공개 상태를 수정하는 기능에 권한 부여
◦
@Roles() 커스텀 데코레이터에서 UserRole.ADMIN을 추가
...
import { User } from 'src/auth/user.entity';
@Controller('api/boards')
@UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증과 role 커스텀 가드를 적용
export class BoardsController {
// 생성자 주입(DI)
constructor(private boardsService: BoardsService){}
...
// 특정 번호의 게시글 삭제
@Delete('/:id')
@Roles(UserRole.USER) // USER만 게시글 삭제 가능
deleteBoardById(@Param('id') id: number, @GetUser() user: User): void {
this.boardsService.deleteBoardById(id, user);
}
// 특정 번호의 게시글의 일부 수정 (관리자가 부적절한 글을 비공개로 설정) // 커스텀 파이프 사용은 명시적으로 사용하는 것이 일반적
@Patch('/:id/status')
@Roles(UserRole.ADMIN)
updateBoardStatusById(@Param('id') id: number, @Body('status', BoardStatusValidationPipe) status: BoardStatus, @GetUser() user: User): void {
this.boardsService.updateBoardStatusById(id, status, user)
}
...
}
TypeScript
복사
boards.service.ts
•
게시글의 삭제
◦
ADMIN의 관리 권한을 제거했으므로 Service 계층에서도 ADMIN을 허용하던 예외 부분을 제거
•
게시글의 부분 수정(공개설정)
◦
관리자는 게시글 비공개 관리 기능의 권한이 추가되었으므로 관리자만이 해당 API를 이용 할 수 있도록 관리자를 확인하는 예외 처리 추가
▪
해당 기능은 수정 기능 중에서 Patch Method를 사용하는 update() 메서드를 사용하고 있다. (일반적인 수정은 save()로 전체 업데이트를 한다.)
▪
update() 메서드는 레코드 중 일부 필드의 값을 수정하는 경우라서 업데이트 행의 수를 알 수 있는 result.affected 로 체크하면 좋다.
▪
일반 사용자가 접근한 경우에도 권한 없는 예외를 처리해준다.
...
import { UserRole } from 'src/auth/user-role.enum';
@Injectable()
export class BoardsService {
constructor(
@InjectRepository(Board)
private boardsRepository: Repository<Board>
){}
...
// 특정 번호의 게시글 삭제
async deleteBoardById(id: number, user: User): Promise<void> {
const foundBoard = await this.getBoardById(id); // 게시글 조회
// 작성자와 요청한 사용자가 같은지 확인
if (foundBoard.user.id !== user.id) {
throw new UnauthorizedException(`You do not have permission to delete this board`);
}
await this.boardsRepository.remove(foundBoard); // 게시글 삭제
}
// 특정 번호의 게시글의 일부 수정(관리자가 부적절한 글을 비공개로 설정)
async updateBoardStatusById(id: number, status: BoardStatus, user: User): Promise<void> {
// 관리자인지 확인
if (user.role === UserRole.ADMIN) {
// 관리자는 상태를 변경할 수 있음
const result = await this.boardsRepository.update(id, { status });
if (result.affected === 0) {
throw new NotFoundException(`There's no updated record or Board with ID ${id} not found`);
}
} else {
// 일반 사용자는 상태 변경 권한이 없음
throw new UnauthorizedException(`You do not have permission to update the status of this board`);
}
}
...
}
TypeScript
복사
•
POSTMAN을 통한 테스트
◦
회원은 본인의 게시글을 삭제 할 수 있다.
◦
관리자는 다른 유저가 작성한 글이더라도 공개 상태를 변경 할 수 있다.
5. 연관관계에 대한 추가 이야기(문제해결의 회고)
현재 1:N에 대한 연관관계만 설명되어있다.
•
N:M, 1:1 등에 대해서는 설명되지 않았다.
•
가장 기본적으로 1:N을 이해한 이후에 다른 연관관계를 코드에서 적용하는 방법을 추가해나가도록 한다.
•
결국 우리가 필요한건 1:N로 풀어나가고 다른 연관 관계를 가질 때 찾아봐야 한다.
◦
특히 N:M(다대다)는 중간테이블로 구분하면 결과적으로 1:N, 1:M으로 동일한 구현이 나온다.
추가적인 구현 연습 필요
•
강의는 효율성을 위해서 기본적인것을 풀어 준 것이고 프로젝트의 방향과 기능에 따라 1:1, N:M 관계가 나타날 것이며 이것들을 풀어나가야 한다.
◦
1:1(일대일) 관계에서는 @JoinColumn을 사용하여 외래 키(FK)를 명시적으로 설정
◦
N:M(다대다) 관계를 구현할 때, 중간 테이블을 사용하여 1:N + N:1로 구조를 만드는 것이 일반적으로 권장
▪
N:M 을 @ManyToMany로 설정 할 수 있지만 자동으로 생성된 미들 테이블은 단순히 각 FK들만 맵핑되는 테이블이 생성된다. 하지만 데이터의 인덱스(id), 추가 메타데이터(예: 생성일, 상태 등)를 설정 하기 어렵다.
▪
그 관계를 형성해주는 미들 테이블 자체도 개발자가 쉽게 접근하고 비지니스 로직에 활용하고자 하기 위해서 명시적으로 중간 테이블을 생성하는것을 권장
•
여러 자료들을 통해서 연관관계, 관계차수, 정규화 등을 스스로 추가적으로 찾아 보아야 한다.
•
데이터 모델링 부분은 사례도 많이 찾아봐야 하며 개발자의 성향에 따라서도 다르고 개발자의 경험치에 따라서도 다르다.
◦
어떠한 설계가 효율적이라 생각 할 수도 있지만 성능적인 문제가 있을 수 있고, 개발자, 이해관계자 등 바라보는 관점에 따라서 객관적인 정답이 없는 경우도 있다.
◦
많은 고민을 해보면서 풀어내야 하는 부분이기 때문에 많은 검색, 구글링, 멘토링 등으로 경험을 누적하는 것이 필요하다고 생각 된다.
•
특히 ORM에서 어떤 관계에 어떤 데코레이터와 어떤 표현이 필요한지는 직접 공식 문서를 찾아보는 것이 가장 정확하다.
◦
아래는 TypeORM에서 1:N, N:M, 1:1 과 같은 예시를 볼 수 있다. 위 예제 코드도 1:N에 대한 공식문서의 내용을 기반으로 작성되었다.
•
아래 데이터베이스 설계과정의 엔터티의 관계에 대해 추가적인 학습이 잘 정리된 블로그를 정리해두었다.
◦
이 외 GPT등의 도움을 받을 수 있지만 신뢰 할 수 있는 자료를 정제해서 보는 것이 중요하다.
Related Posts
Search