Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby 개발자를 위한 SOLID 설계 원칙 완벽 가이드

모든 소프트웨어 애플리케이션은 시간이 지나면서 변화합니다. 소프트웨어에 가해진 변경은 예상치 못한 연쇄적인 문제를 일으킬 수 있습니다. 하지만 변하지 않는 소프트웨어를 만들 수는 없기 때문에 변화는 피할 수 없습니다. 소프트웨어가 성장함에 따라 요구사항도 계속 바뀌기 마련입니다. 우리가 할 수 있는 것은 변화에 유연하게 대응할 수 있도록 소프트웨어를 설계하는 것입니다. 처음에는 시간과 노력이 들지만, 장기적으로 보면 큰 비용을 절약해 줍니다. 강하게 결합된(tightly coupled) 소프트웨어는 취약하며, 변경이 어떤 결과를 가져올지 예측하기 어렵습니다.

잘못 설계된 소프트웨어가 초래하는 대표적인 문제는 다음과 같습니다:

  • 코드의 이식성(immobility)이 떨어집니다.
  • 코드 수정 비용이 매우 높아집니다.
  • 소프트웨어를 단순하게 만드는 것보다 복잡도를 더하는 것이 쉬워집니다.
  • 코드를 관리하기 어려워집니다.
  • 개발자가 기능의 동작 방식을 파악하는 데 많은 시간이 걸립니다.
  • 한 부분을 수정하면 다른 부분이 깨지는 경우가 많고, 변경이 어떤 문제를 일으킬지 예측할 수 없습니다.

로버트 C. 마틴(Robert C. Martin)의 논문 Design Principles and Design Patterns에서는 부패해가는 소프트웨어(rusting software)의 증상을 다음과 같이 정리했습니다:

  • 경직성(Rigidity): 한 부분을 수정하면 코드의 다른 부분까지 수정해야 해서, 문제를 일으키지 않고 코드를 변경하기가 매우 어렵습니다.
  • 취약성(Fragility): 코드를 수정하면 소프트웨어의 동작이 깨지기 쉽습니다. 심지어 변경과 직접 관련이 없는 부분까지 망가질 수 있습니다.
  • 부동성(Immobility): 애플리케이션의 일부 기능이 서로 유사하더라도 코드를 재사용할 수 없어 결국 중복해서 작성해야 합니다.
  • 점착성(Viscosity): 소프트웨어를 수정하기 어려우면, 코드를 개선하는 대신 복잡도만 계속 추가하게 됩니다.

따라서 변경을 통제 가능하고 예측할 수 있게 만들려면, 그에 맞는 소프트웨어 설계가 반드시 필요합니다.

SOLID 설계 원칙은 소프트웨어 프로그램을 디커플링(decoupling, 결합 분리)하여 이러한 문제들을 해결하는 데 도움을 줍니다. 로버트 C. 마틴이 자신의 논문에서 이 개념들을 소개했고, 이후 마이클 페더스(Michael Feathers)가 SOLID라는 두문자어를 만들었습니다.

SOLID 설계 원칙은 다음 다섯 가지 원칙으로 구성됩니다:

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

이제 각 원칙을 하나씩 살펴보며, 이 원칙들이 Ruby로 잘 설계된 소프트웨어를 만드는 데 어떻게 도움이 되는지 알아보겠습니다.

단일 책임 원칙 (SRP: Single Responsibility Principle)

인사관리 소프트웨어에서 사용자 생성, 직원 급여 등록, 급여명세서(payslip) 발행 기능이 필요하다고 가정해 봅시다. 구현할 때 이 기능들을 하나의 클래스에 몰아넣을 수도 있지만, 이런 접근 방식은 기능들 사이에 불필요한 의존성을 만들어냅니다. 시작할 때는 간단해 보이지만, 요구사항이 바뀌고 새로운 기능이 추가될 때 어떤 기능이 깨질지 예측할 수 없게 됩니다.

클래스는 단 하나의 변경 이유만 가져야 한다 — 로버트 C. 마틴

모든 기능이 하나의 클래스에 들어 있는 샘플 코드입니다:

class User
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate_payslip
    # 데이터베이스에서 읽고,
    # 급여명세서를 생성한 뒤
    # 파일로 저장하는 코드
    self.send_email
  end

  def send_email
    # 이메일 전송 코드
    employee.email
    month
  end
end

급여명세서를 생성해 사용자에게 이메일로 발송하려면 클래스를 초기화하고 generate_payslip 메서드를 호출하면 됩니다:

  month = 11
  user = User.new(employee, month)
  user.generate_payslip

그런데 새로운 요구사항이 등장했습니다. 급여명세서는 생성하되 이메일은 보내지 않기를 원합니다. 내부 보고용이라 이메일 발송이 필요 없는 새로운 명세서 생성 기능을 추가하되, 기존 기능은 그대로 유지해야 합니다. 이 과정에서 직원에게 발송되는 기존 급여명세서 기능이 정상 동작하는지 반드시 확인해야 합니다.

이 요구사항을 위해 기존 코드를 재사용할 수는 없습니다. generate_payslip 메서드에 플래그를 추가해 true면 이메일을 보내고 아니면 보내지 않는 방식으로 처리할 수도 있습니다. 하지만 이는 기존 코드를 수정하는 것이므로 기존 기능을 깨뜨릴 위험이 있습니다.

문제를 일으키지 않으려면 이 로직들을 별도의 클래스로 분리해야 합니다:

  class PayslipGenerator
    def initialize(employee, month)
      @employee = employee
      @month = month
    end

    def generate_payslip
      # 데이터베이스에서 읽고,
      # 급여명세서를 생성한 뒤
      # 파일로 저장하는 코드
    end
  end
  class PayslipMailer
    def initialize(employee)
      @employee = employee
    end

    def send_mail
      # 이메일 전송 코드
      employee.email
      month
    end
  end

이제 두 클래스를 초기화하고 각각의 메서드를 호출하면 됩니다:

  month = 11
  # 일반 급여명세서 생성
  generator = PayslipGenerator.new(employee, month)
  generator.generate_payslip
  # 이메일 발송
  mailer = PayslipMailer.new(employee, month)
  mailer.send_mail

이 접근 방식은 책임들을 분리하고 변경을 예측 가능하게 만들어 줍니다. 메일러 기능만 수정해야 할 때, 리포트 생성 로직을 건드리지 않고도 작업할 수 있습니다. 기능 변경 지점도 훨씬 쉽게 예측할 수 있습니다.

예를 들어 이메일의 월(month) 필드 형식을 11 대신 Nov로 바꿔야 한다고 가정해 봅시다. 이 경우 PayslipMailer 클래스만 수정하면 되고, PayslipGenerator 기능은 아무것도 바뀌거나 깨지지 않음을 확신할 수 있습니다.

코드를 작성할 때마다 한 가지 질문을 스스로에게 던져 보세요. "이 클래스의 책임은 무엇인가?" 답변에 "그리고(and)"가 포함된다면, 그 클래스를 여러 개로 나눠야 합니다. 작고 목적이 분명한 클래스가 항상 크고 범용적인 클래스보다 낫습니다.

개방-폐쇄 원칙 (OCP: Open/Closed Principle)

개방-폐쇄 원칙은 버트란트 마이어(Bertrand Meyer)가 저서 Object-Oriented Software Construction에서 처음 제시했습니다.

이 원칙은 "소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하지만 수정에는 닫혀 있어야 한다"고 말합니다. 즉, 엔티티 자체를 수정하지 않고도 동작을 변경할 수 있어야 한다는 의미입니다.

앞선 예제에서 직원 대상 급여명세서 발송 기능을 만들었지만, 이는 모든 직원에게 공통으로 적용되는 아주 일반적인 형태였습니다. 그런데 새로운 요구사항이 생겼습니다. 직원 유형에 따라 급여명세서를 생성해야 합니다. 정규직(full-time) 직원과 계약직(contractor) 직원은 서로 다른 급여 산정 로직이 필요합니다. 이 경우 기존 PayslipGenerator를 수정해 기능을 추가할 수 있습니다:

class PayslipGenerator
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate_payslip
    # 데이터베이스에서 읽고,
    if employee.contractor?
        # 계약직용 급여명세서 생성
    else
        # 일반 급여명세서 생성
    end
    # 파일로 저장하는 코드
  end
end

하지만 이것은 좋지 않은 패턴입니다. 이렇게 하면 기존 클래스를 수정하게 됩니다. 고용 계약 조건에 따라 더 많은 생성 로직을 추가해야 한다면 계속 기존 클래스를 수정해야 하는데, 이는 개방-폐쇄 원칙을 위반합니다. 클래스를 수정하면 의도치 않은 변경이 발생할 위험이 있습니다. 무언가가 변경되거나 추가될 때 기존 코드에 알 수 없는 문제를 일으킬 수 있습니다. 게다가 이런 if-else 분기는 같은 클래스 안의 여러 곳에 존재할 수 있습니다. 새로운 직원 유형을 추가할 때 if-else가 있는 위치를 놓치기 쉽고, 이를 모두 찾아 수정하는 것은 위험하며 문제를 일으킬 수 있습니다.

엔티티를 수정하지 않으면서 확장을 통해 기능을 추가할 수 있도록 코드를 리팩토링할 수 있습니다. 각 유형별로 별도의 클래스를 만들고, 모두 동일한 generate 메서드를 갖도록 해 봅시다:

class ContractorPayslipGenerator
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate
    # 데이터베이스에서 읽고,
    # 급여명세서를 생성한 뒤
    # 파일로 저장하는 코드
  end
end
class FullTimePayslipGenerator
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate
    # 데이터베이스에서 읽고,
    # 급여명세서를 생성한 뒤
    # 파일로 저장하는 코드
  end
end

반드시 메서드 이름을 동일하게 유지하세요. 이제 PayslipGenerator 클래스가 이 클래스들을 사용하도록 변경합니다:

GENERATORS = {
  'full_time' => FullTimePayslipGenerator,
  'contractor' => ContractorPayslipGenerator
}

class PayslipGenerator
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate_payslip
    # 데이터베이스에서 읽고,
    # 급여명세서를 생성한 뒤
    GENERATORS[employee.type].new(employee, month).generate()
    # 파일로 저장하는 코드
  end
end

여기서 GENERATORS 상수는 직원 유형에 따라 호출할 클래스를 매핑합니다. 이를 통해 어떤 클래스를 호출할지 판단할 수 있습니다. 이제 새로운 기능을 추가해야 할 때는 해당 유형의 새 클래스를 만들고 GENERATORS 상수에 등록하기만 하면 됩니다. 기존 로직을 걱정하거나 무언가를 깨뜨릴 염려 없이 클래스를 확장할 수 있으며, 어떤 유형의 급여명세서 생성기든 손쉽게 추가하거나 제거할 수 있습니다.

리스코프 치환 원칙 (LSP: Liskov Substitution Principle)

리스코프 치환 원칙은 "S가 T의 하위 타입이라면, T 타입의 객체는 S 타입의 객체로 교체할 수 있어야 한다"고 정의합니다.

이 원칙을 이해하려면 먼저 문제 상황부터 살펴봐야 합니다. 개방-폐쇄 원칙에 따라 우리는 확장 가능한 형태로 소프트웨어를 설계했습니다. 특정 작업을 수행하는 급여명세서 생성기 하위 클래스를 만들었고, 호출자 입장에서는 실제로 어떤 클래스가 호출되는지 알 수 없습니다. 호출자가 차이를 느낄 수 없도록 이 클래스들은 동일한 동작을 가져야 합니다. 여기서 '동작'이란 클래스의 메서드들이 일관성을 유지해야 한다는 뜻입니다. 이 클래스들의 메서드는 다음 특성을 충족해야 합니다:

  • 메서드 이름이 같아야 합니다.
  • 같은 데이터 타입의 인자를 같은 개수만큼 받아야 합니다.
  • 같은 데이터 타입을 반환해야 합니다.

급여명세서 생성기 예제로 돌아가 봅시다. 정규직용 생성기와 계약직용 생성기, 두 개가 있습니다. 이 급여명세서 생성기들이 일관된 동작을 갖도록 하려면 공통 기반 클래스(base class)를 상속받아야 합니다. User라는 이름의 기반 클래스를 정의해 보겠습니다.

class User
  def generate
  end
end

개방-폐쇄 원칙 예제에서 만든 하위 클래스는 기반 클래스가 없었습니다. User를 기반 클래스로 상속받도록 수정합니다:

class ContractorPayslipGenerator < User
  def generate
    # 급여명세서 생성 코드
  end
end
class FullTimePayslipGenerator < User
  def generate
    # 급여명세서 생성 코드
  end
end

다음으로, User 클래스를 상속하는 모든 하위 클래스가 반드시 구현해야 하는 메서드 집합을 정의합니다. 이 메서드들은 기반 클래스에 선언합니다. 우리 예제에서는 generate 메서드 하나만 필요합니다.

class User
  def generate
    raise "NotImplemented"
  end
end

여기서 raise 문이 포함된 generate 메서드를 정의했습니다. 따라서 기반 클래스를 상속하는 모든 하위 클래스는 generate 메서드를 반드시 구현해야 합니다. 구현하지 않으면 메서드가 구현되지 않았다는 오류가 발생합니다. 이렇게 하면 하위 클래스의 일관성을 보장할 수 있고, 호출자는 언제나 generate 메서드가 존재한다는 것을 확신할 수 있습니다.

이 원칙 덕분에 많은 수정 없이도, 무언가를 깨뜨리지 않고 하위 클래스를 손쉽게 교체할 수 있습니다.

인터페이스 분리 원칙 (ISP: Interface Segregation Principle)

인터페이스 분리 원칙은 주로 정적 타입 언어에 적용되는 원칙입니다. Ruby는 동적 언어이기 때문에 인터페이스라는 개념 자체가 없습니다. 인터페이스는 클래스 간의 추상화 규칙을 정의하는 역할을 합니다.

이 원칙은 다음과 같이 정의됩니다:

클라이언트는 자신이 사용하지 않는 인터페이스에 의존하도록 강요받아서는 안 된다 — 로버트 C. 마틴

즉, 어떤 클래스든 사용할 수 있는 하나의 거대한 범용 인터페이스보다는, 여러 개의 작은 인터페이스로 나누는 것이 좋다는 의미입니다. 범용 인터페이스를 정의하면 클래스는 자신이 사용하지 않는 정의에까지 의존하게 됩니다.

Ruby에는 인터페이스가 없지만, 클래스와 하위 클래스 개념을 활용해 유사한 구조를 만들 수 있습니다.

리스코프 치환 원칙 예제에서 하위 클래스 FullTimePayslipGenerator가 일반 클래스인 User를 상속받았습니다. 하지만 User는 너무 범용적인 클래스라 다른 메서드들도 포함할 수 있습니다. 만약 Leave(휴가) 같은 또 다른 기능이 필요하다면 이 역시 User의 하위 클래스가 되어야 합니다. Leavegenerate 메서드가 필요 없는데도 이 메서드에 의존하게 됩니다. 따라서 범용 클래스 대신 목적에 맞는 구체적인 클래스를 만드는 것이 좋습니다:

class Generator
  def generate
    raise "NotImplemented"
  end
end
class ContractorPayslipGenerator < Generator
  def generate
    # 급여명세서 생성 코드
  end
end
class FullTimePayslipGenerator < Generator
  def generate
    # 급여명세서 생성 코드
  end
end

이제 이 생성기는 급여명세서 생성에 특화되어 있으며, 하위 클래스가 범용적인 User 클래스에 의존할 필요가 없습니다.

의존성 역전 원칙 (DIP: Dependency Inversion Principle)

의존성 역전은 소프트웨어 모듈을 디커플링하기 위해 적용하는 원칙입니다.

상위 모듈은 하위 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다.

지금까지 설명한 원칙들을 적용한 설계는 자연스럽게 의존성 역전 원칙으로 이끕니다. 단일 책임을 가진 클래스라도 동작하기 위해서는 다른 클래스의 도움이 필요합니다. 급여를 산정하려면 데이터베이스에 접근해야 하고, 리포트가 생성되면 파일에 기록해야 합니다. 단일 책임 원칙에 따르면 하나의 클래스에는 하나의 작업만 부여하려고 하지만, 데이터베이스 읽기나 파일 쓰기 같은 작업은 결국 같은 클래스 안에서 수행되어야 합니다.

이러한 의존성을 제거하고 핵심 비즈니스 로직을 분리하는 것이 중요합니다. 그래야 코드가 변화에 유연하게 대응할 수 있고, 변경도 예측 가능해집니다. 의존성을 역전시켜야 하며, 모듈의 호출자가 의존성을 통제할 수 있어야 합니다. 급여명세서 생성기에서 의존성은 리포트의 데이터 소스입니다. 호출자가 데이터 소스를 지정할 수 있도록 코드를 구성해야 합니다. 의존성에 대한 통제권을 역전시키면 호출자가 손쉽게 수정할 수 있습니다.

위 예제에서 ContractorPayslipGenerator 모듈은 의존성을 직접 통제합니다. 어디서 데이터를 읽고 출력을 어떻게 저장할지가 클래스 내부에서 결정되기 때문입니다. 이를 뒤집기 위해 사용자 데이터를 읽는 UserReader 클래스를 만들어 보겠습니다:

class UserReader
  def get
    raise "NotImplemented"
  end
end

이제 Postgres에서 데이터를 읽도록 만들고 싶다고 가정해 봅시다. 이를 위해 UserReader의 하위 클래스를 생성합니다:

class PostgresUserReader < UserReader
  def get
    # Postgres에서 데이터를 읽는 코드
  end
end

마찬가지로 FileUserReader, InMemoryUserReader 등 원하는 어떤 종류의 리더(reader)든 만들 수 있습니다. 이제 FullTimePayslipGenerator 클래스가 PostgresUserReader를 의존성으로 주입받도록 수정합니다.

class FullTimePayslipGenerator < Generator
  def initialize(datasource)
    @datasource = datasource
  end

  def generate
    # 급여명세서 생성 코드
    data = datasource.get()
  end
end

호출자는 이제 PostgresUserReader를 의존성으로 전달할 수 있습니다:

datasource = PostgresUserReader.new()
FullTimePayslipGenerator.new(datasource)

호출자가 의존성을 통제하며, 필요할 때 데이터 소스를 손쉽게 변경할 수 있습니다.

의존성 역전은 클래스에만 적용되는 것이 아닙니다. 설정(configuration)도 역전해야 합니다. 예를 들어 Postgres 서버에 연결할 때는 DB URL, 사용자명, 비밀번호 같은 구체적인 설정이 필요합니다. 이런 설정값들을 클래스 안에 하드코딩하는 대신, 호출자로부터 전달받아야 합니다.

class PostgresUserReader < UserReader
  def initialize(config)
    @config = config
  end

  def get
    # config로 데이터베이스 초기화
    @config
    # Postgres에서 데이터를 읽는 코드
  end
end

호출자가 설정을 제공합니다:

  config = { url: "url", user: "user" }
  datasource = PostgresUserReader.new(config)
  FullTimePayslipGenerator.new(datasource)

이제 호출자가 의존성을 완전히 통제하며, 변경 관리가 쉽고 고통스럽지 않습니다.

결론

SOLID 설계는 코드를 디커플링하여 변경을 덜 고통스럽게 만들어 줍니다. 프로그램을 결합도가 낮고, 재사용 가능하며, 변화에 민첩하게 대응하는 방식으로 설계하는 것이 중요합니다. 다섯 가지 SOLID 원칙은 서로를 보완하며 함께 존재해야 합니다. 잘 설계된 코드베이스는 유연하고, 수정하기 쉽고, 작업하는 재미도 있습니다. 새로 합류한 개발자도 코드를 쉽게 이해하고 빠르게 적응할 수 있습니다.

SOLID가 어떤 종류의 문제를 해결하는지, 그리고 왜 이 원칙을 적용하는지 이해하는 것이 무엇보다 중요합니다. 문제를 제대로 이해해야 설계 원칙을 진심으로 받아들이고 더 나은 소프트웨어를 설계할 수 있습니다.