-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 약 소프트 삭제 도입 및 약물 충돌 정합성 정비 #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,43 +1,62 @@ | ||
| package com.piuda.callcare.domain.drugconflict.converter; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import com.piuda.callcare.domain.drugconflict.dto.response.ConflictDrug; | ||
| import com.piuda.callcare.domain.drugconflict.dto.response.DrugConflictDetailResponse; | ||
| import com.piuda.callcare.domain.drugconflict.dto.response.DrugConflictResponse; | ||
| import com.piuda.callcare.domain.drugconflict.entity.DrugConflict; | ||
| import com.piuda.callcare.domain.medication.entity.Medication; | ||
|
|
||
| @Component | ||
| public class DrugConflictConverter { | ||
|
|
||
| // DrugConflict → DrugConflictResponse (목록 카드용) | ||
| // 카드 표시값(제품종류·제품명·처방기관·처방날짜)은 저장하지 않고 Medication에서 조합한다. | ||
| public DrugConflictResponse toResponse(DrugConflict conflict) { | ||
| return new DrugConflictResponse( | ||
| conflict.getId(), | ||
| conflict.getMedication1().getId(), | ||
| conflict.getMedication1().getDrugName(), | ||
| conflict.getMedication2().getId(), | ||
| conflict.getMedication2().getDrugName(), | ||
| toConflictDrug(conflict.getMedication1()), | ||
| toConflictDrug(conflict.getMedication2()), | ||
| conflict.getSeverity(), | ||
| conflict.getSeverity().getLabel(), | ||
| conflict.getIsResolved() | ||
| ); | ||
| } | ||
|
|
||
| // DrugConflict → DrugConflictDetailResponse (상세 조회용) | ||
| // 약 정보는 목록과 동일한 ConflictDrug로 채워 두 응답의 형태를 맞춘다. | ||
| public DrugConflictDetailResponse toDetail(DrugConflict conflict) { | ||
| return new DrugConflictDetailResponse( | ||
| conflict.getId(), | ||
| conflict.getMedication1().getId(), | ||
| conflict.getMedication1().getDrugName(), | ||
| conflict.getMedication1().getDrugNickname(), | ||
| conflict.getMedication2().getId(), | ||
| conflict.getMedication2().getDrugName(), | ||
| conflict.getMedication2().getDrugNickname(), | ||
| toConflictDrug(conflict.getMedication1()), | ||
| toConflictDrug(conflict.getMedication2()), | ||
| conflict.getSeverity(), | ||
| conflict.getSeverity().getLabel(), | ||
| conflict.getConflictDescription(), | ||
| conflict.getIsResolved(), | ||
| conflict.getCreatedAt() | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Medication → ConflictDrug (목록·상세에 표시할 약 정보 합성) | ||
| private ConflictDrug toConflictDrug(Medication medication) { | ||
| return new ConflictDrug( | ||
| medication.getId(), | ||
| medication.getDrugType(), | ||
| medication.getDrugName(), | ||
| medication.getDrugNickname(), | ||
| medication.getHospitalName(), | ||
| resolvePrescriptionDate(medication) | ||
| ); | ||
| } | ||
|
|
||
| // 처방 날짜 폴백: prescription_date(OCR) → 없으면 start_date(사용자 입력) | ||
| private LocalDate resolvePrescriptionDate(Medication medication) { | ||
| return medication.getPrescriptionDate() != null | ||
| ? medication.getPrescriptionDate() | ||
| : medication.getStartDate(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package com.piuda.callcare.domain.drugconflict.dto.response; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| // 충돌에 관련된 약 한 알의 표시 정보. 목록 카드와 상세가 같은 구조를 공유한다. | ||
| // 저장하지 않고 조회 시 Medication에서 조합한다(파생값 비저장 원칙). | ||
| @Schema(description = "충돌에 관련된 약 정보") | ||
| public record ConflictDrug( | ||
|
|
||
| @Schema(description = "약(medication) ID") Long medicationId, | ||
| @Schema(description = "제품 종류 (drug_type)") String drugType, | ||
| @Schema(description = "제품명 (drug_name)") String drugName, | ||
| @Schema(description = "약 별명 (drug_nickname)") String drugNickname, | ||
| @Schema(description = "처방 기관 (hospital_name)") String hospitalName, | ||
| @Schema(description = "처방 날짜 (prescription_date, 없으면 start_date로 폴백)") LocalDate prescriptionDate | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,10 @@ | ||
| package com.piuda.callcare.domain.drugconflict.service.command; | ||
|
|
||
| import java.util.HashSet; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
| import java.util.Set; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| import org.springframework.dao.DataIntegrityViolationException; | ||
| import org.springframework.stereotype.Service; | ||
|
|
@@ -38,25 +42,53 @@ public void analyze(Long seniorId) { | |
| // TODO: 인증 도입 후 seniorId 소유권 검증 추가 | ||
|
|
||
| List<Medication> medications = medicationRepository.findActiveWithDrugInfoBySeniorId(seniorId); | ||
| Set<MedicationPair> matchedPairs = new HashSet<>(); | ||
|
|
||
| for (int i = 0; i < medications.size(); i++) { | ||
| for (int j = i + 1; j < medications.size(); j++) { | ||
| Medication a = medications.get(i); | ||
| Medication b = medications.get(j); | ||
| drugConflictMatcher.match(a.getDrugInfo(), b.getDrugInfo()) | ||
| .ifPresent(match -> saveIfAbsent(senior, a, b, match)); | ||
| .ifPresent(match -> { | ||
| saveOrUpdate(senior, a, b, match); | ||
| matchedPairs.add(MedicationPair.of(a, b)); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| deleteStaleConflicts(seniorId, medications, matchedPairs); | ||
| } | ||
|
|
||
| // 이번 분석에서 다시 매칭되지 않은 기존 충돌을 제거 — 약 정보가 바뀌어 더 이상 충돌이 아닌 쌍이 | ||
| // 옛 등급으로 목록에 남는 것을 막는다(upsert만으로는 사라진 충돌을 정리할 수 없다). | ||
| // 삭제 범위는 이번 분석 대상(활성·미삭제) 약들로만 이뤄진 쌍에 한정한다 — | ||
| // 비활성·삭제된 약이 낀 행은 애초에 매칭 대상이 아니었을 뿐이므로 지우면 분석 이력이 사라진다. | ||
| private void deleteStaleConflicts(Long seniorId, List<Medication> analyzed, Set<MedicationPair> matchedPairs) { | ||
| Set<Long> analyzedIds = analyzed.stream().map(Medication::getId).collect(Collectors.toSet()); | ||
|
|
||
| List<DrugConflict> stale = drugConflictRepository.findAllWithMedicationsForReanalysis(seniorId).stream() | ||
| .filter(conflict -> analyzedIds.contains(conflict.getMedication1().getId()) | ||
| && analyzedIds.contains(conflict.getMedication2().getId())) | ||
| .filter(conflict -> !matchedPairs.contains(MedicationPair.of( | ||
| conflict.getMedication1(), conflict.getMedication2()))) | ||
| .toList(); | ||
|
|
||
| if (!stale.isEmpty()) { | ||
| drugConflictRepository.deleteAll(stale); | ||
| } | ||
| } | ||
|
|
||
| // (medication_id_1 < medication_id_2)로 정규화해 순서 무관 중복 저장을 방지(find→분기, 2단계 upsert 패턴). | ||
| private void saveIfAbsent(Senior senior, Medication x, Medication y, ConflictMatch match) { | ||
| // (medication_id_1 < medication_id_2)로 정규화해 순서 무관 중복을 방지하고, | ||
| // 기존 쌍이 있으면 최신 분석 결과로 upsert(값이 실제로 바뀐 경우만 UPDATE). 없으면 신규 저장. | ||
| private void saveOrUpdate(Senior senior, Medication x, Medication y, ConflictMatch match) { | ||
| Medication first = x.getId() < y.getId() ? x : y; | ||
| Medication second = x.getId() < y.getId() ? y : x; | ||
|
|
||
| boolean exists = drugConflictRepository.existsBySenior_IdAndMedication1_IdAndMedication2_Id( | ||
| Optional<DrugConflict> existing = drugConflictRepository.findBySenior_IdAndMedication1_IdAndMedication2_Id( | ||
| senior.getId(), first.getId(), second.getId()); | ||
| if (exists) { | ||
| if (existing.isPresent()) { | ||
| // 이미 커밋된 쌍 → 최신 결과로 갱신(dirty checking). 값이 같으면 updateAnalysis가 UPDATE를 생략한다. | ||
| existing.get().updateAnalysis(match.severity(), match.description()); | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -69,7 +101,18 @@ private void saveIfAbsent(Senior senior, Medication x, Medication y, ConflictMat | |
| .conflictDescription(match.description()) | ||
| .build()); | ||
| } catch (DataIntegrityViolationException e) { | ||
| // 동시 요청으로 같은 조합이 먼저 저장된 경우(uk_drug_conflict_senior_med1_med2). 정합성은 제약이 보장하므로 무시. | ||
| // 동시 요청으로 같은 조합이 먼저 INSERT된 레이스(uk_drug_conflict_senior_med1_med2). | ||
| // 정합성은 UNIQUE 제약이 보장하므로 이번 요청은 저장을 건너뛴다(삼중 방어 유지). | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/javaRepository: 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:
💡 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:
💡 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:
💡 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:
💡 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 충돌을 현재 트랜잭션에서 무시하지 마십시오.
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // 순서 무관 비교용 약 쌍 키. 저장 정규화 규칙과 동일하게 (작은 id, 큰 id)로 맞춘다. | ||
| private record MedicationPair(Long first, Long second) { | ||
|
|
||
| static MedicationPair of(Medication x, Medication y) { | ||
| return x.getId() < y.getId() | ||
| ? new MedicationPair(x.getId(), y.getId()) | ||
| : new MedicationPair(y.getId(), x.getId()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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:
Repository: PIUDAProject/Backend
Length of output: 41576
🏁 Script executed:
Repository: PIUDAProject/Backend
Length of output: 249
🏁 Script executed:
Repository: PIUDAProject/Backend
Length of output: 6127
외부 API 응답 계약 변경을 배포 전에 확인하십시오.
약물 필드가
drug1/drug2중첩 객체로 바뀌었습니다. 기존 클라이언트는drug1Name,drug2Name같은 평탄 필드를 더 읽지 못하므로 렌더링 오류가 발생할 수 있습니다.GET /api/conflicts와/api/conflicts/{conflictId}응답을 새 중첩 JSON 구조로 매핑하는 프론트 변경을 배포와 맞추십시오.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-L18src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java#L12-L22src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java#L18-L60🤖 Prompt for AI Agents