날씨 정보를 불러오는 중...

jq 완전 정리 - 터미널에서 JSON을 자유자재로 다루는 필수 도구 (실전 예제 모음)

2026-10-03 11:45 · IT기술 · 조회 32
banner

jq 완전 정리 - 터미널에서 JSON을 자유자재로 다루는 필수 도구 (실전 예제 모음)

요즘 IT 일을 하다 보면 하루에도 몇 번씩 JSON을 마주친다. kubectl get -o json, 클라우드 CLI 출력, REST API 응답, 웹서버의 JSON 로그까지 전부 JSON이다. 문제는 이게 사람이 눈으로 읽으라고 만든 형식이 아니라는 점이다. 수천 줄짜리 출력에서 필요한 값 하나를 찾으려고 grep과 sed를 이어 붙이다 보면, 줄바꿈 하나에 스크립트가 깨진다.

jq는 이 문제를 푸는 명령줄 JSON 처리기다. "JSON계의 sed"라고 불리며, 값 꺼내기, 걸러내기, 집계, 형식 변환, 심지어 JSON을 새로 만들거나 고치는 것까지 한 줄로 처리한다. DevOps·백엔드 개발자라면 거의 필수로 쓰는 도구라, 이 글에서는 기본 문법부터 실무에서 바로 쓸 수 있는 예제까지 정리했다. 예제 출력은 전부 직접 운영 중인 k3s 서버에서 실제로 실행한 결과다.

설치

  • Rocky / RHEL / Fedora — sudo dnf install jq
  • Ubuntu / Debian — sudo apt install jq
  • macOS — brew install jq
  • Windows — winget install jqlang.jq
  • 최신 버전 단일 바이너리 — GitHub(jqlang/jq) 릴리스 페이지에서 받아 PATH에 두기만 하면 된다. 의존성이 없다.

2026년 10월 기준 최신 버전은 1.8.2(2026년 6월)다. 그런데 배포판 저장소의 버전은 꽤 오래된 경우가 많다. 우리 서버(Rocky Linux 8)에서 dnf로 깐 버전은 무려 1.6이었다. 버전에 따라 안 되는 함수가 있으니 뒤에서 따로 정리한다.

기본 문법 - 이것만 알면 80%는 해결된다

jq의 사용법은 jq '필터' 파일 또는 명령 | jq '필터'다. 필터는 작은따옴표로 감싸는 습관을 들이자. 큰따옴표로 감싸면 쉘이 $나 !를 먼저 해석해서 이상한 오류가 난다.

syntax필터 기본

  1. . — 입력 그대로 (보기 좋게 정렬·색칠해서 출력). curl ... | jq .만 해도 쓸모가 크다.
  2. .name, .a.b.c — 키로 값 꺼내기. 키에 - 같은 특수문자가 있으면 .["my-key"]
  3. .[0], .[-1], .[2:5] — 배열 인덱스와 슬라이스
  4. .[] — 배열(또는 객체)의 원소를 하나씩 풀어서 내보내기
  5. | — 앞 필터의 결과를 다음 필터로 넘기기 (쉘 파이프와 같은 개념)
  6. select(조건) — 조건이 참인 것만 통과
  7. {a, b}, [...] — 새 객체·배열 만들기
  8. a // b — a가 null이거나 없으면 b (기본값)

options자주 쓰는 옵션

  • -r — 문자열을 따옴표 없이 출력 (쉘 변수에 넣을 때 필수)
  • -c — 한 줄로 압축 출력
  • -s — 여러 JSON 입력을 하나의 배열로 모아서 처리 (집계할 때)
  • -n — 입력 없이 실행 (JSON을 새로 만들 때)
  • -e — 결과가 null이나 false면 종료 코드 1 (스크립트 조건문용)
  • --arg 이름 값 / --argjson 이름 값 — 쉘 값을 jq 변수로 안전하게 넘기기

실전 1 - kubectl과 함께 쓰기

jq가 가장 빛나는 곳이 쿠버네티스다. kubectl get -o json의 출력은 리소스 몇 개만 돼도 수천 줄이 넘는다.

① 필요한 필드만 골라서 보기

$ kubectl get nodes -o json \
  | jq '.items[0].status.nodeInfo | {kubeletVersion, osImage, kernelVersion, containerRuntimeVersion}'
{
  "kubeletVersion": "v1.36.3+k3s1",
  "osImage": "Rocky Linux 8.10 (Green Obsidian)",
  "kernelVersion": "4.18.0-553.155.1.el8_10.x86_64",
  "containerRuntimeVersion": "containerd://2.3.2-k3s2"
}

{kubeletVersion, osImage}는 {kubeletVersion: .kubeletVersion, osImage: .osImage}의 줄임말이다. 수십 개 필드 중 보고 싶은 것만 뽑아 새 객체를 만든다.

② 값 하나만 쉘 변수로 받기 (-r)

$ VER=$(kubectl get nodes -o json | jq -r '.items[0].status.nodeInfo.kubeletVersion')
$ echo $VER
v1.36.3+k3s1

-r을 빼면 "v1.36.3+k3s1"처럼 따옴표까지 변수에 들어가서 뒤에서 비교문이 조용히 틀어진다. jq 초보가 가장 많이 하는 실수다.

③ 문제 있는 파드만 찾기 (select)

$ kubectl get pods -A -o json | jq -r '
    .items[]
    | select(.status.phase != "Running" and .status.phase != "Succeeded")
    | "\(.metadata.namespace)/\(.metadata.name) \(.status.phase)"'

"\(...)"는 문자열 안에 값을 끼워 넣는 문법이다. 우리 서버에서 돌려보니 실패한 CronJob 파드 하나가 바로 걸렸다. kubectl get pods | grep -v Running보다 정확한 이유는, 화면에 보이는 STATUS 칸이 아니라 실제 상태 필드로 판단하기 때문이다.

④ 네임스페이스별 파드 수 집계 (group_by)

$ kubectl get pods -A -o json | jq -r '
    [.items[].metadata.namespace]
    | group_by(.)
    | map({ns: .[0], count: length})
    | sort_by(-.count)
    | .[:5][]
    | "\(.count)\t\(.ns)"'
26	velero
7	argocd
7	kube-system
6	trading
5	monitoring

여기서 재밌는 걸 발견했다. 백업 도구인 velero 네임스페이스에 파드가 26개나 있었다. 처음엔 백업 작업 파드가 정리되지 않고 쌓인 줄 알았는데, 들여다보니 24개가 Completed 상태의 kopia-maintain-job, 즉 백업 저장소 정리 작업이었다. Velero가 백업 대상 네임스페이스마다 최근 3개씩 일부러 남겨두는 것이라(8개 × 3 = 24) 정상이었다. 평소 kubectl get pods -A로는 눈에 잘 안 띄는데, 집계해 보니 바로 드러나서 확인해볼 수 있었다.

⑤ 리소스 제한(limit)이 없는 컨테이너 찾기

$ kubectl get pods -A -o json | jq -r '
    .items[]
    | .metadata.namespace as $ns
    | .spec.containers[]
    | select(.resources.limits.memory == null)
    | "\($ns)/\(.name)"'
argocd/argocd-application-controller
argocd/argocd-applicationset-controller
argocd/dex
...

.metadata.namespace as $ns는 바깥 값을 변수에 저장해 두는 문법이다. .spec.containers[]로 안쪽에 들어가면 바깥 필드에 접근할 수 없기 때문에 미리 담아둔다. 메모리 limit이 없는 컨테이너는 노드 메모리를 다 먹을 수 있어서, 단일 노드 클러스터에서는 점검해둘 가치가 있다.

⑥ 표 형태로 내보내기 (@tsv, @csv)

$ kubectl get pods -n portal -o json | jq -r '
    .items[] | [.metadata.name, .status.phase, (.status.containerStatuses[0].restartCount|tostring)] | @tsv'
mailserver-56ffc85b9c-2ww5h	Running	0
mini-portal-7d748f7f-5fr8b	Running	0
postgres-0	Running	0

배열을 만들고 @tsv(탭 구분)나 @csv에 넘기면 엑셀이나 column -t, awk로 바로 넘길 수 있는 형태가 된다. 따옴표나 특수문자 이스케이프도 알아서 처리해준다.

실전 2 - JSON 로그 분석

Traefik, Nginx(JSON 포맷 설정 시), 대부분의 클라우드 서비스는 로그를 한 줄에 JSON 하나씩 남긴다. jq는 이런 입력을 줄마다 따로 처리하므로 그대로 파이프로 넘기면 된다.

① 상태 코드별 요청 수

$ kubectl -n kube-system logs deploy/traefik --since=1h \
  | jq -r 'select(.DownstreamStatus != null) | .DownstreamStatus' \
  | sort | uniq -c | sort -rn
     61 200
     19 404

jq로 필요한 필드만 뽑고, 집계는 익숙한 sort | uniq -c에 맡기는 조합이다. 모든 걸 jq 안에서 하려고 애쓸 필요는 없다. 로그 중간에 JSON이 아닌 줄이 섞여 있다면 jq -R 'fromjson? | ...'로 읽으면 깨진 줄은 건너뛴다.

② 평균·최대 응답 시간 (-s로 모아서 계산)

$ kubectl -n kube-system logs deploy/traefik --since=1h | jq -s '
    [.[] | select(.Duration != null)]
    | {
        requests: length,
        avg_ms: ((map(.Duration) | add / length) / 1000000 | floor),
        max_ms: (map(.Duration) | max / 1000000 | floor)
      }'
{
  "requests": 80,
  "avg_ms": 56,
  "max_ms": 1309
}

-s는 모든 줄을 하나의 배열로 모아준다. 그래서 평균이나 최댓값처럼 전체를 봐야 하는 계산이 가능해진다. Traefik의 Duration은 나노초 단위라 1,000,000으로 나눠 밀리초로 바꿨다. 다만 -s는 전부 메모리에 올리므로 수 GB짜리 로그에는 쓰지 말자.

③ 404가 난 경로 상위 목록

$ kubectl -n kube-system logs deploy/traefik --since=1h \
  | jq -r 'select(.DownstreamStatus == 404) | .RequestPath' | sort | uniq -c | sort -rn
     17 /
      1 /wp-login.php
      1 /LAPI/V1.0/PTZ/CustomProtocal/Configuration?TaskID=1

1시간 로그만 봤는데도 워드프레스 로그인 페이지와 IP 카메라 설정 경로를 찔러본 스캐너가 보인다. /가 404인 17건은 .RequestHost를 같이 뽑아보니 전부 도메인 없이 서버의 공인 IP로 직접 들어온 요청이었다. 등록된 도메인이 아니라서 Traefik이 404를 낸 것으로, IP 대역을 통째로 훑는 봇들의 흔적이다. 필드 하나만 바꿔가며 이렇게 빠르게 가설을 확인할 수 있다는 게 jq의 진짜 장점이다.

실전 3 - REST API 응답 다루기

API 응답을 확인할 때는 curl -s URL | jq .가 기본이다. 여기서 한 단계만 더 나가면 바로 쓸 수 있는 정보가 된다. 예를 들어 GitHub API로 k3s의 정식 릴리스(사전 배포판 제외)만 뽑아보면 이렇다.

$ curl -s "https://api.github.com/repos/k3s-io/k3s/releases?per_page=10" | jq -r '
    .[] | select(.prerelease | not) | "\(.tag_name)\t\(.published_at[:10])"'
v1.37.1+k3s1	2026-09-30
v1.36.5+k3s1	2026-09-30
v1.35.9+k3s1	2026-09-30
v1.34.12+k3s1	2026-09-30

select(.prerelease | not)은 "prerelease가 false인 것"이다. .published_at[:10]처럼 문자열도 슬라이스가 된다. 날짜 앞 10자리만 잘라 2026-09-30 형태로 만든 것이다. 이런 한 줄을 cron에 걸어두면 새 버전 알림 스크립트가 된다.

실전 4 - JSON 만들고 고치기

① 쉘 값으로 JSON 만들기 (--arg)

$ jq -n --arg name "demo" --argjson port 8080 '{name: $name, ports: [{port: $port}]}'
{
  "name": "demo",
  "ports": [
    {
      "port": 8080
    }
  ]
}

API에 보낼 JSON 본문을 쉘에서 만들 때 "{\"name\": \"$NAME\"}"처럼 문자열을 이어 붙이면, 값에 따옴표나 줄바꿈이 들어오는 순간 JSON이 깨진다(보안 문제로 이어질 수도 있다). --arg는 값을 항상 올바르게 이스케이프한 문자열로 넣어준다. 숫자나 불리언은 --argjson을 쓴다.

② 기존 JSON 수정하기 (=, |=, del)

$ kubectl get deploy mini-portal -n portal -o json | jq '
    .metadata |= {name, namespace, labels}
    | del(.status)
    | .spec.replicas = 2
    | {metadata, replicas: .spec.replicas}'
{
  "metadata": {
    "name": "mini-portal",
    "namespace": "portal",
    "labels": { ... }
  },
  "replicas": 2
}
  • .a = 값 — 값을 바꾸거나 새로 넣기
  • .a |= 필터 — 기존 값에 필터를 적용한 결과로 바꾸기. 여기서는 metadata에서 세 필드만 남겼다.
  • del(.a) — 필드 삭제

이 패턴은 쿠버네티스 리소스를 다른 네임스페이스로 복사할 때 특히 유용하다. uid, resourceVersion, creationTimestamp 같은 클러스터가 붙인 필드를 지우고 네임스페이스만 바꿔서 다시 apply하면 된다.

kubectl get configmap my-config -n source -o json \
  | jq 'del(.metadata.uid, .metadata.resourceVersion, .metadata.creationTimestamp)
        | .metadata.namespace = "target"' \
  | kubectl apply -f -

자주 하는 실수와 팁

pitfalls알아두면 시간 아끼는 것들

  1. -r을 빼먹기 — 변수에 따옴표까지 들어간다. 쉘에 넘길 값이면 무조건 -r
  2. .[]와 [.[]]의 차이 — .items[]는 원소를 하나씩 따로 출력하고, [.items[]]는 다시 배열 하나로 묶는다. length, group_by, sort_by 같은 함수는 배열이 필요하므로 대괄호로 감싸야 한다.
  3. null 처리 — 없는 키를 꺼내면 오류가 아니라 null이 나온다. 기본값은 //로 준다.
    echo '{"user":{"name":"kim"}}' | jq '.user.email // "없음"' → "없음"
  4. 스크립트 조건문에는 -e — jq -e '.b'는 값이 null이면 종료 코드 1을 돌려준다. if jq -e ... >/dev/null; then으로 "필드가 있는지"를 검사할 수 있다.
  5. 필터는 작은따옴표로 — 쉘 변수를 넣고 싶어서 큰따옴표를 쓰지 말고 --arg를 쓰자.
  6. 큰 파일과 -s — -s는 전부 메모리에 올린다. 수 GB짜리 로그라면 줄 단위로 처리하고 집계는 sort | uniq -c에 넘기는 편이 안전하다.

버전 차이 - 서버의 jq가 너무 오래됐다면

앞서 말했듯 Rocky Linux 8 저장소의 jq는 1.6이다. 최신 1.8.2와 같은 명령을 돌려보면 차이가 바로 보인다.

# jq 1.8.2
$ echo '[3,-5]' | jq -c 'map(abs)'
[3,5]
$ echo '{"metadata":{"name":"web","uid":"x"},"spec":{"replicas":3}}' \
  | jq -c 'pick(.metadata.name, .spec.replicas)'
{"metadata":{"name":"web"},"spec":{"replicas":3}}

# jq 1.6
$ echo '[3,-5]' | jq -c 'map(abs)'
jq: error: abs/0 is not defined at <top-level>, line 1:

pick은 원래 구조(중첩)를 유지한 채 원하는 경로만 남겨주는 함수로, 큰 JSON을 줄여서 볼 때 아주 편하다. 인터넷에서 찾은 jq 예제가 "정의되지 않았다"는 오류를 낸다면 버전부터 확인하자(jq --version). 최신 기능이 필요하면 GitHub 릴리스의 단일 바이너리를 받아 /usr/local/bin에 두는 게 가장 간단하다.

함께 알아두면 좋은 도구

  • kubectl -o jsonpath — 간단한 추출은 jq 없이도 된다. 다만 조건을 중첩하거나 집계하는 건 못 하므로, 조금만 복잡해져도 jq가 낫다.
  • yq — YAML용 jq. 문법이 거의 같아서 jq를 익히면 쿠버네티스 매니페스트나 Helm values 파일을 같은 방식으로 다룰 수 있다.
  • gron — JSON을 json.items[0].metadata.name = "..." 같은 한 줄짜리 경로로 펼쳐준다. 원하는 값이 어느 경로에 있는지 모를 때 gron | grep으로 찾고, 찾은 경로를 jq 필터에 쓰면 된다.

마무리 - 한 장 요약

  1. 보기 좋게 출력 → jq .
  2. 값 하나 꺼내기 → jq -r '.a.b'
  3. 배열 원소마다 → .items[] | ...
  4. 조건으로 거르기 → select(.status == "Failed")
  5. 필드 골라 새 객체 → {name, age}
  6. 집계 → [...] | group_by(.x) | map({k: .[0].x, n: length})
  7. 표로 내보내기 → [.a, .b] | @tsv
  8. 여러 줄 JSON을 모아서 계산 → jq -s
  9. JSON 만들기 → jq -n --arg k v '{k: $k}'
  10. 수정 → .a = 1, .a |= ..., del(.a)

jq는 문법이 독특해서 처음엔 낯설지만, .과 |로 "데이터를 조금씩 좁혀 나간다"는 감각만 잡히면 금방 손에 붙는다. 처음부터 긴 필터를 한 번에 쓰려 하지 말고, jq '.items[0]'처럼 구조를 먼저 확인한 뒤 한 단계씩 파이프를 늘려가는 게 요령이다. 이 글의 예제 출력은 필자의 단일 노드 k3s 서버(jq 1.6 / 1.8.2) 기준이며, 공식 매뉴얼은 jqlang.org에서 확인할 수 있다.


댓글 0

글에 대한 질문이나 오류 제보는 문의하기로 보내 주세요.