낙서장이자 오답 노트이자 컨닝 페이퍼

패스트캠퍼스 환급챌린지 19일차 : 코드팩토리의 백엔드 아카데미 : 한 번에 끝내는 NestJS 패키지 - 기초부터 MSA까지 강의 후기 본문

NestJS

패스트캠퍼스 환급챌린지 19일차 : 코드팩토리의 백엔드 아카데미 : 한 번에 끝내는 NestJS 패키지 - 기초부터 MSA까지 강의 후기

NangIn 2025. 4. 19. 16:07

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

강의 내용 정리   

 

Genre Many to Many Relationship 생성하고 엔드포인트 작업하기

 

관계 설계 배경

  • 기존에는 movie.entity의 genre가 단순 문자열(string)로 구성되어 있었음
    • → 장르별 기능(좋아요, 댓글 등) 확장을 위해 엔티티로 분리 필요
  • Movie와 Genre는 다음과 같은 관계를 가짐:
    • 하나의 영화는 여러 장르를 가질 수 있음
    • 하나의 장르는 여러 영화에 속할 수 있음
      • → 따라서 Many-to-Many 관계가 적절
  • 불가능한 다른 관계 구성:
    • OneToOne: 영화 하나당 장르 하나 → 비현실적
    • ManyToOne: 영화 여러 개가 한 장르에 속할 순 있어도, 장르가 다른 영화에 속하지 못함

 

Genre 모듈 생성

nest g resource genre
  • REST API + CRUD 엔드포인트 자동 생성

 

ManyToMany 관계 구성

  • Genre 엔티티
    • @Entity()
      export class Genre extends BaseTable {
        @PrimaryGeneratedColumn()
        id: number;
      
        @Column({ unique: true })
        name: string;
      
        @ManyToMany(() => Movie, (movie) => movie.genres)
        movies: Movie[];
      }
      
      • movie.genres 프로퍼티로 영화들을 가져옴
  • Movie 엔티티
    • @ManyToMany(() => Genre, (genre) => genre.movies)
      @JoinTable()
      genres: Genre[];
      
      • genre.movies 프로퍼티로 장르들을 가져옴
      • ManyToMany 관계는 @JoinTable()을 반드시 한쪽에 명시 (보통 메인 테이블 쪽에서)
      • 관계 테이블 자동 생성됨 (예: movie_genres_genre)
        • 구성: movieId, genreId 두 칼럼만 존재

 

Genre CRUD 구현

  • 모듈 의존성 설정:
    • // app.module.ts
        TypeOrmModule.forRootAsync({
          // ...
            entities: [Movie, MovieDetail, Director, Genre],
      		// ...
        }),
      
    • // genre.module.ts
      @Module({
        imports: [TypeOrmModule.forFeature([Genre])],
        controllers: [GenreController],
        providers: [GenreService],
      })
      
  • 레포지토리 주입:
    • // genre.service.ts
      constructor(
        @InjectRepository(Genre)
        private readonly genreRepository: Repository<Genre>,
      ) {}
      
  • 메서드별 구현
    • Create
      • // create-genre.dto.ts
        export class CreateGenreDto {
          @IsNotEmpty()
          name: string;
        }
        
      • // genre.service.ts
        
        create(createGenreDto: CreateGenreDto) {
          return this.genreRepository.save(createGenreDto);
        }
        
    • Read
      • 전체
        •   findAll() {
              return this.genreRepository.find();
            }
          
      • 단일:
        •   findOne(id: number) {
              return this.genreRepository.findOne({ where: { id } });
            }
          
    • Update
      • // update-genre.dto.ts
        export class UpdateGenreDto {
          @IsNotEmpty()
          @IsOptional()
          name?: string;
        }
        
      • async update(id: number, updateGenreDto: UpdateGenreDto) {
          const genre = await this.genreRepository.findOne({
            where: {
              id,
            },
          });
        
          if (!genre) {
            throw new NotFoundException('존재하지 않는 장르입니다!');
          }
        
          await this.genreRepository.update(
            { id },
            {
              ...updateGenreDto,
            },
          );
        
          const newGenre = await this.genreRepository.findOne({
            where: { id },
          });
        
          return newGenre;
        }
        
    • Delete
      • async remove(id: number) {
          const genre = await this.genreRepository.findOne({
            where: {
              id,
            },
          });
        
          if (!genre) {
            throw new NotFoundException('존재하지 않는 장르입니다!');
          }
        
          await this.genreRepository.delete(id);
        
          return id;
        }
        

 

Postman 테스트 정리 

메서드 URL  설명
GET /genre 전체 장르 목록
GET /genre/:id 특정 장르 조회
POST /genre 장르 생성
PATCH /genre/:id 장르 수정
DELETE /genre/:id 장르 삭제

 

POST movie 엔드포인트 업데이트하기

 

사전 준비

  • 장르(Genre) 엔티티와 CRUD API는 이미 완료된 상태
  • 관계 설계에 따라 영화(Movie) 생성 시 장르 연결을 구현해야 함
  • 프론트엔드는 genreIds: number[] 형태로 장르 ID 배열을 전달할 것

 

CreateMovieDto 수정

  • export class CreateMovieDto {
      @IsNotEmpty()
      @IsString()
      title: string;
    
      @IsNotEmpty()
      @IsString()
      detail: string;
    
      @IsNotEmpty()
      @IsNumber()
      directorId: number;
    
      @IsArray()
      @ArrayNotEmpty()
      @IsNumber({}, { each: true }) // 리스트 내 각 값 검증
      genreIds: number[];
    }
    

 

MovieService 로직 구현

  • 모듈 의존성 설정
    • //movie.module.ts
      @Module({
        imports: [TypeOrmModule.forFeature([Movie, MovieDetail, Director, Genre])],
        controllers: [MovieController],
        providers: [MovieService],
      })
      
  • GenreRepository 주입
    • constructor(
        @InjectRepository(Genre)
        private readonly genreRepository: Repository<Genre>,
      ) {}
      
  • 장르 ID 배열 기반 조회 및 검증
    • const genres = await this.genreRepository.find({
        where: {
          id: In(createMovieDto.genreIds),
        },
      });
      
      // 유효성 검증
      if (genres.length !== createMovieDto.genreIds.length) {
        throw new BadRequestException(
          `존재하지 않는 장르가 있습니다. 존재하는 ID: ${genres.map((g) => g.id).join(', ')}`,
        );
      }
      
    • 장르 검증 전략
      • In() 연산자 사용 시 여러 ID 조건으로 조회 가능 (SQL의 IN 연산자와 동일)
      • genreIds.length !== genres.length일 경우 → 잘못된 ID가 있다는 뜻
  • 영화 저장 시 장르 연결
    • const movie = await this.movieRepository.save({
        title: createMovieDto.title,
        genre: createMovieDto.genre,
        detail: { detail: createMovieDto.detail },
        director,
        genres, // 관계 연결
      });
      

 

Postman 테스트 시나리오

  1. 선행 작업
    • Director 생성 (예: id: 1, 크리스토퍼 놀란)
    • Genre 생성 (예: id: 1 - 액션, id: 2 - 판타지)
  2. 영화 생성 요청 바디 예시
    • {
        "title": "다크나이트 라이즈",
        "genre": "히어로",
        "detail": "크리스토퍼 놀란 대작 중 하나",
        "directorId": 1,
        "genreIds": [1, 2]
      }
      
  3. 응답 확인
    • movie 정보와 함께 genres 필드에 연결된 장르들이 포함됨

 

DB 테이블 검증 (pgAdmin 기준) 

테이블명 설명
movie 영화 정보 테이블 (title, directorId, etc.)
genre 장르 정보 테이블 (name, createdAt, etc.)
movie_genres_genre 연결 테이블 (movieId, genreId)
  • 관계는 @JoinTable() 위치 기준으로 테이블 명이 정해짐

 

PATCH GET movie 엔드포인트 업데이트하기

 

목표

  • 영화(Movie) 수정(PATCH) 시, 장르(Genre) 정보도 함께 업데이트
  • 기존 장르들을 새로운 ID 배열로 교체
  • 관계는 ManyToMany → 단순 update가 아닌 **save**방식 필요

 

UpdateMovieDto 수정

  • export class UpdateMovieDto {
      // 기존 필드 생략
    
      @IsArray()
      @IsNotEmpty()
      @IsNumber({}, { each: true }) // 배열 내 숫자 값 검증
      @IsOptional()
      genreIds?: number[]; // 선택적으로 받을 수 있도록
    }
    

 

MovieService.update() 로직 변경

  • genreIds 존재 시 장르들 조회
    • const { detail, directorId, genreIds, ...movieRest } = updateMovieDto;
      
      let newGenres: Genre[] | null = null;
      if (genreIds) {
        const genres = await this.genreRepository.find({
          where: {
            id: In(genreIds),
          },
        });
      
        if (genres.length !== genreIds.length) {
          throw new NotFoundException(
            `존재하지 않는 장르가 있습니다. 유효한 ID: ${genres.map((g) => g.id).join(', ')}`,
          );
        }
      
        newGenres = genres;
      }
      
  • TypeORM의 update() 대신 save() 사용
    • 이유: ManyToMany 관계는 update()에서 정상적으로 갱신되지 않음
    • await this.movieRepository.save(newMovie);
      
  • findOne()으로 상세 정보 포함하여 재조회
    • return this.movieRepository.findOne({
        where: { id },
        relations: ['detail', 'director', 'genres'],
      });
      

 

전체 흐름 정리 

단계 내용
DTO genreIds 필드 추가 및 유효성 검사
Service genreIds가 존재하면 → 조회 후 유효성 검사
저장 방식 update() 대신 save() 사용
조회 방식 관계 포함한 findOne으로 상세 응답 제공

 

Postman 테스트

  • 장르 준비
    • Action(1), Fantasy(2), Science Fiction(3) 생성
  • 기존 영화의 장르 변경
    • PATCH /movie/1
      {
        "genreIds": [3]
      }
      
    • 결과: 영화가 Science Fiction 하나만 연결됨
  • 다시 변경
    • PATCH /movie/1
      {
        "genreIds": [1, 2]
      }
      
    • 결과: 영화가 Action, Fantasy 2개로 변경됨

 

DB 검증 (movie_genres_genre 중간 테이블)

movieId  genreId
1 1
1 2
  • 관계가 중간 테이블을 통해 교체됨

 

학습 후기         

 

ManyToMany 관계를 처음 설계하고 구현하는 입장에서 이번 학습은 굉장히 실질적인 통찰을 제공해주었습니다. 기존에는 단순히 문자열로 처리되던 장르 필드를 독립된 엔티티로 분리하면서, 복수의 영화가 하나의 장르를 공유하고 동시에 하나의 영화가 여러 장르를 가질 수 있는 구조를 어떻게 모델링해야 하는지를 명확히 배울 수 있었습니다.

 

특히 TypeORM에서 @ManyToMany 관계를 설정할 때 @JoinTable()을 어디에 명시하느냐에 따라 생성되는 중간 테이블 이름이 달라지고, 이 테이블이 단순히 외래 키만을 가지는 구조라는 점이 인상 깊었습니다. 이론적으로는 알고 있었지만, 실제로 관계 테이블을 pgAdmin에서 확인하며 어떻게 연결되는지 눈으로 확인한 경험은 구조를 보다 명확하게 이해하는 데 큰 도움이 되었습니다.

 

또한 단일 관계와 다중 관계에서의 서비스 처리 방식의 차이도 중요한 학습 포인트였습니다. OneToMany나 ManyToOne 관계에서는 update()로 처리할 수 있었지만, ManyToMany에서는 반드시 save()를 사용해야 관계가 반영된다는 점은 실무에서도 발생할 수 있는 오류를 방지하기 위해 꼭 기억해야 할 부분입니다.

 

서비스 로직에서는 genreIds를 통해 장르 ID 리스트를 받아와 In() 쿼리를 사용하여 검증하고, 요청한 ID와 조회된 ID의 길이를 비교하여 잘못된 ID가 포함됐는지를 검증하는 로직이 매우 인상 깊었습니다. 이는 단순히 관계를 연결하는 것을 넘어서, 유효하지 않은 입력을 방어하는 견고한 백엔드 설계의 예시로 보였습니다.

 

마지막으로, 업데이트 시 genreIds가 들어온 경우만 장르 변경을 수행하도록 하고, save() 이후에는 항상 findOne({ relations: [...] })로 관계 포함된 최신 데이터를 다시 조회하여 반환하는 패턴은 추후 다양한 관계 모델에서도 반복적으로 적용할 수 있을 만한 설계입니다.

 

이번 강의를 통해 관계형 데이터베이스의 모델링과 API 설계가 단순한 CRUD 이상의 사고를 요구한다는 사실을 다시금 체감하였습니다. 관계의 방향성, 유효성 검증, DB 무결성 유지, 직관적인 API 응답까지 고려한 설계를 할 수 있도록 감각을 길러준 강의였습니다.

 

학습 인증샷       

                       

수강 인증 사진
학습 인증샷
공부 시작 시간
공부 종료 시간

https://abit.ly/lisbva