공간엔 사이트 구축기 (트러블슈팅 & UX 안정화) - 3편
운영 회고
3편 · 트러블슈팅 & UX 안정화
운영 트러블슈팅과 사용자 경험 개선기: 메일 실패를 분리하고 SMS 중심으로 안정화한 과정
🔗 실제 적용 사이트
운영 흐름(상담신청 → 저장 → 알림)과 UX 변화를 같이 보면 더 빠르게 이해돼요.
https://www.gongann.co.kr/
운영 흐름(상담신청 → 저장 → 알림)과 UX 변화를 같이 보면 더 빠르게 이해돼요.
초기에 “메일 + SMS를 둘 다” 보내는 구조로 시작했는데, 운영에서 메일 Edge Function이 SMTP 인증 오류(535 Username and Password not accepted)로 계속 실패했습니다.
반면 SMS는 정상이었고, 운영 목적은 “상담 신청이 저장되고 관리자가 빠르게 인지하는 것”이라 핵심 경로를 SMS로 단순화했습니다.
동시에, 사용자가 전송 지연 동안 불안해하지 않도록 pending UI(전송 중…)와 성공/실패 메시지를 추가해 UX를 안정화했어요.
1) 장애 증상: 운영에서 메일 발송만 실패(
SMTP 535), SMS는 정상.2) 로그 확인 → 원인 분리(메일/문자) → “메일을 떼고 SMS만”으로 핵심 경로 안정화.
3) 상담신청은 DB 저장이 우선이며, SMS 성공만으로 운영상 목적 달성.
4) UX: 전송 중 상태 + 버튼 disabled + 스피너 + 하단 메시지로 불안/중복 제출 감소.
5) 배포 중 divergent branch는
git pull --rebase origin master로 정리 후 push.
타임라인(운영 대응 흐름)
- 장애 인지 운영에서 상담 신청은 저장되는데, “메일 알림만” 누락 발생
- 로그 확인 Edge Function 로그에서 SMTP 인증 오류(
535) 확인 - 원인 분리 메일 실패 / SMS 성공을 분리해 “핵심 경로” 정의
- 의사결정 “기능을 잠시 덜어내고, SMS 중심으로 안정화”
- UX 보강 전송 중 표시, 버튼 disabled, 스피너, 하단 결과 메시지 적용
- 배포 이슈 처리 divergent branch 해결(
git pull --rebase) 후 정상 배포
핵심 관점
장애 대응에서 “모든 기능을 유지”보다 “운영이 멈추지 않는 핵심 경로”를 먼저 살리는 게 결과적으로 더 빠르고 안전했습니다.
장애 대응에서 “모든 기능을 유지”보다 “운영이 멈추지 않는 핵심 경로”를 먼저 살리는 게 결과적으로 더 빠르고 안전했습니다.
장애 증상 → 로그 확인 → 원인 분리(메일/문자)
장애 증상(현상)
- 상담신청은 정상적으로 저장됨(DB 원장 유지)
- SMS 알림은 정상 도착
- 메일 알림만 누락(운영자 “메일로도 오게 해둔 것 같은데?”)
로그에서 확인한 에러
- 메일 Edge Function:
535 Username and Password not accepted - SMS Edge Function: OK (정상 응답)
- 결론: “이슈는 메일 전송 경로에 국한”
왜 SMTP 535가 운영에서만 터질까?
보통은 계정 보안 정책(2FA/앱 비밀번호), SMTP 서버 인증 방식 차이, 운영 환경변수 누락/오타, 발신 도메인/서버 정책 같은 이유로 “로컬/테스트는 됐는데 운영에서 실패”가 자주 나옵니다.
이 글의 포인트는 “원인 해결을 미루자”가 아니라, 운영 목표를 달성하는 최소 경로를 먼저 안정화한 다음에 메일을 다시 붙이자는 전략이에요.
보통은 계정 보안 정책(2FA/앱 비밀번호), SMTP 서버 인증 방식 차이, 운영 환경변수 누락/오타, 발신 도메인/서버 정책 같은 이유로 “로컬/테스트는 됐는데 운영에서 실패”가 자주 나옵니다.
이 글의 포인트는 “원인 해결을 미루자”가 아니라, 운영 목표를 달성하는 최소 경로를 먼저 안정화한 다음에 메일을 다시 붙이자는 전략이에요.
왜 “메일을 잠시 덜어내고 SMS로 단순화”했나
운영 목표 재정의
- 필수 상담신청이 DB에 저장된다
- 필수 관리자가 빠르게 인지한다
- 선택 메일/다채널 알림은 “추가 가치”
결정 근거
- SMS가 이미 정상 동작(핵심 경로 존재)
- 메일 실패가 전체 흐름을 불안정하게 만듦
- “저장 + SMS 성공”이면 운영상 목적 달성
원칙
장애 상황에서는 기능을 “추가”하기보다, 실패 가능성이 높은 분기(메일)를 분리/제거해 핵심 성공률을 끌어올리는 것이 우선입니다.
장애 상황에서는 기능을 “추가”하기보다, 실패 가능성이 높은 분기(메일)를 분리/제거해 핵심 성공률을 끌어올리는 것이 우선입니다.
구조 변경: 메일/문자 경로를 분리하고 SMS만 남기기
변경 전/후 비교
| 항목 | 변경 전(메일 + SMS) | 변경 후(SMS 중심) |
|---|---|---|
| 알림 경로 | 메일 Edge Function + SMS Edge Function | SMS Edge Function만 |
| 장애 영향 | 메일 실패가 “전체 전송 실패”처럼 보이기 쉬움 | SMS만 성공하면 운영 목적 달성 |
| 관측/디버깅 | 실패 지점이 2개(메일/SMS)라 원인 추적 느려짐 | 핵심 경로 1개로 로그/대응 단순화 |
| 사용자 UX | 전송 지연/실패 시 사용자 불안 + 중복 제출 | 전송 중 표시 + 결과 메시지로 불안 감소 |
Edge Function 라우팅 단순화(개념)
// ✅ 개념 예시: "메일+SMS"에서 "SMS only"로 단순화
// - 실제 프로젝트 구조에 맞춰 파일/함수명만 맞추면 됩니다.
// - 민감정보는 placeholder로만 표현
export default async function handler(req) {
// 1) 입력 파싱/검증
const payload = await req.json(); // { inquiryId, name, phone, message }
// 2) DB 저장은 프론트에서 이미 끝났다고 가정(또는 여기서 조회)
// 3) 핵심: SMS 발송만 수행
try {
await sendSmsOnly(payload);
return new Response(JSON.stringify({ ok: true, channel: "sms" }), {
headers: { "content-type": "application/json" },
});
} catch (e) {
// ❗ 실패해도 DB 원장은 남아있음(운영 재처리 가능)
console.error("SMS send failed", e);
return new Response(JSON.stringify({ ok: false, error: "SMS_FAILED" }), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
// 과거: await sendEmail(payload); await sendSms(payload);
// 현재: await sendSmsOnly(payload);
운영 안정화 포인트
“저장”과 “알림”을 분리해두면, 알림이 실패해도 데이터 유실이 없고 운영자가 수동 재발송/재처리할 수 있습니다.
“저장”과 “알림”을 분리해두면, 알림이 실패해도 데이터 유실이 없고 운영자가 수동 재발송/재처리할 수 있습니다.
UX 개선 전/후: 사용자가 불안해하지 않게 만들기
개선 전(문제)
- 전송이 지연되면 사용자가 “안 된 건가?”라고 판단
- 버튼을 여러 번 눌러 중복 제출 발생 가능
- 에러가 나도 화면에 명확한 피드백이 없어 이탈
개선 후(해결)
- 전송 중… 상태 표시(pending)
- submit 버튼 disabled + 로딩 스피너
- 폼 하단에 성공/실패 메시지 표시
프론트 상태 머신(추천)
- idle 입력 대기
- pending 전송 중…(버튼 disabled)
- success 접수 완료(안내 메시지)
- error 실패(오류 메시지 + 재시도 안내)
React: 버튼 disabled + 스피너 + 하단 메시지
import { useState } from "react";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient("<SUPABASE_URL>", "<SUPABASE_ANON_KEY>");
export default function InquiryForm() {
const [status, setStatus] = useState("idle"); // idle | pending | success | error
const [message, setMessage] = useState("");
const [form, setForm] = useState({ name: "", phone: "", message: "" });
const isPending = status === "pending";
async function onSubmit(e) {
e.preventDefault();
setStatus("pending");
setMessage("전송 중... 잠시만 기다려 주세요.");
try {
// 1) DB 저장(원장)
const payload = {
name: form.name.trim(),
phone: form.phone.replace(/[^0-9]/g, ""),
message: form.message.trim(),
};
const { data, error } = await supabase
.from("inquiries")
.insert(payload)
.select("id")
.single();
if (error) throw new Error("DB_INSERT_FAILED");
// 2) SMS 트리거(메일 제거, SMS만)
const { error: fnError } = await supabase.functions.invoke("send-inquiry-sms", {
body: { inquiryId: data.id, ...payload },
});
// SMS 실패라도 접수는 완료(운영 재처리 가능)
if (fnError) {
setStatus("success");
setMessage("접수는 완료되었습니다. 확인 후 빠르게 연락드리겠습니다.");
return;
}
setStatus("success");
setMessage("접수 완료! 확인 후 빠르게 연락드리겠습니다.");
} catch (err) {
console.error(err);
setStatus("error");
setMessage("전송에 실패했어요. 잠시 후 다시 시도해 주세요.");
}
}
return (
<form onSubmit={onSubmit}>
{/* 입력들... */}
<button type="submit" disabled={isPending}>
{isPending ? (
<span style={{ display: "inline-flex", gap: 8, alignItems: "center" }}>
<span
aria-hidden="true"
style={{
width: 14,
height: 14,
borderRadius: 999,
border: "2px solid currentColor",
borderRightColor: "transparent",
display: "inline-block",
animation: "spin .8s linear infinite",
}}
/>
전송 중...
</span>
) : (
"상담 신청하기"
)}
</button>
{/* 폼 하단 메시지 */}
<div role="status" aria-live="polite" style={{ marginTop: 10 }}>
<small style={{ color: status === "error" ? "#ef4444" : "#334155" }}>
{message}
</small>
</div>
{/* 스피너 애니메이션(전역 CSS로 옮겨도 OK) */}
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</form>
);
}
실무 포인트(운영/UX 둘 다 잡기)
- 사용자에게는 “DB 저장 기준으로 접수 완료”를 확실히 보여주고,
- 알림은 “운영 추적 가능”하게 로그/ID를 남겨서 후속 처리로 넘기는 게 안정적입니다.
- 사용자에게는 “DB 저장 기준으로 접수 완료”를 확실히 보여주고,
- 알림은 “운영 추적 가능”하게 로그/ID를 남겨서 후속 처리로 넘기는 게 안정적입니다.
운영 배포 시 자주 겪는 git push 거절(divergent branch) 처리 팁
운영 반영 직전에 git push가 거절되고 “remote와 local이 갈라졌다(divergent)”가 뜨는 케이스, 진짜 흔합니다.
보통 누군가가 원격에 먼저 커밋을 올렸거나(또는 CI가 커밋을 만들었거나), 로컬 브랜치 히스토리가 달라져서 생겨요.
가장 무난한 정리: rebase로 히스토리 깔끔하게
# 0) 현재 상태 확인(선택)
git status
git log --oneline --decorate -n 10
# 1) 원격 최신을 rebase로 가져오기 (질문에서 준 명령)
git pull --rebase origin master
# 2) 충돌 나면 해결 → add → rebase continue
git add .
git rebase --continue
# 3) rebase 끝나면 push
git push origin master
주의
이미 팀이 공유 중인 브랜치에서 강제 푸시(
이미 팀이 공유 중인 브랜치에서 강제 푸시(
--force)는 신중해야 합니다.
rebase 후에 강제 푸시가 필요한 상황이면, 팀 규칙(보호 브랜치/PR 정책)에 맞춰 처리하세요.
체크 포인트: 왜 divergent가 났는지 빠르게 찾기
# 내 브랜치와 origin/master 차이 한눈에
git fetch origin
git log --oneline --left-right --graph HEAD...origin/master
독자가 바로 따라 하는 체크리스트
장애 대응 체크리스트(운영)
- [ ] 증상 정의: “저장은 되는데 알림이 누락인가?” “특정 채널만 실패인가?”
- [ ] 로그 확인: Edge Function 로그에서
SMTP 535같은 명확한 에러 코드 확보 - [ ] 원인 분리: 메일/문자/DB 중 어디가 실패인지 분리
- [ ] 핵심 경로 확정: 운영 목적(저장 + 관리자 인지) 달성 조건 정의
- [ ] 임시 완화: 실패 채널(메일) 제거/비활성화로 성공률 확보
- [ ] 재현 가능 메모: 어떤 환경(운영/스테이징)에서만 발생했는지 기록
UX 체크리스트(프론트)
- [ ] pending UI: “전송 중…” 문구 노출
- [ ] 중복 제출 방지: 버튼 disabled 처리
- [ ] 로딩 스피너: 전송 중임을 시각적으로 표시
- [ ] 결과 메시지: 성공/실패를 폼 하단에 명확히 표기
- [ ] 실패 시 안내: “잠시 후 재시도” + 필요하면 연락 채널 안내
배포 체크리스트(git)
[ ]
[ ] push 거절 시
[ ] 충돌 해결 후
[ ] 최종
[ ]
git fetch origin로 원격 최신 확인[ ] push 거절 시
git pull --rebase origin master 우선 시도[ ] 충돌 해결 후
git rebase --continue로 마무리[ ] 최종
git push 성공 확인
회고
이번 이슈에서 얻은 것
- 채널이 많을수록 장애 지점이 늘고, “부분 실패”가 “전체 실패처럼 보이는 UX”를 만들 수 있다.
- 운영 목표를 “DB 저장”과 “관리자 인지”로 다시 정의하니, 의사결정이 빨라졌다.
- 사용자 경험은 기능 스펙보다 “기다리는 동안의 불안”을 줄이는 게 훨씬 중요했다.
- 배포 과정의 git 이슈는 기술 난이도보다 “절차/습관(리베이스 루틴)”이 해결한다.
한 줄 정리
기능을 잠시 덜어내고 핵심 경로(SMS)를 안정화한 덕분에, 운영은 멈추지 않았고 UX까지 같이 좋아졌습니다.
기능을 잠시 덜어내고 핵심 경로(SMS)를 안정화한 덕분에, 운영은 멈추지 않았고 UX까지 같이 좋아졌습니다.
다음 개선 계획
1) 복수 관리자 번호 지원
- 현재: 단일 관리자 번호로 수신
- 개선: 관리자 번호 배열로 확장(팀/지점별 분기 가능)
- 추가: 번호별 성공/실패 로그 저장
2) 재시도 큐(지연/실패 대비)
- 현재: 즉시 발송(실패 시 운영 재처리)
- 개선: 실패 이벤트를 큐에 넣고 일정 횟수 자동 재시도
- 옵션: 백오프(1m → 5m → 15m) 적용
3) 알림 채널 확장(안정화 후)
- 메일 재도입은 “원인 해결 + 관측 강화” 후에
- 채널 후보: Slack/카카오워크/디스코드 웹훅
- 원칙: 채널 추가 전에 “핵심 경로 성공률” 유지
4) 운영 관측성 강화
- inquiries에 상태 컬럼 추가:
saved,sms_sent,sms_failed - 관리자 페이지에서 필터/재발송 버튼 제공
- 실패 사유/응답 코드 저장(분석/개선 근거)
다음 편 예고
“재시도 큐 + 복수 관리자 + 운영 화면(조회/재발송)”까지 붙이면, 상담신청 파이프라인이 진짜 ‘운영용’으로 완성됩니다.
“재시도 큐 + 복수 관리자 + 운영 화면(조회/재발송)”까지 붙이면, 상담신청 파이프라인이 진짜 ‘운영용’으로 완성됩니다.

댓글
댓글 쓰기