SOLID 설계 원칙은 객체 지향 설계의 다섯 가지 기본 원칙을 의미하며, 이를 잘 준수하여 소프트웨어를 설계하면 유지 보수와 확장성이 좋은 코드를 작성할 수 있다.
SOLID 5 원칙
클래스(객체)는 단 하나의 책임만 가져야 한다는 원칙
즉, 하나의 클래스는 하나의 기능을 담당하여 하나의 책임을 수행하는 데 집중하여 코드의 유지 보수성을 높이기 위한 설계 방법이다.
만일, 하나의 클래스에 여러 기능이 존재하면, 기능 변경을 수행할 때 수정해야 할 코드가 늘어난다.
// 각 클래스는 하나의 책임만을 가진다.
public class User {
public void updateUserDetails() { /* 사용자 정보 업데이트 로직 */}
}
public class UserDB {
public void saveUser(User user) { /* DB에 사용자 정보 저장 로직 */ }
}
// User 클래스가 너무 많은 책임을 가지고 있다.
public class User {
public void updateUserDetails() { /* 사용자 정보 업데이트 로직 */}
public void saveUser(User user) { /* DB에 사용자 정보 저장 로직도 여기에 */ }
}
클래스는 확장에 열려 있어야 하며, 수정에는 닫혀 있어야 한다.
확장에 열려 있어야 한다 : 새로운 변경 사항 발생 시, 유연하게 코드를 추가함으로써 큰 힘을 들이지 않고 애플리케이션의 기능을 확장할 수 있다.
수정에는 닫혀 있어야 한다 : 새로운 변경 사항이 발생했을 때는 객체를 직접적으로 수정을 제한한다.
객체를 직접적으로 수정하는 것을 제한
새로운 변경 사항이 발생했을 때, 직접적으로 수정해야 한다면 새로운 변경 사항에 유연하게 대응할 수 없는 애플리케이션이라고 한다. -> 유지 보수의 비용 증가로 이어지는 부적절한 상황 -> 따라서 객체를 직접 수정하지 않고도 변경사항을 적용할 수 있게 설계해야 한다.
바람직한 예
public abstract class Shape {
public abstract void draw(); // 동작
}
public class Circle extends Shape {
@Override
public void draw() { /* 원 그리기 로직 */ }
}
public class Square extends Shape {
@Override
public void draw() { /* 사각형 그리기 로직 */ }
}
public class Line extends Shape {
@Override
public void draw() { /* 선 그리기 로직 */ }
}
public class Shape {
public void Draw(String shapeType) {
if (shapeType == "Circle"){ /* 원 그리기 로직 */ }
else if (shapeType == "Square") { /* 사각형 그리기 로직 */ }
// 새로운 도형을 추가하려면 이 메소드를 수정해야 한다.
}
}
서브(child) 타입은 언제나 기반(parent) 타입으로 교체할 수 있어야 한다.
parentType a = new child1Type();
a = new child2Type(); // 객체만 갈아끼우기
/// 변수의 본질은 변하지 않는다.
public void myData() {
// Collection interface 타입으로 변수 선언
Collection data = new LinkedList();
data = new HashSet(); // 중간ㅇ에 전혀 다른 자료형 클래스를 할당해도 호환됨.
modify(data); // 메소드 실행
}
public void modify(Collection data) {
list.add(1); // interface 구조가 잘 잡혀있기 때문에 add 메소드 동작이 각기 자료형에 맞게 보장됨.
// ...
}
public class Bird {
public void Fly() { /* 비행 로직 */ }
}
public class Sparrow extends Bird {}
public class Ostrich extends Bird {
@Override
public void Fly() {
throw new Exception("타조는 날 수 없습니다.");
}
}
public class Bird {
public void Fly() { /* 모든 새는 날 수 있다고 가정 */ }
}
public class Sparrow extends Bird {}
public class Ostrich extends Bird { /* 타조 클래스이지만, Fly 메소드는 부적절 */ }
SRP 원칙이 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조하는 것으로 보면 된다. 즉, SRP 원칙의 목표는 클래스 분리를 통해 이루어진다면, ISP 원칙은 인터페이스 분리를 통해 설계하는 원칙이다.
클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다.
public interface IPrinter {
public void Print();
}
public interface IScanner {
public void Scan();
}
public class AllInOnePrinter implements IPrinter, IScanner {
public void Print() { /* 인쇄 로직 */ }
public void Scan() { /* 스캔 로직 */ }
}
public interface IMachine {
public void Print();
public void Scan();
}
public class implements IMachine {
public void Print() { /* 인쇄 로직 */ }
public void Scan() { throw new Exception();}
}
ISP 원칙 위반 예제와 수정하기
스마트폰 종류의 클래스를 구현하기 앞서 인터페이스를 통해 스마트폰을 추상화해보았다.
public interface SmartPhone {
public void call(String number); // 통화기능
public void message(String number, String text); // 문자기능
public void wirelessCharge(); // 무선충전기능
public void AR(); // 증강현실(AR) 기능
public void Biometrics(); // 생체인식기능
}
만일 최신 스마트폰 기종의 클래스를 구현한다면, 객체의 동작 모두가 필요하므로 ISP 원칙을 만족하게 된다.
public class NewPhone implements SmartPhone {
public void call(String number) {}
public void message(String number, String text) {}
public void wirelessCharge() {}
public void AR() {}
public void Biometrics() {}
}
하지만 최신 기종이 아닌 구현 기종의 스마트폰 클래스를 다뤄야 하는 경우 문제가 발생할 수 있다. 구형 스마트폰 클래스를 구형해야 한다면 무선 충전, 생체 인식과 같은 기능은 포함되어 있지 않기 때문이다. 이런 경우에는 추상 메서드 구현 규칙 상 오버라이딩은 하되, 메서드 내부는 빈 공간으로 두거나 예외가 발생하도록 구성해야 한다. 결국 필요하지도 않은 기능을 어쩔 수 없이 구현해야 하는 낭비가 발생하는 것이다.
public class OldPhone implements SmartPhone {
public void call(String number) {}
public void message(String number, String text) {}
public void wirelessCharge() {
console.log("지원하지 않는 기능입니다.");
}
public void AR() {
console.log("지원하지 않는 기능입니다.");
}
public void Biometrics() {
console.log("지원하지 않는 기능입니다.");
}
}
따라서 각각의 기능에 맞게 인터페이스를 잘게 분리하여 구성한다. 그리고 잘게 분리된 인터페이스를 추후 구현할 클래스에서 지원하는 기능에 따라 개별적으로 implements하면 ISP 원칙이 적절하게 지켜지게 된다.
public interface Phone {
public void call(String number); // 통화기능
public void message(String number, String text); // 문자기능
}
public interface WirelessChargable {
public void wirelessCharge();
}
public interface ARable {
public void AR();
}
public interface Biometricsable {
public void Biometrics();
}
public class NewPhone implements Phone, WirelessChargable, ARable, Biometricsable {
public void call(String number) {}
public void message(String number, String text) {}
public void wirelessCharge() {}
public void AR() {}
public void biometrics() {}
}
public class OldPhone implements Phone {
public void call(String number) {}
public void message(String number, String text) {}
}
객체는 저 수준 모듈보다 고수준 모듈에 의존해야 한다는 원칙으로, 가급적 객체의 상속은 인터페이스를 통해 이루어져야 한다는 의미로 해석할 수 있다.
public class Sword {
private final String NAME;
private final int DAMAGE;
public Sward(String name, int damage) {
NAME = name;
DAMAGE = damage;
}
// 공격 데미지 반환 함수
public int attack() {
return DAMAGE;
}
// 무기 이름 반환 함수
@Override
public String toString() {
return NAME;
}
}
위와 같이 무기인 칼을 구현한 Sward 객체가 있다. 캐릭터는 위와 같은 무기를 장비할 수 있다.
public class Character {
private final String NAME;
private int health;
private Sword weapon;
public Character(String name, int health, Sward weapon) {
NAME = name;
this.health = health;
this.weapon = weapon;
}
// 공격 데미지 반환 함수
public int attack() {
return weapon.attack();
}
// 피격 함수
public void damaged(int amount) {
health -= amount;
}
// 무기 변경 함수
public void changeWeapon(Sword weapon) {
this.weapon = weapon;
}
// 캐릭터 정보 출력 함수
public void getInfo() {
System.out.println("NAME: " + NAME);
System.out.println("HEALTH: " + health);
System.out.println("WEAPON: " + weapon.toString());
}
}
위 Character 객체에서는 Sword라는 무기 외 다른 무기를 사용할 수 없는 구조이다. Character의 인스턴스 생성 시 Sword에 의존성을 가지기 때문이다. 또한 공격 메서드인 attack() 메서드 또한 Sword에 의존성을 가진다.
이 상황에서 다른 무기를 사용하기 위해서는 Character의 코드를 수정해야 한다. 즉, 개방-폐쇄 원칙(OCP)을 위배한다.
public interface Weapon {
// 공격 추상 함수
int attack();
// 객체 문자열 반환 추상 함수
@Override
String toString();
}
우선 고수준 모듈인 Weapon 인터페이스를 선언한다. 모든 무기 객체는 Weapon 인터페이스를 상속받게 될 것이다.
public class Sword implements Weapon {
private final String NAME;
private final int DAMAGE;
public Sword(String name, int damage) {
NAME = name;
DAMAGE = damage;
}
@Override
public int attack() {
return DAMAGE;
}
@Override
public String toString() {
return NAME;
}
}
Sword 객체는 Weapon을 상속받았다.
public class Character {
private final String NAME;
private int health;
private Weapon weapon;
public Character(String name, int health, Weapon weapon) {
NAME = name;
this.health = health;
this.weapon = weapon;
}
public int attack() {
return weapon.attack();
}
public void damaged(int amount) {
health -= amount;
}
public void changeWeapon(Weapon weapon) {
this.weapon = weapon;
}
public void getInfo() {
System.out.println("이름: " + NAME);
System.out.println("체력: " + health);
System.out.println("무기: " + weapon.toString());
}
}
Character 객체는 기존 Sword 객체에서 더 고수준 모듈인 Weapon을 파라미터로 받는다. 이 덕분에 Sword뿐만 아니라 Weapon을 상속받은 모든 무기를 Character가 사용할 수 있게 된다.