[Java] 객체 지향 설계 5원칙 - SOLID
모든 일은 많은 사람들에 의해 수 차례 반복되고, 연구되면서 여러가지의 방법론이나 원칙이 성립된다. 그 중에서 가장 효율적이고 합리적인 방법론이 정립되고, 그 일을 수행하는데 일반적으로 적용하는 대표 방법론 또는 원칙이 된다.
객체 지향 언어를 이용해 객체 지향 프로그램을 올바르게 설계해 나가는 방법이나 원칙이 존재한다.
로버트 C. 마틴(Robert C. Martin)이 2000년대 초반 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙으로 제시한 것을 마이클 페더스(Michael Feathers)가 두문자어로 소개한 SOLID다.
- SRP(Single Responsibility Principle) : 단일 책임 원칙
- OCP(Open Closed Principle) : 개방 폐쇄 원칙
- LSP(Liskov Substitution Principle) : 리스코프 치환 원칙
- ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
- DIP(Dependency Inversion Principle) : 의존 역전 원칙
위 원칙들은 객체 지향의 4대 특성을 올바르게 사용하는 방법이라고도 할 수 있다.
(참고 : 2022.12.14 - [프로그래밍 언어 공부/Java] - [Java] 객체 지향의 4대 개념 추상화/상속/다형성/캡슐화)
또한 응집도는 높이고 결합도는 낮추라는 고전 원칙이 결국은 핵심이 된다.
SOLID를 소프트웨어에 잘 녹여내면 좀 더 유지보수하기 쉽고, 유연하고, 확장이 쉬우며, 논리적으로 정연하다. 또한 디자인 패턴과 스프링 프레임워크의 근간이 된다.
1. SRP(Single Responsibility Principle) : 단일 책임 원칙
“There should never be more than one reason for a class to change”
(의역) “어떤 클래스를 변경해야 하는 이유는 오직 하나 뿐이어야 한다” - 로버트 C. 마틴
작성된 클래스는 하나의 기능만 가지며 클래스가 제공하는 모든 서비스는 그 하나의 책임을 수행하는 데 집중되어 있어야 한다는 원칙이다. 이는 어떤 변화에 의해 클래스를 변경해야 하는 이유는 오직 하나 뿐이어야 함을 의미하기도 한다.
아래와 같이 남자 클래스에 의존하고 있는 다양한 클래스가 있다고 가정해 보자.
언뜻 봐도 남자 클래스는 역할과 책임이 너무 많다. 당장 여자친구와 헤어지면 키스하기()
나 기념일챙기기()
같은 메서드는 필요가 없어진다. 또한 직장상사로써 출근하기()
나 아부하기()
외의 메서드들은 사용할 일이 없다. 더군다나 사원이면서 소대원인 상황은 있을 수 없다.
위 클래스에 SRP를 적용해 책임을 분리해 보자.
남자라는 클래스를 각 역할과 책임에 따라 네 개의 클래스로 분리했다. 역할과 클래스 명도 일치해 이해하기도 편해졌다.
또 다른 예를 들어보자. 남자는 무조건 군대를 가야하고, 여자는 절대 가지 않는 나라가 있다고 가정했다.
class 사람 {
String 군번;
String 주민등록번호;
// ...
}
// ...
사람 로미오 = new 사람();
사람 줄리엣 = new 사람();
줄리엣.군번 = "22-70008888"; // 잘못 작성된 코드일 확률이 매우 높음
줄리엣.군번 = "22-70008888";
코드는 프로그래머의 실수로 작성된 코드일 것이다. 하지만 현재의 클래스는 구조적으로 이를 제제할 방법이 없다. 사람이라는 클래스가 남자와 여자의 책임을 모두 가지고 있기 때문이다. SRP를 적용하여 클래스를 다시 설계해 보자.
class 사람 {
String 주민등록번호;
// ...
}
class 남자 extends 사람 {
String 군번;
// ...
}
class 여자 extends 사람 {
// ...
}
// ...
사람 로미오 = new 남자();
사람 줄리엣 = new 사람();
줄리엣.군번 = "22-70008888"; // 에러
사람 클래스를 남자와 여자 클래스로 분리한 뒤, 각자 사람 클래스를 상속 받도록 설계했다. 공통 속성은 사람 클래스가 갖게 하고, 군번
속성은 남자 클래스만 가지게 했다. 결과적으로 여자 클래스는 사용하지 않는 군번
속성을 가지지 않으므로 프로그래머의 실수를 예방함과 동시에 응집도도 올라갔다.
2. OCP(Open Closed Principle) : 개방 폐쇄 원칙
“You Should be able to extend a classes behavior, without modifying it”
(의역) ”클래스 형태를 변경하지 않고 확장할 수 있어야 한다” - 로버트 C. 마틴
소프트웨어의 구성요소(컴포넌트, 클래스, 모듈, 함수)는 확장에는 열려있고, 변경에는 닫혀있어야 한다는 원리다. 이를 좀 더 쉽게 풀어 보면, 자신의 확장에는 열려있고, 주변의 변화에 대해서는 닫혀 있어야 한다는 의미다.
소프트웨어 설계 관점으로는 변경을 위한 비용은 가능한 줄이고 확장을 위한 비용은 가능한 극대화 해야 한다는 의미로, 요구사항의 변경이나 추가사항이 발생하더라도, 기존 구성요소는 수정 없이 쉽게 확장해서 재사용할 수 있어야 한다는 뜻이다.
OCP를 적용하는 방법은 아래와 같다.
- 변경(확장)될 것과 변하지 않을 것을 엄격히 구분한다.
- 이 두 모듈이 만나는 지점에 인터페이스를 정의한다.
- 구현에 의존하기보다 정의한 인터페이스에 의존하도록 코드를 작성한다.
예를 들어, 한 운전자가 마티즈(수동 기어)을 타고 다녔다고 하자.
그러다 어느 날 좀 더 편한 소나타(자동 기어)로 차를 바꿨다.
위의 경우 운전자는 차량의 변화에 따라 기어 변경 방법이 달라진다. 또한 키로시동걸기()
는 두 차량에 중복 정의를 해야 하는 비효율도 생긴다. 위 예시에서는 생략되었지만 속성의 경우 대부분이 중복되는 속성일 것이며, 만약 자동차의 종류가 더 많아지게 되면 그 만큼 비효율적인 코드가 비약적으로 증가할 것이다.
따라서 OCP를 적용해 아래와 같이 클래스 설계를 변경하는 것이 바람직하다.
운전자는 차량의 변화에 관계 없이 시동걸기()
와 기어조작()
을 통해 차량을 사용할 수 있게 됐다. 또한 버튼으로 시동을 거는 자동차 등, 다양한 자동차가 생긴다고 해도 메서드 오버라이딩을 통해 차량에 맞게 재구현을 한 뒤, 공통 속성이나 메서드는 상속받으면 된다.
즉, 운전자는 자동차의 변화에는 닫혀 있고, 자동차는 자신의 확장에 대해서는 열려 있게 되는 것이다.
또 다른 예로 JDBC 인터페이스가 있다.
JDBC는 데이터베이스의 종류에 관계 없이 프로그래머에게 동일한 인터페이스를 제공함으로써 프로그래머에게는 데이터베이스 변화에 닫혀 있게 되고, JDBC는 자신의 확장에 열려 있게 된다.
이로써 객체 지향 특성 중 상속과 다형성을 활용해 재사용성과 유지 보수를 극대화 할 수 있게 된다.
3. LSP(Liskov Substitution Principle) : 리스코프 치환 원칙
“Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it”
(의역) “서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다” - 로버트 C. 마틴
이름은 어려워 보이지만 사실 상속(extends
, implemets
)의 원칙을 잘 지키는 것이 LSP를 잘 지키고 있는 것이다.
- 하위 클래스 is a kind of 상위 클래스 : 하위 클래스는 상위 클래스의 한 종류이다.
- 구현 클래스 is able to 인터페이스 : 구현 클래스는 인터페이스할 수 있어야 한다.
예를 들어 가족 이나 회사의 조직도는 리스코프 치환 원칙을 어기는 사례이다.
- 아빠는 할아버지의 한 종류이다. (x)
- 딸은 아빠의 한 종류이다. (x)
- 대리는 부장의 한 종류이다. (x)
이보다는 동물 종의 분류도같은 사례가 올바르다.
- 포유류는 동물의 한 종류이다. (o)
- 참새는 조류의 한 종류이다. (o)
또 다른 예로 자바의 컬렉션 프레임워크가 있다.
언뜻 보면 OCP를 훌륭하게 구현한 예제 같기도 한데, LSP는 OCP를 구성하는 구조가 되기 때문이다.
LSP는 다형성과 확장성을 극대화 하려는 노력으로, 이를 위해서는 하위 클래스를 사용하는 것보다는 상위의 클래스(인터페이스)를 사용하는 것이 더 좋다. 일반적으로 선언은 기반 클래스(상위 클래스)로 생성은 구체 클래스(하위 클래스)로 대입하는 방법을 사용한다.
이를 토대로 로버트 C. 마틴의 말을 다시 의역해 보면,
“하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 한다.”
(참고로 LSP는 바바라 리스코프가 자료 추상화와 계층 (Data abstraction and hierarchy) 이라는 제목으로 기조연설을 한 1987년 컨퍼런스에서 처음 소개한 내용이라고 한다.)
4. ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
“Clients should not be forced to depend upon interfaces that they do not use”
(의역) “클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다” - 로버트 C. 마틴
클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다는 원리로, 어떤 클래스가 다른 클래스에 종속될 때에는 가능한 최소한의 인터페이스만 사용해야 한다는 원칙이다.
만약 어떤 클래스를 이용하는 클라이언트가 여러 개고, 이들이 해당 클래스의 특정 부분 집합만을 이용한다면, 이들을 각각 인터페이스로 빼내어 클라이언트가 기대하는 메세지만을 전달할 수 있도록 해야 한다.
위 SRP 예제에서 남자 클래스를 각 역할에 맞게 여러 개의 클래스로 분리했는데, ISP를 활용해 해결할 수도 있다.
SRP가 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조한다. 하지만 ISP는 어쩔 수 없이 특정 클래스 혹은 인터페이스가 여러 책임을 갖는 것을 인정한다.
결론적으로 SRP와 ISP는 같은 문제에 대한 두 가지 다른 해결책이라고 볼 수 있다.
5. DIP(Dependency Inversion Principle) : 의존 역전 원칙
“High level modules should not depend upon low level modules. Both should depend upon abstractions.” ”Abstractions should not depend upon details. Details should depend upon abstractions.”
(의역) “고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다” ”추상화된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다” - 로버트 C. 마틴
말이 어려울 수 있는데, 최대한 쉽게 풀어 얘기하면 “자신보다 변하기 쉬운 것에 의존하지 마라”는 원칙이다.
예를 들어 자동차가 스노우 타이어에 의존한다고 해보자.
스노우 타이어는 자동차에 비해 계절에 따라 자주 바뀌게 되는 저차원의 모듈이다. 만약 일반 타이어나 산악용 타이어로 바꿔야 할 경우 자동차는 의존 관계를 다시 맺어줘야 한다.
객체 지향에서는 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 변하기 쉬운 것의 변화에 영향을 받지 않게 한다.
자동차는 위 그림처럼 스노우타이어나 일반탕이어는 알 필요가 없다. 그저 타이어(interface)를 바라봐야 하고, 의존해야 한다. 즉, 타이어의 구현이 아닌 타이어의 역할에 의존해야 한다는 것이다.
Java 코드 상에서 DIP를 위반하는 경우는 인터페이스의 구현체를 직접 생성해서 사용했을 때다.
class Car {
private Tire t = new SnowTire();
// ...
}
위 코드를 보면 Car
클래스 내부에서 필드 값으로 Tire
인터페이스 타입을 선언했다. 그런데 구현체인 SnowTire
를 직접 new
로 생성해 객체를 초기화 했는데, 이러면 Car는 Tire
와 SnowTire
둘 다 의존하게 되는 것이다. 만약 타이어를 일반 타이어로 교체하고 싶다면 구현 클래스 NormalTire
의 객체를 생성해주는 코드로 변경해야 하므로 OCP도 깨지게 된다.
위 문제를 해결하려면 제 3자가 이 과정을 끼어들어야 하는데, Spring에서는 그 역할을 컨테이너가 해준다. 어떤 구현체를 사용할 지 Spring에 설정해 두면, 컨테이너가 미리 생성해 둔 구현체 객체(Bean)를 주입시켜 줌으로써 코드를 보면, Car
는 Tire
만 알고 있어도 되는 것을 알 수 있다.
class Car {
private final Tire t;
@Autowired
public Car(Tire t) {
this.t = t; // 실제로는 구현체 객체가 주입
}
// ...
}
이 예제 역시 OCP와 매우 유사하다고 느낄 수 있는데, 객체 지향 특성인 상속과 다형성을 활용한 방법이기 때문에 어찌 보면 당연하다. 때문에 SOLID에서 가장 중요한 걸 뽑자면, OCP와 DIP일 것이다.
Reference
- 스프링 입문을 위한 자바 객체 지향의 원리와 이해 - 김종민
- 인프런 강의 [스프링 핵심 원리 - 기본편] - 김영한
- https://www.nextree.co.kr/p6960/