객체지향 설계 SOLID

자바 객체지향의 원리 책을 읽고 내용을 정리하는 글입니다.

객체 지향 언어의 시초라고 하는 Simula67이 1960년 발표되고 50년 이상의 세월이 흘렀다. 그 세월 동안 수많은 시행착오와 베스트 프랙티스 속에서 객체 지향 설계(OOD; Object Oriented Design)의 정수라고 할 수 있는 5원칙이 집대성 됐다. 바로 SOLID다.

SOLID는 로버트 C. 마틴이 2000년대 초반 객체 지향 프로그래밍 및 설계의 다섯 가지 기본원칙으로 제시한 것을 마이클 페더스가 두문자어로 소개한 것이다. SOLID는 아래 5가지 원칙의 앞 머리 알벳을 따서 부르는 이름이다.

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

응집도를 높이고 (High Cohesion) 결합도는 낮추라 (Loose Coupling)는 고전 원칙을 객체 지향 관점에서 재정립한 것이라고 할 수 있다.

SRP - 단일 책임 원칙

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

남자라고 하는 클래스와 남자 클래스에 의존하는 다양한 클래스가 있다고하자.

남자 클래스는 여자친구 클래스, 어머니 클래스, 직장상사 클래스, 소대장 클래스들이 의존하고 있다고 한다면 남자의 책임과 역할은 굉장히 많아진다. 객체 지향의 세계에서는 이런 경우 나쁜 냄새가 난다고 한다.

남자 클래스가 하는 일(메소드)

  • 기념일챙기기
  • 키스하기
  • 효도하기
  • 안마하기
  • 출근하기
  • 아부하기
  • 사격하기
  • 구보하기

예를 들어, 어느 날 여자 친구와 헤어졌다고 해보자. 남자는 더 이상 챙길 일 없는 기념일과 대상이 없는 키스하기에 힘들어하게 된다. 거기에 더해 여자 친구 없는 스트레스를 온 세상에 뿌리고 다니니 어머니, 직장 상사, 소대장님까지 피곤한 지경에 이르게 되고야 만다.

이런 경우에 역할(책임)을 분리하라는 것이 단일 책임 원칙이다.

여자친구가 의존하는 것은 남자친구 클래스, 직장상사가 의존하는 것은 사원 클래스, 어머니가 의존하는 것은 아들 클래스와 같이 역할(책임)을 분리해보자.

역할과 클래스명도 딱 떨어지니 이해하기도 좋을 뿐더러 이제 여자 친구와 이별하더라도 남자 친구만 상처를 입으면 된다.

여기서는 클래스의 분할에 대해서만 이야기했지만 단일 책임 원칙은 속성, 메서드, 패키지, 모듈, 컴포넌트, 프레임워크 등에도 적용할 수 있는 개념이다.

OCP - 개방 폐쇄 원칙

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

위 문장을 좀 더 의역해 보면 아래와 같은 문장을 이끌어 낼 수 있다.

자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다.

개방 폐쇄 원칙은 다양한 곳에서 다양하게 이야기되고 있으니 딱 꼬집어서 예를 들기가 그리 쉽지는 않다.

그래도 예를 들어보자면 어느 날 한 운전자가 마티즈를 구입했다. 그리고 열심히 마티즈에 적응했다고 해보자. 그리고 훗날 그 운전자에게 쏘나타가 생겼다.

마티즈

  • 창문수동개방
  • 기어수동조작

쏘나타

  • 창문자동개방
  • 기어자동조작

창문과 기어가 수동이던 마티즈에서 창문과 기어가 자동인 쏘나타로 차종을 바꾸니 운전자의 행동에도 변화가 온다. 마티즈를 운전할 때 운전자는 마티즈 인스턴스의 기어수동조작() 메서드를 사용했는데 쏘나타로 차종을 변경하자 쏘나타 인스턴스의 기어자동조작() 메서드를 사용하게 된다. 운전자는 차량에 따라 운전하던 습관을 바꿔야만 하는 것일까? 수동에서 자동의 차량으로 바꿨다고 해서 운전자가 운전에 영향을 받아야만 하는가를 생각해 보자.

현실 세계라면 당연히 어느 정도 변화가 있어야 하겠지만 객체 지향 세계에서는 다른 해법이 있다.

자동차

  • 창문개방
  • 기어조작

자동차 라는 상위 클래스 또는 인터페이스를 중간에 둠으로써 다양한 자동차가 생긴다고 해도 객체 지향 세계의 운전자는 운전 습관에 영향을 받지 않게 된다.

다양한 자동차가 생긴다고 하는 것은 자동차 입장에서 자신의 확장에는 개방돼 있는 것이고, 운전자 입장에서는 주변의 변화에 폐쇄돼 있는 것이다.

혹시라도 DB 프로그래밍을 경험한 적이 있다면 개방 폐쇄 원칙의 아주 좋은 예를 이미 알고 있을 것이다. 그 예란 바로 JDBC다. JDBC를 사용하는 클라이언트는 데이터베이스가 오라클에서 MySQL로 바뀌더라도 Connection을 설정하는 부분 외에 따로 수정할 필요가 없다. Connection 설정 부분을 별도의 설정 파일로 분리해두면 클라이언트 코드는 단 한 줄도 변경할 필요가 없다.

LSP - 리스코프 치환 원칙

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

상속에 대해 설명하면서 객체 지향에서의 상속은 조직도나 계층도가 아닌 분류도가 돼야 한다고 했다. 객체 지향의 상속은 다음의 조건을 만족해야 한다.

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

위 두 개의 문장대로 구현된 프로그램이라면 이미 리스코프 치환 원칙을 잘 지키고 있다고 할 수 있다.

하지만 위 문장대로 구현되지 않은 코드가 존재할 수 있는데 바로 상속이 조직도나 계층도 형태로 구축된 경우다. 예를 들면 아버지와 딸의 관계이다.

아버지 춘향이 = new 딸()

아버지와 딸의 관계로 상속을 한다면 위의 처럼되는데 딱 봐도 이상하지 않은가? 딸을 하나 낳아서 이름을 춘향이라 한 것까지는 좋은데 아빠의 역할을 맡기고 있다.

춘향이는 아버지형 객체 참조 변수이기에 아버지 객체가 가진 행위(메서드)를 할 수 있어야 하는데 춘향이에게 아버지의 어떤 역할을 시킬 수 있을까?

이번엔 조직도가 아닌 분류도의 형태의 예시인 동물 클래스와 이를 상속한 펭귄 클래스를 예시로 들어보자.

동물 뽀로로 = new 펭귄()

논리적인 흠이 없다. 펭귄 한 마리가 태어나 뽀로로라 일므을 짓고 동물의 행위(메서드)를 하게 하는데 전혀 이상함이 없다.

전자인 조직도는 리스코프 치환 원칙을 위배하고 있는 것이며 후자의 분류도는 리스코프 치환 원칙을 만족하는 것이다.

ISP - 인터페이스 분리 원칙

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

단일 책임 원칙(SRP) 예제를 다시 살펴보자. 단일 책임 원칙을 적용하기 전 남자 클래스는 아래와 같이 많은 책임을 갖었었다.

남자 클래스가 하는 일(메소드)

  • 기념일챙기기
  • 키스하기
  • 효도하기
  • 안마하기
  • 출근하기
  • 아부하기
  • 사격하기
  • 구보하기

단일 책임 원칙을 적용한 후 남자친구, 사원, 아들, 소대원으로 단일 책임을 갖는 클래스로 나누었다.

남자친구

  • 기념일챙기기
  • 키스하기

사원

  • 출근하기
  • 아부하기

아들

  • 효도하기
  • 안마하기

소대원

  • 사격하기
  • 구보하기

단일 책임 원칙에서 제시한 해결책은 남자 클래스를 토막내서 하나의 역할(책임)만 하는 다수의 클래스로 분할 하는 것이었다.

그런데 꼭 그 방법뿐일까?

만약 남자를 토막 내는 것이 너무 잔인하다는 생각이 든다면 그때 선택할 수 있는 방법이 바로 ISP 즉, 인터페이스 분할 원칙이다.

ISP를 적용한 남자 클래스

인터페이스 분할 원칙이 제시하는 해결책은 남자 클래스를 토막내는 것이 아니라 자아 붕괴? 또는 다중 인격화? 시켜 여자친구를 만날 때는 남자친구 역할만 할 수 있게 인터페이스를 제한하고 어머니와 있을 때는 아들 인터페이스로 제한하고, 직장 상사 앞에서는 사원 인터페이스로 제한하고 소대장 앞에서는 소대원 인터페이스로 제한 하는 것이 바로 인터페이스 분할 원칙의 핵심인 것이다.

결론적으로 단일 책임 원칙(SRP)과 인터페이스 분할 원칙(ISP)은 같은 문제에 대한 두 가지 다른 해결책이라고 볼 수 있다. 특별한 경우가 아니라면 단일 책임 원칙을 적용하는 것이 더 좋은 해결책이라고 할 수 있다.

인터페이스 분할 원칙을 이야기할 때 항상 함께 등장하는 원칙 중 하나로 인터페이스 최소주의 원칙이라는 것이 있다. 인터페이스를 통해 메서드를 외부에 제공할 때는 최소한의 메서드만 제공하라는 것이다. 남자친구 인터페이스에 사격하기 메서드를 제공할 필요도 없고 제공해서도 안된다는 것이다.

이전에 상위 클래스는 풍성할수록 좋고, 인터페이스는 작을수록 좋다고 했다. 그 이유를 더 살펴보자

리스코프 치환 원칙(LSP)에 따라 하위 객체는 상위 객체인 척 할 수 있다.

빈약한 상위 클래스 vs. 풍성한 상위 클래스

빈약한 상위 클래스인 경우를 이용하는 예제를 살펴보자

public class Driver {
    public static void main(String[] args) {
        사람 김학생 = new 학생("김학생", new Date(2000, 01, 01), "20000101-1234567");
        사람 이군인 = new 군인("이군인", new Date(1998, 12, 31), "19981231-1234567");

        System.out.println(김학생.이름);
        System.out.println(이군인.이름);

        // System.out.println(김학생.생일); // 사용불가
        // System.out.println(이군인.생일); // 사용불가

        System.out.println(((학생) 김학생).생일); // 캐스팅 필요
        System.out.println(((군인) 이군인).생일); // 캐스팅 필요

        // System.out.println(김학생.주민번호); // 사용불가
        // System.out.println(이군인.주민번호); // 사용불가

        System.out.println(((학생) 김학생).주민번호); // 캐스팅 필요
        System.out.println(((군인) 이군인).주민번호); // 캐스팅 필요
        
        ...
    }
}

빈약한 상위 클래스를 이용한 경우 여기저기 형변환이 발생하면서 상속의 혜택을 제대로 누리지 못하고 있음을 볼 수 있다. 물론 김학생 객체 참조 변수를 사람형이 아닌 학생형으로, 이군인 객체 참조 변수를 군인형으로 선언하고 사용하면 되겠지만 그럼 굳이 상속 구조를 만들 필요도 없다.

상위 클래스형의 참조 변수를 이용해야 상속의 가장 큰 혜택을 볼 수 있다.

public class Driver {
    public static void main(String[] args) {
        사람 김학생 = new 학생("김학생", new Date(2000, 01, 01), "20000101-1234567");
        사람 이군인 = new 군인("이군인", new Date(1998, 12, 31), "19981231-1234567");

        System.out.println(김학생.이름);
        System.out.println(이군인.이름);

         System.out.println(김학생.생일);
         System.out.println(이군인.생일);

         System.out.println(김학생.주민번호);
         System.out.println(이군인.주민번호);

        // System.out.println(김학생.학번); // 사용불가
        // System.out.println(이군인.군번); // 사용불가

        System.out.println(((학생) 김학생).학번); // 캐스팅 필요
        System.out.println(((군인) 이군인).군번); // 캐스팅 필요

        김학생.먹다();
        이군인.먹다();

        ...
    }
}

풍성한 상위 클래스를 이용하는 예제를 빈약한 상위 클래스를 이용하는 예제와 비교할 때 사용 불가능한 경우나 형변환이 없음을 볼 수 있다.

소개하다() 메서드의 경우 학생 클래스와 군인 클래스가 다른 기능을 수행할 것 같은데도 상위 클래스인 사람 클래스에 정의된 것을 볼 수 있다. 학생과 군인이 소개 내용이 같다는 것이 이치에 맞지 않는다. 하지만 소개하다라는 기능은 둘 다 필요하다. 이 경우 사용할 수 있는 객체 지향 기법이 있었다. 바로 추상 메서드다.

인터페이스 최소주의가 가진 장점을 살펴보기 위해 위에 인터페이스 분리 원칙을 적용한 남자 클래스를 살짝 다시 보고 오자

새로 부임한 소대장님이 여자이고, 그 소대장님이 남자 객체의 여자 친구가 됐다고 해보자. 그래서 희한한 인터페이스인 소대원남자친구 인터페이스를 만들어 보자.

객체 지향 세계가 조금 난해하니 현실 세계에 대입해서 상상해보자. 유격 훈련 중에 소대장에게 키스하는 소대원남자친구, 데이트 중에 사격하기를 실시하는 소대원남자친구, 불가능하지는 않겠지만 현실 세계에서도 용납이 안되는 상황이다. 아무리 소대장을 여자친구로 둔 남자친구라고 해도 훈련 중에는 소대원으로서의 역할만, 데이트 중에는 남자친구로서의 역할만을 충실하게 수행해야한다. 객체 지향 세계에서도 같인 원리가 적용된다.

인터페이스는 그 역할에 충실한 최소한의 기능만 공개하라는 것이 이 시대 객체 지향 스승들의 가르침이라는 것을 꼭 명심하자.

DIP - 의존 역전 원칙

고차원 모듈은 저차원 모듈에 의존하면 안된다. 이 두 모듈 모두 다른 추상회된 것에 의존해야 한다. 추상화된 것은 구체적인 것에 의존하면 안된다. 구체적인 것이 추상화된 것에 의존해야 한다. 자주 변경되는 구체(concrete) 클래스에 의존하지 마라 - 로버트 C. 마틴

자동차와 스노우타이어 사이에는 의존 관계가 있다.

자동차와 스노우타이어

자동차가 스노우타이어에 의존한다.

그런데 자동차는 한 번 사면 몇 년은 타야 하는데 스노우타이어는 계절이 바뀌면 일반 타이어로 교체해야 한다. 이런 경우 스노우타이어를 교체할 때 자동차는 그 영향에 노출돼 있음을 알 수 있다. 바로 자동차 자신보다 더 자주 변하는 스노우타이어에 의존하기에 부서지기 쉬움이라는 나쁜 냄새를 풍기고 있는 것이다. 그럼 이런 경우 나쁜 냄새가 좋은 향기가 되도록 개선해 보자.

의존 역전 원칙 적용 후

자동차가 구체적인 타이어들이 아닌 추상화된 타이어 인터페이스에만 의존하게 함으로써 스노우타이어에서 일반타이어로, 또는 다른 구체적인 타이어로 변경돼도 자동차는 이제 그 영향을 받지 않는 형태로 구성된다. 그런데 이 설명에서 기시감(데자뷰)이 느껴질 것이다. 바로 개방 폐쇄 원칙(OCP)를 설명할 때 나온 설명이었다. 이렇게 하나의 해결책을 찾으면 그 안에 여러 설계 원칙이 녹아있는 경우가 많다.

기존에는 스노우타이어가 그 무엇에도 의존하지 않는 클래스였는데 추상적인 것인 타이어 인터페이스에 의존하게 됐다. 바로 의존의 방향이 역전된 것이다. 그리고 자동차는 자신보다 변하기 쉬운 스노우타이어에 의존하던 관계를 중간에 추상화된 타이어 인터페이스를 추가해 두고 의존 관계를 역전시키고 있다. 이처럼 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 변하기 쉬운 것의 변화에 영향받지 않게 하는 것이 의존 역전 원칙이다.

정리

  • SPR(Single Responsibility Principle) - 단일 책임 원칙: 어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.
  • OCP(Open Closed Principle) - 개방 폐쇄 원칙: 자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다.
  • LSP(Liskov Substitution Principle) - 리스코프 치환 원칙: 서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다.
  • ISP(Interface Segregation Principle) - 인터페이스 분리 원칙: 클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다.
  • DIP(Dependency Inversion Principle) - 의존 역전 원칙: 자신보다 변하기 쉬운 것에 의존하지 마라.
⤧  Next post Spring Aop와 Java Dynamic Proxy, CGLib ⤧  Previous post 자바가 확장한 객체지향