문제
기존에는 EC2 환경에서 ELK 스택을 사용해 로그를 수집하고 있었다.
하지만 개인 프로젝트와 홈 서버 성격의 서비스까지 포함되면서 다음 문제가 발생했다.
- ELK는 메모리 사용량이 너무 큼
- 라즈베리파이의 RAM 용량은 4~8GB이지만 ELK를 가볍게 띄워도 3~4GB가 소요됨
- 라즈베리파이의 디스크 IO 성능 제한
지금 환경에서 ELK는 기능은 충분하지만, 너무 무거우므로 “가벼운 로그 수집 시스템”이 필요하다.
- 서비스별 로그 흐름 파악
- 장애 발생 시 빠른 원인 추적
- 저사양 환경에서도 안정적으로 동작
- 운영 부담이 적을 것
Loki + Grafana + Promtail 조합
기존 로그 시스템과 달리 로그 본문을 인덱싱 하지 않고 로그에 붙은 라벨(Label)만 인덱싱하는 구조
- 메모리 사용량이 낮고
- 디스크 IO 부담이 적으며
- 라즈베리파이 같은 저사양 환경에 적합하다
각 구성요소 역할
Loki: Grafana Labs에서 만든 로그 수집·저장 시스템
Grafana: 시각화 & 검색 UI ( “관측성(Observability)을 시각화하는 플랫폼”으로 Grafana 자체는 데이터를 저장하지 않는다.)
Promtail : 로그 수집 Agent (로그 파일을 읽어, 라벨을 붙인 뒤 Loki로 전달)
기존 로그 시스템(ELK)의 방식
Full Text Index 방식으로 로그 내용 전체를 인덱싱 ( 모든 필드, 모든 텍스트를 검색 가능 )
[ERROR] OrderService - orderId=1234 timeout occurred
문제점
- 인덱스 용량 큼
- 메모리 사용량 큼
- 디스크 IO 부담
Loki의 접근 방식: 라벨(Label) 기반
❝ 로그의 내용을 인덱싱 하지 않으며, 대신 로그의 메타데이터(label)만 인덱싱 한다 ❞
라벨(Label)이란?
- 라벨은 로그 한 줄 한 줄에 붙는 검색용 메타데이터
이러한 예시 로그가 있다면
2025-12-19 11:36:29 ERROR OrderService orderId=1234 timeout
Loki에 넣을 때 이런 방식으로 로그를 넣게 된다.
즉 Loki는 오직 이 label만 인덱싱하여 로그 본문(message)은 인덱싱 되지 않는다.
labels:
job: chill-logistics
service: order-server
level: ERROR
instance: raspberrypi-01
라벨 기반의 장점
- 로그 본문은 사용하지 않으며 메타데이터(label)만 인덱싱 사용
- 인덱싱 비용 거의 없음
- 로그 쓰기 속도 빠름
“ ELK : 일단 다 넣고 나중에 검색하자”
“ Loki : 어떤 기준으로 검색할지를 미리 정하자”
| 항목 | ELK | Loki |
| 메모리 사용 | 높음 | 낮음 |
| 인덱싱 방식 | Full Index | Label 기반 |
Code
1. Loki + Grafana의 Docker Compose 파일로 저장 후 실행 시켜준다.
예시 : docker compose -f docker-compose-logging.yml up -d
services:
loki:
image: grafana/loki:2.9.8
container_name: loki
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
restart: always
volumes:
- loki_data:/loki
grafana:
image: grafana/grafana:10.4.5
container_name: grafana
ports:
- "3000:3000"
restart: always
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
# 필요하면 아래도 추가
# - GF_SERVER_ROOT_URL=http://<GRAFANA_HOST>:3000
depends_on:
- loki
volumes:
- grafana_data:/var/lib/grafana
volumes:
loki_data:
grafana_data:
2. 로그 저장 형태인 logback.xml 파일은 기존 ELK 형태의 방식과 같다
spring에서 resources 폴더 -> logback.xml 파일로 넣어주면 됩니다
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 로그 경로 -->
<property name="LOG_PATH" value="/app/logs"/>
<property name="INFO_PATH" value="${LOG_PATH}/info" />
<property name="WARN_PATH" value="${LOG_PATH}/warn" />
<property name="ERROR_PATH" value="${LOG_PATH}/error" />
<!-- =============== 콘솔 =============== -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- =============== INFO 전용 JSON 로그 파일 =============== -->
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${INFO_PATH}/application-info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${INFO_PATH}/application-info-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory> <!-- 7일간 보관 -->
</rollingPolicy>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/>
<logLevel/>
<threadName/>
<loggerName/>
<message/>
<arguments/>
<stackTrace/>
<mdc>
<includeMdcKeyName>traceId</includeMdcKeyName>
</mdc>
</providers>
</encoder>
</appender>
<!-- =============== WARN 전용 JSON 로그 =============== -->
<appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${WARN_PATH}/application-warn.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${WARN_PATH}/application-warn-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<!-- WARN만 -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>WARN</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/>
<logLevel/>
<threadName/>
<loggerName/>
<message/>
<arguments/>
<stackTrace/>
<mdc>
<includeMdcKeyName>traceId</includeMdcKeyName>
</mdc>
</providers>
</encoder>
</appender>
<!-- =============== ERROR 전용 JSON 로그 파일 =============== -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${ERROR_PATH}/application-error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ERROR_PATH}/application-error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory> <!-- 7일간 보관 -->
</rollingPolicy>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/>
<logLevel/>
<threadName/>
<loggerName/>
<message/>
<arguments/>
<stackTrace/>
<mdc>
<includeMdcKeyName>traceId</includeMdcKeyName>
</mdc>
</providers>
</encoder>
</appender>
<!-- =============== 루트 로거 =============== -->
<!-- root logger의 레벨을 설정으로 INFO 이상 레벨(INFO, WARN, ERROR) 만 출력한다 -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="INFO_FILE"/>
<appender-ref ref="WARN_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</configuration>
3. Loki + Grafana가 있는 서버로 보내기 위해 Promtail 사용
주의 : 해당 경로에 config.yml 파일이 존재해야 합니다.
/c/app/promtail/config.yml
- config.yml 파일 (/*.log 부분 때문에 주석으로 인식 중이므로 무시해도 됩니다.)
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
# 로깅 서버(Loki) 주소
- url: http://221.xxx.xxx.xxx:3100/loki/api/v1/push
scrape_configs:
- job_name: chill-logistics-info
static_configs:
- targets: [localhost]
labels:
job: chill-logistics
service: business # 어떤 서비스인지 예로 user-server, firm-server처럼 구체적으로 사용이 좋지만 로컬 개발용으로 한번에 하므로
level: INFO
__path__: /app/logs/info/*.log
pipeline_stages:
- json:
expressions:
timestamp: timestamp
logLevel: logLevel
threadName: threadName
loggerName: loggerName
message: message
traceId: traceId
- timestamp:
source: timestamp
format: RFC3339Nano
- job_name: chill-logistics-warn
static_configs:
- targets: [localhost]
labels:
job: chill-logistics
service: business
level: WARN
__path__: /app/logs/warn/*.log
pipeline_stages:
- json:
expressions:
timestamp: timestamp
logLevel: logLevel
threadName: threadName
loggerName: loggerName
message: message
traceId: traceId
- timestamp:
source: timestamp
format: RFC3339Nano
- job_name: chill-logistics-error
static_configs:
- targets: [localhost]
labels:
job: chill-logistics
service: business
level: ERROR
__path__: /app/logs/error/*.log
pipeline_stages:
- json:
expressions:
timestamp: timestamp
logLevel: logLevel
threadName: threadName
loggerName: loggerName
message: message
traceId: traceId
- timestamp:
source: timestamp
format: RFC3339Nano
- config.yml 파일을 경로에 넣고, Promtail Docker Compose 파일 생성 (스프링 서버가 돌아가는 서버에서 실행 시켜준다.)
예시 : docker compose -f docker-compose-promtail.yml up -d
services:
promtail:
image: grafana/promtail:2.9.8
container_name: promtail
restart: always
command: -config.file=/etc/promtail/config.yml
volumes:
- /c/app/promtail/config.yml:/etc/promtail/config.yml:ro
- /c/app/logs:/app/logs:ro
- promtail_positions:/tmp
ports:
- "${PROMTAIL_PORT}:${PROMTAIL_PORT}"
volumes:
promtail_positions:
- 모든 서버를 실행 후 로그가 발생한다면 Loki + Grafana가 실행 중인 서버에 3000번 포트로 들어가면 Grafana에 들어갈 수 있습니다.
- 예시 : http://221.xxx.xxx.xxx:3000 (만약 로그인 창이 뜬다면 초기 값은 ID : admin PW : admin 을 넣어주면 됩니다.)

결론
1. 라즈베리파이와 같은 저사양 환경에서 ELK 스택은 기능적으로는 충분하지만, 운영 비용과 리소스 측면에서 과한 선택이었다.
2. Loki + Grafana + Promtail 조합은 로그 본문을 인덱싱 하지 않고 라벨 기반으로 접근하는 구조 덕분에 제한된 자원 환경에서도 안정적인 로그 수집과 조회를 가능하게 했다.
'Infra > LogBack' 카테고리의 다른 글
| Spring Boot + Logback 구조적 JSON 로그 만들기 (Logging - 2) (0) | 2025.11.29 |
|---|---|
| FileBeat를 활용해 로그 전송 (Loggin - 3) (0) | 2025.11.22 |
| 스프링 로그 분석 환경 구축 Elasticsearch + Kibana 설치 (Logging - 1) (0) | 2025.11.21 |