Article

API開発事例:外部サービス連携により自社システムの利便性が向上

高田晃太郎
API開発事例:外部サービス連携により自社システムの利便性が向上

Postmanの業界調査では、約62%の組織がAPIで直接的な収益を生み出していると回答し¹、投資意欲も高止まりしていると報告されています²。国内でもSaaSの多層利用が進み、決済、ID、地図、メッセージングなどの外部APIを組み合わせることで、開発スピードとユーザー体験を同時に引き上げる動きが一般化しました。本稿では、レガシー基幹システムに外部サービス連携を段階導入する際の設計・実装・運用の勘所を、CTO視点で中上級者向けに整理します。数値例は一般的な検証手法にもとづく代表的なレンジを示すものであり、環境や要件により大きく変動します。なお、p95レイテンシ(全リクエストの95%がこの時間以内に収まる指標)は、現場のユーザー体感と相関が高い実務的メトリクスです。

外部サービス連携で何が変わったのか

まず成果から整理します。既存の受注・課金フローに、決済ゲートウェイ、住所正規化とジオコーディング、SMS/メール通知、そしてOIDC(OpenID Connect)対応のID基盤を統合する構成は一般的です。ユーザーにとっての体感はチェックアウトの短縮と失敗率の低下で、例えば決済フローの完了率は数ポイント改善するケースが報告されています³。住所入力時の補完による配送精度向上は再配達率の低減につながることが多く、運用側では外部要因アラートの吸収や自動化により、障害対応の負荷を抑えやすくなります。エンタープライズ案件で求められる監査証跡についても、APIゲートウェイ経由での集中監査により、要件充足の見通しが立てやすくなります。

開発生産性の観点では、各外部APIを直接たたく方式をやめ、共通のBFF(Backend for Frontend)層とゲートウェイで抽象化することで、機能追加時の改修範囲は2〜3割縮小することが期待できます。外部ベンダの仕様変更はBFFのアダプタで吸収し、コアドメインはそのままに保てる構造に寄せるのが安全です。

測定条件と注意点

計測はステージングと本番の双方で行うのが望ましく、方法を揃えることが重要です。たとえばステージングではAWS c6i.large相当の構成にk6で負荷を与え、500仮想ユーザー、ランプ3分・維持10分のシナリオでp50/p95(中央値/95パーセンタイル)を取得します。本番はBlue/Green切り替え直後など変動が少ない期間に限定し、OpenTelemetryのトレースからサンプリングした統計値を用いるのが実務的です。キャッシュヒット率やベンダ側のスロットリングによって分布は変動するため、ここに示す値は代表値(目安)である点に留意してください。

アーキテクチャ設計:疎結合と安全性を両立

設計の中核は、外部APIを内製システムに直結しないことでした。APIゲートウェイで認証・認可・レート制御・監査を集約し、その内側にBFFを置いてプロバイダごとの差異を吸収します。マイクロサービスはゲートウェイ越しにのみ外部世界と接続し、アウトバウンドはmTLS(相互TLS)を強制。機械間認証はOAuth2のクライアントクレデンシャルズで統一し⁶、JWT(JSON Web Token)の検証はゲートウェイとBFFの双方で段階的に行います⁴。障害時の振る舞いは、タイムアウト、指数バックオフ、サーキットブレーカ(異常時に呼び出しを遮断する仕組み)、そしてGET系の短期キャッシュで安定化させ、決済のような副作用を伴う操作は冪等性キー⁷で重複を防ぎます。さらに、ベンダのイベントはWebhooksで受け取り⁵、整合性は最終的にイベントストアで確定させる(最終的整合性)設計が実務適合です。

以下にBFFでの代表的な実装例を示します。Node.js/Expressで、タイムアウト、リトライ、サーキットブレーカ、冪等性キーを一体で扱う例です。

// Example 1: Node.js BFF with timeout, retry, circuit breaker, idempotency
import express from 'express';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import CircuitBreaker from 'opossum';
import crypto from 'crypto';

const app = express();
app.use(express.json());

const upstream = axios.create({
  baseURL: process.env.PAYMENTS_BASE_URL,
  timeout: 2000,
});

axiosRetry(upstream, {
  retries: 2,
  retryDelay: (retryCount) => Math.min(1000 * 2 ** (retryCount - 1), 1500),
  retryCondition: (err) => !err.response || err.response.status >= 500,
});

async function callPayments(payload, idemKey) {
  return upstream.post('/v1/charges', payload, {
    headers: {
      'Idempotency-Key': idemKey,
      Authorization: `Bearer ${process.env.PAYMENTS_TOKEN}`,
    },
  });
}

const breaker = new CircuitBreaker(callPayments, {
  timeout: 2500,
  errorThresholdPercentage: 50,
  resetTimeout: 5000,
});

app.post('/bff/checkout', async (req, res) => {
  const idemKey = req.headers['x-idempotency-key'] || crypto.randomUUID();
  const payload = { amount: req.body.amount, currency: 'JPY', source: req.body.source };
  try {
    const response = await breaker.fire(payload, idemKey);
    res.status(200).json({ ok: true, chargeId: response.data.id });
  } catch (e) {
    const status = e.response?.status || 502;
    res.status(status).json({ ok: false, reason: 'upstream-failed', idemKey });
  }
});

app.listen(8080), () => console.log('BFF on :8080'));

この構成により、外部障害の影響をできる限りBFF内で打ち消し、アプリ本体のSLO(Service Level Objective:目標水準)を守れる状態を作ります。特にタイムアウトの短さは最大の安定化要因であり、業務要件が許す限り短く設定し、遅い外部APIはキャッシュやバルク化で補うのが実務的です。

実装の要点:認証、Webhook、キャッシュを正しく扱う

認証は機械間であればOAuth2のクライアントクレデンシャルズが標準です。トークンの取得とキャッシュ、期限切れのハンドリングをライブラリに任せすぎず、観測可能にするのが安全です。以下はGoによる外部API呼び出し例で、コンテキストに基づくタイムアウト、指数バックオフ、ジッタ、そして冪等性キーを実装しています。

// Example 2: Go client with context timeout, backoff + idempotency
package main

import (
    "bytes"
    "context"
    "crypto/rand"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "math"
    "math/rand"
    "net/http"
    "time"
)

type ChargeReq struct { Amount int `json:"amount"`; Currency string `json:"currency"`; Source string `json:"source"` }

type ChargeRes struct { ID string `json:"id"` }

func idemKey() string { b := make([]byte, 16); rand.Read(b); return hex.EncodeToString(b) }

func backoff(i int) time.Duration {
    base := 200 * time.Millisecond
    max := 1500 * time.Millisecond
    mult := time.Duration(math.Pow(2, float64(i)))
    jitter := time.Duration(rand.Intn(200)) * time.Millisecond
    d := base * mult + jitter
    if d > max { return max }
    return d
}

func callUpstream(ctx context.Context, client *http.Client, url, token string, req ChargeReq) (*ChargeRes, error) {
    body, _ := json.Marshal(req)
    idem := idemKey()
    for i := 0; i < 3; i++ {
        r, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
        r.Header.Set("Authorization", "Bearer "+token)
        r.Header.Set("Idempotency-Key", idem)
        r.Header.Set("Content-Type", "application/json")
        resp, err := client.Do(r)
        if err != nil {
            time.Sleep(backoff(i))
            continue
        }
        defer resp.Body.Close()
        if resp.StatusCode >= 500 {
            time.Sleep(backoff(i))
            continue
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            b, _ := io.ReadAll(resp.Body)
            var c ChargeRes
            json.Unmarshal(b, &c)
            return &c, nil
        }
        return nil, fmt.Errorf("upstream status %d", resp.StatusCode)
    }
    return nil, errors.New("upstream failed after retries")
}

func main() {
    client := &http.Client{ Timeout: 2 * time.Second }
    ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond)
    defer cancel()
    res, err := callUpstream(ctx, client, "https://api.example.com/v1/charges", "TOKEN", ChargeReq{Amount: 1200, Currency: "JPY", Source: "tok_123"})
    if err != nil { fmt.Println("error:", err); return }
    fmt.Println("charge id:", res.ID)
}

Webhookはプロバイダ側の真実の源泉として扱うべきで、署名とタイムスタンプの検証、冪等処理、リトライの安全性を満たす必要があります⁵。Python/FastAPIの例では、HMAC署名を検証し、遅延やリプレイ攻撃を抑制する実装にしています。

# Example 3: FastAPI webhook with HMAC verification
from fastapi import FastAPI, Request, HTTPException
import hmac, hashlib, time, os

app = FastAPI()
SECRET = os.getenv("WEBHOOK_SECRET", "devsecret")

def verify(sig_header: str, payload: bytes, ts: int) -> bool:
    if abs(int(time.time()) - ts) > 300:
        return False
    mac = hmac.new(SECRET.encode(), payload + str(ts).encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(mac, sig_header)

@app.post("/webhooks/payments")
async def payments_webhook(req: Request):
    body = await req.body()
    ts = int(req.headers.get("x-timestamp", "0"))
    sig = req.headers.get("x-signature", "")
    if not verify(sig, body, ts):
        raise HTTPException(status_code=401, detail="invalid signature")
    event = await req.json()
    event_id = event.get("id")
    # TODO: check idempotency store to avoid double-processing
    # process event safely
    return {"ok": True, "event": event_id}

レイテンシの大半を占める読み取り系の呼び出しは、短命キャッシュが効きます。Node.jsでのioredisを使ったキャッシュ例では、ジオコーディング結果を数分だけ保持し、ベンダのスロットリング緩和にも寄与します。

// Example 4: Node.js GET caching with ioredis
import express from 'express';
import axios from 'axios';
import Redis from 'ioredis';

const app = express();
const redis = new Redis(process.env.REDIS_URL);

app.get('/bff/geo', async (req, res) => {
  const q = (req.query.q || '').toString();
  const key = `geo:${q}`;
  try {
    const cached = await redis.get(key);
    if (cached) {
      return res.json({ source: 'cache', data: JSON.parse(cached) });
    }
    const { data } = await axios.get('https://maps.example.com/geocode', { params: { q }, timeout: 1500 });
    await redis.setex(key, 300, JSON.stringify(data));
    res.json({ source: 'live', data });
  } catch (e) {
    res.status(502).json({ ok: false, reason: 'geo-failed' });
  }
});

app.listen(8081);

エッジでのレート制御とヘッダ変換は、NginxやEnvoy、Kongなどのデータプレーンに任せるのが保守的です。以下はNginxでの簡易レート制御とヘッダサニタイズの例です。

# Example 5: Nginx rate limit and header sanitation
http {
  limit_req_zone $binary_remote_addr zone=perip:10m rate=5r/s;
  server {
    listen 443 ssl;
    location /api/ {
      limit_req zone=perip burst=10 nodelay;
      proxy_set_header X-Request-Id $request_id;
      proxy_set_header Authorization ""; # strip inbound auth to BFF
      proxy_pass http://bff_cluster;
    }
  }
}

OAuth2のトークン取得は、トークンエンドポイントへの呼び出しをラップし、期限前更新と観測を入れておくと運用で困りません。JavaでOkHttpを使ってトークンをキャッシュする最小例を示します。

// Example 6: Java token cache with OkHttp
import okhttp3.*;
import java.io.IOException;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;

public class TokenProvider {
  private final OkHttpClient client = new OkHttpClient();
  private final String tokenUrl, clientId, clientSecret;
  private final AtomicReference<String> token = new AtomicReference<>(null);
  private volatile long exp = 0;

  public TokenProvider(String tokenUrl, String clientId, String clientSecret) {
    this.tokenUrl = tokenUrl; this.clientId = clientId; this.clientSecret = clientSecret;
  }

  public synchronized String get() throws IOException {
    long now = Instant.now().getEpochSecond();
    if (token.get() != null && now < exp - 30) return token.get();
    RequestBody body = new FormBody.Builder()
      .add("grant_type", "client_credentials")
      .add("client_id", clientId)
      .add("client_secret", clientSecret)
      .build();
    Request req = new Request.Builder().url(tokenUrl).post(body).build();
    try (Response res = client.newCall(req).execute()) {
      if (!res.isSuccessful()) throw new IOException("token failed " + res.code());
      // parse {access_token, expires_in}
      String access = "parsed_token"; // parse JSON in real code
      int expiresIn = 3600;
      this.token.set(access); this.exp = now + expiresIn;
      return access;
    }
  }
}

品質とパフォーマンスの検証:実測値とSLO

性能は机上では語れません。ここではk6を用いたシナリオで、BFFの代表エンドポイントに一定の負荷をかける測定例を共有します。決済相当のPOSTと、地図検索のGETを混在させ、キャッシュヒット率が安定するようにウォームアップを含めています。

// Example 7: k6 load test (POST/GET mix)
import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  stages: [
    { duration: '3m', target: 500 },
    { duration: '10m', target: 500 },
    { duration: '2m', target: 0 },
  ],
};

export default function () {
  const idem = Math.random().toString(36).slice(2);
  http.post('https://bff.example.com/bff/checkout', JSON.stringify({ amount: 1200, source: 'tok' }), {
    headers: { 'Content-Type': 'application/json', 'x-idempotency-key': idem },
    timeout: '3s',
  });
  http.get('https://bff.example.com/bff/geo?q=東京都千代田区', { timeout: '2s' });
  sleep(1);
}

代表的な負荷条件(RPSが千件前後)では、BFFとキャッシュが適切に機能している場合、p95は数百ミリ秒台を目安に設計・チューニングできます。外部プロバイダの部分障害が発生しても、GETはキャッシュで吸収し、POSTはサーキットブレーカにより早期失敗でユーザーに返す比率が増える一方、SLO(例:可用性99.9%、p95レイテンシ400ms以内)は維持可能な構成に寄せられます。なお、外部APIのスロットリング閾値が厳しい場合、トークンをテナント単位に分けて割り当て、キューイングで平滑化するのが定石です。

観測性は、トレース、メトリクス、ログの三位一体が重要です。OpenTelemetryの導入で因果関係を追えるようにし、外部呼び出しのラベルにプロバイダ名、メソッド、ステータス、リトライ回数、冪等性キーのハッシュなどを載せると、SREが短時間で原因を絞り込めます。Node.jsの簡易インストゥルメンテーション例を示します。

// Example 8: OpenTelemetry basic setup (Node.js)
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';

const provider = new NodeTracerProvider();
provider.addSpanProcessor(new SimpleSpanProcessor(new OTLPTraceExporter({ url: process.env.OTLP_URL })));
provider.register();

registerInstrumentations({ instrumentations: [new HttpInstrumentation()] });

SLOは可用性とレイテンシの2軸で合意し、外部要因込みで達成可能な値に設定します。ユーザー操作の中心であるチェックアウトフローには、可用性99.9%、p95レイテンシ400ms以内といった目標値を置き、キャッシュが効きにくい画面遷移ではエラーバジェットを広めに確保するのが現実的です。コストの観点では、キャッシュとキューイングの導入により、ピーク時のスケールアウト幅を抑えられ、月間インフラコストを1〜2割程度削減できるケースもあります。詳細なコスト最適化の手順は別稿にまとめていますので、必要に応じて参照してください。クラウドコスト最適化の実務

副作用のある処理での落とし穴

決済や在庫更新のような副作用を伴う処理では、リトライが重複実行を招くことがあります。冪等性キーを必ず導入し、BFF内で結果をキーにひも付けて短期間保持することで、クライアントやネットワークの再送にも耐えられます。Webhooksでも同様に、イベントIDの重複排除と順序保証のためにイベントストアを使い、最終的整合性の観点から定期的な再照会で状態を収束させるのが安全です。

ビジネス価値:ROI、導入期間、運用負荷の現実

外部サービス連携は短期的な開発スピードと長期的な保守容易性の両立を狙う取り組みです。コア機能をスクラッチで作る場合と比べ、初期リリースまでの期間は数十%短縮できることがあり、BFFとゲートウェイの骨格は2スプリント程度で構築、以後の機能追加はアダプタ実装と契約テストの追加が中心となるため、反復のリードタイムはおおむね数日〜1〜2週間に収まります。運用面では、SaaS依存のリスクを観測可能性とフェイルセーフ設計で抑え、当番負荷の低減(2割前後)を狙えます。サードパーティ変更の影響も、BFFのマッピング層で吸収可能な範囲なら、短期間で適用しユーザー影響を最小化できます。

ライセンスコストやベンダーロックインを懸念する声もありますが、契約テストとコンシューマ駆動契約、そして機能ごとの抽象境界を明確にしたBFF設計により、プロバイダの差し替えは実務的な作業範囲に収まります。稟議では、短期の開発短縮効果、運用コストの低減、顧客体験の向上という三つの軸で投資回収期間(例:半年〜1年程度)を示すと、合意形成が進みます。

まとめ:外部に任せ、内側を磨く

すべてを自作せず、外部サービスの価値を取り込みながら、内製のコアを磨き込む。外部障害に巻き込まれないための短いタイムアウトやサーキットブレーカ、ユーザー体験を損なわないためのキャッシュとWebhook、そして観測可能な実装は、利便性と信頼性を両立させるための現実解です。読者の組織で同様の連携を検討しているなら、まずは一つのユーザージャーニーを選び、BFFとゲートウェイの骨格を小さく立てて実測してみてください。数値で語れるようになった瞬間に、合意形成は加速します。どの機能から始めるか、SLOをどう置くか、そして観測の粒度は適切か。次のスプリントの計画に、ひとつ実測可能な改善を織り込んでみませんか。

参考文献

  1. Postman. State of the API 2024. https://www.postman.com/state-of-api/2024/#:~:text=62,and%20marketed%20as%20strategic%20assets
  2. Postman. APIs and Monetization. https://www.postman.com/state-of-api/apis-and-monetization/#:~:text=When%20asked%20whether%20their%20APIs,of%20the%20business%27s%20total%20revenue
  3. Stripe. スムーズなチェックアウトでコンバージョンを高める方法. https://stripe.com/jp/resources/more/streamlined-checkout-processes-how-to-boost-conversions-with-an-easier-checkout-flow#:~:text=%E5%A4%9A%E3%81%8F%E3%81%AE%E6%B1%BA%E6%B8%88%E7%94%BB%E9%9D%A2%E3%81%A7%E3%81%AF%E3%80%81%E5%BF%85%E8%A6%81%E4%BB%A5%E4%B8%8A%E3%81%AE%E6%83%85%E5%A0%B1%E3%82%92%E5%85%A5%E5%8A%9B%E3%81%95%E3%81%9B%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99%E3%80%822024%E5%B9%B4%E3%81%AE%E8%AA%BF%E6%9F%BB%E3%81%A7%E3%81%AF%E3%80%81%E6%B1%BA%E6%B8%88%E3%83%95%E3%83%AD%E3%83%BC%E3%81%A7%E5%B9%B3%E5%9D%8711
  4. AWS. Amazon API Gateway HTTP API の JWT オーソライザー. https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-jwt-authorizer.html#:~:text=You%20can%20use%20JSON%20Web,client%20access%20to%20your%20APIs
  5. LINE Developers. Webhook 署名の検証. https://developers.line.biz/ja/docs/messaging-api/verify-webhook-signature/#:~:text=%E3%83%9C%E3%83%83%E3%83%88%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%ABWebhook%E3%81%AE%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%8C%E3%82%82%E3%81%95%E3%80%81Webhook%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E5%87%A6%E7%90%86%E3%81%99%E3%82%8B%E5%89%8D%E3%81%AB%E3%80%81%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%98%E3%83%83%E3%83%80%E3%83%BC%E3%81%AB%E5%90%AB%E3%81%BE%E3%82%8C%E3%82%8B%E7%BD%B2%E5%90%8D%E3%82%92%E6%A4%9C%E8%A8%BC%E3%81%97%E3%81%A6%E3%81%8F%E3%81%A0%E3%81%95%20%E3%81%84%E3%80%82
  6. Authgear. The Complete Guide to Machine-to-Machine (M2M) Authentication. https://www.authgear.com/post/the-complete-guide-to-machine-to-machine-m2m-authentication#:~:text=Machine,0%20spec%3A%20RFC%206749
  7. Stripe Docs. Idempotent requests. https://docs.stripe.com/api/idempotent_requests?event_types-payment_intent.payment_failed=&lang=dotnet#:~:text=Idempotent%20requests