API Gateway WebSocket を使ってリアルタイム共同お絵描きアプリを作ってみた

API Gateway WebSocket を使ってリアルタイム共同お絵描きアプリを作ってみた

2025.11.16

こんにちは。製造ビジネステクのロジー部の小林です。

普段は API Gateway の Rest API を中心に利用していますが、最近、WebSocket に触れる機会がありました。リアルタイム通信の技術である WebSocket は初めての挑戦だったので、学習を兼ねて「複数人が同時にリアルタイムでお絵描きできるWebアプリケーション」を開発してみました。

本記事では、このリアルタイムお絵描きアプリを API Gateway WebSocket を使ってどのように実現したのか、その開発の道のりをご紹介します。

完成イメージ

複数のブラウザで同じURLにアクセスすると、誰かが描いた線がリアルタイムで全員の画面に反映されます。色や線の太さも選択でき、全消去ボタンで全員の画面を一括でクリアできます。

前提条件

今回は、API Gateway WebSocket の検証に焦点をあてました。そのため、バックエンドとフロントエンドの説明は簡略化していることをご承知おきください🙇‍♂️

なぜWebSocketが必要なのか?

まず、従来のREST APIとWebSocketの違いを見てみます。

REST API(HTTP)の通信モデル

REST APIはリクエスト/レスポンスモデルです。

クライアント → [HTTPリクエスト] → サーバー
クライアント ← [HTTPレスポンス] ← サーバー

特徴

  • クライアントが主導権を持つ(クライアントがリクエストしない限り通信は発生しない)
  • サーバーから能動的にデータを送信できない
  • 1リクエスト = 1レスポンス = 1接続

REST APIでリアルタイム更新を実現しようとする場合(ポーリング)

REST APIはサーバーからクライアントへプッシュ通知ができないため、クライアント側で定期的にサーバーに問い合わせる必要があります。これをポーリング(Polling)と呼びます。

ポーリングの例

// クライアント側のコード例
setInterval(async () => {
  // 1秒ごとにサーバーに「更新ありますか?」と問い合わせ
  const response = await fetch('/api/drawing/updates');
  const data = await response.json();

  // 更新があれば画面に反映
  if (data.hasUpdates) {
    drawLine(data.newDrawing);
  }
}, 1000); // 1秒間隔でポーリング

このソースは常に1秒ごとにサーバーにリクエストを送り続けます。そのため、更新がなくても無駄なリクエストが発生したり、リアルタイム性に欠けてしまいます。

WebSocketの通信モデル

WebSocketは双方向の永続的接続です。

クライアント ⇄ [WebSocket接続(常時接続)] ⇄ サーバー
    ↑                                           ↓
    データ送信 ←―――――――――――――――――→ データ送信

特徴

  • 一度接続すれば、接続が維持される
  • サーバーからクライアントへ自由にデータ送信可能(プッシュ通知)
  • クライアントからサーバーへも自由にデータ送信可能
  • 低レイテンシ(接続済みなので即座に送信)

API Gateway WebSocketの仕組み

AWS API Gateway WebSocketは、WebSocketサーバーを簡単に構築できるマネージドサービスです。
スクリーンショット 2025-11-16 12.24.25
AWS公式ドキュメントから引用

WebSocketの3つの特別なルート

API Gateway WebSocketには、以下の予約済みルートがあります。

ルート 呼び出しタイミング 用途
$connect クライアントが接続した時 認証、接続ID保存
$disconnect クライアントが切断した時 クリーンアップ、接続ID削除
$default ルートが見つからない時 デフォルト処理

さらに、カスタムルートを自由に追加できます(例:draw、clearなど)。

ルート選択の仕組み

クライアントから送信されるメッセージに含まれる特定のフィールド(通常はaction)で、どのルートを呼び出すかを決定します。
ルート選択式の例

// API Gatewayの設定
routeSelectionExpression = '$request.body.action'

// クライアントからのメッセージ
{
  "action": "draw",  // ← この値でルーティング
  "x": 100,
  "y": 200
}

ルーティングの流れ

  1. クライアントが{"action": "draw", ...}を送信
  2. API Gatewayがactionフィールドを確認
  3. drawルートが存在すれば、そのLambda関数を呼び出し
  4. ルートが存在しなければ、$defaultルートを呼び出し

接続の制限

API Gateway WebSocketには以下のような制限があります。

項目 制限値 説明
アイドルタイムアウト 10分 通信がない状態が10分続くと切断(ステータスコード: 1001)
最大接続時間 2時間 接続開始から2時間で自動切断(ステータスコード: 1001)
最大メッセージサイズ 128KB これを超えると切断(ステータスコード: 1009)
レート制限 可変 リクエストが多すぎると一時的にブロック(ステータスコード: 1008)

対策

  • アイドルタイムアウト対策:定期的にpingメッセージ送信
  • 最大接続時間対策:2時間前に再接続
  • メッセージサイズ対策:大きなデータは分割送信

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-websocket-api-overview.html#apigateway-websocket-status-codes

バックエンドからクライアントへの通知

API Gateway WebSocketでは、2つの方法でクライアントにデータを送信できます。

1. 統合レスポンス(同期)

return {
  statusCode: 200,
  body: JSON.stringify({ message: 'OK' })
};
// → このレスポンスは送信者にのみ返される

2. @connections API(非同期・プッシュ通知)

await apiGatewayClient.send(
  new PostToConnectionCommand({
    ConnectionId: targetConnectionId,  // 送信先を指定
    Data: JSON.stringify(data),
  })
);
// → 任意の接続にいつでもメッセージを送信可能

今回のアプリでは、描画データのブロードキャストに@connections APIを使用します。

今回のお絵描きアプリで WebSocket が最適な理由

  1. リアルタイム性が必須
  • 誰かが線を描いたら、他の全員に即座に反映する必要がある
  • REST APIのポーリングでは遅延が発生
  1. 頻繁なデータ送信
  • マウスを動かすたびにデータ送信(1秒間に数十回)
  • REST APIだと無駄なリクエストが多すぎる
  1. サーバー主導の通知
  • 他のユーザーの描画をサーバーからプッシュ通知する必要がある
  • REST APIではクライアントがポーリングする必要がある
  1. スケーラビリティ
  • 複数ユーザーが同時に描画しても問題ない
  • API Gateway WebSocketの自動スケーリングで対応

実際の通信フローを見てみる

ユーザーA: マウスを動かす
    ↓
ユーザーA: WebSocketでサーバーに送信(1ms)
    ↓
Lambda: メッセージを受信
    ↓
Lambda: DynamoDBから接続IDを取得
    ↓
Lambda: 全員にブロードキャスト(並列処理)
    ↓
ユーザーB, C, D: 即座に受信(数ms)
    ↓
ユーザーB, C, D: 画面に描画を反映

ということで、今回のお絵描きアプリのように、リアルタイム性が求められるアプリケーションでは、API Gateway WebSocketが最適な選択肢かと思います。

システムアーキテクチャ

今回構築するシステムの全体像は以下の通りです。
スクリーンショット 2025-11-16 16.01.35

  • API Gateway WebSocket API
    • クライアントとのWebSocket接続を管理
  • Lambda関数
    • Connect Handler: 接続時にDynamoDBに接続IDを保存
    • Disconnect Handler: 切断時にDynamoDBから接続IDを削除
    • Draw Handler: 描画データを受信し、全接続にブロードキャスト
    • Default Handler: 未定義のルートに対するエラーハンドリング
  • DynamoDB: 接続中のユーザーの接続IDを保存
  • S3 + CloudFront: Reactフロントエンドをホスティング
  • WebSocketライフサイクル
    • $connect: クライアント接続時
    • $disconnect: クライアント切断時
    • $default: ルートが見つからない時(エラーハンドリング)
    • カスタムルート(draw, clear): メッセージ受信時

やってみた

まずはCDKプロジェクトのセットアップから始めます。

realtime-drawing-app/
│
├── backend/                          # バックエンド(Go Lambda関数)
│   ├── connect/
│   │   └── main.go                   # $connect ハンドラー
│   ├── disconnect/
│   │   └── main.go                   # $disconnect ハンドラー
│   ├── draw/
│   │   └── main.go                   # draw/clear ハンドラー
│   ├── default/
│   │   └── main.go                   # $default ハンドラー
│   ├── go.mod                        # Go モジュール定義
│   └── go.sum                        # 依存関係のチェックサム
│
├── frontend/                         # フロントエンド(React)
│   ├── public/
│   │   ├── index.html
│   │   ├── favicon.ico
│   │   └── manifest.json
│   ├── src/
│   │   ├── App.tsx                   # メインコンポーネント
│   │   ├── App.css                   # スタイル
│   │   ├── index.tsx                 # エントリーポイント
│   │   ├── index.css
│   │   └── react-app-env.d.ts        # 型定義
│   ├── build/                        # ビルド出力(npm run build後)
│   │   ├── index.html
│   │   ├── static/
│   │   │   ├── js/
│   │   │   │   └── main.abc123.js
│   │   │   └── css/
│   │   │       └── main.def456.css
│   │   └── ...
│   ├── .env                          # 環境変数(WebSocket URL)
│   ├── .env.example                  # 環境変数のサンプル
│   ├── package.json
│   ├── package-lock.json
│   └── tsconfig.json
│
└── infrastructure/                   # インフラ(AWS CDK)
    ├── bin/
    │   └── realtime-drawing-app.ts   # CDKアプリのエントリーポイント
    ├── lib/
    │   └── realtime-drawing-stack.ts # CDKスタック定義
    ├── .vscode/
    │   └── settings.json             # VSCode設定(cSpell辞書)
    ├── cdk.out/                      # CDK合成出力(cdk synth後)
    │   ├── manifest.json
    │   ├── tree.json
    │   └── RealtimeDrawingStack.template.json
    ├── node_modules/                 # npm依存関係
    ├── package.json
    ├── package-lock.json
    ├── tsconfig.json
    └── cdk.json                      # CDK設定ファイル

CDKスタック実装

lib/realtime-drawing-stack.tsでインフラ全体を定義します。

lib/realtime-drawing-stack.ts
lib/realtime-drawing-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as go from '@aws-cdk/aws-lambda-go-alpha';
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as path from 'path';

/**
 * リアルタイム共同お絵描きアプリのCDKスタック
 */
export class RealtimeDrawingStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // ========================================
    // DynamoDB
    // ========================================
    const connectionsTable = new dynamodb.Table(this, 'ConnectionsTable', {
      partitionKey: {
        name: 'connectionId',
        type: dynamodb.AttributeType.STRING,
      },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, // 従量課金モード
      removalPolicy: cdk.RemovalPolicy.DESTROY, // スタック削除時にテーブルも削除
      timeToLiveAttribute: 'ttl', // 接続情報の自動削除用TTL属性
    });

    // Lambda関数用の共通環境変数
    const lambdaEnvironment = {
      CONNECTIONS_TABLE_NAME: connectionsTable.tableName,
    };

    // ========================================
    // Lambda関数(Go)
    // ========================================
    const connectHandler = new go.GoFunction(this, 'ConnectHandler', {
      entry: path.join(__dirname, '../../backend/connect'),
      runtime: cdk.aws_lambda.Runtime.PROVIDED_AL2023,
      architecture: cdk.aws_lambda.Architecture.ARM_64,
      timeout: cdk.Duration.seconds(10),
      environment: lambdaEnvironment,
    });

    const disconnectHandler = new go.GoFunction(this, 'DisconnectHandler', {
      entry: path.join(__dirname, '../../backend/disconnect'),
      runtime: cdk.aws_lambda.Runtime.PROVIDED_AL2023,
      architecture: cdk.aws_lambda.Architecture.ARM_64,
      timeout: cdk.Duration.seconds(10),
      environment: lambdaEnvironment,
    });

    const drawHandler = new go.GoFunction(this, 'DrawHandler', {
      entry: path.join(__dirname, '../../backend/draw'),
      runtime: cdk.aws_lambda.Runtime.PROVIDED_AL2023,
      architecture: cdk.aws_lambda.Architecture.ARM_64,
      timeout: cdk.Duration.seconds(10),
      environment: lambdaEnvironment,
    });

    const defaultHandler = new go.GoFunction(this, 'DefaultHandler', {
      entry: path.join(__dirname, '../../backend/default'),
      runtime: cdk.aws_lambda.Runtime.PROVIDED_AL2023,
      architecture: cdk.aws_lambda.Architecture.ARM_64,
      timeout: cdk.Duration.seconds(10),
      environment: lambdaEnvironment,
    });

    // DynamoDBへのアクセス権限
    connectionsTable.grantReadWriteData(connectHandler);
    connectionsTable.grantReadWriteData(disconnectHandler);
    connectionsTable.grantReadWriteData(drawHandler);

    // ========================================
    // API Gateway WebSocket API
    // ========================================

    /**
     * WebSocket API - クライアントとの双方向通信を実現
     */
    const webSocketApi = new apigatewayv2.WebSocketApi(this, 'DrawingWebSocketApi', {
      apiName: 'DrawingWebSocketApi',
      // $connectルート: 接続確立時
      connectRouteOptions: {
        integration: new WebSocketLambdaIntegration('ConnectIntegration', connectHandler),
      },
      // $disconnectルート: 切断時
      disconnectRouteOptions: {
        integration: new WebSocketLambdaIntegration('DisconnectIntegration', disconnectHandler),
      },
      // $defaultルート: ルートが見つからない時
      defaultRouteOptions: {
        integration: new WebSocketLambdaIntegration('DefaultIntegration', defaultHandler),
      },
    });

    // カスタムルートの追加
    webSocketApi.addRoute('draw', {
      integration: new WebSocketLambdaIntegration('DrawIntegration', drawHandler),
    });

    webSocketApi.addRoute('clear', {
      integration: new WebSocketLambdaIntegration('ClearIntegration', drawHandler),
    });

    /**
     * WebSocket Stage
     * stageName: 'v1' - APIバージョン管理
     * autoDeploy: true - コード変更時に自動デプロイ
     */
    const webSocketStage = new apigatewayv2.WebSocketStage(this, 'WebSocketStage', {
      webSocketApi,
      stageName: 'v1',
      autoDeploy: true,
    });

    /**
     * WebSocket APIへの接続権限を付与
     * Lambda関数が@connections APIを使用するために必要
     */
    const webSocketPolicy = new iam.PolicyStatement({
      actions: ['execute-api:ManageConnections'],
      resources: [
        `arn:aws:execute-api:${this.region}:${this.account}:${webSocketApi.apiId}/${webSocketStage.stageName}/*`,
      ],
    });

    connectHandler.addToRolePolicy(webSocketPolicy);
    disconnectHandler.addToRolePolicy(webSocketPolicy);
    drawHandler.addToRolePolicy(webSocketPolicy);

    // WebSocketエンドポイントをLambda環境変数に設定
    const webSocketEndpoint = `${webSocketApi.apiId}.execute-api.${this.region}.amazonaws.com/${webSocketStage.stageName}`;
    connectHandler.addEnvironment('WEBSOCKET_API_ENDPOINT', webSocketEndpoint);
    disconnectHandler.addEnvironment('WEBSOCKET_API_ENDPOINT', webSocketEndpoint);
    drawHandler.addEnvironment('WEBSOCKET_API_ENDPOINT', webSocketEndpoint);

    // ========================================
    // フロントエンド: S3 + CloudFront
    // ========================================

    // S3バケット(非公開)
    const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    });

    // CloudFront Distribution
    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      defaultBehavior: {
        // S3BucketOrigin.withOriginAccessControl で以下が自動設定される:
        // - OAC(Origin Access Control)の作成
        // - S3バケットポリシーの追加
        // - CloudFrontとS3の紐付け
        origin: origins.S3BucketOrigin.withOriginAccessControl(websiteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
        compress: true, // Gzip圧縮を有効化
      },
      defaultRootObject: 'index.html',
      // SPA対応:すべてのエラーをindex.htmlにリダイレクト
      errorResponses: [
        {
          httpStatus: 403,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: cdk.Duration.minutes(5),
        },
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: cdk.Duration.minutes(5),
        },
      ],
    });

    // フロントエンドのデプロイ
    new s3deploy.BucketDeployment(this, 'DeployWebsite', {
      sources: [s3deploy.Source.asset(path.join(__dirname, '../../frontend/build'))],
      destinationBucket: websiteBucket,
      distribution,
      distributionPaths: ['/*'], // デプロイ時にキャッシュをクリア
    });

    // CloudFormation Outputs
    new cdk.CfnOutput(this, 'WebSocketURL', {
      value: webSocketStage.url,
      description: 'WebSocket API URL',
    });

    new cdk.CfnOutput(this, 'WebsiteURL', {
      value: `https://${distribution.distributionDomainName}`,
      description: 'CloudFront Website URL',
    });
  }
}

API Gateway WebSocket のポイント

WebSocket API の作成

const webSocketApi = new apigatewayv2.WebSocketApi(this, 'DrawingWebSocketApi', {
  apiName: 'DrawingWebSocketApi',
  connectRouteOptions: {
    integration: new WebSocketLambdaIntegration('ConnectIntegration', connectHandler),
  },
  disconnectRouteOptions: {
    integration: new WebSocketLambdaIntegration('DisconnectIntegration', disconnectHandler),
  },
  defaultRouteOptions: {
    integration: new WebSocketLambdaIntegration('DefaultIntegration', defaultHandler),
  },
});

ポイント

  • 予約ルート: connect、disconnect、$defaultを定義
  • Lambda統合: 各ルートにLambda関数を紐付け
  • 自動ルーティング: クライアントからのメッセージのactionフィールドで自動振り分け

カスタムルートの追加

webSocketApi.addRoute('draw', {
  integration: new WebSocketLambdaIntegration('DrawIntegration', drawHandler),
});

webSocketApi.addRoute('clear', {
  integration: new WebSocketLambdaIntegration('ClearIntegration', drawHandler),
});

ポイント

  • カスタムルート: drawとclearを追加
  • 同じLambda関数: 両方ともdrawHandlerを使用(内部で処理を分岐)
  • メッセージ例: {"action": "draw", ...} → drawルートが呼ばれる

WebSocket Stage の作成

const webSocketStage = new apigatewayv2.WebSocketStage(this, 'WebSocketStage', {
  webSocketApi,
  stageName: 'v1',
  autoDeploy: true,
});

ポイント

  • stageName: APIバージョン管理(v1)
  • autoDeploy: コード変更時に自動デプロイ

@connections API の権限付与

const webSocketPolicy = new iam.PolicyStatement({
  actions: ['execute-api:ManageConnections'],
  resources: [
    `arn:aws:execute-api:${this.region}:${this.account}:${webSocketApi.apiId}/${webSocketStage.stageName}/*`,
  ],
});

drawHandler.addToRolePolicy(webSocketPolicy);

ポイント

  • ManageConnections: Lambda関数がクライアントにメッセージを送信するために必要
  • @connections API: PostToConnectionを使用してブロードキャスト
  • 必須権限: これがないとリアルタイム同期ができない

WebSocket Endpoint の環境変数設定

const webSocketEndpoint = `${webSocketApi.apiId}.execute-api.${this.region}.amazonaws.com/${webSocketStage.stageName}`;
drawHandler.addEnvironment('WEBSOCKET_API_ENDPOINT', webSocketEndpoint);

ポイント

  • Lambda環境変数: WEBSOCKET_API_ENDPOINTとして設定
  • ブロードキャスト用: @connections APIのエンドポイント

バックエンド Lambda 関数実装

バックエンドの準備をします。今回は Go を使用します。

# backendディレクトリに移動
cd backend

# Go モジュールの初期化
go mod init backend

# 依存関係を整理
go mod tidy

Connect Handler 実装

backend/connect/main.go
package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

var (
	dynamoClient *dynamodb.Client
	tableName    string
)

func init() {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic(fmt.Sprintf("unable to load SDK config: %v", err))
	}
	dynamoClient = dynamodb.NewFromConfig(cfg)
	tableName = os.Getenv("CONNECTIONS_TABLE_NAME")
}

func handler(ctx context.Context, request events.APIGatewayWebsocketProxyRequest) (events.APIGatewayProxyResponse, error) {
	connectionID := request.RequestContext.ConnectionID
	timestamp := time.Now().Unix()
	ttl := timestamp + 86400 // 24時間後

	fmt.Printf("Connect event: connectionId=%s\n", connectionID)

	// DynamoDBに接続情報を保存
	_, err := dynamoClient.PutItem(ctx, &dynamodb.PutItemInput{
		TableName: &tableName,
		Item: map[string]types.AttributeValue{
			"connectionId": &types.AttributeValueMemberS{Value: connectionID},
			"timestamp":    &types.AttributeValueMemberN{Value: fmt.Sprintf("%d", timestamp)},
			"ttl":          &types.AttributeValueMemberN{Value: fmt.Sprintf("%d", ttl)},
		},
	})

	if err != nil {
		fmt.Printf("Error saving connection: %v\n", err)
		return events.APIGatewayProxyResponse{
			StatusCode: 500,
			Body:       fmt.Sprintf("Failed to connect: %v", err),
		}, nil
	}

	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Body:       "Connected",
	}, nil
}

func main() {
	lambda.Start(handler)
}

Disconnect Handler 実装

backend/disconnect/main.go
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

var (
	dynamoClient *dynamodb.Client
	tableName    string
)

func init() {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic(fmt.Sprintf("unable to load SDK config: %v", err))
	}
	dynamoClient = dynamodb.NewFromConfig(cfg)
	tableName = os.Getenv("CONNECTIONS_TABLE_NAME")
}

func handler(ctx context.Context, request events.APIGatewayWebsocketProxyRequest) (events.APIGatewayProxyResponse, error) {
	connectionID := request.RequestContext.ConnectionID

	fmt.Printf("Disconnect event: connectionId=%s\n", connectionID)

	// DynamoDBから接続情報を削除
	_, err := dynamoClient.DeleteItem(ctx, &dynamodb.DeleteItemInput{
		TableName: &tableName,
		Key: map[string]types.AttributeValue{
			"connectionId": &types.AttributeValueMemberS{Value: connectionID},
		},
	})

	if err != nil {
		fmt.Printf("Error deleting connection: %v\n", err)
		return events.APIGatewayProxyResponse{
			StatusCode: 500,
			Body:       fmt.Sprintf("Failed to disconnect: %v", err),
		}, nil
	}

	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Body:       "Disconnected",
	}, nil
}

func main() {
	lambda.Start(handler)
}

Default Handler 実装

backend/default/main.go
package main

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

type ErrorResponse struct {
	Error            string   `json:"error"`
	Message          string   `json:"message"`
	SupportedActions []string `json:"supportedActions"`
}

func handler(ctx context.Context, request events.APIGatewayWebsocketProxyRequest) (events.APIGatewayProxyResponse, error) {
	fmt.Printf("Default route triggered: %s\n", request.Body)

	var body map[string]interface{}
	json.Unmarshal([]byte(request.Body), &body)

	action := ""
	if val, ok := body["action"].(string); ok {
		action = val
	}

	response := ErrorResponse{
		Error:            "Unknown action",
		Message:          fmt.Sprintf("Action '%s' is not supported", action),
		SupportedActions: []string{"draw", "clear"},
	}

	responseBody, _ := json.Marshal(response)

	return events.APIGatewayProxyResponse{
		StatusCode: 400,
		Body:       string(responseBody),
	}, nil
}

func main() {
	lambda.Start(handler)
}

Draw Handler 実装

backend/draw/main.go
package main

import (
	"context"
	"fmt"
	"os"
	"strings"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
	"github.com/aws/aws-sdk-go-v2/service/apigatewaymanagementapi"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

var (
	dynamoClient *dynamodb.Client
	apiGwClient  *apigatewaymanagementapi.Client
	tableName    string
)

type Connection struct {
	ConnectionID string `dynamodbav:"connectionId"`
}

func init() {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic(fmt.Sprintf("unable to load SDK config: %v", err))
	}
	dynamoClient = dynamodb.NewFromConfig(cfg)
	tableName = os.Getenv("CONNECTIONS_TABLE_NAME")
	endpoint := os.Getenv("WEBSOCKET_API_ENDPOINT")

	// https:// を明示的に追加
	fullEndpoint := "https://" + endpoint
	fmt.Printf("WebSocket endpoint: %s\n", fullEndpoint)

	apiGwClient = apigatewaymanagementapi.NewFromConfig(cfg, func(o *apigatewaymanagementapi.Options) {
		o.BaseEndpoint = &fullEndpoint
	})
}

func handler(ctx context.Context, request events.APIGatewayWebsocketProxyRequest) (events.APIGatewayProxyResponse, error) {
	connectionID := request.RequestContext.ConnectionID

	fmt.Printf("Draw event: connectionId=%s, body=%s\n", connectionID, request.Body)

	// すべての接続を取得
	result, err := dynamoClient.Scan(ctx, &dynamodb.ScanInput{
		TableName: &tableName,
	})
	if err != nil {
		fmt.Printf("Error scanning connections: %v\n", err)
		return events.APIGatewayProxyResponse{
			StatusCode: 500,
			Body:       fmt.Sprintf("Failed to get connections: %v", err),
		}, nil
	}

	var connections []Connection
	err = attributevalue.UnmarshalListOfMaps(result.Items, &connections)
	if err != nil {
		fmt.Printf("Error unmarshaling connections: %v\n", err)
		return events.APIGatewayProxyResponse{
			StatusCode: 500,
			Body:       fmt.Sprintf("Failed to parse connections: %v", err),
		}, nil
	}

	fmt.Printf("Broadcasting to %d connections\n", len(connections))

	messageData := []byte(request.Body)

	for _, conn := range connections {
		// 自分自身には送信しない
		if conn.ConnectionID == connectionID {
			continue
		}

		_, err := apiGwClient.PostToConnection(ctx, &apigatewaymanagementapi.PostToConnectionInput{
			ConnectionId: &conn.ConnectionID,
			Data:         messageData,
		})

		if err != nil {
			fmt.Printf("Failed to send to %s: %v\n", conn.ConnectionID, err)

			// 410 Gone エラーの場合、接続を削除
			if isGoneError(err) {
				fmt.Printf("Removing stale connection: %s\n", conn.ConnectionID)
				dynamoClient.DeleteItem(ctx, &dynamodb.DeleteItemInput{
					TableName: &tableName,
					Key: map[string]types.AttributeValue{
						"connectionId": &types.AttributeValueMemberS{Value: conn.ConnectionID},
					},
				})
			}
		}
	}

	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Body:       "Message sent",
	}, nil
}

func isGoneError(err error) bool {
	if err == nil {
		return false
	}
	errStr := err.Error()
	return strings.Contains(errStr, "GoneException") || strings.Contains(errStr, "410")
}

func main() {
	lambda.Start(handler)
}

React フロントエンド実装

次にフロントエンドのReactアプリケーションを実装します。

frontend/src/App.tsx
import React, { useEffect, useRef, useState, useCallback } from 'react';
import './App.css';

const WS_URL = process.env.REACT_APP_WS_URL || '';

interface Point {
  x: number;
  y: number;
}

interface DrawData {
  action: 'draw';
  startX: number;
  startY: number;
  endX: number;
  endY: number;
  color: string;
  lineWidth: number;
}

interface ClearData {
  action: 'clear';
}

type Message = DrawData | ClearData;

const COLORS = [
  { name: '黒', value: '#000000' },
  { name: '赤', value: '#FF0000' },
  { name: '青', value: '#0000FF' },
  { name: '緑', value: '#00FF00' },
  { name: '黄', value: '#FFFF00' },
  { name: 'オレンジ', value: '#FFA500' },
  { name: '紫', value: '#800080' },
  { name: 'ピンク', value: '#FF69B4' },
];

const LINE_WIDTHS = [
  { name: '細', value: 2 },
  { name: '普通', value: 5 },
  { name: '太', value: 10 },
  { name: '極太', value: 20 },
];

function App() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const wsRef = useRef<WebSocket | null>(null);
  const [isDrawing, setIsDrawing] = useState(false);
  const [lastPoint, setLastPoint] = useState<Point | null>(null);
  const [selectedColor, setSelectedColor] = useState('#000000');
  const [selectedLineWidth, setSelectedLineWidth] = useState(5);
  const [isConnected, setIsConnected] = useState(false);

  // Canvasに線を描画
  const drawLine = useCallback(
    (
      startX: number,
      startY: number,
      endX: number,
      endY: number,
      color: string,
      lineWidth: number
    ) => {
      const canvas = canvasRef.current;
      if (!canvas) return;

      const ctx = canvas.getContext('2d');
      if (!ctx) return;

      ctx.strokeStyle = color;
      ctx.lineWidth = lineWidth;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';

      ctx.beginPath();
      ctx.moveTo(startX, startY);
      ctx.lineTo(endX, endY);
      ctx.stroke();
    },
    []
  );

  // Canvas全消去
  const clearCanvas = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    ctx.clearRect(0, 0, canvas.width, canvas.height);
  }, []);

  // WebSocket接続
  useEffect(() => {
    if (!WS_URL) {
      console.error('REACT_APP_WS_URL is not set');
      return;
    }

    console.log('Connecting to:', WS_URL);
    const ws = new WebSocket(WS_URL);

    ws.onopen = () => {
      console.log('WebSocket接続成功');
      setIsConnected(true);
    };

    ws.onclose = () => {
      console.log('WebSocket切断');
      setIsConnected(false);
    };

    ws.onerror = (error) => {
      console.error('WebSocketエラー:', error);
    };

    ws.onmessage = (event) => {
      const message: Message = JSON.parse(event.data);

      if (message.action === 'draw') {
        drawLine(
          message.startX,
          message.startY,
          message.endX,
          message.endY,
          message.color,
          message.lineWidth
        );
      } else if (message.action === 'clear') {
        clearCanvas();
      }
    };

    wsRef.current = ws;

    return () => {
      ws.close();
    };
  }, [drawLine, clearCanvas]);

  // マウスダウン
  const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;

    setIsDrawing(true);
    setLastPoint({
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    });
  };

  // マウスムーブ
  const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
    if (!isDrawing || !lastPoint) return;

    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;

    const currentPoint = {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    };

    // ローカルに描画
    drawLine(
      lastPoint.x,
      lastPoint.y,
      currentPoint.x,
      currentPoint.y,
      selectedColor,
      selectedLineWidth
    );

    // WebSocketで送信
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      const message: DrawData = {
        action: 'draw',
        startX: lastPoint.x,
        startY: lastPoint.y,
        endX: currentPoint.x,
        endY: currentPoint.y,
        color: selectedColor,
        lineWidth: selectedLineWidth,
      };
      wsRef.current.send(JSON.stringify(message));
    }

    setLastPoint(currentPoint);
  };

  // マウスアップ
  const handleMouseUp = () => {
    setIsDrawing(false);
    setLastPoint(null);
  };

  // マウスリーブ
  const handleMouseLeave = () => {
    setIsDrawing(false);
    setLastPoint(null);
  };

  // 全消去
  const handleClear = () => {
    clearCanvas();

    if (wsRef.current?.readyState === WebSocket.OPEN) {
      const message: ClearData = {
        action: 'clear',
      };
      wsRef.current.send(JSON.stringify(message));
    }
  };

  return (
    <div className="App">
      <header className="header">
        <h1>🎨 リアルタイム共同お絵描きアプリ</h1>
        <div className="connection-status">
          {isConnected ? (
            <span className="status-connected">● 接続中</span>
          ) : (
            <span className="status-disconnected">● 切断</span>
          )}
        </div>
      </header>

      <div className="toolbar">
        <div className="tool-group">
          <label>:</label>
          <div className="color-picker">
            {COLORS.map((color) => (
              <button
                key={color.value}
                className={`color-button ${selectedColor === color.value ? 'selected' : ''}`}
                style={{ backgroundColor: color.value }}
                onClick={() => setSelectedColor(color.value)}
                title={color.name}
              />
            ))}
          </div>
        </div>

        <div className="tool-group">
          <label>太さ:</label>
          <div className="width-picker">
            {LINE_WIDTHS.map((width) => (
              <button
                key={width.value}
                className={`width-button ${selectedLineWidth === width.value ? 'selected' : ''}`}
                onClick={() => setSelectedLineWidth(width.value)}
              >
                {width.name}
              </button>
            ))}
          </div>
        </div>

        <div className="tool-group">
          <button className="clear-button" onClick={handleClear}>
            🗑️ 全消去
          </button>
        </div>
      </div>

      <div className="canvas-container">
        <canvas
          ref={canvasRef}
          width={1200}
          height={700}
          className="canvas"
          onMouseDown={handleMouseDown}
          onMouseMove={handleMouseMove}
          onMouseUp={handleMouseUp}
          onMouseLeave={handleMouseLeave}
        />
      </div>

      <footer className="footer">
        <p>友達と一緒に絵を描こう! 🖌️</p>
      </footer>
    </div>
  );
}

export default App;

CSSの実装です。

frontend/src/App.css
/* リセット */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
}

/* メインコンテナ */
.App {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20px;
  min-height: 100vh;
}

/* ヘッダー */
.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  max-width: 1200px;
  margin-bottom: 20px;
  padding: 20px;
  background: white;
  border-radius: 10px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.header h1 {
  font-size: 28px;
  color: #333;
  margin: 0;
}

.connection-status {
  font-size: 16px;
  font-weight: bold;
}

.status-connected {
  color: #10b981;
}

.status-disconnected {
  color: #ef4444;
}

/* ツールバー */
.toolbar {
  display: flex;
  gap: 30px;
  align-items: center;
  width: 100%;
  max-width: 1200px;
  margin-bottom: 20px;
  padding: 20px;
  background: white;
  border-radius: 10px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  flex-wrap: wrap;
}

.tool-group {
  display: flex;
  align-items: center;
  gap: 10px;
}

.tool-group label {
  font-weight: bold;
  color: #555;
  font-size: 16px;
}

/* 色選択 */
.color-picker {
  display: flex;
  gap: 8px;
}

.color-button {
  width: 40px;
  height: 40px;
  border: 3px solid transparent;
  border-radius: 50%;
  cursor: pointer;
  transition: all 0.2s;
}

.color-button:hover {
  transform: scale(1.1);
}

.color-button.selected {
  border-color: #333;
  box-shadow: 0 0 0 2px white, 0 0 0 4px #333;
}

/* 線の太さ選択 */
.width-picker {
  display: flex;
  gap: 8px;
}

.width-button {
  padding: 8px 16px;
  border: 2px solid #ddd;
  border-radius: 5px;
  background: white;
  cursor: pointer;
  font-size: 14px;
  font-weight: bold;
  transition: all 0.2s;
}

.width-button:hover {
  background: #f3f4f6;
  border-color: #667eea;
}

.width-button.selected {
  background: #667eea;
  color: white;
  border-color: #667eea;
}

/* 全消去ボタン */
.clear-button {
  padding: 10px 20px;
  background: #ef4444;
  color: white;
  border: none;
  border-radius: 5px;
  font-size: 16px;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.2s;
}

.clear-button:hover {
  background: #dc2626;
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
}

.clear-button:active {
  transform: translateY(0);
}

/* キャンバスコンテナ */
.canvas-container {
  background: white;
  border-radius: 10px;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  padding: 10px;
  margin-bottom: 20px;
}

/* キャンバス */
.canvas {
  display: block;
  border: 2px solid #e5e7eb;
  border-radius: 5px;
  cursor: crosshair;
  touch-action: none;
  /* タッチデバイスでのスクロール防止 */
}

/* フッター */
.footer {
  color: white;
  font-size: 18px;
  text-align: center;
  margin-top: 20px;
}

.footer p {
  text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
  margin: 0;
}

/* レスポンシブ対応 */
@media (max-width: 1280px) {
  .canvas {
    width: 100%;
    height: auto;
  }

  .header h1 {
    font-size: 20px;
  }

  .toolbar {
    flex-direction: column;
    align-items: flex-start;
  }
}

@media (max-width: 768px) {
  .App {
    padding: 10px;
  }

  .header {
    flex-direction: column;
    gap: 10px;
  }

  .header h1 {
    font-size: 18px;
  }

  .color-button {
    width: 35px;
    height: 35px;
  }

  .width-button {
    padding: 6px 12px;
    font-size: 12px;
  }
}

実装のポイント

WebSocket接続管理

  • useEffectで接続を確立
  • onmessageで他ユーザーの描画を受信
  • コンポーネントアンマウント時に切断

描画処理

  • Canvas APIで線を描画
  • lineCapとlineJoinをroundに設定して滑らかな線を実現
  • マウスの軌跡を追跡して連続した線を描画

リアルタイム同期

  • 自分が描いたらすぐにWebSocketで送信
  • 他ユーザーからのデータを受信したらCanvasに反映
  • 自分には送信しないようLambda側で制御

デプロイ

まずはインフラをデプロイします。

# インフラディレクトリに移動
cd infrastructure

# デプロイ
npx cdk deploy --require-approval never

インフラのデプロイが完了したら、フロントエンドの準備をします。

# frontend ディレクトリに移動
cd ../frontend

# 先ほど作成した API Gateway WebSocket URLを環境変数に設定
echo "REACT_APP_WS_URL=wss://XXXXXX.execute-api.ap-northeast-1.amazonaws.com/v1" > .env

# 依存関係のインストール
npm install

# ビルド
npm run build

フロントエンドの準備が完了したら再度インフラをデプロイします。

# インフラディレクトリに移動
cd infrastructure

# デプロイ
npx cdk deploy --require-approval never

これでデプロイ作業は完了です。CloudFrontのURLにアクセスすれば、リアルタイムお絵描きアプリが利用できます!

動作確認

アプリケーションへアクセスして、実際にアプリを動かしてみましょう。

CloudFront の URL にアクセス

デプロイ完了後、出力された CloudFront URL にアクセスします。

https://XXXXXXXXXXX.cloudfront.net

以下のような画面が表示されれば成功です。
スクリーンショット 2025-11-16 16.15.13

UIはイケてないですね...

WebSocket接続の確認

ブラウザの開発者ツール(F12)→ Consoleタブで、以下のログが表示されることを確認します。
スクリーンショット 2025-11-16 16.20.45

リアルタイム同期のテスト

複数のブラウザでアプリを開いて、リアルタイム同期を確認します。

  • 手順
    • ブラウザAでアプリを開く
    • ブラウザBで同じURLを開く
    • ブラウザAで線を描く
    • ブラウザBに即座に反映される!

https://youtu.be/OUPiWNaI0dw

DynamoDBの確認

AWSコンソール → DynamoDB → Tables → ConnectionsTable で、接続中のユーザーが記録されていることを確認します。

まず、1タブでお絵描きアプリを開いてから、DynamoDBテーブルを確認します。
スクリーンショット 2025-11-16 16.35.41

1つのIDが登録されていますね。次にもう1つタブを開いて確認してみます。
スクリーンショット 2025-11-16 16.43.32
さらにもう1つのIDが登録されました。

おわりに

今回は、API Gateway WebSocketを使ってリアルタイム共同お絵描きアプリを構築しました。

WebSocketは初めての挑戦でしたが、REST APIとは異なるリアルタイム通信の仕組みを実際に手を動かして学ぶことができました。

実際にリアルタイムで描画が反映されたときは感動してしまいました!

この記事が、WebSocketやリアルタイム通信に興味を持つ方の参考になれば幸いです。
最後まで読んでいただき、ありがとうございました!

参考リンク

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-overview-developer-experience.html#api-gateway-overview-websocket
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_apigatewayv2.WebSocketApi.html
https://developer.mozilla.org/ja/docs/Web/API/Canvas_API/Tutorial

この記事をシェアする

FacebookHatena blogX

関連記事