[SRLT-155] PDF AI 리포트 생성 및 완료 메일 PDF 첨부 발송#99
Hidden character warning
Conversation
|
Note
|
| 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로 검증하고 임시 파일을 안전하게 관리합니다. AiReportPdfViewMapper는 AiReportResult를 뷰 모델로 변환하며 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 이벤트 입력 타입을 도입합니다. AiReportService는 handlePdfReportRequested(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)로 재전파합니다. AiReportErrorType에 EMAIL_SEND_ERROR 상수를 추가합니다. |
어댑터 확장 및 리스너 단순화 src/main/java/starlight/adapter/member/persistence/MemberJpa.java, src/main/java/starlight/application/aireport/event/AiReportPdfEvaluationEventListener.java |
MemberJpa가 aireport.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 | 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.
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
| } 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); | ||
| } |
There was a problem hiding this comment.
🧩 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.javaRepository: StartUpLight/STARLIGHT_BE
Length of output: 592
🏁 Script executed:
sed -n '110,125p' src/main/java/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.javaRepository: 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.
| 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) { |
There was a problem hiding this comment.
요청마다 임시 폰트 파일이 누적되어 디스크 누수 위험이 있습니다.
현재 클래스패스 폰트를 렌더링마다 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()); | |||
There was a problem hiding this comment.
🧩 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:
- 1: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/mail/javamail/JavaMailSenderImpl.html
- 2: https://github.com/spring-projects/spring-framework/blob/master/spring-context-support/src/main/java/org/springframework/mail/javamail/JavaMailSenderImpl.java
- 3: https://stackoverflow.com/questions/14992077/unboxing-a-null-boxed-object-throws-unexpected-nullpointerexception
- 4: https://dev.to/scottshipp/java-pitfalls-unboxing-null-booleans-1agj
🏁 Script executed:
# MailConfig.java 파일 찾기 및 읽기
find . -name "MailConfig.java" -type fRepository: 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.javaRepository: 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.
| 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.
🚀 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 보장