From f26acdd8033d7849225f72ea96dce79dfae60d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:01:46 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20=EC=B9=B4=EB=93=9C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EA=B0=92=20=ED=8C=8C=EC=83=9D=20=EB=B0=B0=EC=B9=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EB=91=90=20=EB=AC=B8=EC=9E=A5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `where s.id in (select max(s2.id) ...)` 한 문장이 item_snapshots 를 전량 스캔하고 있었다. 부하테스트(#911) 실측: 위시 목록 1회(size=20)가 행 10만을 읽고 483ms 를 썼다 - EXPLAIN ANALYZE 로 계획을 확인했다. 서브쿼리는 19행/76ms 로 정확한데, MySQL 이 그 결과를 임시 테이블로 materialize 한 뒤 바깥 테이블 10만 행을 훑으며 매 행을 대조한다(loops=101184). 안쪽이 좁은데 바깥을 다 보는 형태라 데이터가 늘수록 선형으로 나빠진다 - id 목록을 앱으로 받아 findAllById 로 넘긴다. 1단계는 idx_item_snapshots_item_id range scan(0.24ms), 2단계는 PK lookup 이라 양쪽 모두 인덱스를 탄다. 왕복이 하나 늘지만 1단계 비용이 그것을 크게 밑돈다 - 같은 요청의 다른 `id in (상수)` 쿼리는 examined=20·0.4ms 로 정상이었다. IN 문법이 아니라 서브쿼리를 끼운 형태가 원인이다 - 반환 타입이 엔티티에서 id 목록으로 바뀌어 메서드명도 findLatestMachineReadyIdsByItemIds 로 옮겼다. 조합은 Impl 이 맡아 호출부(ItemDisplayService) 시그니처는 그대로다 --- .../repository/ItemSnapshotJpaRepository.kt | 18 +++++++++++------- .../repository/ItemSnapshotRepositoryImpl.kt | 10 ++++++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotJpaRepository.kt index 39599a29..99b0ca00 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotJpaRepository.kt @@ -85,19 +85,23 @@ interface ItemSnapshotJpaRepository : JpaRepository { @Param("itemId") itemId: Long, ): ItemSnapshot? - // 카드 표시값 파생(#857)의 배치 조회 — item 별 마지막 기계(SERVER/SERVER_LLM) READY 하나씩. - // 서브쿼리로 item 별 max(id)만 골라, 이력이 긴 item 이 섞여도 행 수가 item 수를 넘지 않는다. + // 카드 표시값 파생(#857)의 배치 조회 1단계 — item 별 마지막 기계(SERVER/SERVER_LLM) READY 의 **id 만** 고른다. // 출처 null(도입 전 행)은 기계 여부를 모르므로 제외한다 — 그 item 은 호출부가 포인터 버전으로 fallback 한다. + // + // 엔티티 조회(2단계)를 한 문장에 합치지 않는다. 합치면(`where s.id in (select max(s2.id) ...)`) MySQL 이 + // 서브쿼리를 임시 테이블로 materialize 한 뒤 **바깥 테이블 전체를 훑으며 매 행을 대조**하는 계획을 고른다 + // (부하테스트 #911 실측: 서브쿼리는 19행/76ms 로 정확한데 바깥이 10만 행 스캔, 합계 483ms). + // id 목록을 앱으로 받아 2단계에서 상수 IN 으로 넘기면 양쪽 모두 인덱스를 탄다. 왕복이 하나 늘지만 + // 1단계가 0.24ms 라 그 비용을 크게 밑돈다. @Query( - "select s from ItemSnapshot s where s.id in (" + - "select max(s2.id) from ItemSnapshot s2 where s2.itemId in :itemIds " + + "select max(s2.id) from ItemSnapshot s2 where s2.itemId in :itemIds " + "and s2.status = com.depromeet.piki.item.domain.ItemStatus.READY " + "and s2.source in (com.depromeet.piki.item.domain.ItemSnapshotSource.SERVER, com.depromeet.piki.item.domain.ItemSnapshotSource.SERVER_LLM) " + - "and s2.deletedAt is null group by s2.itemId)", + "and s2.deletedAt is null group by s2.itemId", ) - fun findLatestMachineReadyByItemIds( + fun findLatestMachineReadyIdsByItemIds( @Param("itemIds") itemIds: Collection, - ): List + ): List // 병합(#825) — 진(임시) item 의 모든 버전을 이긴 item 소속으로 재부모화한다. wish·tournament_item 은 snapshot 만 // 참조하므로 이 한 문장으로 참조가 자동 추종된다. native bulk 라 auditing 을 우회해 updated_at 을 직접 갱신한다. diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepositoryImpl.kt index d9df5b0a..812344df 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepositoryImpl.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepositoryImpl.kt @@ -20,8 +20,14 @@ class ItemSnapshotRepositoryImpl( override fun findLatestMachineReadyByItemId(itemId: Long): ItemSnapshot? = itemSnapshotJpaRepository.findLatestMachineReadyByItemId(itemId) - override fun findLatestMachineReadyByItemIds(itemIds: Collection): List = - itemIds.takeIf { it.isNotEmpty() }?.let { itemSnapshotJpaRepository.findLatestMachineReadyByItemIds(it) }.orEmpty() + // 두 문장으로 나눠 실행한다(#911) — 한 문장으로 합치면 MySQL 이 바깥 테이블을 전량 스캔한다. + // 자세한 이유는 ItemSnapshotJpaRepository.findLatestMachineReadyIdsByItemIds 주석 참조. + override fun findLatestMachineReadyByItemIds(itemIds: Collection): List { + if (itemIds.isEmpty()) return emptyList() + val snapshotIds = itemSnapshotJpaRepository.findLatestMachineReadyIdsByItemIds(itemIds) + if (snapshotIds.isEmpty()) return emptyList() + return itemSnapshotJpaRepository.findAllById(snapshotIds) + } override fun reparentAll( fromItemId: Long,