ORM과 SQL의 균형
ORM(Object-Relational Mapping)은 객체 모델과 관계형 데이터베이스 사이를 매핑해 주는 기술입니다. SQL을 완전히 숨기는 마법이 아니라, 반복적인 CRUD와 상태 관리를 줄여 주는 추상화입니다. 실무에서는 ORM으로 생산성을 얻고, SQL로 병목을 통제하는 균형이 중요합니다.
ORM을 잘 쓰려면 “객체로 작성했으니 SQL을 몰라도 된다”가 아니라, ORM이 어떤 SQL을 만들고 언제 실행하는지까지 확인해야 합니다.
임피던스 불일치 문제
객체 모델과 관계형 모델은 표현 방식이 다릅니다. 객체는 참조와 그래프를 자연스럽게 다루지만, 관계형 데이터베이스는 테이블, 행, 키, 조인을 중심으로 데이터를 표현합니다.
ORM은 이 차이를 줄여 주지만, 매핑 과정에서 예상보다 많은 SQL, 조인 결과 중복, 지연 로딩 쿼리, 캐시 불일치 같은 비용이 발생할 수 있습니다. 그래서 매핑 자체보다 생성되는 SQL과 트랜잭션 경계를 함께 보는 습관이 필요합니다.
ORM의 장단점
| 장점 | 주의점 |
|---|---|
| 반복적인 CRUD 코드 감소 | 복잡한 조회는 생성 SQL을 반드시 확인 |
| 객체 그래프와 변경 감지 지원 | 지연 로딩, N+1, 카르테시안 곱 위험 |
| 파라미터 바인딩을 쉽게 적용 | Raw SQL을 문자열 연결로 만들면 여전히 위험 |
| 1차 캐시와 동일성 보장 | 벌크 연산 후 영속성 컨텍스트 불일치 가능 |
| DB 독립성을 일부 확보 | DB 고유 기능·힌트·온라인 DDL은 별도 처리 |
| 테스트와 도메인 모델 작성 편의 | 트랜잭션 범위와 커넥션 점유 시간을 봐야 함 |
| 마이그레이션 도구와 함께 운용 가능 | ORM 자동 DDL을 운영 마이그레이션으로 쓰면 위험 |
주요 ORM 프레임워크 비교
각 생태계의 ORM은 같은 문제를 다루지만, 추상화 수준과 SQL 제어 방식이 다릅니다.
| 프레임워크 | 성격 | 특징 |
|---|---|---|
| JPA/Hibernate | JPA 표준 + 구현체 | 영속성 컨텍스트, 변경 감지, JPQL/HQL |
| Django ORM | Active Record 성향 | QuerySet 체이닝, 모델 중심 개발 |
| TypeORM | Data Mapper + Active Record | 데코레이터 기반, QueryBuilder 제공 |
| Prisma | 스키마 우선 ORM | 타입 생성, 관계 API, raw query 지원 |
| MyBatis | SQL Mapper | SQL을 직접 작성하고 객체에 매핑 |
| SQLAlchemy | Data Mapper + SQL Expression | ORM과 Core 쿼리빌더를 함께 제공 |
JPA/Jakarta Persistence 영속성 컨텍스트
JPA는 현재 Jakarta Persistence로 이어지는 Java/Jakarta 생태계의 ORM 표준입니다. 영속성 컨텍스트는 엔티티를 관리하는 1차 캐시입니다. 같은 트랜잭션 안에서 같은 엔티티를 다시 조회하면 동일한 객체 인스턴스를 돌려주고, 변경 감지를 통해 flush 시점에 DB와 동기화합니다. 실제 확정은 commit에서 일어나며, AUTO flush 모드에서는 commit 전 쿼리 실행 전에 flush될 수도 있습니다.
엔티티의 생명주기는 4가지 상태로 구분됩니다.
영속성 컨텍스트를 이해하면 “왜 SELECT가 한 번 더 나갔는가”, “왜 UPDATE가 늦게 실행되는가”, “왜 벌크 UPDATE 뒤 값이 낡아 보이는가”를 설명할 수 있습니다.
N+1 문제
ORM에서 가장 자주 만나는 성능 문제 중 하나가 N+1입니다. 목록을 한 번 조회한 뒤, 각 행의 연관 데이터를 접근할 때 추가 쿼리가 반복적으로 발생하는 패턴입니다.
N+1은 데이터가 적을 때는 잘 보이지 않습니다. 하지만 운영에서 목록 크기가 커지고 네트워크 지연이 붙으면, 1번의 조회가 수십·수백 번의 DB 왕복으로 바뀌어 응답 시간이 급격히 늘어납니다.
해결 방법 1: Fetch Join / EntityGraph
연관 데이터를 처음부터 함께 읽어야 하는 화면이라면, fetch join이나 EntityGraph로 필요한 연관만 명시적으로 가져옵니다. 단순히 연관관계를 EAGER로 바꾸는 것은 전역 부작용이 커서 보통 좋은 해결책이 아닙니다.
Fetch join은 쿼리 수를 줄일 수 있지만, 1:N 컬렉션 조인에서는 결과 행이 늘어나고 pagination이 어려워질 수 있습니다. 특히 여러 컬렉션을 한 번에 join fetch하면 카르테시안 곱이나 Hibernate의 MultipleBagFetchException을 만날 수 있습니다.
해결 방법 2: Lazy Loading 최적화
Lazy Loading은 나쁜 기능이 아닙니다. 문제는 어디서, 얼마나 많이 초기화되는지 모르는 상태입니다. Hibernate에서는 batch fetching으로 여러 지연 로딩 대상을 묶어 조회할 수 있습니다.
예: 데이터 1000건, batch_size = 100
기본 Lazy: 1 + 1000 = 1001번 쿼리
Batch Fetch: 1 + 10 = 11번 안팎의 쿼리
Fetch Join: 1번 쿼리 가능, 단 1:N 중복과 pagination 주의
Spring Boot 설정 예:
spring.jpa.properties.hibernate.default_batch_fetch_size=100Batch size는 고정 정답이 아니라 조회 화면, 평균 목록 크기, IN 조건 크기, DB 실행 계획을 보며 조정합니다.
해결 방법 3: DTO Projection
조회 전용 화면에서는 엔티티 그래프를 모두 가져오지 않고 필요한 컬럼만 DTO로 조회하는 편이 명확할 때가 많습니다. DTO projection은 지연 로딩을 피하고, 응답에 필요한 데이터 형태를 SQL 단계에서 맞출 수 있습니다.
다만 DTO 조회는 변경 감지나 영속성 컨텍스트의 객체 동일성 보장을 기대하는 용도가 아닙니다. 읽기 모델과 쓰기 모델을 의도적으로 분리할 때 적합합니다.
Lazy vs Eager Loading 전략
로딩 전략은 “모든 상황에서 필요한 관계인가”가 아니라 “이 use case에서 무엇을 가져와야 하는가”로 결정해야 합니다.
| 연관관계 | JPA 기본값 | 운영 권장 방향 |
|---|---|---|
| @ManyToOne | EAGER | 가능하면 LAZY로 두고 필요한 쿼리에서 fetch 지정 |
| @OneToOne | EAGER | 가능하면 LAZY, 단 프록시/바이트코드 제약 확인 |
| @OneToMany | LAZY | LAZY 유지, fetch join/batch/entity graph를 선택 사용 |
| @ManyToMany | LAZY | 직접 사용보다 연결 엔티티로 풀어 관리하는 편이 안전 |
Hibernate는 EAGER 연관을 쿼리별로 쉽게 되돌릴 수 없고, join fetch를 빠뜨리면 보조 SELECT가 반복되어 N+1로 이어질 수 있습니다. 그래서 연관은 기본적으로 LAZY에 가깝게 설계하고, 필요한 조회에서 명시적으로 가져오는 편이 안전합니다. 다만 Jakarta Persistence에서 LAZY는 provider hint 성격이 있으므로, 특히 @OneToOne은 provider와 bytecode enhancement 조건을 확인해야 합니다.
Raw SQL이 필요한 시점
ORM만으로 모든 조회를 밀어붙이면 오히려 쿼리가 불투명해질 수 있습니다. 다음 상황에서는 SQL, query builder, native query를 직접 선택하는 편이 낫습니다.
| 상황 | ORM만으로 어려운 점 | 대안 |
|---|---|---|
| 복잡한 집계/분석 | Window 함수, grouping set 표현이 장황 | Raw SQL 또는 Query Builder |
| 대량 배치 업데이트 | 엔티티 단위 변경 감지 비용 큼 | Bulk Update/Delete SQL |
| DB 고유 기능 | 힌트, 파티션, flashback, COPY 등 | Native Query, DB 전용 SQL |
| 성능 크리티컬 조회 | 생성 SQL과 실행 계획 제어가 제한 | 직접 SQL + 실행 계획 검증 |
| 동적 검색 조건이 많은 화면 | if-else 조합이 복잡해짐 | QueryDSL, SQLAlchemy Core 등 |
| 재귀 쿼리/CTE | ORM 표현이 제한적이거나 가독성 저하 | WITH / WITH RECURSIVE |
| 대량 적재 | 건별 INSERT가 비효율 | COPY, LOAD DATA, bulk insert |
기본 CRUD와 단순 조회: ORM
화면 전용 읽기 모델: DTO projection 또는 Query Builder
복잡한 집계·배치·DB 고유 기능: Raw SQL / Native Query
비율은 프로젝트마다 다르다. 중요한 것은 쿼리 로그와 실행 계획으로 검증하는 것이다.각 ORM의 Raw SQL 사용법
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) > :count"
).setParameter("count", 5)
.getResultList();
────────────────────────────────────────────────
Django - raw() / connection.cursor()
────────────────────────────────────────────────
users = User.objects.raw(
'SELECT * FROM users WHERE email = %s', [email]
)
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) > ${minCount}
`;
────────────────────────────────────────────────
TypeORM - query()
────────────────────────────────────────────────
const results = await dataSource.query(
`SELECT * FROM users WHERE email = $1`, [email]
);Prisma의 $queryRaw tagged template은 값을 prepared statement로 전달하지만, $queryRawUnsafe나 인위적으로 만든 raw 문자열에 사용자 입력을 섞으면 SQL Injection 위험이 생깁니다.
ORM 성능 최적화 체크리스트
실무에서는 기능 구현이 끝난 뒤가 아니라, 조회 API를 만들 때부터 생성 SQL과 쿼리 수를 확인해야 합니다.
Query Builder 패턴
Query Builder는 Full ORM과 Raw 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);
────────────────────────────────────────────────
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가 ORM 메서드 이름 조합보다 읽기 쉽고, Raw SQL 문자열 조립보다 안전할 수 있습니다.
마이그레이션 관리
스키마 변경은 애플리케이션 코드처럼 버전 관리되어야 합니다. ORM의 자동 DDL 생성은 개발 환경에서는 편리하지만, 운영에서는 변경 순서, 락, 데이터 backfill, 롤백 전략을 함께 관리해야 하므로 Flyway, Liquibase, Alembic 같은 마이그레이션 도구를 쓰는 편이 안전합니다.
| 도구 | 언어/프레임워크 | 방식 |
|---|---|---|
| Flyway | Java/범용 | 버전 기반 SQL 마이그레이션 |
| Liquibase | Java/범용 | XML/YAML/JSON/SQL 변경셋, rollback은 변경별 검토 필요 |
| Prisma Migrate | TypeScript/Prisma | Prisma schema 기반 migration |
| TypeORM Migrations | TypeScript/TypeORM | TypeScript migration 파일 |
| Alembic | Python/SQLAlchemy | Python migration 스크립트 |
| Django Migrations | Python/Django | 모델 변경 diff 기반 |
Flyway vs Liquibase 비교
운영 환경 마이그레이션 주의사항
운영 DB 마이그레이션은 “실행 가능”이 아니라 “서비스 중 안전하게 실행 가능”해야 합니다. DBMS와 버전에 따라 DDL 잠금 방식이 다르므로, 변경 전에는 staging에서 실행 시간과 lock을 확인합니다.
| 작업 | 위험 | 안전한 방법 |
|---|---|---|
| nullable 컬럼 추가 | 보통 안전 | 먼저 nullable로 추가하고 코드 배포 후 제약을 강화 |
| NOT NULL 추가 | 위험 | 기본값·backfill·검증 후 제약 추가를 단계적으로 수행 |
| 컬럼 삭제 | 위험 | 코드에서 사용 중단 → 관찰 기간 → 다음 배포에서 삭제 |
| 컬럼 이름 변경 | 위험 | 새 컬럼 추가 → 동시 쓰기 → 읽기 전환 → 옛 컬럼 제거 |
| 컬럼 타입 변경 | 위험 | 새 컬럼 추가와 backfill, dual write, 전환 절차 사용 |
| 테이블 삭제 | 매우 위험 | 참조 제거 확인, 백업, 관찰 기간 후 삭제 |
| 인덱스 생성 | lock 가능 | DB별 ONLINE/CONCURRENTLY/LOCK 옵션과 제한 사항 확인 |
| 대량 backfill | 부하 위험 | 작은 batch, pause, retry, replication lag 관찰 |
1단계: 새 컬럼 추가
ALTER TABLE users ADD COLUMN user_name VARCHAR(100);
2단계: 애플리케이션 dual write
코드가 name과 user_name을 함께 쓰도록 배포
기존 데이터는 작은 batch로 backfill
3단계: 읽기 전환
코드가 user_name만 읽도록 전환하고 관찰
4단계: 옛 컬럼 제거
참조가 사라진 것을 확인한 뒤 다음 배포에서 DROP-- 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 InnoDB: 버전/작업 종류에 따라 제약이 있으므로 확인 필요
ALTER TABLE orders ADD INDEX idx_orders_date(order_date),
ALGORITHM=INPLACE, LOCK=NONE;PostgreSQL의 CREATE INDEX CONCURRENTLY는 일반 인덱스 생성보다 쓰기 차단을 줄이지만, 주의 사항이 있고 트랜잭션 블록 안에서 실행할 수 없습니다. MySQL도 online DDL 중 메타데이터 락을 기다릴 수 있으므로 “락이 전혀 없다”고 표현하면 안 됩니다.
실무에서의 ORM과 SQL 균형 전략
프로젝트 상황에 따라 ORM과 SQL의 비중은 달라집니다. 핵심은 기술 취향이 아니라 변경 빈도, 성능 민감도, 쿼리 복잡도, 팀의 SQL 숙련도를 함께 보는 것입니다.
SQL Injection 방지
ORM을 쓰면 보통 파라미터 바인딩 API를 자연스럽게 사용하게 되지만, ORM 자체가 모든 상황에서 SQL Injection을 자동으로 막아 주는 것은 아닙니다. Raw SQL, 동적 테이블명, 정렬 컬럼명처럼 SQL 구조를 조립할 때는 특히 조심해야 합니다.
────────────────────────────────────────────────
위험한 코드 (문자열 연결)
────────────────────────────────────────────────
String sql = "SELECT * FROM users WHERE name = '"
+ userInput + "'";
────────────────────────────────────────────────
안전한 코드 (파라미터 바인딩)
────────────────────────────────────────────────
// 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 tagged template
prisma.$queryRaw`SELECT * FROM users
WHERE name = ${userInput}`;
핵심 원칙:
* 사용자 입력을 SQL 문자열에 직접 연결하지 않기
* 값은 파라미터 바인딩으로 전달하기
* 테이블명/컬럼명/정렬 방향은 allowlist로 제한하기
* Raw SQL 작성 시 unsafe API 사용 여부를 코드 리뷰에서 확인하기ORM 선택 기준
ORM 선택은 “가장 인기 있는 도구”보다 팀과 시스템의 조건에 맞춰야 합니다.
| 기준 | Full ORM | Query Builder | Raw SQL |
|---|---|---|---|
| 개발 속도 | 빠름 | 보통 | 느림 |
| 성능 제어 | 제한적 | 좋음 | 가장 직접적 |
| 타입 안전성 | 도구에 따라 높음 | 도구에 따라 높음 | 별도 매핑 필요 |
| 유지보수 | 도메인 중심이면 좋음 | 복잡한 조회에 유리 | SQL 관리 규칙 필요 |
| DB 독립성 | 일부 확보 | 일부 확보 | 낮음 |
| 적합한 영역 | CRUD, 도메인 쓰기 | 동적 검색, 리포트 조회 | 배치, 분석, DB 고유 기능 |
실무 트러블슈팅 사례
ORM 문제는 대부분 “언제 SQL이 실행되는지”와 “트랜잭션 경계가 어디인지”를 보면 좁혀집니다.
문제 1: LazyInitializationException (JPA)
원인: 트랜잭션 밖에서 Lazy 프록시 접근
해결: 서비스 계층 안에서 필요한 데이터 로딩,
DTO 변환, fetch join/entity graph 사용
문제 2: MultipleBagFetchException (Hibernate)
원인: bag 컬렉션 2개 이상을 동시에 fetch join
해결: 하나만 fetch join + 나머지는 batch fetch,
컬렉션 모델 재검토
문제 3: 카르테시안 곱
원인: 1:N 관계 여러 개를 한 쿼리에서 조인
해결: 쿼리 분리, DTO projection, 애플리케이션 조립
문제 4: 변경 감지 vs 벌크 연산 충돌 (JPA)
원인: 벌크 UPDATE/DELETE 후 영속성 컨텍스트 불일치
해결: 벌크 전 flush, 벌크 후 clear 또는 새 트랜잭션
문제 5: OSIV와 지연 로딩 남용
원인: 웹 요청 전체에서 EntityManager가 열려 있어
view/serializer 단계에서 예기치 않은 SQL 발생
해결: spring.jpa.open-in-view=false 검토,
서비스 계층에서 조회 모델 완성Spring Boot 웹 애플리케이션은 기본적으로 Open EntityManager in View 패턴을 등록할 수 있습니다. 이 설정은 view에서 lazy loading을 허용하지만, 서비스 계층 밖 SQL 실행을 쉽게 만들 수 있으므로 의도적으로 켜고 끄는 결정을 해야 합니다.
ORM과 SQL 균형 종합 정리
| 체크 항목 | 확인 |
|---|---|
| 기본 연관관계를 LAZY 중심으로 설계했는가 | □ |
| 목록 조회에서 N+1 쿼리를 확인했는가 | □ |
| fetch join, batch fetch, DTO projection을 구분하는가 | □ |
| 벌크 연산 전 flush, 실행 후 clear를 검토했는가 | □ |
| 생성 SQL과 실행 계획을 함께 확인하는가 | □ |
| OSIV 설정을 의도적으로 결정했는가 | □ |
| 운영 스키마 변경을 migration 파일로 관리하는가 | □ |
| Raw SQL에서 파라미터 바인딩을 사용하는가 | □ |
| 동적 SQL 구조는 allowlist로 제한하는가 | □ |
| 온라인 DDL 옵션의 DB별 제한을 확인했는가 | □ |
다음 절에서는 데이터베이스를 넘어 시스템 전체, 즉 운영체제와 네트워크에서의 데이터 관리 원리를 다루게 됩니다.