켄트 벡의 TDD("테스트 주도 개발")에 보면 Junit 구현에 Pluggable Selector라는 패턴을 사용했다는 내용이 나온다. 책에서 언급된 내용은 코드보다는 말로 설명되어 있고, 아래 링크에 가면 코드가 어느 정도 나와 있다.

JUnit A Cook's Tour


하지만 안타깝게도 테스트 케이스를 수행하기 위한 객체 생성 부분이 없어서 상상력을 발휘해서 Pluggable Selector 패턴을 구현해 볼까 한다. 혹시 이 글을 보고 실제 Junit이 이렇게 구현되어 있다고 생각하지는 말길 바란다. 여기에 나오는 것은 Pluggable Selector 패턴을 설명하기 용이하게 만들어진 예제에 불과하다.


Pluggable Selector 패턴이란?

첫번째 예는 Unit Test Framework인 Junit3에 관한 것이다(Junit4 부터는 annotation 기반으로 바뀌었기 때문에 외부 구현 상에서 유사점을 찾기 힘들 것이다.) Junit3에서 Unit Test를 작성하기 위해서는 TestCase 라는 클래스를 상속 받아야 한다. 필요한 경우 setUp() 함수나 tearDown() 함수를 재정의 해야 한다.(여기까지는 Pluggable Selector 패턴과 별 상관이 없다.) Pluggable Selector와 관련이 있는 부분은 테스트 함수 부분인데, Junit3에서는 void testNAME() 형식의 함수를 찾아 실행하도록 되어 있다. 더욱이 중요한 부분은 각 테스트 함수들을 실행 할 때 이전 테스트와의 의존성을 배제하기 위해서 테스트 객체를 매번 다시 생성해서 실행 시킨다는 점이다.

보다 정확한 설명을 위해서 우리가 동작 시켜볼 테스트 클래스를 살펴 보도록 하자. 문제를 명확하게 하기 위해서 불필요하게 TestCase를 상속 받는 부분을 제외하였다.

class UnderTest{

    public void setUp(){

        System.out.println("setUp()");

    }

   

    public void testCase1(){

        System.out.println("testCase1()");

    }

   

    public void testCase2(){

        System.out.println("testCase2()");

    }

   

    public void tearDown(){

        System.out.println("tearDown()");

    }

}

위와 같은 테스트 클래스를 Junit3에서 실행 시키면 아래와 같은 순서로 실행이 된다.

(UnderTest 클래스 객체의 생성)

setUp()

    testCase1()

tearDown()


(또 다른 UnderTest 클래스 객체의 생성)

setUp()

    testCase2()

tearDown()


() 안의 동작은 Junit3 프레임 워크 내부에서 수행해 주는 일이다. 반복적인 테스트가 수행되는데 다른 점은 첫번째에는 testCase1()이 실행되고, 두번째는 testCase2()가 실행된다는 점이다. 이 부분이 Pluggable Selector 패턴의 핵심이다. 클래스 상에서 test로 시작하는 테스트 함수들을 모두 찾고, 실행시켜 주는 것이다.


Pluggable Selector  패턴에 대한 좀 더 범용적인 이해를 돕기 위해 다른 예를 들어 보겠다. 상위 클래스를 상속 받은 여러 하위 클래스들이 있다고 하자. 이들 클래스는 상위 클래스와 대부분 비슷하지만 모두 각자 (상위 클래스로부터 상속 받지 않은) 다른 메소드 하나씩을 가지고 있다고 하자. 이 때 상위 클래스에서 제공하지 않는 메소드들을 한번씩 실행 시켜야 한다면 어떻게 해야 할까? 이럴 때 사용하는 것이 Pluggable Selector 패턴이다.


다시 Junit3 예제로 돌아가보자. Java에서 이를 구현하는 방법은 Reflection을 이용하는 것이다. 특히 Reflection을 이용하면 테스트 함수들을 찾고, 객체를 생성하고, 함수별로 실행 시켜주는 과정을 모두 자동화 할 수 있다.


테스트 대상 클래스는 이미 위에서 만들었으니 Pluggable Selector 패턴으로 구현된 JUnit 클래스를 만들어 보자. JUnit 클래스는 테스트 클래스 등록(addTest())과 테스트의 실행(runTest()) 단계로 크게 구분될 수 있다. 우선 addTest()부터 구현해 보자.

class JUnit{

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

   

    public void addTest(Class clazz){

        Method[] methods = clazz.getDeclaredMethods();

        for(Method method : methods){

            method.setAccessible(true);

   

            if(method.getName().startsWith("test")){

                map.put(method.getName(), clazz);

            }

        }

    }

Reflection으로 구현되어 있기 때문에 약간 생소할 수도 있다. 먼저 addTest() 함수의 매개변수는 클래스 객체이다. 여기에 우리는 테스트 대상 클래스인 TestClass.class를 매개변수로 넣을 것이다.

다음으로 Method[]를 추출해 내는 작업을 수행한다. 클래스 객체에서 getDeclaredMethods() 함수를 호출하면 해당 클래스가 선언하고 있는 모든 메소드들을 배열 형태로 리턴해 준다.

이제 각 Method를 대상으로 for 문을 수행하는데, 맨 처음 호출되는 것은 method.setAccessible(true)이다. 이것은 메소드가 public이 아닐 경우에도 접근이 가능하도록 설정해 주는 것이다.

그 다음에는 method.getName()을 이용해서 메소드의 이름을 체크한다. 여기서는 JUnit3과 마찬가지로 test로 시작하는 함수를 추출한다. 그리고 만약 test로 시작하는 함수가 있다면 이 메소드 이름을 key로 사용하는 map에 메소드 이름과 클래스 객체를 값으로 하여 집어 넣게 된다.

이 과정을 마치고 나면 모든 test로 시작하는 메소드 이름 : 클래스 객체의 맵이 만들어지게 된다. 이 맵은 이제 만들어 볼 runTest() 메소드에서 사용된다.

우선 runTest() 메소드에서 해야 할 일을 다시 떠올려 보자. 첫번째 테스트 함수를 테스트 하기 위해서 테스트 객체를 생성하고, setUp() 함수를 호출하고, test 함수를 호출하고 tearDown() 함수를 호출한다. 다음 테스트 함수에 대해서도 마찬가지이다.(이 중에서 setUp() 함수와 tearDown() 함수의 호출 이유는 그다지 중요하지 않으니 Unit Test를 모르는 분들은 무시해도 된다.)

그럼 구현을 살펴보자.

    public void runTest(){

        for(String testName : map.keySet()){

            Class clazz = map.get(testName);

            try {

                Object testObject = clazz.newInstance();


                Class[] parameterTypes = new Class[0];

                Method setUp = clazz.getDeclaredMethod("setUp", parameterTypes);

                Method tearDown = clazz.getDeclaredMethod("tearDown", parameterTypes);

                Method testMethod = clazz.getDeclaredMethod(testName, parameterTypes);

                Object[] parameters = new Object[0];

                setUp.invoke(testObject, parameters);

                testMethod.invoke(testObject, parameters);

                tearDown.invoke(testObject, parameters);

            } catch (InstantiationException e) {

            } catch (IllegalAccessException e) {

            } catch (SecurityException e) {

            } catch (IllegalArgumentException e) {

            } catch (InvocationTargetException e) {

            } catch (NoSuchMethodException e) {

            }

        }

    }

우선 맵의 키 값(여기서는 test 함수 이름을 나타내는 String 객체) 이용하여 for문을 수행한다. 실제 테스트를 수행하려면 우선 객체가 필요하다. 그래서 맵에서 test 함수의 클래스 객체를 가지고 온 다음, testObject라는 이름으로 newInstance() 함수를 호출해서 객체를 생성한다. 클래스 객체를 이용하면 이와 같이 객체의 생성도 수행할 수 있다.

그 다음에는 setUp(), tearDown() 메소드를 클래스 객체로부터 얻어 온다. 마지막으로 테스트할 메소드도 가지고 온다.

실제로 이 메소드들을 실행하는 방법은 Method 객체에 구현된 invoke() 함수를 이용하는 것이다. 이 때 invoke는 특정 객체(위 코드에서는 testObject)의 것을 호출하는 것이다. 따라서 Method 객체는 형식만 지정하는 역할을 하고, 실제 수행되는 객체인 testObject를 매개 변수로 넣어 주어야 한다. 파라메터는 없으므로 비어 있는 Object[] 객체를 넣어 주면 된다.

전체 구현 내용을 종합해 보면 다음과 같다.


최종 결과

class JUnit{

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

   

    public void addTest(Class clazz){

        Method[] methods = clazz.getDeclaredMethods();

        for(Method method : methods){

            method.setAccessible(true);

   

            if(method.getName().startsWith("test")){

                map.put(method.getName(), clazz);

            }

        }

    }

   

    public void runTest(){

        for(String testName : map.keySet()){

            Class clazz = map.get(testName);

            try {

                Class[] parameterTypes = new Class[0];

                Object testObject = clazz.newInstance();

                Method setUp = clazz.getDeclaredMethod("setUp", parameterTypes);

                Method tearDown = clazz.getDeclaredMethod("tearDown", parameterTypes);

                Method testMethod = clazz.getDeclaredMethod(testName, parameterTypes);

                Object[] parameters = new Object[0];

                setUp.invoke(testObject, parameters);

                testMethod.invoke(testObject, parameters);

                tearDown.invoke(testObject, parameters);

            } catch (InstantiationException e) {

            } catch (IllegalAccessException e) {

            } catch (SecurityException e) {

            } catch (IllegalArgumentException e) {

            } catch (InvocationTargetException e) {

            } catch (NoSuchMethodException e) {

            }

        }

    }

}


이제 실행을 시켜 보도록 하자.

public static void main(String[] args) {

    JUnit junit = new JUnit();

    junit.addTest(UnderTest.class);

    junit.runTest();

} 

위와 같이 실행 코드를 작성하고 실행 시켜 보면 아래와 같이 출력된다.

setUp()

testCase2()

tearDown()

setUp()

testCase1()

tearDown()

의도한 대로 각 테스트 함수들이 잘 실행되는 것을 알 수 있다.

JUnit 처럼 테스트 대상 클래스가 어떤 명칭의 테스트 함수를 구현하게 될지 알 수 없을 때에 Pluggable Selector 패턴은 매우 적절한 선택이다. 소프트웨어를 개발하다 보면 향후에 확장을 대비해서 상당한 수준의 자유도를 미리 확보해야 하는 경우가 종종 있는데, 이런 경우에 사용하면 좋을 것이다.

'5.디자인패턴' 카테고리의 다른 글

Adapter 패턴  (0) 2016.08.23
Decorator 패턴(synchronizedList의 구현 패턴)  (0) 2016.08.23
State 패턴  (0) 2016.08.15
Strategy 패턴  (0) 2016.08.15
Telescoping Parameter 패턴  (0) 2016.08.13
Posted by 이세영2
,

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
,

C에서는 enum이 바로 숫자이기 때문에 배열의 인덱스로 자주 활용이 된다. 하지만 JAVA에서는 이것이 명시적이지 않아서 잘 모르는 경우가 많이 있다. 여기서는 JAVA에서 enum 타입을 배열의 인덱스로 활용하는 방법과 더불어 C언어의 enum보다 나은 점도 함께 알아보도록 하겠다.


해결하고자 하는 문제

다음과 같은 클래스가 있다고 하자. 대략 동서남북으로의 거리를 나타내는 클래스라고 생각하면 되겠다.

class Distance{

    private int east;

    private int west;

    private int north;

    private int south;

    public void setEast(int east){ this.east = east; }

    public void setWest(int west){ this.west = west; }

    public void setNorth(int north){ this.north = north; }

    public void setSouth(int south){ this.south = south; }

    public int getEast() { return east; }

    public int getWest() { return west; }

    public int getNorth() { return north; }

    public int getSouth() { return south; }

}

클래스 선언 상으로 보면 큰 문제는 없어보이지만 다만 마음에 걸리는 것은 중복이 많다는 점이다.

그래서 잘 들여다 보면 모두 같은 타입(int)의 변수를 선언하고 있고, 모두 getter와 setter를 제공하고 있음을 알 수 있다. 그리고 동서남북을 나타내는 변수들이므로 서로 연관성도 있어 보인다. 이런 경우에는 enum 타입을 하나 선언해서 동서남북을 가리키는 서브 타입을 만들고 이것을 인덱스로 활용하는 방법을 사용하면 중복이 제거된다.

다음과 같은 과정을 통해서 클래스를 수정해 볼 수 있다. 우선 방향을 나타내는 enum을 선언한다. Direction이라는 클래스명을 사용하도록 하겠다.

방향을 나타내는 enum Direction

enum Direction{

    EAST,

    WEST,

    NORTH,

    SOUTH;

}

이제 enum을 선언했으니 Distance 클래스에서 이를 이용하면서 코드를 간결하게 바꿔 보도록 하자. 바꾸는 방법은 우선 각 방향별로 선언되었던 변수를 하나의 배열로 선언하도록 한다. 방향이 4개니까 4개짜리 배열이 되겠다. 그런 다음 getter와 setter가 Direction을 매개 변수로 받도록 수정한다. 이제 중요한 부분이 남았다. enum 타입인 Direction  하위 타입을 어떻게 인덱스 숫자로 바꾸느냐는 것인데, JAVA에서는 ordinal() 이라는 함수를 통해 숫자로 바꿀 수 있다. int ordinal() 함수는 해당 서브 enum 타입의 정의 순서에 따른 숫자를 리턴한다. 가령 EAST.ordinal() = 0, WEST.ordinal() = 1......와 같은 방식이다. 이것은 배열의 인덱스 값과 같은 값이다. 따라서 전혀 문제 없이 사용할 수 있다.

class Distance 수정

class Distance{

    private int distance[] = new int[4];

    public int get(Direction direction){

        return distance[direction.ordinal()]; 

    }

    public void set(Direction direction, int value){ 

        distance[direction.ordinal()] = value; 

    }

}

이것으로 일단 원하는 대로 수정을 완료 하긴 하였다. 하지만 조금만 더 깊이 들어가 보자. 우리는 이미 동서남북이 4방향이라는 것을 알고 있고, 이에 따라 distance 변수의 배열 길이를 4로 지정했다. 하지만 만약 새로운 방향이 추가 된다면 어떻게 될 것인가? 물론 사용시에 예외가 발생하게 되겠지만 보다 근본적으로 이 문제를 해결할 방법이 있다. JAVA의 enum 타입은 기본적으로 static final이다. 따라서 컴파일 타임에 이미 enum 하위 타입의 개수도 정해진다. enum 하위 타입의 개수를 알아내는 방법은 보통 Type.values().length 를 이용한다. .values() 함수는 enum 하위 타입들을 배열화하여 리턴해주는 함수이다. .length는 잘 알다시피 배열의 길이를 나타내는 배열 객체의 변수이다. 그리고 이들은 앞서 말한 바와 같이 컴파일 타임에 결정되기 때문에 배열의 개수 초기화에도 사용할 수 있다.


values().length 를 이용한 배열 개수 초기화

class Distance{

    private int distance[] = new int[Direction.values().length]; // 이부분이 수정되었다.

    public int get(Direction direction){ 

        return distance[direction.ordinal()]; 

    }

    public void set(Direction direction, int value){ 

        distance[direction.ordinal()] = value; 

    }

}

이렇게 하면 Direction에 NORTH_EAST, SOUTH_EAST 등 새로운 하위 타입이 추가될 때마다 Direction.values().length 값도 함께 바뀌게 되고, 따라서 방향의 추가/삭제 시에도 배열의 길이에 의한 문제점이 발생하지 않게 된다.


완성된 코드를 모두 합쳐 보면 다음과 같다.


최종 결과물

enum Direction{

    EAST,

    WEST,

    NORTH,

    SOUTH;

}

class Distance{

    private int distance[] = new int[Direction.values().length];

    public int get(Direction direction){ 

        return distance[direction.ordinal()]; 

    }

    public void set(Direction direction, int value){ 

        distance[direction.ordinal()] = value; 

    }

}


enum Direction과 Distance 클래스를 혼합하여 사용하는 방법은 아래와 같다.


main() 함수

public static void main(String[] args) {

    Distance distance = new Distance();

    distance.set(Direction.EAST, 100);

    distance.set(Direction.WEST, 200);

    distance.set(Direction.NORTH, 300);

    distance.set(Direction.SOUTH, 400);

    System.out.println(Direction.EAST + " " + distance.get(Direction.EAST));

    System.out.println(Direction.WEST + " " + distance.get(Direction.WEST));

    System.out.println(Direction.NORTH + " " + distance.get(Direction.NORTH));

    System.out.println(Direction.SOUTH + " " + distance.get(Direction.SOUTH));

}

출력되는 결과물은 다음과 같다.


출력 결과

EAST 100

WEST 200

NORTH 300

SOUTH 400


원하던 값이 잘 출력됨을 알 수 있다.


이 과정이 시사하는 바가 있는데 이것을 한번 정리해 보면 다음과 같다.

1. 유사한 코드가 반복적으로 나타나는 경우에는 반복을 줄일 수 있는 방법을 생각해 봐야 한다.

2. 유사성을 표현할 수 있는 enum이나 상위 클래스를 선언할 수 있는지를 살펴 보아야 한다.

3. 변수들이 반복적으로 선언된 경우라면 enum과 배열을, if문이나 switch 문이 있을 경우에는 클래스를 이용한다.(클래스를 이용하는 방법에는 상태/전략 패턴이 있다.)

4. 배열을 이용할 경우 추가적인 타입 선언에 대해서도 고려 해야 한다.

Posted by 이세영2
,