이제 책임을 분리함으로써 소프트웨어의 구조를 잘 만들 수 있게 되었으니 책임을 중심으로 패키지를 만드는 방식을 이야기 할 차례다.


<단일 패키지 내 클래스들 : 지금까지 만든 클래스들을 각각의 소스 파일로 분리하여 하나의 패키지에 넣으면 이와 같은 모양이 될 것이다.>


패키지(package)는 소프트웨어를 배포하는 물리적 단위다. 만약 잘 만들어진 소프트웨어를 재사용해야 하는 상황이라면 패키지 단위로 재사용하는 것이 가장 현명한 선택이다. 그리고 패키지 단위로 배포를 하기 위해서는 패키지 구조를 잘 만들어 두어야 한다. 다행히 지금까지 만들어 왔던 책임 중심의 구조는 패키지로 구조화 시키기에도 매우 적합하다. 잘 패키징 된 소프트웨어는 소스를 분석하기에도 편리하다. Java의 패키지 구조는 디렉토리 구조와 매치된다. 따라서 패키징을 잘 하면 디렉토리 별로 잘 분류된 문서들처럼 손쉽게 필요한 소스들을 찾아 볼 수가 있다. 여기서는 지금까지 만든 클래스들을 어떤 형태로 패키징 하는 것이 좋으며, 특히 구현 책임을 외부로 노출시키지 않기 위해서 어떤 형태로 소스 파일들을 분리하고 가시성을 설정하는 것이 좋은지 이야기 해보도록 하겠다.


가시성 조정

가장 간단한 가시성 부분을 먼저 해결해보도록 하자. 우리는 지금까지 여러 책임을 분리하면서 그 기저에 단 한가지 목표를 두었다. 그것은 Client와 구현 객체의 분리이다. 이를 위해서 계약 책임을 도입하고, 생성 책임을 분리하고, 조립 책임을 분리했다. 결국 Client 클래스 눈에 구현 객체들이 보이면 안되는 것이다. 이것을 아예 설계 단계에서 방지할 수 있는 방법은 구현 객체들의 가시성을 낮추는 것이다. 만약 구현 객체들을 패키지 가시성 수준으로만 낮춘다고 해도 다른 패키지에서는 구현 객체들이 존재하는지조차 알 수 없게 된다. Client 클래스와 구현 클래스들을 서로 다른 패키지에 넣고, 구현 클래스들의 가시성을 패키지 수준(즉 class 선언부에 아무런 가시성 키워드가 없는 형태)으로 떨어뜨려 놓기만 해도 실수로라도 구현 객체에 접근해서 의존성이 발생하는 것을 미리 막을 수 있다.

그 다음엔 Client와 Client와 구현 객체를 별도의 패키지로 분리해 둔다. 이 시기에는 다른 기준이 없기 때문에 구현객체와 Client만 떨어뜨려 놓으면 된다. 일단 Client는 사용자의 입장이고, 다른 모든 클래스들은 사용자가 사용할 대상에 해당된다. 따라서 Client 클래스만 별도의 패키지로 설정하도록 한다. 이제 남은 부분은 모두 MasterContract 클래스와 그 조립을 위한 클래스들이다. Java에서는 기본적으로 서브 패키지 개념이 없다. 사실 모든 패키지는 독립적이다. 하지만 아무래도 이것은 불편하다. 왜냐면 상위 패키지와 그 하위 패키지로 구분해 두면 어떤 패키지가 다른 패키지에 종속적인지, 반대로 이야기 하자면 어떤 패키지가 다른 어떤 패키지에 의존적인지를 알기가 수월할 수 있기 때문이다. 우리의 예제에서는 일단 오직 Client만 MasterContract 클래스를 사용하도록 되어 있기 때문에 패키지의 구조를 계층적으로 구성하도록 한다. 만일 패키지 간에 1:1의 의존 관계가 존재하지 않는다면 하위 패키지 형태로 분류하지 않는 편이 좋다. 아래는 client 패키지와 그 하위 패키지, 즉 master 패키지를 구분한 예이다.

<client 패키지와 master 패키지의 분리. master 패키지는 client 패키지에 의해서만 사용되고 있으므로 하위 패키지 형태로 구성했다.>


패키지 계층 구성

master 패키지 내부에 있는 클래스들은 아직도 정리가 안 된 느낌이다. Assembler나 MasterContract 클래스와 같이 좀 더 종합적인 구조를 가지고 있는 것과 Contract1, 2와 같이 하위 의존성을 위한 클래스나 interface들이 뒤섞여 있다. 이런 형태로 패키지가 구성되어 있으면 일단 소프트웨어의 구조를 분석하는데 불편하다. 어떤 클래스가 다른 어떤 클래스와 어떻게 의존하고 있는지를 알기가 어렵기 때문이다. 또 이런 형태는 배포의 기준점을 잡기가 힘들다. 예를 들어 Contract1에 해당하는 클래스들만 외부에 배포하고 싶다고 할 때 일일이 그와 연관된 클래스 파일들을 골라서 배포해야만 한다. 따라서 master 패키지 내의 소스 파일들도 하위 패키지들을 만들어 분리해 주는 편이 좋다.

master 패키지를 구분한다고 할 때 그 단위가 될 수 있는 기준을 먼저 살펴보도록 하자.

1. MasterContract를 구성하는데 필요한 클래스들.

2. 단일 계약 책임(Contract1, 2)과 그 구현 클래스들.


1번의 MasterContract를 구성하는데 필요한 클래스들은 MasterContract와 그 조립 책임을 담당하는 Assembler 클래스이다. 이들은 여전히 master 패키지에 존재할 필요가 있다. 2번에 해당하는 각각의 계약 책임 클래스들은 서로 분리할 필요가 있다. 첫째 이유는 master 패키지에 있을 클래스들과는 다른 패키지에 놓여져야 하기 때문이고, 두번째 이유는 Contract1과 Contract2는 서로간에 의존성이 없고, 따라서 서로를 모르는 편이 좋기 때문이다. 그리고 세번째로는 소스를 분석하는 입장에서 Contract1과 Contract2가 다른 패키지로 구분되어 있을 때 자연스럽게 체계를 파악할 수 있기 때문이다. 그렇다면 결국 패키지의 구분은 master 패키지의 하위 패키지로 contract1과 contract2를 위한 패키지를 도입하는 형태가 될 것이다. 아래는 이와 같은 형태로 분류된 패키지 구성이다.

<master 하위에 contract1과 contract2를 분류한 패키지 구성>


우선 contract1과 contract2에 대한 패키지를 master 패키지의 하위 패키지 형태로 구성한 이유는 master를 client 패키지의 하위로 분류한 이유와 같다. 만약 contract1이나 contract2가 외부의 다른 패키지와의 의존관계가 있다면 이렇게 종속적인 형태로 구성해서는 안될 것이다. contract1과 2를 구분하는 것은 이슈가 없을 정도로 합리적인 선택이다. 약간의 이슈가 될 수 있는 부분은 Contract1Factory나 Contract2Factory를 함께 패키지에 넣은 것이다. 이들을 master 패키지에 두는 것도 고려해 볼 수 있다. 하지만 패키지 구성을 조정해야 하는 상황이라면 생성 책임은 역시 계약 책임과 함께 존재하는 것이 나은 선택일 수 있다.

위의 패키지에서 주목할 부분은 Task 클래스와 Job 클래스이다. 이들은 각각 Contract1과 Contract2에 대한 구현 클래스이다. 앞서서 이야기 한 것처럼 Client 클래스가 이들 구현 객체는 몰라야 하는 상황이다. 따라서 Task 클래스와 Job 클래스의 가시성을 패키지 가시성 수준으로 떨어뜨려 두었다.


배포과정에서의 이슈

위의 구성에서 보면 client 패키지가 가진 의존성은 모두 하위 패키지 형태로 구성되어 있기 때문에 client 패키지를 배포하는데 전혀 문제가 없다. 또한 master 패키지를 배포한다고 해도 조립책임 및 하위 계약 책임, 그리고 그 구현 책임들을 포함한 패키지 계층이 배포될 수 있는 구조이므로 문제가 될 것이 없다. 한가지 생각해 볼 부분은 각 계약 책임의 구체 구현 객체들에 대한 처리이다.

첫번째 이슈는 배포 시 Task와 같은 구현 객체가 필요하지 않고, 따라서 Contract1Factory와 같은 생성 책임도 필요가 없는 경우이다. 즉 단지 계약 책임만을 가지고 가고 구현 객체는 필요 없을 경우에는 어떻게 하는 것이 좋을까? 잘 생각해 보면 Contract1Factory는 Task에 의존적이다. Contract1은 계약 책임만 가지고 있으므로 Task 클래스와 같은 구현 책임이나 Contract1Factory와 같은 생성 책임에 의존하지 않는다. 따라서 Contract1은 contract1 패키지 내에서 독립적이다. 만약 구현 객체가 필요 없는 경우를 대비한다면 생성 책임을 가진 Contract1Factory와 구현 책임을 가진 Task 클래스를 묶어 contract1의 하위 패키지로 만들어 구성할 수 있다.

<구현 책임과 생성책임을 별도의 하위 패키지로 구성한 경우>


위의 구성에서 만일 Contract1의 계약 책임만을 배포하겠다고 하면 하위 패키지를 제외하고 배포시키면 된다.

배포 과정에서의 또 한가지 이슈는 위의 구성에서 impl 패키지 하위의 구현 책임들을 모두 함께 배포하되, 배포를 받은 입장에서 새로운 구현 객체를 등록, 사용하려고 할 수 있다는 점이다. 이 경우는 Contract1Factory의 생성 책임이 추가되어야 한다는 의미이다. 이 때 만일 Contract1Factory가 컴파일 된 형태로 배포된다면 간단하게 Contract1Factory를 상속 받아서 추가된 구현 객체에 대한 생성 메소드를 추가하는 형태로 구현하면 된다. 대신 새롭게 구현된 구현 클래스나 생성 책임을 가진 클래스는 위의 패키지를 배포 받은 쪽 소스 상에 추가되어야 할 것이다.


단위 테스트(Unit Test) 이슈

위의 패키지 구성에서 또 한가지 고려해야 하는 부분은 단위 테스트이다. 우선 책임을 종류별로 분리하고 이를 각각 클래스화 시켰을 때 단위 테스트는 그렇지 않은 형태보다 훨씬 수월하다. 우선 조립 책임에 의해 의존 관계를 파악하기가 매우 쉽고, 필요하다면 계약 책임을 구현한 mock 객체나 spy 객체 등을 생성하여 재조립(reassemble)하기도 매우 편리하다. 또 각각의 책임이 명확하기 때문에 테스트 작성 계획을 세우거나 테스트 클래스들을 합리적으로 계층화 시키기에도 좋다.

단 한가지 이슈가 될 만한 부분은 구체적인 구현 객체의 가시성으로 인한 테스트 가능성이다. 구현 클래스들의 가시성을 패키지 가시성 수준으로 떨어뜨려 두었기 때문에, 테스트 클래스가 구현 클래스들과 같은 패키지에 있지 않으면 클래스가 존재하는지조자 알 수 없다. 이에 대한 해법은 이미 Spring 프레임워크 등에서 사용하고 있는 폴더 분리 방식을 통해 해결 할 수 있다. 이에 대해서는 아래 글을 참고하기 바란다.

package private class에 대한 unit test


Posted by 이세영2
,

한번 만들어진 소프트웨어는 거기에 그냥 머물지 않고 점차 발전한다. 더 많은 요구사항을 수용하면서 점점 더 복잡해진다. 처음에는 구현 - 계약 - 생성 책임을 가진 객체들을 분리시키는 것 만으로도 이미 충분하다고 생각할지도 모른다. Client가 겨우 몇 개의 계약 책임만을 다루는 형태로도 충분할 수 있다. 하지만 애초에 소프트웨어는 한계를 정해두고 성장하지 않는다. 단지 몇 개의 계약 책임만 가지고 있다가 어느 순간 그 복잡성이 폭발적으로 증가할 수도 있다. 지금 설명하려고 하는 조립 책임은 현대의 소프트웨어로 발전되는 과정에서도 가장 최근에 성립된 개념이라고 할 수 있다. 조립 책임은 소프트웨어의 규모가 충분히 커지지 않으면 그 필요성을 인지하기 힘들기 때문이다.

객체들은 서로 의존한다. 일단 계약 책임과 생성 책임을 분리함으로써 그런 의존 관계를 어느 정도 줄일 수 있다. 또한 Client가 간단히 나열할 수 있는 수의 계약 책임들만을 가지고 있다면 특별히 조립 책임에 관심을 가지지 않을 수도 있다. 하지만 계약의 종류가 점점 더 많아지게 되면 양상은 점차 복잡해진다. 대부분의 소프트웨어는 Client와 Contract interface만의 관계를 가지지 않는다. 계약들은 계약들간에도 관계를 가지게 된다. 예를 들어 A라는 계약을 Client가 사용하지만 A라는 계약은 B라는 계약에 의존할 수 있다. 그러면 A라는 계약을 사용하려면 B라는 계약을 구현한 객체에 대한 참조를 누군가가 만들어 주어야 한다. 이러한 참조의 연결은 계약의 개수가 증가하고, 계약 간의 의존 관계가 복잡해지면 함께 복잡해질 수 밖에 없다. 소프트웨어의 동작 시작 시점이나 동작 중에 Client 혹은 계약들 간에 필요한 의존성을 연결시켜주는 책임을 조립 책임이라고 한다.

만약 소프트웨어의 개발을 시작하는 시점에서 이와 같은 조립을 고려하지 않는다면 어떻게 될까? Client 클래스가 보통은 이 작업을 수행하게 될 것이다. Client 클래스 입장에서는 필요한 시기에 필요한 객체가 있기만 하면 되므로 생성의 위치나 다른 계약들의 관계에 대해서는 별로 신경쓰지 않을 수 있다. 이렇게 되면 시간이 갈수록 점점 더 사용해야 할 객체들이 늘어나면서 각 객체들간의 의존 관계에 의해서 조립 책임을 모아 정리하기가 어려워지게 된다. 그리고 어떤 계약은 다양한 구현 객체들을 가지고 있고, 이들 구현 객체는 사용자의 선택사항이나 요구에 따라서 동적으로 교체되어야 하는 경우도 있다. 이런 상황에서 객체를 잘못 교체하게 될 경우 소프트웨어의 동작은 예측하기 어려워진다. 따라서 여러 객체가 협력해야 하는 소프트웨어라면 초기부터 이런 조립 책임은 일찍 고려되어야만 한다.

그러면 조립 책임을 어떤 식으로 분리해야 하는지 알아보자. 이 조립 책임을 Client 클래스가 가지고 있었다고 가정해보자. 앞서 얘기했듯이 조립 책임에 대한 명확한 구분이 없을 경우 Client는 필요에 의해 언제든 자신이 사용할 객체를 생성하여 사용할 수 있었다. 따라서 조립 책임은 명확하게 나타나지 않고 코드 상에 분산되어 있을 가능성이 높다. 결국 Client가 직접 조립 책임을 지는 경우 Client는 본질적인 기능 뿐 아니라 조립 책임이라는 부수적인 책임도 지게 된다. 이는 단일 책임 원칙에도 어긋나고 코드는 쓸데없이 복잡해질 수 있다. 따라서 이 경우에도 별도의 클래스를 만들어서 조립 책임을 가지도록 하는 편이 좋다. 또한 Client가 개별 계약 구현 객체를 직접 사용할 수도 있고, 여러 계약 구현 객체들의 조합된 단일한 객체를 요구할 수도 있다. 전자의 경우 생성 책임과 조립 책임을 구분하기가 힘들기 때문에 예제에서는 조립 책임을 가진 클래스가 완성된(즉 잘 조립된) 객체를 제공해주는 것으로 가정한다.

interface Contract1{}              // 계약 책임1

class Task1 implements Contract1{} // 구현 책임1

interface Contract2{}              // 계약 책임2

class Job1 implements Contract2{}  // 구현 책임2

class Contract1Factory{            // 생성 책임1

    public static Contract1 createTask1(){ return new Task1(); }

}

class Contract2Factory{            // 생성 책임2

    public static Contract2 createJob1(){ return new Job1(); }

}

class MasterContract{              // 완성 객체

    Contract1 task;

    Contract2 job;

    public void setTask(Contract1 task) { // 계약1의 의존성 주입

        this.task = task;

    }

    public void setJob(Contract2 job) {   // 계약2의 의존성 주입

        this.job = job;

    }

}

class Assembler{ // 조립 책임

    Contract1 task;

    Contract2 job;

    MasterContract master;

    public MasterContract assemble(){ // 조립 메소드

        master = new MasterContract(); // 1

        reassemble(Contract1Factory.createTask1()); // 2

        reassemble(Contract2Factory.createJob1());  // 3

        return master;

    }

    public void reassemble(Contract1 task){ // 계약1의 재조립 메소드

        this.task = task;

        master.setTask(task);

    }

    public void reassemble(Contract2 job){ // 계약2의 재조립 메소드

        this.job = job;

        master.setJob(job);

    }

}

class Client{

    MasterContract master;

    public Client(){

        Assembler assembler = new Assembler();

        master = assembler.assemble();

    }

}

조립 책임을 보여주기 위해서는 적어도 두 종류의 계약이 필요하기 때문에 코드가 좀 길어졌다. 하지만 매우 단순한 코드이므로 천천히 살펴보도록 하자. 우선 두 종류의 계약 책임과 구현 책임을 가진 요소들을 선언했다. Contract1과 Contract2가 두가지 계약에 해당한다. Task는 Contract1의 계약을 구현하고 있고, Job은 Contract2의 계약을 구현하고 있다. 원래는 Task의 종류나 Job의 종류가 다양한 것을 가정하고 있지만 코드가 너무 길어질 것 같아서 제외하였다. 계약이 두 종류로 늘었으니 생성 책임도 둘로 늘어난다. 그리고 전체가 모두 조립된 객체를 내주도록 가정하였기 때문에 완성된 객체를 나타내는 MasterContract 클래스를 선언하였다. 이 MaterContract 클래스는 현재 일반 클래스이지만 만약 MasterContract 클래스 역시 다양한 변경이 가능하도록 하려면 역시 계약 방식(interface를 선언하고 이를 구현하는 방식)을 취해야 한다. 하지만 그런 관계에 대해서는 이미 다루었고 예제의 단순성을 위해 일반 클래스로 선언하였다. 

Assembler 클래스는 이 글에서 설명하고자 하는 조립 책임을 가진 클래스이다. 우선 가장 먼저 Assembler가 조립할 대상 객체들에 대한 참조 변수들을 모두 선언하고 있다. 이들 참조 변수들은 이후 유용하게 사용될 예정이다. assemble() 메소드는 완성 객체를 생성해내는 메소드이다. 이 메소드가 실행되면 MasterContract 객체가 완벽하게 조립되어 나오는 구조이다. 내부를 들여다보면 우선 MasterContract 객체를 생성한다(1). 이 시점에서는 객체는 생성되겠지만 MasterContract가 의존하는 다른 객체들은 아직 연결이 안된 상태이다. 이제 첫번째 계약 구현 객체를 연결한다(2). 이 과정에서 Assembler는 reassemble()이라는 메소드를 이용한다. reassemble()메소드는 메소드 오버로딩을 통해 총 두 개의 메소드로 구성되어 있는데, 각각 두 종류의 계약 구현 객체를 조립하는 메소드이다. 이들 메소드는 2와 3에 의해 각각 계약 구현 객체를 생성 받아 이를 MasterContract 객체에 설정 메소드를 통해 집어 넣는다. MasterContract 클래스는 setTask() 메소드 또는 setJob() 메소드와 같이 의존하는 객체를 외부에서 집어 넣을 수 있는 메소드를 제공한다. 이렇게 의존 객체를 설정하는 방식을 의존성 주입이라고 부른다. 계약을 구현한 구체 객체를 사용하는 객체 내부에서 직접 생성하지 않고 외부로부터 주입 받는 것을 디자인 패턴에서는 전략 패턴(Strategy Pattern)이라고 부른다. 이들 reassemble() 메소드들이 호출되면 전체 조립이 끝난다. 그런데 굳이 reassemble() 메소드를 추출하여 실행하게 된 이유는 무엇일까? 이는 Assembler 클래스 위쪽에서 MasterContract와 각 계약 구현 객체에 대한 참조 변수를 선언한 이유가 같다. Client 객체는 실행 도중 어떤 이유로든 MasterContract 객체가 의존하는 의존 객체들을 바꾸고 싶어 할 수 있다. 이런 경우 적절한 의존 객체를 생성하여 Assembler의 reassemble() 메소드를 호출하기만 하면 기존의 MasterContract 객체의 의존 객체가 변경될 것이다. 이런 방식으로 실시간으로 사용자의 요구가 바뀌거나 옵션에 따라 기능이 달리지는 등의 동작을 구현해 낼 수 있다.

이제 Client 클래스 내부에서는 Assembler를 통해 MasterContract 객체를 생성 받아 사용하기만 하면 된다.

그러면 이처럼 조립 책임을 가진 클래스를 별도로 유지하면 어떤 장점이 있는지를 살펴보자. 무엇보다도 우선 좋아지는 것은 Client 클래스의 코드이다. 조립 책임은 소프트웨어가 복잡해지면 복잡해질수록 더욱 복잡성이 증가한다. 최초에는 Client 코드에 별 문제가 없겠지만 소프트웨어의 확장성을 극대화하기 위해서는 Client 클래스의 코드가 단순해져야만 한다. 또 한가지 장점은 소프트웨어의 구조를 파악하기가 매우 수월해진다는 것이다. UML의 클래스 다이어그램과 같은 것이 없더라도 Assembler 클래스만 분석해보면 어떤 객체가 다른 어떤 객체와 의존관계에 있는지를 손쉽게 파악할 수 있다. 특히 Assembler 클래스는 오직 조립에 대한 책임만을 지고 있기 때문에 의존 관계를 파악하기에 가장 적절한 형태로 구현된다. 만약 조립 책임을 Client 클래스가 지고 있거나 여러 객체들이 분산해서 의존 객체를 생성, 조립하는 경우라면 서로의 의존 관계를 파악하기가 매우 어려울 것이다. 의존 관계를 파악하기 어려운 코드는 의존성 관리가 어렵기 때문에 조금만 수정이 가해지더라도 금새 관계가 복잡해지고 순환 참조와 같은 문제가 발생하게 된다. 조립 책임을 분리하는 것의 또 다른 장점은 새로운 객체에 대한 조립을 구현할 때 조립 과정을 바로 확인할 수 있기 때문에 의존 관계의 안정성 여부를 바로 파악할 수 있다는 점이다. 앞서 말했듯이 순환 참조는 의존 관계 분석이 어려울 때 발생한다. 만약 필요에 의해서 새로운 의존 관계를 만들고 싶을 때 바로 이를 조립 책임 객체에 구현해 보면 정상적인 의존 관계를 만들 수 있는지 여부를 바로 알 수가 있다.

그러면 이제 우리의 소프트웨어가 어떤 구조로 이루어져 있는지를 클래스 다이어그램으로 파악해 보자.

우선 Client 클래스는 MasterContract 객체와 Assembler 객체에 대한 의존 관계가 있다. 매우 단순하게도 단 두 개의 객체에 대한 의존관계만으로도 충분한 구조이다. 혹시 필요에 의해서 계약 객체를 변경해야 할 경우에는 Contract1Factory나 Contract2Factory에 접근하여 객체를 생성할 필요가 있을 수는 있다. 하지만 그렇다고 각 계약 객체나 계약 자체를 Client가 다루어야 할 일은 없다. 어떤 상황에서도 Client 코드는 개별 계약과는 상관 없다. 이를 통해 전체 소프트웨어의 유연성은 유지된다. MasterContract 클래스의 경우 역시 그 의존 관계를 명확히 알 수 있을 정도로 단순한 관계만을 유지하고 있다. 이를 통해 기능을 수행하는 객체들의 관계를 매우 명료하게 알 수 있다. Assembler 클래스는 이 클래스 다이어그램에서 가장 복잡한 요소이다. 다이어그램에 나타나진 않지만 Assembler는 Contract1과 Contract2, 그리고 MasterContract 객체에 대한 참조 변수를 가지고 있기 때문이다. 하지만 조립 책임만을 가진다는 점을 명확히 이해하고 각 객체들의 기능을 수행하는 작업을 함께 구현하지만 않는다면 코드는 전체적으로 매우 단순한 형태를 유지하게 될 것이다. 나머지 클래스들의 관계는 모두 이전의 예와 같기 때문에 별도로 설명하지는 않겠다.

조금만 욕심을 부리자면 Assembler가 모든 기능 객체들을 나열하여 가지고 있다는 점을 이용할 수도 있을 것이다. 즉, Visitor 패턴의 accept() 메소드를 Assembler 클래스가 구현하여 Visitor 객체를 받아 모든 구성 요소들을 돌면서 횡단 관심사들을 구현해 볼 수도 있겠다. 횡단 관심사를 Visitor 객체가 구현할 것이고, Assembler 클래스는 단지 Visitor 객체를 받아 호출만 해주는 것이므로 크게 부담이 될 것은 없다. 이에 대한 판단은 여러분들에게 맡기겠다.



Posted by 이세영2
,

이전 글에서 우리는 구현 책임을 가진 객체와 이를 사용하는 객체와의 의존 관계를 어떻게 더 좋은 관계로 바꾸는지를 알아 보았다. 구현 객체를 직접 사용하는 것은 위험하다. 구현 객체가 어떤 방식으로 수정될지 모르고, 만일 수정되면 사용하는 쪽의 코드 역시 수정되어야 하기 때문이다. 또한 다른 구현 객체를 사용하고자 할 경우에는 다른 구현 객체를 사용하는 코드를 중복으로 만들어 내야만 한다. 이렇게 중복이 많고 수정 가능성이 높은 코드가 좋은 품질의 소프트웨어를 만들어 낼 수는 없다. 그래서 중간에 계약 책임을 가진 Contract interface를 도입하였다. 이를 통해서 Client 객체는 직접 Task 객체에 의존하지 않도록 만들었다. 하지만 아직도 객체의 생성은 직접 생성자를 호출해야 하기 때문에 Task 객체에 대한 의존성이 완전하게 해결되지 못한 상태이다.

GoF의 디자인 패턴에는 이 문제를 해결할 수 있는 다수의 패턴을 포함하고 있다. GoF는 이들을 생성 패턴이라고 분류하였다. 생성 패턴은 GoF의 23가지 전체 패턴 중에서 5가지를 차지하고 있다. 단지 객체를 생성하는 것에 지나지 않는데도 이 같이 큰 비중을 차지한다는 것은 그만큼 객체 생성을 관리하는 것이 중요하다는 것을 의미한다. 이 글은 디자인 패턴을 소개하는 목적으로 쓰여진 것이 아니기 때문에 패턴에 대해서 자세하게 설명하지는 않겠지만, 이들 패턴이 추구하는 목표를 명확하게 하는데 주력해 보도록 하겠다. 여기에 가장 적합한 것이 Factory Method가 아닐까 한다.

디자인 패턴에는 동명의 디자인 패턴이 있다. 여기서 소개할 것은 디자인 패턴이 아니라 Factory Method라고 불리는 메소드, 즉 함수이다. "자바 API 디자인"이라는 책에서는 이 Factory Method를 활용하는 몇가지 예가 나와 있다. 우선 코드를 보면서 이야기 해보자.


class Base{}

class Subject extends Base{   

    // 기본형   

    private Subject(){}// 외부 생성 금지

    public static final Subject create(){ // Factory Method

        return new Subject();

    }

   

    // 구체적인 타입을 숨길 수 있다.

    public static final Base create2(){

        return new Subject();

    }

   

    // 인스턴스 캐싱(Flyweight)과 함께 적용이 가능하다.

    private static Subject instance = null;

    public static final Subject create3(){

        if(instance == null){

            synchronized(Subject.class){

                if(instance == null){

                    instance = new Subject();

                }

            }

        }

        return instance;

    }

   

    // 객체 생성 시점 이후 해야 할 일을 수행하기 용이하다.

    public static final Subject create4(){

        Subject s = new Subject();

        // 객체 생성 이후 초기화 작업

        // this 키워드를 활용하거나 다형성을 이용하는 위험한 작업을 수행

        return s;

    }

   

    // 객체 생성과 그 직후의 일을 동기화 할 수 있다.

    public static final synchronized Subject create5(){

        Subject s = new Subject();

        // 객체을 비롯하여 생성 이후 동작시까지 동기화가 필요한 작업을 수행할 수 있다.

        return s;

    }

}

위의 코드는 Factory Method를 활용하는 다양한 예들이다. 가장 기본적인 형태는 생성자를 private으로 바꾸고(외부에서 생성하는 것을 금지하기 위해서), create라는 자기 자신 타입을 리턴하는 정적 메소드(static)를 선언한 후, 그 안에서 자기 자신의 객체 하나를 생성하여 제공하는 것이다. 이 방법은 외부에서 자신의 생성자 메소드를 호출하는 것을 방지하고, 객체의 생성이 완료된 후부터 create() 메소드의 호출이 종료될 때까지, 즉 객체가 리턴될 때까지 외부에서 생성된 객체를 접근하지 못하도록 한다. 여기까지는 일반적인 생성자 메소드와 용도가 다르지 않다. 그 활용은 다음에 나오는 여러 create 메소드들이 보여주고 있다.

첫번째 용도는 구체적인 객체의 타입을 감추는 것이다. 위의 Subject라는 클래스는 Base 클래스를 상속 받는다. 즉, Subject라는 클래스는 다형성에 의해서 Base라는 타입도 가지게 된다. 유연성을 발휘하기 위해서는 구체적인 타입을 숨기는 것이 좋다는 점을 이미 알고 있다. create2() 메소드는 Subject라는 구체적인 객체를 생성하지만, 생성된 객체가 리턴될 때는 그 상위 타입인 Base 타입으로 리턴된다. 따라서 이 메소드를 통해 생성된 객체를 받는 쪽에서는 그 구체적인 타입을 알 수 없다.

두번째 용도는 인스턴스 캐싱이다. 어떤 객체는 소프트웨어 런타임 전체에서 단 한번만 생성되는 편이 좋을 수 있다. 그리고 그 객체가 이용될지 아닐지는 잘 모르는 경우가 있다. 이런 상황에서는 객체가 사용되는 시기까지 생성을 늦추는 편이 좋다. 즉, create3()과 같이 객체를 외부에서 요구하는 순간까지 객체의 생성을 미루다가, 일단 한번 객체가 생성된 이후에는 그 객체를 instance 참조 변수에 할당하여 두고, 이후 또다른 create3() 메소드 호출이 있으면 이미 생성된 객체를 내주도록 하는 것이다. 이러한 지연 로딩 방식을 인스턴스 캐싱이라고 부른다. 그리고 이렇게 늦게 객체를 생성하는 것을 디자인 패턴에서는 Flyweight 패턴이라고 부른다. 또한 위와 같이 객체 생성에 Flyweight 패턴을 적용한 것을 특별히 Singleton 패턴이라고도 부른다.

세번째 용도는 객체의 초기화 작업이다. 많은 객체들이 생성과 함께, 또는 생성 직후에 초기화 작업을 필요로 한다. 그래서 보통 생성자 내부에 여러 코드들을 집어 넣곤 하는데, 이것이 항상 안전하지는 않다. 어떤 상황에서는 메모리가 부족할 수도 있고, this 참조가 필요해서 사용해야 할 경우도 있다. 이런 작업은 객체가 생성된 이후에는 안전하지만 생성자 내부에서 사용하는데는 위험이 따른다. create4()와 같이 Factory Method를 활용하면 이 같은 문제를 해결할 수 있다. Factory Method는 객체의 생성을 안전하게 완료하고, 메소드가 종료되기 전까지의 시간을 벌어 준다. 만약 생성자 내부에서 해야 할 위험한 일들을 Factory Method의 객체 생성 이후에 수행한다면 객체에 대한 참조를 이용한다거나, 객체가 이용해야 할 다른 객체를 생성하는 등의 작업을 할 때 안전성을 확보할 수 있다.

네번째 용도는 생성의 동기화이다. create5() 메소드는 synchronized 키워드를 통해 Subject 클래스 전체에 대해 동기화 되어 있다. 만일 Subject가 멀티쓰레드 환경에서 동작하도록 설계 되어 있어야 한다면 객체의 생성 역시도 멀티쓰레드 환경에서 보호될 필요가 있을 것이다. 특히 생성자에서 여러 작업을 수행해야 하는 경우에는 더더욱 그렇다. 생성자는 synchronized 키워드를 붙일 수 없기 때문에 보다 안전하게 Factory Method를 이용할 수 있다.


자 우리는 이 중에서도 특히 객체들 간의 의존 관계를 끊는데 더욱 관심이 있다. 이와 관련해서는 구체적인 객체의 타입을 감추는 Factory Method의 특성을 이용할 수 있다. 하지만 이제와서 밝히는 것이지만 Subject 클래스 내부에서 Factory Method를 제공하면서 리턴 타입만 Base 타입으로 하는 형태는 사실 의미가 없다. 상위 타입을 리턴하는 Subject 클래스의 create2() 메소드를 사용하는 코드는 다음과 같다.

Base base = Subject.create2();

위 코드를 보면 알 수 있듯이 Subject 클래스라는 명칭(symbol)이 나온다. 명칭이 나오면 의존 관계가 성립된다. 이런 상황에서 위의 코드가 Subject라는 구체적인 객체에 대해 모른다고 말할 수 없다. 따라서 중요한 점은 Subject 클래스가 아니라 다른 클래스가 Subject에 대한 Factory Method를 제공해 주어야 한다는 점이다.

다시 Client - Contract - Task의 예로 넘어가보자. 우리는 Client와 Task간의 의존성을 끊고 싶다. 그럴려면 Factory Method를 통해 Task 객체를 생성하되, Contract 타입으로 리턴해 주도록 하면 된다. 그러면 Task에 대한 Factory Method를 어디에 구현해야 할까? 일단 Client 클래스는 탈락이다. Client와 Task간의 의존성을 끊는 것이 목적인데 Factory Method 내부에서는 Task의 생성자를 호출해야 하기 때문이다. Task 클래스도 탈락이다. Subject에서의 예와 같이 Task 클래스가 직접 Factory Method를 제공하게 되면 의존 관계가 없어지지 않기 때문이다. Contract interface도 역시 탈락이다. Contract는 interface이기 때문이다.(만약 계약 책임을 interface가 아니라 추상 클래스나 일반 클래스가 가진다면 Factory Method를 그 클래스에 구현하는 것이 가능하다. 이 방법은 "테스트 주도 개발(TDD)"이라는 책에 잘 나와 있으니 참고하기 바란다. 이 예에서는 책임을 분리하는 방법을 설명하는 것이 목적이므로 계약 책임과 생성 책임을 동시에 갖게 되는 방식을 사용하지는 않겠다.) 그러면 남는 방법은 별도의 클래스에게 생성 책임을 맡기는 것 뿐이다. 아래의 코드를 보자.

interface Contract{ // 계약책임

    public void method();

}

class Task1 implements Contract{ // 구현책임

    public void method(){}

}

class Task2 implements Contract{ // 구현책임

    public void method(){}

}

class ContractFactory{ // 생성 책임

    public static Contract createTask1(){ return new Task1(); }

    public static Contract createTask2(){ return new Task2(); }

}

class Client{

    Contract task = ContractFactory.createTask1();  // 1

    public void function(){

        task.method();

    }

}

위 코드에는 새롭게 CotractFactory 클래스가 추가되었다. 이 클래스에는 각종 Task 객체들을 생성할 수 있는 Factory Method들이 선언되어 있다. 잘 보면 Task1이나 Task2와 같은 객체를 생성하긴 하지만 Contract 타입으로 리턴하는 것을 알 수 있다. 따라서 ContractFactory를 통해 Task 객체들을 생성하면, 객체들을 리턴받은 쪽에서는 구체적인 타입을 알 수 없다. Client 클래스에서는 이전에 직접 Task1 또는 Task2의 생성자를 호출하는 대신 1의 코드와 같이 ContractFactory를 이용하여 Task 객체들을 생성하고 있다. 자세히 보면 이제 Client 클래스의 코드에는 어느 곳에서도 Task1이나 Task2를 찾아볼 수 없다. 즉 이제는 구체적인 구현 책임을 가진 객체들과의 의존이 완전히 끊어졌음을 알 수 있다.

현재의 상태를 클래스 다이어그램으로 나타내면 아래와 같다.

이전의 다이어그램과 비교해 보면 다음과 같은 부분이 달라졌다. 일단 ContractFactory라는 클래스가 추가되었다. Client 클래스는 ContractFactory를 통해 Contract 타입의 객체를 얻어가도록 변경되었다. 그리고 Client가 직접 Task1이나 Task2와 의존하던 의존성 연결이 사라졌다. 대신 ContractFactory가 의존성을 대신 가지게 되었다. 이제 전체적으로 보자면 Client 클래스는 Task1과 Task2에 대한 직접적인 의존 관계가 하나도 없다. 즉, Client 클래스는 구체적인 구현 책임을 가진 클래스와의 의존성이 없어진 것이다. 대신 Contract interface와 ContractFactory 클래스와의 의존성이 생겨났다. 일단 구체적인 구현 객체들과의 의존성을 없애겠다는 목표는 달성하긴 했지만, 구체적인 작업을 수행하는 것과는 별로 상관이 없어 보이는 Contract interface나 ContractFactory와 같은 부수적인 요소들과의 의존 관계가 생겨났다. 그러면 지금의 이 구조가 기존의 구조보다 더 낫다고 말할 수 있는 이유는 무엇일까?

가장 먼저 이야기 할 수 있는 것은 새로 발생한 의존은 구체적인 작업, 즉 기능과는 무관하다는 점이다. 기능은 요구사항의 변화에 민감하다. 요구사항이 변하면 기능도 변한다. 요구사항이 추가되면 기능도 추가 되어야 한다. 하지만 기능과 무관한 것은 그 요구사항도 잘 변하지 않는다. 간단하게 말해서 Client는 변화에 민감한 객체들과의 의존관계를 끊고 잘 변하지 않는 것들과의 의존관계를 맺게 된 것이다. 따라서 Client는 구체적인 구현 객체들의 변화에 의해 부수적인 변화가 발생할 가능성이 없어졌다. 또 다른 이점은 구체적인 구현 객체들의 수정도 자유로워졌다는 점이다. 구체적인 객체를 사용하는 곳이 많다는 것은 구체적인 객체를 수정할 경우, 그에 영향을 받아 수정되어야 할 객체들이 많다는 것이다. 이는 새로운 요구사항을 수용하는데 있어서 보수적이 되는 이유가 된다. 이런 형태로 구성된 소프트웨어는 사용자의 요구를 받아들이는데 보수적이 될 수 밖에 없다. 그리고 이런 소프트웨어는 사용자에게 인기가 없다. 또 한가지 이득은 계약에 의해 발생되는 것이다. 구체적인 구현 객체들은 계약을 무조건 따라야 한다. 따라서 계약을 수정할 경우 구체적인 객체들은 어쩔 수 없이 함께 수정되어야 한다. 이것은 interface를 수정하기가 어려워지는 이유이다. 하지만 반대로 이것이 이득일 경우가 있다. 즉, interface를 수정해야 할 중요한 요구사항이 들어왔다고 가정해보자. 그리고 잘 고려해 보니 유사한 요구사항들도 새로운 요구사항의 일부를 수용해야 할 것으로 판단이 되었다고 가정해 보자. 이 경우 기존에는 어떤 요구사항들이 어떤 구현 객체에 구현되어 있는지를 찾아보고 수정해야 한다. 그리고 이 수정에 따라서 구현 객체를 사용하는 Client 쪽도 함께 수정해 주어야 한다. 구체적인 구현 객체의 수만큼 말이다. 하지만 계약 책임을 지는 interface를 이용할 경우, interface를 수정하게 되면 자연스레 구체적인 객체에 대한 수정 사항이 발생한다. 구체적인 객체들은 interface를 제대로 구현하지 않으면 컴파일 자체가 안되기 때문이다. 따라서 수정하려는 입장에서는 문제가 매우 명확하게 보인다. 또한 Client 객체의 경우에는 계약 책임을 가진 interface를 이용하면 단지 interface에 의존하는 코드만 고치면 된다. interface를 이용하지 않았다면 구현 객체와 의존하는 코드들을 모두 고쳐야 했을 것이다. 마지막으로 ContractFactory를 이용하는 경우에는 생성이라는 책임이 명확하므로 생성에 대한 변경이 이루어졌을 때 그 책임이 어디 있는지 명확하고, 그에 대한 변경이 어디서 이루어져야 하는지를 정확하게 알 수 있다. 객체 생성은 어느 곳에서나 이루어질 수 있다. 참조 변수를 선언할 때도 생성할 수 있고, 생성자에서도 생성할 수 있으며 메소드 내에서도 생성할 수 있다. 이러한 자유도는 "관리" 측면에서는 매우 불편하다. 예를 들어 기존에는 객체에 대한 생성 이후 즉각 정상적인 동작이 가능했었는데, 어느 순간 초기화 작업이 필요해졌다고 가정하자. 그러면 이제 객체를 생성하는 코드들을 일일이 찾아가서 수정해 주어야 한다. 이것은 중복코드를 발생시킬 뿐더러 버그가 발생할 가능성도 높이게 된다. 만약 위와 같이 Factory Method를 구현한 별도의 생성 클래스를 두게 되면 생성 후 초기화 작업이 추가되는 경우에도 Factory Method에 단 한번만 초기화 작업을 넣어주면 모든 수정이 완료된다. 새로운 구현 객체가 추가되거나 기존의 객체가 사라진 경우 일단 ContractFactory의 Factory Method만 추가/삭제 해주고 나면 그 이후에는 다른 코드들이 모두 에러 처리되고 컴파일 불가능 해질 것이기 때문에 손쉽게 문제를 찾아 고칠 수 있게 된다.








Posted by 이세영2
,

다시 앞서의 예로 돌아가보자.

class Task{

    public void method(){}

}


class Client{

    Task task = new Task();    // 1

    public void work(){

        task.method();         // 2

    }

}

책임의 종류를 따르자면 Task 클래스는 구현의 책임을 가지고 있다. 현재 명시적인 기능을 구현하지는 않았지만, Client 입장에서 Task는 어떤 기능을 수행하길 바라는 대상이다. Client 클래스는 1에 의해서 Task 객체를 생성하는 책임을 가지고 있다. 그러면서 2와 같이 Task 객체의 메소드를 이용함으로써 의존하고 있다.

만일 만들고자 하는 소프트웨어가 간단히 구구단 정도 출력하는 일이라면 지금의 구조로도 충분히 훌륭한 소프트웨어를 작성할 수 있다. Task 객체에 구구단 출력 기능을 구현하고, 구현된 구구단 출력 메소드를 Client 객체가 호출해 주면 되기 때문이다. 그리고 위의 예에서는 다른 어떤 보호 장치도 없이 Task 객체에 Client가 직접 의존하고 있다. 이것은 마치 Task 객체가 절대 사라지지도, 변경되지도 않을 것임을 확신하고 있는 것이나 다름 없다. 아무래도 Task 객체는 Client 객체와 매우 밀접한 관계에 있는 듯 하다. 그렇지 않고서야 서로 어떤 안전장치도 없이 의존할 수 있겠는가?

이와 같은 상황을 조금 더 이해하기 쉽게 하기 위해서 현실 세계의 예를 하나 도입해 보도록 하겠다. 잠시 스스로가 작은 음식점 주인이 되었다고 가정해보자. 적은 자본을 투자한 관계로 우선 혼자서 가게를 내기로 한다. 혼자 가게를 낸 상황이라면 가게의 모든 일을 주인인 내가 다 해야 하는 상황이다. 위의 예에서 보면 Client가 Task 객체에 의존하지 않고 혼자서 일을 모두 처리하는 상황이다. 시간이 조금 지나서 가게를 찾는 사람도 늘고, 아무리 음식점이라도 전문성이 중요하기 때문에 나보다 더 나은 주방장을 구해 일을 해보기로 한다. 그러면 주인인 나는 고용된 주방장에게 일을 맡기는 형태로 가게를 운영하게 될 것이다. 위의 예에서 Task 객체를 생성하고 메소드를 호출하는 과정과 유사하다. 하지만 뭔가 좀 위태로워 보인다. 주방장을 고용하는데 있어서 어떠한 안전장치도 없다. 음식점의 예에서 보면 취직한 주방장이 가게를 갑작스레 그만 둔다거나, 성실성이 부족하여 종종 가게에 나타나지 않는다거나, 음식 솜씨가 좋다고 해서 고용했는데 맛이 형편 없다거나 하는 위험한 상황이 있을 수도 있는데 말이다. 이런 상황에서 주인인 내가 안전하게 주방장을 고용할 수 있는 방법은 무엇일까? 바로 주방장과 계약을 하는 것이다. 가게 주인인 나는 주방장을 고용하기 위한 일련의 계약서를 작성해 둔다. 출퇴근 시간, 월급여, 경력 사항, 업무 내역과 같이 이슈가 될만한 사항들을 고용의 조건으로 만들어 두는 것이다. 이렇게 되면 좋은 점이 두가지 생긴다. 하나는 고용 조건에 합의한 주방장과 업무 시 직접 주방장이 할 수 있는 일을 물어보고 일하지 않아도 된다. 단지 계약서에 나열된 업무를 중심으로 일을 시키기만 해도 계약에 합의한 주방장은 당연히 그 일을 할 수 있을 것이다. 또 만일 기존의 주방장이 일을 그만두고 다른 주방장을 고용하게 되더라도 계약서에 합의 하기만 한다면 실제 업무 지시 방식을 바꾸지 않아도 새로운 주방장과 일하는데 문제가 없을 것이다.

객체지향으로 넘어와서 음식점 주인과 주방장의 관계 사이에 있던 "계약서"라는 개념이 무엇일까? 바로 interface이다. 이와 관련해서는 정확한 개념이 필요하기 때문에 잠시 정확한 개념을 설명하고자 한다.
앞서 지휘자와 개별 연주자들 간의 관계에서 "연주자"라는 개념을 도입했었다. 연주자는 개별 연주자의 차이점을 배제하고 공통점만 취한 추상적인 개념이다. 이와 상응하는 개념이 객체지향 언어에서의 interface이다. 그리고 가게 주인과 주방장 사이에 "계약서" 개념을 도입했는데 이 역시 interface를 염두해 둔 개념이다. 이들 두 예와 interface는 어떤 관계가 있을까? 우선 "연주자" 개념 역시 "계약서" 개념과 동일하다는 점을 설명할 필요가 있다. 지휘자 - 개별 연주자 간에는 직접적인 계약 관계가 필요하지는 않지만, 적어도 연주가 성공할 수 있으려면 "연주자" 개념은 강제성을 띄어야 한다. 연주하세요라고 말했을 때 연주를 시작하고, 연주를 멈추세요라고 말했을 때 멈추지 않으면 연주는 아름답게 이뤄지지 않을 것이다. 그런 강제성을 직접적으로 명시해야 할 필요가 있다면 그것이 "계약서"가 되는 것이다. 자 그럼 interface와 계약서의 관계를 이야기해 보자.
interface는 추상 공개 메소드(abstract public method)들의 집합으로 이루어진 추상 클래스(abstract class)이다. 만약 Java처럼 interface라는 명시적인 대상이 없는 언어를 다루고 있다면 추상 공개 메소드만 선언된 추상 클래스를 떠올리면 된다. 추상 클래스란 인스턴스화(객체화)가 불가능한 클래스다. interface는 추상 클래스이므로 interface 역시 객체화시킬 수 없다. 추상 공개 메소드는 구현부가 없는 메소드이다. 구현부가 없으므로 당연히 동작시킬 수 없다. 자기 자신은 구현부가 없지만 만일 interface를 상속한 경우(Java에서는 이를 상속이라 부르지 않고 구현(implements)이라고 부른다. 앞으로는 Java의 개념에 따라서 구현이라고 부르도록 하겠다.) 추상 공개 메소드의 구현부를 구현해야 할 의무가 생긴다. 추상 공개 메소드를 구현하고 싶지 않은 하위 클래스는 추상 클래스여야만 한다.
이러한 개념들에 비추어 보면, interface는 이를를 구현하는 하위 클래스들에게 정해진 공개 메소드를 꼭 갖추도록 강제하는 역할을 한다는 점을 알 수 있다. interface 스스로는 직접적인 구현을 제공하지 않는다.
이런 interface의 특징은 두가지 특성을 가져다 준다. 우선 interface를 구현하는 구체적인 객체에게는 강제성을 주면서도 자율성을 보장한다. 강제성은 interface가 선언한 추상 공개 메소드를 꼭 구현하도록 한다는 점에서 그렇다. 대신 interface는 어떤 형태로도 구현을 가이드 하지 않기 때문에 구체적인 객체는 어떤 방식으로든 자유롭게 구현할 수 있다. 이는 연주자가 연주하세요와 멈추세요에 반응해야 하는 것은 강제이지만, 개별 연주자들의 실제 연주 내용(구현)은 서로의 악기에 맞게 자율적으로 할 수 있다는 것과 같다. 이를 통해서 얻을 수 있는 이점은 개별 연주자의 범위를 넓힐 수 있다는 것이다. 단지 연주하세요와 멈추세요에만 응답할 수 있다면 서양의 악기든 동양의 악기든 상관하지 않고 모든 악기 연주자들을 구현해도 좋다. 이는 다양한 요구사항을 반영할 수 있는 역할을 한다. interface가 주는 또 하나의 특성은 구체적인 구현 객체들을 모르더라도 interface를 구현한 객체들이라면 모두 사용할 수 있다는 것이다. interface를 구현한 모든 객체들은 interface가 제공하는 공개 메소드를 구현해야만 한다. 이것은 강제적이다. 따라서 사용하는 측에서는 interface만 보고 객체를 다루더라도 전혀 지장이 없다. 사용하는 측에서 매우 다양해질 수 있는 구체적인 구현 객체를 모르고도 그 객체들을 다룰 수 있다는 점은 유연성에 매우 큰 이득이다. 

계약 책임의 도입

이제 우리의 예제에 계약 책임을 도입해볼 차례다. 계약 책임은 interface가 가지고 있다. 따라서 interface를 선언할 필요가 있다. 그리고 구현 책임을 가진 Task 객체는 그 interface를 구현해야 한다. 마지막으로 사용자인 Client 객체는 Task 객체를 직접 사용하지 않고 interface를 이용해서 Task 객체를 다루어야 한다. 이와 같은 형태로 코드를 수정하면 아래와 같아진다.

interface Contract{

    public void method();

}

class Task implements Contract{

    public void method(){}

}

class Client{

    Contract task = new Task();    // 1

    public void function(){

        task.method();             // 2

    }

}


interface가 계약이라는 점을 명시하기 위해서 interface의 명칭(symbol)은 Contract라고 붙였다. 그리고 이 계약서에는 method()라는 공개 추상 메소드를 구현하도록 명시되어 있다. 따라서 Contract interface를 구현하는 클래스는 항상 method()도 구현해 주어야 한다. 위의 예제에서는 Task 클래스가 Contract interface를 구현하고 있다. 따라서 Task 클래스는 method()를 역시 구현하고 있다. Task 객체를 사용하고자 하는 클래스인 Client 클래스는 "계약"에 따라서 Task를 사용해야 한다. 따라서 기존에는 Task 참조 변수를 선언하였으나 이제는 Contract 참조 변수로 대체되었다.(1번 라인) 하지만 실제 사용할 객체는 Task 타입이므로 Task 객체를 생성하여 Contract 참조 변수로 참조하도록 되어 있다. 2번 라인의 메소드 호출은 일면 이전의 코드와 같아 보인다. 하지만 task 참조 변수는 그 타입이 Contract로 변경되어 있다. 따라서 2번 라인의 호출은 직접 Task 객체의 메소드를 호출하는 것이 아니라 Contract 타입의 method()를 호출하는 것이다. 이와 같은 관계를 이해하기 위해서 다음의 예제도 한번 살펴보자.

interface Contract{

    public void method();

}

class Task2 implements Contract{

    public void method(){ /* something else */ }

}

class Client{

    Contract task = new Task2();

    public void function(){

        task.method();            // 메소드 호출

    }

}


위의 예제에서는 Task 클래스 대신 Task2 클래스를 선언하고 사용하고 있다. Task2 클래스 역시 Contract interface를 구현하고 있으므로 method()도 동일하게 구현하고 있다. 하지만 실제 동작은 기존의 Task 클래스와는 다르게 선언했다고 가정해 보자.
이제 Task2를 사용하기 위해서 Client 클래스에서는 Task2 객체를 생성하여 Contract 타입의 참조 변수인 task에 할당한다. 그리고 메소드 호출 부분으로 넘어가보자. 이전의 예에서와는 다르게 task 참조변수에는 Task2 객체가 참조되어 있다. 따라서 메소드 호출 부분에서 메소드를 호출한 경우 Task2 객체의 메소드가 호출된다. 즉, 이전의 예에서 할당된 객체는 Task 객체였고, 이번읜 Task2 객체이므로 실제 호출되는 메소드는 달라지지만 둘 다 같은 형태로 호출이 된다. 이는 참조를 직접 Task 객체나 Task2 객체로 하지 않고 Contract 타입으로 참조했기 때문에 가능한 일이다.
이를 통해 얻은 것은 무엇일까? 만일 필요에 의해서 Task 객체나 Task2 객체 또는 또 다른 Contract interface를 구현한 객체로 바꾸게 되더라도 function() 메소드가 수정될 필요가 없어졌다. 즉 우리가 바라던 유연성이 확보된 것이다. Contract 타입의 객체는 그 "계약"만 제대로 지킨다면 무수히 늘어날 수 있으므로 무수히 많은 요구사항을 반영할 수 있는 구조가 된 것이다. 

클래스 다이어그램을 통한 이해와 여전한 문제점

그러면 이제껏 진행했던 내용을 UML의 클래스 다이어그램을 통해 이해해보자. 클래스 다이어그램은 실제 동작시 객체간의 유기적인 협력 관계를 표현하기에는 적절하지 않지만(유기적인 협력 관계는 UML의 시퀀스 다이어그램을 이용하는 편이 좋다), 우리가 하고 있는 작업, 즉 의존 관계에 대한 관리를 하는데는 매우 유용한 다이어그램이다. 클래스 다이어그램을 단순화해서 보면 클래스(+ interface)와 관계(association, dependency, generalization)로 나누어 볼 수 있다. 관계는 연관, 의존, 일반화 등으로 나누어질 수 있지만, 단순하게 보자면 어떤 클래스와 다른 클래스가 관계로 연결되어 있다면 그 둘 중 적어도 하나에는 상대 클래스가 코드 상에 등장한다는 것을 의미한다. 그래서 우리가 하는 작업을 통해서 다른 클래스와의 관계가 잘 정리되었는지를 확인하는데 매우 유용하다.

위의 다이어그램은 이 글에서 작성한 코드를 클래스 다이어그램으로 나타낸 것이다. Client의 입장에서는 여러 개별 Task들을 직접 다루기보다는 Contract interface를 도입해서 간접적으로 다루도록 만들었다. 여기서 가장 좋은 것은 Client가 Task들과 아무런 관계를 가지지 않는 것이다. 하지만 위 다이어그램에서도 보듯이 Client는 Task들의 생성자 메소드를 호출함으로써 생성(create)을 수행하고 있다. Task들을 전혀 모르고도 사용할 수 있어야만 의존관계를 완벽하게 끊어낼 수 있다. 더 나은 구조를 만들어내기 위해서는 별도의 작업이 필요하다.


Posted by 이세영2
,

우리의 목표는 객체지향 언어를 이용해서 유연성 있는 소프트웨어를 개발하는 것이다. 소프트웨어가 유연하다는 것은 다양한 요구사항 변화에도 대처가 가능하다는 의미이다. 요구사항 변화는 소프트웨어에 추가, 제거, 변경과 같은 수정을 가져온다. 즉, 유연성은 소프트웨어가 얼마나 손쉽게 수정될 수 있느냐에 달려 있다. 이미 요구사항의 변화를 쉽게 반영할 수 있는 객체지향의 4대 특성에 대해서 이미 살펴봤다. 그리고 객체지향의 4대 특성이 반영된 일반적인 구조에 대해서도 살펴봤다. 지휘자 - 연주자 - 개별 연주자로 연결되는 구조를 통해서 객체지향 언어가 어떻게 다양한 요구사항을 동일하게 취급하는지 살펴 보았다. 하지만 이제 객체지향 언어를 이용하여 완성된 형태의 유연성을 제공하기 위해 알아 두어야 할 요소에 대해 설명할 시간이다.


의존(Dependency)과 책임(Responsibility)

의존과 책임은 객체지향 4대 특성을 통해 확보한 유연성을 더욱 강화시키기 위해 필수적으로 알아 두어야 할 요소이다. 그리고 의존과 책임은 별도로 설명하기 곤란할 정도로 밀접한 관계가 있는 개념들이다. 이 개념들을 실제 코드를 통해 이해해 보도록 하자.

class Task{

    public void method(){}

}


class Client{

    Task task = new Task();    // 1

    public void work(){

        task.method();         // 2

    }

} 

위의 코드에는 두 개의 클래스가 있다. 하나는 Task 클래스이고, 다른 하나는 Task 클래스를 사용하는 Client 클래스이다. Client 클래스에서는 1 처럼 Task 객체를 "생성"하고 있다. 그리고 2번 처럼 그 객체의 메소드를 호출하고 있다. 메소드 호출을 객체지향 용어로는 "메시징"이라고 부른다. 그리고 메소드 호출을 통해 다른 객체를 "사용하는" 것을 객체지향 용어로는 위임(Delegation)이라고 한다. 본래 Client 객체가 해야 할 일이지만 (다양한 이유로 인하여) Task 객체에게 맡긴다는 의미이다. 이 전체를 한마디로 표현하자면 Client 객체가 Task 객체를 생성하고, 일부 기능을 위임한 것이다. 이 구조에서 구체적으로 어떤 기능을 위임했는지는 중요하지 않다. 다만 기능의 종류와 상관 없이 일반적으로 만들어지는 구조라는 것이 중요하다.

또한 Client와 Task 객체간의 관계를 의존관계라고 부른다. 좀 더 정확하게 말하면 Client 객체가 Task 객체에 의존하는 것이다. 위의 코드에서 의존은 두 번 발생한다. 우선 Task 객체를 생성하는 1번 코드에 의해 의존한다. 또 2번의 method() 호출을 통해서도 의존한다. A가 B에 의존한다는 말은 간단하게 이야기 하면 A의 코드 상에 B의 요소가 등장한다는 말이다. 1에서는 Task 객체의 타입이 등장하고, 생성자 메소드 호출이 등장한다. 2에서는 method()라는 Task 객체의 메소드 호출이 등장한다. 이것이 의존으로 불리는 이유는 다른 객체의 요소가 등장하는 순간부터 그 객체가 없어지면 안되기 때문이다. 만일 의존 대상인 Task 클래스가 사라지면 Client 객체는 바로 컴파일이 불가능해지고 동작할 수 없는 상태가 된다.

이 의존 관계가 내포하는 문제를 조금 더 깊이 다뤄보자. 위의 코드처럼 Client가 Task에 의존하는 상태, 즉 Client 클래스 코드에 Task 클래스 코드가 등장하는 상황에서는 Task 클래스의 변경이 Client 코드에 영향을 미치게 된다. 앞서 Task 클래스가 사라지게 되는 경우도 이에 해당한다. 만약 의미를 명확하게 하기 위해서 Task 클래스의 명칭을 바꾼다고 하자. 그러면 Client 코드에서도 Task의 변경된 명칭을 사용해야만 한다. Task의 method()라는 이름의 메소드 역시 의미를 명확하게 하려는 이유로 변경될 수 있다. 그러면 2번 코드 처럼 해당 메소드를 호출하는 코드 역시 변경해 주어야 한다. 만약 Task의 기본 생성자 메소드를 대신하여 인자를 받는 생성자 메소드가 추가되었거나, 기본 생성자 메소드의 가시성을 낮춰서 외부에서는 Task 객체를 생성하지 못하도록 만들었다면 1번의 코드도 그에 맞춰 수정되어야 한다. 위의 예에서는 Task 클래스에 의존하는 곳이 단 두 곳 뿐이지만, 상황에 따라서는 코드들을 모두 변경하기 버거울 정도로 많은 곳에서 의존이 발생할 수도 있다.

이것은 소프트웨어 개발자들이 바라지 않는 일이다. 우리가 Task를 수정해야겠다고 느끼는 시기는 Task를 수정해야 할 이유가 있을 때이다. Task라는 클래스명의 의미가 모호하다고 생각될 경우, method()라는 메소드 이름이 모호하다고 생각될 경우, Task 객체가 해야 할 일이 변경될 경우, 새로운 메소드를 추가하거나 기존 메소드를 제거할 경우 등이 Task 클래스를 수정하게 되는 상황이다. Task 클래스를 수정하는 이유는 이를 사용하는 Client 클래스와는 전혀 상관 없는 일이다. 하지만 위의 코드에서 보듯이 Client의 코드가 Task의 코드에 의존하고 있을 경우 Client의 코드가 수정되는 것은 자명하다. 즉, 수정을 해야할 이유는 Task 코드에서 발생했는데 엉뚱하게 이를 사용하는 Client 코드가 수정되어야 하는 상황이 된 것이다. 

만약 사용되는 객체(또는 요소)로 인하여 사용하는 객체가 수정되는 이와 같은 일이 발생하지 않도록 하려면 어떻게 해야 할까? 단 한가지 답만 존재한다면 사용되는 대상이 수정될 가능성을 낮추는 것이다. 그렇다면 이 "수정될 가능성"을 어떻게 판단할 수 있을까? 이 답은 로버트 C. 마틴이 밝힌 객체지향 설계의 5원칙(SOLID)에 들어 있다. 


책임(Responsibility)의 정의

객체지향 설계의 5 원칙(SOLID) 중에서 첫번째 원칙은 단일 책임의 원칙이다. 단일 책임의 원칙(Single Responsibility Principle)은 객체지향의 구성요소가 단 하나의 책임만을 가진다는 원칙이다. 그러면 책임(Responsibility)이란 무엇인가? 단일 책임 원칙에서는 책임을 다음과 같이 정의 한다.


"변경하려는 이유"


즉 우리가 찾던 "사용하려는 대상이 수정될 가능성"을 "책임"이라고 부를 수 있다는 것이다. 어떤 객체가 다른 객체에 의존하는 이유는 무엇인가? 의존의 대상이 되는 객체에 어떤 "책임"이 부여 되어 있고, 그 책임을 이용하고 싶기 때문이다. 그래서 책임에 대해 덧붙이자면 기능이라고 생각할 수도 있다. 이용해야 할 이유, 즉 기능이 있어야 이용하고 싶기 때문이다. 그리고 기능이 있어야 그 기능을 수정하려는 이유도 생긴다. 아무 기능도 없다면 의존할 필요도 없고, 수정할 필요도 없다. 


그러면 실제 구현 속에서 책임을 찾아보자.

구현의 대상이 필드라면 필드의 가시성, 타입 및 명칭이 변경의 대상이 된다. 또한 필드의 초기값도 변경의 대상이 될 수 있다. 필드 중에서도 객체 참조의 경우에는 참조할 객체의 할당 방법도 변경의 대상이 된다. 예를 들어 필드의 선언과 함께 객체를 생성해서 할당한 경우라면 참조할 객체의 종류 역시 변경될 수 있다.

메소드는 메소드의 시그니쳐(signature)와 메소드 내부 구현이 변경될 수 있다. 메소드의 가시성, 리턴 타입, 메소드 명칭, 파라메터, 메소드의 내부가 변경의 대상이다.

클래스는 타입, 필드, 메소드를 가지고 있다. 따라서 클래스는 (가독성에 따라) 자신의 타입명이 변경될 수도 있고, 다른 클래스를 상속 받거나 interface를 구현하게 됨으로써 자신이 가질 수 있는 타입이 변경될 수도 있다. 또 클래스는 필드가 변경 될 수도, 메소드가 변경될 수도 있다. 

구현의 대상이 interface라면 interface에 선언된 메소드가 변경의 대상이 될 수 있다.


단일 책임 원칙에서 정의하는 책임을 그대로 따른다면 위에서 열거한 변경의 대상은 모두 다 책임이 된다. 이들을 모두 개별적인 책임으로 정의하고 각 책임에 맞는 대응책을 모색하기에는 너무 많은 양이다. 별로 중요하지 않은 책임을 드러내놓고 논의하는 것은 시간 낭비에 불과하다. 적어도 객체지향을 통한 설계나 구현을 위해 의미 있는 정보를 제공해 줄 수 있는 수준이 되어야 한다. 또 너무 유사한 것들을 일일이 나열하면서 설명하는 것도 의미가 없는 일이다. 비슷한 것들은 하나의 개념으로 통합 시킬 필요가 있다. 그래서 중요도, 유사성, 실용성 등을 고려하여 책임을 적절히 분류해 보고자 한다.

책임을 종류별로 분류하고자 하는 목적을 이야기 할 필요가 있다. 다시 말하지만 우리는 객체지향 언어를 통해서 유연한 소프트웨어를 만들고자 한다. 유연한 소프트웨어를 만들기 위해서 추상화된 대상을 통해서 다양한 요구사항의 구체적인 차이점을 감추도록 했다. 이것만으로도 훌륭하지만 구성요소간의 의존성이 여전히 문제로 남아 있다. 다른 객체를 이용하는 객체는 이용되는 객체의 변경에 영향을 받는다. 이를 해결하는 방법은 객체들이 수정될 이유가 적어지게 만드는 것이다. 객체들이 수정될 이유가 바로 책임이다. 만약 어떤 객체가 너무 많은 책임을 가지고 있다면, 이 책임을 분할할 필요가 있다. 책임이 적어진다는 것은 변경할 이유가 적어진다는 것을 의미한다. 의존하는 대상이 변경될 이유가 적어지면 사용하는 쪽의 코드가 수정될 가능성도 함께 적어진다.

그래도 한가지 의문이 더 남는다. 결국 많은 책임을 분할하여 여러 객체나 요소로 분산하자는 얘기인데, 그렇게 되면 결국 의존이 여러 대상으로 분산될 뿐, 전체적인 의존의 복잡성은 그대로 유지되는 것이 아니냐는 것이다. 물론 상황에 따라서는 의존성이 줄어들기보다 객체나 요소가 늘어나는 이유로 인하여 관계가 더 복잡해 보일 수도 있다. 하지만 책임을 나눌 경우 크게 두가지에서 이득을 얻을 수 있다. 

첫째로 책임이 적절하게 여러 대상으로 분할된 경우가 여러 책임이 하나의 대상에 밀집해 있는 경우보다 덜 수정된다는 점이다. 만일 여러 책임이 하나의 클래스에 몰려 있다고 가정해 보자. 이 경우에는 종종 여러 책임별로 메소드 단위, 속성 단위로 잘 구분되지 않는 경우가 많이 있다. 즉, 하나의 메소드가 두가지 책임을 위해서 사용될 경우도 있고, 두가지 이상의 책임이 하나의 메소드 흐름 속에 뒤죽박죽 섞여 있을 수도 있다. 책임을 면밀히 생각해 보지 않았기 때문에 이들이 섞여 있어도 그렇다는 것을 잘 모르는 경우가 많다. 이 때 어떤 이유로 대상을 수정하게 된다면 어떤 일이 벌어질까? 하나의 목적으로 수정을 시작했는데, 그 목적과는 관계없는 코드들이 중간중간에 섞여 있을 것이다. 이는 수정에 집중하기 어려운 상황을 만든다. 그리고 하나의 책임만을 수정했음에도 불구하고 다른 책임을 잘못 건드려서 다른 목적에도 맞지 않게 수정될 가능성도 있다. 이것은 단일 책임 원칙에 의해 책임이 잘 분할된 여러 요소들을 개별적으로 수정할 때에 비해 매우 비효율적일 수 밖에 없다.

둘째로 책임이 분할되어 있을 경우 중복 요소의 확인이 쉽고, 이를 통해 한차원 높은 추상화를 발휘할 수 있다는 점이다. 책임을 분할해 보면 책임이 다른 객체로 분할되거나 별도의 메소드로 분할되게 된다. 이렇게 대상들이 작아지면 전체를 이해하기가 수월해진다. 그리고 작은 단위들은 서로 유사성을 띄는 경우가 많다. 완성된 자동차를 종류별로 비교하면 동일성을 찾기가 어렵지만, 부품단위로 뜯어보면 같은 것을 발견할 가능성이 높아지는 것과 같은 이치이다. 설령 조금씩 다르다고 해도 부품들의 목적이 같다면 둘을 통일 시켜 하나의 요소로 만들기도 쉽다. 같은 세단이라도 완성차끼리 호환시키기는 어렵지만, 세단과 SUV 사이라고 해도 부품별로 놓고 보면 나사, 프레임, 바퀴, 선루프 등 부품별로는 얼마든지 동일성을 유지시킬 수 있는 여지가 있다. 이와 같은 이유로 더 많은 요소의 동일성을 확보하고, 이를 통해 더 많은 유연성을 확보하는 것이 가능해 진다.

책임을 잘 분류하면 다음과 같은 효과를 얻을 수 있다. 우선 단일 책임 원칙을 어떻게 위반하고 있는지를 확인할 수 있다. 어떤 구현 요소(interface, 클래스, 메소드 등)가 여러 종류의 책임을 함께 가지고 있음을 분석해낼 수 있고, 어떤 책임을 분리하여 단순화할지에 대한 전략을 세울 수 있다. 또 책임의 종류에 따라 어떤 형태로 책임의 분할을 수행할 수 있을지를 알 수 있다. 그리고 이런 분할 방식을 이용하여 어떤 소프트웨어를 작성하더라도 정형화 된 형태의 설계나 구현을 할 수 있게 된다. 이것은 개발의 속도를 향상 시키는데 매우 큰 도움이 된다. 마지막으로 객체지향 소프트웨어가 어떤 형태로 기능을 확장해 나가야 할지를 가이드 할 수 있다. 기능이 확장된다는 것은 책임이 증가한다는 것을 의미한다. 책임이 증가되면 단일 책임 원칙을 지키기 위해 분할이 필요하다. 이 때 책임의 종류를 명확하게 이해하고 있으면 쉽게 책임을 분할 할 수 있고, 단일 책임 원칙을 유지시킴으로써 좋은 소프트웨어 구조를 유지할 수 있다.


생성 책임

객체의 생성은 매우 중요한 작업 중의 하나이다. 객체를 생성하는 과정의 중요성 때문에 GoF의 디자인 패턴에도 5가지 종류의 생성 패턴이 존재한다. 또한 생성 과정에서 만나는 다양한 문제점들을 해결하기 위해서 많은 소프트웨어들에서 Factory Method를 이용한다.

이 생성 책임은 세부적으로 다음과 같은 작업들이 정상적으로 이루어지도록 만든다. 첫번째는 적절한 구체 객체를 생성하는 것이다. 생성 패턴이나 Factory Method에서는 사용자가 사용하는 생성자 객체 혹은 생성 메소드에 따라서, 또는 생성 메소드에 의해 전달되는 파라메터에 따라서 다른 객체를 생성하고 제공한다. 생성 책임을 지고 있는 모듈로부터 적절한 객체를 제공 받음으로써 어플리케이션이 적절한 동작을 취할 수 있도록 한다. 두번째로 객체의 타입을 감춘다. 구체 객체를 생성했다고 해도 그 구체 객체의 타입으로 사용하지 않는 편이 좋을 경우가 있다. 대표적으로 다형성을 통해 구현된 일련의 객체들을 받아 사용하는 경우이다. 이 때 생성 책임을 지는 모듈은 구체 객체의 타입을 감추고 추상 타입으로 리턴해 줌으로써 객체를 사용하려는 쪽과 생성된 구체 객체간의 관계가 생성되는 것을 막아준다. 세번째로 객체가 생성되고 나서 사용되기 전에 해줘야 할 작업들을 실행해준다. 대표적으로 초기화 작업이나 이벤트 수신을 위해 생성된 객체를 다른 객체에 등록하는 작업 등이다. 네번째로 객체의 생성에서 사용 전까지 있을 수 있는 동기화 문제를 방지해 주는 역할을 한다. 다섯번째로 객체가 생성되면서 가져야 할 초기 값들을 할당해준다.


조립 책임

현대 소프트웨어처럼 요구사항이 다양화 되기 전까지는 객체를 조립한다는 개념이 두드러지지 않았다. 프레임워크, 즉 어떤 어플리케이션으로 진화하든지 상관 없이 충분한 기능을 지원해 줄 수 있도록 설계된 소프트웨어 플랫폼들에서나 주로 고민하던 부분이다. 하지만 객체지향 언어에서 의존성 관리를 통한 유연성 확보 개념이 널리 퍼지고, 엔터프라이즈 애플리케이션이 더 많은 산업 분야에 적용되고, 같은 기능의 소프트웨어를 대량으로 판매하기 보다는 사용자의 요구에 맞춰 다른 기능들을 제공하는 방식을 더 많이 지원해야 하는 상황에서 이 조립 책임이 대두되었다. 아마도 앞으로 인공지능 분야가 더더욱 발전하고 소프트웨어가 더 많은 데이터를 손쉽게 수집할 수 있는 플랫폼을 개발하는 방향으로 나가게 된다면 이 조립 책임은 더욱더 비중이 커지게 될 것이다.

Spring 프레임워크의 의존성 주입(Dependency Injection), 넷빈즈의 룩업(Lookup) 서비스 객체, 서비스 로케이터(Service Locator) 등과 같이 객체의 조립이라는 과정은 독립적이고 핵심적인 요소이다.


구현 책임

구현의 책임은 구체 객체가 가진 책임이다. 즉 특정 요구사항에 맞게 실제 동작을 수행할 수 있는 메소드를 가지고 있는 것이다. 좀 더 구체적으로 보면 공개 메소드가 구현 책임의 대상이 된다. 이 공개 메소드는 interface나 상위 클래스를 상속함으로써 구현이 강제 되기도 하고, 자체적으로 공개 메소드로 설정하고 제공되기도 한다. 어쨌든 구체 객체는 이들 공개 메소드에 대하여 구현함으로써 실제 작업을 수행하는 책임을 가진다.


계약 책임

계약 책임은 interface에 대한 책임이다. 계약이라는 이야기가 매우 생소할 수도 있다. 하지만 어떤 interface를 구현하는 객체들이 구체적인 구현에 있어서 자율성을 가지고 있다는 사실은 이미 알고 있을 것이다. 객체를 사용하는 쪽에서는 구체적인 객체가 어떤 방식으로 동작하는지에 대해서는 관여하지 않는다. 이를 통해 다형성이 유지되고 유연한 구조의 소프트웨어가 탄생할 수 있다. 하지만 이와 함께 interface는 행위를 강제하는 역할을 한다. 이것을 현실 세계와 비교해 보면 "계약" 관계와 그 유사성을 찾을 수 있다. 계약서를 들이 미는 쪽은 사용자 측이고, 계약을 이행해야 하는 측은 구체적인 구현 객체이다. 그리고 계약서에 해당하는 것은 interface이다. 사용자 측에서는 계약서, 즉 interface를 구현하기만 한다면 구체적인 객체가 어떤 일을 하든지 상관하지 않는다. 대신 계약서(interface)는 반드시 지켜져야만 한다. 

이런 개념으로 interface를 이해하면 리스코프 치환의 원칙이 자연스럽게 지켜진다. 사용자 측에서는 언제나 interface를 통해서만 객체를 취급한다. 구체적인 객체들은 interface의 구현을 강제 당하는 셈이지만, 그 방법에 대해서는 최대한 자유를 누릴 수 있다. 이런 특성들을 개념적으로 잘 이해할 수 있도록 하기 위해서 interface에 계약 책임을 부여하고자 한다.


상태 책임

상태 책임은 필드와 필드의 가시성, 그리고 getter/setter에 대한 책임이다. 어떤 객체에 필드를 선언할 때는 메소드를 선언할 때보다 훨씬 신중해야 한다. 필드는 구현을 도와주는 도구가 아니다. 오히려 가능한 한 선언을 피해야 하고, 일단 선언 되어 있다면 가능한 한 가시성을 낮춤으로써 외부로 유출되는 것을 방지해야 한다. 일단 선언된 필드는 필드를 가지고 있는 객체에 책임을 뒤집어 씌운다. 필드는 필드 스스로 연산을 수행할 능력이 없기 때문이다. 다른 객체들은 특정 필드가 선언된 객체에게 해당 필드의 값을 요구하거나(getter), 어떤 필드 값을 저장하라고 시키거나(setter), 그 필드와 연관된 연산을 수행할 것을 요구할 수 있다. 그리고 특히 getter 메소드는 필드의 값을 외부로 누출시켜 필드에 의해 발생하는 로직들(특히 조건문과 같은)을 전파시키는 역할을 하게 된다. 따라서 단순히 필드를 노출시키는 것만 아니라 필드를 통해 다른 객체들이 하고자 하는 일들을 예상하고 그 일들을 미리 구현해야 한다.


이로써 책임의 분류가 끝났다. 이제 책임의 분류가 어떤 효과를 나타내는지 확인해 볼 차례다.

Posted by 이세영2
,

객체지향 개념들간의 관계 UML


간략히 설명하자면 다음과 같다.

- OOP : Object Oriented Programming

- OOP는 유연성(Flexibility)을 가진다.

- 유연성은 캡슐화와 추상화, 다형성을 이용해서 달성된다.

- 상속은 캡슐화를 활용하고, 다형성은 상속을 이용해서 만들어진다.

- 캡슐화를 통해서 가시성(Visibility) 개념이 만들어지고, 클래스 개념이 만들어진다.

- 클래스는 타입과 필드, 그리고 메소드를 가지고 있다.

- 클래스 개념은 인터페이스(Interface), 추상 클래스(Abstract Class), 구체 클래스(Concrete Class) 개념을 파생시킨다.

- 객체(Object)는 구체 클래스(ConcreteClass)가 가진 개념을 포함한다.

- 객체는 Identity를 가지고 있다.(구체클래스를 통해 클래스가 가진 개념도 가지게 되므로 Method, Field, Type도 가지게 된다.)

- 메소드는 프로토타입을 가지며, 추상 메소드는 메소드의 일종이다.

- 필드는 Reference와 Primitive로 나뉜다.

- 클래스와 메소드, 필드는 가시성을 가진다.

- 클래스는 상속 가능(Inheritable)하다.

- 메소드는 재정의 가능(Overridable)하고, 오버로딩 가능(Overloadable)하다.

Posted by 이세영2
,

중간 정리

이쯤 해서 왜 "객체지향의 올바른 이해"라는 포스팅을 만들게 되었는지 그 동기를 설명하고 넘어가는 편이 전체적인 이해에 도움이 될 것이라고 생각한다.

이전 포스팅들에서 객체지향 4대 원칙이 나온 배경을 유연성의 예를 통해 설명하였다. 개인적으로 여러 책을 읽어보고 검색을 해 봤지만 이와 같은 방식으로 객체지향을 설명한 글은 없었다. 하지만 포스팅의 내용을 이해했다면 현실 세계에서 나온 개념을 프로그래밍 언어에서 차용했다고 보는 편이 더 타당하다는 점을 알 수 있을 것이다. 개인적으로 이 부분은 나름대로 확신하는 편이다. 하지만 그렇다고 해서 굳이 이미 사람들이 객체지향을 이해하고 있는 방식을 바꿔서 설명해야 할 필요가 있겠는지 하는 의문은 생길 것이다. 나는 그것이 필요하다고 봤다.


객체지향을 설명하는 일반적인 방식은 이렇다.

추상화(Abstraction)에 대해서는 공통된 속성과 행위를 추출하는 것으로 주로 설명한다. 이 설명이 틀린 것은 아니다. 하지만 공통된 속성과 행위를 추출하는 과정의 목적이 명확하지 않다. 유연성을 확보하기 위한 목적으로 설명하지 않기 때문에 추상화 과정이 필요한 이유를 설명하기가 어렵다. 그래서 어떻게든 가져다 붙이는 설명이 "속성과 행위를 중복 구현하지 않기 위해서"라고 말한다. 만약 추상화의 목적이 중복 구현의 방지, 즉 재사용이라면 일반적인 언어에서 말하는 작은 규모의 재사용, 즉 라이브러리와 무엇이 다른지를 명확히 해야 한다. 하지만 애초에 목표가 제시되지 않은 상태에서 추상화의 목적을 설명하기 때문에 그 본질적인 용도가 불명확해지는 것이다.

애초에 설명이 이렇게 시작하다보니 다른 개념들도 점차 모호해지기 시작한다. 캡슐화는 속성과 행위의 묶음이고, 이들 중에서 일부는 외부에 노출시키지 않는 것, 소위 정보 은닉을 통해 객체 내부 변경이 외부에 영향을 적게 미치도록 만든다고 말한다. 그래서 객체지향이 다른 언어보다 좋다고 이야기 한다. 캡슐화의 가장 큰 이점은 추상화를 통해 만들어진 객체(추상 객체라고 하자)가 그 고유한 타입을 가지게 되고, 이 추상화된 객체의 타입(추상 타입이라고 하자)을 통해서 여러 개별 객체가 공통으로 다루어 질 수 있음으로써 유연성이 확보된다는 점이다. 이 과정에서 캡슐화의 의미가 "공통의 속성과 행위에 타입을 부여하여 다루어질 수 있는 대상으로 만드는 것"이라는 정의를 성립시킬 수 있다. 따라서 캡슐화에서 가장 강조되어야 하는 부분은 타입의 부여이다. 하지만 캡슐화를 단순한 속성과 행위의 묶음 또는 일부 속성이나 행위를 외부에 노출시키지 않는 것이라고 정의한 덕분에 다른 개념들의 설명은 더욱 모호해진다.

상속에 대해서는 다른 객체의 속성과 행위 "만"을 가져 오는 것으로 보통 설명한다. 앞서 강조했듯이 캡슐화된 객체에 있어서 가장 중요한 것은 타입이다. 상속은 타입을 상속 받음으로써 그 타입으로 다뤄질 수 있도록 만드는 것이 핵심이다. 그래야 구체적인 타입이 아닌 상위 타입, 즉 추상 타입으로 다뤄지면서 유연성을 확보할 수 있게 된다.

다형성(Polymorphism)의 경우 역시 왜곡이 심하다. 다형성을 메소드 오버로딩과 혼돈하는 경우가 많고, 기껏해야 상위 타입으로 호출된 메소드를 하위 타입의 메소드로 대체되어 호출되는 것 정도로 말하는 경우가 대부분이다. 다형성은 하나의 타입이 서로 다른 타입으로 지칭될 수 있는 특성이다. 이 다형성은 객체지향 언어에서 상속을 통해 확보된다. 상속이라는 특성은 속성과 행위 뿐 아니라 타입도 상속시킨다. 이를 통해 상속을 받은 클래스는 자기 고유 타입 뿐 아니라 상위 타입도 가지게 된다. 이러면 가지고 있는 타입이 두 개가 되는데 이것이 다형성(multiple type)이다. 따라서 다형성이라는 단어만으로는 유연성을 이해하기 어렵다.


많은 소프트웨어 개발자들은 새로운 언어를 빨리 배우는 것이 자신의 능력과 연결된다고 생각한다. 그것 자체는 틀린 것이 아니다. 하지만 C언어와 같은 절차지향 언어를 쓰다가 Java와 같은 객체지향 언어를 배우게 되는 경우에도 이런 생각에 사로잡힌다. 특히 개발자들은 자신이 하는 분야에 대한 자부심이 크기 때문에 다른 분야 업무를 경시하는 경향이 있다. C언어와 객체지향 언어는 사용되는 분야가 많이 다르다. 언어를 빨리 배워야 뛰어난 개발자라는 생각과 다른 분야에 대한 경시가 객체지향 언어에 대한 이해 부족으로 나타난다. 빨리 배워서 기능 하나 빠르게 개발해서 보여주고 "쉽네!" 라고 빨리 말할 수 있어야 한다는 강박관념에서 언어를 배우는 것이다. 일반적인 형식의 객체지향 설명이 추구하는 방향이 딱 이런 정도다.

객체지향 언어는 애초에 프로그래밍을 시작할 단계에서부터 유연성을 염두해 두고 시작해야 하는 언어다. 프로그램을 구성해야 하는 요소(즉 객체)가 둘이 되었을 때부터 둘 간의 관계에 대해서 생각해보고, 이들의 관계가 확장될 소지가 있는지, 즉 유연성을 확보해야 할 필요가 있는지를 고려해야 한다. 또 이미 구현되어 있는 프로그램이라고 해도 서로 유사성이 있는 요소는 없는지, 그리고 유사한 요소들을 함께 다룰 수 있도록 함으로써 유연하고 간결한 프로그램으로 만들 수는 없는지를 생각해야 한다. 이런 과정에서 유연성을 확보하는 방법에 대한 일종의 청사진을 가지고 있다면 그 일이 매우 수월해질 것이다. 이것이 현실 세계에서 오케스트라 지휘자의 문제를 보여주고, 이를 객체지향 4대 원칙과 비교하면서 설명하게 된 계기이다.


이것을 통해 얻은 것은 무엇인가? 

우선 객체지향 언어를 배우는 과정에서 많이 간과되거나 잘못 설명되었던 개념들을 정확하게 정립할 수 있었다. 특히 객체지향 언어에서 "타입"이 얼마나 중요한지를 재발견할 수 있게 해주었다. 이제껏 일반적인 객체지향 도서들이나 블로그 들에서는 타입에 대해 거의 언급하지 않았거나 단편적으로만 설명했을 뿐이다. 하지만 이 포스팅을 통해서 객체가 속성과 행위의 집합이 아닌 타입과 속성과 행위의 집합으로 이해될 수 있었다. 그리고 캡슐화가 타입을 부여하는 과정이라는 점, 상속은 속성과 행위만이 아니라 타입까지도 물려 준다는 점, 마지막으로 다형성의 의미가 여러 타입을 가질 수 있는 특성이라는 점까지 이해할 수 있게 되었다.

객체지향 언어의 4대 특성이 왜 도입되었는지를 알 수 있었다. 객체지향이 실제 세계의 반영이라는 점은 많은 글들에서 설명하고 있다. 그러나 단순히 실세계를 반영하기 위해서 4대 특성을 도입했다는 것은 설득력이 부족하다. 앞서서도 이야기 했듯이 객체지향의 개념은 절차지향에 비해 이해하기가 어렵다. 굳이 더 어려운 개념을 도입해서 언어를 개발하고, 그렇게 개발된 언어가 지금처럼 널리 쓰일 수 있게 된 이유가 있어야만 한다. 유연성이라는 목적은 그 이유에 부합한다. 그리고 이해하기 어려운 개념임에도 불구하고 유연성이라는 목적을 명확히 이해하고 사용하면 객체지향을 빠르게 익히는데 많은 도움이 된다.

객체지향 언어의 목적이 유연성이라는 점을 이해하고 있고, 이 목적에 맞게 4대 특성을 이해하고 있으면 이제 디자인 패턴과 같은 설계적인 특성에도 빨리 다가갈 수 있다. GoF의 디자인 패턴 23가지 중 약 10가지는 그 목적이 유연성 확보에 있고, 앞서 설명한 유연성 확보 방식을 기반으로 구현된다. 나머지 패턴들 중 일부는 일부 특수한 목적을 위한 것이거나 객체지향 4대 특성의 일부를 활용한 것들이다.


Posted by 이세영2
,

이제 현실세계에서 객체지향의 세계로 넘어갈 차례다. 아직까지도 왜 객체지향 언어가 다른 언어에 비해 더 유연한 언어인가에 대한 정확한 답을 얻지는 못했을 것이다. 객체지향 언어가 유연한 이유를 간단히 말하자면 현실세계의 유연성을 그대로 프로그래밍 언어에 접목시켰기 때문이다. 객체지향 언어가 가진 고유한 특성들을 이제부터 이야기 할텐데, 이 특성들과 유연성은 완벽하게 매치된다. 이제껏 객체지향의 특성들을 일단 공부하고 외우고 나서 어느 정도 객체지향 개념을 이해하게 된 사람들이나 이제 막 객체지향 언어에 발을 들여 놓은 사람들 모두 현실 세계의 유연성을 일단 이해하고 객체지향 개념을 접하게 된다면 그 개념이 왜 필요한지, 그리고 그것이 어떻게 유연성을 만들어 내는지를 정확히 이해할 수 있게 될 것이다.


객체지향 4대 특성

객체지향 언어는 다른 언어들과 차별화된 특성을 가지고 있다. 그리고 이러한 특성들 중에서 중요한 것을을 객체지향 4대 특성이라고 부른다. 


추상화(Abstraction)

유연성을 확보하는 각 단계 중에서 공통점을 추출하고 차이점을 감추는 작업에 대해서 이야기 했다. 이 단계에서 했던 일을 요약하자면 다음과 같다. 우리는 여러 요구사항들로부터

    1. 공통점을 추출하였고,

    2. 불필요한 공통점을 제거하였다.

오케스트라의 비유에서 우리는 연주한다는 공통점을 추출해냈고, 같은 국적이라는 공통점을 추출해 냈지만 국적은 연주와 상관없는 정보이기 때문에 제거하였다. 객체지향 언어에서 추상화(Abstraction)라고 부르는 특성은 우리가 유연성을 확보하기 위해 했던 과정과 동일하다. 추상화라는 말은 구체적인 것을 제거한다는 말이다. 이것은 여러 요구사항에 대한 공통 요약본을 만드는 과정이다. 따라서 무턱대고 추상화를 할 수는 없다. 우선 제거되는 대상은 공통되지 않은 것이어야 한다. 그리고 우리가 만드는 것은 요약본이기 때문에 공통점들 중에서도 목적한 바와 맞지 않는 것들은 다시 제거해야 한다. 이러한 과정을 통해서 문제와 밀접한 관계가 있고, 모든 요구사항들이 공통적으로 가지고 있는 부분들이 추출된다.



추상화(Abstraction)의 과정



캡슐화(Encapsulation)

캡슐화는 "캡슐로 만들다"라는 의미이다. 우리는 이미 "대상화" 한다는 개념을 통해 이 특성을 익혔다. 캡슐화의 의미는 크게 3가지로 나눌 수 있다.

1. 공통점들의 묶음에 명칭을 부여한다. 이 "묶음"은 다룰 수 있는 "대상"이 된다.

2. 속성과 행위를 하나로 묶는다.

3. 실제 구현 내용 일부를 은닉한다.


일반적으로 캡슐화라고 하면 2번과 3번을 떠올린다. 그리고 그것 만으로도 어느 정도 이해하는데 도움이 되기는 한다. 하지만 정작 중요한 것은 1번이다.

우리는 유연성에 대한 설명 중 이상화 시인의 시 "꽃"에 대한 이야기를 했었다. 2번처럼 속성과 행위를 하나로 묶었다고 하더라도 거기에 "명칭"이 부여되지 않으면 다룰 수 있는 "대상"이 되지 못한다. 일반적으로 객체지향에서 이 "명칭" 이라는 것은 타입(Type)이라고 말하고, "대상"은 객체를 말한다. 즉, 다룰 수 있는 대상인 객체가 되려면 타입을 가져야 한다는 말이다. 아래 코드는 이 캡슐화의 예이다.


class Musician{ // 명칭을 부여했다.

    public void play(){ /* nothing to do */ }

    public void stop(){ /* nothing to do */ }

} 

Musician은 이제 우리가 추출한 공통점들의 명칭이 된다. 그리고 중괄호({})의 시작과 끝에 의해서 그 명칭이 의미하는 내용이 묶여진다. 중괄호를 통해 공통점들이 묶이고 Musician이라는 명칭이 부여된 것이다. 명칭, 즉 타입(Type)이 중요한 이유는 이후 다른 객체지향 특성을 알고 나면 매우 명확해지게 될 것이다. 일단 아래의 코드를 보고 한가지만 이야기 하고 넘어가겠다.

Musician musician = new Musician();

위의 코드 한 줄은 다음과 같은 의미를 지닌다.

    1. 등호( = ) 오른쪽은 Musician 타입의 객체를 생성하는 코드이다. 

    2. 등호 왼쪽은 musician이라는 참조를 선언하는데 그 타입은 Musician 타입이다.

    3. 이 한 줄의 코드를 통해 Musician 타입의 객체가 생성되고, 이 객체는 Musician 타입의 참조 객체를 통해 다루어진다.

객체를 생성하고 객체와 타입의 참조를 통해 객체를 다룬다는 말이다. 같은 타입이니 당연히 객체를 다루는 것은 문제가 없다. 이제 다음에 나올 개념들을 이해할 차례다.


상속(Inheritance)

이제 우리는 공통점에 대해 해야 할 일들을 마쳤다. 그러면 이제 다양한 요구사항들의 차이점들에 집중할 때가 되었다. 오케스트라의 비유에서 각 악기 연주자들은 서로 다른 악기를 다룬다. 따라서 이들 악기로부터 나오는 소리는 모두 다 다를 것이다. 하지만 이들은 모두 연주한다(play()), 멈춘다(stop())는 공통점을 가지고 있어야 한다. 특히 유연성의 관점에서 보면 각 악기 연주자들은 연주한다와 멈춘다를 통해서 다루어져야 한다.

이것은 이미 만들어져 있는 공통점을 개별 악기 연주자가 이용할 수 있어야 함을 의미한다. 객체지향 언어에서 이미 만들어져 있는 타입을 다른 객체가 물려 받아 이용하는 것을 상속(Inheritance)이라고 한다.

상속을 통해 다른 객체로부터 물려 받는 것은 세가지가 있다.

    1. 타입(Type)

    2. 필드(field, = 속성 = 변수)

    3. 메소드(method, = 행위, 함수)

이 중에서 가장 중요한 것을 고르라면 당연히 타입이다. 일반적으로 많은 객체지향 관련 자료들이 놓치는 것이 상속을 통해 타입을 상속받는다는 사실이다. 이 타입 상속에 대한 이해가 없이는 객체지향을 온전히 이해할 수 없다. 타입 상속은 다음에 나올 다형성의 중요한 기반이 된다.

일단 상속이 어떤 방식으로 이루어지는지부터 알아보자. 우리는 공통점을 추출해 낸 Musician이라는 타입을 이미 가지고 있다. 그리고 이제 우리는 구체적인 요구사항, 즉 개별 악기 연주자들을 정의해야 한다. 그리고 개별 악기 연주자는 이미 정의된 Musician으로도 다루어 질 수 있어야 한다. 따라서 Musician이라는 타입을 "상속" 받아야만 한다. 일단 하나만 해보도록 하자.

class FluteMusician extends Musician{

    public void play(){

        System.out.println("playing flute");

    }

    public void stop(){

        System.out.println("stop flute");

    }

} 

FluteMusician 클래스는 Musician 클래스를 상속 받는다. 이를 통해 FluteMusician 클래스는 FluteMusician이라는 자신의 타입도 가지면서 동시에 Musician이라는 타입도 가지게 된다. 또한 Musician 클래스가 가지고 있던 공통점들, 즉 연주하다(play())와 멈추다(stop()) 역시 Musician 클래스로부터 상속을 받는다.(예제에서는 일단 이들 공통점들을 "재정의" 하고 있다.)

여기서 주목할 것은 타입과 함께 공통점들을 물려 받는 것이 왜 중요한가 하는 부분이다. 


다형성(Polymorphism)

다형성은 객체지향의 꽃이라고 불린다. 다형성이란 다(많은) + 형(타입) + 성(특성)이 합쳐진 말로, 하나의 대상이 여러 개의 타입을 가진다는 것을 의미한다. 하나의 대상이 여러개의 타입을 가진다는 것이 무슨 의미일까?

우리는 이미 다형성을 만들어내는 기법을 알고 있다. 바로 상속이다. 상속은 상속을 받는 클래스에게 자신의 타입과 필드와 메소드를 전달해 주는 역할을 한다. 앞서서 이 세가지 중에 가장 중요한 것이 타입이라고 말했다. 이 타입을 물려 줌으로써 물려 받은 클래스는 다형성을 가진 클래스가 되기 때문이다.

위의 예제에서 FluteMusician은 자기 자신의 타입을 가진다. 그와 함께 Musician을 상속 받음으로써 Musician 타입으로도 취급이 될 수 있다. 아래 코드를 보자.

FluteMusician fm = new FluteMusician();

위의 코드는 FluteMusician 클래스를 객체화 하고 FluteMusician 타입으로 다루겠다는 의미를 지닌다. 아래 코드를 한 번 더 보자.

Musician m = new FluteMusician();

위의 코드는 똑같이 FluteMisician 클래스를 객체화 하지만 Musician 타입으로 다루겠다는 것을 의미한다. 이처럼 똑같은 객체가 여러 타입으로 취급될 수 있는 특성이 다형성이다.


그렇다면 왜 다형성이 객체지향의 꽃이라고 불리는가?

우리는 유연성에 대한 예로부터 Musician 객체를 추출해냈다. 그리고 각각의 클래스가 가진 차이점을 나타내는 클래스들 중에서 FluteMusician이라는 클래스를 만들어 보았다. 이 FluteMusician 클래스는 Musician 클래스를 상속 받도록 되어 있다. 이제 하프 연주자, 피아노 연주자도 같은 방식으로 만들어 볼 수 있다.

class HarpMusician extends Musician{

    public void play(){

        System.out.println("playing harp");

    }

    public void stop(){

        System.out.println("stop harp");

    }

}

class PianoMusician extends Musician{

    public void play(){

        System.out.println("playing piano");

    }

    public void stop(){

        System.out.println("stop piano");

    }

}

이제 3명의 연주자로 구성된 오케스트라(좀 빈약 하지만)를 지휘한다고 생각해보자. 만약 다형성이 없었다면, 즉 FluteMusician은 오직 FluteMusician이라는 타입만 가질 수 있고, Musician 타입을 가질 수 없다면, 그리고 다른 클래스들도 이와 같다면 연주는 다음과 같이 이루어질 수 밖에 없다.

public void playAll(){

    FluteMusician fm = new FluteMusician();

    HarpMusician hm = new HarpMusician();

    PianoMusician pm = new PianoMusician();

    // ......

    fm.play();

    hm.play();

    pm.play();

    // ......

    fm.stop();

    hm.stop();

    pm.stop();

    // ......

}

개별 악기 연주자들은 서로 공통점으로 묶어 다룰 수 없다. 다형성이 없을 경우 각각의 클래스는 자기 고유 타입 한가지만 가질 수 있기 때문이다. 따라서 각각의 클래스를 통해 만들어진 객체들은 한번에 취급될 수 없고 각각 다뤄질 수 밖에 없다. 마치 플루트 연주하세요 / 하프 연주하세요 / 피아노 연주하세요 라고 지휘자가 따로따로 외치는 것과 같다. 객체지향에서 다형성이 없다는 것은 현실 세계에서는 마치 개별 연주자들을 공통된 명칭, 즉 연주자라고 부를 수 없는 것과 같다. 플루트 연주자는 현실 세계에서 플루트 연주자이면서 동시에 그냥 연주자라고 불릴 수 도 있다. 하프 연주자도 하프 연주자로 불릴 수 있지만 그냥 연주자라고 불릴 수도 있다. 따라서 현실 세계에서는 연주자 여러분 연주하세요라고 말하면 자연스럽게 동시에 모든 악기 연주자들이 연주를 시작할 수 있다.


객체지향에서는 다형성을 이용하여 현실 세계에서의 동시 연주 효과를 낼 수 있다. 다형성을 통해 개별 연주자 객체들은 Musician이라는 공통 타입을 통해 다루어질 수 있다.

public void playAll(){

    List<Musician> musicians = new ArrayList<Musician>();

    musicians.add(new FluteMusician());

    musicians.add(new HarpMusician());

    musicians.add(new PianoMusician());

    // ......

    musicians.forEach(each -> each.play());

    musicians.forEach(each -> each.stop());

}


개별 연주자들은 각자 고유한 타입을 가지면서도 Musician이라는 타입으로 다뤄질 수 있다. 따라서 Musician 타입 객체를 넣을 수 있는 musicians라는 List에 함께 집어 넣을 수 있다. 그리고 forEach와 같은 순회 메소드를 이용하여 Musician 클래스가 가진 play() 메소드나 stop() 메소드를 동시에 호출함으로써 동시 연주와 같은 효과를 낸다.


유연성과 객체지향 특성

자 이제 다시 한번 정리를 해보자. 우리는 현실세계에서 여러가지 방식으로 유연성을 확보하고 있다. 이미 익숙한 방식을 재활용하는 것은 훌륭한 전략이다. 따라서 그리고 객체지향 언어는 현실 세계에서의 유연성 전략을 그대로 따르고 있다.

현실 세계에서의 유연성은 4가지 단계로 나뉘어진다. 그리고 이 단계들은 객체지향의 4대 특성과 1:1로 매칭된다.

일단 다루어야 할 여러 요구사항의 공통점을 찾고 문제 해결과 관계가 없는 공통점들을 제거한다. 이 과정을 객체지향 언어에서는 추상화(Abstraction)라고 한다.

그런 다음에는 공통점을 묶어 대상화 한다. 객체지향에서는 이를 캡슐화(Encapsulation) 라고 한다.

이 다음에는 개별적인 차이점들을 구현해 주어야 한다. 이 때 이미 뽑아 놓은 공통점은 모든 차이점들에게도 포함을 시켜 줘야 하는데, 이 과정을 객체지향에서는 상속(Inheritance)이라고 한다.

이제 차이점 대신 공통점을 통해 대상을 다루어야 한다. 공통점을 통해 대상을 다루려면 각각의 요구사항들이 공통점을 통해 다뤄 질 수 있어야 하는데, 객체지향에서는 이를 다형성(Polymorphism)을 통해 지원한다.

Posted by 이세영2
,

소프트웨어 분야에서 유연성은 다양한 요구사항을 수용할 수 있는 소프트웨어의 구조적 특성이라고 정의할 수 있다. 소프트웨어는 다른 어떤 컴퓨팅 요소보다도 유연하다. 그리고 잘 만들어진 소프트웨어일 수록 더 유연하게 요구사항을 수용할 수 있다. 소프트웨어의 적용 분야가 확대되고, 소프트웨어 개발 업체들 간에 경쟁이 치열해지고, 더 많은 사람들이 소프트웨어를 사용하게 되고, 더 많은 기기들이 연결되고 더 많은 정보를 수집하게 되면서 유연성에 대한 요구는 점점 더 증가하고 있다. 이러한 요구사항을 반영하기 위해서 소프트웨어는 유연성을 다른 어느 요소보다도 높은 가치로 가지고 갈 수 밖에 없다. 이것이 현대에 객체지향 언어가 대두된 이유이다.

그렇다면 객체지향 언어는 어떤 방식으로 더욱 유연한 구조를 가질 수 있게 되었을까? 여기에 대한 답 역시 현실에서 찾아볼 수 있다.


어린 왕자의 유연성 : 양이 든 상자

소프트웨어의 유연성을 떠올리면 항상 어린 왕자의 일화가 떠오른다. 어린 왕자는 사막에 불시착한 조종사(화자)에게 양을 그려줄 것을 부탁한다. 조종사는 양을 그려본 적이 없기 때문에 거친 솜씨로 이런 저런 양을 그려 준다. 하지만 어린 왕자는 어떤 것은 병들어 보인다, 어떤 것은 숫양이다, 어떤 것은 너무 늙어 보인다는 이유로 퇴짜를 놓는다. 그러면서도 계속해서 양을 다시 그려달라고 말한다.(이쯤에서는 까다로운 요구사항을 늘어놓는 고객이 떠오르게 된다.) 그러자 조종사는 아무렇게나 쓱쓱 그린 상자를 주면서 네가 갖고 싶어하는 양은 그 안에 들어 있다고 말한다. 그러자 어린 왕자의 얼굴은 환하게 밝아진다. 어린 왕자가 어떤 양을 상상하든 그 양은 그 상자 안에 들어 있다. 그래서 어린 왕자는 상자에 만족하게 된다.

우리가 현실세계에서 말하는 유연성이란 이런 상자와도 같다. 구체적인 사항들을 세세하게 늘어 놓으면 정확하긴 해도 다른 것으로 대체하기는 힘들다. 좀 엉성하고 덜 구체적인 것은 이리저리 가져다 붙이면 이것도 되고 저것도 된다. 어린 왕자의 상자는 그런 엉성하고 덜 구체적인 것의 결정판 정도라고 할 수 있다. 엄밀히 말해서 상자는 양과는 너무 동떨어져 있다고 생각될 정도로 매우 추상적이다. 하지만 그렇기 때문에 어린 왕자는 그 상자에서 자신이 원하는 양의 모습을 마음껏 상상해 볼 수 있었다.

이와 비슷한 예를 하나 더 들어보자. 수잔 서덜랜드는 그의 저서 "밈"에서 다음과 같은 타로 카드 점괘를 이야기 한다.

"당신은 남들의 애정과 인정을 받아야만 하는 타입이면서도 스스로에 대해서는 엄격하네요. 겉으로는 절도 있고 자신을 잘 통제하는 것처럼 보이지만, 속으로는 걱정이 많고 불안감도 많이 느끼고요. 가끔은 내가 제대로 된 결정을 내렸을까 하고 심각하게 고민하는군요......"

이 점괘는 사실 트릭인데 이는 "바넘 효과"라는 것을 이용한 것이다. 바넘 효과는 거의 누구나 스스로에 대해서는 참이라고 생각하지만 남에 대해서는 그렇게 생각하지 않는 진술을 던지는 것이다. 이것은 심리적인 유연성에 해당한다. 즉, 대부분의 사람들이 자신에게 맞다고 생각하는 점괘를 말해 줌으로써 그 점괘를 믿도록 만든다. 이러한 점괘에서는 당연히 매우 구체적인 날짜나 시간, 구체적인 명칭이나 사건들에 대해서는 언급을 피하는 것이 상책이다. 그래야만 더 많은 사람들에게 들어 맞는 유연한 점괘가 될 것이기 때문이다.


율리우스 카이사르의 유연성 : 추천 편지


율리우스 카이사르는 기원전 1세기에 살았던 로마의 지도자이다. 율리우스 카이사르는 인물 사전에 보면 로마의 정치가, 장군, 작가라고 나온다. 대단히 다재다능한 사람으로써 생애 전체가 유연성의 표본이 아닌가 싶기도 하다. 그가 지휘해서 이긴 전투들은 대부분 너무 독창적이어서 안타깝게도 사관학교 교재로 쓰이지 못할 정도이고(사관학교에서 교육을 하려면 어느 정도 정형화되고 누가 하더라도 같은 효과를 낼 수 있는 전투 방식이어야 하는데 카이사르의 그것은 너무나도 창의적이었다는 의미이다.), 이런 능력은 여자를 홀리는데도 사용되어 유명한 난봉꾼으로 통하기도 했다.

어쨌든 카이사르의 이러한 재능의 원천은 철저히 합리적인 방식을 통해서 얻어진 것으로 생각된다. 그는 발명에도 일가견이 있었는데, 긴 두루마리에 글을 적고 읽었던 당시에 종이를 낱장으로 잘라 한쪽 면을 이어 붙이는 "책"을 발명하였다. 이것은 두루마리가 이전에 글을 읽었던 위치를 알기에는 적합하지만 원하는 글이 적힌 임의의 위치를 빠르게 찾는데는 불편하다는 점에 착안한 것이다. 또한 당시까지 사용되던 부정확한 로마력을 대신하여 율리우스력이라는 당시로서는 매우 정확한 달력을 사용하도록 했다. 이 달력은 기원전 1세기에 사용되기 시작한 이후로 1582년 그레고리력으로 바뀌기 전까지 사용될 정도로 매우 정확했다.

이런 합리적인 사고 방식은 그가 권력을 유지하기 위해서 지방의 선거에 자신이 원하는 인물들을 추천한 방식에도 나타난다. 카이사르는 다음과 같은 문장이 쓰인 편지들을 미리 만들어 두었다.

"카이사르는 선거구 (       )의 유권자 여러분이 던지는 표로써 후보자 (     )가 그들이 바라는 관직에 당선될 수 있기를 희망한다."

그리고 필요한 시기에 이 편지에다 선거구 이름과 후보자 이름만 적어 넣음으로써 필요한 모든 선거구에서 자신이 원하는 후보를 지지해 주도록 추천할 수 있었다. 카이사르의 합리적인 성격 덕분에 매우 유연성 있는 추천 편지가 만들어지게 된 것이다.

이러한 유연성의 예는 현대에도 자주 찾아 볼 수 있다. 이 글을 읽고 있는 사람들 중에서 이미 결혼을 한 사람이 있다면 결혼을 위해서 청첩장을 만들어 전달했을 것이다. 그리고 그 청첩장의 내용은 그 청첩장을 받는 사람들마다 모두 달랐을 것이다...... 라고 짐작한다면 아무래도 유연성이 떨어진 생각이 아닐 수 없다. 거의 모든 사람들은 청첩장의 내용물은 모두 같고, 단지 전달할 상대에 따라서 겉포장에 이름만 다르게 적어 보냈을 것이다. 이것이 인간미는 조금 떨어질지 모르지만 바쁜 현대인들에게는 상당히 합리적인 선택이라고 할 수 있다.


어린왕자의 상자, 유연한 점괘, 카이사르의 추천 편지, 청첩장의 예를 통해서 우리가 현실 세계에서 유연성이라는 것을 어떻게 이용하고 있는지 확인해 볼 수 있었다. 일단 여러 요구사항들(어린왕자가 원하는 그 양, 점을 보러 오는 다양한 사람들, 다양한 선거구에 다양한 후보)을 모두 만족시키려면 모든 요구사항이 포함하는 공통된 부분들을 먼저 알아야 한다. 그러기 위해서 서로 다른 점들을 제거하고 공통된 부분을 모아 필요한 대상을 미리 만들어 둔다. 그리고 나서 개별적인 요구사항들에 대해서는 차이점만 조금씩 더해서 대응한다. 이렇게 함으로써 여러 요구사항을 개별적으로 대응하는데 들어가는 수고를 최대한 덜어낼 수 있다. 요컨데 똑같이 할 수 있는 부분을 뽑아 내어 한번만 하고 다른 부분들에 대해서는 최소한으로 대응하는 방식이다.


유연성 확보 전략 : 오케스트라의 비유

이제 조금 더 객체지향에 가까운 유연성 비유를 이야기 해보도록 하겠다. 이번에는 오케스트라에 비유해 보도록 하겠다. 오케스트라는 매우 다양한 악기를 연주하는 연주자들과 지휘자가 함께 음악을 연주한다. 악기는 바이올린, 피아노, 플루트, 하프, 비올라, 바순, 트럼펫, 트럼본, 첼로, 콘트라베 등 매우 다양하다.


이제 우리는 오케스트라 지휘자의 행동을 유추해 보자. 일반적으로 지휘자는 특별히 말을 하지 않고 지휘봉을 이용하겠지만 목적한 바를 명확하게 보여주기 위해서 지휘자가 말로 지휘를 한다고 생각해 보겠다. 지휘자는 이제 연주가 시작되었으면 좋겠다고 생각한다. 그래서 이제 각각의 연주자들에게 연주를 부탁해야 한다. 이 상황에서 지휘자가 이렇게 이야기한다.

"바이올린 연주하세요. 피아노 연주하세요. 플루트 연주하세요. 하프 연주하세요......"

이렇게 각각의 연주자들에게 개별적으로 연주를 부탁한다면 어떻게 되겠는가? 일단 연주를 시작하는 시점이 다르기 때문에 아름다운 연주가 되지도 않을 뿐더러 연주를 다 시작하게 될 때까지 시간이 매우 많이 걸리게 될 것이다. 이것은 일반적이고 합리적인 방식이라고 할 수 없다.

그러면 이제 다음의 방법을 생각해 보자.


이런 난감한? 상황을 타개하기 위해서 지휘자는 기지를 발휘한다. 지휘자는 각각의 악기를 다루는 사람들을 한꺼번에 다룰 수 있는 방법을 모색한다. 바이올린을 연주하는 연주자, 피아노를 연주하는 연주자, 플루트를 연주하는 연주자...... 이들은 모두 다른 악기를 연주하지만 연주자라는 공통점을 가지고 있다. 따라서 각각의 악기 이름으로 대응하기 보다는 공통된 부분, 즉 연주자라는 호칭으로 연주를 부탁하면 된다.

"연주자 여러분, 연주하세요."

이렇게 되면 각 연주자들은 동시에 연주를 시작할 수 있고, 지휘자는 단 한번만 요청하면 모든 악기가 연주 되도록 할 수 있다.

이 과정에서 지휘자는 매우 구체적인 대상, 즉 바이올린 연주자, 피아노 연주자 등을 대신하여 조금은 추상적인 대상, 즉 연주자라는 개념을 만들고 활용하였다. 이는 앞서 살펴본 현실 세계의 예들을 대표하는 방식이다. 실제적이면서 개별적인 대상 대신에 공통적이고 조금은 추상적인 개념을 대상화하고 이를 이용함으로써 개별적으로 대응하는 방식에 비해 훨씬 합리적이고 손쉽게 요청을 전달할 수 있었다.


우리는 이제 현실 세계에서 서로 다른 다양한 요구사항들을 어떤 방식으로 합리적이고 유연하게 대처하는지를 알게 되었다. 여러가지 요구사항들을 수집(각 악기 연주자들)하고, 구체적인 요구사항에 비해 다소 추상적인 공통점(연주자)을 찾아내고, 이를 대상화(연주자라고 부를 수 있도록)하고, 이들 공통점에 차이점을 조금 더하고(어떤 연주자는 바이올린을 연주한다), 공통점을 중심으로 대상을 다룬다(연주자 여러분 연주하세요). 이런 방식으로 유연성을 확보하는 것은 조금만 관심있게 찾아보면 늘상 있는 있는 일이라는 점도 알 수 있다. 동사무소에 준비된 각종 서류들은 이름과 서명란, 주소 등의 몇가지 구체적인 정보만 놔 둔체 이미 작성되어 있다. 여기에 자기의 정보만 적어 넣으면 곧바로 훌륭한 신청서가 된다. 보험을 가입할 때에도 약관은 이미 작성되어 있고, 가입자가 개인 정보를 기록하고 사인만 하면 가입이 완료된다. 이런 예를 더 찾아보자면 무수히 많을 것이다.

그러면 이제 우리가 현실 세계에서 유연성을 확보하는 과정을 단계별로 살펴볼 시간이다.


공통점을 추출한다.( = 차이점을 감춘다.)

현실 세계에는 마치 아날로그와도 같은 수준의 서로 다른 요구사항들이 있다. 아날로그 수준이라는 말은 요구사항이 하나하나 떨어져 있지 않고 서로 연속적으로 이어진 것처럼 다양하다는 의미이다. 연속적으로 이어진 요구사항들은 그 사이사이에 또 무수히 많고 다양한 요구사항들을 품고 있다. 이렇게 생각해야 하는 이유는 이미 밝혀진 요구사항들보다도 더 많은 요구사항이 앞으로 생겨날 수 있기 때문이다. 사람들은 종종 한가지를 보면 다른 한 가지를 떠올린다. 요구사항 하나는 그와 유사한 생각을 만들어 내고 이것은 다시 구체적인 요구사항이 되어 돌아오기도 한다. 소프트웨어 분야에서 유독 웹 개발자들이 힘들어지는 이유가 바로 이것 때문이다. 소프트웨어 시스템 내부가 어떻게 동작하는지는 쉽게 알 수 없지만 브라우저 상에서 표현되는 그림이나 글자는 쉽게 눈에 보이고, 눈에 보이는 순간 생각을 하게 되고, 생각이 다시 요구사항으로 돌아 오면서 이미 만들어 놓은 것을 고치게 하기 때문이다.

공통점을 추출하는 과정은 이 아날로그 수준의 서로 다른 요구사항들을 모두 수용할 수 있는 공통된 특징을 모으는 과정이다. 이 과정은 당연히 서로 다른 부분은 제거한다. 오케스트라의 비유에서 각 악기 연주자들은 서로 다른 악기를 가지고 있다. 서로 다른 악기를 가지고 있다는 점을 포함하고 있으면 모든 연주자들을 지칭할 수 있는 공통점을 만들어 낼 수 없다. 따라서 개별적인 악기들은 공통점에서 제거한다.

이와 함께 제거 되어야 하는 것은 또 있다. 각 악기 연주자들은 서로 다른 개인이기도 하다. 거주하는 곳이 다르고, 식성이 다르고, 평소 즐겨 입는 옷도 다르다. 이름, 전화번호, 주민번호, 나이, 헤어스타일 등 다른 점을 찾아보자면 무수하게 많다. 또 공통점도 있을 수 있다. 모두다 한국 사람일 수도 있고, 모두 다 악보를 볼 두 눈을 가지고 있을 수 있다. 이렇게 나열하다 보면 공통점과 차이점은 무수하게 많아지게 될 것이다. 그래서 공통점만 모으는 과정에서 너무 많은 공통점이 모일 수 있다. 그렇기 때문에 공통점을 추출하는 과정에서는 목적에 맞지 않는 불필요한 공통점은 제거되어야 한다. 오케스트라 지휘자는 현실 세계에서 연주자들과 친하게 지내기 위해서 이런 저런 정보들을 알아야 할 수도 있다. 하지만 지금은 지휘에 집중해야 할 때이다. 따라서 지휘하는데 꼭 필요한 공통점만을 추출해 내면 된다. 국적이나 악보를 볼 수 있는 눈 따위는 이 기준에 의해서 제거 되어야 한다.

이처럼 차이점들을 제거하고, 공통된 부분들 중에서 우리의 목적과 상관이 없는 정보들 역시 제거되고 나면 이제 공통점만 모은 어떤 것이 된다.


"대상화" 한다.

어떤 공통점이 모였을까? 우리는 연주를 할 것이기 때문에 각 악기 연주자들은 연주를 할 수 있어야 한다. 그리고 필요한 시점에 연주를 멈춰 줄 줄도 알아야 한다. 더 많은 공통점이 필요할 수도 있지만 이 정도만 모였다고 가정하자. 

그러면 이제 "대상화"를 할 시간이다. 대상화란 모아 놓은 공통점을 다룰 수 있게 만드는 과정이다.


(이상화)

  내가 그의 이름을 불러주기 전에는 
  그는 다만 
  하나의 몸짓에 지나지 않았다


 
내가 그의 이름을 불러주었을 때 
  그는 나에게로 와서 
  꽃이 되었다

  ......
 
 
우리들은 모두 
  무엇이 되고 싶다

 
나는 너에게 너는 나에게 
  잊혀지지 않는 하나의 의미가 되고 싶다
.


이 대상화를 떠올리면 항상 이상화 시인의 꽃이라는 시가 떠오른다. 이 시의 아름다움도 좋지만 대상화라는 과정이 어떤 것인지를 이해하는데도 도움이 된다. 우리가 어떤 대상에 대한 개념을 모았다고 하자. 하지만 이것에 정확한 명칭을 부여하여 다룰 수 있도록 만들기 전에는 그것은 "하나의 몸짓"에 지나지 않는다. 하지만 공통점들을 명료하게 만들고 명칭을 부여하면 그것은 "꽃"이 된다. 오케스트라의 비유에서 "꽃"은 바로 "연주자"라는 개념이다. 이 연주자라는 개념은 구체적이지 않고 추상적인 개념이다. 따라서 구체적인 대상을 현실 세계에서 찾을 수는 없다. 하지만 반대로 연주자라는 개념을 다룰 수는 있다. 연주자는 연주를 할 수 있어야 해, 연주자는 필요할 때 연주를 멈출 수 있어야 해 와 같은 방식으로 다소 추상적인 방식이긴 하지만 다룰 수 있는 대상이다. "대상화"의 개념을 명쾌하게 이해하지 못하겠다면 일단 "명칭"을 부여했다는 정도만이라도 이해하면 되겠다.


차이점을 더한다.

이제 남은 것은 차이점이다. 차이점은 공통점을 추출하는 과정에서 남은 것들이다. 그렇다고 남은 모든 것들은 아니다. 우리의 목적은 오케스트라를 지휘하는 것이다. 차이점을 더하는 과정도 목적에 맞는 것에만 해당한다. 여기서 각 악기 연주자들의 개인정보나 취향은 제거된다. 대신 오케스트라의 목적에 맞게 악기는 다들 가지고 있어야 하겠다. 오케스트라 지휘에 앞서 필요한 악기들이 모두 준비 되었는지를 확인하는 과정도 필요할 것이다. 따라서 차이점에는 악기가 포함된다. 이제 우리는 연주자라는 개념에 개별적인 차이점이 더해진 개별적인 악기 연주자를 설정한다. 대략 플루트 연주자, 하프 연주자, 피아노 연주자 등이 되겠다.

이 차이점을 더하여 개별적인 악기 연주자를 만드는 과정은 한가지 암시적인 가정을 포함한다. 플루트 연주자는 매우 구체적이다. 하지만 동시에 좀 더 추상적인 개념인 연주자와 동일시 될 수도 있다. 즉 플루트 연주자는 다른 연주자들과 구분되는 플루트라는 악기 속성을 가지면서도 동시에 연주자가 가진 연주하는 속성, 연주를 멈추는 속성도 가지고 있어야 한다. 이렇게 되어야만 다음 단계가 가능해진다.

현실에서라면 이와 같은 과정을 겪어야 할 것이다. 아래는 지휘자가 플루트 연주자에게 하는 가상의 대화이다.

"플루트 연주자님, 플루트 연주자님은 다른 악기 연주자들과 다르게 플루트를 가지고 계셔야 합니다. 그리고 저는 연주를 효율적으로 하기 위해서 플루트 연주자님을 그냥 연주자라고 부를 것입니다. 연주자란 연주하세요 하면 자기가 가진 악기를 연주하고, 멈추세요 하면 연주를 멈추는 사람입니다. 이해되셨죠?"

현실 세계에서는 이미 각 악기 연주자가 연주자 개념을 알고 있기 때문에 이런 일이 일어나진 않는다.


공통점을 통해 대상을 다룬다.

이제 지휘자가 오케스트라 지휘를 시작할 시점이다. 앞서의 지휘자와 플루트 연주자와의 대화와 유사한 대화들이 각 악기 연주자들과도 오고 갔다고 가정하자. 그러면 이제 지휘자는 연주를 시작할 수 있는 상태가 된 것이다. 각 악기 연주자들은 우리가 정의한 "연주자"의 개념을 알고 있다. 따라서 각 악기 연주자들에게 개별적으로 연주를 부탁하지 않아도 된다. 단지 다음과 같이 말하기만 하면 된다.

"연주자 여러분, 연주하세요"

그러면 플루트 연주자는 자기가 가진 악기인 플루트를, 하프 연주자는 하프를, 피아노 연주자는 피아노를 연주하게 될 것이다. 이렇게 열심히 공통점을 대상화하고 이용할 수 있도록 만들어 놓고서도 각각의 악기 연주자에게 일일이 연주를 지시하는 것은 비 합리적인 일이다.


자 이제 현실세계에서의 유연성이라는 것은 무엇이고, 이것이 수많은 요구사항들을 어떻게 수용하는지 알 수 있게 되었으며, 유연성을 확보하는 전략을 단계별로 이해할 수 있게 되었을 것이다. 이제 현실 세계에서의 유연성 확보 방법이 우리가 알고자 하는 객체지향 언어와 어떻게 연관되는지 살펴볼 차례다.

Posted by 이세영2
,

현실과 소프트웨어 세계에서의 객체

우리는 현실에서는 객체의 세계, 소프트웨어 세계에서는 객체지향의 세계에서 살고 있다고 해도 과언이 아니다.

현실에서의 객체란 독립적이면서 (분리되지 않고) 하나로 취급되는 인식의 단위를 말한다. 인간은 대상을 정확히 인식하기 위해서 목적한 대상과 다른 대상들과의 개념적인 차이들을 필요로 하고, 그 차이들이 명확해지고 나면 그 대상에 대해 명칭을 부여한다. 이 과정을 통해 불분명했던 대상은 정확한 명칭을 지닌 객체가 된다. 우리의 인식 속에서 일단 객체로 자리잡은 대상은 고유한 인식적 위치를 점유하게 되고 이는 다른 대상을 판별하는데 이용됨으로써 인식의 세계가 점점 더 넓어지는데 일조하게 된다. 대상을 다른 대상들과 분리하여 인식하는 이와 같은 방식은 현실에서의 인간이 살아가는데 큰 도움을 준다.

근본적으로 프로그래밍 언어로서의 객체지향 개념은 위에서 본 현실에서의 객체에 대한 인식과 크게 다르지 않다. 프로그래밍 언어의 세계는 관념적인 세계이다. 인간은 현실을 객체 단위로 이해하고 있지만 실제 세계는 객체적인 세계가 아니다. 개와 고양이는 (관념적으로는) 같은 동물이긴 하지만 개와 고양이는 같은 개념으로 묶을 수 있는 단위가 아니다. 하지만 우리의 두뇌는 현실을 관념적으로 파악하기 때문에 거리낌 없이 개와 고양이를 묶어 동물이라고 부른다. 그리고 아이러니하게도 이렇게 비현실적인 관념으로 세계를 파악하는 방식이 전체를 분리하지 않은 체 이해하는 방식보다 거의 언제나 더 유용하다. 유용하다는 말은 더 빠르고 정확하게 이해할 수 있다는 말이다. 따라서 프로그래밍 언어에 객체지향이라는 개념이 도입된 이유는 인간이 객체를 중심으로 프로그래밍을 하는 편이 더 빠르고 정확하게 이해하면서 구현하는 것이 가능하기 때문이다. 이 점은 매우 중요한데, 프로그래밍의 도구는 오직 두뇌 뿐이고, 두뇌가 더 빠르고 정확하게 문제를 이해하는 것은 프로그래밍에 그만큼 유리하게 작용하기 때문이다.

2016년 6월 기준으로 Java, C++, Python, C#, PHP, Javascript, Ruby, Objective-C 등과 같이 굵직한 언어들 중에서 객체지향을 온전히, 혹은 상당 부분 지원하는 언어의 비중을 따져보면 약 44.5%를 차지했다. 만약 하드웨어적인 제약 사항을 만족시키기 위한 언어(C, Assembly)나 제한된 목적을 위한 언어(Cobol, Matlab, Delphi, Visual Basic 등)를 제외한다면 범용적이고 일반적인 어플리케이션의 대부분은 객체지향 언어로 만들어진다고 볼 수 있을 것이다.


프로그래밍 언어의 역사와 객체지향

우리가 현실 세계를 객체로 이해하고 있다는 점을 이해한다면 객체지향 언어가 프로그래밍 언어 영역에 도입된 시기는 매우 빨랐을 것으로 추측할 수 있다. 실제로도 그렇다. 이반 서덜랜드(1938~)는 1963년도에 "스케치패드 : 사람과 기계와의 그래픽 대화 시스템"이라는 논문을 통해 객체지향 개념을 기반으로 구현한 그래픽 프로그램을 발표한다.("누가 소프트웨어의 심장을 만들었는가") 이후 시뮬라67 언어가 만들어지고(1967), 앨런 케이에 의해 스몰 토크라는 언어가 만들어짐으로써 온전한 객체지향 언어가 탄생하게 된다. 시기적으로 보면 현대에 가장 많이 사용되고 있는 비 객체지향 언어인 C 언어가 1972년에 만들어졌으니 객체지향 언어는 완성된 비 객체지향 언어보다도 개념적으로 더 오랜 역사를 가지고 있는 셈이다.

하지만 이율배반적이게도 많은 사람들이 객체지향 언어를 접한 시기는 상대적으로 최근의 일이다. 여기에는 여러가지 이유가 있다. 첫째 객체지향 언어는 컴퓨팅 파워가 뒤쳐졌던 70년대에는 다른 언어들에 비해 상대적으로 비인기 언어였다. 낮은 하드웨어 성능을 최대한 끌어낼 수 있는 절차지향 언어가 먼저 인기를 끌었다. 둘째, 우리에게는 객체를 중심으로 현실을 이해하는 것 만큼 익숙한 이해의 방식이 하나 더 있기 때문이다. 이것은 어떤 문제(또는 일)를 시간과 인과 관계의 순서로 이해하는 방식이다. "처음엔 A였다가 B라는 이유로 C가 되었다가 결국엔 D로 끝났어"와 같은 서술 방식은 시간과 공간 속에서 살아가는 인간에게 매우 익숙한 상황 인식 및 설명의 방식이다. 이러한 방식을 프로그래밍 언어에 도입한 것이 절차지향 언어이다. 첫번째 이유로 인해 기피되던 객체지향 프로그래밍 방식이 초반부터 매우 강력한 경쟁자를 만난 셈이다.

세번째 이유는 객체지향 언어를 이해하는 것이 절차지향 언어에 비해 상대적으로 어렵다는 점이다. 객체를 중심으로 이해하는 것이 현실을 이해하는데 유용하다는 점에서 볼 때는 매우 아이러니한 일이 아닐 수 없다. 우리는 매우 자연스럽게 세계를 객체 중심으로 이해한다. 이 과정은 너무나도 자연스럽기 때문에 그 메커니즘에 대해서는 잘 인지하지 못한다. 우리는 개와 고양이를 개와 고양이라고 부르면서 구분하고 있고, 동시에 동물이라는 카테고리 안에 이 둘을 자연스럽게 포함시킨다. 또 동물과 식물을 구분하고 있으면서 동시에 생물이라는 카테고리 안에 이 둘을 포함시킨다. 이것을 그림으로 그리면 트리 구조가 될 것이다. 하지만 우리는 이러한 구분을 트리 구조를 통해서 이해하고 있지 않다. 그냥 자연스럽게 그 인식 과정을 받아들인다. 하지만 이러한 메커니즘을 모사하여 프로그래밍 언어에 도입하는 과정은 그다지 순탄하지 않다. 개와 고양이를 구분하려면 개를 개념적으로 명확하게 정의하고 고양이를 개념적으로 명확하게 정의할 수 있어야 한다. 그리고 이들 둘을 동물이라는 같은 개념으로 묶을 수 있으려면 두 동물이 가진 개념 중에서 공통점을 추출할 수 있어야 한다. 또한 동물의 특성을 개와 고양이가 가질 수 있게 하려면 상속이라는 개념을 도입해야 하고, 동물이라는 개념으로 개와 고양이를 다룰 수 있으려면 다형성이라는 개념을 도입해야 한다. 요컨데 인간의 두뇌가 현실을 이해하는데는 엄밀한 정의도 필요하지 않고, 전체적인 논리적 완결성을 추구할 필요가 없지만 이를 매우 논리적인 완결성을 요구하는 프로그래밍 언어에 도입하는 데에는 다양한 개념의 정립이 필요하다.


객체지향이 대두된 이유

그렇다면 절차지향 언어가 계속 우위에 설 수도 있었을텐데 객체지향 언어는 왜 다시 대두되었고, 현재와 같은 주류 언어가 될 수 있었을까?

기초적이고 단편적인 이유들을 먼저 설명해 보자. 첫째로 컴퓨터의 연산 능력이 좋아졌다는 점이다. 객체지향 언어가 생겨난 70년대 이후 수십년에 걸쳐 하드웨어는 비약적인 발전을 거듭해 왔다. 그리고 하드웨어적/소프트웨어적 발전으로 인하여 하드웨어는 단일한 플랫폼을 넘어서 병렬화되면서 성능은 더더욱 좋아지게 되었다. 이것은 상대적으로 연산량을 많이 필요로 하는 객체지향 언어가 활동할 수 있게 된 배경이 된다. 둘째로 절차지향 언어의 한계점이 대두되었기 때문이다. 앞서 말했듯이 절차지향 언어는 시간과 공간 상에서의 인과 관계를 중심으로 현실을 이해하는 인간의 인식 방식을 모방한 것이기 때문에 이해하기 쉽고 다루기 쉬운 프로그래밍 언어이다. 하지만 절차지향 언어로 개발된 소프트웨어는 어느 일부분을 떼어 내어 다른 소프트웨어를 만드는 것이 어렵다. 절차지향 언어가 함수라는 독립된 단위로 소프트웨어를 분할하고 있지만 각 함수는 연쇄적으로 호출되면서 동작하기 때문에 어떤 부분만을 별도로 나누어 다른 시스템에 적용시키기가 어렵다. 또한 절차 전체에서 메모리를 공유하거나, 특정 절차가 어떤 데이터와 연관되는지를 쉽게 알 수 없기 때문에 전체 동작을 이해하기 위해서는 각 절차들의 내부를 자주 들여다 봐야 되고, 이런 방식으로는 큰 소프트웨어를 작성하기가 어려워진다.

하지만 보다 근본적인 이유는 따로 있다. 1930년대 후반에 소위 "컴퓨터"라는 것이 발명된 직후, 컴퓨터의 용도는 주로 암호 해독이었다. 일련의 덧셈, 뺄셈, 곱셈, 나눗셈 등을 이용하여 인코딩된 정보를 디코딩하는 용도라고 할 수 있다. 1940년대에는 군사 분야쪽으로 용도가 넓어진다. 포탄 궤적용 표, 폭발 계산(원자폭탄 개발), 탄도 미사일 계산과 같이 군사 물리적인 용도가 주를 이루었다. 1950년대에는 FORTRAN 언어가 개발되면서 과학 연구 분야에 컴퓨터가 활용되기 시작한다. 초신성 폭발, 수치해석, 일기예보 등이 그 예이다. 1960년대에는 운영체제와 COBOL 언어가 등장함으로써 고등 제조 기술 분야와 비즈니스 분야에 소프트웨어가 활용된다. 항공기 설계(특히 엔진 설계), 우주선의 제작 및 우주선 궤도 계산 분야, 비즈니스 소프트웨어 분야에 활용되었다. 1970년대에 이르러서는 PC가 출현하고 유저 인터페이스 및 인터넷이 탄생한다. 이와 함께 PC용 소프트웨어가 개발되면서 소프트웨어는 산업 전반에 걸쳐 사용된다. 애플I이 개발된 것이 1976년의 일이다. 1980년대는 PC 시대가 활짝 열리게 된다. MS-DOS가 발명되고(1981), PC의 대중화를 주도한 맥킨토시(1984)가 발명된 것도 이 시기이다. 특히 맥킨토시는 최초의 GUI와 마우스를 채용함으로써 그래픽 기반의 소프트웨어 시대를 열었다. 1990년대는 뭐니뭐니해도 WWW, 즉 월드 와이드 웹의 시대이다. HTML과 웹 브라우저로 인하여 전 세계의 컴퓨터가 서로 정보를 주고 받을 수 있는 세상이 되었다. 이로 인하여 소프트웨어는 닫힌 공간을 위한 용도에서 연결된 공간을 위한 용도로 활용되게 된다. 2000년대는 소셜 네트워크 및 집단 지성의 시대이다. 이 시기에 구글이 기존과는 완전히 다른 개념의 검색 기술을 개발(2003)하였고, Wikipidia(2001)와 같이 집단 지성을 활용하는 시대가 되었다. Facebook(2004)은 SNS의 시대를 열었고, 아이폰(2007)은 이 연결을 통한 가치 창출을 모바일의 영역으로 확대하였다. 2010년대는 빅데이터와 인공지능의 시대이다. IBM은 왓슨(2011)을 통해 빅데이터 기반으로 훈련된 인공지능을 선보였고, 2016년에는 이세돌과 알파고의 대결을 통해 인간의 능력을 넘보는 인공지능의 시대가 시작되었다.

소프트웨어의 역사에서 가장 주목할만한 것은 소프트웨어 활용 영역이 지속적으로 확대 되었다는 점이다. 소프트웨어는 컴퓨터의 초기부터 지금까지 그 적용 분야가 계속 확대되어 왔다. 그러면 이제부터는 그 적용 분야가 점차 축소될 것인가? 인공지능 분야는 현재 제한적인 부분에서만 인간의 능력을 뛰어 넘는 수준에 머물러 있지만 앞으로 적어도 모든 분야에서 인간의 능력을 뛰어넘기 전까지는 계속 발전하게 될 것이다. 이를 위해서는 더 많은 정보가 수집되어 분석되어야 한다. 당연히 데이터는 계속 수집될 것이고, 인간의 능력을 뛰어넘는 분야는 점점 넓어질 것이다. 사람들이 가지고 있는 모든 지식과 컴퓨터 뿐만 아니라 모든 센서가 연결되고 그 데이터가 수집될 때까지 이 과정은 지속될지 모른다. 즉, 소프트웨어는 적용 분야, 크기, 연산량이 확대되는 방향으로 발전해 갈 수 밖에 없다.

이 과정에서 소프트웨어가 갖추어야 할 기능의 필요성, 즉 요구사항의 증가는 필연적이다. 요구사항은 계속 증가한다. 단순히 양만 증가하는게 아니고 적용 분야가 증가하면서 종류도 다양해진다. 또한 소프트웨어와 인간이 만나는 횟수가 증가하면서 요구사항은 점점 더 다양해지고 또 자주 바뀌게 된다. 개별적인 인간의 개성이나 변덕의 수준을 맞추기 위해서는 소프트웨어가 더 많이 바뀌고 더 자주 바뀌고 더 빨리 바뀔 수 있어야 한다. 이렇게 잘 바뀌는 특성, 즉 유연성(flexibility)이 소프트웨어의 최고 덕목이 된다. 그래서 프로그래밍 언어 중에서도 가장 유연성이 뛰어난 객체지향 언어가 새로운 강자로 떠오르게 된 것이다.


소프트웨어가 가져야 할 가치

TDD, xUnit 테스트, 애자일의 창시자로 유명한 소프트웨어 프로그래머 켄트 벡은 "켄트 벡의 구현 패턴"에서 소프트웨어가 가져야 할 가치를 유연성, 단순성, 커뮤니케이션으로 정의하였다. 커뮤니케이션은 갈수록 커져가는 소프트웨어의 규모를 맞추기 위해서는 여러 사람들의 협력이 필요하고, 이를 위해서는 소프트웨어가 자기 스스로를 설명할 수 있는 능력, 즉 커뮤니케이션 도구로서의 기능을 가져야 한다. 또한 사용자의 요구사항은 예측이 불가능하게 변화하거나 추가되기 때문에 소프트웨어는 유연성을 꼭 가져야만 한다. 이들 덕목을 갖추면서도 기능을 구현하는 가장 단순한 방법을 추구하는 단순성을 가지고 있어야 한다.

이들 중에서 소프트웨어의 유연성은 소프트웨어의 존재 이유에 해당하는 가치라고 볼 수 있다. 소프트웨어는 하드웨어와 같은 다른 요소가 감당하지 못하는 기능 변경 요구사항을 수용하기 위해서 만들어진 요소이다. 이로 인하여 소프트웨어의 생애 주기 중 대부분은 수정과 개선 기간이 된다. 로버트 L. 글래스는 (우리가 미처 알지 못한) 소프트웨어 공학의 사실과 오해라는 책을 통해서 소프트웨어의 유지보수가 보통 소프트웨어 비용의 40~80%를 차지한다고 말한다. 이와 함께 60/60 법칙을 제시하는데 소프트웨어 비용의 60%는 유지보수에 사용되고, 유지보수 비용의 60%는 소프트웨어 기능의 개선을 위해 사용된다고 한다. 이는 소프트웨어가 얼마나 자주 변경되는지, 요구사항이 얼마나 자주 바뀌는지를 말해준다. 더불어 유연성이 소프트웨어의 가치 중에서 얼마나 큰 비중을 차지하는지를 알 수 있게 해준다.

객체지향은 소프트웨어의 유연성이라는 측면에서 가장 뛰어난 접근 방식이다. 객체지향 언어는 소프트웨어에 새로운 기능을 추가하거나 기존의 기능을 변경하는 것, 또는 더 이상 필요 없는 기능을 제거하는 유지 보수 작업에 가장 유리한 언어이다. 다음 글에서는 객체지향 언어가 이 유연성을 어떻게 확보하는지를 알아보겠다.

Posted by 이세영2
,

TDA 원칙이라고도 불린다.

우리 말로 번역해 보자면 "물어보지 말고 그냥 시켜라"가 될 수 있다.

이는 객체와 객체가 협력하는 경우, 다른 객체에게 정보를 요구하지 말고 그냥 행위하도록 시키라는 의미이다. 즉 정보 은닉의 중요성을 강조하는 원칙이라고 할 수 있겠다.


정보를 처리하는 소프트웨어 구현의 경제성 관점에서 보면 이렇다.

소프트웨어의 복잡성은 다루어야 할 정보의 양에 영향을 받는다. 다루어야 할 정보가 많아지면 더 많은 정보를 가공해야 하고, 정보의 값에 의한 제어 변경(보통 상태라고 부른다)이 더 자주 발생하게 된다. 

하지만 꼭 정보의 양만 복잡도를 가늠하는 척도는 아니다. 정보를 처리하는 단계가 짧고, 정보를 다루어야 할 객체가 적고, 정보에 대한 처리 과정을 중복되지 않고 간결하게 처리한다면 같은 데이터를 처리하더라도 훨씬 단순한 소프트웨어를 만들 수 있을 것이다. 이 과정에서 TDA 원칙의 중요성이 떠오르게 된다. 정보를 입수했을 때 그 정보를 한정적인 범위 내에서만 다루도록 하고(예를 들어 단일 객체), 혹시 외부에서 그 정보에 기반하여 동작을 수행해야 할 경우에는 정보를 가지고 있는 쪽에 동작을 요청하도록 하면 넓은 범위에서 데이터를 입수하여 처리하는 방식에 비해서 훨씬 복잡도가 줄어들게 될 것이다.


아래 그림을 살펴보자.




이 그림에서 데이터는 최초로 객체1에 전달된다. 그림 상에서는 객체1이 받은 데이터를 객체2와 객체3에 주고, 객체3은 이를 다시 객체 4에 준다. 이렇게 데이터가 전달되는 방식은 크게 두 가지가 있다.

1. getter를 통해 데이터를 요청하는 경우.

2. 다른 객체의 API에 데이터를 인자로 넣게 되어 있는 경우.


Tell, don't ask라는 것은 1번에 해당하는 말이다. 즉, 데이터를 getter로 요청하지 말 것을 의미한다. 하지만 데이터를 전파하는 방법은 2도 해당하므로 이 두가지 경우가 발생하지 않도록 설계해야 한다.

자, 그림 상에서 1번이든 2번이든 어떤 방식으로든 데이터를 전달하도록 설계했다고 하자. 그러면 저 그림의 모든 객체들은 데이터의 값에 영향을 받게 된다. 그것이 조건문으로 나타나든, 변수로만 나타나든 어떻게든 코드 상에 모습을 드러내게 된다. 이것은 다음과 같은 문제들을 발생시킨다.


코드가 복잡해진다

데이터를 가지고 있으면 데이터를 핸들링 해야 한다. 핸들링하는 코드는 단순히 전달하거나 저장하는데만 그치는 경우도 있고, 데이터 값의 범위에 따라서 조건문이나 제어문이 필요한 경우도 있다. 어떤 식으로든 코드가 늘어나면 문제가 생기는 것은 당연하다.


데이터의 변경에 다수의 객체가 영향을 받는다

일단 데이터를 가지고 있으면 더이상 쓸모없는 데이터여서 지우거나, 새로운 데이터가 더해지거나 데이터의 타입이 변경되는 등의 여러가지 변경사항에 영향을 받게 된다. 이는 OCP(Open Close Principle) 원칙을 위배하게 된다.


데이터의 무결성을 지키기 어렵다 

각 객체들이 가지고 있는 데이터 값이 시간에 따라서 달라짐으로써 관리가 어려워지게 된다. 특히 멀티 쓰레드 환경에서 여러 곳에 데이터가 흩어져 있으면 데이터의 무결성을 지키기는 더더욱 어렵고 복잡해진다.


중복 코드가 발생할 가능성이 높다

한가지 데이터는 보통 소프트웨어 전체에서 한가지 용도로 사용된다. 따라서 하나의 데이터를 다루는 코드들은 유사성이 매우 높다. 이 코드들은 애초에 한번만 작성되도록 만들어져야 하는데 데이터가 여러 객체로 전달되고 나면 중복 코드가 발생하는 것은 거의 필연적이다.


그렇다면 어떻게 이 문제를 해결할 수 있는지 한번 살펴보자.


간단히 이야기 하자면, 데이터가 입력된 이후에는 데이터를 핸들링하는 객체를 별도로 생성하여 관리하면 된다. 위 그림에서는 데이터 객체가 이에 해당한다. 객체1은 데이터를 받아서 데이터 객체를 생성하거나 데이터 객체에 전달해 주는 것으로 자기 일을 마친다. 그리고 기존에 데이터를 가지고 다루던 객체들은 모두 데이터 객체에게 일을 시키는( tell() ) 형태로 설계를 변경한다. 그러면 데이터와 관련된 모든 일은 데이터 객체가 수행하게 되면서 다른 객체들이 데이터에 의존하는 것을 막을 수 있다.

이를 단계별로 설명해보면 다음과 같다.


데이터를 수신(생성) 단계

소프트웨어의 어떤 부분이든 데이터를 수신하거나 생성해 낸 곳이 있기 마련이다. 데이터는 발생 시점부터가 중요하다. 데이터를 최초로 수신한 객체는 일단 다른 곳으로 데이터를 전파시킬 수 없어야 한다. getter를 통해서 다른 객체들이 데이터를 가지고 가게 하거나, 다른 객체의 매개변수로 데이터를 전송해서는 안된다.


데이터 객체 생성 단계

데이터를 처리할 객체를 생성한다. 데이터는 생성된 이후에는 오직 이 객체에게만 전달된다. 데이터를 처리하는 방식은 두가지가 있다.

1. 데이터 처리를 전담하는 객체가 있다. 이 경우라면 최초로 데이터를 수신한 객체는 처리 객체에 데이터를 넘겨주기만 하면 된다.

dataProcessor.receiveData(data);

2. 데이터 처리를 전담하는 객체가 없고, 여러 객체들이 데이터에 대한 의존성을 가지고 있는 경우가 있다. 이런 경우에는 데이터 객체를 (필요시) 생성하고, 이 데이터 객체를 다른 객체에 전달하여 준다. 이 객체는 데이터가 변경되었을 때 다른 객체를 어떻게 변경시켜야 하는지를 알고 있다. 이렇게 데이터를 객체화 하여 전달하는 것은 State 패턴이나 Strategy 패턴과 유사한 모양이 된다.

// 데이터 객체가 생성된다

Data dataObject = new Data(data);

// 처리를 위해 다른 객체에 전달된다.

object.receiveData(dataObject);


데이터 처리 단계

데이터 객체 생성 단계에서 방법이 두가지가 있듯이 처리 방식도 두가지이다.

1. 데이터 처리 전담 객체의 경우에는 다른 객체들이 수시로 데이터 갱신이 이루어 졌는지를 데이터 처리 전담 객체에게 물어보는 방식이 있고, 다른 객체들이 데이터 처리 객체에 자신을 이벤트 리시버로 등록하는 경우가 있다. 후자는 Observer 패턴과 유사하다.

2. 데이터 객체가 생성되서 다른 객체로 전송되어 오면 각 객체들은 이 데이터 객체를 이용하여 변경된 데이터에 의한 동작을 수행해야 한다. 이 때 각 객체들은 데이터를 모르기 때문에 직접 자기 자신의 행위를 변경할 수는 없다. 따라서 데이터 객체에게 자신의 상태를 변경해 달라고 요청해야 한다. 따라서 이를 실행하면 아래와 같은 형식의 코드가 된다.

// 데이터 객체를 수신한 쪽 : Object2라고 가정했을 때

dataObject.doubleDispatch(this);


// 데이터 객체 쪽

public void doubleDispatch(Object2 object){

    object.doSomething();

}

이러한 방식을 켄트 벡의 구현 패턴에서는 "더블 디스패치"라고 한다. 데이터를 알고 있는 쪽에서 데이터에 종속적으로 동작하는 객체를 넘겨 받아서 자기가 알고 있는 데이터를 기반으로 넘겨 받은 객체의 행위를 호출하는 것이다. 이렇게 하면 데이터를 넘겨주지 않고도 데이터가 넘어 갔을 때 일어나야 하는 행위를 호출할 수 있게 된다.


"정보"의 전달을 금지하는 원칙

기본적으로 정보 은닉(information hiding)은 단순히 캡슐화만을 의미하는 것이 아니다. 정보 은닉(information hiding)에 대한 올바른 이해에서도 이야기 했듯이 생성된 객체의 구체적인 타입을 숨기는 것이나 구현을 숨기는 것도 정보 은닉에 해당된다. 또한 아무리 캡슐화를 잘 했다고 해도 getter를 통해서 데이터를 전달하거나 매개변수로 데이터를 다른 객체에 넘겨버리면 기껏 정보 은닉을 강조한 보람이 없어진다.

정보 은닉은 데이터의 종류를 막론하고 데이터 처리를 수행하는 전담 객체가 아니면 어떠한 객체도 데이터를 전달해주지 않아야 한다는 원칙으로 해석해야 한다. 이 정보에는 외부에서 받은 데이터도 포함되지만 생성한 객체의 구체적인 타입이나 구현부와 같이 프로그래밍 요소의 정보도 포함이 된다. 그리고 이런 정보들은 생성과 동시에 은닉 됨으로써 정보에 의존하는 코드들의 생성을 막아야 한다. 이것이 Tell, don't ask 원칙과 정보 은닉 원칙이 추구하는 방향이다.

Posted by 이세영2
,

우리가 어떻게 해서 유연성을 확보할 수 있었는가?

그것은 추상화(Abstraction)에서부터 시작되었다. 추상화를 통해 우리는 여러 요구사항들 중에서 공통점을 찾고, 이 공통점에서 목표한 것과 관련 없는 것들을 제거하였다. 이를 기반으로 공통점을 캡슐화할 수 있었고, 이 캡슐화된 대상에 타입을 부여할 수 있었다.

이 추상화의 과정을 비유적으로 이야기 해보면 이렇다. 개별적인 것들은 다들 개성이 강하고 다른 듯 하지만 멀리서 보면 대개 비슷하다. 우리 두뇌는 이런 일들을 잘 해낸다. 소위 "패턴"은 이와 맥락을 같이 하는 단어이다. 디자인 패턴이든 건축 패턴이든 패턴이라는 것은 개별적인 시도가 가지는 공통된 맥락을 의미한다. 추상화란 다들 서로 다른 듯 보이는 것들이 내제하고 있는 일반적인 모습, 바로 "패턴"을 찾아내는 과정이다. 사실 세상에는 "패턴"으로 정의할 수 있는 것들이 무수하게 많다. 어느 분야의 대가라고 불리는 사람들은 그 분야에서 벌어지는 다양한 시도들이 어떤 "패턴"을 가지고 있는지를 이해하고 있다. 그들은 그러한 패턴들을 알고 있기 때문에 새로운 시도를 할 때에도 마치 이전에 경험했던 일을 하는 것처럼 쉽게 해낼 수 있다. 

당대에 유명한 사랑꾼으로 통했던 카이사르는 자신이 어떤 후보를 추천하기 위해 추천서를 썼던 것처럼, 이미 작성해 놓은 똑같은 내용의 연애 편지에 이름만 다르게 써서 여자들에게 보냈을지도 모를 일이다. 안타깝게도 카이사르의 연애 편지들은 모두 그 후계자인 아우구스투스가 없애버렸기 때문에 이제는 확인할 길이 없지만 말이다. 어쨌든 똑같은 연애 편지에는 대상자 이름이 적혀 있지는 않았을 것이다. 그래야 연애 편지가 유연성을 가질테니까 말이다. 이것을 좀 더 일반적으로 표현해 보자면 공통된 정보를 모아 놓되 구체적인 정보는 숨겼다는 말이다. 이것을 객체지향에서는 정보 은닉(Information Hiding)이라고 부른다.

진짜 객체지향은 정보 은닉에서부터 시작된다.

객체지향 언어를 통해서 얻고자 하는 것이 유연성(기능의 확장, 교체, 변경)이라면 정보 은닉은 그것을 가능하게 하는 전략이다. 객체, 상속, 캡슐화 등은 정보 은닉의 수단에 불과하다. 그리고 좋은 정보 은닉은 잘 된 추상화를 통해 얻어진다.

많은 개발자들이 객체지향에 들어서면서 캡슐화를 정보 은닉이라고 배운다. 몇몇 훌륭한 블로그들을 제외하고는 대부분의 블로그들이 정보 은닉 = 캡슐화로 설명하고 있다. 매우 안타까운 일이다. 정보 은닉을 캡슐화로만 알고 있으면 아직 객체지향 입구에도 못들어 온 것이다.

정보 은닉과 관련하여 인터넷을 검색해 본 결과, 정확하게 정보 은닉을 설명한 것은 아래 글 밖에 없었다.

http://egloos.zum.com/aeternum/v/1232020


정보 은닉의 정의

- 모든 객체지향 언어적 요소를 활용하여 객체에 대한 구체적인 정보를 노출시키지 않도록 하는 기법.


소프트웨어의 유연성을 확보하는 단 한가지 방법만 있다면 그것은 무엇일까? 그것은 "객체(또는 클래스) 간에 서로를 모르게 하는 것"이다. 어떤 객체가 다른 객체를 생성하든, 다른 객체의 메소드를 호출하든, 다른 객체가 가진 정보를 조회하든, 다른 객체의 타입을 참조하든, 어떤 행위라도 상관이 없이, 안하는 것이 가장 좋다. 두 객체(또는 클래스)가 서로를 모른다는 것은 서로의 코드에 상대 객체나 클래스에 대한 코드가 단 한 줄도 없다는 의미이다. 만약 두 객체간에 전혀 관계가 없다면 두 객체 중 어느 하나가 수정되거나 사라지더라도 다른 객체는 전혀 영향을 받지 않는다. 따라서 두 객체간에는 유연성이 확보된다. 이것은 매우 자명한 이치지만 이런 원칙을 전체 시스템에 확장시킬 수는 없다. 객체지향 언어에서 어떤 목적을 달성하기 위해서는 필연적으로 다른 객체와의 협력이 있어야 하기 때문이다. 이 필연성은 "어떤 객체도 섬이 아니다"라는 워드 커닝헴과 켄트 벡의 말로 대변된다. 객체지향 시스템에 참여하는 모든 객체들은 어떤 형태로든 관계들로 엮여 있다. 객체를 노드로 하고 관계를 엣지로 나타내면 단 한 덩어리의 연결 그래프가 되어야만 한다. 만약 어떤 객체가 다른 어떤 객체와도 관계를 갖지 않는다면 그 객체는 별도의 시스템이다.

일단 발생한 관계는 유연성을 발휘하지 못하게 만든다. A가 B에 책임을 위임한 경우라면 B의 수정은 A의 위임 목적을 해칠 수 있다. 원래 B로 부터 얻고자 했던 결과를 더 이상 얻을 수 없을지 모른다. 같은 관계에서 A의 수정은 B의 책임을 더욱 강화 시킬 수도 있고, 반대로 전혀 필요 없는 객체로 만들어 버릴 수도 있다. 일단 관계가 발생하면 언제라도 관계가 있는 객체에 수정을 발생 시킬 여지가 있다. 그리고 어떤 객체든 적어도 하나 이상의 다른 객체와 관계를 맺어야만 한다. 이것이 어쩔 수 없는 현실이라면, 즉 어떻게든 관계가 있을 수 밖에 없다면, 똑같은 관계라도 더 좋은 관계로 변경해야 한다. 그렇다면 어떤 관계가 좋은 관계일까?


1. 자주 변경될 가능성이 있는 것에는 의존하지 않는다.

2. 외부로 노출된 메소드를 최소한으로 줄인다. 노출된 메소드가 최소인 객체는 노출된 메소드가 많은 객체에 비해 메소드가 적게 호출되고, 이는 다른 객체와의 관계가 발생할 가능성을 줄인다.

3. 객체의 책임을 최소한으로 줄인다. 책임이 작은 객체는 다른 객체와의 관계가 작아진다. 책임이 작아진 객체는 또한 수정될 가능성이 줄어든다. 따라서 다른 객체에 수정의 영향을 줄 가능성도 줄어든다.


정보 은닉의 종류

- 객체의 구체적인 타입 은닉(= 상위 타입 캐스팅)

- 객체의 필드 및 메소드 은닉(= 캡슐화)

- 구현 은닉(= 인터페이스 및 추상 클래스 기반의 구현)


정보 은닉의 목적

- 코드가 구체적인 것들(타입, 메소드, 구현)에 의존하는 것을 막아줌으로써 객체 간의 구체적인 결합도를 약화시켜 기능의 교체나 변경이 쉽도록 함.

- 동일한 타입의 다른 구현 객체들을 교체함으로써 동적 기능 변경이 가능함.

- 연동할 구체적인 구현이 없는 상태에서도 (인터페이스 만으로) 정확한 연동 코드의 생성이 가능함.


자 그러면 본격적으로 구체적인 구현을 통해서 정보 은닉 방법과 이점을 살펴보도록 하자.


"생성부터 은닉질이냐!?"

그렇다. 객체의 생성시부터 정보 은닉을 해야 한다. 아래 코드를 보자.

class Rectangle{

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

}


public static void main(String[] args) {

    Rectangle rectangle = new Rectangle(); // --- 1

    rectangle.rectangle(); // Rectangle 클래스에 의존적인 코드

}

위의 코드에서 1과 같이 객체를 생성했다고 하자. 객체는 생성 이후에 rectangle이라는 Rectangle 클래스 변수로 참조된다. 따라서 Rectangle에 선언된 모든 메소드를 사용할 수 있게 된다. 따라서 rectangle.rectangle()의 호출이 가능해진다. 이것은 Rectangle라는 객체에 전적으로 의존하게 되는 코드이다.

만약 우리가 좀 더 생각해서 Rectangle과 유사한 기능, 즉 Circle을 구현하게 될지도 모르고, 이에 따라서 Rectangle을 대신해서 Circle을 사용하게 될지도 모른다고 하자. 그래서 Rectangle과 Circle을 모두 지칭할 수 있는 상위 클래스인 Shape을 만들고, 각각의 모양을 그릴 수 있는 메소드(draw() 메소드)를 구현하도록 정의했다고 하자. 

그러면 아래의 2와 같은 코드를 만들 수 있다.

abstract class Shape{

    abstract public void draw();

}


class Rectangle extends Shape{

    public void draw(){ rectangle();}   

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

}


public static void main(String[] args) {


    Shape shape = new Rectangle();         // --- 2

    shape.draw();  // Shape 클래스에 의존적인 코드

}

코드 2는 동일하게 Rectangle을 생성했지만 곧바로 그 상위 타입인 Shape 클래스 참조 변수인 shape으로 객체를 참조한다. 따라서 이후에 shape 참조 변수를 통해 사용할 수 있는 메소드는 Shape 클래스의 메소드로만 제한된다. 그래서 생성 이후에는 Rectangle 클래스와 관련된 어떤 메소드도 호출되지 않는다. 이것이 구체적인 타입 은닉에 해당된다.

이를 통해서 얻을 수 있는 이점은 다음과 같다.

- Rectangle의 생성 코드 이후에는 어떤 코드도 Rectangle 클래스에 의존하지 않는다.

- 따라서 Rectangle 대신에 Circle을 사용하고 싶어졌을 때에는 Rectangle 대신 Circle을 생성하도록 변경하기만 하면 된다. 그러면 그 이후의 코드들은 전혀 수정될 필요가 없다.

- 만약 Rectangle을 사용하다가 Circle을 사용해야 할 경우도 발생할 수 있다. 바로 동적으로 기능을 교체해야 할 경우이다. 이 때에도 선언되어 있는 shape 참조 변수에 새로 생성한 Circle 객체만 참조로 할당해 주기면 하면 된다. 이런 방법으로 동적인 기능 전환도 쉽게 할 수 있다.


위의 코드에도 문제가 있다고 해서 다양한 디자인 패턴들이 생겨났다. Abstract Factory 패턴, Factory Method 패턴과 같은 경우가 바로 그것이다. 이들 생성 패턴들은 생성과 동시에 구체적인 타입 은닉을 수행하도록 되어 있다. 

- Abstract Factory 패턴

- Factory Method 패턴


아래는 각 생성 패턴들이 공통으로 추구하는 방향만을 간략하게 구현해 본 것이다.

class ShapeFactory{

    public Shape createRectangle(){ return new Rectangle(); }

    public Shape createCircle(){ return new Circle(); }

}


public static void main(String[] args) {

    ShapeFactory factory = new ShapeFactory();


    Shape shape = factory.createCircle();

    shape.draw();

}

위에서 구체적인 객체, 즉 Rectangle과 Circle 객체를 생성하는 책임을 담당하는 클래스가 ShapeFactory 클래스이다. 그리고 이 클래스는 객체의 생성과 함께 객체를 리턴하는데, 리턴하는 타입은 동일하게 Shape 타입으로 객체를 리턴한다.

이것이 어떤 효과를 가져 오는가? main() 메소드에서 ShapeFactory를 사용하게 되어 있는데, main() 메소드가 ShapeFactory의 createCircle() 메소드를 호출해서 객체를 받아 올 때 타입은 이미 Shape으로 변경되어 있다. 따라서 main() 메소드에서는 Rectangle이라는 클래스나 Circle이라는 클래스는 전혀 모르는 상태다. main()이 알고 있는 것은 오직 Shape 객체 뿐이다. 따라서 이 ShapeFactory를 이용해서 객체를 생성하면 생성된 이후의 모든 코드와 Rectangle 또는 Circle과는 전혀 무관한 코드가 된다. 오직 Shape만 이용하게 되기 때문이다.

그러면 어떤 장점이 있을까? 당연히 객체의 교체나 변경이 쉬워지게 된다. 또 다른 Shape 타입을 추가하는 것도 손쉬워진다. ShapeFactory를 거친 이후에는 모두 다 같은 Shape으로 취급될 것이기 때문이다.


캡슐화를 통한 정보 은닉

이 부분에 대해서는 다른 여러 블로그나 책들에서도 언급을 하고 있다. 하지만 상대적으로 덜 강조되고 있는 부분은 짚고 넘어가야 겠다.

일단 변수(필드)에 private 키워드를 이용해서 외부 노출을 줄이는 부분에 대해서는 어떤 책이든 강조를 하고 있다. 이를 통해서 필드를 외부에서 임의로 접근해서 발생할 수 있는 문제들을 없앨 수 있다.

메소드에 대해서는 상대적으로 강조가 적은 편인데 아래 예를 보면서 이야기를 해보자.

class Process{

    public void init(){}

    public void process(){}

    public void release(){}

}

위의 클래스는 public 메소드가 모두 3개이다. 즉, 외부에서 이 클래스의 객체를 사용하는 코드들에서는 모두 3개의 메소드에 의존하게 된다. 이는 혹시라도 Process 객체를 수정하거나, 아예 제거를 하는 등의 수정이 발생했을 때 3개의 메소드보다 적은 수의 메소드에 의존하는 코드들에 비해 수정이 더 많이 되어야 함을 의미한다.

또한 불필요하게 많은 수의 메소드를 노출시키면 여러가지 나쁜 면이 있다. 첫째로 메소드의 호출 순서를 제대로 알지 못해서 발생하는 문제점이 있을 수 있다. 메소드들이 서로 시간적 연관 관계가 있어서 순서대로 호출되어야 하는데 여러 메소드로 나누어져 있을 경우 이를 알지 못해 오류가 발생할 수 있다. 둘째로 구현의 구체적인 사항을 외부에 노출시킨다는 점이다. 이 Process의 세부 단계에 대해서 외부 객체들이 알게 됨으로써 구현을 유추할 수 있거나 유추해야만 하는 문제가 발생한다. 세번째로 어떤 메소드가 중요한지를 알 수 없게 된다. 적절한 수준에서 정보를 숨겨줌으로써 객체를 이해하는 입장에 도움을 주어야 하는데 모든 메소드가 노출되어 있으면 무엇이 중요한 메소드인지를 알 수 없다.

그럼 아래 코드를 보자.

class Process{

    private void init(){}

    private void process(){}

    private void release(){}

    public void work(){

        init();

        process();

        release();

    }

} 

위와 동일한 기능을 하지만 좀 더 나은 모습이다. 일단 이전에 보여졌던 메소드들이 모두 비공개(private) 메소드로 변경되었다. 따라서 외부 객체에서는 이들 메소드를 호출할 수가 없다. 대신에 외부에서 호출이 가능하도록 work() 메소드를 공개하고 있다. 따라서 외부 메소드들은 work() 메소드만을 이용할 수 있다.

이를 통해 얻는 장점은 다음과 같다. 우선 적절한 수준에서 메소드들이 공개와 비공개로 나누어져 있기 때문에 어떤 메소드를 우선 살펴야 할지를 알 수 있다. 또한 개별 메소드들의 호출 순서를 work() 메소드에서 정해주고 있기 때문에 Process 객체 사용에 대한 정보를 더 적게 알아도 된다. 마지막으로 work()라는 메소드만 노출 되었을 때에는 Process 객체가 하는 일의 세부 내용을 덜 노출시킨다. 즉, 외부에서는 Process 객체가 init - process - release 단계를 거친다는 점을 알 수 없다.


오직 인터페이스에만 의존하도록 한다

만일 객체를 잘 설계하여 변수를 private으로 선언하고, 꼭 필요한 메소드만 외부로 공개하였다고 하자. 그러면 외부 객체와 잘 설계된 객체간에 의존성은 오직 공개 메소드에 의해서만 발생하게 된다. 그래서 공개 메소드를 비공개 또는 보호 메소드들과는 구분하기 위해서 인터페이스라는 별도의 용어를 부여하게 되었다. 그만큼 공개 메소드가 개념적으로 중요하기 때문이다.

JAVA 언어에서는 이 개념을 더욱 강화하여 클래스와 유사하게 상속 가능한 타입이면서 구체적인 구현을 배제한 interface 라는 개념을 만들어 냈다. 예제에서는 interface라는 추상화 요소를 사용하게 될텐데 혹시 JAVA가 아닌 다른 객체지향 언어를 사용하고 있다면 interface를 "공개 추상 메소드만을 가지고 있는 추상 클래스" 정도로만 이해하면 된다.

앞서 이야기 했듯이 객체와 외부와의 소통은 오직 공개 메소드만으로 이루어진다. 그렇다면 어떤 객체가 공개 메소드의 모양, 즉 프로토타입(= 공개 추상 메소드)만 가지고 있는 상위 클래스를 상속 받았고, 오직 그 상위 클래스가 제공하는 공개 메소드만을 외부로 공개하였다면, 이 클래스는 상위 클래스로 지칭될 수 있다. 이 때 상위 클래스는 오직 공개 메소드를 선언하는 선언부 역할만을 하고, 하위 클래스는 이를 구체적으로 구현하는 역할만 가지게 된다.

이를 코드로 나타내 보면 아래와 같다.

interface Interface{

    public void method();

}

class ConcreteClass implements Interface{

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

}

Interface는 오직 공개 추상 메소드만을 정의하고 있다. ConcreteClass는 이 Interface가 제공하는 공개 추상 메소드를 구체적으로 구현하고 있다.

그러면 객체를 사용하는 입장에서는 어떠한가? 객체를 사용하는 입장에서는 ConcreteClass를 Interface 타입으로 지칭할 수 있게 된다. 그리고 모든 기능이 Interface가 정의한 공개 메소드를 통해 실행 가능하므로 ConcreteClass를 이용하는데 전혀 문제가 없다.

이처럼 인터페이스와 구현을 분리하면 다음과 같은 이점이 있다.

- Interface만으로 객체를 다룰 수 있으므로 구체적인 구현에 대해서 전혀 모르더라도 동작이 가능하다. 즉 구현에 대해 관심을 둘 필요가 없다.

- 좀 더 나가보면 Interface만 알고 있어도 Interface에 의존하는 코드를 작성할 수가 있다. 즉, Interface를 상속 받는 임시 객체를 만들어 두고 이를 이용하는 코드들을 만들었다가 추후에 Interface를 구현한 구체 클래스가 완성되었을 때 연결만 시켜주면 된다.

- 모든 클래스들이 오직 Interface에만 의존하게 되므로 구체적인 의존 관계가 없어지면서 각 객체가 서로 분리되어 있기 때문에 구체적인 객체를 다른 객체로 교체한다거나 Interface를 구현한 새로운 객체를 만들어서 제공함으로써 기능을 확장하는 것이 가능해진다.

이러한 인터페이스의 장점을 이용한 디자인 패턴은 모두 열거하기 힘들 정도로 많다. 가장 대표적인 것만 꼽으라면 아래와 같다.

- State 패턴 : 상태를 객체화하고, 인터페이스를 통해 상태화 된 객체를 지칭하게 함으로써 상태가 추가되기 용이하도록 한다.

- Bridge 패턴 : 연관관계가 있는 두 부류의 객체들을 두 개의 인터페이스 간의 연관관계로 바꾸고, 구체적인 객체들을 인터페이스 상속을 통해 구현 함으로써 각 부류의 객체들에 추가/삭제가 발생하더라도 다른쪽 부류에는 영향을 미치지 않도록 한다.

- Stragegy 패턴 : 기능을 담은 객체를 인자로 넘겨 줌으로써 이를 받는 객체의 기능이 변경될 수 있도록 한다. 이 역시 인터페이스를 중심으로 기능을 담은 객체를 지칭함으로써 기능의 확장이나 변경이 용이하도록 한다.

- Observer 패턴 : 관찰자 객체들을 인터페이스로 추상화하고, 관찰 대상 객체에 이벤트가 발생했을 때 인터페이스만을 활용하여 이벤트를 전달함으로써 관찰자와 관찰 대상 간의 구체적인 결합을 제거한다. 이를 통해서 관찰자에 해당하는 구체적인 객체들의 종류가 늘어나더라도 같은 관찰 대상 객체의 구현에는 영향이 없다.


정보 은닉은 객체지향 언어의 목표이다

기능을 간편하게 수정할 수 있으며, 기능을 추가하기 용이하고, 언제든 기능을 교체하는 것이 가능한 소프트웨어를 구현하는 것은 모든 소프트웨어 개발자들의 꿈이다. 그런 꿈이 담겨 있는 것이 객체지향 언어이고, 이 객체지향 언어가 기능의 수정 / 추가 / 교체를 가능하게 하기 위해 세운 기초 전략이 정보 은닉이다.

객체지향 언어를 통해 만들어진 좋은 설계, 즉 디자인 패턴과 같이 좋은 설계를 대표할만한 것들은 모두 정보 은닉 기법을 적어도 일부를 활용하고 있거나, 전적으로 정보 은닉을 통해 이득을 얻기 위해서 만들어진 것들이다. 그리고 객체지향의 설계 원칙(SOLID), 각종 객체지향 설계에 관련된 격언들은 거의 모조리 정보 은닉에 관련된 것들이다. 또한 객체지향이 만들어낸 여러 개념들과 언어적 특성들 중 대부분은 정보 은닉을 위해 만들어진 것들이다.

(예를 들어 상속을 보통 메소드와 변수를 재사용하는 것이라고 이야기하는데 이는 틀린 말이다. 상속을 통해 받을 수 있는 것 중에서 가장 중요한 것은 타입이다. 이 타입을 내려 받을 수 있기 때문에 하위 객체가 상위 클래스로 지칭 될 수 있다. 이것이 정보 은닉을 가져오고 객체지향의 모든 이점들을 가져 온다.)

Posted by 이세영2
,

객체지향 용어에 대한 글들을 찾아보니 개념의 일부만 정리되어 있거나 매우 중요한 개념이 제대로 다루어지지 않은 경우가 많아 보인다. 특히 비 객체지향 언어와의 정합성, 동일하거나 유사한 용어들에 대한 명확한 정리가 없는 점이 안타깝다. 그래서 객체지향 및 그와 연관된 개념들을 생각나는 대로 모두 정리해 보았다. 이 글은 아주 기초적인 내용들을 미리 알고 읽어 보는 것이 좋긴 하다. 하지만 객체지향을 접한지 오래 되었다고 해도 제대로 이해하지 못하는 중요한 부분들에 대해서 강조해 두었으니 충분히 도움이 될 것이라 생각한다.

일부 코드 예제나 추가적인 용어는 시간을 내어 더 작성할 예정이다. 내용이 많으니 시간을 충분히 가지고 도전하기 바란다.


객체지향 기본 요소를 이용한 코드 예제

// interface

interface IShape{ // IShape 타입

    public void draw();// 공개 추상 메소드

}

// 추상 클래스

abstract class Base{

    abstract public void init();// 추상 메소드의 프로토타입

}

// 클래스

class Subject

    extends Base// 상위 클래스, 부모 클래스를 상속한다

    implements IShape// 인터페이스를 구현한다

    // SubjectSubject 고유 타입이면서 Base 타입이고, IShape 타입이기도 하다

{

    Color color;// 변수, 참조 변수

   

    int width;// 변수, primitive 변수

   

    public void setColor(Color c){// 공개 메소드

        // csetColor 메소드의 매개 변수이다.

        this.color = c; // 여기서 cSubject의 멤버 객체가 된다.

    }

   

    @Override

    public void init(){}// 재정의. 추상 메소드를 구현한다

   

    @Override

    public void draw(){

        System.out.println("draw subject");

        color.brighter();// 위임

    }

}

public static void main(String[] args) {

    Subject subject = new Subject();// 객체화.

    // subjectSubject 객체를 참조하고 있다.

    Base base = (Base)subject;// up casting

    Subject subject2 = (Subject)base;// down casting

}


[JAVA] 자바 언어에서만 사용하는 용어

[C++] C++에서만 사용하는 용어

[정적] 코드 상에 존재하는 개념. 정적인 코드를 통해 동적인 것들을 만들어 내게 된다.

[동적] 실제 소프트웨어 동작 시 작동하는 것에 대한 개념. 메모리 상에 올라가 있는 것.

[OOP] OOP 개념이 아닌 소프트웨어 용어. OOP 용어와의 대비를 위해 사용.

~ : 대체적으로 동의어로 사용 가능(비 객체지향 용어지만, 객체지향에는 없는 용어이므로 대충 객체지향 언어로는 이와 같다는 의미)

 

[OOP] 함수(function) = 기능 ~ 행위(Operation) = 메소드(Method) = 멤버 함수 = 공개(public) 메소드 + 비공개(private) 메소드

-       속성에 대한 조작을 수행하는 명령어들을 모아 놓은 단위. 소프트웨어가 동작한다는 의미는 이 메소드가 실행된다는 것을 의미한다.

 

[OOP] 변수(variable) ~ 속성(Attribute) = 필드(field) = 데이터 = 멤버 변수 = 참조 변수 + primitive 변수

-       변경 가능한 값(value)을 저장할 수 있는 프로그래밍 요소. 메모리 상의 공간을 확보하고 있으면서 정ㅇ해진 타입을 가지고 있는 것.


[OOP] primitive 변수

-       비 객체지향 언어에서 말하는 기본 자료형. char, short, int, long 등과 같이 객체가 아닌 멤버 변수를 의미.


[정적] 참조 변수

-       객체가 다른 객체를 참조할 수 있도록 선언된 변수.

-       이 참조 변수가 멤버 객체를 가리키게 된다. 참조 변수를 가지고 있는 객체와 멤버 객체 간의 관계를 “has-a” 관계라고도 한다. 마치 한 객체가 (참조 변수를 통해) 다른 객체를 내부에 가지고 있는 것처럼 보이기 때문이다.

 

[동적] 멤버 객체 = 위임(delegation) 객체 = 참조(reference) 객체 = 내부 객체 = 레퍼런스(reference)

-       다른 객체의 필드로 선언되어 있는 객체

-       멤버 객체는 참조 변수를 통해 사용된다.

 

위임(Delegation)

-       1. A라는 객체가 가진 책임(Responsibility)을 수행하기 위해서 멤버 객체에게 책임의 일부 또는 전부를 수행하도록 시키는 것.

-       2. 멤버 객체를 이용하기 위해서 멤버 객체의 메소드를 호출하는 행위

 

책임(Responsibility)

-       객체가 설계된 기능적 목적. 객체가 해야 할 일.

 

공개(public) 메소드 = 공용 메소드 ~ API(Application Programming Interface) = 인터페이스(interface)

-       1. 객체의 외부에서 호출이 가능하도록 노출된 메소드

-       2. 객체 외부로부터의 명령을 받는 메소드. 이 특성 때문에 모든 공개 메소드의 명칭은 명령형으로 작성되어야 하고 그렇게 해석되어야 한다.

 

비공개(private) 메소드 = 전용 메소드

-       객체 내부에서만 호출이 가능하도록 감춰진 메소드.


접근성(visibility)

-       공개(public) / 보호(protected) / 비공개(private)과 같이 객체의 내부와 외부 또는 상속 관계에 따라서 멤버 변수나 메소드에 대한 접근 권한을 제어하는 키워드.

 

인터페이스(interface)

-       1. 공개 메소드와 동일한 의미로 사용되는 용어.

-       2. [JAVA] 공개 추상(abstract) 메소드를 가진 객체화 불가능한 상위 타입

 

[JAVA] interface

-       1. 순수하게 공개 추상 메소드로만 이루어진 추상 클래스.

-       2. [JAVA] 공개 추상(abstract) 메소드를 가진 객체화 불가능한 상위 타입

-       (원래 1번의 의미가 가장 강하지만 최근에는 static 필드와 default 메소드(구현이 있는 메소드)가 추가되어 엄밀하게는 1번이 정확한 의미는 아니다. 하지만 클래스와 인터페이스를 비교하는 개념으로써는 1번이 맞다.)

-       3. 인터페이스끼리의 상속(extends)을 통해 확장이 가능하고, 하나의 클래스가 여러 인터페이스를 구현(implements)하는 것이 가능하다. 따라서 C++과 같은 언어의 다중 상속 문제를 회피할 수 있는 대안으로 이해 되기도 한다.

 

추상(abstract) 클래스 = [C++]가상(virtual) 클래스

-       객체화 될 수 없는 클래스.

-       보통은 적어도 한 개 이상의 추상 메소드를 가지고 있는 클래스를 의미한다.

-       ([JAVA] interface는 모든 메소드가 공개 추상 메소드인 추상 클래스이다.)

 

추상(abstract) 메소드 = [C++]가상(virtual) 함수

-       프로토타입(prototype)만 있고 동작을 정의한 구현부가 없는 메소드.

 

[OOP] 프로토타입(prototype)

-       [리턴타입][이름][(매개변수)]

-       형식의 예 : int add(int a, int b);

 

[OOP] 매개 변수(argument) = 인자 = [OOP]파라메터(parameter)

-       메소드에 input으로 들어가는 변수.

-       객체와 primitive 변수 모두 매개 변수가 될 수 있음.

 

타입(Type) *** 중요 ***

-       [OOP] 1. 변수의 자료형(char, short, int, long, struct )

-       2. 객체가 다뤄질 수 있는 형식명. 객체가 XX 타입이라는 것은 객체가 XX 클래스 인터페이스로 참조(reference) 가능하다는 것을 의미한다.

 

참조(reference)

-       객체를 지칭하는 행위. 객체를 참조 변수에 할당하는 행위

 

참조 변수(reference variable)

-       Object aObject; à 여기서 aObject Object 타입의 객체를 지칭하는 참조 변수이다.


[정적] 클래스(Class) = 타입(Type) + 공개 메소드 + 비공개 메소드 + 필드

-       객체에 대한 명세서. 청사진.

-       객체지향 소프트웨어를 사전으로 비유하자면 클래스는 단어에 해당한다. ‘단어는 그 의미를 나타내는 설명을 가지고 있다. ‘설명에 해당하는 것이 클래스를 구현한 코드이다. 그러면 객체는? 단어로 지칭할 수 있는 실제 세계의 존재이다. ‘사과단어’, 즉 클래스이고 실제 사과는 객체이다. 따라서 사과라는 클래스는 한 개 뿐이지만, ‘실제 사과는 여러 개가 될 수 있다.

(예제)

 

-       공개 메소드는 특별히 분리할 필요가 있다. 공개 메소드는 클래스가 객체화 되었을 때 외부와 소통할 수 있는 유일한 통로이다. 객체는 수동적인 식물과 같아서 외부에서 공개 메소드를 통해 자극을 주지 않으면 아무것도 하지 않는 것이 기본이다.(동적(active) 객체는 예외.)

-       클래스가 메소드와 필드로 이루어져 있다는 말은 거짓말이다. 클래스에게 가장 중요한 것은 타입이다. 따라서 클래스는 타입과 메소드(공개 + 비공개), 그리고 필드로 이루어져 있다는 것이 정답이다. 타입은 상속을 통해 다형성과 같은 객체지향의 가장 중요한 개념을 형성해 주기 때문에 클래스가 가지고 있는 것 중에서 가장 중요한 것이다.

 

[동적] 객체(Object) = 인스턴스 = 상위 타입 + 고유 타입 + 공개 메소드 + 아이덴티티(identity)

-       실제 메모리가 할당되어 동작하는 객체지향의 최소 단위.

-       클래스의 동적 형식. 클래스가 개념이라면 객체는 실제다. 사과가 모든 사과를 나타낼 수 있도록 정의된 단어, 즉 클래스라면 내가 들고 있는 진짜사과는 그 객체이다.

-       정적 객체의 동작은 오직 공개 메소드에 의해 일어난다. 따라서 공개 메소드는 명령으로 해석되어야 하고, 공개 메소드의 내부 구현은 공개 메소드 명칭에 맞는 명령이 전달되었을 때 수행되어야 할 일을 구현해야 한다.

-       객체는 객체화가 완료된 후, 소프트웨어의 동작에 따라 자신이 가질 수 있는 모든 타입들로 변경되어 지칭되고 사용될 수 있다. 하지만 잘 설계된 객체지향 소프트웨어 내에서 자신의 고유 타입보다는 상위 타입으로 지칭되어 사용되는 경우가 일반적이다. 특히 상위 타입으로 지칭되다가 다시 그 하위 탕입, 또는 자신의 고유 타입으로 지칭되는 것은 일종의 금기이다. 고유 타입을 사용하면 상위 타입으로 지칭됨으로써 얻을 수 있는 정보 은닉(information hiding)의 장점을 잃어버린다.

 

고유 타입

-       객체가 생성될 당시에 생성의 기반이 된 클래스 타입. new Object()라는 명령을 통해 새로운 객체가 생성되었다면 Object가 고유 타입에 해당한다.

-       고유 타입 개념은 중요하다. 어떤 객체가 어떤 타입으로 참조될 수 있는지를 결정하기 때문이다.

 

아이덴티티(identity) = 고유성

-       어떤 객체를 다른 객체와 구분 짓는 고유한 특성.

-       모든 객체는 고유성을 확인할 수 있는 공개 메소드를 가지고 있어야 한다.([JAVA] equals() 메소드)

 

정적(passive) 객체 = 일반 객체

-       내부에 쓰레드를 선언하고 있지 않은 객체.

-       공개 메소드를 통해 명령하지 않으면 아무 일도 하지 않는 객체.

-       일반적으로 객체라고 하면 보통 정적 객체를 의미한다.

 

동적 객체(active) 객체

-       내부에 쓰레드를 선언하고 있고, 쓰레드의 동작에 기반하여 작동하는 객체.

-       정적 객체의 반대말.

 

추상화(Abstraction)

-     개별적인 대상들의 차이점을 배제하고 동일한 점을 추출해 내는 것. 특히 동일한 점을 모아 클래스 또는 인터페이스화 하는 것.

-    "모델링(Modeling)" 이라는 말과 동일하게 쓰인다. "수학적 모델링"이라고 하면 현실 세계의 문제들의 개별적인 차이점을 배제하고 동일한 특성들을 파악해서 오직 수치와 공식으로 표현 가능한 요소들로 바꾸는 작업이다.

-    따라서 객체지향에서 추상화란 "클래스 모델링" 또는 "객체 모델링"이라는 말로 표현할 수 있다.

-    실세계의 예를 들면 철수, 영희, 희동이는 각자 고유한 특성을 가지고 있지만 이런 특성을 배제하고 나면 이름과 나이, 성별, 사는 곳과 같은 동일한 특성을 가진 '사람'이다. 이렇게 동일한 특성들을 모아서 '사람'이라는 이름의 클래스를 만드는 과정이 추상화(Abstraction) 과정이다.


정보 은닉(information hiding) ****** 매우 중요. 클래스, 객체보다 더 중요 ******

-       정보 은닉은 객체지향 언어가 만들어진 목적에 해당하는 개념이다.(모든 개념 중에서 가장 중요한 개념이다. 믿음 소망 사랑 중 사랑이며, good, better, best 중에 best이다. 정보 은닉만 알면 나머지 개념이 왜 생겨난 것인지를 알 수 있다.)

-       정보 은닉은 객체지향 언어를 설계한 모든 목적을 달성하기 위한 특성이다. “객체화한다는 의미로서의 캡슐화와 상속(그 중에서도 타입의 상속)은 정보 은닉을 가능하게 하기 위한 수단(일 뿐)이다.

-       객체의 고유 타입 은닉

n  객체가 생성된 이후, 고유 타입이 아닌 그 상위 타입으로 지칭되도록 함으로써 생성 이후에는 객체의 고유 타입을 모르도록 하는 것.

n  객체의 고유 타입을 모른 상태에서 구현할 수 있다는 것은 그 고유 타입에 의존하는 코드가 없다는 말이다. 이는 그 고유 타입 객체가 삭제되거나 수정되더라도 코드는 전혀 변경되지 않는다는 것을 의미한다.

n  다형성 = 서로 다른 객체들의 고유 타입을 은닉하고 동일한 상위 타입을 통해 다수의 객체를 동일하게 다루는 것.(객체의 고유 타입 은닉 중 특수 케이스에 해당함)

-       객체의 필드 및 메소드 은닉 = 캡슐화

-       타입 하위 캐스팅 금지 : 상위 타입으로 지칭된 객체를 하위 타입으로 바꿔 지칭하는 것을 금기시 함으로써 온전하게 정보 은닉을 달성할 수 있다.

-       정보 은닉의 장점

n  재사용성 : 객체가 다른 객체의 고유 타입에 의존하지 않도록 함으로써 다른 소프트웨어나 다른 모듈에서도 쉽게 이용할 수 있음.

n  유연성 : 위임 객체의 고유 타입에 의존하지 않게 함으로써 위임 객체를 교체하기 쉽게 만들어 소프트웨어 기능을 교체/확장하기 용이하도록 함.

n  유지보수성 : 객체가 가져야 할 기능들을 각각 고유한 클래스에 구현하도록 함으로써 기능의 수정 시 다른 기능에 영향을 주는 것을 최소화 함. 객체가 가진 최소한의 공개 메소드만을 호출함으로써 설계 변경 시 변경할 코드의 양을 최소화 함.

-       객체지향에서 설계를 통해 좋은 특성을 얻는다고 하는 설명이나 좋은 설계를 대표하는 디자인 패턴, 아키텍쳐 패턴, 프레임워크 구조에서 볼 수 있는 패턴들은 모두 다정보 은닉 개념을 활용한 것들이다.

 

캡슐화(encapsulation)

-       1. 다루고자 하는 변수와 그 변수를 다루는 함수를 묶어 객체로 만드는 행위

-       2. 정보 은닉의 하위 개념 중 하나로써, 객체의 필드를 비공개(private)로 하고, 꼭 필요한 경우에만 메소드를 공개를 설정하는 것.

-       캡슐화의 장점은

n  필드에 대한 임의 접근을 방지하여 의도하지 않은 정보 변경을 막을 수 있다. 필드에 대한 접근을 하는 메소드에 대해서만 관리 하면 데이터의 동기화와 같이 구현이나 테스트가 어려운 특성도 상당히 구현하기 쉬워진다.

n  메소드 공개를 최소화하여 객체간의 연관 관계를 느슨하게 함으로써 잠재적인 변경 사항의 반영을 쉽게 한다.

n  객체의 동작을 이해하는데 필요한 정보를 계층화한다.(즉 더 중요한 공개 메소드를 우선 이해하고 비공개 메소드에 대해서는 그 다음 단계에 이해하는 식이다.)

 

 

상속(inheritance)

-       상위 클래스의 타입과 공개 메소드, 필드를 물려 받는 것.

-       상속에서 가장 중요한 부분은 타입을 물려 받는다는 것이다. 이 부분에 대한 강조가 부족하여 객체지향을 제대로 이해하지 못하는 경우가 너무나 많다. 상속을 통해 타입을 물려 받으면 하위 클래스는 상위 타입으로 지칭될 수 있다. 이것이 정보 은닉의 장점을 확보할 수 있도록 해준다. 공개 메소드와 필드를 물려 받아 중복 코드를 줄일 수 있다는 점이 객체지향의 장점이라면, 정보 은닉(여기서는 하위 타입 은닉)을 통해 재사용성, 유연성, 낮은 결합도, 유지보수성, 단일 책임 등의 특성을 얻을 수 있다는 점은 객체지향 언어가 만들어진 목적에 해당한다.

-       그다지 중요한 부분은 아니지만 어떤 경우에는 메소드의 구현이나 필드를 재 구현하지 않기 위해서 상속을 이용하기도 한다고 한다. “위대하신 타입 상속에 비해 중요도가 많이 떨어지는 정보이므로 참고만 하도록 하자.

 

다형성(polymorphism)

-    하나의 객체가 서로 다른 타입으로 지칭될 수 있음을 이르는 말. 클래스가 상위 클래스를 상속하면 상위 클래스의 타입까지 상속받게 된다. 이 때 상속을 받은 클래스는 상위 클래스 타입임과 동시에 자기 자신 타입이기도 하다. 이렇게 여러 타입(= 모양 = 형)을 가질 수 있는 클래스의 특성이 바로 다형성이다.(C언어의 struct와 비교해 보라. C 언어의 struct는 기존의 struct를 내부에 선언하여 사용할 수는 있어도 기존에 있는 struct 타입으로 사용 될 수는 없다.)

-       서로 다른 객체들의 고유 타입을 은닉하고 동일한 상위 타입을 통해 다수의 객체를 동일하게 다루는 것.(정보 은닉의 한가지)

-       다형성은 오케스트라 지휘로 비유할 수 있다. 각각의 고유 타입을 가진 객체를 오케스트라의 바이올린 연주자, 첼로 연주자, 플롯 연주자 등이라고 하면 상위 타입은 연주자이다. 지휘자 격인 객체는 이들 객체를 다룰 때 바이올린 연주자연주하세요, “첼로 연주자연주하세요와 같이 이야기 하지 않는다. “연주자 여러분 연주 하세요라고 한마디만 하면 끝난다. 이처럼 각각의 객체의 다른 점보다 같은점, 즉 상위 타입(여기서는 연주자)을 통해서 여러 다른 객체들을 동일하게 다루는 것이 다형성이다. 당연히 끊임없이 여러 연주자들를 외치는 것보다 단 한번만 연주자라고 부르는 것이 코드를 줄이고 객체를 대체하는데 유리하다.

-       메소드 재정의(overrinding)도 다형성의 일종이다.

 

메소드 재정의(overriding)

-       1. 추상 메소드의 구현부를 구현하는 것.

-       2. 이미 구현부가 있는 메소드의 구현부를 대체하여 구현하는 것. 보통 2의 의미로 더 많이 쓰인다.

-       3. 재정의된 메소드는 프로토타입은 같지만 동작이 다르다. 따라서 재정의된 메소드를 이용하는 객체는 재정의 되지 않은 메소드와 동일하게 취급할 수 있고, 따라서 다형성을 이용하는 수단이 될 수 있다.

-       가급적 구현부를 대체하는 방식보다는 추상 메소드를 제공하는 편이 코드를 이해하기가 훨씬 수월하다.

 

메소드 오버로딩(method overloading)

-       동일한 명칭에 다른 인자를 받는 메소드들을 여럿 구현하는 것.

-       메소드 오버로딩은 근본적으로 같지 않은 인자에 대한 취급 방법을 동일하게 하는 것이라고 생각할 수 있다.(근본적으로 같은 것이라면 여러 메소드를 만들 필요도 없다.)

 

상위 캐스팅(up casting)

-       하위 타입 객체를 상위 타입 변수로 지칭하는 것.

 

하위 캐스팅(down casting)

-       상위 타입 변수로 지칭되던 객체를 하위 타입 변수로 지칭하는 것. 객체지향 언어에서 하지 말 것으로 정해진 것 중의 하나.

 

Has-a 관계 = association = 연관 = 연관 관계

-       어떤 객체 A가 참조 변수를 가지고 있고, 그 변수를 통해서 다른 객체 B를 멤버 객체로 가지는 관계를 A has-a B 관계라고 한다.

 

Is-a 관계 = generalization(일반화) 또는 인터페이스에 대해서는 realization

-       어떤 객체 A가 다른 객체 B의 상위 타입이고, 다른 객체 B는 객체 A의 하위 타입일 때 B is-a A 관계이다.

Posted by 이세영2
,

기초적인 메소드 이름 만드는 법

일반적으로 메소드 이름을 아래와 같이 만들 것을 많은 곳에서 권하고 있다.

1. 동사 + 명사형 이름으로 만들 것 : getData() / set Data() / calculateData() / computeData


하지만 조금만 더 이해하고 나면 더 좋은 메소드를 만들 수 있다. 아래의 내용을 먼저 이해하고 넘어가도록 하자.


객체는 수동적(passive)이다

객체는 외부에서 메소드를 호출해 주지 않으면 아무 일도 하지 않는다. 객체는 완벽하게 수동적이기 때문이다. 따라서 메소드 명칭은 수동적인 객체에게 무언가를 시키는 형태의 이름이 되어야 한다. 즉 객체에게 명령하는 형식의 이름이어야 한다. 따라서 객체의 메소드 이름 만드는 규칙은 정확하게 말하면 아래와 같다.


메소드 명칭 = 명령형 동사 + 목적어

단순히 동사 + 명사형으로 생각하지 않기 바란다. 예를 들어 getData()라는 메소드 명칭은 "데이터를 가지고 온다."라는 의미로도 해석 할 수 있지만 좀 더 명확하게는 "데이터를 내놔!" 이다. 이렇게 해석하는 것이 좋은 이유는 객체를 구현할 때 좀 더 구현을 정확하게 할 수 있기 때문이다. 객체를 구현할 때 getData() 메소드를 구현하면서 "데이터를 가지고 온다" 라고 해석하고 구현하기 시작하면 구현이 꼬이기 시작한다. 반대로 객체가 "데이터를 내놔!"라는 명령을 받았을 때 해야 할 일을 getData() 메소드에 구현하면 된다고 생각하고 구현을 시작하면 구현이 매우 명료해 진다.


메소드 이름을 해석하는 관점

실제로 잘 되어 있는 오픈 소스들은 객체가 수동적이라는 관점으로 메소드 이름을 작성한다. 따라서 메소드를 해석할 때에도 이와 같은 관점에서 해석해야 한다. 객체가 sendEvent()라는 메소드를 가지고 있을 때 이를 "이벤트를 보낸다"라고 해석하지 말고 "이벤트를 보내라!"라고 명령하는 것으로 해석해야 한다. 유사하게 make(만들어라), add(더해라, 또는 집어 넣어라), remove(제거해라), initialize(초기화해라) 등으로 해석하는 편이 훨씬 소스를 이해하는데 도움이 된다.

Posted by 이세영2
,

구조적 언어가 비 구조적 언어에 비해 발전한 부분을 나열해 보면 다음과 같다.

1. 소프트웨어의 구조적 분할 가능 : 전체가 한 덩어리였던 소프트웨어를 함수 단위로 체계적으로 분할 할 수 있게 되었다.

2. 함수간의 호출 가능 : 함수가 다른 함수를 호출할 수 있게 되면서 전체 소프트웨어의 동작 순서를 일목 요연하게 알 수 있게 되었다.

3. 함수 명명 가능 : 함수에 이름을 지을 수 있게 되면서 현재 하려는 작업의 목적을 분명하게 알 수 있게 되었다.


그러나 아직도 구조적 언어에는 문제가 많이 남아 있었고, 이것이 객체지향 언어의 탄생 배경이 되었다.


구조적 언어의 문제점

1. 변수에 대한 구조적 분할이 어려움 -> 전역 변수 문제 : 전역 변수의 출현을 효율적으로 막지 못했다.

2. 변수와 함수의 연관 관계가 불분명 : 어떤 함수가 어떤 변수를 다루는지 직관적으로 알기 힘들다.

3. 함수 내부에 대한 이해 필요 : 여전히 함수 내부를 들여다 봐야 전체 소프트웨어의 흐름을 이해할 수 있다.


구조적 언어는 많은 발전에도 불구하고 여전히 위와 같은 문제점이 있었다. 위의 문제들 때문에 아래와 같은 의도하지 않은 결과물이 나오곤 했다.

1. 분명히 똑같은 절차로 동작 시킨 것 같은데 결과는 다르게 나오는 소프트웨어 : 전역 변수의 변화를 예상하지 못해서 발생하는 문제.

2. 오류를 수정할 때 다른 함수의 동작에 영향을 미침 : 역시 전역 변수가 가장 큰 문제의 원인이다.

3. 하나의 오류를 수정하기 위해 여러 함수를 고쳐야 함


이러한 문제점의 근본적인 원인은 적절한 정보 은닉이 이루어지지 않았기 때문이다. 객체지향 언어는 이와 같은 문제점을 해결하고자 다양한 문법적인 지원과 설계의 원칙을 제시하고 있다.


우선 객체지향 언어의 아버지라 불리는 앨런 케이의 관점을 먼저 이야기 해보자.


"왜 사람들은 (큰) 컴퓨터를 작은 컴퓨터로 나누려고 하지 않는가?"

비 구조적 언어에서 구조적 언어로의 이행을 촉발한 것은 소프트웨어 규모의 확대였다. 앨런 케이는 이것을 명확히 이해하고 매우 직접적인 방식으로 해결하려고 했다. 즉, 큰 컴퓨터를 작은 컴퓨터로 나누려고 했던 것이다. 앨런 케이에게 있어서 이 작은 컴퓨터가 바로 객체였다.


"객체지향 언어에 있어서 가장 중요한 것은 메시지(메시징)이다."

여기서 메시징이라는 것은 쉽게 바꿔 말하면 상대 객체가 가진 공개(public) 메소드를 호출하는 것을 말한다. 앨런 케이의 말에 따르면 객체에게 있어서 가장 중요한 것이 공개 메소드라는 말이다.


앨런 케이의 관점을 종합해 보면 객체지향 언어는 사람이 큰 컴퓨터가 하는 일을 이해하는 것보다는 작은 컴퓨터(객체)가 하는 일을 이해하는 것이 쉽고, 그 작은 컴퓨터는 다른 컴퓨터의 메시지만 이해하고 있으면 된다는 것이다. 이것은 객체가 자신의 역할을 수행하는데 필요한 최소한의 지식만을 알고 있으면 된다는 말이다.


객체가 알아야 할 것

1. 자기 내부

2. 자기와 협력하는 객체의 외부(메시지 = 공개 함수 = 인터페이스 = API)


나머지 정보들은 모두 은폐 되는 것이 바람직하다. 그럼 이제 은폐되어야 할 정보들을 살펴보자.


캡슐화

우리가 객체지향 언어의 최소 공개 원칙 중에 첫번째로 꼽을 수 있는 부분이다. 캡슐화란 일반적으로 변수와 함수를 묶어 클래스로 선언하고, 클래스에서 외부에 노출할 부분만 선택적으로 노출시키는 것을 말한다. 이를 통해서 구조적 언어가 가지고 있던 전역 변수 문제가 해결되었다. 캡슐화가 감추는 정보를 나열해 보면 다음과 같다.

1. (대부분의) 변수 : 이로써 전역 변수 문제가 거의 해결 되었다.

2. private 함수 : 외부에서 호출할 필요가 없는 함수는 은폐되었다. 이로써 외부 객체는 협력하고자 하는 객체의 공개 함수의 외형만 알고 있으면 된다.


타입화(= 구현 은폐)

이 부분은 놓치기 쉬운데, 모든 객체는 타입(= 클래스)을 가지고 있다. 외부 객체는 해당 객체의 구현 전체에 신경 쓸 것이 아니라 그 객체의 타입만 알면 된다. 즉 타입 이외의 모든 정보는 은폐되어야 한다는 말이다.

1. 인터페이스 중심의 설계(외부적 관점과 내부적 관점의 완벽한 분리 및 은폐) : 인터페이스를 중심으로 설계하라는 원칙은 구현 내부에 대해서는 신경쓰지 말라는 말이다. 객체지향 언어에서는 상대의 인터페이스만 알면 협력이 가능하다.

2. 타입 중심의 설계 : 객체지향 언어는 모든 단위가 클래스(=타입)로 이루어져 있다. 클래스는 단순한 속성과 행위의 집합이 아니다. 클래스가 가진 것 중 가장 중요한 것이 바로 타입이다. 타입은 내부 구현을 감춰준다.

구현의 은폐는 수정에 용이한 코드를 만들어 준다. 예를 들어 기존의 객체가 문제가 있거나 다른 기능을 수행하는 객체를 사용해야 한다고 했을 때, 기존의 코드가 타입에만 의존하고 있었다면 그 코드는 수정하기 용이하다. 실제 동작시에 새로 만들어진 객체를 할당해 주기만 하면 기존 코드는 그대로 사용할 수 있기 때문이다.


타입 은폐

심지어는 타입 조차도 감추는 것이 좋다. 정확하게 말해서 상위 타입이 외부에 노출되어 있다면 하위 타입을 감추는 것이 좋다는 말이다.

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

많은 코드에서 이와 같은 형태로 ArrayList를 사용한다. 그 이유는 ArrayList를 코드에서 사용하는 순간 그 코드는 오직 ArrayList를 위한 코드가 되기 때문이다. 객체지향에서는 구체적인 타입을 밝히는 것을 꺼려 한다. 구체적인 타입을 밝히는 순간 그 코드는 그 구체적인 타입 "만"을 위한 코드가 되기 때문이다. 그러면 다른 구체적인 타입을 위한 코드를 또 만들어야 한다. 바로 중복이 발생한다. 또 구체적인 타입에 대해 작성한 코드를 다른 타입에 대해 작성하기 위해서는 상위 타입으로 작성된 코드에 비해 더 많은 수정이 필요하다. 그래서 가능한 한 하위 타입을 은폐하라는 것이다.


SOLID 원칙 중 LSP (Liskov Substitution Principle)은 타입 하이딩에 대한 대표적인 설계 원칙이다. 하위 타입을 구현할 때 상위 타입으로 치환 가능하도록 만들어야 한다. 그래야 하위 타입을 감출 수 있다. 감춰야 하는 이유는 이미 설명한 바와 같다.


다형성 역시 하위 타입을 감추는 목적 중 하나이다. 다형성은 다양한 하위 타입별로 작성되어야 할 코드들을 하나로 묶어준다. 이는 중복을 제거하고 간결한 코드를 만들어 준다.


Factory Method 패턴 : Factory Method 패턴은 기본적으로 생성할 객체의 구체적인 타입을 감추기 위해 만들어진 패턴이다. Factory Method 패턴에서 리턴되는 객체는 항상 상위 타입이다. 그리고 특정 하위 타입의 구체적인 생성자 호출을 하지 않는다. 따라서 객체의 생성 이후에는 그 객체가 정확히 어떤 타입인지 알 수 없다.


최근에는 반대로 (안전이 보장되는 한) 최대 노출의 원칙을 주장하는 소프트웨어 기술도 있다. 바로 단위 테스트이다. 테스트 가능 설계 중 한가지 방식으로써 충분히 의미 있는 주장이다.


단위 테스트를 위한 최대 노출 원칙(테스트 가능 설계)

- 동작에 영향을 주지 않는다면 모든 정보를 제공하라(getter) : 정보 은닉의 목적은 안전한 구현이다. 테스트 가능 설계를 위해 동작에 아무런 영향을 주지 않는 정보 제공 함수(getter)는 충분히 만들어 놓을 가치가 있다.

- 모든 의존 객체에 대한 의존성 주입 함수를 제공하라 : 다른 객체에 의존성이 있을 경우, 그 객체는 주입(setter) 함수를 꼭 만들어 주자. 그러면 단위 테스트 시에 해당 객체를 다른 객체로 대체함으로써 테스트를 용이하게 할 수 있다.




Posted by 이세영2
,

객체지향은 참 어렵다.

이제 객체지향을 그래도 좀 이해하고 프로그래밍을 한다는 생각을 하고 있지만 객체지향 언어로 개발을 해 오면서도 그 개념을 제대로 이해하지 못하고 지낸 시간이 훨씬 더 길다는 점을 생각해보면 지금의 이해한 것을 다른 사람들에게 잘 설명하는 것이 얼마나 힘든 일일지 상상이 간다.


개인적인 경험으로 보면, 객체지향 언어를 배우고 잘 사용하게 될 때까지의 난관을 보면 대략 다음과 같다. 


처음 : 개념 및 문법에 대한 이해

처음에는 객체지향 언어 문법을 열심히 공부하게 된다. 객체지향 언어의 난감한 점은 우리가 풀어 내려고 하는 문제에 대한 직접적인 구현(변수 선언, 함수 선언, 조건문, 반복문, 연산자 등)을 이해하는데 들어가는 노력에 비해 객체지향 개념(클래스, 객체, 캡슐화, 상속, 다형성?, 재정의, 인터페이스, 추상 클래스 등) 자체를 이해하는 것이 훨씬 더 어렵다는 점이다. 어려운 이유는 간단하다. 객체지향 개념이 필요한 이유를 문법을 공부하는 당시까지는 전혀 알 수 없기 때문이다. 하지만 이 단계를 일단 거치고 나면 이제 마치 객체지향을 제대로 다룰 수 있겠다는 생각이 든다.


난관 : 잘 짜여진 객체지향 코드를 접했을 때

이제 어떻게든 클래스와 객체도 알고, 돌아가는 프로그램을 작성할 줄도 알게 된다. 그래서 개발에도 속도가 좀 붙고, 기능을 이렇게 저렇게 만들어 가게 된다. 그러다가 어느 순간 오픈 소스 혹은 잘 짜여진 라이브러리들을 접하고 충격을 받게 된다. 처음에는 너무 난잡해 보여서 이해가 가질 않는다. 어떤 기능이 어떻게 동작하는지를 보려면 이 클래스, 저 클래스 파일을 넘나 들어야 하고, 굳이 상속을 왜 받아서 하위 클래스를 이해하는데 상위 클래스로 이동해야 하고, 상속을 넘어서 인터페이스로 넘어 가면 구현부가 없는 것을 보고 당황해 해야 한다. 온갖 기교를 써가면서 어떻게든 여러 클래스를 같은 클래스 혹은 인터페이스에 대한 하위 클래스로 만들기 위해 노력하는 이유를 대체 알 수가 없다.


깨닫기 전 : 이제 잘하고 있다고 생각했는데

이제 상속을 통한 기능의 확장에 대해서도 이해하고 나름 중복 코드를 보고 줄일 줄 알게 되고, SRP 쯤은 알아서 기능도 잘 구분해서 구현할 줄도 알게 되었다. 하지만 그래도 누군가 와서 내가 만든 코드를 보고 이렇게 이야기 한다. 객체지향의 핵심은 다형성이다, 인터페이스를 중심으로 설계하라, 상속을 통해 기능을 확장하지 마라 등등.



개인의 능력에 따라서는 이 과정이 생각보다 짧았을 수도 있을 것이다. 하지만 개인적인 경험에 비춰 보자면 앞으로 이야기 할 내용을 미리 알고 있었을 정도가 되었다면 객체지향을 접한지 최소 5년은 되었다고 봐야 한다. 그보다 더 오래되었어도 이해하지 못하는 경우도 있다.(좋은 개발 선배가 있었다면 모르지만.) 혹시 아래 내용을 보고 "겨우 그거 이야기 하려고 이런거야?" 라고 하신다면 내 능력이 부족한 것이니 할 말은 없다. 아까운 시간을 빼앗아 죄송스럽다는 말을 할 수 밖에.



이제 본론으로 넘어가서 그러면 과연 상속이란 무엇인가? 우선 위키를 찾아보면 다음과 같다.


1. 객체 지향 프로그래밍(OOP)에서, 상속은 객체들 간의 관계를 구축하는 방법이다. 

2. ......클래스는 기존의 클래스로부터 속성과 동작을 상속받을 수 있다.

3. 그 결과로 생기는 클래스를 파생 클래스, 서브클래스, 또는 자식 클래스라고 한다. 상속을 통한 클래스들의 관계는 계층을 형성한다.


사실 이 내용 중에는 이후에 이야기 할 내용에 딱 맞는 내용이 없다. 하지만 살펴볼 가치는 있다.


우선 2번이 적절한 정의로서 확 와 닿을 것이다. 일반적으로 상속이라는 의미는 단어가 가진 의미와 마찬가지로 무엇인가를 "물려 받는다"는 의미이기 때문이다. 즉, 상위 클래스가 가진 속성(변수)과 행위(멤버 함수)를 내려 받는 것을 상속이라고 보통 말한다. 이것이 이해하기 쉬운 이유는 상속이라는 단어의 실생활 속에서의 의미와 가장 유사한 개념이고, 프로그래밍을 할 때 변수와 함수를 물려 받아 재 구현해야 하는 번거로움을 없애준다는 점에서 실질적인 이득을 주기 때문이다.


아주 마음에 들지는 않지만 1번과 3번이 그나마 좀 더 객체지향을 이해하는데 도움이 되는 정의이긴 하다.


자 그러면 저 정의들이 명시적으로 보여주지 못한 가장 중요한 부분은 무엇일까?


잘 이해가 안 갈 수도 있기 때문에 질문을 살짝 바꿔서, 상속을 받았을 때, 상속 받은 것 중에서 가장 중요한 것은 무엇인가?











정답은 "타입"이다.



사실 2번의 정의는 핵심을 빠뜨렸다. 클래스는 기존 클래스로부터 가장 중요한 것인 "타입"을 물려 받고, 속성이나 동작 "따위"의 소소한 것을 물려 받는다가 정확한 정의다.


객체지향에서 객체를 바라 볼 때 가장 중요한 것은 "외부적 관점"과 "내부적 관점"의 분리이다. 익히 알고 있는 속성과 행위가 아니다. 내부적 관점이라는 것은 객체의 동작을 어떻게 구현할 것인가 하는 점이다. 이 때 보이는 것이 속성과 행위이다. 이것을 잘 구현해야 하는 것은 당연한 것이다. 하지만 이것보다 중요한 것이 외부적 관점이다. 외부적 관점은 "그 객체를 어떤 객체로 인식할 것이며, 어떻게 다룰 것인가?" 이다.


만약 어떤 객체가 실제로는 Rectangle이라고 해도 (Shape을 상속 받았다 치고) 외부에서 인식하기를 Shape이라면 그것은 외부적 관점에서는 Shape이다. 그 실체가 Rectangle이라는 사실은 그 객체 내부에서나 중요한 것이지 밖에서 그 객체를 사용하는 입장에서는 아무런 의미가 없는 것이다. 그 객체는 그냥 Shape일 뿐이다. 오케스트라의 지휘자는 자신의 연주자들이 자신의 지휘에 맞춰 연주할 수 있느냐에 관심이 있지 그 사람이 바이올린 연주자인지 첼로 연주자인지는 관심을 가질 필요가 없다. 그저 지휘만 하면 알아서 연주를 할 것이고, 그것이면 충분하다.


그렇다면 외부적 관점, 즉 "타입"이 왜 그렇게 중요한지 간단한 예제를 통해 살펴보자.


List<Rectangle> rectangles = new ArrayList<Rectangle>();

rectangles.add(new Rectangle(1,2,3,4));

rectangles.forEach(each -> System.out.println(each));

List<Color> colors = new ArrayList<Color>();

colors.add(new Color(255,0,100));

colors.forEach(each -> System.out.println(each));


크게 이해하는데 어려움은 없을 것으로 생각된다. 두 개의 List를 만들어서 각각 Rectangle과 Color를 저장할 수 있도록 하고, 객체 하나씩을 만들어 넣은 후 이를 for 문을 이용해서 출력하는 구문이다. 반복되는 느낌은 있지만 어찌하겠는가? Rectangle과 Color는 엄연히 "타입"이 다른데!


그러면 다음을 보자.


List<Object> objects = new ArrayList<Object>();

objects.add(new Rectangle(1,2,3,4));

objects.add(new Color(255,0,100));

objects.forEach(each -> System.out.println(each));


예상했겠지만 둘의 출력 결과는 같다. 이것이 왜 가능한지도 이미 알고 있을 것이다. Java의 모든 객체는 Object 클래스를 상속 받았기 때문이다. 즉 Object "타입"이다. 코드 상에서, 즉 객체의 외부에서 (실제 객체는 서로 다르더라도) Object 타입으로 불려지게 되는 순간, 둘의 차이는 사라진다. 그리고 그와 함께 중복된 코드들도 함께 사라진다. 만약 서로 다른 종류의 객체가 훨씬 더 많았더라면 더욱 많은 코드들이 마법처럼 사라졌을 것이다. 마치 오케스트라 지휘자가 바이올린 연주하세요, 첼로 연주하세요, 플룻 연주하세요......와 같이 끝나지 않을 것 같은 XXX 연주하세요를 반복하지 않는 것과 같다. 부조리함이 많은 현실 세계에서도 이런 합리성을 추구하는데 매일 논리와 싸우는 우리 같은 사람들이야 어떠하겠는가?


간단한 예제였지만 상속의 진정한 가치와 의미를 이해하는데는 어려움이 없었을 것으로 생각된다. 상속 개념에는 분명 속성과 행위를 내려 받아 중복된 코드를 작성하는 것을 방지해 준다는 이점도 있다. 하지만 상속이 진정한 위력을 발휘 하는 것은 이러한 내부적 관점이 아니라 "타입" 상속이라는 외부적 관점이다. 이 관점이 위력적인 이유는 아무리 실질적으로는 다른 객체들이라도 동일하게 취급할 수 있고, 동일하게 취급되는 순간 유사하긴 하지만 반복적으로 구현되었던 수많은 중복 코드들을 사라지게 만들 수 있다는 점이다. 이러한 관점에서 보면 속성이나 행위 따위를 상속 받는 것은 매우 소소한 것으로 취급될 수 밖에 없다.


그래서 한 번 쯤 더 생각해 볼만한 부분이 바로 인터페이스와 추상 클래스의 개념이다. 인터페이스는 속성과 실제 구현부라는 실체는 눈 씻고 찾아봐도 없고 눈으로 보기에는 빈 껍데기에 불과한 API만을 제공한다. 하지만 이 빈껍데기는 "외부적 관점"을 제공해 주고, 이 관점을 이용하는 외부에서 그 객체를 다루는데는 전혀 어려움이 없다. 따라서 같은 인터페이스를 상속 받은 객체들에 대해서는 같은 관점을 유지할 수 있고, 같은 관점을 유지할 수 있다는 것은 동일하게 취급할 수 있다는 것이고, 이들 객체에 대해서 별도의 코드들을 작성할 필요가 없다는 것이다. 추상 클래스의 경우 인터페이스에 비해 조금 더 많은 것을 주긴 하지만 물려 받은 것에 비해 역시 더 중요한 것은 추상 클래스의 "타입"이다. 이 "타입" 이라는 외부적인 관점을 이용해서 인터페이스와 같은 위력을 발휘할 수 있다. 덤으로 내부적 관점에서의 중복 코드도 줄일 수 있다는 장점과 함께.


타입이 같다는 것이 주는 또 하나의 장점은 적절한 시기에 기존의 객체 대신 실질적으로는 다른 객체로 대체시킬 수 있다는 점이다. 이것은 마치 컴퓨터라는 하드웨어에 내부 소프트웨어만 바꿔 실행하면 여러 기능을 하는 범용 컴퓨터와 유사한 개념이다. 이것은 구조적으로 동일하게 구현된 코드 상에서 서로 다른 로직을 구현한 객체들을 바꾸어 가면서 사용함으로써 소스 코드의 양을 줄이면서도 다양한 기능을 수행하게 만들 수 있다는 말이다. 또한 기능의 확장을 위해서 다른 코드를 수정하지 않고 동일한 "타입"의 새로운 객체 하나만 만들어 넣으면 된다. 이것은 수정이나 기능의 확장을 위해서도 도움이 된다는 말이다. 


만약 상속을 통해 외부적 관점의 통일성, 즉 "타입"을 상속 받지 못하고 단순히 속성과 기능만을 상속할 수 있는 것이었다면 이러한 이점은 생각지도 못할 일이다. 아무리 속성과 기능을 동일한 클래스로부터 상속 받았다 해도 하위 클래스들은 서로 엄연히 다른 관점으로 봐야 했을 것이고, 그렇다면 어떤 방법으로도 이들을 다루는 코드들을 통합 시킬 수 없었을 것이다. 그리고 기존의 객체를 새로운 객체로 간단히 교체한다는 것은 상상하기도 힘들었을 것이다. 상속은, 특히 "타입"의 상속은 이처럼 위대한 개념이다.


혹시 이러한 개념을 잘 생각하지 않고 있었던 분들이라면 이 타입 상속이라는 개념을 염두해 두고 어떻게 하면 동일한 외부 관점을 유지시킬 수 있을 것인지 항상 생각하면서 프로그래밍을 했으면 좋겠다. 그것이 개발자로서의 수명(신체적인 수명까지도;)을 길게 가져가는 길이기도 하니까 말이다.


Posted by 이세영2
,