백엔드/Java

SOLID

삐삐에스 2024. 7. 16. 16:35

오늘은 객체지향 5원칙인 SOLID에 대해서 정리해보겠다.

객체? 객체 지향?

생각해보면 객체를 지향한다는 것이 무슨 의미인 것인지 명확하게 정의하지 못하고 있었던 것 같다.

단순히 "최대한으로 쪼개서 관리하는 것"으로 알고 있었고, 또 그렇게 써왔다.

이번 게시물을 작성하면서 SOLID원칙에 대해서 알아봄과 동시에 객체 지향 마스터가 되어보자!

 

SRP(Single Responsibility Principle)

 

단일 책임원칙은 클래스를 수정을 해야할 때 목적이 하나여야 한다는 의미이다.

 

 

다른 말로 클래스는 하나의 책임만 가지고 있어야 한다.

예를 들어 청소기가 청소도 하고, 화분에 물을 주고, 음식을 만들어주는 기능까지 할 필요가 없는것이다.

청소기는 청소만 수행하는 책임을 가지면 된다.

Example

아래 코드를 보면 PediaStudent클래스는 다음과 같은 기능을 수행하고 있다.

  • takeQrcode(): qr코드를 인증하는 메소드(알파코 1팀과 알파코 2팀이 사용)
  • startEducationTime() : 알파코 1팀이 정규교육시간을 시작하는 메소드
  • startSelfStudyTime() : 알파코 2팀이 자율학습시간을 시작하는 메소드

정규교육시간을 시작하는 메소드인 startEducationTime()와 자율학습시간을 시작하는 메소드인 startSelfStudyTime()에서는 qr코드를 인증하는 메소드인 takeQrcode()를 사용하고 있다.

class PediaAlpha{
    String name;
    String positon;

    PediaAlpha(String name, String position) {
        this.name = name;
        this.positon = position;
    }

	// Qr코드를 찍는 메서드 (두 팀에서 공유하여 사용)
    void takeQrcode() {
        // ...
    }

    // 정규교육 시작 (알파코 1팀에서 사용)
    void startEducationTime() {
        // ...
        this.takeQrcode();
        // ...
    }

    // 자율학습 시작  (알파코 2팀에서 사용)
    void startSelfStudyTime() {
        // ...
        this.takeQrcode();
        // ...
    }

}

그런데 알파코 1팀에서 정규교육시간을 시작하는 방식이 바뀌어서 ,코드에서 qr코드를 인증하는 메서드인 takeQrcode()를 개발팀에게 수정 요청을 한다. 그래서 개발팀이 takeQrcode()를 수정한다.

그러나 변경에 의한 파급효과 때문에 의도치 않게 알파코 2팀에서 사용하는 startSelfStudyTime()메서드도 변경이 되어 에러가 발생한다. 이때 알파코 2팀에서 에러를 해결하기 위해 개발팀에게 수정요청을 할것이고 이게 반복이 되면 에러범벅이 되고 코드가 난잡해질 것이다.

바로 이러한 상황이 SRP에 위배되는 것이다.

즉 , PediaStudent클래스에서 알파코 1팀, 알파코 2팀 이렇게 2개의 액터에 대한 책임을 가지고 있기 때문이다.

 

이러한 문제를 해결하기 위해서 SRP를 적용했다.

class PediaAlpha {
    private String name;
    private String positon;

    PediaAlpha(String name, String position) {
        this.name = name;
        this.positon = position;
    }
    
    // 정규교육시간을 시작하는 메소드 (알파코 1팀에서 사용)
    void startEducationTime() {
        // ...
        new EducationTimeStarter().startEducationTime();
        // ...
    }

    // 자율학습시간을 시작하는 메소드 (알파코 2팀에서 사용)
    void startSelfStudyTime() {
        // ...
        new SelfStudyTimeStarter().startSelfStudyTime();
        // ...
    }

}

// 알파코 1팀에서 사용되는 전용 클래스
class EducationTimeStarter {
    // qr코드를 인증하는 메서드
    void takeQrcode() {
        // ...
    }
    void startEducationTime() {
        // ...
        this.takeQrcode();
        // ...
    }
}

// 알파코 2팀에서 사용되는 전용 클래스
class SelfStudyTimeStarter {
    // qr코드를 인증하는 메서드
    void takeQrcode() {
        // ...
    }
    void startSelfStudyTime() {
        // ...
        this.takeQrcode();
        // ...
    }
}

각 책임에 맞게 클래스를 분리하여 구성했다.

따라서 알파코 1팀에서 정규교육시간을 시작하는 방식이 바뀌었다고 하면 EducationTimeStarter클래스의 takeQrcode()를 수정하면 된다. 따라서 EducationTimeStarter클래스에 영향을 주지 않는다.

위 코드를 보면 “정규교육시간을 시작하는 방식”을 바꾸고 싶으면 EducationTimeStarter클래스만 수정하면 되고 “자율학습시간을 시작하는 방식”을 바꾸고 싶으면 EducationTimeStarter클래스만 수정하면 된다.

따라서 EducationTimeStarter를 수정하는 건 오로지 “정규교육시간을 시작하는 방식”을 수정하고 싶을 때이다. 즉, 아래 문장이 성립된다.

단일 책임원칙은 클래스를 수정을 해야할 때 목적이 하나여야 한다는 의미이다.

 

SRP를 사용하면 코드는 길어지지만 유지보수에 훨씬 적은 시간과 비용이 들 것 이다.

 

OCP(Open/Closed Principle)

기능을 추가할 때는 추가하는 부분 추가만 해야 하며, 기존 코드를 변경하면 안된다.

Example

  1. OCP가 지켜지지 않은 경우
    • 새로운 차인 수소차를 추가하려면 기존 코드인 Car 클래스의 charge 메서드를 수정해야 한다.
    • 기존 코드를 수정한다는 것은 OCP에 위배된다.
    아래 코드에서 새로운 차가 추가될 때마다 매번 코드를 변경해줘야 한다는 것은 상당히 번거로운 일이다.
// Car 클래스 
public class Car { 
    String type; 
    
    public Car(String type) { 
        this.type = type; 
    } 
    
    public void charge(){ 
        if(type.equals("GasCar")){ 
            System.out.println("기름을 주유합니다."); 
        } else if(type.equals("ElectricCar")) { 
            System.out.println("전기차를 충전합니다."); 
        } // 다른 차를 추가하려면 아래와 같이 추가해줘야 함 
        else if(type.equals("HydrogenCar")){ 
            System.out.println("수소차를 충전합니다."); 
        } 
    } 
} 

// Main 
public class Main { 
    public static void main(String[] args) { 
        Car gasCar = new Car("GasCar"); 
        Car electricCar = new Car("ElectricCar"); 
        Car hydrogenCar = new Car("HydrogenCar"); 
        
        // 수소차 추가 
        gasCar.charge(); 
        electricCar.charge(); 
        hydrogenCar.charge(); // 수소차 추가 
    } 
}

 

   2. OCP가 지켜진 경우

  • 새로운 차인 수소차를 추가해도 기존 코드는 수정할 필요 없이, HydrogenCar 클래스만 추가하면 된다.

아래와 같이 OCP 설계 원칙에 따라 추상 클래스 또는 인터페이스를 정의하고 이를 상속하여 확장시키는 형태로 구현하면 변경에는 닫히고(closed) 추가에는 열려있는(opened) 프로그램을 만들 수 있다.

// Car 인터페이스 
public interface Car { 
    void charge(); 
} 

// ElectricCar 클래스 
public class ElectricCar implements Car{ 
    @Override public void charge() { 
        System.out.println("전기차를 충전합니다."); 
    } 
} 

// GasCar 클래스 
public class GasCar implements Car{ 
    @Override public void charge() { 
        System.out.println("기름을 주유합니다."); 
    } 
} 

// HydrogenCar 클래스 
// 다른 차 추가 
public class HydrogenCar implements Car{ 
    @Override public void charge() { 
        System.out.println("수소차를 충전합니다."); 
    } 
} 

// Main 
public class Main { 
    public static void main(String[] args) { 
        Car gasCar = new GasCar(); 
        Car electricCar = new ElectricCar(); 
        Car hydrogenCar = new HydrogenCar(); // 수소차 추가 
        
        gasCar.charge(); 
        electricCar.charge(); 
        hydrogenCar.charge(); // 수소차 추가 
    } 
}

 

LSP(Liskov Substitution Principle)

 

자식은 부모가 정의한대로 구현되어야 한다.

 

인터페이스는 “공통 규약”이다.

인터페이스를 구현한 하위 인스턴스가 그 이상을 구현하면 “공통 규약”이라는 인터페이스의 의미가 퇴색된다.

따라서 인터페이스를 구현하는 하위 인스턴스는 인터페이스 대로 구현해야한다.

인터페이스 외에도 부모 위치에 있는 클래스라면 일반적으로 “공통 규약”을 이야기한다.

따라서 규약에 맞게 하위 인스턴스를 구현해야한다.

Example

  1. LSP가 지켜지지 않은 경우
  • Child 클래스는 Parent 인터페이스를 구현한다.
  • Child 클래스는 Parent의 추상 메소드인 A, B를 구현한다.
  • Child 클래스는 추가적으로 C, D를 구현했다.
interface Parent {
    void A();
    void B();
}

public class Child implements Parent {
    public void A() {}
    public void B() {}
    public void C() {}
    public void D() {}
}

⇒ Parent라는 공통 규약 그 이상을 구현했으므로 LSP에 위배된다.

 

   2. LSP가 지켜진 경우

  • Child 클래스는 Parent1, Parent2 인터페이스를 구현한다.
  • Child 클래스는 Parent1라는 공통 규약에 맞게 추상 메소드인 A, B만을 구현한다.
  • Child 클래스는 Parent2라는 공통 규약에 맞게 추상 메소드인 C, D만을 구현한다.
interface Parent1 {
    void A();
    void B();
}

interface Parent2 {
    void C();
    void D();
}

public class Child implements Parent1, Parent2 {
    public void A() {}
    public void B() {}
    public void C() {}
    public void D() {}
}

⇒ Parent라는 공통 규약을 잘 지켰으므로 LSP에 부합한다.

 

ISP(Interface Segregation Principle)

 

자신이 사용하지 않는 메소드에 의존하지 말아야 한다.

즉, 자신이 사용하는 메소드만 존재하는 인터페이스를 상속해야 한다.

이 원칙을 지키기 위하여

인터페이스가 비대해지지 않고, 너무 많은 기능을 포함하지 않게 작성한다.

 

다른 여러 클라이언트를 위한 기능을 하나의 인터페이스나 클래스에 넣어 비대하게 만들면,

  1. 클라이언트에 불필요한 결합이 생기고,
  2. 한 클라이언트에 변경이 생기면 다른 클라이언트도 다시 컴파일해야 한다.

Example

  1. ISP 가 지켜지지 않은 경우
  • Avante는 PowerOn(), lockWindow() 메소드를 가지고 있음
  • Grandeur는 Avante의 기능에 AI 기능이 추가되었음

Interface Car에 모든 차의 기능을 추가해둔다면 Avante에서는 AI기능이 Car에 의해서 의존되어있다.

interface Car {
	String PowerOn();
	void lockWindow();
	String speakAI();
}

class Avante implements Car {
	String PowerOn(){
		...
	}
	void lockWindow() {
		...
	}
	String speakAI() {
		return "지원하지 않는 기능입니다.";
	}
}

class Grandeur implements Car {
	String PowerOn(){
		...
	}
	void lockWindow() {
		...
	}
	String speakAI() {
		...
	}
}

 

   2. ISP가 지켜진 경우

Car의 기본기능은 interface Car에 구현해두고, Grandeur의 추가 기능은 Grandeur라는 인터페이스를 생성하여 추가 구현해준다.

interface Car {
	String PowerOn();
	void lockWindow();
}

interface AICompanion {
	String speakAI();
}

class Avante implements Car {
	String PowerOn(){
		...
	}
	void lockWindow() {
		...
	}
}

class Grandeur implements Car, AICompanion {
	String PowerOn(){
		...
	}
	void lockWindow() {
		...
	}
	String speakAI() {
		...
	}
}

 

DIP(Dependency Inversion Principle)

고수준 모듈은 저수준 모듈에 의존해서는 안된다. 둘 다 추상화에 의존해야한다. 구체적인 구현보다는 추상화된 인터페이스에 의존하여 모듈간의 결합도를 낮춘다.

Example

   1. DIP가 지켜지지 않은 경우

public class WindowsXPComputer { 
	private final StandardKeyboard keyboard;
	private final BallMouse mouse; 
	
	public Windows98Machine() { 
		mouse = new BallMouse(); 
		keyboard = new StandardKeyboard(); 
	} 
}

이 WindowsXP 컴퓨터는 기본키보드와 볼마우스만 사용할 수 있을것이다.

만약에 볼마우스가 더이상 팔지 않는다면?

이 코드는 볼마우스가 사라지면 WindowsXP 컴퓨터가 고장날것이다.

이 WindowsXP 컴퓨터에게 다양한 키보드와 다양한 마우스를 선택해 사용할 수 있게 해줘야한다.

 

   2. DIP가 지켜진 경우

public class WindowsXPComputer {
    private  Keyboard keyboard;
    private  Mouse mouse;

    public void WindowsXPComputer(Keyboard keyboard, Mouse mouse) {
        this.keyboard = keyboard;
        this.mouse = mouse;
    }
    
    public void typing(){
	    
    }
}

public interface Mouse {
    void move();
}

public class VerticalMouse implements Mouse {
    public void move() {}
}

public class BallMouse implements Mouse {
    public void move() {}
}

public interface Keyboard {
    // 키보드 관련 메서드 정의
}

public class StandardKeyboard implements Keyboard {
    // StandardKeyboard의 구현
}

public class MechanicalKeyboard implements Keyboard {
    // MechanicalKeyboard의 구현
}

어떤 키보드를 생성하더라도 추상화된 키보드 인터페이스를 따르면 컴퓨터 모듈에서 사용할 수 있도록 해줘야한다.

 

응집도, 결합도, 의존성

의존성

 

의존성은 하나의 모듈 또는 클래스가 다른 모듈이나 클래스를 필요로 하는 정도를 의미한다.

이는 특정 객체가 다른 객체를 사용하거나, 특정 객체의 기능에 의존하여 동작하는 관계를 나타낸다.

class Engine {
    void start() {
        System.out.println("Engine started");
    }
}

class Car {
    private Engine engine;

    Car() {
        engine = new Engine(); // Car는 Engine에 의존적
    }

    void startCar() {
        engine.start(); // Car는 Engine의 메소드에 의존적
    }
}

이 예제에서 Car 클래스는 Engine 클래스에 강하게 의존적이다. Engine이 없으면 Car는 동작할 수 없다.

객체지향 설계에서는 의존성을 최소화하여 모듈 간의 독립성을 높이고, 유지보수와 확장을 용이하게 한다.

응집도와 결합도는 모듈의 독립성을 측정하는 기준이라고 할 수 있다.

 

결합도(Coupling)

 

결합도는 모듈간의 상호 의존 정도를 의미한다.

객체지향의 관점에서 클래스나 메소드가 적절한 수준의 관계만을 유지하고 있는지를 나타낸다.

  1. 높은 결합도이는 한 클래스를 수정할 때 연관된 다른 클래스도 변경해야 할 가능성이 높아진다는 것을 뜻한다.
  2. 예를 들어, 자동차의 경우를 생각해보면, 핸들, 바퀴, 엔진 등 여러 모듈이 서로 의존되어 결합된 상태인데, 만약 자동차의 결합도가 너무 높게 설계되었다면, 바퀴를 교체하는데 엔진까지 모두 바꿔야 하는 상황이 발생할 수 있다.
  3. 결합도가 높다는 것은 다른 클래스와의 연관성이 높다는 것을 의미한다.
  4. 낮은 결합도 따라서, 좋은 소프트웨어는 낮은 결합도를 가지고 있다고 말할 수 있다.
  5. 낮은 결합도를 유지하면 모듈 간의 의존성이 줄어들어, 하나의 모듈을 변경하더라도 다른 모듈에 미치는 영향을 최소화할 수 있고, 이는 유지보수성과 확장성을 높이는 중요한 요소이다.

 

응집도(Cohesion)

 

응집도는 한 모듈 내의 구성 요소 간의 밀접한 정도를 의미한다.

객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 있는 책임들을 할당했는지를 나타낸다.

  1. 낮은 응집도
  2. 응집도가 낮은 모듈은 모듈 내부에 서로 관련 없는 함수나 데이터들이 존재하거나 관련성이 적은 여러 기능들이 서로 다른 목적을 추구하며 산재해 있다.
  3. 높은 응집도즉, 응집도가 높을 수록 독립성이 높은 모듈이며 좋은 소프트웨어는 높은 응집도를 유지해야 한다.
  4. 응집도가 높은 모듈은 하나의 모듈 안에 함수나 데이터와 같은 구성 요소들이 하나의 기능을 구현하기 위해 필요한 것들만 배치되어 있고 긴밀하게 협력한다.

 

 


우리 개발자 동기들과 함께 생각하고 작성해본 SOLID 원칙이다.

객체 지향을 배우면서 SOLID 원칙에 대해 더 깊이있게 생각해봤던 것 같다.

이제 Spring을 배우는데 Spring에서도 이 원칙을 항상 생각하며 개발해야겠다고 다짐한 하루였다!

반응형