Conversation
Walkthrough배치 스텝에 WebClientRequestException 재시도(faultTolerant, retryLimit(3))를 추가하고, 일/연간 처리기에 로깅·지연(200ms)·예외 처리를 강화했습니다. 연간 조회에서 Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Job as Spring Batch Job
participant Step as Kamis*PriceSyncStep
participant Reader as ItemReader
participant Processor as ItemProcessor
participant Writer as ItemWriter
participant API as KamisApiClient
Note over Step: faultTolerant + retryLimit(3)<br/>retry on WebClientRequestException
Job->>Step: execute()
loop for each item
Step->>Reader: read()
Reader-->>Step: item or null
alt item != null
Step->>Processor: process(item)
rect rgba(230,240,255,0.6)
Processor->>API: call(...)
Note right of Processor: 200ms throttle
API-->>Processor: response
end
alt WebClientRequestException
Step-->>Processor: retry up to 3
else success
Processor-->>Step: result
Step->>Writer: write(result)
end
else
Step-->>Job: complete
end
end
sequenceDiagram
autonumber
participant Proc as KamisAnnualPriceProcessor
participant API as KamisApiClient
participant Parser as JSON Parser
participant Ent as Entity Builder
Proc->>API: getYearlyPrices(itemCode, kindCode, year)
Note right of Proc: sleep 200ms
API-->>Proc: response string or invalid
alt invalid/absent
Proc-->>Proc: return null
else valid
Proc->>Parser: parseAndFilterPriceItems()
Parser-->>Proc: retail priceItems or null
alt null
Proc-->>Proc: return null
else
Proc->>Ent: convertToEntities(priceItems, year)
Ent-->>Proc: List<AnnualNationalPrice>
Proc-->>Proc: return list
end
end
opt InterruptedException
Proc-->>Proc: interrupt thread, return null
end
sequenceDiagram
autonumber
participant Reader as KamisDailyPriceApiReader
participant API as KamisApiClient
participant Log as Logger
loop categoryCodes × regionCodes
Reader->>API: getDailyPrices(...)
Note right of Reader: sleep 200ms
alt success && errorCode == "000"
API-->>Reader: items
Reader-->>Reader: collect KamisDailyPriceApiData
else InterruptedException
Reader->>Log: warn + re-interrupt
else other Exception
Reader->>Log: error(category, region, message)
end
end
Reader->>Log: info(initialized with N items)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
Status, Documentation and Community
|
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (9)
src/main/kotlin/com/eodigo/batch/KamisAnnualPriceBatchConfiguration.kt (1)
66-66: 카테고리 코드 non-null 속성 확인됨—NULL 처리 불필요, 매직 문자열만 파라미터화 권장
Product.entity의categoryCode가@Column(nullable = false) val categoryCode: String로 non-null이 보장되므로 NULL 체크는 생략해도 됩니다. 대신 쿼리의 매직 리터럴 제거를 위해 파라미터화하세요.- .queryString("SELECT p FROM Product p WHERE p.categoryCode != '500' ORDER BY p.id ASC") + .queryString("SELECT p FROM Product p WHERE p.categoryCode <> :excluded ORDER BY p.id ASC") + .parameterValues(mapOf("excluded" to "500"))src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt (4)
28-29: 현재 연도(LocalDate.now) 고정 의존 — 재실행 재현성 저하. jobParameter 주입 권장연도 기준을 파라미터화하면 백필/재처리 시 안정적입니다. Processor에 대상 연도를 주입(선택적)하고 미지정 시 now()를 사용하세요.
예시:
-class KamisAnnualPriceProcessor( - private val kamisApiClient: KamisApiClient, - private val apiKey: String, - private val certId: String, -) : ItemProcessor<Product, List<AnnualNationalPrice>> { +class KamisAnnualPriceProcessor( + private val kamisApiClient: KamisApiClient, + private val apiKey: String, + private val certId: String, + private val targetYear: Int? = null, +) : ItemProcessor<Product, List<AnnualNationalPrice>> { @@ - val currentYear = LocalDate.now().year.toString() + val currentYear = (targetYear ?: LocalDate.now().year).toString()구성 클래스에서 StepScope로 연도 파라미터 주입을 연결해 드릴 수 있습니다.
50-54: 예외 로깅에서 stack trace 누락원인 파악을 위해 예외 객체를 함께 로깅하세요.
- log.error( - "Failed to process annual price for product id: ${item.id}. Error: ${e.message}" - ) + log.error("Failed to process annual price for product id: {}", item.id, e)
72-72: Thread.sleep(200) 매직 넘버 — 상수/설정으로 추출지연값을 상수 또는 설정(@value)로 노출해 운영 중 튜닝 가능하게 하세요.
- Thread.sleep(200) + Thread.sleep(API_CALL_DELAY_MS)파일 상단(또는 companion object)에 다음을 추가:
private const val API_CALL_DELAY_MS = 200L혹은 생성자 파라미터로 주입받도록 변경 가능합니다.
87-91: caption 문자열(“상품”) 의존은 취약 — 코드값 기반 필터만 사용 권장표기 변경/다국어에 취약합니다. productClsCode만으로 판별하세요.
- val retailPriceSection = - priceSections.find { it.productClsCode == "01" && it.caption?.contains("상품") == true } + val retailPriceSection = + priceSections.find { it.productClsCode == "01" }KAMIS 스키마에서 productClsCode “01”=소매가 맞는지 사양 링크/문서 기준 확인 부탁드립니다.
src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt (4)
75-80: 예외 로깅에서 stack trace 누락원인 분석을 위해 예외 객체를 함께 전달하세요.
- log.error( - "Failed to fetch data for category: {}, region: {}. Error: {}", - categoryCode, - regionCode, - e.message, - ) + log.error( + "Failed to fetch data for category: {}, region: {}", + categoryCode, + regionCode, + e + )
61-69: API 비정상 응답(errorCode != "000")에 대한 관찰성 부족 — 경고 로그 추가 권장성공 외 케이스를 로그로 남기면 장애 분석이 수월합니다.
- if (response?.data?.errorCode == "000") { + if (response?.data?.errorCode == "000") { response.data.items?.let { items -> val wrappedItems = items.map { item -> KamisDailyPriceApiData(regionCode = regionCode, item = item) } allItems?.addAll(wrappedItems) } + } else { + val code = response?.data?.errorCode + val msg = response?.data?.errorMsg + log.warn( + "KAMIS daily API returned errorCode={}, errorMsg={} for category={}, region={}", + code, msg, categoryCode, regionCode + ) }
70-74: Thread.sleep(200) 매직 넘버 — 상수/설정으로 추출운영 튜닝을 위해 지연값을 상수 또는 설정으로 노출하세요.
- Thread.sleep(200) + Thread.sleep(API_CALL_DELAY_MS)이 파일에 상수 추가:
private const val API_CALL_DELAY_MS = 200L또는 Reader 생성 시 파라미터로 주입하는 방식을 선호합니다.
84-84: 문자열 보간 로그 → placeholder 스타일로 통일SLF4J 권장 스타일을 사용해 불필요한 문자열 생성 방지.
- log.info("KamisDailyPriceApiReader: Total ${allItems?.size} items initialized.") + log.info("KamisDailyPriceApiReader: Total {} items initialized.", allItems?.size)
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (4)
src/main/kotlin/com/eodigo/batch/KamisAnnualPriceBatchConfiguration.kt(3 hunks)src/main/kotlin/com/eodigo/batch/KamisDailyPriceBatchConfiguration.kt(2 hunks)src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt(2 hunks)src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt(3 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
src/**/*.kt
⚙️ CodeRabbit configuration file
src/**/*.kt: ## 1. Kotlin 코딩 컨벤션
- Kotlin 공식 코딩 컨벤션을 준수해야 합니다.
- 클래스는 PascalCase, 함수/변수는 camelCase, 상수는 UPPER_SNAKE_CASE로 작성하세요.
- 변경 불가능한 데이터는 val을, 변경이 필요한 경우에만 var를 사용하세요.
- Null Pointer Exception을 유발하는 !! 연산자 사용을 금지합니다.
- 의미를 알 수 없는 숫자나 문자열(매직 넘버) 대신 명명된 상수를 사용하세요.
2. Spring Boot
- Controller-Service-Repository 계층형 아키텍처의 책임을 분리하세요.
- Controller의 요청/응답에는 반드시 DTO를 사용하고, 엔티티를 직접 노출하지 마세요.
- 민감한 설정 정보(DB 계정, API 키 등)는 코드에 하드코딩하지 말고 외부에서 주입하세요.
- @Autowired 필드 주입 대신 생성자 주입을 사용하세요.
3. JPA 및 데이터베이스
- @entity 클래스에 Setter 사용을 지양하고, 비즈니스 메서드를 통해 상태를 변경하세요.
- 데이터를 변경하지 않는 조회용 Service 메서드에는 @transactional(readOnly = true)를 적용하세요.
- N+1 문제가 발생하는 쿼리가 있는지 확인하고, Fetch Join으로 최적화하세요.
- 엔티티의 양방향 연관관계에서 toString()을 사용할 경우 순환 참조가 발생하지 않도록 주의하세요.
4. 예외 처리
- @RestControllerAdvice를 사용하여 예외를 전역에서 일관되게 처리하세요.
- RuntimeException 대신, 의미가 명확한 커스텀 예외(예: MemberNotFoundException)를 정의하여 사용하세요.
Files:
src/main/kotlin/com/eodigo/batch/KamisAnnualPriceBatchConfiguration.ktsrc/main/kotlin/com/eodigo/batch/KamisDailyPriceBatchConfiguration.ktsrc/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.ktsrc/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build-and-test
🔇 Additional comments (4)
src/main/kotlin/com/eodigo/batch/KamisAnnualPriceBatchConfiguration.kt (1)
23-23: spring-retry 의존성 확인 및 backOffPolicy 적용
org.springframework.retry:spring-retry라이브러리가 Gradle 설정에 포함되어 있는지 수동으로 검증하고(검색 결과 없으므로), 누락됐다면 의존성을 추가한 뒤에.faultTolerant() .retryLimit(3) .retry(WebClientRequestException::class.java) .backOffPolicy(FixedBackOffPolicy().apply { backOffPeriod = 500L }) .build()와 같이 backoff 정책을 설정해 주세요.
src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt (2)
99-117: AnnualNationalPrice.price가 Int 타입으로 정의되어 있어, 제안된 BigDecimal → Int 반올림 변환 적용이 적절합니다. 엔티티 타입 변경 없이 코드 적용 가능합니다.
31-45: Writer의 중복 키 처리 로직 안전 확인
Writer는findByProductIdAndSurveyYearAndMarketType로 기존 레코드를 조회해updatePrice후 merge하고, id가 null인 신규 엔티티는 persist하여 unique 제약을 위반하지 않습니다. parse 단계에서 동일 키 중복 생성이 없으므로 별도 대응 불필요합니다.src/main/kotlin/com/eodigo/batch/KamisDailyPriceBatchConfiguration.kt (1)
27-27: spring-retry 의존성 확인 및 backoffPolicy 적용FixedBackOffPolicy 적용 전
org.springframework.retry모듈이 빌드에 포함됐는지 확인하세요. 없으면 build.gradle(.kts)에 의존성을 추가한 뒤 아래처럼 backoffPolicy를 설정해주세요.+import org.springframework.retry.backoff.FixedBackOffPolicy @@ .writer(kamisDailyPriceJpaWriter()) .faultTolerant() .retryLimit(3) .retry(WebClientRequestException::class.java) + .backOffPolicy(FixedBackOffPolicy().apply { backOffPeriod = 500L }) .build()
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/main/kotlin/com/eodigo/batch/processor/KamisDailyPriceProcessor.kt (1)
45-47: 가격 파싱을 보다 견고하게: 숫자 변환 실패 방지
,제거만으로는 공백/비숫자 문자가 남아 NFE가 날 수 있습니다.toIntOrNull()기반으로 안전 파싱하고, 변환된 값을 재사용하세요.- val priceStr = item.price?.replace(",", "") - if (priceStr.isNullOrBlank() || priceStr == "-") { - return null - } + val priceStrNormalized = item.price?.replace(",", "")?.trim() + val price = priceStrNormalized?.toIntOrNull() ?: return null ... - price = priceStr.toInt(), + price = price,Also applies to: 62-68
🧹 Nitpick comments (9)
src/main/kotlin/com/eodigo/batch/scheduler/KamisJobScheduler.kt (1)
30-33: 서울 타임존 기준 산출, 좋습니다. run.time도 동일 타임존으로 정렬 권장스케줄러 zone과 동일하게
run.time파라미터도 서울 기준으로 맞추면 추후 로그/리플레이 시 혼선을 줄일 수 있습니다.다음과 같이 변경해 주세요:
- .addLocalDateTime("run.time", LocalDateTime.now()) + .addLocalDateTime("run.time", LocalDateTime.now(ZoneId.of("Asia/Seoul")))src/main/kotlin/com/eodigo/batch/processor/KamisDailyPriceProcessor.kt (1)
51-58: 널 키 조합 방지: 유효하지 않은 키 조합은 조기 필터링
createProductKey(itemCode, kindCode)가null-...형태를 만들 수 있습니다. 키 생성 전에item.itemCode/item.kindCode/wrapper.regionCode널·빈값을 선제 차단하면 불필요한 로그와 캐시 미스가 줄어듭니다.src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt (2)
34-35: 연도 계산도 서울 타임존으로 일관화스케줄이
Asia/Seoul기준이므로 현 연도 계산도 동일 타임존으로 맞추는 것이 안전합니다(해외 서버 시간대 영향 방지).- val currentYear = LocalDate.now().year.toString() + val currentYear = LocalDate.now(java.time.ZoneId.of("Asia/Seoul")).year.toString()
91-97: 리테일 섹션 선택 로직 보강(캡션 의존 완화)일부 응답에서
caption이 누락/불일치할 수 있습니다.RETAIL_CODE일치 섹션을 1순위, 실패 시 코드만으로 fallback 하도록 완화하면 누락을 줄일 수 있습니다.- val retailPriceSection = - priceSections.find { - it.productClsCode == RETAIL_CODE && it.caption?.contains(RANK_STANDARD) == true - } + val retailPriceSection = + priceSections.find { + it.productClsCode == RETAIL_CODE && it.caption?.contains(RANK_STANDARD) == true + } ?: priceSections.find { it.productClsCode == RETAIL_CODE }src/main/kotlin/com/eodigo/batch/KamisDailyPriceBatchConfiguration.kt (2)
27-27: 예외 타깃은 적절하나, 응답 단계 오류도 재시도 고려네트워크 레벨(WebClientRequestException)만이 아니라 5xx 등 응답 레벨(WebClientResponseException)도 재시도 대상으로 포함하는 것을 권장합니다.
-import org.springframework.web.reactive.function.client.WebClientRequestException +import org.springframework.web.reactive.function.client.WebClientRequestException +import org.springframework.web.reactive.function.client.WebClientResponseException- .retry(WebClientRequestException::class.java) + .retry(WebClientRequestException::class.java) + .retry(WebClientResponseException::class.java)
74-75: 빈 코드 목록에 대한 빠른 실패 또는 경고 추가 제안DB에서 카테고리/지역 코드가 비어오면 조용히 “성공처럼” 종료될 수 있습니다. 빠른 실패(throw) 또는 명시적 경고 로그를 권장합니다.
- val categoryCodes = productRepository.findDistinctCategoryCodes() - val regionCodes = regionRepository.findAllCodes() + val categoryCodes = productRepository.findDistinctCategoryCodes() + val regionCodes = regionRepository.findAllCodes() + require(categoryCodes.isNotEmpty()) { "No category codes found for daily price sync." } + require(regionCodes.isNotEmpty()) { "No region codes found for daily price sync." }src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt (3)
19-25: 상수·로거 도입은 👍, 지연값은 프로퍼티로 외부화 고려API_CALL_DELAY_MS를 애플리케이션 프로퍼티로 외부화하면 운영 환경별 조정이 수월합니다(예: kamis.api.call-delay-ms).
7-7: 필수 import 누락 가능성위 수정 반영 시 WebClientRequestException import가 필요합니다.
import org.slf4j.LoggerFactory +import org.springframework.web.reactive.function.client.WebClientRequestException
89-89: 초기화 로그: 널 안전·가독성 개선allItems는 이 시점에 널이 아니므로 명시적으로 사이즈를 계산하세요.
- log.info("KamisDailyPriceApiReader: Total ${allItems?.size} items initialized.") + val total = allItems?.size ?: 0 + log.info("KamisDailyPriceApiReader: Total {} items initialized.", total)
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (7)
src/main/kotlin/com/eodigo/batch/KamisDailyPriceBatchConfiguration.kt(3 hunks)src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt(2 hunks)src/main/kotlin/com/eodigo/batch/processor/KamisDailyPriceProcessor.kt(2 hunks)src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt(3 hunks)src/main/kotlin/com/eodigo/batch/scheduler/KamisJobScheduler.kt(2 hunks)src/main/kotlin/com/eodigo/domain/product/repository/ProductRepository.kt(2 hunks)src/main/kotlin/com/eodigo/domain/product/repository/RegionRepository.kt(1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
src/**/*.kt
⚙️ CodeRabbit configuration file
src/**/*.kt: ## 1. Kotlin 코딩 컨벤션
- Kotlin 공식 코딩 컨벤션을 준수해야 합니다.
- 클래스는 PascalCase, 함수/변수는 camelCase, 상수는 UPPER_SNAKE_CASE로 작성하세요.
- 변경 불가능한 데이터는 val을, 변경이 필요한 경우에만 var를 사용하세요.
- Null Pointer Exception을 유발하는 !! 연산자 사용을 금지합니다.
- 의미를 알 수 없는 숫자나 문자열(매직 넘버) 대신 명명된 상수를 사용하세요.
2. Spring Boot
- Controller-Service-Repository 계층형 아키텍처의 책임을 분리하세요.
- Controller의 요청/응답에는 반드시 DTO를 사용하고, 엔티티를 직접 노출하지 마세요.
- 민감한 설정 정보(DB 계정, API 키 등)는 코드에 하드코딩하지 말고 외부에서 주입하세요.
- @Autowired 필드 주입 대신 생성자 주입을 사용하세요.
3. JPA 및 데이터베이스
- @entity 클래스에 Setter 사용을 지양하고, 비즈니스 메서드를 통해 상태를 변경하세요.
- 데이터를 변경하지 않는 조회용 Service 메서드에는 @transactional(readOnly = true)를 적용하세요.
- N+1 문제가 발생하는 쿼리가 있는지 확인하고, Fetch Join으로 최적화하세요.
- 엔티티의 양방향 연관관계에서 toString()을 사용할 경우 순환 참조가 발생하지 않도록 주의하세요.
4. 예외 처리
- @RestControllerAdvice를 사용하여 예외를 전역에서 일관되게 처리하세요.
- RuntimeException 대신, 의미가 명확한 커스텀 예외(예: MemberNotFoundException)를 정의하여 사용하세요.
Files:
src/main/kotlin/com/eodigo/domain/product/repository/ProductRepository.ktsrc/main/kotlin/com/eodigo/batch/scheduler/KamisJobScheduler.ktsrc/main/kotlin/com/eodigo/domain/product/repository/RegionRepository.ktsrc/main/kotlin/com/eodigo/batch/KamisDailyPriceBatchConfiguration.ktsrc/main/kotlin/com/eodigo/batch/processor/KamisDailyPriceProcessor.ktsrc/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.ktsrc/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt
🔇 Additional comments (3)
src/main/kotlin/com/eodigo/batch/processor/KamisDailyPriceProcessor.kt (2)
21-23: 상수 도입으로 매직 넘버 제거, 👍
RANK_CODE_STANDARD상수화로 가독성과 유지보수성이 좋아졌습니다.
41-43: 필터 기준 변경(문자열 → 코드)으로 결과 집합 달라질 수 있음 — 도메인 매핑 재확인 필요
item.rank == "상품"에서item.rankCode == "04"로 바뀌었습니다. KAMIS 스키마에서rankCode="04"가 항상 "상품"을 의미하는지, 연/일별 API 간 불일치가 없는지 확인해 주세요. 기준이 다르면 누락/과다 필터링 위험이 있습니다.검증이 필요하면 알려주세요. 단위 테스트/샘플 페이로드 기반의 검증 케이스를 만들어 드리겠습니다.
src/main/kotlin/com/eodigo/domain/product/repository/ProductRepository.kt (1)
17-18:findDistinctCategoryCodes에WHERE p.categoryCode IS NOT NULL조건 불필요categoryCode는@Column(nullable=false)및 Kotlin non-null 타입이므로 null이 반환되지 않으며, 기존 쿼리를 그대로 유지해도 됩니다.Likely an incorrect or invalid review comment.
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (3)
src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt (2)
66-74: 비성공 응답 로깅 누락 — 관측성 보강 필요KAMIS가 errorCode != "000"인 경우 경고 로그가 없어 트러블슈팅이 어렵습니다. 아래처럼 코드/메시지를 함께 남겨주세요.
- if (response?.data?.errorCode == ERROR_CODE_SUCCESS) { + if (response?.data?.errorCode == ERROR_CODE_SUCCESS) { response.data.items?.let { items -> val wrappedItems = items.map { item -> KamisDailyPriceApiData(regionCode = regionCode, item = item) } allItems?.addAll(wrappedItems) } + } else { + val code = response?.data?.errorCode + val msg = response?.data?.errorMessage + log.warn( + "KAMIS daily API non-success. category={}, region={}, code={}, message={}", + categoryCode, regionCode, code, msg + ) }
75-87: 재시도 무력화·중복 위험 — WebClientRequestException 재던지기 전 상태 리셋예외를 포괄적으로 던지지만, 부분적으로 채워진 allItems가 남아 재시도 시 중복/불완전 읽기가 발생할 수 있습니다. 네트워크 계열 예외에서 상태를 초기화하고 재던지세요. 또한 현재 try 블록에서 InterruptedException이 발생할 곳이 없어 해당 catch는 도달하지 않습니다(불필요).
- } catch (e: InterruptedException) { - log.warn("API call delay was interrupted", e) - Thread.currentThread().interrupt() - break - } catch (e: Exception) { - log.error( - "Failed to fetch data for category: {}, region: {}. Error: {}", - categoryCode, - regionCode, - e.message, - ) - throw e + } catch (e: org.springframework.web.reactive.function.client.WebClientRequestException) { + // Clean retry: reset state and rethrow to let Step-level retry handle it + allItems = null + currentItemIndex = 0 + log.warn( + "Transient client/network error — triggering step retry. category={}, region={}", + categoryCode, regionCode, e + ) + throw e + } catch (e: Exception) { + log.error("Failed to fetch daily data. category={}, region={}", categoryCode, regionCode, e) + throw e }src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt (1)
36-59: 재시도 범위 축소: 네트워크 예외만 재던지고 나머지는 스킵모든 예외를 재던지면 파싱/데이터 이슈까지 스텝 실패로 이어집니다.
WebClientRequestException만 재던지고, 그 외는 로깅 후 null 반환으로 스킵하세요.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) { 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, e) - throw e + } catch (e: org.springframework.web.reactive.function.client.WebClientRequestException) { + log.warn("Transient client/network error — triggering step retry. productId={}", item.id, e) + throw e + } catch (e: Exception) { + log.error("Failed to process annual price (non-retryable). productId={}", item.id, e) + return null }검증 스크립트(재시도 대상 확인):
#!/bin/bash # Annual Batch에서 retry 대상을 점검 (WebClientRequestException) rg -n -C2 -g '!**/build/**' -P 'KamisAnnualPriceBatchConfiguration|faultTolerant\(|retry\(|retryLimit\('
🧹 Nitpick comments (4)
src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt (1)
97-97: 로그 값 명시화(경미)널 가능성은 낮지만 가독성을 위해 0 기본값을 사용하세요.
- log.info("KamisDailyPriceApiReader: Total ${allItems?.size} items initialized.") + val count = allItems?.size ?: 0 + log.info("KamisDailyPriceApiReader: Total {} items initialized.", count)src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt (3)
22-26: 상수화 👍 + 누락 상수 제안API 지연·리테일 코드·캡션 기준 상수화는 적절합니다. “결과가 존재하지 않습니다.”도 상수화해 중복·오타를 방지하세요.
companion object { private const val API_CALL_DELAY_MS = 200L private const val RETAIL_CODE = "01" private const val RANK_STANDARD = "상품" + private const val NO_RESULT_MESSAGE = "결과가 존재하지 않습니다." }
34-35: 현재 연도 계산의 시간대·테스트 가능성Asia/Seoul 스케줄과의 시간대 일치 및 테스트 용이성을 위해 Clock 주입을 고려하세요.
- val currentYear = LocalDate.now().year.toString() + val currentYear = LocalDate.now(clock).year.toString()추가: 생성자에
private val clock: Clock주입 및 기본값Clock.system(ZoneId.of("Asia/Seoul")).
61-82: 지연·재시도 책임의 모듈화 제안지연/재시도 로직을 KamisApiClient(또는 공통 헬퍼)로 흡수하면 Reader/Processor 중복이 줄고 정책 변경 시 한 곳에서 관리할 수 있습니다. resilience4j RateLimiter/Retry 도입을 고려하세요.
Also applies to: 36-59
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt(2 hunks)src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt(3 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
src/**/*.kt
⚙️ CodeRabbit configuration file
src/**/*.kt: ## 1. Kotlin 코딩 컨벤션
- Kotlin 공식 코딩 컨벤션을 준수해야 합니다.
- 클래스는 PascalCase, 함수/변수는 camelCase, 상수는 UPPER_SNAKE_CASE로 작성하세요.
- 변경 불가능한 데이터는 val을, 변경이 필요한 경우에만 var를 사용하세요.
- Null Pointer Exception을 유발하는 !! 연산자 사용을 금지합니다.
- 의미를 알 수 없는 숫자나 문자열(매직 넘버) 대신 명명된 상수를 사용하세요.
2. Spring Boot
- Controller-Service-Repository 계층형 아키텍처의 책임을 분리하세요.
- Controller의 요청/응답에는 반드시 DTO를 사용하고, 엔티티를 직접 노출하지 마세요.
- 민감한 설정 정보(DB 계정, API 키 등)는 코드에 하드코딩하지 말고 외부에서 주입하세요.
- @Autowired 필드 주입 대신 생성자 주입을 사용하세요.
3. JPA 및 데이터베이스
- @entity 클래스에 Setter 사용을 지양하고, 비즈니스 메서드를 통해 상태를 변경하세요.
- 데이터를 변경하지 않는 조회용 Service 메서드에는 @transactional(readOnly = true)를 적용하세요.
- N+1 문제가 발생하는 쿼리가 있는지 확인하고, Fetch Join으로 최적화하세요.
- 엔티티의 양방향 연관관계에서 toString()을 사용할 경우 순환 참조가 발생하지 않도록 주의하세요.
4. 예외 처리
- @RestControllerAdvice를 사용하여 예외를 전역에서 일관되게 처리하세요.
- RuntimeException 대신, 의미가 명확한 커스텀 예외(예: MemberNotFoundException)를 정의하여 사용하세요.
Files:
src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.ktsrc/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt
🔇 Additional comments (4)
src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt (1)
19-25: 상수·로거 도입 적절지연(ms)·성공코드 상수화와 로거 주입 모두 👍
src/main/kotlin/com/eodigo/batch/processor/KamisAnnualPriceProcessor.kt (3)
7-7: DTO 타입 명시 import 적절연간 응답 파싱의 타입 안정성 확보에 도움이 됩니다.
84-97: 소매 섹션 선택 로직 적절productClsCode·캡션 기준 필터링 타당합니다.
130-158: 유연한 응답(normalize) 처리 👍price가 List/Map 양자 처리 및 예외 로깅이 적절합니다.
| /** 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 | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| /** 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.
| 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 } | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| 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.
| } finally { | ||
| try { | ||
| Thread.sleep(API_CALL_DELAY_MS) | ||
| } catch (ie: InterruptedException) { | ||
| log.warn("API call delay was interrupted", ie) | ||
| Thread.currentThread().interrupt() | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
인터럽트 시 초기화 즉시 중단하여 부분 상태 방지
sleep 인터럽트 후에도 루프가 계속되어 부분 수집 상태가 남을 수 있습니다. 인터럽트 재설정 후 안전 종료하세요.
} finally {
try {
Thread.sleep(API_CALL_DELAY_MS)
} catch (ie: InterruptedException) {
log.warn("API call delay was interrupted", ie)
Thread.currentThread().interrupt()
- }
+ // Abort initialization to avoid partial state on interrupt
+ allItems = null
+ currentItemIndex = 0
+ return
+ }
}📝 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.
| } finally { | |
| try { | |
| Thread.sleep(API_CALL_DELAY_MS) | |
| } catch (ie: InterruptedException) { | |
| log.warn("API call delay was interrupted", ie) | |
| Thread.currentThread().interrupt() | |
| } | |
| } finally { | |
| try { | |
| Thread.sleep(API_CALL_DELAY_MS) | |
| } catch (ie: InterruptedException) { | |
| log.warn("API call delay was interrupted", ie) | |
| Thread.currentThread().interrupt() | |
| // Abort initialization to avoid partial state on interrupt | |
| allItems = null | |
| currentItemIndex = 0 | |
| return | |
| } | |
| } |
🤖 Prompt for AI Agents
In src/main/kotlin/com/eodigo/batch/reader/KamisDailyPriceApiReader.kt around
lines 87 to 93, the catch for InterruptedException currently logs and resets the
interrupt but allows the loop to continue, risking partial/half-collected state;
after restoring the interrupt (Thread.currentThread().interrupt()) immediately
stop the reader's execution (e.g., break out of the surrounding loop or return
from the method, or rethrow the InterruptedException) so the job exits safely
and no further processing occurs.
📝 작업 내용
외부 API(KAMIS)를 사용하는 일일 가격 동기화 배치와 연간 가격 동기화 배치의 안정성을 개선했습니다.
간헐적으로 발생하던 아래의 오류를 해결하고, 불필요한 API 호출 및 DB 업데이트 로직을 최적화했습니다.
⚡ 주요 변경사항
API 요청 속도 제어 (
Thread.sleep추가)200ms의 지연 시간을 추가하여 트래픽을 분산시켰습니다. (KamisDailyPriceApiReader,KamisAnnualPriceProcessor)재시도(Retry) 로직 도입
faultTolerant기능을 활용하여, 네트워크 예외(WebClientRequestException) 발생 시 최대 3회까지 작업을 자동으로 재시도하도록 설정했습니다.연간 가격 배치 로직 최적화
categoryCode: 500)에 대해 불필요한 API를 호출하지 않도록Reader의 JPQL 쿼리를 수정했습니다.Processor로직을 개선했습니다. 이를 통해 불필요한 DBUPDATE쿼리를 제거했습니다.📌 리뷰 포인트
📋 체크리스트
feat: 기능1 추가)Summary by CodeRabbit
신기능
리팩터
변경