fix(access): P1 — 체크인 동시성 경쟁 제거 + 만료 승인 자동 전이

- 체크인/아웃이 VisitRequest 행을 PESSIMISTIC_WRITE 락으로 로드(resolve)하여
  동일 방문의 입·출입을 직렬화 → 동시 스캔 시 중복 입장 이벤트 방지(TOCTOU 제거).
  스키마 변경 없음(H2/PostgreSQL 이식성 위해 부분 유니크 대신 락 채택).
- VisitExpiryScheduler: 매일 00:10(acs.expiry.cron) visit_to가 지난 APPROVED를
  EXPIRED로 벌크 전이 → 재실/통계/리포트가 만료건을 유효로 취급하지 않음.
- @EnableScheduling 활성화.
- 테스트: CheckInConcurrencyTest(2스레드 → 정확히 1건 입장),
  VisitExpirySchedulerTest(과거 승인만 만료). 전체 3건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
unknown
2026-07-03 11:01:25 +09:00
parent d15ffe3fce
commit 2a6d3a620b
6 changed files with 222 additions and 2 deletions

View File

@@ -2,8 +2,10 @@ package com.itcenter.acs;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class AcsApplication {
public static void main(String[] args) {
SpringApplication.run(AcsApplication.class, args);

View File

@@ -2,7 +2,10 @@ package com.itcenter.acs.repository;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -16,6 +19,25 @@ public interface VisitRequestRepository extends JpaRepository<VisitRequest, Long
List<VisitRequest> findAllByOrderByVisitFromDesc();
Optional<VisitRequest> findByQrToken(String qrToken);
/**
* Check-in/out load the visit under a row-level write lock so the concurrent
* "already inside? / exited today?" checks and the event insert are serialized
* per visit (prevents duplicate-entry TOCTOU races). See AccessService.resolve.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select vr from VisitRequest vr where vr.id = :id")
Optional<VisitRequest> findByIdForUpdate(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select vr from VisitRequest vr where vr.qrToken = :qrToken")
Optional<VisitRequest> findByQrTokenForUpdate(@Param("qrToken") String qrToken);
/** 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 " +
"where vr.status = com.itcenter.acs.entity.VisitStatus.APPROVED and vr.visitTo < :cutoff")
int expireApprovedBefore(@Param("cutoff") LocalDateTime cutoff);
long countByStatus(VisitStatus status);
long countByVisitFromBetween(LocalDateTime from, LocalDateTime to);
List<VisitRequest> findByVisitFromBetweenOrderByVisitFromAsc(LocalDateTime from, LocalDateTime to);

View File

@@ -157,13 +157,18 @@ public class AccessService {
// ===== helpers =====
/**
* Loads the visit under a pessimistic write lock so concurrent check-in/out of the
* same visit are serialized — the second caller blocks until the first commits and
* then sees its event, preventing duplicate-entry races.
*/
private VisitRequest resolve(CheckInRequest req) {
if (req.getQrToken() != null && !req.getQrToken().isBlank()) {
return visitRequestRepository.findByQrToken(req.getQrToken().trim())
return visitRequestRepository.findByQrTokenForUpdate(req.getQrToken().trim())
.orElseThrow(() -> ApiException.notFound("유효하지 않은 QR 코드입니다."));
}
if (req.getVisitRequestId() != null) {
return visitRequestRepository.findById(req.getVisitRequestId())
return visitRequestRepository.findByIdForUpdate(req.getVisitRequestId())
.orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다."));
}
throw ApiException.badRequest("QR 코드 또는 방문 신청을 지정하세요.");

View File

@@ -0,0 +1,37 @@
package com.itcenter.acs.service;
import com.itcenter.acs.repository.VisitRequestRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* Transitions APPROVED visits whose window has fully passed to EXPIRED.
* <p>
* Check-in already expires a stale visit lazily on access, but nobody may ever
* attempt to enter — this batch keeps the in-progress lists, stats and reports
* from treating long-past approvals as still valid.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VisitExpiryScheduler {
private final VisitRequestRepository visitRequestRepository;
/** Daily at 00:10 by default; override with acs.expiry.cron. */
@Scheduled(cron = "${acs.expiry.cron:0 10 0 * * *}")
@Transactional
public void expireStaleApprovals() {
LocalDateTime cutoff = LocalDate.now().atStartOfDay();
int expired = visitRequestRepository.expireApprovedBefore(cutoff);
if (expired > 0) {
log.info("[expiry] 방문일이 지난 승인 {}건을 EXPIRED로 전이했습니다.", expired);
}
}
}

View File

@@ -0,0 +1,95 @@
package com.itcenter.acs;
import com.itcenter.acs.dto.CheckInRequest;
import com.itcenter.acs.entity.Direction;
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.AccessEventRepository;
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 java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Two operators scanning the same visit at the same instant must not both succeed.
* The pessimistic lock in AccessService.resolve serializes them: exactly one entry.
*/
@SpringBootTest
class CheckInConcurrencyTest {
@Autowired AccessService accessService;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Autowired AccessEventRepository accessEventRepository;
@Test
void concurrentCheckInsProduceExactlyOneEntry() throws Exception {
User host = userRepository.findByUsername("host").orElseThrow();
Visitor v = new Visitor();
v.setName("동시성테스트-" + System.nanoTime());
visitorRepository.save(v);
LocalDate today = LocalDate.now();
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("test");
vr.setVisitFrom(today.atStartOfDay());
vr.setVisitTo(today.atTime(23, 59));
vr.setStatus(VisitStatus.APPROVED);
Long vrId = visitRequestRepository.save(vr).getId();
int threads = 2;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch go = new CountDownLatch(1);
AtomicInteger success = new AtomicInteger();
AtomicInteger failure = new AtomicInteger();
for (int i = 0; i < threads; i++) {
pool.submit(() -> {
CheckInRequest req = new CheckInRequest();
req.setVisitRequestId(vrId);
req.setGateId("TEST-GATE");
ready.countDown();
try {
go.await();
accessService.checkIn(req, null);
success.incrementAndGet();
} catch (Exception e) {
failure.incrementAndGet();
}
return null;
});
}
ready.await(5, TimeUnit.SECONDS);
go.countDown(); // fire both at once
pool.shutdown();
assertThat(pool.awaitTermination(15, TimeUnit.SECONDS)).isTrue();
long entryEvents = accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(vrId).stream()
.filter(e -> e.getDirection() == Direction.IN)
.count();
assertThat(success.get()).as("exactly one check-in succeeds").isEqualTo(1);
assertThat(failure.get()).as("the other is rejected").isEqualTo(1);
assertThat(entryEvents).as("only one IN event recorded").isEqualTo(1);
}
}

View File

@@ -0,0 +1,59 @@
package com.itcenter.acs;
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.VisitExpiryScheduler;
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 java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
/** Verifies the scheduled batch expires past-window approvals and leaves current ones alone. */
@SpringBootTest
class VisitExpirySchedulerTest {
@Autowired VisitExpiryScheduler scheduler;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Test
void expiresApprovalsWhoseWindowHasPassed_keepsCurrentOnes() {
User host = userRepository.findByUsername("host").orElseThrow();
LocalDate today = LocalDate.now();
Long staleId = saveApproved(host, today.minusDays(3).atTime(9, 0), today.minusDays(3).atTime(18, 0));
Long currentId = saveApproved(host, today.atStartOfDay(), today.atTime(23, 59));
scheduler.expireStaleApprovals();
assertThat(visitRequestRepository.findById(staleId).orElseThrow().getStatus())
.isEqualTo(VisitStatus.EXPIRED);
assertThat(visitRequestRepository.findById(currentId).orElseThrow().getStatus())
.isEqualTo(VisitStatus.APPROVED);
}
private Long saveApproved(User host, LocalDateTime from, LocalDateTime to) {
Visitor v = new Visitor();
v.setName("만료테스트-" + System.nanoTime());
visitorRepository.save(v);
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("test");
vr.setVisitFrom(from);
vr.setVisitTo(to);
vr.setStatus(VisitStatus.APPROVED);
return visitRequestRepository.save(vr).getId();
}
}