Architecture / / 2024. 4. 6. 18:51

Clean Architecture(클린 아키텍처) 공부 - OOP, SOLID, SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙), LSP(리스코프 치환 원칙), ISP(인터페이스 분리 원칙), DIP(의존성 역전 원칙)

1. SRP(Single Responsibility Principle, 단일 책임 원칙)

저자는 SOLID 원칙 중에서 그 의미가 가장 잘 전달되지 못한 원칙이라고 한다. 단 하나의 일만 해야한다는 원칙은 따로 있다고 하며 다음과 같이 설명한다.

  • 단 하나의 일만 해야 한다는 원칙 : 함수는 반드시 하나의 일만 해야한다.
  • 커다란 함수를 작은 함수들로 리팩토링하는 저수준에서 사용.

그리고 SRP에 대해 조금은 역사적인 흐름에 관련하여 간단하게 설명한다.

  1. 역사적으로, 단일 모듈은 변경의 이유가 하나, 오직 하나 뿐이어야 한다.
  2. 저자의 입맛으로, 하나의 모듈은 하나의, 오직 하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다.
  3. 최종 버전, 하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.

여기서 액터(Actor)와 응집된(cohesive)에 대해 언급이 된다. 이 둘에 대해 다음과 같이 적혀있다. "단일 액터를 책임지는 코드를 함께 묶어 주는 힘이 바로 응집성이다"

 

그런 의미에서 스택 오버 플로우에 다양한 이야기가 오간 것을 알 수 있었다. 바로 SRP가 OOP에서 제 역할을 하냐는 것이었다.

 

 

Does the Single Responsibility Principle work in OOP?

I am struggling to understand how the Single Responsibility Principle can me made to work with OOP. If we are to follow the principle to a tee, then are we not left with many classes, many of whi...

stackoverflow.com

 

그에 대한 답변은 생각할 거리가 많아서 가져와 번역해 봤다.

 

"나는 SRP를 다음과 같이 설명하고 싶어: '너가 작성하는 모든 코드 - 모든 모듈, 클래스, 인터페이스 혹은 메소드는 하나의 일을 가지고 있어. 그것이 모든 일이 되고 그리고 오직 하나의 일이 되어야해.' 모듈과 같은 큰 코드 혹은 메소드 같은 작은 코드, 그리고 그 사이에 있는 클래스들을 작성할 때 이러한 것들은 모두 작은 것들로 이루어져 있으니깐 말이야.

 

일과 책임은 다양한 크기로 다가오고 계층적으로 분해될 수 있어.  예를 들어 경찰의 일은 '보호와 봉사'지만 이러한 일들은 '거리 순찰', '버모지 해결'같은 것들로 분해될 수 있고, 이러한 것들은 다른 유닛으로 다뤄질 수 있지. (중략)"

 

이에 대해 질문자가 혼란스러운 부분이라고 말한다. 즉 정확하게 책임을 나누는 방법이나, 이것이 맞는 방법이냐고 느낄 수 있는 방법이 있는지 물어본다.

 

이에 대한 답변은 다음과 같다.

 

"일 분할의 트레이드 오프: 일을 더 작은 단위로 나누면 큰 일을 이해하기 쉬워지지만, 전체 작업의 수가 늘어나 시스템이 복잡해질 수 있어. 각 작업의 솔루션을 편하게 머릿속에 떠올릴 수 있는 지점이 있지. 그 이상으로 나누면 변경 사항이 더 많은 곳에 영향을 미쳐 오히려 관리가 어려워져. 이 절충점은 사람마다 약간씩 달라."

 

즉, 어느정도로 새세하게 분리해야 하는 가는 결국 개발자의 몫이라는 것이다. 그리고 이러한 SRP에 대해 조금은 고통스러워 하는 글도 발견해서 가져와 보려고 한다.

 

 

I don't love the single responsibility principle

Did you ever happen to disagree with a colleague on the single responsibility principle and its application? Let's try to understand why that could be the case.

www.sklivvz.com

 

글쓴이는 다음과 같은 이유를 들며 SRP에 대한 단점을 서술한다.

 

  • 불명확 : 
    • 버그 수정, 성능 향상, 리팩토링 등이 "변경 이유"에 명확하게 해당되는지 여부가 불분명하다는 점을 정확하게 지적한다. 이는 실제로 원칙을 적용하기 어렵게 만든다고 한다.
    • "이유" 또는 "변경"이 무엇을 구성하는지에 대한 설명이 없다. 버그 수정이 변경인지를 지적한다. 그렇다면 정말 버그가 변경의 타당한 이유인지도 지적한다.
    • 하지만 책에서는 버그 수정을 변경으로 간주하지 않을 것 같다고 한다.
    • 코드를 작성할 때는 현재의 실제 요구사항만이 중요하며, 미래는 거의 관련이 없으므로 미래 요구사항을 기반으로 설계하라고 요구하는 것은 어색한 부분이 있다고 한다.
    • 클래스는 새로운 요구사항이 있을 때만 변경 이유를 가질 수 있다. 그러나 그때는 SRP를 위반하면 발생할 것으로 예상되는 나쁜 일들이 이미 일어다고 치면, 오직 사건이 일어난 후에 적용되는 원칙의 장점은 무엇인지를 반문한다.
  • 모호함
    • 우리가 오직 하나의 변경 이유만 식별한다고 해도, 이 원칙에는 좋은 이유나 나쁜 이유에 대한 개념이 없다.
    • 이 모든 경우가 이 원칙에 의해 허용되는 것처럼 보이거나, 또는 금지되는 것처럼 보입니다. 분명히 정의가 누락되어 있으며, 우리 모두가 무엇이 유효한 책임인지 동의하지 않으면 이 원칙은 작동하지 않습니다.
  • 임의성
    • 이 SRP 원칙 자체가 임의적이다. 무엇이 "한 개의 변경 이유"를 항상 "두 개의 변경 이유"보다 더 낫게 만들까?
    • 숫자 '1'은 훌륭해 보이지만, 저는 단순한 것을 강력히 옹호하는 사람이며 때로는 변경 이유가 더 많은 클래스가 가장 단순한 것일 수 있다.
    • 우리가 너무 많은 다양한 작업을 수행하려는 거대 클래스를 만들어서는 안 된다는 것에는 동의합니다만, 왜 클래스가 한 가지 변경 이유만 가져야 하는가? 
  • 불균형
    • 균형이 없다. 내가 보는 모든 예는 단일 메서드 클래스를 수백만 개 만드는 쪽으로 치우쳐 있다. 두 개의 클래스를 하나로 병합하는 방법에 대해서는 언급이 없습니다.
    • 거대 클래스를 만들지 말아야 한다는 전제에는 전적으로 동의하지만, 이 원칙은 개념을 설명하거나 명백히 문제가 있는 경우를 식별하는 데 전혀 도움이 되지 않는다.
    • 마찬가지로 활동이 거의 없는 빈약한 마이크로 클래스도 코드베이스를 구성하는 매우 복잡한 방법이다. "이유"에 대한 정의가 매우 좁다면 클래스가 여러 "책임"과 여러 변경 이유를 처리하는 것이 더 나을 수 있습니다.

 

2. OCP(Open-Close Principle, 개방-폐쇄 원칙)

"소프트웨어 개체 아티팩트는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다."

 

이는 SRP와 DIP와 연결되어 있기도 하다. 즉, SRP의 "서로 다른 목적으로 변경되는 요소를 적절하게 분리"하는 것과, DIP의 "이들 요소 사이의 의존성을 체계화"하는 것이 합쳐진 것이다. 즉 책임의 분리와 소스코드 의존성도 확실히 조직화하는 과정이다.

 

이를 파이썬 코드로 살펴보겠다.(출처 : https://www.linkedin.com/pulse/solid-principles-python-open-closed-principle-mahdi-jafari/)

 

from math import pi

class Shape:
    def __init__(self, shape_type, **kwargs):
        self.shape_type = shape_type
        if self.shape_type == "rectangle":
            self.width = kwargs["width"]
            self.height = kwargs["height"]
        elif self.shape_type == "circle":
            self.radius = kwargs["radius"]

    def calculate_area(self):
        if self.shape_type == "rectangle":
            return self.width * self.height
        elif self.shape_type == "circle":
            return pi * self.radius**2i

 

이 코드의 큰 문제는 바로 "새로운 형태 타입"이 추가되면 어떻게 해야 하는가 이다. 즉 코드의 수정 범위가 __init__과 함께 calculate_area까지 뻗치게 된다. 이를 OCP의 원칙을 준수하는 형태로 고치면 다음과 같다.

 

from abc import ABC, abstractmetho
from math import pi

class Shape(ABC):
    def __init__(self, shape_type):
        self.shape_type = shape_type

    @abstractmethod
    def calculate_area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        super().__init__("circle")
        self.radius = radius

    def calculate_area(self):
        return pi * self.radius**2

class Rectangle(Shape):
    def __init__(self, width, height):
        super().__init__("rectangle")
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        super().__init__("square")
        self.side = side

    def calculate_area(self):
        return self.side**2d

 

즉 Class Shape은 변경은 하지 않지만, 확장에는 유연하게 대처할 수 있으며 다른 이 추상 클래스 코드를 상속해서 사용하면 된다.

 

 

3. LSP(Liskov Substitution Principle, 리스코프 치환 원칙)

 

책의 내용이 너무 모호하고 설명이 불친절 해서 괜찮은 내용의 아티클을 가져와 봤다.

 

Demystifying the Liskov Substitution Principle: A Guide for Developers

What Liskov Substitution Principle (LSP) is? The Liskov Substitution Principle (LSP) is...

dev.to

 

Liskov’s Substitution Principle (LSP)

“Objects of super classes must be replaceable with the objects of its sub-classes without affecting the correctness.”

medium.com

 

리스코프 치환 원칙은 상속의 행동에 초점을 맞추고 있다. 이는 한 클래스의 객체가 그 서브클래스의 객체로 대체될 수 있어야 한다고 명시하고 있다. 이를 통해 이 원칙은 하위 클래스가 상위 클래스에 충실하도록 강제한다.

 

하위 클래스는 상위 클래스 메서드의 행동을 증가시키거나 감소시키는 방식으로 수정할 수 있지만, 기본 행동에서 벗어날 수는 없다. 이런 방식으로 이 원칙은 부모 클래스를 자식 클래스로 대체함으로써 행동을 수정할 수 있는 힘을 제공할 뿐만 아니라, 그렇게 하는 경우에도 버그 없는 코드를 보장한다.

 

원칙 적용 방법

 

LSP를 적용한다는 것은 상속을 그 본래의 범위 내에서 적용하는 것, 즉 자식 클래스가 부모 클래스의 모든 행동을 가져야 한다는 것을 의미한다. 상속 관계를 적용할 때 고려해야 할 몇 가지 사항은 다음과 같다.

 

"is-a" 관계: 클래스를 상속할 때, 자식 클래스가 부모 클래스와 "is-a" 관계를 보여야 한다. 만약 자식 클래스가 부모 클래스의 모든 메서드와 속성을 가질 수 없거나, 어떤 메서드나 속성의 기본 행동을 변경해야 한다면 상속 관계가 아닌 것이다.

 

인터페이스: 때로는 클래스를 상속하는 것보다 인터페이스를 통해 그 메서드들을 사용하는 것이 현명할 수 있다. 이렇게 하면 불필요한 상속을 피할 수 있고 진정한 상속을 구현할 수 있다.

 

다형성: 이 원칙을 사용하면 다형성을 달성할 수 있다. 즉, 자식 클래스들이 부모 클래스 메서드의 행동을 변경하지 않고도 같은 클래스의 다른 구현을 제공할 수 있고, 이 자식 클래스들의 객체들이 부모 클래스의 객체로 대체될 수 있다.

 

재사용성과 모듈성: LSP를 준수하면 파생 클래스를 기본 클래스 대신 원활하게 대체할 수 있다. 이를 통해 코드 재사용성이 높아지며, 동일한 코드를 다양한 서브클래스에 적용할 수 있다. 

 

유지보수성과 유연성: LSP 준수를 통해 코드베이스의 유지보수성과 유연성이 향상된다. LSP는 코드 중복을 줄이고, 일관되고 명확한 구조를 만들며, 기존 코드에 영향을 미치지 않으면서 파생 클래스에 대한 수정 및 추가를 가능하게 한다.

 

 

4. ISP(Interface Segregation Principle, 인터페이스 분리 원칙)

 

The difference between liskov substitution principle and interface segregation principle

Is there any core difference between Liskov Substitution Principle (LSP) and Interface Segregation Principle (ISP)? Ultimately, both are vouching for designing the interface with common functionali...

stackoverflow.com

 

사실 ISP와 LSP의 차이에 대해서 많이 헷갈리긴 했는데 이에 대해 잘 정리해준 답변이 있어서 가져와 봤다.

 

답변 1번

LSP: 하위 타입은 약속한 계약대로 준수해야 된다.

ISP: 호출자는 기반 타입의 인터페이스에서 필요 이상으로 의존하지 않아야 한다.

 

만약 인터페이스 분리 원칙(ISP)을 적용한다면, 리시버의 전체 인터페이스가 아닌 일부분만을 사용하게 된다. 하지만 리스코프 치환 원칙(LSP)에 따르면, 리시버는 여전히 그 일부분 인터페이스를 준수해야 한다.

 

만약 ISP를 적용하지 않는다면, LSP를 위반할 유혹이 생길 수 있다. 왜냐하면 "이 메서드는 중요하지 않으니 실제로 호출되지 않을 것"이라고 생각할 수 있기 때문이다.

 

답변 2번

LSP(리스코프 치환 원칙): 이 원칙은 모든 자식 클래스가 부모 클래스와 똑같은 동작을 가져야 한다고 요구한다. 예를 들어, Device 클래스가 있고 그 클래스에 callBaba() 함수가 있어서 아버지의 전화번호를 가져와 전화를 거는 기능을 한다고 합시다. 그러면 Device 클래스의 모든 하위 클래스에서 callBaba() 메서드가 똑같은 작업을 수행해야 한다. 만약 Device의 어떤 하위 클래스에서 callBaba() 메서드에 다른 동작이 포함되어 있다면, 이는 LSP를 위반하는 것을 의미한다.

 

ISP (인터페이스 분리 원칙): 서로 다른 책임에 대해서는 다른 인터페이스를 만들라고 요구한다. 다시 말해, 관련 없는 동작들을 하나의 인터페이스에 모아두지 말자. 이미 여러 책임을 가진 인터페이스가 있고, 구현자가 그 모든 것을 필요로 하지 않는다면 ISP를 위반하게 됩니다.

 

 

5. DIP(Dependency Inversion Principle, 의존성 역전 원칙)

DIP의 경우는 파이썬 코드로 한 번 보겠다. (출처 : https://blog.nonstopio.com/dependency-inversion-principle-in-python-18bc0165e6f1)

class Logger:
    def log(self, message):
        with open('log.txt', 'a') as f:
            f.write(message + '\n')

class Calculator:

    def __init__(self):
        self.logger = Logger()

    def add(self, x, y):
        result = x + y
        self.logger.log(f"Added {x} and {y}, result = {result}")
        return result

 

위의 예시에서는 Calculator(계산기) 클래스가 로거 클래스에 의존하고 있다. 이는 DIP를 위배한다. 왜냐하면 계산기 클래스가 저 수준 모듈에 의존하고 있기 때문이다. 이를 고치면 다음과 같이 된다.

 

from abc import ABC, abstractmethod

class LoggerInterface(ABC):
    @abstractmethod
    def log(self, message):
        pass

class Logger(LoggerInterface):
    def log(self, message):
        with open('log.txt', 'a') as f:
            f.write(message + '\n')

class Calculator:
    def __init__(self, logger: LoggerInterface):
        self.logger = logger

    def add(self, x, y):
        result = x + y
        self.logger.log(f"Added {x} and {y}, result = {result}")
        return result

 

log 메서드를 정의하는 LoggerInterface라는 추상 클래스를 만들었다. 또한 Calculator 클래스를 수정하여 생성자에서 LoggerInterface 객체를 의존성으로 받도록 설정했다. 이렇게 함으로써 Calculator 클래스는 Logger 클래스의 구체적인 구현체에 의존하는 것이 아니라 추상화에 의존하게 된다.

 

 

728x90
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유