증상 생성/수정 후 Gemini 기반 패턴 분석을 비동기로 갱신하고 있었다.
기존 흐름은 다음과 같았다.
@Async("analysisTaskExecutor")
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun refreshAsync(symptomId: String, userId: Long) {
val symptom = symptomRepository.findByExternalIdAndUser_Id(symptomExternalId, userId) ?: return
if (!symptom.isAnalysisDirty) return
val result = symptomPatternAnalysisService.generate(symptom, userId)
if (result.shouldUpdate) {
symptom.updateAnalysis(result.analysisJson)
}
}
이 구조에는 두 가지 문제가 있었다.
요구사항 : 사용자가 증상 생성 시 증상에 대한 분석을 생성해야 한다. 결과는 anslysisJson이라는 컬럼으로 저장되며, 응답을 내려줄 때는 dto로 매핑한다.
예를 들어 사용자가 증상을 생성하면 분석 작업 A가 시작되고, 바로 메모를 수정하면 분석 작업 B가 다시 시작된다.
B가 먼저 끝나 최신 데이터 기준 분석을 저장했더라도, A가 늦게 끝나면 과거 데이터 기준 분석으로 analysisJson을 다시 덮어쓸 수 있었다.
비동기 작업마다 “이 분석이 어떤 시점의 증상 상태를 기준으로 만든 결과인지”를 검증하지 않았다.
즉, 작업 시작 시점의 증상 상태와 결과 저장 시점의 증상 상태가 같은지 확인하는 장치가 없었다.
또한 @Transactional(REQUIRES_NEW)가 refresh 전체에 걸려 있어, DB 조회뿐 아니라 Gemini 외부 호출 중에도 트랜잭션이 유지될 수 있었다.
Symptom에 분석 전용 버전 필드인 analysisVersion을 추가했다.
증상 내용이 바뀌어 분석이 다시 필요해질 때마다 버전을 증가시킨다.
@Column(name = "analysis_version", nullable = false, columnDefinition = "bigint default 0")
var analysisVersion: Long = analysisVersion
protected set
fun markAnalysisDirty() {
isAnalysisDirty = true
analysisVersion += 1
}
분석 결과를 저장할 때는 작업 시작 시점의 버전과 현재 버전이 같은 경우에만 반영한다.
fun updateAnalysis(analysisJson: String, expectedVersion: Long): Boolean {
if (!isAnalysisDirty || analysisVersion != expectedVersion) return false
this.analysisJson = analysisJson
isAnalysisDirty = false
return true
}
비동기 서비스에서는 작업 시작 시점의 버전을 캡처한다.
@Async("analysisTaskExecutor")
fun refreshAsync(symptomId: String, userId: Long) {
val symptom = symptomRepository.findByExternalIdAndUser_Id(symptomExternalId, userId) ?: return
if (!symptom.isAnalysisDirty) return
val expectedVersion = symptom.analysisVersion
val result = symptomPatternAnalysisService.generate(symptom, userId)
if (result.shouldUpdate) {
symptomAnalysisResultWriter.updateIfVersionMatches(
symptomId = symptomId,
userId = userId,
expectedVersion = expectedVersion,
analysisJson = result.analysisJson,
)
}
}
결과 저장은 별도 writer에서 짧은 쓰기 트랜잭션으로 처리했다.
@Service
class SymptomAnalysisResultWriter(
private val symptomRepository: SymptomRepository,
) {
@Transactional
fun updateIfVersionMatches(
symptomId: String,
userId: Long,
expectedVersion: Long,
analysisJson: String,
): Boolean {
val symptomExternalId = runCatching { UUID.fromString(symptomId) }.getOrNull() ?: return false
val symptom = symptomRepository.findByExternalIdAndUser_Id(symptomExternalId, userId) ?: return false
return symptom.updateAnalysis(analysisJson, expectedVersion)
}
}
오래된 비동기 작업이 늦게 끝나더라도, 그 사이 증상이 수정되어 analysisVersion이 증가했다면 결과를 저장하지 않는다.
즉, 최신 증상 상태에 기반한 분석만 반영된다.
또한 Gemini 호출을 감싼 긴 쓰기 트랜잭션을 제거하고, 최종 저장 시점에만 짧은 트랜잭션을 사용하도록 분리했다.
오래된 분석 결과가 버려지는 테스트를 추가했다.
@Test
fun `작업 시작 이후 증상이 바뀌어 버전이 다르면 오래된 분석 결과를 버린다`() {
val symptom = SymptomFixture.symptom(isAnalysisDirty = true, analysisVersion = 3L)
whenever(symptomRepository.findByExternalIdAndUser_Id(SymptomFixture.SYMPTOM_EXTERNAL_ID, userId))
.thenReturn(symptom)
val updated = writer.updateIfVersionMatches(
symptomId = SymptomFixture.SYMPTOM_EXTERNAL_ID.toString(),
userId = userId,
expectedVersion = 2L,
analysisJson = """{"label":"오래된 결과"}""",
)
assertThat(updated).isFalse()
assertThat(symptom.analysisJson).isNull()
assertThat(symptom.isAnalysisDirty).isTrue()
assertThat(symptom.analysisVersion).isEqualTo(3L)
}