Skip to content

Query Builder — 실행 & 결과

쿼리의 모양을 다 잡고 나면 남는 건 실제 실행과 결과 처리입니다. 정렬, 페이지네이션, 비관적 잠금, 인덱스 힌트, soft delete 처리, 런타임 결과 검증, 실행 메서드 세 단계(getMany / getPartialMany / getRawMany), 반복 쿼리의 컴파일 — 이 장에서 한 번에 정리합니다.

ORDER BY와 페이지네이션

정렬과 페이지네이션은 기대한 대로 동작해요.

typescript
// 타입 안전 ORDER BY — 컬럼명 자동완성
qb.orderBy({ createdAt: "DESC", name: "ASC" });

// LIMIT / OFFSET
qb.limit(10).offset(20);

// skip / take — 같은 의미의 별칭
qb.skip(20).take(10);

데이터와 전체 개수가 동시에 필요하다면 getManyAndCount()를 씁니다. 두 쿼리를 병렬로 실행해 [T[], number]를 돌려줘요.

typescript
const [users, total] = await em
  .createQueryBuilder(User, "u")
  .where("isActive", true)
  .orderBy({ createdAt: "DESC" })
  .skip(20)
  .take(10)
  .getManyAndCount();

console.log(users.length); // 최대 10
console.log(total);        // 예: 235

커서 기반 페이지네이션, 스트리밍, 전략 선택 가이드는 페이지네이션 & 스트리밍에 있습니다.

비관적 잠금

동시성이 높은 상황에서는 읽은 뒤 다른 트랜잭션이 수정하지 못하도록 행을 잠가야 할 때가 있어요.

typescript
const user = await em
  .createQueryBuilder(User, "u")
  .where("id", 1)
  .forUpdate()
  .getOne();
// SELECT ... FROM "user" AS "u" WHERE "u"."id" = $1 FOR UPDATE

forUpdate()는 배타 잠금(FOR UPDATE)을 겁니다. 내 트랜잭션이 커밋될 때까지 다른 트랜잭션이 이 행을 읽지도 쓰지도 못해요. forShare()는 공유 잠금입니다 — 다른 트랜잭션이 읽을 순 있지만 쓰진 못합니다.

메서드SQL (PostgreSQL / SQLite)SQL (MySQL)효과
forUpdate()FOR UPDATEFOR UPDATE배타 잠금 — 읽기·쓰기 모두 차단
forShare()FOR SHARELOCK IN SHARE MODE공유 잠금 — 쓰기만 차단

언제 무엇을 쓸까

  • 재고 차감, 잔액 갱신처럼 "방금 읽은 값을 그대로 갱신" 할 때 → forUpdate(). 다른 트랜잭션이 이 행을 먼저 읽는 것부터 막습니다.
  • 외래 키 참조가 유효한지 확인만 하고 싶을 때 (내가 쓰진 않지만 남이 지우면 안 됨) → forShare().
  • 작업 큐에서 다른 워커가 잡은 행은 넘기고 다음 걸 잡고 싶을 때forUpdateSkipLocked() (아래).
  • 잡지 못하면 즉시 에러로 받고 사용자에게 재시도를 알리고 싶을 때forUpdateNowait().

NOWAIT과 SKIP LOCKED

동시성이 높은 상황에서 잠금이 풀리기를 기다리는 건 병목이 되기 쉽습니다. 두 가지 고급 옵션으로 "이미 잠긴 행을 만났을 때 어떻게 행동할지"를 조절할 수 있어요.

NOWAIT — 잠금이 풀리기를 기다리는 대신 즉시 에러를 던집니다.

typescript
const user = await em
  .createQueryBuilder(User, "u")
  .where("id", 1)
  .forUpdateNowait()
  .getOne();
// SELECT ... FOR UPDATE NOWAIT
// 다른 트랜잭션이 잠그고 있으면 즉시 에러

SKIP LOCKED — 이미 잠긴 행을 조용히 건너뜁니다. 여러 워커가 같은 테이블에서 작업을 집어가는 잡 큐 패턴에 특히 잘 맞아요.

typescript
// 워커가 다음 비잠금 작업을 집어감
const job = await em
  .createQueryBuilder(Job, "j")
  .where("status", "pending")
  .orderBy({ createdAt: "ASC" })
  .limit(1)
  .forUpdateSkipLocked()
  .getOne();
// SELECT ... ORDER BY ... LIMIT 1 FOR UPDATE SKIP LOCKED
// 남은 작업이 전부 잠겨 있으면 null

네 가지 조합이 모두 제공됩니다.

메서드SQL
forUpdateNowait()FOR UPDATE NOWAIT
forUpdateSkipLocked()FOR UPDATE SKIP LOCKED
forShareNowait()FOR SHARE NOWAIT
forShareSkipLocked()FOR SHARE SKIP LOCKED

WARNING

NOWAIT과 SKIP LOCKED는 MySQL 8.0 이상 또는 PostgreSQL 9.5 이상이 필요해요. SQLite는 비관적 잠금 자체를 지원하지 않아 UNSUPPORTED_DATABASE 에러가 발생합니다.

인덱스 힌트

MySQL에서는 옵티마이저가 어떤 인덱스를 쓰면 좋을지 제안할 수 있습니다. 옵티마이저가 최적이 아닌 인덱스를 고를 때 유용해요.

typescript
const orders = await em
  .createQueryBuilder(Order, "o")
  .where("status", "pending")
  .useIndex("idx_order_status")
  .getMany();
// MySQL: SELECT ... FROM `order` USE INDEX (`idx_order_status`) WHERE ...

MySQL에는 세 종류의 힌트가 있습니다.

메서드SQL효과
useIndex(name)USE INDEX (name)옵티마이저에 인덱스 제안
forceIndex(name)FORCE INDEX (name)특정 인덱스 사용 강제
ignoreIndex(name)IGNORE INDEX (name)특정 인덱스 제외

PostgreSQL에서는 hint() 메서드로 pg_hint_plan 스타일 힌트를 넣을 수 있습니다. 여러 번 호출하면 누적돼 하나의 /*+ ... */ 블록으로 합쳐져요.

typescript
const orders = await em
  .createQueryBuilder(Order, "o")
  .where("status", "pending")
  .hint("IndexScan(o idx_order_status)")
  .hint("Leading(o c)")                       // 힌트 누적
  .getMany();
// PostgreSQL: /*+ IndexScan(o idx_order_status) Leading(o c) */ SELECT ...

WARNING

hint()pg_hint_plan 확장이 설치되고 활성화된 PostgreSQL에서만 실제 효과가 있어요. 확장이 없으면 주석으로 처리되어 옵티마이저가 힌트를 보지 못합니다. SQLite는 모든 쿼리 힌트를 지원하지 않아요.

Soft Delete 처리

엔티티에 @DeletedAt 컬럼이 있으면 쿼리 빌더가 soft-deleted 행을 자동으로 제외합니다. 포함하고 싶다면:

typescript
qb.withDeleted();

결과 검증 — validate()

컴파일 타임 타입 narrowing이 많은 실수를 잡아 주지만, DB가 실제로 돌려주는 값까지 확인해 주지는 않아요. 문자열을 기대한 컬럼에 null이 올 수도 있고, 드라이버의 특성 때문에 숫자가 문자열로 올 수도 있습니다. 이 틈은 validator를 붙여 메워요. 모든 행이 애플리케이션 코드에 도달하기 전에 한 번 더 검사를 받습니다.

가장 단순한 형태는 평범한 함수입니다.

typescript
const users = await em
  .createQueryBuilder(User, "u")
  .select(["id", "name"])
  .validate((row) => {
    if (!row.name) throw new Error("name must not be empty");
    return row;
  })
  .getPartialMany();

각 행이 validator를 통과합니다. 함수가 throw하면 전체 호출이 그 에러로 reject되고, 성공하면 결과에 포함돼요. 기본값은 validator 없음 — 오버헤드 제로입니다. Validator는 getPartialMany()getMany() 모두에서 동작해요.

Validator 함수로 데이터 변환까지 할 수 있습니다. 반환한 값이 실제 결과가 돼요.

typescript
.validate((row) => ({
  ...row,
  name: row.name.trim().toLowerCase(),
}))

Zod로 스키마 검증하기

검증 함수를 손수 쓰는 건 번거롭습니다. zod를 쓰고 있다면 스키마를 그대로 넘기세요 — 쿼리 빌더는 .parse() 메서드가 있는 객체를 알아봅니다.

typescript
import { z } from "zod";

const UserRow = z.object({
  id: z.number(),
  name: z.string().min(1),
});

const users = await em
  .createQueryBuilder(User, "u")
  .select(["id", "name"])
  .validate(UserRow)
  .getPartialMany();

행이 zod 스키마를 통과하지 못하면 어떤 필드가 왜 실패했는지 상세한 ZodError가 throw됩니다. 문자열을 기대한 자리에 NULL이 오거나, 숫자를 기대한 자리에 문자열이 오는 것 같은 데이터 문제를 가장 이른 시점에 잡아 줘요.

Zod의 .transform()도 그대로 씁니다. 검증과 변환을 한 번에 할 수 있어요.

typescript
const NormalizedUser = z.object({
  id: z.number(),
  name: z.string().transform((s) => s.toUpperCase()),
  email: z.string().email(),
});

const users = await em
  .createQueryBuilder(User, "u")
  .select(["id", "name", "email"])
  .validate(NormalizedUser)
  .getPartialMany();
// [{ id: 1, name: "ALICE", email: "alice@example.com" }, ...]

.strict()는 예상치 못한 추가 필드가 있는 행을 거부합니다 — 스키마 드리프트를 감지하는 데 유용해요.

typescript
const StrictUser = z.object({ id: z.number(), name: z.string() }).strict();

// id, name 이외의 컬럼이 내려오면 throw
.validate(StrictUser)

.parse(data) 메서드를 가진 라이브러리라면 무엇이든 호환됩니다 — zod뿐 아니라 io-ts, superstruct 같은 것도 가능해요.

validate()selectSchema() — 뭘 쓸까

두 메서드가 비슷해 보이지만 역할이 다릅니다.

.validate(schema).selectSchema(schema)
런타임 검증
데이터 변환 (transform)
반환 타입 narrowing✗ — 현재 타입 유지✓ — z.infer<schema>로 좁힘
타입의 근거호출자가 선언한 TResult를 신뢰스키마가 타입의 원천
주 쓰임새"호출자 타입은 이미 맞고, 런타임 값만 한 번 더 체크""스키마에서 타입을 도출해 SELECT 모양을 확정"

간단한 규칙 — 런타임 검증만 필요하면 validate(), 타입 추론까지 스키마에 맡길 거면 selectSchema(). selectSchema의 자세한 설명과 예제는 QueryDSL 표현식의 "TypeScript 친화 이스케이프 해치" 섹션에 있어요.

배열 단위 검증 — validateArray()

개별 행이 아니라 결과 집합 전체를 검사해야 할 때도 있죠. 최대 결과 수를 제한하거나, 결과가 비어 있지 않은지 확인하는 경우예요.

typescript
const users = await em
  .createQueryBuilder(User, "u")
  .select(["id", "name"])
  .validateArray((rows) => {
    if (rows.length === 0) throw new Error("expected at least one user");
    if (rows.length > 1000) throw new Error("result set too large");
    return rows;
  })
  .getPartialMany();

Zod 배열 스키마도 씁니다.

typescript
const UsersArray = z
  .array(z.object({ id: z.number(), name: z.string() }))
  .min(1)
  .max(100);

const users = await qb
  .select(["id", "name"])
  .validateArray(UsersArray)
  .getPartialMany();

행 검증과 배열 검증 섞어 쓰기

한 쿼리에 validate()validateArray()를 함께 붙여도 됩니다. 행 단위 검증이 먼저, 그다음 배열 단위 검증이 실행돼요.

typescript
const users = await em
  .createQueryBuilder(User, "u")
  .select(["id", "name"])
  .validate(z.object({             // 1. 각 행 검증 + 변환
    id: z.number(),
    name: z.string().transform((s) => s.trim()),
  }))
  .validateArray((rows) => {       // 2. 배열 전체 제약
    if (rows.length > 100) throw new Error("too many results");
    return rows;
  })
  .getPartialMany();

언제 검증을 쓸까

검증은 행마다 함수 호출이 더해지니 공짜가 아닙니다. 비용 대비 효과가 큰 상황은 다음과 같아요.

상황검증?이유
내부 서비스, 스키마를 신뢰아니오속도가 더 중요
사용자 데이터를 반환하는 API 엔드포인트null / 타입 불일치를 클라이언트에 닿기 전에 차단
느슨한 소스 데이터를 다루는 ETL 파이프라인잘못된 데이터의 전파 차단
역직렬화 버그 디버깅일시적으로 예정확히 어떤 행/필드가 문제인지 특정

기본값 — validator 없음, 오버헤드 제로 — 이 대부분의 내부 쿼리에 어울리는 선택이에요. 데이터가 신뢰 경계를 넘어가는 지점에서 검증을 얹으면 됩니다.

쿼리 실행하기

쿼리를 다 만들었으니 실행하는 일이 남았습니다. 쿼리 빌더는 안전성과 타이핑 보장이 다른 세 단계의 실행 메서드를 제공해요.

Safe — 필수 컬럼 검증이 있는 클래스 인스턴스:

메서드반환설명
getMany()T[]클래스 인스턴스. select() 사용 시 필수 컬럼 검증
getOne()T | null단일 인스턴스 또는 null (자동으로 LIMIT 1)
getOneOrFail()T단일 인스턴스 (결과 없으면 EntityNotFoundError)
getManyAndCount()[T[], number]인스턴스 배열 + 총 개수 병렬 실행

행을 항상 엔티티 클래스 인스턴스로 역직렬화합니다. instanceof가 동작하고, 클래스 메서드를 쓸 수 있고, em.save()에 그대로 넘길 수 있어요. select()로 일부 컬럼을 고르는 경우에는 not-null 컬럼이 반드시 포함돼야 합니다 — 아니면 OrmError를 던져요.

Partial — 타입드 plain object (Pick):

메서드반환설명
getPartialMany()TResult[]Pick<T, K>로 좁혀진 plain object
getPartialOne()TResult | null단일 plain object 또는 null
getPartialManyAndCount()[TResult[], number]plain object 배열 + 총 개수

역직렬화도, 필수 컬럼 검증도 하지 않습니다. select(["id", "name"])를 쓰면 반환 타입이 Pick<T, "id" | "name">[]로 좁혀져요 — 선택하지 않은 컬럼에 접근하면 컴파일 에러입니다. em.save()에는 넘기지 마세요.

Raw — 타입 없는 plain object:

메서드반환설명
getRawMany()Record<string, unknown>[]타입 없는 plain object
getRawOne()Record<string, unknown> | null단일 plain object 또는 null

엔티티에 없는 계산 컬럼(예: addSelect(sqlCOUNT(*), "cnt"))이 결과에 섞일 때 씁니다. 타입 정보도 역직렬화도 없습니다.

유틸 (결과 모양 변경 없음):

메서드반환설명
getCount()number같은 WHERE/JOIN으로 COUNT(*)
exists()boolean매칭 행이 있는지 여부

어떤 걸 쓸지. 기본값은 getMany(). 컴파일 타임 narrowing이 필요한 읽기 전용 DTO에는 getPartialMany(). addSelect나 계산 컬럼이 섞인 쿼리에는 getRawMany().

디버깅할 때는 getSql()로 실행 없이 SQL 문자열과 파라미터를 꺼내볼 수 있어요.

typescript
const { text, values } = qb.getSql();
console.log(text);   // SELECT "u"."id", ... WHERE "u"."is_active" = ?
console.log(values);  // [true]

참고 getSql() 출력의 ? 플레이스홀더는 가독성을 위한 표시입니다. 실제 실행 쿼리는 드라이버 규약에 맞는 파라미터(PostgreSQL은 $1, $2, MySQL은 ?)를 씁니다.

필수 컬럼 검증

getMany()select() 사용 시 not-null 컬럼이 모두 포함됐는지 확인합니다. 필수 필드가 undefined인 불완전한 클래스 인스턴스가 만들어지는 걸 막아줘요.

typescript
@Entity()
class User {
  @PrimaryGeneratedColumn()         // autoIncrement — 생략 가능
  id!: number;

  @Column({ type: "varchar" })      // not-null — 필수
  name!: string;

  @Column({ nullable: true })       // nullable — 생략 가능
  bio!: string | null;

  @Column({ default: "active" })    // 기본값 있음 — 생략 가능
  status!: string;
}

// ✓ OK — "name" (유일한 필수 컬럼)이 포함됨
await qb.select(["name"]).getMany();

// ✗ OrmError MISSING_REQUIRED_COLUMNS — "name" 누락
await qb.select(["bio"]).getMany();

// ✓ getPartialMany()는 검증 스킵
await qb.select(["bio"]).getPartialMany();  // OK — plain object

컬럼이 필수로 분류되려면 다음이 모두 참이어야 해요.

  • nullabletrue가 아님
  • default 값이 없음
  • autoIncrement가 아님 (예: @PrimaryGeneratedColumn)

메서드별 사용 시나리오

상황메서드이유
em.save()에 넘길 데이터getMany()전체 생명주기를 지원하는 클래스 인스턴스
EntitySubscriber / 생명주기 훅getMany()클래스 인스턴스만 listenTo()에 매칭
읽기 전용 API 응답getPartialMany()타입드 DTO, 컴파일 타임 안전, 가벼움
집계 / 계산 컬럼getRawMany()addSelect(sqlCOUNT(*), "cnt")Pick으로 타이핑 불가
존재 여부 확인exists()boolean만 반환 — 데이터 가져오지 않음

em.find()에도 select 옵션이 있지만 반환 타입을 좁혀주지는 않아요 — find()는 항상 T[]입니다. 투영의 타입 안전이 중요하면 쿼리 빌더의 getPartialMany()를 쓰세요.

같은 쿼리를 미리 만들어 두기 — prepare()

같은 모양의 쿼리가 값만 바뀌며 반복될 때는 SQL을 한 번만 만들어 두고 재사용할 수 있습니다. 배치 잡에서 수백 개의 ID로 상세 쿼리를 돌리는 상황이 전형적이에요.

typescript
import { p } from "@stingerloom/orm";
import sql from "sql-template-tag";

const findById = em
  .createQueryBuilder(User, "u")
  .where(sql`u.id = ${p("id")}`)
  .prepare<{ id: number }>();

for (const id of userIds) {
  const user = await findById.executeOne({ id });
  // SQL 문자열은 한 번만 생성, 이후엔 파라미터만 교체
}

SelectQueryBuilder투영된 일부 컬럼만 타입드로 받고 싶을 때preparePartial()을 쓰면 TResultPick<...>로 좁혀집니다. RawQueryBuilder에도 같은 모양의 .prepare(em)이 있어요.

주의 — 쿼리 구조 자체가 달라지면 prepare()는 효과가 없습니다. "필터가 선택적으로 붙는 검색 엔드포인트"처럼 SQL 골격이 매번 바뀌는 경우는 when() / pipe() 쪽이 맞고, prepare()는 어울리지 않아요.

언제 효과가 있는지, WriteBuffer나 배치 쓰기와는 어떻게 다른지의 전체 배경은 쿼리 미리 만들어두기에서 이어집니다.

실전 예제 — 필터 검색 + 페이지네이션

지금까지 본 것들을 합쳐 봅시다. 선택적 필터를 받아 총 개수와 함께 페이지네이션된 결과를 돌려주는 검색 엔드포인트예요.

typescript
import { qAlias } from "@stingerloom/orm";

async function searchPosts(filters: {
  authorName?: string;
  category?: string;
  minLikes?: number;
  page: number;
  pageSize: number;
}) {
  const p = qAlias(Post, "p");
  const u = qAlias(User, "u");

  const qb = em
    .createQueryBuilder(Post, "p")
    .select(["id", "title", "createdAt"])
    .leftJoin(User, "u", (join) =>
      join.on(p.col("authorId"), "=", u.col("id"))
    );

  // 각 필터는 선택 — 값이 있을 때만 조건 추가
  if (filters.authorName) {
    qb.where(u.name.like(`%${filters.authorName}%`));
  }
  if (filters.category) {
    qb.andWhere(p.category.eq(filters.category));
  }
  if (filters.minLikes) {
    qb.andWhere(p.likeCount.gte(filters.minLikes));
  }

  const [posts, total] = await qb
    .orderBy({ createdAt: "DESC" })
    .skip(filters.page * filters.pageSize)
    .take(filters.pageSize)
    .getPartialManyAndCount();

  return { posts, total, page: filters.page, pageSize: filters.pageSize };
}

qAlias()를 쓰면 쿼리 빌더가 자연스럽게 읽힙니다 — u.name.like(...), p.category.eq(...). 타입스크립트가 프로퍼티 이름도, 조건 메서드도 자동완성해 주니 오타가 컴파일 에러로 잡혀요.

when()을 쓰면 if 블록을 아예 없앨 수 있습니다.

typescript
const [posts, total] = await em
  .createQueryBuilder(Post, "p")
  .select(["id", "title", "createdAt"])
  .leftJoin(User, "u", (join) =>
    join.on(p.col("authorId"), "=", u.col("id"))
  )
  .when(!!filters.authorName, (qb) =>
    qb.where(u.name.like(`%${filters.authorName}%`))
  )
  .when(!!filters.category, (qb) =>
    qb.where(p.category.eq(filters.category))
  )
  .when(!!filters.minLikes, (qb) =>
    qb.where(p.likeCount.gte(filters.minLikes))
  )
  .orderBy({ createdAt: "DESC" })
  .skip(filters.page * filters.pageSize)
  .take(filters.pageSize)
  .getPartialManyAndCount();

when(), pipe(), scope 같은 편의 헬퍼는 편의 패턴에서 이어집니다.

다음 단계

Released under the MIT License.