Language/JAVA

SOLID : 좋은 객체 지향 설계의 5원칙

4Legs 2021. 3. 10. 16:45

단일 책임 원칙 (SRP, Single Responsibility Principle)

하나의 클래스는 하나의 책임만 가져야 하며, 클래스는 이러한 책임을 캡슐화 하여야 한다.

이는 곧 클래스가 변경될 때, 그 이유는 단 한 가지여야만 한다는 말과 같다.

즉, 변경이 발생할 때 파급 효과가 적을수록 좋은 객체 지향 설계이다.

class BookReader{
    public String ReadBook(Book book);
    
    public void EditBook(Book book, int line, ...);
}

위와 같은 클래스는 SRP를 만족하지 못한다. 왜일까?

현재 BookReader 클래스는 두 개의 책임을 가진다. Book 객체를 읽는 것(Read)과, Book 객체를 편집(Edit)하는 것이다.

만약 Book 객체를 읽는 로직이 변경된다면 BookReader 클래스의 해당 부분을 수정해야 한다.

하지만, Book 객체를 편집하는 로직이 변경될 때도 마찬가지로 BookReader 클래스의 해당 부분을 수정해야 한다.

한 클래스가 변경되는 이유가 두 가지이므로, BookReader 클래스는 SRP를 만족하지 못하게 된다.

따라서 다음과 같이, 각 기능 하나씩을 책임으로 하도록 클래스를 분리해야 한다.

class BookReader{
	public String ReadBook(Book book);
}

class BookEditor{
	public void EditBook(Book book, int line, ...);
}

이를 통해 각 클래스들이 높은 응집력(Cohesion)을 갖도록 할 수 있다.

 

개방-폐쇄 원칙 (OCP, Open/Closed Principle)

소프트웨어 요소는 확장에는 열려 있지만, 확장을 위해 내용이 수정되어서는 안 된다는 원칙이다.

우리에게 익숙한 게임을 통해 이를 알아보자. 아래의 예시 코드는 OCP를 만족하지 못한다.

class Champion{
	private String ChampionName;
   	public Champion(String ChampionName);	//Constructor
    	public String getChampionName();
}

//메소드
public String UseSkill(Champion champion){
    if(champion.getChampionName().Equals("Warrior"){
    	return "Charge";
    }
    
    if(champion.getChampionName().Eqauls("Wizard"){
    	return "Fireball";
    }
    
    if(champion.getChampionName().Equals("Priest"){
    	return "Heal";
    }
}

만약 새로운 Champion "Archer" 가 추가된다고 가정해 보자.

위의 코드대로라면 UseSkill 메소드에 새로 추가되는 Champion에 대한 코드를 추가해야 할 것이다.

//메소드
public String UseSkill(Champion champion){
    if(champion.getChampionName().Equals("Warrior"){
    	return "Charge";
    }
    
    if(champion.getChampionName().Eqauls("Wizard"){
    	return "Fireball";
    }
    
    if(champion.getChampionName().Equals("Priest"){
    	return "Heal";
    }
    
    if(champion.getChampionName().Equals("Archer"){
    	return "Quickshot";
    }
}

새로운 Champion이 추가될 때마다(확장), 우리는 UseSkill 메소드를 계속해서 수정해야 한다.

따라서, OCP를 만족하지 못하는 코드가 된다. 

OCP를 만족시키기 위해 코드를 다음과 같이 수정한다.

class Champion{
	private String ChampionName;
    	public Champion(String ChampionName);	//Constructor
    	public String getChampionName();
}

class Warrior extends Champion{
	public String getSkill() { return "Charge"; }
}

class Wizard extends Champion{
	public String getSkill() { return "Fireball"; }
}

class Priest extends Champion{
	public String getSkill() { return "Heal"; }
}



//메소드
public String UseSkill(Champion champion){
	return champion.getSkill();
}

이제는 새로운 Champion 유형이 추가되더라도, UseSkill 메소드의 코드를 수정할 필요가 없다.

즉, 확장에는 열려 있지만, 수정에는 닫혀 있다는 OCP 원칙을 만족하게 된다.

 

리스코프 치환 원칙 (LSP, Liskov Substitution Principle)

하위 클래스는 반드시 상위 클래스와 대체 가능해야 한다는 원칙이다.

즉, 원래 상위 클래스가 들어가는 자리에 하위 클래스가 들어가더라도 이상 없이 동작하도록 설계해야 함을 의미한다.

아까의 Champion 코드를 다시 살펴보자. 이 코드는 LSP를 만족한다.

class Champion{
	private String ChampionName;
   	public Champion(String ChampionName);	//Constructor
    	public String getChampionName();
}

class Warrior extends Champion{
	public String getSkill() { return "Charge"; }
}

class Wizard extends Champion{
	public String getSkill() { return "Fireball"; }
}

class Priest extends Champion{
	public String getSkill() { return "Heal"; }
}



//메소드
public String UseSkill(Champion champion){
	return champion.getSkill();
}

UseSkill 메소드는 파라미터로 들어오는 Champion 객체가 Warrior인지, Wizard인지, Priest인지는 전혀 고려하지 않는다.

단지 들어오는 Champion 객체의 getSkill 메소드만을 실행할 뿐이다.

만약 코드를 다음과 같이 구현한다면, LSP를 위반하게 될 것이다.

class Champion{
    private String ChampionName;
    public Champion(String ChampionName);	//Constructor
    public String getChampionName();
}

class Warrior extends Champion{
	public String Warrior_getSkill() { return "Charge"; }
}

class Wizard extends Champion{
	public String Wizard_getSkill() { return "Fireball"; }
}

class Priest extends Champion{
	public String Priest_getSkill() { return "Heal"; }
}


//메소드
public String UseSkill(Champion champion){
    if(champion instanceof Warrior) return Warrior_getSkill();
    if(champion instanceof Wizard) return Wizard_getSkill();
    if(champion instanceof Priest) return Priest_getSkill();
}

이 코드에서 UseSkill 메소드는 파라미터로 들어오는 champion 객체의 타입을 하나하나 확인한다.

 

인터페이스 분리 원칙 (ISP, Interface Segregation Principle)

사용되지 않는 인터페이스에 의존하여서는 안 된다는 원칙이다.

다음과 같은 인터페이스를 보자.

interfase Shape{
    void drawSquare();
    void drawCircle();
    void drawTriangle();
}

위 Shape 인터페이스의 구현체인 Square, Circle, Triangle 코드는 다음과 같다.

class Square implements Shape{
    void drawSquare(){
    //...
    }
    void drawCircle(){
    //...
    }
    void drawTriangle(){
    //...
    }
}

class Circle implements Shape{
    void drawSquare(){
    //...
    }
    void drawCircle(){
    //...
    }
    void drawTriangle(){
    //...
    }
}

class Triangle implements Shape{
    void drawSquare(){
    //...
    }
    void drawCircle(){
    //...
    }
    void drawTriangle(){
    //...
    }
}

Square 클래스는 오직 drawSquare만 구현하면 되지만, Shape 인터페이스의 구현체이기 때문에 반드시 Shape 인터페이스의 모든 메소드가 구현되어야 한다. 만약 모든 메소드를 구현하지 않으면 오류가 발생하게 된다.

즉, Square 클래스라고 해서 Shape 인터페이스의 drawSquare 메소드만을 구현할 순 없다. 이러한 설계는 ISP를 위반한다. 필요하지 않은 메소드들에 의존하고 있기 때문이다. 

따라서 코드를 다음과 같이 수정한다.

interface SquareInterface{
    void drawSquare();
}

class Square implements SquareInterface{
    @Override
    public void drawSquare(){
    	//...
    }
}

이처럼 인터페이스를 분리하여 ISP를 만족하도록 한다.

인터페이스를 분리할수록 명확해지고, 대체 가능성이 높아진다.

 

의존관계 역전 원칙 (DIP, Dependency Inversion Principle)

의존 관계는 구현체가 아닌 추상화에 의존해야 한다.

이는 곧 구현 클래스 자체에 의존하지 말고 인터페이스에 의존하여야 한다는 말과 같다.

결제 시스템의 할인 정책을 적용하는 다음 코드를 보자.

class FixDiscountPolicy{
    //...
    public int DiscountPrice(int price){
    	if(price < 1000) return 0;
    	return price - 1000;
    }
}

class Payment{
    //...
    private FixDiscountPolicy discountPolicy;
    public Payment(FixDiscountPolicy discountPolicy){
    	this.discountPolicy = discountPolicy;
    }
    //...
    
    public int Discount(int price){
    	return discountPolicy.DiscountPrice(price);
    }
    
    //...
}

현재 우리는 주문의 최종 금액에서 1000원을 할인해주는 정책을 사용하고 있다.

만약 이런 상황에서 다른 할인 정책을 적용하고 싶다면 어떻게 해야 할까?

주문 최종 금액의 10%를 할인해주는 RateDiscountPolicy 라는 새로운 할인 클래스를 만들어,

Payment 클래스의 생성자 및 내부 변수를 RateDiscountPolicy 클래스의 구현체가 되도록 수정해야 할 것이다.

즉, Payment 클래스는 FixDiscountPolicy 라는 구현체에 의존하고 있으므로, DIP를 위반하게 된다.

따라서 다음과 같이 코드를 수정한다.

interface DiscountPolicy{
    int DiscountPrice(int price);
}

class FixDiscountPolicy implements DiscountPolicy{
    private int DiscountAmount = 1000;
    
    @Override
    public int DiscountPrice(int price){
    	if(price < DiscountAmount) return 0;
        return price - DiscountAmount;
    }
}

class RateDiscountPolicy implements DiscountPolicy{
    private int DiscountRate = 10;
    
    @Override
    public int DiscountPrice(int price){
    	return price * (1 - DiscountRate / 100);
    }
}

class Payment{
    //...
    private DiscountPolicy discountPolicy;
    public Payment(DiscountPolicy discountPolicy){
    	this.discountPolicy = discountPolicy;
    }
    //...
    
    public int Discount(int price){
    	return discountPolicy.DiscountPrice(price);
    }
    
    //...
}

위처럼 할인 정책을 인터페이스로 정의하고, 각 유형별 할인 정책을 구현체로 정의한다.

Payment 클래스에서는 생성자에 들어오는 DiscountPolicy 인터페이스에 의존하기 때문에,

Payment 객체의 생성자가 호출되는 시점에 적용되는 할인 정책을 지정할 수 있다.

예를 들어, 다른 클래스 내부에서 10% 할인 정책을 적용한 Payment 객체를 사용하려 한다면 new Payment(rateDiscountPolicy) 등으로 생성자를 호출한다.

이제 더 이상 할인 정책의 변경으로 인해 Payment 클래스 자체를 수정할 필요가 없어진다.

이에 대한 자세한 내용은 추후 의존관계 주입에 관한 포스팅에서 알아보자.

 

참고 : 모든 개발자가 알아야만 하는 SOLID 원칙