앞서 Object World 예제에서 나왔던 TemperatureHolder에 관한 내용이다.


우선 소스를 먼저 보고 이야기를 시작하자.


interface ITemperature{

    public String toDbString();

    public void operateThermostat(Thermostat thermostat);

}


class HotTemperature implements ITemperature{

    String dbString = "HOT";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

}

class NormalTemperature implements ITemperature{

    String dbString = "NORMAL";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){thermostat.stop();}

}

class ColdTemperature implements ITemperature{

    String dbString = "COLD";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

}


class TemperatureHolder{

    public static ITemperature HOT = new HotTemperature();

    public static ITemperature NORMAL = new NormalTemperature();

    public static ITemperature COLD = new ColdTemperature();

    public static ITemperature getTemperature(int temperature){

        if(temperature > 30) return HOT;

        if(temperature >= 0) return NORMAL;

        else return COLD;

    }

}


이해하기 쉽게 클래스 다이어그램으로 그려보면 다음과 같다.

Holder 패턴이 생소할지 모르니 좀 더 익숙한 FactoryMethod 패턴을 먼저 이야기 해보겠다.


FactoryMethod 패턴은 객체의 생성을 책임지는 패턴이다. 위와 같이 상위 타입(클래스 다이어그램과 소스 상에서는 ITemperature)이 같은 여러 하위 객체들의 생성을 담당하는 패턴이다. 그리고 매우 중요한 부분이 있는데 이 FactoryMethod 패턴을 통해 생성된 하위 객체의 종류나 그 존재까지도 외부에서는 알지 못하게 해야 한다는 점이다. FactoryMethod 패턴이 구현된 클래스에서 생성된 객체가 외부로 나가는 순간, 외부에서는 그 객체의 구체 클래스가 무엇인지는 알지 못하고 오직 ITemperature로만 알려지게 된다. 이것은 코드의 중복과 강한 결합 등 객체지향에서 지양하는 여러 문제들이 발생하지 않도록 방지해 준다.


Holder 패턴은 FactoryMethod 패턴과 유사점이 많다. 우선 객체의 생성을 담당한다. TemperatureHolder 클래스를 보면 알 수 있는데, 내부에서 각 하위 객체를 생성하여 가지고 있는 것을 볼 수 있다. 그리고 외부에서 객체를 요구할 경우 조건에 맞는 객체를 제공해 준다. 이 경우에도 하위 객체의 타입을 알리지 않기 위해서 ITemperature로 내보낸다. 기본적인 동작과 목적에 있어서 Factory Method 패턴과 Holder 패턴은 매우 유사하다.


다른 점이 딱 한가지 있다. Factory Method 패턴은 외부에서 객체를 요구하면 매번 생성해서 전달해 주는데 비해 Holder 패턴은 하위 객체 각각에 대하여 딱 한번씩만 생성한다는 점이다. TemperatureHolder 클래스에서 보면 HOT/NORMAL/COLD 라는 필드명으로 된 3가지 객체를 하나씩 가지고 있다.


이 글에서는 저 TemperatureHolder 클래스와 관련된 문제를 풀어 보고자 한다. 그리 많은 코드는 아니지만 TemperatureHolder 클래스는 문제점을 가지고 있다.


1. ITemperature 하위 클래스가 늘어나면 TemperatureHolder 클래스가 생성해야 하는 클래스의 개수도 늘어난다.

2. 이와 함께 getTemperature() 함수도 변경된다. VERY_HOT이나 VERY_COLD가 추가될 경우를 생각해 보자. 이들은 단지 ITemperature 인터페이스의 하위 클래스일 뿐인데 이 클래스가 늘어난다고 해서 생성 및 전달의 책임만 가지고 있는 TemperatureHolder 클래스가 변경된다는 것은 문제가 있다.


이 두 가지 문제를 해결해 보도록 하자.


이 중에서 2번이 더 쉬운 문제니까 2번을 먼저 해결해보자. 알아 두어야 할 것은 두가지 문제를 모두 해결해야만 완벽한 해결이 된다. 따라서 2번만 해결하는 코드는 중간 산출물 정도로 이해해 주기 바란다.


getTemperature() 메소드 문제의 원인은 하위 객체들이 생성되는 조건을 TemperatureHolder가 알고 있다는 점이다. 하위 객체들은 특정 상태의 온도에 대한 정보를 가지고 있으므로 당연히 자기 온도 범위도 알고 있어야 한다. 이것을 객체가 가지고 있지 않기 때문에 TemperatureHolder 클래스가 가지고 있게 되고, 그것이 ITemperature 하위 객체가 변경될 때 TemperatureHolder 클래스도 변경되는 원인이다.


그래서 이를 해결하기 위해 ITemperature에 각 하위 객체의 온도 범위를 체크할 수 있는 인터페이스를 구현한다.


interface ITemperature{

    public String toDbString();

    public void operateThermostat(Thermostat thermostat);

    public boolean isInTemperatureRange(int temperature);

}


그리고 각 클래스들은 자신의 범위를 알려주도록 구현한다.


class HotTemperature implements ITemperature{

    String dbString = "HOT";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

    public boolean isInTemperatureRange(int temperature) { return temperature > 30; }

}

class NormalTemperature implements ITemperature{

    String dbString = "NORMAL";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){thermostat.stop();}

    public boolean isInTemperatureRange(int temperature) { return temperature <= 30 && temperature >= 0; }

}

class ColdTemperature implements ITemperature{

    String dbString = "COLD";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

    public boolean isInTemperatureRange(int temperature) { return temperature < 0; }

} 


무상태 프로그래밍을 지향하는 관점에서 상태 객체 생성시에 상태 객체를 생성하는 클래스 이외에 다른 클래스가boolean을 리턴하는 메소드를 구현하는 것은 상태 확인 메소드를 구현하는 것과 같지 않느냐고 질문할지 모르겠다. 변명이 통할지는 모르겠지만 일단 목적과 방법에 있어서 다른 점이 있어서 이야기를 하고 넘어가겠다.


1. 위의 isInTemperatureRange() 메소드는 자기 객체의 상태를 확인하는 것이 아니다. 오히려 인자로 들어온 temperature 값이 자기 기준에 맞는지를 확인해 주는 것이다.

2. isInTemperatureRange() 메소드가 어느 시점에 호출되든지 각 객체의 내부 상태에 대해 알 수 있는 정보는 하나도 없다.

3. 이 메소드는 TemperatureHolder 클래스에 의해 이용되는 것이다. 즉, 오직 생성 클래스에 의해서만 사용된다.

4. 이 메소드는 객체의 특성을 나타내주는 메소드이다. 따라서 객체 스스로 그 특성 정보를 가지고 있는 것이 타당하다.


일단 이렇게 수정한 후에는 TemperatureHolder 클래스를 수정해야 한다.

class TemperatureHolder{

    private static ITemperature HOT = new HotTemperature();

    private static ITemperature NORMAL = new NormalTemperature();

    private static ITemperature COLD = new ColdTemperature();

    private static List<ITemperature> temperatures = new ArrayList<ITemperature>();

   

    static{

        temperatures.add(HOT);

        temperatures.add(NORMAL);

        temperatures.add(COLD);

    }

   

    public static ITemperature getTemperature(int temperature){

        for(ITemperature each : temperatures){

            if(each.isInTemperatureRange(temperature)) return each;

        }

   

        return null;

    }

} 


코드를 더 간결하게 만들어 보고자 약간의 무리수를 두었다. HOT, NORMAL, COLD가 모두 같은 타입인데 따로 떨어져서 사용되는 것을 방지하기 위해 List를 만든 것이다. TemperatureHolder가 생성되지 않고 사용될 수 있게 하기 위해서는 이 과정을 생성자가 아닌 static {} 구문 안에 넣어 주어야 한다. 그래야 실제 사용되기 전에 리스트에 객체들이 들어간 상태가 된다. 어쨌든 이 코드는 중간 결과물이라서 결국에는 모두 제거될 것이다.


자 이제 getTemperature() 메소드를 보자. 이전에는 if문들이 나열되어 있었는데 이제 단순히 각 객체에게 온도 범위를 물어보는 형태로 변경되었다. 적어도 이 상태라면 새로운 온도 상태 객체가 추가되더라도 getTemperature() 메소드가 변경되는 일은 없을 것이다.





이제 온도 상태 객체들의 생성 문제로 넘어가보자. TemperatureHolder 클래스는 Holder로서의 역할 때문에 각 하위 객체를 생성하여 가지고 있어야 한다. 이 점 때문에 새로운 온도 객체가 생성되면 TemperatureHolder 클래스도 변경이 되어야 한다. 이것을 자동화 할 수 있는 방법이 없을까?


1. 자 몇가지 특성을 먼저 이야기 해보겠다. 각 온도 상태 객체들은 타입이 ITemperature이다. 그리고 생성 이후 변경되지 않고 계속 유지되기 때문에 "static으로 선언"된다. 즉 소프트웨어가 실행되기 전에 모두 생성이 완료 된다.

2. 모두 ITemperature를 구현하고 있으므로 모두 "같은 상위 타입"을 가진다.

3. 온도 상태 객체는 "여러개" 이다.


위의 1~3에서 ""로 묶인 특성들을 잘 살펴보자. 어떤 프로그래밍 요소가 떠오르지 않는가? 바로 enum 타입이다. enum 타입은 enum 상위 클래스와 하위 클래스의 집합이다. 또한 static으로 선언되고, 여러 하위 클래스가 나열된다. 따라서 저 특성에 매우 적합한 형태이다. 자 enum을 가지고 각 상태 객체를 구현해 보자.


enum Temperature implements ITemperature{

    HOT{

        public String toDbString(){ return "HOT";}

        public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

        public boolean isInTemperatureRange(int temperature) { return temperature > 30; }

    },

    NORMAL{

        public String toDbString(){ return "NORMAL";}

        public void operateThermostat(Thermostat thermostat){thermostat.stop();}

        public boolean isInTemperatureRange(int temperature) { 

            return temperature <= 30 && temperature >= 0; 

        }

    },

    COLD{

        public String toDbString(){ return "COLD";}

        public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

        public boolean isInTemperatureRange(int temperature) { return temperature < 0; }

    }

    ;

}


우선 이 enum 클래스는 ITemperature 인터페이스를 구현하고 있다. 따라서 외부에서는 자연스럽게 ITemperature로 사용이 가능하다. 또 enum 타입이므로 중복 생성되는 경우가 발생하지 않는다. 그리고 각 하위 클래스는 자신의 특성에 맞게 인터페이스를 구현하고 있다. 따라서 외부에서 사용할 때에는 이전에 개별적으로 구현되었던 방식과 전혀 다를 바 없이 사용이 가능하다.


그러면 우리가 목적했던 TemperatureHolder 클래스는 어떻게 바뀌는지 보자. 당연히 ITemperature 하위 객체를 생성하는 부분은 수정이 되었겠지만 우리가 선언한 enum 기반의 객체들이 어떻게 사용되는지 확인이 필요할 것이다.


class TemperatureHolder{

    public static ITemperature getTemperature(int temperature){

        for(ITemperature each : Temperature.values()){

            if(each.isInTemperatureRange(temperature)) return each;

        }

        return null;

    }

}


뭔가 구현을 덜 해놓은 느낌까지 준다. 하지만 있을 것은 다 있다. 이미 enum을 통해 하위 객체들의 생성은 완료된 상태이다.(enum은 static 이므로 별도의 생성이 필요하지 않다.) 그리고 이전 중간 단계에서 static {}으로 List를 만드는 부분도 없어졌다. 


왜 인지는 getTemperature() 메소드를 보면 알 수 있다. 이전에 리스트를 통해 for문을 돌던 것이 Temperature.values()를 통해 for문을 도는 것으로 변경되었다. enum의 values() 메소드는 enum 클래스 하위 객체들을 배열로 만들어 제공하는 메소드이다. 따라서 for문에 사용될 수 있다. 그리고 이 점이 특히 enum을 사용할 때 좋은 점인데, 만약 새로운 하위 객체가 생성되었다 하더라도 values() 메소드는 새로 추가된 하위 객체를 포함한 배열을 리턴해 준다. 따라서 TemperatureHolder 객체와 같이 enum.values() 메소드를 사용하는 곳에서는 코드를 수정할 필요가 없어진다.


이로써 TemperatureHolder 클래스가 가지고 있던 설계적인 문제들이 모두 해결 되었다.

Posted by 이세영2
,

객체지향으로 만들어진 객체는 능동적인가? 수동적인가?


어떤 책에서는 객체지향을 매우 능동적으로 서술하고 있다. 마치 객체들이 살아서 움직이면서 유기적으로 행동하는 것으로 묘사한다. 어떤 특정한 상태에서는 그것이 맞는 말이긴 하다. 하지만 내가 보는 객체지향의 세계는 상당히 수동적이다.


객체지향을 능동적으로 보는 관점에서 항상 시작은 "어느 객체가 무슨 할 일이 있을 경우"이다. 즉, 할 일이 있을 때 자기가 스스로 해야 할지, 남에게 시켜야 할지(위임)를 결정한다. 만약 해야 할 일이 크다면 위임될 가능성도 크다. 따라서 참조하는 객체에게 자신이 해야 할 일 일부 또는 전부를 시킨다. 따라서 해야 하는 일의 관점과 그 일을 하는 객체의 관점에서 보면 객체는 능동적인 것이 맞다.


하지만 객체지향 전체 세계를 놓고 보면 객체지향의 세계는 매우 수동적이다. 그 이유는 어떤 "이벤트"에 의해 객체가 동작하기 때문이다. 우리는 이 "이벤트"를 주로 메시지, 공개 함수, 공개 멤버 함수, 메소드, 인터페이스, API 등 다양한 이름으로 부른다. 절차지향 또는 구조적 언어에서 객체지향으로 넘어오면서 수동적인 것에서 능동적인 것으로 변화했다고 묘사하는 경우가 종종 있지만 결국 누군가가 시키지 않으면 일하지 않는 것이 기본이다. 맴버 함수 호출이 없는 경우 객체는 아무 일도 하지 않는다. 이것이 기본이다. 이벤트 없이 객체는 움직이지 않는다.


그러면 그 이벤트의 시작은 어디일까? 몇가지로 구분해보자.


1. 인터럽트

OS 레벨에서의 이벤트를 말한다. 통신, UI 조작(키보드나 마우스 이벤트), 타이머 등이 이에 해당된다. 이들은 여러 루트를 통해 이벤트를 객체에게 전달한다. 이러한 이벤트가 객체로 흘러 들어오는 순간 객체는 동작하게 된다. 그리고 이런 이벤트들은 아까 이야기 했던 공개 맴버 함수를 호출하는 형태로 이루어진다. 결국 객체는 시스템의 관점에서 보면 수동적인 것이다.


2. 쓰레드

쓰레드는 유일하게 객체가 능동적으로 보일 수 있는 부분이다. 쓰레드가 하는 일을 크게 나눠보면 두가지가 있는데, 하나는 이벤트를 기다리는 것, 하나는 주기적인 동작이다. 이벤트를 기다리는 것은 1번의 인터럽트와 크게 다를 바가 없다. 주기적인 동작은 일련의 동작들을 반복적으로 수행한다는 개념이다. 이것 정도가 그나마 능동적이라고 할 수 있겠다.


이 이야기를 꺼낸 이유는 간단하다. 무상태 프로그래밍을 수행하기 위해서 상태를 발생시키는 원인이 어디에 있는가를 알아보기 위해서다. 1번의 인터럽트의 경우 당연히 외부적인 요인이므로 상태를 발생시킨다. 그리고 우리가 다루었던 것처럼 외부적인 상태는 FactoryMethod 패턴, Holder 패턴 등을 이용하여 상태를 객체화 시킴으로써 해결할 수 있다. 


남은 것은 쓰레드인데 쓰레드가 어떤 상태를 계속 만들어 낸다고 해서 그것이 객체 세계 전체에 영향을 미치게 할 필요가 있을까? 이미 외부적인 상태에 대해 해결하는 방법을 알려 주었다. 그 방법을 쓰레드가 생성하는 상태에 적용하지 못할 이유가 있을까?


당연히 그렇게 하지 못할 이유는 전혀 없다. 어떻게 상태가 생성되었든 간에 그 상태가 발생한 객체는 여러 방법들을 이용해서 상태를 객체화 시킬 수 있다. 그러고 나면 모든 일은 객체와 객체간의 관계 문제로 바뀐다. 그리고 이 문제를 해결하는 해결책 역시 나와 있다. 상태 패턴, 전략 패턴 등이 바로 그것이다.


결국 객체 세계 내부에서는 (쓰레드를 제외하고는) 상태가 발생하지 않아야 한다.

Posted by 이세영2
,

상태를 전달하는 것은 프로그래밍에서 매우 조심해야 할 행위이다. 이미 만들어진 상태 조차도 상태를 객체화 하는 과정을 거쳐 상태를 보지 않아도 동작이 가능한 코드를 만들려고 노력하는데 상태를 전달한다는 것은 더더욱 피해야 할 일이다.


상태를 매개변수로 다른 객체에 전달하는 것은 다른 객체에게도 상태에 따른 동작을 강요하는 행위이다. 이것이 반복되다 보면 모든 코드들이 상태에 기반하여 동작하게 될 것이다. 이곳 저곳에서 조건문이나 제어문이 남발되고, 코드는 점점 이해할 수 없을 수준의 복잡성을 가지게 될 것이다.


개인적으로는 인정하기 어렵지만, 만약 객체의 동작 중에 새로운 상태가 발생했다면, 이것은 역시 객체화를 통해 해결할 수 있다. 이미 3. 무상태 프로그래밍 : 객체의 세계 외부와의 소통에서 보여준 것처럼 상태 값을 상태별 객체로 만들고, 이것을 상태 값 대신 넘겨주는 것이다.


public class ObjectWorld {

    ITemperature temperature;

    Thermostat thermostat = new Thermostat();

    DataBase dataBase = new DataBase();

    public void receiveTemperatureValue(int temperatureValue){

        temperature = TemperatureHolder.getTemperature(temperatureValue);

        temperature.operateThermostat(thermostat);

        dataBase.insertTemperature(temperature);

    }

    public static void main(String[] args) {

        ObjectWorld world = new ObjectWorld();

        world.receiveTemperatureValue(50);

    }

}


밑줄 친 부분은 데이터베이스 쪽에 상태별 객체를 넣어주는 코드이다. 수정 이전의 코드에서는 상태별 객체 대신 온도 객체가 그대로 들어갔었다. 상태별 객체가 들어가고 나면 이미 데이터베이스에서 필요한 정보, 즉 현재 상태를 나타내는 문자열 정보는 상태별 객체가 가지고 있으므로 기능을 수행하는데 아무런 지장이 없다. 상태 변수가 발생했다고 해서 굳이 상태 변수를 다른 객체에 까지 전파시킬 이유가 없다.


상태 전파

만약 다른 사람이 만든 코드의 인터페이스가 갖가지 상태 값을 받게 되어 있고, 혹시라도 그 사람과 사이가 좋지 않다면 이것은 그 사람을 야근에 빠뜨릴 좋은 기회가 될 것이다. 만약 그 사이 좋지 않은 사람이 바로 자신이라면 야근의 늪에 빠지지 않기 위해서 인터페이스를 변경하는 편이 좋다.


개인적으로 느끼기에 상태 전파는 객체지향에서 나쁜 코드로 인식하는 거의 모든 요소를 모두 가지고 있다. 잦은 조건문 사용을 유발하여 코드의 가독성을 떨어 뜨린다. 매우 간결하고 쉬웠어야 할 코드를 조건 체크로 인하여 복잡하게 만든다. 멀티 쓰레드 환경에서라면 상태 값이 모든 객체에 동일하다고 보장할 수 없으므로 안정성에도 영향을 미친다. 상태 전파는 상태 전파를 낳게 되고, 이로 인하여 중복 코드들이 나타나게 된다. 전파된 상태를 수정하는 일은 너무나 힘든 일이다. 


나는 외부와의 소통을 담당하는 코드가 아닌데도 조건문들이 코드에 등장하는 것은 다형성의 위배나 객체지향에 대한 몰이해의 산물이라고 보는 편이다. 만약 조건문 중첩이 일어났다면 거의 확실하다. 그리고 이러한 문제가 발생하는 원인 중에서 가장 큰 것은 바로 상태 전파 때문이다. 이것을 피하고 싶다면 상태를 상태별 객체로 변경해야 한다.

Posted by 이세영2
,

무상태 프로그래밍을 위해서 객체가 갖추어야 할 것은 두가지이다.


1. 비밀을 유지하라

2. 독립적이 되어라


비밀을 유지하라

간단하게 말하면 상태 확인 메소드를 제공하지 않아야 한다. getter 함수를 제공하지 말라는 얘기가 아니다. 물론 getter 함수도 최대한 자제해야 하는 것이긴 하지만 그보다 더 큰 문제를 발생시키는 것은 상태를 알려주는 메소드이다. 상태를 알려주면 상대 객체는 그 객체 상태에 종속적인 행위를 하게 된다. 이것을 막으려면 상대가 상태를 몰라도 자신에게 책임을 맡길 수 있도록 디자인 해야 한다.


가장 먼저 생각해야 하는 부분은 변수를 선언하는 것이다. 변수를 선언하는 이유를 곰곰히 생각해 봐야 한다. 그 변수가 꼭 필요한 것인지, 꼭 필요하다면 단순히 값을 저장하려는 목적인지, 아니면 상태를 표시하려는 목적인지 생각해 봐야 한다. 만약 조금이라도 값이 상태 처럼 사용된다면, 즉 변수의 값에 따라 동작을 바꿔야 하고, 그러기 위해서 조건문을 써야 한다면 바로 객체로 변환할 방법을 생각해 봐야 한다.


두번째는 그 변수값을 어떻게 사용할 것인지이다. 만약 getter를 제공해서 외부로 내보내야 한다면 외부 객체들은 그 변수를 어떤 용도로 사용하게 될지를 생각해야 한다. 만약 getter를 제공해서 외부에 있는 객체들이 자신의 상황을 알게 해야 하는 것이라면 객체의 설계에 대해서 다시 생각해 봐야 한다.


독립적이 되어라

상대의 상태를 보고 행위하려고 하지 말아야 한다. 뭔가를 해야 한다면 바로 해야 한다. 상대 객체의 상태를 보려고 하다보면 조건문이 생기고 코드는 복잡해진다.




둘 중 어느 것이 중요하냐고 물어본다면 당연히 비밀을 유지하는 쪽이다. 아무것도 제공하지 않는데 사용할 수는 없다. 중요한 부분은 비밀을 유지하면서도 기능을 구현할 수 있는 방법은 무엇인가 이다.


이미 한가지 예는 알려 주었다. 아래 코드는 온도에 관한 코드이다.


class Temperature{

    int temperature;

    public static final int HOT = 0;

    public static final int NORMAL = 1;

    public static final int COLD = 2;

    public void setTemperature(int temperature){

        this.temperature = temperature;

    }

    public int getState(){

        if(temperature > 30) return HOT;

        if(temperature >= 0) return NORMAL;

        else return COLD;

    }

}


이 코드는 자기 상태를 외부에 자꾸 알려 주도록 되어 있었다.(getState() 함수) 이로 인해 다른 객체들은 이 객체의 상태를 확인하지 않고서는 동작할 수 없었다. 하지만 이 Temperature 객체를 상태별로 분리한 후 전체 소스에서 이 객체의 상태를 확인하지 않고도 동작이 가능하게 변경되었다. 이것을 디자인 패턴에서 상태 패턴이라고 부른다.

문제점을 해결한 아래의 소스는 전형적인 상태 패턴이다.


interface ITemperature{

    public String toDbString();

    public void operateThermostat(Thermostat thermostat);

}

class HotTemperature implements ITemperature{

    String dbString = "HOT";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

}

class NormalTemperature implements ITemperature{

    String dbString = "NORMAL";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){thermostat.stop();}

}

class ColdTemperature implements ITemperature{

    String dbString = "COLD";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

}


이 상태별 객체는 이용할 수도 있고 거꾸로 상태별 객체에 의해 다른 객체가 이용될 수도 있다. 상태별 객체를 이용하는 경우는 상태별 객체에 있는 메소드가 제공하는 정보를 이용하는 것만으로도 목표가 달성되는 경우이다. 전형적으로 toDbString() 객체가 그러하다. DataBase 클래스에서는 이 객체의 메소드 중에서 toDbString() 함수만 호출해서 그 결과를 얻으면 끝이다. 이 결과값을 가지고 다시 상태를 확인하려고 하지 않기 때문에 그것 만으로 충분하다.


약간 변경이 필요했던 부분은 Thermostat 부분이다. Thermostat은 상태를 값으로 이용하려 하기 보다 자신의 행위를 변경하려고 했었다. 자신의 행위 변경을 위해서 다른 객체의 상태가 필요하다는 것은 다른 객체의 상태에 종속적이라는 뜻이다. 따라서 다른 객체의 상태를 가지고 자기가 무언가를 하기 보다는 종속적인 방식, 즉 Thermostat 객체가 상태별 객체에 매개 변수로 넘겨지는 편이 더 자연스럽다. 따라서 operateThermostat() 메소드에 의해 Thermostat 객체의 행위가 호출되도록 하면 코드가 더 간결해진다.


무상태 프로그래밍에서는 다른 객체의 상태에 관심이 없다. 단지 자기가 할 일에 집중하면 된다. 만약 다른 객체의 상태를 꼭 확인해야 하는 경우라면 다른 객체에 종속적이라는 의미이다. 따라서 하고자 하는 일을 직접 하지 말고, 상대 객체에 메소드를 정의하고 매개 변수로 객체를 넘겨야 한다.

Posted by 이세영2
,

null을 리턴한다는 것은 리턴 타입이 객체라는 의미이다. null은 객체가 아니다. null과 객체는 같지 않으므로 객체로 취급하면 에러가 발생한다. 그렇기 때문에 null은 "예외적인 처리"를 해 주어야 한다. 결국 null을 리턴할 수 있다는 것은 받는 쪽에서는 null인지 아닌지를 체크해야 한다는 것이다. 이것은 전형적인 상태의 모습이다.


자 취급하기 좋은 예를 들어 보자. 우리는 종종 List에서 특정 조건에 맞는 객체를 꺼내오고 싶을 때가 있다. 그래서 다음과 같이 프로그래밍을 한다.


List<Person> persons = new ArrayList<Person>();

public List<Person> getByName(String name){

    if(persons.size() == 0) return null;

    List<Person> result = new ArrayList<Person>();

    for(Person each : persons){

        if(each.getName().equals(name)) result.add(each);

    }

    return result;

}


여기서 눈여겨 볼 부분은 if(persons.size() == 0) return null 이다. 이 코드는 원하는 객체가 없을 경우 List 객체를 만들지 않고 바로 리턴하는 코드이다. 어떤 사람들은 최대한 연산량을 줄이고 List 객체를 새로 생성하는 것을 방지한다는 면에서 좋다고 생각할지도 모른다. 하지만 이 코드는 코드 자체로도 그다지 큰 효과를 내지 않을 뿐더러 null을 리턴함으로써 메소드를 사용하는 입장에서 null을 한번 더 체크하게 만든다는 문제가 있다. 만약 새로운 객체를 생성하지 않는 것이 그렇게 중요하다면 다음과 같이 하면 된다.


if(persons.size() == 0) return Collections.emptyList();


이미 Java에서는 위와 같이 빈 List를 가지고 있다. 저렇게 리턴하면 메소드를 사용하는 입장에서는 null 체크를 따로 하지 않아도 된다.


Posted by 이세영2
,

이해를 돕기 위해서 예제를 사용해야 할 때가 온 것 같다.


일단 앞서서 객체의 세계와 외부 세계를 구분했다. 객체의 세계란 객체지향 언어를 통해 객체와 객체간의 관계로만 이루어진 소프트웨어 내부를 말한다. 외부 세계란 그 객체 세계와 소통하는 모든 다른 시스템들을 말한다.


이 글에서 나타내야 하는 부분이 바로 객체 세계와 외부 세계의 소통이므로 다음과 같은 예를 들어 보겠다.



Object World는 객체 세계를 말한다. 우리가 만드는 세계이다. 온도계와 DB는 외부 세계이다.

우선 온도계 값은 객체화 되어 있지 않다. 따라서 그냥 int 타입의 변수 값으로 온다고 가정한다.


DB는 온도 값도 저장하지만 온도의 상태도 저장한다고 가정한다. 즉, hot / normal / cold를 저장한다고 한다. 알기 쉽게 기준을 잡아 주자면 0도 미만은 cold, 30도 이상은 hot, 나머지는 normal이다.


그리고 우리의 객체 세계 내에서도 온도의 상태에 따라(즉 hot / normal / cold에 따라서) 여러가지 동작을 취한다고 가정한다. 객체 세계 내부에 온도 조절기(Thermostat)가 있어서 hot일 때는 cooling()이, cold일때는 warming()이 동작 해야 한다고 하자.


이런 상황에서 일반적인 프로그래밍의 방향을 이야기 해보자.


1. 일단 온도계의 온도를 받아야 할 것이다.

2. 온도에 따라서 다른 동작을 취할 수 있어야 한다.

3. DB에 저장할 때는 온도에 따라서 다른 상태 필드 값을 생성해 주어야 한다.


가장 손쉬운 방법으로 코딩을 해보자. 온도가 전달되어 들어 오면 온도조절기에 그 값을 전달해 주고 온도 조절기는 상태에 따라 온도를 조절한다. 그리고 그 값에 따라서 DB에 저장한다. 이것을 코드로 나타내기 위해 몇 개의 클래스를 정의하도록 하겠다.


1. ObjectWorld 클래스 : 시스템 전체를 담는 클래스.

2. Temperature 클래스 : 온도를 담고 있는 클래스

3. Thermostat 클래스 : 온도 조절기 클래스.

4. DataBase 클래스 : DB에 온도를 저장하는 클래스.


코드를 먼저 보기 전에 클래스 다이어그램을 보자.

ObjectWorld는 DataBase / Thermostat / Temperature 객체를 가지고 있다. ObjectWorld 객체가 온도값을 받으면 Temperature 객체의 setTemperature() 함수를 호출하여 온도를 갱신한다. 그리고 DataBase와 Thermostat에 이 객체를 전달해 주고 각자 필요한 동작을 수행하도록 한다. 이를 코드로 나타내 보면 다음과 같다.


Temperature 클래스

class Temperature{

    int temperature;

    public static final int HOT = 0;

    public static final int NORMAL = 1;

    public static final int COLD = 2;

    public void setTemperature(int temperature){

        this.temperature = temperature;

    }

    public int getState(){

        if(temperature > 30) return HOT;

        if(temperature >= 0) return NORMAL;

        else return COLD;

    }

}


온도를 저장하고, 온도의 상태를 계산하여 외부에 전달해준다. 온도 상태를 계산해서 주는 것은 매우 좋은 선택이다. 그렇지 않으면 각 객체들은 오직 temperature 값에 의존해서 상태를 계산해야 한다. 이것은 코드의 중복을 만들어 낸다.


DataBase 클래스

class DataBase{

    public void insertTemperature(Temperature temperature){

        switch(temperature.getState()){

        case Temperature.HOT :

            System.out.println("insert:HOT");

            break;

        case Temperature.NORMAL :

            System.out.println("insert:NORMAL");

            break;

        case Temperature.COLD :

            System.out.println("insert:COLD");

            break;

        }

    }

}

온도 상태에 따라서 데이터베이스에 저장한다.


Thermostat 클래스

class Thermostat{

    public void onTemperatureChanged(Temperature temperature){

        switch(temperature.getState()){

        case Temperature.HOT :

            cooling();

            break;

        case Temperature.NORMAL :

            stop();

            break;

        case Temperature.COLD :

            warming();

            break;

        }

    }

    private void cooling(){ System.out.println("Cooling"); }

    private void warming(){ System.out.println("Warming"); }

    private void stop(){ System.out.println("stop"); }

}


Temperature 객체를 받아서 온도 상태에 따라 온도를 조절한다.


ObjectWorld 클래스

public class ObjectWorld {

    Temperature temperature = new Temperature();

    Thermostat thermostat = new Thermostat();

    DataBase dataBase = new DataBase();

    public void receiveTemperatureValue(int temperatureValue){

        temperature.setTemperature(temperatureValue);

        thermostat.onTemperatureChanged(temperature);

        dataBase.insertTemperature(temperature);

    }

    public static void main(String[] args) { // 테스트 코드

        ObjectWorld world = new ObjectWorld();

        world.receiveTemperatureValue(50);

    }

}

온도 값이 새로 들어 오면 온도 객체를 업데이트 하고, DataBase 객체와 Thermostat 객체에 넣어 준다.




객체들의 책임이나 역할에 별 문제가 있어 보이진 않는다. 다만 상태를 확인하는 코드가 여럿 보인다. 이런 상태의 코드를 개선할 수 있을까?


여기서 무상태 프로그래밍을 위해서 문제가 되는 부분은 상태 확인 메소드인 Temperature.getState() 함수이다. 이 함수가 생긴 이유는 무엇일까? 애초에 상태를 없애고 싶다면 각 상태를 객체화 했어야 했다. 그리고 그러기 위해서 ObjectWorld 객체의 receiveTemperature() 함수는 값을 받기만 하지 않고 객체를 생성하는 Factory Method를 이용해야 했다.


변경의 방향은 이렇다. Temperature 객체는 상위 타입으로만 사용한다. Temperature 객체의 하위 타입으로 Hot, Normal, Cold를 나타내는 클래스를 정의한다. 온도값이 들어오면 값에 맞는 객체를 생성해낸다. 그리고 DataBase나 Thermostat에서는 상태 확인 함수를 동작시키지 않고 바로 자신의 동작을 수행한다. 이를 위해서는 Thermostat 객체와 Temperature와의 관계를 변경할 필요가 있다. 온도 스스로가 Thermostat을 동작 시키도록 말이다.


우선 클래스 다이어그램을 다시 그려 보자. 



우선 각 온도 타입의 상위 타입인 ITemperature가 생겼다. 그리고 온도 값에 따라서 개별 Temperature 객체를 리턴해 줄 TemperatureHolder를 만들었다. 마지막으로 ITemperature가 Thermostat에게 의존하는 형태로 변경이 되었다.


클래스 다이어그램만으로는 원하는 내용을 알 수 없으므로 코드를 보도록 하겠다.


ITemperature 인터페이스

interface ITemperature{

    public String toDbString();

    public void operateThermostat(Thermostat thermostat);

}

인터페이스에는 각 상태별 객체가 해야 할 일들이 정의되어 있다. 즉, DB에 대해서는 자신의 상태를 DB에서 저장할 수 있는 String 타입을 리턴할 수 있도록 해준다. Thermostat에 대해서는 각 상태별 객체가 수행해야 할 일을 직접 하도록 한다.


ITemperature 인터페이스를 구현한 상태별 클래스

class HotTemperature implements ITemperature{

    String dbString = "HOT";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

}

class NormalTemperature implements ITemperature{

    String dbString = "NORMAL";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){thermostat.stop();}

}

class ColdTemperature implements ITemperature{

    String dbString = "COLD";

    public String toDbString(){ return dbString;}

    public void operateThermostat(Thermostat thermostat){ thermostat.cooling(); }

}

각 상태별 클래스는 오직 자기 상태에 해야할 일들만 수행한다. DB를 만났을 때, Thermostat을 입력 받았을 때 해야 할 일들이 정의되어 있다.


TemperatureHolder 클래스

class TemperatureHolder{

    public static ITemperature HOT = new HotTemperature();

    public static ITemperature NORMAL = new NormalTemperature();

    public static ITemperature COLD = new ColdTemperature();

    public static ITemperature getTemperature(int temperature){

        if(temperature > 30) return HOT;

        if(temperature >= 0) return NORMAL;

        else return COLD;

    }

}


이 클래스가 무상태 프로그래밍을 위한 핵심 클래스라고 할 수 있다. 우선 이 클래스는 유일하게 조건문이 들어가는 클래스이다. 이 클래스가 상태에 따라서 동작하는 유일한 클래스가 된다. 그리고 상태에 따라서 동작을 수행하는 것이 아니라 객체를 전달한다. 즉, 각 상태를 객체화한 Hot/Normal/ColdTemperature 객체를 전달해 줌으로써 상태별 동작을 객체별 동작으로 변경한다. 이후 상태별 객체를 사용하는 객체들은 실제 어떤 상태 객체가 있는지 알지도 못한다. 오직 ITemperature 인터페이스만 알 수 있을 뿐이다. 이 객체는 상태 값을 상태 객체로 바꾸고 그 상태 객체의 타입에 대한 정보까지도 감춰 버리기 때문에 이후 프로그래밍에서 "상태에 따른", "상태별" 동작은 전혀 발생하지 않는다.


일반적으로 상태의 변경이 일어났을 경우에는 그 상태에 맞는 클래스를 생성하는 방식으로 대응한다. 따라서 보통은 TemperatureHolder가 아니라 TemperatureFactory가 사용된다. 하지만 이 예제에서는 굳이 객체 생성을 중복해서 할 필요가 없었기 때문에 홀더 형태로 구현하여 부하를 줄였다.


DataBase 클래스와 Thermostat 클래스

class DataBase{

    public void insertTemperature(ITemperature temperature){

        System.out.println("insert:" + temperature.toDbString());

    }

}

class Thermostat{

    public void cooling(){ System.out.println("Cooling"); }

    public void warming(){ System.out.println("Warming"); }

    public void stop(){ System.out.println("Stop"); }

} 


가장 드라마틱한 변화를 가져온 것이 이 부분일 것이다. 상태를 체크하던 코드들이 모두 사라졌다. 너무 단순해져서 두 클래스를 묶어 보여줘도 괜찮을 정도다. DataBase에서 실행되는 코드는 실제로 단 한줄 밖에 없다. Thermostat의 변경은 조금 설명이 필요하다. 이전에는 Temperature 객체를 받아 동작했었다. 하지만 이제는 ITemperature 인터페이스에 매개변수로 입력되게 된다. ITemperature 인터페이스를 상속하는 상태별 온도 클래스들이 이 Thermostat 객체를 받게 되는데, 이 때 이미 각 상태별 객체들이 어떤 동작을 해야 할지는 정해져 있기 때문에 조건문이 전혀 필요하지 않다. DataBase 클래스나 Thermostat 클래스나 모두 자기가 할 일만 구현하면 되도록 변경되었다.


ObjectWorld 클래스

public class ObjectWorld {

    ITemperature temperature;

    Thermostat thermostat = new Thermostat();

    DataBase dataBase = new DataBase();

    public void receiveTemperatureValue(int temperatureValue){

        temperature = TemperatureHolder.getTemperature(temperatureValue);

        temperature.operateThermostat(thermostat);

        dataBase.insertTemperature(temperature);

    }

    public static void main(String[] args) {

        ObjectWorld world = new ObjectWorld();

        world.receiveTemperatureValue(50);

    }

}


대표적으로 수정된 부분은 ITemperature 객체를 얻어오기 위해서 TemperatureHolder 클래스를 이용하는 부분이다. TemperatureHolder 클래스로부터 상태별 객체를 받아오는 과정을 통해 이후에 동작하는 모든 코드에서 상태 체크 부분이 사라지게 된다.


또 한가지 부분은 기존에 Thermostat 객체에 Temperature 객체를 넣어 동작을 수행하던 것을 이제는 반대로 ITemperature 인터페이스 객체에 Thermostat 객체를 넣는 형태로 바뀌었다는 점이다. 이렇게 해야 상태를 체크하지 않고 Thermostat이 동작할 수 있다.


종합적인 변화

자 변화를 모두 종합해 보면 다음과 같다.


상태 체크를 위한 조건문, 제어문이 단 한 곳에서만 사용하도록 변경되었다. TemperatureHolder 객체 이외에 모든 객체는 무상태가 되었다. 더 이상 어느 객체가 다른 객체의 상태를 체크하지 않아도 된다. 따라서 상태에 따른 동작이 더 이상 확장되지도 않는다.


상태가 추가되거나 삭제되는 변화에 의해 수정되는 곳이 단 한 곳으로 줄어 들었다. 만일 새로운 상태(very hot / very cold)가 추가되었다면, 이전의 코드에서는 DataBase, Thermostat도 동시에 변경 되어야 했을 것이다. 하지만 switch - case 문의 단점은 변경사항이 발생한 경우 어디에 switch - case 문이 있는지를 알 수 있는 방법이 없다는 것이다. 이것은 잠재적인 버그의 원인이 된다. 새로 변경된 구조에서는 새로운 상태가 추가 되더라도 다른 코드에 영향을 전혀 미치지 않는다. TemperatureHolder 클래스를 제외한 다른 코드들에서는 상태 객체의 종류 조차 알지 못하기 때문이다.


이후 온도에 기반한 동작을 구현하기가 용이해졌다. 만약 UI가 추가되어 온도를 숫자로 나타내야 한다면 ITempterature 인터페이스에 온도를 숫자로 리턴하도록만 구현해 주면 된다. 만일 상태별 객체로 생성되지 않았었다면 상태를 체크하는 코드가 또 추가되었을 것이다.


가독성이 향상되었다. 조건문과 제어문은 코드의 길이를 길게 만들기도 할 뿐더러 쉽게 읽기 어렵게 만든다. 혹시 온도 뿐 아니라 습도와 같이 유사하고 같이 쓰일 법한 상태 변수가 또 들어온다면 코드의 지저분함은 이루 말할 수 없을 정도로 커지게 될 것이다.


결론

객체의 세계와 외부와의 소통은 단일한 곳에서 이루어져야 한다. 일반적으로 이 소통은 FactoryMethod 패턴, 또는 Holder 패턴에 의해 이루어지게 될 것이다. 그리고 그 소통이 끝난 이후에 외부의 상태는 객체화 되어야 하고 일단 객체화 된 이후에는 어떤 다른 객체들도 그 상태에 대해서는 알 필요가 없어야 한다. 이런 방식으로 프로그래밍을 하면 상태 전파 문제, 조건문 생성 문제가 모두 해결된다.


(외부와 소통하는 객체를 제외하고) 모든 영역에서 조건문과 제어문을 볼 수 없는 코드를 상상해보기 바란다. 우리가 얼마나 많은 시간 동안 if문, switch-case 문의 조건을 계산하는데 시간을 소비하고 있는지를 생각해 본다면 이 프로그래밍 방식의 이점을 충분히 이해할 수 있을 것이다.

Posted by 이세영2
,

일단 객체의 세계 내에서는 상태가 필요 없다는 점을 이야기 했다. 하지만 어떤 이유로든 상태가 생기고 조건문이 생기게 된다고 생각하는 사람들이 있을 것이다. 일단 이 문제를 해결하기 위해 상태가 발생하는 원인을 살펴보자.


1. 객체의 세계 외부와의 소통

2. null을 리턴하는 메소드

3. 상태 확인 메소드

4. 상태 매개 변수

5. 하위 캐스팅

6. enum


객체의 세계 외부와의 소통

객체지향 언어로 만들어진 시스템의 외부에서 내부로 흘러 들어오는 데이터는 모두 객체가 아니다. 이 데이터 중에는 분명 상태의 역할을 할 것들이 존재한다. 사용자가 라디오 버튼 중 어떤 것을 눌렀는가, 어떤 체크 박스를 체크했는가 하는 정보를 속성으로 변환 시키는 순간, 그것은 상태가 되고 조건문을 만들어 내기 시작한다. 또 외부 시스템은 자기가 동작하는 상태를 데이터화 해서 객체의 세계로 보낸다. 그것은 말 그대로 상태 정보다. 데이터베이스에서 튀어 나오는 값들 역시 모두 데이터이고, 이들 중에 어떤 것은 상태 정보일 것이다. 기본적으로 객체지향 내부와 외부의 소통에서는 상태가 무조건 만들어 질 수 밖에 없다. 이 부분을 어떻게 다루느냐가 무상태 프로그래밍의 핵심이다.


null을 리턴하는 메소드

Map에서 key를 찾을 때 key가 저장되어 있지 않는 경우 null을 리턴한다. 라이브러리를 디자인 하는 경우라면 이것은 용인될 수 있다. 하지만 자신이 만드는 메소드라면 null을 리턴하는 것은 좋지 않다. null을 리턴하는 순간 메소드 외부에서는 null인지 아닌지를 체크해야 한다. 이것은 조건문을 만들어 낸다. null 값은 리턴되는 순간 상태 역할을 하게 된다.


상태 확인 메소드

boolean isSomething()과 같은 메소드가 대표적이다. 이것은 보통 if문과 함께 쓰이면서 조건문을 만들어 낸다. 일을 위임할 객체의 상태를 확인할 것이 아니라 그냥 일을 시켜야 한다. 상태에 따른 동작은 위임 객체에서 알아서 할 일이다.


상태 매개 변수

상태를 다른 메소드에 집어 넣는 행위이다. 종종 객체가 다른 객체에게 상태 변수를 집어 넣기도 한다. 이것은 상태에 따른 동작, 즉 조건문을 다른 메소드나 다른 객체에게 전파하는 행위다. 이것은 상태를 객체 세계에 전파한다. 그리고 조건문을 만들고 코드의 가독성을 떨어 뜨린다.


하위 캐스팅

객체지향에서는 하위 타입 은닉을 중요시한다. 하위 타입을 확인하는 순간, 하위 타입을 위한 전용 코드가 된다. 하위 타입은 보통 여러개 존재하므로 비슷한 코드가 늘어나는 문제가 생긴다. 특히 하위 타입 확인은 그 자체가 조건문이다.


IFactory factory = new XpFactory();

if(factory instanceof XpFactory){

      ……

} 



enum 타입

enum은 열거형 타입을 말한다. 열거형은 상태를 가독성 있게 표시하는 대표적인 방식이다. 따라서 상태의 대표격이라고 말할 수 있다. 이것이 상태의 대표가 된데는 C언어의 영향이 크다. 사실 Java에서는 enum도 객체화 되었고, enum을 사용해도 조건문을 사용하지 않을 수 있는 방법들이 많이 있다. 하지만 C언어에서 enum은 int 타입이다. 즉 변수와 다름 없이 쓰이고 가독성도 높아서 거리낌 없이 switch - case 문과 함께 사용된다. enum은 번식력도 높아서 한번 switch - case문과 함께 사용되면 계속해서 사용된다.


여기서 하나 짚고 넘어가고 싶은 것이 있다. 상태라는 것이 꼭 속성으로 선언된 것은 아니라는 점이다.


잘 생각해 보면 조건문이 체크하는 모든 것은 상태다. 상태의 모습을 코드로 확인해 보면 다음과 같다.


상태의 여러가지 모습

boolean flag;

int state;

public boolean isSometing();

if(factory instanceof XpFactory){} 


enum Enum{

    A,

    B,

    C;

}

Enum type;

switch(type){

    case A :

        break;

    case B :

        break;

    case C :

        break;

}


flag는 대표적인 상태 변수다. flag와 if문은 너무나도 잘 어울리는 조합이다.

state는 상태를 나타내는 대표적인 변수명이다. 마치 트레이드 마크 같은 것이다. 이것은 switch - case 문과 매우 잘 어울린다.

boolean 타입을 리턴하는 메소드도 상태의 다른 모습이다. 이 역시 if문과 매우 잘 어울린다.

하위 타입 확인은 if 문과 쓰이는 것이 너무나도 전형적인 형식이다.

enum과 switch - case는 떼어놓기 힘든 커플이다.


이런 모습의 코드는 너무나도 자주 접하고 만드는 것들이라서 과연 저것들을 모두 없애고 "무상태 프로그래밍" 이라는 것을 할 수 있을까? 하는 의문이 생길 것이다.


나는 가능하다고 믿고 있다. 그리고 그것이 코드의 가독성을 높이고 객체지향의 세계를 온전하게 이해하는데 도움을 줄 것이라 생각하고 있다. 다음에 작성될 글들은 실제 상태 기반 프로그래밍 방식을 어떻게 무상태 프로그래밍 방식으로 바꾸는지를 보여줄 것이다.

Posted by 이세영2
,

프로그래밍을 계속 하다 보면 어떤 객체지향 설계 원칙과 같은 좋은 원칙들에 대한 관심이 높아지게 된다. 객체지향 이론과 경험적 지식이 풍부한 경우에는 이미 원칙들을 잘 지켜가면서 프로그래밍을 하고 있겠지만, 아무래도 경험이 많이 쌓이기 전에는 확신을 가지고 자신의 코딩 방식이 맞는지를 알기가 어렵다. 중복 방지, 다형성, 타입 은닉 등 많은 원칙들이 있고 인터넷을 뒤져 보면 그에 대한 설명이 나오긴 하지만 실질적으로 도움을 주기 보다는 선언이나 가이드에 가까운 경우가 많다. 따라서 실제 코딩을 할 때 염두에 두고 실천할 수 있을 만큼 구체적인 가이드가 필요하다는 생각을 하게 되었다.


지금부터 이야기 할 것은 "무상태 프로그래밍"이라는 실천 원칙이다. 읽어 보다 보면 이 원칙이 왜 나오게 되었는지, 다른 객체지향 설계 원칙과의 관계가 어떻게 되는지 알게 될 것이다.


이 이론에는 몇가지 전제가 있다.


1. 객체지향의 구성 요소는 타입과 객체 참조 그리고 행위이다. 속성은 객체지향의 핵심 구성 요소가 아니다.

2. 속성이 상태를 만든다.

3. 이상적인 객체 세계는 무상태이다.

4. 상태는 객체 세계와 외부 세계간의 소통을 위해서만 발생한다.


속성은 객체지향의 구성 요소가 아니다

속성(쉽게 말해서 변수)은 비 객체지향 언어로부터 파생된 산물이다.

객체지향의 저변 확대를 위해 포용한 비 객체지향적 요소이다.

모든 속성은 객체로 치환이 가능하다.


속성이 객체의 세계에서 얼마나 이질적인 것인지 한번 생각해 보자. Java를 통해 이야기 해보자.


1. List<Integer>는 있지만 List<int>는 없다.


List는 객체이다. List<int>는 왜 없을까? 답은 List가 객체를 다루도록 만들어진 인터페이스이기 때문이다. 그러면 왜 primitive 타입은 다루지 않을까? primitive 타입을 다루려면 조건문을 통해 별도로 처리해 주어야 하기 때문이다. 객체가 아닌 이상 조건문 없이 객체를 다루는 코드로 primitive 타입을 사용할 수 없다. 이것은 단일 책임 원칙을 위반하는 것이다.


2. equals가 없다.


primitive 타입은 동치성 계산을 위해서 equals() 함수를 사용할 수 없다. Java에서는 primitive 타입의 동치성을 판단할 수 있는 방법이 없다.


3. 행위가 없다.


가장 문제 삼아야 하는 부분이다. 속성은 행위를 가지고 있지 않다. 속성이 행위하지 않기 때문에 다른 곳에서 속성의 행위를 대신 해주어야 한다. 속성이 하지 못하는 일을 대신 해주는 것이 문제를 일으킨다.


4. 객체가 대신 할 수 있다.


int 타입을 가지고 하는 일은 Integer 객체가 할 수 있는 일이다. 실제로는 Java에서 primitive 타입을 모두 객체로 구현해 두고 있고, 필요하다면 모두 객체로 선언하는 것도 가능하다.


속성이 상태를 만든다

속성은 변화되는 값을 가진다. 변화되는 값은 소프트웨어에 위험 요소가 된다. C언어에서 전역 변수를 생각해 보자. 전역 변수가 가진 값은 변화한다. 이 변화는 어느 함수를 통해 일어날지 예측이 불가능하다. 따라서 위험하다. 그래서 객체지향에서는 변수, 즉 속성을 객체 안에서만 사용하도록 만들었다. 그러면 모든 문제가 해결되었을까? 당연히 아니다. 전역 변수 역할을 하는 것을 목적으로 싱글톤을 만들기도 하고, 다른 객체에게 자기가 가지고 있던 속성을 매개변수로 넘기기도 한다. 이런 몇몇의 행위들 때문에 전역 변수를 없애려고 했던 노력이 무색해 지기도 한다.


속성은 변화되는 값을 가진다. 하지만 스스로 행위하지는 못한다. 객체는 다른 객체의 행위에 의해 자신의 행위를 변화시키지만 속성은 변화하되 행위는 하지 못한다. 그래서 행위를 시킨다. if(state == X) 이면 A, 아니면 B를 하라 라고 시킨다. 이런 속성, 즉 값을 가지는 것에 머물지 않고 행위를 변경하는 속성을 상태라고 한다.


상태는 번식하는 특성이 있다. 한번 속성이 "상태"라고 인식되는 순간, 한번 쓰였던 조건 문은 클래스 내에서 종종 다시 쓰인다. 만일 한 클래스 내에 상태가 두 개 이상이 되고, 이들이 서로 뒤엉켜 사용되면 조건문은 중첩으로 발생한다.


자 따라가 보면 이렇다. 속성이 상태를 만든다. 상태는 행위를 변경시킨다. 행위를 변경시키기 위해 조건문을 만든다. 조건문은 조건문의 번식과 중첩 조건문을 만든다. 조건문은 가독성을 떨어뜨린다.


애초에 이런 일이 발생하지 않도록 하기 위해서는 속성을 만들지 않는 것이 좋다.


이상적인 객체 세계는 무상태이다

인터페이스는 상태가 없다. 객체와 객체 간에는 상태가 존재하지 않는다. 따라서 어떤 객체가 다른 객체의 상태를 살필 필요도 없다. 그러면 객체 내부에서는 어떻게 해야 할까? 이 쯤에서 인정해야 할 것은 속성은 필수 불가결하다는 점이다. 어쨌든 속성은 필요하다. 다만 속성이 상태화 하는 것을 막아야 한다는 것이다. 어떻게? 속성을 객체로 만들어 버리는 것이다. 그리고 속성을 통해 변경하고자 했던 행위를 그 객체의 행위로 변경한다. 이런 방식으로 상태를 없애버린다. 속성의 변경은 객체에 대한 변경으로 치환한다. 객체와 객체와의 관계에서 상태는 존재하지 않기 때문에 이것은 충분히 가능하다.


상태는 객체 세계와 외부 세계간의 소통을 위해서만 발생한다

구체적인 방법은 다음 글에서 설명하겠다. 일단 상태가 어쩔 수 없이 발생하는 단 한가지 원인을 말해야 한다. 그것은 객체의 세계와 비 객체의 세계가 소통할 때 발생한다. 즉, 객체 지향으로 만들어진 소프트웨어와 연동되는 다른 시스템(예를 들어 통신을 통한 시스템 연동, 데이터 베이스, UI 등)에서는 상태 값을 객체로 만들어 전달해 줄 수 없다. 따라서 객체의 세계에서는 이를 어쩔 수 없이 값을 받아야 한다.

Posted by 이세영2
,

이 글을 통해 알리려고 하는 내용은 사실 객체지향에 국한된 얘기는 아니다. 그래서 우선 시작은 구조적 언어(C언어)를 중심으로 이야기 하려고 한다. 하지만 객체지향에서 이 부분이 더욱 강화되고 원칙화 된 것이므로 당연히 객체지향으로의 확장도 다루도록 하겠다.


속성과 행위

결과물은 객체지향 쪽으로 흘러갈테니 용어도 객체지향으로 시작하겠다. 속성이란 기존 구조적 언어에서는 변수라고 부르는 것이고, 행위란 함수라고 부르는 것이다. 변수는 데이터를 저장할 수 있는 것, 변경되는 것이며 함수는 제어, 연산을 수행하는 것, 변경할 수 없는 것으로 이해한다. 일반적으로는 맞는 이야기이지만 이 이해에 매몰되면 좋은 소프트웨어를 만들기 어려운 것이 사실이다.


속성을 통한 행위의 변경

행위(함수)는 변경될 수 없다. 이것은 사실이다. 이미 코드 상에서 행위를 정의한 이후에 이것을 런타임에 변경하는 것은 불가능하다. 하지만 상황에 따라서 다른 행위를 해야 하는 경우는 비일비재 하다. 그래서 이러한 경우에 보통 값에 대한 변경이 가능한 변수를 활용한다. 지금 상황(state)에서는 덧셈을 해야 한다면 상황을 표현하는 변수를 만들고, 이 변수 값(ADD_STATE)을 통해서 덧셈을 하도록 행위를 유도한다. 반대로 뺄셈을 해야하는 상황이라면 변수 값(SUB_STATE)을 변경해서 뺄셈을 하도록 유도한다. 이것은 속성과 행위의 특성을 보면 당연하다. 변경이 가능한 것(변수)을 이용해서 변경이 불가능한 것(행위)을 변경하는 것이다. 그래서 프로그래머들은 상황을 알려 주기 위해서 변수를 선언하고 이를 상태(state)라고 보통 부르고(flag를 사용하기도 한다), 행위의 변경은 이 상태 변수가 어떤 값을 가지는지에 따라 조건문을 작성하고 그에 종속된 행위들을 나열한다.


말로 설명하는 것보다 코드를 보는 편이 더 빠르겠다. 자 다음과 같은 코드를 한번 보자. 일단 이 문제는 구조적 언어로부터 출발해 보도록 하겠다.


상태에 따른 동작 변경

#define ADD_STATE (0)

#define SUB_STATE (1)

int calculate(int state, int a, int b){

    if(state == ADD_STATE){

        return a + b;

    }

    else if(state == SUB_STATE){

        return a - b;

    }

    return 0;

}

int main(){

    int result = 0;

    result = calculate(ADD_STATE, 5, 10);

    result = calculate(SUB_STATE, 5, 10);

}


위의 예제는 state 변수의 값에 따라서 덧셈과 뺄셈을 조건적으로 수행하는 코드이다. 위 코드를 요구사항 관점에서 기술해 보면 다음과 같다. "상태가 덧셈 상태이면 결과에 덧셈 결과를 저장하고, 상태가 뺄셈 상태이면 결과에 뺄셈을 저장하라." "상황에 따라서 다른 동작을 수행해야 한다"는 관점에서 살펴 봤을 때 위의 코드에 어떤 문제점이 있겠는가? state 변수의 값을 if문으로 살펴보는 것은 상황에 따라 다르게 동작해야 한다는 조건을 만족시키는 일이므로 전혀 이상하지 않을 만한 코드이다. 그리고 셀 수 없이 많은 프로그래머들이 이런 형태로 프로그래밍을 하고 있다. 어찌보면 상황에 따라서 다른 동작을 하라는 요구사항을 만족시키는 방법은 위의 코드 밖에 없어 보인다.


자 문제에 대한 관점을 조금씩 바꿔 나가기 위해서 다음과 같이 코드를 수정해 보도록 하겠다.


int add(int a, int b) { return a + b; }

int sub(int a, int b) { return a - b; }


먼저 위와 같이 덧셈과 뺄셈을 수행하는 코드를 별도의 함수로 독립 시킨 다음, 조건문이 있는 곳을 다음과 같이 수정한다.


     if(state == ADD_STATE) result = add(5, 10);

else if(state == SUB_STATE) result = sub(5, 10);


논리적인 결과는 첫번째 소스와 전혀 차이가 없다. 단지 덧셈 구문과 뺄셈 구문을 함수로 독립시켰을 뿐이니까.


그런 다음 한가지 생각을 떠올려 보자. state가 변수로서 하는 일은 오직 add()와 sub()함수를 바꿔서 실행 시킬 수 있도록 하는 것이다. 그렇다면 state 변수 대신 add() 함수와 sub 함수를 바꿔 넣어도 되지 않을까?


행위에 대한 직접 변경

int add(int a, int b) { return a + b; }

int sub(int a, int b) { return a - b; }

int calculate(int (*func)(int, int), int a, int b){

    return func(a, b);

}

int main(){

    int result = 0;

    result = calculate(add, 5, 10);

    result = calculate(sub, 5, 10);

}


생각하는 방식이 바뀐 것을 간단히 정리 해 보면 다음과 같다.


기존 : 상태 변수를 선언하고, 상태 변수에 상태 값을 할당하고, 상태 값을 체크하고 상태 값에 따라 덧셈과 뺄셈을 바꿔 계산한다.

변경 : 하고 싶은 행위에 따라 덧셈과 뺄셈을 하는 함수를 집어 넣는다.


간접적으로 행위를 변경하던 것을 직접 행위를 변경하는 것으로 바꾼 것이다. 이것이 일면 별 것 아닌 것처럼 보이지만 효과는 상당하다. 효과를 정리해 보면 다음과 같다.


1. 조건문이 사라졌다. 부수적으로 코드가 단순해졌다.

2. 이해하기가 쉬워졌다. 계산이 덧셈 뺄셈만이 아니라 곱셈 나눗셈 등 온갖 계산들이 나열되었다면 저 한가지 변화의 크기를 직감할 수 있을 것이다.

3. calculate 함수가 조건 체크에 실패 했을 때 return 0을 수행하던 것이 사라졌다. 이는 의도하지 않은 결과로 소프트웨어가 계속 동작하는 것을 방지해 준다.


생각보다 강력한 결과

이 방법은 상태 변수를 사용하는 모든 경우에 적용이 가능하다. 그 말은 상태 대신 행위를 직접 바꾸면 모든 상태 변수를 없앨 수 있고, 모든 조건문을 없앨 수 있다는 말이다. 이 결과로 유추해 보면 100 라인의 코드든 1000라인의 코드든 이론적으로는 조건문 없이 구현할 수 있다는 얘기다. 실제로 몇 달 전에 작성한 차트 그리기 프로그램(자바)은 5000라인(공백 제외)이었는데 조건문을 조사해 보니 60개 정도였다. 약 100라인에 if 문 혹은 switch 문이 한 개 있는 비율이다. 이것이 상태 변수를 없애고 행위를 직접 변경한 결과이다.


<바 차트, 라인 차트, 레전드 위치 변경, 레이블, 폰트 변경, 바 컬러 변경, 값에 따른 바 컬러 변경, 차트 흐름 고정, 테마 변경 등에 이르는 수많은 옵션들을 지원하도록 만들어졌지만 조건문은 단 60개 뿐이다>


상태에 종속적인 행위 변경

나는 상태를 선언하고, 상태에 대해 조건문을 작성하고 조건에 따른 행위를 수행하는 과정을 "상태 종속적 행위 변경"이라고 부른다. 지금도 많은 프로그래머들이 이러한 안티 패턴을 통해서 소프트웨어를 만들고 있다. 상태 변수를 선언하고, 상태에 따른 조건문을 작성하고 조건에 따라 다른 코드들을 집어 넣는다면 모두 "상태에 종속적인 행위 변경"이라는 안티 패턴을 사용하고 있는 것이다.


객체 지향에서의 행위 변경

객체지향 언어에는 상태 종속적 행위 변경 문제를 해결하는 디자인 패턴이 이미 정의 되어 있다. 전략 패턴과 상태 패턴이 바로 그것이다. 그리고 null 객체 패턴 + Special Case 패턴처럼 조금 덜 유명한 디자인 패턴도 상태 문제를 행위 문제로 바꿔주는 패턴들이다. 

전략 패턴에서는 A라는 객체가 B라는 객체에게 일을 시킬 때 일부 변경하고자 하는 행위가 있다면 이 행위를 객체화 하여 B에게 인자로 넘겨 준다. 이러한 방식으로 행위를 상태 변수 없이 변경한다. 맨 처음 예제가 사실 전략 패턴의 C언어 버전이라고 할 수 있다.


전략 패턴 예제

interface IFunction{

    public int calculate(int a, int b);

}

class Add implements IFunction{

    public int calculate(int a, int b){

        return a + b;

    }

}

class Sub implements IFunction{

    public int calculate(int a, int b){

        return a - b;

    }

}

class Calculator{

    public int calculate(int a, int b, IFunction function){

        return function.calculate(a, b);

    }

} 

< 맨 처음 예제를 Java로 전략 패턴 형태로 바꾸어 본 것이다. 함수 포인터 대신 객체를 넣는다는 점 만 다를 뿐이다>


상태 패턴은 상태가 필요한 객체가 각 상태를 객체화시킨다. 그리고 필요한 상태에 따라 상태 객체를 변경시킨다. 그러면 상태 객체의 메소드를 호출할 때마다 상태에 따라 행위가 바뀌게 된다.

null 객체 패턴은 인자로 넘어온 객체가 null인지 체크(null 상태인지 아닌지를 체크하는 것이다) 하는 대신 아무 동작도 하지 않는 Null 객체를 만들어 넘긴다. 그려면 객체를 받는 쪽에서는 넘어온 객체에 대해 null 인지를 체크하지 않아도 된다. 실행해도 아무 동작도 하지 않을 뿐이다.


객체지향 언어는 무 조건문/무 상태 언어이다

객체지향 언어를 사용하면 switch 문의 사용이 줄어든다는 얘기가 있다. switch - case 문에서 체크  되는 변수 대신 상위 클래스에 대한 레퍼런스를 선언하고, 각각 실행되는 문장들을 개별 객체화하여 레퍼런스에 할당해주면 switch-case문이 없어지기 때문이다.(이것이 디자인 패턴에서 말하는 상태 패턴이다) 여기서 조금 더 나가보면 이런 상태 변수들은 모두 객체 레퍼런스로 대체될 수 있고, 변수에 의해 바뀌는 행위, 즉 조건문 안의 행위들은 모두 개별 객체로 대체될 수 있다. 그러면 조건문들이 모두 사라진다.


객체지향을 이야기 할 때 다형성은 빼 놓고 이야기 할 수 있는 특징이다. 다형성은 여러 객체가 있지만 외부적 관점(즉 "타입" 관점)에서는 동일한 객체로 취급하겠다는 것을 의미한다. 동일하다는 것은 구분하지 않는다는 것이고, 구분하지 않는다는 것은 조건을 체크할 필요가 없다는 것이다. 그것은 상태에 따른 조건문 체크가 없는 것과 같다.


좀 더 나가보면 근본적으로 객체지향이 추구하는 방향은 상태를 보지 않겠다는 것이다. 오직 상대를 행위 대상으로만 바라본다. 인터페이스에는 변수가 없다. 즉 상태를 보지 않아도 된다는 의미다. 어느 객체가 동작 불가능한 상태에 있다고 가정하자. 이것을 외부에 알려줄 필요가 있을까? 객체가 동작 불가능한 상태라고 판단하면 그 객체는 실제 동작들을 객체화 해 두었다가 동작 객체를 동작 불가능한 객체로 바꾸기만 하면 된다. 그러면 외부에 자기 상태를 알려줄 필요가 없다.


객체와 객체 간에는 공유하는 변수가 없다. 상태는 변수의 부분집합이므로 상태도 공유되지 않는다는 의미이다. 인터페이스가 보여주는 것처럼 다른 객체의 상태를 몰라도 사용할 수 있다. 외부적 관점에서 상대방 객체는 상태가 없다. 그러면 조건문 없이 사용할 수 있다. 내부적으로는 상태를 객체로 대체한다. 그러면 상태 없이 구현될 수 있다. 그러면 조건문 없이 사용될 수 있다.


따라서 객체지향 언어는 무 조건문 / 무 상태 언어이다.


(구조적 언어도 함수 포인터라는 무 상태 언어적 특성을 가지고 있다. 하지만 이러한 관점이 널리 보급되지는 못했다. 아무래도 객체지향 언어에서 처럼 상태 제거를 위한 함수 포인터를 다중 중첩해서 사용하기가 어려운 문제가 있었을 것이다. 함수 포인터를 많이 사용한 코드는 사실 이해하기가 어렵다. 함수 포인터는 근본적으로 변수이고, 변수적인 타입만 체크 할 뿐 객체지향 언어의 "타입"처럼 특별한 제약이 없기 때문에 문제가 발생할 가능성이 많다.)


가장 단순한 적용 방법

우선 변수를 선언할 때 생각을 해보자. 변수가 상태를 나타낼 것이고, 조건문에 사용될 것인가? 그렇다면 그 변수는 필요 없다. 객체로 대체하라.


조건문을 만들기 시작했다. 조건문에 사용되는 변수가 하나 인가? 그러면 그 변수는 필요 없다. 객체로 대체하라.

조건문에 들어가는 변수가 여러개인가? 여러 변수를 하나의 변수로 바꿔라.


이 과정을 따르면 반복문을 제외하고는 단 한 줄의 코드도 들여쓰여지지 않은 코드가 만들어질 것이다.

Posted by 이세영2
,