diff --git a/backend/src/test/java/com/shadowfit/repository/outbox/OutboxSkipLockedRaceTest.java b/backend/src/test/java/com/shadowfit/repository/outbox/OutboxSkipLockedRaceTest.java new file mode 100644 index 00000000..77dff1bb --- /dev/null +++ b/backend/src/test/java/com/shadowfit/repository/outbox/OutboxSkipLockedRaceTest.java @@ -0,0 +1,149 @@ +package com.shadowfit.repository.outbox; + +import com.shadowfit.model.outbox.OutboxEvent; +import com.shadowfit.support.MySqlContainerSupport; +import org.junit.jupiter.api.AfterEach; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * outbox 선점 쿼리({@code FOR UPDATE SKIP LOCKED})가 실제 MySQL 에서 «발행기끼리 행을 나눠 갖는다» 를 + * 지키는지 검증한다. + * + *
왜 H2 로는 안 되는가. 기본 테스트(H2)는 이 쿼리의 문법만 통과시킨다. 두 트랜잭션이 + * 동시에 잡을 때 잠긴 행을 건너뛰는지·기다리는지는 스토리지 엔진의 행 락 동작이라 InnoDB 에서만 + * 의미가 있다. 이 동작은 PR #63 때 손으로만 확인했고 테스트가 없었다. + * + *
무엇을 고정하나. + *
실제 발행기와 격리 — 시각을 미래로 민다. 이 클래스의 컨텍스트는 스케줄러를 끄지만
+ * ({@code scheduling.enabled=false}, {@code SessionCompletedFeedFanoutRaceTest} 와 같은 설정이라 컨텍스트를
+ * 같이 쓴다), 캐시된 다른 컨텍스트(스케줄러가 켜진 race 테스트)의 {@code OutboxPublisher} 는 같은
+ * 컨테이너를 1초마다 폴링한다. 전체 스위트에서 실제로 그 발행기가 이 테스트의 행을 먼저 집어 가
+ * B 가 1건만 받은 적이 있다(2026-09-24). 그래서 행의 {@code next_retry_at}·{@code lock_expires_at} 을
+ * {@link #FUTURE} 뒤로 두고, 선점 쿼리의 {@code :now} 에 그 미래 시각을 넘긴다 — 실제 발행기는
+ * {@code LocalDateTime.now()} 로 물으므로 이 행들을 절대 못 집는다.
+ */
+@SpringBootTest(properties = {"scheduling.enabled=false", "grpc.server.port=-1"})
+@ActiveProfiles("race")
+@DisplayName("outbox SKIP LOCKED — 발행기끼리 행을 나눠 갖는다 (실 MySQL)")
+class OutboxSkipLockedRaceTest extends MySqlContainerSupport {
+
+ private static final List 왜 H2 로는 모자란가.
+ * {@code @Transactional} 로 롤백한다 — 동시성을 보는 테스트가 아니라 한 연결 안에서 끝난다.
+ */
+@SpringBootTest(properties = {"scheduling.enabled=false", "grpc.server.port=-1"})
+@ActiveProfiles("race")
+@Transactional
+@DisplayName("daily_logs 네이티브 쿼리 — upsertMemoAndMood·recomputeStats (실 MySQL)")
+class DailyLogNativeQueryRaceTest extends MySqlContainerSupport {
+
+ private static final LocalDate DAY = LocalDate.of(2026, 9, 10);
+
+ @Autowired private DailyLogService dailyLogService;
+ @Autowired private MemberRepository memberRepository;
+ @Autowired private CategoryRepository categoryRepository;
+ @Autowired private ExercisesRepository exercisesRepository;
+ @Autowired private SessionRepository sessionRepository;
+ @Autowired private JdbcTemplate jdbcTemplate;
+
+ private Member member;
+ private Member other;
+ private Exercise exercise;
+
+ @BeforeEach
+ void setUp() {
+ member = memberRepository.saveAndFlush(Member.builder()
+ .email("dailylog-race@test.local").username("dailylog-race").password("x")
+ .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build());
+ other = memberRepository.saveAndFlush(Member.builder()
+ .email("dailylog-race-other@test.local").username("dailylog-race-other").password("x")
+ .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build());
+ Category category = categoryRepository.saveAndFlush(Category.builder().name("DAILYLOG_RACE").build());
+ exercise = exercisesRepository.saveAndFlush(Exercise.builder()
+ .name("dailylog-race-squat").category(category).expectedDurationMinutes(15)
+ .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00"))
+ .build());
+ }
+
+ @ParameterizedTest
+ @EnumSource(Mood.class)
+ @DisplayName("upsertMemoAndMood — 자바 Mood 의 모든 값이 MySQL ENUM 컬럼에 들어간다")
+ void everyMoodFitsTheEnumColumn(Mood mood) {
+ dailyLogService.saveOrUpdateLog(member.getId(), new DailyLogRequestDto(DAY, "memo", mood));
+
+ assertThat(row().get("mood")).isEqualTo(mood.name());
+ }
+
+ @Test
+ @DisplayName("upsertMemoAndMood — 두 번째 쓰기가 memo·mood 를 덮고, 누적 통계는 건드리지 않는다")
+ void secondWriteOverwritesMemoButKeepsStats() {
+ dailyLogService.accumulateStats(member.getId(), DAY, 30, new BigDecimal("120.00"));
+ dailyLogService.saveOrUpdateLog(member.getId(), new DailyLogRequestDto(DAY, "first", Mood.GOOD));
+
+ dailyLogService.saveOrUpdateLog(member.getId(), new DailyLogRequestDto(DAY, "second", null));
+
+ Map ① FK 위반 → SESSION_NOT_FOUND. 서비스는 «존재 검사 뒤 INSERT 사이에 세션이 사라진»
+ * 경우를 FK 위반으로 알아채 {@code SESSION_NOT_FOUND} 로 답한다. 판정은 MySQL 벤더 코드
+ * {@code 1452}(ER_NO_REFERENCED_ROW_2) 또는 H2·PG 의 SQLState 로 한다. 기본 테스트는 H2 라
+ * SQLState 쪽만 돌았고, MySQL 분기 — 특히 {@code rewriteBatchedStatements=true} 로 배치가
+ * 재작성될 때 {@code BatchUpdateException} 의 원인 체인에 1452 가 실제로 실리는가 — 는 검증된 적이
+ * 없었다. 여기서 틀리면 운영에서 세션 소멸이 FK 가 아닌 위반으로 분류돼 500 이 된다.
+ *
+ * ② 삽입 건수. 반환값은 배치 전후 COUNT 차이다(#219 — 재작성된 배치는 행별 결과를
+ * -2 로 준다). 운영과 같은 URL 옵션({@code MySqlContainerSupport}) 위에서 재전송 흡수 건수가 맞는지 본다.
+ *
+ * 레이스를 타이밍에 맡기지 않는다({@code SignupUsernameRaceTest} 와 같은 방식). 존재 검사
+ * ({@code findById})를 스파이로 잡아 없는 세션 id 에도 세션을 돌려주게 만든다 — 검사를 통과한
+ * 직후 세션이 지워진 상태와 DB 가 보는 것이 정확히 같다.
+ */
+@SpringBootTest(properties = {"scheduling.enabled=false", "grpc.server.port=-1"})
+@ActiveProfiles("race")
+@Transactional
+@DisplayName("피드백 배치 — MySQL 1452 번역·중복 흡수 건수 (실 MySQL)")
+class FeedbackLogFkRaceTest extends MySqlContainerSupport {
+
+ /** 존재하지 않는 세션 id — AUTO_INCREMENT 가 여기까지 올 일은 없다. */
+ private static final long GHOST_SESSION_ID = Long.MAX_VALUE - 7;
+
+ @Autowired private FeedbackLogService feedbackLogService;
+ @Autowired private MemberRepository memberRepository;
+ @Autowired private CategoryRepository categoryRepository;
+ @Autowired private ExercisesRepository exercisesRepository;
+ @Autowired private JdbcTemplate jdbcTemplate;
+
+ @MockitoSpyBean private SessionRepository sessionRepository;
+
+ private Session session;
+
+ @BeforeEach
+ void setUp() {
+ Member member = memberRepository.saveAndFlush(Member.builder()
+ .email("feedback-fk-race@test.local").username("feedback-fk-race").password("x")
+ .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build());
+ Category category = categoryRepository.saveAndFlush(Category.builder().name("FEEDBACK_FK_RACE").build());
+ // 멘트 템플릿이 없는 종목 — 유형 지원 검사가 «제한 없음» 으로 통과한다(검증 대상이 아니다).
+ Exercise exercise = exercisesRepository.saveAndFlush(Exercise.builder()
+ .name("feedback-fk-race").category(category).expectedDurationMinutes(15)
+ .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00"))
+ .build());
+ session = sessionRepository.saveAndFlush(Session.builder()
+ .member(member).exercise(exercise).startTime(LocalDateTime.now().minusMinutes(5))
+ .status(Status.IN_PROGRESS).totalReps(0).difficultyLevel(1).build());
+ }
+
+ @Test
+ @DisplayName("검사 뒤 세션이 사라졌으면 MySQL 1452 가 SESSION_NOT_FOUND 로 번역된다")
+ void vanishedSession_isTranslatedToSessionNotFound() {
+ // 검사는 «있다» 고 답하지만 DB 에는 그 id 의 세션이 없다 — FK 가 INSERT 를 거부한다.
+ doReturn(Optional.of(session)).when(sessionRepository).findById(GHOST_SESSION_ID);
+
+ assertThatThrownBy(() -> feedbackLogService.saveBatch(batch(GHOST_SESSION_ID, 1, 2)))
+ .isInstanceOf(BusinessException.class)
+ .extracting(e -> ((BusinessException) e).getErrorCode())
+ .isEqualTo(ErrorCode.SESSION_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("재작성 배치에서도 삽입 건수가 맞다 — 재전송은 0 으로 센다")
+ void insertedCount_isCorrectUnderRewrittenBatch() {
+ FeedbackBatchRequest request = batch(session.getId(), 1, 2);
+
+ int first = feedbackLogService.saveBatch(request);
+ int resent = feedbackLogService.saveBatch(request);
+
+ assertThat(first).isEqualTo(2);
+ assertThat(resent).as("같은 배치 재전송은 uk_session_rep 가 흡수한다").isZero();
+ assertThat(countRows()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("재전송에 새 이벤트가 섞여도 새 것만 센다")
+ void insertedCount_resendMixedWithNew() {
+ feedbackLogService.saveBatch(batch(session.getId(), 1, 2));
+
+ int second = feedbackLogService.saveBatch(batch(session.getId(), 1, 2, 3));
+
+ assertThat(second).isEqualTo(1);
+ assertThat(countRows()).isEqualTo(3);
+ }
+
+ // ⚠️ «한 배치 안에» 같은 (rep, 유형)이 두 번 있는 경우는 여기서 고정하지 않는다 — 실 MySQL 에서
+ // 재작성된 multi-row INSERT 가 에러 1869(Auto-increment value in UPDATE conflicts…)로 통째로
+ // 실패한다(2026-09-24 이 테스트를 쓰다 발견, #816). 지금 AI 는 배치를 항상 1건으로 보내
+ // (ai-server pose.py flush_pending_feedback) 운영 경로에서는 나지 않는다.
+
+ private int countRows() {
+ return jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM session_feedback_logs WHERE session_id = ?", Integer.class, session.getId());
+ }
+
+ private static FeedbackBatchRequest batch(long sessionId, int... reps) {
+ FeedbackBatchRequest.Builder builder = FeedbackBatchRequest.newBuilder()
+ .setSessionId(sessionId).setSetNo(1).setIsFinal(false);
+ for (int rep : reps) {
+ builder.addEvents(FeedbackEvent.newBuilder()
+ .setFeedbackType("KNEE_OUT")
+ .setRepNumber(rep)
+ .setSyncRateAtTrigger(55.0)
+ .setOccurredAt(Timestamps.fromMillis(System.currentTimeMillis())));
+ }
+ return builder.build();
+ }
+}
diff --git a/backend/src/test/java/com/shadowfit/service/exercise/PoseDataPartitionMaintenanceRaceTest.java b/backend/src/test/java/com/shadowfit/service/exercise/PoseDataPartitionMaintenanceRaceTest.java
new file mode 100644
index 00000000..be3284ed
--- /dev/null
+++ b/backend/src/test/java/com/shadowfit/service/exercise/PoseDataPartitionMaintenanceRaceTest.java
@@ -0,0 +1,175 @@
+package com.shadowfit.service.exercise;
+
+import com.shadowfit.support.MySqlContainerSupport;
+import org.junit.jupiter.api.AfterEach;
+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.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * {@link PoseDataPartitionScheduler} 가 실제 MySQL 에서 DDL 을 실행해 보는 테스트.
+ *
+ * {@code PoseDataPartitionSchedulerTest} 는 «무엇을 드롭/생성할지» 결정 로직만 본다(목 JdbcTemplate).
+ * 그 SQL 이 MySQL 에서 실제로 통과하는지 — {@code REORGANIZE PARTITION pfuture INTO (...)} 문법,
+ * {@code UNIX_TIMESTAMP('yyyy-MM-dd 00:00:00')} 경계식, {@code information_schema.partitions} 조회 —
+ * 는 한 번도 실행된 적이 없었다. 되돌릴 수 없는 작업(DROP PARTITION)이라 «돌려 보니 문법 오류» 를
+ * 운영 새벽 4시에 처음 알게 되는 구조였다.
+ *
+ * 격리. race 테스트들은 컨테이너 하나를 같이 쓴다. 실제 {@code pose_data} 의 파티션을
+ * 지우면 다른 테스트가 영향을 받으므로, 같은 컨테이너에 스크래치 스키마를 만들고
+ * {@code CREATE TABLE ... LIKE} 로 Flyway 가 만든 진짜 정의(컬럼·PK·유니크 키)를 복사한 뒤
+ * 파티션만 «오늘 기준 상대 월» 로 다시 짠다. 스케줄러는 {@code DATABASE()} 와 무한정 테이블명
+ * {@code pose_data} 를 쓰므로 스크래치 스키마를 기본 DB 로 하는 커넥션을 주면 그대로 그쪽을 본다 —
+ * main 코드는 바꾸지 않는다.
+ *
+ * ⚠️ 월 경계: 스케줄러는 실행 시점의 {@code YearMonth.now(Asia/Seoul)} 를 쓴다. 테스트가 정확히
+ * 자정 월 넘김에 걸리면 기대값과 한 달 어긋날 수 있다(드물고, 재실행으로 사라진다).
+ */
+@SpringBootTest(properties = {"scheduling.enabled=false", "grpc.server.port=-1"})
+@ActiveProfiles("race")
+@DisplayName("pose_data 파티션 유지보수 — DROP/REORGANIZE 실제 실행 (실 MySQL)")
+class PoseDataPartitionMaintenanceRaceTest extends MySqlContainerSupport {
+
+ private static final String SCRATCH = "pose_partition_it";
+ private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");
+ private static final DateTimeFormatter BOUND = DateTimeFormatter.ofPattern("yyyy-MM-dd 00:00:00");
+ // 스케줄러 기본값과 같게 둔다(@Value 기본값: retention-buffer-months=1, lookahead-months=2).
+ private static final int RETENTION_BUFFER_MONTHS = 1;
+ private static final int LOOKAHEAD_MONTHS = 2;
+
+ /** Flyway 를 돌리기 위해 컨텍스트를 띄운다 — 복사 원본 {@code pose_data} 가 거기서 만들어진다. */
+ @Autowired private JdbcTemplate appJdbc;
+
+ private JdbcTemplate root;
+ private JdbcTemplate scratch;
+ private YearMonth now;
+
+ @BeforeEach
+ void setUp() {
+ now = YearMonth.now(SEOUL);
+ root = new JdbcTemplate(dataSource(MYSQL.getDatabaseName()));
+ root.execute("DROP DATABASE IF EXISTS " + SCRATCH);
+ root.execute("CREATE DATABASE " + SCRATCH);
+ String source = MYSQL.getDatabaseName() + ".pose_data";
+ assertThat(appJdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables "
+ + "WHERE table_schema = DATABASE() AND table_name = 'pose_data'", Integer.class))
+ .as("Flyway 가 만든 복사 원본이 있어야 한다").isEqualTo(1);
+ root.execute("CREATE TABLE " + SCRATCH + ".pose_data LIKE " + source);
+
+ // 파티션 배치: 이름 패턴 밖(p_legacy) · now-3 · now-2 · now-1 · now · pfuture
+ // → 기대: now-3·now-2 드롭(보존은 now-1 부터), p_legacy 는 파싱 실패라 유지,
+ // now+1·now+2 생성(lookahead 2), pfuture 는 끝에 그대로.
+ root.execute("ALTER TABLE " + SCRATCH + ".pose_data PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) ("
+ + "PARTITION p_legacy VALUES LESS THAN (" + upper(now.minusMonths(4)) + "), "
+ + partition(now.minusMonths(3)) + ", "
+ + partition(now.minusMonths(2)) + ", "
+ + partition(now.minusMonths(1)) + ", "
+ + partition(now) + ", "
+ + "PARTITION pfuture VALUES LESS THAN MAXVALUE)");
+
+ scratch = new JdbcTemplate(dataSource(SCRATCH));
+ insertRowIn(now.minusMonths(5), 1); // p_legacy
+ insertRowIn(now.minusMonths(3), 2); // 드롭될 달
+ insertRowIn(now.minusMonths(1), 3); // 버퍼 — 남아야 한다
+ insertRowIn(now, 4); // 이번 달 — 남아야 한다
+ }
+
+ @AfterEach
+ void tearDown() {
+ root.execute("DROP DATABASE IF EXISTS " + SCRATCH);
+ }
+
+ @Test
+ @DisplayName("만료 달만 DROP, 미래 달은 REORGANIZE 로 생성 — 행은 만료 달 것만 사라진다")
+ void maintain_dropsExpiredAndCreatesFuture() {
+ scheduler().checkAndMaintainPartitions();
+
+ assertThat(partitionNames()).containsExactly(
+ "p_legacy",
+ name(now.minusMonths(1)),
+ name(now),
+ name(now.plusMonths(1)),
+ name(now.plusMonths(2)),
+ "pfuture");
+ assertThat(sessionIds())
+ .as("드롭된 달(now-3)의 행만 사라지고, 이름 패턴 밖 파티션·버퍼·이번 달 행은 남는다")
+ .containsExactlyInAnyOrder(1L, 3L, 4L);
+ }
+
+ @Test
+ @DisplayName("두 번 돌려도 결과가 같다 — 매일 도는 잡이라 멱등이어야 한다")
+ void maintain_isIdempotent() {
+ PoseDataPartitionScheduler scheduler = scheduler();
+ scheduler.checkAndMaintainPartitions();
+ List
+ *
+ *
+ *