02 · select & where
이 문서가 답하는 질문: QueryBuilder의 가장 작은 토막인
select와where는 정확히 어떻게 조립되고 —: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']$1은 prepared 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
| 핵심 키 | 값 |
|---|---|
| 기본 — 모든 컬럼 SELECT | createQueryBuilder('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 전략을 본다.