안동민 개발노트 아이콘

안동민 개발노트

15장 : 실무 데이터베이스 운영

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/HibernateJPA 표준 + 구현체영속성 컨텍스트, 변경 감지, JPQL/HQL
Django ORMActive Record 성향QuerySet 체이닝, 모델 중심 개발
TypeORMData Mapper + Active Record데코레이터 기반, QueryBuilder 제공
Prisma스키마 우선 ORM타입 생성, 관계 API, raw query 지원
MyBatisSQL MapperSQL을 직접 작성하고 객체에 매핑
SQLAlchemyData Mapper + SQL ExpressionORM과 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으로 여러 지연 로딩 대상을 묶어 조회할 수 있습니다.

Lazy Loading + Batch Size
예: 데이터 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=100

Batch size는 고정 정답이 아니라 조회 화면, 평균 목록 크기, IN 조건 크기, DB 실행 계획을 보며 조정합니다.


해결 방법 3: DTO Projection

조회 전용 화면에서는 엔티티 그래프를 모두 가져오지 않고 필요한 컬럼만 DTO로 조회하는 편이 명확할 때가 많습니다. DTO projection은 지연 로딩을 피하고, 응답에 필요한 데이터 형태를 SQL 단계에서 맞출 수 있습니다.

다만 DTO 조회는 변경 감지나 영속성 컨텍스트의 객체 동일성 보장을 기대하는 용도가 아닙니다. 읽기 모델과 쓰기 모델을 의도적으로 분리할 때 적합합니다.


Lazy vs Eager Loading 전략

로딩 전략은 “모든 상황에서 필요한 관계인가”가 아니라 “이 use case에서 무엇을 가져와야 하는가”로 결정해야 합니다.

연관관계JPA 기본값운영 권장 방향
@ManyToOneEAGER가능하면 LAZY로 두고 필요한 쿼리에서 fetch 지정
@OneToOneEAGER가능하면 LAZY, 단 프록시/바이트코드 제약 확인
@OneToManyLAZYLAZY 유지, fetch join/batch/entity graph를 선택 사용
@ManyToManyLAZY직접 사용보다 연결 엔티티로 풀어 관리하는 편이 안전

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 등
재귀 쿼리/CTEORM 표현이 제한적이거나 가독성 저하WITH / WITH RECURSIVE
대량 적재건별 INSERT가 비효율COPY, LOAD DATA, bulk insert
ORM vs SQL 선택 기준
기본 CRUD와 단순 조회: ORM
화면 전용 읽기 모델: DTO projection 또는 Query Builder
복잡한 집계·배치·DB 고유 기능: Raw SQL / Native Query

비율은 프로젝트마다 다르다. 중요한 것은 쿼리 로그와 실행 계획으로 검증하는 것이다.

각 ORM의 Raw SQL 사용법

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의 구조를 유지하면서도 타입 안전성, 동적 조건 조립, 파라미터 바인딩을 얻을 수 있습니다.

Query Builder 예시
────────────────────────────────────────────────
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 같은 마이그레이션 도구를 쓰는 편이 안전합니다.

도구언어/프레임워크방식
FlywayJava/범용버전 기반 SQL 마이그레이션
LiquibaseJava/범용XML/YAML/JSON/SQL 변경셋, rollback은 변경별 검토 필요
Prisma MigrateTypeScript/PrismaPrisma schema 기반 migration
TypeORM MigrationsTypeScript/TypeORMTypeScript migration 파일
AlembicPython/SQLAlchemyPython migration 스크립트
Django MigrationsPython/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 구조를 조립할 때는 특히 조심해야 합니다.

SQL Injection 방지 패턴
────────────────────────────────────────────────
위험한 코드 (문자열 연결)
────────────────────────────────────────────────
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 ORMQuery BuilderRaw SQL
개발 속도빠름보통느림
성능 제어제한적좋음가장 직접적
타입 안전성도구에 따라 높음도구에 따라 높음별도 매핑 필요
유지보수도메인 중심이면 좋음복잡한 조회에 유리SQL 관리 규칙 필요
DB 독립성일부 확보일부 확보낮음
적합한 영역CRUD, 도메인 쓰기동적 검색, 리포트 조회배치, 분석, DB 고유 기능

실무 트러블슈팅 사례

ORM 문제는 대부분 “언제 SQL이 실행되는지”와 “트랜잭션 경계가 어디인지”를 보면 좁혀집니다.

흔한 ORM 문제 5가지
문제 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별 제한을 확인했는가

다음 절에서는 데이터베이스를 넘어 시스템 전체, 즉 운영체제와 네트워크에서의 데이터 관리 원리를 다루게 됩니다.