JUNSEOK
04 · 데이터베이스·16분·4개 레슨

FE가 자주 만나는 이슈

N+1, 페이지네이션, 동시성, 시간대 처리.

목표: 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. 이 장에 진입하기 전 전제

이 세 개를 안 봤다면 이 장이 추상적으로 느껴질 수 있다. 이 장은 이전 장들을 실전에 적용하는 챕터다.

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, ApollouseState, 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
비디오·WebRTCWebRTC

11. ⚠️ FE 입장에서 백엔드에 물어봐야 할 질문들

  • 이 엔드포인트 페이지네이션 방식은? (offset/cursor)
  • ?include= 파라미터로 관련 데이터 받을 수 있는가?
  • 정렬·필터 파라미터 규약은?
  • 에러 응답 형식({error, code, message}) 이 표준화돼 있는가?
  • 응답 타임스탬프는 UTC인가 로컬인가?
  • 멱등성 키 지원하는가?
  • Rate limit 정책은? (헤더 X-RateLimit-*)
  • 배치 API가 있는가? (POST /users/batch)
  • 대용량 다운로드는 스트리밍 지원하는가?

12. 연습 문제

Q1. N+1 문제를 FE가 인지하는 방법은?

정답
  1. Network 패널: 같은 도메인에 비슷한 GET이 연달아 N번
  2. 응답 시간이 리스트 크기에 선형 비례
  3. waterfall 에서 요청이 계단식으로 쌓임
  4. 백엔드와 협업해 관련 데이터 include 옵션, GraphQL, batch API 요구

Q2. 무한 스크롤에 Offset을 쓸 수 없는 이유는?

정답
  1. 성능: OFFSET이 커질수록 DB가 앞의 모든 행을 스캔 → 느려짐
  2. 중복·누락: 새 게시글이 맨 앞에 삽입되면 같은 아이템이 두 번 보이거나 사라짐
  3. 정확성: 페이지 간 구분이 흔들림 (delete, update로 순서 변경)

커서 기반 (WHERE (created_at, id) < (...)) 이 정답.

Q3. React Query의 staleTimegcTime 차이는?

정답
  • staleTime: 데이터가 fresh 로 간주되는 시간. 이 동안은 refetch 안 함.
  • gcTime: 쿼리가 사용되지 않을 때(모든 observer 제거 후) 메모리에 유지되는 시간. 이후 캐시 제거.

staleTime은 "언제 다시 가져올까?", gcTime은 "언제 잊을까?".

Q4. 이중 결제를 막기 위해 FE가 해야 할 일은?

정답
  1. 결제 버튼 클릭 즉시 비활성화 (isLoading)
  2. Idempotency-Key 헤더 생성·첨부
  3. 네트워크 에러 재시도 시 같은 키 재사용
  4. 성공 응답 전 탭/새로고침에 대비해 localStorage에 "진행 중" 표시 후 복귀 시 서버 확인
  5. 서버가 멱등성 처리를 하도록 백엔드 확인

Q5. 모든 날짜를 UTC로 저장·전송해야 하는 이유는?

정답
  1. 단일 진실 공급원 — 서버·클라·DB 모두 같은 기준
  2. DST(서머타임) 에 따른 모호함 제거
  3. 다국어 서비스 에서 사용자 타임존 변환이 용이
  4. 문자열 비교·정렬이 단순

표시 시점에만 사용자 로케일·타임존 적용 (Intl.DateTimeFormat).

Q6. 캐시 무효화를 과도하게 하면 어떤 문제가 생기는가?

정답
  • 쓸데없는 refetch → 네트워크·서버 부하
  • 반짝이는 스켈레톤 → 체감 성능 저하
  • 낙관적 업데이트가 즉시 구 서버 응답으로 덮임

해결: 무효화 범위를 정확히 (해당 리스트만), stale-while-revalidate로 UX 유지, exact: false 옵션 주의해서 쓰기.

Q7. 동시 편집 충돌을 "Last Write Wins" 로 처리하면 어떤 UX 문제가 있는가?

정답

한 사용자의 변경이 말없이 사라짐. 특히 협업 문서에서 치명적. 해결:

  1. If-Match ETag 로 충돌 감지 → 충돌 안내 UI
  2. 필드별 낙관적 잠금 (제목/본문 각각)
  3. CRDT 기반 실시간 병합 (Yjs)
  4. 단순 앱이면 "N분 전 다른 사용자가 수정했습니다, 덮어쓰시겠습니까?" 다이얼로그

13. 체크리스트

  • N+1 쿼리를 Network 패널에서 알아챌 수 있다
  • Cursor 페이지네이션을 설계·구현할 수 있다
  • 서버 상태와 클라이언트 상태를 구분해 관리한다
  • React Query의 staleTime/gcTime/invalidation을 적절히 설정한다
  • 멱등성 키로 이중 결제를 방지한다
  • 타임존을 UTC 기준으로 일관되게 처리한다
  • 동시 편집 충돌에 대한 전략을 가진다
  • 실시간 데이터를 폴링/SSE/WebSocket 중 적절히 선택한다
  • 백엔드와 API 설계 초기에 올바른 질문을 던진다

← 4-5. 트랜잭션 | 🏠 홈 | 5-1. 디자인 패턴과 SOLID →

진도 체크시작 전
NEXT

이 단계의 마지막 챕터예요

다음 단계는 아직 준비 중이에요. 1단계 챕터를 복습해보세요.

단계 목록으로 돌아가기