From 3c2480ed9f09a20c60feefcd02daf37fe46dd854 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Sat, 31 Oct 2026 00:00:23 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(auth):=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=ED=95=98=EB=A9=B4=20=EA=B0=99=EC=9D=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=EC=9D=98=20=EA=B0=B1=EC=8B=A0=20=EA=B2=BD=ED=95=A9=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EB=8F=84=20=ED=95=A8=EA=BB=98=20=ED=8F=90?= =?UTF-8?q?=EA=B8=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회전 유예(#371)로 같은 RT 를 동시에 갱신하면 한 세션(family)에 후속 RT 가 여럿 살아 있게 되는데, 로그아웃은 받은 RT 한 개만 폐기해 다른 후속 RT 로 계속 갱신할 수 있었다. 로그아웃은 이제 family 의 살아 있는 RT 를 모두 폐기하고, 갱신·로그아웃 모두 family 활성 RT 잠금을 먼저 잡아 직렬화한다. 유예 판단의 후속 RT 조회도 잠금 읽기로 바꿔 잠금 전 스냅샷 때문에 로그아웃된 세션이 유예를 받지 않게 했다. Fixes #376 Co-Authored-By: Claude Opus 5.5 --- .../auth/controller/AuthController.java | 3 +- .../repository/RefreshTokenRepository.java | 28 +++++- .../auth/service/AuthService.java | 24 +++-- .../auth/SessionDeviceFlowIT.java | 92 ++++++++++++++++++- 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/controller/AuthController.java b/src/main/java/com/ruleup/ruleup_backend/auth/controller/AuthController.java index dd1c65a9..336286e1 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/controller/AuthController.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/controller/AuthController.java @@ -268,7 +268,8 @@ public ApiResponse refresh(@RequestBody RefreshRequest request) { @Operation( summary = "로그아웃", description = """ - 현재 기기의 refreshToken 을 폐기한다. accessToken 은 남은 수명 동안 유효하므로 클라이언트가 함께 지운다. + 현재 기기의 세션을 끊는다. 보낸 refreshToken 과 같은 로그인 세션에서 발급된 refreshToken 을 모두 폐기한다 + (갱신 경합으로 함께 발급된 토큰 포함). accessToken 은 남은 수명 동안 유효하므로 클라이언트가 함께 지운다. **멱등**하다. 이미 로그아웃했거나 존재하지 않는 refreshToken 을 보내도 200 으로 성공 처리한다 (로그아웃이 실패해 로그인 상태에 갇히는 편이 더 나쁘다). diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/repository/RefreshTokenRepository.java b/src/main/java/com/ruleup/ruleup_backend/auth/repository/RefreshTokenRepository.java index aaa28b01..aec9a349 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/repository/RefreshTokenRepository.java @@ -25,21 +25,41 @@ public interface RefreshTokenRepository extends JpaRepository findByTokenHashForUpdate(@Param("tokenHash") byte[] tokenHash); - /** 이 토큰을 회전해 발급된 후속 토큰들 — 회전 유예 판단용(parent_token_id 인덱스). */ - List findByParentTokenId(UUID parentTokenId); + /** 잠금 없이 family(=로그인 세션) ID만 읽는다 — family 잠금을 잡기 전 단계. family_id 는 바뀌지 않는다. */ + @Query("select t.familyId from RefreshToken t where t.tokenHash = :tokenHash") + Optional findFamilyIdByTokenHash(@Param("tokenHash") byte[] tokenHash); + + /** + * family 의 살아 있는 RT 전체를 배타 잠금 — 같은 세션의 갱신·로그아웃을 직렬화한다 + * (idx_refresh_tokens_family_active 범위 잠금이라 잠금 중엔 이 family 에 새 RT도 끼어들지 못한다). + * 회전 유예로 같은 세션에 RT가 여럿 살아 있을 수 있어 제출된 토큰 한 행만 잠가서는 부족하다. + * 갱신·로그아웃 모두 개별 토큰보다 이 잠금을 먼저 잡아야 서로 교착하지 않는다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from RefreshToken t where t.familyId = :familyId and t.revokedAt is null") + List lockActiveFamily(@Param("familyId") UUID familyId); + + /** + * 이 토큰을 회전해 발급된 후속 토큰들 — 회전 유예 판단용(parent_token_id 인덱스). + * 잠금 읽기로 최신 상태를 본다. 일반 읽기는 family 잠금 전 스냅샷이라 그 사이 로그아웃이 폐기한 후속을 살아 있다고 볼 수 있다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from RefreshToken t where t.parentTokenId = :parentTokenId") + List findByParentTokenIdForUpdate(@Param("parentTokenId") UUID parentTokenId); /** 사용자의 활성 RT 전체 revoke — 로그아웃 전체·신규 기기 로그인·탈퇴·정지 시. */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("update RefreshToken t set t.revokedAt = :now where t.user.id = :userId and t.revokedAt is null") int revokeAllByUserId(@Param("userId") UUID userId, @Param("now") Instant now); - /** 재사용 감지 시 해당 family의 활성 RT 전체 revoke (탈취 대응). */ + /** 해당 family의 활성 RT 전체 revoke — 재사용 감지(탈취 대응)·로그아웃 시. */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("update RefreshToken t set t.revokedAt = :now where t.familyId = :familyId and t.revokedAt is null") int revokeFamily(@Param("familyId") UUID familyId, @Param("now") Instant now); diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java b/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java index 0ca54983..f71ba02b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java @@ -618,9 +618,13 @@ public TokenResponse refresh(String refreshTokenValue) { Claims claims = parseRefreshToken(refreshTokenValue); UUID userId = UUID.fromString(claims.getSubject()); - // 같은 RT의 동시 회전을 직렬화한다. 선발 요청이 revoke+새 RT 발급을 커밋한 뒤 - // 후발 요청은 revoked=true를 읽어 재사용으로 처리한다. - RefreshToken stored = refreshTokenRepository.findByTokenHashForUpdate(TokenService.sha256(refreshTokenValue)) + // 같은 세션(family)의 갱신·로그아웃을 직렬화한다. 선발 요청이 revoke+새 RT 발급을 커밋한 뒤 + // 후발 요청은 revoked=true를 읽어 회전 유예·재사용 감지로 간다. 로그아웃이 먼저 끝났으면 유예도 받지 못한다. + byte[] tokenHash = TokenService.sha256(refreshTokenValue); + UUID familyId = refreshTokenRepository.findFamilyIdByTokenHash(tokenHash) + .orElseThrow(() -> new BusinessException(ErrorCode.SESSION_EXPIRED)); + refreshTokenRepository.lockActiveFamily(familyId); + RefreshToken stored = refreshTokenRepository.findByTokenHashForUpdate(tokenHash) .orElseThrow(() -> new BusinessException(ErrorCode.SESSION_EXPIRED)); // 회전 유예 — 방금 회전된 토큰이 몇 초 뒤 같은 기기에서 다시 오는 것은 탈취가 아니라 앱의 갱신 경합이다 @@ -665,15 +669,23 @@ public TokenResponse refresh(String refreshTokenValue) { private boolean inRotationGrace(RefreshToken stored) { if (stored.getReuseDetectedAt() != null || stored.isExpired()) return false; if (stored.getRevokedAt().isBefore(Instant.now().minus(ROTATION_GRACE))) return false; - List successors = refreshTokenRepository.findByParentTokenId(stored.getId()); + List successors = refreshTokenRepository.findByParentTokenIdForUpdate(stored.getId()); return !successors.isEmpty() && successors.stream().noneMatch(RefreshToken::isRevoked); } // ===== 로그아웃 (멱등) ===== + /** + * 제출된 RT가 속한 세션(family)의 살아 있는 RT를 모두 폐기한다. 회전 유예로 같은 세션에 RT가 여럿 생길 수 있어 + * 받은 토큰 하나만 폐기하면 다른 쪽으로 계속 갱신된다(#376). 다른 기기·다른 로그인 세션은 family 가 달라 그대로다. + * 갱신과 같은 family 잠금을 잡으므로 진행 중인 갱신이 만든 RT까지 함께 폐기된다. + */ @Transactional public void logout(String refreshTokenValue) { - refreshTokenRepository.findByTokenHash(TokenService.sha256(refreshTokenValue)) - .ifPresent(RefreshToken::revoke); // 없거나 이미 무효여도 성공 + refreshTokenRepository.findFamilyIdByTokenHash(TokenService.sha256(refreshTokenValue)) + .ifPresent(familyId -> { // 없거나 이미 무효여도 성공 + refreshTokenRepository.lockActiveFamily(familyId); + refreshTokenRepository.revokeFamily(familyId, Instant.now()); + }); } // ===== 닉네임 형식/중복 검사 (스펙 4.6) ===== diff --git a/src/test/java/com/ruleup/ruleup_backend/auth/SessionDeviceFlowIT.java b/src/test/java/com/ruleup/ruleup_backend/auth/SessionDeviceFlowIT.java index 5141780f..5dc23eee 100644 --- a/src/test/java/com/ruleup/ruleup_backend/auth/SessionDeviceFlowIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/auth/SessionDeviceFlowIT.java @@ -58,7 +58,7 @@ * 3) 설치 인계: 기존 회원이 타 계정 점유 설치에서 로그인하면 이전 계정 연결 해제(+세션 종료) * 4) RT 회전: refresh 마다 새 페어 + 기존 즉시 revoke (같은 family 유지) * 5) RT 재사용 감지: revoke 된 RT 재제출 → family 전체 revoke (탈취 대응) — 새 RT도 무효 - * 6) 로그아웃: RT revoke + 멱등, 이후 refresh 401 + * 6) 로그아웃: 같은 세션(family) RT 전체 revoke + 멱등, 이후 refresh 401 — 갱신 경합으로 생긴 RT 포함 * 7) social_tokens: 로그인 시 IdP 토큰 암호화 저장(unlink 근거) */ @SpringBootTest @@ -236,6 +236,31 @@ private static byte[] bytes(UUID id) { .array(); } + /** 두 요청을 최대한 같은 순간에 시작시키고 결과를 제출 순서대로 돌려준다. */ + private List runConcurrently(java.util.concurrent.Callable a, + java.util.concurrent.Callable b) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + List> futures = new java.util.ArrayList<>(); + for (java.util.concurrent.Callable call : List.of(a, b)) { + futures.add(executor.submit(() -> { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("동시 요청 시작 대기 시간 초과"); + } + return call.call(); + })); + } + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + return List.of(futures.get(0).get(10, TimeUnit.SECONDS), futures.get(1).get(10, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } + private void expectError(MvcResult res, int status, String code) throws Exception { assertThat(res.getResponse().getStatus()).as("HTTP status").isEqualTo(status); assertThat((String) read(res, "$.error.code")).isEqualTo(code); @@ -561,6 +586,71 @@ void logout_is_not_undone_by_stale_refresh() throws Exception { expectError(refresh(rt1), 401, "SESSION_EXPIRED"); } + @Test + @DisplayName("동시 갱신으로 RT 가 둘 생긴 세션은 하나로 로그아웃해도 둘 다 갱신할 수 없다(#376)") + void logout_revokes_every_token_from_refresh_race() throws Exception { + String tag = uniq(); + String rt1 = read(signup(tag), "$.data.refreshToken"); + + List raced = runConcurrently(() -> refresh(rt1), () -> refresh(rt1)); + assertThat(raced).extracting(r -> r.getResponse().getStatus()).containsExactly(200, 200); + String atA = read(raced.get(0), "$.data.accessToken"); + String rtA = read(raced.get(0), "$.data.refreshToken"); + String rtB = read(raced.get(1), "$.data.refreshToken"); + assertThat(rtA).isNotEqualTo(rtB); + + assertThat(logout(atA, rtA).getResponse().getStatus()).isEqualTo(200); + + expectError(refresh(rtB), 401, "SESSION_EXPIRED"); + expectError(refresh(rtA), 401, "SESSION_EXPIRED"); + expectError(refresh(rt1), 401, "SESSION_EXPIRED"); // 유예 창 안의 직전 RT 로도 되살릴 수 없다 + assertThat(logout(atA, rtB).getResponse().getStatus()).as("멱등").isEqualTo(200); + } + + @Test + @DisplayName("갱신과 로그아웃이 동시에 와도 로그아웃이 끝난 세션엔 갱신 가능한 RT 가 남지 않는다(#376)") + void concurrent_refresh_and_logout_leave_no_live_token() throws Exception { + String tag = uniq(); + String rt1 = read(signup(tag), "$.data.refreshToken"); + List twins = runConcurrently(() -> refresh(rt1), () -> refresh(rt1)); + String atA = read(twins.get(0), "$.data.accessToken"); + String rtA = read(twins.get(0), "$.data.refreshToken"); + String rtB = read(twins.get(1), "$.data.refreshToken"); + + // 한쪽 RT 로 로그아웃하는 동안 다른 쪽 RT 로 갱신 — 어느 쪽이 먼저 잠금을 잡든 결과는 같아야 한다. + List raced = runConcurrently(() -> logout(atA, rtA), () -> refresh(rtB)); + assertThat(raced.get(0).getResponse().getStatus()).isEqualTo(200); + MvcResult refreshed = raced.get(1); + if (refreshed.getResponse().getStatus() == 200) { + // 갱신이 먼저 커밋됐다 — 그때 받은 RT 도 로그아웃이 함께 폐기했어야 한다. + expectError(refresh(read(refreshed, "$.data.refreshToken")), 401, "SESSION_EXPIRED"); + } else { + expectError(refreshed, 401, "SESSION_EXPIRED"); + } + expectError(refresh(rtB), 401, "SESSION_EXPIRED"); + assertThat(jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM refresh_tokens + WHERE family_id = (SELECT family_id FROM refresh_tokens WHERE token_hash = ?) + AND revoked_at IS NULL + """, Integer.class, (Object) TokenService.sha256(rt1))).isZero(); + } + + @Test + @DisplayName("로그아웃은 그 세션만 끊는다 — 같은 기기 재로그인으로 생긴 다른 세션은 그대로다") + void logout_keeps_other_sessions() throws Exception { + String tag = uniq(); + MvcResult signup = signup(tag); + String at = read(signup, "$.data.accessToken"); + String oldRefresh = read(signup, "$.data.refreshToken"); + MvcResult relogin = postJson("/api/v1/auth/oauth/kakao", loginBody(tag, "inst-" + tag, "dev-" + tag)); + String otherRefresh = read(relogin, "$.data.refreshToken"); + + assertThat(logout(at, oldRefresh).getResponse().getStatus()).isEqualTo(200); + + expectError(refresh(oldRefresh), 401, "SESSION_EXPIRED"); + assertThat(refresh(otherRefresh).getResponse().getStatus()).isEqualTo(200); + } + @Test @DisplayName("로그아웃하면 해당 RT 는 무효가 되고, 로그아웃은 멱등이다") void logout_revokes_and_is_idempotent() throws Exception { From fcccca2c243929757fcc4b5af4793661408ec1db Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 30 Oct 2026 23:57:43 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix(verification):=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=ED=95=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=EB=8A=94=20=EB=B9=88?= =?UTF-8?q?=EB=8F=84=20=EC=A0=9C=ED=95=9C=20=EC=8A=AC=EB=A1=AF=EC=9D=84=20?= =?UTF-8?q?=EB=8F=8C=EB=A0=A4=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 간격 검사가 트랜잭션 밖이라 롤백 콜백이 등록되지 않아, 실패한 sync 가 슬롯을 쥔 채 남았다. 제한기는 반환 가능한 슬롯을 돌려주고, 서비스가 데드락 재시도까지 끝내 실패하면 그 슬롯을 반환한다. 재시도는 슬롯을 다시 잡지 않으므로 429 에 걸리지 않는다. 회귀 IT 는 간격을 300초로 켜고 검증한다. Fixes #378 Co-Authored-By: Claude Opus 5.5 --- .../verification/service/SyncRateLimiter.java | 34 +++++++++-------- .../service/VerificationSyncService.java | 12 +++++- .../verification/SyncRateLimiterTest.java | 38 ++++++++----------- .../VerificationSyncDeadlockRetryIT.java | 23 +++++++++++ 4 files changed, 68 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/SyncRateLimiter.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/SyncRateLimiter.java index a08afef1..6bec2d80 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/SyncRateLimiter.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/SyncRateLimiter.java @@ -4,8 +4,6 @@ import com.ruleup.ruleup_backend.common.error.ErrorCode; import com.ruleup.ruleup_backend.verification.config.VerificationProperties; import org.springframework.stereotype.Component; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Instant; import java.util.Map; @@ -23,6 +21,9 @@ @Component public class SyncRateLimiter { + /** 아무것도 차지하지 않은 슬롯(제한을 끈 경우). */ + private static final Slot NONE = () -> { }; + private final Map lastSyncAt = new ConcurrentHashMap<>(); private final VerificationProperties properties; @@ -30,10 +31,14 @@ public SyncRateLimiter(VerificationProperties properties) { this.properties = properties; } - public void check(String userId, boolean backlog) { + /** + * 간격을 검사하고 슬롯을 차지한다. 처리에 끝내 실패하면 호출자가 {@link Slot#release()} 로 돌려준다. + * 트랜잭션 콜백에 맡기지 않는 이유: 검사는 트랜잭션 바깥에서 하므로 등록할 곳이 없다(#378). + */ + public Slot check(String userId, boolean backlog) { long minInterval = 1000L * (backlog ? properties.syncBacklogMinIntervalSec() : properties.syncMinIntervalSec()); - if (minInterval <= 0) return; // 제한 없음(설정으로 끈 경우) + if (minInterval <= 0) return NONE; // 제한 없음(설정으로 끈 경우) long now = Instant.now().toEpochMilli(); Long prev = lastSyncAt.put(userId, now); if (prev != null && now - prev < minInterval) { @@ -42,7 +47,7 @@ public void check(String userId, boolean backlog) { throw BusinessException.rateLimited(ErrorCode.SYNC_TOO_FREQUENT, (minInterval - (now - prev) + 999) / 1000); } - releaseOnRollback(userId, now, prev); + return () -> release(userId, now, prev); } /** @@ -50,15 +55,14 @@ public void check(String userId, boolean backlog) { * 슬롯을 차지해, 사용자가 곧바로 다시 누른 「동기화」가 429 로 튕긴다 — 실패가 두 번 보인다. * 그 사이 다른 요청이 시각을 갱신했으면 건드리지 않는다. */ - private void releaseOnRollback(String userId, long stamp, Long prev) { - if (!TransactionSynchronizationManager.isSynchronizationActive()) return; - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCompletion(int status) { - if (status == STATUS_COMMITTED) return; - if (prev == null) lastSyncAt.remove(userId, stamp); - else lastSyncAt.replace(userId, stamp, prev); - } - }); + private void release(String userId, long stamp, Long prev) { + if (prev == null) lastSyncAt.remove(userId, stamp); + else lastSyncAt.replace(userId, stamp, prev); + } + + /** {@link #check} 가 차지한 간격 슬롯. */ + @FunctionalInterface + public interface Slot { + void release(); } } diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java index 875c85b6..efdc4f6b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java @@ -182,6 +182,7 @@ void setTransactionManager(PlatformTransactionManager transactionManager) { * 한쪽이 데드락으로 진다 — 부하테스트(2026-10) 피크에서 5xx 11건이 전부 이것이었다. 진 쪽은 아무것도 남기지 않고 * 롤백되므로 처음부터 다시 하면 된다. 재시도를 트랜잭션 밖에 두는 이유: 안에서 잡으면 이미 롤백 표시가 된 뒤다. * 빈도 제한을 재시도에 다시 태우면 자기 자신의 첫 시도에 막혀 429 가 된다. + * 재시도까지 끝내 실패하면 슬롯을 돌려준다 — 곧바로 다시 누른 동기화가 429 로 튕기지 않게(#378). */ public SyncResponse sync(UUID userId, SyncRequest req) { long startedAt = System.nanoTime(); @@ -198,7 +199,16 @@ public SyncResponse sync(UUID userId, SyncRequest req) { if (signals.size() > MAX_SIGNALS_PER_SYNC) { throw new BusinessException(ErrorCode.SYNC_PAYLOAD_TOO_LARGE); // 413 — 클라는 분할 재전송 } - rateLimiter.check(userId.toString(), backlog); + SyncRateLimiter.Slot slot = rateLimiter.check(userId.toString(), backlog); + try { + return syncWithDeadlockRetry(userId, req, signals, startedAt); + } catch (RuntimeException | Error e) { + slot.release(); + throw e; + } + } + + private SyncResponse syncWithDeadlockRetry(UUID userId, SyncRequest req, List signals, long startedAt) { List ignored = signals.stream() .map(SyncSignal::type) .filter(t -> t == null || !KNOWN_SIGNAL_TYPES.contains(t)) diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/SyncRateLimiterTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/SyncRateLimiterTest.java index 4133abbc..9dadfc0b 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/SyncRateLimiterTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/SyncRateLimiterTest.java @@ -3,11 +3,8 @@ import com.ruleup.ruleup_backend.common.error.BusinessException; import com.ruleup.ruleup_backend.verification.config.VerificationProperties; import com.ruleup.ruleup_backend.verification.service.SyncRateLimiter; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -26,18 +23,10 @@ class SyncRateLimiterTest { limiter = new SyncRateLimiter(properties); } - @AfterEach - void clear() { - if (TransactionSynchronizationManager.isSynchronizationActive()) - TransactionSynchronizationManager.clearSynchronization(); - } - @Test - @DisplayName("커밋된 sync 뒤 300초 안의 재요청은 429") - void committedHoldsSlot() { - TransactionSynchronizationManager.initSynchronization(); + @DisplayName("성공한 sync 뒤 300초 안의 재요청은 429") + void successHoldsSlot() { limiter.check("u1", false); - complete(TransactionSynchronization.STATUS_COMMITTED); assertThatThrownBy(() -> limiter.check("u1", false)) .isInstanceOfSatisfying(BusinessException.class, e -> @@ -45,19 +34,22 @@ void committedHoldsSlot() { } @Test - @DisplayName("롤백된 sync 는 슬롯을 돌려준다") - void rolledBackReleasesSlot() { - TransactionSynchronizationManager.initSynchronization(); - limiter.check("u2", false); - complete(TransactionSynchronization.STATUS_ROLLED_BACK); + @DisplayName("돌려준 슬롯은 곧바로 다시 쓸 수 있다") + void releasedSlotIsReusable() { + limiter.check("u2", false).release(); - TransactionSynchronizationManager.initSynchronization(); assertThatCode(() -> limiter.check("u2", false)).doesNotThrowAnyException(); } - private static void complete(int status) { - var syncs = TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - syncs.forEach(s -> s.afterCompletion(status)); + @Test + @DisplayName("그 사이 다른 요청이 차지한 슬롯은 늦게 돌려줘도 건드리지 않는다") + void staleReleaseKeepsNewerSlot() throws InterruptedException { + SyncRateLimiter.Slot first = limiter.check("u3", false); + first.release(); + Thread.sleep(2); // 같은 밀리초면 시각이 같아 「다른 요청」을 구분할 수 없다 + limiter.check("u3", false); // 다음 요청이 새로 차지 + first.release(); // 앞 요청의 늦은 반환 + + assertThatThrownBy(() -> limiter.check("u3", false)).isInstanceOf(BusinessException.class); } } diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java index c1f7c2f9..bbcfa1f0 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java @@ -10,6 +10,7 @@ import org.springframework.context.annotation.Import; import org.springframework.dao.CannotAcquireLockException; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -30,9 +31,11 @@ /** * 같은 사용자의 sync 가 겹쳐 데드락으로 진 트랜잭션은 처음부터 다시 돈다 — 부하테스트(2026-10) 피크 5xx 11건의 원인. * 빈도 제한은 다시 태우지 않으므로 재시도가 자기 첫 시도에 막혀 429 가 되지 않는다. + * 그래서 이 클래스는 간격을 운영값처럼 켜 둔다 — 꺼 두면 「재시도가 429 가 아니다」를 검증하지 못한다. */ @SpringBootTest @Import(TestcontainersConfiguration.class) +@TestPropertySource(properties = "app.verification.sync-min-interval-sec=300") class VerificationSyncDeadlockRetryIT extends VerificationApiSupport { @Autowired WebApplicationContext wac; @@ -73,6 +76,26 @@ void givesUpAfterLimit() throws Exception { verify(ingest, times(3)).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); } + @Test + @DisplayName("끝내 실패한 sync 는 간격 슬롯을 돌려줘 곧바로 다시 보내도 429 가 아니다") + void finalFailureReleasesSlot() throws Exception { + Member me = member(uniq("deadlock-release")); + CannotAcquireLockException deadlock = new CannotAcquireLockException("Deadlock found when trying to get lock"); + doThrow(deadlock).doThrow(deadlock).doThrow(deadlock) + .doCallRealMethod() + .when(ingest).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); + + MvcResult failed = postJsonAuth("/api/v1/verifications/sync", me.token(), syncBody(List.of(steps("dl-3")))); + assertThat(failed.getResponse().getStatus()).isGreaterThanOrEqualTo(500); + + MvcResult retried = postJsonAuth("/api/v1/verifications/sync", me.token(), syncBody(List.of(steps("dl-3")))); + assertThat(retried.getResponse().getStatus()).as(retried.getResponse().getContentAsString()).isEqualTo(200); + + // 성공한 sync 는 슬롯을 그대로 쥔다. + MvcResult tooSoon = postJsonAuth("/api/v1/verifications/sync", me.token(), syncBody(List.of(steps("dl-4")))); + assertThat(tooSoon.getResponse().getStatus()).isEqualTo(429); + } + private static Map steps(String recordId) { Instant to = Instant.now(), from = to.minusSeconds(600); Map reading = new LinkedHashMap<>(); From e1bad5b4b5c1176e3228026e2a9f236671c25c39 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Sat, 31 Oct 2026 00:01:52 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix(challenge):=20=EB=AF=B8=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=ED=99=95=EC=A0=95=EB=90=9C=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=A7=8C=20=EC=A7=80=EC=9A=B0=EA=B3=A0=20S3=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EB=8B=A4=EC=9D=8C=20=ED=9A=8C=EC=B0=A8?= =?UTF-8?q?=EC=97=90=20=EB=8B=A4=EC=8B=9C=20=EC=8B=9C=EB=8F=84=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정리 배치는 후보를 한 건씩 image_url 유니크 인덱스로 잠그며 미등록을 다시 확인하고, 등록 경로(생성·수정)도 같은 행을 잠가 읽어 둘을 직렬화한다. 파일을 먼저 지우고 성공한 뒤에만 소유 기록을 지우며, 저장소 삭제 실패는 예외로 올려 그 건만 롤백한다. Fixes #381 Fixes #382 Co-Authored-By: Claude Opus 5.5 --- .../creation/ChallengeCreationService.java | 4 +- .../ChallengeImageUploadRepository.java | 16 ++- .../lifecycle/ChallengeCleanupService.java | 63 ++++++-- .../settings/ChallengeSettingsService.java | 3 +- .../common/image/ImageObjectStore.java | 3 + .../common/image/ImageStorageService.java | 3 + .../common/image/LocalImageObjectStore.java | 1 + .../common/image/S3ImageObjectStore.java | 4 +- .../challenge/ChallengeImageCleanupIT.java | 135 ++++++++++++++++++ 9 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeImageCleanupIT.java diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/creation/ChallengeCreationService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/creation/ChallengeCreationService.java index b0884179..3c98c88c 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/creation/ChallengeCreationService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/creation/ChallengeCreationService.java @@ -390,10 +390,10 @@ private ParamsResult validateParams(RoutineTemplate template, return new ParamsResult(values, specs); } - /** 이미지: 업로드 API 가 발급한 본인 소유 URL 만 허용. */ + /** 이미지: 업로드 API 가 발급한 본인 소유 URL 만 허용. 행을 잠가 미등록 정리와 줄을 세운다(#381). */ private ChallengeImageUpload validateImage(UUID userId, String imageUrl) { if (imageUrl == null || imageUrl.isBlank()) return null; - ChallengeImageUpload upload = imageUploadRepository.findByImageUrl(imageUrl) + ChallengeImageUpload upload = imageUploadRepository.findByImageUrlForUpdate(imageUrl) .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_IMAGE_URL)); if (!upload.ownedBy(userId)) throw new BusinessException(ErrorCode.IMAGE_NOT_OWNED); return upload; diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/creation/ChallengeImageUploadRepository.java b/src/main/java/com/ruleup/ruleup_backend/challenge/creation/ChallengeImageUploadRepository.java index c7ef2cc6..4e0d192a 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/creation/ChallengeImageUploadRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/creation/ChallengeImageUploadRepository.java @@ -1,11 +1,25 @@ package com.ruleup.ruleup_backend.challenge.creation; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; /** challenge_image_uploads 접근. */ public interface ChallengeImageUploadRepository extends JpaRepository { - Optional findByImageUrl(String imageUrl); + /** + * 등록 경로 전용 — 그 행을 잡고 읽는다({@code SELECT ... FOR UPDATE}). + * + *

미등록 이미지 정리도 같은 행을 잡은 채 파일을 지우므로 둘이 한 줄로 선다. 정리가 먼저면 + * 등록은 행이 사라진 뒤에 읽어 「없는 이미지」로 거절되고, 등록이 먼저면 정리는 등록된 행을 + * 보고 건너뛴다. 잠그지 않고 읽으면 정리가 파일을 지우는 사이 등록이 끼어들어 사라진 이미지를 + * 가리키는 방이 생긴다(#381). + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM ChallengeImageUpload u WHERE u.imageUrl = :imageUrl") + Optional findByImageUrlForUpdate(@Param("imageUrl") String imageUrl); } diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeCleanupService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeCleanupService.java index 5a2d0305..0545ed6f 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeCleanupService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeCleanupService.java @@ -9,6 +9,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.util.List; @@ -28,8 +29,13 @@ public class ChallengeCleanupService { private static final Logger log = LoggerFactory.getLogger(ChallengeCleanupService.class); + /** 미등록 업로드 정리 대상 — 업로드 후 24시간 넘게 어느 챌린지에도 등록되지 않은 것. */ + private static final String ORPHAN_CONDITION = + "registered_at IS NULL AND created_at < DATE_SUB(NOW(6), INTERVAL 24 HOUR)"; + private final JdbcTemplate jdbc; private final ImageStorageService imageStorage; + private final TransactionTemplate tx; @SchedulerLock(name = "ChallengeCleanupService.runDaily", lockAtMostFor = "PT1H", lockAtLeastFor = "PT1M") @Scheduled(cron = "0 40 3 * * *", zone = "Asia/Seoul") @@ -48,20 +54,55 @@ public void cleanupExpiredDrafts() { /** * 24시간 지나도록 챌린지에 등록되지 않은 업로드 이미지의 소유 기록과 파일을 정리한다. * - *

URL 을 먼저 읽고 행을 지운 뒤 파일을 지운다. 순서가 반대면 파일은 사라졌는데 - * 트랜잭션이 롤백돼 "행은 있는데 이미지가 없는" 상태가 남는다. 반대로 이 순서에서 파일 - * 삭제가 실패하면 객체만 남는데, 그건 아무도 참조하지 않는 쓰레기라 다음에 손으로 치우면 된다. + *

한 건씩, 행을 잡은 채로 파일을 먼저 지우고 행을 지운다. + *

    + *
  • 후보 목록은 잠그지 않고 읽는다. 그 사이 등록될 수 있으므로 각 건은 행을 잠그며 + * 조건을 다시 확인하고, 그때도 미등록인 것만 지운다. 등록 경로도 같은 행을 잠그고 + * 읽기 때문에({@code findByImageUrlForUpdate}) 둘이 한 줄로 선다 — 목록을 읽은 뒤 + * 등록된 이미지의 파일까지 지우던 경합이 여기서 막힌다(#381).
  • + *
  • 파일 삭제가 실패하면 그 건의 트랜잭션만 되돌려 행을 남긴다. 행이 곧 재시도 + * 대상 기록이라 다음 회차에 다시 선택된다. 예전처럼 행을 먼저 지우면 실패한 파일은 + * 아무도 모르는 쓰레기로 남았다(#382). 저장소 삭제는 없는 파일에도 성공하므로 + * 파일은 지워졌는데 행 삭제가 실패해도 다음 회차에 같은 일을 반복할 뿐이다.
  • + *
+ * + *

파일 삭제를 트랜잭션 안에서 부르지만 잡는 것은 그 한 행뿐이고, 기다릴 수 있는 + * 상대는 같은 이미지를 등록하려는 요청뿐이다. 24시간 넘게 방치된 업로드라 실제로 겹칠 일은 + * 드물고, 겹쳐도 S3 호출 시간 제한(15초) 안에서 끝난다. */ - @Transactional public void cleanupOrphanImageUploads() { - String orphanCondition = "registered_at IS NULL AND created_at < DATE_SUB(NOW(6), INTERVAL 24 HOUR)"; - List urls = jdbc.queryForList( - "SELECT image_url FROM challenge_image_uploads WHERE " + orphanCondition, String.class); + List candidates = jdbc.queryForList( + "SELECT image_url FROM challenge_image_uploads WHERE " + ORPHAN_CONDITION + " ORDER BY id", String.class); - int deleted = jdbc.update("DELETE FROM challenge_image_uploads WHERE " + orphanCondition); - if (deleted == 0) return; + int deleted = 0; + int failed = 0; + for (String url : candidates) { + try { + if (Boolean.TRUE.equals(tx.execute(status -> purgeOrphanUpload(url)))) deleted++; + } catch (RuntimeException e) { + // 한 건 실패로 뒤에 밀린 건까지 멈추지 않는다. 행은 남아 다음 회차에 다시 시도된다. + failed++; + log.warn("미등록 업로드 이미지 정리 실패 url={}: {}", url, e.toString()); + } + } + if (deleted > 0 || failed > 0) + log.info("미등록 업로드 이미지 정리: {}건 삭제(파일 포함), {}건 실패(다음 회차 재시도)", deleted, failed); + } - urls.forEach(imageStorage::deleteByUrl); - log.info("미등록 업로드 이미지 정리: {}건 삭제(파일 포함)", deleted); + /** + * 행을 잠그고 아직 미등록이면 파일 → 행 순으로 지운다. 그사이 등록됐거나 사라졌으면 false. + * + *

잠금은 등록 경로와 같은 길(image_url 유니크 인덱스)로 잡는다. id 로 잡으면 등록 쪽이 + * 유니크 인덱스 항목을 쥔 채 행을 기다리고, 이쪽은 행을 쥔 채 그 인덱스 항목을 지우려 해 + * 교착이 난다(테스트로 재현됨). + */ + private boolean purgeOrphanUpload(String url) { + List id = jdbc.queryForList( + "SELECT id FROM challenge_image_uploads WHERE image_url = ? AND " + ORPHAN_CONDITION + " FOR UPDATE", + Long.class, url); + if (id.isEmpty()) return false; + imageStorage.deleteByUrl(url); // 실패하면 예외 → 이 건만 롤백, 행 유지 + jdbc.update("DELETE FROM challenge_image_uploads WHERE id = ?", id.getFirst()); + return true; } } diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/settings/ChallengeSettingsService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/settings/ChallengeSettingsService.java index 6b82d967..f3c64211 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/settings/ChallengeSettingsService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/settings/ChallengeSettingsService.java @@ -227,7 +227,8 @@ private void applyImage(Challenge c, UUID userId, JsonNode body, Instant now, if (!node.isString()) throw new BusinessException(ErrorCode.INVALID_FIELD_VALUE); String imageUrl = node.stringValue(); if (imageUrl.equals(c.getImageUrl())) return; - ChallengeImageUpload upload = imageUploadRepository.findByImageUrl(imageUrl) + // 행을 잠가 읽는다 — 미등록 정리가 같은 이미지를 지우는 중이면 끝날 때까지 기다린다(#381) + ChallengeImageUpload upload = imageUploadRepository.findByImageUrlForUpdate(imageUrl) .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_IMAGE_URL)); if (!upload.ownedBy(userId)) throw new BusinessException(ErrorCode.IMAGE_NOT_OWNED); upload.markRegistered(now); diff --git a/src/main/java/com/ruleup/ruleup_backend/common/image/ImageObjectStore.java b/src/main/java/com/ruleup/ruleup_backend/common/image/ImageObjectStore.java index 5e91e3f8..2dae177d 100644 --- a/src/main/java/com/ruleup/ruleup_backend/common/image/ImageObjectStore.java +++ b/src/main/java/com/ruleup/ruleup_backend/common/image/ImageObjectStore.java @@ -20,6 +20,9 @@ public interface ImageObjectStore { /** * 지운다. 없는 파일을 지우는 것은 성공으로 취급한다 — * 정리 배치가 두 번 돌았다고 실패로 볼 이유가 없다. + * + *

그 밖의 실패는 예외로 올린다. 삼키면 정리 배치가 지웠다고 믿고 소유 기록을 지워 + * 그 파일은 다시 아무도 찾지 못한다(#382). 실패를 넘길지는 호출부가 정한다. */ void delete(String filename); diff --git a/src/main/java/com/ruleup/ruleup_backend/common/image/ImageStorageService.java b/src/main/java/com/ruleup/ruleup_backend/common/image/ImageStorageService.java index 8ca31d1d..5639739b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/common/image/ImageStorageService.java +++ b/src/main/java/com/ruleup/ruleup_backend/common/image/ImageStorageService.java @@ -73,6 +73,9 @@ public String storeValidated(ValidatedImage image) { * *

우리 {@code /files/} 경로가 아닌 주소는 조용히 무시한다. 외부 URL 이 섞여 들어와도 * 삭제 대상이 아니고, 여기서 예외를 던지면 정리 배치가 그 한 건에 막힌다. + * + *

반면 저장소 삭제가 실패하면 예외를 그대로 올린다 — 정리 배치는 그걸 보고 기록을 남겨 + * 다시 시도하고, 다른 호출부(업로드 롤백·심사 거부)는 각자 잡아 넘긴다. */ public void deleteByUrl(String url) { filenameOf(url).ifPresent(store::delete); diff --git a/src/main/java/com/ruleup/ruleup_backend/common/image/LocalImageObjectStore.java b/src/main/java/com/ruleup/ruleup_backend/common/image/LocalImageObjectStore.java index e9863611..6653cb01 100644 --- a/src/main/java/com/ruleup/ruleup_backend/common/image/LocalImageObjectStore.java +++ b/src/main/java/com/ruleup/ruleup_backend/common/image/LocalImageObjectStore.java @@ -52,6 +52,7 @@ public void delete(String filename) { Files.deleteIfExists(resolve(filename)); } catch (IOException e) { log.warn("로컬 이미지 삭제 실패 filename={}: {}", filename, e.toString()); + throw new java.io.UncheckedIOException(e); } } diff --git a/src/main/java/com/ruleup/ruleup_backend/common/image/S3ImageObjectStore.java b/src/main/java/com/ruleup/ruleup_backend/common/image/S3ImageObjectStore.java index 79b5f604..2afeeec8 100644 --- a/src/main/java/com/ruleup/ruleup_backend/common/image/S3ImageObjectStore.java +++ b/src/main/java/com/ruleup/ruleup_backend/common/image/S3ImageObjectStore.java @@ -78,9 +78,9 @@ public void delete(String filename) { s3.deleteObject(DeleteObjectRequest.builder() .bucket(bucket).key(keyOf(filename)).build()); } catch (SdkException e) { - // 삭제 실패로 상위 작업을 되돌리지 않는다. 남은 객체는 아무도 참조하지 않는 - // 쓰레기라 다음 회차나 수동 정리로 치우면 된다. + // 삼키지 않는다 — 정리 배치는 이 실패를 보고 소유 기록을 남겨 다음 회차에 다시 시도한다. log.warn("S3 이미지 삭제 실패 key={}: {}", keyOf(filename), e.toString()); + throw e; } } diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeImageCleanupIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeImageCleanupIT.java new file mode 100644 index 00000000..362f3ade --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeImageCleanupIT.java @@ -0,0 +1,135 @@ +package com.ruleup.ruleup_backend.challenge; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.challenge.creation.ChallengeImageUpload; +import com.ruleup.ruleup_backend.challenge.creation.ChallengeImageUploadRepository; +import com.ruleup.ruleup_backend.challenge.lifecycle.ChallengeCleanupService; +import com.ruleup.ruleup_backend.common.image.ImageObjectStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.context.WebApplicationContext; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * 미등록 업로드 이미지 정리 — 등록과의 경합(#381)과 저장소 삭제 실패 재시도(#382). + * + * - 정리가 후보를 읽은 뒤 그중 하나가 등록되면, 그 파일은 지우지 않는다(각 건을 잠그며 다시 확인). + * - 정리가 한 건을 지우는 동안 같은 이미지를 등록하려는 쪽은 기다렸다가 「없는 이미지」를 본다. + * - 저장소 삭제가 실패하면 소유 기록이 남아 다음 회차에 다시 지운다. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class ChallengeImageCleanupIT extends ChallengeApiSupport { + + @Autowired WebApplicationContext wac; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired ChallengeCleanupService cleanupService; + @Autowired ChallengeImageUploadRepository uploadRepository; + @Autowired TransactionTemplate tx; + @MockitoSpyBean ImageObjectStore store; + + MockMvc mvc; + + @Override protected MockMvc mvc() { return mvc; } + @Override protected JdbcTemplate jdbc() { return jdbcTemplate; } + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); + } + + /** 25시간 전에 올리고 등록하지 않은 업로드 1건 → 파일명. */ + private String insertOrphan(UUID userId) { + String filename = UUID.randomUUID() + ".png"; + jdbcTemplate.update("INSERT INTO challenge_image_uploads (user_id, image_url, registered_at, created_at) " + + "VALUES (?, ?, NULL, DATE_SUB(NOW(6), INTERVAL 25 HOUR))", + bytes(userId), urlOf(filename)); + return filename; + } + + private static String urlOf(String filename) { + return "http://localhost/files/" + filename; + } + + private int rowCount(String filename) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM challenge_image_uploads WHERE image_url = ?", Integer.class, urlOf(filename)); + } + + @Test + @DisplayName("후보를 읽은 뒤 등록된 이미지는 지우지 않고, 지우는 중인 이미지의 등록은 끝날 때까지 기다린다") + void registrationDuringCleanupKeepsTheFile() throws Exception { + Member m = member(uniq("img-race")); + String first = insertOrphan(m.id()); // id 순서상 먼저 처리된다 + String second = insertOrphan(m.id()); + + // 첫 건의 파일 삭제 안에서 멈춘다 — 이 시점에 후보 목록(두 건 모두)은 이미 읽혔다 + CountDownLatch inDelete = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(inv -> { + inDelete.countDown(); + assertThat(release.await(10, TimeUnit.SECONDS)).isTrue(); + return inv.callRealMethod(); + }).when(store).delete(first); + + CompletableFuture cleanup = CompletableFuture.runAsync(cleanupService::cleanupOrphanImageUploads); + assertThat(inDelete.await(10, TimeUnit.SECONDS)).isTrue(); + + // ① 목록에 오른 두 번째 건이 그 사이 챌린지에 등록된다(커밋까지) + jdbcTemplate.update("UPDATE challenge_image_uploads SET registered_at = NOW(6) WHERE image_url = ?", + urlOf(second)); + + // ② 지우는 중인 첫 건을 등록하려는 쪽은 그 행에서 기다린다 + CompletableFuture> registering = CompletableFuture.supplyAsync(() -> + tx.execute(status -> uploadRepository.findByImageUrlForUpdate(urlOf(first)))); + Thread.sleep(500); + assertThat(registering).as("정리가 그 행을 잡은 동안 등록은 진행되지 않는다").isNotDone(); + + release.countDown(); + cleanup.get(10, TimeUnit.SECONDS); + + assertThat(registering.get(10, TimeUnit.SECONDS)) + .as("정리가 끝난 뒤의 등록은 사라진 이미지를 보고 거절된다").isEmpty(); + assertThat(rowCount(first)).isZero(); + verify(store, never()).delete(second); + assertThat(rowCount(second)).as("등록된 이미지의 소유 기록은 남는다").isEqualTo(1); + } + + @Test + @DisplayName("저장소 삭제가 한 번 실패하면 기록이 남고, 다음 회차에 다시 지운다") + void failedDeleteIsRetriedNextRun() throws Exception { + Member m = member(uniq("img-retry")); + String filename = insertOrphan(m.id()); + doThrow(new UncheckedIOException(new IOException("일시 장애"))) + .doCallRealMethod() + .when(store).delete(filename); + + cleanupService.cleanupOrphanImageUploads(); + assertThat(rowCount(filename)).as("삭제에 실패한 건은 다음 회차의 대상으로 남는다").isEqualTo(1); + + cleanupService.cleanupOrphanImageUploads(); + assertThat(rowCount(filename)).isZero(); + verify(store, times(2)).delete(eq(filename)); + } +} From 52c4d7d717e4cf14359d3594d3a074e811c229a3 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 30 Oct 2026 23:56:24 +0900 Subject: [PATCH 4/8] =?UTF-8?q?perf(recommendation):=20=EC=84=B8=EA=B7=B8?= =?UTF-8?q?=EB=A8=BC=ED=8A=B8=20=EC=9E=AC=EC=A7=91=EA=B3=84=EB=8A=94=20?= =?UTF-8?q?=EC=B5=9C=EA=B7=BC=2030=EC=9D=BC=20=EC=B1=8C=EB=A6=B0=EC=A7=80?= =?UTF-8?q?=EB=A7=8C=20DB=20=EC=97=90=EC=84=9C=20=EC=9D=BD=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findAll() 로 전체 챌린지를 엔티티로 읽어 앱에서 기간·삭제·템플릿·생성자를 거르던 것을 DB 쿼리로 옮기고, (생성자, 템플릿)별 개수만 받는다. 생성자는 findAllById 로 한 번에 읽는다. 개수 n 을 세그먼트마다 더해 예전처럼 챌린지마다 +1 한 결과와 같다(경계·삭제·봇방장 IT 로 확인). Fixes #384 Co-Authored-By: Claude Opus 5.5 --- .../repository/ChallengeRepository.java | 12 ++ .../service/SegmentScoreService.java | 34 +++-- .../SegmentScoreRebuildTest.java | 117 ++++++++++++++---- .../SegmentScoreWindowQueryIT.java | 96 ++++++++++++++ 4 files changed, 225 insertions(+), 34 deletions(-) create mode 100644 src/test/java/com/ruleup/ruleup_backend/recommendation/SegmentScoreWindowQueryIT.java diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/repository/ChallengeRepository.java b/src/main/java/com/ruleup/ruleup_backend/challenge/repository/ChallengeRepository.java index 2111f519..6027fdb6 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/repository/ChallengeRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/repository/ChallengeRepository.java @@ -137,4 +137,16 @@ public interface ChallengeRepository extends JpaRepository { "WHERE c.status = com.ruleup.ruleup_backend.challenge.domain.ChallengeStatus.COMPLETED " + "AND c.deletedAt IS NULL AND c.templateId IS NOT NULL") List findCompletedTemplateChallenges(); + + /** + * 추천 세그먼트 재집계(최근 N일): {@code since} 이후 생성된 챌린지를 (생성자, 템플릿)별로 센 (creatorId, templateId, count). + * 삭제분·직접 입력(templateId 없음)·봇방장 방(생성자 없음)은 뺀다 — 세그먼트를 잴 대상이 아니다. + * + *

예전에는 전체 챌린지를 엔티티로 읽어 앱에서 걸렀다. 윈도우 밖 과거 데이터까지 메모리로 올라와 + * 누적 데이터에 비례해 무거워졌다. 이제 기간·조건을 DB 가 걸러 필요한 세 값만 돌려준다. + */ + @Query("SELECT c.creatorId, c.templateId, COUNT(c) FROM Challenge c " + + "WHERE c.deletedAt IS NULL AND c.templateId IS NOT NULL AND c.creatorId IS NOT NULL " + + "AND c.createdAt >= :since GROUP BY c.creatorId, c.templateId") + List countTemplateChoicesByCreatorSince(@Param("since") Instant since); } \ No newline at end of file diff --git a/src/main/java/com/ruleup/ruleup_backend/recommendation/service/SegmentScoreService.java b/src/main/java/com/ruleup/ruleup_backend/recommendation/service/SegmentScoreService.java index b735e068..e12c6afa 100644 --- a/src/main/java/com/ruleup/ruleup_backend/recommendation/service/SegmentScoreService.java +++ b/src/main/java/com/ruleup/ruleup_backend/recommendation/service/SegmentScoreService.java @@ -1,6 +1,5 @@ package com.ruleup.ruleup_backend.recommendation.service; -import com.ruleup.ruleup_backend.challenge.domain.Challenge; import com.ruleup.ruleup_backend.challenge.repository.ChallengeRepository; import com.ruleup.ruleup_backend.recommendation.domain.SegmentType; import com.ruleup.ruleup_backend.recommendation.domain.SegmentTypeWeight; @@ -28,7 +27,6 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -50,7 +48,8 @@ * {@link SegmentScoreReader} 캐시를 무효화한다(커밋 전 무효화 시 미커밋 데이터로 재캐싱되는 레이스 방지). * 변경 시점 = 무효화 시점이라 캐시 thrash가 없다. * - *

현재는 윈도우 전량 재집계(단순·정확). 데이터가 커지면 증분(마지막 실행 이후 챌린지만)으로 최적화 가능. + *

현재는 윈도우 전량 재집계(단순·정확). 기간·조건 필터와 (생성자, 템플릿) 묶음은 DB 가 하고, 생성자는 + * 일괄 조회한다. 데이터가 더 커지면 증분(마지막 실행 이후 챌린지만)으로 최적화 가능. */ @Service @RequiredArgsConstructor @@ -82,17 +81,28 @@ public void rebuild() { Instant since = Instant.now().minus(Duration.ofDays(windowDays)); log.info("세그먼트 점수 재집계 시작(윈도우 {}일, since={})", windowDays, since); - Map counts = new HashMap<>(); - Map> userCache = new HashMap<>(); // 생성자 1명이 여러 챌린지 → 조회 캐시 + // 윈도우 안·삭제 X·템플릿 기반·생성자 있는 챌린지만, (생성자, 템플릿)별 개수로 DB 가 묶어 준다. + List choices = challengeRepository.countTemplateChoicesByCreatorSince(since); + + // 생성자는 한 번에 읽는다(예전엔 생성자마다 findById). + Set creatorIds = new HashSet<>(); + for (Object[] r : choices) creatorIds.add((UUID) r[0]); + Map creators = new HashMap<>(); + if (!creatorIds.isEmpty()) { + for (User u : userRepository.findAllById(creatorIds)) creators.put(u.getId(), u); + } - for (Challenge c : challengeRepository.findAll()) { - if (c.getDeletedAt() != null || c.getTemplateId() == null) continue; // 직접 입력/삭제분 제외 - if (c.getCreatedAt() != null && c.getCreatedAt().isBefore(since)) continue; // 윈도우 밖 제외 - if (c.getCreatorId() == null) continue; // 봇방장 방: 세그먼트를 잴 생성자가 없다(findById(null) 은 예외) - User creator = userCache.computeIfAbsent(c.getCreatorId(), userRepository::findById).orElse(null); + // 챌린지 n개를 하나씩 +1 하던 것과 같은 결과가 되도록 세그먼트마다 n 을 더한다. + Map counts = new HashMap<>(); + Map> segmentsByCreator = new HashMap<>(); + for (Object[] r : choices) { + UUID creatorId = (UUID) r[0]; + User creator = creators.get(creatorId); if (creator == null) continue; - for (Segment seg : segmentResolver.resolve(creator)) { - counts.merge(new Key(seg.type(), seg.value(), c.getTemplateId()), 1L, Long::sum); + Long templateId = (Long) r[1]; + long n = (Long) r[2]; + for (Segment seg : segmentsByCreator.computeIfAbsent(creatorId, id -> segmentResolver.resolve(creator))) { + counts.merge(new Key(seg.type(), seg.value(), templateId), n, Long::sum); } } diff --git a/src/test/java/com/ruleup/ruleup_backend/recommendation/SegmentScoreRebuildTest.java b/src/test/java/com/ruleup/ruleup_backend/recommendation/SegmentScoreRebuildTest.java index 97b0e4b5..437f4ad8 100644 --- a/src/test/java/com/ruleup/ruleup_backend/recommendation/SegmentScoreRebuildTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/recommendation/SegmentScoreRebuildTest.java @@ -1,6 +1,5 @@ package com.ruleup.ruleup_backend.recommendation; -import com.ruleup.ruleup_backend.challenge.domain.Challenge; import com.ruleup.ruleup_backend.challenge.repository.ChallengeRepository; import com.ruleup.ruleup_backend.recommendation.domain.SegmentType; import com.ruleup.ruleup_backend.recommendation.domain.TemplateSegmentScore; @@ -21,19 +20,27 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.time.Duration; import java.time.Instant; import java.util.List; -import java.util.Optional; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.isNull; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyIterable; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -/** 04:00 세그먼트 재집계 — 봇방장 방 하나가 배치 전체를 깨지 않아야 한다. */ +/** + * 04:00 세그먼트 재집계 — DB 가 (생성자, 템플릿)별로 묶어 준 개수를 세그먼트 점수로 펼친다. + * 기간·삭제·봇방장 필터는 쿼리 몫이라 {@code SegmentScoreWindowQueryIT} 가 실제 DB 로 본다. + */ class SegmentScoreRebuildTest { private final ChallengeRepository challengeRepository = mock(ChallengeRepository.class); @@ -57,30 +64,96 @@ void tearDown() { TransactionSynchronizationManager.clearSynchronization(); } - private static Challenge challenge(UUID creatorId) { - Challenge c = mock(Challenge.class); - when(c.getTemplateId()).thenReturn(7L); - when(c.getCreatedAt()).thenReturn(Instant.now()); - when(c.getCreatorId()).thenReturn(creatorId); - return c; + private static Object[] row(UUID creatorId, long templateId, long count) { + return new Object[]{creatorId, templateId, count}; + } + + private User creator(UUID id, String country) { + User u = mock(User.class); + when(u.getId()).thenReturn(id); + when(segmentResolver.resolve(u)).thenReturn(List.of( + new Segment(SegmentType.GLOBAL, "ALL"), new Segment(SegmentType.COUNTRY, country))); + return u; + } + + @SuppressWarnings("unchecked") + private Map savedCounts() { + ArgumentCaptor> rows = ArgumentCaptor.forClass(List.class); + verify(scoreRepository).saveAll(rows.capture()); + return rows.getValue().stream().collect(Collectors.toMap( + s -> s.getSegmentType() + "/" + s.getSegmentValue() + "/" + s.getTemplateId(), + TemplateSegmentScore::getSelectionCount)); } @Test - @DisplayName("봇방장 방(creatorId=null)은 건너뛰고 나머지로 재집계한다") + @DisplayName("묶음 개수 n 은 챌린지 n개를 하나씩 센 것과 같은 점수가 된다") + void groupedCountsMatchPerChallengeCounting() { + UUID kr = UUID.randomUUID(); + UUID us = UUID.randomUUID(); + User krUser = creator(kr, "KR"); + User usUser = creator(us, "US"); + // KR 생성자: 템플릿 7 을 2번, 8 을 1번 / US 생성자: 템플릿 7 을 1번 + when(challengeRepository.countTemplateChoicesByCreatorSince(any())) + .thenReturn(List.of(row(kr, 7, 2), row(kr, 8, 1), row(us, 7, 1))); + when(userRepository.findAllById(anyIterable())).thenReturn(List.of(krUser, usUser)); + + service.rebuild(); + + assertThat(savedCounts()).containsExactlyInAnyOrderEntriesOf(Map.of( + "GLOBAL/ALL/7", 3, "GLOBAL/ALL/8", 1, + "COUNTRY/KR/7", 2, "COUNTRY/KR/8", 1, + "COUNTRY/US/7", 1)); + } + + @Test + @DisplayName("생성자는 한 번에 읽고, 한 명씩 findById 하지 않는다") @SuppressWarnings("unchecked") - void skipsBotOwnedRoom() { - UUID creatorId = UUID.randomUUID(); - User creator = mock(User.class); - List challenges = List.of(challenge(null), challenge(creatorId)); - when(challengeRepository.findAll()).thenReturn(challenges); - when(userRepository.findById(creatorId)).thenReturn(Optional.of(creator)); - when(segmentResolver.resolve(creator)).thenReturn(List.of(new Segment(SegmentType.GLOBAL, "ALL"))); + void loadsCreatorsInOneBatch() { + UUID a = UUID.randomUUID(); + UUID b = UUID.randomUUID(); + User ua = creator(a, "KR"); + User ub = creator(b, "KR"); + when(challengeRepository.countTemplateChoicesByCreatorSince(any())) + .thenReturn(List.of(row(a, 7, 1), row(a, 8, 1), row(b, 7, 1))); + when(userRepository.findAllById(anyIterable())).thenReturn(List.of(ua, ub)); service.rebuild(); - verify(userRepository, never()).findById(isNull()); - ArgumentCaptor> rows = ArgumentCaptor.forClass(List.class); - verify(scoreRepository).saveAll(rows.capture()); - assertThat(rows.getValue()).hasSize(1); + ArgumentCaptor> ids = ArgumentCaptor.forClass(Iterable.class); + verify(userRepository, times(1)).findAllById(ids.capture()); + assertThat(ids.getValue()).containsExactlyInAnyOrder(a, b); + verify(userRepository, never()).findById(any()); + verify(segmentResolver, times(1)).resolve(ua); // 같은 생성자는 세그먼트도 한 번만 푼다 + } + + @Test + @DisplayName("사용자 행이 없는 생성자는 건너뛰고 나머지로 재집계한다") + void skipsMissingCreator() { + UUID gone = UUID.randomUUID(); + UUID alive = UUID.randomUUID(); + User aliveUser = creator(alive, "KR"); + when(challengeRepository.countTemplateChoicesByCreatorSince(any())) + .thenReturn(List.of(row(gone, 7, 5), row(alive, 7, 1))); + when(userRepository.findAllById(anyIterable())).thenReturn(List.of(aliveUser)); + + service.rebuild(); + + assertThat(savedCounts()).containsExactlyInAnyOrderEntriesOf(Map.of( + "GLOBAL/ALL/7", 1, "COUNTRY/KR/7", 1)); + } + + @Test + @DisplayName("윈도우 시작은 지금부터 windowDays 일 전이다") + void queriesWithWindowStart() { + when(challengeRepository.countTemplateChoicesByCreatorSince(any())).thenReturn(List.of()); + + service.rebuild(); + + ArgumentCaptor since = ArgumentCaptor.forClass(Instant.class); + verify(challengeRepository).countTemplateChoicesByCreatorSince(since.capture()); + assertThat(Duration.between(since.getValue(), Instant.now()).toSeconds()) + .isCloseTo(Duration.ofDays(30).toSeconds(), within(5L)); + verify(userRepository, never()).findAllById(anyIterable()); // 대상이 없으면 사용자 조회도 없다 + assertThat(savedCounts()).isEmpty(); } } diff --git a/src/test/java/com/ruleup/ruleup_backend/recommendation/SegmentScoreWindowQueryIT.java b/src/test/java/com/ruleup/ruleup_backend/recommendation/SegmentScoreWindowQueryIT.java new file mode 100644 index 00000000..bd1d9094 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/recommendation/SegmentScoreWindowQueryIT.java @@ -0,0 +1,96 @@ +package com.ruleup.ruleup_backend.recommendation; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.challenge.ChallengeApiSupport; +import com.ruleup.ruleup_backend.challenge.repository.ChallengeRepository; +import com.ruleup.ruleup_backend.common.DbTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * 세그먼트 재집계 대상 쿼리 — 예전에 앱이 {@code findAll()} 뒤에 걸러내던 조건을 DB 가 똑같이 거르는지. + * - 윈도우 경계: created_at 이 since 와 같으면 포함, 그보다 이르면 제외({@code isBefore(since)} 제외와 같다) + * - 삭제된 방·직접 입력(템플릿 없음)·봇방장 방 제외 + * - (생성자, 템플릿)별 개수 + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class SegmentScoreWindowQueryIT extends ChallengeApiSupport { + + @Autowired WebApplicationContext wac; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired ChallengeRepository challengeRepository; + MockMvc mvc; + + @Override protected MockMvc mvc() { return mvc; } + @Override protected JdbcTemplate jdbc() { return jdbcTemplate; } + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); + } + + /** 생성자·템플릿·생성 시각을 정한 챌린지 1건. */ + private UUID challenge(UUID creatorId, Long templateId, Instant createdAt) { + UUID id = insertChallenge(creatorId, "EXERCISE", "ACTIVE", "GROUP"); + jdbcTemplate.update("UPDATE challenges SET template_id = ?, created_at = ? WHERE id = ?", + templateId, DbTime.utc(createdAt), bytes(id)); + return id; + } + + @Test + @DisplayName("윈도우 안·삭제 X·템플릿 기반·생성자 있는 챌린지만 (생성자, 템플릿)별로 센다") + void filtersAndGroupsLikeTheOldInMemoryLoop() throws Exception { + UUID a = member("seg_a").id(); + UUID b = member("seg_b").id(); + long t1 = ThreadLocalRandom.current().nextLong(1_000_000_000L, 2_000_000_000L); + long t2 = t1 + 1; + // DATETIME(6) 에 그대로 들어가도록 마이크로초로 자른다 + Instant since = Instant.now().minus(Duration.ofDays(30)).truncatedTo(ChronoUnit.MICROS); + + challenge(a, t1, since); // 경계와 같음 → 포함 + challenge(a, t1, since.plus(Duration.ofDays(10))); // 윈도우 안 → 포함 + challenge(a, t2, Instant.now()); // 다른 템플릿 → 따로 센다 + challenge(b, t1, since.plus(Duration.ofDays(1))); // 다른 생성자 → 따로 센다 + challenge(a, t1, since.minus(1, ChronoUnit.MICROS)); // 경계 직전 → 제외 + challenge(b, t2, since.minus(Duration.ofDays(5))); // 윈도우 밖 → 제외 + challenge(a, null, Instant.now()); // 직접 입력 → 제외 + UUID deleted = challenge(b, t1, Instant.now()); // 삭제됨 → 제외 + jdbcTemplate.update("UPDATE challenges SET deleted_at = ? WHERE id = ?", + DbTime.utc(Instant.now()), bytes(deleted)); + UUID botOwned = challenge(a, t1, Instant.now()); // 봇방장 → 제외 + jdbcTemplate.update("UPDATE challenges SET owner_type = 'BOT', owner_id = NULL WHERE id = ?", + bytes(botOwned)); + + List rows = challengeRepository.countTemplateChoicesByCreatorSince(since).stream() + .filter(r -> Set.of(t1, t2).contains((Long) r[1])) // 공유 DB 의 다른 테스트 행은 뺀다 + .toList(); + + assertThat(rows) + .extracting(r -> tuple(r[0], r[1], r[2])) + .containsExactlyInAnyOrder( + tuple(a, t1, 2L), + tuple(a, t2, 1L), + tuple(b, t1, 1L)); + } +} From d59b4f19c2d5c617a76577c7813d5b3b26851ce8 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 30 Oct 2026 23:56:51 +0900 Subject: [PATCH 5/8] =?UTF-8?q?chore(monitoring):=20stg=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EA=B5=AC=EC=84=B1=20=EC=A7=80=ED=91=9C=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EB=A5=BC=20=EC=A7=80=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prod 반영(#351, #354)이 끝나 STG_BUSINESS_PREFIX 예외를 없앤다. stg 는 biz.members.* 도 CloudWatch 로 보내지 않는다. business_dashboard.py 는 stg 분기를 지우고 prod 만 그린다. AWS 의 stg 대시보드 ruleup-stg-business 삭제는 따로 한다. Refs #356 Co-Authored-By: Claude Opus 5.5 --- infra/monitoring/README.md | 2 +- infra/monitoring/alerting/business_dashboard.py | 13 ++++--------- .../observability/CloudWatchMetricsConfig.java | 7 ++----- .../observability/CloudWatchMetricsConfigTest.java | 6 +++--- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/infra/monitoring/README.md b/infra/monitoring/README.md index e4b9757b..8de63705 100644 --- a/infra/monitoring/README.md +++ b/infra/monitoring/README.md @@ -16,7 +16,7 @@ | 감시 Lambda | `lambdas/` | 버지니아 `slo-calculator`(Rule Flag·버짓·이상 탐지) · `resource-monitor`(ECS·RES·RDS·백업), 오리건·버지니아 `edge-probe` | | 경보 | `alerting/catalog.py` → `p0_p1.py` | 카탈로그 ID 그대로. P0 = SLO-01/02·API-01·EDGE-01·NATIVE-01/02·RES-01~04·DB-10/11·OBS-04. 1분 주기 평가, P0 는 충족 후 1~2분 안에 호출(10/15 리뷰) | | 전달 | `alerting/routing.py` · `slack_channels.py` · `p0_mention.py` · `slack_fallback.py` · `security_events.py` | 서울·버지니아 토픽 → Amazon Q Slack, P0 멘션, 전달 실패 → 이메일, 보안 이벤트 | -| 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup--ops`(OPS-1~6) · `ruleup--observe`(OBS-1~4) · `ruleup-prod-business`(①~④) · `ruleup-stg-business`(④ 회원 구성만, `business_dashboard.py stg`) | +| 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup--ops`(OPS-1~6) · `ruleup--observe`(OBS-1~4) · `ruleup-prod-business`(①~④, prod 전용) | | 배포 보호 | `.github/scripts/monitoring_preflight.py` | ALARM·stale·EligibleCount 누락·계측 불일치·BudgetFreeze (기본 warn) | | 재무장 | `lambdas/rearm.py` | 저트래픽 예외 — 독립 정상 증거가 있을 때만 P0 Flag=0 1회 + 감사 로그 | diff --git a/infra/monitoring/alerting/business_dashboard.py b/infra/monitoring/alerting/business_dashboard.py index 75644ae4..7c0ec01e 100644 --- a/infra/monitoring/alerting/business_dashboard.py +++ b/infra/monitoring/alerting/business_dashboard.py @@ -1,16 +1,16 @@ -"""사용자 행동 지표 대시보드 ruleup--business. 노션 「RuleUp 모니터링」 2절 — 서버 지표(ruleup-prod-ops)와 분리해서 본다. +"""사용자 행동 지표 대시보드 ruleup-prod-business. 노션 「RuleUp 모니터링」 2절 — 서버 지표(ruleup-prod-ops)와 분리해서 본다. 지표 원천은 앱 BusinessMetrics·VerificationRateMetrics(biz.*). 경보는 걸지 않는다 — 추세와 자료용 화면이다. 재실행 안전. prod 전용 — stg 의 가입·로그인 수는 QA 가 만든 값이라 추세로 볼 의미가 없어 stg 는 biz.* 를 내보내지도 않는다(CloudWatchMetricsConfig). -예외는 ④ 회원 구성(biz.members.*) — prod 반영 전 확인용으로 stg 도 내보내므로, stg 대시보드에는 ④ 만 그린다. +④ 회원 구성(biz.members.*)도 같다 — prod 반영 전 확인용으로 stg 에서도 내보내던 예외는 지웠다(#356). 네 묶음: ① 로그인·가입 ② 챌린지 참여(초안·생성·가입) ③ 인증(자동 vs 수동) ④ 회원 구성(나잇대·성별·소셜·관심사·동의·활동·방 참여 경로·참여 방 수 순위). 묶음마다 첫 줄은 「대시보드에서 고른 기간 전체」의 합계·비율(숫자 칸) — 발표·보고 자료에 그대로 옮긴다. 그 아래가 1시간 단위 추세. ④ 는 게이지라 합계가 아니라 「지금 값」이고, 분포 칸(막대·원)도 가장 최근 값을 그린다. -사용: python business_dashboard.py [prod|stg] (기본 prod)""" +사용: python business_dashboard.py [prod] (prod 만 받는다)""" import boto3, json, sys R='ap-northeast-2'; ENV=sys.argv[1] if len(sys.argv)>1 else 'prod' -assert ENV in ('prod','stg'), ENV +assert ENV=='prod', f'{ENV}: prod 전용 — stg 는 biz.* 를 내보내지 않는다' NS=f'RuleUp/App/{ENV}' cw=boto3.client('cloudwatch',region_name=R) widgets=[]; y=0 @@ -90,11 +90,6 @@ def rooms_section(g, latest): y+=8 row(('참여 경로 추이',[p(k,label=l) for k,l in paths],None)) -if ENV=='stg': - members_section() - cw.put_dashboard(DashboardName=f'ruleup-{ENV}-business',DashboardBody=json.dumps({'widgets':widgets},ensure_ascii=False)) - print('ok', len(widgets)); sys.exit(0) - # ---------------------------------------------------------------- ① 로그인 · 가입 text(f'## ① 로그인 · 가입 ({ENV})') totals(('기간 합계',[m('biz.signup.count','result','success',label='가입 완료'), diff --git a/src/main/java/com/ruleup/ruleup_backend/observability/CloudWatchMetricsConfig.java b/src/main/java/com/ruleup/ruleup_backend/observability/CloudWatchMetricsConfig.java index 5f22633a..1e22794f 100644 --- a/src/main/java/com/ruleup/ruleup_backend/observability/CloudWatchMetricsConfig.java +++ b/src/main/java/com/ruleup/ruleup_backend/observability/CloudWatchMetricsConfig.java @@ -34,8 +34,7 @@ * *

서비스 지표({@value #BUSINESS_PREFIX}*)는 prod 에서만 보낸다. stg 의 가입·로그인·인증 수는 QA 가 만든 * 값이라 볼 일이 없고, 이름마다 과금만 된다. stg 대시보드는 서버 지표(ruleup-stg-ops)만 둔다. - * 예외는 회원 구성({@value #STG_BUSINESS_PREFIX}*) 하나다 — 집계·대시보드를 prod 에 올리기 전에 stg 에서 값이 - * 들어오는지 확인하려고 둔다(31개). 확인이 끝나 stg 값을 볼 일이 없어지면 이 예외를 지운다. + * 회원 구성(biz.members.*)도 마찬가지다 — prod 반영 전 확인용으로 stg 에서도 내보내던 예외는 확인이 끝나 지웠다. */ @Configuration @Profile({"prod", "stg"}) @@ -75,7 +74,6 @@ public class CloudWatchMetricsConfig { "biz.members.rooms_per_member"); // 참여 중인 방 개수(rooms=1|2|3|4|5_plus) static final String BUSINESS_PREFIX = "biz."; - static final String STG_BUSINESS_PREFIX = "biz.members."; @Bean public SimpleMeterRegistry simpleMeterRegistry() { @@ -98,8 +96,7 @@ public CloudWatchMeterRegistry cloudWatchMeterRegistry(CloudWatchAsyncClient cli CloudWatchMeterRegistry registry = new CloudWatchMeterRegistry(config, Clock.SYSTEM, client); registry.config() .meterFilter(MeterFilter.denyUnless(id -> EXPORTED.contains(id.getName()) - && ("prod".equals(profile) || !id.getName().startsWith(BUSINESS_PREFIX) - || id.getName().startsWith(STG_BUSINESS_PREFIX)))); + && ("prod".equals(profile) || !id.getName().startsWith(BUSINESS_PREFIX)))); return registry; } } diff --git a/src/test/java/com/ruleup/ruleup_backend/observability/CloudWatchMetricsConfigTest.java b/src/test/java/com/ruleup/ruleup_backend/observability/CloudWatchMetricsConfigTest.java index 3e442c0f..78778351 100644 --- a/src/test/java/com/ruleup/ruleup_backend/observability/CloudWatchMetricsConfigTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/observability/CloudWatchMetricsConfigTest.java @@ -66,8 +66,8 @@ void exportsBusinessMetersOnlyInProd() { } @Test - @DisplayName("회원 구성(biz.members.*)만은 stg 에서도 나간다 — prod 반영 전 확인용 예외") - void exportsMemberProfileInStgToo() { + @DisplayName("회원 구성(biz.members.*)도 stg 에서는 나가지 않는다 — prod 반영 전 확인용 예외는 지웠다") + void exportsMemberProfileOnlyInProd() { for (String profile : new String[]{"stg", "prod"}) { CloudWatchMeterRegistry registry = new CloudWatchMetricsConfig() .cloudWatchMeterRegistry(mock(CloudWatchAsyncClient.class), profile); @@ -78,7 +78,7 @@ void exportsMemberProfileInStgToo() { assertThat(registry.getMeters()).extracting(m -> m.getId().getName()).as(profile) .containsExactlyInAnyOrderElementsOf("prod".equals(profile) ? List.of("biz.members.total", "biz.login") - : List.of("biz.members.total")); + : List.of()); } finally { registry.stop(); } From 21ba79b6ce4dc56e21c5269e80e84258b507c940 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 30 Oct 2026 23:56:11 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(notification):=20=EC=95=BD=EA=B4=80=20?= =?UTF-8?q?=EA=B0=9C=EC=A0=95=20=EA=B3=A0=EC=A7=80=EB=8A=94=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EC=A0=84=EC=B2=B4=EB=A5=BC=20id=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=EB=A1=9C=20=EB=81=9D=EA=B9=8C=EC=A7=80=20=EB=8F=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재동의하지 않은 사람은 매일 다시 대상이라, 앞 2,000명만 읽으면 뒤쪽은 고지를 영영 받지 못했다. userId 커서로 쪽을 넘기며 한 실행에서 끝까지 돌고, 이미 받은 사람은 버전이 든 멱등 키가 걸러 준다. 실행 전체 트랜잭션은 걷어 내 쪽마다 적재가 커밋되게 했다. 쪽 크기는 설정값(기본 2,000)이다. Fixes #377 Co-Authored-By: Claude Opus 5.5 --- .../UserAgreementStateRepository.java | 9 ++-- .../terms/TermsUpdateNoticeBatch.java | 45 ++++++++++++++----- .../notification/TermsUpdateNoticeIT.java | 37 +++++++++++++++ 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/agreement/UserAgreementStateRepository.java b/src/main/java/com/ruleup/ruleup_backend/agreement/UserAgreementStateRepository.java index d60f2edd..7b1b73ed 100644 --- a/src/main/java/com/ruleup/ruleup_backend/agreement/UserAgreementStateRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/agreement/UserAgreementStateRepository.java @@ -32,18 +32,21 @@ List findByUserIdInAndAgreementType(Collection userIds *

동의한 적 없는 사람({@code agreed=false} · 행 없음)은 제외한다. 그들에게 「약관이 * 개정됐어요」는 틀린 문장이고, 필수 약관 미동의는 개정과 무관하게 게이트가 이미 막고 있다. * - *

오래 동의한 순으로 주는 이유는 상한에 걸렸을 때 가장 오래된 버전부터 처리하기 - * 위해서다 — 같은 대상을 매일 다시 긁지 않는다. + *

{@code userId} 커서로 한 쪽씩 준다. 고지를 받아도 재동의하기 전까지는 계속 대상이라, + * 앞에서부터 상한만큼 자르면 매일 같은 사람만 다시 읽고 뒤쪽은 영영 닿지 않는다. 커서는 직전 쪽의 + * 마지막 {@code userId} 이고 첫 쪽은 null 이다. PK 선두가 {@code user_id} 라 정렬도 PK 를 탄다. */ @Query(""" select s from UserAgreementState s where s.agreementType = :type and s.agreed = true and s.version <> :currentVersion - order by s.agreedAt asc + and (:after is null or s.userId > :after) + order by s.userId asc """) List findOutdated(@Param("type") AgreementType type, @Param("currentVersion") String currentVersion, + @Param("after") UUID after, Limit limit); long countByUserId(UUID userId); diff --git a/src/main/java/com/ruleup/ruleup_backend/notification/terms/TermsUpdateNoticeBatch.java b/src/main/java/com/ruleup/ruleup_backend/notification/terms/TermsUpdateNoticeBatch.java index 843e0aac..efa35acb 100644 --- a/src/main/java/com/ruleup/ruleup_backend/notification/terms/TermsUpdateNoticeBatch.java +++ b/src/main/java/com/ruleup/ruleup_backend/notification/terms/TermsUpdateNoticeBatch.java @@ -8,17 +8,17 @@ import com.ruleup.ruleup_backend.notification.service.NotificationPublisher; import com.ruleup.ruleup_backend.notification.domain.NotificationParams; import com.ruleup.ruleup_backend.notification.domain.NotificationType; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Limit; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; /** * 약관 개정 고지 — 저장 버전이 현행과 어긋난 사람에게 재동의를 안내한다. @@ -35,23 +35,40 @@ *

재발송은 멱등 키가 막는다

* 키에 현행 버전을 넣는다. 매일 돌아도 같은 개정에 대해서는 한 번만 적재되고, 다음 개정이 * 나오면 버전이 달라져 새 고지가 나간다. 발송 이력 테이블이 필요 없는 이유다. + * + *

한 실행에서 대상 끝까지 돈다

+ * 고지를 받아도 재동의하기 전까지는 계속 대상이다. 그래서 앞에서부터 상한만큼만 읽으면 매일 같은 + * 사람만 다시 읽고(멱등 키에 막혀 아무것도 안 나간다) 상한 뒤쪽은 영영 받지 못한다. {@code userId} + * 커서로 쪽을 넘기며 끝까지 가고, 이미 받은 사람은 멱등 키가 걸러 준다. + * + *

실행 전체를 한 트랜잭션으로 묶지 않는다. 적재는 쪽마다 바로 커밋되고 큐 투입도 그때 나간다 — + * 전원이 대상인 개정을 한 트랜잭션에 담으면 중단 시 전부 롤백되는데, 이미 적재한 쪽은 남는 편이 + * 낫다. 다음 실행은 처음부터 다시 돌고 멱등 키가 이어받게 해 준다. */ @Slf4j @Component -@RequiredArgsConstructor public class TermsUpdateNoticeBatch { - /** 한 항목에서 한 번에 처리할 인원. 개정은 드물지만 터지면 전원이 대상이라 상한을 둔다. */ - private static final int LIMIT = 2_000; - private final UserAgreementStateRepository stateRepository; private final NotificationPublisher publisher; private final AppProperties props; + /** 한 쪽에 읽을 인원. 개정은 드물지만 터지면 전원이 대상이라 한 번에 다 읽지 않는다. */ + private final int chunk; + + public TermsUpdateNoticeBatch(UserAgreementStateRepository stateRepository, + NotificationPublisher publisher, + AppProperties props, + @Value("${app.notification.terms-update.chunk:2000}") int chunk) { + this.stateRepository = stateRepository; + this.publisher = publisher; + this.props = props; + this.chunk = chunk; + } + /** 매일 03:50 KST. 03:40 미접속 고지 다음 자리다. */ @SchedulerLock(name = "TermsUpdateNoticeBatch.notifyOutdated", lockAtMostFor = "PT1H", lockAtLeastFor = "PT1M") @Scheduled(cron = "0 50 3 * * *", zone = "Asia/Seoul") - @Transactional public int notifyOutdated() { int total = 0; for (AgreementType type : AgreementType.values()) { @@ -61,11 +78,17 @@ public int notifyOutdated() { String current = props.client().termsVersions().of(type); if (current == null || current.isBlank()) continue; // 버전을 모르면 비교할 수 없다 - List outdated = - stateRepository.findOutdated(type, current, Limit.of(LIMIT)); - if (outdated.isEmpty()) continue; + UUID after = null; + while (true) { + List outdated = + stateRepository.findOutdated(type, current, after, Limit.of(chunk)); + if (outdated.isEmpty()) break; - total += publishAll(type, current, outdated); + total += publishAll(type, current, outdated); + // 덜 찼다 = 남은 대상이 없다. 비교 대상은 반복 횟수가 아니라 쪽 크기다. + if (outdated.size() < chunk) break; + after = outdated.getLast().getUserId(); + } } if (total > 0) log.info("약관 개정 고지 — {}건", total); return total; diff --git a/src/test/java/com/ruleup/ruleup_backend/notification/TermsUpdateNoticeIT.java b/src/test/java/com/ruleup/ruleup_backend/notification/TermsUpdateNoticeIT.java index 2803e80a..1408f3b3 100644 --- a/src/test/java/com/ruleup/ruleup_backend/notification/TermsUpdateNoticeIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/notification/TermsUpdateNoticeIT.java @@ -7,6 +7,7 @@ import com.ruleup.ruleup_backend.agreement.domain.UserAgreementState; import com.ruleup.ruleup_backend.config.AppProperties; import com.ruleup.ruleup_backend.notification.domain.Notification; +import com.ruleup.ruleup_backend.notification.service.NotificationPublisher; import com.ruleup.ruleup_backend.notification.terms.TermsUpdateNoticeBatch; import com.ruleup.ruleup_backend.user.UserRepository; import com.ruleup.ruleup_backend.user.domain.OAuthProvider; @@ -45,6 +46,7 @@ class TermsUpdateNoticeIT { @Autowired UserRepository userRepository; @Autowired TransactionTemplate txTemplate; @Autowired AppProperties props; + @Autowired NotificationPublisher publisher; private UUID newUser() { String tag = "tu" + System.nanoTime() + SEQ.incrementAndGet(); @@ -144,4 +146,39 @@ void separateNoticePerAgreement() { assertThat(noticesOf(userId)).hasSize(2); } } + @Nested + @DisplayName("대상이 한 쪽을 넘을 때") + class Paging { + + /** 쪽 크기 2 — 대상 수가 쪽 크기를 넘고, 쪽을 넘기는 횟수도 쪽 크기를 넘게 만든다. */ + private TermsUpdateNoticeBatch smallChunkBatch() { + return new TermsUpdateNoticeBatch(stateRepository, publisher, props, 2); + } + + @Test + @DisplayName("앞쪽 사람이 재동의하지 않고 남아 있어도 뒤쪽 사람까지 모두 받는다 — 각자 한 번만") + void reachesEveryoneBeyondFirstPage() { + TermsUpdateNoticeBatch paged = smallChunkBatch(); + + // 첫 실행에서 고지를 받고도 재동의하지 않은 사람들 — 다음 실행에서도 여전히 대상이다. + List notified = java.util.stream.Stream.generate(TermsUpdateNoticeIT.this::newUser) + .limit(3).toList(); + notified.forEach(u -> agreeAt(u, AgreementType.TOS, olderThanCurrent(AgreementType.TOS))); + paged.notifyOutdated(); + + // 그 뒤에 밀린 사람들. 쪽 크기(2)를 넘고, 앞의 대상까지 합치면 쪽을 넘기는 횟수도 2를 넘는다. + List later = java.util.stream.Stream.generate(TermsUpdateNoticeIT.this::newUser) + .limit(5).toList(); + later.forEach(u -> agreeAt(u, AgreementType.TOS, olderThanCurrent(AgreementType.TOS))); + paged.notifyOutdated(); + paged.notifyOutdated(); + + for (UUID userId : notified) { + assertThat(noticesOf(userId)).as("먼저 받은 사람은 다시 받지 않는다 %s", userId).hasSize(1); + } + for (UUID userId : later) { + assertThat(noticesOf(userId)).as("뒤쪽 사람도 받는다 %s", userId).hasSize(1); + } + } + } } From 8aa6e1a6d3a4408b16fec0135713b7bf7a8d263c Mon Sep 17 00:00:00 2001 From: selee1012 Date: Sat, 31 Oct 2026 00:02:36 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix(recommendation):=20=EC=B6=94=EC=B2=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20=EC=88=98=EC=A7=91=EC=9D=80=20(?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=EC=8B=9C=EA=B0=81,=20id)=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=EB=A1=9C=20=EA=B0=99=EC=9D=80=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=EC=9D=84=20=EB=84=98=EC=96=B4=EA=B0=84=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 워터마크 시각 이상에서 앞 5,000건만 읽어, 같은 시각이 5,000건을 넘으면 매일 그 앞부분만 다시 읽고 멈췄다. 실행 안에서 (verifiedAt, id) 복합 커서로 쪽을 넘기며 끝까지 돌고, 쪽마다 자기 트랜잭션으로 커밋한다. 저장 워터마크는 기존 max(confirmedAt) 그대로 두고, 경계 시각 재수집·정정은 기존 upsert 가 흡수한다. Fixes #383 Co-Authored-By: Claude Opus 5.5 --- .../service/RoutineOutcomeCollector.java | 81 ++++++++-- .../VerificationDailyRepository.java | 15 +- .../RoutineOutcomeCollectorIT.java | 145 ++++++++++++++++++ 3 files changed, 222 insertions(+), 19 deletions(-) create mode 100644 src/test/java/com/ruleup/ruleup_backend/recommendation/RoutineOutcomeCollectorIT.java diff --git a/src/main/java/com/ruleup/ruleup_backend/recommendation/service/RoutineOutcomeCollector.java b/src/main/java/com/ruleup/ruleup_backend/recommendation/service/RoutineOutcomeCollector.java index aee2ed69..9c079843 100644 --- a/src/main/java/com/ruleup/ruleup_backend/recommendation/service/RoutineOutcomeCollector.java +++ b/src/main/java/com/ruleup/ruleup_backend/recommendation/service/RoutineOutcomeCollector.java @@ -7,7 +7,6 @@ import com.ruleup.ruleup_backend.recommendation.repository.RoutineOutcomeRepository; import com.ruleup.ruleup_backend.verification.domain.VerificationDaily; import com.ruleup.ruleup_backend.verification.repository.VerificationDailyRepository; -import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -15,7 +14,7 @@ import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Duration; import java.time.Instant; @@ -40,9 +39,14 @@ *

멱등: 고수위 워터마크(마지막 confirmedAt) 이후를 {@code >=} 로 다시 훑되, * uq(challengeId,userId,targetDate)로 upsert 하므로 경계행 재수집·PENDING→확정 정정이 안전하다. * segment 재집계(04:00) 직전인 03:30 에 돌려 그날치가 반영되게 한다. + * + *

한 실행에서 끝까지 돈다: 확정 배치는 같은 시각으로 여러 건을 닫는다. 워터마크 시각부터 상한만큼만 + * 읽으면 같은 시각이 상한을 넘을 때 매일 그 앞부분만 다시 읽고 멈춘다. 그래서 실행 안에서는 + * (verifiedAt, id) 복합 커서로 쪽을 넘기고, 쪽마다 자기 트랜잭션으로 커밋한다. 중단돼도 커밋된 쪽까지는 + * 워터마크가 올라가 있고, 다음 실행은 그 시각부터 다시 시작해 같은 시각의 나머지를 커서로 넘어간다. + * 워터마크는 지금처럼 시각 하나로 충분하다 — 경계 시각 재수집은 upsert 가 흡수한다. */ @Service -@RequiredArgsConstructor public class RoutineOutcomeCollector { private static final Logger log = LoggerFactory.getLogger(RoutineOutcomeCollector.class); @@ -50,32 +54,75 @@ public class RoutineOutcomeCollector { /** 종결(추천 이력에 남길) 판정만. NOT_TARGET/NOT_REQUIRED/PENDING 제외. */ private static final Set TERMINAL = Set.of(VerificationStatus.SUCCESS, VerificationStatus.FAILED); - /** 한 배치 처리 상한(폭주 방지). 밀리면 다음 배치가 이어받는다. */ - private static final int BATCH_LIMIT = 5000; + + /** + * 한 실행에서 넘길 쪽 수 상한 — 5,000 × 200 = 100만 행. 무한 루프 방어다. 여기 걸리면 커밋된 쪽까지 + * 워터마크가 올라가 있으므로 다음 실행이 이어받는다. + */ + private static final int MAX_PAGES = 200; private final VerificationDailyRepository dailyRepo; private final ChallengeRepository challengeRepo; private final RoutineOutcomeRepository outcomeRepo; + private final TransactionTemplate tx; + + /** 한 쪽 처리량(폭주 방지). 한 쪽이 한 트랜잭션이다. */ + private final int pageSize; /** 첫 수집(워터마크 없음) 시 소급할 일수. */ - @Value("${app.recommendation.outcome-lookback-days:30}") - private int lookbackDays; + private final int lookbackDays; + + public RoutineOutcomeCollector(VerificationDailyRepository dailyRepo, + ChallengeRepository challengeRepo, + RoutineOutcomeRepository outcomeRepo, + TransactionTemplate tx, + @Value("${app.recommendation.outcome-page-size:5000}") int pageSize, + @Value("${app.recommendation.outcome-lookback-days:30}") int lookbackDays) { + this.dailyRepo = dailyRepo; + this.challengeRepo = challengeRepo; + this.outcomeRepo = outcomeRepo; + this.tx = tx; + this.pageSize = pageSize; + this.lookbackDays = lookbackDays; + } /** 매일 03:30 KST: 전날까지 확정된 종결행을 아웃컴 이력으로 수집(세그먼트 재집계 04:00 직전). */ @SchedulerLock(name = "RoutineOutcomeCollector.collect", lockAtMostFor = "PT1H", lockAtLeastFor = "PT1M") @Scheduled(cron = "0 30 3 * * *", zone = "Asia/Seoul") - @Transactional public void collect() { Instant highWater = outcomeRepo.findMaxConfirmedAt(); Instant since = (highWater != null) ? highWater : Instant.now().minus(Duration.ofDays(lookbackDays)); - List terminal = - dailyRepo.findTerminalSince(TERMINAL, since, PageRequest.of(0, BATCH_LIMIT)); - if (terminal.isEmpty()) return; - Map> challengeCache = new HashMap<>(); // 챌린지 1개에 여러 날짜 → 조회 캐시 - int inserted = 0, updated = 0; + Cursor cursor = new Cursor(since, null); + int processed = 0, inserted = 0, updated = 0; + for (int round = 0; round < MAX_PAGES; round++) { + Cursor at = cursor; + Page page = tx.execute(status -> collectPage(at, challengeCache)); + processed += page.size(); + inserted += page.inserted(); + updated += page.updated(); + // 덜 찼다 = 남은 행이 없다. 비교 대상은 반복 횟수가 아니라 쪽 크기다. + if (page.size() < pageSize) { + if (processed > 0) { + log.info("루틴 아웃컴 수집: 종결 {}건 처리(신규 {}, 갱신 {}) since={}", processed, inserted, updated, since); + } + return; + } + cursor = page.next(); + } + log.warn("루틴 아웃컴 수집 상한 도달 — 종결 {}건 처리(신규 {}, 갱신 {}) since={}. 남은 행은 다음 실행이 이어받는다.", + processed, inserted, updated, since); + } + + /** 커서 뒤 한 쪽을 upsert 한다. 호출부가 쪽마다 트랜잭션을 연다. */ + private Page collectPage(Cursor cursor, Map> challengeCache) { + List terminal = dailyRepo.findTerminalAfter( + TERMINAL, cursor.verifiedAt(), cursor.id(), PageRequest.of(0, pageSize)); + if (terminal.isEmpty()) return new Page(0, 0, 0, cursor); + + int inserted = 0, updated = 0; for (VerificationDaily d : terminal) { Challenge ch = challengeCache .computeIfAbsent(d.getChallengeId(), challengeRepo::findById) @@ -96,6 +143,12 @@ public void collect() { inserted++; } } - log.info("루틴 아웃컴 수집: 종결 {}건 처리(신규 {}, 갱신 {}) since={}", terminal.size(), inserted, updated, since); + VerificationDaily last = terminal.getLast(); + return new Page(terminal.size(), inserted, updated, new Cursor(last.getVerifiedAt(), last.getId())); } + + /** (확정 시각, id) 복합 커서. id 가 null 이면 그 시각 이상 전부. */ + private record Cursor(Instant verifiedAt, UUID id) {} + + private record Page(int size, int inserted, int updated, Cursor next) {} } diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/repository/VerificationDailyRepository.java b/src/main/java/com/ruleup/ruleup_backend/verification/repository/VerificationDailyRepository.java index a621073c..a1c9a96a 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/repository/VerificationDailyRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/repository/VerificationDailyRepository.java @@ -171,13 +171,18 @@ long countByChallengeTypeAndStatusAndFinalizeAfter(@Param("type") String type, @ /** * 추천 아웃컴 수집(RoutineOutcomeCollector): 확정 시각이 워터마크 이후인 종결(SUCCESS/FAILED) 행. - * verifiedAt 오름차순 → 페이지 상한(Pageable)으로 한 배치 처리량을 제한한다. + * + *

(verifiedAt, id) 복합 커서로 한 쪽씩 준다. 확정 배치는 같은 시각으로 여러 건을 닫으므로 + * 시각 하나만으로 자르면 같은 시각이 한 쪽을 넘을 때 매번 그 앞부분만 다시 읽고 멈춘다. 첫 쪽은 + * {@code afterId=null} 이라 {@code verifiedAt >= since}, 이후는 직전 쪽 마지막 행의 (시각, id) 뒤부터다. */ @Query("SELECT d FROM VerificationDaily d " + - "WHERE d.status IN :statuses AND d.verifiedAt IS NOT NULL AND d.verifiedAt >= :since " + - "ORDER BY d.verifiedAt ASC") - List findTerminalSince(@Param("statuses") Collection statuses, - @Param("since") Instant since, Pageable pageable); + "WHERE d.status IN :statuses AND d.verifiedAt IS NOT NULL " + + "AND (d.verifiedAt > :since OR (d.verifiedAt = :since AND (:afterId IS NULL OR d.id > :afterId))) " + + "ORDER BY d.verifiedAt ASC, d.id ASC") + List findTerminalAfter(@Param("statuses") Collection statuses, + @Param("since") Instant since, + @Param("afterId") UUID afterId, Pageable pageable); @Query(""" select d from VerificationDaily d where d.status = com.ruleup.ruleup_backend.common.verification.VerificationStatus.FAILED and d.verifiedAt >= :since and d.verifiedAt <= :now and d.shareableAt <= :now diff --git a/src/test/java/com/ruleup/ruleup_backend/recommendation/RoutineOutcomeCollectorIT.java b/src/test/java/com/ruleup/ruleup_backend/recommendation/RoutineOutcomeCollectorIT.java new file mode 100644 index 00000000..dd1e24cb --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/recommendation/RoutineOutcomeCollectorIT.java @@ -0,0 +1,145 @@ +package com.ruleup.ruleup_backend.recommendation; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.challenge.repository.ChallengeRepository; +import com.ruleup.ruleup_backend.common.DbTime; +import com.ruleup.ruleup_backend.common.verification.VerificationStatus; +import com.ruleup.ruleup_backend.recommendation.domain.RoutineOutcome; +import com.ruleup.ruleup_backend.recommendation.repository.RoutineOutcomeRepository; +import com.ruleup.ruleup_backend.recommendation.service.RoutineOutcomeCollector; +import com.ruleup.ruleup_backend.verification.repository.VerificationDailyRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 추천 아웃컴 수집 — 같은 확정 시각이 한 쪽을 넘어도 끝까지 모으는가가 계약이다. + * + *

구 구현은 워터마크 시각 이상에서 앞의 5,000건만 읽었다. 확정 배치는 같은 시각으로 여러 건을 + * 닫으므로, 그 시각이 5,000건을 넘으면 워터마크가 그 시각에 머문 채 매일 같은 앞부분만 다시 읽었다. + * + *

쪽 크기를 2로 줄인 인스턴스로 검증한다. 같은 시각 5건 + 그 뒤 시각 3건이면 같은 시각만으로 + * 쪽을 넘고, 쪽을 넘기는 횟수(4회 이상)도 쪽 크기를 넘는다. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class RoutineOutcomeCollectorIT { + + @Autowired VerificationDailyRepository dailyRepo; + @Autowired ChallengeRepository challengeRepo; + @Autowired RoutineOutcomeRepository outcomeRepo; + @Autowired TransactionTemplate txTemplate; + @Autowired JdbcTemplate jdbc; + + private RoutineOutcomeCollector smallPageCollector() { + return new RoutineOutcomeCollector(dailyRepo, challengeRepo, outcomeRepo, txTemplate, 2, 30); + } + + /** 스위트가 DB 를 공유한다 — 워터마크는 전역이므로 지금 워터마크보다 뒤에 자리를 잡는다. */ + private Instant anchor() { + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant highWater = outcomeRepo.findMaxConfirmedAt(); + Instant base = (highWater != null && highWater.isAfter(now)) + ? highWater.truncatedTo(ChronoUnit.SECONDS).plusSeconds(1) : now; + // 이 시각에 아웃컴 한 건을 박아 워터마크를 고정한다 — 다른 테스트의 종결행을 소급해 읽지 않게. + jdbc.update("INSERT INTO RoutineOutcome (id,userId,challengeId,challengeMemberId,targetDate,status,confirmedAt) " + + "VALUES (?,?,?,?,?,'SUCCESS',?)", + bytes(UUID.randomUUID()), bytes(UUID.randomUUID()), bytes(UUID.randomUUID()), + bytes(UUID.randomUUID()), LocalDate.now(), DbTime.utc(base)); + return base; + } + + private record Row(UUID id, UUID challengeId, UUID userId, LocalDate targetDate) {} + + private Row terminal(VerificationStatus status, Instant verifiedAt) { + Row row = new Row(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), LocalDate.now().minusDays(1)); + jdbc.update("INSERT INTO VerificationDaily (id,challengeMemberId,challengeId,userId,targetDate,status,verifiedVia,verifiedAt) " + + "VALUES (?,?,?,?,?,?,'AUTO',?)", + bytes(row.id()), bytes(UUID.randomUUID()), bytes(row.challengeId()), bytes(row.userId()), + row.targetDate(), status.name(), DbTime.utc(verifiedAt)); + return row; + } + + private Optional outcomeOf(Row row) { + return outcomeRepo.findByChallengeIdAndUserIdAndTargetDate(row.challengeId(), row.userId(), row.targetDate()); + } + + @Test + @DisplayName("같은 확정 시각이 한 쪽을 넘어도 그 시각의 나머지와 이후 시각까지 모두 모은다") + void collectsBeyondSameInstantPage() { + Instant base = anchor(); + Instant tie = base.plusSeconds(1); + Instant later = base.plusSeconds(2); + + List sameInstant = new ArrayList<>(); + for (int i = 0; i < 5; i++) sameInstant.add(terminal(VerificationStatus.SUCCESS, tie)); + List afterwards = new ArrayList<>(); + for (int i = 0; i < 3; i++) afterwards.add(terminal(VerificationStatus.FAILED, later)); + + smallPageCollector().collect(); + + for (Row row : sameInstant) { + assertThat(outcomeOf(row)).as("같은 시각 %s", row.id()).hasValueSatisfying(o -> { + assertThat(o.getStatus()).isEqualTo(VerificationStatus.SUCCESS); + assertThat(o.getConfirmedAt()).isEqualTo(tie); + }); + } + for (Row row : afterwards) { + assertThat(outcomeOf(row)).as("이후 시각 %s", row.id()).hasValueSatisfying(o -> + assertThat(o.getStatus()).isEqualTo(VerificationStatus.FAILED)); + } + } + + @Test + @DisplayName("다시 돌아도 한 행씩이고, 정정된 판정은 같은 행을 고친다") + void rerunAndCorrectionStayIdempotent() { + Instant base = anchor(); + Instant tie = base.plusSeconds(1); + + List rows = new ArrayList<>(); + for (int i = 0; i < 5; i++) rows.add(terminal(VerificationStatus.SUCCESS, tie)); + RoutineOutcomeCollector collector = smallPageCollector(); + collector.collect(); + + // 이의 등으로 판정이 정정되면 확정 시각도 새로 찍힌다. + Row corrected = rows.get(2); + Instant correctedAt = base.plusSeconds(5); + jdbc.update("UPDATE VerificationDaily SET status='FAILED', verifiedVia='APPEAL', verifiedAt=? WHERE id=?", + DbTime.utc(correctedAt), bytes(corrected.id())); + collector.collect(); + collector.collect(); + + for (Row row : rows) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM RoutineOutcome WHERE challengeId=? AND userId=? AND targetDate=?", + Integer.class, bytes(row.challengeId()), bytes(row.userId()), row.targetDate()); + assertThat(count).as("한 멤버×날짜에 한 행 %s", row.id()).isEqualTo(1); + } + assertThat(outcomeOf(corrected)).hasValueSatisfying(o -> { + assertThat(o.getStatus()).isEqualTo(VerificationStatus.FAILED); + assertThat(o.getConfirmedAt()).isEqualTo(correctedAt); + }); + } + + private static byte[] bytes(UUID u) { + ByteBuffer bb = ByteBuffer.allocate(16); + bb.putLong(u.getMostSignificantBits()); + bb.putLong(u.getLeastSignificantBits()); + return bb.array(); + } +} From 6e8b3ca126b38164a7990428104279b31e678309 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Sat, 31 Oct 2026 00:22:43 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix(verification):=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EC=8B=A0=ED=98=B8=EB=8A=94=20id=20=EB=A7=8C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=ED=95=98=EA=B3=A0=20payload=20=EB=8A=94=20PK=20?= =?UTF-8?q?=EB=A1=9C=20=EB=94=B0=EB=A1=9C=20=EC=9D=BD=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prod 2026-10-30 sync 500 3건 — 「Out of sort memory」(1038). 한 날의 원본을 id 순으로 읽는 질의가 payload(JSON)까지 함께 정렬해, LIMIT 5,000 행분이 정렬 버퍼에 들어가야 했다. 정렬은 id 만으로 하고 payload·receivedAt 은 (observedDate, id) PK 로 읽어 id 순서대로 맞춘다. Co-Authored-By: Claude Opus 5.5 --- .../service/VerificationSignalReader.java | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSignalReader.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSignalReader.java index 6e6ab9a5..52d88c2b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSignalReader.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSignalReader.java @@ -158,21 +158,24 @@ private boolean readDay(UUID userId, SignalDomain domain, LocalDate date, List> page = jdbc.queryForList( - "SELECT id, payload, receivedAt FROM " + domain.table() + // 정렬은 id 만으로 한다. payload(JSON)까지 함께 정렬하면 LIMIT 만큼의 행이 정렬 버퍼에 들어가야 해 + // 큰 원본이 섞인 날 「Out of sort memory」로 sync 가 500 이 됐다(prod 2026-10-30). + List ids = jdbc.queryForList( + "SELECT id FROM " + domain.table() + " WHERE observedDate = ? AND userId = ? AND excludeReason IS NULL AND id > ?" + " ORDER BY id LIMIT " + PAGE_SIZE, - Date.valueOf(date), bytes(userId), cursor); - if (page.isEmpty()) return true; + byte[].class, Date.valueOf(date), bytes(userId), cursor); + if (ids.isEmpty()) return true; + List> page = fetchById(domain, date, ids); for (Map row : page) { SyncSignal signal = parse((String) row.get("payload")); // 수신 시각은 payload 가 아니라 행에 있다 — 평가기가 「받은 때」로 따질 수 있게 채워 준다. if (signal != null) out.add(signal.withReceivedAt(receivedAt(row.get("receivedAt")))); - cursor = (byte[]) row.get("id"); } - read += page.size(); - if (page.size() < PAGE_SIZE) return true; // 마지막 페이지 + cursor = ids.getLast(); + read += ids.size(); + if (ids.size() < PAGE_SIZE) return true; // 마지막 페이지 if (read >= MAX_ROWS_PER_DAY) { metrics.signalsReadTruncated(); @@ -184,6 +187,29 @@ private boolean readDay(UUID userId, SignalDomain domain, LocalDate date, Listid 순서대로 돌려준다. + * 여기서는 정렬하지 않는다 — 순서는 id 목록이 이미 갖고 있다. + */ + private List> fetchById(SignalDomain domain, LocalDate date, List ids) { + String in = String.join(",", java.util.Collections.nCopies(ids.size(), "?")); + List args = new ArrayList<>(ids.size() + 1); + args.add(Date.valueOf(date)); + args.addAll(ids); + Map> byId = new HashMap<>(ids.size() * 2); + for (Map row : jdbc.queryForList( + "SELECT id, payload, receivedAt FROM " + domain.table() + + " WHERE observedDate = ? AND id IN (" + in + ")", args.toArray())) { + byId.put(ByteBuffer.wrap((byte[]) row.get("id")), row); + } + List> ordered = new ArrayList<>(ids.size()); + for (byte[] id : ids) { + Map row = byId.get(ByteBuffer.wrap(id)); + if (row != null) ordered.add(row); // 두 질의 사이에 파기된 행은 빠진다 + } + return ordered; + } + /** * 저장해 둔 원본 JSON → 신호. 깨진 행 하나가 그날 판정 전체를 막지 않도록 건너뛴다 — * 계약이 바뀐 직후의 예전 payload 가 여기로 올 수 있다.