Blog

[NestJS] 8. 엔터티의 연관 관계(Relationships) feat. 문제 인식과 해결

Category
Author
citeFred
citeFred
PinOnMain
1 more property
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 : UserN : 1의 관계로 볼 수 있다. (Many To One == 다대일)
위에 양쪽 입장에서의 명확한 논리적인 관계가 통일 될 때
우리는 User : Board 는 1 : N 의 관계이다 라고 정의한다.

1.2 관계에 대한 방향 설정 과정(문제해결방안 탐색2)

User : Board1 : N으로 관계를 정리한 것 같지만. 아직 방향에 대한 정리가 되어있지 않다.
관계가 설정 되면 한단계 더 물어봐야 하는 것이 있다.
User : Board 가 1 : N인데 그럼 User는 Board의 데이터를 조회 할 필요가 있는가?
회원은 여러 게시글을 작성 할 수 있다.(이미 정의된 엔터티 관계)
하지만, 그럼 회원을 조회 할 때 그 회원으로부터
그 회원이 가진 여러 게시글들의 각 제목, 내용 등을 알 필요가 있는가?
그 회원이 가진 여러 게시글들을 수정 할 필요가 있는가?
그 필요성이 하나라도 있을 때, UserBoard단방향 연관관계를 갖는다.
Board : User 는 N : 1인데 그럼 Board는 User의 데이터를 조회할 필요가 있는가?
여러 게시글은 각각 특정 회원으로부터 작성되게 된다.(이미 정의된 엔터티 관계)
하지만, 그럼 게시글을 조회 할 때 그 게시글로부터
그 게시글을 작성한 회원의 이름, 이메일 등 정보를 알 필요가 있는가?
그 게시글을 작성한 회원을 수정 할 필요가 있는가?
그 필요성이 하나라도 있을 때, BoardUser단방향 연관관계를 갖는다.
위 처럼 서로 단방향 관계를 가지고 있을 때, 이것을 양방향 연관관계이다 라고 정의한다.

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
복사
User1 참조되는 BoardN(다수)
User에서는 여러 Board를 가지게 되므로 Board[] 과 같이 배열로 참조해야 한다.
@OneToMany() 라는 데코레이터로 해당 배열 필드의 설명을 추가한다
앞에 있는 것이 본인(User) 따라서 (OneTo..) 이라 생각
@OneToMany() 데코레이터에 추가되는 속성들은 다음과 같다.
Type은 참조되는 엔터티 객체인 Board 타입
board ⇒ board.author 부분은 해당 userboard의 어떤 부분에 들어가야 하는가를 말하는데, boardauthor 부분에 user의 정보가 들어가도록 맵핑하는 것이다.
eager는 방향 관계의 조회에 대한 부분이다.
서로의 필드를 가지고 있기 때문에 기본적으로 양방향 연관관계가 설정되어 있다.
그럼 회원을 통해서도 게시글들을 알 수 있고, 게시글을 통해서는 작성자를 알 수 있다고 위에서 설명했다.
하지만 이러한 무분별한 조회는 성능적, 운영적 문제가 발생 할 수 있기 때문에 조회 방식에 대한 옵션이 제공된다.
eager : true = 즉시 조회 Eager Loading 옵션
즉시 로딩이라고도 불린다.
UserBoard[]에 즉시로딩이 설정되어 있으므로, 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
복사
BoardN참조되는 User1
Board는 여러개가 될 수 있지만 각 게시글은 각각 하나의 작성자를 가지므로 user 단수가 참조되어야 한다.
@ManyToOne() 라는 데코레이터로 해당 배열 필드의 설명을 추가한다
앞에 있는 것이 본인(Board) 따라서 (ManyTo..) 이라 생각
@ManyToOne() 데코레이터에 추가되는 속성들은 다음과 같다.
Type은 위 설명과 동일 참조된 필드는 User 타입을 가져야 한다.
user ⇒ user.boards는 해당 board 객체들이 User가 가지고 있는 board[]에 들어가는 게시글들과 맵핑된다. userboards 부분에 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등의 도움을 받을 수 있지만 신뢰 할 수 있는 자료를 정제해서 보는 것이 중요하다.
Search
 | Main Page | Category |  Tags | About Me | Contact | Portfolio