Grafana Loki에서 LogQL로 초당 에러율·P99 레이턴시를 대시보드에 올리기 — rate, sum by, quantile_over_time 쿼리 패턴
로그를 "검색"하는 것과 로그에서 "인사이트를 뽑아내는 것"은 꽤 다른 일입니다. 서비스에 장애가 났을 때 Grafana Explore(좌측 메뉴 → Explore) 탭에서 허겁지겁 로그를 들여다봤다가, 결국 그냥 grep으로 돌아간 경험이 한 번쯤 있으실 텐데요. 그 답답함의 원인은 대개 LogQL을 필터 도구로만 쓰고 있어서입니다.
LogQL을 제대로 쓰면 이야기가 달라집니다. 로그 라인 수천만 건을 실시간으로 집계해서 초당 에러율, P99 레이턴시, 서비스별 트래픽 히트맵까지 뽑아낼 수 있습니다. PromQL을 이미 알고 계신다면 집계 문법이 익숙하게 느껴질 거고, 처음 접하시더라도 개념 자체는 어렵지 않습니다.
이 글에서는 LogQL의 두 축인 스트림 셀렉터와 로그 파이프라인을 차근차근 짚고, rate·sum by·quantile_over_time 같은 메트릭 집계 연산자를 Grafana 대시보드 패널에 바로 붙일 수 있는 쿼리 패턴으로 정리했습니다.
전제 조건: 이 글은 Grafana Loki가 이미 배포·연동된 환경을 전제로 합니다. Loki 설치 과정은 다루지 않습니다.
목차
핵심 개념
LogQL의 두 구성 요소
LogQL은 Grafana Loki의 전용 쿼리 언어로, PromQL에서 영감을 받아 설계되었습니다. 구조는 단순합니다. 어떤 로그 스트림을 볼 건지 지정하는 스트림 셀렉터와, 그 스트림을 걸러내고 변환하는 로그 파이프라인 — 이 두 가지가 전부입니다.
{app="nginx", env="production"} |= "error" | json | status >= 500
└─── 스트림 셀렉터 ──────────┘ └──────── 로그 파이프라인 ────────────┘스트림 셀렉터는 Loki에 저장된 레이블 인덱스를 활용합니다. 범위를 좁힐수록 쿼리 비용이 급격히 낮아지는 이유가 여기 있습니다. 파이프라인 안의 필터링은 청크를 읽은 다음에 이루어지는 풀스캔이기 때문입니다.
스트림 셀렉터:
{key="value"}형태로 Loki에 적재된 로그를 레이블 기준으로 선택하는 구문. Loki는 로그 내용이 아닌 레이블만 인덱싱하기 때문에, 셀렉터가 좁을수록 I/O 비용이 줄어듭니다.
LogQL 쿼리는 반환 타입에 따라 두 종류로 나뉩니다.
| 유형 | 반환 값 | 주요 용도 |
|---|---|---|
| Log Query | 로그 라인(문자열) | Grafana Logs 패널, Explore 탐색 |
| Metric Query | 숫자 시계열 | Graph, Stat, Alert 패널 |
Metric Query는 Log Query에 rate(), count_over_time() 같은 집계 함수를 씌워서 만듭니다. 아래 두 섹션에서 이 흐름을 순서대로 짚어보겠습니다.
로그 쿼리(Log Query) 작성: 파이프라인 단계별 이해
파이프라인은 |로 연결되는 단계의 연속입니다. 각 단계가 하는 일을 알면 순서를 어떻게 배치해야 빠른지 자연스럽게 보입니다.
1단계 — Line Filter: 로그 라인 자체를 필터링
{app="api"} |= "ERROR" # 문자열 포함
{app="api"} != "healthcheck" # 문자열 제외
{app="api"} |~ "5[0-9]{2}" # 정규식 포함
{app="api"} !~ "GET|POST" # 정규식 제외Line Filter는 파이프라인에서 가장 비용이 낮습니다. 파싱 전에 실행되기 때문에 항상 파이프라인 맨 앞에 두는 것이 좋습니다. 저도 처음엔 | json 다음에 필터를 붙였다가 쿼리가 유독 느리다고 느꼈는데, 순서를 바꾸니 체감이 달라졌습니다.
2단계 — Parser: 비구조화된 로그를 레이블로 구조화
# JSON 로그 파싱
{app="api"} | json
# key=value 형식 (logfmt)
{app="worker"} | logfmt
# 위치 기반 패턴 추출
{app="nginx"} | pattern `<ip> - <user> [<_>] "<method> <path> <_>" <status> <bytes>`
# 정규식으로 named group 추출
{app="legacy"} | regexp `(?P<level>\w+) (?P<message>.+)`파서가 실행되고 나면 로그 라인의 필드들이 레이블로 등록됩니다. 이후 단계에서 status, duration 같은 이름으로 참조할 수 있게 됩니다. "파싱했는데 왜 필드가 없지?"라고 헤맸다면, 파서가 파이프라인 2단계에서야 실행된다는 걸 놓친 경우가 많습니다.
3단계 — Label Filter: 파싱된 필드로 조건 필터링
{app="api"} | json | status >= 500
{app="api"} | json | level = "error" | duration > 200ms
{app="api"} | json | __error__ != "" # 파싱 오류 디버깅__error__ 레이블은 Loki가 파싱 실패 시 자동으로 붙여주는 내부 레이블입니다. JSON 파싱이 안 된다 싶을 때 __error__ != ""로 어떤 로그들이 파싱에 실패하고 있는지 확인해 볼 수 있습니다. 공식 문서에서도 묻혀 있는 팁인데, 로그 포맷이 바뀌었을 때 실마리를 잡는 데 유용합니다.
4단계 — Line Format / Label Format: 출력 재정의
# 출력 라인 재포맷
{app="api"} | json | line_format "{{.level}} | {{.message}}"
# 레이블 이름 변경
{app="api"} | json | label_format response_time=duration, svc=appLogs 패널에서 가독성을 높이거나, 이후 집계 시 레이블 이름을 통일할 때 자주 씁니다.
메트릭 쿼리(Metric Query) 작성: Range Aggregation과 벡터 집계
파이프라인으로 원하는 로그를 걸러낸 다음, 여기에 집계 함수를 씌우면 Metric Query가 됩니다.
Range Aggregation: 로그에서 숫자 추출
두 종류가 있는데, unwrap 사용 여부로 나뉩니다.
unwrap 없이 — 로그 라인 자체를 집계
# 5분간 로그 라인 수
count_over_time({app="api"}[5m])
# 초당 로그 발생 비율
rate({app="api"}[5m])
# 5분간 바이트 합산 / 초당 바이트 비율
bytes_over_time({app="api"}[5m])
bytes_rate({app="api"}[5m])rate()는 count_over_time()을 범위 윈도우 초수로 나눈 값입니다. "초당 몇 개" 단위로 정규화되기 때문에 범위 벡터 크기가 달라져도 비교가 가능합니다. bytes_over_time과 bytes_rate는 각각 로그 라인의 바이트 합산과 초당 바이트를 계산합니다. 로그 볼륨 자체를 용량 단위로 모니터링할 때 유용합니다.
unwrap으로 — 숫자 필드 추출 후 집계
# 평균 응답 시간
avg_over_time({app="api"} | logfmt | unwrap duration [5m])
# 99th 퍼센타일 레이턴시
quantile_over_time(0.99, {app="api"} | json | unwrap response_time [5m])
# 최댓값
max_over_time({app="api"} | json | unwrap response_ms [5m])unwrap: 파싱으로 추출된 레이블 값을 숫자로 꺼내 집계에 사용하는 키워드.
unwrap duration은duration레이블 값을 float으로 변환합니다.1.5s,200ms같은 시간 단위도 자동으로 초 단위로 변환해 줍니다.
quantile_over_time은 T-digest 기반 근사 알고리즘을 사용합니다. Prometheus 기반의 정확한 분위수와 소폭 차이가 날 수 있으므로, P99 값이 예상과 다르게 나온다면 이 점을 염두에 두시면 됩니다.
벡터 집계: sum by, topk로 시리즈 묶기
Range Aggregation으로 뽑은 시계열을 레이블 기준으로 묶거나 집계하는 단계입니다.
# 서비스별 초당 에러율
sum by (service) (rate({env="prod"} |= "error" [5m]))
# 상위 10개 엔드포인트 에러 수
topk(10, sum by (path) (count_over_time({app="api"} | json | status >= 500 [10m])))
# HTTP 상태 코드별 요청 수
sum by (status) (count_over_time({app="nginx"} | json [5m]))에러 비율 계산은 PromQL의 그것과 똑같습니다. 분자엔 에러 조건 필터를, 분모엔 전체 요청을 놓으면 됩니다. 솔직히 이 패턴 하나만 알아도 실무 알림의 절반은 해결됩니다. 단, 서비스별 비율을 원한다면 분자와 분모 모두에 sum by (service)가 있어야 단일 스칼라가 아닌 서비스별 시리즈가 나옵니다.
# 서비스별 에러 비율(%) 계산
sum by (service) (rate({app="api"} | json | status >= 500 [5m]))
/
sum by (service) (rate({app="api"} | json [5m]))
* 100집계 연산자는 PromQL과 거의 같습니다. 실무에서 자주 쓰는 건 sum by와 topk 두 가지이고, 전체 목록은 Loki 공식 문서에서 확인하실 수 있습니다.
실전 적용
예시들을 읽는 순서대로 기능이 점층적으로 쌓이도록 구성했습니다. 예시 1의 기본 에러율 쿼리에 unwrap을 추가하면 예시 2의 레이턴시 쿼리가 되는 식입니다.
예시 1: 서비스별 에러율 모니터링 (Time series 패널)
마이크로서비스 환경에서 "지금 어느 서비스에서 에러가 튀고 있지?"를 한 번에 파악하고 싶을 때 씁니다.
sum by (service, method) (
rate(
{namespace="production"} | json | status =~ "5.." [5m]
)
)| 구성 요소 | 역할 |
|---|---|
{namespace="production"} |
production 네임스페이스 로그만 선택 |
| json |
로그를 JSON으로 파싱해 status, method 등을 레이블화 |
status =~ "5.." |
정규식으로 5xx 상태 코드만 필터 |
rate([5m]) |
5분 윈도우 기준 초당 에러 발생 비율 |
sum by (service, method) |
서비스+메서드 조합별로 합산 |
Grafana Time series 패널에 붙이면 각 서비스·메서드 조합이 개별 시리즈로 표시됩니다. 특정 서비스의 POST /payment 에러가 갑자기 치솟는 걸 바로 포착할 수 있습니다.
예시 2: P99 레이턴시 추이 (예시 1에 unwrap 추가)
예시 1의 기본 구조에서 rate 대신 quantile_over_time을 쓰고, unwrap으로 숫자 필드를 꺼내는 형태입니다. SLA 관리나 성능 회귀 탐지에 자주 씁니다.
quantile_over_time(0.99,
{app="api"} | json | unwrap response_time_ms [5m]
) by (service)| 구성 요소 | 역할 |
|---|---|
| json |
JSON 파싱으로 response_time_ms 필드를 레이블화 |
unwrap response_time_ms |
레이블 값을 숫자로 꺼냄 |
quantile_over_time(0.99, ...[5m]) |
5분간 수집된 값의 99th 퍼센타일 계산 |
by (service) |
서비스별 분리 |
quantile_over_time(φ, ...): 범위 내 값들의 φ(0~1) 분위수를 계산합니다.
0.99면 P99,0.5면 중앙값입니다.avg_over_time과 달리 이상값의 영향을 덜 받습니다. 다만 Loki에서는 T-digest 기반 근사값으로 계산되므로, Prometheus 히스토그램 기반 분위수와 소폭 차이가 날 수 있습니다.
예시 3: AWS ALB 액세스 로그에서 응답 시간 추출
ALB 로그는 구조화되어 있지 않아서 pattern 파서가 아주 유용합니다.
avg_over_time(
{job="alb-access"}
| pattern `<time> <elb> <client_ip>:<client_port> <_> <_> <request_processing_time> <backend_processing_time> <_> <elb_status_code> <_> <received_bytes> <sent_bytes> <request>`
| backend_processing_time > 0
| unwrap backend_processing_time [1m]
) by (elb_status_code)<_>는 추출하지 않을 필드를 건너뛰는 와일드카드입니다. 필요한 필드만 이름을 지정하면 되니까, 컬럼 순서가 고정된 접근 로그 형식에는 pattern 파서가 정규식보다 훨씬 읽기 쉽습니다.
ALB는 백엔드 연결에 실패하면
backend_processing_time을-1로 기록합니다.| backend_processing_time > 0필터 없이avg_over_time을 쓰면 -1 값이 섞여 평균이 왜곡됩니다. 실무에서 한 번은 꼭 밟는 함정입니다.
예시 4: 파싱 오류 디버깅
새로 수집된 로그 포맷이 바뀌었을 때, 어떤 라인이 파싱 실패인지 빠르게 확인할 수 있습니다.
{app="api"} | json | __error__ = "JSONParserErr"
| line_format "PARSE_ERR: {{__line__}}"__line__은 원본 로그 라인 전체를 참조하는 내부 변수입니다. 어떤 원문이 파싱에 실패하고 있는지 그대로 볼 수 있어서 포맷 변경을 추적할 때 매우 유용합니다.
예시 5: 인증 실패 알림 (Grafana Alerting용)
sum(
count_over_time({app="auth-service"} |= "authentication failed" [1m])
)이 쿼리를 Grafana Alert 규칙에 붙이고, Grafana Alerting의 Threshold를 100으로 설정하면 됩니다. 쿼리 자체에 > 100을 넣어두는 방식은 Grafana Alert 평가 로직과 충돌하는 경우가 있어, 임계값은 패널 설정에 맡기는 편이 안전합니다. 브루트포스 공격 탐지 용도로 실무에서 자주 맞닥뜨리는 패턴입니다.
예시 6: 로그 볼륨 히트맵 (로그 레벨별 분포)
sum by (level) (
count_over_time({app="api"} | json [1m])
)이 쿼리는 Grafana 패널 종류에 따라 붙이는 방식이 조금 다릅니다.
- Bar chart 패널: 위 쿼리를 그대로 붙이면 됩니다. 시간대별
info/warn/error바가 스택으로 표시됩니다. 배포 직후error레벨이 튀는지 확인하는 데 이 방식이 더 직관적입니다. - Heatmap 패널: Heatmap은 레이블 값이 숫자 버킷이어야 잘 동작합니다. 레이블 문자열 기반의 분포를 Heatmap으로 표현하려면 패널 설정에서 추가 조정이 필요하며, 로그 레벨 분포라면 Bar chart 패널이 더 적합합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 낮은 인덱싱 비용 | 로그 내용이 아닌 레이블만 인덱싱 → 저장 비용 대폭 절감 |
| PromQL 친화적 | 집계 문법이 PromQL과 유사 → 기존 Prometheus 사용자 학습 곡선 완만 |
| 파이프라인 유연성 | 쿼리 시점에 JSON/logfmt/regexp 파싱 → 인덱스 스키마 사전 설계 불필요 |
| 통합 관찰성 | Grafana에서 메트릭(Prometheus) + 로그(Loki) + 트레이스(Tempo) 연동 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 풀스캔 비용 | 레이블 범위가 넓으면 대량 청크를 읽어야 함 | 스트림 셀렉터를 최대한 좁게 지정 |
| 카디널리티 폭발 | 파싱으로 추출한 고유값 레이블을 by에 남용 시 시리즈 폭발 |
user_id, request_id 같은 고카디널리티 레이블은 집계 키로 쓰지 않기 |
count_over_time + unwrap 혼용 불가 |
count_over_time은 unwrap 값을 받지 않음 |
합산에는 sum_over_time 사용 |
| 정규식 성능 | |~ 정규식 필터는 |= 문자열 필터보다 느림 |
가능하면 문자열 필터 우선 사용 |
| 파이프라인 순서 민감 | 라인 필터를 파이프라인 뒤에 두면 불필요한 파싱 비용 발생 | 라인 필터를 파이프라인 맨 앞에 배치 |
| quantile_over_time 근사값 | T-digest 기반이라 Prometheus 분위수와 소폭 차이 가능 | 정확한 분위수가 필요하면 Prometheus 히스토그램 메트릭 사용 고려 |
카디널리티(Cardinality): 레이블이 가질 수 있는 고유 값의 수.
env는production,staging등 소수라 저카디널리티이지만,user_id는 수백만 개의 고유 값이 있어 고카디널리티입니다. 고카디널리티 레이블을 집계 키로 쓰면 메모리와 저장소 비용이 폭발적으로 늘어납니다.
실무에서 가장 흔한 실수
-
스트림 셀렉터를 너무 넓게 잡는 것 —
{}처럼 비워두거나 범위가 넓으면 Loki가 전체 청크를 스캔하게 되어 타임아웃이 발생합니다. 항상app,namespace,env등의 레이블로 범위를 먼저 좁혀두는 것이 중요합니다. -
라인 필터를 파이프라인 뒤에 배치하는 것 —
| json | status >= 500 |= "timeout"보다|= "timeout" | json | status >= 500순서가 훨씬 빠릅니다. 파싱 전에 라인 필터로 사전 필터링하면 파싱해야 할 로그 수 자체가 줄어듭니다. -
[5m]범위 벡터를 너무 짧게 잡는 것 — 로그 밀도가 낮은 서비스에서[1m]을 쓰면 집계 결과가 듬성듬성하거나 0으로 표시될 수 있습니다. 또한 Grafana 패널의 Step 값이 범위 벡터보다 크면 데이터 포인트 누락이 생기니, Step은 범위 벡터보다 작게 유지하는 것이 좋습니다.
마치며
처음엔 Explore에서 |= "error" 필터 하나 쓰는 게 전부였는데, sum by 하나 알고 나서 대시보드 구성 방식 자체가 바뀌었습니다. 로그를 "보는" 도구에서 "측정하는" 도구로 전환되는 느낌이랄까요.
Loki를 이미 쓰고 있다면, 지금 당장 Grafana 대시보드에 LogQL 기반 패널 하나를 올려보시는 것을 권장합니다.
지금 바로 시작해볼 수 있는 3단계:
-
Grafana Explore에서 스트림 셀렉터부터 확인해 보시면 좋습니다 —
{app="your-service"}를 Explore(좌측 메뉴 → Explore) 탭에 입력하고 로그가 올바르게 수집되고 있는지 먼저 확인할 수 있습니다. 레이블이 어떻게 붙어 있는지 파악하는 것이 모든 쿼리의 출발점입니다. -
에러율 쿼리를 복사해서 Time series 패널에 붙여넣어 보시면 좋습니다 — 이 글의 '예시 1' 쿼리에서
namespace와service레이블만 본인 환경에 맞게 바꾸면 됩니다.sum by (service)결과가 패널에 시리즈로 표시되면 성공입니다. -
P99 레이턴시 쿼리로 확장해 보시면 좋습니다 — 로그에 응답 시간 필드가 있다면
unwrap과quantile_over_time(0.99, ...)을 조합해서 SLA 모니터링 패널을 추가해 볼 수 있습니다.avg_over_time과 나란히 놓으면 평균과 꼬리 레이턴시의 차이를 한눈에 볼 수 있어서 이상 징후 탐지에 유용합니다.
참고 자료
시작하기 좋은 링크
추가 참고
- Query Loki | Grafana Loki 공식 문서
- Log queries | Grafana Loki 공식 문서
- LogQL Reference | Grafana Loki 공식 문서
- Loki v3.5 Release Notes | Grafana Loki 공식 문서
- Grafana Loki: LogQL and Recording Rules from AWS ALB logs | ITNEXT
- A Comprehensive Guide to LogQL | DEV Community
- Introduction to Loki Workshop — Metric Queries Lab
- Loki LogQL Cheat Sheet | logit.io
- LogQL Cheat Sheet | FusionReactor