Skip to content

feat: 건강 검진 결과 수정 기능 구현 - #56

Merged
Kyoung-M1N merged 3 commits into
developfrom
feature/#55_edit-health-report
Mar 11, 2026
Merged

feat: 건강 검진 결과 수정 기능 구현#56
Kyoung-M1N merged 3 commits into
developfrom
feature/#55_edit-health-report

Conversation

@Kyoung-M1N

@Kyoung-M1N Kyoung-M1N commented Mar 10, 2026

Copy link
Copy Markdown
Member

🚀 Related issue

closes #55

#️⃣ Summary

  • 건강 검진 결과 데이터에 대한 수정과, 수정에 따른 임베딩 결과 수정, 추천 식단 재생성 로직을 구현합니다.

🎯 Work Description

  • 기존 건강 검진 결과 수정 기능 구현
  • 기존 건강 검진 결과에 대한 임베딩 정보 수정 기능 구현
  • 기존 건강 검진 결과 수정에 따른 식단 추천 기능 연결

👍 동작 확인

  • 기존 건강 검진 결과 수정
    스크린샷 2026-03-09 오후 4 48 54

💬 To Reviewers

  • 검진 결과 생성에서 사용하는 requestbody와 형태가 동일하고 추후 변경도 동시에 적용되는 상황이라 WriteHealthReportRequest로 명칭을 변경하고 두 메서드에서 재사용하도록 구현하였습니다.
  • 임베딩을 비롯한 비동기 로직이 재사용되어 별도의 UseCase와 Service로 분리하였습니다.
  • 기존 Adapter의 생성 메서드를 재사용하여 DirtyChecking방식이 아닌 Merge방식으로 수정이 발생하도록 구현하였습니다.

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 기존의 건강 검진 기록을 수정할 수 있는 기능이 추가되었습니다.
    • 건강 검진 기록 수정 시 건강 점수가 자동으로 업데이트됩니다.
    • 건강 정보 변경에 따른 맞춤형 식사 권장사항이 자동으로 생성됩니다.
  • 개선 사항

    • 건강 검진 기록 관리 시스템의 안정성이 향상되었습니다.

@Kyoung-M1N
Kyoung-M1N requested a review from byunhm02 March 10, 2026 15:09
@Kyoung-M1N Kyoung-M1N self-assigned this Mar 10, 2026
@coderabbitai

coderabbitai Bot commented Mar 10, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

PR은 건강 검진 결과 수정 기능을 구현하며, 기존 검진 기록 수정, 임베딩 업데이트, 식단 추천 기능 연동을 포함합니다. 요청 타입을 WriteHealthReportRequest로 통합하고, UpdateHealthReportCommand와 UpdateHealthReportService를 추가하여 업데이트 워크플로우를 처리합니다.

Changes

Cohort / File(s) Summary
API 레이어
HealthReportApiDocs.java, HealthReportController.java, WriteHealthReportRequest.java
CreateHealthReportRequest를 WriteHealthReportRequest로 통합하고, PUT 엔드포인트 updateHealthReport 추가하여 수정 기능 제공
응답 코드
SuccessCode.java
HEALTH_REPORT_UPDATED 열거형 상수 추가로 수정 완료 응답 지원
인프라
BaseEntity.java
createdAt 필드에 @Column(updatable = false) 주석 추가하여 생성 후 수정 방지
애플리케이션 계층 - 커맨드
CreateHealthReportCommand.java, UpdateHealthReportCommand.java
CreateHealthReportCommand를 WriteHealthReportRequest 사용으로 변경, 새로운 UpdateHealthReportCommand 추가
애플리케이션 계층 - 포트/인터페이스
SaveHealthReportEmbeddingUseCase.java, UpdateHealthReportUseCase.java
임베딩 저장과 건강 검진 수정을 위한 새로운 인터페이스 추가
애플리케이션 계층 - 서비스
CreateHealthReportService.java, SaveHealthReportEmbeddingService.java, UpdateHealthReportService.java
임베딩 로직을 SaveHealthReportEmbeddingService로 분리, 새로운 UpdateHealthReportService 추가로 수정 워크플로우 구현
도메인 레이어
HealthReport.java, HealthReportEmbedding.java
id와 memberId 필드를 long에서 Long으로 변경, HealthReport에 update() 메서드 추가
영속성 계층 - 엔티티
HealthReportEntity.java, HealthReportEmbeddingEntity.java
빌더 생성자에 id 파라미터 추가하여 ID 설정 가능
영속성 계층 - 매퍼
HealthReportEmbeddingMapper.java, HealthReportMapper.java
id 필드 매핑 추가 및 HealthReportEmbeddingMapper에 오버로드 메서드 추가
영속성 계층 - 어댑터
HealthReportEmbeddingPersistenceAdapter.java
기존 임베딩 조회 후 업데이트 또는 신규 생성 로직 추가

Sequence Diagram

sequenceDiagram
    actor Client
    participant Controller as HealthReportController
    participant Service as UpdateHealthReportService
    participant MemberPort as MemberPersistencePort
    participant ReportPort as HealthReportPersistencePort
    participant HealthScoreUseCase
    participant EmbeddingService as SaveHealthReportEmbeddingUseCase
    participant MealUseCase as CreateRecommendedMealUseCase
    
    Client->>Controller: PUT /{healthReportId} with WriteHealthReportRequest
    Controller->>Service: updateHealthReport(UpdateHealthReportCommand)
    
    Service->>MemberPort: getMember(memberId)
    MemberPort-->>Service: Member
    
    Service->>ReportPort: getHealthReport(memberId, healthReportId)
    ReportPort-->>Service: HealthReport
    
    Service->>ReportPort: checkDateConflict(memberId, newDate)
    ReportPort-->>Service: conflictStatus
    
    Service->>Service: healthReport.update(command)
    Service->>ReportPort: saveHealthReport(healthReport)
    ReportPort-->>Service: saved
    
    Service->>HealthScoreUseCase: updateHealthScore(member)
    HealthScoreUseCase-->>Service: completed
    
    Service->>EmbeddingService: embeddingAndSave(text, member, report)
    Note over EmbeddingService: async execution
    EmbeddingService-->>Service: scheduled
    
    Service->>MealUseCase: createRecommendedMeal(...)
    MealUseCase-->>Service: created
    
    Service-->>Controller: void
    Controller-->>Client: 200 OK with HEALTH_REPORT_UPDATED
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • byunhm02

Poem

🐰 검진 기록을 다시 수정하니
임베딩이 함께 춤을 춘다
식단 추천도 곁을 따르고
한 번 만들고 또 갱신하는
아름다운 업데이트의 마술✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.70% 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 제목은 '건강 검진 결과 수정 기능 구현'으로 변경 사항의 핵심인 건강 검진 결과 수정 기능을 정확하게 반영하고 있습니다.
Description check ✅ Passed PR 설명은 템플릿의 필수 섹션(이슈 링크, 요약, 작업 내용, 동작 확인, 리뷰어 코멘트)을 모두 포함하고 있으며 상세하고 명확합니다.
Linked Issues check ✅ Passed PR의 모든 변경사항이 #55 이슈의 요구사항을 충족합니다: (1) 건강 검진 결과 수정 기능 구현, (2) 임베딩 정보 수정 기능, (3) 식단 추천 기능 연결이 모두 구현되었습니다.
Out of Scope Changes check ✅ Passed BaseEntity의 @Column 어노테이션 추가는 createdAt 필드의 불변성을 보장하기 위한 필수 변경으로, 수정 기능 구현 시 생성일자 버그를 방지하는 범위 내의 변경입니다.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/#55_edit-health-report

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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

🧹 Nitpick comments (1)
src/main/java/org/sopt/carena/healthreport/application/dto/command/UpdateHealthReportCommand.java (1)

5-5: 애플리케이션 계층이 웹 요청 DTO를 직접 알지 않게 분리해 주세요.

application.dto.commandadapter.in.web.request.WriteHealthReportRequest를 import하면 인바운드 어댑터 의존성이 안쪽 계층으로 새어 들어옵니다. 생성 커맨드도 같은 패턴이라, 매핑은 컨트롤러나 전용 매퍼에서 처리하고 커맨드는 웹 타입을 모르게 두는 편이 경계 유지에 안전합니다.

Also applies to: 30-53

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

In
`@src/main/java/org/sopt/carena/healthreport/application/dto/command/UpdateHealthReportCommand.java`
at line 5, UpdateHealthReportCommand currently imports the inbound web DTO
WriteHealthReportRequest which leaks adapter concerns into the application
layer; remove that import and make UpdateHealthReportCommand use plain fields or
domain/value types (e.g., Long userId, LocalDate reportDate, String content,
etc.) and provide a constructor/factory that accepts those primitives/domain
types only; then move the mapping from WriteHealthReportRequest to
UpdateHealthReportCommand into the controller or a dedicated mapper (e.g.,
HealthReportController or HealthReportMapper) so controllers translate
WriteHealthReportRequest -> UpdateHealthReportCommand and the application layer
(UpdateHealthReportCommand, its fields and any related service methods) has no
reference to adapter.in.web.request types.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/org/sopt/carena/healthreport/adapter/in/web/controller/HealthReportController.java`:
- Around line 72-75: Change the healthReportId path variable from String to a
numeric type so Spring performs validation: in HealthReportController
updateHealthReport and getEntireHealthReport change the parameter from
`@PathVariable`(name = "healthReportId") final String healthReportId to
`@PathVariable`(name = "healthReportId") final long healthReportId and remove
Long.parseLong(...) usages (call UpdateHealthReportCommand.of(memberId,
healthReportId, request) directly). Also update the corresponding method
signatures in the HealthReportApiDocs interface to accept a long healthReportId
so both controller and API docs are consistent.

In
`@src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/mapper/HealthReportMapper.java`:
- Around line 36-38: The mapper HealthReportMapper.toEntity sets
.id(domain.getId()) which creates a detached entity that when passed into
healthReportRepository.save(...) (from HealthReportPersistenceAdapter) risks an
incomplete merge because BaseEntity.createdAt and other persistent fields are
omitted; fix by either (A) changing UpdateHealthReportService to fetch the
managed HealthReportEntity, apply domain-level changes onto that managed entity
and then save the managed instance (do not construct a new entity with an ID),
or (B) extend HealthReportMapper.toEntity to copy all persistent fields from the
domain/DTO into the entity—including BaseEntity.createdAt (and any other
audit/persistent fields)—so the saved entity is a full snapshot and not a
partially populated detached entity; pick one approach and update references to
HealthReportMapper.toEntity and the save call in HealthReportPersistenceAdapter
accordingly.

In
`@src/main/java/org/sopt/carena/healthreport/application/service/CreateHealthReportService.java`:
- Around line 53-54: The submitted task discards the Future so exceptions from
saveHealthReportEmbeddingUseCase.embeddingAndSave can be swallowed; change the
submitted Runnable to catch Throwable around embeddingAndSave (or use a
CompletableFuture/whenComplete) and log any exception (including
non-EmbeddingFailedException) so failures are observable; update the code that
calls virtualExecutorService.submit(...) to either store and handle the returned
Future or wrap the call to
saveHealthReportEmbeddingUseCase.embeddingAndSave(...) in a try/catch that logs
the error (include member, healthReport id/context) before rethrowing or
handling, referencing virtualExecutorService.submit and
saveHealthReportEmbeddingUseCase.embeddingAndSave.

In
`@src/main/java/org/sopt/carena/healthreport/application/service/SaveHealthReportEmbeddingService.java`:
- Around line 26-45: The retry currently only triggers for
EmbeddingFailedException, but embeddingAndSave calls embeddingPort.embed(...)
and healthReportEmbeddingPersistencePort.saveHealthReportEmbedding(...) which
may throw other runtime exceptions; either wrap those external exceptions in
EmbeddingFailedException (catch exceptions around embeddingPort.embed(...) and
saveHealthReportEmbedding(...) and rethrow new EmbeddingFailedException(cause))
so `@Retryable`(retryFor = EmbeddingFailedException.class) will apply, or broaden
the `@Retryable` on embeddingAndSave to include the specific persistence/port
exception types (or RuntimeException) so failures during save also retry; update
embeddingAndSave in SaveHealthReportEmbeddingService accordingly.

---

Nitpick comments:
In
`@src/main/java/org/sopt/carena/healthreport/application/dto/command/UpdateHealthReportCommand.java`:
- Line 5: UpdateHealthReportCommand currently imports the inbound web DTO
WriteHealthReportRequest which leaks adapter concerns into the application
layer; remove that import and make UpdateHealthReportCommand use plain fields or
domain/value types (e.g., Long userId, LocalDate reportDate, String content,
etc.) and provide a constructor/factory that accepts those primitives/domain
types only; then move the mapping from WriteHealthReportRequest to
UpdateHealthReportCommand into the controller or a dedicated mapper (e.g.,
HealthReportController or HealthReportMapper) so controllers translate
WriteHealthReportRequest -> UpdateHealthReportCommand and the application layer
(UpdateHealthReportCommand, its fields and any related service methods) has no
reference to adapter.in.web.request types.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a25142cf-8187-4794-b4bc-d6fbe5a06025

📥 Commits

Reviewing files that changed from the base of the PR and between e7859d6 and ad6965d.

📒 Files selected for processing (19)
  • src/main/java/org/sopt/carena/global/common/BaseEntity.java
  • src/main/java/org/sopt/carena/healthreport/adapter/in/web/code/SuccessCode.java
  • src/main/java/org/sopt/carena/healthreport/adapter/in/web/controller/HealthReportApiDocs.java
  • src/main/java/org/sopt/carena/healthreport/adapter/in/web/controller/HealthReportController.java
  • src/main/java/org/sopt/carena/healthreport/adapter/in/web/request/WriteHealthReportRequest.java
  • src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/HealthReportEmbeddingPersistenceAdapter.java
  • src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/entity/HealthReportEmbeddingEntity.java
  • src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/entity/HealthReportEntity.java
  • src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/mapper/HealthReportEmbeddingMapper.java
  • src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/mapper/HealthReportMapper.java
  • src/main/java/org/sopt/carena/healthreport/application/dto/command/CreateHealthReportCommand.java
  • src/main/java/org/sopt/carena/healthreport/application/dto/command/UpdateHealthReportCommand.java
  • src/main/java/org/sopt/carena/healthreport/application/port/in/SaveHealthReportEmbeddingUseCase.java
  • src/main/java/org/sopt/carena/healthreport/application/port/in/UpdateHealthReportUseCase.java
  • src/main/java/org/sopt/carena/healthreport/application/service/CreateHealthReportService.java
  • src/main/java/org/sopt/carena/healthreport/application/service/SaveHealthReportEmbeddingService.java
  • src/main/java/org/sopt/carena/healthreport/application/service/UpdateHealthReportService.java
  • src/main/java/org/sopt/carena/healthreport/domain/HealthReport.java
  • src/main/java/org/sopt/carena/healthreport/domain/HealthReportEmbedding.java

Comment on lines +72 to +75
@PathVariable(name = "healthReportId") final String healthReportId,
@Valid @RequestBody final WriteHealthReportRequest request
) {
updateHealthReportUseCase.updateHealthReport(UpdateHealthReportCommand.of(memberId, Long.parseLong(healthReportId), request));

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify manual parsing of healthReportId in controller/api-docs signatures.
rg -n -C2 '@PathVariable\(name = "healthReportId"\) final String|Long\.parseLong\(healthReportId\)|updateHealthReport\(long memberId, String healthReportId' src/main/java

Repository: team-carena/carena-server

Length of output: 3485


🏁 Script executed:

# Check entire HealthReportController for all methods using healthReportId
cat -n src/main/java/org/sopt/carena/healthreport/adapter/in/web/controller/HealthReportController.java | head -120

Repository: team-carena/carena-server

Length of output: 7046


🏁 Script executed:

# Check HealthReportApiDocs interface signatures
cat -n src/main/java/org/sopt/carena/healthreport/adapter/in/web/controller/HealthReportApiDocs.java

Repository: team-carena/carena-server

Length of output: 5414


🏁 Script executed:

# Search for all Long.parseLong usages in health report module
rg -n 'Long\.parseLong' src/main/java/org/sopt/carena/healthreport/

Repository: team-carena/carena-server

Length of output: 499


healthReportIdString 파싱 대신 숫자 타입으로 바로 바인딩하세요.

updateHealthReportgetEntireHealthReport 두 메서드 모두에서 Long.parseLong()으로 수동 파싱하고 있습니다. 잘못된 값이 들어오면 NumberFormatException으로 500 에러가 발생하므로, @PathVariable long healthReportId로 받아서 Spring이 타입 검증을 맡기세요. 이에 맞춰 HealthReportApiDocs 인터페이스의 두 메서드 시그니처도 함께 수정하세요.

🔧 제안 수정안
 `@PutMapping`(path = "/{healthReportId}")
 public ResponseEntity<SuccessResponse<Void>> updateHealthReport(
     `@AuthenticationPrincipal` final long memberId,
-    `@PathVariable`(name = "healthReportId") final String healthReportId,
+    `@PathVariable`(name = "healthReportId") final long healthReportId,
     `@Valid` `@RequestBody` final WriteHealthReportRequest request
 ) {
-    updateHealthReportUseCase.updateHealthReport(UpdateHealthReportCommand.of(memberId, Long.parseLong(healthReportId), request));
+    updateHealthReportUseCase.updateHealthReport(UpdateHealthReportCommand.of(memberId, healthReportId, request));
 `@GetMapping`(path = "/{healthReportId}")
 public ResponseEntity<SuccessResponse<EntireHealthReportView>> getEntireHealthReport(
     `@AuthenticationPrincipal` final long memberId,
-    `@PathVariable`(name = "healthReportId") final String healthReportId
+    `@PathVariable`(name = "healthReportId") final long healthReportId
 ) {
-    return ResponseEntity.status(SuccessCode.HEALTH_REPORT_FOUND.getStatus())
-        .body(ApiResponse.success(SuccessCode.HEALTH_REPORT_FOUND,
-            getEntireHealthReportUseCase.getEntireHealthReport(memberId, Long.parseLong(healthReportId))));
+    return ResponseEntity.status(SuccessCode.HEALTH_REPORT_FOUND.getStatus())
+        .body(ApiResponse.success(SuccessCode.HEALTH_REPORT_FOUND,
+            getEntireHealthReportUseCase.getEntireHealthReport(memberId, healthReportId)));
 `@Operation`(summary = "건강 검진 결과 수정", description = "건강 검진 결과를 수정합니다.")
-ResponseEntity<SuccessResponse<Void>> updateHealthReport(long memberId, String healthReportId, `@Valid` WriteHealthReportRequest request);
+ResponseEntity<SuccessResponse<Void>> updateHealthReport(long memberId, long healthReportId, `@Valid` WriteHealthReportRequest request);

 `@Operation`(summary = "건강 검진 결과 중 내용 전체 조회", description = "식별자에 해당하는 건강 검진 결과의 전체 내용을 조회합니다.")
-ResponseEntity<SuccessResponse<EntireHealthReportView>> getEntireHealthReport(long memberId, String healthReportId);
+ResponseEntity<SuccessResponse<EntireHealthReportView>> getEntireHealthReport(long memberId, long healthReportId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/sopt/carena/healthreport/adapter/in/web/controller/HealthReportController.java`
around lines 72 - 75, Change the healthReportId path variable from String to a
numeric type so Spring performs validation: in HealthReportController
updateHealthReport and getEntireHealthReport change the parameter from
`@PathVariable`(name = "healthReportId") final String healthReportId to
`@PathVariable`(name = "healthReportId") final long healthReportId and remove
Long.parseLong(...) usages (call UpdateHealthReportCommand.of(memberId,
healthReportId, request) directly). Also update the corresponding method
signatures in the HealthReportApiDocs interface to accept a long healthReportId
so both controller and API docs are consistent.

Comment on lines 36 to +38
public static HealthReportEntity toEntity(final HealthReport domain) {
return HealthReportEntity.builder()
.id(domain.getId())

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# HealthReportMapper.toEntity(...) 호출 위치 확인
rg -n -C3 --type=java 'HealthReportMapper\.toEntity\(' -g '*HealthReport*.java'

# HealthReport 저장/병합 구현 확인
rg -n -C5 --type=java 'implements HealthReportPersistencePort|EntityManager\.merge\(|saveAndFlush\s*\(|save\s*\(' -g '*HealthReport*.java'

# 업데이트 서비스에서 저장까지 이어지는 흐름 확인
rg -n -C8 --type=java 'class UpdateHealthReportService|updateHealthReport\s*\(|healthReportPersistencePort\.' -g '*HealthReport*.java'

Repository: team-carena/carena-server

Length of output: 46016


🏁 Script executed:

cat -n src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/mapper/HealthReportMapper.java

Repository: team-carena/carena-server

Length of output: 4185


🏁 Script executed:

cat -n src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/entity/HealthReportEntity.java | head -50

Repository: team-carena/carena-server

Length of output: 1885


🏁 Script executed:

cat -n src/main/java/org/sopt/carena/global/common/BaseEntity.java

Repository: team-carena/carena-server

Length of output: 746


ID를 포함한 detached 엔티티 저장이 불완전한 merge 위험을 만듭니다.

toEntity() 메서드에서 .id(domain.getId())를 설정하면서 업데이트 경로가 detached HealthReportEntitysave()에 전달하게 됩니다. Spring Data JPA는 ID가 있는 detached 엔티티를 merge하는데, 이 매퍼는 BaseEntitycreatedAt 같은 감사 필드를 싣지 않아 불완전한 엔티티 스냅샷을 만듭니다.

@Column(updatable = false)가 SQL UPDATE를 막더라도, 패턴 자체가 문제입니다. 향후 BaseEntityHealthReportEntity에 새 필드가 추가되면 같은 위험이 노출될 수 있습니다.

수정 경로가 조회된 managed 엔티티를 직접 갱신하거나, 최소한 매퍼가 모든 persistent 상태를 보존하도록 개선하세요:

  • 옵션 1: UpdateHealthReportService에서 조회한 HealthReport를 도메인 레벨에서만 갱신하고, persistence adapter에서는 그 상태를 유지한 채 관리 엔티티를 업데이트
  • 옵션 2: 매퍼에서 모든 BaseEntity 필드(현재 createdAt)를 포함하도록 확장
확인된 코드 위치
src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/mapper/HealthReportMapper.java
- 라인 38: .id(domain.getId())
- 라인 64: .id(domain.getId())

src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/HealthReportPersistenceAdapter.java
- 라인 33: healthReportRepository.save(HealthReportMapper.toEntity(...))

src/main/java/org/sopt/carena/global/common/BaseEntity.java
- `@CreatedDate` createdAt 필드는 매퍼에서 누락됨
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/sopt/carena/healthreport/adapter/out/persistence/mapper/HealthReportMapper.java`
around lines 36 - 38, The mapper HealthReportMapper.toEntity sets
.id(domain.getId()) which creates a detached entity that when passed into
healthReportRepository.save(...) (from HealthReportPersistenceAdapter) risks an
incomplete merge because BaseEntity.createdAt and other persistent fields are
omitted; fix by either (A) changing UpdateHealthReportService to fetch the
managed HealthReportEntity, apply domain-level changes onto that managed entity
and then save the managed instance (do not construct a new entity with an ID),
or (B) extend HealthReportMapper.toEntity to copy all persistent fields from the
domain/DTO into the entity—including BaseEntity.createdAt (and any other
audit/persistent fields)—so the saved entity is a full snapshot and not a
partially populated detached entity; pick one approach and update references to
HealthReportMapper.toEntity and the save call in HealthReportPersistenceAdapter
accordingly.

Comment on lines +53 to +54
virtualExecutorService.submit(
() -> saveHealthReportEmbeddingUseCase.embeddingAndSave(embeddingText, member, healthReport));

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

🧩 Analysis chain

🌐 Web query:

Java ExecutorService submit(Runnable) exception handling when the returned Future is ignored, compared with execute(Runnable)

💡 Result:

  • execute(Runnable): if run() throws an unchecked exception (RuntimeException/Error), it is treated as an uncaught exception on the worker thread, so the thread’s UncaughtExceptionHandler (or the JVM default handler) can see/log it, and the pool will typically replace the dying worker thread. (stackoverflow.com)
  • submit(Runnable): the executor wraps the task in a Future (typically a FutureTask). Any exception thrown by run() is captured and stored in that Future and is only rethrown (wrapped in ExecutionException) if you later call Future.get(). The thread’s UncaughtExceptionHandler is not notified. (stackoverflow.com)
  • If you ignore the returned Future from submit(Runnable): the exception is effectively silenced/lost (unless you add your own reporting, e.g., wrap the Runnable with try/catch + logging, or override ThreadPoolExecutor.afterExecute and extract the exception from the Future). (community.sonarsource.com)

Citations:


🏁 Script executed:

cd /tmp || cd .
find . -type f -name "CreateHealthReportService.java" 2>/dev/null | head -5

Repository: team-carena/carena-server

Length of output: 51


🏁 Script executed:

find . -type f -name "SaveHealthReportEmbeddingUseCase.java" 2>/dev/null | head -5

Repository: team-carena/carena-server

Length of output: 171


🏁 Script executed:

find . -type f -name "*SaveHealthReportEmbedding*" 2>/dev/null | head -10

Repository: team-carena/carena-server

Length of output: 274


🏁 Script executed:

wc -l src/main/java/org/sopt/carena/healthreport/application/service/CreateHealthReportService.java

Repository: team-carena/carena-server

Length of output: 165


🏁 Script executed:

cat -n src/main/java/org/sopt/carena/healthreport/application/service/CreateHealthReportService.java | sed -n '40,70p'

Repository: team-carena/carena-server

Length of output: 821


🏁 Script executed:

cat -n src/main/java/org/sopt/carena/healthreport/application/port/in/SaveHealthReportEmbeddingUseCase.java

Repository: team-carena/carena-server

Length of output: 446


🏁 Script executed:

cat -n src/main/java/org/sopt/carena/healthreport/application/service/SaveHealthReportEmbeddingService.java

Repository: team-carena/carena-server

Length of output: 2606


비동기 예외가 관찰되지 않습니다.

Line 53에서 submit()의 반환값을 버리고 있어서, embeddingAndSave()EmbeddingFailedException 이외의 예외로 실패하면 예외가 Future 안에 갇힌 후 호출부에서는 전혀 관측되지 않습니다. 건강검진 리포트 임베딩 동기화는 비즈니스 흐름의 핵심 후처리이므로, 명시적 로깅으로 모든 예외를 드러내는 것이 안전합니다.

제안 수정안
-		virtualExecutorService.submit(
-				() -> saveHealthReportEmbeddingUseCase.embeddingAndSave(embeddingText, member, healthReport));
+		virtualExecutorService.execute(() -> {
+			try {
+				saveHealthReportEmbeddingUseCase.embeddingAndSave(embeddingText, member, healthReport);
+			} catch (Exception e) {
+				log.error("건강검진 리포트 임베딩 저장 비동기 작업 실패: memberId={}, reportId={}",
+						member.getId(), healthReport.getId(), e);
+			}
+		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/sopt/carena/healthreport/application/service/CreateHealthReportService.java`
around lines 53 - 54, The submitted task discards the Future so exceptions from
saveHealthReportEmbeddingUseCase.embeddingAndSave can be swallowed; change the
submitted Runnable to catch Throwable around embeddingAndSave (or use a
CompletableFuture/whenComplete) and log any exception (including
non-EmbeddingFailedException) so failures are observable; update the code that
calls virtualExecutorService.submit(...) to either store and handle the returned
Future or wrap the call to
saveHealthReportEmbeddingUseCase.embeddingAndSave(...) in a try/catch that logs
the error (include member, healthReport id/context) before rethrowing or
handling, referencing virtualExecutorService.submit and
saveHealthReportEmbeddingUseCase.embeddingAndSave.

Comment on lines +26 to +45
@Retryable(
retryFor = EmbeddingFailedException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 5000, multiplier = 2),
recover = "recoverEmbedding"
)
public void embeddingAndSave(final String embeddingText, final Member member, final HealthReport healthReport) {
// 임베딩 호출
float[] embedding = embeddingPort.embed(embeddingText).vector();

// 임베딩 결과 도메인 생성
HealthReportEmbedding healthReportEmbedding = HealthReportEmbedding.builder()
.embeddingText(embeddingText)
.embedding(embedding)
.memberId(member.getId())
.healthReportId(healthReport.getId())
.build();

// 임베딩 결과 저장
healthReportEmbeddingPersistencePort.saveHealthReportEmbedding(healthReportEmbedding);

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

🧩 Analysis chain

🌐 Web query:

In Spring Retry, when a method annotated with @retryable(retryFor = EmbeddingFailedException.class) throws a different RuntimeException, does it retry or invoke @recover?

💡 Result:

No.

With @Retryable(retryFor = EmbeddingFailedException.class), Spring Retry only retries when the thrown exception is an EmbeddingFailedException (or a subtype). If the method throws a different RuntimeException, it is treated as not retryable and is thrown to the caller immediately—Spring Retry will not retry and will not invoke @Recover (since recovery is for an exhausted retry). [1]

Also, @Recover methods are selected based on the exception type they declare (a matching Throwable subtype) and the original method signature/return type. [2]

Sources:
[1] Spring Retry README (exception classification / “exception is thrown … immediately, if the exception is not retryable”) (github.com)
[2] Spring Retry @Recover Javadoc (docs.spring.io)

Citations:


저장 단계 실패는 재시도/복구 경로에 진입하지 않습니다.

Line 26에서 @Retryable(retryFor = EmbeddingFailedException.class)로 제한한 상태라, Line 34의 임베딩 호출이나 Line 45의 saveHealthReportEmbedding(...)가 다른 런타임 예외를 던지면 첫 실패 후 즉시 호출자에게 전달되고 @Recover도 실행되지 않습니다. 비동기 처리이므로 외부 포트 예외를 EmbeddingFailedException으로 변환하거나, 재시도 대상을 저장 예외까지 포함하도록 확대해야 합니다.

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

In
`@src/main/java/org/sopt/carena/healthreport/application/service/SaveHealthReportEmbeddingService.java`
around lines 26 - 45, The retry currently only triggers for
EmbeddingFailedException, but embeddingAndSave calls embeddingPort.embed(...)
and healthReportEmbeddingPersistencePort.saveHealthReportEmbedding(...) which
may throw other runtime exceptions; either wrap those external exceptions in
EmbeddingFailedException (catch exceptions around embeddingPort.embed(...) and
saveHealthReportEmbedding(...) and rethrow new EmbeddingFailedException(cause))
so `@Retryable`(retryFor = EmbeddingFailedException.class) will apply, or broaden
the `@Retryable` on embeddingAndSave to include the specific persistence/port
exception types (or RuntimeException) so failures during save also retry; update
embeddingAndSave in SaveHealthReportEmbeddingService accordingly.

@byunhm02 byunhm02 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

깔끔하게 잘 구현하셨네용~~수고하셨습니다~!!!!!!!

@Kyoung-M1N
Kyoung-M1N merged commit 0c08bfd into develop Mar 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: 건강 검진 결과 수정 기능 구현

2 participants