SOLID 원칙
SOLID
SOLID란 객체 지향 설계에서 지켜야 할 5가지 원칙으로 이러한 원칙에 맞게 설계하면 새로운 요구사항이나 변화에 유연하게 대처할 수 있으며 코드의 유지보수를 쉽게하여 개발의 생산성을 올려줄 수 있다.
여러 디자인 패턴들이 이러한 SOLID 원칙을 기반으로 만들어졌으며 이는 특정 프로그래밍 언어에 국한되는 것이 아니다.
SRP - 단일 책임 원칙(single responsibility principle)
- 한 클래스는 하나의 책임만 가져야 함
- 하나의 클래스는 하나의 기능만 담당할 것
- 하나의 클래스에 여러 기능이 있다면 기능 수정 시 변경 부분이 많아짐
- 하나의 책임이라는 것이 클 수도, 작을 수도 있고 또한 상황에 따라 다를 수 있는데 변경이 있을 때 파급 효과가 적다면 단일 책임 원칙을 잘 따른 것이다.
public class Animal {
private String name;
public Animal(String name) {
this.name = name;
}
public void cry() {
System.out.println(name + "가 운다.");
}
public void saveToFile() {
// 파일에 동물 정보를 저장하는 로직
System.out.println(name + "의 정보를 파일에 저장");
}
}
위 코드를 보면 Animal 클래스는 울음소리 기능과 파일에 정보를 저장하는 기능을 가지고 있다.
이 때 둘 중 하나의 기능이 변경된다면 두 가지 책임을 지닌 클래스가 변경이 되야한다.
따라서 아래와 같이 Animal 클래스는 동물의 행동만 지정하고, 파일 저장 기능의 경우 별도의 클래스로 나누어 각 클래스가 하나의 책임을 지닐 수 있도록 한다.
public class Animal {
private String name;
public Animal(String name) {
this.name = name;
}
public void cry() {
System.out.println(name + "가 운다.");
}
public String getName() {
return name;
}
}
public class AnimalFileSaver {
public void saveToFile(Animal animal) {
// 파일에 동물 정보를 저장하는 로직
System.out.println(animal.getName() + "의 정보를 파일에 저장.");
}
}
OCP - 개방-폐쇄 원칙 (Open/closed principle)
- 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀있어야 함.
- 새로운 변경 사항이 생겼을 때 유연하게 코드를 추가하여 확장할 수 있어야 하며, 객체를 직접적으로 수정하지 않고도 해당 사항을 적용해야하도록 설계할 것
public class Animal {
String type;
String name;
public Animal(String type, String name) {
this.type = type;
this.name = name;
}
public void cry() {
if (type.equals("dog")) {
System.out.println(name + " 멍");
} else if (type.equals("cat")) {
System.out.println(name + " 야옹");
} // 동물 추가 시 분기 추가
}
}
public class AnimalMain {
public static void main(String[] args) {
Animal dog = new Animal("dog", "바둑이");
Animal cat = new Animal("cat", "야옹이");
dog.cry();
cat.cry();
}
}
위 코드는 OCP 원칙을 안 지킨 예시이다.
문제 없이 동작하지만 여기에는 새로운 기능을 추가할 시 문제가 생긴다.
예를 들어 새로운 동물을 추가하게 된다면 Main 뿐만 아니라 Animal 클래스의 cry 메서드 역시 변경된다는 것이다.
따라서 OCP 원칙을 지키려면 다음과 같이 설계해야 한다.
- 변경 부분과 변경 되지 않는 부분을 구분.
- 두 부분이 만나는 지점에 추상화(추상클래스 or 인터페이스)를 정의.
- 구현체가 아닌 추상화에 의존하도록 코드 작성.
public interface Animal {
void cry(); // 변경 부분 추상화
}
public class Dog implements Animal {
String name;
public Dog(String name) {
this.name = name;
}
@Override
public void cry() {
System.out.println(name + " 멍");
}
}
public class Cat implements Animal {
String name;
public Cat(String name) {
this.name = name;
}
@Override
public void cry() {
System.out.println(name + " 야옹");
}
}
public class Main {
public static void main(String[] args) {
Animal dog = new Dog("바둑이");
Animal cat = new Cat("고양이");
dog.cry(); // 바둑이 멍
cat.cry(); // 고양이 야옹
// 새로운 동물 추가 시, 기존 코드 수정 없이 새로운 클래스만 추가
}
}
LSP - 리스코프 치환 원칙 (Liskov substitution principle)
- 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 함.
- 업캐스팅된 상태에서 부모의 메서드가 의도대로 동작해야 함.
- 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 함.
OCP의 예시에서 사용했던 코드를 살펴보자. 여기서 동물을 추가하려는데 물고기를 추가해야 하는 상황이 생겼다고 하자.
이 때 Animal 클래스를 구현하여 Fish 클래스를 만든다면 cry()를 구현할 수 없는 상황이 발생하게 된다.
public interface Animal {
void cry();
}
public class Dog implements Animal {
String name;
public Dog(String name) {
this.name = name;
}
@Override
public void cry() {
System.out.println(name + " 멍");
}
}
public class Cat implements Animal {
String name;
public Cat(String name) {
this.name = name;
}
@Override
public void cry() {
System.out.println(name + " 야옹");
}
}
public class Fish implements Animal {
String name;
public Fish(String name) {
this.name = name;
}
@Override
public void cry() {
throw new UnsupportedOperationException("물고기는 울지않는다.");
}
}
이렇게 된다면 각 동물에 맞게 cry() 하는 코드를 작성할 때 Fish는 예외를 발생시킨다.
즉 Animal animal = new Fish()와 같이 Animal 타입의 구현 객체를 사용할 때 예외가 발생할 수 없고 이는 부모 클래스의 메서드가 기대와 같이 동작하지 않음을 의미한다.
따라서 cry() 메서드를 인터페이스에서 분리하는 작업이 필요하다.
public interface Animal {
//...
}
public interface Cryable() {
void cry();
}
public class Dog implements Animal, Cryable {
String name;
public Dog(String name) {
this.name = name;
}
@Override
public void cry() {
System.out.println(name + " 멍");
}
}
public class Cat implements Animal, Cryable {
String name;
public Cat(String name) {
this.name = name;
}
@Override
public void cry() {
System.out.println(name + " 야옹");
}
}
public class Fish implements Animal {
String name;
public Fish(String name) {
this.name = name;
}
}
ISP - 인터페이스 분리 원칙 (Interface segregation principle)
- 범용 인터페이스 하나보다 특정 클라이언트를 위한 여러 인터페이스 분리할 것
- SRP가 클래스의 단일 책임이라면 ISP는 인터페이스의 단일 책임
- 각각 책임을 가진 인터페이스로 분리함으로써 인터페이스가 명확해지고 수정 시 다른 인터페이스에 영향을 주지 않음
public interface Animal {
void cry();
void swim();
void fly();
}
예를 들어 위와 같이 동물 인터페이스에 cry(), swim(), fly() 메서드가 있다고 하자.
하지만 이 인터페이스를 구현한 클래스 Cat의 경우 날 수 없어 필요없는 기능임에도 불구하고 구현해야 하며, Fish 역시 cry(), fly()를 사용하지 않는 메서드임에도 구현해야 하는 문제가 발생한다.
따라서 각각 기능에 맞는 인터페이스로 분리하고 구현체는 기능에 맞는 인터페이스를 구현해서 ISP 원칙을 지킴
public interface Cryable {
void cry();
}
public interface Swimmable {
void swim();
}
public interface Flyable {
void fly();
}
public class Cat implements Cryable {
@Override
public void cry() {
System.out.println("야옹");
}
}
public class Fish implements Swimmable {
@Override
public void swim() {
System.out.println("Fish is swimming");
}
}
DIP - 의존관계 역전 원칙 (Dependency inversion principle)
- 추상화에 의존해야지 구체화에 의존하지 말아야 함
- 인터페이스에 의존해야 구현체를 유연하게 변경할 수 있음
- 즉 구현 클래스가 아닌 인터페이스에 의존할 것 (인터페이스는 한 번 정해놓으면 거의 변하지 않으므로)