본문 바로가기
Language/자바&코틀린

객체지향 설계의 SOLID 원칙

by 코딩공장공장장 2023. 11. 15.

SOLID 원칙이란?

객체지향 프로그래밍 설계의 다섯가지 기본 원칙
유지보수와 확장이 쉬운 프로그램을 만들기 위한 목적

  • SRP(Single Responsibility Principle) : 단일 책임 원칙
  • OCP(Open/Close Principle) : 개방/폐쇄 원칙
  • LSP(Liskov Substitute Principle) : 리스코프 치환 원칙
  • ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
  • DIP(Dependency Inversion Principle) : 의존성 역전 원칙

 

1. SRP(Single Responsibility Principle) : 단일 책임 원칙

  • 모듈이나 클래스는 하나의 책임을 가져야 한다
  • 어떤 변화에 의해 클래스를 변경해야하는 이유는 오직 하나 뿐이어야 한다.(하나의 변경 원인만 가져아한다.)
    (한 책임의 변경에서 다른 책임의 변경으로의 연쇄작용에서 자유로울 수 있다.)

ex) 출력과 편집을 제공하는 Docs class

Class Docs {
	Page page = new Page;
    
	public Report printReport() {} 
	public Report editReport() {}
}

 

위 클래스는 문서편집기가 제공해야하는 기능을 제공하는 책임을 담당하고 있다. 

 

언뜻 보면 문서편집기를 하나의 덩어리로 본다면 단일 책임 원칙을 잘 지킨 패턴이라고 볼 수 있을수 도 있을 것 같다.

 

위 클래스에 변경 가능한 부분을 생각해보면 편집 형식(페이지 번호, 정렬, 페이지 방향 등)이 있을 수 있고,

 

출력 형식(홀수/짝수 페이지만, 특정 페이지만, 출력 용지 규격, 페이지 방향 등)이 있을 수 있다.

 

만약, 출력창에서 가로/세로 페이지 형식을 바꿔 출력하는 기능을 추가하기 위해

Class Docs {
	Page page = new Page;

	public Report printReport(boolean isPageVertical) {
		page.setPageDir(isPageVertical);
	} 
	public Report editReport() {}
}

 

이와 같이 소스코드를 수정하고 기능을 사용하였더니 출력창에서만 페이지의 가로/세로가 변경된 것이 아니라

 

편집창에서도 가로/세로가 변경되는 현상이 나타날 수 있다. 

 

Page 객체를 편집기능과 출력기능에 공유하니 출력에서만 가로/세로를 변경하는게 아니라 편집창에서까지 이어졌다.

 

따라서 아래와 같이

Class DocsPrinter {
	Page page = new Page;

	public Report printReport(boolean isPageVertical) {
		page.setPageDir(isPageVertical);
	} 
}

 

Class DocsEditor {
	Page page = new Page;

	public Report editReport() {}
}

 

문서 출력의 책임과 문서 편집의 책임을 분리하여 설계한다면 기존 설계 방식보다 영향도를 줄이며 변경에 대응할 수 있을 것이다.

 

 SRP 설계시 주의 사항

  • 책임의 명확한 정의(추상화), 변경의 이유
    추상화를 통해 같이 수정해야할 것들을 묶고, 따로 수정해야할 것은 분리시킨다.
  • 과도한 분리에 빠지지 않기
    단일 책임 원칙을 고려하다보면 마치 클래스 하나에 메서드 하나를 설계하는게 바람직한건가(?)라고 생각할 수 도 있겠지만, 단일 책임 원칙은 모듈의 응집도를 높이고 결합도를 낮추는데 있다. 즉, 하나의 클래스를 보고 이 클래스가 어떤 목적과 기능을 수행하는지 명확하게 알아야하기에(클래스 여러개를 거쳐가며 파악해야한다면 응집도가 낮은것), 책임을 마구잡이로 분산시키는 것만이 SRP를 잘 지킨 설계는 아니다.

SRP의 장점 

  • 낮은 결합도로 유지보수에 유리함
  • 코드의 재사용성 증가
  • 집도 향상(코드 파악, 클래스 파악 쉬워짐)
  • 쉬운 테스트

 

2. OCP(Open/Close Principle) : 개방/폐쇄 원칙

  • 기존의 코드를 수정하지 않고 기능을 확장할 수 있어야 한다.
  • 기능 수정에 대해서는 폐쇄적이고 확장에 대해서는 열려있는 구조
  • 추상화, 다형성과 연관이 깊다.

 * 추상화 : '구체적이지 않은' 정도 또는 '추상화란 다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징'이라고 정의한다.

 
Close : 변경에 닫혀있다. -> 기존의 소스 코드 수정에 폐쇄적 

Open :확장에 열려있다. -> 상속, 오버라이딩, 오버로딩과 같은 개념을 통한 기능의 확장

 

=> 변경시 기존 소스코드는 변경하지 않고(코드의 안정성) 기존 클래스 상속을 통한 오버라이딩으로 새로운 기능 제공(유연한 기능 확장)

=> 즉, 기존 기능 변경이 아닌 새로운 기능의 확장

 

 

ex) JDBC : DB의 종류가 추가되어도 기존 DB 연결에 영향을 주지 않고 새롭게 확장 시켜 나갈 수 있음

 

 

인터페이스 DatabaseConnector

interface DatabaseConnector {
    Connection connect() throws SQLException;
}

 

클래스 MysqlConnection

class MySqlConnection implements DatabaseConnector {
    private static final String URL = "jdbc:mysql://localhost:3306/your_database";
    private static final String USER = "your_username";
    private static final String PASSWORD = "your_password";

    @Override
    public Connection connect() throws SQLException {
        return DriverManager.getConnection(URL, USER, PASSWORD);
    }
}

 

클래스 OracleConnection

class OracleConnection implements DatabaseConnector {
    private static final String URL = "jdbc:oracle:thin:@localhost:1521:your_sid";
    private static final String USER = "your_username";
    private static final String PASSWORD = "your_password";

    @Override
    public Connection connect() throws SQLException {
        return DriverManager.getConnection(URL, USER, PASSWORD);
    }
}

 

위와 같이 클래스를 설계하고 각 DB에 맞게 커넥터를 연결하여 아래와 같이 사용한다고 할 때

public class DbConnect {
    public Connection connectDb(DatabaseConnector connector) {
        //데이터베이스에 연결
        Connection conn = connector.connect();
        return conn;
    }
}

 

새로운 DatabaseConnector가 추가 되어도 기존 소스인 DbConnect의 변경 발생 없이 사용이 가능하다.

 

 

3. LSP(Liskov Substitute Principle) : 리스코프 치환 원칙

  • 부모 클래스의 인스턴스는 자식 클래스의 인스턴스로 교체될 수 있어야 한다.
  • 자식객체의 확장시 부모객체가 의도한 대로 오버라이딩 이루어져야함(올바른 상속)
    ->  리스코프 치환 원칙은 행동적 하위형화(behavioral subtyping)에 중점을 두고 있으며 메서드의 시그니처가 일치하며 동작이 하위타입에서 올바르게 재정의되어야 한다는 것을 강조한다.
  • 상속, 다형성과 가장 깊은 관계가 있다.

ex) 직사각형과 정사각형의 상속 관계

Rectangle 클래스

public class Rectangle {

    public int width;
    public int 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 int getArea() {
    	return width * height;
    }
}

 

Square 클래스 

public class Square extends Rectangle{
    
    @Override
    public void setWidth(int Width) {
        super.setWidth(width);
        super.setHeight(getWidth());
    }
    
    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(getHeight());
    }
    
}

 

 

하위 클래스의 인스턴스를 상위클래스 인스턴스로 대체했을 때  너비 구하기

public class Main
{
	public static void main(String[] args)
	{
		Rectangle rectangle = new Rectangle();
		rectangle.setWidth(10);
		rectangle.setHeight(5);
		System.out.println(rectangle.getArea());
        
		rectangle = new Square();	//하위 클래스 인스턴스인 정사각형으로 대체
		rectangle.setWidth(10);
		rectangle.setHeight(5);
		System.out.println(rectangle.getArea());
	}
}

 

output

50
25

 

 

리스코프 치환 원칙에 의하면 부모 객체를 호출하는 동작에서 자식 객체가 부모객체를 완전히 대체 할 수 있어야합니다.

그런데 지금 자식 객체로 대체하니 결과가 50이 아닌 25가 나왔습니다. 

 

자식 객체가 부모 객체를 완전히 대체하지 못하였습니다.

이는, 정사각형과 직사각형이 올바른 상속관계가 아니라는 것을 의미합니다. 

 

why?. 분명 사각형의 성질로는 정사각형과 직사각형이 상속관계를 이루는데 왜 올바른 상속관계가 아니라는 거죠?

직사각형의 소스코드를 보면 width와 height가 각각 존재합니다. 하지만 정사각형은 width와 height가 각각 별도로 존재할 필요가 없습니다. 하지만 상속을 받게 되면 부모 클래스의 성질을 그대로 가져오기 때문에 정사각형 클래스는 마치 직사각형처럼 width와 height가 별도로 각각 존재하는 사각형이 되어버렸습니다.

 

그렇다면 제대로된 설계는?

 

상위타입으로 Shape 인터페이스 정의

interface Shape {
	int getArea();
}

 

Rectangle 클래스에서 Shape 인터페이스를 상속받아 getArea 오버라이딩

public class Rectangle implements Shape {

    public int width;
    public int height;
    
    public Rectangle(int width, int height){
    	this.width=width;
        this.height=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;
    }
    @Override
    public int getArea() {
    	return width * height;
    }
}

 

Square 클래스에서 Shape 인터페이스를 상속받아 getArea 오버라이딩

public class Square implements Shape {

    public int length;
    
    public Square(int length){
    	this.length=length;
    }

    public int getLength() {
        return length;
    }
    public void setLength(int length) {
        this.length = length;
    }

    @Override
    public int getArea() {
    	return length * length;
    }
}

 

위와 같이 설계하고 

 

도형의 넓이를 구해보면

public class Main
{
	public static void main(String[] args)
	{
		Shape shape = new Rectangle(10, 5);
		System.out.println(shape.getArea());
        
		shape = new Square(5);	//하위 클래스 인스턴스인 정사각형으로 대체
		System.out.println(shape.getArea());
	}
}

 

output

50
25

 

이제 Rectangle과 Square는 상속관계가 아니므로 결과가 다르다고 해서 LSP에 위반됬다고 생각할게 아니라

Shape의 getArea가 의도한 각 구현체의 넓이를 올바르게 구했으므로 LSP에 위배되지 않다고 볼 수 있다.

 

 

※ LSP의 장점 또는 LSP를 지켜야하는 이유

  • 올바른 상속관계 형성 -> 다형성을 통한 기능 확장 용이 -> 유지보수 용이
  • 부모 클래스와 하위 클래스 간의 일관성 형성 -> 코드 가독성 향상
  • 코드의 재사용성 증가(하위 클래스가 상위 클래스 대체 가능하므로 상위 클래스 타입으로 여러 하위 클래스 처리 가능)

 

4. ISP(Interface Segregation Principle) : 인터페이스 분리 원칙

  • 인터페이스를 사용하는 사용자를 기준으로 분리
  • 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙
  • 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다.
  • 의존성을 약화시키는 목적
  • 몇개의 큰 인터페이스가 있는 편 보다는 작은 인터페이스가 많은 편이 바람직하다.

 

ex) 자동차 인터페이스

interface Car {
	public void drive() ;
	public void reverse();

	public Gas getFuelState();
	public void fillFuel(Gas gas);
}

 

위의 자동차 인터페이스를 보면 전진,후진, 연료량 확인, 연료 채우기라는 자동차가 가져야하는 기능을 갖추고 있다.

 

그래서 아래와 같은 자동차의 상속 관계를 만들었다.

 

디젤, 가솔린, LPG 자동차 모두 Car 인터페이스를 통해 자동차가 갖춰야할 기능들을 모두 갖추게 되었다.

 

허나, 전기차의 등장 이후 getFuelState, fillFuel 기능이 필요가 없어졌다.

 

전기차는 내연기관 자동차와 달리 연료 주입이 아닌 배터리 충전이다. 

 

Gas를 리턴값이나 매개변수로 갖는 연료 충전, 연료 상태를 확인하는 메서드를 전기차에서는 사용하지 못한다. 

 

만약 위와 같은 구조에서 전기차를 구현한다면 아래와 같을 것이다.

 

class ElectricCar implements Car {
	public void drive() {
		//drive 로직...
	}
	public void reverse() {
		//reverse 로직...
	}

	public Gas getFuelState() {
		return null;
	}
	public void fillFuel(Gas gas) {}
	
	public void chargingBattery(Battery battery) {
		//배터리 충전
	}
	
	public Battery getBatteryState() {
		//배터리 상태
	}
	
	public Battery getBatteryLife() {
		//배터리 수명
	}
	
	public Battery changeBattery() {
		//배터리 교체
	}
}

 

위와 같이 getFuelState, fillFuel을 반드시 구현해야하므로 메서드를 비워놓고 배터리와 관련된 메서드를 추가할 것이다.

 

위 클래스에서 보듯이 전기차는 사용하지도 않는 getFuelState, fillFuel을 구현해야하므로 번거로움이 생긴다.

 

만약 더 많은 메서드가 존재했다면 더 큰 버건로움이 생겼을 것이다.

 

 

=> 인터페이스 분리 원칙을 준수한 설계

 

interface CarOperate {
	public void drive() ;
	public void reverse();
}

 

interface GasEngine {
	public Gas getFuelState();
	public void fillFuel(Gas gas);
}

 

interface BatteryEngine {
	public void chargingBattery(Battery battery);
	public Battery getBatteryState();
	public Battery getBatteryLife();
	public Battery changeBattery();
}

 

위와 같이 자동차 조작 인터페이스와 연료 충전 인터페이스를 분리하여 설계한다면 

 

내연기관 자동차와 전기 자동차가 갖는 서로 다른 속성으로 인해 불필요한 메서드 강제 오버라이딩을 막을 수 있다.

 

따라서 아래와 같이  getFuelState, fillFuel 없이 전기차 충전 기능만 오버라이딩 할 수 있다.

 

class ElectricCar implements CarOperator, BatteryEngine {
	public void drive() {
		//drive 로직...
	}
	public void reverse() {
		//reverse 로직...
	}

	public void chargingBattery(Battery battery) {
		//배터리 충전
	}
	
	public Battery getBatteryState() {
		//배터리 상태
	}
	
	public Battery getBatteryLife() {
		//배터리 수명
	}
	
	public Battery changeBattery() {
		//배터리 교체
	}
}

 

 

 

5.  DIP(Dependency Inversion Principle) : 의존성 역전 원칙

  • 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
  • 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.

    즉, 상위와 하위 객체 모두가 동일한 추상화에 의존해야 한다는 것이다.

상위 모듈은 다른 클래스를 사용하는 주된 클래스이고 하위 모듈은 사용되는 클래스를 나타낸다.

 

 

 

ex) 본인인증 시스템

회원가입을 위해 사용자 정보를 인증하는 시스템이 있다고 하자 사용자는 신분증 정보를 입력하여 인증할 수 있다.

 

따라서 우리는 아래와 같은 메서드를 통해 사용자 인증을 진행할 수 있다.

class UserAuthenticate {
	public boolean authenticateUser(IdCard idCard) {
		boolean isAuthenticate = false;
		//사용자 인증로직 ... 
		return isAuthenticate;
	}
}

 

이때 사용자의 요구사항이 추가되서 여권이나 운전면허증으로도 인증이 가능해지도록 구현해야하는 상황이 생겼다.

 

따라서 우리는 아래와 같이 두가지의 메서드를 추가하였다.

class UserAuthenticate {
	public boolean authenticateUser(IdCard idCard) {
		boolean isAuthenticate = false;
		//사용자 인증로직 ... 
		return isAuthenticate;
	}
    
    public boolean authenticateUser(Passport passport) {
		boolean isAuthenticate = false;
		//사용자 인증로직 ... 
		return isAuthenticate;
	}
    
    public boolean authenticateUser(DriverCard driverCard) {
		boolean isAuthenticate = false;
		//사용자 인증로직 ... 
		return isAuthenticate;
	}
}

 

신분증, 여권, 운전면허증이라는 매개변수마다 동일한 로직을 가진 메서드를 갖게 되었다. 

 

이는 유지보수 입장에서도 굉장히 비효율적이다.

 

만약 다른 인증 수단이 추가된다면 그때마다 메서드를 추가해야하고

 

메서드가 수정된다면 모든 메서드를 수정해야하는 상황이 생긴다. 

 

따라서 아래와 같은 관계에서 메서드의 매개변수를 상위 타입인 Credntial로 사용한다면

class UserAuthenticate {
	public boolean authenticateUser(Credential credential) {
		boolean isAuthenticate = false;
		//사용자 인증로직 ... 
		return isAuthenticate;
	}
}

 

사용자 인증수단마다 메서드를 추가할 필요없이 기존의 메서드로 사용이 가능하게 된다.

 

위와 같이 DIP는 하위 타입에 대한 종속성을 줄여 코드의 느슨한 결합을 통해 재사용성, 테스트 용이, 유지보수 향상 와 같은 장점을 얻을 수 있습니다.

 

 

 

반응형