Skip to content

외부 API 연동 삽질기

Local Up 개발하면서 공공데이터포털과 OpenAI API를 연동한 경험을 정리했다. 단순한 HTTP 요청 구현을 넘어서, 실제 운영 환경에서 마주하게 될 사용량 제한과 비용 관리, 장애 대응 등의 문제를 미리 고민하고 대비책을 마련한 과정이다.

외부 API 의존성 증가가 가져오는 복잡도

최근 대부분의 서비스는 외부 API 조합으로 빠르게 구축된다. FCM으로 푸시 알림을 처리하고, 공공데이터포털에서 실시간 정보를 가져오며, OpenAI API로 생성형 AI 기능을 제공한다. 개발 속도는 향상되었지만 제어 불가능한 변수들이 증가했다.

개발 단계에서는 문제없이 동작하던 기능들이 운영 환경에서 예상치 못한 장애로 이어지는 사례를 자주 목격한다. 네트워크 지연, API 스펙 변경, 갑작스러운 사용량 제한, 예상치 못한 과금 등이 대표적이다.

특히 공공데이터포털과 OpenAI API 같은 서로 다른 특성을 가진 외부 서비스를 동시에 사용할 때 문제가 더 복잡해진다. 각각의 인증 체계, 응답 형식, 제한 정책이 완전히 다르기 때문에 일관된 방식으로 처리하려는 시도는 실패로 이어지기 쉽다.

Spring HTTP Client 선택의 기준

Spring에서 HTTP 요청을 처리하는 방식은 시간에 따라 진화해왔다. 각 방식의 특성을 이해하고 프로젝트 상황에 맞는 선택을 하는 것이 중요하다.

RestTemplate - 검증된 안정성

Spring 3.0부터 제공된 RestTemplate은 많은 프로덕션 환경에서 검증된 선택이다. 풍부한 레퍼런스와 안정성이 장점이지만, 복잡한 요청 구성 시 코드가 장황해지는 단점이 있다.

kt
val httpHeaders = HttpHeaders()
httpHeaders.setContentType(MediaType.APPLICATION_JSON)
httpHeaders.setBearerAuth(token)

val httpEntity = HttpEntity(requestData, httpHeaders)

val response = restTemplate.exchange(
    "https://api.example.com/domains",
    HttpMethod.POST,
    httpEntity,
    Response::class.java
)

WebClient - 비동기의 양날의 검

Spring 5.0에서 도입된 WebClient는 리액티브 스트림을 기반으로 한 비동기 처리가 특징이다. I/O 집약적인 작업에서 높은 성능을 발휘할 수 있지만, 팀 전체가 Mono와 Flux 개념에 익숙해야 제대로 활용할 수 있다.

비동기 처리의 성능 이점은 특정 상황에서만 의미가 있다. 독립적인 다중 API 호출이 필요한 경우나 실제 I/O 대기 시간이 병목인 경우에만 효과적이다. 대부분의 비즈니스 로직은 순차적 특성을 가지고 있어 비동기 처리의 복잡성이 성능 이점을 상쇄하는 경우가 많다.

처음에는 성능이 좋다는 말만 듣고 WebClient 도입을 검토했다. 하지만 나부터 Mono, Flux 개념이 헷갈렸고, 팀원들도 리액티브 프로그래밍 경험이 없어서 결국 포기했다.

RestClient - 현실적인 균형점

Spring Boot 3.2부터 도입된 RestClient는 RestTemplate의 안정성과 WebClient의 현대적 API 설계를 결합한 선택이다. 동기식 처리를 유지하면서도 체이닝 방식의 직관적인 인터페이스를 제공한다.

kt
val response = restClient.post()
    .uri("https://api.example.com/domains")
    .header("Authorization", "Bearer $token")
    .contentType(MediaType.APPLICATION_JSON)
    .body(requestData)
    .retrieve()
    .body(Response::class.java)

새로운 프로젝트라면 RestClient를 권한다. RestTemplate의 안정성에 현대적인 API를 결합했고, 기존 코드에서 점진적 마이그레이션도 가능하다. 팀 전체가 리액티브 프로그래밍에 익숙하지 않다면 더 실용적인 선택이다.

API별 특성을 고려한 클라이언트 분리

하나의 RestClient 인스턴스로 모든 외부 API를 처리하려는 접근은 실패 확률이 높다. API마다 요구하는 인증 방식, 타임아웃 정책, 에러 처리 방식이 다르기 때문이다.

공공데이터포털과 OpenAI API의 차이점을 살펴보면 이 문제가 명확해진다.

  • 인증 방식: 공공데이터포털은 ServiceKey를 쿼리 파라미터로 전송하지만, OpenAI는 Authorization 헤더의 Bearer Token을 사용한다.
  • 응답 시간: 공공데이터포털은 1-3초 내외로 빠른 응답을 보이지만, OpenAI API는 프롬프트 복잡도에 따라 5-30초까지 소요될 수 있다.
  • 에러 처리: 공공데이터포털은 표준 HTTP 상태 코드를 사용하지만, OpenAI는 자체 에러 형식을 가진다.

이런 차이 때문에 각각 전용 클라이언트가 필요하다.

kt
@Configuration
class RestClientConfig {

    @Bean
    fun publicDataPortalRestClient(): RestClient {
        return RestClient.builder()
            .requestFactory(HttpComponentsClientHttpRequestFactory().apply {
                setReadTimeout(Duration.ofSeconds(5))
                setConnectTimeout(Duration.ofSeconds(3))
            })
            .build()
    }

    @Bean
    fun openAiRestClient(): RestClient {
        return RestClient.builder()
            .requestFactory(HttpComponentsClientHttpRequestFactory().apply {
                setReadTimeout(Duration.ofSeconds(30))
                setConnectTimeout(Duration.ofSeconds(5))
            })
            .defaultHeader("Authorization", "Bearer \${openai.api.key}")
            .build()
    }
}

이런 분리 전략을 통해 각 API의 특성에 최적화된 설정을 적용할 수 있고, 향후 스펙 변경 시에도 영향 범위를 제한할 수 있다.

사용량 제한과 비용 폭증 시나리오

외부 API 사용량 제한은 개발 단계에서는 잘 드러나지 않는다. 소수의 개발자가 제한된 테스트를 수행하는 환경에서는 한계에 도달할 가능성이 낮기 때문이다. 하지만 실제 사용자 유입이 시작되면 상황이 급변한다.

예상 시나리오들

OpenAI API 비용 급증: 초기 일 사용량이 몇 달러 수준이더라도, 사용자 증가에 따라 월 수십만 원까지 증가할 수 있다. 특히 GPT-4 사용 시 토큰당 비용이 급격히 증가한다.

공공데이터포털 할당량 조기 소진: 날씨 정보 서비스에서 예상보다 많은 트래픽이 몰리면서 일일 할당량을 오전에 모두 소모하는 상황이 발생할 수 있다.

특정 사용자의 과도한 사용: 무료 체험 기능에서 일부 사용자가 과도하게 사용하여 전체 할당량에 영향을 주는 경우가 생길 수 있다.

이런 문제들은 기술적 해결책만으로는 부족하며, 비즈니스 정책과 기술 구현이 함께 고려되어야 한다.

사용량 제어 전략의 선택

사용자 또는 사용량에 대한 제어를 구현하는 방법은 여러 가지가 있으며, 각각의 특성을 이해하고 상황에 맞는 선택을 해야 한다.

Interceptor 방식 - HTTP 레벨 제어

HTTP 요청이 실제로 전송되기 직전에 동작하는 방식이다. 실제 API 비용이 발생하지 않는 시점에서 제어가 가능하다는 것이 가장 큰 장점이다.

kt
class RateLimitInterceptor : ClientHttpRequestInterceptor {
    override fun intercept(
        request: HttpRequest,
        body: ByteArray,
        execution: ClientHttpRequestExecution
    ): ClientHttpResponse {
        val uri = request.uri.toString()

        if (rateLimitExceeded(uri)) {
            throw RateLimitExceededException("I'm broke.")
        }

        return execution.execute(request, body)
    }
}

장점은 완전한 비용 절약이 가능하고, RestClient 설정만으로 모든 요청에 일관되게 적용된다는 것이다. 하지만 URL만으로는 비즈니스 컨텍스트를 파악하기 어렵고, 사용자별 차별화된 정책 적용이 복잡하다는 단점이 있다.

AOP 방식 - 비즈니스 레벨 제어

서비스 메서드 레벨에서 동작하여 비즈니스 컨텍스트를 풍부하게 활용할 수 있는 방식이다.

kotlin
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RateLimit(
    val key: String,
    val limit: Int,
    val window: String = "1h"
)

@Aspect
@Component
class RateLimitAspect {

    @Before("@annotation(rateLimit)")
    fun checkRateLimit(joinPoint: ProceedingJoinPoint, rateLimit: RateLimit) {
        val userId = getCurrentUserId()
        val key = "${rateLimit.key}:$userId"

        if (rateLimitService.isExceeded(key, rateLimit.limit, rateLimit.window)) {
            throw RateLimitExceededException("사용 한도 초과: ${rateLimit.key}")
        }

        rateLimitService.increment(key, rateLimit.window)
    }
}

이 방식의 장점은 기능별 세밀한 제어가 가능하다는 것이다. "채팅 생성 50회/일, 이미지 생성 10회/일" 같은 차별화된 정책을 쉽게 적용할 수 있고, SecurityContext를 통한 사용자별 제한도 용이하다.

Service Layer 방식 - 명시적 제어

각 서비스 메서드에서 직접 사용량을 체크하는 방식이다. 코드에서 제한 로직이 명확히 보이는 명시적 제어가 가능하고, 복잡한 비즈니스 로직을 쉽게 구현할 수 있다.

단점은 각 서비스마다 유사한 제한 로직이 반복되고, 개발자마다 다르게 구현할 가능성이 있다는 점이다.

실무에서는 전역적이고 단순한 제한에는 Interceptor를, 기능별 세밀한 제어가 필요한 경우에는 AOP 방식을 권한다. 대부분의 경우 AOP 방식이 더 유연하고 실용적이라고 생각한다.

장애 격리와 Graceful Degradation

외부 API는 언제든 장애가 발생할 수 있다. 중요한 것은 외부 API 장애가 전체 서비스 장애로 확산되지 않도록 하는 것이다. 이번에 처음 알게 된 Graceful Degradation이라는 개념이 바로 이런 상황을 다룬다.

기본 아이디어는 간단하다. API 호출이 실패하면 즉시 에러를 던지는 대신, 캐시된 데이터를 사용하거나 기본값을 반환하는 것이다. 날씨 API가 죽어도 어제 캐시된 날씨 정보라도 보여주고, 그것도 없으면 "날씨 정보를 불러올 수 없습니다"라는 메시지와 함께 기본 아이콘을 표시한다.

서킷 브레이커 같은 복잡한 패턴 없이도 try-catch와 캐시만으로 기본적인 장애 격리가 가능하다. 핵심은 외부 API 실패가 전체 서비스 실패로 이어지지 않도록 하는 것이다.

Circuit Breaker(서킷 브레이커)

연속된 실패가 발생하면 일정 시간 동안 API 호출을 차단하는 패턴이다. 전기 회로의 차단기처럼 장애가 감지되면 자동으로 연결을 끊어 시스템을 보호한다. Spring Cloud Circuit Breaker나 Resilience4j 같은 라이브러리로 구현할 수 있지만, 간단한 서비스에서는 과도한 복잡성을 가져올 수 있다.

운영 관점에서의 고려사항

동적 설정 관리

운영 환경에서는 애플리케이션 재시작 없이 설정을 변경할 수 있는 능력이 중요하다. 지금은 API 키나 프롬프트를 env 파일로 관리하고 있다. API가 안 되면 env 파일을 수정하고 서버를 재시작하는 방법밖에 없어 서비스 다운 타임이 발생한다.

특히 API 키가 만료되거나 프롬프트 최적화가 필요한 경우 즉시 대응할 수 있어야 한다. 이를 위해 데이터베이스에 설정값을 저장하고 캐시와 조합하는 방식을 고려하고 있다. 관리자 페이지에서 설정을 변경하면 즉시 반영되고, 캐시를 통해 성능도 유지할 수 있다.

다만 보안이 중요한 API 키의 경우 암호화 저장과 접근 권한 관리를 별도로 고려해야 한다. 또한 설정 변경 이력을 추적할 수 있는 감사 로그도 있으면 좋을 것 같다.

캐싱을 통한 비용 최적화

동일한 요청이 반복되는 패턴을 분석하여 적절한 캐싱 전략을 수립하면 상당한 비용 절약이 가능하다. 예를 들어 공공데이터포털의 날씨 API는 2시간마다 갱신되고 10분 후에 데이터가 반영된다. 이 특성을 고려해 3시간 단위로 캐싱하도록 구성했다.

kt
@Cacheable(
    cacheNames = [CacheObjectName.SHORT_TERM_FORECAST_INFORMATION],
    keyGenerator = CacheKeyGeneratorName.SHORT_TERM_FORECAST_INFORMATION,
    sync = true
)
fun findShortTermForecast(
    sigunguCode: String,
    dateTime: LocalDateTime = LocalDateTime.now(),
): ShortTermForecastInformation {}
kt
@Bean
fun shortTermForecastInformationKeyGenerator(): KeyGenerator {
    return KeyGenerator { _, _, params ->
        val sigunguCode = params[0] as String
        val savedDateTime = (params[1] as LocalDateTime)
            .truncatedTo(ChronoUnit.HOURS)
            .withHour((params[1] as LocalDateTime).hour / 3 * 3)

        "$sigunguCode-${savedDateTime.format(DateTimeUtil.DATETIME_FORMATTER_yyyyMMddHHmm)}"
    }
}

sync = true 옵션으로 동시에 같은 키로 요청이 들어와도 실제 API는 한 번만 호출하도록 보장했다. 이렇게 하면 트래픽이 몰려도 API 호출량을 크게 줄일 수 있다.

모니터링과 알림에 대한 고민

운영 환경에서는 외부 API 호출 패턴과 성공률을 지속적으로 모니터링해야 할 것이다. 문제 발생 시 신속한 대응을 위해 적절한 알림 체계도 필요하다.

팀에서 디스코드를 주로 사용해서 알림도 디스코드 웹훅으로 보내는 방향을 생각하고 있다. OpenAI API 호출량이 임계치를 넘거나 공공데이터 API 실패율이 높아지면 디스코드 채널에 알림이 오도록 하면 될 것 같다. 다만 디스코드 자체가 죽으면 알림을 받을 수 없다는 문제가 있다. 보안책으로 이메일이나 문자 알림도 고려해봤지만, 지금 팀 규모에서는 배보다 배꼽이 더 클 것 같아서 일단은 디스코드로만 진행하기로 했다.

모니터링할 지표들로는 API별 호출 횟수, 응답 시간, 실패율, 일일/월간 사용량 정도를 생각하고 있다. Micrometer로 메트릭을 수집하고, 임계치를 넘으면 알림을 보내는 단순한 구조로 시작할 예정이다.

정리

외부 API 연동은 단순한 HTTP 요청 구현을 넘어서 운영 환경의 현실적 제약들을 미리 고민해야 하는 작업이다. 개발할 때는 괜찮던 것들이 사용자가 몰리면서 예상치 못한 비용 폭탄이나 서비스 장애로 이어지는 경우를 많이 봤다.

완벽한 아키텍처를 처음부터 구축하려 하기보다는, 현재 팀 상황에 맞는 적절한 수준에서 시작하는 것이 중요하다고 생각한다. RestClient 선택부터 캐싱 전략, 장애 대응까지 모든 것을 한 번에 완성하려 하지 않았다. 문제가 생길 때마다 하나씩 개선해나가는 방식으로 접근했다.

원칙으로 정리하면 아래와 같다.

  • 점진적 개선: 처음부터 완벽하게 만들려 하지 말고, 실제 문제가 생겼을 때 해결
  • 비용 의식: 유료 API는 반드시 사용량 추적과 제한 구현 필수
  • 장애 격리: 외부 API 죽음이 우리 서비스 전체를 망가뜨리지 않도록
  • 팀 역량 고려: 팀원들이 다 이해할 수 있는 수준에서 구현

결국 기술은 비즈니스 문제를 해결하기 위한 도구다. 외부 API도 마찬가지로 서비스 목표를 달성하기 위한 수단이지, 그 자체가 목적은 아니다. 적당한 수준에서 균형점을 찾는 것이 실무에서 가장 중요한 능력이라고 생각한다.

Released under the MIT License.