Skip to content

Feat/#147 예산 수정 - 예산 변경 이력 추적#148

Open
kingmingyu wants to merge 10 commits into
feat/#141from
feat/#147
Open

Feat/#147 예산 수정 - 예산 변경 이력 추적#148
kingmingyu wants to merge 10 commits into
feat/#141from
feat/#147

Conversation

@kingmingyu

@kingmingyu kingmingyu commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

📌 관련 이슈

🚀 개요

이번 PR에서 변경된 핵심 내용을 요약해주세요.
예산 변경(BudgetHistory) 엔티티를 만들고 관련 엔티티를 활용할 수 있는 도메인에 추가

  • 타임라인 상세조회 응답에 추가
  • 대시보드 예산 변경 이력 조회API 추가

📄 작업 내용

구체적인 작업 내용을 설명해주세요.

  • BudgetHistory 엔티티 생성
  • 예산 수정 시 BudgetHistory 저장
  • 타임라인 상세 조회 응답에 예산 변경 이력 추가
  • 대시보드 예산 변경 이력 조회 API 추가

📸 스크린샷 / 테스트 결과 (선택)

결과물 확인을 위한 사진이나 테스트 로그를 첨부해주세요.

예산 수정 시 DB 저장

  1. Campaign 예산 수정
image image
  1. Ad_group 예산 수정
image image

대시보드 예산 변경 이력 조회 API

image image

타임라인 상세 조회

  • 타임라인 생성
image
  • 타임라인 엔티티 상세조회 시 예산 변경 추가 반환
image

✅ 체크리스트

  • 브랜치 전략(GitHub Flow)을 준수했나요?
  • 메서드 단위로 코드가 잘 쪼개져 있나요?
  • 테스트 통과 확인
  • 서버 실행 확인
  • API 동작 확인

🔍 리뷰 포인트 (Review Points)

리뷰어가 중점적으로 확인했으면 하는 부분을 적어주세요. (P1~P4 적용 가이드)

  • (예: 이 로직이 최선일까요? P2)
  • (예: 예외 처리 누락 여부 확인 부탁드립니다. P1)

P4: 예산 변경을 필드로만 관리하면 가장 최근 값 밖에 조회하지 못하고 수정 시점을 추적하기 위해서는 따로 테이블로 분리해서 관리하는 방법밖에 없을 것 같아서 따로 테이블로 분리해서 구현해봤습니다!
P4: 기존 브랜치(#142 )에 작업하면 pr 단위가 커질 것 같아서 따로 올렸습니다!
P2: 변경 이력을 또 적용할 다른 도메인이 있을까요?
P4: 타임라인 엔티티 AI 요약에 추가하는 건 따로 이슈파서 진행하겠습니다!

💬 리뷰어 가이드 (P-Rules)
P1: 필수 반영 (Critical) - 버그 가능성, 컨벤션 위반. 해결 전 머지 불가.
P2: 적극 권장 (Recommended) - 더 나은 대안 제시. 가급적 반영 권장.
P3: 제안 (Suggestion) - 아이디어 공유. 반영 여부는 드라이버 자율.
P4: 단순 확인/칭찬 (Nit) - 사소한 오타, 칭찬 등 피드백.

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능
    • 캠페인의 일일 예산을 언제든지 수정할 수 있는 기능이 추가되었습니다.
    • 광고그룹의 예산과 입찰가를 개별적으로 수정할 수 있습니다.
    • 모든 예산 변경 내역을 추적할 수 있으며, 대시보드에서 기간별로 조회할 수 있습니다.
    • 타임라인 상세 정보에 예산 변경 이력이 표시됩니다.

@kingmingyu kingmingyu self-assigned this Jun 11, 2026
@kingmingyu kingmingyu added ✨ Feature 새로운 기능 추가 🗄️ DB 데이터베이스, 엔티티, 마이그레이션 관련 labels Jun 11, 2026
@kingmingyu kingmingyu linked an issue Jun 11, 2026 that may be closed by this pull request
1 task
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • develop

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a8a35875-c277-429b-82d7-e2a6b39c7530

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

워크스루

전체적으로 예산 변경 이력을 추적하고 조회하는 기능이 일관되게 잘 설계된 PR입니다. 네이버 API 통합부터 로컬 DB 동기화, 그리고 대시보드/타임라인 조회까지 기능이 체계적으로 전개되어 있습니다.


변경 사항

예산 변경 이력 추적 기능

레이어 / 파일(들) 설명
예산 이력 데이터 모델 정의
src/main/java/com/whereyouad/.../domain/constant/BudgetFieldType.java, src/main/java/com/whereyouad/.../persistence/entity/BudgetHistory.java, src/main/java/com/whereyouad/.../persistence/repository/BudgetHistoryRepository.java
BudgetFieldType 열거형(CAMPAIGN_BUDGET, AD_GROUP_BUDGET, BID_AMOUNT)과 BudgetHistory JPA 엔티티를 정의하고, 조직별·기간별 조회를 위한 findByOrgAndPeriod() 커스텀 쿼리를 구현했습니다.
AdGroup 예산 필드 및 업데이트 메서드
src/main/java/com/whereyouad/.../persistence/entity/AdCampaign.java, src/main/java/com/whereyouad/.../persistence/entity/AdGroup.java
AdGroupbudget, bidAmount 필드를 추가하고, 두 엔티티 모두 updateBudget() 메서드로 선택적 필드 업데이트를 지원합니다.
Naver API 예산 수정 기능
src/main/java/com/whereyouad/.../infrastructure/client/naver/dto/NaverDTO.java, src/main/java/com/whereyouad/.../infrastructure/client/naver/client/NaverClient.java, src/main/java/com/whereyouad/.../domain/service/NaverAdApiService.java, src/main/java/com/whereyouad/.../domain/constant/BudgetFieldType.java, src/main/java/com/whereyouad/.../exception/code/NaverAdErrorCode.java, src/main/java/com/whereyouad/.../application/mapper/AdvertisementConverter.java, src/main/java/com/whereyouad/.../infrastructure/client/naver/converter/NaverConverter.java
Naver API 호출(updateCampaignBudget, updateAdGroupBudget) 및 로컬 DB 동기화, 관리자 권한 검증, 예산값 유효성 검사(10 단위 배수, 범위), 그리고 변경 이력 저장을 일관되게 구현합니다. 네이버 API 제약으로 광고그룹 예산/입찰가는 별도 호출로 처리됩니다.
Naver API 컨트롤러 및 문서화
src/main/java/com/whereyouad/.../presentation/NaverAdApiController.java, src/main/java/com/whereyouad/.../presentation/docs/NaverAdApiControllerDocs.java
PUT 엔드포인트 2개(/campaigns/{campaignId}/budget, /adgroups/{adgroupId}/budget)를 추가하고, Swagger 문서화 인터페이스에서 명세를 정의하며, 기존 테스트용 엔드포인트는 @Hidden으로 숨깁니다.
대시보드 예산 변경 이력 조회 API
src/main/java/com/whereyouad/.../application/dto/response/DashboardResponse.java, src/main/java/com/whereyouad/.../application/mapper/DashboardConverter.java, src/main/java/com/whereyouad/.../domain/service/DashboardService.java, src/main/java/com/whereyouad/.../domain/service/DashboardServiceImpl.java, src/main/java/com/whereyouad/.../presentation/DashboardController.java, src/main/java/com/whereyouad/.../presentation/docs/DashboardControllerDocs.java
GET /api/dashboard/{orgId}/budget-history 엔드포인트를 추가하여 조직별, 기간별 예산 변경 이력을 조회하고, BudgetHistoryItem, BudgetHistoryListResponse DTO로 반환합니다.
타임라인에 예산 변경 이력 통합
src/main/java/com/whereyouad/.../timeline/application/dto/response/TimelineResponse.java, src/main/java/com/whereyouad/.../timeline/application/mapper/TimelineConverter.java, src/main/java/com/whereyouad/.../timeline/domain/service/TimelineServiceImpl.java
타임라인 상세 응답에 budgetHistories 필드를 추가하여 해당 기간의 예산 변경 이력을 함께 반환합니다.

상세 검토 포인트

잘된 부분

  1. 일관된 권한 검증 구조 (NaverAdApiService의 validateAdminOwnership())

    • 연결 조회 → 조직원 검증 → ADMIN 역할 확인의 명확한 체크포인트를 거쳐 단계적으로 검증합니다.
    • 에러도 각 단계마다 구체적으로 던져져 원인 파악이 쉽습니다.
  2. 네이버 API 제약을 반영한 설계

    • 광고그룹 예산/입찰가는 별도 API 호출로 처리하고, 각각 별도의 BudgetHistory로 기록하는 것이 깔끔합니다.
    • 이를 통해 API 호출이 부분적으로 실패해도 성공한 부분은 이력에 남길 수 있습니다.
  3. 유연한 필드 업데이트 메서드

    • AdGroup.updateBudget(budget, bidAmount)에서 null 체크로 필요한 필드만 업데이트하는 방식은 향후 부분 업데이트 시나리오를 잘 지원합니다.
  4. 변환 로직의 명확성

    • DashboardConverter.toBudgetHistoryItems(), TimelineConverter.toBudgetHistoryItems() 등에서 adCampaign 존재 여부에 따라 targetName을 동적으로 설정하는 부분이 직관적입니다.

⚠️ 검토 시 주의할 부분

  1. N+1 쿼리 위험 (BudgetHistoryRepository.findByOrgAndPeriod)

    LEFT JOIN adCampaign ac ON bh.adCampaignId = ac.id
    LEFT JOIN adGroup ag ON bh.adGroupId = ag.id
    LEFT JOIN adCampaign ac2 ON ag.adCampaignId = ac2.id
    • 조인이 3단계인데, 실제 쿼리 플랜을 확인하세요.
    • 만약 AdGroup → AdCampaign 관계가 EAGER로 설정되어 있다면 추가 쿼리가 발생할 수 있습니다.
    • 권장: JPQL의 fetch join 사용 고려 또는 N+1 테스트 추가
  2. updateAdGroupBudget의 부분 성공 시나리오

    // dailyBudget 업데이트는 성공하지만 bidAmt 업데이트가 실패하는 경우
    • 현재 로직에서는 예외 발생 시 즉시 AdvertisementHandler 던지므로, 부분적 성공 상태를 기록하지 않습니다.
    • 이것이 의도한 설계라면 괜찮지만, 트랜잭션 경계와 이력 저장 순서를 문서화하면 좋겠습니다.
  3. 시간대 범위 계산 (DashboardServiceImpl.getBudgetHistory)

    startDate.atStartOfDay() ~ endDate.plusDays(1).atStartOfDay()
    • endDate를 포함하려고 plusDays(1)을 한 것은 맞는데, 타임존 고려가 명확하지 않습니다.
    • 사용자 타임존과 DB 저장 시간이 UTC라면, 경계 부근에서 이력이 누락될 수 있습니다.
    • 권장: LocalDateTime이 저장되는 시점에 타임존 설정을 명시적으로 문서화하세요.
  4. 대시보드/타임라인 DTO의 BudgetHistoryItem 중복

    DashboardResponse.BudgetHistoryItem
    TimelineResponse.BudgetHistoryItem
    • 두 DTO에서 동일한 구조의 BudgetHistoryItem을 각각 정의했습니다.
    • 향후 필드가 늘어나면 동기화 부담이 커집니다.
    • 권장: 공통 DTO를 따로 정의하거나, 하나의 정의로 통일 검토
  5. AdGroup의 useDailyBudget 조건부 설정 (NaverConverter)

    budget = useDailyBudget ? dto.dailyBudget() : null
    • useDailyBudget이 false면 budget이 null로 저장됩니다.
    • AdGroup.updateBudget(null, bidAmount) 호출 시 budget이 갱신되지 않는데, 이것이 의도라면 명시적 주석이 필요합니다.

예상 코드 리뷰 난이도

🎯 4 (복잡함) | ⏱️ ~45분

  • 다층 구조(API 클라이언트 → 서비스 → 저장소 → DTO 변환)의 일관성 확인
  • DB 쿼리 성능(LEFT JOIN 3단계, 기간 범위) 검증 필요
  • 권한 검증 흐름과 에러 처리 명확성 확인
  • 대시보드/타임라인 DTO 중복 검토

관련 가능성 있는 PR

  • WhereYouAd/WhereYouAd-Backend#122: Timeline 모듈에서 TimelineResponse에 budget-history 필드를 추가하는 개선과 맞물립니다.
  • WhereYouAd/WhereYouAd-Backend#134: TimelineResponse.TimelineDetailDTOTimelineConverter의 타임라인 상세 응답 로직을 확장하므로 코드 레벨에서 직접 관련됩니다.
  • WhereYouAd/WhereYouAd-Backend#130: 타임라인 도메인의 TimelineConverterTimelineServiceImpl 구현이 겹치므로 충돌 가능성을 확인하세요.

제안 검토자

  • jinnieusLab
  • ojy0903
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.76% 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
Title check ✅ Passed PR 제목이 핵심 변경사항을 명확하게 요약합니다. 예산 변경 이력 추적이라는 메인 목표를 간결하게 표현했습니다.
Linked Issues check ✅ Passed PR의 구현이 #147 이슈의 요구사항을 충족합니다. BudgetHistory 엔티티 생성, 예산 수정 시 히스토리 저장, 대시보드/타임라인에 반영 모두 구현되었습니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 예산 변경 이력 추적이라는 범위 내에 있습니다. 불필요한 리팩토링이나 무관한 변경은 발견되지 않습니다.
Description check ✅ Passed PR 설명이 기본 템플릿 구조를 잘 따르고 있으며, 개요, 작업 내용, 스크린샷, 체크리스트를 모두 제공하고 있습니다.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#147

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.

@kingmingyu kingmingyu changed the base branch from develop to feat/#141 June 11, 2026 02:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (3)
src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java (1)

118-133: ⚡ Quick win

📝 API 문서 누락: 400 응답 코드 추가 필요

다른 대시보드 API들(getRoasRanking Line 72, getPlatformMetricFacts Line 107)에서는 잘못된 파라미터에 대한 400 에러 응답을 문서화하고 있는데, 이 메서드에는 빠져있네요.

특히 DashboardServiceImpl에 날짜 검증 로직이 추가되면(위에서 제안한 대로), "시작일이 종료일보다 늦은 경우" 또는 "미래 날짜 입력" 등의 400 에러 케이스를 명시해야 프론트엔드 개발자가 에러 핸들링을 제대로 구현할 수 있어요.

📋 문서 보완 제안
 `@ApiResponses`({
         `@ApiResponse`(responseCode = "200", description = "성공"),
+        `@ApiResponse`(responseCode = "400", description = "시작일이 종료일보다 늦거나 미래 날짜인 경우"),
         `@ApiResponse`(responseCode = "403", description = "해당 조직에 대한 접근 권한이 없는 경우"),
         `@ApiResponse`(responseCode = "404", description = "존재하지 않는 조직")
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java`
around lines 118 - 133, Add a 400 ApiResponse to the getBudgetHistory method in
DashboardControllerDocs (method: getBudgetHistory) to document invalid-parameter
errors (e.g., startDate after endDate, future date) similar to getRoasRanking
and getPlatformMetricFacts; update the `@ApiResponses` array to include
`@ApiResponse`(responseCode = "400", description = "잘못된 요청: 날짜 파라미터 검증 실패(시작일>종료일,
미래 날짜 등)") so frontend can handle those validation errors (ensure this matches
any validation/exception cases thrown by DashboardServiceImpl date validation).
src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/converter/NaverConverter.java (1)

63-64: ⚡ Quick win

반복되는 일일 예산 추출 로직을 헬퍼 메서드로 분리하면 좋겠습니다.

dto.useDailyBudget() != null && dto.useDailyBudget() ? dto.dailyBudget() : null 패턴이 여러 곳에서 반복됩니다:

  • Line 63 (AdGroup 생성)
  • Line 76 (AdGroup 업데이트)
  • Line 42 (AdCampaign 업데이트)

이를 헬퍼 메서드로 추출하면 코드 가독성과 유지보수성이 향상됩니다.

♻️ 리팩토링 제안
// NaverConverter 클래스 하단에 추가
private static Long extractDailyBudgetIfUsed(Boolean useDailyBudget, Long dailyBudget) {
    return Boolean.TRUE.equals(useDailyBudget) ? dailyBudget : null;
}

그러면 사용하는 곳에서:

// Line 63-64
.budget(extractDailyBudgetIfUsed(dto.useDailyBudget(), dto.dailyBudget()))
.bidAmount(dto.bidAmt())

// Line 76-77
entity.updateBudget(
    extractDailyBudgetIfUsed(dto.useDailyBudget(), dto.dailyBudget()),
    dto.bidAmt()
);

추가 개선: Java 17의 Record pattern을 활용하면 더 깔끔하게 만들 수도 있습니다:

private static Long extractDailyBudgetIfUsed(NaverDTO.AdGroupResponse dto) {
    return Boolean.TRUE.equals(dto.useDailyBudget()) ? dto.dailyBudget() : null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/converter/NaverConverter.java`
around lines 63 - 64, Extract the repeated daily-budget conditional into a
private helper on NaverConverter (e.g., private static Long
extractDailyBudgetIfUsed(Boolean useDailyBudget, Long dailyBudget) or an
overload taking the DTO) and replace all occurrences of the pattern
dto.useDailyBudget() != null && dto.useDailyBudget() ? dto.dailyBudget() : null
with calls to extractDailyBudgetIfUsed(dto.useDailyBudget(), dto.dailyBudget());
update both the .budget(...) call in the AdGroup builder and the
entity.updateBudget(...) call (and the AdCampaign update site) to use this
helper to improve readability and avoid duplication.
src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/dto/NaverDTO.java (1)

137-163: 💤 Low value

[선택적 개선] DTO 레벨에서 validation 어노테이션 추가 고려

현재는 서비스 계층(NaverAdApiService)에서 예산 범위와 10 단위 배수를 검증하고 있는데, 컨트롤러 입력용 Request DTO에 @NotNull, @Min, @Max 등의 validation 어노테이션을 추가하면 더 명확하고 빠른 실패(fail-fast)가 가능합니다.

예시:

public record UpdateCampaignBudgetRequest(
    `@NotNull` Boolean useDailyBudget,
    `@Min`(50) `@Max`(1_000_000_000) Long dailyBudget
) {}

다만 10 단위 배수 검증은 커스텀 validator가 필요하므로, 현재처럼 서비스 레이어에서 처리하는 것도 합리적입니다. 선택적으로 개선을 고려해보세요!

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/dto/NaverDTO.java`
around lines 137 - 163, Add bean validation annotations to controller-facing
request records to enable fail-fast validation: annotate
UpdateCampaignBudgetRequest.useDailyBudget with `@NotNull` and
UpdateCampaignBudgetRequest.dailyBudget with `@NotNull` plus `@Min/`@Max bounds; do
the same for UpdateAdGroupBudgetRequest (useDailyBudget, dailyBudget, bidAmt)
with appropriate `@NotNull/`@Min/@Max constraints. Keep 10-unit multiple checks in
NaverAdApiService as noted (or implement a custom constraint if you want that
moved to DTOs). Ensure imports for javax/ jakarta validation annotations are
added and that controllers trigger validation (e.g., `@Valid` on parameters).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java`:
- Around line 239-274: The current NaverAdApiService method that calls
naverClient.updateAdGroupBudget twice can produce partial-success
inconsistencies: wrap each naverClient.updateAdGroupBudget call in its own
try-catch and track per-operation flags (e.g., budgetUpdated, bidAmtUpdated) so
you can log/handle partial success before throwing; after each successful remote
update, persist corresponding history via budgetHistoryRepository (use the
adGroupRepository.findByAdCampaign_PlatformAccountAndExternalGroupId(...) result
and avoid silent skips by replacing ifPresent with explicit handling — either
orElseThrow or log a warning when absent) and on failure log that the other side
was already changed (include adgroupId and which flag is true) then rethrow
AdvertisementHandler(NaverAdErrorCode.NAVER_AD_GROUP_BUDGET_UPDATE_FAILED) as
appropriate.
- Around line 203-210: The current updateCampaignBudget() only updates budget
and saves history inside
adCampaignRepository.findByPlatformAccountAndExternalCampaignId(...).ifPresent(...),
so when the local AdCampaign is missing the Naver API call can succeed but no
local budget/history is recorded; change this to use ifPresentOrElse on the
Optional returned by findByPlatformAccountAndExternalCampaignId(...) and in the
present branch perform campaign.updateBudget(...) and
budgetHistoryRepository.save(...), and in the else branch either log a warning
with identifying info and trigger NaverAdSyncService.syncAllMetadata() (or
enqueue a sync/retry) or throw a clear exception so callers can handle it—ensure
the branch includes userId and Provider.NAVER context in logs/operations.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/AdCampaign.java`:
- Around line 98-100: AdCampaign.updateBudget currently assigns budget directly
and should mirror AdGroup.updateBudget's defensive null check; modify
AdCampaign.updateBudget so it only sets this.budget when the passed budget is
not null (i.e., add an if (budget != null) guard) to prevent unintentionally
overwriting the existing budget.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/BudgetHistory.java`:
- Around line 39-46: BudgetHistory currently allows both adCampaign and adGroup
to be null or both set; add a validation to enforce exactly one parent:
implement two factory/static constructors (e.g., BudgetHistory.forCampaign(...)
and BudgetHistory.forAdGroup(...)) that validate the non-null parent and set
only the corresponding field, or add a check in the existing
BudgetHistory.builder()/build() to throw IllegalArgumentException if (adCampaign
== null && adGroup == null) or (adCampaign != null && adGroup != null);
alternatively (or in addition) add a DB CHECK constraint named like
check_single_parent to enforce (ad_campaign_id IS NOT NULL AND ad_group_id IS
NULL) OR (ad_campaign_id IS NULL AND ad_group_id IS NOT NULL).

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java`:
- Around line 13-25: The current repository method findByOrgAndPeriod in
BudgetHistoryRepository uses an OR on ac.organization.id and agc.organization.id
which hurts index usage; replace it by either (A) two repository
methods—findCampaignHistoryByOrgAndPeriod (JOIN bh.adCampaign ac WHERE
ac.organization.id = :orgId AND bh.createdAt BETWEEN :start AND :end) and
findAdGroupHistoryByOrgAndPeriod (JOIN bh.adGroup ag JOIN ag.adCampaign agc
WHERE agc.organization.id = :orgId AND bh.createdAt BETWEEN :start AND :end)—and
merge+sort results by BudgetHistory::getCreatedAt desc in the service, or (B)
use a native SQL UNION ALL query that selects both sets and orders by created_at
desc if you prefer a single DB call; implement one of these in place of
findByOrgAndPeriod to avoid the OR condition.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java`:
- Around line 56-70: The toBudgetHistoryItems method can NPE when both
adCampaign and adGroup are null; add a defensive check in
DashboardConverter.toBudgetHistoryItems to handle that case (e.g., if
bh.getAdCampaign()==null && bh.getAdGroup()==null) and either throw a clear
IllegalStateException including the BudgetHistory identifier (or set a safe
default like "UNKNOWN_TARGET") instead of calling getName() on a null, and
additionally add entity-level validation in BudgetHistory (e.g., a
`@PrePersist/`@PreUpdate method) to assert that at least one of adCampaign or
adGroup is non-null to prevent invalid persisted state.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java`:
- Around line 414-431: Add the same date validation used in getRoasRanking and
getOngoingAdCountByProvider to getBudgetHistory: after org/orgMember checks,
validate that neither startDate nor endDate is in the future (compare to
LocalDate.now()) and that startDate.isAfter(endDate) is not true; when a check
fails, throw a DashboardException with the same error codes used by the other
methods (use the same DashboardErrorCode constants for future-date and
invalid-range errors) so the method consistently rejects invalid date inputs
before querying budgetHistoryRepository.
- Around line 422-426: In DashboardServiceImpl#getBudgetHistory add the same
date validation used in getRoasRanking to reject or swap requests where
startDate is after endDate (preventing future/invalid ranges) and return/throw
accordingly; then update BudgetHistoryRepository#findByOrgAndPeriod to eagerly
fetch related entities (either convert the JPQL/Criteria to use JOIN FETCH for
adCampaign and adGroup or add an `@EntityGraph` including those associations) so
that DashboardConverter.toBudgetHistoryItems accessing
bh.getAdCampaign().getName() and bh.getAdGroup().getName() does not trigger N+1
lazy loads.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java`:
- Around line 48-55: Extract the duplicated record into a shared DTO (e.g.,
create BudgetHistoryDTO record with fields BudgetFieldType fieldType, String
targetName, Long previousValue, Long newValue, LocalDateTime changedAt, Provider
provider) and replace TimelineResponse.BudgetHistoryItem and
DashboardResponse.BudgetHistoryItem usages with this shared BudgetHistoryDTO,
updating imports and any constructor/factory code that referenced the old nested
records so both TimelineResponse and DashboardResponse reuse the common DTO.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java`:
- Around line 78-92: Refactor the duplicated budget-history mapping by
extracting the common logic used in TimelineConverter.toBudgetHistoryItems and
DashboardConverter.toBudgetHistoryItems into a shared utility (or add a helper
method on the BudgetHistory entity like getTargetName()); update both converters
to call that helper. Also harden against NPEs by ensuring the helper (or the
mapping) checks both bh.getAdCampaign() and bh.getAdGroup() and returns a safe
fallback (e.g., empty string or "unknown") if both are null before constructing
TimelineResponse.BudgetHistoryItem, leaving other fields unchanged.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java`:
- Around line 260-266: The BudgetHistory fetch causes an N+1 because
TimelineServiceImpl calls budgetHistoryRepository.findByOrgAndPeriod and then
TimelineConverter.toBudgetHistoryItems accesses bh.getAdCampaign().getName() and
bh.getAdGroup().getName(); update BudgetHistoryRepository.findByOrgAndPeriod to
eagerly load associations (e.g., add LEFT JOIN FETCH bh.adCampaign and LEFT JOIN
FETCH bh.adGroup — and if adGroup references campaign add LEFT JOIN FETCH
ag.adCampaign) or annotate the repository method with an `@EntityGraph` that
includes adCampaign and adGroup, ensuring the original WHERE predicate that
filters by organization (currently tied to ac/agc) is preserved.

---

Nitpick comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java`:
- Around line 118-133: Add a 400 ApiResponse to the getBudgetHistory method in
DashboardControllerDocs (method: getBudgetHistory) to document invalid-parameter
errors (e.g., startDate after endDate, future date) similar to getRoasRanking
and getPlatformMetricFacts; update the `@ApiResponses` array to include
`@ApiResponse`(responseCode = "400", description = "잘못된 요청: 날짜 파라미터 검증 실패(시작일>종료일,
미래 날짜 등)") so frontend can handle those validation errors (ensure this matches
any validation/exception cases thrown by DashboardServiceImpl date validation).

In
`@src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/converter/NaverConverter.java`:
- Around line 63-64: Extract the repeated daily-budget conditional into a
private helper on NaverConverter (e.g., private static Long
extractDailyBudgetIfUsed(Boolean useDailyBudget, Long dailyBudget) or an
overload taking the DTO) and replace all occurrences of the pattern
dto.useDailyBudget() != null && dto.useDailyBudget() ? dto.dailyBudget() : null
with calls to extractDailyBudgetIfUsed(dto.useDailyBudget(), dto.dailyBudget());
update both the .budget(...) call in the AdGroup builder and the
entity.updateBudget(...) call (and the AdCampaign update site) to use this
helper to improve readability and avoid duplication.

In
`@src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/dto/NaverDTO.java`:
- Around line 137-163: Add bean validation annotations to controller-facing
request records to enable fail-fast validation: annotate
UpdateCampaignBudgetRequest.useDailyBudget with `@NotNull` and
UpdateCampaignBudgetRequest.dailyBudget with `@NotNull` plus `@Min/`@Max bounds; do
the same for UpdateAdGroupBudgetRequest (useDailyBudget, dailyBudget, bidAmt)
with appropriate `@NotNull/`@Min/@Max constraints. Keep 10-unit multiple checks in
NaverAdApiService as noted (or implement a custom constraint if you want that
moved to DTOs). Ensure imports for javax/ jakarta validation annotations are
added and that controllers trigger validation (e.g., `@Valid` on parameters).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d5fd30de-8ba8-41e1-80c9-f403efecb49b

📥 Commits

Reviewing files that changed from the base of the PR and between 9da4d60 and f20767c.

📒 Files selected for processing (22)
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/application/mapper/AdvertisementConverter.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/constant/BudgetFieldType.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/exception/code/NaverAdErrorCode.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/AdCampaign.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/AdGroup.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/BudgetHistory.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/presentation/NaverAdApiController.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/presentation/docs/NaverAdApiControllerDocs.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/dto/response/DashboardResponse.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardService.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/DashboardController.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java
  • src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/client/NaverClient.java
  • src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/converter/NaverConverter.java
  • src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/dto/NaverDTO.java

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 11

🧹 Nitpick comments (3)
src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java (1)

118-133: ⚡ Quick win

📝 API 문서 누락: 400 응답 코드 추가 필요

다른 대시보드 API들(getRoasRanking Line 72, getPlatformMetricFacts Line 107)에서는 잘못된 파라미터에 대한 400 에러 응답을 문서화하고 있는데, 이 메서드에는 빠져있네요.

특히 DashboardServiceImpl에 날짜 검증 로직이 추가되면(위에서 제안한 대로), "시작일이 종료일보다 늦은 경우" 또는 "미래 날짜 입력" 등의 400 에러 케이스를 명시해야 프론트엔드 개발자가 에러 핸들링을 제대로 구현할 수 있어요.

📋 문서 보완 제안
 `@ApiResponses`({
         `@ApiResponse`(responseCode = "200", description = "성공"),
+        `@ApiResponse`(responseCode = "400", description = "시작일이 종료일보다 늦거나 미래 날짜인 경우"),
         `@ApiResponse`(responseCode = "403", description = "해당 조직에 대한 접근 권한이 없는 경우"),
         `@ApiResponse`(responseCode = "404", description = "존재하지 않는 조직")
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java`
around lines 118 - 133, Add a 400 ApiResponse to the getBudgetHistory method in
DashboardControllerDocs (method: getBudgetHistory) to document invalid-parameter
errors (e.g., startDate after endDate, future date) similar to getRoasRanking
and getPlatformMetricFacts; update the `@ApiResponses` array to include
`@ApiResponse`(responseCode = "400", description = "잘못된 요청: 날짜 파라미터 검증 실패(시작일>종료일,
미래 날짜 등)") so frontend can handle those validation errors (ensure this matches
any validation/exception cases thrown by DashboardServiceImpl date validation).
src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/converter/NaverConverter.java (1)

63-64: ⚡ Quick win

반복되는 일일 예산 추출 로직을 헬퍼 메서드로 분리하면 좋겠습니다.

dto.useDailyBudget() != null && dto.useDailyBudget() ? dto.dailyBudget() : null 패턴이 여러 곳에서 반복됩니다:

  • Line 63 (AdGroup 생성)
  • Line 76 (AdGroup 업데이트)
  • Line 42 (AdCampaign 업데이트)

이를 헬퍼 메서드로 추출하면 코드 가독성과 유지보수성이 향상됩니다.

♻️ 리팩토링 제안
// NaverConverter 클래스 하단에 추가
private static Long extractDailyBudgetIfUsed(Boolean useDailyBudget, Long dailyBudget) {
    return Boolean.TRUE.equals(useDailyBudget) ? dailyBudget : null;
}

그러면 사용하는 곳에서:

// Line 63-64
.budget(extractDailyBudgetIfUsed(dto.useDailyBudget(), dto.dailyBudget()))
.bidAmount(dto.bidAmt())

// Line 76-77
entity.updateBudget(
    extractDailyBudgetIfUsed(dto.useDailyBudget(), dto.dailyBudget()),
    dto.bidAmt()
);

추가 개선: Java 17의 Record pattern을 활용하면 더 깔끔하게 만들 수도 있습니다:

private static Long extractDailyBudgetIfUsed(NaverDTO.AdGroupResponse dto) {
    return Boolean.TRUE.equals(dto.useDailyBudget()) ? dto.dailyBudget() : null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/converter/NaverConverter.java`
around lines 63 - 64, Extract the repeated daily-budget conditional into a
private helper on NaverConverter (e.g., private static Long
extractDailyBudgetIfUsed(Boolean useDailyBudget, Long dailyBudget) or an
overload taking the DTO) and replace all occurrences of the pattern
dto.useDailyBudget() != null && dto.useDailyBudget() ? dto.dailyBudget() : null
with calls to extractDailyBudgetIfUsed(dto.useDailyBudget(), dto.dailyBudget());
update both the .budget(...) call in the AdGroup builder and the
entity.updateBudget(...) call (and the AdCampaign update site) to use this
helper to improve readability and avoid duplication.
src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/dto/NaverDTO.java (1)

137-163: 💤 Low value

[선택적 개선] DTO 레벨에서 validation 어노테이션 추가 고려

현재는 서비스 계층(NaverAdApiService)에서 예산 범위와 10 단위 배수를 검증하고 있는데, 컨트롤러 입력용 Request DTO에 @NotNull, @Min, @Max 등의 validation 어노테이션을 추가하면 더 명확하고 빠른 실패(fail-fast)가 가능합니다.

예시:

public record UpdateCampaignBudgetRequest(
    `@NotNull` Boolean useDailyBudget,
    `@Min`(50) `@Max`(1_000_000_000) Long dailyBudget
) {}

다만 10 단위 배수 검증은 커스텀 validator가 필요하므로, 현재처럼 서비스 레이어에서 처리하는 것도 합리적입니다. 선택적으로 개선을 고려해보세요!

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/dto/NaverDTO.java`
around lines 137 - 163, Add bean validation annotations to controller-facing
request records to enable fail-fast validation: annotate
UpdateCampaignBudgetRequest.useDailyBudget with `@NotNull` and
UpdateCampaignBudgetRequest.dailyBudget with `@NotNull` plus `@Min/`@Max bounds; do
the same for UpdateAdGroupBudgetRequest (useDailyBudget, dailyBudget, bidAmt)
with appropriate `@NotNull/`@Min/@Max constraints. Keep 10-unit multiple checks in
NaverAdApiService as noted (or implement a custom constraint if you want that
moved to DTOs). Ensure imports for javax/ jakarta validation annotations are
added and that controllers trigger validation (e.g., `@Valid` on parameters).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java`:
- Around line 239-274: The current NaverAdApiService method that calls
naverClient.updateAdGroupBudget twice can produce partial-success
inconsistencies: wrap each naverClient.updateAdGroupBudget call in its own
try-catch and track per-operation flags (e.g., budgetUpdated, bidAmtUpdated) so
you can log/handle partial success before throwing; after each successful remote
update, persist corresponding history via budgetHistoryRepository (use the
adGroupRepository.findByAdCampaign_PlatformAccountAndExternalGroupId(...) result
and avoid silent skips by replacing ifPresent with explicit handling — either
orElseThrow or log a warning when absent) and on failure log that the other side
was already changed (include adgroupId and which flag is true) then rethrow
AdvertisementHandler(NaverAdErrorCode.NAVER_AD_GROUP_BUDGET_UPDATE_FAILED) as
appropriate.
- Around line 203-210: The current updateCampaignBudget() only updates budget
and saves history inside
adCampaignRepository.findByPlatformAccountAndExternalCampaignId(...).ifPresent(...),
so when the local AdCampaign is missing the Naver API call can succeed but no
local budget/history is recorded; change this to use ifPresentOrElse on the
Optional returned by findByPlatformAccountAndExternalCampaignId(...) and in the
present branch perform campaign.updateBudget(...) and
budgetHistoryRepository.save(...), and in the else branch either log a warning
with identifying info and trigger NaverAdSyncService.syncAllMetadata() (or
enqueue a sync/retry) or throw a clear exception so callers can handle it—ensure
the branch includes userId and Provider.NAVER context in logs/operations.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/AdCampaign.java`:
- Around line 98-100: AdCampaign.updateBudget currently assigns budget directly
and should mirror AdGroup.updateBudget's defensive null check; modify
AdCampaign.updateBudget so it only sets this.budget when the passed budget is
not null (i.e., add an if (budget != null) guard) to prevent unintentionally
overwriting the existing budget.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/BudgetHistory.java`:
- Around line 39-46: BudgetHistory currently allows both adCampaign and adGroup
to be null or both set; add a validation to enforce exactly one parent:
implement two factory/static constructors (e.g., BudgetHistory.forCampaign(...)
and BudgetHistory.forAdGroup(...)) that validate the non-null parent and set
only the corresponding field, or add a check in the existing
BudgetHistory.builder()/build() to throw IllegalArgumentException if (adCampaign
== null && adGroup == null) or (adCampaign != null && adGroup != null);
alternatively (or in addition) add a DB CHECK constraint named like
check_single_parent to enforce (ad_campaign_id IS NOT NULL AND ad_group_id IS
NULL) OR (ad_campaign_id IS NULL AND ad_group_id IS NOT NULL).

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java`:
- Around line 13-25: The current repository method findByOrgAndPeriod in
BudgetHistoryRepository uses an OR on ac.organization.id and agc.organization.id
which hurts index usage; replace it by either (A) two repository
methods—findCampaignHistoryByOrgAndPeriod (JOIN bh.adCampaign ac WHERE
ac.organization.id = :orgId AND bh.createdAt BETWEEN :start AND :end) and
findAdGroupHistoryByOrgAndPeriod (JOIN bh.adGroup ag JOIN ag.adCampaign agc
WHERE agc.organization.id = :orgId AND bh.createdAt BETWEEN :start AND :end)—and
merge+sort results by BudgetHistory::getCreatedAt desc in the service, or (B)
use a native SQL UNION ALL query that selects both sets and orders by created_at
desc if you prefer a single DB call; implement one of these in place of
findByOrgAndPeriod to avoid the OR condition.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java`:
- Around line 56-70: The toBudgetHistoryItems method can NPE when both
adCampaign and adGroup are null; add a defensive check in
DashboardConverter.toBudgetHistoryItems to handle that case (e.g., if
bh.getAdCampaign()==null && bh.getAdGroup()==null) and either throw a clear
IllegalStateException including the BudgetHistory identifier (or set a safe
default like "UNKNOWN_TARGET") instead of calling getName() on a null, and
additionally add entity-level validation in BudgetHistory (e.g., a
`@PrePersist/`@PreUpdate method) to assert that at least one of adCampaign or
adGroup is non-null to prevent invalid persisted state.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java`:
- Around line 414-431: Add the same date validation used in getRoasRanking and
getOngoingAdCountByProvider to getBudgetHistory: after org/orgMember checks,
validate that neither startDate nor endDate is in the future (compare to
LocalDate.now()) and that startDate.isAfter(endDate) is not true; when a check
fails, throw a DashboardException with the same error codes used by the other
methods (use the same DashboardErrorCode constants for future-date and
invalid-range errors) so the method consistently rejects invalid date inputs
before querying budgetHistoryRepository.
- Around line 422-426: In DashboardServiceImpl#getBudgetHistory add the same
date validation used in getRoasRanking to reject or swap requests where
startDate is after endDate (preventing future/invalid ranges) and return/throw
accordingly; then update BudgetHistoryRepository#findByOrgAndPeriod to eagerly
fetch related entities (either convert the JPQL/Criteria to use JOIN FETCH for
adCampaign and adGroup or add an `@EntityGraph` including those associations) so
that DashboardConverter.toBudgetHistoryItems accessing
bh.getAdCampaign().getName() and bh.getAdGroup().getName() does not trigger N+1
lazy loads.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java`:
- Around line 48-55: Extract the duplicated record into a shared DTO (e.g.,
create BudgetHistoryDTO record with fields BudgetFieldType fieldType, String
targetName, Long previousValue, Long newValue, LocalDateTime changedAt, Provider
provider) and replace TimelineResponse.BudgetHistoryItem and
DashboardResponse.BudgetHistoryItem usages with this shared BudgetHistoryDTO,
updating imports and any constructor/factory code that referenced the old nested
records so both TimelineResponse and DashboardResponse reuse the common DTO.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java`:
- Around line 78-92: Refactor the duplicated budget-history mapping by
extracting the common logic used in TimelineConverter.toBudgetHistoryItems and
DashboardConverter.toBudgetHistoryItems into a shared utility (or add a helper
method on the BudgetHistory entity like getTargetName()); update both converters
to call that helper. Also harden against NPEs by ensuring the helper (or the
mapping) checks both bh.getAdCampaign() and bh.getAdGroup() and returns a safe
fallback (e.g., empty string or "unknown") if both are null before constructing
TimelineResponse.BudgetHistoryItem, leaving other fields unchanged.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java`:
- Around line 260-266: The BudgetHistory fetch causes an N+1 because
TimelineServiceImpl calls budgetHistoryRepository.findByOrgAndPeriod and then
TimelineConverter.toBudgetHistoryItems accesses bh.getAdCampaign().getName() and
bh.getAdGroup().getName(); update BudgetHistoryRepository.findByOrgAndPeriod to
eagerly load associations (e.g., add LEFT JOIN FETCH bh.adCampaign and LEFT JOIN
FETCH bh.adGroup — and if adGroup references campaign add LEFT JOIN FETCH
ag.adCampaign) or annotate the repository method with an `@EntityGraph` that
includes adCampaign and adGroup, ensuring the original WHERE predicate that
filters by organization (currently tied to ac/agc) is preserved.

---

Nitpick comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java`:
- Around line 118-133: Add a 400 ApiResponse to the getBudgetHistory method in
DashboardControllerDocs (method: getBudgetHistory) to document invalid-parameter
errors (e.g., startDate after endDate, future date) similar to getRoasRanking
and getPlatformMetricFacts; update the `@ApiResponses` array to include
`@ApiResponse`(responseCode = "400", description = "잘못된 요청: 날짜 파라미터 검증 실패(시작일>종료일,
미래 날짜 등)") so frontend can handle those validation errors (ensure this matches
any validation/exception cases thrown by DashboardServiceImpl date validation).

In
`@src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/converter/NaverConverter.java`:
- Around line 63-64: Extract the repeated daily-budget conditional into a
private helper on NaverConverter (e.g., private static Long
extractDailyBudgetIfUsed(Boolean useDailyBudget, Long dailyBudget) or an
overload taking the DTO) and replace all occurrences of the pattern
dto.useDailyBudget() != null && dto.useDailyBudget() ? dto.dailyBudget() : null
with calls to extractDailyBudgetIfUsed(dto.useDailyBudget(), dto.dailyBudget());
update both the .budget(...) call in the AdGroup builder and the
entity.updateBudget(...) call (and the AdCampaign update site) to use this
helper to improve readability and avoid duplication.

In
`@src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/dto/NaverDTO.java`:
- Around line 137-163: Add bean validation annotations to controller-facing
request records to enable fail-fast validation: annotate
UpdateCampaignBudgetRequest.useDailyBudget with `@NotNull` and
UpdateCampaignBudgetRequest.dailyBudget with `@NotNull` plus `@Min/`@Max bounds; do
the same for UpdateAdGroupBudgetRequest (useDailyBudget, dailyBudget, bidAmt)
with appropriate `@NotNull/`@Min/@Max constraints. Keep 10-unit multiple checks in
NaverAdApiService as noted (or implement a custom constraint if you want that
moved to DTOs). Ensure imports for javax/ jakarta validation annotations are
added and that controllers trigger validation (e.g., `@Valid` on parameters).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d5fd30de-8ba8-41e1-80c9-f403efecb49b

📥 Commits

Reviewing files that changed from the base of the PR and between 9da4d60 and f20767c.

📒 Files selected for processing (22)
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/application/mapper/AdvertisementConverter.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/constant/BudgetFieldType.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/exception/code/NaverAdErrorCode.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/AdCampaign.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/AdGroup.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/BudgetHistory.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/presentation/NaverAdApiController.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/presentation/docs/NaverAdApiControllerDocs.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/dto/response/DashboardResponse.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardService.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/DashboardController.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java
  • src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java
  • src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/client/NaverClient.java
  • src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/converter/NaverConverter.java
  • src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/naver/dto/NaverDTO.java
🛑 Comments failed to post (11)
src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java (2)

203-210: ⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 캠페인 예산 수정 API 호출 흐름 확인
rg -nP --type=java -C3 'updateCampaignBudget.*connectionId.*campaignId'

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 2171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) updateCampaignBudget 메서드 본문(203-210 포함) 확인
sed -n '160,260p' src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java

# 2) 메타데이터 동기화가 캠페인을 upsert 하는지 확인(관련 서비스/스케줄러)
rg -n --hidden -S "class NaverAdSyncService|syncAllMetadata\\(" src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service
rg -n --hidden -S "class NaverAdScheduler|syncAllMetadata\\(" src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service

# 3) syncAllMetadata 쪽에서 AdCampaign을 찾고 없으면 저장( upsert )하는지 확인
sed -n '1,260p' src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdSyncService.java

# 4) ifPresent로 인한 누락이 다른 곳에서도 동일 패턴인지(선택) 확인
rg -n "findByPlatformAccountAndExternalCampaignId\\(.*\\)\\s*\\.ifPresent" src/main/java/com/whereyouad/WhereYouAd/domains/advertisement -S

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 19578


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) updateCampaignBudget 메서드 본문(203-210 포함) 확인
sed -n '160,260p' src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java

# 2) 메타데이터 동기화가 캠페인을 upsert 하는지 확인(관련 서비스/스케줄러)
rg -n --hidden -S "class NaverAdSyncService|syncAllMetadata\\(" src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service
rg -n --hidden -S "class NaverAdScheduler|syncAllMetadata\\(" src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service

# 3) syncAllMetadata 쪽에서 AdCampaign을 찾고 없으면 저장( upsert )하는지 확인
sed -n '1,260p' src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdSyncService.java

# 4) ifPresent로 인한 누락이 다른 곳에서도 동일 패턴인지(선택) 확인
rg -n "findByPlatformAccountAndExternalCampaignId\\(.*\\)\\s*\\.ifPresent" src/main/java/com/whereyouad/WhereYouAd/domains/advertisement -S

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 19578


[NaverAdApiService] 캠페인 미존재 시 예산 변경 이력 누락 방지 필요

  • updateCampaignBudget()은 네이버 API 호출 후 adCampaignRepository.findByPlatformAccountAndExternalCampaignId(...).ifPresent(...) 안에서만 campaign.updateBudget(...)budgetHistoryRepository.save(...)가 실행됩니다. 즉 로컬에 AdCampaign이 없으면 네이버는 성공해도 예산/변경 이력이 조용히 누락되고 그대로 result만 반환됩니다.
  • NaverAdSyncService.syncAllMetadata()는 upsert로 없으면 신규 저장까지 하지만, 이 동기화 흐름은 예산 수정 호출과 선행/보장 관계가 없어서 “성공했는데 대시보드/타임라인에 이력 없음”이 발생할 수 있습니다.
  • ifPresentOrElse(미발견 시 경고 로그/예외/동기화 트리거/재시도 등)로 동작을 명확히 분기해 주세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java`
around lines 203 - 210, The current updateCampaignBudget() only updates budget
and saves history inside
adCampaignRepository.findByPlatformAccountAndExternalCampaignId(...).ifPresent(...),
so when the local AdCampaign is missing the Naver API call can succeed but no
local budget/history is recorded; change this to use ifPresentOrElse on the
Optional returned by findByPlatformAccountAndExternalCampaignId(...) and in the
present branch perform campaign.updateBudget(...) and
budgetHistoryRepository.save(...), and in the else branch either log a warning
with identifying info and trigger NaverAdSyncService.syncAllMetadata() (or
enqueue a sync/retry) or throw a clear exception so callers can handle it—ensure
the branch includes userId and Provider.NAVER context in logs/operations.

239-274: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

[주의] 부분 실패 시나리오와 트랜잭션 일관성 검토 필요

네이버 API 제약으로 예산과 입찰가를 별도로 호출하는 것은 합리적이지만, 다음 시나리오를 고려해야 합니다:

문제 상황:

  1. 예산 업데이트 API 성공 (line 245-246)
  2. 입찰가 업데이트 API 실패 (line 249-250)
  3. 전체 catch 블록에서 예외 던짐 (line 272) → 트랜잭션 롤백
  4. 하지만 네이버 측에는 예산 변경이 이미 반영됨 → 로컬 DB와 불일치

추가 고려 사항:

  • Lines 256-265: request.dailyBudget() null 체크로 이력 저장 여부를 결정하는데, 만약 예산 API는 성공했지만 입찰가 API가 실패하면 이력이 저장되지 않습니다.
  • Line 253의 ifPresent 패턴도 동일하게 광고그룹 미발견 시 조용히 스킵됩니다.

권장사항:

  1. 두 API 호출 사이에 부분 성공 상태를 추적하고, 실패 시 이미 성공한 변경 사항을 로그에 명시
  2. 또는 각 API 호출을 별도 try-catch로 감싸서 부분 성공도 반영하도록 처리
  3. ifPresent 대신 orElseThrow 또는 최소한 경고 로그 추가

예시:

NaverDTO.AdGroupResponse result = null;
boolean budgetUpdated = false;
try {
    if (request.dailyBudget() != null || request.useDailyBudget() != null) {
        result = naverClient.updateAdGroupBudget(headers, adgroupId, "budget",
                new NaverDTO.UpdateAdGroupBudgetBody(adgroupId, request.useDailyBudget(), request.dailyBudget(), null));
        budgetUpdated = true;
    }
    if (request.bidAmt() != null) {
        result = naverClient.updateAdGroupBudget(headers, adgroupId, "bidAmt",
                new NaverDTO.UpdateAdGroupBudgetBody(adgroupId, null, null, request.bidAmt()));
    }
} catch (Exception e) {
    if (budgetUpdated) {
        log.error("[NAVER] 입찰가 수정 실패하였으나 예산은 이미 수정됨 - adgroupId={}, 수동 동기화 필요", adgroupId, e);
    }
    throw new AdvertisementHandler(NaverAdErrorCode.NAVER_AD_GROUP_BUDGET_UPDATE_FAILED);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java`
around lines 239 - 274, The current NaverAdApiService method that calls
naverClient.updateAdGroupBudget twice can produce partial-success
inconsistencies: wrap each naverClient.updateAdGroupBudget call in its own
try-catch and track per-operation flags (e.g., budgetUpdated, bidAmtUpdated) so
you can log/handle partial success before throwing; after each successful remote
update, persist corresponding history via budgetHistoryRepository (use the
adGroupRepository.findByAdCampaign_PlatformAccountAndExternalGroupId(...) result
and avoid silent skips by replacing ifPresent with explicit handling — either
orElseThrow or log a warning when absent) and on failure log that the other side
was already changed (include adgroupId and which flag is true) then rethrow
AdvertisementHandler(NaverAdErrorCode.NAVER_AD_GROUP_BUDGET_UPDATE_FAILED) as
appropriate.
src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/AdCampaign.java (1)

98-100: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

AdGroup.updateBudget과의 일관성을 위해 null 체크를 추가하는 것이 좋습니다.

AdGroup.updateBudget (Line 66)에서는 if (budget != null) 체크를 하지만, 여기서는 null 체크 없이 직접 할당합니다.

두 엔티티가 유사한 역할의 메서드를 제공하므로, 동일한 방어 로직을 적용하는 것이 유지보수에 유리합니다:

public void updateBudget(Long budget) {
    if (budget != null) {
        this.budget = budget;
    }
}

이렇게 하면 null 값으로 의도치 않게 예산을 덮어쓰는 실수를 방지할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/AdCampaign.java`
around lines 98 - 100, AdCampaign.updateBudget currently assigns budget directly
and should mirror AdGroup.updateBudget's defensive null check; modify
AdCampaign.updateBudget so it only sets this.budget when the passed budget is
not null (i.e., add an if (budget != null) guard) to prevent unintentionally
overwriting the existing budget.
src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/BudgetHistory.java (1)

39-46: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

예산 이력이 캠페인 또는 광고그룹 중 정확히 하나에만 연결되도록 보장하는 제약이 필요합니다.

주석에 "2개 중에 1개만 연결 가능"이라고 명시했지만, 현재 구조로는 다음 경우들을 막을 수 없습니다:

  • 둘 다 null인 경우
  • 둘 다 값이 있는 경우

다음 중 하나를 적용하는 것을 권장합니다:

해결 방안 1 (권장): Builder나 팩토리 메서드에서 검증 로직 추가

public static BudgetHistory forCampaign(BudgetFieldType fieldType, Long previousValue, 
                                        Long newValue, Long changedBy, Provider provider, 
                                        AdCampaign adCampaign) {
    if (adCampaign == null) {
        throw new IllegalArgumentException("Campaign budget history must have a campaign");
    }
    return BudgetHistory.builder()
            .fieldType(fieldType)
            .previousValue(previousValue)
            .newValue(newValue)
            .changedBy(changedBy)
            .provider(provider)
            .adCampaign(adCampaign)
            .build();
}

public static BudgetHistory forAdGroup(BudgetFieldType fieldType, Long previousValue, 
                                       Long newValue, Long changedBy, Provider provider, 
                                       AdGroup adGroup) {
    if (adGroup == null) {
        throw new IllegalArgumentException("AdGroup budget history must have an adGroup");
    }
    return BudgetHistory.builder()
            .fieldType(fieldType)
            .previousValue(previousValue)
            .newValue(newValue)
            .changedBy(changedBy)
            .provider(provider)
            .adGroup(adGroup)
            .build();
}

해결 방안 2: DB 레벨 CHECK 제약 조건 (PostgreSQL 예시)

ALTER TABLE budget_history ADD CONSTRAINT check_single_parent 
CHECK (
    (ad_campaign_id IS NOT NULL AND ad_group_id IS NULL) OR 
    (ad_campaign_id IS NULL AND ad_group_id IS NOT NULL)
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/BudgetHistory.java`
around lines 39 - 46, BudgetHistory currently allows both adCampaign and adGroup
to be null or both set; add a validation to enforce exactly one parent:
implement two factory/static constructors (e.g., BudgetHistory.forCampaign(...)
and BudgetHistory.forAdGroup(...)) that validate the non-null parent and set
only the corresponding field, or add a check in the existing
BudgetHistory.builder()/build() to throw IllegalArgumentException if (adCampaign
== null && adGroup == null) or (adCampaign != null && adGroup != null);
alternatively (or in addition) add a DB CHECK constraint named like
check_single_parent to enforce (ad_campaign_id IS NOT NULL AND ad_group_id IS
NULL) OR (ad_campaign_id IS NULL AND ad_group_id IS NOT NULL).

Source: Coding guidelines

src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java (1)

13-25: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

OR 조건 사용으로 인한 쿼리 성능 이슈가 예상됩니다.

현재 쿼리는 WHERE (ac.organization.id = :orgId OR agc.organization.id = :orgId) 형태로 OR 조건을 사용하고 있습니다. 이 경우 다음 문제가 발생할 수 있습니다:

  1. 인덱스 최적화 어려움: 데이터베이스가 두 조건 중 어느 인덱스를 사용할지 판단하기 어려워 Full Table Scan이 발생할 수 있습니다.
  2. 대용량 데이터 처리 시 성능 저하: 조직의 예산 이력이 많아질수록 쿼리 성능이 급격히 나빠질 수 있습니다.
🔧 UNION ALL을 사용한 개선된 쿼리
`@Query`("""
    SELECT bh FROM BudgetHistory bh
    JOIN bh.adCampaign ac
    WHERE ac.organization.id = :orgId
      AND bh.createdAt BETWEEN :start AND :end
    UNION ALL
    SELECT bh FROM BudgetHistory bh
    JOIN bh.adGroup ag
    JOIN ag.adCampaign agc
    WHERE agc.organization.id = :orgId
      AND bh.createdAt BETWEEN :start AND :end
    ORDER BY createdAt DESC
""")

이 방식은 각 서브쿼리가 독립적으로 인덱스를 활용할 수 있어 성능이 훨씬 좋습니다. 다만 JPQL에서 UNION ALL 지원 여부는 Hibernate 버전에 따라 다를 수 있으니, 네이티브 쿼리로 전환하거나 두 개의 메서드로 분리 후 애플리케이션 레벨에서 합치는 것도 고려해보세요.

대안: 메서드 분리

`@Query`("""
    SELECT bh FROM BudgetHistory bh
    JOIN bh.adCampaign ac
    WHERE ac.organization.id = :orgId
      AND bh.createdAt BETWEEN :start AND :end
""")
List<BudgetHistory> findCampaignHistoryByOrgAndPeriod(
    `@Param`("orgId") Long orgId,
    `@Param`("start") LocalDateTime start,
    `@Param`("end") LocalDateTime end);

`@Query`("""
    SELECT bh FROM BudgetHistory bh
    JOIN bh.adGroup ag
    JOIN ag.adCampaign agc
    WHERE agc.organization.id = :orgId
      AND bh.createdAt BETWEEN :start AND :end
""")
List<BudgetHistory> findAdGroupHistoryByOrgAndPeriod(
    `@Param`("orgId") Long orgId,
    `@Param`("start") LocalDateTime start,
    `@Param`("end") LocalDateTime end);

// Service에서:
// List<BudgetHistory> all = new ArrayList<>();
// all.addAll(repository.findCampaignHistoryByOrgAndPeriod(...));
// all.addAll(repository.findAdGroupHistoryByOrgAndPeriod(...));
// all.sort(Comparator.comparing(BudgetHistory::getCreatedAt).reversed());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java`
around lines 13 - 25, The current repository method findByOrgAndPeriod in
BudgetHistoryRepository uses an OR on ac.organization.id and agc.organization.id
which hurts index usage; replace it by either (A) two repository
methods—findCampaignHistoryByOrgAndPeriod (JOIN bh.adCampaign ac WHERE
ac.organization.id = :orgId AND bh.createdAt BETWEEN :start AND :end) and
findAdGroupHistoryByOrgAndPeriod (JOIN bh.adGroup ag JOIN ag.adCampaign agc
WHERE agc.organization.id = :orgId AND bh.createdAt BETWEEN :start AND :end)—and
merge+sort results by BudgetHistory::getCreatedAt desc in the service, or (B)
use a native SQL UNION ALL query that selects both sets and orders by created_at
desc if you prefer a single DB call; implement one of these in place of
findByOrgAndPeriod to avoid the OR condition.

Source: Coding guidelines

src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java (1)

56-70: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🚨 NPE 위험: 캠페인과 광고그룹이 모두 null일 경우 처리 필요

Line 58-60에서 adCampaign이 null이면 adGroup.getName()을 호출하는데, 둘 다 null인 경우 NPE가 발생합니다.

BudgetHistory 엔티티 주석에는 "2개 중에 1개만 연결 가능"이라고 되어 있지만, 데이터베이스 제약조건이나 엔티티 검증으로 "둘 중 최소 하나는 반드시 non-null"임을 보장하지 않으면 런타임에 크래시될 수 있어요.

해결 방안:

  1. (권장) BudgetHistory 엔티티에 @PrePersist 검증 추가하여 둘 중 하나는 반드시 존재하도록 보장
  2. 또는 이 메서드에서 방어 코드 추가
🛡️ 방어 코드 예시
 public static List<DashboardResponse.BudgetHistoryItem> toBudgetHistoryItems(List<BudgetHistory> histories) {
     return histories.stream().map(bh -> {
-        String targetName = bh.getAdCampaign() != null
-                ? bh.getAdCampaign().getName()
-                : bh.getAdGroup().getName();
+        String targetName;
+        if (bh.getAdCampaign() != null) {
+            targetName = bh.getAdCampaign().getName();
+        } else if (bh.getAdGroup() != null) {
+            targetName = bh.getAdGroup().getName();
+        } else {
+            throw new IllegalStateException("BudgetHistory must have either adCampaign or adGroup");
+        }
         return new DashboardResponse.BudgetHistoryItem(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java`
around lines 56 - 70, The toBudgetHistoryItems method can NPE when both
adCampaign and adGroup are null; add a defensive check in
DashboardConverter.toBudgetHistoryItems to handle that case (e.g., if
bh.getAdCampaign()==null && bh.getAdGroup()==null) and either throw a clear
IllegalStateException including the BudgetHistory identifier (or set a safe
default like "UNKNOWN_TARGET") instead of calling getName() on a null, and
additionally add entity-level validation in BudgetHistory (e.g., a
`@PrePersist/`@PreUpdate method) to assert that at least one of adCampaign or
adGroup is non-null to prevent invalid persisted state.
src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java (2)

414-431: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

📅 날짜 유효성 검증 누락 - 일관성 문제

같은 클래스의 다른 메서드들(getRoasRanking, getOngoingAdCountByProvider)에서는 날짜 검증을 수행하는데, 이 메서드에는 빠져있어요:

  1. 미래 날짜 체크: startDate 또는 endDate가 미래인 경우 (Lines 194-196, 283-285 참고)
  2. 날짜 순서 체크: startDate.isAfter(endDate) 검증 (Lines 198-200, 287-289 참고)

이런 검증이 없으면 사용자가 잘못된 날짜를 입력해도 에러 없이 빈 결과를 반환하게 되어 UX가 나빠지고 디버깅도 어려워집니다.

✅ 검증 로직 추가 제안
 public DashboardResponse.BudgetHistoryListResponse getBudgetHistory(
         Long userId, Long orgId, LocalDate startDate, LocalDate endDate) {

+    // 날짜 유효성 검사
+    if (startDate.isAfter(LocalDate.now()) || endDate.isAfter(LocalDate.now())) {
+        throw new DashboardException(DashboardErrorCode.INVALID_DATE_RANGE);
+    }
+    if (startDate.isAfter(endDate)) {
+        throw new DashboardException(DashboardErrorCode.INVALID_DATE_RANGE);
+    }
+
     orgRepository.findById(orgId)
             .orElseThrow(() -> new DashboardException(OrgErrorCode.ORG_NOT_FOUND));
📝 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.

    public DashboardResponse.BudgetHistoryListResponse getBudgetHistory(
            Long userId, Long orgId, LocalDate startDate, LocalDate endDate) {

        // 날짜 유효성 검사
        if (startDate.isAfter(LocalDate.now()) || endDate.isAfter(LocalDate.now())) {
            throw new DashboardException(DashboardErrorCode.INVALID_DATE_RANGE);
        }
        if (startDate.isAfter(endDate)) {
            throw new DashboardException(DashboardErrorCode.INVALID_DATE_RANGE);
        }

        orgRepository.findById(orgId)
                .orElseThrow(() -> new DashboardException(OrgErrorCode.ORG_NOT_FOUND));
        orgMemberRepository.findByUserIdAndOrgId(userId, orgId)
                .orElseThrow(() -> new DashboardException(DashboardErrorCode.ACCESS_FORBIDDEN));

        List<BudgetHistory> histories = budgetHistoryRepository.findByOrgAndPeriod(
                orgId,
                startDate.atStartOfDay(),
                endDate.plusDays(1).atStartOfDay()
        );

        List<DashboardResponse.BudgetHistoryItem> items = DashboardConverter.toBudgetHistoryItems(histories);

        return new DashboardResponse.BudgetHistoryListResponse(startDate, endDate, items);
    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java`
around lines 414 - 431, Add the same date validation used in getRoasRanking and
getOngoingAdCountByProvider to getBudgetHistory: after org/orgMember checks,
validate that neither startDate nor endDate is in the future (compare to
LocalDate.now()) and that startDate.isAfter(endDate) is not true; when a check
fails, throw a DashboardException with the same error codes used by the other
methods (use the same DashboardErrorCode constants for future-date and
invalid-range errors) so the method consistently rejects invalid date inputs
before querying budgetHistoryRepository.

Source: Coding guidelines


422-426: ⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# BudgetHistoryRepository에서 fetch join 사용 여부 확인
rg -A 10 "findByOrgAndPeriod" --type java

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 9933


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) BudgetHistoryRepository 쿼리/JPQL/SQL 존재 여부 확인
fd -a "BudgetHistoryRepository.java" src/main/java | head -n 20
for f in $(fd -a "BudgetHistoryRepository.java" src/main/java); do
  echo "===== $f ====="
  cat -n "$f" | sed -n '1,220p'
done

# 2) DashboardConverter 내 toBudgetHistoryItems()가 어떤 필드를 접근하는지 확인
fd -a "DashboardConverter.java" src/main/java | head -n 20
for f in $(fd -a "DashboardConverter.java" src/main/java); do
  echo "===== $f ====="
  rg -n "toBudgetHistoryItems" "$f" || true
  # 메서드 주변만 출력(너무 길면 범위 축소)
  rg -n "toBudgetHistoryItems" "$f" -n --context 5 || true
done

# 3) BudgetHistory 엔티티의 adCampaign/adGroup 매핑 및 FetchType 확인
fd -a "BudgetHistory.java" src/main/java | head -n 20
for f in $(fd -a "BudgetHistory.java" src/main/java); do
  echo "===== $f ====="
  cat -n "$f" | sed -n '1,260p'
done

# 4) DashboardServiceImpl getBudgetHistory() date validation 존재 여부 확인
fd -a "DashboardServiceImpl.java" src/main/java | head -n 20
for f in $(fd -a "DashboardServiceImpl.java" src/main/java); do
  echo "===== $f ====="
  # getBudgetHistory 메서드 범위만 대략 스캔
  rg -n "getBudgetHistory" "$f"
  rg -n "getBudgetHistory" "$f" --context 60
done

# 5) getRoasRanking 쪽에서 어떤 validation을 하는지(비교 기준) 확인
for f in $(fd -a "DashboardServiceImpl.java" src/main/java); do
  echo "===== $f (getRoasRanking 주변) ====="
  rg -n "getRoasRanking" "$f"
  rg -n "getRoasRanking" "$f" --context 60
done

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 15389


⚠️ getBudgetHistory 기간 검증 누락 + BudgetHistory 연관 로딩에 따른 N+1 위험

  • DashboardServiceImpl#getBudgetHistory(라인 414-431)는 getRoasRanking처럼 미래 날짜/기간 역전(startDate > endDate) 검증이 없어 비정상 조회 범위가 그대로 들어갈 수 있습니다.
  • BudgetHistoryRepository#findByOrgAndPeriod(라인 13-25)는 LEFT JOIN만 있고 JOIN FETCH/EntityGraph가 아니라서, DashboardConverter.toBudgetHistoryItems()에서 bh.getAdCampaign().getName() / bh.getAdGroup().getName() 접근 시 FetchType.LAZY 초기화로 **히스토리 개수만큼 추가 쿼리(N+1)**가 발생할 수 있습니다(라인 56-61). → JOIN FETCH 또는 fetch graph 적용을 권장합니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java`
around lines 422 - 426, In DashboardServiceImpl#getBudgetHistory add the same
date validation used in getRoasRanking to reject or swap requests where
startDate is after endDate (preventing future/invalid ranges) and return/throw
accordingly; then update BudgetHistoryRepository#findByOrgAndPeriod to eagerly
fetch related entities (either convert the JPQL/Criteria to use JOIN FETCH for
adCampaign and adGroup or add an `@EntityGraph` including those associations) so
that DashboardConverter.toBudgetHistoryItems accessing
bh.getAdCampaign().getName() and bh.getAdGroup().getName() does not trigger N+1
lazy loads.
src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java (1)

48-55: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

BudgetHistoryItem 레코드가 DashboardResponse에도 동일하게 정의되어 있어요.

현재 TimelineResponse.BudgetHistoryItemDashboardResponse.BudgetHistoryItem이 완전히 동일한 구조로 중복 정의되어 있습니다. 두 도메인에서 같은 예산 변경 이력 정보를 사용하고 있으니, 공통 DTO로 추출하는 것을 권장해요.

예시:

// 공통 패키지로 추출
package com.whereyouad.WhereYouAd.domains.advertisement.application.dto.response;

public record BudgetHistoryDTO(
    BudgetFieldType fieldType,
    String targetName,
    Long previousValue,
    Long newValue,
    LocalDateTime changedAt,
    Provider provider
) {}

그 후 DashboardResponseTimelineResponse에서 이 공통 DTO를 재사용하면 유지보수가 훨씬 편해집니다. 필드 구조가 바뀔 때 한 곳만 수정하면 되거든요!

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java`
around lines 48 - 55, Extract the duplicated record into a shared DTO (e.g.,
create BudgetHistoryDTO record with fields BudgetFieldType fieldType, String
targetName, Long previousValue, Long newValue, LocalDateTime changedAt, Provider
provider) and replace TimelineResponse.BudgetHistoryItem and
DashboardResponse.BudgetHistoryItem usages with this shared BudgetHistoryDTO,
updating imports and any constructor/factory code that referenced the old nested
records so both TimelineResponse and DashboardResponse reuse the common DTO.

Source: Coding guidelines

src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java (1)

78-92: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

toBudgetHistoryItems 메서드가 DashboardConverter와 중복되고, NPE 위험이 있어요.

두 가지 개선점을 발견했습니다:

  1. 코드 중복: DashboardConverter.toBudgetHistoryItems (L56-69)와 완전히 동일한 로직이에요. 두 컨버터가 같은 변환 로직을 사용하고 있으니, 공통 유틸리티 메서드로 추출하는 것을 권장합니다.

  2. NPE 위험: 80-82번 줄에서 adCampaign이 null이면 adGroup.getName()을 호출하는데, 만약 둘 다 null이라면 NPE가 발생합니다. DB 제약조건으로 최소 하나는 항상 존재한다고 보장되나요? 그렇지 않다면 방어 코드를 추가해야 해요.

제안:

 public static List<TimelineResponse.BudgetHistoryItem> toBudgetHistoryItems(List<BudgetHistory> histories) {
     return histories.stream().map(bh -> {
-        String targetName = bh.getAdCampaign() != null
-                ? bh.getAdCampaign().getName()
-                : bh.getAdGroup().getName();
+        String targetName = bh.getAdCampaign() != null
+                ? bh.getAdCampaign().getName()
+                : (bh.getAdGroup() != null ? bh.getAdGroup().getName() : "Unknown");
         return new TimelineResponse.BudgetHistoryItem(

더 좋은 방법은 BudgetHistory 엔티티 레벨에서 getTargetName() 메서드를 추가하거나, 공통 컨버터를 만드는 거예요!

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java`
around lines 78 - 92, Refactor the duplicated budget-history mapping by
extracting the common logic used in TimelineConverter.toBudgetHistoryItems and
DashboardConverter.toBudgetHistoryItems into a shared utility (or add a helper
method on the BudgetHistory entity like getTargetName()); update both converters
to call that helper. Also harden against NPEs by ensuring the helper (or the
mapping) checks both bh.getAdCampaign() and bh.getAdGroup() and returns a safe
fallback (e.g., empty string or "unknown") if both are null before constructing
TimelineResponse.BudgetHistoryItem, leaving other fields unchanged.

Source: Coding guidelines

src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java (1)

260-266: ⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# BudgetHistoryRepository.findByOrgAndPeriod 메서드가 join fetch를 사용하는지 확인

rg -nP -A10 'findByOrgAndPeriod' --type=java -g '*BudgetHistoryRepository*'

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 839


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) BudgetHistoryRepository 메서드 선언/어노테이션 확인
repo_file="src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java"
if [ -f "$repo_file" ]; then
  echo "---- $repo_file ----"
  nl -ba "$repo_file" | sed -n '1,140p'
else
  echo "Missing: $repo_file" >&2
fi

# 2) BudgetHistory 엔티티의 관계 매핑(fetch type) 확인
# 파일명으로 찾기
echo "---- BudgetHistory entity candidates ----"
fd -a "BudgetHistory*.java" src/main/java | head -n 20

bh_file="$(fd -a "BudgetHistory.java" src/main/java | head -n 1 || true)"
if [ -n "${bh_file:-}" ] && [ -f "$bh_file" ]; then
  echo "---- $bh_file (mapping relevant part) ----"
  nl -ba "$bh_file" | sed -n '1,260p'
else
  echo "BudgetHistory.java not found (or multiple). Trying to locate mapping via rg..."
  rg -n "class BudgetHistory|adCampaign|adGroup" src/main/java -S -g '*BudgetHistory*.java'
fi

# 3) TimelineConverter.toBudgetHistoryItems 구현 확인
echo "---- TimelineConverter.toBudgetHistoryItems ----"
converter_files="$(fd -a "TimelineConverter*.java" src/main/java | head -n 20)"
echo "$converter_files"
for f in $converter_files; do
  if rg -q "toBudgetHistoryItems" "$f"; then
    echo "---- $f ----"
    nl -ba "$f" | rg -n "toBudgetHistoryItems" -n --context 20 || true
    nl -ba "$f" | sed -n '1,220p'
  fi
done

# 4) TimelineServiceImpl 호출부 주변(260-266 확인)
service_file="src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java"
if [ -f "$service_file" ]; then
  echo "---- $service_file (260-290) ----"
  nl -ba "$service_file" | sed -n '230,310p'
else
  echo "Missing: $service_file" >&2
fi

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 258


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 5) 메서드가 derived query인지, `@EntityGraph/`@Query가 없는지 빠르게 추가 확인
rg -n "findByOrgAndPeriod\s*\(" src/main/java -S
rg -n "`@Query`|`@EntityGraph`" src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java -S

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 624


🏁 Script executed:

#!/bin/bash
set -euo pipefail

print_with_numbers() {
  # usage: print_with_numbers file start end
  local f="$1" start="$2" end="$3"
  awk -v s="$start" -v e="$end" '{ if(NR>=s && NR<=e) printf "%6d\t%s\n", NR, $0 }' "$f"
}

repo_file="src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java"
if [ -f "$repo_file" ]; then
  echo "---- $repo_file (1-140) ----"
  print_with_numbers "$repo_file" 1 140
else
  echo "Missing: $repo_file" >&2
fi

# locate BudgetHistory entity
echo "---- Locate BudgetHistory entity file ----"
bh_files="$(fd -a "BudgetHistory*.java" src/main/java | head -n 20)"
echo "$bh_files"

bh_file="$(fd -a "BudgetHistory.java" src/main/java | head -n 5 | head -n 1 || true)"
if [ -n "${bh_file:-}" ] && [ -f "$bh_file" ]; then
  echo "---- $bh_file (1-260) ----"
  print_with_numbers "$bh_file" 1 260
  echo "---- $bh_file (mapping-focused search) ----"
  rg -n "adCampaign|adGroup|`@ManyToOne`|fetch\s*=\s*FetchType\.LAZY|FetchType" "$bh_file" || true
else
  echo "BudgetHistory.java not found; fallback search for entity mappings."
  rg -n "class\s+BudgetHistory|`@Entity`\s+.*BudgetHistory|BudgetHistory\s*$" src/main/java -S || true
fi

# locate TimelineConverter and inspect toBudgetHistoryItems
echo "---- Locate TimelineConverter files ----"
conv_files="$(fd -a "TimelineConverter*.java" src/main/java | head -n 50)"
echo "$conv_files"

for f in $conv_files; do
  if rg -q "toBudgetHistoryItems" "$f"; then
    echo "---- $f (toBudgetHistoryItems region) ----"
    # print around method body
    rg -n "toBudgetHistoryItems" "$f"
    # naive range print; adjust if needed
    print_with_numbers "$f" 1 260
    rg -n "getAdCampaign|getAdGroup|adCampaign|adGroup" "$f" || true
  fi
done

# show TimelineServiceImpl around 250-290
service_file="src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java"
if [ -f "$service_file" ]; then
  echo "---- $service_file (240-295) ----"
  print_with_numbers "$service_file" 240 295
fi

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 12695


예산 변경 이력 조회에서 N+1 가능성(major)

TimelineServiceImpl(260-266)에서 findByOrgAndPeriodBudgetHistory를 가져온 뒤, TimelineConverter.toBudgetHistoryItemsbh.getAdCampaign().getName() / bh.getAdGroup().getName()을 호출합니다. 그런데 BudgetHistoryadCampaignadGroup은 둘 다 FetchType.LAZY이고, BudgetHistoryRepository의 JPQL은 LEFT JOIN만 있고 JOIN FETCH가 없습니다. → 결과적으로 항목 수만큼 추가 SELECT가 터질 수 있어요.

해결: findByOrgAndPeriod 쪽을 LEFT JOIN FETCH bh.adCampaign / LEFT JOIN FETCH bh.adGroup(필요 시 LEFT JOIN FETCH ag.adCampaign)로 바꾸거나 @EntityGraph로 필요한 연관을 함께 로딩하세요. (현재 WHERE는 bh.organization.id가 아니라 ac.organization.id 또는 agc.organization.id 기준이라, 그 조건을 그대로 유지해야 합니다.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java`
around lines 260 - 266, The BudgetHistory fetch causes an N+1 because
TimelineServiceImpl calls budgetHistoryRepository.findByOrgAndPeriod and then
TimelineConverter.toBudgetHistoryItems accesses bh.getAdCampaign().getName() and
bh.getAdGroup().getName(); update BudgetHistoryRepository.findByOrgAndPeriod to
eagerly load associations (e.g., add LEFT JOIN FETCH bh.adCampaign and LEFT JOIN
FETCH bh.adGroup — and if adGroup references campaign add LEFT JOIN FETCH
ag.adCampaign) or annotate the repository method with an `@EntityGraph` that
includes adCampaign and adGroup, ensuring the original WHERE predicate that
filters by organization (currently tied to ac/agc) is preserved.

Source: Coding guidelines

@ojy0903 ojy0903 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3: 고생하셨어요! 제가 보기엔 로직은 괜찮아 보이고, 메타랑 구글에서 예산 수정 로직 추가할 때 이 BudgetHistory 엔티티 관련 처리를 추가로 해주는 것만 기억해두고 작업하면 좋을 듯 합니다...!!
그런데 제가 이해한거로는 BudgetHistory 가 AdCampaign 이나 AdGroup 중 하나를 부모로 참조하는 구조인거 같은데 맞을까요?? 맞다면 AdCampaign 이나 AdGroup 이 삭제될 때 이 BudgetHistory 엔티티가 남아있을 가능성이 있을 것 같아서 OnDelete 어노테이션으로 연관된 AdCampaign 이나 AdGroup 이 삭제되면 BudgetHistory 도 함께 삭제되도록 하면 좋을 것 같습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🗄️ DB 데이터베이스, 엔티티, 마이그레이션 관련 ✨ Feature 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: 예산 수정 - 예산 변경 이력 추적

2 participants