I/O와 네트워크 시점에서 코드 읽기: 언제, 얼마나 락을 걸고 기다리는가

코드 한 줄이 실행될 때 브라우저, 네트워크, DB에서 실제로 얼마의 비용이 발생하는가? 대용량 트래픽 환경에서 트레이드오프를 줄이는 실전 관점을 총정리합니다.

I/O와 네트워크 시점에서 코드 읽기: 언제, 얼마나 락을 걸고 기다리는가

코드 한 줄이 실행될 때 브라우저, 네트워크, DB에서 실제로 얼마의 비용이 발생하는가? 대용량 트래픽 환경에서 트레이드오프를 줄이는 실전 관점을 총정리합니다.


도입: 당신의 코드는 지금 어디서 기다리고 있는가?

PR 리뷰를 할 때, 혹은 레거시 코드를 읽을 때 이런 경험이 있지 않으신가요?

"로직은 맞는데... 왜 프로덕션에서 이렇게 느리지?"

대부분의 병목과 비용은 CPU 계산이 아니라 네트워크·디스크 I/O를 기다리는 시간에서 발생합니다. 코드 한 줄이 "기다리는 방식"을 바꾸면 서버 비용이 절반이 되고, 응답 속도가 수십 배 빨라지는 경우가 실제 운영 환경에서 빈번하게 일어납니다.

이 글은 프론트엔드, 백엔드, 풀스택, 플랫폼·인프라 엔지니어 모두가 코드를 읽을 때 트레이드오프를 빠르게 감지하고, 대용량 트래픽 환경에서도 비용과 안정성을 동시에 잡을 수 있는 실전 관점 프레임워크를 정리합니다.


1. I/O와 네트워크 시점: "언제, 얼마나 락을 걸고 기다리는가?"

Non-blocking & Delegation: 정말 지금 기다려야 하는가?

코드를 읽을 때 가장 먼저 물어야 할 질문은 이것입니다.

"이 작업을 메인 스레드나 API 응답 흐름에서 끝까지 기다려야 하는가?"

예를 들어 사용자가 상품을 구매하는 순간, 아래 세 가지 작업이 동시에 실행된다고 가정해봅시다.

  1. 결제 처리 (필수, 동기 대기 필요)
  2. 구매 분석 로그 저장 (비필수, 실패해도 UX 무관)
  3. 마케팅 CRM에 이벤트 전송 (비필수, 지연 허용)

2번과 3번을 API 응답 흐름 안에서 동기로 처리하면, CRM 서버가 느릴 때마다 사용자의 구매 완료 화면이 몇 초씩 지연됩니다. 이런 구조가 숨겨진 단일 장애점(SPOF) 입니다.

개선 패턴:

  • 브라우저에서: navigator.sendBeacon() 또는 fetch({ keepalive: true })로 페이지가 닫혀도 전송을 위임
  • 서버에서: AWS SQS, Redis Queue 등 백그라운드 큐로 넘기고 즉시 200 OK 응답
// ❌ 나쁜 예: CRM 응답을 기다리며 결제 응답이 지연됨
await sendToCRM(purchaseData);
await saveAnalyticsLog(purchaseData);
return { success: true };

// ✅ 좋은 예: 큐에 넣고 즉시 응답, 실제 처리는 Worker가 담당
await queue.send('crm-events', purchaseData);
await queue.send('analytics-logs', purchaseData);
return { success: true }; // CRM 응답 대기 없이 즉시 반환

실제 사례: 국내 한 이커머스 서비스는 결제 완료 API에서 Slack 알림 전송을 동기로 처리하다가, Slack API 지연 시 결제 완료 응답이 5초 이상 걸리는 장애를 경험했습니다. SQS 비동기 분리 후 응답 시간이 200ms 이하로 줄었습니다.


2. 배치(Batching)와 N+1 쿼리: "100번 호출을 1번으로 묶을 수 있는가?"

N+1 문제 예시: 가장 흔한 성능 킬러

배치 처리를 이해하는 가장 빠른 방법은 N+1 쿼리 문제를 직접 보는 것입니다.

시나리오: 쇼핑몰 관리자 페이지에서 최근 주문 10건과 각 주문의 상품명을 조회합니다.

// ❌ N+1 발생 패턴: DB 왕복이 1 + 10 = 11번 발생
const orders = await db.query('SELECT * FROM orders LIMIT 10'); // 1번

for (const order of orders) {
  // 루프를 돌며 매번 DB에 개별 요청 → 10번 추가 발생
  order.items = await db.query(
    'SELECT * FROM items WHERE order_id = ?',
    [order.id]
  );
}

주문이 10건이면 DB 요청이 11번이지만, 주문이 1,000건이면 1,001번의 네트워크 왕복(RTT) 이 발생합니다. 트래픽이 높을수록 이 비용은 선형이 아니라 기하급수적으로 DB에 부담을 줍니다.

배치(Batch) 처리로 개선:

// ✅ Batch 처리: DB 왕복 총 2번으로 해결
const orders = await db.query('SELECT * FROM orders LIMIT 10'); // 1번

const orderIds = orders.map((o) => o.id); // [1, 2, 3, ..., 10]
const items = await db.query(
  'SELECT * FROM items WHERE order_id IN (?)',
  [orderIds]
); // 1번으로 전체 조회

// 메모리에서 매핑 (네트워크 비용 없음)
const itemsByOrderId = groupBy(items, 'order_id');
for (const order of orders) {
  order.items = itemsByOrderId[order.id] ?? [];
}
방식DB 요청 횟수 (주문 1,000건 기준)예상 응답 시간
N+1 패턴1,001번수십 초
Batch (IN 절)2번수백 ms

GraphQL 환경에서의 N+1: GraphQL을 사용할 경우 리졸버(Resolver)가 필드마다 개별 DB 호출을 유발하기 쉽습니다. 이때 DataLoader 패턴이 배치 처리 역할을 대신합니다. DataLoader는 동일한 요청 사이클 내의 개별 조회를 모아서 한 번의 IN 쿼리로 변환해줍니다.

프론트엔드 관점의 배치: 백엔드만의 문제가 아닙니다. React 컴포넌트가 마운트되면서 각각 독립적인 fetch를 10번 날리는 구조도 N+1과 동일한 문제입니다. Promise.all()로 병렬 처리하거나, 부모 컴포넌트에서 한 번에 데이터를 가져와 props로 전달하는 구조가 올바른 배치 처리입니다.


3. 컴퓨팅·실행 시점: "동일한 계산을 반복하고 있는가?"

React 19의 fetch 캐싱, 정확히 어디까지인가?

"리액트 19면 기본적으로 fetch에 캐싱이 되는 거 아닌가요?"

이 질문은 실제 현장에서 가장 자주 오해가 생기는 지점입니다. 반은 맞고 반은 틀립니다.

  • React 19의 cache()와 Request Memoization: 단일 서버 컴포넌트 렌더링 패스(Single Render Pass) 안에서 동일한 fetch를 여러 컴포넌트가 각각 호출해도 실제 네트워크 요청은 1번만 나갑니다. 렌더링 사이클 내 중복 제거가 목적입니다.
  • Next.js 15+의 중요한 변화: 이전 Next.js 13~14에서는 fetch가 기본으로 강력하게 캐싱(Data Cache)되었습니다. 그러나 Next.js 15부터는 기본값이 uncached(캐싱 안 함)로 변경되었습니다. 명시적으로 cache: 'force-cache'나 next: { revalidate: 60 }을 지정해야만 공유 캐시가 동작합니다.
// Next.js 15+ 환경
// ❌ 이제 이것만으로는 캐싱이 되지 않습니다
const data = await fetch('https://api.example.com/products');

// ✅ 명시적으로 캐시 전략을 지정해야 합니다
const data = await fetch('https://api.example.com/products', {
  next: { revalidate: 60 }, // 60초마다 재검증 (ISR 방식)
});

지연 실행(Lazy)과 Dynamic Import: 개념 정확히 구분하기

"지연 실행은 Dynamic Import를 말하는 건가요? 그게 지연 실행은 아니지 않나요?"

鋭한 질문입니다. Dynamic Import는 지연 실행의 한 종류이지만, 지연 실행이 Dynamic Import만을 의미하지는 않습니다.

1) 코드 차원의 지연: Dynamic Import / Code Splitting

초기 화면 렌더링에 불필요한 무거운 모듈(결제 팝업, 차트 라이브러리, PDF 뷰어 등)을 사용자가 실제로 그 기능을 필요로 하는 시점에 로드합니다.

// 결제 버튼 클릭 시점에 결제 모듈 로드 (초기 번들에서 제외)
const PaymentModal = React.lazy(() => import('./PaymentModal'));

2) 데이터 차원의 지연: Lazy Loading / On-demand Query

DB 조회 시 불필요한 연관 데이터를 처음부터 JOIN해서 전부 가져오는 대신, 실제로 그 데이터에 접근하는 시점에 쿼리를 날리는 방식입니다.

  • ORM의 Lazy Relation Loading이 대표적
  • SELECT * 대신 실제 필요한 컬럼만 명시하는 것도 같은 철학

지연 실행의 핵심 철학: "지금 당장 실행하거나 로드할 필요가 없는 비용은 실제로 필요한 순간까지 미룬다(Lazy)." 이것이 초기 응답 시간과 메모리 사용량을 동시에 줄이는 원리입니다.


4. 서버리스와 DB 커넥션: 트래픽이 폭발할 때 진짜 일어나는 일

서버리스는 트래픽 폭발이 오히려 저렴하지 않나요?

"서버리스에서 트래픽이 튀었을 때가 오히려 좋은 거 아닌가요? 비용이 덜 드는 거 아닌가요? 커넥션에 제한이 따로 없다보니까?"

이 오해가 실제 운영 환경에서 수천만 원짜리 청구서(Bill Shock) 로 이어지는 경우가 있습니다. 두 가지 차원에서 명확히 정리합니다.

① 서버리스는 트래픽이 폭발하면 비용도 폭발합니다

고정형 서버(EC2)는 트래픽이 아무리 몰려도 월 비용은 고정(예: 50만 원)입니다. 반면 Lambda, Vercel Functions 같은 서버리스는 호출 횟수 × 실행 시간 × 메모리로 과금됩니다.

실제 사례: 한 스타트업이 이벤트 페이지를 배포했는데 외부에서 자동화 봇 트래픽이 몰렸습니다. EC2였다면 서버가 다운되었을 것이고 비용은 고정이었겠지만, Vercel + Lambda 구조였기 때문에 6시간 만에 약 1,200만 원의 청구서를 받은 사례가 해외에서 보고된 바 있습니다.

② DB 커넥션 제한은 서버리스의 최대 약점입니다

기존 서버는 커넥션 풀(Connection Pool, 예: 최대 20개)을 유지하며 DB와 통신합니다. 서버리스는 트래픽이 튀는 순간 Lambda 인스턴스가 수백~수천 개로 순간 Scale-Out됩니다.

이때 1,000개의 Lambda 인스턴스가 각각 PostgreSQL에 새 커넥션을 맺으려 하면, DB의 Max Connection 제한을 초과하여 DB 전체가 응답 불가 상태(Connection Exhaustion)가 됩니다. "커넥션 제한이 없다"는 것은 Lambda 인스턴스 수의 이야기이지, DB 커넥션 수의 이야기가 아닙니다.

해결 구조: 서버리스 + 커넥션 풀러 필수 조합

[사용자 폭발적 요청]
        │
        ▼
[Lambda / Vercel Function] (수천 개 생성 가능)
        │
        ▼
[AWS RDS Proxy / PlanetScale / Neon]  ← 커넥션 풀러
  (실제 DB 커넥션은 예: 50개로 제한, 대기열 관리)
        │
        ▼
[PostgreSQL / MySQL RDS]  ← 안전하게 보호됨

그렇다면 서버리스를 어떻게 써야 하는가?

트래픽이 예측 불가능하게 폭증하는 구간에 서버리스가 최고의 무기인 것은 변함이 없습니다. 핵심은 "서버리스 전단이 받아낸 트래픽이 뒤의 DB까지 그대로 밀고 들어가 연쇄 장애(Cascading Failure)를 일으키지 않도록 충격 완화 장치를 두는 것" 입니다.

하이브리드(Hybrid) 아키텍처 비율 전략:

트래픽 유형권장 인프라이유
24시간 상시 기본 트래픽ECS/EKS/EC2 (컨테이너)단가 효율이 훨씬 높음
이벤트·선착순 피크 트래픽Lambda / Vercel (서버리스)유연한 Scale-Out
DB 충격 완화SQS + Worker 분리처리 속도 조절(Throttling)

서버리스 내부 안전장치:

  • 동시성 제한(Reserved Concurrency): Lambda 인스턴스 최대 500개로 캡을 씌워 DB 커넥션 고갈 방지
  • 타임아웃(Timeout): 외부 API 응답이 늦어질 때 2~3초 내 강제 종료로 실행 시간 과금 방지
  • 서킷 브레이커(Circuit Breaker): 외부 의존 서비스 장애 시 연쇄 실패 차단

종합 정리: 트레이드오프를 줄이는 코드 읽기 체크리스트

지금까지 다룬 내용을 역할별로 코드를 읽을 때 즉시 적용할 수 있는 체크리스트로 정리합니다.

역할별 핵심 관점

프론트엔드 엔지니어

  • 컴포넌트마다 개별 fetch가 발생하는가? → Promise.all() 또는 데이터 패칭 계층 통합
  • Dynamic Import로 초기 번들에서 무거운 모듈을 분리했는가?
  • sendBeacon 또는 keepalive로 비필수 전송을 위임했는가?
  • Next.js 15+ 환경에서 캐시 전략이 명시적으로 설정되어 있는가?

백엔드 엔지니어

  • ORM이 N+1 쿼리를 발생시키는가? → IN 절 배치 또는 Eager Loading으로 전환
  • 비필수 외부 API 호출이 응답 흐름을 블로킹하는가? → 큐(Queue)로 분리
  • DB 쿼리에서 SELECT * 대신 필요한 컬럼만 추출하는가?

풀스택 / 프로덕트 엔지니어

  • 이 API
댓글 0개

댓글

댓글을 불러오는 중...

지원 문의