객체지향 프로그래밍의 5원칙

원태연·2022년 1월 3일
0

객체지향 5원칙

객체지향 프로그래밍을 위한 원칙들이 존재한다.

1. SRP(single responsibility principle)

  • 단일 책임 원칙
  • 하나의 클래스는 하나의 책임만을 가져야 한다.

  • 클래스가 변경되어야 할 이유는 오직 하나뿐이어야 한다.

    THERE SHOULD NEVER BE MORE THAN ONE REASON FOR A CLASS TO CHANGE

책임의 영역을 확실히 하여, 한 책임의 변경에 대해서 연쇄적인 변경이 요구되지 않고, 이는 곧 유지보수의 용이성과 코드의 가독성을 기대할 수 있게 된다. 책임에 대한 기준과 범위가 다를 수 있다. 하지만, 책임의 규모와 관계없이 변화에 의해 클래스를 변경해야한다면 이유는 오직 하나뿐이어야 한다.

  • 위반 예시
class Coffee {
  String bean;
  int volume;
	String glassShape;
}

//Coffee라는 클래스는 원두(bean)의 종류에 따라 결정된다고 가정
//Coffee는 용량이 volume인 glassShape모양에 담길 수 있다고 가정

Coffee 는 단일 책임 원칙을 위반한다.

Coffee 는 원두에 따라 구분되어야한다. 예를 들어 같은 브라질원두에 대해서 다른 용량의 잔에 담긴다고 다른 종류의 커피라고 보지 않는다는 것이다. 그러면 현재 Coffee 라는 클래스를 변경시키는 요소가 한개 이상이라는 점을 확인 할 수 있다.

-> 책임 분리

class Coffee {
  String bean;
  CoffeeGlass coffeeGlass;
}
class CoffeeGlass {
  int volume;
	String glassShape;
}

커피가 담기는 잔에 대한 객체를 생성해서 분리하였다. Coffee 의 종류는 담기는 잔의 모양이나 용량이 변경되어도 Coffee 는 변경되지 않는다. CoffeeGlass 를 분리함으로써 커피의 변경은 커피 내용물의 변경만을, 잔의 변경은 커피잔의 변경만을 책임 지고 있다.

  • 적용하기

SRP를 준수하기 위해선, 앞서 말한 개념을 중심으로 코드들을 리팩토링해야 할 것이다. 클래스가 어떤 상황에 대해 변경이 불가피한지 확인할 필요가 있고, 클래스들 간의 응집성을 줄이도록 설계해야 한다.

2. OCP (Open Close Principle)

  • 개방 폐쇄의 원칙

    • 확장에는 열려있고, 변경에는 닫혀 있어야 한다
    • 클래스를 수정하지 않고 확장할 수 있어야 한다.
      YOU SHOULD BE ABLE TO EXTEND A CLASSES BEHAVIOR, WITHOUT MODIFYING IT.

요구사항의 변경이나 추가사항이 발생하더라도, 기존 구성요소는 수정이 일어나지 않아야 한다. 기존 구성요소를 확장해서 재사용을 할 수 있도록 해야 한다. 추상화와 다형성이 OCP원칙을 지킬 수 있는 중요 메커니즘이며 이를 통해 유연성, 재사용성, 유지보수의 용이성을 기대할 수 있다.

말이 어렵지만 다음과 같은 방법을 준수해보며 설계하도록 하자

  1. 변경될 것과 변하지 않을 것을 구분
  2. 변경될 것과 변하지 않는 모듈이 만나는 지점에 인터페이스 정의
  3. 구현에 의존하지 말고, 위에서 정의한 인터페이스에 의존하기

예시로 살펴보자

class Coffee {
	String bean;
  Long menuId; //OCP예시를 위해 추가함
  CoffeeGlass coffeeGlass;
}
class CoffeeGlass {
  int volume;
	String glassShape;
}

위에서 SRP를 준수하며 작성한 예시를 보자. 카페에서 Coffee 를 판매하고 있다고 가정하고, menuId를 추가하였다. 그리고 카페에서 Juice 라는 메뉴를 판매하기로 했다고 해보자.

class Juice {
	String fruit;
  Long menuId; 
  JuiceGlass JuiceGlass;
}
class JuiceGlass {
  int volume;
	String glassShape;
}

Coffee 와 동일한 형식으로 Juice 를 찍어냈다. 원두 대신 과일에 대한 요소를 추가하였다. 만약, 음료 메뉴가 10개 이상 늘어난다(확장)면 이와 같은 방식은 비효율적임을 알 수 있다. 1개만 추가해도 비효율을 느낄법 한 찍어내기식 코드이다.

  • 아이디어

변경될 것과 변하지 않을 것 구분하기

카페에서 판매하는 메뉴는 계속 확장될 것이다. 따라서, 메뉴Id는 계속 확장되겠지만, 액체를 잔에 담아 판매한다는 점는 변하지 않을 것이라 가정하자.

변경될 것과 변하지 않는 모듈이 만나는 지점에 인터페이스 정의

이를 합쳐 CafeBeverage 라는 인터페이스를 생성하자.

구현에 의존하지 말고, 위에서 정의한 인터페이스에 의존하기

Coffee, Juice로 구현했던 메뉴들을 인터페이스에 의존하도록 하자

//현재 예시에는 메서드가 존재하지 않아 interface대신 class를 상속받는 것으로 의존도를 표현함
class CafeBeverage {
  Long menuId;
  BeverageGlass glass;
}

class BeverageGlass {
  int volume;
	String glassShape;
}

메뉴가 추가될 때 마다 위 클래스를 상속받아 구체화하여 확장에 대해 유연하게 대처할 수 있다.

  • Juice의 추가
class Juice extends CafeBeverage {
  String fruit;
}

class JuiceGlass extends BeverageGlass {}

의존 관계는 복잡해졌지만, 새로운 메뉴의 확장에 대해서는 기존 코드를 변경할 이유가 없어졌다.

인터페이스를 설계하는 과정에선 추상화의 정도를 잘 정해야한다. OCP를 준수하기 위해 모듈을 분리해 내면, 관계가 오히려 더 복잡해 질 수 있다.

'추상화란 다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징' -그래디 부치

3. LSP (The Liskov Substitution Principle)

  • 리스코브 치환의 원칙

    • 서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다.

    (서브/기반 타입 : ex, 자식/부모 )

    • 서브 타입은 기반 타입이 약속한 규약을 지켜야 한다.

조금 어렵게 들릴 수 있지만, 다형성과 상속의 이유를 지키라는 말이다.

예시를 통해 살펴보자

interface Person {
  void greeting();
  void eat();
}
class Korean implements Person {
  @Override
 	public void greeting() {
    System.out.println("안녕");
  }
  @Override
  public void eat() {
    System.out.println("떡볶이 먹자");
  }
}
class American implements Person {
  @Override
 	public void greeting() {
    System.out.println("Hello");
  }
  @Override
  public void eat() {
    System.out.println("Have Hamburger");
  }
}

Person이라는 추상클래스(인터페이스)가 존재하고 Korean, American이라는 클래스가 구현하고 있다.

public class Playgorund {
  public void play() {
    Person guest = new Korean();
    guest.greeting();
    guest.eat();
    
    guest = new American();
    guest.greeting();
    guest.eat();
  }
}

실행 결과

안녕떡볶이 먹자HelloHave Hamburger

위 코드를 LSP원칙으로 바라보자.

우선 Person을 구현한 KoreanAmerican모두 Person이 약속한 규약을 모두 지키고 있다. 또, Person타입을 통해 Korean과 American 타입으로 치환도 가능하며 의도했던 기능도 정상 작동한다. 사실 이 규칙은 당연하기에 설명보단 다형성과 상속의 이유를 이해하는 것이 LSP원칙을 이해하기에 적합한것 같다.

  • 위반사례
class Robot implements Person {  @Override 	public void greeting() {    System.out.println("Hello World");  }  @Override  public void eat() {  }}

Person 을 구현한 Robot 은 뭐 네이밍에서도 이상하단 생각이 들겠지만, 규약중 하나인 eat()메서드를 사용하지도 않고있다.

리스코프 치환 원칙은 상속과 다형성이라는 특성을 올바르게 활용하면 자연스럽게 얻게 되는 것이다.

4. ISP (Interface Segregation Principle)

  • 인터페이스 분리의 원칙
  • 클래스는 사용하지 않는 인터페이스를 구현하지 말아야 한다.
  • 하나의 일반적인 인터페이스보다는, 여러 개의 구체적인 인터페이스가 낫다.

SRP for class, ISP for Interface

인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리하여야 한다. 이러한 분리를 통해 명확한 인터페이스를 획득할 수 있고, 대체 가능성이 높아진다.

사용예제

ISP 위반 사례

public interface View {  void printView();  String getUserInput();}public class TV implements View {  @Override  public void printView() {    System.out.println("Hello, World");  }    @Override  public String getUserInput() {}}

View 인터페이스에 출력과 입력 기능이 동시에 들어가 있다. TV 객체는 화면만 출력하는 기능을 담당한다면, getUserInput() 메서드는 사용하지 않지만, View 를 구현하였기 때문에 사용하지 않는 메서드도 구현해야한다. 사용하지도 않는 메서드getUserInput()를 포함한 인터페이스View가 수정된다면 이를 구현한 클래스TV도 아무런 변경 사항 없는 수정이 요구된다. 이는 인터페이스의 분리가 더 필요하다는 증거다.

public interface Printable {  void printView();}
public interface Inputable {  String getUserInput();}

위처럼 인터페이스를 분리 한 뒤,

public class TV implements Printable {  @Override  public void printView() {    System.out.println("Hello, World");  }}//화면 출력만 하는 TV는 Printable만,
public class Kiosk implements Printable, Inputable {  @Override  public void printView() {    System.out.println("Hello, World");  }    @Override  public String getUserInput() {    Scanner inputs = new Scanner(System.in);    return inputs.nextLine();  }}//입력과 화면 출력을 둘다하는 Kiosk는 두 인터페이스를 구현하면 된다.

위 코드처럼 인터페이스를 분리하여 필요에 따라 1개 혹은 그 이상을 구현하는 것이 권장된다.

DIP (Dependency Inversion Principle)

  • 의존성역전의 원칙
  • 상위 모듈은 하위 모듈에 종속되지 않고 추상화에 의존해야 한다.
  • 추상화는 세부사항에 의존하지 않는다.

수직적인 모듈(상,하)의 등장은 계층 구조의 아키텍처에서 발생한다.

보통의 아키텍처 구조에서는

표현계층 -> 응용계층 -> 도메인 계층 -> 인프라 계층

ex) View -> Controller(Service) -> Model -> DB

우선 예시로 살펴보자.

//Modelpublic class Dog {  public String bark() {    return "멍멍";  }}
//Servicepublic class AnimalService {  private Dog dog;  public String makeSound() {    return dog.talk();  }}

위 예시 구조를 보면, Service(AnimalService)가 Model(Dog)에 의존하고 있다. 이러한 하층 모듈로의 의존은 다음과 같은 부분에서 문제가 발생한다.

  • 테스트

    AnimalService가 담당하는 bark()를 테스트 하기 위해선 Dog에 의존하는 모든 세부사항이 정상적으로 동작한다는 큰 비용이 필요하다.

  • 확장

    확장에 열려있지 않다.

    예를 들어 Cat이라는 동물도 추가됐다고 하면,

    //Modelpublic class Cat { public String meow() {   return "냐옹"; }}
    //Servicepublic class AnimalService { private Dog dog; private Cat cat; public String makeSound(String animal) {   if (animal.equals("Cat")) {     return cat.meow();   } else if (animal.equals("Dog")) {     return dog.bark();   } }}

    위와 같은 수정이 서비스계층에서도 필요하다.

이제 DIP를 준수하여 상위 모듈이 하위 모듈이 아닌 추상화에 의존하도록 하자.

추상 클래스(interface)를 만들고,

public interface Aniaml {  public String makeSound();}
//Modelpublic class Dog implements Animal {  @Override  public String makeSound() {    return "멍멍";  }}
//Modelpublic class Cat implements Animal {  @Override  public String makeSound() {    return "냐옹";  }}

Service가 이를 의존하도록 한다.

//Servicepublic class AnimalService {  private Animal animal;   	public AnimalService(Animal animal) {    this.animal = animal;  }    public String makeSound() {    return animal.makeSound();  }}

AnimalService Animal <- Cat,Dog

이를 통해 확장에 대해서도 유연하게 대처할 수 있다.

profile
앞으로 넘어지기

0개의 댓글