본문으로 건너뛰기

© 2026 Molayo

Qiita헤드라인2026. 06. 02. 18:53

개인 개발로 게시판과 SNS가 융합된 서비스 『Pomate』를 만들었습니다

요약

익명 게시판의 특성과 현대적 SNS의 UX를 결합한 서비스 'Pomate'의 개발 사례를 소개합니다. Cloudflare Workers, Hono, CockroachDB 등을 활용하여 비용 효율적인 아키텍처를 설계했습니다.

핵심 포인트

  • 익명과 닉네임 모드를 자유롭게 전환하는 화제 중심 SNS 구현
  • Cloudflare Workers와 React Router v7을 이용한 에지 SSR 환경 구축
  • WebSocket 지원을 위해 백엔드는 Railway(Node.js)로 분리 운영
  • 개인 개발을 고려한 월 $15~$30 수준의 저비용 기술 스택 선정

있을 법하지만 없었던, 익명과 닉네임의 자유로운 전환을 주축 컨셉으로 하여, 일본의 과거 커뮤니티 중심이었던 익명 게시판의 흐름을 계승하면서도,

현재 주류가 된 모던한 SNS처럼 사용할 수 있는 사용자 경험(UX)을 구현해 보았습니다.

URL: https://pomate.cc

이 기사에서는 Pomate의 컨셉과 이를 실현하기 위한 기술 스택(Tech Stack) 및 설계에 대해 자세히 써 내려가겠습니다.

Pomate는 **「반익명형 게시판식 SNS」**입니다.

  • 5ch(5ちゃんねる)와 같은
    익명 게시글 작성 - X(Twitter)와 같은 SNS 계정 연동을 통한
    실명(닉네임) 게시글 작성

이 두 가지를 동일한 계정으로 자유롭게 전환할 수 있다는 점이 최대 특징입니다.

X나 Instagram은 「누구를 팔로우할 것인가」가 경험의 중심에 있는 사람 기반의 SNS입니다. 반면, Pomate는 「어떤 화제로 교류하고 싶은가」를 중심으로 한 화제 기반의 SNS를 지향하고 있습니다.

게시판이라고 하면 5ch와 같은 언더그라운드 이미지가 떠오르기 쉽지만, BBS라는 형식의 장점은 모르는 사람과도 플랫하게 같은 화제로 즐겁게 이야기할 수 있다는 점이라고 생각합니다. 결코 무법지대라서 좋다는 이야기가 아닙니다.

한편, SNS의 장점은 자신의 아이덴티티를 가지고 지속적인 발신을 할 수 있다는 것입니다.

Pomate는 이 두 가지를 사용자가 상황에 따라 선택할 수 있는 형태로 제공합니다.

커뮤니티(판) 단위의 스레드— 화제별로 나누어진 그룹. 누구나 판을 생성 가능
익명/실명의 심리스(Seamless)한 전환— 버튼 하나로 게시 모드 변경
스레드 관리 권한— 스레드 생성자가 자신의 스레드 내에서 모더레이션(Moderation)을 수행 가능
리액션 기능— 이모지를 통한 리액션, 특수 리액션으로 스타 획득
타임라인— 모든 판의 최신 게시글을 모아서 표시 (추천/최신 전환)
그리기 게시글— 레이어 대응의 본격적인 페인트 툴 내장
실황 스레드— 채팅 형식의 실시간 게시글

이곳은 Zenn이므로 기술적인 내용 위주로 작성하겠습니다. 「서비스에 대해서만 알면 된다」는 분은 note의 소개 기사를 참고해 주세요.

카테고리채택 기술
프레임워크React Router v7 (SSR)
...
카테고리채택 기술
------
프레임워크Hono
...
  • 프론트엔드는 Cloudflare Workers입니다. React Router v7의 SSR을 에지(Edge)에서 구동하고 있습니다. - 백엔드는 Railway에서 구동하고 있습니다. 원래는 백엔드도 Workers로 통일하고 싶었지만, WebSocket (Socket.IO)를 사용하기 위해 Node.js 런타임이 필요했기에 어쩔 수 없었습니다. - DB는 CockroachDB Cloud를 선택했습니다. PostgreSQL을 사용할 수 있다는 점과 가격만을 보고 결정했습니다.

개인 개발인 만큼, 러닝 코스트(Running Cost)를 최대한 억제할 수 있는 구성으로 선정했습니다. 현재 규모에서는 다음과 같이 예상됩니다.

서비스플랜비용 예상 (월)비고
Cloudflare WorkersPaid$5무료 범위로도 충분하지만 SSR의 CPU 시간을 고려하여 유료 플랜 사용
...합계 (Hobby 구성)$15 〜 $30 정도개인 개발·검증 단계 상정
합계 (Pro 구성)$30 〜 $60 정도본 서비스 운영·사용자 증가 후 상정

이미지 전송에 Cloudflare R2를 선택한 이유는, S3 호환이면서도 이그레스(Egress, 전송량) 요금이 발생하지 않기 때문이며, 이미지가 많아지기 쉬운 게시판 계열 서비스와 궁합이 좋습니다.

DB, Redis, 스토리지(Storage)는 기본적으로 무료 범위 혹은 종량제(Pay-as-you-go) 방식이며, 사용자가 늘어나도 비례해서 늘어나는 형태이므로 개인 개발에서도 파산하지 않는 구성입니다.

Pomate는 Socket.IO에 의한 상시 접속이 다소 무거운 부하 요인이 된다는 점만 주의가 필요하지만, SSR은 Cloudflare Workers로 분산하고 이미지는 R2에서 직접 전송되므로, 백엔드(Railway) 이외에는 병목(Bottleneck)이 발생하기 어렵습니다. 따라서 실질적인 상한선은 Railway의 인스턴스 리소스에 의해 결정됩니다.

구성Railway 리소스예상 MAU동시 접속 수 기준비고
Hobby8GB RAM / 8 vCPU (공유)~ 1만 정도수백 정도단일 인스턴스. 액세스 집중 시 응답 지연 가능성
Pro32GB RAM / 32 vCPU5만 ~ 20만수천 정도수평 확장 (Horizontal Scale) 가능. 복수 레플리카로 중복성 확보 가능

이 수치는 어디까지나 기준일 뿐이며, 실제로는 "1인당 게시 및 열람 빈도", "동시 실황 스레드 수" 등에 따라 크게 변동합니다. Pomate와 같이 실시간 요소가 있는 서비스는 MAU보다 동시 접속 수가 리소스 소비의 병목(Bottleneck)이 되기 쉬우므로, 그 부분을 먼저 살피며 스케일링(Scaling) 판단을 해나가는 것이 현실적입니다.

Pomate의 경험을 한마디로 표현하자면, "스레드에 글을 쓰면 그것이 그대로 SNS의 타임라인에 흐른다"는 것입니다.

기존의 게시판은 게시판(Board) → 스레드(Thread) → 답글(Reply)이라는 계층 구조 안에서 완결되었습니다. 어떤 스레드에서 재미있는 대화가 일어나더라도, 그 스레드를 직접 보러 가지 않는 한 알 수 없습니다. 반면, SNS의 타임라인은 모든 게시물이 플랫(Flat)하게 흘러오기 때문에 발견성은 높지만, 화제별로 모아보는 맛이 없습니다.

Pomate에서는 이 두 가지를 스레드 내의 답글이 타임라인에도 흐른다는 설계로 연결했습니다.

사용자가 게시판의 스레드에 작성한 답글은 해당 스레드의 트리(Tree) 구조로 볼 수 있을 뿐만 아니라, 홈 타임라인에도 하나의 카드로 등장합니다. 타임라인상에서는 어느 게시판의 어느 스레드에 작성된 답글인지 표시되며, 카드를 탭하면 해당 스레드의 문맥(Context)으로 들어갈 수 있습니다.

이를 통해 "특정 화제에 대해 깊게 이야기하고 싶을" 때는 스레드에 들어가 토론에 참여하고, "무슨 재미있는 일이 없나" 싶을 때는 타임라인을 훑어보는, 두 가지 경험이 심리스(Seamless)하게 연결됩니다.

타임라인에는 "추천"과 "최신" 두 개의 탭이 있으며, 추천 탭에서는 즐겨찾기로 등록한 게시판이나 스레드의 게시물이 스코어링(Scoring)을 통해 상위에 표시됩니다. 게시판처럼 화제를 깊이 파고들면서도, SNS처럼 새로운 화제를 만날 수 있는 이 양면성이 Pomate의 핵심 축입니다.

여기서부터는 Pomate만의 "반익명성(Semi-anonymity)"을 실현하기 위해 고안한 부분들을 몇 가지 소개하겠습니다.

Pomate의 근간은 "동일한 계정으로 익명과 실명 모두 게시할 수 있다"는 점입니다. 이를 성립시키기 위해서는 데이터 모델과 API 설계 양쪽 모두에서 배려가 필요했습니다.

  • 게시물 레코드에는 isAnonymous 플래그와 인증된 사용자의 내부 ID(userId)를 갖게 함 - 내부 ID는 익명·실명을 불문하고 API 응답에서 항상 제거함 (프론트엔드에는 전달되지 않음)
  • 익명 게시물의 경우, 이에 더해 작성자 프로필(표시 이름, 아바타, 외부 연동 ID)도 null 처리하여 반환

이를 통해 "동일한 사용자의 익명 게시물과 실명 게시물을 외부에서 대조할 수 있는 수단이 존재하지 않는" 상태를 만들었습니다.

익명성을 진지하게 설계하다 보면 반드시 맞닥뜨리게 되는 것이 바로 오라클 공격 (Oracle Attack) 입니다.

Pomate에는 개별 사용자를 NG(뮤트, Mute) 할 수 있는 기능이 있습니다.

이 NG 기능은 인증된 사용자의 내부 ID(userId)를 사용하여 수행함으로써, 글을 쓴 사용자가 익명이든 실명이든 상관없이 숨길 수 있습니다.

다만, 이를 그대로 구현하면 한 가지 문제가 발생합니다.

예를 들어 "어떤 실명 사용자를 NG(뮤트)에 넣었더니, 다른 익명 게시물도 동시에 사라졌다"라는 현상이 발생하면, 관찰자는 "저 익명 게시물은 저 사람이었구나"라고 추측할 수 있게 됩니다.

Pomate에서는 이를 기술적으로 방지하기 위해, NG 등록 시의 상태와 게시 시의 상태가 일치하는 경우에만 필터링 하도록 설계했습니다.

// NG 사용자 판정 (간략 버전)
function matchesNgUser(post, ngUser) {
// NG 등록 시의 익명 상태와 게시물의 익명 상태가 일치하지 않으면 필터링하지 않음
...

즉,

  • 실명 게시물에서 NG를 등록했다 → 해당 사용자의 실명 게시물만 숨겨짐
  • 익명 게시물에서 NG를 등록했다 → 해당 사용자의 익명 게시물만 숨겨짐

익명과 실명의 NG 리스트가 완전히 분리되어 있기 때문에, 교차 참조(Cross-reference)를 통한 특정은 기술적으로 불가능합니다.

물론, 경우에 따라서는 동일한 사용자를 두 번 NG 처리해야 할 수도 있지만, 아마도 문제를 일으킬 만한 사용자는 익명 게시만 이용할 것이라고 생각되므로 그 정도의 영향은 없을 것이라 봅니다.

익명 게시판의 장점 중 하나는 "같은 스레드 안에서는 누구의 발언인지 어느 정도 알 수 있다"는 경험입니다. Pomate도 이를 실현하고 싶지만, 스레드를 넘나들며 추적당하면 익명성이 붕괴됩니다.

그래서 채택한 것이 스레드 ID와 날짜를 포함하는 해시 기반의 임시 ID입니다.

// 간략 버전
const idSource = `user:${userId}:${topicId}:${dateString}`;
// SHA-256 해시를 7자리의 영숫자 ID로 변환 (구현 시에는 자작(self-post) 탐지를 위해 IP 유래 자리수도 섞고 있음)
...

동일 스레드·동일 날짜: 같은 사용자는 같은 ID가 됨 → 대화의 흐름을 파악할 수 있음
다른 스레드 또는 다음 날: 같은 사용자라도 ID가 바뀜 → 횡단 추적을 방지

이 설계를 통해 "스레드 내의 문맥 파악"과 "장기적인 행동 추적 방지"를 양립하고 있습니다.

솔직히 이 부분이 가장 큰 과제이자 약점입니다. 아래는 여러 가지 시행착오를 겪은 이력입니다.

Zustand의 persist

미들웨어로 localStorage에 상태를 저장하고 있습니다만, SSR(Server-Side Rendering) 시에 서버와 클라이언트의 상태가 불일치하면 하이드레이션 에러(Hydration Error)가 발생합니다. 각 persist 스토어에 skipHydration: true를 설정하고, 클라이언트 마운트 후에 일괄 rehydrate()하는 방식으로 해결하고 있습니다.

// 스토어 정의
export const useSettingsStore = create<SettingsStore>()(
persist(
...

무거운 컴포넌트는 React.lazy + Suspense로 분할하여 초기 번들 사이즈(Bundle Size)를 줄이고 있습니다.

const ScrollJumpButtons = lazy(
  () => import("@/components/layout/ScrollJumpButtons"),
);
...

그림 그리기 에디터(Konva.js), 관리자 화면, 설정 페이지 등 첫 화면 표시에 불필요한 것들은 모두 지연 로딩(Lazy Loading)으로 처리했습니다.

게시판 목록이나 스레드 데이터는 모듈 레벨에서 캐시를 유지하며, SWR(Stale-While-Revalidate) 패턴으로 관리하고 있습니다. 캐시가 신선(Fresh)하다면 즉시 표시하고, 오래되었다면 백그라운드에서 재취득하여 교체합니다.

const cacheStatus = getCacheStatus(cache.timestamp, config);
if (cacheStatus === "fresh") {
// API를 호출하지 않고 즉시 표시
...

스레드 데이터도 마찬가지로 Zustand 스토어에 캐시하여, 동일한 스레드 재방문 시에는 API 호출 없이 표시할 수 있습니다.

빈번하게 액세스되는 API 응답(게시판 목록, 스레드 상세, 위젯 데이터 등)은 Upstash Redis에 캐시하고 있습니다. DB 쿼리 수를 대폭 줄여 응답 속도를 향상시켰습니다.

정적 에셋(JS, CSS, 이미지, 폰트)의 캐시에는 Service Worker를 사용하고 있습니다. 두 번째 액세스부터는 네트워크 요청 없이 에셋을 반환할 수 있어 체감 속도가 크게 향상됩니다.

게시판에 그림을 직접 게시할 수 있는 기능을 Konva.js로 구현했습니다.

  • 레이어 대응 래스터 페인트(Raster Paint)
  • 펜, 지우개, 페인트 브러시, 각종 블렌드 모드(Blend Mode)
  • 스레드 생성·답글 폼에서 직접 전환할 수 있는 별도 페이지로 구현
  • 그림 완료 후에는 Zustand 스토어에 이미지 Blob과 프로젝트 데이터(gzip 압축 레이어)를 유지하여, 원래의 게시 폼으로 돌아왔을 때 자동으로 첨부됨

별도 페이지로 만든 이유는 모바일에서 모달(Modal) 내에 페인트 도구를 두면 UI가 너무 복잡해지기 때문입니다. "페이지로 열어서, 저장하면 원래 위치로 돌아간다"는 동작이 모바일에서도 사용하기 더 쉬워졌습니다.

돌아왔을 때 입력 중이던 본문이 리셋되지 않도록, 스토어에 폼의 초안(Draft)도 함께 저장하는 메커니즘을 넣었습니다.

Pomate에는 "실황 스레드"라는 특수한 스레드 타입이 있습니다. 일반적인 답글 형식이 아닌, 채팅 형식의 UI로 영상을 보면서 실시간으로 코멘트할 수 있습니다.

King gnu「白日」

핵심은 플레이리스트 공유 기능입니다. 스레드에 참여하고 있는 모든 사람이 동일한 영상을 동일한 재생 위치에서 시청할 수 있습니다.

영상 동기화 메커니즘:

「모두가 동일한 영상의 동일한 위치를 보고 있다」는 상태를 만들기 위해, 서버 측에 **권위적인 재생 시각 (Authoritative Playback Time)**을 갖도록 설계했습니다.

재생 상태(videoState, currentVideoTime, currentVideoIndex)는 DB의 스레드 레코드에 저장되며, 서버 측의 SimpleVideoTimer 클래스가 1초 간격으로 currentVideoTime을 인크리먼트(increment)합니다.

┌─────────────────────────────────────────────────────┐
│ 서버 (SimpleVideoTimer) │
│ │
...

클라이언트 측 플레이어는 5초마다 전달되는 video_sync_update 이벤트로 서버의 재생 시각과 자신의 재생 위치를 비교하며, 차이가 크면 시크(seek)하여 보정합니다. WebSocket 통지를 매초가 아닌 5초 간격으로 설정한 이유는 대역폭을 절약하기 위함이며, 그 사이에는 각 클라이언트가 자율적으로 재생을 진행합니다.

중간에 참여한 사용자는 REST API(GET /video-sync/:topicId/state)를 통해 현재 재생 상태를 가져오며, 그 시점의 currentVideoTime부터 재생을 시작하기 때문에 중간부터라도 모두와 동일한 위치에서 시청할 수 있습니다.

영상 종료 및 자동 전환: 서버의 tick() 내에서 currentVideoTime >= videoDuration을 감지하면, VideoCompletionHandler가 다음 영상으로 자동 전환합니다. 여기에는 멱등성 가드(Idempotency Guard, 5초간의 TTL이 포함된 처리 중 플래그)를 넣어, 타이머의 중복 실행이나 네트워크 지연으로 인한 이중 전환을 방지하고 있습니다.

스킵 투표: 시청자가 「이 영상을 스킵하고 싶다」고 투표할 수 있으며, 시청자 수의 과반수(최소 2표)에 도달하면 자동으로 다음 영상으로 넘어가는 민주적인 스킵 기능도 구현했습니다.

  • YouTube / Twitch / niconico 동영상 URL 대응
  • 참여자는 누구나 플레이리스트에 영상을 추가할 수 있음 (삭제는 추가한 본인 또는 스레드 생성자만 가능)
  • 1인당 추가 가능한 영상 수의 상한은 스레드 생성 시 설정 가능. 동일 영상의 중복 방지나 n분 이상의 영상은 차단하는 등의 설정 가능 (무제한도 가능)

영상 모드:

실황 스레드는 생성 시 3가지 모드 중 하나를 선택할 수 있습니다.

  • single: 하나의 영상을 고정하여 표시 (제품 발표회나 장편 영상 등)
  • multiple: 플레이리스트 기능 포함 (모두가 영상을 가져와서 사용하는 방식)
  • normal: 영상 없음, 채팅만 가능 (잡담 룸 같은 방식)

게시판의 특성상 200건이 넘는 장문의 스레드가 드물지 않습니다. 모바일로 열었을 때 「로딩이 끝나지 않는다」거나 「스크롤이 끊긴다」는 체감을 어떻게 억제할지가 SNS 스타일의 UI와 양립하는 데 있어 가장 큰 난관이었습니다.

처음에는 React Router v7 + SSR의 힘을 빌려 「화면에 표시되는 범위의 DOM만 생성하는」 가상 스크롤(Virtual Scroll) 방식을 채택했으나, 특정 답글로의 점프, 스크롤 위치 복원, 모바일의 관성 스크롤 경험과의 상성 문제 등으로 운영하기 어려워 결국 포기했습니다. 지금은 **「모든 항목을 DOM에 출력하되, 브라우저의 최적화 기능과 정합성이 맞는 방식으로 작성하여 가볍게 만든다」**는 방식에 집중하고 있습니다.

답글의 Markdown → HTML 변환은 원래 클라이언트 사이드에서 매번 수행했습니다. 이를 서버 사이드로 옮기고, 생성된 HTML을 content_html 컬럼에 저장하는 방식으로 변경했습니다.

  • POST 시 buildContentHtml()을 통해 isomorphic-dompurify의 새니타이즈(sanitization)를 포함하여 HTML을 생성 및 저장
  • 가져올 때는 DB의 content_html을 그대로 반환
  • 파싱 로직을 변경했을 때는 parse_version을 bump하여 지연 재생성 (이전 버전의 HTML은 API 액세스 시 lazy하게 업데이트)
  • 기존 레코드는 backfillContentHtml.ts로 일괄 백필(backfill)

이를 통해 클라이언트는 dangerouslySetInnerHTML로 표시하기만 하면 되며, 200건의 답글 초기 렌더링 중에 실행되던 Markdown 파싱 처리가 사라졌습니다.

이미지 게시 시 sharp

이미지 게시 시 sharp로 치수(width/height)와 thumbhash의 base64를 취득하여 DB에 저장하고 있습니다.

width/height<img 속성에 전달할 수 있으므로, 이미지 로드 전부터 올바른 종횡비로 레이아웃이 확정(CLS=0)

thumbhash는 LQIP (Low Quality Image Placeholder)로서 이미지 로드 중 placeholder로 사용 (흐릿한 이미지가 잠시 나타나는, Twitter와 같은 방식)

  • 메인 이미지는 WebP로 변환, 썸네일(200px)과 중간 크기(800px) 변형(variant)을 생성
  • 기존 이미지는 배치 작업(batch job)으로 백필(backfill)

스크롤 중 이미지 로드로 인한 레이아웃 점프가 사라졌고, 썸네일로 교체함으로써 대역폭도 절약할 수 있게 되었습니다.

JSON 내용도 줄일 수 있는 부분을 줄여 나갔습니다.

authorauthorProfile의 중복을 폐지하고 author만 남김

null 또는 빈 배열 필드는 omitNullish 헬퍼로 생략

userId (내부 ID), isActive, deletedAt 등 프론트엔드에 불필요한 내부 필드 제거

타임라인 API에서 아이템당 1336B → 843B (약 37% 감소), /full 엔드포인트에서는 482B/item까지 줄어들었습니다. 압축 후의 차이는 더 작아지겠지만, 압축 전의 메모리 사용량과 파싱 시간이 줄어들기 때문에 은근히 효과가 있습니다.

미미해 보이지만 효과가 컸던 것은 Hono 백엔드에 compress() 미들웨어를 추가한 것입니다.

import { compress } from "hono/compress";
app.use("*", compress());

그전까지 api.pomate.ccContent-Encoding 없이 생(raw) JSON을 반환하고 있었습니다. 200건 응답의 응답 크기는 200KB 전후였으므로, Brotli를 적용하면 20-30KB로 줄어듭니다. 프론트엔드인 pomate.cc (Cloudflare Workers)는 원래 자동 압축이 되고 있었지만, API (Railway 상의 Node.js + Hono)는 조치가 필요했습니다. Node 18+의 CompressionStream을 사용하므로 추가 의존성 없이 해결할 수 있습니다.

lucide-react는 각 사용처에서 <svg> + <path> × 4-5개를 인라인으로 출력합니다. 한 응답에 아이콘이 5개 있다면, 200개 응답에서 약 5,000개의 노드가 아이콘만으로 점유된다는 계산이 나옵니다.

그래서 자주 사용하는 21개의 아이콘을 하나의 스프라이트 SVG에 <symbol>로 집약하고, 각 사용처에서는 <svg><use href="#i-name"/></svg>라는 2개의 노드만으로 참조하는 방식으로 전환했습니다.

// 앱의 루트에서 한 번만 렌더링
<svg style={{ display: 'none' }}>
<defs>
...

ReplyItemTimelineItemCardlucide-react 이용을 Icon 컴포넌트로 교체하는 것만으로도 노드 수와 HTML 크기가 눈에 띄게 줄었습니다.

스레드 맨 앞의 이미지(OP의 이미지)가 LCP (Largest Contentful Paint)가 되기 쉬우므로, SSR의 meta() 함수에서 <link rel="preload" as="image" fetchpriority="high">를 출력하도록 했습니다.

const lcpImage = thread.mediumUrls?.[0] || thread.imageUrls?.[0];
if (lcpImage) {
return [
...

브라우저는 <img가 DOM에 나타나기 전부터 이미지 다운로드를 시작할 수 있기 때문에, LCP가 100-300ms 정도 단축됩니다.

Tailwind의 본체 CSS는 <Links />를 통해 render-blocking으로 읽히기 때문에, 그동안 화면이 완전히 하얗게 보일 수 있습니다. 이를 방지하기 위해 최소한의 스타일(배경색, 폰트, 이미지의 max-width)을 <style로 head에 인라인화했습니다.

<style>
html,
body {
...

다크/라이트 판정은 하이드레이션(hydration) 전에 동작하는 themeInitScript

<html class="dark">를 붙이는 처리와 동기화됩니다. 이로써 하얀 화면 깜빡임(flash) → 갑작스러운 화면 전환의 경험이 사라졌습니다.

결과론적인 이야기지만, **"경량화 기술을 추가하는 것보다, 기능을 추가하지 않는 것이 압도적으로 효과적이다"**라는 당연한 이야기였습니다. content-visibility도 SVG sprite도, 결국 per-reply의 무거움을 줄이기 위한 기술일 뿐이며, DOM의 본질적인 무거움(요소 수 × 노드 수)은 크게 변하지 않습니다.

지금까지 상당한 경량화를 시도해 보았지만, 현재로서는 아직 쾌적하다고 생각하지는 않습니다...

이 부분은 설계 시점에서 성패가 갈릴 것 같습니다. SSR(Server Side Rendering)로 만든 것이 잘못되었다고는 생각하지 않지만, 근본적인 설계를 틀렸을지도 모릅니다... (이미 돌이킬 수 없으니 못 본 척하겠습니다)

반익명 SNS에 있어 보안은 "있으면 안심되는 것"이 아니라 "없으면 서비스가 성립되지 않는" 것입니다. 익명성이 깨지는 시점에 신뢰가 붕괴하기 때문에, 다층 방어 (Defense-in-Depth) 관점에서 설계하고 있습니다.

인증 계층: OAuth2 → BetterAuth → 세션 관리
인가 계층: RBAC → 레벨 기반 권한 → 스레드 BAN → 타임아웃
통신 계층: HTTPS → HSTS → CSP → CSRF → CORS
...

전부를 다 쓸 수는 없으므로, 특히 개인 개발에서 의식했던 부분을 몇 가지 소개하겠습니다.

개인 개발 서비스에 있어, 수신되는 시점에 인프라 비용이 폭발하는 금전적 리스크가 있는 DDoS는 최우선으로 대책을 세워야 하는 보안 요소입니다. Pomate에서는 이를 다층적으로 방어하도록 설계했습니다. 다만, 기본적으로는 Cloudflare 측에 맡기고 있는 느낌입니다.

Cloudflare에 의한 네트워크 계층 보호:

프론트엔드를 Cloudflare Workers에 올리고, 이미지 전송도 Cloudflare R2를 통해 수행하고 있기 때문에 모든 트래픽이 자동으로 Cloudflare 네트워크를 경유합니다. 이를 통해 L3/L4 레이어의 DDoS 공격 (SYN Flood, UDP 증폭 공격 등)은 Cloudflare가 무료로 흡수해 줍니다 (신의 한 수).

Cloudflare WAF와 Rate Limiting (속도 제한) 규칙도 병용하여 애플리케이션 계층의 공격에도 어느 정도 대응하고 있습니다.

백엔드의 직접 노출 방지:

프론트엔드는 앞서 언급한 대로 Cloudflare가 보호해 주므로, 취약점이 있다면 이쪽입니다. Railway 측의 백엔드 API는 오리진(Origin)을 직접 호출할 수 없도록 Cloudflare를 경유하여 액세스하도록 설정했습니다. Railway의 URL을 알고 있는 공격자가 직접 호출하면 보호가 작동하지 않기 때문에, 오리진 측에서는 Cloudflare를 경유한 요청만 수락하도록 제어 로직을 넣었습니다.

Cloudflare Turnstile을 통한 봇 방지:

게시물 작성 등의 액션에는 Cloudflare Turnstile을 도입했었으나, 일부 환경에서 자동으로 체크가 되지 않는 등 UX를 해치는 경우가 많아 게시할 때마다 요구하는 방식은 폐지했습니다.

대신, 로그인 없이 전송할 수 있는 문의 양식(Contact Form)에 대해서는 Turnstile을 구현했습니다.

애플리케이션 계층의 Rate Limiting (속도 제한):

API 단위로 Rate Limiting을 구현하고 있습니다. 게시 관련 API는 IP + 사용자 단위, 읽기 관련 API는 IP 단위 등 용도에 따라 임계치를 설정했습니다. 카운터는 애플리케이션의 인메모리(In-memory)에 유지합니다.

비용 폭발 방지:

AI 자동 생성 콘텐츠

본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0