Design Pattern and OOP — 디자인 패턴 개요, 객체지향개발 특성, 객체지향설계 원칙

소프트웨어 디자인 과정에서 자주 발생하는 문제들에 대해 이미 검증된 해결책이 존재한다. 매번 처음부터 설계를 고민하는 대신, 선배 개발자들의 경험이 축적된 패턴을 활용하면 설계 속도와 품질을 동시에 높일 수 있다.

디자인 패턴이란?

디자인 패턴(Design Pattern)이란 소프트웨어 디자인 과정에서 자주 발생하는 문제들에 대한 전형적인 해결책이다. 여러 가지 설계 사례를 분석해 비슷한 문제를 해결하기 위한 설계로 분류하고 유형별로 가장 적합한 설계를 일반화해 패턴으로 정립한 것이다.

대부분의 소프트웨어 문제는 이미 예전에 누군가는 겪은 문제이고, 이를 해결하기 위한 여러 방법들 중에서 자주 사용되었던 패턴들을 모은 것이 디자인 패턴이다. 즉, 문제 해결방법의 best practice(청사진)이다.

알고리즘과의 차이점

알고리즘은 요리법에 비유할 수 있지만, 패턴은 요리법이 아닌 청사진에 더 가깝다. 알고리즘과 요리법 둘 다 목표를 달성하기 위한 명확한 단계들이 제시되어 있다. 반면에 청사진은 결과와 기능들은 제시하나 구현 단계 및 순서는 사용자가 결정한다.

디자인 패턴의 필요성

소프트웨어 개발에 있어서 경험의 중요성은 매우 크다. 프로젝트를 시작할 때 처음 하는 일은 과거에 수행한 유사 프로젝트를 찾아 검토하는 일이다. 기존 개발 경험을 기반으로 개발 기간, 비용, 필요 인력, 개발 조직 등을 참고하고 개발 방법도 가져다 적용할 수 있다.

하지만 항상 비슷한 소프트웨어를 설계 및 개발할 가능성은 매우 적다. 또한, 경험이 적은 사람도 설계를 잘 하려면 경험 많은 전문가의 지식과 노하우를 공유할 수 있는 방법이 필요하다.

GoF(Gang of Four)의 디자인 패턴은 객체지향 개념에 따른 설계 중에서 재사용할 경우에 유용한 것을 정립한 것이다. 너무 일반적이지도 너무 구체적이지도 않은 형태로 소프트웨어 설계를 위한 지식이나 노하우를 공유할 수 있는 방법이다.

디자인 패턴을 배워야 하는 이유

  • 설계 과정의 속도를 높일 수 있다: 이미 검증되고 테스트된 구조이기 때문이다
  • Reusable, flexible: 재사용성을 높이고 변경을 쉽게 하도록 하는 구조이다. 패턴을 배우게 되면 객체 지향 디자인의 원칙들을 사용해 많은 종류의 문제를 해결하는 방법들을 배울 수 있다
  • 의사 소통의 효율성: 복잡한 구조를 합의된 용어로 쉽게 설명 가능하다. 사전 지식으로 커뮤니케이션에 드는 시간, 비용이 절약된다(shared terms)

디자인 패턴에 대한 비판

디자인 패턴 또한 하나의 공학적 도구이며, 이러한 패턴이 왜 필요한지에 대한 고찰 및 적절한 사용이 중요하다.

  1. 좋은 프로그램 언어에도 필요할까: 일반적으로 패턴의 필요성은 개발자가 추상화 수준이 부족한 프로그래밍 언어나 기술을 선택했을 때 발생한다
  2. 비효율적인 해결책: 많은 사람이 패턴들을 절대적으로 신봉하여 패턴을 프로젝트의 맥락에 따라 적용하지 않고 ‘문자 그대로’ 구현한다
  3. 부당한 사용: 많은 초보자는 패턴을 갓 배운 후, 더 간단한 코드로도 문제 해결이 되는 상황에도 모든 곳에 패턴을 적용하려고 한다

디자인 패턴을 위한 필수 지식들

디자인 패턴을 이해하려면 두 가지 기초 지식이 필요하다:

  • 객체지향개발 특성: 추상화(Abstraction), 캡슐화(Encapsulation), 상속성(Inheritance), 다형성(Polymorphism)
  • 객체지향설계 원칙 (SOLID 원칙): 단일 책임 원칙(SRP), 개방-폐쇄 원칙(OCP), 리스코프 치환 원칙(LSP), 인터페이스 분리 원칙(ISP), 의존 역전 원칙(DIP)

객체지향개발 특성

추상화 (Abstraction)

클래스들의 공통적인 특성(변수, 메소드)들을 묶어 표현하는 것이다.

예를 들어 핸드폰은 마이크, 스피커, 디스플레이라는 공통 속성을 가지고 있고, 통화 기능과 디스플레이를 통한 유저 인터페이스 제공이라는 공통 행위를 한다. 이러한 공통된 속성이나 기능을 묶어 클래스로 표현 가능하며 이를 “추상화”라 한다.

// CellPhone.h
class CellPhone {
 public:
  void Call();            // 통화기능 제공
  void Display();         // 디스플레이 제공
  void SetMemory(int value) { memory_ = value; }
  void GetMemory() { return memory_; }
 private:
  bool mic_status_;       // 마이크 상태
  bool speaker_status_;   // 스피커 상태
  bool display_status_;   // 디스플레이 상태
  int memory_;
};

캡슐화 (Encapsulation)

데이터와 코드의 형태를 외부로부터 알 수 없게 하고, 데이터의 구조와 역할, 기능을 하나의 캡슐 형태로 만드는 방법이다.

  • private(혹은 protected)라는 캡슐에 보호하고 싶은 인자를 보호하고 외부에서 캡슐을 사용할 때는 프로그래머가 짜 놓은 방식으로만 사용하게 만든 것이다
  • 외부에 노출될 필요가 없는 함수는 private 안에 캡슐화한다
  • 멤버 변수들 또한 보통 private 안에 캡슐화하고, 필요한 경우 public에 getter/setter를 통해 접근을 허용한다

상속성 (Inheritance)

부모 클래스에 정의된 변수 및 메서드를 자식 클래스에서 상속받아 사용하는 것이다. 클래스를 정의할 때 어떤 클래스를 확장하여 파생하는 것을 의미하며, 객체지향개발 특성의 핵심이라 할 수 있는 다형성 실현에 필수적인 요소이다.

상속성 다이어그램

파생클래스가 기본클래스 속성을 포함하면서 속성을 확장하는 구조이다. 접근 지정자의 확장이 필요하다:

  • public: 멤버(변수, 함수)가 외부에 공개되어 어디서든 접근 가능
  • private: 멤버를 비공개로 지정하여 외부로부터 접근을 막는다
  • protected: 멤버를 비공개로 지정하지만 상속 관계에 있는 파생클래스도 접근할 수 있다. 캡슐화에 유연성을 제공해 주어 상속성의 가치를 높인다

상속성의 주의점:

  • 상속관계의 클래스를 정의할 때는 각 클래스(기본, 파생)의 기능을 명확히 분류해야 한다. 여러 기능들이 섞이다 보면 어느새 상속의 의미는 퇴색된다
  • 기본클래스의 기능을 사용하고자 상속을 무분별하게 하면 난해하고 비효율적인 코드가 된다
  • 다중상속에 주의해야 한다. 상속하고자 하는 대상이 애매해지는 다이아몬드 문제(Diamond Problem)가 발생할 수 있다

다형성 (Polymorphism)

다양한 형태로 표현이 가능한 구조이다. 파생클래스 객체가 기본클래스가 가지고 있는 멤버함수를 커스터마이징을 하고 싶은 경우에 해당한다.

  • 오버로딩(Overloading): 매개변수의 유형 또는 개수는 다르지만, 같은 이름의 메서드를 중복하여 정의하는 것. 매개변수는 같고 리턴 타입만 다르면 오버로딩이 성립되지 않는다
  • 오버라이딩(Overriding): 상위(부모) 클래스가 가지고 있는 메서드를 하위 클래스가 재정의해서 사용. 메서드 명, 매개변수, 리턴 타입이 모두 같아야 한다

정적 바인딩 vs 동적 바인딩

구분 정적 바인딩 동적 바인딩
시점 실행 이전(컴파일 타임)에 값이 확정 실행 이후(런타임)에 값이 확정
효율 컴파일 시 이미 값이 확정되어 효율이 높다 어떤 값이 들어올지 몰라 메모리 공간을 더 차지할 가능성
유연성 값이 변하지 않아서 안정적이나 유연하지 않다 유연하고 값이 변할 수 있다
키워드 - virtual 키워드를 통해 동적 바인딩하는 메서드를 가상 메서드라고 한다

동적 바인딩은 다형성의 핵심이다. virtual 키워드를 사용하면 런타임에 호출될 메서드를 결정하므로, 같은 인터페이스로 다른 동작을 수행할 수 있다.

객체지향설계 원칙 (SOLID)

SOLID 원칙

SOLID는 객체지향 설계의 5가지 핵심 원칙을 나타내는 약어이다.

S — 단일 책임 원칙 (Single Responsibility Principle)

클래스를 변경해야 하는 이유는 단 하나여야 한다.

“책임”의 의미는 “하나의 역할 담당”이다. 하나의 클래스는 하나의 역할을 담당하여 하나의 책임을 수행하는데 집중되어야 있어야 한다는 의미이다. 클래스의 설계가 변경되어야 할 경우는 한 클래스가 둘 이상의 역할을 책임질 경우이며, 이때 역할별로 클래스를 각각 설계해주는 것이 바람직하다.

// 적용 전 — User가 여러 책임을 가지고 있다
class User {
 public:
  void Login() { cout << "User logged in" << endl; }
  void Logout() { cout << "User logged out" << endl; }
  void PrintUserInfo(string username) {
    cout << "Username: " << username << endl;
  }
};

// 적용 후 — 책임별로 분리
class Authentication {
 public:
  void Login() { cout << "User logged in" << endl; }
  void Logout() { cout << "User logged out" << endl; }
};

class UserInfo {
 public:
  void PrintUserInfo(string username) {
    cout << "Username: " << username << endl;
  }
};

class User: public Authentication, public UserInfo {};

O — 개방-폐쇄 원칙 (Open Closed Principle)

확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.

어떤 것은 개방하고 어떤 것은 폐쇄하는 것을 말한다. 예를 들어 VideoPlayer 클래스에 처음에는 MP4만 지원하던 것이 MP21, AVI, WMV 등 지원가능한 파일 형식이 늘어날 때마다 클래스를 수정해야 한다면 개방 폐쇄 원칙에 위반된다.

해결 방법은 기본 클래스(Account)에 virtual 메서드를 정의하고, 각 계좌 유형(SavingsAccount, CreditCardAccount, FixedDepositAccount)을 파생 클래스로 만드는 것이다. 새로운 계좌 유형이 추가되더라도 기존 클래스를 수정할 필요 없이 새 파생 클래스만 추가하면 된다.

L — 리스코프 치환 원칙 (Liskov Substitution Principle)

상위 클래스의 객체는 언제나 자신의 하위 클래스의 객체로 대체될 수 있어야 한다.

상위 클래스의 객체가 들어갈 자리에 하위 클래스의 객체를 넣어도 문제없이 잘 작동해야 한다. 리스코프 교체 원칙은 상속과 재정의의 중요성을 강조한다.

위반 사례: Bird 클래스에 Fly() 메서드가 있을 때, Crow는 정상 작동하지만 Penguin은 날 수 없으므로 런타임 오류가 발생한다.

해결: Bird 기본 클래스에 CanFly()를 추가하고, FlyingBirdBird를 분리하여 PenguinBird만, CrowFlyingBird를 상속하도록 설계한다.

I — 인터페이스 분리 원칙 (Interface Segregation Principle)

클라이언트는 자신이 사용하지 않는 메서드와 의존 관계를 맺으면 안 된다.

다수의 클라이언트가 일반적인 인터페이스 하나를 같이 사용하는 것보다 각각의 클라이언트가 필요한 대로 여러 개의 구체적인 인터페이스로 분리해 사용하는 것이 더 낫다.

위반 사례: Printer 인터페이스에 Print(), Scan(), Fax()가 모두 있으면, LaserPrinterFax() 메서드가 필요하지 않지만 인터페이스를 준수해야 하기 때문에 이를 구현해야 한다.

해결: IsPrintable, IsScannable, IsFaxable로 인터페이스를 분리한다. LaserPrinterIsPrintableIsScannable만, InkJetPrinter는 세 개 모두를 상속한다.

D — 의존 역전 원칙 (Dependency Inversion Principle)

클라이언트는 구체 클래스가 아닌 추상 클래스(인터페이스)에 의존해야 한다.

객체지향 설계에서 서로의 관계가 의존적이라는 것은 결합도가 높다는 것이고 이는 클래스 설계에서 좋은 관계가 아니다. 구체 클래스에 의존하도록 클래스 설계를 하지 않고 자주 바뀌는(추가/삭제) 클래스를 상속 구조로 만들어 추상 클래스에 의존하도록 설계한다.

위반 사례: 고수준 모듈 ColorSearcher가 저수준 모듈 FruitBasket 객체에 대한 세부 정보를 알기 때문에 낮은 수준 모듈에 강하게 의존한다.

해결: 추상 모듈 BasketSearcher를 만들고, FruitBasket이 이를 상속하도록 한다. ColorSearcherBasketSearcher& 참조를 통해 추상 클래스에만 의존한다. 저수준 모듈을 수정해도 고수준 모듈에 영향을 미치지 않는다.