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 원칙이다.