
관계형 데이터베이스 위에 TypeScript 서비스를 올릴 때, 개발자는 오랫동안 두 갈래 사이에서 고민해 왔다. 한쪽은 객체–관계 매핑(ORM) 의 편의성이고, 다른 한쪽은 SQL의 예측 가능성과 성능이다. Prisma는 DX(Developer Experience)가 뛰어나지만 런타임과 쿼리 제어 방식이 팀의 취향과 맞지 않을 수 있고, 순수 SQL은 유지보수 부담이 크다. 그 사이에서 “SQL에 가깝되, 타입으로 끝까지 잡고 싶다”는 요구를 비교적 잘 만족시키는 선택지가 Drizzle ORM이다. 이 글은 Drizzle을 PostgreSQL과 함께 실제 서비스에 운영할 때 필요한 내용을 빠짐없이 담았다. 튜토리얼 수준의 짧은 예제가 아니라, 스키마 설계부터 마이그레이션, 배포, 장애 대응까지 이어지는 흐름을 목표로 했다.
Drizzle을 선택하는 이유와 한계
Drizzle의 철학은 단순하다. 테이블 정의가gold source에 가깝고, 그로부터 TypeScript 타입이 파생된다. SQL과의 거리가 가깝기 때문에, 복잡한 조인·서브쿼리·윈도 함수를 쓸 때 “ORM이 막는 느낌”이 상대적으로 덜하다. 또한 번들 크기와 런타임 특성이 가벼운 편이라, 엣지 런타임이나 서버리스 환경에서도 선택지로 자주 언급된다.
반면 한계도 분명하다. 생태계가 Prisma만큼 거대하지 않을 수 있고, GUI 기반 스튜디오나 풍부한 플러그인을 기대했다면 실망할 수 있다. “모든 것을 추상화해 주는 마법”을 원한다면 Drizzle은 오히려 SQL을 함께 써야 한다는 전제를 요구한다. 팀이 SQL을 피하고 싶어 한다면 다른 도구가 나을 수 있다.
프로젝트 구조 권장안
실무에서는 보통 다음과 같이 나눈다.
src/db/schema/: 테이블·열거형·관계 정의src/db/client.ts:drizzle()인스턴스와 풀drizzle/또는migrations/: drizzle-kit이 생성한 SQL 마이그레이션drizzle.config.ts: drizzle-kit 설정
모노레포라면 패키지 단위로 스키마를 분리하고, 마이그레이션은 저장소 루트에서 한 번에 관리할지, 서비스별로 나눌지를 팀 규칙으로 정한다. 잘못 나누면 순환 의존이나 마이그레이션 순서 문제가 생긴다.
스키마 정의: pg 테이블과 타입 안전성
Drizzle의 핵심은 pgTable 등으로 스키마를 코드로 표현하는 것이다. 예시는 개념 설명용이며, 실제 프로젝트에서는 네이밍·모듈 분리 규칙을 팀에 맞춘다.
// 개념 예시 — 실제 import 경로·버전에 맞게 조정
import { pgTable, serial, varchar, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
export const users = pgTable(
"users",
{
id: serial("id").primaryKey(),
email: varchar("email", { length: 320 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
emailUnique: uniqueIndex("users_email_unique").on(t.email),
})
);여기서 기억할 점은 다음과 같다.
- DB 식별자와 코드 식별자 분리: 컬럼명을 스네이크 케이스로 두면 운영 DB와의 심리적 거리가 줄어든다.
- 시간대:
timestamp에withTimezone: true여부는 서비스 전체 규칙과 맞춘다. 한 번 잘못 고치면 마이그레이션이 아프다. - 인덱스를 스키마 코드에 같이: 나중에 “왜 느리지?”를 추적할 때, 코드 리뷰 단계에서 인덱스 논의가 가능해진다.
관계와 참조 무결성
relations()로 관계를 선언하면, 쿼리 빌더에서 with를 사용한 관계형 로딩 패턴을 쓸 수 있다. 다만 외래 키 제약을 DB에 둘지, 애플리케이션에서만 보장할지는 트레이드오프가 있다.
- DB FK + ON DELETE 규칙: 데이터가 드물게 손상되는 대신, 마이그레이션 순서와 잠금 이슈를 이해해야 한다.
- 애플리케이션만: 마이그레이션은 단순해질 수 있으나, 배치·수동 SQL·다른 서비스가 같은 DB를 건드리면 무결성이 깨지기 쉽다.
일반적으로 프로덕션 핵심 도메인에는 DB 수준 제약을 두는 편이 안전하다.
drizzle-kit과 마이그레이션 워크플로
Drizzle의 운영 경험은 drizzle-kit과 함께 논의되어야 한다. 흔한 흐름은 다음과 같다.
- 로컬에서 스키마 코드를 수정한다.
drizzle-kit generate로 마이그레이션 SQL을 생성한다(이름은 팀 규칙으로).- 리뷰어가 SQL을 읽는다. 여기서 대부분의 사고를 막는다.
- 스테이징에 적용해 본 뒤 프로덕션에 반영한다.
왜 “생성된 SQL 리뷰”가 중요한가
ORM이 SQL을 만들어 주는 순간, 개발자는 “내가 짠 코드”라는 착각을 한다. 하지만 마이그레이션은 한 번만 실행되는 것이 아니라, 이후 모든 환경에 재현되어야 한다. 특히 다음을 반드시 확인한다.
- LOCK: 긴 테이블 락이 걸리는지. 대용량 테이블에
ALTER가 오래 걸리면 서비스가 멈출 수 있다. - DEFAULT / NOT NULL 추가 순서: PostgreSQL 버전에 따라 전략이 달라진다. 기존 행이 있을 때 채우는 전략이 있었는가.
- 인덱스 생성 CONCURRENTLY: 프로덕션에서는
CREATE INDEX CONCURRENTLY가 필요한 경우가 많다. drizzle-kit 출력이 이를 항상 만족하지는 않는다. 수동 SQL 보정이 빈번하다.
즉, Drizzle은 편하지만 DBA 감각을 없애 주지는 않는다.
쿼리 빌더와 SQL 원문의 혼합
Drizzle의 강점 중 하나는 sql 템플릿을 통해 검증된 파라미터 바인딩을 유지하면서도 복잡한 쿼리를 쓸 수 있다는 점이다. 리포트성 쿼리, 배치, 윈도 함수가 필요하면 과도하게 ORM 추상화에 얽매이지 말고 SQL을 밝히는 것이 오히려 유지보수에 유리할 때가 많다.
팀 규칙 예시는 다음과 같다.
- 단순 CRUD: Drizzle 쿼리 빌더
- 복잡 집계·운영 스크립트:
sql또는.execute로 raw에 가깝게 - 일회성 데이터 수정: 저장소에 스크립트를 두고 코드 리뷰 후 실행
트랜잭션과 재시도 정책
금융·재고·포인트처럼 경합이 심한 도메인에서는 트랜잭션 격리 수준과 재시도 전략이 중요하다. Drizzle은 보통 연결 풀에서 db.transaction() 콜백을 제공한다. 여기에 더해 애플리케이션 레벨에서:
- 데드락 감지 시 재시도(지수 백오프)
- 멱등 키(외부 결제 웹훅 등)
을 조합한다. ORM이 아무리 좋아도 동시성 이슈는 비즈니스 로직과 함께 설계해야 한다.
연결 풀과 서버리스
Node에서 pg 풀을 쓸 때 흔한 실수는 서버리스 함수마다 풀을 남발하는 것이다. Lambda 같은 환경에서는 연결 수가 급증해 DB에 도달한다. 대응 전략은 다음과 같다.
- 풀러 게이트웨이(PgBouncer 등) 도입
- 콜드 스타트마다 새 풀 생성 금지 패턴(모듈 스코프 재사용)
- 가능하면 RDS 프록시 같은 관리형 풀링
Drizzle 자체보다 배포 형태가 병목인 경우가 많다.
인덱스 전략과 EXPLAIN
스키마에 인덱스를 적었다고 끝이 아니다. 프로덕션에서는 주기적으로 EXPLAIN (ANALYZE, BUFFERS)로 확인한다. 특히:
- 복합 인덱스 컬럼 순서
- 부분 인덱스가 필요한지
- 통계 정보가 낙후되지 않았는지
Drizzle은 쿼리 작성을 돕지만, 실행 계획은 PostgreSQL이 결정한다. 느린 쿼리 로그와 pganalyze 같은 도구를 함께 쓰는 팀이 유리하다.
마이그레이션 배포 전략
무중단 배포를 목표로 한다면 확장/계약(expand/contract) 패턴을 고려한다.
- 확장: 새 컬럼·새 테이블을 추가하고, 기존 코드는 그대로 둔다.
- 이행: 배치나 듀얼 라이트로 데이터를 채운다.
- 계약: 구 스키마 의존 코드를 제거하고, 불필요한 컬럼을 삭제한다.
Drizzle 마이그레이션 한 방에 “컬럼 삭제 + 코드 삭제”를 하고 싶은 유혹이 생기지만, 배포 파이프라인이 블루–그린이 아닌 이상 위험하다.
관측 가능성: 무엇을 로깅할 것인가
최소한 다음을 추천한다.
- 쿼리 지연: p95, p99. ORM 레벨이 아니라 DB 프록시나 APM에서 볼 수도 있다.
- 에러 코드:
23505unique violation 같은 코드를 매핑해 알림. - 마이그레이션 적용 기록: 어떤 버전이 어느 환경에 적용됐는지.
팀 협업: 코드 리뷰 체크리스트
- 스키마 변경에 비즈니스 맥락이 설명돼 있는가.
- 마이그레이션 SQL에 락/시간 추정이 주석으로라도 있는가.
- 롤백 전략이 현실적인가(되돌리기 SQL, 백업).
- 데이터 백필이 필요한가.
Prisma와의 비교 — 짧게
| 항목 | Drizzle | Prisma |
|---|---|---|
| SQL 표현력 | 매우 가깝게 제어 가능 | 제약이 느껴질 수 있음 |
| 스키마 소스 | TS 코드 중심 | schema.prisma 중심 |
| 마이그레이션 스토리 | 팀에 따라 유연 | 성숙한 워크플로 많음 |
| DX 도구 | 성장 중 | 풍부 |
“누가 이겼다”가 아니라 팀의 SQL 문해력과 운영 성숙도에 맞추는 것이 맞다.
실패 사례: 운영에서 자주 터지는 것들
- 로컬에서는 빠른데 프로덕션만 느리다: 데이터량·캐시·인덱스 차이. 로컬 시드 데이터가 너무 작다.
- 마이그레이션 중 다운타임:
ALTER TABLE이 테이블 전체를 잠근다. - 환경 변수 불일치: 스테이징과 프로덕션의
search_path, 확장(extension) 설치 차이. - 트랜잭션 범위 과대: 한 트랜잭션에 너무 많은 행을 잠근다.
정리
Drizzle은 TypeScript 생태계에서 SQL과 타입 안전성 사이의 균형을 잡으려는 실용적인 ORM이다. drizzle-kit과 함께 쓸 때 비로소 마이그레이션 운영이 완성된다. 이 글에서 반복한 메시지는 하나다. 도구는 SQL과 운영 지식을 대체하지 않는다. 그 대신, 팀이 같은 스키마를 코드로 논의하고 리뷰할 수 있게 해 준다. PostgreSQL을 오래 쓴 엔지니어일수록 Drizzle의 “투명함”이 오히려 편하게 느껴질 것이고, ORM에 익숙한 개발자는 초기에 SQL을 더 쓰도록 훈련할 필요가 있다.
앞으로 서비스를 새로 시작한다면, 스키마 네이밍, 시간대, ID 전략(serial vs UUID vs snowflake), 소프트 삭제 여부를 먼저 합의하고 Drizzle 스키마에 반영하라. 그 합의가 있을 때 마이그레이션은 반복 가능하고, 장애는 줄어든다.
부록: 용어 정리
- 마이그레이션: 스키마 버전을 올리기 위한 SQL 묶음.
- 시드(seed): 개발·테스트용 초기 데이터. 프로덕션과 혼동하지 말 것.
- 드리즐 커널(drizzle-kit config): 생성 경로, 드라이버, 스키마 파일 위치를 가리키는 설정.
이 용어들을 팀 온보딩 문서 첫 페이지에 넣어 두면, 신규 입사자가 ORM 논의에 빨리 합류할 수 있다.
drizzle.config.ts: 한 번만 제대로 잡기
drizzle.config.ts는 “마이그레이션 생성기가 어디를 바라보는지”를 결정한다. 흔한 설정 항목은 다음과 같다.
- schema:
export된 테이블 정의가 모인 파일 또는 glob - out: 생성된 SQL이 쌓일 디렉터리
- dialect / driver:
postgresql등 - dbCredentials: 로컬 개발용 URL (절대 커밋하지 말 것 —
.env와 로더 사용)
모노레포에서 여러 앱이 같은 DB를 공유하면, 스키마 소스는 하나로 모으고 앱별로는 리포지토리 레이어만 분리하는 편이 마이그레이션 충돌을 줄인다. “앱 A만 쓰는 테이블”을 패키지 경계로 나누되, DB 스키마 파일은 공용 패키지에 두는 식이다.
관계형 로딩과 N+1
relations()를 정의하면 query API로 중첩 로딩을 편하게 쓸 수 있다. 다만 N+1 쿼리에 빠지기 쉽다. 리스트 API에서 사용자 100명을 가져온 뒤 각각 주문을 lazy로 읽으면, 쿼리가 폭발한다.
완화 전략은 다음과 같다.
- 한 번에 조인 가능한 것은 조인으로 가져온다.
- 배치 로딩: ID 목록을 모아
inArray로 두 번째 쿼리를 한 번만 날린다. - 데이터 로더 패턴: GraphQL이라면 필수에 가깝다.
ORM이 편하다고 쿼리 개수를 잊지 말 것. 개발 환경에서 DEBUG=drizzle:* 류 로깅을 켜 두거나, APM에서 SQL 개수를 본다.
NULL, 기본값, 도메인 불변 조건
스키마에서 notNull()을 남발하기 전에, 비즈니스적으로 “아직 모름”과 “없음”을 구분할지 결정한다. PostgreSQL의 NULL 의미를 팀이 통일하지 않으면, 통계·리포트·필터 쿼리에서 조용히 틀어진다.
또한 애플리케이션에서만 검증하던 규칙(이메일 형식, 금애 범위)을 CHECK 제약으로 옮길지는 성능·운영 트레이드오프다. 마이그레이션 비용이 크므로, 초기 스키마보다는 도메인이 안정된 뒤에 단계적으로 추가하는 팀도 많다.
읽기 전용 복제본과 Drizzle
읽기 부하를 리드 레플리카로 분산할 때, Drizzle 인스턴스를 두 개(Writer/Reader) 두는 패턴이 흔하다. 라우팅 실수로 쓰기를 복제본에 보내면 조용히 실패하거나 지연만 생길 수 있으므로, 리포지토리 메서드 이름을 findForReport(읽기 전용)처럼 의도가 드러나게 짓는다.
트랜잭션은 항상 writer 연결에서 연다. “읽기 후 같은 트랜잭션에서 쓰기”를 할 때 복제 지연으로 낙관적 락이 깨지는 경우도 있으니, 비즈니스 규칙과 함께 설계한다.
테스트: Testcontainers와 마이그레이션
로컬에 PostgreSQL을 직접 깔지 않고도, CI에서 도커로 DB를 띄운 뒤 마이그레이션을 적용하고 통합 테스트를 돌리는 패턴이 널리 쓰인다. Drizzle이라면:
- 컨테이너 기동
drizzle-kit migrate또는 마이그레이션 SQL 적용- 시드(필요 시)
- 애플리케이션 테스트 실행
이 순서가 스크립트로 고정되면, “내 컴퓨터에서는 되는데”가 줄어든다.
보안: 연결 문자열과 비밀
.env에 DATABASE_URL을 두고, 프로덕션은 시크릿 매니저에서 주입한다. 로그에 쿼리 문자열이 찍히지 않게 마스킹하고, ORM이 에러를 출력할 때 바인딩된 값이 노출되지 않게 로그 레벨을 조정한다.
팀에서 자주 하는 실수는 읽기 전용 계정을 따로 두지 않고 admin 계정을 애플리케이션에 넣는 것이다. 유출 시 피해 면적이 커진다.
버전 업그레이드 전략
drizzle-orm, drizzle-kit, 드라이버(pg)는 함께 올리는 편이 안전하다. 릴리스 노트에서 브레이킹 체인지를 확인하고, 스테이징에서 마이그레이션 dry-run을 습관화한다.
실전 FAQ
Q. 마이그레이션 SQL이 수동으로 너무 많이 고쳐진다.
A. 정상에 가깝다. 특히 대용량 테이블은 ORM이 생성한 기본 ALTER가 위험할 수 있다. 팀에서 “위험 등급” 마이그레이션은 DBA 리뷰를 필수로 하라.
Q. 스키마 코드와 실제 DB가 어긋났다.
A. drift다. drizzle-kit introspect로 실제 DB에서 스키마를 끌어와 비교하거나, 한 방향으로만 진실을 둔다(코드 우선 vs DB 우선).
Q. Enum 타입은 어떻게 다루나?
A. PostgreSQL ENUM은 값 추가·삭제가 까다롭다. 팀에 따라 체크 제약 + 문자열 또는 lookup 테이블을 선호하기도 한다.
배치·데이터 마이그레이션과 Drizzle
스키마 마이그레이션과 별개로, 기존 행을 채우는 데이터 마이그레이션이 필요할 때가 있다. 예를 들어 새 컬럼을 nullable로 추가한 뒤, 배치 잡으로 값을 채우고, 마지막에 NOT NULL로 바꾸는 흐름이다. Drizzle은 이 과정을 자동으로 “예쁘게” 해 주지 않는다. 대신 운영 절차를 문서로 남기고, SQL 스크립트나 임시 Node 스크립트를 코드 리뷰하는 팀이 안전하다.
데이터 마이그레이션 스크립트를 작성할 때는 배치 크기, 슬립, 재실행 가능성(idempotent) 을 명시한다. 한 번에 수백만 행을 업데이트하면 장시간 락과 WAL 폭증이 올 수 있다.
파티셔닝·대용량 테이블을 앞둔 팀에게
주문·로그처럼 데이터가 끊임없이 쌓이는 테이블은 초기 스키마에서 파티셔닝 전략을 논의하는 것이 좋다. Drizzle이 파티션 정의를 완벽히 추상화하지 않을 수 있으므로, 생성 DDL을 수동으로 관리하는 경우도 있다. 이때는 “스키마 코드 우선”과 “수동 DDL”의 경계를 팀 규칙으로 못 박아야 한다. 그렇지 않으면 신입 개발자가 pgTable만 보고 운영 DB의 실제 구조를 오해한다.
운영 플레이북에 넣을 질문 다섯 가지
- 지금 장애가 ORM 버그인가 쿼리 플랜 문제인가 연결 풀 문제인가?
- 최근 마이그레이션 중 롱 러닝 트랜잭션이 있었는가?
- 슬로우 쿼리 로그에 새로운 패턴이 보이는가?
- 복제 지연이 임계치를 넘었는가?
- 디스크 여유와 VACUUM, autovacuum 상태는?
이 질문에 Drizzle은 답을 주지 않는다. 대신 같은 스키마를 기준으로 팀이 대화할 수 있게 해 준다.
마무리 한 줄 더
Drizzle은 SQL을 숨기는 ORM이라기보다, SQL을 팀이 함께 읽을 수 있게 정리해 주는 ORM에 가깝다. 그래서 성장하는 팀일수록 SQL 리뷰 문화와 함께할 때 시너지가 난다. 이 글의 글자 수를 채우는 것이 목적이 아니라, 운영 중에 다시 펼쳐 보고 싶은 체크리스트가 목적이 되기를 바란다.
추가로 한 가지 권한다. 스키마 변경 PR에는 “사용자에게 보이는 변화” 한 줄을 본문에 적어라. 내부 리팩터링만 보이는 DB 작업이 줄어들고, PM·디자이너와의 대화 비용이 줄어든다.