[Programming] 객체지향 프로그래밍 | 5가지 설계 원칙 SOLID

객체지향 설계 5가지 원칙인 SOLID를 알아보고 좋은 설계란 무엇인지 살펴봅니다.

 

[프로그래밍] 객체지향 프로그래밍 | 5가지 설계 원칙 SOLID

SOLID란, 아래 객체지향 설계 5가지 원칙의 영문명 앞글자를 따서 만든 용어입니다. 로버트 C. 마틴(Robert C. Martin)이 2000년 “디자인 원칙과 디자인 패턴(Design Principles and Design Patterns)“에서 개념을 제창한 후 2004년 마이클 페더스(Michael Feathers)가 SOLID라는 약어로 만들면서 더욱 유명해졌습니다.

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

SOLID 5가지 원칙은 반드시 지켜야 하는 의무사항은 아니지만 이 원칙들에 입각해서 설계를 하면 더 좋은 설계를 할 수 있습니다. 각 원칙을 살펴보고 좋은 설계와 나쁜 설계를 알아보겠습니다.

 

1. 왜 설계를 잘해야 하는가?

SOLID를 알아보기 앞서서 왜 설계가 중요한가 살펴보겠습니다. 설계는 프로그램 코드를 이루는 각 모듈 간 의존 관계를 정의하는 겁니다. 현대 프로그래밍은 과거에 비해서 매우 복잡합니다. 과거에는 슈퍼 프로그래머 한두 명이 프로그램을 완성했지만 지금은 일부 스타트업을 제외하고는 수십에서 수백 명이 각자의 맡은 바 코드를 구현합니다. 맡은 바 코드를 모듈 단위로 볼 수 있는데, 이러한 모듈이 모여 프로그램을 이루다 보니까 설계를 잘하지 않으면 많은 문제가 발생할 수 있습니다.’

 

나쁜 설계

좋은 설계를 하려면 먼저 나쁜 설계를 알아야 합니다. 어떤 설계가 나쁜 설계일까요?

  • 경직성(Rigidity모듈 간의 결합도(Coupling)가 너무 높아서 코드를 변경하기 매우 어려운 구조를 말합니다. 때론 모듈 간 의존 관계가 거미줄처럼 얽혀 있어서 어디부터 손대야 할지 모를 정도로 복잡한 구조를 갖기도 합니다.
  • 부서지기 쉬움(Fragility)한 부분을 건드렸더니 다른 부분까지 망가지는 경우입니다. 언제 어떤 부분이 망가질지 모르기 때문에 프로그램을 변경하기가 매우 힘듭니다.
  • 부동성(Immobility)코드 일부분을 현재 어플리케이션에서 분리해서 다른 프로젝트에도 쓰고 싶지만 모듈 간 결합도가 너무 높아서 옮길 수 없는 경우입니다. 그렇게 되면 코드 재사용률이 급격히 감소하므로 (같거나) 비슷한 기능을 매번 새로 구현해야 합니다.

언급한 3가지 경우를 ‘상호 결합도가 매우 높고 응집도가 낮다’로 정리할 수 있겠네요. ‘상호 결합도가 높다’는 얘기는 모듈이 서로 강하게 결합되어 있어서 떼어낼 수 없다는 뜻입니다. 상호 결합도가 높으면 경직성이 증가되고 그로 인해 한 모듈의 수정이 다른 모듈로 전파되어 예기치 못한 문제가 생기고 코드 재사용성을 낮추게 됩니다. ‘응집도가 낮다’는 얘기는 하나의 모듈이 스스로 자립하지 못한다는 뜻입니다. 즉 하나의 모듈이 스스로 완성되지 못하고 다른 모듈에 의존적인 관계를 가지고 있는 경우입니다.

 

좋은 설계

좋은 설계란 나쁜 설계 요소가 없는 설계를 말합니다. 즉 ‘상호 결합도가 낮고 응집도가 높은 설계’입니다. 상호 결합도가 낮기 때문에 모듈을 쉽게 떼어내서 다른 곳에 사용할 수 있고 모듈 간 독립성이 있기 때문에 한 부분을 변경하더라도 다른 모듈에 문제를 발생시키지 않습니다. 그럼으로써 자연스럽게 모듈 완성도가 높아져서 응집도가 높아집니다.

그럼 어떻게 좋은 설계를 할 수 있을까요? 좋은 설계가 무엇인지는 알았지만 지침이 되는 가이드가 없다면 달성하기 매우 힘들 겁니다. 객체지향 설계 5가지 원칙인 SOLID가 좋은 설계로 이끄는 나침반이 되어줍니다. 이제 이 5가지 원칙을 한 가지씩 알아보겠습니다.

 

2. 단일 책임 원칙

무엇이든지 항상 맨 처음 나오는 게 가장 중요한 법입니다. SOLID 5가지 원칙 각각이 모두 중요하지만 그중에서도 단일 책임 원칙(single responsibility principle, SRP)이 제일 중요하다고 생각합니다. 먼저 정의를 보겠습니다.

 

정의

  • “모든 객체는 책임을 하나만 져야 한다.”

 

이점

  • 코드 재사용성을 높여줍니다.

 

어떻게 보면 너무 당연하고 심플한 정의이지만 코딩하다 보면 무시되기 쉽습니다. 다음 코드를 보겠습니다.

 

type FinanceReport struct { // 회계 보고서
    report string
}

func (r *FinanceReport) SendReport(email string) { // 보고서 전송
    ...
}

 

FinanceReport는 회계 보고서 객체입니다. 이메일로 전송하는 SendReport() 메서드를 가지고 있습니다. 문제 없어 보이는 코드지만 단일 책임 원칙을 위배했습니다. FinanceReport는 말그대로 회계 보고서를 담당하는 객체입니다. 즉 회계 보고서라는 책임을 지고 있습니다. 그런데 이 코드는 보고서를 전송하는 책임까지 지고 있어서 책임이 두 개가 되므로 단일 책임 원칙 위배입니다.

이게 왜 문제가 되는지 살펴보죠. 만약 회계 보고서뿐 아니라 마케팅 보고서라는 객체도 만들었다고 가정해보죠.

 

type MarketingReport struct { // 마케팅 보고서
    report string
}

func (r *MarketingReport) SendReport(email string) { // 보고서 전송
    ...
}

 

이 MarketingReport는 FinanceReport의 SendReport() 메서드를 사용할 수가 없습니다. FinanceReport의 SendReport() 메서드는 말 그대로 FinanceReport에 포함된 기능이라서 다른 타입의 객체가 이용할 수 없기 때문입니다. 구현이 비슷한 SendReport() 메서드를 MarketingReport 객체 안에 또 만들어야 할까요? 그렇게 한다면 동향 보고서, 설문조사 보고서, 물류 보고서 등 보고서 종류가 늘어날 때마다 SendReport()도 늘어나야 합니다.

코드 복사 붙이기가 무슨 문제일까 싶을 수도 있습니다. 하지만 만약 보고서를 이메일이 아닌 다른 형태로 보내야 한다면 그동안 만들어둔 SendReport() 메서드들을 모두 수정해야 하는 문제가 발생합니다.

그럼 단일 책임 원칙에 입각한 설계는 어떤 모습일까요?

FinanceReport는 Report 인터페이스를 구현하고, ReportSender는 Report 인터페이스를 이용하는 관계를 형성하면 됩니다.

 

 

단일 책임 원칙으로 보면 FinanceReport는 경제 보고서만을 책임지고 있고, ReportSender는 보고서 전송이라는 책임 하나만 지고 있습니다. 향후 다른 보고서가 나오더라도 Report 인터페이스만 구현하면 ReportSender를 그대로 이용할 수 있습니다.

다음과 같이 코드로 구현할 수 있습니다.

 

type Report interface {        // Report() 메서드를 포함한 Report 인터페이스
    Report() string
}

type FinanceReport struct {    // 경제 보고서를 담당하는 FinanceReport
    report string
}

func (r *FinanceReport) Report() string { // Report 인터페이스를 구현
    return r.report
}

type ReportSender struct {                // 보고서 전송을 담당
    ...
}

func (s *ReportSender) SendReport(report Report) {
// Report 인터페이스 객체를 인수로 받음
    ...
}

 

이처럼 단일 책임 원칙은 잘 살펴보지 않으면 쉽게 지나칠 수 있지만, 잘 살펴서 이것만 잘 지켜도 많은 문제가 저절로 해결되는 중요한 원칙입니다.

 

💡 단일 책임 원칙의 정의를 “객체나 모듈은 변경하려는 단 하나 이유만을 가져야 한다”라고 말하기도 합니다. 객체나 모듈이 나중에 수정사항이 생길 때 오직 단 하나의 이유만으로 수정이 이뤄져야 한다는 뜻입니다.

 

3. 개방-폐쇄 원칙

개방-폐쇄 원칙(open-closed principle, OCP) 정의는 다음과 같습니다.

 

정의

  • “확장에는 열려 있고, 변경에는 닫혀 있다.”

 

이점

  • 상호 결합도를 줄여 새 기능을 추가할 때 기존 구현을 변경하지 않아도 됩니다.

 

확장과 변경이 모두 비슷한 말이라서 언뜻 이게 무슨 말인지 잘 와닿지 않을 수 있습니다. ‘프로그램에 기능을 추가할 때 기존 코드의 변경을 최소화해야 한다’ 정도로 이해하고 개방-폐쇄 원칙이 지켜지지 않는 코드를 살펴보며 자세히 알아봅시다.

 

func SendReport(r *Report, method SendType, receiver string) {
    switch method {
    case Email:
        // 이메일 전송
    case Fax:
        // 팩스 전송
    case PDF:
        // pdf 파일 생성
    case Printer:
        // 프린팅
    ...
    }
}

 

흔히 보는 코드네요. 전송 방식을 추가하려면 새로운 case를 만들어 구현을 추가해주면 됩니다. 즉 기존 SendReport() 함수 구현을 변경하게 되는 거죠. 따라서 개방-폐쇄 원칙에 위배됩니다. 이 SendType에 따른 switch문이 한 곳만 있다면 그나마 다행이지만 코드 여러 곳에 퍼져있다면 변경 범위가 늘어나게 되고 그만큼 버그를 발생시킬 위험성도 커집니다.

개방-폐쇄 원칙에 입각해서 코드를 수정해보겠습니다.

 

type ReportSender interface {
    Send(r *Report)
}

type EmailSender struct {
}

func (e *EmailSender) Send(r *Report) {
    // 이메일 전송
}

type FaxSender struct {
}

func (f *FaxSender) Send(r *Report) {
    // 팩스 전송
}

 

EmailSender와 FaxSender는 모두 ReportSender라는 인터페이스를 구현한 객체입니다. 여기에 새로운 전송 방식을 추가하면 어떻게 될까요? ReportSender를 구현한 새로운 객체를 추가해주면 됩니다. 새 기능을 추가했지만, 기존 구현을 변경하지 않아도 되는 거죠.

참고로 잘못된 예제는 단일 책임 원칙도 위반했네요. SendReport()가 여러 전송 방식을 책임 지고 있습니다. 개방-폐쇄 원칙을 적용해 구현한 코드는 객체 하나에 책임도 하나입니다. 단일 책임 원칙에도 잘 들어맞네요. 이렇듯 SOLID 원칙은 상호 연결되어 있어서 하나만 잘지켜도 나머지가 저절로 지켜지는 경우가 많습니다.

 

4. 리스코프 치환 원칙

SOLID의 원칙 중 가장 이해하기 어렵다는 리스코프 치환 원칙(liskov substitution principle, LSP)을 알아보겠습니다. 정의부터 살펴보겠습니다.

 

정의

  • q(x)를 타입 T의 객체 x에 대해 증명할 수 있는 속성이라 하자. 그렇다면 S가 T의 하위 타입이라면 q(y)는 타입 S의 객체 y에 대해 증명할 수 있어야 한다.”

 

이점

  • 예상치 못한 작동을 예방할 수 있습니다.

 

정의만 봐서는 무슨 뜻인지 알기 어려우니 코드로 살펴보겠습니다.

 

type T interface {        // Something() 메서드를 포함한 인터페이스
    Something()
}

type S struct {
}

func (s *S) Something() { // T 인터페이스 구현
}

type U struct {
}

func (u *U) Something() { // T 인터페이스 구현
}

func q(t T) {
    ...
}
var y = &S{}          // S 타입 y
var u = &U{}          // U 타입 u
q(y)
q(u)                  // 둘 다 잘 동작해야 한다.

 

T 인터페이스가 있습니다. 이것을 S 객체와 U 객체¹가 구현하고 있습니다. 그리고 함수 q()는 인터페이스 T를 인수로 받습니다. 이때 q() 함수는 S 객체 인스턴스인 y와 U 객체 인스턴스인 u 모두에 대해서 잘 동작해야 한다는 얘기입니다.

어찌보면 너무나 당연한 얘기 같아 보입니다. S와 U가 T의 하위 타입이기 때문에 당연히 상위 타입인 T를 인수로 받는 함수에 인스턴스를 넣어도 잘 동작해야 합니다. 하지만 실제론 그렇지 않는 경우가 발생합니다. 우리가 어떤 함수를 만들면 이것은 그 함수를 호출하는 호출자와 함수 구현 간의 계약 관계가 발생한다고 볼 수 있습니다. 예를 들어보겠습니다.

 

type Report interface {
    Report() string
}

func SendReport(r Report)

 

위 SendReport() 함수의 호출자는 어떤 의도로 이 함수를 호출할까요? 당연히 이 함수를 호출하면 Report를 전송할 것으로 생각할 겁니다. 이것이 바로 호출자와 함수 간 계약이 성립한다고 말하는 겁니다. 그런데 이 함수를 호출했는데 Report가 전송되지 않고 다른 일이 발생된다면 호출자가 예상하지 못한 버그가 발생하게 될 겁니다. 코드로 구현해 살펴봅시다.

 

type Report interface {
    Report() string
}

type MarketingReport {
}

func (m *MarketingReport) Report() string {
    ...
}

func SendReport(r Report) {
    if _, ok := r.(*MarketingReport); ok {    // r이 마케팅 보고서일 경우 패닉
        panic("Can't send MarketingReport")
    }
    ...
}

var report = &MarketingReport{}
SendReport(report)            // 패닉 발생

 

Report 인터페이스가 있고, MarketingReport 객체가 Report 인터페이스를 구현합니다. SendReport() 함수는 Report 인터페이스를 인수로 받습니다. MarketingReport는 Report 인터페이스를 구현하고 있기 때문에 SendReport()의 인수로 사용될 수 있습니다. 호출자 입장에서는 당연히 MarketingReport 인스턴스도 전송이 잘 될 거라 예상하지만 실제로는 패닉이 발생합니다.

상위 타입 Report에 대해서 작동하는 SendReport() 함수는 하위 타입인 MarketingReport에 대해서도 똑같이 작동해야 하지만 이 코드는 그렇지 못하기 때문에 리스코프 치환 원칙을 위배한 코드가 됩니다.

리스코프 치환 원칙에 입각한 코드는 함수 계약 관계를 준수하는 코드를 말합니다. 사실 리스코프 치환 원칙은 Go 언어보다는 상속을 지원하는 다른 언어에서 더 큰 문제를 발생시킵니다. Go 언어가 상속을 지원하지 않는 이유 역시 상속을 잘못 사용해 리스코프 치환 원칙을 위반하는 일을 예방하려는 의도라고 생각합니다

 

¹ 리스코프 치환 원칙에 따르면 베이스타입 T와 하위타입 S만 필요하지만 T는 인터페이스이고 Go 언어에서는 상속을 지원하지 않기 때문에 다른 하위타입인 U를 만들어서 설명합니다. 자세한 사항은 B.1절 ‘Go 언어는 객체지향 언어인가?’를 참조하세요.

💡 리스코프 치환 원칙의 리스코프는 사람 이름으로 바바라 리스코프(1939년생)를 말합니다. 컴퓨터 공학자로 객체지향 프로그래밍의 발전에 매우 큰 이바지를 하신 분입니다.

 

5. 인터페이스 분리 원칙

인터페이스 분리 원칙(interface segregation principle, ISP) 정의는 다음과 같습니다.

 

정의

  • “클라이언트는 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.”

 

이점

  • 인터페이스를 분리하면 불필요한 메서드들과 의존 관계가 끊어져 더 가볍게 인터페이스를 이용할 수 있습니다.

 

무슨 말인지 코드로 알아보겠습니다.

 

type Report interface {
    Report() string
    Pages() int
    Author() string
    WrittenDate() time.Time
}

func SendReport(r Report) {
    send(r.Report())
}

 

Report 인터페이스는 메서드를 총 4개 포함합니다. 하지만 SendReport()는 Report 인터페이스가 포함한 4개 메서드 중에 Report() 메서드만 사용합니다. 즉, 인터페이스 이용자에게 불필요한 메서드들을 인터페이스가 포함하고 있습니다. 그래서 인터페이스 분리 원칙을 위반한 코드입니다.

ISP에 입각해서 코드를 고쳐보겠습니다.

 

type Report interface {
    Report() string
}

type WrittenInfo interface {
    Pages() int
    Author() string
    WrittenDate() time.Time
}

func SendReport(r Report) {
    send(r.Report())
}

 

Report 인터페이스는 메서드 하나만 가지고 있습니다. 이제 SendReport()는 함수가 필요한 유일한 메서드인 Report를 포함한 인터페이스와 관계를 맺고, 불필요한 메서드와는 관계 맺지 않습니다. 즉 많은 메서드들을 포함하는 커다란 인터페이스보다는 적은 수의 메서드를 가진 인터페이스 여러 개로 이뤄진 객체가 더 좋다는 얘기가 됩니다.

인터페이스 분리 원칙을 위반한 코드에서는 SendReport()를 이용하려면 Report(), Pages(), Author(), WrittenDate() 등 4개 메서드를 모두 구현해줘야 했습니다. 하지만 인터페이스 분리 원칙에 입각한 코드에서는 SendReport()를 이용하려면 Report() 메서드 하나만 구현해도 됩니다. 이처럼 인터페이스를 분리해 불필요한 메서드들과 의존 관계를 끊으면 더 가볍게 인터페이스를 이용할 수 있습니다.

 

6. 의존 관계 역전 원칙

SOLID에서 가장 중요한 원칙으로 단일 책임 원칙과 함께 의존 관계 역전 원칙(dependency inversion principle, DIP)을 꼽을 수 있습니다. 의존 관계 역전 원칙은 다음과 같은 정의와 두 가지 원칙을 가지고 있습니다.

 

정의

  • “상위 계층이 하위 계층에 의존하는 전통적인 의존 관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다.”

 

원칙

  • 원칙 1 : “상위 모듈은 하위 모듈에 의존해서는 안 된다. 둘 다 추상 모듈에 의존해야 한다.”
  • 원칙 2 : “추상 모듈은 구체화된 모듈에 의존해서는 안 된다. 구체화된 모듈은 추상 모듈에 의존해야 한다.”

 

이점

  • 구체화된 모듈이 아닌 추상 모듈에 의존함으로써 확장성이 증가합니다.
  • 상호 결합도가 낮아져서 다른 프로그램으로 이식성이 증가합니다.

 

6.1 원칙 1 뜯어보기

“상위 모듈은 하위 모듈에 의존해서는 안 된다. 둘 다 추상 모듈에 의존해야 한다.”

대개는 해결책을 찾을 때 위에서 아래로 내려가며 사고하는 경향이 있습니다. 이를 탑다운 방식(Top-Down)이라고 합니다. 우리는 이 방식에 익숙해져 있기 때문에 코딩도 보통 탑다운 방식으로 합니다. 예를 들어 키보드로 받은 입력을 네트워크로 ‘전송’하는 객체를 살펴보겠습니다.

 

 

구조적 프로그래밍 방식으로 이와 같이 나타낼 수 있습니다. 상위 모듈인 전송의 동작은 키보드라는 하위 모듈에서 값을 읽어서 다시 네트워크라는 하위 모듈로 값을 쓰는 방식으로 구현할 수 있습니다. 하지만 이 경우에 상위 모듈인 전송과 하위 모듈인 키보드, 네트워크 간에 결합도가 높아집니다.

제대로 객체지향 설계를 하려면 탑다운 방식이 아닌 전혀 새로운 방식으로 사고해야 합니다. 의존 관계 역전 원칙의 정의대로 상위 모듈은 하위 모듈에 의존해서는 안 됩니다. 전통적인 방식에서 뒤집어서 생각해야 하기 때문에 역전(Inversion)이라는 단어를 사용한 겁니다. 그럼 어떻게 ‘역전’할 수 있을까요? ‘둘 다 추상화 모듈에 의존해야 한다’에 해답이 있습니다.

전송은 결국 Input을 Output으로 연결시켜주는 행위입니다. 다이어그램으로 그려보겠습니다.

 

 

키보드는 입력이라는 추상 모듈을 구현하고 있고, 네트워크는 출력이라는 추상 모듈을 구현하고 있습니다. 전송 모듈은 구체화된 객체인 키보드와 네트워크가 아닌 추상화된 입력과 출력 모듈을 사용하고 있습니다. 즉, 키보드, 네트워크, 전송 모두 추상 모듈에 의존하고 있는 관계가 됩니다.

이렇게 의존 관계를 역전하면 어떤 이득을 얻게 될까요? 각 의존 관계를 떨어뜨리면 각 모듈은 본연의 기능에 충실할 수 있습니다. 전송은 입력을 출력으로 연결시키는 본연의 기능에, 키보드는 입력이라는 기능에, 네트워크는 출력이라는 기능에 충실해집니다. 또 서로 결합도가 낮아짐으로써 독립적이 됩니다. 즉, 키보드가 아니라 터치나 음성, 제스처 등 다른 입력 장치를 사용하더라도 입력 추상 모듈을 구현한다면 전송 모듈을 사용할 수 있습니다. 또한 프린터, 파일, 소리 등 출력 추상 모듈을 구현한 다른 모듈로도 출력할 수 있게 됩니다. 서로 독립성이 유지되기 때문에 전송 모듈을 쉽게 분리해 다른 애플리케이션에도 사용할 수 있습니다. 즉, 코드 재사용성이 높아집니다.

 

6.2 원칙 2 뜯어보기

“추상 모듈은 구체화된 모듈에 의존해서는 안 된다. 구체화된 모듈은 추상 모듈에 의존해야 한다.”

구체화된 모듈 간의 의존 관계를 끊고 추상 모듈을 통해서 의존해야 한다는 원칙을 살펴봅시다. 예를 들어 메일이 수신되면 알람을 울린다고 가정하겠습니다. 메일이라는 모듈과 알람이라는 모듈이 서로 관계 맺고 있는 코드를 살펴보겠습니다.

 

type Mail struct {
alarm Alarm
}

type Alarm struct {
}

func (m *Mail) OnRecv() { // OnRecv() 메서드는 메일 수신 시 호출됩니다.
m.alarm.Alarm()           // 알람을 울립니다.
}

 

메일 객체는 알람 객체를 소유하고 있고, 메일 수신 시 호출되는 OnRecv() 메서드에서 소유한 알람 객체를 사용해 알람을 울립니다. 클래스 다이어그램으로 다음과 같이 나타냅니다.

 

 

메일이라는 구체화된 모듈이 알람이라는 구체화된 모듈에 의존하고 있어 의존 관계 역전 원칙에 위배됩니다. 어떻게 의존 관계 역전 원칙에 입각해서 바꿀 수 있을까요? 메일이 알람에 직접 의존하지 않고 인터페이스를 통해 의존하도록 바꾸면 됩니다. 클래스 다이어그램을 그려서 살펴보겠습니다.

 

 

메일은 Event라는 인터페이스를 구현하고 알람은 EventListener라는 인터페이스를 구현하고 있습니다. 그리고 EventListener는 Event와 관계를 맺고 있습니다. 이렇게 변경하면 메일이라는 구체화된 객체는 알람이라는 구체화된 객체와 관계를 맺고 있지 않고, 추상화된 객체인 Event와 EventListener를 통해서 관계 맺고 있습니다. 어떤 구체화된 모듈도 구체화된 모듈에 의존적이지 않고, 추상화된 모듈 역시 구체화된 모듈에 의존적이지 않기 때문에 의존 관계 역전 원칙의 두 번째 원칙에 입각한 설계가 됩니다. 그럼 코드로 표현해보겠습니다.

 

type Event interface {
    Register(EventListener)
}

type EventListener interface {
    OnFire()
}

type Mail struct {
    listener EventListener
}

func (m *Mail) Register(listener EventListener) { // ① Event 인터페이스 구현
    m.listener = listener
}

func (m *Mail) OnRecv() {            // ② 등록된 listener의 OnFire() 호출
    m.listener.OnFire()
}

type Alarm struct {
}

func (a *Alarm) OnFire() {           // ③ EventListener 인터페이스 구현
    // 알람
    fmt.Println("알람! 알람!")
}

var mail = &Mail{}
var listener EventListener = &Alarm{}

mail.Register(listener)
mail.OnRecv()                  // ④ 알람이 울리게 됩니다.

 

Event 인터페이스는 Register() 메서드를 가지고 있고, ① Mail 객체는 이를 구현하여 Register() 메서드가 호출되면 EventListener를 등록합니다. 그래서 ② OnRecv() 메서드가 호출되면 등록된 EventListener 객체의 OnFire() 메서드를 호출해줍니다. Alarm 객체는 ③ EventListener 인터페이스를 구현하여 OnFire() 메서드가 호출될 때 알람이 울리도록 구현합니다. 그래서 ④ mail 인스턴스에 Alarm 인스턴스를 등록하면 메일 수신 시 알람이 울리게 됩니다.

위와 같이 의존 관계를 역전하면 메일 수신 시 알람이 아니라 다른 메일을 전송하거나 문자를 보내거나 화면에 팝업을 띄우거나 하는 다양한 EventListener를 만들어서 등록할 수 있습니다. 즉 개방-폐쇄 원칙 역시 지켜지게 됩니다. 또 메일 수신뿐 아니라 키보드 입력, 타이머 만료, 특정 온도가 될 때, 공기 중 이산화탄소 농도가 일정 수치 이상일 때 알람이 울리도록 손쉽게 코드를 추가할 수 있습니다. 이게 모두 의존 관계 역전 원칙에 입각해서 의존 관계를 끊었기 때문에 가능한 일입니다.

 

7. 마무리

지금까지 SOLID 5가지 원칙에 대해서 살펴보았습니다. 이 5가지 원칙들이 각각 따로 독립된 원칙들이 아니라 서로 서로가 연결되어 있다는 것을 알 수 있습니다. 즉 한 가지 원칙만 잘지켜도 나머지 원칙들이 저절로 지켜지는 격이지요. 5가지 원칙들은 모두 공통 목적을 가지고 있습니다. 바로 ‘결합도는 낮게, 응집도는 높게’입니다. 앞으로 많은 코드를 접하다 보면 나쁜 설계를 가진 코드들로 침울해질 때가 있을 겁니다. 그럴 때면 SOLID를 적용할 좋은 기회를 만났다고 생각을 바꿔보세요. 좋은 설계로 바꾸며 분명 더 좋은 프로그래머로 성장하게 될 겁니다.

 

핵심 요약

  1. SOLID는 객체지향 설계의 5가지 원칙입니다.
  2. 단일 책임 원칙은 ‘객체는 하나의 책임만 져야 한다’는 원칙입니다.
  3. 개방-폐쇄 원칙은 ‘확장에는 열려 있고 변경에는 닫혀 있어야 한다’는 원칙입니다.
  4. 리스코프 치환 원칙은 ‘상위 타입을 인수로 받는 함수는 하위 타입 인수에도 동작해야 한다’는 원칙입니다.
  5. 인터페이스 분리 원칙은 ‘불필요한 메서드에 의존적이지 않아야 한다’는 원칙입니다.
  6. 의존 관계 역전 원칙은 ‘구체화된 객체는 추상화된 객체와 의존 관계를 가져야 한다’는 원칙입니다.

 

 

 

 

 

WRITER

공봉식

13년 차 게임 서버 프로그래머로 다양한 장르의 온라인 게임을 개발했습니다. 넥슨과 네오위즈를 거쳐서 현재는 EA 캐나다에서 근무 중입니다. 「Tucker Programming」 유튜브 채널을 운영합니다.

Leave a Reply

©2020 GoldenRabbit. All rights reserved.
상호명 : 골든래빗 주식회사
(04051) 서울특별시 마포구 양화로 186, 5층 512호, 514호 (동교동, LC타워)
TEL : 0505-398-0505 / FAX : 0505-537-0505
대표이사 : 최현우
사업자등록번호 : 475-87-01581
통신판매업신고 : 2023-서울마포-2391호
master@goldenrabbit.co.kr
개인정보처리방침
배송/반품/환불/교환 안내