← 프로젝트 회고 · Next/Express
RETROSPECTIVE · 회고

정적 사이트에 백엔드 붙이기: 컴포넌트는 그대로 두고 데이터 계층만 갈아 끼우기

Next/Express

이 사이트는 오랫동안 백엔드 없는 정적 사이트였다. 데이터는 Firestore와 Realtime Database에 두고, 화면에서 SDK로 바로 읽고 썼다. 게임이 늘고 실시간 대국, 랭킹, 회원 기능이 붙으면서 한계가 분명해졌다. 보안 규칙만으로는 '서버가 판단해야 하는 것'(수가 규칙에 맞는지, 점수가 말이 되는지)을 표현하기 어려웠고, 빌드 때 글을 정적으로 만드는 방식은 글 하나 고칠 때마다 다시 배포해야 했다. 그래서 Express + socket.io + MongoDB 백엔드를 붙이고, Next.js를 정적 export에서 서버 모드로 바꿨다.

조건은 하나였다. 로컬에서 기존과 똑같이 동작해야 한다.

컴포넌트는 손대지 않기

화면 컴포넌트 수십 개가 firestore/studyPosts.ts, realtime/chessRoom.ts 같은 모듈의 함수를 부르고 있었다. 이걸 전부 고치면 변경 범위가 사이트 전체가 된다. 그래서 이 모듈들의 이름과 함수 시그니처는 그대로 두고 안쪽만 바꿨다.

// 바뀌기 전: Firestore SDK
export async function fetchStudyPosts() {
const snap = await getDocs(query(collection(db, "study"), orderBy("date", "desc")));
return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
}

// 바뀐 뒤: 같은 이름, 같은 반환 타입, 안쪽만 REST
export async function fetchStudyPosts() {
return api<StudyPost[]>("/api/study-posts");
}

실시간 쪽도 같은 방식이었다. 소켓 위에 rpc(요청-응답)와 subscribe(채널 구독) 두 가지만 만들고, 기존 onSnapshot 자리를 subscribe로 바꿨다. 끊겼다 다시 붙으면 구독을 자동으로 다시 건다. 컴포넌트는 데이터가 어디서 오는지 몰라도 된다. 결과적으로 화면 코드는 거의 그대로였고, 문제가 생기면 데이터 계층 파일만 보면 됐다.

서버가 권위를 갖는 곳과 아닌 곳

전부 서버로 옮기면서 '누가 판단하는가'를 하나씩 정했다.

기능판단 주체저장
체스·장기·오목 대국서버가 수를 검증 (장기·오목 엔진은 웹 코드를 서버가 그대로 import)MongoDB (재시작해도 이어 두기)
스케치 퀴즈서버메모리 (방이 짧게 살아서)
격투 온라인두 브라우저가 WebRTC로 직접, 서버는 연결 중개와 중계만메모리
글·방명록·문의서버 (관리자 확인은 Firebase ID 토큰 검증)MongoDB

Firebase는 로그인(Auth)만 남겼다. 서버는 ID 토큰을 Google 공개키로 검증하고, 관리자는 이메일 목록으로 판단한다.

서버 모드로 바꾸며 생긴 일

  1. 캐시와 즉시 반영: 글 페이지는 60초 캐시를 두되, 글이 바뀌면 Express가 Next의 내부 주소(/internal/revalidate)를 비밀값과 함께 불러 그 페이지 캐시를 바로 비운다. 이 주소는 앞단 프록시(Caddy)에서 외부 접근을 막았다.
  2. SEO: 정적 export일 때는 못 하던 '페이지마다 다른 OG 이미지'를 요청 때 만들게 했다. 페이지의 제목과 설명을 읽어 1200×630 카드를 그린다. 617쪽의 제목·설명·canonical·og:image에 중복이나 누락이 없는지 검사하는 스크립트도 만들었다.
  3. OneDrive와 DB 파일: 레포가 OneDrive 폴더 안에 있어서, 로컬 개발용 MongoDB 데이터를 레포 안에 두면 DB 파일이 동기화에 걸린다. 데이터 폴더를 홈 디렉터리 아래 별도 위치로 뺐다.

데이터 옮기기와 전환

Firestore REST API로 컬렉션을 읽어 MongoDB에 upsert하는 스크립트를 만들었다. upsert라서 여러 번 돌려도 안전하다. 새 서버를 띄운 뒤에도 옛 사이트가 한동안 Firestore에 계속 썼기 때문에, 전환 직전에 랭킹과 방명록만 한 번 더 가져왔다.

옛 주소(github.io)는 새 사이트의 사이트맵을 읽어서 페이지마다 이동 페이지(canonical + meta refresh)를 만들어 배포했다. 검색 엔진이 옛 주소의 순위를 새 주소로 넘겨받게 하려는 것이다.

배포는 GitHub Actions에서 테스트·린트·빌드를 하고, 서버·웹 도커 이미지를 레지스트리에 올린 뒤 SSH로 서버에서 새 이미지를 받아 다시 띄우는 순서다. DB는 매일 새벽 덤프를 떠서 14일 보관한다.

배운 점

  1. 큰 이전은 '경계'를 먼저 정하면 작아진다. 데이터 접근을 한 곳에 모아 두었던 덕분에, 이전의 대부분이 그 모듈 몇 개를 바꾸는 일로 줄었다.
  2. 시그니처를 유지하는 어댑터 방식은 '같이 동작하는지'를 확인하기 쉽다. 같은 화면을 같은 순서로 눌러 보면 된다.
  3. 서버를 붙이는 진짜 이유는 '판단을 어디서 하느냐'다. 기능마다 판단 주체와 저장 위치를 표로 정리하니 무엇을 옮길지가 분명해졌다.
  4. 이전은 끝이 아니라 전환까지가 일이다. 옛 사이트가 계속 쓰는 데이터, 옛 주소로 오는 검색 유입까지 챙겨야 끝난다.
Gunmo Lee