0w0

조용한 인터넷 기술 구성

최근 굉장히 인상적이었던 서비스를 보았습니다.

애용하던 개발자 커뮤니티 zenn의 개발자가 만든 size.me입니다.

저도 늘 생각하던 것이 굳이 인터넷에 상호작용이 필요한가? 였습니다.

상호작용의 단계를 없음 - 소극적 상호작용 - 적극적 상호작용 이렇게 구분한다면 sizu.me는 소극적 상호작용에 해당되는데, 해당 서비스를 이용하며 이런 서비스를 따라하더라도 구현하고 싶다 생각하고 있던 와중, 기술 스택을 추정하는 글을 읽었는데, 몇 일 뒤에 제작자 본인이 기술 스택에 대해 설명했기에 오랜만에 번역으로 남겨두고 싶었습니다.

개인적으로 비슷한 서비스를 만들고 싶었기에 두고두고 보려 합니다.


이런 웹 서비스를 출시했기에, 기술적인 이야기를 정리해서 하고자 합니다.

しずかなインターネット

본래 이 서비스는 취미의 연장선 같은 느낌으로 개발을 시작했습니다. 경쟁 상대로 볼 수 있난 note나 하테나 블로그 등 서비스가 확고히 지위를 갖고 있기도 해서, "돈이 되지는 않겠지만, 스스로 취미를 담을 수 있는 것을 만들어보자" 가벼운 마음으로 개발을 시작했습니다.(즐겁게도)

선정 방침

취미라 말했지만, 문장 투고 서비스이므로 유저가 소수여도 장기간 운용해야합니다.

이에 유저 수가 적으면 운용 비용이 수천엔/월 이하, 유저가 많으면 단계적으로 올릴 수 있는 것을 염두해 선정했습니다.

애플리케이션

풀스택 Next.js 애플리케이션을 Cloud Run에 디플로이했습니다. 각 API 엔드포인트는 Next.js의 API Router로 만들었습니다.

Next.js 관련해서 App Router가 아니라 Pages Router를 사용했습니다. 도중에 App Router로 변경해보려 여러 테스트를 해봤지만 이런 저런 일로 인해 넘어갔습니다.

넘어간 이유

지금은 상황이 바뀌었을지 모르겠습니다만 이하의 이유로 App Router를 선택했습니다.

데이터 베이스

PlanetScale

메인 DB는 PlanetScale(MySQL) 사용했습니다. PlanetScale 무료 제공량이 크기에, 이번 서비스라면 $29/월 플랜으로도 상당한 스케일을 커버가능합니다. 플랜 이용 용량을 넘어간다면 넘어간 만큼 종량 과금되기에, 가격이 한 번에 확 상승할 걱정도 없습니다.

PlanetScale은 읽은 행수가 요금에 큰 영향을 주기에, 효율적 쿼리와 적절한 인덱스가 중요합니다.

![요금](https://storage.googleapis.com/zenn-user-upload/45559ec54b9e-20231126.png align="left")

반가운 것은 대시보드 Query Insight에서 각 쿼리 레이턴시나 행수 읽기를 확인하는 것이 가능한 점입니다. (그대로 EXPLAIN 실행 가능!) 이 좋은 체험은 튜링에 동기부여가 되어, SQL 초보자인 저에게도 상당히 좋은 공부였습니다.

난점

유일한 난점은 Cloud Run에서 PlanetScale에 접속할 때, Google Cloud의 DB(Cloud SQL 등)을 사용하는 경우에 비교적 성능이 나빠진다는 점입니다. 개인적 한 검증에서는 양 리전을 동경을할 때에도 1번 쿼리 마다 10~20ms 정도 대기 시간이 길어졌습니다.

이번은 비용을 최우선으로 했기에, 이를 어쩔 수 없다 느끼며 포기, 되도록 DB에서 데이터 페치 빈도를 적게하고 다른 비동기 처리와 동시에 실행하도록 했습니다.

ORM

Prisma를 이용했습니다.

Upstash

일부 조작 rate limit나 일부 캐시는 Upstash의 Redis를 이용하고 있습니다. 이도 Cloud Run부터 시작하면 Google Cloud의 Redis 서비스를 사용하는 경우와 비교해 어떻게해도 레이턴시가 커졌지만 어쩔 수 없기에 이를 눈 감았습니다.

CDN

CDN에는 비용면에서 Cloudflare를 이용했습니다. CSS나 JS, 이미지 같은 정적 파일에는 긴 캐시를 설정, 퍼지가 필요하다면 리소스 URL를 변경해(혹은 일괄 퍼지) 운용하고 있습니다.

Cloudflare Workers

Cloud Run에 커스텀 도메인을 묶으면 레이턴시가 발생하는 문제의 대책으로 Cloudflare Workers에 커스텀 도메인을 설정, CLoud Run으로 리퀘스트 (정적 파일 제외)를 프록시하도록 했습니다.

Cloud Run 도쿄 리전에 커스텀 도메인을 매핑하면 어느 정도 레이턴시가 발생하는가

Cloud Load Balancing를 사용하면 response가 빨라지지만, 학습 비용과 성능 밸런스를 고려해 이런 형태가 되었습니다.

정적 파일용 URL의 preconnect

정적 파일용 URL에는 sizu.me와 다른 도메인을 이용합니다. 이는 Cloudflare 요금제 제약에 추가, 다른 도메인에서 배포함으로 쓸모없는 Cookie (특히 인증 관련한 Cookie)가 정적 파일에 리퀘스트에 부여되는 것을 방지하기 위한 목적도 있습니다.

103 Early Hints 정적 파일용의 URL를 preconnect 해 봤다

오브젝트 스토리지

요금면에서 효율적인 Cloudflare R2를 사용하고 있습니다. R2는 현상 버저닝을 지원하지 않아 실수로 파일을 삭제했을 때 손쓸 방도가 없다는 점이 걱정입니다.

이에 혹시 모르니 정기적으로 Cloud Run Jobs에서 rclone 실행, R2 상 파일을 Cloud Storage(가장 저렴한 archive 클래스의) 버전업하고 있습니다. 상세한 것은 다음 글을 참조해주세요.

Cloudflare R2의 오브젝트 스토리지를 rclone에서 Cloud Storage에 자동 백업하기

과금

Stripe 이용하고 있습니다. Checkout Session의 URL를 발행해서, 유저에 Stripe의 웹사이트 상 과금 절차를 진행하도록 했습니다. 서비스의 DB에는 과금에 관련한 정보는 거의 대기없이, "구독이 액티브인가 어떤가" 만 캐시 목적으로 유지하도록 하고 있습니다. (Webhook 핸들러에 동기하고 있습니다)

로깅, 에러 통보

Cloud Run에는 console.log에 의한 구조적 로그를 출력할 수 있습니다.

Cloud Run에서 구조적 로그를 출력하는 방법

에러가 발생할 때에는, 로그 레벨(severity)에 대응해 2종류의 통지가 Slack에 날아오도록 설정했습니다.

통지 설정 방법에 대해서는 이하 글을 참고했습니다.

Google Cloud 로그베이스 알람 기능을 사용해 에러 로그가 나오면 Slack에 통지하기

시크릿 관리

서버 런타임 참조하는 시크릿은 Secret Manager으로 관리하고 있습니다. 의외로 월 수백엔 사용됩니다.

비동기 처리, 배치 처리

Cloud Run에는, 메인 서비스에 추가해, Google Cloud 내부에서 리퀘스트만을 처리하는 태스크용 서비스를 디플로이했습니다.

시간이 걸리는 일부 처리는 Cloud Tasks를 사용해 태스크용 서비스에 비동기 처리하도록 하고 있습니다. 배치 처리는 Cloud Scheduler에서 정기적으로 태스크용 서비스에 HTTP 리퀘스트를 보내는 형태로 실행하고 있습니다.

메일 보내기

요금과 신뢰성 밸런스로 AWS SES를 선택했습니다. 이용 제한 완화 신청이나 SPF/DKIM/DMARC 설정 수고가 걸리지만, 좋은 공부가 되었습니다. 실은 Send Grid나 Resend를 사용하고 싶었지만 별 수 없습니다.

여담으로 메일 보내기처리는 Cloud Scheduler에서 호출하고 있습니다. Cloud Scheduler는 at-least-once(적어도 1회 실행)라는 사양이 있기에, 중복 실행처리하는 가능성도 있습니다. 동일 메일을 2통 보내는 것은 피하고 싶기에 메일 전송 전에 Redis에서 배타제어를 하고 있습니다.

CI/CD

풀리퀘스트 작성할 때는 테스트나 Linting이 GitHub Actions에서 실행되도록 실행하고 있습니다. main 브런치에 머지하면 Cloud Build로 동작하고, Cloud Run에서 자동적으로 디플로이 됩니다.

IaC

Google Cloud의 각 서비스 설정에 Terraform으로 관리하고 있습니다. 코드로 관리하는 함으로 안심감도 있습니다. (Terraform 매력은 Zenn 개발 팀에 있을 때 waddy_u님에게 배웠습니다)

인증

NextAuth와 Firebase Authentication를 조합해서 사용하고 있습니다. 단, Google 로그인에서 Firebase Auth(signInWithRedirect)와 Next Auth를 조합하면, 유저 대기 시간이 5초이상 걸리기도해서, 체감이 상당히 나쁘므로 Google 로그인 관련해서 Firebase Authentication를 경유하지 않고 사용하고 있습니다.

이는 개인적 억측이지만, Firebase Auth의 signInWithRedirect의 대기 시간이 이전보다 길어(?)진 것은, 서드파티 Cookie가 막힌 상태에서 Firebase 도메인 간 인증을 이뤄지기에 뒤에서 신경쓸 필요가 있어서가 아닐까? 예쌍하고 있습니다. (참조: 서드파티 Cookie를 블록하는 브라우저에서 signInWithRedirect를 사용하는 경우)

데이터 페치 / 상태 관리

클라이언트에서 API 리퀘스트 부분에는 trpc를 사용하고 있습니다. trpc를 선택한 이유는 TypeScript의 타입 보완 등 체험이 좋았기 때문입니다. trpc는 react-query 래퍼가 되는 패키지도 제공하고 있으며, 이를 사용하면 데이터 페치, 업데이트 처리를 매우 즐겁게 작성할 수 있습니다.

클라이언트에서의 상태 관리도 되도록 react-query에서 마무리짓도록 하고 있습니다. 데이터 페치가 필요없는 일부 상태관리는 jotai를 사용하고 있습니다. jotai는 번들 크기가 작아 좋습니다.

이미지 리사이징

앞으로 Next.js에서 다른 프레임워크로 이행하는 가능성도 염두해, next/image나 ISR 등의 스페셜한 느낌의 기능은 되도록 사용하지 않도록 하고 있습니다. 이미지 리사이징에 관련해서는 API 전용의 엔드포인트를 준비하고, 쿼리 문자열로 이미지의 URL나 설정값을 전달하면 리사이징된 이미지를 반환하도록 했습니다.

# 이미지
/api/resize-image/서명?url=본이미지의URL&width=200

URL에 포함된 서명은 서버사이드에 쿼리 문자열에서 생성, 쿼리 문자열이 변조되면 에러를 반환하도록 했습니다.

동적 OG 이미지 생성

유저 OG 이미지는 satori를 사용해 생성했습니다. 여기도 URL에 서명을 포함하도록 했으며, 제 3자가 이미지를 조작하는 것을 못하도록 했습니다.

여담으로 X의 summary_large_image 형식 링크카드 표시가 미묘해졌으므로 최종적으로 summary 형식의 정방형 이미지 생성만 했습니다.

지금부터는 X 링크 카드를 작게하는 것이 좋아보임

에디터

편집용 에디터에는 TipTap를 사용했습니다. 상세한 것은 문서에 있으며, 코드가 읽기 쉽고, 확장도 하기 쉬워 (정 뭐하면 내부적으로 이용하고 있는 ProseMirror API에 직접 접근 가능) 마음에 들었습니다.

테스트

백엔드를 메인으로 작성했습니다. (지금 세어보니 작성된 테스트 케이스는 530개 입니다) 라이브러리는 VitestReact Testing Library를 사용했습니다. DB를 Mock하기 싫었기에 테스트용 DB를 준비했고, 실행전에 DB 리셋하도록 했습니다.

테스트용 데이터 삽입에는 prisma-fabbrica를 사용했습니다. prisma-fabbrica 덕분에 테스트 코드가 꽤나 간결해졌다 생각합니다.


이런 느낌으로 등장인물이 많은 기술 구성입니다. 비용을 신경쓰지 않았다면 더욱 빠르게 출시 & 속도를 더욱 빠르게 & 더 단순한 구성을 할 수 있었을 것 같다 생각하지만, 이 부분은 어쩔 수 없습니다.

발전하고 기술에 슬그머니 올라타보거나, 조금씩 개량하면 좋겠다 생각합니다.


마지막으로 위의 글과 직접적인 관계는 없지만, 개발에 관련한 글을 첨부해서 두고두고 같이 보려합니다.