infra
Platform

모듈 맵

[Database] Prisma, JPA, TypeORM, SQLAlchemy의 성능 차이와 올바른 사용법

0 / 37 완료

펼치기
0 / 37 완료0%

Database · 23 / 37

[Database] Prisma, JPA, TypeORM, SQLAlchemy의 성능 차이와 올바른 사용법

ORM의 동작 원리를 이해하고 언어별 주요 ORM 도구의 특징과 사용 패턴을 익힙니다

🚨INCIDENT ALERT
HIGH

ORM은 생산성을 높여주지만 SQL을 몰라도 되게 해주는 도구는 아닙니다. 간단한 메서드 호출 뒤에서 어떤 SELECT와 JOIN이 실행되는지 모르면 N+1과 과한 조회가 숨어듭니다. ORM의 동작 방식을 알아야 편의성과 성능을 함께 챙길 수 있습니다.

이번 챕터에서 배울 것

ORM은 객체 지향 언어와 관계형 데이터베이스 사이의 패러다임 불일치를 해결합니다. 이 모듈에서는 ORM이 내부적으로 어떻게 동작하는지 이해하고, 주요 언어별 ORM 도구를 비교하여 올바른 도구를 선택하는 방법을 배웁니다.

  • 1ORM 공통 실패 패턴 — N+1과 의도치 않은 전체 업데이트
  • 2ORM의 동작 원리와 SQL 추상화 방식
  • 3Active Record vs Data Mapper 두 가지 설계 패턴
  • 4Prisma, SQLAlchemy, JPA/Hibernate, TypeORM 비교
  • 5실무에서 ORM 선택 기준

ORM이란? — Prisma, SQLAlchemy, JPA, TypeORM 비교

데이터베이스와 애플리케이션 코드 사이의 다리 역할을 하는 ORM(Object-Relational Mapping)은 현대 서버 개발에서 거의 필수적인 도구입니다. SQL을 직접 작성하는 대신 프로그래밍 언어의 객체로 데이터베이스를 다룰 수 있게 해주지만, 그만큼 잘못 사용하면 심각한 성능 문제를 일으킵니다. ORM을 도입하기 전에 공통 실패 패턴을 먼저 이해하는 것이 중요합니다.


💡개념

ORM 공통 실패 패턴 — 도구를 쓰기 전에 알아야 할 것

코드 리뷰에서 아무도 문제를 지적하지 않았습니다. 로컬 테스트도 정상이었습니다. 그런데 배포하고 나서 게시글 목록 API가 수백 밀리초씩 걸리기 시작합니다. APM을 보니 게시글 100개를 조회할 때 DB 쿼리가 101번 발생합니다. ORM이 편리한 만큼, ORM의 실패 패턴을 모르면 성능 문제는 반드시 터집니다. N+1부터 SELECT *까지, 도구를 쓰기 전에 알아야 할 패턴들입니다.

ORM 공통 실패 패턴 — 도구를 쓰기 전에 알아야 할 것

N+1 문제 — 가장 흔한 ORM 실수

ORM을 처음 사용하는 개발자가 가장 많이 겪는 문제는 N+1 쿼리입니다. 부모 레코드 N개를 가져온 뒤 각각의 자식 레코드를 개별 쿼리로 로드하면, 총 N+1번의 쿼리가 데이터베이스에 발행됩니다. 10개 게시글의 댓글을 불러올 때는 11번, 100개라면 101번의 쿼리가 실행됩니다.

아래 Python 예시는 문제가 발생하는 코드와 올바른 해결 방법을 보여줍니다.

Python
from sqlalchemy.orm import Session, joinedload

# N+1이 발생하는 코드
posts = session.query(Post).limit(10).all()
for post in posts:
    print(post.comments)

# Eager Loading으로 해결 — JOIN으로 한 번에 로드
posts = (
    session.query(Post)
    .options(joinedload(Post.comments))
    .limit(10)
    .all()
)

첫 번째 방식은 post.comments에 접근할 때마다 Lazy Loading이 트리거되어 개별 쿼리가 실행됩니다. joinedload를 사용하면 단일 JOIN 쿼리 또는 2번의 쿼리(selectinload 사용 시)로 해결됩니다.

의도치 않은 전체 테이블 업데이트

ORM이 생성하는 SQL을 확인하지 않으면 예상치 못한 전체 테이블 업데이트가 발생할 수 있습니다. 예를 들어 일부 ORM은 WHERE 조건 없이 모든 레코드를 업데이트하는 SQL을 조용히 실행하기도 합니다. 개발 중에는 반드시 ORM 로깅을 활성화하여 생성되는 SQL을 눈으로 확인해야 합니다.

SQLAlchemy에서는 echo=True 옵션으로 생성 SQL을 출력할 수 있고, Prisma에서는 환경변수 DEBUG="prisma:query"를 설정합니다. JPA/Hibernate에서는 spring.jpa.show-sql=true를 설정합니다.

게시글 목록 API에서 각 게시글의 작성자 정보를 post.author로 접근하면 게시글 수만큼 추가 쿼리가 발생합니다. 개발 환경에서는 데이터가 적어 눈치채지 못하다가 운영 환경에서 게시글이 수백 개가 되면 응답 시간이 급격히 증가합니다.

해결: 관계 데이터가 필요한 쿼리에는 항상 Eager Loading(joinedload, include, JOIN FETCH)을 명시적으로 지정합니다. ORM 로깅을 개발 환경에서 항상 켜두고, 쿼리 수가 예상과 다를 경우 즉시 확인합니다.

TypeORM에서 save() 메서드를 사용할 때 id가 없는 객체를 전달하면 INSERT가 아닌 조건 없는 UPDATE가 실행될 수 있습니다. 또한 일부 ORM은 영속성 컨텍스트의 변경 감지(dirty checking) 로직이 예상과 다르게 동작해 불필요한 컬럼까지 업데이트합니다.

해결: ORM 로깅을 활성화하여 실제 실행 SQL을 확인합니다. 민감한 업데이트 작업에서는 ORM 대신 Raw SQL이나 쿼리 빌더를 사용하는 것이 안전합니다.

💡개념

ORM 동작 원리 — SQL을 코드로 추상화하는 방법

ORM 코드를 작성했는데 예상과 다른 결과가 나옵니다. 조건을 붙였는데 전체 테이블을 스캔하거나, 연관 객체를 접근했는데 추가 쿼리가 자동으로 발생합니다. ORM이 내부에서 무슨 SQL을 실행하는지 모르면 예상치 못한 동작을 만났을 때 원인을 찾기 어렵습니다. ORM이 객체 조작을 SQL로 변환하는 원리를 이해하면 실수도 줄고 디버깅도 빨라집니다.

ORM 동작 원리 — SQL을 코드로 추상화하는 방법

ORM이 생성하는 SQL

ORM의 핵심은 객체 조작 코드를 SQL로 변환하는 것입니다. 아래는 사용자의 최근 주문 목록을 가져오는 동일한 작업을 Raw SQL과 SQLAlchemy ORM으로 각각 표현한 것입니다.

SQL
SELECT u.id, u.name, o.id AS order_id, o.total_amount
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE u.email = 'alice@example.com'
ORDER BY o.created_at DESC
LIMIT 10;
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • 생성 SQLORM 호출이 실제로 어떤 SELECT/INSERT로 변환되는지 확인합니다.
  • 쿼리 횟수목록 조회에서 N+1이 발생하지 않는지 봅니다.
  • 트랜잭션 범위여러 save가 하나의 업무 단위로 묶였는지 점검합니다.
Python
from sqlalchemy.orm import Session

with Session(engine) as session:
    results = (
        session.query(User, Order)
        .join(Order, Order.user_id == User.id)
        .filter(User.email == 'alice@example.com')
        .order_by(Order.created_at.desc())
        .limit(10)
        .all()
    )

ORM은 이 Python 코드를 실행할 때 내부적으로 위의 SQL을 생성합니다. 개발자는 SQL 문법을 직접 작성하지 않아도 되며, 타입 시스템의 도움을 받아 컴파일 시점에 오류를 잡을 수 있습니다.

Active Record vs Data Mapper 패턴

ORM은 크게 두 가지 설계 패턴으로 구현됩니다.

Active Record 패턴은 모델 클래스 자체가 DB 접근 로직을 포함합니다. 모델 객체 스스로 save(), delete() 같은 DB 메서드를 가지며, Rails의 ActiveRecord가 대표적입니다. 단순 CRUD 중심의 소규모 애플리케이션에 적합합니다.

JS
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm";

@Entity()
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ unique: true })
  email: string;
}

const user = new User();
user.name = "Alice";
user.email = "alice@example.com";
await user.save();

const found = await User.findOne({ where: { email: "alice@example.com" } });
await found.remove();

Data Mapper 패턴은 도메인 모델과 DB 접근 로직을 분리합니다. 모델은 순수한 데이터 구조이고, 별도의 Repository나 EntityManager가 DB 작업을 담당합니다. 복잡한 비즈니스 도메인과 테스트 용이성이 중요한 프로젝트에 적합합니다.

Java
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(unique = true, nullable = false)
    private String email;
}

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByNameContaining(String keyword);
}

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    public User createUser(String name, String email) {
        User user = new User();
        user.setName(name);
        user.setEmail(email);
        return userRepository.save(user);
    }
}
비교 항목Active RecordData Mapper
모델 복잡도낮음 (단순)높음 (분리된 계층)
테스트 용이성낮음 (DB 의존)높음 (Mock 가능)
도메인 복잡성단순 CRUD에 적합복잡한 비즈니스 로직에 적합
대표 ORMRuby ActiveRecord, EloquentJPA/Hibernate, SQLAlchemy

ORM의 장점과 단점

장점:

  • 타입 안전성: 컴파일 시점에 컬럼명 오타 등을 잡을 수 있습니다
  • 마이그레이션 통합: 모델 변경 시 마이그레이션 파일을 자동 생성할 수 있습니다
  • DBMS 추상화: PostgreSQL에서 MySQL로 전환 시 코드 변경이 최소화됩니다
  • 개발 속도: 반복적인 CRUD 코드 작성 시간이 줄어듭니다

단점:

  • 복잡한 쿼리 한계: 복잡한 GROUP BY, 윈도우 함수, CTE 등은 ORM으로 표현이 어렵습니다
  • N+1 문제: 관계 데이터를 Lazy Loading으로 접근 시 쿼리가 폭증합니다
  • 성능 예측 어려움: 생성되는 SQL을 파악하지 않으면 의도치 않은 Full Table Scan이 발생합니다
  • 학습 곡선: ORM 자체 개념(세션, 영속성 컨텍스트 등)을 따로 학습해야 합니다
💡개념

언어별 ORM 비교 — Prisma vs SQLAlchemy vs JPA vs TypeORM

새 프로젝트를 시작하는데 팀에서 ORM을 고릅니다. 누군가는 Prisma를 쓰자고 하고, 백엔드 시니어는 JPA를 주장합니다. 각각 뭐가 다른지, 어떤 상황에서 어떤 도구가 적합한지 기준이 없으면 결정이 어렵습니다. 도구마다 N+1 처리 방식, 타입 안전성, 마이그레이션 전략이 다릅니다. 비교 기준을 알면 팀과 프로젝트에 맞는 선택이 가능합니다.

ORM 도구 비교표

언어, 패턴, N+1 대응 방식, 마이그레이션 전략, 타입 안전성은 ORM 선택에서 가장 중요한 기준입니다.

항목PrismaSQLAlchemyJPA/HibernateTypeORM
언어Node.js/TSPythonJavaNode.js/TS
패턴Data Mapper혼합Data Mapper혼합
타입 안전성최상 (자동 생성 타입)보통보통높음
N+1 대응include 옵션joinedload / selectinloadJOIN FETCH / @EntityGraphrelations 옵션
마이그레이션내장 (prisma migrate)Alembic (별도)Flyway / Liquibase내장
복잡 쿼리제한적 ($queryRaw 사용)강력 (Core API)강력 (JPQL/NativeQuery)보통
학습 난이도낮음중간높음중간
성숙도최신매우 높음매우 높음높음

Prisma (Node.js/TypeScript)

Prisma는 스키마 파일로부터 타입 안전한 클라이언트를 자동 생성하는 최신 ORM입니다. Data Mapper 패턴을 따르며, TypeScript 환경에서 최고의 개발 경험을 제공합니다. npx prisma migrate dev 명령으로 마이그레이션을 생성하고 적용하며, schema.prisma의 변경 사항을 감지해 SQL 파일을 자동으로 만듭니다.

PRISMA
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        Int    @id @default(autoincrement())
  title     String
  content   String?
  author    User   @relation(fields: [authorId], references: [id])
  authorId  Int
}
TS
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

const user = await prisma.user.create({
  data: { email: 'alice@example.com', name: 'Alice' }
});

const usersWithPosts = await prisma.user.findMany({
  include: { posts: true }
});

await prisma.user.update({
  where: { id: user.id },
  data: { name: 'Alice Updated' }
});

await prisma.user.delete({ where: { id: user.id } });

SQLAlchemy (Python)

SQLAlchemy는 Python 생태계에서 가장 강력한 ORM으로, 저수준 Core API와 고수준 ORM 레이어를 모두 제공합니다. 마이그레이션은 Alembic을 별도로 사용합니다(alembic revision --autogenerate, alembic upgrade head).

Python
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, relationship, Session

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True, autoincrement=True)
    email = Column(String(255), unique=True, nullable=False)
    name = Column(String(100), nullable=False)
    posts = relationship("Post", back_populates="author", lazy="select")

class Post(Base):
    __tablename__ = "posts"
    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String(255), nullable=False)
    author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    author = relationship("User", back_populates="posts")

engine = create_engine("postgresql+psycopg2://user:pass@localhost/db")

with Session(engine) as session:
    user = User(email="bob@example.com", name="Bob")
    session.add(user)
    session.commit()

    stmt = select(User).where(User.email == "bob@example.com")
    result = session.execute(stmt).scalar_one()

JPA/Hibernate (Java)

JPA(Java Persistence API)는 Java 표준 ORM 명세이고, Hibernate는 가장 널리 쓰이는 구현체입니다. Spring Boot와 함께 사용할 때 생산성이 극대화됩니다. @Query로 JPQL 커스텀 쿼리를 작성하고, N+1 해결에는 JOIN FETCH를 사용합니다.

Java
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Post> posts = new ArrayList<>();
}

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);

    @Query("SELECT u FROM User u JOIN FETCH u.posts WHERE u.id = :id")
    Optional<User> findByIdWithPosts(@Param("id") Long id);
}

TypeORM (Node.js/TypeScript)

TypeORM은 Active Record와 Data Mapper 패턴을 모두 지원하며, TypeScript 데코레이터 기반으로 작동합니다. relations 옵션으로 Eager Loading을 명시적으로 지정합니다.

TS
import { Entity, PrimaryGeneratedColumn, Column,
         OneToMany, Repository } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;

  @OneToMany(() => Post, (post) => post.author, { lazy: true })
  posts: Promise<Post[]>;
}

const userRepo: Repository<User> = dataSource.getRepository(User);

const user = userRepo.create({ email: 'carol@example.com' });
await userRepo.save(user);

const found = await userRepo.findOne({
  where: { email: 'carol@example.com' },
  relations: { posts: true }
});
💼
실무 맥락신규 Node.js 프로젝트에서 Prisma vs TypeORM 선택
현업 패턴

TypeScript 기반 신규 프로젝트에서 Prisma를 선택하는 기준은 팀의 SQL 숙련도가 낮고 빠른 프로토타이핑이 필요할 때입니다. Prisma는 schema.prisma 파일 하나로 타입 생성과 마이그레이션을 통합 관리하므로 온보딩이 빠릅니다. 반면 기존 레거시 DB 스키마를 그대로 사용해야 하거나, 복잡한 동적 쿼리 빌딩이 많다면 TypeORM의 QueryBuilder가 더 유연합니다. Prisma는 Raw SQL 탈출구($queryRaw)를 제공하므로 복잡한 집계 쿼리는 그쪽으로 처리하고, 일반 CRUD는 Prisma를 사용하는 하이브리드 전략이 현장에서 가장 많이 쓰입니다.

실무에서는 언어와 팀의 기술 스택에 따라 선택하되, 복잡한 리포팅 쿼리나 대용량 배치 작업은 ORM이 아닌 Raw SQL을 사용하는 것이 합리적입니다. 좋은 ORM 활용 전략은 "단순 CRUD는 ORM, 복잡한 집계는 SQL"입니다.

다음 모듈에서는 Flyway와 Liquibase를 활용한 DB 마이그레이션 버전 관리와 운영 환경 안전 배포 전략을 다룹니다.

지식 확인

퀴즈 — 5문제

Q1

ORM에서 Active Record 패턴과 Data Mapper 패턴의 핵심 차이는?

Q2

Prisma로 DB 스키마를 변경한 후 애플리케이션 코드에 즉시 반영하려면 어떤 순서로 실행해야 하는가?

Q3

아래 Python 코드를 실행했더니 DB 쿼리가 51번 발생했습니다. 원인과 해결책은? posts = db.query(Post).all() # 게시글 50개 for post in posts: print(post.author.name) # 각 게시글 작성자 이름

Q4

SQLAlchemy ORM으로 활성 사용자(is_active=True)만 이름순으로 조회해야 합니다. 1.x ORM 스타일로 올바른 코드는?

Q5

JPA로 개발된 서비스에서 게시글 목록 API를 호출하면 불필요하게 많은 쿼리가 발생합니다. 코드를 보니 @OneToMany(fetch = FetchType.EAGER)가 설정돼 있었습니다. 문제와 해결책은?

0 / 5 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Ubuntu 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

40📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

database중급 · 60
[Database] JSONB 비정형 데이터 다루기와 전문 검색(Full-text Search)
Database 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점