내가 숨겨진 루프 하나를 고쳐 API 지연 시간을 95% 줄인 방법 ❤️‍🔥

발행: (2026년 1월 31일 오후 01:28 GMT+9)
14 min read
원문: Dev.to

Source: Dev.to

위에 제공된 링크 외에 번역할 실제 텍스트가 포함되어 있지 않습니다. 번역을 원하는 본문을 제공해 주시면 한국어로 번역해 드리겠습니다.

트리거

“이봐, 조직 대시보드가 일부 고객에게 정말 느리게 느껴져. 약… 3–4 초 정도 느려.”

나는 한숨을 쉬었다 🥴 – 또 다른 성능 티켓.

  • 로컬에서 엔드포인트를 호출 → 모든 것이 정상으로 보였다.
  • 스테이징을 확인 → “아마도 그들의 네트워크일 거야,” 라고 생각하고 탭을 닫았다.

하지만 불만은 계속 이어졌다.

증상

  • 오류도 없고, 크래시도 없음.
  • 눈에 띄는 CPU 급증도 없음.
  • 서비스 기술적으로 정상.

그럼에도 불구하고, 프로덕션 지연 시간이 존경받을 만한 200 ms에서 피크 시간대에 불편할 정도로 3–4 seconds까지 늘어났다.

이 엔드포인트의 코드는 최근에 수정되었다. 깔끔하고 관용적인 Go 코드였으며—리뷰에서 훑어보고 바로 신뢰할 수 있는 종류였다. 이전 개발자는 탄탄했다—명백한 실수도 없고, 빨간 깃발도 없었다.

그래도 뭔가 이상했다.

트레이싱으로 파고들기

저는 SigNoz를 열고 해당 엔드포인트로 필터링한 뒤, 가장 느린 요청 중 하나의 트레이스를 클릭했습니다.

제가 찾은 것은 버그가 아니었습니다.
그것은 패턴이었으며 – 우리 데이터베이스를 조용히 질식시키고 있었습니다.

패턴: N+1 쿼리

한 번도 들어본 적이 없다면, 간단히 설명하겠습니다:
코드가 하나의 쿼리로 아이템 목록을 가져오고, 그 목록을 순회하면서 N개의 추가 쿼리를 실행합니다 – 아이템당 하나씩.

Go 코드 (완벽하게 보입니다)

// Fetch all workspaces for an organization
workspaces, err := repo.GetWorkspaces(orgID)
if err != nil {
    return err
}

// For each workspace, fetch its storage stats
for _, ws := range workspaces {
    storage, err := repo.GetWorkspaceStorage(ws.ID) // 🔴 This is the problem
    if err != nil {
        return err
    }
    // ... do something with storage
}

코드는 보기에는 깔끔하지만, 내부적으로는 다음과 같이 변환됩니다:

쿼리SQL (단순화)
#1SELECT * FROM workspaces WHERE org_id = 123 (gets all 100 workspaces)
#2SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 1
#3SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 2
#101SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 100
  • 각각의 개별 쿼리는 빠릅니다 – 약 8–10 ms.
  • 하지만 200개의 쿼리 × 10 ms = 2000 ms의 순수 데이터베이스 시간이 소요됩니다. 🥳

막다른 길

1️⃣ 로그

  • 느린 엔드포인트에 대한 필터링된 로그.
  • 모든 쿼리가 8–12 ms에 완료되었습니다.
  • 로그가 거짓말을 한 것이 아니라, 단지 잘못된 이야기를 하고 있었을 뿐입니다.

2️⃣ 기본 메트릭

  • CPU: 정상.
  • 유일한 이상 현상: API 지연.

나는 막혔어요 😔

돌파구: 요청 추적

  1. 문제의 엔드포인트를 Filter합니다.
  2. waterfall view를 로드합니다.

건강한 서비스 트레이스는 지루해 보입니다:

HTTP handler → short
Go application logic → tiny sliver

하지만 내 트레이스는 다음과 같이 나타났습니다:

[DB] SELECT * FROM workspaces WHERE org_id = ?   (15 ms)
[DB] SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 1   (9 ms)

[DB] SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 100   (9 ms)

아래로 스크롤하면 217개의 데이터베이스 쿼리가 보였습니다 – 바로 범인 🚨.

트레이스가 무시할 수 없게 만든 점

  1. 쿼리 수 폭증 – 40배 이상 차이.
  2. 지연 워터폴 – 애플리케이션 코드 ~45 ms, DB 시간 ~2 s.
  3. 패턴 – 전형적인 N+1.

N+1 문제는 단일 쿼리가 느리지 않기 때문에 슬로우‑쿼리 로그에 나타나지 않습니다. 트레이스가 필요합니다.

나는 트레이스를 한 번에 한 분 동안 바라보았습니다: 217개의 쿼리.

코드를 작성한 개발자는 부주의하지 않았습니다; 그들은 5개의 워크스페이스만으로 테스트했었습니다.

진짜 두더지

workspaces, _ := repo.GetWorkspacesByOrg(ctx, orgID)
for _, ws := range workspaces {
    used, _ := repo.GetWorkspaceStorageUsed(ctx, ws.ID)
    total += used
}
  • 목록을 가져오기 위한 하나의 쿼리.
  • 루프 안에서 워크스페이스당 하나의 쿼리.

프로덕션에서는 “워크스페이스가 적다”는 가정이 조용히 무너졌다.

해결책

두 가지 의도적인 변경

  1. 루프 안에서 쿼리를 수행하지 않기.
  2. 필요한 데이터를 한 번의 쿼리로 모두 가져오기 (또는 최소한 배치 처리).

이전 (개념)

// For each workspace, fetch its storage stats (N+1!)
for _, ws := range workspaces {
    storage, _ := repo.GetWorkspaceStorage(ws.ID)
    // …
}

이후 (개념)

// Fetch workspaces
workspaces, _ := repo.GetWorkspacesByOrg(ctx, orgID)

// Fetch all workspace storage usage in ONE query
storages, _ := repo.GetAllWorkspaceStoragesByOrg(ctx, orgID)

// Map storages to workspaces and process

코드 변경은 약 20 분 정도 걸렸으며, 효과는 즉시 나타났습니다.

데이터베이스 뷰가 중요한 이유 🥳

우리는 이미 물리화된 스타일의 뷰를 가지고 있었습니다:

CREATE OR REPLACE VIEW workspace_storage_view AS
SELECT
    workspace_id,
    COALESCE(SUM(size), 0) AS used_bytes
FROM files
WHERE type != 'folder'
GROUP BY workspace_id;
  • 이 뷰는 수백만 행에 걸친 무거운 SUM(size)미리 집계합니다.
  • 이를 사용하지 않으면, “수정”이 단순히 비용이 많이 드는 집계를 각 요청 쿼리로 옮겨 성능을 여전히 저하시켰을 것입니다.

뷰를 사용하면 집계가 한 번만 수행되고, 애플리케이션은 준비된 결과만 읽게 됩니다.

주요 요약

  • N+1 쿼리는 로그와 기본 메트릭에서 보이지 않습니다. 각 쿼리가 빠르기 때문입니다.
  • 분산 추적은 숨겨진 패턴을 밝혀줍니다.
  • 반복마다 데이터베이스에 접근하는 루프는 항상 의심하세요.
  • 지연 시간을 낮게 유지하려면 배치 쿼리나 사전 집계 뷰를 사용하세요.

제가 추측을 멈추고 단일 요청을 처음부터 끝까지 추적했을 때, 문제는 부인할 수 없게 되었습니다.

배포 완료

그런 다음 모니터링 대시보드를 열고 지켜보았습니다.

엔드포인트의 지연 라인이 절벽처럼 떨어졌습니다.

메트릭 비교

메트릭개선
요청당 쿼리 수2171217배 감소
평균 응답 시간~2.8 s~80 ms35배 빠름
P95 지연~4.2 s~120 ms35배 개선
DB CPU 사용량~65 %~12 %82 % 감소

가장 큰 고객의 엔드포인트를 새로고침했습니다:

  • 78 ms.
  • 다시 새로고침 – 82 ms.

그래프를 보며 다섯 분 동안 그 자리에 앉아 있었습니다. 스파이크는 없었습니다.

가장 좋은 메트릭
“어제 뭘 했든 대시보드 불만이 완전히 사라졌어. 사용자들이 다시 행복해졌어.”

그게 구조적인 쿼리 하나를 바꾼 것이라고는 말하지 않았어요. 그냥 이렇게 말했죠:

“뭔가 고쳤어.” 😁 (그때는 그에게 말했어요.)

N+1 문제를 해결한 느낌이 정말 좋았습니다. 지연 시간이 3 seconds에서 ~80 ms로 떨어지는 것을 보는 순간 도파민이 폭발했습니다.

내가 배운 것

나는 깨끗한 코드면 충분하다고 생각했었다—읽기 쉬운 루프, 적절한 오류 처리, 관심사의 분리. 실제 교훈은 JOINs이나 데이터베이스 뷰에 관한 것이 아니었다.

데이터베이스는 루프가 아니라 집합으로 생각한다.

이제 나는 실제 볼륨으로 테스트한다.

“한 번에 할 수 있을까?”
왜냐하면 수정이 하나의 쿼리 변경이었기 때문이다.

N+1을 피하기 위한 최고의 습관

  1. 루프 안에서 데이터베이스를 쿼리하지 마세요. 절대. (정당한 예외는 매우 드뭅니다.)

  2. 행이 아니라 집합으로 생각하세요.

    • 데이터베이스는 집합 연산에 강합니다 — JOIN, IN 절, 대량 읽기 등.
    • “각 항목마다 데이터가 필요해요” → “이 데이터를 한 번에 모두 가져와야 해요.”
  3. 개발 환경에서 쿼리 로깅을 활성화하세요.

    // Go with GORM
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Info), // Shows all queries
    })

    sqlx 또는 database/sql을 사용할 때는 쿼리를 로깅 미들웨어로 감싸고 로컬 개발 환경 설정에 포함시키세요.

  4. 엔드포인트별 쿼리 수 예산을 설정하세요.
    예시 예산: 상세 페이지(단일 리소스): ≤ 3개의 쿼리

  5. 쿼리 수를 검증하는 테스트를 작성하세요.

    func TestGetOrgStorageUsage_QueryCount(t *testing.T) {
        // Setup test with 200 workspaces
        queryLog := &QueryLogger{}
        service := NewWorkspaceService(queryLog)
    
        service.GetOrgStorageUsage(ctx, orgID)
    
        if queryLog.Count() > 2 {
            t.Errorf("Expected ≤ 2 queries, got %d", queryLog.Count())
        }
    }
  6. 프로덕션 규모 데이터로 프로파일링하세요.

    // ⚠️ N+1 risk: loops over workspaces
  7. 놓친 부분을 잡기 위해 APM 도구를 사용하세요.

    • “요청당 쿼리 수 > 임계값”에 대한 알림을 설정하세요.
  8. ORM의 데이터 가져오기 패턴을 익히세요.
    루프보다 IN 절을 선호하세요:

    SELECT * FROM table WHERE id IN (?, ?, ?);
  9. 코드 리뷰에 “쿼리 수”를 포함시키세요.

    • 이 루프가 데이터베이스 호출을 하나요?
  10. 확신이 서지 않을 때는 측정하세요.
    요청당 쿼리 수와 응답 지연 시간을 추적하세요.

이러한 습관이 모든 성능 문제를 없애지는 못하지만, 대부분의 N+1 문제를 잡아낼 수 있습니다.

놓치기 쉬운 10 %

N+1을 다른 방식으로 처리했거나 더 좋은 방법이 있다고 생각한다면, 알려 주세요.

즐거운 독서 되세요. 쿼리 로그를 확인해 보세요 — 기다리고 있겠습니다. ❤️

Back to Blog

관련 글

더 보기 »