주요글: 도커 시작하기

OOP 소개 자료 입니다.


2012년 11월 10일 자료: 객체, 캡슐화, 추상화

OOP 소개 20121110_2page.pdf



2012년 10월 13일 자료: 객체, 캡슐화, 추상화

OOP 소개 20121013_2page.pdf



2012년 9월 22일 자료: 객체, 캡슐화, 추상화

OOP 소개 20120922.pdf


  1. 홍구 2012.10.04 21:28

    감사히 봅늬다

거의 모든 객체 지향 관련 서적에서 설명하는 기본 객체 지향 설계 원칙이 있는데, 객체 지향 기초의 마지막 이야기로 이들 원칙들을 살펴보도록 하자.


이전글


단일 책임 원칙(SRP)


첫 번째 원칙은 '단일 책임 원칙(SRP; Single Responsibility Principle)'이다. 이는 객체는 단 한 개의 책임(역할)만을 가져야 한다는 내용으로, 객체를 변경해야 하는 이유는 단 하나여야 한다는 원칙이다. 예를 들어, Book 클래스가 JSON으로의 변환을 처리한다고 해 보자. 이 경우 클래스는 다음과 같이 정의될 것이다.


public class Book {

    public int calculatePrice() { ... }

    public String toJson() { ... }

}


만약 Book를 XML이나 HTML로도 표현해야 한다면? 이런 식으로 변환해야 할 대상이 늘어날수록 Book 클래스의 함께 변하게 된다. 이 외에도 JSON이나 XML의 구조가 변경되더라도 Book 클래스의 코드가 변경된다. Book 클래스의 코드가 표현 방식의 추가나 변경에 따라서 함께 바뀌는 증상이 발생하는데, 그 이유는 Book 클래스가 많은 책임을 지고 있기 때문이다.


책임을 많이 질수록 클래스 내부에서 서로 다른 역할을 위한 코드들 간에 강하게 결합될 가능성이 높아진다. 예를 들어, JSON으로 변환하는 코드와 HTML로 변환하는 코드와 계산을 위한 코드 중 어딘가가 연결되어 있을 수 있고, 이런 경우 계산 공식의 변화가 JSON 변환 코드에 변화를 불러 일으킬 수도 있다.


따라서, 한 객체는 하나의 책임만 갖도록 설계해야 한다. 예를 들어, 앞서 Book 클래스의 경우 책 자체의 정보를 처리하는 Book 클래스와 JSON이나 XML로 표현을 변경해주는 JsonConverter, XMLConverter 클래스로 분리해주어야 한다.


이렇게 한 객체가 하나의 책임만을 갖도록 함으로써 각 객체를 변경시키는 이유는 한 가지로 좁혀진다. 책과 관련된 계산 변경이 필요하면 Book만 변경되며, JSON이나 XML 형식이 변경되면 JsonConverter 클래스와 XMLConverter 클래스에만 변화가 생긴다. (물론, Book의 정보 자체에 변화가 생기면 그 변화는 두 Converter에 영향을 준다.)


의존 역전 원칙(DIP)


의존 역전 원칙(Dependency Inversion Principle)은 구현 클래스가 아닌 인터페이스 타입을 사용하라는 규칙이다. 이는 이미 앞서 '객체 지향 기초 3, 유연함 http://javacan.tistory.com/entry/OO-Basic-3-Flexibility'에서 말했던 내용과 동일하다. 구현 클래스는 자주 변경될 가능성이 높기 때문에, 변화 가능성이 상대적으로 적은 추상 타입이나 메서드에 의존하면 변화에 따른 영향을 최소화할 수 있다.



예를 들어, 위 그림에서 보듯이 SomeClient 클래스는 구현 클래스인 FtpLogCollector를 사용(의존)하는 것 보다, 추상 타입인 LogCollector를 사용하는 것이 변화에 영향을 덜 받게 된다. 이 경우 FtpLogCollector 자체의 구현이 변경되거나 심지어 DBLogCollector로 구현 클래스가 바뀌더라도 SomeClient는 거의 영향을 받지 않게 된다.


개방 폐쇄 원칙(OCP)


개방 폐쇄 원칙(Open-Closed Principle)은 특정 클래스(또는 모듈 등)는 그 클래스를 수정하지 않으면서 확장(extension) 가능해야 한다라는 원칙이다. 객체 지향에서 OCP는 크게 두 가지 방법으로 구현할 수 있다.

  • 상속을 통한 구현
  • 조립을 통한 구현

상속을 통한 구현은 스프링 2 버전의 MVC 컨트롤러 구성에서 확인할 수 있다. 아래 그림은 스프링 MVC의 Controller 타입의 계층 구조이다. 이 구조를 보면 AbstractController 클래스를 수정하지 않고, 상속을 통해 기능을 확장한 것을 알 수 있다. 예를 들어, 파라미터를 커맨드 객체로 받기 위해 AbstractController 클래스를 수정하지 않고 이 클래스를 상속받은 BaseCommandController 클래스를 만들었다. 즉, 변경을 하지 않으면서(Closed for modification) 확장에는 열려(open for extension)있는 것이다. 추가로 아래 클래스들은 각각 한 가지 역할만을 갖도록 설계되어 있다. 상속을 통해 기능을 확장하는 경우에도 SRP를 잘 지키고 있다.



OCP를 실현하는 두 번째 방법은 조립(composition)이다. 조립에 대한 내용은 '객체 지향 기초 4, 상속 보단 조립 http://javacan.tistory.com/entry/OO-Basic-4-Composition-Over-Inheritance)에서 살펴봤었는데, 조립과 DIP 그리고 상속이 함께 맞물리는 방법으로 OCP를 구현하게 된다. 아래 그림은 의존 역전 원칙을 설명할 때 사용한 클래스 다이어그램인데, 이 구조를 OCP에도 그대로 사용할 수 있다.



위 그림에서 SomeClient는 로그를 수집하는 기능을 조립하는 방식으로 구현하고 있다. SomeClient를 사용하는 또 다른 코드는 SomeClient가 로그 수집을 직접 수행하는지 조립을 통해 수행하는지는 모를 것이다. SomeClient는 조립을 통해 로그 수집 기능을 구현하고 있기 때문에, 로그 수집 방법을 개선할 필요가 있을 때 SomeClient는 수정하지 않으면 기능을 확장할 수 있다.


참고자료


제가 쓴 객체 지향 입문서입니다.


http://www.aladin.co.kr/shop/wproduct.aspx?ISBN=8969090010  에서 확인하실 수 있습니다.

기능의 재활용이나 기존 기능을 확장하기 위해서 사용하는 가장 손쉬운 방법은 구현 상속을 사용하는 것이다. 수화물 목록을 관리하기 위해 ArrayList를 상속받아서 구현했다고 하자.


public class LuggageCompartment extends ArrayList<Luggage> {

    private int restSpce;

    public void add(Luggage piece) {

        this.restSpace -= piece.getSize();

        super.add(piece);

    }


    public void canContain(Luggage piece) {

        return this.restSpace > piece.size();

    }


    public void extract(Luggage piece) {

        this.restSpace += piece.getSize();

        super.remove(piece);

    }


}


LuggageCompartment는 ArrayList가 제공하는 목록 관리 기능을 재사용하기 위해 ArrayList를 상속받았고, 내부적으로 짐을 넣을 수 있는 여분 공간을 관리하는 기능을 추가하였다. LuggageCompartment 클래스를 정해진 대로만 사용하면 문제가 발생하지 않는다. 하지만, LuggageCompartment는 상위 클래스인 ArrayList의 기능도 함께 제공하기 때문에 다음과 같은 코드를 작성하는 것이 가능하다.


LuggageCompoartment lc = new LuggageCompartment();

lc.add(new Luggage(10));

lc.remove(someLuggage); // 앗!! restSpace가 계산되지 않는다!

lc.extract(anyLuggage);

lc.canContain(aLuggage); // 잘못된 결과


위 코드에서 remove() 메서드는 ArrayList가 제공하는 메서드이므로 LuggageCompartment 객체에서도 호출할 수 있다. 문제는 remove() 메서드를 호출할 경우 LuggageCompartment가 지켜야 하는 규칙(여분 공백 계산)이 지켜지지 않는다는 점이다. 그렇다고 여분 공백 계산을 위해서 ArrayList의 거의 모든 메서드를 오버라이딩하는 것은 배보다 배꼽이 더 큰 상황을 만든다.


상속은 'IS A'에 대한 것


그럼, 왜 이런 문제가 발생하는 걸까? 그 이유는 역할이 같지 않은 클래스를 상속받아 재사용했기 때문이다. 상속은 'IS A' 관계일 때 의미를 갖는다. 예를 들어, ArrayList는 AbstractList이다. (ArrayList is a AbstractList). 따라서, ArrayList가 오버라이딩 하지 않은 AbstractList의 메서드를 호출하더라도 ArrayList 객체는 아무런 문제를 일으키지 않으며, ArrayList는 기대하는 바대로 동작한다.


반면 앞서 LuggageComponent는 ArrayList가 아니다. 즉, LuggageComponent is not a ArrayList 인 것이다. 이런 상황에서 ArrayList의 목록 관리 기능을 재사용하기 위해 ArrayList를 상속받아 구현하면 앞서 살펴본 것 처럼, 상위 클래스의 메서드를 호출할 때 하위 클래스의 기능이 올바르게 동작하지 않는 문제가 발생하게 된다. 즉, 'IS A' 관계가 아닌 두 클래스를 구현 상속으로 연결함으로써 원하지 않은 문제가 발생한 것이다.


따라서, 구현 상속을 이용해서 기능을 재사용하려면 반드시 두 클래스가 같은 역할을 수행하는지 확인해야 한다. 두 클래스가 생성하는 두 객체가 서로 다른 추상 타입을 위한 것이라면 기능 재사용의 방법으로 상속을 선택하면 안 된다.


조립을 통한 기능 재사용


두 클래스가 IS A 관계가 아니라면, 그 두 클래스는 서로 다른 역할을 수행한다는 의미를 갖는다. 앞서 LuggageCompart는 ArrayList처럼 범용적인 객체의 목록을 관리하는 역할을 수행하지 않는다. 단지, 짐의 목록을 관리하기 위한 목적으로 ArrayList의 기능이 필요했던 것이다. 이렇게 역할이 다른 객체의 기능을 재사용하고 싶다면, 조립(composition)을 통해서 재사용하는 것이 좋다.


조립을 통한 재사용은 다음과 같이 필드로 재사용할 객체를 정의하고 메서드 내부에서 해당 필드를 사용하는 식으로 구현된다.


public class LuggageCompartment {


    private List<Luggage> luggages = new ArrayList<Luggage>();

    private int restSpce;


    public void add(Luggage piece) {

        restSpace -= piece.getSize();

        luggages.add(piece);

    }


    public void canContain(Luggage piece) {

        return this.restSpace > piece.size();

    }


    public void extract(Luggage piece) {

        restSpace += piece.getSize();

        luggage.remove(piece);

    }


}


조립 방식으로 구현한 LuggageCompartment 클래스는 ArrayList를 상속받지 않기 때문에 LuggageCompoartment를 사용하는 코드는 remove()와 같은 메서드를 호출할 수 없다. 따라서 LuggageCompartment 클래스는 구현 상속의 경우처럼 원하지 않는 메서드가  호출되어 기능이 비정상적으로 동작하는 걱정을 하지 않아도 된다. 또한, LuggageCompartment가 목록을 관리하기 위해 ArrayList를 사용한다는 점을 클래스 외부에서 알 수 없기 때문에 캡슐화도 향상된다.


조립 방식의 또 다른 장점은 런타임에 재사용할 기능을 교체할 수 있다는 점이다. 상속은 컴파일타임에 정적으로 관계가 결정된다. 만약 ArrayList가 아닌 LinkedList를 상속받으려면 코드를 수정해서 재컴파일해야 한다. 반면 조립방식의 경우는 런타임에 재사용할 객체를 결정할 수 있다. 예를 들어, 앞서 LuggageCompartment가 생성자를 통해서 기능을 재사용할 List 구현 객체를 전달받는다고 해 보자. 이 방식은 런타임에 얼마든지 구현 객체를 변경할 수 있기 때문에, 소스 코드 수정이 필요한 정적 방식보다 더 유연하게 재사용할 객체를 변경할 수 있게 된다. ('객제 지향 기초 이야기 3, 유연함'http://javacan.tistory.com/entry/OO-Basic-3-Flexibility 을 참고하자.)


구현 상속은 정말 필요할 때 만 쓸 것!


구현 상속은 유지보수를 어렵게 만드는 요인이 된다. 예를 들어, 스프링의 AbstractController를 생각해보자. AbstractWizardFormController, SimpleFormController, MultiActionController 등이 이 클래스의 구현을 상속받고 있다. 이는 AbstractController의 기능을 수정하려면 그 하위 클래스들이 받을 영향에 대해서 고려해야 한다는 것을 뜻한다. 즉, 상속 받은 클래스가 많으면 많을수록 코드 변경시 고려해야 하는 클래스들의 숫자는 비례해서 증가하며, 이는 변경의 어려움이 증가한다는 것을 뜻한다. 따라서, 구현 상속을 통해 기능을 재사용하려거든 정말 필요한 경우에 한해서 적용해야 하며, 조립이나 메타 정보 사용 등의 방법으로 풀 수 있다면 다른 방법을 사용하는 것이 코드 유지보수에 있어 유리하다.


관련글

참고자료


제가 쓴 객체 지향 입문서입니다.


http://www.aladin.co.kr/shop/wproduct.aspx?ISBN=8969090010  에서 확인하실 수 있습니다.




객체 지향을 하는 이유는? 앞서 '객체 지향 기초 이야기 1, 객체(http://javacan.tistory.com/entry/OO-Basic-1-Object)' 글에서 유지보수 비용을 절감하기 위해 객체 지향적으로 개발한다고 했던 것을 기억할 것이다. 유지보수 비용을 줄일 수 있다는 것은 기능을 쉽게 추가하거나 변경할 수 있다는 것을 의미하는데, 이는 다음의 두 가지를 통해서 얻을 수 있다.
  • 캡슐화: 내부의 구현이 변경되더라도 외부에 영향을 최소화
  • 다형성: 구현체 변경의 유현함을 제공
이번에 살펴볼 내용은 위 두 가지 중 다형성이 주는 유연함에 대한 것이다.

유연하지 않은 코드


유연한 코드에 대해 살펴보기 전에 먼저 유연하지 않은 코드를 살펴보자. 앞서 예제들에서 사용했던 로그 수집을 생각해보자. 최초에 로그 처리는 FTP로부터 파일을 읽어오는 요구만 있었다. 그래서 로그 처리 과정을 제어하는 객체는 FTP로부터 로그를 다운받아오는 FtpLogCollector를 사용해서 구현하였다.


// 흐름 제어

FtpLogCollector logCollector = new FtpLogCollector(....);

FtpLogSet logSet = logCollector.collect();

....


그런데, 요구 사항이 갑자기 바뀌었다. FTP가 아닌 DB에 쌓여 있는 데이터를 로그로 사용해야 한다는 것이었다. 이제 해야 하는 일은? DB에서 로그를 읽어오는 객체를 구현하고 FtpLogCollector 클래스가 출현하는 부분을 모두 DbLogCollector로 바꿔주는 것이다.


DbLogCollector logCollector = new DbLogCollector(....);

DbLogSet logSet = logCollector.collect();

....


로그의 수집 방식이 바뀌면, 로구를 수집하는 구현 클래스가 바뀌는 것 뿐만 아니라 수집 기능을 사용하는 코드도 바뀌게 된다. 즉, 하나의 변화가 다른 코드의 변화를 유발시키는 것이다. 이런 걸 당해본 개발자들은 알겠지만, 이런 종류의 일은 어딜 변경해야 하는지 코드를 검색해야 하고, 노동 집약적인 방식으로 일을 하도록 만든다.


이런 일이 벌어지는 이유는 추상화된 타입이 아닌 실제 구현체를 이용해서 코드를 만들었기 때문이다.


구현체(implementation)가 아닌 인터페이스(interface)에 대고 프로그래밍하기


객체 지향의 기본 규칙 중에 '구현체가 아닌 인터페이스에 대고 프로그래밍'하라는 규칙이 있다. 이 규칙이 나온 이유는 바로 유연함 때문이다. 앞서 봤듯이 추상화된 인터페이스 타입이 아닌 실제 구현을 제공하는 타입을 이용해서 프로그래밍 할 경우, 구현에 변화가 생겼을 때 변화의 영향력이 이곳 저곳으로 퍼져 나가게 된다.


변화가 발생하는 부분이 있다면, 이 부분을 추상화해서 인터페이스 타입을 이용해서 프로그래밍 해 주어야 한다. 인터페이스 타입을 사용해서 구현하게 되면, 실제 구현 객체가 변경되더라도 인터페이스 타입을 이용해서 구현한 코드는 영향을 받지 않게 된다. 예를 들어, 아래는 FtpLogCollector 대신 로그 수집을 추상화한 LogCollector 인터페이스에 대고 프로그래밍을 한 코드이다.


LogCollector logCollector = ... // LogCollector 구현 객체를 어디선가 구함

LogSet logSet = logCollector.collect();

...


LogCollector의 실제 구현 객체를 구하는 부분은 일단 잊어두고 나머지 코드를 살펴보자. 로그 수집하는 방식이 FTP에서 DB로 바뀌더라도 위 코드는 바뀌지 않는다. DB 뿐만 아니라 어떤 방식으로 바뀌더라도, 심지어 FTP와 DB에서 모두 로그를 읽어오도록 구현하더라도 위 코드는 바뀌지 않는다. 즉, 인터페이스에 대고 프로그래밍함으로써 실제 사용되는 구현 객체를 쉽게 변경할 수 있는 유연함을 얻게 된 것이다.


위 코드에서 바뀌는 부분은 실제 LogCollector 구현 객체를 생성하는 부분이다. 실제 사용할 구현 객체를 구하는  부분은 Factory 패턴을 사용하거나 DI(Dependecy Injection)을 통해서 코드 변경을 최소화할 수 있는데, 이 두 가지에 대한 내용은 본 글의 범위를 벗어나므로 패턴 관련 서적 등을 참고하기 바란다.


하지만, 유연함을 얻는 과정은 간단하지 않다. 먼저 실제 구현 객체를 추상화한 인터페이스를 도출해내야 한다. 처음 객체 지향을 접하는 사람들이 어려워 하는 것 중의 하나가 객체를 알맞게 추상화한 인터페이스를 도출하는 것이기 때문에, 유연한 코드를 얻어 내려면 기능(객체)을 추상화하는 연습을 많이 해 주어야 한다.


인터페이스를 도출하기 위한 기본적인 방법은 구현 클래스의 public 메서드를 인터페이스로 변경해보는 것이다. public 메서드는 외부에 기능을 제공할 목적으로 만들어지는 경우가 많기 때문에 인터페이스 후보가 될 수 있다. 내부의 private 메서드 중에서도 인터페이스 후보가 나올 수 있다. private 메서드는 클래스 내부에서 사용되는 기능을 분리한 경우가 많은데, 이 경우 private 메서드를 별도의 인터페이스로 분리함으로써 해당 기능에 대한 유연함을 얻을 수 있게 된다.


인터페이스를 항상 만들어야 하나?


당장 발생하지도 않을 변화를 준비하느라 미리 앞서 인터페이스를 사용할 필요는 없다. 실제로 모든 구현 타입에 대해 인터페이스 타입을 만드는 것은 매우 성가시면서도 불필요한 일이 될 수 있다. 하지만, 그럼에도 불구하고 필자는 인터페이스를 최대한 도출해내려고 노력하는데, 그 이유 중 하나는 단위 테스트에 필요한 Mock 테스트를 할 수 있도록 하기 위함이다.


구현을 하다보면 다른 기능이 구현되지 않아서 현재 작업중인 코드를 테스트 할 수 없는 경우가 발생하는데, 이 경우 Mock 객체를 사용하면 다른 기능의 구현이 되어 있지 않더라도 작업중인 코드를 어느 정도 테스트 할 수 있게 된다. 다른 기능이 만약 인터페이스로 추상화되어 있다면 여러 Mock 라이브러리를 사용해서 다른 기능을 가짜로 제공할 수 있게 되며, 이는 다른 기능의 구현이 완성되어 있지 않더라도 현재 기능을 테스트 할 수 있다는 것을 의미한다.


관련글

참고자료


제가 쓴 객체 지향 입문서입니다.


http://www.aladin.co.kr/shop/wproduct.aspx?ISBN=8969090010  에서 확인하실 수 있습니다.


  1. 권남 2012.05.29 12:28

    잘 읽고 있습니다.
    참 쉽게 설명하시네요.

한 번 만들어진 소프트웨어는 얼마나 사용될까? 윈도우 XP는 10년 넘게 사용되고 있고, 포털 사이트의 카페 서비스 역시 10년 넘게 운영을 하고 있다. 여기서 알 수 있는 사실은, 한 번 쓰고 버릴 소프트웨어를 제외한 나머지 다수의 소프트웨어는 개발 기간보다 더 오랜 시간 동안 사용된다는 점이다. 소프트웨어는 살아 있는 생물과도 같아서 지속적으로 변화를 하게 되며, 이는 곧 소프트웨어를 유지하기 위해서는 계속해서 비용이 발생한다는 것을 뜻한다. 소프트웨어를 유지하기 위해 비용이 계속해서 발생하는데, 소프트웨어를 개발하는 시간보다 소프트웨어를 사용하는 시간이 더 길다는 것은, 다시 말해서 소프트웨어를 개발하는 비용보다 소프트웨어를 유지하는 비용이 더 많이 발생한다는 사실을 말해준다.


초기 개발 비용 <<<<<<<< 유지보수 개발 비용


비용을 줄이려면 어떻게 코드를 만들어야 할까? 여러 가지 답이 있겠지만 필자는 다음의 두 가지라고 생각한다.

  • 코드 분석이 용이
  • 코드 변경이 용이
당연한 얘기지만, 코드를 쉽게 읽을 수 있고 변경을 쉽게 할 수 있어야 유지보수 비용이 줄어든다. 근데 왜 객체 이야기를 하는데 비용 얘기를 하느냐? 그건 바로 객체 지향적으로 개발을 하는 것이 위 두 가지를 달성할 수 있는 방법이기 때문이다. 설계를 잘 하느니 객체를 잘 뽑아내느니 등의 말은 겉치레에 불과하다. 필자가 객체 지향을 좋아하는 이유는 뭔가 있어 보이는 것도 있지만(^^;) 그것보다는 전체적으로 유지보수 비용을 감소시켜주기 때문이다. 객체 지향이 비용을 감소시켜 주지 못한다면 객체 지향은 쓸모가 없는 것이나 다름없다.

그렇다면, 왜 객체 지향을 하면 유지보수 비용이 줄어들게 될까? 이는 절차 지향과 객체 지향을 비교해보면 좀 더 명확해질 것 같다.


절차 지향과 객체 지향


절차 지향의 핵심은 데이터를 중심으로 구현을 한다는 데 있다. 각각의 기능을 수행하는 프로시저(함수)들이 데이터를 조작하거나 사용하는 방식으로 구현을 한다.



다양한 프로시저들이 데이터를 공유해서 동작하기 때문에 데이터의 변화는 곧 데이터를 사용하는 코드(프로시저)의 변화로 이어진다. 심지어 한 데이터 타입의 변화가 다른 데이터의 변화를 일으키기는 경우도 있다. 예를 들어, 화폐를 표현하는 데이터의 타입이 int에서 double로 변경되면 화폐를 사용하는 프로시저 뿐만 아니라 잔고, 판매금액 등의 다른 데이터 타입까지도 모두 변하게 된다. 심할 경우, 한 변수의 타입 변화가 수 일이 소용되는 노가다를 만들어내기도 한다. 이는 데이터 중심인 절차 지향으로 구현할 경우 유지보수 비용이 높아진다는 것을 의미한다.


중심에 데이터를 놓고 프로시저를 구현하는 절차 지향과 달리 객체 지향은 데이터와 기능을 함께 담고 있는 객체들이 서로 대화하는 방법으로 구현된다. 객체 간에는 데이터를 (최대한) 주고 받지 않으며, 객체 상호간에 기능을 실행해 달라고 요청하는 방식으로 구현을 하게 된다.



각 객체들은 내부적으로 데이터를 담고 있지만 다른 객체에게 데이터를 노출하지는 않는다. 객체와 객체 간에 데이터가 흐르지 않기 때문에 특정 객체가 담고 있는 데이터에 변화가 생기더라도 해당 객체로만 변화가 한정되며 다른 객체에까지 변하가 전파되지 않게 된다. 이 얘기는 변화에 따른 유지보수 비용이 절차 지향에 비해 상대적으로 더 작다는 것을 의미한다.


프로시저와 객체 지향에 대한 보다 구체적인 예는 필자가 이전에 쓴 '캡슐화 http://javacan.tistory.com/entry/EncapsulationExcerprtFromJavaBook'란 글을 참고하기 바란다.


모든 것을 객체 지향으로 할 필요는 없다. 단순히 한 개의 DB 테이블의 데이터를 읽어서 보여주는 웹 방명록을 생각해보자. 이 기능은 데이터를 중심으로 한 절차 지향으로 구현하는 것이 훨씬 간단하다. 또한 일회성으로 사용될 이벤트 프로그램을 구현하기 위해 객체 지향을 추구할 필요는 없다. 왜냐면 유지보수 비용이 발생하지 않기 때문이다. 하지만, 도메인이 복잡하고 유지보수 기간이 길어질수록 절차 지향 방식은 높은 개발 비용을 발생시키기 때문에, 이런 경우에는 객체 지향적으로 하는 것이 비용측면에서 유리하다. 물론, 코드를 만져야 하는 개발자의 정신 건강 측면에서도 훨씬 유리하다.


객체의 핵심은 기능을 제공하는 것


객체를 가장 간단하게 정의하자면 '기능을 제공하는 것'이라고 할 수 있다. 조금 더 길게 설명하자면 다음과 같다.

  • 제공하는 기능으로 정의된다.
  • 내부적으로 어떤 데이터를 들고 있고 어떻게 기능을 구현하는 지는 숨긴다.

객체는 기능을 제공한다. 예를 들어, TV라는 객체를 생각해보자. TV는 다음의 기능들을 제공할 것이다.

  • 채널 증가
  • 채널 감소
  • 지정 번호로 채널 변경
  • 볼륨 증가
  • 볼륨 감소

TV가 내부적으로 볼륨의 크기를 어떤 데이터 타입으로 관리하는지는 중요하지 않고, 단지 TV 객체에 볼륨을 증가시키라고 요청을 할 뿐이다. 이를 코드로 표현하면 다음과 같다.


TV tv = new TV();

tv.increaseVolume(); // 기능을 제공


만약 데이터가 흘러다니도록 구현을 한다면 다음과 같은 코드가 만들어질 것이다.


TV tv = new TV();

int volume = tv.getVolume();

tv.setVolume(volume + 1);


두 코드의 차이는 명확하다. 앞서 첫 번째 코드의 increaseVolume()는 볼륨을 증가시키는 기능을 제공하며, 내부적으로 볼륨의 타입이 변경되더라도 영향을 받지 않는다. 반면에 두 번째 코드는 볼륨 값을 저장하는 타입이 int 임을 노출하고 있으며, 이 데이터에 기대서 볼륨 조절 기능을 구현하고 있다. 두 번째 코드에서 볼륨을 보관하는 데이터 타입이 short이나 byte와 같은 다른 타입으로 변경될 경우 그 데이터를 사용하는 코드가 함께 변경되게 된다.


객체의 역할(또는 책임)과 메시징


객체는 기능을 제공하는 존재이기 때문에, 객체 지향적으로 설계를 한다는 것은 각각의 객체가 어떤 기능을 제공하는지 정의하는 작업이 된다. 또한, 각 객체들이 서로 어떻게 상대방의 기능을 사용하는지를 설계하는 것이 객체 설계가 된다. 이런 의미에서 객체 지향 설계를 할 때 가정 먼저 해야 할 작업은 객체가 어떤 역할을 수행하는 지 결정하는 것이다.


하나의 객체가 모든 기능을 제공하는 것은 유지보수 측면에서 바람직하지 않기 때문에, 각 기능들을 관련된 것들끼리 분리해서 각각의 객체들에 역할을 분배해준다. 예를 들어, 로그 분석하는 시스템을 생각해보자. 이 경우 다음과 같이 객체 별로 역할을 구분해서 할당할 수 있다.

  • 로그 수집 객체 객체: 로그를 수집해서 한 곳에 모아준다.
  • 로그 분석 객체: 로그 파일로부터 데이터를 추출해서 중간 결과를 생성한다.
  • 리포트 생성 객체: 중간 결과로부터 실제 원하는 리포트를 생성한다.
  • DB 연동 객체: DB에 데이터를 삽입해주는 기능을 제공한다.
  • 흐름 제어 객체: 위 객체들간의 실행 순서를 제어한다.

객체의 역할과 함께 도출되는 것은 객체들 간의 관계이다. 전체 기능이 각각의 객체에 분산되어 있기 때문에, 완성된 기능을 제공하려면 각 객체들이 제공하는 기능을 엮어주어야 한다. 이는 객체 간의 메시징을 통해서 이루어진다. 한 객체는 다른 객체에 기능 실행을 요구하기 위한 요청을 보내는데 이 요청을 보통 메시지라고 표현한다. 다른 객체로부터 메시지를 받은 객체는 메시지에 알맞은 기능을 실행하고 그 결과를 메시지로 보낸다. 자바의 경우 기능 실행을 요청하는 메시지가 메서드 호출로 구현되며, 응답 메시지는 리턴이나 익셉션 등으로 구현된다.


객체의 역할과 메시지로부터 뭔가 떠오르는 것이 있을 것이다. 그것은 바로 커뮤니케이션 다이어그램이다. 커뮤니케이션 다이어그램은 객체 간의 메시지를 주고 받는 과정을 표현하기 위한 다이어그램으로, 이는 객체 지향 설계의 기본이 된다. 실제로 객체를 설계한다는 것은 다음을 반복하는 과정이다.

  • 기능들을 제공할 객체 후보를 선별한다.
  • 각 객체들이 어떻게 메시지를 주고 받는 지 연결한다.
  • 안 좋은 냄새가 나면 위 단계를 되풀이한다.

좋은 객체를 만드는 기본 규칙: 데이터 말고 기능 제공해 줘!


좋은 객체가 한 번에 나오면 좋겠지만, 설계란 그리 호락호락하지 않음을 누구나 알고 있다. 그래서 지속적으로 설계가 바뀌게 되고, 그렇기에 리팩토링이 구현에서 중요한 과정이 되는 것이다. 하지만, 출발점을 좋게 잡을 수는 있다. 그 중 하나가 다음에 따라 객체의 기능을 제공하는 것이다.

  • 데이터를 달라고 하지 않고, 해 달라고 하기!
  • 데이터 조회 기능은 필요한 경우에 한정해서 제공한다.

위 규칙에 따라 객체를 설계하다보면 자연스럽게 객체들 사이로 데이터가 흘러다니는 것을 줄이고, 객체가 내부 내장을 드러내지 않고 (즉, 캡슐화하고) 알맞은 기능을 제공하도록 만들 확률을 높일 수 있게 된다.


내용 정리


간단하게 내용을 정리해 보자.

  • 객체는 기능을 제공하는 역할을 수행하며, 객체의 내부가 어떻게 구현되었는지는 숨긴다.
  • 각 객체는 메시지를 주고 받으며 협업하며, 이를 통해 하나의 완성된 기능을 제공한다.
  • 객체 지향은 변화의 영향이 주변으로 퍼져나가는 것을 감소시켜 유지보수 비용을 줄여준다.
관련글

참고자료

제가 쓴 객체 지향 입문서입니다.


http://www.aladin.co.kr/shop/wproduct.aspx?ISBN=8969090010  에서 확인하실 수 있습니다.

 

+ Recent posts