객체지향 설계 5대원칙
결합도를 낮추고 응집도를 높일 수 있는,
객체지향적 설계 관점에서 지켜야 할 원칙들을 재정립 !
SOLID
- SRP : Single Responsibility Principle 단일 책임 원칙
- OCP : Open Closed Principle 개방 폐쇄 원칙
- LSP : Liskov Substitution Principle 리스코프 치환 원칙
- ISP : Interface Segregation Principle 인터페이스 분리 원칙
- DIP : Dependency Inversion Principle 의존성 역전 원칙
SRP
단일 책임 원칙
- 하나의 역할을 책임지는 코드를 함께 묶어주는 것 →
응집도
- 서로 다른 역할을 맡은 코드는 분리하여야 한다.
- 모든 클래스는 단 하나의 책임만을 가져야 함
- 클래스에 정의되어 있는 모든 기능은 하나의 책임을 수행하는 데에 집중되어야 한다.
한 클래스에서 여러 책임이 결합되어 있다면,
하나의 책임을 수정할 때 다른 곳에서 사이드 이펙트가 터질 수 있다.
하나를 수정하게 되면 연쇄적으로 전체를 수정해야 하는 문제점이 발생할 수도 있기 때문에
단일 책임 원칙을 지키자 !
예를 들어 연결을 맺고 메시지를 주고 받는 무전기 객체가 있다고 가정해보자.
interface Radio {
void connect(String address);
void disconnect();
void sendMessage(String message);
String receiveMessage();
}
연결을 맺고 끊는 부분과, 메시지를 주고 받는 부분은 큰 연관성이 없다.
하지만 이렇게 한 클래스에 같이 존재하면 한 부분의 코드를 수정할 때 모두 고쳐야 하는 문제가 생길 수 있다.
관련된 책임별로 분리해서 클래스를 쪼개보자 !
interface Connection {
void connect(String address);
void disconnect();
}
interface Channel {
void sendMessage(String message);
String receiveMessage();
}
OCP
개방 폐쇄 원칙
- 소프트웨어 요소는 확장에 대해 열려 있고, 수정에 대해 닫혀 있어야 함
- 자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 함
- 어떤 클래스 일부분을 변경하는 것이 그 객체에 의존하는 모듈들의 단계적인 변경을
불러 일으킨다면 ?- 프로그램의 변경이 더 이상의 수정을 유발하지 않도록 만들어야 함 !
개방 폐쇄 원칙을 잘 지키는 방법이 뭐가 있을까 ? → 추상화를 이용?
기능을 인터페이스로 명시해두면 선언부가 고정되므로 변경에 닫혀있게 됨.
확장이 필요한 경우에는 상속을 통해 자식클래스를 만들어서 사용
자바 어플리케이션에서 JDBC 메니저를 사용하기 위해 설계된 구조.
예를 들어 Oracle DB 에서 어떤 수정이 생겨도 Java Application 에서는 수정할 코드가 없다 !
예를 들어 사칙연산 프로그램을 생각해보자.
interface Operation {}
// getter, setter 생략
class Add implements Operation {
private int left;
private int right;
private int result;
Add(int left, int right) {
this.left = left;
this.right = right;
}
}
class Sub implements Operation {
private int left;
private int right;
private int result;
Sub(int left, int right) {
this.left = left;
this.right = right;
}
}
class Calculator {
public void calculator(Operation op) {
if (op instanceof Add) {
Add add = (Add) op;
add.setResult(add.getLeft() + add.getRight());
} else if (op instanceof Sub) {
Sub sub = (Sub) op;
sub.setResult(sub.getLeft() - sub.getRight());
}
}
}
더하기와 빼기 기능은 정상 동작하지만 곱하기와 나누기 기능을 추가한다고 생각해보자.
Operation
인터페이스를 상속받아서 Mul
, Div
와 같은 클래스는 쉽게 만들어낼 수 있다.
하지만 Calculator
클래스의 calculator
메소드에도 수정이 필요하다.
if 분기를 더 걸어서 곱하기와 나누기에 대한 처리도 해주어야 한다.
아래처럼 코드를 변경해보자.
interface Operation {
void execute();
}
// getter, setter 생략
class Add implements Operation {
private int left;
private int right;
private int result;
public void execute() { result = left + right; }
Add(int left, int right) {
this.left = left;
this.right = right;
}
}
class Sub implements Operation {
private int left;
private int right;
private int result;
public void execute() { result = left - right; }
Sub(int left, int right) {
this.left = left;
this.right = right;
}
}
class Calculator {
public void calculator(Operation op) {
if (op == null) {
System.out.println("연산 실행 불가");
} else {
op.execute();
}
}
}
인터페이스에 execute
메소드를 추가했다.
Calculator
클래스 입장에서는 곱하기나 나누기 기능이 추가되더라도 클래스 변경없이
확장이 유연해진다 !
하나의 변화가 다른 곳에 연쇄적으로 변화를 유발하는 것을 방지할 수 있는 것이
개방 폐쇄 원칙 !
LSP
리스코프 치환 원칙
- 자식 타입의 객체는 언제나 자신의 부모 타입 객체로 교체할 수 있어야 함
- 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용할 때
코드가 원래 의도대로 동작하여야 함- 부모 클래스의 행동 규약을 자식 클래스가 위반하면 안 됨
- 부모 클래스의 행동 규약을 자식 클래스가 어긴다는 것
- 자식 클래스가 부모 클래스 메소드의 파라미터를 바꾸는 경우
- 자식 클래스가 부모 클래스 메소드의 리턴타입을 바꾸는 경우
- 자식 클래스가 부모 클래스 의도와 다르게 메소드를 오버라이딩 하는 경우
abstract class CoffeeShop {
int money;
int coffeeStock;
String printStock() {
return "커피 재고 : " + coffeeStock;
}
}
class SampleCoffeeShop extends CoffeeShop {
int teaStock;
String printStock() {
return super.printStock() + " 홍차 재고 : " + teaStock;
}
// 이렇게 매개변수가 다른 메소드를 정의하거나
String printStock(String str) {
return super.printStock() + " 홍차 재고 : " + teaStock + str;
}
// 이렇게 리턴값이 다른 메소드를 정의하면 LSP 위배 !
int printStock(int stock) {
return coffeeStock + teaStock;
}
}
자식 클래스가 부모 클래스 메소드의 파라미터나 매개변수를 바꿔서 구현하게 되면
문법상으로 오류는 발생하지 않지만,
내부적으로 문제가 생길 수 있다.
ISP
인터페이스 분리 원칙
- 클라이언트는 자신이 사용하는 메소드에만 의존해야 함
- 인터페이스가 거대해지면?
- 응집력이 낮아짐
- 대부분의 거대한 인터페이스는 서로 다른 클라이언트를 가지는
몇 개의 메소드 그룹으로 분해 가능
그럼 위의 거대한 인터페이스를 아래처럼 쪼개보자.
각 게임종류마다 필요한 메소드들이 다른 상태에서 유저1이 배그만 한다고 생각해보자.
변경 전처럼 게임이라는 인터페이스를 유저들이 모두 바라보고 있다면,
유저 1은 롤이나 카트에 대한 변경이 있을 때에도 영향을 받을 수 있다.
변경을 하고 나면 인터페이스가 분리되어 있기 때문에 유저가 하지 않는 게임에 대해서는 영향을 받지 않는다.
interface Athlete {
void compete();
void bowling();
void golf();
void tennis();
}
class Juwon implements Athlete {
@Override
public void compete() {
System.out.println("주원이가 경쟁을 시작.");
}
@Override
public void bowling() {
System.out.println("주원이가 볼링을 시작.");
}
@Override
public void golf() {}
@Override
public void tennis() {}
}
“주원” 이라는 사용자가 Athlete 인터페이스를 상속받은 운동선수라고 할 때,
볼링만 하는 운동선수인 경우 골프나 테니스 메소드는 필요가 없다.
공통적으로 묶을 수 있는 것은 부모 인터페이스에 두고 나머지는 따로 분리해서 구현하자 !
interface Athlete {
void compete();
}
interface BowlingAthlete extends Athlete {
void bowling();
}
interface GolfAthlete extends Athlete {
void golf();
}
class Juwon implements BowlingAthlete {
@Override
public void compete() {
System.out.println("주원이가 경쟁을 시작.");
}
@Override
public void bowling() {
System.out.println("주원이가 볼링을 시작.");
}
}
이렇게 인터페이스를 분리해서 구현하면 의존성을 줄일 수 있다 !
SRP
의존 역전 원칙
- 상위 클래스는 하위 클래스의 구현 내용에 의존하면 안 됨
- 상위 클래스와 하위 클래스 모두 추상화된 내용에 의존해야 함
- 상위 클래스가 하위 클래스를 사용할 때 직접 인스턴스 가져다 쓰기 금지 !
자신보다 더 변하기 쉬운 것에 의존해서는 안 된다 !
class Album {
void seeContents() {}
void readSample() {}
}
class DVD {
void seeContents() {}
void watchSample() {}
}
class Shelf {
Book book;
void addBook(Book book) {};
void customizeShelf() {}
}
책장에 책을 꽂아 넣는 것을 생각해보자.
책장 클래스에 책이 있고, 책을 꽂아넣는 메소드가 있을 때
책이 아닌 앨범이나 DVD 를 꽂으려고 하면 책장 클래스 자체에 수정이 일어나야 한다.
이를 추상화해서 의존성을 조정해보자.
interface Product {
void seeContents();
void getSample();
}
class Book implements Product {
@Override
public void seeContents() {}
@Override
public void getSample() {}
}
class DVD implements Product {
@Override
public void seeContents() {}
@Override
public void getSample() {}
}
class Shelf {
Product product;
void addProduct(Product product) {}
void customizeShelf() {}
}
의존 역전 원칙은,
구체적인 클래스가 아니라 추상클래스나 인터페이스에 의존해야 함 !