Tasuke Hubのロゴ

ITを中心に困っている人を助けるメディア

分かりやすく解決策を提供することで、あなたの困ったをサポート。 全ての人々がスムーズに生活できる世界を目指します。

Node.jsでAPIレスポンス時間が遅い問題の5つの解決策

記事のサムネイル

パフォーマンス問題の特定方法

Node.jsアプリケーションのパフォーマンス問題を解決する前に、まずは問題を正確に特定することが重要です。

基本的なモニタリング設定

最初に、基本的なレスポンス時間の測定を行います。Expressアプリケーションでは、簡単なミドルウェアでAPIの実行時間を計測できます。

const express = require('express');
const app = express();

// レスポンス時間測定ミドルウェア
app.use((req, res, next) => {
  const start = Date.now();
  
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.path}: ${duration}ms`);
  });
  
  next();
});

パフォーマンスメトリクスの収集

より詳細な分析のために、Node.jsのprocess.hrtime.bigint()を使用してより精密な時間測定を行います。

const measurePerformance = (name) => {
  const start = process.hrtime.bigint();
  
  return () => {
    const end = process.hrtime.bigint();
    const duration = Number(end - start) / 1000000; // ナノ秒をミリ秒に変換
    console.log(`${name}: ${duration.toFixed(2)}ms`);
    return duration;
  };
};

// 使用例
app.get('/api/users', async (req, res) => {
  const endMeasure = measurePerformance('Get Users API');
  
  try {
    const users = await getUsersFromDatabase();
    res.json(users);
  } finally {
    endMeasure();
  }
});

このような測定により、どのAPIエンドポイントが最も時間を要しているかを特定できます。

データベースクエリの最適化

データベースアクセスは、Node.jsアプリケーションのパフォーマンスボトルネックの最も一般的な原因です。適切な最適化により、大幅な改善が可能です。

N+1問題の解決

最も重要な最適化の一つは、N+1問題の解決です。これは、リスト表示時に各アイテムに対して個別にクエリが実行される問題です。

// 悪い例:N+1問題
const getPostsWithAuthors = async () => {
  const posts = await db.query('SELECT * FROM posts');
  
  for (let post of posts) {
    // 各投稿に対して個別クエリ(N+1問題)
    post.author = await db.query('SELECT * FROM users WHERE id = ?', [post.user_id]);
  }
  
  return posts;
};

// 良い例:JOINを使用
const getPostsWithAuthorsOptimized = async () => {
  const query = `
    SELECT 
      posts.*,
      users.name as author_name,
      users.email as author_email
    FROM posts
    JOIN users ON posts.user_id = users.id
  `;
  
  return await db.query(query);
};

インデックスの効果的な利用

適切なインデックスの設定により、クエリ実行時間を劇的に短縮できます。

// PostgreSQLの場合
const createIndexes = async () => {
  // 検索でよく使用されるカラムにインデックス作成
  await db.query('CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at)');
  await db.query('CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id)');
  
  // 複合インデックス
  await db.query('CREATE INDEX IF NOT EXISTS idx_posts_status_created ON posts(status, created_at)');
};

// インデックスを活用したクエリ
const getRecentPostsByUser = async (userId, limit = 10) => {
  const query = `
    SELECT * FROM posts 
    WHERE user_id = $1 
    ORDER BY created_at DESC 
    LIMIT $2
  `;
  
  return await db.query(query, [userId, limit]);
};

バッチ処理による最適化

複数のレコードを一度に処理することで、データベースへのアクセス回数を減らします。

// 悪い例:個別の挿入
const insertPostsIndividually = async (posts) => {
  for (let post of posts) {
    await db.query('INSERT INTO posts (title, content, user_id) VALUES (?, ?, ?)', 
      [post.title, post.content, post.user_id]);
  }
};

// 良い例:バッチ挿入
const insertPostsBatch = async (posts) => {
  const values = posts.map(post => `('${post.title}', '${post.content}', ${post.user_id})`).join(',');
  const query = `INSERT INTO posts (title, content, user_id) VALUES ${values}`;
  
  return await db.query(query);
};

これらの最適化により、データベースアクセスのパフォーマンスを大幅に改善できます。

メモリ使用量の改善

Node.jsアプリケーションのメモリ効率を向上させることで、ガベージコレクションの頻度を減らし、全体的なパフォーマンスを改善できます。

オブジェクトプールの活用

頻繁に作成・破棄されるオブジェクトについては、オブジェクトプールを使用してメモリ使用量を最適化します。

class ObjectPool {
  constructor(createFn, resetFn, initialSize = 10) {
    this.createFn = createFn;
    this.resetFn = resetFn;
    this.pool = [];
    
    // 初期プールを作成
    for (let i = 0; i < initialSize; i++) {
      this.pool.push(this.createFn());
    }
  }
  
  acquire() {
    return this.pool.length > 0 ? this.pool.pop() : this.createFn();
  }
  
  release(obj) {
    this.resetFn(obj);
    this.pool.push(obj);
  }
}

// 使用例:データベース接続用のオブジェクトプール
const responsePool = new ObjectPool(
  () => ({ data: null, status: 200, message: '' }),
  (obj) => {
    obj.data = null;
    obj.status = 200;
    obj.message = '';
  }
);

app.get('/api/data', async (req, res) => {
  const response = responsePool.acquire();
  
  try {
    response.data = await fetchDataFromDatabase();
    response.message = 'Success';
    res.json(response);
  } finally {
    responsePool.release(response);
  }
});

ストリーミング処理による大容量ファイル対応

大きなファイルやデータセットを処理する際は、ストリーミングを使用してメモリ使用量を制御します。

const fs = require('fs');
const csv = require('csv-parser');
const { Transform } = require('stream');

// 大容量CSVファイルの処理
app.get('/api/process-csv', (req, res) => {
  const results = [];
  let processedCount = 0;
  
  const processTransform = new Transform({
    objectMode: true,
    transform(chunk, encoding, callback) {
      // データ処理ロジック
      const processed = {
        id: chunk.id,
        processedAt: new Date(),
        value: parseFloat(chunk.value) * 1.1
      };
      
      processedCount++;
      
      // メモリ使用量を制御するため、バッチ処理
      if (processedCount % 1000 === 0) {
        this.push(JSON.stringify(processed) + '\n');
      }
      
      callback();
    }
  });
  
  fs.createReadStream('large-file.csv')
    .pipe(csv())
    .pipe(processTransform)
    .pipe(res);
});

WeakMapとWeakSetによるメモリリーク防止

適切なデータ構造を選択することで、メモリリークを防止できます。

// メモリリークの原因となる例
const cache = new Map();
const addToCache = (user, data) => {
  cache.set(user, data); // userオブジェクトが削除されてもキャッシュに残る
};

// 改善版:WeakMapを使用
const cache = new WeakMap();
const addToCache = (user, data) => {
  cache.set(user, data); // userオブジェクトが削除されると自動的にキャッシュからも削除
};

// 実践例:リクエストごとのメタデータ管理
const requestMetadata = new WeakMap();

app.use((req, res, next) => {
  requestMetadata.set(req, {
    startTime: Date.now(),
    userAgent: req.get('User-Agent'),
    ip: req.ip
  });
  next();
});

app.get('/api/example', (req, res) => {
  const metadata = requestMetadata.get(req);
  const duration = Date.now() - metadata.startTime;
  
  res.json({
    message: 'Success',
    metadata: { duration, userAgent: metadata.userAgent }
  });
});

これらの手法により、メモリ効率を大幅に改善し、アプリケーションの安定性を向上させることができます。

非同期処理の効率化

Node.jsの非同期処理を適切に活用することで、I/O待機時間を削減し、全体のパフォーマンスを向上させることができます。

Promise.allによる並列処理

複数の非同期処理を効率的に実行するため、Promise.allを活用します。

// 悪い例:逐次実行
const getUserDataSequential = async (userId) => {
  const user = await fetchUser(userId);
  const posts = await fetchUserPosts(userId);
  const followers = await fetchUserFollowers(userId);
  
  return { user, posts, followers };
};

// 良い例:並列実行
const getUserDataParallel = async (userId) => {
  const [user, posts, followers] = await Promise.all([
    fetchUser(userId),
    fetchUserPosts(userId),
    fetchUserFollowers(userId)
  ]);
  
  return { user, posts, followers };
};

ワーカープールによるCPU集約的タスクの処理

CPU集約的なタスクをワーカースレッドで実行し、メインスレッドをブロックしないようにします。

const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
  // メインスレッド
  const processLargeDataset = (data) => {
    return new Promise((resolve, reject) => {
      const worker = new Worker(__filename, {
        workerData: data
      });
      
      worker.on('message', resolve);
      worker.on('error', reject);
    });
  };
  
  app.post('/api/process', async (req, res) => {
    const result = await processLargeDataset(req.body.data);
    res.json(result);
  });
} else {
  // ワーカースレッド
  const processData = (data) => {
    // CPU集約的な処理
    return data.map(item => ({
      ...item,
      processed: true,
      hash: require('crypto').createHash('sha256').update(item.id).digest('hex')
    }));
  };
  
  parentPort.postMessage(processData(workerData));
}

適切なエラーハンドリング

非同期処理のエラーハンドリングを適切に行い、アプリケーションの安定性を確保します。

const asyncHandler = (fn) => {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await getUserById(req.params.id);
  
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  
  res.json(user);
}));

これらの手法により、非同期処理を効率化し、レスポンス時間を大幅に短縮できます。

キャッシュ戦略の実装

適切なキャッシュ戦略により、データベースアクセスや外部API呼び出しを大幅に削減し、レスポンス時間を改善できます。

Redisを使ったメモリキャッシュ

頻繁にアクセスされるデータをRedisにキャッシュすることで、データベースの負荷を軽減できます。

const redis = require('redis');
const client = redis.createClient();

const cacheMiddleware = (ttl = 300) => {
  return async (req, res, next) => {
    const key = `cache:${req.originalUrl}`;
    
    try {
      const cached = await client.get(key);
      
      if (cached) {
        return res.json(JSON.parse(cached));
      }
      
      // レスポンスをキャッチして保存
      const originalSend = res.json;
      res.json = function(body) {
        client.setex(key, ttl, JSON.stringify(body));
        originalSend.call(this, body);
      };
      
      next();
    } catch (error) {
      next();
    }
  };
};

// 使用例
app.get('/api/popular-posts', cacheMiddleware(600), async (req, res) => {
  const posts = await getPopularPosts();
  res.json(posts);
});

アプリケーションレベルキャッシュ

軽量なキャッシュについては、Node.jsのMapオブジェクトを使用したシンプルなキャッシュを実装できます。

class SimpleCache {
  constructor(maxSize = 1000, ttl = 300000) {
    this.cache = new Map();
    this.maxSize = maxSize;
    this.ttl = ttl;
  }
  
  set(key, value) {
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    
    this.cache.set(key, {
      value,
      timestamp: Date.now()
    });
  }
  
  get(key) {
    const item = this.cache.get(key);
    
    if (!item) return null;
    
    if (Date.now() - item.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }
    
    return item.value;
  }
}

const apiCache = new SimpleCache();

app.get('/api/user/:id', async (req, res) => {
  const userId = req.params.id;
  const cacheKey = `user:${userId}`;
  
  let user = apiCache.get(cacheKey);
  
  if (!user) {
    user = await getUserById(userId);
    apiCache.set(cacheKey, user);
  }
  
  res.json(user);
});

ETagsによるHTTPキャッシュ

適切なHTTPヘッダーを設定することで、クライアント側でのキャッシュを効率化できます。

const crypto = require('crypto');

const generateETag = (data) => {
  return crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
};

app.get('/api/articles', async (req, res) => {
  const articles = await getArticles();
  const etag = generateETag(articles);
  
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }
  
  res.set({
    'ETag': etag,
    'Cache-Control': 'public, max-age=300'
  });
  
  res.json(articles);
});

これらのキャッシュ戦略を組み合わせることで、APIのレスポンス時間を劇的に改善できます。

実践的なプロファイリング手法

Node.jsアプリケーションのパフォーマンス問題を根本的に解決するためには、適切なプロファイリングツールの活用が不可欠です。

Node.js内蔵プロファイラーの使用

Node.jsには標準でCPUプロファイラーが内蔵されており、本番環境でも安全に使用できます。

const v8Profiler = require('v8-profiler-node8');
const fs = require('fs');

const startProfiling = (duration = 30000) => {
  const profileName = `profile-${Date.now()}`;
  v8Profiler.startProfiling(profileName, true);
  
  setTimeout(() => {
    const profile = v8Profiler.stopProfiling(profileName);
    
    profile.export((error, result) => {
      fs.writeFileSync(`${profileName}.cpuprofile`, result);
      console.log(`Profile saved: ${profileName}.cpuprofile`);
    });
  }, duration);
};

// 特定のAPIでプロファイリングを実行
app.get('/api/heavy-operation', async (req, res) => {
  if (req.query.profile === 'true') {
    startProfiling(10000);
  }
  
  const result = await performHeavyOperation();
  res.json(result);
});

アプリケーション監視メトリクス

継続的な監視のため、重要なメトリクスを収集し、可視化します。

const promClient = require('prom-client');

// カスタムメトリクスの作成
const httpDuration = new promClient.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status_code']
});

const dbQueryDuration = new promClient.Histogram({
  name: 'db_query_duration_seconds',
  help: 'Duration of database queries in seconds',
  labelNames: ['operation', 'table']
});

// ミドルウェアでメトリクスを収集
app.use((req, res, next) => {
  const start = Date.now();
  
  res.on('finish', () => {
    const duration = (Date.now() - start) / 1000;
    httpDuration
      .labels(req.method, req.route?.path || req.path, res.statusCode)
      .observe(duration);
  });
  
  next();
});

// メトリクスエンドポイント
app.get('/metrics', (req, res) => {
  res.set('Content-Type', promClient.register.contentType);
  res.end(promClient.register.metrics());
});

パフォーマンステストの自動化

継続的にパフォーマンスをチェックするため、自動化されたテストを実装します。

// performance-test.js
const autocannon = require('autocannon');

const runPerformanceTest = async (url, options = {}) => {
  const defaultOptions = {
    url,
    connections: 10,
    pipelining: 1,
    duration: 10,
    ...options
  };
  
  console.log(`Starting performance test for ${url}`);
  
  try {
    const result = await autocannon(defaultOptions);
    
    const report = {
      url,
      timestamp: new Date().toISOString(),
      averageLatency: result.latency.average,
      requests: result.requests.total,
      throughput: result.throughput.average,
      errors: result.errors
    };
    
    console.log('Performance Test Results:', report);
    
    // しきい値をチェック
    if (result.latency.average > 1000) {
      console.warn('⚠️  Average latency exceeds 1000ms');
    }
    
    return report;
  } catch (error) {
    console.error('Performance test failed:', error);
    throw error;
  }
};

// 使用例
runPerformanceTest('http://localhost:3000/api/users', {
  connections: 50,
  duration: 30
});

これらのプロファイリング手法により、パフォーマンス問題を早期発見し、継続的な改善を行うことができます。本記事で紹介した5つの解決策を組み合わせることで、Node.jsアプリケーションのAPIレスポンス時間を大幅に改善できるでしょう。

TH

Tasuke Hub管理人

東証プライム市場上場企業エンジニア

情報系修士卒業後、大手IT企業にてフルスタックエンジニアとして活躍。 Webアプリケーション開発からクラウドインフラ構築まで幅広い技術に精通し、 複数のプロジェクトでリードエンジニアを担当。 技術ブログやオープンソースへの貢献を通じて、日本のIT技術コミュニティに積極的に関わっている。

🎓情報系修士🏢東証プライム上場企業💻フルスタックエンジニア📝技術ブログ執筆者

このトピックはこちらの書籍で勉強するのがおすすめ!

この記事の内容をさらに深く理解したい方におすすめの一冊です。実践的な知識を身につけたい方は、ぜひチェックしてみてください!

おすすめ記事

おすすめコンテンツ