코진남

JAVA SOLID Principles 본문

BackEnd/JAVA

JAVA SOLID Principles

woojin126 2022. 4. 1. 16:47

단일 책임 원칙 (Single Responsibility Principle)

단일 책임의 원칙(SRP, Single Responsibility Principle)은 하나의 모듈은 한 가지 책임을 가져야 한다는 것으로, 이것은 모듈이 변경되는 이유가 한가지여야 함을 의미한다. 여기서 변경의 이유가 한가지라는 것은 해당 모듈이 여러 대상 또는 액터들에 대해 책임을 가져서는 안되고, 오직 하나의 액터에 대해서만 책임을 져야 한다는 것을 의미한다.
만약 어떤 모듈이 여러 액터에 대해 책임을 가지고 있다면 여러 액터들로부터 변경에 대한 요구가 올 수 있으므로, 해당 모듈을 수정해야 하는 이유 역시 여러 개가 될 수 있다. 반면에 어떤 클래스가 단 하나의 책임 만을 갖고 있다면, 특정 액터로부터 변경을 특정할 수 있으므로 해당 클래스를 변경해야 하는 이유와 시점이 명확해진다.

 

단일 책임의 원칙을 위반하는 예시

책 정보 클래스

public class Book {
    String name;
    String authorName;
    int year;
    int price;
    String isbn;

    public Book(String name, String authorName, int year, int price, String isbn) {
        this.name = name;
        this.authorName = authorName;
        this.year = year;
        this.price = price;
        this.isbn = isbn;
    }
}

총 가격을 계산하기위한 송장 클래스

//총 가격을 계산하기위한 송장 클래스
public class Invoice {

    private Book book;
    private int quantity;
    private double discountRate;
    private double taxRate;
    private double total;

    public Invoice(Book book, int quantity, double discountRate, double taxRate, double total) {
        this.book = book;
        this.quantity = quantity;
        this.discountRate = discountRate;
        this.taxRate = taxRate;
        this.total = this.calculateTotal();
    }

    public double calculateTotal() {
        //할인된 책가격 X 수량
        double price = ((book.price - book.price * discountRate) * this.quantity);
        //세율
        double priceWithTaxes = price * ( 1 + taxRate );

        return priceWithTaxes;
    }

    public void printInvoice() {
        System.out.println(quantity + "x " + book.name + " " +          book.price + "$");
        System.out.println("Discount Rate: " + discountRate);
        System.out.println("Tax Rate: " + taxRate);
        System.out.println("Total: " + total);
    }

    public void saveToFile(String filename) {

    }
}

 

이코드의 문제점

1. 총 가격을 계산 하는 countTotal 메소드,

2. 콘솔에 송장을 인쇄해야 하는 printInvoice 메소드,

3. 파일에 송장 쓰기를 담당하는 saveToFile 메소드.

 

첫 번째, 위반은 이클래스는 송장 계산에대한 클래스인데, 출력 메서드가 포함되어있다. (책임과 맞지 않다.)

또한 책임과 맞지않는 메서드인 출력 format을 변경하기위해서는 클래스를 변경해야하는 상황까지 마주친다.

두 번째, 위반은 saveToFile 메서드또한 로직과, 비지니스 로직을 함께 쓰는 잘못된 예시중 하나이다.

총 정리, InVoice 클래스는 총 가격을 산출하는 클래스이다. 그런데 여기에 printInvoice, saveToFile 메서드의 필드를 변경시킨다고한다면 의도와 맞지도않는 메서드로인해 클래스에 사이드이펙드가 발생하게되며, 본질을 흐리게된다. 이를위해 아래에서 수정을해보자.
(아까 위에서 언급했던 클래스가 변경되야하는 하나의 이유만이 있어야하는데, 두가지가 더있다..

수정해보자

두개의 클래스를 따로 책임에맞게 생성한다, InvoicePrinter , InvoicePersistence,

public class InvoicePrinter {
    private Invoice invoice;

    public InvoicePrinter(Invoice invoice) {
        this.invoice = invoice;
    }

    public void print() {
        System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $");
        System.out.println("Discount Rate: " + invoice.discountRate);
        System.out.println("Tax Rate: " + invoice.taxRate);
        System.out.println("Total: " + invoice.total + " $");
    }
}

 

public class InvoicePersistence {
    Invoice invoice;

    public InvoicePersistence(Invoice invoice) {
        this.invoice = invoice;
    }

    public void saveToFile(String filename) {
        // Creates a file with given name and writes the invoice
    }
}

이렇게 분리하게된다면 단일 책임 원칙을 따르고 모든 클래스는 우리 애플리케이션의 aspect를 책임지게된다.

 

개방 폐쇄의 원칙(Open-Closed Principle)

개방-폐쇄 원칙은 
클래스는 확장을 위해 개방되어야 하고 수정을 위해 폐쇄되어야 함을 요구합니다.

수정은 기존 클래스의 코드를 변경하는 것이고 확장은 새로운 기능을 추가하는 것입니다.
따라서 이원칙이 말하고자 하는 바는 클래스의 기존코드는 건드리지 않고 새로운 기능을 추가 할 수 있어야한다는 것입니다. 기존 코드를 수정할 때마다 잠재적인 버그가 발생할 위험이 있기 때문입니다. 따라서 가능하면 테스트되고 신뢰할 수 있는(대부분) 프로덕션 코드를 건드리지 않아야 합니다.

따라서 클래스를 건드리지 않고 새로운 기능을 추가하려면 일반적으로 인터페이스와, 추상 클래스의 도움이 필요합니다.

 

위의 예제와 이어서, 상사가 Invoice를 데이터베이스에 저장하기를 원한다고 하자, 

public class InvoicePersistence {
    Invoice invoice;

    public InvoicePersistence(Invoice invoice) {
        this.invoice = invoice;
    }

    public void saveToFile(String filename) {
        // Creates a file with given name and writes the invoice
    }

    public void saveToDatabase() {
        // Saves the invoice to database
    }
}

불행하게도 우리는 개발자로서 쉽게 확장 할 수 있도록 클래스를 설계하지 않았다. 수정해보자

파일저장, 데이터페이스 저장 ( 공통적인 저장기능이다)

interface InvoicePersistence {

    public void save(Invoice invoice);
}

데이터베이스, 파일저장 클래스를 만들어 인터페이스를 implements 받는다.

public class DatabasePersistence implements InvoicePersistence {

    @Override
    public void save(Invoice invoice) {
        // Save to DB
    }
}
public class FilePersistence implements InvoicePersistence {

    @Override
    public void save(Invoice invoice) {
        // Save to file
    }
}

 

구조를보자

이러한 형태로인해 로직을 쉽게 확장 할 수 있다. 상사가 다른 데이터베이스를 추가하라고 명령을해도 MySql, MongoDb와 같은 다른 2가지 데이터베이스가 있다고 요청을한다면 쉽게 충족할 수 있다.

이처럼 InvoicePersistence 인터페이스를 구현하는 모든 클래스를 다형성의 도움으로 이 클래스에 전달할 수 있습니다. 이것이 인터페이스가 제공하는 유연성입니다.

 

Liskov 치환의 원칙

리스코프 치환 원칙은 1988년 바바라 리스코프가 올바른 상속 관계의 특징을 정의하기 위해 발표한 것으로, 하
위 타입은 상위 타입을 대체할 수 있어야 한다는 것 이다. 즉, 해당 객체를 사용하는 클라이언트는 상위 타입이 하위 타입으로 변경되어도, 차이점을 인식하지 못한 채 상위 타입의 퍼블릭 인터페이스를 통해 서브 클래스를 사용할 수 있어야 한다는 것이다.

 

예제로 보자

일반 사각형의 가로, 세로 ,넓이를 정의

package LiscovPrinciple;

public class Rectangle {

    protected  int width, height;

    public Rectangle() {

    }

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }

    public int getWidth() {
        return width;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}

 

정사각형 클래스를만들어 사각형 클래스를 상속

public class Square extends Rectangle {

    public Square() {}

    public Square(int size) {
        width = height = size;
    }

    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height);
    }
}

잘 살펴보자. 리스코프 치환의 법직이란 상위 객체를 하위객체로도 대체가 가능해야한다는 것이다.

살펴보면 우리는 Rectangle은 직사각형이라고 대부분 인식한다. 그런데 Square 정사각형이 직사각형을 상속받고 사용한다면 의도와 맞겠는가?

public class main3 {

    static void getAreaTest(Rectangle r) {
        int width = r.getWidth();
        r.setHeight(10);
        System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea());
    }

    public static void main(String[] args) {
        Rectangle rc = new Rectangle(2, 3);
        getAreaTest(rc);

        Rectangle sq = new Square();
        sq.setWidth(5);
        getAreaTest(sq);
    }
}

 

 

이와같이 Rectangle rc = new Rectangle(2, 3) 의 기대값은 1번 너비 20, 실제값 20이다.

 

그리고 하위 클래스인 new Square의 결과값은 기대값은 50인데, 얻는값은 100이다.

이러한것은 리스코브원칙의 위반이된다.

 

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

객체가 충분히 높은 응집도의 작은 단위로 설계됐더라도, 목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 적절하게 분리해줄 필요가 있는데, 이를 인터페이스 분리 원칙이라고 부른다. 즉, 인터페이스 분리 원칙이란 클라이언트의 목적과 용도에 적합한 인터페이스 만을 제공하는 것이다. 인터페이스 분리 원칙을 준수함으로써 모든 클라이언트가 자신의 관심에 맞는 퍼블릭 인터페이스(외부에서 접근 가능한 메세지)만을 접근하여 불필요한 간섭을 최소화할 수 있으며, 기존 클라이언트에 영향을 주지 않은 채로 유연하게 객체의 기능을 확장하거나 수정할 수 있다.
인터페이스 분리 원칙을 지킨다는 것은 어떤 구현체에 부가 기능이 필요하다면 이 인터페이스를 구현하는 다른 인터페이스를 만들어서 해결할 수 있다. 예를 들어 파일 읽기/쓰기 기능을 갖는 구현 클래스가 있는데 어떤 클라이언트는 읽기 작업 만을 필요로 한다면 별도의 읽기 인터페이스를 만들어 제공해주는 것이다.

요약:

1.하나의 인터페이스에 다양한 목적을 가진 추상메서드를 선언하기보다는 각 목적에 맞는 여러개의 인터페이스로 분리하는것이 낫다.

2.클라이언트 입장에서 사용하는 기능만 제공하도록 인터페이스를 분리해야 한다.

예제를 보자

단순한 주차장을 모델링했다. 시간당 요금을 지불하는 형태의 주차장이다. 이제 우리는 무료 주차장을 구현하고 싶다고

생각을 해보자.

public interface ParkingLot {

	void parkCar();	// Decrease empty spot count by 1
	void unparkCar(); // Increase empty spots by 1
	void getCapacity();	// Returns car capacity
	double calculateFee(Car car); // Returns the price based on number of hours
	void doPayment(Car car);
}

class Car {

}

 

무료 주차장 클래스

public class FreeParking implements ParkingLot {

	@Override
	public void parkCar() {
		
	}

	@Override
	public void unparkCar() {

	}

	@Override
	public void getCapacity() {

	}

	@Override
	public double calculateFee(Car car) {
		return 0;
	}

	@Override
	public void doPayment(Car car) {
		throw new Exception("Parking lot is free");
	}
}

우리의 주차장 인터페이스는 주차 관련 로직(주차장 주차, 주차 해제, 용량 확보)과 결제 관련 로직의 2가지 항목으로 구성되었습니다. 하지만 너무 구체적입니다. 이 때문에 FreeParking 클래스는 관련 없는 결제 관련 메서드를 구현해야 했습니다. 인터페이스를 분리하거나 분리합시다.

 

이제 주차장을 분리했습니다. 이 새로운 모델을 사용하면 더 나아가 PaidParkingLot 을 분할하여 다양한 유형의 결제를 지원할 수 있습니다.

이제 우리 모델은 훨씬 더 유연하고 확장 가능하며 주차장 인터페이스에서 주차 관련 기능만 제공하기 때문에 클라이언트는 관련 없는 로직을 구현할 필요가 없습니다.

 

의존 역전의 원칙(Dependency Inversion Principle)

객체 사이에 서로 도움을 주고받으면 의존 관계가 발생한다. 
DIP는 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것 보다는 변화하기 어려운 것, 거의 변화가 없는 것에 의존하라는 원칙

 

변하기 쉬운 것과 변하기 어려운것은 어떻게 구분할 수 있을까?

정책, 전략과 같은 어떤 큰 흐름이나, 개념 같은 추상적인 것은 변하기 어려운 것에 해당하고,

구체적인 방식, 사물 등과 같은 것은 변하기 쉬운 것으로 구분하면 좋다.

 

아이가 장난감을 가지고 노는데 어떤 경우에는 로봇을, 어떤 경우에는 자동차를 가지고 놀 것이다.

이때 구체저인 장난감은 변하기 쉬운 것이고, 아이가 장난감을 가지고 노는 사실은 변하기 어려울 것이다.

 

 

의존성 주입이란 말 그대로 클래스 외부에서 의존되는 것을 대상 객체의 인스턴스 변수에 주입하는 기술이다.

의존성 주입을 이용하면 다음과 같이 객체를 변경하지 않고도 외부에서 대상 객체의 외부 의존 객체를 바꿀 수 있다.

예제

public class Toy {

}
public class Kid {
    private Toy toy;

    public void setToy(Toy toy) {
        this.toy = toy;
    }

    public void play() {
        System.out.println(toy.toString());
    }
}

kid클래스에서 setToy 메서드로 아이가 가지고 노는장난감을 바꿀 수 있다.

만약 로봇 장난감을 가지고놀고 싶다면 다음코드가 그 일을 해줄 것이다.

public class Robot extends Toy {
    public String toString() {
        return "Robot";
    }
}

 

public class main4 {
    public static void main(String[] args) {
        Toy t = new Robot();
        Kid k = new Kid();
        k.setToy(t);
        k.play();
    }
}

 

레고를 가지고 놀고 싶다면 다음 코드면 된다.

Kid,Toy,Robot 등 기존의 코드에 전혀 영향을 받지 않고도 장난감을 바꿀 수 있따.

public class Lego extends Toy{
    public String toString() {
        return "Lego";
    }
}
public class main4 {
    public static void main(String[] args) {
        Toy t = new Lego();
        Kid k = new Kid();
        k.setToy(t);
        k.play();
    }
}

 

만약!!!!!!!!!!!!! Kid클래스가 Robot 클래스와 연관관계를 맺는다면 어떻게 될것인가?

public class Kid {
	private Robot toy;
    
    public void setToy(Robot toy) {
    	this.toy = toy;
}

	public void play() {
   	System.out.println(toy.toString());
    }
}



public class Main{
	public static void main(String[] args) {
     Robot t = new Robot();
     Kid k = new Kid();
     k.setToy(t);
     k.play();
    }
}

이런 경우 레고로 장난감의 종류를 변경하려면 기존의 Kid 클래스가 다음처럼 바껴야한다.

public class Kid{
	private Lego toy;
    
    public void setToy(Lego toy) {
    	this.toy = toy;
        }
        
    public void play() {
     	System.out.println(toy.toString());
    }
}

장난감을 바꿀 때 마다 코드를 계속 바뀌어야 한다. 즉 DIP의 위반이 OCP의 위반을 초래한다.

 

단일책임, OCP 이해하는데 도움많이 되었던 블로그

https://tecoble.techcourse.co.kr/post/2020-07-31-solid-1/

'BackEnd > JAVA' 카테고리의 다른 글

JVM 이란?  (0) 2022.04.04
OOP의 4가지 특징과 OOP 5가지 설계 원칙  (0) 2022.03.31
JAVA 란?  (0) 2022.03.31
interface VS abstract class 차이  (0) 2022.02.13
클래스와 객체란? 객체와 인스턴스?  (0) 2022.02.05