우리가 어떻게 해서 유연성을 확보할 수 있었는가?
그것은 추상화(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 패턴과 같은 경우가 바로 그것이다. 이들 생성 패턴들은 생성과 동시에 구체적인 타입 은닉을 수행하도록 되어 있다.
아래는 각 생성 패턴들이 공통으로 추구하는 방향만을 간략하게 구현해 본 것이다.
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), 각종 객체지향 설계에 관련된 격언들은 거의 모조리 정보 은닉에 관련된 것들이다. 또한 객체지향이 만들어낸 여러 개념들과 언어적 특성들 중 대부분은 정보 은닉을 위해 만들어진 것들이다.
(예를 들어 상속을 보통 메소드와 변수를 재사용하는 것이라고 이야기하는데 이는 틀린 말이다. 상속을 통해 받을 수 있는 것 중에서 가장 중요한 것은 타입이다. 이 타입을 내려 받을 수 있기 때문에 하위 객체가 상위 클래스로 지칭 될 수 있다. 이것이 정보 은닉을 가져오고 객체지향의 모든 이점들을 가져 온다.)
'3.객체지향(OOP) 개념' 카테고리의 다른 글
객체지향의 올바른 이해 : 1. 객체지향 언어의 대두 (0) | 2016.11.01 |
---|---|
Tell, don't ask 원칙(TDA 원칙) (3) | 2016.10.01 |
객체지향 용어 정리 (0) | 2016.09.23 |
좋은 메소드 이름 만드는 법 (0) | 2016.09.21 |
최소 공개의 원칙 (4) | 2016.08.15 |