Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.web.reactive.function.client.WebClientRequestException

@Configuration
class KamisAnnualPriceBatchConfiguration(
Expand Down Expand Up @@ -50,6 +51,9 @@ class KamisAnnualPriceBatchConfiguration(
.reader(kamisAnnualPriceReader())
.processor(kamisAnnualPriceProcessor())
.writer(kamisAnnualPriceJpaItemWriter())
.faultTolerant()
.retryLimit(3)
.retry(WebClientRequestException::class.java)
.build()
}

Expand All @@ -59,7 +63,7 @@ class KamisAnnualPriceBatchConfiguration(
.name("kamisAnnualPriceReader")
.entityManagerFactory(entityManagerFactory)
.pageSize(CHUNK_SIZE)
.queryString("SELECT p FROM Product p ORDER BY p.id ASC")
.queryString("SELECT p FROM Product p WHERE p.categoryCode != '500' ORDER BY p.id ASC")
.build()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.web.reactive.function.client.WebClientRequestException

@Configuration
class KamisDailyPriceBatchConfiguration(
Expand Down Expand Up @@ -55,6 +56,9 @@ class KamisDailyPriceBatchConfiguration(
.reader(kamisDailyPriceApiReader(null))
.processor(kamisDailyPriceProcessor(null))
.writer(kamisDailyPriceJpaWriter())
.faultTolerant()
.retryLimit(3)
.retry(WebClientRequestException::class.java)
Comment thread
23tae marked this conversation as resolved.
.build()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.eodigo.domain.product.entity.AnnualNationalPrice
import com.eodigo.domain.product.entity.Product
import com.eodigo.domain.product.enums.MarketType
import com.eodigo.external.kamis.KamisApiClient
import com.eodigo.external.kamis.KamisYearlyPriceItemDto
import com.eodigo.external.kamis.KamisYearlyPriceSectionDto
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
Expand All @@ -22,58 +23,98 @@ class KamisAnnualPriceProcessor(
private val objectMapper: ObjectMapper = jacksonObjectMapper()

override fun process(item: Product): List<AnnualNationalPrice>? {
val currentYear = LocalDate.now().year.toString()
val kindCode = item.kindCode ?: return null

val currentYear = LocalDate.now().year.toString()

try {
// 1. API 호출
val responseString = fetchAnnualPriceData(item.itemCode, kindCode, currentYear)
if (responseString == null) {
return null
}

// 2. 응답 파싱 및 필터링
val priceItems = parseAndFilterPriceItems(responseString)
if (priceItems == null) {
return null
}

// 3. 엔티티로 변환
return convertToEntities(priceItems, item, currentYear)
} catch (e: InterruptedException) {
Comment thread
23tae marked this conversation as resolved.
log.warn("Annual price processing was interrupted for product id: ${item.id}", e)
Thread.currentThread().interrupt()
return null
} catch (e: Exception) {
log.error(
"Failed to process annual price for product id: ${item.id}. Error: ${e.message}"
)
return null
}
Comment thread
23tae marked this conversation as resolved.
}

/** API를 호출하고 응답 문자열을 반환합니다. 유효하지 않은 응답은 null을 반환합니다. */
private fun fetchAnnualPriceData(
itemCode: String,
kindCode: String,
currentYear: String,
): String? {
val responseString =
kamisApiClient.getYearlyPrices(
certKey = apiKey,
certId = certId,
year = currentYear,
itemCode = item.itemCode,
itemCode = itemCode,
kindCode = kindCode,
)

Thread.sleep(200)

if (responseString.isNullOrBlank() || responseString.contains("결과가 존재하지 않습니다.")) {
return null
}
return responseString
}
Comment on lines +61 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

실패 시에도 지연 보장 — 지연을 finally로 이동

현재는 API 호출이 예외를 던지면 지연이 생략됩니다. 실패 시에도 레이트 리밋을 보장하세요.

-    private fun fetchAnnualPriceData(
+    private fun fetchAnnualPriceData(
         itemCode: String,
         kindCode: String,
         currentYear: String,
     ): String? {
-        val responseString =
-            kamisApiClient.getYearlyPrices(
-                certKey = apiKey,
-                certId = certId,
-                year = currentYear,
-                itemCode = itemCode,
-                kindCode = kindCode,
-            )
-
-        Thread.sleep(API_CALL_DELAY_MS)
+        val responseString =
+            try {
+                kamisApiClient.getYearlyPrices(
+                    certKey = apiKey,
+                    certId = certId,
+                    year = currentYear,
+                    itemCode = itemCode,
+                    kindCode = kindCode,
+                )
+            } finally {
+                try {
+                    Thread.sleep(API_CALL_DELAY_MS)
+                } catch (ie: InterruptedException) {
+                    log.warn("Annual API call delay was interrupted", ie)
+                    Thread.currentThread().interrupt()
+                }
+            }
 
-        if (responseString.isNullOrBlank() || responseString.contains("결과가 존재하지 않습니다.")) {
+        if (responseString.isNullOrBlank() || responseString.contains(NO_RESULT_MESSAGE)) {
             return null
         }
         return responseString
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** API를 호출하고 응답 문자열을 반환합니다. 유효하지 않은 응답은 null을 반환합니다. */
private fun fetchAnnualPriceData(
itemCode: String,
kindCode: String,
currentYear: String,
): String? {
val responseString =
kamisApiClient.getYearlyPrices(
certKey = apiKey,
certId = certId,
year = currentYear,
itemCode = item.itemCode,
itemCode = itemCode,
kindCode = kindCode,
)
Thread.sleep(API_CALL_DELAY_MS)
if (responseString.isNullOrBlank() || responseString.contains("결과가 존재하지 않습니다.")) {
return null
}
return responseString
}
/** API를 호출하고 응답 문자열을 반환합니다. 유효하지 않은 응답은 null을 반환합니다. */
private fun fetchAnnualPriceData(
itemCode: String,
kindCode: String,
currentYear: String,
): String? {
val responseString = try {
kamisApiClient.getYearlyPrices(
certKey = apiKey,
certId = certId,
year = currentYear,
itemCode = itemCode,
kindCode = kindCode,
)
} finally {
try {
Thread.sleep(API_CALL_DELAY_MS)
} catch (ie: InterruptedException) {
log.warn("Annual API call delay was interrupted", ie)
Thread.currentThread().interrupt()
}
}
if (responseString.isNullOrBlank() || responseString.contains(NO_RESULT_MESSAGE)) {
return null
}
return responseString
}
🤖 Prompt for AI Agents
In src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt
around lines 61 to 82, the Thread.sleep currently runs only after a successful
API call so exceptions skip the delay; wrap the kamisApiClient.getYearlyPrices
call (and assignment to responseString) in a try-finally block and move
Thread.sleep(API_CALL_DELAY_MS) into the finally clause so the delay always
executes whether the call succeeds or throws, preserving existing null/blank
checks and allowing exceptions to propagate as before.


/** API 응답을 파싱하여 유효한 가격 아이템 리스트를 반환합니다. 유효 데이터가 없으면 null을 반환합니다. */
private fun parseAndFilterPriceItems(responseString: String): List<KamisYearlyPriceItemDto>? {
val priceSections = normalizePriceSections(responseString)

if (priceSections.isNullOrEmpty()) {
return null
}

// 3. 소매 가격/'상품' 등급인 첫 번째 섹션을 찾음
val retailPriceSection =
priceSections.find { it.productClsCode == "01" && it.caption?.contains("상품") == true }

if (retailPriceSection?.items == null) {
return null
}
return retailPriceSection?.items
}

/** DTO 리스트를 엔티티 리스트로 변환합니다. 현재 연도의 데이터만 필터링합니다. */
private fun convertToEntities(
priceItems: List<KamisYearlyPriceItemDto>,
product: Product,
currentYear: String,
): List<AnnualNationalPrice> {
return priceItems
.filter { it.div == currentYear }
.mapNotNull { priceItem ->
val yearStr = priceItem.div
val priceStr = priceItem.avgData?.replace(",", "")

// API 응답 item을 엔티티 리스트로 변환
return retailPriceSection.items.mapNotNull { priceItem ->
val yearStr = priceItem.div
val priceStr = priceItem.avgData?.replace(",", "")

if (
yearStr != null &&
yearStr.all { it.isDigit() } &&
!priceStr.isNullOrBlank() &&
priceStr.all { it.isDigit() }
) {

AnnualNationalPrice(
product = item,
price = priceStr.toInt(),
surveyYear = yearStr.toInt(),
marketType = MarketType.RETAIL,
)
} else {
null
if (
yearStr != null && priceStr?.all(Char::isDigit) == true && priceStr.isNotEmpty()
) {
AnnualNationalPrice(
product = product,
price = priceStr.toInt(),
surveyYear = yearStr.toInt(),
marketType = MarketType.RETAIL,
)
} else {
null
}
}
}
}
Comment on lines +100 to 128
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

가격 파싱 견고성 강화(콤마 외 문자를 제거하고 toIntOrNull 사용)

숫자 외 문자(공백/원 단위 등)로 실패할 수 있습니다. 안전 파싱으로 NPE/NumberFormatException 방지하세요.

-                .mapNotNull { priceItem ->
-                    val yearStr = priceItem.div
-                    val priceStr = priceItem.avgData?.replace(",", "")
-
-                    if (
-                        yearStr != null &&
-                            priceStr?.all(Char::isDigit) == true &&
-                            priceStr.isNotEmpty()
-                    ) {
-                        AnnualNationalPrice(
-                            product = product,
-                            price = priceStr.toInt(),
-                            surveyYear = yearStr.toInt(),
-                            marketType = MarketType.RETAIL,
-                        )
-                    } else {
-                        null
-                    }
-                }
+                .mapNotNull { priceItem ->
+                    val yearStr = priceItem.div
+                    val priceStr = priceItem.avgData
+                        ?.replace(Regex("[^0-9]"), "")
+                        ?.trim()
+
+                    val price = priceStr?.toIntOrNull()
+                    val surveyYear = yearStr?.toIntOrNull()
+                    if (price != null && surveyYear != null) {
+                        AnnualNationalPrice(
+                            product = product,
+                            price = price,
+                            surveyYear = surveyYear,
+                            marketType = MarketType.RETAIL,
+                        )
+                    } else null
+                }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun convertToEntities(
priceItems: List<KamisYearlyPriceItemDto>,
product: Product,
currentYear: String,
): List<AnnualNationalPrice>? {
val entities =
priceItems
.filter { it.div == currentYear }
.mapNotNull { priceItem ->
val yearStr = priceItem.div
val priceStr = priceItem.avgData?.replace(",", "")
if (
yearStr != null &&
priceStr?.all(Char::isDigit) == true &&
priceStr.isNotEmpty()
) {
AnnualNationalPrice(
product = product,
price = priceStr.toInt(),
surveyYear = yearStr.toInt(),
marketType = MarketType.RETAIL,
)
} else {
null
}
}
return entities.ifEmpty { null }
}
private fun convertToEntities(
priceItems: List<KamisYearlyPriceItemDto>,
product: Product,
currentYear: String,
): List<AnnualNationalPrice>? {
val entities =
priceItems
.filter { it.div == currentYear }
.mapNotNull { priceItem ->
val yearStr = priceItem.div
val priceStr = priceItem.avgData
?.replace(Regex("[^0-9]"), "")
?.trim()
val price = priceStr?.toIntOrNull()
val surveyYear = yearStr?.toIntOrNull()
if (price != null && surveyYear != null) {
AnnualNationalPrice(
product = product,
price = price,
surveyYear = surveyYear,
marketType = MarketType.RETAIL,
)
} else null
}
return entities.ifEmpty { null }
}
🤖 Prompt for AI Agents
In src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt
around lines 100 to 128, the current parsing assumes priceStr contains only
digits after removing commas and uses toInt(), which can throw
NumberFormatException for other characters (spaces, currency symbols) and
doesn't safely parse year; update parsing to first normalize the string by
stripping all non-digit characters (e.g., replace(Regex("\\D+"), "")), then use
toIntOrNull() for both price and surveyYear, only construct AnnualNationalPrice
when both toIntOrNull results are non-null, otherwise return null for that item;
keep returning null for the whole list when empty.


private fun normalizePriceSections(responseString: String): List<KamisYearlyPriceSectionDto>? {
Expand Down
48 changes: 32 additions & 16 deletions src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.eodigo.batch.dto.KamisDailyPriceApiData
import com.eodigo.external.kamis.KamisApiClient
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import org.slf4j.LoggerFactory
import org.springframework.batch.item.ItemReader

class KamisDailyPriceApiReader(
Expand All @@ -15,6 +16,8 @@ class KamisDailyPriceApiReader(
private val surveyDate: LocalDate,
) : ItemReader<KamisDailyPriceApiData> {

private val log = LoggerFactory.getLogger(javaClass)

// 1. 처리할 모든 API 호출 결과(가격 아이템)를 담아둘 리스트
private var allItems: MutableList<KamisDailyPriceApiData>? = null
// 2. 현재 읽고 있는 아이템의 인덱스
Expand Down Expand Up @@ -45,26 +48,39 @@ class KamisDailyPriceApiReader(

for (categoryCode in categoryCodes) {
for (regionCode in regionCodes) {
val response =
kamisApiClient.getDailyPrices(
certKey = apiKey,
certId = certId,
regDay = regDay,
categoryCode = categoryCode,
countryCode = regionCode,
)
try {
val response =
kamisApiClient.getDailyPrices(
certKey = apiKey,
certId = certId,
regDay = regDay,
categoryCode = categoryCode,
countryCode = regionCode,
)

if (response?.data?.errorCode == "000") {
response.data.items?.let { items ->
val wrappedItems =
items.map { item ->
KamisDailyPriceApiData(regionCode = regionCode, item = item)
}
allItems?.addAll(wrappedItems)
if (response?.data?.errorCode == "000") {
response.data.items?.let { items ->
val wrappedItems =
items.map { item ->
KamisDailyPriceApiData(regionCode = regionCode, item = item)
}
allItems?.addAll(wrappedItems)
}
}
Thread.sleep(200)
} catch (e: InterruptedException) {
log.warn("API call delay was interrupted", e)
Thread.currentThread().interrupt()
} catch (e: Exception) {
log.error(
"Failed to fetch data for category: {}, region: {}. Error: {}",
categoryCode,
regionCode,
e.message,
)
}
Comment thread
23tae marked this conversation as resolved.
}
}
println("KamisDailyPriceApiReader: Total ${allItems?.size} items initialized.")
log.info("KamisDailyPriceApiReader: Total ${allItems?.size} items initialized.")
}
}