Serverless(Firebase) Web Chat을 Vite + TypeScript로 개발(배포까지)

Table of Contents
- Web Chat을 Vite + TypeScript로 개발(배포까지)
- 0) Web Chat이란?
- 1) 왜 Vite + TypeScript인가
- 2) Vite 기초 — "개발 서버 + 빌드 도구"를 한 번에
- 2.1 Vite가 하는 일 두 가지
- (1) 개발 모드: `npm run dev`
- (2) 배포 빌드: `npm run build`
- 2.2 Vite에서 `index.html`이 중요한 이유
- 2.3 src/와 public/의 차이 (실무에서 매우 중요)
- 2.4 alias(@)는 왜 필요한가?
- 3) TypeScript 기초 — "오류를 미리 잡는 개발 방식"
- 3.1 TypeScript는 무엇이 다른가?
- 3.2 "TS를 쓰려면 Node가 필요한가?"
- 3.3 tsconfig.json 옵션을 "의미 기준"으로 이해하기
- 3.4 DOM에서 TS가 특히 유용한 이유 (예시)
- 4) 최종 프로젝트 구조
- 5) Firebase란? — 백엔드 없이 앱 만들기
- 5.1 Firebase가 뭔가?
- 5.2 Kid Chat에서 Firebase가 하는 일
- 5.3 각 Firebase 서비스 역할
- 5.4 실시간 동기화가 되는 이유
- 5.5 푸시 알림 흐름
- 6) 핵심 설정 파일
- 6.1 package.json
- 6.2 tsconfig.json
- 6.3 vite.config.ts
- 7) 타입 정의로 모델 고정하기
- 8) 모듈 분리 설계
- Firebase 설정 (src/firebase/config.ts)
- 인증 (src/firebase/auth.ts)
- 채팅 (src/firebase/chat.ts)
- FCM (src/firebase/fcm.ts)
- 유틸 (src/utils/helpers.ts)
- 9) 실행 방법
- 개발 서버
- http://localhost:3001
- 타입 체크
- 프로덕션 빌드
- dist/ 생성
- 빌드 결과 미리보기
- 10) 보안: Firebase에서 진짜 보안은 Rules
- 11) 번들 크기 메모
- 12) 운영 배포: Ubuntu + Nginx + PM2 + HTTPS
- 12.1 Nginx 리버스 프록시 설정
- 12.2 PM2로 preview 운영
- 12.3 방화벽(UFW)
- 12.4 HTTPS (Let's Encrypt)
- 13) 운영 트러블슈팅
- 13.1 "Blocked request. This host is not allowed"
- 13.2 외부에서 접속이 안 됨
- 14) CI/CD 안내
- 15) 마무리
Web Chat을 Vite + TypeScript로 개발(배포까지)
목표: 바닐라 채팅 앱을 Vite + TypeScript 기반으로 마이그레이션하고, 운영 배포 환경(Ubuntu + Nginx + PM2 + HTTPS + GITHUB ACTIONS)까지 구성하기
0) Web Chat이란?
"아빠, 나도 카톡하고 싶어!"
아이들이 핸드폰이 없어서 가족들과 메시지를 주고받을 수 없다는 것에 아쉬워했다. 태블릿이나 컴퓨터는 있지만, 카카오톡 같은 메신저는 핸드폰 번호가 필요하다.
그래서 직접 만들었다.
- 핸드폰 없이 태블릿이나 컴퓨터의 웹브라우저만 있으면 사용 가능
- 가족끼리만 쓰는 프라이빗 채팅방
- 아이들 이름에 맞춘 귀여운 색상 지정
- 새 메시지가 오면 푸시 알림으로 알려줌
처음에는 단순히 HTML + JavaScript로 빠르게 만들었는데, 기능이 늘어나면서 코드가 복잡해졌다. 이번에 Vite + TypeScript로 구조를 현대화하면서 그 과정을 기록한다.
1) 왜 Vite + TypeScript인가
이번 작업에서 프레임워크를 바로 도입하지 않은 이유는 단순하다. **"프레임워크 이전에, 프론트 개발의 기본기(모듈/빌드/타입)를 갖춘 구조"**가 먼저 필요했기 때문이다.
- Vite: 개발 서버(HMR) + 프로덕션 빌드(최적화)까지 책임지는 툴체인
- TypeScript: 런타임 이전(개발/빌드 단계)에서 오류를 잡아주는 타입 시스템
- 프레임워크 없이도 구조화가 가능하고, 이후 Vue/React로 확장할 때도 기반 지식이 그대로 재사용된다.
2) Vite 기초 — "개발 서버 + 빌드 도구"를 한 번에
2.1 Vite가 하는 일 두 가지
Vite는 개발 단계와 배포 단계를 각각 최적화한다.
(1) 개발 모드: npm run dev
- 로컬 개발 서버 실행
- 파일 수정 시 즉시 반영(HMR: Hot Module Replacement)
- ES 모듈 기반이라 import/export 흐름이 자연스럽다
(2) 배포 빌드: npm run build
- 프로젝트를 최적화해서
dist/폴더로 출력 - 트리쉐이킹(사용하지 않는 코드 제거), 압축, 해시 파일명 처리 등
- 소스맵 생성 가능(디버깅에 도움)
쉽게 말하면, 개발할 때 빠르고, 배포할 때 최적화된 결과물을 만들어준다.
2.2 Vite에서 index.html이 중요한 이유
Vite 프로젝트에서 index.html은 단순한 HTML이 아니라 앱의 시작점(엔트리) 이다.
<script type="module" src="/src/main.ts"></script>이 한 줄을 기준으로 Vite는:
/src/main.ts로딩- main.ts가 import한 파일들을 추적
- 전체 의존성 그래프를 구성해서 개발/빌드를 처리
즉, index.html → main.ts → 모듈들 흐름이 Vite의 기본 구조다.
2.3 src/와 public/의 차이 (실무에서 매우 중요)
src/는 "번들링 대상"
- TypeScript/JavaScript/CSS를 import하며 연결
- Vite가 분석하고 최적화한다
public/은 "그대로 복사되는 정적 파일"
- 빌드 시 가공 없이 dist 루트로 복사
- 개발 서버에서도 /파일명으로 그대로 접근 가능
왜 서비스 워커를 public/에 두냐면:
- 서비스 워커는 경로/스코프가 민감하고
- 종종 "루트 경로에 특정 파일명"이 요구되기 때문이다
→ public/firebase-messaging-sw.js가 가장 안정적인 선택
2.4 alias(@)는 왜 필요한가?
코드가 커지면 상대경로가 지저분해진다.
import { initFCM } from '../../../firebase/fcm' // 😵alias를 쓰면:
import { initFCM } from '@/firebase/fcm' // 🙂주의할 점:
- Vite는
vite.config.ts의 alias를 보고, - TypeScript/IDE는
tsconfig.json의 paths를 본다.
그래서 둘 다 맞춰야 "실행도 되고" "에디터도 에러가 안 난다."
3) TypeScript 기초 — "오류를 미리 잡는 개발 방식"
3.1 TypeScript는 무엇이 다른가?
JavaScript는 런타임에야 오류가 드러나는 경우가 많다.
TypeScript는 코드를 실행하기 전에:
- 변수/함수/데이터 구조를 검사해서
- 이상한 부분을 컴파일 단계에서 경고/에러로 잡아준다
핵심은 이거다:
TS는 실행 결과를 바꾸는 언어가 아니라, 개발 중 실수를 줄여주는 도구다.
3.2 "TS를 쓰려면 Node가 필요한가?"
- 브라우저는 TS를 실행할 수 없고, JS만 실행한다.
- 그래서 TS는 결국 빌드 과정에서 JS로 변환(트랜스파일)된다.
- Vite/tsc 같은 도구가 보통 Node 생태계(npm) 위에서 돌기 때문에 개발/빌드 환경에서는 Node가 필요하다.
하지만 운영(배포 후 사용자 실행)에는 Node가 필요 없다.
- 사용자는
dist/의 정적 파일(JS/CSS/HTML)만 받는다. - 그 정적 파일은 Nginx 같은 정적 서버로 제공 가능하다.
3.3 tsconfig.json 옵션을 "의미 기준"으로 이해하기
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"]
}
}- strict: true → null 가능성, 타입 불일치 같은 흔한 버그를 강하게 잡는다.
- noEmit: true → tsc는 "타입 검사만 하고" JS 파일을 만들지 않는다. (실제 빌드는 Vite가 담당)
- moduleResolution: "bundler" → Vite 같은 번들러 환경에 맞게 import 해석을 한다.
- lib: ["DOM", ...] → 브라우저 DOM API 타입을 포함해서 document, HTMLElement 등을 알게 한다.
3.4 DOM에서 TS가 특히 유용한 이유 (예시)
브라우저 API는 null을 리턴할 수 있다.
const el = document.getElementById('chat');TS 입장에서는: HTMLElement | null
그래서 실무에서는 보통 helper로 강제한다:
export function getElement<T extends HTMLElement>(id: string): T {
const el = document.getElementById(id);
if (!el) throw new Error(`Missing element: #${id}`);
return el as T;
}이렇게 하면 이후 코드가 깔끔해진다:
const form = getElement<HTMLFormElement>('chatForm');
form.addEventListener('submit', ...);4) 최종 프로젝트 구조
kid-chat/
├── src/
│ ├── firebase/
│ │ ├── config.ts # Firebase 초기화 및 인스턴스 export
│ │ ├── auth.ts # 인증 관련 함수 (login, logout, signup 등)
│ │ ├── chat.ts # 채팅 관련 함수 (메시지 전송, 구독, 삭제)
│ │ └── fcm.ts # FCM 푸시 알림 초기화 및 권한 요청
│ ├── types/
│ │ └── index.ts # 타입/인터페이스 정의
│ ├── utils/
│ │ └── helpers.ts # 유틸리티 함수 (formatTime, getNameClass 등)
│ ├── main.ts # 앱 진입점 (이벤트 핸들러, UI 로직)
│ ├── styles.css
│ └── vite-env.d.ts
├── public/
│ ├── firebase-messaging-sw.js # 서비스 워커(빌드 시 그대로 복사)
│ └── img/
├── functions/ # Firebase Cloud Functions (푸시 알림)
├── dist/ # 빌드 결과 (gitignore)
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── firebase.json
├── firestore.rules
└── CLAUDE.md # 프로젝트 문서src/ vs public/ (중요 포인트)
- src/: 번들링 대상 (TS/JS/CSS 모듈)
- public/: 그대로 서빙/복사되는 정적 파일
- 서비스 워커는 경로/스코프 문제 때문에 public/에 두는 것이 가장 안정적이다.
5) Firebase란? — 백엔드 없이 앱 만들기
5.1 Firebase가 뭔가?
Firebase는 Google이 제공하는 백엔드 서비스 플랫폼(BaaS) 이다. 쉽게 말하면, 서버를 직접 만들지 않고도 다음 기능들을 쓸 수 있다:
- 인증(Authentication): 회원가입, 로그인, 비밀번호 변경
- 데이터베이스(Firestore): 메시지 저장, 실시간 동기화
- 푸시 알림(FCM): 새 메시지 알림
- 서버리스 함수(Cloud Functions): 서버 로직 실행
한 마디로, **"서버 개발 없이 앱을 완성할 수 있게 해주는 도구 모음"**이다.
5.2 Kid Chat에서 Firebase가 하는 일
┌─────────────────────────────────────────────────────────────────┐
│ Kid Chat 구조도 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [브라우저] [Firebase] │
│ │
│ ┌──────────┐ 로그인 요청 ┌─────────────────┐ │
│ │ 사용자 │ ───────────────▶ │ Authentication │ │
│ │ (나윤) │ ◀─────────────── │ (인증 서비스) │ │
│ └──────────┘ 인증 토큰 └─────────────────┘ │
│ │ │
│ │ 메시지 전송 │
│ ▼ │
│ ┌──────────┐ 실시간 동기화 ┌─────────────────┐ │
│ │ 채팅 화면 │ ◀───────────────▶ │ Firestore │ │
│ │ │ │ (데이터베이스) │ │
│ └──────────┘ └─────────────────┘ │
│ │ │ │
│ │ 알림 수신 │ 새 메시지 감지 │
│ ▼ ▼ │
│ ┌──────────┐ 푸시 알림 ┌─────────────────┐ │
│ │ 알림 표시 │ ◀─────────────── │ Cloud Functions │ │
│ │ │ │ + FCM │ │
│ └──────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
5.3 각 Firebase 서비스 역할
| 서비스 | 역할 | Kid Chat에서 하는 일 |
|---|---|---|
| Authentication | 사용자 인증 | 각자이름 계정으로 로그인 |
| Firestore | NoSQL 데이터베이스 | 채팅 메시지 저장, 실시간 동기화 |
| FCM | 푸시 알림 | 새 메시지가 오면 알림 전송 |
| Cloud Functions | 서버리스 함수 | 메시지가 저장되면 자동으로 푸시 알림 트리거 |
5.4 실시간 동기화가 되는 이유
Firestore의 핵심 기능 중 하나는 실시간 리스너(Real-time Listener) 이다.
// 메시지 컬렉션을 "구독"하면
onSnapshot(query(collection(db, 'messages'), orderBy('createdAt')), (snapshot) => {
// 다른 사람이 메시지를 보낼 때마다 이 콜백이 자동 실행됨
snapshot.docChanges().forEach((change) => {
if (change.type === 'added') {
// 새 메시지 화면에 추가
}
});
});이 방식 덕분에:
- 아이가 메시지를 보내면
- Firebase가 "변경됐다"고 모든 구독자에게 알려주고
- 부모 화면에 즉시 메시지가 나타난다
서버를 직접 만들면 WebSocket 설정, 연결 관리, 재연결 로직 등을 다 구현해야 하는데, Firestore는 이걸 다 해준다.
5.5 푸시 알림 흐름
1. 아이가 메시지 전송
↓
2. Firestore에 메시지 저장됨
↓
3. Cloud Functions가 "새 메시지 생성" 이벤트 감지
↓
4. FCM을 통해 다른 가족 기기에 푸시 알림 전송
↓
5. 부모가 태블릿에 "아이: 안녕!" 알림 표시
이 흐름에서 Cloud Functions가 중요한 이유:
- 클라이언트(브라우저)에서 직접 다른 기기로 푸시를 보낼 수 없음
- 서버 측 코드가 필요한데, Cloud Functions가 그 역할을 함
6) 핵심 설정 파일
6.1 package.json
{
"name": "kid-chat",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"firebase": "^10.13.1"
},
"devDependencies": {
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}- build: tsc로 타입 검사 → vite build로 최적화 빌드 생성
- preview: 운영 환경에서 정적 호스팅처럼 동작하는 로컬/서버 미리보기
6.2 tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}- strict: true: TypeScript의 진짜 효용(사고 예방)은 여기서 나온다.
- noEmit: true: tsc는 검사 전용, 실제 번들은 Vite가 담당.
- paths: @/ alias를 TS가 이해하도록 설정.
6.3 vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
host: true,
port: 3001,
open: true,
},
preview: {
host: true,
port: 3001,
allowedHosts: ['chat.funq.kr'],
},
build: {
outDir: 'dist',
sourcemap: true,
},
});- host: true: 외부 접속 허용(기본은 localhost 바인딩)
- allowedHosts: "Blocked request. This host is not allowed" 방지
7) 타입 정의로 모델 고정하기
src/types/index.ts
export interface ChatUser {
uid: string;
name: string;
email: string;
}
export interface Message {
id: string;
uid: string;
name: string;
text: string;
createdAt: Date | null;
}
export type NameColorClass =
| 'color-nayoon'
| 'color-soyoon'
| 'color-parent1'
| 'color-parent2'
| 'color-parent3';타입을 먼저 잡아두면 좋은 점:
- Firestore → 앱 내부 모델로 변환 로직이 명확해짐
- UI 렌더링/이벤트 핸들러에서 실수 확 줄어듦
8) 모듈 분리 설계
Firebase 설정 (src/firebase/config.ts)
- Firebase 앱 초기화
- Auth / Firestore / Messaging 인스턴스 export
- VAPID 키 관리
인증 (src/firebase/auth.ts)
| 함수 | 설명 |
|---|---|
| getCurrentUserName() | 현재 로그인된 사용자 이름 반환 |
| setCurrentUserName(name) | 사용자 이름 설정 및 localStorage 저장 |
| onAuthChange(callback) | 인증 상태 변경 리스너 |
| signup(name, password) | 계정 생성 |
| login(name, password) | 로그인 |
| logout() | 로그아웃 |
| changePassword(current, new) | 비밀번호 변경 |
채팅 (src/firebase/chat.ts)
| 함수 | 설명 |
|---|---|
| subscribeToMessages(callback) | 메시지 실시간 구독 |
| sendMessage(text) | 메시지 전송 |
| deleteMessage(messageId) | 메시지 삭제 |
| parseMessageData(docId, data) | Firestore 데이터 파싱 |
FCM (src/firebase/fcm.ts)
| 함수 | 설명 |
|---|---|
| initFCM() | FCM 초기화 및 서비스 워커 등록 |
| requestNotificationPermission(userId) | 알림 권한 요청 및 토큰 저장 |
| isFcmTokenSaved() | 토큰 저장 여부 확인 |
유틸 (src/utils/helpers.ts)
| 함수 | 설명 |
|---|---|
| getNameClass(name) | 이름에 따른 색상 클래스 반환 |
| nameToEmail(name) | 이름을 가상 이메일로 변환 |
| formatTime(date) | 시간 포맷팅 |
| getElement<T>(id) | 타입 안전한 DOM 요소 가져오기 |
9) 실행 방법
개발 서버
npm run dev
# http://localhost:3001타입 체크
npx tsc --noEmit프로덕션 빌드
npm run build
# dist/ 생성빌드 결과 미리보기
npm run preview10) 보안: Firebase에서 진짜 보안은 Rules
클라이언트 설정(firebaseConfig) 값은 보통 공개 가능하지만, 권한 제어는 Firestore Security Rules로 한다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /messages/{messageId} {
allow read: if request.auth != null;
allow create: if request.auth != null;
allow delete: if request.auth != null && request.auth.uid == resource.data.uid;
}
match /fcmTokens/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
}절대 노출 금지:
- serviceAccountKey.json
- Firebase Admin SDK 자격 증명
- 환경 변수의 비밀 값
11) 번들 크기 메모
npm run build| 파일 | 크기 | gzip |
|---|---|---|
| index.html | 2.70 KB | 0.93 KB |
| assets/index-*.css | 7.32 KB | 2.17 KB |
| assets/index-*.js | 500.36 KB | 114.97 KB |
Firebase SDK 포함으로 JS 번들이 큰 편이라, 필요하면 동적 import로 코드 스플리팅을 고려할 수 있다.
12) 운영 배포: Ubuntu + Nginx + PM2 + HTTPS
12.1 Nginx 리버스 프록시 설정
/etc/nginx/sites-available/chat.conf
sudo tee /etc/nginx/sites-available/chat.conf << 'EOF'
server {
listen 80;
server_name chat.funq.kr;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF적용:
sudo ln -s /etc/nginx/sites-available/chat.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxheredoc 사용 시 EOF는 줄 맨 앞에 있어야 한다(들여쓰기 금지).
12.2 PM2로 preview 운영
npm install
npm run build
pm2 start npm --name "kid-chat" -- run previewPM2 자주 쓰는 명령어:
pm2 logs kid-chat
pm2 restart kid-chat
pm2 status
pm2 save
pm2 startup12.3 방화벽(UFW)
ss -tlnp | grep 3001
sudo ufw allow 300112.4 HTTPS (Let's Encrypt)
sudo certbot --nginx -d chat.funq.krCertbot이 자동으로:
- 인증서 발급
- HTTPS 설정 추가
- HTTP → HTTPS 리다이렉트
- 자동 갱신 스케줄 등록
13) 운영 트러블슈팅
13.1 "Blocked request. This host is not allowed"
Vite의 보안 정책으로 Host가 허용되지 않으면 차단된다. → vite.config.ts의 preview.allowedHosts에 도메인을 추가.
13.2 외부에서 접속이 안 됨
host: true설정 확인- 방화벽(
sudo ufw status) 확인 - 포트 리스닝 확인:
ss -tlnp | grep 3001
14) CI/CD 안내
CI/CD는 아래 글에서 정리한 방식 그대로 적용했다.
(이 글의 흐름대로 구성하면, 빌드/배포/PM2 재시작까지 자연스럽게 연결된다.)
15) 마무리
이번 작업의 수확은 "프레임워크 이전에 갖춰야 할 기본기"를 실제 서비스에 적용해봤다는 점이다.
- 모듈 분리로 코드 이해/수정 범위가 작아짐
- strict TS로 실수 대부분을 개발 단계에서 차단
- Vite로 개발 속도(HMR) + 배포 빌드까지 한 번에 해결
- 운영 배포까지 끝내서 "실사용 가능한 서비스"가 됨
그리고 가장 중요한 건, 아이들이 이제 태블릿으로 "아빠 사랑해!" 메시지를 보낼 수 있게 됐다는 것이다.