[Gson] Gson.fromJson(Object) Empty String / TypeCasting 처리하기

DYKO·2023년 1월 19일
0

Coding Note

목록 보기
2/4

코딩하게 된 배경과 삽질 스토리 TMI~

업무를 하다가 API에서 받아온 데이터를 그대로 DB 테이블에 Merge해야하는 케이스가 생겼다. 처음에는 적재해야 하는 Entity의 모든 필드를 String 타입으로 선언하고, Gson을 이용해 json 문자열을 바로 List<Entity>로 변환해주는 것 까지 완료했다. 이때까지 단위테스트 결과는 전혀 문제가 없어서 순조롭게 진행되는 구나 했는데, 아뿔싸. mybatis로 쿼리를 실행하니 빈 문자열("")로 들어온 필드의 DB 자료형이 decimal이거나 double일 때 형식이 맞지 않아 에러가 발생했다.
그래서 Entity에서 DB의 자료형에 맞게 선언을 바꿔줬더니 이번엔 Gson에서 List<Entity>로 변환하면서 NumberFormatException이 발생한다. 열심히 AttributeConverter<?, ?>를 구현하여 JPA @Converter 를 지정해줬는데도 제대로 작동하지 않았다. 회사 동료에게 기존에 이런 경우 어떻게 처리하는지 물었더니 JsonArray를 그냥 반복문을 돌려 처리하고 있다는 답변을 받았다. OMG... 그 방법은 최후의 방법으로 남겨두고... 이틀 간 열심히 구글링하며 삽질을 하던 중 Gson으로 Serialization/Deserialization 시 사용자 맞춤으로 Type을 핸들링 할 수 있는 방법이 있다는 걸 찾았다. 바로 TypeAdapter<?> 다.


💡 일반적인 fromJson() 구현 로직

import com.google.gson.*;
import ...

@Service
public class SampleService {
	private static final Gson gson = new Gson();
    
    public List<Entity> getEntityList(String jsonStr) {
    	return gson.fromJson(jsonStr, new TypeToken<List<SampleEntity>>(){}.getType());
    }
}

////////////////////////////////

@Data
@Setter(AccessLevel.None)
public class Entity {
	private String idx;
    private String name;
    private String mathScore;
    private String englishScore;
    private String avr;
}

위와 같이 구현하게 되면 idx, mathScore, englishScore에 빈 문자열("")이 들어오게 되면 그대로 해당 값이 null 이 아닌 "" 값을 갖게 된다. 만약 나처럼 한 번에 변환하지 않고 데이터 처리가 필요하다면 그냥 위와 같이 String으로 받아 후처리를 해주면 된다. 하지만... 실제 업무에서 받아오는 데이터의 필드의 갯수가 거의 180개에 달하기 때문에 너무 귀찮는 나는 최대한 다른 방법을 찾았다.


💡 GsonBuilder 활용하여 TypeAdaper 옵션 설정하기

Step 1. Custom TypeAdapter 구현

GsonBuilder를 사용하게 되면 몇 가지 옵션을 추가하여 Gson 객체를 생성할 수 있다. 먼저, TypeAdapater 옵션을 설정하기 위해 원하는 형변환을 해주는 CustomTypeAdapter를 구현해야 한다.

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 DoubleTypeAdapter extends TypeAdapter<Double> {
    @Override
    public void write(JsonWriter jsonWriter, Double aDouble) throws IOException {
        if (aDouble == null) {
            jsonWriter.nullValue();
            return;
        }
        jsonWriter.value(aDouble);
    }

    @Override
    public Double read(JsonReader jsonReader) throws IOException {
        if (jsonReader.peek() == JsonToken.NULL) {
            jsonReader.nextNull();
            return null;
        }
        String stringValue = jsonReader.nextString();
        try {
            Double value = Double.valueOf(stringValue);
            return value;
        } catch (NumberFormatException e){ 
            // empty String 이거나 에러 발생 시 Null로 변환!!
            return null;
        }
    }
}

DoubleTypeAdapter는 별다른 처리는 해주고 있지 않고 stringValue가 빈 문자열("")이거나 double 형식이 아닐 때 null 값을 리턴하도록 되어 있다. 비슷하게 StringTypeAdapter도 구현해 빈 문자열이 들어오면 그냥 null로 처리하도록 했다. 관련 로직은 최하단의 Github 경로에 올려뒀으니 필요 시 참고하면 된다.

만약 좀 더 복잡하게 특정 유형의 형태가 들어왔을 때 처리할 때도 해당 케이스에 대한 TypeAdapter를 생성해 read(), write() 함수를 Override하여 처리 로직을 구현해주면 된다.

(2023.01.24 추가) 만약 "" 경우에만 null 처리를 해주고, 그 외의 에러 케이스의 경우 에러를 발생시키고 싶다면 아래와 같이 read() 메소드를 구현해주면 된다.

	@Override
    public Double read(JsonReader jsonReader) throws IOException {
        if (jsonReader.peek() == JsonToken.NULL) {
            jsonReader.nextNull();
            return null;
        }
        String stringValue = jsonReader.nextString();
        try {
            if("".equals(stringValue)){
                return null;
            } else {
                Double value = Double.valueOf(stringValue);
                return value;
            }
        } catch (NumberFormatException e) {
            throw e;
        }
    }

Step 2. Custom TypeAdapter를 적용한 Gson 객체 생성

어쨌든 위처럼 TypeAdapter를 상속받은 CustomAdapter들을 구현하고 해당 Adapter들을 사용할 수 있도록 Gson을 생성하는 Util 로직을 작성했다. 아래와 같이 매개변수로 사용할 Adapter들을 받아와 해당 옵션을 적용한 Gson 객체를 반환해 준다.

import ...

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();
    }
}

Step 3. 옵션을 적용한 Gson 객체로 json 파싱

자, 이제 호출만 해주면 된다. 맨 처음에 일반적인 구현 방식을 사용한 코드를 조금 수정해봤다.

import com.google.gson.*;
import ...

@Service
public class SampleService {
	
    public List<Entity> getEntityList(String jsonStr) {
    	Gson gson = getCustomGson();
    	return gson.fromJson(jsonStr, new TypeToken<List<SampleEntity>>(){}.getType());
    }
    
    private Gson getCustomGson() {
    	Map<Type, TypeAdapter<?>> adapterMap = new HashMap<>();
        adapterMap.put(Double.class, new DoubleTypeAdapter());
        adapterMap.put(double.class, new DoubleTypeAdapter());
        adapterMap.put(String.class, new StringTypeAdapter());
        adapterMap.put(BigDecimal.class, new BigDecimalTypeAdapter());
        
        return GsonUtils.buildTypeAdapterGson(adapterMap);
    }
}

////////////////////////////////

@Data
@Setter(AccessLevel.None)
public class Entity {
	private String idx;
    private String name;
    private double mathScore;
    private Double englishScore;
    private BigDecimal avr;
}

위에서 만약 객체별로 적용시킬 TypeAdapter가 분류/정의되어 있다면 해당 부분도 GsonUtils에 넣어 중복 코드를 없앨 수 있을 것 같다.

실제로 위 코드를 통해 json을 Entity로 변환 후 toString으로 찍으면 위와 같이 ""이 들어온 경우 null 값으로 처리되는 것을 확인할 수 있다. (double형의 경우 null이 아닌 defualt값인 0으로 초기화된다.)


⚠️ 주의할 점

단, Empty String을 그대로 DB에 적재해야 하거나, 형 변환 중 발생하는 에러에 대해서 throw 되면 안되는 경우 등... 해당 TypeAdapter 기능이 반영돼 원치 않은 결과가 나왔을 때, 버그 추적이 어려울 수 있으니 신중하게 생각하고 사용해야 한다.


Sample Code 저장되어 있는 Github으로 이동

profile
엔지니어가 되는 그 날 까지!

0개의 댓글