Skip to content

[Refactor] External Api Isolation in 방 생성#350

Open
seongjunnoh wants to merge 14 commits intodevelopfrom
refactor/#294-external-api-isolation
Open

[Refactor] External Api Isolation in 방 생성#350
seongjunnoh wants to merge 14 commits intodevelopfrom
refactor/#294-external-api-isolation

Conversation

@seongjunnoh
Copy link
Collaborator

@seongjunnoh seongjunnoh commented Mar 3, 2026

#️⃣ 연관된 이슈

closes #294

📝 작업 내용

외부 API(Naver, Aladin) 지연이 내부 시스템 자원(DB 커넥션, 톰캣 쓰레드) 고갈로 이어지는 연쇄 장애(Cascading Failure)를 차단하고, 방 생성 도메인의 가용성을 확보하기 위해 아키텍처를 개선했습니다.

트랜잭션 경계 분리 및 타임아웃 적용

  • @Transactional을 제거하고 TransactionTemplate을 도입하여 외부 API 호출 구간을 DB 트랜잭션 밖으로 분리
  • RestTemplateWebClient에 명시적 Timeout(Connect 3s, Read 5s) 설정

도메인 특성을 반영한 장애 격리 (Fault Isolation)

  • 부가 정보인 Aladin API 장애 시 예외를 전파하지 않고 pageCount = null로 격리하여 방 생성 속행(Fail-soft)
  • Aladin API 실패 내역은 방 생성 지연을 막기 위해 Discord 비동기(subscribe()) 알림 전송

최종적 일관성(Eventual Consistency) 보장

  • 매일 03:00 실행되는 BookPageCountFillScheduler 도입으로 누락된 pageCount 복구 (외부 API 쓰로틀링 적용)
  • 알라딘 미등록 도서(ISBN_NOT_FOUND)는 pageCountUnfindable = true 플래그로 업데이트하여 무의미한 API 반복 호출(좀비 프로세스) 영구 차단

동시성 예외 제어

  • 동일 ISBN 동시 요청 시 발생하는 DataIntegrityViolationException 예외를 캐치하여 기존 데이터 재조회로 처리

📸 스크린샷

image

💬 리뷰 요구사항

  • TransactionTemplate 내부로 묶인 DB 쓰기 작업의 트랜잭션 경계가 적절히 최소화되었는지 검토 바랍니다.
  • pageCount = null 인 데이터를 백그라운드에서 업데이트하는 스케줄러가 적절히 구현되었는지 검토 바랍니다.
  • Discord 에러 알림이 동기(500 에러)와 비동기(알라딘 장애)로 목적에 맞게 분리되었는지, 쓰레드 블로킹 우려는 없는지 확인 바랍니다.

📌 PR 진행 시 이러한 점들을 참고해 주세요

* P1 : 꼭 반영해 주세요 (Request Changes) - 이슈가 발생하거나 취약점이 발견되는 케이스 등
* P2 : 반영을 적극적으로 고려해 주시면 좋을 것 같아요 (Comment)
* P3 : 이런 방법도 있을 것 같아요~ 등의 사소한 의견입니다 (Chore)

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 매일 오전 3시에 자동으로 누락된 책의 페이지 수를 채우는 스케줄러 추가
    • API 오류 발생 시 실시간 알림 시스템 강화
  • 버그 수정

    • API 요청 타임아웃 설정 추가 (응답 제한시간 5초)
    • 찾을 수 없는 책에 대한 체계적인 처리 개선
    • 동시 다중 요청으로 인한 중복 생성 문제 해결
    • 일시적 API 오류 재시도 로직 개선

- Naver API 통신은 RestTemplate 이 아니라, 기존 코드(java net HttpURLConnection 활용) 유지
- 알라딘 API 통신으로 얻는 pageCount 값은 방 생성 시 부가 정보로 판단
- 따라서 해당 과정에서 예외 발생 시 전체 방 생성 로직을 롤백시키지 않도록 수정
- 알라딘 API 통신 실패 시 동작하는 비동기 알림 전송 메서드 추가
- Discord 서버와 동기/비동기 통신하는 헬퍼 메서드 추가
- 기존 500 error 발생 시 동작하는 디스코드 알림은 기존 동기적 통신 유지
- 실제 디스코드 웹훅 메시지 전송되는지 확인
- 테스트 실행시마다 디스코드 메시지 전송되는 것 막기 위해 해당 코드 주석처리
- WebClient 싱글톤 스프링 빈 활용하도록 config 파일 추가
- 매번 WebClient create 하는 것 방지
- 외부 API 호출 부분을 spring transactional 범위에서 제거
- TransactionTemplate 스프링 빈 추가
- TransactionTemplate 활용해 [방 저장 & 방장 저장] 원자적으로 실행
- 방 생성 시 알라딘 API 장애/지연 으로 pageCount = null로 세팅된 데이터를 백그라운드 업데이트 하는 스케줄러
- 알라딘에 등록되지 않은 책일 경우, 해당 flag를 업데이트
- 스케줄러가 필요없는 API 통신을 수행하지 않도록 하기 위함
Copilot AI review requested due to automatic review settings March 3, 2026 08:39
@seongjunnoh seongjunnoh linked an issue Mar 3, 2026 that may be closed by this pull request
6 tasks
@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

Walkthrough

null 페이지 수를 가진 책의 정보를 알라딘 API로 정기적으로 채우는 스케줄러를 추가합니다. API 실패 시 Discord로 알림을 보내고, 찾을 수 없는 책은 별도 플래그로 표시합니다.

Changes

Cohort / File(s) Summary
API 어댑터 및 유틸
src/main/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapter.java, src/main/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtil.java
알라딘 API 호출 시 예외를 try/catch로 처리하고 Discord 알림을 전송합니다. 네이버 API 유틸에 3초 연결 타임아웃과 5초 읽기 타임아웃을 추가합니다.
Book 도메인 및 영속성
src/main/java/konkuk/thip/book/domain/Book.java, src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java, src/main/java/konkuk/thip/book/adapter/out/mapper/BookMapper.java
pageCountUnfindable 필드를 추가하여 찾을 수 없는 책을 표시하고, 도메인과 영속성 계층 간에 매핑합니다.
Book 명령/조회 포트 및 어댑터
src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java, src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java, src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java, src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java, src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java
null 페이지 수를 가진 책을 조회하고, 찾을 수 없는 것으로 표시하기 위한 메서드를 추가합니다.
페이지 수 채우기 기능
src/main/java/konkuk/thip/book/application/port/in/BookPageCountFillUseCase.java, src/main/java/konkuk/thip/book/application/service/BookPageCountFillService.java, src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java
매일 03:00에 실행되는 스케줄러가 null 페이지 수를 채우고, API 실패 시 Discord로 결과를 보고합니다.
Discord 및 예외 처리
src/main/java/konkuk/thip/common/discord/DiscordClient.java, src/main/java/konkuk/thip/common/exception/ExternalApiException.java, src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java
API 실패 알림과 페이지 수 채우기 결과 보고를 위한 Discord 메서드를 추가합니다. ExternalApiException에 getter를 추가합니다.
HTTP 클라이언트 설정
src/main/java/konkuk/thip/config/RestTemplateConfig.java, src/main/java/konkuk/thip/config/WebClientConfig.java, src/main/java/konkuk/thip/config/AppConfig.java
RestTemplate과 WebClient에 3초 연결 타임아웃 및 5초 읽기 타임아웃을 설정합니다. TransactionTemplate 빈을 추가합니다.
Room 생성 서비스
src/main/java/konkuk/thip/room/application/service/RoomCreateService.java
TransactionTemplate을 사용하여 트랜잭션을 관리하고, 동시성 충돌 시 기존 책을 재사용합니다.
데이터베이스 마이그레이션
src/main/resources/db/migration/V260302__Add_page_count_unfindable_and_scheduler_index_to_book.sql
books 테이블에 page_count_unfindable 컬럼을 추가하고, 스케줄러 조회 최적화를 위한 인덱스를 생성합니다.
테스트
src/test/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapterTest.java, src/test/java/konkuk/thip/book/adapter/out/api/AladinApiDiscordAlertIntegrationTest.java, src/test/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtilTest.java, src/test/java/konkuk/thip/book/application/service/BookPageCountFillServiceTest.java, src/test/java/konkuk/thip/room/application/service/RoomCreateServiceTest.java
API 어댑터, 스케줄러 서비스, 룸 생성 서비스에 대한 단위 및 통합 테스트를 추가합니다.

Sequence Diagram(s)

sequenceDiagram
    participant Scheduler as BookPageCountFillScheduler
    participant Service as BookPageCountFillService
    participant QueryPort as BookQueryPort
    participant CommandPort as BookCommandPort
    participant AladinApi as AladinApiClient
    participant Discord as DiscordClient

    Scheduler->>Service: fillNullPageCounts()
    Service->>QueryPort: findBooksWithNullPageCountLinkedToRooms()
    QueryPort-->>Service: List<Book>
    
    loop for each book
        Service->>AladinApi: findPageCountByIsbn(isbn)
        
        alt Success
            AladinApi-->>Service: pageCount
            Service->>CommandPort: updateForPageCount(book)
            CommandPort-->>Service: OK
        else ISBN Not Found
            AladinApi-->>Service: ExternalApiException(ISBN_NOT_FOUND)
            Service->>CommandPort: updateForUnfindable(book)
            CommandPort-->>Service: OK
        else Transient Failure
            AladinApi-->>Service: ResourceAccessException
            Service->>Service: Skip update (retry later)
        end
        
        Service->>Service: throttle(500ms)
    end
    
    Service->>Discord: sendPageCountFillResult(total, success, unfindable, transientFail)
    Discord-->>Service: OK
Loading
sequenceDiagram
    participant Client as Client
    participant ApiAdapter as CompositeBookApiAdapter
    participant AladinApi as AladinApiClient
    participant Discord as DiscordClient

    Client->>ApiAdapter: findPageCountByIsbn(isbn)
    
    rect rgba(200, 150, 100, 0.5)
    ApiAdapter->>AladinApi: findPageCountByIsbn(isbn)
    
    alt Success
        AladinApi-->>ApiAdapter: pageCount
        ApiAdapter-->>Client: pageCount
    else Exception
        AladinApi-->>ApiAdapter: Exception
        ApiAdapter->>Discord: sendAladinApiFailureAlert(isbn, exception)
        Discord-->>ApiAdapter: OK (async)
        ApiAdapter-->>Client: null
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

🍀 refactor, 🧸 현준

Suggested reviewers

  • hd0rable
  • buzz0331

Poem

🐰 null 페이지들을 모아 모아,
알라딘 API로 채워 채워,
못 찾으면 표시해 두고,
Discord로 알려 주지요,
매일 밤 세시에 쏜다 쏜다! 🌙✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.33% 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 제목은 외부 API 격리 리팩토링의 핵심 변경사항을 요약하며, 방 생성 기능의 개선을 명확히 반영합니다.
Linked Issues check ✅ Passed PR은 #294의 목표인 외부 API 에러 처리 방식 수정을 완벽하게 충족합니다. 알라딘 API 실패시 null로 반환하는 fail-soft 방식, 예약 스케줄러를 통한 최종적 보정, Discord 비동기 알림 등으로 시스템 가용성 확보.
Out of Scope Changes check ✅ Passed RoomCreateServiceTest와 BookPageCountFillServiceTest 추가, WebClientConfig/AppConfig 구성 변경 등 모든 수정사항이 #294 목표인 외부 API 격리 및 에러 처리 개선과 범위 내입니다.

✏️ 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/#294-external-api-isolation

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.

@github-actions
Copy link

github-actions bot commented Mar 3, 2026

Test Results

508 tests   508 ✅  48s ⏱️
157 suites    0 💤
157 files      0 ❌

Results for commit 5748293.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

이 PR은 방 생성 과정에서 외부 API(Naver/Aladin) 지연·장애가 DB 커넥션/서버 스레드 고갈로 번지는 연쇄 장애를 줄이기 위해, 트랜잭션 경계를 외부 호출과 분리하고 타임아웃/Fail-soft/백필 스케줄러를 도입하는 리팩터링입니다.

Changes:

  • 방 생성 트랜잭션 경계를 TransactionTemplate로 최소화하고, 외부 API 호출 구간을 트랜잭션 밖으로 이동
  • RestTemplate/WebClient 타임아웃 명시 + 알라딘 장애 시 null 반환 및 Discord 알림(비동기)로 격리
  • page_count_unfindable 플래그/인덱스 및 BookPageCountFillScheduler/Service로 pageCount 백필(최종적 일관성) 구축 + 테스트 추가

Reviewed changes

Copilot reviewed 14 out of 26 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/main/java/konkuk/thip/room/application/service/RoomCreateService.java 외부 API 호출을 트랜잭션 밖으로 분리하고, Room/Participant 저장만 템플릿 트랜잭션으로 묶음
src/main/java/konkuk/thip/config/AppConfig.java TransactionTemplate Bean 등록
src/main/java/konkuk/thip/config/RestTemplateConfig.java RestTemplate connect/read timeout 설정 추가
src/main/java/konkuk/thip/config/WebClientConfig.java WebClient(Netty) connect/response timeout 설정 추가
src/main/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapter.java 알라딘 실패 시 Discord 알림 후 null 반환(Fail-soft), 네이버는 예외 전파 유지
src/main/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtil.java HttpURLConnection connect/read timeout 적용 및 포맷 정리
src/main/java/konkuk/thip/common/discord/DiscordClient.java WebClient 주입 기반으로 변경, 500 알림(동기) vs 알라딘 실패(비동기) 및 스케줄러 결과 알림 추가
src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java 500 에러 Discord 전송 try/catch 제거(DiscordClient 내부에서 처리하도록 위임)
src/main/java/konkuk/thip/common/exception/ExternalApiException.java errorCode getter 추가
src/main/java/konkuk/thip/book/domain/Book.java pageCountUnfindable 필드 및 markAsUnfindable() 추가
src/main/java/konkuk/thip/book/application/service/BookPageCountFillService.java pageCount null 백필 서비스(스로틀/실패 유형 분류/Discord 결과 알림) 추가
src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java 매일 03:00 백필 스케줄러 추가
src/main/java/konkuk/thip/book/application/port/in/BookPageCountFillUseCase.java 백필 유스케이스 인터페이스 추가
src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java 스케줄러용 조회 포트 메서드 추가
src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java 스케줄러용 unfindable 업데이트 포트 메서드 추가
src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java 스케줄러 대상 조회 JPQL 추가
src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java 스케줄러 대상 조회 구현 추가
src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java updateForUnfindable 구현 추가
src/main/java/konkuk/thip/book/adapter/out/mapper/BookMapper.java pageCountUnfindable 매핑 추가
src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java page_count_unfindable 컬럼 및 mark 메서드 추가
src/main/resources/db/migration/V260302__Add_page_count_unfindable_and_scheduler_index_to_book.sql unfindable 컬럼/스케줄러 인덱스 추가
src/test/java/konkuk/thip/room/application/service/RoomCreateServiceTest.java 트랜잭션 분리/알라딘 fail-soft/동시성 충돌 처리에 대한 단위 테스트 추가
src/test/java/konkuk/thip/book/application/service/BookPageCountFillServiceTest.java 백필 서비스 성공/미등록/일시실패/혼합 케이스 단위 테스트 추가
src/test/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtilTest.java IOException → ExternalApiException 변환 경로 테스트 추가
src/test/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapterTest.java 알라딘 fail-soft + Discord 알림 호출 여부 테스트 추가
src/test/java/konkuk/thip/book/adapter/out/api/AladinApiDiscordAlertIntegrationTest.java (수동 검증용) 실제 Discord 웹훅 전송 확인 테스트 추가

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

.bodyToMono(Void.class)
.block();
.subscribe(
null,
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

sendAsync()에서 subscribe(null, ...)로 구독하고 있는데, Reactor subscribe(Consumer, Consumer) 오버로드는 첫 번째 Consumer가 null이면 런타임에 NPE가 발생할 수 있습니다. 값 consumer는 사용하지 않더라도 빈 람다(unused -> {})처럼 non-null로 전달해 주세요.

Suggested change
null,
unused -> { },

Copilot uses AI. Check for mistakes.
Comment on lines 69 to 76
private void updateBookPageCount(Book book) {
// 알라딘 API 실패 시 null 반환 (Discord 알림은 어댑터에서 처리)
Integer pageCount = bookApiQueryPort.findPageCountByIsbn(book.getIsbn());
if (pageCount == null) return;

book.changePageCount(pageCount);
bookCommandPort.updateForPageCount(book);
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

알라딘 API가 pageCount 필드를 내려주지 않는 경우 AladinApiUtil#getPageCount()asInt() 기본값으로 0을 반환할 수 있습니다. 현재 로직은 0도 정상 값처럼 DB에 업데이트해버려 스케줄러(page_count IS NULL)로도 복구가 불가능해지고, 이후에도 hasPageCount()가 false라 계속 외부 API를 재호출하게 됩니다. pageCount == null || pageCount <= 0 인 경우는 업데이트하지 않도록 방어 로직을 추가해 주세요.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +55
book.changePageCount(pageCount);
bookCommandPort.updateForPageCount(book);
successCount++;
log.info("[스케줄러] pageCount 업데이트 완료 - isbn: {}, pageCount: {}", book.getIsbn(), pageCount);
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

aladinApiClient.findPageCountByIsbn()가 pageCount를 못 찾는 응답을 받으면(예: JSON 필드 누락) 0이 반환될 수 있는데, 현재는 0도 성공으로 카운트하고 DB에 저장합니다. 이렇게 되면 이후 스케줄러 조회 조건(page_count IS NULL)에 걸리지 않아 영구적으로 복구가 막힐 수 있으니, pageCount == null || pageCount <= 0이면 성공 처리/DB 업데이트를 건너뛰고 transient failure로 집계하는 식으로 처리해 주세요.

Suggested change
book.changePageCount(pageCount);
bookCommandPort.updateForPageCount(book);
successCount++;
log.info("[스케줄러] pageCount 업데이트 완료 - isbn: {}, pageCount: {}", book.getIsbn(), pageCount);
// pageCount를 찾지 못했거나 비정상 값(0 이하)인 경우 → 일시 실패로 간주, DB 미반영
if (pageCount == null || pageCount <= 0) {
transientFailCount++;
log.warn("[스케줄러] 유효하지 않은 pageCount 응답 (재시도 예정) - isbn: {}, pageCount: {}", book.getIsbn(), pageCount);
} else {
book.changePageCount(pageCount);
bookCommandPort.updateForPageCount(book);
successCount++;
log.info("[스케줄러] pageCount 업데이트 완료 - isbn: {}, pageCount: {}", book.getIsbn(), pageCount);
}

Copilot uses AI. Check for mistakes.
Copy link

@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: 3

🧹 Nitpick comments (10)
src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java (1)

35-36: 메서드명을 비즈니스 의도 중심으로 다듬는 것을 권장합니다.

updateForUnfindable보다 markPageCountUnfindable처럼 “무엇을 보장하는지”가 드러나는 이름이 포트 계약 이해에 더 유리합니다.

이름 개선 예시
-    void updateForUnfindable(Book book);
+    void markPageCountUnfindable(Book book);

Based on learnings: seongjunnoh는 메서드 네이밍 시 구현 세부사항보다 비즈니스 의도를 반영하는 것을 선호하며, 미래 확장성을 고려한 설계를 선호한다.

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

In `@src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java`
around lines 35 - 36, The port method name updateForUnfindable on the
BookCommandPort interface is implementation-centric; rename it to a
business-intent name such as markPageCountUnfindable(Book book) to make the
contract explicit, then update the BookCommandPort interface declaration, all
implementing classes and any callers/usages, plus Javadoc and tests to use
markPageCountUnfindable(Book) while keeping the method signature (void,
accepting Book) unchanged semantically.
src/main/java/konkuk/thip/config/WebClientConfig.java (1)

17-19: 타임아웃 값을 상수/공통 설정으로 통일하는 것을 권장합니다.

현재 숫자 리터럴이 직접 들어가 있어 RestTemplate 설정과 드리프트가 생기기 쉽습니다. 공통 상수(또는 프로퍼티)로 묶어 유지보수 비용을 줄이는 편이 좋습니다.

간단한 상수화 예시
 public class WebClientConfig {
+    private static final int CONNECT_TIMEOUT_MS = 3_000;
+    private static final int READ_TIMEOUT_MS = 5_000;
@@
         HttpClient httpClient = HttpClient.create()
-                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3_000)
-                .responseTimeout(Duration.ofSeconds(5));
+                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MS)
+                .responseTimeout(Duration.ofMillis(READ_TIMEOUT_MS));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/config/WebClientConfig.java` around lines 17 - 19,
Replace the numeric literals in the HttpClient setup in WebClientConfig (the
HttpClient httpClient = HttpClient.create()... chain) with named constants or
configurable properties so timeouts are centralized; introduce e.g.
CONNECT_TIMEOUT_MS and RESPONSE_TIMEOUT_SEC (as private static final constants
or `@Value-injected` properties) and use them in
ChannelOption.CONNECT_TIMEOUT_MILLIS and Duration.ofSeconds(...) respectively,
then update any other HTTP client or RestTemplate timeout configuration to
reference the same constants/properties to avoid drift.
src/main/java/konkuk/thip/common/discord/DiscordClient.java (1)

29-117: embed 생성 로직 중복 - 리팩토링 고려 가능.

sendErrorMessage, sendAladinApiFailureAlert, sendPageCountFillResult 각각에서 Map<String, String> 필드 생성과 payload 조립 패턴이 반복됩니다. 현재 가독성은 좋으나, 향후 유지보수를 위해 헬퍼 메서드 추출을 고려해볼 수 있습니다.

♻️ 선택적 리팩토링 예시
private Map<String, String> createField(String name, String value) {
    Map<String, String> field = new HashMap<>();
    field.put("name", name);
    field.put("value", value);
    return field;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/common/discord/DiscordClient.java` around lines 29
- 117, The three methods sendErrorMessage, sendAladinApiFailureAlert, and
sendPageCountFillResult duplicate embed/field creation and payload assembly;
extract small helper methods (e.g., createField(String name, String value),
buildEmbed(String title, List<Map<String,String>> fields) that returns the embed
map, and createPayload(Map<String,Object> embed) that returns the final payload)
and replace the repeated Map construction in those three methods to call the
helpers to assemble fields, embedData and payload before calling
sendSync/sendAsync.
src/test/java/konkuk/thip/book/adapter/out/api/AladinApiDiscordAlertIntegrationTest.java (1)

56-58: Thread.sleep 사용에 대한 참고사항.

수동 검증용 테스트로서 의도는 이해되지만, CI 환경에서는 discord.env=test로 인해 실제 Discord 전송이 발생하지 않습니다. @TestPropertySource가 주석 처리되어 있어 현재는 null 반환 검증만 수행됩니다.

비동기 동작 검증이 필요한 경우, DiscordClient를 모킹하고 verify()로 호출 여부를 확인하는 것이 더 안정적인 테스트가 될 수 있습니다.

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

In
`@src/test/java/konkuk/thip/book/adapter/out/api/AladinApiDiscordAlertIntegrationTest.java`
around lines 56 - 58, The test currently uses Thread.sleep after calling
sendAladinApiFailureAlert() to wait for the fire-and-forget subscribe(), which
is unreliable; remove the Thread.sleep and instead mock the asynchronous
collaborator (e.g., DiscordClient) in AladinApiDiscordAlertIntegrationTest and
inject it into the class under test (or use `@MockBean`), then call
sendAladinApiFailureAlert() and use Mockito.verify() (or equivalent) to assert
that the DiscordClient method was invoked (or that the reactive subscribe path
was executed) to deterministically validate the async behavior; also ensure any
commented `@TestPropertySource` is either restored or test-specific properties are
set so the mock is actually used.
src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java (1)

18-21: 예외 발생 시 종료 로그가 누락됩니다.

실행 중 예외가 발생하면 종료 로그가 남지 않아서 운영 추적이 어려워질 수 있습니다. finally로 종료 로그를 보장하는 편이 안전합니다.

개선 예시
 public void fillNullPageCounts() {
     log.info("[스케줄러] pageCount 업데이트 시작");
-    bookPageCountFillUseCase.fillNullPageCounts();
-    log.info("[스케줄러] pageCount 업데이트 종료");
+    try {
+        bookPageCountFillUseCase.fillNullPageCounts();
+    } finally {
+        log.info("[스케줄러] pageCount 업데이트 종료");
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java`
around lines 18 - 21, The fillNullPageCounts method in
BookPageCountFillScheduler currently logs only on start and after successful
completion, so if bookPageCountFillUseCase.fillNullPageCounts() throws the end
log is missing; wrap the call in a try/finally (or try/catch/finally) around
bookPageCountFillUseCase.fillNullPageCounts() so that the "[스케줄러] pageCount 업데이트
종료" log is executed in the finally block, and if you add a catch, log the
exception (e.g., with log.error) and rethrow if needed to preserve behavior.
src/test/java/konkuk/thip/room/application/service/RoomCreateServiceTest.java (2)

218-223: 중복 스텁이 테스트 의도를 흐립니다.

findByIsbn 첫 스텁은 바로 아래 순차 스텁에 의해 덮입니다. 하나로 합치면 시나리오가 더 명확해집니다.

정리 예시
-            given(bookCommandPort.findByIsbn(ISBN)).willReturn(Optional.empty());
             given(bookApiQueryPort.loadBookWithPageByIsbn(ISBN)).willReturn(bookWithPage());
@@
-            given(bookCommandPort.findByIsbn(ISBN)).willReturn(Optional.empty(), Optional.of(bookWithPage()));
+            given(bookCommandPort.findByIsbn(ISBN))
+                    .willReturn(Optional.empty(), Optional.of(bookWithPage()));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/konkuk/thip/room/application/service/RoomCreateServiceTest.java`
around lines 218 - 223, In RoomCreateServiceTest, remove the redundant initial
stubbing of bookCommandPort.findByIsbn and replace it with a single sequential
stub so the first invocation returns Optional.empty() and the subsequent
invocation returns Optional.of(bookWithPage()); keep the existing stubs for
bookApiQueryPort.loadBookWithPageByIsbn(ISBN) to return bookWithPage() and for
bookCommandPort.save(any()) to throw new
DataIntegrityViolationException("duplicate key") to preserve the race-condition
save-then-requery scenario.

57-61: 트랜잭션 경계 회귀를 잡는 검증이 부족합니다.

TransactionTemplate.execute(...)를 단순 pass-through로만 스텁해 두면, 외부 API 호출이 트랜잭션 안으로 다시 들어가도 테스트가 통과할 수 있습니다. PR 목표를 고정하려면 호출 순서 검증을 추가해 주세요.

테스트 보강 예시
 `@Test`
 `@DisplayName`("알라딘 API 성공 시 pageCount가 업데이트되고 방이 생성된다")
 void createRoom_aladinSuccess_pageCountUpdated() {
@@
     Long roomId = roomCreateService.createRoom(createCommand(), USER_ID);

     assertThat(roomId).isEqualTo(ROOM_ID);
     then(bookCommandPort).should().updateForPageCount(any());
     then(roomCommandPort).should().save(any());
+
+    InOrder inOrder = inOrder(bookApiQueryPort, transactionTemplate, roomCommandPort, roomParticipantCommandPort);
+    inOrder.verify(bookApiQueryPort).findPageCountByIsbn(ISBN); // 외부 API
+    inOrder.verify(transactionTemplate).execute(any());          // DB write 트랜잭션
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/konkuk/thip/room/application/service/RoomCreateServiceTest.java`
around lines 57 - 61, The test currently stubs transactionTemplate.execute to
invoke the TransactionCallback but lacks call-order checks, so add an InOrder
verification to assert that transactionTemplate.execute(...) (and the
TransactionCallback execution) happens before the external API mock is invoked;
use Mockito InOrder with the transactionTemplate mock and the external API mock
(e.g., externalApiClient) and verify
transactionTemplate.execute(any(TransactionCallback.class)) was called prior to
externalApiClient.<method>() to prevent regressions where the external API is
accidentally executed inside the transaction.
src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java (1)

23-24: CQRS 포트 경계 일관성을 한 번 더 점검해 주세요.

BookQueryPort가 도메인 엔티티(List<Book>)를 직접 반환하면, 조회 응답 포트와 도메인 조회 포트의 책임이 섞일 수 있습니다. 전용 조회 DTO를 두거나, 도메인 엔티티 조회 성격이면 Command 측 포트로 이동하는 방식을 검토해 주세요.

Based on learnings: THIP 프로젝트에서는 CQRS Port 분리 시 CommandPort에는 findByXXX로 도메인 엔티티를 조회하고, QueryPort에는 조회 API의 response 데이터를 DB에서 조회하는 메서드를 추가하는 컨벤션을 따릅니다.

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

In `@src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java`
around lines 23 - 24, BookQueryPort currently returns domain entities
(List<Book>) from findBooksWithNullPageCountLinkedToRooms which mixes query-port
responsibility with domain model; change this to return a dedicated query DTO
(e.g., BookNullPageCountDto) and update the method signature on BookQueryPort to
List<BookNullPageCountDto> findBooksWithNullPageCountLinkedToRooms(); create the
new DTO containing only the fields needed by the scheduler (id, roomId, any
pageCount-related flags) and move any domain-centric findBy... semantics back to
the Command/Repository port (e.g., BookCommandPort with findBy... returning
List<Book>) if you need the full entity elsewhere.
src/main/java/konkuk/thip/book/application/service/BookPageCountFillService.java (1)

77-79: Discord 알림 실패 시 예외 전파 가능성

discordClient.sendPageCountFillResult() 호출이 예외를 던지면 스케줄러 로그에 에러가 기록되고 작업이 실패한 것처럼 보일 수 있습니다. 실제 DB 업데이트는 이미 완료된 상태이므로, Discord 알림 실패가 전체 작업 결과에 영향을 주지 않도록 try-catch로 감싸는 것을 고려해보세요.

♻️ Discord 알림 실패 격리 제안
         log.info("[스케줄러] 실행 완료 - 대상: {}건 / 성공: {}건 / 미등록: {}건 / 일시실패: {}건",
                 books.size(), successCount, unfindableCount, transientFailCount);
-        discordClient.sendPageCountFillResult(books.size(), successCount, unfindableCount, transientFailCount);
+        try {
+            discordClient.sendPageCountFillResult(books.size(), successCount, unfindableCount, transientFailCount);
+        } catch (Exception e) {
+            log.warn("[스케줄러] Discord 알림 전송 실패 - error: {}", e.getMessage());
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/konkuk/thip/book/application/service/BookPageCountFillService.java`
around lines 77 - 79, The call to discordClient.sendPageCountFillResult in
BookPageCountFillService can throw and bubble up, making the scheduler appear to
fail after DB updates; wrap the sendPageCountFillResult(...) invocation in a
try-catch that logs the Discord delivery failure (using log.error) but does not
rethrow so the job remains successful, and optionally include the exception
details in the log to aid debugging; ensure the log.info([...]) call stays
before or is not suppressed by the catch so DB result counts (books.size(),
successCount, unfindableCount, transientFailCount) are preserved in logs even if
Discord fails.
src/test/java/konkuk/thip/book/application/service/BookPageCountFillServiceTest.java (1)

40-44: 테스트 헬퍼 메서드가 최소한의 필드만 설정합니다.

bookWithIsbn 헬퍼가 ISBN만 설정하고 있습니다. 현재 테스트에서는 BookCommandPort mock을 통해 검증하므로 문제없지만, 향후 book.changePageCount()book.markAsUnfindable() 등 도메인 메서드의 실제 동작을 검증하려면 Book 도메인 객체의 상태 변경도 assert하는 것이 좋습니다.

현재 구조에서는 Book 도메인 로직이 정상 동작하는지 별도 단위 테스트로 커버되어야 합니다.

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

In
`@src/test/java/konkuk/thip/book/application/service/BookPageCountFillServiceTest.java`
around lines 40 - 44, The helper method bookWithIsbn only sets isbn; update it
to initialize a fuller valid Book state (e.g., set
id/title/pageCount/status/author or other required fields used by domain
methods) so domain behavior like Book.changePageCount() and
Book.markAsUnfindable() can be meaningfully exercised in tests; modify the
bookWithIsbn helper (and any related test factories) to construct a Book with
those minimal fields consistent with the Book class invariants and use the Book
builder/constructors used in production so subsequent assertions against Book
instance state will be valid.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java`:
- Around line 35-41: The repository method
findBooksWithNullPageCountLinkedToRooms currently returns a List and loads all
matches into memory; change its signature in BookJpaRepository to accept a
Pageable (e.g., Pageable pageable) and return a Page<BookJpaEntity> or
Slice<BookJpaEntity> so queries can be paginated, update the `@Query` to keep the
same WHERE but remove ordering assumptions if using Slice, and then in the
service that calls findBooksWithNullPageCountLinkedToRooms use
PageRequest.of(page, size) in a loop (increment page until the returned page is
empty) to process results in bounded chunks rather than loading everything at
once.

In
`@src/main/java/konkuk/thip/book/application/service/BookPageCountFillService.java`:
- Around line 49-55: The loop in BookPageCountFillService currently treats any
Integer returned from aladinApiClient.findPageCountByIsbn as a success and
applies book.changePageCount even when the value may be 0 due to JSON parsing;
add defensive validation after calling findPageCountByIsbn (e.g., if pageCount
== null || pageCount <= 0) and skip updating the book (do not call
book.changePageCount or bookCommandPort.updateForPageCount and do not increment
successCount) when the pageCount is invalid so only valid positive page counts
are recorded.

In `@src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java`:
- Around line 17-18: The scheduled method fillNullPageCounts in
BookPageCountFillScheduler (and similar scheduled methods in BookDeleteScheduler
and RoomStateScheduler) will run on every app instance; add a distributed lock
using ShedLock: add the ShedLock dependency and a Redis LockProvider bean (e.g.,
RedisLockProvider) wired to your existing Redis client, then annotate each
scheduled method (fillNullPageCounts and the corresponding methods in
BookDeleteScheduler and RoomStateScheduler) with `@SchedulerLock`(name =
"BookPageCountFillScheduler.fillNullPageCounts" / appropriate unique names) and
set lockAtLeastFor and lockAtMostFor to safe durations so only one instance runs
the job at a time.

---

Nitpick comments:
In `@src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java`:
- Around line 35-36: The port method name updateForUnfindable on the
BookCommandPort interface is implementation-centric; rename it to a
business-intent name such as markPageCountUnfindable(Book book) to make the
contract explicit, then update the BookCommandPort interface declaration, all
implementing classes and any callers/usages, plus Javadoc and tests to use
markPageCountUnfindable(Book) while keeping the method signature (void,
accepting Book) unchanged semantically.

In `@src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java`:
- Around line 23-24: BookQueryPort currently returns domain entities
(List<Book>) from findBooksWithNullPageCountLinkedToRooms which mixes query-port
responsibility with domain model; change this to return a dedicated query DTO
(e.g., BookNullPageCountDto) and update the method signature on BookQueryPort to
List<BookNullPageCountDto> findBooksWithNullPageCountLinkedToRooms(); create the
new DTO containing only the fields needed by the scheduler (id, roomId, any
pageCount-related flags) and move any domain-centric findBy... semantics back to
the Command/Repository port (e.g., BookCommandPort with findBy... returning
List<Book>) if you need the full entity elsewhere.

In
`@src/main/java/konkuk/thip/book/application/service/BookPageCountFillService.java`:
- Around line 77-79: The call to discordClient.sendPageCountFillResult in
BookPageCountFillService can throw and bubble up, making the scheduler appear to
fail after DB updates; wrap the sendPageCountFillResult(...) invocation in a
try-catch that logs the Discord delivery failure (using log.error) but does not
rethrow so the job remains successful, and optionally include the exception
details in the log to aid debugging; ensure the log.info([...]) call stays
before or is not suppressed by the catch so DB result counts (books.size(),
successCount, unfindableCount, transientFailCount) are preserved in logs even if
Discord fails.

In `@src/main/java/konkuk/thip/common/discord/DiscordClient.java`:
- Around line 29-117: The three methods sendErrorMessage,
sendAladinApiFailureAlert, and sendPageCountFillResult duplicate embed/field
creation and payload assembly; extract small helper methods (e.g.,
createField(String name, String value), buildEmbed(String title,
List<Map<String,String>> fields) that returns the embed map, and
createPayload(Map<String,Object> embed) that returns the final payload) and
replace the repeated Map construction in those three methods to call the helpers
to assemble fields, embedData and payload before calling sendSync/sendAsync.

In `@src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java`:
- Around line 18-21: The fillNullPageCounts method in BookPageCountFillScheduler
currently logs only on start and after successful completion, so if
bookPageCountFillUseCase.fillNullPageCounts() throws the end log is missing;
wrap the call in a try/finally (or try/catch/finally) around
bookPageCountFillUseCase.fillNullPageCounts() so that the "[스케줄러] pageCount 업데이트
종료" log is executed in the finally block, and if you add a catch, log the
exception (e.g., with log.error) and rethrow if needed to preserve behavior.

In `@src/main/java/konkuk/thip/config/WebClientConfig.java`:
- Around line 17-19: Replace the numeric literals in the HttpClient setup in
WebClientConfig (the HttpClient httpClient = HttpClient.create()... chain) with
named constants or configurable properties so timeouts are centralized;
introduce e.g. CONNECT_TIMEOUT_MS and RESPONSE_TIMEOUT_SEC (as private static
final constants or `@Value-injected` properties) and use them in
ChannelOption.CONNECT_TIMEOUT_MILLIS and Duration.ofSeconds(...) respectively,
then update any other HTTP client or RestTemplate timeout configuration to
reference the same constants/properties to avoid drift.

In
`@src/test/java/konkuk/thip/book/adapter/out/api/AladinApiDiscordAlertIntegrationTest.java`:
- Around line 56-58: The test currently uses Thread.sleep after calling
sendAladinApiFailureAlert() to wait for the fire-and-forget subscribe(), which
is unreliable; remove the Thread.sleep and instead mock the asynchronous
collaborator (e.g., DiscordClient) in AladinApiDiscordAlertIntegrationTest and
inject it into the class under test (or use `@MockBean`), then call
sendAladinApiFailureAlert() and use Mockito.verify() (or equivalent) to assert
that the DiscordClient method was invoked (or that the reactive subscribe path
was executed) to deterministically validate the async behavior; also ensure any
commented `@TestPropertySource` is either restored or test-specific properties are
set so the mock is actually used.

In
`@src/test/java/konkuk/thip/book/application/service/BookPageCountFillServiceTest.java`:
- Around line 40-44: The helper method bookWithIsbn only sets isbn; update it to
initialize a fuller valid Book state (e.g., set id/title/pageCount/status/author
or other required fields used by domain methods) so domain behavior like
Book.changePageCount() and Book.markAsUnfindable() can be meaningfully exercised
in tests; modify the bookWithIsbn helper (and any related test factories) to
construct a Book with those minimal fields consistent with the Book class
invariants and use the Book builder/constructors used in production so
subsequent assertions against Book instance state will be valid.

In
`@src/test/java/konkuk/thip/room/application/service/RoomCreateServiceTest.java`:
- Around line 218-223: In RoomCreateServiceTest, remove the redundant initial
stubbing of bookCommandPort.findByIsbn and replace it with a single sequential
stub so the first invocation returns Optional.empty() and the subsequent
invocation returns Optional.of(bookWithPage()); keep the existing stubs for
bookApiQueryPort.loadBookWithPageByIsbn(ISBN) to return bookWithPage() and for
bookCommandPort.save(any()) to throw new
DataIntegrityViolationException("duplicate key") to preserve the race-condition
save-then-requery scenario.
- Around line 57-61: The test currently stubs transactionTemplate.execute to
invoke the TransactionCallback but lacks call-order checks, so add an InOrder
verification to assert that transactionTemplate.execute(...) (and the
TransactionCallback execution) happens before the external API mock is invoked;
use Mockito InOrder with the transactionTemplate mock and the external API mock
(e.g., externalApiClient) and verify
transactionTemplate.execute(any(TransactionCallback.class)) was called prior to
externalApiClient.<method>() to prevent regressions where the external API is
accidentally executed inside the transaction.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9e87db0 and 5748293.

📒 Files selected for processing (26)
  • src/main/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapter.java
  • src/main/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtil.java
  • src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java
  • src/main/java/konkuk/thip/book/adapter/out/mapper/BookMapper.java
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java
  • src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java
  • src/main/java/konkuk/thip/book/application/port/in/BookPageCountFillUseCase.java
  • src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java
  • src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java
  • src/main/java/konkuk/thip/book/application/service/BookPageCountFillService.java
  • src/main/java/konkuk/thip/book/domain/Book.java
  • src/main/java/konkuk/thip/common/discord/DiscordClient.java
  • src/main/java/konkuk/thip/common/exception/ExternalApiException.java
  • src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java
  • src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java
  • src/main/java/konkuk/thip/config/AppConfig.java
  • src/main/java/konkuk/thip/config/RestTemplateConfig.java
  • src/main/java/konkuk/thip/config/WebClientConfig.java
  • src/main/java/konkuk/thip/room/application/service/RoomCreateService.java
  • src/main/resources/db/migration/V260302__Add_page_count_unfindable_and_scheduler_index_to_book.sql
  • src/test/java/konkuk/thip/book/adapter/out/api/AladinApiDiscordAlertIntegrationTest.java
  • src/test/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapterTest.java
  • src/test/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtilTest.java
  • src/test/java/konkuk/thip/book/application/service/BookPageCountFillServiceTest.java
  • src/test/java/konkuk/thip/room/application/service/RoomCreateServiceTest.java

Comment on lines +35 to +41
@Query(
"SELECT b FROM BookJpaEntity b " +
"WHERE b.pageCount IS NULL AND b.pageCountUnfindable = false " +
"AND EXISTS (SELECT r FROM RoomJpaEntity r WHERE r.bookJpaEntity = b) " +
"ORDER BY b.bookId ASC"
)
List<BookJpaEntity> findBooksWithNullPageCountLinkedToRooms();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

스케줄러 입력 쿼리가 무제한 조회라서 대량 데이터에서 위험합니다.

현재 List 전체 적재 방식은 건수가 커질수록 메모리 점유와 배치 지연을 키워, 외부 API 보정 작업의 안정성을 떨어뜨릴 수 있습니다. 페이징/청크 기반으로 바꾸는 게 안전합니다.

페이징 적용 예시
+import org.springframework.data.domain.Pageable;
 import java.util.List;
@@
-    List<BookJpaEntity> findBooksWithNullPageCountLinkedToRooms();
+    List<BookJpaEntity> findBooksWithNullPageCountLinkedToRooms(Pageable pageable);

서비스에서는 PageRequest.of(0, N)부터 반복 조회하는 방식으로 처리량을 제한하는 것을 권장합니다.

📝 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
@Query(
"SELECT b FROM BookJpaEntity b " +
"WHERE b.pageCount IS NULL AND b.pageCountUnfindable = false " +
"AND EXISTS (SELECT r FROM RoomJpaEntity r WHERE r.bookJpaEntity = b) " +
"ORDER BY b.bookId ASC"
)
List<BookJpaEntity> findBooksWithNullPageCountLinkedToRooms();
`@Query`(
"SELECT b FROM BookJpaEntity b " +
"WHERE b.pageCount IS NULL AND b.pageCountUnfindable = false " +
"AND EXISTS (SELECT r FROM RoomJpaEntity r WHERE r.bookJpaEntity = b) " +
"ORDER BY b.bookId ASC"
)
List<BookJpaEntity> findBooksWithNullPageCountLinkedToRooms(Pageable pageable);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java`
around lines 35 - 41, The repository method
findBooksWithNullPageCountLinkedToRooms currently returns a List and loads all
matches into memory; change its signature in BookJpaRepository to accept a
Pageable (e.g., Pageable pageable) and return a Page<BookJpaEntity> or
Slice<BookJpaEntity> so queries can be paginated, update the `@Query` to keep the
same WHERE but remove ordering assumptions if using Slice, and then in the
service that calls findBooksWithNullPageCountLinkedToRooms use
PageRequest.of(page, size) in a loop (increment page until the returned page is
empty) to process results in bounded chunks rather than loading everything at
once.

Comment on lines +49 to +55
for (Book book : books) {
try {
Integer pageCount = aladinApiClient.findPageCountByIsbn(book.getIsbn());
book.changePageCount(pageCount);
bookCommandPort.updateForPageCount(book);
successCount++;
log.info("[스케줄러] pageCount 업데이트 완료 - isbn: {}, pageCount: {}", book.getIsbn(), pageCount);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*AladinApiClient*"

Repository: THIP-TextHip/THIP-Server

Length of output: 144


🏁 Script executed:

rg -n "findPageCountByIsbn" --type java -B 2 -A 15

Repository: THIP-TextHip/THIP-Server

Length of output: 41269


🏁 Script executed:

rg -n "changePageCount" --type java -B 2 -A 10

Repository: THIP-TextHip/THIP-Server

Length of output: 6686


🏁 Script executed:

find . -type f -name "*AladinApiUtil*"

Repository: THIP-TextHip/THIP-Server

Length of output: 142


🏁 Script executed:

cat -n ./src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiClient.java

Repository: THIP-TextHip/THIP-Server

Length of output: 538


🏁 Script executed:

cat -n ./src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java

Repository: THIP-TextHip/THIP-Server

Length of output: 2848


🏁 Script executed:

cat -n ./src/main/java/konkuk/thip/book/application/service/BookPageCountFillService.java | head -80

Repository: THIP-TextHip/THIP-Server

Length of output: 3981


AladinApiUtil.getPageCount()이 예외를 던지지 않을 때도 방어적 검증 필요

현재 AladinApiUtil.getPageCount()는 item이 없으면 ExternalApiException을 던지고, 그 외의 경우 JsonNode.asInt()로 pageCount를 반환합니다. 그런데 asInt()는 경로가 존재하지 않을 때 null이 아닌 0을 반환하기 때문에, 데이터 파싱 이상으로 0이 반환되어도 book.changePageCount(0)이 호출되고 성공으로 카운트됩니다.

RoomCreateService에서는 if (pageCount == null) return;로 방어하는 것처럼, BookPageCountFillService도 Integer 반환값에 대한 방어적 검증(null 체크 또는 유효성 검사)을 추가해야 합니다:

Integer pageCount = aladinApiClient.findPageCountByIsbn(book.getIsbn());
if (pageCount == null || pageCount <= 0) return; // 또는 적절한 처리
book.changePageCount(pageCount);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/konkuk/thip/book/application/service/BookPageCountFillService.java`
around lines 49 - 55, The loop in BookPageCountFillService currently treats any
Integer returned from aladinApiClient.findPageCountByIsbn as a success and
applies book.changePageCount even when the value may be 0 due to JSON parsing;
add defensive validation after calling findPageCountByIsbn (e.g., if pageCount
== null || pageCount <= 0) and skip updating the book (do not call
book.changePageCount or bookCommandPort.updateForPageCount and do not increment
successCount) when the pageCount is invalid so only valid positive page counts
are recorded.

Comment on lines +17 to +18
@Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul")
public void fillNullPageCounts() {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== 스케줄링/락 관련 코드 확인 =="
rg -n -C2 '@Scheduled|@SchedulerLock|EnableSchedulerLock|LockProvider|ShedLock|Quartz'

echo
echo "== 배포 스케일아웃 힌트 확인 =="
rg -n -C2 'replicas:|HorizontalPodAutoscaler|maxReplicas|minReplicas|autoscaling'

echo
echo "== BookPageCountFillScheduler 파일 내용 확인 =="
cat -n src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java

echo
echo "== pom.xml 또는 build.gradle에서 ShedLock 의존성 확인 =="
rg -n 'shedlock|ShedLock' --type xml --type gradle

Repository: THIP-TextHip/THIP-Server

Length of output: 2288


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== BookPageCountFillScheduler.java 파일 내용 =="
cat -n src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java

echo
echo "== 배포 설정 파일 확인 (Kubernetes, Docker Compose, etc.) =="
fd -e yaml -e yml -e json | grep -E '(deployment|docker-compose|k8s|manifest)' | head -20

echo
echo "== application.yml/properties에서 스케줄링 관련 설정 =="
find . -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" | head -10 | xargs cat -n 2>/dev/null || true

echo
echo "== 분산 락 설정이 있는지 Spring 설정 클래스 확인 =="
rg -n 'LockConfiguration|@Configuration|LockProvider|SchedulerLock' -A5 --type java | head -50

Repository: THIP-TextHip/THIP-Server

Length of output: 1078


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== application 설정 파일 찾기 =="
find . -type f \( -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" \) 2>/dev/null

echo
echo "== 프로젝트 루트의 설정/배포 파일 =="
ls -la | grep -E '\.(yml|yaml|docker|k8s|compose)' || echo "No deployment files in root"

echo
echo "== Docker/Kubernetes 관련 파일 =="
find . -type f \( -name "Dockerfile*" -o -name "*docker-compose*" -o -name "*.k8s.*" -o -name "*deployment*" -o -name "*statefulset*" \) 2>/dev/null | head -20

echo
echo "== pom.xml에서 분산 락/클러스터링 관련 의존성 =="
if [ -f pom.xml ]; then
  cat -n pom.xml | grep -i -A2 -B2 'shedlock\|redisson\|lettuce\|hazelcast\|spring-cloud\|spring-boot-starter-data-redis' || echo "No distributed lock dependencies found"
else
  echo "pom.xml not found"
fi

echo
echo "== application.yml 내용 확인 =="
find . -name "application.yml" -o -name "application.yaml" | head -1 | xargs cat -n 2>/dev/null || echo "No application.yml found"

Repository: THIP-TextHip/THIP-Server

Length of output: 273


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== build.gradle 확인 =="
find . -name "build.gradle" -o -name "build.gradle.kts" | head -5 | xargs cat -n 2>/dev/null || echo "No build.gradle found"

echo
echo "== Dockerfile 내용 =="
if [ -f ./Dockerfile ]; then
  cat -n ./Dockerfile
else
  echo "Dockerfile not found"
fi

echo
echo "== src/main/resources에서 application 설정 파일 찾기 =="
find src/main/resources -type f \( -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" \) 2>/dev/null | head -5

echo
echo "== 발견된 application 설정 파일 내용 =="
find src/main/resources -type f \( -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" \) 2>/dev/null | head -1 | xargs cat -n 2>/dev/null || echo "No application config found"

echo
echo "== 모든 스케줄러 파일 목록 =="
find . -path "*/scheduler/*.java" -type f | head -20

Repository: THIP-TextHip/THIP-Server

Length of output: 5248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== 모든 위치에서 application 파일 찾기 =="
find . -type f -name "application*" 2>/dev/null | head -20

echo
echo "== 코드베이스에서 `@SchedulerLock` 재검색 =="
rg -n '@SchedulerLock|@Lock|@Transactional.*readOnly|synchronized' --type java

echo
echo "== 다른 스케줄러 파일들의 내용 (분산 락 여부 확인) =="
echo "### BookDeleteScheduler.java ###"
cat -n ./src/main/java/konkuk/thip/common/scheduler/BookDeleteScheduler.java

echo
echo "### RoomStateScheduler.java ###"
cat -n ./src/main/java/konkuk/thip/common/scheduler/RoomStateScheduler.java

echo
echo "== 설정 클래스에서 스케줄링 설정 확인 =="
rg -n '@Configuration|@EnableScheduling|SchedulingConfigurer' --type java -A5 | head -40

Repository: THIP-TextHip/THIP-Server

Length of output: 11959


다중 인스턴스 환경에서 모든 스케줄러의 중복 실행 위험이 있습니다.

@Scheduled는 애플리케이션 인스턴스마다 독립적으로 실행됩니다. 코드베이스에 분산 락 구성(@SchedulerLock, ShedLock 등)이 없으며, Dockerfile을 통해 컨테이너화되어 있어 scale-out 환경에서 인스턴스 수만큼 중복 실행될 수 있습니다.

이는 BookPageCountFillScheduler뿐 아니라 BookDeleteScheduler, RoomStateScheduler 모두에 해당합니다. Redis가 의존성에 포함되어 있으므로 ShedLock을 추가하여 분산 락을 구성하는 것을 권장합니다.

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

In `@src/main/java/konkuk/thip/common/scheduler/BookPageCountFillScheduler.java`
around lines 17 - 18, The scheduled method fillNullPageCounts in
BookPageCountFillScheduler (and similar scheduled methods in BookDeleteScheduler
and RoomStateScheduler) will run on every app instance; add a distributed lock
using ShedLock: add the ShedLock dependency and a Redis LockProvider bean (e.g.,
RedisLockProvider) wired to your existing Redis client, then annotate each
scheduled method (fillNullPageCounts and the corresponding methods in
BookDeleteScheduler and RoomStateScheduler) with `@SchedulerLock`(name =
"BookPageCountFillScheduler.fillNullPageCounts" / appropriate unique names) and
set lockAtLeastFor and lockAtMostFor to safe durations so only one instance runs
the job at a time.

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.

[refactor] 외부 api 동작 관련 에러 핸들링 방식 수정

2 participants