fos-blog/study
01 / 홈02 / 카테고리
01 / 홈02 / 카테고리

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • langgraph 페이지로 이동
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
  • ai 페이지로 이동
    • agent 페이지로 이동
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] CJ푸드빌 커머스/F&B 도메인 설계 면접 대비 — 슬롯 경험을 주문·결제·쿠폰·매장 상태 설계로 번역하기
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 주문 상태와 데이터 정합성 기본기 — CJ푸드빌 면접 대비
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리 — CJ푸드빌 디지털 채널 백엔드 면접 대비
    • [초안] F&B 주문/매장/픽업 상태머신 설계 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 1,600만 고객과 올영세일을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis Cache-Aside × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • Envoy Proxy
    • Graceful Shutdown
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • stock-notes 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • [초안] CJ푸드빌 디지털 채널 Back-end 개발자 직무 분석
    • [초안] CJ푸드빌 디지털 채널 Back-end 면접 답변집 — 슬롯 도메인 경험을 커머스/F&B 설계로 번역하기
    • [초안] F&B / e-Commerce 운영 모니터링과 장애 대응 인터뷰 정리
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/devops/응답을 모두 200으로 래핑하는 환경에서 P…
devops

응답을 모두 200으로 래핑하는 환경에서 Prometheus 비즈니스 errorCode 메트릭 만들기

진행 기간: 2026.04 2026.05 운영 중인 API 서버에서 "어떤 비즈니스 에러가 얼마나 발생하고 있는지"를 Grafana에서 보고 싶었다. Spring Boot Actuator + Micrometer 조합이면 보통 httpserverrequestssecondscount{status="4xx"} 같은 표준 메트릭으로 충분한데, 이 서버는 그게 안...

2026.05.09·11 min read·1 views

진행 기간: 2026.04 ~ 2026.05

운영 중인 API 서버에서 "어떤 비즈니스 에러가 얼마나 발생하고 있는지"를 Grafana에서 보고 싶었다. Spring Boot Actuator + Micrometer 조합이면 보통 http_server_requests_seconds_count{status="4xx"} 같은 표준 메트릭으로 충분한데, 이 서버는 그게 안 됐다. 모든 응답을 HTTP 200으로 통일하고 비즈니스 에러는 응답 body 안의 코드로 표현하는 공통 응답 포맷(response envelope)을 쓰고 있어서, status 라벨이 전부 200으로만 찍혔기 때문이다.

이걸 해결하기 위해 @RestControllerAdvice에 직접 Counter를 박아 비즈니스 errorCode 단위 메트릭을 만들었고, PromQL을 두 번 갈아탔다. 이번 글은 그 과정의 How-to + 의사결정 흐름이다.

왜 표준 메트릭이 무력화됐나

응답 정책이 다음과 같다.

json
HTTP/1.1 200 OK
Content-Type: application/json
 
{
  "header": {
    "isSuccessful": false,
    "resultCode": 4010001,
    "resultMessage": "Invalid appKey or secretKey."
  }
}

콘솔(관리자) URI를 제외하고는 무조건 200이고, 비즈니스 에러는 body의 resultCode/resultMessage로 표현한다. 이 정책 자체는 클라이언트 단순화·로깅 일관성·게이트웨이 정책 통일 같은 이유로 자리잡아 있어 바꿀 수 없는 전제였다.

부작용은 명확하다. Spring Boot가 자동으로 노출하는 http_server_requests_seconds_count 라벨에 status="200"만 남으니, "에러 발생 추세" 패널을 만들려고 하면 항상 0이거나 200 카운트만 잡힌다. HTTP status 기반 알람·대시보드는 전부 무력화되는 환경이다.

해결 방향은 분명했다. 비즈니스 에러를 직접 Counter로 쌓고, 그 카운터에 의미 있는 라벨을 붙여 Grafana에서 분포·추세를 보는 것.

어디서 잡을까: ExceptionController

이 서버는 모든 예외가 @RestControllerAdvice 클래스 한 곳(ExceptionController)에서 처리된다. Spring 표준 예외, Bean Validation 예외, 비즈니스 예외(OcrApiException처럼 resultCode 필드가 있는 커스텀 예외) 등 종류별로 @ExceptionHandler 메서드가 분리되어 있고, 모두 마지막에 DomainResponse.create(resultCode)로 200 래핑된 응답을 만든다.

이 자리가 메트릭을 박기 가장 좋은 지점이다. 모든 비즈니스 에러가 여기를 한 번씩 거치고, 거기서 이미 ResultCode enum을 알고 있다.

java
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class ExceptionController {
 
    static final String BUSINESS_ERROR_METRIC_NAME = "ocr.api.business.error";
 
    private final MeterRegistry meterRegistry;
 
    @ExceptionHandler(OcrApiException.class)
    public DomainResponse handleException(OcrApiException e, HttpServletRequest request) {
        if (e.getResultCode().isUserError()) {
            log.warn("{} {} {}", e.getResultCode(), request.getMethod(), request.getRequestURL(), e);
        } else {
            log.error("{} {} {}", e.getResultCode(), request.getMethod(), request.getRequestURL(), e);
        }
        recordBusinessError(e.getResultCode(), e);
        return DomainResponse.create(e.getResultCode());
    }
 
    @ExceptionHandler({BindException.class,
                       MethodArgumentNotValidException.class,
                       HttpMessageNotReadableException.class,
                       ValidationException.class,
                       MultipartException.class})
    public ResponseEntity<DomainResponse> handleInvalidParameterException(Exception e, HttpServletRequest request) {
        var resultCode = ResultCode.BAD_REQUEST;
        log.warn("{} {} {}", resultCode, request.getMethod(), request.getRequestURL(), e);
        recordBusinessError(resultCode, e);
        return createResponseEntity(request.getRequestURI(), resultCode, HttpStatus.BAD_REQUEST);
    }
 
    // ... NoHandlerFoundException, HttpRequestMethodNotSupportedException 등 동일 패턴
}

핵심은 두 가지다. MeterRegistry를 생성자로 주입받고, 모든 핸들러가 마지막에 recordBusinessError(...)를 호출한다. 핸들러마다 ResultCode만 정해놓으면 카운터는 자동으로 일관되게 쌓인다.

라벨 설계 — code/name/category/exception

가장 시간을 쓴 부분이다. 라벨을 잘못 박으면 Prometheus 자체가 흔들릴 수 있어서 그렇다.

잠깐, "카디널리티"가 뭔가

이 글에서 "카디널리티가 폭발한다"는 표현이 여러 번 나오니 먼저 정리해두는 편이 낫겠다.

Prometheus는 메트릭 이름과 라벨 조합 하나당 하나의 시계열(time series)을 만든다. 예를 들어

plaintext
ocr_api_business_error_total{code="4010001", category="4", exception="OcrApiException"}

이건 그 자체로 한 시리즈다. code 값이 50개로 늘어나면 (다른 라벨이 같다는 가정에서) 50개의 시리즈가 생긴다.

라벨의 카디널리티(cardinality)는 그 라벨이 가질 수 있는 서로 다른 값의 개수다. 그리고 한 메트릭의 총 시리즈 수는 모든 라벨 카디널리티의 곱으로 결정된다 — 조합마다 시리즈가 별도로 생기기 때문이다.

plaintext
code(50) × category(3) × exception(10) = 최대 1,500 시리즈

"카디널리티가 폭발한다"는 건 라벨에 user-id, request-id, IP, timestamp처럼 값이 사실상 무한대로 늘어나는 것을 박았을 때 시리즈 수가 곱셈으로 터지는 상황을 말한다. 시리즈 하나마다 Prometheus가 메모리·인덱스·쿼리 비용을 지불하므로, 한 메트릭이 수십만 시리즈가 되면 OOM이 나거나 쿼리가 타임아웃 난다. 흔히 인용되는 사고 사례 중 상당수가 무심코 추가한 라벨 하나에서 시작한다.

그래서 라벨을 추가할 때는 항상 "이 라벨이 가질 수 있는 값의 상한이 얼마인가"를 곱셈으로 가늠해본 뒤 결정한다. 아래 4개 라벨도 그 가늠을 거쳐서 통과한 것들이다.

최종 결정한 라벨 네 개

이번 메트릭은 code, name, category, exception 네 개로 박았다. 코드부터 본다.

java
private void recordBusinessError(ResultCode resultCode, Exception e) {
    Counter.builder(BUSINESS_ERROR_METRIC_NAME)
            .description("Count of business errors by ResultCode (always-200 wrapped responses).")
            .tag("code", String.valueOf(resultCode.getCode()))
            .tag("name", resultCode.name())
            .tag("category", resolveCategory(resultCode))
            .tag("exception", e.getClass().getSimpleName())
            .register(meterRegistry)
            .increment();
}
 
private static String resolveCategory(ResultCode resultCode) {
    int code = resultCode.getCode();
    if (code < 0) {
        return "system";
    }
    return String.valueOf(code).substring(0, 1);  // 4xxx -> "4", 5xxx -> "5"
}

각 라벨의 의도는 이렇다.

  • code: 비즈니스 에러 식별 숫자(4010001, 400, 415 등). 가장 세분화된 시리즈
  • name: ResultCode enum의 이름(INVALID_APPKEY_SECRETKEY, BAD_REQUEST, UNSUPPORTED_MEDIA_TYPE). code와 1:1 매핑이라 카디널리티 추가 부담 없음
  • category: 첫 자리 또는 system. "사용자 잘못(4)" vs "시스템 에러(5)"를 한 줄로 가르는 용도
  • exception: e.getClass().getSimpleName(). 같은 BAD_REQUEST 코드라도 어떤 예외 타입에서 왔는지 분리해서 보고 싶을 때 유용

name 라벨은 사실 두 번째 PR에서 추가했다

처음에는 code 하나로 충분하다고 봤다. 숫자만 있으면 PromQL 필터링도 되고, Grafana에서도 표시하면 되니까. 그런데 막상 Grafana 패널에 띄워놓고 보니 4010001이 무슨 에러인지 한 번에 안 들어왔다. 코드 표를 옆에 띄워놓고 비교해야 하는 상황이 반복됐고, 운영 담당자에게 보여줄 때마다 코드 매핑을 설명해야 했다.

해결은 단순했다. name 라벨을 같이 넣고, Grafana legend를 {{code}} {{name}} 으로 바꿨다. 그러면 한 줄에 4010001 INVALID_APPKEY_SECRETKEY 처럼 표시된다.

plaintext
ocr_api_business_error_total{
    category="4",
    code="4010001",
    exception="OcrApiException",
    name="INVALID_APPKEY_SECRETKEY"
} 20.0

카디널리티는? code와 name이 1:1이므로 시리즈 수는 변하지 않는다. 같은 정보를 두 개의 키로 노출하는 셈이라 비용 부담은 없고, 가독성 이득은 크다.

이 결정이 일반화되는 지점

라벨 추가 비용은 카디널리티에 따라 결정되는데, 기존 라벨과 1:1로 매핑되는 추가 라벨은 시리즈 수가 변하지 않는다. 그래서 가독성·검색성 이득이 있을 때만 추가 라벨을 붙이는 결정이 합리적이다. 반대로 user-id, request-id, IP 같은 라벨은 카디널리티가 폭발하므로 절대 라벨로 두면 안 된다.

단위 테스트 — SimpleMeterRegistry로 라벨까지 검증

Counter 코드는 의외로 테스트하기 쉽다. Micrometer가 제공하는 SimpleMeterRegistry를 직접 만들어 핸들러에 주입하고, 호출 후 카운터를 조회하면 된다.

java
class ExceptionControllerMetricTest {
 
    private SimpleMeterRegistry meterRegistry;
    private ExceptionController controller;
 
    @BeforeEach
    void setUp() {
        meterRegistry = new SimpleMeterRegistry();
        controller = new ExceptionController(meterRegistry);
    }
 
    @Test
    @DisplayName("OcrApiException 처리 시 ResultCode 코드/카테고리/예외명 라벨 카운터 증가")
    void recordsCounterForOcrApiException() {
        var request = new MockHttpServletRequest("POST", "/api/v1.0/appkeys/test/general");
        var exception = new OcrApiException(ResultCode.INTERNAL_API_FAIL, "test-app-key");
 
        controller.handleException(exception, request);
 
        Counter counter = findCounter(
                "code", String.valueOf(ResultCode.INTERNAL_API_FAIL.getCode()),
                "category", "5",
                "exception", "OcrApiException"
        );
        assertThat(counter).isNotNull();
        assertThat(counter.count()).isEqualTo(1.0);
    }
 
    private Counter findCounter(String... tagPairs) {
        var search = meterRegistry.find(BUSINESS_ERROR_METRIC_NAME);
        for (int i = 0; i < tagPairs.length; i += 2) {
            search = search.tag(tagPairs[i], tagPairs[i + 1]);
        }
        return search.counter();
    }
}

meterRegistry.find(...).tag(...).tag(...).counter() 가 부분 매칭이라는 점이 좋다. 위 테스트는 name 라벨을 검사하지 않지만, 나중에 새 라벨을 추가해도 기존 테스트가 깨지지 않는다. 라벨 하나만 검증하는 좁은 테스트와, 모든 라벨을 검증하는 넓은 테스트를 따로 두면 회귀 안전성과 가독성 둘 다 챙길 수 있다.

신규 라벨을 추가했을 때는 검증 케이스 한 개만 더 붙이면 된다.

java
@Test
@DisplayName("ResultCode enum 이름이 name 라벨로 함께 기록됨")
void recordsResultCodeNameLabel() {
    var request = new MockHttpServletRequest("POST", "/api/v1.0/appkeys/test/general");
    var exception = new OcrApiException(ResultCode.DUPLICATED_APPKEY, "test-app-key");
 
    controller.handleException(exception, request);
 
    Counter counter = findCounter(
            "code", String.valueOf(ResultCode.DUPLICATED_APPKEY.getCode()),
            "name", ResultCode.DUPLICATED_APPKEY.name(),
            "category", "4",
            "exception", "OcrApiException"
    );
    assertThat(counter.count()).isEqualTo(1.0);
}

PromQL을 두 번 갈아탔다

여기가 글의 진짜 본론이다. 같은 메트릭을 띄우는 데 세 가지 PromQL 방식을 다 써봤고, 각 단계에서 "이건 안 되겠다" 싶은 이유가 명확했다.

1차: rate(ocr_api_business_error_total[5m])

가장 흔한 시작점. 분당 발생률을 시계열로 그리는 패턴이다.

promql
sum by (code) (rate(ocr_api_business_error_total{cluster="$cluster"}[5m]))

배포 직후 패널을 켜고 트래픽을 흘렸는데 신규 시리즈가 안 보였다. 분명히 카운터는 올라가고 있는데 패널은 비어 있는 상태가 1~2분 지속됐다.

원인은 알면 단순하다. rate()/increase()는 윈도우 안에 최소 2개의 sample이 있어야 의미 있는 값을 계산한다. scrape 간격이 15초인 환경이라면, 첫 에러 발생 후 두 번째 scrape이 도착할 때까지(15~30초) 시리즈가 표시되지 않는다. 1분에 한 번 발생하는 드문 에러라면 더 길어진다.

데모·검증 단계에서 "방금 발생시켰는데 왜 안 보이지?" 질문이 반복적으로 나왔다.

2차: 누적 sum

신규 시리즈 보임 문제를 해결하려고 누적 합으로 갔다.

promql
sum by (code) (ocr_api_business_error_total{cluster="$cluster"})

Counter는 단조 증가(monotonically increasing)하는 본성이라 이 식은 "전체 누적"을 그대로 보여준다. 첫 sample부터 즉시 값이 표시되고, 신규 시리즈도 즉시 라인에 등장한다.

문제는 다른 데서 터졌다. 에러가 멈춰도 라인이 사라지지 않는다. Grafana 시간 범위 안의 누적값을 그대로 그리니, 한 번 발생한 코드는 시간 범위가 끝날 때까지 계단식 라인이 남는다. "지금 발생 중인 에러"와 "한참 전에 발생한 잔존 라인"이 시각적으로 구분되지 않았다.

운영 입장에서 "지금 문제가 있나?"를 한눈에 보기 위한 패널이 그 본질을 잃은 셈이었다.

최종: increase(...[$__rate_interval])

윈도우를 다시 쓰되, 신규 시리즈 함정을 알고 윈도우 길이를 조정하는 방향으로 갔다.

promql
# 시계열 패널 (errorCode, exception types, system errors)
sum by (code, name) (increase(ocr_api_business_error_total{cluster="$cluster"}[$__rate_interval]))
 
# Instant 계열 패널 (Top-5, Pie chart)
topk(5, sum by (code, name) (increase(ocr_api_business_error_total{cluster="$cluster"}[1h])))
sum by (category) (increase(ocr_api_business_error_total{cluster="$cluster"}[1h]))

정리하면:

패널 종류윈도우의도
시계열 (시간 흐름)$__rate_intervalrefresh 간격에 맞춰 자동 조정. 발생 멈추면 윈도우 끝나는 시점부터 0으로 떨어짐
Instant (현재값)[1h]"최근 1시간 분포" — 첫 scrape 한 번이면 충분히 잡힘, 신규 시리즈도 거의 즉시 보임

$__rate_interval은 Grafana가 패널 refresh 간격에 맞춰 자동 계산하는 변수다. refresh 10초 패널이면 자동으로 적절한 윈도우(보통 1분 이상)를 잡아주고, 패널 너비/시간 범위가 바뀌어도 다시 계산한다. 이걸 직접 [5m] 같이 고정하면 시간 범위에 따라 곡선이 너무 거칠어지거나 너무 부드러워진다.

신규 시리즈 함정은 시계열 패널에서는 여전히 남아 있지만, 정상 운영 트래픽 수준이라면 1~2분 안에 보인다. 정말 처음 발생한 코드를 즉시 보고 싶을 때는 Top-5 같은 instant 패널에서 [1h] 윈도우로 잡힌다.

의사결정 요약

세 단계는 결국 다음 트레이드오프를 따라 움직였다.

방식신규 시리즈 노출발생 멈춤 시 라인 사라짐데모 검증 적합도운영 모니터링 적합도
rate([5m])느림 (≥2 sample 필요)자연스러움나쁨나쁨 (느린 노출)
누적 sum즉시사라지지 않음좋음나쁨 (잔존 라인)
increase([$__rate_interval])시계열은 보통, instant는 빠름자연스러움적당좋음

운영 모니터링이 1순위라 최종은 세 번째다. 데모 시나리오에서 "방금 발생시켰는데 안 보임" 문제는 instant 계열 패널을 적극 활용하고, 시계열 패널은 "시간 흐름이 중요한 영역"에만 둠으로써 절충했다.

Grafana 패널 5종 — PromQL과 legend

최종적으로 만든 대시보드는 다음과 같다. 데모용으로 임의 에러를 흘려넣은 상태라 errorCode와 분포가 의미 있게 잡혀 있다.

비즈니스 에러 카운터 Grafana 대시보드 — errorCode 시계열, User vs System 파이 차트, Top-5 errorCode, Exception types, System errors 단독 패널이 한 화면에 배치되어 있다

#패널시각화PromQLlegend
1errorCodeTime series (stacked)sum by (code, name) (increase(ocr_api_business_error_total[$__rate_interval])){{code}} {{name}}
2User vs System ErrorsPie chartsum by (category) (increase(ocr_api_business_error_total[1h]))displayName override로 User Errors / System Errors
3Top-5 errorCodesBar gaugetopk(5, sum by (code, name) (increase(ocr_api_business_error_total[1h]))){{code}} {{name}}
4Exception typesTime series (stacked)sum by (exception) (increase(ocr_api_business_error_total[$__rate_interval])){{exception}}
5System errors (category=5)Time series + thresholdsum by (code, name) (increase(ocr_api_business_error_total{category="5"}[$__rate_interval])){{code}} {{name}}

색상도 그냥 두면 안 됐다

처음에 5번 패널을 빼고는 모두 palette-classic(자동 색상)으로 두었는데, 시리즈가 7~8개를 넘어가면 색상이 비슷해 보이는 짝이 생겼다. 시각적 식별이 안 되면 패널 자체의 가치가 떨어진다.

해결은 두 가지를 썼다.

  • palette-classic: 시리즈 수가 적당한 패널(1, 3, 4)에 적용. 자동이지만 distinct한 색을 우선 배정
  • fixed color: 시스템 에러 단독 패널(5)은 red 단일색. "이 패널이 켜지면 무조건 위험"이라는 시각적 신호

Pie chart의 category=4 / category=5도 displayName override로 User Errors / System Errors 로 바꾸고, 색상도 orange / dark-red로 고정해서 직관적으로 읽히게 했다.

적용 후 알게 된 것들

Counter는 절대 줄지 않는다 (그래서 retention이 중요하다)

이 글의 PromQL 트레이드오프 전체가 counter는 단조 증가한다는 본성에서 출발한다. Pod이 재시작되면 카운터가 0부터 다시 시작하지만, 그건 Prometheus 입장에서 보면 같은 라벨 시리즈에 reset이 감지되는 것이고, 시리즈 자체는 보관 기간(retention) 내내 살아 있다.

"한 번 발생한 errorCode가 영원히 남는 것 같은데"는 이 본성 때문이다. 사라지게 하려면 (1) 시간 범위를 짧게 (2) rate/increase 윈도우 사용 (3) 시리즈 자체가 보관 기간에서 만료. 보통 (2)가 답이다.

라벨 카디널리티는 사전에 계산하고 시작한다

실수로 user-id, request-id 같은 unbounded 라벨을 박으면 Prometheus 메모리가 폭발한다. 이번 메트릭은:

  • code ↔ name 1:1: 약 50개 (enum 멤버 수)
  • category: 3개 (4, 5, system)
  • exception: 약 10개 이내

총 시리즈 수 상한은 50 × 3 × 10 = 1500 정도. 클러스터/네임스페이스/pod 라벨이 곱해지더라도 충분히 안전한 범위다. 라벨 하나 추가할 때마다 이런 곱셈을 머릿속에서 한 번씩 해보는 습관이 있어야 사고가 안 난다.

$__rate_interval 의 자동성을 신뢰한다

처음에는 [5m] 같이 고정 윈도우를 직접 적었다. 그런데 시간 범위를 30분으로 좁히면 곡선이 거칠어지고, 24시간으로 넓히면 너무 평탄해졌다. $__rate_interval로 바꾸고 나서는 이 신경을 쓸 필요가 없어졌다. Grafana 변수 중 가장 underrated 한 것 같다.

Alert Rule은 운영 데이터가 쌓인 뒤에

대시보드를 만든 직후에는 임계치를 모른다. 1주~2주 정상 트래픽 데이터를 쌓고, "평소 분당 N건"이 어느 수준인지 본 다음에 alert rule을 정해야 false positive가 줄어든다. 그 전에는 패널만 띄워두고 운영자가 자연스럽게 익숙해지게 둔다.

마무리

응답을 200으로 통일하는 공통 응답 포맷 정책 자체는 흔치 않지만, 비슷한 환경(예: GraphQL이라 항상 200, gRPC와 status 변환 레이어가 있어서 표준 status가 무력화됨 등)은 의외로 많다. 표준 메트릭이 안 맞으면 직접 박는 게 결국 답인데, 그 과정에서 "어디에 박을지", "어떤 라벨을 붙일지", "PromQL을 어떻게 쓸지" 세 단계 결정이 있다는 걸 이번에 정리하게 됐다.

특히 PromQL 갈아타는 단계는 "처음부터 잘 결정할 수 있었나" 자문해 봤는데, 솔직히 누적 sum까지는 한 번 부딪혀봐야 알 수 있는 영역이었던 것 같다. 데모 검증 vs 운영 모니터링이라는 두 사용처가 생각보다 다르게 동작한다는 걸, 패널을 띄워놓고 며칠 운영해본 뒤에야 체감했다.

on this page
  • 01왜 표준 메트릭이 무력화됐나
  • 02어디서 잡을까: ExceptionController
  • 03라벨 설계 — code/name/category/exception
  • 잠깐, "카디널리티"가 뭔가
  • 최종 결정한 라벨 네 개
  • name 라벨은 사실 두 번째 PR에서 추가했다
  • 04단위 테스트 — `SimpleMeterRegistry`로 라벨까지 검증
  • 05PromQL을 두 번 갈아탔다
  • 1차: `rate(ocr_api_business_error_total[5m])`
  • 2차: 누적 `sum`
  • 최종: `increase(...[$__rate_interval])`
  • 06Grafana 패널 5종 — PromQL과 legend
  • 07적용 후 알게 된 것들
  • Counter는 절대 줄지 않는다 (그래서 retention이 중요하다)
  • 라벨 카디널리티는 사전에 계산하고 시작한다
  • `$__rate_interval` 의 자동성을 신뢰한다
  • Alert Rule은 운영 데이터가 쌓인 뒤에
  • 08마무리

댓글 (0)