본문 바로가기

클린한 코드/OOP

객체 지향 설계 5원칙 - SOLID

728x90

SOLID란?

객체 지향 프로그래밍 및 설계의 5가지 기본 원칙을 두문자어로 나타낸 것입니다. 응집도는 높이고, 결합도는 낮추라는 고전 원칙 (High cohesion, Loose coupling)을 객체 지향 관점에서 재정립한 것입니다. 

 

SOLID는 아래의 5가지에 해당합니다.

  • SRP (Single Responsibility Principle): 단일 책임 원칙
  • OCP (Open Closed Principle): 개방 폐쇄 원칙
  • LSP (Liskov Substitution Principle): 리스코프 치환 원칙
  • ISP (Interface Segregation Principle): 인터페이스 분리 원칙
  • DIP (Dependency Inversion Principle): 의존 역전 원칙
결합도와 응집도 ※ 

결합도: 모듈(클래스) 간의 상호 의존 정도로, 결합도가 낮으면 모듈(클래스) 간의 상호 의존 정도가 낮아 객체의 재사용이나 수정 및 유지보수가 용이합니다.
응집도: 하나의 모듈 내부에 존재하는 구성 요소들의 기능적 관련성으로, 응집도가 높으면 모듈이 하나의 책임에 집중하고 독립성이 높아져 재사용이나 기능의 수정 및 유지보수가 용이합니다.

 


 

SRP - 단일 책임 원칙

어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.
- 로버트 C. 마틴

예를 들어 아래와 같이 남자라고 하는 클래스와, 남자 클래스에 의존하는 다양한 클래스가 있다고 가정해 보겠습니다.

 


딱 봐도 남자는 너무 피곤할 것 같습니다. 남자 클래스가 맡은 역할과 책임이 너무 많기 때문이죠. 만약, 여자친구랑 헤어졌다면 남자 클래스는 더 이상 데이트하기()나 기념일챙기기() 메서드(역할)를 수행할 필요가 없어지게 됩니다. 따라서, 역할(책임)을 분리하여 코드를 작성하라는 것이 SRP입니다. 즉, 아래와 같이 바꿀 수 있습니다.

 

 


만약, SRP 원칙을 지키지 않는다면, 남자 클래스를 호출하는 입장이 누구냐에 따라 남자 클래스의 호출할 수 있는 메서드도 제약을 걸어주어야 합니다. 즉, if-else 문을 여기저기 사용하면서 나쁜 냄새를 풍기는 코드를 작성할 수밖에 없게 되는 것이죠. (메서드뿐만 아니라 속성을 나타내는 변수도 if 문으로 여러 제약을 걸어서 사용해야 될 것입니다.) 

 

예를 들어, 아래와 같은 코드에서 강아지가 수컷이냐 암컷이냐에 따라 if문을 통해 분기하여 다른 행위를 하도록 지정하고 있습니다. 이는, 강아지 클래스의 makeSound() 메서드가 수컷 강아지의 행위와 암컷 강아지의 행위를 모두 구현하려고 하기에 단일 책임(행위) 원칙을 위배하고 있는 것입니다.


public class Dog {
    int gender;
    final static int boy = 0;
    final static int girl = 1;

    public Dog(int gender) {
        this.gender = gender;
    }

    void makeSound(){
        if(this.gender == 0){
            System.out.println("왈왈!!");
        }
        else{
            System.out.println("멍멍!!");
        }
    }
}

위 코드를 리팩터링 해 보면 아래와 같이 할 수 있습니다.


public abstract class Dog {
    int gender;
    final static int boy = 0;
    final static int girl = 1;

    public Dog(int gender) {
        this.gender = gender;
    }

    abstract void makeSound();
}

class BoyDog extends Dog{

    public BoyDog(int gender) {
        super(gender);
    }

    @Override
    void makeSound(){
        System.out.println("왈왈!!");
    }
}

class GirlDog extends Dog{

    public GirlDog(int gender) {
        super(gender);
    }

    @Override
    void makeSound(){
        System.out.println("멍멍!!");
    }
}

단일 책임 원칙과 가장 관계가 깊은 객체 지향 4대 특성 (캡슐화, 상속, 추상화, 다형성) 중 하나는 모델링 과정을 담당하는 추상화입니다. 따라서, 애플리케이션의 경계를 정하고 추상화를 통해 클래스들을 선별하고 속성과 메서드를 설계할 때 반드시 단일 책임 원칙을 고려하는 습관을 들여야 합니다. 코드 리팩터링을 할 때도, SRP을 적용할 곳이 있는지 꼼꼼히 살펴봐야 합니다.


OCP - 개방 폐쇄 원칙

소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만 변경에 대해서는 닫혀 있어야 한다.
- 로버트 C. 마틴

데이터베이스 프로그래밍에서 JDBC가 OCP의 좋은 예입니다.

JDBC를 사용하는 클라이언트는 데이터베이스가 오라클에서 MySQL로 바뀌더라도 Connection을 설정하는 부분 외에는 따로 수정할 필요가 없습니다. 자바 애플리케이션은 JDBC 인터페이스로 인해 데이터베이스의 변화에 영향을 받지 않기 때문이죠. 즉, 자바 애플리케이션은 데이터베이스라고 하는 주변의 변화에 닫혀 있는 것입니다. 데이터베이스를 교체한다는 것은 데이터베이스가 자신의 확장에는 열려 있다는 것입니다.

 


 

자바의 JVM에서도 OCP를 적용할 수 있습니다. 개발자가 작성한 소스코드는 운영체제의 변화에 닫혀 있고, 각 운영체제별 JVM은 확장에 열려 있는 구조가 됩니다. 이는, 개발자의 소스코드와 운영체제별 JVM 사이에 목적 파일(. class)이 있기에 개발자는 다양한 구동 환경에 대해서는 걱정하지 않고 본인이 작업하고 있는 개발 PC에 설치된 JVM에서 구동되는 코드만 작성할 수 있기 때문이죠.


LSP - 리스코프 치환 원칙

서브 타입은 언제나 자신의 기반 타입(base type)으로 교체할 수 있어야 한다. 
- 로버트 C. 마틴

아래의 두 문장대로 구현된 프로그램이라면 이미 LSP를 잘 지키고 있다고 할 수 있습니다.

  • 하위 클래스 is a kind of 상위 클래스: 하위 분류는 상위 분류의 한 종류이다.
  • 구현 클래스 is able to 인터페이스: 구현 분류는 인터페이스 할 수 있어야 한다.

위 문장대로 구현되지 않은 코드는 상속이 분류도가 아닌, 조직도나 계층도 형태로 구축된 경우입니다. 예를 들어, 상위 클래스로 Father(아버지)를, 하위 클래스로 Daughter(딸)이라고 구현하였다면 전형적인 계층도 형태라고 할 수 있습니다. 이와 같이 구현할 경우, 상위 클래스의 객체 참조 변수에 하위 클래스의 인스턴스를 할당할 시, 문제가 발생합니다.

 

Father Betty = new Daughter(); 

 

위 코드에서는 Betty라는 참조 변수에 딸 객체를 생성해, Father이라는 아버지의 역할을 맡기고 있습니다. 말이 안 되는 이상한 코드라고 할 수 있죠.

 

만약, 상속이 분류도 형태로 구현되었다면 아래와 같이 객체를 사용하여도 논리적인 흠이 없음을 알 수 있습니다.

 

Animal Pororo = new Penguin();

 

즉, 아버지 - 딸 구조 (계층도/조직도)는 LSP를 위배하고 있는 것이고, 동물 - 펭귄 구조 (분류도)는 LSP를 만족하고 있는 것입니다. 객체 지향의 상속이라는 특성을 올바르게 활용하면 자연스럽게 얻게 되는 것이 LSP라고 할 수 있습니다.

 

하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해
상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 한다.
- 로버트 C. 마틴


ISP - 인터페이스 분할 원칙

클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다.
- 로버트 C. 마틴

ISP와 함께 등장하는 원칙 중 하나가 인터페이스 최소주의 원칙입니다. 인터페이스를 통해 메서드를 외부에 제공할 때는 그 역할에 충실한 최소한의 메서드만 제공하라는 것이죠. 

 

 

앞서 SRP 예제에서 모든 메서드를 포함하던 남자 클래스를 SRP를 적용하여 아래와 같이 바꾸었습니다.

 

 


SRP에서 제시한 해결책은 남자 클래스를 토막 내 하나의 역할만 하는 다수의 클래스로 분할하는 것이었습니다. 하지만 이 방법만 있는 것은 아닙니다. ISP를 이용하여 인터페이스 분할 원칙이 제시하는 해결책을 적용하면 아래와 같이 변경할 수 있습니다.

 

 


 

즉, SRP는 남자 클래스를 토막 내었다면, ISP는 남자 클래스의 자아를 붕괴시킴(?)으로써 여자친구를 만날 때는 남자친구 역할만 수행하고, 부모님과 있을 때는 아들의 역할만 할 수 있도록 인터페이스로 제한하였습니다.  

 

결론적으로, SRP와 ISP는 같은 문제에 대한 두 가지 다른 해결책입니다. 프로젝트 요구사항과 설계자의 취향에 따라 SRP나 ISP 중 하나를 선택해서 설계할 수 있습니다. 하지만, 대부분 특별한 경우가 아니라면 SRP를 우선 적용하는 것이 더 좋은 해결책입니다.


 

DIP - 의존 역전 원칙

고차원 모듈은 저차원 모듈에 의존하면 안 된다.
이 두 모듈 모두 다른 추상화된 것에 의존해야 한다.

 

추상화된 것은 구체적인 것에 의존하면 안 된다.
구체적인 것이 추상화된 것에 의존해야 한다.

 

자주 변경되는 구체(Concrete) 클래스에 의존하지 마라
- 로버트 C. 마틴


 

아래와 같이 자동차가 스노우타이어라는 구체 클래스에 의존하게 된다면 문제가 발생합니다.

 


스노우타이어는 계절이 바뀜에 따라 일반 타이어로 교체해야 하는데, 자동차의 경우 한 번 사면 몇 년은 타야 됩니다. 그렇기 때문에, 자동차 자신보다 더 자주 변하는 스노우타이어에 의존하기에 자동차 클래스도 계속 변경되어야 하는, 부서지기 쉬움이라는, 나쁜 냄새를 풍기고 있는 것이죠. 이를 아래와 같이 변경한다면 좋은 향기가 나도록 개선할 수 있습니다.

 

 


자동차가 구체적인 타이어들에 의존하는 것이 아니라, 추상화된 타이어 인터페이스에만 의존하게 함으로써 구체적인 타이어가 변경되더라도 자동차는 그 영향을 받지 않는 형태가 됩니다. (여기에 OCP도 녹아있다는 것을 알 수 있습니다.)

 

그런데 기존에는 스노우타이어가 그 무엇에도 의존하지 않는 클래스였는데, 추상화된 타이어 인터페이스를 추가하니 이 인터페이스에 의존하게 되었습니다. 즉, 의존의 방향이 역전된 것입니다. 이처럼, 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 변하기 쉬운 것의 변화에 영향받지 않게 하는 것이 DIP라고 할 수 있습니다.

 

자신보다 변하기 쉬운 것에 의존하지 마라

 


SOLID 정리

SoC(Seperation Of Concerns: 관심사의 분리)를 적용하면 자연스럽게 SRP(단일 책임 원칙), ISP(인터페이스 분리 원칙), OCP(개방 폐쇄 원칙)에 도달할 수 있습니다. 스프링 또한 SoC를 통해 SOLID를 극한까지 적용하고 있습니다.

 

  • SRP: 어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.
  • OCP: 자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다.
  • LSP: 서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다.
  • ISP: 클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다.
  • DIP: 자신보다 변하기 쉬운 것에 의존하지 마라.

 

SOLID 원칙을 적용하면 소스 파일의 개수는 더 많아지는 경향이 있습니다. 하지만 이렇게 많아진 파일이 논리를 더 잘 분할하고 잘 표현하기 때문에 이해하기가 쉽고 개발 및 유지 보수, 관리에 용이합니다. 


[참고자료]

김종민, [스프링 입문을 위한 자바 객체 지향의 원리와 이해], 위키북스, 2015

 

728x90
반응형

'클린한 코드 > OOP' 카테고리의 다른 글

객체지향 4대 특성  (0) 2023.11.12