💍

結婚式で何も実装しないエンジニア、いる?〜フォトコンテストアプリを自作した話〜

に公開

はじめに

「結婚式だし、せっかくだから何か作るか」

そう思ったエンジニア、挙手

結婚式でゲストが撮影した写真を簡単に共有できるフォトコンテストアプリを、前日深夜にAWS AmplifyとNext.js 15、S3を使って構築しました。本記事では、実装のポイントとなる署名付きURLを使った直接S3アップロードの仕組みを中心に、実際のコードを交えて解説します。

ゲストの人数は50人程度と、小規模アプリケーションになる想定でした。
「結婚式当日にバグったらどうするんだ!」という正論は聞こえなかったことにします。
それでも、Neonの無料プランを使用したりと、可能な限り費用を抑えつつ実装したかったため、色々と工夫してみました!

アーキテクチャ概要

まずは構成から。
以下のような構成で作りました。
アーキテクチャ

ちょっと見づらかったので、簡単な概要も以下に載せます。

┌─────────────┐
│   ユーザー   │
└──────┬──────┘
       │
       ├─── ① Webアプリアクセス
       ├─── ② 署名付きURL要求 (/api/upload)
       ├─── ③ S3へ直接アップロード
       └─── ④ メタデータ保存 (/api/photos)
       │
┌──────▼──────────────────────────────┐
│     AWS Amplify (Next.js 15)        │
│  ┌──────────────────────────────┐  │
│  │  API Routes                  │  │
│  │  - /api/upload               │  │
│  │  - /api/photos               │  │
│  └──────────────────────────────┘  │
└──────┬────────────┬─────────────────┘
       │            │
       │            ├─────────────────┐
       ▼            ▼                 ▼
┌──────────┐  ┌──────────┐  ┌──────────────┐
│ S3 Bucket│  │   Neon   │  │ CloudFront   │
│  (写真)  │  │PostgreSQL│  │   (CDN)      │
└──────────┘  └──────────┘  └──────────────┘

技術スタック

  • フロントエンド: Next.js 15 (App Router), React 19, Tailwind CSS 4
  • ホスティング: AWS Amplify
  • ストレージ: Amazon S3 + CloudFront
  • データベース: Neon (Serverless PostgreSQL)
  • ORM: Prisma 6.15

実装のポイント

1. S3署名付きURLの生成

サーバーサイドでS3への署名付きURLを生成します。これにより、クライアントから直接S3にアップロードできます。

src/lib/s3.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

// S3クライアントの初期化
const s3Client = new S3Client({
  region: process.env.S3_REGION!,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
  },
});

// 署名付きURL生成関数
export async function generatePresignedUrl(
  filename: string,
  contentType: string
) {
  // ユニークなキーを生成(タイムスタンプ + ファイル名)
  const key = `photos/${Date.now()}-${filename}`;

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: key,
    ContentType: contentType,
  });

  // 署名付きURLを生成(有効期限: 1時間)
  const signedUrl = await getSignedUrl(s3Client, command, {
    expiresIn: 3600,
  });

  return {
    signedUrl,
    key,
  };
}

// CloudFront経由のURL生成
export function getCloudFrontUrl(key: string): string {
  return `https://${process.env.CLOUDFRONT_DOMAIN}/${key}`;
}

ポイント:

  • @aws-sdk/client-s3の最新版を使用
  • タイムスタンプをキーに含めることで重複を防ぐ
  • 署名付きURLの有効期限は1時間に設定

2. アップロード用API Route

署名付きURLを返すAPIエンドポイントを実装します。

src/app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { generatePresignedUrl } from '@/lib/s3';

export async function POST(request: NextRequest) {
  try {
    const { filename, contentType } = await request.json();

    // バリデーション
    if (!filename || !contentType) {
      return NextResponse.json(
        { error: 'Filename and content type are required' },
        { status: 400 }
      );
    }

    // 署名付きURLを生成
    const { signedUrl, key } = await generatePresignedUrl(
      filename,
      contentType
    );

    return NextResponse.json({
      signedUrl,
      key,
    });
  } catch (error) {
    console.error('Error generating presigned URL:', error);
    return NextResponse.json(
      { error: 'Failed to generate upload URL' },
      { status: 500 }
    );
  }
}

3. メタデータ保存用API Route

写真のメタデータをNeon PostgreSQLに保存します。

src/app/api/photos/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCloudFrontUrl } from '@/lib/s3';

export async function POST(request: NextRequest) {
  try {
    const { filename, originalName, s3Key, comment, nickname } =
      await request.json();

    // バリデーション
    if (!filename || !originalName || !s3Key || !nickname?.trim()) {
      return NextResponse.json(
        { error: 'Required fields are missing' },
        { status: 400 }
      );
    }

    // CloudFront URLを生成
    const cloudFrontUrl = getCloudFrontUrl(s3Key);

    // Prismaでデータベースに保存
    const photo = await prisma.photo.create({
      data: {
        filename,
        originalName,
        s3Key,
        cloudFrontUrl,
        comment: comment || null,
        nickname: nickname.trim(),
      },
    });

    return NextResponse.json(photo);
  } catch (error) {
    console.error('Error saving photo:', error);
    return NextResponse.json(
      { error: 'Failed to save photo' },
      { status: 500 }
    );
  }
}

export async function GET() {
  try {
    // 写真一覧を新しい順で取得
    const photos = await prisma.photo.findMany({
      orderBy: { uploadedAt: 'desc' },
    });

    return NextResponse.json(photos);
  } catch (error) {
    console.error('Error fetching photos:', error);
    return NextResponse.json([]);
  }
}

4. クライアントサイドのアップロード処理

フロントエンドから3ステップでアップロードを実行します。

src/app/upload/page.tsx
const handleUpload = async () => {
  if (!selectedFile || !nickname.trim()) return;

  setUploading(true);
  try {
    // Step 1: 署名付きURLを取得
    const presignedUrlResponse = await fetch('/api/upload', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        filename: selectedFile.name,
        contentType: selectedFile.type,
      }),
    });

    const { signedUrl, key } = await presignedUrlResponse.json();

    // Step 2: S3に直接アップロード
    const uploadResponse = await fetch(signedUrl, {
      method: 'PUT',
      body: selectedFile,
      headers: { 'Content-Type': selectedFile.type },
    });

    if (!uploadResponse.ok) {
      throw new Error('アップロードに失敗しました');
    }

    // Step 3: メタデータをデータベースに保存
    await fetch('/api/photos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        filename: selectedFile.name,
        originalName: selectedFile.name,
        s3Key: key,
        comment: comment.trim() || null,
        nickname: nickname.trim(),
      }),
    });

    alert('アップロード完了!');
    router.push('/gallery');
  } catch (error) {
    console.error('Upload error:', error);
    alert('アップロードに失敗しました');
  } finally {
    setUploading(false);
  }
};

アップロードフロー:

  1. サーバーから署名付きURLとS3キーを取得
  2. 取得した署名付きURLを使ってS3に直接PUT
  3. S3キーとメタデータをデータベースに保存

5. Neon PostgreSQLとのコネクションプーリング

結婚式では50人以上が同時アクセスする可能性があるため、Neonのコネクションプーリングを使用します。
これが今回工夫した点かなと思います。冒頭にも伝えたようにNeonの無料枠を使ったWebアプリケーションだったため、同時アクセスの負荷を考慮しつつ、実装してみました。

src/lib/db.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

// Neonのコネクションプーリングを使用
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
  log: process.env.NODE_ENV === 'development'
    ? ['query', 'error', 'warn']
    : ['error'],
  datasources: {
    db: {
      url: process.env.DATABASE_URL,
    },
  },
});

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

環境変数(.env.local):

# Neon Pooled Connection String
DATABASE_URL="postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/db?sslmode=require&connection_limit=5&pool_timeout=20"

# S3設定
S3_REGION="ap-northeast-1"
S3_BUCKET_NAME="your-bucket-name"
S3_ACCESS_KEY_ID="your-access-key"
S3_SECRET_ACCESS_KEY="your-secret-key"
CLOUDFRONT_DOMAIN="xxx.cloudfront.net"

重要なパラメータ:

  • connection_limit=5: 各Amplifyインスタンスの最大コネクション数
  • pool_timeout=20: コネクション取得のタイムアウト(秒)
  • NeonダッシュボードでPooled connection stringを取得すること

6. Prismaスキーマ定義

フォトコンテストの要件に合わせたスキーマ設計です。

prisma/schema.prisma
generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "rhel-openssl-1.0.x"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Photo {
  id            String   @id @default(cuid())
  filename      String
  originalName  String
  s3Key         String   @unique
  cloudFrontUrl String
  nickname      String
  comment       String?
  uploadedAt    DateTime @default(now())
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  @@map("photos")
}

nicknamecommentはベストショット撮影者を発表するときに使用したのと、盛り上ることを間違いなしと考えたため、定義しました。

AWS Amplify デプロイ設定

環境変数の設定

AWS Amplify Consoleで以下の環境変数を設定します:

  1. DATABASE_URL: NeonのPooled connection string
  2. S3_REGION: S3バケットのリージョン
  3. S3_BUCKET_NAME: S3バケット名
  4. S3_ACCESS_KEY_ID: IAMアクセスキー
  5. S3_SECRET_ACCESS_KEY: IAMシークレットキー
  6. CLOUDFRONT_DOMAIN: CloudFrontドメイン

IAMポリシー設定

AmplifyからS3にアクセスするためのIAMポリシー:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject"
      ],
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    }
  ]
}

パフォーマンス最適化

1. 直接S3アップロードの利点

  • サーバー負荷の軽減(ファイルがサーバーを経由しない)
  • アップロード速度の向上
  • Amplifyの帯域制限を回避

2. CloudFront CDNの活用

  • 画像配信の高速化
  • グローバルなエッジロケーション活用
  • キャッシュによる負荷分散

3. コネクションプーリング

  • 同時接続数制限(Neonの無料プランは100接続)を回避
  • データベース接続の効率化
  • 50人以上の同時アクセスに対応

セキュリティ対策

  1. 署名付きURLの有効期限: 1時間に制限
  2. S3バケットのCORS設定: 特定オリジンのみ許可
  3. 環境変数の管理: AWS Amplifyの環境変数機能を使用
  4. IAMポリシー: 最小権限の原則(必要な操作のみ許可)

まとめ

スマホでアクセスされる前提だったので、基本的にはレスポンシブ。
最終的にはこのような画面を実装しました。
Webアプリケーション

このアーキテクチャにより、以下を実現しました:

✅ サーバーレスで運用コスト最小化
✅ 50人以上の同時アクセスに対応
✅ 高速な画像アップロードと配信
✅ スケーラブルなデータベース接続
✅ セキュアなファイルアップロード

署名付きURLを使った直接S3アップロードは、Next.js + Amplifyの構成で非常に有効なパターンです。結婚式などのイベントアプリだけでなく、ユーザー生成コンテンツを扱う様々なアプリケーションに応用できます。

無事に結婚式のフォトコンテストは盛り上がり、作ってよかったと思いました!
エンジニアたるもの、実装した方が仕事のアピールにもなると思いますし、ぜひ実装してみてほしいです!
ではでは!

参考リンク

Discussion