API를 사용하게 되면 많이 다루게 되는 데이터 형식 중 하나가 바로 json이다. 개발중인 서비스에서도 연계된 타 서비스의 데이터를 API로 받아오게 되는데, 대용량일 시 json 문자열을 Entity 객체로 파싱하며 성능 이슈가 있는 듯하여 현재 사용중인 라이브러리와 그 외에 대중적으로 사용되는 라이브러리의 성능 차이를 검토하게 되었다.
고코더님이 작성한 포스트에서 대용량에서는 Jackson이 저용량에서는 GSON이 가장 성능이 좋았다는 평이 있다. 현 프로젝트에서는 GSON을 사용하고 있는데, 우리는 큰 파일과 작은 파일을 모두 다루고 있어서 라이브러리 선택이 잘못됐다는 것을 깨달았다. 그냥 JSON-SIMPLE로 변경할까 하다가 각 라이브러리 별 사용법도 익힐 겸 성능 판단도 해볼 겸 직접 테스트 코드 작성을 궈궈했다. 다만, 담당하는 업무가 API로 받아 DB 처리를 하는 것이다 보니 Json --> Java Object
변환 코드만 테스트해봤다..ㅎ
1. 라이브러리 추가(Maven)
<!-- 실제 프로젝트에서는 사용할 라이브러리만 추가 -->
<!-- GSON -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.9.0</version>
</dependency>
<!-- JSON-SIMPLE -->
<dependency>
<groupId>com.googlecode.json-simple</groupId>
<artifactId>json-simple</artifactId>
<version>1.1.1</version>
</dependency>
<!-- Jackson -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.15.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.1</version>
</dependency>
2. 테스트를 위해 공통으로 사용할 JsonParser 인터페이스 생성
import java.util.*;
public interface JsonParserComp<T> {
List<T> parseJsonToObject(String json, Class<T> type) throws Exception;
List<Map<String, Object>> parseJsonToListMap(String json) throws Exception;
// 테스트용.. 귀찮아서 그냥 인터페이스에 넣음
default double getElapsedSec(long startTime) {
long thisTime = System.currentTimeMillis();
return (thisTime - startTime)/1000D;
}
}
제너릭 타입으로 반환하게 한 이유는 호출하는 쪽에서 원하는 Entity 객체를 지정하도록 하고 최대한 중복 코드를 줄이기 위함이다. 만약 특정 객체 지정하고 싶다면 parseJsonToListMap
메소드 처럼 제너릭 타입을 지우고 해당 객체로 반환하게 하고, Class<T> type
매개변수는 제거해주면 된다.
1. Empty String을 Null로 처리하기 위한 TypeAdapter 작성
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
public class StringTypeAdapter extends TypeAdapter<String> {
@Override
public void write(JsonWriter jsonWriter, String s) throws IOException {
if (s == null) {
jsonWriter.nullValue();
return;
}
jsonWriter.value(s);
}
@Override
public String read(JsonReader jsonReader) throws IOException {
if (jsonReader.peek() == JsonToken.NULL) {
jsonReader.nextNull();
return null;
}
String stringValue = jsonReader.nextString();
return stringValue != null && !stringValue.isEmpty() ? stringValue : null;
}
}
Long, Integer 등 다른 자료형 처리 방법은 Gson 관련 포스팅 참고
2. 원하는 Gson 객체 생성하는 Util 코드 작성
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.TypeAdapter;
import com.spb.practice.springbootpractice.utils.typeadapter.*;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
public class GsonUtils {
public static Gson buildTypeAdapterGson(Map<Type, TypeAdapter<?>> adapters){
GsonBuilder builder = new GsonBuilder();
for(Map.Entry<Type, TypeAdapter<?>> entry : adapters.entrySet()){
builder.registerTypeAdapter(entry.getKey(), entry.getValue());
}
return builder.create();
}
public static Gson buildNullableGson(){
Map<Type, TypeAdapter<?>> adapterMap = new HashMap<>();
adapterMap.put(Integer.class, new IntegerTypeAdapter());
adapterMap.put(String.class, new StringTypeAdapter());
adapterMap.put(BigDecimal.class, new BigDecimalTypeAdapter());
adapterMap.put(Long.class, new LongTypeAdapter());
return GsonUtils.buildTypeAdapterGson(adapterMap);
}
}
3. Gson Parser 구현체 작성
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.spb.practice.springbootpractice.utils.GsonUtils;
import java.util.*;
public class GsonParserImpl<T> implements JsonParserComp<T> {
@Override
public List<T> parseJsonToObject(String json, Class<T> type) {
long startTime = System.currentTimeMillis();
Gson gson = GsonUtils.buildNullableGson();
List<T> data = gson.fromJson(json, TypeToken.getParameterized(List.class, type).getType());
System.out.println("=== GSON_PARSER \t\tData Count: " + data.size() + ", 소요시간(s): " + this.getElapsedSec(startTime));
return data;
}
@Override
public List<Map<String, Object>> parseJsonToListMap(String json) throws Exception {
long startTime = System.currentTimeMillis();
Gson gson = new Gson();
List<Map<String, Object>> data = gson.fromJson(json,TypeToken.getParameterized(List.class, Map.class).getType());
System.out.println("=== GSON_PARSER \t\tData Count: " + data.size() + ", 소요시간(s): " + this.getElapsedSec(startTime));
return data;
}
}
fromJson
호출할 때, 원하는 반환 타입을 지정할 수 있다. parseJsonToListMap
에서는 딱히 Empty String은 처리하지 않도록 TypeAdapter 설정은 빼주었다.
1. Empty String을 Null로 처리하기 위한 Deserializer 작성
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
// String Null 처리
public class NullStringDeserializer extends JsonDeserializer<String> {
@Override
public String deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
JsonNode node = jsonParser.readValueAsTree();
if (node.asText().isEmpty()) {
return null;
}
return node.textValue();
}
}
// Long Null 처리
public class NullLongDeserializer extends JsonDeserializer<Long> {
@Override
public Long deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
JsonNode node = jsonParser.readValueAsTree();
if (node.asText().isEmpty()) {
return null;
}
return Long.valueOf(node.longValue());
}
}
// BigDecimal Null 처리
import java.math.BigDecimal;
public class NullBigDecimalDeserializer extends JsonDeserializer<BigDecimal> {
@Override
public BigDecimal deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
JsonNode node = jsonParser.readValueAsTree();
if (node.asText().isEmpty()) {
return null;
}
return node.decimalValue();
}
}
Jackson에서 json을 파싱할 때 원하는대로 처리할 수 있도록 JsonDeserializer<T>
를 상속받아 커스텀한다.
다만, json파일을 데스크탑에서 로드해서 파싱하는 테스트 코드에서는 별다른 문제가 없었는데 실 프로젝트에 적용 시 문제가 생겼었다. String을 제외한 자료형의 경우 기본값만 반환되는 문제 가 발생했다. 디버깅해보니 node 객체에 문자열을 제외한 값은 모두 선언이 안된 것으로 확인되어 해당 문제는 그냥 node.textValue()
로 문자열로 값을 받아와 형변환하도록 수정하니 해결됐다.
//예시
@Override
public Long deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
JsonNode node = jsonParser.readValueAsTree();
if (node.asText().isEmpty()) {
return null;
}
return Long.valueOf(node.textValue()); //longValue() 대신 문자열로 받아와서 형변환
}
2. Jackson Parser 구현체 작성
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.spb.practice.springbootpractice.utils.deserializer.*;
import java.math.BigDecimal;
import java.util.*;
public class JacksonParserImpl<T> implements JsonParserComp<T> {
@Override
public List<T> parseJsonToObject(String json, Class<T> type) throws JsonProcessingException {
long startTime = System.currentTimeMillis();
SimpleModule module = new SimpleModule();
module.addDeserializer(String.class, new NullStringDeserializer());
module.addDeserializer(Integer.class, new NullIntegerDeserializer());
module.addDeserializer(Long.class, new NullLongDeserializer());
module.addDeserializer(BigDecimal.class, new NullBigDecimalDeserializer());
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(module);
List<T> data = mapper.readValue(json, mapper.getTypeFactory().constructCollectionType(List.class, type));
System.out.println("=== JACKSON_PARSER \t\tData Count: " + data.size() + ", 소요시간(s): " + this.getElapsedSec(startTime));
return data;
}
@Override
public List<Map<String, Object>> parseJsonToListMap(String json) throws Exception {
long startTime = System.currentTimeMillis();
ObjectMapper mapper = new ObjectMapper();
List<Map<String, Object>> data = mapper.readValue(json, mapper.getTypeFactory().constructCollectionType(List.class, Map.class));
System.out.println("=== JACKSON_PARSER \t\tData Count: " + data.size() + ", 소요시간(s): " + this.getElapsedSec(startTime));
return data;
}
}
Gson처럼 ObjectMapper
에 자료형 별 적용할 커스텀 Deserializer를 설정해주고, readValue
를 호출할 때 원하는 반환 타입을 지정해주면 완료!
1. JSON-SIMPLE 파싱 Util 코드 작성
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class JsonSimpleUtils {
private final static JSONParser JSON_PARSER = new JSONParser();
public static Map jsonToMap (String jsonData) throws ParseException {
return (JSONObject) JSON_PARSER.parse(jsonData);
}
public static List<Map<String, Object>> jsonArrayToList(String jsonData) throws ParseException {
List<Map<String, Object>> list = new ArrayList<>();
JSONArray jsonArray = (JSONArray) JSON_PARSER.parse(jsonData);
for(Object item : jsonArray) {
list.add((JSONObject)item);
}
return list;
}
public static <T> List<T> jsonArrayToList(String jsonData, Class<T> targetClass) throws Exception {
List<T> list = new ArrayList<>();
JSONArray jsonArray = (JSONArray) JSON_PARSER.parse(jsonData);
for(Object item : jsonArray) {
list.add(jsonObjectToObj((JSONObject)item, targetClass));
}
return list;
}
public static <T> T jsonObjectToObj(String jsonData, Class<T> targetClass) throws Exception {
return jsonObjectToObj((JSONObject) JSON_PARSER.parse(jsonData), targetClass);
}
public static <T> T jsonObjectToObj(JSONObject jsonData, Class<T> targetClass) throws Exception {
Field[] fieldArr = targetClass.getDeclaredFields();
T data = targetClass.getConstructor().newInstance();
for(Field field : fieldArr) {
if(jsonData.containsKey(field.getName())) {
field.setAccessible(true);
field.set(data, castString(field.getType(), (String) jsonData.get(field.getName())));
}
}
return data;
}
public static <T> T castString (Class<?> clazz, String string) {
T value = null;
if(!"".equals(string)) {
if (clazz.isAssignableFrom(Integer.class)) {
value = (T) Integer.valueOf(string);
} else if (clazz.isAssignableFrom(Boolean.class)) {
value = (T) Boolean.valueOf(string);
} else if (clazz.isAssignableFrom(Double.class)) {
value = (T) Double.valueOf(string);
} else if (clazz.isAssignableFrom(Long.class)) {
value = (T) Long.valueOf(string);
} else if (clazz.isAssignableFrom(BigDecimal.class)) {
value = (T) new BigDecimal(string);
} else {
value = (T) string;
}
}
return value;
}
}
JSON-SIMPLE 예제를 작성하면서 그냥 Jackson 쓸까 매우 고민이 됐다...ㅋㅋㅋ 간단한 라이브러리라 그런가 객체로 바로 파싱하는 기능이 없는 것 같았다. 몇 시간 구글링을 해도 나오지 않아서 그냥 직접 반환 타입을 받아서 reflect로 해당 객체에 선언된 모든(private 단위까지) 필드 가져와서 해당 필드의 이름으로 key값이 있으면 해당 키값으로 저장된 문자열 값을 필드의 자료형으로 형변환해서 대입하도록 했다. (jsonObjectToObj
, castString
메소드)
얼마나 귀찮던지... 이렇게 처리한 코드를 본 적은 없고 그냥 오기로 만든거라 성능은 저나라로... 안전성도 저나라로...일단 작동은 한다..ㅎ
2. JSON-SIMPLE 구현체 작성
import com.spb.practice.springbootpractice.utils.JsonSimpleUtils;
import java.util.*;
public class JsonSimpleParserImpl<T> implements JsonParserComp<T> {
private final static JsonSimpleUtils JSON_SIMPLE_UTILS = new JsonSimpleUtils();
@Override
public List<T> parseJsonToObject(String json, Class<T> type) throws Exception {
long startTime = System.currentTimeMillis();
List<T> data = JsonSimpleUtils.jsonArrayToList(json, type);
System.out.println("=== JSON_SIMPLE_PARSER \t\tData Count: " + data.size() + ", 소요시간(s): " + this.getElapsedSec(startTime));
return data;
}
@Override
public List<Map<String, Object>> parseJsonToListMap(String json) throws Exception {
long startTime = System.currentTimeMillis();
List<Map<String, Object>> data = JsonSimpleUtils.jsonArrayToList(json);
System.out.println("=== JSON_SIMPLE_PARSER \t\tData Count: " + data.size() + ", 소요시간(s): " + this.getElapsedSec(startTime));
return data;
}
}
Util에서 고생한 만큼 구현체는 간단~
테스트 코드는 실제 업무에서 사용하는 데이터로 테스트하였다. 테스트할 데이터에 맞는 Entity 객체를 생성해주고 아래처럼 테스트 코드를 작성했다.
import java.util.List;
import java.util.Map;
@TestComponent
public class TestJsonParser {
private JsonParserComp _gsonParser;
private JsonParserComp _jacksonParser;
private JsonParserComp _jsonSimpleParser;
@Test
void convertSmallJson() throws Exception {
String jsonData = CommonUtils.loadFile(JSON_DIR_PATY + DSR_FILE_NM);
Class<DsrData> type = DsrData.class;
_gsonParser = new GsonParserImpl();
List<FareData> gsonData = _gsonParser.parseJsonToObject(jsonData, type);
_jacksonParser = new JacksonParserImpl();
List<FareData> jacksonData = _jacksonParser.parseJsonToObject(jsonData, type);
_jsonSimpleParser = new JsonSimpleParserImpl();
List<FareData> jsonSimple = _jsonSimpleParser.parseJsonToObject(jsonData, type);
System.out.println("=========================================================================");
}
@Test
void convertMiddleJson() throws Exception {
String jsonData = CommonUtils.loadFile(JSON_DIR_PATY + FARE_FILE_NM);
Class<FareData> type = FareData.class;
_gsonParser = new GsonParserImpl();
List<FareData> gsonData = _gsonParser.parseJsonToObject(jsonData, type);
_jacksonParser = new JacksonParserImpl();
List<FareData> jacksonData = _jacksonParser.parseJsonToObject(jsonData, type);
_jsonSimpleParser = new JsonSimpleParserImpl();
List<FareData> jsonSimple = _jsonSimpleParser.parseJsonToObject(jsonData, type);
System.out.println("=========================================================================");
}
@Test
void convertBigJson() throws Exception {
String jsonData = CommonUtils.loadFile(JSON_DIR_PATY + FARE_MONTH_FILE_NM);
Class<FareData> type = FareData.class;
_gsonParser = new GsonParserImpl();
List<FareData> gsonData = _gsonParser.parseJsonToObject(jsonData, type);
_jacksonParser = new JacksonParserImpl();
List<FareData> jacksonData = _jacksonParser.parseJsonToObject(jsonData, type);
_jsonSimpleParser = new JsonSimpleParserImpl();
List<FareData> jsonSimple = _jsonSimpleParser.parseJsonToObject(jsonData, type);
System.out.println("=========================================================================");
}
}
실행결과
객체로 직접 변환해서 그런가... 아니면 기본적으로 잡은 데이터 양이 많아서 그런가... 고코더님이 작성한 포스팅과 다르게 Jackson 라이브러리가 가장 성능이 좋았다. 특히 10만건 가까이 되는 데이터에 대해서는 Jackson이 현 사용중인 Gson보다 압도적으로 성능이 좋았다. 그리고 300건이 데이터가 적다고 생각했는데... 이 마저도 JSON-SIMPLE이 Gson보다 성능이 좋다.
그래서, 간단한 데이터로 List<Map<String,Object>>
변환 테스트도 해봤다.
테스트 데이터
public String getSampleData(){
return "[\n" +
" {\n" +
" \"id\": \"\",\n" +
" \"name\": \"길동스0\",\n" +
" \"phone\": \"\",\n" +
" \"address\": \"\",\n" +
" \"email\": null\n" +
" },\n" +
" {\n" +
" \"id\": \"\",\n" +
" \"name\": \"길동스1\",\n" +
" \"phone\": \"\",\n" +
" \"address\": \"\",\n" +
" \"email\": null\n" +
" },\n" +
" {\n" +
" \"id\": \"\",\n" +
" \"name\": \"길동스2\",\n" +
" \"phone\": \"\",\n" +
" \"address\": \"\",\n" +
" \"email\": null\n" +
" },\n" +
" {\n" +
" \"id\": \"\",\n" +
" \"name\": \"길동스3\",\n" +
" \"phone\": \"\",\n" +
" \"address\": \"\",\n" +
" \"email\": \"\"\n" +
" },\n" +
" {\n" +
" \"id\": \"\",\n" +
" \"name\": \"길동스4\",\n" +
" \"phone\": \"\",\n" +
" \"address\": \"\",\n" +
" \"email\": null\n" +
" }\n" +
"]";
}
테스트 코드
@Test
void convertSampleJson() throws Exception {
String jsonData = _jsonParserService.getSampleData();
_gsonParser = new GsonParserImpl();
List<Map<String, Object>> gsonData = _gsonParser.parseJsonToListMap(jsonData);
_jacksonParser = new JacksonParserImpl();
List<Map<String, Object>> jacksonData = _jacksonParser.parseJsonToListMap(jsonData);
_jsonSimpleParser = new JsonSimpleParserImpl();
List<Map<String, Object>> jsonSimple = _jsonSimpleParser.parseJsonToListMap(jsonData);
System.out.println("=========================================================================");
}
실행결과
단순 데이터는 JSON-SIMPLE 압승!
GSON... 쓰는 이유가 있을 것 같은데... 내가 놓치는게 있는 건지 아니면 JSON-SIMPLE에 비해 코드 작성이 편해서 쓰는 건지 급 궁금증이 든다. 찾아보니 간편성과 역직렬화 시 Java Entity에 접근할 필요가 없다는게 장점이라고 하는데... 간단한 데이터만 주고 받으면 간단한 코드 작성을 위해 사용할 순 있을 것 같다.
실제 업무 환경으로 테스트 시, 결과는 아래와 같다.
Jackson
> JSON-SIMPLE
> Gson
JSON-SIMPLE
> Gson
> Jackson
이에 각자 환경에 맞는 라이브러리를 채택해서 사용하면 될 듯하다. 우리는 하루 데이터 처리가 천 건이 넘어가는 Batch에선 Jacskon을 사용하도록 수정했다. 기존 프론트와 API 통신하는 쪽은 여전히 Gson으로 되어 있다.