한 줄 요약: 접속자 IP 를 좌표로 바꿔 지도에 올리는 일은 배선만 놓고 보면 단순합니다. 어려운 쪽은 빈 패널의 원인을 가르는 일이었습니다. 라벨 폭발로 로그가 상류에서 버려지는 것, 집계가 조인 키를 지우는 것, 라벨 이름을 잘못 아는 것, 필드 타입이 맞지 않는 것 — 네 가지가 화면에서 전부 똑같은 “No data” 로 보이고, 그중 어느 것도 에러 메시지를 남기지 않습니다.
이 글의 위치: 기술 블로그 → 인프라 · 관측성. 작성·검증 방식은 Editorial Policy 참조. 1편 — 활동 모니터를 PostgreSQL 위에 다시 그리기의 후속이고, 이 지도들이 올라가 있는 관측 스택 자체는 홈서버 하나로 관측성부터 GitOps까지에 있습니다. 1편이 “같은 화면이라도 뒤에 있는 데이터의 성격이 다르다”였다면, 이 글은 데이터가 있는 줄 알았는데 도착하지 않은 경우들을 다룹니다.
지도의 입력은 좌표가 아니라 “살아남은 클라이언트 IP”다
지도 패널을 세우기 전에 답해야 할 질문은 하나입니다. 지도에 찍을 IP 가 애초에 남아 있는가. 쿠버네티스 환경에서는 이게 자동으로 보장되지 않습니다.
10.42.0.213 - - [07/Aug/2026:12:03:36] "GET /..." 404 153 "-" "curl/7.88.1" "203.0.113.160"
└─ $remote_addr └─ $http_x_forwarded_for
| 값 | 정체 | 지도에 쓸 수 있나 |
|---|---|---|
$remote_addr |
SNAT 된 클러스터 내부 IP | ❌ 모든 요청이 같은 값 — 점이 하나만 찍힌다 |
X-Forwarded-For |
프록시가 채워 준 실제 요청자 IP | ✅ 이것이 진짜 client |
원인은 서비스의 externalTrafficPolicy 에 있습니다. 기본값인 Cluster 에서는 트래픽이 노드 사이에서 재분배될 수 있어야 하므로 L3 레벨의 클라이언트 IP 가 파드에 닿기 전에 SNAT 로 사라집니다. 살아남는 것은 앞단 프록시가 넣어 준 HTTP 헤더뿐입니다.
여기서 나오는 실무적 규칙 하나. 액세스 로그를 켜기 전에 진입 경로부터 그려야 합니다. 라우트를 하나도 갖고 있지 않은 인그레스 컨트롤러의 로그를 켜 봐야 그 로그에는 아무것도 남지 않습니다. 어디를 지나는지 모르면 “로그를 켰는데 비어 있다”가 되고, 그건 뒤에 나올 네 가지 빈 화면 중 하나로 위장합니다.
⚠️ XFF 는 위조 가능한 헤더입니다. 프록시를 우회해 노드에 직접 닿을 수 있다면 임의의 IP 를 심을 수 있고, 그러면 이 지도는 조작 가능한 화면이 됩니다. 신뢰 범위를 앞단 프록시 대역으로 제한하는 것이 이 지도의 전제 조건입니다.
로그 포맷도 하나 정의해야 합니다. 기본 combined 포맷에는 XFF 가 없어서 클라이언트 IP 를 알 수 없기 때문입니다. 다만 포맷 정의와 활성화는 분리해 두는 편이 낫습니다 — 정의만으로는 비용이 0 이고, 켜는 순간 요청당 한 줄씩 로그 저장소로 흘러 들어갑니다.
켜고 나서 확인할 것이 하나 있습니다. 이미지 기본 설정의 로그 한 줄과 새로 추가한 JSON 한 줄이 같은 요청을 두 번 기록하고 있을 수 있습니다. 지도 쿼리는 JSON 라인만 필터하니 결과는 맞지만 저장소로 가는 양은 두 배입니다.
IP 를 좌표로 바꾸는 자리는 셋뿐이다
IP 를 위치로 해석하는 일은 파이프라인의 어디에서든 할 수 있습니다. 어디서 하느냐에 따라 비용과 제약이 달라집니다.
| 해석 시점 | 방식 | 성격 |
|---|---|---|
| 로그가 흘러갈 때 | 수집 에이전트의 geoip 스테이지 | 실시간 · 결과가 라벨이 된다 |
| 수집 파이프라인 중간 | OTel Collector 등의 프로세서 | 속성으로 남길 수 있어 자유롭지만 컴포넌트가 하나 는다 |
| 이미 쌓인 뒤 | DB 안에서 IP 대역 테이블과 조인 | 과거 데이터도 해석 가능 · 라벨 제약이 없다 |
세 번째가 가장 자유롭지만 흘러가는 로그에는 쓸 수 없고, 첫 번째는 이미 로그를 읽고 있는 컴포넌트에 설정 몇 줄이면 되지만 결과가 라벨로 나온다는 제약이 붙습니다. 이 제약이 이 글의 절반을 차지합니다.
부수적인 결정 하나. 지리 DB 파일(GeoLite2 등)은 압축을 풀면 수십 MB 라 ConfigMap/Secret 상한(1 MiB)에 들어가지 않습니다. 매 기동마다 내려받으면 배포 횟수만큼 외부 다운로드가 발생하고, 배포처는 대개 다운로드 횟수 제한을 둡니다. 노드 로컬 파일 하나를 두고 갱신 주기에 맞춘 잡으로 채우는 편이 단순합니다.
geoip 스테이지가 만드는 것은 “고른 라벨”이 아니라 “전부”다
여기가 개념적으로 가장 중요한 지점입니다. geoip 스테이지에 labels: 로 원하는 항목 하나만 적으면 그것만 라벨이 될 것 같지만, 실제로는 스테이지가 실행되는 순간 해석 결과가 전부 라벨로 승격됩니다.
geoip_city_name geoip_continent_code geoip_continent_name geoip_country_name
geoip_location_latitude geoip_location_longitude geoip_postal_code
geoip_subdivision_code geoip_subdivision_name geoip_timezone
기본 라벨과 합치면 18 개가 되고, 이는 로그 저장소의 스트림당 라벨 수 상한(Loki 기본 max_label_names_per_series = 15)을 넘깁니다. 상한을 넘긴 스트림은 push 가 400 으로 거부되고 그 배치의 로그가 통째로 버려집니다.
증상이 사납습니다. 에이전트는 Running/Ready 이고, 설정도 정상이고, 다른 스트림은 잘 들어옵니다. 오직 그 스테이지를 통과한 스트림만 사라집니다. 로그가 안 보이니 “설정이 안 먹었나”로 진단이 흐르기 쉬운데, 실제로는 너무 잘 먹어서 생긴 일입니다. 확인은 에이전트 로그 한 줄이 가장 빠릅니다.
kubectl -n observability logs ds/promtail | grep 'label names; limit'
# entry for stream '{... 18 labels ...}' has 18 label names; limit 15
이름도 직관과 어긋납니다. 위도는 geoip_latitude 가 아니라 geoip_location_latitude 이고, 국가는 코드가 아니라 이름으로 나옵니다 — geoip_country_code 는 존재하지 않고 geoip_country_name 입니다(필드 목록은 공식 문서에 있습니다). 존재하지 않는 라벨을 찾는 쿼리는 에러가 아니라 0행이므로, 이 한 글자 차이로 대시보드 한 장이 통째로 빌 수 있습니다.
좌표를 라벨 밖으로 빼내는 길이 막혀 있을 때
라벨 수 문제를 근본적으로 피하는 방법은 좌표를 라벨이 아니라 로그 라인 본문에 넣는 것입니다. 다만 이 경로가 항상 열려 있지는 않습니다.
| 시도 | 결과 | 이유 |
|---|---|---|
template 스테이지로 라인에 덧붙이기 |
항상 빈다 | geoip 결과는 추출맵(extracted map)이 아니라 라벨에만 쓰인다 |
pack 스테이지로 라인 JSON 에 담기 |
담기지 않는다 | pack 은 추출맵의 값만 옮긴다 |
같은 pack 설정에서 추출맵에 있던 client_ip 는 라인에 잘 들어가고 좌표만 라벨로 남는 것이 증거였습니다. 결론: 이 경로에서 좌표는 라벨로 둘 수밖에 없습니다.
그래서 카디널리티는 없앤 것이 아니라 감수하기로 한 위험이 됩니다. 이 판단은 숨기지 않고 표로 적어 두는 편이 낫습니다.
| 라벨 | 판단 |
|---|---|
geoip_country_name |
✅ 약 250 개로 닫혀 있다. 유지 |
geoip_location_latitude / longitude |
⚠️ 의도적으로 감수한다. 라인으로 옮길 방법이 없다 |
| city · subdivision · postal · timezone 등 | 🚫 labeldrop. 지도는 좌표로 그리므로 불필요 |
1편에서는 같은 문제를 상위 20 개만 남기고 버리는 것으로 처리했습니다. 여기서는 버릴 방법이 없어서 감시 대상으로 남았습니다. 같은 문제라도 파이프라인의 어느 지점이냐에 따라 선택지가 달라진다는 뜻이고, 탈출구는 정해 두었습니다 — 트래픽이 커지면 좌표 두 개도 버리고 국가 단위만 남긴 뒤 도시 좌표는 DB 경로로 옮깁니다. 거기는 라벨이 아니라 테이블이라 이 제약이 없습니다.
배선을 붙이는 방법에도 함정이 하나 있습니다. GitOps 로 관리되는 리소스를 직접 patch 하면 동기화 컨트롤러가 몇 분 뒤 조용히 되돌립니다. 아무 에러 없이 설정만 사라지고 “분명 적용했는데 라벨이 안 붙는다”가 됩니다. 값 파일 쪽을 마커로 감싸 병합하는 방식이라야 멱등하고, 롤백도 같은 마커로 걷어낼 수 있습니다.

이미 쌓인 데이터를 지도에 올릴 때
흘러가는 로그와 달리, 이미 DB 에 있는 데이터는 나중에 해석해야 합니다. 이쪽에서 배운 것은 네 가지입니다.
① 지도에 찍을 업무 데이터가 있다는 전제부터 확인한다
계획서에 “DB 의 지역 데이터를 지도로”라고 적기는 쉽지만, 실제로 전수 스캔을 해 보면 지리 성격의 컬럼이 아예 없는 경우가 많습니다. 이 클러스터에서도 업무 DB 에는 없었고, 실데이터가 있는 곳은 인증 서버의 로그인 이벤트 테이블 하나뿐이었습니다.
스캔 자체에도 함정이 둘 있었습니다.
character_locations같은 이름은 지리 테이블이 아닐 수 있습니다. 등장인물↔장소(가상 세계) 조인 테이블이었습니다. 이름만 보면 정확히 찾던 것처럼 보입니다.- 컬럼명 부분 문자열 매칭은 오탐이 심합니다.
opacity가city로,coordinated_by가coord로 걸립니다.(^|_)(키워드)(_|$)처럼 토큰 경계로 매칭해야 합니다.
② 대시보드는 스키마가 아니라 뷰 하나와 계약한다
업무 스키마가 바뀔 때마다 대시보드 JSON 을 고치는 구조는 오래 못 갑니다. 그래서 대시보드가 아는 것을 다섯 컬럼짜리 뷰 하나로 고정했습니다.
-- 대시보드가 아는 것은 이 다섯 컬럼뿐이다. 업무 스키마가 무엇이든
-- 이 계약만 지키면 대시보드 JSON 은 손댈 필요가 없다.
CREATE OR REPLACE VIEW geomap_region_stats AS
SELECT r.code, r.name, r.lat, r.lon, 0::bigint AS value
FROM geo_regions r;
좌표 기준표는 업무 DB 가 아니라 별도 DB에 두었습니다. 업무 스키마가 마이그레이션 도구의 이력 관리 아래 있으면, 거기에 관측용 테이블을 끼워 넣는 순간 이력과 실제가 어긋나기 때문입니다. 현재 value 는 전부 0 인 골격 뷰라 점은 찍히되 크기가 균일합니다 — 정직하게 말하면 아직 아무것도 나타내지 않는 지도입니다.
③ 인덱스 하나가 가용성 문제로 번진다
IP 대역 테이블은 IPv4 만으로도 수백만 행입니다. 여기서 인덱스 종류가 성능을 가릅니다.
-- 반드시 GiST + inet_ops 여야 한다.
-- B-tree 로 만들면 >>=(포함) 연산자를 못 써서 플래너가 수백만 행 Seq Scan 을 돈다.
CREATE INDEX geo_ip_blocks_network_gist ON geo_ip_blocks USING gist (network inet_ops);
조회 한 번에 수 초가 걸리는 것과 수 밀리초로 끝나는 것의 차이입니다. 코어가 적은 노드에서 대시보드 패널 여러 개가 동시에 뜨는 상황이라면 이건 성능 문제가 아니라 가용성 문제가 됩니다. 같은 이유로, 조회 시점에 수천 건을 조인하는 일반 뷰 대신 머티리얼라이즈드 뷰로 갱신 시점에 한 번만 계산하도록 했습니다.
④ “지도에 안 찍히는 것”과 “집계에서 사라지는 것”은 다르다
-- LEFT JOIN LATERAL 이어야 한다. 사설 IP 나 미등록 대역은 조회가 0행을 내는데,
-- CROSS JOIN 이면 그 이벤트가 통계에서 통째로 사라진다.
LEFT JOIN LATERAL geo_lookup(e.ip_address) g ON true
전자는 위치를 모를 뿐이고 후자는 통계가 틀린 것입니다. 보안 관점에서 후자가 특히 나쁩니다 — 로그인 실패가 좌표를 못 찾는 대역에서 몰려와도 숫자에 안 잡힙니다.
DB 간 조인이 안 되는 제약도 여기서 만납니다. 인증 서버 스키마 안에 관측용 테이블을 넣는 것은 업그레이드 때 위험하므로, 반대 방향으로
postgres_fdw를 써서 필요한 테이블 하나만 가져오는 편이 안전합니다. 스키마 전체를 import 하면 업그레이드로 사라진 테이블이 남아 조회가 깨집니다. 덧붙여 대시보드용 DB 계정은SELECT만 가진 롤을 따로 두는 것이 기본값이어야 하고, 데이터소스 프로비저닝 파일에는 비밀번호가 들어가므로 ConfigMap 이 아니라 Secret이어야 합니다.

덧붙여, 인증 서버 이벤트만으로 “로그인 지도”를 그리면 인증 서버를 타지 않는 로그인 경로가 통째로 빠집니다. 이 클러스터에서도 프런트 한 쪽은 OIDC 를 쓰고 다른 쪽은 백엔드로 직접 붙어 있어서, 후자는 게이트웨이 액세스 로그에만 흔적이 남습니다. 이걸 모르면 지도는 멀쩡히 그려지고 값만 조용히 절반이 됩니다.
인프라를 지도에 올릴 때 — 라벨과 어노테이션의 경계
세 번째 원천은 메트릭입니다. 흔한 설계는 “노드에 리전 라벨을 붙이고 노드 메트릭과 조인”인데, 그대로는 동작하지 않습니다.
kube_node_labels 0 ← 라벨을 아무리 붙여도 여기엔 안 나온다
kube_node_info 1
kube_node_status_condition 15
최근 kube-state-metrics 는 노드 라벨·어노테이션을 기본으로 내보내지 않고 allowlist 를 요구합니다. 이것도 증상이 조용합니다 — kubectl get node --show-labels 로는 라벨이 멀쩡히 보이는데 메트릭에만 없어서 “라벨을 잘못 붙였나”로 진단이 흐릅니다. 실제로는 라벨은 정확했고 수출 경로가 닫혀 있었던 것입니다.
--metric-labels-allowlist=nodes=[topology.kubernetes.io/region,...]
--metric-annotations-allowlist=nodes=[geo.aidd/lat,geo.aidd/lon,...]
그리고 규약 하나. 좌표는 라벨이 아니라 어노테이션에 넣어야 합니다. 쿠버네티스 라벨 값은 영숫자로 시작하고 끝나야 하는데, 서경·남위는 음수라 -46.6351 같은 값이 라벨로는 거부됩니다. 양수만 다루는 동안에는 모르고 지나가다가 해외 리전을 추가하는 순간 터집니다. 어노테이션 값에는 그런 제약이 없습니다.
| 항목 | 위치 | 예 |
|---|---|---|
| 리전 코드 | 라벨 topology.kubernetes.io/region |
kr-seoul |
| 존 | 라벨 topology.kubernetes.io/zone |
kr-seoul-a |
| 좌표 | 어노테이션 geo.aidd/lat · geo.aidd/lon |
37.5665 / -46.6351 둘 다 가능 |
노드가 하나뿐인 클러스터에서 이 지도의 시각적 가치는 사실상 0 입니다. 그래도 배선과 규약을 지금 고정해 두는 이유는, 필요해진 다음에 규약을 정하면 이미 붙어 있는 노드들을 다시 만져야 하기 때문입니다.
덧붙여, 이 절에서 만난 “라벨은 멀쩡한데 메트릭에만 없다”는 형태는 관측 도구 일반에서 반복됩니다. 같은 함정을 커널 쪽에서 만난 사례는 eBPF 정리에 적어 두었습니다 — 낡은 도구가 “없다”고 대답하는 경우입니다.
“No data” 는 네 가지 얼굴을 하고 있다
지도 4장, 패널 21개를 세워 놓고 보면 빈 패널이 여럿 남습니다. 문제는 화면만 봐서는 왜 빈지 알 수 없다는 것입니다. 데이터가 진짜로 없어서든, 쿼리가 틀려서든, 수집이 끊겨서든, 데이터소스를 못 찾아서든 화면에 나오는 문장은 하나입니다.

① 상류에서 로그가 버려지는 중
앞에서 다룬 라벨 폭발입니다. 상한을 넘긴 스트림은 저장소가 거부하고 그 배치가 통째로 사라집니다. 쿼리도 맞고 대시보드도 맞는데 데이터가 도착한 적이 없는 경우라, 빈 패널을 만나면 이걸 가장 먼저 확인하는 것이 시간을 아낍니다.
② 라벨 이름을 잘못 알고 있음
geoip_country_name 을 geoip_country_code 로 물은 경우입니다. 쿼리는 문법적으로 완벽하고, 존재하지 않는 라벨을 찾았을 뿐입니다. 이것 하나로 대시보드 한 장의 패널 일곱 개가 전부 비었습니다.
③ 집계가 조인보다 먼저 실행됨
sum by (label_x) (metric_A) * on (node) group_left(label_x) metric_B # ❌ 빈 결과
sum by (label_x) ((metric_A) * on (node) group_left(label_x) metric_B) # ✅
metric_A 에 없는 라벨로 sum by 를 하면 에러가 아닙니다. 모든 시계열이 {} 하나로 뭉개지고, 그 과정에서 조인 키인 node 까지 사라집니다. 그래서 뒤이은 조인(vector matching)이 매칭에 실패하고 빈 결과가 됩니다. 괄호 위치 하나 차이입니다.
④ 타입·식별자가 패널이 원하는 형태가 아님
Geomap 의 coords 모드는 숫자 필드를 요구합니다. 그런데 로그 라벨도, 메트릭 라벨도, Postgres 의 numeric 도 Grafana 로 오면 전부 문자열입니다. convertFieldType 변환(메트릭은 labelsToFields 선행)이 없으면 패널이 오류 없이 그냥 빕니다. 같은 얼굴을 한 사촌이 하나 더 있습니다 — 데이터소스 플러그인 id 가 바뀌면(postgres → grafana-postgresql-datasource) 데이터소스 변수가 후보를 하나도 못 찾고 대시보드가 통째로 빕니다. 패널이 아니라 대시보드 전체라 오히려 원인을 엉뚱한 데서 찾게 됩니다.
그래서 눈으로 읽는 검토는 여기까지만 간다
대시보드 JSON 을 읽어서는 이 넷을 못 가릅니다. 그래서 모든 패널의 모든 타깃을 /api/ds/query 로 실제로 실행해서 0행인 것을 표시하는 점검을 붙였습니다. 대시보드 변수를 치환하고, 쿼리 오류와 빈 결과를 구분해서 보고합니다. 이번 작업에서 값이 가장 나간 산출물은 지도 4장이 아니라 이 점검 쪽이었습니다. 눈으로 읽는 검토는 “틀린 곳이 없어 보인다”까지만 갈 수 있고, 그건 “맞다”와 다릅니다.
정리 — 빈 화면은 아무 말도 해주지 않는다
참고 문서
이 글의 geoip 스테이지는 Promtail 기준으로 적었지만, Promtail 은 이미 지원 종료 단계라 공식 문서가 Grafana Alloy 쪽으로 옮겨졌습니다. 스테이지 이름과 필드 이름은 그대로이므로 아래
loki.process문서를 1차 출처로 봅니다. 마이그레이션도 언젠가 해야 할 숙제로 남아 있습니다.
- Grafana Alloy — loki.process
stage.geoip(승격되는 필드 목록) - Loki 설정 레퍼런스 (
max_label_names_per_series) - Grafana — Geomap 패널
- Prometheus — Vector matching
- Kubernetes — 클라이언트 소스 IP 보존 · 라벨 문법
- kube-state-metrics — CLI arguments
- PostgreSQL 16 — postgres_fdw
1편의 결론이 “같은 화면이라도 뒤에 있는 데이터의 성격이 다르다”였다면, 이번 결론은 그것과 짝을 이룹니다. 이번에 만난 실패는 전부 같은 모양이었습니다 — 컴포넌트는 Running/Ready, 설정 파일은 정상, 쿼리는 문법 오류 없음, 그리고 화면은 “No data”.
로그 한 줄이 조용히 버려지든, 괄호가 조인 키를 지웠든, 라벨 이름이 한 글자 다르든, 사용자에게 보이는 결과는 완전히 동일합니다. 그래서 관측 시스템을 만들 때 필요한 것은 예쁜 화면이 아니라 화면이 비었을 때 원인을 가르는 절차입니다.
아직 못 한 것
- L3 소스 IP 보존은 하지 않았습니다.
externalTrafficPolicy는 그대로라 소켓 주소는 계속 SNAT 된 내부 IP 이고, 지도는 XFF 헤더에 의존합니다. XFF 신뢰 범위를 프록시 대역으로 제한하는 일이 남아 있고, 그전까지 이 지도는 원리상 조작 가능한 화면입니다. - DB 원천 지도는 여전히 골격입니다. 업무 테이블에 지역 컬럼이 생기기 전까지
value는 0 입니다. - 노드 지도는 점이 하나입니다. 노드가 늘기 전까지 볼 것이 없습니다.
- 이 대시보드들이 무언가를 잡아낸 적은 아직 없습니다. 1편과 같은 태도로 적어 둡니다 — 생기면 그때 씁니다.
답글 남기기