Reflection을 잘 활용할 줄 알면 Java 프로그래밍에서 코드의 범용성을 극대화 할 수 있다.


이 글에서는 그 중에서 Reflection의 특성을 이해하고 코드의 범용성을 높일 수 있는 범용 toString() 함수를 만들어 보도록 하겠다. toString() 함수는 프로그래밍 시 디버깅 용도로 상당한 활용도가 있는 함수이다.


범용 toString() 이란?

기본적으로 Java에서는 모든 클래스가 toString() 함수를 지원한다. 따라서 라이브러리에 선언되어 있는 클래스들은 .toString() 함수만 호출하면 해당 클래스 내부에 있는 값들을 출력해 낼 수 있다. 하지만 사용자가 직접 선언한 클래스는 toString() 함수 호출만 가지고 내부 변수에 가지고 있는 값들을 확인하기가 어렵다.

예를 들어 다음과 같은 클래스를 선언했다고 하자.

class TestClass{

    short s = 1;

    int i = 10;

    long l = 20;

   

    String name = "nameString";

   

    Long L = new Long(100);

   

    List<String> list = new ArrayList<String>();

    Map<String, String> map = new HashMap<String, String>();

   

    public TestClass(){

        list.add("string1");

        list.add("string2");

        list.add("string3");

        map.put("key1", "value1");

        map.put("key2", "value2");

        map.put("key3", "value3");

    }

}


그리고 아래처럼 toString() 함수를 호출해보면

TestClass tc = new TestClass();

System.out.println(tc.toString());

출력 결과는 다음과 같다.

MyPattern.reflection.TestClass@15db9742

패키지 경로에 따라서 조금씩 다를 수는 있지만 기본적으로는 내부에 있는 변수들의 값이 나오지는 않는다. 그래서 새로 클래스를 정의할 때마다 toString() 함수를 재정의(override) 할 필요가 있다. 하지만 Java 프로그래밍을 하면서 엄청나게 많은 클래스들을 만들텐데 새로 만드는 클래스마다 각 클래스에 필요한 toString() 함수를 재정의 하기는 너무 힘든 일이다.

이 때 Reflection을 이용하여 toString() 함수를 만들지 않고도 사용할 수 있는 것이 바로 범용 toString() 함수이다.


일단 원형을 만들어 보자. 재정의를 이용하지 않으면서 범용으로 사용하기 위해서는 별도의 클래스로 만들 필요가 있다. 그래서 ToString이라는 클래스를 하나 만들도록 하겠다.


class ToString

public class ToString {

    public static String toString(Object object){

        Field []fields = object.getClass().getDeclaredFields();

        String str = "{";

        for(Field field : fields){

            field.setAccessible(true);

        }

        return str + "} ";

    }

}

일단 내부에 toString() 함수가 선언되어 있지만 아직 동작하지는 않는 상태이다.

우선 인터페이스를 살펴 보자. toString() 함수는 Object를 매개변수로 받는다. 이 매개 변수에는 우리가 출력해 내기 원하는 객체를 집어 넣을 것이다. 범용이므로 당연히 Object 클래스 타입으로 집어 넣는다.

내부에 보면 Fields 변수가 선언되어 있고, object.getClass().getDeclaredFields() 함수를 호출하여 Field[]를 가지고 오게 되어 있다. getClass()는 Reflection에 활용되는 클래스 객체를 가지고 오는 함수이다. 즉, object 객체는 .class 객체를 가지고 있는데, 객체의 본래 타입에 따라서 .class 객체는 모두 다르다. getDeclaredFields() 함수는 클래스 객체로부터 선언된 모든 Field 객체들을 배열 형태로 가져오는 함수다. .class 객체가 객체의 본래 타입에 따라 다르기 때문에 getDeclaredFields() 함수를 통해 나오게 되는 Field[]도 모두 다르다.

이제 Field[]들을 모두 얻었으니 이를 이용만 하면 된다. for문 안을 보면 각 Field 객체에 대해 setAccessible(true) 함수가 호출되는 것을 볼 수 있다. 만약 Field가 public이 아닌 경우 캡슐화로 인하여 그 값에 접근하는 것이 불가능한데 Reflection은 이 함수를 통해서 접근이 가능하도록 만들 수 있다.(이 부분은 아직도 이슈가 많은 부분이라 프로그래머 마다 찬반 의견이 다르다. 하지만 유용성에 대해서는 아무도 부인하지는 못할 것이다.)

자 일단 Fileld 객체에 대한 접근도 가능해졌다. 그러면 이제 출력을 해 볼 시간이다. 우선 Field 객체의 출력 포맷을 다음과 같이 정해 보겠다.

[type] [field name] : [value] 

만약 short s = 1 을 출력한다면 short s : 1 과 같이 출력될 것이다.

그러면 type, fieldName, value를 가지고 올 수 있는 Field 객체의 API를 알아 보아야 한다.

String getType() : type에 대한 클래스 객체(Class object)를 반환하는 함수 

String getName() : 필드명을 String 객체로 반환하는 함수

Object get(object) : Field가 값으로 가지고 있는 객체를 반환하는 함수

이를 통해서 간단히 문자열을 만들어 보면 다음과 같다.

str += field.getType().toString() + " " + field.getName() + ":" + field.get(object) + " ";

이렇게 하면 되는데, 예외 처리 때문에 try-catch문이 필요할 것이다. 이것은 있다가 한꺼번에 처리하도록 하고, 우선 한가지만 짚어 보고 넘어가자.

field.getType()의 경우 타입을 반환하긴 하는데 primitive 타입이 아닌 경우 타입이 장황하게 출력될 수 있다. 예를 들어 Long 타입인 경우 "class java.lang.Long"라고 출력된다. 이것이 너무 장황하다면 짧게 줄일 필요가 있겠는데 이는 아래와 같이 하면 된다.

String type = field.getType().toString().substring(field.getType().toString().lastIndexOf(".") + 1);

type 만을 문자열로 뽑아 내는 구문이다. 어려운 것이 아니고 문자열 중에서 마지막 "."이 나오는 index를 찾고, 그 index 다음번(+ 1) 문자부터 시작하는 substring()을 뽑아 내도록 한 것이다. 이렇게 하면 type을 짧게 출력해 낼 수 있다.


이것을 코드에 적용시켜 보면 다음과 같다.

public class ToString {   

    public static String toString(Object object){

        Field []fields = object.getClass().getDeclaredFields();

        String str = "{";

        for(Field field : fields){

            field.setAccessible(true);

   

            try {

                String type 

                  field.getType().toString().

                  substring(field.getType().toString().lastIndexOf(".") + 1);


                  str += type + " " + field.getName() + ":" + field.get(object) + " ";   


            } catch (IllegalArgumentException e) {

            } catch (IllegalAccessException e) {

            }

    }

}


이렇게 해서 맨 처음 선언한 TestClass 객체를 넣어 출력을 해보면 아래와 같이 나오게 될 것이다.

    TestClass tc = new TestClass();

    System.out.println(ToString.toString(tc));


출력결과 :


{short s:1 int i:10 long l:20 String name:nameString Long L:100 List list:[string1, string2, string3] Map map:{key1=value1, key2=value2, key3=value3} } 

위와 같이 원하는 대로 출력이 됨을 알 수가 있다. 특히 Map이나 List도 출력이 가능하므로 이 정도 만으로도 상당히 유용하게 사용할 수 있다.


하지만 우리는 많은 경우에 어떤 객체를 선언할 때 자신이 만든 맴버 객체를 선언하여 사용할 경우가 많다. 이런 경우에는 안타깝게도 이 코드는 소용이 없다. 그 이유가 무엇일까? field.get(object)를 통해 나오는 것은 변수에 할당되어 있는 객체이다. 즉 우리가 만든 맴버 객체가 이 함수를 통해 나오게 된다. String 클래스에서 문자열 변환을 할 때 자동으로 객체의 toString() 함수를 호출하게 되어 있는데, 우리가 만든 객체는 toString() 함수를 재정의 하지 않았다! 따라서 그냥 출력하면 이상한 값이 나오게 될 것이다.


그렇다면 이대로 만족하고 사용해야 할까? 당연히 그렇지 않다. 자, 상황을 정확히 알기 위해 다음의 함수를 만들어 실행해 보도록 하자. 지금 중요한 것은 우리가 만든 객체는 primitive  타입이나 라이브러리에서 제공하는 객체가 아니다. 따라서 우리가 만든 객체임을 확인하고, 그 때에는 재귀적으로 ToString.toString(Object object) 함수를 호출하도록 한다면 문제가 해결될 것이다. 다음과 같은 논리이다.

ToString.toString(Object 우리가만든객체){

    ........ if(field.getType() == 내부에선언된우리가만든객체) toString(내부에선언된우리가만든객체);

}


그렇다 필요한 것은 getType() 함수를 통해서 타입을 확인해 보면 금방 알 수 있다. 그러면 클래스 객체가 어떻게 나오는지를 확인하기 위해 다음과 같은 코드를 동작시켜 보도록 하자. 좀 길긴 하지만 복사해서 붙여 넣고 실행해 보면 된다.

public class ToString {

    public static String toTypeString(Object object){

        Field []fields = object.getClass().getDeclaredFields();

        String str = "";

        for(Field field : fields){

            field.setAccessible(true);

   

            str += field.getType() + " " + field.getName() + "\n";

        }

        return str;

    }


    public static void main(String[] args) {

        AllTypes types = new AllTypes();

        System.out.println(ToString.toTypeString(types));

    }

}

class InnerClass{

    int a = 100;

    long b = 200;

    String name = "innerClass";

}


class AllTypes{

    short s = 1;

    int i = 1;

    long l = 1;

    Short S = new Short((short)0);

    Integer I = new Integer(0);

    Long L = new Long(0);

   

    InnerClass inner = new InnerClass();

   

    List<String> list = new ArrayList<String>();

    Map<String, String> map = new HashMap<String, String>();

}

ToString.toTypeString() 함수를 일단 보면 각 Field 객체에 대해 getType()과 getName()을 호출하여 문자열 화 하는 것을 알 수 있다.

우리가 출력하고자 하는 것은 AllType 클래스이다. 이 클래스는 대부분의 primitive 타입과 Map 타입, List 타입이 선언되어 있어서 toTypeString() 함수를 통해 그 타입을 출력해 볼 수 있다. 중요한 것은 InnerClass 부분이다. 이것이 어떻게 출력되는지를 확인해야 한다. 위의 코드를 실행하면 아래와 같이 출력된다.

short s

int i

long l

class java.lang.Short S

class java.lang.Integer I

class java.lang.Long L

class MyPattern.reflection.InnerClass inner

interface java.util.List list

interface java.util.Map map


눈여겨 볼 것은 볼드체로 되어 있는 부분이다. InnerClass는 우리가 만든 것인데 이것은 class 로 시작해서 패키지 경로.InnerClass 타입명으로 나오는 것으로 되어 있다.

그러면 이런 형태로 우리가 만든 클래스를 구분해 내 볼 수 있을 것이다.

우리가 정의한 객체는 if( 클래스명이 class로 시작 하면서 class java.lang. 으로 시작하지 않는 경우 ) 에 해당된다.

이를 코드에 반영하여 전체 코드를 살펴 보면 다음과 같다.


최종 결과물

public class ToString {  

    public static String toString(Object object){

        Field []fields = object.getClass().getDeclaredFields();

        String str = "{";

        for(Field field : fields){

            field.setAccessible(true);

   

            try {

                String type =               

                    field.getType().toString().

                    substring(field.getType().toString().lastIndexOf(".") + 1);


                if(field.getType().toString().startsWith("class ") &&           

                   !field.getType().toString().startsWith("class java.lang.")){


                   str += type + " " + field.getName() + 

                          toString(field.get(object)) + " ";

                }

                else{

                   str += type + " " + field.getName() + ":"

                          field.get(object) + " ";   

                }

            } catch (IllegalArgumentException e) {

            } catch (IllegalAccessException e) {

            }

        }

        return str + "} ";

    }

   

    public static void main(String[] args) {

        AllTypes types = new AllTypes();

        System.out.println(ToString.toString(types));

    }

}

AllTypes 클래스의 정의는 위에서 가져와서 실행해 보면 된다. 실행 결과를 보면 다음과 같다.


실행 결과

{short s:1 int i:1 long l:1 Short S:0 Integer I:0 Long L:0 InnerClass inner{int a:100 long b:200 String name:innerClass }  List list:[] Map map:{} } 

이제 InnerClass 내부의 정보도 모두 출력되고 있음을 알 수 있다.


약간 덧붙이자면, 일단 field가 null인 경우 동작하지 않을 수 있으므로 null 체크도 들어가는게 좋다.

그리고 배열의 경우 위의 경우에서 모두 벗어나기 때문에 제대로 출력이 안될 수 있다. 이 부분은 필요시 직접 처리하기 바란다.

만일 ToString 클래스를 사용해야 하는 것이 불편하고, toString()을 재정의 해서 사용하고 싶다고 할 때에도 이 ToString 클래스는 유용하다. 클래스마다 toString() 함수를 재정의 할 때 다음과 같이 동일하게 해주면 된다.


toString()함수를 재정의 하고자 할 때

@Override

public String toString() {

    return ToString.toString(this);

}

이렇게 하면 역시 클래스별로 toString() 함수를 재정의 할 때마다 다른 코드를 작성할 필요가 없어진다.


Posted by 이세영2
,