SOLID 법칙

서버란·2024년 9월 2일

CS 지식

목록 보기
6/25
  • SOLID는 SRP, OCP, LSP, ISP, DIP의 앞 글자들을 딴 용어입니다.

SRP (Single Responsibility Principle) - 단일 책임 원칙
OCP (Open-Closed Principle) - 개방 폐쇄 원칙
LSP (Liscov Substitution Principle) - 리스코프 치환 원칙
ISP (Interface Segregation Principle) - 인터페이스 분리 원칙
DIP (Dependency Inversion Principle) - 의존 관계 역전 원칙

  • 장점

    유지보수가 쉬워진다
    확장성이 좋아진다.
    재사용성이 상승한다.
    자연적인 모델링이 가능해진다.
    클래스 단위로 모듈화 해서 대형 프로젝트 개발이 용이해진다.

SRP(Single Responsibility Principle)

  • 클래스와 메서드는 하나의 역할만 하도록 한다.
  • 혹은, 클래스와 메서드는 한 이유로만 변경되어야 한다.
  • 이는 낮은 결합도, 높은 응집도를 얻기 위한 원칙입니다.
  • 애플리케이션에 로그를 저장하거나, 로그를 찍는 LoggingService클래스가 있다고 가정합시다.
    (내부적으로 로그를 어떻게 저장하던, 로그를 찍던 신경 안 써도 됩니다!)
public class LoggingService {

    private DataSource loggingDB = new MySQLDataSource();
    
    //로그를 출력하고 저장하는 비즈니스 로직
    ...

}
 
  • 위의 코드를 보시면 LoggingService는 로그를 저장하는 저장소인 loggingDB를 사용하고 있습니다.

  • 해당 코드를 작성할 때 개발자가 추후 데이터베이스의 변경이 있을 수 있다고 예상하여, DataSource 인터페이스를 만들고, 그 구현체인 MySQL 데이터베이스를 사용하는 MySQLDataSource 구현체를 만들어서 사용하고 있습니다.

  • 잘 설계된 것처럼 보이지만, 이는 자세히 보면 단일 책임 원칙을 위반하고 있습니다.

  • loggingDB 객체를 new 키워드를 사용해 직접 생성하고 있다는 점에 주목해야 합니다.

  • new 키워드의 사용으로 인해 LoggingService는 2가지 역할을 갖게 됩니다.

  1. loggingDB 객체 생성
  2. 로그 출력, 저장 등 비즈니스 로직
  • 이는 SRP, 단일 책임 원칙에 위배됩니다.

  • 클래스와 메서드는 하나의 역할만 하도록 한다.

  • 로그를 찍고 저장하는 역할 외에도 로그를 저장하는 데이터베이스까지 스스로 생성하고 있습니다.

  • 즉, 단일 책임이 아니라 2가지의 책임을 갖고 있는 겁니다.

  • 클래스와 메서드는 한 이유로만 변경되어야 한다.

  • 현재 코드를 보면 loggingDB는 DataSource의 구현체인 MySQLDataSource를 사용하고 있습니다.

  • 추후 MySQL 데이터에비스가 아닌 MongoDB를 사용하게 된다면 어떻게 수정해야 할까요?

  • DataSource 인터페이스를 구현하는 MongoDBDataSource 클래스를 작성하고, 위 코드를 아래와 같이 수정해야 합니다.
public class LoggingService {

    private DataSource loggingDB = new MongoDBDataSource();
    
    //로그를 출력하고 저장하는 비즈니스 로직
    ...

}
  • LoggingService 클래스가, 내부 비즈니스 로직 때문이 아닌, 외부 클래스의 구현의 변경으로 인해 변경되었습니다.

  • 즉, LoggingService는 하나 이상의 이유로 변경되고 있습니다.

  • LoggingService가 SRP, 단일 책임 원칙을 위반하고 있으니 해결해보겠습니다.

  • Spring Framework는 위 문제를 해결해주는 기능인 DI(Dependency Injection)를 제공하고 있습니다.

  • LoggingService를 수정해보겠습니다.

public class LoggingService {

    @Autowired
    private DataSource loggingDB;
    
    //로그를 출력하고 저장하는 비즈니스 로직
    ...

}
  • Spring에서 제공하는 @Autowired 어노테이션을 통해 Datasource 인터페이스를 필드 주입 방식으로 주입받고 있습니다.

⭐️ 참고 ⭐️

  • 물론 필드 주입 방식보다 생성자 주입 방식이 권장되지만, 예제니까 넘어가도록 하겠습니다. 자세한 내용은 아래 링크를 참고해주세요

  • DataSource라는 인터페이스를 구현하는 MongoDBDataSource 클래스를 작성하고, Bean으로 등록해서 Bean Container에 담아두고, @Autowired 어노테이션으로 주입받아 사용하면 됩니다.

여기서 주의할 점이 있습니다.

  • DataSource 인터페이스를 구현하는 MySQLDataSource, MongoDBDataSource를 @Component 어노테이션으로 Bean Container 등록하는 경우, 둘 다 DataSource 타입이기 때문에, 타입을 기준으로 Bean Container에서 주입할 Bean을 찾는 방식인 @Autowired을 사용하면, 아래 사진처럼 DataSource 타입이 2개라서 어느 걸 주입해야 할지 모르겠습니다!라고 합니다.
  • @Qualifier 어노테이션으로 어느 구현체를 주입할지 명시해주거나, @Configuration어노테이션으로 설정 파일을 하나 작성해서 프로그램 실행 시 어느 구현체가 주입될지 명시해주시면 됩니다.
  • 최종적으로 LoggingService는 이렇게 작성됩니다.

  • @Qualifier 어노테이션을 사용하지 않고, 설정 파일을 작성해서 어느 구현체를 주입할지 명시해줬다고 가정합니다.

public class LoggingService {

    @Autowired
    private DataSource loggingDB;
    
    //로그를 출력하고 저장하는 비즈니스 로직
    ...

}
  • 이제 LoggingService 클래스는 더 이상 loggingDB가 어느 구현체를 사용하는지 new 키워드를 통해 생성하지 않습니다.

  • 즉 DB 생성에 대한 역할이 사라지고, 비즈니스 로직만 수행하면 되는 단일 책임 원칙을 지키게 되었습니다.

  • 이 상태에서, DataSource를 MongoDB에서 MariaDB로 바꿔야 하는 상황이 발생하면, LoggingService는 변경되지 않고, MariaDBDataSource 클래스를 작성하고, 설정 파일에서 해당 구현체가 주입되도록 수정해주면, LoggingService는 MariaDBDataSource 클래스를 사용하게 됩니다.

  • 즉 LoggingService는 비즈니스 로직 변화가 생기면, 단 한 개의 이유만으로 변경됩니다.

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

  • 확장엔 열려있고 (Open) , 수정엔 닫혀있자 (Closed)

  • 여기서 확장이란 새로운 기능의 추가, 수정이란 기존 소스코드의 변경을 말합니다.

  • 주로 open은 지키기 쉽지만, closed를 지키기 어렵다는 의견을 많이 듣습니다.

  • 앞서 길게 설명한 예제에서, 수정 전의 코드가 SRP을 위반하는 동시에 OCP를 위반하는 코드입니다.

public class LoggingService {

    private DataSource loggingDB = new MySQLDataSource();
    
    //로그를 출력하고 저장하는 비즈니스 로직
    ...

}
  • 새로운 데이터베이스를 추가하게 되면 (확장), MongoDBDataSource라는 클래스를 작성하면 됩니다.

  • 이는 확장에 열려있는 상태입니다.

  • 단, MongoDBDataSource 클래스를 다 작성하고 나면, LoggingService 소스코드를 수정해줘야 합니다.

  • 이는 변경에도 열려있는 상태입니다.

private DataSource loggingDB = new MongoDBDataSource();

앞서 언급한 예시와 같이 다음과 같이 코드를 작성하면 OCP 원칙도 지키게 됩니다.

public class LoggingService {

    @Autowired
    private DataSource loggingDB;
    
    //로그를 출력하고 저장하는 비즈니스 로직
    ...

}

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

  • B가 A의 자식 타입이면, 부모 타입인 A객체는 자식 타입인 B로 치환해도 작동에 문제가 없어야 한다

  • 리스코프 치환 원칙이 가장 이해하기 어렵고 와닿지 않는 원칙이었습니다.

  • 리스코프 치환 원칙은 코드상에서의 설계보다 객체지향적 설계 부분에서 적용되는 원칙입니다.

  • 객체지향 설계 중 상속이라는 개념을 사용하게 되면 하나의 부모 클래스와, 그를 상속받는 다양한 하위 클래스가 생성되는 건 불가피합니다.
  • 제대로 된 객체지향 설계라면, 부모 클래스 대신에, 하위 클래스 중 아무거나 가져다가 사용해도 오류가 없어야 제대로 된 객체지향 설계라고 할 수 있습니다.
  • 즉, 자식 클래스는 부모 클래스의 역할을 상속받아 구현하되, 부모 클래스의 스펙, 설계와 어긋나는 행동을 하면 안 됩니다.
  • 인터넷에 흔히 돌아다니는 예제인 직사각형-정사각형 말고 다른 실생활 예제를 생각해봤지만, 사각형 예제가 가장 이해가 잘 될 것 같습니다.
  • 가로, 높이의 필드와 넓이를 반환하는 메서드를 가지는 Rectangle 직사각형 클래스와, Rectangle 클래스를 상속받아 가로, 높이 필드와 넓이를 반환하는 메서드를 가지는 Square 정사각형 클래스가 있습니다.
class Rectangle {
    int width;
    int height;
    
    public int getArea() {
    	return width * height;
    }
}

class Square extends Rectangle {
    
    @Override
    public int getArea() {
    	return width * width;
    }
}
  • 직사각형 클래스와 정사각형 클래스에 대해 테스트 코드를 작성해보겠습니다.
//넓이를 계산하는 어플리케이션 비즈니스 로직
public int getArea(Rectangle rectangle) {
    rectangle.setHeight(10);
    rectangle.setWidth(20);
    
    return rectangle.getArea();
}


@Test
@Displayname("직사각형 클래스, 정사각형 클래스 넓이 구하는 로직 테스트")
public void testArea() {
    Rectangle rec = new Rectanlge();
    Rectangle square = new Sqaure();
    
    getArea(rec); //결과 : 200
    getArea(square); //결과 : 100
}
  • 이 테스트 코드에서 볼 수 있듯이, 기존 애플리케이션에 직사각형 대신에 정사각형으로 치환하면 결과가 달라지며 오류가 발생합니다.

  • 여기서 리스코프 치환 원칙에 위배되는것을 확인할 수 있습니다.

  • 이는 직사각형과 정사각형 간의 상속 관계 설계 오류입니다.

  • 둘이 특성이 다른데, 정사각형이 직사각형을 상속받아서 생기는 오류입니다.

  • 리스코프 치환 원칙을 지키기 위해, 직사각형과 정사각형이 Shape이라는 도형 클래스를 상속받도록 수정하면 됩니다.
public class Shape {
    public int getArea();
}

public class Square extends Shape {
    private int width;
    
    @Override
    public int getArea() {
    	return width * width;
    }
}

public class Rectangle extends Shape {
    private int width;
    private int height;
    
    @Override
    public int getArea() {
		return width * height;
    }
}
  • 정사각형이 직사각형을 상속받았던 상속 설계의 오류를, 정사각형과 직사각형 둘 다 만족하는 Shpae클래스를 새로 도입해서 리스코프 치환 원칙을 지켰습니다.
  • 리스코프 치환 원칙은 초기에 객체 지향 설계 시 제대로 된 설계가 된다면 위반할 가능성이 현저히 낮아집니다.

  • 또 다른 표현으로, 완벽한 Is-a 관계가 되도록 상속 구조를 정의해야 합니다.

  • 상속의 관계에서 앞서 언급한 Shape과 Square는 is-a 관계이고, has-a 관계의 예시는 Gun 클래스를 상속받아 사용하는 Police 클래스로 예를 들 수 있습니다.

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

  • 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하면 안 된다.
  • 인터페이스는 하나의 동작을 위해 존재해야 한다
  • 이 원칙은 인터페이스의 존재 이유가, 하나의 역할만 수행하기 위해 존재해야 하는 원칙입니다.
  • 다른 회사 정보들을 불러와서 우리 회사 시스템에 저장해야 하는 애플리케이션을 개발해야 할 수요가 생겼습니다.

  • 한 회사의 정보만 불러올게 아니라, 여러 다양한 회사 정보를 불러와야 하기 때문에 인터페이스를 설계하고, 다양한 구현체를 개발하기로 객체 지향적 설계를 했습니다.

  • 그 인터페이스가 DataManger 인터페이스입니다.

public interface DataManager {
    //데이터 불러옴
    public void laod();
    
    //데이터 준비
    public void prepare();
    
    //데이터 저장
    public void save();
}
  • 그러다 어느 날, A 회사 데이터를 확인해야 하는 서비스를 구현할 요구사항이 생겼습니다.

  • 단, 이 서비스는 데이터를 불러오기만 하고 확인만 하지, 저장은 하지 않습니다.

  • 해당 서비스를 ACompDataService라고 하고 다음처럼 작성하겠습니다.

  • ACompDataService 클래스는 DataManager를 상속받기 때문에 load, prepare, save 메서드를 모두 구현해야 합니다.

public class ACompDataService implements DataManager {
    //데이터 불러옴
    @Override
    public void laod() { ... }
    
    //데이터 준비
    @Override
    public void prepare() { ... }
    
    //데이터 저장
    @Override
    public void save() { ... }
}
  • 여기서 문제점은, ACompDataService클래스는, 필요도 없는 비즈니스 로직을 가진 prepare, save 메서드를 구현하고 있습니다.
  • 만약 DataManager 인터페이스의 요구사항 변경으로 prepare나 save 메서드가 변경되면, ACompDataService클래스는 사용도 안 하는 메서드 때문에 리팩토링의 필요성이 생깁니다.
  • 이는 인터페이스의 분리가 제대로 이뤄지지 않아서 발생한 일입니다.
  • 인터페이스 분리 원칙을 지키기 위해 DataManager 인터페이스를 다음과 같이 잘게 쪼개겠습니다.
public interface DataLoader {
    public void load();
}

public interface DataSaver {
    public void save();
}

public interface DataPreparer {
    public void prepare();
}
  • 만약 데이터를 불러오기만 하는 load 기능이 필요하면, DataLoader 클래스를 구현하면 되고, 불러오고 저장까지 하는 기능이 필요하면 DataLoader, DataSaver모두 구현하면 됩니다.
  • 이렇게 인터페이스를 역할 단위로 잘게 분리하면서 인터페이스 분리 원칙을 지키게 되었습니다.

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

  • 추상화에 의존해야 한다. 구현체에 의존하면 안 된다.
  • 이 원칙은 SRP, OCP를 설명할 때 사용했던 예제로 설명이 가능합니다.
  • 로그를 찍고 저장하는 LoggingService 기억하시나요?

  • 리팩토링 하기 전의 LoggingService는 이렇게 구현되어있었습니다.

public class LoggingService {

    private DataSource loggingDB = new MySQLDataSource();
    
    //로그를 출력하고 저장하는 비즈니스 로직
    ...

}
  • 그중 주목해야 할 소스 코드는 여기입니다.

  • private DataSource loggingDB = new MySQLDataSource();

  • 여기서 DataSource 인터페이스는 추상화, MySQLDataSource는 추상화를 구현한 구체화입니다.

  • LoggingService는 현재 코드에 의하면 추상화, 구체화 모두 의존하고 있습니다.

  • DIP, 의존 관계 역전 원칙을 위배한 것입니다.

  • 앞서 말한 것처럼 Spring에서 제공해주는 기능으로 아래와 같이 코드를 수정합니다.

public class LoggingService {

    @Autowired
    private DataSource loggingDB;
    
    //로그를 출력하고 저장하는 비즈니스 로직
    ...

}
  • 그럼 LoggingService는 DataSource라는 추상화에 의존하지만, 구체화에 의존하는 코드는 사라져서 DIP를 지키게 됩니다.

정리

이렇게 객체 지향 설계 원칙 5가지인 SOLID에 대해 알아보았습니다.

  • SRP
    클래스와 메서드는 하나의 역할만 하도록 한다.
    클래스와 메서드는 한 이유로만 변경되어야 한다.
  • OCP
    확장엔 열려있고 (Open) , 수정엔 닫혀있자 (Closed)
  • LSP
    B가 A의 자식 타입이면, 부모 타입인 A객체는 자식 타입인 B로 치환해도 작동에 문제가 없어야 한다
  • ISP
    클라이언트는 자신이 사용하지 않는 인터페이스에 의존하면 안 된다.
    인터페이스는 하나의 동작을 위해 존재해야 한다.
  • DIP
    추상화에 의존해야 한다. 구현체에 의존하면 안 된다.
  • 객체지향 5원칙 SOLID에 대해 완벽히 이해하면, 객체지향 설계 시 초기부터 제대로 설계하여 유지보수가 쉽고, 확장성 있고, 남들이 보기 좋은, 사용하기 좋은 객체지향적 애플리케이션을 개발하실 수 있습니다.
profile
백엔드에서 서버엔지니어가 된 사람

0개의 댓글