본문 바로가기
Operating System

jq 실전: API 응답과 kubectl -o json, AWS CLI, CloudTrail 로그를 select와 group_by로 뽑기

매큠·2026년 8월 13일·조회 1

운영하다 보면 JSON을 눈으로 읽어야 하는 순간이 하루에도 몇 번씩 온다. kubectl로 파드 상태를 뽑거나, AWS CLI로 인스턴스 목록을 받거나, CloudTrail에서 누가 뭘 지웠는지 뒤질 때다. 브라우저 콘솔로 하나씩 클릭하는 것보다 jq 한 줄이 빠르고, 결과를 CSV로 뽑아 엑셀에 붙이면 보고용으로도 바로 쓴다. 예전에 grep과 awk로 JSON을 억지로 자르던 스크립트가 있었는데, jq로 바꾼 뒤로는 그쪽을 다시 열 일이 없다.

핵심부터 말하면, jq는 select(조건)으로 원하는 레코드만 남기고, group_by(.키)로 묶어 개수를 세고, @csv@tsv로 표 형태로 출력한다. 필드를 CSV로 뽑을 때는 반드시 [...] 배열로 감싸 @csv에 넘겨야 하고, -r(raw output)을 붙여야 따옴표 없는 평문이 나온다. 아래에서 API 응답, kubectl, AWS CLI, CloudTrail 순서로 실제 명령과 출력을 살펴본다.

jq와 핵심 필터 3개를 먼저 정리한다

jq는 JSON을 입력받아 필터를 통과시켜 JSON을 내보내는 커맨드라인 도구다. 유닉스 파이프의 sed나 awk를 JSON에 맞춰 놓은 것이라고 보면 된다. 자주 쓰는 세 가지만 뜻을 짚는다.

  • select(f): 필터 f가 참을 반환하는 입력만 그대로 통과시키고, 아니면 출력하지 않는다. 조건 필터링에 쓴다.
  • map(f): 배열의 각 원소에 f를 적용한다. [.[] | f]와 같다. 배열을 통째로 변형할 때 쓴다.
  • group_by(.foo): 배열을 입력받아 .foo 값이 같은 원소끼리 묶어 배열의 배열로 만든다. 결과는 .foo 값 기준으로 정렬된다. 집계의 시작점이다.

버전은 jq --version으로 확인한다. 이 글의 예시는 jq 1.7 기준이며, group_by나 포맷 문자열 동작은 1.6 이상에서 동일하다.

$ jq --version
jq-1.7.1

API 응답을 select로 거르고 필드만 뽑는다

curl로 받은 JSON 배열에서 조건에 맞는 항목만 남기는 것이 가장 흔한 작업이다. 예시 데이터를 파일로 두고 시작한다.

$ cat users.json
[
  {"id": 1, "name": "kim",  "role": "admin", "active": true},
  {"id": 2, "name": "lee",  "role": "user",  "active": false},
  {"id": 3, "name": "park", "role": "admin", "active": true}
]

role이 admin이면서 active가 true인 사용자만 뽑는다. 배열 원소를 하나씩 흘려보내는 .[] 뒤에 select를 건다.

$ jq '.[] | select(.role == "admin" and .active)' users.json
{
  "id": 1,
  "name": "kim",
  "role": "admin",
  "active": true
}
{
  "id": 3,
  "name": "park",
  "role": "admin",
  "active": true
}

필요한 필드만 골라 새 객체로 재구성하려면 { }로 감싼다. 이름을 바꿔 담을 수도 있다.

$ jq '.[] | select(.active) | {user: .name, r: .role}' users.json
{
  "user": "kim",
  "r": "admin"
}
{
  "user": "park",
  "r": "admin"
}

여기서 한 번 걸리는 지점이 있다. curl 결과를 파이프로 바로 넘길 때 API가 에러 HTML을 반환하면 jq가 parse error: Invalid numeric literal을 뱉는다. 이럴 때는 curl -s로 조용히 받되 응답 원문을 먼저 확인하고, JSON 확실하면 파이프한다.

$ curl -s https://api.example.com/users | jq '.[] | select(.active) | .name'
"kim"
"park"

필드를 CSV로 뽑을 때는 배열로 감싼다

표로 정리해 엑셀이나 스프레드시트에 붙이려면 @csv@tsv를 쓴다. 두 포맷 문자열 모두 입력이 배열이어야 한다. 그래서 뽑을 필드를 [ ]로 묶어 배열로 만든 다음 넘긴다. 그리고 -r을 붙여야 바깥 따옴표와 이스케이프가 빠진 순수 텍스트로 나온다.

$ jq -r '.[] | [.id, .name, .role] | @csv' users.json
1,"kim","admin"
2,"lee","user"
3,"park","admin"

헤더 행을 붙이려면 전체를 배열로 받아 헤더 배열을 앞에 이어 붙인다.

$ jq -r '["id","name","role"], (.[] | [.id, .name, .role]) | @csv' users.json
"id","name","role"
1,"kim","admin"
2,"lee","user"
3,"park","admin"

@csv는 문자열을 큰따옴표로 감싸고, 값 안의 따옴표는 따옴표를 두 번 반복해 이스케이프한다. @tsv는 탭으로 구분하며, 입력 문자 중 라인피드, 캐리지리턴, 탭, 역슬래시 네 가지를 각각 \n, \r, \t, \\ 이스케이프 시퀀스로 출력한다. 셸에서 탭 구분 데이터를 다시 자를 거면 TSV가 다루기 편하다.

$ jq -r '.[] | [.id, .name, .role] | @tsv' users.json
1	kim	admin
2	lee	user
3	park	admin

@csv 관련 에러 두 가지를 구분한다

여기서 초보자가 자주 헷갈리는 에러가 두 개 있는데, 원인이 서로 다르다. 하나씩 구분해 둔다.

첫째, @csv에 넘긴 입력 자체가 배열이 아닐 때다. 필드를 [ ]로 감싸는 걸 빼먹으면 객체나 문자열이 그대로 들어가 이런 에러가 난다.

$ jq -r '.[] | {id, name} | @csv' users.json
jq: error: object ({"id":1,"n...) cannot be csv-formatted, only array

해결책은 입력을 배열로 만드는 것이다. {id, name}[.id, .name]으로 바꿔 배열로 감싸면 된다. 필드를 스칼라로 바꾸는 게 아니라 배열로 감싸는 것이 정답이다.

$ jq -r '.[] | [.id, .name] | @csv' users.json
1,"kim"
2,"lee"
3,"park"

둘째, 배열로는 감쌌지만 그 배열 안의 원소가 다시 배열이나 객체 같은 중첩 값일 때다. 이건 별개의 에러로, is not valid in a csv row가 나온다.

$ echo '[{"name":"kim","tags":["a","b"]}]' | jq -r '.[] | [.name, .tags] | @csv'
jq: error: array (["a","b"]) is not valid in a csv row

이쪽은 중첩 필드를 스칼라로 눌러 줘야 한다. 배열이면 join으로 문자열 하나로 합치거나, 객체면 tojson으로 직렬화한다.

$ echo '[{"name":"kim","tags":["a","b"]}]' | jq -r '.[] | [.name, (.tags | join("|"))] | @csv'
"kim","a|b"

정리하면 cannot be csv-formatted, only array는 배열로 감싸서 풀고, is not valid in a csv row는 안쪽 필드를 스칼라로 바꿔 푼다.

kubectl -o json을 jq로 필터링한다

kubectl에는 -o jsonpath='...' 옵션이 있지만, 조건 필터나 집계로 들어가면 표현이 답답하다. 나는 kubectl은 -o json으로 원본을 통째로 받고 필터링은 jq에 맡긴다. 그쪽이 조건과 집계 모두 유연하다.

재시작 횟수가 있는 컨테이너만 뽑아 파드 이름과 재시작 수를 보는 예시다.

$ kubectl get pods -o json \
  | jq -r '.items[]
      | . as $p
      | .status.containerStatuses[]?
      | select(.restartCount > 0)
      | [$p.metadata.name, .name, .restartCount]
      | @tsv'
api-7d9f     api        3
worker-5c8b  worker     1

. as $p는 현재 파드 객체를 변수에 담아 두는 구문이다. 컨테이너 배열 안으로 내려가면 파드 이름을 잃어버리므로, 미리 변수로 잡아 두고 안쪽에서 $p.metadata.name으로 다시 꺼낸다. containerStatuses[]??는 해당 필드가 없는 파드에서 에러 없이 건너뛰라는 뜻이다.

네임스페이스별 파드 개수처럼 집계가 필요하면 group_by를 쓴다. 배열을 네임스페이스로 묶은 뒤 각 묶음의 길이를 센다.

$ kubectl get pods -A -o json \
  | jq -r '[.items[].metadata.namespace]
      | group_by(.)
      | map({ns: .[0], count: length})
      | .[] | [.ns, .count] | @tsv'
default      12
kube-system  8
monitoring   4

group_by는 입력을 정렬해 묶으므로 결과가 네임스페이스 이름순으로 나온다. 각 묶음은 같은 값들의 배열이라 .[0]으로 대표값을, length로 개수를 얻는다.

AWS CLI 출력을 표로 정리한다

AWS CLI는 기본 출력이 JSON이라 jq와 궁합이 좋다. --query(JMESPath)로도 어느 정도 되지만, 조건이 복잡해지면 jq가 읽기 쉽다. EC2 인스턴스에서 Name 태그와 타입, 상태를 CSV로 뽑는다.

$ aws ec2 describe-instances \
  | jq -r '.Reservations[].Instances[]
      | [ (.Tags[]? | select(.Key=="Name") | .Value),
          .InstanceId, .InstanceType, .State.Name ]
      | @csv'
"web-01","i-0abc123","t3.medium","running"
"web-02","i-0def456","t3.medium","stopped"
"batch-01","i-0ghi789","c6i.large","running"

Tags 배열에서 특정 키만 골라내는 select(.Key=="Name") 패턴은 AWS 응답을 다룰 때 계속 쓴다. Name 태그가 없는 인스턴스는 해당 값이 비어 그 자리가 빈 문자열로 나온다.

인스턴스 타입별 개수를 세려면 타입만 뽑아 group_by로 집계한다.

$ aws ec2 describe-instances \
  | jq -r '[.Reservations[].Instances[].InstanceType]
      | group_by(.) | map({type: .[0], n: length})
      | .[] | [.type, .n] | @tsv'
c6i.large   1
t3.medium   2

CloudTrail 로그를 jq로 뒤진다

CloudTrail은 AWS 계정에서 일어난 API 호출을 기록하는 감사 로그다. 누가 언제 어떤 작업을 했는지 이벤트 단위로 남긴다. aws cloudtrail lookup-events로 받으면 이벤트 상세가 CloudTrailEvent 필드에 JSON 문자열로 박혀 있어, 한 번 더 파싱해야 한다. 이때 fromjson을 쓴다.

$ aws cloudtrail lookup-events --max-results 50 \
  | jq -r '.Events[]
      | .CloudTrailEvent | fromjson
      | [ .eventTime, .eventName,
          (.userIdentity.arn // "-"), (.sourceIPAddress // "-") ]
      | @tsv'
2026-08-13T01:22:10Z  RunInstances     arn:aws:iam::111122223333:user/ops   203.0.113.10
2026-08-13T01:25:41Z  TerminateInstances arn:aws:iam::111122223333:user/ops 203.0.113.10
2026-08-13T02:03:05Z  DeleteBucket     arn:aws:iam::111122223333:role/admin  198.51.100.7

.userIdentity.arn // "-"//는 왼쪽이 null이거나 없으면 오른쪽 기본값을 쓰라는 대체 연산자다. CloudTrail 이벤트는 주체 종류에 따라 필드 구성이 달라 이 방어를 넣지 않으면 중간에 null이 섞여 표가 어긋난다.

어떤 API가 많이 불렸는지, 특히 삭제 계열 이벤트를 추리려면 select로 거른 뒤 group_by로 센다. 이벤트 이름에 DeleteTerminate가 들어간 것만 집계하는 예시다.

$ aws cloudtrail lookup-events --max-results 200 \
  | jq -r '[ .Events[] | .CloudTrailEvent | fromjson
             | select(.eventName | test("Delete|Terminate"))
             | .eventName ]
      | group_by(.) | map({event: .[0], n: length})
      | sort_by(-.n) | .[] | [.event, .n] | @tsv'
DeleteObject       14
TerminateInstances  3
DeleteBucket        1

test("Delete|Terminate")는 정규식 일치를 확인하는 함수로, 이벤트 이름에 두 단어 중 하나라도 들어가면 참이다. sort_by(-.n)으로 개수 내림차순 정렬해 많이 불린 순으로 본다. 실제 건수는 조회 기간과 계정 활동에 따라 다르니 위 숫자는 형태만 참고한다.

파일로 저장하고 파이프라인에 넘긴다

CSV 결과를 파일로 남길 때는 셸 리다이렉션을 그대로 쓴다. 헤더까지 붙여 보고용 파일을 만드는 예시다.

$ aws ec2 describe-instances \
  | jq -r '["name","id","type","state"],
           (.Reservations[].Instances[]
            | [ (.Tags[]? | select(.Key=="Name") | .Value),
                .InstanceId, .InstanceType, .State.Name ])
           | @csv' > instances.csv
$ head -1 instances.csv
"name","id","type","state"

탭 구분으로 뽑으면 cut, sort, awk 같은 기존 유닉스 도구로 이어 붙이기 쉽다. 예를 들어 TSV의 두 번째 열만 다시 뽑을 때다.

$ jq -r '.[] | [.id, .name] | @tsv' users.json | cut -f2
kim
lee
park

정리하면, 조건은 select, 변형은 map, 집계는 group_by로 나눠 생각하고, 표로 뽑을 땐 필드를 [ ]로 감싸 @csv@tsv-r과 함께 넘긴다. 이 조합이면 API 응답부터 감사 로그까지 대부분의 JSON을 콘솔에서 바로 정리할 수 있다.

자주 묻는 질문

@csv에서 'object cannot be csv-formatted, only array' 에러가 나면 어떻게 하나?

이 에러는 @csv에 넘긴 입력이 배열이 아닐 때 난다. 필드를 {id, name} 같은 객체나 스칼라로 넘기면 발생한다. 해결책은 필드를 [.id, .name]처럼 [ ] 배열로 감싸 넘기는 것이다. 스칼라로 바꾸는 게 아니라 배열로 만들어야 풀린다.

'is not valid in a csv row' 에러는 위 에러와 무엇이 다른가?

이 에러는 배열로는 감쌌지만 그 배열 안의 원소가 다시 배열이나 객체 같은 중첩 값일 때 난다. 예를 들어 [.name, .tags]에서 .tags가 배열이면 발생한다. 해결책은 안쪽 필드를 스칼라로 눌러 주는 것으로, 배열이면 join으로 문자열로 합치고 객체면 tojson으로 직렬화한다.

jq 출력에 큰따옴표가 붙는데 어떻게 없애나?

-r(--raw-output) 옵션을 붙인다. -r 없이 문자열을 출력하면 JSON 표기 그대로 바깥에 큰따옴표가 붙는다. @csv나 @tsv, 순수 텍스트를 파일이나 다른 명령으로 넘길 때는 -r을 반드시 붙인다.

kubectl은 -o jsonpath가 있는데 왜 jq를 쓰나?

kubectl -o jsonpath='...'로도 필드 추출은 되지만, 조건 필터나 group_by 같은 집계로 가면 표현이 제한적이다. -o json으로 원본을 받아 jq에 넘기면 select로 조건을 걸고 group_by로 개수를 세는 작업까지 한 번에 처리할 수 있다.

CloudTrail 이벤트 상세를 jq로 어떻게 파싱하나?

aws cloudtrail lookup-events의 응답은 이벤트 상세가 .CloudTrailEvent 필드에 JSON '문자열'로 들어 있다. jq의 fromjson 함수로 그 문자열을 다시 객체로 파싱한 뒤 eventName, userIdentity.arn 같은 필드에 접근한다. 주체마다 필드 구성이 달라 // 대체 연산자로 null 기본값을 넣어 두면 안전하다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.