LogQL 파이프라인 파서 실전 가이드 — Grafana Loki 3.x에서 비정형 로그 필드 추출하기
운영 중인 서비스에서 로그를 분석하다 보면 이런 상황에 자주 마주칩니다. Nginx 액세스 로그가 한 줄짜리 텍스트로 쌓여 있는데 특정 상태 코드만 집계하고 싶거나, API 서버 JSON 로그에서 응답 시간 분포를 뽑고 싶거나, 레거시 앱이 남기는 커스텀 텍스트 로그에서 에러 레벨만 필터링하고 싶은 상황 말입니다. 공통점은 하나입니다. 수집 시점에 아무것도 정의하지 않았다는 것.
Loki는 로그를 저장할 때 내용을 인덱싱하지 않고, 쿼리 시점에 파서가 필드를 동적으로 추출하는 방식으로 이 문제를 해결합니다. 덕분에 사전에 스키마를 설계하거나 인덱스를 구성하지 않아도, 지금 바로 로그 본문에서 원하는 필드를 꺼내 필터링과 집계에 활용할 수 있습니다.
이 글은 Loki 기본 쿼리 경험이 있는 백엔드·인프라 개발자를 대상으로 합니다. | json, | pattern, | regexp 파서를 언제 어떻게 조합하는지, 그리고 Loki 2.9에서 실험적으로 도입되어 3.0에서 정식화된 구조화 메타데이터(Structured Metadata)를 파서 앞에 배치해야 하는 이유가 무엇인지를 실전 예시와 함께 살펴봅니다.
핵심 개념
Loki 쿼리를 처음 접하면 스트림 셀렉터, 파서, 구조화 메타데이터가 각각 독립된 개념처럼 보입니다. 하지만 이 셋은 하나의 계층 구조를 이룹니다. 스트림 셀렉터가 어떤 로그 파일 묶음을 읽을지 결정하고, 구조화 메타데이터가 로그 라인마다 붙어 있는 사전 속성을 걸러내며, 파서가 로그 본문에서 런타임에 필드를 뽑아냅니다. 쿼리 성능도 이 순서에 따라 크게 달라집니다.
LogQL 쿼리의 두 축: 스트림 셀렉터와 로그 파이프라인
{app="nginx", env="prod"} -- 스트림 셀렉터: 어떤 로그를 읽을지 결정
| json -- 파이프라인 시작: 로그 본문 파싱
| status_code >= 500
| line_format "{{.method}} {{.path}} -> {{.status_code}}"- 스트림 셀렉터:
{레이블="값"}형태로 어떤 로그 스트림을 읽을지 결정합니다. Loki는 이 레이블을 기반으로 인덱스를 구성하므로, 셀렉터가 좁을수록 쿼리가 빠릅니다. - 로그 파이프라인:
|기호로 연결된 처리 단계입니다. 왼쪽에서 오른쪽으로 순차 실행되며, 앞 단계에서 줄인 로그 수만큼 이후 단계의 처리 비용이 줄어듭니다.
파이프라인 실행 원칙: 단순 문자열 필터(
|=,!=)는 파서 앞에 배치하는 것이 모범 사례입니다. 파서가 먼저 실행되면 불필요한 로그 라인까지 모두 파싱한 뒤 버리기 때문입니다.
파이프라인 파서 종류와 선택 기준
파서는 로그 라인을 분석해 키-값 쌍의 임시 레이블을 생성합니다. 생성된 레이블은 이후 단계에서 필터링(| status_code >= 500)이나 포맷팅(line_format)에 활용됩니다.
| 파서 | 적합한 로그 형식 | 상대적 속도 |
|---|---|---|
| json |
{"key":"value"} JSON 로그 |
빠름 |
| logfmt |
key=value key2=value2 형식 |
빠름 |
| pattern "<pat>" |
공백·구분자 기반 비정형 로그 | 매우 빠름 |
| regexp "(?P<name>re)" |
복잡한 커스텀 포맷 | 느림 |
| unpack |
Promtail pack 스테이지로 직렬화된 로그 복원 |
빠름 |
unpack은 Promtail(또는 Grafana Alloy)의 pack 스테이지가 레이블을 로그 라인 안에 직렬화해 저장했을 때, 쿼리 시점에 이를 다시 분리해내는 파서입니다. 일반 로그 파싱 용도로는 사용하지 않습니다.
선택 기준: 로그가 JSON이면
| json, 공백이나 구분자로 나뉜 필드라면| pattern, 그 외 복잡한 패턴이면| regexp순서로 시도해보는 것을 권장합니다.pattern파서는 정규표현식 대비 최대 10배 빠른 성능을 보입니다(공식 벤치마크 기준).
구조화 메타데이터 — 세 번째 레이블 계층
구조화 메타데이터는 Loki 2.9에서 실험적(experimental)으로 처음 도입되고, Loki 3.0에서 정식 기능으로 전환된 세 번째 레이블 계층입니다. 인덱스 레이블과 로그 라인 사이에 위치하며, 인덱스 카디널리티를 높이지 않으면서 고유값 필드를 로그에 첨부할 수 있습니다.
┌────────────────────────────────────────────────────┐
│ 인덱스 레이블 app="api", env="prod" │ ← 스트림 정의, 낮은 카디널리티 필수
│ 구조화 메타데이터 trace_id="7f3a92b1" │ ← 고유값 저장, 인덱스 비용 없음
│ 로그 라인 {"level":"error","msg":"..."} │ ← 실제 로그 내용
└────────────────────────────────────────────────────┘OpenTelemetry Collector를 통해 Loki로 로그를 전송하면, OTel의 Resource Attributes와 Log Attributes가 자동으로 구조화 메타데이터로 저장됩니다. 쿼리 시에는 스트림 셀렉터와 동일한 문법으로 접근할 수 있습니다.
-- 구조화 메타데이터 필터 — 파서 없이 바로 사용 가능
{namespace="production"} | trace_id="7f3a92b1"Bloom Filter 가속 (Loki 3.3+):
trace_id,span_id같은 구조화 메타데이터 필터를 파서 이전 단계에 배치해야 Bloom Filter 인덱스 가속이 적용됩니다.| json | trace_id="abc"처럼 파서 이후에 배치하면 전체 로그를 스캔하게 됩니다.
실전 적용
예시는 두 그룹으로 나뉩니다. 기본 파싱 패턴(예시 1–3)은 각 파서의 기본 사용법을, 고급 활용(예시 4–6)은 구조화 메타데이터·메트릭 변환·레이블 정규화를 다룹니다.
기본 파싱 패턴
예시 1: JSON API 로그에서 HTTP 500 오류 집계
입력 로그 예시
{"level":"error","method":"POST","path":"/api/orders","status_code":500,"duration_ms":342}{app="api-server"}
| json
| status_code >= 500
| line_format "{{.method}} {{.path}} -> {{.status_code}}"| 단계 | 역할 |
|---|---|
| json |
로그 라인을 파싱해 status_code, method, path 등을 임시 레이블로 추출 |
| status_code >= 500 |
추출된 status_code 레이블로 숫자 비교 필터링 |
| line_format "..." |
Go 템플릿 문법({{.필드명}})으로 출력 형식 재구성 |
중첩 JSON 처리: 로그에 {"request":{"method":"POST","path":"/api"}} 같은 중첩 구조가 있다면, 특정 필드만 지정해 추출할 수 있습니다.
{app="api-server"}
| json method="request.method", path="request.path"| json 레이블명="중첩경로" 형식으로 경로를 직접 지정하면, 내부 필드를 원하는 레이블 이름으로 꺼낼 수 있습니다.
예시 2: pattern 파서로 Nginx 액세스 로그 파싱
입력 로그 예시
127.0.0.1 - frank [10/Oct/2025:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326{job="nginx"}
| pattern `<ip> - <user> [<ts>] "<method> <path> <proto>" <status> <bytes>`
| status = "500"
| line_format "{{.ip}} | {{.path}}"<필드명> 형태로 위치를 지정하면 해당 위치의 값이 레이블로 추출됩니다. 입력 로그와 패턴 템플릿을 나란히 놓으면 대응 관계를 바로 확인할 수 있습니다.
공백 포함 필드 주의:
"<method> <path> <proto>"처럼 따옴표 안에 공백으로 구분된 여러 필드가 있을 때, 패턴의 따옴표와 공백 구분자가 실제 로그와 정확히 일치해야 합니다. 처음 작성할 때 가장 자주 틀리는 지점이므로,| line_format "{{.method}} {{.path}}"로 추출 결과를 먼저 검증해보는 것을 권장합니다.
예시 3: regexp 파서로 커스텀 레거시 로그 파싱
입력 로그 예시
ERROR 2025-10-10 Null pointer exception in PaymentService.process(){app="legacy-app"}
| regexp `(?P<level>\w+)\s+(?P<ts>\d{4}-\d{2}-\d{2})\s+(?P<msg>.+)`
| level = "ERROR"(?P<name>패턴) 형식으로 캡처 그룹에 이름을 지정하면 해당 이름이 레이블로 생성됩니다. 패턴을 먼저 regex101.com(Go 플레이버 선택)에서 검증한 뒤 적용하면 시행착오를 줄일 수 있습니다.
고급 활용
예시 4: OTel 구조화 메타데이터로 분산 추적 연계
OpenTelemetry로 수집된 로그에서 특정 trace_id에 해당하는 로그만 추출하는 패턴입니다. trace_id는 구조화 메타데이터로 저장되므로 파서 없이 바로 필터링됩니다.
{namespace="production"}
| trace_id="7f3a92b1"
| json
| line_format "{{.message}}"trace_id 필터를 | json 파서 앞에 배치해야 Loki 3.3+의 Bloom Filter 가속이 적용됩니다. 순서를 바꾸면 전체 로그를 스캔하게 됩니다.
예시 5: unwrap으로 로그 내 수치 데이터를 메트릭으로 변환
로그 라인 안에 응답 시간처럼 수치 값이 포함되어 있다면, unwrap으로 꺼내 범위 집계에 활용할 수 있습니다.
sum by (endpoint) (
avg_over_time(
{app="api"} | json | unwrap duration_ms | __error__="" [5m]
)
)
rate()는 카운터 증분(초당 이벤트 수)을 다루는 함수입니다. 응답 시간처럼 측정값의 평균을 구할 때는avg_over_time()을 사용해야 올바른 결과를 얻을 수 있습니다.
| __error__="" 필터를 추가하는 이유가 있습니다. unwrap은 대상 필드가 숫자로 변환되지 않을 경우 __error__ 레이블을 생성합니다. 이 필터로 변환 실패 로그를 제외하지 않으면 집계 결과가 왜곡될 수 있습니다.
예시 6: label_format으로 레이블 이름 정규화
여러 서비스가 동일한 의미의 필드를 다른 이름으로 로깅할 때, label_format으로 이름을 통일할 수 있습니다.
{app="payments"}
| json
| label_format svc=app, req_id=request_id이름 변경(rename)과 복사(copy)의 차이: LogQL의
label_format dst=src형식은src레이블의 값을dst라는 이름으로 **이동(rename)**합니다. 원본 레이블src는 결과에서 제거됩니다. 원본을 유지하면서 새 이름도 추가하고 싶다면| label_format new_name="{{.old_name}}"형식을 활용할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 사전 스키마 불필요 | 파서가 쿼리 시점에 레이블을 동적 생성하므로, 수집 단계에서 스키마를 정의할 필요가 없습니다 |
| 카디널리티 문제 해소 | trace_id, request_id처럼 고유값을 구조화 메타데이터로 저장하면 인덱스 폭증 없이 필터링이 가능합니다 |
| OTel 네이티브 통합 | OTel Collector → Loki 직통 파이프라인에서 속성이 자동으로 구조화 메타데이터로 매핑됩니다 |
| 파이프라인 조합의 유연성 | 파서 → 필터 → 포맷 → 집계를 자유롭게 체이닝할 수 있습니다 |
| 로그에서 메트릭 도출 | unwrap을 활용하면 별도 메트릭 수집 없이 로그에서 수치 집계가 가능합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 카디널리티 폭증 | user_id, IP처럼 고유값을 인덱스 레이블로 설정하면 수천 개의 청크 파일이 생성되어 성능이 급격히 저하됩니다 |
고유값은 구조화 메타데이터 또는 파이프라인 필터로 처리합니다 |
| Bloom 가속 조건 | 구조화 메타데이터 필터를 파서 이후에 배치하면 Bloom Filter 가속이 적용되지 않습니다 | 메타데이터 필터는 항상 파서 이전 단계에 배치합니다 |
| regexp 성능 | 정규표현식 파서는 강력하지만 다른 파서 대비 느립니다 | 가능하면 pattern 파서를 먼저 고려합니다 |
| 전문 검색 한계 | Loki는 로그 내용을 인덱싱하지 않아 전문 검색(full-text search)에 적합하지 않습니다 | 전문 검색이 필요하다면 Elasticsearch/OpenSearch를 검토합니다 |
| unwrap 변환 오류 | unwrap 대상 필드가 숫자가 아닐 경우 __error__ 레이블이 생성되어 집계가 왜곡됩니다 |
| __error__="" 필터를 항상 추가합니다 |
카디널리티(Cardinality): 레이블이 가질 수 있는 고유값의 수입니다.
env="prod/dev/staging"처럼 값의 종류가 적으면 낮은 카디널리티,user_id="1001/1002/..."처럼 값이 무한히 늘어나면 높은 카디널리티라고 합니다. Loki는 레이블별로 별도 청크를 생성하므로 카디널리티가 높아질수록 스토리지와 메모리 사용량이 급증합니다.
실무에서 가장 흔한 실수
- 고유값을 인덱스 레이블로 설정하는 경우:
request_id,trace_id,user_id같은 값을 Promtail·Alloy 설정에서 인덱스 레이블로 추가하면 카디널리티가 폭증합니다. 이 값들은 구조화 메타데이터나 로그 본문에 포함시키고 파이프라인에서 처리하는 것을 권장합니다. - 파서를 파이프라인 맨 앞에 두는 경우:
| json앞에 단순 문자열 필터(|= "ERROR")를 배치하지 않으면, 오류와 무관한 모든 로그 라인을 파싱한 뒤 버리게 됩니다. 단순 필터를 먼저 적용해 파싱 대상 자체를 줄이는 것이 효과적입니다. - 구조화 메타데이터 필터를 파서 이후에 배치하는 경우:
| json | trace_id="abc"처럼 파서 뒤에 메타데이터 필터를 두면 Bloom Filter 가속이 적용되지 않아 쿼리 속도가 저하됩니다.| trace_id="abc" | json순서를 유지하는 것이 중요합니다.
마치며
파이프라인 순서 하나가 쿼리 성능을 수십 배 바꿀 수 있습니다. 파서 앞에 문자열 필터를 배치하고, 구조화 메타데이터를 파서 이전에 두는 두 가지 원칙만 지켜도 대부분의 성능 문제는 해결됩니다.
지금 바로 시작해볼 수 있는 3단계:
- Grafana의 Explore 메뉴에서
{app="서비스명"} | json을 입력해 어떤 필드가 추출되는지 확인합니다. - Nginx나 애플리케이션 로그 한 줄을 복사한 뒤, 각 필드 위치에
<필드명>을 채워 넣어| pattern템플릿을 완성합니다. - OTel Collector를 사용하고 있다면
{namespace="production"} | trace_id="요청ID"쿼리로 특정 요청의 전체 로그 흐름을 추적합니다.
다음 글: Grafana Loki에서
avg_over_time(... | unwrap ...)과quantile_over_time()을 활용해 로그 기반 SLO(Service Level Objective) 대시보드를 구성하는 방법
참고 자료
- Log queries | Grafana Loki 공식 문서
- What is structured metadata | Grafana Loki 공식 문서
- Loki 3.0 릴리즈 노트 — Bloom filters, 네이티브 OTel 지원
- Grafana Loki 3.3 — 구조화 메타데이터용 Bloom 필터 쿼리 가속
- New in Loki 2.3: LogQL pattern parser | Grafana Labs 블로그
- Query acceleration | Grafana Loki 공식 문서
- Ingesting logs to Loki using OpenTelemetry Collector | Grafana Loki 공식 문서
- LogQL template functions | Grafana Loki 공식 문서