최근 예약 목록에 병원명을 붙인다고 하자. 같은 병원의 예약이 여러 개여도 각 DTO를 만들 때 병원을 다시 조회하면 목록 길이만큼 호출이 늘어난다. 병원 조회 하나가 빠르다는 사실만으로 화면의 비용을 설명할 수는 없다.
이 글은 예약 행마다 생기는 추가 조회를 없애면서 페이지와 카드의 의미를 어떻게 유지할 것인가를 다룬다. MongoDB 서비스 반복 조회를 출발점으로 삼고, JPA 조회는 같은 문제의 별도 대안으로 비교한다.
DTO 변환이 다시 저장소를 호출하는지 본다
다음 코드는 예약을 읽은 뒤 병원 조회를 반복한다.
return reservations.findRecent(filter).map { reservation ->
ReservationRow(
reservation.id,
reservation.name,
hospitals.findById(requireNotNull(reservation.hospitalId)).name,
)
}
첫 예제는 병원 ID가 있어야 한다는 조건을 requireNotNull로 확인한다. 삭제된 병원의 처리와 null ID의 응답 정책은 다음 묶음 조회 예제에서 분리한다.
예약 조회를 R, 병원 조회를 H로 두면 R + N × H가 항목 수에 비례해 추가되는 조회 비용을 설명한다. 개발 캐시로 왕복이 가려질 수도 있으므로 같은 병원을 반복 참조하는 데이터와 서로 다른 병원을 참조하는 데이터를 나눠 확인한다.
삭제됐거나 권한에서 제외된 병원을 참조하는 예약도 포함해야 한다. 이를 조용히 빼면 빨라진 목록과 카드의 대상이 달라질 수 있다.
페이지를 먼저 제한하고 필요한 ID만 모은다
전체 예약을 읽어 ID를 모으면 N+1은 줄여도 반환량과 메모리는 제한하지 못한다. 먼저 페이지를 읽고 그 페이지가 필요한 병원 이름만 projection으로 조회한다.
병원명이 예약의 선택 조건이 아니라 표시 정보인 경우에는 아래 순서로 읽을 수 있다. 예약 페이지의 범위를 정한 뒤 그 페이지가 필요한 참조 정보만 가져온다.
그림 1 페이지가 참조한 병원 이름 읽기 — 각 예약마다 병원을 다시 조회하는 대신 페이지의 참조 ID를 모아 응답을 구성한다.
병원 ID가 비어 있으면 이름 조회를 생략한다. 최종 응답은 원래 예약 순서와 누락 병원의 처리 정책을 유지한다. 병원 조건으로 예약을 고르는 화면이라면 페이지를 자르는 순서를 다시 검토한다.
fun loadPage(filter: Filter, page: PageRequest): DashboardPage {
val reservations = reservationReader.findPage(filter, page)
val hospitalIds = reservations.rows.mapNotNull { it.hospitalId }.toSet()
val hospitalNames = if (hospitalIds.isEmpty()) {
emptyList()
} else {
hospitalReader.findNames(hospitalIds)
}
val names: Map<Long, String> = hospitalNames.fold(mutableMapOf<Long, String>()) { names, hospital ->
check(hospital.id !in names) { "Duplicate hospital ID" }
names[hospital.id] = hospital.name ?: "이름 없음"
names
}
val rows = reservations.rows.map { reservation ->
val hospitalName = reservation.hospitalId?.let { names[it] } ?: "병원 정보 없음"
toRow(reservation, hospitalName)
}
return DashboardPage(rows, reservations.total)
}
예제의 DTO는 Kotlin 프로퍼티로 값을 전달하며 hospitalId는 Long?, 병원 이름은 String?일 수 있다. mapNotNull은 조회할 ID만 모으고, 최종 map은 원래 예약 행을 모두 유지한다. 병원 ID가 중복되면 예외를 던져 조회 결과의 유일성 위반을 드러낸다.
병원 ID가 없거나 이름 조회 결과가 없을 때 안내 문구를 남기는 것은 예제의 응답 정책이다. 실제 화면은 제외·오류·안내 중 선택해야 한다. 묶음 조회 내부에서 관리자·계약 정보를 다시 읽으면 숨은 반복 조회가 남으므로 대시보드에 필요한 필드로 제한한다.
ID가 많으면 IN이나 $in을 나눠 제출할 수 있다. 목표는 언제나 쿼리 두 번이 아니라 제한된 수의 조회다. 최종 응답은 병원 조회 순서가 아니라 예약의 정렬을 유지한다. 생성 시각이 같을 때의 보조 키와 최대 페이지 크기도 정한다.
카드의 건수는 같은 대상을 집계해야 한다
카드마다 전체 목록을 읽어 개수를 세면 목록 개선 뒤에도 비용이 남는다. 공통 날짜·권한 범위가 같은 카드에는 다음과 같은 상태별 집계를 사용할 수 있다.
db.reservations.aggregate([
{ $match: { registeredAt: { $gte: fromUtc, $lt: toUtc } } },
{
$group: {
_id: "$status",
count: { $sum: 1 },
},
},
]);
화면 시간대의 하루 시작과 다음 날 시작을 UTC instant로 바꾸고 [from, to) 범위를 사용한다. 신청일과 진료 예정일을 혼동하지 않고 미응답에 포함할 상태도 정의한다. 권한·삭제 조건은 목록과 카드에 함께 적용하고 집계에 없는 상태는 0으로 채운다. 배열을 펼친다면 예약 하나가 여러 번 세어지지 않는지도 확인한다.
집계 하나가 별도 count보다 항상 빠른 것은 아니다. MongoDB $group은 입력을 기다리는 blocking stage이며 큰 입력의 메모리 비용이 있다. 공통 $match와 실행계획으로 후보를 비교한다.
$lookup으로 병원명을 붙일 수도 있다. 그러나 병원 조건으로 예약을 필터링해야 한다면 예약만 먼저 페이지로 자르는 것이 원래 페이지 의미를 바꿀 수 있다. 단순 표시 정보와 선택 조건을 나눠 결합 순서를 정한다.
JPA에서는 조회 계획과 페이지 의미를 같이 고른다
to-one 관계의 fetch join이나 필요한 컬럼의 DTO projection은 후보가 된다. LAZY를 EAGER로 바꾸는 것만으로 화면별 조회 계획이 정해지지는 않는다.
to-many 컬렉션 join은 예약 하나를 여러 SQL 행으로 늘린다. 페이지 제한과 엔티티 개수의 의미가 달라지고 Hibernate가 메모리에서 제한할 수 있다. Hibernate 6.6 페이지네이션 설명을 적용할 때 컬렉션 결합과 단일 to-one 결합을 구분한다. 예약 ID 페이지를 먼저 읽고 연관 데이터를 추가 조회한다면 첫 ID 순서를 복원하고 count의 조건도 별도로 맞춘다.
서비스 반환 이후의 추가 조회까지 확인한다
저장소 메서드 한 번이 DB 명령 한 번과 같지는 않다. MongoDB CommandListener로 find·aggregate와 후속 getMore, 재시도를 나눠 관찰한다. Hibernate에서는 Statistics API의 prepared statement 수와 HQL 실행 수를 구분한다. 전역 통계에는 다른 요청·fixture·캐시가 섞일 수 있으므로 비교 조건을 통제한다.
확인 범위에는 DTO 변환과 응답 직렬화도 포함한다. 페이지 크기를 늘려도 예약별 조회가 반복되지 않는지, 빈 페이지에서 병원 조회를 생략하는지, count 포함 여부에 따라 명령 수가 어떻게 달라지는지 본다.
마지막에는 날짜 경계·권한·누락 병원·동률 정렬까지 같은 결과인지 비교한다. 쿼리 수만 줄이고 더 큰 결과나 다른 페이지를 반환하면 같은 기능의 개선이라고 말할 수 없다. 반복 조회, 전체 반환량, 카드 정의를 함께 제한하는 것이 이 사례의 선택 기준이다.
