From f7aaeaba2a46054a664f3a103b51b75bdbff909e Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 3 Jul 2026 15:49:14 +0900 Subject: [PATCH] =?UTF-8?q?perf(access):=20=EC=9E=AC=EC=8B=A4/=EC=98=A4?= =?UTF-8?q?=EB=8A=98=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20N+1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listInside/listTodayRecords가 방문건마다 findById+이벤트 조회를 반복하던 것을 findAllWithVisitorAndHostByIdIn(join fetch) + findByVisitRequestIdInOrderByEventAtAsc 배치 조회 2~3쿼리로 축소. 재실 판정은 오늘 이벤트의 마지막 방향으로 계산. - 테스트: AccessQueryTest(체크인→재실/오늘기록, 체크아웃→재실 해제·checkOut 시각). 전체 9건 통과. Co-Authored-By: Claude Opus 4.8 --- .../acs/repository/AccessEventRepository.java | 3 + .../repository/VisitRequestRepository.java | 4 + .../itcenter/acs/service/AccessService.java | 71 +++++++++++------ .../com/itcenter/acs/AccessQueryTest.java | 76 +++++++++++++++++++ 4 files changed, 131 insertions(+), 23 deletions(-) create mode 100644 backend/src/test/java/com/itcenter/acs/AccessQueryTest.java diff --git a/backend/src/main/java/com/itcenter/acs/repository/AccessEventRepository.java b/backend/src/main/java/com/itcenter/acs/repository/AccessEventRepository.java index 0ba5d3e..4fc07ff 100644 --- a/backend/src/main/java/com/itcenter/acs/repository/AccessEventRepository.java +++ b/backend/src/main/java/com/itcenter/acs/repository/AccessEventRepository.java @@ -15,6 +15,9 @@ public interface AccessEventRepository extends JpaRepository List findByVisitRequestIdOrderByEventAtAsc(Long visitRequestId); + /** All events for a set of visits (asc), for computing per-visit state without N+1. */ + List findByVisitRequestIdInOrderByEventAtAsc(java.util.Collection visitRequestIds); + /** Distinct visit-request ids that had an entry (IN) within the window (e.g. today). */ @Query("select distinct e.visitRequest.id from AccessEvent e " + "where e.direction = com.itcenter.acs.entity.Direction.IN " + diff --git a/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java b/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java index 4e9d243..2fd002b 100644 --- a/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java +++ b/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java @@ -32,6 +32,10 @@ public interface VisitRequestRepository extends JpaRepository findByQrTokenForUpdate(@Param("qrToken") String qrToken); + /** Loads visits with visitor+host eagerly in one query (avoids N+1 in the access lists). */ + @Query("select vr from VisitRequest vr join fetch vr.visitor join fetch vr.host where vr.id in :ids") + List findAllWithVisitorAndHostByIdIn(@Param("ids") java.util.Collection ids); + /** Bulk-expire approved visits whose window has passed (visit_to before the cutoff). */ @Modifying(clearAutomatically = true) @Query("update VisitRequest vr set vr.status = com.itcenter.acs.entity.VisitStatus.EXPIRED " + diff --git a/backend/src/main/java/com/itcenter/acs/service/AccessService.java b/backend/src/main/java/com/itcenter/acs/service/AccessService.java index 8920b44..dab332d 100644 --- a/backend/src/main/java/com/itcenter/acs/service/AccessService.java +++ b/backend/src/main/java/com/itcenter/acs/service/AccessService.java @@ -21,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -93,23 +94,26 @@ public class AccessService { @Transactional(readOnly = true) public List listInside() { - return accessEventRepository.findInsideVisitRequestIds().stream() - .map(visitRequestRepository::findById) - .filter(java.util.Optional::isPresent) - .map(java.util.Optional::get) - .map(vr -> { - LocalDateTime checkInAt = accessEventRepository - .findFirstByVisitRequestIdOrderByEventAtDesc(vr.getId()) - .map(AccessEvent::getEventAt) - .orElse(null); - return new InsideVisitorResponse( - vr.getId(), - vr.getVisitor().getName(), - vr.getVisitor().getCompany(), - vr.getZoneName(), - vr.getHost().getFullName(), - checkInAt); - }) + List ids = accessEventRepository.findInsideVisitRequestIds(); + if (ids.isEmpty()) { + return List.of(); + } + Map vrById = byId(ids); + // last (latest) event per visit = check-in time for currently-inside visitors + Map checkInAt = new java.util.HashMap<>(); + for (AccessEvent e : accessEventRepository.findByVisitRequestIdInOrderByEventAtAsc(ids)) { + checkInAt.put(e.getVisitRequest().getId(), e.getEventAt()); // asc → last wins = latest + } + return ids.stream() + .map(vrById::get) + .filter(java.util.Objects::nonNull) + .map(vr -> new InsideVisitorResponse( + vr.getId(), + vr.getVisitor().getName(), + vr.getVisitor().getCompany(), + vr.getZoneName(), + vr.getHost().getFullName(), + checkInAt.get(vr.getId()))) .toList(); } @@ -118,19 +122,31 @@ public class AccessService { public List listTodayRecords() { LocalDateTime start = LocalDate.now().atStartOfDay(); LocalDateTime end = start.plusDays(1); + List ids = accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end); + if (ids.isEmpty()) { + return List.of(); + } + Map vrById = byId(ids); + // group today's events per visit in one pass (events already ordered ascending) + Map> eventsByVr = new java.util.HashMap<>(); + for (AccessEvent e : accessEventRepository.findByVisitRequestIdInOrderByEventAtAsc(ids)) { + if (!e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end)) { + eventsByVr.computeIfAbsent(e.getVisitRequest().getId(), k -> new java.util.ArrayList<>()).add(e); + } + } List out = new java.util.ArrayList<>(); - for (Long id : accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end)) { - VisitRequest vr = visitRequestRepository.findById(id).orElse(null); + for (Long id : ids) { + VisitRequest vr = vrById.get(id); if (vr == null) { continue; } - List events = accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(id).stream() - .filter(e -> !e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end)) - .toList(); + List events = eventsByVr.getOrDefault(id, List.of()); LocalDateTime checkInAt = events.stream() .filter(e -> e.getDirection() == Direction.IN) .map(AccessEvent::getEventAt).findFirst().orElse(null); - boolean inside = isInside(id); + // inside = the latest event today is an entry + boolean inside = !events.isEmpty() + && events.get(events.size() - 1).getDirection() == Direction.IN; LocalDateTime checkOutAt = inside ? null : events.stream() .filter(e -> e.getDirection() == Direction.OUT) .map(AccessEvent::getEventAt).reduce((a, b) -> b).orElse(null); @@ -147,6 +163,15 @@ public class AccessService { return out; } + /** One query to load the given visits with visitor+host, keyed by id. */ + private Map byId(List ids) { + Map map = new java.util.HashMap<>(); + for (VisitRequest vr : visitRequestRepository.findAllWithVisitorAndHostByIdIn(ids)) { + map.put(vr.getId(), vr); + } + return map; + } + @Transactional(readOnly = true) public List searchApproved(String q) { if (q == null || q.isBlank()) { diff --git a/backend/src/test/java/com/itcenter/acs/AccessQueryTest.java b/backend/src/test/java/com/itcenter/acs/AccessQueryTest.java new file mode 100644 index 0000000..1142ca7 --- /dev/null +++ b/backend/src/test/java/com/itcenter/acs/AccessQueryTest.java @@ -0,0 +1,76 @@ +package com.itcenter.acs; + +import com.itcenter.acs.dto.AccessRecordResponse; +import com.itcenter.acs.dto.CheckInRequest; +import com.itcenter.acs.dto.InsideVisitorResponse; +import com.itcenter.acs.entity.User; +import com.itcenter.acs.entity.Visitor; +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.VisitStatus; +import com.itcenter.acs.repository.UserRepository; +import com.itcenter.acs.repository.VisitRequestRepository; +import com.itcenter.acs.repository.VisitorRepository; +import com.itcenter.acs.service.AccessService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Guards the batched (N+1-free) listInside/listTodayRecords against behaviour regressions. */ +@SpringBootTest +class AccessQueryTest { + + @Autowired AccessService accessService; + @Autowired VisitRequestRepository visitRequestRepository; + @Autowired VisitorRepository visitorRepository; + @Autowired UserRepository userRepository; + + @Test + void insideAndTodayRecordsReflectCheckInThenCheckOut() { + User host = userRepository.findByUsername("host").orElseThrow(); + String name = "조회테스트-" + System.nanoTime(); + Visitor v = new Visitor(); + v.setName(name); + v.setCompany("테스트회사"); + visitorRepository.save(v); + + LocalDate today = LocalDate.now(); + VisitRequest vr = new VisitRequest(); + vr.setVisitor(v); + vr.setHost(host); + vr.setPurpose("query"); + vr.setZoneName("전산실"); + vr.setVisitFrom(today.atStartOfDay()); + vr.setVisitTo(today.atTime(23, 59)); + vr.setStatus(VisitStatus.APPROVED); + Long vrId = visitRequestRepository.save(vr).getId(); + + CheckInRequest req = new CheckInRequest(); + req.setVisitRequestId(vrId); + req.setGateId("TEST"); + + // check-in → appears inside with a check-in time + accessService.checkIn(req, null); + InsideVisitorResponse inside = accessService.listInside().stream() + .filter(r -> r.getVisitRequestId().equals(vrId)).findFirst().orElseThrow(); + assertThat(inside.getVisitorName()).isEqualTo(name); + assertThat(inside.getCheckInAt()).isNotNull(); + + AccessRecordResponse rec = accessService.listTodayRecords().stream() + .filter(r -> r.getVisitRequestId().equals(vrId)).findFirst().orElseThrow(); + assertThat(rec.isInside()).isTrue(); + assertThat(rec.getCheckInAt()).isNotNull(); + + // check-out → no longer inside; today record shows a check-out time + accessService.checkOut(req, null); + assertThat(accessService.listInside().stream().anyMatch(r -> r.getVisitRequestId().equals(vrId))) + .isFalse(); + AccessRecordResponse after = accessService.listTodayRecords().stream() + .filter(r -> r.getVisitRequestId().equals(vrId)).findFirst().orElseThrow(); + assertThat(after.isInside()).isFalse(); + assertThat(after.getCheckOutAt()).isNotNull(); + } +}