본문 바로가기
개발 공부

[개발 공부] 객체지향 SOLID 원칙, 예제와 함께 이해하기

by 볼링치는 개발자 2022. 1. 24.
반응형

이번 포스팅에서는 자바 개발자 거나 취준생이시면 한 번은 들어봤을 법한 SOLID 법칙에 대해 포스팅해보려고 합니다.

 

SOLID를 학부생 시절에는 그냥 개념만 외우고 넘어갔던 내용인데, 자바 개발자를 꿈꾸면서 다시 한번 제대로 정리해보자 하여 글을 작성하게 되었습니다.

 

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 인터페이스를 필드 주입 방식으로 주입받고 있습니다.

 

⭐️ 참고 ⭐️

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

https://programforlife.tistory.com/111

 

[Spring] 생성자 주입을 사용해야 하는 이유

인턴을 시작한 초기 단계에, Spring 프로젝트 코드 분석을 하다가 신기한 점을 발견해서 질문을 한 기억이 있습니다. Spring프로젝트에서 Controller를 작성할 때, 저는 항상 @Autowired 어노테이션을 사

programforlife.tistory.com

 

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

 

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

DataSource 인터페이스를 구현하는 MySQLDataSource, MongoDBDataSource를 @Component 어노테이션으로 Bean Container 등록하는 경우, 둘 다 DataSource 타입이기 때문에, 타입을 기준으로 Bean Container에서 주입할 Bean을 찾는 방식인 @Autowired을 사용하면, 아래 사진처럼 DataSource 타입이 2개라서 어느 걸 주입해야 할지 모르겠습니다!라고 합니다.

@Qualifier 어노테이션으로 어느 구현체를 주입할지 명시해주거나, @Configuration어노테이션으로 설정 파일을 하나 작성해서 프로그램 실행 시 어느 구현체가 주입될지 명시해주시면 됩니다.

@Qualifier 어노테이션 적용

(설정 파일 작성하는 건 시간 관계상 패스)

 

최종적으로 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();
}

DataManager 인터페이스를 구현해서 잘 사용하고 있었다고 가정합시다.

 

그러다 어느 날, 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에 대해 완벽히 이해하면, 객체지향 설계 시 초기부터 제대로 설계하여 유지보수가 쉽고, 확장성 있고, 남들이 보기 좋은, 사용하기 좋은 객체지향적 애플리케이션을 개발하실 수 있습니다.

반응형

댓글