이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • ORM은 무엇을 줄여주고 무엇을 없애지 못하는가?
  • Repository 메서드가 짧아도 DB 비용이 큰 이유는 무엇인가?
  • JPA를 쓰는 백엔드 개발자가 SQL과 실행 계획을 봐야 하는 이유는 무엇인가?

개요

ORM은 객체와 관계형 DB 사이의 매핑을 도와준다. 반복적인 CRUD, row-to-object 변환, 변경 감지, 연관관계 관리를 줄여준다. 하지만 SQL 실행 비용, 인덱스, 트랜잭션, 락, DB 한계는 사라지지 않는다.

JPA/Hibernate를 쓰는 백엔드 개발자가 DB를 모르면 코드가 짧아질수록 문제가 더 늦게 드러난다. 실제로 DB에 어떤 SQL이 몇 번 나가는지 확인해야 한다.

원리

다음 Repository 메서드는 간단하다.

List<Order> findByMemberIdOrderByCreatedAtDesc(Long memberId);

그러나 DB에는 이런 SQL이 나갈 수 있다.

SELECT o.id, o.member_id, o.status, o.created_at
FROM orders o
WHERE o.member_id = ?
ORDER BY o.created_at DESC;

데이터가 많다면 이 쿼리에는 인덱스와 limit이 필요하다.

CREATE INDEX idx_orders_member_created_id
ON orders (member_id, created_at DESC, id DESC);

ORM 메서드 이름은 SQL 비용을 숨길 뿐 없애지 않는다.

목록 API라면 여기서 끝나지 않는다. Pageable을 붙이면 데이터 조회 SQL뿐 아니라 count SQL이 추가될 수 있고, 정렬 조건이 안정적이지 않으면 페이지가 흔들릴 수 있다.

Page<Order> findByMemberId(Long memberId, Pageable pageable);
SELECT count(*)
FROM orders o
WHERE o.member_id = ?;

데이터 조회는 빠른데 count가 느린 API도 많다. 화면이 전체 페이지 수를 필요로 하지 않으면 Slice나 cursor pagination이 더 적합할 수 있다.

ORM이 줄여주는 것

ORM은 다음을 줄여준다.

  • 반복적인 JDBC 코드
  • ResultSet 매핑
  • insert/update SQL 작성
  • 객체 변경 추적
  • 1차 캐시와 동일성 보장
  • 연관관계 표현
  • DB 방언 차이 일부

이 장점은 매우 크다. 문제는 ORM이 DB 설계를 대신하지 않는다는 점이다.

ORM이 없애지 못하는 것

ORM은 다음을 없애지 못한다.

  • 인덱스 설계
  • 실행 계획
  • N+1
  • 락과 격리 수준
  • 커넥션 풀
  • 트랜잭션 경계
  • 대량 처리 비용
  • schema migration

예를 들어 order.getItems()는 코드상 필드 접근처럼 보이지만, lazy loading이면 DB query가 나갈 수 있다.

for (Order order : orders) {
    int itemCount = order.getItems().size();
}

이 코드는 주문 수만큼 추가 SQL을 만들 수 있다.

SQL 로그 보기

개발과 학습에서는 SQL 로그를 반드시 확인한다.

logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace

운영에서는 SQL 로그가 너무 많고 민감정보를 포함할 수 있으므로 APM, slow query, query sampling, pg_stat_statements 같은 도구를 활용한다.

학습 단계에서는 API 하나를 호출한 뒤 다음을 기록해보면 좋다.

  • 총 SQL 실행 횟수
  • 가장 오래 걸린 SQL
  • 같은 형태로 반복된 SQL
  • count 쿼리 존재 여부
  • 반환 row 수와 스캔 row 수
  • 사용된 인덱스와 정렬 방식

예를 들어 주문 목록 API를 만들었다면 “Repository 메서드가 있다”가 아니라 “orders(member_id, created_at desc, id desc) 인덱스로 20건을 읽고, count는 별도 요구가 없어서 수행하지 않는다”까지 설명할 수 있어야 한다.

ORM 관측 루틴

개발 환경에서는 Hibernate 통계나 SQL 포맷팅을 켜서 감각을 잡을 수 있다.

spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=debug
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace

운영에서는 bind 값 전체를 남기면 개인정보와 로그 비용 문제가 생길 수 있다. 대신 APM span, datasource proxy, Micrometer metric, slow query log, DB별 statement 통계를 조합한다.

HTTP /members/{id}/orders
  query.count = 3
  query.max = 42ms
  query.fingerprint = select ... from orders where member_id = ? order by ...
  rows.returned = 20
  pool.acquire.p95 = 4ms

이런 관측 정보가 있어야 ORM 문제가 “느낌”이 아니라 증거로 다뤄진다.

실전 팁

  • Repository 메서드를 만들면 실제 SQL을 한 번은 확인한다.
  • 목록 조회에는 limit, 정렬 안정성, 인덱스를 함께 설계한다.
  • 엔티티 그래프를 API 응답 모양과 혼동하지 않는다.
  • 복잡한 조회는 DTO projection이나 명시적 query를 검토한다.
  • SQL과 실행 계획을 PR 설명에 포함하면 리뷰 품질이 올라간다.
  • count가 필요한 화면인지 먼저 확인한다. 필요 없다면 Page보다 Slice나 cursor가 더 단순하고 안전할 수 있다.
  • 운영 장애 대응을 위해 API 이름, Repository 메서드, SQL fingerprint를 연결할 수 있게 로그나 APM 태그를 설계한다.

위험 신호!

  • JPA를 쓰니 SQL을 몰라도 된다고 말한다.
  • Repository 메서드 이름이 길어지고 조건이 복잡해졌는데 실행 계획을 보지 않는다.
  • API 응답 DTO를 엔티티 그대로 노출한다.
  • Lazy loading이 언제 SQL을 실행하는지 모른다.
  • 운영 slow query의 출처 Repository를 찾지 못한다.
  • 쿼리 수는 줄였지만 조인 row 수와 메모리 사용량이 늘어난 것을 보지 않는다.
  • 운영에서는 SQL 로그를 못 켜는데 대체 관측 수단도 없다.

확인 질문

  • ORM이 SQL을 없애지 않는다는 말의 의미는 무엇인가?
    • 개발자가 직접 SQL을 덜 쓸 뿐, DB에는 여전히 SQL이 실행되고 그 비용과 실행 계획은 그대로 존재한다는 뜻이다.
  • Repository 메서드가 짧아도 성능 문제가 생길 수 있는 이유는 무엇인가?
    • 생성된 SQL이 많은 행을 읽거나 정렬하고, 인덱스를 활용하지 못하거나 N+1을 만들 수 있기 때문이다.
  • JPA를 쓰는 개발자가 실행 계획을 봐야 하는 이유는 무엇인가?
    • ORM이 만든 SQL도 DB Optimizer가 실행하므로 인덱스, 조인, 정렬 비용 문제를 확인해야 하기 때문이다.
  • 운영에서 ORM SQL 문제를 추적하려면 무엇을 연결해야 하는가?
    • API, Repository 메서드, SQL fingerprint, query count, slow query, 커넥션 풀 지표를 연결해야 원인을 좁힐 수 있다.

참고 문서