Supabase → Firebase 마이그레이션: Firestore에 백엔드 없이 연락처 양식 연결
게시: 29 April 2026
개월 동안, 이 사이트의 연락처 양식은 모의 JavaScript에서 작동했습니다 — 85% 성공을 약속하는, 가짜 Firestore, 저장된 데이터가 전혀 없었습니다. 초기 계획은 이메일 알림을 위한 Google Apps Script와 함께 Supabase 백엔드를 사용하는 것이었습니다. 포기되었습니다. 오늘, 저는 Firebase Firestore로의 마이그레이션에 대해 이야기합니다: 프로젝트 생성, 보안 규칙, JS 재작성, Supabase 죽은 코드 정리. 그리고 왜 이 선택이 개발 철학에 대해 더 넓은 무언가를 말하는지.
- 틱
-
[]
장면: 아무것도 저장하지 않는 폼
이 사이트는 JBake로 생성된 내 Gradle 플러그인입니다.bakery. 그것은 100% 정적입니다 — 백엔드도 없고 데이터베이스도 없습니다. 그런데 저는 연락처 폼이 있습니다. 페이지`contact.html`있어요, HTML이 준비되었습니다(이름, 이메일, 전화번호, 제목, 메시지, HTML5 검증, 스팸 방지 하니팟 필드), Bootstrap 스타일이 적용되었습니다. 시각적으로 모든 것이 완벽합니다.
제출했을 때엔 아무것도 일어나지 않아요.
const firebaseMock = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.85) {
resolve({ status: 201, message: 'Message stored in Firestore.' });
} else {
reject({ status: 500, message: 'Firestore write failed.' });
}
}, 1500);
});
모의. 가짜 약속. 사용자는 스피너를 보고, 이어서 '메시지가 성공적으로 전송되었습니다!' 라는 메시지를 봅니다. 하지만 데이터는 허공으로 사라집니다. 어디에도 메시지가 저장되지 않습니다.
상황은 깨진 양식보다 더 나빠 — 그것은 거짓말을 하는 양식이다.
Supabase의 유산
초기 계획은, …에 문서화된`content/draft/integration_formulaire_contact_supabase.adoc`, 예정하고 있었다 :
-
테이블이 있는 Supabase 데이터베이스`contacts`와 Row Level Security
-
한 RPC`handle_contact_form`서버 측
-
Google Apps Script 웹훅을 호출하는 SQL 트리거
-
Gmail 알림 메일을 보내는 Google Apps Script
해당 JavaScript 코드는 여전히 존재합니다 안에`script.js`. 반이 있다`SupabaseManager`전역 변수를 이용해 Supabase 클라이언트를 초기화하는`SUPABASE_URL` et SUPABASE_KEY, 그리고 한 클래스`ContactFormHandler`폼의 submit 이벤트를 듣고 호출한다`SupabaseManager.submitContactForm()`.
문제 : 이 전역 변수들은 더 이상 푸터에 주입되지 않습니다. 그`<script src="supabase-js">`제거되었습니다. 코드가 호출합니다.`supabase.createClient()`변수에서`supabase`더 이상 존재하지 않습니다. 그래서:
console.error : 'Supabase client library (supabase-js) is not loaded.'
데이터가 저장되지 않을 뿐만 아니라 제출 코드도 죽었습니다.
유령 이중 제출
일을 더 악화시키기 위해, 하나가 있습니다.침묵적인 경쟁같은 폼 위의 두 핸들러 사이 :
-
`contact.js`제출을 듣고, Firebase 모의 객체를 호출해
-
script.js— 경유`ContactFormHandler`— 또한 제출을 들어라, 호출해`SupabaseManager`
둘 다 한다`event.preventDefault() + event.stopPropagation().처럼`contact.js`먼저 로드됩니다`footer.thyme, 그 핸들러가 먼저 연결됩니다. 그것은 전파를 차단합니다.`ContactFormHandler`결코 트리거되지 않을 것입니다.
그것은 심지어 활성 버그도 아니다 — 좀비다. 실행될 기회를 갖지 않는 코드.
@startuml skinparam backgroundColor #FEFEFE title 초기 상태 — 연락처 양식 rectangle "contact.thyme (HTML Bootstrap, honeypot)" as FORM rectangle "footer.thyme (Firebase SDK with config placeholder)" as FOOT rectangle "contact.js\n(모의 Firebase, 85% 성공)" as CONT rectangle "script.js (SupabaseManager + ContactFormHandler, 죽은)" as SCRIPT rectangle "사용자" as USER USER -> FORM : Soumission du formulaire FORM -> CONT : submit event (attaché en premier) CONT -> CONT : preventDefault + stopPropagation CONT -> CONT : mock Promise (1.5s, aléatoire) note right of CONT ⚠️ Aucune donnée stockée end note FORM ..> SCRIPT : submit event (bloqué par stopPropagation) note right of SCRIPT : ❌ Jamais déclenché note right of SCRIPT : ❌ supabase-js non chargé @enduml
왜 Firebase가 Supabase 대신인가?
마이그레이션 결정은 다음에 문서화되어 있습니다.AGENT.adoc:
Firebase는 이제 다음 이유로 선택됩니다: 더 나은 무료 플랜, 네이티브 Firestore, 통합된 Cloud Functions, 더 적합한 Google 생태계. 기존 Supabase 구현은 '⚠️ 폐기됨’으로 표시됩니다.
무료 플랜을 넘어 건축적인 이유가 있습니다. 이 사이트는 Google 생태계 : 대상 저장소는`cheroliv.github.io`, CNAME은 GitHub Pages를 가리키고, Gradle 빌드는 JGit을 통해 GitHub에 푸시됩니다. Firebase와 같은 Google 서비스를 타사 서비스(Supabase) 대신 사용하면 배포 표면이 줄어듭니다.
Firestore를 네이티브 모드(데이터스토어 모드가 아님)로 사용하면 내가 생각하는 NoSQL 문서 모델에 더 가깝습니다: 컬렉션, 문서, 타입이 지정된 필드, 서버 타임스탬프, 내장된 보안 규칙.
1단계: Firebase 프로젝트 만들기
초기화
내 머신에 Firebase CLI가 설치되어 있지 않아서 웹 콘솔을 사용합니다 :
-
가다https://console.firebase.google.com/[Firebase Console]
-
프로젝트 생성`cheroliv-contact`( 또는 기존 프로젝트 재사용)
-
네이티브 모드에서 Firestore를 활성화(데이터스토어가 아님)
-
지역에서 데이터베이스 생성`eur3`(유럽)
우리처럼 단순한 용도(단일 컬렉션, 공개 쓰기)라면 네이티브 모드가 올바른 선택입니다. 복잡한 Datastore 규칙이 필요하지 않습니다.
Firestore 보안 규칙
양식은 공개되어 있습니다 — 누구나 메시지를 보낼 수 있습니다. 하지만 나는 남용을 제한하고 싶습니다 :
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /contact_messages/{messageId} {
// Lecture : admin uniquement (authentifié)
allow read: if request.auth != null;
// Écriture : publique, mais limitée
allow create: if request.auth == null
&& request.resource.data.name is string
&& request.resource.data.name.size() >= 1
&& request.resource.data.name.size() <= 100
&& request.resource.data.email is string
&& request.resource.data.email.matches('.*@.*\\..*')
&& request.resource.data.email.size() <= 254
&& request.resource.data.subject is string
&& request.resource.data.subject.size() >= 3
&& request.resource.data.subject.size() <= 200
&& request.resource.data.message is string
&& request.resource.data.message.size() >= 10
&& request.resource.data.message.size() <= 5000
&& request.resource.data.created_at == request.time
&& request.resource.data.user_agent is string
&& request.resource.data.user_agent.size() <= 500;
}
}
}
핵심 포인트:
-
allow read— 인증된 사용자만이 메시지를 읽을 수 있습니다 (저, Firebase 콘솔을 통해) -
allow create— 누구나 문서를 만들 수 있지만, 필드 검증과 함께 -
서버 측 검증 : 최소/최대 크기, 이메일 형식,
created_at`해야 한다`request.time(위조 방지) -
`user_agent`추적을 위해 전송됨 (중요하지 않지만 유용함)
이 규칙들은 더 엄격합니다보다 간단한`allow write: if true;`. 그들이 공격자가 엄청난 페이로드나 잘못된 필드를 주입하는 것을 방지합니다.
Phase 2 : 제출 JavaScript 다시 쓰기
계약은 간단합니다 :
-
양식 데이터를 읽다
-
허니팟 확인 (필드`hp_name`— 채워져 있으면 봇이며, 아무것도 보내지 않고 성공을 시뮬레이션합니다)
-
전화하다`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
성공 또는 오류 표시
의존성 `window.FIREBASE
안에`footer.thyme`, 스크립트 모듈이 Firebase SDK를 초기화하고 전역 객체를 노출합니다 :
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.0/firebase-app.js";
import { getFirestore, collection, addDoc, serverTimestamp }
from "https://www.gstatic.com/firebasejs/11.6.0/firebase-firestore.js";
const firebaseConfig = { /* valeurs réelles */ };
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
window.__FIREBASE__ = { db, collection, addDoc, serverTimestamp };
</script>
모듈 스크립트는 먼저 실행됩니다`DOMContentLoaded`, 따라서`window.FIREBASE`핸들러가 있을 때 사용 가능하도록 보장됩니다`contact.js`발생합니다. 예방 차원에서, 5초 폴링을 추가로 설정합니다. CDN이 느릴 경우에 대비해.
새로운 `contact.js
document.addEventListener('DOMContentLoaded', function () {
'use strict';
const form = document.getElementById('contact-form');
if (!form) return;
const submitButton = form.querySelector('button[type="submit"]');
const successMessage = document.getElementById('contact-success-message');
const errorMessage = document.getElementById('contact-error-message');
// Éléments de validation
const nameInput = form.querySelector('input[name="name"]');
const emailInput = form.querySelector('input[name="email"]');
const phoneInput = form.querySelector('input[name="phone"]');
const subjectInput = form.querySelector('input[name="subject"]');
const messageInput = form.querySelector('textarea[name="message"]');
const honeypotInput = form.querySelector('input[name="hp_name"]');
/**
* Attend que window.__FIREBASE__ soit disponible.
* Timeout de 5 secondes — si le CDN Firebase est lent, on abandonne.
*/
function waitForFirebase(timeoutMs = 5000) {
return new Promise((resolve, reject) => {
if (window.__FIREBASE__) {
resolve(window.__FIREBASE__);
return;
}
const start = Date.now();
const interval = setInterval(() => {
if (window.__FIREBASE__) {
clearInterval(interval);
resolve(window.__FIREBASE__);
} else if (Date.now() - start > timeoutMs) {
clearInterval(interval);
reject(new Error('Firebase SDK non disponible après timeout'));
}
}, 100);
});
}
// --- Validation (identique à l'existant) ---
function validateForm() {
nameInput.setCustomValidity('');
emailInput.setCustomValidity('');
if (phoneInput) phoneInput.setCustomValidity('');
subjectInput.setCustomValidity('');
messageInput.setCustomValidity('');
if (nameInput.value.trim().length < 1) {
nameInput.setCustomValidity('Veuillez saisir votre nom.');
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(emailInput.value.trim())) {
emailInput.setCustomValidity('Veuillez saisir une adresse email valide.');
}
if (phoneInput && phoneInput.value.trim() !== '') {
const phonePattern = /^\d{10,15}$/;
if (!phonePattern.test(phoneInput.value.trim())) {
phoneInput.setCustomValidity('Veuillez saisir un numéro valide (10 à 15 chiffres).');
}
}
if (subjectInput.value.trim().length < 3) {
subjectInput.setCustomValidity('Veuillez saisir un sujet (3 caractères minimum).');
}
if (messageInput.value.trim().length < 10) {
messageInput.setCustomValidity('Veuillez saisir un message (10 caractères minimum).');
}
form.classList.add('was-validated');
return form.checkValidity();
}
// --- Handler de soumission ---
form.addEventListener('submit', async function (event) {
event.preventDefault();
event.stopPropagation();
if (!validateForm()) return;
// Honeypot : si rempli, simuler un succès sans rien envoyer
if (honeypotInput && honeypotInput.value.trim() !== '') {
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
return;
}
// UI : état d'envoi
submitButton.disabled = true;
submitButton.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Envoi en cours...
`;
successMessage.style.display = 'none';
errorMessage.style.display = 'none';
try {
const fb = await waitForFirebase();
const messagesCollection = fb.collection(fb.db, 'contact_messages');
await fb.addDoc(messagesCollection, {
name: nameInput.value.trim(),
email: emailInput.value.trim(),
phone: phoneInput ? phoneInput.value.trim() : '',
subject: subjectInput.value.trim(),
message: messageInput.value.trim(),
created_at: fb.serverTimestamp(),
user_agent: navigator.userAgent.substring(0, 500)
});
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
} catch (error) {
console.error('Erreur Firestore:', error);
errorMessage.style.display = 'block';
} finally {
submitButton.disabled = false;
submitButton.innerHTML = `
<i class="bi bi-send me-2"></i>
Envoyer le Message
`;
}
}, false);
});
모의와 비교한 변경 사항 :
-
waitForFirebase()— 타임아웃이 있는 폴링, CDN이 느려도 견고함 -
honeypot— 숨은 필드면`hp_name`가 채워졌습니다, 파이어스토어에서 무조건적인 성공을 시뮬레이션합니다. 봉은 성공했다고 생각하지만 아무것도 저장되지 않습니다 -
addDoc(collection, {…})— 실제 Firestore 호출`serverTimestamp()` etuser_agent -
오류 처리와 함께`try/catch`비동기
-
청소하는`finally`(버튼 복원)
|
왜`user_agent`? 이것은 선택사항이지만 진단에 유용합니다. 이상한 메시지가 도착했을 때, 그것이 데스크톱 브라우저, 모바일, 또는 curl 스크립트에서 왔는지 아는 것이 분류에 도움이 됩니다. |
3단계: Supabase의 죽은 코드 정리
`script.js`250줄의 dead code가 포함되어 있습니다:
-
SupabaseManager(라인 417-481) — 65 라인 -
ContactFormHandler(490-551번 줄) — 62줄 -
초기화 블록 (줄 645-654) — 10줄
총 : 약 140줄 삭제
블록`DOMContentLoaded`만든`SupabaseManager`그리고 하나`ContactFormHandler`양식에 연결됨. 앞서 설명한 바와 같이, 이 코드는 결코 실행되지 않습니다(차단된`contact.js`), 심지어 실행되더라도 실패할 것입니다 (Supabase SDK가 로드되지 않음).
삭제합니다 :
-
클래스`SupabaseManager`
-
수업`ContactFormHandler`
-
초기화 블록 안에`DOMContentLoaded`(줄 645-654)
Le reste de script.js`정상입니다 :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.
단계 4: 푸터 구성
`footer.thyme`이미 Firebase boilerplate을 가지고 있지만 값이 플레이스홀더인 상태입니다. 나는 교체합니다:
const firebaseConfig = {
apiKey: "REMPLACER_PAR_VOTRE_API_KEY",
authDomain: "REMPLACER_PAR_VOTRE_AUTH_DOMAIN",
projectId: "REMPLACER_PAR_VOTRE_PROJECT_ID",
storageBucket: "REMPLACER_PAR_VOTRE_STORAGE_BUCKET",
messagingSenderId: "REMPLACER_PAR_VOTRE_SENDER_ID",
appId: "REMPLACER_PAR_VOTRE_APP_ID"
};
실제 값을 가져온 이래프로젝트 설정 > 일반 > 앱 > 웹 앱Firebase 콘솔에서.
값은 민감합니다`apiKey`Firebase에서는 설계상 공개되어 있지만, 저는 평문으로 커밋하는 것을 선호하지 않습니다). 저는 그것을 저장합니다`site.yml`(이미 안에`.gitignore`) 및 플러그인`bakery`그들은 빌드 측에 추가해야 할 로직을 통해 템플릿에 주입한다.
당장, 나는 그들을 바로 안에 넣고 있어요.footer.thyme— 빌드`./gradlew serve`로컬에서 로드할 것입니다. 배포 시, 저는 인젝션을 이동시킬 것입니다.`site.yml`또는 Gradle 변수에.
|
L'`apiKey`Firebase는 아닙니다.아니비밀입니다. 설계상 공개되어 있습니다. 당신의 데이터를 보호하는 것은Firestore 보안 규칙, API 키가 아닙니다. 그 안에 넣지 마세요`.env`서버 측에서 로드된 — 브라우저에 노출되도록 의도되었습니다. |
Phase 5 : 최종 아키텍처
@startuml
skinparam backgroundColor #FEFEFE
title 최종 아키텍처 — 문의 폼 Firebase
actor Utilisateur as USER
package "브라우저" #E8F5E9 {
rectangle "연락.타임\n(HTML, 허니팟)" as FORM
rectangle "contact.js\n(유효성 검사 + Firestore)" as CONT
rectangle "footer.thyme\n(Firebase SDK 초기화)" as FOOT
}
cloud "파이어베이스" #BBDEFB {
rectangle "Firestore
(연락_메시지)" as FS
}
FORM --> CONT : submit event
CONT --> FOOT : window.__FIREBASE__.addDoc()
FOOT --> FS : Insert document\n(règles de sécurité validées)
note right of FS
Firestore Rules :
- create: public, validé
- read: auth uniquement
end note
@enduml
이 이동이 도그푸딩에 대해 말하는 것
이 사이트는 내 자체 Gradle 플러그인으로 생성됩니다.bakery. 연락처 양식은 사이트 내부에 존재합니다. Supabase → Firebase 마이그레이션은 다음에 문서화되어 있습니다.AGENT.adoc, 이 이야기는 백로그에서 논의되며, 이 이야기는 를 통해 테스트됩니다`./gradlew serve`, 그리고 그것은 블로그 포스트(당신이 읽고 있는 글)를 생성합니다.
이것은 순수한 dogfooding이다. 사이트는 플러그인의 제품이고, 플러그인은 개발자의 제품이며, 개발자는 사이트 자체에서 과정을 문서화한다.
루프가 닫혔습니다.
몇 달 동안 모의를 끌고 다녔던 일(양식은 조용히 거짓말을 했던 전체 세션들) 때문에 나는 뭔가를 깨달았다: 개인 정적 사이트의 백로그는 결코 ‘완료’되지 않는다. 항상 우선순위가 있는 US가 있고, 항상 초안인 글이 있고, 항상 템플릿에 주석 처리된 섹션이 있다. 규율은 모든 것을 끝내는 것이 아니라 사용자에게 보이는 것을 끝내는 것이다.
고장난 연락처 양식은 아예 양식이 없는 것보다도 더 나쁘다. 그것은 지키지 못한 약속이다.
수정 사항 요약
파일 |
수정 |
영향 |
|
기사 작성 |
문서 |
|
재작성 (mock → 실제 Firestore) |
기능적인 |
|
삭제 SupabaseManager + ContactFormHandler + 초기화 블록 |
청소 |
|
구성 요소 플레이스홀더 대체 → 실제 값 |
구성 |
다음 단계 (백로그)
-
알림 이메일: 하나의 Cloud Function`onCreate`위에`contact_messages`SendGrid을 통해 메일을 보내는. 폼은 저장되지만 알림을 받지 못합니다. 중위 우선순위 — 메시지가 Firebase 콘솔에서 보입니다.
-
클라이언트 측 요청 제한:
localStorage타임스탬프를 추가하여 연속적인 다중 제출을 방지합니다. 허니팟은 naïve 봇을 차단하고, 레이트 리미터는 좀 더 교묘한 봇도 차단할 것입니다. -
테스트: Playwright 테스트가 폼을 제출하고 Firestore에서 문서가 나타나는지 확인합니다. 현재는 수동으로 테스트하고 있습니다 via`./gradlew serve`.
관련 기사
31 May 2026
14 May 2026