콘텐츠로 이동

ORM의 개념과 역할

11/10(화)

한 줄 요약: 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에서 사용자 이름을 바꾸는 흐름이다.

  1. 클래스 User와 표 app_user를 대응시켜 둔다.
  2. 코드가 User.get(id=3)을 부른다.
  3. ORM이 SELECT … WHERE id = 3을 만들어 DB 드라이버로 보낸다.
  4. 돌아온 행 1개를 User 객체로 바꾼다. 이때 원래 값을 기억해 둔다.
  5. 코드가 user.name을 바꾸고 save()를 부른다.
  6. 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 모양)   (객체 모양)   (변경 추적·지연 로딩)
대략적인 위치다. 같은 "ORM"이라는 이름이 붙어도 오른쪽 끝과 가운데는 쓰는 감각이 꽤 다르다.

🧪 예시

  • 예시 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를 직접 지시
① 객체를 하나 가져오면 SELECT가 나간다. ② 값만 바꾸고 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의 정의와 객체-관계 불일치 개념 · 링크
  • Django 문서 — 모델과 쿼리 만들기 (Django 한국어 문서) — 전형적인 ORM(Active Record)의 사용 모습 · 링크
  • Drizzle ORM Overview (Drizzle 공식 문서, 영문) — Drizzle이 SQL 모양을 유지하는 이유와 질의 방식 · 링크

🗓️ 이력

  • 2026-11-10: Drizzle이 무엇인지, 이어서 ORM의 정확한 뜻을 2번 물음 → 일지

🕸️ 연결된 주제