perf(access): 재실/오늘기록 조회 N+1 제거
- listInside/listTodayRecords가 방문건마다 findById+이벤트 조회를 반복하던 것을 findAllWithVisitorAndHostByIdIn(join fetch) + findByVisitRequestIdInOrderByEventAtAsc 배치 조회 2~3쿼리로 축소. 재실 판정은 오늘 이벤트의 마지막 방향으로 계산. - 테스트: AccessQueryTest(체크인→재실/오늘기록, 체크아웃→재실 해제·checkOut 시각). 전체 9건 통과. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,9 @@ public interface AccessEventRepository extends JpaRepository<AccessEvent, Long>
|
|||||||
|
|
||||||
List<AccessEvent> findByVisitRequestIdOrderByEventAtAsc(Long visitRequestId);
|
List<AccessEvent> findByVisitRequestIdOrderByEventAtAsc(Long visitRequestId);
|
||||||
|
|
||||||
|
/** All events for a set of visits (asc), for computing per-visit state without N+1. */
|
||||||
|
List<AccessEvent> findByVisitRequestIdInOrderByEventAtAsc(java.util.Collection<Long> visitRequestIds);
|
||||||
|
|
||||||
/** Distinct visit-request ids that had an entry (IN) within the window (e.g. today). */
|
/** Distinct visit-request ids that had an entry (IN) within the window (e.g. today). */
|
||||||
@Query("select distinct e.visitRequest.id from AccessEvent e " +
|
@Query("select distinct e.visitRequest.id from AccessEvent e " +
|
||||||
"where e.direction = com.itcenter.acs.entity.Direction.IN " +
|
"where e.direction = com.itcenter.acs.entity.Direction.IN " +
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ public interface VisitRequestRepository extends JpaRepository<VisitRequest, Long
|
|||||||
@Query("select vr from VisitRequest vr where vr.qrToken = :qrToken")
|
@Query("select vr from VisitRequest vr where vr.qrToken = :qrToken")
|
||||||
Optional<VisitRequest> findByQrTokenForUpdate(@Param("qrToken") String qrToken);
|
Optional<VisitRequest> 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<VisitRequest> findAllWithVisitorAndHostByIdIn(@Param("ids") java.util.Collection<Long> ids);
|
||||||
|
|
||||||
/** Bulk-expire approved visits whose window has passed (visit_to before the cutoff). */
|
/** Bulk-expire approved visits whose window has passed (visit_to before the cutoff). */
|
||||||
@Modifying(clearAutomatically = true)
|
@Modifying(clearAutomatically = true)
|
||||||
@Query("update VisitRequest vr set vr.status = com.itcenter.acs.entity.VisitStatus.EXPIRED " +
|
@Query("update VisitRequest vr set vr.status = com.itcenter.acs.entity.VisitStatus.EXPIRED " +
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -93,23 +94,26 @@ public class AccessService {
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<InsideVisitorResponse> listInside() {
|
public List<InsideVisitorResponse> listInside() {
|
||||||
return accessEventRepository.findInsideVisitRequestIds().stream()
|
List<Long> ids = accessEventRepository.findInsideVisitRequestIds();
|
||||||
.map(visitRequestRepository::findById)
|
if (ids.isEmpty()) {
|
||||||
.filter(java.util.Optional::isPresent)
|
return List.of();
|
||||||
.map(java.util.Optional::get)
|
}
|
||||||
.map(vr -> {
|
Map<Long, VisitRequest> vrById = byId(ids);
|
||||||
LocalDateTime checkInAt = accessEventRepository
|
// last (latest) event per visit = check-in time for currently-inside visitors
|
||||||
.findFirstByVisitRequestIdOrderByEventAtDesc(vr.getId())
|
Map<Long, LocalDateTime> checkInAt = new java.util.HashMap<>();
|
||||||
.map(AccessEvent::getEventAt)
|
for (AccessEvent e : accessEventRepository.findByVisitRequestIdInOrderByEventAtAsc(ids)) {
|
||||||
.orElse(null);
|
checkInAt.put(e.getVisitRequest().getId(), e.getEventAt()); // asc → last wins = latest
|
||||||
return new InsideVisitorResponse(
|
}
|
||||||
|
return ids.stream()
|
||||||
|
.map(vrById::get)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.map(vr -> new InsideVisitorResponse(
|
||||||
vr.getId(),
|
vr.getId(),
|
||||||
vr.getVisitor().getName(),
|
vr.getVisitor().getName(),
|
||||||
vr.getVisitor().getCompany(),
|
vr.getVisitor().getCompany(),
|
||||||
vr.getZoneName(),
|
vr.getZoneName(),
|
||||||
vr.getHost().getFullName(),
|
vr.getHost().getFullName(),
|
||||||
checkInAt);
|
checkInAt.get(vr.getId())))
|
||||||
})
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,19 +122,31 @@ public class AccessService {
|
|||||||
public List<AccessRecordResponse> listTodayRecords() {
|
public List<AccessRecordResponse> listTodayRecords() {
|
||||||
LocalDateTime start = LocalDate.now().atStartOfDay();
|
LocalDateTime start = LocalDate.now().atStartOfDay();
|
||||||
LocalDateTime end = start.plusDays(1);
|
LocalDateTime end = start.plusDays(1);
|
||||||
|
List<Long> ids = accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end);
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<Long, VisitRequest> vrById = byId(ids);
|
||||||
|
// group today's events per visit in one pass (events already ordered ascending)
|
||||||
|
Map<Long, List<AccessEvent>> 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<AccessRecordResponse> out = new java.util.ArrayList<>();
|
List<AccessRecordResponse> out = new java.util.ArrayList<>();
|
||||||
for (Long id : accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end)) {
|
for (Long id : ids) {
|
||||||
VisitRequest vr = visitRequestRepository.findById(id).orElse(null);
|
VisitRequest vr = vrById.get(id);
|
||||||
if (vr == null) {
|
if (vr == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
List<AccessEvent> events = accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(id).stream()
|
List<AccessEvent> events = eventsByVr.getOrDefault(id, List.of());
|
||||||
.filter(e -> !e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end))
|
|
||||||
.toList();
|
|
||||||
LocalDateTime checkInAt = events.stream()
|
LocalDateTime checkInAt = events.stream()
|
||||||
.filter(e -> e.getDirection() == Direction.IN)
|
.filter(e -> e.getDirection() == Direction.IN)
|
||||||
.map(AccessEvent::getEventAt).findFirst().orElse(null);
|
.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()
|
LocalDateTime checkOutAt = inside ? null : events.stream()
|
||||||
.filter(e -> e.getDirection() == Direction.OUT)
|
.filter(e -> e.getDirection() == Direction.OUT)
|
||||||
.map(AccessEvent::getEventAt).reduce((a, b) -> b).orElse(null);
|
.map(AccessEvent::getEventAt).reduce((a, b) -> b).orElse(null);
|
||||||
@@ -147,6 +163,15 @@ public class AccessService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One query to load the given visits with visitor+host, keyed by id. */
|
||||||
|
private Map<Long, VisitRequest> byId(List<Long> ids) {
|
||||||
|
Map<Long, VisitRequest> map = new java.util.HashMap<>();
|
||||||
|
for (VisitRequest vr : visitRequestRepository.findAllWithVisitorAndHostByIdIn(ids)) {
|
||||||
|
map.put(vr.getId(), vr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<VisitRequest> searchApproved(String q) {
|
public List<VisitRequest> searchApproved(String q) {
|
||||||
if (q == null || q.isBlank()) {
|
if (q == null || q.isBlank()) {
|
||||||
|
|||||||
76
backend/src/test/java/com/itcenter/acs/AccessQueryTest.java
Normal file
76
backend/src/test/java/com/itcenter/acs/AccessQueryTest.java
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user