데브코스에 참여한지 한 주가 지나가고, 월요일이 돌아왔습니다.. Day 4에 대한 TIL을 작성해볼게요.
📌 TIL
오늘 주제의 큰 틀은 SOLID 로 객체지향 설계의 5대 원리에 대해 살펴보려고 합니다. 먼저 SOLID에 대해 살펴보기 전에, 객체지향의 4대 특성에 대해 한번 짚고 넘어가도록 하겠습니다.
1) 객체지향 4대 특성
캡슐화(Encapsulation) , 상속(Inheritance) , 추상화(Abstraction) , 다형성(Polymorphism)
캡슐화
: 데이터와 코드의 형태를 외부로부터 알 수 없게 하고, 데이터의 구조와 역할, 기능을 하나의 캡슐 형태로 만드는 방법
상속
: 부모 클래스에 정의된 변수 및 메서드를 자식 클래스에서 상속 받아 사용하는 것 , 메서드를 재사용하기 위한 상속은 권장하지 않으며 필드에 대한 재사용을 위한 상속을 권장합니다. 또한 접근 제어자에 따라 상속되는 필드나 메서드가 각각 존재합니다.
추상화
: 클래스들의 공통적인 특성(변수, 메서드)들을 묶어 표현하는 것
다형성
: 똑같은 클라이언트 코드로 안에 들어있는 존재에 따라 다른 동작이 수행되는 것, 그러나 부모 클래스 레퍼런스 변수에 자식 클래스 인스턴스를 넣을 수 있는 것 자체가 다형성은 아닙니다.
2) SRP (Single Responsibility Principle) - 단일 책임 원칙
- 그대로 해석하면, 하나의 클래스는 하나의 책임만을 가져야 한다.
- 책임은 = "변경하려는 이유" 즉, 하나의 클래스에는 변경하려는 이유가 하나만 존재해야 한다.
그렇다면, 이유는 뭘까 ?
💡 ex01) 2개의 컴포넌트 Service와 Repository 가 있다고 해보자.
- Service : Repository에서 객체를 조회해와서 메서드 호출등을 처리한다. (기능 실행 조율)
- Repository : 데이터베이스 같은 저장소에서 객체를 조회하거나 저장한다.
- Controller : 사용자의 HTTP 요청을 받아, 간단한 처리 후 Service를 호출한다.
이때, 각각의 역할에 변경의 이유가 숨어 있는데
- Service : 기능이 변경 된다면 ?
- Repository : 데이터베이스가 아닌 다른 저장소를 사용하게 된다면 ? (ex. 파일)
- Controlelr : HTTP로 요청을 받지 않게 된다면 ? (ex. 메시지 큐를 통한 요청)
만일 객체지향적으로 작성되었다면 각각의 컴포넌트만 변경에 이유에 의해 변경하면 될 것이다. 만약 세 가지의 컴포넌트를 모두 짬뽕시킨 하나의 컴포넌트가 있다고 해보자. 그렇다면 기능이 변경되거나 다른 저장소를 사용하거나 다른 방식의 요청을 받게 되면 ? 변경의 이유가 3가지나 되는 것이다.
💡 ex02) Service 단에서 예외 던지기
- 서비스 단에서 예외 발생시 HTTP 상태 코드를 담은 예외를 던진다고 해보자.
memberRepository.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No Data"));
이런식으로 서비스 단에서 예외가 발생했을 때, HTTP 상태 코드를 담은 예외를 던지게 되면 컨트롤러에서 알아야 할 정보를 서비스 쪽에서도 알고 있기 때문에 컨트롤러가 더 이상 HTTP 방식을 사용하지 않게 되면 컨트롤러 + 서비스 둘 다 수정해야할 가능성이 생깁니다.
--> 서비스는 변경의 이유가 하나 더 늘어나게 된 셈이죠.
결국 SRP를 지키면 각각의 클래스가 응집력이 높기 때문에 코드의 재사용성이 높아지며, 캡슐화를 통해 한 클래스의 변경이 다른 클래스에 영향을 미치지 않도록 만듭니다.
3) OCP (Open Closed Principle) - 개방 폐쇄 원칙
- 소프트웨어는 확장에는 열려(Open)있고, 변경에는 닫혀(Close)있어야 한다.
이것은 이미 우리가 알고 있는 개념이에요. 의존 역전과 관련하여 기존 구체 클래스를 의존하다가, 추상화 된 인터페이스를 의존하여 의존 관계를 역전시켰었는데 이는 인터페이스에 또 다른 구현체를 추가하더라도 확장이 가능하죠 ?
확장은 곧 소프트웨어에 새로운 기능을 추가하는 것이에요. 반대로 구현체를 하나 없애더라도 이는 영향을 받지 않게 됩니다. OCP는 인터페이스를 통해서만 달성 가능한 것은 아닙니다. Enum과 같이 값을 추가해도 클라이언트 코드에 영향이 없는 것들이 있죠.
위 그림에서, 기존에 구체 클래스를 FileRepository와 MysqlRepository만 가지고 있었다고 해볼게요. H2Repository를 새로 추가하여 확장이 가능할 뿐더러, H2Repository를 삭제하여 변경한다고 하더라도 클라이언트 코드에는 영향이 없습니다.
결국 OCP를 지키는 코드는 클라이언트 코드가 추상화에 의존하고 있기 때문에 확장될 때와 변경될 때 모두 다른 코드에 영향을 주지 않게 만듭니다.
4) LSP (Liskov Substitution Principle) - 리스코프 치환 원칙
- 파생 클래스는 기반 클래스를 대체할 수 있어야 한다. (기존 원칙의 정의)
- 부모 클래스가 할 수 있는 행동은 자식 클래스도 할 수 있어야 한다. (풀어서 이해하기 쉬운 방식으로)
ex)
public class Parent{
public Content getContent(){
Content content = new Content();
return content;
}
}
public class Child extends Parent {
@Override
public Content getContent() {
return null;
}
}
Parent 클래스는 Content 클래스를 리턴하는 getContent 메서드를 가집니다. getContent는 최소 디폴트 값을 가진 값을 리턴하도록 작성되어 있습니다.
그런데, Parent 클래스를 상속한 Child 클래스 내에서 getContent() 메서드를 오버라이드하여 구현했는데, 디폴트 값으로 null은 반환하도록 하였고 이후에 Parent 클래스를 Child로 치환한 Main 함수에서는 Null 관련 참조 오류가 발생하게 됩니다.
이것이 리스코프 치환 원칙의 중요 포인트에요. 자식 클래스로 부모 클래스의 내용을 상속하는데 기존 코드에서 보장하던 조건을 수정하거나 적용시키지 않아, 또는 엉뚱한 자식 클래스를 구현하여 기존 부모 클래스를 사용하는 코드에서 예상하지 않은 오류를 발생시킨 것이다.
📍 LSP가 깨지는 상황
- 사전 조건이 자식 클래스에서 더 강해지는 상황
부모 클래스에서는 양수, 음수 할 것 없이 어떠한 입력도 받을 수 있는 메서드가 구현되어 있다고 해보자. 반면 부모 클래스를 상속한 자식 클래스가 메서드를 오버라이딩 하여 음수만을 입력받을 수 있도록 구현했다고 하면 이는 LSP를 위반하는 것이다.
- 접근 제어자가 자식 클래스 쪽에서 더 제한적으로 변경되는 경우
부모 클래스에서 public 인 메서드를 자식 클래스에서 오버라이딩 할 때 private 같은 좀 더 제한적인 접근 제어자를 사용하는 경우 LSP 가 위배됩니다.
4) ISP (Interface Segregation Principle) - 인터페이스 분리 원칙
- 클라이언트별로 세분화 된 인터페이스를 만들어야 한다.
- 즉, 객체는 자신이 사용하는 메서드에만 의존해야 한다.
사실 위 정의로는 ISP를 이해하기엔 좀 어렵다고 생각합니다. 예시를 통해 알아볼게요 !
위 그림에서 class1, class2, class3 객체들이 TotalClass를 인터페이스로써 구현하고 있다고 해볼게요. 이때 class1은 오직 m1 메서드만을 사용하고, class2는 m2 메서드만을, class3는 m3 메서드만을 사용한다고 가정해보겠습니다.
이 상황에서 class1은 TotalClass 전체를 구현해야 하기 때문에 m1,m2,m3 메서드를 모두 사용할 수 있지만, 정작 사용하는 것은 m1 메서드만을 사용하는 상황인거죠. class1은 m2 메서드를 사용하지 않음에도 불구하고 만약 변경이 일어나면, 함께 변경되어 재컴파일 & 재배포 과정을 거쳐야하는 문제가 생깁니다.
구현한 TotalClass 객체의 규모가 필요보다 크기 때문에 발생한 문제입니다.
🙋🏻♂️ 그렇다면, 인터페이스 분리 원칙을 준수하여 설계하면 다음과 같이 설계할 수 있습니다.
이렇게 인터페이스가 분리되면 각각의 객체들은 오직 자신이 필요한 메서드만을 사용할 수 있는 구조가 되는 것이죠. 이 경우에는 m2 메서드의 변경이 일어나더라도 class1 객체에는 전혀 영향이 가지 않고 ISP를 준수한 바람직한 설계 구조라고 볼 수 있어요.
5) DIP (Dependency Inversion Principle) - 의존 역전 원칙
- 고수준 컴포넌트는 저수준 컴포넌트에 의존하지 않아야 한다.
정의만 보았을 때 DIP 역시 이해를 하기 어렵죠?... 예시를 통해 알아보도록 하겠습니다.
ex) DIP를 위반한 사례
서비스 단에서 데이터베이스나, 파일 리포지토리로부터 데이터를 찾지 못하여 발생하는 예외를 try-catch를 통해 잡게 된다면 고수준 컴포넌트 즉, 서비스가 저수준 컴포넌트인 리포지토리로부터 던져지는 예외에 의존하게 되는 상태가 되기 때문에 이는 의존 역전 원칙을 위배하는 사례입니다. (간접적으로 리포지토리에 구현된 구체 클래스에 대해 알게 되는 셈)
따라서 각각 구현체에 대해 구체적인 예외를 던지는 것이 아니라, 추상적인 예외를 던지도록 (범용적인 예외) 하는 것이 이를 해결하기 위한 방안입니다. (ex. EntityNotFoundException)