02 · select & where

이 문서가 답하는 질문: QueryBuilder의 가장 작은 토막selectwhere는 정확히 어떻게 조립되고 — :param 바인딩과 문자열 인터폴레이션은 어디서 갈라지는가? 한 줄 답: where('x = :v', { v })prepared statement고, 문자열 인터폴레이션(`x = '${v}'`)은 SQL injection이다 — 둘은 시각적으로만 비슷하다.”


Why — 왜 select과 where부터 잡아야 하나

select/where는 QueryBuilder의 가장 처음 호출되는 메서드들이고, 그 위에 join, groupBy, having, orderBy가 쌓인다. 이 두 토막이 어긋나면 나머지 체이닝이 전부 어긋난다.

특히 where에서 유저 입력을 어떻게 넣느냐가 — 서비스 전체의 보안 경계다.

핵심 주장:

  • select(string[])반환 컬럼고정하고, addSelect추가한다.
  • where(string, params)prepared statement로 컴파일되어 SQL injection이 구조적으로 막힌다.
  • 반대로 `x = '${v}'` 같은 문자열 인터폴레이션모든 안전장치를 우회해 — DB에 유저 문자열을 그대로 보낸다.
  • andWhere/orWhere추가 조건이지만, where가 두 번 호출되면 마지막 것이 덮어쓴다 (silent bug).

How — 체이닝의 정확한 의미

select / addSelect

// 기본 — 모든 컬럼 SELECT
await userRepo.createQueryBuilder('u').getMany();
// → SELECT u.id, u.name, u.email, ... FROM users u
 
// select — 지정한 컬럼만
await userRepo.createQueryBuilder('u')
  .select(['u.id', 'u.name'])
  .getMany();
// → SELECT u.id, u.name FROM users u
 
// addSelect — 추가 표현식
await userRepo.createQueryBuilder('u')
  .select(['u.id'])
  .addSelect('COUNT(p.id)', 'postCount')
  .leftJoin('u.posts', 'p')
  .groupBy('u.id')
  .getRawMany();
// → SELECT u.id, COUNT(p.id) AS postCount FROM users u LEFT JOIN posts p ON ... GROUP BY u.id

중요: select호출하면 기본 컬럼이 사라진다. addSelect기본 + 추가다.

where vs andWhere vs orWhere

// where를 두 번 부르면 — 마지막 것이 덮어쓴다 (silent bug!)
await userRepo.createQueryBuilder('u')
  .where('u.active = :a', { a: true })
  .where('u.role = :r', { r: 'admin' })  // !! 첫 번째 where가 사라진다
  .getMany();
// → WHERE u.role = 'admin'   (active 조건이 *증발*)
 
// andWhere로 추가
await userRepo.createQueryBuilder('u')
  .where('u.active = :a', { a: true })
  .andWhere('u.role = :r', { r: 'admin' })
  .getMany();
// → WHERE u.active = true AND u.role = 'admin'

규칙: 처음 한 번만 where, 그 다음은 andWhere/orWhere.

Mermaid — where 체이닝


What — :param 바인딩 vs 문자열 인터폴레이션

안전한 자리

// ✅ parameterized — :param 바인딩
await userRepo.createQueryBuilder('u')
  .where('u.email = :email', { email: userInput })
  .getMany();

TypeORM은 이 호출을 다음과 같이 변환한다:

-- 실제 DB로 전송되는 것
SELECT * FROM users u WHERE u.email = $1
-- params: ['raw@example.com']

$1prepared statement placeholder다 — DB는 placeholder의 위치에는 데이터만 들어올 수 있고 SQL 토큰은 못 들어온다는 것을 문법 수준에서 보장한다.

위험한 자리

// ❌ 문자열 인터폴레이션 — SQL injection 직행 통로
await userRepo.createQueryBuilder('u')
  .where(`u.email = '${userInput}'`)
  .getMany();

userInput' OR '1'='1이라면?

-- 실제 DB로 전송되는 것
SELECT * FROM users u WHERE u.email = '' OR '1'='1'
-- → 모든 user row 반환

더 나쁜 경우 — userInput'; DROP TABLE users; --라면 데이터 삭제까지 간다.

비교 테이블

패턴코드실제 SQL보안
:param 바인딩where('x = :v', { v })WHERE x = $1 + params✅ injection 불가
문자열 인터폴레이션where(`x = '${v}'`)WHERE x = '...' (직접 삽입)❌ injection 가능
?? 식별자 바인딩TypeORM은 지원 안 함
Brackets 표현식where(new Brackets(qb => ...))그룹화된 조건✅ 그 안에서도 :param

IN 절은 특별히 주의

// ✅ 배열 바인딩 — TypeORM이 자동 expand
await userRepo.createQueryBuilder('u')
  .where('u.id IN (:...ids)', { ids: [1, 2, 3] })
  .getMany();
// → WHERE u.id IN ($1, $2, $3)
 
// ❌ 문자열로 직접
await userRepo.createQueryBuilder('u')
  .where(`u.id IN (${ids.join(',')})`)  // injection!
  .getMany();

:...ids (spread syntax) — 배열을 각 원소마다 placeholder로 펼친다.

Brackets로 그룹화

import { Brackets } from 'typeorm';
 
await userRepo.createQueryBuilder('u')
  .where('u.active = :a', { a: true })
  .andWhere(new Brackets(qb => {
    qb.where('u.role = :r1', { r1: 'admin' })
      .orWhere('u.role = :r2', { r2: 'owner' });
  }))
  .getMany();
// → WHERE u.active = true AND (u.role = 'admin' OR u.role = 'owner')

Brackets 없이 orWhere만 쓰면 우선순위가 깨져 의도와 다른 SQL이 나간다.


What-if — 잘못된 해석들

오해 1 — “내가 통제하는 값은 인터폴레이션해도 된다”

오늘은 통제한다. 내일 다른 개발자가 그 값에 유저 입력을 흘려도 — 컴파일러는 경고를 주지 않는다. 코드에서 문법 수준으로 막아야 한다 — 그게 :param이다.

오해 2 — “ORM이니까 injection은 자동 방지된다”

find는 자동 방지된다 — 옵션 객체 → 바인딩으로 변환되니까. 하지만 QueryBuilder의 raw 문자열 인자그대로 SQL이 된다. ORM이 모든 자리를 막아주는 건 아니다.

오해 3 — “where를 두 번 부르면 AND로 묶일 것이다”

아니다 — 두 번째가 첫 번째를 덮어쓴다. 이건 문서에 명시되어 있지만, 신참 개발자들이 가장 자주 만드는 silent bug다. ESLint 룰로 where가 한 함수에서 두 번 이상 호출되면 경고를 거는 것이 안전하다.

오해 4 — “addSelect로 추가한 컬럼은 엔티티 필드에 들어간다”

아니다 — addSelect('expr', 'alias')로 추가한 표현식raw 결과의 alias에만 들어간다. getMany()로 받으면 엔티티 필드에 매핑되지 않는다. getRawMany()로 받거나 @VirtualColumn을 써야 한다.


Insight — 한 단락 이야기

“SQL injection은 문법의 모호함에서 태어났다”

1998년 12월, Phrack 매거진 #54에 “NT Web Technology Vulnerabilities” 라는 글이 실렸고, 이것이 SQL injection의 공식 등장이었다. 문제의 본질은 프로그램이 데이터와 코드를 같은 문자열에 섞는다는 것"WHERE x = '" + input + "'"은 *코드(WHERE)와 데이터(input)*가 시각적으로만 구분된다. DB는 그것을 전부 SQL 토큰으로 파싱한다. Prepared statement는 그 모호함을 문법 수준에서 분리하는 발명이었다 — placeholder는 데이터 자리고, 절대 SQL 토큰으로 해석되지 않는다. TypeORM의 :param 문법은 이 prepared statement를 체이닝 메서드로 감싼 것이고, 그래서 :param 바인딩을 쓰는 한 — SQL injection은 구조적으로 불가능하다. 추상화의 본질은 “위험을 사라지게 하는 것”이 아니라 “위험을 문법으로 표현 불가능하게 만드는 것”:param은 그 문법의 이름이다.


요약 + Mermaid

핵심 키
기본 — 모든 컬럼 SELECTcreateQueryBuilder('u').getMany()
지정 컬럼만.select(['u.id', 'u.name'])
추가 표현식.addSelect('COUNT(p.id)', 'cnt')
안전한 조건.where('x = :v', { v })
위험한 조건.where(`x = '${v}'`)
추가 조건.andWhere(...), .orWhere(...)
where 두 번 호출두 번째가 덮어쓴다 — silent bug
그룹화new Brackets(qb => qb.where().orWhere())
IN.where('x IN (:...ids)', { ids })

한 줄 결론select/where는 QueryBuilder의 바닥 토막이고, :param 바인딩은 그 토막의 안전 계약이다. 다음 문서(03)는 그 위에 쌓이는 join 전략을 본다.