개요
<UML 실전에서는 이것만 쓴다> 책에서 6장에 'OOD(객체 지향 개발)의 원칙'에서 5가지의 객체 지향 개발의 원칙을 설명하고 있습니다. 이 내용을 정리해보겠습니다. 또한 인프런 김영한님의 <스프링 기본편>강의를 참고했습니다.
단 하나의 책임 원칙(The Single Responsibility Principle, SRP)
어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.
클래스는 오직 하나의 주제만 알아야 합니다. 즉, 오직 하나의 책임만 져야 합니다. 다시 말해, 어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 합니다.
그림 6.1을 보겠습니다. 이 클래스는 너무 많은 정보를 가지고 있습니다. 임금과 세금을 계산하는 방법, 자신을 디스크에 저장하거나 읽어 오는 방법, 그리고 자신을 XML 입출력하는 방법, 다양한 보고서 형식으로 출력하는 방법도 압니다. '부숴지기 쉬운 상황' 이 느껴지지 않나요?
만약 XML API를 SAX에서 JDOM으로 바꾼다면 Employee도 변경해야 합니다. 마이크로소프트 Access에서 Oracle로 데이터베이스를 바꿀 때도 Employee를 변경해야 합니다. 세금 보고서의 형식을 바꾸어도 Employee를 변경해야 합니다. 이 설계는 결합도가 너무 높습니다.
실제로는 이 모든 개념을 각기 다른 클래스로 분리하여 클래스마다 변경해야 하는 이유가 오직 하나만 있도록 만드는 것이 바람직합니다. Employee 클래스는 세금과 임금만 다루고, XML 관련 클래스는 Employee 인스턴스 관련 XML 입출력만 다루도록 말입니다. 또 EmployeeDatabase 클래스는 Employee 인스턴스를 데이터베이스에 저장하거나 읽는 역할을 담당하고, 보고서 종류마다 클래스를 하나씩 만들면 좋을 것입니다. 간단히 말해서 관심사를 나눠야 합니다. 그림 6.3 설계 예시를 보겠습니다.
그림 6.3에 있는 두 다이어그램을 보면, 왼쪽 다이어그램 Persistable과 Employee이 강하게 결합합니다. 모든 Employee는 이 결합 때문에 Persistable에 '무조건' 의존합니다. Persistable를 변경하면 모든 Employee 사용자에게 영향을 미칠 수 있습니다.
그림 6.3의 오른쪽 다이어그램이라면 Employee는 Persistable에 독립과 동시에 왼쪽 다이어그램과 마찬가지로 Persistable을 사용할 수 있습니다. 또한 PersistableEmployee 인스턴스는 시스템 안에서 Employee 역할도 가능합니다. 즉 Persistable과 Employee의 결합이 있지만, 시스템 영역의 클라이언트들은 이 결합을 모릅니다.
개방 - 폐쇄 원칙(The Open - Closed Principle, OCP)
소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 개방, 변경에 폐쇄되어야 한다.
모듈 자체를 변경하지 않고도 그 모듈을 둘러싼 환경을 바꿀 수 있어야 합니다.
그림 6.4를 보면, EmployeeDB 데이터베이스 퍼사드(facade)를 통해 Employee를 다룹니다.(Employee는 EmployeeDB에 의존합니다) EmployeeDB 는 데이터베이스 API를 직접 다룹니다. 즉, EmployeeDB의 구현이 변경되면 Employee도 다시 빌드해야 할 수도 있으므로 OCP를 위반합니다. Employee는 EmployeeDB를 통해 데이터베이스 API와도 강하게 결합되어 묶인 셈입니다. 심지어, Employee 클래스를 포함하는 시스템은 반드시 TheDatabase API까지 포함합니다.
OCP를 위반하면 단위테스트가 어렵습니다. 예를 들어 Employee를 테스트하려면 데이터베이스가 필요합니다. 하지만 테스트 환경에서 실제 데이터베이스의 데이터를 아예 건드리고 싶지 않다면? Employee가 데이터베이스로 보내는 모든 호출을 가로채고 다른 방식으로 처리할 방법이 필요합니다.
그림 6.5처럼 EmployeeDB를 인터페이스로 바꾸면 유연하게 테스트 할 수 있습니다. 이 인터페이스의 두가지 구현체를 만들되, 하나는 진짜 데이터베이스를 호출하도록 하고 다른 하나는 임의의 테스트를 지원하도록 합니다. 이렇게 인터페이스를 만들면 데이터베이스 API와 Employee를 분리할 수 있고, Employee를 손대지 않고도 데이터베이스 환경을 변경할 수 있습니다.
인터페이스를 사용하기 때문에 모듈을 둘러싼 환경을 마음대로 바꿀 수 있습니다. 다형성(추상화)는 OCP를 지키기 위한 열쇠입니다. 어떻게 추상화를 해야 OCP를 지키는 데 도움이 될까요? 실제 코드를 작성하기 전에 단위 테스트를 먼저 작성할 수 있습니다. 각각 테스트 함수를 작성한 다음, 실제 모듈에는 이 테스트 함수를 통과할 수 있을 정도로만 코드를 작성합니다.
리스코프 치환 원칙(Liskov Substition Principle, LSP)
서브타입은 언제나 자신의 기반 타입(base type)으로 교체할 수 있어야 한다.
if 문장과 instance 표현식이 수없이 많다면, 보통 이런 코드는 LSP를 지키지 않아서 생기는데, 이는 곧 OCP도 지키지 않았다는 말이다.
LSP에 따르면, 기반 클래스(base class)의 참조변수는 자손 타입 클래스를 참조해 사용할 때, 원래 기반 클래스를 사용하는 것처럼 그대로 사용할 수 있어야 합니다. 더 자세히 말하면, instanceof나 다운캐스트를 할 필요가 없어야 하며 클라이언트는 자손 타입에 대해서 아무것도 알 필요가 없어야 합니다. 자손 클래스가 있다는 사실조차도!
그림 6.8에 나온 임금 지급 애플리케이션에서 Employee는 추상 클래스이며, 추상 메서드 calcPay를 가집니다. SalariedEmployee는 월급을 리턴하도록 calcPay 메서드를 구현합니다. HourlyEmployee는 이번 주 (출퇴근 카드에서 알아낸 근무 시간 수 * 시간당 임금)을 리턴하도록 calcPay 메서드를 구현합니다.
만약 2개 이외에 임금 지급을 받지 않는 새로운 VolunteerEployee를 추가하면 어떤 일이 생길까요? 어떻게 calcPay를 구
현해야 할까요? 언뜻 보면 너무나 명백하게도 calcPay가 0을 반환하게 구현하면 됩니다.
public class VounteerEmployee extends Employee {
public double calcPay() {
return 0;
}
}
하지만 이 구현 방법이 정말로 옳을까요? VolunteerEmployee가 calcPay 메서드를 호출하는 것부터 이치에 어긋나는 것이 아닐까요? calcPay가 0을 리턴하도록 구현한다는 것은 calcPay를 호출하는 것이 이치에 맞으며, 자원 봉사 직원에게 임금을 줄 수 있다는 의미를 포함합니다. 0원이라는 월급 총계가 나온 임금 명세서를 출력하고 메일로 발송하거나 이와 비슷한 말도 안 되는 일이 벌어지는 당황스러운 상황에 처할 수도 있습니다.
그렇다면 이 함수를 애초에 호출해서는 안 된다고 나타내기 위해 예외를 더니는 것이 가장 좋은 방법일지도 모릅니다.
public void VolunteerEmployee extends Employee {
public double calcPay() {
throw new UnpayableEmployeeException();
}
}
처음에는 이것도 이치에 맞는것처럼 보이지만 사실 VounteerEmployee의 calcPay를 부른다는 것부터 잘못되었습니다. 예외는 이렇게 잘못된 상황에서 던져야 합니다.
안타깝게도 이제 calcPay가 호출되면 UnpayableEmployeeException 예외가 던져질 수 있으므로, 호출하는 쪽에서 이 예외를 잡거나 자신도 이 예외를 던질지도 모른다고 처리해야 합니다. 따라서 하위 클래스의 제약이 기반 클래스를 사용하는 클라이언트게도 영향을 미치는 상황이 되어버립니다.
설상가상으로 다음 코드는 이제 합리적이지 않습니다.
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.calPay();
} catch (UnpayableEmployyeException e1) { }
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가 있다는 사실만으로도 내부 구현에 의존하며 강하게 결합합니다. 그 결과 이상한 예외와 어색한 instanceof 이 생겼으며 OCP도 어깁니다.
구현한 메서드를 '퇴화시키는 것', 즉 '아무것도 안하는 메서드로 구현하는 것'도 LSP를 어기는 것입니다. VolunteerEmployee에 calcPay가 존재하지 않아야 합니다. 지저분한 예외와 instance를 사용하면 안됩니다.
그렇다면 VolunteerEmployee 문제는 어떻게 해결할 수 있을까요? 자원 봉사자는 돈과 관련된 직원이 아닙니다. 자원 봉사자의 calcPay를 호출하는 것은 이치에 어긋나므로, 애초에 Employee의 하위 타입이면 안되고 함수 내부에서 calcPay를 호출하는 함수에 전달해서도 안됩니다.
의존 관계 역전 원칙(Dependency Inversion Principle, DIP)
A. 고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다.
B. 추상화된 것은 구체적인 것에 의존하면 안된다. 구체적인 것이 추상화된 것에 의존해야 한다.
자주 변경되는 구체 클래스(concrete class)에 의존하지 마라. 만약 어떤 클래스를 상속한다면, 기반 클래스를 추상 클래스로 만들어라. 만약 어떤 클래스를 참조해야 한다면, 클래스를 추상 클래스로 만들어라. 만약 어떤 함수를 호출해야 한다면, 함수를 추상 함수로 만들어라.
구체적인 것보다는 추상적인 것에 의존하는 편이 낫습니다. 추상 클래스와 인터페이스는 보통 자신의 구현체 클래스보다 훨씬 변경이 적기 때문입니다. 이 원칙을 지키면 코드가 변경되었을 때 시스템에 미치는 영향을 줄일 수 있습니다. 그렇다면 Vector나 String을 사용하면 안될까요? 이것들은 구체 클래스인데 DIP를 어기게 될까요? 그렇지 않습니다. 앞으로 변하지 않을 구체 클래스에 의존하는 것은 안전합니다. Vector나 String은 다음 10년 동안에도 변하지 않을 가능성이 높으므로 괜찮습니다.
우리가 의존하면 안 되는 것은 '자주 변경되는' 구체 클래스입니다. 활발히 개발 중인 구체 클래스나 변할 가능성이 높은 비지니스 규칙을 담은 클래스에 의존하면 안됩니다. 이런 경우 인터페이스를 만들어 인터페이스에 의존하는 것이 바람직합니다.
UML을 사용하면 이 원칙을 지키는지 매우 쉽게 검사할 수 있습니다. UML 다이어그램의 화살표마다 따라가서 모두 인터페이스나 추상 클래스를 가리키는지 확인하면 됩니다. 만약 구체 클래스에 의존하는데 그 클래스가 자주 변경된다면 DIP를 어기게 됩니다.
인터페이스 격리 원칙(Interface Segregation Principle, ISP)
클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다.
메서드를 몇십 몇백 개 가지는 클래스 를 본 적이 있나요? 대게 시스템 안에 이런 클래스를 두고 싶어 하지 않지만, 피할 수 없는 경우도 가끔 있습니다. 비대한 클래스가 거대하고 보기 흉하다는 사실 말고도, 이 비대한 클래스의 메서드를 다 사용하는 일이 매우 적다는 것도 문제입니다. 즉 메서드를 몇십개 선언한 클래스에서 사용자는 단지 두세 개만 호출할지도 모릅니다. 불행하게도 클라이언트는 '호출하지도 않은' 메서드에 생긴 변화에서도 영향을 받는다.
예를 들어, 그림 6.9에 나온 강좌 등록 시스템을 보겠습니다. 이 다이어그램에는 StudentEnrollment(학생 등록)을 사용하는 두 클라이언트가 있습니다. EnrollmentReportGenerator(등록 상황 보고서 생성자)가 preparedInvoice나 postPayment 같은 메서드는 사용할 일이 없다고 가정해보겠습니다. 마찬가지로 AccountsReceivable(받을 수 있는 계좌)은 getName이나 getDate 같은 메서드를 호출하지 않는다고 가정해 보겠습니다.
이제 요구사항이 변해서 postPayment 메서드에 새 인자를 추가한다면 무슨 일이 일어날까요?
StudentEnrollement의 선언을 바꾸는 변화 때문에 EnrollmentReportGenerator를 다시 컴파일하고 배포해야 할지도 모릅니다. EnrollemntReportGenerator는 postPayment 메서드와 아무 상관없지만 변경해야 합니다.
클라이언트에게 필요한 메서드만 있는 인터페이스를 제공해서 필요하지 않는 메서드를 가지지 않도록 해야합니다. 다음 페이지의 그림 6.10은 이 규칙을 적용했습니다.
StudentEnrollement를 사용하는 클라이언트마다 자신이 필요한 메서드들만 있는 인터페이스를 제공받습니다. 클라이언트가 관심 없는 메서드에 생긴 변화는 무시합니다. 그리고 클라이언트가 자신이 사용하는 객체를 너무 많이 알게 되는 일도 막을 수 있습니다.
결론
이 원칙들을 적용하는 가장 좋은 방법은 능동적으로 적극 적용하는 것이 아니라, 문제가 생겼을 때 그에 대한 '반응으로써 작용'하는 것입니다. 코드의 구조적인 문제를 처음 발견했거나, 어떤 모듈이 다른 모듈에서 생긴 변화에 영향을 받음을 처음 깨달았을 때 그때 '비로소' 원칙 가운데 하나 또는 여러 개를 써서 이 문제를 해결할 수 있는지 알아보아야 합니다.
단위 테스트를 엄청나게 작성해 보는 것이 아픈 지점을 찾는 가장 좋은 방법 가운데 하나이므로 테스트 대상 코드보다 테스트를 먼저 작성하면 더 좋습니다.
'학습 > Java' 카테고리의 다른 글
싱글톤(Singleton) (0) | 2024.03.18 |
---|---|
Generics의 Type Erasure(타입 소거) (0) | 2024.03.08 |
Generics의 Wildcards(와일드카드) (0) | 2024.03.07 |
Value Object 패턴 (0) | 2023.07.07 |
Entity vs Value Object 차이점 (0) | 2023.06.30 |