jq 잘 쓰는 법 — 실전 가이드

이군·2026년 6월 24일

작업을 하다보면 jq는 빠지지않고 등장하는데 의외로 이걸 뜯어보는 일이 잘 없다.
그래서 정리해보는 글.

jq는 커맨드라인에서 JSON을 다루는 표준 도구다. "JSON을 위한 sed/awk"라고 불린다.
이 문서는 로그 분석(특히 JSONL)을 중심으로, 개념 → 필수 문법 → 실전 패턴 → 함정과 팁 순서로 정리했다.


0. 멘탈 모델: jq는 "값을 흘려보내는 파이프"다

jq를 이해하는 가장 중요한 한 문장:

모든 jq 필터는 "입력값 하나를 받아 출력값 0개 이상을 내보내는 파이프"다.

  • 입력으로 들어온 "현재 값"의 이름은 항상 . 이다.
  • |(파이프)를 넘어가면 왼쪽의 출력이 오른쪽의 새로운 . 이 된다.
  • 출력은 0개일 수도, 1개일 수도, 여러 개일 수도 있다. (그래서 select는 0~1개, .[]는 N개를 낸다)

이 모델만 잡으면 복잡한 명령도 "지금 .이 뭐지?"만 따라가며 읽을 수 있다.

echo '{"user":{"name":"bob"}}' | jq '.user | .name'
#  처음 .  = {"user":{"name":"bob"}}
#  .user 통과 후 . = {"name":"bob"}   ← 파이프 넘으며 . 가 갱신됨
#  .name → "bob"

1. 가장 자주 쓰는 옵션 (이것만 알아도 80%)

옵션이름의미언제 쓰나
-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출력값 자체가 문자열일 때만 효과가 있다. 숫자·객체·배열엔 영향 없음.


2. 기본 문법 — 값 꺼내기

2.1 필드 접근

jq '.'              # 현재 값 그대로 (pretty-print 용도)
jq '.name'          # name 필드
jq '.req.path'      # 중첩 필드
jq '.["weird-key"]' # 키에 - . 공백 등이 있으면 대괄호+따옴표
jq '.code?'         # 없을 수 있는 필드 → 에러 대신 그냥 비움(? = optional)

키에 하이픈이나 특수문자가 있으면 .user-id는 에러다(빼기로 해석). .["user-id"]로 써야 한다.

2.2 배열 다루기

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

2.3 객체 만들기 / 재구성

# 필요한 키만 뽑아 새 객체로
jq '{time: .ts, lvl: .level, message: .msg}'

# 키 이름을 그대로 쓰면 축약 가능
jq '{ts, level, msg}'        # = {ts: .ts, level: .level, msg: .msg}

# 배열로 묶기
jq '[.ts, .level, .msg]'

3. 필터링 — 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가 아닌 것

4. 문자열 다루기 & 보간

4.1 문자열 보간 \(...)

로그를 사람이 읽는 한 줄로 만들 때 핵심. -r과 거의 항상 같이 쓴다.

jq -r '"\(.ts) [\(.level)] \(.msg)"'
# 2024-01-01T10:00 [ERROR] db timeout

4.2 유용한 문자열 함수

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해야 한다.


5. 출력 포맷 — 표/CSV/TSV

# 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을 붙여야 따옴표 없이 깔끔하게 나온다.


6. 집계 — -s(slurp)와 reduce/group_by

jq는 기본적으로 "줄마다 독립 처리"라 전체를 가로질러 세려면 -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

가벼운 대안: jq + 유닉스 도구

전부 메모리에 올리는 -s가 부담스러우면 스트리밍 + sort | uniq:

jq -r '.level' app.log | sort | uniq -c | sort -rn
#   12 INFO
#    4 ERROR
#    1 WARN

reduce — 직접 누적

# code 합계를 reduce로
jq -s 'reduce .[] as $x (0; . + $x.code)' app.log

# 누적 객체 만들기
jq -s 'reduce .[] as $x ({}; .[$x.user] += 1)' app.log   # 유저별 횟수

7. 깨진 줄이 섞인 로그 (실전 필수)

실제 로그엔 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.


8. 실시간 로그(tail -f)와 함께

# 흘러들어오는 ERROR만 즉시 한 줄로
tail -f app.log | jq -r --unbuffered \
  'select(.level == "ERROR") | "\(.ts) \(.msg)"'

--unbuffered가 핵심. 없으면 출력이 버퍼링되어 한참 뒤에 몰려 나온다.
실시간 스트림에는 거의 필수다.


9. 셸 변수 안전하게 넣기 — --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을 써야 한다.


10. 스크립트에서 jq 쓰기 — -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')   # 따옴표 없이 깔끔하게

11. 값 생성/수정 — 업데이트 연산

# 입력 없이 새 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의 현재 값. 갱신엔 보통 |=가 직관적이다.


12. 자주 쓰는 함수 치트시트

함수설명
length길이/크기.tags \| length
keys / keys_unsorted객체 키 목록keys
values값 목록.obj \| values
has("k")키 존재select(has("error"))
to_entries / from_entries객체↔[{key,value}] 변환키 일괄 가공
map(f)배열 각 원소에 fmap(.id)
map_values(f)객체 각 값에 fmap_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

to_entries 활용 (객체를 배열처럼 다루기)

# 객체 키-값을 줄줄이 출력
echo '{"a":1,"b":2}' | jq -r 'to_entries[] | "\(.key)=\(.value)"'
# a=1
# b=2

13. 실전 레시피 모음 (로그 분석)

# 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

14. 흔한 함정 & 디버깅 팁

함정

  1. -r을 안 붙여서 값에 따옴표가 따라온다 → 변수/루프에서 깨짐. 사람이 읽거나 셸로 넘길 땐 -r.
  2. 하이픈 키: .user-id는 빼기로 해석됨 → .["user-id"] 사용.
  3. 없는 필드 접근: .a.b에서 .a가 null이면 에러. .a?.b 또는 .a.b?로 방어.
  4. -s 없이 집계 시도: add, group_by는 배열이 필요 → 줄 단위 입력엔 -s 필요.
  5. 정규식 escape: jq 문자열에선 \\d처럼 백슬래시를 두 번.
  6. = vs |= 혼동: 기존 값 기준으로 갱신하려면 |=.
  7. 큰 파일에 -s: 전부 메모리에 올림. 수GB 로그는 스트리밍(sort|uniq)이나 --stream 고려.
  8. 숫자 정밀도: 매우 큰 정수는 부동소수 변환으로 정밀도 손실 가능. 큰 정수 보존엔 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 '.'

처리 결과를 다시 JSONL/JSON으로

jq -c '.'           # 한 줄(compact)로 → JSONL 유지하며 다음 도구로
jq -s '.'           # 여러 줄을 하나의 배열로
jq -c '.[]'         # 배열을 다시 JSONL로 풀기

15. 한 페이지 요약

멘탈모델:  . = 지금 값,  | = 다음으로 흘리면 . 가 바뀜
꺼내기:    .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 같은 플레이그라운드에서 필터를 즉석에서 테스트하면 학습이 빠르다.

profile
이군의 보안, 그리고 생각을 다룹니다.

0개의 댓글