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

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

NestJS

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

NangIn 2025. 4. 18. 08:22

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

 

강의 내용 정리   

 

Movie-Director Create Update 관계 작업해보기

 

Movie 생성 시 Director 연결 위치 결정

  • 감독 생성은 Director 모듈에서 처리
  • 영화와 감독 연결은 영화 생성 시점(Movie 기준)에서 처리
  • 유스케이스 상, 영화를 먼저 생성하는 관리자(Admin)의 시나리오에 맞춘 설계

 

Director 연결 방식

  • 프론트엔드는 감독 ID만 전송 (directorId)
  • 백엔드는 해당 ID로 감독 유효성 검증 후 연결

 

DTO 확장

  • export class CreateMovieDto {
      @IsNotEmpty()
      title: string;
    
      @IsNotEmpty()
      genre: string;
    
      @IsNotEmpty()
      detail: string;
    
      @IsNotEmpty()
      directorId: number;
    }
    

 

Service 계층 로직

  • MovieService 내부에서 다음 순서로 처리
    1. directorRepository.findOne() 으로 유효성 검사
    2. 존재하지 않으면 NotFoundException 발생
    3. 존재하면 Movie 객체 생성 시 연결
    async create(createMovieDto: CreateMovieDto) {
      const director = await this.directorRepository.findOne({
        where: {
          id: createMovieDto.directorId,
        },
      });
    
      if (!director) {
        throw new NotFoundException('존재하지 않는 ID의 감독입니다!');
      }
    
      const movie = await this.movieRepository.save({
        title: createMovieDto.title,
        genre: createMovieDto.genre,
        detail: {
          detail: createMovieDto.detail,
        },
        director,
      });
    
      return movie;
    }
    
  • Cascade 설정 필요: @ManyToOne(() => Director, { cascade: true })
    • @Entity()
      export class Movie extends BaseTable {
        // ...
        @ManyToOne(() => Director, (director) => director.id, { cascade: true })
        director: Director;
      }
      

 

Service 간 의존성 주입 관련 고민

// movie.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Movie, MovieDetail, Director])],
  controllers: [MovieController],
  providers: [MovieService],
})
// movie.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Movie, MovieDetail, Director])],
  controllers: [MovieController],
  providers: [MovieService],
})
  • 서비스 전체를 주입받아서 이미 만들어 놓은 메서드들을 사용하는 대신, 레포지토리 직접 주입 방식 채택
  • 이유: 서비스 간 의존성 분리를 선호하는 아키텍처 철학
    • 서비스 ↔ 서비스 간 의존보다
    • 서비스 ↔ 레포지토리 간 의존이 더 명확하고 경계가 명시적임
  • 단, 팀의 아키텍처 룰에 따라 서비스 주입도 가능함 (exports + imports)

 

UpdateMovieDto 확장 및 조건부 처리

async update(id: number, updateMovieDto: UpdateMovieDto) {
	// ...
  const { detail, directorId, ...movieRest } = updateMovieDto;

  // 3. 영화 자체 업데이트
  await this.movieRepository.update(id, movieRest);

  // 4. detail이 있을 경우만 MovieDetail 업데이트
  if (detail) {
    await this.movieDetailRepository.update(movie.detail.id, {
      detail,
    });
  }

  // 5. 갱신된 영화 정보 반환 (다시 relations 포함해서 fetch)
  const updated = await this.movieRepository.findOne({
    where: { id },
    relations: ['detail'],
  });

  return updated;
}
export class UpdateMovieDto {
  title?: string;
  genre?: string;
  detail?: string;
  @IsOptional()
  directorId?: number;
}
  • directorId가 있을 경우에만 업데이트 처리
  • 존재하지 않는 감독 ID는 NotFoundException으로 처리

 

업데이트 최종 병합 전략

async update(id: number, updateMovieDto: UpdateMovieDto) {
  
  // ...
  
  const { detail, directorId, ...movieRest } = updateMovieDto;

  let newDirector: Director | null = null;

  if (directorId) {
    const director = await this.directorRepository.findOne({
      where: {
        id: directorId,
      },
    });

    if (!director)
      throw new NotFoundException('존재하지 않는 감독의 영화입니다.');

    newDirector = director;
  }

  const movieUpdateFields = {
    ...movieRest,
    ...(newDirector && { director: newDirector }),
  };

  await this.movieRepository.update(id, movieUpdateFields);

	// ...
	
  const newMovie = await this.movieRepository.findOne({
    where: { id },
    relations: ['detail', 'director'],
  });

  return newMovie;
}
  • newDirector가 있을 때만 객체 병합
  • 조건부 스프레드 문법 활용

 

테스트 결과

  • Post 요청 시: 기존 감독 ID(예: 3번)를 통해 연결된 영화 생성 성공
  • Patch 요청 시:
    • 감독 변경 가능
    • 감독 미입력 시 기존 값 유지됨
  • DB 확인: directorId가 movie 테이블에 잘 반영됨

 

정리 포인트 

항목 내용
연결 위치 Movie 생성 시 Director 연결 (유저 시나리오 기반)
유효성 처리 존재하지 않는 감독일 경우 404 에러 발생
DTO 구성 프론트엔드에 필요한 최소 단위로 (직관적 구조)
아키텍처 원칙 서비스 간 직접 의존보다는 레포지토리 직접 주입 선호
코드 구조 조건부 스프레드로 유연한 필드 병합 처리

 

Movie-Director Create Read 관계 작업해보기

 

목표

  • 영화 데이터를 조회할 때, 감독 정보도 함께 포함되도록 처리

 

TypeORM의 relations 옵션 사용

  • 관계된 엔티티를 함께 불러오기 위한 표준 방식
  • 적용 위치:
    • 전체 리스트 조회
    • 단일 영화 조회
    • 업데이트 후 조회
    // 예시
    const movie = await this.movieRepository.findOne({
      where: { id },
      relations: ['director'],
    });
    

 

적용 대상

  • findAll()
  • findOne(id)
  • update() 후 조회
  • → 모든 조회 로직에서 relations: ['director']를 명시하여 감독 정보 포함

 

Postman 테스트 결과

  • 감독이 연결된 영화(예: Avengers 3)의 경우:
    • 응답에 director 필드가 포함되어 있음
  • 연결되지 않은 영화의 경우:
    • director: null로 출력됨

 

Delete 작업 시 동작 확인 (Cascade X)

  • Movie 삭제 시, 감독은 삭제되지 않음
    • 이유: cascade: false (기본값)로 설정되어 있기 때문
  • 테스트 시나리오:
    • Movie 4번(ID: 4) 삭제 요청
    • Director 엔티티에는 영향 없음 → 크리스토퍼 놀란은 여전히 존재

 

요약 

항목 설명
기능 Movie 조회 시 관련 Director 정보도 함께 조회
구현 방식 TypeORM의 relations: ['director'] 사용
삭제 동작 Movie만 삭제되고 Director는 유지됨 (cascade: false)
실무 중요 포인트 관계 필드가 null일 수 있음을 전제한 UI/UX 및 예외 처리 필요

 

Unique & Nullable Constraint 작업해보기

 

기존 테이블 드랍 (초기화)

DROP TABLE director CASCADE;
DROP TABLE movie CASCADE;
DROP TABLE movie_detail CASCADE;
  • CASCADE: 외래 키로 연결된 데이터도 함께 삭제
  • 이후 서버 재시작하여 테이블 자동 재생성

 

Movie → Director 연결 없이 생성 가능? → ❌ 문제 발생 가능

  • 예시:
    • UPDATE movie SET "director_id" = NULL WHERE id = 1;
      
  • 문제:
    • 감독이 연결되지 않은 영화가 존재할 수 있음 → 무결성 위배

 

해결 방법: 관계 필드의 nullable: false 설정

// movie.entity.ts
@OneToOne(() => MovieDetail, (movieDetail) => movieDetail.id, {
  cascade: true,
  nullable: false,
})
@JoinColumn() 
detail: MovieDetail;

@ManyToOne(() => Director, (director) => director.id, {
  cascade: true,
  nullable: false,
})
director: Director;
  • 해당 설정 시, 관계 컬럼은 null 허용 불가
  • 효과:
    • 영화 생성 시 반드시 감독 연결 필수
    • 실수로 null 삽입 시 DB 자체에서 거부
    • Postgres 기준으로 foreign key null 제한 설정됨

 

관계 필드 삭제 차단

  • 예시: movie_detail을 삭제하면 movie에서 detail_id가 null이 되지 않도록 막기
    • DELETE FROM movie_detail WHERE id = 1;
      -- → 에러 발생: 외래 키 제약 조건 위반
      
  • 이유:
    • nullable: false + foreign key 설정으로 인해 레퍼런스 보호
  • 장점:
    • 고립된 레코드 발생 방지 → 데이터 구조 안정성 유지
  • 개념 용어: 데이터 무결성 (Data Integrity)

 

추가 제약 조건: 유일성 (Unique)

@Column({ unique: true })
title: string;
  • 같은 제목의 영화가 DB에 2개 존재할 수 없음
  • 효과:
    • 중복 방지
    • 실수로 같은 제목 영화 입력 시 자동 차단
  • 실패 시 에러 메시지:
    • duplicate key value violates unique constraint "UQ_title"
      

 

실습 결과 정리 

항목 설정  결과
Movie-Director 관계 nullable: false 감독 없는 영화 생성 ❌
Movie-MovieDetail 관계 nullable: false 상세 정보 삭제 시 영화도 삭제 불가
제목 필드 unique: true 같은 제목의 영화 생성 시 에러 발생

 

요약 정리

  • nullable: false
    • 외래 키가 반드시 존재해야 함
    • 관계 누락을 원천 차단 → 안전한 관계 유지
  • unique: true
    • 중복 데이터 방지
    • 프론트 단에서 검증을 못하더라도 DB 차원에서 제어 가능
  • 무결성 확보는 프론트/백엔드가 아닌 DB가 최종 방어선

 

학습 후기         

 

이번 실습을 통해 ManyToOne 관계를 실제로 구현하면서, 단순한 데이터 연동을 넘어서 도메인 설계와 데이터 무결성을 함께 고민해야 한다는 점을 깊이 체감했습니다. 특히 Movie와 Director의 연결은 단순히 TypeORM 데코레이터 몇 줄로 끝나는 것이 아니라, 실제 유스케이스 기반으로 설계되어야 함을 배웠습니다. 예컨대, 관리자가 영화를 등록할 때 감독을 연결하는 흐름이 자연스러운 사용자 흐름이라는 점에서, Movie 생성 시에 Director를 연결하는 구조가 납득이 갔습니다.

 

Create와 Update에서 directorId만을 받아 내부적으로 관계 객체를 만들어주는 구조는 프론트엔드와의 협업에서도 굉장히 직관적이라고 느꼈습니다. 특히 조건부 스프레드 문법을 활용한 병합 방식은 코드를 간결하게 유지하면서도 다양한 조건을 유연하게 처리할 수 있는 좋은 패턴이라는 점에서 인상 깊었습니다. 앞으로 다양한 DTO와 관계 객체를 다룰 때도 이 전략을 계속 활용할 수 있을 것 같습니다.

 

또한 이번 실습에서 가장 인상 깊었던 부분은 데이터 무결성 확보였습니다. 단순히 코드로 에러 처리를 하는 것이 아니라, DB 스키마 자체에서 nullable: false, unique: true와 같은 제약을 걸어둠으로써 잘못된 데이터가 아예 들어올 수 없는 환경을 만드는 것이 중요하다는 점을 체감했습니다. 특히 관계 필드가 null로 설정되는 것을 방지하거나, 중복된 영화 제목을 막는 등의 제약은 실제 서비스 운영 중 발생할 수 있는 치명적인 오류를 사전에 차단할 수 있는 강력한 방어선이라는 생각이 들었습니다.

 

마지막으로, 레포지토리 직접 주입을 사용하는 방식과 서비스 간 의존성 주입 방식의 차이에 대한 설명도 매우 유익했습니다. 단순히 어떤 방식이 더 좋다는 접근이 아닌, 아키텍처 철학에 따라 선택할 수 있고, 명시적 경계 설정을 위해 레포지토리를 선호하는 방식도 충분히 설득력 있다는 점이 기억에 남습니다. 이는 앞으로 팀 개발을 할 때 아키텍처 토론의 기준이 되어줄 수 있을 것 같습니다.

 

전반적으로 이번 실습은 관계 설정의 실전 감각을 익히는 데 매우 실질적인 도움을 주었고, NestJS와 TypeORM이 제공하는 강력한 기능들을 실제 서비스 플로우에 맞게 활용하는 법을 체득할 수 있는 좋은 기회였습니다.

 

학습 인증샷       

                       

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

https://abit.ly/lisbva