From 41a32b67a63f9c72abdd4c85f892bcce879ae92c Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 15:23:19 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(pesticide):=20PSIS=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=8C=8C=EC=84=9C=EC=97=90=20resultCode/resultMsg/?= =?UTF-8?q?totalCount=20=EB=B4=89=ED=88=AC=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data.go.kr류 API는 HTTP 200에 에러 resultCode를 담아 응답할 수 있어, 이후 동기화 로직이 에러를 감지하려면 item 목록뿐 아니라 응답 봉투(header/body) 정보가 필요하다. parseEnvelope를 추가하고 기존 parse는 이를 재사용하도록 정리한다. Co-Authored-By: Claude Opus 4.8 --- .../pesticide/sync/PsisPesticideEnvelope.kt | 8 +++ .../sync/PsisPesticideResponseParser.kt | 24 ++++++-- .../sync/PsisPesticideResponseParserTest.kt | 61 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideEnvelope.kt diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideEnvelope.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideEnvelope.kt new file mode 100644 index 00000000..e6ac8b0e --- /dev/null +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideEnvelope.kt @@ -0,0 +1,8 @@ +package com.chamchamcham.application.pesticide.sync + +data class PsisPesticideEnvelope( + val resultCode: String?, + val resultMsg: String?, + val totalCount: Int?, + val items: List>, +) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt index e5eebed1..27f2b2dc 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt @@ -14,7 +14,9 @@ import javax.xml.parsers.DocumentBuilderFactory */ @Component class PsisPesticideResponseParser { - fun parse(xml: String): List> { + fun parse(xml: String): List> = parseEnvelope(xml).items + + fun parseEnvelope(xml: String): PsisPesticideEnvelope { val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = false setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) @@ -25,14 +27,28 @@ class PsisPesticideResponseParser { setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") } val document = factory.newDocumentBuilder().parse(InputSource(StringReader(xml))) - val itemNodes = document.getElementsByTagName("item") - return (0 until itemNodes.length).map { index -> - val itemElement = itemNodes.item(index) as Element + val items = (0 until document.getElementsByTagName("item").length).map { index -> + val itemElement = document.getElementsByTagName("item").item(index) as Element val children = itemElement.childNodes (0 until children.length) .mapNotNull { children.item(it) as? Element } .associate { it.tagName to it.textContent.trim() } } + + return PsisPesticideEnvelope( + resultCode = firstTagText(document, "resultCode"), + resultMsg = firstTagText(document, "resultMsg"), + totalCount = firstTagText(document, "totalCount")?.toIntOrNull(), + items = items, + ) + } + + private fun firstTagText(document: org.w3c.dom.Document, tagName: String): String? { + val nodes = document.getElementsByTagName(tagName) + if (nodes.length == 0) { + return null + } + return (nodes.item(0) as Element).textContent.trim() } } diff --git a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParserTest.kt b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParserTest.kt index 816f39c7..5bc833f4 100644 --- a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParserTest.kt +++ b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParserTest.kt @@ -47,4 +47,65 @@ class PsisPesticideResponseParserTest { org.junit.jupiter.api.Assertions.assertThrows(Exception::class.java) { parser.parse(xml) } } + + @Test + fun `parseEnvelope extracts resultCode, resultMsg, totalCount and items from an error envelope`() { + val xml = """ + +
+ 03 + NODATA_ERROR +
+ + + 0 + +
+ """.trimIndent() + + val envelope = parser.parseEnvelope(xml) + + assertThat(envelope.resultCode).isEqualTo("03") + assertThat(envelope.resultMsg).isEqualTo("NODATA_ERROR") + assertThat(envelope.totalCount).isEqualTo(0) + assertThat(envelope.items).isEmpty() + } + + @Test + fun `parseEnvelope reads totalCount and items from a success envelope`() { + val xml = """ + +
+ 00 + NORMAL_SERVICE +
+ + + + 감자 + + + 1 + +
+ """.trimIndent() + + val envelope = parser.parseEnvelope(xml) + + assertThat(envelope.resultCode).isEqualTo("00") + assertThat(envelope.totalCount).isEqualTo(1) + assertThat(envelope.items).hasSize(1) + } + + @Test + fun `parseEnvelope leaves totalCount null when the tag is absent`() { + val xml = "" + + val envelope = parser.parseEnvelope(xml) + + assertThat(envelope.resultCode).isNull() + assertThat(envelope.resultMsg).isNull() + assertThat(envelope.totalCount).isNull() + assertThat(envelope.items).isEmpty() + } } From b48023ee90fcc110ce698f599d7b32e534d87683 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 15:24:43 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(pesticide):=20PSIS=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=EA=B0=80=20=EC=97=85=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=97=90=EB=9F=AC=20resultCode=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=A6=89=EC=8B=9C=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data.go.kr류 API는 HTTP 200 + 에러 resultCode + 빈 item으로 응답할 수 있는데, 기존 로직은 이를 "빈 페이지 = 동기화 완료"로 오인해 0건 동기화를 성공으로 보고했다. resultCode가 성공 코드("00"/"0")가 아니면 BusinessException(PESTICIDE_SYNC_FAILED)로 즉시 실패시켜 운영자가 키/URL 설정 문제를 바로 알 수 있게 한다. resultCode 태그 자체가 없는 기존 테스트 픽스처는 하위호환을 위해 그대로 통과한다. Co-Authored-By: Claude Opus 4.8 --- .../application/exception/ErrorCode.kt | 1 + .../pesticide/sync/PesticideSyncService.kt | 18 ++++++++++++- .../sync/PesticideSyncServiceTest.kt | 27 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/exception/ErrorCode.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/exception/ErrorCode.kt index f3e9888b..c314a5cf 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/exception/ErrorCode.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/exception/ErrorCode.kt @@ -39,6 +39,7 @@ enum class ErrorCode( FARMING_RECORD_TOO_MANY_IMAGES("FARMING_005", "error.farming_record_too_many_images", 400), PESTICIDE_NOT_FOUND("PESTICIDE_001", "error.pesticide_not_found", 404), PEST_NOT_FOUND("PESTICIDE_002", "error.pest_not_found", 404), + PESTICIDE_SYNC_FAILED("PESTICIDE_003", "error.pesticide_sync_failed", 502), VOICE_SESSION_NOT_FOUND("VOICE_001", "error.voice_session_not_found", 404), VOICE_SESSION_INVALID_STATE("VOICE_002", "error.voice_session_invalid_state", 409), VOICE_SESSION_PROVIDER_UNAVAILABLE("VOICE_003", "error.voice_session_provider_unavailable", 503), diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt index 659dbdcf..05e39e04 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt @@ -1,5 +1,7 @@ package com.chamchamcham.application.pesticide.sync +import com.chamchamcham.application.exception.ErrorCode +import com.chamchamcham.application.exception.business.BusinessException import com.chamchamcham.domain.pesticide.Pest import com.chamchamcham.domain.pesticide.PestRepository import com.chamchamcham.domain.pesticide.Pesticide @@ -41,7 +43,9 @@ class PesticideSyncService( "type" to "xml", ) ) - val rawRows = responseParser.parse(body) + val envelope = responseParser.parseEnvelope(body) + failOnUpstreamError(envelope) + val rawRows = envelope.items if (rawRows.isEmpty()) { break } @@ -64,6 +68,17 @@ class PesticideSyncService( ) } + private fun failOnUpstreamError(envelope: PsisPesticideEnvelope) { + val resultCode = envelope.resultCode?.trim() ?: return + if (resultCode in SUCCESS_RESULT_CODES) { + return + } + throw BusinessException( + ErrorCode.PESTICIDE_SYNC_FAILED, + detail = mapOf("resultCode" to resultCode, "resultMsg" to envelope.resultMsg), + ) + } + private fun upsertRows(rows: List): Int { var created = 0 rows.forEach { row -> @@ -126,5 +141,6 @@ class PesticideSyncService( private companion object { const val DEFAULT_PAGE_SIZE = 1000 + val SUCCESS_RESULT_CODES = setOf("00", "0") } } diff --git a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt index cf0cbbc2..9e084da9 100644 --- a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt +++ b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt @@ -1,5 +1,7 @@ package com.chamchamcham.application.pesticide.sync +import com.chamchamcham.application.exception.ErrorCode +import com.chamchamcham.application.exception.business.BusinessException import com.chamchamcham.domain.pesticide.Pest import com.chamchamcham.domain.pesticide.PestRepository import com.chamchamcham.domain.pesticide.Pesticide @@ -7,6 +9,7 @@ import com.chamchamcham.domain.pesticide.PesticideApplication import com.chamchamcham.domain.pesticide.PesticideApplicationRepository import com.chamchamcham.domain.pesticide.PesticideRepository import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -124,6 +127,30 @@ class PesticideSyncServiceTest { assertEquals(0, secondResult.createdApplicationCount) } + @Test + fun `throws when upstream responds with an error resultCode`() { + `when`(transport.get(anyMap())).thenReturn(errorEnvelopeXml()) + + val exception = assertThrows(BusinessException::class.java) { + service.sync(pageSize = 100) + } + + assertEquals(ErrorCode.PESTICIDE_SYNC_FAILED, exception.errorCode) + } + + private fun errorEnvelopeXml(): String = """ + +
+ 03 + NODATA_ERROR +
+ + + 0 + +
+ """.trimIndent() + private fun oneRowPageXml(): String = """ From 29d3cb0c1a425670d2c81dc4de43a9ffd319d442 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 15:24:55 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat(pesticide):=20RowMapper=EC=97=90=20?= =?UTF-8?q?=ED=95=84=EC=88=98=20=ED=95=84=EB=93=9C=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diagnoseRequired가 itemName/cropName/pestName 각각이 실제 raw row에서 해석됐는지 여부를 반환한다. 이후 프로브 기능이 실응답을 받았을 때 어떤 필드의 후보 태그명이 틀렸는지 바로 알려주는 데 재사용한다. Co-Authored-By: Claude Opus 4.8 --- .../pesticide/sync/PsisPesticideRowMapper.kt | 6 +++++ .../sync/PsisPesticideRowMapperTest.kt | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapper.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapper.kt index b2fadc1f..38324712 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapper.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapper.kt @@ -33,6 +33,12 @@ class PsisPesticideRowMapper { ) } + fun diagnoseRequired(raw: Map): Map = mapOf( + "itemName" to (raw.firstNotBlank(ITEM_NAME_KEYS) != null), + "cropName" to (raw.firstNotBlank(CROP_NAME_KEYS) != null), + "pestName" to (raw.firstNotBlank(PEST_NAME_KEYS) != null), + ) + private fun Map.firstNotBlank(keys: List): String? = keys.firstNotNullOfOrNull { this[it]?.trim()?.takeIf(String::isNotEmpty) } diff --git a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapperTest.kt b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapperTest.kt index 7b5091e6..ee733f5b 100644 --- a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapperTest.kt +++ b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapperTest.kt @@ -43,4 +43,29 @@ class PsisPesticideRowMapperTest { assertNull(mapper.map(raw)) } + + @Test + fun `diagnoseRequired resolves all required fields for a well-formed row`() { + val raw = mapOf( + "prdtNm" to "만코제브 수화제", + "cropNm" to "감자", + "aplyPestNm" to "역병", + ) + + val diagnosis = mapper.diagnoseRequired(raw) + + assertEquals(mapOf("itemName" to true, "cropName" to true, "pestName" to true), diagnosis) + } + + @Test + fun `diagnoseRequired marks a field false when its tag is renamed or absent`() { + val raw = mapOf( + "prdtNm" to "만코제브 수화제", + "cropNm" to "감자", + ) + + val diagnosis = mapper.diagnoseRequired(raw) + + assertEquals(mapOf("itemName" to true, "cropName" to true, "pestName" to false), diagnosis) + } } From 236082135450f1e4fa3246be0e7d684158e11658 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 15:25:42 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat(pesticide):=20DB=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=97=86=EC=9D=B4=20PSIS=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=ED=99=95=EC=9D=B8=ED=95=98=EB=8A=94=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EB=B8=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PesticideSyncService.probe가 1페이지(기본 10건)만 조회해 resultCode/ totalCount/실제 태그명/필수 필드 매핑 결과/샘플 매핑 결과를 반환한다. 전량 동기화 전에 필드 태그 매핑과 데이터 규모를 안전하게 검증할 수 있다. sync와 동일한 업스트림 에러 처리 규칙을 적용하며 DB에는 쓰지 않는다. Co-Authored-By: Claude Opus 4.8 --- .../pesticide/sync/PesticideProbeResult.kt | 12 +++++++++ .../pesticide/sync/PesticideSyncService.kt | 25 +++++++++++++++++ .../sync/PesticideSyncServiceTest.kt | 27 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideProbeResult.kt diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideProbeResult.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideProbeResult.kt new file mode 100644 index 00000000..68d4cb4e --- /dev/null +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideProbeResult.kt @@ -0,0 +1,12 @@ +package com.chamchamcham.application.pesticide.sync + +data class PesticideProbeResult( + val resultCode: String?, + val resultMsg: String?, + val totalCount: Int?, + val itemCount: Int, + val distinctTagNames: List, + val sampleRawItem: Map?, + val requiredKeyResolution: Map, + val mapped: PsisPesticideRow?, +) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt index 05e39e04..a88e5e7a 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt @@ -68,6 +68,30 @@ class PesticideSyncService( ) } + fun probe(rows: Int = DEFAULT_PROBE_ROWS): PesticideProbeResult { + val body = transport.get( + mapOf( + "pageNo" to "1", + "numOfRows" to rows.toString(), + "type" to "xml", + ) + ) + val envelope = responseParser.parseEnvelope(body) + failOnUpstreamError(envelope) + + val sampleRawItem = envelope.items.firstOrNull() + return PesticideProbeResult( + resultCode = envelope.resultCode, + resultMsg = envelope.resultMsg, + totalCount = envelope.totalCount, + itemCount = envelope.items.size, + distinctTagNames = envelope.items.flatMap { it.keys }.distinct().sorted(), + sampleRawItem = sampleRawItem, + requiredKeyResolution = sampleRawItem?.let(rowMapper::diagnoseRequired) ?: emptyMap(), + mapped = sampleRawItem?.let(rowMapper::map), + ) + } + private fun failOnUpstreamError(envelope: PsisPesticideEnvelope) { val resultCode = envelope.resultCode?.trim() ?: return if (resultCode in SUCCESS_RESULT_CODES) { @@ -141,6 +165,7 @@ class PesticideSyncService( private companion object { const val DEFAULT_PAGE_SIZE = 1000 + const val DEFAULT_PROBE_ROWS = 10 val SUCCESS_RESULT_CODES = setOf("00", "0") } } diff --git a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt index 9e084da9..b4d4f224 100644 --- a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt +++ b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt @@ -17,6 +17,7 @@ import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyMap import org.mockito.Mock import org.mockito.Mockito.lenient +import org.mockito.Mockito.verifyNoInteractions import org.mockito.Mockito.`when` import org.mockito.junit.jupiter.MockitoExtension import org.springframework.transaction.support.AbstractPlatformTransactionManager @@ -138,6 +139,32 @@ class PesticideSyncServiceTest { assertEquals(ErrorCode.PESTICIDE_SYNC_FAILED, exception.errorCode) } + @Test + fun `probe fetches one page and reports diagnostics without writing to the database`() { + `when`(transport.get(anyMap())).thenReturn(twoRowPageXml()) + + val result = service.probe(rows = 10) + + assertEquals(null, result.totalCount) + assertEquals(2, result.itemCount) + assertEquals(listOf("aplyPestNm", "cropNm", "dltnMag", "prdtNm", "trdmrkNm"), result.distinctTagNames) + val mapped = result.mapped + requireNotNull(mapped) + assertEquals("만코제브 수화제", mapped.itemName) + verifyNoInteractions(pesticideRepository, pestRepository, pesticideApplicationRepository) + } + + @Test + fun `probe throws when upstream responds with an error resultCode`() { + `when`(transport.get(anyMap())).thenReturn(errorEnvelopeXml()) + + val exception = assertThrows(BusinessException::class.java) { + service.probe(rows = 10) + } + + assertEquals(ErrorCode.PESTICIDE_SYNC_FAILED, exception.errorCode) + } + private fun errorEnvelopeXml(): String = """
From b16e50f7b09bfdf73ce4c9aae4b54b7b5322ca4c Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 15:25:53 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat(pesticide):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=94=84=EB=A1=9C=EB=B8=8C=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/admin/pesticide-sync/probe?rows=1..100 을 추가해 전량 동기화 전에 PesticideSyncService.probe 결과를 확인할 수 있게 한다. rows 범위 검증은 컨트롤러 경계에서 수행하고, 범위를 벗어나면 기존 INVALID_INPUT으로 400을 반환한다. Co-Authored-By: Claude Opus 4.8 --- .../AdminPesticideSyncController.kt | 15 ++++ .../api/pesticide/dto/PesticideResponses.kt | 26 ++++++ .../AdminPesticideSyncControllerTest.kt | 86 +++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt diff --git a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncController.kt b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncController.kt index 8533d635..28aba752 100644 --- a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncController.kt +++ b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncController.kt @@ -1,11 +1,15 @@ package com.chamchamcham.api.pesticide.controller import com.chamchamcham.api.common.ApiResponse +import com.chamchamcham.api.pesticide.dto.PesticideResponses.PesticideProbeResponse +import com.chamchamcham.application.exception.ErrorCode +import com.chamchamcham.application.exception.business.BusinessException import com.chamchamcham.application.pesticide.sync.PesticideSyncResult import com.chamchamcham.application.pesticide.sync.PesticideSyncService import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController /** @@ -23,4 +27,15 @@ class AdminPesticideSyncController( val result = pesticideSyncService.sync() return ResponseEntity.ok(ApiResponse.ok(result)) } + + @PostMapping("/probe") + fun probe( + @RequestParam(defaultValue = "10") rows: Int, + ): ResponseEntity> { + if (rows !in 1..100) { + throw BusinessException(ErrorCode.INVALID_INPUT) + } + val result = pesticideSyncService.probe(rows) + return ResponseEntity.ok(ApiResponse.ok(PesticideProbeResponse.from(result))) + } } diff --git a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt index d51d64f3..4b3f248f 100644 --- a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt +++ b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt @@ -1,6 +1,8 @@ package com.chamchamcham.api.pesticide.dto import com.chamchamcham.application.pesticide.PesticideResult +import com.chamchamcham.application.pesticide.sync.PesticideProbeResult +import com.chamchamcham.application.pesticide.sync.PsisPesticideRow import java.util.UUID object PesticideResponses { @@ -45,4 +47,28 @@ object PesticideResponses { ) } } + + data class PesticideProbeResponse( + val resultCode: String?, + val resultMsg: String?, + val totalCount: Int?, + val itemCount: Int, + val distinctTagNames: List, + val sampleRawItem: Map?, + val requiredKeyResolution: Map, + val mapped: PsisPesticideRow?, + ) { + companion object { + fun from(result: PesticideProbeResult): PesticideProbeResponse = PesticideProbeResponse( + resultCode = result.resultCode, + resultMsg = result.resultMsg, + totalCount = result.totalCount, + itemCount = result.itemCount, + distinctTagNames = result.distinctTagNames, + sampleRawItem = result.sampleRawItem, + requiredKeyResolution = result.requiredKeyResolution, + mapped = result.mapped, + ) + } + } } diff --git a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt new file mode 100644 index 00000000..bd2e0c3c --- /dev/null +++ b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt @@ -0,0 +1,86 @@ +package com.chamchamcham.api.pesticide.controller + +import com.chamchamcham.api.exception.GlobalExceptionHandler +import com.chamchamcham.application.pesticide.sync.PesticideProbeResult +import com.chamchamcham.application.pesticide.sync.PesticideSyncService +import com.chamchamcham.application.pesticide.sync.PsisPesticideRow +import com.chamchamcham.application.security.TokenProvider +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.Test +import org.mockito.Mockito.`when` +import org.mockito.Mockito.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(AdminPesticideSyncController::class) +@AutoConfigureMockMvc(addFilters = false) +@Import(GlobalExceptionHandler::class) +class AdminPesticideSyncControllerTest( + @Autowired private val mockMvc: MockMvc +) { + @MockBean + private lateinit var pesticideSyncService: PesticideSyncService + + @MockBean + private lateinit var tokenProvider: TokenProvider + + @Test + fun `probe returns the mocked service result`() { + val mapped = PsisPesticideRow( + itemName = "만코제브 수화제", + brandName = "가가방", + cropName = "감자", + pestName = "역병", + activeIngredient = null, + formulation = null, + usageCategory = null, + humanToxicity = null, + fishToxicity = null, + manufacturer = null, + dilutionRate = "500배", + usageAmount = null, + usageTiming = null, + maxUsageCount = null, + ) + `when`(pesticideSyncService.probe(10)).thenReturn( + PesticideProbeResult( + resultCode = "00", + resultMsg = "NORMAL_SERVICE", + totalCount = 137877, + itemCount = 1, + distinctTagNames = listOf("cropNm", "prdtNm"), + sampleRawItem = mapOf("cropNm" to "감자", "prdtNm" to "만코제브 수화제"), + requiredKeyResolution = mapOf("itemName" to true, "cropName" to true, "pestName" to true), + mapped = mapped, + ) + ) + + mockMvc.perform(post("/api/v1/admin/pesticide-sync/probe")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.resultCode", equalTo("00"))) + .andExpect(jsonPath("$.data.resultMsg", equalTo("NORMAL_SERVICE"))) + .andExpect(jsonPath("$.data.totalCount", equalTo(137877))) + .andExpect(jsonPath("$.data.itemCount", equalTo(1))) + .andExpect(jsonPath("$.data.distinctTagNames[0]", equalTo("cropNm"))) + .andExpect(jsonPath("$.data.requiredKeyResolution.itemName", equalTo(true))) + .andExpect(jsonPath("$.data.mapped.itemName", equalTo("만코제브 수화제"))) + + verify(pesticideSyncService).probe(10) + } + + @Test + fun `probe rejects rows outside 1 to 100`() { + mockMvc.perform(post("/api/v1/admin/pesticide-sync/probe").param("rows", "0")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code", equalTo("COMMON_001"))) + } +} From c9ce1dc41b85344804e6c490fa79a90ddcf767d0 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 15:26:03 +0900 Subject: [PATCH 06/15] =?UTF-8?q?docs(pesticide):=20PSIS=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=ED=99=9C=EC=84=B1=ED=99=94=20=EB=9F=B0?= =?UTF-8?q?=EB=B6=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API 키 발급 완료를 전제로 env 설정, 프로브→매핑 보정→규모 판단→전량 동기화 순서의 절차와 롤백 방법을 정리한다. Co-Authored-By: Claude Opus 4.8 --- backend/docs/pesticide-sync-runbook.md | 68 ++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 backend/docs/pesticide-sync-runbook.md diff --git a/backend/docs/pesticide-sync-runbook.md b/backend/docs/pesticide-sync-runbook.md new file mode 100644 index 00000000..e926caf3 --- /dev/null +++ b/backend/docs/pesticide-sync-runbook.md @@ -0,0 +1,68 @@ +# PSIS 농약등록정보 동기화 런북 + +## 전제 + +PSIS(농약안전정보시스템) 농약등록정보 API 키 발급이 완료된 상태를 전제로 한다. + +## env 설정 + +`api` 모듈의 `application-{profile}.yml`이 다음 환경변수를 읽는다. + +| 환경변수 | 설명 | 기본값 | +| --- | --- | --- | +| `PSIS_PESTICIDE_BASE_URL` | PSIS 마이페이지에 표시되는 실제 요청 URL. 현재 값은 placeholder이므로 발급된 실제 URL로 교체해야 한다. | 없음 (미설정 시 요청 시점에 실패) | +| `PSIS_PESTICIDE_SERVICE_KEY` | 공공데이터포털에서 발급한 인코딩 서비스키. 이중 인코딩 방지를 위해 그대로 전달된다. | 없음 | +| `PSIS_PESTICIDE_TIMEOUT_MILLIS` | HTTP 커넥션/요청 타임아웃(ms). 선택값. | `10000` | + +## Step 1. 프로브 + +앱 기동 후, 전량 동기화 전에 먼저 소량 데이터로 응답 형태를 확인한다. + +```http +POST /api/v1/admin/pesticide-sync/probe?rows=10 +``` + +응답(`ApiResponse`)의 `data`에서 다음을 확인한다. + +- `resultCode`: 성공이면 `"00"`이어야 한다. 그 외 값이면 서비스키/URL 설정 문제일 가능성이 크므로 + `resultMsg`를 참고해 원인을 파악한다. (`resultCode`가 에러 값이면 컨트롤러가 아니라 서비스 단에서 + `BusinessException(PESTICIDE_SYNC_FAILED)`로 502가 반환되므로, 프로브 응답 자체가 실패 응답으로 온다.) +- `totalCount`: 실제 데이터 규모. +- `distinctTagNames`: 실응답에 존재하는 실제 XML 태그명 목록. `PsisPesticideRowMapper`의 후보 + 태그 목록과 비교해 실제 태그가 후보에 포함되어 있는지 확인한다. +- `requiredKeyResolution`: `itemName`/`cropName`/`pestName` 각각이 매핑됐는지 여부. +- `mapped`: 위 세 필드까지 모두 해석됐을 때만 non-null이다. `null`이면 매핑 실패다. + +## Step 2. 보정 + +`mapped`가 `null`이거나 `requiredKeyResolution`에 `false`가 있으면, 실패한 필드의 실제 태그명을 +`PsisPesticideRowMapper`의 해당 후보 리스트(`ITEM_NAME_KEYS`, `CROP_NAME_KEYS`, `PEST_NAME_KEYS` 등) +맨 앞에 추가한다. 이 파일만 수정하면 되고 구조 변경은 필요 없다. 수정 후 Step 1을 다시 실행해 +`mapped`가 채워지는지 재확인한다. + +## Step 3. 규모 판단 + +- `totalCount`가 수천 건 이하면 현행 전량(full) 동기화 그대로 사용해도 된다. +- `totalCount`가 수만 건 이상이면 현재 행 단위 upsert(`PesticideSyncService.sync`)가 느릴 수 있다. + 이 경우 `PolicySyncJob` 패턴처럼 비동기 실행 + 배치 upsert로 전환하는 별도 작업이 필요하지만, + 이번 작업 범위에는 포함되지 않는다(YAGNI — 실제 규모를 확인한 뒤 필요하면 요청한다). +- 만약 `type=xml` 파라미터로 XML 응답이 오지 않는다면(JSON 등으로 응답), PSIS API의 실제 파라미터명이 + `dataType`, `_type` 등 다른 이름일 수 있다. `PsisPesticideHttpTransport.get()`에 전달하는 쿼리 + 파라미터(`sync`/`probe` 양쪽에서 사용하는 `pageNo`/`numOfRows`/`type`)를 조정 지점으로 삼는다. + +## Step 4. 전량 동기화 + +프로브로 매핑이 정상 확인된 뒤 전량 동기화를 실행한다. + +```http +POST /api/v1/admin/pesticide-sync +``` + +응답의 `fetchedRowCount`(가져온 원본 행 수)와 `createdApplicationCount`(새로 생성된 +PesticideApplication 수)를 확인한다. 이미 동기화된 데이터에 대해 재실행하면 dedup 로직 때문에 +`createdApplicationCount`가 0이 되는 것이 정상이다. + +## 롤백 + +현재 pesticide/pest/pesticide_application 테이블에 실데이터가 없다는 전제로 진행한다. 문제가 +발견되면 세 테이블을 truncate한 뒤 Step 1부터 다시 실행한다. From 75267a65f7e5b5a4c9e3d7515f3673c7b47f595b Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 15:31:55 +0900 Subject: [PATCH 07/15] =?UTF-8?q?refactor(pesticide):=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20-=20item=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20hoist=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EB=B9=8C=EB=8D=94=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseEnvelope에서 getElementsByTagName("item")을 루프마다 재호출하던 것을 itemNodes로 1회 hoist (페이지당 O(n^2) DOM 재스캔 회귀 복원) - sync/probe에 중복된 pageNo/numOfRows/type 쿼리맵을 pageQuery 헬퍼로 추출 (type 파라미터명 조정 시 단일 지점) Co-Authored-By: Claude Opus 4.8 --- .../pesticide/sync/PesticideSyncService.kt | 24 ++++++++----------- .../sync/PsisPesticideResponseParser.kt | 5 ++-- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt index a88e5e7a..4292d4e8 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt @@ -36,13 +36,7 @@ class PesticideSyncService( var createdApplicationCount = 0 while (true) { - val body = transport.get( - mapOf( - "pageNo" to pageNo.toString(), - "numOfRows" to pageSize.toString(), - "type" to "xml", - ) - ) + val body = transport.get(pageQuery(pageNo = pageNo, numOfRows = pageSize)) val envelope = responseParser.parseEnvelope(body) failOnUpstreamError(envelope) val rawRows = envelope.items @@ -69,13 +63,7 @@ class PesticideSyncService( } fun probe(rows: Int = DEFAULT_PROBE_ROWS): PesticideProbeResult { - val body = transport.get( - mapOf( - "pageNo" to "1", - "numOfRows" to rows.toString(), - "type" to "xml", - ) - ) + val body = transport.get(pageQuery(pageNo = 1, numOfRows = rows)) val envelope = responseParser.parseEnvelope(body) failOnUpstreamError(envelope) @@ -103,6 +91,14 @@ class PesticideSyncService( ) } + // pageNo/numOfRows만 다르고 나머지는 동일하므로 sync/probe가 같은 쿼리 형태를 공유한다. + // type 파라미터명이 실제 엔드포인트에서 다르면(dataType/_type 등) 여기 한 곳만 고치면 된다. + private fun pageQuery(pageNo: Int, numOfRows: Int): Map = mapOf( + "pageNo" to pageNo.toString(), + "numOfRows" to numOfRows.toString(), + "type" to "xml", + ) + private fun upsertRows(rows: List): Int { var created = 0 rows.forEach { row -> diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt index 27f2b2dc..68e84af9 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt @@ -28,8 +28,9 @@ class PsisPesticideResponseParser { } val document = factory.newDocumentBuilder().parse(InputSource(StringReader(xml))) - val items = (0 until document.getElementsByTagName("item").length).map { index -> - val itemElement = document.getElementsByTagName("item").item(index) as Element + val itemNodes = document.getElementsByTagName("item") + val items = (0 until itemNodes.length).map { index -> + val itemElement = itemNodes.item(index) as Element val children = itemElement.childNodes (0 until children.length) .mapNotNull { children.item(it) as? Element } From 501cec96978e8232ded8c1500ca08b5ae5ec6466 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 16:11:08 +0900 Subject: [PATCH 08/15] =?UTF-8?q?fix(pesticide):=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20PSIS-RDA=20API=20?= =?UTF-8?q?=EA=B7=9C=EA=B2=A9=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serviceKey -> apiKey, PSIS_PESTICIDE_SERVICE_KEY -> PSIS_PESTICIDE_API_KEY - pageNo/numOfRows -> serviceCode(SVC01)/serviceType(AA001)/displayCount/startPoint 오프셋 페이지네이션으로 전환, displayCount는 최대 50으로 clamp - resultCode/resultMsg -> errorCode/errorMsg (성공 응답엔 errorCode가 없음) - PsisPesticideRowMapper가 실응답 태그(pestiKorName/cropName/diseaseWeedName 등)를 직접 매핑하도록 후보 태그 목록 제거 - 관련 파서/매퍼/서비스/컨트롤러 테스트를 PSIS 실응답 XML 형태로 갱신 Co-Authored-By: Claude Opus 4.8 --- .../api/pesticide/dto/PesticideResponses.kt | 8 +- .../src/main/resources/application-dev.yml | 2 +- .../src/main/resources/application-local.yml | 2 +- .../src/main/resources/application-prod.yml | 2 +- .../AdminPesticideSyncControllerTest.kt | 20 ++-- .../sync/JavaNetPsisPesticideHttpTransport.kt | 16 ++- .../pesticide/sync/PesticideProbeResult.kt | 4 +- .../pesticide/sync/PesticideSyncService.kt | 52 ++++---- .../pesticide/sync/PsisPesticideEnvelope.kt | 4 +- .../sync/PsisPesticideResponseParser.kt | 10 +- .../pesticide/sync/PsisPesticideRowMapper.kt | 36 +++--- .../sync/PesticideSyncServiceTest.kt | 105 +++++++++-------- .../sync/PsisPesticideResponseParserTest.kt | 111 +++++++++--------- .../sync/PsisPesticideRowMapperTest.kt | 68 +++++++---- 14 files changed, 237 insertions(+), 203 deletions(-) diff --git a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt index 4b3f248f..b4123e48 100644 --- a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt +++ b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt @@ -49,8 +49,8 @@ object PesticideResponses { } data class PesticideProbeResponse( - val resultCode: String?, - val resultMsg: String?, + val errorCode: String?, + val errorMsg: String?, val totalCount: Int?, val itemCount: Int, val distinctTagNames: List, @@ -60,8 +60,8 @@ object PesticideResponses { ) { companion object { fun from(result: PesticideProbeResult): PesticideProbeResponse = PesticideProbeResponse( - resultCode = result.resultCode, - resultMsg = result.resultMsg, + errorCode = result.errorCode, + errorMsg = result.errorMsg, totalCount = result.totalCount, itemCount = result.itemCount, distinctTagNames = result.distinctTagNames, diff --git a/backend/api/src/main/resources/application-dev.yml b/backend/api/src/main/resources/application-dev.yml index c13926bf..347d1130 100644 --- a/backend/api/src/main/resources/application-dev.yml +++ b/backend/api/src/main/resources/application-dev.yml @@ -67,7 +67,7 @@ weather: psis: pesticide: base-url: ${PSIS_PESTICIDE_BASE_URL:} - service-key: ${PSIS_PESTICIDE_SERVICE_KEY:} + api-key: ${PSIS_PESTICIDE_API_KEY:} timeout-millis: ${PSIS_PESTICIDE_TIMEOUT_MILLIS:10000} openai: diff --git a/backend/api/src/main/resources/application-local.yml b/backend/api/src/main/resources/application-local.yml index c6961a9c..dc9924ef 100644 --- a/backend/api/src/main/resources/application-local.yml +++ b/backend/api/src/main/resources/application-local.yml @@ -69,7 +69,7 @@ weather: psis: pesticide: base-url: ${PSIS_PESTICIDE_BASE_URL:} - service-key: ${PSIS_PESTICIDE_SERVICE_KEY:} + api-key: ${PSIS_PESTICIDE_API_KEY:} timeout-millis: ${PSIS_PESTICIDE_TIMEOUT_MILLIS:10000} openai: diff --git a/backend/api/src/main/resources/application-prod.yml b/backend/api/src/main/resources/application-prod.yml index c32659d3..054af8ff 100644 --- a/backend/api/src/main/resources/application-prod.yml +++ b/backend/api/src/main/resources/application-prod.yml @@ -67,7 +67,7 @@ weather: psis: pesticide: base-url: ${PSIS_PESTICIDE_BASE_URL:} - service-key: ${PSIS_PESTICIDE_SERVICE_KEY:} + api-key: ${PSIS_PESTICIDE_API_KEY:} timeout-millis: ${PSIS_PESTICIDE_TIMEOUT_MILLIS:10000} openai: diff --git a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt index bd2e0c3c..ac4a7970 100644 --- a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt +++ b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt @@ -51,12 +51,12 @@ class AdminPesticideSyncControllerTest( ) `when`(pesticideSyncService.probe(10)).thenReturn( PesticideProbeResult( - resultCode = "00", - resultMsg = "NORMAL_SERVICE", - totalCount = 137877, + errorCode = null, + errorMsg = null, + totalCount = 143912, itemCount = 1, - distinctTagNames = listOf("cropNm", "prdtNm"), - sampleRawItem = mapOf("cropNm" to "감자", "prdtNm" to "만코제브 수화제"), + distinctTagNames = listOf("cropName", "pestiKorName"), + sampleRawItem = mapOf("cropName" to "감자", "pestiKorName" to "만코제브 수화제"), requiredKeyResolution = mapOf("itemName" to true, "cropName" to true, "pestName" to true), mapped = mapped, ) @@ -65,11 +65,11 @@ class AdminPesticideSyncControllerTest( mockMvc.perform(post("/api/v1/admin/pesticide-sync/probe")) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.resultCode", equalTo("00"))) - .andExpect(jsonPath("$.data.resultMsg", equalTo("NORMAL_SERVICE"))) - .andExpect(jsonPath("$.data.totalCount", equalTo(137877))) + .andExpect(jsonPath("$.data.errorCode").doesNotExist()) + .andExpect(jsonPath("$.data.errorMsg").doesNotExist()) + .andExpect(jsonPath("$.data.totalCount", equalTo(143912))) .andExpect(jsonPath("$.data.itemCount", equalTo(1))) - .andExpect(jsonPath("$.data.distinctTagNames[0]", equalTo("cropNm"))) + .andExpect(jsonPath("$.data.distinctTagNames[0]", equalTo("cropName"))) .andExpect(jsonPath("$.data.requiredKeyResolution.itemName", equalTo(true))) .andExpect(jsonPath("$.data.mapped.itemName", equalTo("만코제브 수화제"))) @@ -77,7 +77,7 @@ class AdminPesticideSyncControllerTest( } @Test - fun `probe rejects rows outside 1 to 100`() { + fun `probe rejects rows outside 1 to 50`() { mockMvc.perform(post("/api/v1/admin/pesticide-sync/probe").param("rows", "0")) .andExpect(status().isBadRequest) .andExpect(jsonPath("$.success").value(false)) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/JavaNetPsisPesticideHttpTransport.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/JavaNetPsisPesticideHttpTransport.kt index 27d3ad88..2b3436a7 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/JavaNetPsisPesticideHttpTransport.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/JavaNetPsisPesticideHttpTransport.kt @@ -10,17 +10,15 @@ import java.nio.charset.StandardCharsets import java.time.Duration /** - * PSIS(농약안전정보시스템) 농약등록정보 API 호출 어댑터. - * - * base-url은 확정된 실제 엔드포인트가 아니라 자리표시자(placeholder)다. PSIS에서 API 키가 - * 발급되면 마이페이지에 표시되는 실제 요청 URL로 psis.pesticide.base-url 값을 교체해야 한다. - * serviceKey는 공공데이터포털 인코딩 키가 그대로 전달되도록 pre-encoded URI로 붙인다(이중 인코딩 방지, - * KmaWeatherProvider와 동일한 관례). + * PSIS(농약안전정보시스템) 농약등록정보 API 호출 어댑터. 실제 엔드포인트는 + * http://psis.rda.go.kr/openApi/service.do 이며, apiKey는 PSIS 마이페이지에서 발급받은 + * 인코딩 키가 그대로 전달되도록 pre-encoded URI로 붙인다(이중 인코딩 방지, KmaWeatherProvider와 + * 동일한 관례). */ @Component class JavaNetPsisPesticideHttpTransport( @Value("\${psis.pesticide.base-url:}") private val baseUrl: String, - @Value("\${psis.pesticide.service-key:}") private val serviceKey: String, + @Value("\${psis.pesticide.api-key:}") private val apiKey: String, @Value("\${psis.pesticide.timeout-millis:10000}") private val timeoutMillis: Long, ) : PsisPesticideHttpTransport { private val client: HttpClient by lazy { @@ -31,10 +29,10 @@ class JavaNetPsisPesticideHttpTransport( override fun get(queryParams: Map): String { check(baseUrl.isNotBlank()) { "psis.pesticide.base-url is not configured" } - check(serviceKey.isNotBlank()) { "psis.pesticide.service-key is not configured" } + check(apiKey.isNotBlank()) { "psis.pesticide.api-key is not configured" } val query = queryParams.entries.joinToString("&") { (key, value) -> "$key=$value" } - val uri = URI.create("$baseUrl?serviceKey=$serviceKey&$query") + val uri = URI.create("$baseUrl?apiKey=$apiKey&$query") val request = HttpRequest.newBuilder() .uri(uri) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideProbeResult.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideProbeResult.kt index 68d4cb4e..3989d37c 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideProbeResult.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideProbeResult.kt @@ -1,8 +1,8 @@ package com.chamchamcham.application.pesticide.sync data class PesticideProbeResult( - val resultCode: String?, - val resultMsg: String?, + val errorCode: String?, + val errorMsg: String?, val totalCount: Int?, val itemCount: Int, val distinctTagNames: List, diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt index 4292d4e8..89fb8347 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt @@ -12,11 +12,11 @@ import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate /** - * PSIS 원본 데이터(작물 x 병해충 x 약제 사실 테이블, 약 137,877건)를 전량 페이지네이션 순회하며 - * Pesticide/Pest/PesticideApplication으로 dedup·upsert한다. + * PSIS 원본 데이터(작물 x 병해충 x 약제 사실 테이블, 약 143,912건)를 startPoint 오프셋 페이지네이션으로 + * 전량 순회하며 Pesticide/Pest/PesticideApplication으로 dedup·upsert한다. * * 매 서버 기동마다 자동 실행되지 않는다 — 관리자 트리거(AdminPesticideSyncController)로만 호출된다. - * 행 단위 findBy 쿼리를 쓰므로 137k건 전체 동기화는 느릴 수 있다(1회성 관리 작업이라 우선 정확성을 + * 행 단위 findBy 쿼리를 쓰므로 143k건 전체 동기화는 느릴 수 있다(1회성 관리 작업이라 우선 정확성을 * 우선하고, 실제 실행해보고 느리면 배치 upsert로 최적화한다 - YAGNI). */ @Service @@ -30,15 +30,20 @@ class PesticideSyncService( private val transactionTemplate: TransactionTemplate, ) { fun sync(pageSize: Int = DEFAULT_PAGE_SIZE): PesticideSyncResult { - var pageNo = 1 + val clampedPageSize = pageSize.coerceAtMost(MAX_PAGE_SIZE) + var startPoint = 1 var pagesFetched = 0 var fetchedRowCount = 0 var createdApplicationCount = 0 + var totalCount: Int? = null while (true) { - val body = transport.get(pageQuery(pageNo = pageNo, numOfRows = pageSize)) + val body = transport.get(pageQuery(startPoint = startPoint, displayCount = clampedPageSize)) val envelope = responseParser.parseEnvelope(body) failOnUpstreamError(envelope) + if (totalCount == null) { + totalCount = envelope.totalCount + } val rawRows = envelope.items if (rawRows.isEmpty()) { break @@ -49,10 +54,11 @@ class PesticideSyncService( fetchedRowCount += rawRows.size createdApplicationCount += transactionTemplate.execute { upsertRows(rows) } ?: 0 - if (rawRows.size < pageSize) { + startPoint += clampedPageSize + val currentTotal = totalCount + if (currentTotal != null && startPoint > currentTotal) { break } - pageNo += 1 } return PesticideSyncResult( @@ -63,14 +69,14 @@ class PesticideSyncService( } fun probe(rows: Int = DEFAULT_PROBE_ROWS): PesticideProbeResult { - val body = transport.get(pageQuery(pageNo = 1, numOfRows = rows)) + val body = transport.get(pageQuery(startPoint = 1, displayCount = rows)) val envelope = responseParser.parseEnvelope(body) failOnUpstreamError(envelope) val sampleRawItem = envelope.items.firstOrNull() return PesticideProbeResult( - resultCode = envelope.resultCode, - resultMsg = envelope.resultMsg, + errorCode = envelope.errorCode, + errorMsg = envelope.errorMsg, totalCount = envelope.totalCount, itemCount = envelope.items.size, distinctTagNames = envelope.items.flatMap { it.keys }.distinct().sorted(), @@ -81,22 +87,20 @@ class PesticideSyncService( } private fun failOnUpstreamError(envelope: PsisPesticideEnvelope) { - val resultCode = envelope.resultCode?.trim() ?: return - if (resultCode in SUCCESS_RESULT_CODES) { - return - } + val errorCode = envelope.errorCode ?: return throw BusinessException( ErrorCode.PESTICIDE_SYNC_FAILED, - detail = mapOf("resultCode" to resultCode, "resultMsg" to envelope.resultMsg), + detail = mapOf("errorCode" to errorCode, "errorMsg" to envelope.errorMsg), ) } - // pageNo/numOfRows만 다르고 나머지는 동일하므로 sync/probe가 같은 쿼리 형태를 공유한다. - // type 파라미터명이 실제 엔드포인트에서 다르면(dataType/_type 등) 여기 한 곳만 고치면 된다. - private fun pageQuery(pageNo: Int, numOfRows: Int): Map = mapOf( - "pageNo" to pageNo.toString(), - "numOfRows" to numOfRows.toString(), - "type" to "xml", + // startPoint(1-based row offset)/displayCount만 다르고 나머지는 동일하므로 sync/probe가 같은 + // 쿼리 형태를 공유한다. displayCount는 PSIS 제약상 최대 50이라 여기서 clamp한다. + private fun pageQuery(startPoint: Int, displayCount: Int): Map = mapOf( + "serviceCode" to SERVICE_CODE, + "serviceType" to SERVICE_TYPE, + "displayCount" to displayCount.coerceAtMost(MAX_PAGE_SIZE).toString(), + "startPoint" to startPoint.toString(), ) private fun upsertRows(rows: List): Int { @@ -160,8 +164,10 @@ class PesticideSyncService( } private companion object { - const val DEFAULT_PAGE_SIZE = 1000 + const val SERVICE_CODE = "SVC01" + const val SERVICE_TYPE = "AA001" + const val DEFAULT_PAGE_SIZE = 50 + const val MAX_PAGE_SIZE = 50 const val DEFAULT_PROBE_ROWS = 10 - val SUCCESS_RESULT_CODES = setOf("00", "0") } } diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideEnvelope.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideEnvelope.kt index e6ac8b0e..2bb2db0e 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideEnvelope.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideEnvelope.kt @@ -1,8 +1,8 @@ package com.chamchamcham.application.pesticide.sync data class PsisPesticideEnvelope( - val resultCode: String?, - val resultMsg: String?, + val errorCode: String?, + val errorMsg: String?, val totalCount: Int?, val items: List>, ) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt index 68e84af9..cb9bf922 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParser.kt @@ -8,9 +8,9 @@ import javax.xml.XMLConstants import javax.xml.parsers.DocumentBuilderFactory /** - * 데이터포털/PSIS류 XML 응답에서 반복되는 엘리먼트를 태그명 -> 텍스트값 맵으로 평탄화한다. - * 실제 태그명(예: cropNm, aplyPestNm 등)은 API 키 발급 후 실응답으로 확정하고 - * [PsisPesticideRowMapper]에서만 매핑하면 되도록, 이 파서는 특정 필드명을 알 필요가 없게 만든다. + * PSIS(농약안전정보시스템) XML 응답(...)에서 반복되는 엘리먼트를 + * 태그명 -> 텍스트값 맵으로 평탄화한다. 실제 필드 매핑은 [PsisPesticideRowMapper]에서만 하면 되도록, + * 이 파서는 특정 필드명을 알 필요가 없게 만든다. */ @Component class PsisPesticideResponseParser { @@ -38,8 +38,8 @@ class PsisPesticideResponseParser { } return PsisPesticideEnvelope( - resultCode = firstTagText(document, "resultCode"), - resultMsg = firstTagText(document, "resultMsg"), + errorCode = firstTagText(document, "errorCode"), + errorMsg = firstTagText(document, "errorMsg"), totalCount = firstTagText(document, "totalCount")?.toIntOrNull(), items = items, ) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapper.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapper.kt index 38324712..cfbf630b 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapper.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapper.kt @@ -3,9 +3,8 @@ package com.chamchamcham.application.pesticide.sync import org.springframework.stereotype.Component /** - * PSIS 원본 응답의 태그명이 아직 확정되지 않아, 필드마다 그럴듯한 후보 태그명을 여러 개 시도한다. - * API 키 발급 후 실응답을 받으면 각 필드의 실제 태그명만 후보 목록 맨 앞에 추가하면 된다 - * (구조 변경 불필요 — 이 클래스만 수정). + * PSIS(농약안전정보시스템) 실응답의 실제 태그명으로 raw item 맵을 [PsisPesticideRow]로 매핑한다. + * formulation/fishToxicity/usageAmount는 PSIS 응답에 대응하는 태그가 없어 항상 null이다. */ @Component class PsisPesticideRowMapper { @@ -21,13 +20,13 @@ class PsisPesticideRowMapper { cropName = cropName, pestName = pestName, activeIngredient = raw.firstNotBlank(ACTIVE_INGREDIENT_KEYS), - formulation = raw.firstNotBlank(FORMULATION_KEYS), + formulation = null, usageCategory = raw.firstNotBlank(USAGE_CATEGORY_KEYS), humanToxicity = raw.firstNotBlank(HUMAN_TOXICITY_KEYS), - fishToxicity = raw.firstNotBlank(FISH_TOXICITY_KEYS), + fishToxicity = null, manufacturer = raw.firstNotBlank(MANUFACTURER_KEYS), dilutionRate = raw.firstNotBlank(DILUTION_RATE_KEYS), - usageAmount = raw.firstNotBlank(USAGE_AMOUNT_KEYS), + usageAmount = null, usageTiming = raw.firstNotBlank(USAGE_TIMING_KEYS), maxUsageCount = raw.firstNotBlank(MAX_USAGE_COUNT_KEYS), ) @@ -43,19 +42,16 @@ class PsisPesticideRowMapper { keys.firstNotNullOfOrNull { this[it]?.trim()?.takeIf(String::isNotEmpty) } private companion object { - val ITEM_NAME_KEYS = listOf("prdtNm", "pestcdNm", "itemNm") - val BRAND_NAME_KEYS = listOf("trdmrkNm", "brandNm", "cmpnyNm2") - val CROP_NAME_KEYS = listOf("cropNm", "aplyCropNm", "cropName") - val PEST_NAME_KEYS = listOf("aplyPestNm", "pestNm", "diszInsctNm", "pestName") - val ACTIVE_INGREDIENT_KEYS = listOf("mkeqCn", "ftlDstncNm", "activeIngredient") - val FORMULATION_KEYS = listOf("frmlcNm", "formulation") - val USAGE_CATEGORY_KEYS = listOf("useNm", "purpsNm", "usageCategory") - val HUMAN_TOXICITY_KEYS = listOf("humanToxNm", "hyginTxctyNm") - val FISH_TOXICITY_KEYS = listOf("fishToxNm", "fshTxctyNm") - val MANUFACTURER_KEYS = listOf("cmpnyNm", "manufacturer") - val DILUTION_RATE_KEYS = listOf("dltnMag", "dilutionRate") - val USAGE_AMOUNT_KEYS = listOf("useAmount", "useQnty") - val USAGE_TIMING_KEYS = listOf("useTiming", "useTermNm") - val MAX_USAGE_COUNT_KEYS = listOf("useNmtm", "useMaxCnt") + val ITEM_NAME_KEYS = listOf("pestiKorName") + val BRAND_NAME_KEYS = listOf("pestiBrandName") + val CROP_NAME_KEYS = listOf("cropName") + val PEST_NAME_KEYS = listOf("diseaseWeedName") + val ACTIVE_INGREDIENT_KEYS = listOf("engName") + val USAGE_CATEGORY_KEYS = listOf("useName") + val HUMAN_TOXICITY_KEYS = listOf("indictSymbl") + val MANUFACTURER_KEYS = listOf("compName") + val DILUTION_RATE_KEYS = listOf("dilutUnit") + val USAGE_TIMING_KEYS = listOf("useSuittime") + val MAX_USAGE_COUNT_KEYS = listOf("useNum") } } diff --git a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt index b4d4f224..0865d0b3 100644 --- a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt +++ b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt @@ -83,16 +83,16 @@ class PesticideSyncServiceTest { `when`( pesticideApplicationRepository.findByPesticide_IdAndPest_IdAndCropName(pesticideId, pestId2, "강낭콩") ).thenReturn(null) - `when`(transport.get(anyMap())).thenReturn(twoRowPageXml()) + `when`(transport.get(anyMap())).thenReturn(twoRowPageXml(totalCount = 2)) - val result = service.sync(pageSize = 100) + val result = service.sync() assertEquals(2, result.fetchedRowCount) assertEquals(2, result.createdApplicationCount) } @Test - fun `stops paginating once a page returns fewer rows than the page size`() { + fun `stops paginating once startPoint passes totalCount`() { `when`(pesticideRepository.findByItemNameAndBrandName("만코제브 수화제", "가가방")) .thenReturn(null, savedPesticide) `when`(pestRepository.findByName("역병")).thenReturn(null) @@ -103,9 +103,9 @@ class PesticideSyncServiceTest { `when`( pesticideApplicationRepository.findByPesticide_IdAndPest_IdAndCropName(pesticideId, pestId2, "강낭콩") ).thenReturn(null) - `when`(transport.get(anyMap())).thenReturn(twoRowPageXml()) + `when`(transport.get(anyMap())).thenReturn(twoRowPageXml(totalCount = 2)) - val result = service.sync(pageSize = 100) + val result = service.sync() assertEquals(1, result.pageCount) } @@ -120,20 +120,20 @@ class PesticideSyncServiceTest { null, PesticideApplication(pesticide = savedPesticide, pest = savedPest1, cropName = "감자") ) - `when`(transport.get(anyMap())).thenReturn(oneRowPageXml()) + `when`(transport.get(anyMap())).thenReturn(oneRowPageXml(totalCount = 1)) - service.sync(pageSize = 100) - val secondResult = service.sync(pageSize = 100) + service.sync() + val secondResult = service.sync() assertEquals(0, secondResult.createdApplicationCount) } @Test - fun `throws when upstream responds with an error resultCode`() { + fun `throws when upstream responds with an errorCode`() { `when`(transport.get(anyMap())).thenReturn(errorEnvelopeXml()) val exception = assertThrows(BusinessException::class.java) { - service.sync(pageSize = 100) + service.sync() } assertEquals(ErrorCode.PESTICIDE_SYNC_FAILED, exception.errorCode) @@ -141,13 +141,16 @@ class PesticideSyncServiceTest { @Test fun `probe fetches one page and reports diagnostics without writing to the database`() { - `when`(transport.get(anyMap())).thenReturn(twoRowPageXml()) + `when`(transport.get(anyMap())).thenReturn(twoRowPageXml(totalCount = 2)) val result = service.probe(rows = 10) - assertEquals(null, result.totalCount) + assertEquals(2, result.totalCount) assertEquals(2, result.itemCount) - assertEquals(listOf("aplyPestNm", "cropNm", "dltnMag", "prdtNm", "trdmrkNm"), result.distinctTagNames) + assertEquals( + listOf("cropName", "dilutUnit", "diseaseWeedName", "pestiBrandName", "pestiKorName"), + result.distinctTagNames + ) val mapped = result.mapped requireNotNull(mapped) assertEquals("만코제브 수화제", mapped.itemName) @@ -155,7 +158,7 @@ class PesticideSyncServiceTest { } @Test - fun `probe throws when upstream responds with an error resultCode`() { + fun `probe throws when upstream responds with an errorCode`() { `when`(transport.get(anyMap())).thenReturn(errorEnvelopeXml()) val exception = assertThrows(BusinessException::class.java) { @@ -166,47 +169,47 @@ class PesticideSyncServiceTest { } private fun errorEnvelopeXml(): String = """ - -
- 03 - NODATA_ERROR -
- - - 0 - -
+ + ERR_101 + 인증키가 등록되지 않았습니다. 정상적인 인증키를 확인하세요. + """.trimIndent() - private fun oneRowPageXml(): String = """ - - - 만코제브 수화제 - 가가방 - 감자 - 역병 - 500배 - - + private fun oneRowPageXml(totalCount: Int): String = """ + + $totalCount + + + 만코제브 수화제 + 가가방 + 감자 + 역병 + 500배 + + + """.trimIndent() - private fun twoRowPageXml(): String = """ - - - 만코제브 수화제 - 가가방 - 감자 - 역병 - 500배 - - - 만코제브 수화제 - 가가방 - 강낭콩 - 탄저병 - 500배 - - + private fun twoRowPageXml(totalCount: Int): String = """ + + $totalCount + + + 만코제브 수화제 + 가가방 + 감자 + 역병 + 500배 + + + 만코제브 수화제 + 가가방 + 강낭콩 + 탄저병 + 500배 + + + """.trimIndent() private class NoopTransactionManager : AbstractPlatformTransactionManager() { diff --git a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParserTest.kt b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParserTest.kt index 5bc833f4..f6b228e3 100644 --- a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParserTest.kt +++ b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideResponseParserTest.kt @@ -9,102 +9,105 @@ class PsisPesticideResponseParserTest { @Test fun `parses repeated item elements into tag-to-text maps`() { val xml = """ - - - - - 감자 - 역병 - 만코제브 수화제 - - - 강낭콩 - 탄저병 - 만코제브 수화제 - - - - + + 2 + + + 감자 + 역병 + 만코제브 수화제 + + + 강낭콩 + 탄저병 + 만코제브 수화제 + + + """.trimIndent() val rows = parser.parse(xml) assertThat(rows).hasSize(2) - assertThat(rows[0]).containsEntry("cropNm", "감자").containsEntry("aplyPestNm", "역병") - assertThat(rows[1]).containsEntry("cropNm", "강낭콩").containsEntry("aplyPestNm", "탄저병") + assertThat(rows[0]).containsEntry("cropName", "감자").containsEntry("diseaseWeedName", "역병") + assertThat(rows[1]).containsEntry("cropName", "강낭콩").containsEntry("diseaseWeedName", "탄저병") } @Test fun `returns empty list when no item elements exist`() { - val xml = "" + val xml = "" assertThat(parser.parse(xml)).isEmpty() } @Test fun `does not resolve external doctype entities`() { - val xml = """]>""" + val xml = """]>""" org.junit.jupiter.api.Assertions.assertThrows(Exception::class.java) { parser.parse(xml) } } @Test - fun `parseEnvelope extracts resultCode, resultMsg, totalCount and items from an error envelope`() { + fun `parseEnvelope extracts errorCode, errorMsg and empty items from an error envelope`() { val xml = """ - -
- 03 - NODATA_ERROR -
- - - 0 - -
+ + ERR_101 + 인증키가 등록되지 않았습니다. 정상적인 인증키를 확인하세요. + """.trimIndent() val envelope = parser.parseEnvelope(xml) - assertThat(envelope.resultCode).isEqualTo("03") - assertThat(envelope.resultMsg).isEqualTo("NODATA_ERROR") - assertThat(envelope.totalCount).isEqualTo(0) + assertThat(envelope.errorCode).isEqualTo("ERR_101") + assertThat(envelope.errorMsg).isEqualTo("인증키가 등록되지 않았습니다. 정상적인 인증키를 확인하세요.") + assertThat(envelope.totalCount).isNull() assertThat(envelope.items).isEmpty() } @Test - fun `parseEnvelope reads totalCount and items from a success envelope`() { + fun `parseEnvelope reads totalCount and items from a success envelope with no errorCode`() { val xml = """ - -
- 00 - NORMAL_SERVICE -
- - - - 감자 - - - 1 - -
+ + 143912 + 15:51:31[935] + + + 973 + + 세균벼알마름병 + 살균 + 가스가마이신 액제 + 가스가민 + (주)동방아그로 + Kasugamycin SL 2.3 % + 라3 + 1000배 - + 수확14일전 + 5회 + + + 10 + 1 + """.trimIndent() val envelope = parser.parseEnvelope(xml) - assertThat(envelope.resultCode).isEqualTo("00") - assertThat(envelope.totalCount).isEqualTo(1) + assertThat(envelope.errorCode).isNull() + assertThat(envelope.errorMsg).isNull() + assertThat(envelope.totalCount).isEqualTo(143912) assertThat(envelope.items).hasSize(1) + assertThat(envelope.items[0]).containsEntry("pestiKorName", "가스가마이신 액제") } @Test fun `parseEnvelope leaves totalCount null when the tag is absent`() { - val xml = "" + val xml = "" val envelope = parser.parseEnvelope(xml) - assertThat(envelope.resultCode).isNull() - assertThat(envelope.resultMsg).isNull() + assertThat(envelope.errorCode).isNull() + assertThat(envelope.errorMsg).isNull() assertThat(envelope.totalCount).isNull() assertThat(envelope.items).isEmpty() } diff --git a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapperTest.kt b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapperTest.kt index ee733f5b..5c9e203e 100644 --- a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapperTest.kt +++ b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PsisPesticideRowMapperTest.kt @@ -8,38 +8,66 @@ class PsisPesticideRowMapperTest { private val mapper = PsisPesticideRowMapper() @Test - fun `maps a raw row using primary candidate tag names`() { + fun `maps a raw row using the real PSIS tag names`() { val raw = mapOf( - "prdtNm" to "만코제브 수화제", - "trdmrkNm" to "가가방", - "cropNm" to "감자", - "aplyPestNm" to "역병", - "dltnMag" to "500배", + "pestiKorName" to "가스가마이신 액제", + "pestiBrandName" to "가스가민", + "cropName" to "벼", + "diseaseWeedName" to "세균벼알마름병", + "useName" to "살균", + "engName" to "Kasugamycin SL 2.3 %", + "indictSymbl" to "라3", + "compName" to "(주)동방아그로", + "dilutUnit" to "1000배 -", + "useSuittime" to "수확14일전", + "useNum" to "5회", ) val row = mapper.map(raw) requireNotNull(row) - assertEquals("만코제브 수화제", row.itemName) - assertEquals("가가방", row.brandName) - assertEquals("감자", row.cropName) - assertEquals("역병", row.pestName) - assertEquals("500배", row.dilutionRate) + assertEquals("가스가마이신 액제", row.itemName) + assertEquals("가스가민", row.brandName) + assertEquals("벼", row.cropName) + assertEquals("세균벼알마름병", row.pestName) + assertEquals("살균", row.usageCategory) + assertEquals("Kasugamycin SL 2.3 %", row.activeIngredient) + assertEquals("라3", row.humanToxicity) + assertEquals("(주)동방아그로", row.manufacturer) + assertEquals("1000배 -", row.dilutionRate) + assertEquals("수확14일전", row.usageTiming) + assertEquals("5회", row.maxUsageCount) + } + + @Test + fun `formulation fishToxicity and usageAmount are always null since PSIS has no matching tag`() { + val raw = mapOf( + "pestiKorName" to "가스가마이신 액제", + "cropName" to "벼", + "diseaseWeedName" to "세균벼알마름병", + ) + + val row = mapper.map(raw) + + requireNotNull(row) + assertNull(row.formulation) + assertNull(row.fishToxicity) + assertNull(row.usageAmount) } @Test fun `falls back to item name when brand name is missing`() { - val raw = mapOf("prdtNm" to "만코제브 수화제", "cropNm" to "감자", "aplyPestNm" to "역병") + val raw = mapOf("pestiKorName" to "가스가마이신 액제", "cropName" to "벼", "diseaseWeedName" to "세균벼알마름병") val row = mapper.map(raw) requireNotNull(row) - assertEquals("만코제브 수화제", row.brandName) + assertEquals("가스가마이신 액제", row.brandName) } @Test fun `returns null when a required field is missing`() { - val raw = mapOf("prdtNm" to "만코제브 수화제", "cropNm" to "감자") + val raw = mapOf("pestiKorName" to "가스가마이신 액제", "cropName" to "벼") assertNull(mapper.map(raw)) } @@ -47,9 +75,9 @@ class PsisPesticideRowMapperTest { @Test fun `diagnoseRequired resolves all required fields for a well-formed row`() { val raw = mapOf( - "prdtNm" to "만코제브 수화제", - "cropNm" to "감자", - "aplyPestNm" to "역병", + "pestiKorName" to "가스가마이신 액제", + "cropName" to "벼", + "diseaseWeedName" to "세균벼알마름병", ) val diagnosis = mapper.diagnoseRequired(raw) @@ -58,10 +86,10 @@ class PsisPesticideRowMapperTest { } @Test - fun `diagnoseRequired marks a field false when its tag is renamed or absent`() { + fun `diagnoseRequired marks a field false when its tag is absent`() { val raw = mapOf( - "prdtNm" to "만코제브 수화제", - "cropNm" to "감자", + "pestiKorName" to "가스가마이신 액제", + "cropName" to "벼", ) val diagnosis = mapper.diagnoseRequired(raw) From c68e0d9d7f406c3eee01da414847de14d0675a79 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 16:16:38 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat(pesticide):=20=EB=8C=80=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=20=EB=8F=99=EA=B8=B0=ED=99=94=EB=A5=BC=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=A7=84=ED=96=89=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=9E=A1=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domain: PesticideSyncJob/PesticideSyncJobStatus/PesticideSyncJobRepository 추가 (PolicySyncJob과 동일한 패턴, table pesticide_sync_job) - application: PesticideSyncService.sync()를 createSyncJob(RUNNING 잡 저장)/ runExistingJob(startPoint 오프셋 페이지네이션 실행 후 succeed/fail)/getJob으로 분리, PesticideSyncAsyncRunner(@Async)로 실제 순회를 비동기 실행 - api: AdminPesticideSyncController가 POST로 잡을 생성해 비동기 실행을 트리거하고 GET /{jobId}로 상태를 조회하도록 변경, probe 허용 범위를 1..50으로 조정 (displayCount 최대치에 맞춤) - 관련 도메인/서비스/컨트롤러 테스트 추가·갱신 Co-Authored-By: Claude Opus 4.8 --- .../AdminPesticideSyncController.kt | 49 ++++- .../api/pesticide/dto/PesticideResponses.kt | 41 ++++ .../AdminPesticideSyncControllerTest.kt | 86 ++++++++ .../sync/PesticideSyncAsyncRunner.kt | 15 ++ .../pesticide/sync/PesticideSyncResult.kt | 46 ++++- .../pesticide/sync/PesticideSyncService.kt | 103 ++++++---- .../sync/PesticideSyncServiceTest.kt | 184 +++++++++--------- .../domain/pesticide/PesticideSyncJob.kt | 62 ++++++ .../pesticide/PesticideSyncJobRepository.kt | 6 + .../pesticide/PesticideSyncJobStatus.kt | 7 + .../domain/pesticide/PesticideSyncJobTest.kt | 31 +++ 11 files changed, 486 insertions(+), 144 deletions(-) create mode 100644 backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncAsyncRunner.kt create mode 100644 backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJob.kt create mode 100644 backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobRepository.kt create mode 100644 backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobStatus.kt create mode 100644 backend/domain/src/test/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobTest.kt diff --git a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncController.kt b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncController.kt index 28aba752..589249e5 100644 --- a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncController.kt +++ b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncController.kt @@ -2,40 +2,71 @@ package com.chamchamcham.api.pesticide.controller import com.chamchamcham.api.common.ApiResponse import com.chamchamcham.api.pesticide.dto.PesticideResponses.PesticideProbeResponse +import com.chamchamcham.api.pesticide.dto.PesticideResponses.PesticideSyncJobDetailResponse +import com.chamchamcham.api.pesticide.dto.PesticideResponses.PesticideSyncJobSummaryResponse import com.chamchamcham.application.exception.ErrorCode import com.chamchamcham.application.exception.business.BusinessException -import com.chamchamcham.application.pesticide.sync.PesticideSyncResult +import com.chamchamcham.application.pesticide.sync.PesticideSyncAsyncRunner import com.chamchamcham.application.pesticide.sync.PesticideSyncService import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.util.UUID /** - * PSIS 데이터를 서버 DB로 1회성 동기화하는 관리자 트리거. 137k건 전체를 순회하므로 시간이 걸릴 수 - * 있어 요청-응답을 동기로 유지한다(진행 상황 폴링이 필요해지면 PolicySyncJob처럼 비동기+상태추적으로 - * 확장 - YAGNI). + * PSIS 데이터를 서버 DB로 동기화하는 관리자 트리거. 143k건 전체를 순회하므로 시간이 걸려 + * RUNNING 잡을 즉시 반환하고 실제 순회는 [PesticideSyncAsyncRunner]가 비동기로 수행한다 + * (PolicySyncJob과 동일한 패턴). 진행 상태는 GET .../{jobId}로 폴링한다. */ @RestController @RequestMapping("/api/v1/admin/pesticide-sync") class AdminPesticideSyncController( - private val pesticideSyncService: PesticideSyncService + private val pesticideSyncService: PesticideSyncService, + private val pesticideSyncAsyncRunner: PesticideSyncAsyncRunner, ) { @PostMapping - fun sync(): ResponseEntity> { - val result = pesticideSyncService.sync() - return ResponseEntity.ok(ApiResponse.ok(result)) + fun createSyncJob( + @AuthenticationPrincipal principal: Any?, + ): ResponseEntity> { + val result = pesticideSyncService.createSyncJob(parseMemberId(principal)) + pesticideSyncAsyncRunner.run(result.jobId) + return ResponseEntity.ok(ApiResponse.ok(PesticideSyncJobSummaryResponse.from(result))) + } + + @GetMapping("/{jobId}") + fun getJob( + @PathVariable jobId: UUID, + ): ResponseEntity> { + val result = pesticideSyncService.getJob(jobId) + return ResponseEntity.ok(ApiResponse.ok(PesticideSyncJobDetailResponse.from(result))) } @PostMapping("/probe") fun probe( @RequestParam(defaultValue = "10") rows: Int, ): ResponseEntity> { - if (rows !in 1..100) { + if (rows !in 1..50) { throw BusinessException(ErrorCode.INVALID_INPUT) } val result = pesticideSyncService.probe(rows) return ResponseEntity.ok(ApiResponse.ok(PesticideProbeResponse.from(result))) } + + // pesticide sync job의 createdByMemberId는 nullable이라(관리 트리거는 인증 주체가 없어도 되는 + // 1회성 작업), 정책 동기화 컨트롤러와 달리 principal이 없거나 UUID 형식이 아니어도 401 대신 null로 + // 처리한다. + private fun parseMemberId(principal: Any?): UUID? { + val memberId = when (principal) { + is String -> principal + is UserDetails -> principal.username + else -> null + } + return memberId?.let { runCatching { UUID.fromString(it) }.getOrNull() } + } } diff --git a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt index b4123e48..5168c111 100644 --- a/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt +++ b/backend/api/src/main/kotlin/com/chamchamcham/api/pesticide/dto/PesticideResponses.kt @@ -2,7 +2,10 @@ package com.chamchamcham.api.pesticide.dto import com.chamchamcham.application.pesticide.PesticideResult import com.chamchamcham.application.pesticide.sync.PesticideProbeResult +import com.chamchamcham.application.pesticide.sync.PesticideSyncResult import com.chamchamcham.application.pesticide.sync.PsisPesticideRow +import com.chamchamcham.domain.pesticide.PesticideSyncJobStatus +import java.time.LocalDateTime import java.util.UUID object PesticideResponses { @@ -71,4 +74,42 @@ object PesticideResponses { ) } } + + data class PesticideSyncJobSummaryResponse( + val jobId: UUID, + val status: PesticideSyncJobStatus, + ) { + companion object { + fun from(result: PesticideSyncResult.JobSummary): PesticideSyncJobSummaryResponse = + PesticideSyncJobSummaryResponse( + jobId = result.jobId, + status = result.status, + ) + } + } + + data class PesticideSyncJobDetailResponse( + val jobId: UUID, + val status: PesticideSyncJobStatus, + val totalCount: Int, + val fetchedRowCount: Int, + val createdApplicationCount: Int, + val errorMessage: String?, + val startedAt: LocalDateTime, + val finishedAt: LocalDateTime?, + ) { + companion object { + fun from(result: PesticideSyncResult.JobDetail): PesticideSyncJobDetailResponse = + PesticideSyncJobDetailResponse( + jobId = result.jobId, + status = result.status, + totalCount = result.totalCount, + fetchedRowCount = result.fetchedRowCount, + createdApplicationCount = result.createdApplicationCount, + errorMessage = result.errorMessage, + startedAt = result.startedAt, + finishedAt = result.finishedAt, + ) + } + } } diff --git a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt index ac4a7970..28db17f5 100644 --- a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt +++ b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/controller/AdminPesticideSyncControllerTest.kt @@ -2,9 +2,12 @@ package com.chamchamcham.api.pesticide.controller import com.chamchamcham.api.exception.GlobalExceptionHandler import com.chamchamcham.application.pesticide.sync.PesticideProbeResult +import com.chamchamcham.application.pesticide.sync.PesticideSyncAsyncRunner +import com.chamchamcham.application.pesticide.sync.PesticideSyncResult import com.chamchamcham.application.pesticide.sync.PesticideSyncService import com.chamchamcham.application.pesticide.sync.PsisPesticideRow import com.chamchamcham.application.security.TokenProvider +import com.chamchamcham.domain.pesticide.PesticideSyncJobStatus import org.hamcrest.Matchers.equalTo import org.junit.jupiter.api.Test import org.mockito.Mockito.`when` @@ -14,10 +17,17 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.annotation.Import +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.RequestPostProcessor import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.LocalDateTime +import java.util.UUID @WebMvcTest(AdminPesticideSyncController::class) @AutoConfigureMockMvc(addFilters = false) @@ -25,12 +35,68 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status class AdminPesticideSyncControllerTest( @Autowired private val mockMvc: MockMvc ) { + private val adminMemberId = UUID.fromString("00000000-0000-0000-0000-000000000002") + private val jobId = UUID.fromString("00000000-0000-0000-0000-000000000301") + @MockBean private lateinit var pesticideSyncService: PesticideSyncService + @MockBean + private lateinit var pesticideSyncAsyncRunner: PesticideSyncAsyncRunner + @MockBean private lateinit var tokenProvider: TokenProvider + @Test + fun `create sync job calls service and starts async runner`() { + `when`(pesticideSyncService.createSyncJob(adminMemberId)) + .thenReturn(PesticideSyncResult.JobSummary(jobId = jobId, status = PesticideSyncJobStatus.RUNNING)) + + mockMvc.perform( + post("/api/v1/admin/pesticide-sync") + .with(authenticatedMember(adminMemberId.toString(), "ROLE_ADMIN")) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.jobId", equalTo(jobId.toString()))) + .andExpect(jsonPath("$.data.status", equalTo("RUNNING"))) + + verify(pesticideSyncService).createSyncJob(adminMemberId) + verify(pesticideSyncAsyncRunner).run(jobId) + } + + @Test + fun `get sync job returns counters and status`() { + val startedAt = LocalDateTime.of(2026, 1, 1, 9, 0) + val finishedAt = LocalDateTime.of(2026, 1, 1, 9, 5) + `when`(pesticideSyncService.getJob(jobId)).thenReturn( + PesticideSyncResult.JobDetail( + jobId = jobId, + status = PesticideSyncJobStatus.SUCCEEDED, + totalCount = 143912, + fetchedRowCount = 143912, + createdApplicationCount = 140000, + errorMessage = null, + startedAt = startedAt, + finishedAt = finishedAt, + ) + ) + + mockMvc.perform(get("/api/v1/admin/pesticide-sync/{jobId}", jobId)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.jobId", equalTo(jobId.toString()))) + .andExpect(jsonPath("$.data.status", equalTo("SUCCEEDED"))) + .andExpect(jsonPath("$.data.totalCount", equalTo(143912))) + .andExpect(jsonPath("$.data.fetchedRowCount", equalTo(143912))) + .andExpect(jsonPath("$.data.createdApplicationCount", equalTo(140000))) + .andExpect(jsonPath("$.data.errorMessage").doesNotExist()) + .andExpect(jsonPath("$.data.startedAt", equalTo("2026-01-01T09:00:00"))) + .andExpect(jsonPath("$.data.finishedAt", equalTo("2026-01-01T09:05:00"))) + + verify(pesticideSyncService).getJob(jobId) + } + @Test fun `probe returns the mocked service result`() { val mapped = PsisPesticideRow( @@ -83,4 +149,24 @@ class AdminPesticideSyncControllerTest( .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.error.code", equalTo("COMMON_001"))) } + + @Test + fun `probe rejects rows above 50`() { + mockMvc.perform(post("/api/v1/admin/pesticide-sync/probe").param("rows", "51")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code", equalTo("COMMON_001"))) + } + + private fun authenticatedMember(memberId: String, role: String): RequestPostProcessor { + return RequestPostProcessor { request -> + SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken( + memberId, + null, + listOf(SimpleGrantedAuthority(role)) + ) + request + } + } } diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncAsyncRunner.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncAsyncRunner.kt new file mode 100644 index 00000000..1cb46b83 --- /dev/null +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncAsyncRunner.kt @@ -0,0 +1,15 @@ +package com.chamchamcham.application.pesticide.sync + +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class PesticideSyncAsyncRunner( + private val pesticideSyncService: PesticideSyncService +) { + @Async + fun run(jobId: UUID) { + pesticideSyncService.runExistingJob(jobId) + } +} diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncResult.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncResult.kt index e5bfde88..f4887999 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncResult.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncResult.kt @@ -1,7 +1,43 @@ package com.chamchamcham.application.pesticide.sync -data class PesticideSyncResult( - val fetchedRowCount: Int, - val createdApplicationCount: Int, - val pageCount: Int, -) +import com.chamchamcham.domain.pesticide.PesticideSyncJob +import com.chamchamcham.domain.pesticide.PesticideSyncJobStatus +import java.time.LocalDateTime +import java.util.UUID + +object PesticideSyncResult { + data class JobSummary( + val jobId: UUID, + val status: PesticideSyncJobStatus + ) { + companion object { + fun from(job: PesticideSyncJob): JobSummary = + JobSummary(requireNotNull(job.id), job.status) + } + } + + data class JobDetail( + val jobId: UUID, + val status: PesticideSyncJobStatus, + val totalCount: Int, + val fetchedRowCount: Int, + val createdApplicationCount: Int, + val errorMessage: String?, + val startedAt: LocalDateTime, + val finishedAt: LocalDateTime? + ) { + companion object { + fun from(job: PesticideSyncJob): JobDetail = + JobDetail( + jobId = requireNotNull(job.id), + status = job.status, + totalCount = job.totalCount, + fetchedRowCount = job.fetchedRowCount, + createdApplicationCount = job.createdApplicationCount, + errorMessage = job.errorMessage, + startedAt = job.startedAt, + finishedAt = job.finishedAt + ) + } + } +} diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt index 89fb8347..1f80665f 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt @@ -8,14 +8,19 @@ import com.chamchamcham.domain.pesticide.Pesticide import com.chamchamcham.domain.pesticide.PesticideApplication import com.chamchamcham.domain.pesticide.PesticideApplicationRepository import com.chamchamcham.domain.pesticide.PesticideRepository +import com.chamchamcham.domain.pesticide.PesticideSyncJob +import com.chamchamcham.domain.pesticide.PesticideSyncJobRepository import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate +import java.util.UUID /** * PSIS 원본 데이터(작물 x 병해충 x 약제 사실 테이블, 약 143,912건)를 startPoint 오프셋 페이지네이션으로 * 전량 순회하며 Pesticide/Pest/PesticideApplication으로 dedup·upsert한다. * * 매 서버 기동마다 자동 실행되지 않는다 — 관리자 트리거(AdminPesticideSyncController)로만 호출된다. + * 143k건 전체 순회는 오래 걸리므로 [createSyncJob]으로 RUNNING 잡을 즉시 반환하고, 실제 순회는 + * [PesticideSyncAsyncRunner]가 비동기로 [runExistingJob]을 호출해 수행한다(PolicySyncJob과 동일한 패턴). * 행 단위 findBy 쿼리를 쓰므로 143k건 전체 동기화는 느릴 수 있다(1회성 관리 작업이라 우선 정확성을 * 우선하고, 실제 실행해보고 느리면 배치 upsert로 최적화한다 - YAGNI). */ @@ -27,45 +32,56 @@ class PesticideSyncService( private val pesticideRepository: PesticideRepository, private val pestRepository: PestRepository, private val pesticideApplicationRepository: PesticideApplicationRepository, + private val pesticideSyncJobRepository: PesticideSyncJobRepository, private val transactionTemplate: TransactionTemplate, ) { - fun sync(pageSize: Int = DEFAULT_PAGE_SIZE): PesticideSyncResult { - val clampedPageSize = pageSize.coerceAtMost(MAX_PAGE_SIZE) - var startPoint = 1 - var pagesFetched = 0 - var fetchedRowCount = 0 - var createdApplicationCount = 0 - var totalCount: Int? = null - - while (true) { - val body = transport.get(pageQuery(startPoint = startPoint, displayCount = clampedPageSize)) - val envelope = responseParser.parseEnvelope(body) - failOnUpstreamError(envelope) - if (totalCount == null) { - totalCount = envelope.totalCount - } - val rawRows = envelope.items - if (rawRows.isEmpty()) { - break - } - pagesFetched += 1 - - val rows = rawRows.mapNotNull(rowMapper::map) - fetchedRowCount += rawRows.size - createdApplicationCount += transactionTemplate.execute { upsertRows(rows) } ?: 0 + fun createSyncJob(adminMemberId: UUID?): PesticideSyncResult.JobSummary { + val job = transactionTemplate.execute { + pesticideSyncJobRepository.save(PesticideSyncJob(createdByMemberId = adminMemberId)) + } ?: error("Transaction did not return a pesticide sync job") + return PesticideSyncResult.JobSummary.from(job) + } - startPoint += clampedPageSize - val currentTotal = totalCount - if (currentTotal != null && startPoint > currentTotal) { - break + fun runExistingJob(jobId: UUID) { + try { + var startPoint = 1 + var fetchedRowCount = 0 + var createdApplicationCount = 0 + var totalCount: Int? = null + + while (true) { + val body = transport.get(pageQuery(startPoint = startPoint, displayCount = DEFAULT_PAGE_SIZE)) + val envelope = responseParser.parseEnvelope(body) + failOnUpstreamError(envelope) + if (totalCount == null) { + totalCount = envelope.totalCount + } + val rawRows = envelope.items + if (rawRows.isEmpty()) { + break + } + + val rows = rawRows.mapNotNull(rowMapper::map) + fetchedRowCount += rawRows.size + createdApplicationCount += transactionTemplate.execute { upsertRows(rows) } ?: 0 + + startPoint += DEFAULT_PAGE_SIZE + val currentTotal = totalCount + if (currentTotal != null && startPoint > currentTotal) { + break + } } + + succeedJob(jobId, totalCount ?: fetchedRowCount, fetchedRowCount, createdApplicationCount) + } catch (exception: Exception) { + failJob(jobId, exception) } + } - return PesticideSyncResult( - fetchedRowCount = fetchedRowCount, - createdApplicationCount = createdApplicationCount, - pageCount = pagesFetched, - ) + fun getJob(jobId: UUID): PesticideSyncResult.JobDetail { + return transactionTemplate.execute { + PesticideSyncResult.JobDetail.from(findJob(jobId)) + } ?: error("Transaction did not return a pesticide sync job detail") } fun probe(rows: Int = DEFAULT_PROBE_ROWS): PesticideProbeResult { @@ -163,6 +179,27 @@ class PesticideSyncService( return true } + private fun succeedJob(jobId: UUID, totalCount: Int, fetchedRowCount: Int, createdApplicationCount: Int) { + transactionTemplate.executeWithoutResult { + findJob(jobId).succeed( + totalCount = totalCount, + fetchedRowCount = fetchedRowCount, + createdApplicationCount = createdApplicationCount, + ) + } + } + + private fun failJob(jobId: UUID, exception: Exception) { + transactionTemplate.executeWithoutResult { + findJob(jobId).fail(exception.message ?: exception.javaClass.simpleName) + } + } + + private fun findJob(jobId: UUID): PesticideSyncJob = + pesticideSyncJobRepository.findById(jobId).orElseThrow { + BusinessException(ErrorCode.RESOURCE_NOT_FOUND, detail = jobId) + } + private companion object { const val SERVICE_CODE = "SVC01" const val SERVICE_TYPE = "AA001" diff --git a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt index 0865d0b3..20113b6b 100644 --- a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt +++ b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt @@ -8,7 +8,11 @@ import com.chamchamcham.domain.pesticide.Pesticide import com.chamchamcham.domain.pesticide.PesticideApplication import com.chamchamcham.domain.pesticide.PesticideApplicationRepository import com.chamchamcham.domain.pesticide.PesticideRepository +import com.chamchamcham.domain.pesticide.PesticideSyncJob +import com.chamchamcham.domain.pesticide.PesticideSyncJobRepository +import com.chamchamcham.domain.pesticide.PesticideSyncJobStatus import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -23,14 +27,15 @@ import org.mockito.junit.jupiter.MockitoExtension import org.springframework.transaction.support.AbstractPlatformTransactionManager import org.springframework.transaction.support.DefaultTransactionStatus import org.springframework.transaction.support.TransactionTemplate +import java.util.Optional import java.util.UUID /** - * PesticideRepository/PestRepository/PesticideApplicationRepository의 커스텀 조회 메서드는 Kotlin에서 - * 선언된 non-null 파라미터를 갖고 있어, Mockito 5의 Kotlin null-safety 검증 때문에 any()/any(Class)/ - * nullable() 같은 와일드카드 매처를 스텁 설정에 쓰면 "must not be null" 예외가 난다. 그래서 이 테스트는 - * 고정된 UUID/문자열 등 구체값으로만 스텁한다. save()는 상속받은 Java(JpaRepository) 메서드라 영향이 - * 없어 any(Class)를 그대로 쓴다. + * PesticideRepository/PestRepository/PesticideApplicationRepository/PesticideSyncJobRepository의 커스텀 + * 조회 메서드는 Kotlin에서 선언된 non-null 파라미터를 갖고 있어, Mockito 5의 Kotlin null-safety 검증 때문에 + * any()/any(Class)/nullable() 같은 와일드카드 매처를 스텁 설정에 쓰면 "must not be null" 예외가 난다. 그래서 + * 이 테스트는 고정된 UUID/문자열 등 구체값으로만 스텁한다. save()는 상속받은 Java(JpaRepository) 메서드라 + * 영향이 없어 any(Class)를 그대로 쓴다. */ @ExtendWith(MockitoExtension::class) class PesticideSyncServiceTest { @@ -38,25 +43,22 @@ class PesticideSyncServiceTest { @Mock private lateinit var pesticideRepository: PesticideRepository @Mock private lateinit var pestRepository: PestRepository @Mock private lateinit var pesticideApplicationRepository: PesticideApplicationRepository + @Mock private lateinit var pesticideSyncJobRepository: PesticideSyncJobRepository private lateinit var service: PesticideSyncService + private lateinit var persistedJob: PesticideSyncJob + private val jobId = UUID.fromString("00000000-0000-0000-0000-000000000201") + private val adminMemberId = UUID.fromString("00000000-0000-0000-0000-000000000101") private val pesticideId = UUID.fromString("00000000-0000-0000-0000-0000000000a1") - private val pestId1 = UUID.fromString("00000000-0000-0000-0000-0000000000b1") - private val pestId2 = UUID.fromString("00000000-0000-0000-0000-0000000000b2") + private val pestId = UUID.fromString("00000000-0000-0000-0000-0000000000b1") private val savedPesticide = Pesticide(id = pesticideId, itemName = "만코제브 수화제", brandName = "가가방") - private val savedPest1 = Pest(id = pestId1, name = "역병") - private val savedPest2 = Pest(id = pestId2, name = "탄저병") + private val savedPest = Pest(id = pestId, name = "역병") @BeforeEach fun setUp() { lenient().`when`(pesticideRepository.save(any(Pesticide::class.java))).thenReturn(savedPesticide) - lenient().`when`(pestRepository.save(any(Pest::class.java))).thenAnswer { invocation -> - when (invocation.getArgument(0).name) { - "역병" -> savedPest1 - else -> savedPest2 - } - } + lenient().`when`(pestRepository.save(any(Pest::class.java))).thenReturn(savedPest) lenient().`when`(pesticideApplicationRepository.save(any(PesticideApplication::class.java))) .thenAnswer { invocation -> invocation.getArgument(0) } @@ -67,90 +69,74 @@ class PesticideSyncServiceTest { pesticideRepository = pesticideRepository, pestRepository = pestRepository, pesticideApplicationRepository = pesticideApplicationRepository, + pesticideSyncJobRepository = pesticideSyncJobRepository, transactionTemplate = TransactionTemplate(NoopTransactionManager()), ) } @Test - fun `dedupes the same pesticide across rows within one page`() { - `when`(pesticideRepository.findByItemNameAndBrandName("만코제브 수화제", "가가방")) - .thenReturn(null, savedPesticide) - `when`(pestRepository.findByName("역병")).thenReturn(null) - `when`(pestRepository.findByName("탄저병")).thenReturn(null) - `when`( - pesticideApplicationRepository.findByPesticide_IdAndPest_IdAndCropName(pesticideId, pestId1, "감자") - ).thenReturn(null) - `when`( - pesticideApplicationRepository.findByPesticide_IdAndPest_IdAndCropName(pesticideId, pestId2, "강낭콩") - ).thenReturn(null) - `when`(transport.get(anyMap())).thenReturn(twoRowPageXml(totalCount = 2)) + fun `createSyncJob saves a RUNNING job and returns its summary`() { + stubJobSaveAndFind() - val result = service.sync() + val result = service.createSyncJob(adminMemberId) - assertEquals(2, result.fetchedRowCount) - assertEquals(2, result.createdApplicationCount) + assertEquals(jobId, result.jobId) + assertEquals(PesticideSyncJobStatus.RUNNING, result.status) } @Test - fun `stops paginating once startPoint passes totalCount`() { + fun `runExistingJob paginates by startPoint until totalCount is exhausted and succeeds the job`() { + stubJobSaveAndFind() `when`(pesticideRepository.findByItemNameAndBrandName("만코제브 수화제", "가가방")) .thenReturn(null, savedPesticide) - `when`(pestRepository.findByName("역병")).thenReturn(null) - `when`(pestRepository.findByName("탄저병")).thenReturn(null) + `when`(pestRepository.findByName("역병")).thenReturn(null, savedPest) `when`( - pesticideApplicationRepository.findByPesticide_IdAndPest_IdAndCropName(pesticideId, pestId1, "감자") - ).thenReturn(null) - `when`( - pesticideApplicationRepository.findByPesticide_IdAndPest_IdAndCropName(pesticideId, pestId2, "강낭콩") - ).thenReturn(null) - `when`(transport.get(anyMap())).thenReturn(twoRowPageXml(totalCount = 2)) - - val result = service.sync() - - assertEquals(1, result.pageCount) - } + pesticideApplicationRepository.findByPesticide_IdAndPest_IdAndCropName(pesticideId, pestId, "감자") + ).thenReturn(null, PesticideApplication(pesticide = savedPesticide, pest = savedPest, cropName = "감자")) + `when`(transport.get(anyMap())).thenAnswer { invocation -> + val params = invocation.getArgument>(0) + if (params["startPoint"] == "1") { + pageXml(totalCount = 51, itemCount = 50) + } else { + pageXml(totalCount = 51, itemCount = 1) + } + } - @Test - fun `does not duplicate an application already synced in a prior run`() { - `when`(pesticideRepository.findByItemNameAndBrandName("만코제브 수화제", "가가방")).thenReturn(savedPesticide) - `when`(pestRepository.findByName("역병")).thenReturn(savedPest1) - `when`( - pesticideApplicationRepository.findByPesticide_IdAndPest_IdAndCropName(pesticideId, pestId1, "감자") - ).thenReturn( - null, - PesticideApplication(pesticide = savedPesticide, pest = savedPest1, cropName = "감자") - ) - `when`(transport.get(anyMap())).thenReturn(oneRowPageXml(totalCount = 1)) + service.createSyncJob(adminMemberId) + service.runExistingJob(jobId) - service.sync() - val secondResult = service.sync() + assertEquals(PesticideSyncJobStatus.SUCCEEDED, persistedJob.status) + assertEquals(51, persistedJob.totalCount) + assertEquals(51, persistedJob.fetchedRowCount) + assertEquals(1, persistedJob.createdApplicationCount) + assertNotNull(persistedJob.finishedAt) - assertEquals(0, secondResult.createdApplicationCount) + val detail = service.getJob(jobId) + assertEquals(PesticideSyncJobStatus.SUCCEEDED, detail.status) + assertEquals(51, detail.fetchedRowCount) } @Test - fun `throws when upstream responds with an errorCode`() { + fun `runExistingJob marks the job FAILED when upstream responds with an errorCode`() { + stubJobSaveAndFind() `when`(transport.get(anyMap())).thenReturn(errorEnvelopeXml()) - val exception = assertThrows(BusinessException::class.java) { - service.sync() - } + service.createSyncJob(adminMemberId) + service.runExistingJob(jobId) - assertEquals(ErrorCode.PESTICIDE_SYNC_FAILED, exception.errorCode) + assertEquals(PesticideSyncJobStatus.FAILED, persistedJob.status) + assertNotNull(persistedJob.errorMessage) + verifyNoInteractions(pesticideRepository, pestRepository, pesticideApplicationRepository) } @Test fun `probe fetches one page and reports diagnostics without writing to the database`() { - `when`(transport.get(anyMap())).thenReturn(twoRowPageXml(totalCount = 2)) + `when`(transport.get(anyMap())).thenReturn(pageXml(totalCount = 2, itemCount = 2)) val result = service.probe(rows = 10) assertEquals(2, result.totalCount) assertEquals(2, result.itemCount) - assertEquals( - listOf("cropName", "dilutUnit", "diseaseWeedName", "pestiBrandName", "pestiKorName"), - result.distinctTagNames - ) val mapped = result.mapped requireNotNull(mapped) assertEquals("만코제브 수화제", mapped.itemName) @@ -168,6 +154,25 @@ class PesticideSyncServiceTest { assertEquals(ErrorCode.PESTICIDE_SYNC_FAILED, exception.errorCode) } + private fun stubJobSaveAndFind() { + `when`(pesticideSyncJobRepository.save(any(PesticideSyncJob::class.java))).thenAnswer { invocation -> + val job = invocation.arguments[0] as PesticideSyncJob + persistedJob = PesticideSyncJob( + id = jobId, + status = job.status, + startedAt = job.startedAt, + finishedAt = job.finishedAt, + totalCount = job.totalCount, + fetchedRowCount = job.fetchedRowCount, + createdApplicationCount = job.createdApplicationCount, + errorMessage = job.errorMessage, + createdByMemberId = job.createdByMemberId, + ) + persistedJob + } + lenient().`when`(pesticideSyncJobRepository.findById(jobId)).thenAnswer { Optional.of(persistedJob) } + } + private fun errorEnvelopeXml(): String = """ ERR_101 @@ -175,25 +180,9 @@ class PesticideSyncServiceTest { """.trimIndent() - private fun oneRowPageXml(totalCount: Int): String = """ - - $totalCount - - - 만코제브 수화제 - 가가방 - 감자 - 역병 - 500배 - - - - """.trimIndent() - - private fun twoRowPageXml(totalCount: Int): String = """ - - $totalCount - + private fun pageXml(totalCount: Int, itemCount: Int): String { + val items = (1..itemCount).joinToString("\n") { + """ 만코제브 수화제 가가방 @@ -201,16 +190,17 @@ class PesticideSyncServiceTest { 역병 500배 - - 만코제브 수화제 - 가가방 - 강낭콩 - 탄저병 - 500배 - - - - """.trimIndent() + """.trimIndent() + } + return """ + + $totalCount + + $items + + + """.trimIndent() + } private class NoopTransactionManager : AbstractPlatformTransactionManager() { override fun doGetTransaction(): Any = Any() diff --git a/backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJob.kt b/backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJob.kt new file mode 100644 index 00000000..fce468cc --- /dev/null +++ b/backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJob.kt @@ -0,0 +1,62 @@ +package com.chamchamcham.domain.pesticide + +import com.chamchamcham.domain.common.BaseTimeEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "pesticide_sync_job") +class PesticideSyncJob( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(nullable = false, updatable = false, columnDefinition = "uuid") + val id: UUID? = null, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + var status: PesticideSyncJobStatus = PesticideSyncJobStatus.RUNNING, + + @Column(name = "started_at", nullable = false) + val startedAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "finished_at") + var finishedAt: LocalDateTime? = null, + + @Column(name = "total_count", nullable = false) + var totalCount: Int = 0, + + @Column(name = "fetched_row_count", nullable = false) + var fetchedRowCount: Int = 0, + + @Column(name = "created_application_count", nullable = false) + var createdApplicationCount: Int = 0, + + @Column(name = "error_message", length = 1000) + var errorMessage: String? = null, + + @Column(name = "created_by_member_id", columnDefinition = "uuid") + val createdByMemberId: UUID? = null, +) : BaseTimeEntity() { + fun succeed(totalCount: Int, fetchedRowCount: Int, createdApplicationCount: Int) { + this.status = PesticideSyncJobStatus.SUCCEEDED + this.totalCount = totalCount + this.fetchedRowCount = fetchedRowCount + this.createdApplicationCount = createdApplicationCount + this.errorMessage = null + this.finishedAt = LocalDateTime.now() + } + + fun fail(message: String) { + this.status = PesticideSyncJobStatus.FAILED + this.errorMessage = message.take(1000) + this.finishedAt = LocalDateTime.now() + } +} diff --git a/backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobRepository.kt b/backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobRepository.kt new file mode 100644 index 00000000..8670a7e5 --- /dev/null +++ b/backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobRepository.kt @@ -0,0 +1,6 @@ +package com.chamchamcham.domain.pesticide + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface PesticideSyncJobRepository : JpaRepository diff --git a/backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobStatus.kt b/backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobStatus.kt new file mode 100644 index 00000000..809aca9e --- /dev/null +++ b/backend/domain/src/main/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobStatus.kt @@ -0,0 +1,7 @@ +package com.chamchamcham.domain.pesticide + +enum class PesticideSyncJobStatus { + RUNNING, + SUCCEEDED, + FAILED +} diff --git a/backend/domain/src/test/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobTest.kt b/backend/domain/src/test/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobTest.kt new file mode 100644 index 00000000..00d81131 --- /dev/null +++ b/backend/domain/src/test/kotlin/com/chamchamcham/domain/pesticide/PesticideSyncJobTest.kt @@ -0,0 +1,31 @@ +package com.chamchamcham.domain.pesticide + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class PesticideSyncJobTest { + @Test + fun `succeed records counters and finished timestamp`() { + val job = PesticideSyncJob() + + job.succeed(totalCount = 143912, fetchedRowCount = 143912, createdApplicationCount = 140000) + + assertThat(job.status).isEqualTo(PesticideSyncJobStatus.SUCCEEDED) + assertThat(job.totalCount).isEqualTo(143912) + assertThat(job.fetchedRowCount).isEqualTo(143912) + assertThat(job.createdApplicationCount).isEqualTo(140000) + assertThat(job.errorMessage).isNull() + assertThat(job.finishedAt).isNotNull() + } + + @Test + fun `fail records bounded error message`() { + val job = PesticideSyncJob() + + job.fail("x".repeat(1200)) + + assertThat(job.status).isEqualTo(PesticideSyncJobStatus.FAILED) + assertThat(job.errorMessage).hasSize(1000) + assertThat(job.finishedAt).isNotNull() + } +} From b756d74dbfca193dd8d318c4de5aa7cf4f5f5dd2 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 16:17:10 +0900 Subject: [PATCH 10/15] =?UTF-8?q?docs(pesticide):=20PSIS=20=EC=8B=A4?= =?UTF-8?q?=EA=B7=9C=EA=B2=A9=C2=B7=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=9E=A1?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=9F=B0=EB=B6=81=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실제 엔드포인트/파라미터(apiKey, serviceCode=SVC01, serviceType=AA001, displayCount<=50, startPoint)와 errorCode 기반 에러 규격으로 정정 - 동기 전량 실행 절차를 잡 생성(POST) + 상태 폴링(GET /{jobId}) 흐름으로 갱신 - dev/prod에 pesticide_sync_job 테이블 수동 준비가 필요함을 명시 Co-Authored-By: Claude Opus 4.8 --- backend/docs/pesticide-sync-runbook.md | 76 +++++++++++++++----------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/backend/docs/pesticide-sync-runbook.md b/backend/docs/pesticide-sync-runbook.md index e926caf3..a34ba035 100644 --- a/backend/docs/pesticide-sync-runbook.md +++ b/backend/docs/pesticide-sync-runbook.md @@ -4,65 +4,75 @@ PSIS(농약안전정보시스템) 농약등록정보 API 키 발급이 완료된 상태를 전제로 한다. +- 엔드포인트: `GET http://psis.rda.go.kr/openApi/service.do` +- 파라미터: `apiKey`, `serviceCode=SVC01`, `serviceType=AA001`, `displayCount`(최대 50), + `startPoint`(1-based 행 오프셋), 선택 필터(`cropName`/`pestiKorName`/`pestiBrandName`/ + `diseaseWeedName`/`useName`/`compName`) +- 성공 응답은 `.........` + 형태이며 `` 태그가 없다. +- 실패 응답은 `ERR_101...` 형태다. + 주요 코드: `ERR_101`(인증키 미등록), `ERR_102`(서비스 중지), `ERR_103`(잘못된 serviceCode), + `ERR_201`(잘못된 파라미터), `ERR_901`(시스템 오류). `errorCode`가 하나라도 존재하면 실패다. +- 전체 데이터 규모는 약 143,912건이며, `displayCount=50` 기준 약 2,879회 호출이 필요하다. + ## env 설정 `api` 모듈의 `application-{profile}.yml`이 다음 환경변수를 읽는다. | 환경변수 | 설명 | 기본값 | | --- | --- | --- | -| `PSIS_PESTICIDE_BASE_URL` | PSIS 마이페이지에 표시되는 실제 요청 URL. 현재 값은 placeholder이므로 발급된 실제 URL로 교체해야 한다. | 없음 (미설정 시 요청 시점에 실패) | -| `PSIS_PESTICIDE_SERVICE_KEY` | 공공데이터포털에서 발급한 인코딩 서비스키. 이중 인코딩 방지를 위해 그대로 전달된다. | 없음 | +| `PSIS_PESTICIDE_BASE_URL` | PSIS 실제 요청 URL(`http://psis.rda.go.kr/openApi/service.do`). | 없음 (미설정 시 요청 시점에 실패) | +| `PSIS_PESTICIDE_API_KEY` | PSIS에서 발급한 인코딩 API 키. 이중 인코딩 방지를 위해 그대로 전달된다. | 없음 | | `PSIS_PESTICIDE_TIMEOUT_MILLIS` | HTTP 커넥션/요청 타임아웃(ms). 선택값. | `10000` | ## Step 1. 프로브 -앱 기동 후, 전량 동기화 전에 먼저 소량 데이터로 응답 형태를 확인한다. +전량 동기화 전에 먼저 소량 데이터로 응답 형태와 매핑을 확인한다. ```http POST /api/v1/admin/pesticide-sync/probe?rows=10 ``` +`rows`는 1~50 범위만 허용된다(PSIS `displayCount` 제약). + 응답(`ApiResponse`)의 `data`에서 다음을 확인한다. -- `resultCode`: 성공이면 `"00"`이어야 한다. 그 외 값이면 서비스키/URL 설정 문제일 가능성이 크므로 - `resultMsg`를 참고해 원인을 파악한다. (`resultCode`가 에러 값이면 컨트롤러가 아니라 서비스 단에서 - `BusinessException(PESTICIDE_SYNC_FAILED)`로 502가 반환되므로, 프로브 응답 자체가 실패 응답으로 온다.) -- `totalCount`: 실제 데이터 규모. -- `distinctTagNames`: 실응답에 존재하는 실제 XML 태그명 목록. `PsisPesticideRowMapper`의 후보 - 태그 목록과 비교해 실제 태그가 후보에 포함되어 있는지 확인한다. +- `errorCode`/`errorMsg`: 성공이면 둘 다 `null`이어야 한다. `errorCode`가 값을 가지면 API + 키/URL 설정 문제일 가능성이 크며, 컨트롤러가 아니라 서비스 단에서 + `BusinessException(PESTICIDE_SYNC_FAILED)`로 502가 반환되므로 프로브 응답 자체가 실패 + 응답으로 온다. +- `totalCount`: 실제 데이터 규모(정상이면 143,912 부근). +- `distinctTagNames`: 실응답에 존재하는 실제 XML 태그명 목록. - `requiredKeyResolution`: `itemName`/`cropName`/`pestName` 각각이 매핑됐는지 여부. -- `mapped`: 위 세 필드까지 모두 해석됐을 때만 non-null이다. `null`이면 매핑 실패다. - -## Step 2. 보정 +- `mapped`: 위 세 필드까지 모두 해석됐을 때만 non-null이다. -`mapped`가 `null`이거나 `requiredKeyResolution`에 `false`가 있으면, 실패한 필드의 실제 태그명을 -`PsisPesticideRowMapper`의 해당 후보 리스트(`ITEM_NAME_KEYS`, `CROP_NAME_KEYS`, `PEST_NAME_KEYS` 등) -맨 앞에 추가한다. 이 파일만 수정하면 되고 구조 변경은 필요 없다. 수정 후 Step 1을 다시 실행해 -`mapped`가 채워지는지 재확인한다. +## Step 2. 전량 동기화(비동기 잡) -## Step 3. 규모 판단 +143k건 전체 순회는 시간이 걸리므로 요청-응답을 동기로 기다리지 않는다. 잡을 생성하면 +`RUNNING` 상태로 즉시 응답하고, 실제 페이지네이션 순회는 서버가 비동기로 수행한다. -- `totalCount`가 수천 건 이하면 현행 전량(full) 동기화 그대로 사용해도 된다. -- `totalCount`가 수만 건 이상이면 현재 행 단위 upsert(`PesticideSyncService.sync`)가 느릴 수 있다. - 이 경우 `PolicySyncJob` 패턴처럼 비동기 실행 + 배치 upsert로 전환하는 별도 작업이 필요하지만, - 이번 작업 범위에는 포함되지 않는다(YAGNI — 실제 규모를 확인한 뒤 필요하면 요청한다). -- 만약 `type=xml` 파라미터로 XML 응답이 오지 않는다면(JSON 등으로 응답), PSIS API의 실제 파라미터명이 - `dataType`, `_type` 등 다른 이름일 수 있다. `PsisPesticideHttpTransport.get()`에 전달하는 쿼리 - 파라미터(`sync`/`probe` 양쪽에서 사용하는 `pageNo`/`numOfRows`/`type`)를 조정 지점으로 삼는다. - -## Step 4. 전량 동기화 +```http +POST /api/v1/admin/pesticide-sync +``` -프로브로 매핑이 정상 확인된 뒤 전량 동기화를 실행한다. +응답(`ApiResponse`)에서 `jobId`와 `status`(`RUNNING`)를 받는다. ```http -POST /api/v1/admin/pesticide-sync +GET /api/v1/admin/pesticide-sync/{jobId} ``` -응답의 `fetchedRowCount`(가져온 원본 행 수)와 `createdApplicationCount`(새로 생성된 -PesticideApplication 수)를 확인한다. 이미 동기화된 데이터에 대해 재실행하면 dedup 로직 때문에 -`createdApplicationCount`가 0이 되는 것이 정상이다. +`status`가 `SUCCEEDED`가 될 때까지 폴링한다. `ApiResponse`의 +`totalCount`/`fetchedRowCount`/`createdApplicationCount`를 확인한다. 이미 동기화된 데이터에 +재실행하면 dedup 로직 때문에 `createdApplicationCount`가 낮게(또는 0으로) 나오는 것이 정상이다. +실패 시 `status`는 `FAILED`가 되고 `errorMessage`에 원인이 담긴다. ## 롤백 -현재 pesticide/pest/pesticide_application 테이블에 실데이터가 없다는 전제로 진행한다. 문제가 -발견되면 세 테이블을 truncate한 뒤 Step 1부터 다시 실행한다. +현재 pesticide/pest/pesticide_application/pesticide_sync_job 테이블에 실데이터가 없다는 전제로 +진행한다. 문제가 발견되면 네 테이블을 truncate한 뒤 Step 1부터 다시 실행한다. + +## dev/prod 스키마 준비 + +dev/prod는 `ddl-auto: none`이고 Flyway가 없으므로, 이번 변경으로 추가된 +`pesticide_sync_job` 테이블(및 관련 컬럼)을 배포 전에 수동으로 준비해야 한다. 로컬에서 +`ddl-auto: create`로 생성한 DDL을 참고해 dev/prod 스키마에 반영한다. From da9f8b988e47a3514968e27eecea5e40138962a5 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 16:29:27 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix(pesticide):=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EA=B0=80=20totalCount=20=EB=AF=B8=EB=8B=AC=EB=A1=9C?= =?UTF-8?q?=20=EB=81=9D=EB=82=98=EB=A9=B4=20=EC=9E=A1=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 크롤 도중 PSIS가 일시적으로 빈 페이지를 반환하면 수집량이 totalCount에 못 미친 채 순회가 종료될 수 있는데, 기존에는 SUCCEEDED로 표시돼 부분 적재가 성공으로 오인됐다. 미달이면 명확한 메시지와 함께 FAILED로 표시(재실행은 dedup되어 idempotent). 코드리뷰 지적사항 반영. Co-Authored-By: Claude Opus 4.8 --- .../pesticide/sync/PesticideSyncService.kt | 11 ++++++++- .../sync/PesticideSyncServiceTest.kt | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt index 1f80665f..7eefb905 100644 --- a/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt +++ b/backend/application/src/main/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncService.kt @@ -72,7 +72,16 @@ class PesticideSyncService( } } - succeedJob(jobId, totalCount ?: fetchedRowCount, fetchedRowCount, createdApplicationCount) + // 중간에 일시적 빈 페이지가 오면 totalCount에 못 미친 채 순회가 끝날 수 있다. 이때 + // SUCCEEDED로 두면 부분 적재가 조용히 성공으로 보이므로, 미달이면 실패로 표시한다(재실행은 + // dedup되어 안전). + val resolvedTotal = totalCount + if (resolvedTotal != null && fetchedRowCount < resolvedTotal) { + throw IllegalStateException( + "PSIS 동기화가 불완전하게 종료되었습니다: 전체 ${resolvedTotal}건 중 ${fetchedRowCount}건만 수집(재실행 필요)" + ) + } + succeedJob(jobId, resolvedTotal ?: fetchedRowCount, fetchedRowCount, createdApplicationCount) } catch (exception: Exception) { failJob(jobId, exception) } diff --git a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt index 20113b6b..3e05d545 100644 --- a/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt +++ b/backend/application/src/test/kotlin/com/chamchamcham/application/pesticide/sync/PesticideSyncServiceTest.kt @@ -116,6 +116,30 @@ class PesticideSyncServiceTest { assertEquals(51, detail.fetchedRowCount) } + @Test + fun `runExistingJob marks the job FAILED when pagination ends before totalCount is reached`() { + stubJobSaveAndFind() + `when`(pesticideRepository.findByItemNameAndBrandName("만코제브 수화제", "가가방")).thenReturn(null) + `when`(pestRepository.findByName("역병")).thenReturn(null) + `when`( + pesticideApplicationRepository.findByPesticide_IdAndPest_IdAndCropName(pesticideId, pestId, "감자") + ).thenReturn(null) + `when`(transport.get(anyMap())).thenAnswer { invocation -> + val params = invocation.getArgument>(0) + if (params["startPoint"] == "1") { + pageXml(totalCount = 100, itemCount = 1) + } else { + pageXml(totalCount = 100, itemCount = 0) + } + } + + service.createSyncJob(adminMemberId) + service.runExistingJob(jobId) + + assertEquals(PesticideSyncJobStatus.FAILED, persistedJob.status) + assertNotNull(persistedJob.errorMessage) + } + @Test fun `runExistingJob marks the job FAILED when upstream responds with an errorCode`() { stubJobSaveAndFind() From 5e0955db79d6cd3f11cb8ff218de9dce8a0653c8 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 16:52:23 +0900 Subject: [PATCH 12/15] =?UTF-8?q?test(pesticide):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=86=A0=ED=81=B0=20=EC=97=86=EC=9D=B4=20=EC=8B=A4?= =?UTF-8?q?=20DB=EC=97=90=20=EC=A0=81=EC=9E=AC=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=20=EB=A1=9C=EB=8D=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROLE_ADMIN HTTP 엔드포인트 대신, PSIS_PESTICIDE_SYNC_RUN=true 플래그가 있을 때만 활성화되는 @SpringBootTest 로더로 실 Postgres에 전량 적재. 일반 test 실행에선 스킵. 런북에 사용법·전제(Postgres+Redis+env) 추가. Co-Authored-By: Claude Opus 4.8 --- .../PesticideSyncManualLoaderTest.kt | 53 +++++++++++++++++++ backend/docs/pesticide-sync-runbook.md | 25 ++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideSyncManualLoaderTest.kt diff --git a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideSyncManualLoaderTest.kt b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideSyncManualLoaderTest.kt new file mode 100644 index 00000000..75e3567d --- /dev/null +++ b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideSyncManualLoaderTest.kt @@ -0,0 +1,53 @@ +package com.chamchamcham.api.pesticide + +import com.chamchamcham.application.pesticide.sync.PesticideSyncService +import com.chamchamcham.domain.pesticide.PesticideSyncJobStatus +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles + +/** + * PSIS 농약등록정보(≈143,912건)를 실 DB에 1회성으로 적재하는 수동 로더. + * + * 관리자 토큰/HTTP 없이 실 Postgres에 바로 적재하기 위한 것으로, 일반 테스트 실행(`./gradlew test`)에서는 + * 절대 돌지 않도록 전용 환경변수 `PSIS_PESTICIDE_SYNC_RUN=true`가 있을 때만 활성화된다. + * + * 실행 전제(로컬 앱 구동과 동일): + * - 로컬 Postgres(5444) + Redis 기동 + * - env: `PSIS_PESTICIDE_API_KEY`(서비스인증키), `PSIS_PESTICIDE_BASE_URL=http://psis.rda.go.kr/openApi/service.do` + * + * 실행 예: + * PSIS_PESTICIDE_SYNC_RUN=true \ + * PSIS_PESTICIDE_API_KEY=<서비스인증키> \ + * PSIS_PESTICIDE_BASE_URL=http://psis.rda.go.kr/openApi/service.do \ + * ./gradlew :api:test --tests "com.chamchamcham.api.pesticide.PesticideSyncManualLoaderTest" + * + * runExistingJob을 (비동기 러너가 아니라) 직접 호출해 동기로 끝까지 순회하므로, 전량 적재가 끝날 때까지 + * 블로킹된다(수 분~수십 분). 재실행은 dedup되어 안전하다. totalCount에 미달하면 잡이 FAILED가 되어 + * 아래 단언에서 실패로 드러난다. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("local") +@EnabledIfEnvironmentVariable(named = "PSIS_PESTICIDE_SYNC_RUN", matches = "true") +class PesticideSyncManualLoaderTest @Autowired constructor( + private val pesticideSyncService: PesticideSyncService, +) { + @Test + fun `PSIS 농약등록정보를 실 DB에 전량 적재한다`() { + val job = pesticideSyncService.createSyncJob(adminMemberId = null) + pesticideSyncService.runExistingJob(job.jobId) + + val detail = pesticideSyncService.getJob(job.jobId) + println( + "[PSIS sync] status=${detail.status} total=${detail.totalCount} " + + "fetched=${detail.fetchedRowCount} createdApplications=${detail.createdApplicationCount} " + + "error=${detail.errorMessage}" + ) + + assertThat(detail.status).isEqualTo(PesticideSyncJobStatus.SUCCEEDED) + assertThat(detail.fetchedRowCount).isGreaterThan(0) + } +} diff --git a/backend/docs/pesticide-sync-runbook.md b/backend/docs/pesticide-sync-runbook.md index a34ba035..9650b0d8 100644 --- a/backend/docs/pesticide-sync-runbook.md +++ b/backend/docs/pesticide-sync-runbook.md @@ -64,7 +64,30 @@ GET /api/v1/admin/pesticide-sync/{jobId} `status`가 `SUCCEEDED`가 될 때까지 폴링한다. `ApiResponse`의 `totalCount`/`fetchedRowCount`/`createdApplicationCount`를 확인한다. 이미 동기화된 데이터에 재실행하면 dedup 로직 때문에 `createdApplicationCount`가 낮게(또는 0으로) 나오는 것이 정상이다. -실패 시 `status`는 `FAILED`가 되고 `errorMessage`에 원인이 담긴다. +실패 시 `status`는 `FAILED`가 되고 `errorMessage`에 원인이 담긴다. `fetchedRowCount`가 +`totalCount`에 미달하면(중간 빈 페이지 등) 잡은 자동으로 `FAILED` 처리되므로 그대로 재실행한다. + +> ⚠️ `/api/v1/admin/**` 경로는 `ROLE_ADMIN` 권한이 필요하다(SecurityConfig). 아직 관리자 권한 +> 부여 절차가 없다면 아래 "대안"으로 적재하고, 관리자 인증은 추후 도입한다. + +### 대안: 관리자 토큰 없이 수동 로더로 적재 (현재 권장) + +HTTP·토큰 없이 실 DB에 직접 적재하는 1회성 로더 테스트가 있다: +`api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideSyncManualLoaderTest.kt`. + +일반 `./gradlew test`에서는 절대 실행되지 않고, 전용 플래그 `PSIS_PESTICIDE_SYNC_RUN=true`가 +있을 때만 활성화된다(로컬 Postgres + Redis 기동 필요 — 로컬 앱 구동과 동일 전제). + +```bash +PSIS_PESTICIDE_SYNC_RUN=true \ +PSIS_PESTICIDE_API_KEY=<서비스인증키> \ +PSIS_PESTICIDE_BASE_URL=http://psis.rda.go.kr/openApi/service.do \ +./gradlew :api:test --tests "com.chamchamcham.api.pesticide.PesticideSyncManualLoaderTest" +``` + +`runExistingJob`을 동기로 끝까지 돌리므로 전량 적재가 끝날 때까지 블로킹된다(수 분~수십 분). +콘솔의 `[PSIS sync] status=... total=... fetched=... createdApplications=...` 로그로 결과를 +확인한다. `SUCCEEDED`가 아니면 단언에서 실패로 드러난다. ## 롤백 From 5abf2009e50db81e3d52969b6389e188303ba0aa Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 20:35:22 +0900 Subject: [PATCH 13/15] =?UTF-8?q?test(pesticide):=20=EC=A0=81=EC=9E=AC=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B2=80=EC=A6=9D=20=ED=95=98?= =?UTF-8?q?=EB=84=A4=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=ED=82=A4=20=ED=8C=8C=EC=9D=BC=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PesticideCatalogVerificationTest: 적재 후 PesticideCatalogService(검색/병해충 조회) 로직을 실 DB에 대해 직접 검증(PSIS_PESTICIDE_VERIFY=true 게이트, 토큰 불필요) - .psis.env(로컬 PSIS 키 파일) gitignore 처리 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 ++ .../PesticideCatalogVerificationTest.kt | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideCatalogVerificationTest.kt diff --git a/.gitignore b/.gitignore index 8bcc381c..247bafe8 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,7 @@ backend/api/src/test/kotlin/com/chamchamcham/api/dev/ backend/application/src/main/kotlin/com/chamchamcham/application/coaching/rag/seed/ backend/application/src/test/kotlin/com/chamchamcham/application/coaching/rag/seed/ backend/docs/db/crop-seed.sql + +# 로컬 전용 시크릿(예: PSIS 농약 API 키) — 절대 커밋 금지 +backend/.psis.env +.psis.env diff --git a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideCatalogVerificationTest.kt b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideCatalogVerificationTest.kt new file mode 100644 index 00000000..d00697d2 --- /dev/null +++ b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideCatalogVerificationTest.kt @@ -0,0 +1,59 @@ +package com.chamchamcham.api.pesticide + +import com.chamchamcham.application.pesticide.PesticideCatalogService +import com.chamchamcham.domain.pesticide.PestRepository +import com.chamchamcham.domain.pesticide.PesticideApplicationRepository +import com.chamchamcham.domain.pesticide.PesticideRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.PageRequest +import org.springframework.test.context.ActiveProfiles + +/** + * 적재된 실 DB에 대해 "기록 흐름 조회 API"가 쓰는 로직([PesticideCatalogService])이 정상 동작하는지 + * 검증하는 수동 하네스. `PesticideSyncManualLoaderTest`로 적재를 끝낸 뒤 실행한다. + * + * 검색/병해충 조회 엔드포인트(`GET /api/v1/pesticides`, `.../{id}/pests`)는 인증이 필요하지만, + * 컨트롤러는 이 서비스에 위임할 뿐이므로 서비스를 실 데이터에 대해 직접 호출하면 토큰 없이 동일 로직을 + * 검증할 수 있다(HTTP/인증 계약은 별도 컨트롤러/시큐리티 테스트가 이미 커버). + * + * 일반 `./gradlew test`에서는 스킵되고 `PSIS_PESTICIDE_VERIFY=true`일 때만 활성화된다(로컬 Postgres 필요). + * 실행: + * PSIS_PESTICIDE_VERIFY=true ./gradlew :api:test --tests "com.chamchamcham.api.pesticide.PesticideCatalogVerificationTest" + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("local") +@EnabledIfEnvironmentVariable(named = "PSIS_PESTICIDE_VERIFY", matches = "true") +class PesticideCatalogVerificationTest @Autowired constructor( + private val pesticideCatalogService: PesticideCatalogService, + private val pesticideRepository: PesticideRepository, + private val pestRepository: PestRepository, + private val pesticideApplicationRepository: PesticideApplicationRepository, +) { + @Test + fun `적재된 데이터로 농약 검색과 병해충 조회가 동작한다`() { + val pesticideCount = pesticideRepository.count() + val pestCount = pestRepository.count() + val applicationCount = pesticideApplicationRepository.count() + println("[verify] 적재 건수 pesticides=$pesticideCount pests=$pestCount applications=$applicationCount") + + assertThat(pesticideCount).isGreaterThan(0) + assertThat(pestCount).isGreaterThan(0) + assertThat(applicationCount).isGreaterThan(0) + + // 검색 API 로직(키워드 = 품목명/상표명 부분일치 + 커서 페이지네이션) + val page = pesticideCatalogService.search(keyword = "가스가마이신", cursor = null, size = 5) + println("[verify] search('가스가마이신') -> ${page.items.size}건, nextCursor존재=${page.nextCursor != null}") + println("[verify] 첫 결과=${page.items.firstOrNull()}") + assertThat(page.items).isNotEmpty + + // 약제별 병해충 조회 API 로직 + val anyPesticide = pesticideRepository.findAll(PageRequest.of(0, 1)).content.first() + val pests = pesticideCatalogService.listPestsByPesticide(requireNotNull(anyPesticide.id)) + println("[verify] '${anyPesticide.itemName}/${anyPesticide.brandName}' -> 병해충 ${pests.size}종, 예시=${pests.firstOrNull()}") + assertThat(pests).isNotEmpty + } +} From b958bf04458ac222fb0ef947d4e033008299d3d3 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 22:10:59 +0900 Subject: [PATCH 14/15] =?UTF-8?q?test(pesticide):=20=EC=A0=81=EC=9E=AC+?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=ED=95=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EC=9C=BC=EB=A1=9C=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=ED=95=B4=20ddl-auto=20=EC=82=AD=EC=A0=9C=20=ED=95=A8=EC=A0=95?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit local 프로필은 ddl-auto:create라 Spring 컨텍스트가 부팅될 때마다 스키마를 DROP/재생성한다. 적재 로더와 조회 검증을 별도 테스트(별도 부팅)로 두면 검증 테스트의 부팅이 방금 적재한 데이터를 통째로 지운다. 검증을 로더 테스트 안으로 옮겨 한 번의 부팅에서 적재→검색/병해충 조회까지 끝내도록 하고, 별도 PesticideCatalogVerificationTest를 제거한다. 런북에 함정을 명시한다. Co-Authored-By: Claude Opus 4.8 --- .../PesticideCatalogVerificationTest.kt | 59 ------------------- .../PesticideSyncManualLoaderTest.kt | 38 +++++++++++- backend/docs/pesticide-sync-runbook.md | 8 +++ 3 files changed, 45 insertions(+), 60 deletions(-) delete mode 100644 backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideCatalogVerificationTest.kt diff --git a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideCatalogVerificationTest.kt b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideCatalogVerificationTest.kt deleted file mode 100644 index d00697d2..00000000 --- a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideCatalogVerificationTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.chamchamcham.api.pesticide - -import com.chamchamcham.application.pesticide.PesticideCatalogService -import com.chamchamcham.domain.pesticide.PestRepository -import com.chamchamcham.domain.pesticide.PesticideApplicationRepository -import com.chamchamcham.domain.pesticide.PesticideRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.data.domain.PageRequest -import org.springframework.test.context.ActiveProfiles - -/** - * 적재된 실 DB에 대해 "기록 흐름 조회 API"가 쓰는 로직([PesticideCatalogService])이 정상 동작하는지 - * 검증하는 수동 하네스. `PesticideSyncManualLoaderTest`로 적재를 끝낸 뒤 실행한다. - * - * 검색/병해충 조회 엔드포인트(`GET /api/v1/pesticides`, `.../{id}/pests`)는 인증이 필요하지만, - * 컨트롤러는 이 서비스에 위임할 뿐이므로 서비스를 실 데이터에 대해 직접 호출하면 토큰 없이 동일 로직을 - * 검증할 수 있다(HTTP/인증 계약은 별도 컨트롤러/시큐리티 테스트가 이미 커버). - * - * 일반 `./gradlew test`에서는 스킵되고 `PSIS_PESTICIDE_VERIFY=true`일 때만 활성화된다(로컬 Postgres 필요). - * 실행: - * PSIS_PESTICIDE_VERIFY=true ./gradlew :api:test --tests "com.chamchamcham.api.pesticide.PesticideCatalogVerificationTest" - */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) -@ActiveProfiles("local") -@EnabledIfEnvironmentVariable(named = "PSIS_PESTICIDE_VERIFY", matches = "true") -class PesticideCatalogVerificationTest @Autowired constructor( - private val pesticideCatalogService: PesticideCatalogService, - private val pesticideRepository: PesticideRepository, - private val pestRepository: PestRepository, - private val pesticideApplicationRepository: PesticideApplicationRepository, -) { - @Test - fun `적재된 데이터로 농약 검색과 병해충 조회가 동작한다`() { - val pesticideCount = pesticideRepository.count() - val pestCount = pestRepository.count() - val applicationCount = pesticideApplicationRepository.count() - println("[verify] 적재 건수 pesticides=$pesticideCount pests=$pestCount applications=$applicationCount") - - assertThat(pesticideCount).isGreaterThan(0) - assertThat(pestCount).isGreaterThan(0) - assertThat(applicationCount).isGreaterThan(0) - - // 검색 API 로직(키워드 = 품목명/상표명 부분일치 + 커서 페이지네이션) - val page = pesticideCatalogService.search(keyword = "가스가마이신", cursor = null, size = 5) - println("[verify] search('가스가마이신') -> ${page.items.size}건, nextCursor존재=${page.nextCursor != null}") - println("[verify] 첫 결과=${page.items.firstOrNull()}") - assertThat(page.items).isNotEmpty - - // 약제별 병해충 조회 API 로직 - val anyPesticide = pesticideRepository.findAll(PageRequest.of(0, 1)).content.first() - val pests = pesticideCatalogService.listPestsByPesticide(requireNotNull(anyPesticide.id)) - println("[verify] '${anyPesticide.itemName}/${anyPesticide.brandName}' -> 병해충 ${pests.size}종, 예시=${pests.firstOrNull()}") - assertThat(pests).isNotEmpty - } -} diff --git a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideSyncManualLoaderTest.kt b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideSyncManualLoaderTest.kt index 75e3567d..5b2ad41d 100644 --- a/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideSyncManualLoaderTest.kt +++ b/backend/api/src/test/kotlin/com/chamchamcham/api/pesticide/PesticideSyncManualLoaderTest.kt @@ -1,12 +1,17 @@ package com.chamchamcham.api.pesticide +import com.chamchamcham.application.pesticide.PesticideCatalogService import com.chamchamcham.application.pesticide.sync.PesticideSyncService +import com.chamchamcham.domain.pesticide.PestRepository +import com.chamchamcham.domain.pesticide.PesticideApplicationRepository +import com.chamchamcham.domain.pesticide.PesticideRepository import com.chamchamcham.domain.pesticide.PesticideSyncJobStatus import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.PageRequest import org.springframework.test.context.ActiveProfiles /** @@ -28,15 +33,24 @@ import org.springframework.test.context.ActiveProfiles * runExistingJob을 (비동기 러너가 아니라) 직접 호출해 동기로 끝까지 순회하므로, 전량 적재가 끝날 때까지 * 블로킹된다(수 분~수십 분). 재실행은 dedup되어 안전하다. totalCount에 미달하면 잡이 FAILED가 되어 * 아래 단언에서 실패로 드러난다. + * + * ⚠️ 적재와 조회 검증은 반드시 **같은 실행(=같은 Spring 컨텍스트)** 안에서 끝낸다. local 프로필은 + * `ddl-auto: create`라 컨텍스트가 부팅될 때마다 스키마를 DROP/재생성하므로, 적재가 끝난 뒤 별도 + * 테스트/앱을 local 프로필로 다시 띄우면 방금 넣은 데이터가 통째로 지워진다. 그래서 적재 직후 + * 이 테스트 안에서 [PesticideCatalogService]로 검색·병해충 조회까지 함께 검증한다. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @ActiveProfiles("local") @EnabledIfEnvironmentVariable(named = "PSIS_PESTICIDE_SYNC_RUN", matches = "true") class PesticideSyncManualLoaderTest @Autowired constructor( private val pesticideSyncService: PesticideSyncService, + private val pesticideCatalogService: PesticideCatalogService, + private val pesticideRepository: PesticideRepository, + private val pestRepository: PestRepository, + private val pesticideApplicationRepository: PesticideApplicationRepository, ) { @Test - fun `PSIS 농약등록정보를 실 DB에 전량 적재한다`() { + fun `PSIS 농약등록정보를 실 DB에 전량 적재하고 조회를 검증한다`() { val job = pesticideSyncService.createSyncJob(adminMemberId = null) pesticideSyncService.runExistingJob(job.jobId) @@ -49,5 +63,27 @@ class PesticideSyncManualLoaderTest @Autowired constructor( assertThat(detail.status).isEqualTo(PesticideSyncJobStatus.SUCCEEDED) assertThat(detail.fetchedRowCount).isGreaterThan(0) + + // 같은 컨텍스트에서 곧바로 조회 검증(별도 부팅 시 ddl-auto:create가 데이터를 지우므로 여기서 검증). + val pesticideCount = pesticideRepository.count() + val pestCount = pestRepository.count() + val applicationCount = pesticideApplicationRepository.count() + println("[verify] 적재 건수 pesticides=$pesticideCount pests=$pestCount applications=$applicationCount") + + assertThat(pesticideCount).isGreaterThan(0) + assertThat(pestCount).isGreaterThan(0) + assertThat(applicationCount).isGreaterThan(0) + + // 검색 API 로직(키워드 = 품목명/상표명 부분일치 + 커서 페이지네이션) + val page = pesticideCatalogService.search(keyword = "가스가마이신", cursor = null, size = 5) + println("[verify] search('가스가마이신') -> ${page.items.size}건, nextCursor존재=${page.nextCursor != null}") + println("[verify] 첫 결과=${page.items.firstOrNull()}") + assertThat(page.items).isNotEmpty + + // 약제별 병해충 조회 API 로직 + val anyPesticide = pesticideRepository.findAll(PageRequest.of(0, 1)).content.first() + val pests = pesticideCatalogService.listPestsByPesticide(requireNotNull(anyPesticide.id)) + println("[verify] '${anyPesticide.itemName}/${anyPesticide.brandName}' -> 병해충 ${pests.size}종, 예시=${pests.firstOrNull()}") + assertThat(pests).isNotEmpty } } diff --git a/backend/docs/pesticide-sync-runbook.md b/backend/docs/pesticide-sync-runbook.md index 9650b0d8..d70d4a88 100644 --- a/backend/docs/pesticide-sync-runbook.md +++ b/backend/docs/pesticide-sync-runbook.md @@ -89,6 +89,14 @@ PSIS_PESTICIDE_BASE_URL=http://psis.rda.go.kr/openApi/service.do \ 콘솔의 `[PSIS sync] status=... total=... fetched=... createdApplications=...` 로그로 결과를 확인한다. `SUCCEEDED`가 아니면 단언에서 실패로 드러난다. +적재 직후 같은 실행 안에서 `PesticideCatalogService`(검색/병해충 조회)까지 함께 검증하며, +`[verify] ...` 로그로 적재 건수와 검색·병해충 조회 결과를 확인한다. + +> ⚠️ local 프로필은 `ddl-auto: create`라 **컨텍스트가 부팅될 때마다 스키마를 DROP/재생성**한다. +> 적재가 끝난 뒤 별도 테스트나 앱을 local 프로필로 다시 띄우면 방금 넣은 데이터가 통째로 지워진다. +> 그래서 적재와 조회 검증을 하나의 테스트(=한 번의 부팅) 안에서 끝낸다. 실데이터를 계속 쓰려면 +> dev/prod처럼 `ddl-auto: none`으로 스키마를 미리 준비한 환경에 적재해야 한다. + ## 롤백 현재 pesticide/pest/pesticide_application/pesticide_sync_job 테이블에 실데이터가 없다는 전제로 From a874709c0f8f2dbf41c7f08cb22422f12d366022 Mon Sep 17 00:00:00 2001 From: Kimseungin0529 Date: Mon, 13 Jul 2026 22:52:50 +0900 Subject: [PATCH 15/15] =?UTF-8?q?docs(pesticide):=20=EC=84=9C=EB=B2=84=20D?= =?UTF-8?q?B=20=EB=B0=98=EC=98=81=20dump/restore=20=ED=95=B8=EB=93=9C?= =?UTF-8?q?=EC=98=A4=ED=94=84=20=EC=A0=88=EC=B0=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev/prod는 ddl-auto:none이라 농약 스키마+데이터를 서버 DB에 직접 넣어야 한다. 서버에서 PSIS를 재호출하는 대신 로컬 적재분을 pg_dump로 덤프해 서버에 붓는 방식을 문서화한다. pg_dump 파일이 CREATE TABLE(스키마)+데이터를 함께 담으므로 별도 DDL은 불필요. Part A(앱 담당자: 덤프 생성)와 Part B(서버 담당자: 처음 하는 사람도 따라할 수 있는 적용/검증/롤백 명령)로 분리해 단독 수행 가능하게 했다. Co-Authored-By: Claude Opus 4.8 --- backend/docs/pesticide-sync-runbook.md | 111 ++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/backend/docs/pesticide-sync-runbook.md b/backend/docs/pesticide-sync-runbook.md index d70d4a88..8d0f04d3 100644 --- a/backend/docs/pesticide-sync-runbook.md +++ b/backend/docs/pesticide-sync-runbook.md @@ -102,8 +102,111 @@ PSIS_PESTICIDE_BASE_URL=http://psis.rda.go.kr/openApi/service.do \ 현재 pesticide/pest/pesticide_application/pesticide_sync_job 테이블에 실데이터가 없다는 전제로 진행한다. 문제가 발견되면 네 테이블을 truncate한 뒤 Step 1부터 다시 실행한다. -## dev/prod 스키마 준비 +## 서버(dev/prod) 반영 — dump/restore 핸드오프 -dev/prod는 `ddl-auto: none`이고 Flyway가 없으므로, 이번 변경으로 추가된 -`pesticide_sync_job` 테이블(및 관련 컬럼)을 배포 전에 수동으로 준비해야 한다. 로컬에서 -`ddl-auto: create`로 생성한 DDL을 참고해 dev/prod 스키마에 반영한다. +dev/prod는 `ddl-auto: none`이고 Flyway가 없다. 즉 앱이 부팅해도 테이블을 스스로 만들지 +않으므로, **농약 4개 테이블(스키마)과 카탈로그 데이터(약 14만 건)를 서버 DB에 직접 넣어줘야** +한다. 서버에서 PSIS API를 다시 호출(수천 회, ~1시간)하는 대신, **로컬에 이미 적재·검증한 +데이터를 그대로 덤프해 서버에 붓는다.** `pg_dump`가 만든 파일 하나에 `CREATE TABLE`(스키마) ++ 인덱스 + 외래키 + 데이터가 모두 들어가므로, 별도의 DDL 스크립트는 필요 없다. + +> 대상 테이블: `pesticide`, `pest`, `pesticide_application`(← 두 테이블을 참조하는 외래키 보유), +> `pesticide_sync_job`(잡 이력용, 스키마만 필요하고 데이터는 옮기지 않음). 이 4개는 서로만 +> 참조하고 `member` 등 다른 테이블과 얽히지 않으므로 이 묶음만으로 완결적이다. + +작업은 두 사람으로 나뉜다. **Part A는 이 앱을 빌드/실행하는 사람(=덤프 파일 제작)**, **Part B는 +서버 DB 접근 권한을 가진 사람(=서버에 적용)**. Part B는 이 대화 맥락 없이도 단독으로 수행 가능하도록 +아래에 모든 명령을 적어 둔다. + +### Part A. 덤프 파일 만들기 (앱 담당자, 로컬) + +전제: 로컬 Postgres(도커 컨테이너 `ccc-postgres`, 5444)에 `PesticideSyncManualLoaderTest`로 +데이터가 적재되어 있고 `[verify]` 단언이 통과한 상태. + +```bash +docker exec ccc-postgres pg_dump -U chamchamcham -d chamchamchamdb \ + -t pesticide -t pest -t pesticide_application -t pesticide_sync_job \ + --exclude-table-data=pesticide_sync_job \ + --clean --if-exists --no-owner --no-privileges \ + > pesticide-seed.sql +``` + +- `--exclude-table-data=pesticide_sync_job`: 잡 이력 테이블은 **구조만** 만들고 로컬 잡 로그는 + 옮기지 않는다. +- `--clean --if-exists`: 파일 맨 앞에 `DROP TABLE IF EXISTS ...`가 붙어, 서버에서 **다시 적용해도** + 깨지지 않는다(기존 4개 테이블을 지우고 새로 만든다 — 서버에 이 데이터 외 보존할 것이 없다는 전제). +- `--no-owner --no-privileges`: 로컬 롤/권한 구문을 빼서 서버 계정에 그대로 적용되게 한다. + +만들어진 파일을 확인한다(사람 눈으로 첫 줄과 데이터량 정도만). + +```bash +ls -lh pesticide-seed.sql # 파일 크기(수십 MB 수준이면 정상) +grep -c "^COPY " pesticide-seed.sql # COPY 블록이 3개(pesticide/pest/application) 보이면 정상 +``` + +이 `pesticide-seed.sql` 파일을 Part B 담당자에게 전달한다(사내 스토리지/첨부 등). **파일에 비밀값은 +없다**(공개 카탈로그 데이터 + 스키마뿐, API 키/토큰 없음). + +### Part B. 서버 DB에 적용 (서버 담당자) — 처음 하는 사람용 전체 절차 + +받는 것: `pesticide-seed.sql` 파일 하나. 필요한 것: 서버 PostgreSQL 접속 정보와 `psql` 클라이언트. +**dev에 먼저 적용해 확인한 뒤 prod에 적용한다.** + +1. **psql 준비 확인** — 로컬에 psql이 없으면 설치한다(macOS `brew install libpq`, Ubuntu + `apt-get install postgresql-client`). 서버 DB로 나가는 네트워크(방화벽/VPN)가 열려 있어야 한다. + +2. **접속 확인** — 접속 문자열을 환경변수로 둔다(히스토리에 비밀번호가 남지 않게 `read`로 입력). + `<...>`는 실제 값으로 바꾼다. + + ```bash + read -s -p "DB URL 입력: " DBURL; echo + # 형식 예: postgresql://<사용자>:<비밀번호>@<호스트>:<포트>/ + psql "$DBURL" -c "select version();" # 버전 문구가 출력되면 접속 성공 + ``` + +3. **(안전장치) 현재 상태 백업** — 만약 이미 같은 테이블이 있다면 먼저 백업한다. 없으면 이 단계는 + 에러 없이 빈 결과가 나오니 넘어가도 된다. + + ```bash + pg_dump "$DBURL" -t pesticide -t pest -t pesticide_application -t pesticide_sync_job \ + --no-owner --no-privileges > server-pesticide-backup-$(date +%Y%m%d).sql 2>/dev/null || true + ``` + +4. **적용** — 오류가 나면 즉시 멈추도록 `ON_ERROR_STOP=1`을 준다. + + ```bash + psql "$DBURL" -v ON_ERROR_STOP=1 -f pesticide-seed.sql + ``` + + 중간에 멈추지 않고 끝까지 돌면 성공이다. `--clean` 덕분에 재실행해도 된다. + +5. **검증** — 건수가 0이 아니고, 조인이 맞물리는지 확인한다. + + ```bash + psql "$DBURL" -c " + select 'pesticide' t, count(*) from pesticide + union all select 'pest', count(*) from pest + union all select 'pesticide_application', count(*) from pesticide_application;" + ``` + + `pesticide`/`pest`가 수천~수만, `pesticide_application`이 10만 안팎이면 정상이다(로컬 적재 시점의 + `[verify]` 건수와 일치해야 한다). + +6. **앱 반영** — dev/prod 앱은 `ddl-auto: none`이라 이 테이블들을 건드리지 않는다. 이미 떠 있으면 + 재시작 없이도 조회 API(`GET /api/v1/pesticides`, `.../{id}/pests`)가 바로 실데이터를 반환한다. + 단, **서버에 배포된 앱 빌드가 이번 농약 기능(엔티티)을 포함**하고 있어야 한다. + +7. **dev 확인 후 prod 반복** — 3~5단계를 prod 접속 문자열로 한 번 더 수행한다. + +**롤백**: 문제가 생기면 4단계 파일을 다시 적용하거나(멱등), 3단계 백업 파일을 `psql "$DBURL" -f +server-pesticide-backup-YYYYMMDD.sql`로 되돌린다. 완전히 비우려면 +`truncate pesticide_application, pest, pesticide restart identity cascade;`. + +> ⛔ **절대 금지**: 이 서버 DB를 로컬 앱/로더가 `local` 프로필(`ddl-auto: create`)로 바라보게 하면 +> 부팅 순간 스키마째 삭제된다. 로더는 로컬 DB 전용이다. + +### 이 라운드의 다른 스키마 변경 (참고) + +Phase 1 기록 항목 개선(`feat/farming-record-refine` 브랜치)은 기존 farming 테이블의 컬럼을 +바꾼다(신규 `planting_method` 등 enum/단위 변경). 이는 옮길 데이터가 없어 덤프가 아니라 해당 PR의 +배포노트대로 스키마를 갱신해야 하며, **농약 반영과는 별개**다.