티스토리 뷰

SOLID란?

클린코드로 유명한 로버트 마틴이 좋은 객체 지향 설계의 5가지 원칙을 정리

  • SRP: 단일 책임 원칙
  • OCP: 개방-폐쇄 원칙
  • LSP: 리스코프 치환 원칙
  • ISP: 인터페이스 분리 원칙
  • DIP: 의존관계 역전 원칙

 

SRP 단일 책임 원칙

하나의 클래스는 하나의 기능을 담당해 하나의 책임을 수행하는데 집중되어야 있어야 합니다. 단일 책임 원칙 준수 유무에 따른 가장 큰 특징 기준 척도는, '기능 변경(수정)' 이 일어났을때의 파급 효과 이며, 모듈이 변경되는 이유가 한가지 여야 함을 뜻합니다.

 

책임이란? 

책임 = 해야 하는 것
       = 할 수 있는 것

       = 해야 하는 것을 잘 할 수 있는 것

 

산탄총 수술

산탄총 수술은 산탄이 사방으로 퍼져날라가 동물에게 맞았을 때 수술해야하는 부위가 많아지는 것 처럼 어떤 변경이 있을 때 하나가 아닌 여러 클래스를 변경해야한다는 점에서 착안되었습니다.  이러한 문제를 해결하기 위한 방법은 부가 기능을 별개의 클래스로 분리해 책임을 담당하게 하는 것입니다. 즉, 여러 곳에 흩어진 공통 책임을 한 곳에 모으면서 응집도를 높입니다.

 

주의점 
1. 클래스명은 책임의 소재를 알수있게 작명
2. 책임을 분리할때 항상 결합도, 응집도 따져가며

 

OCP 개방-폐쇄 원칙 

OCP원칙은 확장에는 열려 있으나 변경에는 닫혀 있어야 한다는 것입니다. 이는 변해야 하는 것은 쉽게 변할 수 있게 하고, 변하지 않아야 할 것은 변하는 것에 영향을 받지 않게 해야 한다는 것입니다.따라서 OCP원칙은 추상화를 의미한다고 보면 됩니다.


 

상속을 통한 다형성으로 클라이언트 클래스가 어떤 동작을 실행할 때 클라이언트에 영향을 주지 않고 쉽게 동작을 확장할 수 있게 한다. 클래스 Shape가 확장되더라도 클라이언트 클래스는 영향을 받지 않는다.

확장에 열려있다.

새로운 변경 사항이 발생했을 때 유연하게 코드를 추가함으로써 애플리케이션의 기능을 큰 힘을 들이지 않고 확장할 수 있다는 것입니다. 

변경에 닫혀있다.

객체를 직접적으로 수정하지 않고도 변경사항을 적용할 수 있도록 설계해야 합니다.

규칙
1. 먼저 변경(확장)될 것과 변하지 않을 것을 엄격히 구분한다.
2. 이 두 모듈이 만나는 지점에 추상화(추상클래스 or 인터페이스)를 정의한다.
3. 구현체에 의존하기보다 정의한 추상화에 의존하도록 코드를 작성 한다.

 

LSP 리스코프 치환 원칙

부모 객체와 자식 객체가 있을 때 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있다는 원칙입니다.

주의점
LSP 원칙의 핵심은 상속이다. 그런데 주의할 점은, 객체 지향 프로그래밍에서 상속은 기반 클래스와 서브 클래스 사이에 IS-A 관계가 있을 경우로만 제한 되어야 한다. 그 외의 경우에는 합성을 이용하도록 권고되어 있습니다. 따라서 다형성을 이용하고 싶다면 extends 대신 인터페이스로 implements하여 인터페이스 타입으로 사용해야 하며, 상위 클래스의 기능을 이용하거나 재사용을 하고 싶다면 상속(inheritnace) 보단 합성(composition)으로 구성하는게 좋다.

 

LSP를 위반하는 전형적인 예로, 너비와 높이의 조회(getter) 및 할당(setter) 메서드를 가진 직사각형 클래스로부터 정사각형 클래스를 파생하는 경우를 들 수 있습니다. 정사각형 클래스는 항상 너비와 높이가 같다고 간주할 수 있습니다. 정사각형 객체가 직사각형을 다루는 문맥에서 사용되는 경우, 정사각형의 크기는 독립적으로 변경할 수 없기 때문에 (혹은 그래서는 안되기 때문에) 예기치 못한 행동을 하게 됩니다.

public class Rectangle
{
	protected int width;
	protected int height;
	
	/**
	 * 너비 반환 함수
	 *
	 * @return [int] 너비
	 */
	public int getWidth()
	{
		return width;
	}
	
	/**
	 * 높이 반환 함수
	 *
	 * @return [int] 높이
	 */
	public int getHeight()
	{
		return height;
	}
	
	/**
	 * 너비 할당 함수
	 *
	 * @param width: [int] 너비
	 */
	public void setWidth(int width)
	{
		this.width = width;
	}
	
	/**
	 * 높이 할당 함수
	 *
	 * @param height: [int] 높이
	 */
	public void setHeight(int height)
	{
		this.height = height;
	}
	
	/**
	 * 넓이 반환 함수
	 *
	 * @return [int] 넓이
	 */
	public int getArea()
	{
		return width * height;
	}
}


public class Square extends Rectangle
{
	/**
	 * 너비 할당 함수
	 *
	 * @param width: [int] 너비
	 */
	@Override
	public void setWidth(int width)
	{
		super.setWidth(width);
		super.setHeight(getWidth());
	}
	
	/**
	 * 높이 할당 함수
	 *
	 * @param height: [int] 높이
	 */
	@Override
	public void setHeight(int height)
	{
		super.setHeight(height);
		super.setWidth(getHeight());
	}
}


public class Main
{
	/**
	 * 메인 함수
	 *
	 * @param args: [String[]] 매개변수
	 */
	public static void main(String[] args)
	{
		Rectangle rectangle = new Rectangle();
		rectangle.setWidth(10);
		rectangle.setHeight(5);
		
		System.out.println(rectangle.getArea());
	}
}

예상 결과는 50이지만 output은 25가 나옵니다.

따라서 다음과 같이 수정할 수 있습니다. 

public class Shape
{
	protected int width;
	protected int height;
	
	/**
	 * 너비 반환 함수
	 *
	 * @return [int] 너비
	 */
	public int getWidth()
	{
		return width;
	}
	
	/**
	 * 높이 반환 함수
	 *
	 * @return [int] 높이
	 */
	public int getHeight()
	{
		return height;
	}
	
	/**
	 * 너비 할당 함수
	 *
	 * @param width: [int] 너비
	 */
	public void setWidth(int width)
	{
		this.width = width;
	}
	
	/**
	 * 높이 할당 함수
	 *
	 * @param height: [int] 높이
	 */
	public void setHeight(int height)
	{
		this.height = height;
	}
	
	/**
	 * 넓이 반환 함수
	 *
	 * @return [int] 넓이
	 */
	public int getArea()
	{
		return width * height;
	}
}


class Rectangle extends Shape
{
	/**
	 * Rectangle 생성자 함수
	 *
	 * @param width: [int] 너비
	 * @param height: [int] 높이
	 */
	public Rectangle(int width, int height)
	{
		setWidth(width);
		setHeight(height);
	}
}


class Square extends Shape
{
	/**
	 * Square 생성자 함수
	 *
	 * @param length: [int] 길이
	 */
	public Square(int length)
	{
		setWidth(length);
		setHeight(length);
	}
}

public class Main
{
	/**
	 * 메인 함수
	 *
	 * @param args: [String[]] 매개변수
	 */
	public static void main(String[] args)
	{
		Shape rectangle = new Rectangle(10, 5);
		Shape square = new Square(5);

		System.out.println(rectangle.getArea());
		System.out.println(square.getArea());
	}
}

 

 

리스코프 치환 원칙와 개방 폐쇄 원칙

자식 클래스가 클라이언트의 관점에서 부모 클래스를 대체할 수 있다면 기능 확장을 위해 자식 클래스를 추가하더라도 코드를 수정할 필요가 없어진다. 따라서 리스코프 치환 원칙은 개방-폐쇄 원칙을 만족하는 설게를 위한 전제 조건이다. 일반적으로 리스코프 치환 원칙 위반은 잠재적인 개방-폐쇄 원칙 위반이다. 

 

ISP 인터페이스 분리 법칙

객체는 자신이 사용하는 메서드에만 의존해야 합니다. 따라서 반드시 객체가 자신에게 필요한 기능만을 가지도록 불필요한 상속과 구현을 최대한 방지함으로써 객체의 불필요한 책임을 제거해야 합니다.  SRP원칙이 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조합니다. 핵심은 관련 있는 기능끼리 하나의 인터페이스에 모으되 지나치게 커지지 않도록 크기를 제한해야 하는 것입니다.

주의점

한번 인터페이스를 분리하여 구성해놓고 나중에 무언가 수정사항이 생겨서 또 인터페이스들을 분리하는 행위를 가하지 말아야 한다. SRP를 만족하더라고 ISP를 반드시 만족한다고는 할 수 없다.

 

DIP 의존관계 역전 원칙 

추상화에 의존해야지 구체화에 의존해서는 안된다는 것입니다. 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 되며 대신 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 합니다. 클라이언트(사용자)가 상속 관계로 이루어진 모듈을 가져다 사용할때, 하위 모듈을 직접 인스턴스를 가져다 쓰지 말아야 합니다. 왜냐하면 그렇게 할 경우, 하위 모듈의 구체적인 내용에 클라이언트가 의존하게 되어 하위 모듈에 변화가 있을 때마다 클라이언트나 상위 모듈의 코드를 자주 수정해야 되기 때문입니다.

DIP는 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는 변화하기 어려운 것, 거의 변화가 없는 것에 의존하라는 원칙이다. 이는 인터페이스나 추상 클래스와 의존 관계를 맺도록 설계해야 한다는 것이다.
인터페이스 = 변하지 않는 것
구체 클래스 = 변하기 쉬운 것


디미터 법칙 (Law of Dememter)

 "최소한의 지식 원칙(The Principle of Least Knowledge)"으로 알려져 있으며, 모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다는 것을 의미합니다. 즉, 간단하게 말하면 여러개의 .(dot)을 최대한 사용하지 말라는 법칙으로 말할 수 있으며, 디미터 법칙을 준수하게 되면 캡슐화를 높여 객체의 자율성과 응집도를 높일 수 있습니다. 한번 직접적인 예시를 통해 좀 더 자세히 알아보겠습니다. 

@Getter
public class Employee {

    private final String name;
    private final Enterprise enterprise;
    
    public int getEnterprisePostalCode() {
        return this.enterprise.getAddressPostalCode();
    }

}

@Getter
public class Enterprise {

    private final int employeeNumber;
    private final String domain;
    private final Address address;

    public int getAddressPostalCode() {
        return this.address.getPostalCode();
    }
    
}

@Getter
public class Address {

    private final String street;
    private final int postalCode;
    private final String city;

}

public class App {

    public static void main(String[] args) {
        final Address address = new Address("종로구 청와대로 1", 03054, "서울특별시");
        final Enterprise enterprise = new Enterprise(100, "청와대", address);
        final Employee employee = new Employee("안주형", enterprise);

        // 1번 - 디미터 법칙 위반 코드!!
        System.out.println(employee.getEnterprise().getAddress().getPostalCode());

        // 2번 - 디미터 법칙 준수 코드!!
        System.out.println(employee.getEnterprisePostalCode()); 
    }
}

둘 다 회사의 우편번호를 얻는 로직이지만 1번의 경우는 2번과 달리 객체에게 우편번호를 얻어오기 위한 메시지를 보내는 것이 아닌 객체가 가지고 있는 자료를 직접 확인하고 있습니다. 따라서 처음에 말했던 문제인 다른 객체가 어떠한 자료를 갖고 있는지 지나치게 잘 알게 됩니다.

2번의 경우는 1번의 코드처럼 여러개의 .(dot)를 사용하여 참조하지 않고, 함수라는 메시지를 통해 자료를 제공받고 있기 때문에 디미터 법칙을 잘 준수하는 코드가 됩니다.

즉, A에서 C의 정보를 얻어 오기 위한 방법은 C에서 B로, B에서 A로 값을 캡슐화하여 값을 가져오도록 하는 것입니다.

 

단, DTO와 자료구조 같은 경우에는 내부 구조를 외부에 노출하는 것이 당연하므로 디미터 법칙을 적용하지 않습니다. 또한 Java Stream API처럼 메서드 체이닝(method chaining)을 사용하는 경우는 디미터 법칙을 위반하지 않습니다.
디미터 법칙은 결합도와 관련된 이야기이므로, 본질을 잊고 .(dot)에 매몰되어서는 안 됩니다.

'CS > OOP' 카테고리의 다른 글

추상 클래스와 인터페이스의 차이점  (0) 2024.04.17
UML(UML 필요성, 클래스 다이어그램)  (1) 2024.03.29
객체 지향 원리 적용  (2) 2023.03.03