Skip to content

2.0 업그레이드

1.x는 뭔가 잘못돼도 대체로 그냥 넘어갔습니다. 오타 난 컬럼 이름은 드라이버까지 흘러가 정체 모를 에러로 돌아왔고, 없는 행에 save()를 해도 성공했다고 답했고, 배치로 넣은 시각은 밀리초가 잘려 나갔습니다. 2.0은 이런 자리를 찾아 실패를 실패라고 말하게 바꾼 릴리스입니다.

그래서 업그레이드는 보통 버전만 올리면 끝납니다. 다만 그동안 문제를 덮고 있던 코드가 있었다면 이제 그 자리에서 멈춥니다. 어디가 멈출 수 있는지를 아래에 모아 뒀습니다.

bash
pnpm add @stingerloom/orm@2

올리기 전에 한 번

로깅을 켜고 테스트를 돌려 보세요. 2.0에서 바뀐 것은 거의 다 예외나 경고로 드러나기 때문에, 한 번만 돌려도 손볼 자리가 대부분 나옵니다.

typescript
await em.register({ /* ... */, logging: true });

빌드가 깨질 수 있는 곳

루트에서 가져오던 내부 심볼

1.x의 @stingerloom/orm은 모듈을 통째로 다시 내보냈습니다. 그 바람에 엔진 내부(SchemaRegistrar, CascadeHandler, EntityManagerInternals)와 표현식 렌더링 함수, 내부 타입 가드까지 673개가 루트에서 그냥 import됐습니다. 2.0부터는 공개할 것만 골라서 이름으로 내보냅니다.

import가 풀리지 않는다면 원래 공개 API가 아니던 심볼입니다. 대체 경로는 두지 않았으니, 꼭 필요한 쓰임새가 있다면 이슈로 알려 주세요. 진입점 자체는 그대로입니다 — @stingerloom/orm, /nestjs, /prisma-import, 그리고 다이얼렉트별 서브패스.

직접 만든 드라이버

ISqlDriverescapeIdentifier(name: string): string가 생겼습니다. 문서의 마이그레이션 예제는 예전부터 이 메서드를 부르고 있었는데 정작 구현이 어디에도 없어서, 예제를 그대로 복사하면 컴파일도 안 되고 런타임에서도 죽었습니다. 내장 드라이버 세 개에는 이미 들어갔고, 직접 만든 드라이버라면 이 정도로 채우면 됩니다.

typescript
escapeIdentifier(name: string): string {
  return `"${name.replace(/"/g, '""')}"`;
}

메타데이터를 손으로 만드는 코드

ColumnMetadata.nameEntityScannerMetadata.name이 선택에서 필수로 바뀌었습니다. 실제로는 늘 채워지던 값인데 타입만 느슨했던 자리라, 영향을 받는 건 이 객체를 직접 만드는 커스텀 스캐너나 스키마 도구 정도입니다.

예외가 새로 나는 곳

아래 상황은 지금까지 아무 말 없이 넘어갔고, 대신 잘못된 결과를 돌려줬습니다.

이런 코드1.x2.0
커넥션 entities에 없는 엔티티첫 SQL에서 드라이버 에러로 죽음진입점에서 EntityMetadataNotFoundError
where: { emial: "…" }오타를 그대로 드라이버에 넘겨 "no such column"어느 컬럼이 없는지, 비슷한 이름은 뭔지 알려 주는 InvalidQueryError
relations: ["autor"]쿼리는 성공하고 관계만 undefined해석 가능한 관계 목록을 담은 InvalidQueryError
PK가 어디에도 없는 save()성공했다고 답하고 afterUpdate까지 실행EntityNotFoundError
qb.where("status = 'open'")SQL 조각 전체를 컬럼 이름으로 취급InvalidQueryError
표현할 수 없는 diff의 migrate:generateup()이 TODO 주석뿐인 마이그레이션 생성예외를 던져서 죽은 마이그레이션이 커밋되지 않게 함

이 중 둘은 조금 더 설명이 필요합니다.

컬럼 이름 검증. 프로퍼티 이름, DB 컬럼 이름, @RelationColumn FK 섀도우, @ComputedColumn, 단일 테이블 상속의 형제 컬럼까지는 그대로 받습니다. 막히는 건 엔티티가 매핑하지 않은 물리 컬럼입니다. 예전에는 검증 없이 통과해서 우연히 동작하던 경로인데, 이제는 거부합니다. 그 컬럼으로 꼭 필터링해야 한다면 @Column으로 선언하거나 createQueryBuilder()/em.query()로 내려가면 됩니다.

없는 행에 save(). save()를 "있으면 수정, 없으면 삽입"으로 쓰고 있었다면 upsert()로 의도를 드러내세요. 행이 없을 수도 있다는 걸 정말 전제한 코드라면 exists()로 먼저 확인하는 편이 낫습니다.

같은 코드인데 결과가 달라지는 곳

take: 0은 이제 0건입니다

1.x에서는 0이 falsy 검사에 걸려서 테이블 전체가 나왔습니다. 쿼리 빌더의 qb.take(0)은 처음부터 LIMIT 0이었으니 이제야 둘이 같아진 셈입니다. 페이지 크기를 계산해 넘기는 코드가 "0이면 제한 없음"에 기대고 있었다면 그 부분은 직접 처리해야 합니다.

stream()이 지정한 범위를 지킵니다

내부 배치 로직이 호출자의 limit/take/skip을 덮어쓰고 있었습니다. 게다가 take가 배치 크기보다 크면 같은 행이 다시 나왔습니다. 배치 100에 take: 150을 주면 300건이 나오고 그중 50건이 중복이었죠. 이제는 지정한 범위가 스트림 전체의 범위입니다. take를 넘겼는데도 전부 받아 오던 코드는 이제 요청한 만큼만 받습니다.

시각 값이 저장되는 방식

insertMany, insertManyAndReturn, batchUpsert, 그리고 @CreateTimestamp/@UpdateTimestamp 컬럼은 시각을 오프셋도 밀리초도 없는 로컬 벽시계 문자열로 만들어 넣었습니다. 반면 save() 같은 단일 행 경로는 Date를 그대로 드라이버에 넘겼고요. 그래서 한 컬럼 안에 표기가 두 가지 섞였습니다. 2.0부터는 모든 경로가 Date를 그대로 넘기고, 문자열로 바꾸는 일은 드라이버가 맡습니다.

  • 배치로 넣어도 밀리초가 남습니다. 컬럼 정밀도가 받쳐 준다면요.
  • PostgreSQL timestamptz에서 배치 쓰기와 save()가 같은 시각을 저장합니다. 애플리케이션 프로세스와 서버의 타임존이 다르면 둘이 어긋나던 문제였습니다.
  • SQLite에 저장되는 문자열이 2026-03-01 21:34:56에서 2026-03-01T12:34:56.789Z로 바뀝니다.

읽기는 그대로입니다. ORM이 두 표기를 모두 해석하고 있었으니 이미 저장된 행의 의미는 달라지지 않습니다. 다만 저장된 문자열을 직접 비교하는 코드가 있다면, 예컨대 손으로 쓴 SQL이나 덤프 비교 같은 것들은 손봐야 합니다. 자세한 건 날짜와 타임존에 정리해 뒀습니다.

SQLite 소프트 삭제 시각

SQLite에서 softDelete()datetime('now')를 썼습니다. 이 함수는 UTC를 주는데 존 표기가 없고, 읽는 쪽은 존 없는 문자열을 로컬로 해석합니다. 결국 deletedAt이 프로세스 타임존만큼 어긋난 값으로 돌아왔습니다. 서울에서 돌리면 아홉 시간 이른 시각이 나왔죠. 이제는 Z 표기를 달고 저장하므로 그대로 읽힙니다.

1.x에서 삭제된 행은 옛 표기를 그대로 갖고 있습니다. 그 시각이 "버려진 행인가" 이상의 의미를 갖고 UTC가 아닌 환경에서 운영했다면, 날짜와 타임존에 한 번만 돌리면 되는 보정 SQL을 넣어 뒀습니다.

로그에 새로 뜨는 것

에러 메시지에 해결 힌트가 함께 나옵니다

suggestion을 갖고 있던 에러는 그 문장을 message 끝에 Suggestion: ... 줄로 붙입니다. 메시지나 스택을 찍는 로거라면 어디서든 보이게 됩니다. 에러 문자열을 정확히 비교하는 테스트가 있다면 손봐야 하고, error.suggestion 자체는 예전 그대로입니다.

테넌트 컨텍스트가 없을 때

tenantStrategy: "tenant_column"을 쓰면서 MetadataContext.run() 밖에서 읽으면, 1.x는 모든 테넌트의 행을 아무 말 없이 훑었습니다. 이제 엔티티 클래스마다 한 번씩 경고합니다. 정책을 직접 정해 두는 편이 좋습니다.

typescript
await em.register({
  // ...
  tenantStrategy: "tenant_column",
  tenantOnMissingContext: "throw", // "throw" | "warn"(기본) | "allow"
});

새로 짜는 코드에는 "throw"를 권합니다. 다음 메이저에서 기본값이 될 예정이고요. 테넌트를 가로지르는 게 정당한 배치 작업이라면 컨텍스트 부재에 기대지 말고 runUnscoped(), 명시적인 run("public"), withoutTenantScope, @NonTenantEntity 중 하나로 의도를 드러내세요.

MySQL에서 timestamptz를 쓸 때

MySQL과 MariaDB에는 타임존을 아는 DATETIME이 없습니다. 그래서 timestamptz 컬럼은 그냥 DATETIME으로 만들어지고 오프셋은 저장되지 않습니다. 이 매핑 자체는 1.x와 같고, 이제 프로세스당 한 번 경고를 남깁니다.

모르는 옵션 키

register()가 인식하지 못하는 옵션 키를 그냥 넘기지 않고, 비슷한 이름을 제안하며 경고합니다.

배포 파이프라인에서 확인할 것

마이그레이션 실패가 배포를 멈춥니다

stingerloom migrate:runmigrate:rollback이 마이그레이션 실패 시 종료 코드 1을 반환합니다. 1.x는 "0 succeeded, 1 failed"를 info 로그로 찍고 0으로 끝났기 때문에, migrate:run && start 같은 사슬이 반쯤 마이그레이션된 스키마 위에서 그대로 앱을 띄웠습니다.

파이프라인을 한 번 확인해 보세요. 실패해도 초록이던 잡이 이제 멈춥니다. 그게 목적이긴 하지만, 그동안 티 나지 않게 실패하고 있던 마이그레이션이 이 참에 드러날 수도 있습니다.

인자 파서도 모르는 플래그와 값이 빠진 옵션, 남는 인자를 그냥 버리지 않고 에러로 알립니다. 1.x에서는 --dry-runn이 무시된 채 파일이 써졌고, --ouput ./migrations migrate:run./migrations를 커맨드로 실행했습니다.

NestJS 종료 시 커넥션 풀

StingerloomOrmService.onApplicationShutdown()이 자기가 연 풀을 닫습니다. tenantStrategy: "database"로 만들어진 테넌트 풀도 전부 포함해서요. Nest의 셧다운 훅 이후에도 커넥션을 쓰던 코드가 있다면 이제 실패하니, 그 작업은 종료 전으로 옮겨야 합니다.

같이 보면 좋은 것

파괴적인 변경은 아니지만 2.0에서 새로 쓸 수 있게 된 것들입니다.

  • 쿼리 결과 캐시 — 읽기마다 cache: truecache: 30_000으로 켭니다. 같은 EntityManager를 통한 쓰기가 해당 테이블의 엔트리를 알아서 무효화합니다.
  • 코드 우선 엔티티의 상호 참조defineEntity로 만든 두 엔티티가 TS7022 순환 오류 없이 서로를 참조할 수 있습니다.
  • 타임존 교차 테스트 — 시각을 다루는 테스트가 있다면 TZ를 하나로 두지 마세요. UTC에서는 존 없이 저장한 값과 제대로 저장한 값이 똑같아 보여서, 이번에 고친 버그들이 전부 여기 숨어 있었습니다. 이 저장소는 pnpm test:temporal-tz로 네 개 존에서 돌립니다.

Released under the MIT License.