인셔셔RSS 관심 있는 블로그, 뉴스, 기술 정보를 효율적으로 추적하고 읽으세요
원문 읽기 InertiaRSS에서 열기

추천 피드

博客园 - 司徒正美
V
V2EX
T
Tailwind CSS Blog
有赞技术团队
有赞技术团队
aimingoo的专栏
aimingoo的专栏
Apple Machine Learning Research
Apple Machine Learning Research
IT之家
IT之家
Blog — PlanetScale
Blog — PlanetScale
A
About on SuperTechFans
月光博客
月光博客
T
The Blog of Author Tim Ferriss
宝玉的分享
宝玉的分享
Martin Fowler
Martin Fowler
博客园 - 聂微东
The GitHub Blog
The GitHub Blog
V
Visual Studio Blog
WordPress大学
WordPress大学
酷 壳 – CoolShell
酷 壳 – CoolShell
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python)
공유된 GraphQL 조각이 우리의 목록 성능을 침묵 속에서 죽였습니다
Ryo Tsugawa · 2026-05-24 · via DEV Community

무언가 느림

우리 제품 백로그 리스트 페이지가 느렸습니다. 극적으로 깨진 것은 아니지만 — 그냥… 잘 안 됩니다. 새로운 데모 테넌트에서 실제 데이터가 있는 실제 테넌트로 전환할 때 느낄 수 있는 종류의 느림.

그래서 측정해 보았습니다:

  • 8 항목: 206ms TTFB (첫 바이트까지의 시간)
  • 67 항목: 675ms TTFB

그것은+469ms 차이 또는 대략 8ms per item의 선형적 하락. 매우 작은 백로그 항목이 추가될 때마다 응답 시간이 ~8ms 증가했습니다. 좋지 않습니다.

처음에는 프론트엔드를 비난하는 것에 대한 본능이었습니다 — 컴포넌트 트리가 너무 적극적으로 재렌더링되었을 수도 있었습니다. 하지만 TTFB는 이를 즉시 배제했습니다. 브라우저가 아직 렌더링을 시작하지 않았을 때 서버가 그렇게 오랫동안 응답하는 것이었습니다.

그래서 블록버스터는 API 서버와 데이터베이스 사이 어딘가였습니다. 더 깊이 파고들어야 할 시간입니다.

우리의 프리로딩 작동 방식

우리는 Go + gqlgen + GORM를 Cloud Run에서 실행하며 Cloud SQL (PostgreSQL)과 통신합니다. GraphQL 레이어는 간단한 원칙을 기반으로 설계되었습니다:

클라이언트는 필요한 필드만 요청합니다. 서버는 요청된 내용만 미리 로드합니다.

실제로는 우리의 리졸버가 들어오는 GraphQL 선택 집합을 검사하고 GORM Preload() 호출로 변환합니다. 프론트엔드가 tasks { assignees { user } }을 요청하면 백엔드는 성실하게 Tasks → Assignees → User을 미리 로드합니다. 만약 tasks { backlogStatus }만 요청하면 우리는 Tasks → BacklogStatus만 미리 로드합니다.

이것은 "적절한" 설계입니다. 백엔드는 프론트엔드가 자신의 데이터 요구사항을 정확히 선언할 것이라고 신뢰하며, 정확히 그것에 대한 응답을 보냅니다 — 더 이상도, 더 적어도 없습니다.

이론적으로.

실제로 일어나고 있던 일

우리의 목록 페이지가 발사한 GraphQL 쿼리는 다음과 같습니다 (간소화됨):

query GetProductBacklogItems($projectId: ID!) {
  productBacklogItems(projectId: $projectId) {
    edges {
      node {
        id
        name
        storyPoint
        priority
        progress
        backlogStatus { id name status }
        tasks {
          ...SubTaskFields
        }
      }
    }
  }
}

전체 화면 모드로 입력 전체 화면 모드 종료

그리고SubTaskFields는 이렇게 보였습니다:

fragment SubTaskFields on Task {
  id
  name
  backlogStatus { id name status }
  assignees {
    user { id givenName familyName }
  }
}

전체 화면 모드로 전환 전체 화면 모드 종료

문제를 보셨나요? 목록 페이지는 tasks { ...SubTaskFields }을 요청하고 있었고, SubTaskFields에는 assignees { user { ... } }이 포함되어 있습니다.

목록 페이지는 작업 할당자를 표시하지 않습니다.그 페이지에 있는 단일 구성 요소에서 읽지 않습니다task.assignees 그래도 쿼리는 그것을 요청하고 있었습니다.

백엔드에서 이는 4단계 사전 로드 체인 을 트리거했습니다 — 백로그 항목마다 한 번 실행됩니다.

레벨 사전 로드
1 Tasks
2 Tasks.BacklogStatus
3 Tasks.Assignees
4 Tasks.Assignees.User

67개 항목에 대해, 그것은 67 × 4 계층적 데이터베이스 조회입니다. 매우 하나하나가 네트워크를 통해 Cloud SQL을 쳤습니다. 그래서 선형적이었던 것이 아무래도 이유가 없지 않은가.

왜 아무도 포착하지 못했는가

이 지루한 부분은 이것입니다: Fragment는 정확히 그것이 해야 할 일을 했지만 — 다른 맥락에서.

백로그 항목에 대해 우리는 두 가지 뷰를 가지고 있습니다:

  1. 리스트 뷰 — 이름, 상태, 스토리 포인트, 진행 상황을 표시합니다. 작업 수준의 할당 정보는 표시되지 않습니다.
  2. 상세 보기 — 모든 내용을 표시하며, 각 하위 작업에 누가 할당되었는지도 포함합니다.

상세 보기는 정말로 assignees { user }SubTaskFields 안에 필요로 합니다. 그 Fragment는 상세 보기용으로 작성되었고, 그곳에서는 맞았습니다.

리스트 뷰는 그냥… 빌려왔어. 같은 Fragment지만 다른 context에서는 매우 다른 성능 특성을 보인다.

작은 규모에서는 아무도 안 눈치채지 못했다. 8개 항목 × 4개 미리 로드는 아마도 64ms 추가될 수 있어. 그건 소음이야. 하지만 50개 이상이 되면 선형 비용이 아픔을 느끼게 돼.

또 다른 요인이 있어: 로컬 개발 머신은 너무 빠르다. 개발 중에는 데이터베이스가 로컬이거나 가까운 Docker 컨테이너에 있으며 — 네트워크 둘러싼 순환은 본질적으로 거의 없습니다. 각 사전 로드는 마이크로초에 걸리므로 십수 레벨이나 느껴지지 않습니다. 생산 환경에서는 각 사전 로드가 Cloud Run과 Cloud SQL 사이의 네트워크 힙이 됩니다. 그 힙당 지연 시간을 4 레벨 × N 항목으로 곱한 것이 눈에 보이지 않는 오버헤드를 실제 병목 현상으로 바꾼 것입니다.

교훈은: GraphQL Fragments는 개발자를 위한 편의 기능이지, 데이터 요구사항에 대한 계약은 아닙니다. 다양한 표시 요구사항을 가진 뷰들 사이에서 Fragment를 공유하면, 필요하지 않은 사전 로드를 묵묵히 선택하게 됩니다.

해결 방법

단계 1: 목록 쿼리에서 불필요한 필드를 제거합니다

가장 명백한 방법 — 목록 쿼리에서 tasks { ...SubTaskFields }를 요청하지 않기:

query GetProductBacklogItems($projectId: ID!) {
  productBacklogItems(projectId: $projectId) {
    edges {
      node {
        id
        name
        storyPoint
        priority
        progress
        backlogStatus { id name status }
        # tasks removed — list page doesn't render subtask details
      }
    }
  }
}

전체 화면 모드로 전환 전체 화면 모드 종료

하지만 여기서는 특별한 점이 있습니다.

단계 2: progress 필드는 여전히 작업 데이터가 필요합니다

각 백로그 항목에 대한 progress 백분율은 서버 측에서 계산됩니다에서 자식 작업들로부터 분리됩니다. 구체적으로, 완료된 작업 수와 총 작업 수를 세는 것입니다. 이를 위해 서버는 TasksTasks.BacklogStatus을 로드해야 합니다.

프론트엔드가 tasks 요청을 중단하면, 백엔드는 이를 미리 로드 중단하고, progress은 모든 것에 대해 조용히 0을 반환합니다. 그것은 느린 것보다 더 나쁩니다.

단계 3: EnhanceFields 패턴

이미 이에 대한 확립된 패턴이 있었습니다: 필드 주입 보조 함수 이는 프론트엔드가 무엇을 요청하든 필요한 사전 로드가 존재하도록 보장합니다. 우리는 프로젝트 통계와 팀 통계에 동일한 접근 방식을 사용합니다.

아이디어는 간단합니다: 요청된 필드를 사전 로드 레이어에 전달하기 전에 서버 측 계산에 필요한 필드가 있는지 확인합니다. 없으면 주입합니다.

func enhanceFields(fields []string) []string {
    // If the client didn't ask for "tasks" at all,
    // inject the minimum needed for progress calculation.
    if !contains(fields, "tasks") {
        fields = append(fields, "tasks", "tasks.backlogStatus")
    }
    return fields
}

전체 화면 모드로 전환 전체 화면 모드 종료

해결기(resolver)에서 이 도우미는 선택 집합 파서와 사전 로드 빌더 사이에 위치합니다:

func (r *resolver) ListItems(ctx context.Context, ...) {
    fields := enhanceFields(getRequestedFields(ctx))
    // fields is now guaranteed to include "tasks" + "tasks.backlogStatus",
    // but NOT "tasks.assignees" or "tasks.assignees.user"
    items := r.repo.FindAll(ctx, filters, fields)
    // ...
}

전체 화면 모드로 전환 전체 화면 모드 종료

핵심 통찰: 리스트와 상세 보기는 이제 비대칭 사전 로드 깊이를 가지고 있습니다 디테일 뷰는 여전히 모든 4 레벨을 받습니다 (프론트엔드가 요청합니다). 목록 뷰는 2 레벨만 받습니다 — 진행 상태를 계산하기에 충분합니다, 할당 대상 데이터를 끌어들이지 않으므로 결코 표시되지 않습니다.

결과

이전 이후
프론트엔드 요청 tasks { backlogStatus, assignees { user } } (작업 요청하지 않음)
백엔드 사전 로드 작업 → 백로그 상태 → 할당자 → 사용자 작업 → 백로그 상태
사전 로드 깊이 4 2
TTFB (67개 항목) 675밀리초 135밀리초

5배 빠르게. ~8밀리초/항목 선형 하락은 효과적으로 사라졌습니다.

그리고 최고의 부분은: 상세 보기가 전혀 영향을 받지 않습니다. 여전히 tasks { ...SubTaskFields }을 요청하고 있으며, EnhanceFields 헬퍼는 hasTasks이 이미 참(true)인 경우에는 아무 작업도 수행하지 않습니다. 상세 페이지에 대한 행동 변화는 없습니다.

나는 다르게 할 것들

이걸 디버깅하면서 얻은 몇 가지 시사점:

  • TTFB은 첫 번째 진단 도구입니다. TTFB가 항목 수와 선형적으로 비례한다면, 문제는 서버 측입니다. API를 배제하기 전까지 React 렌더링을 프로파일링하는 시간을 낭비하지 마세요.

  • Fragments는 개발자 경험(DX)의 편의 기능이지, 데이터 계약은 아닙니다. 여러 뷰에서 서로 다른 필드 세트를 렌더링하는 Fragments를 공유하면, 해당 뷰들의 성능 프로파일 간에 가시성 없는 결합이 생성됩니다. SubTaskFieldsList를 고려해보세요.SubTaskFieldsDetail 대신 하나의 공유된 Fragment를 사용하지 않습니다.

  • "서버는 요청한 것만 로드한다"는 것은 올바르게 요청하면 작동합니다. GraphQL의 정확한 데이터 조회에 대한 약속은 쿼리 작성자가 정확해야 의존합니다. 서버는 명령을 충실히 따르고 있지만 — 문제는 항상 당신이 전달하는 것에 있습니다.

  • 선형적 하락은 작은 데이터셋에 숨어 있습니다. 8ms/item은 8개일 때 보이지 않습니다. 100개에서는 벽이 됩니다. 랜덤 데이터만 테스트하면 이런 유형의 문제를 절대로 발견하지 못합니다. 생산 규모의 데이터로 프로파일링해야 합니다.


이 글은 실제 성능 수정에 기반한 것으로, Lasimban를 기반으로 합니다. 이는 Scrum 팀을 위해 만들어진 작업 관리 SaaS입니다. 무료로 사용할 수 있습니다 — 신용카드가 필요 없습니다.

Lasimban 무료로 시도해보세요 → lasimban.team

Lasimban - Scrum 중심의 작업 관리 도구

Scrum 개발을 더 직관적이고 즐거운 것으로 만드세요. Lasimban은 팀에게 올바른 방향을 보여주는 Scrum 중심의 작업 관리 도구입니다.

favicon lasimban.team


GraphQL 설정에서 유사한 Fragment 공유 함정에 부딪혔습니까? 다른 팀들이 목록 대비 상세 사전 로드 분할을 어떻게 처리하는지 듣고 싶습니다.