공식 환율은 평일 오전에만 공시되지만, 금융 서비스는 365일 24시간 중단 없이 운영되어야 합니다.
FinSight와 IdolGlow의 환율 통합 기능을 개발하며 직면했던 '주말 데이터 공백' 문제와, 이를 해결하기 위해 적용한 영업일 역추적 Fallback 전략을 정리했습니다.


주말도 서비스해야 하는 공식 환율 조회의 영업일 Fallback 금융 전략

1. 환율 데이터의 시간차

공식 환율은 영업일 기준, 사용자는 매일 조회

공식 환율은 중앙은행이나 공인된 기관에서 특정 시점에 공시합니다.
하지만 사용자는 언제든 환율을 궁금해합니다.

  • 공식 공시 주기 (월~금): 매일 오전 08:00~09:00 사이 새로운 기준율 공시
  • 데이터 공백기 (토~일 및 공휴일): 새로운 공시가 없으므로 API 응답이 비어 있음

증상: 당일 공식 환율이 비어 있음

예를 들어 2024년 10월 20일(토)에 환율을 조회하면 API는 빈 값을 반환합니다.
GET /api/exchange-rates?date=2024-10-20Response: { items: [] }

이전 구현에서는 이 공백을 메우기 위해 지점 DB에 있는 임의의 데이터를 끌어다 썼지만, 이는 데이터의 신뢰성을 떨어뜨리는 나쁜 설계였습니다.


2. 지점 DB 데모 데이터로 버티기

Fallback이 역할을 혼동할 때 발생하는 문제

지점 DB는 각 환전 지점이 기준율에 마진을 더해 산출한 '지점 시세'를 담고 있습니다.
이를 공식 환율의 Fallback으로 사용하면 다음과 같은 문제가 발생합니다.

// 나쁜 예: 출처가 다른 데이터를 공식인 것처럼 위장
fun queryDailyRates(date: LocalDate): List<ExchangeRate> {
    val official = exchangeRateQueryPort.fetchDailyRates(date)
    
    return if (official.isEmpty()) {
        exchangeBranchRepository.findAllByDate(date)
            .map { branch ->
                ExchangeRate(
                    currency = branch.currency,
                    rate = branch.rate,  // 마진이 포함된 지점 시세
                    source = "official"  //  x 거짓 정보 제공
                )
            }
    } else {
        official
    }
}
  • 데이터 오염: 사용자는 기준율을 기대했는데 마진이 포함된 지점 시세를 보게 됩니다.
  • 신뢰성 하락: "왜 주말 시세는 평일과 비교해서 유독 차이가 클까?"라는 의문을 해결할 수 없습니다.
  • 불필요한 의존성: 공식 환율 서비스가 지점 관리 리포지토리까지 알아야 하는 결합도가 발생합니다.

3. 영업일 역추적 Fallback

"가장 최근의 영업일 데이터를 찾을 때까지 거슬러 올라가기"

데이터가 없는 날에는 다른 출처의 가짜 데이터를 보여주는 대신, 가장 최근에 공시된 진짜 공식 환율을 찾아 보여주는 것이 금융 서비스의 정석입니다.

로직 흐름:
1. 당일 환율 조회 시도
2. 데이터가 없으면 전날로 이동 단, 토·일요일은 공시가 없으므로 건너뜀
3. 최대 7일간 역추적하여 데이터가 있는 최초의 날을 반환


4.ExchangeRateQueryService

개선된 서비스는 오직 공식 API 포트에만 의존하며, 명확한 역추적 로직을 가집니다.

개선된 코드

@Service
class ExchangeRateQueryService(
    private val exchangeRateQueryPort: ExchangeRateQueryPort
) {
    fun queryExchangeRates(searchDate: LocalDate?): ExchangeRateResponse {
        // 1. 시간대 명시 (Asia/Seoul)
        val targetDate = searchDate ?: LocalDate.now(ZoneId.of("Asia/Seoul"))
        
        // 2. 최대 7일 역추적하여 최근 공식 환율 탐색
        val foundRate = findMostRecentOfficialRate(targetDate)
        
        return ExchangeRateResponse(
            date = foundRate?.date ?: targetDate,
            source = "official",
            items = foundRate?.items ?: emptyList()
        )
    }

    private fun findMostRecentOfficialRate(startDate: LocalDate): RateData? {
        repeat(MAX_PREVIOUS_BUSINESS_DAY_RETRIES) { attempt ->
            val candidateDate = startDate.minusDays(attempt.toLong())
            
            // 주말은 공시가 없으므로 즉시 스킵
            if (candidateDate.isWeekend()) return@repeat
            
            val rates = exchangeRateQueryPort.fetchDailyRates(candidateDate)
            if (rates.isNotEmpty()) {
                return RateData(date = candidateDate, items = rates)
            }
        }
        return null
    }

    private fun LocalDate.isWeekend(): Boolean =
        this.dayOfWeek in listOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)

    private companion object {
        const val MAX_PREVIOUS_BUSINESS_DAY_RETRIES = 7
    }
}

개선점 요약

  1. 출처 일관성: 모든 데이터는 official 출처에서 오거나, 없으면 빈 값을 반환합니다. 거짓 정보를 제공하지 않습니다.
  2. 의존성 제거: 지점 리포지토리에 대한 의존성을 제거하여 서비스의 역할을 명확히 분리했습니다.
  3. 명확한 시간대: 서버 환경에 의존하지 않고 Asia/Seoul 기준 시간을 명시적으로 사용하여 정산 오류를 방지합니다.

5. 지점 목록 보간

공식 환율은 역추적으로 해결했지만, UI 데모를 위해 지점 목록이 항상 일정 개수 이상 보여야 하는 요구사항은 ExchangeBranchQueryService에서 별도로 처리합니다.

@Service
class ExchangeBranchQueryService(
    private val exchangeBranchRepository: ExchangeBranchJpaRepository
) {
    fun queryBranchRates(currency: Currency): List<ExchangeBranch> {
        val branches = exchangeBranchRepository.findByCurrency(currency)
        
        // 지점 데이터가 부족하면 데모용 템플릿으로 보간
        return if (branches.size < 5) {
            branches + createDemoBranches(5 - branches.size, currency)
        } else {
            branches
        }
    }
    
    private fun createDemoBranches(count: Int, currency: Currency): List<ExchangeBranch> {
        return (1..count).map { 
            ExchangeBranch(branchName = "Demo Branch $it", isDemo = true, source = "template") 
        }
    }
}

이처럼 공식 데이터 조회UI용 보간 로직을 분리함으로써 각 서비스의 책임이 명확해졌습니다.


6. 금융 서비스의 시간 처리

이번 리팩토링을 통해 얻은 금융 데이터 처리의 원칙입니다.

  1. 영업일의 정의를 명확히 하라: 단순히 LocalDate.now()를 쓰기보다 주말과 공휴일을 고려한 '비즈니스 데이' 개념을 도입해야 합니다.

  2. 데이터의 출처를 오염시키지 마라: 부족한 데이터를 메우기 위해 다른 성격의 데이터를 섞는 것은 단기적인 해결책일 뿐, 장기적으로 시스템의 신뢰성을 파괴합니다.

  3. Fallback은 점진적으로: 데이터가 없다고 즉시 에러를 내기보다, 비즈니스 규칙이 허용하는 범위 내에서 대안 데이터를 탐색하는 유연함이 필요합니다.

공휴일 캘린더

현재 로직은 토·일요일만 건너뜁니다. 추석이나 설날 같은 명절 연휴에도 환율 공시가 없으므로, 향후 HolidayCalendar를 도입하여 법정 공휴일까지 자동으로 스킵하도록 확장할 계획입니다.


결론

금융 데이터의 가치는 정확성신뢰에서 옵니다.
주말에도 중단 없는 서비스를 제공하기 위해 도입한 '영업일 역추적'은 시스템이 사용자에게 정직하면서도 유용한 정보를 전달하게 만드는 가장 안정적인 전략입니다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글