0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【どう実装する?シリーズ #3】データの集計、Controller で全部やってない?

Posted at

生データから集計値を計算するロジックをControllerに直接書いていたら、パフォーマンス改善でデータソースを変更する際に全面改修が必要に。Repository パターンで抽象化しておけば避けられた問題を振り返ります。

はじめに

前回の記事では、履歴機能をEvent + Listenerで分離する話をしました。

今回はLPcatsの「アクセスデータ分析機能」で直面したパフォーマンス問題と、それを改善する際に苦労した話です。「データソースが変わっただけなのに、なぜControllerを全面改修しないといけないのか...」という反省から、どう実装すべきだったかを考えます。

LPcatsのアクセス分析機能

LPcatsでは、LPのパフォーマンスを可視化するための分析機能を提供しています。

記録している指標

LPレベル:

  • セッション数
  • ユーザー数
  • CTAクリック数・率
  • CV数・率
  • 平均滞在時間

これらの指標を期間指定で取得できるAPIを提供しています。

データ構造

access_logs テーブル(生データ)
- id
- lp_id
- step_id
- session_id
- user_id
- event_type (view, cta_click, conversion)
- duration
- created_at

全てのアクセスイベントを記録する生ログテーブルです。

最初の実装

class AnalyticsController extends Controller
{
    public function getLpAnalytics(Lp $lp)
    {
        $startDate = request('start_date');
        $endDate = request('end_date');
        
        // セッション数(SQL集計)
        $sessionCount = AccessLog::where('lp_id', $lp->id)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->distinct('session_id')
            ->count('session_id');
        
        // ユーザー数(SQL集計)
        $userCount = AccessLog::where('lp_id', $lp->id)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->distinct('user_id')
            ->count('user_id');
        
        // CTAクリック数(SQL集計)
        $ctaClicks = AccessLog::where('lp_id', $lp->id)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->where('event_type', 'cta_click')
            ->count();
        
        $ctaClickRate = $sessionCount > 0 
            ? ($ctaClicks / $sessionCount) * 100 
            : 0;
        
        // CV数(SQL集計)
        $conversions = AccessLog::where('lp_id', $lp->id)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->where('event_type', 'conversion')
            ->count();
        
        $conversionRate = $sessionCount > 0 
            ? ($conversions / $sessionCount) * 100 
            : 0;
        
        // 平均滞在時間(ハズレ値除外のためPHPで計算)
        $durations = AccessLog::where('lp_id', $lp->id)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->whereNotNull('duration')
            ->pluck('duration');
        
        $avgDuration = $this->calculateAverageDuration($durations);
        
        return response()->json([
            'session_count' => $sessionCount,
            'user_count' => $userCount,
            'cta_clicks' => $ctaClicks,
            'cta_click_rate' => $ctaClickRate,
            'conversions' => $conversions,
            'conversion_rate' => $conversionRate,
            'avg_duration' => $avgDuration,
        ]);
    }
    
    private function calculateAverageDuration($durations)
    {
        if ($durations->isEmpty()) return 0;
        
        // 平均と標準偏差計算
        $mean = $durations->avg();
        $stdDev = $this->standardDeviation($durations, $mean);
        
        // 3σ外のデータを除外
        $filtered = $durations->filter(function($duration) use ($mean, $stdDev) {
            return abs($duration - $mean) <= 3 * $stdDev;
        });
        
        return $filtered->avg();
    }
    
    private function standardDeviation($durations, $mean)
    {
        $variance = $durations->map(function($duration) use ($mean) {
            return pow($duration - $mean, 2);
        })->avg();
        
        return sqrt($variance);
    }
}

特徴:

  • Controllerで生の AccessLog テーブルから直接集計
  • カウント系はSQLで計算
  • 平均滞在時間だけはハズレ値除外ロジックがあるのでPHPで計算

最初はこれで問題なく動いていました。

問題発覚:パフォーマンス悪化

サービスが成長し、アクセスログが増えるにつれて問題が発生しました。

access_logs テーブル
- 初期:数万件
- 半年後:数十万件
- 1年後:数百万件 ← クエリが遅い...

症状:

  • 分析画面の表示に5秒以上かかる
  • 期間を広げるとタイムアウト
  • DBの負荷が高い

生データから毎回集計するのは限界でした。

改善策:集計テーブルの導入

パフォーマンス改善のため、日次で集計済みのデータを保持することにしました。

daily_lp_analytics テーブル(集計済み)
- id
- lp_id
- date
- session_count
- user_count
- cta_clicks
- conversions
- total_duration
- duration_count
- created_at

バッチ処理で毎日集計して、このテーブルに保存する仕組みにしました。

さらなる問題:Controller全面改修が必要に

集計テーブルができたので、Controllerを修正...しようとしたところで気づきました。

全面的な書き換えが必要

// Before: 生データから
$sessionCount = AccessLog::where('lp_id', $lp->id)
    ->whereBetween('created_at', [$startDate, $endDate])
    ->distinct('session_id')
    ->count('session_id');

// After: 集計テーブルから
$sessionCount = DailyLpAnalytics::where('lp_id', $lp->id)
    ->whereBetween('date', [$startDate, $endDate])
    ->sum('session_count');

この変更が全ての指標で発生します。

public function getLpAnalytics(Lp $lp)
{
    $startDate = request('start_date');
    $endDate = request('end_date');
    
    // ← 全部書き直し
    $sessionCount = DailyLpAnalytics::where('lp_id', $lp->id)
        ->whereBetween('date', [$startDate, $endDate])
        ->sum('session_count');
    
    $userCount = DailyLpAnalytics::where('lp_id', $lp->id)
        ->whereBetween('date', [$startDate, $endDate])
        ->sum('user_count');
    
    $ctaClicks = DailyLpAnalytics::where('lp_id', $lp->id)
        ->whereBetween('date', [$startDate, $endDate])
        ->sum('cta_clicks');
    
    // ... 以下同様に全部変更
}

問題点:

  • APIの入出力は全く変わっていない
  • データソースが変わっただけ
  • なのにController全体を書き直す必要がある
  • テストも全部書き直し

これは明らかに設計ミスでした。

こうしておけばよかった:Repositoryパターン

振り返って、Repository パターンでデータ取得を抽象化しておけばよかったと思います。

Repositoryインターフェース

interface LpAnalyticsRepository
{
    public function getSessionCount(int $lpId, string $startDate, string $endDate): int;
    public function getUserCount(int $lpId, string $startDate, string $endDate): int;
    public function getCtaClicks(int $lpId, string $startDate, string $endDate): int;
    public function getConversions(int $lpId, string $startDate, string $endDate): int;
    public function getDurations(int $lpId, string $startDate, string $endDate): Collection;
}

最初の実装:生データから取得

class AccessLogLpAnalyticsRepository implements LpAnalyticsRepository
{
    public function getSessionCount(int $lpId, string $startDate, string $endDate): int
    {
        return AccessLog::where('lp_id', $lpId)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->distinct('session_id')
            ->count('session_id');
    }
    
    public function getUserCount(int $lpId, string $startDate, string $endDate): int
    {
        return AccessLog::where('lp_id', $lpId)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->distinct('user_id')
            ->count('user_id');
    }
    
    public function getCtaClicks(int $lpId, string $startDate, string $endDate): int
    {
        return AccessLog::where('lp_id', $lpId)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->where('event_type', 'cta_click')
            ->count();
    }
    
    public function getConversions(int $lpId, string $startDate, string $endDate): int
    {
        return AccessLog::where('lp_id', $lpId)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->where('event_type', 'conversion')
            ->count();
    }
    
    public function getDurations(int $lpId, string $startDate, string $endDate): Collection
    {
        return AccessLog::where('lp_id', $lpId)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->whereNotNull('duration')
            ->pluck('duration');
    }
}

Controller(データソースに依存しない)

class AnalyticsController extends Controller
{
    public function __construct(
        private LpAnalyticsRepository $repository
    ) {}
    
    public function getLpAnalytics(Lp $lp)
    {
        $startDate = request('start_date');
        $endDate = request('end_date');
        
        // Repositoryから取得(どのテーブルから取るかは知らない)
        $sessionCount = $this->repository->getSessionCount($lp->id, $startDate, $endDate);
        $userCount = $this->repository->getUserCount($lp->id, $startDate, $endDate);
        $ctaClicks = $this->repository->getCtaClicks($lp->id, $startDate, $endDate);
        $conversions = $this->repository->getConversions($lp->id, $startDate, $endDate);
        $durations = $this->repository->getDurations($lp->id, $startDate, $endDate);
        
        // 計算ロジック(変わらない)
        $ctaClickRate = $sessionCount > 0 
            ? ($ctaClicks / $sessionCount) * 100 
            : 0;
        
        $conversionRate = $sessionCount > 0 
            ? ($conversions / $sessionCount) * 100 
            : 0;
        
        $avgDuration = $this->calculateAverageDuration($durations);
        
        return response()->json([
            'session_count' => $sessionCount,
            'user_count' => $userCount,
            'cta_clicks' => $ctaClicks,
            'cta_click_rate' => $ctaClickRate,
            'conversions' => $conversions,
            'conversion_rate' => $conversionRate,
            'avg_duration' => $avgDuration,
        ]);
    }
    
    private function calculateAverageDuration($durations)
    {
        if ($durations->isEmpty()) return 0;
        
        $mean = $durations->avg();
        $stdDev = $this->standardDeviation($durations, $mean);
        
        $filtered = $durations->filter(function($duration) use ($mean, $stdDev) {
            return abs($duration - $mean) <= 3 * $stdDev;
        });
        
        return $filtered->avg();
    }
    
    private function standardDeviation($durations, $mean)
    {
        $variance = $durations->map(function($duration) use ($mean) {
            return pow($duration - $mean, 2);
        })->avg();
        
        return sqrt($variance);
    }
}

パフォーマンス改善時:Repositoryだけ変更

class AggregatedLpAnalyticsRepository implements LpAnalyticsRepository
{
    public function getSessionCount(int $lpId, string $startDate, string $endDate): int
    {
        // 集計テーブルから取得に変更
        return DailyLpAnalytics::where('lp_id', $lpId)
            ->whereBetween('date', [$startDate, $endDate])
            ->sum('session_count');
    }
    
    public function getUserCount(int $lpId, string $startDate, string $endDate): int
    {
        return DailyLpAnalytics::where('lp_id', $lpId)
            ->whereBetween('date', [$startDate, $endDate])
            ->sum('user_count');
    }
    
    public function getCtaClicks(int $lpId, string $startDate, string $endDate): int
    {
        return DailyLpAnalytics::where('lp_id', $lpId)
            ->whereBetween('date', [$startDate, $endDate])
            ->sum('cta_clicks');
    }
    
    public function getConversions(int $lpId, string $startDate, string $endDate): int
    {
        return DailyLpAnalytics::where('lp_id', $lpId)
            ->whereBetween('date', [$startDate, $endDate])
            ->sum('conversions');
    }
    
    public function getDurations(int $lpId, string $startDate, string $endDate): Collection
    {
        // 集計テーブルには total_duration と duration_count がある
        // ここでは簡略化のため生データから取得する例
        // 実際にはより工夫が必要
        return AccessLog::where('lp_id', $lpId)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->whereNotNull('duration')
            ->pluck('duration');
    }
}

DIコンテナで切り替え

// AppServiceProvider
public function register()
{
    $this->app->bind(
        LpAnalyticsRepository::class,
        AggregatedLpAnalyticsRepository::class // ← ここを変えるだけ
    );
}

変更箇所:

  • Repositoryの実装クラス
  • DIコンテナのバインディング

変更不要:

  • Controller
  • テスト(Repositoryをモックしているため)
  • APIの入出力

この設計の利点

1. データソースの変更に強い

Controllerはデータの取得方法を知らないため、データソースが変わってもControllerは一切変更不要です。

2. テストが簡単

public function test_getLpAnalytics()
{
    // Repositoryをモック
    $mockRepository = Mockery::mock(LpAnalyticsRepository::class);
    $mockRepository->shouldReceive('getSessionCount')->andReturn(100);
    $mockRepository->shouldReceive('getUserCount')->andReturn(80);
    $mockRepository->shouldReceive('getCtaClicks')->andReturn(30);
    $mockRepository->shouldReceive('getConversions')->andReturn(10);
    $mockRepository->shouldReceive('getDurations')->andReturn(collect([120, 150, 180]));
    
    $this->app->instance(LpAnalyticsRepository::class, $mockRepository);
    
    $response = $this->get("/api/lps/1/analytics");
    
    $response->assertOk();
    $response->assertJson([
        'session_count' => 100,
        'user_count' => 80,
        'cta_clicks' => 30,
        'cta_click_rate' => 30.0,
        'conversions' => 10,
        'conversion_rate' => 10.0,
    ]);
}

Repositoryをモックすることで、DBに依存しないテストが書けます。

3. 段階的な移行が可能

class HybridLpAnalyticsRepository implements LpAnalyticsRepository
{
    public function getSessionCount(int $lpId, string $startDate, string $endDate): int
    {
        // 最近のデータは集計テーブルから、古いデータは生データから
        $cutoffDate = now()->subMonths(3);
        
        if ($startDate >= $cutoffDate) {
            return DailyLpAnalytics::where('lp_id', $lpId)
                ->whereBetween('date', [$startDate, $endDate])
                ->sum('session_count');
        }
        
        return AccessLog::where('lp_id', $lpId)
            ->whereBetween('created_at', [$startDate, $endDate])
            ->distinct('session_id')
            ->count('session_id');
    }
}

データソースを柔軟に切り替えられます。

4. 責務の分離

  • Controller:APIの入出力とビジネスロジック
  • Repository:データの取得方法

それぞれが独立して変更できます。

5. 将来の拡張性

例えば、キャッシュを挟む場合:

class CachedLpAnalyticsRepository implements LpAnalyticsRepository
{
    public function __construct(
        private LpAnalyticsRepository $repository
    ) {}
    
    public function getSessionCount(int $lpId, string $startDate, string $endDate): int
    {
        $key = "lp:{$lpId}:sessions:{$startDate}:{$endDate}";
        
        return Cache::remember($key, 3600, function() use ($lpId, $startDate, $endDate) {
            return $this->repository->getSessionCount($lpId, $startDate, $endDate);
        });
    }
    
    // 他のメソッドも同様にキャッシュを追加
}

Decorator パターンでキャッシュ層を追加できます。Controllerは変更不要。

まとめ

アクセスデータの集計をControllerで直接行っていたため、パフォーマンス改善でデータソースを変更する際に:

  • Controller全面改修
  • テスト全面書き直し
  • APIは変わっていないのに...

という問題が発生しました。

Repository パターンでデータ取得を抽象化しておけば:

  • Controllerはデータソースを知らない
  • 変更はRepositoryに閉じる
  • テストも変更不要
  • 段階的な移行も可能

データソースが変わる可能性がある場合、最初からRepositoryパターンで抽象化しておくべきでした。

次回予告

次回は、平均滞在時間の計算方法を見直す際にも同様の問題に直面した話です。計算ロジックをドメインオブジェクトにカプセル化しておけば、もっと楽に変更できたはず...という反省から、どう実装すべきだったかを書きます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?