들어가며
이 포스팅에 나오는 내용은 <<UML 실전에서는 이것만 쓴다>> 6장 객체지향 개발의 원칙에 나오는 내용을 요약한 것이다.
설계의 품질
잘 설계되었다는 것은 무슨 뜻일까? 잘 설계된 시스템은 이해하기 쉽고, 바꾸기도 쉽고, 재사용하기도 쉽다. 반면 잘못된 설계에서는 마치 썩는 고기처럼 역한 냄새가 난다.
역한 냄새
- 경직성: 무엇이든 하나를 바꿀 때마다 다른 것도 바꿔야 하고, 그 사슬이 연쇄로 이어져 있기 때문에 시스템을 변경하기 어렵다.
- 부서지기 쉬움: 시스템에서 한 부분을 변경하면, 그것과 전혀 상관없는 다른 부분이 동작을 멈춘다.
- 부동성: 시스템을 여러 컴포넌트로 분해하여, 다른 부분에 사용하기 어렵다.
- 끈끈함: 편집 > 컴파일 > 테스트 순환을 한 번 도는 시간이 엄청나게 길다.
- 쓸데없이 복잡함: 괜히 머리를 굴려서 짠 코드 구조가 굉장히 많다. 언젠가는 유용하다고 생각하고 만든 것들이다.
- 필요 없는 반복: 코드를 작성한 프로그래머 이름이 ‘복사’, ‘붙여넣기’ 같다.
- 불투명함: 코드를 만든 의도를 짐작할 수 없다.
이유
이러한 역한 냄새가 나는 이유는, 잘못 관리한 의존 관계 때문인 경우가 많다. 객체지향 언어는 의존관계를 관리하는 데 도움이 되는 도구들을 제공한다. 그렇다면 어떤 원칙을 기반으로 작업해야 할까? 여기서 나오는 것이 객체지향 5대 원칙, SOLID이다.
단 하나의 책임 원칙(SRP)
어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다. 객체가 스스로 GUI에 그리는 법을 알아야 한다던지, 디스크에 저장하는 법을 알아야 한다던가 XML로 변경해야 하는 법을 알아야 한다는 이야기가 많다.
내 생각은 좀 다르다. 클래스는 오직 한 가지만 알아야 한다. 좀 더 핵심적인 말로 이야기하자면, 어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.
하나의 클래스에 다수의 개념이 존재하게 된다면, 이 모든 개념을 각각 클래스로 찢어서 구현하는 것이 바람직하다. 이 원칙은 쉬워 보이지만, 원칙을 어기는 예를 찾기 매우 쉽다. 대표적으로는 특정 속성을 부여하는 인터페이스를 하나 또는 그 이상으로 구현하는 클래스이다.
예를 들면, 디스크에 저장하는 능력을 부여하는 인터페이스가 있다고 생각해 보자. 비즈니스 객체가 이 인터페이스를 구현하게 되면, SRP원칙이 깨지게 되는 것이다.
개방 - 폐쇄 원칙(OCP)
소프트웨어 엔티티는 확장에 대해서는 개방되어야 하지만, 변경에 대해서는 폐쇄되어야 한다.
이 원칙의 의미는 간단하다. 모듈 자체를 변경하지 않고도, 모듈을 둘러싼 환경을 변경할 수 있어야 한다. 이 원칙을 지키면 테스트를 할 때나, 여러모로 편리한 점이 많다.
만약에 DB에 커넥션을 맺고 있는 모듈을 사용한다고 해 보자. 테스트를 할 때는 DB에 직접 커넥션을 맺지 않고도 mock 객체를 이용하여 테스트를 할 수 있다. 이것이 모듈 자체를 변경하지 않고, 모듈을 둘러싼 환경을 변화시킨다는 것이다.
UI와 모델 구조에서도 마찬가지이다. 데이터를 조작하는 부분과 화면을 그리는 부분을 명확하게 분리해 두었다면, 화면을 그리는 대화상자를 쉽게 명령형 UI나 텍스트로 된 메뉴 UI로 변경할 수 있다.
그렇다면 어떻게 하면 OCP를 지킬 수 있을까? 작업을 하기 전에 해당 클래스의 인터페이스를 정의하고, 인터페이스를 활용하여 작업을 진행하면 된다. 추상화야말로 OCP를 지키는 열쇠이다.
어떻게 추상화를 하면 될까? 단위 테스트를 먼저 작성하는 것을 권장한다. 먼저 테스트 함수를 작성한다. 그리고 실제 모듈에는 이 테스트 함수를 통과할 수 있을 정도로만 코드를 작성하면 된다.
리스코프 교체 원칙(LSP)
서브타입은 언제나 자신의 기반 타입(base type)으로 교체할 수 있어야 한다.
if문장과 instanceof 표현식이 수없이 많은 코드는, 대부분의 경우 LSP를 지키지 않아서 발생하는 코드이다.
LSP에 따르면, 기반 클래스의 사용자는 그 기반 클래스에서 유도된 클래스를 기반 클래스로써 사용할 때, 원래 기반 클래스처럼 사용할 수 있어야 한다.
다시 이야기하면, instanceof나 다운캐스트를 할 필요가 없어야 한다.
예를 들면, 직원 클래스는 추상 클래스이며 calcPay라는 추상 메서드를 가진다. 월급을 받는 직원이라는 클래스는 월급을 리턴하도록 calcPay 메서드를 구현할 것이다.
시급을 받는 직원클래스는 이 메서드를 근무시간 * 시간당 임금을 리턴하게 구현할 것이다.
그럼 여기에 자원 봉사 직원을 추가한다고 해 보자. calcPay를 어떻게 구현해야 할까?
public class VolunteerEmployee extends Employee {
public double calcPay() {
return 0;
}
}언듯 보면 괜찮아 보인다. 하지만 자원 봉사자가 임금 계산 메서드 자체가 있는게 이상한 게 아닐까? calcPay가 있다는 건 이 메서드를 호출하는 것이 이치에 맞으며, 자원봉사자는 0원이라는 임금 명세표를 받아간다는 의미가 된다. 이건 약간 애매하다.
그러면 메서드를 호출하면 예외를 던지게 해 보자
public class VolunteerEmployee extends Employee {
public double calcPay() {
throw new UnpayableEmployeeException();
}
}그러면 이 메서드를 호출하면 예외가 던져질 수 있으므로, 호출하는 측에서 try ~ catch를 사용하거나 예외를 위로 던져야 한다. 이는 이상하다. 파생 클래스의 제약이 기반 클래스의 구조를 변경하게 되는 것이다.
설상가상으로 다음 코드는 올바르지 않다.
for (int i = 0; i<employees.size(); i++) {
Employee e = (Employee) employees.elementAt(i);
totalPay += e.calcPay();
}calcPay()를 호출할 때 이를 try - catch 블록으로 묶어 주기로 하자
for (int i = 0; i<employees.size(); i++) {
Employee e = (Employee) employees.elementAt(i);
try {
totalPay += e.calcPay();
} catch(UnpayableEmployeeException el) {
}
return totalPay;
}이 코드는 보기 흉하고, 핵심에서 벗어나 있다. 그래서 다음처럼 변경하고 싶을 수도 있다.
for (int i = 0; i<employees.size(); i++) {
Employee e = (Employee) employees.elementAt(i);
if(!(e instanceof VolunteerEmployee)) {
totalPay += e.calcPay();
}
}이건 더 나쁘다. 원래 Employee라는 기반 클래스로 작업했더 코드에서, 이제는 유도된 클래스가 어떤 것인지 코드에 나타나야 하기 때문이다.
이 모든 것은 LSP, 리스코프 교체 원칙을 어겼기 때문에 발생한 문제이다. VolunteerEmployee는 Employee 대신에 들어갈 수 없다. Employee의 사용자는 VolunteerEmployee 가 추가되었다는 사실 때문에 영향을 받는다. 그 때문에, 이상한 if 절과 instanceof가 추가되게 되고, OCP를 어기게 된다.
유도된 클래스의 어떤 함수를 호출하는 행위를 불법으로 만들 때마다, LSP를 어기게 됨을 알 수 있다. 유도된 메서드를 오버라이드 해서 아무것도 하지 않게 만드는 것도 LSP를 어기게 된다.
그렇다면 위의 문제는 어떻게 해결할 수 있을까? VolunteerEmployee는 직원이 아니므로 Employee를 상속할 수 없다.
의존관계 역전 원칙(DIP)
- 고차원 모듈을 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다.
- 추상화된 것은 구체화된 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다.
쉽게 말하면 다음과 같다 : 자주 변경되는 컨크리트 클래스에 의존하지 마라. 만약 어떤 클래스에서 상속받아야 한다면, 기반 클래스를 추상 클래스로 만들어라.
어떤 클래스를 레퍼런스해야 한다면, 참조 대상이 되는 클래스를 추상 클래스로 만들어라. 만약 어떤 함수를 호출해야 한다면, 호출되는 함수를 추상 함수로 만들어라.
추상 클래스와 인터페이스는 보통 자신에게서 유도된 클래스보다 훨씬 덜 변한다. 그렇다면 모든 클래스에 의존하면 안 되는 것인가? 자주 변경되지 않는 컨크리트 클래스에 의존하는 것은 안전하다
예를 들면, String클래스 같은 것은, 다음 10년 동안에도 변하지 않을 가능성이 높다.
인터페이스 격리 원칙
클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다.
하나의 클래스에서 제공하는 메서드는, 클라이언트에서 사용하는 메서드어야 한다. 하나의 클라이언트에 A,B,C 메서드를 제공한다고 가정해 보자.
이 때 클라이언트는 단지 A 메서드만 사용하는데, B나 C의 인터페이스를 변경하면 클라이언트가 다시 컴파일 - 배포해야 하는 일이 생길지도 모른다.
이것 외에도, 하나의 클라이언트에게 제공하는 클래스가 비대해지는 것은 유지보수 측면에서도 좋지 않다 (요세 많이 느끼고 있다)
해법은 인터페이스를 제공하여, 필요하지 않는 메서드로부터 사용자를 보호하는 것이다.
결론
자, 간단한 다섯 가지 원칙이다.
- SRP - 어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이다
- OCP - 어떤 클래스를 변경하지 않고도, 그 클래스의 환경을 바꿀 수 있어야 한다.
- LSP - 유도된 클래스의 메서드를 퇴화시키거나 불법으로 만드는 일을 피하라. 기반 클래스의 사용자는 그 기반 클래스로부터 유도된 클래스에 대해 아무것도 알 필요가 없어야 한다.
- DIP - 자주 변경되는 컨트리트 클래스 대신에 인터페이스나 추상 클래스에 의존하라
- ISP - 어떤 객체의 사용자에게 그 사용자에게 필요한 메서드만 있는 인터페이스를 제공하라
그러나 원칙보다 중요한 것은, 언제 이 원칙을 코드에 적용하냐는 것이다. 전체 시스템이 언제나 모두 이 다섯 가지 원칙을 따르게 하는 건 현명하지 못하다.
OCP를 적용할 환경이나, SRP를 적용할 변경의 이유를 모두 생각해 내려면 끝도 없을 것이다. ISP를 지키기 위해 자잘한 인터페이스를 마구마구 만들게 될 것이며, DIP를 지키기 위해 쓸모없는 추상 클래스를 무수히 만들게 될 것이다.
이 원칙들을 적용하는 가장 좋은 방법은, 문제가 생겼을 때, 즉 조금이라도 고통스러울 때 이 다섯 가지 원칙을 살피고, 그에 대한 반응으로써 적용하는 것이다.
그렇기 때문에, 문제가 생겼을 때 비로소 반응하는 접근 방법을 사용할꺼면 그전에 시스템에 적극적으로 압력을 가하여, 어디가 문제인지 확인해야 한다.
가장 좋은 방법은 단위 테스트를 엄청나게 작성해 보는 것이다. 테스트 대상 코드보다 테스트를 먼저 작성하면 더 좋다.