JPA 조회 계획과 안정 페이지
ORM은 객체 중심 코드를 SQL로 번역하지만, 생성된 쿼리의 비용까지 없애 주지는 않습니다. 연관 관계를 반복해서 지연 로딩하면 N+1이 생길 수 있고, 필요한 열보다 큰 Entity를 읽으면 네트워크와 영속성 컨텍스트 비용이 커집니다.
최적화 전에는 SQL 로그와 query 수로 문제를 재현하고, PostgreSQL의 EXPLAIN (ANALYZE, BUFFERS)로 실제 실행 계획을 확인합니다. 감으로 index를 추가하면 쓰기 비용만 늘고 조회 계획은 그대로일 수 있습니다.
ORM 성능 문제는 생성된 SQL로 확인한다
먼저 한 HTTP 요청이 몇 개의 SQL을 실행하는지 셉니다. 반복 쿼리를 줄인 뒤에는 각 쿼리의 scan 방식과 읽은 buffer를 확인합니다. 마지막으로 pagination 정렬에 유일한 tie-breaker를 포함해 같은 날짜의 행이 페이지 사이에서 중복되거나 빠지지 않게 합니다.
fetch join은 연관 객체 전체가 실제로 필요할 때 유용하고, projection은 목록 화면처럼 일부 열만 필요할 때 적합합니다. 둘 중 문법이 짧은 쪽이 아니라 반환할 데이터 모양과 변경 여부를 기준으로 선택합니다.
fetch join과 projection을 목적에 맞게 사용한다
이 목록은 연관 Entity가 아니라 다섯 열만 필요하므로 interface projection을 사용합니다. studyDate DESC, id ASC는 날짜가 같은 행에도 전체 순서를 제공합니다.
package com.andongmin.studylog.session;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
public interface StudySessionReadRepository
extends Repository<StudySessionEntity, UUID> {
@Query("""
select s.id as id, s.subject as subject, s.topic as topic,
s.studyDate as studyDate, s.minutes as minutes
from StudySessionEntity s
where s.subject = :subject and s.studyDate >= :from
order by s.studyDate desc, s.id asc
""")
List<StudySessionRow> findRecent(
@Param("subject") String subject,
@Param("from") LocalDate from,
Pageable pageable);
interface StudySessionRow {
UUID getId();
String getSubject();
String getTopic();
LocalDate getStudyDate();
int getMinutes();
}
}CREATE INDEX idx_study_session_subject_date_id
ON study_session (subject, study_date DESC, id);복합 index의 열 순서는 query의 동등 조건과 정렬 순서를 반영합니다. 그러나 테이블이 작으면 PostgreSQL이 sequential scan을 선택하는 것이 더 저렴할 수 있습니다. index 이름이 계획에 반드시 보이는지를 목표로 삼지 말고 실제 시간과 읽은 buffer를 비교합니다.
query 수와 page 중복을 재측정한다
충분한 표본 데이터를 넣고 실행 계획을 확인합니다. 개발 데이터 몇 행만으로는 optimizer 선택이 운영 데이터와 달라질 수 있으므로 행 수와 분포도 함께 기록합니다.
docker compose exec postgres psql -U studylog -d studylog -c "EXPLAIN (ANALYZE, BUFFERS) SELECT id, subject, topic, study_date, minutes FROM study_session WHERE subject = 'Spring' ORDER BY study_date DESC, id LIMIT 20"실행 계획이 전체 행의 불필요한 sort를 피하고 결과 순서는 study_date와 id로 안정되어야 합니다.안정 정렬은 다음 페이지의 계약이다
정렬 열 study_date만 사용하면 같은 날짜의 행 순서가 정해지지 않습니다. 첫 요청과 다음 요청 사이에 행이 추가되면 offset pagination은 여전히 중복될 수 있습니다. 변경이 잦고 깊은 페이지가 필요하면 (study_date, id) cursor를 전달하는 keyset pagination도 검토합니다.
DB query를 먼저 줄이고 측정한 뒤에도 반복 조회 비용이 크다면, 다음 절의 캐시를 두 번째 복사본으로 도입합니다.