Skip to content

Refactor/#102 improve 10s interval push#104

Merged
strongmhk merged 6 commits intodevelopfrom
refactor/#102-improve-10s-interval-push
Mar 2, 2026
Merged

Refactor/#102 improve 10s interval push#104
strongmhk merged 6 commits intodevelopfrom
refactor/#102-improve-10s-interval-push

Conversation

@strongmhk
Copy link
Copy Markdown
Member

@strongmhk strongmhk commented Mar 2, 2026

📄 PR 요약

알람 울림 폴링 프로세스를 DB 조회 중심에서 Redis Sorted Set 기반으로 전환해 10초 간격 스케줄러 성능/일관성을 개선하고, 관련 단위/통합 테스트를 보강

AS-IS

flowchart LR
    A["Scheduler (10s)"] --> B["AlarmQueryService"]
    B --> C["DB: alarm_occurrence 조회"]
    C --> D["RingingPushInfo 목록"]
    D --> E["RedisService: FCM 토큰 조회"]
    E --> F["FCM 전송"]
    G["알람 울림 상태 변경"] -. 별도 연계 .-> C

Loading
  • 스케줄러가 매 10초마다 DB에서 울릴 알람 조회
  • 조회 결과로 푸시 대상을 구성해 FCM 전송
  • 울림/출석/삭제 흐름과 스케줄러 조회 경로가 DB 중심으로 결합

TO-BE

flowchart LR
    A["Scheduler (10s)"] --> B["RingingAlarmRedisRepository"]
    B --> C["Redis ZSET: alarm:ringing<br/>ZRANGEBYSCORE 0..now"]
    C --> D["RingingPushInfo 목록"]
    D --> E["RedisService: FCM 토큰 조회"]
    E --> F["FCM 전송"]

    G["ringAlarm()"] --> H["ZADD alarm:ringing"]
    I["checkInAlarm()/deleteAlarm()"] --> J["ZREM alarm:ringing"]

Loading
  • 스케줄러는 DB 대신 Redis Sorted Set에서 즉시 울릴 대상만 조회
  • ringAlarm() 시 ZADD, checkIn/delete 시 ZREM으로 상태 동기화
  • DB 폴링 부담을 줄이고 울릴 알람 조회를 경량화

✍🏻 PR 상세

  1. 스케줄러 조회 경로 개선
  • AlarmRingingNotificationScheduler에서 DB 조회(AlarmQueryService) 대신 RingingAlarmRedisRepository 조회로 변경
  • 메트릭 명칭/설명도 db_query_durationredis_query_duration에 맞게 정리
  1. Redis Sorted Set 저장소 도입
  • RingingAlarmRedisRepository 신규 추가
  • Key: alarm:ringing, Member: {alarmId}:{memberId}, Score: epochMillis
  • add/getRingingAlarms/remove 구현으로 울림 대상 적재/조회/제거 처리
  1. 알람 울림/출석/삭제 로직 Redis ZSET 연산 추가
  • AlarmCommandServiceImpl.ringAlarm()에서 울림 시작 시 Redis ZSET 적재 추가
  • checkInAlarm(), deleteAlarm()에서 비활성화/삭제 시 Redis ZSET 제거 추가
  • score 계산 시 Asia/Seoul 기준 epoch millis 사용
  1. 날짜 계산 유틸 개선
  • DateUtil.getNextOccurrenceDate(repeatDays, fromDateTime, alarmTime) 오버로딩 추가
  • “오늘이 반복 요일이고 아직 알람 시각 전이면 오늘 반환” 케이스 지원
  1. 테스트/환경 보강
  • RingingAlarmRedisRepositoryTest 신규 작성
  • DateUtilTest 신규 작성
  • AlarmCommandServiceTest에서 ringAlarm 관련 검증(로그 + Redis 적재) 보강
  • src/test/resources/docker-java.properties 추가 (api.version=1.44)

👀 참고사항

  • 전체 테스트 실행 결과: BUILD SUCCESSFUL (bash gradlew test, 2026-03-02 기준)
  • 브랜치 diff 기준 이슈 번호는 커밋 메시지 기준 #102로 작성

👀 참고사항

✅ 체크리스트

  • PR 양식에 맞게 작성했습니다.
  • 모든 테스트가 통과했습니다.
  • 프로그램이 정상적으로 작동합니다.
  • 적절한 라벨을 설정했습니다.
  • 불필요한 코드를 제거했습니다.

🚪 연관된 이슈 번호

Closes #102

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced alarm ringing state management with Redis-based tracking for improved performance and responsiveness.
    • Added time-aware occurrence date calculation for recurring alarms.
  • Documentation

    • Added comprehensive development guidelines and architecture standards documentation.
  • Tests

    • Expanded test coverage for alarm ringing functionality and date utility calculations.

오늘이 반복 요일이고, 알람이 울리는 시간이 현재 시각보다 뒤라면 오늘 날짜를 반환
testcontainer 1.21.x 버전에서 docker-java 라이브러리의 기본 버전은 1.32를 사용하고 있으나 도커 엔진 v29부터는 docker-java 1.44를 최소 버전으로 요구함.
@strongmhk strongmhk self-assigned this Mar 2, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 2, 2026

Walkthrough

Redis 기반 울리는 알람 상태 관리 시스템을 도입하여 기존 DB 쿼리 방식을 대체합니다. 새로운 Redis 저장소, 스케줄러 수정, 도메인 서비스 업데이트, 날짜 유틸리티 확장 및 포괄적인 테스트를 추가합니다.

Changes

Cohort / File(s) Summary
Redis 저장소 및 인프라
src/main/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepository.java
Redis Sorted Set을 사용하여 현재 울리는 알람을 관리하는 새로운 저장소 클래스 추가. add, getRingingAlarms, remove 메서드를 통해 알람 상태를 CRUD 관리합니다.
스케줄러 업데이트
src/main/java/akuma/whiplash/domains/alarm/application/scheduler/AlarmRingingNotificationScheduler.java
AlarmQueryService 의존성을 RingingAlarmRedisRepository로 교체. DB 쿼리 기반 접근에서 Redis 기반 접근으로 변경하고 관련 메트릭 이름 및 설명을 업데이트합니다.
도메인 서비스 업데이트
src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java
RingingAlarmRedisRepository 의존성 추가. removeAlarm, checkinAlarm, ringAlarm 메서드에서 Redis 상태 관리 로직(제거, 추가)을 통합합니다.
날짜 유틸리티 및 배치 서비스
src/main/java/akuma/whiplash/global/util/date/DateUtil.java, src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmOccurrenceBatchService.java
DateUtil에 LocalDateTime과 LocalTime을 처리하는 새로운 getNextOccurrenceDate 메서드 오버로드 추가. AlarmOccurrenceBatchService에 포맷팅 조정(공백 줄)을 적용합니다.
테스트 및 테스트 자원
src/test/java/akuma/whiplash/.../AlarmCommandServiceTest.java, src/test/java/akuma/whiplash/global/util/date/DateUtilTest.java, src/test/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepositoryTest.java, src/test/resources/docker-java.properties
AlarmCommandServiceTest에서 Redis 저장소 및 아카이브 서비스 목(mock) 추가 및 ringAlarm 테스트 재활성화. DateUtilTest와 RingingAlarmRedisRepositoryTest 신규 테스트 클래스 추가. Docker 테스트 자원 파일 추가합니다.
문서화
.claude/CLAUDE.md
Claude Code 사용 가이드라인을 정의하는 새로운 문서. 아키텍처, 계층 분류, 규칙, 테스트 전략, 커밋 메시지 형식 등을 명시합니다.

Sequence Diagram(s)

sequenceDiagram
    participant AlarmCmd as AlarmCommandService
    participant Redis as RingingAlarmRedisRepository
    participant Log as AlarmRingingLogRepo
    participant Scheduler as AlarmRingingNotificationScheduler
    participant FCM as FCM Service
    
    rect rgba(100, 200, 150, 0.5)
    Note over AlarmCmd,Redis: 1. 알람 울림 (Ring)
    AlarmCmd->>Log: 알람 울림 로그 저장
    Log-->>AlarmCmd: 저장 완료
    AlarmCmd->>Redis: add(alarmId, memberId, epochMillis)
    Redis-->>AlarmCmd: Redis Sorted Set 등록
    end
    
    rect rgba(100, 150, 200, 0.5)
    Note over Scheduler,Redis: 2. 스케줄러 폴링
    Scheduler->>Redis: getRingingAlarms()
    Redis-->>Scheduler: 현재 울려야 할 알람 목록
    end
    
    rect rgba(200, 150, 100, 0.5)
    Note over Scheduler,FCM: 3. 푸시 알림 전송
    Scheduler->>FCM: 푸시 알림 발송
    FCM-->>Scheduler: 전송 완료
    Scheduler->>Redis: remove(alarmId, memberId)
    Redis-->>Scheduler: 알람 제거
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

✅ test

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 주요 변경사항인 DB 중심의 폴링을 Redis 기반으로 전환하는 내용을 명확히 반영하고 있습니다.
Linked Issues check ✅ Passed PR이 문제 #102의 주요 요구사항들을 충족합니다: DB 폴링 부하 감소, Redis Sorted Set 기반 조회 구현, 알람 생명주기 동기화, 반복 요일 로직 유지, 테스트 추가 완료.
Out of Scope Changes check ✅ Passed 대부분의 변경사항이 #102 범위 내입니다. 다만 CLAUDE.md 추가는 문서화이고 docker-java.properties는 환경 설정으로, 핵심 기능 변경(Redis 마이그레이션)과는 부차적 관련성이 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/#102-improve-10s-interval-push

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 OpenGrep (1.16.1)
src/main/java/akuma/whiplash/domains/alarm/application/scheduler/AlarmRingingNotificationScheduler.java

OpenGrep SIGABRT (exit code 2)

src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java

OpenGrep SIGABRT (exit code 2)

src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmOccurrenceBatchService.java

OpenGrep SIGABRT (exit code 2)

  • 5 others
🔧 Semgrep (1.152.0)
src/main/java/akuma/whiplash/domains/alarm/application/scheduler/AlarmRingingNotificationScheduler.java

/usr/local/lib/python3.11/dist-packages/requests/init.py:113: RequestsDependencyWarning: urllib3 (2.6.3) or chardet (6.0.0.post1)/charset_normalizer (3.4.4) doesn't match a supported version!
warnings.warn(

src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmOccurrenceBatchService.java

/usr/local/lib/python3.11/dist-packages/requests/init.py:113: RequestsDependencyWarning: urllib3 (2.6.3) or chardet (6.0.0.post1)/charset_normalizer (3.4.4) doesn't match a supported version!
warnings.warn(

src/main/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepository.java

/usr/local/lib/python3.11/dist-packages/requests/init.py:113: RequestsDependencyWarning: urllib3 (2.6.3) or chardet (6.0.0.post1)/charset_normalizer (3.4.4) doesn't match a supported version!
warnings.warn(

  • 5 others

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.claude/CLAUDE.md:
- Around line 250-260: Add a trailing newline at the end of the file so the
"Claude Code 관련 질문 처리" section (the block containing the curl example `curl
https://code.claude.com/docs/ko/overview.md` and the documentation URL pattern)
ends with a single Unix-style newline character; update the file so the final
line is followed by one newline to satisfy POSIX/newline conventions.
- Around line 208-230: Add a language identifier to the commit message code
block so syntax highlighting is explicit; update the fenced code block
containing "[`#이슈번호`] :Emoji: <type>: <subject>" to use a language tag (e.g.,
```text) so the template at that snippet is rendered consistently and clearly.
- Around line 28-74: The fenced code blocks in the architecture doc lack
language specifiers which triggers markdown lint warnings; update both blocks
(the layer structure block containing "presentation (Controller) ..." and the
package tree block starting with "akuma.whiplash/" that mentions
RingingAlarmRedisRepository) to use a language tag (e.g., change ``` to ```text)
so the linter stops flagging them.
- Around line 110-207: Fix the fenced code block lint warnings in
.claude/CLAUDE.md by adding explicit language identifiers and ensuring a blank
line immediately after the opening fence for the example blocks (the structure
diagram containing "{원본클래스}Test" and the Java example starting with
"@DisplayName(\"AlarmCommandService Unit Test\")"); update the opening fences to
use ```text and ```java respectively and insert the required blank line so the
static analyzer stops flagging those blocks.

In
`@src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java`:
- Around line 199-200: AlarmCommandServiceImpl currently calls Redis operations
(e.g., ringingAlarmRedisRepository.remove and the other Redis calls around the
same spots) directly inside the DB transaction which can lead to DB/Redis state
divergence on rollback; change these to run only after a successful commit by
deferring them via a transaction synchronization or after-commit event: remove
the direct calls to ringingAlarmRedisRepository.remove (and the other Redis
calls in the same methods) and instead register a post-commit action (e.g.,
TransactionSynchronizationManager.registerSynchronization(...) with
afterCommit() or publish a domain event and handle it with
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)) that
performs the same repository calls; keep the same method names and parameters
when invoking the repository in the after-commit handler so the logic is
identical but executed only after DB commit.

In
`@src/main/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepository.java`:
- Around line 38-41: Rename the repository methods that use vague verbs (add,
getRingingAlarms, remove) to explicit persistence-style names to match the
coding guidelines: change add(...) to insertRingingAlarm(...),
getRingingAlarms(...) to findRingingAlarms(...), and remove(...) to
deleteRingingAlarm(...) (and apply the same pattern for the other similar
methods referenced around the file). Update all callers to use the new method
names and ensure JavaDoc/signatures reflect the new intent; leave internal logic
(e.g., use of redisTemplate.opsForZSet) unchanged.
- Around line 56-63: The stream mapping in RingingAlarmRedisRepository that
converts members into RingingPushInfo using the lambda (member -> { String[]
parts = member.split(":"); return RingingPushInfo.builder()... }) will throw on
malformed entries and must defensively skip them; update the mapping to first
validate parts.length == 2 and safely parse alarmId/memberId catching
NumberFormatException (or use a parse helper) and on parse failure log/debug the
bad member and return an empty Optional or filter it out so only valid
RingingPushInfo instances are collected, ensuring the scheduler won’t fail due
to a single bad member string.

In
`@src/test/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceTest.java`:
- Around line 67-70: Add verification that
ringingAlarmRedisRepository.remove(...) is called in the successful checkinAlarm
and removeAlarm test cases: update AlarmCommandServiceTest to assert
ringingAlarmRedisRepository.remove(...) is invoked (with the expected alarm id
or key) in the success paths of the test methods that exercise checkinAlarm and
removeAlarm, so the new mock is validated and Redis-sync removal is covered.

In `@src/test/java/akuma/whiplash/global/util/date/DateUtilTest.java`:
- Line 13: 현재 DateUtilTest 클래스의 `@DisplayName`("DateUtil Unit Test")는 규칙에 맞지 않으므로
`@DisplayName` 값을 문장형 도메인 설명으로 바꾸세요; 예컨대 DateUtil의 동작을 설명하는 문장(예: "DateUtil이 날짜를
지정된 포맷으로 변환한다" 또는 "날짜 파싱 시 유효하지 않은 입력을 예외 처리한다")처럼 테스트 의도를 한 문장으로 서술하도록
DateUtilTest 클래스의 `@DisplayName` 어노테이션을 수정하세요.

In
`@src/test/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepositoryTest.java`:
- Line 19: Update the `@DisplayName` annotations in
RingingAlarmRedisRepositoryTest so they use sentence-style, domain-term phrasing
(e.g., "알람이 저장되면 레디스에 존재한다" or "등록된 알람을 삭제하면 레디스에서 제거된다") instead of
title-style; locate the class-level `@DisplayName` on
RingingAlarmRedisRepositoryTest and the remaining `@DisplayName` annotations
within the same test file (the other occurrences in this class) and rewrite each
to a full-sentence, domain-focused description per the src/test/** guidelines.
- Around line 57-71: The test success_idempotent currently only checks count;
update it to assert the score was actually updated by calling
ringingAlarmRedisRepository.add(42L, 7L, updatedScore) and then retrieving
List<RingingPushInfo> result = ringingAlarmRedisRepository.getRingingAlarms();
assert result has size 1 and that result.get(0).getScore() (or the appropriate
field on RingingPushInfo) equals updatedScore (or is > firstScore) to ensure the
second add replaced the score (use the add and getRingingAlarms methods and the
RingingPushInfo score accessor to locate the fields).

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3a18a9c and fb92b7b.

⛔ Files ignored due to path filters (2)
  • logs/app-error.log is excluded by !**/*.log
  • logs/app.log is excluded by !**/*.log
📒 Files selected for processing (10)
  • .claude/CLAUDE.md
  • src/main/java/akuma/whiplash/domains/alarm/application/scheduler/AlarmRingingNotificationScheduler.java
  • src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java
  • src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmOccurrenceBatchService.java
  • src/main/java/akuma/whiplash/global/util/date/DateUtil.java
  • src/main/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepository.java
  • src/test/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceTest.java
  • src/test/java/akuma/whiplash/global/util/date/DateUtilTest.java
  • src/test/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepositoryTest.java
  • src/test/resources/docker-java.properties

Comment on lines +28 to +74
## 아키텍처

### 레이어 구조 (도메인별로 반복)

```
presentation (Controller)
application (UseCase) ← 도메인 서비스 조합, DTO 변환
domain (CommandService / QueryService) ← 비즈니스 규칙
persistence (Repository / Entity)
```

각 도메인(`alarm`, `auth`, `member`, `place`)은 위 4개 레이어를 독립적으로 가진다.

### 도메인 패키지 구조

```
akuma.whiplash/
├── domains/
│ └── {domain}/
│ ├── application/
│ │ ├── dto/ (request, response, etc)
│ │ ├── mapper/ (Entity ↔ DTO)
│ │ ├── usecase/ (UseCase 인터페이스 + 구현)
│ │ └── scheduler/ (alarm 도메인만 존재)
│ ├── domain/
│ │ ├── constant/ (enum)
│ │ └── service/ (CommandService, QueryService)
│ ├── persistence/
│ │ ├── entity/
│ │ └── repository/
│ ├── presentation/
│ └── exception/ (도메인별 ErrorCode enum)
├── infrastructure/
│ ├── redis/ (RedisRepository, RedisService, RingingAlarmRedisRepository)
│ └── firebase/ (FcmService, MockFcmService)
└── global/
├── config/
│ ├── security/ (JWT, Spring Security)
│ └── scheduler/ (스케줄러 ThreadPool)
├── exception/ (ApplicationException)
└── response/code/ (공통 ErrorCode, SuccessCode)
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

아키텍처 문서화가 PR 변경사항과 잘 정렬되어 있습니다

레이어 구조와 패키지 구조가 명확하게 문서화되어 있으며, 특히 Line 65의 RingingAlarmRedisRepository 언급이 이번 PR에서 도입된 Redis 기반 울림 상태 관리 시스템과 정확히 일치합니다.

📝 선택적 개선: Markdown linting 이슈 해결

Static analysis에서 fenced code block에 언어 지정자가 없다는 경고가 있습니다. 엄격한 markdown linting을 적용하려면 아래와 같이 수정할 수 있습니다:

Line 32-40의 레이어 구조 다이어그램:

-```
+```text
 presentation (Controller)

Line 46-74의 패키지 구조:

-```
+```text
 akuma.whiplash/

단, 이는 문서 가독성에 영향을 주지 않는 nitpick 수준의 개선사항입니다.

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 32-32: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 46-46: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/CLAUDE.md around lines 28 - 74, The fenced code blocks in the
architecture doc lack language specifiers which triggers markdown lint warnings;
update both blocks (the layer structure block containing "presentation
(Controller) ..." and the package tree block starting with "akuma.whiplash/"
that mentions RingingAlarmRedisRepository) to use a language tag (e.g., change
``` to ```text) so the linter stops flagging them.

Comment on lines +110 to +207
## 테스트 컨벤션

### 어떤 테스트를 선택할까

| 목적 | 사용할 어노테이션 |
|---|---|
| 컨트롤러 요청-응답 / 검증 / 예외 핸들링 | `@WebMvcTest` |
| 서비스 비즈니스 로직 / 트랜잭션 경계 | `@ExtendWith(MockitoExtension.class)` |
| 리포지토리 / 엔티티 / JPQL | `@PersistenceTest` |
| 보안 + 필터 + DB/Redis 연동 + 전체 플로우 | `@IntegrationTest` |
| Redis Sorted Set 등 Redis 슬라이스 | `@DataRedisTest` + `RedisContainerInitializer` |

### 테스트 어노테이션 상세

- **`@ExtendWith(MockitoExtension.class)`**: 서비스 단위 테스트. DB/Redis 없음, Mockito만 사용
- **`@WebMvcTest`**: 컨트롤러 슬라이스. 보안 필터 제외 시 `@AutoConfigureMockMvc(addFilters = false)`. 협력 빈은 `@MockitoBean`으로 주입
- **`@PersistenceTest`**: MySQL Testcontainer + `@DataJpaTest`. JPA Auditing 필요 시 `@Import(JpaAuditingConfig.class)`
- **`@IntegrationTest`**: 전체 Spring 컨텍스트, MySQL + Redis Testcontainer, 트랜잭션 롤백
- **`@DataRedisTest`** + `@ContextConfiguration(initializers = RedisContainerInitializer.class)`: Redis 슬라이스

### 테스트 구조 규칙 (@Nested)

하나의 테스트 클래스는 **클래스의 각 메서드마다 `@Nested` inner class 하나**로 구성한다.
각 inner class 안에 성공/실패 케이스를 모두 작성한다.

```
{원본클래스}Test
└── @Nested {메서드명}Test ← 메서드마다 inner class
├── success()
├── fail_{비즈니스실패이유}()
└── fail_{다른실패이유}()
```

예시:
```java
@DisplayName("AlarmCommandService Unit Test")
@ExtendWith(MockitoExtension.class)
class AlarmCommandServiceTest {

@Mock private AlarmRepository alarmRepository;
@InjectMocks private AlarmCommandServiceImpl alarmCommandService;

@Nested
@DisplayName("alarmOff - 알람 끄기(OFF)")
class AlarmOffTest {

@Test
@DisplayName("성공: 주간 OFF 한도 내에서 알람을 끈다")
void success() { ... }

@Test
@DisplayName("실패: 주간 OFF 한도를 초과하면 예외를 던진다")
void fail_weeklyLimitExceeded() { ... }
}

@Nested
@DisplayName("ringAlarm - 알람 울림")
class RingAlarmTest {

@Test
@DisplayName("성공: alarmRinging=true, 로그 저장, Redis 적재가 모두 수행된다")
void success() { ... }

@Test
@DisplayName("실패: 알람 시간이 되지 않았으면 예외를 던진다")
void fail_notAlarmTime() { ... }
}
}
```

### 클래스 / 메서드 네이밍

- 루트 테스트 클래스: `{원본클래스명}Test`
- inner 테스트 클래스: `{메서드명}Test` (ex. `alarmOff` → `AlarmOffTest`)
- 테스트 메서드: `success` / `fail_{비즈니스_관점_실패이유}` (ex. `fail_weeklyLimitExceeded`, `fail_memberNotFound`)

### DisplayName 규칙

- 문장형으로 작성. "~테스트" 금지
- 결과까지 기술: `"성공: 주간 OFF 한도 내에서 알람을 끈다"` / `"실패: 한도 초과 시 예외를 던진다"`
- inner class의 `@DisplayName`: `"{메서드명} - {한글 기능 설명}"` (ex. `"alarmOff - 알람 끄기(OFF)"`)
- 도메인 용어 사용 (메서드 이름 관점 X, 정책 관점 O)

### BDD 스타일 (Given / When / Then)

모든 테스트는 `// given`, `// when`, `// then` 주석으로 구분한다.

### 테스트 픽스처

`src/test/java/akuma/whiplash/common/fixture/`에 enum 기반 픽스처 존재:
- `MemberFixture` — 테스트용 멤버 (`MEMBER_1` ~ `MEMBER_N`), `toMockEntity()` 제공
- `AlarmFixture` — 테스트용 알람 (`ALARM_01` ~ `ALARM_N`), `toMockEntity()` 제공
- `AlarmOccurrenceFixture`

### FCM 테스트

프로파일 `test`에서는 `MockFcmService`가 자동으로 등록되어 실제 FCM 요청을 보내지 않는다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

테스트 컨벤션이 매우 체계적이고 실용적입니다

  • 테스트 어노테이션 선택 가이드(Line 114)가 목적별로 명확하게 정리되어 있습니다.
  • @Nested 구조 규칙과 예제(Lines 132-178)가 구체적이어서 일관된 테스트 코드 작성에 큰 도움이 될 것입니다.
  • DisplayName 규칙("~테스트" 금지, 결과까지 기술)이 명확하고 합리적입니다.
  • 테스트 픽스처 enum 패턴과 MockFcmService 설명이 유용합니다.
📝 선택적 개선: Markdown linting 이슈 해결

Static analysis에서 일부 fenced code block 관련 경고가 있습니다:

Line 135의 구조 다이어그램:

-```
+```text
 {원본클래스}Test

Line 144의 Java 예제 (blank line 추가 + 언어 지정):

 예시:
+
-```java
+```java
 `@DisplayName`("AlarmCommandService Unit Test")

이는 nitpick 수준의 개선사항입니다.

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 135-135: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 144-144: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/CLAUDE.md around lines 110 - 207, Fix the fenced code block lint
warnings in .claude/CLAUDE.md by adding explicit language identifiers and
ensuring a blank line immediately after the opening fence for the example blocks
(the structure diagram containing "{원본클래스}Test" and the Java example starting
with "@DisplayName(\"AlarmCommandService Unit Test\")"); update the opening
fences to use ```text and ```java respectively and insert the required blank
line so the static analyzer stops flagging those blocks.

Comment on lines +208 to +230
## 커밋 메시지 컨벤션

```
[#이슈번호] :Emoji: <type>: <subject>

<body>
- 파일명
- 변경 내용

<footer>
- 해결: #이슈번호
```

| Type | Emoji | 설명 |
|---|---|---|
| Feature | ✨ | 새로운 기능 추가 |
| Fix | 🐛 | 버그 수정 |
| Docs | 📝 | 문서 수정 |
| Style | 🎨 | 코드 포맷팅 (로직 변경 없음) |
| Refactor | ♻️ | 리팩토링 |
| Test | ✅ | 테스트 코드 추가/수정 |
| Chore | 🔧 | 빌드, 패키지 매니저 수정 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

커밋 메시지 컨벤션이 명확하게 정의되어 있습니다

이슈 번호 연결, Emoji/Type 분류, Subject 규칙(50자 제한, 마침표 없음)이 모두 명시되어 일관된 커밋 이력 관리가 가능합니다.

📝 선택적 개선: 템플릿 코드 블록 언어 지정

Line 210의 커밋 메시지 템플릿:

-```
+```text
 [`#이슈번호`] :Emoji: <type>: <subject>
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 210-210: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/CLAUDE.md around lines 208 - 230, Add a language identifier to the
commit message code block so syntax highlighting is explicit; update the fenced
code block containing "[`#이슈번호`] :Emoji: <type>: <subject>" to use a language tag
(e.g., ```text) so the template at that snippet is rendered consistently and
clearly.

Comment on lines +250 to +260
## Claude Code 관련 질문 처리

claude-code-guide는 틀린 답을 낼 때가 있다. 사용자가 Claude Code 기능에 대해 추가 질문을 하면, 공식 문서를 curl로 직접 참조해서 답한다.

```bash
curl https://code.claude.com/docs/ko/overview.md
```

문서 URL 패턴: `https://code.claude.com/docs/ko/{페이지명}.md`

답변 후에는 `AskUserQuestion`으로 퀴즈를 내서 사용자가 직접 따라해보도록 안내한다. No newline at end of file
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Claude Code 질문 처리 가이드가 유용합니다

공식 문서를 curl로 직접 참조하도록 한 것은 최신 정보를 얻는 실용적인 방법입니다.

📝 선택적 개선: 파일 끝 newline 추가

Unix 표준 관행에 따라 파일 끝에 빈 줄을 추가하세요:

 답변 후에는 `AskUserQuestion`으로 퀴즈를 내서 사용자가 직접 따라해보도록 안내한다.
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Claude Code 관련 질문 처리
claude-code-guide는 틀린 답을 낼 때가 있다. 사용자가 Claude Code 기능에 대해 추가 질문을 하면, 공식 문서를 curl로 직접 참조해서 답한다.
```bash
curl https://code.claude.com/docs/ko/overview.md
```
문서 URL 패턴: `https://code.claude.com/docs/ko/{페이지명}.md`
답변 후에는 `AskUserQuestion`으로 퀴즈를 내서 사용자가 직접 따라해보도록 안내한다.
## Claude Code 관련 질문 처리
claude-code-guide는 틀린 답을 낼 때가 있다. 사용자가 Claude Code 기능에 대해 추가 질문을 하면, 공식 문서를 curl로 직접 참조해서 답한다.
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 260-260: Files should end with a single newline character

(MD047, single-trailing-newline)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/CLAUDE.md around lines 250 - 260, Add a trailing newline at the end
of the file so the "Claude Code 관련 질문 처리" section (the block containing the curl
example `curl https://code.claude.com/docs/ko/overview.md` and the documentation
URL pattern) ends with a single Unix-style newline character; update the file so
the final line is followed by one newline to satisfy POSIX/newline conventions.

Comment on lines +199 to +200
// 1-2. 울리고 있을 수 있으므로 Redis Sorted Set에서 먼저 제거
ringingAlarmRedisRepository.remove(alarmId, memberId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

DB 커밋 전 Redis 반영으로 상태 불일치 위험이 있습니다.

Line [199], Line [266], Line [304]의 Redis 연산이 트랜잭션 내부에서 즉시 실행됩니다. 이후 DB 롤백이 나면 Redis/DB 상태가 어긋납니다. Redis 반영은 afterCommit(또는 outbox)로 미루는 편이 안전합니다.

afterCommit 적용 예시
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
@@
-        ringingAlarmRedisRepository.remove(alarmId, memberId);
+        runAfterCommit(() -> ringingAlarmRedisRepository.remove(alarmId, memberId));
@@
-        ringingAlarmRedisRepository.remove(alarmId, memberId);
+        runAfterCommit(() -> ringingAlarmRedisRepository.remove(alarmId, memberId));
@@
-        ringingAlarmRedisRepository.add(alarmId, memberId, score);
+        runAfterCommit(() -> ringingAlarmRedisRepository.add(alarmId, memberId, score));
@@
+    private static void runAfterCommit(Runnable action) {
+        if (!TransactionSynchronizationManager.isActualTransactionActive()) {
+            action.run();
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(
+            new TransactionSynchronization() {
+                `@Override`
+                public void afterCommit() {
+                    action.run();
+                }
+            }
+        );
+    }

Also applies to: 264-267, 298-305

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java`
around lines 199 - 200, AlarmCommandServiceImpl currently calls Redis operations
(e.g., ringingAlarmRedisRepository.remove and the other Redis calls around the
same spots) directly inside the DB transaction which can lead to DB/Redis state
divergence on rollback; change these to run only after a successful commit by
deferring them via a transaction synchronization or after-commit event: remove
the direct calls to ringingAlarmRedisRepository.remove (and the other Redis
calls in the same methods) and instead register a post-commit action (e.g.,
TransactionSynchronizationManager.registerSynchronization(...) with
afterCommit() or publish a domain event and handle it with
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)) that
performs the same repository calls; keep the same method names and parameters
when invoking the repository in the after-commit handler so the logic is
identical but executed only after DB commit.

Comment on lines +56 to +63
return members.stream()
.map(member -> {
String[] parts = member.split(":");
return RingingPushInfo.builder()
.alarmId(Long.parseLong(parts[0]))
.memberId(Long.parseLong(parts[1]))
.build();
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

비정상 member 포맷 1건이 전체 조회를 실패시킬 수 있습니다.

Line [58]~[61]은 포맷 이상 데이터가 들어오면 파싱 예외로 전체 스케줄러 경로를 깨뜨릴 수 있습니다. 잘못된 엔트리는 건너뛰도록 방어가 필요합니다.

방어 로직 예시
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
@@
-        return members.stream()
-            .map(member -> {
-                String[] parts = member.split(":");
-                return RingingPushInfo.builder()
-                    .alarmId(Long.parseLong(parts[0]))
-                    .memberId(Long.parseLong(parts[1]))
-                    .build();
-            })
-            .toList();
+        return members.stream()
+            .map(this::parseMemberSafely)
+            .flatMap(Optional::stream)
+            .toList();
     }
+
+    private Optional<RingingPushInfo> parseMemberSafely(String member) {
+        String[] parts = member.split(":", 2);
+        if (parts.length != 2) return Optional.empty();
+        try {
+            return Optional.of(
+                RingingPushInfo.builder()
+                    .alarmId(Long.parseLong(parts[0]))
+                    .memberId(Long.parseLong(parts[1]))
+                    .build()
+            );
+        } catch (NumberFormatException e) {
+            return Optional.empty();
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepository.java`
around lines 56 - 63, The stream mapping in RingingAlarmRedisRepository that
converts members into RingingPushInfo using the lambda (member -> { String[]
parts = member.split(":"); return RingingPushInfo.builder()... }) will throw on
malformed entries and must defensively skip them; update the mapping to first
validate parts.length == 2 and safely parse alarmId/memberId catching
NumberFormatException (or use a parse helper) and on parse failure log/debug the
bad member and return an empty Optional or filter it out so only valid
RingingPushInfo instances are collected, ensuring the scheduler won’t fail due
to a single bad member string.

Comment on lines +67 to +70
@Mock
private RingingAlarmRedisRepository ringingAlarmRedisRepository;
@Mock
private ArchiveService archiveService;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Redis 동기화 목적에 맞춰 remove 경로 검증도 추가해 주세요.

새 mock이 추가됐으니 checkinAlarm/removeAlarm 성공 케이스에서 ringingAlarmRedisRepository.remove(...) 호출 검증을 넣으면 회귀 방지가 더 강해집니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceTest.java`
around lines 67 - 70, Add verification that
ringingAlarmRedisRepository.remove(...) is called in the successful checkinAlarm
and removeAlarm test cases: update AlarmCommandServiceTest to assert
ringingAlarmRedisRepository.remove(...) is invoked (with the expected alarm id
or key) in the success paths of the test methods that exercise checkinAlarm and
removeAlarm, so the new mock is validated and Redis-sync removal is covered.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("DateUtil Unit Test")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

클래스 DisplayName을 문장형 도메인 설명으로 수정해 주세요.

Line [13]의 "DateUtil Unit Test"는 규칙상 요구되는 문장형/도메인 관점 표현과 맞지 않습니다.
As per coding guidelines src/test/**: - @DisplayName 문장형, 도메인 용어 사용. **/*Test.java: 테스트명은 @DisplayName을 사용하고, "음료를 추가하면 주문 목록에 담긴다"와 같이 문장형으로 작성합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/akuma/whiplash/global/util/date/DateUtilTest.java` at line 13,
현재 DateUtilTest 클래스의 `@DisplayName`("DateUtil Unit Test")는 규칙에 맞지 않으므로
`@DisplayName` 값을 문장형 도메인 설명으로 바꾸세요; 예컨대 DateUtil의 동작을 설명하는 문장(예: "DateUtil이 날짜를
지정된 포맷으로 변환한다" 또는 "날짜 파싱 시 유효하지 않은 입력을 예외 처리한다")처럼 테스트 의도를 한 문장으로 서술하도록
DateUtilTest 클래스의 `@DisplayName` 어노테이션을 수정하세요.


import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("RingingAlarmRedisRepository Test")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

테스트 그룹 DisplayName을 문장형으로 통일해 주세요.

현재 표현은 제목형이라 규칙의 문장형 스타일과 다릅니다.
As per coding guidelines src/test/**: - @DisplayName 문장형, 도메인 용어 사용. **/*Test.java: 테스트명은 @DisplayName을 사용하고, "음료를 추가하면 주문 목록에 담긴다"와 같이 문장형으로 작성합니다.

Also applies to: 38-38, 75-75, 111-111

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepositoryTest.java`
at line 19, Update the `@DisplayName` annotations in
RingingAlarmRedisRepositoryTest so they use sentence-style, domain-term phrasing
(e.g., "알람이 저장되면 레디스에 존재한다" or "등록된 알람을 삭제하면 레디스에서 제거된다") instead of
title-style; locate the class-level `@DisplayName` on
RingingAlarmRedisRepositoryTest and the remaining `@DisplayName` annotations
within the same test file (the other occurrences in this class) and rewrite each
to a full-sentence, domain-focused description per the src/test/** guidelines.

Comment on lines +57 to +71
@Test
@DisplayName("성공: 동일한 alarmId:memberId로 재호출 시 score만 갱신된다(멱등)")
void success_idempotent() {
// given
long firstScore = System.currentTimeMillis() - 2000;
long updatedScore = System.currentTimeMillis() - 1000;
ringingAlarmRedisRepository.add(42L, 7L, firstScore);

// when
ringingAlarmRedisRepository.add(42L, 7L, updatedScore); // 동일 member, score만 갱신

// then
List<RingingPushInfo> result = ringingAlarmRedisRepository.getRingingAlarms();
assertThat(result).hasSize(1); // 중복 적재 아님
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

멱등성 테스트에서 score 갱신까지 단언해 주세요.

현재는 건수만 확인해서 “갱신” 보장이 약합니다. 두 번째 add를 미래 score로 바꾼 뒤 조회 결과가 비어야 함을 검증하면 더 정확합니다.

검증 강화 예시
-            long updatedScore = System.currentTimeMillis() - 1000;
+            long updatedScore = System.currentTimeMillis() + 60_000;
@@
-            assertThat(result).hasSize(1); // 중복 적재 아님
+            assertThat(result).isEmpty(); // score가 갱신되어 due 대상에서 제외
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/akuma/whiplash/infrastructure/redis/RingingAlarmRedisRepositoryTest.java`
around lines 57 - 71, The test success_idempotent currently only checks count;
update it to assert the score was actually updated by calling
ringingAlarmRedisRepository.add(42L, 7L, updatedScore) and then retrieving
List<RingingPushInfo> result = ringingAlarmRedisRepository.getRingingAlarms();
assert result has size 1 and that result.get(0).getScore() (or the appropriate
field on RingingPushInfo) equals updatedScore (or is > firstScore) to ensure the
second add replaced the score (use the add and getRingingAlarms methods and the
RingingPushInfo score accessor to locate the fields).

@strongmhk strongmhk merged commit 052aa1f into develop Mar 2, 2026
1 of 2 checks passed
@strongmhk strongmhk deleted the refactor/#102-improve-10s-interval-push branch March 2, 2026 04:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

알람 울릴 때 10초 간격 푸시 알림 전송 기능을 개선한다.

1 participant