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:
unknown
2026-07-03 15:49:14 +09:00
parent 8ca939c057
commit f7aaeaba2a
4 changed files with 131 additions and 23 deletions

View File

@@ -15,6 +15,9 @@ public interface AccessEventRepository extends JpaRepository<AccessEvent, Long>
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). */
@Query("select distinct e.visitRequest.id from AccessEvent e " +
"where e.direction = com.itcenter.acs.entity.Direction.IN " +

View File

@@ -32,6 +32,10 @@ public interface VisitRequestRepository extends JpaRepository<VisitRequest, Long
@Query("select vr from VisitRequest vr where vr.qrToken = :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). */
@Modifying(clearAutomatically = true)
@Query("update VisitRequest vr set vr.status = com.itcenter.acs.entity.VisitStatus.EXPIRED " +

View File

@@ -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<InsideVisitorResponse> 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<Long> ids = accessEventRepository.findInsideVisitRequestIds();
if (ids.isEmpty()) {
return List.of();
}
Map<Long, VisitRequest> vrById = byId(ids);
// last (latest) event per visit = check-in time for currently-inside visitors
Map<Long, LocalDateTime> 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<AccessRecordResponse> listTodayRecords() {
LocalDateTime start = LocalDate.now().atStartOfDay();
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<>();
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<AccessEvent> events = accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(id).stream()
.filter(e -> !e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end))
.toList();
List<AccessEvent> 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<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)
public List<VisitRequest> searchApproved(String q) {
if (q == null || q.isBlank()) {

View 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();
}
}