목표: N+1 쿼리, 페이지네이션, 서버 상태 관리, 동시 편집, 캐시 무효화, 타임존 등 FE 개발자가 실무에서 부딪히는 DB 관련 이슈를 이해하고 올바른 요구사항·API 설계로 풀 수 있어야 한다.
0. 왜 이 장이 FE에게 중요한가 — 초심자용
0-1. "DB 문제는 BE가 해결한다"는 착각
이 장의 주제들은 표면상 DB 쪽 이슈로 보이지만, 실제 증상은 프론트에서 먼저 드러난다.
- 리스트 페이지가 5초 걸림 → N+1 쿼리
- 게시판 아래쪽으로 갈수록 점점 느려짐 → OFFSET 페이지네이션
- 같은 페이지 열어둔 두 사람이 서로 덮어씀 → 동시 편집 문제
- 배포 후 어제 데이터가 계속 보임 → 캐시 무효화
- "지난 달" 필터가 시차 때문에 이상한 결과 → 타임존
사용자는 이 증상을 "앱이 이상하다" 로만 느낀다. FE 개발자가 원인을 진단할 수 있어야 한다. 진단이 정확하면 BE와 협업이 빠르고, 진단이 틀리면 FE에서 헛수고만 한다.
0-2. 이 장의 6가지 이슈
| 이슈 | 한 줄 |
|---|---|
| N+1 쿼리 | "리스트 1번 + 각 항목별 1번씩" 으로 쿼리가 폭발 |
| 페이지네이션 | OFFSET vs Cursor — 무한 스크롤의 옳은 방법 |
| 서버 상태 관리 | 서버가 진실. 클라이언트 캐시를 어떻게 동기화? (React Query) |
| 동시 편집 | 두 명이 동시에 수정. 누구 걸 반영할 것인가 |
| 캐시 무효화 | "배포했는데 사용자가 구 버전을 본다" — CDN, 브라우저 캐시 |
| 타임존 | "지난 7일"이 사용자마다 다른 의미. UTC 저장 원칙 |
0-3. 이 장에 진입하기 전 전제
- 4-1~4-5의 기본기
- 2-9 캐싱과 CDN의 캐시 개념
- 2-3 API 스타일의 REST 감각
이 세 개를 안 봤다면 이 장이 추상적으로 느껴질 수 있다. 이 장은 이전 장들을 실전에 적용하는 챕터다.
0-4. 이 장을 읽은 뒤 할 수 있는 대화
- "이 API 응답이 아이템 수에 비례해서 느려지는데, N+1 같아요. JOIN으로 바꿔주실 수 있나요?"
- "페이지네이션 10000 이후로 느린데, OFFSET 대신 cursor-based로 바꾸면 어떨까요?"
- "이 화면은 실시간이 아니어도 되니 React Query의 staleTime을 60초로 설정하겠습니다."
- "날짜 필터는 UTC 기준이면 사용자가 혼란스러워해요. 클라이언트 타임존으로 변환해야 합니다."
BE와 협업할 때 구체적인 용어로 대화할 수 있는 것이 이 장의 궁극 목표.
1. N+1 쿼리 — 모든 FE가 한 번은 당한다
문제
-- 1번: 게시글 100개
SELECT * FROM posts LIMIT 100;
-- 100번: 각 게시글의 작성자
SELECT * FROM users WHERE id = ?; -- × 100
→ 총 101 쿼리. 100ms씩 걸려도 10초.
FE에서 징후
- 리스트 페이지가 느림
- 아이템 수에 비례해 응답 시간 증가
- Network 탭에 "로딩 스피너가 오래"
해결
① JOIN 또는 IN 서브쿼리 (백엔드)
SELECT p.*, u.name
FROM posts p JOIN users u ON u.id = p.author_id
LIMIT 100;
② ORM Eager Loading
// Prisma
const posts = await prisma.post.findMany({
include: { author: true }
});
③ GraphQL + Dataloader
- 한 틱에 들어온 쿼리를 batch 처리
④ API 재설계
GET /posts?include=author,tags,commentCount
include 파라미터로 필요한 관계 명시.
⚠️ FE가 할 수 있는 것
- API가 필요한 관련 데이터를 한 번에 주는지 확인
- 쓸데없이 여러 엔드포인트를 순차 호출하지 말 것
Promise.all병렬 호출이라도 여전히 N+1 — 원천적으로 API 설계 개선
2. 페이지네이션 설계
Offset 방식
GET /posts?offset=100&limit=20
SELECT * FROM posts ORDER BY created_at DESC OFFSET 100 LIMIT 20;
- 페이지 번호 UI에 자연스러움
- 뒤로 갈수록 느려짐 — OFFSET 10000 = 앞 1만 건 스킵
- 새 글 삽입 시 중복·누락 발생
Cursor 방식
GET /posts?cursor=eyJpZCI6MTAwfQ==&limit=20
SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
- 항상 빠름
- 삽입에 안전
- 무한 스크롤 UX에 적합
- 임의 페이지 점프 불가
응답 형식
{
"data": [...],
"pagination": {
"nextCursor": "eyJpZCI6MTIwfQ==",
"hasMore": true
}
}
FE 규칙
- 리스트는 커서 방식 기본
- 관리자 페이지·검색 결과만 Offset
- 총 개수(
total) 필요 시 별도 count 요청 (비용 주의)
3. 무한 스크롤의 함정
// React Query 무한 쿼리
const {
data, fetchNextPage, hasNextPage, isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
getNextPageParam: (last) => last.pagination.nextCursor,
});
흔한 실수
- 스크롤 이벤트에 throttle 없음 → 수십 회 호출
- 로딩 중 재호출 방어 없음
- 키보드 접근성 (Tab, PageDown) 없음 → "Load more" 버튼 병행
4. 서버 상태 vs 클라이언트 상태
| 서버 상태 | 클라이언트 상태 | |
|---|---|---|
| 출처 | DB | 컴포넌트 로컬 |
| 공유 | 여러 사용자 | 단일 사용자 |
| 일관성 | 서버가 진실 | 클라가 진실 |
| 도구 | React Query, SWR, Apollo | useState, Zustand |
혼동하면 버그 폭증. 서버 상태는 React Query, 클라이언트 상태는 일반 상태 관리.
React Query의 핵심 개념
staleTime: fresh 유지 시간 → 이 동안 refetch 안 함gcTime(구 cacheTime): 컴포넌트 unmount 후 캐시 유지 시간refetchOnWindowFocus: 포커스 시 재요청invalidateQueries: 수동 무효화 (mutation 후)
5. 캐시 무효화
"컴퓨터 과학의 두 난제: 네이밍, 캐시 무효화, off-by-one"
흔한 시나리오
1. GET /posts → 캐시
2. POST /posts (새 글 작성)
3. 리스트 페이지 이동 → 구 캐시 보임
해결 — mutation 성공 시 invalidate
const createPost = useMutation({
mutationFn: (data) => api.post('/posts', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
세밀한 무효화
// 작성글 목록과 특정 태그 목록만
queryClient.invalidateQueries({ queryKey: ['posts'] });
queryClient.invalidateQueries({ queryKey: ['posts', 'tag', tagId] });
낙관적 업데이트 + 최종 invalidate
(4-5장 복습)
6. 동시 편집 (Concurrent Editing)
시나리오
사용자 A가 제목 수정 중
사용자 B가 동시에 본문 수정
누가 저장하면 상대 변경이 사라짐 (Last Write Wins)
해결 방법
① 비관적 락 (문서 편집 권한)
- 한 번에 한 명만 편집 가능
- UX 나쁨, 협업 불가
② 낙관적 락 (If-Match + ETag)
PUT /posts/1
If-Match: "v7"
→ 412 Precondition Failed → UI에 충돌 안내
③ 필드 수준 merge
- 충돌 없는 필드만 병합
- 충돌 필드는 사용자에게 선택 UI
④ CRDT (Conflict-Free Replicated Data Type)
- Yjs, Automerge
- 구글 Docs, Figma, Notion 스타일 실시간 협업
- 복잡도 ↑, 모든 연산이 교환·결합 법칙 성립해야 함
7. 타임존 — 은근한 재앙
서버 저장 규칙
- UTC로 저장 (
TIMESTAMPTZ) - 서버 시간대를 UTC로 설정
- 클라이언트에서 표시 시에만 사용자 타임존 적용
흔한 버그
// ❌ 로컬 파싱 (KST)
new Date('2026-04-19') // KST 자정 → UTC 2026-04-18 15:00
// ✅ UTC 명시
new Date('2026-04-19T00:00:00Z')
표시
// Intl로 사용자 로케일·타임존
new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
dateStyle: 'long',
timeStyle: 'short'
}).format(date);
라이브러리
- date-fns-tz: 가볍고 선언적
- Luxon: 풍부한 API
- Temporal API (ES proposal): 미래의 정답
- moment.js: 레거시, 신규 사용 비추천
8. Idempotency — 같은 요청 두 번
문제
POST /orders (결제)
→ 네트워크 타임아웃
→ 사용자 재시도
→ 이중 결제
해결 — Idempotency-Key
const idempotencyKey = crypto.randomUUID();
await api.post('/orders', data, {
headers: { 'Idempotency-Key': idempotencyKey }
});
// 재시도 시 같은 키 → 서버는 1회만 처리
FE 규칙
- POST, PATCH, DELETE 는 멱등성 키 생성 (네트워크 재시도에 대비)
- GET, PUT은 본질적으로 멱등
9. 대용량 데이터 — 스트리밍 / 청크
큰 검색 결과
// Virtualized List (react-virtual, react-window)
// 10만 건도 부드럽게 렌더
파일 업로드
// 청크 업로드 (resumable upload)
const CHUNK = 5 * 1024 * 1024;
for (let i = 0; i < file.size; i += CHUNK) {
const chunk = file.slice(i, i + CHUNK);
await api.post(`/upload/${uploadId}/chunk`, chunk, {
headers: { 'X-Chunk-Index': i / CHUNK }
});
}
- 네트워크 끊겨도 이어서 업로드
- 메모리 폭발 방지
큰 응답 다운로드
const response = await fetch('/export/csv');
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value는 Uint8Array chunk
processChunk(value);
}
10. 실시간 데이터
정기 폴링
useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
refetchInterval: 5000,
});
- 간단, 단점: 트래픽·서버 부하
SSE (Server-Sent Events)
const es = new EventSource('/stream');
es.onmessage = (e) => setData(JSON.parse(e.data));
- 서버 → 클라 단방향
- 자동 재연결
- HTTP 기반 → 방화벽 친화
WebSocket
- 양방향 실시간
- 협업, 채팅, 게임
- 상태 관리 복잡 (재연결, heartbeat)
선택 기준
| 시나리오 | 선택 |
|---|---|
| 알림 배지 | 폴링 (30s+) |
| 실시간 대시보드 | SSE |
| 채팅·협업 | WebSocket |
| 비디오·WebRTC | WebRTC |
11. ⚠️ FE 입장에서 백엔드에 물어봐야 할 질문들
- 이 엔드포인트 페이지네이션 방식은? (offset/cursor)
-
?include=파라미터로 관련 데이터 받을 수 있는가? - 정렬·필터 파라미터 규약은?
- 에러 응답 형식(
{error, code, message}) 이 표준화돼 있는가? - 응답 타임스탬프는 UTC인가 로컬인가?
- 멱등성 키 지원하는가?
- Rate limit 정책은? (헤더
X-RateLimit-*) - 배치 API가 있는가? (
POST /users/batch) - 대용량 다운로드는 스트리밍 지원하는가?
12. 연습 문제
Q1. N+1 문제를 FE가 인지하는 방법은?
정답
- Network 패널: 같은 도메인에 비슷한 GET이 연달아 N번
- 응답 시간이 리스트 크기에 선형 비례
- waterfall 에서 요청이 계단식으로 쌓임
- 백엔드와 협업해 관련 데이터
include옵션, GraphQL, batch API 요구
Q2. 무한 스크롤에 Offset을 쓸 수 없는 이유는?
정답
- 성능: OFFSET이 커질수록 DB가 앞의 모든 행을 스캔 → 느려짐
- 중복·누락: 새 게시글이 맨 앞에 삽입되면 같은 아이템이 두 번 보이거나 사라짐
- 정확성: 페이지 간 구분이 흔들림 (delete, update로 순서 변경)
커서 기반 (WHERE (created_at, id) < (...)) 이 정답.
Q3. React Query의 staleTime 과 gcTime 차이는?
정답
staleTime: 데이터가 fresh 로 간주되는 시간. 이 동안은 refetch 안 함.gcTime: 쿼리가 사용되지 않을 때(모든 observer 제거 후) 메모리에 유지되는 시간. 이후 캐시 제거.
staleTime은 "언제 다시 가져올까?", gcTime은 "언제 잊을까?".
Q4. 이중 결제를 막기 위해 FE가 해야 할 일은?
정답
- 결제 버튼 클릭 즉시 비활성화 (isLoading)
Idempotency-Key헤더 생성·첨부- 네트워크 에러 재시도 시 같은 키 재사용
- 성공 응답 전 탭/새로고침에 대비해 localStorage에 "진행 중" 표시 후 복귀 시 서버 확인
- 서버가 멱등성 처리를 하도록 백엔드 확인
Q5. 모든 날짜를 UTC로 저장·전송해야 하는 이유는?
정답
- 단일 진실 공급원 — 서버·클라·DB 모두 같은 기준
- DST(서머타임) 에 따른 모호함 제거
- 다국어 서비스 에서 사용자 타임존 변환이 용이
- 문자열 비교·정렬이 단순
표시 시점에만 사용자 로케일·타임존 적용 (Intl.DateTimeFormat).
Q6. 캐시 무효화를 과도하게 하면 어떤 문제가 생기는가?
정답
- 쓸데없는 refetch → 네트워크·서버 부하
- 반짝이는 스켈레톤 → 체감 성능 저하
- 낙관적 업데이트가 즉시 구 서버 응답으로 덮임
해결: 무효화 범위를 정확히 (해당 리스트만), stale-while-revalidate로 UX 유지, exact: false 옵션 주의해서 쓰기.
Q7. 동시 편집 충돌을 "Last Write Wins" 로 처리하면 어떤 UX 문제가 있는가?
정답
한 사용자의 변경이 말없이 사라짐. 특히 협업 문서에서 치명적. 해결:
- If-Match ETag 로 충돌 감지 → 충돌 안내 UI
- 필드별 낙관적 잠금 (제목/본문 각각)
- CRDT 기반 실시간 병합 (Yjs)
- 단순 앱이면 "N분 전 다른 사용자가 수정했습니다, 덮어쓰시겠습니까?" 다이얼로그
13. 체크리스트
- N+1 쿼리를 Network 패널에서 알아챌 수 있다
- Cursor 페이지네이션을 설계·구현할 수 있다
- 서버 상태와 클라이언트 상태를 구분해 관리한다
- React Query의 staleTime/gcTime/invalidation을 적절히 설정한다
- 멱등성 키로 이중 결제를 방지한다
- 타임존을 UTC 기준으로 일관되게 처리한다
- 동시 편집 충돌에 대한 전략을 가진다
- 실시간 데이터를 폴링/SSE/WebSocket 중 적절히 선택한다
- 백엔드와 API 설계 초기에 올바른 질문을 던진다