수정하지 않는 프로그램은 없다. 사용자의 요구사항은 항상 변하기 때문에 유지보수에 용이한 프로그램을 짜는 것이 중요하다. 객체 지향 기법을 적용하면 소프트웨어를 더 쉽게 변경할 수 있고, 따라서 요구사항의 변화를 더 빠르게 수용할 수 있다.
절차 지향은 프로시저들로 프로그램을 구성하는 기법인데, 다수의 프로시저가 데이터를 공유하는 방식으로 만들어진다. 따라서 프로그램 규모가 커질수록 데이터가 수정될 때 수정해야 되는 프로시저가 증가하고, 같은 데이터를 프로시저들이 서로 다른 의미로 사용하는 경우가 발생할 가능성이 높아진다.
이러한 단점을 보완할 수 있는 것이 이 책에서 자세하게 설명하는 객체 지향이다. 객체 지향은 객체들의 협력으로 구성된 프로그램이고, 객체는 자신만의 데이터와 프로시저를 갖고, 기능을 제공한다. 객체 지향은 모든 프로시저가 데이터를 공유하지 않고, 객체 별로 데이터와 프로시저를 알맞게 정의해야 한다. 객체 별로 데이터를 정의하기 때문에 객체의 데이터를 변경해도 다른 객체에는 영향을 주지 않는다. 따라서 절차 지향보다 프로그램을 더 쉽게 변경할 수 있다. 하지만 객체지향도 만약 한 객체에 너무 많은 기능이 있다면 절차지향에서 나타나던 문제가 또 나타나기 때문에 단일 책임 원칙 하에 객체는 단 한 개의 책임을 가져야 한다. 그리고 한 객체가 갖는 책임을 정의한 것이 타입/인터페이스이다.
객체에서 가장 중요한 것은 그 객체가 어떤 기능을 제공하는지이다. 객체가 제공하는 기능을 오퍼레이션이라고 부르고, 오퍼레이션으로 객체는 정의된다. 오퍼레이션의 사용법을 시그니처라고 부르고, 기능을 식별할 수 있는 이름, 파라미터, 기능 실행 결과 값으로 구성된다.
객체 지향의 중요한 특징 중 하나가 캡슐화이다. 캡슐화는 객체가 기능을 어떻게 구현됐는 지를 숨겨둔 것을 말한다. 캡슐화를 하면 내부 구현이 바뀌더라도 다른 곳에 미치는 영향을 줄일 수 있고, 이는 프로그램 유지보수를 쉽게 해준다. Tell, Don't Ask와 데미테르의 법칙을 따르면 캡슐화를 하기 쉬워진다. 객체에 기능 실행을 요청하고, 객체 내부에 구현이 되게끔 하면 자연스럽게 캡슐화가 된다. 데이터를 객체에서 받아서 밖에서 조작을 하는 경우 캡슐화가 제대로 안된 것이라고 볼 수 있다.
프로그램 수정에 유연함을 주는 객체 지향의 또 한 가지 특징은 추상화이다. 추상화는 단순화하는 과정이다. 클래스들을 추상화해서 인터페이스를 도출할 수 있다. 인터페이스를 사용하면 여러 개의 concrete 클래스들을 같은 타입으로 묶어서 동일한 기능을 시킬 수 있다. 이를 활용하면 concrete 클래스가 추가/변경되어도 기존 코드 부분의 변경을 최소화할 수 있다. 어느 부분을 추상화할 지 감이 잡히지 않는다면 요구사항이 바뀔 때 변화되는 부분은 앞으로도 변화될 수 있는 부분이니 요구사항에 따라 변화되는 부분을 추상화하면 좋다. 인터페이스는 사용하는 코드 입장에서 작성하면 더 좋은 인터페이스를 도출할 수 있다. 또한 인터페이스가 있다면 만약 아직 실제로 구현이 되지 않은 경우에도 진짜처럼 행동하는 Mock 객체를 Mockito나 jmock같은 프레임워크를 통해 인터페이스로 생성하여 쉽게 테스트할 수 있게 된다.
객체 지향의 또 하나 중요한 특징인 상속은 상속받은 클래스의 기능을 재사용하면서 기능을 추가하여 확장할 수 있다. 하지만 상속에는 단점이 있기 때문에 재사용만을 위해 상속을 사용하면 안된다. 상속은 상위 클래스의 변경을 어렵게 만드는 단점이 있다. 클래스를 상속받는다는 것은 상위 클래스에 하위클래스가 의존한다는 것이고, 의존하는 객체가 변경되면 변경 여파가 퍼져나가기 때문에 상위클래스의 변화는 하위 클래스에 영향을 줄 수 있다. 또한 IS-A관계가 아닌 경우에 재사용만을 위해 상속을 사용해서는 안된다. 상속은 IS-A관계일 때, 그리고 상위클래스의 기능을 유지하면서 기능을 확장하고 싶을 때 사용해야 한다.
객체 조립을 통해 복잡한 기능을 제공하는 객체를 만들면 상속을 통한 재사용을 했을 때 생기는 문제를 해결할 수 있다. 상속을 통한 재사용을 할 때 기능이 추가될 때마다 하위클래스가 기능의 조합에 따라 매우매우 증식될 수 있는데, 클래스 조립을 활용하면 기능에 대한 클래스만 추가해주고, composition을 통해 불필요한 클래스 증가를 막을 수 있다. 또한 상속 관계가 아니기 때문에 클래스의 변경이 상속관계일 때보다 간편해진다. 따라서 기능을 재사용 해야하는 경우 상속보다는 객체 조립을 먼저 고민하는 것이 좋다.
객체 지향 프로그램을 기본 원칙에 따라 작성하면 좋은 설계를 할 수 있다. 좋은 설계란 기능 확장을 쉽게 할 수 있는 설계를 의미한다. SOLID는 객체 지향 설계 기본 원칙이다. S: 단일 책임 원칙 (Single responsibility principle) O: 개방-폐쇄 원칙 (Open-closed principle) L: 리스코프 치환 원칙 (Liskov substitution principle) I: 인터페이스 분리 원칙 (Interface segregation principle) D: 의존 역전 원칙 (Dependency inversion principle)
하나하나 살펴보자.
1. 단일 책임 원칙 클래스는 하나의 책임을 가져야한다는 원칙이다. 이 원칙을 따르면 클래스가 변경되는 이유를 한 가지로 축소시킬 수 있다. 또한 이 원칙을 지키면 변경의 영향을 최소한으로 줄일 수 있고, 다른 객체에서 사용하기도 간편해진다.
2. 개방 폐쇄 원칙 기능을 변경하거나 확장할 수 있으면서 그 기능을 사용하는 코드를 수정하지 않는다는 원칙이다. 추상화를 이용해서 변화될 수 있는 부분을 표현해두면 추상화된 부분(인터페이스 등)을 상속받아서 새롭게 구현하여 확장할 수 있으면서, 그 기능을 사용하는 코드를 수정하지 않을 수 있어서 이 원칙을 지킬 수 있다. 즉, 추상화와 다형성을 이용하면 개방 폐쇄 원칙을 지킬 수 있다. 다운 캐스팅이나 instanceof나 비슷한 if-else블록이 많다면 추상화와 다형성으로 리팩토링을 할 수 있을지 체크해보는 것이 좋다!
3. 리스코프 치환 원칙 상위 타입의 객체를 하위 타입의 객체로 치환해도 프로그램이 정상적으로 돌아가야 한다는 원칙이다. 리스코프 치환 원칙을 지키지 않으면 개방 폐쇄 원칙 또한 지켜지지 않을 수 있기 때문에 리스코프 치환 원칙을 꼭 지켜야 한다. 리스코프 치환 원칙을 지키려면 명세를 제대로 지켜주면 된다. 즉, 하위 타입이 상위 타입의 명세를 벗어나지 않도록 구현해야 한다. 명세에서 벗어난 값을 리턴하거나 명세에서 벗어난 익셉션을 발생하거나 명세에서 벗어난 기능을 수행하면 리스코프 치환 원칙이 깨진다.
4. 인터페이스 분리 원칙 인터페이스 분리 원칙은 그 인터페이스를 사용하는 클라이언트를 기준으로 인터페이스를 분리하라는 원칙이다. 이 원칙을 통해 각 클라이언트가 사용하지 않는 인터페이스를 변경해도 영향을 받지 않을 수 있다.
5. 의존 역전 원칙 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다는 원칙이다. 저수준 모듈은 고수준 모듈의 하위 기능들을 어떻게 구현할지에 대한 내용을 다룬다. 즉, 고수준 모듈은 상대적으로 큰 틀에서 프로그램을 다루고, 저수준 모듈은 각 개별 요소가 어떻게 구현될지에 대해 다룬다. 프로그램이 안정화되면, 주로 저수준 모듈은 추가/ 변경되어도, 고수준 모듈은 많이 바뀌지 않는다. 의존 역전 원칙은 저수준 모듈이 변경되더라도 고수준 모듈이 바뀌지 않기 위한 원칙에 해당한다. 저수준 모듈의 추상 타입을 도출해서 고수준 모듈에서는 추상 타입을 이용하면 저수준 모듈의 변경에 고수준 모듈이 영향을 덜 받게 된다. 또한 이 원칙을 이용해서 개방 폐쇄 원칙을 패키지 수준까지 확장시켜줄 수 있다. 주의할 점은 소스 코드 상에서 의존을 역전시켜서 변경의 유연함을 확보할 수 있는 원칙인 것이고, 런타임에서의 의존을 역전시키는 것은 아니라는 점이다.
이 원칙들을 사용하면 한 객체가 너무 커지지 않아서 기능 변경의 여파를 최소화해서 기능 변경을 쉽게 할 수 있게 해주고, 기능 확장을 하면서도 기존 코드를 수정하지 않을 수 있게 도와준다.
큰 프로그램을 만들려면 여러 객체들을 협력하게 해야 하는데, 객체들을 연결할 때 의존성 주입과 서비스 로케이터라는 방법이 사용될 수 있다. 의존성 주입(DI)는 필요한 객체를 외부에서 넣어주는 방식이다. 스프링 프레임워크를 많이 쓰는 이유는 스프링 프레임워크가 객체를 생성하고 조립해주는 기능을 제공하는 DI프레임워크이기 때문이다. 또한 DI는 의존하는 객체를 Mock객체로 바꾸어 쉽게 넣어줄 수 있기 때문에 단위 테스트를 할 때도 유리하다.
프레임워크가 DI처리를 위한 방법을 제공하지 않아서 DI패턴을 적용할 수 없는 경우에 서비스 로케이터 등의 방법을 이용할 수 있다. 서비스 로케이터는 어플리케이션에서 필요로 하는 객체를 제공해준다. 따라서 의존 객체가 필요한 코드에서 서비스 로케이터의 메소드로 필요한 객체를 구하면 된다. 인터페이스 분리 원칙을 지키기 위해 동일한 구조의 서비스 로케이터 클래스를 중복해서 만들게 되는데, 중복을 없애기 위해 제네릭을 사용하는 것이 좋다. 서비스 로케이터는 동일 타입의 객체가 여러 개 필요한 경우에 각 객체 별로 제공 메서드를 만들어 줘야 하는 단점이 있고, 클라이언트가 꼭 필요로 하는 부분만 포함하고 있지 않을 수 있기 때문에 인터페이스 분리 원칙을 위반한다는 단점도 있기 때문에 부득이한 상황이 아니라면 서비스 로케이터 보다는 DI를 사용해야 한다.
디자인 패턴은 자주 나타나는 문제 상황에 대한 해결법을 패턴으로 정형화해둔 것이다.
1. 전략 패턴 특정 기능의 알고리즘을 분리하여 여러가지 전략을 기능 코드 변경 없이 사용할 수 있다.
2. 템플릿 메소드 실행 과정이나 단계가 일정한데 그 과정 중 일부 과정에 variation을 주고 싶은 경우 사용한다.
3. 상태 패턴 상태에 따라 동일한 기능 요청을 다르게 처리하고 싶을 때 사용한다.
4. 데코레이터 패턴 상속 대신 위임 방식으로 기능을 확장해 나가는 패턴이다. 데코레이터를 조합하는 방식으로 기능을 확장할 수 있다.
5. 프록시 패턴 실제 객체를 대신하는 프록시(대리) 객체를 이용하는 패턴이다.
6. 어댑터 패턴 클라이언트가 요구하는 인터페이스와 사용하려는 모듈의 인터페이스가 맞지 않을 때 사용할 수 있는 패턴이다.
7. 옵저버 패턴 한 객체의 상태 변화를 여러 객체에게 알리고 싶을 때 사용하는 패턴이다.
8. 미디에이터 패턴 객체들 사이에 중계를 해주는 미디에이터 객체가 객체들을 간접적으로 이어주는 패턴이다.
9. 파사드 패턴 하위 시스템을 감춰 주는 인터페이스를 제공하는 패턴이다. 클라이언트가 파사드에 의존하여 하위 시스템 수정에 의한 클라이언트에 대한 영향을 최소화할 수 있다.
10. 추상 팩토리 패턴 특정 객체들을 생성하는 책임이 있는 객체를 생성해주는 패턴이다.
11. 컴포지트 패턴 폴더 안에 폴더가 들어가고 상위 폴더와 하위 폴더는 같은 기능들(복사, 잘라내기 등)을 쓸 수 있듯이 상위 클래스(전체)와 하위 클래스(부분)가 동일한 인터페이스를 구현하도록 하는 패턴이다.
12. null 객체 패턴 null을 반환하고 싶은 부분에 null을 대신할 객체를 만들어서 대신 반환하는 패턴이다.