vite

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

Serverless(Firebase) Web Chat을 Vite + TypeScript로 개발(배포까지)
0 views
views
20 min read
#vite
Table of Contents

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는:

  1. /src/main.ts 로딩
  2. main.ts가 import한 파일들을 추적
  3. 전체 의존성 그래프를 구성해서 개발/빌드를 처리

즉, 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사용자 인증각자이름 계정으로 로그인
FirestoreNoSQL 데이터베이스채팅 메시지 저장, 실시간 동기화
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 preview

10) 보안: 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.html2.70 KB0.93 KB
assets/index-*.css7.32 KB2.17 KB
assets/index-*.js500.36 KB114.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 nginx

heredoc 사용 시 EOF는 줄 맨 앞에 있어야 한다(들여쓰기 금지).

12.2 PM2로 preview 운영

npm install
npm run build
pm2 start npm --name "kid-chat" -- run preview

PM2 자주 쓰는 명령어:

pm2 logs kid-chat
pm2 restart kid-chat
pm2 status
pm2 save
pm2 startup

12.3 방화벽(UFW)

ss -tlnp | grep 3001
sudo ufw allow 3001

12.4 HTTPS (Let's Encrypt)

sudo certbot --nginx -d chat.funq.kr

Certbot이 자동으로:

  • 인증서 발급
  • HTTPS 설정 추가
  • HTTP → HTTPS 리다이렉트
  • 자동 갱신 스케줄 등록

13) 운영 트러블슈팅

13.1 "Blocked request. This host is not allowed"

Vite의 보안 정책으로 Host가 허용되지 않으면 차단된다. → vite.config.ts의 preview.allowedHosts에 도메인을 추가.

13.2 외부에서 접속이 안 됨

  1. host: true 설정 확인
  2. 방화벽(sudo ufw status) 확인
  3. 포트 리스닝 확인: ss -tlnp | grep 3001

14) CI/CD 안내

CI/CD는 아래 글에서 정리한 방식 그대로 적용했다.

(이 글의 흐름대로 구성하면, 빌드/배포/PM2 재시작까지 자연스럽게 연결된다.)


15) 마무리

이번 작업의 수확은 "프레임워크 이전에 갖춰야 할 기본기"를 실제 서비스에 적용해봤다는 점이다.

  • 모듈 분리로 코드 이해/수정 범위가 작아짐
  • strict TS로 실수 대부분을 개발 단계에서 차단
  • Vite로 개발 속도(HMR) + 배포 빌드까지 한 번에 해결
  • 운영 배포까지 끝내서 "실사용 가능한 서비스"가 됨

그리고 가장 중요한 건, 아이들이 이제 태블릿으로 "아빠 사랑해!" 메시지를 보낼 수 있게 됐다는 것이다.