ORM과 SQL의 균형
ORM(Object-Relational Mapping)은 SQL을 직접 작성하지 않고 객체 지향 방식으로 데이터베이스를 다루게 해주는 기술입니다. 객체와 관계형 데이터베이스 사이의 임피던스 불일치(Impedance Mismatch)를 해결하려는 목적으로 탄생했습니다. 편리하지만 만능 도구는 아닙니다. 언제 ORM을 쓰고, 언제 SQL을 직접 작성해야 하는지 정확히 판단할 수 있어야 합니다.
임피던스 불일치 문제
객체 모델과 관계형 모델 사이에는 근본적인 구조 차이가 존재합니다. 이를 임피던스 불일치라고 부릅니다.
1. 세분성 (Granularity)
객체: Address를 별도 클래스로 분리
RDB: users 테이블에 address 컬럼으로 포함
2. 상속 (Inheritance)
객체: Employee → Manager, Developer 상속
RDB: 상속 개념 없음 → 단일 테이블 / 조인 전략 필요
3. 동일성 (Identity)
객체: == (메모리 주소), equals() (내용 비교) 두 가지
RDB: PK 하나로 식별
4. 연관 관계 (Associations)
객체: 참조(단방향/양방향)
RDB: 외래 키(항상 양방향 접근 가능)
5. 탐색 (Navigation)
객체: order.getUser().getAddress() 그래프 탐색
RDB: JOIN으로 한 번에 조회해야 효율적ORM은 이 다섯 가지 불일치를 자동으로 매핑해주는 역할을 합니다. 하지만 매핑 과정에서 성능 저하가 발생할 수 있으며, 복잡한 관계일수록 ORM이 생성하는 SQL의 품질이 떨어질 수 있습니다.
ORM의 장단점
| 장점 | 단점 |
|---|---|
| SQL 작성 없이 객체로 CRUD | 복잡한 쿼리 표현 어려움 |
| DB 종류 변경 시 코드 변경 최소 | 생성되는 SQL 비효율 가능성 |
| 타입 안전성 | N+1 문제 발생 위험 |
| 마이그레이션 자동화 | 학습 곡선 |
| 보일러플레이트 감소 | 성능 튜닝이 어려움 |
| SQL Injection 자동 방지 | 추상화 leaky 현상 |
| 캐시(1차/2차) 자동 관리 | 디버깅 난이도 증가 |
주요 ORM 프레임워크 비교
각 언어 생태계에서 대표적인 ORM 프레임워크가 존재합니다.
┌─────────────────────────────────────────────────────────────┐
│ Full ORM (Active Record / Data Mapper) │
│ │
│ JPA/Hibernate │ Django ORM │ TypeORM │ Sequelize │
│ (Java) │ (Python) │ (TS/JS) │ (JS) │
│ │
│ * 엔티티 매핑, 관계 관리, 변경 감지 │
│ * 1차/2차 캐시, Lazy/Eager Loading │
│ * 마이그레이션 자동 생성 │
├─────────────────────────────────────────────────────────────┤
│ Lightweight ORM / Query Builder │
│ │
│ Prisma │ Knex.js │ MyBatis │ SQLAlchemy Core │
│ (TS) │ (JS) │ (Java) │ (Python) │
│ │
│ * SQL에 더 가까운 인터페이스 │
│ * 세밀한 쿼리 제어 가능 │
│ * 학습 곡선이 낮음 │
└─────────────────────────────────────────────────────────────┘| 프레임워크 | 패턴 | 특징 |
|---|---|---|
| JPA/Hibernate | Data Mapper | 영속성 컨텍스트, 변경 감지, JPQL |
| Django ORM | Active Record | QuerySet 체이닝, 간결한 문법 |
| TypeORM | Data Mapper + Active Record | 데코레이터 기반, TypeScript 친화 |
| Prisma | 독자 패턴 | 스키마 우선, 타입 자동 생성, 직관적 API |
| MyBatis | SQL Mapper | XML/어노테이션으로 SQL 직접 매핑 |
| SQLAlchemy | Data Mapper | Core(쿼리빌더) + ORM 이중 구조 |
JPA 영속성 컨텍스트
JPA(Java Persistence API)는 가장 널리 쓰이는 ORM 표준입니다. 핵심 개념인 영속성 컨텍스트를 이해해야 합니다.
┌── EntityManager ────────────────────────────────┐
│ │
│ 영속성 컨텍스트 (Persistence Context) │
│ ┌─────────────────────────────────────┐ │
│ │ 1차 캐시 (Identity Map) │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ @Id=1 → User("홍길동") │ │ │
│ │ │ @Id=2 → User("김철수") │ │ │
│ │ └──────────────────────────────┘ │ │
│ │ │ │
│ │ 쓰기 지연 SQL 저장소 │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ INSERT INTO users ... │ │ │
│ │ │ UPDATE users SET ... │ │ │
│ │ └──────────────────────────────┘ │ │
│ │ │ │
│ │ 변경 감지 (Dirty Checking) │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ 스냅샷 vs 현재 엔티티 비교 │ │ │
│ │ └──────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
│ │
│ flush() → DB에 SQL 전송 │
│ commit() → 트랜잭션 확정 │
└─────────────────────────────────────────────────┘엔티티의 생명주기는 4가지 상태로 구분됩니다.
비영속 (new/transient)
│ persist()
▼
영속 (managed) ←── merge()
│ ▲
│ detach() │
▼ │
준영속 (detached) ──────┘
│
│ remove()
▼
삭제 (removed)
* 영속 상태의 엔티티만 변경 감지 대상
* 준영속 엔티티를 수정하면 merge()로 다시 영속화 필요
* flush() 시점에 변경 감지 → UPDATE SQL 자동 생성N+1 문제
ORM에서 가장 흔하고 치명적인 성능 문제입니다. 연관 엔티티를 조회할 때 추가 쿼리가 대량 발생합니다.
주문 목록 조회 (10건)
1. SELECT * FROM orders LIMIT 10; ← 1번 쿼리 (주문 목록)
2. SELECT * FROM users WHERE id = 1; ┐
3. SELECT * FROM users WHERE id = 2; │
4. SELECT * FROM users WHERE id = 3; │ N번 추가 쿼리!
5. SELECT * FROM users WHERE id = 4; │ (각 주문의 사용자 조회)
... │
11. SELECT * FROM users WHERE id = 10; ┘
총 11번 쿼리 (1 + N)
→ 주문이 1000건이면 1001번 쿼리!N+1 문제가 위험한 이유는 데이터가 적을 때는 눈에 띄지 않다가, 운영 환경에서 데이터가 쌓이면 갑자기 성능이 급격히 저하되기 때문입니다. 테스트 환경에서 5건이면 6번 쿼리로 문제없지만, 운영에서 10만 건이면 10만 1번 쿼리가 발생합니다.
해결 방법 1: Eager Loading (즉시 로딩)
연관 엔티티를 함께 조회하여 쿼리 수를 줄입니다.
방법 A: JOIN으로 한 번에 (Fetch Join)
SELECT o.*, u.* FROM orders o
JOIN users u ON o.user_id = u.id
LIMIT 10;
→ 1번 쿼리
방법 B: IN 절로 배치 조회
1. SELECT * FROM orders LIMIT 10;
2. SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
→ 2번 쿼리────────────────────────────────────────────────
JPA (Java) - Fetch Join
────────────────────────────────────────────────
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
-- 또는 @EntityGraph
@EntityGraph(attributePaths = {"user"})
List<Order> findAll();
-- 또는 @BatchSize (IN절 배치)
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
────────────────────────────────────────────────
Django (Python) - select_related / prefetch_related
────────────────────────────────────────────────
# FK/OneToOne → JOIN (select_related)
Order.objects.select_related('user').all()
# ManyToMany/Reverse FK → 별도 쿼리 (prefetch_related)
Order.objects.prefetch_related('items').all()
────────────────────────────────────────────────
TypeORM (TypeScript) - relations / QueryBuilder
────────────────────────────────────────────────
// relations 옵션
orderRepo.find({ relations: ['user'] })
// QueryBuilder LEFT JOIN
orderRepo.createQueryBuilder('order')
.leftJoinAndSelect('order.user', 'user')
.getMany();
────────────────────────────────────────────────
Prisma (TypeScript) - include
────────────────────────────────────────────────
prisma.order.findMany({
include: { user: true, items: true }
})해결 방법 2: Lazy Loading 최적화
Lazy Loading 자체를 없앨 수는 없지만, 배치 사이즈를 설정하면 N번이 아닌 N/batch_size번으로 줄일 수 있습니다.
batch_size = 100, 데이터 1000건일 때:
Lazy (기본): 1 + 1000 = 1001번 쿼리
Batch(100): 1 + 10 = 11번 쿼리
Fetch Join: 1번 쿼리 (단, 데이터 뻥튀기 주의)
JPA 글로벌 설정:
spring.jpa.properties.hibernate.default_batch_fetch_size=100해결 방법 3: DTO 직접 조회
엔티티가 아닌 DTO로 필요한 컬럼만 조회하면 N+1 문제 자체가 발생하지 않습니다.
@Query("SELECT new com.example.OrderDto(o.id, o.amount, u.name)
FROM Order o JOIN o.user u")
List<OrderDto> findOrderDtos();
장점
* N+1 문제 원천 차단
* 필요한 컬럼만 SELECT → 네트워크/메모리 절약
* 실무에서 복잡한 조회 화면에 권장
단점
* 엔티티가 아니므로 변경 감지 불가
* 화면별 DTO 클래스 증가Lazy vs Eager Loading 전략
로딩 전략 선택은 성능에 직접적인 영향을 미칩니다.
┌──────────────┬──────────────────────┬──────────────────────┐
│ │ Lazy Loading │ Eager Loading │
├──────────────┼──────────────────────┼──────────────────────┤
│ 시점 │ 실제 접근 시 조회 │ 부모 조회 시 함께 │
│ 쿼리 수 │ 접근할 때마다 발생 │ JOIN 1번 또는 IN 2번 │
│ 초기 로딩 │ 빠름 │ 느릴 수 있음 │
│ N+1 위험 │ 높음 │ 없음 │
│ 메모리 │ 필요한 것만 로드 │ 전체 로드 │
│ JPA 기본값 │ @ManyToOne: EAGER │ @OneToMany: LAZY │
│ 권장 │ 글로벌 기본값 │필요한 곳만 Fetch Join│
└──────────────┴──────────────────────┴──────────────────────┘
실무 전략:
* 글로벌: 모든 연관관계를 LAZY로 설정
* 필요한 곳: JPQL Fetch Join 또는 @EntityGraph
* API 응답: DTO 직접 조회| 연관관계 | JPA 기본값 | 권장 설정 |
|---|---|---|
| @ManyToOne | EAGER | LAZY 변경 필요 |
| @OneToOne | EAGER | LAZY 변경 필요 |
| @OneToMany | LAZY | LAZY 유지 |
| @ManyToMany | LAZY | LAZY 유지 |
Raw SQL이 필요한 시점
ORM으로 모든 것을 해결할 수는 없습니다. 다음 상황에서는 Raw SQL을 직접 작성해야 합니다.
| 상황 | ORM 한계 | 대안 |
|---|---|---|
| 복잡한 집계/분석 | Window 함수 표현 제한 | Raw SQL |
| 대량 데이터 배치 처리 | 행 단위 처리로 느림 | Bulk Insert/Update SQL |
| DB 고유 기능 | Oracle 힌트, Flashback 등 | Native Query |
| 성능 크리티컬 쿼리 | 생성 SQL 최적화 어려움 | 직접 작성 + 실행 계획 확인 |
| 복잡한 조인 (3개 이상) | 코드 가독성 저하 | Raw SQL |
| 재귀 쿼리(CTE) | ORM 지원 부족 | WITH RECURSIVE SQL |
| 대량 INSERT | 건별 INSERT 비효율 | BULK INSERT / COPY |
CRUD 기본: ORM 사용 80%
복잡한 조회: Raw SQL 15%
배치/분석: Raw SQL 5%각 ORM의 Raw SQL 사용법
────────────────────────────────────────────────
JPA - Native Query
────────────────────────────────────────────────
@Query(value = "SELECT * FROM users WHERE email = :email",
nativeQuery = true)
User findByEmail(@Param("email") String email);
// EntityManager 직접 사용
List<Object[]> results = em.createNativeQuery(
"SELECT u.name, COUNT(o.id) " +
"FROM users u JOIN orders o ON u.id = o.user_id " +
"GROUP BY u.name HAVING COUNT(o.id) > 5"
).getResultList();
────────────────────────────────────────────────
Django - raw() / connection.cursor()
────────────────────────────────────────────────
# raw() - 모델 인스턴스 반환
users = User.objects.raw(
'SELECT * FROM users WHERE email = %s', [email]
)
# cursor - 완전한 Raw SQL
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT COUNT(*) FROM orders")
row = cursor.fetchone()
────────────────────────────────────────────────
Prisma - $queryRaw
────────────────────────────────────────────────
const users = await prisma.$queryRaw`
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.name
HAVING COUNT(o.id) > 5
`;
────────────────────────────────────────────────
TypeORM - query()
────────────────────────────────────────────────
const results = await dataSource.query(
`SELECT * FROM users WHERE email = $1`, [email]
);ORM 성능 최적화 체크리스트
실무에서 ORM 사용 시 반드시 확인해야 할 성능 관련 항목입니다.
1. 쿼리 로그 확인
┌─────────────────────────────────────────────┐
│ spring.jpa.show-sql=true │
│ spring.jpa.properties.hibernate.format_sql │
│ =true │
│ logging.level.org.hibernate.SQL=DEBUG │
│ logging.level.org.hibernate.type │
│ .descriptor.sql=TRACE ← 바인딩 파라미터 │
└─────────────────────────────────────────────┘
→ 개발 중 항상 켜두고 N+1 발생 여부 확인
2. 페이징 + Fetch Join 주의
* 컬렉션(1:N) Fetch Join + 페이징 시
→ Hibernate가 메모리에서 페이징 (위험!)
* 해결: @BatchSize 또는 DTO 조회
3. OSIV (Open Session In View) 설정
* OSIV=true: 뷰 렌더링까지 영속성 컨텍스트 유지
→ 편리하지만 DB 커넥션 오래 점유
* OSIV=false: 서비스 계층에서만 영속성 컨텍스트
→ 커넥션 빨리 반환, LazyInitException 주의
* API 서버 권장: OSIV=false
4. 벌크 연산
* 수천 건 UPDATE → em.createQuery().executeUpdate()
* 벌크 연산 후 영속성 컨텍스트 초기화 필수
→ em.flush(); em.clear();
5. 2차 캐시 활용
* 자주 조회, 변경 적은 데이터 → 2차 캐시 적용
* @Cacheable, EhCache / Redis 연동Query Builder 패턴
Full ORM과 Raw SQL의 중간 지대로, 프로그래밍 방식으로 SQL을 조립하되 SQL의 구조를 유지하는 패턴입니다.
────────────────────────────────────────────────
Knex.js (JavaScript)
────────────────────────────────────────────────
const orders = await knex('orders')
.join('users', 'orders.user_id', 'users.id')
.select('orders.id', 'users.name', 'orders.amount')
.where('orders.amount', '>', 1000)
.orderBy('orders.created_at', 'desc')
.limit(20);
// 생성 SQL:
// SELECT orders.id, users.name, orders.amount
// FROM orders
// JOIN users ON orders.user_id = users.id
// WHERE orders.amount > 1000
// ORDER BY orders.created_at DESC
// LIMIT 20
────────────────────────────────────────────────
SQLAlchemy Core (Python)
────────────────────────────────────────────────
from sqlalchemy import select, func
stmt = (
select(orders.c.id, users.c.name,
func.sum(order_items.c.price).label('total'))
.join(users, orders.c.user_id == users.c.id)
.join(order_items, orders.c.id == order_items.c.order_id)
.group_by(orders.c.id, users.c.name)
.having(func.sum(order_items.c.price) > 10000)
)
────────────────────────────────────────────────
QueryDSL (Java) - JPA와 함께 사용
────────────────────────────────────────────────
List<OrderDto> results = queryFactory
.select(Projections.constructor(OrderDto.class,
order.id, user.name, order.amount))
.from(order)
.join(order.user, user)
.where(order.amount.gt(1000)
.and(order.status.eq(OrderStatus.COMPLETED)))
.orderBy(order.createdAt.desc())
.offset(0).limit(20)
.fetch();Query Builder의 장점은 SQL 구조를 유지하면서도 타입 안전성과 동적 쿼리 조립이 가능하다는 것입니다. 검색 조건이 동적으로 변하는 화면에서 특히 유용합니다.
마이그레이션 관리
스키마 변경을 코드로 관리하고, 버전 관리 시스템과 함께 추적합니다. 마이그레이션은 데이터베이스 스키마의 형상 관리 도구입니다.
| 도구 | 언어/프레임워크 | 방식 |
|---|---|---|
| Flyway | Java (범용) | SQL 기반 마이그레이션 |
| Liquibase | Java (범용) | XML/YAML/JSON 변경셋 |
| Prisma Migrate | TypeScript (Prisma) | 스키마 diff 기반 |
| TypeORM Migrations | TypeScript (TypeORM) | TypeScript 코드 |
| Alembic | Python (SQLAlchemy) | Python 스크립트 |
| Django Migrations | Python (Django) | 모델 diff 자동 생성 |
개발자가 스키마 변경 시
1. 마이그레이션 파일 생성
└─ V001__create_users_table.sql
└─ V002__add_email_to_users.sql
└─ V003__create_orders_table.sql
2. 버전 관리 (Git)에 커밋
3. 배포 시 자동 적용
└─ 현재 DB 버전 확인 (V002)
└─ V003 실행
└─ 버전 기록 테이블 업데이트Flyway vs Liquibase 비교
┌─────────────────┬────────────────────┬────────────────────┐
│ │ Flyway │ Liquibase │
├─────────────────┼────────────────────┼────────────────────┤
│마이그레이션 형식│ SQL 파일 │ XML/YAML/SQL │
│ 버전 관리 │ 파일명 순서 │ changeset ID │
│ 롤백 │ 유료(Pro) │ 무료 지원 │
│ DB 독립성 │ SQL이므로 DB 종속 │ 추상 형식이면 독립 │
│ 학습 곡선 │ 매우 낮음 │ 보통 │
│ 잠금 관리 │ 내장 │ 내장 │
│ Spring Boot │ 자동 설정 │ 자동 설정 │
│ 권장 상황 │ 단일 DB 프로젝트 │ 멀티 DB 지원 필요 │
└─────────────────┴────────────────────┴────────────────────┘
Flyway 명명 규칙:
V1__Create_users.sql (버전 마이그레이션)
V2__Add_email.sql (순서대로 실행)
R__Views.sql (반복 실행 가능)운영 환경 마이그레이션 주의사항
운영 DB 마이그레이션은 서비스 중단 없이 수행해야 합니다. 잘못된 마이그레이션은 장애로 직결됩니다.
| 작업 | 위험 | 안전한 방법 |
|---|---|---|
| 컬럼 추가 | 보통 안전 | ALTER TABLE ADD (NOT NULL이면 기본값 필수) |
| 컬럼 삭제 | 위험 | 코드에서 사용 중단 → 다음 배포에서 삭제 (2단계) |
| 컬럼 이름 변경 | 위험 | 새 컬럼 추가 → 데이터 복사 → 옛 컬럼 삭제 (3단계) |
| 컬럼 타입 변경 | 위험 | 새 컬럼 추가 → 트리거로 동기화 → 전환 |
| 테이블 삭제 | 매우 위험 | 백업 → 코드에서 참조 제거 확인 → 삭제 |
| 인덱스 생성 | 락 가능 | ONLINE / CONCURRENTLY 옵션 사용 |
| NOT NULL 추가 | 위험 | DEFAULT 지정 → 기존 NULL 데이터 업데이트 → 제약 추가 |
──── 1단계 배포 ────
ALTER TABLE users ADD COLUMN user_name VARCHAR(100);
UPDATE users SET user_name = name;
-- 코드: name과 user_name 두 컬럼 모두 쓰기
──── 2단계 배포 ────
-- 코드: user_name만 사용하도록 전환
-- 트리거: name에 쓰면 user_name에도 복사 (안전장치)
──── 3단계 배포 ────
ALTER TABLE users DROP COLUMN name;
-- 트리거 제거-- Oracle: ONLINE 옵션
CREATE INDEX idx_orders_date ON orders(order_date) ONLINE;
-- PostgreSQL: CONCURRENTLY 옵션 (락 없이)
CREATE INDEX CONCURRENTLY idx_orders_date
ON orders(order_date);
-- MySQL 5.6+: ALGORITHM=INPLACE
ALTER TABLE orders ADD INDEX idx_orders_date(order_date),
ALGORITHM=INPLACE, LOCK=NONE;실무에서의 ORM과 SQL 균형 전략
프로젝트 상황에 따라 ORM과 SQL의 비중을 조절해야 합니다.
┌─────────────────────────────────────────────────────────────┐
│ 계층별 권장 접근법 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Command (CUD) │
│ ├─ 단순 CRUD → ORM 엔티티 (변경 감지 활용) │
│ ├─ 벌크 처리 → Native Query / JDBC Batch │
│ └─ 복잡한 비즈니스 → ORM + 도메인 로직 │
│ │
│ Query (R) │
│ ├─ 단순 조회 → ORM (Fetch Join) │
│ ├─ 복잡한 검색 → QueryDSL / Query Builder │
│ ├─ 통계/집계 → Native SQL │
│ └─ 대시보드 → 전용 View 또는 Materialized View │
│ │
│ CQRS 패턴 적용 시: │
│ ├─ Command 측: ORM (도메인 모델 중심) │
│ └─ Query 측: QueryDSL / Raw SQL (성능 중심) │
│ │
└─────────────────────────────────────────────────────────────┘SQL Injection 방지
ORM은 기본적으로 Prepared Statement를 사용하여 SQL Injection을 방지합니다. 하지만 Raw SQL 사용 시 주의가 필요합니다.
────────────────────────────────────────────────
위험한 코드 (문자열 연결)
────────────────────────────────────────────────
// 절대 하면 안 됨!
String sql = "SELECT * FROM users WHERE name = '"
+ userInput + "'";
// userInput이 "'; DROP TABLE users; --" 이면 재앙
────────────────────────────────────────────────
안전한 코드 (파라미터 바인딩)
────────────────────────────────────────────────
// JPA
em.createNativeQuery(
"SELECT * FROM users WHERE name = :name")
.setParameter("name", userInput);
// JDBC PreparedStatement
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE name = ?");
ps.setString(1, userInput);
// Prisma (자동 이스케이프)
prisma.$queryRaw`SELECT * FROM users
WHERE name = ${userInput}`;
핵심 원칙:
* 사용자 입력을 SQL 문자열에 직접 연결하지 않기
* 항상 파라미터 바인딩(Prepared Statement) 사용
* ORM이 생성하는 쿼리는 기본적으로 안전
* Raw SQL 작성 시 반드시 파라미터 바인딩 확인ORM 선택 기준
프로젝트에 적합한 ORM을 선택하는 기준입니다.
프로젝트 시작
│
├─ Java/Kotlin?
│ ├─ 복잡한 도메인 → JPA/Hibernate + QueryDSL
│ ├─ SQL 직접 제어 → MyBatis
│ └─ 두 가지 혼합 → JPA(CUD) + MyBatis(R)
│
├─ TypeScript/JavaScript?
│ ├─ 타입 안전성 중시 → Prisma
│ ├─ Active Record 선호 → TypeORM
│ ├─ SQL 친화적 → Knex.js
│ └─ 경량 프로젝트 → Sequelize
│
├─ Python?
│ ├─ Django 사용 → Django ORM
│ ├─ 고급 제어 → SQLAlchemy (Core + ORM)
│ └─ 비동기 필요 → SQLAlchemy 2.0 + asyncio
│
└─ 성능 최우선?
└─ Query Builder 또는 Raw SQL 중심 설계| 기준 | Full ORM | Query Builder | Raw SQL |
|---|---|---|---|
| 개발 속도 | 빠름 | 보통 | 느림 |
| 성능 제어 | 제한적 | 좋음 | 완전 |
| 학습 곡선 | 높음 | 보통 | 낮음 (SQL 알면) |
| 유지보수 | 좋음 | 보통 | SQL 관리 필요 |
| DB 독립성 | 높음 | 보통 | 없음 |
| 복잡한 쿼리 | 어려움 | 가능 | 자유로움 |
실무 트러블슈팅 사례
ORM 사용 시 실무에서 자주 마주치는 문제와 해결 방안입니다.
문제 1: LazyInitializationException (JPA)
원인: 트랜잭션 밖에서 Lazy 프록시 접근
해결: Fetch Join, @Transactional 범위 확인,
DTO 변환을 서비스 계층에서 처리
문제 2: MultipleBagFetchException (JPA)
원인: 2개 이상 컬렉션을 동시에 Fetch Join
해결: 하나만 Fetch Join + 나머지 @BatchSize,
또는 Set으로 변경
문제 3: 카르테시안 곱 (모든 ORM)
원인: 1:N 관계 여러 개 JOIN 시 결과 뻥튀기
해결: 서브쿼리 분리, 별도 쿼리로 조회 후
애플리케이션에서 조립
문제 4: 변경 감지 vs 벌크 연산 충돌 (JPA)
원인: 벌크 UPDATE 후 영속성 컨텍스트 불일치
해결: 벌크 연산 후 em.flush(); em.clear();
문제 5: 커넥션 풀 고갈 (모든 ORM)
원인: OSIV=true + 느린 외부 API 호출
해결: OSIV=false, 또는 @Transactional 범위 최소화ORM과 SQL 균형 종합 정리
┌─────────────────────────────────────────────────────────────┐
│ ORM과 SQL 균형의 5가지 원칙 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. ORM을 기본으로, SQL을 무기로 │
│ → CRUD는 ORM, 복잡한 조회는 SQL │
│ │
│ 2. 생성되는 SQL을 항상 확인하라 │
│ → 쿼리 로그를 개발 중 항상 켜둘 것 │
│ │
│ 3. N+1은 반드시 잡아라 │
│ → Fetch Join, BatchSize, DTO 조회 활용 │
│ │
│ 4. 추상화를 맹신하지 마라 │
│ → ORM이 생성하는 SQL의 실행 계획 확인 │
│ → 느린 쿼리는 Raw SQL로 대체 │
│ │
│ 5. 마이그레이션은 코드로 관리하라 │
│ → Flyway/Liquibase/Prisma Migrate 사용 │
│ → 운영 DB 변경은 반드시 안전한 절차 준수 │
│ │
└─────────────────────────────────────────────────────────────┘| 체크 항목 | 확인 |
|---|---|
| 글로벌 Lazy Loading 설정했는가 | □ |
| N+1 쿼리 발생하지 않는가 | □ |
| 벌크 연산 후 영속성 컨텍스트 초기화하는가 | □ |
| 쿼리 로그로 생성 SQL 확인하는가 | □ |
| 복잡한 조회에 Raw SQL/QueryDSL 사용하는가 | □ |
| OSIV 설정을 의도적으로 결정했는가 | □ |
| 마이그레이션 도구를 사용하는가 | □ |
| Raw SQL에 파라미터 바인딩을 사용하는가 | □ |
| 운영 마이그레이션 시 안전 절차를 따르는가 | □ |
| 인덱스 생성 시 ONLINE 옵션을 사용하는가 | □ |
다음 절에서는 데이터베이스를 넘어 시스템 전체, 즉 운영체제와 네트워크에서의 데이터 관리 원리를 다루게 됩니다.