Skip to content

fix: [ALT-301] 근무 배정·대타 승인 동시 요청의 시간 충돌 직렬화 - #111

Merged
hodoon merged 6 commits into
devfrom
fix/alt-301
Nov 4, 2026
Merged

hodoon merged 6 commits into
devfrom
fix/alt-301

Conversation

@ysw789

@ysw789 ysw789 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

동일 사용자의 겹치는 근무가 충돌 검사와 저장 사이의 동시 요청으로 모두 확정되는 문제를 고쳤습니다. 배정·근무자 교체·시간 변경·대타 승인·고정 생성이 사용자 행 잠금을 공유하고 잠금 후 최신 충돌을 검사해 같은 트랜잭션에 저장합니다.

변경

  • 기존 port의 사용자 ID 오름차순/일정 행 잠금 조회: QueryDSL PESSIMISTIC_WRITE 및 SET LOCAL lock_timeout5s.
  • 수동4경로는 일정→사용자 ID 오름차순 잠금으로 최신 시간/배정을 읽습니다. 배치는 사용자들을 한번 잠근 뒤 DB근무를 한번 재조회하고 기존 메모리 충돌검사/saveAll 유지.
  • 기존 자기 제외·인접 시간·충돌 코드/메시지·조회/급여·대타 후보/수락 사전 검사·고정 반복 정책 유지.

검증

  • PostgreSQL Red5건: 배정 대 배정/타업장/대타승인/배치 대 수동 겹침 확정2건, 시간변경 대 교체의 stale시간 승인 재현.
  • 관련42개 및 JAVA_TOOL_OPTIONS=-Dapi.version=1.44 ./gradlew clean build 최초 전체536개 성공(1m1s, skipped/실패0); 아래 test-only 후속의 최신 전체536개 성공(1m). 임시 Testcontainers 정리 완료.
  • 독립 localhost HTTP E2E: 실제 PG holder와 lock_waiters2 관찰 후 동시 요청; 배정/타업장/대타/시간변경/교체/동일일정 경합의 승자만 저장. 최종 DB동일사용자 CONFIRMED 겹침0.
  • HTTP user/shift timeout 각각429/E001(약6초/5초) 및 전후DB무변경. 인접/비중첩/자기제외/일반 교체·승인/권한 회귀 통과.
  • 배치 대 수동은 실제 PG 서비스 별도Tx 검증이며 HTTP가 아닙니다. 자정 scheduler·브라우저·배포 QA는 남음.
  • 운영 코드는 기존 HTTP E2E와 동일하며 PG 테스트 한 사례는 아래 계약 분리 후 재검증했습니다. 합성계정·격리PG17.2/Redis7.4.2 사용 및 서버·임시자원·민감파일 정리 완료. 외부발송 없음.

기존 별도 관찰·계약 보존

삭제한 원근무의 대타 승인으로 일정이 CONFIRMED로 부활하는 기존 동작을 PG/HTTP에서 재현했습니다. 이번 잠금 작업이 무관한 상태 정책을 바꾸지 않도록 기존 승인 및 수동3경로 삭제일정404를 보존했습니다. 부활 결함은 이 PR에서 미해결이며 ALT305에서 별도 수정했고 독립 HTTP E2E 검증 중입니다. 위 부활 관찰은 standalone ALT301 당시의 기존 동작입니다.

겹침·병합·통합

  • PR105: 동일query adapter/근무 관련파일 겹침, 예상수입 정책은 가져오지 않음. 별도 merge-tree/통합 확인 필요.
  • ALT298: 동일adapter 월조회null하한 변경. 이PR은 그메서드를 고치지 않음. ALT298→ALT301 순서 권장, 양쪽변경 유지.
  • ALT302: 대타상세 신규읽기API, ALT309: 고정 생성 정책, ALT311: 동시성 회귀. 조합·회귀는 별도 통합 검증.
  • 새migration 없음. 최신 사용자 승인 통합 기준은 V15스키마snapshot→PR107 V16 적용입니다. 저장소 초기 V1 create-schema baseline이 없어 빈DB V1부터 전체 적용으로 주장하지 않습니다.
  • 이 PR은 draft이며 ready/병합은 사람이 진행합니다.

관련 작업: ALT-301

ALT305 통합을 위한 test-only 후속

  • 운영 코드 변경 없이 PG 테스트 한 사례를 보완했습니다. 삭제 일정 승인 부활이라는 기존 버그를 기대값으로 고정하던 사례를 findByIdForUpdate가 DELETED 행도 읽으며 상태를 변경하지 않는다는 ALT301의 실제 query 계약 검증으로 바꿨습니다.
  • 기존 수동3경로의 삭제일정404 검증은 유지합니다. 삭제 대타 승인 자체를 거부하는 도메인 불변식과 일 조회 제외는 별도 ALT305가 검증하며 이 PR에 그 source patch를 가져오지 않았습니다.
  • 보완 후 PG16개 및 JAVA_TOOL_OPTIONS=-Dapi.version=1.44 ./gradlew clean build 전체536개 통과(1m, skipped/실패0).
  • src/main 운영 diff SHA1 02937e5e059aedd70552fbc2f34b75c0fea7980a가 기존 HTTP E2E 코드와 동일합니다. 기존 E2E의 end-diff/sql-assertions/assertions SHA 검증도 모두 일치합니다. 이번 보완을 새 HTTP E2E 수행으로 표현하지 않습니다.

@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@hodoon hodoon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰했습니다. 인라인 코멘트 확인 부탁드립니다.

WorkspaceWorker workspaceWorker = schedule.getWorkspaceWorker();
Long userId = workspaceWorker.getUser().getId();
List<WorkspaceShift> existingShifts = existingShiftsByUserId.getOrDefault(userId, List.of());
List<WorkspaceShift> existingShifts = latestShiftsByUserId.getOrDefault(userId, List.of());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이제 겹침 판정이 잠금 후 다시 조회한 latestShiftsByUserId만 사용해서, existingShiftsByUserId 파라미터와 Javadoc은 더 이상 의미가 없어진 것 같습니다.
호출 측 GenerateNextMonthWorkspaceShift도 buildExistingShiftsMap 사전 조회와 커밋 후 맵 병합을 그대로 수행하고 있어, 결과에 반영되지 않는 쿼리가 배치마다 한 번씩 나가고 있습니다.
Tx가 업장마다 커밋한 뒤 DB를 다시 조회하므로 앞선 업장에서 만든 근무도 그 조회에 포함됩니다. 파라미터와 사전 조회·병합 코드를 함께 정리하면 어떨까요?

List<WorkspaceShift> findByWorkspaceAndDateRange(Workspace workspace, int year, int month);
List<WorkspaceShift> findByManagerAndDateRange(ManagerUser managerUser, Long workspaceId, int year, int month);
Optional<WorkspaceShift> findById(Long id);
Optional<WorkspaceShift> findByIdForUpdate(Long id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 port들(SubstituteRequestQueryRepository, WorkspaceQueryRepository, PostingQueryRepository)은 비관적 잠금 조회에 findByIdWithPessimisticLock이라는 이름을 쓰고 있어서, 이름을 findByIdWithPessimisticLock / findAllByIdWithPessimisticLock으로 맞추면 일관성이 생길 것 같습니다.
또 SET LOCAL lock_timeout = '5s' 문자열이 두 어댑터에 직접 적혀 있는데, PostingQueryRepositoryImpl처럼 LOCK_TIMEOUT_STATEMENT 상수와 H2 비호환 주석을 두는 방식을 따르면 어떨까요?

@hodoon hodoon self-assigned this Oct 21, 2026
hodoon added a commit that referenced this pull request Oct 22, 2026
겹침 판정이 사용자 잠금 후 재조회한 근무만 쓰게 되면서 existingShiftsByUserId 파라미터와
배치의 사전 조회·맵 병합이 결과에 반영되지 않는 쿼리로 남았다. 업장 Tx가 커밋 후 다시 조회하므로
앞선 업장 생성분도 판정에 포함돼 제거해도 동작은 같다.
잠금 조회 메서드는 기존 포트 관례(findByIdWithPessimisticLock)에 맞추고 lock_timeout 문은
PostingQueryRepositoryImpl과 같은 LOCK_TIMEOUT_STATEMENT 상수로 둔다.
ysw789 and others added 6 commits November 4, 2026 19:43
겹침 판정이 사용자 잠금 후 재조회한 근무만 쓰게 되면서 existingShiftsByUserId 파라미터와
배치의 사전 조회·맵 병합이 결과에 반영되지 않는 쿼리로 남았다. 업장 Tx가 커밋 후 다시 조회하므로
앞선 업장 생성분도 판정에 포함돼 제거해도 동작은 같다.
잠금 조회 메서드는 기존 포트 관례(findByIdWithPessimisticLock)에 맞추고 lock_timeout 문은
PostingQueryRepositoryImpl과 같은 LOCK_TIMEOUT_STATEMENT 상수로 둔다.
사용자 잠금 직렬화로 같은 근무자 동시 요청이 잠금 대기 초과 시 429(E001)를 받을 수 있고,
잠금 후 재검사로 겹침 400(B001)과 중복 배정 409(B020)가 동시 요청에서도 확실히 나오는데
문서에 빠져 있어 클라이언트가 처리 대상을 알 수 있도록 남긴다.
@hodoon
hodoon marked this pull request as ready for review November 4, 2026 10:51
@hodoon
hodoon merged commit 419143f into dev Nov 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants