작업을 하다보면 jq는 빠지지않고 등장하는데 의외로 이걸 뜯어보는 일이 잘 없다.
그래서 정리해보는 글.
jq는 커맨드라인에서 JSON을 다루는 표준 도구다. "JSON을 위한 sed/awk"라고 불린다.
이 문서는 로그 분석(특히 JSONL)을 중심으로, 개념 → 필수 문법 → 실전 패턴 → 함정과 팁 순서로 정리했다.
jq를 이해하는 가장 중요한 한 문장:
모든 jq 필터는 "입력값 하나를 받아 출력값 0개 이상을 내보내는 파이프"다.
. 이다.|(파이프)를 넘어가면 왼쪽의 출력이 오른쪽의 새로운 . 이 된다.select는 0~1개, .[]는 N개를 낸다)이 모델만 잡으면 복잡한 명령도 "지금 .이 뭐지?"만 따라가며 읽을 수 있다.
echo '{"user":{"name":"bob"}}' | jq '.user | .name'
# 처음 . = {"user":{"name":"bob"}}
# .user 통과 후 . = {"name":"bob"} ← 파이프 넘으며 . 가 갱신됨
# .name → "bob"
| 옵션 | 이름 | 의미 | 언제 쓰나 |
|---|---|---|---|
-r | --raw-output | 출력이 문자열이면 따옴표 제거 + 이스케이프 해석 | 사람이 읽거나 다른 명령에 값 넘길 때 (거의 항상) |
-R | --raw-input | 입력을 JSON이 아닌 raw 텍스트(줄)로 받음 | 깨진 줄 섞인 로그, JSON 아닌 입력 |
-s | --slurp | 입력 전체를 하나의 배열로 모아서 처리 | 전체 집계(개수·합계·정렬) |
-c | --compact-output | 한 줄로 압축 출력 (pretty 안 함) | JSONL로 다시 저장, 다음 도구에 넘길 때 |
-n | --null-input | 입력 없이 null로 시작 | 값을 처음부터 생성할 때 |
-e | --exit-status | 결과에 따라 종료 코드 설정 | 셸 스크립트 조건 분기 |
-j | --join-output | -r + 끝 줄바꿈 제거 | 값들을 줄바꿈 없이 이어붙일 때 |
--arg / --argjson | — | 외부 변수를 안전하게 주입 | 셸 변수를 jq에 넣을 때 |
--tab | — | 들여쓰기를 탭으로 | 출력 포맷 취향 |
-r의 효과를 다시 명확히echo '{"name":"alice","msg":"a\nb"}' | jq '.name' # "alice" ← 따옴표 붙음
echo '{"name":"alice","msg":"a\nb"}' | jq -r '.name' # alice ← 깔끔
echo '{"name":"alice","msg":"a\nb"}' | jq -r '.msg' # a(줄바꿈)b ← \n 해석됨
-r은 출력값 자체가 문자열일 때만 효과가 있다. 숫자·객체·배열엔 영향 없음.
jq '.' # 현재 값 그대로 (pretty-print 용도)
jq '.name' # name 필드
jq '.req.path' # 중첩 필드
jq '.["weird-key"]' # 키에 - . 공백 등이 있으면 대괄호+따옴표
jq '.code?' # 없을 수 있는 필드 → 에러 대신 그냥 비움(? = optional)
키에 하이픈이나 특수문자가 있으면
.user-id는 에러다(빼기로 해석)..["user-id"]로 써야 한다.
jq '.[0]' # 첫 원소
jq '.[-1]' # 마지막 원소
jq '.[2:5]' # 슬라이스 (index 2,3,4)
jq '.[]' # 배열/객체를 풀어서 원소를 하나씩 스트림으로
jq '.tags[]' # tags 배열의 원소들을 하나씩
jq 'length' # 배열 길이 / 문자열 길이 / 객체 키 개수
.[](이터레이터)는 jq의 심장이다. "배열을 N개의 개별 값으로 풀어준다"는 뜻이고,
이후 파이프에서 .은 각 원소가 된다.
echo '[{"v":10},{"v":20}]' | jq '.[] | .v'
# 10
# 20
# 필요한 키만 뽑아 새 객체로
jq '{time: .ts, lvl: .level, message: .msg}'
# 키 이름을 그대로 쓰면 축약 가능
jq '{ts, level, msg}' # = {ts: .ts, level: .level, msg: .msg}
# 배열로 묶기
jq '[.ts, .level, .msg]'
select가장 많이 쓰는 패턴. select(조건)은 조건이 참인 입력만 통과시킨다(거짓이면 0개 출력).
jq 'select(.level == "ERROR")' # level이 ERROR
jq 'select(.level == "ERROR" and .code >= 500)' # AND
jq 'select(.level == "ERROR" or .level == "WARN")' # OR
jq 'select(.user != "system")' # 부정
jq 'select(.code >= 400 and .code < 500)' # 범위
jq 'select(.msg | test("timeout"))' # 정규식 부분 매칭
jq 'select(.msg | test("(?i)error"))' # (?i) = 대소문자 무시
jq 'select(.tags | index("urgent"))' # 배열에 특정 값 포함?
jq 'select(has("error"))' # 특정 키 존재 여부
jq 'select(.user | startswith("admin"))' # 접두사
jq 'select(.path | endswith(".json"))' # 접미사
jq 'select(.code != null)' # null 아닌 것만
==, !=, <, <=, >, >=and, or, not (※ not은 필터라서 ... | not 형태)test(re), match(re), startswith, endswith, contains, ascii_downcase, ascii_upcase# not 사용 예
jq 'select(.level == "DEBUG" | not)' # DEBUG가 아닌 것
\(...)로그를 사람이 읽는 한 줄로 만들 때 핵심. -r과 거의 항상 같이 쓴다.
jq -r '"\(.ts) [\(.level)] \(.msg)"'
# 2024-01-01T10:00 [ERROR] db timeout
jq '.msg | ascii_downcase' # 소문자화
jq '.msg | ltrimstr("PREFIX-")' # 접두사 제거
jq '.msg | rtrimstr(".log")' # 접미사 제거
jq '.path | split("/")' # 구분자로 쪼개 배열로
jq '.tags | join(", ")' # 배열을 구분자로 합쳐 문자열로
jq '.msg | sub("foo"; "bar")' # 첫 매칭만 치환
jq '.msg | gsub("\\d+"; "N")' # 전역 치환(정규식). \는 두 번 escape
jq '.ts | .[0:10]' # 문자열 슬라이스 (날짜만: 2024-01-01)
jq '@base64' # base64 인코딩 (@base64d = 디코딩)
정규식 안에서
\d,\s같은 건 jq 문자열에서\\d,\\s로 한 번 더 escape해야 한다.
# TSV (탭 구분) — column으로 정렬하면 표처럼 보임
jq -r '[.ts, .level, .msg] | @tsv' app.log | column -t -s$'\t'
# CSV (따옴표/이스케이프 자동 처리)
jq -r '[.ts, .level, .user, .msg] | @csv' app.log
# 헤더 + 데이터 (slurp으로 모아서)
jq -r '["TS","LEVEL","MSG"], (.[] | [.ts, .level, .msg]) | @tsv' -s app.log
# 셸/URL용 안전한 문자열 이스케이프
jq -r '@sh "rm \(.path)"' # 셸에 안전하게 인용
jq -r '@uri "\(.query)"' # URL 인코딩
@tsv, @csv, @sh, @uri 같은 포맷 지시자는 결과를 문자열로 만든다.
반드시 -r을 붙여야 따옴표 없이 깔끔하게 나온다.
-s(slurp)와 reduce/group_byjq는 기본적으로 "줄마다 독립 처리"라 전체를 가로질러 세려면 -s로 배열로 모아야 한다.
# 전체 줄(레코드) 수
jq -s 'length' app.log
# level별 개수
jq -s 'group_by(.level) | map({level: .[0].level, count: length})' app.log
# ERROR만 골라 배열로
jq -s 'map(select(.level == "ERROR"))' app.log
# code 평균 / 합계 / 최대
jq -s 'map(.code) | add / length' app.log # 평균
jq -s 'map(.code) | add' app.log # 합계
jq -s 'map(.code) | max' app.log # 최대
jq -s '[.[].latency] | add/length' app.log # 평균 latency
# 고유값 목록
jq -s 'map(.user) | unique' app.log
jq -s 'group_by(.user) | length' app.log # 고유 유저 수
# 카운트 맵 만들기 ({INFO: 5, ERROR: 2, ...})
jq -s 'group_by(.level) | map({(.[0].level): length}) | add' app.log
# 상위 N개 (정렬 후 자르기)
jq -s 'sort_by(.latency) | reverse | .[0:5]' app.log # 느린 요청 top5
전부 메모리에 올리는 -s가 부담스러우면 스트리밍 + sort | uniq:
jq -r '.level' app.log | sort | uniq -c | sort -rn
# 12 INFO
# 4 ERROR
# 1 WARN
# code 합계를 reduce로
jq -s 'reduce .[] as $x (0; . + $x.code)' app.log
# 누적 객체 만들기
jq -s 'reduce .[] as $x ({}; .[$x.user] += 1)' app.log # 유저별 횟수
실제 로그엔 JSON이 아닌 줄(스택트레이스, 빈 줄, 평문)이 섞인다.
이때 jq가 파싱 에러로 멈추는 걸 막아야 한다.
# -R 로 각 줄을 문자열로 받고, fromjson? 로 파싱 시도, 실패하면 버림
jq -R 'fromjson? // empty | select(.level == "ERROR")' app.log
해석:
-R : 각 줄을 raw 문자열로 입력fromjson? : 문자열을 JSON으로 파싱 시도, ?로 실패해도 에러 안 냄// empty : 파싱 실패(=빈 값)면 그 줄을 출력에서 제거select 적용
//는 "왼쪽이 없거나 false/null이면 오른쪽" 이라는 대체(alternative) 연산자다.
기본값 지정에도 자주 쓴다:jq '.code // 0'→ code 없으면 0.
# 흘러들어오는 ERROR만 즉시 한 줄로
tail -f app.log | jq -r --unbuffered \
'select(.level == "ERROR") | "\(.ts) \(.msg)"'
--unbuffered가 핵심. 없으면 출력이 버퍼링되어 한참 뒤에 몰려 나온다.
실시간 스트림에는 거의 필수다.
--arg / --argjson문자열 보간("...$VAR...")으로 셸 변수를 jq 코드에 끼워넣지 마라. 인용 깨짐·인젝션 위험이 있다.
--arg(문자열)와 --argjson(JSON 값)을 써서 변수로 주입한다.
LEVEL=ERROR
MIN=500
# --arg: 문자열로 들어감 ($lvl)
jq --arg lvl "$LEVEL" 'select(.level == $lvl)' app.log
# --argjson: 숫자/불린/객체 등 JSON 값으로 들어감 ($min)
jq --argjson min "$MIN" 'select(.code >= $min)' app.log
# 동시에 여러 개
jq --arg lvl "$LEVEL" --argjson min "$MIN" \
'select(.level == $lvl and .code >= $min)' app.log
차이: --arg n 5 는 문자열 "5", --argjson n 5 는 숫자 5. 비교 연산엔 --argjson을 써야 한다.
-e(종료 코드)-e는 결과에 따라 종료 코드를 설정해서 if 분기에 쓸 수 있게 한다.
(마지막 출력이 null/false거나 출력이 없으면 비정상 종료 코드)
# ERROR 로그가 하나라도 있으면 알림
if jq -e 'select(.level=="ERROR")' app.log > /dev/null; then
echo "에러 발견!"
fi
# 값 존재 여부 체크
if echo "$JSON" | jq -e '.user' > /dev/null; then
echo "user 필드 있음"
fi
값을 변수에 받을 때도 -r 잊지 말 것:
NAME=$(echo "$JSON" | jq -r '.user.name') # 따옴표 없이 깔끔하게
# 입력 없이 새 JSON 생성 (-n)
jq -n '{status: "ok", ts: now}'
# 필드 추가/덮어쓰기
jq '.processed = true'
jq '.level = (.level | ascii_upcase)' # 기존 값 가공해 덮기 (= 우변에 .쓰면 원본 기준)
# |= 는 "해당 경로의 값을 이 필터로 갱신"
jq '.msg |= ascii_downcase' # msg를 소문자로 갱신
jq '.code |= (. // 0)' # code 없으면 0으로
# 필드 삭제
jq 'del(.password)' # 민감 필드 제거
jq 'del(.a, .b)' # 여러 개
# 객체 병합
jq '. + {env: "prod"}' # 얕은 병합
jq '. * {meta: {v: 2}}' # 깊은(재귀) 병합
=와|=차이:.x = (.y)는 우변을 루트.기준으로 평가.
.x |= (. + 1)는 우변의.이.x의 현재 값. 갱신엔 보통|=가 직관적이다.
| 함수 | 설명 | 예 |
|---|---|---|
length | 길이/크기 | .tags \| length |
keys / keys_unsorted | 객체 키 목록 | keys |
values | 값 목록 | .obj \| values |
has("k") | 키 존재 | select(has("error")) |
to_entries / from_entries | 객체↔[{key,value}] 변환 | 키 일괄 가공 |
map(f) | 배열 각 원소에 f | map(.id) |
map_values(f) | 객체 각 값에 f | map_values(. * 2) |
select(cond) | 조건 필터 | select(.ok) |
add | 배열 합치기/합계 | [1,2,3] \| add → 6 |
unique / unique_by(f) | 중복 제거 | map(.user) \| unique |
sort / sort_by(f) | 정렬 | sort_by(.ts) |
group_by(f) | 그룹화 | group_by(.level) |
min / max / min_by / max_by | 최소/최대 | max_by(.latency) |
flatten | 중첩 배열 평탄화 | flatten(1) |
range(n) | 0..n-1 생성 | [range(3)] → [0,1,2] |
now / todate / fromdate | 시간 처리 | now \| todate |
ascii_downcase/upcase | 대소문자 | .s \| ascii_downcase |
tostring / tonumber | 타입 변환 | .code \| tostring |
type | 타입 이름 | .x \| type → "string" |
paths | 모든 경로 | 구조 탐색 |
getpath / setpath | 경로로 접근/설정 | 동적 경로 |
env / $ENV | 환경변수 | $ENV.HOME |
# 객체 키-값을 줄줄이 출력
echo '{"a":1,"b":2}' | jq -r 'to_entries[] | "\(.key)=\(.value)"'
# a=1
# b=2
# 1) 특정 시간대 ERROR만 한 줄씩
jq -r 'select(.level=="ERROR") | "\(.ts) \(.msg)"' app.log
# 2) 느린 요청 top 10 (latency 내림차순)
jq -s 'sort_by(.latency) | reverse | .[0:10] | .[] | "\(.latency)ms \(.path)"' -r app.log
# 3) 상태코드 분포
jq -r '.status' app.log | sort | uniq -c | sort -rn
# 4) 분당 에러 개수 (ts 앞 16자리 = 분 단위로 그룹)
jq -r 'select(.level=="ERROR") | .ts[0:16]' app.log | sort | uniq -c
# 5) 특정 유저의 활동만 시간순
jq -s 'map(select(.user=="alice")) | sort_by(.ts) | .[] | "\(.ts) \(.action)"' -r app.log
# 6) 민감정보 제거 후 저장 (compact JSONL로)
jq -c 'del(.password, .token)' app.log > clean.log
# 7) 에러 메시지별 빈도 (어떤 에러가 잦은지)
jq -r 'select(.level=="ERROR") | .msg' app.log | sort | uniq -c | sort -rn | head
# 8) 두 필드 조합으로 그룹 집계
jq -s 'group_by(.service) | map({service: .[0].service, errors: map(select(.level=="ERROR")) | length})' app.log
# 9) 깨진 줄 무시하고 안전하게 ERROR 추출
jq -R 'fromjson? // empty | select(.level=="ERROR")' app.log
# 10) JSON 배열 파일을 JSONL로 변환 (반대도: -s로 다시 배열)
jq -c '.[]' array.json > lines.jsonl
jq -s '.' lines.jsonl > array.json
-r을 안 붙여서 값에 따옴표가 따라온다 → 변수/루프에서 깨짐. 사람이 읽거나 셸로 넘길 땐 -r..user-id는 빼기로 해석됨 → .["user-id"] 사용..a.b에서 .a가 null이면 에러. .a?.b 또는 .a.b?로 방어.-s 없이 집계 시도: add, group_by는 배열이 필요 → 줄 단위 입력엔 -s 필요.\\d처럼 백슬래시를 두 번.= vs |= 혼동: 기존 값 기준으로 갱신하려면 |=.-s: 전부 메모리에 올림. 수GB 로그는 스트리밍(sort|uniq)이나 --stream 고려.jq 버전·옵션 확인.# 단계별로 . 가 뭔지 확인하며 파이프를 하나씩 늘려가라
jq '.' # 일단 전체 구조 보기
jq '.data' # 한 단계
jq '.data | .[0]' # 또 한 단계
jq '.data | .[0] | keys' # 키 확인
# debug: 값을 stderr로 흘리며 그대로 통과 (중간 점검)
jq '.items[] | debug | .id'
# 타입이 헷갈리면 type으로 확인
jq '.value | type' # "string"? "number"? "array"?
# 구조를 모를 때 모든 경로 보기
jq -c 'paths' sample.json | head
# 한 줄만 샘플로 떼서 실험
head -1 app.log | jq '.'
jq -c '.' # 한 줄(compact)로 → JSONL 유지하며 다음 도구로
jq -s '.' # 여러 줄을 하나의 배열로
jq -c '.[]' # 배열을 다시 JSONL로 풀기
멘탈모델: . = 지금 값, | = 다음으로 흘리면 . 가 바뀜
꺼내기: .field .a.b .[0] .[] .[2:5]
필터: select(.level=="ERROR" and .code>=500)
문자열: -r '"\(.ts) [\(.level)] \(.msg)"'
표: '[.a,.b,.c] | @tsv' (반드시 -r)
집계: -s 'group_by(.x) | map({k:.[0].x, n:length})'
빠른집계: jq -r '.level' | sort | uniq -c | sort -rn
깨진줄: jq -R 'fromjson? // empty | ...'
실시간: tail -f f | jq -r --unbuffered 'select(...)'
변수주입: --arg s "$S" / --argjson n "$N"
스크립트: jq -e '...' >/dev/null && echo ok / $(jq -r '.x')
수정: .x |= f del(.secret) . + {k:v}
저장: -c (compact, JSONL 유지)
디버깅: 파이프 한 단계씩 늘리기 / debug / type / paths
# macOS
brew install jq
# Debian/Ubuntu
sudo apt install jq
# 버전 확인
jq --version
더 깊이: 공식 매뉴얼
man jq또는 jq 매뉴얼 페이지에 모든 내장 함수가 정리돼 있다.
온라인 실험은 jqplay 같은 플레이그라운드에서 필터를 즉석에서 테스트하면 학습이 빠르다.