만약, 계산 클래스를 통해 GUI를 가지는 계산기 프로그램을 개발한다.
그런데 중간에 GUI 관련 코드를 계산 클래스에 넣어버리면, 계산 클래스는 계산과 GUI라는 두 가지 책임을 지게 된다.
GUI 관련 수정 사항이 발생하게 되면, 별 상관도 없어보이는 계산 클래스를 고치게 된다.
결국 클래스의 목적이 모호해지고, 기능을 수정할 때 영향을 받는 범위도 커져서 유지보수가 힘들어지며,
작성한 본인조차도 이게 정확히 뭐하는 클래스인지 명확히 설명할 수가 없는 스파게티 코드가 되어버린다.
객체는 오직 하나의 책임을 가져야 한다. (객체는 오직 하나의 변경의 이유만을 가져야 한다.)
사칙연산 함수를 가지고 있는 계산 클래스는 오직 사칙연산 기능만을 책임진다.
클래스의 목적을 명확히 함으로써 구조가 난잡해지거나 수정 사항이 불필요하게 넓게 퍼지는 것을 예방하고 기능을 명확히 분리할 수 있게 한다.
학생 / 학생 DAO / 성적표 / 출럭부 / ...
이런식으로 클래스를 분리하여 관리하면, 변경에 유연하게 대처 가능하다.
public class Student {
public void getCourse() { // 강의 조회
}
public void addCourse() { // 강의 추가
}
public void save() { // DB 에 학생 정보 저장
}
public Student load() { // DB 에 저장된 학생 정보 불러오기
}
public void printOnReportCard() { // 성적표 출력
}
public void printOnAttendanceBook() { // 출석부 출력
}
}
Student 클래스 하나가 많은 책임(조회, 추가, 출력 ...)을 지고 있다.
→ 일부분을 수정하게 될 때 모든 데이터가 변경 대상이 되어버린다.
→ 즉, 변화에 민감한 클래스가 되어버린다.
강의 조회(getCourse()) 와 학생 정보 불러오기(load()) 가 연결될 확률이 높다.
→ 코드 결합을 방지하기 위해, 모든 기능을 테스트해야 하는 번거로움
→ 즉, 유지 보수 하기에 어려운 코드가 되어버린다.
만약, 어떤 게임 캐릭터를 만든다.
이런저런 공통사항을 생각하며 메서드와 필드를 정의한다.
이 중에 이동 메서드는 대상 위치를 인수로 받아 속도에 따라 대상 위치까지 길찾기 인공지능을 사용해 이동한다.
이를 위해, 이동 메서드에서 이동 패턴을 나타내는 코드를 별도의 메서드로 분리하고, 구현을 하위 클래스에 맡긴다.
그러면 캐릭터 클래스에서는 이동 패턴 메서드만 재정의하면 유닛 클래스의 변경 없이 색다른 움직임을 보여줄 수 있다.
'캐릭터' 클래스의 '이동' 메서드는 수정할 필요조차 없다(수정에 대해선 폐쇄).
그냥 캐릭터 클래스의 이동 패턴 메서드만 재정의하면 된다(확장에 대해선 개방).
기존 코드를 변경하지 않고, 기능을 수정/추가 할 수 있도록 설계해야 한다.
객체는 확장에 대해서는 개방적이고, 수정에 대해서는 폐쇄적이어야 한다.
즉, 객체 기능의 확장을 허용하고, 스스로의 변경은 피해야 한다.
// play() 메소드를 인터페이스로 분리시킨다.
interface playAlgorithm {
public void play();
}
// 분리 1
class Wav implements playAlgorithm {
@Override
void play() {
System.out.println("play wav")
}
}
// 분리 2
class Mp3 implements playAlgorithm {
@Override
void play() {
System.out.println("play Mp3")
}
}
// SoundPlay 클래스
class SoundPlay {
private playAlgorithm file;
// playAlgorithm 인터페이스를 멤버 변수로 둔다
public void setFile (playAlgorithm file) {
this.file = file;
}
// play() 함수 : playAlgorithm 인터페이스를 상속받아서 구현 (분리 1 or 분리 2 가 실행됨)
public void play() {
file.play();
}
}
public class Client {
public static void main(String[] args) {
SoundPlayer sp = new SoundPlayer();
sp.setFile(new Wav());
sp.setFile(new Mp3());
sp.play();
}
}
→ 이런 설계를 '전략 패턴'이라고 한다.
참고: 디자인 패턴 - (4) 행위 패턴 - 9) 스트래티지 (Strategy, 전략)
class SoundPlayer {
void play() {
System.out.println("play wav");
}
}
public class Client {
public static void main(String[] args) {
SoundPlayer sp = new SoundPlayer();
sp.play();
}
}
SoundPlayer 클래스는 wav 파일로 음악을 재생할 수 있다.
그러나,
요구사항이 음악을 mp3 파일로 재생하도록 변경되었을 경우, SoundPlayer 클래스의 play() 메소드를 수정해야 한다.
만약, 마우스를 사용한다
볼 마우스든 광 마우스든 기본적인 조작 방식은 동일하다.(마우스를 바닥에 붙여서 사용한다 등...)
그러나
오른쪽/왼쪽 버튼 대신 옆쪽 버튼을 사용하는 펜 마우스를 처음으로 접하게 될 경우, 사용자는 이상을 호소할 것이다.
이것이 LSP를 전혀 지키지 못한 경우다.
(마우스 : 부모 클래스 / 볼 마우스, 광마우스, 펜 마우스 : 자식 클래스들)
자식 클래스는 언제나 자신의 부모 클래스를 대체할 수 있다.
즉, 부모 클래스가 들어갈 자리에 자식 클래스를 넣어도 계획대로 잘 작동해야 한다.
이를 지키지 않으면, 부모 클래스 본래의 의미가 변해서 is-a 관계가 망가져 다형성(하나의 변수, 또는 함수가 상황에 따라 다른 의미로 해석될 수 있는 것)을 지킬 수 없게 된다.
부모 클래스 : 가방
public class Bag {
private double price;
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
}
자식 클래스 : 할인된 가방
public class DiscountedBag extends Bag {
private double discountRate;
public void setDiscountRate(double discountRate) {
tihs.discountRate = discountRate;
}
public void applyDiscount(int price) {
super.setPrice(price - (int)(discountRate * price));
}
}
Bag 를 사용하고 있는 부분을 DiscountedBag 로 대체해도 OK
DiscountedBag 에서 사용하는 applyDiscount() 메소드를 Bag 에서는 사용 X
→ 자식 클래스가 부모 클래스에 변화(오버라이딩, 기능 추가 ...)를 줘서는 안된다.
복합기를 사용하고자는 사람들이 있다.
(A는 복사를, B는 프린트를, C는 팩스 기능을 사용하고자 함)
다양한 복합기의 기능 중에서 A 에게는 복사 기능만이 작동하면 된다.
A가 사용하지 않는 기능(프린트, 팩스)에 대해서는 복사 기능에 아무런 영향을 받지 않는다.
한 클래스는 자신이 사용하지 않는 인터페이스에 대해서는 구현하지 말아야 한다.
하나의 일반적인 인터페이스보다는, 여러개의 구체적인 인터페이스가 낫다.
클라이언트에서 사용하지 않는 메서드는 사용해선 안 된다. 그러므로 인터페이스를 다시 작게 나누어 만든다.
ISP를 잘 지키면 OCP도 잘 지키게 될 확률이 비약적으로 증가한다.
상위 클래스가 풍성해질수록, 하위 클래스에게는 기능 확장 ↑ + 코드 중복 ↓
interface ISpeakable {
}
interface IRun {
}
interface Digable {
}
인터페이스를 나눠서, 각 동물이 사용하는 인터페이스만 상속받도록 한다.
interface IAnimal {
void Speak();
void Run();
void Dig();
}
강아지, 고양이는 울고(speak), 달리고(run), 땅을 파기도(dig) 한다.
그러나, 새 일 경우 땅을 파지는(dig) 않을 것이다.
자동차는 추상적인 타이어 인터페이스에만 의존하게 한다.
만약, 자동차가 구체적인 타이어(스노우타이어, 일반타이어, 광폭타이어)에 의존하게 될 경우, 타이어의 수정에 따라 자동차도 수정이 필요해진다.
결국 잦은 수정이 발생해버린다.(변하기 쉬워짐)
의존 관계를 맺을 때, 변화하기 쉬운 것(구체적인 것, 구체화된 클래스)보다는 변화하기 어려운 것(추상적인 것, 추상 클래스/인터페이스)에 의존해야 한다.
추상성이 높고 안정적인 고수준의 클래스는 구체적이고 불안정한 저수준의 클래스에 의존해서는 안 된다.
일반적으로 객체지향의 인터페이스를 통해서 이 원칙을 준수할 수 있게 된다.
주의! IoC(Inversion of control)는 제어의 흐름에 대한 개념이지만, DIP는 클래스 사이의 의존성에 대한 개념이다.
// 새로운 오디오 파일 포맷을 실행하기 위해, 새로운 SoundPlayer 클래스 생성
class SoundPlayer {
private playAlgorithm file;
public void setFile(playAlgorithm file) {
this.file = file;
}
// play() 인터페이스를 상속받아 구현
public void play() {
file.play();
}
}
public class Client {
public static void main(String[] args) {
SoundPlayer sp = new SoundPlayer();
sp.setFile(new Wav()); // setFile 을 통해, file 멤버 변수에 주입
sp.setFile(new Mp3());
sp.play();
}
}
참고: [Java] 객체지향 5대 원칙 SOLID
참고: [Java] 객체지향 설계 5원칙 - SOLID란 무엇일까?
참고: 객체지향 개발 5대 원칙 SOLID