시작하며

좋은 설계란 상호 결합도가 낮고 응집도가 높은 설계이다. 상호 결합도가 낮으면 모듈을 쉽게 떼어내서 다른 곳에 붙여 사용할 수 있고, 응집도가 높으면 모듈이 의존적이지 않고 독립적으로 자립한다. 이 좋은 설계를 위한 가이드 중 하나가 SOLID이다.

SOLID 원칙

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

An Object class should only be responsible for only one specific function, and only have one reason to change.

  • 정의: 모든 객체는 하나의 특정 기능만 책임진다. 수정이나 변경사항이 이루어지는 이유는 단 한 가지여야 한다.
  • 이점: 코드 재사용성을 높여준다.

예시

// 잘못된 설계
type FinanceReport struct { // 보고서 객체
    report string
}
 
func (r *FinanceReport) SendReport(email string) { // 메서드
    ...
}
 
// 이게 왜 단일 책임 원칙을 위배한 경우인가?
// `FinanceReport` 가 아닌 새로운 `MarketingReport`가 생긴다면.. SendReport를 재활용할 수 없고 새로 만들어야 한다!
// 올바른 설계
// FinanceReport가 Report인터페이스를 구현하고, ReportSender는 Report 인터페이스를 이용하는 관계를 형성한다.
type Report interface{
    Report() string
}
 
type FinanceReport struct { // 경제 보고서만을 책임진다.
    report string
}
 
func (r *FinanceReport) Report() string { // Report 인터페이스를 구현했다.
    return r.report
}
 
type ReportSender struct { // 보고서 전송만을 책임진다.
    ...
}
 
func (s *ReportSender) SendReport(report Report){ // 전송하는 기능만을 담당한다.
// Report 인터페이스 객체를 인수로 전달받는다.
}

2) 개방-폐쇄 원칙 Open Closed Principle, OCP

Developers should be able to add new features, functions, and extensions to a class while leaving the rest of the existing codebase intact.

  • 정의: 확장에는 열려있고 변경에는 닫혀있다.
  • 이점: 상호 결합도를 줄여 새 기능을 추가할 때 기존 구현을 변경하지 않아도 된다.
// 잘못된 설계
// case를 추가하게 되면 SendReport의 구현을 변경하게 된다. 
func SendReport(r *Report, method SendType, receiver string) {
    switch method {
        case Email:
            ...
        case Fax:
            ...
        case PDF:
            ...
        case Printer:
            ...
    }
}
// 올바른 설계
// EmailSender와 FaxSender 모두 ReportSender interface를 구현한다.
// 새로운 객체를 추가하는 것만으로 기능이 확장된다.
type ReportSender interface {
    Send(r *Report)
}
 
type EmailSender struct { }
 
func (e *EmailSender) Send(r *Report) {
 
}
 
type FaxSender struct {
 
}
 
func (f *FaxSender) Send(r *Report) {
 
}

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

The Objects contained in a subclass must exhibit the same behavior as any higher-level superclass it’s dependent on.

  • 정의: q(x)를 타입 T의 객체 x의 속성이라고 하자. S가 T의 하위타입이라면, q(y)는 타입 S의 객체 y에 대해 증명할 수 있어야 한다.
  • 이점: 예상치 못한 동작을 예방한다.
// 상위 클래스에서 동작하는 상위클래스 함수는 하위타입에 대해서도 똑같이 동작해야 한다.
// Go는 상속을 지원하지 않음으로써, 상속을 잘못 사용해 리스코프 치환 원칙을 위반하는 일을 사전에 방지했다.
type T interface {
    Something()
}
 
type S struct {
}
 
func (s *S) Something() { // S -> T interface 구현
}
 
type U struct {
}
 
func (u *U) Something() { // U -> T interface 구현
}
 
func q(t T) {
    ...
}
 
var y = &S{} // S타입 y
var u = &U{} // U타입 u
q(y)
q(u) // 두개 다 잘 동작해야 한다.

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

Create a separate client interface for each class within an application, even if those classes share some of the same methods.

  • 정의: 클라이언트는 자신이 이용하지 않는 메소드에 의존해서는 안 된다.
  • 이점: 인터페이스를 분리하면 불필요한 메소드들과의 의존관계가 끊어진다.
// 잘못된 설계
type Report interface {
    Report() string
    Pages() int
    Author() string
    WrittenDate() time.Time
}
 
func SendReport(r Report) {
    send(r.Report())
}
// 올바른 설계
// 인터페이스를 분리하면 불필요한 구현이 사라져 더 가볍게 인터페이스를 이용할 수 있다.
type Report interface {
    Report() string
}
 
type WrittenInfo interface {
    Pages() int
    Author() string
    WrittenDate() time.Time
}
 
func SendReport(r Report) {
    send(r.Report())
}

5) 의존관계 역전 원칙 Dependency Inversion Principle, DIP

When a subclass is dependent on a superior class, the higher-level class should not be affected by any changes made to the subclass.

  • 정의: 상위 계층이 하위 계층에 의존하는 전통적인 의존관계를 반전시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립될 수 있다.
    • rule 1) 상위 모듈은 하위 모듈에 의존하는 것이 아니라 둘 다 추상 모듈에 의존한다.
      // 키보드 입력을 모니터 출력으로 보내야 한다면, 입력부분과 출력부분을 추상모듈로 만들고
      // 키보드<->입력모듈<->출력모듈<->모니터와 같은 방식으로 추상화시키면 결합도를 떨어트릴 수 있다.
    • rule 2) 추상 모듈은 구체화된 모듈에 의존하는 것이 아니라 그 반대가 되어야 한다.
      // 구체화된 모듈은 추상모듈에 의존하도록 만든다. 추상 모듈인 Event와 EventListener가 있고,
      // 각각에 해당하는 이벤트와 이벤트 발생시 수행할 작업들을 등록해주는 모양새이다.
  • 이점:
    • 추상 모듈에 의존함으로써 확장성이 증가한다.
    • 결합도가 낮아져서 이식성이 증가한다.

정리하며

SOLID 원칙은 낮은 결합도와 높은 응집도를 달성하기 위한 객체지향 설계의 5가지 가이드이다. Go는 상속 대신 인터페이스 조합을 사용하기 때문에 이 원칙들을 자연스럽게 따르는 구조를 만들기 쉽다. 각 원칙은 독립적이지 않고 서로 연관되어 있으며, SRP를 지키면 OCP도 따르기 쉬워지는 식으로 상호 보완적으로 작용한다.