[SRLT-155] PDF AI 리포트 생성 및 완료 메일 PDF 첨부 발송#99
Hidden character warning
Conversation
|
Warning Review limit reached
More reviews will be available in 22 minutes and 22 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
Note
|
| Layer / File(s) | 설명 |
|---|---|
인프라 및 런타임 환경 설정 deploy/Dockerfile, gradle/spring.gradle, src/main/java/starlight/bootstrap/MailConfig.java |
Docker 런타임에 PDF 렌더링용 한글/CJK 글꼴(noto, ttf-dejavu)을 설치하고, OpenHTMLtoPDF 라이브러리(openhtmltopdf-pdfbox, openhtmltopdf-svg-support) 의존성을 추가합니다. MailConfig에서 StringUtils로 SMTP 인증 플래그를 동적 계산하고, 포트별로 SSL(465) 또는 STARTTLS(587) 설정을 분기합니다. |
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 인터페이스(render 메서드)와 MemberLookupPort 조회 포트를 정의합니다. AiReportPdfView 레코드는 총점, 섹션(제목/점수/체크리스트), 강점/약점, 레이더 차트 SVG를 포함하는 중첩 레코드 구조를 제공합니다. |
PDF 렌더링 구현 및 폰트 처리 src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfRenderer.java, src/main/java/starlight/adapter/shared/infrastructure/pdf/mapper/AiReportPdfViewMapper.java |
PdfRenderer는 AiReportResult를 뷰로 매핑 후 Thymeleaf 템플릿을 HTML로 변환하고 OpenHTMLtoPDF로 PDF 바이트 배열을 생성합니다. 클래스패스/OS 글꼴을 순차 시도하며, ConcurrentHashMap 캐시와 synchronized 구간으로 동시성을 제어하고, 임시 파일 삭제를 안전하게 관리합니다. AiReportPdfViewMapper는 섹션 점수/체크리스트 매핑, 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 |
PdfReportRequestedInput(이벤트 타입 이름 변경), AiReportReadyMailInput(패키지 이동: event), 두 퍼블릭 메서드 추가: handlePdfReportRequested(PDF 채점 완료 처리, self-invocation completePdfGrading), publishAiReportReadyMailEvent(NOT_SUPPORTED 전파로 트랜잭션 밖 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 추가: AFTER_COMMIT 후 AiReportReadyMailInput 비동기 처리, @Retryable(3회, 2000ms, 지수 2), @Recover 최종 실패 로깅. SmtpMailClient는 @RequiredArgsConstructor 적용, 명시 생성자 제거, sendPdfAiReportReadyMail에서 예외를 AiReportException(EMAIL_SEND_ERROR)로 재전파. AiReportErrorType에 EMAIL_SEND_ERROR(500) 상수 추가. |
어댑터 확장 및 리스너 단순화 src/main/java/starlight/adapter/member/persistence/MemberJpa.java, src/main/java/starlight/application/aireport/event/AiReportPdfEvaluationEventListener.java |
MemberJpa가 aireport.required.MemberLookupPort 추가 구현. AiReportPdfEvaluationEventListener는 기존 30줄 이상의 조회/다운로드/메일 로직을 제거하고 aiReportService.handlePdfReportRequested(...) 단일 위임 호출로 단순화. |
통합 및 단위 테스트 리팩토링 src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java, src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java |
AiReportServiceIntegrationTest: 이벤트 리스너→AiReportReadyMailEventListener, @RecordApplicationEvents 추가, 동기 Executor(emailTaskExecutor, aiReportPdfExecutor), TransactionTemplate(REQUIRES_NEW) 커밋 경계 명시, dispatchRecordedAiReportReadyMailEvents 메서드로 이벤트 수동 디스패치. AiReportServiceUnitTest: 공통 createSut() 팩토리 메서드, 추가 의존성(AiReportPdfRenderPort, MemberLookupPort, PlatformTransactionManager) 모킹. |
🎯 3 (Moderate) | ⏱️ ~25 분
연관 PR
- StartUpLight/STARLIGHT_BE#94: 동일 AI 리포트 비동기 메일/PDF 렌더링 파이프라인 변경(
AiReportService이벤트 메서드,PdfReportRequestedInput이벤트, 이벤트 입력/포트 타입,SmtpMailClient예외 처리)이 겹침. - StartUpLight/STARLIGHT_BE#50:
MemberJpa의 포트 인터페이스 구현 조정(MemberLookupPort추가/변경) 관련. - StartUpLight/STARLIGHT_BE#27:
@Async,@Retryable, 비동기 메일 전송 재시도 인프라 관련.
제안 레이블
✨ FEAT, ☁️ DEPLOY
🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Docstring Coverage | Docstring coverage is 10.20% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. |
✅ Passed checks (4 passed)
| Check name | Status | Explanation |
|---|---|---|
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
| Title check | ✅ Passed | PR 제목은 'PDF AI 리포트 생성 및 완료 메일 PDF 첨부 발송'으로, 전체 변경사항의 핵심을 정확하고 간결하게 요약하고 있습니다. |
| 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. |
✏️ 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
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.
Comment @coderabbitai help to get the list of available commands and usage tips.
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
src/main/resources/fonts/NotoSansKR-Regular.ttfis excluded by!**/*.ttf
📒 Files selected for processing (20)
deploy/Dockerfilegradle/spring.gradlesrc/main/java/starlight/adapter/member/persistence/MemberJpa.javasrc/main/java/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.javasrc/main/java/starlight/adapter/shared/infrastructure/pdf/PdfRenderer.javasrc/main/java/starlight/adapter/shared/infrastructure/pdf/mapper/AiReportPdfViewMapper.javasrc/main/java/starlight/adapter/shared/infrastructure/pdf/view/AiReportPdfView.javasrc/main/java/starlight/application/aireport/AiReportService.javasrc/main/java/starlight/application/aireport/event/AiReportPdfEvaluationEventListener.javasrc/main/java/starlight/application/aireport/event/AiReportReadyMailEventListener.javasrc/main/java/starlight/application/aireport/event/AiReportReadyMailInput.javasrc/main/java/starlight/application/aireport/event/PdfReportRequestedInput.javasrc/main/java/starlight/application/aireport/required/AiReportMailPort.javasrc/main/java/starlight/application/aireport/required/AiReportPdfRenderPort.javasrc/main/java/starlight/application/aireport/required/MemberLookupPort.javasrc/main/java/starlight/bootstrap/MailConfig.javasrc/main/java/starlight/domain/aireport/exception/AiReportErrorType.javasrc/main/resources/templates/pdf-ai-report.htmlsrc/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.javasrc/test/java/starlight/application/aireport/AiReportServiceUnitTest.java
- AiReportException 에러 트레이스 반영
- 폰트파일 재사용 로직 추가
- JavaMailSenderImpl의 setPort 메소드에 null 값 방지
- synchronized 로직 삭제 - 실패 시 캐시 미저장 - @PostConstruct로 웜업
…om/StartUpLight/STARLIGHT_BE into SRLT-155-ai-리포트-email에-리포트-pdf-업로드
🚀 Why - 해결하려는 문제가 무엇인가요?
✅ What - 무엇이 변경됐나요?
구현한 기능 요약
PDF 사업계획서 AI 채점 완료 시, 서버에서 HTML → PDF를 생성하고 완료 안내 메일에 PDF를 첨부해 발송합니다.
주요 변경사항
PdfRenderer+ OpenHTMLtoPDF로pdf-ai-report.html→ PDF 변환AiReportPdfView/AiReportPdfViewMapper를 adapter 계층으로 이동,AiReportPdfRenderPort포트 정의PdfReportRequestedInput→ 채점 →AiReportReadyMailInput→ 메일 발송handlePdfReportRequested()에서 채점 → 메일 이벤트 발행 담당, 리스너는 얇게 유지SmtpMailClient.sendPdfAiReportReadyMail()— HTML 본문 + PDF 첨부MemberLookupPort추가,MemberJpa가 구현AiReportErrorType.EMAIL_SEND_ERROR추가NotoSansKR-Regular.ttfclasspath 번들, Dockerfile에 Alpine 한글 폰트 패키지 추가openhtmltopdf-pdfbox,openhtmltopdf-svg-support추가🛠️ How - 어떻게 해결했나요?
전체 흐름
핵심 로직
1. Listener 얇게 — orchestration을 Service로 이동
AiReportPdfEvaluationEventListener는 Service 한 줄만 호출합니다.채점 실패·메일 이벤트 발행 실패는 Service에서 각각 catch 후 로깅하고, 채점 실패 시 메일은 발송하지 않습니다.
2. PDF 생성 트랜잭션 분리
handlePdfReportRequestedNOT_SUPPORTEDcompletePdfGradingObjectProviderself-invocation)publishAiReportReadyMailEventNOT_SUPPORTEDloadMailPrepareDataInReadOnlyTransactionTransactionTemplate(readOnly=true)aiReportPdfRenderPort.render()3. PDF 렌더링 (
PdfRenderer)pdf-ai-report.html렌더 → OpenHTMLtoPDF 변환BatikSVGDrawer로 레이더 차트 SVG 렌더NotoSansKR-Regular.ttf우선, 없으면 OS 폰트 fallback4. PDF 레이아웃 (
pdf-ai-report.html)5. 메일 발송 (
SmtpMailClient)[STARLIGHT] "{파일명}"에 대한 AI 리포트가 도착했습니다pdf-ai-report-readyThymeleaf 템플릿AiReportReadyMailInput.pdfBytes)AiReportException(EMAIL_SEND_ERROR)throw6. 헥사고날 경계
application 계층은
AiReportResult만 알고, PDF view/mapper는 adapter에 격리했습니다.예외 사항 · 고민 포인트
@Transactional이 동작하도록ObjectProvider<AiReportService>로 프록시 호출.ttf)만 사용,isPdfBoxLoadableTrueType()으로 사전 검증@Retryable(maxAttempts=3, backoff=2s×2)+@Recover로 최종 실패 로깅font-noto,font-noto-cjk설치해 OS 폰트 fallback 보장Summary by CodeRabbit
릴리스 노트
새로운 기능
개선사항