jangjunha blog

잠자는 서비스 만들기

HeekTime 웹사이트 스크린샷

6년 전 쯤 학교 과제로 HeekTime 이라는 학교 시간표 짜는 애플리케이션을 사이드프로젝트로 만들기 시작했습니다. 처음엔 과제로 시작했지만 이후로는 새로 공부한 것들을 적용해보는 테스트베드가 되었고 결과적으로 유지비가 크게 늘었습니다. 당시에는 공부를 위해 투자한다고 생각했지만 이제는 더이상 이 프로젝트를 공부 목적으로 사용하지 않아서 유지비를 줄이고 싶어졌고 서버리스 아키텍처로 전환하기로 결정했습니다. 실제로 요청을 처리하는 시간 외에는 서버가 실행되지 않기 때문에 글 제목에서는 ‘잠자는 서비스’라고 표현해봤습니다. 짬짬히 시간을 내어 몇 달간 천천히 전환을 마쳤고 그 과정을 글로 남기고자 합니다.

🔗 결론

월별 비용 보고서 막대 그래프. 작년 3월부터 올해 6월까지 매달 6만원. 7월 3만원. 8월, 9월은 500원.
월별 비용 보고서

결론부터 말하면 월 ₩60,000 이었던 비용을 ₩500 정도로 줄였습니다.

프로젝트 규모를 생각했을 때 이전 비용이 꽤 높았는데, 실무에서 사용하는 클라우드 서비스들과 Kubernetes를 도입하면서 비용이 많이 늘었습니다. HTTP 부하분산기, 컴퓨팅 인스턴스가 비용의 대부분을 차지하고 있었습니다.

🔗 기능 소개 및 이전 구성

본론에 앞서 서비스에 어떤 기능들이 있고 기존엔 어떻게 실행하고 있었는지를 밝히겠습니다. HeekTime은 네 개의 서비스로 이루어져 있습니다.

  • 웹서버는 HTTP API 서버로 클라이언트의 모든 요청을 받아서 처리하며, RDB와 외부 스토리지에 상태를 저장하고 관리합니다.
  • 비동기 워커는 강의 서비스로부터 새 강의목록을 받아와서 웹서버가 사용할 수 있도록 직렬화하여 캐싱해두는 일을 합니다. Celery를 사용 중입니다.
  • 스케쥴러는 일정 시간마다 강의 캐시 갱신을 요청합니다. Celery beat을 사용 중입니다.
  • 강의 서비스는 다른 서비스에 최신 강의 정보를 제공합니다. 로직 위주이며 상태(DB)가 없는 서비스입니다. 다른 서비스와는 gRPC로 통신합니다.

네 서비스와 데이터베이스, 작업 큐는 Kubernetes 클러스터 위에서 운영되고 있습니다. 외부 요청은 로드밸런서를 통해 내부로 전달됩니다. 아래는 서비스의 주요 흐름입니다.

  • 클라이언트가 요청을 보내면 웹서버가 요청을 받아서 처리한 후 응답합니다.
  • 스케쥴러에 의해 캐시 갱신 요청이 트리거되면 비동기 워커는 강의 서비스에 강의 목록을 요청합니다. 강의 서비스가 강의 목록을 반환하면 워커는 이를 직렬화해서 스토리지 서비스(Cloud Storage)에 업로드합니다.

🔗 문제 살펴보기

🔗 상황

  1. 서비스의 사용자가 많지 않습니다. 😭
  2. 서비스 특성상 학기마다 수강신청 기간에만 이용량이 몰립니다.
  3. 개인적인 공부를 위해 사용한 리소스들(Kubernetes 노드 인스턴스, 로드밸런서)의 최소 고정 비용이 사용량에 비해 높습니다.

🔗 목표

  1. 서비스 운영 비용을 크게 줄입니다.

🔗 제약 조건

  1. 가급적이면 이전 과정에서 서비스를 중단하지 않습니다. (욕심입니다)
  2. 업데이트하지 않은 구버전 클라이언트를 계속해서 지원합니다.

🔗 다양한 해결책 생각해보기

  1. 사용량이 적으니 가상 서버를 한 대 띄우고 거기에서 웹서버와 워커, DB, 큐를 모두 직접 구성합니다.

    • 현재 구조를 그대로 유지할 수 있어서 시간이 적게 듭니다.
    • 가상 서버 한 대 만큼의 최소비용은 발생합니다.
    • 확장이 어렵습니다.
  2. Google App Engine이나 Heroku와 같은 PaaS를 사용합니다.

    • 구조를 크게 변경하지는 않아도 됩니다.
    • 사용량이 몰릴 때 확장이 용이합니다.
    • 플랫폼에 종속됩니다.
    • 구성에 따라 어느정도 최소 고정 비용이 발생합니다. (예: DB 인스턴스 비용)
  3. 서버리스로 만듭니다.

    • 요청량이 적으면 최소비용을 크게 줄일 수 있습니다.
    • 사용량이 몰릴 때 확장이 용이합니다.
    • 백엔드부터 클라이언트까지 구조를 크게 바꿔야합니다.
    • 플랫폼에 더 종속됩니다.

🔗 선택한 해결책: 서버리스

선택하는 과정에서 가장 중요한 부분은 비용이었습니다. 사이드프로젝트지만 되도록이면 운영을 중단하고 싶지 않았고, 사용량이 적은 상황인만큼 저비용으로 유지할 수 있길 바랐습니다. 따라서 사용한 만큼만 비용이 발생하면서 최소 고정비용이 없는 서버리스 아키텍처를 선택했습니다.

더 구체적으로는 DB를 Firebase Firestore로 대체하기로 결정했고, 그에 맞추어 다른 기능들은 Firebase 또는 Google Cloud Platform에서 제공하는 제품들을 선택했습니다.

🔗 기능별 이전 계획

기능세부 기능마이그레이션
RDB상태 저장클라이언트에서 직접 접근하도록 설계된 Firestore로 대체합니다. 다만 Firestore는 RDB가 아니기 때문에 구조적 수정이 필요합니다.
정합성 유지클라이언트가 유지하며 가능한 한 Firestore 보안 규칙으로 강제합니다.
웹서버인증Firebase 인증을 사용합니다.
상태 접근클라이언트가 (Firestore SDK를 사용하여) 직접 수행합니다.
데이터 검증 · 접근 제어Firestore 보안 규칙을 지정해서 보안 규칙을 통과하는 클라이언트 요청만 실행되도록 합니다.
데이터 표현 등각 클라이언트에서 직접 수행합니다.
비동기 워커강의목록 캐시 갱신Cloud Function으로 실행합니다.
스케쥴러강의목록 갱신 요청 스케쥴링Cloud Scheduler로 대체합니다.
강의 서비스Cloud Run 위에서 실행합니다.
작업 큐Cloud Tasks로 대체합니다.

🔗 작업 계획

  1. iOS 클라이언트에 Firestore를 적용하면서 계획에 문제가 없는지 점검하기
  2. Firestore를 백엔드로 사용하는 하위 호환 서버 작성해서 마이그레이션 과정에 문제 없을지 확인하기
  3. 웹서버를 기능, 데이터별로 나누어 단계적으로 하위 호환 서버 및 Firestore로 이관하기
  4. 업데이트한 클라이언트들 배포하기
  5. 일정 기간 후 하위 호환 서버 운영 종료하기

업데이트하지 않은 이전 버전의 클라이언트(주로 iOS 구버전)가 여전히 있을 수 있어서 하위 호환성을 유지하면서 마이그레이션을 진행해야합니다. 이를 위해서 이전 API를 제공하는 하위 호환 서버를 일정기간 App Engine으로 실행시킬 계획입니다.


중간에 예상치 못했던 문제들을 몇몇 만나기도 했고 현실과 타협하기도 하면서 완벽한 무중단 배포는 실패하긴 했지만 처음 계획에서 크게 벗어나지 않고 어찌저찌 이전시키는 데에는 성공했습니다. 예상치 못하게 만났던 문제들과 그것들을 해결하는 과정을 포함해서 조금 더 자세한 이전 과정은 뒷편으로 이어서 적어보겠습니다.

〈잠자는 서비스 만들기〉 시리즈

잠자는 서비스 만들기

DB 스키마에 대응하는 Firestore 스키마 정의하기

Firebase로 웹사이트 만들기

Firestore 보안 규칙 작성 시작하기

Firestore 보안 규칙으로 필드 고유성 보장하기

Firestore 보안 규칙 기타 예제

Firebase 인증에서 자체 인증 서버 사용하기