TuBrief
구독 채널
비디오
커뮤니티

チャットボットのフォームデータ正規化とサーバーレスDB連携の実践

TuBrief 편집팀
2026년 8월 21일
0
Computing/Software

원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.

日本語한국어EnglishEspañol中文العربيةहिन्दीDeutschFrançaisPortuguêsРусскийBahasa Indonesia

관련 영상

▲ Chat SDK: フォームボットを構築する29:07

▲ Chat SDK: フォームボットを構築する

Vercel

커뮤니티의 다른 글

사내 시스템에 llm api 붙일 때 마주하는 현실적인 한계와 대응법

2026년 9월 13일

레거시 백엔드에 GPT-6 Astra 붙일 때 예산 승인과 보안 통과를 먼저 끝내는 법이 있습니다

2026년 9월 13일

에이전트끼리 대화하다 6천만 원 청구서가 나오는 이유

2026년 9월 13일

사내 RAG 벡터 검색에 Okta 권한 필터를 직접 거는 방법

2026년 9월 13일

브라우저 에이전트에게 내 구글 계정을 통째로 넘기면 안 되는 이유

2026년 9월 12일

Apple Won the AI Race

2026년 9월 12일

댓글 (0)

Log in to leave a comment

아직 작성된 글이 없습니다

© 2026 . All rights reserved.

TuBrief
구독 채널
비디오
커뮤니티
로그인

チャットボットのフォームデータ正規化とサーバーレスDB連携の実践

社内メッセンジャーボットに入力されるフォームのデータをデータベースに保存する作業は、思った以上に面倒です。SlackとDiscordのペイロード構造が完全に異なるためです。SDKのドキュメントを見ながら毎回API連携に時間を費やしていると、バックエンド開発者として虚しさを覚えます。

マルチプラットフォームのスキーマフラグメンテーション処理

Slackはモーダル送信時に3重のネストオブジェクトでデータを送信します。Discordはコンポーネント配列の形式で値を送ります。Webhook受信後3秒以内に応答しなければならないというプレッシャーも重なります。

アダプターパターンとZodを組み合わせて、ドメイン正規化レイヤーを作成します。

  1. Zodでバックエンドの標準スキーマを定義します。
  2. SlackPayloadAdapterとDiscordPayloadAdapterをそれぞれ実装します。
  3. チャンネルごとに異なるデータパスをパースし、共通スキーマにバインドします。

`typescript
import { z } from 'zod';

export const CommonFormSchema = z.object({
platform: z.enum(['SLACK', 'DISCORD']),
userId: z.string().min(1),
formId: z.string().min(1),
submittedAt: z.date(),
fields: z.object({
applicantName: z.string().min(2),
contactEmail: z.string().email(),
category: z.enum(['BUG', 'FEATURE', 'INQUIRY']),
description: z.string().max(2000),
}),
});

export type NormalizedFormData = z.infer;

export class SlackPayloadAdapter {
static adapt(rawPayload: any): NormalizedFormData {
const values = rawPayload.view?.state?.values || {};
return CommonFormSchema.parse({
platform: 'SLACK',
userId: rawPayload.user?.id,
formId: rawPayload.view?.callback_id,
submittedAt: new Date(),
fields: {
applicantName: values['name_block']?.['name_action']?.value,
contactEmail: values['email_block']?.['email_action']?.value,
category: values['category_block']?.['category_action']?.selected_option?.value,
description: values['desc_block']?.['desc_action']?.value,
},
});
}
}

export class DiscordPayloadAdapter {
static adapt(rawPayload: any): NormalizedFormData {
const components = rawPayload.data?.components || [];
const fieldMap: Record<string, string> = {};

for (const row of components) {
  for (const comp of row.components || []) {
    if (comp.custom_id) {
      fieldMap[comp.custom_id] = comp.value || comp.values?.[0];
    }
  }
}

return CommonFormSchema.parse({
  platform: 'DISCORD',
  userId: rawPayload.member?.user?.id || rawPayload.user?.id,
  formId: rawPayload.data?.custom_id,
  submittedAt: new Date(),
  fields: {
    applicantName: fieldMap['applicant_name'],
    contactEmail: fieldMap['contact_email'],
    category: fieldMap['category'],
    description: fieldMap['description'],
  },
});

}
}

`

アダプターを分離しておけば、新しいメッセンジャーが追加されてもビジネスロジックを変更する必要がありません。保守コストが軽減されます。

サーバーレス環境のコネクションプールとトランザクション

Vercelのサーバーレス関数は、リクエストごとにインスタンスが起動します。従来の方法でPostgreSQLに直接接続すると、max connectionsの上限を超えてエラーが発生します。

Neonのサーバーレスプーラーを使用すると、接続の遅延を大幅に削減し、同時リクエストを処理できます。べき等性キー(Idempotency Key)で重複保存を防ぐ必要があります。

`typescript
import { Pool } from '@neondatabase/serverless';

const pool = new Pool({ connectionString: process.env.POSTGRES_URL });

export async function insertNormalizedFormsBulk(forms: NormalizedFormData[]) {
const client = await pool.connect();

try {
await client.query('BEGIN');

const insertQuery = `
  INSERT INTO form_responses (
    idempotency_key,
    platform,
    user_id,
    form_id,
    payload,
    created_at
  )
  VALUES ($1, $2, $3, $4, $5, $6)
  ON CONFLICT (idempotency_key) 
  DO UPDATE SET 
    payload = EXCLUDED.payload,
    created_at = EXCLUDED.created_at
  RETURNING id;
`;

for (const form of forms) {
  const idempotencyKey = `${form.platform}:${form.userId}:${form.formId}:${form.submittedAt.getTime()}`;

  await client.query(insertQuery, [
    idempotencyKey,
    form.platform,
    form.userId,
    form.formId,
    JSON.stringify(form.fields),
    form.submittedAt,
  ]);
}

await client.query('COMMIT');

} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}

`

Webhookは速やかに200レスポンスを返し、実際のデータ保存は非同期で処理する方が安全です。3秒のタイムアウトを回避するための唯一の方法です。

入力値のエラー処理と状態管理

入力ミスがあったからといってモーダルを閉じ、エラーを表示するやり方ではユーザーが疲弊してしまいます。開いているモーダルの中で即座にフィードバックを提供すべきです。

`typescript
export function formatSlackValidationErrorResponse(zodError: z.ZodError) {
const errorMap: Record<string, string> = {};

for (const issue of zodError.issues) {
const fieldName = issue.path[issue.path.length - 1];
if (fieldName === 'contactEmail') {
errorMap['email_block'] = issue.message;
} else if (fieldName === 'applicantName') {
errorMap['name_block'] = issue.message;
} else if (fieldName === 'description') {
errorMap['desc_block'] = issue.message;
}
}

return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
response_action: 'errors',
errors: errorMap,
}),
};
}

`

Redisでセッションを管理し、15分のTTLを設定します。エラーログを収集してみると、メールアドレスや文字数制限で引っかかるケースがほとんどです。モーダルのヒントを修正し、リアルタイムフィードバックを組み込むことで、入力エラー率は低下します。