Performance & Architecture Audit

rinda-landing 백엔드 · DB · 어드민 성능 전수분석

어드민 페이지가 느린 근본 원인을 프론트엔드·백엔드·DB 쿼리·환경설정·의존성 전 영역에서 코드 근거(file:line)와 함께 추적하고, 2026년 기준 최신 패턴으로 개선안을 제시한다.

대상 rinda-landing · Next.js 15.5.15 (Pages Router) · React 19 분석일 2026-06-19 방법 4-에이전트 병렬 정적분석 + 마이그레이션 SQL 대조
한 줄 결론 — 어드민이 느린 이유는 DB가 할 계산을 앱(서버·브라우저)이 대신 떠안고 있어서다.
● 현황 (느림)

“오늘 방문자 몇 명?”을 알려고 방문자 기록 5만 건을 통째로 서버로 퍼와 서버 코드가 하나씩 센다.
3~5초 · 데이터 10배 늘면 10배 느려짐 · 1000건 넘으면 잘려서 수치도 틀림

☕ 비유 — 카페 사장이 화면에 “이번 여름에 수박주스 몇 잔 팔렸지?”를 띄우고 싶다.
지금 방식(앱이 셈) — 여름에 나간 영수증 5만 장을 전부 꺼내 사무실로 가져와 직원(= 서버·브라우저의 JS 코드)이 손으로 세고 수박주스만 골라낸다. 장사 잘될수록 영수증 더미·시간 폭증, 책상이 좁아 다 못 올리면(= 메모리 한계) 일부가 빠진 채 틀린 수를 보고한다.
바꿀 방식(DB가 모두 세고 결과만 반환) — POS(계산대)에 물으면 즉시 “6,842잔”이라고 답한다(= DB 쿼리). POS는 팔 때마다 장부에 적어둬서(= 색인) 다시 셀 필요가 없다.

0. 요약 (TL;DR)

어드민 지연은 단일 버그가 아니라 “DB에서 전체 row를 끌어와 JS로 집계”하는 데이터 접근 패턴이 깔린 구조적 문제다. 이 JS 집계는 주로 백엔드(서버 Node.js) API 핸들러에서, 일부는 프론트엔드(브라우저)에서 일어난다. 데이터가 커질수록 선형으로 악화된다.

9
영향도 ‘상’ 병목
161
API 라우트 (어드민/CMS 다수)
12.4K
어드민 컴포넌트 LOC
~3–5s
대시보드 추정 로드(주범 식별)
가장 비싼 단일 원인 — 어드민 analytics 핸들러 다수가 _shared.fetchAllPages()OFFSET 페이지네이션을 돌려 최대 5만 row를 메모리에 적재한 뒤 JS에서 filter/reduce 집계한다. 대시보드 초기 진입 시 이런 핸들러 5개가 동시에 터지면서 3–5초 지연을 만든다. (CLAUDE.md의 “OFFSET 금지·keyset 사용” 원칙 정면 위반)
좋은 소식 — 캐시 인프라(analytics_cache 테이블 + getCacheShared + RPC admin_overview_agg)와 dynamic import·TanStack Query는 이미 갖춰져 있다. 문제는 일부 핸들러만 이 모범 패턴을 쓴다는 것 — 나머지로 확산만 하면 큰 비용 없이 대부분 해결된다.

1. 어드민 페이지가 느린 이유 — 종합

근본 원인 순위(WSJF: 영향 ÷ 수정비용). 각 항목은 아래 상세 절의 코드 근거로 이어진다.

  1. 전량 fetch + JS 집계 (DB층) — analytics 핸들러가 OFFSET로 최대 5만 row를 끌어와 앱에서 집계. 대시보드 3–5s 주범
    _shared.ts:254-289, heatmap.ts, page-metrics.ts, day-detail.ts
  2. 마운트 시 API 5개 동시 발사 + 캐시 불일치 (FE층) — 대시보드 진입 즉시 5요청 burst, 그중 4개는 raw fetch+useState라 TanStack 캐시 밖 → 뷰 왕복마다 재요청.
    AdminDashboard.tsx:288-544
  3. SSR 게이트의 직렬 인증 왕복 (TTFB) — 매 진입 시 getServerSideProps에서 Supabase getUser()cms_users 조회를 직렬 await. TTFB가 인증 2회 왕복에 묶임.
    admin/index.tsx:132-138
  4. 전체 게시글 select("*") 무제한 + JS 페이징 (DB·FE 공통)getPosts()가 content 포함 전 row 로드, VisitorList도 전 방문자 받아 클라에서 slice.
    storage.ts:393-398, VisitorList.tsx:124-155
  5. 요청 경로의 외부 LLM/API 동기 블로킹 — AI 개선·트렌드 리포트·Buffer 발행을 요청 핸들러에서 동기 await(최대 16K 토큰 비스트리밍).
    ai-improve.ts:68-95, posts/[id]/index.ts:109-162
  6. mutation마다 카테고리 전체 재집계 + 루프 UPDATE — 글 저장/상태토글 등 모든 변경이 전 테이블 재집계 트리거.
    storage.ts:586-605
  7. recharts eager import → 초기 번들 비대 — 기본 뷰 대시보드가 recharts 전체(d3 의존) 동기 로드.
    AdminDashboard.tsx:3-15
  8. 비가상화 + memo 부재 → 렌더 랙 — VisitorList 매 렌더 Set/sort 재계산, AdminDashboard map 45곳 vs memo 4개, 15초 heartbeat가 거대 컴포넌트 전체 리렌더.
    VisitorList.tsx:134-155, AdminDashboard.tsx
  9. 집계 캐싱 누락 + 무한 폴링 — funnel/analytics/queue-status 등은 매 요청 재계산, heartbeat 15s·auth 5m 폴링이 탭 비활성에도 지속.
    funnel.ts:77-85, AdminDashboard.tsx:523-544

2. 프론트엔드 성능

어드민은 단일 URL /admin(SSR) + 인증 후 클라 상태로 뷰 전환되는 SPA. 컴포넌트 29파일 12,398 LOC, 최대 HeatmapView.tsx 2,042 LOC.

번들 / 코드 크기

F1. AdminDashboard가 recharts를 eager import

기본 뷰가 대시보드인데 recharts 전체(AreaChart/BarChart/ResponsiveContainer, d3 의존)를 동기 import → 인증 직후 초기 JS 청크 비대, TTI 지연.

AdminDashboard.tsx:3-15 · index.tsx:10

→ 차트 wrapper를 next/dynamic({ssr:false})로 분리하거나 recharts v3로 교체(트리셰이킹 개선). React 19면 React.lazy+Suspense.

F2. AnalyticsOverview만 dynamic import 누락

8개 무거운 뷰는 이미 dynamic인데 analytics 트리(6개 서브차트)만 정적 import → 대시보드만 보는 사용자도 전체 번들 부담.

index.tsx:11 · analytics/AnalyticsOverview.tsx:21-26

→ 나머지처럼 dynamic(..., {ssr:false})로 통일.

F3. CommandPalette가 레이아웃에 항상 로드 · F4 긍정: 8개 뷰는 이미 dynamic 처리됨 ✔

⌘K 트리거 전까지 지연 로드 가능. 한편 HeatmapView·SupportBot 등 가장 무거운 뷰가 이미 lazy인 점은 적절.

AdminLayout.tsx:20 · index.tsx:17-55

데이터 패칭 워터폴 / 폴링

F5. 마운트 시 5개 API 동시 발사 + 캐시 불일치

overview(useQuery) + sections + plan-diagnosis + marketing-events + heartbeat 5개 동시. 4개는 raw fetch+useState라 dedup·캐시 없음 → 뷰 재진입마다 재요청, 무거운 집계 API에 burst.

AdminDashboard.tsx:288-300, 440, 492, 508, 527 (파일 내 fetch 20회)

→ 전부 useQuery로 이관(staleTime·자동 dedup). 가능하면 서버에서 합쳐 1요청으로 축소.

F6. 전역 QueryClient defaultOptions 미설정

new QueryClient()가 옵션 없이 생성 → 누락 쿼리는 staleTime=0(매번 stale)·focus refetch on.

_app.tsx:98

defaultOptions:{queries:{staleTime:60_000, gcTime:300_000, refetchOnWindowFocus:false}}

F7. heartbeat 15초 폴링 (visibility 가드 없음)

탭이 백그라운드여도 15초마다 polling 지속, 모달 토글마다 인터벌 재생성.

AdminDashboard.tsx:523-544

document.visibilityState 가드 또는 SSE 전환(프로젝트에 SSE 패턴 존재). 최소 refetchInterval useQuery로 통합.

F9. 모달 오픈 시 중첩 fetch 체인(일부 순차 await)

방문자/전환 모달에서 이미 받은 데이터를 재fetch, 일부 순차.

AdminDashboard.tsx:1082-1332

useQuery(enabled:isOpen) 캐시·dedup, 마운트 데이터 재사용.

렌더링 · SSR

F10. VisitorList 전량 fetch + 비가상화 + 매 렌더 재계산

기간 내 전체 방문자를 한 번에 받아 클라에서 filter→50개씩 slice. filtered/options를 useMemo 없이 매 렌더 재계산(Set 생성·정렬·전체 순회). 가상화 라이브러리 프로젝트 전체 부재.

VisitorList.tsx:124, 134, 151-155

→ keyset cursor 서버 페이지네이션 + useInfiniteQuery + IntersectionObserver(=CLAUDE.md 대규모 조회 패턴), @tanstack/react-virtual 도입, options useMemo.

F12. AdminDashboard memo 커버리지 부족

map 45곳 vs memo 4개. state 30+개 집중, 15초 heartbeat setState가 차트 포함 전체 리렌더.

AdminDashboard.tsx:305-397

→ 차트/리스트 섹션 분리 후 React.memo, 고빈도 state 하위 격리.

F13 / F14. SSR 게이트 직렬 인증 왕복 — 어드민에 SSR 자체가 불필요

매 요청 getServerSideProps에서 getUser()cms_users 직렬 await로 TTFB가 인증에 묶임. 어드민은 SEO 불필요·데이터 전부 CSR이라 SSR 이점 0.

admin/index.tsx:113-148

→ 정적 셸 + 클라 인증 게이트로 전환, IP 제한은 middleware.ts/엣지로 이전 → Supabase 왕복 제거, 셸 즉시 응답.

3. 백엔드 · DB 쿼리분석

핸들러 본문 + supabase/migrations 인덱스 정의를 직접 대조. DB는 자체호스팅 Supabase/Postgres(단일 supabaseAdmin 클라이언트).

공통 루트 원인 — ① OLTP/analytics DB 분리 없음(무거운 집계가 OLTP 풀에서 실행, supabase.ts:20). ② 캐시 인프라(analytics_cache+getCacheShared+RPC)는 우수하나 overview/sections/visitors만 적용, 나머지 analytics 핸들러는 미적용. ③ 다수 핸들러가 “전량 fetch → JS 집계” 패턴.

3.1 최우선 쿼리 병목 (영향도 상)

ID증상근거(file:line)권장
N1.1getPosts() blog_posts 전체 select("*") 무제한 + JS 정렬·검색·페이징. analytics/posts/queue-status/seo-audit 공유blog/storage.ts:393-398, 401-432WHERE/ORDER BY/keyset를 DB 위임, 목록은 content 제외, 검색 trgm GIN
N1.2page_analytics를 limit 없이 select → Supabase 기본 1000행 silent 절단 (성능+정확성 동시 버그: 전환율·퍼널 부정확)admin/ai/analyze.ts:26-29, 117-121, 269-272집계를 SQL/RPC로 이관(행 fetch 불필요). getHours() TZ 버그도 동반
N1.3fetchAllPages가 OFFSET로 최대 5만 row 적재 후 JS filter/reduce. heatmap/page-metrics/day-detail/events/plan-diagnosis 의존 대시보드 주범admin/analytics/_shared.ts:254-289잔여 핸들러 전부 RPC 이관, 행 필요 시 keyset cursor
N1.4heatmap 7개 OFFSET 풀fetch 동시 + JS 그리드 집계, L2 캐시 없음analytics/heatmap.ts:347-368, 451-708단일 RPC(width_bucket+GROUP BY)로 버킷팅, getCacheShared 적용
N1.5page-metrics/day-detail 전기간 fetchAllPages + JS 다중패스, 캐싱 없음page-metrics.ts:49-259 · day-detail.ts:128-309RPC GROUP BY. day-detail 과거날짜 불변→긴 TTL
N1.6모든 post mutation마다 전 테이블 재집계 + 카테고리별 루프 UPDATE (무관 수정도 트리거)blog/storage.ts:586-605category/status 변경 시에만, UPDATE...SELECT count 단일 SQL 또는 트리거
N1.7queue-status 전 posts+채널매핑 로드 후 채널수×글수 JS filter (위젯 폴링 핫패스)cms/queue-status.ts:43-76채널×상태 DB GROUP BY + Redis 단기 캐시
N1.8utm/stats 기간 내 utm_visits 전량 fetch 후 JS 집계 + count:exactcms/utm/stats.ts:35-138DB GROUP BY+date_trunc RPC, 상위 N만, 5분 캐시

3.2 요청 경로의 외부 LLM/API 동기 블로킹

핸들러근거외부호출영향
ai-improvecms/ai-improve.ts:68-95 (16K토큰 비스트리밍)Anthropic
trends/reportcms/trends/report.ts:118-122 (maxDuration 120s)Anthropic
utm/insights/refreshcms/utm/insights/refresh.ts:33-35 (+전량 fetch)Anthropic
posts/[id] 발행cms/posts/[id]/index.ts:109-162 (for-loop await)Buffer 순차
personaadmin/analytics/persona.ts:96-115 (캐시 없음, 구버전 모델ID)Claude
썸네일/인라인 생성cms/generate-thumbnail.ts:14-67 등Gemini
git-logadmin/git-log.ts:21-24 execSync (이벤트루프 블록)

→ 모두 BullMQ/waitUntil 큐화 + 202 즉시 반환 + SSE/폴링 진행률. (schedule-generate·ai-generate는 이미 큐 모범패턴.)

3.3 OFFSET + count:exact 페이지네이션 (CLAUDE.md 위반)

대상: media/list.ts, utm/visits.ts, audit-log.ts, trends/index.ts, admin/analytics/events.ts, support-chat-conversations, post-counts.ts(7× count head). → (created_at, id) keyset cursor, count→estimated, post-counts는 단일 GROUP BY status.

3.4 인덱스 갭 (핸들러 쿼리 vs 마이그레이션 정의 대조)

테이블누락 인덱스쿼리 근거
utm_visitscreated_at(기간·정렬), source/medium/campaign 복합, search 4컬럼 GIN trgmutm/stats.ts, utm/visits.ts (content/keyword idx만 존재)
support_chat_conversations / messages테이블·인덱스 정의가 마이그레이션에 전무(런타임/외부 생성 추정)admin/support-chat-conversations/*
cms_mediafilename/created_at, 테이블 정의 마이그레이션 없음media/list.ts
blog_poststitle/content 검색용 trgm GIN (category/status/slug/published_at은 있음)posts.ts JS substring 검색
cms_audit_log.actionprefix liketext_pattern_opsaudit-log.ts:74

RLS는 병목 아님 — service_role(supabaseAdmin)은 RLS 우회. page_analytics(053)·trend_items(042)·click_heatmap 등은 인덱스 양호.

라이브 EXPLAIN 노트 — 실측 쿼리플랜을 위해 DB(3.35.240.227:8788)에 직접 접속 시도했으나 외부 IP 화이트리스트로 차단(ETIMEDOUT)되어 불가. 위 분석은 핸들러 코드 + 마이그레이션 SQL 정적 대조 기반이다. 실측이 필요하면 화이트리스트 IP나 SSH 터널 경유로 EXPLAIN (ANALYZE, BUFFERS) 재실행 권장.

4. 환경변수 · 설정 감사

.env.local 27개 변수. 시크릿의 NEXT_PUBLIC 유출 없음, .env.local git 추적 안전(check-ignore 확인).

변수범주용도사용클라노출
ANTHROPIC / GEMINI _API_KEYAIClaude·Gemini
OPENAI_API_KEYAI번역도구(tools/)⚠️ orphan
DATABASE_URL / DIRECT_DATABASE_URLDBPostgres pooled/direct
SUPABASE_SERVICE_ROLE_KEYDB/Auth관리자(서버전용)
NEXT_PUBLIC_SUPABASE_URL / _ANON_KEY공개Supabase 클라(정상 공개)
FIREBASE_PROJECT_ID / CLIENT_EMAIL / PRIVATE_KEYAuthFirebase admin
CMS_AUTH_SECRET / CRON_SECRET / RECAPTCHA_SECRET_KEYAuthCMS·cron·reCAPTCHA
CMS_LICENSE_KEY외부APICMS 라이선스⚠️ orphan
BUFFER / PEXELS / PIXABAY / UNSPLASH / YOUTUBE _KEY외부APISNS·이미지·영상
NEXT_PUBLIC_GA_ID / GTM_ID / OTEL / PRICING_API_URL / SITE_URLAnalytics·공개측정·URL
VERCEL_OIDC_TOKEN인프라Vercel CLI 자동생성⚠️ auto

설정 관찰

4S. 슬랙(Slack) 연동 구성

두 개의 독립 서브시스템이 공존한다 — (A) Hermes/Threads 봇(bot token + 서명검증 + 모달·홈·버튼, 풀 인터랙티브) / (B) Incoming Webhook 알림(리드·문의·CMS 단방향 통보, 서명검증·봇토큰 없음).

구성 요약

엔드포인트 / 모듈방향용도트리거 · 대상
api/slack-command.tsIn슬래시 /threads → 작성 모달 opentrigger_id→views.open, 빈 200 ack
api/slack-interaction.tsIn+Out단축키·모달제출·버튼 처리shortcut / view_submission / block_actions
api/slack-events.tsInurl_verification, App Home 열림app_home_opened→publishHome (waitUntil)
lib/hermes/slack-bot.tsOutbot token API: postMessage·views.open·views.publishSLACK_BOT_TOKEN
lib/hermes/slack-verify.ts검증HMAC-SHA256 서명 + 5분 replay 차단A 그룹 3개 엔드포인트 전용
lib/cms/slack.ts (notifySlack)OutCMS webhook (발행·트렌드·cron 알림, prod-only)SLACK_CMS_WEBHOOK_URL
contact / preview / pack / community / financialOut리드·문의·상담 신청 알림 (+ claim 버튼)SLACK_WEBHOOK_URL
chatbot-inquiry-slack.tsOut챗봇 문의 알림 + claim, 3회 재시도webhook
api/cron/threads-monitor.tsOutThreads mock run 모니터 결과 게시CRON_SECRET 인증

Inbound 버튼 액션: threads_recheck/stop(모니터 재확인·종료), threads_publish(mock 발행, waitUntil), threads_home_new/refresh, claim_inquiry(문의 담당자 지정 — 버튼 블록을 “제가 담당할게요” 텍스트로 교체).

시크릿

시크릿종류검증/인증사용처
SLACK_BOT_TOKENBot OAuthBearerHermes 봇(메시지·모달·홈)
SLACK_SIGNING_SECRET서명 검증키HMAC — fail-openA 그룹 inbound 검증
SLACK_WEBHOOK_URLIncoming Webhook없음(outbound)리드·문의·상담 채널
SLACK_CMS_WEBHOOK_URLIncoming Webhook없음(outbound)CMS 발행·트렌드·cron
HERMES_THREADS_SLACK_CHANNEL채널 ID기본 C0B85Q2ULSJ, 4개 파일 하드코딩 중복

성능 · 보안 관찰

5. 의존성 최신버전 조사

pnpm@9.12.2 / Node ≥22. transitive 취약점은 overrides로 이미 핀 고정됨. major 갭 + 어드민 성능 ROI 중심.

패키지현재최신이점 / 비고우선
recharts2.15.43.8.1v3 렌더성능·트리셰이킹 개선. 어드민 차트 핵심, 사용처 2곳뿐이라 범위 좁음
sanitize-html2.17.32.17.5XSS 패치 — 즉시 적용
dompurify3.4.23.4.11XSS 패치. sanitize-html과 중복 사용 → 통일 검토
@supabase/supabase-js2.98.02.108.2버그픽스·타입 minor
@google/genai1.46.02.8.0v2 major, Gemini 최신 모델
@anthropic-ai/sdk0.81.00.105.0신규 모델/툴 API (0.x minor=breaking 주의)
firebase-admin13.6.014.0.0v14 보안·SDK 갱신
@types/node20.x25.x런타임 Node22인데 타입 20 → 22로 정합
tailwindcss3.4.64.3.1v4 Oxide 빌드 10×, CSS-first(큰 마이그레이션, tailwind-merge v3·framer v12 동반)
next15.5.1516.2.9Turbopack prod 안정화·빌드속도. 단 v16 legacy i18n 제거 → Pages+내장 i18n 구조라 마이그레이션 부담
typescript5.9.36.0.3v6 major, 급하지 않음

Next 15.5 + React 19 — 적용 가능한 2026 패턴

6. 실행 플랜 (WSJF 우선순위)

Phase 1 — 즉시 (저비용·고효과, 어드민 체감 직결)

  1. 잔여 analytics 핸들러(heatmap·page-metrics·day-detail·funnel)를 기존 RPC + getCacheShared 패턴으로 이관fetchAllPages 제거. 대시보드 3–5s → 수백 ms
  2. 대시보드 마운트 raw fetch 4종 → useQuery + 전역 QueryClient.defaultOptions(staleTime 60s, focus off). F5·F6
  3. admin/ai/analyze.ts 1000행 절단 버그 수정(집계 RPC화) — 정확성+성능. N1.2
  4. recharts/AnalyticsOverview dynamic import + recharts v3, heartbeat visibility 가드. F1·F2·F7
  5. sanitize-html·dompurify 보안 patch. 즉시

Phase 2 — 구조 개선

  1. getPosts() select("*") 제거 + keyset cursor, VisitorList 서버 페이지네이션 + useInfiniteQuery + 가상화. N1.1·F10
  2. 요청 경로 외부 LLM/Buffer 동기호출 → BullMQ 큐화 + 202 + SSE 진행률. §3.2
  3. updateCategoryCounts 증분 갱신/트리거화. N1.6
  4. 어드민 SSR 게이트 제거 → 정적 셸 + 미들웨어 인증. F13·F14
  5. 누락 인덱스 추가(utm_visits.created_at·trgm, blog_posts trgm, audit_log text_pattern_ops). §3.4

Phase 3 — 인프라/장기

부록 — 분석 방법