5장. 객체 지향 설계 5원칙 - SOLID

SOLID 원칙은 누군가 갑작스레 떠올린 것이 아니라, 응집도는 높이고(High Cohesion) 결합도는 낮추라(Loose Coupling)는 고전 원칙을 객체 지향의 관점에서 재정립한 것이다.

결합도와 응집도

결합도와 응집도 좋은 소프트웨어 설계 방식에서 항상 나오는 얘기를 보면 결합도는 낮추고 응집도는 높이는 것이 바람직한 설계의 정석이라고 한다.

결합도란?

  • 하나의 모듈이 다른 모듈을 의존하는 있는 의존 관계를 의미하며, 결합도가 낮을수록 다른 객체를 의존하고 있지 않기 때문에 테스트 코드를 작성하거나 기능을 수정하기 편리하다.

응집도란?

  • 하나의 모듈 내부에 존재하는 구성 요소들이 관련 있는 기능의 집합들로, 응집도가 높을수록 요구사항을 처리하는 하나의 객체에서 쉽게 찾아서 수정할 수 있는 편리함이 있다.

단일 책임 원칙에서 개발자 A, B가 생각하는 책임의 기준이 서로 다르면 어떻게 해야할까?

"책임" 이라는 것은 누구에게나 주관적으로 접근할 수 있기 때문에, 서로 생각하는 범위가 다른게 일반적이다.

그럼, 단일 책임 원칙에 있어서 "책임"은 어떻게 산정해야할까?

단일 모듈은 변경해야 하는 이유가 오직 하나뿐이어야한다.

만약, 상품을 주문하는 기능이 있다고 가정하고 나는 아래와 같이 상품 주문을 하는 행위는 아래 처럼 상품 주문과 취소밖에 없다고 생각하고 객체를 설계 했다.

상품 주문 서비스
- 상품 주문
- 주문 취소

어느 날, 다른 개발자는 상품 주문이 곧 바로 결제랑도 이어지기 때문에 상품 주문 내부에 결제도 추가했다.

처음 만든 개발자와 그 후에 수정하는 개발자는 서로 책임의 범위가 어디까지인지 주관적으로 생각했기 때문에 오차 범위가 생긴다.

변경해야 하는 이유가 무조건 한 개여야한다 라는 원칙을 다시 적용시켜보면, 우리는 위 수정된 객체설계가 SRP 를 위반하고 있다는 것을 확신할 수 있다.

상품을 주문하는 행위가 아님에도 불구하고 우리는 결제 관련된 내용을 수정하기 위해 상품 결제 메서드를 수정해야하고, 주문과 관련된 내용을 수정해야할 때도 주문 관련 메서드를 수정해야한다.

여기서 벌써, 두 개의 수정해야 하는 이유가 생기게된다. 처음엔 별 다른 문제가 없고 의도대로 코드가 흘러가지만 점차 시간이 지날수록 의존 관계가 생기고 주문과 전혀 상관 없는 결제 관련된 의존성이 같이 결합되면서 코드 한 번 수정하기 위해 굉장히 많은 비용이 발생한다.

개방 폐쇄 원칙을 지키면, 기존 코드를 수정하지 않고 어떻게 추가할 수 있을까?

코드를 추가하지 않는다는 말의 핵심은 비즈니스 로직이 수정되지 않아야 한다는 점이다.

예를 들어, 간편 결제 데이터를 받으면 어떤 결제건인지 확인해서 구분자를 추가해준다고 가정한다.

Payment 인터페이스 타입을 인자로 받아 다형성을 활용하면 구현체에 따라 구현 내용을 다르게 유지한 채 전략 패턴을 활용하면 손 쉽게 해결할 수 있다.

Payment 를 구현하고 있는 객체는 초기에 카카오페이만 있었다면, 앞으로 늘어나는 네이버페이, 페이코, 토스페이 등 다양한 간편결제 유형이 추가 되더라도 우리는 손 쉽게 Payment 인터페이스만 구현하여 작성하면 된다.

당연히, 순수 자바에서는 구현체를 정하기 위해 팩터리 패턴을 사용하여 특정 데이터는 카카오라는 식으로 생성 해주어야 하겠지만 우리가 자주 사용하는 스프링 부트 프레임워크에서는 빈 컨테이너가 이 행위를 도와주기 때문에 개발자가 비즈니스 요구사항에 집중할 수 있게 된다.

만약, 이러한 다형성 없이 구현한다면 어떻게 될까?

instanceof 아니더라도, 우린 특정 데이터는 조건문을 통해 다른 처리 방식을 적용하는 조건분기가 생성되게 된다.

물론 이 방식을 나쁘다고 표현하지는 않는다. 다만, 이 코드가 앞으로 확장되면서 가져오는 유지보수 비용을 비싸게 지불해야 한다는 문제점이 보이기 때문에 사전에 개선하는 것을 추천한다.

리스코프 치환 원칙을 위배 하는 경우, 부모 타입으로 잘 동작하던 코드가 자식 타입을 넘겼을 뿐인데 왜 오작동할까?

리스코프 치환 원칙은 SOLID 원칙 중에서 의미를 바로 유추하기 가장 어려운 원칙 중 하나이고, 만약 자신이 가장 잘 이해하고 있는 SOLID 원칙 하나를 뽑아 설명해달라고 요청한다면 아무도 선택하지 않을 정도의 이해하기 까다로운 내용이다.

리스코프가 제시하는 이 원칙의 의미는 "S 타입의 객체 o1과 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 프로그램 P 에서 o2를 o1으로 치환해도 P의 행위가 변하지 않는다면, S는 T의 하위타입이다."

굉장히 이해하기 어렵다. 이 내용을 한 줄로 쉽게 풀어서 작성해보면 이런 내용이 된다.

하위타입 구현 시, 부모의 기능을 수정하면 안 된다는 원칙이다.

리스코프 치환 원칙이 중요하게 여기는 부분 중 하나는 가장 중요한 부분을 치환하더라도 "프로그램의 행위"가 변하지 않아야한다는 것이다.

  • 하위 타입 구현 시, 상위 타입의 기능을 무작위로 수정한다면 타입을 치환했을 때 정상적으로 동작하는지 확신할 수 없기 때문에 타입을 서로 치환할 수 없게 된다.

간단하게 예시를 살펴보며, 리스코프 치환 원칙과 상속 관계에서 부모-자식 관계를 사용하면 안되는 또 하나의 이유를 같이 이해해보자.

이 코드를 보면, 딸 객체를 생성 했는데 아빠 역할을 하고 있다. 예를 들어, 아빠는 "휴식" 이라는 메서드에 TV 를 시청하는 행위를 하지만, 딸은 친구와 만난다고 했을 때 벌써 타입의 치환이 불가능하게 된다.

이 예시는 리스코프 치환 원칙에서 어긋날뿐더러, 상속 관계가 부모-자식인 경우 생기는 문제다. 상속은 언제나 계층도로 나타내는 분류여야만 한다.

이렇게 생성했다면 딸과 사람의 타입을 치환 하더라도 사람이 행하는 행위는 딸도 행하기 때문에 우린 프로그램에서 정상적으로 동작할 것이라고 판단할 수 있다.

그리고, 하나 더 나아가서 리스코프 치환 원칙에서 중요시 여겼던 상위 타입의 기능을 하위 타입이 수정하지 말아야 한다는 점이다.

가장 대표적인 예시 중 하나인 정사각형, 직사각형을 예시로 들어본다.

내부 구현을 모르는 개발자는 왜 이런 에러가 발생하게 되었는지 찾기 굉장히 까다로워진다.

자동차 시동을 거는 방식이 과거엔 키를 꽂아 돌리는 형태였다면 요즘 버튼을 누르는 방식이고 더 나아가 모바일 어플리케이션으로 원격 시동도 걸 수 있다.

만약, 원격 시동을 걸 때 자동차 시동을 거는 행위에 추가적으로 사용자를 위해 에어컨을 미리 틀어놓는 기능을 만들어둔다면 우리는 원격 시동을 이용하면 추운 겨울에도 에어컨을 쐬야한다.

상위 타입이 만들어 둔 시동을 거는 규약 자체는 변경 되면 안된다는 점을 주의하자.

인터페이스 분리 원칙은 어떻게 인터페이스를 잘 분리할 수 있을까?

인터페이스 분리 원칙을 누군가에게 설명할 때 이렇게 말 한다. "인터페이스를 분리하는 원칙입니다." 그럼 듣는 사람은 당연히 무슨 뜻인지 이해하기 어렵기 때문에 더 상세한 요청을 원한다.

  • 어떻게 잘 분리할 수 있을까? -> 기능 및 책임에 따라 분리한다.

  • 우린 굴레에 빠진다, 기능 및 책임은 어떻게 정의해서 분리할까?

ISP 의 진짜 의미는 사용하지 않는 것에 의존하지 말아야한다. 이다.

기능별로 나누어진 인터페이스가 존재한다고 하더라도 만약, 사용하지 않는 행위가 존재한다면 인터페이스를 더 분리해야한다는 뜻이 된다.

예를 들어, 매장에서 우리는 키오스크에서 버튼을 눌러 결제를 진행한다. 그 단말기는 바코드일수도 카드 단말기일 수도 있다.

오프라인결제 라는 인터페이스가 있다고 가정해보자.

오프라인결제는 현금결제, 바코드 결제, 단말기 결제 두 가지 행위를 제공하고 키오스크는 오프라인결제를 구현하고 있다.

키오스크에서 주문을 하는데 바코드 결제와, 단말기 결제는 가능하지만 현금 결제는 불가능하다.

이러한 상황에서 키오스크가 현금 결제를 사용하지 않기 때문에 인터페이스를 분리해야 하는 이유가 된다.

의존성 역전 원칙은, 왜 의존성을 역전 시킬까?

의존성 역전 원칙에 핵심은 추상화에 의존해야 하며 구체화에 의존하면 안 된다. 는 내용이다.

추상화된 인터페이스는 더 고수준의 명령을 지원한다. 그에 따라 하위 구현체는 어떻게를 수행하는데, 이 둘의 관계는 객체지향의 사실과 오해 [ 자율적인 책임 ] 에 나오듯, 추상화된 메시지는 "무엇"을 의미하고 하위 구현 메시지는 "어떻게"를 의미한다.

예를 들어, 우리가 파일을 업로드 한다고 가정해보자.

추상화 된 메시지는 "파일 업로드"이고, 구현 내용은 "로컬 파일 업로드" 또는 "S3 파일 업로드" 이런식으로 나뉘게 된다.

만약, 우리가 직접적인 "로컬 파일 업로드" 행위를 참조하고 있다면 미래에 AWS 나 GCP 같은 클라우드로 이전할 때 참조하고 있던 코드를 모두 수정하게 되는 불상사가 발생한다.

그리고, 이렇게 구현된 행위를 제어하는 것은 개발자 입장에서 당연한 내용이다. 파일을 어떻게 업로드 할 것이고 이런 행위는 모두 개발자가 논리적 흐름에 따라 정의한다. 하지만, 이렇게 참조하고 있던 코드를 매번 수정하기 번거롭기 때문에 우리는 추상화된 행위 하나를 외 부로 공개하고, 내부를 감춘다.

그렇게 될 경우 앞으로 개발자가 직접 제어하던 로컬에 파일을 업로드 행위는 숨게되고, 파일 업로드 라는 고수준 행위가 로컬에 저장할지, S3에 저장할지를 정하게 된다.

그러므로 개발자가 직접 제어하던 역할에서 파일 업로드라는 추상적 행위가 직접 제어하는 역할로 바뀌게 되었다.

Last updated