Skip to content

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배 빨라요.

typescript
await em.insertMany(User, [
  { name: "Alice", email: "alice@example.com" },
  { name: "Bob", email: "bob@example.com" },
  { name: "Charlie", email: "charlie@example.com" },
]);

ORM이 생성하는 SQL은 이래요:

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 컬럼은 행에 값이 없으면 type과 상관없이(timestamptz 포함) 현재 시각으로 채워집니다. 그 밖의 날짜·시간 컬럼은 건드리지 않아요.
  • @Version 컬럼은 각 행마다 1로 초기화되고, 클라이언트에서 생성하는 키(@PrimaryGeneratedColumn("uuid") / "uuid-v7")는 값이 없는 행마다 새로 만들어집니다. 이렇게 생성된 값은 넘긴 객체에도 기록됩니다.
  • 어느 행도 값을 주지 않은 컬럼은 문장에서 빠지므로 DB의 DEFAULT가 적용됩니다. saveMany()와 같은 규칙이에요(INSERT에서 undefined vs null 참고).
  • { affected: number }를 반환해요 -- 삽입된 행을 돌려받아야 한다면 insertManyAndReturn()(PostgreSQL / SQLite) 또는 saveMany()(전 다이얼렉트)를 사용하세요.

배치 삽입 후 반환 -- insertManyAndReturn() ​

insertManyAndReturn()가 필요한 이유 ​

insertMany()는 빠르지만 { affected: number }만 반환해요. 삽입된 행의 생성된 PK, 타임스탬프, DB 기본값이 채워진 엔티티 인스턴스가 필요하면서도 멀티 행 INSERT의 단일 문 효율을 그대로 유지하고 싶을 때 insertManyAndReturn()을 사용하세요.

내부적으로 ORM은 INSERT INTO ... VALUES (...), (...), (...) RETURNING * 문 하나를 실행하고, 결과 행을 ResultTransformer를 통해 역매핑해요. 컬럼 별칭과 NamingStrategy 매핑이 결과에 적용돼요.

typescript
const users = await em.insertManyAndReturn(User, [
  { name: "Alice", email: "alice@example.com" },
  { name: "Bob", email: "bob@example.com" },
]);
// users[0].id => 1  (DB가 생성한 PK)
// users[1].id => 2

ORM이 생성하는 SQL은 이래요:

sql
-- PostgreSQL
INSERT INTO "user" ("name", "email")
VALUES ($1, $2), ($3, $4)
RETURNING *
-- Parameters: ['Alice', 'alice@example.com', 'Bob', 'bob@example.com']

-- SQLite 3.35+
INSERT INTO "user" ("name", "email")
VALUES (?, ?), (?, ?)
RETURNING *
-- Parameters: ['Alice', 'alice@example.com', 'Bob', 'bob@example.com']

주요 특징:

  • 단일 SQL 문으로 실행돼요 -- insertMany()와 동일한 효율이에요.
  • 결과 엔티티 인스턴스를 입력 순서대로 반환해요.
  • @CreateTimestamp, @UpdateTimestamp, @Version과 클라이언트 생성 UUID 키가 INSERT 전에 채워지고, 어느 행도 값을 주지 않은 컬럼은 DB DEFAULT에 맡깁니다. insertMany()와 같은 규칙입니다.
  • items가 비어 있으면 DB를 전혀 건드리지 않고 즉시 []를 반환해요.

다이얼렉트 지원. insertManyAndReturn()은 INSERT ... RETURNING이 필요해요. PostgreSQL과 SQLite 3.35+ 이상에서 사용할 수 있어요. MySQL에서 호출하면 OrmError (UNSUPPORTED_DATABASE)가 발생해요. MySQL에서는 saveMany()를 사용하세요.

typescript
// MySQL에서는 OrmError (UNSUPPORTED_DATABASE) 발생
// await em.insertManyAndReturn(User, [{ name: "Alice" }]); // MySQL에서 사용 금지

// MySQL에서는 saveMany() 사용:
const users = await em.saveMany(User, [{ name: "Alice" }]);

배치 INSERT 방법 선택 가이드 ​

메서드지원 다이얼렉트SQL 문 수반환값
insertMany()전체1{ affected: number }
insertManyAndReturn()PostgreSQL, SQLite 3.35+1엔티티 인스턴스 배열
saveMany()전체N (행당 1개)엔티티 인스턴스 배열

행을 돌려받을 필요가 없고 MySQL도 지원해야 한다면 insertMany()를 쓰세요. 행을 돌려받아야 하고 PostgreSQL 또는 SQLite를 대상으로 한다면 insertManyAndReturn()을 쓰세요. MySQL에서 행을 돌려받아야 하거나, 새 엔티티와 기존 엔티티가 섞인 배치(행마다 INSERT vs UPDATE)를 처리해야 한다면 saveMany()를 쓰세요.


배치 저장 -- saveMany() ​

insertMany()가 있는데 왜 saveMany()가 필요할까요? ​

insertMany()는 빠르지만 새 행만 삽입할 수 있어요. saveMany()는 각 항목의 PK를 확인해서 INSERT와 UPDATE를 알아서 구분해요. 새 엔티티와 기존 엔티티가 섞여 있을 때 사용하세요.

typescript
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 타임라인은 이래요:

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()가 처리할 수 없는 생성/수정 혼합 시나리오를 다룰 수 있어요.


INSERT에서 undefined vs null -- DB 기본값 살리기 ​

왜 구분이 중요할까요? ​

save()와 saveMany()는 undefined를 "값을 안 줬다"로 해석해요 (TypeORM/knex와 같은 의미론이에요). 엔티티 값이 undefined인 컬럼은 INSERT 컬럼 목록에서 빠지고, 그 자리는 DB 쪽 DEFAULT 절 -- @Column({ default }) 포함 -- 이 채워요. 명시적인 null은 달라요: 컬럼이 포함되고 NULL이 기록돼요.

typescript
@Entity()
class Article {
  @PrimaryGeneratedColumn() id!: number;
  @Column() title!: string;
  @Column({ default: "draft" }) status!: string;
  @Column({ nullable: true }) summary?: string;
}

const a = await em.save(Article, { title: "Hello" });
// INSERT INTO "article" ("title") VALUES ($1)
// -> "status"는 생략되고 DB DEFAULT 'draft'가 적용됨
a.status; // "draft"

await em.save(Article, { title: "Hello", summary: null });
// INSERT INTO "article" ("title", "summary") VALUES ($1, $2)
// -> "summary"는 포함되고 명시적으로 NULL을 기록

자동 주입 컬럼은 예외예요. @CreateTimestamp, @UpdateTimestamp, @Version, 클라이언트 측 UUID 생성 전략은 값이 undefined라도 여전히 포함되고 자동으로 채워져요.

모든 컬럼이 생략되면 ORM은 다이얼렉트별 "전부 기본값" INSERT 폼을 내보내요:

sql
-- MySQL / MariaDB
INSERT INTO `t` () VALUES ()

-- PostgreSQL / SQLite
INSERT INTO "t" DEFAULT VALUES

saveMany() 배치 삽입에서는 멀티 행 VALUES가 컬럼 목록 하나를 공유해요. 그래서 컬럼은 배치의 어떤 항목도 값을 주지 않았을 때만 생략돼요. 일부 항목만 값을 준 혼합 배치에서는 컬럼이 목록에 남고, 값이 없는 행에는 NULL이 바인딩돼요.

insertMany(), insertManyAndReturn(), createInsertBuilder(), batchUpsert()도 이 배치 규칙을 따르고, upsert() / insertIgnore()는 단일 행 규칙을 따릅니다. insertMany()의 어느 행도 컬럼 값을 하나도 주지 않았다면 전체 컬럼 목록을 그대로 두고 NULL을 바인딩합니다. 멀티 행 INSERT에는 이식 가능한 "전부 기본값" 형태가 없기 때문입니다. 2.1 이전의 insertMany(), insertManyAndReturn(), createInsertBuilder()는 선언된 컬럼을 전부 나열하고 빠진 값에 NULL을 바인딩해서, 이 경로에서는 DEFAULT가 적용되지 않았습니다.


쓰기 페이로드의 미지 키 ​

어떤 키가 "아는 키"인가요? ​

save(), saveMany(), insertMany(), insertManyAndReturn(), upsert(), insertIgnore(), batchUpsert()는 값을 속성 키로 읽습니다. 페이로드에 실을 수 있는 키는 다음과 같아요.

  • @Column 속성명 (first_name이라는 DB 컬럼명이 아니라 firstName);
  • 모든 종류의 관계 속성 (team, posts, tags) -- 캐스케이드 입력과 조회로 얻은 인스턴스;
  • @ManyToOne / @OneToOne의 FK 섀도우 속성 (teamId, 또는 직접 선언한 fkProperty)과 조인 컬럼 자체;
  • @ComputedColumn 속성 -- 쓰이지는 않지만 조회로 얻은 인스턴스마다 붙어 있습니다;
  • 단일 테이블 상속에서는 판별자 컬럼과 형제 클래스의 컬럼.

그 밖의 키 -- 오타, 컬럼이 되지 못한 DTO 필드, 속성 대신 적은 DB 컬럼명 -- 는 쓰이지 않습니다. 2.1 이전에는 아무 말 없이 버려졌어요. 같은 키를 updateMany()나 조회에 넘기면 진작부터 Did you mean 제안과 함께 거절됐는데도요.

unknownWriteKeys 정책 ​

연결 옵션 unknownWriteKeys가 동작을 정합니다.

정책동작
"warn" (기본값)쓰기는 실행됩니다. 키는 EntityManager 수명 동안 엔티티·키당 한 번 로그로 남고, 호출한 메서드와 가장 가까운 허용 키를 알려줘요.
"throw"SQL을 만들기 전에 InvalidQueryError로 거절합니다. 조회 where의 오타가 던지는 것과 같은 에러예요. 경고가 다 정리된 뒤에 권장합니다.
"ignore"이전 동작 그대로, 미지 키를 조용히 버립니다.
typescript
await DatabaseClient.getInstance().connect({
  type: "postgres",
  // ...
  unknownWriteKeys: "throw",
});

await em.save(User, { firstNam: "kim" } as any);
// InvalidQueryError: Unknown column "firstNam" in "data" for entity "User". Did you mean "firstName"?

기본 정책에서는 같은 호출이 성공하고 다음 로그가 남아요.

[WriteInput] Unknown key "firstNam" in the data passed to save() for entity "User" — it matches no column, relation or FK property and was not written. Did you mean "firstName"? Set unknownWriteKeys: "throw" to reject such writes, or "ignore" to silence this warning.

DB 컬럼명도 보고 대상입니다. INSERT는 그 키를 읽지 않기 때문이에요. save(Team, { team_name: "x" })는 Did you mean "teamName"?과 함께 경고하지만, 조회의 where: { team_name }은 컬럼으로 풀립니다. 쓰기는 속성명으로 하세요.

애초에 쓰이지 않는 값은 보고하지 않습니다. undefined 필드(위 절 참고)와 함수 값 멤버(엔티티 인스턴스의 메서드)가 그렇습니다. 검사는 훅·캐스케이드·테넌트 컬럼 주입보다 먼저 실행되므로, 넘긴 페이로드를 그대로 봅니다.

update() / updateMany()는 기존 계약을 유지해 정책과 무관하게 data나 where의 미지 키에서 예외를 던집니다. create(), merge(), preload()는 영속화하지 않으므로 키를 인스턴스에 그대로 두고, 뒤따르는 쓰기가 보고합니다.


RETURNING 결과는 프로퍼티 이름으로 돌아와요 ​

RETURNING을 지원하는 드라이버(PostgreSQL, MariaDB 10.5+)에서 save()의 반환 엔티티는 RETURNING * 행으로 만들어져요. 이제 이 행이 ResultTransformer를 거치면서 DB 컬럼명이 엔티티 프로퍼티 키로 역매핑돼요 -- @Column({ name })과 SnakeNamingStrategy 같은 NamingStrategy 매핑을 모두 포함해서요. 컬럼 트랜스포머의 from도 함께 적용돼요.

typescript
@Entity()
class Category {
  @PrimaryGeneratedColumn() id!: number;
  @Column({ name: "LFT_NO" }) left!: number;
}

const saved = await em.save(Category, { left: 1 });
saved.left;             // 1 -- 클래스에 선언한 프로퍼티 키 그대로
(saved as any).LFT_NO;  // undefined -- raw DB 키가 더 이상 새어 나오지 않음

이전에는 반환 객체가 raw DB 키(예: left 대신 LFT_NO)를 그대로 노출했어요. 이 매핑은 INSERT RETURNING, UPDATE RETURNING, saveMany() 배치 RETURNING에 모두 적용돼요.


배치 삭제 -- deleteMany() ​

왜 delete()를 여러 번 호출하는 대신 deleteMany()를 쓸까요? ​

insertMany()가 save() 루프보다 빠른 것과 같은 이유예요. SQL 문 하나가 여러 개보다 나아요. deleteMany()는 PK 값 배열을 받아서 단일 DELETE ... WHERE id IN (...) 문을 생성해요.

typescript
const result = await em.deleteMany(User, [1, 2, 3]);
console.log(result.affected); // 3
sql
-- 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()를 사용하세요:

typescript
await em.delete(User, { isActive: false });

delete() / softDelete() / restore()의 연산자 조건 ​

criteria 객체는 동등 비교에 묶여 있지 않아요. delete(), softDelete(), restore()는 조회의 where와 똑같은 find 스타일 연산자 객체 -- { between: [a, b] }, { gt }, { gte }, { lt }, { lte }, { in }, { like } 등 -- 를 받고, null은 IS NULL로 해석돼요. 조회 경로와 동일한 WhereResolver가 처리해요 (updateMany()는 원래부터 지원했어요).

typescript
// 중첩 집합(nested set) 서브트리 전체를 문장 하나로 삭제
await em.delete(Category, { lft: { between: [node.lft, node.rgt] } });

// 오래된 초안 소프트 삭제
await em.softDelete(Post, { status: "draft", updatedAt: { lt: cutoff } });

// null -> IS NULL
await em.delete(Session, { userId: null });

빈 criteria는 여전히 DeleteWithoutConditionsError를 던져요 -- 테이블 전체 삭제를 막는 가드는 그대로예요.

벌크 criteria의 논리 결합자 -- AND / OR / NOT ​

delete(), softDelete(), restore()의 criteria와 updateMany() / update()의 where는 조회의 where와 똑같이 AND / OR / NOT 키를 받습니다. 중첩 깊이에도 제한이 없어요. 쓰기 앞단의 식별자 검사 역시 조회 경로와 같은 방식으로 결합자 안쪽까지 순회하므로, OR 분기 안의 오타는 SQL을 실행하기 전에 같은 Did you mean 제안과 함께 잡힙니다.

typescript
// 보관됐거나 오래된 글을 한 문장으로 삭제
await em.delete(Post, {
  OR: [{ status: "archived" }, { updatedAt: { lt: cutoff } }],
});

// 담당자가 있는 열린 티켓만 닫기
await em.updateMany(Ticket, { status: "closed" }, {
  where: {
    AND: [{ status: "open" }, { NOT: { assigneeId: null } }],
  },
});

// 일반 컬럼 옆에 놓인 결합자는 find()와 마찬가지로 AND로 묶입니다
await em.softDelete(Draft, { authorId: 7, OR: [{ title: null }, { body: "" }] });

criteria가 아무 조건도 만들지 못하면 빈 criteria와 똑같이 취급해 DeleteWithoutConditionsError를 던집니다. 테이블 전체를 건드리는 문장으로 넘어가지 않아요. { OR: [] }, { AND: [] }, 값이 전부 undefined인 criteria({ status: undefined }), 안쪽이 아무 조건도 남기지 못하는 NOT이 여기에 해당합니다. updateMany()도 같은 규칙을 따르고, SET 페이로드까지 비어 있는 경우도 포함합니다. 예전에는 그 조합이 criteria를 문제 삼지 않고 { affected: 0 }을 돌려줬어요.

OR 분기(또는 배열 형태의 원소)가 아무 조건도 만들지 못하면 다른 에러가 납니다. 필터가 없어지는 게 아니라 문장이 넓어지기 때문입니다. 빈 분기는 TRUE라서 { OR: [{ id: undefined }, { id: 2 }] }는 모든 행에 매칭됩니다. 이때는 문제가 된 분기를 지목하며 InvalidQueryError를 던집니다. AND 안에서는 빈 분기가 항등원이라 건너뛰고 나머지 조건은 그대로 적용해요. where나 criteria 객체에서 undefined를 다루는 전체 규칙은 undefined 값에 정리돼 있습니다.

criteria 검사는 이제 쓰기가 하는 다른 모든 일보다 먼저 실행됩니다. 조건이 하나도 남지 않는 criteria로 delete()를 호출하면 beforeDelete가 발생하지 않고, one-to-many 캐스케이드가 부모를 조회해 자식을 먼저 지우는 일도 없습니다. 예전에는 트랜잭션이 그 행들을 되돌리긴 했지만 리스너는 이미 실행된 뒤였어요. softDelete()와 restore()도 각각 beforeSoftDelete / beforeRestore 이벤트 앞에서 같은 방식으로 막습니다.


조건부 수정 -- update() ​

update()가 필요한 이유 ​

updateMany()는 강력하지만, "필터에 맞는 행을 바꾼다"는 가장 흔한 경우엔 장황해요. 필터를 옵션 객체({ where: ... }) 안에 넣어야 해서, 필터가 그냥 두 번째 인자인 delete(entity, criteria)와 모양이 달라요. update()는 그 간극을 메우는 필터 우선 단축형이에요:

typescript
// update(entity, where, data) -- 필터가 delete()처럼 두 번째 인자
await em.update(User, { id: 1 }, { name: "Alice" });

// raw SQL 표현식도 그대로 SET 절로 전달돼요
await em.update(Post, { id: 1 }, { viewCount: sql`view_count + 1` });

내부적으로 updateMany()에 위임하므로 모든 안전장치를 그대로 상속해요: 빈 WHERE 가드(테이블 전체 수정 거부), 테넌트 스코핑, @UpdateTimestamp 자동 주입, NamingStrategy 컬럼 매핑, Sql 표현식 지원까지요. 반환값도 동일하게 { affected }예요.

정렬·개수 제한 수정(orderBy + limit)이 필요할 때만 updateMany()를 직접 쓰면 돼요. 그 외에는 update()가 더 짧고 일관된 호출이에요. 리포지토리에서도 repo.update(where, data)로 쓸 수 있어요.


일괄 수정 -- updateMany() ​

updateMany()가 필요한 이유 ​

동일한 변경을 수천 행에 적용해야 할 때가 있어요 -- 만료된 계정 비활성화, 카테고리 가격 일괄 인상, 플래그 초기화 같은 경우요. save()로 하면 각 행을 조회하고, 수정하고, 다시 저장해야 해요. updateMany()는 단일 UPDATE ... SET ... WHERE ...로 한 번에 처리해요.

typescript
const result = await em.updateMany(User,
  { isActive: false },            // SET -- 적용할 데이터
  { where: { lastLoginAt: null } }, // WHERE -- 매칭 조건
);
console.log(result.affected); // number of updated rows
sql
-- PostgreSQL
UPDATE "user"
SET "isActive" = $1
WHERE "lastLoginAt" IS NULL
-- Parameters: [false]

-- MySQL
UPDATE `user`
SET `isActive` = ?
WHERE `lastLoginAt` IS NULL
-- Parameters: [false]

where는 조회의 where가 받는 것을 전부 받습니다. AND / OR / NOT도 포함돼요(위의 벌크 criteria의 논리 결합자 참고). 반면 data 인자는 SET 절이라 컬럼과 값의 대응만 담습니다. 여기에 결합자 키가 들어오면 컬럼으로 오해하는 대신 Logical combinator "OR" is not allowed in the update data로 거절하니, where로 옮기면 됩니다.

좀 더 현실적인 예시 -- 최근 로그인하지 않은 유저 비활성화:

typescript
const result = await em.updateMany(User,
  { isActive: false, deactivatedAt: new Date() },
  { where: { isActive: true } },
);
console.log(`Deactivated ${result.affected} users`);
sql
-- 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 컬럼을 자동 주입해요.

soft-delete된 행은 기본적으로 제외됩니다 ​

엔티티에 @DeletedAt 컬럼이 있으면 updateMany()는 (그리고 여기에 위임하는 update() / increment() / decrement()도) 살아 있는 행만 건드립니다. find()와 똑같이 WHERE에 "deletedAt" IS NULL을 덧붙이므로, 일괄 수정이 논리적으로 삭제된 행의 데이터를 몰래 되살리는 일이 없어요.

trash된 행까지 포함하려면 withDeleted: true를 넘깁니다.

typescript
// 기본: trash된 행은 그대로 둡니다
await em.updateMany(User, { plan: "free" }, { where: { plan: "pro" } });

// 명시적 opt-in: soft-delete된 행도 수정합니다
await em.updateMany(
  User,
  { plan: "free" },
  { where: { plan: "pro" }, withDeleted: true },
);
sql
-- 기본 -- PostgreSQL
UPDATE "user" SET "plan" = $1 WHERE "plan" = $2 AND "deletedAt" IS NULL

-- withDeleted: true -- soft-delete 술어가 빠집니다
UPDATE "user" SET "plan" = $1 WHERE "plan" = $2

단일 테이블 상속(STI) 자식 클래스라면 updateMany()가 discriminator 술어까지 덧붙이므로, updateMany(CreditCardPayment, …)가 같은 테이블을 공유하는 형제 타입을 건드리지 않아요 -- find()·delete()가 따르는 규칙 그대로입니다.

updateMany에서 SQL 표현식 사용 ​

가끔 계산된 업데이트가 필요할 때가 있어요 -- 카운터 증가, 문자열 추가, 데이터베이스 함수 사용 등. updateMany는 sql-template-tag를 통해 raw SQL 표현식을 컬럼 값으로 받을 수 있어요:

typescript
import sql from "sql-template-tag";

// 조회수 증가
await em.updateMany(Post,
  { viewCount: sql`"viewCount" + 1` },
  { where: { id: 1 } },
);
sql
-- PostgreSQL
UPDATE "post"
SET "viewCount" = "viewCount" + 1
WHERE "id" = $1
-- Parameters: [1]

리터럴 값과 SQL 표현식을 같은 업데이트에서 혼합할 수도 있어요:

typescript
await em.updateMany(Product,
  {
    price: sql`"price" * 1.1`,         // 10% 가격 인상
    lastUpdatedBy: "admin",             // 리터럴 값
  },
  { where: { category: "electronics" } },
);

주요 특징:

  • @UpdateTimestamp 컬럼이 SET 절에 자동 주입돼요.
  • 빈 WHERE 조건은 DeleteWithoutConditionsError를 던져요 (안전 장치).
  • save()와 달리 엔티티 라이프사이클 훅이나 이벤트가 발생하지 않아요 -- raw 일괄 연산이에요.

DANGER

파라미터 순서가 (Entity, setData, { where }) 예요 -- (Entity, where, setData)가 아니에요. 설정할 데이터가 먼저 와요.


원자적 증감 -- increment() / decrement() ​

왜 필요할까요? ​

update()와 updateMany() 모두 raw SQL 표현식으로 카운터를 증가시킬 수 있어요:

typescript
import sql from "sql-template-tag";
await em.update(Post, { id: 1 }, { viewCount: sql`view_count + 1` });

동작은 하지만 sql-template-tag를 임포트하고, 실제 DB 컬럼명을 알아야 하고, 올바른 SQL 표현식을 직접 써야 해요. increment()는 안전한 단축형이에요: 엔티티 속성명을 올바르게 이스케이프된 컬럼으로 변환하고, by를 파라미터로 바인딩하며(문자열 연결 없음), update()에 위임하므로 모든 안전장치를 그대로 가져와요.

사용법 ​

typescript
// viewCount에 1 더하기 (by 기본값 1)
await em.increment(Post, { id: 1 }, "viewCount");

// balance에 50 더하기
await em.increment(Wallet, { userId: 7 }, "balance", 50);

// stock에서 1 빼기
await em.decrement(Product, { id: 9 }, "stock");

// balance에서 100 빼기
await em.decrement(Wallet, { userId: 7 }, "balance", 100);

각 호출은 단일 UPDATE 문을 발행해요:

sql
-- PostgreSQL (viewCount += 1)
UPDATE "post"
SET "viewCount" = "viewCount" + $1, "updatedAt" = $2, "version" = "version" + 1
WHERE "id" = $3
-- Parameters: [1, '2026-06-13 10:00:00', 1]

-- MySQL (stock -= 1)
UPDATE `product`
SET `stock` = `stock` - ?, `updatedAt` = ?
WHERE `id` = ?
-- Parameters: [1, '2026-06-13 10:00:00', 9]

시그니처 ​

typescript
// EntityManager
em.increment<T>(entity: Class<T>, where: WhereClause<T>, column: keyof T & string, by?: number): Promise<{ affected: number }>
em.decrement<T>(entity: Class<T>, where: WhereClause<T>, column: keyof T & string, by?: number): Promise<{ affected: number }>

// BaseRepository (엔티티가 이미 바인딩됨 -- 첫 인자 없음)
repo.increment(where: WhereClause<T>, column: keyof T & string, by?: number): Promise<{ affected: number }>
repo.decrement(where: WhereClause<T>, column: keyof T & string, by?: number): Promise<{ affected: number }>

동작 방식 ​

  • 원자적 -- 델타가 데이터베이스에서 SET col = col + ? 형태로 적용돼요. 두 개의 동시 호출도 올바른 합산 결과를 냅니다. 읽기-수정-쓰기 레이스 컨디션이 없어요.
  • update()에 위임 -- 빈 WHERE 가드(where가 비어 있으면 DeleteWithoutConditionsError 발생), 테넌트 스코핑, NamingStrategy 컬럼 매핑, @UpdateTimestamp 자동 주입을 모두 상속해요. 엔티티에 @Version 낙관적 잠금 컬럼이 있으면 같은 문에서 함께 증가시켜요.
  • by 기본값은 1 -- 0, NaN, Infinity 또는 유한하지 않은 숫자를 전달하면 InvalidQueryError를 던져요.
  • { affected } 반환 -- 데이터베이스 드라이버가 보고한 수정 행 수예요.

리포지토리 단축형 ​

typescript
const postRepo = em.getRepository(Post);

// em.increment(Post, { id: 1 }, "viewCount")와 동일
await postRepo.increment({ id: 1 }, "viewCount");

const productRepo = em.getRepository(Product);
// em.decrement(Product, { id: 9 }, "stock")와 동일
await productRepo.decrement({ id: 9 }, "stock");

Upsert -- 삽입 또는 수정 ​

왜 upsert가 필요할까요? ​

"로그인 추적" 기능을 생각해 보세요: 유저가 로그인할 때마다 레코드를 새로 만들거나 기존 레코드를 업데이트하고 싶어요. upsert 없이는 이렇게 해야 해요:

  1. findOne() -- 레코드 존재 여부 확인
  2. 있으면: PK와 함께 save()로 UPDATE
  3. 없으면: PK 없이 save()로 INSERT

왕복 3번에 레이스 컨디션까지 있어요 (두 요청이 동시에 "없음"을 확인하고 둘 다 INSERT를 시도하면 중복 키 에러가 나요). upsert는 단일 원자적 문으로 두 문제를 모두 해결해요.

PK 기준 ​

typescript
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
sql
-- 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)예요. 둘 다 "삽입을 시도하되, 지정된 컬럼에서 충돌이 나면 기존 행을 대신 업데이트하라"는 뜻이에요.

유니크 컬럼 기준 ​

세 번째 인자로 컬럼 이름 배열을 전달해서 충돌 대상을 지정할 수 있어요:

typescript
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
sql
-- 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 에러가 발생해요.

배치 Upsert -- batchUpsert() ​

수백 또는 수천 행을 한 번에 upsert해야 할 때 batchUpsert()가 upsert()를 루프로 호출하는 것보다 훨씬 빨라요. 모든 행을 하나의 다중 행 INSERT ... ON CONFLICT 문으로 묶어서 보내요.

typescript
await em.batchUpsert(User, [
  { email: "alice@example.com", name: "Alice", loginCount: 1 },
  { email: "bob@example.com", name: "Bob", loginCount: 1 },
  { email: "charlie@example.com", name: "Charlie", loginCount: 1 },
], ["email"]);
sql
-- PostgreSQL
INSERT INTO "user" ("email", "name", "loginCount")
VALUES ($1, $2, $3), ($4, $5, $6), ($7, $8, $9)
ON CONFLICT ("email") DO UPDATE SET "name" = EXCLUDED."name", "loginCount" = EXCLUDED."loginCount"

-- MySQL
INSERT INTO `user` (`email`, `name`, `loginCount`)
VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `loginCount` = VALUES(`loginCount`)

세 번째 인자(옵션)로 충돌 컬럼을 지정해요. 생략하면 기본 키가 사용돼요.

충돌 시 관리 컬럼 ​

upsert(), insertIgnore(), batchUpsert()는 ORM이 관리하는 컬럼을 페이로드의 복사본에 채웁니다. 넘긴 객체는 테넌트 컬럼까지 포함해 바뀌지 않으므로, 생성된 키·버전·타임스탬프가 필요하면 행을 다시 읽으세요. 충돌 분기에서 기본 키와 관리 컬럼은 페이로드의 값을 받지 않습니다.

컬럼새 행 삽입충돌한 행
기본 키넘긴 값. "uuid" / "uuid-v7" 키는 생성하고, auto-increment 키는 DB가 부여쓰지 않음 — 저장된 키가 유지됨
그 밖의 "uuid" / "uuid-v7" 컬럼넘긴 값, 없으면 생성페이로드에 값이 있을 때만 씀
@Version넘긴 값, 없으면 1저장된 값 + 1 (저장된 NULL은 0으로 셈)
@CreateTimestamp넘긴 값, 없으면 현재 시각쓰지 않음
@UpdateTimestamp넘긴 값, 없으면 현재 시각삽입 행의 값 — 직접 넘기지 않았다면 현재 시각. 키와 updatedAt만 넘겨도 갱신됨
@DeletedAt넘긴 값, 없으면 NULL페이로드에 없으면 NULL — soft-delete된 행이 복구됨
테넌트 컬럼(tenant_column)현재 테넌트쓰지 않음

@Version, @UpdateTimestamp, @DeletedAt이 있는 Order라면 이렇게 됩니다.

typescript
await em.upsert(Order, { slug: "a-1", amount: 42 }, ["slug"]);
sql
-- PostgreSQL (SQLite는 소문자 `excluded`만 다르고 같습니다)
INSERT INTO "order" ("slug", "amount", "version", "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT ("slug") DO UPDATE SET "amount" = EXCLUDED."amount",
  "updatedAt" = EXCLUDED."updatedAt",
  "version" = COALESCE("order"."version", 0) + 1,
  "deletedAt" = NULL

-- MySQL / MariaDB
INSERT INTO `order` (`slug`, `amount`, `version`, `createdAt`, `updatedAt`)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE `amount` = VALUES(`amount`),
  `updatedAt` = VALUES(`updatedAt`),
  `version` = COALESCE(`order`.`version`, 0) + 1,
  `deletedAt` = NULL

알아 둘 점은 다음과 같습니다.

  • 버전은 올리기만 하고 검사하지 않습니다. upsert는 마지막에 쓴 값이 이기는 연산이라 OptimisticLockError를 던지지 않아요. 대신 upsert 이전 버전을 쥔 save()가 나중에 거절되도록 보장합니다. 키만 넘긴 upsert가 soft-delete된 행을 복구하기만 할 때는 restore()처럼 버전을 유지합니다. upsert() / batchUpsert() 페이로드에 넣은 @Version 값은 새 행을 삽입할 때만 쓰이고 충돌 시에는 무시되며, 엔티티 클래스당 한 번 경고가 남습니다. 오래된 쓰기를 거절해야 한다면 save()를 쓰세요.
  • 관리 컬럼만으로는 갱신이 일어나지 않습니다. 충돌 대상 말고는 갱신할 사용자 컬럼이 없으면, 문장이 PostgreSQL·SQLite에서는 DO NOTHING, MySQL/MariaDB에서는 아무것도 바꾸지 않는 ON DUPLICATE KEY UPDATE <col> = <col>로 내려갑니다. 없는 행은 삽입되고, 살아 있는 충돌 행은 버전·타임스탬프까지 그대로 남아요. 예외는 soft-delete된 충돌 행 하나뿐이고, 이 행은 여전히 복구됩니다(DO UPDATE SET "deletedAt" = NULL WHERE "order"."deletedAt" IS NOT NULL). 2.1 이전에는 이런 호출이 SQL을 아예 보내지 않고 { affected: 0 }을 돌려줘서, 없는 행조차 삽입되지 않았습니다.
  • soft delete와 유니크 키. 일반 유니크 인덱스라면 삭제된 행도 충돌하고, upsert가 넘긴 값으로 그 행을 복구합니다. soft-delete된 행을 건너뛰는 updateMany()와는 다릅니다. 새 행을 만들고 싶다면 부분 유니크 인덱스(WHERE "deletedAt" IS NULL, PostgreSQL·SQLite)를 두고 createInsertBuilder().onConflict(cols, { where })로 지정하세요. upsert()는 부분 인덱스를 지정할 수 없습니다.
  • INSERT는 항상 시도됩니다. DB는 충돌을 찾기 전에 NOT NULL을 먼저 검사하므로, 행이 이미 있어도 기본값 없는 NOT NULL 컬럼은 전부 페이로드에 있어야 합니다. 컬럼도 관계도 하나 없는 페이로드는 아무것도 보내지 않고 { affected: 0 }을 돌려줍니다.
  • 관계는 외래 키로 기록됩니다. @ManyToOne과 @OneToOne 소유자 측은 save(), insertMany()와 마찬가지로 연관 인스턴스, 키 값 그대로, ${property}Id 섀도 프로퍼티 중 어느 형태로 넘겨도 됩니다. 키는 INSERT에 들어가고, 충돌 대상에 속하지 않으면 다른 컬럼처럼 충돌 시에도 갱신됩니다. 페이로드에 없는 관계는 저장된 키를 그대로 둡니다. 충돌 대상은 DB 컬럼명을 받으므로 외래 키 쌍에 걸린 유니크 인덱스는 조인 컬럼으로 지정합니다: em.upsert(Like, { user, post, weight: 3 }, ["user_id", "post_id"]). 캐스케이드는 없으니 연관 인스턴스에는 기본 키가 이미 있어야 합니다. 2.1 이전에는 키가 버려져 NULL로 저장됐고, 충돌 대상이 외래 키이면 호출할 때마다 행이 하나씩 더 쌓였어요.
  • JOINED 자식은 거부됩니다. upsert(), insertIgnore(), batchUpsert(), insertMany(), insertManyAndReturn(), createInsertBuilder()는 테이블 하나에만 쓰는데, 테이블별 상속(TPT)의 자식은 두 테이블에 걸쳐 있으므로 UNSUPPORTED_OPERATION을 던집니다. save()를 쓰세요(saveMany()는 이런 자식에서 save()로 넘어갑니다).
  • **insertIgnore()**도 삽입하는 행에는 같은 값을 채우고, 충돌한 행은 soft-delete 여부와 상관없이 절대 쓰지 않습니다.
  • **createInsertBuilder()**도 삽입 행은 같은 방식으로 채우지만, doUpdate()는 나열한 컬럼만 대입합니다. 버전 증가, 타임스탬프 갱신, @DeletedAt 초기화는 붙지 않아요.

반환값 — { affected: number } ​

upsert()와 batchUpsert() 모두 Promise<{ affected: number }>를 반환합니다.

typescript
const result = await em.upsert(User, { id: 1, name: "Alice" });
console.log(result.affected); // MySQL: INSERT면 1, UPDATE면 2 / PostgreSQL·SQLite: 1

affected 값은 드라이버 원본 그대로 반환됩니다 — 정규화 없음.

드라이버INSERTUPDATE변경 없음충돌 행 건너뜀
MySQL1211
PostgreSQL1110
SQLite1110

MySQL은 ON DUPLICATE KEY UPDATE의 affectedRows를 씁니다. INSERT는 1, UPDATE는 2로 세고, 값이 그대로인 기존 행은 MySQL 매뉴얼상의 0이 아니라 1로 잡혀요 — mysql2가 CLIENT_FOUND_ROWS(변경된 행이 아니라 매칭된 행)로 접속하기 때문입니다. @Version이 있는 엔티티에는 '변경 없음'이 없습니다. 충돌 분기가 버전을 올리므로, 넘긴 값이 저장된 행과 전부 같아도 MySQL은 2를 보고해요. PostgreSQL과 SQLite는 쓴 행마다 1, 건너뛴 충돌 행은 0을 반환합니다. 충돌 행을 건너뛰는 경우는 갱신할 사용자 컬럼이 남지 않았을 때(충돌 시 관리 컬럼 참고)와, tenant_column 전략에서 다른 테넌트가 그 행을 소유할 때입니다.

batchUpsert()는 items 배열이 비어 있으면 { affected: 0 }을 반환합니다.

리포지토리에서는 userRepo.batchUpsert(items, conflictColumns)로 동일하게 사용할 수 있어요.

tenantStrategy: "tenant_column"일 때 ​

충돌 분기는 현재 테넌트가 소유한 행만 씁니다. PostgreSQL과 SQLite는 predicate가 DO UPDATE ... WHERE로 붙어요.

sql
INSERT INTO "user" ("email", "name", "tenant_id") VALUES ($1, $2, $3)
ON CONFLICT ("email") DO UPDATE SET "name" = EXCLUDED."name"
WHERE "user"."tenant_id" = $4

MySQL/MariaDB의 ON DUPLICATE KEY UPDATE에는 WHERE가 없어서, 대입마다 가드를 답니다.

sql
INSERT INTO `user` (`email`, `name`, `tenant_id`) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `name` = IF(`user`.`tenant_id` = ?, VALUES(`name`), `user`.`name`)

알아 두면 좋은 결과는 세 가지입니다.

  • 테넌트 컬럼 자체가 갱신 목록에 들어가지 않으므로, 충돌한 행의 소유자가 바뀔 일이 없습니다.
  • 다른 테넌트 소유라서 건너뛴 행은 PostgreSQL·SQLite에서 affected에 잡히지 않고, 엔티티 클래스당 한 번 경고가 남습니다. MySQL/MariaDB는 같은 경우를 1로 보고하는데(mysql2가 CLIENT_FOUND_ROWS로 접속해서 매칭만 돼도 세거든요), INSERT가 보고하는 숫자와 같습니다. 확실히 알아야 하면 행을 다시 읽어 보세요.
  • 갱신할 사용자 컬럼이 테넌트 컬럼밖에 남지 않으면, 갱신할 것이 없는 다른 upsert와 똑같이 문장이 내려갑니다. PostgreSQL·SQLite는 DO NOTHING, MySQL/MariaDB는 아무것도 바꾸지 않는 ON DUPLICATE KEY UPDATE가 돼요. INSERT는 그대로 수행되고, 충돌한 행은 건드리지 않습니다.
  • 관리 컬럼 대입도 같은 가드 아래에 있습니다. MySQL/MariaDB의 버전 증가는 `version` = IF(`user`.`tenant_id` = ?, COALESCE(`user`.`version`, 0) + 1, `user`.`version`)이므로, 다른 테넌트의 행은 버전이 올라가거나 복구되지 않습니다.

insertIgnore()에는 가드가 필요 없습니다 — 기존 행을 쓰는 일이 없으니까요. 충돌한 키를 다른 테넌트가 소유하고 있으면, 버려지는 쪽은 이번 INSERT입니다.


표현식 기반 Upsert — createInsertBuilder() ​

upsert()의 한계 ​

upsert()와 batchUpsert()가 넘긴 컬럼에 대해 충돌 시 할 수 있는 일은 하나뿐입니다. 저장된 값을 제안한 값으로 덮어쓰는 것, 즉 col = EXCLUDED.col이에요. "마지막에 쓴 값이 이긴다"면 이걸로 충분합니다. 저장된 행을 읽어 계산하는 건 ORM이 직접 챙기는 관리 컬럼(@Version 증가, 충돌 시 관리 컬럼 참고)뿐이고, 사용자 컬럼에는 쓸 수 없습니다.

문제는 새 값이 저장된 값에 의존할 때입니다.

records   = 저장된 records + 제안한 records          -- 누적
last_time = MAX(저장된 last_time, 제안한 last_time)   -- 최댓값 유지

떠오르는 우회는 find()로 읽고 TypeScript에서 계산한 뒤 save()로 되쓰는 것인데, 여기엔 경쟁 조건이 있습니다. 동시에 두 요청이 records = 10을 읽고 둘 다 12를 쓰면 증가분 하나가 사라져요. 이걸 올바르게 만들려면 SELECT … FOR UPDATE로 행 잠금을 잡아야 합니다. 반면 문장 하나로 처리하면 잠금이 아예 필요 없습니다. 데이터베이스가 행을 쥔 채로 식을 평가하니까요.

createInsertBuilder()가 바로 그 문장입니다.

카운터 누적 ​

typescript
import { greatest, sql } from "@stingerloom/orm";

await em.createInsertBuilder(SyncMarker)
  .values(buckets)
  .onConflict(["mac", "bucketStart"])
  .doUpdate((t, ex) => ({
    records:  t.records.add(ex.records),
    lastTime: greatest(t.lastTime, ex.lastTime),
    syncedAt: sql`NOW()`,
  }))
  .execute();

doUpdate()의 콜백은 참조 두 개를 받습니다.

  • t — 이미 저장되어 있는 행. 테이블명으로 한정해 렌더링됩니다("sync_markers"."records"). PostgreSQL에서는 이게 필수입니다. DO UPDATE SET과 그 WHERE 안에서는 대상 테이블과 EXCLUDED가 모두 스코프에 있어서, 한정하지 않은 컬럼명은 모호하다고 거부되거든요. MySQL과 SQLite도 같은 표기를 받아들입니다.
  • ex — 이 INSERT가 제안한 행. EXCLUDED."col"(PostgreSQL) · excluded."col"(SQLite) · VALUES(`col`)(MySQL)로 렌더링됩니다.

둘 다 평범한 qAlias 참조라서 표현식 전체가 그대로 조합됩니다. .add(), .mul(), coalesce(), greatest(), CASE, JSON 경로까지요.

sql
-- PostgreSQL
INSERT INTO "sync_markers" ("mac", "bucket_start", "records", "last_time", "synced_at")
VALUES ($1, $2, $3, $4, $5), ($6, $7, $8, $9, $10)
ON CONFLICT ("mac", "bucket_start") DO UPDATE
   SET "records"   = ("sync_markers"."records" + EXCLUDED."records"),
       "last_time" = GREATEST("sync_markers"."last_time", EXCLUDED."last_time"),
       "synced_at" = NOW()

VALUES 리스트 만들기 ​

values()는 행 하나도, 배열도 받습니다. 여러 번 호출하면 누적되므로 루프에서 행을 모아 넣기에 편합니다.

typescript
const builder = em.createInsertBuilder(SyncMarker);
for (const batch of batches) builder.values(batch.rows);

셀에는 원시 sql 프래그먼트도 넣을 수 있습니다. 바인딩되는 대신 쓴 그대로 튜플에 삽입되어 데이터베이스가 평가합니다. NOW()나 시퀀스 호출 같은 것들이요.

typescript
builder.values({ mac, bucketStart, records, syncedAt: sql`NOW()` });
// VALUES ($1, $2, $3, NOW())

일반 값에는 평소처럼 컬럼 write 트랜스포머가 적용되고, 프래그먼트는 호출자 책임입니다.

doUpdate()의 세 가지 형태 ​

typescript
// 1. 나열한 컬럼을 제안한 값으로 덮어쓰기 — upsert()와 같은 형태지만
//    @Version / @UpdateTimestamp / @DeletedAt 처리는 없음
.doUpdate(["name", "email"])

// 2. 리터럴 값과 원시 SQL
.doUpdate({ status: "seen", seenAt: sql`NOW()` })

// 3. 두 행에 걸친 표현식
.doUpdate((t, ex) => ({ hits: t.hits.add(ex.hits) }))

2번의 리터럴 값에는 insertMany()의 값과 똑같이 컬럼 write 트랜스포머가 적용됩니다.

충돌 무시 — doNothing() ​

typescript
await em.createInsertBuilder(AuditLog)
  .values(entries)
  .onConflict(["requestId"])
  .doNothing()
  .execute();

MySQL에서는 INSERT IGNORE가 됩니다. 중복 키뿐 아니라 그 문장의 모든 에러를 경고로 격하시킨다는 점에 주의하세요. insertIgnore()가 이미 감수하고 있는 것과 같은 트레이드오프입니다.

갱신 대상 좁히기 — doUpdateWhere() ​

제안한 측정값이 실제로 더 최신일 때만 행을 전진시키고 싶다면. 조건에서 저장된 행과 제안한 행을 직접 비교할 수 있습니다. doUpdate()에서와 똑같이, 한정하지 않은 참조는 저장된 행을 읽고 qExcluded 참조는 제안한 행을 읽어요.

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

const m  = qAlias(Reading, "m");
const ex = qExcluded(Reading);

await em.createInsertBuilder(Reading)
  .values(rows)
  .onConflict(["sensorId"])
  .doUpdate((t, x) => ({ value: x.value, takenAt: x.takenAt }))
  .doUpdateWhere(m.takenAt.lt(ex.takenAt))
  .execute();
sql
-- PostgreSQL
… DO UPDATE SET "value" = EXCLUDED."value", "taken_at" = EXCLUDED."taken_at"
  WHERE "readings"."taken_at" < EXCLUDED."taken_at"

술어를 통과하지 못한 행은 그대로 남습니다. 그래서 오래된 배치를 다시 재생해도 데이터가 뒤로 가는 일이 없어요. 읽고-고치고-되쓰는 왕복 없이 쓰기가 멱등이 됩니다.

PostgreSQL과 SQLite 전용입니다. ON DUPLICATE KEY UPDATE에는 WHERE가 없으므로 MySQL에서는 술어를 조용히 버리는 대신 예외를 던집니다. MySQL에서 같은 의도를 표현하려면 조건을 iff()로 각 대입 값 안에 접어 넣으세요. CASE 식으로 렌더링되어 세 다이얼렉트 모두에서 동작합니다.

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

.doUpdate((t, x) => ({
  value:   iff(x.takenAt.gt(t.takenAt), x.value, t.value),
  takenAt: iff(x.takenAt.gt(t.takenAt), x.takenAt, t.takenAt),
}))
// "value" = CASE WHEN EXCLUDED."taken_at" > "taken_at" THEN EXCLUDED."value" ELSE "value" END, …

가드가 거짓이면 모든 컬럼이 저장된 값으로 되돌아가므로 결과는 같습니다. 대신 조건을 컬럼마다 반복해야 하는 비용이 있어요.

부분 유니크 인덱스와 제약 이름 ​

typescript
// ON CONFLICT ("email") WHERE "deleted_at" IS NULL DO UPDATE …
.onConflict(["email"], { where: u.deletedAt.isNull() })

// ON CONFLICT ON CONSTRAINT "user_email_key" DO UPDATE …   (PostgreSQL 전용)
.onConflictConstraint("user_email_key")

{ where }는 어떤 인덱스가 충돌을 판정할지를 좁힙니다(유니크 인덱스 자체가 부분 인덱스일 때 필요해요). doUpdateWhere()는 충돌한 행 중 무엇을 갱신할지를 좁히고요. 서로 다른 절이라 함께 쓸 수 있습니다.

SQL 확인 ​

build()는 Sql 조각을, toSql()은 텍스트와 바인딩 값을 실행 없이 돌려줍니다.

typescript
const { text, values } = em.createInsertBuilder(SyncMarker)
  .values(rows)
  .onConflict(["mac", "bucketStart"])
  .doUpdate((t, ex) => ({ records: t.records.add(ex.records) }))
  .toSql();

테넌트 스코프는 실행 시점에 적용되므로 build() 결과에는 나타나지 않습니다. 테넌트 컬럼 채움은 물론이고, tenant_column 전략에서 doUpdate()가 다른 테넌트의 행을 건드리지 못하게 막는 가드(직접 지정한 doUpdateWhere() 조건과 AND로 결합)도 마찬가지예요.

동작 참고 ​

  • createUpdateBuilder()와 마찬가지로 문장 단위 API입니다. beforeInsert / afterInsert 이벤트도, 엔티티 훅도 발화하지 않아요. 테넌트 컬럼과 @CreateTimestamp / @UpdateTimestamp / @Version 기본값, 생성 UUID 키, 컬럼 트랜스포머는 삽입되는 행에 insertMany()와 똑같이 적용됩니다. 충돌 절은 직접 나열한 컬럼만 대입합니다.
  • 한 문장 안의 중복 키는 알아서 합쳐 주지 않습니다. PostgreSQL은 같은 충돌 대상을 두 번 건드리는 VALUES 목록을 거부하고(ON CONFLICT DO UPDATE command cannot affect row a second time), SQLite는 행을 순차 적용해서 누적이 겹칩니다. 문장을 만들기 전에 호출 측에서 합쳐 주세요.
  • affected는 드라이버가 보고한 값 그대로입니다. upsert()와 동일한 MySQL 1 대 2 주의사항이 그대로 적용돼요.
  • 리포지토리에서는 markerRepo.createInsertBuilder()로 씁니다.

드라이버 지원 ​

기능PostgreSQLMySQL / MariaDBSQLite
doUpdate() 표현식지원지원지원
excluded 참조EXCLUDED.colVALUES(col)excluded.col
onConflict([...]) 컬럼지원받되 생성하지 않음지원
onConflict(..., { where })지원예외지원
onConflictConstraint()지원예외예외
doNothing()DO NOTHINGINSERT IGNOREDO NOTHING
doUpdateWhere()지원예외지원

MySQL은 모든 유니크 키를 한꺼번에 대상으로 삼기 때문에 지정할 충돌 대상 자체가 없습니다. 그래도 .onConflict([...])를 붙여 두는 편이 좋아요. 같은 호출을 PostgreSQL로 그대로 옮길 수 있으니까요.


트랜잭션 ​

왜 트랜잭션이 필요할까요? ​

EntityManager의 개별 연산(save, find, delete 등)은 자동으로 각각의 트랜잭션으로 감싸져요. 하지만 두 연산이 반드시 함께 성공하거나 실패해야 할 때는 어떻게 할까요?

이커머스 결제를 생각해 보세요: 주문을 생성하고 재고를 차감해요. 주문 생성은 성공했는데 재고 차감이 실패하면, "아직 구매 가능"한 상품에 대한 주문이 남게 돼요 -- 데이터 불일치예요. 트랜잭션은 연산을 원자적 단위로 묶어서 해결해요: 모두 커밋되거나, 모두 롤백돼요.

콜백 API -- transaction() ​

트랜잭션을 사용하는 가장 간단한 방법이에요. 콜백이 this EntityManager를 받고, 내부의 모든 연산이 같은 트랜잭션을 공유해요.

typescript
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 타임라인이에요:

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:
ROLLBACK

BEGIN과 COMMIT/ROLLBACK은 자동으로 처리돼요. 직접 작성할 필요 없어요.

에러 발생 시 동작 ​

콜백 내부에서 예외가 발생하면, ORM이:

  1. 예외를 잡아요
  2. ROLLBACK을 실행해서 트랜잭션 내 모든 변경을 되돌려요
  3. 원래 예외를 다시 던져서 애플리케이션 코드에서 처리할 수 있게 해요

데이터베이스가 절반만 완료된 상태로 남는 일은 없어요. 모든 변경이 적용되거나, 아무것도 적용되지 않아요.

데드락 재시도 ​

동시성이 높은 시나리오(여러 유저가 같은 상품을 구매하는 경우 등)에서는 데드락이 발생할 수 있어요. 데드락은 두 트랜잭션이 서로 잠금 해제를 기다리면서 둘 다 진행하지 못하는 상태예요. 데이터베이스가 이를 감지하고 하나를 종료해요.

transaction() 메서드는 자동 재시도를 지원해요:

typescript
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 사용 (권장) ​

typescript
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 템플릿 태그가 쿼리 텍스트와 파라미터 값을 자동으로 분리해요. 실제로 데이터베이스에 전송되는 내용은 이래요:

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에 대한 주요 방어 수단이에요.

문자열 + 파라미터 배열 사용 ​

typescript
const posts = await em.query<{ id: number; title: string }>(
  "SELECT id, title FROM post WHERE author_id = $1",
  [42]
);
sql
-- Query text
SELECT id, title FROM post WHERE author_id = $1
-- Parameters: [42]

WARNING

Raw SQL 문자열을 사용할 때는 반드시 파라미터 바인딩($1, ? 등)을 사용하세요. 유저 입력을 문자열에 직접 연결하면 안 돼요.

반환 타입 ​

query<T>()는 T[]를 반환해요. 제네릭 파라미터 T로 결과 행의 타입을 지정할 수 있어요:

typescript
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
`);
sql
-- 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를 참고하세요.


다음 단계 ​

Released under the MIT License.