Composite 패턴

5.디자인패턴 2016. 9. 17. 20:17

Composite 패턴은 "복합 객체(Composite Object)"와 "단일 객체(Leaf Object)"를 동일하게 취급하고 싶을 때 사용하는 패턴이다.

일반적으로 가장 이해하기 쉬운 비유는 디렉토리와 파일의 관계이다. 디렉토리는 복합 객체와 유사하고, 파일은 단일 객체와 유사한 특성을 가지고 있다. 디렉토리는 자기 내부에 같은 디렉토리 또는 파일을 가지고 있을 수 있다. 파일은 단일 객체이기 때문에 내부에 다른 객체를 가지고 있을 수 없다. 만약 디렉토리 - 파일 관계를 보다 단순화 시키고자 한다면 디렉토리와 파일을 동일한 타입으로 취급하는 방법을 사용할 수 있을 것이다.

물론 모든 동작들을 이와 같이 만들 수는 없다. 적어도 디렉토리와 파일이 유사한 동작을 할 수 있는 경우에만 가능하다. 아래의 예제에서는 파일의 갯수를 세는 동작과 파일의 라인 수(공백을 제외한)를 세는 동작에 대해서 정의하고 있다. 디렉토리는 파일이 아니고, 내부에 파일들을 가지고 있기 때문에 파일 개수를 세는 동작을 단일 객체인 파일에 위임할 수 있다. 또한 라인 수를 계산하는 동작에 대해서도 디렉토리는 항상 파일에게 이를 위임한다. 파일은 파일 갯수에 대해 물어보면 항상 1이라고 대답한다. 라인 수에 대한 응답은 파일을 열어 공백을 제외한 라인의 수를 제공하도록 한다. 만약 디렉토리와 파일이 동일한 상위 타입을 가지고 있다면 외부에서는 최 상위 디렉토리에게 파일의 수와 라인 수를 물어보는 것만으로도 전체 디렉토리 구조 상에서의 정보들을 얻어 낼 수 있을 것이다. 이런 복잡한 동작을 단순하게 만들어 줄 수 있는 것이 Composite 패턴이다.


Composite 패턴 다이어그램

Item 인터페이스는 디렉토리와 파일로부터 파일 갯수(getFiles()) 및 라인 수(getLines())를 얻어 올 수 있는 공통 인터페이스를 정의한다. 그리고 디렉토리를 구현한 DirectionItem과 파일을 구현한 FileItem은 외부에서 동일하게 취급될 수 있도록 Item 인터페이스를 구현한다. FileItem은 단일 객체로써, 자신의 파일 수( = 항상 1)와 라인 수를 계산하여 넘겨주도록 구현된다. DirectoryItem은 복합 객체로서 내부에 하위 디렉토리 및 하위 파일들을 담을 수 있는 List 를 소유한다.

DirectoryTree 클래스는 실제 디렉토리를 방문하면서 디렉토리 및 파일의 트리 구조를 만들고 최종 결과를 얻어 낼 수 있는 인터페이스를 가지고 있다. 이를 위해서 DirectoryTree 객체는 makeTree() 메소드를 통해서 Item 객체들을 생성하고 디렉토리 구조를 만드는 작업을 수행한다.

아래는 위의 클래스 다이어그램을 구현한 코드이다.


Composite 패턴의 구현

interface Item{

    public int getFiles();

    public int getLines();

}

class DirectoryItem implements Item{

    List<Item> children = new ArrayList<Item>();

    public void addChild(Item child){ children.add(child); }

    File file;

    public DirectoryItem(File file){

        this.file = file;

    }

    public int getFiles(){

        int result = 0;

        for(Item each : children){

            result += each.getFiles();

        }

        return result;

    }

    public int getLines(){

        int result = 0;

        for(Item each : children){

            result += each.getLines();

        }

        return result;

    }

}

class FileItem implements Item{

    File file;

    public FileItem(File file){ this.file = file; }

    public int getFiles(){

        return 1;

    }

    public int getLines(){

        Scanner scan;

        int result = 0;

        try {

        scan = new Scanner(file);

        while(scan.hasNextLine()){

            String line = scan.nextLine();

            if(!line.equals("")) result ++;

        }

        } catch (FileNotFoundException e) {

            System.out.println("File Not Found");

        }

        return result;

    }

}


Composite 패턴을 이용하여 디렉토리 트리를 만드는 DirectoryTree 클래스

class DirectoryTree{

    String path;

    public DirectoryTree(String path){ this.path = path; }

    Item items;

    public void makeTree(){

        File file = new File(path);

        items = tourDirectory(file);

    }

    private Item tourDirectory(File file){

        DirectoryItem directory = new DirectoryItem(file);

        File[] files = file.listFiles();

        for(File each : files){

            if(each.isDirectory()){

                Item childDirectory = tourDirectory(each);

                directory.addChild(childDirectory);

            }

            else{

                FileItem childFile = new FileItem(each);

                directory.addChild(childFile);

            }

        }

        return directory;

    }

    public int getFiles(){

        return items.getFiles();

    }

    public int getLines(){

        return items.getLines();

    }

} 


사용 방법

public static void main(String[] args) {

    DirectoryTree treenew DirectoryTree("C:/JavaDesignPattern/src/GofPattern/");

    tree.makeTree();

    System.out.println(tree.getFiles());

    System.out.println(tree.getLines());

} 


여러 다른 타입의 객체들을 동일한 타입으로 취급할 수 있게 되면 구현은 단순해지기 마련이다. Composite 패턴에서는 복합 객체와 단일 객체가 동일하게 취급된다. 그리고 자칫 복잡해질 수도 있는 복합 객체가 하위 객체들에게 작업을 위임하는 방식으로 동작함으로써 구현이 단순해졌음을 알 수 있다. 

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

Flyweight 패턴  (0) 2016.09.18
Chain Of Responsibility 패턴  (0) 2016.09.17
Iterator 패턴  (0) 2016.09.17
Enum Abstract Factory 패턴  (0) 2016.09.16
Proxy 패턴과 그 활용  (0) 2016.09.16
Posted by 이세영2
,

Iterator 패턴

5.디자인패턴 2016. 9. 17. 10:55

Java에서는 이제 언어적인 지원이 이루어지기 때문에 잘 쓰이지는 않지만 여전히 C++ 등에서는 자주 쓰이는 패턴이다. 보통 컬렉션들(C++에서는 컨테이너)에 쓰인다.

Iterator 패턴은 일련의 순서를 가진 데이터 집합에 대하여 순차적인 접근을 지원하는 패턴이다.


Iterator 패턴 클래스 다이어그램


Iterator 패턴의 구현

interface IIterator{

    public boolean hasNext();

    public Object next();

}

interface IContainer{

    public IIterator iterator();

}

class NameContainer implements IContainer{

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

    public void add(String name){

        list.add(name);

    }

    class NameIterator implements IIterator{

        String current;

        @Override

        public boolean hasNext() {

            if(list.size() == 0) return false;

            if(list.indexOf(current) != list.size()-1) return true;

            return false;

        }

        @Override

        public Object next() {

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

            if(list.indexOf(current) != list.size()-1){

                current = list.get(list.indexOf(current) + 1);

                return current;

            }

            return null;

        }

    }

    @Override

    public IIterator iterator() {

        return new NameIterator();

    }

}


사용방법

public static void main(String[] args) {

    NameContainer names = new NameContainer();

    names.add("Nick");

    names.add("Ceasar");

    names.add("Augustus");

    for(IIterator iter = names.iterator(); iter.hasNext();){

        String name = (String)iter.next();

        System.out.println(name);

    }

}



우선 데이터 집합을 가지고 있는 Container는 모두 Iterator를 제공해 줄 수 있어야 한다. 따라서 Container의 인터페이스인 IContainer와 Iterator의 인터페이스인 IIterator를 선언한다. 이는 어떤 Container를 구현하더라도 같은 방식으로 Iterator를 사용할 수 있도록 하기 위함이다. 구체 클래스들은 선언한 인터페이스들을 구현한다.

Iterator가 직접 데이터에 접근할 수 있도록 하는 것이 구현이 단순해 지므로 보통 Iterator는 Container의 내부 클래스로 구현하는 경우가 많다. 위의 예제에서도 NameContainer 내부에 NameIterator를 선언하였다. NameContainer로부터 NameIterator를 받아 오는 메소드는 iterator()인데, 이 메소드가 호출되면 NameContainer를 새로 생성하여 넘겨준다. 이렇게 해야 Iterator를 가지고 데이터에 접근하는 쓰레드가 많아도 문제 없이 동작이 가능하다.(데이터 집합의 추가/삭제로 인한 쓰레드 문제는 별도로 처리해 주어야 한다.)

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

Chain Of Responsibility 패턴  (0) 2016.09.17
Composite 패턴  (0) 2016.09.17
Enum Abstract Factory 패턴  (0) 2016.09.16
Proxy 패턴과 그 활용  (0) 2016.09.16
Observer 패턴  (0) 2016.09.16
Posted by 이세영2
,

미리 봐야 할 것 : Abstract Factory 패턴

Abstract Factory 패턴의 경우도 Factory Method 패턴과 마찬가지로 Factory 객체 생성의 이슈가 있다. 가만히 놔두면 Factory 객체가 자주 생성될 수 있고, 이를 해결하려면 Singleton 패턴을 적용해 주어야 한다. 이러한 문제를 해결하는 방법으로 enum을 활용한 방법이 있다. 이를 Enum Abstract Factory 패턴이라고 한다.


우선 생성하고자 하는 대상을 알아보자. Abstract Factory 패턴은 일련의 연관성 있는 객체 군을 생성하는 패턴이다. Xp 객체 군과 Linux 객체군이 있다고 가정하자. 그리고 각 객체군은 Button과 Edit 객체를 포함하고 있다. 마지막으로 생성된 Button과 Edit 객체는 객체군과 상관 없이 동일하게 취급 되어야 한다. 따라서 IButton과 IEdit와 같이 인터페이스를 선언해 주고 Xp 타입과 Linux 타입에 해당하는 클래스들을 선언해 준다.


생성하고자 하는 객체들의 선언

interface IButton {};

interface IEdit   {};

class XpEdit implements IEdit {

    public XpEdit(){System.out.println("XpEdit()");}

};

class XpButton implements IButton {

    public XpButton(){System.out.println("XpButton()");}

};

class LinuxEdit implements IEdit {

    public LinuxEdit(){System.out.println("LinuxEdit()");}

};

class LinuxButton implements IButton {

    public LinuxButton(){System.out.println("LinuxButton()");}

};


이제 Abstract Factory를 enum을 이용해서 구현한다.


Enum Abstract Factory의 구현

interface IFactory

{

    public IButton createButton();

    public IEdit createEdit();

};


public enum EnumAbstractFactory implements IFactory{

    XP{

        public IButton createButton() {

            return new XpButton();

        }

        public IEdit createEdit() {

            return new XpEdit();

        }

    }

    ,LINUX{

        public IButton createButton() {

            return new LinuxButton();

        }

        public IEdit createEdit() {

            return new LinuxEdit();

        }

    }

    ;

}

각 Factory들도 사용하는 측에서는 동일한 타입으로 취급이 가능해야 한다. 따라서 Factory들에 대한 추상 타입인 IFactory를 선언해 준다. 그리고 개별 Factory들이 IFactory를 구현하도록 선언해 준다. enum은 실제로 abstract class이기 때문에 인터페이스를 구현하는 것도 가능하다.


사용 방법

public static void main(String[] args) {

    IFactory factory = EnumAbstractFactory.XP;

    IButton button = factory.createButton();

    IEdit edit = factory.createEdit();

    factory = EnumAbstractFactory.LINUX;

    button = factory.createButton();

    edit = factory.createEdit();

}

사용 방법은 일반적인 enum과 동일하다. 대신 Factory 객체의 생성 문제가 enum을 통해 해결되었다. enum의 각 하위 타입은 public static final 키워드를 암시적으로 달고 있다. 따라서 Singleton의 특성을 가지기 때문에 별도로 생성할 필요가 없고, 오직 한번만 생성이 된다.

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

Composite 패턴  (0) 2016.09.17
Iterator 패턴  (0) 2016.09.17
Proxy 패턴과 그 활용  (0) 2016.09.16
Observer 패턴  (0) 2016.09.16
Abstract Factory 패턴  (0) 2016.09.16
Posted by 이세영2
,

Proxy 패턴은 단순하면서도 자주 쓰이는 패턴이고 활용 방식도 다양하다. 이 글에서는 Proxy 패턴과 함께 그 다양한 활용 방법에 대해서 이야기 해 보도록 하겠다.


Proxy 패턴의 클래스 다이어그램


Proxy 패턴의 구현(기본형)

interface Subject {

    public Object action();

}

class RealSubject implements Subject {

    public RealSubject(){}

    public Object action() { /* do something */ return null;}

}

class Proxy implements Subject{

    private RealSubject realSubject;

    public Proxy(){

        realSubject = new RealSubject();

    }

    public Object action() {

        return realSubject.action();

    }

}


Proxy 패턴은 Proxy 패턴의 기본형을 어떤 방식으로 변형하느냐에 따라 달라진다.

Proxy 패턴의 변형에는 두가지 방향이 있다.


1. RealSubject의 생성 시점

2. Proxy 객체의 action()에서 하는 일


위의 두가지 변형을 조합함으로써 활용도를 다르게 가지고 갈 수 있다.


동적 생성 프록시

class Proxy implements Subject{

    private RealSubject realSubject;

    public Proxy(){}

    public Object action() {

        if(realSubject == null){

            realSubject = new RealSubject();

        }

        return realSubject.action();

    }

}

동적 생성 프록시는 프록시 객체가 실제 객체를 생성하지 않고 시작한다. 그리고 실제 요청(action() 메소드 호출)이 들어 왔을 때 실제 객체를 생성한다. 이 구현은 실제 객체의 생성에 많은 자원이 소모 되지만 사용 빈도는 낮을 때 쓰는 방식이다.


자원관리 프록시

class RealSubject implements Subject {

    private String fileName;

    @Override

    public void action() {

        System.out.println("Displaying " + fileName);

    }

    public void loadFromDisk(String fileName){

        this.fileName = fileName;

        System.out.println("Loading " + fileName);

    }

}

class Proxy implements Subject{

    private RealSubject realSubject;

    private String fileName = "";

    public Proxy(){

        realSubject = new RealSubject();

    }

   

    @Override

    public void action() {

        realSubject.action();

    }

   

    public void loadFromDisk(String fileName){

        if(this.fileName.equals(fileName)) return;

        this.fileName = fileName;

        realSubject.loadFromDisk(fileName);

    }

}

자원관리 프록시는 실제 객체가 비용이 많이 드는 자원에 대한 생성을 담당할 때 쓰인다. 실제 객체는 기본적인 자원 생성(위 예제에서는 loadFromDisk() 메소드)를 담당하고, 프록시는 이 자원이 이미 생성되었는지를 체크한다. 만약 이미 생성된 자원이라면 자원에 대한 생성을 스킵한다. 이를 통해서 불필요하게 자원을 중복으로 생성하는 것을 방지한다.


가상 프록시

class Proxy implements Subject{

//    private RealSubject realSubject;

    public Proxy(){}

    public Object action() {

        return virtualAction();

    }

   

    private Object virtualAction(){ /* do something */ return null; }

}

실제 객체가 여러가지 이유로 존재하지 않을 경우에 사용되는 방식이다. 특히 여러 단위의 조직이 협업을 할 때 인터페이스는 정의되어 있으나 실제 구현은 아직 되어 있지 않은 경우에 이 가상 프록시를 사용한다. 가상 프록시에 시뮬레이션 된 동작을 하도록 구현해 두고 개발을 진행한 후, 실제 객체가 완성된 이후에 프록시를 실제 객체로 대체 시킨다. 이런 방식을 통해서 실제 객체를 붙여 보기 전에 발생할 수 있는 문제점들을 미리 테스트 해 볼 수 있기 때문에 통합 작업에서 발생하는 문제점들을 줄일 수 있다.


원격 프록시

class Proxy implements Subject{

//    private RealSubject realSubject;

    public Proxy(){}

    public Object action() {

        return remoteAction();

    }

   

    private Object remoteAction(){

        connect();

        Object result = getData();

        disconnect();

        return result;

    }

   

    private void connect(){/* connect to remote */}

    private Object getData(){/* data request and wait response */ return null;}

    private void disconnect(){/* disconnect from remote */}

}

프록시가 마치 원격에 있는 실제 객체처럼 동작하도록 하는 방법이다. 프록시 객체를 사용하는 객체들은 실제 객체와 동일한 인터페이스를 통해 프록시를 사용하고, 데이터 통신이나 변환과 같은 작업은 프록시 객체 내부에서 수행하도록 한다.


AOP(Aspect Oriented Programming) 프록시

class Proxy implements Subject{

    private RealSubject realSubject;

    public Proxy(){

        realSubject = new RealSubject();

    }

    public Object action() {

        preProcess();

        Object result = realSubject.action();

        postProcess();

        return result;

    }

    private void preProcess(){/* 선행 작업 */}

    private void postProcess(){/* 후행 작업 */}

}

AOP는 모든 객체에 공통으로 적용되는 기능들을 구현하는 개발 방법을 말한다. 예를 들어 어떤 객체를 멀티 쓰레딩 환경에서 보호해야 한다거나, 어떤 메소드 호출이 걸리는 시간을 측정하거나, 어떤 동작에 대한 트랜젝션을 작성하는 등의 경우이다. 이런 것들은 실제 객체의 행위와 별개로 이루어질 수 있다.

위의 코드에서 보면 action() 메소드가 preProcess() 메소드를 먼저 호출 한 후 realSubject의 action()을 호출한다. 그리고 이후에 postProcess() 메소드를 호출하도록 되어 있다. 이 preProcess()와 postProcess()에 어떤 작업을 구현해 넣느냐에 따라서 관점(Aspect)이 달라진다. 만약 mutex.lock()과 mutex.unlock()을 넣는다면 멀티쓰레드 안정성을 제공할 수 있다. 만약 preProcess()와 postProcess()의 호출 시각을 저장하고 둘의 차를 계산한다면 action() 메소드 실행에 걸린 시간을 측정할 수 있다.

이렇게 실제 객체는 그대로 두고 모든 객체들이 공통으로 수행해야 할 일들을 프록시 객체를 통해서 구현할 수 있다.(실제 Spring과 같은 프레임워크에서 AOP를 구현하는 방식은 reflection을 이용하는 방식이다. 단지 여기서는 같은 형태의 구현을 프록시로 할 수 있다는 점 만 보여주는 것이다.)

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

Iterator 패턴  (0) 2016.09.17
Enum Abstract Factory 패턴  (0) 2016.09.16
Observer 패턴  (0) 2016.09.16
Abstract Factory 패턴  (0) 2016.09.16
Factory Method 패턴  (4) 2016.09.16
Posted by 이세영2
,

Observer 패턴

5.디자인패턴 2016. 9. 16. 12:38

Observer 패턴은 관찰 대상 객체에 변경 사항이 발생했을 때 이벤트 형태로 알림을 받는 패턴이다. Push / Pool 방식의 데이터 처리 방식 중에서 Push에 해당한다. 소프트웨어에서 데이터 처리에서는 너무 자주 나타나는 패턴이기 때문에 자주 쓰이고, 그렇기 때문에 사용에 주의해야 하는 패턴이다. Observer 패턴을 너무 자주 사용하면 구조와 동작을 알아보기 힘들어진다.

일반적으로 Observer 패턴에서 관찰 대상이 되는 객체(Observerable) 한 개에 대하여 다수의 관찰자 객체(Observer)가 존재한다. 다수의 관찰자 객체들은 모두 같은 타입으로 취급되어야만 구현이 단순해진다. 따라서 관찰자들은 모두 같은 인터페이스나 상위 클래스를 상속 받아 구현된다. 관찰 대상으로부터 이벤트가 발생하여 관찰자 객체에게 전달되었을 때 이 데이터를 처리하는 내용은 개별 관찰자 객체들이 자신에 맞게 구현한다.


Observer 패턴의 클래스 다이어그램


Observer 패턴의 구현

interface IObserver{

    public void notify(Object data);

}

class Observable{

    private List<IObserver> observers = new ArrayList<IObserver>();

   

    public void registObserver(IObserver observer){ observers.add(observer); }

   

    String []table = {"a","b","c","d","e"};

    public void update(String data, int index){

        table[index] = data;

        onUpdate();

    }

    public void onUpdate(){

        for(IObserver observer : observers){

            observer.notify(table);

        }

    }

}

class Graph implements IObserver{

    public Graph(Observable obervable){ obervable.registObserver(this); }

    public void notify(Object data){

        String[] table = (String[])data;

        System.out.println("Graph : ");

        for(int i = 0; i < 5; i++) System.out.println(table[i]);

    }

}

class Display implements IObserver{

    public Display(Observable obervable){ obervable.registObserver(this); }

    public void notify(Object data){

        String[] table = (String[])data;

        System.out.println("Display : ");

        for(int i = 0; i < 5; i++) System.out.println(table[i]);

    }

} 


실행 방법

public static void main(String[] args) {

    Observable observable = new Observable();

    IObserver graph = new Graph(observable);

    IObserver display = new Display(observable);

    observable.update("abc", 1);

    observable.update("def", 2);

    observable.update("ghi", 3);

}


위의 코드에서 관찰 대상은 Observable이고, 관찰자 인터페이스는 IObserver로 선언되어 있다. 그리고 관찰자 구체 클래스는 Graph와 Display이다. 위의 코드에서는 우선 관찰 대상인 Observable 객체가 먼저 생성되어야 한다. 그리고 관찰자 객체들은 생성자에서 Observable 객체를 매개변수로 받은 후, Observable 객체에 자신을 관찰자로 등록한다.(Observable 객체에 관찰자를 등록 시키는 방법은 꼭 생성자를 이용한 방법이 아니어도 좋다.) 이후 관찰 대상에 이벤트가 발생하면 각 관찰자 객체들의 notify 함수를 통해서 발생한 이벤트를 전달해 준다.

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

Enum Abstract Factory 패턴  (0) 2016.09.16
Proxy 패턴과 그 활용  (0) 2016.09.16
Abstract Factory 패턴  (0) 2016.09.16
Factory Method 패턴  (4) 2016.09.16
Memento 패턴  (2) 2016.09.13
Posted by 이세영2
,

Abstract Factory 패턴은 연관성이 있는 객체군이 여러벌 있을 경우 각각의 구체 Factory 클래스를 통해 동일한 객체군을 생성하는 패턴이다.

예를 들어 윈도우 화면을 만든다고 생각해 보자. 윈도우에는 버튼도 들어갈 수 있고 에디터 박스도 들어갈 수 있다. 실행 환경에 따라서 구분해 보면 Linux 환경, 윈도우 XP 환경 등 다양하다. 이 실행 환경에 따라서 윈도우의 모양(테마)은 바뀔 수 있다. 이 경우 버튼과 에디터 박스는 공통으로 생성될 수 있지만 테마는 Linux냐 Xp냐에 따라서 다르다. 이런 경우 Linux와 연관된 객체군들은 Linux 객체군을 생성하는 클래스가, Xp와 연관된 객체군들은 Xp 객체군을 생성하는 클래스가 생성하도록 할 수 있다. 이런 상황을 구현하는 패턴이 Abstract Factory 패턴이다.

Abstract Factory 패턴을 설명하기 앞서서 Factory Method 패턴과의 관계를 알아보자.

유사점 : 객체를 생성하고, 구체적인 타입을 감춘다.

차이점

1. Factory Method 패턴은 생성 이후 해야 할 일의 공통점을 정의하는데 촛점을 맞추는 반면, Abstract Factory 패턴은 생성해야 할 객체군의 공통점에 촛점을 맞춘다.

2. 따라서 Factory Method 패턴은 생성해야 할 객체가 한 종류이고, Abstract Factory 패턴은 여러 종류이다.


물론 유사점과 차이점을 조합해서 복합 패턴을 구성하는 것도 가능하다.


그러면 Abstract Factory 패턴의 구현을 알아보자.

Abstract Factory 패턴 클래스 다이어그램

Abstract Factory 패턴의 구현

interface IFactory

{

    public IButton createButton();

    public IEdit createEdit();

};

interface IButton {};

interface IEdit   {};

class XPFactory implements IFactory

{

    public IButton createButton() {/// XpButton을 생성하는 함수.

        return new XpButton();

    }

    public IEdit createEdit() {/// XpEdit를 생성하는 함수.

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

        return new XpEdit();

    }

}

class LinuxFactory implements IFactory

{

    public IButton createButton() {/// LinuxButton을 생성하는 함수.

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

        return new LinuxButton();

    }

    public IEdit createEdit() {/// LinuxEdit를 생성하는 함수.

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

        return new LinuxEdit();

    }

}

class XpEdit implements IEdit {

    public XpEdit(){System.out.println("XpEdit()");}

};

class XpButton implements IButton {

    public XpButton(){System.out.println("XpButton()");}

};

class LinuxEdit implements IEdit {

    public LinuxEdit(){System.out.println("LinuxEdit()");}

};

class LinuxButton implements IButton {

    public LinuxButton(){System.out.println("LinuxButton()");}

};


사용 방법

public static void main(String[] args) {

    IFactory factory = null;

    factory = new LinuxFactory();

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

    factory.createButton();

    factory.createEdit();

    factory = new XPFactory();

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

    factory.createButton();

    factory.createEdit();

}


위의 예제에서는 객체 군에는 Xp와 Linux가 있고, 객체의 종류에는 Edit와 Button이 있다. 그리고 각각을 구현한 XpEdit와 XpButton, LinuxEdit와 XpButton이 있다.

Abstract Factory 패턴의 목적은 연관성 있는 객체군을 생성할 수 있도록 제어하는 것이다. Xp용 윈도우를 만들고 싶다면 XpEdit와 XpButton을 생성할 수 있도록 하고, Linux용 윈도우를 만들고 싶다면 LinuxEdit와 LinuxButton을 생성할 수 있도록 하는 것이다. 이를 위해서는 연관성 있는 객체군을 생성할 수 있는 객체를 만들어야 한다. 그리고 또 하나 중요한 부분은 객체가 생성된 이후에는 Xp인지 Linux인지를 알 수 없도록 하는 것이다.

이를 위해서 객체군 생성을 담당하는 Factory를 선언한다. 객체군이 여럿(Xp, Linux)이기 때문에 IFactory라는 인터페이스를 먼저 만든다. 그리고 Xp 윈도우 용 객체들을 생성할 수 있는 XpFactory, Linux용 객체들을 생성할 수 있는 LinuxFactory를 만든다. 그런 다음 각각의 객체군들에 맞게 객체를 생성하는 메소드를 구현해 주면 된다.

이 과정에서 XpButton과 LinuxButton은 객체 생성 이후에 구분되어서는 안된다. 따라서 공통 인터페이스인 IButton을 구현하도록 한다. Edit쪽도 마찬가지로 IEdit를 구현하도록 만들어 준다.

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

Proxy 패턴과 그 활용  (0) 2016.09.16
Observer 패턴  (0) 2016.09.16
Factory Method 패턴  (4) 2016.09.16
Memento 패턴  (2) 2016.09.13
Bridge 패턴  (0) 2016.09.13
Posted by 이세영2
,

Factory Method 패턴은 객체의 생성과 관련된 패턴이다.

Factory라는 것은 생산품을 생산하는 생산자의 의미로 사용되는 단어이고, 객체지향 언어에서는 객체를 생성하는 생산자를 의미한다.

Method는 본래 Template Method 패턴에서 차용한 단어이다. Factory Method 패턴에서는 객체의 생성 직후에 해야 할 일을 순서대로 정의한 메소드를 제공한다. 그리고 구체적인 생성은 구체 클래스들에 위임한다.

이 Factory Method 패턴을 통해 얻을 수 있는 이익은 다음과 같다.

1. 객체의 생성 후 공통으로 할 일을 수행한다.

객체가 생성된 직후에 어떤 작업을 수행해야 하는 경우는 자주 나타난다. 특히 문제가 되는 경우는 객체가 이벤트를 수신하는 경우이다. 만약 객체를 이벤트 수신자로 등록하는 코드를 생성자 내부에 넣어 두게 되면 생성이 완료 되기 이전에 이벤트를 받게 되어 오류가 발생하는 경우가 생긴다. 이런 일들은 객체 생성 이후에 해주어야 하는데, Factory Method 패턴을 이용하면 이 문제를 해결할 수 있다.

2. 생성되는 객체의 구체적인 타입을 감춘다.

Factory Method 패턴은 그 구조상 생성된 객체의 타입을 구체적인 타입이 아닌 추상 타입(상위 타입)으로 리턴한다. 이를 통해서 객체를 사용하는 측에서는 구체적인 타입의 존재 조차 모르도록 할 수 있다.

3. 구체적인 Factory 클래스가 생성할 객체를 결정하도록 한다.

구체 Factory 클래스는 생성 메소드 내부의 구현을 책임진다. 구체 생성 메소드 내부에서는 필요한 동작을 자유롭게 구현할 수 있는데, 특히 인자를 받거나 상태에 따라서 생성할 객체를 바꿀 수도 있다. 이렇게 하면 좀 더 다양한 기능을 수행하거나 수정에 용이한 구조를 만들어 낼 수 있다.


본격적인 구현 예제에 앞서서, 매우 간단한 형태의 Simple Factory Method를 구현해 보고자 한다. 이 구현은 사실 Gof가 의도한 Factory Method 패턴과는 관계가 없을 수 있다. 생성을 담당하는 메소드이기 때문에 Factory Method라고 불리긴 하지만 Factory Method 패턴은 아니다. 하지만 위에서 언급한 1번의 경우를 구현한 것이기 때문에 봐 둘 필요는 있다.


Factory Method ( Factory Method 패턴이 아님 )

class Wheel{

    private Wheel(){}// 생성자를 감춤.

    public static Wheel create(){

        Wheel wheel = new Wheel();

        wheel.init();// 생성 후 꼭 해줘야 할 일

        return wheel;

    }

    public void init(){}

}

위의 클래스에서 create() 메소드가 이에 해당한다. create() 메소드는 객체가 생성된 직 후 해주어야 할 일(위에서는 init() 메소드를 호출하는 일)을 수행한다. 위에서 이야기 한 것처럼 어떤 작업은 객체의 생성자 내부에서 하지 못하는 일이 있을 수 있다. 그런 일들은 객체가 생성된 이후에 해주어야 하는데, 이런 Factory Method를 제공해 주지 않으면 객체를 생성 받은 쪽에서 그 일을 해야 한다. 만약 객체를 생성하는 곳이 여러 곳이라면 이런 작업을 여러 곳에서 해야 하고, 또 이를 수정해야 할 경우에는 여러 곳에서 이를 수정해야 한다. 이런 문제를 방지할 수 있도록 해주는 것이 Factory Method 이다.


Factory Method 패턴은 좀 더 많은 문제에 관여하는 패턴이다. 다음의 예제를 보자.


Factory Method 패턴 클래스 다이어그램

Factory Method 패턴의 구현

abstract class ShapeFactory{

    public final Shape create(Color color){

        Shape shape = createShape();

        shape.setColor(color);       // 1. 생성 후 공통으로 할 일

        return shape;                // 2. 구체적인 타입을 알 수 없음

    }

   

    // protected이기 때문에 외부에 노출 안됨.

    // 3. 매개 변수를 받아서 생성할 객체를 결정하도록 할 수 있음.

    abstract protected Shape createShape();

}

class RectangleFactory extends ShapeFactory{

    @Override

    protected Shape createShape() {

        return new Rectangle();

    }

}

class CircleFactory extends ShapeFactory{

    @Override

    protected Shape createShape() {

        return new Circle();

    }

}

interface Shape

{

    public void setColor(Color color);

    public void draw();

}

class Rectangle implements Shape

{

    Color color;

    public void setColor(Color color){ this.color = color; }

    public void draw() { System.out.println("rect draw"); }

}

class Circle implements Shape

{

    Color color;

    public void setColor(Color color){ this.color = color; }

    public void draw() { System.out.println("circle draw"); }

}

최초에 나오는 ShapeFactory가 Factory Method 패턴의 기본 클래스이다. 

여기서 create() 메소드가 생성 메소드 역할을 담당한다. 이 메소드는 구체 메소드이므로 ShapeFactory를 상속 받은 클래스들은 모두 이를 이용할 수 있다. 또한 final 메소드이므로 재정의가 불가능하다. Shape을 리턴 타입으로 사용하기 때문에 ShapeFactory를 사용하는 곳에서는 리턴되는 Shape의 구체적인 타입(예제에서는 Rectangle인지 Circle인지)을 알 수 없다.

ShapeFactory는 createShape()이라는 추상 메소드를 선언하고 있다. ShapeFactory의 구체 클래스들은 이 메소드를 통해서 어떤 Shape 객체를 생성할지를 결정한다.

create() 메소드와 createShape() 메소드와의 관계는 Template Method 패턴과 같다.

createShape() 메소드는 protected로 선언되어 있어서 외부 클래스에서는 사용이 불가능하다. 즉, create() 메소드만을 이용하도록 해서 객체의 생성 이후 수행할 작업이 완료될 것을 강제하는 것이다. createShape() 메소드를 구현하는 구체 Factory 클래스들에서는 다양한 형태로 이를 구현할 수 있다. createShape() 메소드가 매개변수를 받는다면, 매개변수를 조건으로 하여 구체적으로 생성할 Shape의 종류를 바꿀 수도 있고, createShape() 메소드 내부에서 가지고 있는 정보들을 활용하여 생성 조건을 변경할 수도 있다.


Factory Method 패턴에서 문제가 될 부분은 구체 Factory들이 여러번 생성될 필요가 없다는 점이다. 따라서 Singleton 패턴을 이용해야할 경우가 있는데 이렇게 되면 패턴이 복잡해지는 문제점이 있다. 이 문제는 Enum 타입을 통해 Factory Method 패턴을 구현함으로써 해결할 수 있다. Enum Factory Method 패턴을 참고하기 바란다.

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

Observer 패턴  (0) 2016.09.16
Abstract Factory 패턴  (0) 2016.09.16
Memento 패턴  (2) 2016.09.13
Bridge 패턴  (0) 2016.09.13
Template Method 패턴  (0) 2016.09.10
Posted by 이세영2
,

Memento 패턴

5.디자인패턴 2016. 9. 13. 20:50

Memento 패턴은 특정 시점에 객체의 상태를 저장하고 복원하기 위해서 사용하는 패턴이다. 바둑이나 오목과 같이 일련의 진행 사항을 저장해 두었다가 다시 복구하기 위해서 사용할 수도 있고, 실패가 예상되는 작업이 있을 경우 복원 지점을 저장해 두었다가 실패했을 때 원 상태로 복원하기 위해서 사용할 수도 있다.


Memento 패턴 클래스 다이어그램

이 다이어그램은 User 클래스와 Memento 클래스의 관계를 표현한다. User 클래스는 id와 level, exp와 같은 필드들을 가진다. Memento 클래스는 User 클래스가 가진 필드들을 저장하는 역할을 한다. 따라서 User가 가지고 있는 모든 필드들을 가지고 있어야 한다. User 클래스는 다이어그램에서 save와 load 메소드를 제공하도록 되어 있다. 원래 Memento 패턴에서는 User 클래스의 데이터 저장과 복원을 별도로 관리하는 클래스를 두기도 하는데 이렇게 하는 경우 생각보다 구현이 복잡해 질 수 있다. 따라서 이 예제에서는 간단히 User 클래스 스스로가 저장과 복원을 관리하도록 하였다.

세부적인 구현은 아래 코드를 보고 이야기 하도록 하자.


Memento 패턴의 구현

class User

{

    private String id;

    private int level;

    private int exp;

  

    // 객체의 상태를 저장하기 위해 꼭 필요한 항목을 캡슐화

    class Memento

    {

        private String id;

        private int level;

        private int exp;

        public Memento(User user){

            id = user.id;

            level = user.level;

            exp = user.exp;

        }

    };

    Vector<Memento> backup = new Vector<>();

    public int save()

    {

        backup.add(new Memento(this));

        return backup.size() - 1;

    }

    public void load(int token)

    {

        Memento m = backup.get(token);

        id = m.id;

        level = m.level;

        exp = m.exp;

    }

}

특징적인 부분을 이야기해 보면, 우선 Memento 클래스가 User 클래스 내부에 선언되어 있다. 이것은 클래스가 단순할 경우 특히 유용한데, 다수의 Memento 패턴이 사용되어야 할 경우라면 Memento 클래스를 내부 클래스로 선언하는 편이 좋다. 

save와 load를 구현하기 위해서는 Memento 객체를 생성하여 저장해 둘 backup 리스트를 선언해 주어야 한다. save() 메소드에서는 새로운 Memento 객체를 생성하고, User 객체가 자기 자신을 인자로 넘겨 Memento 객체가 초기화 되도록 한다. 그리고 backup 리스트에 저장하여 복원에 대비한다. load() 메소드에서는 backup 리스트의 인덱스를 받아 Memento 객체를 꺼내고, 이 Memento 객체에 저장된 필드 값들을 User 객체의 필드 값으로 바꿔준다. 이를 통해 이전에 저장된 데이터를 복원할 수 있다.

이 구현 방식은 Memento 패턴의 간략화 된 버전이라 할 수 있다. 이 Memento 패턴의 save() load() 메소드를 이용하여 undo()나 redo()와 같은 기능들도 구현할 수 있다.

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

Abstract Factory 패턴  (0) 2016.09.16
Factory Method 패턴  (4) 2016.09.16
Bridge 패턴  (0) 2016.09.13
Template Method 패턴  (0) 2016.09.10
Singleton 패턴  (0) 2016.09.10
Posted by 이세영2
,

Bridge 패턴

5.디자인패턴 2016. 9. 13. 19:32

Bridge 패턴은 두 구체 클래스 간의 강한 결합을 제거하기 위해서 사용하는 패턴이다. 특히 두 클래스 모두 추상화된 상위 클래스 또는 인터페이스(= 타입)를 가지게 되고, 의존성은 상위 타입간에만 이루어지게 된다. 이를 통해 실제 의존성이 발생하더라도 서로의 구체 타입은 알 수 없도록 한다. 이렇게 되면 두 상위 타입을 구현하는 어느 쪽도 변경이 가능한 상태가 된다.

구체적인 예를 위해서 RPG 게임을 제작한다고 생각해 보자. 일단 우리에게는 각종 무기들(Bow, Sword 등)이 필요할 것이다. 그리고 무기를 다루는 전사들이 있을 것이다. 일단 Warrior가 있다고 가정하자. 그런데 무기를 다루는 것은 전사 뿐만 아니라 대장장이(Smith)도 있다. 대장장이도 어떻게든 무기를 다룰 수 있도록 해 주어야 한다.

만일 Warrior가 구체적으로 Bow와 Sword를 다루는 구현을 가지고 있다면 Bow와 Sword를 변경할 때마다 Warrior의 구현도 변경되어야 할 것이다. 또한 무기가 추가되면 또 해당 무기에 대한 구현을 해야 한다. 또한 Smith 역시 무기들에 대한 구체적인 구현을 가지고 있다면 같은 문제점을 가지게 될 것이다.

이러한 경우에 Bridge 패턴을 이용하면 구체적인 클래스들 간의 의존성을 배제시킬 수 있다.


무기(Weapon) - 무기 핸들러(WaponHandler) Bridge 패턴 Class Diagram


위 클래스 다이어그램에서 보는 바와 같이 Weapon과 WeaponHandler는 각각 인터페이스이다. 그리고 구체적인 의존 관계는 이 인터페이스들 간에만 존재한다. Weapon을 구현한 Bow 클래스와 Sword 클래스는 실제 자신들을 다룰 구체적인 클래스를 알지 못한다. 역시 WeaponHandler를 구현한 Warrior나 Smith도 구체적인 무기, 즉 Bow나 Sword를 알지 못한다. 따라서 Weapon이 Spear, Gun 등과 같이 늘어나더라도 WeaponHandler의 구체 클래스들에는 변경에 있어서 영향을 주지 않는다.

그러면 실제 구현을 살펴 보자.


Weapon - WeaponHandler Bridge 패턴 구현

interface Weapon{

    public void attack();

    public void repair();

}

class Bow implements Weapon{

    public void attack(){

        System.out.println("Bow attack");

    }

    public void repair(){

        System.out.println("Bow repair");

    }

}

class Sword implements Weapon{

    public void attack(){

        System.out.println("Sword attack");

    }

    public void repair(){

        System.out.println("Sword repair");

    }

}


interface WeaponHandler{

    public void handle();

}

class Warrior implements WeaponHandler{

    private Weapon weapon;

    public Warrior(Weapon weapon){

        this.weapon = weapon;

    }

    public void handle(){

        System.out.print("Warrior ");

        weapon.attack();

    }

}

class Smith implements WeaponHandler{

    private Weapon weapon;

    public Smith(Weapon weapon){

        this.weapon = weapon;

    }

    public void handle(){

        System.out.print("Smith ");

        weapon.repair();

    }

}

여기서 Weapon 클래스 군과 WeaponHandler 클래스 군 간의 구체적인 의존성이 나타나는 곳은 Warrior와 Smith 클래스의 생성자에서 Weapon을 인자로 받는 부분이다. 이 경우에서 보듯이 Warrior나 Smith 클래스는 인터페이스인 Weapon만 알 뿐, 구체적인 타입은 알지 못한다.

이러한 방식으로 무기의 종류를 늘리거나 무기를 다루는 사람을 늘리더라도 다른 한 쪽에는 변경에 영향을 미치지 않도록 만들어 준다.

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

Factory Method 패턴  (4) 2016.09.16
Memento 패턴  (2) 2016.09.13
Template Method 패턴  (0) 2016.09.10
Singleton 패턴  (0) 2016.09.10
Builder 패턴  (0) 2016.09.10
Posted by 이세영2
,

Template Method 패턴은 실행 순서(sequence)는 고정하면서 세부 실행 내용(operation)은 다양화 될 수 있는 경우에 사용하는 패턴이다.

구현 시 자주 발생하는 문제들 중에서 실행 순서가 고정되어야 하는 경우가 있다. 예를 들어 input() -> calculate() -> output() 순서에 따라서 실행되어야 하거나 init() -> do() -> release(), 또는 lock() -> do() -> unlock()과 같이 정확한 순서에 따라 실행되어야만 올바른 결과를 얻을 수 있는 경우가 있다. 이럴 때 보통 사용하는 방법은 외부에는 상세 단계들을 정해진 순서대로 실행시키는 함수를 public으로 제공하고, 각 단계를 실행하는 함수는 private으로 감춰 두는 것이다.

Template Method 패턴은 이것과 매우 유사한 방식이다. 다만, 실행 방법만 정해져 있을 뿐 각 순서에 따라 해야 할 일들이 서로 다를 수 있는 경우에 이 세부 순서를 구현할 수 있는 추상 메소드를 제공한다. 따라서 보통 Template Method 패턴은 추상 클래스로 구현되는 경우가 많다.

아래는 Template Method 패턴을 구현한 상위 클래스의 형식이다.

abstract public class AbstractTemplate {

    public final void templateMethod(){

        sequence1();

        sequence2();

        sequence3();

        sequence4();

    }

   

    abstract protected void sequence1();

    abstract protected void sequence2();

    abstract protected void sequence3();

    abstract protected void sequence4();

}

먼저 templateMethod()를 살펴보자. 이 패턴의 목적은 일단 순서를 고정하자는 것이다. 따라서 하위 클래스가 이 클래스를 상속 받은 후 templateMethod()를 재정의 하는 것을 방지하기 위해서 final 키워드를 사용해 주어야 한다. 그리고 설계상에서 이미 정해 놓은 순서를 내부적으로 구현한다. 각 순서를 메소드화 한 후, templateMethod() 에서 이 메소드들을 순서에 맞게 호출하는 것이다.

세부 메소드는 하위 클래스들이 구현할 수 있도록 abstract 함수로 제공한다. 이들 abstract 함수들은 templateMethod()에서 호출되어야 하므로 private이 아닌 protected로 선언되어야 한다. 

세부 순서 함수 중 일부는 모든 하위 클래스에게 동일할 수 있다. 이 경우에는 상위 클래스가 이를 구현하고 하위 클래스들은 아예 볼 수 없도록 private으로 선언하는 편이 좋다.

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

Memento 패턴  (2) 2016.09.13
Bridge 패턴  (0) 2016.09.13
Singleton 패턴  (0) 2016.09.10
Builder 패턴  (0) 2016.09.10
Holder 패턴  (0) 2016.08.28
Posted by 이세영2
,

Singleton 패턴

5.디자인패턴 2016. 9. 10. 18:13

Singleton 패턴을 이야기 하기 전에 먼저 주의사항부터 이야기 할 필요가 있다.

1. 가급적 쓰지 마라.

이것은 실제로 자주 겪는 문제이기도 한데, Singleton으로 객체를 생성하는 경우, 이 객체는 "전역 변수"와 같다. 단 하나의 객체만이 생성되고, static 함수(getInstance() 함수)를 통해 접근이 가능하고, static 함수가 public 이기 때문에, 전역 변수와 같은 특성을 지니게 된다. 의도한 바가 아니더라도 말이다. 그래서 마치 전역 변수처럼 사용되어 버리는 문제가 생긴다. 테스트 주도 개발(켄트 벡)이라는 책에는 싱글톤에 대해 이렇게 나와 있다.

"싱글톤 : 전역 변수를 제공하지 않는 언어에서 전역 변수를 사용하려면 어떻게 해야 할까? 사용하지 마라. 프로그램은 당신이 전역 변수를 사용하는 대신 설계에 대해 고민하는 시간을 가졌던 점에 대해 감사할 것이다."

싱글톤은 전역 변수처럼 무분별하게 사용될 여지가 있고, 특히 Unit Test를 작성하기 어렵게 만든다. 전역 변수라서 이곳 저곳에서 접근이 가능하고 static final 변수로 선언되기 때문에 대체가 불가능하다. 비슷한 문제가 enum이나 Holder 패턴에서도 발생한다. 하지만 Singleton 객체는 이들 패턴보다 훨씬 무질서하게 사용되는 경향이 있다.

Singleton 패턴에는 크게 두가지 종류가 있다. 하나는 static 변수 선언 시 바로 생성하는 방식이고, 하나는 getInstance() 함수 호출 시 생성하는 방식이다.


static으로 생성하는 방식

public class StaticSingleton {

    private static final StaticSingleton instance = new StaticSingleton();

    private StaticSingleton() {}   // 규칙 1. private 생성자.

    // instance, getInstance, singleton, shared

    public static StaticSingleton getInstance()  // 규칙 2. 오직 한개만 만듦

    {

        return instance;

    }

   

    public static void main(String[] args) {

        StaticSingleton instance = StaticSingleton.getInstance();

    }

}

이 방식은 특별히 구현에 신경 쓸 것이 별로 없다. 생성자를 private으로 만들어서 객체를 외부에서 생성하지 못하도록 하는 것, 그리고 getInstance()라는 메소드를 static으로 제공해 줌으로써 외부에서 객체에 접근할 수 있도록 하는 것이다.

getInstance() 함수를 통해 생성하는 방식

public class Singleton {

    private volatile static Singleton instance = null;

    public static Singleton getInstance(){

        if (instance == null) {                     // A

            synchronized(Singleton.class) {

                if (instance == null) {             // B

                    instance = new Singleton();

                }

            }

        }

        return instance;

    }

    private Singleton(){} // 생성자를 통한 생성 방지

   

    public static void main(String[] args) {

        Singleton instance = Singleton.getInstance();

    }

} 

앞서 static 생성시와의 차이점 중 하나는 instance 변수 선언 시 Singleton 객체가 생성되지 않는다는 점이다. Singleton 객체가 매우 크고, 쓰일 가능성이 매우 낮은 경우에 보통 사용하는 방식이다. 객체의 생성은 getInstance() 함수가 호출 될 때 이루어진다.

이 경우 객체가 이미 생성되었는지 그렇지 않은지(null 여부)를 체크함으로써 중복 생성을 방지할 필요가 있다. 이 때 멀티 쓰레드 상태에서 발생할 수 있는 레이싱 문제도 고려해 주어야 한다. 

위에서 보면 instance 변수가 null인지 체크하는 부분이 두 곳(A와 B)이 있다. 

첫번째(A)는 일단 instance가 null이 아닐 경우 synchronized 블럭 내부로 들어가는 것을 미리 차단함으로써 Singleton 객체를 얻기 위해 발생하는 부하를 줄여 주는 역할을 한다. 

일단 null임이 확인된 이후에는 synchronized 블럭으로 넘어가게 되는데, 이 때는 이 블럭이 단 하나의 쓰레드만 내부로 접근할 수 있도록 막는다. 

이 후 instance 객체의 null 체크를 한번 더 한다(B). 이는 맨 처음 synchronized 블럭에 들어온 쓰레드를 위한 것이 아니고 그 다음에 들어오는 경우를 위한 것이다. 

즉, A에서 두 쓰레드가 instance를 null로 판단했고, 둘 다 synchronized 블럭에 접근했다고 하자. 첫번째 쓰레드는 일단 instance 객체를 생성하고 종료 한다. 

두번째 쓰레드가 B 구문에 도달했을 때에는 이미 첫번째 쓰레드가 생성을 마치고 난 다음이다. 따라서 두번째 쓰레드는 B에서 instance 객체가 null이 아니라고 판단하게 된다. 그리고 그냥 블럭을 빠져 나온다. 이를 통해서 instance 객체가 중복 생성되는 것을 막을 수 있다.


사실상 최근의 컴퓨팅 파워를 고려할 경우 두번째 방식은 실제로는 거의 의미가 없다시피 하다. 어쨌든 필요한 경우 사용해 보려면 정확한 구현 메카니즘을 이해하고 있는 것이 도움이 될 것이다.

다시 한번 강조하지만 가급적 Singleton은 사용하지 않는 편이 좋다.

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

Bridge 패턴  (0) 2016.09.13
Template Method 패턴  (0) 2016.09.10
Builder 패턴  (0) 2016.09.10
Holder 패턴  (0) 2016.08.28
Adapter 패턴  (0) 2016.08.23
Posted by 이세영2
,

Builder 패턴

5.디자인패턴 2016. 9. 10. 17:36

빌더 패턴은 객체의 생성과 객체 생성 시 필요한 매개 변수 설정 과정을 분리함으로써 다음과 같은 이점을 확보해 주는 패턴이다.

1. 매개 변수가 많은 경우(특히 연속된 동일 타입의 매개 변수를 설정할 경우)에 발생할 수 있는 설정 오류를 방지할 수 있는 가독성을 제공한다.

2. 디폴트 값이 존재하는 매개 변수를 생략할 수 있도록 한다.

3. (immutable 변수나 final 키워드가 있는 변수처럼) 꼭 생성자를 통해서 설정 되어야 하는 변수를 지원할 수 있는 방법을 제공한다.


최종 결과물

class Hero{

    private int exp;

    private int cash0;

    private final String name; // 생성자를 통해 설정되어야 할 변수

    private final int level;   // 생성자를 통해 설정되어야 할 변수

    private Hero(Builder builder){

        this.exp = builder.exp;

        this.cash = builder.cash;

        this.name = builder.name;

        this.level = builder.level;

    }

    public void print(){

        System.out.println(exp + " " + cash + " " + name + " " + level);

    }

    static class Builder{

        private int exp = 0;

        private int cash = 0;

        private final String name;

        private final int level;

        public Builder(String name, int level){

            this.name = name;

            this.level = level;

        }

        public Builder exp(int exp){ this.exp = exp; return this;}

        public Builder cash(int cash){ this.cash = cashreturn this;}

        public Hero build(){

            return new Hero(this);

        }

    }

} 


실행 방법(객체 생성)

public static void main(String[] args) {

    Hero hero = new Hero.Builder("ceasar", 10).cash(20).exp(20).build();

    hero.print();

}


이 예제에서는 Hero라는 객체를 생성하고자 한다. Hero는 총 4개의 변수를 가지고 있고, 이 중에서 name과 level은 꼭 생성자를 통해서 설정 되어야 하는 변수이다. 그리고 나머지 exp와 cash 변수는 디폴트 값인 0을 가지고 있다.

따라서 다음과 같은 경우의 수를 가지고 있다.

1. name과 level만을 매개변수로 받아 생성하는 경우

2. name + level + exp

3. name + level + cash

4. name + level + exp + cash


만약 이러한 조합을 Telescoping Parameter Pattern으로 구현한다면 총 4개의 생성자를 구현해야 한다. 여기서 매개 변수가 더 추가된다면 생성자 수는 조합적으로 증가하게 될 것이다.

또한 level과 exp, 그리고 cash 변수는 모두 int 타입이다. 만약 단일 생성자에서 4개의 변수를 모두 설정한다면

    생성자("이름", 1, 2, 3);

과 같은 형식으로 변수 값을 할당하게 될 것이다. 하지만 이 경우 생성자의 변수 입력 순서를 정확히 알고 있지 않다면 여러 값을 넣으면서 착오에 의한 에러를 발생시킬 수 있다. 그리고 코드만으로는 쉽게 오류를 찾아내기가 힘들다.

이런 문제점을 해결하는 것이 빌더 패턴이다. 우선 Hero 클래스를 만들어 보자.

class Hero{

    private int exp;

    private int cash;

    private final String name; // 꼭 생성자를 통해 설정되어야 할 변수

    private final int level;   // 꼭 생성자를 통해 설정되어야 할 변수

간단히 변수만 선언한 모습이다. 빌더 패턴에서는 생성자에 바로 매개 변수를 입력하도록 하지 않고, 내부 클래스인 빌더 클래스를 이용하게 되어 있다. 빌더 객체인 Builder는 Hero의 내부 클래스로 선언이 된다. 그리고 외부 클래스인 Hero 객체를 생성하는 것이므로 Hero 객체가 없이 Builder 객체를 생성, 이용할 수 있도록 static 클래스로 선언해 주어야 한다. 그 모양은 아래와 같다.

class Hero{

    private int exp;

    private int cash;

    private final String name; // 생성자를 통해 설정되어야 할 변수

    private final int level;   // 생성자를 통해 설정되어야 할 변수

    static class Builder{

        private int exp = 0;

        private int cash = 0;

        private final String name;

        private final int level;

    }

}

일단 Builder 클래스가 가지고 있는 내부 변수는 모두 Hero가 가지고 있는 것과 일치한다. 여기서 중요한 점은 Builder 객체가 가지고 있는 변수 값이 Hero 객체의 초기값이 될 예정이므로 Builder 객체가 가진 변수들의 초기값을 원하는 값으로 맞춰 주어야 한다는 점이다. 즉, exp와 cash는 0이라는 초기값을 가지고 있는데, 이 초기값을 Hero가 아닌 Builder 클래스의 초기값으로 설정해 주어야 한다. 그래야 나중에 Builder 객체의 초기값이 Hero에 덮어 씌워지면서 원하는 초기값을 가질 수 있게 된다.

다음으로는 생성자를 만들 차례다. 생성자는 Builder 객체가 제공해 준다. 이 때 Hero의 변수 중 name과 level이 final로 설정되어 있으므로, 이 두 변수를 필수적으로 설정해 주도록 생성자에 매개 변수를 선언해야 한다. 구현을 해 보면 다음과 같다.

class Hero{

    private int exp;

    private int cash;

    private final String name; // 꼭 생성자를 통해 설정되어야 할 변수

    private final int level;   // 꼭 생성자를 통해 설정되어야 할 변수



    static class Builder{

        private int exp = 0;

        private int cash = 0;

        private final String name;

        private final int level;


        public Builder(String nameint level){

            this.name = name;

            this.level = level;

        }

    }

}

위와 같이 Builder의 생성자를 선언하여 name과 level을 매개 변수로 입력하도록 강제 할 수 있다.

다음은 옵션으로 입력 받을 수 있는 exp와 cash 변수에 대한 setter를 선언한다. 일반적인 setter 함수는 set+변수명 형식이지만 Builder 패턴에서는 가독성을 좋게 하면서도 setter와의 다른 특성을 가지고 있는 점을 알리기 위해서 변수명 그대로를 setter 이름으로 사용한다.(Java에서는 이것이 가능하지만 다른 언어라면 언더 바('_') 등을 활용할 수 있다.) 이를 선언해 보면 아래와 같다.

class Hero{

    private int exp;

    private int cash;

    private final String name; // 꼭 생성자를 통해 설정되어야 할 변수

    private final int level;   // 꼭 생성자를 통해 설정되어야 할 변수



    static class Builder{

        private int exp = 0;

        private int cash = 0;

        private final String name;

        private final int level;


        public Builder(String nameint level){

            this.name = name;

            this.level = level;

        }


        public Builder exp(int exp){ this.exp = exp; return this;}

        public Builder cash(int cash){ this.cash = cashreturn this;}

    }

}

여기서 주목할 부분은 각 함수 마지막 구문인 return this;이다. 여기서 this는 Builder 객체를 말한다. Builder 객체 자신을 리턴함으로써 생성자 호출 후 옵션 변수 setter 함수들을 연속적으로 호출할 수 있다. 가령

Builder("이름", 10).exp(값).cash(값).... 형태로 연속 호출이 가능하다는 말이다. 이를 통해 각 변수 값이 어떤 변수에 셋팅되게 되는지를 쉽게 알 수 있게 된다.

Builder 클래스에서는 최종적으로 build() 함수를 제공해 주어야 한다. build() 함수는 생성자와 setter를 통해 설정된 매개 변수들을 이용하여 Hero 객체를 생성하는 함수이다.

class Hero{

    private int exp;

    private int cash;

    private final String name; // 꼭 생성자를 통해 설정되어야 할 변수

    private final int level;   // 꼭 생성자를 통해 설정되어야 할 변수



    static class Builder{

        private int exp = 0;

        private int cash = 0;

        private final String name;

        private final int level;


        public Builder(String nameint level){

            this.name = name;

            this.level = level;

        }


        public Builder exp(int exp){ this.exp = expreturn this;}

        public Builder cash(int cash){ this.cash = cash;  return this;}


        public Hero build(){

            return new Hero(this);

        }

    }

}

이제 build() 함수가 호출하는 Hero 클래스의 생성자를 만들어 주어야 한다. build() 함수 내에서의 Hero 생성자는 this, 즉 Builder 객체를 받도록 되어 있다. 따라서 Builder 객체를 받는 생성자를 선언해 주어야 한다. 그리고 Builder 객체가 가지고 있는 변수 값들을 모두 가지고 와서 자신의 변수 값으로 셋팅하는 과정을 포함해야 한다.

class Hero{

    private int exp = 0;

    private int cash = 0;

    private final String name; // 생성자를 통해 설정되어야 할 변수

    private final int level;   // 생성자를 통해 설정되어야 할 변수


    static class Builder{

        private int exp;

        private int cash;

        private final String name;

        private final int level;

        public Builder(String name, int level){

            this.name = name;

            this.level = level;

        }

        public Builder exp(int exp){ this.exp = exp; return this;}

        public Builder cash(int cash){ this.cash = cashreturn this;}

        public Hero build(){

            return new Hero(this);

        }

    }


    private Hero(Builder builder){

        this.exp = builder.exp;

        this.cash = builder.cash;

        this.name = builder.name;

        this.level = builder.level;

    }

    public void print(){

        System.out.println(exp + " " + cash + " " + name + " " + level);

    }

}

위와 같이 Hero 클래스의 생성자를 구현해 주었다. 그리고 추가로 내부 변수 값을 확인할 수 있는 print() 함수를 구현해 주었다. 실행은 글의 첫머리에 나오는 실행 함수를 실행해 보면 된다.

이처럼 Builder 패턴은 객체 생성시 초기 설정 값들에 의해 발생할 수 있는 여러 문제점들을 해결해준다. 종종 setter 함수의 경우에는 가독성 지원 문제를 해결하기 위해 생성 과정과는 별도로 구현해서 사용하기도 한다.

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

Template Method 패턴  (0) 2016.09.10
Singleton 패턴  (0) 2016.09.10
Holder 패턴  (0) 2016.08.28
Adapter 패턴  (0) 2016.08.23
Decorator 패턴(synchronizedList의 구현 패턴)  (0) 2016.08.23
Posted by 이세영2
,

Java에서는 다음과 같이 가변 길이 파라메터를 지원한다. 이 가변길이 파라메터는 재밌는 특성들이 있는데 이를 활용하는 방법을 알아보자.


우선 기본적인 사용법 부터 알아보도록 한다.

public static void target(String...strings){

    String[] array = strings; // 배열화 된다.

    for(String str : strings) System.out.println(str);

}

이 함수의 사용법은 아래와 같다.

target("A", "B", "C", "D");

target();

실행 결과는 아래와 같다.

A

B

C

D


이와 같이 0~* 개의 동일한 타입을 파라메터로 받을 수 있다. 만약 또 다른 파라메터와 함께 사용하고 싶다면 가변길이 파라메터는 맨 뒤에 위치 시켜야 한다.


자 여기서부터 재밌는 실험을 해보도록 하자. 혹시 가변길이 파라메터로 받은 인자를 가변길이 파라메터 함수에 다시 넣을 수 있을까? 코드는 아래와 같다.

public static void wrap(String...strings){

    target(strings); // 받은 가변 인자 파라메터를 그대로 넘김

}

      

public static void target(String...strings){

    String[] array = strings; // 배열화 된다.

    for(String str : strings) System.out.println(str);

}

다음과 같이 실행해 본다.

wrap("A", "B", "C", "D");

wrap();

실행 결과는 위와 같다. 즉, 가변인자 파라메터로 받은 인자를 다시 가변인자 파라메터로 넘기는 것이 가능하다.

재밌는 것은 가변길이 파라메터에 집어 넣은 파라메터들이 무엇으로 변환 되는지이다. target() 함수 두번째 줄에 보면 가변길이 파라메터들이 배열로 변환됨을 알 수 있다. 이것이 의미하는 바는 무엇인가? String...strings와 같은 가변인자 파라메터 형식은 여러 String 객체가 나열된 형식("a", "b", "c"와 같은)도 인자로 인정해 주고, String[] 형식 즉, 배열 한 개도 인자로 인정해 준다는 의미이다. 따라서 아래와 같은 사용도 가능하다.

String[] array = new String[]{"a", "b"};

target(array);

결과는 예상할 수 있을 것이다.


이 특성을 이용하면 몇가지 유틸리티 성 함수를 만들어 볼 수 있다. 우선 여러 개별 객체를 받아 배열로 변환해 주는 toArray(String...strings)와 같은 함수를 만들어 볼 수 있겠다.

public static String[] toArray(String...strings){

    return strings;

}


또한 객체의 배열을 인자로 받는 함수를 이용하기 쉽게 만들 수도 있다. 만약 제공된 API가 다음과 같은 모양이라고 가정하자.

public void api(Object[]);

이런 경우 가장 손쉽게 사용하는 방법은 api(new Object{......})와 같은 형식이다. 하지만 위의 toArray() 함수와 같이 한번 가변인자 파라메터로 통과 시켜 주면 더 간편하게 api() 함수를 사용할 수 있다.

Posted by 이세영2
,

복사해서 붙여 넣으면 끝!

중복 코드가 나쁘다는 것은 익히 알고 있지만 문제는 중복 코드가 자주 나타난다는 점이다. 여러가지 이유가 있겠지만 가장 큰 이유는 복사해서 붙여 넣기가 너무 쉽기 때문이다. 조금 다른 코드를 작성하는 경우에도 복사해서 붙여 넣고 일부만 수정해 주면 끝이다. 즉, 중복을 만드는 것은 매우 쉬운 일인데 상대적으로 중복을 없애기는 어렵다. 중복은 머리가 없어도 만들 수 있지만 중복을 없애는 것은 머리가 없이 할 수 없는 일이다.


중복에 대한 인식

중복에 대해서 크게 문제 삼지 않는 경우이다. 중복 코드에 대한 인식 문제는 프로그래밍에 대한 자세와 연관이 깊다. 코드를 만들고 돌아가면 끝이고 다시는 그 코드를 들여다 보고 싶지 않은 개발자들이 보통 이런 사고를 가지고 있다.


객체지향 설계 및 디자인 패턴에 대한 지식 부재

요컨데 중복 코드를 제거하는 도구와 정형화 된 방법들에 대한 이해가 부족하기 때문이다. 객체지향의 겉모습은 온톨로지(Ontology)를 기반으로 한 실제 세계에 대한 인식을 컴퓨터 상에 모사한 것으로 보이지만, 깊숙히 들여다 보면 중복을 효율적으로 제거하는 도구들로 채워져 있다. 문제는 객체지향을 이해하는 것보다 코드를 만드는 일이 더 선행된다는 점이다. 즉, 중복을 제거할 수 있는 도구는 모른체 코드를 만들기 시작한다. 당연히 코드는 중복으로 넘쳐날 수 밖에 없다.


발견하기 어려운 경우

중복 코드가 너무 멀리 있는 경우, 중복 코드를 만든지 너무 오래된 경우, 중복 코드가 너무 짧은 경우, 중복 코드가 다른 코드에 뭍혀 있는 경우, 중복 코드 블럭 중간에 다른 코드가 들어가 있는 경우 등이다. 아래 코드를 보자.

class Example{

    private int data;

    public Example(int data){

        this.data = data; // 중복

    }

    public void setData(int data){

        this.data = data; // 중복

    }

}

이 코드는 중복이 있다. 아래와 같이 고쳐 줘야 한다.

class Example{

    private int data;

    public Example(int data){

        setData(data); // 중복 제거

    }

    public void setData(int data){

        this.data = data;

        /* data 갱신시 수행할 일들 추가 */

    }

}

얼핏 보면 우스운 일이다. 단 한 줄의 코드고, 직접 수행하던 코드를 함수까지 써가면서 수정했다. 코드는 줄지도 않았고 오히려 함수 콜에 의한 연산만 증가했다. 하지만 기존의 코드는 명백한 중복 코드이다. 만약 data 값이 변경 되었을 때 해야 할 일이 생겼다면 어떻게 할 것인가? 기존의 코드에서는 생성자와 setter 함수 모두 그 일을 수행하도록 변경해야 할 것이다. 하지만 아래 코드에서는 setData() 함수 내부만 수정해 주면 된다. 코드는 한 줄 바뀌었지만 "data가 갱신 되었을 경우 해야 할 일"에 대한 코드가 들어가야 할 위치가 setter 함수 쪽으로 단일화 되었다. 단 한 줄의 코드가 중복인 경우라도 수정에 닫혀 있지 않다면 중복이다. 그리고 중복을 해결하면 코드가 짧아질 것이라는 선입견을 버려야 한다. 중복 코드를 없애는 것은 코드를 짧게 줄이는 것이 아니고 중복 코드가 발생시킬 수 있는 문제를 차단하는 것이다.


다 똑같은데 일부만 다른 경우

대표적인 예가 순회(방문) 코드일 것이다. 즉, 여러 객체들을 방문해서 어떤 작업을 수행하는 코드이다. 이 때 방문 코드가 매우 길어지면 상대적으로 방문해서 할 작업 코드는 짧아진다. 그러면 다른 작업이 추가되면 방문 코드는 복사해서 붙여 넣게 된다. 이런 형태의 코드들은 발견해도 바로 수정하기는 어렵다.


다른 코드와 섞여 있는 경우

중복 코드가 블럭 A와 블럭 B의 연속이라고 하자. 그리고 이 중복 코드가 두 군데 이상 존재하는데, 어느 한 쪽에서 블럭 A와 블럭 B 사이에 흐름과 관계 없는 코드를 집어 넣었다고 가정하자. 이런 경우에 중복 코드를 발견해 내기가 어려울 수 있다. 그리고 상황에 따라서는 삽입된 코드가 중복 코드들과 유사하거나 어떤 영향이 있는지를 알기 힘들어서 분리해 내기가 어려울 수도 있다.

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

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

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

public void example(){

    for(int i = 0; i < list1.size(); i++){

        /* list1에 대한 연산 */

        /* list1 + list2 + list3에 대한 연산 */

    }

} 

list1에 대한 연산이 중복 코드일 경우, 아래에 있는 코드와의 관계를 재빨리 파악하기는 힘들다. 왜냐하면 둘 모두 같은 for 문에 묶여 있기 때문이다. 이 경우 코드를 유심히 들여다 보지 않고는 중복 코드가 있는지 발견하기 어렵다.


매개가 필요한 경우

이 경우가 해결하기 어려운 문제 중 하나이다. 분명 코드는 중복인 것처럼 보이지만 다들 조금씩 다르고 해결하기에는 쉬워 보이지 않는다. 아래 코드를 보자.

class Boundary{

    int northLimit = 100;

    int southLimit = 50;

    int eastLimit = 20;

    int westLimit = 10;

   

    public int getNorthLimit() { return northLimit; }

    public int getSouthLimit() { return southLimit; }

    public int getEastLimit() { return eastLimit; }

    public int getWestLimit() { return westLimit; }

    public void setNorthLimit(int northLimit) { this.northLimit = northLimit; }

    public void setSouthLimit(int southLimit) { this.southLimit = southLimit; }

    public void setEastLimit(int eastLimit) { this.eastLimit = eastLimit; }

    public void setWestLimit(int westLimit) { this.westLimit = westLimit; }

}

각 데이터들에 대해서 반복적인 패턴이 나타난다. 즉, 데이터 선언과 getter / setter 선언이 그것이다. 이러한 중복은 얼핏 해결이 불가능해 보인다. 완벽하게 동일한 코드가 아니고 유사한 코드들의 나열이기 때문이다. 이것을 해결하기 위해서는 매개체가 필요하다.

enum Direction{

    NORTH,

    SOUTH,

    EAST,

    WEST

    ;

    public static int size(){ return values().length; }

}

class Boundary{

    int[]boundaries = new int[Direction.size()];

    public int getLimit(Direction direction) { return boundaries[direction.ordinal()]; }

    public void setLimit(Direction direction, int limit) { boundaries[direction.ordinal()] = limit; }

}

이것이 중복의 해결책인 이유는 다음과 같다. 만약 Direction이 추가되었을 경우, Boundary 클래스는 수정이 전혀 필요하지 않게 된다. 기존의 코드에서는 새로운 데이터가 추가될 때 getter/setter가 추가 되어야 했다는 점을 주목하자. 이렇게 매개체가 필요한 형태의 코드들은 중복을 발견해 내기가 어렵다.

Posted by 이세영2
,

많은 책에서 코드 중복이 나쁘다는 말이 나온다. 이 글에서 코드 중복이 발생시키는 문제들을 정리해 보도록 하겠다.


완벽하게 논리적인 사고로 만들어진 버그

중복 코드가 있을 경우, 개발자가 완벽하게 논리적인 사고를 한다고 해도 버그가 발생하게 된다. 이 문제를 첫번째로 놓은 이유는 그만큼 이 문제가 심각한 영향을 미치기 때문이다. 개발자의 능력이 아무리 뛰어나고 논리적 사고를 잘 한다고 해도 중복 코드가 있으면 비 논리적인 코드를 만들어 내게 된다.

아래 코드를 보자.

public void function1(){

    task1();

}

public void function2(){

    task1();

} 

위와 같이 두 개의 함수가 있다고 가정하자. 편의상 두 함수를 나란히 배치했지만, 두 함수가 멀리 떨어져 있다고 가정하자. 중복된 코드는 task1()이다. 여기서 task2()를 추가한다고 가정해 보자. 논리적으로 task1() 이후에는 항상 task2()가 와야 한다. 그러면 각 함수의 task1() 이후에 task2()가 실행되도록 수정되어야 할 것이다.

그래서 개발자는 function1()에서 task1() 이후 task2()를 실행하도록 코드를 수정한다. 하지만 가정했듯이 두 함수가 아주 멀리 떨어져 있다면 function2() 함수가 있는지 모를 수도 있고, 그래서 task2()를 실행하도록 수정하지 않았다면 그 코드는 버그로 남게 된다.

이 문제는 중복 코드가 들어 있는 함수 간의 거리, 중복 코드를 만든 후 지나간 시간, 중복 코드의 개수, 중복 코드의 길이에 비례하여 커진다.


중복의 강요

중복된 코드는 중복을 강요한다. Unit Test를 한다면 중복된 코드에 대해서 중복으로 테스트를 만들어야 한다. 중복된 코드는 중복된 주석, 문서, 설명을 요구한다. 복사 해서 붙여 넣기는 쉽지만 그것을 계속 유지하기는 어렵다.


OCP(Open Close Principle) 위배

코드는 수정에 닫혀 있어야 한다는 SOLID 원칙에 위배 된다. 하나의 문제를 수정하기 위해 여러 코드를 수정해야 하기 때문이다.


코드량 증가

중복 코드는 코드의 양을 증가 시킨다. 코드의 양이 늘어나면 코드를 읽는데 걸리는 시간이 늘어나고, 수정이나 디버깅 이슈가 발생할 가능성이 높아진다. 같은 기능을 구현한다면 짧고 간결한 코드를 작성하는 것이 좋다.


중복 코드 동일성 검사

우스운 일일지 모르지만 중복 코드들은 모두 동일해야 한다. 그래서 개발자들은 종종 중복 코드가 완벽하게 동일한지를 검사한다. 이런 일이 발생하는 이유는 중복 코드를 발견하기는 쉽지만 생각보다 제거하기는 더 어렵기 때문이다. 어쨌든 이런 검사도 불필요한 비용을 발생시킨다.


Posted by 이세영2
,

Holder 패턴

5.디자인패턴 2016. 8. 28. 22:03

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


FactoryMethod 패턴은 객체의 생성을 책임지는 패턴이다. 특히 상위 타입을 가지고 있는 여러 하위 객체들 중에서 외부에서 입력한 조건에 맞게 생성해 주는 역할을 한다. 그리고 생성된 객체를 전달해 줄 때는 상위 타입으로 캐스팅해서 리턴한다. 따라서 FactoryMethod 패턴을 구현한 클래스 외부에서는 객체의 구체적인 타입을 알 수 없다. 이것은 구체적인 타입에 의존하면서 발생하는 강한 결합과 코드의 중복 등 객체지향에서 지양하는 여러 문제들이 발생하지 않도록 방지해 준다.


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


다른 점이 딱 한가지 있다. Factory Method 패턴은 외부에서 객체를 요구하면 매번 생성해서 전달해 주는데 비해 Holder 패턴은 하위 객체 각각에 대하여 딱 한번씩만 생성한다는 점이다. 이 점이 FactoryMethod와 Holder 패턴을 구분하는 가장 큰 차이점이다.


그러면 이제 Holder 패턴을 구현해 보도록 하겠다. 우리는 이 예제에서 온도 상태를 나타내는 객체를 생성하고자 한다. 온도 상태 객체는 ITemperature라는 인터페이스 형태의 상위 타입을 가진다. 그리고 하위 객체들은 구체적으로 Hot / Normal / Cold 라는 상태를 표현하는 객체이다. Holder 객체는 외부에서 받은 온도 값이 30도 이상이면 Hot 객체를, 0도 미만이면 Cold 객체를, 나머지의 경우에는 Normal 객체를 외부에 제공하게 될 것이다.


우선 상위 타입을 나타내는 ITemperature 인터페이스를 정의하도록 한다.



interface ITemperature{

    public String toDbString();

    public void operateThermostat(Thermostat thermostat);

}


이 ITemperature 인터페이스는 하위 객체의 구체적인 타입을 감추도록 만들어 준다. 인터페이스에서 구현하고 있는 메소드들이 크게 중요하진 않지만 이해를 돕기 위해서 설명을 하자면 다음과 같다. 우선 toDbString() 메소드는 이 온도 상태 객체가 데이터베이스를 만났을 때 자신의 상태에 대한 문자열을 생성하여 제공해 주는 메소드이다. operateThermostat() 메소드는 각 온도 상태에 따라 온도조절기(Thermostat)를 동작 시킨다. 상태 객체별로 다른 동작을 수행해야 하므로 온도조절기 객체를 인자로 받도록 되어 있다.


다음은 Hot/Normal/Cold를 나타내는 구체 객체를 구현한 코드이다.


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

}


Hot/Normal/Cold 상태를 표현하는 3개의 클래스가 정의되었다. 셋은 모두 ITemperature 인터페이스을 구현하고 있고, 모두 각자의 상태에서 해야할 인터페이스들을 구현하였다.


Holder 패턴을 구현하는 TemperatureHolder 클래스는 아래와 같이 정의 될 수 있다.


class TemperatureHolder{

    public static final ITemperature HOT = new HotTemperature();

    public static final ITemperature NORMAL = new NormalTemperature();

    public static final ITemperature COLD = new ColdTemperature();

    public static ITemperature getTemperature(int temperature){

        if(temperature > 30) return HOT;

        if(temperature >= 0) return NORMAL;

        else return COLD;

    }

}


우선 객체의 위쪽에 ITemperature 타입으로 Hot/Normal/Cold 총 3개의 객체를 선언하고 있음을 알 수 있다. 이 객체들은 단 한 개 씩만 생성될 것이므로 static final로 선언된다.


그리고 getTemperature() 라는 메소드가 있다. 이 메소드는 ITemperature 타입을 리턴하도록 되어 있고 온도 값(temperature)을 매개 변수로 받도록 되어 있다. Holder 객체는 외부에서 받은 온도 값이 30도 이상이면 Hot 객체를, 0도 미만이면 Cold 객체를, 나머지의 경우에는 Normal 객체를 외부에 제공하도록 구현되어 있다.



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





이대로 사용하는 것도 괜찮지만 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<ITemperaturetemperatures = 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 클래스가 가지고 있던 설계적인 문제들이 모두 해결 되었다.


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

Singleton 패턴  (0) 2016.09.10
Builder 패턴  (0) 2016.09.10
Adapter 패턴  (0) 2016.08.23
Decorator 패턴(synchronizedList의 구현 패턴)  (0) 2016.08.23
Pluggable Selector 패턴  (1) 2016.08.21
Posted by 이세영2
,

앞서 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
,

Adapter 패턴

5.디자인패턴 2016. 8. 23. 21:50

Adapter 패턴은 이미 구현 되어 있는 객체를 이용하여 다른 기능을 구현하는 패턴이다.


코드는 가능한 한 적은 것이 좋다. 이미 구현되어 있는 코드가 있다면 최대한 그 코드를 이용하는 것이 좋다. 중복은 코드를 만들어 내고 관리에 필요한 비용을 만들어 낸다. 그렇다고 해서 아무렇게나 기존의 코드를 이용하는 것은 문제가 있다.

객체지향에서 대표적으로 기존에 구현된 코드를 이용하는 방식에는 상속이 있다. 하지만 조금만 공부해 보면 상속을 통해서 상위 클래스의 기능을 물려 받은 후 하위 클래스로 다른 기능을 구현하는 것은 객체지향에서 금기시 되고 있다. 이는 다형성을 위배하고, 불필요한 인터페이스를 전파하며, 클래스 계층관계를 이해하는데 혼돈을 준다.


말로만 해서는 잘 이해가 안될 수도 있으니 하지 말라는 것을 한번 해보도록 하겠다. Stack을 구현하는 과정을 예로 들어 보겠다. Stack은 데이터를 push()와 pop()으로 넣었다 뺐다 하는 자료 구조이다. 익히 알다시피 Stack 뿐만 아니라 다른 대부분의 컬렉션들도 비슷한 기능은 이미 지원하고 있다. 그렇다면 다른 컬렉션 중 하나를 상속 받고, Stack이 지닌 고유한 특성, 즉 push()와 pop() 기능을 구현해 주면 될 것이다.


그러면 간단히 Vector를 상속 받아서 Stack을 구현해 보자.


상속을 통해 구현한 Stack(안티 패턴)

class Stack<T> extends Vector<T>{

    public void push(T t){ add(t); }

    public T pop(){

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

        T t = get(size() - 1);

        remove(size() - 1);

        return t;

    }

}


그리고 테스트 코드를 작성해서 실행을 시켜보자.

테스트 코드

public static void main(String[] args) {

    Stack<Integer> stack = new Stack<Integer>();

    stack.push(100);

    stack.push(200);

    stack.push(300);

    System.out.println(stack.pop());

    System.out.println(stack.pop());

    System.out.println(stack.pop());

}


실행 결과


300

200

100



원하는 대로 매우 잘 동작하는 것을 알 수 있다. 하지만 여기에 아무 문제도 없을까?

이제 테스트 코드에서 선언한 Stack 인스턴스에 인터페이스를 살펴 보자. 이클립스라면 stack 옆에 "."을 찍으면 이와 같은 화면이 뜰 것이다.




위의 그림에 보면 우리가 직접 선언한 push()와 pop() 조차 보이지 않을 만큼 많은 API가 선언되어 있음을 알 수 있다. 이것들은 대체 어디서 온 것일까? 그렇다. 우리는 Vector를 상속 받았다. 이 메소드들은 모두 Vector에서 온 것들이다. 우리는 Vector 구체 클래스를 상속 받았기 때문에 그 API를 그대로 물려 받은 것이다.


이것은 우리의 의도와는 다른 결과를 만들어 낸다. 우리는 분명 이 Stack 클래스를 Stack으로 사용할 것을 기대하고 배포하였다. 하지만 이 코드를 받아 본 다른 개발자는 이 클래스가 가진 다른 API들을 보고 자신에게 필요한 용도로 활용할 수 있을 것이라 생각했다. 그래서 add() 함수를 통해서 Stack에 저장되는 순서와 다른 형태로 저장도 하고, remove() 함수를 통해서 역시 순서와 상관 없이 데이터를 제거하는 코드를 만들었다. 이렇게 만들어진 코드에 대해서 Vector를 상속 받아 Stack을 만든 사람은 책임을 피하기 어려울 것이다.


문제는 또 있다. Vector 클래스에 대한 설계가 변경되어 기존의 API들을 고쳐야 하는 상황이 온 것이다. Stack 클래스에서 새로 구현한 API들은 그대로 사용해도 문제가 없겠지만 Vector 클래스의 API를 사용해버린 경우 Vector 클래스의 수정에 영향을 받게 된 것이다. 단지 기존의 기능을 좀 이용하려 했을 뿐인데 상위 클래스의 변경에도 취약한 코드가 되어 버린 것이다.


자 이런 경우에 활용할 수 있는 것이 바로 Adapter 패턴이다. 일단 Adapter 패턴은 상속을 받아 구현하지 않는다. 따라서 상위 클래스의 변경 문제로부터 자유로울 수 있다. 대신에 Adapter 패턴에서는 기존의 클래스를 맴버 객체로 가지고 있다가 자신이 해야 할 일을 맴버 객체에게 시키는 방식으로 동작한다. 자기가 해야 할 일을 맴버 객체에게 시킴으로써 목적을 달성하는 것을 위임이라고 한다. 이를 통해 기존의 코드를 이용하면서도 상속 문제와 상위 클래스의 API 문제를 해결할 수 있다.


그러면 이제 Adapter 패턴으로 구현된 Stack 코드를 보자.


Adapter 패턴으로 구현한 Stack 클래스

class Stack<T>{

    private Vector<T> vector = new Vector<T>();

    public void push(T t){ vector.add(t); }

    public T pop(){

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

        T t = vector.get(vector.size() - 1);

        vector.remove(vector.size() - 1);

        return t;

    }

}


우선 우리가 이용하고자 하던 기존 코드, 즉 Vector는 내부 맴버 객체로 선언이 되었다.(더 좋은 코드라면 강한 의존성 문제를 해결하기 위해서 의존성 주입 형태를 사용해야 한다.) 그리고 마찬가지로 push()와 pop()을 구현하는데, 이 때 기존 코드에서는 상속을 받았기 때문에 직접 호출했었던 API들을 이제는 vector 맴버 객체의 API를 호출하고 있다.


이렇게 구현된 경우, Stack 클래스는 Vector가 가지고 있는 API들은 하나도 노출시키지 않고 Vector의 기능을 이용할 수 있다. 만약 Vector 클래스의 API가 변경된 경우라면 Stack 클래스를 사용하는 코드에는 수정이 가해질 필요가 없고 단지 Stack 클래스 내부 코드만 수정하면 된다.

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

Builder 패턴  (0) 2016.09.10
Holder 패턴  (0) 2016.08.28
Decorator 패턴(synchronizedList의 구현 패턴)  (0) 2016.08.23
Pluggable Selector 패턴  (1) 2016.08.21
State 패턴  (0) 2016.08.15
Posted by 이세영2
,

Decorator 패턴은 동일한 타입의 객체를 품고 있는 패턴이다.

Decorator 패턴은 기본적인 기능을 구현한 클래스를 인자로 받아서 추가된 기능을 구현한 객체가 이용함으로써 기능의 확장이나 변경을 수행하는 패턴이다. 이 패턴의 장점은 동적으로 기능의 추가 제거가 가능하고, 기능을 구현하는 클래스들을 분리함으로써 수정이 용이해진다는 점이다. 마치 기본 제품에 포장지나 외부 디자인을 살짝 변경해 줌으로써 새로운 기능을 부여하는 것과 같다고 해서 이 명칭이 붙었다.


해결하고자 하는 문제

일단 다음과 같은 데이터가 있다고 가정하자.

int data;

개발자들에게 주어진 미션은 다음과 같다.

    1. data를 멀티쓰레드 환경에서 사용할 수 있도록 구현할 것. 즉 동시성을 만족할 것.

    2. data의 사용 환경이 싱글쓰레드일 경우 성능을 우선시 할 것.(즉 동시성 만족을 위한 코드를 제거할 것)


이런 문제를 처음 접한 사람들은 상당한 난감함에 빠질 것이다. 데이터의 동시성 코드를 구현함과 동시에 동시성을 구현하지 않은 코드도 구현하라니. 특히나 C언어를 중심으로 공부한 사람들은 사실 이 문제가 더더욱 어렵게 느껴질 것이다. 전역 변수 문제로 데이터를 다루는 함수를 특정짓기가 어렵기 때문이다.


하지만 일단 객체지향으로 넘어오고 나면 개념적으로 이 문제를 해결할 수 있는 실마리가 있다. 클래스의 정의로부터 클래스는 속성(데이터)과 행위의 집합임을 알 수 있고, 속성은 정보 은닉(infomation hiding)을 통해서 외부에 노출시키지 않을 수 있다. 그리고 데이터를 다루는 것은 데이터를 포함하고 있는 클래스의 메소드들로 한정 지을 수 있다.


먼저 해결의 단계를 밟기 전에 Decorator 패턴을 적용하기 용이하도록 interface를 하나 정의하도록 하겠다. 물론 int data를 보호하기 위해 만들어질 클래스를 위한 인터페이스이다. 단순히 데이터를 꺼내 가고 집어 넣는 get/set 함수들이다.

interface IData{

    public void setData(int data);

    public int getData();

}



이제 위의 인터페이스를 구현하면서 int data를 선언하고 그에 필요한 행위를 정의하는 클래스를 선언한다. 이 클래스를 선언하는 순간 해결의 첫 단계를 밟게 된다.

class Data implements IData{

    private int data;

    public void setData(int data){

        this.data = data;

    }

    public int getData(){

        return data;

    }

}


위의 클래스를 선언함으로써 얻은 효과는 다음과 같다.

1. data를 private으로 선언함으로써 외부에서 임의로 접근하여 생기는 동시성 문제를 차단하였다.

2. data를 다루는 행위들을 모두 한 클래스 안에 모아 둠으로써 동시성 문제가 확산되는 것을 방지하였다.


이제 적극적으로 동시성 문제를 해결해 볼 차례다. Java 뿐 만 아니라 다른 객체지향 언어를 사용하는 사람들도 익숙한 형태로 먼저 문제를 해결해 보도록 하겠다. 현재 data를 다루는 메소드는 getData() 함수와 setData() 함수 둘 뿐이다. 그리고 여러 쓰레드에서 이들 함수를 호출할 때 동시성 문제가 발생하게 된다. 이를 방지하려면 데이터를 다루는 코드 영역을 동시에 여러 쓰레드에서 접근하지 못하도록 제한하면 된다. 즉, Lock 또는 Mutex를 이용하는 것이다.


동시 접근을 제한해야 할 영역은 이미 위의 두 메소드로 한정되어 있으니 적용도 역시 간단하다.

class Data implements IData{

    private int data;

    private Lock mutex = new ReentrantLock();

    public void setData(int data){

        mutex.lock();

        this.data = data;

        mutex.unlock();

    }

    public int getData(){

        mutex.lock();

        int backup = data;

        mutex.unlock();

        return backup;

    }

}


ReentrantLock 클래스는 Java에서 사용하는 Lock의 구체 클래스이다. 다른 언어에서 사용하는 Mutex나 Lock이라고 생각하고 보면 된다. setData()와 getData()를 보면 data를 다루는 영역이 Lock으로 잠겨 있는 것을 알 수 있다. data를 사용하는 영역은 저 두 영역 뿐이고, 두 영역이 같은 Lock 객체를 통해 잠겨 있으므로 동시에 저 영역이 접근되는 것은 차단되어 있다.


자 이제 Java 사용자들이 익숙한 synchronized 키워드를 이용한 접근 제한을 구현해 보도록 하겠다. 다른 언어 개발자들은 그냥 위의 코드와 아래 코드가 동일한 기능을 한다고 이해하면 되겠다.

class Data implements IData{

    private int data;

    public void setData(int data){

        synchronized(this){

            this.data = data;

        }

    }

    public int getData(){

        synchronized(this){

            return data;

        }

    }

}


바뀐 부분을 보면, 기존에 Lock 객체에 의해 상하로 막혀 있던 것이 synchronized 키워드를 통해 감싸져 있고, this 객체, 즉 자기 자신에 의해 동기화 되어 있음을 알 수 있다. this 객체에 의해 동기화 되어 있으므로 같은 객체 안에서 synchonized 키워드로 둘러 싸인 영역은 중복 접근이 불가능하다.


이것으로 일단 1번 요구사항을 만족시켰다. 하지만 2번 요구사항을 만족시키는 것은 간단해 보이지 않는다. 2번 요구사항을 좀 해석해 보자면, 동시성을 만족시키는 구현과 동시성을 만족시키지 않는 구현을 바꿔치기 하기 용이하도록 구현하라는 것이다.


이제 위에서 선언한 interface가 사용될 때가 되었다. 일단 동시성을 만족시키지 않는 클래스는 그대로 사용하도록 한다. 그러면 동시성을 만족하는 클래스를 만들어야 한다. 하지만 이미 구현된 동시성 코드와 그렇지 않는 코드는 중복이다. 이러면 한 클래스가 수정되었을 때 다른 클래스는 수정되지 않을 수 있다. 따라서 중복 코드는 제거 되어야 한다. 이 때 사용하는 것이 Decorator 패턴이다. 아래는 Decorator 패턴을 통해 구현한 동시성 만족 클래스이다.

class SynchronizedData implements IData{

    private IData data;

    public SynchronizedData(IData data){

        this.data = data;

    }

    public void setData(int data){

        synchronized(this){

            this.data.setData(data);

        }

    }

    public synchronized int getData(){

        synchronized(this){

            return data.getData();

        }

    }

} 


위의 코드는 우선 IData 인터페이스를 구현하고 있다. 따라서 Data 클래스와 외부적 관점에서는 동일한 타입이 된다. 내부에는 첫째로 IData 객체에 대한 레퍼런스를 선언해 두고 있다. 그리고 생성자를 통해서 IData 타입의 객체를 입력 받도록 되어 있다. 우리가 선언한 Data 클래스가 외부적 관점에서는 IData 타입이다. 이것을 객체로 생성하여 집어 넣을 것이다. 그리고 data를 다루는 메소드들에서는 생성자를 통해 들어온 참조 객체를 그냥 이용하기만 한다. 대신 synchronized 블럭을 이용함으로써 동시성을 만족하도록 구현 되어 있다. 이렇게 하면 동시성을 만족하면서도 중복 코드가 없어서 수정에 닫혀 있는 형태의 구현이 완성된다.


그러면 이제 결과물을 종합해 보자.


클래스 다이어그램


최종 결과물

interface IData{ // 동일한 타입으로 만들어 주기 위한 인터페이스

    public void setData(int data);

    public int getData();

}


class Data implements IData{ // 동시성을 구현하지 않은 Data 클래스

    private int data;

    public void setData(int data){

        this.data = data;

    }

    public int getData(){

        return data;

    }

}


class SynchronizedData implements IData{ // 동시성을 구현한 클래스(Decorator 패턴)

    private IData data;

    public SynchronizedData(IData data){

        this.data = data;

    }

    public void setData(int data){

        synchronized(this){

            this.data.setData(data);

        }

    }

    public synchronized int getData(){

        synchronized(this){

            return data.getData();

        }

    }

} 


이 패턴을 사용하는 코드를 보자.

public static void main(String[] args) {

    IData data = new Data(); // 동시성이 필요없을 때

    IData data = new SynchronizedData(new Data()); // 동시성이 필요할 때


}


동시성이 필요하지 않은 경우에는 그냥 new Data()를 호출해서 바로 Data 클래스를 사용하면 된다. 동시성이 필요한 경우에는 같은 코드에서 new SynchronizedData()를 호출한 후 Data 클래스를 생성해서 넣어 주기만 하면 된다. 즉, 동시성을 만족하고 안하고는 저 코드 한 줄만 수정하면 되는 문제이다.


사실 이 구현 방식은 특별한 것이 아니다. 이미 Java 라이브러리에서는 동시성을 이런 방식으로 구현해 두었다. 다음의 코드를 보자.

사용 방법

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

    List<String> list = Collections.synchronizedList(new ArrayList<String>());


Java에서는 Collection을 자주 사용하게 되는데, 사용 빈도가 높은 만큼 Collection의 동시성 문제는 상당히 해결하기 어려워 질 수 있다. 하지만 두번째 줄에 보면 우리가 구현한 내용과 유사한 코드가 보인다. 저것이 Decorator 패턴을 이용해 구현한 동시성 지원 Collection 클래스 사용 방법이다. Collections.synchronizedList와 우리의 SynchronizedData가 얼마나 유사한지 알아보기 위해 라이브러리 소스를 살펴 보도록 하겠다.


Collections.synchronizedList 내부 소스 코드

static class SynchronizedList<E> implements List<E> {

    final List<E> list;

    SynchronizedList(List<E> list) {

        super(list);

        this.list = list;

    }

    public E get(int index) {

        synchronized (mutex) {return list.get(index);}

    }

    public E set(int index, E element) {

        synchronized (mutex) {return list.set(index, element);}

    }

    public void add(int index, E element) {

        synchronized (mutex) {list.add(index, element);}

    }

    public E remove(int index) {

        synchronized (mutex) {return list.remove(index);}

    }

    ......

}


일단 잡다한 코드들은 일부 지웠다. 눈여겨 볼 것은 데이터를 다루는 메소드들이다. 보면 mutex 객체를 통해 모두 synchronized 블럭으로 막혀 있고, 그 안에서 일반 List 객체를 다루고 있는 것을 볼 수 있다. 그리고 생성자에서는 당연히 일반 List 객체를 인자로 받도록 되어 있다. 구조적으로 위에서 구현 내용과 완전히 같음을 알 수 있다.

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

Holder 패턴  (0) 2016.08.28
Adapter 패턴  (0) 2016.08.23
Pluggable Selector 패턴  (1) 2016.08.21
State 패턴  (0) 2016.08.15
Strategy 패턴  (0) 2016.08.15
Posted by 이세영2
,

이 글에 앞서

모든 악의 근원 : 불완전성의 원리

- 비 구조적 언어와 예견된 위기


소프트웨어 기술에 대한 이야기를 본격적으로 하기에 앞서, 소프트웨어 - 불완전성 - 인간의 인지 능력에 대한 관계를 명확히 해 둘 필요가 있다. 특히 이 글에서는 인간의 인지 능력의 특징을 살펴 보도록 하겠다. 인간의 인지 능력이 어떤 것인지 알아야만 왜 소프트웨어 기술이 현재와 같은 방향으로 발전하게 되었는지를 이해할 수 있다.(내가 누구이며 왜 내가 그것을 알아야 하는지를 아는 것을 메타 인지라고 한다. 소프트웨어 기술이 왜 필요한지를 아는 메타 인지가 있어야 기술을 배우고 익히는데 주저함이 없어지고 과감하게 도전하게 되는 것이다)

우선 불완전성의 원리와 소프트웨어의 관계에 대해서는 모든 악의 근원 : 불완전성의 원리에서 이야기 했다. 그리고 초창기 소프트웨어 기술이 어떤 문제로 불완전성을 관리하지 못했는지를 비 구조적 언어와 예견된 위기에서 이야기 했다.

불완전성과 인간

만약에 소프트웨어에 불완전성이 없었다면 어떤 세상이 되어 있었을까? 즉, "완전성의 원리"라는 것이 증명이 되었다고 가정해 보자. "완전성의 원리"는 어떤 공리계가 무모순이고, 그 공리계는 자기 자신의 무모순에 대한 정리를 포함할 수 있다고 가정해 보자. 좀 더 쉽게 이야기 하자면, 어떤 알고리즘이 있고, 이 알고리즘이 오류가 없을 때 이 알고리즘은 자기 스스로 오류가 없음을 증명할 수 있다고 하자. 좀 더 과감하게 가정해 보면 모든 알고리즘이 스스로 오류가 있는지 없는지를 검사할 수 있다고 해보자. 

만약 그런 세상이었다면 이제 모든 수학자들이 알고리즘 연구에 매달리게 되었을 것이다. "알고리즘 스스로 자신의 알고리즘이 완벽함을 증명하는 알고리즘"은 당연히 먼저 만들어지게 되었을 것이다. 그 다음에는 "스스로의 완전함을 증명할 수 있으면서 다른 알고리즘의 완전함도 증명할 수 있는 알고리즘"이 만들어졌을 것이다. 그리고 이게 발전하면 "모든 다른 알고리즘의 완전함을 증명할 수 있는 알고리즘"이 만들어지게 될 것이다. 

이것은 상상하기 힘들 정도로 엄청난 알고리즘임에 틀림 없다. 인간이 어떤 소프트웨어를 작성하더라도 이 알고리즘은 소프트웨어에 오류가 있음을 알아서 찾아 준다. 좀 더 지나면 찾는 것에서 끝나지 않고 사람이 의도한 바를 알고 알아서 오류가 없도록 만들어 주기도 할 것이다. 이제 사람이 소프트웨어를 개발한다는 것은 이 알고리즘에게 자신이 개발하려는 것이 무엇인지 그 의도를 알려 줄 수만 있으면 된다. 이런 세계에서라면 소프트웨어 개발자는 전 세계에 몇 명 되지 않을 것이다.

몇가지 좀 더 상상력을 발휘해 볼 수도 있지만 애초에 가정에서 시작한 것이니 이쯤에서 접어 두겠다. 그럼 이제 현실은 어떠한가? 모든 소프트웨어는 불완전성의 원리에 의해 지배 된다. 소프트웨어 스스로는 자신의 동작에 오류가 없음을 증명할 수 없다. 증명은 불완전성의 원리에 의해 불가능하다. 오직 잘 관리할 수만 있을 뿐이다. 그러면 무엇이 그것을 관리할 수 있을까?

답은 "인간의 두뇌" 밖에 없다. 다른 소프트웨어는 자기 스스로도 완전함을 증명할 수 없다. 따라서 당연히 다른 소프트웨어를 관리할 수 없다. 결국은 소프트웨어를 만드는 사람이 소프트웨어를 관리할 수 밖에 없다. 소프트웨어의 문제는 인간의 문제인 것이다.

자 여기서 결론을 먼저 이야기 해 보도록 하겠다. 모든 소프트웨어 기술, 정확히 말해서 소프트웨어를 만드는 기술은 (소프트웨어를 위해서 만들어진 것이 아니라) 인간을 위해 만들어진 것이다.

기술까지 가지 않고도 이야기 할 수 있는 부분은 많다. int count를 int a라고 쓰지 않는 이유는 무엇인가? int count든 int a든 컴퓨터에게는 어떤 차이도 없다. 둘 중 무엇을 쓰든지 컴퓨터에는 어떤 문제도 발생하지 않는다. 문제는 인간에게 생긴다. 인간은 count 대신 a를 사용하는 것과 같은 짓을 10번만 해도 코드를 이해하기 힘들어진다. 코드를 이해하기 힘들어지면 소프트웨어를 개발하는 과정도 힘들어진다. 그리고 소프트웨어는 스스로 완전함을 증명할 수 없다. 그러면 소프트웨어에는 문제가 생긴다. 간단한 예시였지만 불완전성과 인간의 관계가 어떤 것인지 이해할 수 있었을 것이다.


인간의 인지 능력

소프트웨어의 불완전성을 관리하는 것은 인간의 몫이다. 그 중에서도 특히 인간의 두뇌이고, 이 중에서도 소프트웨어를 다루는 능력이 될 것이다. 우선 인간의 두뇌 능력을 포괄적으로 인지 능력이라고 부르도록 하자. 인지 능력이란 무엇인가를 이해하는 능력이다. 두뇌가 뛰어난 사람들은 더 많은 것을 이해할 수 있다. 그러나 두뇌가 뛰어나다고 해서 모든 것에 대한 이해가 다른 사람들보다 뛰어난 것은 아니다. 두뇌의 능력은 사람에 따라 다르고 경험에 따라 다르다. 그래서 개개인의 특성을 이야기하는 것은 별 의미가 없다. 우리에게 필요한 것은 소프트웨어를 관리할 수 있는 두뇌의 능력을 이해하는 것이다.

인지 능력 중 필요한 것을 나열하는 것으로 시작해 볼 수도 있다. 하지만 인간의 두뇌는 당연하고 일상적인 것을 받아 들일 때에는 그 중요성을 잊어버리는 경향이 있다. 그래서 두뇌의 능력에 맞게 오히려 인지를 방해해서 이해하기 어렵게 하는 것들이 무엇인지 이야기 해보자.


사실이 아닌 것

개인적인 경험일 뿐이지는 모르겠지만 다른 사람과 이야기를 하다 보면 너무나도 당연한 사실을 아니라고 우기는 사람들이 있다. 그래서 사실이 아닌 것을 사실이라고 말하는 사람에게 그 이유를 물어 보게 된다. 왜 믿는지를 이야기하는 과정에서 잘못된 가정이나 왜곡된 사실이 있는지를 알아보기 위해서다. 그래서 이야기를 듣고 나서는 일단 그렇게 믿게 된 이유를 알게 되긴 했다. 그 자리에서 그것을 알고 나서 얼마 지난 후에 그 사람의 이야기를 다시 조립해보면 다시 이해가 가지 않는다. 그리고 그 사람과의 대화 내용이 제대로 기억이 나질 않는다.

공감이 가는 이야기인가? 사람은 사실이 아닌 것을 기억하기 힘들어 한다. 사람의 기억은 (적어도 그 개인에게는) 너무나 자명한 사실이다. 사실이라는 것은 생존을 이롭게 만든다. 송이 버섯을 먹을 수 있다는 것은 사실이다. 그것을 기억하고 있으면 생존에 더 이롭다. 그래서 사람들은 생존에 이로운 것을 사실로 기억하고, 다른 것을 기억해야 할 때 이미 가지고 있는 기억과 연관지어 다른 것들을 계속 생각하게 된다. 그래서 사실인 것은 기억하기가 쉽고 사실이 아닌 것은 기억하기가 어렵다.

이것이 소프트웨어와 무슨 연관이 있을까? 아래 코드를 보자.

class Dog{

    public final int LEGS = 2;

    public final int HEAD = 25;

    public final String name = "cat";

}

class Cat{

    public final int LEGS = 4;

    public final int HEAD = 1;

    public final String name = "cat";

}

첫번째 클래스와 두번째 클래스 중에서 기억하기 쉬운 것은 어떤 것인가? 아마 Dog 클래스를 이해하려고 시도하면 머리 속에 혼란이 오기 시작할 것이다. 이 코드를 보고 기분이 나빠지기 시작했다면 미안하다. 하지만 이 코드로 의도하고자 했던 것은 이해할 수 있을 것이다. 우리는 소프트웨어를 작성하면서 소프트웨어가 완전히 논리적인 것이며, 그렇기 때문에 실제 세계와는 생각하는 방식이 완전히 다를 것이라고 추측하곤 한다. 하지만 이 예제에서 보듯이 소프트웨어를 만드는 일은 실제 세계에서 하는 일과 유사하다. 즉, 기억하고 있는 것과 새로 기억해야 할 것들을 연관지어서 생각하는 것이다. 단기적으로 Dog 클래스를 기억할 수는 있다. 하지만 그 기억은 오래가지 못할 것이다. 우리가 아는 실제 세상, 즉 사실과 다르기 때문이다. 사실과 다른 것은 생존에 도움이 되지 않는다. 따라서 머리 속에서 금방 지워진다.


개념의 매핑이 올바르지 않은 것

빨강 노랑 파랑 녹색 주황

위의 단어 중 맞는 매핑이 맞는 단어는?

답을 3초 이내에 맞춰냈다면 대단한 사람이라고 칭찬할 수 밖에 없다.


그러면 아래에 주어질 단어와 색깔의 매칭은 맞는가?

빨강 노랑 파랑 녹색 주황

성인이 이 매칭에 3초 이상 걸렸다면 약간 문제가 있는 것일 수도 있다. 이 문제는 내가 매우 좋아 하는 문제 중 하나이다. 위의 것은 맞는 건 단 하나 뿐인데 찾는데 시간이 걸리고, 아래는 모두 매칭 시켜 봐야 하는데도 시간이 덜 걸렸을까?

답은 매우 간단하다. 우리가 색깔에 대한 단어를 훈련할 때 그렇게 훈련했기 때문이다. 즉 빨강 색깔을 보여 주고 단어를 이야기 해주거나 빨강이라는 단어를 주고 색을 찾도록 훈련해 왔기 때문이다. 우리가 익히 아는 색깔이 아닌 다른 색깔을 주어주고 이름을 이야기 했을 때 다른 색에 비해 새로 배운 색깔을 찾아 내는데 어려움을 겪는 것도 같은 이유다. 이 매칭 문제는 훈련이 없이는 얻어지는 것이 아니다. 첫번째 문제가 어려운 이유는 단순하다. 저런 식으로 색깔과 단어를 매칭시키는 훈련을 한 적이 없기 때문이다.


비 구조적인 것

비 구조적이라는 말은 구조가 없다는 말이다. 간단히 말하면 여러 부분으로 분리될 수 없음을 의미 한다. 소프트웨어에서 보면 우리가 익히 알고 있는 함수나 객체, 패키지나 모듈 단위로 쪼갤 수 없음을 의미한다. 현대에 와서 이런 소프트웨어 구조는 상상도 할 수 없다. 그러기에는 현대 소프트웨어는 너무 크다.

그런데 잠깐 생각해보자. 왜 사람은 비 구조적인 것을 구조적인 것보다 어렵게 느끼는 것일까? 왜 비 구조적인 것은 소프트웨어 크기를 크게 만들 수 없고, 구조적인 것은 더 크게 만들 수 있는 것일까?

일단 구조적인 것은 부분으로 분리가 가능하다. 부분으로 나누고 나면 관리하기가 편해지는데 이는 로마에서 말하는 "분할하여 통치하라"는 격언과 딱 들어 맞는다. 사람은 잡다한 것들의 덩어리 보다는 명확히 규정되어 있는 것들의 부분 부분을 이해하는 데 훨씬 뛰어나다. 이것이 어느 정도나 그러냐 하면, 실제 세계는 매우 다양한 양상들의 뒤섞임을 통해 만들어지는데도 불구하고 자꾸 분리하려고 할 정도다. 미시적으로 보면 동물과 식물은 명확히 구분되지 않는다. 미생물의 영역에서 보면 동물인지 식물인지를 구분하기 힘든 생물들이 존재하기 때문이다. 하지만 우리는 생물이 동물과 식물로 구분되어 있다고 규정 짓는다. 이 편이 이해하기 훨씬 쉽기 때문이다. 동물과 식물을 나누면 동물의 특성과 식물의 특성을 규정 짓기가 쉬워지고, 그러고 나면 동물과 식물을 더 이해하기 쉬워진다. 그리고 나면 자연스레 그 경계 영역에 있는 생물의 특성도 이해하기가 쉬워진다.(이러한 방식이 문제가 되는 곳도 있다. 바로 인간 스스로를 이렇게 구분 짓는 경우다. 그 문제도 중요하긴 하지만 주제 밖이므로 일단 논리적인 것에 집중하자.)

분할은 인간의 인지 능력의 기본 특성이다. 지식은 분리를 통해 시작된다. 소프트웨어를 배울 때 처음에는 무작정 다 같은 소프트웨어인 줄 알고 시작하지만 금새 여러 언어가 있음을 알게 된다. 하나의 언어를 배우고 나면 다른 언어와의 차이를 모르지만 여러 언어를 배우다 보면 왜 언어 마다 다른 특성이 있는지 어느 정도 이해하게 된다. 문법을 익히고 나면 구조 설계라는 것이 있다는 것을 알게 되고, 코딩 룰이나 디자인 패턴과 같이 좋은 설계나 구현의 방식들이 있음을 알게 된다. 이런 형태로 지식은 계속 분화하면서 이해는 계속 깊어지게 된다.

그런데 만일 소프트웨어가 구조가 없다면 어떻게 될까? 그 소프트웨어를 접한 사람은 그것을 이해하기 위해 알고 있는 지식들을 총 동원하게 될 것이다. 장담하건데 적어도 전체 코드의 일부를 분할해서 그 블럭에 이름을 붙이려고 시도할 것이다. 설령 goto 문으로 이리저리 얽혀 있는 코드라고 해도 그렇게 이름을 붙여서 한 덩어리로 된 소프트웨어를 어떻게든 분할해 놓는 편이 이해하기가 훨씬 쉽기 때문이다.


너무 큰 것

너무 큰 소프트웨어는 인지 능력에 방해가 된다. 인간의 두뇌는 명백히 제한적인 리소스만 가지고 있기 때문이다. 소프트웨어의 위기는 소프트웨어가 커지면서 시작되었다. 그리고 그러한 노력이 구조적 언어와 객체지향 언어를 만들어 냈다. 하지만 언제나 이런 통찰력 있는 시도들을 모든 사람이 따르는 것은 아니다. 여전히 수천줄짜리 함수를 누군가는 만들어 내고 있고, 그 많은 소스들을 한 개의 파일에 담으려고 노력하고 있다. 마치 내일은 없는 것처럼.


비 가시적인 것

눈으로 볼 수 없는 것은 이해하기도 어렵다. 잘 그려진 플로우 차트나 시퀀스 다이어그램 만으로도 복잡한 코드를 손쉽게 이해해 본 경험이 있을 것이다. 코드는 스스로 그림을 그려주지 않기 때문에 코드만을 가지고는 소프트웨어의 동작을 이해하기 어려울 수 있다. 이것이 정적인 뷰와 동적인 뷰가 따로 존재하는 이유이다.


표현하기 어려운 것

모든 소프트웨어 개발자들은 이름을 짓는데 어려움을 느낀다. 어떤 통계에서는 소프트웨어 개발자로서 가장 어려운 것이 무엇인지 조사했더니 절반 가까이가 이름 짓는 것이라고 말했다고 한다. 이름을 짓는 것이 어려운 이유는 자신이 만들어 낸 것을 현실 세계에서 찾아 낼 수 있는 개념이 아니거나, 이미 유사한 개념들을 다른 곳에 많이 써버렸기 때문이다. 그래서 현실 세계에서의 개념이 빈약한 사람들은 프로그래밍을 잘 할 수 없다. 이름을 지을 수 없기 때문이다. 이름을 잘 지을 수 있는 것도 능력이다.

이름이 없는 것을 이야기 하는 일을 이야기 해보자.

"지금으로부터 바로 이전 해가 뜬 시점에서 얼마 지나지 않은 그 때에 색깔이 얼룩이거나 단색이면서 네 개의 지면에 붙은 관절이 있는 부위로 걷고 머리 쪽에 밥을 먹는 구멍으로는 "야옹"이라는 소리를 내는 짐승을 보았다."

"아침에 고양이를 보았다"

같은 이야기를 하려 해도 너무 힘들다. 이름이 없다는 것은 이만큼 힘들다. 이름이 없어도 표현하려고 하다보면 말은 길어지고 의미 전달은 힘들어진다. 그 마저도 표현력이 뛰어나고 이해력이 뛰어난 두 사람이 만났다면 모를까 이름 없이 소프트웨어의 구현을 이야기 한다는 건 너무 힘든 일이다. 


너무 긴 시간

시간은 기억의 적이다. 모든 기억은 시간이 지날수록 희미해진다. 아무리 날카로운 지성이라고 해도 긴 시간 앞에서는 무기력 할 수 밖에 없다. 시간이 지나면서 프로그래밍 실력이 늘어났다고 해서 예전에 만든 코드에 대한 기억력이 더 높아지지는 않는다. 따라서 인지 능력을 최대한으로 발휘하기 위해서는 가능한 짧은 시간 내에 필요한 모든 일을 처리해야 한다.

어떤 일을 인지하는데 시간이 문제가 되는 또 다른 이유는 시간이 길어지면 길어질수록 집중력은 떨어지고, 다른 일들이 중간에 발생할 여지가 커진다는 점이다. 그러면 다시 이전의 이해로 돌아가는데 시간이 걸린다. 이것은 효율적으로 인지 능력을 사용하는 방법이 아니다.


그리고 소프트웨어

이처럼 인지능력에 해가 되는 것들이 무엇인지 알게 되었으니 이를 소프트웨어와 연관 지어 보도록 하자. 소프트웨어는 불완전성을 가지고 있다. 따라서 소프트웨어를 개발할 때 불완전성을 관리할 수 있는 도구는 오직 두뇌밖에 없다. 그리고 두뇌에는 한계가 있다. 이 한계 내에서 두뇌를 효율적으로 사용하려면 인지 능력을 최대한 발휘할 수 있는 형태로 소프트웨어를 관리해야 한다. 따라서 인지능력이 올바르게 발휘되도록 하려면 소프트웨어는 다음과 같은 특성을 가지고 있어야 한다.

최대한 실제 세계와 유사하도록 만들 것, 개념( = 메소드 혹은 변수, 클래스의 명칭)과 실제(구현)가 맞도록 할 것, 구조적일 것, 작은 단위로 나눠져 있을 것, 가시적인 도구를 사용할 것, 명확하게 표현할 것, 개발의 주기를 짧게 가져갈 것.

이와 같이 불완전성을 관리하는 도구로서의 두뇌를 최대한 활용할 수 있는 형태로 소프트웨어를 만들어야만 성공적인 소프트웨어를 만들 수 있다. 소프트웨어 분야에서는 이러한 특성을 이미 경험적으로 알고 이를 소프트웨어 기술 형태로 지속적으로 발전 시켜왔다. 어떤 때에는 선구자들의 통찰력을 통해, 어떤 때에는 그 통찰을 언어나 툴, 프레임워크에 담음으로써 기술적인 완성도를 높여 왔다. 소프트웨어는 거의 컴퓨터 초창기부터 있어 왔으나, 소프트웨어 기술을 기반으로 해서 세계 최고의 매출을 올리는 기업들이 속속 등장하는 이유도 이러한 소프트웨어 기술들이 만들어지고 많은 개발자들이 이를 적용하게 되었기 때문이다.

이 다음 글에서는 이렇게 인간의 인지 능력을 최대한 활용할 수 있도록 만들어 준 소프트웨어 기술들에 대해 하나씩 알아 보도록 하겠다.

Posted by 이세영2
,

켄트 벡의 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
,