객체 지향 설계의 핵심 원칙은 응집도를 높이고(High Cohesion), 결합도를 낮추라(Loose Coupling)는 고전 원칙을 재정립한 것이다. 이러한 원칙을 SOLID라는 약어로 표현하며, 각 원칙은 객체 지향 설계의 주요 개념을 다루고 있다.
SRP - 단일 책임 원칙 (Single Responsibility Principle)
"어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다" - 로버트 C. 마틴
SRP는 클래스, 모듈, 메서드 등 각 요소가 하나의 책임만 가지도록 설계하는 것. 이는 코드의 유지보수성과 이해도를 높이는 데 중요한 역할을 한다.
잘못된 예
class 사람 {
String 군번;
}
사람 로미오 = new 사람();
사람 줄리엣 = new 사람();
줄리엣.군번 = "1573042009"; // 이건?
사람형 참조 변수 줄리엣이 가진 군번 속성에 값을 할당하거나 읽어 오는 코드를 제어할 방법이 없다.
개선된 예
abstract class 사람 {}
class 남자 extends 사람 {
String 군번;
}
class 여자 extends 사람 {}
위 코드와 같이 사람 클래스를 남자 클래스와 여자 클래스로 분할하고 남자 클래스에만 군번 속성을 갖게 하는 것이 좋다.
OCP - 개방 폐쇄 원칙 (Open/Closed Principle)
"소프트웨어 엔티티는 확장에 대해서는 열려 있어야 하지만 변경에 대해서는 닫혀 있어야 한다" - 로버트 C. 마틴
위 문장을 조금 더 의역해보면 아래와 같은 문장을 이끌어 낼 수 있다.
"자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다."
OCP는 기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있도록 설계하는 것을 목표로 한다. 이를 통해 소프트웨어의 유연성과 유지보수성을 향상시킬 수 있다.
나쁜 예시
기존 코드를 수정해야만 기능을 확장할 수 있는 경우
class 보고서 {
public void 출력(String 타입) {
if (타입.equals("PDF")) {
System.out.println("PDF 형식으로 보고서를 출력합니다.");
} else if (타입.equals("HTML")) {
System.out.println("HTML 형식으로 보고서를 출력합니다.");
}
// 새로운 형식 추가 시 여기에서 else if를 추가해야 함
}
}
이 예시에서는 새로운 보고서 형식을 추가할 때마다 출력 메서드를 수정해야 한다. 이는 OCP 원칙을 위배하는 것.
좋은 예시
새로운 기능을 추가할 때 기존 코드를 수정하지 않고, 확장만으로 가능하도록 설계한 경우:
interface 보고서형식 {
void 출력();
}
class PDF보고서 implements 보고서형식 {
public void 출력() {
System.out.println("PDF 형식으로 보고서를 출력합니다.");
}
}
class HTML보고서 implements 보고서형식 {
public void 출력() {
System.out.println("HTML 형식으로 보고서를 출력합니다.");
}
}
class 보고서 {
private 보고서형식 형식;
public 보고서(보고서형식 형식) {
this.형식 = 형식;
}
public void 출력() {
형식.출력();
}
}
이 예시에서는 새로운 형식의 보고서를 추가하려면 보고서형식 인터페이스를 구현하는 새로운 클래스를 추가하고, 보고서 클래스에 해당 형식의 인스턴스를 주입하면 된다. 기존 코드 수정 없이 새로운 기능을 확장할 수 있으므로 OCP 원칙을 잘 준수한 사례이다.
class Excel보고서 implements 보고서형식 {
public void 출력() {
System.out.println("Excel 형식으로 보고서를 출력합니다.");
}
}
// 새로운 보고서 형식 추가 시 기존 코드 수정 없이 확장 가능
보고서 보고서 = new 보고서(new Excel보고서());
보고서.출력();
이렇게 하면 새로운 보고서 형식이 추가되더라도 기존 코드를 변경하지 않고 확장할 수 있어 OCP 원칙을 잘 지키게 된다.
LSP - 리스코프 치환 원칙 (Liskov Substitution Principle)
"서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다" - 로버트 C. 마틴
LSP는 하위 클래스가 상위 클래스의 역할을 완전히 대체할 수 있어야 한다는 원칙이다. 이를 통해 상속 구조에서의 일관성과 안정성을 보장할 수 있다.
객체 지향에서의 상속은 다음의 조건을 만족해야 한다.
- 하위 클래스 is a kind of 상위 클래스 - 하위 분류는 상위 분류의 한 종류다.
- 구현 클래스 is able to 인터페이스 - 구현 분류는 인터페이스할 수 있어야 한다.
※"인터페이스할 수 있어야한다." 는 인터페이스명에 따라 읽으면 쉽게 이해할 수 있다. (Clonable - 복제할수 있어야 한다.
Runnable - 실행할 수 있어야 한다.)
위 두 개의 문장대로 구현된 프로그램이라면 이미 리스코프 치환 원칙을 잘 지키고 있다 할 수 있다.
다음 안좋은 예시를 보자.
아버지 춘향이 = new 딸();
뭔가 이상하다. 딸을 하나 낳아서 이름을 춘향이라 한 것까지는 좋은데 춘향이에게 아빠의 역할을 맡기고 있다.
다음은 좋은 예시를 보자.
동물 뽀로로 = new 펭귄();
논리적인 흠이 없다. 펭귄 한 마리가 태어나 뽀로로라 이름짓고 동물의 행위(메서드)를 하게 하는데 전혀 이상함이 없다.
즉, 리스코프 치환 원칙을 만족하는 것이다.
위와 같은 계층도/조직도인 경우에는 딸이 아버지, 할아버지의 역할을 하는 것이 논리에 맞지 않음을 알 수 있다.
즉, 리스코프 치환 원칙을 위반하는 것이다.
위와 같은 분류도의 경우는 하위에 존재하는 것들은 상위에 있는 것들의 역할을 하는데 전혀 문제가 없다. 고래가 포유류 또는 동물의 역할을 하는 것은 전혀 문제가 되지 않는다.
즉, 리스코프 치환 원칙을 잘 지켰다고 할 수 있다.
ISP - 인터페이스 분리 원칙 (Interface Segregation Principle)
"클라이언트는 자신이 사용하지 않는 메서드의 의존 관계를 맺으면 안 된다" - 로버트 C. 마틴
ISP는 인터페이스를 클라이언트에 맞게 분리하여, 클라이언트가 불필요한 메서드에 의존하지 않도록 설계하는 원칙이다. 이는 단일 책임 원칙과도 밀접한 관련이 있다.
위와 같은 남자 클래스가 있다고 하자.여기에 단일 책임 원칙을 적용하면 다음과 같다.
단일 챔익 원칙에서 제시한 해결책은 남자 클래스를 토막내어 하나의 역할(책임)만 하는 다수의 클래스로 분할하는 것이었다. 여기서 이 방법말고 선택할 수 있는 방법이 바로 인터페이스 분할 원칙이다.
위와 같이 여자친구를 만날 때는 남자친구 역할만 할 수 있게 인터페이스로 제한하고, 어머니와 있을 때는 아들 인터페이스로 제한 하는 방법으로 분할하는 것이 인터페이스 분할 원칙의 핵심이다.
인터페이스 분할 원칙을 이야기할 때 항상 함께 등장하는 원칙 중 하나로 인터페이스 최소주의 원칙이라는 것이 있다.
인터페이스를 통해 메서드를 외부에 제공할 때는 최소한의 메서드만 제공하라는 것이다.
나쁜 예시
하나의 큰 인터페이스를 여러 클라이언트가 사용해야 하는 경우
interface 다기능프린터 {
void 인쇄();
void 팩스();
void 스캔();
void 복사();
}
class 단순프린터 implements 다기능프린터 {
public void 인쇄() {
System.out.println("인쇄합니다.");
}
public void 팩스() {
throw new UnsupportedOperationException("팩스를 지원하지 않습니다.");
}
public void 스캔() {
throw new UnsupportedOperationException("스캔을 지원하지 않습니다.");
}
public void 복사() {
throw new UnsupportedOperationException("복사를 지원하지 않습니다.");
}
}
이 예시에서는 단순프린터 클래스가 다기능프린터 인터페이스의 메서드 중 사용하지 않는 메서드들을 구현해야 한다. 이는 ISP 원칙을 위배하는 것.
좋은 예시
클라이언트가 자신이 필요로 하는 메서드만 의존하도록 인터페이스를 분리한 경우
interface 인쇄기능 {
void 인쇄();
}
interface 팩스기능 {
void 팩스();
}
interface 스캔기능 {
void 스캔();
}
interface 복사기능 {
void 복사();
}
class 단순프린터 implements 인쇄기능 {
public void 인쇄() {
System.out.println("인쇄합니다.");
}
}
class 다기능프린터 implements 인쇄기능, 팩스기능, 스캔기능, 복사기능 {
public void 인쇄() {
System.out.println("인쇄합니다.");
}
public void 팩스() {
System.out.println("팩스합니다.");
}
public void 스캔() {
System.out.println("스캔합니다.");
}
public void 복사() {
System.out.println("복사합니다.");
}
}
이 예시에서는 단순프린터 클래스가 인쇄기능 인터페이스만 구현하여 필요한 기능만을 가지도록 하였다.다기능프린터 클래스는 필요한 모든 인터페이스를 구현하여 모든 기능을 제공한다. 이는 ISP 원칙을 잘 준수했다고 할 수 있다.
DIP - 의존 역전 원칙 (Dependency Inversion Principle)
"고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 추상화된 것에 의존해야 한다" - 로버트 C. 마틴
DIP는 구체적인 구현보다 추상화된 인터페이스나 상위 클래스에 의존하도록 설계하는 원칙이다. 이를 통해 변화에 유연하고 안정적인 구조를 유지할 수 있다.
자동차와 스노우타이어 사이에는 의존 관계가 있다. 자동차는 한 번 사면 몇년은 타야 하는데 스노우타이어는 계절이 바뀌면 일반 타이어로 교체해야 한다.
이런 경우 스노우타이어를 일반타이어로 교체할 때 자동차는 그 영향에 노출돼 있음을 알 수 있다.
위와 같이 자동차가 구체적인 타이어들(스노우타이어,일반타이어 ... )이 아닌 추상화된 타이어 인터페이스에만 의존하게 함으로써 스노우타이어에서 일반타이어로, 또는 다른 구체적인 타이어로 변경돼도 자동차는 그 영향을 받지 않는 형태로 구성된다.
이처럼 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 변하기 쉬운 것의 변화에 영향받지 않게 하는 것이 의존 역전 원칙이다.
나쁜 예시
상위 모듈이 하위 모듈에 직접 의존하는 경우
class LightBulb {
public void turnOn() {
System.out.println("LightBulb: Bulb turned on...");
}
public void turnOff() {
System.out.println("LightBulb: Bulb turned off...");
}
}
class Switch {
private LightBulb bulb;
public Switch(LightBulb bulb) {
this.bulb = bulb;
}
public void operate(String command) {
if (command.equals("ON")) {
bulb.turnOn();
} else if (command.equals("OFF")) {
bulb.turnOff();
}
}
}
이 예시에서 Switch 클래스는 LightBulb 클래스에 직접 의존하고 있다. 만약 LightBulb 클래스가 변경되면 Switch 클래스도 변경되어야 한다.
좋은 예시
상위 모듈이 추상화에 의존하고, 구체적인 구현은 이를 구현하도록 하는 경우
interface Switchable {
void turnOn();
void turnOff();
}
class LightBulb implements Switchable {
public void turnOn() {
System.out.println("LightBulb: Bulb turned on...");
}
public void turnOff() {
System.out.println("LightBulb: Bulb turned off...");
}
}
class Fan implements Switchable {
public void turnOn() {
System.out.println("Fan: Fan turned on...");
}
public void turnOff() {
System.out.println("Fan: Fan turned off...");
}
}
class Switch {
private Switchable device;
public Switch(Switchable device) {
this.device = device;
}
public void operate(String command) {
if (command.equals("ON")) {
device.turnOn();
} else if (command.equals("OFF")) {
device.turnOff();
}
}
}
이 예시에서 Switch 클래스는 Switchable 인터페이스에 의존하며, LightBulb와 Fan 클래스는 Switchable 인터페이스를 구현한다. 이를 통해 Switch 클래스는 구체적인 구현에 의존하지 않게 되어 유연성과 재사용성이 높아진다.
정리
SOLID를 이야기할 때 빼놓을 수 없는 것이 SoC다. SoC는 관심사의 분리(Separation Of Concerns)의 머리글자다. 관심이 같은 것끼리는 하나의 객체 안으로 또는 친한 객체로 모으고, 관심이 다른 것은 가능한 한 따로 떨어져 서로 영향을 주지 않도록 분리하라는 것이다. 하나의 속성, 하나의 메서드, 하나의 클래스, 하나의 모듈, 또는 하나의 패키지에는 하나의 관심사만 들어 있어야 한다는 것이 SoC다. SoC를 적용하면 자연스럽게 단일 책임 원칙,인터페이스 분리 원칙, 개방 폐쇄 원칛에 도달하게 된다. 스프링 또한 SoC를 통해 SOLID를 극한까지 적용하고 있다.
SOLID에 대해 꼭 기억해두자
- SRP(단일 책임 원칙): 어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.
- OCP(개방 폐쇄 원칙): 자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다.
- LSP(리스코프 치환 원칙): 서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다.
- ISP(인터페이스 분리 원칙): 클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다.
- DIP(의존 역전 원칙): 자신보다 변하기 쉬운 것에 의존하지 마라.
이 글은 스프링 입문을 위한 자바 객체 지향의 원리와 이해를 참고하여 작성하였습니다.
스프링 입문을 위한 자바 객체 지향의 원리와 이해 | 김종민 - 교보문고
스프링 입문을 위한 자바 객체 지향의 원리와 이해 | 이 책은 자바에서 스프링으로 나아가기 위한 연결 고리를 제공한다.
product.kyobobook.co.kr
'종합' 카테고리의 다른 글
[Spring] 스프링이 사랑한 디자인 패턴들 1 (어댑터, 프록시, 데코레이터 , 싱글톤) (0) | 2024.08.09 |
---|