낙서장이자 오답 노트이자 컨닝 페이퍼
패스트캠퍼스 환급챌린지 20일차 : 코드팩토리의 백엔드 아카데미 : 한 번에 끝내는 NestJS 패키지 - 기초부터 MSA까지 강의 후기 본문
패스트캠퍼스 환급챌린지 20일차 : 코드팩토리의 백엔드 아카데미 : 한 번에 끝내는 NestJS 패키지 - 기초부터 MSA까지 강의 후기
NangIn 2025. 4. 20. 12:58본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.
강의 내용 정리
QueryBuilder 이론
QueryBuilder란?
- TypeORM에서 복잡한 쿼리를 동적으로 구성할 수 있게 도와주는 툴
- 기본적인 CRUD는 Repository로 충분하지만, 복잡한 조건·서브쿼리·동적 조합 등에서는 QueryBuilder가 유리함
- 스트링 기반 조립 구조 → 다이나믹 쿼리에 강력함
- NestJS에서 실무적으로 꼭 학습해야 할 핵심 기능
기초 문법 5종 세트
- SELECT
-
await dataSource .createQueryBuilder() .select('movie') .from(Movie, 'movie') .leftJoinSelect('movie.detail', 'detail') .leftJoinSelect('movie.director', 'director') .leftJoinSelect('movie.genres', 'genres') .where('movie.id = :id', { id: 1 }) .getOne(); - .select() : 조회할 테이블/필드 지정 (기본은 *)
- .from() : 대상 엔티티 및 별칭(alias) 지정 → select 대신 사용 가능
- .leftJoinSelect() : 관계 테이블을 조인 후 셀렉트, 널 값도 포함, 2번째 인자는 alias
- .where() : 조건 필터링 (:id는 파라미터화된 변수)
- .getOne() / .getMany() : 단일 또는 다중 결과 반환
-
- INSERT
-
await dataSource .createQueryBuilder() .insert() .into(Movie) .values({ title: 'Inception', genre: 'Sci-fi', director: director, genres: genres }) .execute(); - .insert() : Insert 쿼리 시작
- .into(Movie) : 삽입 대상 테이블(엔티티) 설정
- .values({...}) : 삽입할 컬럼-값 지정 (객체 형태)
- .execute() : 쿼리 실행
- → 반환값은 InsertResult 객체로 identifiers, generatedMaps, raw 등이 포함
-
- UPDATE
-
await dataSource .createQueryBuilder() .update(Movie) .set({ title: 'Updated Title' }) .where('id = :id', { id: 1 }) .execute(); - .update(Movie) : 업데이트 대상 테이블 지정
- .set({...}) : 수정할 컬럼과 새 값
- .where() : 조건 설정 → 해당 조건에 부합하는 행만 수정
- .execute() : 쿼리 실행
- → .where() 생략 시 전체 행 수정되므로 주의
-
- DELETE
-
await dataSource .createQueryBuilder() .delete() .from(Movie) .where('id = :id', { id: 1 }) .execute(); - .delete() : Delete 쿼리 시작
- .from(Movie) : 삭제 대상 테이블 지정
- .where() : 삭제 조건 설정
- .execute() : 쿼리 실행
- → 조건 없이 실행 시 전체 데이터 삭제됨 → 항상 where 명시하는 습관!
-
- RELATION 조작
-
await dataSource .createQueryBuilder() .relation(Movie, 'genres') .of(1) // id 1번 movie .add(2); // id 2 추가 .loadMany() - .relation() : 엔티티 간의 관계 필드 지정
- .of(id) : 어떤 엔티티(id)의 관계를 조작할지 지정
- .add(genreId) : 관계 추가
- .remove(genreId) : 관계 제거
- .set([ids]) : 기존 관계 초기화 후, 새 관계 설정
- .loadOne() / .loadMany() : 현재 관계된 데이터 조회
- → 주로 ManyToMany, OneToMany 등에서 활용
- → 내부적으로 중간 테이블(join table)을 자동 조작함
-
추가 쿼리 기능
- getOne(), getMany(), select()
-
// 단일 Row만 가져올 때 const users = await connection.getRepository(User) .createQueryBuilder("user") .select(["user.id", "user.firstName", "user.lastName"]) .getOne()- .select()
- 가져올 컬럼을 명시적으로 지정할 수 있음
- 생략하면 전체 컬럼이 선택됨 (※ 주의: 조인했을 경우 불필요한 데이터까지 포함될 수 있음)
- 배열로 전달하거나 문자열로도 사용 가능
- getOne()
- 단일 row 조회
- 조건에 맞는 첫 번째 row를 반환 (그 이상 결과가 있어도 무시)
- 조건이 없거나 여러 row가 반환될 수 있으면 오류 발생 가능성 있음
- .select()
-
// 복수 Row 가져올 때 const users = await connection.getRepository(User) .createQueryBuilder("user") .select(["user.id", "user.firstName", "user.lastName"]) .getMany()- .getMany()
- 조건에 맞는 여러 row를 배열로 반환
- 리스트 화면, 검색 결과 등에서 사용
- .getMany()
-
- where, andWhere, orWhere – 조건문 설정
-
.where('user.isActive = :active', { active: true }) .andWhere('user.role = :role', { role: 'admin' }) .orWhere('user.name = :name', { name: 'John' })- .where() : 첫 번째 조건
- .andWhere() : AND 논리 연산으로 조건 추가
- .orWhere() : OR 논리 연산으로 조건 추가
- :변수명 : 바인딩 변수 → { 변수명: 값 } 형식으로 전달
- Tip: SQL Injection 방지 + 가독성을 위해 반드시 변수 바인딩을 사용
-
- orderBy, addOrderBy – 정렬 기준 설정
-
.orderBy('user.createdAt', 'DESC') // 최신순 .addOrderBy('user.id', 'ASC') // 동일 시간일 경우 ID 오름차순- .orderBy() : 첫 정렬 기준 지정
- .addOrderBy() : 다중 정렬 조건 추가
- 'DESC' | 'ASC' : 내림차순 / 오름차순
- 정렬 기준이 여러 개일 때 addOrderBy() 사용
-
- skip, take – 페이징 처리
-
.skip(10) // 11번째부터 .take(5) // 총 5개만 조회- .skip(n) : n개 건너뜀 (OFFSET)
- .take(n) : n개 가져옴 (LIMIT)
- 사용 예시: 페이지네이션 구현 시 → skip = (page - 1) * limit, take = limit
-
Join 종류
| 구분 | 포함 조건 |
| innerJoinAndSelect | 두 테이블 모두 일치할 때만 조회 |
| leftJoinAndSelect | 왼쪽 테이블 기준 → 오른쪽 null 가능 |
| rightJoinAndSelect | 오른쪽 테이블 기준 → 왼쪽 null 가능 |
Aggregation (집계 함수)
-
.select('COUNT(user.id)', 'userCount')- .select(집계함수, alias) 형식으로 사용
- SQL의 집계 함수(COUNT, SUM, AVG, MAX, MIN 등)를 그대로 사용할 수 있음
- 'userCount'는 응답 결과의 키(alias 이름)
SubQuery (서브쿼리)
-
.where(qb => { const sub = qb.subQuery() .select('COUNT(*)') .from(Order, 'order') .where('order.userId = user.id') .getQuery(); return `${sub} > 5`; })- qb.subQuery() : 서브쿼리(QueryBuilder 안에서 또 다른 쿼리 생성)
- .getQuery() : 쿼리 문자열 반환
- .where(qb => { ... }) : 바깥 쿼리의 where에서 서브쿼리 삽입 가능
정리
| 항목 | 설명 |
| 사용 시점 | 동적 조건, 복잡한 조인, 서브쿼리 필요 시 |
| 장점 | 레포지토리보다 더 유연하고 강력한 쿼리 작성 가능 |
| 단점 | 문법이 스트링 기반이라 실수 가능성 있음 |
| 실전 전략 | 레포지토리 → 충분할 땐 유지 |
| 쿼리빌더 → 확장성 & 조건 복잡 시 선택 |
QueryBuilder 사용하는 방식으로 로직 변경하기
QueryBuilder 도입 이유 및 적용 범위
- Repository 방식은 간단한 쿼리에는 적합하지만,
- 복잡한 조인 / 동적 조건 처리 / 집계 등에서는 QueryBuilder가 더 강력함.
- 실제 현업에서는 복잡한 조건을 다루거나 동적 필터링할 때 자주 사용됨.
- 실습에서는 Movie 모듈에만 적용 → 나머지 모듈(Genre, Director 등)은 간단하므로 Repository 유지.
Movie 목록 조회 (FindAll → QueryBuilder 변환)
- .createQueryBuilder('movie') → 영화 테이블에 대한 별칭 지정
- .leftJoinAndSelect() → detail, director, genres 모두 조인
- .getMany() → 모든 영화 가져오기
- .getManyAndCount() → 전체 리스트 + 총 개수 반환
const qb = this.movieRepository
.createQueryBuilder('movie')
.leftJoinAndSelect('movie.detail', 'detail')
.leftJoinAndSelect('movie.director', 'director')
.leftJoinAndSelect('movie.genres', 'genres');
if (title) { // 조건부로 쿼리를 더 붙일 수 있음
qb.where('movie.title LIKE :title', { title: `%${title}%` });
}
return qb.getManyAndCount();
단일 조회 (FindOne → QueryBuilder 변환)
- .where('movie.id = :id', { id }) → 조건 설정
- .getOne() → 단일 row 반환
const movie = await this.movieRepository
.createQueryBuilder('movie')
.leftJoinAndSelect('movie.detail', 'detail')
.leftJoinAndSelect('movie.director', 'director')
.leftJoinAndSelect('movie.genres', 'genres')
.where('movie.id = :id', { id })
.getOne();
if (!movie) throw new NotFoundException('존재하지 않는 영화입니다.');
return movie;
생성 (Create → QueryBuilder 변환)
- MovieDetail → .insert().into().values().execute() 방식으로 먼저 삽입
- Movie → 두 번째 .insert() 실행
- cascade(movieDetail) → 직접 movieDetail의 Id를 넣어줘야 함
- ManyToMany(장르) 관계 → .relation().of().add()로 관계 연결 필요
const movieDetail = await this.movieRepository
.createQueryBuilder()
.insert()
.into(MovieDetail)
.values({ detail: createMovieDto.detail })
.execute();
const movieDetailId = movieDetail.identifiers[0].id as number;
const movie = await this.movieRepository
.createQueryBuilder()
.insert()
.into(Movie)
.values({
title: createMovieDto.title,
detail: {
id: movieDetailId,
},
director,
genres,
})
.execute();
const movieId = movie.identifiers[0].id as number;
await this.movieRepository
.createQueryBuilder()
.relation(Movie, 'genres')
.of(movieId)
.add(genres.map((genre) => genre.id));
return await this.movieRepository.findOne({
where: { id: movieId },
relations: ['detail', 'director', 'genres'],
});
수정 (Update → QueryBuilder 변환)
- .update().set().where().execute() 형식
- Genre(다대다)는 .addAndRemove()를 사용하여 기존 관계 제거 후 새로운 값 추가
await this.movieRepository
.createQueryBuilder()
.update(Movie)
.set(movieUpdateFields)
.where('id = :id', { id })
.execute();
if (detail) {
await this.movieRepository
.createQueryBuilder()
.update(MovieDetail)
.set({ detail })
.where('id = :id', { id: movie.detail.id })
.execute();
}
if (newGenres) {
await this.movieRepository
.createQueryBuilder()
.relation(Movie, 'genres')
.of(movie.id)
.addAndRemove(
newGenres.map((g) => g.id), // 추가할 아이디들
movie.genres.map((g) => g.id), // 삭제할 아이디들
);
}
삭제 (Delete → QueryBuilder 변환)
- .delete().from().where().execute()
await this.movieRepository
.createQueryBuilder()
.delete()
.where('id = :id', { id })
.execute();
트랜잭션 필요성
- insert 쿼리 3개(MovieDetail → Movie → Genre 관계)가 각각 실행됨
- 중간에 하나라도 실패하면 앞의 쿼리 결과가 고아 데이터로 남을 수 있음
- 해결 방법: 트랜잭션으로 묶어서 실행 → 이후 강의에서 다룸 예정
정리
| 항목 | 설명 |
| 목적 | QueryBuilder 실전 적용 및 레포지토리 대체 |
| 주요 기능 | select, insert, update, delete, relation, addAndRemove |
| 강점 | 동적 조건 필터링, 조인 처리, 명확한 SQL 컨트롤 |
| 단점 | save의 cascade 처리 불가, 코드 길어짐, 트랜잭션 필수 |
| 추천 사용처 | 조건 많은 검색, 관계 조작, 커스텀 쿼리 필요 시 |
학습 후기
이번에 QueryBuilder를 학습하면서, 단순한 CRUD 수준을 넘는 복잡한 쿼리 작성 방식에 대해 깊이 이해할 수 있었습니다. 그동안 Repository 방식만으로도 충분하다고 느꼈지만, 실제로 조인이나 조건 필터링이 많은 로직, 그리고 동적으로 쿼리가 구성되어야 하는 경우에는 QueryBuilder가 훨씬 더 강력하다는 것을 체감했습니다. 특히 .leftJoinAndSelect()를 통해 관계된 엔티티를 한 번에 가져오거나, .where()에 조건을 동적으로 추가하는 방식은 실무에서도 자주 쓰일 수밖에 없다는 확신이 들었습니다.
실제로 Movie 모듈을 QueryBuilder로 리팩토링하는 과정에서, 단일 조회와 리스트 조회를 넘어 insert, update, delete, 관계 조작까지 모두 QueryBuilder로 대체해보니 레포지토리 방식에 비해 명확한 SQL 흐름이 보인다는 점에서 이해와 디버깅 측면에서도 장점이 많다고 느꼈습니다. 반면, save()의 cascade 기능이 자동으로 적용되지 않는다는 점과, 쿼리 구조가 길어지고 실수가 발생할 가능성이 더 높다는 단점도 명확했습니다.
특히 관계 조작에서는 .relation().of().addAndRemove() 문법을 통해 ManyToMany 관계를 세밀하게 조작할 수 있었고, 이것이 단순히 중간 테이블 조작을 자동화해준다는 점에서 매우 유용했습니다. 하지만 동시에, 관계를 조작하는 모든 작업을 명시적으로 수행해야 하기 때문에, 기존보다 더 많은 코드와 책임이 개발자에게 전가된다는 점도 체감했습니다.
결정적으로, 쿼리를 나눠서 실행할 경우 데이터 무결성을 보장할 수 없다는 사실을 인지하고, 트랜잭션의 필요성을 강하게 느꼈습니다. insert 작업을 세 단계로 나누었을 때 그 중 하나라도 실패하면 고아 레코드가 생길 수 있다는 점은 매우 현실적인 문제였고, 따라서 QueryBuilder를 사용할 경우 트랜잭션과의 연계는 필수적이라는 사실을 학습했습니다.
전체적으로 QueryBuilder는 실무에서 반드시 알고 있어야 할 도구라고 느꼈습니다. 복잡한 조건, 유동적인 쿼리, 다양한 조인을 다루는 환경이라면 더더욱 그렇습니다. 이번 학습을 통해 단순히 문법을 암기하는 것을 넘어, 왜 써야 하는지, 어떤 상황에서 유용한지에 대한 감각을 키울 수 있었습니다. 앞으로 복잡한 비즈니스 로직에서는 자연스럽게 QueryBuilder를 선택하는 개발자가 될 수 있을 것 같습니다.
학습 인증샷



