Skip to content

feat: 약 소프트 삭제 도입 및 약물 충돌 정합성 정비 - #74

Merged
neibler merged 2 commits into
developfrom
feature/71
Aug 10, 2026
Merged

feat: 약 소프트 삭제 도입 및 약물 충돌 정합성 정비#74
neibler merged 2 commits into
developfrom
feature/71

Conversation

@neibler

@neibler neibler commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

PR Description — feature/71

제목

feat: 약물 충돌 카드 정보 보강 및 약 삭제 방식 soft delete 전환

본문

무엇을 / 왜

약물 충돌 분석 결과가 원천 데이터를 따라가지 못하는 정합성 문제를 정리하고,
그 과정에서 드러난 약 삭제 방식(하드 삭제 → soft delete)을 함께 전환했습니다.

주요 변경

1. 충돌 재분석 시 기존 행 갱신 (skip → upsert)
이미 저장된 조합을 건너뛰어, 약 정보가 바뀌어 위험도가 "주의 → 금기"로 올라가도 옛 등급이 그대로 노출됐습니다.
값이 실제로 달라진 경우에만 갱신하도록 변경했습니다.

2. 충돌 카드에 표시 정보 4종 추가
제품종류·제품명·처방기관·처방날짜를 내려줍니다. 저장하지 않고 조회 시 Medication에서 합성해
약을 수정하면 카드에도 즉시 반영됩니다(기존 fetch join 활용 — 추가 쿼리 없음).
처방날짜는 prescription_date(OCR) → 없으면 start_date(사용자 입력) 폴백.

3. 약 삭제를 soft delete로 전환
medication_log 등이 FK로 물려 있어 하드 삭제 시 복약 이력이 파괴되는 문제가 있었습니다.
deleted_at만 채우고 참조 행은 보존하며, 노출 규칙은 "삭제일 당일부터 앞으로만 숨김" 입니다.
→ 오늘·미래 홈카드와 약물노트·충돌 목록에서는 빠지고,
삭제 이전 날짜의 홈카드와 복약 기록 리포트에는 그대로 남습니다.

4. 유효하지 않은 충돌 숨김
복용이 끝났거나 삭제된 약의 충돌은 목록·상세 모두에서 노출되지 않습니다(두 화면 기준 통일).

⚠️ Breaking Change — 프론트 협의 필요

GET /api/conflicts 응답 구조가 바뀝니다. 배포 순서 조율이 필요합니다.

medicationId1 / drugName1  →  drug1.medicationId / drug1.drugName
medicationId2 / drugName2  →  drug2.medicationId / drug2.drugName
                    (신규)  →  drug1.drugType / drug1.hospitalName / drug1.prescriptionDate

DB

medication.deleted_at 컬럼 추가 — ddl-auto: update로 자동 반영되며 기존 행은 NULL(유효)입니다.

테스트

  • 단위 테스트 40개 중 39개 통과. 실패 1건(CallcareApplicationTests)은 로컬 MySQL 미기동에 따른
    컨텍스트 로드 실패로, 이번 변경 이전부터 있던 동일 실패입니다.
  • 재분석 시 기존 충돌이 갱신되는지 검증하는 테스트를 추가했고,
    구조 변경 후 깨져 있던 HomeCardQueryServiceTest 픽스처를 복구했습니다.
  • JPQL은 단위 테스트가 목 기반이라 검증되지 않아, 로컬 기동으로 파싱을 확인했습니다.

리뷰 포인트

  • 약물노트 "복용완료" 목록에서도 삭제된 약을 제외했습니다. 복용완료는 이력이 아니라 보유 목록의
    상태값이고 이력은 리포트가 담당한다고 보았는데, 다르게 보신다면 의견 주세요.
  • 충돌 상세도 목록과 같은 기준으로 막았습니다(유효하지 않은 경고를 현재 위험으로 오인 방지).

Summary by CodeRabbit

  • 새 기능

    • 약물 삭제가 복구 불가능한 영구 삭제에서 소프트 삭제 방식으로 변경되었습니다.
    • 삭제된 약물은 최신 목록, 검색, 홈 카드 및 충돌 조회에서 제외됩니다.
    • 과거 복약 기록과 리포트에는 삭제된 약물 정보가 유지됩니다.
    • 약물 충돌 응답에 약 이름, 별칭, 유형, 처방 기관 및 처방 날짜가 포함됩니다.
  • 개선

    • 충돌 재분석 시 기존 결과를 최신 상태로 갱신하고, 해소된 충돌은 정리합니다.
    • 날짜 기준 약물 및 복약 일정 조회가 더욱 정확해졌습니다.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 44976600-2dd5-44e8-ba31-708f7ef9f9d2

📥 Commits

Reviewing files that changed from the base of the PR and between 3c5d66f and f34b358.

📒 Files selected for processing (1)
  • src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java

📝 Walkthrough

Walkthrough

약물 충돌 응답에 약물 상세 객체를 추가했습니다. 충돌 재분석은 기존 결과를 갱신하고 stale 충돌을 정리합니다. 약물 삭제는 deletedAt을 기록하는 소프트 삭제로 변경되며, 약물·스케줄·홈카드·충돌 조회가 삭제 시점을 반영합니다.

Changes

약물 충돌 응답 구조

Layer / File(s) Summary
약물 정보 응답 계약과 변환
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/*, src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java
ConflictDrug를 추가했습니다. 목록과 상세 응답은 제품 정보, 병원명, 처방 날짜를 중첩 객체로 제공합니다. 처방 날짜는 prescriptionDate, 없으면 startDate를 사용합니다.

충돌 재분석 및 upsert

Layer / File(s) Summary
재분석 결과 갱신과 stale 충돌 정리
src/main/java/com/piuda/callcare/domain/drugconflict/entity/DrugConflict.java, src/main/java/com/piuda/callcare/domain/drugconflict/repository/DrugConflictRepository.java, src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java, src/test/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandServiceTest.java
기존 충돌은 최신 심각도와 설명으로 갱신합니다. 재분석에서 다시 매칭되지 않은 활성 분석 대상의 충돌은 삭제합니다. 분석 대상에서 제외된 약물이 포함된 충돌은 유지합니다.

약물 소프트 삭제와 조회 범위

Layer / File(s) Summary
소프트 삭제와 저장소 필터
src/main/java/com/piuda/callcare/domain/medication/entity/Medication.java, src/main/java/com/piuda/callcare/domain/medication/repository/*, src/main/java/com/piuda/callcare/domain/medication/service/*
약물 삭제는 deletedAt 기록으로 변경되었습니다. 단건 조회, 목록, 검색, 그룹, 활성 약물 조회는 삭제된 약물을 제외합니다. 과거 복약 리포트 조회는 삭제된 약물을 유지합니다.
홈카드와 복용 토글의 날짜 경계
src/main/java/com/piuda/callcare/domain/home/service/*, src/main/java/com/piuda/callcare/domain/medication/repository/MedicationScheduleRepository.java, src/main/java/com/piuda/callcare/domain/medication/controller/MedicationController.java, src/test/java/com/piuda/callcare/domain/home/service/*
홈카드와 완료 상태 재계산은 다음 날 0시를 종료 시각으로 사용합니다. 삭제된 약물은 복용 토글과 날짜별 홈카드에서 제외됩니다. 삭제 API 설명과 테스트를 새 조회 규칙에 맞게 갱신했습니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DrugConflictCommandService
  participant DrugConflictRepository
  participant DrugConflict
  DrugConflictCommandService->>DrugConflictRepository: 약물 쌍에 해당하는 기존 충돌 조회
  DrugConflictCommandService->>DrugConflict: severity와 conflictDescription 갱신
  DrugConflictCommandService->>DrugConflictRepository: stale 충돌 삭제 또는 신규 충돌 저장
Loading
sequenceDiagram
  participant MedicationCommandService
  participant MedicationRepository
  participant Medication
  participant MedicationScheduleRepository
  MedicationCommandService->>MedicationRepository: 삭제되지 않은 약물 조회
  MedicationCommandService->>Medication: softDelete() 호출
  MedicationScheduleRepository->>Medication: deletedAt와 hiddenFrom 기준 조회
Loading

Possibly related issues

Possibly related PRs

  • PIUDAProject/Backend#45 — 약물 충돌 변환기, DTO, 저장소, 엔티티, 분석 서비스 변경과 직접 연결됩니다.
  • PIUDAProject/Backend#36 — 홈카드와 스케줄 저장소의 약물 날짜별 필터링 변경과 직접 연결됩니다.
  • PIUDAProject/Backend#54 — 약물 삭제, 단건 조회, 저장소 조회 조건 변경과 직접 연결됩니다.

Suggested labels: ✨ Feature, 🐞 Fix

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% 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 제목은 약물 소프트 삭제 도입과 약물 충돌 정합성 개선이라는 변경사항의 핵심을 정확하고 간결하게 요약합니다.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/71

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.

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

🧹 Nitpick comments (1)
src/test/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryServiceTest.java (1)

75-75: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

삭제 날짜 경계를 영속성 테스트로 검증하세요.

Line 75의 mock은 hiddenFrom 인자 전달만 검증합니다. MedicationScheduleRepositorym.deletedAt IS NULL OR m.deletedAt >= :hiddenFrom 조건은 실행하지 않습니다.

@DataJpaTest에서 다음 경우를 검증하세요.

  • deletedAt == null이면 조회 결과에 포함됩니다.
  • deletedAttargetDate이면 조회 결과에서 제외됩니다.
  • deletedAttargetDate.plusDays(1).atStartOfDay()이면 targetDate 조회 결과에 포함됩니다.

As per path instructions, src/test/**/*.java: 테스트 커버리지를 확인한다.

🤖 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/test/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryServiceTest.java`
at line 75, Replace the mock-only boundary check in HomeCardQueryServiceTest
with a `@DataJpaTest` covering
MedicationScheduleRepository.findActiveSchedulesForHomeCards: verify records
with deletedAt null are included, records deleted at targetDate are excluded,
and records deleted at targetDate.plusDays(1).atStartOfDay() are included.

Source: Path instructions

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

Inline comments:
In
`@src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java`:
- Around line 10-18:
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java:10-18에서
DrugConflictResponse의 drug1/drug2 중첩 응답 계약을 기준으로 목록 API의 새 JSON 구조를 반영하십시오.
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java:10-18의
drugType, drugName, prescriptionDate 등 중첩 필드를 사용하고 기존 평탄 필드는 제거한 뒤,
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java:12-22와
src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java:18-60에서도
목록 및 상세 조회 모두 동일하게 매핑되도록 수정하십시오. API 문서와 예시 응답도 새 중첩 구조와 필드 삭제를 반영하고 프론트 소비자 변경을
배포에 맞추십시오.

In
`@src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java`:
- Around line 103-106: Update the DataIntegrityViolationException handling in
DrugConflictCommandService so concurrent drug-pair inserts are not swallowed
within the surrounding `@Transactional` transaction; use an atomic database upsert
or a separate REQUIRES_NEW transaction that re-reads the duplicate row and
updates severity and conflictDescription. Add an integration test covering two
transactions analyzing the same drug pair concurrently and verifying the
committed row contains the latest analysis result without
UnexpectedRollbackException.

---

Nitpick comments:
In
`@src/test/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryServiceTest.java`:
- Line 75: Replace the mock-only boundary check in HomeCardQueryServiceTest with
a `@DataJpaTest` covering
MedicationScheduleRepository.findActiveSchedulesForHomeCards: verify records
with deletedAt null are included, records deleted at targetDate are excluded,
and records deleted at targetDate.plusDays(1).atStartOfDay() are included.
🪄 Autofix

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 Plus

Run ID: 529cd260-0a9e-4d2e-8306-4d480b80aa74

📥 Commits

Reviewing files that changed from the base of the PR and between 0ae96ae and 3c5d66f.

📒 Files selected for processing (18)
  • src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java
  • src/main/java/com/piuda/callcare/domain/drugconflict/entity/DrugConflict.java
  • src/main/java/com/piuda/callcare/domain/drugconflict/repository/DrugConflictRepository.java
  • src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java
  • src/main/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandService.java
  • src/main/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryService.java
  • src/main/java/com/piuda/callcare/domain/medication/controller/MedicationController.java
  • src/main/java/com/piuda/callcare/domain/medication/entity/Medication.java
  • src/main/java/com/piuda/callcare/domain/medication/repository/MedicationRepository.java
  • src/main/java/com/piuda/callcare/domain/medication/repository/MedicationScheduleRepository.java
  • src/main/java/com/piuda/callcare/domain/medication/service/command/MedicationCommandService.java
  • src/main/java/com/piuda/callcare/domain/medication/service/query/MedicationQueryService.java
  • src/test/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandServiceTest.java
  • src/test/java/com/piuda/callcare/domain/home/service/command/MedicationToggleCommandServiceTest.java
  • src/test/java/com/piuda/callcare/domain/home/service/query/HomeCardQueryServiceTest.java

Comment on lines 10 to 18
public record DrugConflictResponse(

@Schema(description = "충돌 ID") Long conflictId,
@Schema(description = "약1 ID") Long medicationId1,
@Schema(description = "약1 이름") String drugName1,
@Schema(description = "약2 ID") Long medicationId2,
@Schema(description = "약2 이름") String drugName2,
@Schema(description = "약1 정보") ConflictDrug drug1,
@Schema(description = "약2 정보") ConflictDrug drug2,
@Schema(description = "심각도 (CONTRAINDICATED=금기, CAUTION=주의)") ConflictSeverity severity,
@Schema(description = "심각도 한글 라벨") String severityLabel,
@Schema(description = "확인(해결) 여부") boolean isResolved
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Find code and tests that still reference removed flat response fields.
rg -n -C 3 \
  'DrugConflictResponse|DrugConflictDetailResponse|ConflictDrug|drug1Id|drug2Id|drug1Name|drug2Name' \
  src/main/java src/test/java

# Locate controller and API-documentation tests for the affected endpoints.
rg -n -C 5 \
  'getConflicts|getConflictDetail|/api/conflicts|MockMvc|WebTestClient' \
  src/main/java src/test/java

Repository: PIUDAProject/Backend

Length of output: 41576


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect exact response DTOs and converter logic.
printf '--- DTO records ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java
printf '\n--- ConflictDrug ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java
printf '\n--- DrugConflictDetailResponse ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java
printf '\n--- Converter relevant section ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java | sed -n '1,80p'

# Read-only verifier: compare current public DTO public constructor/schema fields against removed flat fields.
python3 - <<'PY'
from pathlib import Path
import re

files = [
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java",
]
records = []
for path in files:
    text = Path(path).read_text()
    m = re.search(r'public record \w+\s*(.*?)\n\{', text, re.S)
    if not m:
        raise SystemExit(f"You need check {path}")
    body = re.sub(r'`@Schema`\s*\(.*?\)\s*', '', m.group(1), flags=re.S)
    fields = [re.sub(r'[,;\s]+.*','', f.strip()) for f in body.splitlines() if f.strip() and not f.strip().startswith('//')]
    records.append((path.split('/')[-1], fields))

for name, fields in records:
    print(name, fields)

flat_fields = ['drug1Id','drug2Id','drug1Name','drug2Name','drug1DrugType','drug2DrugType','drug1DrugNickname','drug2DrugNickname','drug1HospitalName','drug2HospitalName','drug1PrescriptionDate','drug2PrescriptionDate']
for name, fields in records:
    removed = [f for f in fields if f.startswith('drug') or 'drugId' in f or f in flat_fields]
    nested = [f for f in fields if f in ('drug1','drug2')]
    if removed or nested:
        print('root flat/nested fields:', name, removed or nested)
PY

printf '\n--- API tests using /api/conflicts or controller responses ---\n'
rg -n -C 4 '/api/conflicts|DrugConflictController|MockMvc|JsonPath|Extract.*Response|ApiResponse' src/test/java || true

Repository: PIUDAProject/Backend

Length of output: 249


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect exact response DTOs and converter logic.
echo '--- DTO records ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java
echo
echo '--- ConflictDrug ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java
echo
echo '--- DrugConflictDetailResponse ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java
echo
echo '--- Converter relevant section ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java | sed -n '1,80p'

# Read-only verifier: compare current public DTO public constructor/schema fields against removed flat fields.
python3 - <<'PY'
from pathlib import Path
import re

files = [
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java",
]
records = []
for path in files:
    text = Path(path).read_text()
    m = re.search(r'public record \w+\s*(.*?)\n\{', text, re.S)
    if not m:
        raise SystemExit(f"Check {path}")
    body = re.sub(r'`@Schema`\s*\([^)]*\)\s*', '', m.group(1), flags=re.S)
    fields = [re.sub(r'[,;\s]+.*','', f.strip()) for f in body.splitlines() if f.strip() and not f.strip().startswith('//')]
    records.append((path.split('/')[-1], fields))

flat_fields = ['drug1Id','drug2Id','drug1Name','drug2Name','drug1DrugType','drug2DrugType','drug1DrugNickname','drug2DrugNickname','drug1HospitalName','drug2HospitalName','drug1PrescriptionDate','drug2PrescriptionDate']
for name, fields in records:
    print(name, fields)
    removed = [f for f in fields if f.startswith('drug') or 'drugId' in f or f in flat_fields]
    nested = [f for f in fields if f in ('drug1','drug2')]
    if removed or nested:
        print('root flat/nested fields:', name, removed or nested)
PY

echo
echo '--- API tests using /api/conflicts or controller responses ---'
rg -n -C 4 '/api/conflicts|DrugConflictController|MockMvc|JsonPath|Extract.*Response|ApiResponse' src/test/java || true

Repository: PIUDAProject/Backend

Length of output: 6127


외부 API 응답 계약 변경을 배포 전에 확인하십시오.

약물 필드가 drug1/drug2 중첩 객체로 바뀌었습니다. 기존 클라이언트는 drug1Name, drug2Name 같은 평탄 필드를 더 읽지 못하므로 렌더링 오류가 발생할 수 있습니다.

  • GET /api/conflicts/api/conflicts/{conflictId} 응답을 새 중첩 JSON 구조로 매핑하는 프론트 변경을 배포와 맞추십시오.
  • API 문서와 예시 응답은 기존 필드 삭제와 drugType, drugName, prescriptionDate 같은 중첩 필드 추가를 반영하십시오.
  • 이 변경은 GET /api/conflicts와 상세 조회 모두에 적용됩니다.
📍 Affects 4 files
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java#L10-L18 (this comment)
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java#L10-L18
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java#L12-L22
  • src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java#L18-L60
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java`
around lines 10 - 18,
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java:10-18에서
DrugConflictResponse의 drug1/drug2 중첩 응답 계약을 기준으로 목록 API의 새 JSON 구조를 반영하십시오.
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java:10-18의
drugType, drugName, prescriptionDate 등 중첩 필드를 사용하고 기존 평탄 필드는 제거한 뒤,
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java:12-22와
src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java:18-60에서도
목록 및 상세 조회 모두 동일하게 매핑되도록 수정하십시오. API 문서와 예시 응답도 새 중첩 구조와 필드 삭제를 반영하고 프론트 소비자 변경을
배포에 맞추십시오.

Comment on lines +103 to +106
} catch (DataIntegrityViolationException e) {
// 동시 요청으로 같은 조합이 먼저 저장된 경우(uk_drug_conflict_senior_med1_med2). 정합성은 제약이 보장하므로 무시.
// 동시 요청으로 같은 조합이 먼저 INSERT된 레이스(uk_drug_conflict_senior_med1_med2).
// 정합성은 UNIQUE 제약이 보장하므로 이번 요청은 저장을 건너뛴다(삼중 방어 유지).
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect the declared JPA/Hibernate versions and the current transaction path.
fd -HI '^(pom\.xml|build\.gradle|build\.gradle\.kts)$' . -x sh -c \
  'echo "--- $1"; rg -n -C 2 "spring-boot|spring-data-jpa|hibernate" "$1"' sh {} || true

ast-grep outline \
  src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java \
  --view expanded

rg -n -C 4 \
  'DataIntegrityViolationException|saveAndFlush|flush\(|`@Transactional`|findBySenior_IdAndMedication1_IdAndMedication2_Id' \
  src/main/java src/test/java

Repository: PIUDAProject/Backend

Length of output: 37173


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "--- drug-conflict service"
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java

echo "--- drug-conflict entity"
fd -HI 'DrugConflict\.java$' src/main/java -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}

echo "--- build files"
cat -n build.gradle | sed -n '1,120p'
fd -HI '^(gradle\.properties|gradle-wrapper\.properties|libs\.versions\.toml)$' . -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}

Repository: PIUDAProject/Backend

Length of output: 12771


🌐 Web query:

Hibernate JPA data integrity violation unique constraint flush exception isRollbackOnly transaction rollback only

💡 Result:

In Hibernate and JPA, a data integrity violation—such as a unique constraint failure—typically occurs during the flush process, when the persistence context synchronizes its state with the database [1][2]. Because this violation puts the persistence context into an inconsistent or potentially corrupted state, Hibernate marks the current transaction as "rollback-only" [3]. When a transaction is marked as rollback-only, any attempt to commit it will fail, usually resulting in a javax.persistence.RollbackException or Spring's UnexpectedRollbackException [4][5][3]. Even if you catch the underlying DataIntegrityViolationException within your code, you cannot "save" the transaction; it is already doomed to be rolled back by the transaction manager [5][3]. Key Technical Realities: 1. Persistence Context Inconsistency: When a flush fails due to a constraint violation, Hibernate cannot guarantee the integrity of the session [3]. Consequently, the session is invalidated for the remainder of the transaction, and any subsequent attempts to use it will likely fail [3]. 2. Deferred Execution: Hibernate often delays SQL execution until a flush is triggered (e.g., at commit, during a query, or via a manual flush) [2]. This is why the exception is often thrown at the end of a method rather than the specific line of code that triggered the data change [4][6]. 3. Handling the Exception: - You cannot simply catch the exception to continue the same transaction [5][3]. - If you need to handle duplicate entries or constraint violations gracefully, the standard approach is to perform a check (e.g., repository.existsBy...) before attempting the operation [7]. - If you must attempt an operation that might fail, you can isolate it by using a separate transaction with propagation set to Propagation.REQUIRES_NEW [3]. This ensures the failure only affects the isolated transaction and does not mark the main transaction as rollback-only [3]. If you encounter the isRollbackOnly issue, it confirms that your application attempted to commit a transaction that encountered an unrecoverable error during the Hibernate flush phase [8][3]. To resolve this, redesign your logic to avoid relying on exceptions for flow control, or isolate risky operations into independent transactions [3][7].

Citations:


🌐 Web query:

Hibernate UNIQUE constraint violation unique key insert select insert exception

💡 Result:

A ConstraintViolationException occurs in Hibernate when a database operation violates a defined integrity constraint, such as a UNIQUE or PRIMARY KEY constraint [1][2]. Because Hibernate sessions are generally invalidated upon encountering such exceptions, the current transaction must be rolled back [3][4][5]. To handle scenarios involving potential unique constraint violations, consider the following approaches: 1. Use Upsert Logic (Recommended) The most robust solution to avoid duplicate key exceptions is to perform an atomic upsert operation rather than a select-then-insert pattern [6][4]. Since Hibernate 6.5, you can use the ON CONFLICT DO clause in JPQL/Criteria queries to handle conflicts in a database-agnostic or database-specific way (e.g., ON DUPLICATE KEY UPDATE in MySQL or MERGE in Oracle) [7]. 2. Defensive Checks You may perform a select query to check for the existence of the entity before attempting an insert [3][8]. Note that this is not atomic and can still fail due to race conditions in high-concurrency environments; using a transaction with an appropriate isolation level is necessary to mitigate this [3][4]. 3. Exception Handling If you must catch the exception, be aware of the following: - JPA often wraps the Hibernate ConstraintViolationException inside a PersistenceException [5]. - Because the session is invalidated, you cannot simply swallow the exception and continue using the same session [3][4]. You must rollback the transaction and, if necessary, start a new one to proceed [4]. - You can inspect the exception to identify the specific constraint violated using the getConstraintName and getKind methods, provided the database driver reports this information reliably [1][9][2]. Avoid the anti-pattern of relying solely on catching exceptions to control business logic, as it can be less performant and harder to maintain [8]. Always ensure that your database sequences or auto-increment configurations are correctly synchronized with existing data to prevent unintended primary key collisions [10][11].

Citations:


🌐 Web query:

MySQL InnoDB UNIQUE constraint violation deadlock duplicate insert wait lock unique index

💡 Result:

In MySQL InnoDB, deadlocks involving unique constraint violations are often caused by the specific way InnoDB handles locking when an INSERT statement encounters a duplicate key [1][2]. When a duplicate-key error occurs, InnoDB typically acquires a shared (S) lock on the existing duplicate index record [1][3]. This behavior is designed to ensure serializability and to guarantee that the information returned to the user—that a duplicate exists—is consistent with the database state at that moment [2]. The deadlock mechanism generally occurs as follows: 1. Multiple sessions attempt to insert the same unique key value [1][2]. 2. If one session already holds an exclusive (X) lock on the record (e.g., due to a prior operation like a DELETE or UPDATE), subsequent sessions attempting the insert will request a shared (S) lock on that same record due to the detected duplicate key [1][2]. 3. When the session holding the exclusive lock rolls back or completes, the queued requests for shared locks are granted [1][2]. 4. If multiple sessions now hold a shared lock and subsequently attempt to promote those locks to exclusive locks (or perform other operations requiring exclusive access) on the same record, they can deadlock because each session is waiting for the others to release their shared locks [1][2]. Additional factors that can contribute to these deadlocks include: - Next-Key Locking: InnoDB may apply next-key locks (a combination of a record lock and a gap lock) rather than simple record locks, which can block concurrent inserts in surrounding gaps, increasing the surface area for contention [1][4][5]. - Gap Locking with Multiple Unique Keys: When tables have multiple unique indexes, a duplicate key error on one index may trigger gap locks on the primary key or other secondary indexes to maintain consistency, further limiting concurrency [5][6]. - INSERT... ON DUPLICATE KEY UPDATE: While this statement behaves differently by acquiring exclusive locks rather than shared locks upon a collision, complex interaction with concurrent standard INSERT operations can still lead to deadlocks [1][7]. These behaviors are inherent to InnoDB's pessimistic locking approach to ensure data integrity and ACID compliance, particularly under high concurrency [8]. They are generally not considered bugs but expected behavior within the engine's locking architecture [5][8]. To mitigate these issues, it is recommended to ensure application code is prepared to handle deadlock errors (e.g., via retries) and to optimize access patterns to avoid frequent collisions on the same unique keys [7][6].

Citations:


🌐 Web query:

MySQL 2024-2025 duplicate key insert lock lock wait timeout gap lock concurrent insert unique index

💡 Result:

In MySQL's InnoDB storage engine, a lock wait timeout during concurrent INSERT operations involving unique indexes is often caused by the locking behavior triggered by duplicate-key errors [1][2][3][4]. When a session attempts an INSERT and encounters a duplicate-key error (due to a violation of a UNIQUE or PRIMARY KEY constraint), InnoDB sets a lock on the existing duplicate index record [5][3]. For simple INSERT statements, this is typically a shared (S) lock, whereas INSERT... ON DUPLICATE KEY UPDATE sets an exclusive (X) lock [5][1]. The key technical factors causing lock wait timeouts in concurrent environments include: 1. Next-Key Locking and Gap Locks: While a standard INSERT primarily uses insert intention locks (which allow concurrency), a duplicate-key error can cause InnoDB to escalate to a next-key lock (index record lock plus a gap lock) on the duplicate record [5][1]. This gap lock prevents other sessions from inserting into the gap before the record, potentially blocking unrelated concurrent inserts [6][3][4]. 2. Tables with Multiple Unique Indexes: When a table has multiple unique indexes, an insert might successfully pass one index check but trigger a duplicate-key error on another [2]. This behavior can result in unexpected gap locks on indexes that were not intended to be locked, leading to wide-ranging blocking of other INSERT operations and subsequent lock wait timeouts for unrelated transactions [2][3][4]. 3. Deadlocks and Lock Wait Timeouts: If multiple sessions attempt to insert the same duplicate value simultaneously, they may all request locks on the same record [5]. If one session holds an exclusive lock and others request shared locks (or vice-versa), it can lead to deadlocks or prolonged waiting periods that exceed the innodb_lock_wait_timeout setting [5][1][4]. To mitigate these issues, it is generally recommended to avoid using ON DUPLICATE KEY UPDATE on tables with multiple unique indexes [2]. Furthermore, because gap locks are necessary for maintaining data integrity (specifically to prevent phantom rows and enforce uniqueness constraints in transactional isolation), they cannot be entirely disabled [3][7][8]. If you encounter frequent lock wait timeouts, analyze your transaction patterns to reduce the time that locks are held and consider whether your application logic can be structured to minimize contention on highly active unique indexes [4][9].

Citations:


동시 INSERT 충돌을 현재 트랜잭션에서 무시하지 마십시오.

@Transactional 메서드에서 발생한 DataIntegrityViolationException은 일반적으로 트랜잭션을 rollback-only로 만들고, 이후 commitUnexpectedRollbackException으로 실패할 수 있습니다. duplicate row가 commit되면 severity/conflictDescription도 현재 요청의 최신 결과로 갱신되지 않습니다. DB 원자적 upsert 또는 REQUIRES_NEW 단위로 재조회 후 갱신하는 별도 트랜잭션 흐름으로 변경하고, 두 트랜잭션에서 같은 약물 쌍을 동시 분석하는 통합 케이스를 추가하십시오.

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

In
`@src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java`
around lines 103 - 106, Update the DataIntegrityViolationException handling in
DrugConflictCommandService so concurrent drug-pair inserts are not swallowed
within the surrounding `@Transactional` transaction; use an atomic database upsert
or a separate REQUIRES_NEW transaction that re-reads the duplicate row and
updates severity and conflictDescription. Add an integration test covering two
transactions analyzing the same drug pair concurrently and verifying the
committed row contains the latest analysis result without
UnexpectedRollbackException.

@neibler
neibler merged commit 0551851 into develop Aug 10, 2026
1 check 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.

1 participant