Skip to content

[SRLT-155] PDF AI 리포트 생성 및 완료 메일 PDF 첨부 발송#99

Open
2ghrms wants to merge 12 commits into
developfrom
SRLT-155-ai-리포트-email에-리포트-pdf-업로드

Hidden character warning

The head ref may contain hidden characters: "SRLT-155-ai-\ub9ac\ud3ec\ud2b8-email\uc5d0-\ub9ac\ud3ec\ud2b8-pdf-\uc5c5\ub85c\ub4dc"
Open

[SRLT-155] PDF AI 리포트 생성 및 완료 메일 PDF 첨부 발송#99
2ghrms wants to merge 12 commits into
developfrom
SRLT-155-ai-리포트-email에-리포트-pdf-업로드

Conversation

@2ghrms

@2ghrms 2ghrms commented May 20, 2026

Copy link
Copy Markdown
Member

🚀 Why - 해결하려는 문제가 무엇인가요?

  • PDF 사업계획서 AI 채점이 완료된 뒤, 사용자에게 웹 리포트 URL만 안내하고 있어 실제 리포트 내용을 바로 확인·공유하기 어려웠습니다.
  • 채점 완료 알림 메일에 AI 리포트 PDF를 첨부해, 사용자가 메일만으로 결과를 확인할 수 있도록 개선이 필요했습니다.
  • PDF 생성(OpenHTMLtoPDF 렌더링)은 CPU·I/O 비용이 크고 시간이 오래 걸리므로, DB 트랜잭션 안에서 실행되면 커넥션 점유·타임아웃 위험이 있었습니다.
  • 기존 이벤트 리스너에 orchestration 로직이 섞여 있어, expert 패턴과 맞지 않는 헥사고날 아키텍처 경계를 정리할 필요가 있었습니다.

✅ What - 무엇이 변경됐나요?

구현한 기능 요약

PDF 사업계획서 AI 채점 완료 시, 서버에서 HTML → PDF를 생성하고 완료 안내 메일에 PDF를 첨부해 발송합니다.

주요 변경사항

영역 변경 내용
PDF 렌더링 PdfRenderer + OpenHTMLtoPDF로 pdf-ai-report.html → PDF 변환
헥사고날 경계 AiReportPdfView / AiReportPdfViewMapper를 adapter 계층으로 이동, AiReportPdfRenderPort 포트 정의
이벤트 흐름 PdfReportRequestedInput → 채점 → AiReportReadyMailInput → 메일 발송
Service orchestration handlePdfReportRequested()에서 채점 → 메일 이벤트 발행 담당, 리스너는 얇게 유지
트랜잭션 분리 PDF 렌더링·메일 준비를 write TX 밖으로 분리, DB 조회만 readOnly TX
메일 발송 SmtpMailClient.sendPdfAiReportReadyMail() — HTML 본문 + PDF 첨부
회원 조회 MemberLookupPort 추가, MemberJpa가 구현
예외 처리 AiReportErrorType.EMAIL_SEND_ERROR 추가
폰트/배포 NotoSansKR-Regular.ttf classpath 번들, Dockerfile에 Alpine 한글 폰트 패키지 추가
의존성 openhtmltopdf-pdfbox, openhtmltopdf-svg-support 추가
테스트 PDF 파이프라인 통합 테스트·유닛 테스트 보강

🛠️ How - 어떻게 해결했나요?

전체 흐름

requestCreateAndGradePdfBusinessPlan
  └─ publishEvent(PdfReportRequestedInput)
       └─ AiReportPdfEvaluationEventListener  [async, AFTER_COMMIT]
            └─ AiReportService.handlePdfReportRequested()
                 ├─ completePdfGrading()          ← write TX (OCR → LLM → DB 저장)
                 └─ publishAiReportReadyMailEvent()
                      ├─ loadMailPrepareDataInReadOnlyTransaction()  ← readOnly TX
                      ├─ aiReportPdfRenderPort.render()              ← TX 밖
                      └─ publishEvent(AiReportReadyMailInput)
                           └─ AiReportReadyMailEventListener  [async, AFTER_COMMIT, @Retryable]
                                └─ SmtpMailClient.sendPdfAiReportReadyMail()

핵심 로직

1. Listener 얇게 — orchestration을 Service로 이동

AiReportPdfEvaluationEventListener는 Service 한 줄만 호출합니다.

aiReportService.handlePdfReportRequested(event.businessPlanId(), event.pdfUrl(), event.memberId());

채점 실패·메일 이벤트 발행 실패는 Service에서 각각 catch 후 로깅하고, 채점 실패 시 메일은 발송하지 않습니다.

2. PDF 생성 트랜잭션 분리

메서드 트랜잭션
handlePdfReportRequested NOT_SUPPORTED
completePdfGrading 별도 write TX (ObjectProvider self-invocation)
publishAiReportReadyMailEvent NOT_SUPPORTED
loadMailPrepareDataInReadOnlyTransaction TransactionTemplate(readOnly=true)
aiReportPdfRenderPort.render() TX 없음

3. PDF 렌더링 (PdfRenderer)

  • Thymeleaf로 pdf-ai-report.html 렌더 → OpenHTMLtoPDF 변환
  • BatikSVGDrawer로 레이더 차트 SVG 렌더
  • 한글 폰트: classpath NotoSansKR-Regular.ttf 우선, 없으면 OS 폰트 fallback
  • CFF OTF 폰트는 PDFBox 로드 실패 → 사전 검증 후 skip

4. PDF 레이아웃 (pdf-ai-report.html)

  • 1~2장 압축 레이아웃
  • 1페이지: 대시보드(총점 + 영역별 점수 + 레이더 차트)
  • 2페이지: 영역별 상세 2×2 그리드 + 하단 총평(강점/약점)

5. 메일 발송 (SmtpMailClient)

  • 제목: [STARLIGHT] "{파일명}"에 대한 AI 리포트가 도착했습니다
  • 본문: pdf-ai-report-ready Thymeleaf 템플릿
  • 첨부: 생성된 PDF (AiReportReadyMailInput.pdfBytes)
  • 실패 시 AiReportException(EMAIL_SEND_ERROR) throw

6. 헥사고날 경계

application/required/AiReportPdfRenderPort  ← 포트
adapter/pdf/PdfRenderer                     ← 어댑터 구현
adapter/pdf/view/AiReportPdfView            ← PDF 전용 view (adapter 내부)
adapter/pdf/mapper/AiReportPdfViewMapper    ← AiReportResult → View 변환

application 계층은 AiReportResult만 알고, PDF view/mapper는 adapter에 격리했습니다.

예외 사항 · 고민 포인트

  • Self-invocation: @Transactional이 동작하도록 ObjectProvider<AiReportService>로 프록시 호출
  • 폰트 호환성: CFF OTF는 OpenHTMLtoPDF/PDFBox에서 NPE 발생 → TrueType(.ttf)만 사용, isPdfBoxLoadableTrueType()으로 사전 검증
  • 메일 리스너 재시도: @Retryable(maxAttempts=3, backoff=2s×2) + @Recover로 최종 실패 로깅
  • Docker Alpine: JRE 이미지에 font-noto, font-noto-cjk 설치해 OS 폰트 fallback 보장
  • OCR/LLM은 여전히 write TX 안: 이번 PR 범위에서는 PDF 렌더·메일 준비만 TX 분리 (의도적 최소 변경)

@2ghrms 2ghrms self-assigned this May 20, 2026
@2ghrms 2ghrms added 🧵 REFACTOR 코드 리팩토링 ✨ FEAT 새로운 기능 구현 labels May 20, 2026
@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key(s) in object: 'tools'
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
📝 Walkthrough

전체 요약

이 PR은 사업계획서 AI 리포트를 PDF로 렌더링하고 완료 메일을 비동기로 전송하는 엔드-투-엔드 기능을 구현합니다. 인프라 설정부터 시작하여 OpenHTMLtoPDF 기반 PDF 렌더링, Thymeleaf 템플릿 구성, 트랜잭션 기반 이벤트 아키텍처, 그리고 메일 전송 재시도 로직까지 통합적으로 개발되었습니다.


변경사항 분석

AI Report PDF 생성 및 메일 전송 파이프라인

Layer / File(s) 설명
인프라 및 런타임 환경 설정
deploy/Dockerfile, gradle/spring.gradle, src/main/java/starlight/bootstrap/MailConfig.java
Docker 런타임에 PDF 렌더링용 한글/CJK 글꼴 및 ttf-dejavu를 설치하고, OpenHTMLtoPDF 라이브러리 의존성을 추가합니다. MailConfig는 StringUtils를 사용해 SMTP 인증 플래그를 동적으로 계산하고, 포트별로 SSL/STARTTLS를 분기하도록 수정되었습니다.
PDF 렌더링 계약 및 데이터 모델
src/main/java/starlight/application/aireport/required/AiReportPdfRenderPort.java, src/main/java/starlight/application/aireport/required/MemberLookupPort.java, src/main/java/starlight/adapter/shared/infrastructure/pdf/view/AiReportPdfView.java
AiReportPdfRenderPort 인터페이스와 MemberLookupPort 조회 포트를 정의하고, 총점/섹션/강점·약점/레이더 차트 SVG를 담는 AiReportPdfView 레코드 계층구조를 추가합니다.
PDF 렌더링 구현 및 폰트 처리
src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfRenderer.java, src/main/java/starlight/adapter/shared/infrastructure/pdf/mapper/AiReportPdfViewMapper.java
PdfRenderer는 Thymeleaf 템플릿을 HTML로 변환 후 OpenHTMLtoPDF로 PDF 바이트 배열을 생성합니다. 클래스패스와 OS 글꼴 경로를 순차 시도하며 TrueType 로드 가능성을 PDFBox로 검증하고 임시 파일을 안전하게 관리합니다. AiReportPdfViewMapperAiReportResult를 뷰 모델로 변환하며 JSON 파싱, 점수 매핑, 레이더 차트 SVG 생성을 담당합니다.
PDF 렌더링 HTML 템플릿
src/main/resources/templates/pdf-ai-report.html
Thymeleaf 템플릿으로 총점 대시보드, 섹션별 점수 카드, 레이더 차트 SVG, 영역별 상세 평가(최대 4개 섹션), 강점·약점 목록을 PDF 레이아웃으로 구성합니다. 체크리스트 비어있음/존재 여부에 따라 조건부 렌더링을 수행합니다.
이벤트 입력 타입 및 AiReportService 확장
src/main/java/starlight/application/aireport/event/PdfReportRequestedInput.java, src/main/java/starlight/application/aireport/event/AiReportReadyMailInput.java, src/main/java/starlight/application/aireport/AiReportService.java
PdfReportRequestedInputAiReportReadyMailInput 이벤트 입력 타입을 도입합니다. AiReportServicehandlePdfReportRequested(PDF 채점 완료 처리)와 publishAiReportReadyMailEvent(메일 발송 준비) 메서드를 추가하며, 후자는 트랜잭션 밖에서 PDF를 렌더링하고 읽기 전용 템플릿으로 리포트/멤버/플랜 데이터를 로드합니다.
메일 이벤트 리스너 및 재시도 로직
src/main/java/starlight/application/aireport/event/AiReportReadyMailEventListener.java, src/main/java/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.java, src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java
AiReportReadyMailEventListener는 트랜잭션 커밋 후 메일 이벤트를 비동기로 처리하며, @Retryable로 최대 3회 재시도(지연 2000ms, 지수 백오프)를 설정합니다. SmtpMailClient는 예외 발생 시 AiReportException(EMAIL_SEND_ERROR)로 재전파합니다. AiReportErrorTypeEMAIL_SEND_ERROR 상수를 추가합니다.
어댑터 확장 및 리스너 단순화
src/main/java/starlight/adapter/member/persistence/MemberJpa.java, src/main/java/starlight/application/aireport/event/AiReportPdfEvaluationEventListener.java
MemberJpaaireport.required.MemberLookupPort를 추가로 구현합니다. AiReportPdfEvaluationEventListener는 복잡한 조회/다운로드/메일 로직을 제거하고 AiReportService.handlePdfReportRequested 위임 호출로 단순화합니다.
통합 및 단위 테스트 리팩토링
src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java, src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java
AiReportServiceIntegrationTest는 이벤트 리스너를 AiReportReadyMailEventListener로 교체하고, @RecordApplicationEvents와 동기 Executor를 추가합니다. TransactionTemplate(REQUIRES_NEW)로 커밋 경계를 명시하고, dispatchRecordedAiReportReadyMailEvents 메서드로 기록된 이벤트를 수동 디스패치합니다. AiReportServiceUnitTest는 공통 createSut() 팩토리 메서드 기반으로 리팩토링합니다.

🎯 3 (Moderate) | ⏱️ ~25 분


연관 PR

  • StartUpLight/STARLIGHT_BE#94: AI 리포트 비동기 메일/PDF 파이프라인 변경 사항(AiReportService 이벤트 처리 메서드, PdfReportRequestedInput 전환, 이벤트 입력/포트 타입 정의, SmtpMailClient 예외 처리)이 동일 문제 영역을 해결합니다.

제안 레이블

✨ FEAT, ☁️ DEPLOY, 🧵 REFACTOR

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.64% 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
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The PR title clearly describes the main change: implementing PDF AI report generation and sending completion emails with PDF attachments, which aligns with the majority of changes across renderer, mapper, service, and event listener files.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch SRLT-155-ai-리포트-email에-리포트-pdf-업로드

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.

@2ghrms 2ghrms changed the title [SRLT-155] ai 리포트 email에 리포트 pdf 업로드 [SRLT-155] PDF AI 리포트 생성 및 완료 메일 PDF 첨부 발송 May 20, 2026

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
gradle/spring.gradle (1)

15-25: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

spring-boot-starter-mail 의존성 중복 제거

Line 15와 Line 24에서 동일한 메일 라이브러리가 중복 선언되고 있습니다. 한 쪽만 남겨 주세요. Gradle 의존성 관리의 복잡도를 줄이고 향후 버전 업데이트 시 혼선을 방지하기 위해 필요한 정리입니다. // Emailclient 주석 아래 Line 24의 선언 의도를 고려하여 Line 15의 중복을 제거하거나, 필요시 첫 번째 블록에 통합해 주시면 좋겠습니다.

🤖 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 `@gradle/spring.gradle` around lines 15 - 25, Remove the duplicate dependency
declaration for 'org.springframework.boot:spring-boot-starter-mail' so it
appears only once; keep the single declaration under the "// Emailclient" block
(or conversely remove the second one if you prefer the first block) and delete
the other occurrence to avoid duplicate Gradle entries.
🧹 Nitpick comments (2)
src/main/java/starlight/application/aireport/event/AiReportReadyMailEventListener.java (1)

28-37: 💤 Low value

재시도마다 중복 로깅 발생

try-catch 블록에서 예외 발생 시 로그를 남기고 다시 throw하는 구조로, 재시도될 때마다 동일한 에러 로그가 출력됩니다. 최종 실패 시에만 로깅하려면 @Recover 메서드에서만 로깅하거나, 여기서는 DEBUG 레벨로 변경하는 것을 고려해 주세요.

🤖 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/starlight/application/aireport/event/AiReportReadyMailEventListener.java`
around lines 28 - 37, The handleAiReportReadyMailEvent method in
AiReportReadyMailEventListener currently logs an error and rethrows, causing
duplicate logs on each retry; change the catch block so it does not log an ERROR
on each retry—either downgrade the log call to log.debug(...) (including the
same context args) and rethrow, or remove the log here and add the error logging
into your `@Recover` method so only the final failure is logged; ensure you update
the catch in handleAiReportReadyMailEvent and the `@Recover` method consistently
to avoid losing context (use event.toEmail() and event.filename() in the final
log).
src/main/resources/templates/pdf-ai-report.html (1)

302-321: ⚡ Quick win

섹션 점수 카드 렌더링 로직을 인덱스 기반 접근으로 리팩토링하세요.

현재 th:each + th:if="${stat.index % 2 == 0/1}" 방식은 다음 문제가 있습니다:

  • 레이아웃 안정성: 섹션 개수가 홀수이거나 5개 이상일 경우, 한 행에 3개 이상의 <td>가 생성되어 각각 width: 50%를 적용받으면 총 너비가 100%를 초과하여 레이아웃이 깨질 수 있습니다.
  • 가독성 저하: 짝수/홀수 필터링 로직을 이해해야 하므로 코드 파악이 어렵습니다.
  • 일관성 부족: 동일 파일의 "영역별 상세 평가" 부분(lines 330-423)에서는 직접 인덱스 접근(sections[0], sections[1]...)을 사용하여 스타일이 혼재됩니다.
  • 유지보수성: 섹션 개수 변경 시 잠재적 버그 위험이 있습니다.

lines 330-423의 방식처럼 sections.size() 조건과 직접 인덱스 접근을 사용하면 각 행에 정확히 2개의 셀만 생성되어 안정적이고 명확합니다.

♻️ 제안하는 리팩토링
               <table class="cat-table">
                 <tr>
-                  <td class="cat-card" th:each="section, stat : ${sections}" th:if="${stat.index % 2 == 0}">
-                    <div class="cat-card__name" th:text="${section.title}">문제 정의</div>
+                  <td class="cat-card" th:if="${sections.size() > 0}" th:with="section=${sections[0]}">
+                    <div class="cat-card__name" th:text="${section.title}">섹션 1</div>
+                    <div class="cat-card__score">
+                      <span th:text="${section.score}">0</span>점
+                      <span class="cat-card__score-sub" th:text="|/ ${section.total}점|">/ 0점</span>
+                    </div>
+                  </td>
+                  <td class="cat-card" th:if="${sections.size() > 1}" th:with="section=${sections[1]}">
+                    <div class="cat-card__name" th:text="${section.title}">섹션 2</div>
                     <div class="cat-card__score">
                       <span th:text="${section.score}">0</span>점
                       <span class="cat-card__score-sub" th:text="|/ ${section.total}점|">/ 0점</span>
                     </div>
                   </td>
                 </tr>
                 <tr>
-                  <td class="cat-card" th:each="section, stat : ${sections}" th:if="${stat.index % 2 == 1}">
-                    <div class="cat-card__name" th:text="${section.title}">실현 가능성</div>
+                  <td class="cat-card" th:if="${sections.size() > 2}" th:with="section=${sections[2]}">
+                    <div class="cat-card__name" th:text="${section.title}">섹션 3</div>
+                    <div class="cat-card__score">
+                      <span th:text="${section.score}">0</span>점
+                      <span class="cat-card__score-sub" th:text="|/ ${section.total}점|">/ 0점</span>
+                    </div>
+                  </td>
+                  <td class="cat-card" th:if="${sections.size() > 3}" th:with="section=${sections[3]}">
+                    <div class="cat-card__name" th:text="${section.title}">섹션 4</div>
                     <div class="cat-card__score">
                       <span th:text="${section.score}">0</span>점
                       <span class="cat-card__score-sub" th:text="|/ ${section.total}점|">/ 0점</span>
                     </div>
                   </td>
                 </tr>
               </table>
🤖 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/resources/templates/pdf-ai-report.html` around lines 302 - 321, The
table currently uses th:each with th:if="${stat.index % 2 == 0/1}" which can
produce uneven <td> counts and break layout; replace this with explicit
index-based rendering like the "영역별 상세 평가" pattern: check sections.size() and
render each row with exactly two cells using direct index access (e.g.,
sections[0], sections[1], sections[2], sections[3] etc.), guarding each access
with a sections.size() check so you only render a cell when that index exists;
update the two table rows that currently use th:each (the cat-table block
containing the th:each/ th:if pairs) to use these explicit sections[index]
conditions to guarantee precisely two cells per row and stable layout.
🤖 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/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.java`:
- Around line 115-121: The catch blocks in SmtpMailClient currently throw
AiReportException without passing the original exception, losing the cause;
update both catch handlers (the MessagingException and generic Exception
catches) to include the original exception as the cause when constructing the
AiReportException (e.g., throw new
AiReportException(AiReportErrorType.EMAIL_SEND_ERROR, e) or use the
AiReportException constructor that accepts a Throwable), so the original stack
trace is preserved for debugging.

In `@src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfRenderer.java`:
- Around line 96-103: The temp font files are being created on every render and
only rely on deleteOnExit(), causing disk leak; change PdfRenderer to cache and
reuse a single prepared temp File per classpath font resource instead of calling
copyFontToTemp on every render (make a static ConcurrentHashMap<String,File> or
similar keyed by classpathLocation and populate it once), still validate with
isPdfBoxLoadableTrueType before inserting into the cache, and call
builder.useFont with the cached File; apply the same caching fix to the other
occurrence around the builder.useFont call at the 144-153 region so fonts are
prepared once and not recreated per request.

In `@src/main/java/starlight/bootstrap/MailConfig.java`:
- Line 21: sender.setPort(mailProperties.getPort()) can NPE when
mailProperties.getPort() is null; change it to use the same defaulting logic as
buildJavaMailProps (use port 587 when null) before calling
JavaMailSenderImpl#setPort so an int is always passed. Locate the sender.setPort
call and mailProperties.getPort usage and replace with a null-safe conversion
(e.g., determine int port = (mailProperties.getPort() != null ?
mailProperties.getPort() : 587)) or equivalent, keeping buildJavaMailProps
behavior consistent.

---

Outside diff comments:
In `@gradle/spring.gradle`:
- Around line 15-25: Remove the duplicate dependency declaration for
'org.springframework.boot:spring-boot-starter-mail' so it appears only once;
keep the single declaration under the "// Emailclient" block (or conversely
remove the second one if you prefer the first block) and delete the other
occurrence to avoid duplicate Gradle entries.

---

Nitpick comments:
In
`@src/main/java/starlight/application/aireport/event/AiReportReadyMailEventListener.java`:
- Around line 28-37: The handleAiReportReadyMailEvent method in
AiReportReadyMailEventListener currently logs an error and rethrows, causing
duplicate logs on each retry; change the catch block so it does not log an ERROR
on each retry—either downgrade the log call to log.debug(...) (including the
same context args) and rethrow, or remove the log here and add the error logging
into your `@Recover` method so only the final failure is logged; ensure you update
the catch in handleAiReportReadyMailEvent and the `@Recover` method consistently
to avoid losing context (use event.toEmail() and event.filename() in the final
log).

In `@src/main/resources/templates/pdf-ai-report.html`:
- Around line 302-321: The table currently uses th:each with th:if="${stat.index
% 2 == 0/1}" which can produce uneven <td> counts and break layout; replace this
with explicit index-based rendering like the "영역별 상세 평가" pattern: check
sections.size() and render each row with exactly two cells using direct index
access (e.g., sections[0], sections[1], sections[2], sections[3] etc.), guarding
each access with a sections.size() check so you only render a cell when that
index exists; update the two table rows that currently use th:each (the
cat-table block containing the th:each/ th:if pairs) to use these explicit
sections[index] conditions to guarantee precisely two cells per row and stable
layout.
🪄 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: 247c6807-5246-4521-b8bb-dc352283f4a5

📥 Commits

Reviewing files that changed from the base of the PR and between 2652b17 and 2da4fdf.

⛔ Files ignored due to path filters (1)
  • src/main/resources/fonts/NotoSansKR-Regular.ttf is excluded by !**/*.ttf
📒 Files selected for processing (20)
  • deploy/Dockerfile
  • gradle/spring.gradle
  • src/main/java/starlight/adapter/member/persistence/MemberJpa.java
  • src/main/java/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.java
  • src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfRenderer.java
  • src/main/java/starlight/adapter/shared/infrastructure/pdf/mapper/AiReportPdfViewMapper.java
  • src/main/java/starlight/adapter/shared/infrastructure/pdf/view/AiReportPdfView.java
  • src/main/java/starlight/application/aireport/AiReportService.java
  • src/main/java/starlight/application/aireport/event/AiReportPdfEvaluationEventListener.java
  • src/main/java/starlight/application/aireport/event/AiReportReadyMailEventListener.java
  • src/main/java/starlight/application/aireport/event/AiReportReadyMailInput.java
  • src/main/java/starlight/application/aireport/event/PdfReportRequestedInput.java
  • src/main/java/starlight/application/aireport/required/AiReportMailPort.java
  • src/main/java/starlight/application/aireport/required/AiReportPdfRenderPort.java
  • src/main/java/starlight/application/aireport/required/MemberLookupPort.java
  • src/main/java/starlight/bootstrap/MailConfig.java
  • src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java
  • src/main/resources/templates/pdf-ai-report.html
  • src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java
  • src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java

Comment on lines 115 to 121
} catch (MessagingException e) {
log.error("[MAIL] AI 리포트 완료 메일 발송 실패 to={}", input.toEmail(), e);
throw new AiReportException(AiReportErrorType.EMAIL_SEND_ERROR);
} catch (Exception e) {
log.error("[MAIL] AI 리포트 완료 메일 처리 실패 to={}", input.toEmail(), e);
throw new AiReportException(AiReportErrorType.EMAIL_SEND_ERROR);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# AiReportException 생성자 확인
ast-grep --pattern $'class AiReportException {
  $$$
}'

Repository: StartUpLight/STARLIGHT_BE

Length of output: 51


🏁 Script executed:

fd -type f -name "*AiReportException*"

Repository: StartUpLight/STARLIGHT_BE

Length of output: 240


🏁 Script executed:

fd -t f -name "*AiReportException*"

Repository: StartUpLight/STARLIGHT_BE

Length of output: 300


🏁 Script executed:

fd "AiReportException"

Repository: StartUpLight/STARLIGHT_BE

Length of output: 142


🏁 Script executed:

cat -n src/main/java/starlight/domain/aireport/exception/AiReportException.java

Repository: StartUpLight/STARLIGHT_BE

Length of output: 592


🏁 Script executed:

sed -n '110,125p' src/main/java/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.java

Repository: StartUpLight/STARLIGHT_BE

Length of output: 768


원본 예외(cause)가 유실됨

AiReportException 생성 시 원본 예외 e를 cause로 전달하지 않아 스택 트레이스가 유실됩니다. 디버깅 시 근본 원인 파악이 어려워질 수 있습니다.

🐛 예외 cause 전달 수정
         } catch (MessagingException e) {
             log.error("[MAIL] AI 리포트 완료 메일 발송 실패 to={}", input.toEmail(), e);
-            throw new AiReportException(AiReportErrorType.EMAIL_SEND_ERROR);
+            throw new AiReportException(AiReportErrorType.EMAIL_SEND_ERROR, e);
         } catch (Exception e) {
             log.error("[MAIL] AI 리포트 완료 메일 처리 실패 to={}", input.toEmail(), e);
-            throw new AiReportException(AiReportErrorType.EMAIL_SEND_ERROR);
+            throw new AiReportException(AiReportErrorType.EMAIL_SEND_ERROR, e);
         }
🧰 Tools
🪛 PMD (7.24.0)

[Low] 116-116: InvalidLogMessageFormat (Error Prone): Too many arguments, expected 1 argument but found 2

(InvalidLogMessageFormat (Error Prone))

🤖 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/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.java`
around lines 115 - 121, The catch blocks in SmtpMailClient currently throw
AiReportException without passing the original exception, losing the cause;
update both catch handlers (the MessagingException and generic Exception
catches) to include the original exception as the cause when constructing the
AiReportException (e.g., throw new
AiReportException(AiReportErrorType.EMAIL_SEND_ERROR, e) or use the
AiReportException constructor that accepts a Throwable), so the original stack
trace is preserved for debugging.

Comment on lines +96 to +103
File fontFile = copyFontToTemp(resource);
if (!isPdfBoxLoadableTrueType(fontFile.toPath())) {
log.warn("[AI_REPORT_PDF] skipping unsupported classpath font: {}", classpathLocation);
return false;
}
builder.useFont(fontFile, FONT_FAMILY, 400, BaseRendererBuilder.FontStyle.NORMAL, true);
return true;
} catch (Exception e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

요청마다 임시 폰트 파일이 누적되어 디스크 누수 위험이 있습니다.

현재 클래스패스 폰트를 렌더링마다 temp로 복사하고 deleteOnExit()에 의존해, 장시간 프로세스에서 파일이 계속 쌓일 수 있습니다. 폰트 파일은 1회만 준비해 재사용하도록 바꾸는 게 안전합니다.

개선 예시
+    private volatile File cachedClasspathFontFile;
...
     private boolean registerClasspathFont(PdfRendererBuilder builder, String classpathLocation) {
         try {
             ClassPathResource resource = new ClassPathResource(classpathLocation);
             if (!resource.exists()) {
                 return false;
             }
-            File fontFile = copyFontToTemp(resource);
+            File fontFile = getOrCreateCachedFontFile(resource);
             if (!isPdfBoxLoadableTrueType(fontFile.toPath())) {
                 log.warn("[AI_REPORT_PDF] skipping unsupported classpath font: {}", classpathLocation);
                 return false;
             }
             builder.useFont(fontFile, FONT_FAMILY, 400, BaseRendererBuilder.FontStyle.NORMAL, true);
             return true;
...
     }
+
+    private File getOrCreateCachedFontFile(ClassPathResource resource) throws Exception {
+        if (cachedClasspathFontFile != null && cachedClasspathFontFile.exists()) {
+            return cachedClasspathFontFile;
+        }
+        synchronized (this) {
+            if (cachedClasspathFontFile == null || !cachedClasspathFontFile.exists()) {
+                cachedClasspathFontFile = copyFontToTemp(resource);
+            }
+            return cachedClasspathFontFile;
+        }
+    }

Also applies to: 144-153

🤖 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/starlight/adapter/shared/infrastructure/pdf/PdfRenderer.java`
around lines 96 - 103, The temp font files are being created on every render and
only rely on deleteOnExit(), causing disk leak; change PdfRenderer to cache and
reuse a single prepared temp File per classpath font resource instead of calling
copyFontToTemp on every render (make a static ConcurrentHashMap<String,File> or
similar keyed by classpathLocation and populate it once), still validate with
isPdfBoxLoadableTrueType before inserting into the cache, and call
builder.useFont with the cached File; apply the same caching fix to the other
occurrence around the builder.useFont call at the 144-153 region so fonts are
prepared once and not recreated per request.

@@ -20,18 +21,22 @@ public JavaMailSender javaMailSender(MailProperties mailProperties) {
sender.setPort(mailProperties.getPort());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Spring Boot, does JavaMailSenderImpl#setPort use primitive int, and can passing a nullable Integer cause NullPointerException via auto-unboxing?

💡 Result:

Yes, the setPort method in JavaMailSenderImpl uses the primitive int type [1][2]. Specifically, the method signature is defined as public void setPort(int port) [1][2]. Because the method expects a primitive int, passing a nullable Integer object to it will trigger Java's automatic unboxing mechanism [3][4]. If that Integer object is null, the JVM will attempt to invoke the intValue method on a null reference during the unboxing process, which results in a NullPointerException [3][4]. Therefore, you must ensure that any Integer value passed to setPort is non-null to avoid this runtime exception [3].

Citations:


🏁 Script executed:

# MailConfig.java 파일 찾기 및 읽기
find . -name "MailConfig.java" -type f

Repository: StartUpLight/STARLIGHT_BE

Length of output: 121


🏁 Script executed:

# mailProperties 관련 클래스 찾기
find . -name "*MailProperties*" -o -name "*mail*properties*" | grep -i "\.java$"

Repository: StartUpLight/STARLIGHT_BE

Length of output: 51


🏁 Script executed:

cat -n ./src/main/java/starlight/bootstrap/MailConfig.java

Repository: StartUpLight/STARLIGHT_BE

Length of output: 2441


Line 21에서 null 포트 값으로 인한 런타임 NullPointerException 위험이 있습니다.

sender.setPort(mailProperties.getPort())JavaMailSenderImpl#setPort(int)의 원시형 int 파라미터로 nullable Integer를 직접 전달합니다. 포트 값이 null이면 자동언박싱 과정에서 NPE가 발생합니다.

buildJavaMailProps 메서드(Line 36)에서는 포트의 null 값을 기본값 587로 처리하고 있으니, sender.setPort()에도 동일하게 적용하세요.

수정 예시
-        sender.setPort(mailProperties.getPort());
+        int port = mailProperties.getPort() != null ? mailProperties.getPort() : 587;
+        sender.setPort(port);
📝 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
sender.setPort(mailProperties.getPort());
int port = mailProperties.getPort() != null ? mailProperties.getPort() : 587;
sender.setPort(port);
🤖 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/starlight/bootstrap/MailConfig.java` at line 21,
sender.setPort(mailProperties.getPort()) can NPE when mailProperties.getPort()
is null; change it to use the same defaulting logic as buildJavaMailProps (use
port 587 when null) before calling JavaMailSenderImpl#setPort so an int is
always passed. Locate the sender.setPort call and mailProperties.getPort usage
and replace with a null-safe conversion (e.g., determine int port =
(mailProperties.getPort() != null ? mailProperties.getPort() : 587)) or
equivalent, keeping buildJavaMailProps behavior consistent.

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

Labels

✨ FEAT 새로운 기능 구현 🧵 REFACTOR 코드 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant