AYEONG.DEV
모든 글 보기

Infra

Grafana + Loki를 도입한 이유

less -R 우리 헤어지자

·

1. 로그는 쌓이고 있는데 찾기가 어렵다

백엔드 개발을 하다 보면 로그를 확인할 일이 꽤 많다. 로컬에서는 콘솔만 열어봐도 충분하지만, 여러 애플리케이션이 서버에서 실행되는 환경은 조금 다르다. Node.js 애플리케이션을 PM2로 관리하고 있었고, 문제가 발생하면 서버에 접속해 로그 파일을 하나하나 확인했었다… pgup과 pgdn 을 넘나들며 …….

plain text
pm2 logs

특정 로그 파일을 확인할 때는 다음 명령어를 사용했다.

plain text
tail -f application-error.log

간단한 오류는 이렇게 확인해도 충분하다. 문제는 로그가 많아졌을 때다. 특정 시간대의 오류를 찾고, 어떤 요청에서 시작됐는지 확인하고, 연관된 로그를 다시 찾아야 했다.

grep으로 검색하는 방법도 있지만, 여러 파일에 걸쳐 있는 로그를 추적하는 작업은 생각보다 번거로웠다. 특히 동일한 API에서 오류가 반복되는 상황이라면 다음 정보가 필요했다.

  • 언제 오류가 발생했는가?
  • 어떤 API 요청에서 발생했는가?
  • HTTP 상태 코드는 무엇인가?
  • 같은 요청에서 발생한 다른 로그는 무엇인가?
  • 특정 시간대에 오류가 집중적으로 발생했는가?

로그 자체는 이미 충분히 쌓이고 있었다. 문제는 로그가 없는 게 아니라, 로그를 활용하기 어려운 구조라는 것이었다. 그래서 서버에 직접 접속하지 않고 로그를 검색하고 분석할 수 있는 환경을 구성하기로 했다.


2. Grafana + Loki + Promtail 구성하기

로그 수집부터 저장, 조회까지 한 번에 처리할 수 있도록 Grafana, Loki, Promtail을 조합했다.

각 컴포넌트의 역할은 다음과 같다.

처음에는 Grafana가 로그까지 직접 저장하는 것으로 생각할 수도 있지만, 실제 역할은 다르다. Grafana는 데이터를 조회하고 시각화하는 도구이며, 로그 데이터 자체는 Loki가 관리한다. Promtail은 애플리케이션의 로그 파일을 읽어 Loki로 전달한다.

전체 데이터 흐름

애플리케이션이 로그를 생성하면 PM2가 파일에 기록하고, Promtail은 해당 파일의 내용을 수집해 Loki에 전송한다. 이후 Grafana에서 LogQL을 이용해 원하는 로그를 조회하는 구조다.

3. Docker로 로그 수집 환경 구성하기

구성 요소들을 각각 설치할 수도 있지만, 이번에는 Docker Compose로 관리하는 방식을 선택했다. 애플리케이션과 모니터링 구성 요소를 분리하고, 필요할 때 개별적으로 재시작하거나 설정을 변경하기 쉽기 때문이다.

구조는 다음와 같이 구성했다.

plain text
monitoring/
├── docker-compose.yml
├── loki/
│   └── loki-config.yml
└── promtail/
    └── promtail-config.yml

3.1 Docker Compose

다음은 구성 방식을 설명하기 위한 예시다. 실제 설치 시에는 사용할 버전을 고정하고 Loki 설정 파일과 보안 설정을 함께 구성해야 한다.

yaml
services:
  loki:
    image: grafana/loki:3.5.0
    ports:
      - "127.0.0.1:3100:3100"
    volumes:
      - ./loki/loki-config.yml:/etc/loki/config.yml:ro
      - loki-data:/loki
    command: -config.file=/etc/loki/config.yml

  promtail:
    image: grafana/promtail:3.5.0
    volumes:
      - ./promtail/promtail-config.yml:/etc/promtail/config.yml:ro
      - /var/log/myapp:/var/log/myapp:ro
      - promtail-positions:/var/lib/promtail
    command: -config.file=/etc/promtail/config.yml
    depends_on:
      - loki

  grafana:
    image: grafana/grafana:12.0.0
    ports:
      - "127.0.0.1:3000:3000"
    volumes:
      - grafana-data:/var/lib/grafana
    depends_on:
      - loki

volumes:
  loki-data:
  grafana-data:
  promtail-positions:

핵심은 Promtail의 볼륨 마운트 설정이다.

yaml
volumes:
  - /var/log/myapp:/var/log/myapp:ro

호스트 서버의 로그 디렉터리를 컨테이너 내부에서도 읽을 수 있도록 연결한다.

여기서 ro는 읽기 전용이라는 의미다.

Promtail은 로그 파일을 수정할 필요가 없으므로 읽기 권한만 부여했다.

참고로 Docker 볼륨 마운트는 로그 파일을 컨테이너로 복사하는 것이 아니다. 호스트에 있는 파일을 컨테이너 내부 경로에서 접근할 수 있게 만드는 방식이다.

3.2 Promtail 수집 설정

Promtail은 scrape_configs를 통해 어떤 로그 파일을 수집할지 결정한다.

yaml
server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /var/lib/promtail/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: api-production
    static_configs:
      - targets:
          - localhost
        labels:
          job: api-production
          env: production
          service: api
          __path__: /var/log/myapp/*.log

여기서 중요한 설정은 세 가지다.

clients

수집한 로그를 전송할 Loki 주소를 지정한다. Docker Compose에서는 컨테이너 간 통신에 서비스 이름을 사용할 수 있으므로 http://loki:3100으로 설정했다.

labels

로그를 구분할 메타데이터다. 예를 들어 환경을 나타내는 env, 서비스를 구분하는 service, 수집 작업을 나타내는 job 등을 지정할 수 있다.

__path__

수집할 로그 파일 경로를 지정한다. 설정한 경로의 파일을 Promtail이 읽어 Loki로 전송한다.

이때 positions 파일은 Promtail이 어디까지 로그를 읽었는지 기억하는 데 사용된다. 재시작 시 중복 수집을 줄이고 이어서 읽을 수 있도록 영속 볼륨에 보관했다.

4. Loki는 로그를 어떻게 저장할까?

Loki의 특징은 Elasticsearch 같은 전체 텍스트 검색 중심의 시스템과 인덱싱 방식이 다르다는 점이다. Loki는 로그 본문 전체를 인덱싱하지 않고, 주로 라벨을 기준으로 로그 스트림을 식별하고 조회한다.

예를 들어 다음과 같은 라벨을 설정했다고 가정하자.

shell
job="api-production"
env="production"
service="api"

Grafana에서는 이 라벨을 이용해 필요한 로그 범위를 좁힐 수 있다.

shell
{job="api-production"}

여기에 문자열 필터를 추가할 수도 있다.

shell
{job="api-production"} |= "error"

특정 문자열이 포함된 로그만 조회하는 방식이다. 다만 이 쿼리는 문자열을 검색할 뿐 실제 로그 레벨이 ERROR인지 판단하는 것은 아니다. 구조화된 JSON 로그를 사용하면 level 필드로 더 정확하게 구분할 수 있다.

라벨 설계에서 주의할 점

Loki는 라벨을 중심으로 로그를 인덱싱하기 때문에 라벨 설계가 중요하다. 서비스 이름이나 배포 환경처럼 값의 종류가 제한된 정보는 라벨로 관리하기 적합하다.

반면 요청마다 달라지는 requestId나 사용자 ID를 인덱스 라벨로 사용하면 로그 스트림 수가 급격히 늘어날 수 있다. 따라서 다음과 같이 구분하는 편이 좋다.

요청별 정보는 로그 본문에 기록하고 필요한 순간에 파싱해 사용하는 방식을 선택했다.


5. Grafana에서 Loki 연결하기

Loki가 정상적으로 실행됐다면 Grafana에 데이터 소스를 등록한다.

Grafana 웹 화면에 접속한 뒤 다음 메뉴로 이동했다.

Connections → Data Sources → Add data source → Loki

Connection URL에는 다음 주소를 입력한다.

plain text
http://loki:3100

여기서 localhost가 아니라 loki를 사용하는 이유는 Grafana와 Loki가 동일한 Docker Compose 네트워크에서 실행되고 있기 때문이다.

Grafana 컨테이너 입장에서 localhost는 자기 자신을 가리킨다.

설정을 저장한 뒤 Save & test를 통해 연결을 확인한다.

이제 Grafana의 Explore 화면에서 Loki를 선택하고 로그를 조회할 수 있다.

plain text
{job="api-production"}

로그가 정상적으로 출력된다면 기본 수집 파이프라인이 연결된 것이다.

로그가 조회되지 않는다면?

처음부터 복잡한 설정을 의심하기보다는 각 단계가 정상적으로 동작하는지 확인하는 편이 빠르다.

먼저 Promtail 컨테이너 내부에서 로그 파일이 보이는지 확인한다.

plain text
docker exec -it monitoring-promtail-1 sh
plain text
ls -al /var/log/myapp

그다음 Promtail과 Loki의 컨테이너 로그를 확인한다.

plain text
docker compose logs promtail
docker compose logs loki

점검할 부분은 다음과 같다.

  • 로그 파일 경로가 올바른가?
  • Promtail에 파일 읽기 권한이 있는가?
  • Loki 서버에 정상적으로 연결되는가?
  • Grafana의 조회 시간 범위가 적절한가?
  • 조회할 때 사용하는 라벨이 실제로 존재하는가?

로그 수집이 안 돼서 로그를 확인해야 하는 상황도 생긴다.

로그 시스템  만들었더니 로그 시스템 로그를 보는 나
로그 시스템 만들었더니 로그 시스템 로그를 보는 나

6. LogQL로 원하는 로그 찾기

기본적인 수집이 확인되었다면 로그 검색 쿼리를 작성할 차례다. Loki는 LogQL이라는 쿼리 언어를 제공한다.

사용 방식은 비교적 직관적이다.

6.1 특정 서비스 로그 조회

plain text
{job="api-production"}

6.2 특정 키워드 검색

plain text
{job="api-production"} |= "error"

|=는 지정한 문자열이 포함된 로그를 찾는 연산자다.

6.3 정규식으로 검색

plain text
{job="api-production"} |~ "error|exception"

|~를 사용하면 정규식으로 필터링할 수 있다.

6.4 JSON 로그 파싱

애플리케이션이 다음과 같은 구조화 로그를 기록한다고 가정해 보자.

plain text
{
  "timestamp": "2026-10-19T06:00:00Z",
  "level": "error",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "statusCode": 500,
  "errorCode": "PAGE_SAVE_FAILED",
  "operation": "page/save",
  "message": "페이지 저장에 실패했습니다."
}

JSON 파서를 적용하면 개별 필드를 기준으로 필터링할 수 있다.

plain text
{job="api-production"}
| json
| statusCode >= 400

특정 에러 코드만 조회하는 것도 가능하다.

plain text
{job="api-production"}
| json
| errorCode="PAGE_SAVE_FAILED"

이런 방식으로 단순한 텍스트 로그를 분석 가능한 데이터로 활용할 수 있다.

7. 로그를 테이블 형태로 만들기

Explore에서 로그를 조회하는 것만으로도 충분히 편리해졌지만, 자주 확인하는 정보는 대시보드에서 바로 확인하고 싶었다. 특히 다음 정보들을 한 화면에 보여주고 싶었다.

  • 발생 시간
  • requestId
  • HTTP 상태 코드
  • 에러 코드
  • 로그 파일
  • 오류 메시지

Grafana에서 Dashboard를 생성한 다음 Table 패널을 추가했다.

7.1 정규식으로 필드 추출

애플리케이션 로그가 항상 JSON 형태인 것은 아니다.

예를 들어 다음처럼 문자열과 JSON이 섞인 로그가 있다고 가정하자.

plain text
requestId=550e8400-e29b-41d4-a716-446655440000|{"statusCode":500,"message":"Internal error"}

이런 경우 LogQL의 regexp를 이용해 필요한 필드를 추출할 수 있다.

plain text
{job="api-production"}
| regexp `requestId=(?P<req_id>[0-9a-f-]{36})\|.*"statusCode":\s*(?P<status>\d+)`

여기서 (?P<req_id>...)와 (?P<status>...)는 이름 있는 캡처 그룹이다. 정규식에 매칭된 값을 각각 req_id, status로 추출한다.

다만 JSON 로그를 일관되게 출력할 수 있다면 복잡한 정규식보다 | json을 사용하는 편이 관리하기 쉽다. 정규식은 기존 로그 포맷을 변경하기 어려운 상황에서 활용할 수 있는 방법이다.

7.2 Transformations로 데이터 가공하기

LogQL로 값을 추출했다고 해서 곧바로 원하는 테이블이 완성되는 것은 아니다. Grafana의 Transformations 기능을 이용하면 조회 결과를 화면에 맞게 가공할 수 있다.

예를 들어 status가 문자열로 추출됐다면 숫자 타입으로 변환한다. 이후 400 이상의 값만 필터링하면 오류 응답만 표시하는 테이블을 만들 수 있다. Organize fields by name에서는 불필요한 원본 로그 컬럼을 숨기고 다음과 같이 구성했다.

여기까지 구현하면 대시보드에서 오류 내역을 확인할 수 있다. 하지만 한 가지 더 하고 싶은 것이 있었다.

테이블에서 requestId를 클릭하면 해당 요청의 전체 로그가 바로 열리게 만드는 것.

8. requestId 클릭 한 번으로 상세 로그 추적하기

여러 서비스나 함수에 걸쳐 실행되는 요청은 하나의 오류 로그만으로 원인을 알기 어려울 수 있다. 그래서 요청마다 고유한 requestId를 부여하고, 해당 요청에 관련된 로그에 동일한 값을 기록하도록 했다.

plain text
[INFO]  requestId=abc-123 요청 시작
[INFO]  requestId=abc-123 데이터 조회
[INFO]  requestId=abc-123 외부 API 호출
[ERROR] requestId=abc-123 저장 실패

동일한 requestId를 기준으로 검색하면 관련 로그를 함께 확인할 수 있다.

plain text
{job="api-production"} |= "abc-123"

물론 Explore에서 직접 검색해도 된다. 하지만 오류 테이블에서 매번 requestId를 복사해 다시 검색하는 과정은 생략하고 싶었다. Grafana의 Data Link 기능을 이용하면 테이블의 특정 값을 클릭했을 때 다른 화면으로 이동하도록 설정할 수 있다.

8.1 Data Link 설정

Table 패널의 편집 화면에서 다음 순서로 설정한다.

  1. Overrides 탭에서 Add field override를 선택한다.
  2. Fields with name으로 req_id를 지정한다.
  3. Add override property에서 Data links를 추가한다.
  4. 상세 로그를 조회할 URL을 설정한다.
  5. Open in new tab을 활성화한다.

Data Link에서는 Grafana의 내장 변수를 사용할 수 있다.

이 변수들을 조합하면 클릭한 requestId와 기존 시간 범위를 상세 로그 화면으로 전달할 수 있다.

8.2 Explore로 이동하는 링크

실제 구현에서는 Explore URL에 Loki 쿼리와 시간 범위를 포함하는 방식을 사용했다. 개념적으로 다음과 같은 쿼리가 만들어진다.

shell
{job="api-production", filename="/var/log/myapp/api.log"}
|= "550e8400-e29b-41d4-a716-446655440000"

Grafana Explore URL은 버전에 따라 상태값을 전달하는 형식이 달라질 수 있으므로, 사용 중인 Grafana에서 생성한 Explore 링크 형식을 기준으로 Data Link를 구성하는 편이 안전하다.

여기서 filename은 수집 과정에서 생성된 실제 라벨 값이어야 한다.

추가로 filename으로 먼저 범위를 좁히면 특정 파일에 대한 로그 검색으로 한정할 수 있다. 다만 서비스 로그가 여러 파일에 분산되는 구조라면 파일명을 고정하는 대신 requestId로 전체 관련 로그를 조회하는 방식이 더 적절할 수 있다.

8.3 최종적으로 원하는 동작

plain text
Grafana Dashboard
        │
        ▼
오류 목록 Table
        │
        ▼
requestId 클릭
        │
        ▼
Grafana Explore
        │
        ▼
동일 requestId 로그 조회
        │
        ▼
요청 흐름 및 오류 원인 분석

이렇게 하면 오류가 발생했다는 사실을 확인하는 화면과 상세 원인을 분석하는 화면이 연결된다.

특정 오류를 확인한 뒤 요청 ID를 복사하고, 로그 검색 화면을 열고, 시간 범위를 다시 맞추는 과정이 줄어드는 것이다.


9. 로그를 차트로 시각화하기

테이블 외에도 시간에 따른 오류 발생 추이를 확인할 수 있도록 LogQL의 메트릭 쿼리를 활용할 수 있다. 예를 들어 최근 5분 동안 발생한 오류 로그의 건수를 집계할 수 있다.

shell
sum(
  count_over_time(
    {job="api-production"}
    | json
    | statusCode >= 500
    [5m]
  )
)

이 쿼리는 최근 5분 구간의 500 이상 상태 코드 로그를 집계한다. 시계열 패널로 표시하면 오류 발생 추이를 확인할 수 있다.

단, 하나의 HTTP 요청에 여러 개의 오류 로그가 기록되는 구조라면 이 값은 실패한 요청 수가 아니라 조건에 맞는 로그 건수다. 요청 실패율을 정확히 계산하려면 요청당 한 번 기록되는 완료 로그나 별도 메트릭을 기준으로 집계해야 한다.

에러 코드별 발생 건수

shell
sum by (errorCode) (
  count_over_time(
    {job="api-production"}
    | json
    | level="error"
    | errorCode != ""
    [5m]
  )
)

이렇게 하면 어떤 종류의 오류가 많이 발생하는지 파악할 수 있다. 다만 errorCode 값은 로그에서 파싱된 필드이며, 해당 필드를 실제 Loki 인덱스 라벨로 등록할 필요는 없다.

10. 구축하면서 고려한 부분

10.1 로그 포맷은 가능하면 통일하기

정규식으로 로그를 파싱하는 방법은 유용하지만, 출력 형식이 달라질 때마다 쿼리를 수정해야 하는 문제가 있다. 따라서 신규 로그는 가능한 한 JSON 기반의 구조화 로그로 출력하는 것이 유리하다.

예를 들어 다음과 같은 필드를 공통으로 사용한다.

shell
{
  "timestamp": "2026-10-19T06:00:00Z",
  "level": "error",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "layer": "infrastructure",
  "operation": "storage/file/upload",
  "errorCode": "FILE_UPLOAD_FAILED",
  "statusCode": 502,
  "message": "파일 업로드에 실패했습니다."
}

로그 형식이 일정하면 Grafana에서 JSON 파서만으로 필요한 값을 추출할 수 있다. 또한 오류가 발생한 계층과 작업까지 기록하면 로그에서 원인을 파악하기가 더 쉬워진다.

10.2 개인정보와 민감 정보는 기록하지 않기

로그를 중앙화하면 여러 애플리케이션의 로그가 한곳에 모인다. 따라서 로그에 민감한 데이터가 포함되지 않도록 주의해야 한다.

특히 다음과 같은 값은 그대로 기록하지 않는 편이 안전하다.

  • Access Token / Refresh Token
  • Authorization 헤더
  • 비밀번호 및 인증 정보
  • 개인정보가 포함된 요청 본문
  • DB 연결 문자열과 비밀키

로그 검색이 편리해진 만큼 접근 권한과 데이터 보존 정책도 함께 고려해야 한다.

10.3 로그 보존 기간과 디스크 사용량 관리

Loki가 로그를 압축해 저장한다고 해도 디스크를 무제한으로 사용할 수 있는 것은 아니다. 로그 보존 기간, 저장소 용량, 오래된 데이터 삭제 정책을 별도로 설정해야 한다. 또한 전체 로그를 무조건 저장하기보다는 필요한 로그 레벨과 수집 범위를 먼저 정의하는 편이 좋다.

10.4 Promtail에서 Grafana Alloy로

이번 구성에서는 Promtail을 로그 수집기로 사용했다. 다만 Promtail은 2026년 3월 2일부로 지원이 종료되었다.

따라서 신규 환경이라면 Grafana Alloy를 사용하는 것이 권장된다. Alloy는 파일 로그 수집뿐 아니라 여러 관측 데이터를 수집하고 전달하는 데 활용할 수 있다. 기존 Promtail 설정을 Alloy 설정으로 변환할 수 있는 마이그레이션 도구도 제공된다. 즉, 전체 구조를 완전히 바꿔야 하는 것은 아니다.

plain text
기존 구조

Application → Promtail → Loki → Grafana

권장 구조

Application → Alloy → Loki → Grafana

이번에 구성했던 로그 저장 및 시각화 방식은 유지하면서 수집기 부분을 교체하는 방향으로 확장할 수 있다.

11. 마무리

처음에는 서버에 접속해서 로그를 확인하는 작업이 조금 불편하다는 정도로 생각했다. 하지만 로그 수집 환경을 구성하면서 단순히 로그를 한곳에 모으는 것보다 중요한 부분이 있다는 것을 알게 됐다.

바로 로그를 어떤 기준으로 분류하고, 어떻게 연결해서 분석할 것인가였다.

Grafana와 Loki를 통해 로그를 중앙에서 조회할 수 있었고, LogQL을 이용해 필요한 로그만 필터링할 수 있었다. 여기에 requestId 기반의 상세 로그 연결까지 추가하면서 하나의 요청에서 발생한 오류를 추적할 수 있는 구조를 만들었다.

개인적으로 가장 의미 있었던 부분은 단순히 예쁜 대시보드를 만든 것이 아니었다. 기존에는 오류를 발견하면 관련 로그를 찾는 것부터 시작해야 했다면, 이제는 오류를 확인한 화면에서 해당 요청의 로그로 바로 이동할 수 있다는 점이었다. 물론 이 구조만으로 완전한 모니터링 시스템이 완성되는 것은 아니다. 서비스 메트릭, 분산 트레이싱, 알림 설정까지 연결한다면 더 넓은 범위의 관측 환경을 구축할 수 있을 것이다.

다만 이번 작업의 목적은 분명했다. 로그를 잘 남기는 것에서 끝나지 않고, 실제로 찾고 분석할 수 있는 환경을 만드는 것. 로그가 많은 것과 로그를 잘 활용하는 것은 다른 문제였다.


참고 자료

조회 0

댓글

익명 캐릭터로 가볍게 이야기를 남겨 주세요.

0/1000