본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 17. 04:39

PHP 8.2로 멀티 LLM 뉴스 CMS 구축하기: 200개 이상의 운영 사이트에서 얻은 교훈

요약

본 기사는 200개 이상의 뉴스 포털을 운영하며 얻은 실질적인 경험을 바탕으로, PHP를 사용하여 멀티 LLM 기반의 CMS 아키텍처 구축 방법을 공유합니다. 핵심 내용은 비용 최적화를 위한 캐스케이드 라우팅(cascade routing)과 벤더 독립성을 확보하는 제공업체 추상화(provider abstraction) 패턴입니다. 이 접근 방식을 통해 AI 추론 비용을 대폭 절감하고, 특정 LLM 공급자의 장애에 대비할 수 있습니다.

핵심 포인트

  • 단일 LLM 의존성 탈피: OpenAI 등 단일 공급자에만 의존하는 것은 장애 발생 시 서비스 중단을 초래할 위험이 있습니다.
  • 비용 최적화 전략: 모든 작업에 최고 성능의 모델을 사용할 필요는 없으며, 단순 요약 같은 작업에는 저렴한 모델(예: Gemini Flash)을 라우팅하여 비용을 획기적으로 절감할 수 있습니다.
  • 캐스케이드 라우팅 패턴 구현: 각 작업 유형별로 여러 LLM 공급자 목록을 정의하고, 순차적으로 호출하며 품질 게이트를 통과하는 첫 번째 응답을 채택합니다. (예: Groq의 Llama-3.1-70b -> Gemini Flash)
  • 벤더 독립성 확보: 제공업체 추상화 계층을 통해 특정 LLM 공급자의 문제 발생 시 다른 모델로 원활하게 전환(fallback)할 수 있습니다.
  • 특정 작업에 최적화된 모델 활용: Claude는 긴 문맥, Gemini는 구조화된 출력, Groq는 실시간 채팅 등 각 LLM의 강점을 파악하여 적절히 배치해야 합니다.

서론
지난 21년 동안 저희 팀은 현재 터키 전역의 200개 이상의 활성 뉴스 포털을 구동하는 뉴스 콘텐츠 관리 시스템 (CMS)을 구축하고 유지 관리해 왔습니다. 지난 18개월 동안 저희는 6개의 서로 다른 LLM 제공업체 (OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, Mistral)를 시스템에 통합했습니다. 이 기사에서는 비용 최적화를 위한 캐스케이드 라우팅 (cascade routing), 벤더 독립성을 위한 제공업체 추상화 (provider abstraction), 그리고 품질을 높게 유지하면서 AI 추론 (inference) 비용을 약 95% 절감할 수 있게 해준 아키텍처 결정 등 실제 운영 환경에서 사용하는 실무 패턴을 공유합니다. 유행어는 없습니다. 실제로 작동하는 코드만 다룹니다.

왜 "그냥 GPT-4를 사용하는 것" 대신 멀티 LLM인가?
저희가 단일 AI 제공업체에 의존하지 않는 세 가지 이유는 다음과 같습니다:

  1. 비용 최적화. GPT-4o는 입력 토큰 100만 개당 2.50달러입니다. Gemini Flash는 100만 개당 0.075달러로 33배 더 저렴합니다. 단순한 요약 작업에는 GPT-4o의 추론 능력이 필요하지 않습니다. 작업을 적절한 모델로 라우팅하는 것은 막대한 비용 절감으로 이어집니다.
  2. 벤더 독립성. 2024-2025년 OpenAI에 장애가 발생했을 때, GPT에만 의존하던 사이트들은 중단되었습니다. 저희 시스템은 Claude 또는 Gemini로 원활하게 전환(fallback)되었습니다.
  3. 특화된 강점. Claude는 긴 문맥 추론 (long-context reasoning)에 더 뛰어납니다. Gemini는 구조화된 출력 (structured output)에 더 뛰어납니다. Groq는 실시간 채팅에 가장 빠릅니다. Mistral은 다국어 콘텐츠를 잘 처리합니다.

Cascade Router 패턴

핵심 추상화는 각 작업 유형(task type)에 맞는 적절한 모델을 선택하는 라우터(router)입니다:

<?php

declare ( strict_types = 1 );

namespace App\AI ;

enum TaskType : string {
    case SIMPLE_SUMMARY = 'simple_summary' ;
    case HEADLINE_SUGGEST = 'headline_suggest' ;
    case SEO_META = 'seo_meta' ;
    case CONTENT_GENERATION = 'content_generation' ;
    case FACT_CHECKING = 'fact_checking' ;
    case TRANSLATION = 'translation' ;
}

class CascadeRouter {
    private const ROUTES = [
        TaskType::SIMPLE_SUMMARY->value => [
            'groq:llama-3.1-70b',
            'gemini:flash'
        ],
        TaskType::HEADLINE_SUGGEST->value => [
            'gemini:flash',
            'gpt:4o-mini'
        ],
        TaskType::SEO_META->value => [
            'groq:llama-3.1-70b'
        ],
        TaskType::CONTENT_GENERATION->value => [
            'claude:sonnet',
            'gpt:4o'
        ],
        TaskType::FACT_CHECKING->value => [
            'claude:sonnet'
        ], // 정확도가 중요함 (accuracy critical)
        TaskType::TRANSLATION->value => [
            'gemini:flash',
            'deepseek:chat'
        ],
    ];

    public function __construct (
        private readonly ProviderRegistry $registry ,
        private readonly QualityGate $qualityGate ,
    ) {}

    public function route (
        TaskType $task ,
        string $prompt
    ): AIResponse {
        $models = self::ROUTES[$task->value];
        $errors = [];

        foreach ($models as $modelId) {
            try {
                $provider = $this->registry->get($modelId);
                $response = $provider->complete($prompt);

                if ($this->qualityGate->meetsBar($response, $task)) {
                    return $response;
                }
            } catch (RateLimitException | ProviderDownException $e) {
                $errors[] = "$modelId : " . $e->getMessage();
                continue;
            }
        }

        throw new AllModelsFailedException(
            "All models failed for $task->value : " . implode('; ', $errors)
        );
    }
}

라우터는 폭포수(cascade) 방식으로 작동합니다: 가장 빠르고 저렴한 모델을 먼저 시도하고, 필요한 경우에만 더 성능이 뛰어난 모델로 폴백(fall back)합니다.

공급자 추상화 계층 (Provider Abstraction Layer)

각 AI 공급자(provider)는 서로 다른 SDK, 요청 형식(request formats), 그리고 에러 처리(error handling) 방식을 가지고 있습니다.

우리는 이를 공통 인터페이스(common interface) 뒤로 숨깁니다:

<?php

namespace App\AI\Provider;

interface AIProviderInterface
{
    public function complete(string $prompt, array $options = []): AIResponse;
    public function getName(): string;
    public function getCostPerMillionTokens(): array;
    // ['input' => x, 'output' => y]
    public function getMaxContextLength(): int;
    public function supportsStreaming(): bool;
}

class OpenAIProvider implements AIProviderInterface
{
    public function __construct(
        private readonly string $apiKey,
        private readonly string $model = 'gpt-4o-mini',
        private readonly HttpClient $http,
    ) {}

    public function complete(string $prompt, array $options = []): AIResponse
    {
        $response = $this->http->post('https://api.openai.com/v1/chat/completions', [
            'headers' => [
                'Authorization' => "Bearer {$this->apiKey}",
            ],
            'json' => [
                'model' => $this->model,
                'messages' => [['role' => 'user', 'content' => $prompt]],
                'temperature' => $options['temperature'] ?? 0.7,
                'max_tokens' => $options['max_tokens'] ?? 1000,
            ],
        ]);

        $data = $response->json();

        return new AIResponse(
            content: $data['choices'][0]['message']['content'],
            tokensUsed: $data['usage']['total_tokens'],
            cost: $this->calculateCost($data['usage']),
            model: $this->model,
        );
    }
}

class AnthropicProvider implements AIProviderInterface
{
    public function __construct(
        private readonly string $apiKey,
        private readonly string $model = 'claude-sonnet-4-6',
        private readonly HttpClient $http,
    ) {}

    public function complete(string $prompt, array $options = []): AIResponse
    {
        $response = $this->http->post('https://api.anthropic.com/v1/messages', [
            'headers' => [
                'x-api-key' => $this->apiKey,
                'anthropic-version' => '2023-06-01',
            ],
            'json' => [
                'model' => $this->model,
                'max_tokens' => $options['max_tokens'] ??

1024, 'messages' => [[ 'role' => 'user', 'content' => $prompt ]], ], ]); $data = $response->json(); return new AIResponse ( content: $data['content'][0]['text'], tokensUsed: $data['usage']['input_tokens'] + $data['usage']['output_tokens'], cost: $this->calculateCost($data['usage']), model: $this->model, ); } }

패턴: 하나의 인터페이스(Interface), N개의 구현체(Implementations). 새로운 제공자(Provider)를 추가하는 데 약 150줄의 코드면 충분합니다.

비용 최적화: 실제 적용 시 95% 절감
우리는 세 가지 계층을 통해 비용 절감 목표를 달성했습니다:

계층 1: 캐시 (Cache) (~절감액의 약 60%)

class CachedProvider implements AIProviderInterface {
    public function __construct (
        private readonly AIProviderInterface $inner ,
        private readonly Redis $cache ,
        private readonly int $ttl = 86400 * 7, // 1주일
    ) {}

    public function complete ( string $prompt , array $options = []): AIResponse {
        $cacheKey = 'ai:' . md5 ( $prompt . serialize ( $options ) . $this->inner->getName ());
        if ( $cached = $this->cache->get ( $cacheKey )) {
            return AIResponse::fromCache ( unserialize ( $cached ));
        }
        $response = $this->inner->complete ( $prompt , $options );
        $this->cache->setex ( $cacheKey , $this->ttl , serialize ( $response->toArray ()));
        return $response ;
    }
}

많은 뉴스 CMS 작업은 결정론적(Deterministic)입니다: 동일한 기사로

Layer 3: Cascade Routing (~10% 추가 절감)

작업이 유료 프리미엄 모델(Premium model)에 도달할 때쯤이면, 이미 다음과 같은 단계를 거쳐 필터링된 상태입니다:

  • Cache (무료)
  • 저가형 모델 (Groq llama, Gemini Flash)
  • 중간 단계 모델 (GPT-4o-mini, Claude Haiku)
  • 프리미엄 모델 (Claude Sonnet, GPT-4o) — 오직 필요한 작업에만 사용

Turkish News Agency Integration (터키 뉴스 통신사 통합)

이 부분은 지리적 맥락(Geographic context)이 중요한 지점입니다. 터키의 뉴스 생태계는 8개의 주요 통신사를 중심으로 돌아갑니다:

  • AA (Anadolu Ajansı) — 국영 뉴스 통신사
  • DHA (Demirören Haber Ajansı) — 주요 상업 통신사
  • İHA (İhlas Haber Ajansı) — 종교적-보수적 성향
  • ANKA, THA, HİBYA, İGFA, BHA — 지역 및 전문 통신사

각 통신사는 고유의 콘텐츠 형식, API 프로토콜, 그리고 카테고리 분류 체계(Taxonomy)를 가지고 있습니다. 일반적인 CMS 플랫폼(WordPress, Drupal)은 이를 처리하지 못합니다. 우리의 솔루션은 다음과 같습니다:

Factory + Adapter 패턴:

<?php

namespace App\News\Agency;

interface AgencyAdapterInterface
{
    public function fetchArticles(int $limit = 50): iterable;
    public function mapCategory(string $agencyCategory): ?int;
    public function downloadAssets(Article $article): void;
}

class AgencyFactory
{
    public static function create(string $code, PDO $pdo, array $config): AgencyAdapterInterface
    {
        return match ($code) {
            'AA' => new AAAdapter($pdo, $config),
            'DHA' => new DHAAdapter($pdo, $config),
            'IHA' => new IHAAdapter($pdo, $config),
            'ANKA' => new ANKAAdapter($pdo, $config),
            'THA' => new THAAdapter($pdo, $config),
            'HIBYA' => new HIBYAAdapter($pdo, $config),
            'IGFA' => new IGFAAdapter($pdo, $config),
            'BHA' => new BHAAdapter($pdo, $config),
            default => throw new \InvalidArgumentException("Unknown agency: $code"),
        };
    }
}

Cron job(크론 잡)이 3분마다 실행되어, 활성화된 모든 통신사로부터 새로운 기사를 병렬로 가져오고, 이미지를 다운로드하여 WebP로 변환하고, 썸네일을 생성하며, 기존 콘텐츠와 중복을 제거한 뒤 검토 대기열(Moderation queue)에 저장합니다.

AI Visibility: llms.txt 및 ai-sitemap.xml

전통적인 SEO는 Google을 목표로 합니다.

AI 가시성 (AI visibility)은 ChatGPT, Claude, Gemini, Perplexity를 대상으로 합니다. 표준은 다음과 같습니다:

/llms.txt — LLM을 위한 사이트 가이드

robots.txt와 유사하지만 콘텐츠에 집중함

Our News Portal > 정치, 경제, 지역 뉴스를 다루는 터키 중심의 뉴스 포털.

주요 섹션

  • /haberler — 모든 뉴스 (날짜순 정렬)
  • /kategori/ekonomi — 경제
  • /yazarlar — 약력이 포함된 저자들

About 독립적인 뉴스 포털, BIK 준수, 2015년 설립.

ai-sitemap.xml은 sitemap.xml과 유사하지만, LLM이 수집할 수 있는 기사 요약과 구조화된 메타데이터를 포함합니다:

<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <url> <loc>https://example-news.com/haber/example-slug</loc> <ai:summary>LLM 수집을 위한 짧은 기사 요약</ai:summary> <ai:category>Politics</ai:category> <ai:published>2026-05-16T10:00:00+03:00</ai:published> </url> </urlset>

각 페이지의 Schema.org NewsArticle JSON-LD와 결합하면, LLM 크롤러(crawler)에게 구조화된 접근 권한을 제공합니다:

<script type="application/ld+json"> { "@context": "https://schema.org", "@type": "NewsArticle", "headline": "Article title", "datePublished": "2026-05-16T10:00:00+03:00", "author": { "@type": "Person", "name": "Author Name" }, "publisher": { "@type": "NewsMediaOrganization", "name": "News Portal Name", "logo": { "@type": "ImageObject", "url": "..." } } } </script>

우리가 명시적으로 허용하는 봇(Bot): GPTBot, ClaudeBot, anthropic-ai, PerplexityBot, OAI-SearchBot, CCBot, Bytespider, AppleBot, Google-Extended.

200개 이상의 뉴스 사이트에서 이 스택을 18개월 동안 운영한 후의 운영 수치:

  • AI 추론 비용 (AI inference cost) 절감: 단순 GPT-4o 전용 방식 대비 약 95%
  • 캐시 히트율 (Cache hit rate): 일반적인 작업(요약, 헤드라인, SEO)에서 약 70%
  • 제공업체 가용성 (Provider availability): 99.97% (단일 제공업체 99.5% 대비)
  • 기사 처리 속도: 중앙값 30-50ms (캐시됨), 800-2500ms (캐시되지 않음)
  • 에이전시 콘텐츠 수집: 8개 에이전시 대상 3분 간격 폴링(polling), 일일 약 3,500개의 기사 처리

CMS 소프트웨어를 21년 동안 구축하고 AI를 위해 18개월 동안 최적화하며 얻은 교훈:

  • 단일 제공업체에 종속되지 마세요. "그냥 OpenAI를 사용하면 돼"라는 유혹에 빠지기 쉽지만, 그러지 마세요.
  • 공격적으로 캐싱하세요. 대부분의 AI 작업은 결정론적인(deterministic) 출력과 함께 반복됩니다.
  • 유행이 아니라 작업의 복잡도에 따라 라우팅하세요. 대부분의 작업에는 GPT-4o나 Claude Opus가 필요하지 않습니다.
  • 지역 규제는 최우선 고려 사항입니다. 터키의 경우 KVKK, İYS, BİK가 있으며, EU의 경우 GDPR, AI Act가 있습니다. 이를 나중에 덧붙이지 말고, 처음부터 이를 고려하여 설계하세요.
  • 품질 게이트(Quality gates)가 중요합니다. 잘못된 답을 내놓는 저렴한 모델은 비싼 모델보다 더 많은 비용을 초래합니다. 검증(validation) 단계를 추가하세요.
  • 반짝이는 것보다 안정적인 것이 낫습니다. PHP 8.2 + Smarty + MySQL + Redis는 트렌디하지는 않지만, 영원히 작동합니다.

맺음말
뉴스 CMS를 위한 "최고의" 아키텍처는 가장 참신한 것이 아닙니다. 다음과 같은 조건을 충족하는 아키텍처입니다:

  • 수년간 안정적으로 작동함
  • 고객에게 청구하는 비용보다 적게 듦
  • 지역적 특이점(규제, 언어, 문화)을 처리함
  • 제공업체의 서비스 중단(deprecation)에서도 살아남음

계단식 라우팅(cascade routing)을 활용한 멀티 LLM(Multi-LLM) 방식은 2026년에 이 조건에 부합합니다. 아마 2030년에도 여전히 부합할 것입니다. 제공업체는 바뀌겠지만, 추상화(abstraction)는 바뀌지 않을 것이기 때문입니다.

저는 실용적인 소프트웨어 아키텍처, 멀티 LLM 시스템, 그리고 대규모 CMS 운영을 통해 얻은 교훈에 대해 글을 씁니다. 질문이 있다면 댓글로 자유롭게 남겨주세요.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0