CS
객체지향 5원칙 - SOLID
HRuler
2024. 5. 6. 13:40
1. 객체지향 5원칙 소개
- 객체 지향 5원칙은 유연하고 확장 가능한 소프트웨어를 설계하기 위한 원칙으로 단일 책임 원칙(SRP), 개방-폐쇄 원칙(OCP), 리스코프 치환 원칙(LSP), 인터페이스 분리 원칙(ISP), 의존 관계 역전 원칙(DIP)이 있습니다. 즉 객체지향 프로그래밍을 위한 5가지 원칙입니다.
각 원칙 약자의 앞글자를 따서 SOLID 원칙이라고 부른다.
2. 단일 책임 원칙(SRP - Single Responsibility Principle)
- 하나의 객체에는 하나의 책임만을 가진다는 원칙 -> 하나의 객체에 하나의 책임이 완전히 캡슐화되어야 함을 의미
- 더 쉽게 이해하려면 하나의 객체인 클래스를 수정하는 목적, 이유가 한 가지여야 함을 의미한다고 생각하면 된다.
- 아래와 같이 더하기와 곱하기의 두가지 목적을 가진 코드가 있다고 생각해보자, 만일 결과 값이 1이 증가되면 좋겠다라는 생각으로 더하는 코드를 수정하게 된다면 곱하기 연산에 영향을 주게 되면서 예상과는 다른 결과를 갖게 된다.
public class MathService {
public int complexCalculation(int a, int b, int c) {
int result;
// 더하기 연산
result = a + b;
// 곱하기 연산
result = result * c;
return result;
}
}
- 만일 위의 코드를 아래 코드로 분리한다면 어떨까? 더하기 연산에 수정이 필요한 내용만 수정하게 되면 반환되는 값은 우리가 예상한 값과 일치할 것이다.
public class MathService {
public int simpleCalculation(int a, int b) {
// 더하기 연산
return a + b;
}
public int simpleCalculation2(int a, int b) {
// 곱하기 연산
return a * b;
}
}
- 이를 통해서 알 수 있는 것은 목적을 분리하는 것 즉, 단일책임을 유지하는 것이 코드 간의 영향력를 낮추고 응집도를 높일 수 있는 방법임을 알 수 있다. 또한 하나의 목적에 대한 수정은 어느 메서드(코드)를 수정할 지가 명확해 진다.
3. 개방 폐쇄 원칙(OCP - Open Closed Principle)
- 확장에는 열려있고, 변경에는 닫혀 있어야 한다는 원칙
3.1. 확장에는 열려있다.
- 새로운 변경 혹은 추가 코드가 필요한 경우 기존 코드를 수정하는 것이 아닌 기존 코드에 이어서 개발을 진행하여 기능 추가, 변경에 큰 자원을 들이지 않을 수 있음을 의미한다.
3.2. 변경에는 닫혀있다
- 먼저 개발된 객체를 직접적으로 수정하는 것을 제한해야 함을 의미한다. 그렇다면 수정이 필요한 기능이 있는 경우에 어떻게 개발해야 될까라는 의문점이 남는다.
- 변경, 수정을 위해서는 기존 코드를 확장하는 방식으로 변경사항이 적용될 수 있게 개발되어야 한다. 즉, 처음부터 확장 가능한 구조로 프로젝트가 개발되어야 개방폐쇄 원칙을 유지하면서 개발될 수 있는 것이다.
3.3. 개방 폐쇄 원칙 예제
- 이 정도 설명을 듣다보면 대략적으로 어떤 개념인지 느낌이 올 거라 생각한다. 하지만 코드로 보는 것이 이해해 도움이 더 좋을거라 생각한다.
- 먼저 개방 폐쇄 원칙을 위반하는 예제이다.
public class Main {
public static void main(String[] args) {
Animal dog = new Animal("Dog");
dog.sound();
Animal cat = new Animal("Cat");
cat.sound();
}
}
class Animal {
String name;
public Animal(String name) {
this.name = name;
}
public void sound() {
if (this.name.equals("Dog")){
System.out.println("bark");
} else if (this.name.equals("Cat")) {
System.out.println("meow");
}
}
}
- 이 코드에서는 어떤 문제가 생길까? 만일 우리가 추가적인 동물이 개발되어 진다고 생각해보자 그렇다면 우리는 기존에 개발된 코드인 Animal 클래스의 sound 메서드 내의 if문의 구조를 변형시키는 코딩을 해야할 것이다. 그렇다면 아래와 같은 코드에서는 어떨까?
abstract class Animal {
String name;
public void sound() {}
}
class Dog extends Animal {
@Override
public void sound() {
System.out.println("bark");
}
}
class Cat extends Animal {
@Override
public void sound() {
System.out.println("meow");
}
}
- 이러한 방식으로 클래스를 추상화하여 Dog 클래스와 Cat 클래스를 확장하여 개발하는 구조로 처음부터 개발한다면, 이후 추가 수정으로 인한 Cow, Wolf 등의 수정이 있을 때도 기존의 코드를 수정하는 것이 아닌 기존 abstract 클래스를 확장하여 개발할 수 있다는 것이다.
- 즉, 개방 폐쇄 원칙의 핵심은 추상화를 통한 확장에 있음을 알 수 있다.
4. 리스코프 치환 원칙(LSP - Liskov Substitution Principle)
- 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다는 원칙
4.1. 바바라 리스코프(Babara Liskov)
- 리스코프 치환 원칙을 개발 및 발표한 컴퓨터 공학자로, 상속 관계의 특징을 정의하기 위한 원칙으로서 서브 타입(상속을 받은) 객체는 언제나 기반 타입(상속을 해준) 객체를 대체할 수 있어야한다는 원칙을 개발했다.
4.2. 다형성 보존을 위한 원칙
void main(){
Collection list = new LinkedList();
list = new HashSet();
modify(list);
}
void modify(Collection list){
list.add("Hello");
}
- 위의 코드처럼 다형성이 지켜지기 위해서는 Collection 인터페이스 하위의 구현체인 LinkedList로 선언된 객체가 HashSet으로 재선언되어서 사용될 수 있어야 하고 Collection 파라미터를 사용한 메서드에서도 하위 구현체 HashSet의 객체가 아규먼트로 사용될 수 있어야 한다. 즉, 리스코프 치환의 원칙을 통해서 다형성이 보존되는 것이다.
class Animal {
int speed = 100;
int go(int distance) {
return speed * distance;
}
}
class Eagle extends Animal {
String go(int distance, boolean flying) {
if (flying)
return distance + "만큼 날아서 갔습니다.";
else
return distance + "만큼 걸어서 갔습니다.";
}
}
public class Main {
public static void main(String[] args) {
Animal eagle = new Eagle();
eagle.go(10, true);
}
}
- 이와 같은 코드가 다형성이 깨지는 코드입니다. 부모 클래스가 정의해둔 메서드를 재정의 하는 과정에서 파라미터의 개수를 변경했는데, 이러한 개발이 다형성을 깨고 리스코프 치환의 원칙을 지키지 않음으로 인해 크게는 객체 지향 의미를 떨어지게 하는 결과를 가져온다.
5. 인터페이스 분리 원칙(ISP - Interface Segregation Principle)
- 여러 개의 작은 인터페이스로 분리하여 사용 빈도가 낮은 인터페이스에 대한 의존도를 낮추는 원칙
- 단일 책임 원칙이 객체(클래스)의 책임을 분리하는 것이라면 인터페이스 분리 원칙은 인터페이스의 분리를 통한 개별 인터페이스의 단일 책임을 강조하는 원칙이다.
interface Animal {
void eat();
void sleep();
void work();
}
class Developer implements Animal {
public void eat() {
System.out.println("Developer is eating");
}
public void sleep() {
System.out.println("Developer is sleeping");
}
public void work() {
System.out.println("Developer is working");
}
}
class Dog implements Animal {
public void eat() {
System.out.println("Dog is eating");
}
public void sleep() {
System.out.println("Dog is sleeping");
}
public void work() {
}
}
- 위와 같은 코드는 인터페이스 분리 원칙을 지키지 못한 코드이다. 그렇다면 이러한 코드를 지양해야 하는 이유는 무엇일까? 위 코드를 보면 Animal 인터페이스는 eat, sleep, work 메서드를 가지고 있는데 이 메서드는 Developer 클래스에는 필요한 메서드이지만, Dog 클래스에는 work 메서드는 필요하지 않다. 그렇다면 우리는 어떻게 코드를 설계해야 할까?
interface Animal {
void eat();
void sleep();
}
interface Human{
void work();
}
class Developer implements Animal, Human {
public void eat() {
System.out.println("Developer is eating");
}
public void sleep() {
System.out.println("Developer is sleeping");
}
public void work() {
System.out.println("Developer is working");
}
}
class Dog implements Animal {
public void eat() {
System.out.println("Dog is eating");
}
public void sleep() {
System.out.println("Dog is sleeping");
}
}
- 각 클래스가 본인에게 필요한 메서드만을 구현하고, 단일 책임 원칙을 위배하지 않기 위해서는 위와 같이 개발해야 한다. 위 코드에서는 동물이 하는 행동(메서드)와 인간이 하는 행동(메서드)를 구분하여 인터페이스로 설계했다. 이렇게 개발하면 각 인터페이스에 해당하는 객체(클래스)들은 해당 인터페이스에 대한 구현체로서 필요한 메서드만을 구현할 수 있게 됐다.
6. 의존 관계 역전 원칙(DIP - Dependency Inversion Principle)
- 클라이언트가 구체적인 하위 객체가 아닌 추상적인 상위 객체(추상 클래스 or 인터페이스)를 사용해야 한다는 원칙
interface Toy {}
class Robot implements Toy {}
class Lego implements Toy {}
class Doll implements Toy {}
class Kid {
Toy toy;
void setToY(Toy toy) {
this.toy = toy;
}
void play() {}
}
public class Main {
public static void main(String[] args) {
Kid boy = Kid();
Toy toy = new Robot();
boy.setToy(toy);
boy.play();
Toy toy = new Lego();
boy.setToy(toy);
boy.play();
}
}
- 위의 코드와 같이 아이 클래스가 Toy 인터페이스의 구현체인 Robot 혹은 Lego 클래스가 아닌 인터페이스인 Toy 인터페이스를 참조하여 사용하는 이유가 바로 의존 관계 역전 원칙에 의한 것이라고 할 수 있다.
- 만약 Toy가 아닌 Robot 혹은 Lego 클래스를 참조하게 된다면 어떻게 됐을까? 만일 Robot을 참조하고 있다면 Kid 클래스를 직접 수정하는 상황이 발생할 것이다. 즉, 의존 관계 역전 원칙에 맞추어 개발하게 된다면 사용할 때마다 인터페이스의 구현체만 교체해서 사용할 수 있는 방식으로 유지보수를 진행할 수 있게 된다는 의미를 갖는다.