We will find a way, we always have.

-interstellar

카테고리 없음

[독서] 자바 / 스프링 개발자를 위한 실용주의 프로그래밍 - 2

Redddy 2024. 10. 21. 03:42

4. SOLID

객체지향에서 좋은 설계와아키텍처를 이야기하면 빠지지 않고 나오는 것이 SOLID 원칙이다. 

 

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

 

로버트 C.마틴이 2000년대 초반에 고안했으며 각 원칙은 객체지향 언어에서 좋은 설계를 얻기 위해 개발자가 지켜야 할 규범과 같은 것을 이야기한다. 그리고 각 원칙의 목표는 소프트웨어의 유지보수성과 확장성을 높이는 것이다. 

 

유지보수성을 높인다는 것은 무엇일까? 가독성 좋은 코드가 결국에는 유지보수성이 높은 코드라고 생각할 수 있겠지만(나역시도 그랬다) 설계 관점에서 코드의 유지보수성을 판단할 때 사용할 수 있는 실무적인 세 가지 맥락이 있다. 

 

  • 영향 범위: 코드 변경으로 인한 영향 범위가 어떻게 되는가?
  • 의존성: 소프트웨어의 의존성 관리가 제대로 이뤄지고 있는가?
  • 확장성: 쉽게 확장 가능한가?

 

SOLID를 따르는 코드는 코드 변경으로 인한 영향 범위를 축소할 수 있고, 의존성을 제대로 관리하며 기능 확장이 쉽다. 

 

 

 

4.1 SOLID 소개

4.1.1 단일 책임 원칙

A class should have one, and only one, reason to change.
- 로버트 C. 마틴

단일 책임 원칙은 클래스에 너무 많은 책임이 할당돼서는 안되며, 단 하나의 책임만 있어야 한다는 것이다. 클래스는 하나의 책임만 갖고 있을 때 변경이 쉬워진다. 

 

예를 들어 몇천줄이 넘어가는 클래스가 있다고 할 때, 이 클래스는 가독성이 떨어지고, 어떤 메서드가 어디까지 영향을 주고 있는지 한눈에 파악하기가 어려워 문제가 된다. 다른 클래스들도 이 클래스를 참조하고, 또 반대로 이 클래스도 프로그램에 존재하는 모든 클래스를 참조하고 있을 것이다. 따라서 이렇게 복잡하고 과한 책임이 할당된 클래스를 변경하려고 할 때 문제가 발생한다. 영향 범위를 알 수 없으니 코드 변경 자체가 어려워진다. 

 

과하게 집중된 책임은 피하고 분할해야 한다. 클래스에 할당된 책임이 하나라면 코드를 이해하는 것도 쉬워지고, 코드 수정하는 것도 쉬워진다. 유지보수가 필요할 때 다른 책임과의 충돌을 걱정할 필요가 없다. 하나의 문제에만 집중하면 되고 이미 그렇게 만들어져 있기 떄문이다. 결과적으로 단일 책임 원칙을 추구하면 변경으로 인한 영향 범위를 최소화할 수 있다. 

 

책임이라는 단어를 잠시 짚고 넘어가자. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Developer {
    
    public String createFrontendCode() {
        // 프론트엔드 코드를 만든다.
    }
 
    public String publishFrontend() {
        // 프론트엔드 서비스를 배포한다.
    }
 
    public String createBackendCode() {
        // 백엔드 코드를 만든다.
    }
 
    public String serveBackend() {
        // 백엔드 서비스를 배포한다.    
    }
}
 
cs

 

 

위 코드에서 Developer는 단일 책임 원칙을 지키도 있는 클래스일까?

 

 

위 코드에서 책임을 프론트엔드 개발자의 책임(createFrontentCode, publishFrontend)와 백엔드 개발자의 책임(createBachendCode, servceBackend)로 나누는 사람이 있을 수 있고, 아니면 publishFrontend와 servceBackend는 시스템 운영자의 책임으로 분리하는 사람도 있을 수 있다. 정말 아니면 다 하나로 묶어 하나의 시스템을 개발하는 사람으로 보아 시스템 개발자의 책임으로 묶을 수 있다. 

 

책임이라는 개념을 바라보는 개인이나 상황마다 다르게 해석될 여지가 있다. 따라서 책임이란 무엇이고 이를 어떻게 나눌지 기준이 필요하다. 

 

 

A module should be responsible to one, and only one, actor
- 로버트 C. 마틴

 

 

마틴은 책임을 설명하기 위해 액터(actor)라는 개념을 등장시켰다. 액터는 메시지를 전달하는 주체이다. 그리고 단일 책임 원칙에서 말하는 책임은 액터에 대한 책임이다. 메시지를 요청하는 주체가 누구냐에 따라 책임이 달라질 수 있다. 즉 단일 책임 원칙을 이해하려면 책임이 무엇인지 이해하려 노력하기보다는 오히려 액터에 집중해야 한다. 시스템에서 어떤 모듈이나 클래스를 사용하게 될 액터가 몇 명인지를 먼저 확인해야 한다. 같은 코드일지라도 시스템에 따라 액터가 다를 수 있다. 어떤 클래스를 사용하게 될 액터가 한 명이라면 단일 책임 원칙을 지키고 있는 것이고 여럿이라면 위반하고 있는 것이다. 

 

위 Developer 코드로 다시 돌아와 시스템에서 Developer 객체를 사용하는 액터를 백엔드 개발을 시키는 액터와 프론트엔드 개발을 시키는 액터로 분리한다면 이 클래스에는 책임이 두 가지 존재한다고 볼 수 있다. 반대로 시스템에서 Developer 객체를 사용하는 액터가 풀스택 개발을 시키는 액터로 하나만 있다면 책임이 하나만 존재한다고 볼 수 있다. 

 

액터가 하나일 수 있다면 클래스를 변경할 이유도 하나로 고정된다. 바로 해당 액터의 요구사항이 변경될 때이다. 

 

 

4.1.2 개방 폐쇄 원칙

 

You should be able to extend a behavior, without modifying it
- 로버트 C. 마틴

 

 

개방 폐쇄 원칙은 확장에 관한 이야기를 다룬다. 이 원칙을 풀어 말하면 "확장에는 열려 있고, 변경에는 단혀 있어야 한다" 로 표현한다. 이 원칙의 주된 목적은 기존 코드를 수정하지 않으면서도 확장이 가능한 시스템을 만드는 것이다. 

 

그렇다면 왜 기존 코드를 수정하지 않으면서 확장이 가능한 시스템을 만들어야 할까? 생각해보면 의외로 간단하다. 시스템을 운영하면서 코드를 변경하는 것은 매우 위험한 일이다. 작은 프로젝트에서야 코드 수정이 자유롭지만 규모가 큰 시스템에서는 코드를 변경하는 것이 쉬운 일이 아니다. 특히 변경하려는 코드를 사용하는 액터가 여럿이면 더더욱.

 

때문에 코드를 확장하고자 할 때 취할 수 있는 최고의 전략은 기존 코드를 아에 건드리지 않는 것이다. 구현체에 의존하는 것이 아닌 역할에 의존한다면 새로운 요구사항이 들어와도 기존 코드를 크게 변경하지 않고도 요구사항을 만족할 수 있다. 

 

OCP의 목표는 확장하기 쉬우면서 변경으로 인한 영향 범위를 최소화하는 것이다. 코드를 추상화된 역할에 의존하게 만듦으로써 이를 달성할 수 있다. 

 

 

4.1.3 리스코프 치환 원칙

 

 

Derved classes must be subsitutable for their base classes
- 로버트 C. 마틴

 

바바라 리스코프(Barbara Liskov)에 의해 고안되어 리스코프 치환 원칙이라 불리는 이 원칙은 한 문장으로 정의하면 '기본 클래스의 계약을 파생 클래스가 제대로 치환할 수 있는지 확인하라'는 원칙이다. 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Getter
@Setter
@AllArgsContrutor
class Rectable {
 
    
    protected long width;
    protected long height;
 
    public long calculateArea() {
        return width * height;
    }
 
    
    class Square extends Rectangle {
        public Square(long length) {
            super(lengthlength);
        }
    }
}
 
 
cs

 

 

 

예제로 설명해보자. 직사각형(Rectangle)이라는 클래스와 정사각형(Square) 클래스가 있다. 그리고 정사각형이 직사각형을 상속하는 형태이다. 

 

1
2
3
4
5
Rectangle rectangle = new Square(10);
rectangle.setHeight(5);
 
System.out.println(rectangle.calculateArea());
 
cs

 

 

위 결과는 50이 나온다. 5와 10을 곱했기 때문이다. 하지만 우리는 25가 나오길 원한다. 정사각형은 가로 세로 길이가 같으니 말이다. 

 

이 예시는 리스코프 치환 원칙을 설명하면서 보여주는 대표적인 리스코프 치환 원칙의 위반 사례이다. 리스코프 치환 원칙에 따르면 파생 클래스가 기본 클래스의 모든 동작을 완전히 대체할 수 있어야 한다. 하지만 현재 정사각형 클래스는 직사각형 클래스의 모든 동작을 완전히 대체하지 못한다. 이 문제를 해결하기 위해서는 우선 파생 클래스가 기본 클래스를 대체할 수 있는지 파악하기 위해 기본 클래스에 할당된 의도가 무엇인지를 먼저 파악해야 한다. 

 

4.1.4 인터페이스 분리 원칙

Make fine grained interfaces that are client specific

 

 

인터페이스 분리 원칙은 클라이언트가 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다는 원칙이다. 즉 어떤 클래스가 자신에게 필요하지 않은 인터페이스의 메서드를 구현하거나 의존하지 않아야 한다는 말이다. 이를 통해 인터페이스의 크기를 작게 유지하고, 클래스가 필요한 기능에만 집중할 수 있다. 

 

스프링 코드를 살펴보자. 

 

1
2
3
4
5
public class LifecycleBean implements BeanNameAware, BeanFactoryAware, InitializingBean, DisposableBean {
 
// ...
}
 
cs

 

 

LifecycleBean이라는 클래스의 일부인데, 해당 클래스는 총 4개의 인터페이스를 구현하고 있다. BeanNameAware와 BeanFactoryAware 인터페이스는 BeanAware라는 인터페이스로 합칠 수 있을 거 같다고 생각하는 사람도 있을 것이다.

 

아래 예제를 봐보자. 

 

1
2
3
4
5
public aspect AnnotationBeanConfigurerAspect extends AbstractInterfaceDrivenDependencyInjectionAspect
        implements BeanFactoryAware, InitializingBean, DisposableBean {
 
// ...
}
cs

 

 

AnnotationBeanConfigurerAspect 클래스는 BeanFactoryAware와 BeanNameAware 인터페이스 중 BeanFactoryAware 인터페이스만 구현하였다. 추정하건대 이렇게 개발한 이유는 단순하다. 실제로 구현해야 하는 기능이 BeanFactoryAware 인터페이스 만으로 충분했기 떄문일 것이다. 

 

통합된 인터페이스는 구현체에 불필요한 구현을 강요할 수도 있다. 따라서 범용성을 갖춘 하나의 인터페이스를 만들기보다는 다수의 특화된 인터페이스를 만드는 편이 더 낫다. 

 

누군가는 인터페이스를 통합하는 것이 응집도를 추구하는 행위일 수도 있지 않냐고 물어볼 수 있다. 실제로 인터페이스를 통합하려는 시도는 응집도를 추구하는 행위일 수도 있다. 하지만 그 것이 곧 응집력이 높아지는 결과로 이어지는 것은 아니다. 왜냐하면 응집도라는 개념은 유사한 코드를 한곳에 모은다에서 끝나는 것이 아니기 때문이다. 응집도의 종류는 다양하며, 좀 더 세분화된 수준으로는 다음과 같은 응집도가 있다.

 

  1. 기능적 응집도(functional cohesion): 모듈 내 컴포넌트들이 같은 기능을 수행하도록 설계된 경우를 말함. 즉, 모듈이 어떤 목정을 가지고 있고 컴포넌트들은 그 목적을 달성하기 위해 협력하며, 오직 관련된 작업만 수행하는 경우.
  2. 순차적 응집도(sequential cohesion): 모듈 내의 컴포넌트들이 특정한 작업을 수행하기 위해 순차적으로 연결된 경우. 
  3. 통신적 응집도(communicational cohesion): 모듈 내의 컴포넌트들이 같은 데이터나 정보를 공유하고 상호 작용할 때 이에 따라 모듈을 구성하는 경우를 의미한다. 
  4. 절차적 응집도(procedural cohesion): 절차적 응집도는 모듈 내의 요소들이 단계별 절차를 따라 동작하도록 설계된 경우를 나타낸다. 
  5. 논리적 응집도(logical cohesion): 논리적 응집도는 모듈 내의 요소들이 같은 목적을 달성하기 위해 논리적으로 연관된 경우를 말한다. 

인터페이스를 분리하라는 말은 기능적 응집도를 추구하는 것이라고 볼 수 있다. 

 

 

4.1.5 의존성 역전 원칙

 

Depend on abstractions, not on concretions
- 로버트 C. 마틴

 

의존성 역전 원칙은 다음과 같은 것을 말한다. '상위 모듈은 하위 모듈에 의존해서는 안 된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다. 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.'

 

다시 정리해보자면,

 

  1. 고수준 모듈은 추상화에 의존해야 한다.
  2. 고수준 모듈이 저수준 모듈에 의존해서는 안 된다.
  3. 저수준 모듈은 추상화를 구현해야 한다.

 

이제 의존성이 무엇인지 살펴보러 가자.

 

 

4.2 의존성

 

의존이란 다른 객체나 함수를 사용하는 상태를 말한다. A 클래스에서 B 클래스를 메서드 파라미터로 가지고 있어도, 인스턴스 변수로 가지고 있어도, 상속하고 있어도 A 클래스는 B 클래스에 의존한다고 한다. 의존관계 파악은 단순하다. 사용하기만 해도 의존하는 것이다. 그렇기 때문에 소프트웨어는 의존하는 객체들의 집합이라고 볼 수 있다. 객체지향에서 객체들은 필연적으로 협력하는 데 서로를 사용하기 때문이다. 의존이야말로 소프트웨어 설계의 핵심이다. 

 

의존을 표현하는 또 다른 용어로는 결합(coupling)이 있다. 의존과 마찬가지로 어떠 객체나 코드를 사용하기만 해도 결합이 생긴다고 할 수 있다. 

 

 


고민 & 질문 아니면 혼잣말

 

이 책을 읽기 전까지 유지보수하기 좋은 코드는 가독성이 좋은 코드라고 생각했었다. 

우리의 서비스 클래스(XXXService)들은 단일 책임 원칙을 지킨다고 할 수 있을까?

Jpa를 사용할 때 사용하는 Repository는 너무 많은 역할을 가지고 있다. 하지만 지금까지 잘 써왔자나? 원칙은 원칙일뿐,,,