프로젝트에 객체를 Map<String, String>으로 바꿔 주는 유틸 메서드가 있었다. 열어 보니 Reflection으로 필드를 하나씩 훑고 있었다. 동작은 한다. 다만 Jackson이 이미 의존성에 있는데 같은 일을 손으로 하고 있었다.

이 글에서는 기존 메서드가 무엇을 하는지 보고, ObjectMapper로 바꾼 코드와 바꿀 때 챙길 점을 정리한다.

기존 메서드는 선언된 필드를 전부 연다

public <T> Map<String, String> convertToMap(T object) {
    Map<String, String> map = new HashMap<>();
    try {
        Class<?> clazz = object.getClass();
        Field[] fields = clazz.getDeclaredFields();
        for (Field field : fields) {
            field.setAccessible(true);
            String fieldName = field.getName();
            Object value = field.get(object);
            if (value != null) {
                map.put(fieldName, value.toString());
            }
        }
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
    return map;
}

흐름은 세 단계다.

  • getDeclaredFields()로 필드 목록을 가져온다. getDeclaredXXX() 계열은 상속받은 클래스와 인터페이스를 빼고 그 클래스에 직접 정의된 요소만 가져온다. 대신 접근 제어자는 가리지 않아서 private, protected, public 필드가 전부 나온다.

  • 필드마다 setAccessible(true)로 접근 제한을 풀고, 값을 읽어 null이 아니면 toString() 결과를 Map에 넣는다.

  • 접근할 수 없는 필드를 읽으면 IllegalAccessException이 난다. 이 메서드는 그걸 printStackTrace()로 찍고 넘어간다.

마지막 줄이 걸린다. 예외가 나면 호출한 쪽은 그때까지 채워진 Map을 받을 뿐, 변환이 실패했는지 알 방법이 없다.

ObjectMapper로 바꾸면 한 줄이다

public <T> Map<String, String> convertToMap(T object) {
    ObjectMapper mapper = new ObjectMapper();
    return mapper.convertValue(object, new TypeReference<Map<String, String>>() {});
}

Jackson의 ObjectMapper는 JSON을 다룰 때 쓰는 도구지만 convertValue로 객체를 다른 타입으로 바로 옮길 수 있다. 필드 순회, 접근 권한, 예외 처리를 전부 Jackson에 넘긴다. 변환에 실패하면 IllegalArgumentException이 올라오니 삼켜지는 예외도 사라진다.

TypeReference는 결과 타입을 명시하는 장치다. Java 제네릭은 런타임에 지워지기 때문에 Map.class만 넘기면 Jackson은 값 타입을 모른다. new TypeReference<Map<String, String>>() {}로 넘기면 값을 String으로 맞춰 담는다.

바꿀 때 챙길 점

성능은 원래 이 리팩토링의 이유 중 하나였다. Jackson도 내부적으로는 Reflection을 쓰지만, 클래스를 한 번 분석한 결과를 ObjectMapper 인스턴스에 캐싱해 두고 재사용한다. ObjectMapper를 재사용해야 캐싱 효과가 난다. 위 코드처럼 호출마다 new ObjectMapper()를 만들면 매번 처음부터 분석한다. 필드로 한 번만 만들어 두거나, Spring Boot라면 이미 등록된 ObjectMapper Bean을 주입받는 편이 낫다.

두 메서드가 돌려주는 Map도 같지 않다.

  • 기존 메서드는 선언된 필드를 읽고, Jackson은 기본 설정에서 getter와 public 필드를 읽는다. getter 없는 private 필드는 빠진다.

  • null 값을 기존 메서드는 건너뛰었고, Jackson은 기본적으로 키를 남긴 채 값에 null을 넣는다.

  • 상속받은 필드는 기존 메서드가 읽지 않았다. Jackson은 부모 클래스의 getter도 읽는다.

호출하는 쪽이 이 차이에 기대고 있지 않은지 한 번 보고 바꾸는 게 안전하다.