S.O.L.I.D.
- -
이번 포스트에서는 객체지향 프로그래밍 설계의 5가지 주요 원칙인 S.O.L.I.D.에 대해 살펴보자.
SRP(Single Responsibility Princlple: 단일 책임 원칙)
개요
객체의 응집도(cohesion)에 관한 내용으로 하나의 클래스는 하나의 책임만 가진다.
- 클래스가 제공하는 모든 기능은 그 책임을 수행하는데 집중한다.
- 환경이 바뀌어 완전히 새로운 기능을 추가해야 한다면 그 클래스는 분할되어야 한다.
주로 클래스의 설계 즉 추상화와 연관된 개념이다.
설명
SRP는 다른 원칙들에 비해 가장 손쉽게 설명이 가능한 원칙이다. 주변에 있는 큰 패밀리 레스토랑에 가보자. 많은 직원들이 요리, 서빙, 안내, 정리 등 다양한 업무를 보고 있다. 그런데 이들은 모두 자기의 업무에 집중한다.
만약 한 직원이 요리, 서빙, 손님 안내를 모두 도맡아 한다면 어떨까? 바쁜 업무로 정신없을 것이고 그러다 보면 실수할 가능성도 많아진다. 그 직원이 아프기라도 하면 식당 문을 닫아야 할 수도 있다.
간단한 코드와 리펙토링
여기 Order라는 클래스가 있다. 당연히 주요 기능을 주문처리이다.
초기의 Order | 결재가 추가된 Order |
그런데 결재가 필요하다는 걸 깨닫고 pay()라는 기능을 추가해 보기로 했다. 만약 이 pay를 Order에 추가한다고 생각해 보자. 하지만 아는 사람은 다 안다. 결재와 주문은 다르다!
일단은 현재의 상태로도 동작은 잘 된다. 특히나 만든 사람은 잘 사용할 것이다. 하지만 시간이 흘러 스토리를 모르는 사람이 결재를 하려고 물었더니 "주문시스템에 물어보세요"라고 한다면 당황스럽다. 또 만약 결재 방식으로 카드와 현금이 도입된다면 pay가 변경되어야 할 것이고 느닷없이 주문 시스템을 재설계해야 한다.
따라서 하나의 클래스에는 하나의 책임만 주고 여러 책임이 들어가야 할 때는 클래스를 분리해야 한다.
OCP(Open-Closed Principle: 개방 폐쇄의 원칙)
개요
자신의 확장에는 열려있고 주변의 변화에는 닫혀 있어야 한다. 즉 변경에 대한 비용을 최소화(interface) 하고 확장에 의한 이득은 최대화(extends) 하자.
- 요구사항 변경이 발생하더라도 기존 요소의 수정이 발생하지 않도록 해야 한다.
- 기존 요소를 쉽게 재활용해서 확장할 수 있어야 한다.
주로 추상화/다형성과 연관된 개념이다.
설명
예를 들어 레고 블록으로 만든 집을 생각해 보자. 기존에 꽉 막힌 문으로 되어있었는데 이를 창문으로 변경하려면? 기존의 문을 조금 수정해서 창문으로 변경하면 된다. 즉 기존의 객체를 확장(extends)해서 새로운 문의 형태를 만드는 것이다. 하지만 이 과정에서 분명 수정 사항들이 발생한다. 이에 대한 비용을 최소화하기 위해서 레고의 규격이나 문의 크기등은 미리 약속되어 있어야 한다. 즉 미리 만들어 놓은 interface를 지키면 변경에 따른 비용을 최소화할 수 있다.
따라서 문을 설계할 때 변경되지 않는 부분(외부와의 연결 힌지, 문의 크기 등)는 인터페이스로 고정하고 내부의 변할 수 있는 부분들을 쉽게 확장할 수 있게 만드는 것이 좋다.
간단한 코드와 리펙토링
결제 시스템을 구성하려면 여러 결제 수단(현금, 계좌이체, 신용카드 등)이 있는데 이들마다 실제 결재가 진행되는 방식은 다르다. 한 가지 결재 수단만 사용해야 한다면 당연히 고객이 줄어들 것이다. 또한 미래의 결제수단(비트코인 등)까지 고려해야 하는데 어떻게 동작할지 알 수 없다.
지불 수단 | 현금 | 계좌이체 | 신용카드 | 비트코인 |
시작 | 물건과 결재 금액을 확인한다. | |||
결재 | 지갑에서 꺼낸다. 지불한다. |
대상 계좌를 입력한다. 금액을 이체한다. 이체 결과를 확인한다. |
카드를 전달한다. 결재한다. |
? |
결과 | 지불 완료 |
이런 상황에서 결재의 고정 부분인 시작과 결과를 인터페이스에 정의하고 이를 구현하는 클래스들을 만들어주면 된다.
LSP(Liskov Substitution Principle: 리스코프 교체의 원칙)
개요
하위 타입이 상위 타입을 대체할 수 있어야 한다. (부모 클래스 자리에 자식 클래스가 들어가도 동작해야 한다.) LSP를 위반하게 되면 OCP가 붕괴되고 결국은 OOP 자체가 흔들리게 된다.
- 부모 클래스를 그대로 확장할 때는 문제 되지 않으나 부모가 의도하지 않은 방향으로 오버라이딩하면 문제가 발생한다.
LSP를 지키기 위해서는 다음의 원칙을 준수해야 한다.
- 하위 클래스는 상위 클래스의 모든 행동을 이해하고 수행할 수 있어야 한다. 따라서 메서드 재정의는 기능을 변경하는 것이 아니라 기능을 확장해야 한다. 조상 Person의 wearGlass가 시력을 보정하는 용도인데 Child의 wearGlass가 시력 보정은 빼고 패션용으로만 되면 안된다.
- 속성의 재정의 시 유의해야 한다. 조상 Car의 maxSpeed 속성(차가 낼 수 있는 최대 속도)을 자식 SportsCar가 재정의할 때는 차가 기록한 최소 속도로 개념에 혼돈이 오지 않는지 주의해야 한다.
주로 상속과 관련된 원칙이다.
설명
새는 fly()가 가능하다. 만약 비행기에서 밀렵군에게 잡혀있는 새들을 구조한 후 이 새를 풀어준다면 새들을 훨훨 날아 새 삶을 꾸릴 수 있을 것이다. 그런데 만약 새를 상속받은 펭귄이 있다면 어떨까? 이 펭귄들을 높은 곳에서 떨어뜨리면 잘 날아갈 수 있을까?
이 경우 펭귄은 새의 모든 동작을 완벽히 수행할 수 없으므로 LSP를 위반하게 된다. 결국 fly()는 새의 고유 기능일 수 없는 것이다.
간단한 리펙토링
앞서 이야기했듯이 흔한 착각으로 fly()라는 기능이 Bird에 있고 Penguin가 Bird를 상속받을 것 같지만 이는 사실상 LSP를 위배하므로 부적절하다.
public class BirdTest {
public static void main(String[] args) {
var birds = List.of(new Eagle(),
new Penguin());
birds.forEach(Bird::fly);
}
}
이 문제의 출발점은 Bird는 무조건 날 수 있다고 전제한 부분에 있다. 따라서 날 수 있는 새와 날수 없는 새로 세분하거나 아예 Flyable 같은 interface를 적용하는 것도 좋은 방법일 수 있다.
ISP(Interface Segregation Principle: 인터페이스 분리의 원칙)
개요
클래스는 자신이 사용하지 않는 인터페이스를 구현하지 말아야 한다.
- 인터페이스를 작은 단위로 분리해서 사용하고 클래스는 자신이 필요한 인터페이스만 구현하도록 한다.
- 클라이언트가 필요로 하는 메서드만을 가진 인터페이스를 여러 개 만드는 것이 불필요한 메서드까지 포함한 큰 인터페이스를 만드는 것보다 낫다.
역시 인터페이스 설계 즉 추상화와 관련된 원칙이다.
설명
최근에 구매한 스마트폰의 설정 기능을 살펴보자. 설정 다양한 카테고리의 메뉴들이 있는데 만약 이 메뉴들이 그냥 설정의 하위에 모두 있다고 생각해 보자. 아마도 필요한 기능을 찾기 위해 많은 스크롤과 시간이 필요할 것이다.
하지만 실제 메뉴들을 보면 '디스플레이 및 밝기', '소리 및 진동', '배터리' 등 카테고리별로 그룹화되어 있고 각 카테고리에는 그 부류의 기능들만 선언되어 있어 원하는 기능을 신속히 찾아볼 수 있다.
만약 어떤 메뉴를 수정해야 한다고 할 때에도 한통에 있을 때는 전체 메뉴가 수정 중이겠지만 분리해서 사용하면 관련 메뉴만 수정하면 된다.
간단한 리펙토링
프린트, 복사, 팩스가 가능한 복합기를 생각해 보자. 모델은 달라도 이런 기능들은 동일하므로 interface로 작성해 볼 수 있다.
그런데 만약 MFPModuleA는 저가형 제품이라 fax()는 사용할 수 없다고 한다. 그럼 아마도 fax 기능은 다음처럼 처리하지 않을까?
@Override
public void fax() {
throw new RuntimeException("지원되지 않는 기능입니다.");
}
MFPModuleB를 구매한 고객은 거의 print() 기능만을 위해 복합기를 구매한 고객이다. 하지만 사용할 때마다 필요한 기능을 찾아야 하는 번거로움이 발생한다.
이런 문제들은 Interface의 덩어리가 커서 발생한 것이다. ISP에 입각해서 인터페이스를 분리해 보자.
이제 MFPModuleA는 쓸데없이 fax() 기능을 구현할 필요가 없어졌다. MFPModuleB를 사용할 때에도 Printer Interface를 통해서 접근하면 기능을 찾아 헤맬 일이 없어진다.
DIP(Dependency Inversion Principle: 의존관계 역전의 원칙)
개요
클래스 사이에서 의존 관계를 맺을 때는 쉽게 변하는 것(구체적인 클래스)보다는 변화가 없는 것(추상 클래스, 인터페이스)에 의존해야 한다.
- 의존관계란 어떤 클래스에서 다른 클래스를 멤버 변수로 포함하고 있는 관계를 의미한다.
- 이때 쉽게 변경되는 하위 클래스에 의존하게 되면 그 클래스가 변경될 때 포함하는 클래스가 영향을 받게 돼서 의존성이 좋지 않다.
Dependency Injection과 다르다.
설명
지금 타이핑을 하고 있는 컴퓨터와 키보드를 생각해 보자. 컴퓨터는 키보드를 has-a 관계로 관계를 맺고 있기 때문에 의존 관계이다. 만약 컴퓨터에 특정 키보드 제품만 사용하도록 되어있다면 그 키보드 고장 시 다른 키보드로 대체하기가 매우 어려울 것이고 결국 컴퓨터 자체를 사용할 수가 없어진다.
이때 컴퓨터와 키보드 사이에 "타이핑할 수 있는"과 같은 추상화 레벨이 들어가게 되는 것이 DIP이다. 그러면 타이핑할 수 있는 모든 제품들을 사용할 수 있게 된다. 즉 실제로는 하위 모듈인 키보드 제품을 사용하지만 거기에 의존하는 것이 아니라 그 상위 추상화 모듈에 의존해야 한다.
간단한 리펙토링
처음의 상태는 Computer에서 KeyboardA 타입으로 keyboard를 의존하고 있는 모습이다. 비록 KeyboardA와 KeyboardB가 모두 Typeable을 구현하고 있지만 Computer는 Typeable에 의존하지 않고 KeyboardA에 의존하므로 KeyboardB로 바꾸려면 추가적인 작업이 필요하다.
이 과정에 DIP를 적용해서 Computer가 Typeable에 의존하도록 변경해 보자.
이제 Computer 입장에서 KeyboardA가 오거나 KeyboardB가 오거나 Typeable만 있으면 문제가 없게 된다. 물론 Typeable은 interface이기 때문에 구현체가 필요하고 이 의존성은 생성자 또는 setter를 통해서 주입해서 처리해야 한다.
'자바(SE)' 카테고리의 다른 글
[Thread] 01. Multi Thread 개념 (0) | 2024.07.03 |
---|---|
[java]try~with~resource의 close 호출 시점 (0) | 2024.03.14 |
[JDK] 버전별 특징 - JDK17 (0) | 2023.05.23 |
[JDK] 버전별 특징 - JDK16 (0) | 2023.05.23 |
[JDK] 버전별 특징 - JDK15 (0) | 2023.05.23 |
소중한 공감 감사합니다