EntityManager -- 쓰기 & 트랜잭션
이 문서에서는 배치 쓰기 연산, upsert, 트랜잭션, Raw SQL 실행을 다뤄요.
단일 엔티티 CRUD는 CRUD 기본을, 조회 관련 기능은 쿼리 & 페이지네이션을 참고하세요.
배치 삽입 -- insertMany()
insertMany()가 필요한 이유
유저 1,000명을 생성해야 한다고 생각해 보세요. save()를 루프로 호출하면 INSERT 문 1,000개, 네트워크 왕복 1,000번, 커밋 1,000번이 발생해요. 엄청 느려요.
insertMany()는 모든 행을 하나의 INSERT INTO ... VALUES (...), (...), (...) 문으로 묶어서 보내요. 왕복 1번, 파싱 1번, 커밋 1번이에요. 실제 벤치마크에서 개별 삽입 대비 보통 10~50배 빨라요.
await em.insertMany(User, [
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" },
{ name: "Charlie", email: "charlie@example.com" },
]);ORM이 생성하는 SQL은 이래요:
-- PostgreSQL
INSERT INTO "user" ("name", "email")
VALUES ($1, $2), ($3, $4), ($5, $6)
-- Parameters: ['Alice', 'alice@example.com', 'Bob', 'bob@example.com', 'Charlie', 'charlie@example.com']
-- MySQL
INSERT INTO `user` (`name`, `email`)
VALUES (?, ?), (?, ?), (?, ?)
-- Parameters: ['Alice', 'alice@example.com', 'Bob', 'bob@example.com', 'Charlie', 'charlie@example.com']주요 특징:
- 단일 SQL 문으로 실행돼요 --
save()루프보다 훨씬 효율적이에요. @CreateTimestamp,@UpdateTimestamp컬럼이 자동으로 주입돼요.@Version컬럼은 각 행마다1로 초기화돼요.{ affected: number }를 반환해요 -- 생성된 PK가 필요하면saveMany()를 사용하세요.
배치 저장 -- saveMany()
insertMany()가 있는데 왜 saveMany()가 필요할까요?
insertMany()는 빠르지만 새 행만 삽입할 수 있어요. saveMany()는 각 항목의 PK를 확인해서 INSERT와 UPDATE를 알아서 구분해요. 새 엔티티와 기존 엔티티가 섞여 있을 때 사용하세요.
const users = await em.saveMany(User, [
{ name: "New User", email: "new@example.com" }, // No PK -> INSERT
{ id: 2, name: "Updated User", email: "upd@example.com" }, // Has PK -> UPDATE
]);
// Returns the saved entities with generated PKs내부적으로 saveMany()는 모든 연산을 단일 트랜잭션으로 감싸고 각 항목을 하나씩 처리해요. 위 예시의 SQL 타임라인은 이래요:
-- Step 1: BEGIN transaction
BEGIN
-- Step 2: First item has no PK -> INSERT
-- PostgreSQL
INSERT INTO "user" ("name", "email") VALUES ($1, $2) RETURNING *
-- Parameters: ['New User', 'new@example.com']
-- Step 3: Second item has PK -> UPDATE
-- PostgreSQL
UPDATE "user" SET "name" = $1, "email" = $2 WHERE "id" = $3 RETURNING *
-- Parameters: ['Updated User', 'upd@example.com', 2]
-- Step 4: COMMIT
COMMIT트레이드오프는 명확해요: saveMany()는 항목당 쿼리 하나를 보내서 순수 삽입에서는 느리지만, insertMany()가 처리할 수 없는 생성/수정 혼합 시나리오를 다룰 수 있어요.
배치 삭제 -- deleteMany()
왜 delete()를 여러 번 호출하는 대신 deleteMany()를 쓸까요?
insertMany()가 save() 루프보다 빠른 것과 같은 이유예요. SQL 문 하나가 여러 개보다 나아요. deleteMany()는 PK 값 배열을 받아서 단일 DELETE ... WHERE id IN (...) 문을 생성해요.
const result = await em.deleteMany(User, [1, 2, 3]);
console.log(result.affected); // 3-- PostgreSQL
DELETE FROM "user" WHERE "id" IN ($1, $2, $3)
-- Parameters: [1, 2, 3]
-- MySQL
DELETE FROM `user` WHERE `id` IN (?, ?, ?)
-- Parameters: [1, 2, 3]TIP
PK가 아닌 조건으로 삭제하려면 WHERE 절과 함께 delete()를 사용하세요:
await em.delete(User, { isActive: false });일괄 수정 -- updateMany()
updateMany()가 필요한 이유
동일한 변경을 수천 행에 적용해야 할 때가 있어요 -- 만료된 계정 비활성화, 카테고리 가격 일괄 인상, 플래그 초기화 같은 경우요. save()로 하면 각 행을 조회하고, 수정하고, 다시 저장해야 해요. updateMany()는 단일 UPDATE ... SET ... WHERE ...로 한 번에 처리해요.
const result = await em.updateMany(User,
{ isActive: false }, // SET -- 적용할 데이터
{ where: { lastLoginAt: null } }, // WHERE -- 매칭 조건
);
console.log(result.affected); // number of updated rows-- PostgreSQL
UPDATE "user"
SET "isActive" = $1
WHERE "lastLoginAt" IS NULL
-- Parameters: [false]
-- MySQL
UPDATE `user`
SET `isActive` = ?
WHERE `lastLoginAt` IS NULL
-- Parameters: [false]좀 더 현실적인 예시 -- 최근 로그인하지 않은 유저 비활성화:
const result = await em.updateMany(User,
{ isActive: false, deactivatedAt: new Date() },
{ where: { isActive: true } },
);
console.log(`Deactivated ${result.affected} users`);-- PostgreSQL
UPDATE "user"
SET "isActive" = $1, "deactivatedAt" = $2, "updatedAt" = $3
WHERE "isActive" = $4
-- Parameters: [false, '2026-03-22 12:00:00', '2026-03-22 12:00:00', true]SET 절에 "updatedAt" 컬럼이 자동으로 포함된 게 보이죠 -- ORM이 일괄 수정에서도 @UpdateTimestamp 컬럼을 자동 주입해요.
주요 특징:
@UpdateTimestamp컬럼이 SET 절에 자동 주입돼요.- 빈 WHERE 조건은
DeleteWithoutConditionsError를 던져요 (안전 장치). save()와 달리 엔티티 라이프사이클 훅이나 이벤트가 발생하지 않아요 -- raw 일괄 연산이에요.
DANGER
파라미터 순서가 (Entity, setData, { where }) 예요 -- (Entity, where, setData)가 아니에요. 설정할 데이터가 먼저 와요.
Upsert -- 삽입 또는 수정
왜 upsert가 필요할까요?
"로그인 추적" 기능을 생각해 보세요: 유저가 로그인할 때마다 레코드를 새로 만들거나 기존 레코드를 업데이트하고 싶어요. upsert 없이는 이렇게 해야 해요:
findOne()-- 레코드 존재 여부 확인- 있으면: PK와 함께
save()로 UPDATE - 없으면: PK 없이
save()로 INSERT
왕복 3번에 레이스 컨디션까지 있어요 (두 요청이 동시에 "없음"을 확인하고 둘 다 INSERT를 시도하면 중복 키 에러가 나요). upsert는 단일 원자적 문으로 두 문제를 모두 해결해요.
PK 기준
await em.upsert(User, {
id: 1,
name: "Alice",
email: "alice@example.com",
});
// If id=1 exists -> UPDATE name and email
// If id=1 doesn't exist -> INSERT new row-- PostgreSQL
INSERT INTO "user" ("id", "name", "email")
VALUES ($1, $2, $3)
ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email"
-- Parameters: [1, 'Alice', 'alice@example.com']
-- MySQL
INSERT INTO `user` (`id`, `name`, `email`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `email` = VALUES(`email`)
-- Parameters: [1, 'Alice', 'alice@example.com']핵심은 ON CONFLICT ... DO UPDATE (PostgreSQL) 또는 ON DUPLICATE KEY UPDATE (MySQL)예요. 둘 다 "삽입을 시도하되, 지정된 컬럼에서 충돌이 나면 기존 행을 대신 업데이트하라"는 뜻이에요.
유니크 컬럼 기준
세 번째 인자로 컬럼 이름 배열을 전달해서 충돌 대상을 지정할 수 있어요:
await em.upsert(User, {
email: "alice@example.com",
name: "Alice",
lastLoginAt: new Date(),
}, ["email"]);
// If a row with this email exists -> UPDATE name and lastLoginAt
// If no row with this email -> INSERT-- PostgreSQL
INSERT INTO "user" ("email", "name", "lastLoginAt")
VALUES ($1, $2, $3)
ON CONFLICT ("email") DO UPDATE SET "name" = EXCLUDED."name", "lastLoginAt" = EXCLUDED."lastLoginAt"
-- Parameters: ['alice@example.com', 'Alice', '2026-03-22 12:00:00']
-- MySQL
INSERT INTO `user` (`email`, `name`, `lastLoginAt`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `lastLoginAt` = VALUES(`lastLoginAt`)
-- Parameters: ['alice@example.com', 'Alice', '2026-03-22 12:00:00']충돌 감지 방식
"충돌 컬럼"은 데이터베이스에게 어떤 유니크 제약 조건을 확인할지 알려줘요. ["email"]을 지정하면, 삽입하려는 값과 email이 일치하는 기존 행을 찾아요. 있으면 해당 행을 업데이트하고, 없으면 새 행을 삽입해요.
INFO
충돌 컬럼(세 번째 인자)에는 유니크 제약 조건이 있거나 PK여야 해요. 그렇지 않으면 데이터베이스가 쿼리를 거부해요. PostgreSQL에서는 there is no unique or exclusion constraint matching the ON CONFLICT specification 에러가 발생해요.
트랜잭션
왜 트랜잭션이 필요할까요?
EntityManager의 개별 연산(save, find, delete 등)은 자동으로 각각의 트랜잭션으로 감싸져요. 하지만 두 연산이 반드시 함께 성공하거나 실패해야 할 때는 어떻게 할까요?
이커머스 결제를 생각해 보세요: 주문을 생성하고 재고를 차감해요. 주문 생성은 성공했는데 재고 차감이 실패하면, "아직 구매 가능"한 상품에 대한 주문이 남게 돼요 -- 데이터 불일치예요. 트랜잭션은 연산을 원자적 단위로 묶어서 해결해요: 모두 커밋되거나, 모두 롤백돼요.
콜백 API -- transaction()
트랜잭션을 사용하는 가장 간단한 방법이에요. 콜백이 this EntityManager를 받고, 내부의 모든 연산이 같은 트랜잭션을 공유해요.
const order = await em.transaction(async (txEm) => {
const order = await txEm.save(Order, {
userId: 1,
status: "pending",
});
await txEm.insertMany(OrderItem, [
{ orderId: order.id, productId: 10, quantity: 2 },
{ orderId: order.id, productId: 20, quantity: 1 },
]);
return order;
// COMMIT on success
});
// If any operation throws -> ROLLBACK automatically이 트랜잭션의 정확한 SQL 타임라인이에요:
-- 1. Open a connection and start the transaction
BEGIN
-- 2. Insert the order
INSERT INTO "order" ("userId", "status") VALUES ($1, $2) RETURNING *
-- Parameters: [1, 'pending']
-- 3. Insert order items (single multi-row statement)
INSERT INTO "order_item" ("orderId", "productId", "quantity")
VALUES ($1, $2, $3), ($4, $5, $6)
-- Parameters: [1, 10, 2, 1, 20, 1]
-- 4a. If everything succeeded:
COMMIT
-- 4b. If any query threw an error:
ROLLBACKBEGIN과 COMMIT/ROLLBACK은 자동으로 처리돼요. 직접 작성할 필요 없어요.
에러 발생 시 동작
콜백 내부에서 예외가 발생하면, ORM이:
- 예외를 잡아요
ROLLBACK을 실행해서 트랜잭션 내 모든 변경을 되돌려요- 원래 예외를 다시 던져서 애플리케이션 코드에서 처리할 수 있게 해요
데이터베이스가 절반만 완료된 상태로 남는 일은 없어요. 모든 변경이 적용되거나, 아무것도 적용되지 않아요.
데드락 재시도
동시성이 높은 시나리오(여러 유저가 같은 상품을 구매하는 경우 등)에서는 데드락이 발생할 수 있어요. 데드락은 두 트랜잭션이 서로 잠금 해제를 기다리면서 둘 다 진행하지 못하는 상태예요. 데이터베이스가 이를 감지하고 하나를 종료해요.
transaction() 메서드는 자동 재시도를 지원해요:
await em.transaction(async (txEm) => {
const stock = await txEm.findOne(Inventory, {
where: { productId: 42 },
lock: LockMode.PESSIMISTIC_WRITE,
});
if (stock.quantity < 1) {
throw new Error("Out of stock");
}
stock.quantity -= 1;
await txEm.save(Inventory, stock);
}, {
retryOnDeadlock: true, // Enable deadlock retry
maxRetries: 3, // Maximum attempts (default: 3)
retryDelayMs: 100, // Delay between retries in ms (default: 100)
});ORM은 다이얼렉트별로 데드락 에러를 감지해요:
- MySQL:
errno 1213(ER_LOCK_DEADLOCK) - PostgreSQL:
code 40P01(deadlock_detected) - SQLite:
SQLITE_BUSY/ "database is locked"
데드락이 감지되면 콜백 전체가 처음부터 다시 실행돼요. 콜백은 멱등(idempotent)해야 해요 -- 안전하게 반복할 수 없는 데이터베이스 외부 부수 효과(이메일 발송 등)가 없어야 해요.
데코레이터 기반 트랜잭션
NestJS 서비스에서는 콜백 API 대신 @Transactional() 데코레이터를 사용할 수 있어요. 데코레이터 사용법, 격리 수준, savepoint에 대한 자세한 내용은 트랜잭션을 참고하세요.
Raw SQL -- query()
왜 Raw SQL이 필요할까요?
EntityManager API가 데이터베이스 상호작용의 90%를 커버해요. 하지만 가끔 API가 제공하지 않는 기능이 필요해요: 윈도우 함수, CTE(Common Table Expressions), DB 전용 구문, 또는 raw SQL로 작성하는 게 더 읽기 쉬운 복잡한 조인 같은 거요.
query()는 ORM의 커넥션 관리, 트랜잭션 처리, 파라미터 바인딩의 이점을 누리면서 어떤 SQL이든 실행할 수 있는 탈출구예요.
sql-template-tag 사용 (권장)
import sql from "sql-template-tag";
const users = await em.query<{ id: number; name: string }>(
sql`SELECT * FROM "user" WHERE "age" > ${18} AND "city" = ${"Seoul"}`
);문자열 보간처럼 보이지만 아니에요. sql-template-tag의 sql 템플릿 태그가 쿼리 텍스트와 파라미터 값을 자동으로 분리해요. 실제로 데이터베이스에 전송되는 내용은 이래요:
-- Query text (sent to database)
SELECT * FROM "user" WHERE "age" > $1 AND "city" = $2
-- Parameters (sent separately): [18, 'Seoul']데이터베이스가 쿼리 구조와 값을 별도로 받아요. '; DROP TABLE user; -- 같은 악의적인 값도 리터럴 문자열로 처리되지, SQL 코드로 실행되지 않아요. 이걸 **파라미터화된 쿼리(parameterized queries)**라고 하고, SQL injection에 대한 주요 방어 수단이에요.
문자열 + 파라미터 배열 사용
const posts = await em.query<{ id: number; title: string }>(
"SELECT id, title FROM post WHERE author_id = $1",
[42]
);-- Query text
SELECT id, title FROM post WHERE author_id = $1
-- Parameters: [42]WARNING
Raw SQL 문자열을 사용할 때는 반드시 파라미터 바인딩($1, ? 등)을 사용하세요. 유저 입력을 문자열에 직접 연결하면 안 돼요.
반환 타입
query<T>()는 T[]를 반환해요. 제네릭 파라미터 T로 결과 행의 타입을 지정할 수 있어요:
interface MonthlyStats {
month: string;
total_orders: number;
revenue: number;
}
const stats = await em.query<MonthlyStats>(sql`
SELECT
TO_CHAR("created_at", 'YYYY-MM') AS month,
COUNT(*) AS total_orders,
SUM("amount") AS revenue
FROM "order"
WHERE "created_at" >= ${startDate}
GROUP BY TO_CHAR("created_at", 'YYYY-MM')
ORDER BY month DESC
`);-- PostgreSQL
SELECT
TO_CHAR("created_at", 'YYYY-MM') AS month,
COUNT(*) AS total_orders,
SUM("amount") AS revenue
FROM "order"
WHERE "created_at" >= $1
GROUP BY TO_CHAR("created_at", 'YYYY-MM')
ORDER BY month DESC
-- Parameters: [startDate]T[] 반환 타입은 런타임에서 검증하지 않아요 -- 타입 어노테이션을 신뢰해요. SQL이 인터페이스와 일치하지 않는 컬럼을 반환해도 TypeScript가 컴파일 타임에 잡지 못해요. 제네릭 파라미터는 런타임 보장이 아닌, 팀을 위한 문서로 생각하세요.
CTE, UNION, 윈도우 함수, 서브쿼리에 대한 전체 가이드는 Raw SQL & CTE를 참고하세요.
다음 단계
- CRUD 기본 -- save, find, delete, soft delete
- 쿼리 & 페이지네이션 -- SELECT, 페이지네이션, 스트리밍, 집계
- 고급 기능 -- 이벤트, subscriber, 멀티테넌시, 플러그인, FindOption 레퍼런스