Java - OOP and SOLID

  • Java의 객체 지향의 특징과, SOLID에 대하여 공부한 내용이다.

  • 우선 자바는 객체지향 언어임을 알아야 한다.


절차지향 프로그래밍과 객체지향형 프로그래밍

1. 절자치향 언어 ( POP )

  • 장점 :
    • 초기 프로그래밍 언어로, 컴퓨터 처리구조와 비슷해 실행속도가 빠르다.
  • 단점 :
    • 유지보수가 어렵다
    • 하나가 고장나면 시스템 전체가 고장이난다.
    • 디버깅이 힘들다
    • 순서가 엄격히 정해져있어 비효율적이고 생산성이 하락한다.
    • 과도한 전역변수의 사용으로 값을 잘못 설정하게 될 확률이 높다.

2. 객체지향 언어 ( OOP )

  • 장점 :
    • 코드 재활용성이 높다
    • 생산성이 높다
    • 모델링이 쉽다 ( 실제모습의 구조가 객체에 녹아들어 있기 때문에 있는 그대로를 구현하기 쉬움 )
    • 유지보수가 좋다
  • 단점 :
    • 절차지향 언어를 사용하는 것보다 코드 난이도가 높음
    • 절차지향 언어에 비해 실행 속도가 느림.



객체지향 프로그래밍이란?

  • 객체 지향 프로그래밍 (Object-Oriented Programming, OOP)은 프로그래밍에서 필요한 데이터를 추상화 시켜 상태와 행위를 가진 객체로 만들고,

  • 객체들간의 상호작용을 통해 로직을 구성하는 프로그래밍 방법이다.




OOP의 특징

1. 캡슐화

  • 실제로 구현한 부분을 외부에 드러나지 않도록 정보를 은닉하는 특징
  • 변수와 메서드를 하나로 묶음
  • 데이터를 외부에서 직접 접근하지 않고, 함수를 통해서만 접근 ( 변수는 private로 묶고, getter와 setter로 접근 )
  • 단순히 getter, setter 만 있다고 캡슐화가 아니라, 해당 메서드 안에 validation 이 있는 등 클래스 내에서 제공한 기능으로만, 변수에 접근 할 수 있게 하는 것도 캡슐화의 특징임.
  • 남에게 완전히 위임하는게 아닌, 한정된 행동만 할 수 있게끔 주는 느낌이다.


2. 추상화

  • 핵심적인 개념과 기능만 추려내어 클래스를 만들고, 자식 클래스들이 상속을 받아 구현하도록 하는 특징
  • 공통된 변수나 공통된 기능을 묶을 수 있어 중복된 코드를 만들지 않아도 됨.
  • 자바에 추상클래스와 인터페이스가 존재하는 이유.
  • 보통 우리는 추상화라는 개념에 대해 ‘구체적이지 않은’ 정도의 의미로 느슨하게 알고만 있다. 하지만 ‘그래디 부치(Grady Booch)’에 의하면 ‘추상화란 다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징’이라고 정의한다.
  • 즉, 추상 메서드 설계에서 적당한 추상화 레벨을 선택함으로써, 어떠한 행위에 대한 본질적인 정의를 서브 클래스에 전파함으로써 관계를 성립되게 하는 것이다.
  • 현실 세계의 복잡한 시스템이나 개념을 단순화하여 모델링하는 프로세스를 나타낸다고 보면 된다.
  • 추상화를 통해 우리는 세부적인 구현을 숨기고 중요한 부분에 집중할 수 있다.


3. 상속

  • 부모 클래스에 정의된 변수 및 메서드를 자식 클래스에서 extends로 상속받아 사용하는 것.
  • 코드의 재사용으로 시간과 비용을 줄일 수 있음.
    • ex) Animal 이라는 클래스를 Lion이라는 클래스가 extends로 받음.


4. 다형성

  • 부모타입의 참조변수로 자식타입의 객체를 제어한다.
  • 어느 특정한 요소가 다양한 형태에 속할 수 있는 성질을 말한다
  • 하나의 객체가 여러 가지 타입을 가질 수 있는 것을 의미하기도 한다.
  • 자바에서는 이러한 다형성을 부모 클래스 타입의 참조 변수로 자식 클래스 타입의 인스턴스를 참조할 수 있도록 하여 구현하고 있다.
  • 우리가 같은 Discount라는 인터페이스에 RateDiscount라는 구체 클래스로 생성하느냐, FixDiscount라는 구체 클래스로 생성하느냐에 따라 같은 이름의 메서드더라도 다른 기능을 함.
  • 오버로딩, 오버라이딩이 해당 됨.




객체지향의 5원칙 SOLID

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

  • 하나의 클래스는 하나의 책임만 가져야 한다.
  • 하나의 메서드는 하나의 기능만 가져야 한다.
  • 그래야 재사용성이 좋다.
  • 비슷한 기능을 구현 할 때, A 라는 메서드를 사용해야 하는데, A 메서드가 2가지 이상의 기능을 갖고 있다면 내가 원하는 기능 말고 다른 기능도 있어서 재사용을 못할 수도 있다. 그래서 단일 책임을 잘 지켜줘야 재사용성이 높아진다.
      public class Animal {
          private String animal;
          public void bark() {
              if ( animal.equals(Dog) ) {
                  System.out.println(Bark !);
              } else if ( animal.equals(Cat) ) {
                  System.out.println(Meow ...);
              }
          }
      }
    
  • 해당 코드는 Dog와 Cat 두 가지 책임을 가지고 있기 때문에 SRP 원칙에 위반한다.
  • 수정을 하면
      abstract class Animal {
          abstract void bark();
      }
      class Dog extends Animal {
          @Override
          void bark() {
              System.out.println(Bark !);
          }
      }
      class Cat extends Animal {
          @Override
          void bark() {
              System.out.println(Meow ...);
          }
      }
    
  • 이렇게 하나의 클래스는 하나의 책임만 갖게끔 설계하는 것이 SRP 특징을 잘 살려 설계하는 것이다.


2. 개방 폐쇄의 원칙 ( OCP : Open/Closed Principle )

  • 확장에는 열려(Open) 있으나, 변경에는 닫혀(Closed)있어야 한다.
  • 즉, 기존 코드를 변경하지 않으면서 기능을 추가할 수 있게 해줘야 한다.
  • 한마디로 추상화 사용을 통한 관계 구축을 권장하는 것을 의미한다.
  • 결합도가 감소하고, 확장성이 용이해진다.
      class Animal {
          String type;
    		
          Animal(String type) {
              this.type = type;
          }
      }
    
      // 동물 타입을 받아 각 동물에 맞춰 울음소리를 내게 하는 클래스 모듈
      class HelloAnimal {
          void hello(Animal animal) {
              if(animal.type.equals("Cat")) {
                  System.out.println("냐옹");
              } else if(animal.type.equals("Dog")) {
                  System.out.println("멍멍");
              }
          }
      }
    
      public class Main {
          public static void main(String[] args) {
              HelloAnimal hello = new HelloAnimal();
    			
              Animal cat = new Animal("Cat");
              Animal dog = new Animal("Dog");
    
              hello.hello(cat); // 냐옹
              hello.hello(dog); // 멍멍
          }
      }
    
  • 해당 코드는 OCP 원칙을 위반한다. 왜냐하면, 여기서 새로운 동물의 울음소리를 추가하려면…
      class HelloAnimal {
          // 기능을 확장하기 위해서는 클래스 내부 구성을 일일히 수정해야 하는 번거로움이 생긴다.
          void hello(Animal animal) {
              if (animal.type.equals("Cat")) {
                  System.out.println("냐옹");
              } else if (animal.type.equals("Dog")) {
                  System.out.println("멍멍");
              } else if (animal.type.equals("Sheep")) {
                  System.out.println("메에에");
              } else if (animal.type.equals("Lion")) {
                  System.out.println("어흥");
              }
              // ...
          }
      }
    
  • 이렇게 클래스를 수정해야 할 수 밖에 없게 설계하였기 때문이다. 이러면 OCP를 위반하는 것이다.
  • 하지만 아래와 같이 코드를 작성하면 OCP를 위반하지 않는다.
      // 추상화
      abstract class Animal {
          abstract void speak();
      }
    
      class Cat extends Animal { // 상속
          void speak() {
              System.out.println("냐옹");
          }
      }
    
      class Dog extends Animal { // 상속
          void speak() {
              System.out.println("멍멍");
          }
      }
    
      class HelloAnimal {
          void hello(Animal animal) {
              animal.speak();
          }
      }
    
      public class Main {
          public static void main(String[] args) {
              HelloAnimal hello = new HelloAnimal();
    
              Animal cat = new Cat();
              Animal dog = new Dog();
    
              hello.hello(cat); // 냐옹
              hello.hello(dog); // 멍멍
          }
      }
    
  • 이러면 동물 울음소리를 추가하고 싶을 때, 아래와 같은 클래스만 새로 생성하면 될 뿐 클래스를 수정할 필요가 없다.
      class Lion extends Animal {
          void speak() {
              System.out.println("어흥");
          }
      }
    


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

  • 자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행이 보장되어야 한다는 의미이다.
  • 따라서 기본적으로 LSP 원칙은 부모 메서드의 오버라이딩을 조심스럽게 따져가며 해야한다.
  • 왜냐하면 부모 클래스와 동일한 수준의 선행 조건을 기대하고 사용하는 프로그램 코드에서 예상치 못한 문제를 일으킬 수 있기 때문이다.
  • 아래는 예시코드이다.
      public class Rectangle {
          protected double width;
          protected double height;
    
          public double getWidth() {
              return width;
          }
    
          public void setWidth(final double width) {
              this.width = width;
          }
    
          public double getHeight() {
              return height;
          }
    
          public void setHeight(final double height) {
              this.height = height;
          }
    
          public double getArea() {
              return width * height;
          }
      }
    	
      public class Square extends Rectangle {
          @Override
          public void setWidth(final double width) {
              this.height = width;
              this.width = width;
          }
    
          @Override
          public void setHeight(final double height) {
              this.height = height;
              this.width = height;
          }
      }
    
  • 이렇게 정사각형이 직사각형 클래스를 상속 받고, 메서드를 저렇게 오버라이딩을 하면
      class SquareTest {
          @Test
          void 직사각형이_제대로_넓이를_구하는지_테스트() {
              /* Given */
              Rectangle rectangle = new Rectangle();
              /* When */
              rectangle.setHeight(4);
              rectangle.setWidth(5);
              /* Then */
              assertThat(rectangle.getArea()).isEqualTo(20);
          }
    
          @Test
          void 정사각형이_제대로_넓이를_구하는지_테스트() {
              /* Given */
              Rectangle rectangle = new Square();
              /* When */
              rectangle.setHeight(4);
              rectangle.setWidth(5);
              /* Then */
              assertThat(rectangle.getArea()).isEqualTo(20);
          }
      }
    
  • 이렇게 테스트를 해주었을 때 올바른 값이 출력되지 않는다.
  • 즉, 메서드를 재정의 할 때는 자식 클래스가 부모 클래스의 역할을 최소한 할 수 있는지를 확인해가며 확장을 해주어야 한다.


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

  • ISP 원칙은 인터페이스를 각각 사용에 맞게 끔 잘게 분리해야한다는 설계 원칙이다.
  • SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조하는 것으로 보면 된다.
  • 만약 인터페이스의 추상 메서드들을 범용적으로 이것저것 구현한다면, 그 인터페이스를 상속받은 클래스는 자신이 사용하지 않는 인터페이스마저 억지로 구현 해야 하는 상황이 올 수도 있다.
  • 인터페이스는 제약 없이 자유롭게 다중 상속(구현)이 가능하기 때문에, 분리할 수 있으면 분리하여 각 클래스 용도에 맞게 implements 하라는 설계 원칙이라고 이해하면 된다
      interface ISmartPhone {
          void call(String number); // 통화 기능
          void message(String number, String text); // 문제 메세지 전송 기능
      }
    
      interface WirelessChargable {
          void wirelessCharge(); // 무선 충전 기능
      }
    
      interface ARable {
          void AR(); // 증강 현실(AR) 기능
      }
    
      interface Biometricsable {
          void biometrics(); // 생체 인식 기능	
      }
    	
    	
      class S21 implements ISmartPhone, WirelessChargable, ARable, Biometricsable {
          public void call(String number) {
          }
    
          public void message(String number, String text) {
          }
    
          public void wirelessCharge() {
          }
    
          public void AR() {
          }
    
          public void biometrics() {
          }
      }
    
      class S3 implements ISmartPhone {
          public void call(String number) {
          }
    
          public void message(String number, String text) {
          }
      }
    	
    
  • 이렇게 구현하는 방식이 ISP를 올바르게 지킨 방식이다. 만약 저기에서 S3에 불필요한 메서드가 들어가서 억지로 구현하게끔 implements를 하였다면 그건 ISP를 위반한 것이 된다.


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

  • 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙이다.
  • 다시 말하면 클라이언트(사용자)가 상속 관계로 이루어진 모듈을 가져다 사용할때, 하위 모듈을 직접 인스턴스를 가져다 쓰지 말라는 뜻이다. 왜냐하면 그렇게 할 경우, 하위 모듈의 구체적인 내용에 클라이언트가 의존하게 되어 하위 모듈에 변화가 있을 때마다 클라이언트나 상위 모듈의 코드를 자주 수정해야 되기 때문이다.
  • 따라서 한마디로 상위의 인터페이스 타입의 객체로 통신하라는 원칙이다.
  • 우리가 Spring에서 개발을 할 때도 중요한 부분이다. ( 인터페이스를 부르고, 구현 클래스는 외부에서 의존성 주입을 한다. )
  • 아래 코드는 DIP 원칙을 준수하지 않아서 생기는 문제이다.
      class OneHandSword {
          final String NAME;
          final int DAMAGE;
    
          OneHandSword(String name, int damage) {
              NAME = name;
              DAMAGE = damage;
          }
    
          int attack() {
              return DAMAGE;
          }
      }
    
      class TwoHandSword {
          // ...
      }
    
      class BatteAxe {
          // ...
      }
    
      class WarHammer {
          // ...
      }
    
      class Character {
          final String NAME;
          int health;
          OneHandSword weapon; // 의존 저수준 객체
    
          Character(String name, int health, OneHandSword weapon) {
              this.NAME = name;
              this.health = health;
              this.weapon = weapon;
          }
    
          int attack() {
              return weapon.attack(); // 의존 객체에서 메서드를 실행
          }
    
          void chageWeapon(OneHandSword weapon) {
              this.weapon = weapon;
          }
    
          void getInfo() {
              System.out.println("이름: " + NAME);
              System.out.println("체력: " + health);
              System.out.println("무기: " + weapon);
          }
      }
    
  • 이렇게 OneHandSword weapon; 으로 의존해버리면, 다른 무기를 쓰고 싶을 때 수정하는 것이 어려워진다.
  • 그래서 가장 상위 인터페이스를 하나 추가하고, 아래 코드처럼 만들면 된다.
      interface Weaponable {
          int attack();
      }
    
      class OneHandSword implements Weaponable {
          final String NAME;
          final int DAMAGE;
    
          OneHandSword(String name, int damage) {
              NAME = name;
              DAMAGE = damage;
          }
    
          public int attack() {
              return DAMAGE;
          }
      }
    
      class TwoHandSword implements Weaponable {
          // ...
      }
    
    
      class BatteAxe implements Weaponable {
          // ...
      }
    
      class WarHammer implements Weaponable {
          // ...
      }
    
      class Character {
          final String NAME;
          int health;
          Weaponable weapon; // 의존을 고수준의 모듈로
    
          Character(String name, int health, Weaponable weapon) {
              this.NAME = name;
              this.health = health;
              this.weapon = weapon;
          }
    
          int attack() {
              return weapon.attack();
          }
    
          void chageWeapon(Weaponable weapon) {
              this.weapon = weapon;
          }
    
          void getInfo() {
              System.out.println("이름: " + NAME);
              System.out.println("체력: " + health);
              System.out.println("무기: " + weapon);
          }
      }
    
  • 최상위 인터페이스인 Weaponable을 받아오는 것이다.
  • 이러면 무기 변경도 수월하고, 무기 확장도 수월해서 OCP 원칙도 지키게 된 셈이다. 이것이 DIP 원칙이다.






results matching ""

    No results matching ""