ORM의 개념과 역할¶
한 줄 요약: ORM은 프로그램의 객체와 DB의 표를 서로 대응시켜, 코드로 DB를 다루게 해 주는 기술이다. SQL을 얼마나 감추는지는 도구마다 다르다. Drizzle은 SQL 모양을 거의 그대로 드러내는 쪽이다.
📍 만난 상황¶
- 2026-11-10 — 재난 대시보드 동작을 파악하던 중이었다. 2번 물음 → 그날 일지
- 09:58 대시보드 전체 아키텍처 설명을 받았다. 그림에 Drizzle이 나와서 "Drizzle이 뭐야?"를 물었다. → TypeScript로 PG를 다루는 라이브러리(ORM)이고,
drizzle-orm과drizzle-kit두 부품이 있다. - 10:00 답에 나온 "ORM"의 정확한 뜻을 바로 물었다. → 객체와 표를 대응시키는 기술이고, Drizzle은 전형적인 ORM보다 "SQL 빌더"에 가깝다.
🧩 쉽게 말하면¶
통역사를 떠올리자. 프로그램은 "객체 말"을 쓰고, DB는 "표 말"을 쓴다.
코드에서 user.name = "관리자"라고 하면, 통역사가 UPDATE … SET name = …로 옮겨 DB에 전한다. DB가 행을 돌려주면 다시 객체로 옮겨 준다.
통역사가 없으면 매번 SQL 문자열을 직접 쓴다. 돌아온 행도 손으로 객체에 옮긴다. 열 이름을 틀려도 실행해 봐야 안다.
📖 제대로 알기¶
뜻¶
- ORM: Object-Relational Mapping(객체-관계 매핑)의 줄임말이다.
- 객체(object): 프로그램 안의 데이터 덩어리다. 속성과 기능(메서드)을 함께 가진다.
- 관계형 DB: 데이터를 표(행과 열)로 두는 DB다. PostgreSQL, MySQL이 대표적이다.
두 세계는 모양이 다르다¶
| 프로그램(객체) | DB(표) |
|---|---|
user.favorites[0].dataset.title처럼 참조를 따라감 |
JOIN으로 표를 이어야 함 |
| 객체 안에 목록·객체를 넣을 수 있음 | 칸 하나에 값 하나, 목록은 별도 표 |
user.name = "관리자" 하면 끝 |
UPDATE … WHERE id = …를 보내야 함 |
이 차이를 객체-관계 불일치(object-relational impedance mismatch)라고 한다. ORM은 이 틈을 메운다.
무엇을 무엇에 대응시키나¶
| 객체 쪽 | 표 쪽 | 대시보드 예 |
|---|---|---|
| 클래스(설계도) | 표 | appUser ↔ app.app_user |
| 객체 하나 | 행 하나 | id가 3인 사용자 |
| 속성 | 열 | email |
| 참조 | 외래 키 | favorite.user_id → app_user.id |
erDiagram
APP_USER ||--o{ FAVORITE : "한 사람이 여러 개"
DATASET ||--o{ FAVORITE : "한 데이터셋이 여러 번"
표 세계에서는 favorite 표의 user_id 열로 이어진다. 객체 세계에서는 이것이 user.favorites 목록이 된다.
전형적인 ORM이 해 주는 일¶
- 변경 추적(change tracking): 객체 값만 바꾸고
save()하면, 무엇이 바뀌었는지 보고 UPDATE를 만든다. - 관계 따라가기:
user.favorites처럼 속성에 접근하면 JOIN이나 추가 SELECT를 알아서 보낸다. - 지연 로딩(lazy loading): 실제로 접근하는 순간에 읽는다. 편하지만 N+1 문제(목록 1번 + 항목마다 1번씩 질의가 나감)의 원인이 된다.
- 저장 방식은 두 갈래다. 객체가 스스로 저장하는 Active Record(Rails, Django)와, 별도 관리자가 저장하는 Data Mapper(Hibernate·JPA, SQLAlchemy)다.
ORM 곁에 따라오는 도구¶
- 스키마 정의: 표 구조를 코드로 적는다.
- 마이그레이션(migration): 표 구조 변경을 번호 붙은 SQL 파일로 남기고 순서대로 DB에 적용한다.
- 엄밀히는 ORM과 별개 기능이다. 하지만 함께 묶여 오는 경우가 많다.
⚙️ 어떻게 동작하나¶
전형적인 ORM에서 사용자 이름을 바꾸는 흐름이다.
- 클래스
User와 표app_user를 대응시켜 둔다. - 코드가
User.get(id=3)을 부른다. - ORM이
SELECT … WHERE id = 3을 만들어 DB 드라이버로 보낸다. - 돌아온 행 1개를
User객체로 바꾼다. 이때 원래 값을 기억해 둔다. - 코드가
user.name을 바꾸고save()를 부른다. - ORM이 원래 값과 비교해 바뀐 열만 UPDATE로 보낸다.
sequenceDiagram
participant C as 앱 코드
participant O as ORM
participant D as DB
C->>O: User.get(id=3)
O->>D: SELECT ... WHERE id = 3
D-->>O: 행 1개
O-->>C: User 객체
C->>O: 이름 변경 후 save()
O->>D: UPDATE ... SET name
앱 코드는 SQL을 한 줄도 쓰지 않았다. SQL을 만들고 행을 객체로 바꾸는 일은 모두 ORM이 했다.
왜 이렇게 만들었나. 프로그램 대부분은 "한 줄 읽기·고치기·넣기"(CRUD: Create·Read·Update·Delete)다. 이 반복을 코드로 짧게 쓰려는 것이다.
도구마다 SQL을 감추는 정도가 다르다¶
SQL을 직접 쓴다 ◀──────────────────────────▶ SQL을 감춘다
pg 드라이버 쿼리 빌더 Drizzle Prisma Hibernate·Django ORM
(문자열 SQL) (Knex 등) (SQL 모양) (객체 모양) (변경 추적·지연 로딩)
🧪 예시¶
-
예시 1 · 일상에 빗대어: 즐겨찾기 10개의 데이터셋 이름을 출력한다고 하자. 친절한 통역사(지연 로딩)는 먼저 "즐겨찾기 목록 주세요"를 1번 묻는다. 그다음 항목마다 "이 데이터셋 이름은요?"를 10번 더 묻는다. 모두 11번 왕복이다. 처음부터 "즐겨찾기와 데이터셋 이름을 같이 주세요"라고 하면 1번이면 된다.
-
예시 2 · 실제로는: 같은 일을 두 도구로 쓰면 이렇다.
# Django: SQL이 안 보인다
user = User.objects.get(id=3) # ① SELECT … WHERE id = 3
user.name = "관리자"
user.save() # ② UPDATE … SET name = …
for f in user.favorites.all(): # ③ SELECT … FROM favorite
print(f.dataset.title) # ④ 반복마다 SELECT … FROM dataset
// Drizzle: SQL 모양이 그대로 보인다
const rows = await db
.select({ title: dataset.title })
.from(favorite)
.innerJoin(dataset, eq(favorite.datasetId, dataset.id))
.where(eq(favorite.userId, 3)); // ⑤ JOIN 한 번
await db.update(appUser).set({ name: "관리자" })
.where(eq(appUser.id, 3)); // ⑥ UPDATE를 직접 지시
save()하면 변경 추적이 UPDATE를 만든다.
③④ 관계를 따라가면 질의가 숨어서 나간다. 즐겨찾기 10개면 모두 11번이다(N+1).
⑤ Drizzle은 select…from…innerJoin…where가 SQL 순서 그대로다. 질의는 정확히 1번 나간다.
⑥ Drizzle에는 변경 추적이 없다. 객체를 바꿔도 DB는 그대로고, update를 직접 부른다.
대시보드에서 Drizzle 표 정의는 src/server/db/schema.ts 한 곳에 있다. 여기서 TypeScript 타입이 자동으로 나온다(typeof dataset.$inferSelect). 그래서 열 이름을 틀리면 컴파일 단계에서 오류가 난다.
⚖️ 비교 · 흔한 오해¶
| 구분 | 전형적인 ORM | Drizzle |
|---|---|---|
| SQL | 감춘다 | 모양을 그대로 드러낸다 |
| 값 바꾸기 | 객체 수정 후 save() |
update()를 직접 부름 |
| 관계 읽기 | 접근하면 자동으로(지연 로딩) | 요청한 것만 |
오해 1. "ORM은 DB의 한 종류다." 아니다. ORM은 앱 안에서 도는 라이브러리다. DB는 여전히 PostgreSQL이다.
오해 2. "Drizzle도 Hibernate처럼 SQL을 알아서 해 준다."
패키지 이름이 drizzle-orm이라 그렇게 생각하기 쉽다. 실제로는 "ORM이라 불리는 SQL 빌더"에 가깝다. Drizzle 문서도 "SQL을 알면 Drizzle을 안다"는 식으로 소개한다. 관계를 한 번에 읽는 질의 기능(db.query…with)도 있지만, 명시적으로 부를 때만 읽는다.
오해 3. "ORM을 쓰면 SQL을 몰라도 된다." 평소에는 그렇게 보인다. 하지만 느린 질의나 N+1을 고칠 때는 결국 실제로 나간 SQL을 읽어야 한다.
오해 4. "마이그레이션은 ORM의 일부다."
같이 오는 경우가 많을 뿐 별개다. Drizzle에서는 실행 중 질의는 drizzle-orm, 마이그레이션은 개발용 drizzle-kit이 맡는다. npm run db:generate가 바뀐 만큼 SQL 파일을 만들고, npm run db:migrate가 DB에 적용한다.
🛠️ 실무에서는¶
- 쓰기 좋을 때: 한 줄 읽기·쓰기가 많은 앱 기록(즐겨찾기, 제안, 문의). 타입 검사가 필요한 곳.
- 날 SQL이 나을 때: 복잡한 집계, 분석 DB에 보내는 질의. 대시보드도 ClickHouse로 보내는 SQL은 Drizzle이 아니라 서버가 정한 틀(
plan.ts)로 만든다.
체크리스트
- [ ] 개발 중에 실제로 나가는 SQL을 볼 수 있나(Drizzle은 logger: true)
- [ ] 반복문 안에서 질의를 부르고 있지 않나
- [ ] schema.ts를 고친 뒤 마이그레이션 파일을 만들어 함께 커밋했나
- [ ] DB를 손으로 고쳐서 schema.ts와 어긋나지 않았나
문제가 생기면 먼저 볼 것 - 느리다: 질의 로그에서 같은 모양의 SELECT가 여러 번 나가는지 본다(N+1). - 열이 없다는 오류: 마이그레이션을 적용하지 않은 DB에 붙지 않았는지 본다.
❓ 자주 묻는 질문¶
- Q. Prisma와 Drizzle은 뭐가 다른가?
A. Prisma는 전용 스키마 파일(
.prisma)과 객체 모양 질의를 쓴다. Drizzle은 TypeScript로 표를 적고 SQL 모양으로 질의한다. - Q. ORM 없이
pg드라이버만 써도 되나? A. 된다. 대신 타입과 마이그레이션 관리를 직접 해야 한다. - Q. N+1은 어떻게 막나? A. 필요한 관계를 처음부터 JOIN하거나 한 번에 미리 읽는다(즉시 로딩, eager loading).
🔭 더 깊이 보기¶
- 트랜잭션은 ORM에서 어떻게 묶나? — 승인처럼 여러 표를 한 번에 바꾸는 흐름의 정확성이 여기에 달려서.
- 마이그레이션을 운영 DB에 안전하게 적용하는 순서는? — 열 삭제·이름 변경은 배포 순서를 잘못 잡으면 서비스가 멈춰서.
- 관련 주제: PostgreSQL 데이터베이스와 스키마 · ERD 관계와 카디널리티 · DB 정규화와 연결 표
📚 참고 자료¶
- 객체 관계 매핑 (위키백과 한국어) — ORM의 정의와 객체-관계 불일치 개념 · 링크
- Django 문서 — 모델과 쿼리 만들기 (Django 한국어 문서) — 전형적인 ORM(Active Record)의 사용 모습 · 링크
- Drizzle ORM Overview (Drizzle 공식 문서, 영문) — Drizzle이 SQL 모양을 유지하는 이유와 질의 방식 · 링크
🗓️ 이력¶
- 2026-11-10: Drizzle이 무엇인지, 이어서 ORM의 정확한 뜻을 2번 물음 → 일지