무언가 느림
우리 제품 백로그 리스트 페이지가 느렸습니다. 극적으로 깨진 것은 아니지만 — 그냥… 잘 안 됩니다. 새로운 데모 테넌트에서 실제 데이터가 있는 실제 테넌트로 전환할 때 느낄 수 있는 종류의 느림.
그래서 측정해 보았습니다:
- 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는 정확히 그것이 해야 할 일을 했지만 — 다른 맥락에서.
백로그 항목에 대해 우리는 두 가지 뷰를 가지고 있습니다:
- 리스트 뷰 — 이름, 상태, 스토리 포인트, 진행 상황을 표시합니다. 작업 수준의 할당 정보는 표시되지 않습니다.
- 상세 보기 — 모든 내용을 표시하며, 각 하위 작업에 누가 할당되었는지도 포함합니다.
상세 보기는 정말로 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 백분율은 서버 측에서 계산됩니다에서 자식 작업들로부터 분리됩니다. 구체적으로, 완료된 작업 수와 총 작업 수를 세는 것입니다. 이를 위해 서버는 Tasks와 Tasks.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 중심의 작업 관리 도구입니다.
lasimban.team
GraphQL 설정에서 유사한 Fragment 공유 함정에 부딪혔습니까? 다른 팀들이 목록 대비 상세 사전 로드 분할을 어떻게 처리하는지 듣고 싶습니다.












