# 커피 시장 위젯 개발 후기 - 라이믹스 기반 실시간 환율·선물 지수 표시

### 커피 시장 위젯 개발 후기 - 라이믹스 기반 실시간 환율·선물 지수 표시

---

최근 트럼프로 인한 미국 관세 여파 때문에 국제 커피 선물 시장도 난리다. 특히 브라질산 커피에 상당한 관세를 매기는 것이 커피 선물 index를 크게 상승시키는 원인이 되었고, 국내커피 시장도 이미 이에 대해 큰 영향을 받고 있다. 이와 관련해서 환율은 물론 커피 아라비카, 로부스타 선물 지수를 웹사이트에 반영할 수 없느냐는 요청이 있어서 관련 위젯을 개발했다.

웹사이트가 국내 CMS 라이믹스 기반이다보니 코드는 PHP로 작성해야 하고, 환율과 선물을 모두 동시에 API로 제공하는 무료 서비스는 존재하지 않기 때문에 여러 데이터 소스를 조합해야 했다. 

### **완성된 위젯 모습**

![Image](https://upload.cafenono.com/image/slashpagePost/20251025/200223_30T6QfvFif9t18FGR7?q=80&s=1280x180&t=outside&f=webp)

## 기술적 해결 과제

가장 큰 과제는 환율과 커피 선물 지수를 각기 다른 소스에서 가져와야 한다는 점이었다. 일단 그래서 각각 정보를 제공하는 API 제공 서비스 프로바이더를 찾아야하는 것이 과제였다. 그리고 굳이 DB를 사용하지 않고도 캐싱 전략으로 가벼운 구현 방법을 적용하려 했다.

**환율 데이터 (USD/KRW, EUR/KRW)

**

- **선택한 API**: FXRatesAPI ([https://fxratesapi.com/](https://fxratesapi.com/))

- **이유**: 무료 플랜에서 실시간 환율 제공, API 키 기반의 안정적인 서비스

- **엔드포인트**: `[https://api.fxratesapi.com/latest?api_key={KEY}&base=USD&currencies=KRW,EUR](https://api.fxratesapi.com/latest?api_key=%7BKEY%7D&base=USD&currencies=KRW,EUR)`

[https://api.fxratesapi.com/latest?api_key={KEY}&base=USD&currencies=KRW,EUR](https://api.fxratesapi.com/latest?api_key={KEY}&base=USD&currencies=KRW,EUR)

```javascript
$fx_url = 'https://api.fxratesapi.com/latest?api_key=' . FXRATESAPI_KEY . '&base=USD&currencies=KRW,EUR';
$fx_pair = coffee_market_widget_fetch_json_with_raw($fx_url);
```

해외 서비스이다 보니, 기본 베이스 통화가 달러라, USD 기준의 EUR 비율을 받아서 유로화를 계산하여 표시하기로 했다. 아주 타이트한 유로환율이 필요한 건 아니라서 이정도도 충분히 유용하다.

```javascript
// EUR/USD 비율을 EUR/KRW로 변환
$eur_usd = (float)$fx['rates']['EUR'];
if ($usd_rate !== null) {
    $eur_rate = $usd_rate / $eur_usd;  // 환율 계산
    $data['eurkrw'] = $eur_rate;
}
```

**커피 선물 데이터 (Arabica, Robusta)

**

- **선택한 소스**: Stooq.com CSV 엔드포인트

- **이유**: 무료로 커피 선물 데이터를 CSV 형식으로 제공

- **심볼**: 

    - Arabica (KC.F) - 센트/파운드(¢/lb) 단위

    - Robusta (RM.F) - 달러/톤(USD/ton) 단위

커피 선물은 대부분 유료로 제공하기 때문에 돈을 쓰기 싫어서 CSV를 무료로 제공하는 stooq.com을 사용한다. stooq.com 에서 csv 파일을 받아 데이터를 추출하는 방식으로 진행하면 별도의 api key 등이 필요 없이 구현이 가능하다. 

```javascript
// Arabica 데이터
$stooq_kc_url = 'https://stooq.com/q/l/?s=kc.f&f=sd2t2ohlcv&h&e=csv';
$csv_kc = coffee_market_widget_http_get($stooq_kc_url);

// Robusta 데이터
$stooq_rm_url = 'https://stooq.com/q/l/?s=rm.f&f=sd2t2ohlcv&h&e=csv';
$csv_rm = coffee_market_widget_http_get($stooq_rm_url);
```

CSV 파싱은 PHP의 `str_getcsv()` 함수를 활용해 간단하게 처리했다.

```javascript
$lines = preg_split('/\r?\n/', trim($csv_kc));
foreach ($lines as $idx => $line) {
    if ($line === '') { continue; }
    if ($idx === 0 && stripos($line, 'symbol') !== false) { continue; }  // 헤더 스킵
    $cols = str_getcsv($line);
    if (count($cols) < 7) { continue; }
    $close = is_numeric($cols[6] ?? null) ? (float)$cols[6] : null;  // 종가 추출
    // ...
}
```

### 2. 라이믹스 환경 호환성

라이믹스 CMS에 위젯을 통합하기 위해서는 몇 가지 특별한 고려사항이 필요했다.

**실행 환경 검증**

```javascript
if (!defined('__XE__') && !defined('__RHYMIX__')) {
    exit('This script must be executed within Rhymix.');
}
```

XpressEngine(XE) 기반의 라이믹스에서만 실행되도록 안전장치를 설정했다.

**HTTP 요청 계층화 (Graceful Degradation)**

라이믹스의 `Rhymix\Framework\Http` 클래스를 우선 사용하되, 실패 시 cURL, 그마저도 안되면 PHP 스트림으로 폴백하는 3단계 전략을 구현했다.

```javascript
function coffee_market_widget_http_get(string $url, array &$debug = null): ?string
{
    $accept = (stripos($url, 'stooq.com') !== false) ? 'text/csv' : 'application/json';
    
    // 1단계: Rhymix HTTP (라이믹스 내장 HTTP 클라이언트)
    if (class_exists('Rhymix\\Framework\\Http')) {
        try {
            $response = \Rhymix\Framework\Http::get($url, [], [
                'timeout' => 30,
                'headers' => [
                    'Accept' => $accept,
                    'User-Agent' => 'CoffeeMarketWidget/1.2',
                ],
            ]);
            if (is_string($response) && $response !== '') {
                return $response;
            }
        } catch (\Throwable $e) {
            $last_error = 'Rhymix HTTP: ' . $e->getMessage();
        }
    }

    // 2단계: cURL
    if (function_exists('curl_init')) {
        $handle = curl_init($url);
        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_FOLLOWLOCATION => true,
            // ...
        ]);
        $body = curl_exec($handle);
        if ($body !== false && $body !== '') {
            return $body;
        }
    }

    // 3단계: file_get_contents (최후의 수단)
    $context = stream_context_create([/* ... */]);
    $body = @file_get_contents($url, false, $context);
    return $body ?: null;
}
```

이렇게 하면 어떤 서버 환경에서도 최소한 하나는 작동할 가능성이 높다.

**파일 시스템 경로 처리**

라이믹스 환경과 독립 실행 환경 모두를 지원하기 위해 경로 결정 로직을 구현했다.

```javascript
function coffee_market_widget_cache_dir(): string
{
    if (defined('_XE_PATH_')) {
        $base = _XE_PATH_;  // 라이믹스 환경
    } else {
        $base = dirname(__DIR__);  // 독립 실행 환경
    }
    
    $dir = rtrim($base, '/\\') . '/files/cache/' . COFFEE_MARKET_WIDGET_CACHE_SUBDIR;
    if (!is_dir($dir)) {
        @mkdir($dir, 0755, true);
    }
    return $dir;
}
```

### 3. 캐싱 전략으로 API 제한 회피

무료 API들은 대부분 호출 제한이 있다. 특히 Stooq는 일일 호출 제한이 있어서 스냅샷 파일에서 "Exceeded the daily hits limit" 에러가 발견되었다. 이를 해결하기 위해 일별 캐싱 전략을 도입했다. 쉽게 말하면, 유저들이 사이트에 접속할 때마다 API 프로바이더들에게 요청하면 일일 제공, 혹은 월간 제공 사용량을 금방 초과하기 때문에 가장 첫 유저가 요청을 성공시 이를 서버에 캐시로 저장하고 다른 유저들은 캐시 유무를 확인하여 캐시가 존재할 경우 API 요청대신 서버에서 제공하는 캐싱된 데이터를 수신한다. 

**캐시 파일명**: `snapshot_YYYYMMDD.json`

```javascript
function coffee_market_widget_cache_file(): string
{
    return coffee_market_widget_cache_dir() . '/snapshot_' . date('Ymd') . '.json';
}
```

날짜별로 캐시를 분리하면

- 자정이 지나면 자동으로 새 캐시 파일 생성

- 하루 한 번만 API 호출로 충분

- 디버그 모드가 아니면 항상 캐시 우선 사용

```javascript
// 오늘자 캐시가 있고 디버그가 아니면 사용
if (!COFFEE_MARKET_WIDGET_DEBUG && coffee_market_widget_nonempty_file($cache_file)) {
    $snap = coffee_market_widget_read_json_file($cache_file);
    if (is_array($snap)) {
        $snap['debug']['cache_hit'] = true;
        return $snap;
    }
}
```

### 4. 에러 처리와 디버깅

실제 운영 환경에서는 API가 언제든 실패할 수 있다. 각 단계에서 발생하는 에러를 수집하고 디버그 정보를 기록한다. 디버그는 별도 콘솔에는 표시하지 않고, 캐시 파일에 기록한다.

```javascript
$debug_log = ['cache_hit' => false];
$errors = [];

// FX API 호출
if (is_array($fx) && isset($fx['success']) && $fx['success'] === true) {
    // 성공 처리
} else {
    $errors[] = 'FXRatesAPI 응답 오류';
    $debug_log['fx_parse_error'] = 'Invalid response structure';
}

// 최종 에러 메시지 통합
if ($errors) {
    $data['error'] = implode(' / ', array_unique($errors));
}

// 디버그 정보 포함
$data['debug'] = $debug_log;
```

스냅샷 파일(`snapshot_20251025.json`)을 보면 디버그 정보가 어떻게 저장되는지 확인할 수 있다.

```javascript
{
  "debug": {
    "cache_hit": false,
    "fx_url": "https://api.fxratesapi.com/latest?...",
    "fx_source": "fxratesapi.com",
    "fx_raw": "{...}",
    "fx_http_method": "cURL",
    "stooq_kc_preview": "Exceeded the daily hits limit"
  }
}
```

### 5. UI/UX - 무한 스크롤 티커

결과적으로 이렇게 가져온 데이터는 상단 탑 영역에 플로팅 텍스트 형태로 흘러가게 만들 예정이니 좁은 공간에 많은 정보를 표시하기 위해 뉴스 티커 스타일의 무한 스크롤 UI를 구현했다.

**핵심 CSS**

```javascript
@keyframes ticker-scroll {
    0% {
        transform: translateX(0);
    }
    100% {
        transform: translateX(-50%);
    }
}

.ticker-content {
    display: inline-flex;
    animation: ticker-scroll 30s linear infinite;
}

.ticker-content:hover {
    animation-play-state: paused;  /* 마우스 호버 시 정지 */
}
```

**무한 반복 트릭**: 동일한 콘텐츠를 두 번 렌더링하고, 첫 번째가 화면을 벗어날 때 두 번째가 보이기 시작한다. `translateX(-50%)`로 정확히 절반만큼 이동하면 끊김 없는 무한 스크롤이 완성된다.

```javascript
<!-- 첫 번째 세트 -->
<span class="widget-title">커피 시장 현황</span>
<div class="market-item">...</div>
<!-- ... -->

<!-- 반복 콘텐츠 (무한 스크롤 효과) -->
<span class="widget-title">커피 시장 현황</span>
<div class="market-item">...</div>
<!-- ... -->
```

**다크 테마 디자인**: 커피라는 주제에 맞게 블랙 배경(`#121212`)과 밝은 텍스트를 사용했다.

```javascript
.coffee-market-widget {
    background: #121212;
    color: #f8f5ef;
    border-radius: 0px;
    padding: 10px 0;
}
```

### 6. 보안 고려사항

**XSS 방지**: 모든 출력 데이터에 `htmlspecialchars()` 적용

```javascript
<?= htmlspecialchars(coffee_market_widget_format_number($data['usdkrw']), ENT_QUOTES, 'UTF-8'); ?>
```

### 후기

### 1. CSV는 생각보다 까다롭다

처음에는 JSON API만 사용하려 했는데, 커피 선물 데이터를 제공하는 무료 서비스는 CSV였다. CSV에서 바로 데이터 추출을 해서 표시하는 건 쉽게 가능할거란 생각이 들진 않았지만, 그래도 구현이 되는 걸 보면 하나 더 배운 셈이다.

CSV 파싱 시 주의할 점

- 구조에 따라서 헤더 행 스킵 필요

- 빈 줄 처리

- 컬럼 개수 검증

- 심볼명 대소문자 처리

### 2. API 호출 제한은 실제로 발생한다

개발 중에는 괜찮았는데, 테스트하다 보니 get 요청으로는 아무런 문제가 없었는데 서버단에서 요청할 경우 Stooq가 "Exceeded the daily hits limit"를 반환했다. 무료 API는 항상 제한을 고려한 캐싱 전략이 필수다.

### 3. PHP 버전 호환성

라이믹스는 다양한 서버 환경에서 실행되므로 최신 PHP 문법만 쓸 수 없다. 하지만 타입 힌트(`string`, `array`, `?string`)는 PHP 7.0+에서 지원되므로 적극 활용했다.

### 4. 폴백 전략의 중요성

HTTP 요청 한 가지 방법만 쓰면 문제가 발생할 수 있지만, 3단계 폴백 덕분에 다양한 환경에서 안정적으로 작동한다.

For the site tree, see the [root Markdown](https://slashpage.com/lop.md).
