챗봇 폼 데이터 정규화와 서버리스 디비 연동 실전
사내 메신저 봇으로 들어오는 폼 입력을 데이터베이스에 넣는 일은 생각보다 번거롭습니다. 슬랙과 디스코드의 페이로드 구조가 완전히 다르기 때문입니다. SDK 문서를 보며 매번 API 연동에 시간을 쏟다 보면 백엔드 개발자로서 현타가 옵니다.
멀티 플랫폼 스키마 파편화 처리하기
슬랙은 모달 제출 시 3중 중첩 객체로 데이터를 던집니다. 디스코드는 컴포넌트 배열 형태로 값을 보냅니다. 웹훅 수신 후 3초 이내에 응답해야 하는 압박까지 겹칩니다.
어댑터 패턴과 Zod를 묶어 도메인 정규화 계층을 만듭니다.
- Zod로 백엔드 표준 스키마를 정의합니다.
- SlackPayloadAdapter와 DiscordPayloadAdapter를 각각 구현합니다.
- 채널별로 제각각인 데이터 경로를 파싱해 공통 스키마로 바인딩합니다.
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<typeof CommonFormSchema>;
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'],
},
});
}
}
어댑터를 분리하면 새 메신저가 추가되어도 비즈니스 로직을 고칠 필요가 없습니다. 유지보수 공수가 줄어듭니다.
서버리스 환경 커넥션 풀과 트랜잭션
버셀 서버리스 함수는 요청마다 인스턴스가 뜹니다. 전통적인 방식으로 포스트그레스에 직접 연결하면 max connections 한도를 초과해 에러가 터집니다.
네온의 서버리스 풀러를 쓰면 연결 지연을 대폭 낮추고 동시 요청을 처리할 수 있습니다. 멱등성 키로 중복 저장을 막아야 합니다.
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();
}
}
웹훅은 빠르게 200 응답을 던지고 실제 적재는 비동기로 처리하는 편이 안전합니다. 3초 타임아웃을 피하는 유일한 길입니다.
입력값 예외와 상태 관리
잘못 입력했다고 모달을 닫고 에러를 뱉으면 사용자는 지칩니다. 열린 모달 안에서 바로 피드백을 줘야 합니다.
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,
}),
};
}
레디스로 세션을 관리하고 15분 TTL을 겁니다. 에러 로그를 수집해 보면 이메일이나 글자 수 제한에서 막히는 경우가 대부분입니다. 모달 힌트를 수정하고 실시간 피드백을 붙이면 입력 오류율이 떨어집니다.