스크립트 grep 어떤 실행 결과로부터 문자를 파싱

Bash 스크립트에서 REST API 호출을 하는 경우 데이터가 JSON 포맷으로 얻어지는 경우가 많다. 문제는 XML이나 JSON 문서에서 데이터를 얻어오기 위해서는 파싱을 해야한다는 점이다. grep 명령이나 awk 스크립트를 이용해서 처리할 수도 있지만 jq 명령을 이용하면 쉽게 JSON 문서를 다룰 수 있다.

jq 설치

$ sudo yum install jq -y
$ jq --version
jq-1.5

jq는 yum, apt-get, brew 등의 패키지 관리자로 쉽게 설치할 수 있다.

yum install epel-release -y

yum install에서 jq를 찾을 수 없다고 하는 경우라면 epel-release 를 설치해주면 된다.

jq 사용법

jq 명령은 입력으로 받은 JSON 문서에 필터를 적용해서 출력을 얻는 형태로 사용하게 된다.

jq '$filter'

jq 명령의 사용법은 필터 문자열에 대한 사용법이라고 생각해도 된다.

jq에 json 입력하기

jq 명령에 처리할 JSON 문서를 몇 가지 방법으로 입력할 수 있다. 우선 사용자의 입력을 직접 받는 방식이다. 다음 명령을 실행해보자.

$ jq '.'

jq의 필터 부분에 점(.) 값을 넣어줬다. 이 필터는 Identity 필터로 입력받은 값을 그대로 출력하라는 의미를 가지고 있다. 엔터를 치면 아무것도 출력이 안되고 사용자의 입력을 기다린다.

$ jq '.'
{"foo": "bar"}
{
  "foo" : "bar"
}

간단한 JSON 문서인 {"foo": "bar"} 를 입력해보면, 입력받은 내용이 보기 좋게 출력되는 것을 볼 수 있다. jq는 입력받은 JSON 문서를 파싱해서 저장한 다음 필터를 적용한다. 파싱한 내용을 그대로 출력하라는 필터를 줬으니 이런 출력 결과가 나오는 것이다.

두 번째 입력 방법은 파일을 지정하는 것이다.

$ jq '.' foobar.json

jq 명령의 마지막 인자로 파일의 경로를 주면, 파일의 내용을 읽어서 jq 필터를 적용한다.

마지막으로 가장 많이 이용하게 될 파이프 연산을 통한 입력이다. REST API 호출 등 다른 명령의 결과값을 파이프라이닝해서 jq 명령의 입력으로 가져올 수 있다.

$ curl -s "https://localhost/rest_api/v1 | jq '.'

이런식으로 REST API에서 받아온 JSON 데이터를 jq 입력으로 파이프라이닝 할 수 있다.

jq 명령을 이용한 JSON 데이터 정형화

앞서 언급했던 것처럼 jq 명령은 입력받은 JSON 문서를 파싱해서 메모리에 들고 있다가 필터를 적용한 결과를 출력해준다. 입력을 파싱하고 출력하는 과정에서 JSON 문서는 사람이 보기 좋게 정형화된다.

예를 들어 다음 JSON 문서를 생각해보자.

{"a":{"b":"null","c":{"d":"e", "f":["g", "h", "i"]}, "j":"k"}}

한줄로 길게 늘여 쓴 JSON 문서는 공백문자나 라인피드 문자가 없어서 사이즈가 좀 줄겠지만 사람이 보고 이해하기는 매우어렵다. 이 JSON 문서를 jq에 입력해보자.

$ jq '.'
{"a":{"b":"null","c":{"d":"e", "f":["g", "h", "i"]}, "j":"k"}}
{
  "a": {
    "b": "null",
    "c": {
      "d": "e",
      "f": [
        "g",
        "h",
        "i"
      ]
    },
    "j": "k"
  }
}

입력받은 JSON 문서를 보기 좋게 정형화되어 나온다.

jq 명령은 JSON 문서를 입력으로 받아서 하나 이상의 JSON 문서를 출력으로 내보낸다. 즉, 출력되는 문자열도 유효한 JSON 문서라는 것이다.

반대로 json 문서를 하나의 라인으로 만들어야 할 경우도 있다. 이 경우 다음 명령을 사용하면 된다.

$ cat jsonFile | jq -c .

jq 필터 문법

결국 json을 파싱해서 어떤 데이터를 뽑아오고 싶은지, 어떻게 가공하고 싶은지가 제일 중요하다. '.' 필터를 이용해서 json 데이터를 정형화하는 것만으로도 좋지만 필터 문법을 제대로 알고 있으면 Bash 스크립트에서 json 데이터를 자유롭게 처리할 수 있다.

JSON 속성 필터

JSON 문서에서 특정 속성을 따라가기 위해서는 점(.) 문자 뒤에 속성의 이름을 붙여주면 된다.

$ echo '{"foo1": "bar1", "foo2": "bar2"}' | jq '.foo1'
"bar"

만약 속성이름에 기호가 포함되어 있다면 필터가 제대로 동작하지 않을 수 있다.

$ echo '{"foo-key": "bar1", "foo2": "bar2"}' | jq '.foo-key'
jq: error: key/0 is not defined at <top-level>, line 1:
.foo-key     
jq: 1 compile error

".속성이름" 필터는 사실 .["속성이름"]의 축약버전이다. 만약 속성이름에 기호가 포함되어 있다면 축약버전 대신 원래 문법을 사용하면 된다.

$ echo '{"foo-key": "bar1", "foo2": "bar2"}' | jq '.["foo-key"]'
"bar1"

JSON 문서를 재귀적으로 따라내려가면서 탐색하는 것도 가능하다. 바로 다음에 알아볼 파이프라인을 이용해서 다음과 같이 쓸 수 있다.

$ cat test.json | jq ' .. | .age? '

'..' 필터는 재귀적으로 json 엘리먼트들을 탐색해 내려가겠다는 의미다. 여기에 .age를 주면 age 라는 이름의 속성 값들을 모두 출력하겠다는 의미다. '?' 문자를 뒤에 붙이면 null 값인 경우 출력하지 않겠다는 의미다.

필터 파이프 라인

jq 필터 문법에도 Bash처럼 파이프 연산이 있다. Bash에서처럼 필터의 적용 결과에 다음 필터를 순차적으로 적용하겠다는 의미다.

$ cat test.json | jq ' filter1 | filter2 '

test.json에 저장되어 있는 json에 filter1을 우선 적용하고, 그 결과에 filter2를 또 한번 적용하는 의미다.

스크립트 grep 어떤 실행 결과로부터 문자를 파싱

그림으로 그려보면 이런 개념이된다. 파이프 연산을 이용하 여러 필터를 연결해서 표현하면 복잡한 json 처리도 이해하기 쉽게 표현할 수 있다.

필터의 파이프 라이닝을 이해하기 위해 {"a": {"b": {"c": "d"}}} 라는 json 문서를 처리해보겠다. 직전에 봤던 속성필터를 이용해서 순차적으로 값을 따라내려가보자.

$ echo '{"a": {"b": {"c": "d"}}}' | jq '.a'
{
  "b": {
    "c": "d"
  }
}

$ echo '{"a": {"b": {"c": "d"}}}' | jq '.a | .b'
{
  "c": "d"
}

$ echo '{"a": {"b": {"c": "d"}}}' | jq '.a | .b | .c' 
"d"

.a | .b | .c 라는 필터는 우선 '.a' 로 속성을 가져온 다음 그 결과에 '.b'를 적용, 그 결과에 다시 '.c'를 적용하는 필터다. 속성필터의 경우 파이프라인을 축약해서 다음과 같이 사용할 수도 있다.

$ echo '{"a": {"b": {"c": "d"}}}' | jq '.a.b.c'
"d"

배열 접근 - 인덱스

json 문서에는 객체뿐만 아니라 배열도 존재한다. 예를들어

["a", "b", "c", "d"]

이런 배열이 있을 때, 3번째에 해당하는 값을 가져오기 위해서는 다음과 같이 필터를 사용하면 된다.

$ echo '["a", "b", "c", "d"]' | jq '.[2]'
"c"

배열의 인덱스는 0부터 시작한다는 점을 잊지 말자.

배열이 속성으로 존재하는 경우에는 파이프라인을 이용해서 가져 올 수 있다.

$ echo '{"data":["a", "b", "c"]}' | jq '.data | .[1]'
"b"

혹은 축약해서 사용할 수도 있다.

$ echo '{"data":["a", "b", "c"]}' | jq '.data[1]'
"b"

배열의 접근 인덱스는 음수도 가능하다.

$ echo '{"data":["a", "b", "c"]}' | jq '.data[-1]'
"c"

배열 인덱스를 음수로 주게 되면 배열의 뒤쪽에서부터 몇 번째인지 접근하게 된다.

배열에 대한 슬라이싱도 가능하다.

echo '{"data":["a", "b", "c"]}' | jq -c '.data[0:1]'
["a"]

마치 파이썬에서 배열 슬라이싱을 하는 것처럼 배열의 일부를 취할 수 있다.

참고로 같은 문법을 문자열에 사용하면 문자열의 일부를 잘라오는 식으로 사용된다.

$ echo '{"foo1": "bar1", "foo2": "bar2"}' | jq '.foo1[0:3]'
"bar"

배열 접근 - Iterator

json 배열의 각 엘리먼트들에 대한 순차적인 접근을 하고 싶다면

$ echo '["a", "b", "c", "d"]' | jq '.[]'
"a"
"b"
"c"
"d"

대괄호 안쪽에 인덱스를 생략하면 된다. 이렇게 주면 마치 for 문을 이용한 것처럼 배열의 각 엘리먼트들을 순회하게 된다. '[]' 연산이 출력하는 각각은 유효한 JSON 객체이다. 이 뒤로 파이프라인을 연결해서 배열의 각 엘리먼트에 필터를 적용해볼 수도 있다.

$ echo '[{"name": "john"}, {"name": "merry", "age": 24}]' | jq -c '.[] | .name'
"john"
"merry"

'[]' 필터는 객체의 값들을 반복하는데에도 사용된다.

$ echo '{"name": "merry", "age": 24}' | jq '.[]'
"merry"
24

배열 생성

반대로 여러개의 출력 값을 하나로 모아 배열을 생성하는 것도 가능하다.

$ echo '[{"name": "kim"}, {"name": "lee", "age": 24}]' | jq -c '.[] | [.name]'
["kim"]
["lee"]

[ ] 안쪽에 배열의 값으로 넣고 싶은 항목들을 명시하면 된다.

각각 별도의 배열이 아닌 하나의 배열 안쪽으로 묶고 싶다면 다음과 같이 필터를 작성하면 된다

$ echo '[{"name": "kim"}, {"name": "lee", "age": 24}]' | jq -c '[.[] | .name]'
["kim","lee"]

'[' 문자와 ']' 문자 사이에 배열로 만들고 싶은 값을 뽑는 필터를 써넣으면 된다.

객체 생성

출력되는 결과를 묶어서 json 객체로 만들 수도 있다. 배열을 만들고 싶었을 때 대괄호를 사용했던 것처럼 객체를 만들고 싶을 때는 중괄호로 묶어주면 된다.

$ echo '{"name": "lee", "friends": ["tom","jackson"]}' | jq -c '{"name": .name}'
{"name":"lee"}

원본 데이터의 키 값을 그대로 재사용하는 경우에는 축약해서 쓸 수 있다.

$ echo '{"name": "lee", "friends": ["tom","jackson"]}' | jq -c '{name}'
{"name":"lee"}

원본 데이터의 값을 결과의 키로 사용할 수도 있다.

$ echo '{"name": "lee", "friends": ["tom","jackson"]}' | jq -c '{(.name): .friends}'
{"merry":["tom","jackson"]}

원본의 값을 키로 사용할 경우에는 소괄호를 이용해서 키로 사용할 부분을 감싸줘야 한다.

쉼표 연산자

쉼표 연산자를 이용하면 하나의 입력에서 여러개의 출력으로 결과를 뽑아낼 수 있다.

$ echo '{"name": "lee", "age": 24}' | jq -c '.name , .age , .'
"lee"
24
{"name": "lee", "age": 24}

입력 json에서 .name에 해당하는 json 문서와 .age에 해당하는 문서, 원본 문서 전체에 해당하는 문서까지 총 3가지 json 문서를 결과로 뽑아냈다.

괄호 연산자

괄호를 이용해서 우선순위를 적용해볼 수 있다.

$ echo '{"name": "lee", "friends": ["tom","jackson"]}'  | jq -c '.name , .friends | .[0]'
jq: error (at <stdin>:1): Cannot index string with number

이런식으로 사용할 경우 friends 속성은 배열이기 때문에 .[0] 필터를 적용할 수 있다. 하지만 .name 속성은 배열이 아니기 때문에 .[0] 필터를 적용할 수 없다. 따라서 .friends 속성에만 .[0] 필터가 적용되도록 괄호를 통해서 우선순위를 조정해준다.

$ echo '{"name": "lee", "friends": ["tom","jackson"]}' | jq -c '.name , (.friends | .[0])'
"lee"
"tom"

Built-in Operations

jq 에서는 값들을 효과적으로 처리하기 위해 여러가지 빌트인 연산들을 제공한다.

'+' 연산

더하기 연산은 두 개의 필터에 대해 출력되는 결과 값을 더하는 연산이다. 더한다는 의미는 데이터 타입에 따라 다르다. 숫자 데이터의 경우 두 숫자의 값을 사칙연산 중 덧셈 연산으로 더하겠다는 의미다. 문자열 데이터에 대해서는 두 문자열을 concat 하겠다는 의미이며, 배열의 경우 두 배열을 합치는 동작, 객체는 두 객체를 병합하는 동작이 실행된다. (키 값이 같다면 오른쪽에 있는 키가 덮어쓰여진다.)

'-' 연산

빼기 연산은 두 개의 필터에 대해 출력되는 결과 값을 빼는 연산이다. 마찬가지로 데이터 타입에 따라 의미가 다르다. 숫자는 사칙연산중 뺄셈 연산을 적용하게 되며, 배열에 대해서는 엘리먼트를 제거하는 등의 동작이 수행된다.

'*' 연산

곱하기 연산은 숫자에 대해 사칙연산 중 곱셈을 적용하고, 문자열은 곱하기 숫자만큼 문자열을 여러번 붙여넣는 동작이 적용된다. 객체에 대한 곱셈은 재귀적인 병합을 의미한다.

'/' 연산

나누기 연산은 숫자에 대해 나눗셈이 적용되고, 문자열에 대해서는 split 연산으로 사용된다.

length

문자열은 문자열의 길이, 배열은 배열의 엘리먼트 개수, 객체의 경우 프로퍼티 개수를 출력한다.

keys, keys_unsorted

keys는 객체가 주어졌을 때 객체의 속성들을 배열로 리턴한다. keys_unsorted는 키들을 정렬하지 않는 옵션이다.

만약 배열에 대해 적용한다면 배열의 인덱스들이 리턴된다.

has(key)

객체에 인자로 받은 key 값이 존재하는지 여부를 true 혹은 false 값으로 리턴한다. 배열의 경우 숫자에 해당하는 인덱스가 사용가능한지 (인덱스에 해당하는 값이 있는지)를 체크한다.

map(filter), map_values(filter)

map(filter)는 주어진 배열의 각 항목에 대해 필터를 적용하고 그 결과를 배열로 만들어 출력한다. map_values(filter)는 배열이 아닌 객체의 값에 대해서 동작한다.

select(boolean_exp)

조건에 맞는 항목만 출력한다

이 밖에도 많은 빌트인 연산들이 제공된다. 정확한 사용법은 jq 매뉴얼을 참고하도록 하자

jq 옵션

-c : 출력 결과를 한 줄에 모아서 출력

-n : 입력을 받지 않음 (null 을 입력으로 받음)

-r : 출력 결과가 쌍따옴표로 감싸진 형태로 출력하는데, 이 쌍따옴표를 제거함 (객체나 배열은 그대로 JSON 형태로 출력됨)

-s : 다수의 JSON 입력을 받았을 때 이를 배열로 연결해서 하나의 입력으로 처리함

—indent : 인덴트를 지정. 기본값은 2고 최대 8