Go에서 아주 큰 JSON 파일을 핸들링하기

최근에 시덥지 않은 문제로 구글링하다가 단순하게 해결한 일이 있어서 짧게 글로 공유 해볼까 합니다. 복잡한 문제로 생각해서 오히려 가까이에 있는 답을 놓치고 말았었네요.

어떤 문제가?

만들던 서비스에서 큰 JSON 파일을 처리해야할 경우가 생겼습니다. json.Unmarshal 후 루프를 돌아서 모든 키 값에 대한 처리를 진행하는 로직인데, 아래 같은 느낌으로 코드를 작성하고 돌렸더니 엄청나게 긴 처리시간과 함께 CPU가 치솟는 상황을 맞이했죠.

func something(data string) (somethingObject, error) {
	var obj somethingObject
	json.Unmarshal([]byte(data), &obj)

	for k,v := range obj {
		// BlahBlah...
	}
	return obj, nil
}

추측과 삽질

이 상황을 보면서 가장 먼저 생각이 든것은 Unmarshal에서 오래걸릴거란 의심을 했었습니다. 제가 보통은 golang의 기본 json 라이브러리를 사용하지만, 많은 사람들은 gjson 등 좀 더 빠르고 편리하다는 라이브러리를 쓰는 경우가 많았고 당연히 이런 문제로 인해서 쓴단 생각이 들었었네요.

그래서 비슷하게 gjson으로 구현해봤었죠.

func something(data string) error {
	gjson.Parse(string(data)).ForEach(func(key, value gjson.Result) bool {
 		if check(key.String()) {
		 	for _, vv := range value.Array() {
		 		// BlahBlah... vv.String()
	 		}
 		}
 		return true
 	})
}

그러나 처리 시간은 크게 차이가 나지 않았죠. 이외에도 여러가지 내용으로 구글링해봤지만, 대다수는 gjson과 같은 라이브러리를 써보란 이야기가 압도적이였습니다.

추측말고 팩트가 필요해!

앞선 문제와 해결 방법은 실제로 하나하나 테스트한 결과가 아닌 저의 추측을 기반으로 구글링 했었는데 문뜩 내가 뭐하고 있는건가 이런 생각이 들었습니다. 아무 생각없이 원인 파악과 해결을 찾던게 아닌 구글링부터 좀 부끄러워 졌던 순간이였네요.

그래서 디버깅하면서 찾아보기로 합니다. 저는 보통 디버거를 이용한 디버깅보단 Test code를 이용한 방법을 많이 사용합니다. (쉽게 특정 구간에 개입할 수 있어서) 그래서 해당 코드의 테스트 코드를 일부러 에러를 유도하여 딜레이 구간을 찾는 방법을 택했습니다.

import (
  "reflect"
  "testing"
)

func Testsomething(data string) (somethingObject, error) {
  type args struct {
    data string
  }
  tests := []struct {
    name     string
    args     args
    want     string
    wantBool bool
  }{
    {
      name: "test1",
      args: args {
        data: "blahblah (big json text)"
      },
      wantBool: false,
    },
  }
  /// ...snip...
}

원인과 해결

여러번 테스트해보니 결국 원인은 for loop 였네요. 단일 로직으로 대량의 for를 처리할 때 느린건 당연한 건데, 생각도 안하고 있었어요. 그래서 고루틴+고채널로 동시성 처리로 해결하면 간단할 것 같았고, 동시성 모델은 제가 자주 사용하는 형태(worker goroutine을 여러개 두고, 서로 job에 접근하면서 일을 처리하는 형태)로 처리 하기로 했습니다.

func something(t *testing.T) {
	var obj somethingObject
	var wg sync.WaitGroup
	concurrency := 500
	tasks := make(chan string)
	rch := make(chan string)

	json.Unmarshal([]byte(data), &obj)

	go func(ch <-chan string) {
		for {
			v := <-ch
			result = result + v + "\n"
		}
	}(rch)

	for i := 0; i < concurrency; i++ {
		wg.Add(1)
		go func() {
			for v := range tasks {
				for _, vv := range obj[v] {
					rch <- vv
				}
			}
			wg.Done()
		}()
	}
	for chanKey, _ := range response {
		tasks <- chanKey
	}

	close(tasks)
	wg.Wait()
	close(rch)
	return nil
}

결국 한시간이 지나도 끝나지 않던 작업이 3초만에 완료 🤩

Conclusion

제가 느낀건 크게 2가지가 있네요.

  • json.Unmarshal 은 느리지 않다. 뒤에 로직에 문제가 있을 가능성이 크다.
  • 추측보단 팩트를 기반으로 해결하자. (제발…)